Rust Proto の設計上の決定

Rust Proto 実装が行う設計上の選択肢のいくつかを説明します。

どのライブラリとも同様に、Rust Protobuf は Google のファーストパーティによる Rust の使用と、外部ユーザーの両方のニーズを考慮して設計されています。その設計空間で進むべき道を選択するということは、たとえそれが実装全体にとって正しい選択であったとしても、場合によっては一部のユーザーにとって最適ではない選択がされることを意味します。

このページでは、Rust Protobuf 実装が行うより大きな設計上の決定のいくつかと、それらの決定に至った考慮事項について説明します。

C++ Protobuf を含む、他の Protobuf 実装によって「バックアップ」されるように設計

Protobuf Rust は Protobuf の純粋な Rust 実装ではなく、既存の Protobuf 実装、または私たちがこれらの実装を「カーネル」と呼ぶものの上に実装された安全な Rust API です。

この決定における最大の要因は、すでに非 Rust の Protobuf を使用している既存のバイナリに Rust を追加する際のコストをゼロにすることでした。実装を C++ Protobuf の生成コードと ABI 互換にすることで、言語の境界 (FFI) を越えて Protobuf メッセージをプレーンなポインタとして共有することが可能になり、一方の言語でシリアライズし、バイト配列を境界を越えて渡し、もう一方の言語でデシリアライズする必要がなくなります。これにより、同じメッセージに対して各言語の冗長なスキーマ情報がバイナリに埋め込まれるのを避け、これらのユースケースでのバイナリサイズも削減されます。

Protobuf Rust は現在、3つのカーネルをサポートしています。

  • C++ カーネル - 生成されたコードは C++ Protocol Buffers (通常サーバーで使用される「完全な」実装) によってバックアップされます。このカーネルは、C++ ランタイムを使用する C++ コードとのメモリ内相互運用性を提供します。これは Google 内のサーバーでのデフォルトです。
  • C++ Lite カーネル - 生成されたコードは C++ Lite Protocol Buffers (通常モバイルで使用される) によってバックアップされます。このカーネルは、C++ Lite ランタイムを使用する C++ コードとのメモリ内相互運用性を提供します。これは Google 内のモバイルアプリでのデフォルトです。
  • upb カーネル - 生成されたコードは、C で書かれた非常に高性能でバイナリサイズが小さい Protobuf ライブラリである upb によってバックアップされます。upb は、他の言語の Protobuf ランタイムによる実装の詳細として使用されるように設計されています。これは、すでに C++ Protobuf を使用しているコードとの静的リンクがよりまれであると予想されるオープンソースビルドでのデフォルトです。

複数の非 Rust カーネルをサポートするという決定は、ゲッターで使用される型(このドキュメントの後半で説明)を含む、私たちの公開 API の決定に大きく影響します。

純粋な Rust カーネルがない

API が複数のバックエンド実装によって実装可能であるように設計されていることを考えると、今日サポートされているカーネルが C と C++ というメモリ安全でない言語で書かれているのはなぜか、という自然な疑問が生じます。

Rust がメモリ安全な言語であることは、重大なセキュリティ問題への暴露を大幅に減らすことができますが、どの言語もセキュリティ問題から完全に免れることはできません。私たちがカーネルとしてサポートする Protobuf 実装は、Google が自社のサーバーやアプリで信頼できない入力のサンドボックス化されていない解析を実行するためにそれらの実装を使用することに安心できるレベルまで、精査され、ファズテストされています。現時点で Rust で書かれた新規のバイナリパーサーは、既存の C++ Protobuf パーサーよりも重大な脆弱性を含む可能性がはるかに高いと理解されるでしょう。

オープンソースで私たちの実装を使用する開発者にとってのツールチェーンの困難さなど、純粋な Rust 実装を長期的にサポートすることには正当な議論があります。

Google が将来のある時点で純粋な Rust 実装をサポートすることは合理的な想定ですが、今日私たちはそれに投資しておらず、現時点では具体的なロードマップもありません。

View/Mut プロキシ型

Rust Proto API は、不透明な「プロキシ」型で設計されています。message SomeMsg {} を定義する .proto ファイルに対して、Rust 型 SomeMsgSomeMsgView<'_>、および SomeMsgMut<'_> を生成します。簡単な経験則として、View 型と Mut 型は、デフォルトですべての用法で &SomeMsg&mut SomeMsg の代わりになると期待しており、それらの型から期待されるすべての借用チェック/Send/などの振る舞いを引き続き得られます。

これらの型を理解するための別の視点

これらの型のニュアンスをよりよく理解するためには、これらの型を次のように考えると役立つかもしれません。

