c#的源代码生成器SourceGenerator/T4模板

简介

微软在 .NET 5 中引入了 Source Generator 的新特性,利用 Source Generator 我们可以在应用编译期间根据当前编译信息动态生成代码,而且可以在我们的 C# 代码中直接引用动态生成的代码,从而大大减少重复代码。

前面我们介绍过Emit和表达式树,本篇讲到的源代码生成器也是同样一个动态代码生成方案,他们之间的区别:

  • Source Generators在编译时运行,Emit在运行时运行,表达式树既可以在编译时生成代码,也可以在运行时构建代码
  • Source Generators和Emit通常用于生成方法和类型,而表达式树可以用于构建更复杂的表达式。
  • Source Generators是.NET Core及更高版本中的新功能,而Emit和表达式树在早期版本中就已存在。

源代码生成器有什么优点呢?

编译时反射
Source Generators 将可以让 ASP.NET Core 所有的类型发现、依赖注入等在编译时就全部完成并编译到最终的程序集当中,最终做到 0 运行时反射使用,不仅利于 AOT 编译,而且运行时 0 开销。

AOT 编译
Source Generators 的另一个作用是可以帮助消除 AOT 编译优化的主要障碍。许多框架和库都大量使用反射,非常不利于 AOT 编译优化,因为为了使反射能够正常工作,必须将大量额外甚至可能不需要的类型元数据编译到最终的原生映像当中。有了 Source Generators 之后,只需要做编译时代码生成便可以避免大部分的运行时反射的使用,让 AOT 编译优化工具能够更好的运行。

性能比较

表达式树 和 Source Generators 与 Emit 在简单的反射获取和写入私有属性时,哪种效率更高?

在简单的反射获取和写入属性的情况下,Source Generators 的效率可能会更高一些,因为它们可以在编译时生成额外的代码,并且生成的代码可以直接访问属性而无需使用反射。

通常情况下,使用 Emit 直接生成 IL 代码可能会比使用表达式树更高效,因为 Emit 可以直接生成底层的 IL 指令,而表达式树需要在运行时动态解释和执行。Emit 可以更接近底层的实现,因此通常性能会更好。但是其性能差异不明显。

总的来说:Source Generators > Emit >= 表达式树

Source Generators(源生成器 - ISourceGenerator)

目前项目中有一个需求“c#游戏项目中,自定义了序列化和反序列化二进制的功能”,目前使用的“运行时反射”来序列化,前面也提到运行时反射性能是正常调用的100倍左右,而序列化和反序列化又是一个超高频率的调用,性能急需优化。接下来我们使用源代码生成器来优化运行时反射。

工作原理

我们来了解一下源代码生成器的工作原理:Source Generators编译过程的一部分,它以编译树作为输入,通过分析代码,动态生成文件并把它们加入到编译过程中。

编译运行 -> [分析源代码 -> 生成新代码] -> 将生成的新代码添加入编译过程 -> 编译继续

你必须实现ISourceGenerator接口,并且用GeneratorAttribute标注:

[Generator]
public class DemoSourceGenerator : ISourceGenerator
{
    public void Execute(GeneratorExecutionContext context)
    {
        // code
    }

    public void Initialize(GeneratorInitializationContext context)
    {
        // code
    }
}

ISourceGenerator 有两个接口:

  • Initialize,在 Initialize 方法中, 我们可以通过 RegisterForSyntaxNotifications 注册自己的语法接收器来筛选自己关心的语法节点,也可以通过 RegisterForPostInitialization 注册初始化完成之后的回调,也可以通过 CancellationToken 来判断是否停止初始化过程,如果初始化逻辑比较复杂耗时较长的时候可以考虑判断 CancellationToken 来检测用户用户是否取消了编译

  • Execute,我们通常生成代码都是在这个方法中处理的,这个方法有更多的信息,我们可以获取到当前的编译信息,我们可以通过 AddSource 方法来添加我们自定义代码,可以通过 SyntaxReceiver/SyntaxContextReceiver 来获取我们在 Initialize 方法中注册的自定义语法接收器,同样的我们也可以根据 CancellationToken 来判断是否停止编译过程

在上述代码中,最需要注意的就是GeneratorExecutionContext,它是一个只读的结构体,参考微软Roslyn的API文档如下:

