blog.syfm

徒然なるままに考えていることなどを書いていくブログ

Go の t.Cleanup がとてもべんり

Go 1.14 で testing パッケージに新しく t.Cleanup(func())b.Cleanup(func()) が導入されました。
最初は今まで defer を使っていたところを置き換えられるくらいしか良いところがないかな〜と思っていましたが、想像以上に柔軟な使い方ができるので今まで使用したパターンを書いておきます。

Cleanup の特徴

テストランナーは panic ハンドラがあるので、Cleanup は panic が起きたとしても常に呼び出されます。例えば、以下のコードではちゃんと called が出力されます。

func Test_main(t *testing.T) {
    t.Cleanup(func() {
        fmt.Println("called")
    })
    panic("")
}

The Go Playground

別 goroutine で panic した場合や t.Fatal 系が呼ばれた場合でも常に呼び出されます。

追記:
柴田さんからの指摘により、goroutine のスケジューリングを待っていなかったので呼び出されていたことがわかりました。

以下のコードを実行すると t.Cleanup は呼ばれていないことがわかります。

The Go Playground

ドキュメントにもある通り、Cleanup に登録した関数は defer と同様に FILO で呼び出されていきます。以下のコードだと 3、2、1 の順番となります。

func Test_main(t *testing.T) {
    t.Cleanup(func() {
        fmt.Println("1")
    })
    t.Cleanup(func() {
        fmt.Println("2")
    })
    t.Cleanup(func() {
        fmt.Println("3")
    })
}

The Go Playground

Cleanupdefer では defer のほうが先に呼び出されます。

func Test_main(t *testing.T) {
    t.Cleanup(func() {
        fmt.Println("cleanup")
    })
    defer func() {
        fmt.Println("defer")
    }()
}

The Go Playground

また、伝搬はしないので t.Run によってサブテストを作ったとしても、サブテストの終了時に Cleanup が呼ばれることはありません。

役に立ちそうなパターン

環境変数の切り替え

テスト時に動的に環境変数を与えたい場合、今までは以下のようなコードを書いていました。

func setEnv(t *testing.T, k, v string) func() {
    old := os.Getenv(k)
    if err := os.Setenv(k, v); err != nil {
        t.Fatal(err)
    }
    return func() {
        if err := os.Setenv(k, old); err != nil {
            t.Fatal(err)
        }
    }
}

func Test_main(t *testing.T) {
    unsetEnv := setEnv(t, "foo", "bar")
    defer unsetEnv()
}

Cleanup を使うと、unsetEnv の呼び出しを caller にさせずに元に戻すことができます。

func setEnv(t *testing.T, k, v string) func() {
    old := os.Getenv(k)
    if err := os.Setenv(k, v); err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        if err := os.Setenv(k, old); err != nil {
            t.Fatal(err)
        }
    })
}

func Test_main(t *testing.T) {
    setEnv(t, "foo", "bar")
}

クライアントとサーバの用意

Web API サーバのような、クライアントとサーバが存在する場合は、それらの用意を Cleanup を使って簡潔に書くことができます。 今までだと、例えば HTTP サーバのテストをしたい場合は以下のようなコードを書くことになると思います。

func Test_main(t *testing.T) {
    srv := newServer(t)
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            t.Error(err)
        }
    }()

    cli := newClient(t)

    // Test with client.

    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        t.Fatal(err)
    }
}

func newServer(t *testing.T) *http.Server {
    mux := http.NewServeMux()

    // Register handlers.

    return &http.Server{Addr: ":8080", Handler: mux}
}

func newClient(t *testing.T) *http.Client {
    return http.DefaultClient
}

このコードだと、テスト関数である Test_main 内にテストの setup/teardown ロジックが多く入り込んでしまっていて可読性も良くないし、別なテスト関数で同じようなことをやりたいときにこれらのロジックを再び書くことになってしまいます。

t.Cleanup を使うと以下のように caller 側の setup/teardown ロジックを callee に隠蔽することができます。

func Test_main(t *testing.T) {
    cli := newClientAndRunServer(t)

    // Test with client.
}

func newServer(t *testing.T) *http.Server {
    mux := http.NewServeMux()

    // Register handlers.

    return &http.Server{Addr: ":8080", Handler: mux}
}

func newClientAndRunServer(t *testing.T) *http.Client {
    srv := newServer(t)
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            t.Error(err)
        }
    }()
    t.Cleanup(func() {
        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            t.Fatal(err)
        }
    })

    return http.DefaultClient
}

サーバの起動から停止まで全てを newClientAndRunServer に入れることができるので caller 側はサーバについて何も考えなくても良くなりました。

