1クール続けるブログ

とりあえず1クール続けるソフトウェアエンジニアの備忘録

s3selectを実行するGo製CLI を作ってみての気づきとか

背景・モチベーション

ALBのアクセスログとかをクエリしたいときがあります。例えばELB5xxエラーが発生していて、そのリクエストの詳細を知りたい時などです。しかも複数ファイルに対して、一括でクエリを投げられるとなお嬉しい。
1日待てばAthenaに放り込まれるが待っていられないぞーという自分がいたりします。

そこで、自分の要件に沿ったs3selectCLIを勉強がてら作ってみました。作る上で参考にしていたのは、GitHub CLIAWS LoadBalancer ControllerCopilot CLIあたりです。

気づきをこの記事でまとめて、後々の自分に役立ててもらおうと思います。

リポジトリはこちらになります。

github.com

CLIについて

cobra を使う前提です

エラーハンドリングについて

cobraSilenceErrorsSilenceUsagetrueにして、cmd.ExecuteC() の呼び出し元でSentinel Error方式を利用しエラーの種類によって出力を分岐させるのが良さそう。エラーによっては Usageを出力したりしなかったりとか諸々パターンが有ると思うので、cobraの設定でハンドリングするのが難しいからかもしれない。
CLIであれば、Error interfaceを実装した独自エラー構造体を作りerrors.Asを使って処理を分岐するといったところまで作り込まなくても良さそう。

func NewCmdRoot(f *cli.Factory, version, buildDate string) *cobra.Command {
    cmd := &cobra.Command{
        SilenceErrors: true,
        SilenceUsage:  true,
    }
}

func main() {
    if _, err := rootCmd.ExecuteC(); err != nil {
        if errors.Is(err, terminal.InterruptErr) {
            fmt.Fprint(stderr, "\n")
        }
    }
}

CLIで共通利用する設定はひとまとめにする

ターミナルの入出力設定やCLIが独自の設定ファイルを用意する場合には、cmd.AddCommandの引数に渡す *cobra.command を生成するコンストラクタに共通の設定を定義した構造体を渡してあげるようにするとスッキリする。

type Factory struct {
    In     io.ReadCloser
    Out    io.Writer
    ErrOut io.Writer

    Config     func() (config.Config, error)
    Logger     *zap.Logger
    Executable string
}

func main() {
    cliFactory := cli.NewFactory(buildVersion, logger)
    rootCmd := root.NewCmdRoot(cliFactory, buildVersion, buildDate)
    // ...
}

テストについて

外部APIを利用するテストにはgomockを使う

外部API、今回の文脈ではaws-sdk-goを利用して叩くAWSAPIをテストする場合についてです。スタブを用意しても良いのかもしれませんが、間接出力も合わせて検証したいのでモックを利用します。モック/スタブの定義は テストダブル - Wikipedia を参考にしています。

Goにおけるモックライブラリでよく使われていそうなのは、golang/mockもしくはstretchr/testifymockパッケージという印象があります。今回、自分は前者を利用しました。
自分で定義したinterfaceに、aws-sdk-goで定義されているインターフェース(下記のコードで言うところのs3iface.S3API)をEmbeddingすることで、mockgenが外部APIのモックコードも自動生成してくれます。

//go:generate mockgen -destination=../../../mocks/aws/services/mock_s3.go -package=mock_services github.com/44smkn/s3select/pkg/aws/services S3
type S3 interface {
    s3iface.S3API

    // wrapper to ListObjectsV2PagesWithContext, which aggregates paged results into list.
    ListObjectsV2AsList(ctx context.Context, input *s3.ListObjectsV2Input) ([]*s3.Object, error)
}

設定ファイル読み込み関数はテストはStubを用意するのも一つの手

テスト対象がメソッドかつ間接出力も検証したいという場合にはモックが必要になるかなと思いますが、テスト対象が関数かつ間接出力の検証も必須でない場合にはStubで十分というのがあります。設定ファイルの読み込み処理なんかは、もちろんメソッドに落とし込むことも出来るかと思いますが関数にしがちかと。
テスト対象から呼び出される関数は変数として宣言しておきます。

var ReadConfigFile = func(filename string) ([]byte, error) { ... }

// テスト関数から defer で呼び出す
func stubConfig(t *testing.T, configContents string) func() {
    t.Helper()
    original := config.ReadConfigFile
    config.ReadConfigFile = func(filename string) ([]byte, error) { ... }
    return func() {
        config.ReadConfigFile = original
    }
}

