この記事は every Tech Blog Advent Calendar 2024(夏) 3 日目の記事です。
はじめに
こんにちは、トモニテでバックエンド周りの開発を行っている rymiyamoto です。 最近は学園アイドルのプロデューサー業に追われています。
今回は、Go 言語で CLI ツールを開発する際によく使われるライブラリである cobra と go1.21 から標準パッケージで使えるようになった slog を使って、CLI ツールを開発する方法について紹介します。
選定理由
現状の課題
Go 言語だとスクリプト処理を実装する際、簡単なものであれば main.go にそのまま処理を書いていくことが多いですが、コマンドライン引数を取るような処理を書く場合、コードが複雑になりがちです。 実際トモニテ内のスクリプト処理も当時の実装メンバーに依存しており以下のような課題がありました。
- コマンドのフォーマット
- サブコマンド指定だったり引数だったり設計者依存
- hoge --param=1 or hoge -param 1
- それぞれで無駄な共通引数定義
- dry-run
- ログの出力
これらの課題から、コマンドライン引数を取る処理を簡単に実装できるパッケージとして cobra を、ログは go1.21 から標準パッケージで使えるようになった構造化ログが扱える slog を採用しました。
cobra について
cobra は Go 言語で CLI ツールを開発する際に歴史があり、Kubernetes、Hugo、GitHub CLI などの多くの Go プロジェクトで使用されています。 コマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また、CLI ツールで cobra-cli が提供されており、コマンドからスクリプトファイルの作成ができます。
slog について
go1.21 から導入された構造化ログを扱うことができる go の標準パッケージです。 構造化ログは JSON や key=value 形式でログを出力することができ、ログの解析や可視化が容易になります。 また、標準パッケージであるため、外部パッケージを追加することなく go の標準ライブラリでログを出力することができます。
イメージ
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger.Info("hello", "count", 3)
{"time":"2024-06-03T15:28:26.000000000-05:00","level":"INFO","msg":"hello","count":3}
環境作成
以下のようなディレクトリ構成で CLI ツールを作成していきます。
$ tree . ├── Dockerfile ├── Makefile ├── cobra.yml └── compose.yml
事前準備
Dockerfile
cobra-cli を使いたいので、Go のイメージに cobra-cli をインストールします。
ARG GO_VERSION=1.22.3 FROM golang:${GO_VERSION} AS dev RUN go install github.com/spf13/cobra-cli@v1.3.0
compose.yml
name: go-cli-management services: scripts: container_name: scripts build: context: . dockerfile: ./Dockerfile target: dev working_dir: /scripts volumes: - .:/scripts tty: true
Makefile
cobra-cli を使ったコマンドやコマンドの実行をやりやすくするために作成しています。
container = scripts .PHONY: dev dev: docker compose up -d .PHONY: init init: dev docker compose exec $(container) go mod init $(name) docker compose exec $(container) cobra-cli init .PHONY: add add: dev @$(eval script_file := ${name}.go) @$(if $(name),, $(error name is not defined)) @$(eval script_file_exists := $(shell ls . | grep ${script_file})) @$(if $(script_file_exists), $(error $(name) is already exists)) docker compose exec $(container) cobra-cli add $(name) --config ./cobra.yml .PHONY: run run: dev docker compose exec $(container) go run ./main.go $(line)
cobra.yml
cobra-cli でコマンドを追加する際の設定ファイルです。 このファイルを編集することでコマンドの中身を拡張できます。
name: author_name useViper: true
初期設定
以下のコマンドから gomod の初期化と cobra-cli の初期化を行います。
$ make init name=go-cli
実行後は以下のようなディレクトリ構成になります。
$ tree . ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd │ └── root.go # cobra で生成されたファイルで、このファイルをベースにしてコマンドを追加していきます ├── cobra.yml ├── compose.yml ├── go.mod ├── go.sum └── main.go # CLI ツールのエントリーポイント
拡張
現状 cmd/root.go に処理をベタ書きしていけばそのままコマンドとして実行できますが、それだと拡張性が失われてしまうのでサブコマンドやデフォルトフラグを追加して取り回しを良くしていきます。
デフォルトフラグの追加
cmd/root.go に初期値を追加します。
今回は並列処理の管理と dry-run モードを追加します。
const ( // concurrencyDefault デフォルトの並列数 concurrencyDefault = 10 // waitTimeDefault デフォルトの処理チャンク単位の待機時間 waitTimeDefault = 1 ) // ... func init() { rootCmd.PersistentFlags().Bool("dry-run", false, "Dry run mode") rootCmd.PersistentFlags().Uint("concurrency", concurrencyDefault, "並列更新数(1以上)") rootCmd.PersistentFlags().Uint("wait-time", waitTimeDefault, "処理チャンク単位の待機時間(秒)") }
サブコマンドの追加
cobra-cli を使ってサブコマンドを追加します。
$ make add name=hello
実行すると cmd 配下に hello.go が作成されます。(以下参照)
/* Copyright © 2024 rymiyamoto */ package cmd import ( "fmt" "github.com/spf13/cobra" ) // helloCmd represents the hello command var helloCmd = &cobra.Command{ Use: "hello", Short: "A brief description of your command", Long: `A longer description that spans multiple lines and likely contains examples and usage of using your command. For example: Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to quickly create a Cobra application.`, Run: func(cmd *cobra.Command, args []string) { fmt.Println("hello called") }, } func init() { rootCmd.AddCommand(helloCmd) // Here you will define your flags and configuration settings. // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: // helloCmd.PersistentFlags().String("foo", "", "A help for foo") // Cobra supports local flags which will only run when this command // is called directly, e.g.: // helloCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") }
この状態で make run line=hello を実行すると hello called が出力されます。
$ make run line=hello
hello called
フラグの追加
フラグの追加は作成された cmd/hello.go に cmd/root.go のときと同様に行います。
// ... func init() { rootCmd.AddCommand(helloCmd) helloCmd.Flags().StringP("target-at", "t", time.Now().In(time.FixedZone("Asia/Tokyo", 9*60*60)).Format(time.DateOnly), "対象日(e.g 2023-10-05)") }
処理の整形
Run
メソッドではコマンドの実行時の処理を記述しますが、エラーを返すことができないため、エラーハンドリングが限定的です。これに対し、RunE
メソッドを使用すると、エラーを呼び出し元に返すことができ、より柔軟なエラー処理が可能になります。
また、slog を使用してデフォルト引数やフラグの値をログに埋め込むことで、実行時の状況を明確に記録できます。slog の JSON ハンドラを標準出力に設定することで、レイヤードアーキテクチャにおいても、中間層を介さずに直接ログを出力することが可能です。これにより、ログの伝播に関するコードの複雑さが軽減されます。
// ... RunE: func(cmd *cobra.Command, args []string) error { // デフォルトフラグ dryRun, _ := rootCmd.Flags().GetBool("dry-run") concurrency, _ := rootCmd.Flags().GetUint("concurrency") waitTime, _ := rootCmd.Flags().GetUint("wait-time") // サブコマンド固有フラグ targetAt, _ := cmd.Flags().GetString("target-at") base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})) logger := base.With("dry-run", dryRun, "concurrency", concurrency, "wait-time", waitTime, "target-at", targetAt) slog.SetDefault(logger) slog.Info("hello world!") return nil },
実行すると、以下のような構造化ログが出力されます。構造化ログは、ログデータをキーと値のペアで表現することで、自動化された解析や人間による読解を容易にします。これにより、ログの監視や分析が効率的に行えるようになります。
# サブコマンド実行 $ make run line="hello" {"time":"2024-05-29T11:20:21.059692464Z","level":"INFO","msg":"hello world!","dry-run":false,"concurrency":10,"wait-time":1,"target-at":"2024-05-29"} # デフォルトフラグの書き換え $ make run line="hello --dry-run" {"time":"2024-05-29T11:20:46.268378503Z","level":"INFO","msg":"hello world!","dry-run":true,"concurrency":10,"wait-time":1,"target-at":"2024-05-29"} # サブコマンド固有フラグの書き換え $ make run line="hello --target-at=2024-06-02" {"time":"2024-05-29T11:21:23.834180257Z","level":"INFO","msg":"hello world!","dry-run":false,"concurrency":10,"wait-time":1,"target-at":"2024-06-02"}
あとはサブコマンドの中身を実装や追加をしていけば、CLI ツールの開発が進められます。
まとめ
今回は Go 言語で CLI ツールを開発する際によく使われるライブラリである cobra と go1.21 から標準パッケージで使えるようになった slog を使って、CLI ツールを開発する方法について紹介しました。 cobra はコマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また slog を使うことで構造化ログを出力することができ、ログの解析や可視化が容易になります。
RunE の繰り返しは面倒な作業ですが、これを改善する方法を模索していく予定です。
今後は、このベースを使って実際の処理を実装していくことで、より実用的な CLI ツールを開発していきたいと思います。
Go Conference 2024 まで、あと 5 日! gocon.jp
株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp