1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

殆ど何も設定しなくても使える CLI ライブラリ

Last updated at Posted at 2018-07-09

CLI アプリに必要な部分のみを極力少ない記述で表現するためのライブラリを作りました。

せっかく作ったので知ってもらいたいのと、それだけだとアレなので、
各所で使っている実装方法を紹介したいと思います。

CLI ライブラリ gli

shu-go/gli

goget
go get github.com/shu-go/gli

アプリのサンプルは example/todo にあります。

下記のように使います。

ライブラリの機能を呼び出している部分は殆どありません。
コードの規約的なものは若干ありますが、ライブラリ独自の構造体などは使いません。

package main

import "github.com/shu-go/gli"

// コマンドやオプションを struct で定義する。
type globalCmd struct {
    SubCommand subCommand1 `cli:"sub1"`
    Sub2       subCommand2

    Opt1 int
    Opt2 string
}

// サブコマンドもサポートしている。
type subCommand1 struct {
    SubOpt1 `cli:"opt1"`
}
// :

// サブコマンドが実行された場合の処理内容
// グローバルオプションやオプション外の引数も受け取れる。
// なお、引数の並びは自由。不要なら引数から削除しても良い。
func (sub1 *subCommand1) Run(args []string, global *globalCmd) error {
    fmt.Printf("global: %#v\n", global)
    fmt.Printf("sub1: %#v\n", sub1)
    fmt.Printf("args: %#v\n", args)

    return nil
}

func main() {
    app := gli.NewWith(&globalCmd{}) // 何がルートになるかを指定してあげる
    app.Run(os.Args)
}

main 関数内にのみ、本ライブラリ gli の関数呼び出しが現れています。

メタ情報を定義する

極力 CLI 定義のコードを書きたくないので、フィールドのタグとして以下のメタ情報を定義できるようにしています。

  • 別名(cli)
  • ヘルプメッセージ(help)
  • デフォルト値(default)
  • 環境変数名(デフォルト値の取得元)(env)
type struct someCmd {
    Name   string `cli:"name, n=YOUR_NAME"  default:"You"  help:"あなたのお名前"`
    // --name もしくは -n に続いて名前を指定。デフォルト値は "You"

    Age    int
    // --age に続いて年齢を指定。

    Planet Planet `env:"DEFAULT_PLANET"`
    // 指定がなければ、環境変数 DEFAULT_PLANET の値をセットする。
}

名前は複数指定できます。
末尾に =PLACEHOLDER とすることで、セットする値の意味合いを表現することもできます。(ヘルプメッセージに表示されます)

実装: struct を渡して、フィールドを列挙する

struct に対して (reflect.Type).NumField(reflect.Type).Field を使います。

structT := reflect.TypeOf(myStruct)

for i := 0; i < structT.NumField(); i++ {
    fieldT := structT.Field(i)
}

上の例では myStruct が想定どおり struct であるという前提になっていますが、
実際にはどのような struct も処理する関係上、myStruct の型は interface になると思います。

そのため、NumField を呼び出す前に myStruct の型をチェックしたほうが安全です。

(reflect.Type).Kind を使いチェックします。
型のチェックをする際には、ポインターかどうかの判断も入れたほうが良いでしょう。
ポインター型から参照先の型を取得するためには、(reflect.Type).Elem を使います。

structT := reflect.TypeOf(myStruct)

if structT.Kind() == reflect.Ptr {
    if structT.Elem().Kind() != reflect.Struct {
        panic("not a pointer to a struct")
    }
} else if structT.Kind() != reflect.Struct {
    panic("not a struct")
}

Elem は、ポインターの参照元を取得するだけではなく、配列・スライス・チャネルの要素型を取得する際にも使えます。

実装: フィールドがサブコマンドを意味しているのかオプションの定義を意味しているのか見分ける

単純にする場合、フィールドの型が struct もしくは *struct であればサブコマンド、そうでなければオプションとして判断することもできます。

組み込み型以外の形式での読み込みもサポートしたかったので、struct であってもオプションとして扱う場合があります。
具体的には gli.Parsable というインターフェイスを実装しているかどうかで見分けています。

type Parsable interface {
	Parse(str string) error
}

そのため、サブコマンドであることのチェックは2段構えとなっています。

  1. struct もしくは *struct である
  2. かつ、gli.Parsable を実装していない(実装していたらオプション扱い)

前者は (reflect.Type).Kind を使ってチェック可能です。
後者は (reflect.Type).Implements を使います。

