MyBatis 组件化实践:踩坑全记录

random-pic-api

第一个坑:XML 去哪了

最先踩的坑很基础——MyBatis 的 Mapper XML 文件在打包成 jar 后消失了。

背景是我们在做一个日志组件,里面包含了 dao 接口和对应的 XML:

1
2
3
4
src/main/java/com/xxx/log/service/dao/
├── LogDao.java
└── xml/
└── LogDao.xml

有同事把 XML 和 dao 接口放在同一个包路径下,理由是”方便对照”。开发阶段一切正常,mvn spring-boot:run 跑起来 SQL 也能加载。但打包成 jar 给业务方引用时,运行提示 Invalid bound statement (not found)

原因很简单:Maven 编译时只处理 src/main/java 下的 .java 文件,.xml 文件不会被拷贝到 target/classes。打包成 jar 后自然也没有。

修法有两种。第一种,把 XML 挪到 src/main/resources 下,保持相同包路径:

1
2
src/main/java/com/xxx/log/service/dao/LogDao.java
src/main/resources/com/xxx/log/service/dao/LogDao.xml

Maven 会把 resources 下的文件原样拷贝到 classpath,打包自然也会带上。这是标准做法。

第二种,在 pom.xml 中显式声明资源路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
</resources>
</build>

但第二种破坏了代码与资源的分离原则。长期维护下来,src/main/java 下什么都有,新人不知道哪些是代码哪些是资源。所以我选了第一种。

其实往深了想,”方便对照”这个需求可以用工具解决。IntelliJ IDEA 有 MyBatis 插件,可以从 dao 接口跳转到 XML,也可以反向跳转。而且免费的 MyBatisCodeHelper 或者付费的 MyBatis Log Plugin 都能做到。不需要用”把 XML 放在 java 目录下”这种反 Maven 约定的方式来解决问题。

第二个坑:组件里的 dao 扫不到

XML 问题解决后,遇到了第二个坑:业务方引了组件,但 dao 接口没被 Spring 扫描到。

Spring Boot 的默认包扫描范围是主启动类所在包及其子包。组件的包路径是 com.xxx.log.service.dao,而业务方的主启动类在 com.xxx.business 下,根本扫不到。

业务方解决方式是自己在启动类上加 @MapperScan

1
2
3
@SpringBootApplication
@MapperScan({"com.xxx.business.dao", "com.xxx.log.service.dao"})
public class BusinessApplication { ... }

功能性上是解决了,但破坏了组件的封装性。业务方需要知道组件内部有哪些 dao 包。如果将来组件内部重构,改了包名,所有引用方的启动类都要跟着改。

正确做法是组件自己提供自动配置:

1
2
3
4
@Configuration
@MapperScan("com.xxx.log.service.dao")
public class LogAutoConfiguration {
}

再配上 Spring Boot 的 spring.factories 或者 AutoConfiguration.imports 文件:

1
2
# META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.xxx.log.service.config.LogAutoConfiguration

业务方引入依赖后,dao 自动被扫描,零配置接入。这才是组件应该有的体验。

但自动配置也有要注意的地方。如果业务方自己也有 @MapperScan,两者会不会冲突?答案是:不会。MyBatis-Plus 的 @MapperScan 支持多次声明,多个扫描路径会被合并。但前提是不要在不同层级重复扫描同一个包,否则可能导致同一个 Mapper 被注册两次。

第三个坑:间接依赖的 dao 找不到

接下来是一个更隐蔽的问题。xxx-manage-service 示例项目报了一个错:

1
2
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found):
com.xxx.user.api.dao.DictDao.getNormalDictMapByDictCodes

排查过程是这样的:

项目的依赖链是 xxx-manage-serviceinfrastructure-service-starteruser-api-service-starterDictDaouser-api-service-starter 中,是通过 infrastructure-service-starter 间接引入的。

