Read OSS

protoc の内側:.proto ファイルから型安全なコードへ

中級

前提知識

  • 第 1 回:Protocol Buffers のソースコード:全体像を把握する
  • コンパイラのフロントエンド(字句解析・構文解析・AST)の基本的な理解

protoc の内側:.proto ファイルから型安全なコードへ

第 1 回では、protocProtobufMain() でジェネレーターを登録し、cli.Run() を呼び出すところまで確認しました。では、その Run() の内側では何が起きているのでしょうか。答えは、驚くほど洗練されたコンパイルパイプラインです。人間が読める .proto テキストを完全に検証・相互参照された型グラフへと変換し、言語ごとのコードジェネレーターへと受け渡します。

この記事では、そのパイプラインの各ステップを順に追っていきます。途中では、なぜ tokenizer が手書きなのか、DescriptorPool がどのようにスレッドセーフな不変性を実現しているのか、そして descriptor.proto がリポジトリ全体でなぜ最も重要なファイルなのかを明らかにします。

CommandLineInterface::Run():オーケストレーターとしての役割

Run() メソッドは protoc の中核を担います。パイプライン全体を一本の線形フローとして統括します。

sequenceDiagram
    participant User
    participant CLI as CommandLineInterface
    participant DST as DiskSourceTree
    participant STDB as SourceTreeDescriptorDatabase
    participant Pool as DescriptorPool
    participant Gen as CodeGenerator

    User->>CLI: Run(argc, argv)
    CLI->>CLI: ParseArguments()
    CLI->>DST: InitializeDiskSourceTree()
    CLI->>STDB: Create(disk_source_tree)
    CLI->>Pool: Create(source_tree_database)
    CLI->>Pool: SetupFeatureResolution()
    CLI->>Pool: ParseInputFiles() → FileDescriptor*
    CLI->>Pool: Validate options & extensions
    CLI->>Gen: Generate(FileDescriptor*, ...)
    Gen-->>User: Output files written

主要なフェーズを順に見ていきましょう。まず ParseArguments() がコマンドラインを処理し、--proto_path で指定されたディレクトリ、--X_out 形式の出力フラグ、入力 .proto ファイルをそれぞれ抽出します。戻り値は「処理継続」「正常終了」「失敗」の三値 enum です。

次に、descriptor データベースの基盤を構築します。--descriptor_set_in が指定されている場合は、事前にコンパイル済みの FileDescriptorSet オブジェクトを SimpleDescriptorDatabase インスタンスに読み込みます。それ以外の場合は、--proto_path のディレクトリを仮想パスにマッピングする DiskSourceTree を作成し、SourceTreeDescriptorDatabase でラップします。

DescriptorPool はデータベースの上に作成されます。いくつかの制約フラグが有効化されます。

descriptor_pool->EnforceWeakDependencies(true);
descriptor_pool->EnforceSymbolVisibility(true);
descriptor_pool->EnforceNamingStyle(true);
descriptor_pool->EnforceFeatureSupportValidation(true);

フィーチャー解決は SetupFeatureResolution() で設定されます(詳細は第 6 回で解説します)。続いて ParseInputFiles() が実際の解析を開始します。各入力 .proto ファイルはデータベース経由でロードされ、トークナイズ、パース、そして FileDescriptor への構築が行われます。

最後に、検証済みの FileDescriptor オブジェクトが各登録済みコードジェネレーターへと渡されます。

スキーマ解析パイプライン:Tokenizer → Parser → FileDescriptorProto

.proto テキストから構造化されたメタデータへの変換は、3 つのステージで行われます。

flowchart LR
    A[".proto text<br/>(raw bytes)"] -->|ZeroCopyInputStream| B["Tokenizer<br/>(tokenizer.h)"]
    B -->|"Token stream"| C["Parser<br/>(parser.h)"]
    C -->|"FileDescriptorProto"| D["DescriptorPool<br/>(descriptor.h)"]
    D --> E["FileDescriptor<br/>(immutable)"]

Tokenizersrc/google/protobuf/io/tokenizer.h)は、flex のような自動生成ツールではなく手書きで実装されています。これは意図的な設計です。protobuf では行番号・列番号を正確に含むエラーメッセージと外部依存ゼロが求められます。またトークン文法がシンプルなため、手書きの字句解析器のほうが自動生成コードより読みやすく、保守もしやすいのです。

tokenizer が認識するトークン種別は、識別子・整数・浮動小数点数・文字列・記号とシンプルです。C および C++ スタイルのコメントを処理し、すべてのトークンのソース位置を追跡します。ErrorCollector インターフェースは、行番号・列番号付きのエラーを設定されたレポート機構へと振り分けます。

