LoginSignup
3
2

[更新] Source Generator を使って懐かしの AOP を作ってみる

Last updated at Posted at 2023-09-18

Roslyn Source Generatorが様々のリポジトリで使われている。例えばdotnet-isolated-workerなどである。残念ながら私は触ったことが無くてコードが読めなかったので、簡単なサンプルアプリを書いて理解してみることにした。
 お題は、めっちゃ懐かしのAOP(Aspect Oriented Programming)のなんちゃって版だ。Attributeを定義すると、関数の実行時に前後にログを注入するというサンプルが書けるか試してみた。

Rosyn APIs とは?

Source Generator は Roslyn APIs(The .NET Compiler Platform SDK) の一部の機能だ。Roslyn APIs は.NETでコンパイル時に、ソースコードを評価して、アプリケーションのモデルを作る。このAPIはそのモデルを操作するためのものだ。具体的には下記のことができる。

  • コードを静的分析する
  • コードの問題をFixする
  • コードをリファクタリングする
  • コードを生成する

上記の機能を使って、VisualStudioは、コードのサジェスチョンや、リファクタリングを実装していると思われる。この機能により、コーディング規約をまもるための支援が出来たりする。

image.png

.NET ではコンパイル時に上記のようなパイプラインが実行される。それに対応するように、APIが提供されている。

  • Syntax Tree API: ソースコードをパースして作成されたモデルのツリーにアクセス
  • Symbol API: その結果作成されたシンボルにアクセスする
  • Binding and Flow Analysis APIs: シンボルをコードの識別子にバインドする
  • Emit API: アセンブリを出力する

どんなことができるかは次の Visual Studio ができることと比較してみるとわかりやすい。

image.png

Source Generator

今回は、Source Generatorに絞って調査をしたい。

単純に説明すると下記のステップを実行できる

  • Compilation object にアクセスして、コードの構造にアクセスしたり、分析する
  • ソースコードを生成して、それを含めてコンパイルを行いアセンブリを作成する

具体的にどんなことができるかというと

  • 通常リフレクションで実行するような処理を、コード生成で実施することができるのでパフォーマンスが向上する
  • MSBuildのタスクの実行順番を操作する
  • 動的なルーティングをコード生成によって静的なルーティングに変える(ASP.NETはこれを使っている)

私が見かけたユースケースは例えば下記のようなものだった

・Loggingの定義ファイルを作成するのが面倒だから、コード解析をしてそれをコードジェネレーションですべてのパターンを作成
・Attributeを参考にして、本来動的に実行するべきメソッドをコードジェネレートすることにより、パフォーマンスを向上させる

使い方

ソースジェネレータプロジェクトを作成する

下記のようなライブラリを参照する。ちなみに現在はnetstandard2.0でないと動作しないらしいので注意

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

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

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.7.0" PrivateAssets="all" />
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
	</ItemGroup>
</Project>

ジェネレータを作成する

ジェネレータはISourceGeneratorインターフェイスを実装して作成する。初期処理をするInitialize(GeneratorInitializationContext context)Execute(GeneratorExecutionContext context)を実装すればよい。それぞれのメソッドのパラメータのcontextオブジェクトを通じて、現在のコードのモデルへのアクセスと、コード生成が可能だ。context.Compilationオブジェクトによって、既存モデルにアクセスができるし、context.AddSourceメソッドにより、コード生成ができる。

 モデルを読んで、分析して、自分で好きにコード生成させてコンパイルさせることができる。下記の例では、自動でファクトリが作成される。

