Visual Studio と ソース ジェネレーター (Source Generator) を使用してコードを自動生成する

Siden oppdatert :
ページ作成日 :

動作確認環境

Visual Studio
  • Visual Studio 2022
.NET
  • .NET 8.0

動作必須環境

Visual Studio
  • ある程度古いバージョンでも動きます
.NET
  • ある程度古いバージョンでも動きます

はじめに

コードを独自の定義によって自動生成する手法はいくつかありますが、今回は「ソース ジェネレーター (Source Generator)」を使った方法について説明します。 ソース ジェネレーターの最大のメリットは現在のプロジェクトのソースコードの構成に合わせて解析を行い、それをもとに新たにコードを生成することができることです。 例えば新しくクラスを作った時にそのクラスに合わせてコードを自動で追加するように作ることもできます。 どのようなコードを生成するかは自分でプログラムできるので好きな形のコード自動生成を作ることができます。

コードは基本的にバックグラウンドで自動生成され見えないところでプロジェクトに組み込まれます。 ファイルとして目にわかるように出力されることはないため自動生成コードを汎用的に使いまわす目的では使用しません (一応コピーすれば取れますが)。 ですがプロジェクトの構成に合わせてコードが自動生成されるので手入力するコストを減らし、それに合わせてコードの記述ミスを減らせるので非常に大きなメリットとなります。

今回はコードが自動生成されるのを確認するところまでを説明するので実際にコードを深く解析して高度な出力を行う、というところまではやりません。 そこは応用として自分で調べてみてください。

セットアップ

まずは Visual Studio をインストールしてください。簡単な説明は以下の Tips にまとめています。

基本的にどのプロジェクトでも使用できるのでどのワークロードでセットアップしても構いません。 ただ今回は「個別のコンポーネント」として「.NET Compiler Platform SDK」を追加してください。 これがあると Source Generator 開発時のデバッグで便利です。 Visual Studio をインストール済みの場合は Visual Studio のメニューの「ツール > ツールと機能を取得」から追加可能です。

Source Generator のプロジェクトの作成と準備

Source Generator はメインで開発するアプリケーションのプロジェクトとは別のプロジェクトで作成します。 先に作成しても後から追加で作成してもどちらでも構いません。 今回は Source Generator のプロジェクトから作成してみます。

新しいプロジェクトの作成画面で「クラス ライブラリ」を選択します。

プロジェクト名は何でも構いませんがここでは CodeGenerator としておきます。

クラスライブラリについては現在 Source Generator が「.NET Standard 2.0」のみ対応となっています。

プロジェクトを作成したら NuGet で Microsoft.CodeAnalysis.CSharp パッケージを取得します。 バージョンによって挙動が異なる場合があるのですが、古いバージョンを使い続けてもしょうがないので最新版を入れておきます。

続いてプロジェクトファイルをコードとして開きます。

開くと以下のようになっていると思います。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
  </ItemGroup>

</Project>

これを以下のように追加します。他に必要なものがあれば追加して構いません。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>

    <!-- Roslyn によるコード解析を行う -->
    <IsRoslynComponent>true</IsRoslynComponent>
    <!-- 入れないと警告が表示されるので入れておく -->
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <!-- 最新の C# の記述を使いたいので入れておく -->
    <LangVersion>Latest</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
  </ItemGroup>

</Project>

次にコードの自動生成となるクラスのコードを作成します。 まずは枠組みだけ作るので最初からある Class1.cs を書き換えるなり新しくコードを追加してください。

コードは以下のようにします。クラス名は何でもいいですが、どういったコードを自動生成するのかがわかるような名前のほうがいいです。 ここでは SampleGenerator としておきます。 この Initialize メソッドの中にコードの解析とコードの生成処理を書いていくことになります。

using Microsoft.CodeAnalysis;

namespace CodeGenerator
{
  [Generator(LanguageNames.CSharp)]
  public partial class SampleGenerator : IIncrementalGenerator
  {
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
    }
  }
}

