记一次扫不到其他模块mapper的问题排查
先说结论:这一次的坑,坑在“包扫描路径”上,但根子却在“模块依赖”和“配置文件加载顺序”这两件看起来毫不相干的事情上。
事情是这样的,一个多模块的Spring Boot工程,结构大致是parent下面挂着common、business、web三个子模块。business模块里放着业务逻辑和Mapper接口,web模块负责启动和接口暴露。之前一直跑得好好的,某天新加了一个report模块,把一部分报表相关的Mapper放到了report模块里,顺手在web模块的启动类上增加了@MapperScan的扫描路径,结果一启动就发现report模块下的Mapper一个都扫不到。
报错信息很经典:Invalid bound statement (not found),或者直接No qualifying bean of type ...。看一眼就头大,因为这种问题往往是“配置看起来没错,代码看起来也没错,但就是跑不起来”。
1. 扫不到Mapper的第一个怀疑方向:路径配错
遇到扫不到Mapper,大多数人第一反应都是检查@MapperScan的包路径。我这次也不例外,打开启动类反复看了好几遍,basePackages写得和report模块的包名一模一样,甚至把路径从com.xxx.report.mapper改成com.xxx.report.**.mapper再试一次,还是扫不到。
这里先说一个容易被忽略的点:MyBatis的@MapperScan扫描逻辑,实际上注册的是MapperFactoryBean。它会在指定包路径下找接口类,然后给每个接口生成代理对象,最终注册成Spring容器里的Bean。这个过程中有一个关键机制——扫描依赖的是ClassPathMapperScanner,它调用的是Spring底层的ClassPathScanningCandidateComponentProvider。后者扫描的是“类路径资源”,也就是说,它只能扫到当前运行时classpath里真实存在的类。
所以路径字符串本身没问题,不代表扫描就一定能成功。路径写得再对,如果对应的类不在classpath里,那也是白搭。我后来专门去web模块的构建产物里翻了一下,target/classes下面根本没有report/mapper目录的影子。到这一步,问题才真正开始清楚:不是扫描配置错了,是这个模块压根没被依赖进来,它的类根本没进到最终的classpath。
1.1 模块依赖缺失的隐蔽性
多模块项目里模块A引用模块B,最常见的方式当然是在A的pom.xml里加一个dependency。但问题是,很多人加了依赖之后,因为mvn compile成功、启动也不报“类不存在”的错误,就会忽略一个问题——依赖的scope对不对、是不是optional、有没有被传递依赖给“吞掉”。
比如这次,web模块确实加了report模块的依赖,但是用的是optional=true。在这个项目里,optional意味着“这个依赖不会被传递”。平时开发调试没什么问题,但一旦涉及到某些打包插件或者最终启动时的classpath组装,optional依赖可能并不会出现在真正需要它的地方。更隐蔽的是,web模块是通过business模块间接依赖report的,当时为了图省事没有直接加依赖,结果某个环境构建时传递依赖断链了,类就“消失”了。
排查这种问题,别靠猜。最快的办法是直接在IDE里打开依赖图,或者在启动类旁边写一段临时代码打印classpath,再或者用mvn dependency:tree看依赖关系。手头没有IDE环境时,直接看构建产物更实在——web模块的target/classes里有没有report的Mapper接口,一看便知。
2. 真正的问题往往不在扫描器,而在Bean定义
确认类确实不在classpath后,我就去补了依赖。补完,重新构建,再次启动,结果还是扫不到。这就有点意思了。
然后我开始重点怀疑@MapperScan是不是“只扫到了部分接口”。用MyBatis-Spring这套组合的人应该都知道,@MapperScan的底层其实会注册一个MapperScannerConfigurer的Bean定义,而且它注册的时机非常早,在Spring容器的BeanDefinitionRegistryPostProcessor阶段就会执行。这个阶段有个坑:如果你在启动类上同时用了@MapperScan和@Configuration,但某些配置类里又定义了一些和数据源、事务相关的Bean,这些Bean的初始化顺序有时候会和扫描过程产生微妙的关系。
不过这次真正让我卡住的,是report模块的Mapper接口上加了@Repository注解。这本来是好习惯,让Spring在组件扫描的时候也能把它们当作Bean处理。坏就坏在,这个模块的Mapper接口位于包com.xxx.report.mapper下,而web模块的启动类@SpringBootApplication里自带的@ComponentScan扫描范围是com.xxx.web。如果只靠@MapperScan去处理Mapper的Bean注册,那业务上没问题;但如果某处配置把@MapperScan的路径覆盖了,或者把全局的mapper-locations配置指向了别的位置,Spring就会产生歧义。
具体表现是:report模块的Mapper接口一方面被@MapperScan尝试注册,另一方面因为@Repository注解又被组件扫描器扫到。这时候如果两个扫描器都生效,有概率产生“同一个接口被注册了两次”的兼容性问题,虽然不一定报错,但是另一部分Mapper(比如business模块的)会被漏掉。
我把report模块Mapper上的@Repository去掉,只保留@MapperScan统一管理,问题竟然就解决了。这是个比较冷门但容易踩的点,建议所有用MyBatis的人注意:不要重复使用多种Mapper注册机制,同类冲突有时候不会立刻报错,它只会悄悄让你“扫不到”。
2.1 关于工厂Bean与Mapper接口代理的细节
再往深挖一层。Mapper接口能被注入到Service里,靠的是MapperFactoryBean给每个接口生成代理。MapperFactoryBean有一个checkDaoConfig方法,启动时会校验Mapper接口对应的XML命名空间是否存在。如果你的Mapper是“纯注解模式”(接口上直接用@Select、@Insert等),没有XML,那还可以绕过去。但如果是“XML模式”,必须保证mapper-locations里配置的路径和实际XML文件位置一致,并且XML的namespace必须和接口全限定名一致。
这次我虽然补了模块依赖、去了重复注解,但还发现自己踩了一个更常见的问题:report模块的XML文件放在src/main/resources/mapper/report/目录下,而全局配置里写的是classpath:mapper/**/*.xml。按理说能匹配到,但因为report模块在最终打包时,resource目录下的Mapper XML没有被包含进去。原因很搞笑——report模块的打包配置里没有配置<resources>段,默认只打包src/main/resources下所有资源,但项目里之前有人改过父工程的<resources>规则,设了<include>排除规则,把XML给排掉了。
这个问题不实际看一下构建产物很难发现。所以排查顺序很关键:先看接口类有没有进classpath,再看XML有没有进classpath,最后才看扫描配置。顺序反了,容易被“配置看起来没问题”给带偏。
3. 从“扫不到”到“定位根因”的完整排查路径
说了这么多,我把这次完整的排查路径整理一下。基本上多模块项目里遇到扫不到Mapper,按下面这个顺序走一遍,绝大多数问题都能暴露。
第一,确认报错形态。是Invalid bound statement,还是No qualifying bean,还是启动时MapperScan警告?不同报错对应不同方向。No qualifying bean偏向Bean没有注册成功,而Invalid bound statement则是Bean有了,但Mapper方法对应的SQL找不到,也就是XML或者注解没有绑定上。
第二,拉构建产物。这一步最简单直接,去最终启动模块的target/classes目录下,用find命令或者直接在IDE里右键打开,看两个东西:Mapper接口的.class文件在不在,Mapper XML文件在不在。如果不在一会儿就建索引,项目多的时候很乱。
我个人的习惯是直接在项目根目录跑一句:
mvn clean package -DskipTests然后用jar tf去看最终打的jar包里都有什么。比如:
jar tf web.jar | grep report结果里如果有com/xxx/report/mapper/ReportMapper.class,说明类进去了;没有,就回头看依赖。同理,XML用类似方式检查:
jar tf web.jar | grep 'report.*xml'这一步能过滤掉一半的假“扫不到”问题。
第三,查依赖关系。确认了类没进classpath之后,就去查依赖树。多模块项目建议直接看web模块的依赖图,重点看三处:report模块有没有被直接依赖、依赖的scope是什么、有没有被optional标记。另外注意检查是不是被某个聚合模块给剔除了。用dependency:tree时配合-Dincludes参数可以快速过滤:
mvn dependency:tree -Dincludes=com.xxx:report第四,验证扫描配置。如果类都在、XML也都在,剩下的就是@MapperScan或@Mapper相关配置的问题。这里建议把“扫描器”统一成一个入口。最推荐的做法是:启动类上只用@MapperScan,Mapper接口上一律不加@Repository、不加@Component、不加@Mapper。避免重复注册。@MapperScan本身足够完成所有工作,多写反而引入不确定性。
第五,检查MyBatis全局配置。这里要特别留意mapper-locations和type-aliases-package。mapper-locations如果配的是classpath:mapper/*.xml,那只匹配根目录下的一层;如果XML在嵌套目录里,要写成classpath:mapper/**/*.xml。type-aliases-package同理,多模块下建议配成父级包名,比如com.xxx,而不是某个具体的模块包名,否则别名解析可能漏掉其它模块。
3.1 配置踩坑速查表
下面这个表是我整理的多模块场景下常见配置坑,按发生频率排序:
| 现象 | 可能原因 | 快速验证方式 |
|---|---|---|
启动报No qualifying bean | @MapperScan路径没覆盖到对应包 | 打印applicationContext.getBeansOfType(MapperFactoryBean.class) |
启动报Invalid bound statement | XML缺失或namespace不匹配 | 检查jar tf中XML文件,再核对namespace和接口全限定名 |
| 部分Mapper是Bean,部分不是 | 同时用了@Mapper和@MapperScan | 统一去掉接口上的@Mapper注解 |
| 新模块Mapper扫不到 | 模块依赖缺失或optional=true | mvn dependency:tree查依赖 |
| XML找不到/SQL异常 | resource过滤/打包插件排除 | jar tf查产物,检查父工程<resources>配置 |
| 服务启动成功但调用时报空指针/代理异常 | 出现了同包名同接口的重复定义 | IDE依赖图查看是否有两个版本模块同时存在 |
表格里最后一条也值得多说一句:多模块项目里,如果A模块和B模块都定义了包名相同的类,而且两个模块同时被引用,最终classpath里只保留一个(取决于构建顺序),很容易出现“本地是对的,服务器上是错的”这类诡异现象。这种问题靠配置解决不了,只能消歧。
4. MyBatis扫描机制的底层逻辑,顺便讲透
很多人对@MapperScan的理解停留在“扫一下包路径就行”的层面,实际它内部做的事比较复杂。整个流程大概是:Spring容器启动时,MapperScannerConfigurer作为一个BeanDefinitionRegistryPostProcessor,会在标准Bean扫描之前注册一批候选的Mapper接口。它把每个Mapper接口包装成一个BeanDefinition,然后把beanClass替换为MapperFactoryBean,并设置构造参数为Mapper接口类型。之后容器创建Bean时,MapperFactoryBean就会用MyBatis的SqlSessionTemplate给接口生成代理。
理解这个机制后,很多问题都能从理论上反推。比如@MapperScan和@Mapper混用,实际是两波扫描器在抢着注册同一批接口,虽然Spring有去重机制,但不同扫描器的BeanDefinition元信息不同,有些场景下就会导致后注册的覆盖先注册的,而覆盖后可能丢失了某一部分参数,比如sqlSessionTemplateRef。这也是为什么我会强烈建议“一个项目只保留一种Mapper扫描方式”。
另外一个和“扫不到”强相关的点:@MapperScan支持多个路径写在一个数组里,比如:
@MapperScan(basePackages = {"com.xxx.business.mapper", "com.xxx.report.mapper"})但如果你写的是:
@MapperScan(basePackages = "com.xxx.*.mapper")那是无效的。ClassPathMapperScanner不支持这样通配多个包。它支持的是Spring的basePackages多个字符串数组,以及basePackageClasses(就是指定一个类,取这个类所在的包作为扫描起点),不支持*号通配多级。很多人在这里栽跟头,花了不少时间。
4.1 使用basePackageClasses替代字符串路径
有一个比较能规避这类问题的技巧,就是用basePackageClasses。比如在report模块里定义一个空的标记接口或者标记类,然后在启动类的@MapperScan里指定:
@MapperScan(basePackageClasses = ReportMapperMarker.class)这样做的好处是:不依赖字符串拼写,包路径在编译期就能校验;而且如果以后重构包名,编译器会直接提示,不会出现路径写错扫不到的问题。坏处是每个模块得多建一个标记类,稍微啰嗦,但换来的是可靠性,我认为值得。
5. 如何彻底避免“扫不到Mapper”问题
排查这次问题之后,我给自己定了几条规矩,以后凡是多模块项目,都按这个标准来。
第一,模块依赖只保留一种方向,外层模块直接依赖所需模块,不指望传递依赖。Maven的传递依赖虽然有,但在多模块复杂项目里,传递依赖经常被optional、provided、exclusion影响,指望它是不可靠的。启动模块需要什么,就直接声明什么。
第二,Mapper接口的注册统一走@MapperScan,接口上任何其它Spring注解都不加。@MapperScan是专门为MyBatis设计的扫描器,它处理了MapperFactoryBean、SqlSessionTemplate注入等逻辑,比@ComponentScan配合@Mapper注解的方式更可控。
第三,全局配置里mapper-locations默认写classpath*:mapper/**/*.xml。注意这里我写的是classpath*:而不是classpath:,区别在于classpath:只能匹配第一个classpath目录,classpath*:会扫描所有依赖JAR包里的匹配资源。多模块场景下,Mapper XML可能散落在不同模块的JAR里,用classpath*:才能一网打尽。
第四,凡是新加模块,第一时间检查最终启动模块的构建产物。不要等到启动报错再来查,直接在target/classes或者jar包里看类在不在、XML在不在,三十秒就能确认的事,不要让它变成一小时的事故。
第五,如果项目里配置了@MapperScan,就不要再用mybatis-plus之类的框架时同时开它的@MapperScan或者@Mapper,同类框架的注解机制不同,扫描器也不同,重复注册时的行为差异更难预判。
6. 几个特别值得留意的边界场景
排查过程里还发现了一些容易忽略的边界情况,这里单独列一下。
第一种是Spring Boot的配置中心场景。有些项目会把mapper-locations放到nacos或apollo里,启动时配置中心还没完全加载,MyBatis的配置就已经被初始化了。这种时序问题导致的“扫不到”,往往换个本地配置就好,但一接配置中心就挂。解决办法是确保MyBatis配置依赖于配置中心的@ConfigurationProperties,而不是在application.yml里用${}占位符直接引用。如果引用了占位符,请确保配置中心的加载优先于MyBatis自动配置。
第二种是单元测试里扫不到Mapper。很多人只在启动类上配置了@MapperScan,但单元测试用的是Test上下文,它不会加载启动类。这时候如果@MybatisTest或者@SpringBootTest没有显式指定@MapperScan,测试里就会报扫不到。推荐在测试类上加上@Import或者写上@MapperScan,或者直接让测试类继承主启动类的配置。
第三种是切面(AOP)场景。最近网上也常有人问“怎么用面向切面的方式只在mapper层改数据”,这个和扫不到其实有关联:如果你要对Mapper的方法做切面,前提是这个Mapper Bean必须存在,而且切面要能代理到MapperFactoryBean生成的代理对象上。切面上的pointcut如果写的是execution(* com.xxx.report.mapper.*.*(..)),一旦Mapper本身没扫进容器,切面再怎么对也不会有任何效果。所以先解决“扫得到”的问题,再谈“切得到”。如果切面本身没有生效,优先确认两件事:@EnableAspectJAutoProxy是否开启(Spring Boot默认开启但可被覆盖),以及切面类是否在@ComponentScan覆盖的包路径下。
第四种是动态数据源场景。使用了@DS注解或者手动切数据源的路由,多个SqlSessionTemplate并存时,@MapperScan上如果有sqlSessionTemplateRef指定了名字,但实际配置里另一个数据源的SqlSessionTemplate名字写错了,也会导致Mapper虽然扫到了,但初始化时报错。这种情况下报错信息通常是Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required。
7. 排查工具的推荐与实战用法
排查这类问题,有几个工具用好了效率会翻倍。
第一个是Spring Boot的/actuator/beans端点。如果你的应用已经起了但功能异常,直接GET这个端点,看看reportMapper这个Bean在不在,beanType是什么,依赖了哪些其它Bean。如果Bean都没有,直接说明扫描注册阶段就失败了。
第二个是IDE里的Diagrams依赖图。IntelliJ IDEA在pom.xml右键选Diagrams->Show Dependencies,可以直观看到模块间依赖关系。遇到“感觉依赖了,实际没有”的情况,这张图比任何文档都清楚。
第三个是mvn dependency:tree配合grep,前面提过,不再重复。第四个是arthas的sc命令。如果应用已经跑起来,用sc com.xxx.report.mapper.ReportMapper可以查看这个类是否被加载,以及由哪个ClassLoader加载的。如果输出结果是Affect(row-cnt:0),说明类根本没进入JVM,直接往前端模块依赖查即可。
这一轮排查下来,我的最终结论是:绝大多数“扫不到Mapper”都不是扫描器的问题,而是“这个类在不在classpath里”的问题。先把产物、依赖、资源这三件事查清楚,再去调@MapperScan、调包路径、调配置,顺序不能反。顺序一错,不仅浪费时间,还会把自己绕进“配置玄学”里出不来。
后来我把项目中report模块的包装DB配置重新梳理了一遍,全部统一为:所有Mapper接口集中在各模块的xxx.mapper包下,由启动模块统一声明@MapperScan,XML放在各模块src/main/resources/mapper/目录下,配置用classpath*:mapper/**/*.xml。从那次之后,这个项目再没出现过“扫不到Mapper”的问题。如果哪天你也被这个问题折磨,不妨按我上面的思路一层层剥。你可能会发现,问题并不复杂,只是坑点比较隐蔽罢了。