cobra/viperでコマンドラインツールを作っていて良かった所、詰まった所

まえおき

最近、Androidアプリを開発するのが全然おもしろくなくなってきたので、Dartを触り始めた。(※ Flutterではない)

で、Dartコマンドラインツール作ろうと思ったけど、コマンドラインツールといえば、とりあえずGoの cobra/viper 知っとかないと・・・

ということでGoを5年ぶりくらいに触り始めた。

何を作った?

github.com

appetize.io っていうWebベースのエミュレータAPIを便利に使うコマンドラインツールを作ってみた。 brew tapでインストールできるようにリポジトリを適当に作った ので、たぶん

brew tap YusukeIwaki/appetize
brew install appetize

で使えると思われる。

※ 完全に余談だけど、今回はとくに「自分自身が "使いたい!" と思う形でリリースする」ということにこだわった。

「必須だけどユーザによって使う値はだいたい決まっている、けどコマンドラインパラメータでオーバーライドもできる」が瞬殺だった

会社の同僚がQiitaに書いていて、そのまんまなんだけども。

APIトークンとかって、毎回毎回 --token=tok_79ad08a09dca80dc9a みたいなのを打つのはあまりに面倒。

  1. ~/.appetize.ymlapi_token: tok_9ads8ad9a7dsa みたいな設定値があればそれを使う
  2. 環境変数APPETIZE_API_TOKEN=tok_7987adaca9d7ca8c7 みたいな変数があれば、それを使う
  3. コマンドラインパラメータで --token=toka9sd7sa9d87a みたいなパラメータが指定されていれば、それを使う

で、同時指定の場合は、3 > 2 >1 の優先順位で動いてくれるのが望ましい。

これ、viperとcobraの典型的なユースケースで、わりと瞬殺で実装できてしまう。

// initConfig reads in config file and ENV variables if set.
func initConfig() {
    // Find home directory.
    home, err := homedir.Dir()
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    // Search config in home directory with name ".appetize" (without extension).
    viper.AddConfigPath(home)
    viper.SetConfigName(".appetize")
    viper.BindEnv("api_token", "APPETIZE_API_TOKEN")

    // If a config file is found, read it in.
    if err := viper.ReadInConfig(); err == nil {
        //fmt.Println("Using config file:", viper.ConfigFileUsed())
    }
}

func init() {
    cobra.OnInitialize(initConfig)

    rootCmd.PersistentFlags().String("token", "", "Appetize api token")
    viper.BindPFlag("api_token", rootCmd.PersistentFlags().Lookup("token"))
}

これだけだ。なんて便利。

「上書き保存用のフォームオブジェクト」ってどうやってつくるの?

App
  |- PublicKey
  |- Timeout
  |- Disabled

みたいなのがサーバー側でモデルとしてあって、JSON APIで書き換えるときに詰まった。

1つの属性だけ書き換えたい時にどうするか?

たとえば、 ./appdemo update --timeout 100 って打った時に、勝手にdisabled=falseを上書きするような実装だと困る。

コマンドラインパラメータの処理

cmd.Flags().GetBool("Disabled") だと、

  • ユーザが指定せずデフォルトパラメータとして falseが返っている
  • ユーザが意図して --disabled false を指定している

がわからない。

調べてみると、cobraリポジトリにissueが既に上がっていて、

Question: See if a flag was set by the user · Issue #23 · spf13/cobra · GitHub

Changed を使えばわかるよ!って書いてあった。

if cmd.Flags().Changed("Disabled") {
  attributes.Disabled = cmd.Flags().GetBool("Disabled")
}

ってすればいいらしい。

パラメータの構造体の定義

if cmd.Flags().Changed("Disabled") { のなかでいきなりJSONを組み立てるのとかはさすがに筋が良くないので、多くの場合は一旦それ専用の構造体をつくることになる。

ここでも、「ユーザが意図してfalseを設定したのか、デフォルト値としてのfalseなのか・・・」というのを迷うことになった。

type AttributesForUpdate struct {
  PublicKey  string
  Timeout     int
  Disabled    bool
}

愚直に↑みたいなフォームを作ると、Disabled = false のときの処理に困ることになる。(ちなみに、これは正直Goに限った問題ではなくて、だいたいどの言語でも同じようなことは言える)

var attributes AttributesForUpdate
attributes.Timeout = 100

fmt.Println(attributes.Disabled) // => false

これは、調べてみるといくつかのパターンはあって、みんなそれなりに苦しんでいるっぽい?

nilもいけるように、ポインタ型にしちゃう?

proposal: spec: option types · Issue #7054 · golang/go · GitHub

type AttributesForUpdate struct {
  PublicKey  *string
  Timeout     *int
  Disabled    *bool
}

*int がなかなかに鬼畜、ってissueでコメントしてる人がいる。

Optional型を作っちゃう

GoogleのCloud PlatformのSDKがこの方式を使っている

nilでToBoolするとpanicする、ってなかなか激しいぞ・・・

type AttributesForUpdate struct {
  PublicKey  optional.String
  Timeout     optional.Int
  Disabled    optional.Bool
}

JSONのMarshalとかをやらないなら、アリかもしれない。

ぜんぶstring型にしちゃおう

さすがにこれをやってる例は見なかった。

type AttributesForUpdate struct {
  PublicKey  string
  Timeout     string
  Disabled    string
}
if attributes.Disabled == "true" {
  params.Add("disabled", true)
} else if attributes.Disabled == "false" {
  params.Add("disabled", false")
}

ヤバイw

いっそのことstructじゃなくて、 map[string]interface{} にする

var attributes map[string]interface{}

if cmd.Flags().Changed("Disabled") {
  attributes["Disbaled"] = cmd.Flags().GetBool("Disabled")
}

うん、タイポしててもわからないね・・・。

これはさすがに却下。


ということで、とりあえずGoogleがやってるように、Optional型つくる、かなぁー・・・

まとめ

cobra/viperは素晴らしい