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 互換性を可能にすることで、Protobuf メッセージを言語境界 (FFI) を介してプレーンポインタとして共有することができ、ある言語でシリアル化し、バイト配列を境界を越えて渡し、別の言語でデシリアル化する必要がなくなります。これにより、各言語で同じメッセージに対して冗長なスキーマ情報がバイナリに埋め込まれるのを避けることで、これらのユースケースにおけるバイナリサイズも削減されます。

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 プロキシ型をオンデマンドで作成することができます。

非標準型

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

場合によっては、Rust Protobuf API は、対応する標準型が同じ名前で存在し、現在の実装が単に標準型をラップしているような状況でも、独自の型を作成することを選択することがあります。例えば proto::UTF-8Error などです。

これらの型を標準型ではなく使用することで、将来的に実装を最適化する上でより多くの柔軟性が得られます。現在の実装は Rust の標準 UTF-8 検証を使用していますが、独自の proto::Utf8Error 型を作成することで、実装を、Rust の標準 UTF-8 検証よりも高速な、C++ Protobuf から使用している非常に最適化された C++ UTF-8 検証実装に変更することが可能になります。

ProtoString

Rust の str および std::string::String 型は、有効な UTF-8 のみを含むという厳密な不変条件を維持しますが、C++ Protobuf および C++ の std::string 型は通常、そのような保証を強制しません。string 型の Protobuf フィールドは常に有効な UTF-8 のみを含むことを意図していますが、その強制には多くの抜け穴があり、実行時に string フィールドが不正な UTF-8 コンテンツを含む可能性があります。

C++ と Rust 間でゼロコストのメッセージ共有を実現しつつ、高コストな検証や Rust での未定義動作のリスクを最小限に抑えるため、string フィールドのゲッターに str/String 型を使用しないことを選択し、代わりに ProtoStr および ProtoString 型を導入しました。これらは、稀な状況で不正な UTF-8 を含む可能性がある点を除いて同等の型です。これらの型を使用することで、アプリケーションコードは、オンデマンドで検証を実行して &str を取得するか、または検証を避けて生バイトを操作するかを選択できます。

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