cyamli: YAMLからCLIを自動生成するツール

概要

本記事は,OSS Advent Calendar 2023の12月14日の記事です.

qiita.com

本記事では,自作OSSとして,YAMLからCLIを自動生成するツール「cyamli」を試作しましたので,これについて紹介します.

背景

最近,Goでプログラムを書いています.その中で,作業の自動化を目的としてコンソールアプリケーションを作成することがよくあります. コンソールアプリケーションは,CLI(Command Line Interface)を備えたプログラムで,サブコマンド,オプション引数,位置引数を含むコマンドライン引数を取り扱うことが必要となります.

コマンドライン引数を取り扱うためには,コマンドライン引数を定義・解釈する仕組みとしてが必要となりますが,Goの既存のライブラリの中で以下の条件を満たすAPI(本記事では,APIApplication Programming Interface)といえばWeb APIではなく,ライブラリ等のプログラミング言語のためのインターフェースのことを指します.)を見つけることができませんでした.

  1. コマンドライン引数として,サブコマンド,オプション引数,位置引数を定義・解釈することができる.
  2. 型付けされたAPIとして提供される.
  3. SSOT(Single Source of Truth)となる.

例えば,Goの標準ライブラリにあるos.Argsは,最も原始的なAPIで,コマンドライン引数をstringを要素とするスライスとして表現しますが,1も2も満たしません. pkg.go.dev

Goの標準ライブラリにあるflagパッケージは,型付けされたオプション引数を扱うことができますが,サブコマンドや位置引数を定義する機能は持ちません.また,オプション引数の定義は,var helpFlag = flag.Bool("help", true, "show help")のように行うのですが,変数名helpFlagとオプション引数名"help"のように情報が重複してしまうことがあり,3を完全に満たしているとは言えません. pkg.go.dev

外部ライブラリであるcobraは,サブコマンド,オプション引数,位置引数を定義・解釈することができますが,位置引数の型を指定することができないため,2を完全に満たしているとは言えません.また,flagパッケージと同様にソースコード内で情報が重複してしまうことがあり,3を完全に満たしているとは言えません. github.com

別の外部ライブラリであるdocoptは,独自のフォーマットに沿って記述されるヘルプテキストによって,サブコマンド,オプション引数,位置引数を含む複雑なコマンドライン引数を定義することができ,これを元にコマンドライン引数を解釈することができますが,型を指定することができません. github.com

YAMLからCLIを自動生成するツール「cyamli」

上で示した条件1は,自分がコマンドライン引数を扱うコンソールアプリケーションを開発する上で,必要だと感じた最低限の機能です. 条件2は,エディタや統合開発環境の支援を享受したり,コンソールアプリケーションのビルド時に網羅的な型チェックを実施するために必要ものです. 条件3は,プログラムを管理する上で,一貫性を保ちやすくするために必要なものです.

コマンドライン引数を定義・解釈する仕組みとして,これらの条件をみたすものを実現するために「cyamli」というツールを開発しました. GitHub上で公開しています. github.com

「cyamli」の仕組みと特徴

「cyamli」は,以下の図に示すような仕組みで動作します.

cyamliは,CLIの定義を記述したYAMLファイルを読み込んで,CLIを解釈するAPIを書き出す.内部では,型の構築とコード生成が行われる.
「cyamli」の仕組み

「cyamli」は以下の特徴を持ちます.

  • サブコマンド,オプション引数,位置引数をサポートします.
  • CLI定義の情報源が1つのYAMLに集約されるため,SSOTが実現できます.
  • コンソールアプリケーションをビルドする前にCLIの型が決定されるため,型付けされたAPIを提供することができます.

以下は,前節で挙げたライブラリと「cyamli」を比較する表です.

「cyamli」は「機能」「型付け」「SSOT」を全て満たす.
前節のライブラリと「cyamli」の比較

「cyamli」の使い方と使用例

「cyamli」の使い方は,次の通りです.

  1. CLIYAMLで定義する.
  2. CLIを解釈するAPIを自動生成する.
  3. 自動生成されたAPIに関数を割り当てる.