struct SomeMsg(Box<cpp::SomeMsg>);
struct SomeMsgView<'a>(&'a cpp::SomeMsg);
struct SomeMsgMut<'a>(&'a mut cpp::SomeMsg);

この視点から見ると、次のことがわかります。

  • &SomeMsg が与えられた場合、SomeMsgView を取得することが可能です(&Box<T> が与えられた場合に &T を取得できるのと同様です)。
  • SomeMsgView が与えられた場合、&SomeMsg を取得することは*できません*(&T が与えられた場合に &Box<T> を取得できないのと同様です)。

&Box の例と同様に、これは関数の引数では、&'a SomeMsg ではなく SomeMsgView<'a> をデフォルトで使用する方が一般的に良いことを意味します。これにより、より多くの呼び出し元がその関数を使用できるようになります。

理由

この設計には主に2つの理由があります。考えられる最適化の利点を引き出すためと、カーネル設計の固有の結果としてです。

最適化機会の利点

Protobuf は非常にコアで広範囲にわたる技術であるため、観察可能なすべての振る舞いが誰かに依存される傾向が異常に高く、また比較的小さな最適化が大規模な環境で異常に大きな純影響を与えることがあります。型の不透明性を高めることで、異常に高いレバレッジが得られることがわかりました。これにより、どの振る舞いを公開するかをより意図的に決定でき、実装を最適化する余地が広がります。

SomeMsgMut<'_> は、&mut SomeMsg では得られない機会を提供します。つまり、それらを遅延的に、かつ所有されているメッセージ表現とは異なる実装の詳細で構築できます。また、そうでなければ制限したり制御したりできなかった特定の振る舞いを本質的に制御することもできます。例えば、任意の &mutstd::mem::swap() で使用できますが、これは &mut SomeChild が呼び出し元に与えられた場合に、親と子の構造体間で維持できる不変条件に強い制約を課す振る舞いです。

カーネル設計に固有

プロキシ型を使用するもう一つの理由は、カーネル設計の固有の制約にあります。&T を持つ場合、メモリのどこかに実際の Rust の T 型が存在しなければなりません。

私たちの C++ カーネル設計では、ネストされたメッセージを含むメッセージを解析し、ルートメッセージを表すために小さな Rust のスタック割り当てオブジェクトのみを作成し、他のすべてのメモリは C++ ヒープに保存することができます。後で子メッセージにアクセスする際、その子に対応する事前に割り当てられた Rust オブジェクトは存在しないため、その瞬間に借用する Rust インスタンスはありません。

プロキシ型を使用することで、事前にそれらのインスタンスのために Rust メモリを積極的に割り当てることなく、意味的に借用として機能する Rust プロキシ型をオンデマンドで作成することができます。

非 Std 型

直接対応する Std 型を持つ可能性がある単純な型

場合によっては、Rust Protobuf API は、同じ名前の対応する std 型が存在する場合でも、独自の型を作成することがあります。現在の実装が単に std 型をラップしている場合でも、例えば protobuf::UTF8Error のようにです。

std 型の代わりにこれらの型を使用することで、将来的に実装を最適化する際の柔軟性が高まります。現在の実装は Rust の std UTF-8 検証を使用していますが、独自の protobuf::Utf8Error 型を作成することで、C++ Protobuf で使用している、Rust の std UTF-8 検証よりも高速な、高度に最適化された C++ の UTF-8 検証実装を使用するように変更することが可能になります。

ProtoString

Rust の strstd::string::String 型は、有効な UTF-8 のみを含むという厳格な不変条件を維持しますが、C++ Protobuf と C++ の std::string 型は通常、そのような保証を強制しません。string 型の Protobuf フィールドは有効な UTF-8 のみを含むことが意図されており、C++ Protobuf は正確で高度に最適化された UTF8 検証器を使用します。C++ Protobuf の API サーフェスは、string フィールドが常に有効な UTF-8 を含むという実行時不変条件を厳密に強制するようには設定されていません(代わりに、検証をシリアライズ時または後続のパース時に遅延させます)。

不必要な検証や Rust での未定義の振る舞いのリスクを最小限に抑えながら、C++ Protobuf を使用する既存のコードベースに Rust を統合できるようにするため、string フィールドのゲッターに str/String 型を使用しないことを選択しました。代わりに ProtoStrProtoString という型を導入しました。これらは同等の型ですが、まれな状況では無効な UTF-8 を含む可能性があります。これらの型により、アプリケーションコードは、フィールドを Result<&str> として観察するためにオンデマンドで検証を実行するか、実行時検証を避けるために生のバイトを操作するかを選択できます。

str のような語彙型が慣用的な使用法にとって非常に重要であることは認識しており、Rust の使用法の詳細が進化するにつれて、この決定が正しいものであるかどうかを注視していくつもりです。