在Spring项目里写MyBatis,大家最熟悉的一行代码大概就是启动类上那个@MapperScan("com.xxx.mapper")。加了这个注解,Mapper接口就能直接注入Service里用,不用写实现类,不用手动new,仿佛一切都被安排得明明白白。用得越顺手,就越容易忽略它背后到底是怎么工作的。如果你只是停留在"会用"层面,这一篇可能没必要看;但如果你想把MyBatis和Spring两套框架的协作机制弄明白,@MapperScan就是最值得拆的那个入口。
这篇文章会沿着一条完整链路讲清楚:先回顾不用Spring时MyBatis怎么用,再讲Spring集成解决了哪些痛,然后一行一行拆@MapperScan的源码,从@Import到ImportBeanDefinitionRegistrar,从ClassPathMapperScanner到MapperFactoryBean,最后落到MapperProxy动态代理,把"接口没有实现类却能注入、能执行SQL"这件事彻底说透。文章后面还会整理几类实际开发中非常常见的踩坑场景,比如扫描不到Mapper、多数据源冲突、分页插件失效等。适合对MyBatis和Spring IoC有一定使用经验、想了解底层原理的Java后端开发者。
1. 先说结论:@MapperScan到底替你做了什么
先给一个整体印象:@MapperScan的本质,是扫描指定包下的Mapper接口,把每个接口都注册成Spring容器里的一个Bean,但这个Bean的实际形态是一个代理对象,由MapperFactoryBean负责创建,最终通过JDK动态代理拦截方法调用,路由到SqlSession执行SQL。
这三步拆开看:
- 扫描:
ClassPathMapperScanner在指定包下面找接口,过滤出符合条件的Mapper接口。 - 注册:把每个接口的BeanDefinition改造成
MapperFactoryBean的BeanDefinition,Spring在创建Bean时调用MapperFactoryBean.getObject()。 - 代理:
getObject()返回的是MapperProxy的JDK代理,调用Mapper接口方法时,代理会解析方法对应的SQL,交给SqlSession执行。
用一句话说清楚:@MapperScan不是把接口直接放进容器,而是给Spring递了一张"接口名片",让Spring在需要用的时候,通过MapperFactoryBean这个工厂生成一个代理来完成实际工作。
我习惯把这条链路类比成公司招人:Mapper接口只是候选人的简历,ClassPathMapperScanner是猎头,负责筛简历;MapperFactoryBean是HR,负责安排面试、办理入职;MapperProxy就是正式上岗的员工,真正干活的是它背后的SqlSession。Spring容器里自始至终没有"实现类"这个东西,容器知道的只是:你注入UserMapper时,我给你一个代理对象,这个代理能完成你要的数据库操作。
这个机制理解起来其实不复杂,但它依赖Spring的几个关键扩展点:@Import、ImportBeanDefinitionRegistrar、FactoryBean、BeanDefinitionRegistryPostProcessor。这些概念单独看都容易懂,合在一起就成了很多人眼中的"魔法"。所以这篇文章后面的几个章节,我会把这条链路上的每个环节都用实际代码摆出来,一步步拆。
2. 从原生MyBatis到Spring集成,一条不得不走的路
2.1 原生MyBatis的使用方式长什么样
在没有Spring的年代,用MyBatis写一个查询,流程大致是固定的:
String resource = "mybatis-config.xml"; InputStream inputStream = Resources.getResourceAsStream(resource); SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream); try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user = mapper.findById(1L); System.out.println(user); }这段代码暴露了三个问题:
- 每次都要重复构建流程:即使
SqlSessionFactory只需要创建一次,也必须自己管理它的生命周期。 - SqlSession不是线程安全的:它不能被多个线程共享,所以每个线程都得拿一个独立的SqlSession,用完还得关闭。
- 业务代码和会话管理耦合:服务层每个方法都要写开session、close的样板代码,代码重复度高,还容易漏关连接。
这就像你去饭店吃饭,每让服务员上菜一次,都得亲自跑一趟后厨拿菜、端菜、洗盘子。开发效率自然上不去。
2.2 Spring集成要解决的核心问题
MyBatis和Spring集成后,主要解决了三件事:
- SqlSessionFactory单例化:交给Spring容器管理,整个应用只维护一个
SqlSessionFactory。 - 会话线程安全:用
SqlSessionTemplate包装SqlSession,它是线程安全的,可以在Service层注入使用,内部自己管理session获取和关闭。 - Mapper接口Bean化:通过动态代理,把Mapper接口直接注册成Spring Bean,Service里
@Autowired进来直接用。
所以Spring集成MyBatis后,上面的原生代码会变成:
@Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } public User getUserById(Long id) { return userMapper.findById(id); } }没有SqlSession管理,没有close(),也没有手动拿Mapper。这些脏活累活都被框架吞掉了。
2.3 传统Spring XML配置和注解方式的区别
在Spring纯XML时代,集成MyBatis需要在XML里注册两个关键Bean:
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.demo.mapper"/> </bean>SqlSessionFactoryBean负责生产SqlSessionFactory,MapperScannerConfigurer负责扫描Mapper接口并把它们注册成Bean。注意MapperScannerConfigurer实现了BeanDefinitionRegistryPostProcessor,它会在Spring的Bean定义加载阶段,主动往容器里塞Mapper定义。
@MapperScan正是把这个XML配置的语义浓缩成了一个注解。两者做的事情本质相同,只是从XML配置换成了注解加Java配置。这也是为什么后来Spring Boot项目中,很少再见到MapperScannerConfigurer的身影,因为@MapperScan一行就搞定了。
2.4 @Mapper和@MapperScan的关系与选择
提到@MapperScan,经常有人把@Mapper和它搞混。
@Mapper是MyBatis提供的注解,标注在单个Mapper接口上,告诉扫描器"我也是候选接口"。@MapperScan是让Spring主动扫描某个包下的所有接口,批量注册。
如果你不用@MapperScan,就得在每个Mapper接口上加@Mapper。但加@Mapper只是标记接口,实际触发扫描的还是Spring Boot的自动配置或MapperScannerConfigurer。换句话说,@Mapper是给扫描器看的筛选条件,@MapperScan是发起扫描的动作。
实际开发中我通常只选一种方式,推荐统一用@MapperScan加在配置类或启动类上。这样包结构一目了然,新加Mapper接口不用再重复标注某个特定注解,少了一些心智负担。
3. @MapperScan源码拆解:从注解到代理的完整链路
3.1 注解入口与关键参数
先看MapperScan注解本身的定义:
@Import(MapperScannerRegistrar.class) public @interface MapperScan { String[] value() default {}; String[] basePackages() default {}; Class<?>[] basePackageClasses() default {}; Class<? extends Annotation> annotationClass() default Annotation.class; Class<?> markerInterface() default Class.class; String sqlSessionTemplateRef() default ""; String sqlSessionFactoryRef() default ""; Class<? extends BeanNameGenerator> nameGenerator() default BeanNameGenerator.class; }几个关键参数:
value/basePackages:指定扫描包路径,不需要全量包名,父包即可,扫描会包含子包。annotationClass:给定一个注解类型,只扫描标了这个注解的接口。markerInterface:给定一个标记接口类型,只扫描实现了它的接口。sqlSessionTemplateRef/sqlSessionFactoryRef:多数据源或多SqlSessionFactory时,指定使用哪个实例。
重点是开头的@Import(MapperScannerRegistrar.class)。这个@Import就是整条链路的第一块多米诺骨牌。
3.2 @Import与ImportBeanDefinitionRegistrar的工作方式
@Import在Spring里的作用,常规理解是用来引入一个配置类或普通组件类。但比起同类,Spring还给了它一个更强大的能力:导入一个实现了ImportBeanDefinitionRegistrar的类时,可以在Spring容器加载BeanDefinition阶段,手动向BeanDefinitionRegistry注册任意BeanDefinition。
MapperScannerRegistrar正是这么做的。看核心代码:
public class MapperScannerRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware { @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { MapperScannerConfigurer configurer = new MapperScannerConfigurer(); // 复制注解中的属性到configurer中 configurer.setBasePackage(...); configurer.registerBeanDefinition(registry); } }不同版本实现略有差异,但整体思路一致。registerBeanDefinitions有两个关键入参:importingClassMetadata能拿到@MapperScan注解上的所有属性值;registry则提供了往容器注册BeanDefinition的入口。
这里需要理解一个重要时序:@MapperScan标注的配置类,Spring在处理配置类时发现@Import,然后创建MapperScannerRegistrar实例,通过ConfigurationClassPostProcessor这个BeanDefinitionRegistryPostProcessor,在标准Bean注册流程之前完成Mapper接口的注册。所以Mapper相关的BeanDefinition会早于普通用户Bean被注册,这也是它能提前准备代理Bean的前提。
3.3 核心扫描器:ClassPathMapperScanner
MapperScannerRegistrar实际注册的是MapperScannerConfigurer,而MapperScannerConfigurer内部真正干活的,是一个继承自ClassPathBeanDefinitionScanner的ClassPathMapperScanner。
ClassPathBeanDefinitionScanner是Spring提供的通用类路径扫描器,正常扫描时是找出类路径下符合条件的候选组件,比如@Component标注的类。ClassPathMapperScanner重写了关键方法,让它专门适配Mapper接口的扫描需求。
重写的地方主要是isCandidateComponent:
@Override protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { AnnotationMetadata metadata = beanDefinition.getMetadata(); return metadata.isInterface() && metadata.isIndependent(); }注意这段逻辑:metadata.isInterface()意味着只有接口才算候选。普通类哪怕加了@Component,也不会被当作Mapper处理,这符合Mapper设计初衷:Mapper必须是接口,不能是类。
同时还需要看setAnnotationClass和setMarkerInterface。如果你在@MapperScan里指定了annotationClass,扫描器会要求接口上有对应注解;如果指定了markerInterface,则要求接口实现这个标记接口。默认情况下,两个都不指定,扫描器会把包下所有接口都当作候选。
3.4 processBeanDefinitions:把接口变成工厂Bean
doScan从类路径下找到候选接口后,会生成一组BeanDefinition,然后进入processBeanDefinitions方法。这里发生了最关键的偷梁换柱:
protected void processBeanDefinitions(Set<BeanDefinitionHolder> beanDefinitions) { for (BeanDefinitionHolder holder : beanDefinitions) { GenericBeanDefinition definition = (GenericBeanDefinition) holder.getBeanDefinition(); definition.setBeanClass(this.mapperFactoryBeanClass); definition.getConstructorArgumentValues().addGenericArgumentValue(holder.getBeanName()); definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE); } }这段代码的含义是:一个原本描述"接口类型"的BeanDefinition,现在被改造成了"工厂类型"的BeanDefinition。definition.setBeanClass(MapperFactoryBean.class)表示Spring将来要实例化的对象不是接口,而是MapperFactoryBean。同时,原接口的名字被作为构造器参数传给MapperFactoryBean。
可以把这一步理解成"仓库里的货架标签被换掉了":原来标签写的是"这里放着一袋大米",现在改成"这里放着一个碾米机,里面能出来一袋大米"。Spring创建Bean时看到标签是MapperFactoryBean,就会调用它的构造器并生成实际对象。
还要注意AUTOWIRE_BY_TYPE。这个自动装配模式意味着MapperFactoryBean里声明的依赖属性(尤其是SqlSessionFactory或SqlSessionTemplate)会根据类型自动注入,这也是为什么你在配置里只需要提供数据源和SqlSessionFactory,它就能自动配上。
3.5 MapperFactoryBean到底创建了什么
MapperFactoryBean是SqlSessionDaoSupport的子类,同时实现了FactoryBean<T>接口。FactoryBean是Spring里专门用来"生产Bean的Bean":Spring容器会先创建MapperFactoryBean,然后调用它的getObject()方法,把返回值作为真正的Bean放到容器里。
public class MapperFactoryBean<T> extends SqlSessionDaoSupport implements FactoryBean<T> { private Class<T> mapperInterface; public MapperFactoryBean(Class<T> mapperInterface) { this.mapperInterface = mapperInterface; } @Override public T getObject() throws Exception { return getSqlSessionTemplate().getMapper(mapperInterface); } @Override public Class<T> getObjectType() { return this.mapperInterface; } }看到getObject()调用了getSqlSessionTemplate().getMapper(mapperInterface),这个getMapper方法就指向了MyBatis的动态代理创建逻辑。SqlSessionTemplate.getMapper()内部的实现类似:
@Override public <T> T getMapper(Class<T> type) { return getConfiguration().getMapper(type, this); }Configuration.getMapper()最终调用MapperRegistry.getMapper(),通过ProxyFactory创建JDK代理对象:
public <T> T getMapper(Class<T> type, SqlSession sqlSession) { MapperProxyFactory<T> mapperProxyFactory = knownMappers.get(type); return mapperProxyFactory.newInstance(sqlSession); }这里产出的就是带InvocationHandler的JDK动态代理对象。也就是说,Spring容器里保存的"UserMapper"本质上是一个代理类实例,instanceof UserMapper会返回true,但它的内部逻辑全部交给了MapperProxy。
3.6 最后一棒:MapperProxy方法拦截
MapperProxy实现了InvocationHandler,代理对象每次方法调用都会进入invoke方法:
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { // toString、equals、hashCode等Object方法直接调用,不走SQL return method.invoke(this, args); } // 根据Method拿到对应的MapperMethod MapperMethod mapperMethod = cachedMapperMethod(method); return mapperMethod.execute(sqlSession, args); }MapperMethod中封装了两个关键对象:SqlCommand和MethodSignature。SqlCommand里记录着这条SQL的id和类型(SELECT、INSERT、UPDATE、DELETE),MethodSignature里记录着参数映射关系和返回类型。在执行阶段,MapperMethod.execute()根据命令类型分发到SqlSession的对应方法,调用链最终落到JDBC执行器。
所以完整的调用路径是这样的:
Service -> UserMapper代理 (MapperProxy) -> MapperMethod.execute -> SqlSessionTemplate -> MyBatis执行器 (Executor) -> JDBC -> 数据库到这一步,@MapperScan的整条链路就通了:扫描接口、注册工厂Bean、工厂生成代理、代理拦截方法并把请求路由到SqlSession执行SQL。
4. 手把手实操:Spring Boot + MyBatis集成全流程
原理说得再多,不如亲手搭一个项目看看效果。这一节我会从零配置一个Spring Boot + MyBatis项目,全程带代码,跑通之后再做几个针对性实验来验证前面讲的原理。
4.1 依赖准备
用Maven做构建,创建项目后,在pom.xml加入这些依赖:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies>我用H2内存数据库做演示,省去搭建MySQL环境的步骤。实际项目替换成MySQL驱动坐标即可。
4.2 配置文件
application.yml里配置数据源和Mapper相关参数:
spring: datasource: driver-class-name: org.h2.Driver url: jdbc:h2:mem:testdb username: sa password: mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: truemapper-locations告诉框架去哪里找XML文件,type-aliases-package简化实体类别名,map-underscore-to-camel-case能把数据库里的下划线字段映射成驼峰属性。这些都属于基础配置,实际项目中几乎每次都会用。
4.3 启动类加上@MapperScan
主启动类加一行注解:
@SpringBootApplication @MapperScan("com.example.demo.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这行注解的作用范围是com.example.demo.mapper包及其子包,扫描到的所有接口都会注册成对应的代理Bean。
4.4 Mapper接口和XML映射文件
先定义一个实体类:
public class User { private Long id; private String name; private Integer age; // getter/setter省略 }再写Mapper接口:
public interface UserMapper { User findById(@Param("id") Long id); int insert(@Param("user") User user); }对应的XML文件放在classpath:mapper/下:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="findById" resultType="com.example.demo.entity.User"> SELECT id, name, age FROM user WHERE id = #{id} </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO user(name, age) VALUES(#{user.name}, #{user.age}) </insert> </mapper>XML的namespace必须是Mapper接口的全限定名,这个namespace和id组合决定了MapperMethod.SqlCommand能正确找到SQL语句。
4.5 Service注入使用
@Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } @Transactional public User insertAndQuery(User user) { userMapper.insert(user); return userMapper.findById(user.getId()); } }这里userMapper字段是构造器注入的。注意实际注入的是代理对象,但类型是UserMapper,所以编译和运行都没问题。
4.6 启动项目观察现象
启动项目后,留意日志中关于Mapper的注册过程。然后在Controller或启动逻辑里调用一次查询,控制台如果出现SQL日志,说明整条链路已经跑通。
如果去掉@MapperScan,启动大概率会看到类似这样的报错:
Description: Field userMapper in com.example.demo.service.UserService required a bean of type 'com.example.demo.mapper.UserMapper' that could not be found. Action: Consider defining a bean of type 'com.example.demo.mapper.UserMapper' in your configuration.这个报错说明Spring容器里没有UserMapper类型的Bean。因为Spring默认不会扫描Mapper接口,更不会主动为接口创建代理,所以必须通过@MapperScan或@Mapper触发扫描注册。
4.7 验证代理对象的形态
写一个小工具类,在启动后输出Bean的类型:
@Component public class MapperInspector implements ApplicationRunner { @Override public void run(ApplicationArguments args) { UserMapper userMapper = applicationContext.getBean(UserMapper.class); System.out.println(userMapper.getClass()); System.out.println(userMapper.getClass().getSuperclass()); } }输出结果通常是这样:
class com.sun.proxy.$Proxy60说明容器里的UserMapper确实是JDK动态代理对象。如果项目里做了AOP,还有其他切面逻辑,输出可能会变成CGLIB包装后的代理,但核心仍然是JDK动态代理链。
5. 高频问题与排查技巧实录
5.1 扫描不到Mapper
现象:项目启动正常,但注入Mapper时报错找不到Bean;或者应用启动时报Invalid bound statement (not found)。
常见原因:
@MapperScan的basePackages路径写错,扫不到目标包。- 接口定义在某个jar包中,但当前模块没有依赖引入该jar,classpath下压根没有这个接口。
- 配置了
annotationClass或markerInterface,但接口不满足过滤条件。 - Spring Boot自动配置时,扫描根路径和
@MapperScan的包路径不在同一个体系里,看起来扫描逻辑正确,实际包名写错一个字符。
排查思路:
- 首先确认包路径是否真实存在,看编译产物里是否有对应class文件。
- 在启动类上加
@MapperScan的包路径,写大一点也没关系,比如直接写com.example,看能否覆盖到目标mapper包。 - 检查BeanDefinition注册情况,启动时加
--debug参数,Spring Boot会输出自动配置报告和Bean注册列表,从中确认有没有UserMapper相关的Bean。 - 如果使用多个模块,优先确认mapper接口所在模块是否已经被当前应用依赖引入。
我遇到过一次比较隐蔽的情况:项目是Maven多模块,mapper模块的jar包在本地仓库里是旧版本,新的接口发布后没有重新install,导致新接口一直扫描不到。这类问题不是代码逻辑错误,而是构建管理问题,排查时容易忽略。
5.2 多个数据源时的MapperScan冲突
现象:配置了两个数据源,两个@MapperScan分别扫不同包,但某个Mapper执行时用错了连接,或者启动时报No SqlSessionFactoryBean found。
原因分析:
每个数据源通常要对应一个独立的SqlSessionFactory。如果一个Mapper在注册时没有指定使用哪个SqlSessionFactory,默认会找容器里唯一的那个;如果容器里有多个SqlSessionFactory,且没有明确指定,MapperFactoryBean在注入SqlSessionFactory时就会出问题。
正确做法是给每个@MapperScan指定对应的工厂引用:
@Configuration @MapperScan(basePackages = "com.example.mapper.primary", sqlSessionFactoryRef = "primarySqlSessionFactory") public class PrimaryDataSourceConfig { } @Configuration @MapperScan(basePackages = "com.example.mapper.secondary", sqlSessionFactoryRef = "secondarySqlSessionFactory") public class SecondaryDataSourceConfig { }同样,多数据源场景下SqlSessionTemplate也要分别构建,并配置好事务管理器。这类问题的本质是:一个SqlSessionFactory只能指向一个数据源,而Mapper必须明确自己归属于哪个工厂。
5.3 Mapper方法重载与namespace冲突
现象:XML里有多个同名SQL id,MyBatis启动时直接报错,或者执行时调到了错误的SQL。
MyBatis的SQL定位机制是namespace + id唯一确定一个MappedStatement。Java里方法可以重载,但SQL id如果重复,直接就是冲突。Mapper接口里写两个同名方法:
public interface UserMapper { User findById(@Param("id") Long id); User findById(@Param("id") Long id, @Param("name") String name); }XML里如果只有一条id="findById",第二个方法运行时会报Invalid bound statement (not found),因为MyBatis认为找不到对应的MappedStatement。如果XML里写了两条同id的SQL,MyBatis初始化就会因为重复注册报错。
建议:Mapper接口方法名保持全局唯一,不做重载。这不仅是MyBatis的限制,实际从阅读和维护角度看,重载也会让SQL映射关系变得混乱。
5.4 分页插件失效或SQL执行异常
现象:引入分页插件后,count查询生成失败,或者分页参数没有生效。
原因分析:分页插件本质上是MyBatis的Interceptor,它必须被注册到执行器链路中。在MyBatis + Spring集成时,插件要添加到SqlSessionFactoryBean的插件列表里:
@Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(new Interceptor[]{ interceptor }); return factoryBean.getObject(); }多数据源场景下,每个SqlSessionFactory都要单独配置插件,只配一个会出现另一个数据源分页不生效的情况。还有一类坑是插件版本与MyBatis版本不匹配,升级版本后没同时更新插件坐标,报错往往非常隐蔽,编译期不报错,运行期才出诡异SQL。
还有一点,分页插件对写法有要求:count查询会重写SQL,如果Mapper XML里的SQL写得太复杂,比如多了自定义JOIN或子查询,分页插件解析时容易出问题。遇到这类问题,优先查看插件生成的count SQL日志,对比原SQL和改写后的SQL,确认哪一步被转换错了。
6. 一些实用心得与扩展思路
实际使用中,我对@MapperScan最大的感受是:注释越短,背后承担的职责越重。很多人写代码时只觉得这一行是"启动MyBatis开关",但项目中一旦遇到扫描不到、多数据源路由出错、分页插件失效这类问题,如果脑子里没有完整的链路模型,排查起来会非常被动。
我自己的排查习惯是分三步走:
- 先看类型:容器里到底注入了什么东西,是
JdkDynamicAopProxy还是原始代理,用getClass()直接看。 - 再看注册源:Bean名字叫什么,是从哪个BeanDefinition创建的,用
BeanFactory的getBeanDefinition反查。 - 最后看SQL绑定:Mapper方法能不能找到对应的MappedStatement,有问题就检查XML路径、namespace和id三者是否一一对应。
这套思路几乎覆盖了我遇到过的所有MyBatis集成问题。
另外,学完@MapperScan的机制后,可以横向迁移到其他框架上。比如MyBatis-Plus的@MapperScan、Spring Data JPA的@EnableJpaRepositories、甚至一些自研starter里用@EnableXxx做的自动装配,本质上都是同一条套路:用@Import引入一个ImportBeanDefinitionRegistrar或BeanDefinitionRegistryPostProcessor,在Bean定义阶段注册一批接口的代理Bean。理解了这一个模式,再去看任何同类框架的启动注解,都不会觉得神秘了。
最后分享一个经验:代码里用@MapperScan时,包路径不要写得太具体。扫一个宽泛的父包,以后新增子模块、新增mapper接口时不用频繁改这个注解,也更不容易因为包结构调整导致扫描遗漏。代价是启动时稍微多做一点类路径扫描,但这个开销在绝大多数业务系统里完全可以忽略。比起启动多花几毫秒,业务上线后因为漏扫导致的找不到Bean问题显然更让人头疼。