次にコード自動生成対象となるアプリケーション用のプロジェクトを作成します。 なんでもいいですがここでは簡単にコンソールアプリケーションとします。 コード自動生成先のプロジェクトのフレームワークの種類やバージョンは大体対応しています。

アプリケーション側のプロジェクトからソースジェネレーターのプロジェクトの参照を追加します。

一部の設定はプロパティで設定できないのでプロジェクトファイルをコードで開きます。

以下のようになっていると思います。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\CodeGenerator\CodeGenerator.csproj" />
  </ItemGroup>

</Project>

参照したプロジェクトの設定を追加して以下のようにします。XML の構文を間違えないようにしてください。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\CodeGenerator\CodeGenerator.csproj">
      <OutputItemType>Analyzer</OutputItemType>
      <ReferenceOutputAssembly>False</ReferenceOutputAssembly>
    </ProjectReference>
  </ItemGroup>

</Project>

続いて「コードジェネレーター側」のプロジェクトのプロパティを開きます。

「デバッグ起動プロパティ UI を開く」のリンクをクリックします。

最初からあるプロファイルは使わないので削除します。

新しいプロファイルを追加します。

「Roslyn Component」を選択します。

ここまでの設定を正しく行っていればアプリケーションプロジェクトが選択できるはずなので選択します。

準備はここまでとなります。

デバッグできるか確認

ソースジェネレーターのコードを開き Initialize メソッドの最後にブレークポイントを置きます。

ソースジェネレーターをデバッグ実行してみます。

ブレークポイントで処理が止まれば正常にデバッグできていると確認できます。 これでソースジェネレーターの開発がそれなりに楽になるはずです。

とりあえず固定のコードを出力してみる

まずは簡単に固定のコードを出力してみます。コードの解析も必要ないので簡単です。 固定のコードといっても文字列で扱うのでプログラムを組めば決まった形でコードを増産することも可能です。

固定のコードを出力する場合は context.RegisterPostInitializationOutput を使用することによって出力することができます。 以下は出力するコードの例です。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace CodeGenerator;

[Generator(LanguageNames.CSharp)]
public partial class SampleGenerator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    // コードを解析せずそのままコードを出力するならこれを使用します
    context.RegisterPostInitializationOutput(static postInitializationContext =>
    {
      // コード生成処理が中断されることもあるので CancellationToken 処理は入れたほうがいいです
      System.Threading.CancellationToken token = postInitializationContext.CancellationToken;
      token.ThrowIfCancellationRequested();

      // 出力するコードを作ります
      var source = """
internal static class SampleClass
{
  public static void Hello() => Console.WriteLine("Hello Source Generator!!");
}
""";
      
      // コードファイルに出力します (実際に目に見えるどこかのフォルダに出力されるわけではありません)
      postInitializationContext.AddSource("SampleGeneratedFile.cs", SourceText.From(source, Encoding.UTF8));
    });
  }
}

コードの内容はコメントに書いてある通りなので詳細は省きます。ビルドを行いエラーがないことを確認してください。

ビルドが完了したらアプリケーション側のプロジェクトにある「アナライザー」を展開するとコードジェネレーター側で生成されたコードを確認することができます。

もし表示されていない場合は Visual Studio を再起動してから確認してみてください。 どうも現時点では Visual Studio 再起動しないと更新されないこともあるみたいです。 プログラムを作成するときに使う Intellisence やシンタックスハイライトも同様っぽいです。 ただコード自体はビルド時点で反映されているのでアプリケーション側にプログラムは反映されるようです。

コードが生成されたらアプリケーション側で使ってみてください。正常に動作するはずです。

コードを解析して生成してみる

普通にコードを加工して出力するだけではほかのコード自動生成とあまり変わらずソースジェネレーターのメリットを生かせません。 なので今度はアプリケーションプロジェクトのコードを解析してそれに合わせてコードを生成してみます。

