.NET 5.0 Source Generators

.NET 5.0 Source Generators

Source Generators 目前还属于 Preview 状态

随着.NET 5 正式版的发布,我相信.NET 社区以及使用.NET 技术栈的工程师们已迎来新血液,.NET 5 发布了诸多新功能,例如:Top-level programs,这让我们可以像写脚本语言一样,不用需要声明命名空间和类,直接开写。新一代 GC,更高的编译性能,Dictionary<K,V>的匿名声明支持等等,其中有一项功能我在预览版就已经用于我的项目,并且解决了非常大的痛点,这项功能就是 Source Generators

简介

Source Generators 直接翻译就是代码生成器,源代码生成器是一段在编译过程中运行的代码,可以检查您的程序以生成与其他代码一起编译。
使用 Source Generators,可以做到这些事情:

  • 检索一个表示所有正在编译的用户代码的 Compilation 对象。可以检查此对象,并且您可以编写与正在编译的代码的语法和语义模型一起使用的代码。
  • 生成可在编译过程中添加到 Compilation 对象的 C#源文件。换句话说,可以在编译代码时提供其他源代码作为编译的输入

应用方向

拿 ASP.NET Core 举例,启动一个 ASP.NET Core 应用时,首先会通过运行时反射来发现 Controllers、Services 等的类型定义,然后在请求管道中需要通过运行时反射获取其构造函数信息以便于进行依赖注入。

然而运行时反射开销很大,即使缓存了类型签名,对于刚刚启动后的应用也无任何帮助作用,而且不利于做 AOT 编译。

什么是 AOT

在计算机科学中,提前编译(AOT 编译)是编译更高级编程语言(如 C 或 C ++)或中间代码(如 Java 字节码或.NET Framework 通用中间语言(CIL)代码),转换为本机(系统相关的)机器代码,以便生成的二进制文件可以本机执行的行为。

这是 Wiki 的解释,简单而言就是,AOT 会把咱们写的高级语言编译成介乎机器代码之间的一层语言,并且会把我们的代码进行优化。

Source Generators 和 AOT

Source Generators 的另一个特点是,它们可以帮助消除基于链接器和 AOT(提前)编译优化的主要障碍。许多框架和库都大量使用反射或反射发射,例如 System.Text.Json,System.Text.RegularExpressions 以及诸如 ASP.NET Core 和 WPF 之类的框架,它们在运行时从用户代码中发现和/或发出类型。

人们在使用许多顶级 NuGet 软件包时会大量使用反射来在运行时发现类型。集成这些软件包对于大多数.NET 应用程序至关重要,因此,代码的“可链接性”和使用 AOT 编译器优化的能力将受到极大影响。

项目实践

作为.NET 程序员,大家一定写过 WPF 或者 UWP,在 ViewModel 中为了使属性变更可被发现,需要实现 INotifyPropertyChanged 接口,并且在每一个需要的属性的 Setter 处触发属性更改事件

他大概是这个样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private string _title;
public string Title
{
get => _title;
set
{
_title = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Title)));
}
}
}

然后视图绑定

1
<TextBlock Text="{Binding Title}"></TextBlock>

因为 Title 字段实现了 PropertyChanged,所以我们在 ViewModel 中改变 Title 的值,那视图中的 TextBlock 的 Text 也会实时更新。看起来非常不错,视图和 VM 分离,但是问题来了,在实际的业务中,我们的 GUI 会非常复杂,需要非常多的字段去驱动这个 GUI,那么为了响应更改通知,我们的每一个字段都需要这样封装,这非常非常痛苦,虽然有一些库可以帮我解决痛点,比如 PropertyChange,只需要一个 Attribute,即可解决声明问题,他的写法类似于这样:

1
2
3
4
5
6
[AddINotifyPropertyChangedInterface]
class ViewModel
{
public string Title;
public int Count;
}

这样在编译的时候这个框架会自动加入 IL 编织代码,去帮我们实现 PropertyChanged 的属性声明

但是对于喜欢造轮子的程序员来说,我们还喜欢自己造一个,那接下来我们就会利用.NET 5 的新特性 Source Generators 来制造这么一个东西,帮我们自动封装响应字段。

前期准备和检查

为了使用 Source Generators,我们需要引入两个包

  • Microsoft.CodeAnalysis.CSharp.Workspaces
  • Microsoft.CodeAnalysis.Analyzers

编写 AutoNotify Source Generators

