📚

Cobra+Afero で作る Golang 製 CLI

2022/08/13に公開

はじめに

Go 言語で日記を管理する CLI (今回は適当に mydiary という名前にしました)を作りました。リポジトリはこちら

元々のモチベは AWS Copilot CLI が Golang で実装されていて CLI 部分は Cobra で実装されているっぽいので挙動を把握したかったからです。題材に日記を管理する CLI を選んだのは、2017 年から日記をテキストファイルでつけていて今までは Python スクリプトを元日に叩いてテンプレートを生成していたところ、年一回しか実行しないのと型もないので挙動がわからなくなっていたので作り直そうと思ったからです。

例によって開発は Docker で行って VSCode Remote Containers にお世話になっています。

やってみる

作るものの詳細

個人的に使うものなので以下の二つの機能があれば十分です。

  • 日記の特定のテンプレートを生成する。例えば 2022 年なら 2022/202201.txt みたいな形で各月のテキストファイルを生成し中身が以下のようになっていればいい。コマンドはこんな感じ: mydiary init --year 2020
2020,January,01,Sat

2020,January,02,Sun

  • 指定した日にちの日記を見る。コマンドはこんな感じ: mydiary show --date 20200101

Cobra とは

Cobra は Golang の CLI を作るときの便利ライブラリで、 AWS Copilot CLI 以外にも静的サイトジェネレーターの Hugo や Kubernetes でも使われています。実は Cobra Generator という CLI アプリケーションの初期化を簡単にできるものがあるらしいことを後で知りました。これの存在を知らなかったので自分は AWS Copilot CLI のコードを読みながら挙動を読み解いていったのですが、そんなことをせずに普通にユーザーガイドを読むと使い方のイメージが掴みやすいと思います。

CLI を作る場合大元のコマンドがあってそれにサブコマンドが複数付属していてさらにフラグを指定できるみたいな作りが多いと思います。例えば Git なら git という大元のコマンドがあって、それに addcommit のようなサブコマンドがあり、さらに commit には -m フラグがあります。Cobra の場合コマンドは cobra.Command 構造体で定義して AddCommand でサブコマンドとして別のコマンドを追加できます。今回作った CLI だとここで大元のコマンドを定義しています。

func BuildRootCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "mydiary",
		Short: "Manage your diary",
		Long: `mydiary is a CLI tool for Golang that manages your diary.
This tool generates diary templates, shows diary of specified date.`,
	}

	cmd.AddCommand(BuildInitCmd())
	cmd.AddCommand(BuildShowCmd())
	return cmd
}

mydiary init コマンド

このコマンドでは年を引数にとってその年の各月の日記テンプレートを生成します。テンプレートは日付と曜日を含んでおり日付を入れるのは簡単ですが曜日の場合はそんなに簡単にはいきません。といってもツェラーの公式を実装するだけで実現できます。曜日を計算する処理はここに書いておいてテストも書くことにしました。

init コマンドには year フラグがついています。フラグについてはユーザーガイドのここにまとまっています。ざっくりいうとこんな感じで使えます。

var year int64
cmd := &cobra.Command{
    // 省略
}
cmd.Flags().Int64Var(&year, "year", 0, "year of diary")
cmd.MarkFlagRequired("year")

int64 型の値を year 変数に代入するフラグとして year をもうけてヘルプコマンドに対する説明としては "year of diary" を返すようにしており、 init コマンドの必須フラグに year を指定しています。他にも String や Boolean の値をフラグに代入することもできます。

そしてこの init コマンドはテンプレートファイルを生成するわけなのでファイル I/O が発生します。やってることはディレクトリを作成してファイルを書き込むだけなので os.Mkdir してもいいのですが、それだと開発している最中にディレクトリが何度も作られて微妙だなと思いました。色々調べているうちにすぐあとで述べる afero を使うことにしました。

Afero を使うとテストが便利

Afero は Golang のファイルシステムフレームワークです。以下のように標準の os パッケージと同じ感じで使えます。

var Fs = afero.NewOsFs()
err *Fs.MkdirAll("2020", 0755)

README によれば Afero をテストに使うことができます。 afero.NewMemMapFs() を使うと実際にファイルの読み書きをするのではなくメモリ上のファイルシステムを使って読み書きを行うので、例えばテストのたびにテスト用のディレクトリを作ったり削除したりする必要がありません。

今回作った CLI だと日記のテンプレートファイルを書き込む WriteMonthTemplate では ws.Fs.WriteFile(filename, template, 0755) のように引数に Afero のファイルシステムを持たせて、テストの時には MemMapFs を渡すことで単体テストをやりやすくしています。

(2023/05/28 追記)

mattn さんの「Go 言語プログラミングエッセンス」に触発されてテストケースを Table Driven Test にする・GoReleaser と GitHub Actions を使って自動でリリースする・テストを並行で実行する、などの更新を加えました。

おわりに

Cobra を使ってコードを書いたことでだいぶ AWS Copilot CLI のコードが読みやすく感じるようになりました。ちょっとした CLI を作る時には Cobra はだいぶ便利だと思います。

Discussion