MyBatis 嵌套结果映射

在实际应用中,除了使用简单的 select 语句查询单个表,还可能通过多表连接查询获取多张表的记录,这些记录在逻辑上需要映射成多个 Java 对象,而这些对象之间可能是一对一或一对多等复杂的关联关系,这就需要使用MyBatis 提供的嵌套映射。

1. 一对一映射

一对一映射不需要考虑是否存在重复数据,可以直接使用 MyBatis 的自动映射。使用自动映射就是通过别名让MyBatis 自动将值匹配到对应的字段上,简单的别名映射如 user_name 对应 userName。除此之外 MyBatis 还支持复杂的属性映射,可以多层嵌套,如 user.role_name 映射 user.roleName。

  1. @Getter
  2. @Setter
  3. public class AppInfoDO {
  4. private Long id;
  5. private Long appId;
  6. private Integer buId;
  7. private String name;
  8. private AppQuota quota;
  9. }

其对应的 mapper.xml 配置如下:

  1. <select id="queryAppInfoAndQuotaById" resultType="com.example.mybatis.domain.AppInfoDO">
  2. select
  3. i.id id,
  4. i.app_id appId,
  5. i.bu_id buId,
  6. i.name name,
  7. q.target_quota "quota.targetQuota",
  8. q.remaining_quota "quota.remainingQuota"
  9. from
  10. app_info i inner join app_quota q on i.app_id=q.app_id
  11. where
  12. i.id=#{id}
  13. </select>

当使用 MyBatis 的自动映射时,实体类需要一个空的构造函数,否则 MyBatis 无法将别名映射到属性上。除了使用 MyBatis 的自动映射来处理一对一嵌套外,还可以在 XML 映射文件中通过 <association> 配置结果映射。

  1. <resultMap id="AppInfoAndQuota" type="com.example.mybatis.domain.AppInfo">
  2. <id property="id" column="id"/>
  3. <result property="appId" column="app_id"/>
  4. <result property="buId" column="bu_id"/>
  5. <result property="name" column="name"/>
  6. <association property="quota" columnPrefix="quota_" javaType="com.example.mybatis.domain.AppQuota">
  7. <result property="targetQuota" column="target_quota"/>
  8. <result property="remainingQuota" column="remaining_quota"/>
  9. </association>
  10. </resultMap>
  11. <select id="queryAppInfoAndQuotaById" resultMap="AppInfoAndQuota">
  12. select
  13. i.id,
  14. i.app_id,
  15. i.bu_id,
  16. i.name,
  17. q.target_quota quota_target_quota,
  18. q.remaining_quota quota_remaining_quota
  19. from
  20. app_info i inner join app_quota q on i.app_id=q.app_id
  21. where
  22. i.id=#{id}
  23. </select>
  • association 标签主要包含以下属性:
    • property:对应实体类中的属性名,必填项。
    • javaType:属性对应的 Java 类型。
    • resultMap:可以直接使用现有的 resultMap,而不需要在这里配置。
    • columnPrefix:查询列的前缀,配置前缀后,在子标签配置 result 的 column 时可以省略前缀。
  • 同样,实体类需要一个空的构造函数。

除了通过复杂的 SQL 联表查询获取结果,还可以利用简单的 SQL 通过多次查询转换为我们需要的结果,这种方式与根据业务逻辑手动执行多次 SQL 的方式相像,最后会将结果组合成一个对象。对应 Mapper 接口为:

  1. public interface AppInfoDao {
  2. AppInfo queryAppInfoAndQuotaById(@Param("id") Long id);
  3. AppQuota queryAppQuotaByAppId(@Param("id") Long id);
  4. }

其中,第一个嵌套查询内部依赖第二个查询,对应 Mapper.xml 如下:

  1. <!-- 主SQL -->
  2. <resultMap id="AppInfoAndQuota" type="com.example.mybatis.domain.AppInfo">
  3. <id property="id" column="id"/>
  4. <result property="appId" column="app_id"/>
  5. <result property="buId" column="bu_id"/>
  6. <result property="name" column="name"/>
  7. <association property="quota" column="{id=app_id}" fetchType="lazy"
  8. select="com.example.mybatis.dao.AppInfoDao.queryAppQuotaByAppId"/>
  9. </resultMap>
  10. <select id="queryAppInfoAndQuotaById" resultMap="AppInfoAndQuota">
  11. select id, app_id, bu_id, name
  12. from app_info where id=#{id}
  13. </select>
  14. <!-- 查询额度信息的SQL -->
  15. <resultMap id="AppQuota" type="com.example.mybatis.domain.AppQuota">
  16. <id property="id" column="id"/>
  17. <result property="targetQuota" column="target_quota"/>
  18. <result property="remainingQuota" column="remaining_quota"/>
  19. </resultMap>
  20. <select id="queryAppQuotaByAppId" resultMap="AppQuota">
  21. select target_quota, remaining_quota
  22. from app_quota where app_id=#{id}
  23. </select>
  • select:另一个映射查询的 id,MyBatis 会额外执行这个查询获取嵌套对象的结果。
  • column:列名(或别名),将主查询中列的结果作为嵌套查询的参数。本例中将主查询中的 app_id 做为嵌套查询的 id 参数。如果需要多个,用逗号隔开即可。
  • fetchType:数据加载方式,分为延迟加载(lazy)和积极加载(eager)。

