1. 为什么我决定在.NET上"复刻"一个Lombok
1.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 Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> </ItemGroup> </Project>消费方引用的写法才是核心,很多人栽在这里:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> </PropertyGroup> <ItemGroup> <ProjectReference Include="..\..\src\DotNetLombok.Generator\DotNetLombok.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> </ItemGroup> </Project>这里OutputItemType="Analyzer"表示把该项目当作分析器传给编译器,ReferenceOutputAssembly="false"表示不把这个程序集加进消费方的运行时依赖。少任何一个,生成器要么不跑,要么会把生成器本身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> <EmitCompilerGeneratedFiles>true</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 ILogger<OrderService> _logger; }生成器自动产出下面的代码:
public partial class OrderService { public OrderService(IOrderRepository repository, ILogger<OrderService> 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时抛诊断,把决定权留给用户。与其生成一个语义可能错误的代码,不如明确说不支持。
第三个是泛型类。Repository<T>这类类型要原样保留泛型参数,生成代码时要把T的约束也接上。代码生成时我用symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)拿全限定名,这比手拼global::前缀安全得多,也不会漏掉Nullable上下文。
3.4 生成效果对照:直接贴手写和生成的差异
我拿一个真实的服务类做对比,感兴趣可以自己抄下来体会。手写版:
public partial class InventoryService { private readonly IInventoryRepository _repository; private readonly IMapper _mapper; private readonly ILogger<InventoryService> _logger; public InventoryService( IInventoryRepository repository, IMapper mapper, ILogger<InventoryService> logger) { _repository = repository; _mapper = mapper; _logger = logger; } public async Task<InventoryDto> GetAsync(int id) { var entity = await _repository.GetByIdAsync(id); return _mapper.Map<InventoryDto>(entity); } }生成器版(用户手写的部分):
[AutoInject] public partial class InventoryService { [Inject] private readonly IInventoryRepository _repository; [Inject] private readonly IMapper _mapper; [Inject] private readonly ILogger<InventoryService> _logger; public async Task<InventoryDto> GetAsync(int id) { var entity = await _repository.GetByIdAsync(id); return _mapper.Map<InventoryDto>(entity); } }字段声明仍然显式写,这是故意的。隐式字段会导致代码可读性断崖式下降,同事看你这个类根本不知道依赖有哪些。透明、可预期,是这类代码生成工具在设计上必须守住的底线。
4. 日志注入与构造者模式:把两个高频样板按模板化
4.1 日志注入:不硬编码日志框架的懒加载方案
日志注入看起来简单:生成一个ILogger字段就行了。但实际设计时有一个隐藏问题:ILogger<T>里的T必须是使用该日志的类。如果生成器给OrderService生成了ILogger<OrderService>,那就要默认用户一定注入了日志框架。这在某些纯领域项目里根本不存在,硬塞进去就是不可控的依赖。
我的设计是这样:生成器只生成一个IsExternalInit风格的日志属性占位,采用延迟获取模式,不向构造函数里添加参数,也不强制要求DI容器提供ILogger:
public partial class OrderService { private ILogger<OrderService>? _logger; protected ILogger<OrderService> Logger => _logger ??= Microsoft.Extensions.Logging.LoggerFactory .Create(builder => builder.AddConsole()) .CreateLogger<OrderService>(); }等等,我实际测试后发现直接LoggerFactory是坑——如果服务端已经配置了结构化日志、日志级别过滤,你这么创建会绕过全局配置,日志行为会跟项目里其他地方不一致。所以我最后改成了可替换的注入点:默认生成如下结构:
public partial class OrderService { private ILogger<OrderService>? _logger; protected ILogger<OrderService> Logger => _logger ??= CreateLogger(); partial void CreateLoggerPartial(ref ILogger<OrderService> 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; }生成器产出一个SearchRequestBuilder:
public 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(); }设计上我有三条原则:
- 可变集合属性不直接用字段默认值。如果属性是
List<string> Tags,生成字段直接new List<string>(),否则用户调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彻底重启一次,别只build,build往往不够。
5.2 用诊断输出当"日志",让生成过程可见
生成器代码里Console.WriteLine是看不到的,因为编译器不在控制台环境跑你的代码。我后来养成一个好习惯:一切运行信息都走Diagnostic通道。
我在生成器里定义一个DiagnosticDescriptor,Severity设为Info,IsEnabledByDefault设为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> <IncludeBuildOutput>false</IncludeBuildOutput> <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking> </PropertyGroup> <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup>SuppressDependenciesWhenPacking很关键,它避免把生成器依赖的Microsoft.CodeAnalysis.CSharp也打进依赖项列表,因为生成器运行时编译器会自己提供Roslyn程序集,不需要也不应该单独引用。
6.2 多目标框架与IDE版本的兼容问题
生成器本身目标netstandard2.0是为了被老版本Roslyn加载,而Roslyn版本取决于IDE。我测试过用Microsoft.CodeAnalysis.CSharp 4.8.0编译的生成器,在VS2022预览版和.NET 8 SDK下都正常,但netstandard2.0加上LangVersion=latest后,代码里一旦用到较新的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]这种涉及框架上下文的功能。每加一个特性之前,先在纸上把"不支持什么"写清楚,比急着把功能做出来重要得多。
我最后一个小小的体会:源生成器这东西,第一次跑通的时候你会很兴奋,第二次改出问题的时候你会骂娘,但真正把它放进日复一日的工作流里,就会发现它和那些基础设置一样——平时没什么存在感,可一旦拿掉,立刻觉得浑身别扭。这就是好的开发工具该有的样子。