spf13/cobra を使って CLI ツールのルート/サブコマンド単位の共通セットアップを記述する

記事タイトルとURLをコピーする

社内SE & ソフトウェアエンジニアの橋本 (@hassaku_63) です。

2024/09/02 時点における当社(サーバーワークス)が採用する主要なプログラミング言語は Python, TypeScript, Ruby なのですが、今日は Go の話をしようと思います。

社内メンバーに配布する CLI ツールの開発言語として、Go に注目しています。

クロスコンパイルに対応し、シングルバイナリを吐き出せるという特徴がクライアントPCに配布する利用形態に非常に良くフィットしていると考えました。周辺エコシステムが充実している点もポジティブに評価しています。例えば、GoReleaser や GitHub Actions 等を組み合わせれば、git タグの push などをトリガーとして各プラットフォーム向けのバイナリを吐き出すようなリリースも比較的容易に実現できます。

Go で CLI ツールを構築したいと考えたとき、spf13/cobra というモジュールが非常に有力そうだとわかったので、使ってみました。

cobra.dev

使ってみた感じ、サブコマンドを含んだ CLI ツールを作るのは非常に簡単そうでした。Python で CLI を構築する際に私が利用している argparse と比べても個人的に使い勝手は良いように思います(標準ライブラリと比較するのはアンフェアかもですが)。

ここで、ふと「ルートコマンド、もしくはサブコマンドごとの共通処理を都度書かずに済むようにしたいな」と思いました。本記事で cobra でそれを実現する方法についてご紹介してみます。

やりたいこと

前フリで記述したように、「ルートコマンド、もしくはサブコマンドごとの共通処理を都度書かずに済むようにしたい」が本記事でやりたいことです。

例えば次のようなユースケースが該当します。

  • グローバルオプションとして --debug を定義してログレベルを変更する
  • CLI のプレゼンテーション(出力)のフォーマッタの生成方法を制御する(例: AWS CLI の --output オプション)

こうしたユースケースを実現するためには、ルートコマンド or サブコマンド全体に適用されるオプションを定義する必要があり、かつそのオプションに関するセットアップの処理をロジック本体より前に実行しておきたいです。このとき、「セットアップ」に相当する処理を(仮にサブコマンドが多数存在する場合でも)何度も書かずに済むようにしたいです。

前提

この記事は 2024/09/02 時点の記述になります。

Go のバージョンは 1.22 を利用しており、cobra は v1.8.1 を利用しています。

結論

やるべきことは次の2つです。

  • ルートコマンド or サブコマンド全体に適用されるオプションを定義する
  • 定義したオプションに関するセットアップの処理を記述する

まず、前者は PersistentFlags を使うことで実現が可能です。cobra-cli で自動生成されるcmd/root.go の init 関数にも記載がありますので、cobra の利用者であればこれはすでにご存じでしょう。

後者は、ルートコマンドあるいはサブコマンドの Command 型に PersistentPreRun もしくは PersistentPreRunE を実装することで実現可能です。これらのメソッドを定義したコマンドのスコープ配下すべてに PersistentPreRun / PersistentPreRunE の処理内容が適用されます。配下のサブコマンドで同様のロジックを複製する必要はありません。

公式ドキュメントにも、PreRun and PostRun Hooks のセクションで言及されています。

https://cobra.dev/#prerun-and-postrun-hooks

前述したデバッグフラグの例で言えば、次のような実装が一例です。ここではロガーに zerolog を利用し、--debug フラグが指定された場合はロガーのレベルを DEBUG に設定しています。

// cmd/root.go
package cmd
  
import (
    "fmt"
    "os"

    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
    "github.com/spf13/cobra"
)
  
var debugMode bool
  
var rootCmd = &cobra.Command{
    Use:   "hello-cobra",
    Short: "Example cobra cli",
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
        zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
        log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
        if debugMode {
            zerolog.SetGlobalLevel(zerolog.DebugLevel)
        } else {
            zerolog.SetGlobalLevel(zerolog.WarnLevel)
        }
        fmt.Println("rootCmd.PersistentPreRun called")
    },
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("rootCmd.Run called")
    },
}
  
func Execute() {
    cobra.EnableTraverseRunHooks = true

    err := rootCmd.Execute()
    if err != nil {
        os.Exit(1)
    }
}

func init() {
    rootCmd.PersistentFlags().BoolVar(&debugMode, "debug", false, "enable debug log (default: false)")
}

参考実装は hassaku63/hello-cobra で公開しています。

github.com

次のようなサブコマンドの階層を持つ実装になっています。

  • root
    • subcmd1
    • subcmd2
      • nested

まとめ

個人的にはこれまで見てきた CLI 構築のライブラリの中でもかなり扱いやすいと感じました。多数の著名なプロジェクトで採用されているだけのことはあるなぁ、、、と思いました。興味があれば cobra の README を覗いてみてください。Kubernetes や GitHub CLI, CockroachDB などで採用実績があるようです。以下は README から採用実績について言及した部分の抜粋です。

Cobra is used in many Go projects such as Kubernetes, Hugo, and GitHub CLI to name a few. This list contains a more extensive list of projects using Cobra.

cobra/README.md at main · spf13/cobra · GitHub

PersistentPreRun の存在は cobra-cli の自動生成コードからは直接見えない機能なので気づきづらいかもしれませんが、Persistent Flag と組み合わせることでロジックの共通化が捗りますね。めちゃくちゃ便利なモジュールだと思いました。

なお、私はこの記事を書き始めるまで pkg.go.dev と実際のソースコードしかチェックしておらず、cobra.dev のドキュメントの存在に気づきませんでした...

(2024/09/15 追記)

実装例が不完全だったので、訂正しました。 デフォルトの動作では、PreRun/PostRun は実行されるそのコマンド自身のものしか実行されません。 親コマンドで定義している PreRun/PostRun も共通処理として実行したい場合は、次のように EnableTraverseRunHooks を True にセットする必要があります。

cobra.EnableTraverseRunHooks = true

ref: cobra package - github.com/spf13/cobra - Go Packages

本文の実装例、および GitHub のコードを修正しました。上記の処理を root コマンドの Execute 関数内でセットしています。