DEV Community

Tsuyoshi Ushio
Tsuyoshi Ushio

Posted on

[Update] Creating Nostalgic AOP Using Source Generator

Introduction to Roslyn Source Generator

Roslyn Source Generator is used in various repositories, such as dotnet-isolated-worker. Unfortunately, I have never worked with it and couldn't understand the code. So, I decided to write a simple sample application to try and understand it.

The challenge is to create a mock version of AOP (Aspect Oriented Programming). I wanted to see if I could write a sample that injects logs before and after the execution of a function based on defined attributes.

What are Roslyn APIs?

Source Generator is part of the Roslyn APIs (The .NET Compiler Platform SDK). The Roslyn APIs allow the evaluation of source code during compilation to create a model of the application. These APIs are used to manipulate that model. Specifically, the following can be done:

  • Static code analysis
  • Code fixes
  • Code refactoring
  • Code generation

By using these features, Visual Studio can provide code suggestions and assist with code refactoring. This feature enables support for maintaining coding conventions.

image.png

.NET executes a pipeline similar to the one shown above during compilation. APIs are provided to support this pipeline:

  • Syntax Tree API: Access the tree model created by parsing the source code.
  • Symbol API: Access the symbols created as a result of parsing the code.
  • Binding and Flow Analysis APIs: Bind symbols to identifiers in the code.
  • Emit API: Output assemblies.

Comparing what Visual Studio can do with Roslyn APIs makes it easier to understand the capabilities.

image.png

Source Generator

For this investigation, I want to focus on Source Generator.

It allows you to perform the following steps:

  • Access the Compilation object to access and analyze the structure of the code.
  • Generate source code and compile it to create an assembly.

Specifically, you can:

  • Improve performance by performing tasks that are typically done with reflection using code generation.
  • Manipulate the order of execution of tasks in MSBuild.
  • Convert dynamic routing into static routing using code generation (used by ASP.NET).

Some use cases I have come across are:

  • Creating a definition file for logging is cumbersome, so perform code analysis and generate patterns for all of them.
  • Improve performance by generating code for methods that should be executed dynamically, referring to attributes.

How to use it

Create a source generator project

Reference the following libraries. Note that it seems to work only with netstandard2.0 currently.

<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>
Enter fullscreen mode Exit fullscreen mode

Create a generator

To create a generator, implement the ISourceGenerator interface. You need to implement the Initialize(GeneratorInitializationContext context) and Execute(GeneratorExecutionContext context) methods. You can access the current code model and perform code generation through the context objects in each method. You can access the existing model using the context.Compilation object, and perform code generation using the context.AddSource method.

You can read, analyze the model, generate code according to your preferences, and compile it. In the following example, a factory is automatically created.

namespace SourceGenerator
{
    [Generator]
    public class FactoryGenerator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            // How to access the CompilationObject
            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)
        {

        }
Enter fullscreen mode Exit fullscreen mode

The caller would look like the following. We haven't created the code for the FunctionFactory class anywhere, but there will be no complaints from the IDE about the missing class.

    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() });
    }
Enter fullscreen mode Exit fullscreen mode

In other words, the project containing the original Program.cs file, which acts as the caller, would have the following project definition. By setting OutputItemType=Analyzer and ReferenceOutputAssembly="false", the SourceGenerator project's DLL is not imported, and the analyzer is executed. You can find detailed definitions in Common MSBuild Project Items.

<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>
Enter fullscreen mode Exit fullscreen mode

This is the basic usage of Source Generator.

Debugging with Source Generator

Debugging

By default, it is not possible to debug Source Generators directly. However, you can enable debugging in Visual Studio by calling the following method in the Initialize method:

public void Initialize(GeneratorInitializationContext context)
{
    Debugger.Launch();
}
Enter fullscreen mode Exit fullscreen mode

Viewing Generated Source

By default, you cannot view the source code generated by the generator. However, you can enable it by adding the following definition to the csproj file of the project where the Program.cs is located. Add it to the PropertyGroup section:

  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
Enter fullscreen mode Exit fullscreen mode

image.png

Creating an AOP Sample

The previous example was a simple one, but it would be better to try a slightly more complex sample. So let's implement a fake AOP (Aspect-Oriented Programming) by defining an attribute called Function. If the Logging flag in the attribute is set to true, it automatically outputs logs when the method is executed.

For example, if we write a program like this:

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

