s3selectを実行するGo製CLI を作ってみての気づきとか
背景・モチベーション
ALBのアクセスログとかをクエリしたいときがあります。例えばELB5xxエラーが発生していて、そのリクエストの詳細を知りたい時などです。しかも複数ファイルに対して、一括でクエリを投げられるとなお嬉しい。
1日待てばAthenaに放り込まれるが待っていられないぞーという自分がいたりします。
そこで、自分の要件に沿ったs3select
のCLIを勉強がてら作ってみました。作る上で参考にしていたのは、GitHub CLIとAWS LoadBalancer Controller、Copilot CLIあたりです。
気づきをこの記事でまとめて、後々の自分に役立ててもらおうと思います。
リポジトリはこちらになります。
CLIについて
cobra を使う前提です
エラーハンドリングについて
cobraの SilenceErrors
と SilenceUsage
をtrue
にして、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を利用して叩くAWSのAPIをテストする場合についてです。スタブを用意しても良いのかもしれませんが、間接出力も合わせて検証したいのでモックを利用します。モック/スタブの定義は テストダブル - Wikipedia を参考にしています。
Goにおけるモックライブラリでよく使われていそうなのは、golang/mockもしくはstretchr/testifyのmock
パッケージという印象があります。今回、自分は前者を利用しました。
自分で定義した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で定義したテストにはこの引数を指定しています。
ビルドタスクについて
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のパッケージマネージャからも扱えるように出来たりするのも良い点ではと思います。
雑多
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" ] }
VSCodeのデバッグを行う上でちゃんと launch.json 使う
めっちゃ当たり前!なんだけど出来ていなかった。リクエストを受け付けるアプリケーションやシンプルなものだと、なんとなくでデバッグ出来てしまっていた。
CLIとかだと引数によって挙動が大きく変わるので、launch.json
のargs
とかによって複数設定を管理しておくと楽ということに気づけた。
感想
作ってみたアプリケーション自体は、テストのカバレッジがまだまだ低かったりエラーハンドリングまだまだだったりするので、勉強がてらちまちま書いていこう
以上!