C#源代码生成器深入讲解上

C#源代码生成器深入讲解上

01 源代码生成器初体验

  1. 新建一个类库,一定是standard2.0版本,否则会出问题。
  2. 引用Nuget包Microsoft.CodeAnalysis.Common
  3. 新建一个类,继承自ISourceGenerator接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//一定要写,制定语言
[Generator(LanguageNames.CSharp)]
public sealed class GreetingGenerator : ISourceGenerator
{
//源代码生成器的所要生成的方法
public void Execute(GeneratorExecutionContext context)
{
//建议名称使用.g.cs
//建议使用全局命名空间global:: 为了防止诸如System和Windows.System冲突
context.AddSource("Greeting.g.cs",

$$"""
//加上这句话,告知编译器,这个文件是由源代码生成器生成的,
//防止编译器进行代码分析,避免不必要的编译器警告
//<auto-generated>
namespace GreetingTest;

//配置预处理指令
#nullable enable
//告知源代码生成器生成的代码
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]
//告知由哪个源代码生成器生成的代码
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{nameof(GreetingGenerator)}}","1.0")]
public static class Greeting
{
//告知源代码生成器生成的代码
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]
//告知由哪个源代码生成器生成的代码
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("{{nameof(GreetingGenerator)}}","1.0")]
public static void SayHello(string name)
{
global::System.Console.WriteLine($"Hello, World {name}!");
}
}
"""
);
}
//源代码生成器本身的初始化方法
public void Initialize(GeneratorInitializationContext context)
{

}
}

