BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル C#のソースジェネレータを開発する

C#のソースジェネレータを開発する

ブックマーク

キーポイント

  • Source generators are a good way to reduce the amount of repetitive code that needs to be written.
  • Plan your source generator by first deciding how consuming projects will use it. 
  • Do not use a source generator if the input is something unreliable such as a database.
  • Learning the Roslyn syntax tree isn’t required, but it will give you more options.
     

原文(投稿日:2021/05/27)へのリンク

今回の記事では、C#のソースジェネレータを取り上げます。その過程で、自分自身で開発する上で必要となるであろう、いくつかの重要なテクノロジや、開発過程で出会うかも知れない落とし穴についてご紹介したいと思います。

新たにソースジェネレータの開発を始めるにあたって、最初に持つ疑問は、当然ながら"何を実現したいのか?"ということです。

直後に続くのが"ソースジェネレータとして実用的か?"という疑問です。ソースジェネレータはコードがコンパイルされる時に毎回実行されますから、この疑問は重要です。

コードの生成にデータベースなど外部ソースからのデータを必要とするのであっては、ソースジェネレータとして適切ではないでしょう。

今回のウォークスルーでは、Tortuga Test Monkeyのソースコードを使用します。このコードはGitHubにおいて、MITライセンスの下で公開されています。

プロジェクトの目標

ユニットテストを書く場合、カバレッジのために必要なテストの多くは"価値の低い"ものです。それらが実際にエラーを検出する確率は、百分の一か、あるいはそれ以下でしょう。

プロパティのゲッタとセッタが意図通り動作することを確認するテストを、例として挙げましょう。

[TestMethod]
public void @FirstName_SelfAssign()
{
    var objectUnderTest = CreateObject();
    var originalValue = objectUnderTest.@FirstName;
    objectUnderTest.FirstName = originalValue;
    Assert.AreEqual(originalValue, objectUnderTest.@FirstName, "Assigning a property to itself should not change its value.");
}
[TestMethod]
public void @FirstName_DoubleRead()
{
    var objectUnderTest = CreateObject();
    var readOnce = objectUnderTest.@FirstName;
    var readTwice = objectUnderTest.@FirstName;
    Assert.AreEqual(readOnce, readTwice, "Reading a property twice should return the same value both times.");
}

こんなテストを書きたいと思う人はいないでしょう。面倒ですし、バグを検出できる可能性がほとんどないからです。しかし、"ほとんどない"と"絶対にない"は違いますから、テストは必要です。このような価値の低いテストを作るために、コードジェネレータを使えないでしょうか?これらを単なる属性で済ませることができれば、カバレッジを達成すると同時に、もっと難しいテストに集中できるように開発者を解放することが可能になるはずです。

[TestClass]
[MakeTests(typeof(SimpleClass), TestTypes.All)]
public partial class Test_SimpleClass

今回のウォークスルーでは、このようなテストジェネレータの開発方法を探りたいと思います。

プロジェクトの構造

今回の開発では少なくとも2つのプロジェクトが必要になります。ひとつはソースジェネレータ自体であり、もうひとつはテストを行う対象です。ソースジェネレータでは、プロジェクトファイルに以下の追加を行う必要があります。

まず、ターゲットフレームワークを.NET Standard 2.0に設定しなくてはなりません。これはC#コンパイラの要件であるため、必須です。さらに、すべての依存関係についても、同じように.NET Standard 2.0あるいはそれ以前用でなければなりません。

<TargetFramework>netstandard2.0</TargetFramework>

次に、NuGetからCodeAnalysisライブラリを追加します。

<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.9.0" PrivateAssets="all" />
  <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2" PrivateAssets="all" />
</ItemGroup>

さらに、これが"アナライザ"であることを示した方がよいでしょう。このステップを省略すると、ソースジェネレータをNuGetパッケージとしてデプロイした時に動作しなくなります。

<ItemGroup>
  <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