Parsersrc/google/protobuf/compiler/parser.h)は、典型的な再帰下降パーサーです。中心となるメソッド Parse(io::Tokenizer* input, FileDescriptorProto* file) はトークンストリームを消費し、FileDescriptorProto を構築します。この FileDescriptorProto 自体が descriptor.proto で定義された protobuf メッセージです。ここで protobuf の自己参照的な性質が初めて現れます。つまり、パースの出力が proto スキーマによって記述されているのです。

parser はインポートの解決やファイル間の参照検証は行いません。単一ファイルの構文的な内容を表す生の FileDescriptorProto を生成するだけです。

SourceTreeDescriptorDatabasesrc/google/protobuf/compiler/importer.h)は、ファイルシステムと DescriptorPool をつなぐ橋渡し役です。pool がファイルを必要とすると、データベースはソースツリーからそのファイルを開き、トークナイズして FileDescriptorProto へとパースし、返却します。この遅延ロード設計により、ファイルは実際に必要になったとき——通常は他のファイルにインポートされたとき——にのみパースされます。

DescriptorPool:型情報の中央レジストリ

DescriptorPool はすべての型情報の正規ストアです。FileDescriptorProto が pool に渡されると、マルチパスの処理を経て不変の FileDescriptor へと構築されます。

  1. シンボル解決:フィールド型・メソッドの入出力・拡張など、すべての型参照がターゲットの descriptor へと解決されます
  2. 検証:オプション値のチェック、フィールド番号の一意性確認、予約範囲の強制適用が行われます
  3. フィーチャー解決:エディションのフィーチャーが継承・マージされます(第 6 回で詳解)
  4. フリーズ:生成された descriptor は不変となります
classDiagram
    class DescriptorPool {
        +FindFileByName()
        +FindMessageTypeByName()
        +BuildFile(FileDescriptorProto)
    }
    class FileDescriptor {
        +name() string
        +message_type(i) Descriptor*
        +enum_type(i) EnumDescriptor*
        +service(i) ServiceDescriptor*
        +dependency(i) FileDescriptor*
    }
    class Descriptor {
        +name() string
        +field(i) FieldDescriptor*
        +nested_type(i) Descriptor*
        +oneof_decl(i) OneofDescriptor*
    }
    class FieldDescriptor {
        +name() string
        +number() int
        +type() Type
        +message_type() Descriptor*
    }
    class EnumDescriptor {
        +name() string
        +value(i) EnumValueDescriptor*
    }
    class ServiceDescriptor {
        +name() string
        +method(i) MethodDescriptor*
    }
    DescriptorPool --> FileDescriptor
    FileDescriptor --> Descriptor
    FileDescriptor --> EnumDescriptor
    FileDescriptor --> ServiceDescriptor
    Descriptor --> FieldDescriptor
    Descriptor --> OneofDescriptor

構築済み descriptor の不変性は、重要な設計上の選択です。一度 FileDescriptor が構築されると、それ以降は変化しません。これにより、複数のスレッドが同期処理なしに descriptor を並行して読み取ることができます。ランタイム層全体がこの性質に依存しています。

Tip: .proto ファイルをプログラムから処理するツールを書く場合、Parser を直接使うより Importer クラス(importer.h)を使うのがほぼ確実に正解です。Importer はファイルパスから構築済み FileDescriptor オブジェクトまで、インポート解決を含むパイプライン全体を担ってくれます。

CodeGenerator インターフェースと言語ジェネレーターの登録

すべてのコードジェネレーターは CodeGenerator 抽象インターフェースを実装します。その中核となる契約は、単一の純粋仮想メソッドです。

virtual bool Generate(const FileDescriptor* file,
                      const std::string& parameter,
                      GeneratorContext* generator_context,
                      std::string* error) const = 0;

FileDescriptor* には完全に解決済みのスキーマが格納されています。GeneratorContext は出力ファイルを作成するためのファクトリーメソッドを提供します。ジェネレーターは descriptor を検査し、GeneratorContext::Open() を通じてコードを出力し、成否を返します。

ジェネレーターは GetSupportedFeatures() を通じて自身のケイパビリティを宣言します。主要なフィーチャービットは FEATURE_PROTO3_OPTIONALFEATURE_SUPPORTS_EDITIONS の 2 つです。エディションのサポートは第 6 回で説明するマイグレーションに不可欠で、エディションサポートを宣言していないジェネレーターは edition = "2023" を使用するファイルを拒否します。

第 1 回で見たように、main.cc はすべての組み込みジェネレーターを cli.RegisterGenerator("--X_out", "--X_opt", &generator, "help text") で登録します。CLI は --X_out フラグを登録情報と照合し、対応するジェネレーターへディスパッチします。

プラグインプロトコル:フォークなしで protoc を拡張する

protoc に組み込まれていない言語向けに、プラグインプロトコルがクリーンな拡張メカニズムを提供します。フローは次のとおりです。

