从零开始 Spring Boot 23:MyBatis

从零开始 Spring Boot 23:MyBatis

spring boot

图源:简书 (jianshu.com)

虽然国内大部分使用MyBatis的Spring Boot项目都会使用MyBatisPlus,因此开发人员(包括我)对MyBatis的认识并不深入,甚至于很多功能都不清楚是MyBatisPlus实现的还是MyBatis实现的,所以全面系统地学习MyBatis很有必要。

本篇文章从一个空白项目开始,不包含MyBatisPlus,只使用MyBatis,以此学习相关功能。

对应的最终示例代码和数据库见learn_spring_boot/ch23 (github.com)。

依赖

添加依赖

<dependency>
    <groupId>org.mybatis.spring.bootgroupId>
    <artifactId>mybatis-spring-boot-starterartifactId>
    <version>2.2.2version>
dependency>

MyBatis适配的Spring Boot是有版本要求的,Spring Boot 2.5以上可以使用2.2版本的MyBatis,具体的版本对应可以查看:

  • mybatis-spring-boot-autoconfigure – Introduction

映射器

MyBatis通过映射器(Mapper)来“绑定”SQL,并执行查询。

可以使用@Mapper注解简单地引入一个映射器:

@Mapper
public interface BookMapper {
    @Select("SELECT * FROM book WHERE `type`=#{type}")
    List<Book> getBooks(@Param("type") Integer type);
}

对应的SQL可以通过@Select注解绑定,这样可以不用编写专门的XML文件。

对应的entity对象:

public class Book {
    private Integer id;
    private String name;
    private String description;
    private Integer userId;
    private Integer type;
    private String extra;
    private Integer delFlag;
}

可以让入口类实现CommandLineRunner接口并重写run进行测试,这样可以在应用启动后通过映射器执行对应的SQL查询:

@SpringBootApplication
public class Books2Application implements CommandLineRunner {
    @Autowired
    BookMapper bookMapper;

    public static void main(String[] args) {
        SpringApplication.run(Books2Application.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        System.out.println(bookMapper.getBooks(5));
    }
}

当然,需要使用@Autowired绑定映射器实例,或者通过构造器绑定也是可行的。

控制台可以看到类似下面的输出:

[cn.icexmoon.books2.book.entity.Book@5d145c53, cn.icexmoon.books2.book.entity.Book@35a514f1, cn.icexmoon.books2.book.entity.Book@301b71f, cn.icexmoon.books2.book.entity.Book@54b5e6a4, cn.icexmoon.books2.book.entity.Book@2bbccea5, cn.icexmoon.books2.book.entity.Book@362355bf, cn.icexmoon.books2.book.entity.Book@3814b52b, cn.icexmoon.books2.book.entity.Book@43eff44b, cn.icexmoon.books2.book.entity.Book@eebd375, cn.icexmoon.books2.book.entity.Book@22c9e1ba, cn.icexmoon.books2.book.entity.Book@3fe9b525, cn.icexmoon.books2.book.entity.Book@398433f, cn.icexmoon.books2.book.entity.Book@53385842, cn.icexmoon.books2.book.entity.Book@3e91a7ae, cn.icexmoon.books2.book.entity.Book@4c237dc4, cn.icexmoon.books2.book.entity.Book@de35ec4]

可以通过重写BooktoString方法让输出更友好。

自动扫描

可以使用@MapperScan注解实现自动扫描:

@SpringBootApplication
@MapperScan("cn.icexmoon.books2.book.mapper")
public class Books2Application implements CommandLineRunner {
   // ...
}

这里就不再需要@Mapper注解了。

应用启动后就会自动扫描cn.icexmoon.books2.book.mapper包中的接口定义,并注册为映射器。

如果你的项目结构采用/模块/mapper这种层级结构,可以使用通配符来扫描映射器:

@MapperScan("cn.icexmoon.books2.*.mapper")

也可以使用;间隔来指定多个具体的映射器包目录:

@MapperScan("cn.icexmoon.books2.book.mapper;cn.icexmoon.books2.user.mapper")

SqlSession

MyBatis的核心概念是SqlSession,MyBatis通过它来连接数据库,执行SQL、提交事务或进行回滚。

一般情况下我们不需要手动创建,使用默认提供的就可以了,当然也可以手动创建:

package cn.icexmoon.books2.system;
// ...
@Configuration
public class MyBatisConfig {
    @Resource
    private DataSource dataSource;

    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        return factoryBean.getObject();
    }

    @Bean
    public SqlSessionTemplate sqlSession() throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory());
    }
}

这里有两层抽象,需要先创建一个SqlSession的工厂类(接口)SqlSessionFactory的实例,然后用SqlSessionFactory来创建一个SqlSessionTemplate作为具体的SqlSession实现。

这里用SqlSessionTemplate的好处在于这是一个线程安全的组件,可以用于并发。

有了SqlSession后就可以直接调用映射器的对应方法:

package cn.icexmoon.books2.user.service.impl;

// ...
@Service
public class RoleServiceImpl implements RoleService {
    @Autowired
    private SqlSession sqlSession;

    @Override
    public Role getRoleById(Integer id) {
        return sqlSession.selectOne("cn.icexmoon.books2.user.mapper.RoleMapper.getRoleById", id);
    }

    @Override
    public List<Role> getAllRoles() {
        return sqlSession.selectList("cn.icexmoon.books2.user.mapper.RoleMapper.getAll");
    }
}

如果现在通过这个Service进行接口调用,就会报错,内容类似下边:

Mapped Statements collection does not contain value...

实际上这是因为MyBatis的映射器都由一个映射器注册器(MapperRegister)管理,映射器必须要注册到映射器注册器后才能被正常使用,这里通过SqlSession.selectList之类的API调用也一样。

所以还需要在配置类中进行注册:

package cn.icexmoon.books2.system;

// ...
@Configuration
public class MyBatisConfig {
	// ...

    @Bean
    public MapperFactoryBean<RoleMapper> roleMapper() throws Exception {
        MapperFactoryBean<RoleMapper> factoryBean = new MapperFactoryBean<>(RoleMapper.class);
        factoryBean.setSqlSessionFactory(sqlSessionFactory());
        return factoryBean;
    }
}

这里通过代码的方式创建了一个RoleMapper的Bean,效果和@Mapper注解是相同的。

当然,以上这些代码都是为了说明SqlSession的作用和用途,实际中并不需要这么使用。此外,MyBatis对数据库的所有操作都是通过SqlSession实现的,它的一大优点是使用了Spring事务,当然后者其实也是用的JDBC的事务。这样的好处是只要和Spring用的是同一数据源,实际上无论通过MyBatis进行的数据库事务还是非MyBatis的数据库事务,都是由Spring管理的事务,就不存在冲突。

