C# Source Generator 開発チュートリアル

C# Source Generator 開発チュートリアル

こんにちは、そして、お久しぶりです。
Aiming の土井です。
今年はファミリーベーシック40周年だそうです。小学生の頃に触れて、ゲームのプログラムを書く仕事を志したという思い出があり感慨深いですね。年齢は秘密です!

今回は「C# Source Generator の作り方」について書いていきます。

ソースジェネレーターとは

ソース ジェネレーターとは、.NET Compiler Platform (“Roslyn”) SDK で提供される、「コンパイル時コード生成機能」です。自動生成されたソースコードは、裏側に隠れ、生成コードを使用していることを意識せず、動的なコード生成による柔軟な実装を行うことができるようになります。

ソースジェネレーターは、アセンブリ単位毎に、コードに変更が入ったタイミングで動作し、任意のコード生成処理を実装することができます。

ユースケース

外部 DLL を参照することなく「ソースコード自動生成による機能」を追加することができます。また、コンパイルする C# ソースコードの構文木を解析し、適応したソースコードを生成することもできます。

インターフェースを追加する

既存のクラスにメソッドを生やすといったことができます。実装を伴うインターフェースを、継承関係を無視して導入できる。アスペクト指向なアプローチが可能です。

C#コードをスキーマ定義として使う

型として表現されたスキーマを基に、シリアライザ、通信コードといった付随するボイラープレートコードを自動生成し、煩雑で似たようなコードが増加することを防ぎ、本質的なロジックを書くことに集中できます。

リフレクションを避ける

ランタイムリフレクションを使わずに、自動生成されたコードに置き換え、処理の最適化や可読性の向上が期待できます。

Rider を使ったチュートリアル

ソースジェネレータープロジェクトの作成

  • クラスライブラリ
  • ターゲットフレームワーク netstandard2.0

として、ジェネレータープロジェクトを作りましょう。

続いて、NuGet パッケージ Microsoft.CodeAnalysis.CSharp version 3.8.0 をインストールします。
バージョン 3.8.0 と少し古いバージョンとなっている点に注意しましょう。これは、Unity でも動作するジェネレーターを作るために指定されているバージョンとなります。

 

動作確認用プロジェクトの作成

  • コンソールアプリで適当に作り、ソリューションに追加
  • ソースジェネレータープロジェクト csproj へのプロジェクト参照を作成し、csproj を直接編集して、以下のように、Analyzer として動作するように設定する
  • ここは .csproj を直接編集する必要があります。Analyzer として指定しないとソースジェネレーターが動かないので注意
<ItemGroup>
  <ProjectReference
     Include="..\SimpleSourceGenerator\SimpleSourceGenerator.csproj"
     OutputItemType="Analyzer"
     ReferenceOutputAssembly="false" />
</ItemGroup>

最小のサンプルコード

  • Untiy のドキュメントに示されたサンプルコードを、ソースジェネレータープロジェクト側に実装する。
  • 動作確認用プロジェクトをビルドすると、以下の場所に、コードが自動生成されていることを確認できる

※ Rider を使っている場合、ジェネレーターに依存したコードは初回の生成が行われるまではコンパイルエラーとして警告されます。
これは、まだ生成したコードが存在しないためです。

ソースジェネレーターのデバッグ

ソースジェネレーターを開発するためには、デバッグができないと相当しんどいです。
まずは、デバッグ環境を整えましょう。

ソースジェネレーター側のプロジェクトに、Properties フォルダを掘り、launchSettings.json というファイルを以下のように作成します。
targetProject には、動作確認用プロジェクトの csproj を指定します。

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "profiles": {
    "Generators": {
      "commandName": "DebugRoslynComponent",
      "targetProject": "../ConsoleTest/ConsoleTest.csproj"
    }
  }
}

Rider で launchSettings.json を開くと、再生ボタンが表示されているので、ここからデバッグ起動を行うことができます。
これで、動作確認用プロジェクトのソースコードがジェネレーターに渡り動作確認ができるようになります。また、ソースジェネレーター側のコードのブレークポイントが有効となりデバッグ可能となります。