コンシューマプロジェクトの設定

次に、ソースジェネレータをコンシュームするプロジェクトが、ソースジェネレータをランタイムで使用するのか、コンパイル時のみなのかを決める必要があります。コンパイル時のみに使用する場合、コンシューマプロジェクトにソースジェネレータのクラスは不要なので、次の定義を追加することができます。

<PropertyGroup>
  <IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>

今回は、Tortuga.TestMonkey.dllの中にコンシューマプロジェクトが必要とするpublicクラス、具体的には属性とマッチングenumが含まれているので、上記の設定は使用しません。

GitHubにあるTortuga.TestMonkey.csprojファイルを見ると、"NuGet Packaging Boilerplate"というセクションがあることに気付くでしょう。ソースジェネレータがこれを操作することはありませんが、これがあれば、NuGetパッケージの公開が少し楽になります。

一方でコンシューマ側はソースジェネレータを参照する必要があるのですが、これはプロジェクトあるいはパッケージ参照として定義することができます。

<ItemGroup Condition="'$(Configuration)'=='Debug'">
  <ProjectReference Include="..\Tortuga.TestMonkey\Tortuga.TestMonkey.csproj" OutputItemType="Analyzer" />
</ItemGroup>
 
<ItemGroup Condition="'$(Configuration)'=='Release'">
  <PackageReference Include="Tortuga.TestMonkey" Version="0.2.0" />
</ItemGroup>

上の例は両方のオプションを示しています。これによって、ソースジェネレータの開発中にプロジェクト参照を利用するという利便性を確保しながら、リリースモードでNuGetパッケージをテストすることが可能になるのです。

コードジェネレータの結果を確認するためには、EmitCompilerGeneratedFilesをオンにします。

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

<ItemGroup>
  <!-- Don't include the output from a previous source generator execution into future runs; the */** trick here ensures that there's
  at least one subdirectory, which is our key that it's coming from a source generator as opposed to something that is coming from
  some other tool. -->
  <Compile Remove="$(CompilerGeneratedFilesOutputPath)/*/**/*.cs" />
</ItemGroup>

生成されたコードをチェックインしたくない場合は、次の行を.gitignoreファイルに追加してください。

