GoでCLIツールを作る:Cobraフレームワーク実践ガイド

GoでCLIツールを作る:Cobraフレームワーク実践ガイド | mohablog
目次

はじめに:CLIツール開発の悩みから始まる

マイクロサービスの運用やインフラ周りの作業をしていると、ちょっとした自動化ツールが欲しくなることってありますよね。シェルスクリプトでも動きますけど、複雑な処理や複数のサブコマンドが必要になると「これ、Goで書きたいな」と思う場面が増えてきました。

そこで出会ったのがCobraというフレームワークなんですよね。実際のプロジェクトで何本かCLIツールを書いてみて、Cobraを使うと本当に開発効率が上がる。この記事では、実際に僕が経験した落とし穴や、実装のコツをお伝えしようと思います。

この記事では、Go 1.22Cobra 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():このコマンド専用のフラグ

実際のプロジェクトでの経験から言うと、PersistentFlagsFlagsの使い分けが最初の落とし穴なんですよね。「あ、これ子コマンドでも使う変数だ」となったらPersistentFlagsに移す必要があります。

実装例その1:基本的なCLIツール構造

では実際に作ってみます。「ブログ管理ツール」を想定して、userpostというサブコマンドを持つツールです。

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はPreRunPostRunというライフサイクルフックを提供しているんですよね。

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以降の更新は、主に新機能追加やバグ修正なので、依存性の心配は少ないですね。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次