自研代码生成器t3code:告别CRUD重复劳动,统一分层架构代码风格
先说个让我印象很深的场景。有一次我帮同事 review 新模块的代码,看到他花了整整一个下午,把另一个模块的 Service 复制过来,再手动改掉表名、字段名和查询条件。我当时在想,这种"复制-粘贴-改参数"的体力活如果一直靠人肉完成,那我们的时间到底花在了哪里?也就是从那天起,我开始动手做一个内部代码生成工具,名字就叫 t3code。
t3code 不是一个框架,而是一个面向分层架构的代码生成脚手架。它解决的核心问题有三个:样板代码重复劳动、各模块代码风格漂移、接口与数据模型变更后改动范围失控。适合谁看?如果你正在被 CRUD 接口的重复编码折磨,或者团队里已经出现"一百个模块一百种写法"的局面,又或者你正打算自研一个代码生成器但不知道从哪里下手,这篇复盘应该能给你一些参考。下面我会把 t3code 的设计思路、完整生成链路、实测数据和砍掉的功能一一说清楚,包括踩过的坑。
1. 为什么会诞生 t3code:从加班改 CRUD 说起
1.1 接手一个"代码风格自由"的老项目
我接手过一个内部系统,不算大,但模块很多。第一次打开它的代码,我看到的不是同一个项目的不同部分,而是一堆"方言":有的模块把查询逻辑全部堆在 Controller 里,Service 就剩一个空壳;有的模块 Service 写了 2000 行,DAO 层却几乎没有封装;还有一个模块更夸张,直接用 JDBC 拼 SQL,连 ORM 都没接。
当时我正在做一个非常常规的需求:给订单列表加一个按状态筛选的接口。正常来说,这个需求涉及四个文件:Controller、Service、ServiceImpl、DAO,再加一个查询参数的 DTO。但我光阅读这几个文件、理解它们各自的命名习惯和分层约定,就花了大半天。更离谱的是,我照着某个模块的写法抄了一段代码,交上去之后被另一个同事指出"这个模块我们用的是另外一套命名规范"。
那段时间团队有个数据:每个人每周大概有 40% 的开发时间花在了和业务无关的结构性代码上。我说的结构性代码就是那种"字段从 DTO 透传到 VO,从 VO 再透传到 Entity"的桥接代码,它们没有复杂的逻辑,但你必须写,还必须写得符合规范。时间久了你会发现,真正折磨人的不是写不出业务逻辑,而是重复写了太多不该重复的东西。
1.2 真正需要自动化的不是数据库,而是"层与层之间的桥接代码"
很多人一提到代码生成器,第一反应是"自动生成数据库操作代码",比如根据表结构自动生成 MyBatis 的 Mapper。但我的体会是,单表增删改查的自动化只是其中一小块。一个业务功能从数据库走到前端页面,中间要经过 Entity、DTO、VO、Controller、Service、ServiceImpl、DAO、Mapper XML 这么多层。大部分字段在这些层之间来回透传,没有业务含义,只有结构意义。
这种透传代码有个特点:结构高度固定,但必须存在。假如一个订单表有 20 个字段,你写 DTO 的时候要把 20 个字段写一遍,写 VO 的时候再写一遍,写 Entity 的时候又写一遍。每写一遍都要手动对齐字段名、类型和注释。手写这种东西不是不行,而是只要改一个字段,你就要在三四个文件里同步修改,漏改一个就是线上事故。
所以 t3code 的定位从一开始就不是"生成 SQL",而是"生成层与层之间的桥接代码"。数据库表结构只是输入,真正的产物是整个分层链路里那些结构固定的文件。这个定位决定了它后面的所有设计。
1.3 市面上现成的代码生成器,为什么我最后还是决定自己写
我也认真评估过现成的代码生成工具,包括一些开源项目,它们确实能省不少事。但落到我们这个场景里,有几个问题始终绕不过去。
第一个问题是它们的目标往往是"单表 CRUD"。生成一个表的增删改查确实很快,但业务系统里大量存在的是主从关系、字典关联、软删除、乐观锁这类通用模式。这些模式靠现成工具默认模板很难覆盖,你得自己改模板,改到后来基本等于维护一个自己的代码生成器。
第二个问题是模板体系相对黑盒。很多工具模板都是打包在程序里的,你只能通过配置项微调。当你需要生成的文件结构、注释风格、包名规则和团队规范不一致时,改起来很痛苦。我也试过把它们生成的代码用格式化工具再刷一遍,但刷完只是表面统一,注释还是空的,类型映射还是老的,实际维护价值不大。
第三个问题是生成代码的可读性。很多工具生成的代码一眼就能看出来是机器写的:没有合理的空行,注释是"表名"而不是"字段含义",泛型边界处理得比较随意。这种代码如果放进团队仓库,后面维护的人会非常痛苦。我希望 t3code 生成的东西看起来就像一个老手花半小时手写出来的,这个目标只能通过完全自研来实现。
2. t3code 的整体架构与三个核心设计决策
2.1 t3code 名字的含义:Tier 3 + Code
t3code 的意思是 Tier 3 + Code,三层架构代码。这里的"三层"指的不是网上的某一种固定框架,而是我们团队长期沉淀下来的分层约定:领域层(Entity / DTO / VO)、服务层(Service / ServiceImpl 业务编排)、接口层(Controller 对外的 REST 接口)。
这个命名在项目里是刻意的。我们不想做那种"生成完就跑"的一次性工具,而是希望它成为团队的技术基建之一。只要团队的分层约定不变,t3code 生成的东西就一直能复用。如果哪天分层约定变了,那需要改的也不是每个文件,而是模板本身。
从工程结构上说,t3code 本身由三部分构成:元数据解析器负责读取表结构和配置,模板引擎负责渲染代码文件,落盘管理器负责把渲染结果写到正确的位置。这三个部分彼此独立,后续扩展新的数据库支持或新的架构风格时,不需要牵动整个项目。
2.2 核心决策一:用一份元数据描述代替多份分散配置
我开始做 t3code 之前,见过不少代码生成工具,它们的输入是"一堆配置项":要给哪个表生成、字段怎么命名、要不要生成 Mapper XML、Controller 的路由前缀是什么。这些配置散落在不同地方,换个表就要重新指定一堆参数,非常容易记错。
t3code 换了一个思路:整个项目的生成输入只有一份元数据描述。用 JSON 或 YAML 写清楚一张表长什么样,有哪些字段,主键是谁,哪些字段是软删除标记,哪些字段是审计字段,表之间的关系是什么。所有模板共享这一份元数据,不再存在"Controller 模板要一套配置、Service 模板要另一套配置"的情况。
这样做最大的好处是单一来源。后面你会发现,当生成结果出现问题时,只需要检查一处输入,而不是在模板参数和配置文件之间来回对比猜原因。
2.3 核心决策二:模板引擎选型,以及模板必须可插拔
模板引擎是代码生成器的心脏。最粗暴的做法是字符串拼接,写起来快,但模板稍微复杂一点就变成一坨难维护的字符串操作。我没有选这条路。我选择的是目录式模板引擎:模板就是普通文件,按一定命名规则组织在 templates 目录下,渲染时根据目标文件类型去目录里找对应的模板文件。
这样有几个实际好处。第一,模板可以直接用团队熟悉的语言来写,比如 ServiceImpl 的模板就是一段普通的 Java 文件,里面插入少量渲染指令。第二,模板支持覆盖机制:内置模板是默认值,自定义模板如果放在项目里,优先级更高。团队可以根据自己的规范修改模板,不需要改 t3code 本身的代码。第三,模板文件可以正常被 IDE 识别,有语法高亮,维护门槛低很多。
2.4 核心决策三:生成代码必须"像人写的"
这个决策看起来不涉及技术,但其实是最影响工具成败的。我做 t3code 时给自己定了一条硬规矩:生成出来的代码如果被同事一眼看出是机器生成的,那就是失败的。
怎么做到"像人写的"?首先,注释必须从数据库表结构里提取出来,落到具体字段上。数据库设计时建的表注释、字段注释通常都写得比较完整,这些信息必须变成代码里的 Javadoc 和字段注释。其次,空行规范要跟着团队风格走,方法之间要有空行,逻辑块之间要有空行,不能一整段下来密不透风。第三,泛型边界、返回值封装、异常处理方式都要符合团队约定,不能想当然。
这条规矩带来的直接结果是,t3code 生成的文件可以直接通过团队的 Code Review 标准,后面维护的人不会觉得这套代码是"外来户"。
3. 从表结构到可运行代码的完整生成链路
3.1 第一环:输入一张表,先变成一份"元数据对象"
t3code 支持两种输入方式。第一种是直连数据库,通过读取系统表里的表结构信息,自动反向解析出一份元数据。第二种是手工编辑一份 YAML 描述文件,适合那些数据库表还没有完全成型、或者你只想生成部分接口的阶段。
我建议的使用方式是:绝大多数场景下先用直连数据库的方式拿到基础元数据,然后在此基础上手工调整补充。因为数据库里有些信息是代码生成需要的但建表语句里未必体现,比如这个字段是否参与列表查询、是否要作为导出字段、前端展示时用什么组件,这些都不属于数据库范畴,需要人工补充到元数据里。
下面是一个简化后的元数据示例,大家感受一下它的形态:
table: t_order entityName: Order serviceName: OrderService controllerPath: /api/order fields: - name: id type: bigint comment: 主键ID primary: true autoIncrement: true - name: order_no type: varchar length: 64 comment: 订单编号 query: true - name: user_id type: bigint comment: 下单用户ID index: true - name: status type: tinyint comment: 订单状态: 0-待支付 1-已支付 2-已取消 dictionary: order_status - name: amount type: decimal precision: 10 scale: 2 comment: 订单金额 - name: is_deleted type: tinyint comment: 软删除标记 softDelete: true - name: create_time type: datetime comment: 创建时间 createTime: true - name: update_time type: datetime comment: 更新时间 updateTime: true这份元数据是 t3code 的"单一事实来源"。注意里面的 dictionary 字段,它表示 status 字段的取值对应一个字典编码。t3code 在生成 VO 的时候会自动为这个字段生成对应的枚举类,或者在前端接口定义里生成相关的注释,不需要开发人员再去翻数据库文档。
3.2 第二环:数据库类型到目标语言的类型映射
拿到元数据之后,下一步是把数据库的类型映射到目标语言的类型。这一步看着简单,实际很容易踩坑。
我最开始做类型映射的时候,处理得太粗暴了:数据库里的 decimal 直接映射成 Java 的 BigDecimal,varchar 映射成 String,tinyint 映射成 Integer。后来在真实代码里发现问题:decimal 字段如果没指定 precision 和 scale,生成的 BigDecimal 在运算时会遇到精度溢出的坑;datetime 字段如果没做时区处理,生成的前端接口定义和 Java 实体的语义会对不上。
所以 t3code 的类型映射不是一个简单的"数据库类型到语言类型"的对照表,而是带着参数和约束的映射。比如 decimal 要读取 precision 和 scale,tinyint(1) 要判断是不是布尔语义,bigint 在 JavaScript 和 TypeScript 侧要考虑是否超出 Number 安全整数范围。
下面是一张我整理的 MySQL 到 Java / TypeScript 的常用类型映射表:
| MySQL 类型 | Java 类型 | TypeScript 类型 | 备注 |
|---|---|---|---|
| bigint | Long | number | string | 超过 2^53-1 时建议用 string |
| int / integer | Integer | number | |
| tinyint(1) | Boolean | boolean | 需要结合字段注释判断 |
| tinyint / smallint | Integer | number | 状态枚举场景常用 |
| decimal(p,s) | BigDecimal | string(金额精度要求高) | 必须读取 precision 和 scale |
| varchar / char | String | string | |
| datetime / timestamp | LocalDateTime / Date | string(ISO 8601) | 注意时区约定 |
| date | LocalDate | string | |
| text / longtext | String | string | |
| json | 自定义类型或 String | any / 具体 interface | 尽量避免在 Java 直接 String |
类型映射是生成链路里最容易被轻视的一块。我的建议是,在设计生成器时就把类型映射做成可配置的,因为不同团队对精度和时区的约定确实不一样,硬编码迟早要返工。
3.3 第三环:命名规范与字段语义增强
数据库里的命名通常是下划线风格:user_info、create_time。代码里的命名有各自的语言规范:Java 是驼峰,TypeScript 也是驼峰,但文件名又分成 PascalCase 和 camelCase 两种。t3code 里专门实现了一个命名转换器,负责处理这些规则。
命名转换看起来简单,但有几个边界情况需要注意。比如表名 t_order 去掉 t_ 前缀之后变成 order,类名应该是 Order;表名 sys_user_role 去掉 sys_ 之后是 user_role,类名是 UserRole。字段名也一样,create_time 转成 createTime,user_id 转成 userId。如果表名没有设计好前缀,转换规则会非常混乱,所以 t3code 支持配置"表名前缀忽略列表"。
字段语义增强是 t3code 比较独有的一个环节。普通代码生成工具只会做类型映射和命名转换,但 t3code 会额外识别几类特殊字段:
- 软删除字段,比如 is_deleted,生成查询语句时自动带上
is_deleted = 0条件; - 乐观锁字段,比如 version,生成更新语句时自动带上
version = #{oldVersion}并做版本号自增; - 创建时间 / 更新时间字段,生成插入语句时自动填充当前时间,生成更新语句时自动刷新 update_time;
- 字典字段,比如 status,同时生成对应的枚举类或注释信息。
这些字段如果没有被自动识别出来,开发人员生成完代码之后还得手动去改 SQL 和逻辑,那自动化就变成一半自动化,价值大打折扣。
3.4 第四环:渲染与落盘,以及"不覆盖手写代码"的底线
元数据准备好之后,就进入渲染阶段。渲染阶段的核心是把元数据对象注入模板,得到最终代码文本。下面是一个简化版的 ServiceImpl 模板片段,用来说明渲染指令的形态:
package {{packageName}}.service.impl; import {{packageName}}.entity.{{entityName}}; import {{packageName}}.mapper.{{entityName}}Mapper; import org.springframework.stereotype.Service; @Service public class {{entityName}}ServiceImpl implements {{entityName}}Service { private final {{entityName}}Mapper {{entityNameLower}}Mapper; public {{entityName}}ServiceImpl({{entityName}}Mapper {{entityNameLower}}Mapper) { this.{{entityNameLower}}Mapper = {{entityNameLower}}Mapper; } @Override public {{entityName}} getById(Long id) { return {{entityNameLower}}Mapper.selectByPrimaryKey(id); } }模板里的{{...}}就是渲染指令。为了模板自身可读,我刻意没有引入太复杂的条件判断和循环,而是把这些逻辑放在元数据处理阶段。模板里只有简单的变量替换,这样就算不熟悉模板引擎的人也能直接上手改。
渲染完成之后是落盘阶段。文件写到哪个目录、叫什么名字,都有一套明确的规则:Java 包名转成目录路径,类名决定文件名,Service 和 ServiceImpl 放在不同目录。落盘阶段有一条底线原则:绝不覆盖用户已经手动修改过的代码。怎么判断"手动修改过"?我在后面第五章会专门讲 marker 区的设计。
4. 实测效果与性能优化:一个订单模块的生成实验
4.1 实验场景:三张表、六个接口、分层完整
我在团队内部做了一次完整的实测,用 t3code 生成一个订单模块,包含三张表:订单主表 t_order、订单明细表 t_order_item、物流信息表 t_order_logistics。需求是六个接口:订单分页查询、订单详情、创建订单、修改订单、取消订单、物流信息追加。
这个实验设计得比较贴近真实业务,因为涉及主表和明细表的关系,也有状态流转字段和软删除字段。t3code 处理完成后,生成的文件清单大概是这样的:
| 文件 | 作用 | 预估代码行数 |
|---|---|---|
| Order.java | 订单实体 | 约 120 行 |
| OrderItem.java | 明细实体 | 约 100 行 |
| OrderLogistics.java | 物流实体 | 约 80 行 |
| OrderQueryDTO.java | 分页查询参数 | 约 60 行 |
| OrderVO.java | 详情返回对象 | 约 90 行 |
| OrderService.java | 服务接口 | 约 40 行 |
| OrderServiceImpl.java | 服务实现 | 约 300 行 |
| OrderController.java | REST 接口 | 约 120 行 |
| OrderMapper.java | 数据访问接口 | 约 30 行 |
| OrderMapper.xml | SQL 映射 | 约 180 行 |
三张表生成下来,一共是 30 个左右的文件,累计大约 2600 行代码。不要小看这个数字,如果靠手写,这些代码每一行都逃不掉。
4.2 生成速度与手写对比:省时间不是最主要的
t3code 初次版本跑完这个订单模块,耗时大约 2.8 秒。后来做了模板预编译优化,降到了 1.2 秒左右。这个速度完全在可接受范围内,因为代码生成属于低频操作,没人会每秒都跑他。
我更想对比的是手写成本。按我自己的经验,一个熟练的 Java 工程师手写上述这些文件,不算理解业务逻辑的时间,只算机械编写时间,至少需要 3 到 4 个小时。而且手写的过程里很容易出现低级错误,比如 DTO 字段漏一个、VO 类型对不上、SQL 里字段名拼错。t3code 生成出来的代码,在这些结构性错误上是零失误的。
但我想强调的是,省时间不是这个工具最核心的价值。拿订单分页查询这个接口来说,手写 10 次,会有 10 种略微不同的写法:有人直接在 Controller 里写查询逻辑,有人用 MyBatis-Plus 的 LambdaQueryWrapper 做封装,有人偏爱 XML 里的动态 SQL。t3code 的价值是把这 10 种写法收敛成 1 种,让所有模块的代码结构保持一致。时间长了,团队维护成本下降的幅度会远远超过那几个小时的生成时间。
4.3 隐藏在性能后面的优化:模板预编译与并发落盘
第一次跑 t3code 时,我根本没注意性能问题,觉得"反正几秒也能接受"。后来模块多了,一次生成十几个模块,耗时累计到了 20 多秒,才意识到模板解析和类型转换占了大量开销。
优化做在两个方面。第一个是模板预编译:程序启动时把 templates 目录下所有模板一次性解析进内存,生成阶段直接复用编译后的模板对象,不再重复解析。这个优化让单次生成从 2.8 秒降到了 1.6 秒左右。第二个是落盘的并发处理:文件写盘本身很快,瓶颈不在这里,但如果一次性生成大量文件,串行写磁盘也会积压耗时。我引入了有限的并发池,把渲染完成后的文件内容并行写入磁盘,同时严格控制池大小,避免占用过多资源。
还有一个优化点容易被忽略:元数据解析阶段对数据库的连接和查询是串行的。如果一次要生成 20 张表,逐张读取结构信息会明显变慢。t3code 在批量场景下会把表结构查询合并成一次批量查询,大幅减少网络往返。实测下来,批量生成 15 个模块时,总耗时从原来的 28 秒降到 8 秒左右,这个提升还是很明显的。
5. t3code 的边界与经验教训:哪些功能被我有意砍掉
5.1 不生成复杂的业务规则与状态机
代码生成器有个天然的诱惑:功能越做越多,最后想什么都能生成。我在设计 t3code 时给自己划了一条明确的边界:只生成结构固定的代码,不生成复杂的业务规则和状态机。
原因很简单。业务规则的多样性和变动频率远高于结构。比如一个订单的状态流转,可能今天允许"待支付 -> 已支付 -> 已取消",明天产品就加了一个"待支付 -> 已关闭"。这种规则如果写死在生成模板里,模板就会变成逻辑迷宫,生成器本身也失去灵活性。所以 t3code 对这类逻辑只生成带 TODO 注释的空方法体或接口默认实现,具体逻辑留给开发人员手工完成。
边界明确之后,模板维护变得非常轻松。模板里不需要出现复杂的条件分支和循环嵌套,每处渲染都是简单的变量替换,出问题也好排查。
5.2 不生成全家桶式前端,只生成接口定义与基础页面骨架
我也犹豫过要不要生成前端页面。犹豫的原因很现实:光有后端接口,没有前端调用,联调阶段依然要花时间。后来我还是决定 t3code 不全量生成前端页面,原因也很现实:前端技术栈迭代太快,组件库、状态管理方案、请求封装每一轮都在变,生成出来的页面大概率在下一个版本就被重构掉了。
最终我选择了一个折中方案:t3code 生成两类前端相关产物。第一类是 API 定义层,也就是 TypeScript 的接口定义文件和请求函数,这类代码生命周期很稳定,前端团队可以直接使用。第二类是一个极简的列表页骨架,包含查询表单、表格列配置和分页逻辑,定位是"预览级"页面,让联调时可以快速看到数据,真正的交互细节仍然由前端团队完成。
这个取舍让 t3code 避免了"前端模板三天两头改"的维护噩梦,也让前端团队保留了足够的自主空间,不容易引起抵触。
5.3 手改代码与再生成如何共存:marker 区与 diff 保护
代码生成器绕不开一个问题:生成之后我又手动改了,下次再生成会不会把我改的东西覆盖掉?t3code 的答案是 marker 区。
具体做法是,在生成文件时,如果模板对应的方法需要手写逻辑,就在方法体内部输出一组标记注释,类似:
// ===== USER_CODE_START ===== // 这里可以自由编辑,再次生成时不会覆盖 // ===== USER_CODE_END =====生成器在落盘前会执行一个逻辑判断:如果目标文件已经存在,就解析旧文件,提取所有 USER_CODE_START 到 USER_CODE_END 之间的内容,然后把旧文件里这些区域的内容和本次渲染的新内容合并。换句话说,标记之外的部分可以被安全更新,标记之内的手写代码永远保留。
这个方案的实际体验是,改完字段想重新生成 Mapper XML,手写的复杂查询 SQL 不会丢;改完业务逻辑想重新生成 ServiceImpl,手写规则不会丢。但我必须提醒一点:marker 区本身也需要规范约束。如果开发人员把大量业务逻辑全部塞进 marker 区,生成器的更新能力就会退化。我建议 marker 区只放必须手写的逻辑,能放进独立 Service 方法的逻辑就不要堆在标记里。
5.4 给想做代码生成工具的朋友三个建议
t3code 从落地到今天,我踩过的坑不少,现在把最核心的几条经验整理出来。
第一条,先盘点重复代码,不要在 schema 设计上花太多时间。很多人做代码生成器,一开始就陷入"怎么设计元数据格式"的细节里。其实更重要的是先统计团队里哪些代码是真正重复的:是 DTO 透传重复,还是 Mapper XML 重复,还是接口注释重复?统计完再决定生成什么,方向才不会偏。
第二条,MVP 越小越好。第一版只需要支持一种数据库、一种架构风格、一种模板体系,能跑通一个真实模块就够了。不要一开始就想着兼容 MySQL、PostgreSQL、Oracle,还要支持微服务和单体两套架构。每多一个"支持",维护成本都是指数级上升的。
第三条,生成结果必须被当成正式代码来 Review。这一点我和团队发生过几次争执。有人觉得生成代码不是人写的,格式差不多就行,不用仔细看。我坚决不同意。生成代码最终要进生产环境的,如果带着错误进仓库,后面排查问题的成本比手写还高。t3code 生成的每个文件都要过正常的 Code Review 流程,这个习惯能让生成器的 bug 在早期暴露,而不是上线之后爆雷。
对我来说,t3code 最大的价值不是省了几千行代码,而是把团队的接口风格重新拉回同一个基准线上。以前新同事入职要花两周才能适应的模块差异,现在看一处代码就基本能看懂所有模块。如果你也在为重复代码烦恼,不妨也试着做一个类似的工具,从最小的闭环开始跑起来。