世界を動かす技術を、日本語で。

プロトバッファは間違っている (2018)

概要

  • Protocol Buffers(protobuffers) の設計と実装に対する批判的な考察
  • 型システムの欠陥合成性のなさ といった構造的問題の指摘
  • 後方互換性・前方互換性 の主張に対する反論
  • Googleの内部実装 における課題の具体例
  • より良い設計案 の提案と現状の問題点のまとめ

Protocol Buffers(protobuffers)批判

  • protobuffersアマチュア的アドホック な設計による問題の温床
    • Google内部 でも同じ問題が発生
  • 型システムの貧弱さ が最大の課題
    • Javaの型システムに似ており、静的型付け・動的型付けの両者から不満
  • 多くの仕様が 後付け で追加されており、 一貫性・合成性 に欠ける
  • 既知の問題 に対して、 既に解決されている設計原則 を無視した対応

合成性の欠如

  • oneof フィールドは 繰り返し不可 という制約
  • map<k,v> の専用構文は他の型では使えない
  • ユーザー定義型 のパラメータ化ができず、汎用データ構造の再実装が必要
  • map のキーに使える型が限定的(stringのみ、enum不可)
  • map の値に他のmapを使えない
  • これらは 設計上の一貫性のなさ後付け仕様 の弊害

改善案の提案

  • すべてのフィールドを 必須 にし、 積型 として扱う
  • oneof独立した型(余積型) として昇格
  • 積型・余積型 を型パラメータで拡張可能に
  • これにより 任意のデータ構造 をシンプルに表現可能

スカラー型とメッセージ型の問題

  • スカラー型 は常に値が存在し、 未設定とデフォルト値の区別が不可能
  • メッセージ型 は内部的に有無の状態を持つが、アクセス方法が複雑
    • フィールド未設定時にデフォルト初期化が返される仕様
    • msg.foo = msg.foo; という代入が副作用を持ち、 直感に反する動作
  • has_foo() のようなメソッドで有無を判定する必要
  • これらの仕様により 抽象化・汎用化が困難

oneofフィールドの実装上の問題

  • oneof は本来 余積型 となるべきだが、実態は 排他的なオプション集合
  • セッターが他のフィールドを自動的にunsetするため、 予期しないデータ消失 の原因
  • 法則に則ったPrismやLens としての利用が不可能
  • 型安全・ポリモーフィックな操作 が実質的に不可能

後方互換性・前方互換性の幻想

  • protobuffersは データの意味的な保証をしない ことで互換性を主張
  • すべてがオプション扱いとなり、 不正なデータも型検査を通過
  • 防御的コーディング が全コードベースに拡散し、 集中管理が困難
  • 未知フィールドの保持 も現実的なアプリケーションではほぼ活用されない
  • DRY原則 を否定し、定義のインライン化を推奨
    • 将来的な分岐のために 再利用性・保守性を犠牲

より良い設計への示唆

  • 現代的な型システム の理解と適用があれば、仕様の大幅な簡素化と制約の撤廃が可能
  • 必須フィールド・積型・余積型・型パラメータ の導入による柔軟なデータ表現
  • 抽象化・型安全性・再利用性 を重視した設計の重要性

Hackerたちの意見

最初の一行も終わらないうちに「明らかに素人が書いたものだ」とか言ってる。これ、ただの怒りを煽るためのもので、読む価値ないよ。

自分の意見を伝える最良の方法は、相手を攻撃して自分の優れた知性を主張することだね。

そうそう、この記事は名誉の殿堂級の複合的誤謬で始まる。誰も主張してない仮想のアドホミネムを反論するストローマン。著者が一年以内にいくつかの大手テック企業から追い出された理由がなんとなく分かるね、LinkedInによると。

この記事が問題の詳細な分析と解決策も提供してくれたらいいのに。あ、実際そうだ!読んでみるべきだよ。

アマチュアが書いたものだけど、Google(世界で一番大きくて進んだテック企業の一つ)だけが抱える問題を解決してるよ。

