Cobraでサブコマンドのpackageを分ける

前置き

先日CodeBuildを操作するCLIを作った際にCobraというCLIフレームワークを利用しました。

複数のCodebuildを同時に「上書きでビルドを開始する」CLIツールをGoで書いた

サブコマンドの管理やフラグの設定などを容易に実装できるためとても便利なんですが、デフォルトだとサブコマンドが全て同じ cmd というpackageに内包されます。
そのためnamespaceがサブコマンド間で全て共有され少し微妙です。

なのでサブコマンドのpackageを分割して保守性を上げてみました。

本記事の内容はほぼ以下のstack overflowの記事と同じです。
ただ日本語でググった時にパッと見情報がなかったのと、自身の理解整理のためにまとめています。

How to put cobra sub commands sources into separate folders

何が困るの?

デフォルトで test-cli というコマンドに subA, subB というサブコマンドを作成した場合このような形になります。

1go mod init test-cli
2cobra-cli init
3cobra-cli add subA
4cobra-cli add subB
 1% tree .
 2.
 3├── LICENSE
 4├── cmd
 5│   ├── root.go
 6│   ├── subA.go
 7│   └── subB.go
 8├── go.mod
 9├── go.sum
10└── main.go
 1% go run ./main.go
 2A longer description that spans multiple lines and likely contains
 3examples and usage of using your application. For example:
 4
 5Cobra is a CLI library for Go that empowers applications.
 6This application is a tool to generate the needed files
 7to quickly create a Cobra application.
 8
 9Usage:
10  test-cli [command]
11
12Available Commands:
13  completion  Generate the autocompletion script for the specified shell
14  help        Help about any command
15  subA        A brief description of your command
16  subB        A brief description of your command
17
18Flags:
19  -h, --help     help for test-cli
20  -t, --toggle   Help message for toggle
21
22Use "test-cli [command] --help" for more information about a command.
1% go run ./main.go subA
2subA called

root.go, subA.go, subB.go はいずれも cmd packageに含まれるため、それぞれのファイル内で定義された変数や関数に相互にアクセスできます。

例として subA.gofuncA を定義して、subB.go からアクセスしてみます。

1// subA.go
2
3+func funcA() string {
4+	return "Function defined in subA.go"
5+}
 1// subB.go
 2
 3Cobra is a CLI library for Go that empowers applications.
 4This application is a tool to generate the needed files
 5to quickly create a Cobra application.`,
 6	Run: func(cmd *cobra.Command, args []string) {
 7		fmt.Println("subB called")
 8+		fmt.Println(funcA())
 9	},
10}
1% go run ./main.go subB
2subB called
3Function defined in subA.go

今後開発していってサブコマンドが増えていった場合、ある一つのサブコマンドを改修した時に他のサブコマンドが壊れてしまうというケースが発生しそうです。

改修

上記の状態を直すため改修していきます。

1. ディレクトリ構成変更

まずディレクトリ構成を以下の形に変えます。

 1% tree
 2.
 3├── LICENSE
 4├── cmd
 5│   ├── root.go
 6│   ├── subA
 7│   │   └── subA.go
 8│   └── subB
 9│       └── subB.go
10├── go.mod
11├── go.sum
12└── main.go

2. rootCmdRootCmd に変換する

Goは関数などが大文字ではじまる場合は別パッケージから参照可能、小文字はじまりだと不可能になります。
今回はサブコマンドごとに別packageになるため、大文字に変更してあげます。

 1// root.go
 2
 3-var rootCmd = &cobra.Command{
 4+var RootCmd = &cobra.Command{
 5	Use:   "test-cli",
 6	Short: "A brief description of your application",
 7
 8// Execute adds all child commands to the root command and sets flags appropriately.
 9// This is called by main.main(). It only needs to happen once to the rootCmd.
10func Execute() {
11-	err := rootCmd.Execute()
12+   err := RootCmd.Execute()
13	if err != nil {
14		os.Exit(1)
15	}
16}
17
18
19	// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.test-cli.yaml)")
20
21	// Cobra also supports local flags, which will only run
22	// when this action is called directly.
23-	rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
24+	RootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
25}
1// subA.go and subB.go
2
3func init() {
4-	rootCmd.AddCommand(subACmd)
5+	RootCmd.AddCommand(subACmd)
6
7	// Here you will define your flags and configuration settings.

3. package名を変更する

subAとsubBのpackageをそれぞれ変更します。

1// subA.go and subB.go
2
3-package cmd
4+package subA

4. サブコマンド側でcmdをimportして、RootCmdの呼び出しを修正

 1// subA.go and subB.go
 2
 3import (
 4	"fmt"
 5
 6+	"test-cli/cmd"
 7	"github.com/spf13/cobra"
 8)
 9
10func init() {
11-	RootCmd.AddCommand(subACmd)
12+	cmd.RootCmd.AddCommand(subACmd)
13
14	// Here you will define your flags and configuration settings.

5. main.goでサブコマンドのパッケージをimport

今のままだと subAとsubBがどこからも呼ばれないのでmain.goのimportに追加してあげます。

 1// main.go
 2
 3package main
 4
 5-import "test-cli/cmd"
 6+import (
 7+	"test-cli/cmd"
 8+	_ "test-cli/cmd/subA"
 9+
10+	_ "test-cli/cmd/subB"
11+)
12
13func main() {
14	cmd.Execute()
15}

動作確認

これにより、サブコマンド間のpackageが別になりnamespaceが分割されます。

先の例のようにsubBからfuncAを呼ぼうとしてもエラーになります。

1% go run ./main.go subB
2# test-cli/cmd/subB
3cmd/subB/subB.go:44:15: undefined: funcA

仮に意図的にサブコマンド間でリソースを共有する場合は、上記のように関数名を大文字はじまりにして呼び出し元でimportしてあげるとアクセスできます。 なのでコードが冗長になりすぎることも防げます。

まとめ

少しだけコードは冗長になりますが、packageが分割されることによってより保守性の高い状態になります。
hugoもCobraを使用していますが本記事とは異なった形のディレクトリ構成になっているので、他にもやり方はありそうです。