gRPC Federation: gRPC サービスのための Protocol Buffers を進化させるDSL

Merpay Engineering Productivity Team の goccy です。

gRPC Federation は、gRPC で通信する複数のサービスから得た結果を合成して返すようなサービスを簡単に作成するための仕組みです。DSL ( Domain Specific Language ) を Protocol Buffers 上で記述することで利用します。まずは、GraphQL(Apollo) Federation の gRPC 用のものだと考えるとわかりやすいと思います。2023年8月に OSS として公開し、先日 Public Roadmap を公開しました。2024/6月末を目標に Version 1.0 ( GA版 ) をリリースする予定です。また、最近は Protocol Buffers のエコシステムに参加しました。Protobuf Global Extension Registry への登録Buf Schema Registry への登録Buf Plugin のサポート が終わり、既存のエコシステムに従って gRPC Federation を利用できます。

本稿では、Version 1.0 を目前に控えた gRPC Federation をどのような思想のもとで設計したかを説明し、現在の gRPC Federation の表現力やプラグインシステム、周辺ツールなどの機能について触れ、今後の予定を紹介します。2023年8月の Merpay & Mercoin Tech Fest で紹介したものから多くのアップデートがあります。ぜひ、新しいアーキテクチャを考える際の材料にしてください

設計方針

Protocol Buffers を進化させる

gRPC Federation は DSL を Protocol Buffers 上に記述することで利用します。本項では、私たちがこの選択を選んだ理由を説明します。

従来、Protocol Buffers は主にAPIやデータ構造を定義する設計用途で利用されてきました。コード生成と組み合わせることで、設計に対応した実装を生成でき、設計と実装を乖離させることなく保守・運用できることが強みです。さらに、プラグインの仕組みとそれを利用したツールによって、Protocol Buffers 上で定義されたAPIやデータ構造に対してカスタムオプションを利用して付加情報を与えることができ、これにより多様な自動生成が可能になっています。

gRPC Federation はこの点に着目し、gRPC サービスを動作させるために必要十分な実装を DSL として Protocol Buffers 上で記述できるようにしました。これによって、Protocol Buffers は自身のもつ情報だけで gRPC サービスを構築できる言語へと進化します。

DSL を Protocol Buffers 上で記述すべきか、別の専用のファイルで記述すべきかは議論を重ねました。DSL を専用のファイルで記述する場合、言語のシンタックスを自由に調整でき、書き味を向上させやすいメリットがあります。しかしその反面、独自の言語を利用する場合は Parser の実装が必要になり、ソフトウェアの複雑度が飛躍的に増加する懸念や、専用のファイルをどのように管理すべきか考える必要があります。Protocol Buffers と分離することで、設計と実装を乖離させることなく保守・運用できるという恩恵を受けづらくなるともいえます。

また、開発者が普段慣れ親しんだ汎用プログラミング言語でコードを書くことに比べて、gRPC Federation のような DSL を利用する効果とは何かについても考えました。
DSL を利用することで必要最小限の記述でやりたいことを表現できるという側面はあります。ですが、DSL 自体に学習コストがあるため、慣れ親しんだ言語で書いた方が効率よく開発できそうな気がします。また、定型化できる部分をライブラリなどで提供すれば、より少ない記述で実装することもできそうです。
私は、DSL のメリットは「多様な表現ができない」こと自体にあると考えています。DSL を利用する以上、汎用プログラミング言語のように自由にコードが書けるわけではありません。逆を言えば、DSL を利用して制約のある中で生成するコードはすべて予測可能で、知らないミドルウェアやサービスにアクセスしたり、ファイルシステムにアクセスするようなことはありません。これは DSL を利用して作成されたサービスのビルドやデプロイを管理する立場からすると重要な意味を持ちます。例えばビルド時に特別な依存がないことが保証されている場合、より高速にビルドしたりビルドプロセスを自動化したりといった手段が選択できます。同様にデプロイに関しても、アプリケーションが動作するために必要十分な環境を用意しやすい、動作環境をセキュアに保ちやすいといった側面があります。

こうした理由から、私たちは Protocol Buffers 上で DSL を書く方法を選択しました。シンタックスの融通が効かないデメリットを差し置いても、すでに Protocol Buffers 上で定義されている API やデータ構造をそのまま Protocol Buffers 上で参照できるメリットは大きいと考えています。また、gRPC Federation の利用過程でサービス間の依存関係が明示されることで、サービスの循環参照の有無や、問題発生時の影響範囲の特定、APIレベルでの実行コストの計算といった様々な解析を行うことが Protocol Buffers だけでできるようになります。

DSL の限界とプラグインシステム

