SELECTED PROJECTS

templatecenter:Spring Boot 与 FreeMarker 代码生成器

将单表元数据渲染为后端、前端和数据库脚本,记录模板管理与打包输出设计。

阅读设置

我用 Spring Boot + FreeMarker 写了一个代码生成器,给大学生课设用


为什么会有这回事

大二下学期课设季,朋友圈里全是求救消息。

“有没有人写过学生管理系统?求源码!” “哥们,我选题订的酒店管理系统,Spring Boot 到底怎么配啊?” “Vue 的路由到底怎么跳转?救救我。”

每年课设季都这样。同一批学生管理系统、酒店管理系统、图书馆管理系统,前人的代码在各种 QQ 群里传来传去,质量参差不齐,能跑就谢天谢地了。

我当时刚学完 Spring Boot,FreeMarker 也看了一点,就想着:能不能写个东西,把这些重复的 CRUD 代码自动生成出来?不用再让学生们复制粘贴别人的项目了。

于是 templatecenter 就这么来了。


技术选型

后端用 Spring Boot 3.1,Java 17,MyBatis-Plus 做 ORM。前端生成 Vue 3 + Vite + Element Plus 的代码。数据库脚本直接生成 MySQL 的 DDL。

核心思路很简单:用 Apache FreeMarker 的模板引擎,把一套写好的 .ftl 模板填上数据,输出用户能直接跑的完整项目。

为什么选 FreeMarker 而不用 Velocity 或者 Thymeleaf?说实话,FreeMarker 的模板语法最接近 Java 本身,写起来直觉,不需要额外学一套标签。而且它不依赖 Servlet 环境,纯 Java 就能跑,在代码生成这种场景下很方便。


整体架构

项目本身是个 Spring Boot Web 应用,分层组织方式(仅目录结构不能证明采用 DDD),大概长这样:

templatecenter/
├── src/main/java/com/templatecenter/
│   ├── controller/      # GeneratorController 接收前端请求
│   ├── service/         # GeneratorService 核心生成逻辑
│   ├── domain/          # DDD 领域模型
│   ├── infrastructure/  # 基础设施层,ZipUtils 在这一层
│   └── dto/             # 前后端交互的数据对象
├── src/main/resources/
│   ├── templates/       # FreeMarker 模板,.ftl 文件
│   └── application.yml
└── pom.xml

用户在前端页面上选好要生成什么表、什么字段,点一下"生成"按钮。后端拿到这些元数据,喂给 FreeMarker 模板引擎,模板引擎输出一堆文件,最后用 ZipUtils 打包成 zip 下载。

流程就这么直接。


代码生成逻辑

GeneratorService 是核心。用户传入的元数据(表名、字段列表、注释)会先被处理成 FreeMarker 的 DataModel,然后模板引擎挨个渲染。

关键代码大概是这个结构:

@Service
public class GeneratorService {

    private final Configuration freemarkerCfg;

    public GeneratorService() {
        freemarkerCfg = new Configuration(Configuration.VERSION_2_3_32);
        freemarkerCfg.setClassForTemplateLoading(this.getClass(), "/templates");
        freemarkerCfg.setDefaultEncoding("UTF-8");
    }

    public void generateProject(GeneratorRequest request, OutputStream outputStream) {
        // 1. 把用户输入的元数据转成 FreeMarker 能用的 Map
        Map<String, Object> dataModel = buildDataModel(request);

        // 2. 遍历模板列表,逐个渲染
        List<String> templateFiles = listTemplates();
        try (ZipOutputStream zos = new ZipOutputStream(outputStream)) {
            for (String templateFile : templateFiles) {
                Template template = freemarkerCfg.getTemplate(templateFile);
                StringWriter sw = new StringWriter();
                template.process(dataModel, sw);

                // 3. 把渲染出来的内容塞进 zip
                String outputPath = resolveOutputPath(templateFile, request);
                zos.putNextEntry(new ZipEntry(outputPath));
                zos.write(sw.toString().getBytes(StandardCharsets.UTF_8));
                zos.closeEntry();
            }
        }
    }

    private Map<String, Object> buildDataModel(GeneratorRequest request) {
        Map<String, Object> model = new HashMap<>();
        model.put("tableName", request.getTableName());
        model.put("className", toCamelCase(request.getTableName()));
        model.put("fields", request.getFields());
        model.put("projectName", request.getProjectName());
        model.put("packageName", request.getPackageName());
        model.put("date", LocalDate.now().toString());
        return model;
    }
}

这段代码做三件事:准备数据、渲染模板、打包输出。逻辑不复杂,但里面有不少细节值得说。

toCamelCase 这个方法会把 t_user_info 转成 UserInfo,把 order_detail 转成 OrderDetail。字段名、表名、类名之间的转换是代码生成器最烦人的部分之一。命名风格一旦确定下来,后面所有模板都靠这个统一的命名规则。

Zip 打包用的是 Java 原生的 ZipOutputStream,不需要引入额外依赖。每个模板渲染完就往 zip 里塞一个 entry,最终用户拿到的就是一个完整的、解压即用项目。


FreeMarker 模板

模板是这个项目的灵魂。每个 .ftl 文件对应一个输出文件。比如生成后端的 Controller:

package ${packageName}.controller;

import ${packageName}.domain.${className};
import ${packageName}.service.${className}Service;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/${tableName}")
public class ${className}Controller {

    private final ${className}Service service;

    public ${className}Controller(${className}Service service) {
        this.service = service;
    }

    @GetMapping
    public List<${className}> list() {
        return service.list();
    }

    @GetMapping("/{id}")
    public ${className} getById(@PathVariable Long id) {
        return service.getById(id);
    }

    @PostMapping
    public void save(@RequestBody ${className} entity) {
        service.save(entity);
    }