ただコードの解析は結構奥が深いのでここですべて説明することはできません。とりあえず解析してコードを出力できるよ、というところまでを説明します。 より深く作りこみたい場合は各々で調べてください。

とりあえずここでは一例として「作られたすべてのクラスに Reset メソッドを追加してすべてのプロパティの値を default に再設定する」というコードを自動で作るようにしてみたいと思います。 近年はイミュータブルなインスタンスを扱うことが好まれているようですが、ゲームプログラムなんかでは毎フレームごとに新しいインスタンスを作るわけにはいかないので値を再設定する処理の利用場面はあるかと思います。 default 以外を設定したいこともあるかもしれませんがそこまでやると最初の例としては長くなってしまうのでそこは各自応用してやってみてください。

コードジェネレーター側

前に作ったジェネレーターは残す目的で新しいクラスを作ります。 自動生成する内容が変わる場合は別クラスで新たに作ったほうが良いです。

コードの構造化自体は Roslyn コンパイラが行ってくれるのでここでやることは構造化されたデータを解析してコードの作成を行うことです。

まずはコードをすべて掲載しておきます。可能な限りコードが少なくなるようにしてみました。 今回はこれで動きますがあくまでもサンプルとして動くレベルなので実際に開発を進めると足りない部分は結構出てきます。 そこは必要に応じて改善してください。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Linq;
using System.Text;
using System.Threading;

namespace CodeGenerator;

[Generator(LanguageNames.CSharp)]
public partial class Sample2Generator : IIncrementalGenerator
{
  public void Initialize(IncrementalGeneratorInitializationContext context)
  {
    // 構文を解析し処理対象のノードをコード出力用に変換します
    var syntaxProvider = context.SyntaxProvider.CreateSyntaxProvider(
      // すべてのノードが処理対象となるため、predicate で処理対象とするノードを限定します
      predicate: static (node, cancelToken) =>
      {
        // 処理が中断される場面は多々あるのでいつでもキャンセルできるようにしておく
        cancelToken.ThrowIfCancellationRequested();

        // クラスのノードのみを対象とする (record とかつけると別な種類のノードになるので注意)
        // また partial class であること
        return node is ClassDeclarationSyntax nodeClass && nodeClass.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
      },
      // 対象のノードをコード出力に必要な形に変換します
      transform: static (contextSyntax, cancelToken) =>
      {
        // いつでもキャンセルできるようにしておく
        cancelToken.ThrowIfCancellationRequested();

        // 今回は ClassDeclarationSyntax 確定なのでそのままキャストします
        ClassDeclarationSyntax classDecl = (ClassDeclarationSyntax)contextSyntax.Node;

        // データと構文から宣言されたシンボルを取得します
        // 今回利用場面がほとんどないですが取得しておくといろんな情報を取得できるので便利です
        var symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(contextSyntax.SemanticModel, classDecl, cancelToken)!;

        // 出力に必要な値を返します。大抵の型を渡すことができます
        return (symbol, classDecl);
      }
    );

    // 解析し変換した情報をもとにコードを出力します
    context.RegisterSourceOutput(syntaxProvider, static (contextSource, input) =>
    {
      // いつでもキャンセルできるようにしておく
      CancellationToken cancelToken = contextSource.CancellationToken;
      cancelToken.ThrowIfCancellationRequested();

      // 解析後に渡された値を受け取ります
      var (symbol, classDecl) = input;

      StringBuilder sbInitValues = new();

      // プロパティを列挙して値を初期化するコードを生成
      foreach (var syntax in classDecl.Members.OfType<PropertyDeclarationSyntax>())
      {
        sbInitValues.AppendLine($"{syntax.Identifier.ValueText} = default;");
      }

      // 出力するコードを作成
      var source = @$"
        {string.Join(" ", classDecl.Modifiers.Select(x => x.ToString()))} class {symbol.Name}
        {{
          public void Reset()
          {{
            {sbInitValues}
          }}
        }}";

      // ファイル名はクラス名に合わせておきます。作成したコードを出力します
      contextSource.AddSource($"{symbol.Name}.g.cs", source);
    });
  }
}

