Mybatis应用分析和最佳实践
以下是一些 MyBatis 的高级用法或者扩展方式,帮助我们更好地使用 MyBatis。
为什么要动态SQL
避免因为前端传入的查询参数不同,所以导致写很多的if else,还需要非常注意SQL语句中的and,空格,逗号和转义的单引号,拼接和调试sql非常耗时。
Mybatis的动态SQL就解决了这个问题,其是基于OGNL表达式的。
动态标签
if
choose(when,otherwise)
trim(where,set)
一般用来去掉前缀后者或追
...
foreach
需要遍历集合的时候动态生成语句
批量操作
我们在生产的项目中会有一些批量操作的场景,比如导入文件批量处理数据的情况(批量新增商户、批量修改商户信息),当数据量非常大,比如超过几万条的时候,在Java 代码中循环发送 SQL 到数据库执行肯定是不现实的,因为这个意味着要跟数据库创建几万次会话,即使我们使用了数据库连接池技术,对于数据库服务器来说也是不堪重负的。
在 MyBatis 里面是支持批量的操作的,包括批量的插入、更新、删除。我们可以直 接传入一个 List、Set、Map 或者数组,配合动态 SQL 的标签,MyBatis 会自动帮我们 生成语法正确的 SQL 语句。
比如我们来看两个例子,批量插入和批量更新。
批量插入
批量插入的语法是这样的,只要在 values 后面增加插入的值就可以了。
insert into tbl_emp (emp_id, emp_name, gender,email, d_id) values ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? ) , ( ?,?,?,?,? )
在 Mapper 文件里面,我们使用 foreach 标签拼接 values 部分的语句:
SELECT LAST_INSERT_ID()
insert into tbl_emp (emp_id, emp_name, gender,email, d_id)
values
( #{emps.empId},#{emps.empName},#{emps.gender},#{emps.email},#{emps.dId} )
Java 代码里面,直接传入一个 List 类型的参数。
我们来测试一下。效率要比循环发送 SQL 执行要高得多。最关键的地方就在于减少了跟数据库交互的次数,并且避免了开启和结束事务的时间消耗。
@Test
public void testBatchInsert() {
List list = new ArrayList();
long start = System.currentTimeMillis();
int count = 100000;
// max_allowed_packet 默认 4M,所以超过长度会报错
for (int i = 0; i < count; i++) {
String gender = i % 2 == 0 ? "M" : "F";
Integer did = i % 2 == 0 ? 1 : 2;
Employee emp = new Employee(null, "TestName" + i, gender, "[email protected]", did);
list.add(emp);
}
employeeMapper.batchInsert(list);
long end = System.currentTimeMillis();
System.out.println("批量插入" + count + "条,耗时:" + (end - start) + "毫秒");
}
批量更新
update tbl_emp set
emp_name =
when #{emps.empId} then #{emps.empName}
,gender =
when #{emps.empId} then #{emps.gender}
,email =
when #{emps.empId} then #{emps.email}
where emp_id in
#{emps.empId}
批量删除也是类似的
Batch Executor
当然 MyBatis 的动态标签的批量操作也是存在一定的缺点的,比如数据量特别大的 时候,拼接出来的 SQL 语句过大。
MySQL 的服务端对于接收的数据包有大小限制,max_allowed_packet
默认是 4M,需要修改默认配置才可以解决这个问题。
Caused by: com.mysql.jdbc.PacketTooBigException: Packet for query is too large (7188967 > 4194304). You can change this value on the server by setting the max_allowed_packet' variable.
在我们的全局配置文件中,可以配置默认的 Executor 的类型。其中有一种BatchExecutor。
也可以在创建会话的时候指定执行器类型
SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH);
BatchExecutor 底层是对 JDBC ps.addBatch()的封装,原理是攒一批SQL以后再发
还有一个可能对你来说是新见到的参数,就是 ExecutorType
。这个枚举类型定义了三个值:
-
ExecutorType.SIMPLE
:这个执行器类型不做特殊的事情。它为每个语句的执行创建一个新的预处理语句。 -
ExecutorType.REUSE
:这个执行器类型会复用预处理语句。 -
ExecutorType.BATCH
:这个执行器会批量执行所有更新语句,如果 SELECT 在它们中间执行,必要时请把它们区分开来以保证行为的易读性。
JDBC BatchExecutor使用
public void testJdbcBatch() throws IOException {
Connection conn = null;
PreparedStatement ps = null;
try {
Long start = System.currentTimeMillis();
// 打开连接
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?useUnicode=true&characterEncoding=utf-8&rewriteBatchedStatements=true", "root", "123456");
ps = conn.prepareStatement(
"INSERT into blog values (?, ?, ?)");
for (int i = 1000; i < 101000; i++) {
Blog blog = new Blog();
ps.setInt(1, i);
ps.setString(2, String.valueOf(i)+"");
ps.setInt(3, 1001);
//ExecuteType=BATCH 就是对于这个ps的封装,批量插入500w的数据,用这个性能会得到很大改善
ps.addBatch();
}
ps.executeBatch();
// conn.commit();
ps.close();
conn.close();
Long end = System.currentTimeMillis();
System.out.println("cost:"+(end -start ) +"ms");
} catch (SQLException se) {
se.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (ps != null) ps.close();
} catch (SQLException se2) {
}
try {
if (conn != null) conn.close();
} catch (SQLException se) {
se.printStackTrace();
}
}
}
}
三种Executor的区别
SimpleExecutor
每执行一次 update或 select,就开启一个 Statement对象, 用完立刻关闭 Statement对象。
ReuseExecutor
- 执行 update或 select,以sql作为key查找 Statement对象, 存在就使用,不存在就创建
- 用完后,不关闭 Statement对象,而是放置于Map内, 供下一次使用。
- 简言之,就是重复使用 Statement对象
Batch Executor
- 执行 update(没有 select,JDBC批处理不支持 select)
- 将所有SQL都添加到批处理中( add Batch())
- 等待统一执行( executebatch()),它缓存了多个 Statement对象,每个 Statement对象都是 add Batch()完毕后,等待逐一执行 execute Batch0批处理。与DBC批处理相同。
- executeupdate()
- 是一个语句访问一次数据库
- executebatch()
- 是一批语句访词一次数据库(具体一批发送多少条SQL跟服务端的 max allowed packet有关)。
- Batchexecutor底层是对 JDBC
- ps. add Batch()
- ps. execute Batch()的封装。
嵌套(关联查询/N+1/延迟加载)
https://mybatis.org/mybatis-3/zh/sqlmap-xml.html#Result_Maps
我们在查询业务数据的时候经常会遇到跨表关联查询的情况,比如查询员工就会关联部门(一对一),查询成绩就会关联课程(一对一),查询订单就会关联商品(一对多),等等。
我们映射结果有两个标签,一个是 resultType,一个是 resultMap。
resultType 是 select 标签的一个属性,适用于返回 JDK 类型(比如 Integer、String 等等)和实体类。这种情况下结果集的列和实体类的属性可以直接映射。如果返回的字
段无法直接映射,就要用 resultMap 来建立映射关系。 对于关联查询的这种情况,通常不能用 resultType 来映射。用 resultMap 映射,要么就是修改 dto(Data Transfer Object),在里面增加字段,这个会导致增加很多无关的字段。要么就是引用关联的对象,比如 Blog 里面包含了一个 Author 对象,这种情况 下就要用到关联查询(association,或者嵌套查询),MyBatis 可以帮我们自动做结果 的映射。
一对一的关联查询有两种配置方式:
嵌套结果
嵌套查询 N+1问题
是分两次查询,当我们查询了员工信息之后,会再次发送一条SQL到数据库查询部门信息。
我们只执行了一次查询员工信息的SQL(所谓的1),如果返回了N条记录,就会再发送N条到数据库查询部门信息(所谓的N),这就是我们说的N+1的问题,这样会白白的浪费我们的应用和数据库的性能。
懒加载
如果我们使用了嵌套查询的方式,怎么解决这个问题?
能不能等到使用部门信息的时候再去查询?这就是我们所说的延迟加载,或者叫懒加载
在Mybatis里面可以通过开启延迟加载的开关来解决这个问题。
setting配置+代理
在setting标签里面可以配置
lazyLoadingEnabled 决定了是否延迟加载。
aggressiveLazyLoading 决定了是不是对象的所有方法都会触发查询。
先来测试一下(也可以改成查询列表):
1、没有开启延迟加载的开关,会连续发送两次查询;
2、开启了延迟加载的开关,调用 blog.getAuthor()以及默认的(equals,clone,hashCode,toString)
时才会发起第二次查询,其他方法并不会触发查询,比如 blog.getName()
;
3、如果开启了aggressiveLazyLoading=true
,其他方法也会触发查询,比如blog.getName()
。
问题:为什么可以做到延迟加载?
blog.getAuthor(),只是一个获取属性的方法,里面并没有连接数据库的代码,为什么会触发对数据库的查询呢?
是因为我们这个类被代理了
System.out.println(blog.getClass());
打印出来果然不对
class com.zzjson.domain.associate.BlogAndAuthor_$$_jvst70_0
这个类的名字后面有 jvst,是 JAVASSIST 的缩写
当开启了延迟加载的开关,对象是怎么变成代理对象的?
DefaultResultSetHandler.createResultObject()
既然是代理对象,那么必须要有一种创建代理对象的方法。我们有哪些实现动态代 理的方式?
这个就是为什么 settings 里面提供了一个 ProxyFactory 属性。MyBatis 默认使用 JAVASSIST 创建代理对象。也可以改为 CGLIB,这时需要引入 CGLIB 的包。
CGLIB 和 JAVASSIST 区别是什么?
测试一下,我们把默认的 JAVASSIST 修改为 CGLIB,再打印这个对象。
分页
RowBounds
public void testSelectByRowBounds() throws IOException {
SqlSession session = sqlSessionFactory.openSession();
try {
BlogMapper mapper = session.getMapper(BlogMapper.class);
int start = 0; // offset
int pageSize = 5; // limit
RowBounds rb = new RowBounds(start, pageSize);
List list = mapper.selectBlogList(rb); // 使用逻辑分页
for(Blog b :list){
System.out.println(b);
}
} finally {
session.close();
}
}
参数传入RowBounds
- 是一个伪的分页,实际上会先查询所有,然后获取多少条
org.apache.ibatis.executor.resultset.DefaultResultSetHandler#handleRowValuesForSimpleResultMap
手动limit
需要在java代码计算序号
PageHelper
https://github.com/pagehelper/Mybatis-PageHelper
- 利用插件
- ThreadLocal来设置
依赖
com.github.pagehelper
pagehelper
x.x.x
插件配置
使用
静态方法调用
//获取第1页,10条内容,默认查询总数count
PageHelper.startPage(1, 10);
PageInfo
//获取第1页,10条内容,默认查询总数count
PageHelper.startPage(1, 10);
List list = userMapper.selectAll();
//用PageInfo对结果进行包装
PageInfo page = new PageInfo(list);
参数方式
List selectByPageNumSize(
@Param("user") User user,
@Param("pageNumKey") int pageNum,
@Param("pageSizeKey") int pageSize);
MybatisGenerator
https://github.com/mybatis/generator
我们在项目中使用 MyBaits 的时候,针对需要操作的一张表,需要创建实体类、 Mapper 映射器、Mapper 接口
,里面又有很多的字段和方法的配置,这部分的工作是 非常繁琐的。而大部分时候我们对于表的操作是相同的,比如根据主键查询、根据 Map 查询、单条插入、批量插入、根据主键删除等等等等。当我们的表很多的时候,意味着 有大量的重复工作。所以有没有一种办法,可以根据我们的表,自动生成实体类、Mapper 映射器、Mapper 接口
,里面包含了我们需要用到的这些基本方法和 SQL 呢?
MyBatis 也提供了一个这样的东西,叫做 MyBatis Generator,简称 MBG。我们只需要修改一个配置文件,使用相关的 jar 包命令或者 Java 代码就可以帮助我们生成实体类、映射器和接口文件。不知道用 MyBatis 的同学有没有跟当年的我一样,还是实体类的一个一个字段,接口的一个一个方法,映射器的一条一条 SQL 去写的。
MBG 的配置文件里面有一个 Example 的开关,这个东西用来构造复杂的筛选条件的,换句话说就是根据我们的代码去生成 where 条件
原理:在实体类中包含了两个有继承关系的 Criteria,用其中自动生成的方法来构建查询条件。把这个包含了 Criteria 的实体类作为参数传到查询参数中,在解析 Mapper映射器的时候会转换成 SQL 条件。
(mybatis-standalone 工程:
com.zzjson.domain.BlogExample
com.zzjson.BlogExampleTest)
BlogExample 里面包含了一个两个 Criteria:
实例:查询 bid=1 的 Blog,通过创建一个 Criteria 去构建查询条件:
BlogMapper mapper = session.getMapper(BlogMapper.class);
BlogExample example = new BlogExample();
BlogExample.Criteria criteria = example.createCriteria();
criteria.andBidEqualTo(1);
List list = mapper.selectByExample(example);
生成的语句
select 'true' as QUERYID, bid, name, author_id from blog WHERE ( bid = ? )
翻页
在写存储过程的年代,翻页也是一件很难调试的事情,我们要实现数据不多不少准确地返回,需要大量的调试和修改。但是如果自己手写过分页,就能清楚分页的原理。
在我们查询数据库的操作中,有两种翻页方式,一种是逻辑翻页(假分页),一种是物理翻页(真分页)。逻辑翻页的原理是把所有数据查出来,在内存中删选数据。 物理翻页是真正的翻页,比如 MySQL 使用 limit 语句,Oracle 使用 rownum 语句,SQLServer 使用 top 语句。
逻辑翻页
MyBatis 里面有一个逻辑分页对象 RowBounds,里面主要有两个属性,offset 和limit(从第几条开始,查询多少条)。
我们可以在 Mapper 接口的方法上加上这个参数,不需要修改 xml 里面的 SQL 语句。
public List selectBlogList(RowBounds rowBounds);
使用:mybatis-standalone- MyBatisTest-testSelectByRowBounds()
int start = 10; // offset,从第几行开始查询
int pageSize = 5; // limit,查询多少条
RowBounds rb = new RowBounds(start, pageSize);
List list = mapper.selectBlogList(rb); for(Blog b :list){
System.out.println(b);
}
它的底层其实是对 ResultSet 的处理。它会舍弃掉前面 offset 条数据,然后再取剩下的数据的 limit 条。
// DefaultResultSetHandler.java
private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
DefaultResultContext
很明显,如果数据量大的话,这种翻页方式效率会很低(跟查询到内存中再使用subList(start,end)没什么区别)。所以我们要用到物理翻页。
物理翻页
物理翻页是真正的翻页,它是通过数据库支持的语句来翻页
第一种简单的办法就是传入参数(或者包装一个 page 对象),在 SQL 语句中翻页。
第一个问题是我们要在 Java 代码里面去计算起止序号;第二个问题是:每个需要翻页的 Statement 都要编写 limit 语句,会造成 Mapper 映射器里面很多代码冗余。
那我们就需要一种通用的方式,不需要去修改配置的任何一条 SQL 语句,只要在我 们需要翻页的地方封装一下翻页对象就可以了。
我们最常用的做法就是使用翻页的插件,这个是基于 MyBatis 的拦截器实现的,比如 PageHelper。
// pageSize 每一页几条
PageHelper.startPage(pn, 10);
List emps = employeeService.getAll(); // navigatePages 导航页码数
PageInfo page = new PageInfo(emps, 10);
return Msg.success().add("pageInfo", page);
PageHelper 是通过 MyBatis 的拦截器实现的,插件的具体原理我们后面再分析。简单地来说,它会根据 PageHelper 的参数,改写我们的 SQL 语句。比如 MySQL会生成 limit 语句,Oracle 会生成 rownum 语句,SQL Server 会生成 top 语句。
通用 Mapper
问题:当我们的表字段发生变化的时候,我们需要修改实体类和 Mapper 文件定义的字段和方法。如果是增量维护,那么一个个文件去修改。如果是全量替换,我们还要去对比用 MBG 生成的文件。字段变动一次就要修改一次,维护起来非常麻烦。
解决这个问题,我们有两种思路。
第一个,因为 MyBatis 的 Mapper 是支持继承的(见https://github.com/mybatis/mybatis-3/issues/35 ) 。 所 以 我 们 可 以 把 我 们 的Mapper.xml 和 Mapper 接口都分成两个文件。一个是 MBG 生成的,这部分是固定不变的。然后创建 DAO 类继承生成的接口,变化的部分就在 DAO 里面维护。
mybatis-standalone 工程:
public interface BlogMapperExt extends BlogMapper {
public Blog selectBlogByName(String name);
}
所以以后只要修改 Ext 的文件就可以了。
这么做有一个缺点,就是文件会增多。
既然针对每张表生成的基本方法都是一样的,也就是公共的方法部分代码都是一样的,我们能不能把这部分合并成一个文件,让它支持泛型呢?编写一个支持泛型的通用接口,比如叫 GPBaseMapperBlogMapper extends GPBaseMapper
,自动获得对实体类的操作方法。遇到没有的方法,我们依然 可以在我们自己的 Mapper 里面编写。我们能想到的解决方案,早就有人做了这个事了,这个东西就叫做
通用 Mapper。 https://github.com/abel533/Mapper/wiki
用途:主要解决单表的增删改查问题,并不适用于多表关联查询的场景。
除了配置文件变动的问题之外,通用 Mapper 还可以解决:
- 每个 Mapper 接口中大量的重复方法的定义;
- 屏蔽数据库的差异;
- 提供批量操作的方法;
- 实现分页。
通用 Mapper 和 PageHelper 作者是同一个人(刘增辉)。
使用方式:在 Spring 中使用时,引入 jar 包,替换 applicationContext.xml 中的
sqlSessionFactory 和 configure。
Mybatis-Plus
https://mybatis.plus/guide
MyBatis-Plus 是原生 MyBatis 的一个增强工具,可以在使用原生 MyBatis 的所有 功能的基础上,使用 plus 特有的功能。
MyBatis-Plus 的核心功能:
通用 CRUD:
定义好 Mapper 接口后,只需要继承 BaseMapper
另外 MyBatis-Plus 也有分页的功能。
我的笔记仓库地址gitee 快来给我点个Star吧