数据延迟加载:
由于嵌套查询会多执行 SQL,如果查询的是 N 条数据,那就会出现 N+1 问题,主 SQL 查询一次,查询出 N 条结果,N 条结果要各自执行一次查询,一共需要进行 N+1 次查询。我们可以通过 fetchType 实现延迟加载,解决 N+1 问题。当我们把 fetchType 设为 lazy 后,只有需要获取嵌套查询里的结果的时候,MyBatis 才会执行嵌套查询去获取数据。前提是将 MyBatis 全局配置中的 aggressiveLazyLoading 设为 false。

2. 延迟加载

延迟加载的含义是:暂时不用的对象不会真正载入到内存中,直到真正需要使用该对象时,才去执行数据库查询操作,将该对象加载到内存中。在 MyBatis 中,如果一个对象的某个属性需要延迟加载,那么在映射该属性时,会为该属性创建相应的代理对象并返回;当真正要使用延迟加载的属性时,会通过代理对象执行数据库加载操作,得到真正的数据。

一个属性是否能够延时加载,主要看两个地方的配置:

  • 如果属性在 中的相应节点明确地配置了 fetchType 属性,则按照 fetchType 属性决定是否延迟加载。

  • 如果未配置 fetchType 属性,则需要根据 mybatis-config.xml 配置文件中的 lazyLoadingEnabled 配置决定是否延时加载,具体配置为:

MyBatis 中的延迟加载是通过动态代理实现的,由于 MyBatis 映射的结果对象大多是普通的 JavaBean,没有实现任何接口,所以无法使用 JDK 动态代理。因此,MyBatis 提供了 CGLIB 和 JAVASSIST 的方式。

CGLIB 采用字节码技术实现动态代理功能,其原理是通过字节码技术为目标类生成一个子类,并在该子类中采用方法拦截的方式拦截所有父类方法的调用,从而实现代理功能。因为 CGLIB 使用生成子类的方式实现动态代理,所以无法代理 final 关键宇修饰的方法。CGLIB 与 JDK 动态代理之间可以相互补充:在目标类实现接口时,使用 JDK 动态代理创建代理对象,当目标类没有实现接口时,使用 CGLIB 实现动态代理的功能。JAVASSIST 是一个操纵 Java 字节码的类库,我们可以直接通过 JAVASSIST 提供的 Java API 动态生成或修改类结构。

3. 一对多映射

类似,集合的嵌套结果映射就是指通过一次 SQL 查询将所有的结果查询出来,然后通过 <collection> 标签配置的结果映射,将数据映射到不同的对象中去。

  1. @Getter
  2. @Setter
  3. public class AppInfoDO {
  4. private Long id;
  5. private Long appId;
  6. private Integer buId;
  7. private String name;
  8. private List<VmInstanceCost> costList;
  9. }

