使用 SourceGenerator 简化 Options 绑定

时间:2024-03-12 15:25:04

目录

  • 摘要
  • 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: https://github.com/huiyuanai709/SourceGeneratorPower

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

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

总结

本文介绍了 Options Pattarn 与 Configuration 绑定的 SourceGenerator 实现,以及介绍了如何 Debug,和如何格式化代码。可以看见 SourceGenerator 使用起来也比较简单,套路不多,更多信息可以从官方文档
https://docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/ 及 Github 上了解和学习。

文章源自公众号:灰原同学的笔记,转载请联系授权