ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

若依3.8.7持久层改造:MyBatis-Plus与代码生成器迁移

若依3.8.7持久层改造:MyBatis-Plus与代码生成器迁移

若依 3.8.7 前后端分离版这套底子,我前后接手过好几个基于它二次开发的项目,前端 Vue 那套基本没人抱怨,真正让人难受的是持久层:单表 CRUD 全靠手写 XML,一个十几张表的业务模块光 Mapper 文件就能堆出半屏,字段一改就得同步改 XML、改实体、改 Service,漏一处就 500。这次我把整套持久层换成 MyBatis-Plus,顺手把自带的 Velocity 代码生成器一起改成 Plus 版本,让新生成的代码天生带 Wrapper、分页和自动填充。这篇就按我实际动手的顺序讲:依赖怎么换、配置怎么迁、分页和审计字段怎么接管、生成器模板怎么一段一段改,以及我踩过的那些坑。适合手上正压着一个若依二开项目、想上 MP 又怕改崩的同行,也适合刚接触这套脚手架、想知道它持久层长什么样的朋友。

1. 这套改造到底在改什么:先看清骨架再动手

动框架之前我习惯先把要改的东西画清楚,不然很容易改着改着发现牵一发动全身。若依 3.8.7 的持久层不是简单地"用了 MyBatis",而是围绕 MyBatis 做了一整套封装,包括 SqlSessionFactory 的手工装配、PageHelper 分页、基于 Velocity 的代码生成模板。这三块是咬合在一起的,只换其中一块,另外两块大概率会给你脸色看。

1.1 若依 3.8.7 持久层的四层结构拆解

我把它拆成四层来看。最底层是mybatis-spring-boot-starter,提供 SqlSessionFactory 和 Mapper 扫描;往上一层是MyBatisConfig配置类,手工构建SqlSessionFactoryBean,从application.yml里读typeAliasesPackage、mapperLocations、configLocation三个参数塞进去;再往上是com.ruoyi.common.core.mapper.BaseMapper这个占位接口,所有业务 Mapper 都继承它,它本身几乎是个空壳,只挂了@Mapper语义;最上面是代码生成器,用 Velocity 模板按数据库表结构生成 domain、mapper、service、serviceImpl、controller 五件套加一份菜单 SQL。

理解这个结构之后,改造路径就清楚了:把 starter 换掉、把MyBatisConfig里的工厂类型换掉、把占位 BaseMapper 升级成 MP 的 BaseMapper、把模板文件里的注解和继承关系换掉。四步对应四个层次,一层一层来,中间任何一层没对齐,下一层就会报错。

这里有个容易被忽略的点:若依在common模块里放的那个BaseMapper是个绝佳的"改造锚点"。因为所有业务 Mapper 都继承了它,你只要让它extends com.baomidou.mybatisplus.core.mapper.BaseMapper<T>,全项目所有 Mapper 瞬间获得 MP 的selectPage、selectList(Wrapper)能力,业务代码一行不用改。这个技巧比逐个改业务 Mapper 的继承关系要省事得多,也不容易漏。

1.2 三条改造路线的成本对比

我评估过三种走法,最后选了第三种,这里把账算给你看。

路线具体做法改造量遗留风险我的评价
路线一:并存保留原生 MyBatis,只在新模块引入 MP极小两套 SqlSessionFactory 争抢数据源配置,事务边界模糊不推荐,后期排查成本极高
路线二:渐进替换老模块保留 XML,新模块用 MP中等pagehelper 与 MP 分页插件同时存在,startPage()后紧跟的 MP 查询会被二次分页可接受,但必须严格约定"一个方法里只用一种分页"
路线三:整体替换移除 pagehelper,分页统一走 MP较大,集中在 controller 层老代码的分页调用要逐处改推荐,改完一次痛苦,之后一直舒服