对应的 Mapper.xml 如下所示:

  1. <resultMap id="AppCostMap" type="com.example.mybatis.domain.AppInfo">
  2. <id property="id" column="id"/>
  3. <result property="appId" column="app_id"/>
  4. <result property="buId" column="bu_id"/>
  5. <result property="name" column="name"/>
  6. <collection property="costList" columnPrefix="cost_" ofType="com.example.mybatis.domain.VmInstanceCost">
  7. <id property="id" column="id"/>
  8. <result property="cost" column="cost"/>
  9. <result property="cpuCount" column="cpu_count"/>
  10. <result property="memSize" column="mem_size"/>
  11. </collection>
  12. </resultMap>
  13. <select id="queryAppCostById" resultMap="AppCostMap">
  14. select
  15. i.id, i.app_id, i.bu_id, i.name,
  16. vm.vm_type cost_vm_type, vm.cpu_count cost_cpu_count, vm.mem_size cost_mem_size
  17. from app_info i inner join vm_instance_cost vm on i.app_id=vm.app_id
  18. where i.id=#{id}
  19. </select>
  • 如果是 List 集合的映射,集合元素的类型需要使用 ofType 属性。
  • MyBatis 在处理结果时,会判断结果是否相同,如果是相同的结果,则只会保留第一个结果(通过 id 标签进行判断)。
  • collection 和 association 标签可以相互嵌套,用来进行多级结果映射,但注意嵌套时的前缀要叠加。
  1. <resultMap id="AppCostMap" type="com.example.mybatis.domain.AppInfo">
  2. <id property="id" column="id"/>
  3. <result property="appId" column="app_id"/>
  4. <result property="name" column="name"/>
  5. <collection property="costList" columnPrefix="cost_" ofType="com.example.mybatis.domain.VmInstanceCost">
  6. <id property="id" column="id"/>
  7. <result property="cost" column="cost"/>
  8. <result property="cpuCount" column="cpu_count"/>
  9. <result property="memSize" column="mem_size"/>
  10. <association property="quota" columnPrefix="quota_" javaType="com.example.mybatis.domain.AppQuota">
  11. <id property="id" column="id"/>
  12. <result property="targetQuota" column="target_quota"/>
  13. <result property="remainingQuota" column="remaining_quota"/>
  14. </association>
  15. </collection>
  16. </resultMap>
  17. <select id="queryAppCostById" resultMap="AppCostMap">
  18. select
  19. i.id,
  20. i.app_id,
  21. i.name,
  22. vm.cost cost_cost,
  23. vm.cpu_count cost_cpu_count,
  24. vm.mem_size cost_mem_size,
  25. q.target_quota cost_quota_target_quota,
  26. q.remaining_quota cost_quota_remaining_quota
  27. from
  28. app_info i
  29. inner join vm_instance_cost vm on i.app_id=vm.app_id
  30. inner join app_quota q on vm.app_id=q.app_id
  31. where
  32. i.id=#{id}
  33. </select>

标签中也支持使用 select 属性进行嵌套查询,具体示例如下:

  1. public interface AppInfoDao {
  2. AppInfo queryAppInfoById(@Param("id") Long id);
  3. List<VmInstanceCost> queryAppCostListByAppId(@Param("appId") Long appId);
  4. List<AppQuota> queryAppQuotaListByAppId(@Param("appId") Long appId);
  5. }

对应的 Mapper.xml 如下所示:

  1. <!-- 查询总信息 -->
  2. <resultMap id="AppInfo" type="com.example.mybatis.domain.AppInfo">
  3. <id property="id" column="id"/>
  4. <result property="appId" column="app_id"/>
  5. <result property="buId" column="bu_id"/>
  6. <result property="name" column="name"/>
  7. <collection property="costList" fetchType="lazy" column="{appId=app_id}"
  8. select="com.example.mybatis.dao.AppInfoDao.queryAppCostListByAppId"/>
  9. </resultMap>
  10. <select id="queryAppInfoById" resultMap="AppInfo">
  11. select id, app_id, bu_id, name from app_info where id=#{id}
  12. </select>
  13. <!-- 查询成本信息 -->
  14. <resultMap id="AppCostList" type="com.example.mybatis.domain.VmInstanceCost">
  15. <id property="id" column="id"/>
  16. <result property="appId" column="app_id"/>
  17. <result property="vmType" column="vm_type"/>
  18. <result property="cost" column="cost"/>
  19. <result property="cpuCount" column="cpu_count"/>
  20. <result property="memSize" column="mem_size"/>
  21. <collection property="quotaList" fetchType="lazy" column="{appId=app_id}"
  22. select="com.example.mybatis.dao.AppInfoDao.queryAppQuotaListByAppId"/>
  23. </resultMap>
  24. <select id="queryAppCostListByAppId" resultMap="AppCostList">
  25. select app_id, vm_type, cost, cpu_count, mem_size from vm_instance_cost where app_id=#{appId}
  26. </select>
  27. <!-- 查询额度信息 -->
  28. <resultMap id="AppQuotaList" type="com.example.mybatis.domain.AppQuota">
  29. <id property="id" column="id"/>
  30. <result property="targetQuota" column="target_quota"/>
  31. <result property="remainingQuota" column="remaining_quota"/>
  32. </resultMap>
  33. <select id="queryAppQuotaListByAppId" resultMap="AppQuotaList">
  34. select target_quota, remaining_quota from app_quota where app_id=#{appId}
  35. </select>

MyBatis 缓存配置

使用缓存可以使应用更快地获取数据,避免频繁的数据库交互,尤其是在查询越多、缓存命中率越高的情况下,使用缓存的作用就越明显。MyBatis 作为持久化框架,提供了非常强大的查询缓存特性,可以非常方便地配置和定制使用。

