. NET SourceGenerators automatically generates implementation classes according to the HTTP API interface

Posted by SpanKie on Wed, 26 Jan 2022 21:06:04 +0100

catalogue

  • abstract
  • Metadata analysis
  • Implementation using Source generators
  • Using Source generators to implement assembly analysis
  • usage method
  • SourceCode && Nuget package
  • summary

abstract

Source generators follow net5 was launched and launched in net6 is widely used. It can create new code based on existing code and add it to compile time based on compile time analysis. Using SourceGenerator can liberate developers from some templated and repetitive work, invest more creative work, and improve the performance consistent with the native code. In this article, we will demonstrate how to use source generators to automatically generate implementation classes according to the HTTP API interface, implement cross project analysis, and add them to the DI container.

Metadata analysis

Source generators can create new code based on syntax tree or symbol analysis at compile time. Therefore, we need to provide enough metadata before compilation. In this article, we need to know which interfaces need to generate implementation classes, and which methods defined in the interface should be sent by Get, Post, etc, In this article, we provide these metadata through attribute / annotation. Of course, you can also provide them through interface constraints and naming conventions.

First, we define the annotation on the interface, which will determine the interface we need to scan and how to create HttpClient:

/// <summary>
/// Identity a Interface which will be implemented by SourceGenerator
/// </summary>
[AttributeUsage(AttributeTargets.Interface)]
public class HttpClientAttribute : Attribute
{
    /// <summary>
    /// HttpClient name
    /// </summary>
    public string Name { get; }

    /// <summary>
    /// Create a new <see cref="HttpClientAttribute"/>
    /// </summary>
    public HttpClientAttribute()
    {
    }

    /// <summary>
    /// Create a new <see cref="HttpClientAttribute"/> with given name
    /// </summary>
    /// <param name="name"></param>
    public HttpClientAttribute(string name)
    {
        Name = name;
    }
}

Then we define the annotation on the interface method to indicate how to request the API and the requested template path. Here, take the HttpGet method as an example:

/// <summary>
/// Identity a method send HTTP Get request
/// </summary>
public class HttpGetAttribute : HttpMethodAttribute
{
    /// <summary>
    /// Creates a new <see cref="HttpGetAttribute"/> with the given route template.
    /// </summary>
    /// <param name="template">route template</param>
    public HttpGetAttribute(string template) : base(template)
    {
    }
}

/// <summary>
/// HTTP method abstract type for common encapsulation
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public abstract class HttpMethodAttribute : Attribute
{
    /// <summary>
    /// Route template
    /// </summary>
    private string Template { get; }
    
    /// <summary>
    /// Creates a new <see cref="HttpMethodAttribute"/> with the given route template.
    /// </summary>
    /// <param name="template">route template</param>
    protected HttpMethodAttribute(string template)
    {
        Template = template;
    }
}

Of course, it also provides RequiredServiceAttribute to inject services and HeaderAttribute to add header information and other annotations. It is not expanded here. Thanks to the string interpolation syntax sugar of C #, to support routing variables and other functions, you only need to wrap variables with {}, such as [HttpGet("/todos/{id}"), which will be automatically replaced with corresponding values at run time.

Implementation using Source generators

Create a new httpclient SourceGenerator project, SourceGenerator needs to introduce Microsoft CodeAnalysis. Analyzers´╝î Microsoft.CodeAnalysis.CSharp package and set the TargetFramework to netstandard2 0

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

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

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
        <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
        ...
    </ItemGroup>

</Project>

To use SourceGenerator, we need to implement the ISourceGenerator interface and add the [Generator] annotation. Generally, we register the Syntax receiver in Initialize, add the required classes to the receiver, discard the context that is not the receiver in Execute, and Execute the specific code generation logic.

public interface ISourceGenerator
{
  void Initialize(GeneratorInitializationContext context);

  void Execute(GeneratorExecutionContext context);
}

Here, we need to understand the syntax tree model and semantic model in roslyn api. In short, the syntax tree represents the syntax and lexical structure of the source code, indicating whether the node is an interface Declaration or a class Declaration or a using instruction block. This part of information comes from the Parse phase of the compiler; Semantics comes from the Declaration stage of the compiler and consists of a series of named symbols, such as TypeSymbol, MethodSymbol, etc. similar to the CLR type system, TypeSymbol can get marked annotation information, and MethodSymbol can get ReturnType and other information.

