.NET SourceGenerators 根據 HTTPAPI 介面自動生成實現類

目錄

  • 摘要
  • 元數據分析
  • 使用 Source generators 實現
  • 使用 Source generators 實現程式集分析
  • 使用方法
  • SourceCode && Nuget package
  • 總結

摘要

Source generators 隨著 .net5 推出,並在 .net6 中大量運用,它可以基於編譯時分析,根據現有程式碼創建新的程式碼並添加進編譯時。利用 SourceGenerator 可以將開發人員從一些模板化的重複的工作中解放出來,更多的投入創造力的工作,並且和原生程式碼一致的性能。 在這篇文章中,我們將演示如何使用 Source generators 根據 HTTP API 介面自動生成實現類,以及實現跨項目分析,並且添加進 DI 容器。

元數據分析

Source generators 可以根據編譯時語法樹(Syntax)或符號(Symbol)分析,來執行創建新程式碼,因此我們需要在編譯前提供足夠多的元數據,在本文中我們需要知道哪些介面需要生成實現類,並且介面中定義的方法該以 Get,Post 等哪種方法發送出去,在本文中我們通過註解(Attribute/Annotation)來提供這些元數據,當然您也可以通過介面約束,命名慣例來提供。

首先我們定義介面上的註解,這將決定我們需要掃描的介面以及如何創建 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;
    }
}

然後我們定義介面方法上的註解,表明以何種方式請求 API 以及請求的模板路徑,這裡以HttpGet方法為例:

/// <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;
    }
}

當然還提供RequiredServiceAttribute來注入服務,HeaderAttribute來添加頭資訊等註解這裡不做展開,得益於 C# 的字元串插值(String interpolation)語法糖,要支援路由變數等功能,只需要用{}包裹變數就行 例如[HttpGet("/todos/{id}")],這樣在運行時就會自動替換成對應的值。

使用 Source generators 實現

新建 HttpClient.SourceGenerator 項目,SourceGenerator 需要引入 Microsoft.CodeAnalysis.Analyzers, Microsoft.CodeAnalysis.CSharp 包,並將 TargetFramework 設置成 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>

要使用 SourceGenerator 需要實現 ISourceGenerator 介面,並添加 [Generator] 註解,一般情況下我們在 Initialize 註冊 Syntax receiver,將需要的類添加到接收器中,在 Execute 丟棄掉不是該接收器的上下文,執行具體的程式碼生成邏輯。

public interface ISourceGenerator
{
  void Initialize(GeneratorInitializationContext context);

  void Execute(GeneratorExecutionContext context);
}

這裡我們需要了解下 roslyn api 中的 語法樹模型 (SyntaxTree model) 和 語義模型 (Semantic model),簡單的講, 語法樹表示源程式碼的語法和詞法結構,表明節點是介面聲明還是類聲明還是 using 指令塊等等,這一部分資訊來源於編譯器的Parse階段;語義來源於編譯器的Declaration階段,由一系列 Named symbol 構成,比如TypeSymbolMethodSymbol等,類似於 CLR 類型系統, TypeSymbol 可以得到標記的註解資訊,MethodSymbol 可以得到 ReturnType 等資訊。

定義 HttpClient Syntax Receiver,這裡我們處理節點資訊是介面聲明語法的節點,並且介面聲明語法上有註解,然後再獲取其語義模型,根據語義模型判斷是否包含我們上邊定義的 HttpClientAttribute

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);
            }
        }
    }
}

接下來就是循環處理接收器中的 TypeSymbol,根據介面裡面定義的方法以及註解自動生成實現體的方法,這裡不做展開詳細程式碼可以查看 Github。

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();
}

我們這裡生成一個擴展方法,並將 HTTP API 介面和實現類添加到 DI容器,然後在主項目中調用這個擴展方法,同時為了避免可能的命名空間衝突,我們這裡使用 global:: 加上包含命名空間的全名來引用。

   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));
   ...

使用 Source generators 實現程式集分析

在上面我們介紹了如何根據語法樹來分析哪些介面需要生成這隻適合單項目,但在實際工作中常常是分項目開發的,項目之間通過 ProjectReference 引用。

在 Source generators 中我們可以使用 context.Compilation.SourceModule.ReferencedAssemblySymbols 來分析程式集中的程式碼,這其中包含了框架的引用程式集,項目引用的程式集以及 nuget 包引用的程式集,我們可以通過 PublicKey 為空條件只保留項目引用的程式集。

在程式集符號(IAssemblySymbol)中, 符號的關係如下圖,我們需要的是找到最終的 INameTypeSymbol 判斷是否是需要我們進行生成的介面。

這裡我們可以自定義 Symbol visitor 來實現遍歷掃描需要生成的介面。

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);
        }
    }
}

然後將這部分與上邊的 HttpClientSymbolReceiver的 INameTypeSymbol 合併到一起,生成程式碼的邏輯不變。

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());
    ...   
}

使用方法

介面定義

[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);
}

主項目引用,並配置對應的 HttpClient

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

注入介面並使用

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: //github.com/huiyuanai709/SourceGeneratorPower

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

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

總結

Source generators 非常強(Powerful!!!),以一種現代化的,人類可讀(human readable)的方式解決重複編碼的問題,並且擁有與原生程式碼一致的性能,讀者可以結合文章以及官方示例用 Source generators 來解決實際工作中的問題,任何建議和新功能需求也歡迎留言或在 Github 上提出。