Rust Proto の設計上の決定
他のライブラリと同様に、Rust Protobuf は、Google の Rust のファーストパーティでの利用と外部ユーザーのニーズの両方を考慮して設計されています。その設計空間でパスを選択するということは、実装全体にとって正しい選択であったとしても、場合によっては一部のユーザーにとって最適ではない選択が行われることを意味します。
このページでは、Rust Protobuf 実装が行う主要な設計上の決定と、それらの決定に至った考慮事項について説明します。
C++ Protobuf を含む他の Protobuf 実装に「支えられる」ように設計されている
Protobuf Rust は、protobuf の純粋な Rust 実装ではなく、既存の protobuf 実装、またはそれらの実装を「カーネル」と呼ぶものの上に実装された安全な Rust API です。
この決定の最大の要因は、すでに非 Rust Protobuf を使用している既存のバイナリに Rust を追加するコストをゼロにすることでした。C++ Protobuf 生成コードと ABI 互換性を持つように実装することで、Protobuf メッセージを言語境界 (FFI) を越えてプレーンポインタとして共有することが可能になり、ある言語でシリアライズし、バイト配列を境界を越えて渡し、別の言語でデシリアライズする必要がなくなります。これにより、同じメッセージについて各言語で冗長なスキーマ情報をバイナリに埋め込むことを避けることで、これらのユースケースのバイナリサイズも削減されます。
Google は、Rust を、既存のブラウンフィールド C++ サーバーの主要部分にメモリ安全性を段階的に導入する機会と捉えています。言語境界でのシリアライズのコストは、これらの重要でパフォーマンスに敏感なケースの多くで C++ を置き換える Rust の採用を妨げるでしょう。このサポートを持たないグリーンフィールドの Rust Protobuf 実装を追求した場合、Rust の採用を妨げ、これらの重要なケースが C++ に留まることを要求することになります。
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 Protobuf は、全く同じ API を公開しながら複数の代替実装 (複数の異なるメモリレイアウトを含む) をサポートするように設計されており、同じアプリケーションコードを異なる実装によって支えられるように再コンパイルすることを可能にします。この設計上の制約は、ゲッターで使用される型 (このドキュメントの後半で説明) を含む、私たちの公開 API の決定に大きく影響します。
純粋な Rust カーネルなし
API が複数のバッキング実装によって実装可能であるように設計されていることを考えると、なぜ現在サポートされているカーネルが C および C++ のメモリunsafeな言語で書かれているのかという疑問が当然生じます。
Rust がメモリ安全な言語であることは、重大なセキュリティ問題への露出を大幅に減らすことができますが、どんな言語もセキュリティ問題から完全に免れることはありません。カーネルとしてサポートしている Protobuf 実装は、Google が自社のサーバーやアプリでサンドボックスなしで信頼できない入力を解析するために使用できるほど、徹底的に精査され、ファジングされています。
現時点で Rust で書かれたグリーンフィールドのバイナリパーサーは、広範囲にファジング、テスト、レビューされてきた既存の C++ Protobuf または upb パーサーよりも、重大な脆弱性を含む可能性が高いと理解されています。
長期的には、開発者がビルド時に C コードをコンパイルするために Clang を用意する必要がなくなる機能など、純粋な Rust カーネル実装をサポートする正当な議論があります。
Google は将来、同じ公開 API を持つ純粋な Rust 実装をサポートすると予想していますが、現時点では具体的なロードマップはありません。C++ Proto と upb に支えられていることによる制約を避けることで「より良い」API を持つ第2の公式 Rust Protobuf 実装は計画されていません。なぜなら、Google 自身の Protobuf の利用を分断したくないからです。
View/Mut プロキシ型
Rust Proto API は、不透明な「プロキシ」型で設計されています。`message SomeMsg {}` を定義する `.proto` ファイルの場合、Rust の型 `SomeMsg`、`SomeMsgView<'_>`、および `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` を取得できるのと似ています)。 - `SomeMsgView` が与えられた場合、`&SomeMsg` を取得することは不可能です (これは、`&T` が与えられた場合に `&Box
` を取得できないのと似ています)。
`&Box` の例と同様に、これは、関数の引数では、`&'a SomeMsg` よりも `SomeMsgView<'a>` をデフォルトで使用する方が一般的に良いことを意味します。これにより、より広範な呼び出し元がその関数を使用できるようになるためです。
理由
この設計には、最適化の恩恵を可能にすることと、カーネル設計の本質的な結果であるという2つの主な理由があります。
最適化の機会の恩恵
Protobuf は非常に中心的で広く普及している技術であるため、あらゆる可能な観測可能な動作が誰かに依存されている傾向があり、また比較的小さな最適化が大規模に異常に大きな純影響を与える傾向があります。私たちは、型の不透明性が異常に高いレバレッジを与えることを発見しました。これにより、どのような動作を公開するかについてより意図的になり、実装を最適化するためのより多くの余地が与えられます。
`SomeMsgMut<'_>` は、`&mut SomeMsg` では得られない機会を提供します。つまり、それらを遅延的に、かつ所有されているメッセージ表現とは異なる実装詳細で構築できるということです。また、それ以外では制限または制御できなかった特定の動作を本質的に制御することもできます。たとえば、任意の `&mut` は `std::mem::swap()` とともに使用できますが、これは、`&mut SomeChild` が呼び出し元に与えられた場合、親と子の構造体の間で維持できる不変条件に強い制限を課す動作です。
カーネル設計に固有
プロキシ型を使用するもう1つの理由は、カーネル設計に固有の制限にあります。`&T` がある場合、メモリのどこかに実際の Rust の `T` 型が存在する必要があります。
C++ カーネル設計では、ネストされたメッセージを含むメッセージを解析し、ルートメッセージを表す小さな Rust のスタック割り当てオブジェクトのみを作成し、他のすべてのメモリは C++ ヒープに格納されます。後で子メッセージにアクセスするとき、その子に対応するすでに割り当てられた Rust オブジェクトがないため、その時点で借用する Rust インスタンスはありません。
プロキシ型を使用することで、事前にこれらのインスタンスのために熱心に割り当てられた Rust メモリがなくても、借用として意味的に機能する Rust プロキシ型をオンデマンドで作成できます。
非標準型
直接対応する標準型を持つ可能性のある単純な型
場合によっては、Rust Protobuf API は、対応する標準型と同じ名前を持つ独自の型を作成することを選択することがあります。現在の実装では、単に標準型をラップしている場合もあります。たとえば、`protobuf::UTF8Error` などです。
これらの型を標準型ではなく使用することで、将来的に実装を最適化する柔軟性が高まります。現在の実装では Rust の標準 UTF-8 検証を使用していますが、独自の `protobuf::Utf8Error` 型を作成することで、C++ Protobuf で使用している高度に最適化された C++ UTF-8 検証の実装を使用するように実装を変更できます。これは Rust の標準 UTF-8 検証よりも高速です。
ProtoString
Rust の `str` および `std::string::String` 型は、有効な UTF-8 のみを含むという厳密な不変条件を維持しますが、C++ の `std::string` 型はそのような保証を強制しません。`string` 型の Protobuf フィールドは、常に有効な UTF-8 のみを含むことを意図しており、C++ Protobuf は正確で高度に最適化された UTF8 バリデーターを使用します。ただし、C++ Protobuf の API サーフェスは、その `string` フィールドが常に有効な UTF-8 を含むというランタイムの不変条件を厳密に強制するように設定されていません。代わりに、場合によっては、非 UTF-8 データを `string` フィールドに設定することを許可し、検証はシリアライゼーションが行われる後の時間にのみ発生します。
C++ Protobuf を使用する既存のコードベースに Rust を統合し、同時に Rust で未定義の動作のリスクなしにゼロコストの境界を越えることを可能にするために、残念ながら `string` フィールドのゲッターには `str`/`String` 型を避ける必要があります。代わりに、`ProtoStr` および `ProtoString` 型が使用されます。これらは同等の型ですが、ごくまれな状況では無効な UTF-8 を含む可能性があります。これらの型により、アプリケーションコードは、フィールドを `Result<&str>` として観察するためにオンデマンドで検証を実行するか、ランタイム検証を避けるために生バイトで操作するかを選択できます。すべてのセッターパスは、`&str` または `String` 型を渡せるように設計されています。
私たちは、`str` のような語彙型が慣用的な使用法にとって非常に重要であることを認識しており、Rust の使用法の詳細が進化するにつれて、この決定が正しいかどうかを監視し続けるつもりです。