そのラインの理由は根本的な緊張を示している。デビッド・ウィーラーが有名な言葉で言ったように、「コンピュータサイエンスのすべての問題は別の間接的なレベルで解決できる。ただし、間接的なものが多すぎる問題を除いて。」時間が経つにつれて、ますます巧妙な抽象が蓄積されていく。内面的に取り入れた抽象は見えなくなってしまう。それが私たちのやり方になって、他の人にどんなコストを強いているのか分からなくなる。すべての抽象は漏れがあるし、すべての抽象はメンテナンスプログラマーにとって障壁になる。これが、ブライアン・カーニハンが警告した問題につながる。「誰もがデバッグはプログラムを書くのの2倍難しいことを知っている。だから、書くときにできるだけ賢くなったら、どうやってデバッグするんだ?」結局、デバッグしなきゃいけないのは、あなたの抽象を知らないメンテナンスプログラマーだろう。Googleのアプローチから見える重要な知恵の一つは、業界全体が抽象に対して持つ傾向が有害だということ。特定の抽象が強力であっても、あまりにも多くなるとそれ自体が問題になる。だから、例えばGoは過剰な抽象を強く抑制するように設計されている。プロトバッファーは、言っている通りのことをする。意図されたシンプルな使い方をしていれば、うまく機能する。彼の不満は結局、「新しい抽象を生成するためにメタ操作を試みたけど、デザインがそれを許さなかった」ってことに集約される。それはアマチュアが書いたからではなく、ほとんどのプログラマーが無視できるほど賢いと思っているエンジニアリングの知恵を取り入れるために書かれているからだ。(過去の自分もそのプログラマーの一人だった。)技術は悪用されることもあるし、バカなことをする人もいるし、やりたいことができないこともある。もちろん。でも、KISSを守れば、うまくいく。シンプルに保てば保つほど、うまく機能する。これが、より良いエンジニアリングデザインを生み出すためのインセンティブだと思ってる。

if (m_foo = null) Googleをアマチュア呼ばわりして、その書いたコードが代入と比較演算子を区別できない一年生のエラーを含んでるなんて想像してみて。プログラマーが基礎的な技術について文句を言うクラスの愚痴がネット上にはあって、スキルの問題を認める代わりにそういうことを言ってる。もしその穴に深く入り込むと、最終的にはRustでカーネルを書き直す羽目になるよ。

うん、記事には皮肉がたくさんあって、彼らの主張を台無しにしてるよね。

プロトコルバッファはクソだけど、他のものも同じくらいクソだよね。逆互換性を保ちながら変更できることを定義して、逆互換性のある変更を強制するリンターがあるシリアライズ宣言フォーマット、他に何かある?その二つの条件だけで、せいぜい六つのフォーマットに絞られるけど、その中でプロトコルバッファが一番使われてる。記事には逆互換性のあるものは誰も使ってないって書いてあるけど、俺にはそれが奇妙に思える。プロトコルバッファを使って通信するN個のクライアントとサーバーを設定して、スキーマにフィールドを追加して、サーバーとクライアントをどの順番でもデプロイできるのは、他のフォーマットよりずっと楽だよ。プロトがクソなのはリモートプロシージャコールがクソだからで、プロトはそのクソさを隠そうとせずにむしろ露呈させるから。プロトや他の代替案に取り組んでいる人たちが改善を続けてくれることを願ってるけど、今使わないよりはマシだよ。

あまり広く使われてないけど、Typicalのアプローチは好きだな。https://github.com/stepchowfun/typical > Typicalは、互換性を壊さずにレコードタイプにフィールドを安全に追加または削除するための古典的な問題に対する新しい解決策(「非対称」フィールド)を提供してる。この非対称フィールドの概念は、和集合型でケースを追加または削除する際に互換性を保つという二重の問題も解決するんだ。

好きってわけじゃないけど、SBE(シンプルバイナリエンコーディング)は、逆互換性と前方互換性の領域ではまあまあの解決策だね。

ASN.1はメッセージのバージョニングを非常に正確に実装してる。リンターを実装するのは簡単だよ。

その通り。プロトバッファはJavaやGoと同じように考えてる - 少なくともC++で書いてるわけじゃないしね。あまり仕様がはっきりしてないJSONを使うのをやめるのは、こういう小さなストレスを考えると価値があると思う。

ゲームでプロトコルバッファを使ってて、互換性のあるやつを常に使ってるよ。ゲームのリリースごとにバージョン番号を含めてる。プロトを変更するときは新しいフィールドを追加して古いのを非推奨にして、バージョンを上げる。バージョン番号を使って古いフィールドを新しいものにアップグレードするための一連のステップを実行してる。