go testするときに -race flagつけるべし

Race Conditionを検出するフラグがgo testにあることを僕は知らなかったです。オライリーから出てるGo言語による並行処理のAppendixに記載ありましたが見逃していました。Go1.1からほぼ全てのgoコマンドに追加されたもので、検出された際には標準エラー出力にログを出力するようになっています。これをCIに組み込むのは有用なので、GitHub Actionで定義したテストにはこの引数を指定しています。

blog.golang.org

ビルドタスクについて

Goで書いても良いんじゃない?

Goではビルドするときに、go build の引数である -ldflagsというgo tool linkコマンドのオプションに渡されるフラグで、-X importpath.name=valueと指定してバージョンはdateを埋め込むことが多いと思います。後はWindowsの場合には、拡張子でexeを付けるとか。
自分はそのようなタスクをシェルスクリプトorMakefileで書くものと思っていましたが、GitHub CLIがgoでビルドタスクを書いてMakefileから呼び出すようにしていました。Windowsでも動きますし有用かもしれません。

.PHONY: bin/gh$(EXE)
bin/gh$(EXE): script/build
   @script/build $@

script/build: script/build.go
  GOOS= GOARCH= GOARM= GOFLAGS= CGO_ENABLED= go build -o $@ $<

goreleaserが便利

goreleaserは有名なので知ってはいましたが、今回始めて使いました。GitHubでのリリースが抽象化されていてあっという間にやりたいことが出来ました。GitHub Actionsとの組み合わせも容易でした。リリースノートもコマンドのオプションで渡せますしね。
自分はやっていないですが、nfpmを利用して、Linuxのパッケージマネージャからも扱えるように出来たりするのも良い点ではと思います。

goreleaser.com

雑多

awssdk利用する上でHandlerは理解しておいたほうが良さそう

今までちょこちょこ aws-sdk-go 使ってきたけど理解が足りなかったところで、aws-sdk-go/aws/request.Handlerにぶら下がるHanlderListをhttpリクエストの対応するフェーズで呼び出しています。

例えば、CloudTrailのレコードにUserAgentが記録されますが、そこにツール名を入れられると後で確認しやすそうです。その場合には、下記のようにHandlerを利用側から追加することが出来ます。
また、各リクエスト毎にもこの設定は可能で、かつHandlerを逆に削除することも可能です。リクエストの可変長引数に request.Optionを渡せるのですが、このOptionの型が func(*Request)なので割と好き勝手に出来てしまいます。

// ハンドラーの追加
func NewCloud(cfg CloudConfig) (Cloud, error) {
    sess := session.Must(session.NewSession(aws.NewConfig().WithRegion(cfg.Region)))
    sess.Handlers.Build.PushFrontNamed(awsrequest.NamedHandler{
        Name: fmt.Sprintf("%s/user-agent", appName),
        Fn:   awsrequest.MakeAddToUserAgentHandler(appName, build.Version),
    })
}

// ハンドラーの削除
func hoge() {
    resp, err := s.cloud.S3().SelectObjectContentWithContext(ctx, input, func(r *awsrequest.Request) {
        r.Handlers.Send.RemoveByName(awsclient.LogHTTPResponseHeaderHandler.Name)
    })
}

renovate入れとく

renovateは依存モジュールのバージョンを最新に保ってくれるためのサービスです。Githubリポジトリにはここから導入することが出来ます。依存しているモジュールがバージョンアップしたら、Pull Requestを発行してくれます。

アップデート後にgo mod tidyを実行するように設定ファイルにpostUpdateOptionsを宣言しておくと良いです。

{
  "postUpdateOptions": [
    "gomodTidy"
  ]
}

docs.renovatebot.com

VSCodeデバッグを行う上でちゃんと launch.json 使う

めっちゃ当たり前!なんだけど出来ていなかった。リクエストを受け付けるアプリケーションやシンプルなものだと、なんとなくでデバッグ出来てしまっていた。
CLIとかだと引数によって挙動が大きく変わるので、launch.jsonargsとかによって複数設定を管理しておくと楽ということに気づけた。

code.visualstudio.com

感想

作ってみたアプリケーション自体は、テストのカバレッジがまだまだ低かったりエラーハンドリングまだまだだったりするので、勉強がてらちまちま書いていこう
以上!