💨

gRPCのスキーマ設計および実装のベストプラクティス

2024/07/23に公開

バックエンドエンジニアのYです。Sprocketは2022年8月26日に第2回目となるオンラインイベントのSprocket Tech Session#2 Sprocketのマイクロサービス間通信を支えるgRPCを学ぶを開催しました。

Sprocketのバックエンド開発ではマイクロサービス間の通信にgRPCを採用しています。gRPCはHTTP/2を使用した効率的な通信の実現やスキーマ駆動開発が可能であることなどがメリットとして挙げられます。イベントではSprocketで採用しているgRPCのスキーマ設計および実装のベストプラクティスを発表しました。今回はこちらで発表した内容をまとめて記事にしました。

スキーマ設計

冒頭述べた通り、gRPCを使用した開発は先にサーバー、クライアント間のスキーマを取り決めるスキーマ駆動となります。gRPCのスキーマ定義はProtocol Buffersと呼ばれるインタフェース定義言語(IDL)によって記述されます。Protocol Buffersの例は次のようなものになります。

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

GreeterサービスのSayHelloHelloRequestを受け取り、HelloReplyを返すようなRPCとなっています。gRPCでは1つのRPC定義をメソッドと呼び、記述方法もプログラミング言語でよく見られるメソッド定義のような見た目となっています。

このProtocol Buffersによるスキーマ定義設計のベストプラクティスを本章では紹介します。

リクエストにIDを付与する

gRPCに限らず、RPCによってトリガーされる処理に冪等性を付与することは呼び出し側を設計、実装する際に大きな効用をもたらします。冪等性とは同じ操作を複数回行ったしても同じ結果が得られるという性質です。冪等性のある処理にすることで具体的にクライアントサイドには次のようなメリットが考えられます。

  • リクエストのリトライ処理をシンプルに保てる。
  • 分散システムによるリクエストの重複を考慮する必要がなくなる。

gRPCのメソッドに冪等性を持たせるためには、リクエストのメッセージにIDが入るフィールドを持たせるというプラクティスがあります。フィールドとしてリクエストのIDを持たせる他にメタデータの利用なども考えられますが、型情報を付与できる点でフィールドとしてしまうのがおすすめです。

次のように記事のエントリーを作成するメソッドの冪等性を考えてみましょう。idというフィールドがなく、冪等なメソッドでない場合、リクエストに一意性がなくなりリトライなどが発生すると重複した内容のエントリーが作成されてしまいます。

message CreateEntryRequest {
  int64 id = 1;
  string title = 2;
  string body = 3;
}

SprocketではidをキーとするレコードをRedisに発行するなどして冪等性を持つメソッドを実現しています。

リクエストとレスポンス

次に、メソッドのリクエストとレスポンスのメッセージを定義するプラクティスです。各メソッドごとに独立してリクエストとレスポンスのメッセージを定義すべきとされています。

例として記事のエントリーを作成するCreateEntryと更新するUpdateEntryという2つのメソッドを持つBlogServiceというサービスを考えます。開発初期段階において、2つのメソッドは全く同じフィールドを持つリクエストとレスポンスであるとなったため、次のように共通化して定義したとします。

service BlogService {
  rpc CreateEntry(Request) returns (Response);
  rpc UpdateEntry(Request) returns (Response);
}

しかし、開発が進むにつれUpdateEntryのリクエストに更新日時を示すupdated_atを追加する必要が出た場合、改修の影響がCreateEntryにも及んでしまいます。そのため、メソッドの独立性を保つため開発の初期段階から次のようにリクエストとレスポンスを分けておくと良いでしょう。

service BlogService {
  rpc CreateEntry(CreateEntryRequest) returns (CreateEntryResponse);
  rpc UpdateEntry(UpdateEntryRequest) returns (UpdateEntryResponse);
}

enumの設計

Protocol Buffersにはデータ型としてenumがサポートされています。enum型を定義する際のベストプラクティスは以下があります。

  • UNSPECIFIEDを0番目の要素として用意する。
  • enumの各要素の名前はパッケージ内において一意にする。

それぞれを次のenum型のColorを例に説明します。

enum Color {
  COLOR_UNSPECIFIED = 0;  // Default value
  COLOR_RED = 1;
  COLOR_GREEN = 2;
  COLOR_BLUE = 3;
}

UNSPECIFIEDを0番目の要素として用意する

フィールドには各型に応じたデフォルト値というものが存在します。例えば、int64型のデフォルト値は0string型のデフォルト値は空文字です。クライアントがリクエストメッセージのフィールドに対し、明示的に値を指定しなかった場合、このデフォルト値が設定されます。公式ドキュメントにそれぞれの型に対するデフォルト値が記されているため、気になる方はお読みください。

enum型のデフォルト値は最初の要素になります。よって、ColorCOLOR_UNSPECIFIEDがない場合、COLOR_REDがデフォルト値となります。そうすると、Color型のフィールドにCOLOR_REDが入って送られた際、サーバーは値が指定された状態なのかそうでないのかが判別できなくなってしまいます。このような状況を避けるために0番目の要素をUNSPECIFIEDとしておくべきでしょう。

enumの各要素の名前はパッケージ内において一意にする