实际测试发现,Service层的相应方法必须使用@Transactional注解才能在执行SQL时使用Spring事务,否则就会在日志中输出JDBC Connection xxx wrapping xxx will not be managed by Spring这样的信息。所以不显示使用事务的话通过MyBatis执行单条SQL是不受Spring事务约束的,而是MyBatis单独开启一个事务并立即提交。

XML

虽然现在的MyBatis可以通过@Select等注解为映射器绑定SQL,但实际上更常见的是通过定义XML来绑定SQL。

比如我们需要为映射器添加一个查询角色的方法:

package cn.icexmoon.books2.user.mapper;
// ...
public interface RoleMapper {
	// ...
    List<Role> queryRoles(@Param("dto") RoleQueryDTO dto);
}

返回值是一个包含Role对象的列表,查询条件封装为一个DTO类:

package cn.icexmoon.books2.user.dto;
// ...
@Data
public class RoleQueryDTO {
    private String name;
    private String cname;
    private Integer level;
}

需要注意的是,为了在接下来绑定的XML中能够“引用”方法的入参,必须告诉XML入参的名称,具体是通过@Param注解标识的方式来完成。实际上这是因为在早期的Java(SDK1.8版本之前)中,反射机制存在缺陷,在运行时Java会抹除接口的参数名,导致无法通过反射机制获取接口参数名称,所以只能通过加注解的方式完成。换句话说,@Param注解并非必须的,这和你项目运行的JDK版本相关,如果你使用的是某个较高版本,是可以不用写的(可以通过反射机制正确获取接口参数名),但如果你希望有更好的兼容性,可以加上。

接下来为映射器的方法创建承载对应SQL语句的XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.user.mapper.RoleMapper">
    <select id="queryRoles" resultType="cn.icexmoon.books2.user.entity.Role">
        SELECT * FROM
            `role`
        WHERE 1=1
          <if test="dto.name != null and '' != dto.name">
            AND `name` LIKE '%${dto.name}%'
          if>
          <if test="dto.cname != null and '' != dto.cname">
            AND cname LIKE '%${dto.cname}%'
          if>
          <if test="null != dto.level and dto.level >= 0">
            AND `level`=${dto.level}
          if>
          AND `del_flag`=0
    select>
mapper>

一般习惯将Mapper对应的XML创建在resources/mapper目录。

第一行的标签并非必须,但这是XML标准规定的,用于在XML开始部分声明XML版本和使用的编码,最好写上。

第二行的用于添加一个XML对应的dtd文件,用于后续XML格式的约束和验证。

XML主体是一个根节点mapper,其属性namespace内容是SQL对应的映射器类的完整类名,通过这个属性MyBatis就可以将这个XML绑定到具体的映射器。

<mapper namespace="cn.icexmoon.books2.user.mapper.RoleMapper">
	
mapper>

映射器具体的方法对应的SQL由mapper的相应子节点体现,不同类型的SQL需要使用不同的子节点,对于最长用的查询SQL,由select子节点定义:

<select id="queryRoles" resultType="cn.icexmoon.books2.user.entity.Role">
	
select>

selectid属性对应映射器中的方法名,MyBatis依靠这个属性将SQL和映射器的方法执行进行绑定。resultType属性用于说明返回的SQL查询结果集中每一行数据要包装为的目标类型,通常我们会指定为对应的entity类或者POJO类。具体的包装方式是将对应SQL结果集的字段和resultType属性中的类属性一一对应。

具体的SQL以select节点的值方式定义:

SELECT * FROM
`role`
WHERE 1=1
<if test="dto.name != null and '' != dto.name">
AND `name` LIKE '%${dto.name}%'
if>
<if test="dto.cname != null and '' != dto.cname">
AND cname LIKE '%${dto.cname}%'
if>
<if test="null != dto.level and dto.level >= 0">
AND `level`=${dto.level}
if>
AND `del_flag`=0

可以通过if节点构建复杂查询,其test属性定义条件语句,如果条件为真,就会嵌入if节点中的SQL。条件语句中可以直接引用映射器方法的参数。

  • 因为XML中有一些特殊字符是XML的“特殊字符”,所以不能在test属性中使用&&,可以用and替代,相应的,||可以用or替代。
  • WHERE条件开始的1=1是为了更方便进行条件拼接,实际上这里可以用del_flag=0替代。

在SQL中,我们可以用${}来引用映射器的入参,熟悉Bash脚本的应该不会陌生,实际上就是将变量的值“替换”进字符串。

现在如果直接测试,可能会看到类似下边的错误信息:

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): xxxxx

这是因为MyBatis不知道我们的XML存放位置,需要通过添加配置信息来解决:

