1. 先想清楚一个问题:普通的复制粘贴到底输在哪
代码生成器这个东西,市面上现成的一大把,但真正适合自己团队的,大概率还是要自己写。这篇开发指南就是我从零搭建、并在三个项目里迭代过一套生成器的完整复盘——里面没有理论堆砌,全是已经在生产环境里跑过的方案、模板和踩出来的坑。
先说我为什么开始碰这个东西。大多数项目起步阶段,CRUD 代码全靠复制粘贴:从旧模块复制一个 Controller,改改类名、换换字段,再复制一个 Service、一个 Mapper XML。前两个模块这么做没问题,等模块涨到二十来个的时候,隐患就开始冒头了。
最典型的一次事故:客户要求把某个业务字段从 String 改成 Long,我按全局搜索把涉及的文件逐个点开改,改了四十多分钟,以为自己全改完了,结果编译一跑,还有三处漏网之鱼,其中一处是 mapper XML 里的 parameterType。那一刻我意识到,复制粘贴维护的不是代码,是一堆定时炸弹。字段映射、异常处理风格、注释格式,每一个不一致的点都可能在后续迭代里咬你一口。与其继续手工复制,不如花几天时间,把"复制粘贴"这个动作本身固化成工具——这就是我写生成器的起点。
1.1 现成生成器为什么总差那口气
先别急着动手写,市面上的生成器确实不少:老牌的 MyBatis Generator、偏重全栈脚手架的 JHipster、各种配套的 renren-generator 之类。这些工具都用过一轮之后,我最大的感受是:它们解决的是"通用场景",而你实际遇到的是"带团队私有约定的场景"。
举几个具体差异你就明白了。
我们团队有一个统一的 BaseEntity,每个实体必须继承它,里面包含 createTime、updateTime、deleted 这些公共字段;所有接口返回必须走统一响应体R<T>,错误码有自己的枚举;ServiceImpl 里必须加一个自定义注解用于记录操作日志。这些约定,MyBatis Generator 不会替你生成,JHipster 生成的代码风格和我们团队完全不是一个路子。
通用工具也不是不能定制,很多支持自定义模板。但问题是:定制一次的成本不低,而且工具版本升级之后模板 API 可能要跟着改,第三方项目的维护节奏你控制不了。等它支持新特性,你的脚手架可能早就等不及了。
所以我的结论很直接:如果团队有稳定的代码规范、有足够的重复代码场景,自己写一个生成器,前期投入三五天,后续省下来的时间和一致性收益,是通用工具给不了的。
1.2 生成器真正解决的问题是"固化约定"
很多人把生成器理解成"自动复制粘贴",这个认知会误导架构设计。复制粘贴只是生成器最外层的功能,它真正的价值在于:把团队约定变成可执行、可校验、不会腐烂的代码。
我这么解释你就能体会了。假设团队规定"所有新增接口必须写 Swagger 注解、必须做参数校验、必须捕获 BizException"。手工开发时,这一步靠 code review 盯,靠新人培训灌输,总有人漏。但如果你把这些规则写进生成模板里,那么从生成器出来的每一份代码,天然带着注解、带着校验、带着异常处理。Review 的人只需要看业务逻辑,不需要检查那些重复性的格式问题。
还有一个被低估的价值:一致性。字段类型调整、统一加审计字段、全局改日志框架,这类跨模块的需求,在手工模式下意味着几十个文件的手术。有了生成器,很多时候只需要改一份元数据,重新生成一遍,再 review 一下 diff 就行。代码风格的一致性也上来了——生成器产出的代码,缩进、命名、注释风格统一,新人接手任意模块都不会有陌生感。
1.3 不是所有项目都适合上生成器
灌完这碗鸡汤,也得泼盆冷水。生成器不是银弹,我见过不合适的场景硬上生成器,最后维护生成器本身的时间比写业务代码还多。
什么情况不适合上?第一,业务模块之间差异性极大,每个模块的核心逻辑都不同,公共部分只有包名和类名这种程度,那生成器省不了多少事,反而因为多了一层元数据要维护,净成本是负的。第二,架构还在剧烈变动期。比如你连 Controller 层要不要统一返回R<T>都还没定下来,建议先别写生成器——生成器最怕的就是元数据稳定而模板不稳定,架构变一次,模板要跟着改一次,两三次下来你就烦了。
什么情况适合上?重复性 CRUD 多、模块结构相似、团队有一套已经跑稳的代码规范,至少满足前两条再加第三条。我写过的那套生成器,就是在一个后台管理系统里跑起来的,那类系统四十多个页面,接口长得几乎一模一样,简直是生成器的天然试验田。
2. 生成器的四层骨架:元数据、模板、引擎、输出
想清楚"为什么写"之后,来看"怎么写"。我的经验是,一个合格的代码生成器,无论是什么语言、什么实现方式,核心骨架都是四层:元数据模型、模板文件、生成引擎、输出策略。把这四层的职责边界划清楚,后面开发起来会非常顺,边界模糊则必然踩坑。
2.1 元数据模型:所有文件的源头
元数据就是"你要生成什么"的结构化描述。拿一张数据库表举例,元数据至少要包含:表名、类名、包名、表注释、字段列表。每个字段又包含:列名、属性名、Java 类型、JDBC 类型、注释、是否是主键、是否允许为空。
我习惯先在代码里把这些定义成纯数据结构,不掺杂任何生成逻辑。Java 项目里很简单:
public class TableMeta { private String tableName; // 数据库表名 private String className; // 生成的实体类名 private String packageName; // 包名 private String comment; // 表注释 private List<ColumnMeta> columns; // getter / setter 省略 } public class ColumnMeta { private String columnName; // 数据库列名 private String fieldName; // 实体属性名 private String javaType; // Java 类型 private String jdbcType; // JDBC 类型 private String comment; // 字段注释 private boolean primaryKey; // 是否主键 private boolean nullable; // 是否允许为空 }元数据本身怎么来,通常有三条路:手写 JSON 描述、读数据库表结构自动生成、用已有的实体类反射生成。我建议第一版先用手写 JSON,因为最直观、最容易调试。后面表多了再考虑数据库反读,这一点到进阶方向那节细说。
这里有一条重要原则:元数据是整个生成过程唯一的输入契约,所有模板都必须围绕它的结构来写。所以元数据的结构一旦定了,就要尽量保持稳定——你可以在字段上做加法(加新的属性),但不要轻易改字段的名字和含义,否则所有模板都要跟着动。
2.2 模板引擎怎么选
模板引擎是生成器的核心执行器,选型直接决定你后续写模板的幸福感。我把常见的选择列个表:
| 引擎 | 语言生态 | 特点 | 适合场景 |
|---|---|---|---|
| FreeMarker | Java | 成熟稳定,内置指令丰富,IDE 插件完善,错误信息带模板行号 | Java 后端团队首选 |
| Velocity | Java | 老牌,但迭代慢,语法略显陈旧 | 维护老项目时才会碰 |
| Jinja2 | Python | 语法简洁,模板继承能力好 | Python 工具链,或做独立 CLI 工具 |
| EJS / Nunjucks | Node.js | 与前端生态贴合 | 生成前端代码为主的场景 |
| 手写 StringBuilder | 任意 | 无模板概念,逻辑全在代码里 | 只生成长度极短的固定片段,不推荐用于复杂生成 |
我自己的选择是 Java + FreeMarker,原因很实际:后端团队本来就是 Java,生成器作为一个小工具直接并入工程的 tools 模块,大家维护起来没有额外语言负担。FreeMarker 的?cap_first、<#list>、<#if>这些内置能力,写代码模板时足够顺手,而且它的报错信息会指出模板的第几行出了问题,排障比某些引擎友好得多。
需要特别提醒的是:别想着自己拼字符串当模板。第一版我犯过这个错,用String.format拼一段实体代码,字段一多、逻辑一复杂,拼接代码比业务代码还难维护,转义、缩进、循环全部变成手写逻辑,bug 多到怀疑人生。模板引擎这种成熟组件,能用现成的就别重复造轮子。
2.3 生成引擎与输出策略
生成引擎本身不复杂,职责就四步:加载元数据、准备 FreeMarker 环境、遍历模板渲染、把结果写到目标路径。难的不是渲染,是输出这一步——文件写到哪、怎么处理已存在的文件。
输出目录结构我建议直接对标目标工程的包路径:
output/ └── com/example/demo/ ├── controller/SysUserController.java ├── service/SysUserService.java ├── mapper/SysUserMapper.java ├── entity/SysUserEntity.java └── resources/mapper/SysUserMapper.xml输出策略是新手最容易忽略的环节。我第一版生成器就是无脑覆盖,后来出了事故,才认认真真把策略分成四种:
- 覆盖(OVERWRITE):目标文件存在也直接写。适合纯生成文件,且你的元数据是唯一事实来源的场景。用了覆盖策略就必须配合 git,每次生成后看 diff 再提交。
- 跳过(SKIP):文件已存在就不动。适合"首次生成脚手架、后续手工演进"的场景,但生成的代码会慢慢和元数据脱节。
- 备份(BACKUP):覆盖前把旧文件复制一份带时间戳的 .bak。这是我会在风险文件上用的保险丝。
- 合并(MERGE):通过特殊标记保留文件中用户自定义的片段,其余部分重新生成。这个实现成本最高,通常只有 MyBatis Generator 那种复杂工具才需要,我至今没在自己项目里用上。
核心原则一句话:默认走覆盖加 git diff 审核,遇到不想被覆盖的文件单独标记为跳过。
3. 搭一个最小可用版本:Java + FreeMarker 实战
概念说再多,不如直接跑一个最小版本。这一节是照着我的第一版抄作业,工程结构、模板、主流程都给你,跑通了再往里面加你的团队约定。
3.1 定义元数据文件和模板文件
第一步,先建一个生成器自己的工程目录,我把它放在主工程的tools/code-generator下面,和业务代码隔离。Maven 里引入三个依赖就够了:FreeMarker、Jackson(读 JSON 元数据)、Lombok(可选,省 getter/setter 体力活)。
然后在src/main/resources/templates下面建模板文件夹,meta 信息先写成 JSON 文件,放在 generator 的配置目录里:
{ "tables": [ { "tableName": "sys_user", "className": "SysUser", "packageName": "com.example.demo", "comment": "系统用户表", "columns": [ { "columnName": "id", "fieldName": "id", "javaType": "Long", "comment": "主键", "primaryKey": true }, { "columnName": "user_name", "fieldName": "userName", "javaType": "String", "comment": "用户名" }, { "columnName": "created_at", "fieldName": "createdAt", "javaType": "LocalDateTime", "comment": "创建时间" } ] } ] }注意这里有个小小的约定:数据库下划线列名user_name,我在元数据里已经直接转成了驼峰属性名userName。这个转换也可以放在代码里做,但第一版我建议直接手写在 JSON 里,让元数据文件和模板都保持可读。
实体类模板长这样:
package ${meta.packageName}.entity; import java.time.LocalDateTime; /** * ${meta.comment} * 本文件由代码生成器生成,如需修改请先修改元数据定义后重新生成。 */ public class ${meta.className}Entity { <#list meta.columns as column> /** ${column.comment} */ private ${column.javaType} ${column.fieldName}; </#list> <#list meta.columns as column> public ${column.javaType} get${column.fieldName?cap_first}() { return this.${column.fieldName}; } public void set${column.fieldName?cap_first}(${column.javaType} ${column.fieldName}) { this.${column.fieldName} = ${column.fieldName}; } </#list> }模板里用得最多的就是<#list>循环和?cap_first首字母大写。第一眼看上去,模板的语法很啰嗦,但模板本身就是要承担"展开重复"的职责——你现在嫌它麻烦,等到要批量改三十个实体的时候就知道这么写有多值了。
3.2 写第一版生成主流程
生成主流程的核心代码就几十行:
public class CodeGenerator { private static final String TEMPLATE_DIR = "/templates"; private static final String OUTPUT_DIR = "output/"; public static void main(String[] args) throws Exception { // 1. 读取元数据 ObjectMapper mapper = new ObjectMapper(); MetadataModel model = mapper.readValue( new File("generator-config/metadata.json"), MetadataModel.class); // 2. 初始化 FreeMarker Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setClassLoaderForTemplateLoading( CodeGenerator.class.getClassLoader(), TEMPLATE_DIR); cfg.setDefaultEncoding("UTF-8"); cfg.setOutputEncoding("UTF-8"); cfg.setWhitespaceStripping(true); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); // 3. 逐表执行生成 for (TableMeta meta : model.getTables()) { generateEntity(cfg, meta); } System.out.println("生成完成,输出目录: " + OUTPUT_DIR); } private static void generateEntity(Configuration cfg, TableMeta meta) throws Exception { Template template = cfg.getTemplate("entity.ftl"); Path outputPath = Paths.get(OUTPUT_DIR, String.join("/", meta.getPackageName().split("\\.")), "entity", meta.getClassName() + "Entity.java"); Files.createDirectories(outputPath.getParent()); try (Writer writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) { template.process(meta, writer); } } }这段代码里有几个细节值得说明。一是setWhitespaceStripping(true),不配这个,模板里指令行留下的换行符会变成输出文件里的空行,Java 文件不至于编译失败,但很难看。二是Files.newBufferedWriter显式指定UTF_8,不指定的话在 Windows 上容易踩编码坑,这个到踩坑章节细说。三是输出路径用包名拆目录,保证生成出来的文件结构可以直接拷进目标工程。
跑完这个 main,你会在output/目录下拿到一个可以编译的实体类。到这一步,一个最小可用生成器已经诞生了——它的能力边界是"生成一个实体类",但骨架已经完全立起来了。
3.3 一个能用的生成器至少要有三种运行模式
第一版我只有上面的全量生成,用了一个月就觉得不够,补了两种模式,强烈建议你一开始就设计进去。
- 全量生成(full):上面这段 main 的默认行为,遍历所有表、生成所有文件、覆盖输出目录。
- 增量生成(incremental):增加一个
--only-new参数,目标文件已存在就跳过。适合给已上线的工程补新模块,不会干扰已有代码。 - 演练模式(dry-run):增加一个
--dry-run参数,只打印"将要生成哪些文件、写到哪个路径",不真正落盘。这个模式在调模板时极其好用,我后面专门讲模板调试时还会提到。
三种模式用一个简单的参数解析就能区分,成本很低,收益却是立竿见影的。没有 dry-run 的时候,我每次改模板都要跑到 output 目录里看文件,看完还要手动清理,多一步操作,效率就低一截。
4. 模板设计方法论:写模板比写业务代码更考验设计
生成器跑起来之后,你就进入了一个更磨人的阶段:写模板。我曾经天真地以为模板就是把业务代码抽几个变量出来,实际写了十几个模板之后才发现,模板设计的难度不亚于设计一个 API——因为它要在"通用"和"可读"之间找平衡。
4.1 模板分层:宏、片段、引用
复制粘贴时代,每个文件的文件头注释、import 块、类的公共注解都是重复的。到了模板世界,这些重复不会自己消失,只是从"代码里复制"变成"模板里复制"。如果每个模板各写各的,后期统一改一个公共注解,就得改十几个模板文件,那和手工改代码有什么区别?
所以模板也要分层。把公共片段抽出来,放在一个common.ftl里:
<#macro fileHeader comment> /** * ${comment} * 本文件由代码生成器生成,请勿手动修改;如需修改请编辑元数据后重新生成。 */ </#macro>然后在具体的模板里引用:
<#include "/common.ftl"> <@fileHeader comment="${meta.comment}"/>这样公共逻辑只有一份,后续要在所有生成文件的头部加个"生成时间"或者"生成器版本号",只需要改宏定义一处。我把这个实践叫作"模板的单一事实来源",和代码里 DRY 原则是一个道理。一个几十个模板的生成器项目,没有这一层抽象,维护成本会失控。
4.2 把复杂逻辑留在模型里,不要让模板背着
模板引擎虽然支持条件判断、循环、甚至自定义函数,但模板里塞的业务逻辑越复杂,排障越痛苦。FreeMarker 的报错信息比很多引擎好,但和 Java 的堆栈比起来还是不够直观,而且模板很难写单元测试。
我的原则是:模板只做三种事——取值、循环、简单判断。剩下的逻辑全部下沉到元数据模型的 Java 方法里。最典型的例子是 JDBC 类型到 Java 类型的映射。你当然可以在模板里写一个巨大的<#switch>,但更好的做法是在ColumnMeta里加一个方法:
public class ColumnMeta { // 其他字段省略 public String getJavaType() { return switch (this.jdbcType) { case "VARCHAR", "CHAR", "TEXT" -> "String"; case "BIGINT" -> "Long"; case "INT", "INTEGER" -> "Integer"; case "DATETIME", "TIMESTAMP" -> "LocalDateTime"; case "DECIMAL", "NUMERIC" -> "BigDecimal"; default -> "String"; }; } }模板那边只需要写${column.javaType},拿到一个已经算好的值。类型映射这种逻辑放进 Java 方法之后,可以被单元测试覆盖,可以在多个模板里复用,修改时只用改一处——而模板瘦身之后,也更容易阅读和维护。
还有一个好处是模板的"幂等性"更强。模型方法逻辑是确定的,同样的元数据永远算出同样的值,模板不会因为你忘了某个映射分支而悄悄生成一个错误的类型。
4.3 让生成的代码"可读且可维护"
生成器产出的代码最后是要进 code review 的。如果生成出来的代码风格怪异、命名晦涩,就算功能是对的,review 的人也难受,改的人也难受。
几个我总结出来的实操原则:
命名和团队手写代码完全一致。模板里类的命名、方法命名、变量命名,都要对标团队现有的规范,不要因为是从模板里出来的就随意。比如我们团队实体命名是XxxEntity,那模板就把这个后缀写死,哪怕元数据里没有存这个信息。
在文件头写清楚"我是谁"。注释里说明这是生成文件、由什么生成的,避免后来的人以为这是手写代码,直接在文件里改了之后被下次生成覆盖。结合作业区标记(比如"如需修改请先改元数据"),等于提前给后人排雷。
注释质量不低于手写代码。我见过一些生成器的模板,实体字段全没有注释,结果生成出来的代码除了字段名什么也看不懂。元数据里明明有 comment,模板里却没取——这属于白白浪费信息。字段注释、方法注释、类注释都是元数据的一部分,能带上的都带上。
保持结构简单。生成器不要试图生成复杂的继承链、泛型嵌套、设计模式。它擅长的是把重复结构批量产出,一旦模板里出现大量条件分支嵌套,说明你要么应该把逻辑下沉到模型里,要么这个场景根本不适合生成。
5. 踩坑实录:那些让我翻过车的真实问题
写生成器这一年半里,翻车的次数不算少。挑四个最有代表性的问题讲透,每一个都是我真实踩过、也真实排查出来的。
5.1 空白行与缩进:FreeMarker 的换行陷阱
第一次把生成出来的实体类打开,我愣住了:每个 getter/setter 之间空了三行,类的开头还有个多余的空行。代码能编译,但丑得没法交差。
原因其实很简单:FreeMarker 的指令标签(<#list>、<#if>、<#assign>这类)默认会自己独占一行,指令本身不输出内容,但它后面紧跟的那个换行符会被原样输出到文件里。所以指令用得越多,生成的代码空行越多。
解决办法有几个层面。首先打开cfg.setWhitespaceStripping(true),这个配置能去掉模板里那些"只有空白和指令"的行的输出,解决大部分问题。其次,模板写的时候把指令贴紧内容行,比如:
<#if column.primaryKey>@Id @GeneratedValue(strategy = GenerationType.IDENTITY) </#if>这样条件判断就不会在多行之间留下空行。最后一个兜底方案:生成完之后用统一的格式化工具(我们用的 google-java-format / Spotless)把文件过一遍,缩进和空行全部交给工具处理。我的最终实践是"模板负责内容正确、格式化工具负责姿势好看",两者配合,生成的代码看起来和手写的一模一样。
5.2 编码陷阱:中文注释为什么总是乱码
这个坑我印象太深了。有一版生成器在同事的 Windows 机器上跑,生成的实体类所有中文注释全部变成乱码,而在我的 Mac 上跑又是正常的。查了大半天,根因是 FreeMarker 的默认编码跟随系统平台,Windows 上默认 GBK,而模板文件是 UTF-8 存的,两边对不上,中文自然就花了。
解决方式分三层,缺一不可:
- 模板文件本身统一存成 UTF-8(无 BOM),IDE 里设置好,别用记事本改模板。
- FreeMarker 环境里显式配置:
cfg.setDefaultEncoding("UTF-8")和cfg.setOutputEncoding("UTF-8"),两个都要写。很多教程只写第一个,但setDefaultEncoding只管模板的读取,输出的Writer编码还得靠第二个。 - 写文件时用
Files.newBufferedWriter(path, StandardCharsets.UTF_8),明确指定字符集。
另外提醒一个隐蔽细节:Windows 下用 Notepad 保存文件会偷偷加 BOM 头,模板一旦带了 BOM,生成的代码第一行会出现一个不可见的乱码字符,编译的时候报一个莫名其妙的错。排查方法是把模板文件用十六进制编辑器看一眼开头有没有EF BB BF,有就去掉。
5.3 用户代码被覆盖的惨案
前面提到我用覆盖策略,结果在一个项目上出了事故。当时有个同事对生成的OrderServiceImpl做了大量手工修改,增加了一整套订单状态流转的逻辑。后来表结构加了一个字段,他重新跑了一遍生成器——所有手工修改全部被覆盖,git 里连历史都差点找不到。
那次之后我立了几条规矩:
第一条:生成文件必须带文件头标记。模板统一生成一行注释"本文件由代码生成器生成,请勿手动修改",凡是看到这行注释,就不建议直接在文件里手工改。这样做的潜台词是:你需要一个机制来区分"这个文件只属于生成器"和"这个文件生成完了归我管"。
第二条:可能存在手工代码的文件,改为跳过或备份策略。不是所有文件都要每次全量覆盖。ServiceImpl 这种大概率会被业务逻辑填满的文件,我直接改成--only-new模式下跳过,或者生成前自动把旧文件备份成带时间戳的副本。
第三条:生成完先看 git diff 再提交。这一步是和"覆盖策略"配套的保险丝。生成器本身应该稳定,元数据没变、模板没变的情况下,重复生成的结果应该一模一样;如果你发现 diff 里出现了不该出现的改动,就要停下来查原因,而不是稀里糊涂先提交。
5.4 模板调试的土办法
模板报错的信息往往比较抽象,尤其是数据类型不匹配、空值访问这类问题。我自己摸索出几个土但很管用的调试手段。
最常用的是"先渲染到内存再决定要不要落盘"。写一个调试入口,把模板渲染成一个字符串打印出来,而不是直接写文件。配合--dry-run模式,我可以在不产生任何文件的情况下,反复调整模板、立刻看到输出效果。
第二个手段是隔离问题文件。生成器一次生成四五个文件,某个文件出问题时,先写一个只渲染单个模板的测试方法,把元数据固定成一个最小样例,快速定位是这个模板的问题,还是元数据的问题。最小样例特别重要:一个表、两个字段,比在几十个表的真实元数据里排查快得多。
第三个手段是看 FreeMarker 报错里的模板行号。FreeMarker 的异常信息会指到.ftl文件的具体行,我先去那行检查——百分之七八十的问题都是null值访问,比如某个元数据对象没有某个字段,模板里却取了。这种情况我会在元数据模型里给字段加默认值,从源头上防住。
另外,Linux 下有cat -A可以查看文件里不可见的换行符和缩进,排查空行问题时屡试不爽。这个命令很多人不知道,但真的是排查模板输出问题的利器。
6. 进阶方向:从一个模块到一套脚手架
最小版本跑通、模板规范也建立起来之后,生成器的价值就从"省复制粘贴的力"升级成了"快速搭模块脚手架"。这节讲三个我实际推进过的方向,每一个都能明显提升效率,但也各有要注意的边界。
6.1 反向读取数据库结构,让元数据自动生成
手写元数据在小规模时很舒服,但表一多就变成新的体力活:五六十张表,每张表十几个字段,手写 JSON 能写到怀疑人生。这时候就该让程序从数据库里把元数据读出来。
我用的是 JDBC 直连,查询information_schema.COLUMNS视图:
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION;拿到列信息之后,在 Java 代码里完成两件事:下划线列名转驼峰属性名,JDBC 类型映射成 Java 类型——这两套逻辑前面都已经在模型里躺着了,直接复用。表注释、列注释都从数据库里取,连手填的功夫都省了。
这一步之后,生成器的流程就变成:
- 连上测试库,解析出所有表的元数据;
- 对元数据做微调(比如指定哪些表不生成、哪些字段不参与生成);
- 一键生成全部模块。
我在这条路上提醒一个注意点:数据库是动态的,表结构变了,元数据也要跟着变。所以"从数据库读元数据"这个功能,应当每次生成前都重新读一遍,而不是读一次存成文件以后就再也不更新——否则你会发现元数据悄悄和数据库脱节了。
6.2 生成后的格式化与编译校验
生成器最容易犯的毛病是"只求能编译,不求质量"。这句话反过来讲:如果连编译都没跑过,怎么敢说生成结果是可用的?
我的实践是给生成器接两个阀门。第一个是格式化阀门:生成完文件之后,调用 Spotless 或 google-java-format 把 Java 文件统一格式化一遍。这样模板即使写得稀烂,输出也能保持统一风格,模板维护的压力会小很多。第二个是编译阀门:在 CI 里加一个专门的 job,把生成的代码拷贝到一个干净的测试工程里,执行mvn compile,编译失败就直接让构建挂掉。
更进一步,我还在测试里加了一个"黄金文件"校验:用固定的元数据样例行生成一遍,把结果存成基准文件,每次改动模板之后跑一次对比,凡是和基准结果不一致的地方都要人工确认。这个机制保证了模板不会在无意中被改坏,改模板变得像改接口一样有回归保障。
6.3 把生成器本身当作产品来迭代
最后想聊聊心态。生成器不是写完就扔的脚本,它会陪伴你的项目很久,所以值得像产品一样维护。我给自己的几条迭代纪律:
元数据即代码。元数据 JSON 存进 git,和代码一样走分支、走 review。哪天库表结构变了、元数据没跟上,代码 review 时一眼就能看出来。
模板改动要过会。改一个公共模板意味着所有模块的代码风格变化,影响面比改个别模块大得多。我的做法是:模板改动必须同步更新示例输出,让 review 的人直观看到改动效果。
先统计再决定要不要加新模板。我的原则很简单:同一个类型的文件,三个月内手工写过三次以上,才值得为它加一个模板。生成器是在收敛团队的重复劳动,而不是展开控制面——模板数量太多、太杂,本身就会变成一种新的维护负担。
说到这,再分享最后一个使用习惯:我现在接一个新模块,第一件事不是新建文件,而是去补元数据。这个动作本身就像是在给模块画一张结构图——表结构、字段含义、包名归属,全部在 JSON 里确定下来之后再生成代码,开发流程好像突然变得顺了。生成器的本质,就是把你想清楚的东西,用最快、最一致的方式落成代码。只要抓住"元数据是源头、模板是手段、一致性是目标"这三句话,你的生成器就不会跑偏。