blog.syfm

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

gRPC リフレクションはなにをしているか?

gRPC リフレクションは、対象の gRPC サーバがどのようなサービス、メソッドを公開しているかを知るための機能です。

gRPC を使う上でリフレクションを有効にすると、gRPCurl や Evans といったツールを使う際に Protocol Buffers などの IDL を直接的に読み込まずにメソッドを呼び出すことができてとても便利ですが、gRPC リフレクションはなにをしていて、ツールは gRPC リフレクションをどうやって使っているのでしょうか?
それなりに複雑でしばしば忘れるので記事にしておこうと思います。なお、この記事では gRPC の実装として grpc/grpc-go、Protocol Buffers の実装として protocolbuffers/protobuf-go を参照します。

gRPC リフレクションを有効化しているサーバの例

grpc/grpc-go に gRPC リフレクションの例があるのでそこから抜粋します。 このサーバには GreeterServerEchoServer という gRPC サービスがあり、GreeterServer には SayHello というメソッドがあります。

gRPC リフレクションを使っていないサーバとの差異は reflection.Register(s) を呼ぶか呼ばないかという点しかありません。

func main() {
    flag.Parse()
    lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    fmt.Printf("server listening at %v\n", lis.Addr())

    s := grpc.NewServer()

    // Register Greeter on the server.
    hwpb.RegisterGreeterServer(s, &hwServer{})

    // Register RouteGuide on the same server.
    ecpb.RegisterEchoServer(s, &ecServer{})

    // Register reflection service on gRPC server.
    reflection.Register(s)

    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

reflection.Register は以下のように、gRPC サーバに ServerReflectionServer を登録している処理しかありません。これは上記の RegisterGreeterServerRegisterEchoServer と同じような gRPC サービスの登録処理です。つまり、gRPC リフレクション自体も gRPC サービスとして実装されているということになります。

func Register(s *grpc.Server) {
    rpb.RegisterServerReflectionServer(s, &serverReflectionServer{
        s: s,
    })
}

gRPC リフレクションの API 定義

gRPC リフレクションの API 定義は grpc/grpc に置かれています。

grpc/reflection.proto at master · grpc/grpc · GitHub

コメント等を削除した定義は以下のようになっています。

syntax = "proto3";

package grpc.reflection.v1alpha;

service ServerReflection {
  rpc ServerReflectionInfo(stream ServerReflectionRequest) returns (stream ServerReflectionResponse);
}

message ServerReflectionRequest {
  string host = 1;
  // To use reflection service, the client should set one of the following
  // fields in message_request. The server distinguishes requests by their
  // defined field and then handles them using corresponding methods.
  oneof message_request {
    string file_by_filename = 3;

    string file_containing_symbol = 4;

    ExtensionRequest file_containing_extension = 5;

    string all_extension_numbers_of_type = 6;

    string list_services = 7;
  }
}

message ExtensionRequest {
  string containing_type = 1;
  int32 extension_number = 2;
}

message ServerReflectionResponse {
  string valid_host = 1;
  ServerReflectionRequest original_request = 2;
  oneof message_response {
    FileDescriptorResponse file_descriptor_response = 4;

    ExtensionNumberResponse all_extension_numbers_response = 5;

    ListServiceResponse list_services_response = 6;

    ErrorResponse error_response = 7;
  }
}

message FileDescriptorResponse {
  repeated bytes file_descriptor_proto = 1;
}

message ExtensionNumberResponse {
  string base_type_name = 1;
  repeated int32 extension_number = 2;
}

message ListServiceResponse {
  repeated ServiceResponse service = 1;
}

message ServiceResponse {
  string name = 1;
}

message ErrorResponse {
  int32 error_code = 1;
  string error_message = 2;
}

ServerReflectionInfo という Bidirectional Streaming なメソッドがあり、message_request で指定されたフィールドに対応してレスポンスが返されます。 gRPC クライアントはこの ServerReflectionInfo から欲しい情報をリクエストし、メソッドを呼び出します。

メソッドを呼び出すために必要な情報を取得する

例えば SayHello を呼び出すことを考えてみましょう。

まず、SayHello が属しているサービスを知る必要があります。そのためにはまず gRPC サーバが公開しているサービスの一覧を取得しなければいけません。これには list_services フィールドを使用します。
Evans で実際に gRPC リフレクションサービスの ServerReflectionRequest を叩いてみます。

f:id:ktr_0731:20200621200850p:plain

このように、grpc.examples.echo.Echogrpc.reflection.v1alpha.ServerReflectionhelloworld.Greeter が公開されていることが分かりました。

次に、これらのサービス名からメソッドを取得する必要があります。これには file_containing_symbol を使用します。

f:id:ktr_0731:20200621201500p:plain

Base64エンコードされた FileDescriptor が返ってきました。descriptor とは Protocol Buffers のシンボルをエンコード・デコードするために必要なメタデータの集合のことで、FileDescriptor はその名前の通り、Protocol Buffers の定義が記述されたファイルの descriptor です。今回の場合だと helloworld.Greeter が定義されているファイルである helloworld.proto がソースとなっています。

FileDescriptor 自体も Protocol Buffers で定義されたメッセージ型で、Go だと FileDescriptorProto という型で自動生成されています。FileDescriptorProto のフィールドには ServiceDescriptorProto のスライスがあり、このファイルに定義されているサービスの descriptor が含まれていることを意味しています。さらに、ServiceDescriptorProto のフィールドには MethodDescriptorProto があり、SayHello も一つの MethodDescriptorProto として表現されています。

以下のようなコードを書けば descriptor をデコードできます。

package main

import (
    "encoding/base64"
    "fmt"
    "log"

    "github.com/golang/protobuf/protoc-gen-go/descriptor"
    "google.golang.org/protobuf/proto"
)

func main() {
    var out []byte
    in := "Ci9leGFtcGxlcy9oZWxsb3dvcmxkL2hlbGxvd29ybGQvaGVsbG93b3JsZC5wcm90bxIKaGVsbG93b3JsZCIiCgxIZWxsb1JlcXVlc3QSEgoEbmFtZRgBIAEoCVIEbmFtZSImCgpIZWxsb1JlcGx5EhgKB21lc3NhZ2UYASABKAlSB21lc3NhZ2UySQoHR3JlZXRlchI+CghTYXlIZWxsbxIYLmhlbGxvd29ybGQuSGVsbG9SZXF1ZXN0GhYuaGVsbG93b3JsZC5IZWxsb1JlcGx5IgBCZwobaW8uZ3JwYy5leGFtcGxlcy5oZWxsb3dvcmxkQg9IZWxsb1dvcmxkUHJvdG9QAVo1Z29vZ2xlLmdvbGFuZy5vcmcvZ3JwYy9leGFtcGxlcy9oZWxsb3dvcmxkL2hlbGxvd29ybGRiBnByb3RvMw=="
    out, err := base64.StdEncoding.DecodeString(in)
    if err != nil {
        log.Fatal(err)
    }

    var m descriptor.FileDescriptorProto
    if err := proto.Unmarshal(out, &m); err != nil {
        log.Fatal(err)
    }

    fmt.Println(*m.Name) // examples/helloworld/helloworld/helloworld.proto
    fmt.Println(*m.Service[0].Name) // Greeter
    fmt.Println(*m.Service[0].Method[0].Name) // SayHello
}

メソッドのリクエストとレスポンス型の名前が MethodDescriptorProto に、message の descriptor である MessageDescriptor の一覧が FileDescriptorProto に含まれているため、これにより MessageDescriptorProto も取得することができます。

gRPC リフレクションを使って必要な情報をすべて集めることができました。

Protocol Buffers の message の構築

メソッド呼び出しに必要な情報はすべて集まったため、リクエストを送ることはできるようになりましたが、実際のリクエストの値はどのように作れば良いのでしょうか?
MessageDescriptor は手に入っていますが、実際の message 型 (proto.Message を実装する型) は手に入っていません。通常であれば protoc-gen-go により生成された型を使うことができますが、gRPC リフレクションを使う場合は自動生成された型が手に入りません。

これに対応するには、proto.Message を実装する動的な汎用型を定義する必要があります。Go でいえば jhump/protoreflect の dynamic.Message、protocolbuffers/protobuf-go の dynamic.Message がそれに相当します。

これらは内部的に MessageDescriptor を持ち、MessageDescriptor と入力値を元にエンコード・デコードを行っています。

descriptor はどこから手に入れるのか?

先の例では FileDescriptor や ServiceDescriptor などの descriptor が出てきましたが、そもそも gRPC サーバはそれらをどのようにして手に入れているのでしょうか?
これは完全に実装の話なので Go 以外では全く異なった実装になっていると思います。

FileDescriptor

Go では、Protocol Buffers で定義された型から protoc-gen-go を使い、対応する Go の型を自動生成することができます。
FileDescriptor はこの自動生成されたファイルの中に含まれています。

GreeterServer が定義されている helloworld.proto の場合、そこから自動生成された helloworld.pb.go の末尾に FileDescriptor を値として持つ変数があります。

grpc-go/helloworld.pb.go at 9a465503579e4f97b81d4e2ddafdd1daef80aa93 · grpc/grpc-go · GitHub

そしてこの自動生成されたファイル内の init ではこの FileDescriptor を使って以下のような処理を行っています。

func init() {
    proto.RegisterFile("examples/helloworld/helloworld/helloworld.proto", fileDescriptor_b83ea99a5323a2c7)
}

これは、proto パッケージに FileDescriptor を登録している処理です。
ここで登録された FileDescriptor は proto パッケージの FileDescriptor 関数により取得することができます。

ServiceDescriptor

gRPC + Protocol Buffers において、 ServiceDescriptor は二種類あります。一つは gRPC における ServiceDescriptor である grpc.ServiceDesc です。こちらは gRPC サービスの名前、サービスの具象型、ハンドラやストリームの情報を含む型です。
もう一つは Protocol Buffers で定義された Protocol Buffers の ServiceDescriptor である ServiceDescriptorProto です。こちらは gRPC には全く依存していません。

gRPC の ServiceDescriptor は自動生成されたファイルに含まれており、gRPC サービスの登録処理を行う際に gRPC サーバ内部に保持されます。GreeterServer であれば、RegisterGreeterServer の部分です。

func RegisterGreeterServer(s *grpc.Server, srv GreeterServer) {
    s.RegisterService(&_Greeter_serviceDesc, srv)
}

この _Greeter_serviceDesc が protoc-gen-go-grpc により自動生成される ServiceDescriptor で、これは以下のようになっています。

var _Greeter_serviceDesc = grpc.ServiceDesc{
    ServiceName: "helloworld.Greeter",
    HandlerType: (*GreeterServer)(nil),
    Methods: []grpc.MethodDesc{
        {
            MethodName: "SayHello",
            Handler:    _Greeter_SayHello_Handler,
        },
    },
    Streams:  []grpc.StreamDesc{},
    Metadata: "examples/helloworld/helloworld/helloworld.proto",
}

この ServiceDescriptor の Metadata フィールドに入っているのは元になった Protocol Buffers ファイルの URL です。gRPC リフレクションはこの Metadata から proto.FileDescriptor 関数を呼び出すことで FileDescriptor および FileDescriptor のフィールドから ServiceDescriptor を取得しています。

まとめ

この記事では gRPC リフレクションが有効化された gRPC サーバの例からはじめ、gRPC リフレクションの API 定義およびそれらの API がなにを行っているかを簡単に説明しました。
また、FileDescriptor や ServiceDescriptor について、どのように生成・登録され、gRPC リフレクションはそれらをどのように取得しているかを説明しました。

おそらく他の gRPC 実装ではまた違った方法を使っていると思うので興味がある方は読んでみてください。

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 したいけどない 😇