恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
UnitOfWork与仓储模式落地:附自定义代码生成器实战
首页
资讯中心
/
UnitOfWork与仓储模式落地:附自定义代码生成器实战
UnitOfWork与仓储模式落地:附自定义代码生成器实战
发布时间:2026/10/15 10:20:57
做后端开发的都清楚数据访问层一旦膨胀起来代码会越来越难维护。尤其是一个功能要同时操作订单、库存、流水好几张表事务边界划在哪、谁来调 SaveChanges、仓储接口要不要按实体拆——这些问题在团队里往往能吵一上午。这一篇是这个系列的第 9 篇我打算把 UnitOfWork 的完整落地方式讲透顺便带出一个我自己每天都在用的自定义代码生成器用来把仓储、服务这些高度重复的样板代码一键批量生成。适合已经在用 ASP.NET Core 和 EF Core 写项目、但还没找到一套顺手的数据层组织方案的开发者阅读内容偏实战直接可以照着抄。先说明一点这里说的 UnitOfWork 不是把 EF Core 的 DbContext 再包一层这么简单而是要解决数据访问层的职责边界、事务控制、可测试性、以及后续代码生成的可约定性这几个问题。自定义代码生成器也是为这一套架构服务的——架构定了、约定清楚了模板才能写得稳。这篇文章我就按照这个思路往下讲。1. 先搞明白ASP.NET Core 里到底缺一个什么样的 UnitOfWork1.1 问题从一场改动三个表的事务说起我之前接手过这样的业务需求用户下单时要写订单主表、扣减库存、再写一条积分流水。团队当时的做法是三个仓储分别 Add然后在 Controller 里调用三个SaveChangesAsync。听起来没毛病但一旦第二步扣库存成功、第三步积分流水失败前面已经写进数据库的订单和库存变更就出不来了。真正的问题不是这三行代码而是数据访问层的设计缺少一个统一的事务边界。每个仓储各管各的业务层没人知道这些操作到底应该算成一个整体还是一个一个提交。这个时候 UnitOfWork 的价值就出来了它把一组仓储操作绑定在同一个上下文里由它统一提交、统一回滚业务层不用关心 EF Core 内部到底怎么跟踪状态。1.2 DbContext 与你以为的 UnitOfWork很多人以为 EF Core 的 DbContext 已经实现了工作单元模式这个说法理论上没错DbContext 内部维护了一整套变更追踪器所有对实体的修改都会记录在ChangeTracker里直到你调用SaveChangesAsync才一次性推给数据库而且这个过程本身就是原子性的。那为什么还要再包一层因为在实际项目里业务代码往往直接注入 DbContext然后在 Controller 或 Service 里到处调用_dbContext.SaveChangesAsync()。短期看很方便长期看有几个隐患一是无法强制约束业务层的提交时机今天有人调一次、明天有人调五次行为完全不可控二是测试和 mock 的成本高业务层跟具体的 EF Core 实现绑死了三是数据访问的统一入口被抹掉了后面想在第 9 篇讲的代码生成器、或者想统一注入审计字段都没有一个合适的切入点。所以我的结论是小项目、单表操作居多的项目直接用 DbContext 完全没问题没必要上 UnitOfWork但当你已经在一路演进到多仓储、多聚合根、业务层需要明确事务边界的项目时补上这一层很值。1.3 到底什么时候需要自建 UnitOfWork判断要不要自建我会看三个信号业务代码里开始频繁出现先查 A再改 B最后根据 C 的结果决定是否提交这样的跨表流程团队在代码评审时对谁负责 SaveChanges产生了持续争论项目里已经形成了每个实体一个仓储接口的约定但仓储之间缺少一个统一协调者。如果三个信号里中了两个那就可以动手了。我在这篇文章里给出的做法不是唯一正确的但它是经过项目验证、足够简单且能和代码生成器良好配合的一套。2. 仓储模式与 DbContext 生命周期不先想清楚这两件事UnitOfWork 容易画蛇添足2.1 仓储的职责边界别做成数据表的搬运工很多团队的仓储接口长这样每个表对应一个仓储里面堆满GetByXxxAsync、GetByYyyAsync这种组合查询方法最后仓储类变成一个几百行的工具类。这种做法其实违背了仓储模式的核心意图——仓储应该是聚合的入口而不是数据表的搬运工。我的做法很务实泛型仓储接口只提供最基础的几个能力——按主键查、查全部、提供IQueryable查询入口、新增、更新、删除。复杂查询不塞进仓储接口让业务层通过Query()自己组合或者单独写查询服务。这样仓储接口保持稳定后面我们做代码生成时模板也容易写得简单。public interface IRepositoryT where T : class { TaskT? GetByIdAsync(object id); TaskIEnumerableT GetAllAsync(); IQueryableT Query(); Task AddAsync(T entity); void Update(T entity); void Delete(T entity); }可能有人会问GetAllAsync一次性把所有数据拉到内存会不会有问题会但这个方法本身就是为简单的字典表、配置表准备的真正的列表页和分页查询一律走Query()所以我并不担心这个接口被滥用。2.2 Scoped 生命周期背后的工作单元语义ASP.NET Core 里默认把 DbContext 注册成 Scoped 生命周期——一个 HTTP 请求内所有注入的地方拿到的是同一个实例。这个设计天然就跟 UnitOfWork 的一个业务操作一个工作单元是对齐的同一个请求里改了多少实体最后统一SaveChangesAsync提交的边界就是请求的边界。但这里藏着一个很容易踩的坑如果 UnitOfWork 注册成了 Singleton或者有人在请求里手动 new 了 DbContext那工作单元的语义就崩了。Singleton 会导致并发请求共享同一个 DbContextEF Core 的并发跟踪会直接抛异常手动 new 的 DbContext 则完全脱离了请求级别的共享事务边界会莫名其妙地扩大或缩小。所以 UnitOfWork 必须是 Scoped它内部持有的 DbContext 也必须跟着请求走。这一点我在代码生成器的服务注册代码里也会特别注意每次生成完代码都要检查一遍不要注册成 Singleton。2.3 多 DbContext 与事务协调一个项目里如果存在多个 DbContext比如一个管业务库、一个管日志库UnitOfWork 的价值就进一步放大了。每个 DbContext 各自是一个工作单元跨 DbContext 的写入就不能依赖单一事务了得用TransactionScope或者分布式事务协调方案。我对这个问题的处理原则是能拆就拆尽量不让一个业务操作同时横跨两个库。如果确实改不了那就用TransactionScopeAsyncFlowOption.Enabled做隐式事务协调但要注意数据库必须支持分布式事务而且性能会打折。UnitOfWork 在这个场景下的职责就是保证每个 DbContext 内的操作仍然以整体提交为边界跨库的协调由专门的应用服务来完成。3. BaseRepository 与 UnitOfWork 的落地实现一个靠得住的最小骨架3.1 接口设计小而稳的边界先看仓储的泛型实现继承前面定义的接口。这里我用 EF Core 的SetT()来获取实体的 DbSet这样就不需要为每个实体单独写一个仓储实现了新增实体后只要泛型接口还在编译器会自动帮你生成对应的类型化仓储。public class BaseRepositoryT : IRepositoryT where T : class { private readonly AppDbContext _context; private readonly DbSetT _dbSet; public BaseRepository(AppDbContext context) { _context context; _dbSet context.SetT(); } public virtual async TaskT? GetByIdAsync(object id) { return await _dbSet.FindAsync(id); } public virtual async TaskIEnumerableT GetAllAsync() { return await _dbSet.ToListAsync(); } public virtual IQueryableT Query() { return _dbSet.AsQueryable(); } public virtual async Task AddAsync(T entity) { await _dbSet.AddAsync(entity); } public virtual void Update(T entity) { _dbSet.Update(entity); } public virtual void Delete(T entity) { _dbSet.Remove(entity); } }有两点想说明所有方法都标了virtual这是刻意留的口子——某个实体仓储确实需要重写查询逻辑时不用改基类Update和Delete没有标记async因为 EF Core 的修改和删除是纯内存操作异步只是浪费。3.2 UnitOfWork 实现细节与仓储缓存接下来是核心的 IUnitOfWork 接口和实现。我设计的时候刻意让接口保持最小只放四个能力获取仓储、统一保存、开启事务、提交/回滚。不放GetEntities、ClearTracking这类锦上添花的东西原因很简单接口每多一个成员后面所有 mock 和测试的成本就高一截。public interface IUnitOfWork : IDisposable { IRepositoryT GetRepositoryT() where T : class; Taskint SaveChangesAsync(CancellationToken cancellationToken default); TaskIDbContextTransaction BeginTransactionAsync(); Task CommitAsync(); Task RollbackAsync(); }实现里有一个容易被忽略的细节仓储实例要用字典缓存起来。如果不缓存每次GetRepositoryT()都new一个同一个工作单元内两处调用拿到的是不同实例看起来不影响最终SaveChanges的结果但会失去状态的一致性也容易让开发者在 Debug 时产生为什么这个列表没更新的困惑。public class UnitOfWork : IUnitOfWork { private readonly AppDbContext _context; private readonly DictionaryType, object _repositories new(); public UnitOfWork(AppDbContext context) { _context context; } public IRepositoryT GetRepositoryT() where T : class { var type typeof(T); if (_repositories.TryGetValue(type, out var existing)) { return (IRepositoryT)existing; } var repository new BaseRepositoryT(_context); _repositories[type] repository; return repository; } public async Taskint SaveChangesAsync(CancellationToken cancellationToken default) { return await _context.SaveChangesAsync(cancellationToken); } public async TaskIDbContextTransaction BeginTransactionAsync() { return await _context.Database.BeginTransactionAsync(); } public async Task CommitAsync() { await _context.Database.CommitTransactionAsync(); } public async Task RollbackAsync() { await _context.Database.RollbackTransactionAsync(); } public void Dispose() { _context.Dispose(); } }这套实现的关键约束是UnitOfWork和AppDbContext都是 Scoped。因为我们在构造函数里只注入了AppDbContext没有注入IServiceScopeFactory之类的东西所以它的生命周期完全跟 DbContext 一致——请求开始UnitOfWork 被创建请求结束两个一起释放。3.3 事务封装与审计字段注入UnitOfWork 还应该提供一个自动事务的便捷方法。业务层最常见的写法是开事务执行若干仓储操作统一保存提交出异常就回滚。我把这个流程封装成一个泛型方法业务层只需要把核心逻辑写在一个委托里。public async TaskTResult ExecuteInTransactionAsyncTResult(FuncTaskTResult action) { await using var transaction await BeginTransactionAsync(); try { var result await action(); await SaveChangesAsync(); await CommitAsync(); return result; } catch { await RollbackAsync(); throw; } }另外我一直强烈建议在 UnitOfWork 的SaveChangesAsync里统一处理审计字段。很多入门项目会漏掉CreatedAt和UpdatedAt因为每次新增实体都要手写entity.CreatedAt DateTime.UtcNow漏一次数据就脏一次。放在 UnitOfWork 里一劳永逸public virtual async Taskint SaveChangesAsync(CancellationToken cancellationToken default) { var now DateTime.UtcNow; foreach (var entry in _context.ChangeTracker.Entries()) { if (entry.Entity is IAuditable auditable) { switch (entry.State) { case EntityState.Added: auditable.CreatedAt now; auditable.UpdatedAt now; break; case EntityState.Modified: auditable.UpdatedAt now; break; } } } return await _context.SaveChangesAsync(cancellationToken); }这一小段代码基本能消灭掉项目里一半的为什么这个时间字段是空的 BUG。我后面讲代码生成器时生成的服务层骨架也会自动带上IAuditable的调用逻辑因为这条线已经打通了。4. 推荐用 Repository 模板 自定义生成器的四个理由4.1 为什么一定要自定义代码生成代码写到这里你已经发现了仓储、服务这两层高度重复。新增一个实体类型你至少得写一个IXRepository接口、一个XRepository实现、一个IXService接口、一个XService实现。四个文件内容几乎一模一样区别只是把Product换成Category、把int换成Guid。手写的成本不只是敲键盘的时间更在于容易复制粘贴出错。比如忘了把Product改成Category编译直接报错或者接口里的方法签名跟实现不一致运行期才暴露。就算你经验丰富连续写几十个这样的文件也会无聊到怀疑人生。自定义代码生成器就是把这类高重复、低智力密度的工作交给机器让自己腾出手来写真正的业务逻辑。4.2 备选方案对比T4、Scriban 与手写拼接做代码生成不同团队有不同的选择。我把常见方案放在一起对比方案优点缺点适合场景T4 模板Visual Studio 原生支持、可配合 EF 模型跨平台体验差、命令行自动化弱、模板语法略显笨重Windows 生态内的传统团队Scriban 模板语法简洁、跨平台、可在控制台工具中复用需要额外引包、团队需要学习模板语法我要讲的自定义生成器方案手写字符串拼接零依赖、随改随用代码极其啰嗦、改一处模板要动大量 C# 代码一次性临时脚本Source Generator编译期生成、无运行时反射开销门禁高、调试模板麻烦、生成的是隐式代码追求极致性能的框架级团队我最终选了 Scriban因为它是独立的模板引擎生成器可以做成一个普通的控制台项目在 Linux 编译机、CI 环境、本地开发环境行为一致。T4 也不是不能用但真到了命令行自动化那一步就难受了。后面所有模板我都用 Scriban 来做你如果之前没用过跟着代码走一遍就熟了。4.3 生成策略空白接口加实现业务差异留给你这里想强调一个关键策略生成的仓储接口是空接口不包含任何业务方法。它存在的意义是给按实体拆扩展点留位置同时让依赖注入和代码查找更有语义感。比如IProductRepository继承IRepositoryProduct里面一开始是空的。等哪天Product需要查最近上架的商品列表时你直接在接口里补一个方法、在实现里写 SQL 即可。生成器每次重新运行都只覆盖这个空接口文件不会把你手写的内容删掉——这个冲突控制策略后面会细讲。public interface IProductRepository : IRepositoryProduct { // 这里可以按需添加扩展方法 }服务层也是一样的思路。模板只生成最基础的 CRUD 骨架根据主键查、查全部、新增、更新、删除。分页、搜索、权限这类业务差异极大的方法我明确不生成它们应该出现在业务服务或专门查询服务里。这样生成器永远不会成为业务发展的阻碍。5. 代码生成器的整体设计与运行流程5.1 扫描实体从 DbContext 反射拿元数据生成器的第一步是拿到项目里到底有哪些实体。最可靠的方式不是去扫描文件夹也不是读一个手工维护的清单而是直接从 DbContext 的模型元数据里拿。这样实体定义和生成结果永远是同步的。下面的代码从一个AppDbContext实例里取出所有带主键的实体类型顺便过滤掉 owned type值对象这类不需要独立仓储的类型using var db CreateDbContext(); var entityTypes db.Model.GetEntityTypes() .Where(e !e.IsOwned() e.FindPrimaryKey() ! null) .ToList();然后对每个实体类型提取生成模板所需的变量实体名、命名空间、主键类型。主键我不用猜名字的方式而是直接从 EF Core 的主键元数据里取这样就算你的主键叫ProductGuid而不是Id生成器也能正确处理var meta entityType; var entityClrType meta.ClrType; var entityName entityClrType.Name; var keyProperty meta.FindPrimaryKey().Properties[0]; var primaryKeyType keyProperty.ClrType.Name; var tableName meta.GetTableName();5.2 Scriban 模板实操仓储层模板解析拿到实体元数据后就可以往模板里灌数据了。Scriban 的语法核心就一个{{ 变量名 }}。我的仓储接口模板长这样namespace MyProject.Repositories; using MyProject.Entities; public interface I{{ entity_name }}Repository : IRepository{{ entity_name }} { // 这里可以按需添加扩展方法 }对应的仓储实现模板namespace MyProject.Repositories; using MyProject.Entities; using Microsoft.EntityFrameworkCore; public class {{ entity_name }}Repository : BaseRepository{{ entity_name }}, I{{ entity_name }}Repository { public {{ entity_name }}Repository(AppDbContext context) : base(context) { } }模板里的命名空间、引用是固定的因为整个解决方案已经统一了目录约定。这里要说明一个容易踩的坑模板里如果出现 C# 的大括号Scriban 是能正常处理的——Scriban 只认{{ }}这种双花括号标签普通单层花括号照常输出。之所以我建议用文件级命名空间namespace X;而不是块级命名空间不是技术限制而是生成出来的文件更干净、diff 时更好看。5.3 输出路径与命名空间约定代码生成最怕的是生成出来的文件不知道放哪、不知道是否覆盖。我的约定是接口统一放到Domain/Repositories目录实现统一放到Infrastructure/Repositories目录服务接口放到Application/Interfaces服务实现放到Application/Services生成规则I{实体名}Repository.cs、{实体名}Repository.cs、I{实体名}Service.cs、{实体名}Service.cs。由于命名空间跟目录严格对应模板里面不用写一堆条件逻辑。我的生成器里有个ProjectPaths配置文件用相对路径定位这些目录保证了 generate 一次、全项目文件位置都不乱。5.4 生成器的运行与覆盖策略生成器主体是一个控制台项目核心逻辑就是一个 foreach 循环对每个实体渲染四份模板然后写文件foreach (var meta in entityTypes) { var entityName meta.ClrType.Name; var primaryKeyType meta.FindPrimaryKey().Properties[0].ClrType.Name; var model new { entity_name entityName, entity_namespace meta.ClrType.Namespace, primary_key_type primaryKeyType, table_name meta.GetTableName() }; var irContent Template.Parse(irRepoTemplate).Render(model); File.WriteAllText( Path.Combine(Paths.RepositoriesDir, $I{entityName}Repository.cs), irContent); var repoContent Template.Parse(repoTemplate).Render(model); File.WriteAllText( Path.Combine(Paths.RepositoriesDir, ${entityName}Repository.cs), repoContent); // 服务层同样处理 }写文件这里我强调一个原则一律用File.WriteAllText覆盖写绝不追加。追加模式第一次运行没事第二次运行就会在文件后面拼出两份一模一样的内容编译直接报重复定义。覆盖写配合 Git让每次生成结果的 diff 都清清楚楚。6. 从模板到上线把生成代码接入项目的实战步骤6.1 Demo 实体准备Product 与 Category为了让你有体感我拿一个极简的电商场景做例子。项目里有两张表Product和Category实体定义如下public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public decimal Price { get; set; } public int CategoryId { get; set; } } public class Category { public int Id { get; set; } public string Name { get; set; } string.Empty; }运行生成器之后Repositories目录下会多出四个文件Application/Services下再多四个文件。这时项目还不能编译因为服务层的实现里依赖了IRepositoryT和IUnitOfWork的注入需要先完成服务注册。6.2 服务注册与管线装配在Program.cs里添加依赖注入配置builder.Services.AddScopedIUnitOfWork, UnitOfWork(); builder.Services.AddScoped(typeof(IRepository), typeof(BaseRepository)); // 生成的仓储和服务手动注册进去 builder.Services.AddScopedIProductRepository, ProductRepository(); builder.Services.AddScopedICategoryRepository, CategoryRepository(); builder.Services.AddScopedIProductService, ProductService(); builder.Services.AddScopedICategoryService, CategoryService();如果你不想每次生成完手动加注册可以再写一个小反射扫描器在启动时按照约定批量注册。不过我自己更倾向于显式注册因为项目到中后期仓储数量有限显式注册反而方便排查不会出现我明明写了这个接口怎么跑起来没生效的魔法问题。6.3 Controller 实战完整增删改查链路服务层生成之后Controller 的编写体验会非常舒服。下面这个ProductsController就是标准的 CRUD 链路完全不用碰 DbContext也不用手动调仓储[ApiController] [Route(api/[controller])] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService productService; } [HttpGet] public async TaskIActionResult Get() { return Ok(await _productService.GetAllAsync()); } [HttpGet({id})] public async TaskIActionResult GetById(int id) { var product await _productService.GetByIdAsync(id); return product is null ? NotFound() : Ok(product); } [HttpPost] public async TaskIActionResult Create(Product product) { var count await _productService.CreateAsync(product); return count 0 ? Ok() : BadRequest(); } [HttpPut] public async TaskIActionResult Update(Product product) { var count await _productService.UpdateAsync(product); return count 0 ? Ok() : NotFound(); } [HttpDelete({id})] public async TaskIActionResult Delete(int id) { var count await _productService.DeleteAsync(id); return count 0 ? Ok() : NotFound(); } }注意CreateAsync、UpdateAsync、DeleteAsync这些方法内部其实就是我前面 UnitOfWork 那一节的用法——在服务层内部拿到仓储完成操作后统一调用_unitOfWork.SaveChangesAsync()。这套链路一旦跑通后面每加一个新实体你只需要新建实体类、跑生成器、注册依赖、写 Controller十分钟内能交付一个标准 CRUD 模块。6.4 验证编译、测试与代码审查生成代码接入项目后我一般会做三道检查第一道是编译检查。dotnet build必须零错误。如果生成器输出的文件有问题绝大多数会在这里暴露——命名空间不对、类型找不到、using 缺失一眼就能看出来。第二道是数据库层面的真实调用。我会用集成测试把每个实体的新增-查询-修改-删除走一遍。这一步能抓到运行时问题比如主键类型模板填错了、表名映射不对、外键约束没配好。用 SQLite 内存库跑集成测试是非常省事的方式但要注意有些数据库特性 SQLite 不支持最好还是对着真实的开发库跑一遍。第三道是代码审查。我把生成的文件和手写的文件分开审查的重点只放在手写逻辑上。生成的代码如果反复出现低级问题那应该去改模板而不是在代码审查时一个个手改。这个理念是我做代码生成器之后最深刻的体会生成器的产出质量取决于模板质量而不取决于写代码的人有多细心。7. 我把这套方案用在真实项目后的心得与踩坑清单7.1 生成与手写代码的边界用久了你会发现代码生成器有个隐形的边界它能生成的是形态高度一致的代码不能生成的是业务判断差异极大的代码。在我目前的实践里适合生成的包括实体仓储接口与实现、服务接口与 CRUD 骨架、基础的 DTO 映射。不适合生成的是Controller分页、权限、幂等策略差异太大、领域服务里的核心业务规则、报表和复杂查询。这个边界不是一成不变的。比如后来我在模板里加了软删除支持所有实现了ISoftDelete接口的实体生成的仓储实现会自动带上IsDeleted false的过滤条件。这不属于 CRUD 骨架但它已经成了团队约定的一部分所以模板化也是合理的。7.2 踩过的坑清单与解决办法下面这些坑几乎都是我从真实项目里踩出来的每条都带解决办法问题原因分析解决办法生成的仓储文件 using 缺失实体命名空间和仓储命名空间不同模板统一加上using MyProject.Entities;生成后编译报重复类定义多次运行用了追加写文件统一File.WriteAllText覆盖复合主键实体没生成仓储模板默认取第一个主键属性生成前检查主键数量复合主键跳过并输出警告服务层模板里 C# 大括号乱掉误以为 Scriban 会解析普通花括号Scriban 只解析{{ }}普通括号原样输出无须转义实体里有无键查询类型被生成了仓储没过滤IsKeyless和无主键类型Where(e e.FindPrimaryKey() ! null)过滤运行生成器时 DbContext 无法实例化构造函数依赖服务或数据库连接提供 DesignTime 工厂方式创建 DbContext最后一条值得展开说说。如果你的 DbContext 构造函数注入了配置或者别的东西直接new会失败。我采用的方式是定义设计时工厂专门给生成器和迁移工具用public class AppDbContextFactory : IDesignTimeDbContextFactoryAppDbContext { public AppDbContext CreateDbContext(string[] args) { var optionsBuilder new DbContextOptionsBuilderAppDbContext(); optionsBuilder.UseSqlServer(Server(localdb)\\mssqllocaldb;DatabaseMyProjectDev;Trusted_ConnectionTrue;); return new AppDbContext(optionsBuilder.Options); } }这样生成器只需调用new AppDbContextFactory().CreateDbContext(Array.Emptystring())就能拿到可用的上下文不需要真实连线也能扫描模型元数据。7.3 把这套东西扩展成团队基建生成器跑顺之后我做的第一件事就是把它交到团队手里让大家在新增实体时都走这套流程。为了让推广更顺利我做了三件事第一把生成器接入解决方案用dotnet run一键执行并在项目 README 里写下标准操作流程新增实体 - 跑生成器 - 注册服务 - 写 Controller。第二在 CI 里加了一个检查任务如果发现某个实体的仓储或服务文件缺失直接报错提示防止有人绕开流程。第三约定所有生成文件头部都写上本文件由生成器生成请勿手动修改需要修改时请改模板这样一行注释减少误改。这套组合拳下来团队的 CRUD 开发速度和一致性明显上了一个台阶。注意生成器本身也一直在迭代——谁发现模板有问题直接改代码生成项目的模板而不是手工去改 N 个生成文件。这已经成了我们数据层的隐形成熟度加分项。最后再分享一个我自己用的技巧别把生成器局限在实体-仓储-服务这三件套上。你完全可以在同一个工具里继续扩展比如读取数据库表的注释生成 DTO 的 Swagger 说明、根据枚举实体自动生成常量类、甚至生成自动化测试的骨架。每次团队里出现这代码模式完全一样就是名字不一样的抱怨时都是生成器的下一个扩展点。把这个工具养起来它会成为整个数据层最顺手的那根杠杆。