この記事について
Money Forward Engineering 2 Advent Calendar 2023 - Adventar の 20日目 の記事です。
はじめに
Protobuf使っていますか?
私はKafkaでメッセージをやり取りするのに使っています。
バイナリデータを扱うといっても、非常に柔軟だなぁという認識を持っているので、私がまだ新卒だった頃にやっていたような、
Cで書いて動かしているソケットサーバで受信したバイナリメッセージを、ファンクションポインタを使って動的にメソッド呼び出しするようなものを、Protobufを使ってWEBの世界でやってみても良いんじゃない?と思ってたりします。
(gRPC-Webってやつでそういうのをやってみたい)
それは一旦おいておいて、このページではProtobufで定義したメッセージ構造を、抜本的に変更しても安全に行うことができりょって方法を紹介します。
この方法は、私が所属するチームで実際に行い、無停止でリリースしています。
※この手の話が見当たらなかったので、一応自分で編み出しました
対象はKafkaでメッセージをやりとりしているフォーマットですが、それ以外にも色々使えると思います。
※このページの説明上はKafkaのトピックに寄せて書きます。
ちなみにProtobuf3未満でやった場合の動作がどうなるかはわからないです。3が対象です。
Protobufの定義について
message EventV1 {
int32 event_id = 1;
Hoge hoge = 2;
google.protobuf.Timestamp occurred_at = 3;
}
message Hoge {
int32 hoge_id = 1;
string message = 2;
}
はじめ上記のものに対して、EventにHoge以外も含めたい、みたいなのを考える必要が出てきたとします。
バイナリデータのやり取りに慣れている人ならば、以下のように考えるのではないでしょうか。
(ちなみに私は考えました)
- event_idはint32だから4バイト読み込み。
- 次にHoge構造体がくるから、その分を読み込む。(string messageだからそんな単純ではないはずだけど)
- Timestampのデータ数分読み込む。
これは単純に定義順にバイナリデータをそのバイト数分型はめして取る、というC言語でネットワーク触ったことがあればおそらく誰もが通る道です。
これに当てはめると、トピック分けるか〜という考えが湧くか、一旦使わないバイトをゼロ埋めでもして、後ろにくっつけるか、みたいなことをしたくなるかもしれません。
ただ、Protobufではその方法を考える必要はありません。
Protobufの定義の右辺に1とか2とか書いてあるものは定義順ではなく、キーとなります。
JavaやKotlinで架空の型を定義するなら、Map<Int, Binary>みたいな感じです。
これを見てわかるとおりで、Keyが被らなければ何ら問題ありません。
というわけで以下みたいに定義し直しました。
message EventV2 {
int32 event_id = 1;
int32 sturcture_version = 15;
EventType event_type = 4;
oneof content {
Hoge hoge = 5;
Fuga fuga = 6;
Foo foo = 7;
}
google.protobuf.Timestamp occurred_at = 3;
}
enum EventType {
UNSPECIFIED = 0;
HOGE_EVENT = 1;
FUGA_EVENT = 2;
FOO_EVENT = 3;
}
message Hoge {
int32 hoge_id = 1;
string message = 2;
}
message Fuga {
string message = 1;
}
message Foo {
string foo = 1;
}
右辺の数字に同じものを使用すると、古いメッセージ構造を使用してデシリアライズしたときに、読み込んでしまいますが、
上記のように全く異なるキーとして定義を行いデシリアライズをした場合に関しては、古いメッセージ構造で読み込んだとしても無視されるか、unknown fieldとしてマークが付くだけとなります。
※ここの挙動は使用しているライブラリに寄りますが、エラーになるものには遭遇したことはありません。
読み込みについて
このページではKafkaトピックに放り込まれるデータを考えているため、以下のようなケースを考える必要があります。
※リリース時には混ざる可能性があったり、障害が起きた場合に、オフセットを巻き戻したりしたい場合を考えてます。
Kafkaでなくとも、gRPCでも一瞬こういうのは起きるかもです。
この絵の通りで、Consumer側はKafkaにメッセージが保存されている間(デフォルトは7日)は、過去のものが混ざる前提でデータを読み込まないといけません。
ここで役に立つのがEventV2に追加したsturcture_versionとなります。
EventV2として書いたデータには、「2」でも入れておけばいいです。
ただ、EventV1に関してはフィールドそのものがありません。
でも心配はありません。Protobuf3ではデフォルトoptionなので、EventV1でそのデータを見に行ってもnullとなります。デフォルト値で0にでも書き換えて、1以下ならEventV1としてデシリアライズする、とでもしておけば良いでしょう。
※Protobufv2以下でやった場合、どうなるかわからない。
なので戦略的には、EventV1でデシリアライズして、sturcture_versionが1以下であることを確認するのが良いでしょう。
(EventV2からでもいいけど、先に受信する可能性が高いのはV1の方なので、そっちにV2を一旦デシリアライズさせて死なないことを確認できた方が精神的に楽だったので、私はそうしました。動作確認してればV2からデシリアライズさせても問題ないです)
KafkaからEventV1のメッセージが完全に消えたら、EventV1でのデシリアライズは削除して大丈夫です。
余談
EventV2にoneofを使うようにしてますが、これはCで言うとこの共用体(Union)に値します。
この例ではHoge/Fuga/Fooのうちのどれか、を表現するのに使えて非常に便利です。
どれを使えば良いかと言うのは、EventTypeを見て決められるようにしています。
(プログラミングをミスって)複数入れた場合は、右辺が一番大きいもので置き換わるので、例ではFooになります。
最初からoneof使っとけば良かったなぁという反省が私にあったので、ついでに共有しました。
※この仕組みを使えば、ファンクションポインタに近いことをできると思ってます。
最後に
Protobufでメッセージ構造を劇的に変えても問題ない、というのをお届けました。