首页 / 资讯中心 / 文章详情

三层架构代码生成器t3code:用模板引擎批量产出标准CRUD代码

三层架构代码生成器t3code:用模板引擎批量产出标准CRUD代码 ★ FEATURED ARTICLE
接手一个新项目的数据库表光是用户、角色、菜单、日志这四个模块的增删改查从建表语句写到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 前缀就会自动带上/systemService 类名就会自动带上前缀这样多模块项目里不会出现类名冲突。2.2 命名规则与类型映射的“强制统一”代码风格最怕的是每个人各写各的。t3code 在生成过程中内置了一套刚性命名规则不允许讨论、不允许例外这样就堵死了风格分歧的来源。首先数据库字段到 Java 字段采用标准的下划线转驼峰。数据库里的user_name会被转成userNamecreated_at转成createdAt。这个转换规则不是可选项而是生成器的固定行为。其次类型映射表也是写死的数据库字段类型到 Java 类型的对应关系会在生成时自动匹配。数据库类型Java 类型说明bigintLong主键和长整型 ID 通用int / integerInteger状态、计数等普通整数tinyint(1)Boolean逻辑删除、开关类字段decimal(p,s)BigDecimal金额、费率等精度敏感字段datetimeLocalDateTime创建时间、更新时间dateLocalDate日期字段varchar / charString字符串统一映射text / longtextString长文本也映射成 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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); ListFileTemplate 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 的 BaseMapperMapper public interface SysUserMapper extends BaseMapperSysUser { }ServiceImpl 生成的代码长这个样子Service public class SysUserServiceImpl implements SysUserService { Resource private SysUserMapper sysUserMapper; Override public PageResultSysUser page(SysUserQuery query) { LambdaQueryWrapperSysUser wrapper new LambdaQueryWrapper(); // TODO 在此追加查询条件 PageSysUser page new Page(query.getPageNum(), query.getPageSize()); PageSysUser 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 -DmetaFilemeta/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模板从一个类开始慢慢沉淀成团队规范用久了你会发现它其实就是你们团队把“约定”固化成代码的过程。
阅读完成 · 觉得有帮助?
咨询建站