1. 先把问题说清楚:为什么要做这么个生成器
做 .NET 开发这些年,最让我心烦的从来不是业务逻辑有多绕,而是整天要写那些毫无营养的样板代码。ViewModel 要挨个加 INotifyPropertyChanged,实体类要写 DTO 映射,仓储层要补接口实现,加一个新字段就得动三四个文件。手写吧,复制粘贴容易漏;用反射跑吧,性能又扛不住;上 T4 模板吧,生成时机和 IDE 体验又很别扭。后来我用 .NET 的源码生成器(Source Generator)把这类问题一锅端了,而且用的是最顺手的 partial 范式——用户代码写一半,生成器在编译期补一半,两个 partial 声明最终被编译器缝合成一个完整的类型,简直就像拼乐高。这篇文章会把我从零造轮子到成功上 NuGet 的完整路径拉一遍,核心聚焦两件事:partial 范式的正确用法,以及 NuGet 打包这个最容易劝退人的环节。适合被样板代码折磨的中级 .NET 开发者,或者想在团队里做一套代码生成基础设施的同学直接当操作手册来用。
1.1 源码生成器能干掉哪些样板代码
先给对源码生成器还不太熟的朋友补个基础认知。源码生成器是 Roslyn 编译器对外开放的一个扩展点,它不是一个运行时框架,而是一个“编译期的外挂”。你写好规则,编译器在编译你的项目时,会拿着这些规则去读语法树和语义模型,然后往你的项目里“偷偷”塞进新的 C# 源码。这些新源码和项目里的手写代码一起吃编译,最后生成程序集。
这些年我在实际项目里用生成器处理的典型场景包括:
- INotifyPropertyChanged 自动实现:给字段打个
[AutoNotify]特性,生成器直接补出属性、事件和通知方法。 - DTO 映射:根据实体类的公共属性,生成到 DTO 的映射扩展方法,省掉大量手工赋值。
- 依赖注入注册:扫描程序集里带
[Service]特性的类,自动生成注册代码。 - 枚举扩展:给枚举生成 DisplayName、Description 之类的元数据访问方法。
- 配置项强类型绑定:根据配置类生成读取键值的样板逻辑。
源码生成器相比反射方案的最大优势是零运行时损耗,因为代码在编译期就已经写死进程序集里了;相比 T4 模板,它又天然集成在编译流程里,生成结果在 IDE 里能实时看到,语法错误会直接变成编译错误,生成规则有问题也可以通过分析器诊断抛给开发者。这套东西做基础设施,团队使用成本很低,大家只需要知道“打个特性就能白嫖代码”。
1.2 为什么偏偏是 partial 范式
partial 这个词在 C# 里其实有两个含义:partial 类和 partial 方法。前者允许把一个类型拆成多个文件分别声明,后者允许把方法的声明和实现分开写,声明放在用户代码里,实现可以交给生成器补全。
源码生成器和 partial 范式可以说是天生一对。你想想,如果生成器只能凭空造新类型,那它能做的事情就窄了很多——没法在用户已有的类型上补成员,也够不到用户类的私有字段,那就没法实现类似 AOP 的“在原有类上增强代码”的效果。而 partial 类恰恰补上了这个缺口:用户在代码里写 public partial class User 并放上字段,生成器生成另一份 public partial class User,把属性、事件、方法一股脑往里填,编译器在编译时会把所有 partial 声明合并成一个完整类型,手写部分和生成部分互不干扰、和谐共处。
partial 方法在 C# 9 之后也更好用了,允许带访问修饰符、允许有返回值,生成器可以看情况决定要不要补实现。不过在我这个例子里,核心还是依赖 partial 类来“补成员”。有一个非常关键的坑必须提前说:多个 partial 声明合并时,访问修饰符必须一致,你用户代码写的 public partial class,生成代码也必须是 public partial class,否则编译器直接报 CS0260。这也是很多人第一次写生成器最容易翻车的地方,后面实操部分我会专门演示怎么动态读取并复用用户类的访问级别。
1.3 方案选型:增量生成器 + 特性驱动
源码生成器有两套 API 可走,老的是 ISourceGenerator,新的是 IIncrementalGenerator。我强烈建议新项目直接上增量生成器。原因很简单:增量生成器把数据源拆成了一条可缓存的管道,只有真正受影响的输入变了,生成逻辑才会重新执行,大型项目编译时性能差别非常大。老接口每次编译都全量跑,项目一大会卡出明显延迟。
这个例子我会再叠加“特性驱动”的思路,也就是说,用户想在哪个字段上生成属性,就给那个字段贴上 [AutoNotify] 特性。为什么不用全自动扫描所有字段?因为全自动看似省事,实际上不可控——不是所有字段都需要暴露成带通知的属性,而且全量扫描很难向使用者解释清楚“哪些代码会被生成”。特性相当于给生成器明确的语义标记,精准、可读、可扩展,是最稳妥的交互方式。
技术选型上有两个版本要求需要卡住:增量生成器需要 Roslyn 4.0 以上,ForAttributeWithMetadataName 这个神器需要 Roslyn 4.3 以上。所以生成器项目里的 Microsoft.CodeAnalysis.CSharp 包我建议直接引 4.8.0,跟 .NET 8 的 SDK 对齐,兼容面足够广。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 项目骨架和 Roslyn 版本怎么选
源码生成器项目本身是一个类库,但它有两个独有的硬性约束,新手很容易忽略。
第一,目标框架必须选 netstandard2.0。为什么不是 net6.0 或者 net8.0?因为生成器 dll 是要被编译器进程和 IDE 设计时进程加载的,这些进程有的跑在 .NET Framework 上,有的跑在较新的 .NET 上,netstandard2.0 是各端都能兼容的公约数。一旦你选了高版本 TFM,老 IDE 或者老构建环境直接加载失败。
第二,Microsoft.CodeAnalysis.CSharp 的版本不能随意追新。生成器引用的 Roslyn 版本越高,对客户端编译器版本的要求就越严格;比如你拿 5.x 的 Roslyn 写的生成器,让一个跑在 .NET SDK 8 上的老项目去加载,很可能直接拒载。我这边长期用的是 4.8.0,跟 .NET 8 SDK 内置的 Roslyn 版本匹配,同时也能兼容大部分稍老的 SDK,是最稳的档位。
项目文件骨架长这样:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>
这里几个属性的作用要拎清楚:IsRoslynComponent 会在编译期帮你做一道校验,提醒你别用一些生成器里不该碰的 API;IncludeBuildOutput 要设成 false,否则默认会把你编译出来的普通 dll 直接打进 NuGet 包的 lib 目录,这跟后面打包的预期不一致;SuppressDependenciesWhenPacking 是为了打包时不要把 Roslyn 那些依赖项往下游传递——因为下游编译器已经内置 Roslyn 了,包里再带一份反而是灾难。
2.2 生成器的生命周期:从 Initialize 到 Execute
增量生成器的核心入口是 Initialize 方法。你不需要像写普通代码那样控制执行顺序,只需要在这里注册“数据从哪里来”以及“数据变化后往哪里输出”。
它的本质是一条数据管道,大概逻辑是这样:语法树中的节点经过筛选器(predicate)先做一遍快速预筛,命中的节点再经过转换器(transform)转换成自定义的模型对象,这些模型对象会作为中间结果被缓存。当某个模型的输入没有变化时,后续的生成逻辑根本不会重跑,这就是增量比全量快的原因。
一个最小骨架长这样:
csharp复制using Microsoft.CodeAnalysis;
namespace GeneratorDemo;
[Generator(LanguageNames.CSharp)]
public sealed class AutoNotifyGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider.ForAttributeWithMetadataName(
"GeneratorDemo.AutoNotifyAttribute",
predicate: static (_, _) => true,
transform: static (ctx, _) => new FieldModel(
(IFieldSymbol)ctx.TargetSymbol));
context.RegisterSourceOutput(provider, static (spc, model) =>
{
// 这里根据 model 生成源码,通过 spc.AddSource 输出
});
}
}
internal sealed record FieldModel(IFieldSymbol FieldSymbol);
注意 RegisterSourceOutput 里面拿到的 SourceProductionContext 除了 AddSource,还能调用 ReportDiagnostic 输出编译期错误或警告。你可以把它理解成生成器的“异常出口”,用户把特性用错地方了,就在这里报一个 Diagnostic,错误会直接显示在 IDE 的错误列表里。
2.3 用 ForAttributeWithMetadataName 读取语义信息
老一代写法喜欢自己写 SyntaxReceiver,靠字符串匹配语法节点再手动拿语义模型,又慢又容易误伤。比说你在代码注释里写了“AutoNotify”四个字,简单字符串匹配可能就把它选中了。ForAttributeWithMetadataName 这个 API 直接绕过了这些麻烦,它会严格按特性元数据的全名去语义层匹配,命中之后在 transform 里直接给你 TargetSymbol 和 Attributes 列表,信息准确且高效。
用它的时候有个性能心法:predicate 参数只管做语法层的快速预筛,目的是把明显不相关的节点挡在外面,能不能匹配最终由语义层拍板。如果你拿不准筛选条件,保守起见直接 static (_, _) => true 也行,功能不会错,只是大规模编译时性能差点。
另外一定要记住:符号之间的判等不能直接 ==,因为 Roslyn 里同一个符号可能被包装成多个实例。比对两个 ISymbol 时必须用 SymbolEqualityComparer.Default,这是老鸟都会踩的坑。后面分组生成的时候就会用到这个比较器。
2.4 生成代码的“坑前须知”
生成器最隐蔽的问题往往不在生成器本身的逻辑,而在于“你生成出去的代码合不合法”。以下几条我在实战中被反复教育过,提前写在这里帮你避雷。
第一,partial 声明的访问级别必须与用户代码一致。用户写 public partial class,你生成的也必须是 public partial class;如果用户类在某个命名空间下,你的生成代码也要放在同一个命名空间下,否则生成的就不是同一个类型。
第二,同一个类如果有多个字段触发生成,必须合并成一次输出。很多人图省事,每个字段触发时就往输出里扔一段 public partial class User : INotifyPropertyChanged,结果同一个接口被多个 partial 声明重复列出,编译器直接报 CS0528。更稳妥的做法是把同一类型下的所有字段收集起来,分组后一次性生成完整类型。
第三,生成代码里的类型引用尽量用完全限定名。用 global::System.Int32 而不是 int,用 ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) 去拿字段类型。这样能避免用户项目里某个命名空间遮蔽了全局类型,导致生成代码编译不过。
第四,生成的代码要主动加上可空上下文和生成标记。文件开头写 #nullable enable,类上打 [global::System.CodeDom.Compiler.GeneratedCode("GeneratorDemo", "1.0.0")],既能减少用户项目开什么严格空检查时出现一堆警告,也能让代码分析工具知道这段代码不该被当成手写代码来批评。
3. 实操过程与关键环节实现
3.1 先写特性类,给用户一个“开关”
特性类是整个方案的入口,用户就是靠它来告诉生成器“请在这里给我生成代码”。和生成器放在同一个程序集里完全可以,但要注意一点:这个特性类必须能被使用方的项目引到。后面打包时我们会把同一个 dll 既塞进 analyzers 目录(加载为生成器),也塞进 lib 目录(加载为普通引用),目的就是为了让 [AutoNotify] 这个特性在用户代码里能用。
特性类的定义很简单,但 AttributeUsage 一定要卡准:
csharp复制namespace GeneratorDemo;
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
public sealed class AutoNotifyAttribute : Attribute
{
}
这里限定只能用在字段和属性上,避免用户把特性贴到类上导致后面语义模型读不到 IFieldSymbol。如果真有人贴错了,生成器里可以用 ReportDiagnostic 给个明确的编译错误,这个后面会提到。
3.2 生成器主体:字段分组,按类输出
现在写生成器的主体逻辑。我的目标是:用户在一个 partial class 里声明若干个带 [AutoNotify] 的字段,生成器自动为这个类补上 INotifyPropertyChanged 的实现,并为每个字段生成对应的属性。
先定义两个模型类,一个描述字段,一个描述类:
csharp复制internal sealed record FieldModel(
INamedTypeSymbol ClassSymbol,
string FieldName,
ITypeSymbol FieldType);
internal sealed record ClassModel(
INamedTypeSymbol ClassSymbol,
ImmutableArray<FieldModel> Fields);
然后在管道里做分组。这一步是整个实现最核心的地方:用 ForAttributeWithMetadataName 拿到所有被特性标记的字段后,必须按类符号分组,同一类的所有字段合并成一个 ClassModel,最后只对这个类输出一次源码。如果不分组,上面说的重复接口列表、重复方法定义等问题全会冒出来。
csharp复制using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace GeneratorDemo;
[Generator(LanguageNames.CSharp)]
public sealed class AutoNotifyGenerator : IIncrementalGenerator
{
private const string AttributeName = "GeneratorDemo.AutoNotifyAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var fields = context.SyntaxProvider.ForAttributeWithMetadataName(
AttributeName,
predicate: static (_, _) => true,
transform: static (ctx, _) =>
{
var field = (IFieldSymbol)ctx.TargetSymbol;
return new FieldModel(
field.ContainingType,
field.Name,
field.Type);
});
var classes = fields
.Collect()
.SelectMany((items, _) => items
.GroupBy(f => f.ClassSymbol, SymbolEqualityComparer.Default)
.Select(g => new ClassModel(g.Key, g.ToImmutableArray())));
context.RegisterSourceOutput(classes, static (spc, model) =>
GenerateType(spc, model));
}
private static void GenerateType(SourceProductionContext context, ClassModel model)
{
var className = model.ClassSymbol.Name;
var ns = model.ClassSymbol.ContainingNamespace;
var accessibility = model.ClassSymbol.DeclaredAccessibility.ToString().ToLowerInvariant();
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.ComponentModel;");
sb.AppendLine();
if (!ns.IsGlobalNamespace)
{
sb.AppendLine($"namespace {ns.ToDisplayString()}");
sb.AppendLine("{");
}
sb.AppendLine($"{accessibility} partial class {className} : INotifyPropertyChanged");
sb.AppendLine("{");
sb.AppendLine(" public event PropertyChangedEventHandler? PropertyChanged;");
sb.AppendLine();
sb.AppendLine(" protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)");
sb.AppendLine(" {");
sb.AppendLine(" PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var field in model.Fields)
{
var propName = ToPascalCase(field.FieldName);
var fieldType = field.FieldType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
sb.AppendLine($" public {fieldType} {propName}");
sb.AppendLine(" {");
sb.AppendLine($" get => {field.FieldName};");
sb.AppendLine(" set");
sb.AppendLine(" {");
sb.AppendLine($" if (!global::System.Collections.Generic.EqualityComparer<{fieldType}>.Default.Equals({field.FieldName}, value))");
sb.AppendLine(" {");
sb.AppendLine($" {field.FieldName} = value;");
sb.AppendLine(" OnPropertyChanged();");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine("}");
if (!ns.IsGlobalNamespace)
{
sb.AppendLine("}");
}
context.AddSource($"{className}_{Guid.NewGuid():N}.g.cs",
SourceText.From(sb.ToString(), Encoding.UTF8));
}
private static string ToPascalCase(string name)
{
var candidate = name.TrimStart('_');
if (string.IsNullOrEmpty(candidate))
{
candidate = name;
}
return char.ToUpperInvariant(candidate[0]) + candidate.Substring(1);
}
}
这段代码有几个细节必须展开说。访问级别我用 DeclaredAccessibility.ToString().ToLowerInvariant() 直接转成了小写字符串,因为枚举值 Public、Internal 转出来就是合法的 C# 修饰符,这个简单粗暴的做法在绝大多数类上都能用。生成属性类型时用 FullyQualifiedFormat,比如 int 它给的是 int,string 会给你 string,但用户在引用类型时如果遇到命名空间遮蔽,完全限定格式也能给出 global::System.String 这种最稳的写法,遇到泛型类型更是不会手抖。
生成的文件名里加 Guid.NewGuid():N 是为了防止同一个类在多次生成、多个输出间出现文件名冲突。虽然我们按类分组后每个类只生成一次,但文件名保持唯一能避免增量编译时一些边界情况下的覆盖问题。
生成结果在用户侧等价于手动写出了这段代码:
csharp复制public partial class User : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public string Name
{
get => _name;
set { ... }
}
}
3.3 消费者项目怎么联调测试
生成器写完之后,不能在生成器项目里直接按 F5 跑,因为它不是一个独立程序。最常用的联调做法是建一个普通控制台项目,用 ProjectReference 同时把生成器项目“引用两次”——既作为普通引用(为了让 [AutoNotify] 特性类可见),又作为 Analyzer(为了让生成器跑起来)。
测试项目的 csproj 里这样配:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GeneratorDemo\GeneratorDemo.csproj"
OutputItemType="Analyzer" />
</ItemGroup>
</Project>
注意这个 OutputItemType="Analyzer" 是关键,少了它,编译器只会把生成器项目当成普通类库引用,生成器根本不会执行。
然后写一个用户侧的类型:
csharp复制using GeneratorDemo;
namespace DemoApp;
public partial class User
{
[AutoNotify]
private string _name = string.Empty;
[AutoNotify]
private int _age;
}
主程序里做个小验证:
csharp复制var user = new User();
user.PropertyChanged += (_, e) => Console.WriteLine($"属性变化: {e.PropertyName}");
user.Name = "张三";
user.Age = 30;
一编译一运行,如果看到“属性变化: Name”“属性变化: Age”输出,说明生成器已经正常工作了。第一次跑通这个流程时确实会有点小激动,因为它意味着以后任何需要 INPC 的类都只需要这几行代码,剩下的全是编译器替你干的。
3.4 NuGet 打包这个坎,怎么扛过去
本地联调跑通只是第一步,真正把源码生成器做成基础设施,最难啃的是 NuGet 打包。很多人在这里卡住:dotnet pack 出来的包装进别的项目,结果发现 [AutoNotify] 用不了,或者生成器一点反应都没有,其实都是打包目录结构不对。
源码生成器的包和普通类库包结构完全不同。普通类库的 dll 放在 lib/<tfm>/ 目录下,而生成器 dll 必须放在 analyzers/dotnet/cs/ 目录下,编译器才会自动把它当成分析器加载。所以打包时不能依赖默认的 IncludeBuildOutput 机制,要手动把 dll 拖进正确的包路径。
另外我还需要让 [AutoNotify] 这个特性在消费者代码里可用,所以同一个 dll 要再放一份到 lib/netstandard2.0/。这样消费者的编译器既能加载它作为生成器,也能引用它拿到特性类型。打包配置完整版如下:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<DevelopmentDependency>true</DevelopmentDependency>
<PackageId>GeneratorDemo</PackageId>
<Version>1.0.0</Version>
<Authors>YourName</Authors>
<Description>一个基于 partial 范式的源码生成器示例包</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="lib/netstandard2.0"
Visible="false" />
</ItemGroup>
</Project>
DevelopmentDependency 设为 true 很关键,它告诉 NuGet 这个包只是一个开发期依赖,不应该传递给下游项目。加上消费者侧引用时一般还会配 PrivateAssets="all",双保险,防止团队里有人把这个包当成运行时组件到处传播。
打包命令很简单:
bash复制dotnet pack -c Release -o .\artifacts
打完包之后建议立刻检查一下 nupkg 内部结构,可以用解压工具打开,也可以直接用命令行:
bash复制unzip .\artifacts\GeneratorDemo.1.0.0.nupkg -d .\pkg-check
tree /f .\pkg-check
正确的结果应该是这样的:
code复制lib/netstandard2.0/GeneratorDemo.dll
analyzers/dotnet/cs/GeneratorDemo.dll
只要你看到 analyzers/dotnet/cs 目录下有 dll,就说明这个包安装到消费者项目里时,编译器会把它当分析器加载,这个环节就“扛”过去了。
3.5 本地源实测整个流程
打包完成后,强烈建议在自己机器上建一个本地 NuGet 源,从零走一遍消费者流程。这一步能暴露很多“我以为没问题”的隐患。
先加一个本地源:
bash复制dotnet nuget add source .\artifacts --name LocalFeed
然后在另一个干净目录建一个控制台项目,添加包引用:
bash复制dotnet new console -n DemoConsumer
cd DemoConsumer
dotnet add package GeneratorDemo --version 1.0.0 --prerelease
注意,如果项目文件里已经配好了 PackageReference,可以直接改 csproj:
xml复制<PackageReference Include="GeneratorDemo" Version="1.0.0" PrivateAssets="all" />
然后写测试代码、dotnet build、dotnet run。这一步和之前的 ProjectReference 联调体验几乎一致,说明包的内容和结构都没问题。如果期间报“找不到 AutoNotifyAttribute”,多半是 lib 目录没放 dll;如果代码能编译但属性不生成,多半是 analyzers 目录没放对,或者 IDE/编译服务器还缓存着旧版本分析器。
4. 常见问题与排查技巧实录
4.1 生成器不触发,先查缓存
源码生成器项目里最折磨人的问题就是“我明明改了代码,为什么生成结果还是旧货”。这不是生成器逻辑写错了,而是编译服务器缓存惹的祸。Roslyn 在构建时会启动一个叫做 VBCSCompiler 的共享编译进程,它会缓存加载过的分析器 dll。你改了生成器代码重新编译生成器项目,如果编译服务器还持有旧 dll 文件句柄,消费者项目拿到的生成器就是旧版本。
遇到这种情况,第一件事不是翻代码,而是重启编译服务器:
bash复制dotnet build-server shutdown
如果用的是 Visual Studio,干脆把 VS 里所有相关实例关掉再重开。我自己被这个问题坑过两次之后,现在养成了一个习惯:每次改了生成器代码,先执行 dotnet build-server shutdown 再重新构建测试项目,一步到位,省得怀疑人生。
4.2 生成的代码看不见?落盘看现场
有时候你会怀疑“生成器到底生成了什么”,尤其当 IDE 里看不到生成的结果时,心里完全没底。其实生成器生成的代码在默认情况下是看不见的,它存在于内存中,直接参与了编译,但不会出现在你的源码树里。
想看到现场,就在消费者项目里开两个属性:
xml复制<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
重新编译后,生成结果会落盘到 obj/generated/GeneratorDemo/GeneratorDemo.AutoNotifyGenerator/*.g.cs。打开看,生成代码的每个字符都清清楚楚,排查语法错误、类型全限定名写没写对,全靠这一手。
4.3 版本和依赖的雷区
生成器项目尽量别引入第三方依赖库。这是一个非常现实的约束:分析器 dll 运行在编译器进程中,它的依赖项不会像普通应用程序那样自动从 NuGet 缓存里解析出来,它只在自身所在目录附近找依赖。如果生成器代码里用了 Newtonsoft.Json,而打包时又没把 Newtonsoft.Json.dll 也塞到 analyzers/dotnet/cs 目录下,消费者项目编译时轻则抛 FileNotFoundException,重则直接编译中断,错误信息还特别难懂。
所以我的原则是:生成器代码能不用第三方库就不用,实在绕不开,就要用 None 项把依赖 dll 一并打包进 analyzers/dotnet/cs。另外生成器引用的 Microsoft.CodeAnalysis 系列包必须设 PrivateAssets="all",绝不能让它出现在包依赖里,原因前面说过,编译器自己已经带了一套 Roslyn,你再要求它下载一个版本完全一致的 Roslyn,纯粹是自找麻烦。
4.4 调试生成器:GeneratorDriver 是首选
有人喜欢在生成器代码里加一句 Debugger.Launch(),一编译就弹窗选调试器,这招救急可以,但每次编译都弹窗,非常打断节奏。我现在更习惯用单元测试驱动生成器,也就是在测试项目里直接用 CSharpGeneratorDriver 把生成器跑一遍,这样可以打普通断点、看中间变量,完全不污染生成器生产代码。
核心思路是先把源码字符串处理成 CSharpCompilation,再创建 CSharpGeneratorDriver 执行生成:
csharp复制var source = """
using GeneratorDemo;
namespace DemoApp;
public partial class User
{
[AutoNotify] private string _name = "";
[AutoNotify] private int _age;
}
""";
var compilation = CSharpCompilation.Create(
"Demo",
new[] { CSharpSyntaxTree.ParseText(source) },
new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) },
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var driver = CSharpGeneratorDriver.Create(new AutoNotifyGenerator());
driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);
var generated = outputCompilation.SyntaxTrees.Skip(compilation.SyntaxTrees.Length);
foreach (var tree in generated)
{
// 在这里打断点查看 tree.ToString()
}
这个方法非常适合生成器开发阶段的常规自测,配合分组、字段类型转换等逻辑,比一遍遍改消费者项目快得多。
4.5 常用避坑速查表
| 问题 | 典型表现 | 解决办法 |
|---|---|---|
| 生成器不执行 | 编译成功但行为无变化 | 先 dotnet build-server shutdown,再关掉 VS 重开 |
[AutoNotify] 找不到 |
编译报特性类型不存在 | 检查包里是否有 lib/netstandard2.0 目录的 dll |
| 生成代码重复成员 | CS0111 之类的成员重复错误 | 按类分组生成,不要每个字段单独输出一个 partial 块 |
| partial 声明访问级别不一致 | CS0260 partial 声明冲突 | 生成时读取用户类 DeclaredAccessibility 并保持一致 |
| 生成代码报命名空间冲突 | CS0246 或类型无法解析 | 类型用 FullyQualifiedFormat 输出,必要时加 global:: 前缀 |
| 生成器引第三方库导致编译失败 | 消费者项目 FileNotFoundException | 第三方依赖也塞进 analyzers/dotnet/cs,或者干脆不用 |
| NuGet 包装完没效果 | 编译器不加载分析器 | 确认 dll 在 analyzers/dotnet/cs 而非只有 lib |
| package 依赖被传递到下游 | 下游项目莫名装上 Roslyn 依赖 | 打包设 SuppressDependenciesWhenPacking,引用设 PrivateAssets="all" |
最后多聊两句我的体会。源码生成器这套玩法,前期最难受的就是“看不见摸不着”,你不知道它到底有没有跑、跑到哪一步挂了。但只要学会让生成文件落盘、学会用 GeneratorDriver 做单测,整个开发体验会一下子通透起来。我现在的习惯是:生成器逻辑稳定之后,第一时间打包放到本地源里,用独立的消费者项目跑一遍完整流程,确认包结构没问题再往团队内部源推。这样一来,团队成员拿到的就是一个“打个特性就能白嫖代码”的成熟基础设施,而不会再遇到那些我自己踩过的坑。这套 partial 范式加 NuGet 打包的组合拳,在 .NET 世界里能做的东西远比一个 INPC 生成器多,你完全可以把同样的思路延伸到 DTO 映射、接口代理、配置绑定等场景,只要守住“语义精确、按类合并、生成合法、包结构正确”这四个底线,基本就稳了。