setup で複数の goroutine を起動したい場合

上記のクライアント・サーバの応用系として、サーバだけでなく非同期で動くワーカー goroutine などが必要である場合も sync.WaitGrouperrgroup.Group を使って簡潔に書くことができます。

func Test_main(t *testing.T) {
    cli := newClientAndRunServer(t)

    // Test with client.
}

func newServer(t *testing.T) *http.Server {
    mux := http.NewServeMux()

    // Register handlers.

    return &http.Server{Addr: ":8080", Handler: mux}
}

func newClientAndRunServer(t *testing.T) *http.Client {
    ctx, cancel := context.WithCancel(context.Background())
    eg, cctx := errgroup.WithContext(ctx)
    t.Cleanup(func() {
        cancel()
        if err := eg.Wait(); err != nil {
            t.Fatal(err)
        }
    })

    srv := newServer(t)
    eg.Go(func() error {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            return err
        }
        return nil
    })
    t.Cleanup(func() {
        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            t.Fatal(err)
        }
    })
    eg.Go(func() error {
        return runWorker1(cctx)
    })
    eg.Go(func() error {
        return runWorker2(cctx)
    })

    return http.DefaultClient
}

べんりですね。

Evans v0.9.0 をリリースしました

汎用 gRPC クライアントである Evans の v0.9.0 をリリースしました。最後にリリースしたのは昨年末だったので、実に 5 ヶ月ぶりとなります。
今回のコードネームは GOLD RUSH です。

このリリースでは、多くの機能追加、バグ修正等があったため、特徴的なものに関してリリースノートを書こうと思います。

REPL モード

call コマンドに --dig-manually フラグを追加

REPL モードでは、入力対象の message について深さ優先探索を行い、プリミティブフィールドが現れると入力用のプロンプトが表示されるような仕様になっていますが、この実装だとネストされた message 自体に値を何もセットしたくない場合 (Go だと nil を設定したい場合) をサポートできていませんでした。 例えば、以下のような Protocol Buffers 定義があり、Request が入力である場合、Evans は自動的にネストされているメッセージのフィールド Foo.bar への入力を求めるため、そもそも Foo を入力したくない場合は --dig-manually フラグなしでは実現できません。--dig-manually フラグが有効になっている場合、Request.foo フィールドを入力するかどうかのプロンプトが表示されるため、入力をスキップすることができます。

message Foo {
  string bar = 1;
}

message Request {
  Foo foo = 1;
}

header コマンドに --raw フラグを追加

header コマンドの value にカンマ区切りで複数の値を同時に指定できますが、その影響により v0.9.0 以前の Evans では、カンマを含めた value を指定することができませんでした。カンマを value に含めてしまうと、それがデリミタとして解釈されてしまい、value が分割されてしまうためです。
今回新しく導入された --raw フラグは、value を "raw" string として扱うためのものです。これを使うことでカンマが含まれていても正常に指定することができるようになります。

f:id:ktr_0731:20200423004246p:plain

コマンドヒストリ

実行したコマンドをシェル環境のようにコマンドヒストリから探し出すことができるようになりました。↑↓キーもしくは Ctrl+PCtrl-N でヒストリを辿ることができます。残念ながら、Ctrl+R (reverse search) や Ctrl+S (forward search) は未サポートです。*1

パッケージが指定されていない Protocol Buffers 定義の許容

v0.9.0 以前の Evans の REPL モードでは対象の gRPC サービスのパッケージ名を指定することが必須になっていましたが、Protocol Buffers ではパッケージ名を省略することができるため、パッケージ名が省略された gRPC 定義を使用することができませんでしたが v0.9.0 では修正されています。

フラグ補完

REPL モードの各コマンドにはフラグを指定することができますが、v0.9.0 ではフラグの補完も有効になります。例えば call コマンドでは以下のように - を入力した時点で補完候補が現れるようになります。

f:id:ktr_0731:20200502181241p:plain

CLI モード

cli コマンドに属するサブコマンドの追加

今回のリリースで導入された大きな機能の一つです。 cli コマンドにサブコマンドが追加されました。今後は cli コマンドに属するサブコマンドを使用してください。詳しくは各サブコマンドのヘルプに使用例が記載されています。

$ echo '{}' | evans -r cli call Unary

以下のような既存のインターフェースは依然として使えますが非推奨となります。v1.0.0 で削除される予定です。

$ echo '{}' | evans -r --cli --call Unary
$ echo '{}' | evans -r cli --call Unary

call コマンドの追加

前述した通りです。

list コマンドの追加

grpc_cligrpcurl に用意されているような、gRPC サーバに対してサービスやメソッドを列挙する機能です。 以下のように使うことができます。