public readonly struct GeneratorExecutionContext
{
    AdditionalFiles // 【次重要】额外文件,可以理解成模板,一组可由生成器使用的其他非代码文本文件。
    AnalyzerConfigOptions // 允许访问分析器配置提供的选项
    CancellationToken // CancellationToken可检查该生成是否应取消。
    Compilation // 【重要】编译对象,有了这个对象你就可以访问当前编译项目的整个语法树(SyntaxTree)。只要我们遍历语法树,即可拿到编译中的任何代码。
    ParseOptions // ParseOptions获取将用于分析任何已添加源的源。
    SyntaxContextReceiver // 【重要】同SyntaxReceiver但功能更多除了语法节点外还提供语义模型,如果生成器在初始化期间注册了一个 ISyntaxContextReceiver ,则这是为此生成传递创建的实例。
    SyntaxReceiver // 【重要】如果生成器在初始化期间注册了一个 ISyntaxReceiver ,则这是为此生成传递创建的实例。ISyntaxReceiver有个方法OnVisitSyntaxNode(SyntaxNode),它将在编译中的每一个语法节点进行调用,我们可实现此方法后进行过滤节点,将我们需要的节点存储下来,在Excute()方法中调用。

    AddSource() // 【重要】向编译器添加源代码
    ......
}

这里提供一个DEMO

// demo
public void Execute(GeneratorExecutionContext context)
{
    //获取第一个附加文件内容,用作代码模板
    var template = context.AdditionalFiles.First().GetText().ToString();
    //获取第一个类名
    var className = context.Compilation.SyntaxTrees.SelectMany(p => p.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()).First().Identifier.Text;
    // 替换文本生成代码
    // 你也可以使用模板引擎或者StringBuilder拼接出代码
    var source = template.Replace("{Class}", className);
    // 向编译过程添加代码文件
    context.AddSource("Demo.g.cs", SourceText.From(source, Encoding.UTF8));
}

// 在代码生成器的项目配置文件(.csproj)中添加附加文件(模板文件)
<ItemGroup>
    <AdditionalFiles Include="template.txt"  />
</ItemGroup>

主项目中引用源代码生成器生成的代码

// ReferenceOutputAssembly=False 在引用项目时,不额外引入依赖文件,默认true
// OutputItemType="Analyzer" 要将目标输出到的项类型,默认空
<ItemGroup>
    <ProjectReference Include="..\PathTo\SourceGenerator.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
</ItemGroup>

// 默认的源代码生成器所生成的代码都是没有直接存放到项目文件夹里面的,不受源代码管理工具管理,不方便调试。配置这个后就可以将生成的代码输出到文件中。
<PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

常用类和方法

在写生成器代码过程中,经常用到的关键对象和代码,这部分太复杂强烈建议写的时候查官方文档:

第一步:找到编译信息和语法树

Microsoft.CodeAnalysis.SyntaxTree:包含单个文件里所有语法节点的语法树
Microsoft.CodeAnalysis.Compilation: 包含整个编译项目的编译信息

GeneratorExecutionContext.Compilation 即整个项目的编译信息;
GeneratorExecutionContext.Compilation.SyntaxTrees 包含整个项目正在参与编译的所有非生成器生成的代码的语法树。

第二步:获取语义模型和语义符号

// 获取语法树的语义模型;通过这个语义模型,你可以找到每一个语法节点所对应的语义符号到底是什么。
var semanticModel = compilation.GetSemanticModel(syntaxTree);

接下来的部分,你需要先拥有 Roslyn 语法分析的基本能力才能完成,因为要拿到一个语义符号,你需要先拿到其对应的语法节点(至少是第一个节点)。例如,拿到一个语法树(SyntaxTree)中的类型定义,可以用下面的方法:

// 遍历语法树中的所有节点,找到所有类型定义的节点。
var classDeclarationSyntaxes = from node in syntaxTree.GetRoot().DescendantNodes()
                               where node.IsKind(SyntaxKind.ClassDeclaration)
                               select (ClassDeclarationSyntax) node;

这样,针对这个语法树里面的每一个类型定义,我们都可以拿到其对应的语义了:
foreach (var classDeclarationSyntax in classDeclarationSyntaxes)
{
    if (semanticModel.GetDeclaredSymbol(classDeclarationSyntax) is { } classDeclarationSymbol)
    {
        // 在这里使用你的类型定义语义符号。
    }
}

第三步:使用语义模型
经过了前两个步骤,Roslyn 语义分析最难的部分就结束了,接下来就可以像使用反射(实际比反射更强大,更难)一样获取各种元数据,例如:命名空间、类、属性、方法、类特性,方法特性、字段特性等等。

// 获取类型的命名空间。
var namespace = classDeclarationSymbol.ContainingNamespace;

// 获得基类,获得接口。
var baseType = classDeclarationSymbol.BaseType;
var interfaces = classDeclarationSymbol.Interfaces;

// 获取类型的成员。
var members = classDeclarationSymbol.GetMembers();

