首页 文章
取消

Mybatis:TypeHandler、Interceptor的应用案例

TypeHandlerInterceptorMybatis中的两种接口。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实现了TypeHandlersetParameter方法与三个重载的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时已经指定了jdbcTypejavaTypeMybatis会自动匹配jdbcTypeLONGVARCHARjavaTypeString的情况。

<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()的结果,即执行目标对象的原始方法。

  • 然后,获取当前事务中的所有资源,也就是所有被事务管理器管理的对象。这些对象可能包括DataSourceConnectionSqlSession等。这些对象都被存储在一个Map中,键是对象本身,值是一个封装了对象和其他信息的Holder对象。

  • 接着,遍历这个Map中的每个键值对,判断值是否是一个SqlSessionHolder对象。如果是,说明这个键就是一个SqlSession对象,那么就从SqlSessionHolder对象中获取这个SqlSession对象,并调用其clearCache方法来清空缓存。

其实只清空SqlSession的一级缓存,并不能完全保证for update时拿到的数据是最新的,因为有可能还存在二级缓存,至于如何完全保证,以下是Bing chat的回答(未验证过):

  • 一种是关闭二级缓存,只使用一级缓存。这样就可以避免不同SqlSession之间的数据不一致问题,但是也会牺牲一些查询性能和内存空间。

  • 另一种是使用关联刷新的机制,让一个Mapper的更新操作能够触发其他相关Mapper的缓存刷新。这样就可以保证二级缓存中的数据和数据库中的数据一致,同时也保留了二级缓存的优势。