ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JSON配置+TT模板:自动生成MyBatis全套CRUD代码

JSON配置+TT模板:自动生成MyBatis全套CRUD代码 每次接到“给业务表加个查询接口”的需求我心里都会先叹一口气。不是功能难写而是要在实体类、Mapper接口、XML映射、DTO、Service、Controller之间来回补代码同一个字段名要在七个文件里原封不动出现七八次。有一次我只改了实体没改XMLMyBatis启动直接抛异常排查到凌晨两点才发现字段名对不上。也就是那次之后我开始把表结构定义收敛成一份json配置文件再用TT模板——也就是.text template的.tt文件——在构建期自动生成这些重复代码。这篇文章是我整理出来的完整做法写给那些还在手工维护字段同步、想给项目上一套轻量代码生成机制的人。不管你是Java还是.NET团队只要被“改字段就牵一发动全身”折腾过这篇的思路都可以直接平移过去。1. 新增一张表要手改七个文件重复劳动背后的信息一致性问题1.1 一份增删改查需求背后的手写清单先说清楚我当时的处境。我们是一个Spring Boot MyBatis的老项目业务模块很多数据库表大概有四十多张。每次产品提一个“新增一张用户表提供增删改查接口”的需求我的完整工作清单是这样的先写数据库建表SQL执行到开发库写实体类SysUser.java字段、注释、JPA式或者MyBatis-style的注解写Mapper接口SysUserMapper.java声明insert/selectById/update/delete等方法写Mapper XML映射文件resultMap里要把每个column对应到property再写一堆SQL写DTO或VO给前端展示用的字段子集写Service接口和ServiceImpl基础CRUD至少五个方法写ControllerREST接口每个方法挂一堆注解如果有分页、条件查询还得在XML里拼动态SQL。这些文件加起来轻则七八个重则十来个。单看任何一个文件都不难但真正的成本在于同一个字段名、同一个类型、同一条注释必须在这些文件里一字不差地重复出现。1.2 真正的问题不是写代码而是信息的扩散手工同步最可怕的地方不是慢而是错。用软件工程的术语说就是DRY原则被破坏了一份字段定义的信息被复制到十个地方每个地方都成为“事实来源”任何一个点被改漏系统不会在编译期告诉你只会在运行期出问题。这个场景特别像公司通讯录如果每个人手里都有一份纸质名单人事变动后大家各自改迟早会有人改漏。代码生成器的思路是把“唯一的信息源”收拢到一张表或一个文件里再派生出所有需要保持一致的产物。我后来把这份信息源选成了JSON配置文件就是因为JSON足够简单、跨语言、可读性好任何人的都能打开看明白。1.3 一次字段不同步引发的线上事故真正逼我动手做自动化的是一次线上事故。当时业务表加了一个is_deleted字段做逻辑删除我改了实体类加上private Integer isDeleted;但XML里的select语句忘了加WHERE is_deleted 0。结果线上把已删除的数据也查询出来了用户看到一堆过期订单。排查过程很痛苦因为代码编译没问题日志也没报错只是数据不对。最后一行一行对XML和实体才发现字段没同步。那天下班后我跟自己说重复劳动靠自律是不可靠的必须靠机制来保障。2. TT模板胜出的理由对比MyBatis Generator、脚本和IDE插件之后2.1 我试过的两条路逆向工具和手工脚本一开始我先想到的是现成工具。MyBatis Generator大家应该都用过它能根据数据库表反向生成实体、Mapper和XML。但我实际用下来有几个问题一是生成的代码风格偏老想自定义模板得去看它那一套配置和模板结构学习成本并不低二是它只能覆盖MyBatis这一层Service、Controller、DTO还是得手写三是每次表结构变更都要重新连数据库逆向整个流程像被工具绑架。后来我又试过用Python写一个脚本本质就是字符串拼接。小场景还行一旦字段类型复杂、要生成的文件类型变多字符串拼接里的转义、缩进、条件逻辑全混在一块模板越来越难维护。半年后让我自己再改一遍我都不太想碰。这两个方案共同的问题是数据和重复样板没有真正分离。它们都在“写代码”而不是“渲染模板”。2.2 TT模板为什么更适合做这件事后来我接触到了TT模板就是Visual Studio生态里的T4文本模板文件后缀是.tt。它的设计初衷就是做文本生成把静态的代码骨架和可变的占位逻辑放在同一个文件里用# #把C#代码块包起来运行的时候相当于执行一个小程序最后输出纯文本。我看中它的几个点模板文件本身就是源码可以直接进版本库整个团队共享不需要每个人都装额外IDE插件语法和我平时写的C#一致能调用的类库都能用解析JSON、处理字符串完全没有障碍构建期执行改完模板重新运行就生效配合命令行工具可以做到一键生成。更关键的是模板引擎不只T4一家。Java生态的FreeMarker、VelocityGo语言自带的text/template本质都是同一个思想数据源 模板 文本输出。我选TT模板纯粹是因为它离我的IDE最近不需要再造轮子。2.3 如果不是.NET项目怎么办有人会说我这是Java Spring Boot项目用.tt模板是不是很别扭实际用下来完全不冲突。我把TT模板当成一个“构建期小工具”独立运行它读取JSON配置文件往项目源码目录里输出.java文件然后项目正常编译。这就好比很多Java项目也会用Node脚本做前端构建一样代码生成器只是工具链的一部分平台根本不需要一致。如果你的团队更习惯Java生态完全可以用FreeMarker替代T4思路一模一样。后面的章节里我会把模板写成尽量中性的逻辑重点是你怎么设计JSON、怎么设计模板而不是死守某个引擎。3. 先把JSON的Schema定死才有后面的代码生成效率3.1 一张用户表的JSON配置长什么样自动化的核心不是模板而是JSON配置。它是所有生成结果的唯一数据源所以我第一步不是写模板而是把配置结构定下来。下面是我实际在用的user.json你一看就明白{ tableName: sys_user, className: SysUser, packageName: com.example.module.user, comment: 用户表, fields: [ { name: id, type: long, dbType: bigint, comment: 主键, primaryKey: true, autoIncrement: true }, { name: username, type: string, dbType: varchar, length: 50, comment: 用户名, nullable: false }, { name: password, type: string, dbType: varchar, length: 100, comment: 密码, nullable: false }, { name: email, type: string, dbType: varchar, length: 100, comment: 邮箱, nullable: true }, { name: status, type: integer, dbType: int, comment: 状态, defaultValue: 1 }, { name: createTime, type: date, dbType: datetime, comment: 创建时间 } ] }每个顶层字段的用途tableName是数据库真实表名className是生成的Java类名packageName控制包路径comment是表注释fields是字段数组。每个字段里的name统一用小驼峰命名type写的是中性数据类型dbType是数据库里的原始类型再加上length、nullable、defaultValue这些约束信息。3.2 为什么字段类型要同时保留Java型和DB型这是我最想强调的一个设计细节JSON里的type字段不要直接写String、Long这种Java类型也不要写varchar、bigint这种数据库类型而是写一个中性的、自解释的类型名比如string、integer、long、decimal、date、boolean。为什么要这样因为同一份字段定义既可能用来生成Java实体也可能用来生成DDL建表SQL还可能用来生成前端TypeScript类型。Java的String在TypeScript里对应的是number吗不是Java的String对应TS的string但Java的Long对应TS的number。如果你在JSON里直接写死Java类型那生成DDL和前端类型时就麻烦了模板还得反向解析你的Java类型名非常不可靠。正确做法是让模板层负责类型翻译。JSON里只提供一个“语义类型”由不同的模板各自映射成目标语言类型。一份数据源至少服务Java、数据库、前端三端输出这是后面所有效率提升的基础。3.3 配置约束与JSON注释的处理Schema定下来之后还要配两个约束才能保证生成器不出乱子第一类型白名单必须统一。type字段只能从string、integer、long、decimal、date、boolean里选谁都不允许随手加一个int64或者String2进去。模板里遇到不认识的类型就抛异常让错误在生成期直接暴露。第二标准JSON不支持注释这很恼人。我的处理方式是在配置里加一个_comment冗余字段比如{ _comment: 这里说明为什么这个字段可空 }这样所有JSON解析器都能读不会因为注释语法问题报错。也有人喜欢用JSONC在模板里过滤注释但我觉得没必要一个下划线字段就能解决。4. 模板核心语法拆解循环、条件分支和类型映射表4.1 TT模板的三个基本块T4模板的语法非常直观核心只有三种块指令块# ... #用来声明模板语言、输出文件扩展名、引用程序集、导入命名空间语句块# ... #里面写C#逻辑代码比如解析JSON、循环遍历表达式块# ... #把表达式的计算结果输出到文本中。外加普通文本块就是原样输出的静态内容。一个最简模板长这样# template languageC# # # output extension.txt # Hello # World #运行之后会生成一行文本Hello World。不理解这段之前会觉得T4有门槛理解之后会发现它就是一个“能执行C#代码的文本文件”和写普通的控制台程序没有本质区别。4.2 循环和条件分支把字段列表变成类定义模板真正的力量在循环。以生成实体类为例核心逻辑是解析JSON拿到fields数组然后foreach遍历每一次循环输出一行属性定义。我早期实际用过的一段模板样式如下# template languageC# hostspecifictrue # # output extension.java # # assembly nameNewtonsoft.Json # # import namespaceSystem.IO # # import namespaceNewtonsoft.Json.Linq # # var json File.ReadAllText(this.Host.ResolvePath(user.json)); var root JObject.Parse(json); var className root[className].ToString(); var fields (JArray)root[fields]; # public class # className # { # foreach (var field in fields) { # /** # field[comment] # */ private # field[type] # # field[name] #; # } # }注意看foreach的写法# foreach (...) { #和# } #之间夹着的是普通文本。模板每一次循环会把这段普通文本原样发射一遍变量用# ... #输出。这是T4里最容易理解、也最容易搞混的地方——循环体里的“静态文本”不是循环外的一次性文本而是会被重复生成的。条件分支同理比如遇到primaryKey字段要额外输出一个Id注解# if (field[primaryKey] ! null (bool)field[primaryKey]) { # Id # } # private # field[type] # # field[name] #;4.3 类型映射表JSON里写什么代码里长什么样我在第三章说过JSON里存中性类型由模板负责翻译。翻译动作一般放在模板的类块# ... #里写一个辅助方法# private string ToJavaType(string type) { switch (type) { case string: return String; case integer: return Integer; case long: return Long; case decimal: return BigDecimal; case date: return LocalDateTime; case boolean: return Boolean; default: throw new Exception(未知类型: type); } } #类型映射表最好用switch或字典维护遇到未知类型宁可抛异常也不要让它静默返回原值。下面是我常用的一组映射关系中性typeJava类型数据库dbTypeTypeScript类型stringStringvarcharstringintegerIntegerintnumberlongLongbigintnumberdecimalBigDecimaldecimalnumberdateLocalDateTimedatetimestringbooleanBooleantinyintboolean有了这张表模板就能根据同一个type生成完全不同的目标代码而JSON文件本身保持干净。5. 全流程复现从user.json到SysUser.java再到Mapper.xml5.1 跑通T4生成的关键设置下面我用一个完整的实战过程展示这套流程怎么落地。假设项目结构是标准的Maven工程我建了一个tools/codegen目录里面放user.json和几个.tt模板。我用的是Mono.TextTemplating提供的命令行工具安装方式dotnet tool install --global dotnet-t4然后执行模板把输出的.java文件写到源码目录t4 entity.tt -o ../src/main/java/com/example/module/user/SysUser.java第一次跑的时候最容易踩的坑是路径问题。模板里用了this.Host.ResolvePath(user.json)这个API只有hostspecifictrue时才可用它的作用是以模板文件所在目录为基准去定位JSON文件这样不管你在哪个目录执行命令行都能稳定读到配置。5.2 一个实体类的完整模板长什么样这是我实际在用的实体类模板不是玩具可以直接抄# template languageC# hostspecifictrue # # output extension.java # # assembly nameNewtonsoft.Json # # import namespaceSystem.IO # # import namespaceNewtonsoft.Json.Linq # # import namespaceSystem.Collections.Generic # # var json File.ReadAllText(this.Host.ResolvePath(user.json)); var root JObject.Parse(json); var className root[className].ToString(); var packageName root[packageName].ToString(); var comment root[comment].ToString(); var fields (JArray)root[fields]; # package # packageName #; /** * # comment # * 本文件由TT模板自动生成请勿手工修改。 */ public class # className # { # foreach (var field in fields) { # /** # field[comment] # */ private # ToJavaType(field[type].ToString()) # # field[name] #; # } # # foreach (var field in fields) { # public void set# Capitalize(field[name].ToString()) #(# ToJavaType(field[type].ToString()) # # field[name] #) { this.# field[name] # # field[name] #; } public # ToJavaType(field[type].ToString()) # get# Capitalize(field[name].ToString()) #() { return this.# field[name] #; } # } # } # private string ToJavaType(string type) { switch (type) { case string: return String; case integer: return Integer; case long: return Long; case decimal: return BigDecimal; case date: return LocalDateTime; case boolean: return Boolean; default: throw new Exception(未知类型: type); } } private string Capitalize(string name) { return char.ToUpperInvariant(name[0]) name.Substring(1); } #一个需要注意的设置# assembly nameNewtonsoft.Json #要求运行环境能找到这个程序集。如果命令行工具直接找不到就把Newtonsoft.Json.dll放到模板目录指令里改成绝对路径引用例如# assembly name$(ProjectDir)tools/codegen/Newtonsoft.Json.dll #跑通一次之后这些细节就不再是问题。5.3 运行后得到的输出示例执行完t4 entity.tt之后生成的SysUser.java会是这个样子package com.example.module.user; /** * 用户表 * 本文件由TT模板自动生成请勿手工修改。 */ public class SysUser { /** 主键 */ private Long id; /** 用户名 */ private String username; /** 密码 */ private String password; /** 邮箱 */ private String email; /** 状态 */ private Integer status; /** 创建时间 */ private LocalDateTime createTime; public void setId(Long id) { this.id id; } public Long getId() { return this.id; } // 其余getter/setter省略 }同样的JSON我再写一个xml.tt模板生成的SysUserMapper.xml里会自动产出resultMap和基础SQLresultMap idBaseResultMap typecom.example.module.user.SysUser id columnid propertyid jdbcTypeBIGINT/ result columnusername propertyusername jdbcTypeVARCHAR/ result columnstatus propertystatus jdbcTypeINTEGER/ result columncreate_time propertycreateTime jdbcTypeTIMESTAMP/ /resultMap你注意createTime到create_time的转换也在模板里写了通用方法小驼峰属性名自动转下划线列名这是生成MyBatis映射文件必须处理的一步。5.4 生成完之后的目录结构一套流程跑完项目里出现这样的结构src/main/java/com/example/module/user/ ├── SysUser.java (生成) ├── SysUserMapper.java (生成) ├── SysUserService.java (手写) ├── SysUserServiceImpl.java (手写) └── SysUserController.java (手写) src/main/resources/mapper/ ├── SysUserMapper.xml (生成)关键经验是生成文件和手写文件必须严格分开。实体、Mapper、XML这些“纯机械”的交给模板Service、Controller这种可能会写业务逻辑的尽量手写。如果某个生成文件确实需要微调正确做法是写一个继承生成类的子类或者用组合方式扩展不要直接改生成产物。6. 生成结果不对时我是这样一步步定位的排查实录6.1 空壳类模板路径写错时的第一现场我第一次跑模板生成的Java类只有类名属性一个都没有打开文件一看就是一个空壳。第一反应是JSON没读进去。排查过程我在模板里临时加一行# root.ToString() #把解析出来的JSON原样输出到生成文件顶部。结果发现输出里fields数组是空的因为我在模板里写的是root[fields]而JSON里其实把键名错写成了fieds少了一个字母。这一步排查最大的经验是先确认数据有没有进来再怀疑模板逻辑。不要一上来就盯着循环语句反复看先在模板里把关键变量打出来数据正确了问题往往立刻水落石出。T4在Visual Studio里可以打断点调试命令行工具下就靠这种临时输出最有效。6.2 非法类型类型映射白名单的盲区有一次生成的代码里出现了private date createTime;编译直接报错。最开始我以为是模板里类型映射没生效打开模板一看ToJavaType方法根本没有date这个case。实际上我一开始的类型白名单里确实漏了date。JSON里写了type: date模板调用ToJavaType(date)时走到的default分支直接抛异常了提示“未知类型: date”。从定位角度来看这是好事因为问题在生成期就暴露了而不是等到编译或者运行期。这也说明一个设计原则类型映射表里遇到未知类型必须fail fast宁可抛异常中断生成也不要静默返回原值。团队里偶尔会有人把类型写成int而不是integer配置不规范时抛异常是唯一能提醒他改配置的方式。6.3 中文乱码编码问题比想象中隐蔽Windows环境下跑生成时我遇到过最诡异的坑是中文注释全部乱码生成文件里显示的是一堆符号。排查过程很典型第一步我以为是JSON文件的问题把user.json重新另存为UTF-8问题还在。 第二步我怀疑模板读取文件的编码不对于是把File.ReadAllText改成显式指定编码File.ReadAllText(this.Host.ResolvePath(user.json), Encoding.UTF8)问题仍然在。 第三步我才注意到是模板文件本身被某些IDE保存成了GBK模板里的静态中文字符在生成时直接按错误的编码读出来了。解决方式是统一把.tt文件和.json文件全部设为UTF-8并在模板头部加上# output encodingutf-8 #从那以后凡是参与生成的文件我一律在编辑器里固定UTF-8不混用任何其他编码。这个坑在多人协作时特别容易复发值得写进团队规范。6.4 重复生成覆盖手写生成器的固有漏洞前三个月用得很爽直到有一天同事在生成出来的Controller里手动加了一段逻辑过了几天重新跑生成那一段逻辑被整个覆盖掉了他又气又急。这个问题是所有代码生成器都绕不开的。我的应对策略有三条生成文件与手写文件放不同目录生成目录只放机械产物所有生成文件顶部自动打上“本文件由模板生成请勿手工修改”的标识如果必须在生成类上扩展逻辑优先用子类或包装类而不是直接改生成文件。本质上是一句话把“生成的代码”当做不可变资产修改的入口永远在手写区。7. 同一份JSON还能再榨出价值从DDL到文档再到前端类型7.1 生成建表SQL和数据库字典当JSON配置稳定之后你会发现收益远不止Java实体。我写了一个ddl.tt用同一个user.json生成建表SQLCREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码, email VARCHAR(100) COMMENT 邮箱, status INT DEFAULT 1 COMMENT 状态, create_time DATETIME COMMENT 创建时间, PRIMARY KEY (id) ) COMMENT用户表;JSON里的dbType、length、nullable、defaultValue在这里全用上了。同样的字段数组再加一个markdown.tt还能生成一份字段字典文档交付资料也不用单独写了。7.2 一份配置生成OpenAPI文档描述后端接口文档也是重复劳动。从同一个user.json我让模板输出OpenAPI的schema片段{ SysUser: { type: object, properties: { id: { type: integer, format: int64, description: 主键 }, username: { type: string, description: 用户名 }, status: { type: integer, description: 状态 }, createTime: { type: string, format: date-time, description: 创建时间 } } } }这些片段可以直接合并进接口文档工程。执行一次t4文档的字段定义自动和实体保持同步再也不会出现“文档说字段叫userName代码里叫username”这种尴尬。7.3 前端TypeScript类型与API定义前端的重复劳动也可以省掉。你只需要换一个类型映射表long映射成numberstring映射成stringdate映射成Date模板主体几乎不用改export interface SysUser { id: number; username: string; email?: string; status?: number; createTime?: Date; }这已经是“自动生成代码”的另一个层次配置以一份为准后端Java、数据库DDL、前端接口类型全部从这里派生。团队约定一旦形成任何一个新表上线需要盯的就只有JSON配置是否正确剩下的交给模板。7.4 配置文件片段生成logback与application.yml最后提一个我顺手做的扩展。Spring Boot项目里每个模块基本都要复制一份差不多相同的logback.xml或者application.yml把这种配置文件片段也丢进JSON和模板体系里变化的部分能自动渲染静态部分保持不变。比如从项目清单JSON里读取moduleName和logLevel模板输出一个application.yml的公共配置片段mybatis: mapper-locations: classpath:mapper/*.xml logging: level: com.example.# moduleName #: # logLevel #这算是“JSON配置文件自动生成代码”的最后一个延伸本质上和生成Java类没有区别只是输出目标变成了不同格式的文本罢了。这套流程我用了快两年最深的体会是模板本身不重要重要的是把数据源先定干净。无论你用TT模板、FreeMarker还是任何模板引擎只要遵循“配置先于代码、一份数据源多处渲染”的原则重复劳动就会以肉眼可见的速度消失。最后分享一个我一直保留的习惯所有生成文件顶部都打上“本文件由模板生成请勿手工修改”并且把生成目录整体交给构建脚本清理重建。真要在生成代码上做扩展就写子类或包装类这样无论重新生成多少次都不会丢掉手写的心血。
返回列表