MyBatis 学习笔记

MyBatis 执行器

JDBC 的执行过程分为四步:

  1. 获取数据库连接(Connection)
  2. 预编译 SQL(PrepareStatement)
  3. 设置参数
  4. 执行 SQL(ResultSet)

MyBatis 提供了执行器 Executor 将这一过程进行封装,对外提供 SqlSession 让用户通过调用其 API 直接操作数据库,因为 SqlSession 持有执行器 Executor 的引用

MyBatis 提供了实现 Executor 接口的 BaseExecutor 类,以及继承 BaseExecutor 的三个子类,分别是:

  • SimpleExecutor:简单执行器
  • ReuseExecutor:可重用执行器,能重用预编译的 SQL,比如一次查询执行 100 次,如果使用 SimpleExecutor 就需要重复预编译 100 次相同的 SQL,而 ReuseExecutor 只需要预编译一次即可
  • BatchExecutor:批处理执行器,能批量处理修改操作

这三个执行器都实现了父类的抽象方法 doQuery 和 doUpdate,而父类 BaseExecutor 又会在自身的 query 和 update 方法中分别调用子类的 doQuery 和 doUpdate 方法,这是因为 BaseExecutor 在真正调用子类方法查询数据前会先查找一级缓存,如果一级缓存有数据则直接返回

然而,SqlSession 持有的并不是 BaseExecutor 的引用,而是 CachingExecutor。CachingExecutor 不是 BaseExecutor 的子类,而是一个包装类,对 BaseExecutor 的功能进行增强,增加了二级缓存。如果在二级缓存查询到数据就直接返回,否则才会执行 BaseExecutor 的查询方法

无论一级缓存还是二级缓存,默认更新数据后会清空缓存

一级缓存

一级缓存在 BaseExecutor 实现,以 key/Value 的形式保存

一级缓存生效有四个条件:

  1. SQL 语句和参数必须相同
  2. 必须是相同的 statementId
  3. 必须是同一个 SqlSession(会话)
  4. RowBounds 返回行范围必须相同

第一种情况好理解,SQL 语句和参数都是缓存 Key 的组成部分

第二种情况如下代码所示,UserMapper 定义的 selectById1 和 selectById2 的 SQL 语句和参数都相同,但不是同一个 Statement,StatementId 也是缓存 Key 的组成部分,所以无法命中缓存

java 复制代码
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user1 = mapper.selectById1(10);
User user2 = mapper.selectById2(10);

第三种情况,因为 SqlSession 和 Executor 是一对一的绑定关系,所以不同的 SqlSession 使用的不是同一个缓存

第四种情况,RowBounds 也是缓存 Key 的组成部分

清空一级缓存的场景有四个:

  1. 手动提交或回滚
  2. 设置属性 flushCache = true
  3. 执行更新操作
  4. 配置缓存作用域为 STATEMENT

当 Spring 整合 MyBatis 时,有时会出现一级缓存未被使用情况,是因为 Spring 使用动态代理生成 Mapper 代理对象,而每个代理对象在执行方法时实际上会新建一个 SqlSession 进行操作,因为每个方法的 SqlSession 不一致,所以无法使用一级缓存。解决办法是将多个方法放在同一个事务,当处于同一个事务时,就会将创建的 SqlSession 保存在该事务对应的事务管理器,每一次都从里面获取

二级缓存

一级缓存在 CachingExecutor 实现,实现方式与一级缓存类似,但它的作用范围是整个应用,可以跨线程使用

MyBatis 提供 Cache 接口定义二级缓存,并提供六个实现类:

  • SynchronizedCache:线程安全
  • LoggingCache:记录命中率
  • LRUCache:溢出清理
  • ScheduledCache:过期清理
  • BlockingCache:防止缓存穿透
  • PerpetualCache:使用内存存储

这六个实现类基于装饰器 + 责任链模式实现 MyBatis 二级缓存,一个实现类完成自己的工作会交由下一个实现类继续工作,直到走到末端

在 MyBatis 中,每个 Mapper 都必须定义自己的命名空间 NameSpace,每个 NameSpace 都有自己的二级缓存,比如 UserMapper 的二级缓存和 OrderMapper 拥有不同的命名空间,所以它们拥有各自的二级缓存,缓存的数据就是自身 Mapper 所定义查询语句所能得到的数据。不同的 SqlSession 访问同一个命名空间才是访问同一个二级缓存,因此要想使用二级缓存必须在 Mapper 对应的xml中添加 <cache> 标签或在 Mapper 接口添加 @CacheNamespace 注解声明命名空间

