前言 今天 .NET 官方博客宣佈 C 9 Source Generators 第一個預覽版發佈,這是一個用戶已經喊了快 5 年特性,今天終於發佈了。 簡介 Source Generators 顧名思義代碼生成器,它允許開發者在代碼編譯過程中獲取查看用戶代碼並且生成新的 C 代碼參與編譯過程,並且可 ...
前言
今天 .NET 官方博客宣佈 C# 9 Source Generators 第一個預覽版發佈,這是一個用戶已經喊了快 5 年特性,今天終於發佈了。
簡介
Source Generators 顧名思義代碼生成器,它允許開發者在代碼編譯過程中獲取查看用戶代碼並且生成新的 C# 代碼參與編譯過程,並且可以很好的與代碼分析器集成提供 Intellisense、調試信息和報錯信息,可以用它來做代碼生成,因此也相當於是一個加強版本的編譯時反射。
使用 Source Generators,可以做到這些事情:
- 獲取一個 Compilation 對象,這個對象表示了所有正在編譯的用戶代碼,你可以從中獲取 AST 和語義模型等信息
- 可以向 Compilation 對象中插入新的代碼,讓編譯器連同已有的用戶代碼一起編譯
Source Generators 作為編譯過程中的一個階段執行:
編譯運行 -> [分析源代碼 -> 生成新代碼] -> 將生成的新代碼添加入編譯過程 -> 編譯繼續。
上述流程中,中括弧包括的內容即為 Source Generators 所參與的階段和能做到的事情。
作用
.NET 明明具備運行時反射和動態 IL 織入功能,那這個 Source Generators 有什麼用呢?
編譯時反射 - 0 運行時開銷
拿 ASP.NET Core 舉例,啟動一個 ASP.NET Core 應用時,首先會通過運行時反射來發現 Controllers、Services 等的類型定義,然後在請求管道中需要通過運行時反射獲取其構造函數信息以便於進行依賴註入。然而運行時反射開銷很大,即使緩存了類型簽名,對於剛剛啟動後的應用也無任何幫助作用,而且不利於做 AOT 編譯。
Source Generators 將可以讓 ASP.NET Core 所有的類型發現、依賴註入等在編譯時就全部完成並編譯到最終的程式集當中,最終做到 0 運行時反射使用,不僅利於 AOT 編譯,而且運行時 0 開銷。
除了上述作用之外,gRPC 等也可以利用此功能在編譯時織入代碼參與編譯,不需要再利用任何的 MSBuild Task 做代碼生成啦!
另外,甚至還可以讀取 XML、JSON 直接生成 C# 代碼參與編譯,DTO 編寫全自動化都是沒問題的。
AOT 編譯
Source Generators 的另一個作用是可以幫助消除 AOT 編譯優化的主要障礙。
許多框架和庫都大量使用反射,例如System.Text.Json、System.Text.RegularExpressions、ASP.NET Core 和 WPF 等等,它們在運行時從用戶代碼中發現類型。這些非常不利於 AOT 編譯優化,因為為了使反射能夠正常工作,必須將大量額外甚至可能不需要的類型元數據編譯到最終的原生映像當中。
有了 Source Generators 之後,只需要做編譯時代碼生成便可以避免大部分的運行時反射的使用,讓 AOT 編譯優化工具能夠更好的運行。
例子
INotifyPropertyChanged
寫過 WPF 或 UWP 的都知道,在 ViewModel 中為了使屬性變更可被髮現,需要實現 INotifyPropertyChanged
介面,並且在每一個需要的屬性的 setter
處除法屬性更改事件:
class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private string _text;
public string Text
{
get => _text;
set
{
_text = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Text)));
}
}
}
當屬性多了之後將會非常繁瑣,先前 C# 引入了 CallerMemberName
用於簡化屬性較多時候的情況:
class MyViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private string _text;
public string Text
{
get => _text;
set
{
_text = value;
OnPropertyChanged();
}
}
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
即,用 CallerMemberName
指示參數,在編譯時自動填充調用方的成員名稱。
但是還是不方便。
如今有了 Source Generators,我們可以在編譯時生成代碼做到這一點了。
為了實現 Source Generators,我們需要寫個實現了 ISourceGenerator
並且標註了 Generator
的類型。
完整的 Source Generators 代碼如下:
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(InitializationContext context)
{
// 註冊一個語法接收器,會在每次生成時被創建
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
public void Execute(SourceGeneratorContext context)
{
// 添加 Attrbite 文本
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));
// 獲取新綁定的 Attribute,並獲取INotifyPropertyChanged
INamedTypeSymbol attributeSymbol = compilation.GetTypeByMetadataName("AutoNotify.AutoNotifyAttribute");
INamedTypeSymbol notifySymbol = compilation.GetTypeByMetadataName("System.ComponentModel.INotifyPropertyChanged");
// 遍歷欄位,只保留有 AutoNotify 標註的欄位
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)
{
// 獲取欄位符號信息,如果有 AutoNotify 標註則保存
IFieldSymbol fieldSymbol = model.GetDeclaredSymbol(variable) as IFieldSymbol;
if (fieldSymbol.GetAttributes().Any(ad => ad.AttributeClass.Equals(attributeSymbol, SymbolEqualityComparer.Default)))
{
fieldSymbols.Add(fieldSymbol);
}
}
}
// 按 class 對欄位進行分組,並生成代碼
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, SourceGeneratorContext context)
{
if (!classSymbol.ContainingSymbol.Equals(classSymbol.ContainingNamespace, SymbolEqualityComparer.Default))
{
// TODO: 必須在頂層,產生診斷信息
return null;
}
string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
// 開始構建要生成的代碼
StringBuilder source = new StringBuilder($@"
namespace {namespaceName}
{{
public partial class {classSymbol.Name} : {notifySymbol.ToDisplayString()}
{{
");
// 如果類型還沒有實現 INotifyPropertyChanged 則添加實現
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;
// 獲取 AutoNotify Attribute 和相關的數據
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: 無法處理,產生診斷信息
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)
{
// 將具有至少一個 Attribute 的任何欄位作為候選
if (syntaxNode is FieldDeclarationSyntax fieldDeclarationSyntax
&& fieldDeclarationSyntax.AttributeLists.Count > 0)
{
CandidateFields.Add(fieldDeclarationSyntax);
}
}
}
}
}
有了上述代碼生成器之後,以後我們只需要這樣寫 ViewModel 就會自動生成通知介面的事件觸發調用:
public partial class MyViewModel
{
[AutoNotify]
private string _text = "private field text";
[AutoNotify(PropertyName = "Count")]
private int _amount = 5;
}
上述代碼將會在編譯時自動生成以下代碼參與編譯:
public partial class MyViewModel : System.ComponentModel.INotifyPropertyChanged
{
public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
public string Text
{
get
{
return this._text;
}
set
{
this._text = value;
this.PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Text)));
}
}
public int Count
{
get
{
return this._amount;
}
set
{
this._amount = value;
this.PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(nameof(Count)));
}
}
}
非常方便!
使用時,將 Source Generators 部分作為一個獨立的 .NET Standard 2.0 程式集(暫時不支持 2.1),用以下方式引入到你的項目即可:
<ItemGroup>
<Analyzer Include="..\MySourceGenerator\bin\$(Configuration)\netstandard2.0\MySourceGenerator.dll" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MySourceGenerator\MySourceGenerator.csproj" />
</ItemGroup>
註意需要最新的 .NET 5 preview(寫文章時還在 artifacts 里沒正式 release),並指定語言版本為 preview
:
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
另外,Source Generators 需要引入兩個 nuget 包:
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.6.0-3.final" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.0.0" PrivateAssets="all" />
</ItemGroup>
限制
Source Generators 僅能用於訪問和生成代碼,但是不能修改已有代碼,這有一定原因是出於安全考量。
文檔
Source Generators 處於早期預覽階段,docs.microsoft.com 上暫時沒有相關文檔,關於它的文檔請訪問在 roslyn 倉庫中的文檔:
後記
目前 Source Generators 仍處於非常早期的預覽階段,API 後期還可能會有很大的改動,因此現階段不要用於生產。
另外,關於與 IDE 的集成、診斷信息、斷點調試信息等的開發也在進行中,請期待後續的 preview 版本吧。