基于mybatis拦截器分表实现
1、拦截器简介
MyBatis提供了一种插件(plugin)的功能,但其实这是拦截器功能。基于这个拦截器我们可以选择在这些被拦截的方法执行前后加上某些逻辑或者在执行这些被拦截的方法时执行自己的逻辑。
这点跟spring的拦截器是基本一致的。它的设计初衷就是为了供用户在某些时候可以实现自己的逻辑而不必去动Mybatis固有的逻辑。
拦截器的使用中,分页插件应该是使用得最多的了。分表的实现也差不多类似。
2、拦截的方法调用
MyBatis 允许你在已映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
- Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
- ParameterHandler (getParameterObject, setParameters)
- ResultSetHandler (handleResultSets, handleOutputParameters)
- StatementHandler (prepare, parameterize, batch, update, query)
总体概括为:
- 拦截执行器的方法
- 拦截参数的处理
- 拦截结果集的处理
- 拦截Sql语法构建的处理
我们看到了可以拦截Executor接口的部分方法,比如update,query,commit,rollback等方法,还有其他接口的一些方法等。
这4各方法在MyBatis的一个操作(新增,删除,修改,查询)中都会被执行到,执行的先后顺序是Executor,ParameterHandler,ResultSetHandler,StatementHandler。undefine
3、Interceptor接口
了解到了拦截器能够拦截的方法调用,就需要看看拦截接口是如何实现的了。
package org.apache.ibatis.plugin; import java.util.Properties; public interface Interceptor { Object intercept(Invocation invocation) throws Throwable; Object plugin(Object target); void setProperties(Properties properties); } |
接口中一共定义有三个方法,intercept、plugin 、setProperties。
- intercept方法就是要进行拦截的时候要执行的方法。
- setProperties方法是用于在Mybatis配置文件中指定一些属性的。
- plugin方法是拦截器用于封装目标对象的,通过该方法我们可以返回目标对象本身,也可以返回一个它的代理。当返回的是代理的时候我们可以对其中的方法进行拦截来调用intercept方法,当然也可以调用其他方法。
分表实现
1、大体思路
基于业务来看,我想要 按月 分表,因此数据库表里增加了一个string类型字段 account_month 来记录月份,分表字段就使用account_month。
分表表名:表名_年月 例如明细表:ebs_date_detail_201607。
分表是一月一张表,分表的建立就是默认建立了12个分表,如果超出了,后续再手工添加吧。也可以写个脚本每月底创建下一个月的表,但是我觉得没啥必要。就算哪天忘记添加了,代码逻辑的异常处理流程里面也能够保证我的数据不丢失,启动一下异常数据处理也就妥妥的了。
在sql语句里面会要求带上分表字段,通过分表字段计算得到分表的表名,然后替换掉原来的sql,直接将数据路由到指定的分表就行了。
听起来好像很简单的样子,那么就这么出发吧。
2、问题目录
分表开始之前的问题:
- Mybatis如何找到我们新增的拦截服务。
- 自定义的拦截服务应该在什么时间拦截查询动作。即什么时间截断Mybatis执行流。
- 自定义的拦截服务应该拦截什么样的对象。不能拦截什么样的对象。
- 自定义的拦截服务拦截的对象应该具有什么动作才能被拦截。
- 自定义的拦截服务如何获取上下文中传入的参数信息。
- 如何把简单查询,神不知鬼不觉的,无侵入性的替换为分表查询语句。
- 最后,拦截器应该如何交还被截断的Mybatis执行流。
带着这些问题,我们来看看我们自定义的拦截服务是如何实现的。
3、逐步实现
3.1 Mybatis如何找到我们新增的拦截服务
对于拦截器Mybatis为我们提供了一个Interceptor接口,前面有提到,通过实现该接口就可以定义我们自己的拦截器。自定义的拦截器需要交给Mybatis管理,这样才能使得Mybatis的执行与拦截器的执行结合在一起,即,拦截器需要注册到mybatis-config配置文件中。
通过在Mybatis配置文件中plugins元素下的plugin元素来进行。一个plugin对应着一个拦截器,在plugin元素下面我们可以指定若干个property子元素。Mybatis在注册定义的拦截器时会先把对应拦截器下面的所有property通过Interceptor的setProperties方法注入给对应的拦截器。
配置文件:mybatis-config.xml
<configuration> <plugins> <plugin interceptor="com.selicoco.sango.common.database.paginator.interceptor.ShardTableInterceptor"> </plugin> </plugins> </configuration>
3.2 什么时间截断Mybatis执行流
Mybatis允许我们能够进行切入的点:
-
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
-
ParameterHandler (getParameterObject, setParameters)
-
ResultSetHandler (handleResultSets, handleOutputParameters)
-
StatementHandler (prepare, parameterize, batch, update, query)
因为我是想要通过替换原来SQL中的表名来实现分表,包括查询,新增,删除等操作,所以拦截的合理时机选在StatementHandler中prepare。
执行流在PreparedStatementHandler.instantiateStatement()方法中 return connection.prepareStatement(sql); 最终真正的执行了语句。
所以拦截器的注解内容:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = { Connection.class }) })
3.3 应该拦截什么样的对象
并不是所有的表都进行了分表,也不是所有的表都需要拦截处理。所以我们要根据某些配置来确定哪些需要被处理。
这里主要使用注解的方式,设置了对应的参数。
@Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface TableSeg { //表名 public String tableName(); // 分表方式,取模,如%5:表示取5余数, // 按时间,如MONTH:表示按月分表 // 如果不设置,直接根据shardBy值分表 public String shardType(); //根据什么字段分表 ,多个字段用数学表达表示,如a+b a-b public String shardBy(); // 根据什么字段分表,多个字段用数学表达表示,如a+b a-b public String shardByTable(); }
注解完成后,在mapper上去配置。如果是自定义的查询语句和返回,没有对应的mapper文件,那么在对应的dao 上进行配置就可以了。
@TableSeg(tableName="ebs_date_detail",shardType="MONTH",shardBy="accountMonth",shardByTable="account_month") public interface EbsDataDetailMapper {}
@Repository @TableSeg(tableName="ebs_date_detail",shardType="MONTH",shardBy="accountMonth",shardByTable="account_month") public class EbsDataDetailDao {}
3.4 如何获取上下文中传入的参数
关于这个问题,我觉得我有很大的发言权,真的是摸着石头过来的。
天真的以为,只要拿到要执行之前已经组装好的语句,然后用我的分表表名替换一下原表名就可以了。当然其实也差不多就是这样子的。不过实际情况还是有点坎坷的。
首先,如何拿到执行前已经组装好的语句。分两种情况来说,查询和更新。
不说话先看图:
新增数据的时候,我们从boundSql里面的additionalParameters 里面能轻松拿到注解上面 shardBy="accountMonth"所对应的参数值。然后根据参数来生成分表语句,一切顺利。
如此简单,觉得自己好机智。开心的去码后面的代码了,等到单测的时候执行查询,然后就报错啦。只能Debug看看。
没有想到,都是mybatis的动态sql,结果参数方式竟然不同,想来也只能自己去取参数了。参数在哪里?看图
具体的就看后面实现代码吧,反正就是通过两种方式取到我们要的分表字段的参数值,这样才能求得分表表名。
3.5 真正实现分表查询语句
拦截器主要的作用是读取配置,根据配置的切分策略和字段,来切分表,然后替换原执行的SQL,从而实现自动切分。
String accountMonth = genShardByValue(metaStatementHandler, mappedStatement ,tableSeg, boundSql); String newSql = boundSql.getSql().replace(tableSeg.tableName(), tableSeg.tableName() + "_" + accountMonth); if (newSql != null) { logger.debug(tag, "分表后SQL =====>" + newSql); metaStatementHandler.setValue("delegate.boundSql.sql", newSql); }
3.6 交还被截断的Mybatis执行流
把原有的简单查询语句替换为分表查询语句了,现在是时候将程序的控制权交还给Mybatis了
// 传递给下一个拦截器处理 return invocation.proceed();
4 实现源码
4.1 配置文件
见本文: 3.1 Mybatis如何找到我们新增的拦截服务 -- mybatis-config.xml
4.2 分表配置注解
分表注解定义、mapper注解配置、DAO注解配置
见本文: 3.3 应该拦截什么样的对象
4.3 分表实现
分表具体实现
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = { Connection.class }) }) public class ShardTableInterceptor implements Interceptor { private final static Logger logger = LoggerFactory.getLogger(ShardTableInterceptor.class); private static final String tag = ShardTableInterceptor.class.getName(); @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaStatementHandler = MetaObject.forObject(statementHandler); MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue("delegate.mappedStatement"); BoundSql boundSql = (BoundSql) metaStatementHandler.getValue("delegate.boundSql"); String sqlId = mappedStatement.getId(); String className = sqlId.substring(0, sqlId.lastIndexOf(".")); Class<?> classObj = Class.forName(className); TableSeg tableSeg = classObj.getAnnotation(TableSeg.class); if(null == tableSeg){ //不需要分表,直接传递给下一个拦截器处理 return invocation.proceed(); } //根据配置获取分表字段,生成分表SQL String accountMonth = genShardByValue(metaStatementHandler, mappedStatement ,tableSeg, boundSql); String newSql = boundSql.getSql().replace(tableSeg.tableName(), tableSeg.tableName() + "_" + accountMonth); if (newSql != null) { logger.debug(tag, "分表后SQL =====>" + newSql); metaStatementHandler.setValue("delegate.boundSql.sql", newSql); } // 传递给下一个拦截器处理 return invocation.proceed(); } @Override public Object plugin(Object target) { // 当目标类是StatementHandler类型时,才包装目标类,否者直接返回目标本身,减少目标被代理的次数 if (target instanceof StatementHandler) { return Plugin.wrap(target, this); } else { return target; } } @Override public void setProperties(Properties properties) { logger.info("scribeDbNames:" + properties.getProperty("scribeDbNames")); } //根据配置获取分表的表名后缀 private String genShardByValue(MetaObject metaStatementHandler,MappedStatement mappedStatement, TableSeg tableSeg, BoundSql boundSql) { String accountMonth = null; Map<String, Object> additionalParameters = (Map<String, Object>) metaStatementHandler.getValue("delegate.boundSql.additionalParameters"); if (null != additionalParameters.get(tableSeg.shardBy())) { accountMonth = boundSql.getAdditionalParameter(tableSeg.shardBy()).toString(); } else { Configuration configuration = mappedStatement.getConfiguration(); String showSql = showSql(configuration,boundSql); accountMonth = getShardByValue(showSql,tableSeg); } return accountMonth; } //根据配置获取分表参数值 public static String getShardByValue(String showSql,TableSeg tableSeg) { final String conditionWhere = "where"; String accountMonth = null ; if(StringUtils.isBlank(showSql)){ return null; }else{ String[] sqlSplit = showSql.toLowerCase().split(conditionWhere); if(sqlSplit.length>1 && sqlSplit[1].contains(tableSeg.shardByTable())){ accountMonth = sqlSplit[1].replace(" ","").split(tableSeg.shardByTable())[1].substring(2,8); } } return accountMonth; } //组装查询语句参数 public static String showSql(Configuration configuration, BoundSql boundSql) { Object parameterObject = boundSql.getParameterObject(); List<ParameterMapping> parameterMappings = boundSql.getParameterMappings(); String sql = boundSql.getSql().replaceAll("[\\s]+", " "); if (parameterMappings.size() > 0 && parameterObject != null) { TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry(); if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) { sql = sql.replaceFirst("\\?", getParameterValue(parameterObject)); } else { MetaObject metaObject = configuration.newMetaObject(parameterObject); for (ParameterMapping parameterMapping : parameterMappings) { String propertyName = parameterMapping.getProperty(); if (metaObject.hasGetter(propertyName)) { Object obj = metaObject.getValue(propertyName); sql = sql.replaceFirst("\\?", getParameterValue(obj)); } else if (boundSql.hasAdditionalParameter(propertyName)) { Object obj = boundSql.getAdditionalParameter(propertyName); sql = sql.replaceFirst("\\?", getParameterValue(obj)); } } } }else{ return null; } return sql; } private static String getParameterValue(Object obj) { String value = null; if (obj instanceof String) { value = "'" + obj.toString() + "'"; } else if (obj instanceof Date) { DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.CHINA); value = "'" + formatter.format(new Date()) + "'"; } else { if (obj != null) { value = obj.toString(); } else { value = ""; } } return value; } }