$ evans -r cli list # サービスの列挙
api.Example
grpc.reflection.v1alpha.ServerReflection

$ evans -r cli list api.Example # api.Example サービスに属するメソッドの列挙
api.Example.Unary
...

また、--output (-o) フラグが用意されており、リクエスト・レスポンスの型を含めた詳細を JSON で出力することも可能です。

$ evans -r cli list -o json api.Example.Unary
{
  "name": "Unary",
  "fully_qualified_name": "api.Example.Unary",
  "request_type": "api.SimpleRequest",
  "response_type": "api.SimpleResponse"
}

desc コマンドの追加

grpc_cligrpcurl に用意されているような、gRPC サーバが登録しているサービスやメッセージなどのシンボルの詳細を Protocol Buffers のフォーマットで表示する機能です。 以下のように使うことができます。

$ evans -r cli desc api.Example
api.Example:
service Example {
  rpc Unary ( .api.SimpleRequest ) returns ( .api.SimpleResponse );
  ...
}

$ evans -r cli desc api.Example.Unary
api.Example.Unary:
rpc Unary ( .api.SimpleRequest ) returns ( .api.SimpleResponse );

$ evans -r cli desc api.SimpleRequest
api.SimpleRequest:
message SimpleRequest {
  string name = 1;
}

モード共通

レスポンス表示の強化

今回のリリースで導入された最も大きな機能のもう一つです。v0.9.0 以前では gRPC サービスからのレスポンスのうち、レスポンスコードが OK もしくはそれ以外かの表示、レスポンスボディ、エラーメッセージのみしか表示することができていませんでしたが、新たに ヘッダメタデータ、トレイラーメタデータ、レスポンスコード、エラー詳細 (errdetails など) を表示することができるようになりました。

REPL モードでは call コマンドに --enrich フラグを指定することで有効になります。

f:id:ktr_0731:20200502182325p:plain
正常系の例

f:id:ktr_0731:20200502182541p:plain
異常系の例

CLI モードでも同様に call コマンドに --enrich フラグを指定することで有効になります。

f:id:ktr_0731:20200502182751p:plain

また、CLI モードでは --output (-o) フラグが用意されており、JSON 形式で出力することもできます。

f:id:ktr_0731:20200502182857p:plain

gRPC-Web サービスで正しくヘッダーが送れていない問題の修正

頑張って修正しました。Evans では、gRPC-Web の Improbable 社による実装をサポートしていますが、gRPC-Web は Web ブラウザのみをクライアントとする前提を置いているため JavaScript のクライアント実装しか存在していません。しかし、Evans は pure Go 実装であるため、Evans から gRPC-Web に準拠したリクエストを投げるには Go 実装を作る必要があり、ktr0731/grpc-web-go-client を開発していました。今回の修正は主にこのライブラリの修正となっています。

終わりに

今回の機能追加や変更は v1.0.0 のリリースの前段階といえます。具体的なリリース予定は未定ですが、v1.0.0 では複数の破壊的変更を予定しています。こちらについてはまた別途記事を書ければと思います。

Evans の開発は Issue 報告者、contributor および GitHub Sponsors で支援を頂いている方々から成り立っています。いつもありがとうございます!

*1:これは c-bata/go-prompt の制約によるものです。気力があれば実装して contribution したいけどない 😇

CLI ツールのフラグ設計むずかしい

CLI ツールを作る・保守していく上で最も難しいのはフラグ設計かもしれない。 一つのことをうまくやる、サブコマンドを持たないレベルまで小さいものであれば比較的容易だけど、サブコマンドを使うレベルになってくると途端に難しくなる。

どういった点が難しいのかをざっと思いつくものについて上げてみる。

  • フラグ名の割り当て
  • 適切な名前
  • 提供する機能の粒度
  • 後方互換

フラグ名の割り当て

例えば --version の短縮名として -v--help の短縮名として -h がしばしば使われる。なので、ユーザは初めて使う CLI ツールであってもとりあえず -v-h を叩いてみることが多い (特に使い方がわからない場合は --help-h を必ずと行っていいほど叩くと思う)。
ここで、-h がヘルプではなく別の機能に割り当てられていた場合、ユーザは違和感を覚えると思う。直感的な操作を提供できるか、というのは CLI ツールの設計においてかなり重要だと感じるのでできる限り直感に沿った機能を提供したい。