逆互換性を持つ変更を定義し、逆互換性のある変更を強制するリンターがある別のシリアライズ宣言フォーマットを挙げてみて。この記事では「逆互換性と前方互換性の嘘」というセクションでこれを扱っている。プロトコルバッファーを使った経験は、著者がこのセクションで説明していることと一致してる。

この記事では誰も逆互換性のあるものを使っていないって言ってるけど、それはちょっと変だと思う。Nクライアントとサーバーを設定してプロトコルバッファーで通信させて、スキーマにフィールドを追加して、サーバーとクライアントをどの順番でもデプロイできるのは、他のフォーマットよりずっと楽だよ。なのに著者はプロトバッファーの作者(元々はジェフ・ディーンたち)を「アマチュア」って呼ぶなんて、ほんと大胆だよね。

これを考えるときは、常に「代替案は何か?」ってことを探るべきだよね。そして、なんでより良いものがないのか。protobufのほとんどのユースケース、特にそのデザインに影響を与えたものが理解できない。ESPホストで使って、2つのMCU間で通信するために使ってるけど、見た中で一番摩擦の多いシリアライズプロトコルだし、バイト効率もあまり良くないかも。もしかしたら、特化したシリアライズライブラリ(bincodeやpostcardなど)の方が簡単かもしれないけど、ネットワークシステムに適用される抽象について何か見落としてる気がする。

その2つの基準だけで、せいぜい6つのフォーマットに絞られるけど、その中でProtocol Buffersが一番広く使われてる。こういうブログ記事で一番嫌なのは、ブロガーがいろいろ批判的で意見を持ってるのに、記事が2018年のもので、protobufがまだ支配的だってこと。しかも、こんなに長い間、ブロガーが自分の考えに合ったより良い解決策をまとめられなかったのが明らか。トピックに強い思い入れを持つのは全然いいけど、批判するためにこんなにエネルギーを使って、プロジェクトに貢献した人に個人的な攻撃をするのは無意味だし、ただの自己宣伝に過ぎない。自分のビジョンを実現するものを作るか、他の人を貶めるために時間を使わない方がいいよ。かっこよくないね。

Protobufは新しいものじゃないよ。要するに、https上のrpcみたいなもんだ。1997年にIDLを使ったdce-rpcを使ったことがあるし、CORBAもIDLを使ってたと思うけど、個人的には使ったことないな。他にもejbみたいな試みがあったけど、基本的には同じパラダイムだよ。Protobufの一番の利点は、技術面じゃなくて、社会的・経済的な側面だね。オープンソースで、以前のソリューションみたいな独自のハックからも解放されてるし。それに、rpcがサブトピックの分散システムは一般的に難しいから、期待値としてはあまり良くないかもね。

著者の気持ち、わかるなぁ。俺もこういうの大嫌い。実話だけど、macOSのPhotos.appのSQLiteデータベースフォーマットを逆エンジニアリングして、人間が読める位置情報を画像から抽出しようとしたことがある。結局解決したけど、ベース64エンコードされたバイナリプラスト形式で、一つのフィールドにプロトバッファが含まれてて、その中にさらに別のプロトバッファがあって、ユニコード文字列が含まれてて、データが正しくエンコードされてなかった(例えば、U+2013 EN DASHが\342\200\223としてエンコードされてた)。これ、単純なJSON文字列で済んだはずだよ。

まあ、どんなシリアルフォーマットでもネストしてエンコードできるからね。これはProtobuf特有の問題じゃなくて、開発の組織図がデータ構造として現れてるだけだよ。

それはひどいね。なんかAppleのソフトウェアはもっとクリーンだと思ってたけど、結局はマーケティングに影響されてたのかな。実際は中身は同じスパゲッティだよね。

https://github.com/RhetTbull/osxphotos

これ、シンプルなJSON文字列で済んだんじゃない?JSONをシリアライズフォーマットとして解析するのは全然「シンプル」じゃないよ。

JSONバージョンも間違ったエンコーディングになってたはずだよ。すべてのフォーマットは、人間が書いたコードから供給されたデータのフレーミングに過ぎない。Macの場合、エムダッシュは常に問題になる。だって、Macがそれを意図的に決めたからね。

もしそれが慰めになるなら、現在のバージョンのスキーマでは、ZASSETテーブルの中で単にZLATITUDE FLOAT、ZLONGITUDE FLOATになってるよ。