var iscmd bool // true if fieldT is a subcommand(not an option)
// 1
if fieldT.Type.Kind() == reflect.Struct || (fieldT.Type.Kind() == reflect.Ptr && fieldT.Type.Elem().Kind() == reflect.Struct) {
    // 2
    if !doesStructImplement(fieldT.Type, reflect.TypeOf((*Parsable)(nil)).Elem()) {
        iscmd = true
    }
}

func doesStructImplement(st reflect.Type, iface reflect.Type) bool {
	if st.Kind() != reflect.Struct && !(st.Kind() == reflect.Ptr && st.Elem().Kind() == reflect.Struct) {
		return false
	}

	return reflect.PtrTo(st).Implements(iface) || st.Implements(iface)
}

実装: タグを取り出す

上記のように列挙したフィールドそれぞれについて、そのタグを取得します。

Go 言語では、フィールドに対して 1 つのタグが指定でき、そのタグの中にはスペース区切りで key:value を記述することができます。

Age int `key1:"value1"  key2:"value2"`

特に今回は特定の名前のキーの値のみを使いますが、その場合は (reflect.StructTag).Lookup を使います。

structT := reflect.TypeOf(myStruct)

for i := 0; i < structT.NumField(); i++ {
    fieldT := structT.Field(i)

    tag := fieldT.Tag
    if tv, ok := tag.Lookup("cli"); ok {
        // 
    }
}

ok であれば、あとは取得した値 tv の文字列を煮るなり焼くなり、好きにします。

ちなみに、間違えて key 同士の区切りをカンマにしてしまうとうまく取得できません。
注意しましょう。

// NG
Age int `key1:"value1", key2:"value2"`

注意点: タグから読み取った内容は文字列型になる

タグで定義する関係上、デフォルト値はどうしても文字列で記述することになります。

例えば、上の例で Age (int 型) にデフォルト値を設定する場合は次のようになります。

Age int `default:"100"`

標準では bool, int, intXX, uint, uintXX, floatXX に変換するようにしています。(strconv を使っています)
ですので、このようにしていれば 100 がデフォルト値となります。

ここで、渡された struct の変数(実体)に対して、そのフィールドに設定されたデフォルト値をセットしていくことになります。

実装: struct のフィールドへの代入

(reflect.Value).Set を使います。
ただし、こちらの記事([Go言語] reflectパッケージで変数の値を変える)にもあるように、
元となる struct へのポインター変数からたどっていく必要があるので注意してください。

ちなみに、gli では、gli.New にポインターを渡させるようにしています。
(なので、コード上はそれほどアドレスを取る操作はしていない)

ポインター型にすると、省略されたことを検知可能

オプションとなるフィールドの型を *int などとしておけば、そのオプションが実行時に指定されていない場合に nil がセットされます。

当然、オプションが指定されていれば、その値を参照するようなアドレスがセットされます。

実装: new に相当する処理

フィールドへの値のセットについては、これまでの実装の紹介で挙げていますが、
ここではポインター型が相手ですので、単純に値をセットすることはできません。

(1)ポインターの参照先の型の領域を作成し、(2)そこに値をセットした上で、(3)フィールドにそこのアドレスをセットしなければなりません。

ここでは主に (1) の実装を紹介しています。

(1)ポインターの参照先の型の領域を作成

fieldT := structT.Field(i)

ptrV := reflect.New(fieldT.Elem())
// fieldT はポインター (例: *int)
// fieldT.Elem() はその参照先の型 (int)
// それを reflect.New している (new int)

(2)そこに値をセット
これは、上記の ptrV に対して値を Set するということです。

ptrV.Set(hogehoge)

(3)フィールドにそこのアドレスをセット
これは、フィールドに対して ptrV を Set するということです。

fieldV := valueOfMyStruct.Field(i)

fieldV.Set(ptrV)

Run メソッドのシグネチャーが割と自由

だいぶん軽い見出しになっていますが、その様になっています。

  • サブコマンドにおいては、その親の(サブ)コマンド(複数OK)を引数に入れれば参照できる
    • 引数に入れてもいいし、入れなくても良い
    • ルートのコマンドのみとか、直前の親とか、必要なものだけでOK
    • (あまり意味ないけど) ポインター型でもOK
  • サブコマンドの後ろに入れた引数(オプションではなくて)を引数に入れれば参照できる
    • 引数に入れてもいいし、入れなくても良い
  • 引数の並び順は自由
  • Run メソッド中で発生したエラーを返すこともできる
    • 戻り値の型を error にすれば返せる
    • 戻り値の型は、error でも良いし、無しでも良い

実装: 定義されたシグネチャーを考慮して呼び出す

事前に次の情報が必要になります。

  • メソッドが定義された型のインスタンス
  • サブコマンドンを解析した結果
    • サブコマンドのスタック
    • 各サブコマンドに指定されたオプション
    • オペランド