デバッグ実行を行い、ジェネレーター内のブレークポイントで処理が停止することを確認しましょう。

デバッグ実行環境セットアップのまとめ

  • 動作確認用プロジェクトの csproj を直接編集し、ジェネレーターとして扱われるように参照を記述する
  • デバッグ環境を必ず整える

構文木を渡り歩く

次は、コンパイルされるソースコードの構文木を解析して、コードの内容に従ったコード生成を行ってみましょう。

ジェネレーターコールバックに渡される GeneratorExecutionContext にはコンパイルするソースコードの構文木が格納されています。

構文木は SyntaxNode という基底クラスのコンポジットとなっています。
単純な木構造のデータなので、foreach で子を探索していくことももちろん可能です。
しかし、一般的なシナリオを扱う場合には便利な機能が用意されているのでそちらを使いましょう。

ISyntaxReceiver

ISyntaxReceiver を継承したクラスを作成することで、SyntaxNode 毎に呼び出されるコールバックを登録できます。
ここで、ノードの判定を行うことで、木構造を意識することなく必要なノードだけを取得することができます。
クラス定義、関数定義といった定義を基点にコード生成を行うことが多いので、これで十分機能します。

最小の SyntaxReceiver

クラス定義をすべて抽出する SyntaxReceiver は以下のようになります。

    // クラス定義をすべて抽出
    class ClassReceiver : ISyntaxReceiver
    {
        public List<ClassDeclarationSyntax> Classes { get; }
            = new List<ClassDeclarationSyntax>();

        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
                Classes.Add(classDeclarationSyntax);
        }
    }

作った SyntaxReceiver をジェネレーターに登録するには、
ジェネレーターの Initialize で以下のように GeneratorInitializationContext.RegisterForSyntaxNotifications() を呼び出します。


   public void Initialize(GeneratorInitializationContext context)
   {
       context.RegisterForSyntaxNotifications(() => new ClassReceiver());
   }

より複雑な解析

ここまでくれば、あとは、構文木を解析する部分のコードをどう書くか、という問題のみになりました。
ここでは、属性付与されたクラスを抽出し、そのクラス定義に従って何かをするコードを書いてみます。
また、partial として定義されたクラスのみを扱うようにしてみます。

SyntaxNode の判定

Syntax Node は木構造のノードを表現するコンポジットです。
インスタンスの型とデータをみて、所望のデータかどうかを判定します。

クラスの判定

クラス定義は、ClassDeclarationSyntax という型のノードなので、これを判定します。

        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            // クラス定義か?
            var isClassDeclaration = syntaxNode is ClassDeclarationSyntax classDeclarationSyntax;
            if (isClassDeclaration)
            {
                Classes.Add(classDeclarationSyntax);
            }
        }

partial の判定

classDeclarationSyntax.Modifiers に partial キーワードが指定されているかの情報が含まれています。

        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            // クラス定義か?
            var isClassDeclaration = syntaxNode is ClassDeclarationSyntax classDeclarationSyntax;
            // partial か?
            var isPartial = classDeclarationSyntax.Modifiers.Any(c => c.IsKind(SyntaxKind.PartialKeyword));

            if (isClassDeclaration && isPartial)
            {
                Classes.Add(classDeclarationSyntax);
            }
        }

属性の判定

        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            // クラス定義か?
            var isClassDeclaration = syntaxNode is ClassDeclarationSyntax classDeclarationSyntax;
            // partial か?
            var isPartial = classDeclarationSyntax.Modifiers.Any(c => c.IsKind(SyntaxKind.PartialKeyword));
          // Hoge属性か?
            var hasAttribute = classDeclarationSyntax.AttributeLists
                    .Any(astx => astx.Attributes
                        .Any(x => $"{x.Name.ToFullString()}Attribute" == "HogeAttribute"));

            if (isClassDeclaration && isPartial && hasAttribute)
            {
                Classes.Add(classDeclarationSyntax);
            }
        }

これで、Hoge 属性を持つクラス定義を拾ってくることができました。
取得する属性を外側から指定できるようにした最終的なコードを以下に示します。