コメントに大体書いていますがところどころ解説しておきます。

// 構文を解析し処理対象のノードをコード出力用に変換します
var syntaxProvider = context.SyntaxProvider.CreateSyntaxProvider(
  // すべてのノードが処理対象となるため、predicate で処理対象とするノードを限定します
  predicate: ...,
  // 対象のノードをコード出力に必要な形に変換します
  transform: ...
);

context.SyntaxProvider.CreateSyntaxProvider を呼ぶことによりアプリケーションプロジェクトにあるコードを解析して可能な限り分割して構造化します。 あらゆるものがノードとして分割されるのでその中から処理対象とするものを predicate で判定します。 また処理対象となったものを必要であれば transform で変換して出力側に渡します。

今回 predicate では以下のような処理をしています。

// すべてのノードが処理対象となるため、predicate で処理対象とするノードを限定します
predicate: static (node, cancelToken) =>
{
  // 処理が中断される場面は多々あるのでいつでもキャンセルできるようにしておく
  cancelToken.ThrowIfCancellationRequested();

  // クラスのノードのみを対象とする (record とかつけると別な種類のノードになるので注意)
  // また partial class であること
  return node is ClassDeclarationSyntax nodeClass && nodeClass.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
},

まずどの処理でもそうですが、コードの自動生成処理は常に途中でキャンセル可能性があります。 なのでキャンセルトークンを使用して ThrowIfCancellationRequested を呼び出しいつでも中断できるようにしておきます。

predicate ではあらゆるノードが呼ばれるためどれが処理対象であるかどうかを判定します。 膨大な数になるためある程度ここで絞り込むのがよいでしょう。

今回クラスに対して処理を追加することにしているので ClassDeclarationSyntax かどうか判定してクラスのみ処理をするように判定します。 また partial class でコードをくっつけるので partial class であることを判定として入れています。

// 対象のノードをコード出力に必要な形に変換します
transform: static (contextSyntax, cancelToken) =>
{
  // いつでもキャンセルできるようにしておく
  cancelToken.ThrowIfCancellationRequested();

  // 今回は ClassDeclarationSyntax 確定なのでそのままキャストします
  ClassDeclarationSyntax classDecl = (ClassDeclarationSyntax)contextSyntax.Node;

  // データと構文から宣言されたシンボルを取得します
  // 今回利用場面がほとんどないですが取得しておくといろんな情報を取得できるので便利です
  var symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(contextSyntax.SemanticModel, classDecl, cancelToken)!;

  // 出力に必要な値を返します。大抵の型を渡すことができます
   return (symbol, classDecl);
}

transform では解析内容を必要な形に変換してコード出力に渡すことができます。 今回は大した変換はせずそのまま return して出力側に値を渡しています。 途中で「宣言されたシンボル」を取得していますが、SemanticModelSyntac を使って追加でいろいろな情報を取得できるので必要があれば取得してもいいと思います。 return でタプルを作って返していますが、出力側に渡すデータの構成はなんでも構いません。 複数のデータを渡したいときはこのようにタプルを作ってもいいですし独自のクラスを定義して渡しても構いません。

