Cobraを使ったCLI開発の新しい定番 ~ベストプラクティスと実践ガイド~

この記事は Akerunのカレンダー | Advent Calendar 2023 - Qiita 15日目の記事です。

こんにちは、住宅開発チームの島田です。

はじめに

今回は、Go言語でCLI(Command Line Interface)を実装した際の課題とそれに対するアプローチを紹介したいと思います。

CLIで実現したい事は、以下の内容になります。

  • 管理者ユーザをDBに登録する
  • CLIのパラメーターで登録するメールアドレスを受ける
  • 登録ができたら、登録したメールアドレスにメールを送信する

パッケージを利用しないパターン

Go言語にはCLIを実装するための便利なパッケージがいくつかありますが、それらを利用しないでまずは実装をしてみました。

batch/create_admin_user/main.go

// 以下のコードサンプルは概念的なアイディアを示すものであり、
// この例では実際に動作するコードではありません。
package cmd

import (
    "errors"
    "flag"
    "os"

  ...

    "gorm.io/gorm"
)

var (
    mailAddress string
)

func init() {
    flag.StringVar(&mailAddress, "mail", "", "登録するメールアドレス")
}

func main() {
    err := createAdminUser()
    if err != nil {
        os.Exit(1)
    }

    err = middleware.NewMailMiddleware().SendMail(mailAddress)
    if err != nil {
        os.Exit(1)
    }
}

func createAdminUser() error {
    flag.Parse()
    err := validateArgs(mailAddress)
    if err != nil {
        return err
    }

    con := db.NewDB(&gorm.Config{}, "default")
    defer db.CloseDB(con)

    adminUser := &repository.AdminUser{
        Mail: mailAddress,
    }

    return con.Model(&repository.AdminUser{}).Create(adminUser).Error
}

func validateArgs(mailAddress string) error {
    if mailAddress == "" {
        return errors.New("mailAddress is required")
    }

    return nil
}

上記のコードは要件を満たしています。

しかし、CLIが増えた場合に以下のような課題を孕んでいます。

  • 同じような実装が散在する(DBのコネクション処理や引数のバリデーションなど)
  • エラー終了などのハンドリングなどの統一を強制できない
  • テストコードでmiddlewareをmockにしたい場合に実装の外部から置き換えることができない

これらの課題を解消するために、CLIの実装に対して統一的なアプローチを強制できるように、CLIパッケージの導入しました。

CLIパッケージの選定

Go言語で代表的なCLIパッケージである以下の2つを比較しました。

どちらも、

  • インタフェースがシンプルで、簡単に導入できる
  • 導入事例が多くある
  • ドキュメントを含めて、メンテナンスが継続的にされている

でした。

現時点では、Cobra の方がやや導入事例が多かったのと、urfave/cli のv3 alphaがリリースされており今後の変更が考えられたので、Cobraを採用しました。

Cobraを利用したパターン1

Cobraを利用して、先ほどのCLIを変更します。

構成は以下のようになります。

batch
├── cmd
│   ├── adminUser.go
│   └── root.go
└── main.go

main.gocmd/root.go は、 $ cobra-cli initで生成したままの構成です。

batch/main.go

package main

import "github.com/Photosynth-inc/example.git/cmd"

func main() {
    cmd.Execute()
}

batch/cmd/root.go

package cmd

import (
    "os"

    "github.com/spf13/cobra"
)

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

func Execute() {
    err := rootCmd.Execute()
    if err != nil {
        os.Exit(1)
    }
}

batch/cmd/admin_user.go

以下のコードが先ほどのCLICobraで置き換えたものになります。

package cmd

import (
    "github.com/Photosynth-inc/example.git/pkg/db"
    "github.com/Photosynth-inc/example.git/pkg/middleware"
    "github.com/Photosynth-inc/example.git/pkg/repository"
    "github.com/spf13/cobra"
    "gorm.io/gorm"
)

var adminUserCmd = &cobra.Command{
    Use:  "adminUser",
    RunE: func(cmd *cobra.Command, args []string) error {
        mailAddress, _ := cmd.Flags().GetString("mail")
        err := createAdminUser(mailAddress)
        if err != nil {
            return err
        }

        err = middleware.NewMailMiddleware().SendMail(mailAddress)
        if err != nil {
            return err
        }
        return nil
    },
}

func init() {
    rootCmd.AddCommand(adminUserCmd)

    adminUserCmd.Flags().String("mail", "", "mail address.")
    adminUserCmd.MarkFlagRequired("mail")
}

func createAdminUser(mailAddress string) error {
    con := db.NewDB(&gorm.Config{}, "default")
    defer db.CloseDB(con)

    adminUser := &repository.AdminUser{
        Mail: mailAddress,
    }

    return con.Model(&repository.AdminUser{}).Create(adminUser).Error
}

Cobraで置き換えたことによって、

  • CLIの実装を統一したインタフェースで実装することが出来る
  • 引数のバリデーションをシンプルにできる

といった事が享受できます。

しかし依然として、残ったままの課題があります。