Define the HttpClient Syntax Receiver. Here, we process the node information, which is the node of the interface declaration syntax, and the interface declaration syntax is annotated. Then we obtain its semantic model, and judge whether it contains the HttpClientAttribute defined above according to the semantic model.

class HttpClientSyntax : ISyntaxContextReceiver
{
    public List<INamedTypeSymbol> TypeSymbols { get; set; } = new List<INamedTypeSymbol>();

    public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
    {
        if (context.Node is InterfaceDeclarationSyntax ids && ids.AttributeLists.Count > 0)
        {
            var typeSymbol = ModelExtensions.GetDeclaredSymbol(context.SemanticModel, ids) as INamedTypeSymbol;
            if (typeSymbol!.GetAttributes().Any(x =>
                    x.AttributeClass!.ToDisplayString() ==
                    "SourceGeneratorPower.HttpClient.HttpClientAttribute"))
            {
                TypeSymbols.Add(typeSymbol);
            }
        }
    }
}

The next step is to cycle the TypeSymbol in the receiver and automatically generate the implementation body according to the methods defined in the interface and annotations. There is no expansion here. You can view Github for detailed code.

private string GenerateGetMethod(ITypeSymbol typeSymbol, IMethodSymbol methodSymbol, string httpClientName,
    string requestUri)
{
    var returnType = (methodSymbol.ReturnType as INamedTypeSymbol).TypeArguments[0].ToDisplayString();
    var cancellationToken = methodSymbol.Parameters.Last().Name;
    var source = GenerateHttpClient(typeSymbol, methodSymbol, httpClientName);
    source.AppendLine($@"var response = await httpClient.GetAsync($""{requestUri}"", {cancellationToken});");
    source.AppendLine("response!.EnsureSuccessStatusCode();");
    source.AppendLine(
        $@"return (await response.Content.ReadFromJsonAsync<{returnType}>(cancellationToken: {cancellationToken})!)!;");
    source.AppendLine("}");
    return source.ToString();
}