虽然 user-api-service-starter 有自己的自动配置类,但某些场景下没有正确生效。排查发现是 spring.factories 文件中的自动配置类名写错了——少写了一个字母,Spring Boot 加载的时候静默跳过,没有报任何错。

这是个教训:自动配置的声明一定要和实际类名一致。Spring Boot 不会因为找不到自动配置类而启动失败,它只会跳过。所以在写 AutoConfiguration.imports 或者 spring.factories 时,建议用 copy-paste 类全限定名(来自 IDE 的 Copy Reference),不要手敲。

修好自动配置后还有一个小细节:业务方的 mapper-locations 配置需要匹配组件的包路径:

1
2
mybatis-plus:
mapper-locations: classpath*:com/xxx/**/*Dao.xml

关键是 classpath*: 前缀。因为组件的 XML 在 jar 包的 classpath 中,业务方代码在另一个 classpath 中,用 classpath* 才能扫描到多个 jar 中的 XML。

第四个坑:拦截器绑架

这个问题在之前的文章里已经提到过,但在 MyBatis 组件化这个话题下值得再说一遍。

MybatisAutoConfiguration 中同时装配了加密拦截器和分页拦截器:

1
2
3
4
5
6
7
8
@Bean
@ConditionalOnMissingBean(MybatisPlusInterceptor.class)
public MybatisPlusInterceptor paginationInterceptor(...) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new EncryptionInterceptor(encryptService));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(dbType));
return interceptor;
}

条件注解是:

1
@ConditionalOnProperty(prefix = "secret.mybatis", name = "enable", havingValue = "true")

也就是说,不开加密就没有分页。这两个功能没有逻辑上的关联,纯粹是在同一个方法里创建的。

分开的改法也很直接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 加密拦截器 —— 受配置控制
@Bean
@ConditionalOnProperty(prefix = "secret.mybatis", name = "enable", havingValue = "true")
public EncryptionInterceptor encryptionInterceptor(EncryptService encryptService) {
return new EncryptionInterceptor(encryptService);
}

// 分页拦截器 —— 独立存在
@Bean
public PaginationInnerInterceptor paginationInnerInterceptor(
MybatisPlusExpandProperties properties) {
PaginationInnerInterceptor interceptor = new PaginationInnerInterceptor(properties.getDbType());
interceptor.setMaxLimit(properties.getMaxLimit());
return interceptor;
}

不止如此,加密拦截器本身也值得优化。它用反射检查每个入参是否有 @EncryptedTable 注解,每次查询/更新都做一遍:

1
2
3
4
// 当前实现:每次操作都走反射
private boolean annotateWithEncrypt(Class<?> objectClass) {
return AnnotationUtils.findAnnotation(objectClass, EncryptedTable.class) != null;
}

高频调用场景下,反射是瓶颈。加一层缓存:

1
2
3
4
5
6
private final Map<Class<?>, Boolean> encryptClassCache = new ConcurrentHashMap<>();

private boolean annotateWithEncrypt(Class<?> objectClass) {
return encryptClassCache.computeIfAbsent(objectClass, clazz ->
AnnotationUtils.findAnnotation(clazz, EncryptedTable.class) != null);
}

同理,加密字段的反射查找也可以缓存:

1
2
3
4
5
private final Map<Class<?>, List<Field>> encryptFieldCache = new ConcurrentHashMap<>();

private List<Field> getEncryptFields(Class<?> clazz) {
return encryptFieldCache.computeIfAbsent(clazz, this::findEncryptFields);
}

改完之后,加密拦截器对数据库操作的性能影响降低了一个数量级。缓存的 key 是 Class 对象,内存占用极小,生命周期和 JVM 一致。

第五个坑:@Configuration@AutoConfiguration 的微妙差别

这个问题不是 MyBatis 特有的,但确实是在排查 MyBatis 自动装配相关问题时踩出来的。

框架里有各种自动装配类,有的用 @Configuration,有的用 @AutoConfiguration。它们都能让 Bean 被 Spring 容器管理,但在 Spring Boot 3.x 中,两者的行为有了关键差异。

