相关源码已上传至我的github,对应的插件代码在src/main/java/net/dwade/plugins/mybatis目录
https://github.com/huangxfchn/dwade/tree/master/framework-plugins
项目背景
项目中使用oracle数据库 + mybatis框架,由于数据量较大,需要使用日表。而我们又不希望对mybatis的mapper文件做较大的改动,比如在SQL中添加日表后续,通过变量符的方式操作日表,因为这样的话就不能使用mybatis预编译的SQL影响性能,而且将来如果使用分布式数据库的话,意味着将来还要改动mapper文件。虽然当当有sharding-jdbc框架,但是不支持oracle,因此,自己开发了简单的mybatis插件,通过sql改写的方式操作日表。
特性
- 支持oracle、mysql
- 支持pagehelper分页插件
- 简单实用,出于项目实际情况考虑,该插件目前只支持编码的方式指定要操作的日表,不支持根据某个字段进行拆表
quick start
添加插件支持
如果项目中用到了pagehelper分页插件,需要将该插件放到分表插件前面,因为mybatis对拦截器进行了处理,顺序靠后的的拦截器越先执行,下面是InterceptorChain中的pluginAll方法,返回的是最后被代理的对象
public Object pluginAll(Object target) {
for (Interceptor interceptor : interceptors) {
target = interceptor.plugin(target);
}
return target;
}
下面是mybatis的xml配置,添加了分表插件
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close">
<property name="driverClass" value="oracle.jdbc.driver.OracleDriver" />
<property name="jdbcUrl" value="jdbc:oracle:thin:@localhost:1521:dwade" />
<property name="user" value="******" />
<property name="password" value="******" />
<property name="minPoolSize" value="20" />
<property name="maxPoolSize" value="200" />
<property name="initialPoolSize" value="20" />
<property name="acquireIncrement" value="20" />
<property name="checkoutTimeout" value="10000" />
<property name="idleConnectionTestPeriod" value="600" />
<property name="maxIdleTime" value="600" />
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="configuration" ref="mybatisConfig" />
<property name="plugins">
<array>
<!-- 具体参数请查看wiki: https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md -->
<bean class="com.github.pagehelper.PageInterceptor">
<property name="properties">
<value>
helperDialect=oracle
reasonable=true
supportMethodsArguments=true
</value>
</property>
</bean>
<!-- 日表插件 -->
<bean id="tableSegInterceptor"
class="net.dwade.plugins.mybatis.ShardingInterceptor">
</bean>
</array>
</property>
<property name="mapperLocations" value="classpath*:net/dwade/payment/dao/**/*Mapper.xml" />
<property name="typeAliasesPackage" value="net.dwade.payment.dao.*.model" />
</bean>
<bean id="mybatisConfig" class="org.apache.ibatis.session.Configuration">
<property name="logImpl" value="org.apache.ibatis.logging.log4j2.Log4j2Impl" />
</bean>
<bean id="paymentMapperScanner" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="basePackage" value="net.dwade.payment.dao" />
<property name="sqlSessionFactoryBeanName" value="sqlSessionFactory" />
</bean>
编码
使用方式和分页插件相同,通过将日表条件绑定到ThreadLocal中,简单的调用ShardingHolder的api即可。在项目中,我们会在主键中体现日期,很多情况下是知道数据存放在哪张表里面的。
* 插入日表
假设我们需要将数据插入到T_USER_20170603这张表中,如下面的代码所示。
User user = new User();
user.setUserId( "12341234" );
user.setCreateTime( new Date() );
user.setUserName( "15567899876" );
user.setEmail( "xxx@163.com" );
ShardingHolder.set( "20170603" );
userDao.insert( user );
- 日表查询
ShardingHolder.set( "20170603" );
userDao.selectByPrimaryKey( "2017060312341234" );
- 多表、分页关联查询
ShardingHolder.set( "20170601", "20170602" );
PageHelper.startPage( xxx, xxx );
userDao.selectXXX( param1, param2 );
注意事项
- 多个表关联查询,需要按照表名在sql出现的顺序,依次设置,如果涉及到其中的某个表为全表,设为null即可,eg:ShardingHolder.set( “20170712”, null, “20170712” );
- 为了保证分页条件的准确性,调用ShardingHolder的set方法之后必须紧接着调用dao方法,错误示例:
ShardingHolder.set( "20170601", "20170602" );
// do something 1
// do something 2
payUserDao.selectXXX( "12341234" );
源码说明
该插件的原理非常简单,通过拦截StatementHalder接口,对sql进行解析、改写,mybatis使用改写的SQL执行,最终获得我们想要的结果。
mybatis拦截器基本原理
mybatis允许我们对四大接口的方法进行拦截,所以要先了解Mybatis的四大接口对象Executor, StatementHandler, ResultSetHandler, ParameterHandler各自的作用,分别代表执行器,SQL语法处理、结果集处理、参数处理。关于更详细的介绍,请参考《mybatis插件原理》http://www.jianshu.com/p/7c7b8c2c985d
核心代码
该分表插件拦截了StatementHandler的prepare方法,用于对SQL进行改写,如Signature注解所示。其中,args代表方法的参数,因为只有指定了接口、方法名、参数,mybatis才能确定需要拦截哪个方法,值得一提的是,低版本的mybatis的StatementHandler接口中的prepare方法只有一个参数(如3.2.8版本只有一个参数,而我的项目里面用的是3.4.2),因此注解中args指定了Connection和Integer。此外,还拦截了Executor的query和update方法,主要的作用是为了支持pagehelper分页插件,因为在分页插件中,先是调用了Executor接口执行了一次count (1)的SQL语句,然后才是执行查询数据的SQL。这样一来,执行count (1)的SQL会调用我们拦截器的interceptor方法,如果不做额外的处理,分表条件便会清除,所以我们还拦截了Executor接口,并且在其执行完毕之后才清理ThreadLocal中的分表条件,如代码中的54行所示。
对于SQL解析,我们使用的是开源的jsqlparse,简单的封装了下,只获取SQL中的表结构,具体请参考net.dwade.plugins.mybatis.parser.JSqlParserFactory.java
/**
* mybatis分表拦截器,<em>如果同时和分页插件一起使用,需要配置在分页插件之后</em><br/>
* <p>mybatis拦截器的执行顺序:Executor-->StatementHandler(ParameterHandler)-->ResultSetHandler</p>
* <p>
* 该分表插件拦截了Executor的query和update方法,Executor执行完毕之后将分表条件清除,
* 否则会把全表操作误认为分表操作,此外,由于分页插件也拦截了Executor的query方法,因此和分页插件同时
* 使用时需要将分页插件配置在该分表插件前面,因为InterceptorChain.pluginAll(Object target)返回的
* 是最后一个拦截器的代理,因此会先执行最后一个拦截器的intercept方法
* </p>
* <p>为了避免对非日表的操作带来影响,该插件在Executor执行完毕的时候清除ThreadLocal中的分表条件。</p>
* <strong>为什么不在获取分表条件之后就清理ThreadLocal中的分表条件?</strong>
* 因为分页插件拦截的是Executor,并且自己创建了BoundSql进行调用,先是count操作,再是查询数据,如果拦截的是获取之后就清除,
* 那么只会对count操作的分表起作用,对分页插件的数据查询操作是不会起作用的
* @author huangxf
* @date 2017年6月29日
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = { java.sql.Connection.class, Integer.class }),
@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 ShardingInterceptor implements Interceptor {
private Logger logger = LoggerFactory.getLogger( this.getClass() );
private final SqlParserFactory parserFactory = new JSqlParserFactory();
private final Field boundSqlField;
public final String DEFAULT_SEPARATOR = "_";
/**
* 分表的连接符,T_ORDER_20160629,其中T_ORDER为逻辑表名,_代表separator
*/
private String separator = DEFAULT_SEPARATOR;
public ShardingInterceptor() {
try {
boundSqlField = BoundSql.class.getDeclaredField("sql");
boundSqlField.setAccessible(true);
} catch (Exception e) {
throw new RuntimeException( e );
}
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
//---------------------------------------------------------------
// 对于分页插件而言,它自己调用了count的SQL查询,最后还是会进入intercept方法,只不过
// invocation的target是StatementHandler了,而不再是Executor
//---------------------------------------------------------------
if ( invocation.getTarget() instanceof Executor ) {
try {
return invocation.proceed();
} finally {
ShardingHolder.remove();
}
}
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
// 判断是否设置分表条件,ThreadLocal中的变量在commit或者rollback的时候清除
final String[] actualTables = ShardingHolder.get();
if ( ArrayUtils.isEmpty( actualTables ) ) {
return invocation.proceed();
}
// 进行SQL解析
SqlParser sqlParser = parserFactory.createParser( boundSql.getSql() );
List<Table> tables = sqlParser.getTables();
if ( tables.isEmpty() ) {
return invocation.proceed();
}
// 如果设置的表名数量和实际不一致,抛出SQL异常
if ( tables.size() != actualTables.length ) {
throw new SQLException( "Table sharding exception, tables in sql not equals to actual settings" );
}
// 设置实际的表名
for ( int index = 0; index < tables.size(); index++ ) {
if ( StringUtils.isEmpty( actualTables[ index ] ) ) {
continue;
}
Table table = tables.get( index );
String targetName = table.getName() + separator + actualTables[ index ];
logger.info( "Sharding table, {}-->{}", table, targetName );
table.setName( targetName );
}
// 修改实际的SQL
String targetSQL = sqlParser.toSQL();
boundSqlField.set( boundSql, targetSQL );
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap( target, this );
}
@Override
public void setProperties(Properties properties) {
this.separator = properties.getProperty( "separator", DEFAULT_SEPARATOR );
}
}