**/Generated/Tortuga.TestMonkey/*

今回のウォークスルーの目的では、コンシューマプロジェクトとして動作するユニットテストプロジェクトを作成する必要があります。テスト対象のプロジェクトも必要でしょう。読者の便宜のため、GitHubのソースコード例の中にこれらを用意しました。

ソースジェネレータ

ソースジェネレータはISourceGenerator属性とISourceGeneratorインターフェースを使って定義されています。これらはいずれもMicrosoft.CodeAnalysisネームスペースにあります。

Initializeメソッドを使用して、"syntax notificationと"post-initialization"という2つのイベントを登録します。これらはEventHandlerスタイルのイベントではないので、それぞれがひとつのイベントを処理することになります。今回のウォークスルーではRegisterForSyntaxNotificationsのみを使用しますが、RegisterForPostInitializationのユースケースを記事の後半で説明しています。

"syntax notification"イベントにはISyntaxContextReceiverが必要です。コンパイラがソースコードから構文木を作り終えると、これが呼び出されます。ソースジェネレータでソースコードを解析する必要がなければ、このステップは省略しても構いません。

実際にソースコードを生成するには、Executeメソッドを使用します。構文レシーバ(syntax receiver)を使用していれば、その後でこれが実行されます。これについては後ほど取り上げますが、差し当たり必要なのはこの行のみです。

// retrieve the populated receiver
if (!(context.SyntaxContextReceiver is SyntaxReceiver receiver))
    return;

receiver変数は、Initializeメソッドでセットアップしたものと同じオブジェクトです。

構文レシーバ

構文レシーバはISyntaxContextReceiverインターフェースを実装する必要があります。このインターフェースはただひとつのメソッドOnVisitSyntaxNodeを持っていて、"コンパイル処理の各構文ノード毎に呼び出され"ます。

Microsoftの提供するサンプルの多くは、構文レシーバをソースジェネレータクラス内のプライベートクラスとして実装していていますが、今回の構文レシーバは比較的規模が大きいので、それとは違う方法を採用して、独立したファイルとして扱いやすくしています。

私たちの構文レシーバには2つのプロパティがあります。

public List<string> Log { get; } = new();
public List<WorkItems> WorkItems { get; } = new();

"WorkItems"は、コード生成に必要なもののリストに過ぎません。基本的には、ソースジェネレータがExecuteメソッドで使用する"to-do"リストです。

"Log"については、もう少し説明が必要です。これが必要なのは、デバッガをコンパイラ自体にアタッチすることができないからです。ソースジェネレータはコンパイラ内で動作するので、実際に何が起きているのかを知るよい方法がありません。そこで、昔気質のプログラマがConsole.WrireLineを使うように、可能なものはすべてテキストファイルにダンプすることにします。

これをセットアップするには、以下のコードを構文レシーバのOnVisitSyntaxNodeメソッド内に置いてください。

try
{
    if (context.Node is ClassDeclarationSyntax classDeclarationSyntax)
    {
        Log.Add($"Found a class named {testClass.Name}");
    }
}
catch (Exception ex)
{
    Log.Add("Error parsing syntax: " + ex.ToString());
}

このログデータを見るためには、ソースジェネレータのExecuteメソッド内でキャプチャする必要があります。

//Write the log entries
context.AddSource("Logs", SourceText.From($@"/*{ Environment.NewLine + string.Join(Environment.NewLine, receiver.Log) + Environment.NewLine}*/", Encoding.UTF8));

コンシュームプロジェクト内でEmitCompilerGeneratedFilesを有効にすれば、次のような内容の"Generated\Tortuga.TestMonkey\Tortuga.TestMonkey.TestGenerator\Logs.cs"というファイルが作られるはずです。

/*
Found a class named Test_SimpleClass
Found a class named Test_AnotherClass
Found a class named Test_NoDefaultConstructor
Found a class named Test_SimplePair
Found a class named AutoGeneratedProgram
*/

クラスを探す

"context.Node is ClassDeclarationSyntax classDeclarationSyntax"という行は、クラス宣言を表す構文ノードを探していることを示します。それ以外のノードはスキップされます。

それ以外のものを探したい場合は、Visual StudioのRoslyn Syntax Visualizerを使って、探す構文ノードの種類を決めることができます。このツールのインストールおよび使用の方法は、Microsoft Docsに説明されています。

今回のコードジェネレータでは、実際に構文ノードを気にすることはあまりありません。私たちの目標を達成するには、情報が不足しているからです。次のステップでは、構文ノードを"意味ノード(semantic node)"に変換します。クラスとしては、次のようなものになります。

var testClass = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(context.Node)!;

今回の例では、testClassがMakeTests属性を持ったテストクラスかどうかをチェックすることにします。他のクラスがユニットテストプロジェクトにあれば、それらもキャプチャすることになります。

実際に何を獲得したのかは、ログファイルを使って確認することにします。

var attributes = testClass.GetAttributes();
Log.Add($"    Found {attributes.Length} attributes");
foreach (AttributeData att in attributes)
{
    Log.Add($"   Attribute: {att.AttributeClass!.Name} Full Name: {att.AttributeClass.FullNamespace()}");
    foreach (var arg in att.ConstructorArguments)
    {
        Log.Add($"    ....Argument: Type='{arg.Type}' Value_Type='{arg.Value?.GetType().FullName}' Value='{arg.Value}'");
    }
}

以下は、更新されたログファイルからの抜粋です。