// 获取成员的类型,然后忽略掉属性里面的方法。
foreach (var member in members)
{
    if (member is IMethodSymbol method && method.MethodKind is MethodKind.PropertyGet or MethodKind.PropertySet)
    {
        continue;
    }
    // 其他成员。
}

// 获得方法的形参数列表。
var parameters = method.Parameters;

// 获得方法的返回值类型。
var returnType = method.ReturnType;

注意事项

  • 目前只能用 .NET Standard 2.0 程序集作源生成器。
  • 注意分析器的版本,在实际项目中可能本地分析器版本与NuGetMicrosoft.CodeAnalysis.CSharp不一致可能会出错。
  • 源代码生成器中一般操作是只添加代码不改变现有代码。通常使用partial来扩展类和方法。
  • 生成的文件中最好以.g.cs作为后缀名。例如:context.AddSource("Demo.g.cs", template);
  • IDE编辑器目前对源代码生成器支持不是很友好,遇到问题后可能要重启IDE或者清理主项目和生成器项目后再编译。

Source Generators(源生成器 - IIncrementalGenerator)

Incremental Generators(增量源生成器,c#>=10)用以区分前面的 Source Generators(源生成器,c#>=9)。

注意:在GITHUB官方的设计文档中有以下声明:
警告:已弃用实现 ISourceGenerator 的源生成器,转而使用增量生成器(Incremental Generators)。

虽然官方不推荐使用ISourceGenerator但是其还是和IIncrementalGenerator并存的。增量源生成器是与源生成器一起存在的新 API,允许用户指定可由托管层以高性能方式应用的生成策略。总的来说就是它比较新,且比源生成器的性能高,所以以后需要使用源生成器推荐使用增量生成器。看博客中别人源生成器用多了(10个以上)IDE就会很卡~

IIncrementalGenerator 有一个 Initialize 方法,无论这个生成器被引用了多少次,其方法只调用一次。

使用方法

按照官方的设计,将会分为三个步骤完成增量代码生成:

  • 第一步:告诉框架层需要关注哪些文件的变更。在有对应的文件的变更情况下,才会触发后续步骤。如此就是增量代码生成的关键。
  • 第二步:告诉框架层从变更的文件里面感兴趣什么数据,对数据预先进行处理。
  • 第三步:使用给出的数据进行处理源代码生成逻辑。这一步的逻辑和普通的 Source Generator 是相同的,只是输入的参数不同

常用类和方法

// 与源生成器一样,增量生成器在[外部程序集]中定义,并通过 -analyzer: 选项传递给编译器。
// 增量源生成器模板
[Generator]
public class CustomGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(static postInitializationContext => {
            postInitializationContext.AddSource("生成的文件名.cs", SourceText.From("""
                // 生成的代码
                """, Encoding.UTF8));
        });
    }
}

// 增量源生成器中最重要的对象
public readonly struct IncrementalGeneratorInitializationContext {
    // 读取文件信息,通常是模板
    public IncrementalValuesProvider<AdditionalText> AdditionalTextsProvider;
    // 分析器的一些配置
    public IncrementalValueProvider<AnalyzerConfigOptionsProvider> AnalyzerConfigOptionsProvider;
    // 【重要】获取当前编译上下文
    public IncrementalValueProvider<Compilation> CompilationProvider;
    // 一些程序集,图片,流操作时需要用到
    public IncrementalValueProvider<MetadataReference> MetadataReferencesProvider;
    // 语义分析的一些配置
    public IncrementalValueProvider<ParseOptions> ParseOptionsProvider;
    // 【重要】有2个方法创建语法/语义分析:ForAttributeWithMetadataName<T>(string,Func,Func)[性能高,特性相关] 与 CreateSyntaxProvider<T>(Func,Func)[性能相对低]
    // 可以将语义分析转化成IncrementalValueProvider<T>类型,同时我们可以在第一个委托进行语法过滤,只有指定语法才能进入后续逻辑,第二个委托用于用来编写处理逻辑。
    public SyntaxValueProvider SyntaxProvider;

    // 初始化运行后立即提供源代码,它不接受任何输入,因此不能引用用户编写的任何源代码或任何其他编译器输入。
    // 在运行任何其他转换之前,初始化后源将包含在编译中,这意味着它将作为常规执行管道的其余部分可见。
    // 它对于向用户的源代码添加特性(Attribute)定义特别有用。然后,用户可以在他们的代码中应用这些代码,生成器可以通过语义模型找到属性代码。
    public void RegisterPostInitializationOutput(Action<IncrementalGeneratorPostInitializationContext>);
    // 它允许生成器向编译管道注册生成的源代码。生成的代码会在每次生成时完全重建。
    // 允许生成器作者生成将包含在用户编译中的源文件和诊断。提供的 SourceProductionContext 可用于添加源文件和报告诊断。
    public void RegisterSourceOutput<T>(IncrementalValue[s]Provider<T>, Action<SourceProductionContext,T>);
    // 同上,可以按需执行,当输入数据发生变化时相关生成步骤才执行,从而大幅提高构建效率。
    public void RegisterImplementationSourceOutput<T>(IncrementalValue[s]Provider<T>, Action<SourceProductionContext,T>);
}