使用例を以下に示します.

1. CLIYAMLで定義

ここでは例として,データベースからテーブルの情報を取得するコンソールアプリケーションの作成を想定して,以下のようにCLIを定義します.

# cli.yaml
name: demo
description: demo app to get table information from databases
subcommands:
  list:
    description: list tables
    options: 
      -config:
        description: path to config file
        short: -c
  describe:
    description: show information of tables
    options: 
      -config:
        description: path to config file
        short: -c
      -format:
        short: -f
        description: "output format: json or sql"
        default: json
      -unique:
        description: includes unique constraint information
        type: boolean
      -foreign-key:
        description: includes foreign key constraint information
        type: boolean
      -check:
        description: includes check constraint information
        type: boolean
    arguments:
      - name: tables
        variadic: true
        description: names of tables to be described

このYAMLファイルでは,コンソールアプリケーションについて以下の内容が定義されています.

  • 名前がdemoである.
  • サブコマンドとしてlistdescribeを持つ.

また,listサブコマンドについては以下の内容が定義されています.

  • 以下のオプション引数を持つ.
    • -config:文字列型の値を取り,コンフィグファイルのパスを指定する.短縮バージョンとして-cがある.

describeサブコマンドについては以下の内容が定義されています.

  • 以下のオプション引数を持つ.
    • -config:コンフィグファイルのパスを指定する.短縮バージョンとして-cがある.
    • -format:出力のフォーマットを指定する.短縮バージョンとして-fがある.デフォルト値は"json"である.
    • -uniqueブーリアン型の値を取り,ユニーク制約を含めるかどうかを指定する.
    • -checkブーリアン型の値を取り,チェック制約を含めるかどうかを指定する.
    • -foreign-keyブーリアン型の値を取り,外部キー制約を含めるかどうかを指定する.
  • 以下の位置引数を持つ.
    • tables:取得対象のテーブルを0個以上指定する.

2. CLIを解釈するAPIを自動生成

「cyamli」を実行してAPIを生成します. 「cyamli」の実行は以下のように行うことができます.

# cyamli のインストール
go install "github.com/Jumpaku/cyamli/cmd/cyamli@latest"

# cyamliの実行
# cli.yamlを入力し,cli.gen.goに出力する.
cyamli golang -schema-path=cli.yaml -out-path=cli.gen.go

単に以下のように実行することもできます.

go run "github.com/Jumpaku/cyamli/cmd/cyamli@latest" golang -schema-path=cli.yaml -out-path=cli.gen.go

これにより,以下のようなAPIが生成されます(一部省略).

// Code generated by cyamli v0.0.11, DO NOT EDIT.
package main

// ...

type Func[Input any] func(subcommand []string, input Input, inputErr error) (err error)

type CLI struct {
    List     CLI_List
    Describe CLI_Describe
    FUNC     Func[CLI_Input]
}
type CLI_Input struct {
}

type CLI_List struct {
    FUNC Func[CLI_List_Input]
}
type CLI_List_Input struct {
    Opt_Config string
}

type CLI_Describe struct {
    FUNC Func[CLI_Describe_Input]
}
type CLI_Describe_Input struct {
    Opt_Check      bool
    Opt_Config     string
    Opt_ForeignKey bool
    Opt_Format     string
    Opt_Unique     bool
    Arg_Tables     []string
}

// ...

func NewCLI() CLI {
    return CLI{}
}


func Run(cli CLI, args []string) error {
    // ...
}

このように,cyamliが生成するコードには以下のAPIが含まれます.

  • CLI:ルートコマンドを表現する構造体
  • CLI_Input:ルートコマンドに渡すコマンドライン引数を表現する構造体
  • CLI_<サブコマンド>:サブコマンドを表現する構造体
  • CLI_<サブコマンド>_Input:サブコマンドに渡すコマンドライン引数を表現する構造体
  • Run:エントリーポイントとなる関数

3. 自動生成されたAPIに関数を割り当て

