「Cobra」を使ってGoの記念日管理CLIツールを作ってみる

こんにちは、Imamottyです。

こちらは「[ラクス Advent Calendar 2022](https://qiita.com/advent-calendar/2022/rakus)」の18日目の記事になります。
（投稿遅くなりました・・！！！）
昨日は @mzuk さんの[「記憶の構造からプログラミング学習を考える」](https://qiita.com/mzuk/items/a0ca68ce4a658a7b4fc2)でした。

# はじめに
私は最近業務でGoを扱っており、CLIツールをGoでサクッと作れるようになりたいと思っているため、
今回のアドベントカレンダー参加に際して、自己学習も兼ねてGoでサンプルCLIツールを作ってみました。
今日はそのお話を書いていきたいと思います。

## 3行まとめ
- Go言語用のCLIツール作成ライブラリは [Cobra](https://github.com/spf13/cobra) がとても有名です
- [Cobra](https://github.com/spf13/cobra) はCLIツール作成に必要な便利機能を数多く備えています
- [Cobra](https://github.com/spf13/cobra) でサンプルのCLIツールを作りながら、作り方を簡単に紹介します

# Cobraとは

[Cobra (spf13/cobra)](https://github.com/spf13/cobra) はGo言語でCLIツールを作成するためのライブラリで、以下のような機能を持ったコマンドを簡単に実装できます。

- サブコマンドを追加
  - 例：`app serve`->`app`がルートコマンド、`serve`がサブコマンド
- フラグを設定
  - 例：`app serve --port 8080`, `app serve -p 8080`
    - `-p`または`--port`でポート番号を指定するフラグ
- コマンドとフラグに対するhelp(`-h`, `--help`)を自動生成
- 誤入力時のサジェスト機能を自動生成 

Docker, Kubernetes, GitHub CLI等の有名ライブラリ・ツールでも利用されており、GoのCLI作成ライブラリとしてのデファクトスタンダードとなっています。

# Cobraで記念日を管理するCLIツールを作ってみる

Cobraのサンプルアプリとして、今回は記念日を管理するCLIツール(`anniv`コマンド)をGoで作ってみました。
作成したコードはGitHubにも上げてあります。
https://github.com/kudagonbe/anniv

# `anniv`コマンド仕様

`anniv`コマンドの仕様は以下です。
記念日の追加とリスト参照をできるようにします。
- 記念日を追加：`anniv add --date [yyyymmdd] --name [記念日名] (--tag [タグ])`
- 記念日リストを標準出力：`anniv list (--tag [タグ])`

# `anniv`コマンド実装
それでは`anniv`コマンドを以下の手順で作成していきます。
```
1. cobra-cliでCobraアプリを初期化してサブコマンドを追加
2. フラグを設定
3. それぞれのサブコマンドの処理を実装
```

## 1. cobra-cliでCobraアプリを初期化してサブコマンドを追加

まずはCobraアプリ用のコードジェネレータである [spf13/cobra-cli](https://github.com/spf13/cobra-cli) を使って、
Cobraアプリの初期化とサブコマンド追加を行います。

### cobra-cliをインストール

Goが実行できる環境で以下のコマンドを実行して`cobra-cli`コマンドをインストールします。
```bash:cobra-cliをインストール
$ go install github.com/spf13/cobra-cli@latest
```

### Cobraアプリ用ディレクトリを作成

続いてCobraアプリのルートとなるフォルダを作成して、`go module init [module-name]`で初期化します。
フォルダに`go.mod`が生成されればOKです。

```bash:Cobraアプリ用フォルダを準備
$ cd $HOME/code
$ mkdir anniv
$ cd anniv
$ go mod init github.com/kudagonbe/anniv
```

### Cobraアプリを初期化

`cobra-cli init`でCobraアプリを初期化します。
最低限必要なファイル群が生成されます。
```bash:Cobraアプリ初期化
$ cobra-cli init

# 以下のファイルが生成される
$ tree .
.
├── LICENSE
├── cmd
│   └── root.go
├── go.mod
├── go.sum
└── main.go

1 directory, 5 files
```

この時、`main.go`が生成されるので、`go run main.go`で実行が可能な状態になっています。
```go:main.go
package main

import "github.com/kudagonbe/anniv/cmd"

func main() {
        cmd.Execute()
}
```
```bash:main.goは実行可能
$ go run main.go
A longer description that spans multiple lines and likely contains
examples and usage of using your application. 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.
```

また、`go build`することで実行可能バイナリが生成されます。
```bash:実行可能バイナリ生成
$ go build

# annivファイルが増えている
$ ls
LICENSE  anniv  cmd  go.mod  go.sum  main.go

# annivを実行するとgo run main.goと同じ内容が出力される
$ ./anniv
A longer description that spans multiple lines and likely contains
examples and usage of using your application. 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.
```

### サブコマンドを追加

サブコマンドの`add`, `list`の追加は`cobra-cli add`で実施します。

```bash:サブコマンドの追加
$ cobra-cli add add
$ cobra-cli add list

# サブコマンド用のgoファイルがcmd配下に追加されている
$ tree .
.
├── LICENSE
├── cmd
│   ├── add.go
│   ├── list.go
│   └── root.go
├── go.mod
├── go.sum
└── main.go

1 directory, 7 files
```

### コマンド説明文を修正

`cmd/`配下にあるgoファイルを修正すると、コマンドごとの説明文を変更することができます。
各goファイルで定義されている`*cobra.Command`型の変数を変更します。
以下は`anniv`コマンドの本体となる`cmd/root.go`の修正後の状態です。
`rootCmd`の`Short`フィールドと`Long`フィールドを修正しており、それぞれ短い説明と長い説明を表します。

```go:cmd/root.go
var rootCmd = &cobra.Command{
	Use:   "anniv",
	Short: "'anniv' is a CLI tool to manage anniversaries",
	Long: `'anniv' is a CLI tool to manage anniversaries.

You can register anniversaries and 
refer to the anniversaries list from the CLI.`,
}
```

同様に`cmd/add.go`, `cmd/list.go`も編集して、自動生成されているヘルプを表示してみます。
(`-h`フラグをつけるとヘルプが表示されます)
```bash:ヘルプを表示
# コマンド全体のヘルプ
$ go run main.go -h
'anniv' is a CLI tool to manage anniversaries.

You can register anniversaries and
refer to the anniversaries list from the CLI.

Usage:
  anniv [command]

Available Commands:
  add         Register anniversary
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  list        Refer anniversary list

Flags:
  -h, --help     help for anniv
  -t, --toggle   Help message for toggle

Use "anniv [command] --help" for more information about a command

# サブコマンド`add`のヘルプ
$ go run main.go add -h
Register anniversary

Usage:
  anniv add [flags]

Flags:
  -h, --help   help for add
```

## 2. フラグを設定

続いて各コマンドのフラグを有効化していきます。
フラグの設定は大まかに以下の要領で進めます。

- `cmd/`配下にあるgoファイルごとに以下の実装を実施
  - フラグの値を格納する変数を宣言
  - `init()`関数内で変数にフラグの値をバインド

またCobraで取り扱うフラグは主に「コマンド単位で設定するフラグ **(Local Flag)**」と「常に設定可能なフラグ **(Persistent Flags)**」の2種類があります。

### コマンド単位で設定するフラグ(Local Flag)を追加

サブコマンド`add`に付与するフラグ`date`と`name`は、`add`コマンドにおいてのみ有効なフラグです。
どちらの値も文字列として受け取る前提で、`cmd/add.go`を以下のように修正します。

```go:cmd/add.go
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

//フラグバインド用の変数
var date, name string

var addCmd = &cobra.Command{
...
}

func init() {
	rootCmd.AddCommand(addCmd)

	//フラグの値を変数にバインド
	addCmd.Flags().StringVarP(&date, "date", "D", "", "Anniversary date in the format 'YYYYMMDD'")
	addCmd.Flags().StringVar(&name, "name", "", "Anniversary date name")

	//必須のフラグに指定
	addCmd.MarkFlagRequired("date")
	addCmd.MarkFlagRequired("name")
}
```

`cobra.Command`の`Flags()`メソッドで`*pflag.FlagSet`を参照し、フラグの値を`date`, `name`変数にバインドしています。
また、`MarkFlagRequired()`メソッドでフラグが必須であることを宣言しています。

`*pflag.FlagSet`フラグの値を`*string`変数にバインドするメソッドは4種類あります。
```go:string関連のメソッド
func (f *FlagSet) String(name string, value string, usage string) *string
func (f *FlagSet) StringP(name, shorthand string, value string, usage string) *string
func (f *FlagSet) StringVar(p *string, name string, value string, usage string)
func (f *FlagSet) StringVarP(p *string, name, shorthand string, value string, usage string)
```
これらのメソッド名には分かりやすい命名ルールがあります。
- バインドする変数の型を表す`String`で始まる
- メソッド名の`Var`
  - あり：第1引数でバインド先の`*string`を渡す。
  - なし：`*string`を生成して返却
- メソッド名の`P`
  - あり：通常のフラグ名(例.`--name`)だけでなく、省略形のフラグ名(例.`-n`)も引数として指定する
  - なし：通常のフラグ名(例.`--name`)のみを引数として指定する

また、`string`だけでなく`[]string`,`bool`,`int`をはじめとした多様な型へのバインド用メソッドも用意されています。
数が多く羅列するのが大変なので、[spf13/pflag](https://github.com/spf13/pflag) で使えそうなメソッドを探してみると良いと思います。

### 常に設定可能なフラグ(Persistent Flag)を追加

常に設定可能なフラグ(Persistent Flag)の設定は、コマンド単位で設定するフラグ(Local Flag)の設定方法に非常に似ています。
ポイントは`root.go`で設定するという点と、`cobra.Command`の`Flags()`の代わりに`PersistentFlags()`を使う点です。

`add`, `list`に共通な`tag`というフラグがあるので、こちらをPersistent Flagに設定します。
```go:root.go
package cmd

import (
	"os"

	"github.com/spf13/cobra"
)

// フラグバインド用の変数
var tag string

var rootCmd = &cobra.Command{
...
}

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

func init() {
	//フラグの値を変数にバインド
	rootCmd.PersistentFlags().StringVar(&tag, "tag", "", "Tags for classifying anniversaries")
}
```

### ヘルプでフラグの設定を確認
以下が確認できます。
- ルートコマンドおよびサブコマンドのヘルプにPersistent Flagの`tag`が追加されている
- サブコマンドの`add`にLocal Flagの`date`と`name`が追加されている
```bash:ルートコマンド
$ go run main.go -h
'anniv' is a CLI tool to manage anniversaries.

You can register anniversaries and
refer to the anniversaries list from the CLI.

Usage:
  anniv [command]

Available Commands:
  add         Register anniversary
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  list        Refer anniversary list

Flags:
  -h, --help         help for anniv
      --tag string   Tags for classifying anniversaries

Use "anniv [command] --help" for more information about a command.
```
```bash:サブコマンド add
$ go run main.go add -h
Register anniversary

Usage:
  anniv add [flags]

Flags:
  -D, --date string   Anniversary date in the format 'YYYYMMDD'
  -h, --help          help for add
      --name string   Anniversary date name

Global Flags:
      --tag string   Tags for classifying anniversaries
```

## 3. それぞれのサブコマンドの処理を実装

### addコマンドを実装

`anniv add`コマンドは`~/.anniv/data.csv`に記念日の情報(日付,記念日名,タグ)をCSVで登録します。
`add.go`の`addCmd`に`RunE`フィールドを追加して、コマンド実行時の処理を記述します。
なお、コマンド実行時の処理を記述するフィールドとして`Run`を指定することもできます。
両者の違いは`RunE`には`error`を戻り値とする関数を設定できる点にあります。

```go:cmd/add.go#addCmd
var addCmd = &cobra.Command{
	Use:   "add",
	Short: "Register anniversary",
	Long:  "Register anniversary",
    //RunEフィールドを追加
	RunE: func(cmd *cobra.Command, args []string) error {
		if _, err := time.Parse("20060102", date); err != nil {
			return fmt.Errorf("invalid date format: %s", err.Error())
		}
		if len(name) == 0 {
			return errors.New("anniversary name is empty")
		}

		home, err := os.UserHomeDir()
		if err != nil {
			return fmt.Errorf("cannot get user home dir path: %s", err.Error())
		}

		dir := fmt.Sprintf("%s/.anniv", home)
		if s, err := os.Stat(dir); os.IsNotExist(err) {
			if err := os.Mkdir(dir, 0777); err != nil {
				return fmt.Errorf("cannot create directory: %s", err.Error())
			}
		} else if !s.IsDir() {
			return fmt.Errorf("%s is not directory", dir)
		}

		f := fmt.Sprintf("%s/data.csv", dir)
		fp, err := os.OpenFile(f, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
		if err != nil {
			return fmt.Errorf("cannot create or open file: %s", err.Error())
		}
		defer fp.Close()

		w := csv.NewWriter(fp)
		w.Write([]string{date, name, tag})
		w.Flush()
		if err := w.Error(); err != nil {
			return err
		}

        fmt.Println("Success!!")

		return nil
	},
}
```
コマンドを実行すると以下のような結果となりました。
tagありとtagなしのいずれも許容されています。
```bash:anniv addコマンドを実行
$ go run main.go add --date 20221220 --name "久しぶりにQiitaに投稿！" --tag blog
Success!!

$ go run main.go add --date 20221218 --name "ほんとはこの日に投稿しなければならなかった"
Success!!

$ cat ~/.anniv/data.csv
20221220,久しぶりにQiitaに投稿！,blog
20221218,ほんとはこの日に投稿しなければならなかった,
```

### listコマンドを実装

`anniv list`コマンドは`~/.anniv/data.csv`を読み込んで、CSVを標準出力します。
また、`--tag`の指定がある場合は、そのタグを持ったレコードのみを標準出力します。

addコマンドと同様に、`list.go`の`listCmd`に`RunE`フィールドを追加して処理を実装します。

```go:list.go#listCmd
var listCmd = &cobra.Command{
	Use:   "list",
	Short: "Refer anniversary list",
	Long:  "Refer anniversary list",
	RunE: func(cmd *cobra.Command, args []string) error {
		home, err := os.UserHomeDir()
		if err != nil {
			return fmt.Errorf("cannot get user home dir path: %s", err.Error())
		}

		f := fmt.Sprintf("%s/.anniv/data.csv", home)
		if s, err := os.Stat(f); os.IsNotExist(err) {
			fmt.Println("date,name,tag")
			return nil
		} else if s.IsDir() {
			return fmt.Errorf("%s is directory", f)
		}

		fp, err := os.Open(f)
		if err != nil {
			return fmt.Errorf("cannot open file: %s", err.Error())
		}
		defer fp.Close()

		r := csv.NewReader(fp)

		fmt.Println("date,name,tag")
		for {
			record, err := r.Read()
			if err == io.EOF {
				break
			}
			if err != nil {
				return fmt.Errorf("cannot read file: %s", err.Error())
			}
			if tag == "" || (len(record) >= 3 && record[2] == tag) {
				fmt.Println(strings.Join(record, ","))
			}
		}
		return nil
	},
}
```
コマンドを実行すると以下のような結果となりました。
タグでの絞り込みもできています。
```bash:anniv listコマンドを実行
$ go run main.go list
date,name,tag
20221220,久しぶりにQiitaに投稿！,blog
20221218,ほんとはこの日に投稿しなければならなかった,

$ go run main.go list --tag blog
date,name,tag
20221220,久しぶりにQiitaに投稿！,blog

$ go run main.go list --tag birthday
date,name,tag
```

以上でCobraアプリケーションの実装はいったん完了となります。

# おわりに

元々あまり知識の無い状態からCobraを触り始めましたが、思いのほか簡単に取り扱うことができ、覚えるまでにハマる箇所はほとんどありませんでした。
この記事が、Cobraをこれから使っていきたいという方の参考になれば幸いです。

19日目は @rs_tukki さんの [「WSLでKaliLinuxを構築してみる【2022年度最新版】」](https://qiita.com/rs_tukki/items/dc38b6efed5938f82a80)です。
こちらも是非ご覧ください。
それでは、またいつか！メリークリスマス！