选路线三的核心理由是分页冲突。PageHelper 的原理是在Executor层挂 Interceptor,startPage()把分页参数丢进 ThreadLocal,紧接着执行的第一个 SQL 被改写并自动取总数。MP 的PaginationInnerInterceptor也是干这个的,而且它还有一个"分页参数上下文"机制。两个插件同时挂着,你调startPage()之后再执行baseMapper.selectPage(...),前者会拦截后者生成的 SQL 再加一次LIMIT,结果就是LIMIT ?,? LIMIT ?,?这种明显不对的语句,或者总数对不上。这个坑我在过渡期踩过一次,排查了半天,最后发现是插件双注册。

所以我的建议很明确:如果你决定上 MP,就一次性把 pagehelper 依赖摘掉,分页全部迁到 MP。改造量看起来大,但若依的分页调用其实高度集中——每个list接口开头一行startPage(),结尾一行getDataTable(list),全项目扫一遍大概几十处,半天能改完。

1.3 版本边界与不能动的地方

版本要对齐,不然依赖冲突会让你怀疑人生。我这边实测可用的组合是:JDK 8、Spring Boot 2.5.x(若依 3.8.7 自带)、mybatis-plus-boot-starter3.5.3.2。选 3.5.3.x 而不是最新的 3.5.5+,是因为 3.5.4 之后对mybatis-spring的版本要求抬到了 2.1.x,和 Spring Boot 2.5 默认带的 2.0.x 会对不上,强行升级可能波及 Spring 的事务代理。3.5.3.2 依赖mybatis-spring 2.0.7,和 Spring Boot 2.5 的组合是稳的。

有几处我建议你不要动:application.yml里的mapperLocations路径结构保持原样,业务 XML 文件不用挪位置;@MapperScan的扫描范围保持原样;mybatis-config.xml文件保留,MP 用的也是 MyBatis 的原生配置文件格式,里面的useGeneratedKeys、callSettersOnNulls这些 setting 依然有效。改的东西越少,出问题时回溯越容易。

2. 依赖替换与全局配置迁移

这一层是地基,改完必须能正常启动、正常连库,否则后面全是空谈。我的习惯是改完立刻写一个最简单的selectById单测跑通,确认工厂装配没问题再往下走。

2.1 pom 里到底要删什么、加什么

先看父 pom 的dependencyManagement,找到 mybatis 相关的那一段,把 starter 换掉:

<!-- 原来的 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>${mybatis.version}</version> </dependency> <!-- 换成 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency>

在ruoyi-common或者ruoyi-framework模块里,把pagehelper-spring-boot-starter的依赖删掉或者注释掉。这一步别心软,留着它就会在启动时自动注册PageInterceptor,和 MP 的分页插件打架。

删除之后一定会编译报错,因为BaseController.getDataTable()里用到了com.github.pagehelper.PageInfo,PageUtils.startPage()也引用了 PageHelper 的类。这两处我先不急着改,留着让编译器帮我定位所有依赖点——编译器报错的位置,就是你要改造的全部位置,比人工搜索靠谱。

有一个冲突要特别注意:如果你的项目里还有其他 starter 间接引入了mybatis-spring-boot-starter,比如某些数据源组件、某些分页组件,启动时会出现"两个 SqlSessionFactory"或者"Mapper 重复注册"的报错。用mvn dependency:tree -Dincludes=org.mybatis*查一下,把间接依赖排除掉:

<exclusions> <exclusion> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </exclusion> </exclusions>

2.2 application.yml 配置项逐条对照

若依原来的配置在mybatis:前缀下,MP 换成了mybatis-plus:。注意不是所有配置项都能直接平移,有些键名变了。我把对照关系整理成表格,你照着改就行。

原配置项新配置项说明
mybatis.typeAliasesPackagemybatis-plus.typeAliasesPackage通配符**依然支持,不用改值
mybatis.mapperLocationsmybatis-plus.mapperLocations路径保持classpath*:mapper/**/*Mapper.xml
mybatis.configLocationmybatis-plus.configLocation指向mybatis/mybatis-config.xml
mybatis.configuration.*mybatis-plus.configuration.*驼峰映射、缓存开关等
无mybatis-plus.global-config.db-config.id-type新增,主键策略
无mybatis-plus.global-config.banner新增,关掉启动横幅