也可以使用 <cache-ref> 标签或 @CacheNamespaceRef 注解声明一个关联的命名空间,将两个命名空间的二级缓存组合在一起,这样对数据的保存和修改都能一起进行了,通常用于多表联查的场景。比如在 AMapper 连接查询 A 表和 B 表的数据,结果会保存在 AMapper 的二级缓存,如果 BMapper 修改 B 表的数据,此时再连接查询,得到的还是之前缓存的 B 表数据,此时可以在 A 命名空间关联 B 命名空间来解决

二级缓存是线程共享的,所以查询到的数据必须提交后才会放入二级缓存,这是为了防止脏读问题。假设会话一的事务先修改了 A 数据,再查询 A 数据并立刻更新到二级缓存,这时会话二从二级缓存查询到更新后的 A 数据,而会话一又对 A 数据进行回滚,那么会话二读到的就是脏数据

正是因为二级缓存的这个特性,因此查询到的数据在未提交前会放到一个暂存区,只有提交后才会真正更新到缓存。具体实现为 CachingExecutor 持有一个 TransactionalCacheManager,TransactionalCacheManager 保存了每个二级缓存对应的暂存区。查询时,首先通过 MappedStatement 获取对应的二级缓存,再获取其对应暂存区,暂存区会持有对应二级缓存的引用,通过二级缓存查找数据,如果不存在则查找数据库,并保存在暂存区,等到提交后才更新到缓存,并清空暂存区。更新时,因为提交后会清空二级缓存,所以暂存区也会被清空

StatementHandler

在 JDBC 中会构建 Statement 并设置参数,然后执行 SQL,StatementHandler 的作用是用于构建 Statement,绑定参数,并组装结果返回

和 Executor 一样,MyBatis 提供了 StatementHandler 接口以及实现该接口的 BaseStatementHandler 类,而其子类又有四个:

  • RoutingStatementHandler:不提供具体的实现,只是根据类型创建不同的 StatementHandler
  • SimpleStatementHandler:对应 Statement 对象,用于没有预编译参数的 SQL 的运行
  • PreparedStatementHandler:对应 PreparedStatement 对象,用于预编译参数 SQL 的运行
  • CallableStatementHandler:对应 CallableStatement 对象,用于存储过程运行

下面以常用的 PreparedStatementHandler 为例讲解其执行过程:

1. 创建 PreparedStatement

首先通过 Configuration 对象创建 RoutingStatementHandler,RoutingStatementHandler会根据设置的类型返回具体的 StatementHandler,通过调用 StatementHandler 的 instantiateStatement 方法得到对应的 Statement

2. 参数设置

MyBatis 会使用 ParamNameResolver 解析方法参数,以键值对的形式保存,一般会按参数顺序设置为 param0-value0,param1-value1 以此类推,如果对参数设置 @Param 注解则是使用其配置的参数名,jdk8 之后可以通过反射直接获取参数名。再通过 BoundSql 获取 xml 或注解中 sql 的参数占位符,通过名称映射使用对应 TypeHandler 进行类型转换,并在 PrepareStatement 中设置参数

3. 处理结果集

MyBatis 执行 SQL 后获得结果集,调用 DefaultResultSetHandler 的 handleResultSets 方法处理结果集,处理结果集的每一行并映射为对应的 Java 对象,最后放入 multipleResults 并返回

MetaObject

MetaObject 类是一个用于操作 Java 对象属性的工具类,它提供了一种统一的方式来访问和操作 Java 对象的属性

MetaObject 主要方法如下:

  • hasGetter(name):判断是否有属性 name 或 name 的 getter 方法
  • getGetterNames():获取含有 getter 相关的属性名称
  • getGetterType(name):获取 getter 方法的返回类型
  • getValue(name):获取属性值
  • hasSetter(name):判断是否有属性 name 或 name 的 setter 方法
  • getSetterNames():获取含有 setter 相关的属性名称
  • getSetterType(name):获取 setter 方法的参数类型
  • setValue(name, value):设置属性值

MetaClass 则用于获取类相关的信息