今度はさらにリファクタリングをすすめて課題を解消したいと思います。

Cobraを利用したパターン2

batch/main.go

異常終了時の戻り値のハンドリングを元々は cmd/root.go でしていまたが、エラー発生時に os.Exit をしてしまうと、 defer が実行されないため main.go に移動しました。

package main

import (
    "os"

    "github.com/Photosynth-inc/example.git/cmd"
)

func main() {
    err := cmd.Execute()
    if err != nil {
        os.Exit(1)
    }
}

batch/cmd/root.go

  • 通化してサブコマンドで個別に実装しないようにした点
    • PersistentPreRun , PersistentPostRun に実行時のコマンド名を出力
    • データベース接続の初期化処理
  • ログに一連のコマンド実行時のログだとわかるように、一意のIDをContextに設定してコマンドの実行時に渡す
package cmd

import (
    "context"

    "github.com/Photosynth-inc/example.git/pkg/db"
    "github.com/Photosynth-inc/example.git/pkg/config"
    "github.com/google/uuid"
    "github.com/spf13/cobra"
    "gorm.io/gorm"
)

var rootCmd = &cobra.Command{
    Use:   "cmd",
    PersistentPreRun: func(cmd *cobra.Command, args []string) {
        fmt.Println("start cmd", cmd.Name())
    },
    PersistentPostRun: func(cmd *cobra.Command, args []string) {
        fmt.Println("end cmd", cmd.Name())
    },
}

func Execute() error {
    con := db.NewDB(&gorm.Config{}, "default")

    defer func() {
        d, _ := con.DB()
        d.Close()
    }()

    adminUserBatch := NewAdminUserBatch(con)
    rootCmd.AddCommand(adminUserBatch.NewCommand())

    ctx := context.WithValue(context.Background(), config.CTX_KEY_BATCH_ID, uuid.New().String())
    return rootCmd.ExecuteContext(ctx)
}

batch/cmd/admin_user.go

middleware をテスト実行時にmockに置き換えることが出来るように、 cobra.Command の生成を構造体でラップしました。

package cmd

import (
    "github.com/Photosynth-inc/example.git/pkg/middleware"
    "github.com/Photosynth-inc/example.git/pkg/repository"
    "github.com/spf13/cobra"
    "gorm.io/gorm"
)

type MailMiddleware interface {
    SendMail(to) error
}

type AdminUserBatch struct {
    db *gorm.DB
    mailMiddleware MailMiddleware
}

func NewAdminUserBatch(db * gorm.DB) *AdminUserBatch{
    return &AdminUserBatch{
        db: db,
        mailMiddleware: middleware.NewMailMiddleware(),
    }
}

func (b AdminUserBatch) NewCommand() *cobra.Command {
    cmd := &cobra.Command{
        Use:  "adminUser",
        RunE: func(cmd *cobra.Command, args []string) error {
            mailAddress, _ := cmd.Flags().GetString("mail")
            err := b.createAdminUser(mailAddress)
            if err != nil {
                return err
            }

            err = b.mailMiddleware.SendMail(mailAddress)
            if err != nil {
                return err
            }
            return nil
        },
    }

    cmd.Flags().String("mail", "", "mail address.")
    cmd.MarkFlagRequired("mail")
    return cmd
}

func (b AdminUserBatch) createAdminUser(mailAddress string) error {
    adminUser := &repository.AdminUser{
        Mail: mailAddress,
    }

    return b.db.Model(&repository.AdminUser{}).Create(adminUser).Error
}

batch/cmd/admin_user_test.go

サブコマンドの生成を構造体でラップすることで、以下のようにテストコードでDBの接続先をテストDBにしたり、メール送信をmockにするといった事が可能になりました。

package cmd

import (
    "context"
    "os"
    "testing"

    "github.com/Photosynth-inc/example.git/pkg/mocks"
    "github.com/Photosynth-inc/example.git/pkg/test_util"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestAdminUserBatch(t *testing.T) {
    db := test_util.ConnectTestDB()
    defer test_util.CloseDB(db)

    mockMiddleware := new(mocks.MailMiddleware)
    mockMiddleware.
        On("SendMail", mock.AnythingOfType("string")).
        Return(nil)

    batch := &AdminUserBatch{
        db: db,
        mailMiddleware: mockMiddleware,
    }

    os.Args = append(os.Args, "adminUser")
    os.Args = append(os.Args, "--mail")
    os.Args = append(os.Args, "test@example.com")
    cmd := batch.NewCommand()
    err := cmd.ExecuteContext(context.Background())
    assert.NoError(t, err)
}

以上が、Cobraを利用したCLI実装の課題と改善アプローチの紹介でした。

CobraCLI実装に対して非常に強力なパッケージでありますが、 cobra.Command の生成と実行は柔軟に出来るので、今回紹介した以外にも課題に対するアプローチは他にもあると思いました。

もし他にも良いアプローチがあればフィードバックをいただけると嬉しいです。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp

Akerunにご興味のある方はこちらから akerun.com