はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【C#】.protoからC#コードを生成するprotocのプラグインをC#で作成する方法

はじめに

Protocol Buffersのコンパイラであるprotocですが、プラグインを作成することで.protoを解析して自由にコードなどのファイルを生成することができます。また標準入出力さえフォーマットを守っていればよいのでどの言語でもプラグインの作成が可能です。

protoc, the Protocol Buffers Compiler, can be extended to support new languages via plugins. A plugin is just a program which reads a CodeGeneratorRequest protocol buffer from standard input and then writes a CodeGeneratorResponse protocol buffer to standard output. These message types are defined in plugin.proto. We recommend that all third-party code generators be written as plugins, as this allows all generators to provide a consistent interface and share a single parser implementation.

// DeepL
プロトコルバッファコンパイラであるprotocは、プラグインによって新しい言語をサポートするように拡張することができる。プラグインは、標準入力からCodeGeneratorRequestプロトコルバッファを読み込み、標準出力にCodeGeneratorResponseプロトコルバッファを書き込む単なるプログラムである。これらのメッセージタイプはplugin.protoで定義される。私たちは、すべてのサードパーティのコードジェネレータがプラグインとして書かれることを推奨します。これは、すべてのジェネレータが一貫したインターフェイスを提供し、単一のパーサ実装を共有することを可能にするからです。

Other Languages | Protocol Buffers Documentation

私はC#を普段利用しているので、この記事では.protoからC#コードを生成するプラグインをC#で作成する方法を紹介したいと思います。

実装方針

最初に全体像を書くと.protoを入力として受取り、protocpluginを呼び出してファイルを出力します。

protoc pluginが利用される流れ

pluginにだけフォーカスすると、

  • 標準入力でCodeGeneratorRequestを受取り
  • 標準出力でCodeGeneratorResponseを書き出す

を満たすコンソールアプリケーションを作成すればOKです。

CodeGeneratorRequestCodeGeneratorResponseについては.protoでスキーマが記述されています。初見だと難しそうに感じられるかもしれませんが、C#ではGoogleが出しているGoogle.ProtoBufというライブラリを使用すれば型が用意されているのでそこまで迷うことなく利用できるかと思います。このpluginの中に.protoのどの情報を用いて、どのようなファイルを生成するかを記述します。
plugin.pb.h | Protocol Buffers Documentation
https://github.com/protocolbuffers/protobuf/blob/main/src/%C3%A5google/protobuf/compiler/plugin.proto


自作したpluginを呼び出すのがprotocです。例えば以下のようなコマンドを用いることでpluginを使用することができます。

# mypluginという自作プラグインを使用
# ./sample.protoを入力とし、./outputにファイル出力を行う
$ protoc --plugin=protoc-gen-myplugin=./publish/MyPlugin --myplugin_out=./output ./sample.proto

ちょっと記法に癖があるので後ほど詳しく書きますが、基本はこれだけです。

プラグインの作成

C#でコンソールアプリケーションを作成する

dotnet cli(コマンド)でもVisualStudioやRiderでも何でも良いのですが、コンソールアプリケーションを作成してください。

# CustomProtocPluginというプロジェクト名のコンソールアプリ用プロジェクト作成
$ dotnet new console -n CustomProtocPlugin

NuGetパッケージの追加

先程も触れたのですが、Google.Protobufを導入すると型を用意してくれているのでかなり便利です。先程同様コマンドでもGUI上でもいいですがNuGetでインストールしてください。
www.nuget.org

$ dotnet add package Google.Protobuf

ちなみに使い方について本当に簡単にですが昔書いていました。
www.hanachiru-blog.com

コード実装

何度も同じことを言ってしまっていますが、標準入力からCodeGeneratorRequestを読み込み、標準出力としてCodeGeneratorResponseを書き込みます。割と型を見れば直感的に書けます。

using Google.Protobuf;
using Google.Protobuf.Compiler;

// 標準入力から CodeGeneratorRequest を読み込む
using var stdin = Console.OpenStandardInput();
var request = CodeGeneratorRequest.Parser.ParseFrom(stdin);