MetaClass 主要方法如下:

  • 静态方法 forClass(type, reflectorFactory):创建 MetaClass 对象
  • hasDefaultConstructor():判断是否有默认构造方法
  • hasGetter(name):判断是否有属性 name 或 name 的 getter 方法
  • getGetterNames():获取含有 getter 相关的属性名称
  • getGetInvoker(name):获取 name 的 getter 方法的 Invoker
  • hasSetter(name):判断是否有属性 name 或 name 的 setter 方法
  • getSetterNames():获取含有 setter 相关的属性名称
  • getSetterType(name):获取 setter 方法的参数类型
  • getSetInvoker(name):name 的 setter 方法的 Invoker

MyBatis 解决循环依赖

MyBatis 手动映射结果集时,ResultMap 可以包含子查询,如果 A 查询的 resultMap 包含了 B 属性,而 B 属性又是通过子查询获得,而这个子查询又是 A 查询,就会出现循环依赖问题

如下例子,调用 selectBlogById 会触发子查询 selectCommentsByBlogId,而子查询又触发 selectBlogById,造成循环依赖

<resultMap id="blogMap" type="blog" autoMapping="true"> 
  <result column="title" property="title"/>
  <collection property="comments" column="id" select="selectCommentsByBlogId"/>
</resultMap>

<resultMap id="commentMap"type="comment">
  <association property="blog" column="blog_id" select="selectBlogById"/>
</resultMap>

<select id="selectBlogById" resultMap="blogMap">
  select * from blog where id=#{id}
</select>

<select id="selectCommentsByBlogId" resultMap="commentMap">
  select * from comment where blog_id=#{blogId}
</select>
</mapper>

MyBatis 为了解决该问题,使用了延迟加载方案。MyBatis 在执行查询时,会将结果放入一级缓存,但一开始放入的值只是一个没有意义的占位符,只有等到查询过程结束才会真正将值保存到缓存。子查询在执行前会先查找一级缓存,如果存在可用的值则直接使用,如果不存在或者存在但不可用(保存的是占位符),则会构建一个能唯一表示该子查询的 DeferredLoad 保存起来。等到所有子查询走完,回到最顶层的查询时,才会遍历所有 DeferredLoad 从缓存中获取值填充属性

MyBatis 懒加载

所谓懒加载,就是在真正使用到对应数据时才从数据库获取,比如有如下 Blog 类:

java 复制代码
public class Blog implements Serializable {
    private Integer id;
    ...
    private List<Comment> comments;

  // set/get/toString 等方法
    ...... 
}

public class Comment implements Serializable {
    ...
}

定义 Mapper

xml 复制代码
<resultMap id="blogMap" type="Blog" autoMapping="true">
    <id property="id" column="id"></id>
    ...
    <association property="comments" column="id" select="selectCommentsByBlog" fetchType="lazy">
        ...
    </association>
</resultMap>

<select id="selectBlogById" resultMap="blogMap">
   SELECT * FROM blog WHERE id = #{id}
</select>

<select id="selectCommentsByBlog" resultType="Comment">
    select * from comment where blog_id = #{id}
</select>

当调用 selectBlogById 获取 Blog 对象后,其中的 comments 属性其实并没有值,只有等到使用时如调用 getComments 方法才会真正执行子查询去获取数据

当启用懒加载时,查询返回的对象是一个使用 javassist 框架创建的代理对象,该代理对象重写了被代理类的所有方法,并为每一个需要懒加载的属性创建一个装载器 ResultLoader,以 Map 的形式保存在代理对象中。当调用代理对象的方法时,会判断该方法是否是懒加载属性的 get 方法,或者是 clone()、equals()、hashCode()、toString() 方法,如果是前者就找到对应属性的 ResultLoader 执行查询,后者则会调用所有 ResultLoader 为所有懒加载属性赋值。ResultLoader 使用完毕后就会删除,因此如果 ResultLoader 只能使用一次,如果中途发生异常,那么下一次调用就不会触发懒加载了。另外,如果在调用懒加载属性的 get 方法之前使用 set 方法赋值,那么 MyBatis 会自动把对应的 ResultLoader 删除,也就不会触发懒加载了

联合查询 & 嵌套映射

使用联合查询获取结果,除了新增字段,还可以使用嵌套映射

xml 复制代码
<resultMap id="blogMap" type="blog" autoMapping="true">
  <id column="id" property="id" />
  <collection property="comments" ofType="comment" autoMapping="true" columnPrefix="comment_">
  </collection>
</resultMap>

<select id="selectBlogById" resultMap="blogMap">
  select a.id,a.title,
  c.id as comment_id,
  c.body as comment_body
  from blog a
  left join comment c on a.id=c.blog_id
  where a.id = #{id};
