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 実装ではまた違った方法を使っていると思うので興味がある方は読んでみてください。