先说个让我印象很深的场景。有一次我帮同事 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 Codet3code 的意思是 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 的 BigDecimalvarchar 映射成 Stringtinyint 映射成 Integer。后来在真实代码里发现问题decimal 字段如果没指定 precision 和 scale生成的 BigDecimal 在运算时会遇到精度溢出的坑datetime 字段如果没做时区处理生成的前端接口定义和 Java 实体的语义会对不上。所以 t3code 的类型映射不是一个简单的数据库类型到语言类型的对照表而是带着参数和约束的映射。比如 decimal 要读取 precision 和 scaletinyint(1) 要判断是不是布尔语义bigint 在 JavaScript 和 TypeScript 侧要考虑是否超出 Number 安全整数范围。下面是一张我整理的 MySQL 到 Java / TypeScript 的常用类型映射表MySQL 类型Java 类型TypeScript 类型备注bigintLongnumber | string超过 2^53-1 时建议用 stringint / integerIntegernumbertinyint(1)Booleanboolean需要结合字段注释判断tinyint / smallintIntegernumber状态枚举场景常用decimal(p,s)BigDecimalstring金额精度要求高必须读取 precision 和 scalevarchar / charStringstringdatetime / timestampLocalDateTime / DatestringISO 8601注意时区约定dateLocalDatestringtext / longtextStringstringjson自定义类型或 Stringany / 具体 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 转成 createTimeuser_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.javaREST 接口约 120 行OrderMapper.java数据访问接口约 30 行OrderMapper.xmlSQL 映射约 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 最大的价值不是省了几千行代码而是把团队的接口风格重新拉回同一个基准线上。以前新同事入职要花两周才能适应的模块差异现在看一处代码就基本能看懂所有模块。如果你也在为重复代码烦恼不妨也试着做一个类似的工具从最小的闭环开始跑起来。