改完大概是这个样子:

mybatis-plus: typeAliasesPackage: com.ruoyi.**.domain mapperLocations: classpath*:mapper/**/*Mapper.xml configLocation: classpath:mybatis/mybatis-config.xml global-config: banner: false db-config: id-type: auto table-underline: true configuration: map-underscore-to-camel-case: true cache-enabled: false call-setters-on-nulls: true

id-type: auto这个必须设。MP 默认主键策略是ASSIGN_ID(雪花算法),而若依的建表脚本绝大多数主键是bigint auto_increment。不设这一项,你插入数据时会发现主键变成了一串十九位的长数字,数据库自增序列完全没用上,而且前端 JS 处理这么大整数还会丢精度。这个问题极其隐蔽——插入不报错,查询不报错,直到某天发现在前端搜不到某条记录才炸出来。

日志级别也顺手加一下,方便看 SQL:

logging: level: com.ruoyi: debug mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

2.3 MyBatisConfig 的工厂类型替换

这是整个改造里最关键的一处代码。原来的MyBatisConfig用的是SqlSessionFactoryBean,必须换成MybatisSqlSessionFactoryBean。这两者名字只差几个字,但后者是 MP 的核心——MP 的GlobalConfig、SqlInjector、各种 InnerInterceptor 都挂在它上面,用错类型,插件全都不生效。

