🦁

goでencoding/json感覚でコマンドライン引数を扱えるライブラリを作ってました

2022/08/04に公開約5,700字

以前から作っていたライブラリなのですが、昨日go1.19がリリースされて、ついでにflagがencoding/TextUnmarshalerに対応していたのを見たのでそれに追随したのも兼ねて紹介記事を書いてみることにしました。

https://github.com/podhmo/flagstruct

どうしてライブラリを新たに作ったのか?

有名所ではspf13/cobraなどgoには既にコマンドラインパーサー的なライブラリがたくさん存在していますが、新しくライブラリを作りたかった理由は以下のようなものです。

  • encoding/json感覚でタグを指定して使いたい
  • ネストした構造にも対応したい
  • 環境変数でも設定したいが、指定可能な設定値をヘルプメッセージに載せたい

この3つです。

encoding/json感覚でタグを指定して使いたい

例えば、goでは、以下のように構造体の各フィールドにタグを付加してあげると、それがJSONに変換したときのフィールド名として扱われます。

package main

import (
	"encoding/json"
	"os"
)

type Config struct {
	URL   string `json:"url"`
	Debug bool   `json:"debug"`
}

func main() {
	config := Config{URL: "http://example.net", Debug: false}
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.Encode(config)
}

urlとdebugというfieldを持つJSONが出力されますね。

{
  "url": "http://example.net",
  "bool": false
}

このJSONを利用するのと同様に、structにflagタグを与えてあげると、コマンドラインオプションとして扱われます。以下のコードでは --url--debugの2つのオプションが生えます。また、Build()に渡した値がデフォルト値として使われます。

package main

import (
	"encoding/json"
	"os"

	"github.com/podhmo/flagstruct"
)

type Config struct {
	URL   string `json:"url" flag:"url"`
	Debug bool   `json:"debug" flag:"debug"`
}

func main() {
	config := Config{URL: "http://example.net", Debug: false}

	flagstruct.Parse(&config) // default引数

	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.Encode(config)
}

ヘルプメッセージを確認してみます。表示されている通り環境変数でも設定可能です。

$ go build -o /tmp/hello
$ /tmp/hello --help
Usage of /tmp/hello:
      --debug        ENV: DEBUG - (default false)
      --url string   ENV: URL   - (default "http://example.net")

実行してみた結果は以下のようなものです。

$ /tmp/hello
{
  "url": "http://example.net",
  "debug": false
}

$ /tmp/hello --url 127.0.0.1 --debug
{
  "url": "127.0.0.1",
  "debug": true
}

$ URL=https://example.net DEBUG=1 /tmp/hello
{
  "url": "https://example.net",
  "debug": true
}

ネストした構造にも対応したい

ちなみに、ネストした構造に対応する必要が無いのであれば、似たような機能を持つライブラリとしてheetch/confitaがあります [1] 。

ネストした構造に対応することで何が嬉しいのかというと、あらかじめ設定用の構造体を作っておき(DBConfig)、利用するコード側のmain.goでそれらを使った構造体を定義してあげれば(Config)コマンドとして完成するみたいなことができるようになります。

例えば、以下のコードでは --db.uri--another-db.uri という2つのDB設定に対するオプションが利用可能になります。

package main

import (
	"encoding/json"
	"fmt"
	"os"

	"github.com/podhmo/flagstruct"
)

type DBConfig struct {
	URI   string `flag:"uri"`
	Debug bool   `flag:"debug"`
}

type Config struct {
	DB        DBConfig `flag:"db"`         // add --db.uri, --db.debug
	AnotherDB DBConfig `flag:"another-db"` // add --another-db.uri, --another-db.debug
}

func main() {
	config := &Config{}
	flagstruct.Parse(config)
	
	fmt.Println("parsed")
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.Encode(config)
}

ヘルプメッセージは以下のようなものです。