mybatis.mapper-locations=classpath*:mapper/*.xml

${}和#{}

实际上上边的SQL是存在问题的,用#{}来拼接SQL会存在SQL注入漏洞。

假设我们用下边的查询条件进行查询:

{
    "name":"guest%' AND cname LIKE '%访客"
}

通过控制台打印SQL,你可以看到实际上执行的SQL查询是:

==>  Preparing: SELECT * FROM `role` WHERE 1=1 AND `name` LIKE '%guest%' AND cname LIKE '%访客%' AND `del_flag`=0
==> Parameters: 
<==    Columns: id, name, cname, level, del_flag
<==        Row: 1, guest, 访客, 0, 0
<==      Total: 1

当然这里的演示相当简单,实际上可以通过构造子查询来干更多的事。

所以我们应当使用#{}而不是${}来构造查询,前者实际上是用?作为占位符来构建SQL,然后将值以参数的形式填充,这样就可以有效避免SQL注入攻击。

<if test="dto.name != null and '' != dto.name">
    AND `name` LIKE CONCAT('%',#{dto.name},'%')
if>
<if test="dto.cname != null and '' != dto.cname">
    AND cname LIKE CONCAT('%',#{dto.cname},'%')
if>
<if test="null != dto.level and dto.level >= 0">
    AND `level`=#{dto.level}
if>

需要注意的是这里并没有将LIKE '%${dto.name}%'简单替换为LIKE '%#{dto.name}%',实际上这样是无法运行的,原因是后者将产生类似LIKE '%?%'这样的SQL,而这里的字符串内的?并不是一个有效的参数占位符,会导致占位符实际上和入参数目不符的错误。

所以正确的方式是使用SQL的字符串连接函数CONCAT%与入参的值进行连接,如LIKE CONCAT('%',#{dto.name},'%')

现在用同样的方式进行测试,就不会查询出结果,控制台的SQL日志显示如下:

==>  Preparing: SELECT * FROM `role` WHERE 1=1 AND `name` LIKE CONCAT('%',?,'%') AND `del_flag`=0
==> Parameters: guest%' AND cname LIKE '%访客(String)
<==      Total: 0

可以看到用户输入都以LIKE条件的值来对待了,不存在SQL注入的漏洞。

当然,${}也有其存在的必要和意义,如果某些时候#{}不能满足需要,可以尝试用${}进行替换,需要明确的是,用户输入的变量只能使用#{},否则就会出现SQL注入漏洞。

resultMap

如果你注意,可能会发现查询的结果中RoledelFlag属性是null,也就是结果集并没有正确映射到返回的Role对象中。这是因为SQL查询结果集中字段是del_flag,而Role中的属性是delFlag,两者并不完全一致。

最简单的解决方式是修改SQL,使用字段别名:

SELECT *,del_flag AS delFlag FROM
...

如果你不想这么做,可以使用resultMap来实现手动映射:

<mapper namespace="cn.icexmoon.books2.user.mapper.RoleMapper">
    <select id="queryRoles" resultMap="roleMap">
        
    select>
    <resultMap id="roleMap" type="cn.icexmoon.books2.user.entity.Role">
        <id property="id" column="id">id>
        <result property="delFlag" column="del_flag">result>
    resultMap>
mapper>

resultMapid属性定义了可以在当前XML中引用的标识,type定义了结果集要映射的目标Java类型。

resultMap中的idresult节点定义了主键和其它字段的映射关系,其中的property属性对应Java类中的属性名称,column属性对应SQL查询结果中的字段名称。通过定义这些节点就可以手动完成结果集到Java类的映射。当然并不用完成所有的映射,我们只需要在默认行为(名称一致匹配)的基础上,额外指定需要“特殊对待”的映射就行,比如这里的del_flag字段。

  • MyBatis会先指定默认的映射行为,然后执行resultMap定义的映射行为。
  • idresult还有一个javaType属性,如果映射的目标是一个Java Bean,MyBatis可以通过反射获取对应属性的Java类型,就不需要指定该属性,单如果映射的目标是一个容器,比如Map,就需要通过该属性指定具体的Java类型。
  • idresult的区别,官方文档是这么解释的:*这两者之间的唯一不同是,id元素对应的属性会被标记为对象的标识符,在比较对象实例时使用。 这样可以提高整体的性能,尤其是进行缓存和嵌套结果映射(也就是连接映射)的时候。*总的来说,如果是采用一般性的为所有表使用id字段作为自增主键的话,直接使用id字段指定id节点映射即可。

需要注意的是,使用resultMap后需要将select中的resultType字段修改为resultMap,并将值改为对应的resultMapid

自动映射

实际上大多数情况下SQL的字段都会被定义为下划线分隔的形式,比如del_flag,而Java的属性命名通常遵循驼峰形式,比如delFlag,这种情况相当常见,所以MyBatis本身就支持这种情况下的映射,只需要添加配置就可以开启:

mybatis.configuration.map-underscore-to-camel-case=true

现在你可以删除XML中的del_flag字段映射或者将select改回使用resultType字段,程序都会有正确的映射行为。

实际上MyBatis有三种自动映射行为:

  • none,完全禁用自动映射,此时只有手动添加的映射会生效。
  • partial,对手动映射指定的字段之外的字段进行自动映射。
  • full,对所有属性使用自动映射。

MyBatis默认使用partial,如果有特殊需要,也可以通过以下配置来修改:

mybatis.configuration.auto-mapping-behavior=full

full可能会引发一些问题,需要谨慎使用。

无论全局的自动映射行为如何,我们都可以在具体的resultMap中通过autoMapping属性开启或者关闭:

<resultMap id="roleMap" type="cn.icexmoon.books2.user.entity.Role" autoMapping="false">

此时在使用这个resultMap时,只有手动映射的属性会被填充。

构造器

如果entity或POJO类具备构造器,我们也可以通过constructor节点通过构造器完成映射:

<resultMap id="roleMap" type="cn.icexmoon.books2.user.entity.Role" autoMapping="false">
	<constructor>
        <idArg column="id" javaType="Integer" name="id">idArg>
        <arg column="name" javaType="String" name="name">arg>
        <arg column="cname" javaType="String" name="cname">arg>
        <arg column="del_flag" javaType="Integer" name="delFlag">arg>
        <arg column="level" javaType="Integer" name="level">arg>
	constructor>
resultMap>

这里idArgarg节点对应的是构造器中的形参,其属性name对应的就是形参的参数名称:

@ToString
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
    private Integer id;
    private String name;
    private String cname;
    private Integer level;
    private Integer delFlag;
}

这里是通过@AllArgsConstructor创建的构造器,所以参数名就是属性名。

特别的,如果constructor中的参数顺序与构造器参数顺序完全一致,可以省略name属性:

        <constructor>
            <idArg column="id" javaType="Integer">idArg>
            <arg column="name" javaType="String">arg>
            <arg column="cname" javaType="String">arg>
            <arg column="level" javaType="Integer">arg>
            <arg column="del_flag" javaType="Integer">arg>
        constructor>

但并不推荐这么做。

关联

很常见的,我们的表数据会存在一些关联关系,比如书籍表中的数据和用户表就存在一对一关系,每一个书籍数据都有对应一个上传书籍的用户,在表中由字段book.user_id体现,可以通过SQL查看这种关联关系:

SELECT * FROM
book AS b,
`user` AS u
WHERE b.user_id=u.id;

同样可以在XML中实现这种关联关系,从而可以通过调用映射器的方法获取建立了关联关系的结果。

假设我们需要查询一个包含了上传用户信息的书籍信息,对应的entity类如下:

package cn.icexmoon.books2.book.entity;

// ...
@Data
public class Book {
    private Integer id;
    private String name;
    private String description;
    private Integer userId;
    private Integer type;
    private String extra;
    private Integer delFlag;
    //添加图书的用户
    private User user;
}

这里user属性用于保存关联到Book对象上的User对象。

添加映射器的接口:

package cn.icexmoon.books2.book.mapper;

// ...
public interface BookMapper {
	// ...

    /**
     * 根据图书ID获取图书
     * @param id 图书ID
     * @return
     */
    Book getBookById(@Param("id") int id);
}

添加XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
    <select id="getBookById" resultMap="bookResult">
        SELECT *
        FROM book
        WHERE id = #{id}
    select>
    <resultMap id="bookResult" type="cn.icexmoon.books2.book.entity.Book">
        <id column="id" property="id">id>
        <result property="userId" column="user_id">result>
        <association property="user" column="user_id" javaType="cn.icexmoon.books2.user.entity.User"
                     select="getUserById">association>
    resultMap>
    <select id="getUserById" resultType="cn.icexmoon.books2.user.entity.User">
        SELECT *
        FROM `user`
        WHERE id = #{user_id}
    select>