// 生成器的Action<SourceProductionContext,T> 的参数
public readonly struct SourceProductionContext {
    public CancellationToken CancellationToken { get; }
    // 重要
    public void AddSource(string hintName, string source);
    public void ReportDiagnostic(Diagnostic diagnostic);
}

// 【重要】单个或多个值,其提供了类似LINQ的方法(注意只是类似),方法太多,查看官方文档,文档中还有其设计思路,转化思路等。
// 输入数据以不透明数据源的形式提供给管道,可以通过Context的各种Provider访问。
public readonly struct IncrementalValue[s]Provider<T>

// CreateSyntaxProvider 详细讲解
var incrementalValuesProvider = context.SyntaxProvider.CreateSyntaxProvider(
    (syntaxNode, _) => {
        // 在此进行快速的语法判断逻辑,可以判断当前的内容是否感兴趣,如此过滤掉一些内容,从而减少后续处理,提升性能
        // 返回:true表示是我们关心的语法,false表示不是我们关心的语法
        // 这里样式的是获取类的完全限定名,也就是只需要用到 Class 类型
        return syntaxNode.IsKind(SyntaxKind.ClassDeclaration);
    },
    (GeneratorSyntaxContext generatorSyntaxContext, CancellationToken _) => {
        // 这里为生成源代码方法提供预处理数据
        // GeneratorSyntaxContext {Node是上一步判断为true的SyntaxNode信息,SemanticModel是Node的语义模型}
        return generatorSyntaxContext;
    }
);

// ForAttributeWithMetadataName 详细讲解
var incrementalValuesProvider = context.SyntaxProvider.ForAttributeWithMetadataName(
    "命名空间.特性名",
    (syntaxNode, _) => {
        // 同上
        return syntaxNode.IsKind(SyntaxKind.ClassDeclaration);
    },
    (GeneratorAttributeSyntaxContext syntaxContext, CancellationToken _) => {
        // 注意:这里的 GeneratorAttributeSyntaxContext 和上面的的 GeneratorSyntaxContext 不一样。
        // 其有4个属性:Attributes表示匹配到的特性,SemanticModel语义模型,TargetNode特性附加到的语法节点,TargetSymbol特性附加到的语法的符号信息
        return generatorSyntaxContext;
    }
);


语法/语义分析

编译器的构成: 源程序->词法分析-语法分析-语义分析-中间代码生成-中间代码优化-目标代码生成->目标程序

安装基于 Roslyn 的语法/语义分析插件:
Visual Studio扩展开发工作负载之后,在右侧将可选的 .NET Compiler Platform SDK 也打上勾, 或者在Visual Studio右上角的“快速启动”工具栏, 输入 Roslyn 点击安装。
安装完之后,视图->其它窗口->Syntax Visualizer打开语法可视化工具,会显示的当前打开的源代码的Syntax Tree及其所有节点信息和节点属性。

语法可视化树中有三种不同颜色的节点:

  • 蓝色:SyntaxNode,[重要]表示声明、语句、子句和表达式等语法构造。
  • 绿色:SyntaxToken,[次重要]表示关键字、标识符、运算符等标点。
  • 红色:SyntaxTrivia,代表语法上不重要的信息,例如标记、预处理指令和注释之间的空格。

语义分析常用步骤:

  • 找到编译信息(Compilation)和语法树(SyntaxTree/SyntaxNode/各种XxxxSyntax[节点实例,继承至SyntaxNode,每个节点类名都不一样])。
  • 获取语义模型(SemanticModel)和语义符号(ISymbol)。
  • 使用语义模型,就像反射一样。