Since the isLogging attribute is set to true, the Source Generator will automatically find this file and inject the logging logic.

To make it easier, let's write an adapter like this:

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}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, you can generate the code by creating an instance of LoggingAdapter and passing it to new ***Function().

Code Generation Logic

namespace SourceGenerator
{
    [Generator]
    public class FactoryGenerator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            // Get the symbol for the FunctionAttribute
            INamedTypeSymbol attributeSymbol = context.Compilation.GetTypeByMetadataName("AOPLib.FunctionAttribute");
            // Get all classes from Compilation.SyntaxTrees
            var targetClasses = context.Compilation.SyntaxTrees
                .SelectMany(st => st.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>());
            // Extract only the classes that have the FunctionAttribute
            var selectedTargetClasses = targetClasses.Where(p => p.AttributeLists.FirstOrDefault()?.Attributes.FirstOrDefault()?.Name.NormalizeWhitespace().ToFullString() == "Function");
            // Save information about the selected classes as metadata in a dictionary
            var registeredClasses = new Dictionary<string, FunctionMetadata>();
            // Check the information of the Attribute used in the class.
            // Store the metadata information of the Function in the dictionary, such as `isLogging`.
            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 });
            }

            // Create the code generation template and generate the code
            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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

Execution Result

Although no code was written in the main program, logs are automatically outputted as expected with the fake AOP.

image.png

If isLogging is set to false, no logs will be outputted.

image.png

IncrementalGenerators

Now, the interface I used in the above example is an official method, but it seems to be an older method. I received feedback from someone with worldwide expertise. It's amazing to receive feedback from such people when I write a blog!

https://x.com/neuecc/status/1703657375394341330?s=20

You should probably read the article C# in 2022 (Incremental) Source Generator Development Method and the official documentation on Incremental Generators. Let's explain the new interface.

IIncrementalGenerator

Compared to ISourceGenerator, it only has

Porting the AOP Sample

Now that we have learned the new approach, let's rewrite the AOP sample with Incremental Generators.
Great! It has become much easier to understand! I will add explanations within the code. The process follows the same flow as explained earlier: we process the IncrementalValue(s)Provider<T> using a Linq-like interface, manipulate it, and pass the final result to RegisterOutput to generate the files. The key point is to realize that it may look like Linq, but it's not actually Linq. By correctly reading about Incremental Generators, you will understand that it's not about handling collections, so it can also return a single value. That's where IncrementalValueProvider<T> comes in for singular values, and IncrementalValuesProvider<T> for multiple values.

I will add comments as needed throughout the code.

namespace SourceGenerator
{
    [Generator(LanguageNames.CSharp)]
    public class FactoryGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            // Uncomment this line if you want to debug.
            // Debugger.Launch();
            // ExtensionMethod ForAttributeWithMetadataName() is awesome!
            // It returns all classes with the specified attribute.
            var source = context.SyntaxProvider.ForAttributeWithMetadataName(
                "AOPLib.FunctionAttribute",
                (node, token) => true,
                (ctx, token) => ctx
                )
                // It can be written in a similar way to Linq. Here we analyze the classes and 
                // retrieve the values from the attributes attached to the classes and store 
                // them in 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 };
            // Finally, we perform Collect() to convert it to IncrementalValueProvider.
            // It was IncrementalValuesProvider<FunctionMetadata> before, but now it
            // has been converted to IncrementalValueProvider<ImmutableArray<FunctionMetadata>>.
            // We convert it from plural to singular to ensure the Action is only called once.
            }).Collect();

            context.RegisterSourceOutput(source, Emit);
        }

        // Since we have converted it to a singular form, this method will be called only once.
        // Without Collect(), it would be called multiple times.
        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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

Compared to the previous version, it looks much cleaner. By the way, let's compare the case where IIncrementalGenerator is available and when it's not, like this:

Summary

This generator enables us to execute code that would normally be done using reflection in a static manner, or analyze the structure of the code. It is a very useful and interesting feature. However, I have only scratched the surface, and I think it may be difficult to detect bugs in the generated code. It seems that it would be necessary to write unit tests or other means to cover those cases.

However, since I am still new to this, I can't say that I fully understand it yet. I will continue to investigate and write more samples.

I have provided the sample used in this blog post here:

Resources

This blog is translated from [更新] Source Generator を使って懐かしの AOP を作ってみる Powered by BlogBabel

Top comments (0)