特定の属性を持つ partial クラスを取得する SyntaxReceiver 例

    // [T] 属性を持つクラスを抽出
    class AttributedMethodReceiver<T> : ISyntaxReceiver
        where T: Attribute
    {
        public List<ClassDeclarationSyntax> Classes { get; }
            = new List<ClassDeclarationSyntax>();

        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            var targetName = typeof(T).Name;
            if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&
                classDeclarationSyntax.Modifiers.Any(c => c.IsKind(SyntaxKind.PartialKeyword)) &&
                classDeclarationSyntax.AttributeLists
                    .Any(astx => astx.Attributes
                        .Any(x => $"{x.Name.ToFullString()}Attribute" == targetName)))
            {
                Classes.Add(classDeclarationSyntax);
            }
        }
    }

コード生成

いよいよ、抽出したクラス定義に基づいたコード生成を行ってみましょう。
ここでは、該当するクラスに Log() メソッドを自動的に生やす実装をしてみたいと思います。

抽出したクラスをつかって Log メソッドを生やす例

用意した AttributedMethodReceiver に抽出したクラス定義を確認し、クラス名を取得します。
クラス名は、partial クラスの生成コードに埋め込み、生成されるコードのファイル名にも使用しています。
ここでは、単純に文字列としてコードを表現しています。

    public class SimpleSourceGeneratorLoggingAttribute : Attribute
    {
    }
    [Generator]
    public class SimpleSourceGenerator : ISourceGenerator
    {
        public void Initialize(GeneratorInitializationContext context)
        {
            context.RegisterForSyntaxNotifications(() => new AttributedMethodReceiver<SimpleSourceGeneratorLoggingAttribute>());
        }

        public void Execute(GeneratorExecutionContext context)
        {
            var syntaxReceiver = (AttributedMethodReceiver<SimpleSourceGeneratorLoggingAttribute>)context.SyntaxReceiver;

            // 対象のクラスを順番に処理する
            var classes = syntaxReceiver.Classes;
            foreach (var c in classes)
            {
                var className = c.Identifier.ToString();
                var code = new StringBuilder();

                // コードを文字列として組み立てる
                code.Append("using System;");
                code.Append($@"
public partial class {className}
{{
    public void Log(string text) 
    {{
        Console.WriteLine($""{{text}}"");
    }}
}}");
                // 生成したソースコードを書き出す
                context.AddSource($"{className}.g", SourceText.From(code.ToString(), Encoding.UTF8));
            }
        }
    }

このジェネレーターを通して生成されるコードは以下のようなものとなります。

using System;
public partial class GenTestClass
{
    public void Log(string text) 
    {
        Console.WriteLine($"{text}");
    }
}

このジェネレーターを使うことで、以下のように Log メソッドが定義されていないクラスで Log() 呼び出しができるようになりました。動作確認用プロジェクトを実行して、実際にログが出力される様子を確認してみましょう。

    [SimpleSourceGeneratorLogging]
    public partial class GenTestClass
    {
        public void SomeMethod()
        {
            Console.WriteLine($"Some method called");
            Log("MyLogger");
        }
    }

サンプルコードの注意

このサンプルコードは要点をシンプルにするため簡略化されています。クラスを namespace 下に定義すると、生成コードが機能しません。対象クラスの SyntaxNode を親方向にたどって、NamespaceDeclarationSyntax ノードを探し、見つかった場合は、namespace で囲ったコードを生成する必要があります。

暗黙的に関数が定義されることには注意が必要です。ジェネレーターを使うことによる学習コストの増大とプロジェクトの方針をきちんと擦り合わせ、ドキュメントなどを用意して機能の周知をする必要があるでしょう。

さいごに

ソースジェネレーターは、アスペクト指向な設計や、スキーマを要求するようなプロジェクト、フレームワーク開発にとって非常に有用なツールだと感じました。
(すいません、Unity で動かすところまで、書ききれませんでした。)

Unity でも使えるので、いろいろ応用がききそうですね!
本記事が、皆さんのジェネレーター開発の土台作りに役立てばと思います!

参考資料