语法/语义分析常用类:

  • SyntaxTree(语法树):是源代码的抽象语法结构的表示。它捕获了源代码的结构。
  • SyntaxNode(语法节点)SyntaxNodeSyntaxTree 中的一个节点,代表源代码中的一个构造(如类、方法、表达式等)语法节点都有Parent访问其父节点, ChildNodes()访问子节点列表, 每个节点还包含用于检查子代的方法。
  • SemanticModel(语义模型):提供了源代码的语义信息。它知道源代码中各个元素(如变量、类型、方法等)的含义和它们之间的关系。
  • ISymbol(符号):通过 SemanticModel 的方法来获取 ISymbol 实例, 其包含了编译器收集的信息, 其功能类似反射。符号表示源代码声明的不同元素(例如变量/方法的声明), 每个命名空间类型方法属性字段事件参数局部变量都由符号表示。Compilation 类型的各种方法和属性可帮助查找符号, 还可以访问整个符号表, 符号表是以全局命名空间为根的符号树。

简而言之,SyntaxTree 和 SyntaxNode 提供了源代码的结构信息,而 SemanticModel 和 ISymbol 提供了源代码的语义信息。

语法分析

const string programText =
@"using System;
using System.Collections;
using System.Linq;
using System.Text;

namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(""Hello, World!"");
        }
    }
}";

// 语法分析(一): 手动遍历,类似遍历树形结构从root到具体某个节点, 类似HTML的DOM中的XPath从根节点依次遍历

// 将string转化成SyntaxTree
SyntaxTree tree = CSharpSyntaxTree.ParseText(programText);
// 从SyntaxTree中获取根节点的类型
CompilationUnitSyntax root = tree.GetCompilationUnitRoot();
WriteLine($"表达式树是 {root.Kind()} 节点 , {root.Members.Count} 各元素, {root.Usings.Count} 个using节点");
// 取root的第一个成员 namespace 命名空间节点
MemberDeclarationSyntax firstMember = root.Members[0];
var helloWorldDeclaration = (NamespaceDeclarationSyntax)firstMember;
// 取 namespace 的 第一个成员 class 类节点
var programDeclaration = (ClassDeclarationSyntax)helloWorldDeclaration.Members[0];
WriteLine($"There are {programDeclaration.Members.Count} members declared in the {programDeclaration.Identifier} class.");
WriteLine($"The first member is a {programDeclaration.Members[0].Kind()}.");
// 取 class 的 第一个成员 main() 方法节点
var mainDeclaration = (MethodDeclarationSyntax)programDeclaration.Members[0];
WriteLine($"The return type of the {mainDeclaration.Identifier} method is {mainDeclaration.ReturnType}.");
WriteLine($"The method has {mainDeclaration.ParameterList.Parameters.Count} parameters.");

// 语法分析(二): 查询方法, 结合LINQ使用,上下级查找
var firstParameters = from methodDeclaration in root.DescendantNodes().OfType<MethodDeclarationSyntax>()
                      where methodDeclaration.Identifier.ValueText == "Main"
                      select methodDeclaration.ParameterList.Parameters.First();

var argsParameter2 = firstParameters.Single();
WriteLine(argsParameter == argsParameter2);

// 语法分析(三): 语法查看器,查找语法树中特定类型的所有节点
// 略

语义分析

语法分析和上下文无关,语义分析与上下文有关。
语义分析:主要功能包括建立符号表,进行静态语义检查(包括声明、类型匹配、类型转换),发现语义错误
将名称和表达式与“符号”进行关联的过程被称为“绑定”。
通常分成3步:

  • 1、获取语法节点;
  • 2、语义模型SemanticModel;
  • 3、获取节点的Symbol信息;
// 使用上一段代码中的 programText 源代码做语义分析

// 语法树,语法节点获取
SyntaxTree tree = CSharpSyntaxTree.ParseText(programText);
CompilationUnitSyntax root = tree.GetCompilationUnitRoot();

// 创建CSharpCompilation对象(编译对象),因为语义分析需要在编译环境中才能进行
var compilation = CSharpCompilation.Create("HelloWorld")
    .AddReferences(MetadataReference.CreateFromFile(typeof(string).Assembly.Location))
    .AddSyntaxTrees(tree);

// 语义模型SemanticModel
SemanticModel model = compilation.GetSemanticModel(tree);

// ===============================
// 【名称绑定符号】:第一个using指令中的名称绑定为System命名空间的符号
SymbolInfo nameInfo = model.GetSymbolInfo(root.Usings[0].Name);

// 枚举System命名空间的子命名空间并将其名称打印到控制台
var systemSymbol = (INamespaceSymbol?)nameInfo.Symbol;
if (systemSymbol?.GetNamespaceMembers() is not null)
{
    foreach (INamespaceSymbol ns in systemSymbol?.GetNamespaceMembers()!)
    {
        Console.WriteLine(ns);
    }
}

