こんにちは、Imamottyです。
こちらは「ラクス Advent Calendar 2022」の18日目の記事になります。
(投稿遅くなりました・・!!!)
昨日は @mzuk さんの「記憶の構造からプログラミング学習を考える」でした。
はじめに
私は最近業務でGoを扱っており、CLIツールをGoでサクッと作れるようになりたいと思っているため、
今回のアドベントカレンダー参加に際して、自己学習も兼ねてGoでサンプルCLIツールを作ってみました。
今日はそのお話を書いていきたいと思います。
3行まとめ
- Go言語用のCLIツール作成ライブラリは Cobra がとても有名です
- Cobra はCLIツール作成に必要な便利機能を数多く備えています
- Cobra でサンプルのCLIツールを作りながら、作り方を簡単に紹介します
Cobraとは
Cobra (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 を使って、
Cobraアプリの初期化とサブコマンド追加を行います。
cobra-cliをインストール
Goが実行できる環境で以下のコマンドを実行してcobra-cliコマンドをインストールします。
$ go install github.com/spf13/cobra-cli@latest
Cobraアプリ用ディレクトリを作成
続いてCobraアプリのルートとなるフォルダを作成して、go module init [module-name]で初期化します。
フォルダにgo.modが生成されればOKです。
$ cd $HOME/code
$ mkdir anniv
$ cd anniv
$ go mod init github.com/kudagonbe/anniv
Cobraアプリを初期化
cobra-cli initで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で実行が可能な状態になっています。
package main
import "github.com/kudagonbe/anniv/cmd"
func main() {
cmd.Execute()
}
$ 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することで実行可能バイナリが生成されます。
$ 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で実施します。
$ 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フィールドを修正しており、それぞれ短い説明と長い説明を表します。
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フラグをつけるとヘルプが表示されます)
# コマンド全体のヘルプ
$ 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を以下のように修正します。
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種類あります。
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を生成して返却
- あり:第1引数でバインド先の
- メソッド名の
P- あり:通常のフラグ名(例.
--name)だけでなく、省略形のフラグ名(例.-n)も引数として指定する - なし:通常のフラグ名(例.
--name)のみを引数として指定する
- あり:通常のフラグ名(例.
また、stringだけでなく[]string,bool,intをはじめとした多様な型へのバインド用メソッドも用意されています。
数が多く羅列するのが大変なので、spf13/pflag で使えそうなメソッドを探してみると良いと思います。
常に設定可能なフラグ(Persistent Flag)を追加
常に設定可能なフラグ(Persistent Flag)の設定は、コマンド単位で設定するフラグ(Local Flag)の設定方法に非常に似ています。
ポイントはroot.goで設定するという点と、cobra.CommandのFlags()の代わりにPersistentFlags()を使う点です。
add, listに共通なtagというフラグがあるので、こちらをPersistent Flagに設定します。
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が追加されている
$ 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.
$ 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を戻り値とする関数を設定できる点にあります。
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なしのいずれも許容されています。
$ 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フィールドを追加して処理を実装します。
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
},
}
コマンドを実行すると以下のような結果となりました。
タグでの絞り込みもできています。
$ 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年度最新版】」です。
こちらも是非ご覧ください。
それでは、またいつか!メリークリスマス!