そうそう、oneOfフィールドは繰り返し使えるけど、メッセージで包むといいよ。見た目はあんまり良くないけど、これで問題が起きたことはない。著者が全てのメッセージを必須にしようとしてるのは、全フィールドがオプションである理由を理解してないってことだよね。これがシステムを壊すことになる(そういうポストモーテムもあるし)、それにプロトコルの不一致も出てくる。

何度も話し合われてきたよね:https://news.ycombinator.com/item?id=18188519 (299コメント) https://news.ycombinator.com/item?id=21871514 (215コメント) https://news.ycombinator.com/item?id=35281561 (59コメント)

これらの古いスレッドには素晴らしいコメントがたくさんあって、2018年以降この分野で新しい科学はあまりないから、古いスレッドの方が今のより読み応えがあるかも。面白いのがあるよ:https://news.ycombinator.com/item?id=21873926

https://news.ycombinator.com/item?id=18190005 参考までに:protobuf v2のデザイナーからの必須コメント。確かにprotobufにはデザインミスがたくさんあるけど、この記事は問題の領域を理解していない人が書いている。シリアライズの複雑さの大部分は、異なる時点間の実装互換性から来てるんだ。これがデザインの幅を大きく制限している。

関連して、著者の懸念のほとんどはメッセージでラップすることで解決できる。 > oneofフィールドは繰り返せない。繰り返せるメッセージでoneofフィールドをラップすればいい。 > mapフィールドは繰り返せない。繰り返しフィールドを含むメッセージでラップすればいい。 > mapの値は他のmapではいけない。mapを値として使えるメッセージでラップすればいい。これ、ちょっと不便かもしれないけど、著者はこれを「プロトは基本的にできない」って位置づけてる。

シリアライズの複雑さの大部分は、異なる時点間の実装の互換性から来ている。著者は互換性についてかなり語っていて、特に設定されていないフィールドと、意図的にデフォルトに設定されたフィールドを区別することの重要性について話している。プロトバフがこれをどう回避したのか、彼らは何を理解していないと思う?

著者が正しいか間違っているかは分からないけど、プロトバフをプロとして扱ったことはないんだ。でも最近、趣味のプロジェクトで実装してみたら、かなりのゲームチェンジャーだったよ。ESPやArduinoのプロジェクトでは、データの送受信、つまりテレメトリーや制御メッセージを送りたい場面が必ずある。多くの人はアドホックなプロトコルやHTTP/JSONを使ってるけど、私はnanopbライブラリを試してみることにした。最終的には、UDPパケットを使った比較的スッキリした解決策ができた。私の目的には、1つのパケットで十分なスペースがあるし、将来的にこのアプローチを簡単に拡張できる。これをやったのは私が初めてじゃないけど、何かもっと良いものが出てくるまでプロトバフを使い続けるつもり。エコシステムがあるから、楽しいと思うことに集中できるしね。

それにUDPだから、もしパケットが失われたらそれで終わり。標準のHTTP/JSONじゃないから、1年後には誰も理解できなくてデコードもできない。学んだり遊んだりするにはいいけど、なんでわざわざ人生を複雑にする必要があるの?

埋め込み型/制約のあるUDPでは、プロトバフのワイヤフォーマット(でもGoogleのライブラリは除く)が素晴らしい。セルラー越しのIoTなど、すべてを単一のデータグラムに収める必要があるからね(往復回数が消費電力を決める)。「UDPは信頼性がない」と言う人には、アプリケーションレベルでARQを実装すればいいんだ。TCPがやってるのと同じだけど、SYN-SYN-ACKのハンドシェイクに往復を無駄にする必要もないし、もはや関係ないデータを送るためにバイトを無駄にすることもない。バリアントで勝負だね。時系列データをバリアント配列の列として送信すれば、デルタやRLL圧縮もかなり簡単になる。そしてボーナスとして、デバイスに新しいフィールドを実装してすぐに展開できる。サーバー側のサポートは、実際に必要になるまで待てばいいからね。フラットバッファやキャプテンプロトは固定レイアウトのせいで大きすぎて受け入れられない。CBORは絶対にダメだよ。毎回スキーマに貴重なバイトを無駄にする理由がわからない。一般的な圧縮(gzipなど)は、こんな小さなサイズではあまり効果がないし、逆に悪化させる可能性が高い。はい、ASNが正しい解決策のはずだけど、$$$がかかる完全な実装はないし、全体的に膨れすぎてる。期待されていることに対してうまくいかないのはちょっと面白いけど、他のところでは実際に光ってるんだよね。

