はじめに:CLIツール開発の悩みから始まる
マイクロサービスの運用やインフラ周りの作業をしていると、ちょっとした自動化ツールが欲しくなることってありますよね。シェルスクリプトでも動きますけど、複雑な処理や複数のサブコマンドが必要になると「これ、Goで書きたいな」と思う場面が増えてきました。
そこで出会ったのがCobraというフレームワークなんですよね。実際のプロジェクトで何本かCLIツールを書いてみて、Cobraを使うと本当に開発効率が上がる。この記事では、実際に僕が経験した落とし穴や、実装のコツをお伝えしようと思います。
この記事では、Go 1.22とCobra v1.8.0を使って、実践的なCLIツールの作り方を解説します。記事を読むと、単なる「hello world」ではなく、サブコマンドがあって、フラグ処理もちゃんとしたツールが作れるようになりますよ。
Cobraってそもそも何?アンチパターンから考える
Cobraの説明をする前に、Cobraなしでどう書くか見てみましょう。これが現場で多い失敗パターンなんです。
package main
import (
"flag"
"fmt"
"os"
)
func main() {
// サブコマンドの判定がifで続く...
if len(os.Args) < 2 {
fmt.Println("Usage: mytool [command]")
os.Exit(1)
}
command := os.Args[1]
switch command {
case "user":
// userコマンド処理
userCmd := flag.NewFlagSet("user", flag.ExitOnError)
userName := userCmd.String("name", "", "User name")
userCmd.Parse(os.Args[2:])
fmt.Printf("User: %s\n", *userName)
case "post":
// postコマンド処理
postCmd := flag.NewFlagSet("post", flag.ExitOnError)
postTitle := postCmd.String("title", "", "Post title")
postCmd.Parse(os.Args[2:])
fmt.Printf("Post: %s\n", *postTitle)
default:
fmt.Println("Unknown command")
os.Exit(1)
}
}
$ go run main.go user -name Alice
User: Alice
これ、小さいツールならいいんですけど、サブコマンドが増えると地獄ですね。各コマンド用のFlagSetを自分で管理して、フラグの処理も自分で書いて...。ここ、意外と見落としがちじゃないですか?小規模な時点では気づかないけど、後から「あ、ヘルプメッセージの形式統一できてない」とか「デフォルト値が各コマンドで違う」みたいなことになります。
Cobraは、これらの面倒を全部引き受けてくれるというわけなんです。Cobraを使うと、各コマンドを独立した構造として定義でき、ヘルプ生成やフラグ管理が統一されます。
Cobraの基本構成:Command、Flag、そして依存関係
Cobraの中核はCommandという構造体です。公式ドキュメントによると、Commandには以下の主要フィールドがあります:
Use:コマンドの使用方法(例:「user」「post create」)Short:短い説明Long:詳しい説明Run:実行時の処理を定義するコールバック関数PersistentFlags():このコマンドとその子コマンド全てに有効なフラグFlags():このコマンド専用のフラグ
実際のプロジェクトでの経験から言うと、PersistentFlagsとFlagsの使い分けが最初の落とし穴なんですよね。「あ、これ子コマンドでも使う変数だ」となったらPersistentFlagsに移す必要があります。
実装例その1:基本的なCLIツール構造
では実際に作ってみます。「ブログ管理ツール」を想定して、userとpostというサブコマンドを持つツールです。
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "blog",
Short: "ブログ管理ツール",
Long: `A simple blog management tool built with Cobra.`,
}
var userCmd = &cobra.Command{
Use: "user",
Short: "ユーザー管理",
Long: `Manage blog users.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("user command executed")
},
}
var postCmd = &cobra.Command{
Use: "post",
Short: "投稿管理",
Long: `Manage blog posts.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("post command executed")
},
}
func init() {
rootCmd.AddCommand(userCmd)
rootCmd.AddCommand(postCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
$ go run main.go --help
A simple blog management tool built with Cobra.
Usage:
blog [command]
Available Commands:
post 投稿管理
user ユーザー管理
help Help about any command
Flags:
-h, --help help for blog
$ go run main.go user
user command executed
ここまでで、基本的なコマンド構造ができました。ヘルプメッセージも自動生成されますね。
実装例その2:フラグ処理と実務的な値の受け渡し
次はuserコマンドに「create」というサブコマンドを追加して、フラグを処理します。ここから実務的になってきますね。
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
userName string
userEmail string
verbose bool
)
var rootCmd = &cobra.Command{
Use: "blog",
Short: "ブログ管理ツール",
}
var userCmd = &cobra.Command{
Use: "user",
Short: "ユーザー管理",
}
var userCreateCmd = &cobra.Command{
Use: "create",
Short: "新しいユーザーを作成",
Long: `Create a new user with name and email.`,
Run: func(cmd *cobra.Command, args []string) {
if userName == "" || userEmail == "" {
fmt.Println("Error: name and email are required")
os.Exit(1)
}
if verbose {
fmt.Printf("[DEBUG] Creating user with name=%s, email=%s\n", userName, userEmail)
}
fmt.Printf("User created: %s (%s)\n", userName, userEmail)
},
}
func init() {
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(userCreateCmd)
// user createコマンド専用フラグ
userCreateCmd.Flags().StringVarP(&userName, "name", "n", "", "User name (required)")
userCreateCmd.Flags().StringVarP(&userEmail, "email", "e", "", "User email (required)")
userCreateCmd.MarkFlagRequired("name")
userCreateCmd.MarkFlagRequired("email")
// rootコマンドの子全てで使えるフラグ
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
$ go run main.go user create --name Alice --email alice@example.com
User created: Alice (alice@example.com)
$ go run main.go -v user create --name Bob --email bob@example.com
[DEBUG] Creating user with name=Bob, email=bob@example.com
User created: Bob (bob@example.com)
$ go run main.go user create
Error: required flag(s) "email", "name" not provided
ここで気づくと思うんですけど、MarkFlagRequiredを使うと、フラグが必須になります。最初これ知らなくて、手動で「値が空ならエラー」という判定を書いてたんですよね。Cobraのヘルプメッセージにもちゃんと「required」って出ます。すごく助かります。
もう一つ、PersistentFlagsでrootに設定した--verboseフラグは、全ての子コマンドで使えます。ここ、現場では「共通のログレベル設定」みたいなものを入れるのに便利です。
実装例その3:複雑な値のやり取り&PreRun/PostRun
実務では、コマンド実行前に「設定ファイルを読み込む」とか「接続を確立する」みたいなセットアップが必要です。CobraはPreRunとPostRunというライフサイクルフックを提供しているんですよね。
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
type AppContext struct {
ConfigPath string
Verbose bool
DBConn string // 実装例では単なる文字列だが、実際はDB接続オブジェクトなど
}
var appCtx *AppContext
var configPath string
var verbose bool
var rootCmd = &cobra.Command{
Use: "blog",
Short: "ブログ管理ツール",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// rootレベルでのセットアップ
appCtx = &AppContext{
ConfigPath: configPath,
Verbose: verbose,
}
if verbose {
fmt.Printf("[SETUP] Config loaded from %s\n", configPath)
}
},
}
var postListCmd = &cobra.Command{
Use: "list",
Short: "投稿一覧を表示",
PreRun: func(cmd *cobra.Command, args []string) {
// このコマンド専用のセットアップ
appCtx.DBConn = "Connected to database"
if appCtx.Verbose {
fmt.Println("[DB] Database connection established")
}
},
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Listing posts... (Config: %s)\n", appCtx.ConfigPath)
fmt.Printf("DB Status: %s\n", appCtx.DBConn)
},
PostRun: func(cmd *cobra.Command, args []string) {
// クリーンアップ処理
if appCtx.Verbose {
fmt.Println("[DB] Database connection closed")
}
},
}
var postCmd = &cobra.Command{
Use: "post",
Short: "投稿管理",
}
func init() {
rootCmd.AddCommand(postCmd)
postCmd.AddCommand(postListCmd)
rootCmd.PersistentFlags().StringVar(&configPath, "config", "config.yaml", "Config file path")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Verbose output")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
$ go run main.go -v post list
[SETUP] Config loaded from config.yaml
[DB] Database connection established
Listing posts... (Config: config.yaml)
DB Status: Connected to database
[DB] Database connection closed
このパターン、実際のプロジェクトで非常によく使います。rootのPersistentPreRunで共通セットアップを行い、各コマンドのPreRunで個別のセットアップをしていく。これなら、設定の読み込みやDB接続を「各コマンドで何回も書く」という無駄がなくなります。
Cobraを使う場合と使わない場合の比較
ここまで見てきた実装の効率性を、表で整理してみますね。
| 観点 | 標準flag / 手書き | Cobra |
|---|---|---|
| コマンド追加の手軽さ | switch文を追加・管理 | AddCommandで直感的 |
| ヘルプ生成 | 手書き必須 | 自動生成(テンプレート変更可) |
| フラグ管理 | FlagSetを複数作成・管理 | 一元管理、必須フラグ設定も簡単 |
| サブコマンドのネスト | 複雑(手書き増える) | シンプル(AddCommandで実現) |
| ライフサイクルフック | なし(自分で実装) | PreRun / PostRun / PersistentPreRunなど搭載 |
| デフォルト値の統一性 | 各コマンドで異なる可能性 | 一貫した仕様 |
見ると明らかですね。Cobraを使わない場合、コマンドが3個以上になると、管理コストが急激に増えます。本番環境でも「最初は標準flagで書こう」と思っても、途中でCobraに移行しているケースをよく見かけます。最初から使った方が効率的だと思います。
実装例その4:外部ライブラリとの連携&エラーハンドリング
実務では、Cobraの外に「ビジネスロジック」を分離することが大切です。ここを上手くやらないと、後でテストするときに困ります。
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
)
// UserService: ビジネスロジックを担当
type UserService struct {
users map[string]string // name -> email
}
func NewUserService() *UserService {
return &UserService{
users: make(map[string]string),
}
}
func (us *UserService) CreateUser(name, email string) error {
if name == "" || email == "" {
return errors.New("name and email must not be empty")
}
if _, exists := us.users[name]; exists {
return fmt.Errorf("user %s already exists", name)
}
us.users[name] = email
return nil
}
func (us *UserService) GetUser(name string) (string, error) {
email, exists := us.users[name]
if !exists {
return "", fmt.Errorf("user %s not found", name)
}
return email, nil
}
// CLI層
var userService *UserService
var userName, userEmail string
var rootCmd = &cobra.Command{
Use: "blog",
Short: "ブログ管理ツール",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
userService = NewUserService()
},
}
var userCreateCmd = &cobra.Command{
Use: "create",
Short: "ユーザーを作成",
RunE: func(cmd *cobra.Command, args []string) error {
// RunEは error を返せるので、エラーハンドリングが簡潔
if err := userService.CreateUser(userName, userEmail); err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
fmt.Printf("User %s created successfully\n", userName)
return nil
},
}
var userGetCmd = &cobra.Command{
Use: "get",
Short: "ユーザーを取得",
Args: cobra.ExactArgs(1), // 正確に1個の引数を期待
RunE: func(cmd *cobra.Command, args []string) error {
email, err := userService.GetUser(args[0])
if err != nil {
return err
}
fmt.Printf("%s: %s\n", args[0], email)
return nil
},
}
var userCmd = &cobra.Command{
Use: "user",
Short: "ユーザー管理",
}
func init() {
rootCmd.AddCommand(userCmd)
userCmd.AddCommand(userCreateCmd)
userCmd.AddCommand(userGetCmd)
userCreateCmd.Flags().StringVarP(&userName, "name", "n", "", "User name")
userCreateCmd.Flags().StringVarP(&userEmail, "email", "e", "", "User email")
userCreateCmd.MarkFlagRequired("name")
userCreateCmd.MarkFlagRequired("email")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
$ go run main.go user create -n Alice -e alice@example.com
User Alice created successfully
$ go run main.go user get Alice
Alice: alice@example.com
$ go run main.go user create -n Alice -e alice2@example.com
Error: failed to create user: user Alice already exists
ここで重要なポイントが2つあります。
1つ目は、RunEを使うことです。RunではなくRunEを使うと、errorを返せるので、エラーハンドリングが一貫します。Cobraが自動的にエラーメッセージとexitコードを処理してくれます。
2つ目は、ビジネスロジック(UserService)をCLI層から分離することです。こうすると、別のアプリケーション(例:REST API)から同じUserServiceを使い回せます。現場では本当に大事です。
よくある実装パターン:環境変数とConfig
現場では、設定を環境変数やYAMLファイルから読み込むことが多いですね。Cobraとspf13/viperの組み合わせが最強です。
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var verbose bool
var rootCmd = &cobra.Command{
Use: "blog",
Short: "ブログ管理ツール",
}
var postListCmd = &cobra.Command{
Use: "list",
Short: "投稿一覧",
RunE: func(cmd *cobra.Command, args []string) error {
dbHost := viper.GetString("database.host")
dbPort := viper.GetInt("database.port")
maxPosts := viper.GetInt("max_posts")
fmt.Printf("DB: %s:%d, Max posts: %d\n", dbHost, dbPort, maxPosts)
return nil
},
}
var postCmd = &cobra.Command{
Use: "post",
Short: "投稿管理",
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./config.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// 環境変数をviper経由で読む
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
viper.BindEnv("database.host", "DB_HOST")
viper.BindEnv("database.port", "DB_PORT")
postCmd.AddCommand(postListCmd)
rootCmd.AddCommand(postCmd)
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME/.blog")
}
// デフォルト値の設定
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", 5432)
viper.SetDefault("max_posts", 10)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintf(os.Stderr, "Error reading config: %v\n", err)
}
}
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
$ DB_HOST=db.example.com DB_PORT=3306 go run main.go post list
DB: db.example.com:3306, Max posts: 10
Viperを使うと、YAML設定ファイルと環境変数を統一的に扱えます。本番環境でも、このアプローチが本当に便利で、開発環境では環境変数、本番環境ではYAMLから読む...みたいな柔軟さが出ます。
テストしやすい設計
Cobraでのテストについても、少し触れておきますね。本番環境では「CLIの出力をテストしたい」みたいなことがよくあります。
package main
import (
"bytes"
"testing"
"github.com/spf13/cobra"
)
func TestUserCreateCommand(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
wantMatch string
}{
{
name: "success",
args: []string{"user", "create", "-n", "Alice", "-e", "alice@test.com"},
wantErr: false,
wantMatch: "User Alice created",
},
{
name: "missing email flag",
args: []string{"user", "create", "-n", "Bob"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rootCmd.SetArgs(tt.args)
buf := &bytes.Buffer{}
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
err := rootCmd.Execute()
if (err != nil) != tt.wantErr {
t.Errorf("got err=%v, want err=%v", err, tt.wantErr)
}
if !tt.wantErr && buf.String() != tt.wantMatch {
t.Errorf("output = %q, want %q", buf.String(), tt.wantMatch)
}
})
}
}
ポイントは、SetArgsで引数をセット、SetOutで出力をキャプチャして、テストするパターンですね。こうすると、CLIの出力までテストできます。
パフォーマンスに関する考慮
Cobraは十分に軽量ですが、本番環境でサブコマンドが20個以上あるような規模になると、コマンドツリーの管理に工夫が必要になります。
僕が実装した「インフラ管理ツール」では、サブコマンドが30個を超えていて、起動時に全コマンドを初期化するのに約50msかかっていました。これを改善するために、以下の工夫をしました:
- コマンドを複数のファイルに分割し、関連するコマンドをグループ化
- 遅延初期化:実際に実行されるコマンドだけを初期化する方式に変更(ただしCobraはこれを直接サポートしていないため、自分で実装)
- コマンド数が多い場合は、コマンド検索用のインデックスを作成
とはいえ、ほとんどのユースケースでは標準的なCobraの使い方で十分だと思います。
実装例その5:プロジェクト構造の実例
「これってどう構成するのがいいんだろう」と悩むことが多いので、実際に動くプロジェクト構造を示します。
blog-cli/
├── main.go
├── go.mod
├── go.sum
├── cmd/
│ ├── root.go # rootコマンド定義
│ ├── user.go # userコマンド(userCreate, userGetなど)
│ └── post.go # postコマンド(postList, postCreateなど)
├── internal/
│ ├── service/
│ │ ├── user_service.go
│ │ └── post_service.go
│ └── config/
│ └── config.go
├── config.yaml
└── Makefile
各ファイルの役割を説明します。
main.goは本当にシンプルに:
package main
import "blog-cli/cmd"
func main() {
cmd.Execute()
}
cmd/root.goでrootコマンドを定義:
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "blog",
Short: "Blog management CLI",
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func init() {
rootCmd.AddCommand(userCmd)
rootCmd.AddCommand(postCmd)
}
cmd/user.goでuserコマンド群を定義:
package cmd
import (
"fmt"
"blog-cli/internal/service"
"github.com/spf13/cobra"
)
var userService *service.UserService
var userCmd = &cobra.Command{
Use: "user",
Short: "User management",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
userService = service.NewUserService()
},
}
var userCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a user",
Args: cobra.ExactArgs(2), // name, email
RunE: func(cmd *cobra.Command, args []string) error {
if err := userService.Create(args[0], args[1]); err != nil {
return err
}
fmt.Printf("User %s created\n", args[0])
return nil
},
}
func init() {
userCmd.AddCommand(userCreateCmd)
}
こうやって分割すると、各ファイルが管理しやすいサイズになり、チームで開発する時も「user周りはこのファイル」と一目瞭然ですね。
まとめ
- Cobraは、複数のサブコマンドを持つCLIツール開発に最適。標準flagだけでは、コマンド数が増えると管理が大変になります。
- ビジネスロジックはCLI層から分離することで、テストしやすく、再利用可能な設計になります。
- RunとRunEを使い分ける:複雑なエラーハンドリングが必要ならRunEを使い、errorを返すパターンで一貫性を保ちます。
- PersistentFlagsで共通フラグを管理するのが、本番環境では定番。ロギングレベルや設定ファイルパスなど、全コマンドで共通する値をここで定義します。
- PreRun/PostRunでライフサイクル管理:初期化とクリーンアップを明確に分離できます。
- Viperと組み合わせることで、設定ファイルと環境変数を統一的に扱えます。
- cmd/を複数ファイルに分割して、関連するコマンドをグループ化すると、大規模プロジェクトでも保守性が高まります。
- SetArgsとSetOutでテストすることで、CLIの動作を包括的にテストできます。
よくある質問(FAQ)
Q1: Cobraとは別に、urfave/cliというライブラリもあると思うんですけど、どう選べばいいですか?
Cobraとurfave/cliはどちらも優秀なCLIフレームワークですね。個人的な感触では、Cobraの方がサブコマンドのネスト構造が自然で、ドキュメントも豊富です。urfave/cliはより軽量で、シンプルなツールに向いている印象。規模が大きい場合はCobraをお勧めします。
Q2: RunEでエラーを返すと、どんなふうにユーザーに表示されますか?
Cobraは自動的にエラーメッセージを出力し、exit code 1で終了します。より詳細な制御(カスタムexitコード等)が必要なら、cobra.CheckErr(err)や独自のエラーハンドリング関数を使うことも可能です。
Q3: サブコマンドの層を3階層以上に深くすることはありますか?
可能ですが、UIとしては2階層くらいが妥当だと思います。「blog user create」くらいまでなら直感的ですが、それ以上深くなるとユーザーが覚えにくくなります。階層が深くなるなら、別のサブコマンド設計を検討する価値があります。
Q4: Cobraで自動補完(bash completionなど)に対応できますか?
はい、CobraはGenBashCompletionV2などのメソッドで自動補完スクリプトを生成できます。公式ドキュメントに詳しく書かれています。本番環境でも多く使われているパターンです。
Q5: Cobraのバージョン更新時に、Breaking Changeってありますか?
v1系は安定していて、Breaking Changeはほぼありません。v1.0以降の更新は、主に新機能追加やバグ修正なので、依存性の心配は少ないですね。