1. 一级缓存

MyBatis 的一级缓存存在于 SqlSession 的生命周期中,在同一个 SqlSession 中查询时,MyBatis 会把执行的方法和参数通过算法生成缓存的键,将键和查询结果存入一个 Map。如果同一个 SqlSession 中执行的方法和参数完全一致,那么通过算法会生成相同的键,因此当 Map 中已经存在该键时,会直接返回缓存的对象。

MyBatis 中的一级缓存(也叫本地缓存)默认会启用,用户不能控制。但是一级缓存只作用于 SELECT 语句,任何 INSERT、UPDATE、DELETE 操作都会清空一级缓存。

2. 二级缓存

MyBatis 的二级缓存非常强大,它不同于一级缓存只存在于 SqlSession 的生命周期中,而是可以理解为存在于SqlSessionFactory 的生命周期中。当 SqlSession 关闭时,SqlSession 会保存数据到二级缓存中,在这之后二级缓存才会有缓存数据。

MyBatis 的全局配置中有一个参数 cacheEnabled,这个参数是二级缓存的全局开关,默认是 true,初始状态
为启用状态。如果把这个参数设置为 false,即使有后面的二级缓存配置,也不会生效。MyBatis 的二级缓存是和命名空间绑定的,即二级缓存需要配置在 Mapper.xml 映射文件中,或者配置在 Mapper.java 接口中。在映射文件中,命名空间就是 XML 根节点 mapper 的 namespace 属性。在 Mapper 接口中,命名空间就是接口的全限定名称。

1)Mapper.xml 中配置二级缓存
在保证二级缓存的全局配置开启的情况下,给 Mapper.xml 开启二级缓存只需要添加 <cache/> 元素即可。

  1. <mapper namespace="com.example.mybatis.dao.AppInfoDao">
  2. <cache/>
  3. <!-- 其他配置 -->
  4. </mapper>
  • 映射语句文件中的所有 SELECT 语句将会被缓存。
  • 映射语句文件中的所有 INSERT、UPDATE、DELETE 语句会刷新缓存。
  • 缓存会使用 LRU(最近最少使用的)算法来收回。
  • 根据时间表(如 no Flush Interval,没有刷新间隔),缓存不会以任何时间顺序来刷新。
  • 缓存会存储集合或对象(无论查询方法返回什么类型的值)的 1024 个引用。
  • 缓存会被视为 read/write(可读/可写)的, 缓存对象可以安全地被调用者修改。

标签还提供了多个属性来修改缓存规则,比如:

  1. <cache eviction="FIFO" readOnly="true" flushInterval="6000" size="512" />
  • eviction(收回策略):LRU(默认值)、FIFO。
  • flushinterval(刷新间隔):代表一个合理的毫秒形式的时间段。默认不设置,即没有刷新间隔,缓存仅在调用语句时刷新。
  • size(引用数目):默认是 1024 。
  • readOnly(只读):只读的缓存会给所有调用者返回缓存对象的相同实例,因此这些对象不能被修改(性能高)。而可读写的缓存会通过序列化返回缓存对象的拷贝(性能慢,但安全),因此默认是 false。

2)Mapper 接口中配置二级缓存
使用注解时,如果想对注解方法启用二级缓存,只需在 Mapper 接口添加 @CacheNamespace 注解即可。

  1. @CacheNamespace
  2. public interface AppInfoDao {
  3. //...
  4. }

@CacheNamespace 注解同样可以配置各项属性。

  1. public @interface CacheNamespace {
  2. Class<? extends Cache> implementation() default PerpetualCache.class;
  3. // 缓存收回策略
  4. Class<? extends Cache> eviction() default LruCache.class;
  5. long flushInterval() default 0;
  6. int size() default 1024;
  7. // true为读写,false为只读
  8. boolean readWrite() default true;
  9. boolean blocking() default false;
  10. Property[] properties() default {};
  11. }

配置二级缓存后,当调用 SELECT 时,二级缓存就已经开始起作用了。注意,由于配置的是可读写的缓存,而 MyBatis 使用 SerializedCache 序列化缓存来实现可读写缓存类,并通过序列化和反序列化来保证通过缓存获取数据时,得到的是一个新的实例。因此,如果配置为只读缓存,MyBatis 就会使用 Map 来存储缓存值,这种情况下,从缓存中获取的对象就是同一个实例。因为使用可读写缓存,可以使用 SerializedCache 序列化缓存。这个缓存类要求所有被序列化的对象必须实现Serializable 接口,所以需要修改实体对象。