// 解析し変換した情報をもとにコードを出力します
context.RegisterSourceOutput(syntaxProvider, static (contextSource, input) =>
{
  // いつでもキャンセルできるようにしておく
  CancellationToken cancelToken = contextSource.CancellationToken;
  cancelToken.ThrowIfCancellationRequested();

  // 解析後に渡された値を受け取ります
  var (symbol, classDecl) = input;

  StringBuilder sbInitValues = new();

  // プロパティを列挙して値を初期化するコードを生成
  foreach (var syntax in classDecl.Members.OfType<PropertyDeclarationSyntax>())
  {
    sbInitValues.AppendLine($"{syntax.Identifier.ValueText} = default;");
  }

  // 出力するコードを作成
  var source = @$"
    {string.Join(" ", classDecl.Modifiers.Select(x => x.ToString()))} class {symbol.Name}
    {{
      public void Reset()
      {{
        {sbInitValues}
      }}
    }}";

  // ファイル名はクラス名に合わせておきます。作成したコードを出力します
  contextSource.AddSource($"{symbol.Name}.g.cs", source);
});

context.RegisterSourceOutput では context.SyntaxProvider.CreateSyntaxProvider で処理対象となったデータをもとにコードを生成して出力します。 context.SyntaxProvider.CreateSyntaxProviderreturn した値を第2引数の Action の第2引数で受け取れるので、その値をもとにコードを生成します。

今回はクラスの構文からプロパティの構文一覧を取得し、各々の名称からプロパティを default にセットするコードを作っています。

後はクラス名をもとに Reset メソッドを作成し、先ほど作成したプロパティ一覧のコードを埋め込めばすべてのプロパティの値を default にセットしなおす Reset メソッドの完成です。

コードは contextSource.AddSource メソッドでクラスごとに分けて出力しています。 ちなみにファイル名に「g」と入れているのはビルドエラーがあった時に手動で作成したコードなのか自動生成のコードのエラーなのか判別しやすくするためです。

実際作っていくと「フィールドもリセットしたい」「default 以外で初期化したい」「自動プロパティのみリセットしたい」などの要望がでると思いますが、 それらを入れるとコードが長くなってしまうのでそこは各々で作ってみてください。

アプリケーションプロジェクト側

今回クラスのメソッドを拡張するコードを作ったので適当にクラスを作ってみます。

中身は以下のようにしていますがプロパティがあれば後は適当で構いません。

Player.cs

public partial class Player
{
  public string Name { get; set; } = "";


  public float PositionX { get; set; }
  public float PositionY { get; set; }

  public int Level { get; set; } = 1;

  public override string ToString()
  {
    return $"{nameof(Player)} {{{nameof(Name)} = {Name}, {nameof(PositionX)} = {PositionX}, {nameof(PositionY)} = {PositionY}, {nameof(Level)} = {Level}}}";
  }
}

Item.cs

public partial class Item
{
  public string Name { get; set; } = "";

  public float PositionX { get; set; }
  public float PositionY { get; set; }

  public int Type { get; set; }

  public override string ToString()
  {
    return $"{nameof(Item)} {{{nameof(Name)} = {Name}, {nameof(PositionX)} = {PositionX}, {nameof(PositionY)} = {PositionY}, {nameof(Type)} = {Type}}}";
  }
}

コードを作った時点で Reset メソッドは拡張されていますが、Visual Studio 上では反映されていない場合もあるのでその時は Visual Studio を再起動してください。 アナライザーにコードが自動的に拡張されていることを確認できると思います。

インデントがおかしいですが自動生成されたコードを触ることは基本的にないので無視していいでしょう。

実際に Program.cs にコードを書いてみて Reset メソッドが呼べることを確認してみてください。 実行結果も期待した通りに反映されていると思います。

Program.cs

Console.WriteLine("Hello, World!");

SampleClass.Hello();
Console.WriteLine();

Player player = new()
{
  Name = "Mike",
  PositionX = 10,
  PositionY = 5.5f,
  Level = 3,
};
Console.WriteLine(player);
player.Reset();
Console.WriteLine(player);
Console.WriteLine();

Item item = new()
{
  Name = "Banana",
  PositionX = 50,
  PositionY = 53.5f,
  Type = 12,
};
Console.WriteLine(item);
item.Reset();
Console.WriteLine(item);
Console.WriteLine();