// ================================
// 【表达式绑定符号】:绑定到简单的字符串文本
// 使用语法模型查找字符串
var helloWorldString = root.DescendantNodes().OfType<LiteralExpressionSyntax>().Single();
// 使用语义模型获取字符串的类型信息,为string
TypeInfo literalInfo = model.GetTypeInfo(helloWorldString);
// 语法节点表示的表达式的类型。 对于没有类型的表达式,返回 null。
ITypeSymbol stringTypeSymbol = (INamedTypeSymbol?)literalInfo.Type;
// 控制台中显示string类的所有方法名称
foreach (string name in (from method in stringTypeSymbol?.GetMembers().OfType<IMethodSymbol>()
    where SymbolEqualityComparer.Default.Equals(method.ReturnType, stringTypeSymbol) && method.DeclaredAccessibility == Accessibility.Public
    select method.Name)
    .Distinct())
{
    Console.WriteLine(name);
}

实战讲解

项目中的部分代码如下:

// 数据包抽象类
namespace 游戏服务器.网络类
{
    internal abstract class 游戏封包
    {
        internal bool 是否加密 { get; set; } = true;
        internal readonly bool 附近广播;
        internal readonly ushort 封包编号;
        internal readonly ushort 封包长度;
    }
}
// 游戏数据包类(项目中有1000+个类似数据包)
namespace 游戏服务器.网络类.客户端封包
{
    [封包信息描述(来源 = 封包来源.客户端, 编号 = 113, 长度 = 14)]
    internal sealed class 接取普通任务 : 游戏封包
    {
        [封包字段描述(下标 = 2, 长度 = 4)]
        internal int 任务编号;

        // 普通任务: 1, 紧急任务 4
        [封包字段描述(下标 = 6, 长度 = 4)]
        internal int 点击参数2;

        [封包字段描述(下标 = 10, 长度 = 4)]
        internal int 点击参数3;
    }
}
// 自定义特性
namespace 游戏服务器
{
    // 数据包的类描述
    [AttributeUsage(AttributeTargets.Class)]
    internal class 封包信息描述 : Attribute
    {
        public 封包来源 来源;
        public ushort 编号;
        public ushort 长度;
        public string 注释;
        public bool 附近广播;
    }

    // 数据包的字段描述
    [AttributeUsage(AttributeTargets.Field)]
    internal class 封包字段描述 : Attribute
    {
        public ushort 下标;
        public ushort 长度;
        public bool 反向;
        public byte 结束符 = 0;
    }
}

