这个项目其实来的很偶然。有段时间我维护了好几个微服务,每个服务都有自己的一套配置文件和对应的实体类、DTO、Feign Client,代码结构高度雷同,但就是得一个文件一个文件去写、去改。后来我实在受不了这种重复劳动,决定用“JSON 配置文件 + TT 模板”搞一套自动生成代码的小工具,把那些套路化的代码直接从配置里“变”出来。这个思路听起来不复杂,但真正落地的时候,坑还挺多的,今天就把整个方案、模板写法和踩过的坑一次性讲清楚。
这套方案适合谁?如果你手头有大量结构相似的接口定义、实体类、配置项,或者是团队里要统一代码规范、减少手写出错率,那这个思路可以直接抄作业。它能做的事情包括:从一份 JSON 配置里批量生成 Java 实体类、MyBatis Mapper、OpenAPI 描述、前端 TypeScript 类型定义,甚至 Maven 的 pom.xml 片段。核心就一句话:把“数据”和“代码模板”分离,数据改一份,代码全同步。
1. 为什么想到用 JSON 配置 + 模板来生成代码
1.1 手工维护配置和代码的痛点
我先说个真实场景。之前做一个订单中台项目,订单域下有订单主表、订单明细、支付记录、退款记录、物流信息五张表。每张表都要写实体类、Mapper 接口、Mapper XML、Service 接口、ServiceImpl 实现类、Controller,一个表下来少说 6 个文件。五张表就是 30 个文件,而且这还只是订单域,后面又加了用户域、商品域、营销域。
写第一个表的时候还好,写到第三个就开始麻木了,到第五个的时候基本是复制粘贴改字段名。问题就出在复制粘贴上——字段类型容易抄错,注释忘了改,某个字段名在 Mapper XML 里拼错了 resultMap 的 column,编译不报错,跑起来才报错,排查成本极高。更麻烦的是,后来表结构加了一个字段,五个表的实体类、DTO、VO、Mapper 全都要跟着改一遍,漏一个就是线上事故。
这种痛的本质是:数据和表现形式的重复。表结构、字段含义、类型映射这些信息在数据库设计文档里已经有了,在实体类里写一遍,在 Mapper XML 里写一遍,在 DTO 里再写一遍,每次都是人肉同步。只要同步的过程中有一次疏忽,代码就和实际结构对不上。
1.2 为什么选 JSON 作为配置载体
当时我也犹豫过到底用 YAML 还是 JSON。YAML 可读性好,写注释方便,团队里不少人更熟。但最后选了 JSON,理由有三点。
JSON 本身就是一种树形结构的表达方式,和我们要描述的“类结构”、“表结构”天然对应。一个类有类名、有字段列表,字段有名字、类型、注释,这种嵌套关系用 JSON 表达非常直观。
JSON 的解析库太成熟了。Java 有 Jackson、Gson,Python 有内置的 json 模块,Node 更是直接 JSON.parse。无论生成器用什么语言写,读取 JSON 配置都是零成本的事,不需要引入额外的配置解析依赖。
JSON 可以和数据库表结构的元数据、接口文档工具做对接。很多数据库设计工具能直接导出 JSON 格式的表结构描述,后面完全可以做到“数据库表结构一变,自动更新 JSON 配置,再重新生成代码”,形成一个半自动化的链路。
当然,JSON 也有缺点,不能写注释是最烦人的。我的解决办法是约定一个 _comment 字段专门用来写说明,生成器读取的时候直接忽略这个字段。
1.3 TT 模板的核心思想
TT 模板在这里指的就是“文本模板”(Text Template),一种朴素的代码生成方式。它的核心思想特别简单:把代码里会变化的部分留成占位符,把不变的部分固化成模板。
举个例子,一个 Java 实体类的骨架是这样的:
java复制package com.example.entity;
import lombok.Data;
@Data
public class OrderEntity {
private Long id;
private String orderNo;
}
这里面 OrderEntity、id、orderNo、Long、String 是变化的,其他的都是固定结构。那我写一个模板,把这些变化的地方变成 占位符,然后用 JSON 配置里的数据去填充,就能生成任意多个类似的实体类。
TT 模板的优势在于:它不绑定特定的语言。你用 Java 写生成器就用 Java 模板引擎,用 Python 写生成器就用 Python 模板引擎,甚至用 Node 写也行。关键是“模板”本身和“数据”分开,谁都可以维护。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体方案设计与技术选型
2.1 生成流程总览
整个生成流程分四步,链路不长,但每一步都有讲究。
第一步,维护 JSON 配置。这一步是人工的,也是最需要规范的。配置里要定义清楚“要生成什么”、“生成到什么位置”、“用什么包名”、“有哪些字段”。
第二步,校验配置合法性。这一步很多人会忽略,直接跑到第三步,结果模板一渲染就报错,回头找半天发现是 JSON 里少了一个逗号。我建议在生成之前先做一次 schema 校验。
第三步,加载模板文件。模板文件放在单独的 templates 目录下,一个模板对应一类生成物,比如 entity.ftl 对应实体类,mapper.ftl 对应 Mapper 接口。
第四步,渲染并输出。模板引擎读入 JSON 数据,渲染模板,把结果写到目标目录。
贴一段我早期用 Java + FreeMarker 写的主流程代码,这个结构后来沿用到了 Python 版本里:
java复制public void generate(String configPath, String templateDir, String outputDir) {
ObjectMapper mapper = new ObjectMapper();
JsonNode config = mapper.readTree(new File(configPath));
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDirectoryForTemplateLoading(new File(templateDir));
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
Map<String, Object> data = new HashMap<>();
data.put("config", config);
data.put("gen", new GeneratorUtils()); // 注册自定义工具方法
File[] templates = new File(templateDir).listFiles((dir, name) -> name.endsWith(".ftl"));
for (File tpl : templates) {
Template template = cfg.getTemplate(tpl.getName());
String outputName = resolveOutputName(tpl.getName(), config);
try (Writer out = new FileWriter(new File(outputDir, outputName), StandardCharsets.UTF_8)) {
template.process(data, out);
}
}
}
这段代码的精髓是:直接扫模板目录,有多少模板就生成多少文件。新增一种生成物,只要放一个模板文件进去就行,生成器本身不用改。
2.2 方案选型背后的考量
为什么要把生成器和模板拆开?因为这两部分的变更频率完全不同。模板一旦稳定下来,基本不会动;而 JSON 配置会随着业务变化经常改。如果生成器和模板耦合在一起,每次改配置都得碰代码,风险就大了。
当时我考虑过直接用现成的代码生成器,比如 MyBatis Generator、OpenAPI Generator。但这类工具的问题是:它们解决的问题太固定了。MyBatis Generator 只能生成 MyBatis 那一套,OpenAPI Generator 只能基于 OpenAPI 文档。我要生成的是实体类 + Mapper + 前端类型定义 + 配置文件片段,跨了技术栈,没有现成工具能满足。
所以我决定自己写一个轻量的生成器,核心就两个文件:一个加载 JSON 配置,一个渲染模板。其他地方全部靠模板表达。
选择模板引擎的时候,我对比了三个:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| FreeMarker | 功能全,条件循环都支持,Java 生态成熟 | 语法稍重,嵌套模板麻烦 | Java 项目,生成 Java 代码 |
| Python string.Template | 极简,零依赖 | 只支持变量替换,不支持循环和条件 | 简单的配置展开 |
| Jinja2 | 语法舒服,功能和 FreeMarker 相当 | 要引入 Python 依赖 | Python 写生成器,通用性强 |
我最终选了 Java + FreeMarker,因为当时项目整体是 Java 技术栈,生成器可以直接放进 Maven 工程的 tools 模块里,和主工程共享依赖。如果你是新起一个项目,我更推荐 Python + Jinja2,写起来更快,迭代也方便。
2.3 输出文件的命名策略
一个很容易忽略但特别影响体验的细节:输出文件的命名。一开始我把文件名写死在模板里,比如 entity.ftl 固定生成 Entity.java,后来 JSON 配置里有很多个类要生成,这就尴尬了,一个模板只能生成一个文件。
解决思路是:模板文件名不要直接对应输出文件名,而是约定一个规则。比如模板文件名是 entity.ftl,输出文件名的类名部分从 JSON 配置里取。我的做法是在 JSON 配置的根节点放一个 className 字段,生成器读取之后拼成目标文件名。
java复制private String resolveOutputName(String templateName, JsonNode config) {
String baseName = templateName.replace(".ftl", "");
String className = config.path("className").asText("Generated");
switch (baseName) {
case "entity":
return className + "Entity.java";
case "mapper":
return className + "Mapper.java";
case "service":
return className + "Service.java";
case "controller":
return className + "Controller.java";
default:
return baseName + ".java";
}
}
这个规则很土,但很直观。每个模板一眼就能看出来它会生成什么文件。
3. JSON 配置文件的编写规范
3.1 基础字段定义
JSON 配置是整个方案的“数据源”,它的结构设计直接决定模板写起来顺不顺手。结构设计得不好,模板里全是嵌套的 config.fields[0].name,看得人头大。设计得好,模板可以写得像自然语言一样清晰。
一个标准的配置长这样:
json复制{
"_comment": "订单实体配置",
"className": "Order",
"package": "com.example.order.entity",
"author": "zhangsan",
"tableName": "t_order",
"fields": [
{
"fieldName": "id",
"fieldType": "Long",
"columnName": "id",
"comment": "主键",
"primary": true,
"nullable": false
},
{
"fieldName": "orderNo",
"fieldType": "String",
"columnName": "order_no",
"comment": "订单编号",
"nullable": false,
"maxLength": 64
},
{
"fieldName": "amount",
"fieldType": "BigDecimal",
"columnName": "amount",
"comment": "订单金额",
"nullable": true
},
{
"fieldName": "createdAt",
"fieldType": "LocalDateTime",
"columnName": "created_at",
"comment": "创建时间"
}
]
}
注意几个细节:className 用驼峰命名,方便直接拼类名;fieldName 是 Java 字段名,columnName 是数据库列名,两个分开,因为下划线转驼峰这种事不应该靠生成器猜,而是配置里明确写清楚。primary、nullable 这种布尔字段是给模板做条件判断用的,比如主键字段生成 @TableId 注解,非空字段生成 @NotNull 校验注解。
3.2 嵌套结构与数组的处理
真实业务里不可能全是扁平结构。订单有明细,用户有角色,这些一对多关系怎么在 JSON 里表达?我的做法是支持“配置内嵌套定义”,一个字段的类型可以指向另一个配置块。
json复制{
"className": "Order",
"fields": [
{
"fieldName": "orderItems",
"fieldType": "List<OrderItem>",
"comment": "订单明细",
"nested": {
"className": "OrderItem",
"fields": [
{
"fieldName": "skuId",
"fieldType": "Long",
"comment": "商品SKU ID"
},
{
"fieldName": "quantity",
"fieldType": "Integer",
"comment": "购买数量"
}
]
}
}
]
}
这种写法的好处是:一个配置文件就能描述一整个对象图。生成器在渲染主类的时候,如果发现某个字段有 nested 属性,就递归地再渲染一个嵌套类文件,输出目录里自动多出一个 OrderItem.java。
递归嵌套的问题是模板要反复调用自己。FreeMarker 里可以用 <#macro> 递归宏,也可以在生成器里写递归逻辑。我选择在生成器里做递归,因为模板只负责单层渲染,递归的逻辑放在 Java 代码里,出错更好排查。
3.3 配置合法性校验
不校验配置就生成代码,等于拿生产环境开玩笑。我有一次把 fieldType 漏写了,模板渲染的时候 fieldType 是空字符串,生成的 Java 代码直接是 private name;,编译一把过不了,还得回头查配置。
后来我写了一个简单的校验方法,在加载配置之后、渲染模板之前执行:
java复制public void validate(JsonNode config) {
if (!config.has("className") || config.get("className").asText().isEmpty()) {
throw new IllegalArgumentException("配置缺少 className");
}
if (!config.has("package") || config.get("package").asText().isEmpty()) {
throw new IllegalArgumentException("配置缺少 package");
}
JsonNode fields = config.path("fields");
if (!fields.isArray() || fields.size() == 0) {
throw new IllegalArgumentException("配置缺少 fields 数组");
}
for (JsonNode field : fields) {
if (!field.has("fieldName") || !field.has("fieldType")) {
throw new IllegalArgumentException("字段配置缺少 fieldName 或 fieldType");
}
}
}
这个只是最基础的校验。更严格的方案是定义 JSON Schema,用现成的库去校验,比如 Java 的 everit-org/json-schema。不过对小团队来说,手写几个 if 判断就够了,注意把错误信息写清楚,最好精确到哪个字段出了问题。
4. TT 模板的编写与语法实践
4.1 占位符与变量替换
模板的核心就是占位符替换。FreeMarker 的语法是 ${} 包裹变量名,JSON 配置的数据解析成 Map 之后直接注入模板上下文。
一个最简单的实体类模板开头是这样的:
ftl复制package ${config.package};
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* ${config._comment}
* 表名:${config.tableName}
* 生成时间:${gen.now()}
*/
@Data
public class ${config.className}Entity {
<#list config.fields as field>
/**
* ${field.comment}
*/
private ${field.fieldType} ${field.fieldName};
</#list>
}
这里有两个技巧值得注意。
第一个是 ${gen.now()}。我在生成器的 data 模型里注册了一个 GeneratorUtils 工具类,里面放了 now() 方法用来输出当前时间。模板里可以调用这个工具类的任意静态方法,等于给模板开了一个“后门”,很多生成时需要的小功能都可以往这个工具类里放,比如下划线转驼峰、首字母大写。
第二个是 <#list> 循环。config.fields 在 JSON 里是一个数组,解析成 Java 对象后是 List,FreeMarker 的 <#list> 指令可以直接遍历它,循环体里用 field.xxx 访问数组元素的属性。
4.2 循环与条件判断
只做简单的变量替换,代码生成器就失去了意义。真正的威力在于循环和条件判断的组合使用。
举一个 MyBatis Mapper XML 的例子。我要根据字段配置生成 insert 语句,但主键字段是不能插入的,所以需要循环里加条件:
ftl复制<insert id="insert" parameterType="${config.package}.${config.className}Entity">
INSERT INTO ${config.tableName}
<trim prefix="(" suffix=")" suffixOverrides=",">
<#list config.fields as field>
<#if !field.primary>
<if test="${field.fieldName} != null">
${field.columnName},
</if>
</#if>
</#list>
</trim>
<trim prefix="VALUES (" suffix=")" suffixOverrides=",">
<#list config.fields as field>
<#if !field.primary>
<if test="${field.fieldName} != null">
#{${field.fieldName}},
</if>
</#if>
</#list>
</trim>
</insert>
这段模板的亮点在于:MyBatis 的动态 SQL 里的 <if> 标签和 FreeMarker 的 <#if> 指令在一个文件里共存,两者语法不冲突。文件后缀是 .xml,FreeMarker 不会在意输出格式,它只负责渲染模板,所以你可以输出任何格式的文本。
每次写这段循环的时候都要注意变量作用域。FreeMarker 的 <#list> 里,循环变量只在这个循环内有效,出了循环就没了。所以第二次循环需要重新 <#list config.fields as field>,不能指望第一个循环里的 field 变量还能继续用。
4.3 模板拆分与公共片段复用
当一个模板文件超过 200 行,维护起来就很痛苦了。我的经验是:按生成物的层次拆模板,而不是按功能拆。
什么意思呢?比如生成一个 Service 接口文件,它的结构是:包名声明 + import 语句 + 接口声明 + 方法列表。其中“包名声明 + import + 接口声明”这部分几乎是所有 Java 接口文件通用的,可以抽出来作为一个公共片段。
FreeMarker 提供了 <#include> 指令来引入公共片段。我维护了一个 common/header.ftl,里面放公共的文件头:
ftl复制package ${config.package};
import java.util.List;
import java.util.Map;
<#if config.needPageImport?? && config.needPageImport>
import com.baomidou.mybatisplus.core.metadata.IPage;
</#if>
然后在具体的 Service 模板里引入:
ftl复制<#include "common/header.ftl">
public interface ${config.className}Service {
List<${config.className}Entity> list();
void insert(${config.className}Entity entity);
}
拆模板的原则是:同一个公共片段被三个以上模板引用才值得拆出来。如果只有一个模板用,拆出来反而增加了跳转成本。我一开始拆得太细,一个实体模板拆成了 header、imports、fields、methods 四个片段,结果写的时候要在五个文件之间来回跳,效率反而低了。
还有 <#macro> 宏,适合定义“可复用的生成单元”。我在生成字段校验注解的时候用了一个宏:
ftl复制<#macro fieldAnnotation field>
<#if field.nullable?? && !field.nullable>
@NotNull(message = "${field.fieldName}不能为空")
</#if>
<#if field.maxLength??>
@Size(max = ${field.maxLength}, message = "${field.fieldName}长度不能超过${field.maxLength}")
</#if>
</#macro>
使用的时候 <@fieldAnnotation field=field />,这段注解逻辑可以在实体类模板、DTO 模板、VO 模板里共用,保证三处的校验规则完全一致。
5. 核心生成代码的落地实现
5.1 主生成器的完整实现
前面给过主流程的骨架代码,这里补全成可以直接跑的版本。我用的是 Maven 工程结构,生成器放在 tools/code-generator 模块下,依赖只有 Jackson 和 FreeMarker。
xml复制<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
</dependencies>
主生成器代码完整版:
java复制package com.example.generator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateExceptionHandler;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
public class CodeGenerator {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path configPath;
private final Path templateDir;
private final Path outputDir;
public CodeGenerator(String configPath, String templateDir, String outputDir) {
this.configPath = Paths.get(configPath);
this.templateDir = Paths.get(templateDir);
this.outputDir = Paths.get(outputDir);
}
public void run() throws Exception {
JsonNode config = MAPPER.readTree(Files.readAllBytes(configPath));
validate(config);
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDirectoryForTemplateLoading(templateDir.toFile());
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
Map<String, Object> dataModel = new HashMap<>();
dataModel.put("config", config);
dataModel.put("gen", new GeneratorUtils());
Files.createDirectories(outputDir);
try (DirectoryStream<Path> stream = Files.newDirectoryStream(templateDir, "*.ftl")) {
for (Path templatePath : stream) {
Template template = cfg.getTemplate(templatePath.getFileName().toString());
String outputName = resolveOutputName(templatePath.getFileName().toString(), config);
try (Writer out = new OutputStreamWriter(
Files.newOutputStream(outputDir.resolve(outputName)), StandardCharsets.UTF_8)) {
template.process(dataModel, out);
}
System.out.println("生成文件: " + outputName);
}
}
// 处理嵌套类
generateNestedClasses(config, cfg, dataModel);
}
private void generateNestedClasses(JsonNode config, Configuration cfg, Map<String, Object> dataModel) {
JsonNode fields = config.path("fields");
for (JsonNode field : fields) {
if (field.has("nested")) {
JsonNode nested = field.get("nested");
dataModel.put("config", nested);
try {
Template template = cfg.getTemplate("entity.ftl");
String className = nested.path("className").asText();
try (Writer out = new OutputStreamWriter(
Files.newOutputStream(outputDir.resolve(className + "Entity.java")), StandardCharsets.UTF_8)) {
template.process(dataModel, out);
}
System.out.println("生成嵌套类: " + className + "Entity.java");
} catch (Exception e) {
throw new RuntimeException("生成嵌套类失败: " + className, e);
}
}
}
}
private void validate(JsonNode config) {
// 省略,见 3.3 节
}
private String resolveOutputName(String templateName, JsonNode config) {
// 省略,见 2.3 节
}
public static class GeneratorUtils {
public String now() {
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
public String upperFirst(String str) {
if (str == null || str.isEmpty()) return str;
return Character.toUpperCase(str.charAt(0)) + str.substring(1);
}
public String lowerFirst(String str) {
if (str == null || str.isEmpty()) return str;
return Character.toLowerCase(str.charAt(0)) + str.substring(1);
}
}
}
有几个细节值得展开。
generateNestedClasses 这个方法是我后来加的。一开始嵌套类直接放在父类的模板里用 <#list> 生成,但输出文件只能有一个,嵌套类的内容会串到父类文件里。改成递归生成之后,每个类输出到一个独立文件,干净多了。
dataModel.put("config", config) 之后,模板里所有访问都从 ${config.xxx} 开始。但是生成嵌套类的时候,我用 dataModel.put("config", nested) 把配置换成了嵌套类的配置,这样同一个 entity.ftl 模板不需要任何修改,直接渲染嵌套类,非常方便。
5.2 从 JSON 到 Java 实体类的生成示例
用前面订单的配置跑一下,entity.ftl 模板的完整内容如下:
ftl复制package ${config.package};
<#list config.fields as field>
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import javax.validation.constraints.*;
</#list>
/**
* ${config._comment}
* 表名:${config.tableName}
* 生成时间:${gen.now()}
* 作者:${config.author}
*/
@Data
public class ${config.className}Entity {
<#list config.fields as field>
<#if field.primary>
/** ${field.comment} */
private ${field.fieldType} ${field.fieldName};
<#else>
<@annotation field=field />
/** ${field.comment} */
private ${field.fieldType} ${field.fieldName};
</#if>
<#if field_has_next>
</#if>
</#list>
<#macro annotation field>
<#if field.nullable?? && !field.nullable>
@NotNull(message = "${field.fieldName}不能为空")
</#if>
<#if field.maxLength??>
@Size(max = ${field.maxLength}, message = "${field.fieldName}长度不能超过${field.maxLength}")
</#if>
</#macro>
}
生成的代码长这样:
java复制package com.example.order.entity;
import lombok.Data;
/**
* 订单实体配置
* 表名:t_order
* 生成时间:2025-01-15 14:30:22
* 作者:zhangsan
*/
@Data
public class OrderEntity {
/** 主键 */
private Long id;
@NotNull(message = "orderNo不能为空")
@Size(max = 64, message = "orderNo长度不能超过64")
/** 订单编号 */
private String orderNo;
/** 订单金额 */
private BigDecimal amount;
/** 创建时间 */
private LocalDateTime createdAt;
}
注意一个细节:import 部分我没有做类型推导,直接把所有常用的类型都 import 了,生成的代码会有未使用的 import。Java 编译器不会因为未使用的 import 报错,只是 IDE 可能会标黄线。对生成代码来说,这个可以接受,因为代码本来就是要交给 IDE 再格式化的。
5.3 从 JSON 到 Maven 配置的生成示例
生成 Java 代码只是 TT 模板的一部分能力。同样的 JSON 配置还能生成 pom.xml 片段、application.yml、docker-compose.yaml,只要你想,任何文本格式都能生成。
比如我维护的订单服务,每次创建一个新的微服务模块,需要往根 pom.xml 里加一段 module 声明和依赖管理。手工操作很容易忘记加,或者版本号写错。用模板生成就稳了:
ftl复制<!-- ${config.className} 服务模块 -->
<module>${config.moduleName}</module>
依赖管理的版本号从 JSON 配置里读取:
json复制{
"dependencies": [
{"groupId": "org.springframework.boot", "artifactId": "spring-boot-starter-web", "version": "2.7.18"},
{"groupId": "com.baomidou", "artifactId": "mybatis-plus-boot-starter", "version": "3.5.5"},
{"groupId": "mysql", "artifactId": "mysql-connector-java", "version": "8.0.33"}
]
}
模板渲染:
ftl复制<#list config.dependencies as dep>
<dependency>
<groupId>${dep.groupId}</groupId>
<artifactId>${dep.artifactId}</artifactId>
<version>${dep.version}</version>
</dependency>
</#list>
这个场景里 JSON 配置的作用就体现出来了:团队里维护一份“标准依赖清单”,所有服务模块的 pom 都从这一份配置生成,版本号统一管理,再也不用担心各模块依赖版本漂移的问题。
5.4 集成到 Maven/Gradle 构建流程
生成器做出来了,怎么用?总不能每次生成都要手动跑一遍 main 方法吧。两个思路:一个是把生成器做成命令行工具,写一个 shell 脚本;另一个是接入 Maven 的 exec-maven-plugin,通过 mvn generate-sources 触发。
我用的是 Maven 插件方式。在生成器模块的 pom.xml 里配置:
xml复制<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>java</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>com.example.generator.CodeGenerator</mainClass>
<arguments>
<argument>${basedir}/config/order.json</argument>
<argument>${basedir}/templates</argument>
<argument>${basedir}/generated-sources/java</argument>
</arguments>
</configuration>
</plugin>
</plugins>
</build>
然后把 generated-sources/java 配入 Maven 的编译源码目录。这样执行 mvn clean generate-sources 就会先跑生成器,再编译主代码。整个链路是:改 JSON 配置 → 跑 mvn generate-sources → 新代码自动编译进 target。
这里有个重要的坑要提醒:生成的代码不应该提交到 Git 仓库。既然有了生成器,生成的代码就是“编译产物”,和 target 目录一样应该被忽略。我一开始把生成代码提交了,结果每个人改了配置重新生成之后,diff 一堆冲突,非常痛苦。后来在 .gitignore 里把 generated-sources 加了进去,整个世界清净了。
6. 常见问题与排查技巧实录
6.1 转义符导致的输出错乱
TT 模板最容易踩的坑就是转义。尤其是生成 Java 代码时,模板里的字符串字面量、注解值经常含特殊字符。
最典型的是生成一个包含正则表达式的代码,比如 @Pattern(regexp = "^[a-zA-Z0-9_]+$"),模板文件里的写法是:
ftl复制@Pattern(regexp = "^[a-zA-Z0-9_]+$")
这个问题我花了一晚上才解决。FreeMarker 模板本身不解析这些特殊字符,理论上应该原样输出,但实际生成的代码里正则表达式被截断了一部分。后来排查发现是模板文件编码的问题,模板文件被 IDE 保存成了 GBK,而生成器读取模板用的是 UTF-8,中文字符和特殊字符全乱套了。
解决办法:统一所有模板文件和配置文件的编码为 UTF-8。这个看起来是小事,但在 Windows 环境下特别容易踩。我在模板目录里放了一个 encoding.md 的说明文件,提醒团队所有模板必须存成 UTF-8 without BOM。
6.2 数组循环索引丢失
生成集合类字段时,有时需要下标,比如生成 field0、field1。FreeMarker 的 <#list> 提供了内建变量 field_index,可以直接拿到当前下标:
ftl复制<#list config.fields as field>
${field_index}: ${field.fieldName}
</#list>
但如果循环里嵌了另一个循环,内层循环的 field_index 会覆盖外层。要保留外层下标,需要在进入内层循环前把值缓存下来:
ftl复制<#list config.fields as field>
<#assign outerIndex = field_index>
<#list field.validators as validator>
${outerIndex}.${validator_index}: ${validator.name}
</#list>
</#list>
这个 outerIndex 一定要在进入内层循环之前 <#assign>,否则到内层 field_index 就变成内层的下标了。
6.3 空值和默认值处理
JSON 配置里有些字段是可选的,比如 author。如果不提供这个字段,模板里直接访问 ${config.author} 会报错。FreeMarker 对不存在的变量默认是抛异常的,这一点和很多模板引擎不一样。
处理方式有三种。第一种是模板里用 ?? 判断:
ftl复制<#if config.author??>
作者:${config.author}
</#if>
第二种是生成器在构造 dataModel 时填充默认值:
java复制dataModel.put("config", mergeDefaults(config));
第三种最简单,JSON 配置文件本身就把默认值写全,比如:
json复制{
"author": "default_author",
"version": "1.0.0"
}
我的建议是:模板里写守卫判断,配置里给默认值,两者结合。模板只对真正需要条件的字段做判断,其他字段默认配置必须给全,减少模板里的分支逻辑,模板读起来更清爽。
6.4 模板中特殊字符和编码问题
生成 YAML 文件时,缩进特别敏感。模板里如果手写缩进,很容易多一个少一个空格。FreeMarker 的 <#list> 指令本身会输出空白行,可以用 <#t> 去掉行尾空白,<#noparse> 标记不需要解析的部分。
YAML 模板的典型写法:
ftl复制server:
port: ${config.serverPort}
<#list config.datasource as ds>
${ds.alias}:
url: ${ds.url}
username: ${ds.username}
password: ${ds.password}
</#list>
问题在于 FreeMarker 在渲染后会保留模板里的空白行,导致 YAML 解析失败。解决办法是在 <#list> 起始标签前加 <#t>,并尽量把循环体的缩进用变量控制。
遇到最诡异的一个问题:生成出来的 YAML 文件用 IntelliJ 打开是正常的,但 docker-compose 解析时报错,说缩进不对。发现是模板文件里用了 Tab 键,而 YAML 是不允许用 Tab 做缩进的。从那以后,我所有模板文件都强制在 IDE 里开启“把 Tab 替换为空格”,并且关闭了“在保存时保留尾随空白”的选项。
6.5 递归嵌套结构的生成策略
前面提到了 nested 字段支持嵌套类。如果嵌套的层级比较深,比如订单里嵌套了明细,明细里又嵌套了子明细,递归生成就需要注意循环引用的问题。
一个常见错误:配置里 A 的嵌套引用了 B,B 的嵌套又引用了 A,生成器进入死循环,递归栈溢出。我后来加了一个访问深度限制:
java复制private static final int MAX_NEST_DEPTH = 5;
private void generateNestedClasses(JsonNode config, Configuration cfg, Map<String, Object> dataModel, int depth) {
if (depth > MAX_NEST_DEPTH) {
throw new RuntimeException("嵌套层级超过上限: " + MAX_NEST_DEPTH);
}
// 省略递归逻辑
}
还有一个更隐蔽的问题:同一个嵌套类可能被多个父类引用。比如 Address 类被 Order 和 User 同时引用,如果两个父类配置里都嵌套定义了 Address,生成器会生成两次,第二次覆盖第一次。解决办法是在生成器里维护一个“已生成类名”的集合,遇到重复就跳过。
java复制private final Set<String> generatedClassNames = new HashSet<>();
private void generateNestedClasses(JsonNode config, Configuration cfg, Map<String, Object> dataModel, int depth) {
JsonNode fields = config.path("fields");
for (JsonNode field : fields) {
if (!field.has("nested")) continue;
JsonNode nested = field.get("nested");
String className = nested.path("className").asText();
if (generatedClassNames.contains(className)) {
continue;
}
generatedClassNames.add(className);
// 渲染逻辑...
generateNestedClasses(nested, cfg, dataModel, depth + 1);
}
}
6.6 生成结果的验证机制
生成器输出之后不能直接信,一定要有验证环节。我的做法是:生成结束后自动执行一次编译,编译不通过说明模板或者配置有问题,直接报错退出。
在 Maven 里天然有这个机制,因为生成器挂在 generate-sources 阶段,生成完了后面就是 compile 阶段,如果生成的代码有语法错误,编译器会直接报错。但用 IDE 单独跑生成器的时候没有这个保护,我会在生成器的 run() 方法最后加一段输出统计:
java复制System.out.println("生成完成!共生成 " + files.size() + " 个文件");
然后手动去 target/generated-sources 目录检查。另外,我建议写一个简单的“生成结果快照对比”测试:把某次成功的生成结果保存一份在 test/resources 里,每次生成后做 diff,任何非预期的变化都能在测试里暴露出来。这个成本不高,但对防止模板被误改特别有效。
7. 实测经验与后续扩展方向
用这套方案跑了两个多月,最直接的体感是:新增一个表的代码开发时间从半天压缩到十分钟。十分钟里有八分钟是在写 JSON 配置,剩下两分钟是跑生成器加人工 review。
人工 review 不能省。生成器再聪明,它也只能生成“模式化”的代码,真正的业务逻辑、复杂关联、定制化查询,还得人写。我的建议是把生成器的定位定在“消除 80% 的重复劳动”上,剩下的 20% 交给开发者。
后续扩展方向有三个我觉得价值很大。
第一个是反向同步。既然能从 JSON 生成代码,能不能从数据库表结构反向生成 JSON 配置?数据库的 information_schema 里存着所有表的字段、类型、注释,写个脚本读出来映射成 JSON,就完成了“数据库表结构 → JSON 配置”的自动化。这样改表结构的时候,只要重跑一次反向同步脚本,再跑一次生成器,整个代码就自动更新了。
第二个是脚手架化。现在这套生成器是代码库里的一个模块,新成员入职要自己配环境。更好的做法是把它做成一个命令行脚手架工具,类似 Spring Initializr,输入几个参数就能生成一个新的微服务模块,里面包含完整的配置、实体类、Controller、Service 一套代码。我最近正在研究用 GraalVM 把生成器打包成原生可执行文件,这样不依赖 JVM 环境,分发成本更低。
第三个是多语言扩展。目前的模板只生成了 Java 和 XML。同一份 JSON 配置,理论上可以同时生成 Java 后端代码、TypeScript 前端类型定义、OpenAPI 文档、数据库建表语句。这样前后端联调的时候,两边的类型定义永远一致,不会出现后端返回的字段名和前端 TypeScript 类型对不上的情况。我已经在写 TypeScript 的模板了,原理完全一样,interface Field { fieldType: string; } 这种格式用 FreeMarker 渲染没有任何障碍。
最后再分享一个小技巧:模板文件一定要搞一套独立的测试。特别是 JSON 配置结构调整过之后,老模板可能没法解析新配置。我在 CI 里加了一个步骤,用一组固定的 JSON 配置跑一遍生成器,然后把生成结果和基准快照做 diff。任何一次模板改动导致了非预期的输出变动,CI 都能第一时间发现。这比任何 review 都管用,因为它校验的是“实际输出”,而不是“代码看起来对不对”。
这套方案说到底,就是用“数据驱动”的思维把重复劳动自动化。JSON 是数据,TT 模板是规则,生成器是执行引擎。三者分开,各司其职,代码生成这件事就变得可控、可维护、可扩展了。如果你也在被大量重复代码困扰,不妨从手头最痛的那一块开始,先写一个模板解决一个问题,跑通之后再加复杂度。