mapper>

为了实现关联关系,我们需要先创建一个getBookById查询,用于查询单一的书籍信息。其结果映射由一个ID为bookResultresultMap定义,这个resultMap中除了必要的idresult节点外,包含一个association节点,用于指定book表到user表的关联关系。

association节点有以下几个重要属性:

  • property,对应resultMaptype中指定的Java类的属性。
  • column,用于关联的字段,在这个示例中就是指book表的user_id字段。这个字段将会作为参数传递给关联的查询。比较特别的是,如果不是采用统一ID自增方式建表,而是存在联合外键,可以通过column="{prop1=col1,prop2=col2}"的方式指定多个列作为联合外键,同样的,prop1prop2会作为参数传递给关联查询。
  • javaType,和之前一样,指映射到的目标Java类型,一般不需要手动指定,MyBatis可以自动获取。
  • select,用于关联的查询ID,也就是select节点的ID。

指定了关联关系后,我们就可以在关联查询getUserById中用传入的参数user_id构建SQL,查询书籍关联的用户信息,进而将一个User对象关联到Book对象。

这里发现了一个奇怪的现象,因为user_id字段由association节点指定用于关联查询,该字段就不会被自动映射用于填充Book对象的属性,必须要手动添加一个result用于映射,不清楚这是一个特性还是bug。

使用关联需要注意的是,滥用这种功能很可能造成SQL性能低下,比如我们要查询的是一个包含10个书籍的列表,通过这种方式执行SQL,就会先查10个书籍,再通过关联查询查10次书籍对应的添加用户,也就是每次都会执行11次SQL查询,这种性能降低会随着查询数据规模的上升而显著出现。

这个问题可以通过使用联表查询SQL来解决,假设需要分页查询书籍信息:

package cn.icexmoon.books2.book.mapper;

// ...
public interface BookMapper {
	// ...
    /**
     * 获取书籍分页数据
     * @param start 开始游标
     * @param limit 结束游标
     * @return
     */
    List<Book> pageBooks(@Param("start") int start, @Param("limit") int limit);
}

对应的XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
    
    <select id="pageBooks" resultMap="bookResult2">
        SELECT b.*,u.id AS u_id,u.name AS u_name,u.password AS u_password,u.open_id AS u_open_id,u.real_name AS u_real_name,u.icon AS u_icon,u.dept_id AS u_dept_id,u.del_flag AS u_del_flag
        FROM book AS b
                 LEFT JOIN `user` AS u
                           ON b.user_id=u.id
        WHERE b.del_flag=0
          AND u.del_flag=0
            LIMIT #{start},#{limit};
    select>
    <resultMap id="bookResult2" type="cn.icexmoon.books2.book.entity.Book" autoMapping="true">
        <id column="id" property="id">id>
        <result column="user_id" property="userId">result>
        <association property="user" columnPrefix="u_" resultMap="userResult">association>
    resultMap>
    <resultMap id="userResult" type="cn.icexmoon.books2.user.entity.User" autoMapping="true">
        <id column="id" property="id">id>
    resultMap>
mapper>

用于查询的SQL使用了左连接,一次性查询出了书籍和对应的用户信息,为了保证字段名称唯一,对查询出的用户信息字段使用u_前缀的别名命名。对应的,可以在表示关联关系的association节点中通过columnPrefix属性指定字段前缀,这样就可以在userResult中利用自动映射完成大部分字段和属性的映射,不需要我们一一手动映射。

因为这里用SQL一次性查出了包含关联关系的结果集,不需要额外的SQL进行关联查询,所以也就不需要在association中通过属性columnselect指定额外的关联查询。

如果你不需要在使用关联查询时重复利用resultMap,也可以直接在association中不使用resultMap,直接构建结果映射:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
    
    <select id="pageBooks" resultMap="bookResult3">
        
    select>
    
    <resultMap id="bookResult3" type="cn.icexmoon.books2.book.entity.Book" autoMapping="true">
        <id column="id" property="id">id>
        <result column="user_id" property="userId">result>
        <association property="user" columnPrefix="u_" autoMapping="true">
            <id property="id" column="id">id>
        association>
    resultMap>
mapper>

两者是等效的。

集合映射

并不是所有的关联关系都是一对一的,如果有一对多的关系,要如何表示?比如要根据ID查询一个用户,而用户中包含多个角色。

对应的用户entity可以用下面的类表示:

package cn.icexmoon.books2.user.entity;
// ...
@Data
public class User {
    private Integer id;
    private String name;
    private String password;
    private String openId;
    private String realName;
    private String icon;
    private Integer deptId;
    private Integer delFlag;
    private List<Role> roles;
}

这里用一个类型为Listroles属性表示一个用户拥有的多个角色。

在映射器中创建对应的查询用方法:

package cn.icexmoon.books2.user.mapper;
// ...
public interface UserMapper {
    /**
     * 获取用户
     * @param id 用户id
     * @return
     */
    User getUserById(@Param("id") int id);
}

对应的XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.user.mapper.UserMapper">
    <select id="getUserById" resultMap="userResult">
        SELECT *
        FROM `user`
        WHERE id=#{id}
    select>
    
    <resultMap id="userResult" type="cn.icexmoon.books2.user.entity.User">
        <id column="id" property="id">id>
        <collection property="roles" javaType="ArrayList" column="id" ofType="cn.icexmoon.books2.user.entity.Role" select="getRolesByUserId">collection>
    resultMap>

    <select id="getRolesByUserId" resultType="cn.icexmoon.books2.user.entity.Role">
        SELECT r.*
        FROM `user_role` AS ur
                 LEFT JOIN `role` AS r
                           ON ur.role_id=r.id
        WHERE ur.del_flag=0
          AND r.del_flag=0
          AND ur.user_id=#{id}
    select>
mapper>

这里用collection节点表示一个User拥有的多个Rolecollection节点的属性和association是相似的,我们同样可以通过selectcolumn来指定关联的查询并将列作为参数传入。不同的是,collection需要定义一个ofType属性,用于指定集合中的元素类型。

类似的,像上面这样用集合映射关联查询结果同样存在SQL性能降低的风险,着同样可以通过使用一条关联查询SQL查询出所有关联结果后再进行映射:

    <select id="getAllUsers" resultMap="userResult2">
        SELECT u.*,r.`id` AS `role_id`,r.`cname` AS role_cname,r.`name` AS role_name,r.`level` AS role_level,r.`del_flag` AS role_del_flag
        FROM `user` AS u
                 LEFT OUTER JOIN user_role AS ur
                                 ON u.`id`=ur.user_id
                 LEFT JOIN `role` AS r
                           ON ur.`role_id`=r.`id`
    select>
    <resultMap id="userResult2" type="cn.icexmoon.books2.user.entity.User" autoMapping="true">
        <id column="id" property="id"/>
        <collection property="roles" javaType="ArrayList" ofType="cn.icexmoon.books2.user.entity.Role" columnPrefix="role_" autoMapping="true">
            <id column="id" property="id"/>
        collection>
    resultMap>