</select>

机制分析如下:

测试代码如下:

java 复制代码
String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession session = sqlSessionFactory.openSession();
Object o = session.selectOne("com.xxx.xxx.mapper.UserMapper.selectUserById", 1);
session.close();

前两行代码是加载 mybatis 的配置文件获取流对象,重点从第三行开始,此次会根据配置文件初始化一个会话工厂对象,具体过程如下:

  1. 解析主配置文件,把主配置文件里的所有信息封装到 Configuration 对象
  2. 解析配置的 mappers 路径下所有 xml 文件
  3. 把每个 xml 的每个 sql 标签解析成 MapperStatement 对象,MapperStatement 对象保存该 sql 标签的所有数据,并保存到 Configuration 维护的 Map 集合中,其中 key 是 sql 标签上定义的 id,value 是 MapperStatement 对象
  4. 通过 xml 定义的命名空间反射生成 class 对象,并生成对应 Mapper 的 MapperProxy 对象,MapperProxy 对象可用于生成对应 Mapper 的代理对象。将 class 对象和 MapperProxy 对象保存到 Configuration 维护的 Map 集合中,其中 key 是 class 对象,value 是对应的 MapperProxy 对象

完成初始化后,就可以通过会话工厂获取会话 session 并使用了,具体过程如下:

  1. 创建一个执行器 Executor,默认使用 SimpleExecutor
  2. 根据 id 从 Configuration 获取 MapperStatement 对象
  3. 先在缓存查找,没有就获取数据库连接,处理 sql 和入参并通过 JDBC 的方式执行,处理结果集
  4. 将结果放入缓存并返回

还可以使用动态代理的方式使用 MyBatis

java 复制代码
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
SqlSession session = sqlSessionFactory.openSession();
UserDao mapper = session.getMapper(UserDao.class);
User user = mapper.selectUserById(1);

MyBatis 会根据传入的 class 对象在 Configuration 中找到对应的 MapperProxy 代理类,并根据 MapperProxy 基于 JDK 动态代理生成代理对象。在 invoke 方法中,先获取 MapperMethod 类,然后调用 mapperMethod.execute() 方法

java 复制代码
public class MapperProxy<T> implements InvocationHandler, Serializable {

  ......

  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, args);
      } else if (isDefaultMethod(method)) {
        return invokeDefaultMethod(proxy, method, args);
      }
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
    final MapperMethod mapperMethod = cachedMapperMethod(method);
    return mapperMethod.execute(sqlSession, args);
  }

  private MapperMethod cachedMapperMethod(Method method) {
    MapperMethod mapperMethod = methodCache.get(method);
    if (mapperMethod == null) {
      mapperMethod = new MapperMethod(mapperInterface, method, sqlSession.getConfiguration());
      methodCache.put(method, mapperMethod);
    }
    return mapperMethod;
}

  ......
}

找到类 MapperMethod 类的 execute 方法,发现 execute 通过调用本类中的其他方法获取并封装返回结果。该类里有两个内部类 SqlCommand 和 MethodSignature,SqlCommand 用来封装 CRUD 操作,也就是我们在 xml 中配置的操作的节点,MethodSignature 用来封装方法的参数以及返回类型。execute 方法根据不同的操作类型分别处理,也就是说动态代理的方式本质上还是使用 SqlSession 的接口调用

java 复制代码
public class MapperMethod {

  private final SqlCommand command;
  private final MethodSignature method;

  public MapperMethod(Class<?> mapperInterface, Method method, Configuration config) {
    this.command = new SqlCommand(config, mapperInterface, method);
    this.method = new MethodSignature(config, mapperInterface, method);
  }

  ......