Here we generate an extension method, add the HTTP API interface and implementation class to the DI container, and then call the extension method in the main project. In order to avoid possible namespace conflicts, we use global:: plus the full name containing the namespace.

   var extensionSource = new StringBuilder($@"
using SourceGeneratorPower.HttpClient;
using Microsoft.Extensions.Configuration;

namespace Microsoft.Extensions.DependencyInjection
{{
    public static class ScanInjectOptions
    {{
        public static void AddGeneratedHttpClient(this IServiceCollection services)
        {{
");

   foreach (var typeSymbol in receiver.TypeSymbols)
   {
       ...
       extensionSource.AppendLine(
           $@"services.AddScoped<global::{typeSymbol.ToDisplayString()}, global::{typeSymbol.ContainingNamespace.ToDisplayString()}.{typeSymbol.Name.Substring(1)}>();");
   }

   extensionSource.AppendLine("}}}");
   var extensionTextFormatted = CSharpSyntaxTree
       .ParseText(extensionSource.ToString(), new CSharpParseOptions(LanguageVersion.CSharp8)).GetRoot()
       .NormalizeWhitespace().SyntaxTree.GetText().ToString();
   context.AddSource($"SourceGeneratorPower.HttpClientExtension.AutoGenerated.cs",
       SourceText.From(extensionTextFormatted, Encoding.UTF8));
   ...

Using Source generators to implement assembly analysis

In the above, we introduced how to analyze which interfaces need to be generated according to the syntax tree. This is only suitable for a single project, but it is often developed by sub projects in practical work, and projects are referenced through ProjectReference.

In Source generators, we can use context Compilation. SourceModule. Referencedassemblysymbols to analyze the code in the assembly, which includes the reference assembly of the framework, the assembly referenced by the project and the assembly referenced by the nuget package. We can only keep the assembly referenced by the project through the condition that the PublicKey is empty.

In the assembly symbol (IAssemblySymbol), the relationship between symbols is shown in the figure below. What we need is to find the final INameTypeSymbol and judge whether it is the interface we need to generate.

Here, we can customize the Symbol visitor to realize the interface that needs to be generated for traversal scanning.

class HttpClientVisitor : SymbolVisitor
{
    private readonly HashSet<INamedTypeSymbol> _httpClientTypeSymbols;

    public HttpClientVisitor()
    {
        _httpClientTypeSymbols = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
    }

    public ImmutableArray<INamedTypeSymbol> GetHttpClientTypes() => _httpClientTypeSymbols.ToImmutableArray();

    public override void VisitAssembly(IAssemblySymbol symbol)
    {
        symbol.GlobalNamespace.Accept(this);
    }

    public override void VisitNamespace(INamespaceSymbol symbol)
    {
        foreach (var namespaceOrTypeSymbol in symbol.GetMembers())
        {
            namespaceOrTypeSymbol.Accept(this);
        }
    }

    public override void VisitNamedType(INamedTypeSymbol symbol)
    {
        if (symbol.DeclaredAccessibility != Accessibility.Public)
        {
            return;
        }

        if (symbol.GetAttributes().Any(x =>
                x.AttributeClass!.ToDisplayString() == "SourceGeneratorPower.HttpClient.HttpClientAttribute"))
        {
            _httpClientTypeSymbols.Add(symbol);
        }

        var nestedTypes = symbol.GetMembers();
        if (nestedTypes.IsDefaultOrEmpty)
        {
            return;
        }

        foreach (var nestedType in nestedTypes)
        {
            nestedType.Accept(this);
        }
    }
}

Then combine this part with the INameTypeSymbol of the HttpClientSymbolReceiver above, and the logic of the generated code remains unchanged.

public void Execute(GeneratorExecutionContext context)
{
    if (!(context.SyntaxContextReceiver is HttpClientSyntax receiver))
    {
        return;
    }

    var httpClientVisitor = new HttpClientVisitor();
    foreach (var assemblySymbol in context.Compilation.SourceModule.ReferencedAssemblySymbols
                 .Where(x => x.Identity.PublicKey == ImmutableArray<byte>.Empty))
    {
        assemblySymbol.Accept(httpClientVisitor);
    }
    receiver.TypeSymbols.AddRange(httpClientVisitor.GetHttpClientTypes());
    ...   
}

usage method

Interface definition

[HttpClient("JsonServer")]
public interface IJsonServerApi
{
    [HttpGet("/todos/{id}")]
    Task<Todo> Get(int id, CancellationToken cancellationToken = default);

    [HttpPost(("/todos"))]
    Task<Todo> Post(CreateTodo createTodo, CancellationToken cancellationToken = default);

    [HttpPut("/todos/{todo.Id}")]
    Task<Todo> Put(Todo todo, CancellationToken cancellationToken);

    [HttpPatch("/todos/{id}")]
    Task<Todo> Patch(int id, Todo todo, CancellationToken cancellationToken);

    [HttpDelete("/todos/{id}")]
    Task<object> Delete(int id, CancellationToken cancellationToken);
}

Reference the main project and configure the corresponding HttpClient

builder.Services.AddGeneratedHttpClient();
builder.Services.AddHttpClient("JsonServer", options => options.BaseAddress = new Uri("https://jsonplaceholder.typicode.com"));

Inject interface and use

public class TodoController: ControllerBase
{
    private readonly IJsonServerApi _jsonServerApi;

    public TodoController(IJsonServerApi jsonServerApi)
    {
        _jsonServerApi = jsonServerApi;
    }

    [HttpGet("{id}")]
    public async Task<Todo> Get(int id, CancellationToken cancellationToken)
    {
        return await _jsonServerApi.Get(id, cancellationToken);
    }
    ...
}

SourceCode && Nuget package

SourceCode: https://github.com/huiyuanai709/SourceGeneratorPower

Nuget Package: https://www.nuget.org/packages/SourceGeneratorPower.HttpClient.Abstractions

Nuget Package: https://www.nuget.org/packages/SourceGeneratorPower.HttpClient.SourceGenerator

summary

Source generators are very Powerful (Powerful!!!), It solves the problem of repeated coding in a modern and human readable way, and has the performance consistent with the native code. Readers can use source generators to solve practical problems in combination with articles and official examples. Any suggestions and new functional requirements are also welcome to leave a message or put forward on Github.

Topics: .NET DotNet