原理和写法都同介绍关联时相似,这里不再过多阐述。

鉴别器

有时候我们会对业务对象进一步抽象,比如将优惠券抽象为无门槛券、满减券等,这样我们就可以为这些优惠券创建一些独特的行为,比如它们可以以独特的风格进行页面展示,或者拥有独特的属性等等。

这其实是设计模式中最朴素的一种思想,建立从“鸭子”到“特种鸭子”的抽象层次后,我们就可以统一调用同样的方法而不需要关心具体的鸭子类型。

对于这种业务需要,通常的做法是从数据持久层查询出基本的优惠券数据后,编写Java程序根据类别创建具体的子类对象。显而易见的,需要额外的工作进行类似的处理,对此MyBatis提供一个有用的工具——鉴别器(discriminator)。

假设我们有一个优惠券表:

CREATE TABLE `coupon` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `add_user_id` int NOT NULL COMMENT '添加人',
  `add_time` datetime NOT NULL COMMENT '添加时间',
  `amount` decimal(10,2) NOT NULL COMMENT '金额',
  `expire_time` datetime NOT NULL COMMENT '失效时间',
  `enough_amount` decimal(10,2) NOT NULL COMMENT '满减门槛',
  `type` tinyint NOT NULL COMMENT '类型 1固定金额券 2满减券',
  `del_flag` tinyint NOT NULL DEFAULT '0' COMMENT '删除标识',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

示例数据如下:

从零开始 Spring Boot 23:MyBatis_第1张图片

在这个表中,用type字段区分两种不同类型的优惠券,1表示固定金额(无门槛券),2表示满减券。

创建对应的实体类:

package cn.icexmoon.books2.book.entity;

// ...
@Data
public class Coupon {
    private Integer id;
    private Integer addUserId;
    private LocalDateTime addTime;
    private LocalDateTime expireTime;
    private Integer type;
    private Double amount;
}

和之前的做法有所不同,这里我们并没有在Coupon中列举coupon表的全部字段,因为这里我只想让coupon作为一个最基础的优惠券的实体类,在具体使用时将使用其派生类。

我们可以从实体类直接派生出特异化的优惠券具体类型:

package cn.icexmoon.books2.book.bo;

// ...
@Data
public class EnoughCoupon extends Coupon {
    private Double enoughAmount;
}
package cn.icexmoon.books2.book.bo;

// ...
@Data
public class FreeCoupon extends Coupon {

}

EnoughCoupon代表满减券,相比基本的优惠券,它多了一个满减金额,要满足这个金额的订单才能够使用这个优惠券。FreeCoupon代表无门槛券,它的属性和基本类型完全一致,为了保持抽象层次一致性,这里依然派生了这么一个类型。

有了从Coupon实体类派生出的类型后,我们就可以直接让映射器返回具体类型而非基础的Coupon类型,当然这要用到鉴别器。

先创建映射器的接口:

package cn.icexmoon.books2.book.mapper;
// ...
public interface CouponMapper {
    Coupon getCouponById(@Param("id") int id);
}

创建对应的XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.CouponMapper">
    <select id="getCouponById" resultMap="couponResult">
        SELECT * FROM
            coupon
        WHERE id=#{id} AND del_flag=0
    select>
    <resultMap id="couponResult" type="cn.icexmoon.books2.book.entity.Coupon">
        <discriminator javaType="int" column="type">
            <case value="1" resultType="cn.icexmoon.books2.book.bo.FreeCoupon">case>
            <case value="2" resultType="cn.icexmoon.books2.book.bo.EnoughCoupon">case>
        discriminator>
    resultMap>
mapper>

XML中的discriminator标签就是鉴别器,需要在鉴别器中通过javaType属性和column属性指定用来区分目标映射类型的字段名称和类型。然后在子标签case中通过value属性指定具体的column的值,resultType属性指定对应的映射类型。

需要注意的是,这里的javaType并不会根据Coupon中的type属性进行类型推断,因为discriminatorresultMap有着不同的用途。discriminatorjavaType用于将字段转换为相应的Java类型后与case中的值比对是否相等,如果缺省,默认会使用String。在大多数情况下String比较也符合预期,但最好还是手动指定合适的类型。

整体写法类似于编程语言中的switch...case结构。

现在通过映射器获取到的将是具体类型,而非基础类型,可以通过下边的代码进行验证:

package cn.icexmoon.books2.book.controller;

// ...
@RestController
@RequestMapping("/book/coupon")
public class CouponController {
    @Autowired
    private CouponService couponService;
    @GetMapping("/{id}")
    Coupon getCouponByid(@PathVariable int id){
        Coupon coupon = couponService.getCouponById(id);
        System.out.println(coupon.getClass());
        return coupon;
    }
}

你会看到随着接口请求id的不同,获取到的coupon的实际类型会是FreeCouponEnoughCoupon,对应返回的JSON结构也会有所区别。

这样做的好处在于我们在映射器层面对返回的类型进行了解耦,不需要再编写针对不同类型做不同处理的代码,相应的,只要在具体类型中编写相应的方法,通过多态调用就可以实现不同类型产生不同的行为。

这正是策略模式的基础概念。

最后要说明的是,和switch...case一样,鉴别器也存在没有匹配到的情况,类似的情况下会使用鉴别器外层的resultMap进行结果映射。

比如我们添加一个type为0的优惠券:

从零开始 Spring Boot 23:MyBatis_第2张图片

调用接口就能发现控制台输出的实际类型是:class cn.icexmoon.books2.book.entity.Coupon

缓存

默认情况下Mybatis只会对会话中的数据进行缓存,如果需要启动全局二级缓存,需要在XML中添加cache标签:

<cache/>

使用缓存我们可以优化查询效率,这里举一个简单的示例:

package cn.icexmoon.books2.common.mapper;
// ...
public interface MathMapper {
    /**
     * 通过数据库获取圆周率
     *
     * @return
     */
    Double getPi();
}

这是一个计算圆周率并返回的映射器,对应的XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.common.mapper.MathMapper">
    <select id="getPi" resultType="Double">
        SELECT PI()+0.000000000000000000
                   AS PiValue;
    select>
mapper>

计算圆周率往往是最消耗计算资源的行为,我们可以利用这点来观察性能优化情况。

编写一个Http Handler来返回计算结果:

package cn.icexmoon.books2.common.controller;
// ...
@RestController
@RequestMapping("/common/math")
public class MathController {
    @Autowired
    private MathMapper mathMapper;

