CLI開発 - 解説

概要

本解説では、solution.mdで実装したCLIツール「filetool」のアーキテクチャ、設計パターン、実装の詳細について深く掘り下げます。CLI開発における重要な概念と、実務で活用できる高度なテクニックを学びます。

Cobraライブラリの内部アーキテクチャ

Commandパターンの実装

Cobraは、GoF(Gang of Four)デザインパターンの一つであるCommandパターンを採用しています。このパターンは、操作をオブジェクトとしてカプセル化し、異なる操作を統一的に扱うことを可能にします。

// cobra.Commandの構造体(簡略版)
type Command struct {
    Use   string              // コマンド名
    Short string              // 短い説明
    Long  string              // 詳細な説明
    Run   func(cmd *Command, args []string)      // 実行関数
    RunE  func(cmd *Command, args []string) error // エラーを返す実行関数

    commands []*Command       // サブコマンドのリスト
    parent   *Command         // 親コマンド
    flags    *flag.FlagSet    // コマンド固有のフラグ
}

Commandパターンの利点

  • 拡張性: 新しいコマンドを簡単に追加できる
  • 再利用性: コマンドを組み合わせて複雑な操作を構築
  • テスト容易性: 各コマンドを独立してテストできる
  • 統一的なインターフェース: 全てのコマンドが同じ構造を持つ

// コマンドの追加例
rootCmd.AddCommand(listCmd)    // rootCmdにlistCmdを追加
rootCmd.AddCommand(searchCmd)  // rootCmdにsearchCmdを追加

// 実行時、Cobraは以下のようにコマンドツリーを構築
// root
//   ├── list
//   ├── search
//   ├── stats
//   ├── watch
//   └── compress

コマンド実行のライフサイクル

Cobraがコマンドを実行する際の内部フローを理解することは、デバッグとカスタマイズに役立ちます。

1. rootCmd.Execute() 呼び出し
   ↓
2. コマンドライン引数の解析
   ↓
3. コマンドツリーの探索(最も深いマッチを探す)
   ↓
4. PersistentPreRun/PreRun の実行
   ↓
5. フラグの検証とバインディング
   ↓
6. Run/RunE の実行
   ↓
7. PostRun/PersistentPostRun の実行
   ↓
8. エラーハンドリング

各フックの使い分け

var rootCmd = &cobra.Command{
    Use: "mytool",

    // 全てのサブコマンドの前に実行(親から子へ継承)
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
        // ログ設定、設定ファイル読み込みなど
        initLogging()
    },

    // このコマンドの前に実行
    PreRun: func(cmd *cobra.Command, args []string) {
        // コマンド固有の前処理
    },

    // メインの実行関数
    RunE: func(cmd *cobra.Command, args []string) error {
        // コマンドのロジック
        return nil
    },

    // このコマンドの後に実行
    PostRun: func(cmd *cobra.Command, args []string) {
        // クリーンアップなど
    },

    // 全てのサブコマンドの後に実行
    PersistentPostRun: func(cmd *cobra.Command, args []string) {
        // グローバルなクリーンアップ
    },
}

引数バリデーション

Cobraは、組み込みのバリデータを提供しており、コマンド引数の検証を簡単に行えます。

// 組み込みバリデータ
cobra.NoArgs          // 引数を受け付けない
cobra.ExactArgs(n)    // 正確にn個の引数を要求
cobra.MinimumNArgs(n) // 最低n個の引数を要求
cobra.MaximumNArgs(n) // 最大n個の引数を受け付ける
cobra.RangeArgs(min, max) // min〜max個の引数を受け付ける

// カスタムバリデータ
var deployCmd = &cobra.Command{
    Use: "deploy <environment>",
    Args: func(cmd *cobra.Command, args []string) error {
        if len(args) != 1 {
            return errors.New("requires exactly one environment argument")
        }
        validEnvs := []string{"dev", "staging", "production"}
        env := args[0]
        for _, valid := range validEnvs {
            if env == valid {
                return nil
            }
        }
        return fmt.Errorf("invalid environment '%s'. Must be one of: %v", env, validEnvs)
    },
    RunE: runDeploy,
}

