“t3code”这个项目名我是在整理本地工具库时翻出来的,当时给这套代码生成脚手架起名“t3”,取的是 Three-Tier(三层架构)的缩写,后面缀上 code 就是直白说明它的用途——生成代码。简单来说,t3code 是一个面向中小型业务系统的代码生成工具,专注于把表现层、业务层、数据层这三个层级的基础代码一次性批量生成,解决的是每次新项目都要重复写 CRUD(增删改查)、数据库映射、接口定义这类模板代码的痛点。
我第一次意识到需要这样一个工具,是在连续接手第三个内部管理系统的时候。三个项目用的都是同一套技术栈,数据库表结构高度相似,但每一遍都得从零开始写实体类、写数据访问接口、写业务逻辑、写前端表单页。花费的时间里真正有业务价值的改动可能只占两三成,剩下的全是在复制粘贴然后改类名、改字段名。几次下来我确定了一个判断:这类重复劳动完全可以通过代码模板自动化,而且自动化之后的质量会比手动改更稳定——因为手动改经常会漏改某个字段、漏更新某个文件的引用,而模板生成不会犯这种低级错误。
整个项目做下来,从最初只针对 Java Spring Boot 的简单模板,到后来扩展成支持拆分指定表、自定义模板语言、多数据源配置的完整脚手架,前前后后迭代了二十多个版本。这篇文章就把这个工具的完整设计思路、核心实现、实操流程和踩坑记录都写出来,如果你是刚接触代码生成方向,或者正好也想给自己或团队搭一个类似的内部脚手架,可以直接参考。
1. 项目为什么叫t3code:核心设计思路与三层架构拆解
1.1 三层架构在代码生成中的落地方式
做业务系统的人对三层架构都不陌生:Controller 层负责接收请求和返回响应,Service 层处理业务规则,Dao/Mapper 层负责跟数据库打交道。但实际写代码的时候,这三层之间的调用关系和数据传递往往容易混乱,尤其是团队协作时,不同人写出来的分层风格差异会越来越大。t3code 生成代码的第一个原则,就是让每一层的职责边界严格固定下来,生成出来的代码从目录结构到类名命名都保持一致,这样即使新来的同事接手,读代码的成本也会明显降低。
具体落地上,我按表驱动的方式处理:每个数据库表对应一套完整的三个层级代码。从一个表名开始,t3code 会自动推导出实体类名、DAO 接口名、Service 接口名、Service 实现类名、Controller 类名,以及前端列表页和表单页的文件名。为了让这套推导规则可复用,项目在初始化时会扫描用户传入的连接信息,把指定数据库里的所有表结构拉出来,然后逐表生成。
有一个细节特别值得说:表名到类名的转换规则。比如数据库里一张表叫sys_user_info,t3code 会按照下划线转驼峰、去掉前缀的策略,变成UserInfo,然后这个基础名会被套进所有层级的命名模板中,最终生成UserInfoController、UserInfoService、UserInfoServiceImpl、UserInfoDao、UserInfo实体类。如果表名带有sys_、t_这类前缀,在配置文件中可以定义忽略列表,避免类名里残留无意义的前缀。
1.2 为什么选择“模板+配置”而不是“写死代码”
一开始我确实走过弯路:把常见的 CRUD 方法直接写成固定的代码段拼接,通过字符串拼出整个文件内容。这个方法在表结构非常简单的时候还能用,但一旦字段类型复杂、主键策略不同、或者出现逻辑删除字段,字符串拼接的代码就会开始失控——每加一种情况,就要在拼接逻辑里加一个分支判断,代码越来越难维护。
后来我彻底换成模板引擎方案,用独立的模板文件描述代码结构,用配置数据驱动模板渲染。这样做的好处有三个:第一,模板文件本身是静态文本,改起来直观,不需要懂拼接逻辑;第二,模板和代码生成器解耦,想增加生成新的文件类型,只要新增模板文件即可;第三,模板可以内嵌条件判断和循环,针对不同的字段类型、主键策略可以生成不同的代码路径。举个例子,在实体类模板里,我可以对日期字段单独生成@JsonFormat注解,对逻辑删除字段生成@TableLogic注解,这些条件判断放在模板里,比放在拼接代码里清爽太多。
1.3 这个工具适合谁,解决了什么问题
如果你属于下面几类人群,t3code 这种思路对你会有直接帮助:一是做外包项目或者接私活,经常要一周内交付一个带后台管理的业务系统,快速生成基础 CRUD 能省掉至少一天的工作量;二是企业内部开发者,要维护多个功能相似的后台管理系统,希望能统一代码风格和分层规范;三是团队技术负责人,想把团队约定好的代码规范固化到工具中,让每个人都产出相同结构的代码。
当然也要说明白,t3code 不适合解决所有问题。它解决的是标准化程度高的基础代码问题,如果项目里充满了高度定制化的业务逻辑,生成出来的骨架代码仍然需要你手动去调整和补充。但即便只生成骨架和基础 CRUD,省下来的时间和减少的出错概率也已经相当可观了。
2. 模板引擎选型与定制化设计机制
2.1 为什么选用 Jinja2 作为核心模板引擎
整个工具核心部分的技术选型上,我选择了 Python 语言来实现,模板引擎则用了 Jinja2。选型的逻辑很直白:Python 写命令行工具有成熟的生态,Jinja2 是事实标准的模板引擎,语法简单,条件渲染和循环渲染能力足够强。对大部分后端开发者来说,Jinja2 的{% if %}、{% for %}语法学习成本几乎为零,改模板的门槛就低很多。
Jinja2 在设计上的一个关键优势是模板继承(Template Inheritance)。我可以定义一个基础模板,把代码文件的公共结构放进去,比如 Controller 类的基本框架、类注释、包名声明,然后在子模板里覆盖具体的方法实现部分。这样做的好处是当我想在所有生成的 Controller 中统一增加一个日志注解或者统一的返回结果封装时,只需要改基础模板,所有代码文件的生成结果都会同步更新。对于那些定制化程度高的项目,这能节省大量重复修改的时间。
2.2 模板加载器的路径解析策略
模板文件的管理方式直接影响到工具的易用性。t3code 在初始化时会创建一个template目录,内部按照文件类型分子目录存放模板,例如:
template/ ├── java/ │ ├── controller.java.j2 │ ├── service.java.j2 │ ├── serviceImpl.java.j2 │ ├── dao.java.j2 │ └── entity.java.j2 ├── xml/ │ └── mapper.xml.j2 └── web/ ├── list.html.j2 └── form.html.j2模板加载器会扫描这个目录结构,然后根据生成时指定的类型去查找对应模板文件。这样做看起来简单,但实际上要处理一个容易被忽视的问题:用户自定义模板的路径可能很随意,有的模板文件嵌套了好几层子目录,有的模板文件名跟生成的文件名不一致。解决方法是给模板文件加入元信息注释,在模板开头声明这个模板要生成什么类型的文件、输出时的文件名规则,加载器解析这些元信息来确定输出位置。
这部分的实现细节是:解析模板开头的注释块,识别# filename:和# output:两个字段。比如# filename: {entity_name}Controller.java表示输出文件名是基于参数动态变化的,而# output: controller表示输出到controller子目录。这个设计虽然初期实现时多花了一些工夫,但当模板数量增多以后,这套元信息驱动的机制让模板管理变得非常清晰。
2.3 扩展机制:自定义模板与多方言支持
不同团队用的技术栈差异很大,有人用 MyBatis,有人用 Spring Data JPA,有人用 JDBCTemplate,模板不可能只针对一套技术栈写死。t3code 的解决方式是支持配置多套模板目录,在生成命令中通过--template参数指定使用哪一套模板。默认提供一套基于 Spring Boot + MyBatis Plus 的模板,也是我个人最常用的一套,然后其他场景通过自定义模板目录来支持。
在模板目录的配置方面,我采用了一个简单的配置文件t3config.json,核心结构长这样:
{ "templates": [ { "name": "springboot-mybatis", "path": "./templates/default" } ], "ignoredTablePrefixes": ["sys_", "t_"], "basePackage": "com.example.demo", "db": { "host": "127.0.0.1", "port": 3306, "database": "app_db", "username": "root", "password": "******" } }在生成命令里传入--template springboot-mybatis就能立即切换模板。这种“配置变量 + 切换模板”的方式,比把每种技术栈的生成逻辑硬编码进主程序要灵活得多。团队内部如果沉淀出一套更好的模板,直接加进模板目录即可,不需要改工具本身的代码。
3. 实操流程:如何用一条命令生成完整的三层代码
3.1 初始化配置:连接数据库并解析表结构
使用 t3code 的第一步是初始化配置。以我本地的一个示例项目为例,数据库里有一张用户表和一张角色表,字段定义如下(做了简化):
CREATE TABLE `sys_user_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(64) NOT NULL COMMENT '用户名', `password` varchar(128) NOT NULL COMMENT '密码', `email` varchar(128) DEFAULT NULL COMMENT '邮箱', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `status` tinyint(4) DEFAULT 1 COMMENT '状态:0禁用 1启用', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `deleted` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';在项目根目录执行t3code init,工具会出现交互式提问,依次询问数据库地址、端口、库名、用户名、密码和基础包名。这些配置写入t3config.json。随后工具立即开始读取表结构,并把字段列表、类型、注释、主键、约束信息缓存到内部结构中。
首次连接数据库时有个容易出错的地方:数据库驱动和连接参数。如果连的是 MySQL 8.0 以上版本,连接串里最好显式加上useSSL=false和serverTimezone=Asia/Shanghai,否则会因为时区问题和 SSL 默认行为报错。我一开始就是没在意这块,导致初始化时一直连不上,排查半天发现是驱动升级后默认行为变了。
3.2 生成文件的目标结构
执行生成命令之后,t3code 会在当前目录下创建一个按包名结构组织的目录树。以com.example.demo为基础包名,生成后的结构如下:
└── src/main/java/com/example/demo/ ├── controller/ │ └── UserInfoController.java ├── service/ │ ├── UserInfoService.java │ └── impl/ │ └── UserInfoServiceImpl.java ├── dao/ │ └── UserInfoDao.java ├── entity/ │ └── UserInfo.java └── mapper/ └── UserInfoMapper.xml前端文件则输出到一个独立的web目录中,默认是 Vue 单页面的列表页和表单页结构。生成时我特意把前后端输出目录分开,避免混在一起导致版本管理不便。这个目录结构被设计成符合 Maven 标准布局的,生成完毕之后可以直接复制进一个 Spring Boot 工程中使用。
具体命令只有一句:
t3code generate --tables sys_user_info,sys_role_info如果省略--tables参数,工具会默认生成数据库中所有非忽略前缀的表。这里提供一个非常实用的选项:--tables支持通配符,比如--tables user_*可以匹配所有以 user_ 开头的表,实际使用中非常顺手。
3.3 生成的代码长什么样
拿UserInfo.java实体类举例,生成的内容大致如下:
package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableLogic; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("sys_user_info") public class UserInfo { @TableId(type = IdType.AUTO) private Long id; private String username; private String password; private String email; private String phone; private Integer status; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime updateTime; @TableLogic private Integer deleted; }这段代码在几个位置体现了模板的智能处理逻辑:主键字段自动加了@TableId注解,deleted字段自动识别为逻辑删除并添加对应注解,时间字段自动加了序列化格式注解。这些规则都在模板内部通过条件判断实现,而不是在生成器代码里写死。实体类的注释直接从数据库字段注释获取,生成的代码自带说明文档,省去了后续补注释的环节。
3.4 Controller 层的默认接口集
生成的UserInfoController.java默认包含一组标准的 RESTful 接口,对应一个基础的 CRUD 场景:
package com.example.demo.controller; import com.example.demo.entity.UserInfo; import com.example.demo.service.UserInfoService; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/userInfo") public class UserInfoController { private final UserInfoService userInfoService; public UserInfoController(UserInfoService userInfoService) { this.userInfoService = userInfoService; } @GetMapping("/list") public List<UserInfo> list() { return userInfoService.list(); } @GetMapping("/{id}") public UserInfo getById(@PathVariable Long id) { return userInfoService.getById(id); } @PostMapping("/save") public UserInfo save(@RequestBody UserInfo userInfo) { userInfoService.save(userInfo); return userInfo; } @PutMapping("/update") public UserInfo update(@RequestBody UserInfo userInfo) { userInfoService.updateById(userInfo); return userInfo; } @DeleteMapping("/{id}") public String delete(@PathVariable Long id) { userInfoService.removeById(id); return "success"; } }这块生成的代码有几个值得关注的细节:接口路径全部采用小写驼峰格式,保持风格统一;实现了 MyBatis Plus 提供的IService接口,让基础的 CRUD 方法可以直接继承,不需要在 Service 实现类里手动写重复逻辑。查询方法没有做拼接条件,因为项目的实际业务查询逻辑大多数情况下都需要按具体情况定制,所以生成器只提供最基础的能力接口,把扩展空间留给开发者。
4. 关键细节与参数设计:生成规则背后的逻辑
4.1 字段类型映射规则
数据库字段类型跟 Java 类型的映射是代码生成中最核心的一张映射表。不同类型的数据库字段在 Java 中对应不同的类型,而且在某些边界情况下还需要特殊处理。我整理了一张最常用的 MySQL 到 Java 的映射参考表:
| MySQL 类型 | Java 类型 | 说明 |
|---|---|---|
bigint | Long | 主键常用 |
int/integer | Integer | 普通整型 |
tinyint | Integer | 常用作状态位 |
varchar/char | String | 字符串 |
text/longtext | String | 长文本处理为 String |
decimal/numeric | BigDecimal | 金额等精度计算 |
date | LocalDate | 日期 |
datetime | LocalDateTime | 时间戳 |
timestamp | LocalDateTime | 时间戳 |
float/double | BigDecimal | 推荐用 BigDecimal 代替浮点 |
映射表看起来很常规,但实际使用中有个很隐蔽的坑:如果字段类型是tinyint(1),MySQL 会把它当成布尔类型,而tinyint(4)是整数。直接用类型名判断会出问题,比如线上一个字段是status tinyint(1),当初建表时只想存 0 和 1,但在 Java 端接收到生成的代码里依然是Integer,并不会自动变成Boolean。我在设计的时候特意做了这个取舍:统一将tinyint映射为Integer,因为状态字段往往不只是 0 和 1 两个值,后续扩展状态时Integer更加稳妥。如果你确实需要Boolean映射,可以在模板中自定义字段类型转换规则。
4.2 主键策略与逻辑删除
生成实体类时有两个经常被忽略的问题:主键生成策略和逻辑删除字段。数据库的主键有可能是自增的,也有可能是用分布式 ID 生成的。t3code 在读取表结构时会检查主键列是否包含auto_increment标识,如果是自增,就在实体类上生成@TableId(type = IdType.AUTO);如果不是自增,则生成@TableId(type = IdType.ASSIGN_ID),让 MyBatis Plus 用雪花算法生成分布式 ID。
逻辑删除的判断规则是:检查字段名或注释中是否包含“删除”或deleted关键字,并排除普通删除标记字段。当命中逻辑删除字段时,生成器会自动做两件事:在实体类字段上添加@TableLogic注解,让它自动在查询时过滤掉已删除数据,并在执行删除操作时自动转换为更新操作;同时在 Mapper XML 的通用查询语句中自动追加deleted = 0条件。这个细节省去了不少手写出错的机会。
我在测试时发现一个容易踩的坑:如果数据库中存在一个字段叫delete_user_id,它的含义是“删除人的 ID”,而不是“是否删除”。简单按字段名包含deleted来判断会误判。解决方式是判断字段名精确匹配deleted或字段注释包含“逻辑删除”关键词,而不是宽泛地匹配delete这个子串。模板中的条件判断就要写得严谨一些。
4.3 API 路由命名规则与风格统一
生成 API 路由时,t3code 默认将实体类名转成小驼峰形式作为基础路径。UserInfo变成/api/userInfo,RoleInfo变成/api/roleInfo。这种规则简洁,在中小型系统中够用。如果你想把复数形式加上,也可以在配置里开启pluralizeRoutes,这样UserInfo会变成/api/userInfos,但在实际项目中这个选项我很少用,因为中文语境下对复数不敏感,加不加反而不一致。
对于生成出来的每一个 Controller,方法命名也有一套约定:查询列表用list,按 ID 查询用getById,新增用save,更新用update,删除用delete。这套命名在生成模板中直接内置,团队中不同模块之间的接口风格因此保持一致。对前端来说,对接的成本也会降低,因为不管哪个模块,接口语义都差不多。
4.4 多表批量生成的顺序与事务问题
在批量生成多张表的代码时,默认按照表的排序逐个生成。但数据库表之间存在外键关系时,生成顺序可能会影响代码结构。比如先生成Order(订单)表对应的代码,再生成User表对应的代码,如果Order的实体类中要关联User的字段,那么生成的代码在编译阶段可能会因为找不到引用的类而报错。
为了解决这个问题,t3code 在生成时会先构建一张依赖关系图,遍历表结构中的外键信息,按依赖关系排序,先生成被依赖的表代码。如果表结构设计得不好,没有明确外键约束,也可以通过配置文件手动指定生成顺序。批量生成时还有一个细节:每张表生成前会检查目标文件是否存在,默认覆盖时提醒一次,避免误操作把已经手写的代码覆盖掉。这个检查在开发中救过我很多次,因为经常在生成后手动补充了业务代码,再重跑一次生成命令忘记加备份选项,结果手写部分全被覆盖了。
5. 常见问题与排查技巧实录
5.1 字段类型映射异常:MySQL 枚举类型处理
数据库设计中总有人用枚举类型定义状态字段,比如status ENUM('pending', 'active', 'disabled')。这个类型在 MySQL 中是合法的,但 Java 端没有直接对应的类型。如果直接按常规类型处理,生成的代码会报 SQL 映射错误,MyBatis 在读取枚举值时也无法转换。
我最初遇到这个问题时,采用的方式是把所有枚举字段映射为 String 类型,但这样在 Java 代码中写条件判断时会比较麻烦,还要处理字符串常量。后来在模板里做了更完善的方案:在配置文件中允许定义枚举字段白名单,把这些字段映射为专门的枚举类并生成对应枚举文件。以订单状态为例,生成一个独立的枚举类OrderStatusEnum.java,包含PENDING、ACTIVE、DISABLED三个常量,并在实体类字段上使用这个枚举类型。这样做带来了更好的类型安全。
不过也要提醒一点:枚举映射的启用是有代价的,生成前必须仔细确认枚举值与业务语义一致。如果业务中频繁改变枚举状态集,这个方案会带来一定的迁移成本。所以这里提供一个很实际的经验:状态字段不超过三个稳定值可以选择开启枚举映射,超过或者状态会频繁变化,直接用 String 映射反而更容易维护。
5.2 连接数据库超时和时区问题
首次使用工具连接 MySQL 时最容易遇到的问题是时区差引起的报错。MySQL 8.0 以上版本默认时区是UTC,而本机在北京时间,连接初始化时如果没有指定serverTimezone,读取到的DateTime字段会全部少 8 小时。这个问题的出现非常隐蔽,因为你连接数据库时不会报错,数据也能读取,但生成的文件和实际业务运行后返回的数据差 8 个小时,排查起来很费劲。
解决方案就是在配置初始化时强制加上连接参数:
t3code init --jdbc-url "jdbc:mysql://127.0.0.1:3306/app_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"这个参数里同时把characterEncoding=utf8mb4加上了,防止中文表名、字段名读取时出现乱码。特别是数据库中存在中文注释时,字符编码正确性直接影响生成的注释文字。我建议所有人在配置数据库连接时都养成显式声明编码和时区的习惯,不要依赖默认值。
5.3 模板渲染失败:变量名冲突与语法错误
Jinja2 模板渲染过程中报错最常见的原因是:模板中引用了一个不存在的变量,或者变量名跟 Python 内置函数或关键字冲突。模板中如果需要表名,我用的是table_name、entity_name、mapper_name等命名,但一开始的版本里直接把变量命名为type,结果跟 Python 内置函数type冲突,导致某些场景下渲染行为异常。
这类问题排查的方法也很简单:开发时可以打开模板渲染的报错堆栈,把 Jinja2 的调试模式开启。它会直接告诉你哪一行的哪个变量未定义。我这里有一个非常实用的模板编写小技巧:在模板开头加一行调试输出注释,渲染时如果发现问题,可以先查看这一行是否正常,缩小排查范围。
还有一种常见情况:模板中包含会被 Jinja2 解释的符号,比如在生成 JavaScript 代码时出现{{ }}这种双大括号,会被 Jinja2 当成变量表达式解析,结果因为没有定义该变量而报错。解决方式是在模板中使用{% raw %}块包住不需要渲染的部分,或者用{{ '{{' }}转义。生成前端代码时这个坑容易出现,尤其是 Vue 模板中经常有双大括号插值语法,因为 Vue 和 Jinja2 都使用{{ }}作为表达式定界符。
5.4 文件路径与命名空间不一致引发的编译错误
生成代码文件后直接放进 IDE 编译,经常会遇到一个错误:文件在磁盘上的路径跟包名不一致。比如 Java 文件的package声明是com.example.demo.entity,但文件被输出到了src/main/java/com/example/demo/controller目录下,这个在 Eclipse 或 IDEA 中都会报编译错误。
解决办法是让输出目录严格由包名推导出来,而不是由模板文件名推导。t3code 在生成文件时,会根据basePackage和output元信息组合出完整的输出路径。生成器内部先计算包名对应的目录结构,再传入模板引擎决定输出位置。记得在批量生成后,执行一下测试编译或者至少在 IDE 中刷新项目目录检查一遍,避免低级的目录错误。
5.5 重复生成的代码与手写扩展的冲突
这是实际使用中遇到最多的一个真实困惑。生成器生成好基础代码后,开发者在 Service 实现类中手动添加了自定义方法,结果当表结构发生变更需要重新生成时,手写的代码会被覆盖。怎么管理这个冲突?
t3code 给出的策略是区分生成区域与手写区域。在模板文件中,通过注释标记一个代码块为“生成区”,这部分在重新生成时会被覆盖;而“手写区”会被保留。具体实现是:模板渲染时生成一个带特殊标记的代码块,比如在文件内用注释包裹起来:
// ============ GENERATED START ============ // 此区域由 t3code 自动生成,重新生成时将被覆盖 public void generatedMethod() { ... } // ============ GENERATED END ============重新生成时,工具会读取旧文件,先定位GENERATED START和GENERATED END标记,只替换标记内的内容,标记外的部分保持不变。这个方案在我连续几个月迭代业务代码时节省了大量的合并时间,也因此稳定保留了下来。
按照我个人在实际操作中的体会来说,代码生成工具的价值不在于它能写出复杂的业务逻辑,而在于它能让我们把重复劳动的时间省下来去思考真正需要思考的问题。t3code 的设计思路是“适可而止”的——它生成的是骨架、是基础 CRUD、是统一的分层规范,而不是试图去猜测业务逻辑,更不会试图取代开发者的设计决策。这也是我在多次迭代之后逐步收敛下来的判断:一个代码生成工具,越克制反而越好用。
如果你也想搭一个类似的工具,我的建议是先明确你自己的模板需求,定义好哪些文件是需要批量生成的核心部分,再配置好一套可用的模板。不要一开始就把所有功能铺开,最后你会发现真正有长期价值的可能就只有那几张核心模板而已。剩下的时间,拿去多看看业务代码,想想怎么把不重复的部分写好,这比任何工具都更值得。