サブコマンドのスタックは、コマンドラインを prog cmd1 cmd2 cmd3 などとした場合に [ cmd{cmd1} cmd{cmd2} cmd{cmd3} ] のように保持されている内容です。

メソッドの取得

メソッドが定義された型のインスタンスから、実行したいメソッドを取得し、そこから引数と戻り値の情報を抽出します。

メソッドの取得に際しては2経路の取得方法があります。

  • (reflect.Value).MethodByName
  • (reflect.Type).MethodByName

いずれもメソッドを取得しますが、Value の方は呼び出すためのもの。
Type の方は定義を取得するためのものです。

いずれにしても (Value の方法で取得した場合でも最終的には (reflect.Value).Type を呼び出すことになり) 、後者の取得方法になります。

(reflect.Value).MethodByName
// (reflect.Type).MethodByName とは異なり、メソッドが見つからない場合は zero value を返す

methV := structV.MethodByName(funcName)
if methV == (reflect.Value{}) {
    // not found
    return ...
}
(reflect.Value).MethodByName
methT, found := structT.MethodByName(funcName)
if !found {
    // not found
    return ...
}

メソッドの引数や戻り値

引数の個数は (reflect.Type).NumIn で、N 番目の引数は (reflect.Type).In で取得できます。

後で呼び出すことを見据え、実引数に相当する内容は []reflect.Value として定義しておきます。

var argv []reflect.Value

今回は以下のルールで実引数を構築していきます。

  • In(i) の型が struct なら、サブコマンドのスタック内にある型が同じものを実引数に詰める
  • struct へのポインターなら、スタックの要素のアドレスを詰める
  • []string なら、サブコマンド内に記録したオペランドを実引数に詰める
  • それ以外はエラー

こうして構築された実引数をもとに呼び出します。

retV := methV.Call(argv)

戻り値も []reflect.Value です。
この中で error 型になっているものを、ライブラリの利用側に戻します。

その他、実装や設計の注意点

struct がないとコマンドラインの解析が行えない

以下の曖昧さがあるため、実際にセットされる対象の struct がわからない状態では解析がままなりません。

  • サブコマンドとオペランド
    • hoge foo piyo bar ←どれがサブコマンドで、どれがオペランド?
    • サブコマンドを扱える CLI ライブラリでは、どこまでがサブコマンド名の羅列か判断する必要があります。
      そのためには、サブコマンドの定義が必要になります。
  • ハイフン1つの場合の、オプションの扱い
    • -abc ←これは abc というオプション? それとも abc
    • どちらも許容するようにした場合、どの様に解決するかはそのライブラリのクセになります。
    • ちなみに今回のライブラリでは、ロング名として一致するものを探した上で、なければ全てショート名として扱います。

オプションの名前についていうと、Go のツール群は go test -bench . のように、ひとかたまりの bench として認識しますが、ショートオプション名を連結したものとして扱うソフトもあります。(むしろ、こっちの方がメジャーなんじゃない?)
蛇足かつ邪推ですが、Go のツール群には「紛らわしくないユニークなオプション名を定義できれば、オプションの名前にロングもショートも区別は無くせて、従ってオプションかその他かを判別するためのプレフィックスが1つ付くだけでよい筈だ」という考えがあるのかもしれませんね。

いずれにしても、オプションやサブコマンドの定義と独立してコマンドラインの解析のみを行うことはできないと考えられます。
もしくは、名前解決のルールという機能を設けることで、ある程度独立したライブラリとして括りだすことはできるかもしれませんが。

動的なデフォルト値を実現するための仕組みを考慮する

デフォルト値をタグ内に記述するため、どうしても文字列型になることに言及しました。

他の CLI ライブラリでは、デフォルト値を式として定義することができるものがあります。
そのため、例えば複数の環境変数を連結したものをデフォルト値としたい場合であっても、env("HOGE")+env("PIYO")などと記述することはできません。

対策として、サブコマンド単位で初期値をセットする仕組みを備えられるようにしています。

func (sub1 *subCommand1) Init() error {
    sub1.Opt2 = os.Getenv("HOGE") + os.Getenv("PIYO")
}

// Run と同様、定義されていれば呼び出されるメソッドには、Init, Before, After, Help がある。
// Init: フィールドのメタ情報から初期化するのと同タイミングで実行される。オプションの実引数をセットする前に呼び出される。
// Before: オプションの実引数がセットされた状態で、親→子→孫の順番で各サブコマンドについて呼び出される。
// After: Before の後始末。Before とは逆順に呼び出される。
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?