Protocol BuffersのNullの取り扱いの問題 Link to heading

Protocol Buffersのproto3の仕様ではmessageの中の値をOptionalで表現する記法がない。 値を詰めずに送ると入れ子の値がデフォルト値になってしまう。

しかし、アプリケーションの設計によってはOptionalな値を表現する必要が出てくる。

そういった時、プリミティブな型ならgoogle/protobuf/wappers.protoを使う方法がある。

以下のように使うことでmsgはOptionalな値として取り扱えるようになる。

syntax = "proto3";

package example;

import "google/protobuf/wrappers.proto";

message Example {
  google.protobuf.StringValue msg = 1;
}

wappers.protoを読むと、google.protobuf.StringValueは内部にstringを持つmessageとして定義している。 入れ子構造になったmessageはデフォルト値がnullになるという仕様を利用して擬似的にOptionalを表現する仕組みであるということがわかる。

しかし、プリミティブな型ではないmessageを定義して入れ子構造にするようなmessageを定義すると、入れ子になったmessageのデフォルト値はnullであるという特性からそれがOptionalなのかRequiredなのかが分かりづらくなるという問題がある。

例えば、以下のようにDecimal 型が定義されたとき、それを入れ子構造にするようなOrder型があるとする。

syntax = "proto3";

package example;

message Decimal {
  int64 value = 1;
  int64 exp = 2;
}

// アプリケーションに注文をするメッセージ
message Order {
  // 支払う金額(Required)
  Decimal amount = 1;
  // 通貨単位(Required)。
  int64 currency_unit = 2;
  // 使用するポイント(Optional)
  Decimal point = 3;
}

Order型では金銭を表現しているため数値を正確に表現できるDecimal型を使って金額とポイントを定義している。 しかし、アプリケーションの都合でamountはRequiredにpointはOptionalにしたいとき、どちらもデフォルト値がnullになってしまうため、そのままだと両方を同時に表現できません。 また、0ポイントなのかnullなのかで挙動に明確な違いがある場合は0を送るという手段も成立しない。

Orderぐらいの定義量であればコメントやドキュメントで対応ができる。 しかし、生成されるコードがOptionalであるということは変わらないため、せっかく型付でコード生成をしてくれるProtocol Buffersのメリットが半減してしまう。 また、人類にドキュメントを書く読む管理するという行為は難しすぎるため、プロダクトの成長に従ってドキュメントは陳腐化していってしまう。

解決策 Link to heading

DecimalをOptionalで表現するために以下のようなNullDecimal型を定義してみる。

message NullDecimal {
  bool has_value = 1;
  Decimal decimal = 2;
}

定義の通り、値を持っているかどうかを判別するhas_valueと実際の値になるDecimal型を持つ。 Goを書く方はこの時点でもわかるかもだが、sql.Null~型と同じ形を表している。

上記のNullDecimal型を使用してOrder型を再定義すると以下のようになる。

syntax = "proto3";

package example;

message Decimal {
  int64 value = 1;
  int64 exp = 2;
}

message NullDecimal {
  bool has_value = 1;
  Decimal decimal = 2;
}

message Order {
  // 支払う金額。Required
  Decimal amount = 1;
  // 通貨単位(enumにするべきですが省略)。
  int64 currency_unit = 2;
  // 使用するポイント。Optional
  NullDecimal point = 3;
}

OptionalにしたかったpointNullDecimal型にすることで、その属性を明示的にできた。

その上で「Protocol Buffersのmessageの値は全て情報を詰める」という取り決めを作っておくことで、もしアプリケーション都合でpointがRequiredな属性になっても、コードを生成すれば属性が変わっていることに気づくことができる。

デメリットは記述量が少し増えることだが、明示的に書くことによって減るコストに比べればだいぶ安く済むと思われる。

値しか持たないため同質のものかどうかは微妙だが、NullをそのままNullとして扱わず特別な型を用意するという発想はSpecial Case Patternとして昔から提唱されているものでもある。

Protocol Buffersはシンプルな設計だが、プログラミング言語的な表現力も兼ね備えているため、こういったOOPにおける原理原則を適用することが可能。