使用 SourceGenerator 簡化 Options 綁定

目錄

  • 摘要
  • Options 綁定
  • 使用 SourceGenerator 簡化
  • 如何 Debug SourceGenerator
  • 如何 Format 生成的程式碼
  • 使用方法
  • SourceCode && Nuget package
  • 總結

摘要

Never send a human to do a machine’s job

Source Generator 隨著 .net 5.0 推出,並在 .net 6 中大量使用,利用 SourceGenerator 可以將開發人員從一些模板化的重複的工作中解放出來,更多的投入創造力的工作。 一些開發人員可能會認為 SourceGenerator 很複雜,害怕去了解學習,我們將打破這種既定印象,不管其內部如何複雜,但至少使用起來很簡單。

本系列將自頂向下的學習分享 SourceGenerator,先學習 SourceGenerator 如何在我們工作中應用,再逐漸深入學習其原理。本文將介紹如何使用 SourceGenerator 來自動將 Options 和 Configuration 綁定。

1. Options 綁定

一般情況下,Options 和 Configuration 的綁定關係我們使用如下程式碼來實現,其中只有 Options type 和 section key 會變化,其它部分都是重複的模板程式碼。

在之前的方案中我們可以想到的是在 Options 類打上一個註解,並在註解中指明 section key,然後在程式啟動時,然後通過掃描程式集和反射在運行時動態調用 Configure 方法,但這樣會有一定的運行時開銷,拖慢啟動速度。下面將介紹如何使用 SourceGenerator 在編譯時解決問題。

builder.Services.Configure<GreetOption>(builder.Configuration.GetSection("Greet"));

2. 使用 SourceGenerator 簡化

編譯時程式碼生成需要足夠多的元數據 (metadata),我們可以使用註解,命名,繼承某個特定類,實現特定介面等途徑來指明哪些東西需要生成或指明生成所需要的資訊。在本文中我們想在編譯時生成程式碼也必須知道 Options 類型和 section key,這裡我們使用註解來提供元數據。

2.1 Option Attribute

被標記的 class 即為 Options 類型,構造函數參數即指明 section key

/// <summary>
/// Mark a class with a Key in IConfiguration which will be source generated in the DependencyInjection extension method
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class OptionAttribute : Attribute
{
    /// <summary>
    /// The Key represent IConfiguration section
    /// </summary>
    public string Key { get; }

    public OptionAttribute(string key)
    {
        Key = key;
    }
}

並在需要綁定的 Options 類上邊加上該 Attribute

[Option("Greet")]
public class GreetOption
{
    public string Text { get; set; }
}

2.2 Options.SourceGenerator

新建 Options.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 構成,比如 TypeSymbol,MethodSymbol 等,類似於 CLR 類型系統, TypeSymbol 可以得到標記的註解資訊,MethodSymbol 可以得到 ReturnType 等資訊。

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

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

    public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
    {
        if (context.Node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0)
        {
            ITypeSymbol typeSymbol = context.SemanticModel.GetDeclaredSymbol(cds) as ITypeSymbol;
            if (typeSymbol!.GetAttributes().Any(x =>
                    x.AttributeClass!.ToDisplayString() ==
                    "SourceGeneratorPower.Options.OptionAttribute"))
            {
                TypeSymbols.Add(typeSymbol);
            }
        }
    }
}

接下來就是循環處理接收器中的 TypeSymbol,獲取 OptionAttribute 的 AttributeData,一般通過構造函數初始化的 Attribute,是取 ConstructorArguments,而通過屬性賦值的是取 NamedArguments,這裡為了避免 using 問題直接取 typeSymbol 的 DisplayString 即包含了 Namespace 的類全名。並用這些元數據來生成對應的模板程式碼。

private string ProcessOptions(ISymbol typeSymbol, ISymbol attributeSymbol)
{
    AttributeData attributeData = typeSymbol.GetAttributes()
        .Single(ad => ad.AttributeClass!.Equals(attributeSymbol, SymbolEqualityComparer.Default));
    TypedConstant path = attributeData.ConstructorArguments.First();
    return $@"services.Configure<{typeSymbol.ToDisplayString()}>(configuration.GetSection(""{path.Value}""));";
}

由於 SourceGenerator 被設計成不能修改現有的程式碼,這裡我們使用 SourceGenerator 來生成一個擴展方法,並將上邊生成的模板程式碼添加進去。可以看見有一部分的程式碼是不會有變動的,這裡有個小技巧,先寫真實的類來發現其中的變化量,然後將不變的直接複製過來,而變化的部分再去動態拼接,注意收尾的括弧不能少。

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

    INamedTypeSymbol attributeSymbol =
        context.Compilation.GetTypeByMetadataName("SourceGeneratorPower.Options.OptionAttribute");

    StringBuilder source = new StringBuilder($@"
using Microsoft.Extensions.Configuration;

namespace Microsoft.Extensions.DependencyInjection
{{
    public static class ScanInjectOptions
    {{
        public static void AutoInjectOptions(this IServiceCollection services, IConfiguration configuration)
        {{
");
    foreach (ITypeSymbol typeSymbol in receiver.TypeSymbols)
    {
        source.Append(' ', 12);
        source.AppendLine(ProcessOptions(typeSymbol, attributeSymbol));
    }

    source.Append(' ', 8).AppendLine("}")
        .Append(' ', 4).AppendLine("}")
        .AppendLine("}");
    context.AddSource("Options.AutoGenerated.cs",
        SourceText.From(source.ToString(), Encoding.UTF8));
}

如何 Debug SourceGenerator

在寫 SourceGenerator 的過程中,我們可能需要用到 Debug 功能,這裡我們使用 Debugger 結合附加到進程進行 Debug,選擇的進程名字一般是 csc.dll,注意需要提前打好斷點,之前編譯過還需要 Clean Solution。
一般在方法的開頭我們加上以下程式碼,這樣編譯程式將一直自旋等待附加到進程。

if (!Debugger.IsAttached)
{
    SpinWait.SpinUntil(() => Debugger.IsAttached);
}

如何 Format 生成的程式碼

可以看見上邊的示例中,我們使用手動添加空格的方式來格式化程式碼,當需要生成的程式碼很多時,結構比較複雜時,我們如何格式化生成的程式碼呢?這裡我們可以使用 CSharpSyntaxTree 來轉換一下,再將格式化後的程式碼添加到編譯管道中去。

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

使用方法

首先在 Options 類上邊打上標記

[Option("Greet")]
public class GreetOption
{
    public string Text { get; set; }
}

appsetting.json 配置

{
  "Greet": {
    "Text": "Hello world!"
  }
}

然後使用擴展方法, 這裡以 .Net 6 為例, .Net5 也是類似的

builder.Services.AutoInjectOptions(builder.Configuration);

SourceCode && Nuget package

SourceCode: //github.com/huiyuanai709/SourceGeneratorPower

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

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

總結

本文介紹了 Options Pattarn 與 Configuration 綁定的 SourceGenerator 實現,以及介紹了如何 Debug,和如何格式化程式碼。可以看見 SourceGenerator 使用起來也比較簡單,套路不多,更多資訊可以從官方文檔
//docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/ 及 Github 上了解和學習。

文章源自公眾號:灰原同學的筆記,轉載請聯繫授權