バッチコマンドをテストしやすいようにリファクタリングする

Merpay Advent Calendar 2019 の8日目は、メルペイ backend payment platform チーム の @knsh14 がお送りします。

はじめに

私はメルペイでお客様の残高を管理しているマイクロサービス(以下Balance Service)の開発を担当しています。
Balance Serviceは次のような定常的に動かしているバッチがあります。

  • 有効期限があるポイントの失効処理
  • 会計データの突合
  • 毎日知りたいデータのレポート

Balance Serviceではメルペイリリースに向けて限られたスケジュールの中で開発を進めていました。
サービスとしての実装を優先して開発をしていたため、まずはテストのカバレッジを挙げて動作を担保する方針をとっていました。
結果としてリリースから今まで大きな事故もなく運用できています。
ですが、1日目の記事にもあるように7月からはリリース時に技術的に積み残したことを解消する作業に継続して取り組んでいます。
その取組の1つとして、バッチコマンドをリファクタリングしてよりテストしやすい設計に改善していく過程の話をします。

今までの設計

バッチコマンドを作成するためにBalance Serviceではgithub.com/spf13/cobraを使っています。
cobraはGoでは有名なコマンドラインフレームワークの1つでサブコマンドを作成したり、コマンドラインのフラグを管理したりできる便利なライブラリです。
それぞれのコマンドは*cobra.Command構造体で表現され、処理を実行するメソッドがあります。
これを使って実際にテストコード内で動かしてバッチの実行結果のテストもすることができます。

今まではcobraのコマンドから生成されるスタイルを踏襲して新しいコマンドを作っていました。
次のようなコードです。

package command
var PubSubClient *pubsub.Client
var FooCmd = &cobra.Command{
Use:   "foo",
Short: "foo is ...",
RunE: func(cmd *cobra.Command, args []string) error {
...
err := PubSubClient.Publish(msg)
if err != nil {
return errors.Wrap(err, "publish message")
}
return nil
},
}

これだとシンプルで簡単ですが、いくつか問題点があります。

  1. 生成されたコマンドがすべてcmdディレクトリに生成される。なのでフラグで使うパッケージ変数(maxIDなど)が共有されるのでメンテナンス性が低い
  2. 外部サービスに接続するクライアントなどをパッケージ変数を介して渡しているため、並列にテストを実行すると正しいテストにならない

リファクタリング一歩目: コマンド生成関数を準備する

まず最初に手を付けたのはパッケージ変数としてcobra.Command型の変数を公開するのをやめることです。
代わりにfunc NewXXXCommand()
cobra.Commandというコマンド生成用の関数から取得できるようにしました。

これまでは全てのバッチコマンドが同じパッケージにありました。
それらがフラグで使うための変数や外部へのクライアントを共有しています。
なので、予期していないところで変数が使われていたり変更される場合がありました。
テストコードでクライアントを変更してテストしていると他のテストの動作に影響がでる可能性があり、非常に危険なバランスでテストを書く必要がありました。

NewXXXCommand()を準備することで毎回 *cobra.Command を生成します。
結果として独立したコマンドを実行することができます。
さらにフラグから値を受け取るための変数のように、内部で使う変数を閉じ込める事ができます。

これで最低限テストで独立してコマンドを実行できるようになりました。

リファクタリング二歩目: 設定情報を変更する

更にテストしやすいようにするための工夫をしていきます。
テストではタイムアウトの時間を調整したり、外部への接続をfake実装に切り替える必要があります。
そのために設定情報をコマンドを生成する際に指定する必要があります。

この実装はいくつか方法があるので比較します。

1. Commandを生成するコマンドの引数にすべてを渡す

func NewFooCommand(timeout time.Duration, httpClient http.Client, dbClient database.Client) *cobra.Commandのように変更する必要がある要素を全て引数で渡す方法です。

この方法だと、どの要素を変更できるのかが引数で示すことができます。
ですが、通常のパターンでもコマンドを生成する側が引数として渡す要素を生成する必要があります。
それを生成するためのロジックがコマンドを生成する関数の外にあるので、あまりいい方法ではありません。

2. option構造体を利用する

https://github.com/golangci/golangci-lint などで使われている方法です。
コマンド毎に Options という構造体を定義して、そこに設定情報を指定する方針です。
コードにすると次のようになります。

type Options struct {
Timeout time.Duration
HTTPClient *http.Client
DBClient *db.Client
}
func NewFooCommand(ctx context.Context, opts *Options) *cobra.Command {
if opts == nil {
opts = getDefaultOptions()
}
return &cobra.Command {
...
run: func() {
opts.HTTPClient.Get()
},
}
}

この方法だとFooCommandのための設定情報はすべてOptionsにまとめられます。
今後設定項目を増やす場合もNewFooCommandのシグネチャには一切の変更を加えることなく拡張することができます。

この方法は Sourcegraph のスタイルガイドでも紹介されている方法です。
https://about.sourcegraph.com/handbook/engineering/go_style_guide#options

ただ問題点として、設定する情報が多くなればなるほど、Options構造体のフィールドも増えていきます。
上の例では3つなので読みやすく保たれています。しかし、項目が30や40となった場合に可読性が低くなります。

3. Functional Option Pattern で実装する

最後はFunctional Option Patternで実装する方法です。
先日話題になったUberのコーディングガイドなどでも紹介されているGoらしい方法です。
gRPCのクライアントの設定などでも同じパターンが使われています。

Functional Option Patternでは次のように適用する設定情報の構造体は定義しますが、外部には公開しません。
外部に公開するのは*cobra.Commandを返す関数と、設定情報を適用するためのinterfaceを返す関数です。
次のような実装になります。

type options struct {
Timeout    time.Duration
HTTPClient *http.Client
DBClient   *db.Client
}
type Option interface {
apply(*options)
}
func WithTimeout(d time.Duration) Option {
return TimeoutOption{d: d}
}
type TimeoutOption struct{ d time.Duration }
func (to TimeoutOption) apply(o *options) {
o.Timeout = to.d
}
func NewFooCommand(ctx context.Context, opts ...Option) *cobra.Command {
var minID, maxID uint
opt := &options{
Timeout:    defaultTimeout,
HTTPClient: http.DefaultClient,
DBClient:   defaultDBClient,
}
for _, o := range opts {
o.apply(opt)
}
cmd := &cobra.Command{RunE: func(cmd *cobra.Command, args []string) error {
return process(ctx, opt, args, minID, maxID)
}}
cmd.Flags().Uint64Var(&minID, "min", 1, "minimum id")
cmd.Flags().Uint64Var(&maxID, "max", 100, "maximum id")
return cmd
}

こうすると、設定情報を必要な箇所だけ可変長引数として渡すことができます。
他の設定はデフォルトの値が使用されるので、ゼロ値での動作の検証も容易にできます。
もし本番で利用する場合、何もオプションを渡さなければ全てデフォルトの設定値でコマンドが作られます。

このパターンだと実装コストも多少かかりますが、今後のメンテナンス性などを考えるとベストな方法だと判断し実装しています。

まとめ

マイクロサービスのバッチコマンドに対する技術的負債を返済する方法について説明しました。
リリースした当初のシンプルだけど潜在的なバグがある状態から、モダンで保守性が高い方法へとリファクタリングが進んでいます。
1日目の記事でもあった通り、メルペイでは技術的負債の返済と向き合っていろいろな改善をしています。
自分たちのチームでも新機能開発と技術的負債の返済の両方をうまく進めていく予定です。

明日のMerpay Advent Calendar執筆担当は、SREチームの@kekeさんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加