CLI構造体とCLI_<サブコマンド>構造体は,FUNCフィールドを持ちます.FUNCフィールドは,コマンドライン引数を表現する構造体を受け取ることが可能な関数の型を持っており,コマンドが実行するべき処理を設定することができます.

以下は,コマンドが実行するべき処理を設定したmain.goの例です.

// main.go
package main

import (
    _ "embed"
    "fmt"
    "os"
)

func main() {
    // CLIオブジェクトを生成
    cli := NewCLI()

    // ルートコマンドの処理を設定
    cli.FUNC = func(subcommand []string, input CLI_Input, inputErr error) (err error) {
        fmt.Printf("%#v\n", input)
        fmt.Println(cli.DESC_Detail())
        return inputErr
    }
    // listサブコマンドの処理を設定
    cli.List.FUNC = func(subcommand []string, input CLI_List_Input, inputErr error) (err error) {
        fmt.Printf("%#v\n", input)
        fmt.Println(cli.List.DESC_Detail())
        return inputErr
    }
    // describeサブコマンドの処理を設定
    cli.Describe.FUNC = func(subcommand []string, input CLI_Describe_Input, inputErr error) (err error) {
        fmt.Printf("%#v\n", input)
        fmt.Println(cli.Describe.DESC_Detail())
        return inputErr
    }

    // コマンドライン引数の解釈と対応する処理の呼び出しを実行
    if err := Run(cli, os.Args); err != nil {
        panic(err)
    }
}

以下は,ルートコマンドの実行例です.

go build -o demo . && ./demo

main.CLI_Input{}
demo:
demo app to get table information from databases

Usage:
    $ demo


Subcommands:
    describe:
        show information of tables

    list:
        list tables

自動生成されたヘルプテキストが利用できることがわかります.

以下は,listサブコマンドの実行例です.

go build -o demo . && ./demo list -c=./demo.config

main.CLI_List_Input{Opt_Config:"./demo.config"}
list tables

Usage:
    $ <program> list [<option>]...


Options:
    -config=<string>, -c=<string>  (default=""):
        path to config file

短縮バージョンのオプション引数-cで指定した文字列"./demo.config"を,オプション引数-configの値として渡すことができたことがわかります.

以下は,describeサブコマンドの実行例です.

go build -o demo . && ./demo describe -check Table1 Table2 Table3

main.CLI_Describe_Input{Opt_Check:true, Opt_Config:"", Opt_ForeignKey:false, Opt_Format:"json", Opt_Unique:false, Arg_Tables:[]string{"Table1", "Table2", "Table3"}}
show information of tables

Usage:
    $ <program> describe [<option>|<argument>]... [-- [<argument>]...]


Options:
    -check[=<boolean>]  (default=false):
        includes check constraint information

    -config=<string>, -c=<string>  (default=""):
        path to config file

    -foreign-key[=<boolean>]  (default=false):
        includes foreign key constraint information

    -format=<string>, -f=<string>  (default="json"):
        output format: json or sql

    -unique[=<boolean>]  (default=false):
        includes unique constraint information


Arguments:
    [0:] [<tables:string>]...
        names of tables to be described

オプション引数-formatにデフォルト値である"json"が渡されていることがわかります. また,オプション引数-checkにより,ブーリアン型の値trueが渡されていることがわかります. さらに,位置引数を可変長とすることも可能であることがわかります.

まとめと展望

以下に本記事のまとめを示します.

  • コンソールアプリケーションを作成する際には,CLI(サブコマンド,オプション引数,位置引数を含むコマンドライン引数)を定義・解釈する仕組みが必要となります.
  • YAMLからCLIを自動生成するというアプローチにより,この仕組みを実現する「cyamli」を開発し,公開しました.
  • 「cyamli」を紹介し,型付けされたAPIを提供できること,SSOTを実現できることを示しました.

以下に展望を示します.

  • 「cyamli」は,その内部のコード生成処理を置き換えることで,様々なプログラミング言語をサポートすることが可能なため,今後はサポートするプログラミング言をGo以外にも広げていきたいと考えています.
  • 本記事を読んでくださった方には,ぜひ「cyamli」を使ってみてもらいたいと思っています.