Read OSS

ディスクリプタシステムとメッセージ階層:Protobufの型ユニバース

上級

前提知識

  • 第2回:protocの内部構造(.protoファイルからディスクリプタが生成される仕組み)
  • C++クラス階層とメモリレイアウトの基本的な理解

ディスクリプタシステムとメッセージ階層:Protobufの型ユニバース

protocコンパイラを「工場」に例えるなら、ディスクリプタシステムはその工場が生み出す「設計図」です。10以上の言語にわたるすべてのprotobufランタイムは、実行時のスキーマ把握のためディスクリプタを必要とします。C++実装においてこのシステムは、コンパイラ・ランタイムライブラリ・アプリケーションコードをつなぐ結合組織とも言える存在です。

この記事では、Descriptorクラス階層の解剖とDescriptorPoolのメモリ最適化の仕組みを見ていきます。MessageLiteMessage → 生成クラスという3層構造をトレースし、DynamicMessageとReflection APIの詳細を確認します。最後にproto2/proto3の二項対立を置き換えるEditionsフィーチャーシステムについても解説します。

Descriptorクラス階層

ディスクリプタ階層の頂点に立つのがFileDescriptorで、1つの.protoファイルを表します。他のすべてのディスクリプタはその配下に収まります。descriptor.h のヘッダコメントには設計意図が明記されており、ディスクリプタを使えば「実行時にメッセージが持つフィールドとその型を知ることができる」と述べられています。

classDiagram
    class FileDescriptor {
        +name()
        +package()
        +dependency()
        +message_type()
        +enum_type()
        +service()
    }
    
    class Descriptor {
        +name()
        +full_name()
        +field()
        +nested_type()
        +enum_type()
        +oneof_decl()
        +containing_type()
    }
    
    class FieldDescriptor {
        +name()
        +number()
        +type()
        +message_type()
        +enum_type()
        +is_repeated()
        +is_map()
    }
    
    class EnumDescriptor {
        +name()
        +value()
        +FindValueByName()
    }
    
    class ServiceDescriptor {
        +name()
        +method()
    }
    
    class OneofDescriptor {
        +name()
        +field()
    }
    
    FileDescriptor --> Descriptor : message_type()
    FileDescriptor --> EnumDescriptor : enum_type()
    FileDescriptor --> ServiceDescriptor : service()
    Descriptor --> FieldDescriptor : field()
    Descriptor --> Descriptor : nested_type()
    Descriptor --> OneofDescriptor : oneof_decl()
    Descriptor --> EnumDescriptor : enum_type()

メッセージ型を表すDescriptorクラスは、internal::SymbolBaseをprivate継承しています。この基底クラスはuint8_t symbol_type_フィールドをひとつ持ち、プールのシンボルテーブルが型の識別に使用します。virtual dispatchではなく1バイトでディスクリプタの型を判別するこの設計は、メモリ効率を最優先にした意図的な選択です。

各ディスクリプタ型は共通のパターンに従っています。構築後はイミュータブルで、DescriptorPoolによって所有され、スキーマ情報へのアクセサメソッドを提供します。フィールドディスクリプタは、ワイヤ型・メッセージ型(サブメッセージの場合)・enum型(enumの場合)・カーディナリティ(singular/repeated/map)・containment(oneofに属するかどうか)といった情報をすべて把握しています。

DescriptorPool:レジストリとメモリ最適化

DescriptorPoolはすべてのディスクリプタオブジェクトを管理するクラスであり、protobufの中でも特に積極的なメモリ最適化が施されている場所です。ヘッダファイルには次のような警告が記されています。

「このファイルのクラスはライブラリの中でも相当なメモリフットプリントを持つ。特定プラットフォームの構造体サイズをハードコードすることで、誤って肥大化させないようにしている。」

最初の主要な最適化がDescriptorNamesです。これはディスクリプタの名前文字列向けに設計されたカスタムメモリレイアウトです。多くのプラットフォームで32バイトを要するstd::stringを使う代わりに、ポインタが文字列領域を指し、その後ろにuint16_tのオフセット/サイズペアが並ぶパック構造を採用しています。

[ chars .... ] [ data0 (uint16_t) ] [ ... ] [ dataN (uint16_t) ]
              ^
     payload_ points here

フィールドの名前、完全名、JSON名、camelCase名、小文字名は互いにバイトを共有できます。bar.Foo.field_nameという完全名には短縮名がサフィックスとして含まれているため、文字列の実体は1つだけ保存すれば済みます。大規模なprotobufスキーマでは何十万ものディスクリプタが同時にメモリ上に存在することを考えると、この最適化がいかに重要かがわかります。

