LoginSignup
5
1

More than 1 year has passed since last update.

generic な golang gRPC サーバをつくる

Last updated at Posted at 2021-12-01

まえふり

gRPC サーバを実装するには protobuf ファイルから各言語のコードを自動生成し、各 RPC ごとの処理を実装することが一般的と思われる。一方で、どんな RPC に対しても同様な処理を行いたい generic な gRPC サーバを実現したい場合、もう少し簡単に RPC の追加を行いたい。

C++ の場合、このような generic な gRPC サーバを構築する方法はよく知られているように思う。ここのフォーラムで言及されているし、 一例を挙げると envoy の json_grpc_transcoder で似たようなことは実装されている。
また、 Java でも同様なことができるとサンプルコードとともに提示されている。

このような低レイヤのIFの提供は全ての言語で行われているわけではないが、golang でも2020年に新しくなったライブラリを使えば generic な golang gRPC サーバを構築できそうだったのでつくってみたい。

descriptor と dynamic message

具体的な実装に触れる前に、 generic な gRPC サーバを作るための要素となる descriptor と dynamic message について軽く触れておく。

descriptor

descriptor は protobuf ファイルに対応する情報を持った protobuf message のことである。

protobuf ファイルを作成すると何らかの名称・型・オプション等を持つ field を含んだ message を作ることになる。descriptor はこれらのような protobuf ファイルで定義される情報を保持できるような構造を持った protobuf message となっている。descriptor の protobuf ファイル にあるように、protobuf の file・service・rpc・message・field・enum のそれぞれに対応する file descriptor・service descriptor・method descriptor・field descriptor・enum descriptor が存在する。
なので、file descriptor がわかっていれば protobuf ファイルをほぼ復元することができる。

dynamic message

dynamic message は message descriptor から生成された protobuf message のことである。

protobuf message は各言語のコード自動生成によって各言語に応じた型やクラスにマッピングされるが、これをこれを使うとそれらの型やクラスを使わずに、 runtime で渡された descriptor をもとに protobuf message を生成できる。
C++ は dynamic_message, Java は DynamicMessage, Golang は dynamicpb で実装されている。

golang での実装

golang protobuf の標準ライブラリを使って実装する。

descriptor_set ファイルの生成

protobuf ファイルは公式の helloworld.protoサンプルを利用する。
unary RPC を一つ持つシンプルな protobuf を例として取り上げるが、 複数 unary や stream RPC を含む場合でも似たようなことができると思う。

protobuf ファイルから下記コマンドで descriptor_set ファイルが生成される。これはhelloworld.proto の内容を google.protobuf.descriptor.FileDescriptorSet message の形に serialize されたファイルとなっている。

protoc --descriptor_set_out=helloworld_descriptor.pb helloworld.proto

descriptor set file を取り込む

gRPC サーバ起動時に RegisterService メソッドを呼び出して service を登録する際に、 descriptor_set ファイル から descriptorpb.FileDescriptorSet に deserialize して全ての service を取得して下記のようにそれらを登録する。

func newGrpcServer() (*grpc.Server, error) {
    s := grpc.NewServer()
    bytes, err := ioutil.ReadFile("<descriptor file path>")
    if err != nil {
        return nil, err
    }
    var fileSet descriptorpb.FileDescriptorSet
    if err := proto.Unmarshal(bytes, &fileSet); err != nil {
        return nil, err
    }

    files := protoregistry.GlobalFiles
    for _, fd := range fileSet.File {
        d, err := protodesc.NewFile(fd, files)
        if err != nil {
            return nil, err
        }
        for i := 0; i < d.Services().Len(); i++ {
            // protobuf service descriptor から gRPC service descriptor に変換して service を登録する
            s.RegisterService(...)
        }
    }
    return s, nil
}

上記サンプルでは省略したが、もし gRPC reflection の機能をサーバに取り込みたい場合protoregistry.GlobalFiles.RegisterFile(...) を使って proto file を registry に登録する必要がある。

RPC 呼び出し時の処理をおこなう

全ての RPC で同様な処理を行いたい場合、 gRPC service descriptor を作成するときに grpc.MethodDesc.Handler に同一の handler を仕込むことになる。
その handler は例えば下記のような実装になる。request, response message ともに service descriptor から取得できる input message descriptor, output message descriptor から dynamic message を生成している。

func unaryHandler(svcd protoreflect.ServiceDescriptor, md protoreflect.MethodDescriptor) func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
    return func(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
        in := dynamicpb.NewMessage(md.Input())
        if err := dec(in); err != nil {
            return nil, err
        }
        if interceptor == nil {
            return callRPC(ctx, in, md.FullName(), md.Output())
        }
        info := &grpc.UnaryServerInfo{
            Server:     srv,
            FullMethod: fmt.Sprintf("/%s/%s", svcd.FullName(), md.Name()),
        }
        handler := func(ctx context.Context, req interface{}) (interface{}, error) {
            return callRPC(ctx, req.(proto.Message), md.FullName(), md.Output())
        }
        return interceptor(ctx, in, info, handler)
    }
}

// RPC が呼ばれた際の処理例
func callRPC(base context.Context, request proto.Message, fullName protoreflect.FullName, mdesc protoreflect.MessageDescriptor) (interface{}, error) {
    reqbyte, err := protojson.Marshal(request)
    if err != nil {
        return nil, err
    }
    var m map[string]interface{}
    err = json.Unmarshal(reqbyte, &m)
    if err != nil {
        return nil, err
    }
    resp := dynamicpb.NewMessage(mdesc)
    respbyte := []byte(fmt.Sprintf("{\"message\":\"%s\"}", fmt.Sprintf("hello %s", m["name"])))
    err = protojson.Unmarshal(respbyte, resp)
    return resp, err
}

さいごに

実際に実行可能なコードはこちらのリポジトリにまとめた。
今回は標準ライブラリを使って実装したが、github.com/jhump/protoreflect も使いやすい API になっておりオススメ。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1