多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

用元数据驱动和模板渲染生成Java CRUD代码的完整方案

用元数据驱动和模板渲染生成Java CRUD代码的完整方案 做后端开发的兄弟应该都有过这种体验一个业务需求下来真正写业务逻辑的时间可能只占三分之一剩下的时间全耗在重复劳动上——实体类、Mapper、Service接口、ServiceImpl、Controller、DTO、VO再加上参数校验和统一返回包装一套 CRUD 写完人已经麻了。我自己维护了几个老项目最怕的就是新接一个模块光建目录、写模板代码、对齐命名规范就能忙活大半天改完之后还得对着 IDE 的报错一个个排查。后来我花了几周时间把平时复制粘贴频率最高的那套代码固化成工具按“元数据驱动 模板渲染”的思路做了一个生成器内部代号就叫t3code。t3code 解决的核心问题很简单把“从数据库表到可用业务接口”之间那段又臭又长的样板代码从手写变成自动生成。它适合正在做 Java 业务后端、又被重复 CRUD 折磨的团队也适合想把自己团队编码规范固化下来的技术负责人。这篇文章我会把 t3code 的设计思路、核心实现、实操步骤和踩坑记录完整分享出来代码和配置都给到可直接复用的程度希望能帮你省下同样多的时间。1. 项目定位与整体设计思路1.1 为什么叫 t3code三件事的顺序不能乱t3code 这个名字拆开看是 T 3 code。T 是 Templatecode 很好理解中间那个“3”字是我定下的三条铁律先定义元数据再生成代码先跑通单表再扩展到关联先本地验证再提交版本库。这三条顺序一旦颠倒工具就会失控。最早我并不是这个思路。第一版我图省事直接批量复制旧项目的代码然后全局替换包名和类名结果替换出来的代码经常带着上一个业务的残留字段有一次甚至把订单模块的金额字段留在了用户模块里测试的时候才被发现。从那以后我就明白生成代码这件事输入必须是结构化、可校验的不能靠人肉替换文本。所以 t3code 选择先让用户写一份 YAML 或 JSON 格式的元数据描述描述里明确表名、字段、类型、校验规则、关联关系再由引擎统一渲染。这样无论是字段遗漏还是类型错误在生成之前就能暴露出来。这个定位和市面上的 MyBatis Generator 不一样。MyBatis Generator 擅长根据数据库表反向生成持久层代码但 Controller、Service、DTO 这一层它管不着和低代码平台也不一样低代码平台往往把运行期也接管了对既有项目侵入太大。t3code 只做“生成”这一件事生成完的代码就是普通工程里的普通文件后续怎么改、怎么部署完全不受工具限制。这也是我最终选这条路的核心理由——生成器不该绑架项目结构它应该顺手把团队规范带进去然后立刻退场。1.2 从输出思维转向输入思维大部分开发者在刚开始写工具时想的是“我要生成长什么样的代码”于是把模板写了又改、改了又写折腾半天发现不同业务之间的模板差异比想象中大得多。我的经历也一样模板写得越具体复用面越窄。后来我换了个角度先想“一份元数据要包含哪些信息才能推导出一整套代码”。这才是 t3code 的转折点。比如一个用户模块的元数据至少得包含表名和业务前缀用来决定类名和文件路径每个字段的 Java 类型和 JDBC 类型用来决定 DTO / VO 属性字段的校验注解比如 NotBlank、Min、Max用来生成入参校验哪些字段是查询条件影响 Mapper XML 的动态 SQL哪些字段是逻辑删除、乐观锁或审计字段影响公共逻辑当输入信息齐全以后输出其实是被“推导”出来的模板只需要关心结构和排版。这一步想通之后我再也没有频繁改过模板。t3code 的 config、metadata、template 三个目录就这么定下来了对应配置层、描述层、渲染层各管各的。1.3 常见误区生成代码不等于免维护还有一个必须说清楚的问题代码生成后它就是你工程里的一份普通代码一样要坚持代码评审、一样要写单测。工具的意义是帮你省掉机械劳动不是帮你逃避工程责任。我见过有人把生成代码当作“黑盒产物”改需求时直接改生成结果然后又重新生成整个文件结果手工改动被覆盖得干干净净。所以在 t3code 里我加了文件管理的约定要么只允许增量生成新文件要么把自定义逻辑隔离到子类或扩展方法中。比如生成 Service 接口时同时生成一个 ServiceImpl 基类让开发者在子类里写业务逻辑这样重新生成基类就不会覆盖已有内容。这个设计在后面实操部分我会细讲。2. 核心细节解析与技术选型2.1 模板引擎选型为什么用 Handlebars 而不是 Velocity模板引擎是 t3code 的核心依赖没有它元数据就是一盘散沙。最初我考虑过 Velocity、Freemarker、Handlebars.java三者都能做 Java 代码渲染但实际体验差距很大。Velocity 的语法是#foreach、$!{var}老牌稳定但它的上下文查找比较宽松变量写错了经常不报错渲染出来一段空白才发现问题。Freemarker 很强大标签多、指令多团队里每个人写的模板风格都不一样这对需要“一套模板多人维护”的场景来说反而是缺点。Handlebars.java 是 Mustache 系的实现语法极简——{{var}}、{{#each}}、{{#if}}没有任何业务逻辑混入模板的可能天然就是“只做展示不做计算”的定位。我最后选 Handlebars不是因为它性能最好而是因为约束力最强。生成器模板本身也是代码如果模板里允许写复杂逻辑很快会出现逻辑复制、各写一套的问题。Handlebars 能用的判断和循环非常有限反而逼着大家把差异都收敛到元数据里。现在 t3code 项目里几十个模板文件都是同一个风格新成员上手成本极低。2.2 元数据模型设计一个 YAML 描述一个业务模块元数据是用来驱动模板的它的结构直接决定了模板的复杂度。t3code 的元数据我设计成两层顶层是模块信息底层是字段列表和关联列表。一个典型的用户模块元数据长这样module: user package: com.example.demo tableName: sys_user className: User comment: 用户信息 fields: - name: id javaType: Long jdbcType: BIGINT primaryKey: true autoIncrement: true - name: username javaType: String jdbcType: VARCHAR notNull: true maxLength: 50 query: eq comment: 用户名 - name: email javaType: String jdbcType: VARCHAR maxLength: 100 comment: 邮箱 - name: status javaType: Integer jdbcType: TINYINT defaultValue: 1 comment: 状态 1正常 0停用 logicDeleteField: deleted createTimeField: create_time updateTimeField: update_time这里有几个设计细节值得说一下query: eq表示该字段在列表页作为精确查询条件。模板渲染 Mapper XML 时遇到这个标记就生成if testusername ! null and username ! AND username #{username}/if。autoIncrement只影响实体类和插入语句的字段排除逻辑不会影响数据库迁移脚本——数据库表结构用 Flyway 或 Liquibase 管理生成器不做 DDL。把审计字段createTime、updateTime、logicDelete作为模块级配置而不是字段级配置方便在公共模板里统一处理。比如生成 BaseEntity 时用它们而生成普通 DTO 时干脆不出现。有了这份元数据t3code 会依次推导出实体类字段过滤掉逻辑删除字段但保留在数据库中DTO / VO 字段自动排除审计字段因为前端不需要关心Controller 接口基于 RESTful 风格映射 GET / POST / PUT / DELETEService 接口和实现骨架事务注解、防重校验、分页查询Mapper 接口和 XML基础 CRUD、动态查询、批量更新2.3 命名策略与代码风格一致性生成器比较尴尬的一件事如果生成的代码和团队手写风格不一致不但不给工程加分反而制造混乱。t3code 把命名策略单独抽成了一个模块由配置驱动。在t3code-config.yml里可以指定实体类后缀默认无后缀UserController 后缀默认ControllerService 接口/实现后缀Service/ServiceImplVO、DTO、Query 对象后缀VO、DTO、Query布尔字段前缀是否使用is前缀建议不用避免序列化歧义还需要指定常量风格。Java 代码里user_id这样的列名映射成userId但常量类里的字段往往要MAX_USERNAME_LENGTH这种大写风格。t3code 里写了三个转换策略camelCase、PascalCase、SNAKE_CASE底层用统一的字段名转换器任何模板里都能用{{camelName}}、{{pascalName}}这样的辅助方法。在模板里禁止直接拼写名称防止同一个字段在不同模板里大小写不一致。我记得第一次在订单模块里跑生成有个模板把order_id渲染成了OrderId另一个模板渲染成了orderId编译直接报错。后来才下的决心所有名称一律通过辅助方法生成不靠模板作者手写。这个经验对任何生成器项目都适用——名称转换必须集中收敛不可以散落在模板各处。3. 实操过程与核心环节实现3.1 初始化 t3code 项目与目录规划t3code 本身是个命令行工具用 Java 写成通过t3 build构建独立可执行 jar。拿到发布包之后在一个空目录里执行t3 init会生成以下骨架my-generator/ ├── t3code-config.yml ├── metadata/ │ └── user.yaml ├── templates/ │ ├── entity.ftl.hbs │ ├── mapper.ftl.hbs │ ├── service.ftl.hbs │ ├── serviceImpl.ftl.hbs │ ├── controller.ftl.hbs │ └── dto.ftl.hbs ├── output/ └── target/output是每次生成的根目录target存放上次生成的缓存信息。t3code 启动后会先读t3code-config.yml再扫描metadata下的所有 yaml 文件按顺序解析成一个中间数据模型最后执行模板渲染。这个流程就像一条流水线原料元数据进入经过固定工序模板产出标准件代码文件。t3code-config.yml的核心内容如下project: basePackage: com.example.demo author: zhangsan version: 1.0.0 paths: metadata: metadata templates: templates output: output naming: entitySuffix: controllerSuffix: Controller serviceSuffix: Service serviceImplSuffix: ServiceImpl mapperSuffix: Mapper voSuffix: VO dtoSuffix: DTO querySuffix: Query common: swagger: true lombok: true mapstruct: true注意mapstruct: true这是另一个大坑。MapperStruct 映射器确实能省掉大量 DTO 到 VO 的转换代码但版本不兼容时编译报错特别隐晦。t3code 生成的代码默认在接口上用Mapper并且把componentModel spring写死避免出现生成后还需要手动补注解的情况。3.2 编写第一份业务元数据以用户模块为例为了演示完整流程我用一个用户管理模块走一遍。在metadata/user.yaml里写清楚表信息、字段信息和查询需求。然后执行t3 generate -m metadata/user.yaml -c t3code-config.yml跑完以后output下会生成这样的目录结构output/com/example/demo/ ├── controller/UserController.java ├── service/UserService.java ├── service/impl/UserServiceImpl.java ├── mapper/UserMapper.java ├── mapper/xml/UserMapper.xml ├── entity/User.java ├── dto/UserDTO.java ├── dto/UserQuery.java └── vo/UserVO.java每个文件都是完整可编译的 Java 类。比如User.java实体类package com.example.demo.entity; import lombok.Data; import java.time.LocalDateTime; /** * 用户信息 * author zhangsan */ Data public class User { /** 主键 */ private Long id; /** 用户名 */ private String username; /** 邮箱 */ private String email; /** 状态 1正常 0停用 */ private Integer status; /** 创建时间 */ private LocalDateTime createTime; /** 更新时间 */ private LocalDateTime updateTime; }如果你配置了logicDeleteField: deleted则逻辑删除字段不会暴露在 VO 和 DTO 中entity 里默认用private Integer deleted;表示。这也是团队规范的一部分所有删除都走逻辑删除任何代码都不得物理删除。生成器从元数据层面强制了这个规范。3.3 Controller、Service 与 Mapper 层的生成效果Controller 层生成出来是这个风格package com.example.demo.controller; import com.example.demo.common.Result; import com.example.demo.dto.UserDTO; import com.example.demo.dto.UserQuery; import com.example.demo.service.UserService; import com.example.demo.vo.UserVO; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/user) RequiredArgsConstructor Api(tags 用户信息) public class UserController { private final UserService userService; GetMapping(/{id}) ApiOperation(根据ID查询) public ResultUserVO getById(PathVariable Long id) { return Result.success(userService.getById(id)); } GetMapping ApiOperation(分页查询) public ResultPageUserVO page(UserQuery query) { return Result.success(userService.page(query)); } PostMapping ApiOperation(新增) public ResultLong create(RequestBody UserDTO dto) { return Result.success(userService.create(dto)); } PutMapping(/{id}) ApiOperation(修改) public ResultBoolean update(PathVariable Long id, RequestBody UserDTO dto) { return Result.success(userService.update(id, dto)); } DeleteMapping(/{id}) ApiOperation(删除) public ResultBoolean delete(PathVariable Long id) { return Result.success(userService.delete(id)); } }这里有几个细节是团队规范固化的结果。路径统一/api/{module}方法统一 RESTful业务放在 Service 层、Controller 只做参数接收和结果包装。如果你有权限校验需求元数据里可以加permissionPrefix: userController 方法上方会生成PreAuthorize(hasAuthority(user:add))之类的注解这比后期人肉补注解可靠得多。Service 实现类的生成逻辑稍微复杂一点Override Transactional(rollbackFor Exception.class) public Long create(UserDTO dto) { // 唯一性校验username 唯一 if (userMapper.existsByUsername(dto.getUsername())) { throw new BizException(用户名已存在); } User entity new User(); BeanUtils.copyProperties(dto, entity); entity.setCreateTime(LocalDateTime.now()); entity.setUpdateTime(LocalDateTime.now()); userMapper.insert(entity); return entity.getId(); }这里的existsByUsername方法也不是凭空生成的。元数据里定义字段时如果加了unique: true模板会自动在 Mapper 接口里追加一个existsByXxx方法并在 XML 里实现对应查询。这样生成的代码兼顾了业务校验而不是一层皮。3.4 自定义模板五个步骤加一个新的生成文件t3code 内置的模板覆盖了最常规的 CRUD但你迟早会遇到特殊需求比如为每个模块生成一个 Elasticsearch 索引映射类。好在加新模板并不难。第一步在templates/下新建一个文件比如esIndex.ftl.hbspackage {{basePackage}}.es; import lombok.Data; import org.springframework.data.annotation.Id; import org.springframework.data.elasticsearch.annotations.Document; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; Data Document(indexName {{moduleName}}) public class {{className}}Index { Id private {{primaryKey.javaType}} {{primaryKey.camelName}}; {{#each fields}} {{#unless primaryKey}} Field(type FieldType.Text, analyzer ik_max_word) private {{javaType}} {{camelName}}; {{/unless}} {{/each}} }第二步在t3code-config.yml中加一个任务声明tasks: - name: esIndex template: esIndex.ftl.hbs outputPath: es/{{className}}Index.java第三步执行t3 generate -m metadata/user.yaml -t esIndex此时只渲染这个新模板。你也可以-t all跑全部任务。第四步检查output/es/UserIndex.java是否符合预期。第五步如果没问题把这个任务加到默认任务列表里以后所有模块都会生成 ES 索引类。这种“任务”机制让模板变成了可插拔的组件不需要在代码里硬编码每个模板的输出路径统一由配置文件声明。想临时调试单个模板也不需要跑完整套流程。4. 常见问题与排查技巧实录4.1 模板渲染失败Handlebars 语法与 Java 注释冲突第一次大面积使用 t3code 时报错最多的是模板渲染失败。常见原因有两个。第一模板里使用了 Handlebars 不支持的方法或表达式。Handlebars.java 不像 Freemarker 那样可以调用任意 Java 方法它只允许访问上下文 Map 里暴露的数据和注册的 helper。所以模板里不要写类似{{basePackage.replace(., /)}}这种代码正确做法是注册一个packagePathhelper把点号转成斜杠。第二Java 源码里的{{和}}会被 Handlebars 误认为是表达式。这个尤其坑比如写了一个字符串模板SELECT {{}} FROM xxx或者注释里画了个大括号直接渲染失败。我在模板里所有可能引起冲突的位置都做了转义比如\{{并且用单元测试覆盖了模板渲染的每个文件。建议任何二次开发者在新增模板时先跑一个最小元数据验证不要直接在真实业务上调试。4.2 生成的文件多了或者少了先查元数据解析有几个典型场景generate跑完以后发现少生成 Mapper XML多半是元数据里没有写在config.mapperXml路径或者模板任务没有配置xml任务。另一个常见问题是生成的 DTO 里多了一个deleted字段那是因为在元数据字段列表里定义了deleted同时又设置了模块级logicDeleteField: deleted两个配置叠加导致字段没有被正确过滤。这种问题排查起来需要方法论。t3code 提供了一个诊断命令t3 debug -m metadata/user.yaml会输出解析后的中间数据模型包括字段数量、过滤结果、唯一性约束、查询条件字段等。我一般先跑这个命令确认元数据解析无误再去查模板。生成器这类工具很容易让人误以为是模板问题实际八成是输入的问题。输入标准化之后排查范围会大大缩小。4.3 中文乱码与 IDEA 文件缓存导致编译不对Windows 环境下最容易出乱码我早期生成的实体类注释经常变成???。原因是模板文件用了 UTF-8 编码但默认文件读取用系统编码。t3code 在t3code-config.yml里加了encoding: UTF-8配置同时启动脚本里强制指定-Dfile.encodingUTF-8。如果你的工程出现中文乱码先检查这两个地方不要怀疑模板。还有一个经常被忽略的问题IDEA 的缓存。把生成代码复制进项目后有时候编译还是旧的类提示“找不到符号”其实代码已经更新了。这时候Build - Rebuild Project通常能解决如果还不行就File - Invalidate Caches / Restart。这不是 t3code 的 bug但确实是很多初次使用生成器的人会卡住的地方。4.4 常见问题速查表现象可能原因处理方式渲染输出为空模板变量名拼写错误执行t3 debug检查中间模型字段名模板报错无法解析模板中的{{未转义使用\{{转义避免 Java 字符串模板冲突生成文件中文乱码平台默认编码与模板不一致统一encoding: UTF-8并指定 JVM 参数多次生成后自定义逻辑被覆盖手工修改了生成文件并再次生成使用{baseClass}扩展机制或拆分公共部分到基类同一字段在不同类中类型不一致元数据字段类型被手工修改统一检查javaType和jdbcType映射刷新后 IDE 仍显示旧代码构建缓存IDEA 执行 Rebuild Project生成的接口出现循环依赖ServiceImpl 同时注入自己和 Controller检查生成的依赖注入注解默认用构造器注入没有生成查询条件元数据字段缺少query标记添加query: eq或query: like重新生成5. 工程化集成与团队协作落地5.1 接入既有项目的三种方式把 t3code 接入一个跑着的项目不建议直接把整包生成结果一次性导进去很容易造成大范围 diff。我自己用下来三种方式都试过各有适用场景。方式一按新模块增量接入。新需求来时用 t3code 生成新模块代码老模块完全不动。这样风险最小团队接受度最高。方式二老模块分批重构。某个 Controller 改动频繁就把它对应的 entity、mapper、service 重新生成一遍然后手工合并原先的业务逻辑。注意这时不要整文件覆盖而是把生成结果当作参考把结构对齐到新规范。这种方式适合想逐步治理老项目但不敢大动的团队。方式三全量重新生成 代码评审兜底。适合新项目启动或者是小团队完全掌控的仓库。生成完以后通过 Git diff 检查全部文件重点看命名、类型、路径是否符合预期。因为 t3code 生成的结果是可编译的全量接入时反而比人肉改动容易发现问题。5.2 团队规范与模板的管理生成器最大的隐藏价值不是自动写代码而是把团队规范变成可执行的规则。代码评审里经常吵的命名、分层、注解问题在生成器层面就被消灭了。这里我建议两条硬性要求。第一模板仓库需要单独管理、受保护分支不允许某个人随意改。模板变更后需要有人 review因为影响面是所有生成代码。第二元数据要写清楚字段含义它既是生成输入也应该是新人了解业务模型的入口。新同学拿到一份metadata/order.yaml基本就能明白订单表有哪些字段、哪些字段是查询条件、哪些字段有唯一约束这个体验比看几十个实体类快得多。还有一个值得养成的习惯把t3 generate集成到 Maven 的 compile 阶段之前用增量模式自动生成。比如新引入一张表只要在 metadata 里加文件并跑一次其他机器上的同事拉代码后不需要额外操作。不过要提醒一句自动生成一定要配合 Git 提交可以保证生成结果可追溯。如果哪天有同事手工改了生成文件然后又跑生成diff 会立刻告诉你哪里被覆盖了。5.3 从单表生成到关联查询的扩展思路t3code 目前内置的主要是单表 CRUD这也是我故意保持克制的决定。很多人问为什么不做多表关联生成我的回答是多表关联的查询逻辑千差万别关联方式、聚合条件、返回结构往往需要业务深度参与强行模板化容易产生很多“看起来能用、实际上要返工”的代码。但也不是完全不能做。我预留了一个relations字段元数据里可以声明oneToMany或manyToOne。比如订单和订单明细在订单元数据里加relations: - type: oneToMany targetModule: orderItem foreignKey: orderId生成订单 VO 时会多出一个ListOrderItemVO items字段同时生成一个selectWithItems方法。但具体怎么 JOIN、分批查询还是嵌套查询仍由模板里预置策略控制。这个设计跟“任务”一样是给有余力的团队留的扩展口而不是一上来就把所有复杂性都揽进生成器。写在最后用 t3code 这几个月我最直观的感受是以前接一个新模块需求估时总在两三天现在普遍能压到一天以内省下来的时间用来写真正需要思考的业务规则和单元测试。它不是什么高深的技术框架就是一套“输入元数据、输出标准代码”的固定流程但因为把命名的、层的、注解的规范全固化了反而比很多花哨工具更省心。如果你也想做类似的东西我的建议是不要急着写模板先把你们团队最痛的那段代码找出来统计一下里面哪些部分是每张表都一样的哪些是偶尔变化的最后再决定哪些字段进元数据、哪些逻辑留给开发人员手工处理。生成器最忌讳的不是功能少而是它替你做太多决定最后反而把灵活性锁死了。最后再分享一个小技巧t3code 生成的代码文件头都会带一行注释包含元数据文件路径和生成时间。这个习惯帮了我大忙——有时候业务说自己改过代码一查文件头注释发现根本就是生成后没动过排查效率一下子高了很多。希望这套思路对你也有用。
返回列表