1. 为什么Spring操作MyBatis会有注解和XML两套玩法
先说个结论:这两套玩法不是甲方乙方的关系,而是互补关系。MyBatis作为半自动ORM框架,核心工作就是两件事:把Java方法映射成SQL语句,把SQL查询结果映射回Java对象。Spring要操作MyBatis,本质上就是要把MyBatis的SqlSessionFactory交给Spring容器管理,再让Mapper接口能被自动扫描注册成Bean。这个过程中,SQL语句放哪里、怎么放,就是注解和XML两条路的分水岭。
我见过不少刚接触MyBatis的朋友,一上来就问"到底学注解还是学XML",其实这个问题本身就问偏了。真实项目里几乎没有纯注解或纯XML的极端情况,绝大多数是混着用。更准确的说法是:XML负责复杂SQL和动态SQL,注解负责简单CRUD和参数映射明确的场景。这个分工背后有很实在的理由,后面我会详细拆。
MyBatis的注解体系从3.0版本开始引入,现在的主流注解有@Select、@Insert、@Update、@Delete四大金刚,加上@Param、@Options、@Results、@ResultMap、@MapperScan这些辅助注解。它们的作用就是让你在一个Java接口方法上直接把SQL写死,省掉XML映射文件。但一旦SQL开始复杂——比如需要动态拼条件、批量插入、关联查询映射结果集——注解写法就开始吃力了。MyBatis的注解虽然支持@SelectProvider这类"注解里嵌脚本"的方案,但可读性和维护性真的不如XML里的动态SQL标签直观。
XML方式则是MyBatis从1.x时代起家的老本行。一个mapper namespace绑定一个接口,
Spring在这中间扮演的角色是"胶水"。不管是注解还是XML,最终都要靠Spring把Mapper接口代理成可执行的Bean注入到Service层中。Spring的核心装配逻辑是通过SqlSessionFactoryBean创建MyBatis的SqlSessionFactory实例,再通过MapperScannerConfigurer或@MapperScan把接口包扫描出来,每个接口生成一个动态代理对象。注解和XML的差异只体现在"代理对象拿到接口方法后,去哪里找对应的SQL语句"上——XML写在映射文件里,注解直接写在方法上。
这篇文章我打算从实际项目视角,把Spring通过注解和XML方式操作MyBatis的完整链路走一遍:环境怎么搭、两种方式的运行原理、混用时的优先级规则、常见坑怎么避。内容偏实战,适合已经用过一点MyBatis但想搞明白底层机制的人,也适合正在面试前梳理MyBatis知识体系的同学。
2. 环境准备:Spring整合MyBatis的最小工程长什么样
2.1 依赖引入:别漏了spring-jdbc
先说环境。即便你用的是Spring Boot,也得清楚底层依赖的本质。Spring整合MyBatis需要三类依赖:Spring基础、MyBatis本体、MyBatis与Spring的桥接包。
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.8</version> </dependency>spring-jdbc最容易漏。MyBatis本身不依赖它,但Spring管理事务、DataSourceUtils获取连接时都会用到。你不加它,启动时可能不报错,等事务一开就翻车。
数据库驱动按实际选,MySQL就加mysql-connector-j,PostgreSQL加postgresql,连接池一般用HikariCP或Druid。这里不搞花活,直接用Spring内置的DriverManagerDataSource做演示比较清楚,真实项目请务必换连接池。
2.2 Spring配置文件:装配SqlSessionFactoryBean
XML配置方式的核心是这段:
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- 指定XML映射文件位置 --> <property name="mapperLocations" value="classpath*:mapper/*.xml"/> <!-- 指定类型别名包 --> <property name="typeAliasesPackage" value="com.demo.entity"/> <!-- 可选:指定MyBatis全局配置 --> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.demo.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean>这段配置的核心逻辑要讲清楚。SqlSessionFactoryBean是MyBatis的SqlSessionFactory在Spring里的替身,#buildSqlSessionFactory()会在初始化时调用,加载所有mapperLocations指向的XML文件、解析typeAliasesPackage下的类型别名、把configuration的全局设置写入MyBatis配置。MapperScannerConfigurer则在BeanDefinition注册阶段扫描basePackage包,把每个接口注册成MapperFactoryBean。
用Spring Boot的话,这段配置就变成了application.yml里的mybatis.mapper-locations和@MapperScan注解,本质没变,换了个马甲而已。
2.3 最基础的Mapper接口:两种写法对比
假设有一张user表,字段为id、name、email。先定义实体:
public class User { private Long id; private String name; private String email; // getter/setter 省略 }XML方式的接口和映射文件:
public interface UserMapper { User selectById(Long id); List<User> selectAll(); }<?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.demo.mapper.UserMapper"> <select id="selectById" resultType="com.demo.entity.User"> SELECT id, name, email FROM user WHERE id = #{id} </select> <select id="selectAll" resultType="com.demo.entity.User"> SELECT id, name, email FROM user </select> </mapper>注解方式的接口:
@Mapper public interface UserMapper { @Select("SELECT id, name, email FROM user WHERE id = #{id}") User selectById(Long id); @Select("SELECT id, name, email FROM user") List<User> selectAll(); }到这里,能直观看出两种方式的第一差异:XML方式的接口干干净净,SQL在XML里;注解方式的SQL和接口绑定在一起。有人觉得注解方式省事省文件,有人觉得XML方式井井有条。别急着站队,接着看原理。
注意:注解方式的@Mapper不是MyBatis注解,是org.apache.ibatis.annotations.Mapper。Spring Boot里也常用这个注解来标识要被扫描的Mapper接口。它和@MapperScan是两种不同的扫描策略,后面会专门讲。
3. XML方式的核心机制:namespace到SqlSessionFactory的完整链路
3.1 MyBatis解析XML映射文件的底层行为
XML方式不是简单地"把XML文件放在那就行了"。Spring容器启动时,SqlSessionFactoryBean会调用XMLConfigBuilder和XMLMapperBuilder来做两级解析。
第一级是全局配置解析。XMLConfigBuilder处理MyBatis的全局配置,比如settings、typeAliases、typeHandlers,然后通知XMLMapperBuilder去解析所有映射文件。第二级才是mapper映射文件解析。XMLMapperBuilder对每个 标签做parse(),它会读取namespace属性,注册一个MapperRegistry的knownMappers一个Class引用,然后解析这个namespace指向的接口上的所有方法。
有个细节经常被忽略:MyBatis会把XML里的SQL和注解里的SQL同时"记住",但存的地方不一样。XML的语句会存入Configuration.mappedStatements,注解的语句也会在接口方法解析时加入mappedStatements,只不过一个的来源是XML映射文件,一个的来源是AnnotationBuilder。它们的key都是"namespace + id"。如果两边key冲突,MyBatis的处理逻辑是:XML的mappedStatement会覆盖注解的。这个规则很实用,后面讲混用时会重点用上。
#{}和${}的差异是XML方式里最容易踩的坑。#{}走PreparedStatement参数占位,安全防注入;${}是直接字符串拼接,只适合表名、排序字段这种没法预编译的场景。真实项目里,${}的使用必须严格限制,最好对传入值做白名单校验。
3.2 Spring如何把Mapper接口变成可注入的Bean
这是Spring整合MyBatis的核心秘密。MapperScannerConfigurer在Spring容器refresh阶段会注册一堆BeanDefinition,每个Mapper接口对应一个MapperFactoryBean。这个MapperFactoryBean的getObject()方法返回的不是接口的实现类,而是JDK动态代理对象。
整个调用链是这样的:Service层注入UserMapper时,Spring发现它是一个FactoryBean,于是调用getObject()返回代理对象。代理对象通过mapperInterface.getId()去Configuration.mappedStatements里查对应的MappedStatement,然后交给SqlSession执行。SqlSession拿到MappedStatement后,按statement的sqlSource类型执行SQL:XML方式的sqlSource是XMLStatementBuilder构建的DynamicSqlSource或RawSqlSource,注解方式的是ProviderSqlSource或RawSqlSource。
换句话说,不管你用注解还是XML,最终到了运行期都统一变成了"MappedStatement里有SQL,代理对象去执行",差异只在构建MappedStatement的源头。理解这一点,很多疑惑都会迎刃而解。
3.3 XML动态SQL:为什么复杂查询一定要回到XML
我举个实际例子,一个带条件查询的用户列表:
<select id="searchUsers" resultType="com.demo.entity.User"> SELECT id, name, email FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="email != null and email != ''"> AND email = #{email} </if> </where> ORDER BY id DESC <if test="offset != null and limit != null"> LIMIT #{offset}, #{limit} </if> </select>这段SQL逻辑如果用注解写,你大概只能写Provider类,用字符串拼接SQL,或者直接用@Select加上< script >标签。先不说可读性,光是字符串拼接时的引号、空格、AND的组装就够你喝一壶了。XML的 标签会自动去掉前导AND或OR, 标签做条件判断, 做集合遍历,清楚又稳定。
动态SQL是MyBatis当初区别于普通JDBC工具类的重要原因。你在XML里写的 、 标签,运行时会被动态SQL解析器翻译成一个个SqlNode节点,执行时按条件动态拼接成最终的BoundSql。这套完整的标签式动态文本处理,是MyBatis最核心的价值之一。真实项目中超过80%的业务SQL都只是"基础查询+可变条件",用XML天然好使,没必要自虐式地全用注解。
提示:如果你用Spring Boot,别忘了在application.yml里加mybatis.mapper-locations: classpath:mapper/*.xml。漏了这行,XML方式直接失效,接口调用会报"Invalid bound statement"。
4. 注解方式:@Select到动态代理的执行链路拆解
4.1 注解SQL如何变成MappedStatement
注解方式看似跟XML是两套东西,实际上在MyBatis初始化时已经完成了"归拢"。前面说过,XMLMapperBuilder在解析接口时,会在parseStatement()里既找XML标签ID,也扫描方法上的注解。具体到注解构建SQL,是MapperAnnotationBuilder在方法上同时读取@Select、@Insert、@Update、@Delete等注解,通过AnnotationBuilder生成对应的SqlSource,统一put进mappedStatements。
SQL注解和XML映射还有一个关键配合点:一个接口方法只要没有对应的XML标签,注解上的SQL就会被使用;如果XML里有同id的标签,XML覆盖注解的。这一点很多人不知道,但它是混用两种方式的最关键机制。也就是说,"注解加XML双写"在MyBatis里是允许的,而且XML优先级高。
4.2 注解家族常用成员逐一说明
直接上代码,把注解方式的完整CRUD和常见扩展写法列出来:
@Mapper public interface UserMapper { @Select("SELECT id, name, email FROM user WHERE id = #{id}") @Results(id = "userMap", value = { @Result(column = "id", property = "id", id = true), @Result(column = "name", property = "name"), @Result(column = "email", property = "email") }) User selectById(Long id); @Select("SELECT id, name, email FROM user") @ResultMap("userMap") List<User> selectAll(); @Insert("INSERT INTO user(name, email) VALUES(#{name}, #{email})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(User user); @Update("UPDATE user SET name = #{name}, email = #{email} WHERE id = #{id}") int update(User user); @Delete("DELETE FROM user WHERE id = #{id}") int deleteById(Long id); @Select("SELECT id, name, email FROM user WHERE name LIKE CONCAT('%', #{name}, '%')") List<User> findByName(@Param("name") String name); }几个容易被忽视的点:
@Results注解的id属性用于定义一个结果映射的引用ID,配合@ResultMap引用。它对应XML里的 。当查询列名和实体属性名不一致时(比如数据库是user_name,实体是userName),一种方式是开启mapUnderscoreToCamelCase,一种方式是显式写@Results。如果项目中既有列名不一致、又有嵌套对象映射,建议还是用XML的resultMap,注解的@Results面对复杂嵌套会写到你怀疑人生。
@Param注解负责给参数起名字。方法只有一个参数时,可以不加@Param,直接用#{id}也能解析到;但参数是两个及以上时,不加@Param会报错或只能通过arg0、param1这种丑爆的位置名访问。我自己的习惯是:只要方法里能看见参数名,一律加@Param,省得后续重构时炸得莫名其妙。
@Options的useGeneratedKeys和keyProperty用来处理自增主键回填。加了这两个属性,insert执行后,MyBatis会把数据库生成的自增ID回填到传入对象User的id字段。有的同学insert完想拿新ID,查了半天发现自己插入的对象ID还是null,多半就是这个属性没配。
4.3 注解方式在复杂映射和动态SQL上的边界
注解方式不是坏事,它非常适合简单业务和快速原型开发。但它有几个明确的短板:
第一,动态SQL写起来恶心。注解里支持