// 最终的增量源生成器 - 用以自定义序列化服务端数据包、反序列化客户端数据包
using System.Collections.Generic;
//using System.Diagnostics;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Server.SourceGenerator
{
    [Generator(LanguageNames.CSharp)]
    public class GeneratorPacketMothod : IIncrementalGenerator
    {
        /// <summary>
        /// 源代码生成器的所要生成的方法
        /// </summary>
        /// <param name="context"></param>
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            //Debugger.Launch();

            var provider = context.SyntaxProvider.ForAttributeWithMetadataName(
                "游戏服务器.封包信息描述",
                static (node, _) =>
                {
                    if (node is not ClassDeclarationSyntax classNode)
                    {
                        return false;
                    }
                    if (!classNode.Modifiers.Any(SyntaxKind.PartialKeyword))
                    {
                        return false;
                    }
                    if (!IsInterface(classNode.BaseList, "游戏封包"))
                    {
                        return false;
                    }
                    return true;
                },
                (syntaxContext, _) =>
                {  
                    // 语义分析
                    if (syntaxContext.TargetSymbol is not INamedTypeSymbol typeSymbol)
                    {
                        return default;
                    }
                    // 需要获取:命名空间,类名, 类特性,字段信息,字段特性
                    // 取类特性信息
                    var classAttributes = syntaxContext.Attributes
                        .FirstOrDefault(attr => attr.AttributeClass?.Name == "封包信息描述");
                    // 取类名,命名空间,所有字段
                    var classInfo = (ClassDeclarationSyntax)syntaxContext.TargetNode;
                    // 取所有字段含有指定特性的字段,字段信息,字段特性信息
                    var fields = new List<(IFieldSymbol, AttributeData)>();
                    foreach (var member in typeSymbol.GetMembers())
                    {
                        // 此成员是字段,且字段的特性包含"封包字段描述"
                        var attributeData = member.GetAttributes()
                            .FirstOrDefault(attr => attr.AttributeClass?.Name == "封包字段描述");
                        if (member is IFieldSymbol fieldSymbol && attributeData != null)
                        {
                            fields.Add((fieldSymbol, attributeData));
                        }
                    }
                    return (classAttributes, classInfo, fields);
                });

            // 生成文件
            context.RegisterSourceOutput(provider,(sourceProductionContext, ctx) =>
            {
                if (ctx.classInfo is null)
                {
                    return;
                }

                // 封包信息描述:来源/编号/长度
                var nameArg = ctx.classAttributes.NamedArguments;
                var length = (ushort)(nameArg.FirstOrDefault(arg => arg.Key == "长度").Value.Value);
                var 编号 = (ushort)(nameArg.FirstOrDefault(arg => arg.Key == "编号").Value.Value);
                var 来源 = (int)nameArg.FirstOrDefault(arg => arg.Key == "来源").Value.Value;

                StringBuilder serializationCode = new StringBuilder();

                if (来源 == 1)
                {
                    // 序列化方法
                    serializationCode.AppendLine($"protected override ArraySegment<byte> Serialize()");
                    serializationCode.AppendLine("        {");
                    if (length == 0)
                    {
                        serializationCode.AppendLine($"            using var stream = new System.IO.MemoryStream();");
                    }
                    else
                    {
                        serializationCode.AppendLine($"            using var stream = new System.IO.MemoryStream(new byte[{length}], 0, {length}, true, true);");
                    }
                    serializationCode.AppendLine("            using var writer = new System.IO.BinaryWriter(stream);");
                    // 封包内容
                    foreach (var member in ctx.fields)
                    {
                        // 字段信息
                        var variableName = member.Item1.Name;
                        // var fieldType = getTypeStr(member.Item1.Type.SpecialType);

                        // 特性信息
                        var memvar = member.Item2.NamedArguments;
                        ushort 下标 = 0;
                        ushort 长度 = 0;
                        byte 反向 = 0;
                        byte 结束符 = 0;
                        foreach (var arg in memvar)
                        {
                            switch (arg.Key)
                            {
                                case "下标": 下标 = (ushort)arg.Value.Value;break;
                                case "长度": 长度 = (ushort)arg.Value.Value; break;
                                case "反向": 反向 = (bool)arg.Value.Value ? (byte)1 : (byte)0; break;
                                case "结束符": 结束符 = (byte)arg.Value.Value; break;
                            }
                        }

                        serializationCode.AppendLine($"            二进制.写二进制(writer, new 字段描述({下标},{长度},{反向},{结束符}), this.{variableName});");
                    }
                    // 封包编号
                    serializationCode.AppendLine($"            writer.Seek(0, SeekOrigin.Begin);");
                    serializationCode.AppendLine($"            writer.Write((ushort){编号});");
                    // 封包长度
                    if (length == 0)
                    {
                        serializationCode.AppendLine($"            writer.Write((ushort)stream.Length);");
                    }
                    serializationCode.AppendLine("            bool succ = stream.TryGetBuffer(out var array);");
                    serializationCode.AppendLine("            if (!succ)");
                    serializationCode.AppendLine("            {");
                    serializationCode.AppendLine("                array = new ArraySegment<byte>(stream.ToArray());");
                    serializationCode.AppendLine("            }");
                    serializationCode.AppendLine("            return array;");
                    serializationCode.AppendLine("        }");

                }
                else
                {
                    //StringBuilder deserializationCode = new StringBuilder();
                    // 反序列化方法
                    serializationCode.AppendLine($"protected override void Deserialize(ArraySegment<byte> data)");
                    serializationCode.AppendLine("        {");
                    if (ctx.fields.Count > 0)
                    {
                        serializationCode.AppendLine("            using var stream = new System.IO.MemoryStream(data.Array, data.Offset, data.Count);");
                        serializationCode.AppendLine("            using var reader = new System.IO.BinaryReader(stream);");
                    }

                    foreach (var member in ctx.fields)
                    {
                        // 字段信息
                        var variableName = member.Item1.Name;
                        // var fieldType = getTypeStr(member.Item1.Type.SpecialType);

                        // 特性信息
                        var memvar = member.Item2.NamedArguments;
                        ushort 下标 = 0;
                        ushort 长度 = 0;
                        byte 反向 = 0;
                        byte 结束符 = 0;
                        foreach (var arg in memvar)
                        {
                            switch (arg.Key)
                            {
                                case "下标": 下标 = (ushort)arg.Value.Value; break;
                                case "长度": 长度 = (ushort)arg.Value.Value; break;
                                case "反向": 反向 = (bool)arg.Value.Value ? (byte)1 : (byte)0; break;
                                case "结束符": 结束符 = (byte)arg.Value.Value; break;
                            }
                        }

                        // 反序列化
                        serializationCode.AppendLine($"            二进制.读二进制(reader, new 字段描述({下标},{长度},{反向},{结束符}), out this.{variableName});");
                    }

                    serializationCode.AppendLine("        }");
                }

                // 取命名空间名
                var namespaceName = ctx.classInfo.Ancestors().OfType<NamespaceDeclarationSyntax>().First().Name.ToString();
                // 取类名称
                var className = ctx.classInfo.Identifier.ToString();

                var source = $$"""
// <auto-generated/>
using System;
using 游戏服务器.工具类;

namespace {{namespaceName}}
{
    internal partial class {{className}} : 游戏封包
    {
        {{serializationCode}}
    }
}
""";

                var filename = 来源 == 1 ? $"{className}.server.g.cs" : $"{className}.client.g.cs";
                sourceProductionContext.AddSource(filename, source);
            });
        }

        private static bool IsInterface(BaseListSyntax bases, string specifiedClass)
        {
            if (bases == null || specifiedClass == null) return false;
            // 遍历基类列表中的每个类型
            foreach (var baseType in bases.Types)
            {
                // 获取基类的名字
                var baseTypeName = baseType.Type.GetFirstToken().ValueText;
                // 检查是否与指定类匹配
                if (baseTypeName == specifiedClass)
                {
                    // 找到了指定的类
                    return true; // 如果只需要找到第一个匹配项,就中断循环
                }
            }
            return false;
        }
    }
}