二级缓存虽然能提高应用效率,减轻数据库服务器的压力,但如果使用不当,很容易产生脏数据。MyBatis 的二级缓存是和命名空间绑定的,通常情况下每个 Mapper 都有自己的二级缓存,不同 Mapper 的二级缓存互不影响。当我们进行多表联合查询时,肯定会将该查询放到某个命名空间下的映射文件中,这样一个多表的查询就会缓存在该命名空间的二级缓存中。但涉及这些表的增、删、改操作通常不在一个映射文件中,它们的命名空间不同,因此当有数据变化时,多表查询的缓存未必会被清空,这种情况下就会产生脏数据。

MyBatis 拦截器

MyBatis 允许用户使用自定义拦截器对 SQL 语句执行过程中的某一点进行拦截。默认情况下,MyBatis 允许拦截器拦截 Executor、ParameterHandler、ResultSetHandler 以及 StatementHandler 中的方法。具体可拦截的方法如下:

  • Executor 中的 update、query、flushStatements、commit、rollback、getTransaction、close、isClosed 方法。
  • ParameterHandler 中的 getParameterObject、setParameters 方法。
  • ResultSetHandler 中的 handleResultSets、handleOutputParameters 方法。
  • StatementHandler 中的 prepare、parameterize、batch、update、query 方法。

1. 拦截器接口

MyBatis 中使用的拦截器都需要实现 Interceptor 接口。Interceptor 接口是 MyBatis 插件模块的核心接口,其接口定义如下:

  1. public interface Interceptor {
  2. // 执行拦截逻辑的方法
  3. Object intercept(Invocation invocation) throws Throwable;
  4. // 决定是否触发intercept()方法
  5. default Object plugin(Object target) {return Plugin.wrap(target, this);}
  6. // 根据配置初始化Interceptor对象
  7. default void setProperties(Properties properties) {}
  8. }

用户自定义拦截器的 plugin() 方法,可以考虑使用 MyBatis 提供的 Plugin 工具类实现,它实现了 InvocationHandler 接口,并提供了 一个 wrap() 静态方法用于创建代理对象。

intercept 方法是 MyBatis 运行时要执行的拦截方法。通过该方法的参数 invocation 可以得到很多有用的信息。比如:getTarget() 可以获取当前被拦截的对象,getMethod() 可以获取当前被拦截的方法,getArgs() 可以返回被拦截方法中的参数。proceed() 可以执行被拦截对象真正的方法。

2. 拦截器签名

用户自定义的拦截器除了需要实现拦截器接口外,还需要使用 @Intercepts 和 @Signature 这两个注解进行标识。@Intercepts 注解中的属性是一个 @Signature 数组,每个 @Signature 注解中都标识了该插件需要拦截的方法,因此可以在同一个拦截器中同时拦截不同的接口和方法。

  1. @Intercepts(@Signature(
  2. type = Executor.class,
  3. method = "query",
  4. args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
  5. ))
  • type:设置拦截的接口,可选值是上文提到的 4 个接口。
  • method:设置拦截接口中的方法名,可选值是上文 4 个接口对应的方法,需要和接口匹配。
  • args:设置拦截方法的参数类型数组,通过方法名和参数类型可以确定唯一一个方法。

自定义拦截器示例如下:

  1. @Intercepts(@Signature(
  2. type = Executor.class,
  3. method = "query",
  4. args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
  5. ))
  6. public class SimpleInterceptor implements Interceptor {
  7. @Override
  8. public Object plugin(Object target) {
  9. return Plugin.wrap(target, this);
  10. }
  11. @Override
  12. public void setProperties(Properties properties) {
  13. }
  14. @Override
  15. public Object intercept(Invocation invocation) throws Throwable {
  16. System.out.println("enter interceptor");
  17. Object result = invocation.proceed();
  18. System.out.println("leave interceptor");
  19. return result;
  20. }
  21. }

定义完一个自定义拦截器后,需要在 mybatis-config.xml 配置文件中对该拦截器进行配置,如下所示:

  1. <plugins>
  2. <plugin interceptor="org.xl.mybatis.interceptor.SimpleInterceptor"/>
  3. </plugins>

这样一个用户自定义的拦截器就配置好了。在初始化时,会通过 XMLConfigBuilder.pluginElement() 方法解析mybatis-config.xml 配置文件中定义的 节点,得到相应的 Interceptor 对象以及配置的相应属性,之后会调用 Interceptor.setProperties(properties) 方法完成对 Interceptor 对象的初始化配置,最后将 Interceptor 对象添加到 Configuration.interceptorChain 字段中保存。