every Tech Blog

株式会社エブリーのTech Blogです。

go 言語で cobra と slog を使った CLI ツール開発

この記事は 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
  • ログの出力
    • 標準の logger だと使いにくい
    • logrus apex/logzap と割と自由にしがち
    • 同じような pkg が多いとメンテナンスも辛い

これらの課題から、コマンドライン引数を取る処理を簡単に実装できるパッケージとして cobra を、ログは go1.21 から標準パッケージで使えるようになった構造化ログが扱える slog を採用しました。

cobra について

cobra は Go 言語で CLI ツールを開発する際に歴史があり、Kubernetes、Hugo、GitHub CLI などの多くの Go プロジェクトで使用されています。 コマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また、CLI ツールで cobra-cli が提供されており、コマンドからスクリプトファイルの作成ができます。

github.com github.com

slog について

go1.21 から導入された構造化ログを扱うことができる go の標準パッケージです。 構造化ログは JSON や key=value 形式でログを出力することができ、ログの解析や可視化が容易になります。 また、標準パッケージであるため、外部パッケージを追加することなく go の標準ライブラリでログを出力することができます。

pkg.go.dev

イメージ

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 でコマンドを追加する際の設定ファイルです。 このファイルを編集することでコマンドの中身を拡張できます。

github.com

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メソッドを使用すると、エラーを呼び出し元に返すことができ、より柔軟なエラー処理が可能になります。

pkg.go.dev

また、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