📝

protobufのoptionalをそろそろ覚えたいので言語化してみる

に公開

背景

チームでgRPCを用いてフロントエンドとバックエンドをスキーマ駆動で開発を行っていた。

チームで設計会と称してDB定義とスキーマ定義を決めるMTGを毎スプリントの開始で行っていたが、
毎回protobufのoptionalの定義や使い方を確信を持って言語化できなかったので調べていた。
ふわっとした理解を脱して説明できるようになりたいのでこの機会にちゃんと調べて説明してみる。

そもそものprotobufとgRPCについても改めて調べて軽く説明してみる。
optionalの解説見たい方はすっ飛ばしてくださいm(..)m

protobufとは

https://protobuf.dev/

Protocol buffers are Google’s language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages.

つまり、、、

Protocol Buffers (protobuf) は、Googleが提供する言語やプラットフォームに依存しない拡張可能な構造化データのオブジェクトやデータ構造を、ネットワーク転送やファイル保存に適した形式に変換する仕組みである。

XMLの様だが、よりコンパクトで高速かつシンプルな設計らしい。
データ構造を一度定義すれば、生成された専用ソースコードを使用して、さまざまなデータストリームや言語を通じて構造化データの読み書きを容易に行うことができる。

主な特徴

protoファイルを使って、データの構造(メッセージ)を定義する

  • ここでoptionalとかの話も出てくる。

コード生成

  • protoファイルから様々な言語に対応したコードを自動生成できる。これにより、開発者は手動でデータのエンコードやデコードを行う必要がない。

効率性

  • JSONやXMLに比べて、はるかに高速かつデータサイズが小さい。テキストベースではなく、バイナリ形式でデータを扱うから。

後方互換性:

  • 既存のproto定義にフィールドを追加しても、古いバージョンの定義で作成されたデータも読み込むことができる。これは嬉しい。

gRPCとは

https://grpc.io/

gRPC is a modern open source high performance Remote Procedure Call (RPC) framework that can run in any environment. It can efficiently connect services in and across data centers with pluggable support for load balancing, tracing, health checking and authentication. It is also applicable in last mile of distributed computing to connect devices, mobile applications and browsers to backend services.

gRPCは、あらゆる環境で実行可能な現代的なオープンソースの高性能リモートプロシージャコール(RPC)フレームワーク。
ロードバランシング、トレース、ヘルスチェック、認証のプラグイン対応機能を備え、データセンター内およびデータセンター間のサービスを効率的に接続できる。
また、分散コンピューティングの最終段階において、デバイス、モバイルアプリケーション、ブラウザをバックエンドサービスに接続する用途にも適用可能。

そしてgRPCは、クライアントとサーバー間の通信に前述したProtobufを標準のIDL(Interface Definition Language)として採用している。

gRPCにおけるprotobufの役割

サービスの定義

  • gRPCでは、protoファイルを使用して、サービスが提供するメソッドとその引数、戻り値のデータ形式を定義。

メッセージの定義

  • メソッドが受け取るリクエストや、返却するレスポンスで用いる型のようなデータの構造(メッセージ)もprotoファイルで定義。

コード生成

定義したデータ構造に応じてコードが生成される。

  • サーバー側

    • サービスを実装するためのひな形コードを生成。開発者は、このひな形に具体的なロジックを実装。
  • クライアント側

    • サーバーのメソッドを呼び出すためのスタブコードが生成される。開発者は、このスタブを使ってサーバーの機能を簡単に呼び出せる。

これらにより、異なる言語で書かれたクライアントとサーバーが、厳密な型定義に基づいて、高速かつ効率的にデータをやり取りできるようになり、堅牢で高性能な分散システムを構築することが可能になる。

protobufのoptionalの定義

ここからが本題というこいうことで、protobufのoptionalについて説明する。

optional: (recommended) An optional field is in one of two possible states:

the field is set, and contains a value that was explicitly set or parsed from the wire. It will be serialized to the wire.
the field is unset, and will return the default value. It will not be serialized to the wire.
You can check to see if the value was explicitly set.

つまり、、、

オプションのフィールドは、次の2つの状態のいずれかとなる。

デフォルト値(ゼロ値)を明示的に設定した場合

optionalが設定されたフィールドに、その型のデフォルト値(ゼロ値)を明示的に設定した場合は、フィールドは「設定されている」状態となる。

例) optional int32 age = 1というフィールドで、age = 0と設定した場合。

  • 0という値は意図的に設定された値として扱われる。したがって、データはシリアライズ(バイト列に変換)されてワイヤ(通信経路)に送信される。
  • 受信側は、ageフィールドに0という値が格納されていることを認識でき、has_age()のようなメソッドで「値が設定されている」ことを確認できる。

フィールドに値を何も設定しなかった場合

optionalが設定されたフィールドに、何も値を設定しなかった場合は、フィールドは「設定されていない」状態とみなされる。