先看一个具体场景。2.1.0 版本的网关启动类是这样的:

1
2
3
4
5
@SpringBootApplication(scanBasePackages = {"com.sctelcp"},
exclude = {DataSourceAutoConfiguration.class, AjCaptchaAutoConfiguration.class})
@EnableFeignClients(basePackages = {"com.sctelcp"})
@EnableDiscoveryClient
public class GateWayBoot { ... }

exclude 排除了 AjCaptchaAutoConfiguration,在当时一切正常。但升级到 Spring Boot 3.x 后启动报错:

1
2
3
java.lang.IllegalStateException: The following classes could not be excluded
because they are not auto-configuration classes:
- com.anji.captcha.config.AjCaptchaAutoConfiguration

原因很简单:AjCaptchaAutoConfiguration 用的是 @Configuration,而不是 @AutoConfiguration。Spring Boot 3.x 不再把 @Configuration 视为自动装配类,exclude 属性不认它了。

修法是用 @ImportAutoConfigurationexclude 来排除:

1
2
3
4
@EnableFeignClients
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@ImportAutoConfiguration(exclude = {AjCaptchaAutoConfiguration.class})
public class GatewayApplication { ... }

@Configuration@AutoConfiguration 到底该怎么选?规则其实很清晰:

  • @AutoConfiguration 用在 starter 组件中,配合 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件使用。它的加载时机由 Spring Boot 的自动配置机制控制,可以使用 @ConditionalOnClass@ConditionalOnMissingBean 等条件注解来控制是否生效,也可以用 @AutoConfigureBefore / @AutoConfigureAfter 控制加载顺序。

  • @Configuration 用在微服务自身的配置类中,由 @SpringBootApplication 的组件扫描发现并加载。它不走 AutoConfiguration.imports 文件。

一个容易犯的错误是:在 starter 中用了 @Configuration 而不是 @AutoConfiguration,然后期望业务方用 exclude 排除它——在 Spring Boot 3.x 中这会失败。反过来,如果你的配置类只需要在特定微服务中生效,用 @Configuration 就够了,不需要写 AutoConfiguration.imports 文件。

实践中还有一个值得注意的点:@AutoConfiguration 是 Spring Boot 2.7 引入的,它和旧的 spring.factories 机制可以共存,但 AutoConfiguration.imports 是推荐的写法。如果你在维护一个同时支持 2.x 和 3.x 的组件,需要两套都保留。

第六个坑:逻辑删除 + 唯一索引 = 冲突

框架里用了 MyBatis-Plus 的逻辑删除功能,配置如下:

1
2
3
4
5
6
mybatis-plus:
global-config:
db-config:
logic-delete-field: delFlag
logic-delete-value: 1
logic-not-delete-value: 0

数据不会被物理删除,delFlag 置为 1 就算删了。MyBatis-Plus 会自动在查询时加上 WHERE delFlag = 0,对业务代码透明。

但和唯一索引配合使用时,问题就来了。

以用户表为例,username 必须唯一。最直观的做法是加唯一索引:

1
CREATE UNIQUE INDEX uni_username ON user(username);

现在有一个用户 zhangsan,被逻辑删除后 delFlag = 1。如果业务上要再创建一个叫 zhangsan 的用户,唯一索引会直接报 Duplicate entry——因为 username = 'zhangsan' 的记录还在表里,只是被标记为删除了。

于是换成联合唯一索引:

1
CREATE UNIQUE INDEX uni_username_delflag ON user(username, delFlag);

这样 ('zhangsan', 0) 只能有一条——未删除的用户名依旧唯一。而 ('zhangsan', 1) 可以和 ('zhangsan', 0) 共存——已删除的同名用户不影响新用户创建。

