こんにちは。バクラク事業部 Enabling チームの @izumin5210 です。最近「HUNTER×HUNTER」の既刊を全部読みました。
この記事はLayerXテックアドカレ2023の9日目の記事です。
RDB や KVS などのデータ保存先において、データを正規化せずにそのまま保存したいと思うことはありませんか?
8月にリリースされた「バクラク請求書発行」というプロダクトには「柔軟なレイアウトカスタマイズ」機能が搭載されています。リンク先の画面操作イメージを見ていただくと、この機能の雰囲気を理解していただけると思います。この機能が扱うレイアウトデータはまさに「関係の正規化をせずに保存したいデータ」でした。
このような場合、皆さんはどのようにデータを保存しますか?最も手軽な方法は、データを JSON などに変換して保存することでしょう。
ただし、 JSON などのデータ構造をDBに保存する方法は柔軟性が高い一方で、 DB スキーマで内容が制約されないため、メンテナンスや拡張が難しくなることがあります。そのような問題の一つの解決策として、この記事では Protobuf を紹介します。
前提: 半構造化データでもなるべくスキーマを定義しておこう
スキーマレスで柔軟性が高い、と言われますが、本当にスキーマは存在しないのでしょうか。
確かに、完全に自由な JSON を扱うこともあるでしょう。しかし、多くの場合、 JSON でも一定の形式でデータを構造化しているはずです。この場合、 DB としてはスキーマが存在しないかもしれませんが、データ自体にはスキーマ(構造の定義)が存在します。このスキーマを定義・管理することで、メンテナンス性や拡張性が改善します。
JSON のスキーマ定義は、その名の通り JSON Schema というものがあります。 OpenAPI Specification でも JSON Schema を利用しているため、触れたことがある方も多いでしょう。 DB に保存する JSON データのスキーマ管理にこの JSON Schema を使うのも一つの方法ですが、この記事では Protobuf を使う方法を提案します。
Protobuf (Protocol Buffers)
Protobuf (Protocol Buffers) は、言語やプラットフォームに依存しない構造化データのシリアライズを可能にする仕組みです。インターフェース定義言語(IDL: Interface Definition Language)を利用し、IDL で記述されたデータ構造からシリアライズ・デシリアライズの実装を生成します。 gRPC や Connect でも使用されています。
DB に保存するデータを Protobuf で扱う利点
実装とリンクしたスキーマを得られる
Protobuf は IDL で記述されたスキーマに基づいて、シリアライザ/デシリアライザの実装を生成します。
逆に言えば、スキーマを書かなければ実装は存在しません。
「スキーマは書いたが、それが実装に反映されない」という問題はよく起きますが、Protobuf ではその問題は起こりません。
人間が読み書きしやすいスキーマになる
「今さらProtocol Buffersと、手に馴染む道具の話」という記事に言いたいことは全部書いてあるので、そちらを読んでもらうのが一番いいでしょう。
読み書きしやすさの違いを体感するには、実際のコードを見比べてもらうのが早いでしょう。以下2つのコードは、上記の記事からの引用です。2つは同じスキーマを表現していますが、どちらが読み書きしやすいでしょうか。
// 「今さらProtocol Buffersと、手に馴染む道具の話 #JSON - Qiita」から引用 // https://qiita.com/yugui/items/160737021d25d761b353 syntax = "proto3"; package example.protobuf; message SimpleMessage { message HeaderItem { string name = 1; string value = 2; } enum Type { START = 0; BLOB = 1; END = 2; } uint64 id = 1; Type message_type = 2; repeated HeaderItem headers = 3; bytes blob = 4; }
# 「今さらProtocol Buffersと、手に馴染む道具の話 #JSON - Qiita」から引用した JSON を YAML に変換したもの # https://qiita.com/yugui/items/160737021d25d761b353 "$schema": http://json-schema.org/draft-06/schema# description: an example schema type: object properties: id: "$ref": http://json-schema.org/draft-06/schema#/definitions/nonNegativeInteger message_type: enum: - START - BLOB - END default: START headers: type: array items: "$ref": "#/definitions/HeaderItem" blob: type: string contentEncoding: base64 contentMediaType: application/octet-stream definitions: HeaderItem: type: object properties: name: type: string value: type: string
YAML はつらい人にはとことんつらいと思いますが、Protobuf は Go や TypeScript などの最近のプログラミング言語に慣れた人なら読み書きにそこまで苦労しないのではないでしょうか。
また、コメントも普通のプログラミング言語っぽく書くことができます。そのデータ構造の歴史・ Why は構造のみから読み取ることは難しいですが、それもコメントで記述することで後世の人々に伝えることができます。もちろん JSON Schema でも同じことはできますが、スキーマ記述をパッと見たときの読み取りやすさは Protobuf に軍配が上がりそうです。
// バクラク請求書発行で定義されているスキーマの一部 message TableColumn { // Required. 行の背景色。 // 2個以上ある場合はその順番で繰り返される。 // 常に1つ以上の値が入るが、万が一ない場合は #ffffff が1つある状態と同等として扱われる。 repeated Color background_colors = 1; // Required. 右側の罫線の設定。ただし、一番右の列の設定は無視される。 NodeBorder border_right = 2; // Required. 行の下の罫線の設定。ただし、一番右の列の設定は無視される。 NodeBorder border_bottom = 3; }
前方互換・後方互換を保ちやすい仕組み
DB に保存するデータの生存期間は、APIでやり取りされるデータよりも通常長くなるため、互換性には注意が必要です。 Protobuf はこの互換性を保つための機能が充実しています。
まず、 Protobuf のシリアライザとデシリアライザは、未知のフィールドを無視し、欠けているフィールドには型に合わせてゼロ値を補います。したがって、新しいコードで古いデータを読む場合や、逆に古いコードで新しいデータを読む場合(例えば、フロントエンドがバックエンドより後にデプロイされた場合)でも、クラッシュすることなく動作します。
また、使わなくなったフィールドを予約しておくことで、同じ名前のデータが再利用されるのを防ぐことも可能です。
// 「protobufスキーマとgRPC通信 - Wantedly Engineering Handbook」より引用 // https://docs.wantedly.dev/fields/the-system/apis#to message User { uint64 id = 1; // 将来間違って2を再利用しないようにreserveしておく reserved 2; // JSONマッピングを使っている場合、フィールド名も予約するとよい reserved "name"; Profile profile = 3; }
また、万が一スキーマに破壊的変更(互換性を壊すような変更、たとえば名前や tag number はそのままに型が変わる場合など)があった場合にも、 スキーマの破壊的変更を機械的に検知することができるツールが提供されています。
JSON への serialize も可能
ここまで読んで、「とはいえ DB にバイナリで保存するといざというときつらくない?」と思った方もいるのではないでしょうか。
Protobuf はバイナリ形式のデータであるとイメージされがちです。しかし、実は Protobuf は JSON への Mapping が仕様として定義されています。
Go や JS などではその仕様に則った JSON へのシリアライザ・デシリアライザの実装も提供されます。
これらを使えば、「Protobuf IDL でスキーマを定義し、保存するデータは JSON 形式にする」ということも可能です。
雑に SELECT * FROM ...
した結果を眺めるだけでも理解できる・最悪気合いで内容を検索できる、というのはいざというときの最終防衛ラインとして重要になることがあります。アプリケーションで利用するメインの DB には Protobuf で入れておきつつ、BigQuery などに分析可能な JSON で保存しておく というのもアリかもしれません。
(Protobuf をパースする BigQuery 用 UDF を提供している OSS があるので、BigQuery ではそれを使うという手もあるかも)
バイナリ形式(Wire format)ならデータ量を小さくできる
スキーマにもよるとは思いますが、Wire format を Base64 encode して文字列にしたとしても JSON と比べて小さくなりやすいです。プロダクト上で扱っているデータを実際に見てみると、Wire format を Base64 encode したものは同じ内容の JSON とくらべて30%未満となっていました。
ここまではデータを保存する時の話ばかりでしたが、DB に保存するデータをシリアライズするタイミングは他にもあります。たとえば、データを API からフロントエンドに渡す場合です。ここで人間からみた読み書きしやすさを重視しなくていい場合などで、Wire format を利用できる可能性があります。
バクラク請求書発行では Backend と Web Frontend のやりとりは GraphQL を利用しています。一方で、書類のレイアウト情報については「レイアウトの一部のプロパティだけを利用する」ということはなく、常にオブジェクト全体を利用します。そのため、GraphQL の選択的データ取得を利用するメリットがほぼありません。なので、このデータは GraphQL 上では String とし、Wire format を Base64 encode したものをやりとりしています。
query GetTemplateLayout($layoutId: String!) { templateById(layoutId: $layoutId) { # どうせ全部使うので手で列挙する意味があまりないし、そもそも大変すぎる # layout { # pages { # nodes { # ... on TextNode { # # ... # } # # ... # } # } # } # base64 encode したバイナリを返したほうが楽だし、データ量も抑えられる layoutB64 } }
まとめ
JSON など DB スキーマに現れない形でデータを保存したい場合に、Protobuf を利用するという手法を紹介しました。
DB としてはスキーマレスだとしても、データとしてはスキーマ(構造の定義)が存在することも多いはずで、そういうところでも Protobuf は活躍してくれます。
「Protobuf の利用」といっても「IDL によるスキーマ記述, コード生成, JSON シリアライザを利用」「Wire format でデータ保存」など幅がありますが、少なくとも前者を導入することで JSON のつらさの多くを低減できるのではと思っています。
スキーマのない JSON 型でつらくなるのは今の自分ではなく近い将来の自分・引き継いでくれた他の人なことが多いでしょう。将来のために禍を残さないための一つの手段として、この記事で紹介した方法が役立てば嬉しいです。
あわせて以下の記事を読んでいただけると、この記事で伝えられなかった Protobuf の良さを感じてもらえるかもしれません。