    @PutMapping
    public void update(@RequestBody ${className} entity) {
        service.updateById(entity);
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable Long id) {
        service.removeById(id);
    }
}

这个模板渲染出来就是一个标准的 Spring Boot REST Controller。五个接口,增删改查都有了,命名规范,该有的注解都在。

前端模板就更长一些了,.vue 文件里面要处理 Element Plus 的表格、表单、分页。不过核心思路一样:在模板里写死页面结构和交互逻辑,用 ${变量名} 把动态部分留出来。

比如前端列表页模板里,表头部分可能是这样:

<template>
  <div class="app-container">
    <el-table :data="tableData" v-loading="loading" border>
      <#list fields as field>
      <el-table-column prop="${field.fieldName}" label="${field.comment}" />
      </#list>
      <el-table-column label="操作" width="180">
        <template #default="{ row }">
          <el-button @click="handleEdit(row)">编辑</el-button>
          <el-button type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<#list fields as field> 这个语法会遍历用户定义的字段列表,每个字段生成一列。用户选了五个字段,表格就有五列。选了八个,就是八列。


数据库脚本生成

除了前后端代码,数据库脚本也是生成的一部分。用户定义的字段会被拼成 MySQL DDL:

CREATE TABLE IF NOT EXISTS `${tableName}` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  <#list fields as field>
  `${field.fieldName}` ${field.dbType} <#if field.nullable>NULL<#else>NOT NULL</#if> COMMENT '${field.comment}',
  </#list>
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='${tableName}表';

这里每个动态字段之后保留逗号,因为后面还有 PRIMARY KEY;字段名、类型和注释还必须分别校验或转义。这种细节如果手写 SQL 脚本很容易出错,用模板就省心了。


踩过的坑

第一个大坑是 FreeMarker 的空值处理。用户可能不填某个字段,模板里直接 ${field.comment} 会报错。FreeMarker 对 null 值处理非常严格,必须显式处理。

解决办法是用 FreeMarker 的默认值语法:

${field.comment!''}

后面的 !'' 意思是如果 comment 为 null,就用空字符串。缺失值通常会触发模板异常,不能假定自动生成字符串 null;默认值语法也不替代注释转义。

第二个坑是编码。Windows 上默认编码是 GBK,FreeMarker 默认用 UTF-8,中文注释到模板里全是乱码。在 Configuration 里显式设置 setDefaultEncoding("UTF-8") 解决了。但如果用户的操作系统环境不同,偶尔还是会有问题。

第三个坑是模板路径。setClassForTemplateLoading 加载的是 classpath 下的模板,开发时 IDEA 里跑没问题,打成 jar 包部署的时候路径就不对了。最后改成在 jar 外面放一个 templates/ 目录,用文件路径加载,部署就稳定了。

第四个坑和 FreeMarker 无关,是 Zip 打包。ZipOutputStream 的 closeEntry 一定要调,不然 zip 文件会损坏。这个 bug 很隐蔽,解压的时候不一定报错,但解压出来的文件可能是空的。调试了好半天才发现。


一些想法

这个项目不算复杂,Spring Boot 的 Web 层 + FreeMarker 的模板引擎 + Java 的 Zip 输出,三样东西拼在一起就能跑。但做下来对"代码生成"这个概念有了不一样的理解。

代码生成的核心不是 AI,不是大模型,不是多高端的技术。核心是抽象。你得从一堆具体的项目里抽出共性:每个管理系统都有增删改查,每个表都有主键和创建时间,每个前端页面都有表格和表单。把这些共性抽象成模板,把变化的部分留成变量,代码生成器就成立了。

FreeMarker 在这里的角色就是"填空"。它不关心业务逻辑,不关心数据库连接,不关心用户认证。它只做一件事:把数据填进模板,输出文本。简单,但在这个场景下够用。

相比现在的 AI 代码生成,templatecenter 这种方式输出结果完全可控,输出由模板与输入共同决定,仍可能生成有缺陷的代码,不会突然给你换个框架。缺点也很明显:模板要自己维护,新功能要手写模板,没有 AI 那种"你说一句话我就给你生成"的灵活性。

但对于大学生课设来说,可控性比灵活性重要。学生要的是能跑、能交作业、能过答辩的东西,不是炫技。


下一步

现在 templatecenter 只支持单表 CRUD,不支持表关联。用户想生成一个"订单-订单明细"的一对多系统,现在还做不到。后面想加上关联关系定义,让模板能处理外键和嵌套查询。

另外模板现在都是硬编码在项目里的。想做成让用户可以自定义模板上传,这样有经验的开发者可以加自己的模板进来。

权限控制还没做,Sa-Token 的依赖加进去了但没接入。现在谁都能访问生成接口,生产环境肯定要加认证。

这些都是后面的事了。先把单表 CRUD 跑稳,再说其他的。


写在最后

templatecenter 是个小项目,没什么复杂的架构,没什么高深的技术。但它解决了一个真实的问题:每年课设季,总有一批学生在网上乱找源码,找到的要么跑不起来,要么缺文件,要么数据库连不上。

如果这个东西能帮他们省点时间,就够了。

代码在 GitHub 上,templatecenter,感兴趣可以看看。有问题提 issue,我一般隔天回。

课设季又快到了。希望今年的求救消息少一点。

生成器的校验边界

模板必须来自可信来源。包名、类名、SQL 标识符、字段类型和输出路径需要白名单或结构化校验;ZIP entry 不允许绝对路径或 … 路径穿越。classpath 加载可以用于打包 JAR,应检查资源是否正确打包;外置模板并非必需。示例方法省略异常处理与输入校验,生成项目还需编译和测试。

JavaSpring BootFreeMarkerVue 3