Found a class named Test_SimpleClass
    Found 2 attributes
   Attribute: TestClassAttribute Full Name: Microsoft.VisualStudio.TestTools.UnitTesting
   Attribute: MakeTestsAttribute Full Name: Tortuga.TestMonkey
    ....Argument: Type='System.Type' Value_Type='Microsoft.CodeAnalysis.CSharp.Symbols.PublicModel.NonErrorNamedTypeSymbol' Value='Sample.UnderTest.SimpleClass'
    ........Found a INamedTypeSymbol named 'Sample.UnderTest.SimpleClass'
    ....Argument: Type='Tortuga.TestMonkey.TestTypes' Value_Type='System.Int32' Value='-1'

クラスの属性の他に、それら属性のコンストラクタパラメータがあることが分かります。その他に読み込みの必要なプロパティがあれば、att.NamedArgumentsを使って問い合わせることも可能です。

属性のひとつは、テスト対象のクラスを参照しています。この情報は今後、どのようなテストが必要かを示す列挙とともに使用されることになります。

var makeTestAttribte = testClass.GetAttributes().FirstOrDefault(att => att.AttributeClass.FullName() == "Tortuga.TestMonkey.MakeTestsAttribute");
if (makeTestAttribte != null)
{
    var classUnderTest = (INamedTypeSymbol?)makeTestAttribte.ConstructorArguments[0].Value;
    var desiredTests = (TestTypes)(int)(makeTestAttribte.ConstructorArguments[1].Value ??0);
    if (classUnderTest != null && desiredTests != TestTypes.None && testFramework != TestFramework.Unknown)
    {
        WorkItems.Add(new(testClass, classUnderTest, desiredTests));
        Log.Add($"Added work item for {classUnderTest.FullName()}!");
    }
}

次に進む前に、いくつかの拡張メソッドを呼び出しておく必要があります。メソッドFullName()は、ファイルSemanticHelper.csに定義されています。このファイルには他にも、意味解析木(semantic tree)の操作を支援する関数が含まれています。

この後、さらに情報を得るために構文レシーバに戻ることになりますが、差し当たってコード生成を始めるにはこれで十分です。

ソースコードの生成

次のこのステップでは、ソースジェネレータのExecuteメソッドにもう一度戻ります。まず最初に、構文レシーバによって集められたワークアイテムをベースとしたループをセットアップします。

foreach (var workItem in receiver.WorkItems)
{
    var fileName = workItem.TestClass.FullName() + ".cs";
    var code = new CodeWriter();
    //populate code here
    context.AddSource(fileName, SourceText.From(code.ToString(), Encoding.UTF8));
}

CodeWriterStringBuilderの簡易なラッパで、インデントなどの処理を行います。これを省略してStringBuilderを直接使用しても構いませんが、メソッド定義などの反復的なコードを置く場所として便利です。

最初に加えなくてはいけないのはテストフレームワークのための"using"文で、その後にネームスペースとクラス宣言が続きます。