2つ目の主要な最適化がLazyDescriptorです。lazily_build_dependencies_が有効なプールに対して、クロスリンクを遅延させる仕組みです。プールがビルド中に型参照を見つけた場合、名前文字列だけを保存しておき、実際にディスクリプタにアクセスされるまで解決を先送りできます。解決処理はabsl::once_flagで保護されており、スレッドセーフな遅延初期化が保証されています。

flowchart TD
    subgraph "DescriptorPool Internals"
        DB["DescriptorDatabase<br/>(backing store)"]
        TABLES["Symbol Tables<br/>(flat_hash_map)"]
        ALLOC["FlatAllocator<br/>(arena-style)"]
        LAZY["LazyDescriptor<br/>(deferred linking)"]
    end
    
    DB -->|"FindFileByName()"| BUILD["Build FileDescriptor"]
    BUILD --> TABLES
    BUILD --> ALLOC
    BUILD -->|"unresolved refs"| LAZY
    LAZY -->|"first access"| RESOLVE["Resolve + Build dependency"]

FlatAllocatorはディスクリプタオブジェクト用のprotobuf内製アリーナアロケータです。個々にヒープアロケーションを行う代わりに、あるファイルのすべてのディスクリプタのメモリを連続したブロックにまとめて確保します。これによりアロケーションのオーバーヘッドが減り、ディスクリプタツリーをたどる際のキャッシュ局所性も向上します。

Tip: メモリに制約のある環境でprotobufを扱う場合、DescriptorPoollazily_build_dependencies_を有効にすることを検討してみましょう。推移的にimportされるファイルのビルドを実際に必要になるまで遅延させることで、起動時のメモリ使用量を大幅に削減できます。

MessageLiteとMessage:2層構造のランタイム

C++のメッセージクラス階層は、意図的な2層構造になっています。MessageLiteはreflectionなしでシリアライゼーションを提供する最小限の基底クラスで、MessageはそこにGetDescriptor()GetReflection()を加え、完全なランタイムイントロスペクションを実現します。

classDiagram
    class MessageLite {
        +SerializeToString()
        +ParseFromString()
        +ByteSizeLong()
        +MergePartialFromCodedStream()
        +New(Arena*)
        +GetTypeName()
    }
    
    class Message {
        +GetDescriptor() : Descriptor*
        +GetReflection() : Reflection*
        +CopyFrom(Message&)
        +MergeFrom(Message&)
        +FindInitializationErrors()
    }
    
    class GeneratedMessage["MyMessage (Generated)"] {
        +field_name() : FieldType
        +set_field_name(FieldType)
        +has_field_name() : bool
    }
    
    MessageLite <|-- Message
    Message <|-- GeneratedMessage

この分割の目的はヘッダコメントにはっきりと示されています。MessageLiteは「liteおよびnon-liteを問わず、すべてのプロトコルメッセージオブジェクトが実装する抽象インターフェース」です。.protoファイルでoption optimize_for = LITE_RUNTIMEを指定してコンパイルすると、生成クラスはMessageではなく直接MessageLiteを継承し、ディスクリプタとreflectionのサポートがすべて省かれます。reflectionはディスクリプタインフラ全体のリンクを必要とするため、これによってバイナリサイズを大幅に削減できます。

Messageクラスが追加するGetDescriptor()GetReflection()は非常に重要なメソッドです。Reflection APIがあることで、JSONシリアライゼーション・テキストフォーマット・デバッグツールといったフレームワークが、コンパイル時に型を知らなくてもあらゆるprotobufメッセージに対して動作できるようになります。

message.hのヘッダには型付きAPIとReflection APIを並べた優れた使用例が掲載されており、生成アクセサと同等の動的アクセスをReflection::GetString()Reflection::GetRepeatedInt32()で実現する方法が示されています。

DynamicMessageとReflection API

バイナリにコンパイルされていない型のメッセージインスタンスを動的に生成したい場合はどうすればよいでしょうか。その答えがDynamicMessageFactoryです。

このファクトリは実行時にDescriptorオブジェクトからMessageインスタンスを生成します。生成されたDynamicMessageオブジェクトはシリアライゼーション・reflection・標準的なメッセージ操作をすべてサポートしていますが、型付きアクセサは持ちません。フィールドへのアクセスはすべてReflectionインターフェースを通じて行います。

ヘッダコメントはこの設計上のトレードオフを次のように説明しています。

「DynamicMessageは動作するために自身の型に関する追加情報を構築する必要がある。この情報の大部分は同じ型のDynamicMessage間で共有できる。しかし、何らかのグローバルマップにキャッシュするのは得策ではない。特定のディスクリプタに紐づくキャッシュ情報が、ディスクリプタ自体より長生きしてしまう可能性があるからだ。」

