Read OSS

protoc の内部構造:.proto ファイルからコード生成まで

中級

前提知識

  • 第1回:Protobuf モノレポの全体像を把握する
  • コンパイラの基本的な概念(字句解析・構文解析・AST)への理解

protoc の内部構造:.proto ファイルからコード生成まで

第1回で確認したように、protoc コンパイラはすべての .proto ファイルが通過する唯一の入口です。しかし「コンパイラ」という言葉では、その実態を十分に表せません。protoc は一種のフレームワークです。字句解析器・構文解析器・型システムのバリデーター・ファイル間リンカー、そして拡張可能なコードジェネレーターへのディスパッチシステムを組み合わせ、それらをすべて 4000 行超の CommandLineInterface クラスが統括しています。

この記事では、生の .proto テキストから生成済みソースファイルに至るまでの全工程を追いながら、パイプラインの各ステージと拡張性を支える主要な抽象化を順に見ていきます。

CommandLineInterface:オーケストレーター

すべての処理は CommandLineInterface から始まります。このクラスは protoc の中核であり、ヘッダーのコメントに丁寧なドキュメントが添えられています。使い方はヘッダーのサンプルコードを見ると一目瞭然です。

int main(int argc, char* argv[]) {
    google::protobuf::compiler::CommandLineInterface cli;
    
    google::protobuf::compiler::cpp::CppGenerator cpp_generator;
    cli.RegisterGenerator("--cpp_out", &cpp_generator,
      "Generate C++ source and header.");
    
    return cli.Run(argc, argv);
}

Run() が実際の処理を担います。コマンドライン引数を解析し、ファイル解決のための DiskSourceTree をセットアップします。次に SourceTreeDescriptorDatabase を生成して DescriptorPool を構築し、入力された .proto ファイルを解析して適切なコードジェネレーターやプラグインへディスパッチします。

flowchart TD
    A["cli.Run(argc, argv)"] --> B["ParseArguments()"]
    B --> C["Set up DiskSourceTree"]
    C --> D["Create SourceTreeDescriptorDatabase"]
    D --> E["Build DescriptorPool"]
    E --> F["Parse .proto files → FileDescriptor"]
    F --> G{Generator or Plugin?}
    G -->|Built-in| H["CodeGenerator::Generate()"]
    G -->|Plugin| I["Fork subprocess<br/>Send CodeGeneratorRequest<br/>via stdin"]
    H --> J["Write output files"]
    I --> J

CLI が担う重要な責務のひとつが、proto パスの解決です。AddDefaultProtoPaths 関数は、descriptor.proto などのウェルノウン型がインストールされている場所を自動的に探し出し、protoc バイナリの位置を基準に相対パスを確認します。標準的な型に対して --proto_path フラグを明示しなくても protoc がそのまま動くのは、この仕組みのおかげです。

字句解析と構文解析:テキストから FileDescriptorProto へ

最初の変換ステップは、生の .proto テキストを FileDescriptorProto に変換することです。FileDescriptorProto は、後続のすべてのステージが消費する AST 表現です。

Tokenizer クラスは ZeroCopyInputStream からトークンのストリームを生成します。識別子・整数・浮動小数点数・文字列・記号といった C 系言語に近いトークンを認識します。トークンの種類は次の enum で定義されています。

enum TokenType {
    TYPE_START,       // Next() has not yet been called.
    TYPE_END,         // End of input reached.
    TYPE_IDENTIFIER,  // Letters, digits, underscores
    TYPE_INTEGER,     // Decimal, hex (0x), or octal
    TYPE_FLOAT,       // Floating point literal
    TYPE_STRING,      // Quoted string
    TYPE_SYMBOL,      // Any other printable character
};

続いて Parser がそのトークンストリームを受け取り、FileDescriptorProto を構築します。ヘッダーのコメントにはこのクラスの役割が率直に書かれています。

"Parser is a lower-level class which simply converts a single .proto file to a FileDescriptorProto. It does not resolve import directives or perform many other kinds of validation needed to construct a complete FileDescriptor."

flowchart LR
    A[".proto text"] --> B["ZeroCopyInputStream"]
    B --> C["Tokenizer"]
    C -->|"Token stream"| D["Parser"]
    D --> E["FileDescriptorProto"]
    
    style E fill:#f9f,stroke:#333

Parse() メソッドのシグネチャがすべてを物語っています。

bool Parse(io::Tokenizer* input, FileDescriptorProto* file);