但新的问题又出现了。如果数据库中已经存在被删除的 ('zhangsan', 1),而业务逻辑再次对这个用户执行删除操作,就会尝试将 delFlag 从 1 更新为 1——SQL 执行没问题,但联合唯一索引的约束是 (username, delFlag),两次删除会尝试写入相同的 ('zhangsan', 1'),触发唯一索引冲突。

根本原因是 delFlag 的值对于所有已删除记录都是相同的 1,违反了唯一性。解决思路是让每个已删除记录的 delFlag 值都唯一——用行 ID:

1
2
@TableLogic(value = "0", delval = "id")
private Long delFlag;

同时修改 DDL:

1
2
3
4
5
-- 原来
`del_flag` smallint NOT NULL DEFAULT '0' COMMENT '是否删除 0:否; 非0:是'

-- 改为
`del_flag` bigint NOT NULL DEFAULT '0' COMMENT '是否删除 0:否; 非0:是'

这样一来,删除时 delFlag 被设置为该行的 id,每条已删除记录的 delFlag 都不同,联合唯一索引不会冲突。查询时,MyBatis-Plus 自动拼接 WHERE del_flag = 0,逻辑删除语义不变。

这引出了一个更普遍的问题:框架没有对 PO 实体做字段抽象。 delFlagcreateTimeupdateTime 这类公共字段,每个数据库实体都要写一遍。后期如果要加一个公共字段,所有 PO 都要改。合理的做法是抽一个父 PO 让所有实体继承:

1
2
3
4
5
6
7
8
9
10
11
@Data
public abstract class BasePO {
@TableLogic(value = "0", delval = "id")
private Long delFlag;

@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

另外,像 delFlag 这种只有 0 和非 0 两种状态的字段,用 bigint(8 字节)其实浪费了。建议 DDL 中用 tinyint(1 字节),但配合 @TableLogic(delval = "id") 时需要和 Java 类型一致——如果 delval 要取 id 的值(通常是 bigint),那字段类型就需要是 bigint。这是一个取舍:用 tinyint + delval = 1 可以省存储,但要承受上面说的联合唯一索引冲突问题。用 bigint + delval = "id" 占空间稍多,但消除了唯一索引冲突。我个人倾向后者——存储成本远低于 bug 修复成本。

Feign 接口的整理:一个被遗忘的角落

这七个坑之外,还有一个不算坑但值得提的——Feign 接口的组织。

框架中有多达 9 个 Feign 接口声明,分别对应不同的远程服务:

1
2
3
4
5
6
7
8
9
10
11
@FeignClient(contextId = "securityUserAuthService", 
value = "${framework.core-service.user-auth-service:auth-service}",
path = "/user/auth",
fallbackFactory = AuthenticationServiceFactory.class)
public interface AuthenticationService { ... }

@FeignClient(contextId = "infrastructureService",
value = "${framework.core-service.infrastructure-service:infrastructure-service}",
path = "/infrastructure/api",
fallbackFactory = InfrastructureServiceFallBackFactory.class)
public interface InfrastructureService { ... }

这些接口散落在不同的包中,没有一个统一的归纳文档。新同事要了解服务间的依赖关系,得把所有 Feign 接口的地方搜一遍。

在跨模块协作时,这个问题会被放大。A 组的服务改了接口签名,B 组引了对应的 Feign 接口但没有同步更新,运行时 404 或者参数错位。因为 Feign 接口只在编译期做类型检查,运行时的契约一致性是需要额外机制保证的。

理想情况下,Feign 接口应该由服务提供方以独立 jar 的形式发布——类似于 SDK。服务提供方改了接口,SDK 发一个新版本,消费方升级依赖,编译期就能发现不兼容的变更。但在项目型组织中,这种方式目前的性价比不高——服务数量不够多,单独发布 SDK 的维护成本可能超过收益。暂无完美方案,至少先把所有 Feign 声明整理到一个文档里,让服务依赖关系清晰可见。


这篇和下一篇(网关性能调优)是这个系列的收尾。网关那篇会聚焦实际问题——Nginx Cannot assign requested address 是怎么引发的、keepalive 怎么配、内核参数怎么调。都是上线后才知道的事。