sequenceDiagram
    participant protoc
    participant Plugin as protoc-gen-foo

    protoc->>protoc: Parse .proto files → FileDescriptors
    protoc->>protoc: Serialize CodeGeneratorRequest
    protoc->>Plugin: Write request to stdin
    Plugin->>Plugin: Deserialize request
    Plugin->>Plugin: Generate code
    Plugin->>protoc: Write CodeGeneratorResponse to stdout
    protoc->>protoc: Write output files to disk

CodeGeneratorRequest には、パースされたすべてのファイルとその推移的インポートのシリアライズ済み FileDescriptorProto オブジェクト、実際に生成対象となるファイルのリスト、そしてパラメーター文字列が含まれます。CodeGeneratorResponse には生成されたファイルの内容が含まれます。

PluginMain() は C++ プラグインを書くための 1 行エントリーポイントを提供します。ただし、このプロトコルは標準入出力上の protobuf シリアライゼーションを使用するため、protobuf のランタイムが存在するあらゆる言語でプラグインを書けます。Go でも Rust でも TypeScript でも構いません。プラグインが担う仕事はリクエスト/レスポンスメッセージの読み書きだけです。

この設計により、protoc 自体には一切の変更を加えることなく protobuf エコシステムを拡張できます。Go チームは Go で protoc-gen-go を、Dart チームは Dart で protoc-gen-dart をそれぞれメンテナンスしており、サードパーティは gRPC・バリデーション・モック生成など、あらゆる用途のコードを生成できます。

ブートストラップ:descriptor.proto が自分自身を記述する仕組み

protobuf コンパイラで最も頭を悩ませる部分がここです。descriptor.protoFileDescriptorProtoDescriptorProtoFieldDescriptorProto をはじめとするすべてのメタデータ型を定義しています。parser はその FileDescriptorProto インスタンスを出力し、DescriptorPool がそれを消費します。

しかし FileDescriptorProto 自体が protobuf メッセージであるため、シリアライズ・パースのために生成されたコードが必要です。そしてその生成コード(descriptor.pb.hdescriptor.pb.cc)は protoc によって生成されます。その protoc を動かすには FileDescriptorProto が必要です。

flowchart TD
    A["descriptor.proto"] -->|"parsed by"| B["protoc"]
    B -->|"generates"| C["descriptor.pb.h/.cc"]
    C -->|"compiled into"| B
    style A fill:#f9f,stroke:#333
    style C fill:#f9f,stroke:#333

これは古典的なブートストラップ問題です。解決策は実用的です。リポジトリに事前生成済みの descriptor.pb.hdescriptor.pb.cc をコミットしておきます。protoc をソースからビルドする際はこのコミット済みファイルを使用し、ビルドが完了したら再生成できます。CI がこれらの同期を継続的に検証します。

descriptor.proto の先頭にあるオプションに注目してください。

option optimize_for = SPEED;

これにより descriptor.proto がリフレクションサポートを持つ完全な(non-lite)メッセージを生成します。コンパイラの内部が descriptor proto に対してリフレクションベースのアルゴリズムを使用するため、これは必須です。

Tip: descriptor.proto には Edition 列挙型(EDITION_PROTO2EDITION_PROTO3EDITION_2023EDITION_2024 などの値を持つ)と、エディションシステムを支える FeatureSet メッセージも定義されています。protobuf が自身のメタデータとして何を考えているのか理解したいなら、まずここから始めましょう。

まとめ:全体像を俯瞰する

具体的な例で確認しましょう。次のコマンドを実行したとします。

protoc --cpp_out=out/ --proto_path=src/ src/mypackage/foo.proto
  1. ParseArguments()proto_path=src/、出力先 cpp_out=out/、入力ファイル mypackage/foo.proto を抽出します
  2. DiskSourceTreesrc/ を仮想ルートにマッピングします
  3. ソースツリー上に SourceTreeDescriptorDatabase が作成されます
  4. DescriptorPool がデータベースをラップします
  5. ParseInputFiles() が pool に mypackage/foo.proto を要求します
  6. pool がデータベースに問い合わせ、データベースがファイルを開いてトークナイズし、FileDescriptorProto へとパースします
  7. インポートがあれば同じ経路で再帰的に読み込まれます
  8. pool が FileDescriptorProto を不変の FileDescriptor へと構築し、ファイル間のすべての参照を解決します
  9. CLI が FileDescriptor* を持って CppGenerator::Generate() へディスパッチします
  10. ジェネレーターが GeneratorContext::Open() を通じて foo.pb.hfoo.pb.cc を出力します

次回の記事では、生成された C++ コードがランタイムでどのように動作するかを詳しく見ていきます。MessageLite/Message のクラス階層、PROTOBUF_CUSTOM_VTABLE のディスパッチ機構、ランタイムで型を構築する DynamicMessage、そして protobuf のアロケーションをほぼゼロコストにする 3 層のアリーナアロケーションシステムを解説します。