源生成器生成的文件,其中二进制.写二进制()二进制.写二进制()是对BinaryWriter的基本类型的操作。

// 同步个人信息.server.g.cs 服务端序列化方法
// <auto-generated/>
using System;
using 游戏服务器.工具类;

namespace 游戏服务器.网络类.服务器封包
{
    internal partial class 同步个人信息 : 游戏封包
    {
        protected override ArraySegment<byte> Serialize()
        {
            using var stream = new System.IO.MemoryStream(new byte[32], 0, 32, true, true);
            using var writer = new System.IO.BinaryWriter(stream);
            二进制.写二进制(writer, new 字段描述(2,4,0,0), this.对象编号);
            二进制.写二进制(writer, new 字段描述(6,4,0,0), this.对象等级);
            二进制.写二进制(writer, new 字段描述(10,4,0,0), this.最大体力);
            二进制.写二进制(writer, new 字段描述(14,4,0,0), this.最大魔力);
            二进制.写二进制(writer, new 字段描述(18,4,0,0), this.当前体力);
            二进制.写二进制(writer, new 字段描述(22,4,0,0), this.当前魔力);
            二进制.写二进制(writer, new 字段描述(26,2,0,0), this.横向坐标);
            二进制.写二进制(writer, new 字段描述(28,2,0,0), this.纵向坐标);
            二进制.写二进制(writer, new 字段描述(30,2,0,0), this.坐标高度);
            writer.Seek(0, SeekOrigin.Begin);
            writer.Write((ushort)194);
            bool succ = stream.TryGetBuffer(out var array);
            if (!succ)
            {
                array = new ArraySegment<byte>(stream.ToArray());
            }
            return array;
        }

    }
}

// 接取普通任务.client.g.cs 客户端反序列号
// <auto-generated/>
using System;
using 游戏服务器.工具类;

namespace 游戏服务器.网络类.客户端封包
{
    internal partial class 接取普通任务 : 游戏封包
    {
        protected override void Deserialize(ArraySegment<byte> data)
        {
            using var stream = new System.IO.MemoryStream(data.Array, data.Offset, data.Count);
            using var reader = new System.IO.BinaryReader(stream);
            二进制.读二进制(reader, new 字段描述(2,4,0,0), out this.任务编号);
            二进制.读二进制(reader, new 字段描述(6,4,0,0), out this.点击参数2);
            二进制.读二进制(reader, new 字段描述(10,4,0,0), out this.点击参数3);
        }

    }
}

另一种生成源代码的方案(T4模板)

T4模板通常用于生成逻辑不是很复杂的源代码,大量重复的代码,例如数据库ORM模型

手动生成源代码

手动生成源代码就是构造代码字符串,然后保存为cs文件到指定文件夹中。

扩展阅读

GITHUB:C#源代码生成器和相关资源的列表:文章、演讲、演示
GITHUB:官方:源生成器指南/源生成器设计文档
C#源代码生成器深入讲解一
GITHUB:官方:增量生成器指南/增量源生成器设计文档
IIncrementalGenerator系列教程
IIncrementalGenerator视频教程
源生成器(二):高效轻量的增量生成器
学习Source Generators之IIncrementalGenerator
微软文档:SyntaxNode类
Roslyn 入门:使用 Visual Studio 的语法可视化(Syntax Visualizer)窗格查看和了解代码的语法树
Roslyn 语法树中的各种语法节点及每个节点的含义
微软文档:语法分析入门-SyntaxNode操作

此处评论已关闭