我们需要建立一个标准的.NET Standard 2.0 库,然后编写如下代码。

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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace MySourceGenerator
{
[Generator]
public class AutoNotifyGenerator : ISourceGenerator
{
private const string attributeText = @"
using System;
namespace AutoNotify
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
sealed class AutoNotifyAttribute : Attribute
{
public AutoNotifyAttribute()
{
}
public string PropertyName { get; set; }
}
}
";

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

public void Execute(GeneratorExecutionContext context)
{
context.AddSource("AutoNotifyAttribute", SourceText.From(attributeText, Encoding.UTF8));

if (!(context.SyntaxReceiver is SyntaxReceiver receiver))
return;

CSharpParseOptions options = (context.Compilation as CSharpCompilation).SyntaxTrees[0].Options as CSharpParseOptions;
Compilation compilation = context.Compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(SourceText.From(attributeText, Encoding.UTF8), options));

INamedTypeSymbol attributeSymbol = compilation.GetTypeByMetadataName("AutoNotify.AutoNotifyAttribute");
INamedTypeSymbol notifySymbol = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");

List<IFieldSymbol> fieldSymbols = new List<IFieldSymbol>();
foreach (FieldDeclarationSyntax field in receiver.CandidateFields)
{
SemanticModel model = compilation.GetSemanticModel(field.SyntaxTree);
foreach (VariableDeclaratorSyntax variable in field.Declaration.Variables)
{
IFieldSymbol fieldSymbol = model.GetDeclaredSymbol(variable) as IFieldSymbol;
if (fieldSymbol.GetAttributes().Any(ad => ad.AttributeClass.Equals(attributeSymbol, SymbolEqualityComparer.Default)))
{
fieldSymbols.Add(fieldSymbol);
}
}
}

foreach (IGrouping<INamedTypeSymbol, IFieldSymbol> group in fieldSymbols.GroupBy(f => f.ContainingType))
{
string classSource = ProcessClass(group.Key, group.ToList(), attributeSymbol, notifySymbol, context);
context.AddSource($"{group.Key.Name}_autoNotify.cs", SourceText.From(classSource, Encoding.UTF8));
}
}

private string ProcessClass(INamedTypeSymbol classSymbol, List<IFieldSymbol> fields, ISymbol attributeSymbol, ISymbol notifySymbol, GeneratorExecutionContext context)
{
if (!classSymbol.ContainingSymbol.Equals(classSymbol.ContainingNamespace, SymbolEqualityComparer.Default))
{
return null; //TODO: issue a diagnostic that it must be top level
}

string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();

StringBuilder source = new StringBuilder($@"
namespace {namespaceName}
{{
public partial class {classSymbol.Name} : {notifySymbol.ToDisplayString()}
{{
");


if (!classSymbol.Interfaces.Contains(notifySymbol))
{
source.Append("public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;");
}


foreach (IFieldSymbol fieldSymbol in fields)
{
ProcessField(source, fieldSymbol, attributeSymbol);
}

source.Append("} }");
return source.ToString();
}

private void ProcessField(StringBuilder source, IFieldSymbol fieldSymbol, ISymbol attributeSymbol)
{

string fieldName = fieldSymbol.Name;
ITypeSymbol fieldType = fieldSymbol.Type;


AttributeData attributeData = fieldSymbol.GetAttributes().Single(ad => ad.AttributeClass.Equals(attributeSymbol, SymbolEqualityComparer.Default));
TypedConstant overridenNameOpt = attributeData.NamedArguments.SingleOrDefault(kvp => kvp.Key == "PropertyName").Value;

string propertyName = chooseName(fieldName, overridenNameOpt);
if (propertyName.Length == 0 || propertyName == fieldName)
{
//TODO: issue a diagnostic that we can't process this field
return;
}

source.Append($@"
public {fieldType} {propertyName}
{{
get
{{
return this.{fieldName};
}}

set
{{
this.{fieldName} = value;
this.PropertyChanged?.Invoke(this, new System.ComponentModel. PropertyChangedEventArgs(nameof({propertyName})));
}}
}}");

string chooseName(string fieldName, TypedConstant overridenNameOpt)
{
if (!overridenNameOpt.IsNull)
{
return overridenNameOpt.Value.ToString();
}

fieldName = fieldName.TrimStart('_');
if (fieldName.Length == 0)
return string.Empty;

if (fieldName.Length == 1)
return fieldName.ToUpper();

return fieldName.Substring(0, 1).ToUpper() + fieldName.Substring(1);
}

}

class SyntaxReceiver : ISyntaxReceiver
{
public List<FieldDeclarationSyntax> CandidateFields { get; } = new List<FieldDeclarationSyntax>();

/
/
/
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{

if (syntaxNode is FieldDeclarationSyntax fieldDeclarationSyntax
&& fieldDeclarationSyntax.AttributeLists.Count > 0)
{
CandidateFields.Add(fieldDeclarationSyntax);
}
}
}
}
}

引入依赖,进行测试

我们需要把上面编写的 .NET 标准库引入到自己的项目中,引入项目后需要编辑项目文件,然后加入输出项目类型为 Analyzer ,这样才可以识别我们写的 Attribute ,然后标记引用输出编译为 false。

1
OutputItemType="Analyzer" ReferenceOutputAssembly="false"

然后进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public partial class ExampleViewModel
{
[AutoNotify]
private string _text = "private field text";

[AutoNotify(PropertyName = "Count")]
private int _amount = 5;
}

public static class UseAutoNotifyGenerator
{
public static void Run()
{
ExampleViewModel vm = new ExampleViewModel();
string text = vm.Text;
Console.WriteLine($"Text = {text}");
int count = vm.Count;
Console.WriteLine($"Count = {count}");
vm.PropertyChanged += (o, e) => Console.WriteLine($"Property {e.PropertyName} was changed");
vm.Text = "abc";
vm.Count = 123;
}
}

Source Generator 自动为我们生成了 Text 和 Count 字段,并且响应了 PropertyChange 通知。

评论