gRPC Federation を作る上で、「DSL でどこまで表現できれば十分か」を考えることが一番難しい点でした。様々な機能をサポートしていく過程で DSL の表現力は向上していきますが、どこまでいっても DSL では実現不可能なロジックは存在します。また、DSL で表現できる範囲だったとしても、再実装せずに、すでにある3rd party製のライブラリを利用したい場合も考えられます。そこで私たちは、DSL には限界があることを理解した上で、Protocol Buffers 上で最低限記述すべき内容を決め、それ以外は DSL の外で実装する選択ができるようにしています。

Protocol Buffers 上で最低限記述すべき内容は「gRPC メソッド呼び出しの記述」としました。gRPC Federation の機能を簡潔に書くならば、「gRPC メソッドを呼び出す」ことと「メソッド呼び出しの結果を加工する」 ことを Protocol Buffers 上で書くことです。このとき、「どのgRPC メソッドを呼び出しているか」が Protocol Buffers 上に書かれなければ、Protocol Buffers を見ただけではどのサービス(のどのメソッド)に依存しているのかわからなくなってしまいます。私たちは経験上、マイクロサービス開発においてサービスの依存関係を把握することがとても重要であることを知っています。そのため、最低限「gRPC メソッド呼び出しの記述」は Protocol Buffers 上で行い、Protocol Buffers を解析するだけでサービス間の依存関係を把握できるようにしています(下図)。

deps

DSLの外で実装する手段として、いくつかの方法を用意しています。まず、gRPC Federation では DSL で表現できない部分だけを Go 言語によって実装することができます。しかし Go で実装する部分が多くなると Protocol Buffers と Go で実装が分離し、あまり嬉しくありません。そこで、もうひとつの選択肢としてプラグインの仕組みを提供しています。Go で書く場合と違う部分は、DSL で式の評価に利用している CEL( Common Expression Language ) の API を拡張できる点です。この仕組みを利用することで、Protocol Buffers 上で独自の API を使った表現が記述でき、Go で書く場合に比べて Protocol Buffers 上に実装を集中させやすくなります。また、複数の Protocol Buffers ファイルから共通の処理を利用したい場合にも有効です。

gRPC Federation の活用場面

gRPC Federation を利用することでサービス間の依存関係が明確になり、Protocol Buffers 上で把握できる情報を増やすことが可能です。また、gRPC Federation によって生成されたコードを利用することで、サービス開発における定型化された作業に割く時間を大きく減らし、ビジネスロジックの実装に集中できるようになります。

そのため、複数のマイクロサービスの結果を合成して返すことが主な責務である BFF ( Backends For Frontends ) や Public API のような toB 向けのサービスは gRPC Federation を採用する例として最も適していますが、通常のマイクロサービス開発でも十分に利用できると考えています。

gRPC Federation がもつ表現力

次に、現状の gRPC Federation の表現力について、重要な機能をいくつか簡単に紹介します。

gRPC Federation では service / message / field など Protocol Buffers上の各要素に対して専用のオプションを用意しています。簡単な例を利用した説明はこちらに記載しました
本稿では、長くなりすぎてしまうので基本的な使い方については省略しますが、各項目の例を見ていただければ、なんとなく何ができるのか理解していただけると思います。

公式リファレンスはこちらです

変数定義と式の評価

gRPC Federation の開発を進めていくにあたって、変数や式の評価を行う仕組みが必要になりました。式の評価には、Kubernetes の Custom Resource Definition でも利用されるようになった 、Common Expression Language (CEL) を採用しました。こちらに言語仕様がとまっています
CEL は式を評価することに特化した言語で、小さくかつ洗練された仕様と豊富な拡張性をもっています。四則演算や論理演算、三項演算から関数、マクロまで様々な機能がある他、gRPC Federation では独自に CEL の機能を拡張し、例えば google.protobuf.Timestamp に対して Go の time ライブラリの機能を適応したり、reducefirst といったマクロを使用できるようにしています。CEL は Protocol Buffers と親和性高く設計されており、gRPC Federation のように Protocol Buffers 上の定義を CEL の中で利用したい場合に適しています。ですが、CEL は変数の定義ができないため、gRPC Federation の仕様として 「CEL の評価結果を変数に代入できる機能」と「定義済みの変数をCELの評価式の中で参照できる機能」を追加しました。

次のように、 def キーワードを利用して式を評価した結果に名前を付けることで変数を定義できます。grpc.federation.message option で定義された変数は grpc.federation.field option で参照することができ、次のように参照した変数の値をそのままフィールドに代入することができます。

