TypeHandler
、Interceptor
是Mybatis
中的两种接口。TypeHandler
是类型转换器,负责JDBC
类型与Java
类型之间的转换;Interceptor
是拦截器,允许开发者在SQL
执行前后添加自己的逻辑。本文为这两种接口分别给出应用案例。
TypeHandler
场景
我们经常会有一些表字段是大文本类型,虽说这种字段可以使用MongoDB
存储,但有时跟其他字段放在一起总是会方便一些,这里不展开论述。为了提高性能,我们可以考虑在插入表之前,将大文本压缩一下,查询出来时再解压缩,这种功能就可以使用TypeHandler
实现。
实践
我们定义一个CompressHandler
来实现TypeHandler
接口。
public class CompressHandler implements TypeHandler<String> {
@Override
public void setParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
int maxLength = 32000;
if (Config.ENABLE_GZIP() && StringUtils.isNotBlank(parameter) && parameter.length() > maxLength) {
parameter = "GZIP:" + GzipUtil.compress(parameter);
}
ps.setString(i, parameter);
}
@Override
public String getResult(ResultSet rs, String columnName) throws SQLException {
String v = rs.getString(columnName);
if (StringUtils.isNotBlank(v) && v.startsWith("GZIP:")) {
v = GzipUtil.uncompress(v.substring(5));
}
return v;
}
@Override
public String getResult(ResultSet rs, int columnIndex) throws SQLException {
String v = rs.getString(columnIndex);
if (StringUtils.isNotBlank(v) && v.startsWith("GZIP:")) {
v = GzipUtil.uncompress(v.substring(5));
}
return v;
}
@Override
public String getResult(CallableStatement cs, int columnIndex) throws SQLException {
String v = cs.getString(columnIndex);
if (StringUtils.isNotBlank(v) && v.startsWith("GZIP:")) {
v = GzipUtil.uncompress(v.substring(5));
}
return v;
}
}
我们来分析一下上面的代码:
CompressHandler
实现了TypeHandler
的setParameter
方法与三个重载的getResult
方法。setParameter
的逻辑是当SQL
参数长度大于32000时,用GZIP
压缩后再拼接上GZIP:
的前缀作为新的参数值;而三个getResult
的逻辑都是当查询出来的字段值是以GZIP:
开头时,删除前缀并解压缩后作为新的返回值。
CompressHandler
定义完毕后,我们需要注册到Mybatis
的配置文件。
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<typeAliases>
<typeAlias type="neatlogic.framework.dao.plugin.CompressHandler" alias="CompressHandler"/>
</typeAliases>
<typeHandlers>
<typeHandler jdbcType="LONGVARCHAR" javaType="String"
handler="neatlogic.framework.dao.plugin.CompressHandler"/>
</typeHandlers>
</configuration>
我们使用<typeHandler>
注册了CompressHandler
,再使用<typeAlias>
为CompressHandler
指定了别名,别名的作用会在下文体现。
CompressHandler
注册完毕后,我们就可以在mapper.xml
中使用了,下面分别给出了查询和插入的时使用范例。
<resultMap id="transactionDetailMap" type="neatlogic.framework.cmdb.dto.transaction.TransactionVo">
<id property="id" column="id"/>
<result property="ciId" column="ciId"/>
<result property="ciLabel" column="ciLabel"/>
<result property="error" column="error" typeHandler="CompressHandler"/>
</resultMap>
如果没有定义别名,那么
typeHandler
属性就要写CompressHandler
的完整包路径。typeHandler
在这里也可以省略,因为我们注册CompressHandler
时已经指定了jdbcType
和javaType
,Mybatis
会自动匹配jdbcType
为LONGVARCHAR
、javaType
为String
的情况。
<insert id="insertContent">
REPLACE INTO test_content (id, content, compress_content)
values (1, #{value}, #{value,typeHandler=CompressHandler})
</insert>
Interceptor
场景一
有时我们需要监控一些SQL
的执行时间,监控的方式有很多种,本文介绍如何使用Interceptor
实现。
实践
我们定义一个SqlCostInterceptor
来实现Interceptor
接口。
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class SqlCostInterceptor implements Interceptor {
Logger logger = LoggerFactory.getLogger(SqlCostInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
long startTime = System.currentTimeMillis();
// 放行SQL的执行
Object val = invocation.proceed();
// 记录执行时间
logger.debug(String.valueOf(System.currentTimeMillis() - startTime));
return val;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
我们主要关注intercept
方法,它是Interceptor
的核心方法,它接收一个Invocation
对象,表示被拦截的目标对象和方法。在此方法中,首先记录当前时间作为开始时间,然后调用invocation.proceed()
放行SQL
的执行,然后计算执行时间,并打印到日志中,最后返回执行结果。
扩展
我们注意到SqlCostInterceptor
上标记了三个@Signature
,关于@Signature
,本文不做展开,以下引用Bing Chat的回答:
@Signature注解是用来定义拦截点的,它有三个属性,分别是type,method和args。它表示要拦截的类,方法和参数类型。代码中的三个@Signature分别对应Executor类的update和query方法,它们都有MappedStatement和Object两个参数,分别表示映射语句和参数对象。query方法还有RowBounds和ResultHandler两个参数,分别表示分页信息和结果处理器。第三个query方法还有CacheKey和BoundSql两个参数,分别表示缓存键和绑定SQL。这些方法都是MyBatis中执行SQL语句的核心方法,所以拦截它们可以获取SQL语句的执行时间。具体来说:
第一个@Signature表示要拦截Executor类的update方法,它有两个参数,分别是MappedStatement.class和Object.class。update方法是用来执行增删改操作的,它返回一个int值,表示影响的行数。
第二个@Signature表示要拦截Executor类的query方法,它有四个参数,分别是MappedStatement.class,Object.class,RowBounds.class和ResultHandler.class。query方法是用来执行查询操作的,它返回一个List对象,表示查询结果。RowBounds是用来实现分页查询的,ResultHandler是用来自定义结果处理的。
第三个@Signature表示要拦截Executor类的query方法,它有六个参数,分别是MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class,CacheKey.class和BoundSql.class。这个query方法是在第二个query方法的基础上增加了两个参数,CacheKey是用来实现二级缓存的,BoundSql是用来封装SQL语句和参数的。
场景二
我们再看一个Interceptor
的应用案例。
我们都知道用for update
可以锁住行数据,但是我们希望锁住之前,for update
所在的SQL
查询到的数据是最新的,此时我们就需要先清空SqlSession
的一级缓存,再执行SQL
。
实践
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SqlSessionCacheClearInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
// 判断sql语句是否以“for update”结尾
boolean isForUpdate = false;
int index = 0;
char[] lowerChars = {'e', 't', 'a', 'd', 'p', 'u', 'r', 'o','f'};
char[] upperChars = {'E', 'T', 'A', 'D', 'P', 'U', 'R', 'O','F'};
int length = sql.length();
for (int i = length - 1; i >= 0; i--) {
char c = sql.charAt(i);
if (c >= 'A' && c <= 'Z') {
if (upperChars[index] == c) {
index++;
} else {
break;
}
} else if (c >= 'a' && c <= 'z') {
if (lowerChars[index] == c) {
index++;
} else {
break;
}
}
if (index >= lowerChars.length) {
isForUpdate = true;
break;
}
}
// 如果sql是以“for update”结尾,就清空当前SqlSession缓存
if (isForUpdate) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
Map<Object, Object> resourceMap = TransactionSynchronizationManager.getResourceMap();
for (Map.Entry<Object, Object> entry : resourceMap.entrySet()) {
Object holder = entry.getValue();
if (holder instanceof SqlSessionHolder) {
SqlSessionHolder sqlSessionHolder = (SqlSessionHolder) holder;
SqlSession sqlSession = sqlSessionHolder.getSqlSession();
if (sqlSession != null) {
sqlSession.clearCache();
}
}
}
}
}
return invocation.proceed();
}
}
我们主要关注if (isForUpdate)
里的逻辑(以下引用自Bing Chat):
首先,判断当前是否有事务激活,也就是是否有
@Transactional
注解或者手动开启了事务。如果没有事务激活,那么就不需要清空缓存,直接返回invocation.proceed()
的结果,即执行目标对象的原始方法。然后,获取当前事务中的所有资源,也就是所有被事务管理器管理的对象。这些对象可能包括
DataSource
、Connection
、SqlSession
等。这些对象都被存储在一个Map中,键是对象本身,值是一个封装了对象和其他信息的Holder
对象。接着,遍历这个Map中的每个键值对,判断值是否是一个
SqlSessionHolder
对象。如果是,说明这个键就是一个SqlSession
对象,那么就从SqlSessionHolder
对象中获取这个SqlSession
对象,并调用其clearCache
方法来清空缓存。
其实只清空SqlSession
的一级缓存,并不能完全保证for update
时拿到的数据是最新的,因为有可能还存在二级缓存,至于如何完全保证,以下是Bing chat的回答(未验证过):
一种是关闭二级缓存,只使用一级缓存。这样就可以避免不同
SqlSession
之间的数据不一致问题,但是也会牺牲一些查询性能和内存空间。另一种是使用关联刷新的机制,让一个
Mapper
的更新操作能够触发其他相关Mapper
的缓存刷新。这样就可以保证二级缓存中的数据和数据库中的数据一致,同时也保留了二级缓存的优势。