例) optional int32 age = 1というフィールドで、何も値を代入しなかった場合。

  • この場合、フィールドはシリアライズされず、データはワイヤに送信されない。これにより、通信量が削減される。フィールドごと自体消えるということ。

  • 受信側がageフィールドにアクセスすると、protobufはデフォルト値である0を返す。しかし、has_age()のようなメソッドで「値が設定されていない」ことを確認できる。これにより、デフォルト値が本当に設定されたのか、それとも単に省略されたのかを区別できる。


ユースケース

optionalについて整理できたのでリクエストレスポンスごとにoptionalを付与するべきケースに関して解説する。

リクエスト

値の有無を明確にしたい

  • クライアントが意図的にデフォルト値(ゼロ値)を送信したいのか、あるいはそのフィールド自体を省略したいのかを明確に区別できる。

例) ユーザーのプロフィールを更新するAPIにおいてageフィールドがint32、デフォルト値が0だとする。

  • ageフィールドがリクエストに含まれない場合、optionalをフィールドに付与しておけばサーバー側で「年齢が更新対象ではない」と判断できる。
  • ageフィールドがoptionalでなかった場合、ユーザーが本当に年齢を0に設定したいのか、それとも単にフィールドを省略した結果なのかを区別することできない。

後方互換性

  • APIに新しいフィールドを追加する際に、既存のクライアントが影響を受けないようにするために使う。
    • 後から追加されたフィールドにoptionalを付けていないと古いバージョンからリクエストを送信した際に送信できなくなってしまうので必須ではないフィールドはoptionalにしておくことでフィールドに値がない際の処理をロジックで担保しておけば後方互換性が保たれる。

optionalを使うべきではないケース

  • 反対に必須フィールドの様なリクエストにそのフィールドがなければ、サーバーが処理を実行できない場合にはoptionalが使うべきではない。

レスポンス

データの存在が不確実

  • データが存在せず、送信する必要のないフィールドに関してはoptionalを付けておけば無駄なデータを送る必要がなくなる。

後方互換性

  • レスポンスも同様に新しいフィールドを追加しても古いバージョンを使っているクライアントへ後方互換性が保たれる。

optionalを使うべきではないケース

レスポンスにおいて、そのフィールドが常に何らかの値を持つことが保証されている場合には使うべきではない。

bool値

bool値はデフォルト値がfalseなので最もoptionalの有無による影響を受けるケースである

リクエスト、レスポンス両方においてoptionalをフィールドに定義しておくことで明示的にfalseを付与したのか未設定なのかをサーバやクライアントに伝えることができる。

boolが必須パラメータの場合

必須パラメータの場合にはoptionalを定義するべきではない。
確かに、optionalを定義しないとboolの値がfalseなのか未設定なのか区別をつけることができなくなってしまう。

しかし、それよりも必須パラメータであるということをクライアントやサーバーに定義し伝える事の方がProtobufの責務として守られるべきだからである。
これはbool以外のフィールドでも、もちろん当てはまる。

optionalで定義してサーバー側やクライアント側でバリデーションするアプローチも可能だが、これはAPIの設計思想として矛盾が生じてしまう。

「このフィールドはoptionalだけど、実際は必須」という状況は、スキーマを見ただけでは開発者は知ることができない。ドキュメントを読むか、実際にコードを読んだりデバッグで試してみて初めてその事実を知ることになる。

もちろん、チーム内で認識を合わせていて必須パラメータという共通認識がズレ続けないのならoptionalを付けていても問題はないのだが、

このような定義外のドメイン知識をロジックに組み込むことは暗黙知を増やすことに繋がり、メンバーの変更や時間の経過で認識のズレを生む。クライアントとサーバー側の開発者が別チームだった場合などはなおさらである。

optionalは、本来「このフィールドは送っても送らなくてもよい」という意図を表現するためのものなので、これを必須パラメータに使うと、APIの設計意図が不明瞭になってしまうのだ。

本来はprotobufで必須フィールドと定義してそれルールをクライアント側、サーバー側でロジックで担保するといった流れのほうがスキーマ駆動として健全だと思う。

必須フィールドをリクエストやレスポンスに追加した際に後方互換性を保つためには

では、後方互換性を保ちたいけど、必須フィールドなのでoptionalは付与したくない時にはどうするべきなのだろうか。

下記のように新しいメッセージとしてスキーマを定義するのがベストプラクティスだろう。
実務では外部に公開しない限り、クライアント、サーバー間の開発者で連携を取れば良いのでこの様な考慮は必要ないが一応。

// 既存のメッセージ(変更しない)
message CreateUser {
  string name = 1;
  string email = 2;
}

// 必須フィールドを追加した新しいメッセージ
message CreateUserV2 {
  string name = 1;
  string email = 2;
  string department_id = 3; // 必須フィールド
}

おわりに

これまではboolはoptionalにしてfalseと未設定の区別をできる様にしておこうみたいな方針で考えていたが、バッドプラクティスだったことが今回初めて分かったのでこの記事を書いてみて良かった(クライアントとサーバーが同じチームだったので問題はなかったが、、、)

やはりフワッとした理解ではなく言語化して説明できる様にする過程でアウトプットするのは新たな気づきがあって良いのでここに時間をかけることを続けてみようと思う。

GitHubで編集を提案

Discussion