サブコマンド設計の階層構造

浅い階層 vs 深い階層

CLIツールの設計において、コマンド階層の深さは重要な設計判断です。

浅い階層の例(Docker CLI)

docker run nginx
docker ps
docker logs container-id
docker stop container-id

深い階層の例(Kubectl)

kubectl get pods
kubectl create deployment my-app
kubectl scale deployment my-app --replicas=3

中間的なアプローチ(Git)

git commit -m "message"        # 直接コマンド
git remote add origin url      # サブコマンド

階層設計の原則

  • 関連する操作をグループ化
// リソース管理のグループ化
rootCmd.AddCommand(createCmd)
rootCmd.AddCommand(deleteCmd)
rootCmd.AddCommand(listCmd)

// または
resourceCmd := &cobra.Command{Use: "resource"}
resourceCmd.AddCommand(createCmd)
resourceCmd.AddCommand(deleteCmd)
resourceCmd.AddCommand(listCmd)
rootCmd.AddCommand(resourceCmd)

  • 頻繁に使うコマンドは浅く
// 頻繁に使うコマンドはトップレベルに
rootCmd.AddCommand(statusCmd)  // mytool status

// あまり使わないコマンドはサブコマンドに
configCmd.AddCommand(setCmd)   // mytool config set key value

  • 名詞-動詞の一貫性
// 名詞が先(Kubectl スタイル)
kubectl get pods
kubectl delete pod my-pod

// 動詞が先(Git スタイル)
git add file.txt
git commit -m "message"

// 一貫性を保つことが重要

実装例:複雑な階層構造

// cmd/resource.go
var resourceCmd = &cobra.Command{
    Use:   "resource",
    Short: "Manage resources",
}

var resourceCreateCmd = &cobra.Command{
    Use:   "create <name>",
    Short: "Create a new resource",
    Args:  cobra.ExactArgs(1),
    RunE:  runResourceCreate,
}

var resourceDeleteCmd = &cobra.Command{
    Use:   "delete <name>",
    Short: "Delete a resource",
    Args:  cobra.ExactArgs(1),
    RunE:  runResourceDelete,
}

var resourceListCmd = &cobra.Command{
    Use:   "list",
    Short: "List all resources",
    RunE:  runResourceList,
}

func init() {
    // サブコマンドの登録
    resourceCmd.AddCommand(resourceCreateCmd)
    resourceCmd.AddCommand(resourceDeleteCmd)
    resourceCmd.AddCommand(resourceListCmd)

    // ルートコマンドに登録
    rootCmd.AddCommand(resourceCmd)
}

// 使用例:
// mytool resource create my-resource
// mytool resource delete my-resource
// mytool resource list

設定の優先順位システム

優先順位の実装

CLIツールでは、設定値を複数のソースから読み込む必要があります。Viperを使うと、以下の優先順位が自動的に適用されます:

1. フラグ(最優先)
2. 環境変数
3. 設定ファイル
4. デフォルト値(最低優先)

実装例

package config

import (
    "github.com/spf13/viper"
    "github.com/spf13/pflag"
)

// Config は全ての設定を保持する構造体
type Config struct {
    APIKey    string
    Region    string
    Timeout   int
    Verbose   bool
    LogLevel  string
}

// Load は全ての設定ソースから設定を読み込む
func Load(flags *pflag.FlagSet) (*Config, error) {
    // 1. デフォルト値の設定
    viper.SetDefault("region", "us-east-1")
    viper.SetDefault("timeout", 30)
    viper.SetDefault("verbose", false)
    viper.SetDefault("log_level", "info")

    // 2. 設定ファイルの読み込み
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.mytool")
    viper.AddConfigPath(".")

    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, err
        }
        // 設定ファイルが見つからない場合は無視
    }

    // 3. 環境変数のバインド
    viper.SetEnvPrefix("MYTOOL")
    viper.AutomaticEnv()

    // 4. フラグのバインド(最優先)
    viper.BindPFlags(flags)

    // 5. 設定の構造体へのマッピング
    config := &Config{
        APIKey:   viper.GetString("api_key"),
        Region:   viper.GetString("region"),
        Timeout:  viper.GetInt("timeout"),
        Verbose:  viper.GetBool("verbose"),
        LogLevel: viper.GetString("log_level"),
    }

    return config, nil
}