message M {
  option (grpc.federation.message) = {
    def [
      {
        name: "t"
        // 2024/4/01 00:00:00+0
        by: "grpc.federation.time.date(2024, 4, 1, 0, 0, 0, 0, grpc.federation.time.UTC())"
      },
      { name: "sum" by: "[2, 3, 4].reduce(accum, cur, accum + cur, 1)" }, // sum = 10
      { name: "v" by: "[1, 2, 3, 4].first(cur, cur % 2 == 0)" } // v = 2
    ]
  };
  google.protobuf.Timestamp time = 1 [(grpc.federation.field).by = "t"];
  int64 sum = 2 [(grpc.federation.field).by = "sum"];
  int64 first = 3 [(grpc.federation.field).by = "v"];
}

このように、message option の中でフィールドに割り当てる値を作り、 field option でその値を参照して代入するというのが基本の使い方です。
現在 gRPC Federation で利用可能な CEL API は こちらにまとめました

gRPC メソッドの呼び出し

必ず Protocol Buffers 上に記述してもらいたい、gRPC メソッドの呼び出し方法について説明します。リファレンスはこちらです

使い方の前に、呼び出し対象のメソッドが次のように定義されているとします。
メソッドへの FQDN は foopkg.FooService/GetFoo となり、メソッドを呼び出すためには GetFooRequest メッセージの内容を作る必要があります。返り値は GetFooResponse です。

package foopkg;

service FooService {
  rpc GetFoo(GetFooRequest) returns (GetFooResponse);
}

message GetFooRequest {
  FooParam param = 1;
}

message FooParam {
  string x = 1;
}

message GetFooResponse {
  Foo foo = 1;
}

message Foo {
  string bar = 1;
}

このとき、メソッドを呼び出すには、次のように call{} を記述します。

message M {
  option (grpc.federation.message) = {
    def {
      name: "res"
      call {
        method: "foopkg.FooService/GetFoo"
        request { field: "param" by: "foopkg.FooParam{x: 1}" }
      }
    }
    def { name: "f" by: "res.foo" } // f = foopkg.Foo{}
  };

  string result = 1 [(grpc.federation.field).by = "f.bar"]; // assign foopkg.Foo.bar field to result field.
}

method に呼び出したいメソッドの FQDN を記述し、requestGetFooRequest メッセージの各フィールドの値を指定します。ここでは CEL を使って foopkg.FooParam の内容を作成しました。 メソッドの呼び出し結果は res 変数に格納します。
次の変数定義で res 変数の foo フィールドへアクセスしているので、 foopkg.Foo の値が変数 f に代入されます。最後に、フィールドバインディング時に変数 f を参照し、bar フィールドの値を取り出して result フィールドに代入しています。

メッセージへの依存

メソッドを呼び出した結果を欲しい形に加工する上で重要になるのが、メッセージ間に依存関係を作る機能です。リファレンスはこちらです
あるメッセージは別のメッセージに依存することができます。依存関係は gRPC Federation のオプションを利用して明示的に記述することができます。例えば、 次の例にある M というメッセージを構築することが目標である場合、M メッセージのフィールドに存在する Dep メッセージの値を作る必要があります。ここで、Dep メッセージが GetFoo メソッドの呼び出し結果の値を利用することで作れるとすると、次のように記述することができます。

