cover

gRPC と『拡張指向プログラミング』

sorcererxw

gRPCはProto DSLを使用して型とインターフェースを定義し、各言語向けのコードを生成します。したがって、gRPCにとって互換性は最も重要です。これは、横断的にすべての言語の特性をサポートすること、また縦断的に異なるバージョンのインターフェース定義に対して後方互換性を保つことを意味します。これを実現するためには、gRPCのインターフェース定義が非常に優れた拡張性を持つ必要があります。本稿では、gRPCが後方互換性と拡張性においてどのような設計を採用しているかについて説明し、これらの設計の意図を理解することで、将来的によりメンテナンスしやすく、拡張可能なインターフェースを設計する助けとなるでしょう。

すべてのフィールドはオプショナルです

proto3では、optionalキーワードが削除され、すべてのフィールドがオプショナルでなければならないと規定されました。protoに初めて触れた時、これはGoのような必須フィールドの概念がない言語に適応するためかもしれないと感じました。

しかし、より深く考えると、次のことに気づくことができます:

  • RPC はプロセス間通信であり、どんな入力もあり得ます。他のプロセスの入力を制約することで自プロセスの論理の正確性を保証することはできません。できることは、防御的プログラミングのみです。
  • 一方では、必須フィールドをサポートする場合、既存のタイプに新しい必須フィールドを追加すると、そのタイプに依存している既存のコードとの互換性が失われ、破壊的変更が発生します。既存のコードベースが大きいほど、この変更はより恐ろしいものになります。

したがって、必須フィールドの制約を捨てることは、工学的に賢明な選択であり、これはある程度、Goがstructの必須フィールドをサポートしていない理由を説明しています。

UnimplementedServer

grpc-go はサーバースタブを生成する際、以下のようなコードを生成します:

type Server interface {
	Do(context.Context, *Request) (*Response, error)
	mustEmbedUnimplementedServer()
}

type UnimplementedServer struct {
}

func (UnimplementedServer) Do(context.Context, *Request) (*Response, error) {
	return nil, status.Errorf(codes.Unimplemented, "method CancelContract not implemented")
}

func (UnimplementedServer) mustEmbedUnimplementedServer() {}

<code>mustEmbedUnimplementedServer</code> がエクスポートされていないため、外部で <code>Server</code> を実装する際には、常に制約を満たすことができません。これは、grpc-go が <code>Server</code> を実装するすべての struct に <code>UnimplementedServer</code> を埋め込むことを望んでいるためです。そして <code>UnimplementedServer</code> は完全な <code>Server</code> を実装するため、将来 <code>Server</code> に新しいインターフェースが追加されたとしても、既存のコードは直接互換性を持つことができます。

type impl struct {
	UnimplementedServer
}

しかし一方で、UnimplementedServer が暗黙的にすべてのインターフェースを実装しているため、コンパイル時に関数が実装されているかどうかをチェックできないというリスクがあります。この問題についてはコミュニティ内でもかなり議論されています:

protoc-gen-go-grpc: API for service registration · Issue #3669 · grpc/grpc-go
There were some changes in #3657 that make it harder to develop gRPC services and harder to find new unimplemented methods - I wanted to start a discussion around the new default and figure out why...
https://github.com/grpc/grpc-go/issues/3669

入出力は必ず構造体でなければならない

Proto では、RPC を定義する際に、プリミティブ型やコレクション型ではなく、message(つまり構造体)のみをインターフェースの入力または出力として使用することが許されています。message のみが拡張可能であり、任意にフィールドを追加することができるからです。

もし最初からインターフェースがリストを返すと決めつけて、インターフェースの戻り値を直接リストとして定義した場合、いつかページネーションのカーソルも同時に返す必要が出てきたら、新しいインターフェースを使うか、既存のインターフェースに破壊的変更を加えてレスポンスタイプをメッセージに変更しなければならなくなります。

各 RPC 専用の入出力タイプ

proto 自体の制約を超えて、linter でもいくつかのベストプラクティスを見つけることができます。Buf にはいくつかのルールがあります:

  • RPC_REQUEST_RESPONSE_UNIQUE

    このルールは、各RPCインターフェースが独立した入出力タイプを持つことを要求し、複数のインターフェースがトップレベルのタイプを共有することを許可していません。

  • rpc_allow_google_protobuf_empty_requests / rpc_allow_google_protobuf_empty_responses

    これら二つのルールは、RPCの入出力にempty型を使用するかどうかを示しています。デフォルトでは無効になっており、有効にすることは推奨されていません。

これら二つのルールは、インターフェース定義の作成にかなりの追加負担をもたらすかもしれません。既に存在する型を使うか、あるいは入力パラメータが全くないのに、なぜ新たな型を定義する必要があるのでしょうか。実は、その理由は上述した「入出力は構造体でなければならない」と似ています。つまり、将来的にどのようなフィールドがインターフェースに追加されるかを我々は決して予測できないのです:今日、二つのインターフェースが同じ入力パラメータを持っているかもしれませんが、明日のうちに一方のインターフェースに新たなフィールドが追加されるかもしれません;今日は入力パラメータがないインターフェースも、明日には何かパラメータを受け取る必要が出てくるかもしれません。もし私たちが最初の設計段階で将来の拡張性を考慮せず、入出力を適当に再利用したり、空で定義したりすると、将来的に変更を余儀なくされた際に、breaking changeを引き起こす可能性があります!

用户发送的内容不包含中文,所以按照指示,我不会翻译这一部分。原文如下: 总结

UnimplementedServerやオプショナルフィールドなどの実践には、ある前提があります。それは後方互換性が正確性よりもある程度重要であるということです。時には、関数を一つ実装しなかったり、フィールドを一つ渡さなかったりすることもありますが、それに比べてコードベース全体に破壊的変更を引き起こすことは、さらに受け入れがたいです。エンジニアリングの観点から見ると、これは正しい選択です。したがって、ビジネスコードにはより多くの要求があります。RPCに関わる場合、入力パラメータのダウングレード、返り値のタイプのダウングレード、関数呼び出しのダウングレードは不可欠です。