設定値の取得パターン

パターン1:直接取得

apiKey := viper.GetString("api_key")
timeout := viper.GetInt("timeout")

パターン2:構造体へのアンマーシャル

type ServerConfig struct {
    Host string `mapstructure:"host"`
    Port int    `mapstructure:"port"`
}

var serverConfig ServerConfig
err := viper.UnmarshalKey("server", &serverConfig)

パターン3:全設定の一括取得

type AppConfig struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    Logging  LoggingConfig  `mapstructure:"logging"`
}

var config AppConfig
err := viper.Unmarshal(&config)

設定ファイルの複数環境対応

func loadConfigForEnv(env string) error {
    // 環境ごとの設定ファイル名
    viper.SetConfigName(fmt.Sprintf("config.%s", env))
    viper.AddConfigPath("./configs")

    if err := viper.ReadInConfig(); err != nil {
        return err
    }

    // ベース設定とマージ
    viper.SetConfigName("config")
    return viper.MergeInConfig()
}

// 使用例
// config.yaml (ベース設定)
// config.development.yaml (開発環境)
// config.production.yaml (本番環境)

機密情報の安全な管理

// 環境変数から機密情報を読み込む
func loadSecrets() error {
    // API キー
    apiKey := os.Getenv("MYTOOL_API_KEY")
    if apiKey == "" {
        return errors.New("API key not set. Please set MYTOOL_API_KEY environment variable")
    }

    // または、専用の秘密管理ツールから取得
    secret, err := loadFromVault("secret/mytool/api-key")
    if err != nil {
        return err
    }

    viper.Set("api_key", secret)
    return nil
}

// 設定ファイルには機密情報を含めない
// config.yaml
// api_key: ${MYTOOL_API_KEY}  # プレースホルダー

// 起動時に環境変数で置き換え
func expandEnvVars() {
    for _, key := range viper.AllKeys() {
        value := viper.GetString(key)
        viper.Set(key, os.ExpandEnv(value))
    }
}

クロスプラットフォーム対応のポイント

ファイルパスの扱い