    @GetMapping("/pi")
    public Map<String, String> getPi() {
        Map<String, String> res = new HashMap<>();
        for (int i = 0; i < 99; i++) {
            mathMapper.getPi();
        }
        res.put("pi", mathMapper.getPi().toString());
        return res;
    }
}

为了能显著观察到使用缓存前后的性能差异,这里循环调用映射器计算圆周率100次。

进行测试:

从零开始 Spring Boot 23:MyBatis_第3张图片

第一次请求接口耗时442ms,之后每次请求耗时都在50ms左右,这是因为数据库自身也是有缓存的,重复相同的查询会使用缓存减少响应时间。

现在我们加上Mybatis缓存:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.common.mapper.MathMapper">
    <select id="getPi" resultType="Double">
        SELECT PI()+0.000000000000000000
                   AS PiValue;
    select>
    <cache/>
mapper>

再测试:

从零开始 Spring Boot 23:MyBatis_第4张图片

第一次请求耗时375ms,好像和未使用缓存相差不大,但之后每次请求只有17ms左右,差距相当明显,此时会直接在映射器层使用缓存返回数据,不再查询数据库,速度当然提升很多。而且这种差异会随着查询规模的提升而显著增加。

在数据处理规模过大时,使用缓存或多线程往往可以起到很不错的效果。

默认情况下的Mybatis缓存遵循以下策略:

  • XML中的所有select语句会被缓存。
  • insertupdatedelete语句都会刷新缓存。
  • 清除缓存策略使用最近最少使用算法(LRU, Least Recently Used)。
  • 缓存不会定时刷新。
  • 缓存最大容量为1024(最多会保留1024个映射器查询到的结果对象引用)。
  • 缓存返回的对象可读可写(相当于结果拷贝,可以安全地被调用方修改而不用担心改变缓存结果)。

可以通过cache标签属性来手动修改这些策略:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.common.mapper.MathMapper">
    <select id="getPi" resultType="Double">
        SELECT PI() + 0.000000000000000000
                   AS PiValue;
    select>
    <cache
            eviction="FIFO"
            flushInterval="6000"
            size="512"
            readOnly="true"
    />
mapper>

上面XML创建的缓存将使用FIFO算法清理缓存,间隔6000ms刷新缓存,最大保存512个结果对象引用,返回的结果对象是只读的。

可供选择的缓存清理算法包含以下几种:

  • FIFO,最近最少使用:移除最长时间不被使用的对象。
  • LRU, 先进先出:按对象进入缓存的顺序来移除它们。
  • SOFT,软引用:基于垃圾回收器状态和软引用规则移除对象。
  • WEAK,弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。

flushInterval可以设置缓存的刷新时间间隔,默认为不指定,即不主动刷新缓存,只有相关XML中的SQL被执行才会触发缓存刷新。

readOnly可以设置返回的缓存对象是可读可写还是只读,显然前者涉及一次对象拷贝,效率低于后者,但优点是获取到的缓存对象是可写的,在需要的时候直接可以使用,而不需要自行拷贝。

最后需要说明的是,Mybatis的缓存是事务的,相应的映射器方法调用时如果发生事务提交或者回滚,缓存同样会被刷新,不需要在这方面有额外的担心。

自定义缓存

如果有需要,你也可以使用自定缓存:

 <cache type="cn.icexmoon.books2.system.MyCache"/>

自定缓存需要实现Mybatis的Cache接口:

package cn.icexmoon.books2.system;

// ...
public class MyCache implements Cache {
    private String id;

    private static Map<String, Map<Object, Object>> cachePool = Collections.synchronizedMap(new HashMap<>());

    public MyCache(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public void putObject(Object o, Object o1) {
        this.getCurrentCachePool().put(o, o1);
    }

    @Override
    public Object getObject(Object o) {
        return this.getCurrentCachePool().get(o);
    }

    @Override
    public Object removeObject(Object o) {
        return this.getCurrentCachePool().remove(o);
    }

    @Override
    public void clear() {
        this.getCurrentCachePool().clear();
    }

    @Override
    public int getSize() {
        return this.getCurrentCachePool().size();
    }

    private Map<Object, Object> getCurrentCachePool() {
        if (!cachePool.containsKey(id)) {
            cachePool.put(id, Collections.synchronizedMap(new HashMap<>()));
        }
        return cachePool.get(id);
    }
}

这里我使用线程安全的Map作为底层的缓存池。

因为MyBatis的缓存是依据映射器的命名空间(namespace)来划分的,这体现在我们需要在缓存类中实现一个接收String类型的id的构造器。

因此这里作为所有映射器的缓存池是静态的,并且用id作为其key。

具体映射器的缓存池,可以通过缓存对象的getCurrentCachePool方法来获取,如果没有,我们可以为其初始化一个线程安全的Map作为对应映射器的结果对象缓存池。

具体处理缓存结果的相应方法,都简单代理给Map的相应方法即可。

这里只是我为了演示编写的一个自定义缓存类,未经过充分测试和性能调优,不建议直接用于项目。

需要说明的是,如果使用了自定义缓存,那之前所说的cache中一系列修改缓存策略的属性都会失效(显然都将取决于自定义缓存类的具体实现)。

之前说过,默认情况下,select会读取同一命名空间中的缓存,而insertupdatedelete会刷新同一命名空间中的缓存,这样的策略是很符合使用习惯的。

如果有必要,你也可以改变这一策略,比如对于某个插入日志的操作,你不想让其刷新缓存:

    <insert id="addLog" flushCache="false">
		
    insert>

或者某个select操作不使用缓存:

    <select id="getRealResult" useCache="false">
        
    select>

甚至让某个select语句触发缓存刷新:

    <select id="getRealResult" useCache="false" flushCache="true">
        
    select>

这些都可以通过相应语句的useCacheflushCache属性来控制。

共享缓存

之前说了,默认情况下MyBatis创建的缓存是基于映射器的命名空间的,也就是说每个映射器具备单独的缓存,显然,映射器A的查询不会使用映射器B的缓存,映射器A的插入也不会触发刷新映射器B缓存的刷新。

如果某些映射器之间存在关联关系,如果它们能共享一个缓存实例就可以提高查询性能。

可以通过cache-ref标签来引用缓存:

<cache-ref namespace="com.someone.application.data.SomeMapper"/>

insert

现在看一下select之外的几种SQL语句如何编写:

package cn.icexmoon.books2.book.mapper;
// ...
public interface BookMapper {
	// ...
    /**
     * 添加图书
     *
     * @param book
     */
    void addBook(@Param("book") Book book);
}

DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
	