message M {
  option (grpc.federation.message) = {
    def {
      name: "res"
      call {
        method: "foopkg.FooService/GetFoo"
        request { field: "param" by: "foopkg.FooParam{x: 1}"
      }
    }
    def {
      name: "dep"
      message { name: "Dep" args { name: "f" by: "res.foo" } }
    }
  };

  Dep dep = 1 [(grpc.federation.field).by = "dep"];
}

message Dep {
  string bar = 1 [(grpc.federation.field).by = "$.f.bar"];
}

message{} を利用することで他のメッセージの値を作ることができます。メッセージの値を作る際は args{} を利用して自由に依存先のメッセージに対して引数を渡すことができ、name で名前を指定することで、依存先のメッセージ側で $. というプレフィックスを付けて引数にアクセスすることができます。

この例では、 res 変数から取得した foo フィールドの値に対して、 f という名前の引数を作って Dep の値を作っています。Dep メッセージ側では、CEL の評価式の中で $.f と記述することで引数にアクセスしています。

バリデーション

サービスを実装する上で、メソッドを呼び出した結果に対するバリデーションは常に意識しなければいけません。バリデーションの結果、エラーを返す場合は gRPC の慣習に従ってエラーを作る必要もあります。Protocol Buffers でバリデーションと聞くと、protovalidate が有名だと思います。これはリクエストパラメータのバリデーションに利用するものですが、 gRPC Federation の場合はリクエストに限らず、参照可能なあらゆる変数に対して行うことができます。また、gRPC エラーを返すために特化した機能も用意しています。リファレンスはこちらです

例えば次の例のように、GetFoo メソッドを呼び出した結果が期待値かどうかを確認することが可能です。エラーは google.rpc.Status を作るようになっており、error_details.proto で定義されているものがサポートされています。加えて、独自のメッセージを作ってエラーに含めることも可能です。

例えば Go 言語では、errdetails パッケージを使って grpc.Status を作る処理に該当します。

message M {
  option (grpc.federation.message) = {
    def {
      name: "res"
      call {
        method: "foopkg.FooService/GetFoo"
        request { field: "param" by: "foopkg.FooParam{x: 1}" }
       }
     }
     def {
        validation {
          error {
            if: "res.foo.bar != 'xxx'"
            code: FAILED_PRECONDITION
            message: "'unexpected foopkg.Foo.bar value'",
          }
        }
     }
  };
}

ここで紹介した機能は全体のごくわずかです。gRPC Federation は他にも多くの機能が存在するので、お時間のある際にぜひ見てみてください。

WebAssembly を利用したプラグインシステム

gRPC Federation では、DSL 中に記述する CEL API や gRPC Federation がもつコード生成パイプラインを WebAssembly を利用して拡張することができます。プラグインを WebAssembly として実行することで、WebAssembly ランタイム側で制約を設けることができます。これにより、例えばネットワークやファイルシステムへのアクセスを禁止することで、プラグインによる予期しない動作を防止しています。

DSL からコードを生成する際に、Logger や gRPC Interceptor など、ドメイン固有の実装を同時に生成したい場合があります。そのような場合にコード生成パイプラインをプラグインによって拡張することで、gRPC Federation がもともとコード生成に使用している情報と全く同じものをプラグインで受け取り、自由にコード生成を行うことができるようになります。

Protocol Buffers からコード生成を行って gRPC サーバをビルドするまでの過程とプラグインの関係を図にすると次のようになります。

plugin

周辺ツール

DSL を提供する上で、周辺ツールの整備も重要だと考えています。今回は Protocol Buffers のプラグインとして動作するため、 protoc のプラグインを用意するのはもちろんですが、他にも専用の Linter や Language Server 、コード生成ツールを用意しています。今回はこの中から、Language Server と コード生成器について紹介します。

  • protoc-gen-grpc-federation: protoc プラグイン
  • grpc-federation-linter: Linter
  • grpc-federation-language-server: Language Server
  • grpc-federation-generator: コード生成器

Language Server

DSL を書いてもらう上で当初から Language Server の提供は必須だと考えており、 専用の Language Server を提供しています。専用といっても、通常の Protocol Buffers の開発で最低限必要な Syntax Highlight や コードジャンプなどは実装済みなので、Protocol Buffers の Language Server としても利用することができます。

コードエディタによって Language Server の利用方法は様々ですが、VSCode では利用しやすいように、すでに Extension を公開しています。他の IDE 向けの対応も現在進めていますので、どうぞご期待ください。

Language Server によって Syntax Highlight された Protocol Buffers は次のようになります。文字列中の CEL の式などが適切にハイライトされているのが確認できると思います。

lsp

コード生成器

コード生成に関して、protoc を利用した方法 以外に、Buf を利用する方法 や、gRPC Federation 独自のコード生成ツールによる方法 をサポートしています。

独自のツールを作った背景には、Protocol Buffers を編集した瞬間に gRPC サービスが立ち上がるような開発体験を提供したいという思いからでした。独自のツールには -w オプションを付けることで Protocol Buffers の変更を検知して即座にコンパイル、コード生成を実行する仕組みがあります。この機能と Air などのホットリローダを組み合わせることで、コード生成された側から Go のコンパイルを行う仕組みを作れるため、他に gRPC サービスを起動するために必要な情報をプラグインの形で外から与えさえすれば、Protocol Buffers を編集した瞬間に gRPC サービスが立ち上がる状態を作ることができます。
個人的にはこれを Protocol Buffers Driven Development と呼んでおり、スキーマ駆動開発を促進できると考えています。図にすると以下のようになります。

pdd

今後

メルペイ社内では、 gRPC Federation を使ったサービスがそろそろ本番環境で稼働し始めようとしています。そこで、最終的な機能の精査を行い、6月末を目標に Version 1.0 ( GA版 ) を提供する予定です。 1.0 以降は、基本的に破壊的な変更を入れず後方互換性を保ち、どうしても変更したい場合は十分な変更期間をとるなど社外のユースケースを想定してメンテナンスしていくことを考えています。
そのため、gRPC Federation の導入を考えるとてもいい機会だと考えています。導入のご相談は随時受け付けていますので、ぜひお気軽にご連絡ください。また、OSSに関しても積極的にコントリビューションを受け付けています。こちらもあわせてよろしくお願いします。

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