接手一个新项目的数据库表,光是用户、角色、菜单、日志这四个模块的增删改查,从建表语句写到Controller,再手动把参数校验、分页查询、统一返回格式一层层补全,我大概要花掉整整两天。后来我花了点时间,把这类重复劳动彻底“关进笼子里”,做了一个叫 t3code 的小工具。
t3code 不是某个开源框架的名字,也不是什么颠覆性技术。它是 Three-tier Code Generator 的缩写,意思就是“三层架构代码生成器”。核心思路非常简单:把后端开发里固定的、模式化的三层代码——表现层(Controller)、业务层(Service/ServiceImpl)、数据访问层(Mapper/Entity)——用一套模板批量生成出来,只留下真正需要人判断的业务逻辑让人来写。这篇文章就把 t3code 的完整设计思路、核心模板细节、实操过程和我在落地过程中踩过的坑一次讲清楚。适合正在做后台管理系统、CRUD 接口比重很大的中小团队,也适合想自己造个脚手架工具的开发者参考。
1. 项目背景与整体设计思路
1.1 三层开发的“标准动作”到底有多机械
我先说个具体的场景。假设现在要上线一个后台管理系统,业务方一口气提了二十张表的需求:商品、分类、品牌、用户、订单、退款单……每张表背后都是同一套“标准动作”:写实体类、写 Mapper 接口、写 XML 映射、写 Service 接口、写实现类、写 Controller,再把分页、排序、条件查询、新增、修改、删除和统一返回结果怼上去。
这个阶段表面上是在“写业务”,实际上超过一半的时间花在复制粘贴上。二十张表就是二十遍同样的流程,每遍要改的只有类名、字段名、字段类型和表名。人不是机器,重复二十遍之后一定会出幺蛾子:要么某张表的实体类漏了个字段,要么某个查询条件忘记传参数,要么 Controller 的路径命名风格不统一。t3code 要解决的就是这个“机械重复 + 人为失误”的组合问题。
1.2 为什么用“模板生成”而不是手动复制或低代码平台
刚打算做这个工具时,我其实考虑过两个替代方案:一个是干脆用现成的低代码平台,另一个是每次复制旧代码再手改。低代码平台的问题在于黑盒,生成出来的代码风格和团队现有规范不一致,而且一旦业务超过平台预设的模型,改造成本特别高。手动复制更不用说了,一个小时复制出来的代码可能有一半时间花在找上一张表的结构、改字段名、排查 typo 上。
最终选择模板引擎方案,是因为:第一,模板文件是纯文本,放在 Git 里可以评审、可以版本化;第二,模板引擎本身非常轻量,不管是用 FreeMarker、Velocity 还是直接用字符串替换占位符,都能实现;第三,也是最关键的,生成出来的代码就是团队平时手写的风格,完全可控,生成的产物可以直接合并进现有工程,不存在“系统生成一套、团队维护另一套”的割裂感。
1.3 生成器的边界:不做什么比做什么更重要
很多代码生成器容易犯一个毛病:功能越做越多,最后变成一个四不像的“智能框架”。t3code 在最初设计时就明确了边界——只生成标准 CRUD,不生成业务流程。
比如“下单”这个动作,背后有库存扣减、价格计算、优惠券核销、订单状态流转,这些是真正的业务逻辑,生成器再强大也替代不了人的判断。t3code 只负责把订单表的模型类、基础 Mapper、基础 Service 和基础 Controller 生成好,让数据层的出入口是完整的,剩下的业务编排留给开发者在 Service 实现类里继续写。这个边界的价值在于,生成器永远不会成为开发的瓶颈,反而像一个训练有素的新人,先把脏活累活干完,交给你一个干净合规的起点。
2. 核心细节解析与实操要点
2.1 输入模型:把表结构变成生成器的“元数据”
t3code 的第一步是把数据库表结构改写成一份机器可以读懂的结构化描述。这里我选择的方式是用 JSON 格式定义元数据,而不是直接连数据库读取表结构。直接连库看起来更省事,但会让工具依赖数据库连接和驱动,而且团队里每个人本地数据库的权限配置都不一样,跑一次生成还要先配环境,很不友好。
元数据文件长这样:
{ "tableName": "sys_user", "className": "SysUser", "moduleName": "system", "apiPath": "/system/user", "fields": [ { "columnName": "id", "fieldName": "id", "javaType": "Long", "comment": "主键" }, { "columnName": "username", "fieldName": "username", "javaType": "String", "comment": "用户名" }, { "columnName": "status", "fieldName": "status", "javaType": "Integer", "comment": "状态:1启用 0禁用" } ] }每个字段的定义都映射到生成模板里的一个变量,没有多余信息,也没有隐藏逻辑。之所以要把表名、类名、模块名、API 路径全部显式声明出来,是因为生成器必须拿到完整的上下文才能产出风格一致的代码。比如模块名是 system,那么 Controller 里的 RequestMapping 前缀就会自动带上/system,Service 类名就会自动带上前缀,这样多模块项目里不会出现类名冲突。
2.2 命名规则与类型映射的“强制统一”
代码风格最怕的是每个人各写各的。t3code 在生成过程中内置了一套刚性命名规则,不允许讨论、不允许例外,这样就堵死了风格分歧的来源。
首先,数据库字段到 Java 字段采用标准的下划线转驼峰。数据库里的user_name会被转成userName,created_at转成createdAt。这个转换规则不是可选项,而是生成器的固定行为。其次,类型映射表也是写死的,数据库字段类型到 Java 类型的对应关系会在生成时自动匹配。
| 数据库类型 | Java 类型 | 说明 |
|---|---|---|
| bigint | Long | 主键和长整型 ID 通用 |
| int / integer | Integer | 状态、计数等普通整数 |
| tinyint(1) | Boolean | 逻辑删除、开关类字段 |
| decimal(p,s) | BigDecimal | 金额、费率等精度敏感字段 |
| datetime | LocalDateTime | 创建时间、更新时间 |
| date | LocalDate | 日期字段 |
| varchar / char | String | 字符串统一映射 |
| text / longtext | String | 长文本也映射成 String |
这里的实操心得是:tinyint(1) 和 tinyint(2) 必须区别对待。tinyint(1) 在 MySQL 里经常被当作布尔值用,而 tinyint(2) 更多是枚举值,如果一股脑映射成 Boolean,生成出来的代码在做等值判断时就会人格分裂。我在 t3code 的类型映射逻辑里专门加了一个长度判断,长度为 1 的小整数映射成 Boolean,否则映射成 Integer,这一个细节在后面避免了好多坑。
2.3 五个核心模板的设计与占位符约定
t3code 的模板体系一共五类文件:实体类(Entity)、数据访问接口(Mapper)、MyBatis 映射 XML、Service 接口、ServiceImpl 实现类。Controller 我一开始也放进了模板,但后面实际使用时发现 Controller 往往需要定制返回结构和权限注解,所以单独留了模板开关,默认生成,必要时可以关掉。
每个模板内部使用${}占位符,与 FreeMarker 语法保持一致。实体类模板的核心片段长这样:
@Data @TableName("${tableName}") public class ${className} { @TableId(value = "id", type = IdType.AUTO) private Long id; <#list fields as field> /** ${field.comment} */ @TableField("${field.columnName}") private ${field.javaType} ${field.fieldName}; </#list> }Service 模板则长这样:
public interface ${className}Service { PageResult<${className}> page(${className}Query query); ${className} getById(Long id); void create(${className} entity); void update(${className} entity); void delete(Long id); }占位符本身没有设计难度,真正的难点在于如何保证模板里的方法和注解能匹配团队现有的技术栈。如果团队用的是 MyBatis-Plus,那主键注解写@TableId;如果用的是 JPA,就得换成@Id。t3code 的策略是在模板开头定义一组可配置的“技术栈变量”,比如主键注解、分页对象、返回结果类,统一替换,这样同一套生成器可以适配不同项目。
2.4 生成代码的可维护性设计
生成器最容易招黑的地方就是“生成完就完了,下次手写改动全部被覆盖”。t3code 在这里做了一个很朴素但很有效的设计:所有生成代码都遵守“扩展而非修改”的原则。
具体来说,生成的实体类、Mapper、Service 接口、ServiceImpl 基础实现,全部存放在代码仓库里,并且都允许开发者在生成文件中追加自己的方法。为了避免下次生成时覆盖手写内容,t3code 在写文件之前会做一次内容比对,如果目标文件已存在,就跳过生成,只在终端输出一行提示“文件已存在,跳过”。这样一来,生成器变成了“只负责初始化,不负责销毁”的角色。
实际用下来,这个设计还有一个额外好处:生成的代码质量会被团队当成底线。任何人接手一个模块,看到的都是风格完全一致的代码,符合命名规范、包含完整注释、基础 CRUD 都齐全,增补业务逻辑时只需要在对应位置追加方法,不需要理解前人那套“神级抽象”。这比任何代码评审都管用。
3. 实操过程与核心环节实现
3.1 先准备一张表和一份字段清单
我拿一个最典型的用户管理模块来演示 t3code 的完整工作流。假设数据库里已经建好了sys_user表:
CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '用户名', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', `created_at` datetime NOT NULL COMMENT '创建时间', `updated_at` datetime NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';拿到这张表之后,我们并不需要真的手写完整的字段描述,更常见的做法是从建表 SQL 反推,或者用 IDE 的数据库工具直接把表结构导出成 JSON。t3code 里的输入模型前面已经写好了,就是一个包含表名、类名、模块名、API 路径和字段列表的 JSON 文件。
这里有一个经验,字段描述里的 comment 一定要认真写。生成器会把 comment 直接变成代码里的 Javadoc 注释,把字段说明写在数据库表里的人可能不多,但把字段说明留在注释里,至少能让十个月后再打开代码的你,以及接手你代码的下一个同事少骂几句。
3.2 模板配置与生成器主流程
模板文件我放在项目的tools/t3code/templates/目录下,生成器主程序是一段不依赖运行环境的 Java 启动类,也可以用 Python 写,核心逻辑都一样:读取 JSON 输入文件、解析元数据、遍历模板文件、渲染输出、写入目标目录。
主流程的伪代码大概是:
public void generate(String metaFile) { TableMeta meta = readMeta(metaFile); InitContext ctx = buildContext(meta); List<FileTemplate> templates = loadTemplates(); for (FileTemplate tpl : templates) { String rendered = renderTemplate(tpl, ctx); if (tpl.isRequired()) { String targetFile = calcTargetPath(tpl, ctx); if (!new File(targetFile).exists()) { writeFile(targetFile, rendered); } else { log("文件已存在,跳过: " + targetFile); } } } }模板引擎我选的是 FreeMarker,因为它的语法对 Java 开发者足够友好,而且支持条件判断和循环,刚好可以应对“有些字段需要注解、有些字段不需要”这种灵活的生成需求。执行器的入口不需要做成 Web 服务,一个命令行参数就能驱动,减少了很多无谓的物理依赖。
3.3 生成出来的代码长什么样
基于sys_user表,t3code 会在对应目录下生成五个文件。实体类的关键部分是这样的:
@Data @TableName("sys_user") public class SysUser { @TableId(value = "id", type = IdType.AUTO) private Long id; /** 用户名 */ @TableField("username") private String username; /** 邮箱 */ @TableField("email") private String email; /** 状态:1启用 0禁用 */ @TableField("status") private Boolean status; /** 创建时间 */ @TableField("created_at") private LocalDateTime createdAt; /** 更新时间 */ @TableField("updated_at") private LocalDateTime updatedAt; }Mapper 接口的生成结果更简单,基础查询方法可以直接继承 MyBatis-Plus 的 BaseMapper:
@Mapper public interface SysUserMapper extends BaseMapper<SysUser> { }ServiceImpl 生成的代码长这个样子:
@Service public class SysUserServiceImpl implements SysUserService { @Resource private SysUserMapper sysUserMapper; @Override public PageResult<SysUser> page(SysUserQuery query) { LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); // TODO 在此追加查询条件 Page<SysUser> page = new Page<>(query.getPageNum(), query.getPageSize()); Page<SysUser> result = sysUserMapper.selectPage(page, wrapper); return PageResult.from(result); } @Override public SysUser getById(Long id) { return sysUserMapper.selectById(id); } @Override public void create(SysUser entity) { sysUserMapper.insert(entity); } @Override public void update(SysUser entity) { sysUserMapper.updateById(entity); } @Override public void delete(Long id) { sysUserMapper.deleteById(id); } }这里生成的分页逻辑用了 MyBatis-Plus 的 LambdaQueryWrapper,查询条件的位置留了一个 TODO 注释。开发者拿到这份代码后,只需要在 TODO 后面补齐业务条件,比如wrapper.eq(StringUtils.hasText(query.getUsername()), SysUser::getUsername, query.getUsername()),就完成了一个完整的条件分页查询。
3.4 接线与验证:生成不是终点
代码生成出来之后,还有几件必须做的事。第一件是把生成的五个文件加入 Git 版本管理,然后开一个分支做编译验证。如果项目用了 Lombok,需要确认实体类上的@Data注解能正常编译;如果项目没有用 Lombok,模板就要改成手写 getter/setter 的版本。
第二件是把 Controller 和前端约定好的路径对齐。t3code 会把apiPath直接落到 Controller 的 RequestMapping 上,比如/system/user,文档里也要同步记录。曾经遇到过一次,前端对接时说接口路径 404,排查半天发现是 API 路径写错了,多了一个复数 s,这类问题在生成器里根本不会出现,因为模板里的路径是元数据直接拼出来的。
第三件事是跑一次冒烟测试。调用新增接口插入一条测试数据,调用分页查询接口看能不能按条件查回来,再调一次删除接口确认逻辑删除或物理删除符合预期。这一套验证流程在手动开发时代也要做,但有了生成器,验证时间会从半小时压缩到十分钟,因为代码里已经没有低级笔误的空间了。
3.5 把生成器沉淀到团队工程里
t3code 做出来一段时间后,我开始让团队里的同事一起用。为了让使用成本降到最低,我把生成器做成了一个 Maven 插件任务,一条命令就能执行:
mvn t3code:gen -DmetaFile=meta/user.json插件的作用只是把核心生成逻辑包了一层,本质还是读 JSON、解析模板、输出文件。团队里的新人看到这个命令后,不需要理解生成器内部实现,只要按模板文件一个个填字段,跑完用 IDE 打开看一遍生成的代码,就知道接下来该往哪里写业务了。
这里有个很重要的工程化经验:模板也要走代码评审。以前我把模板当成“一次写好就不用管”的东西,后来发现随着团队技术栈升级、公共字段增加、返回结构变化,模板也需要同步演进。把模板放进 Git 仓库、跟着主工程一起评审,才能保证生成器始终和团队规范保持一致。
4. 常见问题与排查技巧实录
4.1 数据库类型与 Java 类型不匹配的坑
生成器最频繁翻车的地方就是类型映射。前面我提到 tinyint(1) 映射成 Boolean 的问题,那只是一个例子。更隐蔽的是 decimal 类型的精度丢失:如果元数据里把 decimal(10,2) 映射成了 Double,生成的代码在金额计算时就会出现 0.1 + 0.2 不等于 0.3 的现象。所以 t3code 的默认映射里,凡是 decimal 一律映射成 BigDecimal,凡是 datetime 一律映射成 LocalDateTime,凡是 text 一律映射成 String。
排查这类问题的方法是做一次生成结果的“代码走读”:拿到生成文件之后,从头到尾扫一遍字段类型,重点看有没有出现 Integer 突然变成 Long、Date 突然变成 String 这类情况。一旦发现,优先检查元数据 JSON 有没有写错,再检查类型映射表有没有覆盖到该数据库类型。
4.2 重复生成把手写代码冲掉
t3code 默认采用“文件已存在就跳过”的策略,但还是有同事不小心把生成目录配置到了已存在源码的目录,导致文件被覆盖。虽然我们后来在写文件之前加了内容比对,但最稳妥的做法仍然是:生成之前先确认目标目录的状态,用 Git 开一个新分支,生成完检查 diff,确认没有异常再合入主干。
给团队的硬性规定是:生成器只允许把代码生成到src/main/java下的指定包路径,禁止直接覆盖已有文件。所有生成文件必须经过git diff确认后才允许提交。有一次同事图省事,生成完直接 commit 了,结果把人家手写的 Service 实现全冲掉了,最后靠 Git 历史找回,折腾了半小时。从那以后我才发现,“生成器不覆盖已有代码”这条规则不只是技术上要保证,流程上也要约束。
4.3 模板里的特殊字符转义问题
FreeMarker 模板用${}做占位符,但 MyBatis 的 SQL 映射 XML 里也用${},这就产生了语法冲突。比如生成 Mapper XML 时,我们经常需要在 XML 里写${ew.customSqlSegment}或者${orderByColumn},这会被模板引擎误认为变量占位符,从而抛异常或者渲染出奇怪的内容。
解决办法有两个。简单方案是用<#noparse>包裹不需要渲染的部分,让 FreeMarker 把这一段当成纯文本输出。更推荐的方案是尽量不在模板里写${},而是把动态 SQL 的片段设计成模板变量,由元数据里的特定字段填充,把拼接逻辑放到生成器代码里,模板只负责最基础的占位替换。
4.4 关联查询与公共字段怎么处理
代码生成器有一类共同烦恼:公共字段,比如创建时间、更新时间、创建人、更新人,几乎每张表都有,如果每个实体类都手写一遍,还是重复劳动。t3code 的处理方式是在元数据的字段列表里打标签,带systemField标记的字段在生成实体类时统一添加@TableField(fill = FieldFill.INSERT_UPDATE)注解,配合 MyBatis-Plus 的 MetaObjectHandler 自动填充,这样生成出来的代码不需要手动维护时间字段。
至于关联查询,我的态度一直是:不要放到生成器里。一对多、多对多的关联查询往往和业务语义强相关,生成器硬生成出来的关联条件十有七八是错的。t3code 只在 Mapper XML 里预留一个扩展标签,开发者需要写 join 时自己补。这个取舍让生成器保持轻薄,也让团队的 SQL 功力和业务理解在关联查询里真正发挥作用。
4.5 问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 生成文件为空或缺失字段 | 元数据 JSON 字段名拼错 | 校验元数据,检查 fieldName 与模板占位符是否一致 |
| 日期字段生成成了 Date | 类型映射表未覆盖 datetime | 补充映射,指向 LocalDateTime |
| 金额字段出现精度问题 | decimal 被映射为 Double | 修改映射为 BigDecimal,并检查元数据实际类型 |
| 生成时模板报错 | FreeMarker 与 MyBatis${}冲突 | 用<#noparse>包裹或改造模板渲染逻辑 |
| 生成文件覆盖了手写代码 | 目标文件存在且未做保护 | 开启文件存在跳过机制,并检查生成路径配置 |
| 实体类缺少公共字段 | 元数据漏填 systemField 标记 | 补全字段并重新生成一次 |
| Controller 路径不一致 | 元数据 apiPath 写错 | 统一从模块名计算 API 路径,不在 JSON 里手动写 |
用 t3code 这段时间,我最深的体会是:一个工具好不好用,不取决于它功能有多炫,而取决于它能不能在你最累、最走神的时候帮你兜住底线。以前连续写五个模块的 CRUD 到后来整个人都是麻木的,各种低级笔误不断;现在生成器把重复动作全部接管了,我反而有更多精力去思考真实的业务逻辑和接口设计。代码生成器不是银弹,它解决不了架构腐化,也替代不了业务理解,但它能把“体力活”从开发日常里剔除出去,让团队把时间花在更值得的事情上。如果你也在维护一堆建表五分钟、写接口两小时的系统,不妨按这个思路搭一个属于你们自己的 t3code,模板从一个类开始,慢慢沉淀成团队规范,用久了你会发现,它其实就是你们团队把“约定”固化成代码的过程。