// リクエスト内の各 .proto ファイルを処理する
var response = new CodeGeneratorResponse();
foreach (var protoFile in request.ProtoFile)
{
    // CodeGeneratorRequest.FileToGenerateに含まれるファイル(明示的に指定されたファイル)のみを処理対象とする
    if (!request.FileToGenerate.Contains(protoFile.Name))
    {
        continue;
    }

    // 生成するコードを指定
    response.File.Add(new CodeGeneratorResponse.Types.File
    {
        Name = $"{Path.GetFileNameWithoutExtension(protoFile.Name)}.cs",
        Content = $$$"""
                     public class {{{Path.GetFileNameWithoutExtension(protoFile.Name)}}}Sandbox
                     {
                         public string[] GetMessages() =>
                         [
                             {{{string.Join(", ", protoFile.MessageType.Select(x => $"\"{x.Name}\""))}}}
                         ];
                         
                         public string GetPackageName() => "{{{protoFile.Package}}}";
                     }
                     """
    });
}

// 標準出力に CodeGeneratorResponse を書き込む
using var stdout = Console.OpenStandardOutput();
response.WriteTo(stdout);

このプロジェクトをビルドして実行バイナリを吐き出させます。

# ./publishの中にCustomProtocPluginという実行バイナリが吐き出される
$ dotnet publish -o ./publish
CodeGeneratorRequestについて

protocからpluginに渡される情報はすべてCodeGeneratorRequestに集約されています。

.protoの定義 Google.Protobufでの定義 説明
file_to_generate FileToGenerate(RepeatedField<string>) コマンドラインで直接指定された.protoファイル名のリスト
parameter Parameter(string) コマンドラインで渡されるパラメーター
proto_file ProtoFile(RepeatedField<FileDescriptorProto>) 解析された全ての.protoファイル(依存関係も含む)の内容
compiler_version CompilerVersion(Version) protocのバージョン

parameterについては後ほど渡し方を載せておきます。

CodeGeneratorResponseについて

.protoの定義 Google.Protobufでの定義 説明
error Error(string) エラーメッセージ。空でない場合はコード生成の失敗を示す
supported_features SupportedFeatures(ulong) プラグインがサポートする機能のビットマスク
minimum_edition MinimumEdition(int) 対応する最小のエディション
maximum_edition MaximumEdition(int) 対応する最大のエディション
file File(RepeatedField) 生成されるファイルのリスト

CodeGeneratorRequestが解析不可能であるようなprotoc自体の問題を示すエラーは標準エラーを0以外にし、それ以外はErrorに値を入れてステータスコードを0で返します。

プラグインの利用方法

作成した自作pluginprotocを通して利用します。

プラグインの名前

まずはプラグインの名前を決めておきます。これは必ずしも実行バイナリと名前が一致している必要はありません。

プラグイン名(NAMEを置換)・プラグインへのパス(path/to/mylibraryを置換)・出力先フォルダ(OUT_DIRを置換)を元に--plugin=protoc-gen-NAME=path/to/mybinary--NAME_out=OUT_DIRのようにprotocに渡してあげます。

Windowsの場合は必ず.exeを付与してください。
plugin.h | Protocol Buffers Documentation

# Windows
protoc --plugin=protoc-gen-NAME=path/to/mybinary.exe --NAME_out=OUT_DIR

# Otherwise
$ protoc --plugin=protoc-gen-NAME=path/to/mybinary --NAME_out=OUT_DIR

正しく実行できるとprotocはプラグインを呼び出して出力を生成し、出力先フォルダに保存します。

動作例

sample.proto
syntax = "proto3";

package sample.package;

option csharp_namespace = "Sample.Package";

message MyMessage1 {
  string name = 1;
}

message MyMessage2 {
  int32 id = 1;
}
sample.cs
public class sampleSandbox
{
    public string[] GetMessages() =>
    [
        "MyMessage1", "MyMessage2"
    ];
    
    public string GetPackageName() => "sample.package";
}

より高度な利用

パラメータを渡す方法

--myplugin_opt=key1=val1,key2=val2のようにコマンドラインで指定すると、CodeGeneratorRequest.Parameterstringkey1=val1,key2=val2が渡ってきます。

$ protoc --plugin=protoc-gen-myplugin=./publish/MyPlugin --myplugin_out="key1=val1,key2=val2:./output" ./sample.proto

カスタムオプションの利用

以前記事を書いたのでそちらを参照してみてください。

www.hanachiru-blog.com
www.hanachiru-blog.com