しかし、その一方でフラグ名の割り当ては慎重に決定する必要がある。例えば、ヘルプはユーザが CLI ツールの使い方を忘れるたびに叩かれるのでそれなりに使用される頻度は多いが、バージョンを表示する --version はそれと比べて圧倒的に頻度が少ないはず。使用頻度が少ないコマンドに短縮名を使ってしまうと、その文字を頭文字に持つ他のフラグの短縮名を提供したくなったときに困ることになる。例えば、v が頭文字につくものでしばしばフラグ名に使われているものとして --verbose がある。--verbose が具体的にどういった機能を提供するかはそれぞれの CLI ツールに依るが、少なくとも冗長・補助的な出力を増やすフラグであることがほとんどで、--version よりも使用される頻度が高いと思う。

短縮形で使用できる文字にはかなり制限がある (一文字しか使えないので) ため、短縮形を使うべきフラグは非常に使用頻度が高いものや、その CLI ツールにおいてコアな機能を提供するものにのみ使うべきだと個人的には思う (= 基本的に短縮形は使わない)。

適切な名前

例えば --port のような、フラグ名の指しているものの値を変えるような場合であればフラグ名を決定するのは容易だが、振る舞いを変えるようなフラグ名の場合は適切な名前を決めるのが難しくなる。これは変数名をつける時と同じ問題だが、CLI ツールの場合、フラグ名の長さは短ければ短いほど良いのでより難しい。 しかも、振る舞いを変えるフラグの場合、あまり慣習のような名前もない。そのため、日常的に使われている CLI ツールを見てもあまり参考にならなかったりする。

提供する機能の粒度

これが一番難しい。本当に難しい。例えば、出力のフォーマットを JSON 形式で行いたいというユースケースがあるとする。ぱっと思いつくフラグ名は --json といったフラグを提供して、これが有効になっていれば通常の出力ではなく JSON 形式に切り替えるようにする。しかし、JSON だけで良いのかという問題もある。例えば、kubectl は --output というフラグを提供していて、これは --output=jsonJSON 形式、--output=yamlYAML 形式で出力を行うことができる。

ではこの CLI ツールにも --output--format のようなフラグを作って複数のフォーマットに対応したほうが良いのだろうか?正直一長一短だと思う。複数のフォーマットに対応するということは一つのことをうまくやる UNIX 哲学に沿っていないようにも感じられる。また、--output フラグには値を指定しなければいけないため、bool のフラグよりもほんの少しだけ使い方が複雑になる。しかし、その一方で出力内容が複雑な場合、構造化された出力形式がないとパイプを組み合わせたフィルタ処理がしにくく、やはり UNIX 哲学に沿えてない設計になってしまう。また、--json のような特定のフォーマットに依存したようなフラグを定義すると他のフォーマットをサポートすることになった場合に比例してフラグの数が増えることになる。また、--json という名前だけでは 出力 フォーマットかどうか知ることができない。ツールが複雑になっていくと、例えば入力を JSON 形式の文字列で受け取りたいといった別なユースケースが生じてフラグ名が衝突する可能性もある。かといって --json-out のような名前にするとフラグ名が長くなってしまい、打つのがめんどうになる。短縮名を用いることはできるが前述したとおり、それもまた衝突する可能性がある。

後方互換

OSS として公開しているともっとも大変なのが後方互換性の維持だと思う。自分しか使っていないような CLI ツールであれば master に雑に push して後方互換性を破壊しても困る人はいないが、多くの人に使われるようになった CLI ツールでは後方互換性を維持することが必須となる。セマンティックバージョニングであればメジャーバージョン前であれば定義的には後方互換性を壊しても問題ないが、告知なしに互換性を壊されるとツールを使うのをやめるユーザも多いと思う。そもそも多くの人にとってセマンティックバージョニングはどうでも良く、手元ですんなり動くことが最も重要なはず。

例えば社内のライブラリであれば開発チャンネルで @here したり、マイクロサービスであればクライアントをすぐに洗い出せるので合意も取りやすい。しかし、CLI ツールは社内のソフトウェアとは異なり、ユーザの顔が見えないので後方互換性を崩したい場合は事前に告知をした上で、メジャーバージョンを上げる際にのみ行わなければいけない。

前述したフラグ名の問題も後方互換性の問題と組み合わせるとより辛くなる。迂闊にネームスペースの大きいフラグ名を付けてしまうと次のメジャーバージョンまでそれを変更することができず、さらにそのフラグ名にふさわしい機能が増えた時に割り当てることができずに妥協した名前になってしまう。特にサブコマンドがある CLI ツールの場合、グローバルフラグ (コマンド名の直後に現れる、CLI ツール全体で有効なフラグ) とローカルフラグ (サブコマンドでのみ有効なフラグ) があることが多いので定義するフラグがグローバル・ローカルどちらにあるべきかを慎重に選ばないといけない。

うーん、むずかしい 😇