トークナイザーと出力用プロトを受け取り、成否を返すだけです。エラー報告には ErrorCollector インターフェースが使われ、行番号と列番号を受け取ります。列番号はタブ文字を「8の倍数に揃える」ルールで正確に追跡されます。

ヒント: Parser が扱うのは単一ファイルだけです。import の解決、ファイル間の型チェック、ディスクリプタグラフの構築は、上位レイヤーが担います。解析の問題をデバッグするときは、Parser を単独で切り出してテストできます。

Importer と SourceTree の抽象化

生のファイル I/O と Parser の間には重要な抽象レイヤーがあります。SourceTree とそのパートナーである Importer です。これらのクラスは、.proto ファイル内の import パスを実際のファイル内容にマッピングするという問題を解決します。

SourceTreeDescriptorDatabase は、ファイルシステムの抽象とディスクリプタシステムをつなぐ橋渡し役です。DescriptorDatabase インターフェースを実装しており、SourceTree でファイルを開き Parser で解析します。DescriptorPool がまだロードされていないファイルを必要とすると、データベースに問い合わせが来て、その場でファイルの読み込みと解析が行われます。

classDiagram
    class DescriptorDatabase {
        <<interface>>
        +FindFileByName()
        +FindFileContainingSymbol()
    }
    
    class SourceTreeDescriptorDatabase {
        -source_tree_: SourceTree*
        -fallback_database_: DescriptorDatabase*
        +FindFileByName()
        +RecordErrorsTo()
        +GetValidationErrorCollector()
    }
    
    class SourceTree {
        <<interface>>
        +Open(filename): ZeroCopyInputStream*
    }
    
    class DiskSourceTree {
        +MapPath(virtual_path, disk_path)
        +Open(filename)
    }
    
    class Importer {
        -database_: SourceTreeDescriptorDatabase
        -pool_: DescriptorPool
        +Import(filename): FileDescriptor*
    }
    
    DescriptorDatabase <|-- SourceTreeDescriptorDatabase
    SourceTree <|-- DiskSourceTree
    SourceTreeDescriptorDatabase --> SourceTree
    Importer --> SourceTreeDescriptorDatabase
    Importer --> DescriptorPool

Importer クラスはこれらをシンプルなインターフェースにまとめています。Import("foo.proto") を呼ぶと、そのファイルとすべての import を再帰的に解析し、完全な FileDescriptor オブジェクトを構築します。また、Importer はどのファイルがすでに import 済みかを追跡し、重複した解析やエラー報告を防ぎます。

DiskSourceTree の実装では --proto_path マッピングの概念が加わり、仮想的な import パス(例:google/protobuf/timestamp.proto)を物理的なディスク上のパスに変換します。これが protoc が複数のルートディレクトリと -I フラグをサポートできる仕組みです。

DescriptorPool:バリデーションとクロスリンク

FileDescriptorProto インスタンスが解析されると、次に DescriptorPool へと渡されます。DescriptorPool はスキーマの検証、ファイル間の参照解決、不変の Descriptor オブジェクトの生成を担う中央レジストリです。

型システムの本質的な処理が行われるのはここです。import "other.proto" と書いてそのファイルのメッセージを参照したとき、その参照を解決するのが DescriptorPool です。フィールド型の存在確認、フィールド番号の一意性チェック、oneof 定義の整合性検証、オプションの有効性確認など、幅広い検証を行います。

DescriptorPool には2つの動作モードがあります。ひとつは DescriptorDatabase をバックエンドに持ち、必要に応じてディスクリプタを遅延構築するモードです。もうひとつは、生成済みコードがランタイムに使うモードで、生成コード自体に埋め込まれたシリアライズ済みの FileDescriptorProto データからディスクリプタを事前構築します。

遅延構築モードは lazily_build_dependencies_ フラグで制御され、実際に型がアクセスされるまでファイル間のリンクを遅延します。大規模な依存グラフを扱う protoc のパフォーマンスにとって重要な設計で、1ファイルのコード生成のためだけに全推移的 import を完全解決する無駄を防ぎます。

flowchart TD
    FDP["FileDescriptorProto<br/>(parsed AST)"] --> POOL["DescriptorPool"]
    POOL --> VALIDATE["Validate field numbers,<br/>types, options"]
    VALIDATE --> RESOLVE["Resolve cross-file<br/>type references"]
    RESOLVE --> LINK["Cross-link descriptors"]
    LINK --> FD["FileDescriptor<br/>(immutable, complete)"]
    FD --> MD["Descriptor<br/>(message types)"]
    FD --> FLD["FieldDescriptor"]
    FD --> ED["EnumDescriptor"]
    FD --> SD["ServiceDescriptor"]