問題:OSによってパスセパレータが異なる

  • Unix/Linux/macOS: /
  • Windows: \
  • 解決策path/filepathパッケージを使用

    import "path/filepath"
    
    // 悪い例
    configPath := home + "/.config/mytool/config.yaml"  // Windowsで動かない
    
    // 良い例
    configPath := filepath.Join(home, ".config", "mytool", "config.yaml")
    // Unix: /home/user/.config/mytool/config.yaml
    // Windows: C:\Users\user\.config\mytool\config.yaml
    

    ホームディレクトリの取得

    // Go 1.18以降
    home, err := os.UserHomeDir()
    if err != nil {
        return err
    }
    configPath := filepath.Join(home, ".mytool", "config.yaml")
    
    // 環境変数からの取得(古い方法)
    var home string
    if runtime.GOOS == "windows" {
        home = os.Getenv("USERPROFILE")
    } else {
        home = os.Getenv("HOME")
    }
    

    OS固有のコードの分離

    ビルドタグを使用した条件コンパイル

    // config_unix.go
    //go:build !windows
    
    package config
    
    import "path/filepath"
    
    func defaultConfigPath() string {
        home, _ := os.UserHomeDir()
        return filepath.Join(home, ".config", "mytool", "config.yaml")
    }
    
    func defaultCachePath() string {
        home, _ := os.UserHomeDir()
        return filepath.Join(home, ".cache", "mytool")
    }
    

    // config_windows.go
    //go:build windows
    
    package config
    
    import (
        "os"
        "path/filepath"
    )
    
    func defaultConfigPath() string {
        appData := os.Getenv("APPDATA")
        return filepath.Join(appData, "mytool", "config.yaml")
    }
    
    func defaultCachePath() string {
        localAppData := os.Getenv("LOCALAPPDATA")
        return filepath.Join(localAppData, "mytool", "cache")
    }
    

    実行ファイルのパスを取得

    import (
        "os"
        "path/filepath"
    )
    
    func getExecutablePath() (string, error) {
        exe, err := os.Executable()
        if err != nil {
            return "", err
        }
    
        // シンボリックリンクを解決
        realPath, err := filepath.EvalSymlinks(exe)
        if err != nil {
            return "", err
        }
    
        // ディレクトリを取得
        dir := filepath.Dir(realPath)
        return dir, nil
    }
    
    // 使用例:実行ファイルと同じディレクトリの設定を読み込む
    exeDir, _ := getExecutablePath()
    configPath := filepath.Join(exeDir, "config.yaml")
    

    改行コードの扱い

    import (
        "bufio"
        "strings"
    )
    
    // テキストファイルを読み込む際、改行コードを正規化
    func readLines(filename string) ([]string, error) {
        file, err := os.Open(filename)
        if err != nil {
            return nil, err
        }
        defer file.Close()
    
        var lines []string
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
            line := scanner.Text()
            // 改行コードを削除(\r\nも\nも対応)
            line = strings.TrimRight(line, "\r\n")
            lines = append(lines, line)
        }
    
        return lines, scanner.Err()
    }
    

    プロセス実行の違い

    import (
        "os/exec"
        "runtime"
    )
    
    func openBrowser(url string) error {
        var cmd *exec.Cmd
    
        switch runtime.GOOS {
        case "windows":
            cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
        case "darwin":
            cmd = exec.Command("open", url)
        default: // Linux, BSD, etc.
            cmd = exec.Command("xdg-open", url)
        }
    
        return cmd.Start()
    }
    

    カラー出力の対応

    import (
        "github.com/fatih/color"
        "github.com/mattn/go-isatty"
        "os"
    )
    
    func initColors() {
        // Windowsのカラー出力を有効化
        if runtime.GOOS == "windows" {
            color.NoColor = false
        }
    
        // パイプやリダイレクト時はカラーを無効化
        if !isatty.IsTerminal(os.Stdout.Fd()) {
            color.NoColor = true
        }
    }
    

    テンポラリディレクトリの取得

    import (
        "os"
        "path/filepath"
    )
    
    // OS固有のテンポラリディレクトリを取得
    tempDir := os.TempDir()
    // Unix: /tmp
    // macOS: /var/folders/...
    // Windows: C:\Users\user\AppData\Local\Temp
    
    // アプリ専用のテンポラリディレクトリを作成
    appTempDir, err := os.MkdirTemp("", "mytool-*")
    // Unix: /tmp/mytool-123456
    // Windows: C:\Users\user\AppData\Local\Temp\mytool-123456
    

    並行処理とパフォーマンス最適化

    Goroutineによる並行処理

    基本パターン

    func processFilesSequential(files []string) error {
        for _, file := range files {
            if err := process(file); err != nil {
                return err
            }
        }
        return nil
    }
    
    func processFilesConcurrent(files []string) error {
        var wg sync.WaitGroup
        errChan := make(chan error, len(files))
    
        for _, file := range files {
            wg.Add(1)
            go func(f string) {
                defer wg.Done()
                if err := process(f); err != nil {
                    errChan <- err
                }
            }(file)
        }
    
        wg.Wait()
        close(errChan)
    
        // 最初のエラーを返す
        select {
        case err := <-errChan:
            return err
        default:
            return nil
        }
    }
    

    セマフォによる並行数制限

    func processFilesWithLimit(files []string, maxConcurrent int) error {
        sem := make(chan struct{}, maxConcurrent)
        var wg sync.WaitGroup
        errChan := make(chan error, len(files))
    
        for _, file := range files {
            wg.Add(1)
            sem <- struct{}{} // セマフォを取得
    
            go func(f string) {
                defer wg.Done()
                defer func() { <-sem }() // セマフォを解放
    
                if err := process(f); err != nil {
                    errChan <- err
                }
            }(file)
        }
    
        wg.Wait()
        close(errChan)
    
        select {
        case err := <-errChan:
            return err
        default:
            return nil
        }
    }
    

    ワーカープールパターン

    type Job struct {
        ID   int
        Path string
    }
    
    type Result struct {
        Job   Job
        Error error
    }
    
    func processWithWorkerPool(files []string, numWorkers int) error {
        jobs := make(chan Job, len(files))
        results := make(chan Result, len(files))
    
        // ワーカーを起動
        for w := 1; w <= numWorkers; w++ {
            go worker(w, jobs, results)
        }
    
        // ジョブを送信
        for i, file := range files {
            jobs <- Job{ID: i, Path: file}
        }
        close(jobs)
    
        // 結果を収集
        for range files {
            result := <-results
            if result.Error != nil {
                return result.Error
            }
        }
    
        return nil
    }
    
    func worker(id int, jobs <-chan Job, results chan<- Result) {
        for job := range jobs {
            err := process(job.Path)
            results <- Result{Job: job, Error: err}
        }
    }
    

    コンテキストによるキャンセル処理

    import (
        "context"
        "time"
    )
    
    func processWithTimeout(files []string, timeout time.Duration) error {
        ctx, cancel := context.WithTimeout(context.Background(), timeout)
        defer cancel()
    
        var wg sync.WaitGroup
        errChan := make(chan error, 1)
    
        for _, file := range files {
            wg.Add(1)
            go func(f string) {
                defer wg.Done()
    
                select {
                case <-ctx.Done():
                    errChan <- ctx.Err()
                    return
                default:
                    if err := process(f); err != nil {
                        errChan <- err
                        cancel() // エラー発生時に他の処理もキャンセル
                    }
                }
            }(file)
        }
    
        wg.Wait()
        close(errChan)
    
        select {
        case err := <-errChan:
            return err
        default:
            return nil
        }
    }
    

    バッファリングによる最適化

    import (
        "bufio"
        "os"
    )
    
    // 大きなファイルの読み込み
    func processLargeFile(filename string) error {
        file, err := os.Open(filename)
        if err != nil {
            return err
        }
        defer file.Close()
    
        // バッファサイズを調整(デフォルトは4096バイト)
        reader := bufio.NewReaderSize(file, 64*1024) // 64KB
    
        for {
            line, err := reader.ReadString('\n')
            if err == io.EOF {
                break
            }
            if err != nil {
                return err
            }
    
            processLine(line)
        }
    
        return nil
    }
    

    エラーハンドリングのベストプラクティス

    エラーのラップと追跡

    import (
        "fmt"
        "errors"
    )
    
    // カスタムエラー型
    type FileProcessError struct {
        Path string
        Op   string
        Err  error
    }
    
    func (e *FileProcessError) Error() string {
        return fmt.Sprintf("failed to %s file %s: %v", e.Op, e.Path, e.Err)
    }
    
    func (e *FileProcessError) Unwrap() error {
        return e.Err
    }
    
    // エラーのラップ
    func processFile(path string) error {
        data, err := os.ReadFile(path)
        if err != nil {
            return &FileProcessError{
                Path: path,
                Op:   "read",
                Err:  err,
            }
        }
    
        if err := validate(data); err != nil {
            return &FileProcessError{
                Path: path,
                Op:   "validate",
                Err:  err,
            }
        }
    
        return nil
    }
    
    // エラーの判定
    func handleError(err error) {
        var fpErr *FileProcessError
        if errors.As(err, &fpErr) {
            fmt.Printf("File operation failed: %s\n", fpErr.Path)
        }
    
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("File does not exist")
        }
    }
    

    複数エラーの収集

    type MultiError struct {
        Errors []error
    }
    
    func (m *MultiError) Error() string {
        if len(m.Errors) == 0 {
            return "no errors"
        }
        if len(m.Errors) == 1 {
            return m.Errors[0].Error()
        }
    
        var sb strings.Builder
        sb.WriteString(fmt.Sprintf("%d errors occurred:\n", len(m.Errors)))
        for i, err := range m.Errors {
            sb.WriteString(fmt.Sprintf("  %d. %v\n", i+1, err))
        }
        return sb.String()
    }
    
    func (m *MultiError) Add(err error) {
        if err != nil {
            m.Errors = append(m.Errors, err)
        }
    }
    
    func (m *MultiError) HasErrors() bool {
        return len(m.Errors) > 0
    }
    
    // 使用例
    func processMultipleFiles(files []string) error {
        var merr MultiError
    
        for _, file := range files {
            if err := processFile(file); err != nil {
                merr.Add(err)
            }
        }
    
        if merr.HasErrors() {
            return &merr
        }
        return nil
    }
    

    発展的学習への道筋

    1. TUI(Terminal User Interface)開発

    CLIの次のステップとして、より洗練されたTUIの開発があります。

    Bubble Teaライブラリ

    import (
        tea "github.com/charmbracelet/bubbletea"
        "github.com/charmbracelet/lipgloss"
    )
    
    type model struct {
        choices  []string
        cursor   int
        selected map[int]struct{}
    }
    
    func (m model) Init() tea.Cmd {
        return nil
    }
    
    func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tea.KeyMsg:
            switch msg.String() {
            case "ctrl+c", "q":
                return m, tea.Quit
            case "up", "k":
                if m.cursor > 0 {
                    m.cursor--
                }
            case "down", "j":
                if m.cursor < len(m.choices)-1 {
                    m.cursor++
                }
            case " ":
                _, ok := m.selected[m.cursor]
                if ok {
                    delete(m.selected, m.cursor)
                } else {
                    m.selected[m.cursor] = struct{}{}
                }
            }
        }
        return m, nil
    }
    
    func (m model) View() string {
        s := "Select items:\n\n"
    
        for i, choice := range m.choices {
            cursor := " "
            if m.cursor == i {
                cursor = ">"
            }
    
            checked := " "
            if _, ok := m.selected[i]; ok {
                checked = "x"
            }
    
            s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
        }
    
        s += "\nPress q to quit.\n"
        return s
    }
    
    func runTUI() {
        p := tea.NewProgram(model{
            choices:  []string{"Option 1", "Option 2", "Option 3"},
            selected: make(map[int]struct{}),
        })
    
        if err := p.Start(); err != nil {
            fmt.Printf("Error: %v", err)
            os.Exit(1)
        }
    }
    

    2. プラグインシステムの実装

    拡張可能なCLIツールにするため、プラグインシステムを実装できます。

    Go Pluginを使用した方法

    // plugin/interface.go
    package plugin
    
    type Plugin interface {
        Name() string
        Execute(args []string) error
    }
    
    // plugin_loader.go
    func LoadPlugin(path string) (Plugin, error) {
        p, err := plugin.Open(path)
        if err != nil {
            return nil, err
        }
    
        symPlugin, err := p.Lookup("Plugin")
        if err != nil {
            return nil, err
        }
    
        plugin, ok := symPlugin.(Plugin)
        if !ok {
            return nil, errors.New("invalid plugin")
        }
    
        return plugin, nil
    }
    
    // 使用例
    plugins, _ := filepath.Glob("plugins/*.so")
    for _, pluginPath := range plugins {
        p, err := LoadPlugin(pluginPath)
        if err != nil {
            continue
        }
    
        cmd := &cobra.Command{
            Use: p.Name(),
            RunE: func(cmd *cobra.Command, args []string) error {
                return p.Execute(args)
            },
        }
        rootCmd.AddCommand(cmd)
    }
    

    3. バイナリ配布戦略

    GoReleaserを使った自動リリース:

    # .goreleaser.yml
    before:
      hooks:
        - go mod tidy
        - go generate ./...
    
    builds:
      - env:
          - CGO_ENABLED=0
        goos:
          - linux
          - windows
          - darwin
        goarch:
          - amd64
          - arm64
        ldflags:
          - -s -w
          - -X main.version={{.Version}}
          - -X main.commit={{.Commit}}
          - -X main.date={{.Date}}
    
    archives:
      - format: tar.gz
        name_template: >-
          {{ .ProjectName }}_
          {{- .Version }}_
          {{- .Os }}_
          {{- .Arch }}
        format_overrides:
          - goos: windows
            format: zip
    
    checksum:
      name_template: 'checksums.txt'
    
    snapshot:
      name_template: "{{ incpatch .Version }}-next"
    
    changelog:
      sort: asc
      filters:
        exclude:
          - '^docs:'
          - '^test:'
    
    brews:
      - name: mytool
        tap:
          owner: myuser
          name: homebrew-tap
        homepage: "https://github.com/myuser/mytool"
        description: "My awesome CLI tool"
    

    4. シェル補完の実装

    // コマンドの補完を生成
    var completionCmd = &cobra.Command{
        Use:   "completion [bash|zsh|fish|powershell]",
        Short: "Generate completion script",
        Long: `To load completions:
    
    Bash:
      $ source <(mytool completion bash)
      # To load completions for each session, execute once:
      # Linux:
      $ mytool completion bash > /etc/bash_completion.d/mytool
      # macOS:
      $ mytool completion bash > /usr/local/etc/bash_completion.d/mytool
    
    Zsh:
      $ source <(mytool completion zsh)
      # To load completions for each session, execute once:
      $ mytool completion zsh > "${fpath[1]}/_mytool"
    
    Fish:
      $ mytool completion fish | source
      # To load completions for each session, execute once:
      $ mytool completion fish > ~/.config/fish/completions/mytool.fish
    
    PowerShell:
      PS> mytool completion powershell | Out-String | Invoke-Expression
      # To load completions for every session, add the output to your profile.
    `,
        DisableFlagsInUseLine: true,
        ValidArgs:             []string{"bash", "zsh", "fish", "powershell"},
        Args:                  cobra.ExactValidArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            switch args[0] {
            case "bash":
                return cmd.Root().GenBashCompletion(os.Stdout)
            case "zsh":
                return cmd.Root().GenZshCompletion(os.Stdout)
            case "fish":
                return cmd.Root().GenFishCompletion(os.Stdout, true)
            case "powershell":
                return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
            }
            return nil
        },
    }
    

    5. メトリクスとテレメトリ

    import (
        "github.com/prometheus/client_golang/prometheus"
        "github.com/prometheus/client_golang/prometheus/promauto"
    )
    
    var (
        commandExecutions = promauto.NewCounterVec(
            prometheus.CounterOpts{
                Name: "mytool_command_executions_total",
                Help: "Total number of command executions",
            },
            []string{"command"},
        )
    
        commandDuration = promauto.NewHistogramVec(
            prometheus.HistogramOpts{
                Name: "mytool_command_duration_seconds",
                Help: "Command execution duration in seconds",
            },
            []string{"command"},
        )
    )
    
    func trackCommand(cmdName string) func() {
        start := time.Now()
        commandExecutions.WithLabelValues(cmdName).Inc()
    
        return func() {
            duration := time.Since(start).Seconds()
            commandDuration.WithLabelValues(cmdName).Observe(duration)
        }
    }
    
    // 使用例
    func runDeploy(cmd *cobra.Command, args []string) error {
        defer trackCommand("deploy")()
    
        // デプロイ処理
        return nil
    }
    

    まとめ

    本解説では、以下のトピックをカバーしました:

  • Cobraのアーキテクチャ: Commandパターン、実行ライフサイクル、引数バリデーション
  • サブコマンド設計: 階層構造の設計原則と実装パターン
  • 設定管理: Viperによる多層設定システムと優先順位
  • クロスプラットフォーム対応: OS固有の処理の分離と抽象化
  • 並行処理: Goroutine、セマフォ、ワーカープール
  • エラーハンドリング: カスタムエラー、エラーのラップ、複数エラーの収集
  • 発展的学習: TUI開発、プラグインシステム、配布戦略、シェル補完、メトリクス

これらの知識を活用することで、プロダクショングレードのCLIツールを開発できます。継続的な学習とOSSへの貢献を通じて、さらなるスキル向上を目指しましょう。