
1. 为什么我决定在.NET上复刻一个Lombok1.1 从Java迁移到.NET的第一感受样板代码把人都淹没了去年我带一个项目从Java技术栈往.NET迁移团队里几个写惯了Spring Boot Lombok的老哥第一天就开始抱怨C#怎么连个Slf4j都没有我当时还不以为意觉得C#有属性有字符串内插样板代码能多到哪去结果真动起手来才发现问题比想象中大得多。拿一个最常见的业务服务类来说你要写依赖注入的构造函数、要写日志字段、要写查询参数的Builder、要写DTO的映射、要写一堆Equals和GetHashCode……这些代码有一个共同点语义上完全确定但你必须手工逐行敲出来。敲完你自己都分不清到底哪些是有业务逻辑的代码哪些只是给编译器看的搬运工代码。代码评审的时候满屏都是构造函数赋值和日志初始化真正需要人看的那几行反而被淹没在噪音里。Java圈子的Lombok对这个问题的解法是用注解在编译期生成字节码RequiredArgsConstructor直接生成带参构造Slf4j直接生成log字段Builder直接生成链式构造器。C#其实不缺类似的机制——**源生成器Source Generator**就是干这个的最好工具只不过国内讨论得少很多人还停留在听说过Roslyn但没用过的阶段。所以这个项目的名字就叫打造.NET平台的Lombok目标很朴素把模板代码从人的肩膀上挪到编译器的自动流水线上。1.2 Lombok到底做对了什么以及它为什么没法直接搬过来Lombok做对的核心事情不是减少敲键盘而是让代码意图和代码实现分离。你写一个Builder读代码的人一眼就知道这个类支持构造者模式而不需要从头到尾看一遍几十行的WithXxx方法才能确认这个类的形态。这种声明式编程的好处是长期维护的你的代码量少了阅读成本也低了出错的概率自然下降。那为什么不直接用Java那套思路在.NET上照搬原因有三。第一C#和Java的编译管线不同。Lombok靠的是Java编译器内部的注解处理器钩子直接在AST上改字节码而.NET这边对应的钩子是Roslyn源生成器它只能向编译工程里追加文件不能修改已有的类定义。你没法在编译中间阶段给某个类注入一个构造函数进去只能生成一个partial类文件通过partial关键字把生成的方法和成员拼到同一个类型里。第二两个生态的语法习惯不一样。Java里Value、Data那一套偏重POJO的getter/setter生成C#有属性语法这块需求天然少了一大半C#更缺的是依赖注入构造、日志、Builder、映射这些偏服务端工程化的样板代码。所以做.NET版Lombok功能优先级和Java是完全不同的。第三微软官方其实给了一部分答案比如record类型自带Equals/GetHashCode/ToString主构造函数在C# 12里也能省掉一部分字段赋值。但record解决不了全部问题尤其是构造函数注入和日志这类涉及依赖注入容器、生命周期管理的场景社区一直没有标准做法。这反而说明这块空白值得自己动手填。1.3 方案选型源生成器、T4模板、IL织入、动态代理怎么选在真正动手前我列了一张表把可行的几条技术路线过了一遍技术方案实现原理优点主要问题Roslyn 源生成器编译期分析语法树/语义模型生成partial代码文件IDE智能提示、AOT友好、无运行时依赖学习曲线稍陡调试麻烦T4 模板在VS里生成代码文本文件老牌、资料多只在IDE保存时生成CI里配置繁琐无法感知语义IL织入Mono.Cecil等编译后改写程序集能改已有类型调试困难、与AOT不兼容、步骤繁琐动态代理 / 反射运行时生成代理类灵活性能损耗、调试困难、与原生类型有隔阂最终的结论很明确源生成器是唯一一个既能参与编译期语义分析、又不引入运行时依赖、还能让用户看到生成代码的选项。它生成的代码就是普通的C#出错时能断点进去单步看这对团队落地太重要了——我可以理直气壮跟同事说这工具生成的东西你随时能在目标目录里查到不是黑盒魔法。选型确定后剩下的问题就是工程细节了。下面的内容我按实现顺序讲从工程骨架到三个核心功能构造函数注入、日志注入、构造者模式再到调试和打包全程是我实际跑通的结果不是从文档里抄的。2. 源生成器工程骨架项目结构、SDK配置和调试链路2.1 一个生成器类库加一个消费项目的标准结构源生成器的工程结构和普通类库不一样至少需要两层Generator项目生成器本体必须目标框架netstandard2.0因为这样才能被VS和MSBuild的Roslyn编译器加载不管下游是用.NET 6还是.NET 8。Consumer项目示例/测试项目真正引用生成器、使用[AutoInject]这些标签的库或可执行程序。我是这么组织的DotNetLombok.sln ├── src/DotNetLombok.Generator // 源生成器本体 │ ├── AutoInjectGenerator.cs │ ├── AutoLoggerGenerator.cs │ └── AutoBuilderGenerator.cs ├── samples/BasicUsage // 示例消费项目 │ ├── Services/OrderService.cs // 标记了特性的类 │ └── Program.cs └── tests/Generator.SnapshotTests // 快照测试可选但强烈推荐Generator项目的csproj有几个关键点网上抄来的配置往往漏掉Analyzer这行Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknetstandard2.0/TargetFramework LangVersionlatest/LangVersion EnforceExtendedAnalyzerRulestrue/EnforceExtendedAnalyzerRules Nullableenable/Nullable /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.CodeAnalysis.CSharp Version4.8.0 PrivateAssetsall / /ItemGroup /Project消费方引用的写法才是核心很多人栽在这里Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet8.0/TargetFramework /PropertyGroup ItemGroup ProjectReference Include..\..\src\DotNetLombok.Generator\DotNetLombok.Generator.csproj OutputItemTypeAnalyzer ReferenceOutputAssemblyfalse / /ItemGroup /Project这里OutputItemTypeAnalyzer表示把该项目当作分析器传给编译器ReferenceOutputAssemblyfalse表示不把这个程序集加进消费方的运行时依赖。少任何一个生成器要么不跑要么会把生成器本身dll带进生产产物等于把一个编译期工具打进了运行时属于低级错误。2.2 IIncrementalGenerator的核心写法从语法树到代码输出现在生成器推荐用IIncrementalGenerator老式的ISourceGenerator已经过时且缓存性能差。核心思路可以理解成一条流水线先找标记 → 再做语义校验 → 最后产出代码。拿构造函数注入生成器举例我第一个版本的管线是[Generator(LanguageNames.CSharp)] public sealed class AutoInjectGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var provider context.SyntaxProvider.ForAttributeWithMetadataName( DotNetLombok.AutoInjectAttribute, static (node, _) node is ClassDeclarationSyntax or RecordDeclarationSyntax, static (ctx, _) (ctx.TargetSymbol as INamedTypeSymbol)!); context.RegisterSourceOutput(provider, static (spc, typeSymbol) { if (typeSymbol is null) return; var code InjectCodeGenerator.Generate(typeSymbol); spc.AddSource($AutoInject_{typeSymbol.ToDisplayString().Replace(, [)}.g.cs, SourceText.From(code, Encoding.UTF8)); }); } }ForAttributeWithMetadataName是.NET 6之后提供的高性能API它直接把标记了某特性的类型的语义符号筛出来省掉了我以前手写RegisterSyntaxNodeProvider再解析Attribute的笨办法。用它的最直接收益是增量缓存——编译器只在受影响的文件变化时重跑而不是全量重新生成这在大型项目里的编译速度差距是秒级和分钟级。2.3 调试生成器的三板斧断点、脱机文件和诊断输出生成器调试起来和普通代码不一样普通代码F5就跑生成器要等到编译触发。压箱底的三招第一招让生成器弹调试器。在生成器代码开头加#if DEBUG System.Diagnostics.Debugger.Launch(); #endif每次编译都会弹出一个选择调试器的对话框选VS断点就命中了。这招简单粗暴但很管用。第二招直接看生成的文件。默认情况下生成的.g.cs文件在obj/Debug/net8.0/generated/DotNetLombok.Generator/DotNetLombok.Generator.AutoInjectGenerator/目录里。我习惯在项目文件里加一段让VS自动把它们展示出来PropertyGroup EmitCompilerGeneratedFilestrue/EmitCompilerGeneratedFiles CompilerGeneratedFilesOutputPath$(BaseIntermediateOutputPath)generated/CompilerGeneratedFilesOutputPath /PropertyGroup打开obj/generated目录生成代码长什么样一目了然比什么日志都好使。第三招用报告器直接定位错误。如果生成逻辑里有无法处理的边界情况不要默默return应该向编译器抛诊断spc.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor(DOTNETLOMBOK001, 无法生成构造函数, 类型 {0} 带有不能注入的字段 {1}, DotNetLombok, DiagnosticSeverity.Error, isEnabledByDefault: true), location));这样出错时错误列表里跟着一条带文件名和行号的提示同事在IDE里就能看到而不是等运行时报MissingMethodException才开始排查。3. 构造函数注入让编译器替你写构造参数和赋值3.1 目标代码形态一个特性少写二十行我要做的效果是用户定义一个服务类时不再手写构造和字段赋值只需要[AutoInject] public partial class OrderService { [Inject] private readonly IOrderRepository _repository; [Inject] private readonly ILoggerOrderService _logger; }生成器自动产出下面的代码public partial class OrderService { public OrderService(IOrderRepository repository, ILoggerOrderService logger) { _repository repository; _logger logger; } }一眼看上去平平无奇但想想你维护的几十个服务类每一个都省掉构造赋值累积起来非常可观。我把目标定为生成构造函数和字段赋值而不是用属性注入是因为构造函数注入是.NET依赖注入的标准推荐做法配合微软的IServiceCollection类型的依赖关系在创建时就被固定下来不可中途改变既好测也好排查。3.2 语义模型解析拿类型、筛字段、拼签名生成逻辑拆成三步。第一步拿到类型符号后先过滤掉非partial类型。没有partial生成代码就没法和原类型合并这一步必须前置校验。第二步遍历类型的成员筛选出带[Inject]特性的字段。这里有个我之前踩过的坑不能只做词法层面的匹配比如用ctx.Node.DescendantNodes()找Attribute语法节点因为可能撞上同名但来自别处的Attribute。正确做法是利用ForAttributeWithMetadataName已经给好的符号然后用语义模型判断字段类型和可空性。第三步才是生成代码。生成时需要做几件小事字段排序。我按字段在源码中的顺序生成参数用户声明顺序即依赖注入顺序这样和手写习惯一致。参数可空性。string _name和string? _nickName生成出来参数可空修饰必须一致否则会有nullable警告。避免参数名冲突。如果字段叫logger参数也叫logger赋值为this.logger logger会非常别扭我统一给参数名加injected前缀比如injectedRepository既避免冲突又能在代码里隐式表达这是注入进来的依赖。3.3 partial类和继承关系的边界处理很快会遇到三个绕不开的问题我逐个说。第一个是partial类没写全。用户如果在另一个文件里已经手动声明了一个构造函数生成器再生成一个就必须合并。我采用的做法是生成前检查符号上有没有已声明的构造函数有就不生成构造函数本体只生成字段赋值逻辑通过partial void钩子让用户去接。不过实际用下来感觉这种灵活增加了理解成本最后我反而选择了更严格的规则类上手动定义构造函数的行为直接报诊断DOTNETLOMBOK002让用户删掉手写构造或放弃使用[AutoInject]。规则简单了产品才好用。第二个是继承场景。如果服务类继承了某个基类基类有自己的构造参数子类生成的构造函数必须用base(...)把参数传上去。这一块很难自动判定基类哪些构造参数应该传什么我的处理很克制默认不支持带基类参数的自动注入遇到类有基类且基类不是object时抛诊断把决定权留给用户。与其生成一个语义可能错误的代码不如明确说不支持。第三个是泛型类。RepositoryT这类类型要原样保留泛型参数生成代码时要把T的约束也接上。代码生成时我用symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)拿全限定名这比手拼global::前缀安全得多也不会漏掉Nullable上下文。3.4 生成效果对照直接贴手写和生成的差异我拿一个真实的服务类做对比感兴趣可以自己抄下来体会。手写版public partial class InventoryService { private readonly IInventoryRepository _repository; private readonly IMapper _mapper; private readonly ILoggerInventoryService _logger; public InventoryService( IInventoryRepository repository, IMapper mapper, ILoggerInventoryService logger) { _repository repository; _mapper mapper; _logger logger; } public async TaskInventoryDto GetAsync(int id) { var entity await _repository.GetByIdAsync(id); return _mapper.MapInventoryDto(entity); } }生成器版用户手写的部分[AutoInject] public partial class InventoryService { [Inject] private readonly IInventoryRepository _repository; [Inject] private readonly IMapper _mapper; [Inject] private readonly ILoggerInventoryService _logger; public async TaskInventoryDto GetAsync(int id) { var entity await _repository.GetByIdAsync(id); return _mapper.MapInventoryDto(entity); } }字段声明仍然显式写这是故意的。隐式字段会导致代码可读性断崖式下降同事看你这个类根本不知道依赖有哪些。透明、可预期是这类代码生成工具在设计上必须守住的底线。4. 日志注入与构造者模式把两个高频样板按模板化4.1 日志注入不硬编码日志框架的懒加载方案日志注入看起来简单生成一个ILogger字段就行了。但实际设计时有一个隐藏问题ILoggerT里的T必须是使用该日志的类。如果生成器给OrderService生成了ILoggerOrderService那就要默认用户一定注入了日志框架。这在某些纯领域项目里根本不存在硬塞进去就是不可控的依赖。我的设计是这样生成器只生成一个IsExternalInit风格的日志属性占位采用延迟获取模式不向构造函数里添加参数也不强制要求DI容器提供ILoggerpublic partial class OrderService { private ILoggerOrderService? _logger; protected ILoggerOrderService Logger _logger ?? Microsoft.Extensions.Logging.LoggerFactory .Create(builder builder.AddConsole()) .CreateLoggerOrderService(); }等等我实际测试后发现直接LoggerFactory是坑——如果服务端已经配置了结构化日志、日志级别过滤你这么创建会绕过全局配置日志行为会跟项目里其他地方不一致。所以我最后改成了可替换的注入点默认生成如下结构public partial class OrderService { private ILoggerOrderService? _logger; protected ILoggerOrderService Logger _logger ?? CreateLogger(); partial void CreateLoggerPartial(ref ILoggerOrderService logger); }用户觉得有需要时自己用partial void CreateLoggerPartial(...)去接DI容器不接则用一个安全的默认实现。这么一来特性不绑架框架项目里没配日志也能编译配了日志也可以通过partial方法无缝接入。4.2 构造者模式C#的链式API怎么生成才顺手构造者模式在Java世界靠Builder在C#这边其实一直没有标准方案。手写一个Builder动辄几十行而且改一个字段要改三处原类的私有构造/属性初始化、Builder的字段声明、Builder的赋值方法。源生成器做这件事的收益比构造注入和日志还明显。我期望的用法是[AutoBuilder] public partial class SearchRequest { public string? Keyword { get; set; } public int PageIndex { get; set; } 1; public int PageSize { get; set; } 20; }生成器产出一个SearchRequestBuilderpublic sealed class SearchRequestBuilder { private string? _keyword; private int _pageIndex 1; private int _pageSize 20; public SearchRequestBuilder WithKeyword(string? keyword) { _keyword keyword; return this; } public SearchRequestBuilder WithPageIndex(int pageIndex) { _pageIndex pageIndex; return this; } public SearchRequestBuilder WithPageSize(int pageSize) { _pageSize pageSize; return this; } public SearchRequest Build() new SearchRequest { Keyword _keyword, PageIndex _pageIndex, PageSize _pageSize }; public static SearchRequestBuilder Create() new SearchRequestBuilder(); }设计上我有三条原则可变集合属性不直接用字段默认值。如果属性是Liststring Tags生成字段直接new Liststring()否则用户调Build()拿到的集合永远是一个共享实例改一个对象会污染其他对象。值类型字段的默认值跟着属性初始化器走。PageIndex 1意味着Builder里对应字段初始值也得是1我用属性初始化器的常量做初值避免看起来默认值一致、实际生成对象取值是0的隐性差异。链式方法命名只提供WithXxx。不要再去生成SetXxx。C#团队已经习惯record的with语法WithXxx最直观不需要两套方法增加体积。4.3 生成粒度上的取舍宁可少生成不要瞎生成这三个特性的实现过程中我反复拿捏一个原则生成器不是万能的不该为所有业务场景变魔法。遇到下面情况我会选择不生成或报诊断而不是硬生成类里有只读字段但没有定义初始化器同时属性面向UI需要无参构造——这种语义冲突我不擅自决定。类层级过深三层以上继承的Builder——复杂继承下的Builder本来就不该无脑生成人工梳理依赖关系更靠谱。泛型类型参数上加了标记——虽然技术上能把T原样拷贝但涉及约束复合比如where T : class, new()时很容易生成出不能编译的代码。我目前对泛型类生成Builder保持谨慎先做非泛型主场景。克制放在第一位生成器是替你省时间的不是替你挖新坑的。一个生成的代码如果用户要花一倍时间去调试那不如不生成。5. 调试源生成器时绕不开的坑缓存、断点和改了没生效5.1 改了源码生成结果却纹丝不动的三大来源开发和调试源生成器最劝退的就是我明明改了标记A生成结果没变化。我统计过自己三次犯同一个错误的来源现象原因解决办法改了特性名VS里没有反应bin/obj缓存没清重启VS或手动删obj和.vs目录断点根本没进生成器生成器dll被MSBuild进程加载过一次后面一直在用旧dll结束所有dotnet.exe/MSBuild.exe进程或重启VS生成代码和当前编译环境对不上IntelliSense用的是上一次编译的缓存dotnet build /t:Rebuild强制全量其中最坑的是第二个。VS的Roslyn组件会常驻MSBuild进程你改了生成器代码重新编译后dll更新了但进程里加载的还是旧版本。你会发现加了Debugger.Launch怎么不弹窗了多半就是旧进程在干活。稳妥的办法是把VS彻底重启一次别只buildbuild往往不够。5.2 用诊断输出当日志让生成过程可见生成器代码里Console.WriteLine是看不到的因为编译器不在控制台环境跑你的代码。我后来养成一个好习惯一切运行信息都走Diagnostic通道。我在生成器里定义一个DiagnosticDescriptorSeverity设为InfoIsEnabledByDefault设为true在关键节点输出spc.ReportDiagnostic(Diagnostic.Create( new DiagnosticDescriptor(DOTNETLOMBOK_DEBUG, 生成器调试, 正在为类型 {0} 生成构造函数找到注入字段 {1} 个, DotNetLombok, DiagnosticSeverity.Info, true), null, typeSymbol.Name, fieldCount));这样在VS的错误列表里把生成器/ Analyzer筛选打开就能看到一条条Info信息。比断点还方便因为它是全量编译视角能确认到底哪个类进入了生成器。5.3 增量缓存别让高效API反过来坑你IIncrementalGenerator的API设计是为了编译缓存但它会把你用来生成代码的输入参数当成缓存key。如果输入的是一个不可比较的对象Roslyn可能直接放弃缓存如果输入的是带DateTime.Now这类不稳定值的自定义对象则会产出错误缓存。我的经验是对生成器的输入模型保持纯粹**把语义符号转换成自定义的、只包含值类型的EquatableModel**再进入生成环节。比如record struct InjectableField(string Name, string Type, bool IsNullable, Accessibility Access);缓存比较时只比对字符串和枚举稳定、可预期。这也让快照测试变得好写——生成的代码只依赖模型值不依赖Roslyn内部的引用测试时构造模型就能断言输出。我在这个阶段还发现一个容易犯的错生成代码里写了绝对路径或时间戳。比如有人图方便在代码头部加// generated at 2025-...这种信息会污染仓库版本每次编译都产生diff对增量构建是灾难。生成代码必须幂等、确定性否则所有快照测试都会变红。6. 打包发布与团队落地从自己爽到同事愿意用6.1 打包成NuGet源生成器的正确姿势工具在自己项目里跑通只是第一步要让团队真正用起来最好打成NuGet包。源生成器的打包和普通类库有区别普通类库默认把编译产物放进lib/生成器要放进analyzers/dotnet/cs目录PropertyGroup IncludeBuildOutputfalse/IncludeBuildOutput SuppressDependenciesWhenPackingtrue/SuppressDependenciesWhenPacking /PropertyGroup ItemGroup None Include$(OutputPath)\$(AssemblyName).dll Packtrue PackagePathanalyzers/dotnet/cs Visiblefalse / /ItemGroupSuppressDependenciesWhenPacking很关键它避免把生成器依赖的Microsoft.CodeAnalysis.CSharp也打进依赖项列表因为生成器运行时编译器会自己提供Roslyn程序集不需要也不应该单独引用。6.2 多目标框架与IDE版本的兼容问题生成器本身目标netstandard2.0是为了被老版本Roslyn加载而Roslyn版本取决于IDE。我测试过用Microsoft.CodeAnalysis.CSharp 4.8.0编译的生成器在VS2022预览版和.NET 8 SDK下都正常但netstandard2.0加上LangVersionlatest后代码里一旦用到较新的C#语法比如record struct、list patterns生成器自身的dll就会要求较新的Roslyn运行时去解析——生成器自身代码的语言版本不能太激进。我的惯例是生成器内部代码尽量停留在C# 10左右的保守语法生成的目标代码则可以根据消费方框架自动判断版本特性。消费方是.NET 6就用file作用域命名空间消费方是.NET Framework就用显式命名空间这个判断在生成代码时用context.ParseOptions去拿语言版本即可。6.3 团队落地的三条经验命名、可见性、审查边界这个工具实际在团队里跑了两个月留下三条经验比代码本身更值钱。第一特性名和命名空间要短、要显眼。统一namespace DotNetLombok特性名不带后缀[AutoInject]、[AutoLogger]、[AutoBuilder]。同事一眼就知道这是生成器在起作用而不是某个业务框架的行为。第二生成代码必须能被审查。我要求所有生成代码都在obj/generated目录里可查并且写进CI的核心验证环节跑一份快照测试任何一个生成结果和预期不一致构建直接失败。这样生成器改了不会无声无息影响线上同事也放心。第三给不用生成器留出路径。有的同事就是喜欢显式写构造函数你按头让他用工具反而会引发对立。我在IntelliSense代码片段库里准备了一份手写模板谁想手写就手写生成器只是选项不是强制规约。工具推广最大的阻力从来不是技术而是信任。7. 回头看这个项目到底给我和团队省了什么写到这里整个工具的核心实现已经讲完。回过头看这个项目最大的价值不在少敲了几千行代码而在它逼着我把哪些代码是模板、哪些代码是逻辑分了个清清楚楚。以前写服务类脑子要装着构造、日志、Builder一堆杂事现在只关注业务方法本身读代码的人同样只需要看业务方法。如果打算在自己的项目里复刻我会建议从[AutoInject]开始它是三个功能里收益最明显、边界最清晰的跑通之后再扩展[AutoBuilder]最后再碰[AutoLogger]这种涉及框架上下文的功能。每加一个特性之前先在纸上把不支持什么写清楚比急着把功能做出来重要得多。我最后一个小小的体会源生成器这东西第一次跑通的时候你会很兴奋第二次改出问题的时候你会骂娘但真正把它放进日复一日的工作流里就会发现它和那些基础设置一样——平时没什么存在感可一旦拿掉立刻觉得浑身别扭。这就是好的开发工具该有的样子。