UDPでもJSONを送れるよ。Wizのスマートバルブがコミュニケーションにこれを使ってるんだ。https://github.com/sbidy/pywizlight?tab=readme-ov-file#examp...

確かに、理論上はクールな機能だね。でも、実際にその特性を保持するアプリケーションを見たことがない。おそらく、著者はこの言葉を書いている時にそれを実際に使っていたソフトウェアを使っていたんじゃないかな。この機能はChrome Syncの動作にとって重要なんだ。もし他のデバイスで古いブラウザバージョンを使って、未知のフィールドを認識できずに静かに削除されてしまったら、同期された状態を失いたくないよね。これが重要すぎて、ある時点でChromeはプロトバフライブラリをフォークして、未知のフィールドがプロトバフライトモードを使っていても保持されるようにしたんだ。

これらのシリアライズライブラリの中で、ワイヤフォーマットとアプリケーションフォーマットを指定できる機能があって、相互に変換するためのレシピがあるものはある?あまり真剣に使ったことはないけど、以前に直面した問題は、ワイヤフォーマットがアプリケーションが使いたいものじゃなかったこと。いいアプリケーションフォーマットはワイヤにはスペース効率が悪すぎた。私が見た限りでは、これをうまくやる方法はなかった。すべてのアプリでワイヤアプリコンバータを書き直すか、コンバータプログラムを持つことになって、結局2つのワイヤフォーマットが必要になり、その余分なプログラムやデータの移動をワークフローに組み込まなきゃいけないか、ライブラリを書いてすべての言語のバインディングを維持しなきゃいけない。

すべてのアプリでワイヤアプリコンバータを書き直すことができる これがGoogleのやり方だよ。私たちは「プロトバフAをプロトバフBに変換する」ことが私たちの仕事だと冗談を言ってる。

ネットワークの帯域幅が気になるなら、送信前に圧縮すればいいよ。ほとんどのウェブアプリがそうしてるしね。そうすれば、アプリのフォーマットのスペース効率についてあまり心配しなくて済むよ。

これを実現する方法は、コード生成のステップをハードコーディングしないことから始まる。代わりに、コード生成をデータスキーマオブジェクトとコードテンプレート(例えば、Jinja2テンプレート言語で表現されるもの)両方の関数にするんだ。コード生成の段階は、テンプレートをデータスキーマに適用してコードアーティファクトを生成するだけ。テンプレートは、データスキーマがメタスキーマ(例えば、JSONのデータスキーマ用のJSONスキーマ)に従って提供されることを前提に書かれてる。例えば、言語ごとのテンプレートを開発して、シリアライズコードやシリアライズ形式(ワイヤ上)とアプリケーションフレンドリーな形式の間の変換器を生成することができる。特定のターゲット用のテンプレートを開発するための追加の努力は、共通のメタスキーマに従うすべてのデータスキーマで機能するから、コストが分散される。「コード生成」段階では、もちろん「コード」テンプレート以外のものを使って、データスキーマに関するリファレンスドキュメントをHTMLやテキスト、nroff/manなどの異なる形式で生成することもできる。

なんか、あの悪いデザインの決定がGoogleの「文化的バイアス」の症状なんじゃないかって思い始めてる。特に「合成性がない」って点ね。これ、GoやCSS、ウェブプラットフォーム全般の似たような悪いデザインを思い出させる。一般化されたユーザーが組み合わせ可能なソリューションが、デザイナーがその時に関連性があると思う具体的なユースケースを満たすための特別な構造に取って代わられる傾向があるみたい。これでしばらくはうまくいくし、言語の複雑さを減らすけど、時間が経つにつれてデザインが特定のデザイン機能の巣窟になって、変な制約がついてくる。最終的には、デザイナーが諦めて言語にもっと一般的な構造を追加するかもしれないけど、それはなんか無理やり感があって、もう特定の機能と共存しなきゃいけなくなる。

これは両方の方向で機能する。一般的な構造は過度に抽象的になりがちで、ちょっとした抽象の変更でいろんなところに隠れたエラーが出てくることがある。古い格言のように、これは好みの問題だよね。良いソフトウェアエンジニアリングには、まず第一に、選んだ道やツールに関係なく、素晴らしい規律が必要だよ。