この処理の出力が、不変の Descriptor 階層です。FileDescriptorDescriptorFieldDescriptorEnumDescriptor などがコードジェネレーターに渡されます。この階層については第3回で詳しく取り上げます。

CodeGenerator インターフェースとプラグインシステム

最後のステージがコード生成です。すべての組み込みジェネレーターは CodeGenerator 抽象インターフェースを実装しています。中核となるメソッドはこちらです。

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

各ジェネレーターは、バリデーション済みの FileDescriptor、コマンドラインオプションから渡されるパラメーター文字列、そして ZeroCopyOutputStream を通じて出力ファイルを管理する GeneratorContext を受け取ります。GeneratorContext はインサーションポイントもサポートしており、既に生成されたファイルの指定箇所に追加コードを差し込むことができます。

CodeGenerator インターフェースでは、GetMinimumEdition()GetMaximumEdition() を通じた Editions のサポート宣言や、GetFeatureExtensions() による機能拡張の宣言も行えます。これらは第3回で取り上げる Editions システムで使用されます。

外部ジェネレーターに対しては、protoc はサブプロセス通信プロトコルを使います。未知の --foo_out フラグを検出すると、AllowPlugins("protoc-") で設定されたプレフィックスに基づいて protoc-gen-foo という名前のバイナリを PATH から探します。通信プロトコルは plugin.h にドキュメント化されています。

sequenceDiagram
    participant protoc
    participant Plugin as protoc-gen-foo
    
    protoc->>Plugin: Fork + pipe
    protoc->>Plugin: CodeGeneratorRequest (stdin)
    Note over Plugin: Contains FileDescriptorProtos<br/>for all files + dependencies
    Plugin->>Plugin: Generate code
    Plugin->>protoc: CodeGeneratorResponse (stdout)
    Note over protoc: Contains generated file<br/>names and content
    protoc->>protoc: Write output files

プラグインは stdin で CodeGeneratorRequest プロトバフを受け取ります。これには対象ファイルとその推移的依存関係の両方の FileDescriptorProto データがすべて含まれています。プラグインは .proto ファイルを直接読み込んではいけません。すべてのデータはシリアライズされたディスクリプタセットを通じて渡されます。これにより、プラグインが protoc と同じ解析・検証済みのスキーマビューで動作することが保証されます。

C++ でプラグインを書く場合、protoc は PluginMain ヘルパーを提供しています。

int main(int argc, char* argv[]) {
    MyCodeGenerator generator;
    return google::protobuf::compiler::PluginMain(argc, argv, &generator);
}

ヒント: コードジェネレーターの問題をデバッグするときは、--descriptor_set_out でシリアライズされたディスクリプタをダンプして、それを手動でジェネレーターに渡す方法が使えます。ジェネレーターが受け取る入力を完全に再現できるので、問題の切り分けがしやすくなります。

パイプライン全体の流れを整理する

では、main.cc のエントリーポイントからすべての処理をつなげてみましょう。protoc --cpp_out=. foo.proto を実行したとき、内部では次の手順が進みます。

  1. ProtobufMain() がすべての組み込みジェネレーターを登録する
  2. cli.Run() が引数を解析し、--cpp_out を C++ ジェネレーターと対応付ける
  3. DiskSourceTree--proto_path で指定されたディレクトリをマッピングする
  4. SourceTreeDescriptorDatabase がソースツリーをラップする
  5. DescriptorPool がデータベースをラップする
  6. foo.protoTokenizerParser のチェーンで FileDescriptorProto に解析される
  7. DescriptorPool がそれを検証・クロスリンクして FileDescriptor を構築する
  8. CppGenerator::Generate()FileDescriptor を受け取り、.pb.h.pb.cc を出力する

このパイプラインは対象言語によらず同じです。変わるのはステップ 8 だけです。このアーキテクチャの一貫性こそが、1つのツールで 10 以上の言語ターゲットをサポートできる理由です。

第3回では、Descriptor 階層そのものを深掘りします。コンパイラとすべての言語ランタイムが依存するランタイム型システムです。DescriptorPool がパックレイアウトでメモリを最適化する方法、MessageLiteMessage が2層のクラス階層を形成するしくみ、そして旧来の proto2/proto3 の区別を置き換える新しい Editions システムについて見ていきます。