Spring Boot多模块工程Mapper扫描不到?从组件扫描原理到@MapperScan排坑全记录
最近在维护一个Spring Boot多模块工程时碰上了一个非常典型的启动失败问题服务一启动就报错提示找不到某个Mapper。按理说编译都过了配置也写了接口上甚至加了Mapper注解XML文件也在resources目录里到底为什么扫不到这个问题我在本地和CI流程里反反复复试了几轮最后才把根因彻底定位。这篇记录就围绕这个扫不到其他模块mapper的问题排查展开从现象、机制到最终落地把我实际踩过的坑和排查思路原原本本写出来希望给同样掉进这个坑里的朋友省点时间。先说结论核心Spring Boot启动类默认只会扫描自己所在包及子包多模块工程里Mapper接口如果落在默认扫描范围之外光靠接口上的Mapper注解是救不了的必须依靠MapperScan或者合理调整包结构来兜底。看起来像个配置问题实际上牵扯到了Spring的自动配置机制和MyBatis的后置处理器执行逻辑。1. 项目结构还原与问题现象描述1.1 典型的多模块工程长什么样这次的工程是一个非常标准的Maven多模块项目通常拆成三类模块demo-common存放工具类、通用DTO、常量定义。demo-dao存放MyBatis相关的东西比如Mapper接口、XML映射文件、Entity实体。demo-service存放启动类、Controller、Service实现是最后打包运行的模块。模块之间依赖关系很明确demo-service依赖demo-daodemo-dao可能依赖demo-common。每个模块在Maven里都有自己独立的坐标和生命周期编译时确实没问题因为编译只看依赖是否引进来了代码里的引用能不能解析而Mapper接口编译后就是一个普通接口根本不涉及容器装配。所以你会发现一件很迷惑的事情mvn clean package一路绿灯一运行java -jar或者从IDE点启动立刻崩给你看。报错信息大致是这一串Parameter 0 of method setDemoMapper in com.demo.service.impl.DemoServiceImpl required a bean of type com.demo.dao.mapper.DemoMapper that could not be found.或者换成MyBatis风格Invalid bound statement (not found): com.demo.dao.mapper.DemoMapper.selectById前一种说明连Mapper代理Bean都没注册进去后一种说明Mapper接口注册了但XML映射文件和接口没有正确绑定。这次的场景属于前一种问题就出在“接口根本没有被Spring容器感知”。1.2 为什么说影响范围不止一次启动失败可能有人觉得启动失败重启一下、改改配置就好了不值得上纲上线。但在实际工程里这种问题的影响面远比表面看到的要广。首先是本地开发效率。配置类、扫描路径、启动类结构稍微不一致就会浪费大段时间去排查。尤其是多人协作的项目每个人本地的IDE设置、Maven仓库状态都不一样问题可能只在一部分人机器上复现搞得大家互相怀疑谁的代码有问题。其次是CI/CD流水线。打包过程不报错但部署后应用无法启动或者启动后接口调用立即失败这种问题往往在自动化测试阶段才暴露。一旦流水线里的冒烟测试覆盖不到Mapper调用问题就会一路带到生产环境。生产环境应用启动失败影响的就是所有依赖这个服务的调用方。另外这类问题还容易和Spring Boot版本、MyBatis Starter版本产生联动。新版本里自动配置类的包名变了、扫描注册器的行为变了再加上多模块的包路径设计不合理问题会变得非常隐蔽。这也是为什么我建议遇到启动报找不到Mapper的情况不要急着补注解而是先把扫描机制理解透。1.3 初步判断代码和配置看起来都没问题我先复述一下这次排查前的“案发现场”。启动类写在demo-service模块里包名是com.demo.service代码长这样package com.demo.service; SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }Mapper接口在demo-dao模块里包名是com.demo.dao.mapperpackage com.demo.dao.mapper; public interface DemoMapper { DemoEntity selectById(Param(id) Long id); }为了让MyBatis认识这个接口我还在接口上加过Mapper注解也在启动类上试过MapperScan(com.demo.dao.mapper)但同样没能解决问题。这就很反常了理论上这两种方式都能把Mapper注册进容器怎么都不生效排查了依赖demo-service/pom.xml里确实引入了demo-dao的依赖代码里也能import到Maper接口说明编译阶段没问题。XML文件的位置也检查过在demo-dao/src/main/resources/mapper/下文件内容、namespace也对得上。一切看起来都正常但启动就是报找不到Bean。然后我意识到了一个很关键的点依赖引入了、代码编译过了不代表Spring在运行时会真的去扫描demo-dao这个模块下的包。Spring的扫描范围和Maven的依赖范围是两套逻辑前者靠注解和包名约定后者靠classpath和jar包。很多人在这一步就开始原地打转问题其实是出在“扫描范围”上。2. 排查过程从怀疑配置到理解扫描机制2.1 第一步先从依赖入手而不是急着改代码有段时间我一遇到扫不到Mapper的问题第一反应就是给启动类加各种注解。后来发现这样做经常是在碰运气正确做法是先把依赖链路理清楚。我先在demo-service模块下执行了依赖树命令mvn dependency:tree -Dincludescom.demo:demo-dao输出里确认了demo-dao确实以依赖形式进入了运行classpath。这一步很关键如果依赖都没进来后面的一切排查都是徒劳的。接着检查了demo-dao模块打包出来的jar包内容jar tf target/demo-dao-1.0.0.jar确认com/demo/dao/mapper/DemoMapper.class和mapper/DemoMapper.xml都在。这一步排除了“代码没打进去”的嫌疑。很多时候多模块开发只执行了mvn clean compile没有执行mvn clean install导致本地仓库里的demo-dao还是旧版本的jar包代码改了但打包出来的东西没变也会引发类似问题。如果依赖和jar包都正常那就说明问题大概率出在Spring的组件扫描机制上。2.2 第二步我把Spring Boot的扫描逻辑重新捋了一遍必须承认很多人包括我自己对SpringBootApplication的理解停留在“加了这个注解就能启动”的层面没有仔细想过它背后干了什么。实际上这个注解是一个组合注解核心是EnableAutoConfiguration、ComponentScan和Configuration。最关键的就是ComponentScan。它的默认规则是以被标注类所在的包作为基准包扫描这个包及其所有子包下的组件。也就是说启动类在com.demo.serviceSpring默认会去扫描com.demo.service层级下的所有类把它交给容器管理。那么问题就来了DemoMapper接口在com.demo.dao.mapper包下跟com.demo.service不是一个层级甚至不在同一个模块里。Spring默认扫描根本不会走到那里去所以Mapper接口无论加不加Mapper注解都不会被容器发现。有人可能会反驳Mapper注解不是用来标记MyBatis接口的吗加了它不就应该被处理吗这就涉及MyBatis Starter的运作细节了。mybatis-spring-boot-starter里有一个自动配置类MybatisAutoConfiguration它通过Import(AutoConfiguredMapperScannerRegistrar.class)来扫描Mapper接口。重点在于这个扫描器并不是扫描整个classpath而是使用AutoConfigurationPackages.get(beanFactory)获取到的包集合。这个包集合来源于注册到容器中的、被SpringBootApplication修饰的启动类所在包。换句话说自动扫描器扫描的范围还是跟随启动类所在包走的。DemoMapper在默认范围之外Mapper注解就不会被处理。只有手动的MapperScan可以指定额外的扫描路径强制把com.demo.dao.mapper纳入扫描列表。2.3 第三步根因定位靠的是逐个场景验证为了确认是不是扫描路径的问题我做了几个小实验。第一个实验把启动类上的SpringBootApplication换成显式的ComponentScan指定包含com.demo.dao的完整包路径。启动后问题依旧但仔细一看原来我写成了这样ComponentScan(basePackages {com.demo.service})这等于把默认扫描范围重新限定回了com.demo.service反而削弱了扫描范围。所以这里有一个很重要的细节一旦显式声明了ComponentScan默认的“启动类包及其子包”规则就会失效必须把所有需要扫描的包全部写全。写少了原本能扫到的Service、Controller也可能跟着消失。第二个实验在启动类上同时加上MapperScan(com.demo.dao.mapper)。这一次启动成功了Mapper Bean能正常注入了。这说明问题就是Mapper接口没被手动扫描注册和XML配置、namespace都没关系。第三个实验也是让我印象最深的我把Mapper接口上的Mapper注解删掉只保留MapperScan启动依然正常。反过来取消MapperScan但保留Mapper注解启动直接失败。这个对比把机制解释得很清楚了在多模块工程里想靠Mapper注解让MyBatis找到接口前提是接口本身得处在Spring的默认扫描范围内一旦不在Mapper根本不会被MyBatis的自动配置扫描器看见。真正可靠的做法还是用MapperScan手动指定包路径。2.4 顺手看了一眼自动配置报告排查过程中我还用了Spring Boot提供的AutoConfiguration Report这个工具对定位类似问题极其有用。在启动参数里加上--debug或者直接配置debugtrue控制台就会输出一份条件评估报告里面有Positive matches和Negative matches。重点看Negative matches里和MyBatis相关的部分比如AutoConfiguredMapperScannerRegistrar Did not match: - ConditionalOnBean did not find any beans of type org.mybatis.spring.mapper.MapperFactoryBean这段日志说明MyBatis自动配置尝试去扫描Mapper但因为找不到基准包或者没有额外指定的扫描包最终跳过了自动扫描逻辑。看到这个基本就能锁定方向不用再猜来猜去了。3. 解决方案三种可落地的处理方式3.1 方案一启动类上指定MapperScan最直接有效这是最推荐的做法改动量小效果立竿见影。在启动类上增加注解明确告诉MyBatis要去哪些包下面找Mapper接口SpringBootApplication MapperScan(com.demo.dao.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }如果Mapper散布在多个包下面可以写成MapperScan({com.demo.dao.mapper, com.demo.module1.mapper, com.demo.module2.mapper})这里有几个细节需要注意MapperScan扫描的是接口不是XML文件XML文件的位置单独靠mybatis.mapper-locations配置控制。MapperScan和接口上的Mapper注解可以同时存在不会冲突重复扫描同一个接口也不会有问题MyBatis内部会做去重处理。MapperScan除了指定包路径还可以指定sqlSessionFactoryRef、sqlSessionTemplateRef等参数。当工程里配置了多个数据源时每个数据源对应一个SqlSessionFactory这时候MapperScan必须绑定对应的SqlSessionTemplate否则Mapper会找准了包但用错了数据源。我当时就是用了这个方案加上之后启动一次通过。3.2 方案二统一包结构让默认扫描就能覆盖到如果不想到处加注解可以考虑从包结构上根治问题。核心思路是把启动类所在包作为所有子模块包的共同祖先。比如启动类放在com.company.project包下那所有模块的包名都从这个祖先包往下分com.company.project.dao demo-dao模块 com.company.project.service demo-service模块 com.company.project.common demo-common模块这样Spring Boot默认扫描com.company.project时天然会覆盖到每个子包即使不写MapperScanMapper接口只要在com.company.project.dao包下加上Mapper注解就能被自动配置扫描器发现。但这个方法有个前提模块之间的包名设计必须从项目的第一天就统一规划。如果项目已经跑了很多年各模块包名各自为政想靠改包结构来解决问题迁移成本会非常大而且很容易改出新的问题。这种方案更适合新项目启动时制定规范或者小范围内的结构调整。3.3 方案三模块内显式配置与依赖管理双管齐下还有一种场景就是启动类不愿意写太多的MapperScan或者不想把所有Mapper包都暴露出来。这时候可以在demo-dao模块内部自己定义一个配置类把扫描工作收敛到数据访问层package com.demo.dao.config; Configuration MapperScan(com.demo.dao.mapper) public class DaoAutoConfiguration { }然后在demo-service模块的启动类上把这个配置类导入进来Import(DaoAutoConfiguration.class) SpringBootApplication public class DemoApplication { // ... }这样做的好处是扫描逻辑跟着数据访问模块走业务模块只需要依赖demo-dao并引用它提供的配置即可。如果以后Mapper迁移到别的包只需改DaoAutoConfiguration里的MapperScan路径不需要去改启动类。同时要检查demo-dao模块的pom.xml确保它被正确安装到本地仓库并由其他模块引用dependency groupIdcom.demo/groupId artifactIddemo-dao/artifactId version1.0.0/version /dependency如果demo-dao是私有模块最好把版本号统一托管在父POM的dependencyManagement里避免各模块引用时版本漂移。3.4 方案选型对比不是每一种都适合所有项目我用一张表把三种方案的核心差异列出来方便参考方案改动位置适用场景推荐度启动类加MapperScan启动类已有项目快速修复Mapper包数量少高统一包结构全模块包名新项目规划阶段或小范围重构中模块内配置类Importdao模块新增配置类模块边界清晰希望收敛扫描逻辑高实际操作中我建议优先使用方案一理由很朴素改动最小风险最低。方案二需要协调所有模块的包名调整在团队协作中很容易引发冲突。方案三适合那些架构上已经做了模块化治理的团队看起来干净但对项目规范要求较高。注意不论采用哪种方案都需要保持XML文件和接口之间的映射关系正确。mybatis.mapper-locations配置决定了XML加载路径我习惯统一设置成classpath*:mapper/*.xml注意前面的classpath*:必须带星号表示从所有依赖jar包中搜索mapper目录下的XML文件。漏掉这个星号多模块下很容易出现“接口找得到XML找不到”的问题。4. 常见问题速查与进阶实战建议4.1 类似问题的排查速查表这次排查之后我整理了一份速查清单按优先级排序遇到同类问题直接照着过一遍现象可能原因排查要点启动报NoSuchBeanDefinitionExceptionMapper接口未被扫描注册检查MapperScan路径是否覆盖接口包检查接口包是否在启动类默认扫描范围内启动报Invalid bound statement接口与XML未配对检查XML文件名、namespace、statement id是否与接口一致检查mapper-locations配置编译报错找不到Mapper接口依赖未引入检查pom.xml依赖坐标执行mvn dependency:tree查看依赖树代码改为依赖未更新本地仓库还是旧jar执行mvn clean install重新安装依赖模块接口能找到但方法无法调用XML与接口方法签名不匹配对比方法名、参数类型、返回类型重新生成XML或用IDE的MyBatis插件校验多数据源场景下Mapper串了SqlSessionFactory绑定错误在MapperScan中指定sqlSessionTemplateRef隔离数据源这个表里包含了最常见的问题分布基本覆盖了我这些年遇到过的90%的Mapper相关启动故障。4.2 几个容易踩的变种坑除了上面那种“其他模块扫不到Mapper”的基础情况我还遇到过几个变种坑值得单独拎出来说。第一个是拆分模块后不同模块里出现了同名的Mapper接口。比如order模块和user模块都有UserMapper包名还不一样MapperScan把两个包都扫进来之后Spring容器里会存在两个类型具备相同的短类名。如果Service里按接口类型注入并且两个接口恰好全限定名不同但短类名相同某些情况下会让人误以为是扫描漏了实际上是装配歧义。解决办法是避免跨模块短类名重复或者在注入时使用Qualifier明确指定Bean名称。第二个是Spring Boot版本和MyBatis Starter版本组合问题。不同版本的mybatis-spring-boot-starter包名和自动配置类的位置有过调整。如果你用的版本比较新但代码里还按旧教程写MapperScan的包路径或者引错了MapperScan类也会出现注解没生效的情况。遇到这种问题最稳妥的办法是查看当前版本的官方文档或者直接在IDE里用Shift按两次查类确认org.mybatis.spring.annotation.MapperScan确实存在于当前依赖的jar包里。第三个是模块依赖没有传递。比如demo-service模块依赖了demo-dao而demo-dao依赖了demo-common。如果demo-dao的POM里把demo-common声明成了scopeprovided/scope运行时就可能缺失某些类导致启动过程中MyBatis实例化Mapper失败报错信息甚至不会直接提到Mapper。这种问题要靠完整堆栈和依赖树一起分析单独看启动日志很容易误判。第四个是Mapper接口所在的jar包被构建工具过滤掉了。有些团队配置了maven-jar-plugin只打包特定包路径下的类。如果过滤列表写的是**/service/**恰好Mapper接口不在这个范围内就会导致打进jar包的内容不完整启动后同样找不到Mapper。检查方法还是回到jar tf确认jar包里有没有Mapper的class文件。4.3 进阶从“扫不到”到“统一处理”的思考把扫不到的问题解决之后我又顺手想了一件事既然Mapper层的扫描和装配已经理清楚了那能不能在Mapper这一层做统一的横切逻辑这也是我在实际项目里经常被问到的问题——怎么用面向切面的方式只在mapper层改数据。比如你有这样一个需求所有查询Mapper执行前自动注入租户ID、数据权限条件或者所有更新Mapper执行后记录操作日志。这类需求如果都去改每个Mapper方法太容易漏了而且还很难维护。最直接的做法是写一个AOP切面把切入点限定在Mapper接口层Aspect Component public class MapperAspect { Around(execution(* com.demo.dao.mapper.*.*(..))) public Object aroundMapper(ProceedingJoinPoint joinPoint) throws Throwable { // 方法执行前统一处理参数、权限上下文等 Object result joinPoint.proceed(); // 方法执行后统一处理结果 return result; } }这个切面生效的前提依然是Mapper接口必须已经注册成Spring容器里的Bean否则AOP代理根本找不到目标对象。所以前面排查扫描问题的结论在这里也有意义只有保证了Mapper能被正常扫描和装配后续的AOP增强、拦截器、数据权限过滤才有施展的空间。如果你的需求更偏向底层SQL层面比如拦截某些查询自动追加过滤条件那么比起AOP更合适的方案是实现MyBatis的Interceptor接口在Executor层面统一处理。这个原理上比AOP更贴近SQL强账单但这篇文章就不展开了知道有这个路径就行。4.4 最后说点实在的经验踩过几次这种“扫不到Mapper”的坑之后我总结出几条受益很深的经验供你参考。第一多模块工程的组件扫描规则应该被当成架构规范写下来而不是当作一个人人靠猜的记忆。新模块加入时先看新模块的包路径和启动类所在包的上下级关系再决定是在启动类里写扫描注解还是把扫描逻辑收在模块内部。架构评审阶段多花几分钟能省下后面一大片排查时间。第二MyBatis相关配置尽量收敛不要散落到多个配置类里。我见过有些项目启动类上挂着一堆扫描注解Controller包也扫一遍、Service包也扫一遍、Mapper包也扫一遍导致后续的人根本不敢动启动类。可以的话把Mapper的扫描配置单独拎到一个配置类里保持启动类干净。第三依赖版本统一交给父POM管理。把demo-dao、demo-common、mybatis-spring-boot-starter的版本都放在dependencyManagement中子模块只声明依赖坐标不写版本号能从根本上避免模块间的版本冲突。第四遇到类似问题不要急着改配置先把mvn dependency:tree、jar tf、自动配置报告这几个工具跑一遍。正确率比直觉高得多。有时候问题不在扫描注解而在底层依赖根本没有进入运行环境。最后再分享一条小技巧启动类所在包尽量设计成所有模块包路径的顶层父包哪怕后续模块再多Spring Boot的默认扫描总能覆盖到很多看似玄学的Bean找不到问题都会自动消失。这条规则我和团队实测下来对减少多模块工程的同类故障非常有帮助。