FactoryGenerator.cs
namespace SourceGenerator
{
    [Generator]
    public class FactoryGenerator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            // How to access CompilationObjet
            INamedTypeSymbol attributeSymbol = context.Compilation.GetTypeByMetadataName("AOPLib.FunctionAttribute");
            // Create Factory
            context.AddSource("FunctionFactory.g.cs", SourceText.From($@"
namespace AOPLib{{
  public class FunctionFactory {{
    public static IFunction CreateFunction(string name) {{
          switch(name) {{
            default:
              return new SampleFunction();
          }}
    }}
  }}
}}
", Encoding.UTF8));
        }

        public void Initialize(GeneratorInitializationContext context)
        {

        }

Caller は次のようなコードになる。我々はどこにも、FunctionFactoryクラスのコードを作っていないが、IDEにクラスが見当たらないと怒られることはない。

    public static void Main(string[] args)
    {
        var functionName = Console.ReadLine();
        IFunction function = AOPLib.FunctionFactory.CreateFunction(functionName);
        function.Execute(new FunctionContext() { Name = "hello", TraceId = Guid.NewGuid() });
    }

Callerつまり、本来のProgram.csが存在するプロジェクトのプロジェクト定義ファイルは次のようになる。OutputItemType=AnalyzerReferenceOutputAssembly="false"によって、SourceGeneratorプロジェクトのDLLを取り込まず、アナライザを実行することが設定されている。これらの詳細な定義はCommon MSBuild Project Itemsに記載されている。

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

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

  <ItemGroup>
    <ProjectReference Include="..\SourceGenerator\SourceGenerator.csproj"
					  OutputItemType="Analyzer"
					  ReferenceOutputAssembly="false"/>
  </ItemGroup>

</Project>

これが基本的なSource Generator の使い方だ。

Source Generator でデバッグをする

デバッグをする

普通に実行すると、SourceGeneratorのデバッグはできないのだがInitializeメソッドに下記のメソッドをコールすることでVisualStudioでデバッグ可能になる。

        public void Initialize(GeneratorInitializationContext context)
        {
            Debugger.Launch();
        }

生成されたソースを閲覧する

デフォルトでは、ジェネレータによって生成されたソースコードは見ることができない。下記の定義をProgram.csがある側のプロジェクトのcsprojファイルの‘PropertyGroup`に追加することによって、ファイルが生成される

csproj
	  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>

image.png

AOPサンプルの作成

上記の例は簡単に動作したが、もう少し複雑なサンプルを試しておく方が良いだろうと思って、懐かしのなんちゃってAOPの実装をしてみることにした。
 私の考えた仕様は次の通り

  • Function というAttributeを定義して、そこのLoggingのフラグがTrueの場合、メソッド実行時にログを自動的に出力する

つまり、

LoggingFunction.cs
namespace AOPSample
{
    [Function(isLogging: true)]
    public class LoggingFunction : IFunction
    {
        public void Execute(FunctionContext context)
        {
            Console.WriteLine($"[{nameof(LoggingFunction)}]: execute. Name: {context.Name}");
        }
    }
}

こんなプログラムを例えば書いたとすると、isLogging:trueなので、SourceGeneratorがこのファイルを自動で見つけて、ロギングのロジックを注入してくれるといった具合である。

具体的には次のようなアダプタを書いておくと簡単だろう。

LoggingAdapter.cs
    public class LoggingAdapter : IFunction
    {
        private readonly IFunction _function;
        public LoggingAdapter(IFunction function)
        {
            _function = function;
        }
        public void Execute(FunctionContext context)
        {
            Console.WriteLine($"[{DateTime.UtcNow}] {_function.GetType().Name}: before execute. TraceId: {context.TraceId}");
            _function.Execute(context);
            Console.WriteLine($"[{DateTime.UtcNow}] {_function.GetType().Name}: after execute. TraceId: {context.TraceId}");
        }
    }

そして、new LoggingAdapter(new ***Function()) といった具合でコードを生成してあげればよいだろう。

コード生成ロジック

FactoryGenerator.cs
namespace SourceGenerator
{
    [Generator]
    public class FactoryGenerator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            // FunctionAttribute のシンボルを取得する
            INamedTypeSymbol attributeSymbol = context.Compilation.GetTypeByMetadataName("AOPLib.FunctionAttribute");
            // Comilation.SyntaxTreesからクラスのみ取得している
            var targetClasses = context.Compilation.SyntaxTrees
                .SelectMany(st => st.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>());
            // 取得したクラスのうちFunctionAttributeを持っているクラスのみ抽出する
            var selectedTargetClasses = targetClasses.Where(p => p.AttributeLists.FirstOrDefault()?.Attributes.FirstOrDefault()?.Name.NormalizeWhitespace().ToFullString() == "Function");
            // 対象のクラスの情報をメタデータとしてディクショナリに保存する
            var registeredClasses = new Dictionary<string, FunctionMetadata>();
            // クラスで使われているAttributeの情報を確認している。
            // `isLogging`など、Functionのメタデータ情報をディクショナリに格納している
            foreach (var clazz in selectedTargetClasses)
            {
                var attribute = clazz.AttributeLists.First()?.Attributes.FirstOrDefault();
                var attributeArgument = attribute.ArgumentList.Arguments.FirstOrDefault();
                var isLogging = attributeArgument.Expression.NormalizeWhitespace().ToFullString().Contains("true");
                var typeSymbol = context.Compilation.GetSemanticModel(clazz.SyntaxTree).GetDeclaredSymbol(clazz);
                var className = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
                var classNameOnly = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
                registeredClasses.Add(classNameOnly, new FunctionMetadata { FullyQualifiedName = className, Name = classNameOnly, IsLogging = isLogging });
            }


            // コード生成のテンプレートを作成してコードを生成する
            context.AddSource("FunctionFactory.g.cs", SourceText.From($@"
namespace AOPLib{{
  public class FunctionFactory {{
    public static IFunction CreateFunction(string name) {{
          switch(name) {{
            {GetFunctionsSection(registeredClasses)}
            default:
              return new SampleFunction();
          }}
    }}
  }}
}}
", Encoding.UTF8));
        }

        public void Initialize(GeneratorInitializationContext context)
        {
            Debugger.Launch();
        }

        private string GetFunctionsSection(Dictionary<string, FunctionMetadata> metadata)
        {
            var sb = new StringBuilder();
            foreach (var item in metadata)
            {
                sb.AppendLine($"case \"{item.Key}\":");
                if (item.Value.IsLogging)
                {
                    sb.AppendLine($"return new LoggingAdapter(new {item.Value.FullyQualifiedName}());");
                }
                else
                {
                    sb.AppendLine($"return new {item.Value.FullyQualifiedName}();");
                }
            }
            return sb.ToString();
        }
    }

    class FunctionMetadata
    {
        public string Name { get; set; }
        public string FullyQualifiedName { get; set; }
        public bool IsLogging { get; set; }
    }
}

実行結果

本体側のコードで何も書いていないが、無事AOPもどきでログが自動で出力されるようになった。

image.png

isLogging:falseの場合はもちろんログは出ない。

image.png

IncrementalGenerators

さて、私が上記で使用したインターフェイスは公式に乗っている方法なのだが、実は古い方法のようだ。
ガチに世界レベルの方がフィードバックをくれた。ブログ書いたらそんな人にフィードバックもらえるの最高!

2022年のC# (Incremental) Source Generator開発手法と公式のIncremental Generatorsを読めば十分だろう。新しいインターフェイスを説明しよう。

IIncrementalGenerator

ISourceGeneratorと比べると、Initialize の実装だけになっている。基本的な構成はIncrementalValueProvider<T> (単数形) か IncrementalValuesProvider<T> (複数形)を使って、トランスフォーメーションを行い、context.RegisterSourceOutput() を使ってソースをジェネレートします。

 ポイントとしては、context.RegisterSourceOutput()は二つのオーバーロードがあります。

  • public void RegisterSourceOutput<TSource>(IncrementalValueProvider<TSource> source, Action<SourceProductionContext, TSource> action)
  • public void RegisterSourceOutput<TSource>(IncrementalValuesProvider<TSource> source, Action<SourceProductionContext, TSource> action)

一瞬どこがちゃうねん?と思うのですがよく見ると第一引数がIncrementalValueProviderか、IncrementalValuesProvider の違いです。後者は複数系になっています。つまり、複数形の方はエントリの数だけ、actionを実行しますが、単数形の方は一回きりです。

 ソースを生成する場合は、例えばAttributeが定義されているクラスを検索して、そのクラスに対して何らかのクラスを生成する。というようなケースでは複数形の方が便利でしょう。
 ところが、Attributeが定義されている複数のクラスを検索するけど、生成したいのはファクトリクラス1つといったユースケースの場合は、単数形の方が便利です。1つのファイルを生成するだけですので。

公式のサンプル
[Generator(LanguageNames.CSharp)]
public class Generator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext initContext)
    {
        // define the execution pipeline here via a series of transformations:

        // find all additional files that end with .txt
        IncrementalValuesProvider<AdditionalText> textFiles = initContext.AdditionalTextsProvider.Where(static file => file.Path.EndsWith(".txt"));

        // read their contents and save their name
        IncrementalValuesProvider<(string name, string content)> namesAndContents = textFiles.Select((text, cancellationToken) => (name: Path.GetFileNameWithoutExtension(text.Path), content: text.GetText(cancellationToken)!.ToString()));

        // generate a class that contains their values as const strings
        initContext.RegisterSourceOutput(namesAndContents, (spc, nameAndContent) =>
        {
            spc.AddSource($"ConstStrings.{nameAndContent.name}", $@"
    public static partial class ConstStrings
    {{
        public const string {nameAndContent.name} = ""{nameAndContent.content}"";
    }}");
        });
    }
}

AOP サンプルを移植する

折角新しい書き方を学んだので先ほどのAOPサンプルをIncremental Generatorsで書き換えてみましょう。
素晴らしい!めっちゃわかりやすくなりました!コード中に解説をいれておきます。流れは先ほど説明したとおりの流れで、IncrementalValue(s)Provider<T>をLinqっぽいインターフェイスで加工しながら、RegisterOutputに最終的な成果物を渡してファイルを生成します。ポイントはLinqっぽいということに気づくことです。Linqではありません。ちゃんとIncremental Generatorsを読めばわかりますが、Linqだと思うと、IEnumerableを期待すると思うのですが、この仕組みはコレクションを扱うものではないので、単数も返せます。それがIncrementalValueProvider<T> でそうでない複数のものがIncrementalValuesProvider<T>です。

詳細は子度にコメントを入れておきます。

namespace SourceGenerator
{
    [Generator(LanguageNames.CSharp)]
    public class FactoryGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            // デバッグしたい人はこちらを有効化する。
            // Debugger.Launch();
            // ExtensionMethod の ForAttributeWithMetadataName()最高!
            // ずばり、該当Attributeが付いたクラスをすべて返却してくれます。
            var source = context.SyntaxProvider.ForAttributeWithMetadataName(
                "AOPLib.FunctionAttribute",
                (node, token) => true,
                (ctx, token) => ctx
                )
                // Linq と似た感じで書けます。ここでは、クラスを分析するのと、
                // クラスについたAttributeの設定値を取得してFunctionMetadataに格納
                .Select((s, token) => { 
                    var typeSymbol = (INamedTypeSymbol)s.TargetSymbol;
                    var fullType = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
                    var name = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
                    var isLogging = (bool)(s.Attributes.FirstOrDefault()?.ConstructorArguments.FirstOrDefault().Value ?? false);
                    return new FunctionMetadata { FullyQualifiedName = fullType, Name = name, IsLogging = isLogging };
            // 最後にCollect() を実施することで、IncrementalValueProviderに変換しています
            // 先ほどまでの型はIncrementalValuesProvider<FunctionMetadata>だったのですが
            // IncrementalValueProvider<ImmutableArray<FunctionMetadata>>に変換。複数形から単数形
            // に変換して、Actionが一回のみ呼ばれるようにしています。
            }).Collect();

            context.RegisterSourceOutput(source, Emit);
        }

        // 単数系に変換したのでこのメソッドは1回のみコールされます。`Collect`が無いと複数回よばれます。
        private void Emit(SourceProductionContext context, ImmutableArray<FunctionMetadata> source)
        {
            // Create Factory
            context.AddSource("FunctionFactory.g.cs", SourceText.From($@"
namespace AOPLib{{
  public class FunctionFactory {{
    public static IFunction CreateFunction(string name) {{
          switch(name) {{
            {GetFunctionsSection(source)}
            default:
              return new SampleFunction();
          }}
    }}
  }}
}}
", Encoding.UTF8));
        }

        private string GetFunctionsSection(ImmutableArray<FunctionMetadata> metadata)
        {
            var sb = new StringBuilder();
            foreach (var item in metadata)
            {
                sb.AppendLine($"case \"{item.Name}\":");
                if (item.IsLogging)
                {
                    sb.AppendLine($"return new LoggingAdapter(new {item.FullyQualifiedName}());");
                }
                else
                {
                    sb.AppendLine($"return new {item.FullyQualifiedName}();");
                }
            }
            return sb.ToString();
        }
    }

    class FunctionMetadata
    {
        public string Name { get; set; }
        public string FullyQualifiedName { get; set; }
        public bool IsLogging { get; set; }
    }
}

前回に比べると相当すっきりかけました。ちなみに、IIncrementalGeneratorがある時とない時を551のように比較してみましょう。

まとめ

このジェネレータはリフレクションで本来実施するようなものをコード生成でStaticに実行したり、コードの構造を分析したりするので、とても有用で面白い機能だ。ただし、ちょっとつかっただけだけど、おそらく、生成したコードのバグには気づきにくいと思う。多分ユニットテストなどを書いてカバーしてあげる必要性がありそうだ。

ただ、わたしもまだ触りたてなので、あまりまだ理解しているとはいいがたいので、また継続して調べたりサンプルを書いたりしてみよう。

尚今回のサンプルはこちらに置いておいた。

Resources

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2