だからこそDynamicMessageFactoryが独立したオブジェクトとして存在するのです——ファクトリがキャッシュそのものです。同じファクトリから生成された同じ型のDynamicMessageインスタンスは、型のメタデータを共有します。ファクトリはすべてのメッセージより長生きしなければならず、ファクトリに渡したディスクリプタはファクトリより長生きしている必要があります。

sequenceDiagram
    participant App as Application
    participant Factory as DynamicMessageFactory
    participant Pool as DescriptorPool
    participant Msg as DynamicMessage

    App->>Pool: FindMessageTypeByName("Foo")
    Pool-->>App: Descriptor*
    App->>Factory: GetPrototype(descriptor)
    Factory-->>App: Message* (prototype)
    App->>Msg: prototype->New()
    Msg-->>App: DynamicMessage*
    App->>Msg: GetReflection()->SetString(msg, field, "value")

DynamicMessageは、コンパイル中のファイルで定義されたメッセージを操作する必要があるprotoc自体、gRPC reflection、そしてスキーマのコンパイル時知識なしにprotobufデータを処理するあらゆるシステムにとって、欠かせないインフラです。

EditionsとFeatureResolver

Editionsシステムは、syntax = "proto2"syntax = "proto3"の間で長年続いてきた緊張関係を解消するためにprotobufが用意した解決策です。2つのシンタックスモードの二者択一ではなく、Editionsではファイル・メッセージ・フィールドの各レベルで設定できる細粒度のフィーチャーフラグを導入しています。

FeatureResolverクラスはこのシステムのエンジンです。デフォルト値・ファイルレベルのオーバーライド・メッセージレベルのオーバーライド・フィールドレベルのオーバーライドをマージして、任意のディスクリプタ要素の解決済みフィーチャーセットを算出するのが役割です。

解決プロセスは2フェーズで動作します。まずCompileDefaults()がエディションからデフォルトフィーチャー値へのマッピングを構築し、言語固有の拡張も取り込みます。

static absl::StatusOr<FeatureSetDefaults> CompileDefaults(
    const Descriptor* feature_set,
    absl::Span<const FieldDescriptor* const> extensions,
    Edition minimum_edition, Edition maximum_edition);

次にMergeFeatures()が特定の要素に対する解決済みセットを算出します。

absl::StatusOr<FeatureSet> MergeFeatures(
    const FeatureSet& merged_parent,
    const FeatureSet& unmerged_child) const;
flowchart TD
    A["Edition 2024 defaults"] --> B["File-level features"]
    B --> C["Message-level features"]
    C --> D["Field-level features"]
    D --> E["Resolved FeatureSet"]
    
    F["Language extensions<br/>(e.g., pb::cpp)"] --> A
    
    style E fill:#f9f,stroke:#333

フィーチャーシステムはValidateFeatureLifetimes()によるライフサイクル管理をサポートしており、フィーチャーがサポートされているエディション範囲内で使用されているかを検証し、非推奨フィーチャーにフラグを立てます。各コードジェネレータはGetMinimumEdition()GetMaximumEdition()でサポートするエディション範囲を宣言します——第2回で見たように、RustとC++のジェネレータはどちらもEDITION_PROTO2からEDITION_2024までをサポートしています。

Tip: Editionsシステムは前方互換性を重視して設計されています。新しいエディションが導入されても、既存の.protoファイルを変更する必要はありません——宣言されたエディションのデフォルト値でそのまま動作し続けます。新しいエディションの挙動を採用するのは、新規に作成するファイルだけで十分です。

型ユニバースをつなぐもの

ディスクリプタシステム・メッセージ階層・Editionsインフラは、protobufの「型ユニバース」を形成しています。他のあらゆるサブシステムがこれに依存しています。

  • コードジェネレータはDescriptorオブジェクトを消費して言語固有のコードを生成する
  • Reflection APIはフィールドへの動的アクセスにFieldDescriptorを使用する
  • アリーナアロケーション(第4回)は適切なクリーンアップ登録にディスクリプタ情報を活用する
  • TcTableパーサ(第4回)はディスクリプタから導出されたフィールドメタデータを使用する
  • すべての言語ランタイムは最終的にディスクリプタデータに依存している——C++の完全なディスクリプタ、upbのMiniTableコンパクト表現(第5回)、シリアライズされたディスクリプタprotosのいずれかを通じて

第4回では、protobufのパフォーマンスクリティカルな内部実装に踏み込みます。Arenaによるリージョンベースメモリ管理の仕組みと、ZeroCopyInputStreamがmemcpyを排除する方法を解説します。さらにTcTableテールコール解析システムが64ビットエントリにフィールドメタデータを詰め込み、mandatory tail callsでスタック成長を回避しながら驚異的なスループットを実現する仕組みも取り上げます。