做后端快十年,各种代码生成器、低代码平台也见识了不少,但大部分时候都是围观别人家的产品,真正自己动手写一个还是头一回。今天要聊的t3code就是我自己在项目空档期折腾出来的一个小工具,定位非常明确:专门给三层架构(Controller-Service-DAO/Repository)项目生成基础代码。核心思路不复杂,就是读取数据库表结构,把 Entity、Mapper、Service、Controller 这些重复性极高的代码一次性输出,同时把 VO、DTO、Mapper XML 也一并生成。它解决的是我长期以来的一个痛点:项目一多,每个模块的增删改查代码翻来覆去都是同样的套路,手写浪费时间还容易出错,团队里每个人写出来的风格还不一样。这个项目适合后端开发、全栈工程师以及想做工程化提效的团队参考,尤其是被重复 CRUD 代码折磨过的朋友,看完应该能get到不少实用思路。
1. 项目定位与整体设计思路
1.1 “三层”到底指什么,以及我为什么非要自研
很多刚接触的人会问:现在现成的脚手架一堆,Spring Initializr 、MyBatis Generator 、甚至 IDEA 插件都能搞定,为什么要自己写一个取名t3code的东西?
实际上“三层”这个说法在 t3code 里有两层含义。第一层是经典的三层架构,也就是 Controller 层、Service 层、DAO 层。这是绝大多数 Web 项目的落地骨架。第二层是“三段时间”:读取表结构花一段,渲染模板花一段,校验输出花一段。我把工具设计成三段式,每段都可以独立调试,出了问题时不用整个重跑,能快很多。
至于为什么不直接用现成方案,我用真实体验来说。MyBatis Generator 很强,但它的代码风格非常固定,生成的注释丑、方法冗长,而且和现代 Spring Boot 风格多少有点脱节。市面上一些在线生成平台倒是漂亮,但表结构一敏感就不能外传,内网部署又要钱。IDEA 插件嘛,换个 IDE 就得换一套肌肉记忆。所以我的结论是:与其费劲去适配别人的工具链,不如花一两天写一套自己可控的生成器,模板完全捏在自己手里,想怎么改就怎么改。对于长期有建表需求的团队来说,这套投入回报率非常高。
1.2 方案选型:从“手写模板”到“元数据驱动”
早期我做 t3code 的时候,思路还停留在“字符串拼接代码”的层次,就是在 Java 里写一堆 StringBuilder,把 package、import、类名拼起来。这么做的问题很明显:只要代码结构调整一次,拼接逻辑就得改一遍,维护成本高到崩溃。
后来我换成了“元数据驱动 + 模板引擎渲染”的方案。具体来说,通过 JDBC 的DatabaseMetaData接口读取表名、字段名、字段类型、注释、主键、索引信息,把这一坨信息封装成统一的元数据对象,然后传给模板引擎去渲染。t3code 用的是 Freemarker,主要原因是它语法简单、对 Java 开发者友好,而且支持局部变量、条件判断、循环遍历,写模板时非常顺手。
这一步看似简单,其实是整个项目的地基。元数据模型设计成“表信息”、“字段信息”、“主键信息”三块,分别对应 Java 里的TableMeta、ColumnMeta、PrimaryKeyMeta。每个字段要记录的信息包括:数据库物理名、Java 属性名(驼峰转换后)、Java 类型、JDBC 类型、是否主键、是否自增、是否允许为空、字段注释、默认值。把这些信息完整塞进模型,后面的模板渲染工作就能很轻松。
2. 核心架构与关键技术拆解
2.1 t3code 的四个核心模块
t3code 整体分成四个模块,每个模块各管一段,逻辑清晰,测试也好写。
- 配置解析模块:负责读入数据库连接配置、输出路径、包名、作者名、是否覆盖文件等参数。它支持 YAML 和 Java 原生的
Properties两种格式,我个人推荐 YAML,可读性好,团队协作时改动也直观。 - 元数据读取模块:基于 JDBC 的
DatabaseMetaData和ResultSetMetaData实现。这里有一个很多人容易忽略的细节:不同数据库返回的元数据大小写规则不一样,MySQL 默认小写,Oracle 默认大写,PostgreSQL 则常见小写带下划线。所以 t3code 里统一做了一次lowerCase归一化,再进入后续处理。 - 模板渲染模块:加载 Freemarker 模板,传入元数据模型,输出字符串内容。这个模块本身不关心文件写到哪,只负责“渲染”,所以单测非常好写。
- 代码输出模块:负责创建目录结构、处理文件覆盖策略、修正换行符、输出最终文件。Windows 和 Linux 混用的团队,输出前统一转成 LF,能避免不少莫名其妙的 diff。
每个模块之间的通信都靠数据模型,不共享可变状态。这就意味着后面我想加“生成 TypeScript 代码”或者“生成数据库迁移脚本”,只需要新加模板和适配器,不需要动已有的渲染逻辑。
2.2 数据库方言与类型映射的细节
讲一个我踩过的坑:数据库字段类型到 Java 类型的映射,绝不是一个switch-case就能解决的。以 MySQL 为例,datetime映射到LocalDateTime没问题,但date如果也按某些旧框架的思路映射成Date,在 Spring Boot 3 的jackson-datatype-jsr310下序列化就会出问题。
我在 t3code 里维护了一张类型映射表,核心规则如下:
| 数据库类型 | Java 类型 | 说明 |
|---|---|---|
BIGINT | Long | 主键常用,注意无符号整型 |
INT/INTEGER | Integer | 如果字段可能超 21 亿,要手动改成Long |
TINYINT | Integer | 不直接映射Boolean,因为业务上经常存 0/1/2 多状态 |
VARCHAR | String | 长度映射到@Column(length = ...),方便 DDL 对齐 |
DECIMAL/NUMERIC | BigDecimal | 金额字段必用,禁止用Double |
DATETIME/TIMESTAMP | LocalDateTime | 新项目统一LocalDateTime |
DATE | LocalDate | 只存年月日 |
TEXT/LONGTEXT | String | 同时生成@Lob注解 |
BOOLEAN/BIT | Boolean | 只有确定是布尔语义时才用 |
这张表不是死的,我特意在配置里留了“自定义类型映射”的口子,用户可以加一条DOUBLE -> Float之类的覆盖规则。因为真实项目的字段语义千奇百怪,生成器能做的是给一个合理的默认值,而不是替业务做决定。
2.3 为什么不直接套用现成脚手架
看到这里估计有人会问:既然这么麻烦,为什么不用现有的微服务脚手架,再配合一个在线接口文档反向生成代码?
我做 t3code 时其实权衡过。现成脚手架的问题在于“重”。你拉一个开源项目下来,里面有几百个类,一半用不上。而 t3code 生成的代码只包含当前表相关的几个文件,不引入多余依赖,也不绑架你的架构决策。生成出来的 Controller 是继续用 Spring MVC 还是换 WebFlux,由你后续的模板调整决定,工具本身不做限制。这种“轻”对于追求代码可控的老手特别重要,我可以把生成的代码轻易融入现有项目,而不需要为了迁就框架去改我的分层习惯。
另一方面,自己写生成器的过程中,你会重新审视项目中哪些代码其实是重复劳动,哪些才是核心价值。这个思维转变比工具本身还值钱。
3. 从零实现:核心代码与实操流程
3.1 第一步:定义数据源与生成配置
我习惯用一个t3code.yaml做总入口,里面直接写明数据库连接和生成选项。
database: url: jdbc:mysql://localhost:3306/your_db username: root password: your_password driver: com.mysql.cj.jdbc.Driver generator: # 输出根路径,默认是当前目录下的 generated-code outputDir: ./generated-code basePackage: com.example.module author: zhangsan # 需要生成的表,支持通配符 tables: - sys_user - sys_role - sys_permission # 要生成的分层组件,按需开关 layers: entity: true mapper: true service: true controller: true dto: true vo: true # 是否覆盖已存在的同名文件 overwrite: false配置项不多,但每一条都有讲究。basePackage决定了整个包名路径,com.example.module意味着生成路径是com/example/module,和 Maven/Gradle 的标准目录结构对齐。tables我支持通配符,比如sys_*,因为它比手写一张张表名高效得多。overwrite默认是false,这个必须提一下——如果你第一次生成后发现模板有小 bug,修正后想要重新生成部分文件,直接覆盖很容易把手工改动过的代码冲掉。所以我的习惯是:模板稳定前一律不覆盖,模板确定没问题的文件再手动开启覆盖。
3.2 第二步:编写模板文件
t3code 的核心资产就在src/main/resources/templates目录里。我最初的版本直接写死 Java 代码,后来全部换成了.ftl文件。目录结构如下:
templates/ ├── entity.ftl ├── mapper.ftl ├── mapperXml.ftl ├── service.ftl ├── serviceImpl.ftl ├── controller.ftl ├── dto.ftl └── vo.ftl拿最基础的entity.ftl来举例,核心渲染逻辑大概是这样的:
package ${meta.packageName}.entity; import java.time.LocalDateTime; import java.math.BigDecimal; /** * ${meta.tableComment} */ public class ${meta.className} { <#list meta.columns as column> /** * ${column.comment} */ private ${column.javaType} ${column.camelName}; </#list> <#list meta.columns as column> public ${meta.className} set${column.pascalName}(${column.javaType} ${column.camelName}) { this.${column.camelName} = ${column.camelName}; return this; } public ${column.javaType} get${column.pascalName}() { return this.${column.camelName}; } </#list> }注意几个关键点。meta是传给模板的根对象,它有packageName、tableName、className、columns等属性。column.camelName是数据库字段user_name转换得到的userName,column.pascalName则是UserName。这类转换逻辑在元数据读取模块里提前完成,模板里不写大小写转换代码,保持模板干净。为什么这么设计?因为 Freemarker 里写字符串转换逻辑会让模板变得很难调试,最好把所有业务/格式逻辑全部前置到 Java 代码里,模板只负责做“填空”和“循环”两件事。
Mapper XML 的模板更有意思。insert语句的列名、#{}占位符、set子句、where条件,都得根据字段元数据动态渲染。比如selectByPrimaryKey的<where>部分,只渲染主键字段的条件,其余字段一概忽略。这就是为什么元数据模型里一定要有isPrimaryKey这个属性,否则模板里没法区分普通字段和主键字段。
3.3 第三步:运行生成与结果校验
t3code 的主程序入口在GeneratorApplication.java,核心逻辑只有三步:加载配置、读取元数据、批量渲染。别整花活,命令行工具最重要的就是“可预期”。
执行完生成后,我习惯性做三件校验:
- 目录结构校验:看包名路径是否和
basePackage一致,防止 Windows 下反斜杠问题导致目录创建错位。 - 语法编译校验:写一个简单的 Maven 子工程,把生成代码丢进去
mvn compile,可以快速抓出注解缺失、 import 不完整等低级错误。 - 内容抽查:手动打开 Controller、ServiceImpl 各一眼,确认没有把时间格式化写死、没有生成多余的空行、没有把数据库注释带成乱码。
别小看第三步里“乱码”这件事。数据库表注释如果是中文,生成到 Java 文件里经常出现????。这里有两个坑:一是数据库连接串没带characterEncoding=utf8,二是模板文件本身编码不是 UTF-8。t3code 在加载模板时强制 UTF-8,又统一在 JDBC URL 上默认追加编码参数,问题才彻底解决。
4. 生成代码的工程化落地与团队协作
4.1 生成代码与 Spring Boot 的集成
工具生成出来的代码要能用,必须无缝融入 Spring Boot 项目。t3code 生成的 Entity 直接放在entity包下,Mapper 接口放在mapper包下,并且自动加上@Mapper注解。这样 Spring Boot 启动时组件扫描能识别到 Mapper 接口,配合@MapperScan更是双保险。Controller 上默认标注@RestController和@RequestMapping("/api/${meta.lowerCamelName}"),Service 接口和 ServiceImpl 分开,后者加@Service。这就是典型的三层结构,没有魔法,够用且直白。
这里要特别说一下 Lombok 的处理。t3code 模板默认生成手写的 getter/setter,而不是依赖 Lombok 的@Data。理由有两层。第一层是兼容性:总有一些项目因为各种历史原因不愿意引入 Lombok,手写访问器能降低接入门槛。第二层是可控性:生成器输出的代码不仅是给机器看的,更多时候是给新人当样例学的,手写字段和方法更直观。当然,配置里也留了useLombok=true的选项,打开后模板跳转生成@Data、@Builder等注解,代码量能少一半。两种模板我都实际跑过,团队自己选就好。
4.2 通过 Checkstyle 与自定义规则保证生成质量
生成器最尴尬的时刻是:生成的代码自己都没通过团队的代码规范检查。所以我给 t3code 内置了和 Checkstyle 对齐的常见规则,主要有:
- 类名使用 PascalCase,方法名使用 camelCase,常量使用 UPPER_SNAKE_CASE。
- 每行代码不超过 120 个字符,超长的链式调用要换行。
- 方法体不允许超过 80 行(针对生成的 ServiceImpl 里容易出现的超长事务方法)。
- 魔法值不允许直接出现,比如状态字段比较时要用常量或者枚举。
这些规则本身不复杂,真正有价值的点是:模板里写代码时就直接遵守规则,而不是生成后再靠工具去格式化。我举个例子,模板里所有if (xxx) {的后面必须跟一个空行再进入业务处理,这样人类读起来舒服,IDE 格式化也不会反复改。这种事你只有自己写一次生成器才会有深刻体会,现成的生成工具永远不会根据你团队的 Code Style 去定制模板。
4.3 在团队中推广的落地流程
工具做出来不用就是浪费。我在团队内部推广 t3code 时定了一个非常轻的流程,大家接受度很高:
- 开发者在数据库里新建表,字段注释写清楚。
- 把表名填入
t3code.yaml,运行一个命令t3code -c t3code.yaml。 - 生成代码直接落到本地模块里,然后
mvn compile验证通过。 - 人工 review 的重点不再是“代码重复”,而是“业务逻辑有没有遗漏”。
这里我特别强调“注释写清楚”这一个点。因为数据库字段注释会被直接带进 Entity 和 VO 的 Javadoc,注释写得烂,生成出来的代码文档也烂。有些同事一开始不以为然,结果生成的注释全是“备注”“类型”这类废话,等于没写。后来我们约定:字段注释必须是一个能看懂业务含义的完整短句,比如“用户最近一次登录时间”,而不是“登录时间”。这个约定用了一段时间后,生成的代码文档质量明显提升,连接口文档的维护压力都小了很多。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
我整理了这段时间用 t3code 实际踩过的问题,做成一张速查表:
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| 生成文件里中文注释全是问号 | 模板文件或 JDBC 连接编码不对 | 模板强制 UTF-8,URL 后加?characterEncoding=utf8 |
| 自增主键插入后获取不到 ID | useGeneratedKeys没打开 | 在 Mapper XML 模板中加入useGeneratedKeys="true" keyProperty="id" |
表字段是create_time,Java 属性却叫createTime,SQL 无法识别 | 未进行驼峰到下划线的映射 | 在mybatis-config里开启mapUnderscoreToCamelCase,同时在模板中保证列名引用正确 |
| 多表生成的 Mapper 接口互相冲突 | 不同模块同名类,包名没有区分 | basePackage按模块拆开,或者表名前缀映射到子包 |
| 覆盖文件后手工优化代码丢失 | overwrite设置为 true | 开发期保持false,代码确定不需要调整后再开启覆盖 |
生成的 Controller 没有@CrossOrigin,前端联调报跨域 | 模板里没加跨域注解 | 可根据团队规范在模板中统一加入该注解,而不是每个接口手动加 |
BigDecimal字段返回 JSON 出现科学计数法 | 未配置 JsonSerialize | 在实体字段上增加@JsonSerialize(using = ToStringSerializer.class)或全局配置统一处理 |
以上问题里最常出现的是第一项,中文乱码,其次是第三项,字段映射。遇到问题别急着改模板,先打开生成的文件看一下实际输出,再顺着元数据模型排查,很容易定位是渲染问题还是数据读取问题。
5.2 三个最值得注意的工程化问题
第一,字段类型映射不能闭门造车。我最初的版本把TINYINT(1)一律映射成Boolean,后来有位做电商的同事提醒我,他们的订单状态字段用TINYINT存 0、1、2、3 四种状态,生成成Boolean直接输出灾难。所以我调整了默认映射,只在字段名包含flag、enabled这类明确布尔语义时才映射为Boolean。这个策略对于真实业务更友好。
第二,模板别写太死。最早我把 Controller 里的分页参数写成了pageNum和pageSize,结果有个项目前端用的是page和limit,改模板要改好几个文件。后来我统一从配置中心读取分页参数名,模板里只引用变量。这在做通用工具时非常重要:凡是你觉得“每个团队可能不一样”的东西,都应该做成可配置项,不要凭自己喜好硬编码。
第三,别让生成器承载过多的权限控制逻辑。有个很自然的想法是“在模板里根据用户角色生成不同的代码”,听起来很智能,实际上非常难维护。t3code 的定位就是生成基础 CRUD,权限这块我在 Service 层生成一个空壳方法checkPermission,里面抛出“TODO 请根据业务实现权限校验”。这种刻意留白反而比强行生成一套权限脚手架更适合真实项目。
6. 个人经验与扩展建议
做了这个项目之后,我对“代码生成”有了新的看法。工具不一定要做得多万能,也不一定要覆盖所有设计模式,它真正改变的是团队的交付节奏。以前接到一个新后台模块,花一上午把 CRUD 写完再调格式,现在一分钟生成代码,剩下的时间全部留给业务梳理、权限设计和接口文档,这才是这个工具真正的价值。
最后再分享一个小技巧:t3code 里我加了一个--dry-run参数,运行时不真正写文件,只打印每个模板将要生成的路径和文件大小。这个参数对排查模板错误特别有用,因为你不必等代码写到一半才报错,可以提前捕捉异常。后续我还在尝试的扩展方向是把数据库反向解析能力抽出成独立 SDK,这样不仅能在生成器里用,也能在写数据字典文档时直接读表结构。如果你也在为重复 CRUD 头疼,真心建议画一个下午把这个问题想清楚,自己动手写一套轻量生成器的收益,绝对比你想象的更大。