ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于.NET Core的WebAPI代码生成工具:自动连接数据库与模板引擎实践

基于.NET Core的WebAPI代码生成工具:自动连接数据库与模板引擎实践 简介这是一款面向.NET Core Webapi开发者的代码生成工具核心能力是自动连接数据库并基于数据模型快速生成API代码框架适合希望减少重复编码、专注业务逻辑的中初级开发者也可作为团队快速搭建项目骨架的辅助方案。压缩包共1051个文件约58.82MB以js、scss、css、html等前端资源为主配合json、yml等配置文件以及pdb、exe、web.config等部署与调试文件构成一个可直接运行的完整Webapi项目结构。资源中附带直观易用的生成脚本与使用说明帮助使用者快速理解工具的运行方式与配置要点。目前已有80人学习下载。通过这份资源读者可以省去从零手写基础代码的过程直接获得包含后端服务、前端页面与部署配置的完整项目参考并借助脚本与文档掌握自动生成代码的流程从而提升开发效率与项目搭建速度。1. 从建表到接口只差一个命令.NET Core WebAPI 代码生成工具到底在解决什么新项目立项数据库表结构评审通过接下来就是最枯燥的环节手写 Entity、DbContext、DTO、Service、Controller一张十几列的表要敲两百多行样板代码五张表就是一千行。更麻烦的是字段类型映射——数据库的datetime2对应DateTimedecimal(18,2)对应decimalnvarchar(max)对应string稍不留神就写错。.NET Core WebAPI 代码生成工具自动连接数据库要干的事很直接连上你的数据库读取表结构按模板吐出整套可编译的 WebAPI 代码。它适合三类人——正在搭新项目骨架的后端、需要快速做原型验证的独立开发者、以及维护几十张表老系统想批量补接口的团队。核心价值不是省打字而是把字段映射、命名规范、分层结构这些容易出错的重复劳动交给程序人只负责业务逻辑。下面从选型到落地一步步拆。2. 自动连接数据库的三种取数路径选错一个后面全是坑2.1 为什么元数据读取方式决定了工具的适用范围代码生成工具的第一步永远是拿到表结构。这一步的实现方式直接决定了工具能支持哪些数据库、能不能处理视图和存储过程、以及生成速度。常见做法有三条路径各有明确的适用边界。第一条是查询系统表。SQL Server 用INFORMATION_SCHEMA.COLUMNS和sys.tablesMySQL 用information_schema库PostgreSQL 用pg_catalog。这种方式不依赖任何额外组件纯 SQL 就能拿到表名、列名、数据类型、是否可空、默认值、主键、外键。缺点是不同数据库的系统表结构差异大要写多套查询语句。我一般会为每种数据库写一个ISchemaReader实现用工厂模式在运行时根据连接字符串切换。第二条是走 ADO.NET 的GetSchema()方法。DbConnection基类提供了统一的元数据接口调用connection.GetSchema(Tables)和connection.GetSchema(Columns)就能拿到结构信息不用关心底层是哪个数据库。听起来很美但实际用起来会发现不同数据库驱动的GetSchema返回的列名和格式并不统一SQL Server 返回的DATA_TYPE是nvarcharMySQL 可能返回varchar而且外键和索引信息经常拿不全。适合快速原型不适合生产级工具。第三条是解析建表 SQL 脚本。有些团队没有数据库直连权限只能拿到.sql文件。这时候需要用正则或 SQL 解析器提取CREATE TABLE语句。这种方式最脆弱——注释、换行、大小写、方言差异都会让正则翻车。除非确实连不上数据库否则不建议走这条路。选型建议很明确生产环境用系统表查询原型验证用 GetSchema离线场景才考虑 SQL 解析。下面给出 SQL Server 和 MySQL 两套系统表查询的核心代码。2.2 用系统表查询拿到表结构的完整代码先定义一个通用的表结构模型后续所有数据库的读取结果都映射到这个模型上// Models/TableSchema.cs // 通用表结构模型屏蔽不同数据库的差异 public class TableSchema { public string TableName { get; set; } string.Empty; public string? TableComment { get; set; } // 表注释部分数据库支持 public ListColumnSchema Columns { get; set; } new(); public Liststring PrimaryKeys { get; set; } new(); } public class ColumnSchema { public string ColumnName { get; set; } string.Empty; public string DbType { get; set; } string.Empty; // 数据库原始类型如 nvarchar public string CsharpType { get; set; } string.Empty; // 映射后的 C# 类型 public bool IsNullable { get; set; } public bool IsPrimaryKey { get; set; } public int? MaxLength { get; set; } public string? Comment { get; set; } }然后是 SQL Server 的读取实现。注意INFORMATION_SCHEMA拿不到列注释需要关联sys.extended_properties// SchemaReaders/SqlServerSchemaReader.cs public class SqlServerSchemaReader : ISchemaReader { private readonly string _connectionString; public SqlServerSchemaReader(string connectionString) _connectionString connectionString; public async TaskListTableSchema ReadAsync(string[]? filterTables null) { var result new ListTableSchema(); using var conn new SqlConnection(_connectionString); await conn.OpenAsync(); // 查询表清单可选按表名过滤 var tableSql SELECT t.name AS TableName, ep.value AS TableComment FROM sys.tables t LEFT JOIN sys.extended_properties ep ON ep.major_id t.object_id AND ep.minor_id 0 AND ep.name MS_Description WHERE (filter IS NULL OR t.name IN (SELECT value FROM STRING_SPLIT(filter, ,))) ORDER BY t.name; using var cmd new SqlCommand(tableSql, conn); cmd.Parameters.AddWithValue(filter, filterTables null ? DBNull.Value : string.Join(,, filterTables)); using var reader await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { result.Add(new TableSchema { TableName reader.GetString(0), TableComment reader.IsDBNull(1) ? null : reader.GetString(1) }); } await reader.CloseAsync(); // 逐表读取列信息 foreach (var table in result) { var colSql SELECT c.name, ty.name AS DbType, c.is_nullable, c.max_length, CASE WHEN pk.column_id IS NOT NULL THEN 1 ELSE 0 END AS IsPk, ep.value AS Comment FROM sys.columns c JOIN sys.types ty ON c.user_type_id ty.user_type_id LEFT JOIN ( SELECT ic.column_id FROM sys.index_columns ic JOIN sys.indexes i ON ic.index_id i.index_id AND ic.object_id i.object_id WHERE i.is_primary_key 1 AND ic.object_id OBJECT_ID(tableName) ) pk ON pk.column_id c.column_id LEFT JOIN sys.extended_properties ep ON ep.major_id c.object_id AND ep.minor_id c.column_id AND ep.name MS_Description WHERE c.object_id OBJECT_ID(tableName) ORDER BY c.column_id; using var colCmd new SqlCommand(colSql, conn); colCmd.Parameters.AddWithValue(tableName, table.TableName); using var colReader await colCmd.ExecuteReaderAsync(); while (await colReader.ReadAsync()) { table.Columns.Add(new ColumnSchema { ColumnName colReader.GetString(0), DbType colReader.GetString(1), IsNullable colReader.GetBoolean(2), MaxLength colReader.GetInt16(3), IsPrimaryKey colReader.GetInt32(4) 1, Comment colReader.IsDBNull(5) ? null : colReader.GetString(5) }); } } return result; } }这段代码的关键点有三个。第一sys.extended_properties是 SQL Server 存列注释的地方minor_id 0表示表级注释minor_id column_id表示列级注释很多工具漏掉这一步导致生成的代码没有注释。第二STRING_SPLIT是 SQL Server 2016 及以上才有的函数如果目标环境版本低要改成动态拼接IN子句。第三max_length对nvarchar返回的是字节数实际字符数要除以 2这个坑后面避坑章节会细说。MySQL 的实现思路类似但系统表在information_schema库里注释直接存在COLUMNS.COLUMN_COMMENT字段不需要额外关联// SchemaReaders/MySqlSchemaReader.cs public class MySqlSchemaReader : ISchemaReader { private readonly string _connectionString; public MySqlSchemaReader(string connectionString) _connectionString connectionString; public async TaskListTableSchema ReadAsync(string[]? filterTables null) { var result new ListTableSchema(); using var conn new MySqlConnection(_connectionString); await conn.OpenAsync(); var sql SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA DATABASE() AND TABLE_TYPE BASE TABLE AND (filter IS NULL OR FIND_IN_SET(TABLE_NAME, filter)) ORDER BY TABLE_NAME; using var cmd new MySqlCommand(sql, conn); cmd.Parameters.AddWithValue(filter, filterTables null ? DBNull.Value : string.Join(,, filterTables)); using var reader await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { result.Add(new TableSchema { TableName reader.GetString(0), TableComment reader.IsDBNull(1) ? null : reader.GetString(1) }); } await reader.CloseAsync(); foreach (var table in result) { var colSql SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH, COLUMN_KEY, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA DATABASE() AND TABLE_NAME tableName ORDER BY ORDINAL_POSITION; using var colCmd new MySqlCommand(colSql, conn); colCmd.Parameters.AddWithValue(tableName, table.TableName); using var colReader await colCmd.ExecuteReaderAsync(); while (await colReader.ReadAsync()) { table.Columns.Add(new ColumnSchema { ColumnName colReader.GetString(0), DbType colReader.GetString(1), IsNullable colReader.GetString(2) YES, MaxLength colReader.IsDBNull(3) ? null : colReader.GetInt32(3), IsPrimaryKey colReader.GetString(4) PRI, Comment colReader.IsDBNull(5) ? null : colReader.GetString(5) }); } } return result; } }MySQL 这边要注意CHARACTER_MAXIMUM_LENGTH对text类型返回的是65535对longtext返回4294967295映射到 C# 时统一用string即可不要试图用MaxLength特性去限制。另外COLUMN_KEY除了PRI还有UNI和MUL分别代表唯一索引和普通索引如果生成 DTO 时需要标注唯一性校验要把UNI也纳入判断。2.3 类型映射表数据库类型到 C# 类型的对照规则拿到原始类型后下一步是映射到 C# 类型。这张表是整个工具的核心配置建议做成可扩展的字典而不是硬编码switch数据库类型SQL Server / MySQLC# 类型可空处理备注int / intintint?主键自增时保持 intbigint / bigintlonglong?smallint / smallintshortshort?tinyint / tinyintbytebyte?MySQL 的 tinyint(1) 常作 boolbit / bit(1)boolbool?decimal / decimaldecimaldecimal?金额字段必须用 decimalfloat / floatdoubledouble?real / doublefloatfloat?datetime / datetimeDateTimeDateTime?datetime2 / datetime(6)DateTimeDateTime?date / dateDateOnlyDateOnly?.NET 6 支持time / timeTimeOnlyTimeOnly?.NET 6 支持nvarchar / varcharstringstring?引用类型默认可空ntext / textstringstring?uniqueidentifier / char(36)GuidGuid?varbinary / blobbyte[]byte[]?映射逻辑用字典实现方便后续加数据库// TypeMapper.cs public static class TypeMapper { // key 为数据库类型小写value 为 C# 类型名 private static readonly Dictionarystring, string Map new(StringComparer.OrdinalIgnoreCase) { [int] int, [bigint] long, [smallint] short, [tinyint] byte, [bit] bool, [decimal] decimal, [numeric] decimal, [float] double, [real] float, [double] double, [datetime] DateTime, [datetime2] DateTime, [smalldatetime] DateTime, [date] DateOnly, [time] TimeOnly, [nvarchar] string, [varchar] string, [char] string, [nchar] string, [ntext] string, [text] string, [uniqueidentifier] Guid, [varbinary] byte[], [binary] byte[], [blob] byte[] }; public static string ToCsharp(string dbType, bool isNullable) { if (!Map.TryGetValue(dbType, out var csType)) csType string; // 未知类型兜底为 string避免生成失败 // 值类型可空时加 ?引用类型本身可空不加 var isValueType csType ! string csType ! byte[]; return isNullable isValueType ? csType ? : csType; } }这里有个容易忽略的点MySQL 的tinyint(1)通常表示布尔值但information_schema返回的DATA_TYPE只是tinyint拿不到显示宽度。如果业务上确实用tinyint(1)存布尔需要在读取时额外查COLUMN_TYPE字段返回tinyint(1)来判断。我一般会在ColumnSchema里加一个RawColumnType字段存原始定义映射时优先判断。3. 从表结构到可编译代码模板引擎选型与生成流程3.1 Scriban 还是 T4模板引擎的取舍拿到表结构后要把它渲染成 C# 代码文件。模板引擎的选择直接影响开发效率和后期维护成本。.NET 生态里常见的有三种T4 模板、Scriban、Razor 引擎。T4 是 Visual Studio 内置的.tt文件在保存时自动执行适合在 IDE 里做一次性生成。缺点是运行时生成很麻烦——T4 的运行时引擎Microsoft.VisualStudio.TextTemplating包体积大、依赖多在 CI/CD 流水线里跑容易出问题。而且 T4 模板语法对非 VS 用户不友好。Scriban 是轻量级模板引擎纯 .NET 实现语法接近 Liquid支持for、if、include性能好NuGet 包只有几百 KB。运行时生成、命令行工具、CI 集成都很方便。我一般首选 Scriban。Razor 引擎RazorLight等语法是大家熟悉的Model但运行时编译依赖Microsoft.AspNetCore.Razor.Language包体积大而且版本兼容性经常出问题。除非团队已经重度使用 Razor否则不推荐。选型结论运行时生成用 ScribanIDE 内一次性生成用 T4Razor 留给 Web 页面。下面用 Scriban 走完整流程。3.2 实体类模板字段、注释、导航属性的生成规则先看实体类模板。Scriban 模板文件后缀.sbn放在项目的Templates目录下{{~ # Templates/Entity.sbn ~}} using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace {{ namespace }}.Entities; /// summary /// {{ table.table_comment ?? table.table_name }} /// /summary [Table({{ table.table_name }})] public class {{ table.table_name | string.capitalize }} { {{~ for col in table.columns ~}} /// summary /// {{ col.comment ?? col.column_name }} /// /summary {{~ if col.is_primary_key ~}} [Key] {{~ end ~}} {{~ if col.max_length col.csharp_type string ~}} [MaxLength({{ col.max_length }})] {{~ end ~}} {{~ if !col.is_nullable col.csharp_type string ~}} [Required] {{~ end ~}} [Column({{ col.column_name }})] public {{ col.csharp_type }} {{ col.column_name | string.capitalize }} { get; set; }{{ if col.csharp_type string }} string.Empty;{{ end }} {{~ end ~}} }模板里有几个设计决策值得说明。第一string.capitalize是 Scriban 内置函数把user_name转成User_name但这不是标准的 PascalCase。要得到UserName需要自定义一个pascal_case函数注册到 Scriban 的TemplateContext// ScribanHelper.cs public static class ScribanHelper { // 把 snake_case 或 camelCase 转成 PascalCase public static string PascalCase(string input) { if (string.IsNullOrEmpty(input)) return input; var parts input.Split(new[] { _, - }, StringSplitOptions.RemoveEmptyEntries); return string.Concat(parts.Select(p char.ToUpperInvariant(p[0]) p.Substring(1).ToLowerInvariant())); } public static TemplateContext CreateContext() { var ctx new TemplateContext(); ctx.PushGlobal(new { pascal_case (Funcstring, string)PascalCase }); return ctx; } }第二[Required]只加在非空字符串上值类型非空由 C# 类型系统保证不需要额外特性。第三字符串属性初始化为string.Empty避免可空引用类型警告。第四[MaxLength]只在max_length有值且类型为 string 时加nvarchar(max)的max_length是 -1要过滤掉。3.3 生成器主流程从连接字符串到落盘把读取、映射、渲染串起来的主流程// CodeGenerator.cs public class CodeGenerator { private readonly ISchemaReader _reader; private readonly string _outputDir; private readonly string _namespace; public CodeGenerator(ISchemaReader reader, string outputDir, string ns) { _reader reader; _outputDir outputDir; _namespace ns; } public async Task GenerateAsync(string[]? tables null) { // 1. 读取表结构 var schemas await _reader.ReadAsync(tables); // 2. 类型映射 foreach (var table in schemas) foreach (var col in table.Columns) col.CsharpType TypeMapper.ToCsharp(col.DbType, col.IsNullable); // 3. 加载模板 var templateDir Path.Combine(AppContext.BaseDirectory, Templates); var entityTpl Template.Parse(File.ReadAllText(Path.Combine(templateDir, Entity.sbn))); var controllerTpl Template.Parse(File.ReadAllText(Path.Combine(templateDir, Controller.sbn))); // 4. 逐表渲染并落盘 foreach (var table in schemas) { var ctx ScribanHelper.CreateContext(); ctx.PushGlobal(new { table, namespace _namespace }); var entityCode await entityTpl.RenderAsync(ctx); var entityPath Path.Combine(_outputDir, Entities, ${ScribanHelper.PascalCase(table.TableName)}.cs); Directory.CreateDirectory(Path.GetDirectoryName(entityPath)!); await File.WriteAllTextAsync(entityPath, entityCode); var controllerCode await controllerTpl.RenderAsync(ctx); var controllerPath Path.Combine(_outputDir, Controllers, ${ScribanHelper.PascalCase(table.TableName)}Controller.cs); Directory.CreateDirectory(Path.GetDirectoryName(controllerPath)!); await File.WriteAllTextAsync(controllerPath, controllerCode); } } }主流程的关键参数说明_outputDir是代码输出根目录建议指向 WebAPI 项目的根目录生成器会自动创建Entities、Controllers子目录。tables参数为null时生成全部表传入表名数组时只生成指定表——这个参数在增量生成时非常有用避免每次全量覆盖导致手改的代码丢失。Template.Parse每次调用都会重新解析模板如果表数量多几百张建议把解析结果缓存起来只对RenderAsync做循环。3.4 Controller 模板CRUD 接口与分页参数Controller 模板比实体模板复杂要生成增删改查加列表分页{{~ # Templates/Controller.sbn ~}} using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using {{ namespace }}.Entities; namespace {{ namespace }}.Controllers; [ApiController] [Route(api/[controller])] public class {{ table.table_name | pascal_case }}Controller : ControllerBase { private readonly AppDbContext _db; public {{ table.table_name | pascal_case }}Controller(AppDbContext db) _db db; /// summary分页查询/summary [HttpGet] public async TaskIActionResult GetList(int page 1, int size 20) { if (page 1) page 1; if (size 1 || size 200) size 20; var query _db.{{ table.table_name | pascal_case }}s.AsNoTracking(); var total await query.CountAsync(); var items await query.Skip((page - 1) * size).Take(size).ToListAsync(); return Ok(new { total, page, size, items }); } /// summary按主键查询/summary [HttpGet({id})] public async TaskIActionResult GetById({{ table.primary_keys[0] | string.downcase }} id) { var entity await _db.{{ table.table_name | pascal_case }}s.FindAsync(id); return entity null ? NotFound() : Ok(entity); } /// summary新增/summary [HttpPost] public async TaskIActionResult Create([FromBody] {{ table.table_name | pascal_case }} input) { _db.{{ table.table_name | pascal_case }}s.Add(input); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(GetById), new { id input.{{ table.primary_keys[0] | pascal_case }} }, input); } /// summary更新/summary [HttpPut({id})] public async TaskIActionResult Update({{ table.primary_keys[0] | string.downcase }} id, [FromBody] {{ table.table_name | pascal_case }} input) { if (id ! input.{{ table.primary_keys[0] | pascal_case }}) return BadRequest(); _db.Entry(input).State EntityState.Modified; await _db.SaveChangesAsync(); return NoContent(); } /// summary删除/summary [HttpDelete({id})] public async TaskIActionResult Delete({{ table.primary_keys[0] | string.downcase }} id) { var entity await _db.{{ table.table_name | pascal_case }}s.FindAsync(id); if (entity null) return NotFound(); _db.{{ table.table_name | pascal_case }}s.Remove(entity); await _db.SaveChangesAsync(); return NoContent(); } }分页参数page和size做了边界保护page小于 1 时归 1size限制在 1 到 200 之间防止恶意请求拉全表。AsNoTracking()用于只读查询减少 EF Core 的变更跟踪开销。CreatedAtAction返回 201 状态码并带上新资源的访问地址符合 REST 规范。主键参数类型直接用table.primary_keys[0]对应的 C# 类型但模板里写的是string.downcase实际生成时应该用主键列映射后的类型——这里为了模板简洁做了简化生产环境建议在TableSchema里加一个PrimaryKeyType属性。4. 避坑与排查自动生成代码最容易翻车的五个地方4.1 生成代码编译不过先查类型映射和命名冲突现象生成器跑完没报错但dotnet build一堆编译错误常见的是找不到类型或命名空间冲突。原因类型映射表漏了某个数据库类型兜底成了string但实际字段是数值赋值时报错。或者表名和 C# 关键字冲突比如表名叫class、event、object生成的类名直接用了关键字。解决在TypeMapper里加日志遇到未映射的类型时输出警告而不是静默兜底。命名冲突用一个关键字黑名单处理private static readonly HashSetstring Keywords new(StringComparer.OrdinalIgnoreCase) { class, event, object, string, int, public, private, namespace, using }; public static string SafeIdentifier(string name) { var pascal PascalCase(name); return Keywords.Contains(pascal) ? pascal Entity : pascal; }4.2 字符串长度映射错误nvarchar 的字节数陷阱现象SQL Server 里nvarchar(50)的字段生成的[MaxLength]是 100插入 60 个字符时数据库报截断错误。原因sys.columns.max_length对nvarchar返回的是字节数nvarchar(50)占 100 字节。直接拿这个值当字符数就翻倍了。解决读取时判断类型nvarchar、nchar、ntext要除以 2var maxLen colReader.GetInt16(3); if (dbType is nvarchar or nchar maxLen 0) maxLen / 2; // 字节转字符MySQL 的CHARACTER_MAXIMUM_LENGTH直接返回字符数不需要转换所以这个逻辑要放在 SQL Server 的 Reader 里不能放到通用映射层。4.3 增量生成覆盖手写代码输出目录和文件命名策略现象第二次生成时把上次手动改过的 Controller 覆盖了业务逻辑全丢。原因生成器直接File.WriteAllText同名文件无条件覆盖。解决三种策略按场景选。一是生成到独立目录如Generated/手写代码放Custom/用partial class合并。二是生成前检查文件是否存在存在则跳过并输出提示。三是用#region标记生成区域只替换标记内的内容。我一般用第一种最干净// 生成到 Generated 目录手写代码用 partial 扩展 var entityPath Path.Combine(_outputDir, Generated, Entities, ${name}.cs); // 手写文件放 Entities/ 目录声明 partial class 同名类4.4 连接字符串泄露配置文件与环境变量现象生成器把连接字符串硬编码在生成的代码里提交到仓库后密码泄露。原因模板里直接写了_connectionString或者把连接串写进了appsettings.json并提交。解决生成器本身从环境变量或用户机密读取连接串生成的代码里绝不出现连接串。ASP.NET Core 项目用dotnet user-secrets存本地连接串生产环境用环境变量ConnectionStrings__Default。生成器的配置文件加进.gitignore。4.5 生成速度慢批量表的异步与缓存优化现象几百张表全量生成要几分钟每张表都重新打开连接、重新解析模板。原因ReadAsync里每张表单独查一次列信息N 张表就是 N 次往返。模板每次Template.Parse都重新解析。解决列信息用一条 SQL 批量查所有表在内存里按表名分组。模板解析结果缓存到静态字典private static readonly ConcurrentDictionarystring, Template TemplateCache new(); private static Template GetTemplate(string path) { return TemplateCache.GetOrAdd(path, p Template.Parse(File.ReadAllText(p))); }批量查列信息的 SQL 把WHERE TABLE_NAME tableName改成WHERE TABLE_NAME IN (...)一次拿回所有列再按TABLE_NAME分组。几百张表的生成时间能从分钟级降到秒级。5. 让生成器真正好用自定义模板与 CI 集成生成器跑通只是起点真正决定它能不能在团队里活下来的是两件事模板能不能按项目规范定制以及能不能接进流水线自动跑。先说模板定制。不同团队的规范差异很大——有的要求 Controller 继承自定义的BaseController有的要求所有 DTO 单独一层有的要求接口返回统一包装ApiResultT。这些都不应该改生成器源码而是改模板。我的做法是把模板目录做成可配置项生成器启动时从appsettings.json读TemplatePath默认指向内置模板团队可以复制一份到项目里改。Scriban 支持include指令可以把公共部分抽成_header.sbn、_pagination.sbn被多个模板引用改一处全生效。再说 CI 集成。数据库表结构变更后接口代码应该同步更新。在流水线里加一个生成步骤用dotnet run --project CodeGen -- --connection $CONN --output ./src/Api触发。生成后跑git diff --exit-code如果有差异说明有人改了表结构但没重新生成代码流水线直接失败并提示。这个检查能挡住大部分表加了字段但接口没更新的低级问题。注意连接字符串从 CI 的密钥管理里注入不要写进流水线配置文件。还有一个实用技巧给生成器加一个--dry-run参数只打印将要生成的文件列表和变更摘要不实际写盘。评审表结构变更时先跑 dry-run确认影响范围再正式生成。这个参数实现起来很简单在落盘前判断一下标志位即可但能省掉很多生成完发现改错了再回滚的后悔药。最后说一个我踩过的坑生成器本身的版本要和项目依赖的 EF Core 版本对齐。有次项目升级到 EF Core 8生成器还在用 EF Core 6 的DateOnly映射逻辑生成的实体在SaveChanges时报类型转换错误。后来我把生成器的目标框架和 EF Core 包版本写进 README每次升级项目时同步升级生成器。这个习惯看起来笨但比事后排查省事得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进