本文将带你为你的某个库添加自动生成代码的逻辑。
本文以 dotnetCampus.Ipc 项目为例,来说明如何为一个现成的 .NET 类库添加自动生成代码的功能。这是一个在本机内进行进程间通信的库,在你拥有一个 IPC 接口和对应的实现之后,本库还会自动帮你生成通过 IPC 代理访问的代码。由于项目加了 Roslyn 的 SourceGenerator 功能,所以当你安装了 dotnetCampus.Ipc NuGet 包 后,这些代码将自动生成,省去了手工编写的费神。
dotnetCampus.Ipc 简介
例如你有一个接口 IWalterlv
和其对应的实现 WalterlvImpl
:
1
2
3
4
5
6
7
8
9
10
11
12
public interface IWalterlv
{
Task<string> GetUrlAsync();
}
public class WalterlvImpl : IWalterlv
{
public Task<string> GetUrlAsync()
{
return Task.FromResult("https://blog.walterlv.com");
}
}
那么只需要在 WalterlvImpl
上标记这是一个 IPC 对象即可:
1
2
++ [IpcPublic(typeof(IWalterlv))]
public class WalterlvImpl : IWalterlv
这时,编译这个项目,将会自动生成这样的两个类:
WalterlvIpcProxy
:负责代理访问 IPC 对方WalterlvIpcJoint
:负责接收对方的 IPC 访问,然后对接到本地真实实例
那么本文就以它为例子说明如何编写一个代码生成器:
- 开始编写一个基本的代码生成器
- 使用代码生成器生成需要的代码
- 将代码生成器加入到现有的 NuGet 包中
- 调试代码生成器
一个基本的代码生成器
创建一个项目,例如 dotnetCampus.Ipc.Analyzers
,然后编辑其项目文件(csproj)。至少要包含以下内容:
TargetFramework
必须是netstandard2.0
,目前(Visual Studio 2022 和 MSBuild 17)不支持其他任何框架。- 引用
Microsoft.CodeAnalysis.Analyzers
和Microsoft.CodeAnalysis.CSharp
并且不对外传递他们的依赖。
1
2
3
4
5
6
7
8
9
10
11
12
13
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
</ItemGroup>
</Project>
这里的 AppendTargetFrameworkToOutputPath
是可选的,目的是去掉生成路径下的 netstandard2.0
文件夹。
接着创建一个代码生成器类:
1
2
3
4
5
6
7
8
9
10
11
[Generator]
public class ProxyJointGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
}
}
这样,你就写好了一个基本的生成器的代码框架了,剩下的就是往里面填内容了。
生成代码
Initialize
方法可进行一些初始化,你可以在这里订阅代码的变更通知,可以要求监听某些 C# 甚至是非代码文件的修改。本文是入门向,所以不涉及到这个方法。
接下来我们大部分的代码都将从那个 Execute
方法开始。
例如,我们可以随便写一个:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 这段代码来自 https://docs.microsoft.com/zh-cn/dotnet/csharp/roslyn-sdk/source-generators-overview
public void Execute(GeneratorExecutionContext context)
{
// find the main method
var mainMethod = context.Compilation.GetEntryPoint(context.CancellationToken);
// build up the source code
string source = $@"
using System;
namespace {mainMethod.ContainingNamespace.ToDisplayString()}
{{
public static partial class {mainMethod.ContainingType.Name}
{{
static partial void HelloFrom(string name)
{{
Console.WriteLine($""Generator says: Hi from '{{name}}'"");
}}
}}
}}
";
// add the source code to the compilation
context.AddSource("generatedSource", source);
}
这里的 AddSource
就是将代码添加到你的项目中了。
而我在 dotnetCampus.Ipc 库中编写的生成代码会稍微复杂一点,会根据项目中标记了 IpcPublic
的类的代码动态生成对这个类的代理访问和对接代码,使用的是 Roslyn 进行语义分析。可参见:使用 Roslyn 对 C# 代码进行语义分析 - walterlv。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void Execute(GeneratorExecutionContext context)
{
foreach (var ipcObjectType in FindIpcPublicObjects(context.Compilation))
{
try
{
var contractType = ipcObjectType.ContractType;
var proxySource = GenerateProxySource(ipcObjectType);
var jointSource = GenerateJointSource(ipcObjectType);
var assemblySource = GenerateAssemblyInfoSource(ipcObjectType);
context.AddSource($"{contractType.Name}.proxy", SourceText.From(proxySource, Encoding.UTF8));
context.AddSource($"{contractType.Name}.joint", SourceText.From(jointSource, Encoding.UTF8));
context.AddSource($"{contractType.Name}.assembly", SourceText.From(assemblySource, Encoding.UTF8));
}
catch (DiagnosticException ex)
{
context.ReportDiagnostic(ex.ToDiagnostic());
}
catch (Exception ex)
{
context.ReportDiagnostic(Diagnostic.Create(DIPC001_UnknownError, null, ex));
}
}
}
这段代码的含义为:
- 通过自己写的
FindIpcPublicObjects
方法找到目前项目里所有的标记了IpcPublic
特性的类; - 为这个类生成代理类(Proxy);
- 为这个类生成对接类(Joint);
- 为这些类生成关系(AssemblyInfo);
- 将这些新生成的代码都加入到项目中进行编译;
- 如果中间出现了未知异常,则用自己编写的
DiagnosticException
异常类辅助报告编译错误。
这里只介绍创建代码分析器的一般方法,更多生成器代码可以前往仓库浏览:dotnetCampus.Ipc 项目。
为 NuGet 包添加生成代码的功能
现在,我们要将这个生成代码的功能添加到 NuGet 包中。最终打出的 NuGet 包会是下面这样:
为了生成这样的包,我们需要:
- 添加解决方案依赖,确保编译 dotnetCampus.Ipc 之前,dotnetCampus.Ipc.Analyzers 项目已完成编译;
- 将 dotnetCampus.Ipc.Analyzers.dll 加入到 NuGet 包中。
对于 1,在解决方案上右键->“项目依赖项”,然后在 dotnetCampus.Ipc 项目上把 dotnetCampus.Ipc.Analyzers 勾上。
对于 2,我们需要修改真正打包的那个项目,也就是 dotnetCampus.Ipc 项目,在其 csproj 文件的末尾添加:
1
2
3
4
5
<Target Name="_IncludeAllDependencies" BeforeTargets="_GetPackageFiles">
<ItemGroup>
<None Include="..\dotnetCampus.Ipc.Analyzers\bin\$(Configuration)\**\*.dll" Pack="True" PackagePath="analyzers\dotnet\cs" />
</ItemGroup>
</Target>
这样便能生成我们期望的 NuGet 包了。等打包发布后,就能出现本文一开始说的能生成代码的效果了。
调试代码生成器
代码生成器编写更复杂的时候,调试就成了一个问题。接下来我们说说如何调试代码生成器。
这种代码的调试,大家可能很容易就想到了用 Debugger.Launch()
来调试,就像这样:
1
2
3
4
public void Initialize(GeneratorInitializationContext context)
{
++ System.Diagnostics.Debugger.Launch();
}
但是,用什么项目的编译来触发这个调试呢?总不可能在某个项目上安装上这个 NuGet 包吧……那样效率太低了。
我们再建一个 dotnetCampus.Ipc.Test
项目,在其 csproj 文件上加上这么一行:
1
2
3
<ItemGroup>
<ProjectReference Include="..\..\src\dotnetCampus.Ipc.Analyzers\dotnetCampus.Ipc.Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
OutputItemType="Analyzer"
表示将项目添加为分析器,ReferenceOutputAssembly="false"
表示此项目无需引用分析器项目的程序集。
这样,编译此 dotnetCampus.Ipc.Test
项目时,就会触发选择调试器的界面,你就能调试你的代码生成器了。
使用这种方式引用,相比于 NuGet 包引用来说,项目的分析器列表里无法看到生成的代码。如果需要在这种情况下看到代码,你可能需要在 context.AddSource
那里打上一个断点,来看生成的代码是什么样的。
当然,除了用项目引用的方式,你还能直接引用最终的 dll:
1
2
3
<ItemGroup>
<Analyzer Include="..\..\src\dotnetCampus.Ipc.Analyzers\bin\$(Configuration)\dotnetCampus.Ipc.Analyzers.dll" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
参考资料
- 源生成器 - Microsoft Docs
- roslyn/source-generators.md at main · dotnet/roslyn
- roslyn/source-generators.cookbook.md at main · dotnet/roslyn
本文会经常更新,请阅读原文: https://blog.walterlv.com/post/generate-csharp-source-using-roslyn-source-generator ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected]) 。