模板代码模块化设计:从复制粘贴到高效代码生成 做后端开发久了几乎每隔一段时间就会收到同一种求助项目代码里全是结构相同的Service、Controller、Mapper大家靠复制粘贴手改类名和字段改得慢不说还经常漏改。这套东西我叫它模板代码——不是简单的重复代码而是一类带有确定结构、但又要随业务变化的代码资产。模板代码如果只靠人肉维护早晚会出问题。我这几年代码生成器和脚手架维护下来最大的心得就是模板代码同样需要模块化设计。把模板当软件一样设计拆分、抽象、配置化才能真正让团队从重复劳动里解脱出来还能保证改一处、处处同步。这篇内容适合正在维护生成器、脚手架或者每天都和批量样板代码打交道的同学包含完整设计思路、FreeMarker实操、IDEA格式化配合以及一堆实测踩坑记录。1. 为什么要给模板代码做模块化设计1.1 模板代码到底指什么这里说的模板代码不是聊天机器人里的提示词模板也不是前端页面上的HTML模板而是业务项目里那些结构高度一致、只随少数参数变化的代码资产。最典型的是CRUD五件套实体类、DTO、VO、Service接口与实现、Mapper接口、XML里的ResultMap、分页查询SQL、统一异常处理片段、各种注解标记。这类代码每个项目都会大量产出大家一开始都是复制粘贴改改类名、换换字段就算完成了一个模块。问题在于这些模板代码并不是生成完就结束的静态产物。只要业务规则发生变化比如所有实体要加Swagger注解、所有Mapper要支持逻辑删除、所有分页SQL的写法要调整你就得在所有已生成的副本上同步修改。更难受的是很多企业级项目会对同一套模板在不同时间段生成多批代码后期项目用了新版模板老项目还保持旧版结构代码风格越漂越远。所以模板代码模块化设计的对象是“模板资产本身”不是某一次生成的产物。我们要把模板文件组织得足够好让模板的修改成本可控、复用程度高、不同人能够并行维护最终让“生成代码”这个行为变得安全可靠。1.2 演进路径从复制粘贴到模块化模板库绝大多数团队都经历过同样的三个阶段。第一阶段是纯复制粘贴。把上一个服务的代码整段复制过来全局替换类名手工调整字段。这个阶段的最大问题是漏改一个字段名编译期能发现还算好如果字段类型没对齐运行期才出问题排查成本非常高。而且复制出来的代码带着上一任作者的注释、废弃的方法、甚至别人的包名。第二阶段是引入代码生成器。常见做法是MyBatis Generator或者自己用FreeMarker、Velocity写一个批量生成脚本从数据库表结构读取元数据一次性生成Service、Controller、Mapper。这个阶段确实能解决批量产出问题但我见过的大多数自研生成器模板文件本身还是一个“大泥潭”一个ftl文件里塞满了各种if/else既管实体又管DTO还管Mapper几百上千行看起来能跑改起来想哭。第三阶段就是模板模块化。把生成器本身当成一个小型软件工程来维护模板文件也讲单一职责、分层和抽象。公共片段抽出来配置和模板分离主模板只做骨架组装。如果你所在团队还停留在第一或者第二阶段抓住机会把模板库按下面的方法重做一次投入产出比非常高。尤其当你维护的是“一套模板库支撑多个项目”的场景时不模块化就等着被需求反复折腾。1.3 模块化之后的收益我用几个实例说明第一个收益是改动成本的变化。我做过一次统计把统一异常处理逻辑从“每个模块复制一份try-catch”改成“模板里抽成一个异常处理片段”之后再遇到异常处理规范调整只需要改一个片段文件重新生成边缘模块验证一次即可不用再全局替换几百处。第二个收益是多个产物共享同一组细节。实体类、DTO、QueryModel共用一个字段定义片段和一个字段注解片段实体类新增了字段校验DTO不会漏掉。我见过很多团队Entity和DTO各自维护一套字段列表结果某次需求加了字段Entity加了校验DTO和QueryModel没跟上联调时才发现返工成本很高。第三个收益是模板库可以多人并行维护。片段拆开之后有人专门负责类注释和规范的注释模板有人负责字段注解有人负责SQL片段分割清晰Git冲突也明显减少。模块化设计之后模板就不再是某个人的私有产物而是整个团队都能参与维护的公共资产。一句话总结这一层模板代码不是低技术含量的重复劳动它应该被当作基础组件去设计、评审和测试。下面我会具体讲怎么拆、怎么搭、怎么避坑。2. 模板代码模块化的三层设计思路2.1 第一层按输出类型拆分主模板模块化设计的第一步是让每个输出工件对应一个专属的主模板而不是一个模板管好几种产物。主模板的职责是定义输出文件的整体骨架文件类型、包名、访问修饰符、继承关系、类上的注解、字段排列方式、方法生成策略。它只负责“组装骨架”具体的业务字段和项目偏好交给数据和片段去决定。例如实体类的主模板只需要写出类的整体结构然后在字段位置让片段来填充Service接口的主模板只需要确定接口的继承关系、方法清单然后把方法内部实现的具体逻辑交给实现类模板或者公共方法片段。这样一来每个主模板的行数会大幅压缩通常控制在80到150行以内可读性会明显改善。这里有一个很重要的设计原则一个主模板的职责就是“根据给定的数据模型渲染一份结构完整的文件”。如果你的主模板里出现了大量分支去兼容不同项目的写法那就要考虑把差异抽成“片段”或者“配置选项”。硬塞在一个模板里短期看着省事后期维护就是灾难。我在实际项目中见过一个700多行的实体类模板各种项目特判堆在一起最终没人敢改只能推倒重来。2.2 第二层抽取跨模板的公共片段这一层是整个模块化收益最集中的地方。仔细对比一下项目的各个类会发现实体的类头、DTO的类头、QueryModel的类头长得很像都是那几行注释加几个注解字段上的校验注解、API文档注解也几乎一样分页查询SQL的前半段、排序字段的拼接、逻辑删除的过滤条件在不同的Mapper里也是重复的。公共片段天然适合抽取成模板片段。以FreeMarker为例就是一个个可以被include的ftl文件。主模板里写一行include指令渲染时就把片段切入对应位置。片段内部可以使用传入的变量所以同一个片段能被多个主模板复用。我从实践中总结的抽取原则是先找“变化频率高且复现次数多”的细节比如字段级别的注解、导入包、类注释、日志对象声明、统一返回包装再按“主模板之间重合度”决定是否抽取重合度低于50%的暂时不抽避免过度设计。模块化不是把模板拆得越碎越好拆分粒度要让维护者一眼能看到全貌千万不要为了体现设计感把模板拆成满地碎片。2.3 第三层参数与配置驱动模板行为模板本身是“渲染层”它不负责做业务决策。所有应该变化的点都应该由外部数据驱动。这个原则听起来简单实际执行时很多人做不到最常见的问题是模板里写死一些项目特有信息比如硬编码包名、硬编码某个企业内部的工具类名。我通常把输入切成两类。全局配置项目名、根包名、作者、是否启用Lombok、是否启用Swagger注解、统一响应体类名、主键生成策略、时间字段命名规则等。表级元数据表名、实体名、字段列表、字段类型映射、主键字段、逻辑删除字段、乐观锁字段、审计字段等。在FreeMarker的使用中我会把这些数据包装成一个Context Map模板只认Map里的键。模板里出现“如果”分支也要尽量基于配置项比如#if config.enableLombok这种分支就是合理的配置驱动而#if project order这种硬编码项目名的分支就是危险的信号。配置驱动的好处是新项目接入时只需要在配置层加数据不需要改模板模板的稳定性和通用性都能保持住。2.4 三层模型为什么比扁平模板可靠我见过不少生成器的模板目录就一层每个模板文件几百上千行看起来功能齐全维护时却无从下手。扁平模板的问题有三个。没有复用机制。A模板里有一个字段循环B模板里又复制了一份一旦字段格式要变所有相关文件都要同步改漏改一个就是线上事故。没有分层边界。模板里既是结构又是逻辑还是数据改的时候分不清影响面可能只想调整DTO的注释结果因为公共逻辑嵌在里面实体类的输出也变了。没办法做单元级验证。模板里逻辑一多喂数据时很难判断到底是哪个片段出了问题。三层模型的好处是片段可以单独验证、单独版本化、单独维护。我给片段做了很多小的测试用例字段片段喂三组不同类型的字段数据就能确认渲染结果是否正确主模板只要确认“骨架正确加片段正确”整体输出大概率就没问题。这种可验证性才是模块化设计真正值钱的地方。3. 实操基于FreeMarker的模板代码模块化重构3.1 模板仓库的目录结构设计先看一下我最终稳定的模板目录结构这个结构在多个团队里都直接落地过。它把不同职责的文件归到不同目录从文件名和路径就能判断模板的定位。templates/ ├── base/ # 基础片段全局复用 │ ├── imports.ftl # 导入包片段 │ ├── class_annotations.ftl # 类头注解片段 │ ├── class_comment.ftl # 类注释片段 │ ├── logger_declare.ftl # 日志对象声明片段 │ └── field_annotations.ftl # 字段级注解片段 ├── models/ # 主模板按输出类型拆分 │ ├── entity.ftl # 实体类 │ ├── dto.ftl # DTO │ ├── query.ftl # 查询模型 │ ├── service.ftl # Service接口 │ ├── service_impl.ftl # Service实现类 │ ├── controller.ftl # Controller │ └── mapper_xml.ftl # Mapper XML ├── fragments/ # 领域级片段局部复用 │ ├── paging_sql.ftl # 分页SQL片段 │ ├── logic_delete.ftl # 逻辑删除条件片段 │ └── audit_fields.ftl # 审计字段片段 ├── config/ │ ├── global.yaml # 全局配置 │ └── table_meta.json # 表结构元数据 └── output/ # 生成输出目录目录设计的原则是base放全局通用片段fragments放领域相关的片段models放主模板config放驱动数据。主模板可以通过相对路径引用片段也可以在FreeMarker的配置里设置一个模板根路径用#include /base/field_annotations.ftl这种绝对路径更不容易出错。我建议在代码里设置绝对include路径因为主模板分散在多个子目录时相对路径很容易写错层级。3.2 主模板示例实体类模板实体类主模板我精简成一个可运行的版本package ${config.basePackage}.${table.moduleName}.domain.entity; #include /base/imports.ftl /** * ${table.tableDesc!} * * author ${config.author!} * date ${generateTime?string(yyyy-MM-dd HH:mm:ss)} */ #include /base/class_annotations.ftl public class ${table.entityName} implements Serializable { private static final long serialVersionUID 1L; #list table.fieldList as field #if field.comment?? field.comment?length gt 0 /** ${field.comment} */ /#if #assign currentField field / #include /base/field_annotations.ftl / private ${field.javaType} ${field.propertyName}; /#list #include /base/getter_setter.ftl }注意几个细节。table.moduleName和basePackage来自配置和元数据模板里不写死。字段级注解被抽到/base/field_annotations.ftl因为DTO、QueryModel的主模板也要用同一套字段注解。generateTime是传给模板的渲染时间统一格式后不同批次生成的文件对比Diff时不会被时间戳干扰。注意模板里的空行会直接体现在输出文件中。FreeMarker渲染时include片段前后的空白也会被保留输出文件可能多出不少空行。建议在配置里开启setWhitespaceStripping(true)并在模板中尽量用显式的空行控制版式不要依赖片段前后的隐式空白。3.3 公共片段的写法字段注解与导入包字段注解片段是所有模板片段里复用频率最高的文件我来展示一个实际版本#if currentField.comment?? currentField.comment?length gt 0 ApiModelProperty(value ${currentField.comment}) /#if #if currentField.primaryKey?? currentField.primaryKey TableId(type IdType.AUTO) /#if #if currentField.logicDelete?? currentField.logicDelete TableLogic /#if #if currentField.fillStrategy?? currentField.fillStrategy INSERT TableField(fill FieldFill.INSERT) /#if这里的currentField是通过模板变量传递的主模板里在#assign currentField field /之后include片段片段就能读取对应字段的属性。为什么要用这种方式而不是直接用field因为在FreeMarker的include机制里片段共享当前上下文直接用field也可以但用currentField是在多个片段共用一套变量名时的统一约定避免不同主模板用不同字段变量名导致片段没法复用。导入包片段同样可以做成配置驱动import lombok.Data; import lombok.EqualsAndHashCode; #if config.enableSwagger?? config.enableSwagger import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; /#if import javax.validation.constraints.*; import java.io.Serializable; import java.util.Date;一个容易踩的坑FreeMarker模板里如果片段文件以空白行结尾include进来后会在生成文件里多出空白行。当时生成的Java文件每个字段之间多了一行空行Code Review被同事吐槽了很久。后来我在所有模板里统一约定“片段文件末尾不留空行”并在生成器的输出逻辑里追加一次轻量的行尾清理这个问题才算根治。3.4 数据模型与Java调用装配模板写得再好数据模型不给力输出也好不到哪去。从数据库读表结构后我会转换成这样一组对象public class TableModel { private String name; // 表名 private String moduleName; // 模块名如 order private String tableDesc; // 表注释 private String entityName; // 实体类名如 Order private ListFieldModel fieldList; } public class FieldModel { private String columnName; // 列名 private String propertyName; // 属性名 private String javaType; // Java类型 private String comment; // 注释 private boolean primaryKey; // 是否主键 private boolean logicDelete; // 是否逻辑删除 private String fillStrategy; // 填充策略 }FreeMarker的装配代码非常简洁Configuration cfg new Configuration(Configuration.VERSION_2_3_31); cfg.setClassForTemplateLoading(this.getClass(), /templates); cfg.setDefaultEncoding(UTF-8); cfg.setWhitespaceStripping(true); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); MapString, Object root new HashMap(); root.put(config, globalConfig); root.put(table, tableModel); root.put(moduleName, tableModel.getModuleName()); root.put(generateTime, new Date()); Template tpl cfg.getTemplate(models/entity.ftl); tpl.process(root, new OutputStreamWriter(new FileOutputStream(outputFile), StandardCharsets.UTF_8));整体执行链路是数据库连接器读取表结构转换器把数据库类型映射成Java类型元数据打包成TableModel再配合全局配置合并成ContextFreeMarker按模板渲染最后写入文件。模块化的边界就体现在这里数据转换是数据层的事模板渲染是展示层的事两层通过固定的对象结构解耦。3.5 片段抽取的单一职责与粒度最后一个小节专门说下拆分粒度。我见过拆到极端的模板库一个简单的实体类模板拆了十多个片段每行代码都做一次include维护起来也很痛苦。我的经验是按“变化频率”和“复用次数”两个维度来判断。复用次数高且变化频率中等的比如字段注解、导入包抽成base片段。复用次数低但变化会影响多种产物的比如分页SQL、逻辑删除条件抽成fragments片段。只在单个主模板里出现且未来不太可能复用的不要抽写在主模板里面减少层级。判断标准很朴素如果一个片段只有一处引用它就不叫片段叫多余文件。模块化的价值在于削减重复不是为了创造更多的文件层级。4. 生成代码的格式化闭环IDEA代码格式化模板配合4.1 为什么生成代码还要单独管格式模板模块化之后生成代码的内容不会跑偏但格式又会成为下一个问题。同一个模板生成的代码在不同电脑上、经过不同人的手缩进可能不一样换行可能不一样imports顺序可能不一样。如果团队里有人用Tab缩进有人用4空格缩进生成的Java文件一进Git就会被Diff刷屏Code Review也没法进行。我见过一个项目模板统一、代码生成器也统一但生成出来的代码还是要花大量时间去调整格式。原因就是没有一个强制的格式化标准。模板本身可能很长里面嵌套了很多includeFreeMarker渲染时缩进会根据include的缩进位置变化输出结果天然不一样。所以需要在生成的最后一步用格式化工具把输出统一成标准风格。4.2 IDEA代码格式化模板怎么管理和使用IntelliJ IDEA本身就提供了完整的代码风格方案也就是这里说的IDEA格式化模板。在Settings Editor Code Style里你可以配置缩进、空行、注释、imports排序、字段对齐、连续赋值对齐等等。这套配置可以导出为一个XML文件在Scheme旁边的设置按钮里选Export会得到一个类似project_code_style.xml的文件。我建议把这个XML文件和模板库放在同一个Git仓库的config/目录下团队直接共用。新同学拉完仓库导入这份格式化模板再执行一次Reformat Code生成的代码风格就和大家完全一致。具体导入步骤是Settings Editor Code Style Scheme Import Scheme IntelliJ IDEA code style XML。这里有一个很容易被忽略的点IDEA自身的格式化模板和Google Java Format规则并不完全等价。如果你想用Google的代码风格可以直接在IDEA的Scheme里选择“GoogleStyle”再导出XML也可以在生成的流水线里接入google-java-format依赖。两者选一即可不要混用否则同一个代码文件会被两种规则反复拉扯。注意格式化模板一定要和模板库放在一起做版本管理。只放模板不放格式化模板团队里每个人看到的代码风格还是不一样格式化结论不具备可重复性。4.3 把格式化接入代码生成流水线最理想的情况不是让人去手动Reformat而是生成器的输出流程里就直接带上格式化环节。项目里常见的做法有两种。第一种如果生成器是Java或Kotlin写的直接在生成文件的代码里引入google-java-format或者对应的格式化库生成完立刻格式化再落盘。以google-java-format为例new Formatter().formatSource(source)一行调用就能完成。这样生成的每个文件落盘之前就已经是标准格式开发人员拿到的就是可以直接进入Code Review的成品。第二种如果生成器是脚本类工具就统一在CI阶段加一个格式化校验任务用Spotless或Checkstyle检查所有生成文件是否符合风格模板。不符合就构建失败强制开发人员跑一次格式化。校验规则和格式化模板要来自同一份配置避免“格式化和校验不是一套标准”的脱节。还有个务实的小技巧生成器的输出目录不要因为“这是生成的代码”就排除在格式化覆盖范围之外。生成代码也是代码格式化和规范检查都要覆盖到。只是可以在提交说明里标注“generated code”让Review的人知道这些都是模板产出不需要逐行看业务逻辑。如果你不对生成代码做格式化校验出问题后很难定位是模板的问题还是格式化的问题。5. 常见问题与排查技巧实录5.1 模板改了生成结果不一致这是最常收到的问题明明改了某个公共片段重新生成后只有部分文件变了另一部分没变。先说结论九成是版本不同步造成的。生成器、模板库、数据模型三者都有自己的版本如果你修改模板之后没有清理旧的输出文件或者生成器缓存了模板类新内容不会生效。排查顺序我一般是先确认生成器用的模板路径是不是当前仓库里的路径很多人本地存在多个模板仓库一不小心引用了旧路径再确认是不是IDE的缓存FreeMarkersetClassForTemplateLoading会从classpath加载模板改完模板没有重新构建的话试一下Clean和Rebuild最后再看是不是输出文件被覆盖规则拦截了很多生成器对已存在的文件默认跳过需要开启强制覆盖或者先清理输出目录。5.2 FreeMarker空值和默认值处理模板渲染最普遍的异常就是空值报错。数据库的字段注释经常为空如果模板里直接写${field.comment}渲染时遇到空注释直接抛异常。处理方式要分场景。注释为空时最好不输出Javadoc用#if field.comment?? field.comment?length gt 0包起来。默认值场景用${field.javaType!String}这种形式注意感叹号的位置。列表为空时用#list fieldList![] as field兜底。这些细节看着不起眼实战中影响很大。我第一次写生成器时一张没有注释的表直接把整个批量生成流程打断排查了半天才知道是某个模板里的空值没兜底。所以后来我在模板里定了规矩所有从配置或元数据读取的值输出前都要判断是否存在不能想当然认为数据库字段一定有注释。5.3 生成代码覆盖已有改动怎么办模板模块化之后生成器往往还要继续为已经迭代了很多轮的业务代码服务。如果直接覆盖会丢掉手工写的业务逻辑如果不覆盖模板更新又没法传播到老代码。业界比较通用的做法是“自定义扩展区”在模板生成的文件里预留两个标记注释比如// CUSTOM START 和// CUSTOM END 生成器解析已存在文件时自动把两个标记之间的内容保存下来重新生成后回填回去。这样既保住了手工代码又拿到了模板更新带来的新模块。我实际用过这种方案效果不错但要注意标记注释必须放在固定位置而且代码里不能嵌套相同的标记否则回填会错乱。另外支持这种方式后模板文件就不能随便改标记命名规范要当作公开API来维护。一旦老项目里的标记格式不统一生成器的回填逻辑就会原地罢工。5.4 编码与换行符问题模板文件是UTF-8输出文件也应该是UTF-8。但Windows环境下很容易出现生成文件带BOM或者换行符变成CRLF的情况Java文件在Linux的CI环境里可能因为BOM问题出编译异常。我的经验是在生成器代码里强制指定输出编码模板加载也要明确编码。cfg.setDefaultEncoding(UTF-8)这行配置加上同时输出Writer也用new OutputStreamWriter(..., StandardCharsets.UTF_8)。对于换行符统一在格式化阶段处理比如google-java-format内部会按LF输出就不会再出现CRLF混行的问题。如果你用IDEA的Reformat可以在Code Style里设置Line separator: Unix and OS X (\n)。5.5 常见问题速查表现象常见原因处理办法多个文件生成了同样的代码主模板没拆分逻辑冗余按输出类型拆分主模板字段格式要调整多处漏改字段级逻辑没有被抽取成片段抽取字段注解片段模板改了不生效生成器缓存旧模板Clean重新构建确认模板路径生成文件格式杂乱缺少格式化闭环接入IDEA格式化模板和CI校验生成代码丢失手工改动覆盖策略粗暴增加自定义扩展区标记这份速查表也提醒我模板模块化不是一次性的重构它是一个持续维护的过程。每当你觉得“改个东西怎么这么费劲”的时候往往不是代码有问题而是模板资产本身的结构需要进一步清理了。6. 实操体会模块化模板库的演进之路模板库模块化之后我和团队摸索出的一个最重要的执行经验是先把公共片段抽取出来再拆主模板最后补格式化闭环。顺序千万别反。我有一次一上来就大刀阔斧地重写主模板结果公共片段还没抽完主模板改了没地方复用反而制造了大量中间态。另一个让我印象深刻的体会是模板代码模块化本质上是一种“标准化的产品思维”。它要求你把模板当成一个会长期演化的产品来对待而不是一份写完就完事的脚本。最直接的体现就是模板库要有ChangeLog改动公共片段前要评估对哪些主模板和哪些项目会影响提交时要把影响面写清楚。我们后来甚至给模板库配了一个小型的“版本兼容矩阵”记录模板版本和项目生成版本的对应关系老项目需要重新生成时能快速找到匹配的模板版本。最后分享一个小技巧字段和方法的命名要尽量用模板内部的约定而不是迁就具体数据库的原始命名。比如currentField这种变量名在全库模板里保持一致新成员接手时能快速意识到这是一个循环内的当前字段对象而不是靠猜。规范一旦定下来就不要随意换名因为模板文件很多include关系复杂改名成本远大于新建一个片段。如果你正在被一堆重复代码折磨不妨从今天开始拿出一个模块把模板代码做成模块化设计。别追求一步到位先抽两个片段试试水感受一下“改一处同步生效”的爽感再逐步扩张。模板这东西早做早省心。