图源:简书 (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通过映射器(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]
可以通过重写Book
的toString
方法让输出更友好。
可以使用@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")
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单独开启一个事务并立即提交。
虽然现在的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>
select
的id
属性对应映射器中的方法名,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注入漏洞。
如果你注意,可能会发现查询的结果中Role
的delFlag
属性是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>
resultMap
的id
属性定义了可以在当前XML中引用的标识,type
定义了结果集要映射的目标Java类型。
resultMap
中的id
和result
节点定义了主键和其它字段的映射关系,其中的property
属性对应Java类中的属性名称,column
属性对应SQL查询结果中的字段名称。通过定义这些节点就可以手动完成结果集到Java类的映射。当然并不用完成所有的映射,我们只需要在默认行为(名称一致匹配)的基础上,额外指定需要“特殊对待”的映射就行,比如这里的del_flag
字段。
- MyBatis会先指定默认的映射行为,然后执行resultMap定义的映射行为。
id
和result
还有一个javaType
属性,如果映射的目标是一个Java Bean,MyBatis可以通过反射获取对应属性的Java类型,就不需要指定该属性,单如果映射的目标是一个容器,比如Map
,就需要通过该属性指定具体的Java类型。id
和result
的区别,官方文档是这么解释的:*这两者之间的唯一不同是,id元素对应的属性会被标记为对象的标识符,在比较对象实例时使用。 这样可以提高整体的性能,尤其是进行缓存和嵌套结果映射(也就是连接映射)的时候。*总的来说,如果是采用一般性的为所有表使用id字段作为自增主键的话,直接使用id字段指定id
节点映射即可。
需要注意的是,使用resultMap
后需要将select
中的resultType
字段修改为resultMap
,并将值改为对应的resultMap
的id
。
实际上大多数情况下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>
这里idArg
和arg
节点对应的是构造器中的形参,其属性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为bookResult
的resultMap
定义,这个resultMap
中除了必要的id
和result
节点外,包含一个association
节点,用于指定book
表到user
表的关联关系。
association
节点有以下几个重要属性:
property
,对应resultMap
的type
中指定的Java类的属性。column
,用于关联的字段,在这个示例中就是指book
表的user_id
字段。这个字段将会作为参数传递给关联的查询。比较特别的是,如果不是采用统一ID自增方式建表,而是存在联合外键,可以通过column="{prop1=col1,prop2=col2}"
的方式指定多个列作为联合外键,同样的,prop1
和prop2
会作为参数传递给关联查询。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
中通过属性column
和select
指定额外的关联查询。
如果你不需要在使用关联查询时重复利用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;
}
这里用一个类型为List
的roles
属性表示一个用户拥有的多个角色。
在映射器中创建对应的查询用方法:
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
拥有的多个Role
,collection
节点的属性和association
是相似的,我们同样可以通过select
和column
来指定关联的查询并将列作为参数传入。不同的是,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
示例数据如下:
在这个表中,用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
属性进行类型推断,因为discriminator
和resultMap
有着不同的用途。discriminator
的javaType
用于将字段转换为相应的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
的实际类型会是FreeCoupon
或EnoughCoupon
,对应返回的JSON结构也会有所区别。
这样做的好处在于我们在映射器层面对返回的类型进行了解耦,不需要再编写针对不同类型做不同处理的代码,相应的,只要在具体类型中编写相应的方法,通过多态调用就可以实现不同类型产生不同的行为。
这正是策略模式的基础概念。
最后要说明的是,和switch...case
一样,鉴别器也存在没有匹配到的情况,类似的情况下会使用鉴别器外层的resultMap
进行结果映射。
比如我们添加一个type
为0的优惠券:
调用接口就能发现控制台输出的实际类型是: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次。
进行测试:
第一次请求接口耗时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>
再测试:
第一次请求耗时375ms,好像和未使用缓存相差不大,但之后每次请求只有17ms左右,差距相当明显,此时会直接在映射器层使用缓存返回数据,不再查询数据库,速度当然提升很多。而且这种差异会随着查询规模的提升而显著增加。
在数据处理规模过大时,使用缓存或多线程往往可以起到很不错的效果。
默认情况下的Mybatis缓存遵循以下策略:
select
语句会被缓存。insert
、update
和delete
语句都会刷新缓存。可以通过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个结果对象引用,返回的结果对象是只读的。
可供选择的缓存清理算法包含以下几种:
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
会读取同一命名空间中的缓存,而insert
、update
、delete
会刷新同一命名空间中的缓存,这样的策略是很符合使用习惯的。
如果有必要,你也可以改变这一策略,比如对于某个插入日志的操作,你不想让其刷新缓存:
<insert id="addLog" flushCache="false">
insert>
或者某个select
操作不使用缓存:
<select id="getRealResult" useCache="false">
select>
甚至让某个select
语句触发缓存刷新:
<select id="getRealResult" useCache="false" flushCache="true">
select>
这些都可以通过相应语句的useCache
和flushCache
属性来控制。
之前说了,默认情况下MyBatis创建的缓存是基于映射器的命名空间的,也就是说每个映射器具备单独的缓存,显然,映射器A的查询不会使用映射器B的缓存,映射器A的插入也不会触发刷新映射器B缓存的刷新。
如果某些映射器之间存在关联关系,如果它们能共享一个缓存实例就可以提高查询性能。
可以通过cache-ref
标签来引用缓存:
<cache-ref namespace="com.someone.application.data.SomeMapper"/>
现在看一下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"
指定了生成主键回写的对象属性名称。举例来说,当前表是这样:
在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会将自增主键的值写入参数book
的id
属性中,通过这个属性我们可以获取到刚刚新增的数据行的主键值。
很多数据库支持在单条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
标签拼接多个数据行的值,值得注意的是,这种情况下依然可以使用useGeneratedKeys
和keyProperty
属性,相应的,我们可以一次性获取到批量插入的所有数据行自动生成的主键值:
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,不需要使用事务。
有时候可能我们不会通过数据库自增方式生成主键,而是通过其它方式生成,比如说使用随机数。
假设我们有这样一个表:
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生成的随机整数重写了参数tmp
的id
属性,这样在接下来的插入语句中就会使用相应的随机数来作为主键进行插入。
selectKey
的keyProperty
属性决定了selectKey
中语句执行后要将结果回写的目标变量,order
属性则决定了这种行为是发生在insert
语句执行之前还是之后。
对可以复用的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
标签嵌入,include
的refid
属性指向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}
除了一般性的javaType
和jdbcType
属性,这里还使用numericScale=2
,指定写入数据库的数据保留2位小数。
一般的,这样做是多余的,比如这里的
javaType
和jdbcType
都可以通过反射正常处理,而coupon
字段的数据库类型是decimal(10,2)
,本身就是保留2位小数,这里处理不处理都无关紧要。这里只是说明了在必要的时候,如何通过类似的特性来解决非一般性的问题。
虽然这里没有介绍完MyBatis的相关功能,但依然可以看出,作为一个半ORM持久层框架,MyBatis的优点是支持在XML中使用丰富的语义构建复杂查询SQL,缺点是即使是普通的增删改查语句,也需要编写大量的样板代码。而MyBatisPlus的意义正在于此。
谢谢阅读。
最后,本文最终的完整示例见learn_spring_boot/ch23 (github.com)。