はなちるのマイノート

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

【C#】.protoに記載されているコメントをランタイムで取得する方法(--descriptor_set_outを用いてFileDescriptorSetを活用する)

はじめに

今回は.protoに記載されているコメントをランタイムで取得する方法を紹介したいと思います。

// example.proto
syntax = "proto3";

import "google/protobuf/descriptor.proto";

option csharp_namespace = "Protobuf.Sample";

// メッセージ定義にカスタムオプションを追加
extend google.protobuf.MessageOptions {
  string my_option = 50000;
}

// メッセージ定義 <= ここらへんのコメントをランタイムで取得したい
message MyMessage {
  // 上で定義したカスタムオプションを使用
  option (my_option) = "Hello world!";

  // メッセージフィールド <= ここらへんのコメントを取得したい
  string message = 1;
}

概要

例えば以下のような.protoがあったとします。

// example.proto
syntax = "proto3";

import "google/protobuf/descriptor.proto";

option csharp_namespace = "Protobuf.Sample";

// メッセージ定義にカスタムオプションを追加
extend google.protobuf.MessageOptions {
  string my_option = 50000;
}

// メッセージ定義
message MyMessage {
  // 上で定義したカスタムオプションを使用
  option (my_option) = "Hello world!";

  // メッセージフィールド
  string message = 1;
}

まず単純にprotoc--csharp_outを指定して出力します。Google.Protobufが入っていないと生成コードのコンパイルが通らないので注意です。

$ cd <.protoが入っているフォルダ>
$ protoc --proto_path="."  --csharp_out="."  example.proto

一応XMLドキュメントでコメントがついていたりはしますが、直接コメントをプログラムから取得することはできません。

/// <summary>
/// メッセージ定義
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class MyMessage : pb::IMessage<MyMessage>

ただし上手いこと工夫をすると、これを取得することができるのでそのやり方を紹介したいと思います。

やり方

protocを利用してdescriptor_setを生成する

下記コマンドを打ち込むことで、descriptor_setを生成することができます。

$ protoc --proto_path="." --descriptor_set_out="./descriptor_set.desc" --include_source_info --include_imports example.proto
FileDescriptorProtoとは

FileDescriptorProtoは、Protocol Buffersのスキーマ定義を表現するためのメッセージです。具体的には、.protoファイルの内容を表現します。メッセージ、列挙型、サービス、フィールドなどの定義が含まれており、コメントもしっかりと含まれています。
github.com

FileDescriptorSet(descriptor_set)とは

FileDescriptorSet(descriptor_set)は、複数のFileDescriptorProtoをまとめたものです。これは、複数の.protoファイルのスキーマ定義を一つにまとめて扱うために使用されます。protocを実行する際に--descriptor_set_outを指定すると生成することができます。
github.com

protocのインストール先

globalにprotocをインストールして利用することもできます。
Installing protoc | proto-lens

$ brew install protobuf

またGoogle.Protobuf.ToolsをNuGetで取得して、そこに入っているprotocを利用もできます。
www.hanachiru-blog.com

protocのオプションについて

ヘルプコマンドを打つと良いでしょう。

$ protoc --help
  -IPATH, --proto_path=PATH   Specify the directory in which to search for
                              imports.  May be specified multiple times;
                              directories will be searched in order.  If not
                              given, the current working directory is used.
                              If not found in any of the these directories,
                              the --descriptor_set_in descriptors will be
                              checked for required proto file.
  -oFILE,                     Writes a FileDescriptorSet (a protocol buffer,
    --descriptor_set_out=FILE defined in descriptor.proto) containing all of
                              the input files to FILE.
  --include_imports           When using --descriptor_set_out, also include
                              all dependencies of the input files in the
                              set, so that the set is self-contained.
  --include_source_info       When using --descriptor_set_out, do not strip
                              SourceCodeInfo from the FileDescriptorProto.
                              This results in vastly larger descriptors that
                              include information about the original
                              location of each decl in the source file as
                              well as surrounding comments.
  --csharp_out=OUT_DIR        Generate C# source file.
  @<filename>                 Read options and filenames from file. If a
                              relative file path is specified, the file
                              will be searched in the working directory.
                              The --proto_path option will not affect how
                              this argument file is searched. Content of
                              the file will be expanded in the position of
                              @<filename> as in the argument list. Note
                              that shell expansion is not applied to the
                              content of the file (i.e., you cannot use
                              quotes, wildcards, escapes, commands, etc.).
                              Each line corresponds to a single argument,
                              even if it contains spaces.

今回利用したものは上記になります。

FileDescriptorSetを利用してコメントを取り出す

先ほど生成したFileDescriptorSetをランタイムで読み込み、コメントを取得します。