上のColorの定義を見ると各要素に冗長なCOLOR_というプレフィックスが付けられているのがわかります。これはenumの要素名をパッケージ内において一意にするためです。パッケージ内で一意ではない場合、C言語およびC++のコード生成時に名前の競合が原因となり以下のエラーを出して失敗してしまいます。

Note that enum values use C++ scoping rules, meaning that enum values are siblings of their type, not children of it.

そのため、冗長に見えますがenumの名前をプレフィックスとして要素に付加します。このプラクティスは特定の言語に依ったものとなっているため、あまり嬉しいものではありませんね。

gRPCサーバーの実装

gRPCサーバーおよびクライアントはProtocol Buffersにより定義されたスキーマから生成されたコードを元に実装します。本章では、実装に関するベストプラクティスを紹介します。登場する実装例はGo 1.18、grpc-go 1.48.0を使用しています。

エラーハンドリング

エラーハンドリングのアンチパターンとしては、次のようにエラーメッセージを入れるフィールドをレスポンスに用意し、それを元にエラーハンドリングを行うというものがあります。

message Response {
   string error_message = 1;
}

このように、独自にエラーメッセージを入れるフィールドを定義するとサーバークライアント間に暗黙のルールを持たせてしまうことになり、保守性を損ないます。スキーマからコード生成するライブラリにはエラーハンドリングの方法が提供されているので、それに従うと良いでしょう。grpc-goの場合、生成されたinterfaceの第二戻り値はerror型となっています。この戻り値にnilでないインスタンスを渡してやることでクライアント側にエラーを伝えることができます。

また、gRPCにもステータスコードが定義されています。サーバーが適切なコードを返すことでクライアントへエラーの情報をより良く伝えることができるでしょう。grpc-goのステータスコードの付与は次のようになります。

import (
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

err := ... // Do something
if err != nil {
  return nil, status.Error(codes.NotFound, err.Error())
}

次に、gRPCサーバーをGo言語で実装する場合の注意点として、メッセージからフィールドを取り出す際にGetterメソッドを使用してnil-safeにするというものがあります。Getterメソッドを使用せずにフィールドを取り出すと意図せずnil pointer dereferenceによるpanicを発生させてしまう場合があります。以下のようなメッセージを考えます。

message Request {
  Entry entry = 1;
}

message Entry {
  string title = 1;
  string body = 2;
}

このスキーマからは以下のようなGoの構造体が生成されます。

struct Request {
  Entry *Entry
}

struct Entry {
  Title string
  Body  string
}

RequestからEntryTitleを取り出す場合、Request.Entry.Titleのように呼び出してしまうと、Request.Entrynilの場合、panicが発生してしまいます。これを避けるためには、各メッセージに実装されているGetterメソッドを通して値の取り出しを行うと良いでしょう。Getterメソッドは以下のようにnil-safeな値の取り出しを提供しています。

func (x *Request) GetEntry() *Entry {
  if x != nil {
    return x.Entry
  }
  return nil
}

func (x *Entry) GetTitle() string {
  if x != nil {
    return x.Title
  }
  return ""
}

リクエスト・レスポンスのデータサイズ

gRPCのリクエストのデータサイズの上限は4MBとされています。しかし、サーバーは受け取ったリクエストメッセージをメモリへ展開するという仕様となっており、上限サイズのメッセージを大量に受け取り続けるとOut of Memoryを起こす可能性があります。これを避けるためにリクエストのデータサイズの規模がわかる場合には受け取る上限サイズを明示的に設定すると良いでしょう。grpc-goにはサーバー起動時にリクエストのデータサイズ上限を指定できるMaxRecvMsgSize()というメソッドが用意されています。

また、レスポンスサイズが大きくなる場合はgRPCのstream機能を使用するか、ページネーションを提供すると良いでしょう。

チャンネル

gRPCでは、サーバー・クライアント間の接続がチャンネルという概念で抽象化されています。チャンネルの作成はHTTP/2のコネクションを確立するため、TCPハンドシェイクとTLSハンドシェイクが行われます。よってチャンネルの作成はコストの高い処理となるので、一度作成したチャンネルはできる限り使い回すように心がけましょう。ただ、gRPCサーバーの前にロードバランサーなどを立てている場合はアイドルタイムアウトによる接続の切断に注意する必要があります。例えばAWSのApplication Load Balancerはデフォルトで60秒アイドル状態であると接続を切断するという仕様になっています。このアイドル状態を避ける、keepaliveの仕組みを利用してコネクションを維持します。また、サーバー側はクライアントが送る過剰な頻度のkeepaliveのためのpingからサーバーを保護する設定も行うべきでしょう。grpc-goではping送信の間隔が一定時間より短い場合、チャンネルを閉じるという設定が可能です。

まとめ

今回は、gRPCのスキーマ設計および実装のベストプラクティスをいくつか紹介しました。Sprocketではバックエンドのマイクロサービス間通信として積極的にgRPCを採用しています。

Sprocketで働きませんか?

弊社ではカジュアル面談を実施しております。
ご興味を持たれましたら、こちらからご応募お待ちしております。

参考文献

Sprocketテックブログ

Discussion