
1. 从“能跑”到“跑得稳”代码工厂的优化动机先说个背景。我之前在某公司带着团队维护一个内部的服务端工程Go 写的压在一套 PostgreSQL 上。Go 这门语言写业务确实利索但日子久了问题就冒出来了每次新增一张表都要手写 model、dao、query、handler 这一整套模板代码表一多复制粘贴都嫌烦。更要命的是PostgreSQL 的一些类型比如数组、JSONB、区间类型跟 Go 的结构体字段映射起来很别扭手写一遍两遍还行写多了必然出错。于是我们当时就做了一个内部工具姑且叫“Go代码工厂”。它的核心功能很简单连上 PostgreSQL 的库读 information_schema把表结构、字段类型、索引、主键、外键这些元信息抓出来然后按模板批量生成 Go 的 entity、mapper、service 和简单的 CRUD 接口。工具上线之后开发效率确实上来了新表落地从“半天”变成“五分钟”。但用了半年大家开始抱怨“生成的代码能跑但不好用。”这就是我写这篇文章的出发点。代码生成工具不难做难的是“生成出来的代码在真实生产环境里经得起折腾”。这篇文章不聊怎么搭一个简单的模板引擎那网上教程一抓一大把。我想讲的是我们为了让它真正适配 PostgreSQL踩过哪些坑、优化了哪些细节、为什么这么改以及最后沉淀下来的这套可复用的实践方案。需要这套方案的人我猜主要是有这么几类一是团队内部想搞代码生成器但没头绪的二是已经在用生成器但觉得生成代码质量不行的三是纯粹想了解 Go 访问 PostgreSQL 时那些性能细节的同学。不管你属于哪类这篇文章应该都有点参考价值。2. 整体架构与关键设计取舍2.1 代码工厂的分层结构先把我们最终敲定的结构画出来你就知道优化点都藏在哪儿了。整个工具分四层元数据抓取层负责连库读取 PostgreSQL 的系统表得到表、字段、约束、索引、注释等信息类型映射层把 PostgreSQL 的数据类型映射到 Go 的类型这是最容易出错的一层模板渲染层基于映射结果填充 Go 代码模板输出 entity、dao、service、handler输出/落盘层处理文件的输出路径、包名、格式化、幂等覆盖策略这四层各管各的互相之间只通过结构化数据传递信息。最初版本不是这样最初我把模板渲染和元数据抓取耦合在一起模板里直接写 SQL 查询结果就是换一个数据库版本模板就要动一遍维护成本极高。优化后的第一原则就是元数据层只产生结构化的中间表示模板层只消费这份稳定的结构体。后面你要支持 MySQL、SQLite只需要替换元数据抓取层模板层完全不用动。这个分层思路不但让工具本身好维护也让生成代码的质量更容易控制。2.2 为什么选 PostgreSQL 作为核心适配目标可能有人问为什么专门针对 PostgreSQL 做优化MySQL 不香吗这里有个很现实的原因PostgreSQL 的数据类型体系比 MySQL 复杂得多。MySQL 基本就是 int、varchar、datetime 这些几乎都能直接对应到 Go 基础类型上。PostgreSQL 呢有numeric、uuid、jsonb、int4range、tsvector、citext、枚举类型、数组类型……如果映射策略不做对生成的代码要么编译不过要么运行时 panic要么出现严重的隐性类型转换性能问题。举个例子PostgreSQL 的numeric类型在 Go 里直接映射成float64是最偷懒的做法但在涉及金额、精度要求高的场景下float64 的精度问题会直接污染业务数据。我们一开始就吃过这个亏后来强制规范为numeric必须映射成string或shopspring/decimal类型具体取决于使用场景。这类问题如果你不做专门适配光靠通用模板是永远发现不了的。另外PostgreSQL 的索引类型和 SQL 语法也有不少“非标准”的地方比如ILIKE、jsonb_path_query、DISTINCT ON这些在生成查询条件时如果不考虑进去生成的 DAO 层代码就只能覆盖最简单的场景稍微复杂一点的业务需求就要手工改那就失去代码工厂的意义了。2.3 设计取舍表驱动还是字段驱动我们在迭代中还做了一个重要的设计决策生成代码时默认按“单表主键驱动”来生成基础 CRUD但生成结果不绑定死。什么意思呢就是工具生成的代码默认支持FindByPrimaryKey、Insert、UpdateByPrimaryKey、DeleteByPrimaryKey、PageQuery这几个方法所有方法都基于主键展开。但对于没有主键的表PostgreSQL 允许不建主键工具会自动降级为“全字段等值查询”模式同时会在生成的代码注释里明确打上警告标记提示开发者手动补充。这个取舍的价值在于它让生成的代码在一个可控的范式内保持一致性又不会因为“过度自适应”导致生成结果不可预测。代码生成工具最怕的就是“玄学生成”同样的输入换个环境生成的东西不一样那还不如不搞。3. 核心细节拆解那些决定成败的小事3.1 类型映射的最终规则表这是整个工具的核心我直接给出踩坑后沉淀下来的映射规则。先说好这不是唯一的正确答案但这是我们在实际生产环境里打磨过的版本。PostgreSQL 类型Go 类型默认可选类型说明smallint/int2int16int一般业务用int更省心但严格按宽度更好integer/int4int32int同上看团队规范bigint/int8int64string雪花 ID 这类超长 ID 建议 stringnumeric/decimaldecimal.Decimalstring涉及金额、精度绝不建议 floatreal/float4float32float64一般场景直接用 float64double precision/float8float64--booleanbool--text/varchar(n)string--char(n)string-记得处理 TrimSpaceuuidstringuuid.UUID推荐 string避免引入依赖json/jsonb[]bytejson.RawMessage/ 自定义 struct别默认映射成 stringdatetime.Timestring注意时区问题timestamp/timestamptztime.Time-timestamptz要小心时区bytea[]byte--数组类型如text[][]stringpq.Array/pgx原生支持视驱动而定枚举类型string自定义类型生成常量注释这套映射表看着简单细节全在坑里。拿jsonb来说很多初版工具会把它映射成string然后业务里到处json.Unmarshal([]byte(obj.Field))。一旦字段值为 null或者 PostgreSQL 返回的 jsonb 带了格式缩进写出来的代码就花式报错。我们后来统一映射成[]byte并且在模板里生成一个Scan的辅助方法自动处理 null 和错误包装这个问题才算根治。再说timestamptz。如果你用的驱动是lib/pq扫出来的是一个time.Time但时区信息可能会被吞掉换到pgx就好很多。我们在模板里默认生成一个注释提醒开发者所有时间字段统一用 UTC 存储由 API 层做时区转换。这是很实用的团队规范代码生成工具就该承载这种规范而不是只翻译类型。3.2 元数据抓取的 SQL 细节读 PostgreSQL 元数据最核心的一条 SQL 是查字段信息。我这里有两条比较成熟的查询语句一条是查表基础信息一条是查字段列表直接在information_schema和pg_catalog上跑。-- 查表基础信息 SELECT c.relname AS table_name, obj_description(c.oid) AS table_comment FROM pg_class c JOIN pg_namespace n ON n.oid c.relnamespace WHERE n.nspname public -- 指定 schema避免不小心扫到系统表 AND c.relkind r -- 只取普通表排除视图、物化视图等 ORDER BY c.relname;-- 查字段信息 SELECT a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, COALESCE(col_description(a.attrelid, a.attnum), ) AS column_comment, CASE WHEN pk.attname IS NOT NULL THEN true ELSE false END AS is_primary_key, CASE WHEN a.attidentity a THEN true ELSE false END AS is_identity, COALESCE(( SELECT pg_get_expr(d.adbin, d.adrelid) FROM pg_attrdef d WHERE d.adrelid a.attrelid AND d.adnum a.attnum ), ) AS default_expr FROM pg_attribute a JOIN pg_class c ON c.oid a.attrelid JOIN pg_namespace n ON n.oid c.relnamespace LEFT JOIN ( SELECT conrelid, attname FROM pg_constraint JOIN pg_attribute ON pg_attribute.attnum ANY(pg_constraint.conkey) AND pg_attribute.attrelid pg_constraint.conrelid WHERE pg_constraint.contype p ) pk ON pk.conrelid a.attrelid AND pk.attname a.attname WHERE n.nspname public AND c.relname $1 AND a.attnum 0 AND NOT a.attisdropped ORDER BY a.attnum;这段 SQL 是优化过程中反复调整出来的。有几个细节值得注意format_type(a.atttypid, a.atttypmod)会把varchar(255)这种带长度的类型也格式化出来这样类型映射层就能拿到完整的声明自己决定要不要截断长度信息。主键的识别方式我一开始用information_schema.table_constraints关联key_column_usage去查后来发现慢改成直接在pg_constraint上查contype p更快也更准确。attidentity是用来判断自增列的。PostgreSQL 10 之后推荐的GENERATED AS IDENTITY在这里能识别出来但老的serial类型不在这里体现要额外看default_expr是否包含nextval(。这个细节在生成 Insert 语句时非常关键否则会把自增列拼进去导致主键冲突。3.3 模板引擎选型标准库就够了模板引擎我们没引入第三方的用的就是 Go 标准库text/template。很多人觉得标准库功能不够强想上pongo2或jet但我们对比过之后还是坚持了标准库。原因很简单代码工厂的模板逻辑复杂度有限无非就是range循环字段、if判断类型、拼字符串标准库都覆盖了减少依赖工具本身的构建和分发会简单很多标准库的模板执行性能虽然不算顶尖但生成代码这个场景是低频操作性能根本不敏感真正需要花心思的是模板的组织。我们调整后的模板目录结构是templates/ ├── entity.go.tmpl // 实体结构体 ├── dao.go.tmpl // 数据访问层含基础 CRUD ├── query.go.tmpl // 扩展查询基于条件构造 ├── service.go.tmpl // 业务服务层包装事务 ├── handler.go.tmpl // HTTP 处理器 ├── router.go.tmpl // 路由注册 └── partials/ ├── common.go.tmpl // 通用导入、错误处理等 └── field_convert.go.tmpl // 字段转换辅助函数每个表生成的文件就是把这些模板用同一个数据对象渲染出来。注意同一个模板文件会被多次渲染但数据不同所以模板中绝不能写死表名、字段名。我们曾经在某个模板里偷懒直接把表名写死在模板注释里结果生成第二个表的时候注释全部错乱排查了半天才发现是模板缓存的问题。标准库模板没有内置的“继承”机制但你可以用{{define xxx}}定义子模板在主模板里{{template xxx .}}引用。我们就是这样把公共的error handling部分抽成 partials避免每个模板都重复一坨if err ! nil的逻辑。4. 针对 PostgreSQL 的深度功能优化4.1 批量 Insert 的生成策略PostgreSQL 的批量插入有几种方式逐条INSERT、单条INSERT拼接多组VALUES、COPY协议。代码工厂默认生成的是第二种也就是带多组 values 的单条 SQL因为这种方式兼容性最好也便于在生成代码里做参数绑定。但在生成时我们需要处理一个 PostgreSQL 特有的限制参数占位符$N的编号是全局递增的不能像某些数据库那样每组 values 都从$1重新开始。所以模板里必须维护一个“参数序号”的累加器。INSERT INTO {{.TableName}} ({{range .InsertableFields}}{{.Name}}, {{end}}) VALUES {{range $i, $row : .Rows}} (${{$offset : add (mul $i (len $.InsertableFields)) 1}}... {{end}}这段模板代码的逻辑就是第i行的第一个参数序号是i * 字段数 1每行内部的字段序号依次递增。这个看起来很简单的计算如果不注意生成的 SQL 参数编号就会乱掉。我们在初版就踩了这个坑实际跑起来之后发现 PostgreSQL 报bind message supplies 0 parameters的错误排查日志才知道序号对不上。除了参数编号批量Insert还有一个经验值可以分享单批插入的行数并非越多越好。我们压测下来在普通云数据库实例上每批 200 到 500 行之间性能比较稳定再往上提升有限反而可能因为单次 SQL 过大触发网络层问题。所以在生成的代码里我们默认按 300 行一批做切片并且把这个值设计成了常量方便开发者在调用处覆盖。4.2 JSONB 字段的查询写法PostgreSQL 的jsonb是很多团队的“心头好”因为业务数据加字段太方便了。但代码工厂生成的 DAO 层如果只支持“where field ?”jsonb 的作用就废了一半。我们优化时特意针对方括号和对象嵌套查询移植了 Postgres 的原生能力。生成的查询代码支持两种 jsonb 查询方式// 第一种对象字段等值匹配 SELECT * FROM orders WHERE payload - status $1; // 第二种路径提取匹配 SELECT * FROM orders WHERE payload # {user, profile, name} $1;在查询构造器的模板里我们根据字段元数据里的一个约定来自动选择写法字段名以jsonb_前缀开头的生成路径查询代码否则生成普通等值查询。这个约定简单粗暴但团队里执行起来零成本比搞一堆配置项好用。这里要提醒一句jsonb查询尽量走 GIN 索引。我们生成的 migration 文件里会默认给jsonb字段加上USING gin (字段名 jsonb_ops)的索引建议但不会强制创建。选择权交给业务方因为索引和查询模式的匹配度只有业务最清楚。4.3 UPSERT 的模板支持PostgreSQL 的ON CONFLICT DO UPDATE语法在实现业务幂等写入时非常好用。代码工厂为每个表生成一个Upsert方法模板里显式声明冲突目标为主键或唯一索引func (d *XxxDao) Upsert(ctx context.Context, e *XxxEntity) (int64, error) { sqlStr : INSERT INTO xxx (id, name, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET name EXCLUDED.name, payload EXCLUDED.payload, updated_at now() // 执行并返回影响行数 }这里有个细节点DO UPDATE只会更新传入的非主键字段主键字段在冲突时保持不变。这样设计是为了避免一个常见的并发陷阱——两条带同一个业务键的请求先后到达如果不加保护后一条可能把前一条的主键或者创建时间覆盖掉。模板生成时所有非主键字段都会出现在SET列表里保证“全字段覆盖式更新”语义清晰。另外ON CONFLICT后面的目标列必须是唯一的。如果表上没有唯一索引生成的代码在 PostgreSQL 会直接报错。为了不让工具生成“必坏代码”我们在元数据抓取阶段就检查了表上的唯一约束如果一张表既没有主键、也没有唯一约束就不生成Upsert方法而是把方法名留空同时在生成的日志中打一条告警。这个“能做才生成不做就明确提示”的原则后来帮团队避了不少雷。4.4 分页查询的排序稳定性PostgreSQL 的LIMIT/OFFSET分页有一个经典问题如果排序字段不是唯一的翻页时可能出现数据重复或者丢失。比如按created_at排序而同一时间戳有多条记录那么每页的边界就会漂移。代码工厂生成的PageQuery方法对此做了两件事第一默认排序字段不只是你指定的排序键还会自动追加主键作为次排序键。比如SELECT * FROM xxx ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2;这样可以保证分页结果在逻辑上是“稳定排序”的。第二排序字段默认白名单机制。生成的代码不会接受任意字符串作为排序字段而是从元数据里把合法的字段名列出来传入排序字段如果不在白名单里直接返回参数错误。这既防了 SQL 注入也防了手滑写错列名的低级 bug。5. 连接池和数据库访问参数优化5.1 pgx 驱动是更好的选择说到 Go 连 PostgreSQL初期我们用的lib/pq后来全部迁到了pgx。不是lib/pq不好而是pgx在底层性能、类型支持和协议利用上确实更胜一筹。迁移之后最明显的变化有两个pgx使用 PostgreSQL 原生的二进制格式传输数据在传输大数据量的bytea、jsonb时性能比文本格式快不少pgx支持自动读取 PostgreSQL 的类型信息自定义类型、数组类型都能直接映射到 Go 的切片和结构体省掉了我们自己写一堆转换函数的功夫连接池这块我们用的是pgxpool。配置参数经过几轮压测调整后固定下来一套相对稳妥的初始值poolConfig, _ : pgxpool.ParseConfig(dsn) poolConfig.MaxConns 20 poolConfig.MinConns 2 poolConfig.MaxConnLifetime 30 * time.Minute poolConfig.MaxConnIdleTime 5 * time.Minute poolConfig.HealthCheckPeriod 1 * time.Minute这里每个参数都有说道。MaxConns不是越大越好PostgreSQL 的每个连接都是一个进程连接数过多反而会造成上下文切换开销。我们有同事把MaxConns直接配成 100结果数据库 CPU 没上去反而是连接管理和锁等待把性能拖垮了。一般经验是连接数 机器核数 * (2 到 3) 磁盘等待系数具体还是要压测。MaxConnLifetime设 30 分钟是因为 PostgreSQL 后端连接太长时间不换会被云数据库或防火墙悄然断开客户端这边如果不感知就会报broken pipe之类的错误设置连接生命周期可以规避这种情况。5.2 预编译语句的取舍pgx是默认使用 prepared statement 的也就是预编译。好处是 SQL 解析只做一次执行更快坏处是连接较多、SQL 模式特别多的情况下prepared statement 会占用服务端内存。代码工厂生成的 DAO 层里所有Insert、Update、Delete方法使用的 SQL 模式都是固定的这是 prepared statement 的最佳使用场景。但查询类方法因为条件动态拼接一个方法可能产生几十种 SQL 变体这时候如果还是无条件启用 prepared statement服务端缓存就会膨胀。我们的处理策略是代码工厂生成的 DAO 层里查询方法统一走Exec/Query的非预编译路径只有固定的写操作走预编译。具体在pgx里就是通过pgx.QueryExecModeSimpleProtocol之类的配置来控制模式或者在 SQL 拼接后直接调用带pgx.QueryRewriter的接口。这样既稳住了写操作的性能又不让查询模式把服务端缓存打爆。5.3 超时控制必须生成代码工厂生成的每个 DAO 方法第一个参数都是context.Context这一点特别重要。生成代码里所有数据库操作都会带上这个 context并且模板中强制生成context.WithTimeout的调用位置。评论区看到过不少“数据库连接泄漏”的案例十有八九是 context 没传。代码工厂用模板从源头把这条路堵住所有方法签名必须有一个ctx参数所有Query、Exec调用前统一defer cancel()。这套“消失的 defer”我们已经在生成层实现了不用开发者每次手写。在超时值的选择上默认给的是5 * time.Second。有人可能觉得 5 秒太长但在我们实际业务中有些复杂查询确实会跑 2 到 3 秒5 秒算一个比较稳妥的保守值。具体到慢查询场景开发者可以在调用时传入自定义的 withTimeout 覆盖默认值模板提供这个口子。6. 实操过程与踩坑实录6.1 一个典型的表生成全流程演示光说理论不直观拿一个具体例子走一遍整个流程。假设我们有张user_profile表CREATE TABLE user_profile ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, nickname VARCHAR(64) NOT NULL, email CITEXT UNIQUE, avatar_url TEXT, settings JSONB DEFAULT {}::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );跑代码工厂的命令行codegen --db postgres://user:passlocalhost:5432/app --schema public --table user_profile --output ./internal/model工具会在内部执行我们前面说的元数据查询得到字段信息后自动生成entity.go、user_profile_dao.go、user_profile_service.go、router.go。打开看entity 大概长这样type UserProfile struct { ID int64 json:id Nickname string json:nickname Email string json:email AvatarURL string json:avatar_url,omitempty Settings json.RawMessage json:settings CreatedAt time.Time json:created_at UpdatedAt time.Time json:updated_at }注意Email映射成的就是string因为citext类型在驱动里本来就会作为文本处理。Settings映射成json.RawMessage而不是string这就避免了手动强转的麻烦。DAO 层的PageQuery方法生成的长这样func (d *UserProfileDao) PageQuery(ctx context.Context, req PageRankReq) (*PageRankResp, error) { // 排序字段白名单校验 if !d.sortableFields[req.SortField] { return nil, ErrInvalidSortField } sqlStr : SELECT * FROM user_profile WHERE ($1 OR nickname ILIKE % || $1 || %) ORDER BY req.SortField req.SortOrder , id DESC LIMIT $2 OFFSET $3 // ... }从命名到写法这套代码都是能直接进 code review 的。我们团队内部做过一次统计代码工厂生成的文件评审时被打回的比率远低于手写代码主要原因就是模板层面已经把常见问题全部规避了。6.2 初期版本踩过的三个典型坑第一个坑jsonb 字段被当作普通字符串处理。初版生成的 DAO查jsonb字段直接是WHERE settings $1但 JSONB 的内部二进制格式和文本并不总是严格相等导致某些场景查不到数据。后来改成settings::text $1或走包含操作符才彻底解决。第二个坑带时区的时间字段被搞错。我们的数据库统一存的是 UTC 时间但生成的 Go 代码在读取timestamptz後直接格式化输出有的环境自动转成了本地时区前端看到的时间就差了 8 小时。现在模板里生成时间字段时会自动调用一个formatTime辅助函数强制以 UTC 格式输出并且在注释里写明约定这个坑就再没犯过。第三个坑生成文件覆盖导致手写代码丢失。代码工厂用的模板文件是稳定的但业务一旦在生成文件上做了二次开发比如加了几个自定义方法下次跑工具整个文件被覆盖辛辛苦苦写的代码就没了。后来我们加了一个保护机制生成文件开头统一写入“Code generated by codegen. DO NOT EDIT.”标记工具在覆盖前检查这个标记如果没有这个标记就跳过覆盖并提示开发者手动合并。这个策略极大减少了大伙的“工伤”。此外还有一个 SQL 大小写的问题。PostgreSQL 默认会把未加引号的标识符折叠成小写如果生成的 SQL 里表名、字段名带了大小写混合并且没有用双引号括起来就会报 “relation does not exist”。模板中我们对所有标识符统一做了小写处理避免这种低级错误。6.3 生成代码的性能验证方法代码生成完性能过关不过关不能拍脑袋。我建议大家在工具里直接内置一个自检模式生成完代码后自动跑一轮轻量级基准测试。具体做法是生成一个_test.go文件里面放几个基础的 CRUD 基准测试用例专门测批量插入吞吐量、分页查询延迟、UPSERT 并发稳定性这三项。我们的经验数值大致是批量插入 5000 行耗时控制在 1.5 秒内分页查询 10 万行数据单次延迟低于 100 毫秒UPSERT 并发 50 个 goroutine 跑 10 分钟不出现锁等待超时。达不到这些数值就该回头检查数据库索引、连接池配置或模板生成的 SQL 了。这种方法的好处是把“代码质量关”前移到了生成阶段而不是等业务上线后才发现。代码工厂成了开发流程里的一环而不是一个“跑完就忘”的脚本。7. 常见问题与排查思路速查我把实际运维中遇到过的高频问题整理成一张表方便你排查问题时对着看问题现象可能原因排查方法/解决方案生成的代码编译报错字段类型不匹配类型映射规则没覆盖到特殊类型检查format_type返回的原始类型字符串加入映射表检查是否映射成了interface{}改为具体类型运行时报bind message supplies 0 parameters批量 Insert 的参数编号累加逻辑错误打印最终 SQL 和参数列表检查$N编号是否连续递增查询jsonb字段查不到数据直接用了等值比较未考虑 jsonb 内部格式改用操作符或者字段先::text做等值转换分页翻页重复/丢失数据ORDER BY字段不唯一追加主键字段作为次排序键长时间运行后出现broken pipe连接被服务端静默回收设置MaxConnLifetime定期刷新连接ON CONFLICT报错no unique or exclusion constraint matching表上无主键或唯一索引元数据层检查唯一约束不生成Upsert时间字段差 8 小时时区转换被默认环境时区影响强制在 DAO 层将时间格式化为 UTC 输出重新生成代码后手写部分丢失覆盖策略过于激进生成文件头部加DO NOT EDIT标记覆盖前检查标记生成的enum类型无法映射自定义枚举类型没定义生成常量列表和类型定义映射为 string并附带合法值注释这些问题的根源大多数不是 Go 本身而是对 PostgreSQL 数据模型的理解不够细。代码工厂只是把这份理解固化成了模板让团队里每个开发者不用重复踩坑。8. 这套方案的扩展思路与实际体会代码工厂这套思路不只适用于 PostgreSQL。后来我们用它接 MySQL发现只要把元数据抓取层换成 MySQL 系统表的查询类型映射做一次扩展模板基本不用动。再往后团队还想接入 Redis 的数据结构生成器我提出复用分层架构只改元数据层效果也不错。这说明初始的分层设计才是整个工具最重要的部分它决定了后续所有优化的空间。关于“代码生成到底该不该用”我个人有些不同的看法。网上常有人争论手写代码和生成代码谁更好我觉得这是伪问题。代码工厂不是用来取代开发者的它是把那些重复、机械、容易出错的部分自动完成让人把时间和精力腾出来处理真正的业务逻辑。但代码工厂也有边界。它适合生成 CRUD 这种高度模式化的代码但不适合生成复杂的业务编排和领域逻辑硬把业务规则塞进模板里只会得到一个“什么都想管、什么都做不好”的四不像。所以我在实际使用中一直坚持一个原则工具负责提供可靠的“积木”业务层自己搭乐高。最后分享一个可能很多人都没意识到的小技巧代码工厂生成的代码最好直接纳入 CI 流水线做一次“编译 格式校验 静态检查”。我们在实践里把代码生成做成了一个 CI 步骤每次元数据有变更、模板有调整都自动跑一遍生成代码的变化会在 MR 里清楚展示出来。这比让开发者在本地跑工具靠谱得多因为本地环境千奇百怪有人是 Windows有人用 Docker搞到最后生成结果不一致问题都出在“本地还跑得好好的”这句话上。有了 CI 兜底代码工厂才真正变成了团队基础设施的一部分。