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/int2 | int16 | int | 一般业务用int更省心,但严格按宽度更好 |
integer/int4 | int32 | int | 同上,看团队规范 |
bigint/int8 | int64 | string | 雪花 ID 这类超长 ID 建议 string |
numeric/decimal | decimal.Decimal | string | 涉及金额、精度,绝不建议 float |
real/float4 | float32 | float64 | 一般场景直接用 float64 |
double precision/float8 | float64 | - | - |
boolean | bool | - | - |
text/varchar(n) | string | - | - |
char(n) | string | - | 记得处理 TrimSpace |
uuid | string | uuid.UUID | 推荐 string,避免引入依赖 |
json/jsonb | []byte | json.RawMessage/ 自定义 struct | 别默认映射成 string |
date | time.Time | string | 注意时区问题 |
timestamp/timestamptz | time.Time | - | timestamptz要小心时区 |
bytea | []byte | - | - |
数组类型(如text[]) | []string | pq.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:pass@localhost: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 兜底,代码工厂才真正变成了团队基础设施的一部分。