@Configuration public class MyBatisConfig { @Autowired private Environment env; @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件,必须指定数据库类型,否则分页方言判断不出来 PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(1000L); // 单页最大条数,防止前端传个超大值拖垮库 pagination.setOverflow(false); // 页码越界时不回到首页,直接返回空 interceptor.addInnerInterceptor(pagination); // 防全表更新与删除,放在最后 interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; } @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); String typeAliasesPackage = env.getProperty("mybatis-plus.typeAliasesPackage"); String mapperLocations = env.getProperty("mybatis-plus.mapperLocations"); String configLocation = env.getProperty("mybatis-plus.configLocation"); if (StringUtils.isNotEmpty(typeAliasesPackage)) { factory.setTypeAliasesPackage(typeAliasesPackage); } if (StringUtils.isNotEmpty(mapperLocations)) { factory.setMapperLocations( new PathMatchingResourcePatternResolver().getResources(mapperLocations)); } if (StringUtils.isNotEmpty(configLocation)) { factory.setConfigLocation(new DefaultResourceLoader().getResource(configLocation)); } GlobalConfig globalConfig = new GlobalConfig(); globalConfig.setBanner(false); factory.setGlobalConfig(globalConfig); factory.setPlugins(mybatisPlusInterceptor()); return factory.getObject(); } }

三个细节值得单独说。第一,setPlugins()和setGlobalConfig()必须在getObject()之前调用,getObject()一旦执行,工厂就构建完了,之后再 set 什么都是无效的。我见过有人把 set 写在 return 之后的位置,编译没问题,运行不报错,就是插件不生效,非常难受。

第二,PaginationInnerInterceptor的构造参数DbType.MYSQL不能省。省掉之后 MP 会尝试从 JDBC 连接里猜数据库类型,多数情况能猜对,但在多数据源场景下会猜错,表现为分页 SQL 用了 PostgreSQL 的LIMIT x OFFSET y语法去 MySQL 上跑。

第三,插件顺序有讲究。MP 官方建议的顺序是:多租户/动态表名 → 分页/乐观锁 → 防全表操作。如果你后面还要加多租户插件,记得插在分页前面,否则分页插件先改写了 SQL,多租户插件再去找表名就找不到了。

2.4 启动自检:怎么确认插件真的生效了

改完别急着往下走,先验证。我一般做三个检查。

第一个,启动日志里不应该再出现 PageHelper 的 banner(如果还有,说明依赖没删干净)。第二个,随便找个已有接口调用一次,打开 debug 日志看输出的 SQL,如果带LIMIT就说明分页插件在跑。第三个,写个临时接口测试BlockAttackInnerInterceptor:执行一次不带 where 条件的update,正常应该抛出Prohibition of table update operation异常。这个异常出现,说明防全表插件生效了,可以放心。

这里补一句个人经验:BlockAttackInnerInterceptor上线前一定要确认业务代码里没有"全表更新"的合法场景,比如定时任务里批量刷某个状态字段、数据修复脚本。我遇到过一个数据迁移脚本用了update xxx set status = 1 where 1 = 1,改造后被直接拦掉,脚本静默失败,跑了三天才发现数据没变。这类脚本记得加真实条件或者临时关掉插件。

3. 框架能力对齐:分页、填充、主键、逻辑删除

地基打好之后,要把若依原来依赖 MyBatis 实现的那几项通用能力,一一对接到 MP 上。这一步做不干净,后面代码生成器生成的代码就跑不通。

3.1 分页:PageHelper 与 MP 分页的迁移策略

前面说了要统一到 MP 分页,具体怎么改?若依的典型写法是这样:

@GetMapping("/list") public TableDataInfo list(SysUser user) { startPage(); List<SysUser> list = userService.selectUserList(user); return getDataTable(list); }

startPage()从请求里解析pageNum、pageSize、orderByColumn、isAsc,塞给 PageHelper;getDataTable()把List包装成PageInfo再取总数。改到 MP 之后,这两端都要动。

服务层这边,让 Service 接口继承IService<T>,实现类继承ServiceImpl<Mapper, T>,分页方法直接调page():

Page<SysUser> page = userService.page( new Page<>(pageNum, pageSize), new LambdaQueryWrapper<SysUser>() .like(StringUtils.isNotEmpty(user.getUserName()), SysUser::getUserName, user.getUserName()) .eq(user.getStatus() != null, SysUser::getStatus, user.getStatus()) .orderByDesc(SysUser::getCreateTime) );

控制层这边,getDataTable()需要改造成能识别IPage:

protected TableDataInfo getDataTable(List<?> list) { TableDataInfo rspData = new TableDataInfo(); rspData.setCode(HttpStatus.SUCCESS); rspData.setMsg("查询成功"); rspData.setRows(list); rspData.setTotal(list instanceof IPage ? ((IPage<?>) list).getTotal() : list.size()); return rspData; }

这里有个小坑:MP 的page()返回的是IPage<T>,getRecords()才是真正的数据列表。如果你直接把IPage塞给getDataTable,前端表格会渲染成空白——因为rows里放的是一个分页对象而不是数组。我建议要么在 controller 里传page.getRecords()并把 total 单独传,要么就像上面这样让getDataTable内部判断类型。我倾向后者,接口签名统一,调用方无感。

关于排序参数,若依前端传的是orderByColumn+isAsc,值是数据库列名的驼峰形式,MP 的 Wrapper 需要的是实体方法引用,两者对不上。我的处理是写一个小工具,把前端传来的字段名转成LambdaQueryWrapper的orderBy字符串形式,同时做一层白名单校验——只允许排序列在预设集合里,防止前端传个不存在的列导致 SQL 报错,或者更糟的情况被拼接进去。这层白名单别省,虽然若依自己有SqlUtil.escapeOrderBySql做转义,但换成 MP 的字符串排序后这层防护就丢了。

3.2 审计字段自动填充:MetaObjectHandler 接管

若依的BaseEntity里有一组公共字段:createBy、createTime、updateBy、updateTime、remark、params、searchValue。原来这些是在ServiceImpl里手工 set 的,MP 有自动填充机制,可以彻底省掉这步。

先给BaseEntity的字段加注解:

public class BaseEntity implements Serializable { @TableField(exist = false) private String searchValue; @TableField(fill = FieldFill.INSERT) private String createBy; @TableField(fill = FieldFill.INSERT) private Date createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private String updateBy; @TableField(fill = FieldFill.INSERT_UPDATE) private Date updateTime; private String remark; @TableField(exist = false) private Map<String, Object> params; }

@TableField(exist = false)这两处是必须的,不加会直接报错:params是Map类型,searchValue在表里根本没有对应列,MP 在拼接 SQL 时会尝试把它们当列名拼进去,报"Unknown column 'params'"。这个错误我在第一次改造时遇到过,报错信息不太直观,得看完整的 SQL 才能定位。除了这两个,实体子类里那些children、parentName、ancestors之类的非表字段也要挨个加,或者更省事的方式是继承一个专门用于 VO 的父类。

然后是填充器:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", Date.class, new Date()); this.strictInsertFill(metaObject, "updateTime", Date.class, new Date()); try { String username = SecurityUtils.getUsername(); this.strictInsertFill(metaObject, "createBy", String.class, username); this.strictInsertFill(metaObject, "updateBy", String.class, username); } catch (Exception e) { // 无登录上下文的场景,比如定时任务、数据初始化 } } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date()); try { this.strictUpdateFill(metaObject, "updateBy", String.class, SecurityUtils.getUsername()); } catch (Exception e) { } } }

SecurityUtils.getUsername()外面必须包 try-catch。若依用SecurityContextHolder拿当前登录用户,在定时任务、MQ 消费、单元测试这些没有请求上下文的场景里会抛NullPointerException。我第一版没包,结果定时任务一跑就报错,日志里一堆堆栈,排查了半天才发现是填充器的问题。

还有一个细节:strictInsertFill的"严格"体现在它只在字段有@TableField(fill = ...)注解时才填充,而且如果字段已经有值就不覆盖。这个行为大部分时候是对的——手工指定了createTime就不该被覆盖。但如果你希望强制覆盖,用setFieldValByName()代替。

3.3 主键策略与 IdType 的选择

全局配置里设了id-type: auto,但代码生成器生成的实体最好还是显式标注,原因有两个:一是明确,读代码的人一眼知道主键怎么来的;二是某些表可能确实需要用雪花 ID 或者 UUID,全局配置一刀切不合适。

@TableId(value = "user_id", type = IdType.AUTO) private Long userId;

IdType的选项里,AUTO对应数据库自增,ASSIGN_ID对应雪花算法(19 位 Long),ASSIGN_UUID对应 32 位字符串。若依的建表脚本清一色bigint(20) auto_increment,所以默认用AUTO。如果你的项目里有分库分表规划,主键就不能靠数据库自增了,得换成ASSIGN_ID,同时注意前端 JS 的精度问题——Long 类型的雪花 ID 超过 2^53 之后在 JS 里会丢精度,需要在序列化时把 Long 转成 String。

这个精度问题很典型。表现是:列表页看着正常,点进详情却 404,因为前端拿着丢了精度的 ID 去查,自然查不到。解决办法是在Jackson的全局配置里把 Long 序列化成 String,或者在实体字段上加@JsonSerialize(using = ToStringSerializer.class)。若依默认没有这个问题(因为它用的是自增 ID,位数少),但一旦切雪花 ID 就会冒出来。

3.4 逻辑删除与若依 status 字段的边界

MP 的逻辑删除很方便,加个@TableLogic注解,deleteById()会自动变成update set del_flag = 1,查询自动带上del_flag = 0。但我建议你在若依项目里慎用。

原因在于若依多数业务表压根没有del_flag字段,删除就是物理删除。如果你在全局配置里写了logic-delete-field: delFlag,MP 会给所有实体的所有查询自动拼上del_flag = 0条件,而那些表里没有这个列,直接报错。这个错误很隐蔽,因为报错信息是"Unknown column 'del_flag' in 'where clause'",但你翻遍实体类也找不到 del_flag 在哪定义的——它在全局配置里。

正确做法是:全局配置不设逻辑删除字段,需要逻辑删除的表单独在实体上加注解。

@TableLogic(value = "0", delval = "2") private String delFlag;

value和delval两个值要和你表里的实际取值对上。若依的sys_user表里del_flag是'0'正常、'2'删除,如果你照抄网上的示例写成0和1,会发现"已删除"的用户还能查出来。这种字段语义必须去翻建表脚本确认,不能想当然。

还有一点,逻辑删除生效后,selectById查不到已删除的数据,但数据库里还在。做数据统计、做关联查询的时候要特别注意,@TableLogic只对 MP 自动生成的 SQL 生效,手写 XML 里的 SQL 不受影响,该带的条件还得自己带。这个不一致性在多表关联查询里特别容易出事。

4. 代码生成器改造为 Plus 版本

这是整个项目里最有价值的一块。若依自带的生成器用的是原生 MyBatis 模板,生成的 Mapper 只有空壳,SQL 全靠 XML 里手写。改成 MP 版本之后,单表 CRUD 一行 XML 都不用写,Wrapper 直接拼条件,新模块的开发效率能翻倍。

4.1 先读懂原生成器的调用链

在动手改模板之前,得先知道生成器是怎么跑的。若依的生成器模块叫ruoyi-generator,核心链路是这样:GenController接收请求 →GenTableServiceImpl负责查询表结构、读取列信息 →GenUtils负责把数据库列类型映射成 Java 类型、构造GenTable和GenColumn对象 →VelocityInitializer初始化模板引擎 → 按GenUtils.getTemplatePath()返回的路径列表,逐个渲染vm目录下的模板文件 → 输出成 zip 包。

模板文件放在src/main/resources/vm/下,结构是:

vm/ ├── java/ │ ├── controller.java.vm │ ├── domain.java.vm │ ├── mapper.java.vm │ ├── service.java.vm │ └── serviceImpl.java.vm ├── sql/ │ └── sql.vm └── vue/ ├── index.vue.vm └── ...

改造范围就是java/目录下的五个文件,sql.vm和前端模板基本不用动。这一点很重要——意味着改造是可控的、可回滚的,改坏了把文件换回去就行。

在动手前,我建议你把原来的五个模板先备份一份,改的时候对照着看。Velocity 语法对格式敏感,比如#if和#end必须配对,$变量名拼错不会报错只会输出空白,多一个空行都可能影响生成的代码格式。备份能让你在改崩时快速回退。

4.2 模板文件逐个改造

domain.java.vm的改造重点是加表名注解和主键注解。原模板生成的实体继承BaseEntity,我们把@TableName和@TableId加上:

package ${packageName}.domain; #foreach ($import in $importList) import ${import}; #end import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; /** * ${functionName}对象 ${tableName} * * @author ${author} * @date ${datetime} */ @TableName("${tableName}") public class ${ClassName} extends BaseEntity { private static final long serialVersionUID = 1L; #foreach ($column in $columns) #if(!$column.superColumn && $column.javaField != "createBy" && $column.javaField != "createTime" && $column.javaField != "updateBy" && $column.javaField != "updateTime") /** ${column.columnComment} */ #if($column.pk) @TableId(value = "${column.columnName}", type = IdType.AUTO) #end private $column.javaType $column.javaField; #end #end

这里#if(!$column.superColumn ...)那段判断是关键。若依的GenColumn里有个superColumn标记,用来表示这个字段是否已经在BaseEntity里声明过了。原模板靠它跳过createBy这些字段,避免重复声明。改造时保留这个判断,另外把createBy、createTime这几个名字也显式排除一遍,双保险。

mapper.java.vm的改造最简单,因为前面已经把com.ruoyi.common.core.mapper.BaseMapper升级成了 MP 版本,模板里的继承关系一行都不用改。只需要在头部加个 import:

package ${packageName}.mapper; import ${packageName}.domain.${ClassName}; #if($table.sub) import ${packageName}.domain.${subClassName}; #end /** * ${functionName}Mapper接口 * * @author ${author} * @date ${datetime} */ public interface ${ClassName}Mapper extends BaseMapper<${ClassName}> { #if($table.sub) /** * 批量删除${subTable.functionName} */ public int delete${subClassName}By${subTableFkClassName}s(#{${subTableFkClassName}s}); #end }

如果你不想动 common 模块里的 BaseMapper(比如怕影响别的项目),那就改成显式 import MP 的接口,模板里写import com.baomidou.mybatisplus.core.mapper.BaseMapper;。两种方式都行,前者影响面广但一劳永逸,后者隔离性好但每个模板都要写。

service.java.vm让接口继承IService:

package ${packageName}.service; import java.util.List; import com.baomidou.mybatisplus.extension.service.IService; import ${packageName}.domain.${ClassName}; /** * ${functionName}Service接口 */ public interface I${ClassName}Service extends IService<${ClassName}> { /** * 查询${functionName}列表 */ public List<${ClassName}> select${ClassName}List(${ClassName} ${className}); #if($table.sub) public List<${subClassName}> select${subClassName}List(${subTableFkClassName}); #end }

继承IService之后,save、updateById、removeById、page、list、getById这些方法全部自带,不用自己声明。

serviceImpl.java.vm的改造有个取舍。原模板生成的实现类是@Service public class XxxServiceImpl implements IXxxService,内部注入 Mapper,手写每个方法。改成继承ServiceImpl<Mapper, Entity>之后,baseMapper属性可以直接用,原来的方法体基本不用改,只是把注入的xxxMapper换成baseMapper:

package ${packageName}.service.impl; import java.util.List; import org.springframework.stereotype.Service; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import ${packageName}.mapper.${ClassName}Mapper; import ${packageName}.domain.${ClassName}; import ${packageName}.service.I${ClassName}Service; /** * ${functionName}Service业务层处理 */ @Service public class ${ClassName}ServiceImpl extends ServiceImpl<${ClassName}Mapper, ${ClassName}> implements I${ClassName}Service { @Override public List<${ClassName}> select${ClassName}List(${ClassName} ${className}) { LambdaQueryWrapper<${ClassName}> wrapper = new LambdaQueryWrapper<>(); #foreach($column in $columns) #if($column.queryType == "EQ" && !$column.superColumn) wrapper.eq(${className}.get${column.capJavaField}() != null, ${ClassName}::get${column.capJavaField}, ${className}.get${column.capJavaField}()); #end #end return baseMapper.selectList(wrapper); } }

这里用$column.queryType把若依原来的查询方式(EQ、LIKE、BETWEEN、GT、LT)映射成 Wrapper 的条件构造,这是个很实用的自动化点。原来这些条件靠 XML 的<if>标签拼,现在直接生成到代码里。queryType为LIKE的字段用.like(),BETWEEN的用.between(),生成出来的方法体可读性比 XML 好太多。

一个注意点:LambdaQueryWrapper的方法引用写法依赖 Lombok 或者手写的 getter。若依生成的实体默认带 getter/setter(模板里有#foreach生成),所以没问题。但如果你的项目用了 Lombok 的@Data,要确认模板里生成的 getter 和 Lombok 没有冲突,重复定义会编译报错。

controller.java.vm改分页调用:

@GetMapping("/list") public TableDataInfo list(${ClassName} ${className}) { PageDomain pageDomain = TableSupport.buildPageRequest(); Page<${ClassName}> page = ${className}Service.page( new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize()), ${className}Service.buildQueryWrapper(${className}) ); return getDataTable(page); }

这里我把 Wrapper 的构建挪到 Service 层,buildQueryWrapper方法在serviceImpl里生成。这么做的好处是 controller 保持轻量,而且同一个 Wrapper 可以被多个接口复用(比如导出接口和列表接口共用同一套查询条件)。若依原来的写法是每个接口自己调startPage(),条件靠 XML 复用,现在改成 Wrapper 复用,思路是一样的,只是位置变了。

导出接口要注意TableDataInfo和AjaxResult的返回类型不同。导出走的是ExcelUtil.exportExcel(),它接收List而不是IPage,所以得先page.getRecords()再传进去。忘了这一步会报类型转换异常。

4.3 GenUtils 与列信息映射的适配

模板改完之后,还有几个 Java 层面的小调整。

第一个是GenUtils.getTemplatePath(),它返回一个 Map<String, String>,key 是模板名,value 是输出路径。这部分基本不用改,但如果你想让生成器同时输出一份mapper.xml(哪怕是空的),可以在这里加一条。我的做法是保留 XML 输出但内容为空,因为若依的前端页面模板依赖mapper.xml存在才生成某些结构,删掉会引起前端模板报错。

第二个是主键字段的识别。原生成器通过column.isPk()判断主键,这个逻辑在GenTableServiceImpl里,不用动。但如果你的表用了复合主键,MP 不支持复合主键(@TableId只能标一个字段),这种情况得改用手写 SQL 绕过。这种情况在若依项目里很少见,但外键关联表偶尔会遇到。

第三个是字段类型的映射。若依的GenUtils.getJavaType()把tinyint、smallint、int映射成Integer,datetime映射成Date。MP 对类型没有额外要求,这块保持原样即可。但有一类字段要注意:json类型。MySQL 5.7+ 的json列在若依加 MP 的环境里需要额外处理,实体字段标@TableField(typeHandler = JacksonTypeHandler.class),同时类上要加@TableName(autoResultMap = true),否则查出来永远是 null。这个坑我踩过,排查了很久。

还有一个隐性的适配点:若依的GenTable里有个treeCode、treeParentCode的树形结构配置,生成的selectXxxList会按parent_id排序。改成 Wrapper 之后,这部分排序要挪到 Wrapper 的orderBy里,别忘了加,否则树形菜单的顺序会乱。

4.4 生成结果回归验证清单

模板改完不代表完事,得用一张真实的表跑一遍,看生成结果对不对。我固定用这几条检查:

检查项期望结果不通过时的典型原因
实体类注解有@TableName、主键有@TableId模板#if($column.pk)判断写错
import 齐全TableName、TableId、IdType都在忘了在模板头部加 import
Service 继承extends IService<T>模板里漏改了 interface 声明
分页调用生成的是service.page(...)而非startPage()controller 模板没替换
编译通过mvn compile 无错误import 冲突,比如同时 import 两个 BaseMapper
启动无报错无 Mapper 重复注册、无 Bean 冲突旧依赖未排除

表结构改完之后记得让生成器重新同步一次。若依的生成器有个"编辑"功能,可以在界面上改字段的 Java 类型、查询方式、是否在列表显示,这些配置存在gen_table_column表里。改了表结构之后同步,会把新列补进来,但已有的配置不会丢——这个机制很贴心,改模板期间表结构频繁变动也不怕。

最后一步是把生成出来的代码放进项目跑一遍完整 CRUD。我的验收标准是:新增一条、列表能查到、修改能生效、删除能成功、导出的 Excel 能打开。五步全过,模板改造就算收工。

5. 踩坑实录与排查速查表

改造过程中遇到的问题我基本都记了笔记,挑五个最高频的写出来,附上定位思路。

5.1 五个高频报错的定位路径

报错一:Invalid bound statement (not found)

这是最经典的。原因是 Mapper 接口的方法在 XML 里找不到对应 SQL,或者 XML 根本没被加载。在若依 + MP 的场景下,常见原因有三个:mapperLocations配置的路径通配符写错(比如写成classpath:mapper/**/*.xml少了*);XML 文件的namespace和 Mapper 接口全限定名不一致;打包时 XML 没被复制到target/classes(需要在 pom 里配resources包含**/*.xml)。

排查方法:启动时加--debug,看日志里有没有Parsed mapper file字样,没有就是没加载。

**报错二:`Unknown

返回列表