ARTICLE DETAIL

资讯详情

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

基于模板驱动的自定义代码生成器实战:从表结构到分层代码一键生成

基于模板驱动的自定义代码生成器实战:从表结构到分层代码一键生成 1. 为什么我没直接用现成的代码生成器一次真实的选型复盘如果你在一家业务迭代很快的团队待过大概率经历过这种循环业务建一张表你就得在 Controller、Service、ServiceImpl、Mapper、Entity 五个文件之间来回切换字段抄一遍分页写一遍Swagger 注解补一遍终于跑通了下张表又来了。我那段时间一天能写三套一模一样的增删改查手指比脑子先动了。后来我实在受不了花了一周时间做了一个基于模板驱动的自定义代码生成器一句话概括就是读一遍表结构按模板把整套分层代码一次性生成出来。做之前我当然也看过市面上的工具。MyBatis Generator 是老牌选择PowerDesigner 也能导代码还有各类网页版在线生成器。它们解决“从无到有”是够用的但换到真实项目里我感受到三个比较实在的痛点。1.1 现成工具让我难受的三个点第一个是代码风格对不上团队规范。生成器默认的注释、文件头、包名规则、缩进风格跟你们组的 Code Style 大概率不一致。你可以配参数但配置项本身也是学习成本而且很多生成器的扩展点设计得并不舒服。第二个是生成范围太浅。大部分生成器能给你 Entity、Mapper但 Service 里的分页查询、逻辑删除、创建人/创建时间这些公共字段的填充还是得自己补。你想想这些公共逻辑恰恰是最重复的生成器不给你补你拿到手的代码还是要改三分之一。第三个是业务约定没法融进去。多租户、数据权限、状态机字段这些是你们项目的“私有规范”开源工具很难替你考虑。1.2 什么样的情况才值得自己写我判断的标准很朴素一张表从建表到跑通接口如果手写要四十分钟到一个小时而你的项目里还有三四十张表在排队就值得花一周时间做个生成器。反过来总共就五张表项目做完就交付了你用在线生成器导一把也能接受没必要自研。说白了自研代码生成器本质上是在投资自己的重复劳动时间只有当重复量大到足够摊薄开发成本时才划算。另外还有一层隐性收益生成器会成为团队规范的“强制落地工具”。只要模板是对的任何人用它生成出来的代码都是符合规范的新同学也不用逐行学代码风格。这一点在团队协作里价值很大比单纯省时间更值钱。1.3 我把目标先限定得很窄为了避免一上来就陷入“什么都想生成”的泥潭我把范围卡得很死只做 MySQL 单表场景只生成 Controller、Service、ServiceImpl、Mapper、MapperXML、Entity 这几层不处理多表联查不处理存储过程不生成前端页面。目标就一句话一张表从零到可跑通的增删改查加分页五分钟内完成。范围收窄之后后面的模板设计和代码实现会轻松很多这也是我给想自研的人的第一个建议。2. 第一件事不是写模板而是把表结构读干净很多人一上来就去找 FreeMarker 语法写 Entity 模板结果卡在“模板里的数据从哪来”。代码生成器的数据源头是数据库的表结构这一步搞干净了后面所有模板就是纯渲染。2.1 用 JDBC 元数据接口拿表信息Java 里读表结构最直接的方式是 JDBC 的 DatabaseMetaData不用自己拼 SQL 查 information_schema。下面这个方法片段基本够用DatabaseMetaData metaData connection.getMetaData(); // 读取所有表 ResultSet tables metaData.getTables(null, null, %, new String[]{TABLE}); // 读取指定表的列 ResultSet columns metaData.getColumns(null, null, tableName, %); // 读取主键 ResultSet pk metaData.getPrimaryKeys(null, null, tableName);需要注意一个细节MySQL 连接串上要加useInformationSchematrue否则 DatabaseMetaData 走的是 show create table 的路径在并发场景下性能差而且部分类型信息拿不全。我一开始没加这个参数读 30 张表要好几秒加上之后秒级。另外 getColumns 返回的TYPE_NAME是数据库的 JDBC 类型名后面做类型映射时要用到它最好把类型代码DATA_TYPE也一起拿了有些驱动下二者并不完全等价。2.2 元数据模型TableMeta 和 ColumnMeta我定义了两个很普通但很关键的模型类。TableMeta 描述“一张表”ColumnMeta 描述“一个字段”public class TableMeta { private String tableName; // user_info private String className; // UserInfo private String classComment; // 用户信息表 private ListColumnMeta columns; private ListString pkNames; } public class ColumnMeta { private String columnName; // user_name private String fieldName; // userName private String jdbcType; // VARCHAR private String javaType; // String private String comment; // 用户名 private boolean nullable; private boolean primaryKey; private boolean autoIncrement; }这个模型的价值在于模板里需要什么它就提供什么。注释、主键、自增、可空这些属性会分别决定生成的注解、校验规则和类型。我强烈建议在建表时把 COMMENT 写完整生成器会把 COMMENT 直接填成 Java 代码里的注释、Swagger 描述和接口文档。很多项目前期偷懒不写注释生成出来的代码就没有注释这套工具的体验直接打对折。2.3 下划线转驼峰与表名清洗数据库列名一般是 user_nameJava 字段名是 userName。转换逻辑不复杂逐字符扫描下划线后大写下一字符就行。但有几个坑表名带前缀 t_、sys_ 之类生成类名时要剥掉列名尾部有个下划线的情况我也遇过要处理还有连续下划线。我单独抽了一个 NameUtils 工具类里面放 toCamelCase、stripPrefix、toPascalCase 这几个静态方法模板里所有命名都走它。这也是模板驱动方案里比较容易被低估的部分命名策略统一了生成代码的规范性才有保障。3. 模板引擎选型模板工程怎么组织才不会乱模板驱动核心就是模板引擎。我用的是 FreeMarker但这个问题早点定下来别写了一半再换。3.1 为什么是 FreeMarker 而不是 Velocity 或手写拼接手写字符串拼接是最傻的方案改一行模板就要改代码重新编译完全失去了“模板驱动”的意义。Velocity 确实更轻但 FreeMarker 对生成代码场景有几个很顺手的内建能力?cap_first把一个字符串首字母大写?uncap_first首字母小写?replace做字符替换?default()处理空值。这些在代码生成里天天用省掉大量在 Java 里做字符串处理的代码。而且 FreeMarker 的#list、#if指令对嵌套循环支持很成熟生成 XML、Java 都很自然。如果你不是 Java 技术栈思路是一样的选一个自带“大小写转换、空值兜底、循环分支”能力的模板引擎都行。真正要注意的只有一点别把模板写得太散太碎否则后面维护模板的人会骂你。3.2 模板目录结构我的模板工程长这样templates/ ├── entity.java.ftl ├── mapper.java.ftl ├── mapper.xml.ftl ├── service.java.ftl ├── serviceImpl.java.ftl └── controller.java.ftl每个模板对应一个输出文件这是一个重要的约束不要在同一个模板里生成多个文件。有些人图省事想在一个 ftl 里循环输出 Controller 和 Service最后文件管理和覆盖策略都会很别扭。模板文件名里带上输出文件类型比如.java.ftl、.xml.ftl走查目录时一眼就知道哪个模板对应哪层。模板文件的包路径与输出路径也提前定好。FreeMarker 配置里我会把templates设为 classpath 根路径Java 里通过 TemplateLoader 加载。输出路径则完全由配置文件控制模板不感知绝对路径——这是为了以后把模板工程独立出去留着余地。3.3 模板调试的两个心得第一个是 FreeMarker 报错的行号和模板真正出错的行对不上尤其是标签嵌套多的时候。我的习惯是先拿一张空表测模板也就是没有任何字段的表结构跑一遍渲染确认骨架没问题再加字段看细节。第二个是空白行控制。#if标签单独占一行时渲染结果里会留下空行生成的 Java 代码全是一行空一行很丑。解决办法是集中在一个地方配置 whitespace stripping或者在标签后不加多余换行。我更推荐前者因为后者会让模板代码挤成一团可读性很差。4. 分层代码模板的落地骨架我这么写的模板引擎定了下面就是这场戏的主角。我按分层架构从 Entity 到 Controller 逐个说代码片段都是核心思路完整模板根据你自己的规范改。4.1 Entity 模板Entity 是其它层的数据基础模板里除了字段就是注解。我用的技术栈是 Spring Boot MyBatis-Plus Lombok Swagger所以字段上有三类注解TableName标记表名TableId/TableField标记主键和字段ApiModelProperty生成接口文档。模板写起来大概是下面这样package ${packageName}.entity; ... TableName(${table.tableName}) Data public class ${table.className} { #list table.columns as column /** ${column.comment} */ ApiModelProperty(value ${column.comment}) #if column.primaryKey TableId(value ${column.columnName}, type IdType.AUTO) #else TableField(${column.columnName}) /#if private ${column.javaType} ${column.fieldName}; /#list }一个经验主键生成策略不要写死在模板里。有的表是自增主键有的是雪花 ID有的是分布式 ID。我用一个 per-table 的idType配置项控制模板里只是type ${table.idType}这样一张表一套配置不会出现“主键策略全错了”的尴尬。4.2 Mapper 与 Mapper XML 模板Mapper 接口很简单继承 MyBatis-Plus 的 BaseMapper 就够了几乎没有自己的方法。真正的工作在 XML 里。虽然 MyBatis-Plus 的 QueryWrapper 能解决大部分查询但团队里有人还是习惯 XML而且复杂查询最终也绕不开 XML所以 XML 我照样生成。XML 里最核心的是通用查询条件也就是把每个字段变成一个if标签模板如下where #list table.columns as column if test${column.fieldName} ! null and ${column.columnName} #{${column.fieldName}} /if /#list /where这段模板生成了之后分页查询、列表查询的 where 条件会自动带上所有非空字段。当然这种“全字段等值匹配”只适合简单场景一旦有范围查询、模糊查询建议在 Service 层手工补充。模板负责给你一个能跑的骨架别指望它覆盖所有业务查询。4.3 Service 与 ServiceImpl 模板Service 接口可以直接继承IServiceT省去大量基础方法声明。ServiceImpl 里我额外生成一个分页查询方法因为它太常用了public PageResult${table.className} page(${table.className}Query query) { LambdaQueryWrapper${table.className} wrapper new LambdaQueryWrapper(); #list table.columns as column #if column.javaType String wrapper.like(StringUtils.hasText(query.get${column.fieldName?cap_first}()), ${table.className}::get${column.fieldName?cap_first}, query.get${column.fieldName?cap_first}()); #else wrapper.eq(query.get${column.fieldName?cap_first}() ! null, ${table.className}::get${column.fieldName?cap_first}, query.get${column.fieldName?cap_first}()); /#if /#list Page${table.className} page baseMapper.selectPage(Page.of(query.getPageNum(), query.getPageSize()), wrapper); return PageResult.of(page); }这里要特别提醒的是公共字段处理。逻辑删除、创建人、创建时间、更新时间、租户 ID 这类字段几乎每张表都有。一定要在模板里针对性地处理比如逻辑删除字段在新增时强制填默认值更新时排除创建时间用数据库默认值而不是应用层赋值。如果你的模板没处理这些生成出来的代码跑不了几次就会出数据问题。这也是我反复强调“现成生成器不帮你补公共逻辑”的原因。4.4 Controller 模板Controller 模板我最喜欢因为它几乎不变。一套标准的 RESTful 接口GET /{id}查详情POST新增PUT更新DELETE /{id}删除POST /page分页。模板里只需要把统一返回体RT和参数校验注解拼好PostMapping public RBoolean create(RequestBody Valid ${table.className} entity) { return R.ok(${table.className}Service.save(entity)); }Controller 层生成的价值在于它保证了所有接口的最外层结构完全一致。对前端同学来说这意味着所有页面的请求方式、返回结构、错误码风格都是同一套联调成本会低很多。4.5 为什么 DTO/VO 不默认生成有些生成器默认把 DTO、VO、Convert 全部生成出来文件数量翻倍。我试用之后发现简单内部系统里 Entity 直接作为入参和出参完全够用生成 DTO 反而增加字段映射成本和模板维护量。所以我的默认配置只生成 Entity、Mapper、XML、Service、ServiceImpl、Controller 六类文件DTO/VO 用一个开关控制按需开启。这个取舍不绝对如果你的项目是前后端分离且对外提供 APIDTO/VO 是逃不掉的那就把开关打开。5. 类型映射与命名策略最容易翻车但我翻过的地方这一章可能没有模板那么有“生成感”但实际开发中 80% 的报错都出在类型映射上。5.1 MySQL 到 Java 的类型对照我维护了一张映射表核心内容如下MySQL 之外的其他数据库可以照这个思路扩展MySQL/JDBC 类型Java 类型备注VARCHAR / CHAR / TEXT / LONGTEXTString文本类无脑映射 StringINT / INTEGERIntegerBIGINTLong雪花 ID 和自增主键一般用 LongTINYINT / SMALLINTInteger布尔见下方特殊处理DECIMAL / NUMERICBigDecimal金额字段必须用 BigDecimalDATELocalDateDATETIME / TIMESTAMPLocalDateTimeBITBooleanBLOB / VARBINARYbyte[]文件二进制字段再多想想这个表不是拍脑袋列出来的每一行都对应实际开发里踩过的问题。比如金额字段用 Float 会把精度算丢日期字段用 java.util.Date 在 JSON 序列化和 MyBatis 参数绑定上都不如 LocalDateTime 顺手。类型映射做对的唯一标准是生成的代码编译不报类型错、序列化不炸、精度不丢。5.2 tinyint(1)、decimal 精度和 blob 的三种特殊情况第一个坑是 tinyint(1)。MySQL 中 tinyint(1) 常被用来表示布尔值但很多业务用它存状态码比如 0 和 1 之外还有 2、3。如果映射成 Boolean状态值就会丢失。我的做法是默认tinyint(1) - Boolean然后在配置文件里允许针对具体字段覆盖为 Integer比如状态字段。第二个坑是 decimal。decimal(10,0)在 Java 里映射 BigDecimal 虽然对但 JSON 序列化后可能变成198.00这种带小数点的字符串前端处理麻烦。如果团队确认不会出现小数部分可以配置映射成 Long。第三个坑是 BLOB 字段默认 byte[] 没毛病但它没法直接被 JSON 序列化生成代码时我会在模板里跳过这类字段的入参输出避免报错。5.3 保留字和特殊列名的处理方式列名是 order、desc、group 这种 MySQL 保留字时MyBatis 生成的 SQL 里必须加反引号否则直接语法错误。处理办法两个一是在建表规范里强制避免保留字二是在模板的 SQL 片段里统一给列名加反引号。我一开始只在 where 条件里加了反引号结果 select 列表里也炸了后来干脆所有列名输出都走同一个col()函数统一处理不再纠结哪里有坑。命名策略这里还有一个容易被忽略的点布尔字段如果是is_deleted、is_activeLombok 生成的 getter 是isDeleted()、isActive()但 MyBatis-Plus 的实体映射有时会出现字段找不到的问题。解决方式是在模板里对 Boolean 类型的字段名统一处理成普通名称或者在建表规范中避免 is_ 前缀。这个属于小概率但隐蔽的问题遇到了会排查好久提前在模板层处理掉最省心。6. 一键生成的执行链从配置文件到文件落盘模板和元数据都准备好了最后把它们串起来的就是生成器主程序。6.1 一份够用的配置文件我用了最简单的 YAML 配置关键字段都在注释里datasource: url: jdbc:mysql://localhost:3306/business?useInformationSchematrue username: root password: xxxxxx generator: packageRoot: com.example.project outputPath: ../project-business/src/main/java/ mapperXmlPath: ../project-business/src/main/resources/mapper/ tables: - user_info - order_info ignoreTablePrefix: - t_ idType: ASSIGN_ID配置里tables如果不填默认生成全部表ignoreTablePrefix用于清洗表名idType是整个项目的默认主键策略个别表可以在一个tableOverrides列表里单独覆盖。文件输出路径和包名拆成两个配置是为了处理多模块项目——包根路径是com.example.project但代码可能要输出到project-business、project-admin多个模块的不同目录。对于多模块项目我再补充一句每个模块单独配一个 output 路径而不是在一份配置里写死否则后续改模块结构时配置文件会变得极难维护。6.2 生成主流程生成器的核心代码其实很少逻辑可以浓缩成一段伪代码ListTableMeta tables metadataReader.readTables(config.getTables()); for (TableMeta table : tables) { MapString, Object model DataModelBuilder.build(table, config); for (TemplateItem item : config.getTemplateItems()) { String text freeMarker.render(item.getTemplate(), model); Path target resolveOutputPath(item, table, config); writeWithBackup(target, text); } }整个流程就四步读配置、取元数据、组装数据模型、渲染并写文件。DataModelBuilder是最值得花时间的组件它把 TableMeta、配置信息、类型映射、命名策略全部合并成模板能直接消费的 Map模板里的${table.className}、${column.javaType}都是从这里来的。模型组装得越干净模板写起来就越像填空。文件落盘时我加了一个writeWithBackup方法如果目标文件已存在先把旧文件改名成.bak再写新文件。这样做不是为了自动合并而是为了逼自己走一次 git diff确认生成的改动是有意为之。生成器最危险的一次事故就是覆盖了我手改过的 ServiceImpl从那以后我再也不允许生成器直接覆盖同名文件。6.3 生成后必须做的三件事代码生成不是跑完就算完我每次生成之后固定做三件事。第一编译一次。类型映射、模板语法如果出了问题编译是最快的照妖镜报错信息直接指向问题文件。第二统一格式化。不要在模板里纠结空格和换行生成完用 Spotless 或者团队的 formatter 跑一遍格式交给工具处理。第三冒烟一个接口。挑一张表用刚生成的代码启动服务跑一次分页和详情接口确认数据库连接、字段映射、JSON 序列化都没问题。这三件事都通过说明这批生成代码基本可以合并了。7. 实战中踩过的坑和现在的取舍最后聊聊真实的血泪账。工具能跑通之前我反反复复试了很多轮有几个坑值得记下来。7.1 坑一模板里的公共字段处理不到位生成代码带上脏数据最开始我的模板把所有字段无视逻辑删除直接 select 出来。某次联调发现删除接口调用之后数据还能查出来。排查到最后发现逻辑删除字段既没有被 Service 层的删除逻辑排除也没有在查询条件里带上等于逻辑删除是假的。从那以后我在模板里增加了一套“公共字段规则”凡是字段名命中了deleted、create_time、update_time、tenant_id这类规则自动在对应层生成补充逻辑。这个规则列表放在配置文件里团队可以自己维护。7.2 坑二列名特殊字符把模板搞崩数据库里有一列的注释写的是“生效时间xx 平台”里面带了半角括号和特殊符号结果 FreeMarker 渲染时直接抛异常。排查后发现是注释内容进入了模板的${column.comment}位置特殊字符干扰了解析。解决方案比较简单在 DataModelBuilder 里对注释做一次清洗去掉换行和可能破坏模板结构的字符再输出。这个坑提醒我一个道理任何数据库内容在进模板之前都要当成“不可信输入”处理。7.3 坑三主键策略写死在模板里换一种 ID 方案就得改模板前面已经提过一次但值得展开说。我第一版模板写的是TableId(type IdType.AUTO)自增主键的表跑得挺好。后来遇到一个老系统主键是应用层生成的雪花 ID这批表生成出来全部报错。当时我面临两个选择给所有生成代码手动改注解或者在模板里引入配置。后来我把idType变成了每张表可配置的字段默认读全局配置个别表覆盖。从那以后我再也没有因为主键策略返工这个教训就是模板里越通用的地方越不应该写死。7.4 现在的取舍代码生成器做到现在我的原则可以归纳成三句话元数据只抽必要信息模板只保留稳定的骨架配置只暴露需要变化的点。这套东西进一步扩展的方向我也在考虑从表结构变更自动生成 ALTER 脚本、生成接口文档的 Markdown、根据 Entity 反向生成建表 SQL、甚至做成 IDEA 插件让生成动作在编辑器里一键触发。每一种扩展都是在原有模板驱动思路上的自然延伸核心骨架不用动。我回看整个实践最有价值的不是那些生成出来的代码而是把团队规范变成了可执行的模板。如果你也在为几十张表的重复分层代码头疼我建议你先列一张自己项目里最常用的三张表的公共结构把它们抽成模板跑通一次生成流程你会明显感觉到这套思路的实用性。
返回列表