  public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    switch (command.getType()) {
      case INSERT: {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.insert(command.getName(), param));
        break;
      }
      case UPDATE: {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.update(command.getName(), param));
        break;
      }
      case DELETE: {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.delete(command.getName(), param));
        break;
      }
      case SELECT:
        if (method.returnsVoid() && method.hasResultHandler()) {
          executeWithResultHandler(sqlSession, args);
          result = null;
        } else if (method.returnsMany()) {
          result = executeForMany(sqlSession, args);
        } else if (method.returnsMap()) {
          result = executeForMap(sqlSession, args);
        } else if (method.returnsCursor()) {
          result = executeForCursor(sqlSession, args);
        } else {
          Object param = method.convertArgsToSqlCommandParam(args);
          result = sqlSession.selectOne(command.getName(), param);
        }
        break;
      case FLUSH:
        result = sqlSession.flushStatements();
        break;
      default:
        throw new BindingException("Unknown execution method for: " + command.getName());
    }
    if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
      throw new BindingException("Mapper method '" + command.getName() 
          + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
    }
    return result;
  }

  private <E> Object executeForMany(SqlSession sqlSession, Object[] args) {
    List<E> result;
    Object param = method.convertArgsToSqlCommandParam(args);
    if (method.hasRowBounds()) {
      RowBounds rowBounds = method.extractRowBounds(args);
      result = sqlSession.<E>selectList(command.getName(), param, rowBounds);
    } else {
      result = sqlSession.<E>selectList(command.getName(), param);
    }

    if (!method.getReturnType().isAssignableFrom(result.getClass())) {
      if (method.getReturnType().isArray()) {
        return convertToArray(result);
      } else {
        return convertToDeclaredCollection(sqlSession.getConfiguration(), result);
      }
    }
    return result;
  }

  ......
}

Configuration

Configuration 与配置文件(或者配置类)相对应,由 SqlSessionFactoryBuilder 构建而成,用于存放 MyBatis 所需要的配置项

Configuration 的配置元素有如下:

  • properties:全局参数
  • settings:开启配置项,如二级缓存,懒加载
  • environment:定义了 MyBatis 所使用的数据源
  • typeAliasRegistry:类型的别名注册机,内置了很多别名
  • typeHandlerRegistry:类型处理器注册机,TypeHandler 用于类型处理
  • MapperRegistry:映射器注册机,以 Map 的形式存储 MapperProxyFactory ,key 是对应 Mapper 的 Class 类,使用 MapperProxyFactory 可以生成对应 Mapper 的代理对象
  • MappedStatements:sql 的映射声明,key 为 Mapper 中对应的方法名,value 是对应的 MappedStatement
  • resultMaps:自定义的映射结果集

MyBatis 插件

MyBatis 提供的拦截器机制可以分别对 Executor、StatementHandler、ParameterHandler、ResultHandler 组件的操作进行拦截,用户可自定义创建拦截器添加逻辑

MyBatis 提供了拦截器链 InterceptorChain,InterceptorChain 保存了用户自定义的拦截器。当 MyBatis 创建上述四个组件时,都会使用 InterceptorChain 对自身进行包装,如果是对应的拦截器则通过动态代理返回一个包装后的代理对象

java 复制代码
public class InterceptorChain {

  private final List<Interceptor> interceptors = new ArrayList>();

  public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
      target = interceptor.plugin(target);
    }
    return target;
  }
}

...

自定义拦截器需要实现 MyBatis 的 Interceptor 接口,该接口包含了两个核心方法:intercept 和 plugin,intercept 方法用于拦截和处理具体的逻辑,而 plugin 方法用于创建代理对象并绑定拦截器

@Intercepts 注解用于标记一个类是 MyBatis 拦截器,并指定拦截的方法和参数类型

@Signature 注解用于指定要拦截的方法签名,通常与 @Intercepts 注解一起使用

  • type:指定被拦截的目标类型,目标类型为 Executor.class,表示拦截 Executor 接口的方法
  • method:指定拦截的方法名,拦截的方法名为 update,表示拦截 Executor 接口的 update 方法
  • args:指定拦截的方法参数类型,拦截的方法参数类型为 {MappedStatement.class, Object.class},表示拦截的方法需要接受一个 MappedStatement 类型的参数和一个 Object 类型的参数
java 复制代码
@Intercepts({
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class CustomInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 在执行前进行拦截逻辑
        System.out.println("Before executing the database operation...");
        // 执行原始操作
        Object result = invocation.proceed();
        // 在执行后进行拦截逻辑
        System.out.println("After executing the database operation...");
        return result;
    }

    @Override
    public Object plugin(Object target) {
        // 创建代理对象并绑定拦截器
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 可选实现,用于设置拦截器的属性
    }
}

在 MyBatis 的配置文件中添加拦截器的配置,在 <plugins> 标签内添加一个 <plugin> 标签,并指定自定义拦截器类的完整路径

xml 复制代码
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
         "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <plugins>
        <plugin interceptor="com.example.demo.mapper.plugin.CustomInterceptor">
            <property name="key1" value="value1"/>
            <property name="key2" value="value2"/>
            ......
        </plugin>
    </plugins>
</configuration>