using var descriptorSetFile = File.OpenRead("---/descriptor_set.desc");
var descriptorSet = FileDescriptorSet.Parser.ParseFrom(descriptorSetFile);

foreach (var file in descriptorSet.File)
{
    Console.WriteLine($"File Name : {file.Name}");
            
    // SourceCodeInfoは元のソースコードに関するオプションの情報が含まれます。--include_source_infoを有効にして生成された場合にのみ含まれます。
    foreach (var location in file.SourceCodeInfo.Location)
    {
        if(location.Path == null || location.Path.Count == 0)
        {
            continue;
        }
                
        // 要素の前にあるコメント
        if (location.HasLeadingComments)
        {
            Console.WriteLine($"- LeadingComments : {location.LeadingComments}");
        }

        // 要素の後にあるコメント
        if (location.HasTrailingComments)
        {
            Console.WriteLine($"- TrailingComments : {location.TrailingComments}");
        }
                
    }
}
File Name : example.proto
- LeadingComments :  メッセージ定義にカスタムオプションを追加
- LeadingComments :  メッセージ定義
- LeadingComments :  上で定義したカスタムオプションを使用
- LeadingComments :  メッセージフィールド

ただネタバレをするとFileDescriptorSetをそのまま使うと非常に使いづらいです。上記でコメントを取得したとしても、どのmessageserviceと紐づいているかを確認するのも一苦労です。

FileDescriptorを利用する

そこでFileDescriptorというラッパークラスを用います。これはGoogle.Protobuf.Reflectionに入っているclassになります。

github.com
github.com

// FileDescriptorSetを読み込む
using var descriptorSetFile =
    File.OpenRead("---/descriptor_set.desc");
FileDescriptorSet descriptorSet = FileDescriptorSet.Parser
    .ParseFrom(descriptorSetFile);

// FileDescriptorProtoをFileDescriptorに変換するために一度ByteStringに変換する
List<ByteString> byteStrings = descriptorSet.File
    .Select(x => x.ToByteString())
    .ToList();

// FileDescriptorに変換
IReadOnlyList<FileDescriptor> fileDescriptors =
    FileDescriptor.BuildFromByteStrings(byteStrings);

foreach (var fileDescriptor in fileDescriptors)
{
    Console.WriteLine(fileDescriptor.Name);
    foreach (var fileDescriptorMessageType in fileDescriptor.MessageTypes)
    {
        Console.WriteLine(fileDescriptorMessageType.Declaration.LeadingComments);
        Console.WriteLine(fileDescriptorMessageType.Declaration.TrailingComments);
    }
    foreach (var fileDescriptorService in fileDescriptor.Services)
    {
        Console.WriteLine(fileDescriptorService.Declaration.LeadingComments);
        Console.WriteLine(fileDescriptorService.Declaration.TrailingComments);
    }
}

LeadingCommentsが前についているコメントで、TrailingCommentsが後ろについているコメントです。これでどのServiceやMessageなどについているコメントかどうかを一目瞭然で調べることができました。

カスタムオプションについて

カスタムオプションを読み取るには、少し工夫がいるのでそれも紹介したいと思います。ExtensionRegistryというものを用意しておいて、FileDescriptor.BuildFromByteStringsメソッドの引数で渡してあげる必要があります。

// CustomOptionを利用している場合はExtensionRegistryを用意しなければならないので注意
ExtensionRegistry extensionRegistry =
[
    ExampleExtensions.MyOption
];

// FileDescriptorSetを読み込む
using var descriptorSetFile =
    File.OpenRead("---/descriptor_set.desc");
FileDescriptorSet descriptorSet = FileDescriptorSet.Parser
    .WithExtensionRegistry(extensionRegistry)
    .ParseFrom(descriptorSetFile);

// FileDescriptorProtoをFileDescriptorに変換するために一度ByteStringに変換する
List<ByteString> byteStrings = descriptorSet.File
    .Select(x => x.ToByteString())
    .ToList();

// FileDescriptorに変換
IReadOnlyList<FileDescriptor> fileDescriptors =
    FileDescriptor.BuildFromByteStrings(byteStrings, extensionRegistry);

foreach (var fileDescriptor in fileDescriptors)
{
    Console.WriteLine(fileDescriptor.Name);
            
    foreach (var fileDescriptorMessageType in fileDescriptor.MessageTypes)
    {
        var options = fileDescriptorMessageType.GetOptions();
        if (options != null && options.HasExtension(ExampleExtensions.MyOption))
        {
            var myOption = options.GetExtension(ExampleExtensions.MyOption);
            Console.WriteLine($"Custom Option (my_option): {myOption}");
        }
    }
}
$ dotnet run
example.proto
Custom Option (my_option): Hello world!