	<insert id="addBook">
        INSERT INTO book (`name`,`description`,user_id,`type`,extra,del_flag)
        VALUES (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
    insert>
mapper>
获取新增主键

通过BookMapper.addBook我们可以向数据库中book表添加数据,但这里存在一个问题,因为book表的主键是自增的id字段,如果我们需要获取新增的数据行的id,要怎么做?

    <insert id="addBook" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book (`name`,`description`,user_id,`type`,extra,del_flag)
        VALUES (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
    insert>

这里通过insert的属性useGeneratedKeys="true"说明插入语句涉及的表会自己生成主键,keyProperty="id"指定了生成主键回写的对象属性名称。举例来说,当前表是这样:

image-20221022163858351

Service层调用映射器的addBook方法:

package cn.icexmoon.books2.book.service.impl;
// ...
@Service
public class BookServiceImpl implements BookService {
	// ...
    @Override
    public Integer addBook(BookDTO dto) {
        Book book = new Book();
        BeanUtils.copyProperties(dto, book);
        bookMapper.addBook(book);
        return book.getId();
    }
}

调用完后MyBatis会将自增主键的值写入参数bookid属性中,通过这个属性我们可以获取到刚刚新增的数据行的主键值。

批量插入

很多数据库支持在单条SQL中批量插入多行数据,比如:

INSERT INTO book (`name`,`description`,user_id,`type`,extra,del_flag)
VALUES ('哈利波特和魔法石','《哈利波特》系列第一册',1,5,'',0),
('哈利波特和密室','《哈利波特》系列第二册',1,5,'',0),
('哈利波特和阿兹卡班的囚徒','《哈利波特》系列第三册',1,5,'',0)

通过XML描述MyBatis同样可以实现类似的SQL:

package cn.icexmoon.books2.book.mapper;
// ...
public interface BookMapper {
	// ...
    /**
     * 批量添加图书
     *
     * @param books
     */
    void addBooks(@Param("books") Collection<Book> books);
}
    <insert id="addBooks" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book (`name`,`description`,user_id,`type`,extra,del_flag)
        VALUES
        <foreach collection="books" separator="," item="book">
            (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
        foreach>
    insert>

这里使用foreach标签拼接多个数据行的值,值得注意的是,这种情况下依然可以使用useGeneratedKeyskeyProperty属性,相应的,我们可以一次性获取到批量插入的所有数据行自动生成的主键值:

package cn.icexmoon.books2.book.service.impl;
// ...
@Service
public class BookServiceImpl implements BookService {
	// ...
    @Override
    public List<Integer> addBooks(Collection<BookDTO> dtos) {
        List<Book> books = dtos.stream().map(dto -> {
            Book book = new Book();
            BeanUtils.copyProperties(dto, book);
            return book;
        }).collect(Collectors.toList());
        bookMapper.addBooks(books);
        return books.stream().map(b -> b.getId()).collect(Collectors.toList());
    }
}

其实一般性的批量插入都可以通过遍历调用BookMapper.addBook完成,但如果数据规模很大,用单条SQL批量插入的性能要更好,且因为是单条SQL,不需要使用事务。

selectKey

有时候可能我们不会通过数据库自增方式生成主键,而是通过其它方式生成,比如说使用随机数。

假设我们有这样一个表:

CREATE TABLE `tmp` (
  `id` int unsigned NOT NULL,
  `msg` varbinary(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

这个表的主键id不是自增的,需要在插入时指定。

package cn.icexmoon.books2.common.mapper;
// ...
public interface TmpMapper {
    void addTmp(@Param("tmp") Tmp tmp);
}

DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.common.mapper.TmpMapper">
    <insert id="addTmp">
        <selectKey keyProperty="tmp.id" order="BEFORE" resultType="int" keyColumn="a">
            SELECT ROUND(RAND()*1000000) a;
        selectKey>
        INSERT INTO tmp(id, msg)
        VALUES (#{tmp.id}, #{tmp.msg})
    insert>
mapper>

这里使用selectKey在执行插入语句前用一个SQL生成的随机整数重写了参数tmpid属性,这样在接下来的插入语句中就会使用相应的随机数来作为主键进行插入。

selectKeykeyProperty属性决定了selectKey中语句执行后要将结果回写的目标变量,order属性则决定了这种行为是发生在insert语句执行之前还是之后。

sql

对可以复用的SQL,可以使用sql标签进行复用,比如这段XML:

<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
    
	<insert id="addBook" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book (`name`,`description`,user_id,`type`,extra,del_flag)
        VALUES (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
    insert>
    <insert id="addBooks" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book (`name`,`description`,user_id,`type`,extra,del_flag)
        VALUES
        <foreach collection="books" separator="," item="book">
            (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
        foreach>
    insert>
mapper>

使用sql标签复用后的XML:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
	
    <insert id="addBook" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book <include refid="bookColumns">include>
        VALUES (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
    insert>
    <insert id="addBooks" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book <include refid="bookColumns">include>
        VALUES
        <foreach collection="books" separator="," item="book">
            (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
        foreach>
    insert>
    <sql id="bookColumns">
            (`name`,`description`,user_id,`type`,extra,del_flag)
    sql>
mapper>

像上边的示例这样,用sql标签定义的SQL片段,可以用include标签嵌入,includerefid属性指向sql标签的id

不仅是无参数的静态SQL片段可以通过这样进行复用,带参数的SQL片段也可以:


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.BookMapper">
	
	<insert id="addBook" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book <include refid="bookColumns">include>
        VALUES
            <include refid="bookColumnValues">
            include>
    insert>
    <insert id="addBooks" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO book <include refid="bookColumns">include>
        VALUES
        <foreach collection="books" separator="," item="book">
            <include refid="bookColumnValues">
            include>
        foreach>
    insert>
    <sql id="bookColumns">
            (`name`,`description`,user_id,`type`,extra,del_flag)
    sql>
    <sql id="bookColumnValues">
        (#{book.name},#{book.description},#{book.userId},#{book.type},#{book.extra},0)
    sql>
mapper>

虽然SQL片段bookColumnValues包含对参数的使用,但实际上#{book.name}这样的写法拼接的依然是静态SQL,最后通过JDBC的方式进行参数替换,比如这样:

// 近似的 JDBC 代码,非 MyBatis 代码...
String selectPerson = "SELECT * FROM PERSON WHERE ID=?";
PreparedStatement ps = conn.prepareStatement(selectPerson);
ps.setInt(1,id);

如果要拼接真正的动态SQL,就要使用${}而非#{}


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.common.mapper.TmpMapper">
	
    <sql id="selectAnyTable">
        SELECT * FROM
        ${tableName}
    sql>
    <sql id="selectTmpTable">
        <include refid="selectAnyTable">
            <property name="tableName" value="tmp"/>
        include>
    sql>
    <select id="getAllTmps" resultType="cn.icexmoon.books2.common.entity.Tmp">
        <include refid="selectTmpTable">include>
    select>
mapper>

上边示例中的SQL片段selectAnyTable表示一个可以查询任何表数据的查询语句,而SQL片段selectTmpTable通过引入selectAnyTable片段,并通过property指定参数tableName,实现了一个查询tmp表的SQL片段。最后,在select语句getAllTmps中使用这个片段来完成查询。

参数

之前示例中的Coupon实体类的type属性使用的是Integer,这在使用中会带来诸多不便,更常用的做法是将类似的值定义为枚举类型:

package cn.icexmoon.books2.book.entity;
// ...
@Data
@Accessors(chain = true)
public class Coupon {
    private Integer id;
    private Integer addUserId;
    private LocalDateTime addTime;
    private LocalDateTime expireTime;
    private CouponType type;
    private Double amount;
}
package cn.icexmoon.books2.book.entity.enums;
// ...
public enum CouponType {
    FREE_COUPON(1, "无门槛券"), ENOUGH_COUPON(2, "满减券");
    @JsonValue
    private Integer value;
    private String desc;

    public Integer getValue() {
        return value;
    }

    public String getDesc() {
        return desc;
    }

    CouponType(Integer value, String desc) {
        this.value = value;
        this.desc = desc;
    }

    public static CouponType match(Integer value) {
        for (CouponType ct : CouponType.values()) {
            if (ct.getValue().equals(value)) {
                return ct;
            }
        }
        return null;
    }
}

@JsonValue注解的用途是方便在DTO对象中使用,能让用户输入正常的解析为枚举类型,相关内容可以阅读从零开始 Spring Boot 16:枚举 - 红茶的个人站点 (icexmoon.cn)。

映射器中我们使用之前的写法:

package cn.icexmoon.books2.book.mapper;
// ...
public interface CouponMapper {
    Coupon getCouponById(@Param("id") int id);

    void addCoupon(@Param("coupon") Coupon coupon);
}
<?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="cn.icexmoon.books2.book.mapper.CouponMapper">
	<!-- ... -->
	<insert id="addCoupon" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO coupon(add_user_id,add_time,amount,expire_time,enough_amount,`type`,del_flag)
        VALUES (#{coupon.addUserId},#{coupon.addTime},#{coupon.amount},#{coupon.expireTime},#{coupon.enoughAmount},#{coupon.type},0)
    </insert>
</mapper>

但实际执行你就会发现一个类似以下的错误信息:

java.sql.SQLException: Incorrect integer value: 'ENOUGH_COUPON' for column 'type' at ...

这是因为对于输入的值,为了能够正常处理一些非常规的类型,比如枚举、时间等,MyBatis使用TypeHandler来处理类型转换后最终写入SQL。对于枚举类型,MyBatis默认使用的是EnumTypeHandler

package org.apache.ibatis.type;

import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class EnumTypeHandler<E extends Enum<E>> extends BaseTypeHandler<E> {
    private final Class<E> type;

    public EnumTypeHandler(Class<E> type) {
        if (type == null) {
            throw new IllegalArgumentException("Type argument cannot be null");
        } else {
            this.type = type;
        }
    }

    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {
        if (jdbcType == null) {
            ps.setString(i, parameter.name());
        } else {
            ps.setObject(i, parameter.name(), jdbcType.TYPE_CODE);
        }

    }

    public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String s = rs.getString(columnName);
        return s == null ? null : Enum.valueOf(this.type, s);
    }

    public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String s = rs.getString(columnIndex);
        return s == null ? null : Enum.valueOf(this.type, s);
    }

    public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String s = cs.getString(columnIndex);
        return s == null ? null : Enum.valueOf(this.type, s);
    }
}

可以看到,这个EnumTypeHandler的工作逻辑是将枚举类型的字面量(parameter.name())保存进数据库,在读取数据库时,是用字面量来获取对应的枚举实例(Enum.valueOf(this.type, s))。

这显然和我们预期的行为(保存枚举的整型值)是不一致的。

对此,我们可以使用自定义的TypeHandler来解决:

package cn.icexmoon.books2.book.entity.typehandler;
// ...
public class CouponTypeHandler extends BaseTypeHandler<CouponType> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, CouponType couponType, JdbcType jdbcType) throws SQLException {
        if (jdbcType == null) {
            ps.setInt(i, couponType.getValue());
        } else {
            ps.setObject(i, couponType.getValue(), jdbcType.TYPE_CODE);
        }
    }

    @Override
    public CouponType getNullableResult(ResultSet rs, String columnName) throws SQLException {
        int value = rs.getInt(columnName);
        return CouponType.match(value);
    }

    @Override
    public CouponType getNullableResult(ResultSet rs, int i) throws SQLException {
        int value = rs.getInt(i);
        return CouponType.match(value);
    }

    @Override
    public CouponType getNullableResult(CallableStatement cs, int i) throws SQLException {
        int value = cs.getInt(i);
        return CouponType.match(value);
    }
}

对应的,需要在insert语句的参数中显式说明要使用的handler


DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.CouponMapper">
	
	<insert id="addCoupon" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO coupon(add_user_id,add_time,amount,expire_time,enough_amount,`type`,del_flag)
        VALUES (#{coupon.addUserId},#{coupon.addTime},#{coupon.amount},#{coupon.expireTime},#{coupon.enoughAmount},#{coupon.type,typeHandler=cn.icexmoon.books2.book.entity.typehandler.CouponTypeHandler},0)
    insert>
mapper>

除了typeHandler以外,参数还可以指定其他属性:

#{coupon.amount,javaType=double,jdbcType=NUMERIC,numericScale=2}

除了一般性的javaTypejdbcType属性,这里还使用numericScale=2,指定写入数据库的数据保留2位小数。

一般的,这样做是多余的,比如这里的javaTypejdbcType都可以通过反射正常处理,而coupon字段的数据库类型是decimal(10,2),本身就是保留2位小数,这里处理不处理都无关紧要。这里只是说明了在必要的时候,如何通过类似的特性来解决非一般性的问题。

总结

虽然这里没有介绍完MyBatis的相关功能,但依然可以看出,作为一个半ORM持久层框架,MyBatis的优点是支持在XML中使用丰富的语义构建复杂查询SQL,缺点是即使是普通的增删改查语句,也需要编写大量的样板代码。而MyBatisPlus的意义正在于此。

谢谢阅读。

最后,本文最终的完整示例见learn_spring_boot/ch23 (github.com)。

参考资料

  • mybatis-spring-boot-autoconfigure – Introduction
  • mybatis-spring –
  • mybatis – MyBatis 3 | XML 映射器

你可能感兴趣的:(JAVA,mybatis,spring,boot,orm)