$ go build -o /tmp/nested ../03nested
$ /tmp/nested --help
Usage of /tmp/nested:
      --another-db.debug        ENV: ANOTHER_DB_DEBUG   -
      --another-db.uri string   ENV: ANOTHER_DB_URI     -
      --db.debug                ENV: DB_DEBUG   -
      --db.uri string           ENV: DB_URI     -

1つのコマンドを作るだけではあまり恩恵は得られませんが、例えばモノリポで作られたマイクロサービスのような環境の中で、似たような設定値を持つコマンドをたくさんつくりたい場合には結構便利に使えるんじゃないかと思っています。

指定可能な環境変数をヘルプメッセージに載せたい

実は、標準パッケージのflagだけでもVisitAll()を使って以下のようにコードを追加してあげれば、環境変数での設定に対応する事ができます[2]。しかし、頭があまり良くない自分としては指定可能な環境変数が何かをヘルプメッセージに表示されていると嬉しいです。

flag.VisitAll(func(f *flag.Flag) {
	if s := os.Getenv(strings.ToUpper(f.Name)); s != "" {
		f.Value.Set(s)
	 }
 }

そんなわけで、ライブラリを作るモチベーションがまた少し増えました[3]。

なぜ、今この記事を書いているか?

冒頭でも話した通りgo 1.19がリリースされ、flagパッケージの機能が拡充されたからでした。

The new function TextVar defines a flag with a value implementing encoding.TextUnmarshaler, allowing command-line flag variables to have types such as big.Int, netip.Addr, and time.Time.

実際、TextVar()などが追加され、これを利用することでencoding/TextUnmarshalerなどに対応した型の値のオプションを手軽に定義できるようになります。

🎉 昨日これに対応したので、net.IP なども動くようになりました[4]。

package main

import (
	"encoding/json"
	"net"
	"os"
	"time"

	"github.com/podhmo/flagstruct"
)

type Config struct {
	Now time.Time `json:"now" flag:"now"`
	IP  net.IP    `json:"ip" flag:"ip"`

	Ignore string `json:"-" flag:"-"`

	URL   string `json:"url" flag:"url"`
	Debug bool   `json:"debug" flag:"debug"`
}

func main() {
	config := Config{URL: "http://example.net", Debug: true, IP: net.IPv4(127, 0, 0, 1)}

	flagstruct.ParseArgs(&config, os.Args[1:]) // default引数

	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	enc.Encode(config)
}

やりましたね。

$ /tmp/hello  --help
Usage of /tmp/hello:
      --debug           ENV: DEBUG      - (default true)
      --ip net.IP       ENV: IP - (default 127.0.0.1)
      --now time.Time   ENV: NOW        - (default 0001-01-01T00:00:00Z)
      --url string      ENV: URL        - (default "http://example.n

$ /tmp/hello  --now 2022-02-22T22:22:22Z 
{
  "now": "2022-02-22T22:22:22Z",
  "ip": "127.0.0.1",
  "url": "http://example.net",
  "debug": true
}

なんでこの記事を書いたのか?

✨ スターが欲しいからです。

https://github.com/podhmo/flagstruct

追記

v0.4.0 Parse()Build() + WithContinueOnError() を使うのがデフォルトになったのでコード例を変更 (2022/08/05)

[1]: https://github.com/heetch/confita/issues/87 でissueにはなっている。なぜコチラにPRを出さなかったのかといえば、confitaはetcdやvaultなどいろいろな設定からの取得に対応しているためその辺のことを考えるのがめんどくさかったからです。
[2]: Big Sky :: Re: Goでコマンドライン引数と環境変数の両方からflagを設定したい
[3]: 環境変数での設定には https://github.com/caarlos0/env を使う人も多いようです。
[4]: pflagとの兼ね合いから自前で実装を持つことになりましたが。pflagへの依存は必要なんだろうか?という気持ちにもなってはいます。見た目上の --<foo> というロングオプションの表示とショートオプションの機能が利用したい。

Discussion

ログインするとコメントできます