🐃

BigQueryでprotobufをパースする関数を作った

2023/11/06に公開

bqpbというライブラリを作ったので解説します。

https://github.com/qnighy/bqpb

使い方についてはリポジトリ内のドキュメントを参照してください。

動機

protobufはGoogleが開発したバイナリシリアライゼーションフォーマットです。msgpackCBORもバイナリシリアライゼーションフォーマットですが、これらがスキーマレスフォーマットに分類されるのに対して、protobufはスキーマ依存のフォーマットです。この意味では同じくスキーマ依存のフォーマットであるASN.1 DERに近いと考えるのがよいでしょう。

一方、BigQueryはGoogleによるデータウェアハウスです。データへのアクセスにはもっぱらBigQuery用のSQLが用いられます。

BigQueryにはJSONを扱う関数群がありますが、protobufを扱う関数は今のところありません。もし何らかの理由で(たとえば簡潔であるとか、protobufでやり取りされたデータをそのまま保存したいとかの理由で)BigQueryにprotobufのデータを保管している場合、その分析は困難になってしまいます。

実はこの問題には以前に実際に業務で遭遇しており、そのときは即席のパーサーを書いて対処しました。

https://speakerdeck.com/qnighy/parsing-protobuf-in-bigquery

https://gist.github.com/qnighy/79d5eedbd4cf26a573c2cbd09a4b3956

しかし、これでは汎用性がなく、他の人が同じ解決方法の恩恵を受けることができません。そこで、いかなるユースケースでも使えるような改良版を作ることを考えました。

どうやったか

BigQueryにはプログラムを書く方法がいくつかあります。

今回はJavaScript UDFを使うことにしました。パーサーのようにある程度アルゴリズム的な複雑さを持つ問題は、汎用のプログラミング言語が適していると判断したためです。Remote Functionsはライブラリではなくサービスとして運営することになってしまうため除外しました。

BigQuery UDFのJS環境

はじめに、BigQuery UDFのJS環境を調査しました。それはUDF上で以下のようなコードを実行することで概ねわかります。

return Object.getOwnPropertyNames(globalThis).join(",");

すると、BigQueryのJS環境では、ECMA-262で規定されているものに加えて、以下の追加機能しか持たないことがわかりました。

  • console
  • WebAssembly
  • dremel_js_extensions
    • dremel_js_extensions.extern_params -- 詳細不明
    • dremel_js_extensions.NativeDate -- 詳細不明だが、おそらくTIMESTAMP型のサポートのためのものだと思われる

たとえば、ブラウザやNode.jsに存在するatobはBigQuery環境には存在しません。存在しないものについては基本的に自作することになります。

構成

最終的に出力したいものは、単独の関数として動作するJavaScriptコードです。しかし、開発時はこのフォーマットではあまりに不便です。そこで、以下のような構成をとることにしました。

  • 開発時はTypeScriptのモジュールとしてパーサーを実装する。
  • 専用のCLIツールでモジュールを関数に変換する。

これを達成するために最適なランタイムとしてDenoを選びました。テストはDenoに組み込みのテストフレームワークで書けますし、何も設定しなくてもすぐにTypeScriptが利用できます。変換ツールを書くのに必要だったBabelとTerserはnpmから取得できます。

インターフェース

protobufを入力するとJSONが返ってくるインターフェースにしました。BigQueryはJSONの操作関数が充実しているため、構造化データの操作にまつわる諸々のツールを自前で提供するよりも、JSONに変換してあとは利用者に任せるほうが有益であると判断したためです。

protobufのパースには本来スキーマが必要です。これについては以下のように対応しました。

  • スキーマがない場合、実際のメッセージからスキーマを推定して表示する。これによりトライ&エラーによる漸進的な解析の助けになることを意図しています。
  • スキーマが必要になったら引数で指定する。スキーマの指定形式は独自のJSONにしています。これは実装をなるべく軽量化するためと、スキーマのうち現在の解析に必要な部分だけを取り出して指定しやすくするためです。

実装

実装にあたっては、protobufの以下の仕様書を参照しました。

仕様書では分かりづらい部分や曖昧な部分の解決のため、また仕様書を正しく読解できているかの確認として、Goの既存実装に対するテストを書いて挙動を確認しながら実装を進めました。そのためbqpbのリポジトリにはGoで書かれたテストが含まれています。

実装では機能性を優先としつつも、おおむね生成されるコードを節約する方向を意識しました。

たとえばBase64のエンコードとデコードは存在しないため仕方なく自作していますが、デコードは信頼できる入力しかないことがわかっているため簡易的な実装になっています。一方、UTF-8のデコードはdecodeURIComponentによって間接的に実装されているため、この機能を再利用しています。

ジェネレーター

モジュールをUDFに変換する部分は自作しています。変換先が「関数の本体部分のみ」という特殊な形式なこともその理由です。 (とはいっても実はCommonJS Modulesがそれに該当するとも言えるのですが)

ジェネレーターはおおむね以下の手順を取っています。

  1. BabelでTypeScript固有の構文を取り除く。
  2. Babelの自作プラグインで以下を行う。
    • exportを取り除く。
    • 所定の名前でexportされている関数をその場で呼び出しreturnする関数呼び出しを最後に付加する。
  3. Terserで圧縮する。
  4. UDFのガワを付加する。

まとめ & 自慢したいこと

BigQueryという分析ツールにはprotobufというフォーマットを解析する手段がなかったので、JavaScriptで頑張って自作しました。TypeScriptで書いてビルドする仕組みを自作する・参照実装と比較しながらテストするなどの工夫で爆速で実装できました。

ほぼ仕様通りかつほぼ全機能を網羅して、100行ちょっとに収まる便利関数になりました。1週間でここまで作れたのはかなり凄いのでは?

その他

記事についているアイコンは protocol "buffer" にちなんで buffalo です。

Discussion