注意事项

  • 在使用某些方法或者特性时,最好写全命名空间,并用global::命名空间限定符
  • 文件名称建议使用.g.cs后缀
  • 建议在开头增加<auto-generated>注释
  • 可以使用原生字符串符号"""三个双引号,以及双内插符号$$,这样可以使用{{}}来进行内插
  1. 建立一个控制台项目,并引用刚才的类库,要加上OutputItemType和 ReferenceOutAssembly

    1
    2
    3
    4
    <ItemGroup>
    <!--ReferenceOutAssembly设定false,表示不会将生成器的作为引用,而是将分析器生成的代码。-->
    <ProjectReference Include="..\SourceGeneratorConsole.Generator\SourceGeneratorConsole.Generator.csproj" OutputItemType="Analyzer" ReferenceOutAssembly="false" />
    </ItemGroup>
  2. 使用源代码生成器,使用生成器中所生成的方法。

1
2
using GreetingTest;
Greeting.SayHello("李四");

02 使用分部类型

很多时候,不需要源代码生成器生成完整的类型,而是和主程序交互,分别形成一定的代码,此时可以使用分部类型来实现。

  1. 在上一章节中的控制台项目中增加一个类
1
2
3
4
5
6
7
namespace GreetingTest
{
public static partial class GreetingUsePartialClass
{
public static partial void SayHello(string name);
}
}
  1. 修改上一章节中源代码生成器类库项目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
namespace SourceGeneratorConsole.Generator;

[Generator(LanguageNames.CSharp)]
public sealed class GreetingGenerator : ISourceGenerator
{
//源代码生成器的所要生成的方法
public void Execute(GeneratorExecutionContext context)
{
//修改为GreetingUsePartialClass.g.cs,和控制台中定义的名称相对应
context.AddSource("GreetingUsePartialClass.g.cs",

$$"""
//<auto-generated>
namespace GreetingTest;
//分部类可以省略public static等,只要在一个地方定义了就可以了
partial class GreetingUsePartialClass
{
//分部方法必须写全
public static partial void SayHello(string name)
{
global::System.Console.WriteLine($"Hello, World {name}!");
}
}
"""
);
}
//源代码生成器本身的初始化方法
public void Initialize(GeneratorInitializationContext context)
{

}
}
  1. 在控制台应用中调用
1
2
3
4
5
static void Main(string[] args)
{
GreetingUsePartialClass.SayHello("Source Generator");
Console.Read();
}

03 使用SyntaxReceiver属性

上一章节中,在源代码生成器中将类名和方法名写进去了,源代码生成器往往是应用在不同的项目中,类型名和方法名都不是固定的,所以要动态的修改名称,这就要用到了SyntaxContextReceiver属性。

  1. 在上一章节中的源代码生成器文件中,写一个SyntaxReceiver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//file只在本文件可以用,跟internal一样是访问修饰符
//提供一个语法搜索类型,这个类型只用于寻找主要项目里的指定语法满足条件部分
file sealed class SyntaxReceiver:ISyntaxReceiver
{
//表示一个方法的语法节点,这个方法就是用到的SayHello方法,这个方法的返回值是void,静态、partial
public MethodDeclarationSyntax? SayHelloToMethodSyntaxNode {private set; get; }

public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
//检查syntaxNode是否是类型定义,且Modifiers属性不为空
if (syntaxNode is not TypeDeclarationSyntax { Modifiers:var modifiers and not [] })
{
return;
}
//如果类型不包含partial关键字
if (!modifiers.Any(SyntaxKind.PartialKeyword))
{
return;
}
//判断子节点,也就是类型内部的成员是否有partial
foreach (var childrenNode in syntaxNode.ChildNodes())
{
// 判断当前语法节点是否是一个合理的方法定义。
// 该方法名为 SayHelloTo
// 该方法返回一个 void 类型。
// 该方法还需要额外的修饰符(一会儿要用来判断 partial 关键字)。
if (childrenNode is not MethodDeclarationSyntax {
Identifier:{ ValueText: "SayHello" },
ReturnType:PredefinedTypeSyntax{
Keyword.RawKind:(int)SyntaxKind.VoidKeyword},
Modifiers:var childrenModifiers and not []
} possibleMethodDeclarationSyntax
)
{
continue;
}
// 该方法必须有 partial 关键字的存在。
if (!childrenModifiers.Any(SyntaxKind.PartialKeyword))
{
continue;
}
if (SayHelloToMethodSyntaxNode is null)
{
SayHelloToMethodSyntaxNode = possibleMethodDeclarationSyntax;
return;
}
}
}
}
  1. 修改属性生成器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[Generator(LanguageNames.CSharp)]
public sealed class GreetingGenerator : ISourceGenerator
{
//源代码生成器的所要生成的方法
public void Execute(GeneratorExecutionContext context)
{

var syntaxReceiver = (SyntaxReceiver)context.SyntaxReceiver;
//{}为属性模式匹配,在此处表示不为空,not {}表示为空
if (syntaxReceiver.SayHelloToMethodSyntaxNode is not {} methodSyntax)
{
return;
}
var type = methodSyntax.Ancestors().OfType<TypeDeclarationSyntax>().First();
var typeName = type.Identifier.ValueText;

//建议名称使用.g.cs
//建议使用全局命名空间global:: 为了防止诸如System和Windows.System冲突
context.AddSource($"{typeName}.g.cs",

$$"""
//加上这句话,告知编译器,这个文件是由源代码生成器生成的,
//防止编译器进行代码分析,避免不必要的编译器警告
//<auto-generated>
namespace GreetingTest;
partial class {{typeName}}
{
public static partial void SayHello(string name)
{
global::System.Console.WriteLine($"Hello, World {name}!");
}
}
"""
);
}
//源代码生成器本身的初始化方法
public void Initialize(GeneratorInitializationContext context)
{
//注册一个语法的通知类型,作用是运行源代码生成器的时候,去检查固定语法是否满足条件
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
}

在Initialize中返回刚才创建的类, Execute方法中获得相应的类名称。

  1. 调用
1
2
3
4
5
static void Main(string[] args)
{
GreetingUsePartialClass.SayHello("Source Generator");
Console.Read();
}

image-20240323111749071

04 调试源代码生成器

源代码生成器是在编译阶段中自动生成,一般无法调试,这时可以在源代码生成器中的Initialize方法中加上

1
2
3
4
5
6
//添加调试器,如果程序没有调试器的时候就启动
//如果用了多个源代码生成器,只要有一个配置了这个,也可以调试其他的
//if (!Debugger.IsAttached)
//{
// Debugger.Launch();
//}

05 ISyntaxContextReceiver属性

上面是已知有了SayHello的方法,假设不知道是什么方法名,如何使用源代码生成器,本节借助特性来实现

  1. 在主项目中声明特性,一般都是放在主项目中,因为在主项目中的引用其他项目的设置中已设置了OutputItemType="Analyzer" ReferenceOutAssembly="false",这表示不会将生成器的作为引用,而是将分析器生成的代码,如果将特性定义在生成器中,主项目引用不到特性定义
1
2
3
4
5
namespace SourceGeneratorConsole
{
[AttributeUsage(AttributeTargets.Method,AllowMultiple =false,Inherited =false)]
public sealed class SayHelloAttribute:Attribute; //新语法,特性可以直接使用分号结束
}
  1. 在主项目中声明一个分部方法
1
2
3
4
5
6
7
8
namespace SourceGeneratorConsole
{
public partial class GreetingUseAttribute
{
[SayHello]
public static partial void SayHi(string name);
}
}
  1. 按照上面的流程创建源代码生成器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
namespace SourceGeneratorConsole.UseAttributes
{
[Generator(LanguageNames.CSharp)]
public sealed class GreetingGenerator : ISourceGenerator
{

public void Execute(GeneratorExecutionContext context)
{
if (context is not { SyntaxContextReceiver: SyntaxContextReceiver { FoundSymbolPairs: var methodSymbols and not [] } })
{
return;
}
foreach (var methodSymbol in methodSymbols)
{
//获取对应的class类型
var containingType = methodSymbol.ContainingType;
//获取完整命名空间名称,包括global
var namespaceName = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var namspaceString = namespaceName["global::".Length..];
//查看到底是什么类型
var typeKindString = containingType.TypeKind switch
{
TypeKind.Class => "class",
TypeKind.Struct => "struct",
TypeKind.Interface => "interface",
_ => throw new InvalidOperationException("错误类型")
} ;

var syntaxNode = (MethodDeclarationSyntax)methodSymbol.DeclaringSyntaxReferences[0].GetSyntax();

context.AddSource(
$"{containingType.Name}.g.cs", $$"""
//加上这句话,告知编译器,这个文件是由源代码生成器生成的,
//防止编译器进行代码分析,避免不必要的编译器警告
//<auto-generated>
namespace {{namspaceString}};
partial {{typeKindString}} {{containingType.Name}}
{
{{syntaxNode.Modifiers}} void {{methodSymbol.Name}}(string name)
{
global::System.Console.WriteLine($"Hello, World {name}!");
}
}
""");
}
}

public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new SyntaxContextReceiver());
}
}

//带有语法上下文的接口,获取所有标记了SayHelloAttribute的方法
file sealed class SyntaxContextReceiver : ISyntaxContextReceiver
{
//表示找到方法的定义信息
public List<IMethodSymbol> FoundSymbolPairs { get; } = new();
public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
{
//判别当前语法是否为方法
//如果是,还要是分部方法
//如果满足,获取编译信息和语义信息
if (context is not { Node: MethodDeclarationSyntax { Modifiers: var modifiers and not [] } methodSytax, SemanticModel: { Compilation: var compolation } semanticModel })
{
return;
}

//上面的替代方式
// var node = context.Node;//语法节点
// if (node is not MethodDeclarationSyntax methodSyntax)
// {
// return;
// }
// var semanticModel= context.SemanticModel;//具有更多语义信息的模型
// var compolation= semanticModel.Compilation;//编译信息

if (!modifiers.Any(SyntaxKind.PartialKeyword))
{
return;
}
var attribute = compolation.GetTypeByMetadataName("SourceGeneratorConsole.SayHelloAttribute")!;//通过全名称
var methodSymbol = semanticModel.GetDeclaredSymbol(methodSytax)!;//获取定义信息
//判断是否有特性,要用SymbolEqualityComparer.Default.Equals来进行比较
bool hasAttribute = methodSymbol.GetAttributes().Any(e => SymbolEqualityComparer.Default.Equals(e.AttributeClass, attribute));
if (!hasAttribute)
{
return;
}
//方法必须返回void,而且有一个string参数
if (methodSymbol is not { ReturnsVoid: true, Parameters: [{ Type.SpecialType:SpecialType.System_String}] })
{
return;
}

FoundSymbolPairs.Add(methodSymbol);
}
}
}
  1. 使用源代码生成器
1
GreetingUseAttribute.SayHi("使用特性的属性生成器");

image-20240323111809220

06 自定义MyTuble类型实战

我们经常用到Func泛型委托,该泛型委托最多支持16个参数和一个返回值,因为泛型定义没有类似于可变参数的功能,对于不同数量的泛型参数一定要定义同数量的泛型定义。类似于下面这样。

1
2
3
4
5
6
7
8
9
Func<TResult>
Func<T, TResult>
Func<T1, T2, TResult>
Func<T1, T2, T3, TResult>
Func<T1, T2, T3, T4, TResult>
Func<T1, T2, T3, T4, T5, TResult>
Func<T1, T2, T3, T4, T5, T6, TResult>
Func<T1, T2, T3, T4, T5, T6, T7, TResult>
Func<T1, T2, T3, T4, T5, T6, T7, T8, TResult>

我们仿照Func泛型委托自定义一个MyTuple泛型类型

  1. 先定义一个MyTuple模板,这是一个定义了2个泛型参数的MyTuple类型,根据该模板要定义支持多个泛型参数的MyTuple类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public readonly struct MyTuple<T1, T2>(T1 value1, T2 value2) : 
IEqualityOperators<MyTuple<T1, T2>, MyTuple<T1, T2>, bool>
where T1 : IEqualityOperators<T1, T1, bool>
where T2 : IEqualityOperators<T2, T2, bool>
{
public T1 Value1 { get; } = value1;
public T2 Value2 { get; } = value2;

public static bool operator ==(MyTuple<T1, T2> left, MyTuple<T1, T2> right)
{
return left.Value1 == right.Value1 && left.Value2 == right.Value2;
}
public static bool operator !=(MyTuple<T1, T2> left, MyTuple<T1, T2> right)
{
return !(left == right);
}
}
  1. 写一个源代码生成器,根据上面的模板进行改造,自动生成含有1-8个泛型参数的MyTuple类型,其根本原理就是字符串的操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
[Generator(LanguageNames.CSharp)]
public class MyTupleGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
var list = new List<string>();
for (int i = 2; i <= 8; i++)
{
var indices = Enumerable.Range(1, i).ToArray();
var genericArgs = $"<{string.Join(", ",
from index in indices
select $"T{index}" )}>";
var ctorArgs = string.Join(", ",
from index in indices
select $"T{index} value{index}");
var constraints = string.Join("\r\n\t",
from index in indices
select $"where T{index}: global::System.Numerics.IEqualityOperators<T{index},T{index},bool>");
var properties = string.Join("\r\n\t",
from index in indices
select $"public T{index} Value{index} {{ get; }}=value{index};");

var comparison = string.Join(" && ", from index in indices
select $"left.Value{index} == right.Value{index}");

list.Add($$"""
public readonly struct MyTuple{{genericArgs}}({{ctorArgs}}):
global::System.Numerics.IEqualityOperators<MyTuple{{genericArgs}},MyTuple{{genericArgs}},bool>
{{constraints}}
{
{{properties}}

public static bool operator ==(MyTuple{{genericArgs}} left, MyTuple{{genericArgs}} right)
{
return {{comparison}};
}
public static bool operator !=(MyTuple{{genericArgs}} left, MyTuple{{genericArgs}} right)
{
return !(left == right);
}
}
""");
}

context.AddSource("MyTuple.g.cs", $$"""
//<auto-generated/>
namespace System;
{{string.Join("\r\n\r\n",list)}}
""");
}

public void Initialize(GeneratorInitializationContext context)
{
}
}
  1. 主项目引用源代码生成器后,使用MyTuple
1
2
3
4
5
6
7
8
9
10
var myTuple1 = new MyTuple<int, double>(1, 3.0);
var myTuple2 = new MyTuple<int, double>(1, 3.0);
var myTuple3 = new MyTuple<int, double,float>(1, 3.0,5.6f);
var myTuple4 = new MyTuple<int, double,float>(1, 3.0,5.6f);
var myTuple5 = new MyTuple<int, double,float,uint>(1, 3.0,5.6f,8);
var myTuple6 = new MyTuple<int, double,float,uint>(1, 3.0,5.6f,7);

Console.WriteLine(myTuple2 == myTuple1);
Console.WriteLine(myTuple4 == myTuple3);
Console.WriteLine(myTuple6 == myTuple5);

image-20240323111821486

07AdditionalFiles的使用

上一章节中,我们在直接定义了MyTuple时设置最大泛型参数数量为8,如果我们需要根据需要来设置最大泛型参数数量,则可以在主项目中增加一个配置文件,文件中对此进行设置,并在源代码生成器中使用GeneratorExecutionContext的AdditionalFiles属性来处理非代码文件

  1. 在主项目中增加一个文件,本次案例增加一个MyTupleMaxTypeArgumentCount.txt文件,在该文件中写入4。
  2. 在主项目配置中,增加
1
2
3
<ItemGroup>
<AdditionalFiles Include="MyTupleMaxTypeArgumentCount.txt"/>
</ItemGroup>
  1. 在06章节中源代码基础上,增加读取本地文件功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[Generator(LanguageNames.CSharp)]
public class MyTupleGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
var maxCount = 8;
//读取本地文件
var additionalFiles = context.AdditionalFiles;
if (additionalFiles is [{ Path: var path }])
{
var result = File.ReadAllText(path);
var regex = new Regex(@"\d+");
if (regex.Match(result) is { Success:true,Value:var v} && int.TryParse(v,out var value) && value is >=2 and <=8)
{
maxCount = value;
}
}
var list = new List<string>();
for (int i = 2; i <= maxCount; i++)
{
......//忽略,参考06章节
}
......//忽略,参考06章节
}
}

08自定义编译器诊断信息

在进行编译时,编译器会自动给出编译信息供用户查看,通常编译器诊断信息如下所示。

image-20240323111829529

由于源代码生成器会自动后台生成,所以给出诊断信息是十分必要的。本章节根据07章节中的章节,给出自定义编译器诊断信息的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[Generator(LanguageNames.CSharp)]
public class MyTupleGenerator : ISourceGenerator
{
//首先创建一个DiagnosticDescriptor
static readonly DiagnosticDescriptor descriptor = new DiagnosticDescriptor(
"SG0001",//代码,可自定义,格式一般为 两个字母+四位数字
"本地配置文件错误",
"源代码生成器生成成功,但本地配置文件有错误。{0}","SourceGenerator", //此处可以用占位符
DiagnosticSeverity.Warning,//提示类别
true,
"源代码生成器生成成功,但本地配置文件有错误。");

public void Execute(GeneratorExecutionContext context)
{
var maxCount = 8;
//读取本地文件
var additionalFiles = context.AdditionalFiles;
if (additionalFiles is [{ Path: var path }])
{
var result = File.ReadAllText(path);
var regex = new Regex(@"\d+");
var match = regex.Match(result);
if(!match.Success)
{
//给出编译器信息,后面的文字则是在descriptor中流出的占位符
context.ReportDiagnostic(Diagnostic.Create(descriptor,Location.None, "配置文件的内容并不是一个数字")); //此处不能return,因为此处不成立要使用默认值maxCount = 8,采用goto语句
goto nextStep;
}
var v = match.Value;
if (!int.TryParse(v,out var value))
{
context.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, "数字过大"));
goto nextStep;
}
if (value is not >=2 and <=8)
{
context.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, "数字只能在[2,8]"));
goto nextStep;
}
maxCount = value;
}
//此处利用标签进行跳转
nextStep:
var list = new List<string>();
for (int i = 2; i <= maxCount; i++)
{......//忽略,参考06章节
}
......//忽略,参考06章节
}
}

随便改一下MyTupleMaxTypeArgumentCount.txt里面的内容为非数字类型,则会收到

image-20240323111837874

作者

步步为营

发布于

2024-03-23

更新于

2025-03-15

许可协议