MyBatis 高级篇
MyBatis 缓存
前言
频繁地查询必然会给数据库带来巨大的压力,为此 MyBatis 提供了丰富的缓存功能。缓存可以有效的提升查询效率、缓解数据库压力,提高应用的稳健性。
MyBatis 的缓存有两层,默认情况下会开启一级缓存,并提供了开启二级缓存的配置。本小节我们将一起学习 MyBatis 的缓存,充分地了解和使用它。
一级缓存
MyBatis 一级缓存是默认开启的,缓存的有效范围是一个会话内。一个会话内的 select 查询语句的结果会被缓存起来,当在该会话内调用 update、delete 和 insert 时,会话缓存会被刷新,以前的缓存会失效。
使用一级缓存
下面,我们以一个简单的例子来看看 MyBatis 的一级缓存是如何工作的。
1 | package com.ahao.mybatis.cache; |
结果:
1 | User{id=1, username='peter-gao', age=180, score=1000} |
在这个例子中,我们连续两次调用了 userMapper 的 selectUserById 方法,但是在程序输出中,user1 和 user2 却指向了同一块内存区域。这就是 MyBatis 缓存的作用,当第二次调用查询时,MyBatis 没有查询数据库而是直接从缓存中拿到了数据。
弃用一级缓存
select 配置关闭缓存
select 默认会启用一级缓存,我们也可通过配置来关闭掉 select 缓存。
如下,我们通过 flushCache 属性来关闭 select 查询的缓存。
1 | <select id="selectUserById" flushCache="true" parameterType="java.lang.Integer" |
再次运行程序,结果如下:
1 | User{id=1, username='peter-gao', age=180, score=1000} |
此时 user1 与 user2 不再指向同一内存区,缓存失效了。
调用 insert、update、delete 刷新缓存
一般情况下,我们都推荐开启 select 的缓存,因为这会节省查询时间。当然在一个会话中,调用 insert、update、delete 语句时,会话中的缓存也会被刷新。
如下:
1 | UserMapper userMapper = session.getMapper(UserMapper.class); |
在第一个查询调用前,我们先进行了一次 insert 操作,此时会刷新缓存,user1 和 user2 又没有指向同一处内存。
二级缓存
MyBatis 二级缓存默认关闭,我们可以通过简单的设置来开启二级缓存。二级缓存的有效范围为一个 SqlSessionFactory 生命周期,绝大多数情况下,应用都会只有一个 SqlSessionFactory,因此我们可以把二级缓存理解为全局缓存。
全局可用
在 MyBatis 全局配置文件中,即 mybatis-config.xml 文件,二级缓存可由 settings 下的 cacheEnabled 属性开启。如下:
1 | <settings> |
当打开 cacheEnabled 属性后,二级缓存全局可用。
TIPS:注意,这里是可用,cacheEnabled 的默认值其实也是 true,即全局可用,由于二级缓存需要对 mapper 配置后才真正生效,简单来说就是双层开关。当将其设置为 false 后,则全局关闭,mapper 中即使配置了,二级缓存也会失效。
mapper 中开启
xml 开启
在二级缓存全局可用的情况下,mapper 才可通过 cache 配置开启二级缓存。如,在 UserMapper.xml 文件中开启二级缓存:
1 | <cache/> |
这种情况下,缓存的行为如下:
- mapper 下的所有 select 语句会被缓存;
- mapper 下的 update,insert,delete 语句会刷新缓存;
- 使用 LRU 算法来回收对象;
- 最大缓存 1024 个对象;
- 缓存可读、可写。
- 缓存不会根据时间来刷新。
cache 提供了诸多属性来修改缓存行为,示例如下:
1 | <cache |
这个例子下的缓存使用 FIFO 算法来回收对象,并每隔 60 秒刷新一次,最多缓存 512 个对象,且缓存只可读。
cache 有 4 个属性可配置,从而改变缓存的行为。
属性 | 描述 |
---|---|
eviction | 回收策略,默认 LRU,可选择的有 FIFO(先进先出),SOFT(软引用),WEAK(弱引用) |
flushInterval | 刷新时间 |
size | 最多缓存对象数 |
readOnly | 是否只读 |
注解开启
如果你不使用 mapper.xml 文件,也可以使用注解来开启。
如下:
1 | package com.ahao.mybatis.mapper; |
注解 CacheNamespace 的配置与 xml 配置保持一致,唯一区别在于若使用注解,那么 eviction 属性需直接给出缓存实现类。
缓存共享
xml 共享
有时候,我们想在不同的 mapper 中共享缓存,为了解决这类问题,MyBatis 提供了 cache-ref 配置。
使用也很简单,如下:
1 | <cache-ref namespace="com.ahao.mybatis.mapper.UserMapper"/> |
mapper 由 namespace 来唯一标识,因此只需在另一个 mapper 文件中添加上 cache-ref 配置,并加上相应的 namespace 即可。
这样当前的 mapper 可以共享来自 UserMapper 的缓存。
注解共享
同样的,我们也可以使用注解来共享缓存。
如下:
1 |
|
这里,BlogMapper 共享了 UserMapper 的缓存。
TIPS: 注意,CacheNamespaceRef 与 CacheNamespace 不能共存,既然选择了共享就不能再独立开辟缓存区了。
小结
- MyBatis 的一级缓存默认可用,有效范围小,不会影响到其它会话,因此无特殊情况,不推荐丢弃一级缓存。
- MyBatis 二级缓存默认使用程序内存缓存,但这显然不够安全,一般情况下我们都推荐使用 Redis 等专业的缓存。
MyBatis 类型处理器
前言
MyBatis 提供了诸多类型处理器,但是相较于丰富的数据库类型仍然略显不足,比如 MyBatis 只能将 JSON 数据类型当成普通的字符串处理。因此 MyBatis 提供了类型处理器接口,让开发者可以根据具体的业务需求来自定义适合的类型处理器。
本小节,我们将以 JSON 类型处理器作为落脚点,来介绍类型处理器,并自定义 JSON 类型处理器。
JSON 数据类型
首先,我们需要为 MyBatis 内置类型处理器增加一个它无法处理的数据类型,这里我们选择 MySQL5.7 中新增的 JSON 数据类型,这也是大家普遍使用的一个数据类型。在可用的数据库环境中,我们运行如下脚本:
1 | DROP TABLE IF EXISTS blog; |
在这个脚本中,我们新建了一个 blog 数据表,blog 数据表除 id 外有 info 和 tags 两个字段,这两个字段都是 JSON 类型,并通过 insert 语句添加了两条记录。
类型处理器
MyBatis 默认是无法很好处理 info 和 tags 这两个字段的,只能将它们当成字符串类型来处理,但显然这不是我们想要的效果。我们希望新增 json 类型处理器来处理好这两个字段。
MyBatis 提供了 TypeHandler 接口,自定义类型处理器需要实现了这个接口才能工作。考虑到很多开发者不够熟练,MyBatis 还提供了一个 BaseTypeHandler 抽象类来帮助我们做自定义类型处理器,只需继承这个基类,然后实现它的方法即可。
JsonObject 处理器
JSON 可分为 object 和 array 两大类,分别对应 info 和 tags 字段,这两类需要分别实现类型处理器。由于需要对 JSON 进行处理,我们在 pom.xml 文件中添加上对应的依赖。
1 | <dependency> |
这里,我们使用阿里巴巴开源的 fastjson库。
在 com.ahao.mybatis 包下新建 handler 包,并向 handler 包中添加上 json object 的类型处理器 JsonObjectTypeHandler。如下:
1 | package com.ahao.mybatis.handler; |
有了 BaseTypeHandler 作为基础后,实现一个类型处理器就比较简单了,我们只需要为其中 4 个方法添加上对应的实现即可。
类型处理器有两个作用,第一处理 Java 对象到 JdbcType 类型的转换,对应 setNonNullParameter 方法;第二处理 JdbcType 类型到 Java 类型的转换,对应 getNullableResult 方法,getNullableResult 有 3 个重载方法。下面我们依次来说明这四个方法的作用:
- setNonNullParameter:处理 PreparedStatement 中的 JSONObject 参数,当调用 PreparedStatement 执行 SQL 语句时,调用该处理 JSONObject 类型的参数,这里我们通过 fastjson 的
JSON.toJSONString(o)
函数将 JSONObject 转化为字符串类型即可。 - getNullableResult:从结果集中获取字段,这里 CallableStatement 和 ResultSet 分别对应不同的执行方式,对于 JDBC 而言 JSON 类型也会当做字符串来处理,因此这里我们需要将字符串类型转化为 JSONObject 类型,对应
JSON.parseObject(t)
代码。
JsonArray 处理器
与 JsonObjectTypeHandler 一样,在 handler 包下新建 JsonArrayTypeHandler 类,继承 BaseTypeHandler 类,并将具体方法的实现从 JSON.parseObject 改变为 JSON.parseArray,如下:
1 |
|
注册类型处理器
自定义类型处理器无法直接被 MyBatis 加载,我们需要增加相关的配置告诉 MyBatis 加载类型处理器。
全局注册
在全局配置配置文件中可通过 typeHandlers 属性来注册类型处理器。如下:
1 | <typeHandlers> |
通过 package 项来指定类型处理器所在的包路径,这样 handler 包中的所有类型处理器都会注册到全局。
当然如果你的类型处理器分散在其它地方,也可以通过如下方式来注册。
1 | <typeHandlers> |
全局注册的类型处理器会自动被 MyBatis 用来处理所有符合类型的参数。如 JsonArrayTypeHandler 通过 MappedJdbcTypes 注解表明了自己将会处理 JdbcType.VARCHAR 类型,MyBatis 会自动将字符串类型的参数交给 JsonArrayTypeHandler 来进行处理。
但是,这样显然有问题,因为 JsonObjectTypeHandler 注册的类型也是 JdbcType.VARCHAR 类型,所以全局注册是不推荐的,除非你需要对所有参数都做类型转换。
局部注册
由于全局注册会对其它类型产生歧义和污染,因此我们选择更加精准的局部注册。在 BlogMapper 中,我们来注册和使用类型处理器。
在 BlogMapper.xml 文件中,我们添加上如下配置。
1 | <resultMap id="blogMap" type="com.ahao.mybatis.model.Blog"> |
我们定义了 名为 blogMap 的 resultMap 和名为 selectById 的查询。在 result 映射中,我们注册了相关的类型处理器,info 字段对应
JsonObjectTypeHandler 类型处理器,tags 字段对应 JsonArrayTypeHandler 类型处理器。
这样自定义的类型处理器不会污染到其它数据,blogMap 的类型 com.ahao.mybatis.model.Blog 定义如下:
1 | package com.ahao.mybatis.model; |
处理 JDBC 类型
在对应的 BlogMapper.java 接口上添加上对应的 selectById 方法:
1 | package com.ahao.mybatis.mapper; |
我们测试一下 selectById 方法:
1 | BlogMapper blogMapper = session.getMapper(BlogMapper.class); |
输出结果如下:
1 | Blog{id=1, info={"rank":1,"title":"世界更大","content":".......****............"}, tags=["世界观"]} |
从结果中可以看出,类型处理器成功的处理了查询的数据,info 和 tags 字段都能够通过 fastjson 的 API 来获取里面的内容。
处理 JSON 类型
在查询可以工作的情况下,那么如何通过 insert 插入 JSON 对象了。
我们在 BlogMapper 中新增一个 insertBlog 方法,如下:
1 | <insert id="insertBlog"> |
这样 MyBatis 就可以处理 JSON 类型的参数了,我们再次测试一下:
1 | JSONObject info = new JSONObject().fluentPut("title", "测试案例").fluentPut("rank", 1); |
输出结果:
1 | 1 |
可以看到类型处理器成为了 Java JSON 类型和 JDBC 类型转换桥梁,在查询的时候主动将数据库类型转化为了可用的 JSON 类型,而在插入的时候将 JSON 类型又转化为了数据库可识别的字符串类型。
小结
- 自定义类型处理器并不难,MyBatis 已经帮我们做好了大多数工作,我们只需在适当的位置适当的配置就可以了。
- 数据库 JSON 类型的用处会越来越广泛,在 MyBatis 官方未内置处理器之前,我们也可以通过本小节的方式来提早的使用它。
MyBatis 插件
前言
MyBatis 允许我们以插件的形式对已映射语句执行过程中的某一点
进行拦截调用
,通俗一点来说,MyBatis 的插件其实更应该被称作为拦截器。
MyBatis 插件的使用十分广泛,分页、性能分析、乐观锁、逻辑删除等等常用的功能都可以通过插件来实现。既然插件如此好用,本小节我们就一起来探索插件并且实现一个简单的 SQL 执行时间计时插件。
介绍
可拦截对象
MyBatis 允许插件拦截如下 4 个对象的方法。
- Executor的 update, query, flushStatements, commit, rollback, getTransaction, close, isClosed 方法
- ParameterHandler的 getParameterObject, setParameters 方法
- ResultSetHandler的 handleResultSets, handleOutputParameters 方法
- StatementHandler的 prepare, parameterize, batch, update, query 方法
注意,这四个对象都是接口,插件会拦截实现了该接口的对象。
插件接口
插件必须实现 Interceptor 接口。Interceptor 接口共有 3 个方法,如下:
1 | public interface Interceptor { |
其中 plugin 和 setProperties 方法都是默认实现的方法,我们可以选择不覆盖实现,而 intercept 方法则必须实现。如下:
- intercept : 核心方法,通过 Invocation 我们可以拿到被拦截的对象,从而实现自己的逻辑。
- plugin: 给 target 拦截对象生成一个代理对象,已有默认实现。
- setProperties: 插件的配置方法,在插件初始化的时候调用。
拦截器签名
插件可对多种对象进行拦截,因此我们需要通过拦截器签名来告诉 MyBatis 插件应该拦截何种对象的何种方法。举例如下:
1 |
|
类 XXXPlugin 上有两个注解:
- Intercepts注解: 拦截声明,只有 Intercepts 注解修饰的插件才具有拦截功能。
- Signature注解: 签名注解,共 3 个参数,type 参数表示拦截的对象,如 StatementHandler,另外还有Executor、ParameterHandler和ResultSetHandler;method 参数表示拦截对象的方法名,即对拦截对象的某个方法进行拦截,如 prepare,代表拦截 StatementHandler 的 prepare 方法;args 参数表示拦截方法的参数,因为方法可能会存在重载,因此方法名加上参数才能唯一标识一个方法。
推断可知 XXXPlugin 插件会拦截 StatementHandler对象的 prepare(Connection connection, Integer var2) 方法。
一个插件可以拦截多个对象的多个方法,因此在 Intercepts 注解中可以添加上多个 Signature注解。
实践
接下来,我们一起来实现一个简单的 SQL 执行时间计时插件。插件的功能是日志输出每一条 SQL 的执行用时。
在 com.ahao.mybatis 包下,我们新建 plugin 包,并在包中添加 SqlStaticsPlugin 类。SqlStaticsPlugin 会拦截 StatementHandler的prepare方法,如下:
1 | package com.ahao.mybatis.plugin; |
我们一起来完善这个插件。
- 首先需要得到 invocation 的拦截对象 StatementHandler,并从 StatementHandler 中拿到 SQL 语句。
- 得到当前的时间戳 startTime。
- 执行 SQL。
- 得到执行后的时间戳 endTime。
- 计算时间差,并打印 SQL 耗时。
对应的 intercept 方法代码如下:
1 | public Object intercept(Invocation invocation) throws Throwable { |
注意,通过反射调用后的结果 res,我们一定要记得返回。MyBatis 提供了 MetaObject 这个类来方便我们进行拦截对象属性的修改,这里我们简单的使用了getValue
方法来得到 SQL 语句。
我们在全局配置文件注册这个插件:
1 | <plugins> |
到这,这个插件已经可以工作了,但是我们希望它能更加灵活一点,通过配置来拦截某些类型的 SQL,如只计算 select 类型SQL的耗时。
插件会在初始化的时候通过 setProperties 方法来加载配置,利用它我们可以得到哪些方法需要被计时。如下:
1 | public class SqlStaticsPlugin implements Interceptor { |
methods 参数默认可通过 select、insert、update 和 delete 类型的SQL语句,如果插件存在配置项 methods,那么则根据插件配置来覆盖默认配置。
在全局配置文件中,我们来添加上 methods 这个配置:
1 | <plugins> |
类型之间以 , 隔开,MyBatis 会在插件初始化时,自动将 methods 对应的值通过 setProperties 方法来传递给SqlStaticsPlugin插件。插件拿到 Properties 后解析并替换默认的 methods 配置。
再次完善一下 intercept 方法,使其支持配置拦截:
1 | public Object intercept(Invocation invocation) throws Throwable { |
当插件注册后,应用程序会打印出如下的日志语句:
1 | 17:48:14.110 [main] INFO com.ahao.mybatis.plugin.SqlStaticsPlugin - sql: INSERT INTO blog(info,tags) VALUES(?, ?) - cost: 87ms |
至此,一个简单的 SQL 计时插件就开发完毕了。
小结
- MyBatis 插件强大且易用,是深入掌握 MyBatis 的必备知识点。
- 不少 MyBatis 三方库都提供了很多好用的插件,如 Pagehelper 分页插件,我们可以拿来即用。