code.AppendLine("//This file was generated by Tortuga Test Monkey");
code.AppendLine();
code.AddTestFramework();
code.AppendLine();
using (code.BeginScope($"namespace {workItem.TestClass.FullNamespace()}"))
{
    using (code.BeginScope($"partial class {workItem.TestClass.Name}"))

BeginScopeはインデントレベルのアップを同時に行うAppendLineです。FullNamespaceSemanticHelperクラスのもうひとつの拡張メソッドです。クラス名には、MakeTests属性が保持しているクラスと同じものを使用します。

プロパティの列挙

今回は、テストのタイプ毎に別々の関数を作ることにします。それぞれの関数では、最初にテストのタイプが期待したものであることを確認した上で、プロパティを列挙し、適切なコードをStringBuilderに出力します。  

static void PropertySelfAssign(WorkItems workItem, CodeWriter code)
{
    if (workItem.TestTypes.HasFlag(TestTypes.PropertySelfAssign))
    {
        code.AppendLine();
        code.AppendLine("//Property Self-assignment Tests");

        foreach (var property in workItem.ClassUnderTest.ReadWriteScalarProperties())
        {
            using (code.StartTest($"{property.Name}_SelfAssign"))
            {
                code.AppendLine("var objectUnderTest = CreateObject();");
                code.AppendLine($"var originalValue = objectUnderTest.@{property.Name};");
                code.AppendLine($"objectUnderTest.{property.Name} = originalValue;");
                code.AssertAreEqual("originalValue", $"objectUnderTest.@{property.Name}", "Assigning a property to itself should not change its value.");
            }
        }
    }
}

拡張メソッドReadWriteScalarPropertiesは次のように定義されています。

public static IEnumerable<IPropertySymbol> ReadWriteScalarProperties(this INamedTypeSymbol symbol)
{
    return symbol.GetMembers().OfType<IPropertySymbol>().Where(p => (p.GetMethod != null) && (p.SetMethod != null) && !p.Parameters.Any());
}

ここで、プロパティがpublicかどうかをチェックしていない点に注目してください。この情報は利用できませんし、必要でもありません。アクセスできないprivateないしinternalなプロパティは、コンパイラによって意味解析木から自動的に取り除かれているからです。

Partialメソッドの処理

上のコードにはCreateObjectという関数があります。これが必要なのは、コードジェネレータがテスト対象として適切なオブジェクトを常に生成できるとは限らないからです。デフォルトコンストラクタで生成したオブジェクトでは不適切かも知れませんし、デフォルトコンストラクタを持たない場合も考えられます。この問題を解決するためにソースジェネレータでは、Partialメソッドと、対応するドライバ関数を公開しています。

partial void CreateObject(ref Sample.UnderTest.SimpleClass?objectUnderTest);
Sample.UnderTest.SimpleClass CreateObject()
{
    Sample.UnderTest.SimpleClass?result = null;
    CreateObject(ref result);
    if (result != null)
        return result;
    return new Sample.UnderTest.SimpleClass();
}

呼び出し側がCreateObject(ref T objectUnderTest)をオーバーライドしなければ、デフォルトコンストラクタが使用されます。

しかし、デフォルトコンストラクタがない場合はどうなるのでしょうか?その場合には、SemanticHelperHasDefaulrConstructor拡張メソッドを使用します。

public static bool HasDefaultConstructor(this INamedTypeSymbol symbol)
{
    return symbol.Constructors.Any(c => c.Parameters.Count() == 0);
}

プロパティと同じように、不可視のコンストラクタは意味解析木から除外されています。

次に、少し違うヘルパ関数を用意します。この関数は例外をスローして、テストをフェールさせます。

Sample.UnderTest.NoDefaultConstructor CreateObject()
{
    Sample.UnderTest.NoDefaultConstructor?result = null;
    CreateObject(ref result);
    if (result != null)
        return result;

    throw new System.NotImplementedException("Please implement the method 'partial void CreateObject(ref Sample.UnderTest.NoDefaultConstructor?objectUnderTest)'.");
}

処理方法はこれだけではありません。例外をスローする代わりに、Required Partialメソッドを使用することも可能です。Required Partialメソッドは、実装されていなければコンパイラがエラー終了する、というものです。

PartialメソッドをRequiredにするためには、privateなどのアクセス修飾子を含める必要があります。例えば、

private partial Sample.UnderTest.NoDefaultConstructor CreateObject();

Optional Partialメソッドとは違い、Required Partialメソッドは値を返すことができるため、refパラメータを使う必要はありません。

依存性の検出

次に解決すべき問題は、ユニットテストフレームワークに関するものです。今回はTortuga Test Monkeyの対象をMSTest、NUnit、XUnitという主要な3つのテストフレームワークに限定しているのですが、それぞれに対して異なるコードを生成する必要は残ります。

これを解決するために、一旦構文レシーバに戻って、テストプロジェクトが参照するアセンブリのリストを確認できるようにします。このリストは、意味解析木内のノードのContainingModuleから取得できます。

var testFramework = TestFramework.Unknown;
foreach (var assembly in testClass.ContainingModule.ReferencedAssemblies)
{
    if (assembly.Name == "Microsoft.VisualStudio.TestPlatform.TestFramework")
        testFramework = TestFramework.MSTest;
    else if (assembly.Name == "nunit.framework")
        testFramework = TestFramework.NUnit;
    else if (assembly.Name == "xunit.core")
        testFramework = TestFramework.XUnit;
}

この後は、CodeWriter内の単純なswitchブロックが残りの処理を行います。

public void AddTestFramework()
{
    switch (TestFramework)
    {
        case TestFramework.MSTest:
            AppendLine("using Microsoft.VisualStudio.TestTools.UnitTesting;");
            break;
        case TestFramework.XUnit:
            AppendLine("using Xunit;");
            break;
        case TestFramework.NUnit:
            AppendLine("using NUnit.Framework;");
            break;
    }
}

ソースジェネレータDLLを切り離すには

Tortuga.TestMonkeyでは、ユニットテストプロジェクトが直接Tortuga.TestMonkeyを参照できるようにしました。これが可能なのは、ユニットテストプロジェクトはデプロイ対象ではないので、余分な依存関係を持っていても大きな問題にはならないからです。

ですが、あなたが開発するソースジェネレータでは、そうではないかも知れません。幸運にも、別の選択肢があります。コンシュームプロジェクトがソースジェネレータのクラスを参照するのではなく、インジェクトすればよいのです。これを行うのが、Initializeメソッド内のRegisterForPostInitializationイベントです。例えば、

context.RegisterForPostInitialization(context =>
{
    context.AddSource("TestTypes",
        @"using System;
        namespace Tortuga.TestMonkey
        {
            [Flags]
            internal enum TestTypes
            {
                /// <summary>
                /// Do not generate any tests for this class.
                /// </summary>
                None = 0,

                /// <summary>
                /// Read a property and assign it to itself, verifying that it hasn't changed.
                /// </summary>
                PropertySelfAssign = 1,

                /// <summary>
                /// Read the same property twice, expecting the same result both times.
                /// </summary>
                PropertyDoubleRead = 2,

                All = -1
            }
        }");
});

このメソッドを使用する場合は、プロジェクト設定のIncludeBuildOutputをfalseにセットすることを忘れないでください。

このアプローチには、注意すべきいくつかの制限があります。まず、ヘルパクラスをinternalにしなければなりません。そうしなければ、複数のライブラリが同じソースジェネレータを使用すると、名称の衝突が発生する可能性があります。

見て分かるように、コードは単なる長大な文字列です。そのため、クラスを記述する時にコンパイラのサポートは一切受けられなくなります。文字列にペーストする前に、別のスクラッチプロジェクト内でコードを開発して、正しくコンパイルできることを確認しておいた方がよいでしょう。

もうひとつの問題は、どちらかといえばIDEに関するものです。Visual Studioが、これらのヘルパクラスを確認できない場合があるのです。そのような場合には、プロジェクトが間違いなくビルド可能な状態でも、IDEのエディタ内にコンパイラエラーが表示されるようになります。

これら"開発者エクスペリエンス"の問題から、私自身は、可能な限りソースジェネレータを直接参照するようにしています。

著者について

Jonathan Allen氏は90年代後半に診療所用のMISプロジェクトに参画し、その後AccessとExcelからエンタープライズソリューションへと段階的に仕事の範囲を広げてきました。金融セクタ向けの自動トレーディングシステムの開発に5年間携わった後、コンサルタントになり、ロボット倉庫のUIやがん研究ソフトウェアのミドル層、大手不動産保険企業のビッグデータのニーズなど、さまざまなプロジェクトのコンサルティングを行ってきました。余暇には、16世紀の武道に関する研究や執筆を楽しんでいます。 

 

この記事に星をつける

おすすめ度
スタイル

BT