第11篇:Mybatis查询限制插件设计
一、实现目标
目标: 针对Mysql数据库实现动态修改sql的能力,增加上limit的查询限制。
二、知识扩展
首先下了解下有那些的分页技术。
2.1 物理分页
所谓物理分页是数据库直接提供了分页的预发, 如mysql的limit,oracle的rownum,好处是效率高;不好的地方就是不同数据库有不同的语法。
2.2 逻辑分页
逻辑分页利用游标分页,好处是所有数据库都统一,坏处就是效率低。
二、实现分析
首先我们先易后难,先说逻辑分页。
2.1 逻辑分页
首先我们看下Mybatis中当执行查询时候的代码,当返回是list时候。会走到executeForMany方法中。 该方法主要判断是否需要进行逻辑分页。代码不难,看就完了。
1public class MapperMethod {
2 public Object execute(SqlSession sqlSession, Object[] args) {
3 ....
4 case SELECT:
5 if (method.returnsVoid() && method.hasResultHandler()) {
6 executeWithResultHandler(sqlSession, args);
7 result = null;
8 } else if (method.returnsMany()) {
9 result = executeForMany(sqlSession, args);
10 }
11 ...
12 }
13
14 private <E> Object executeForMany(SqlSession sqlSession, Object[] args) {
15 List<E> result;
16 // 参数解析
17 Object param = method.convertArgsToSqlCommandParam(args);
18 // 判断是否逻辑分页了。
19 if (method.hasRowBounds()) {
20 RowBounds rowBounds = method.extractRowBounds(args);
21 result = sqlSession.selectList(command.getName(), param, rowBounds);
22 } else {
23 result = sqlSession.selectList(command.getName(), param);
24 }
25 ....
26 }
27}hasRowBounds 可以判断当前的方法是否要走逻辑分页。 MethodSignature#hasRowBounds的逻辑也比较简单,就是判断方法入参中是否包含了RowBounds,如下代码。
1public class MethodSignature{
2 public MethodSignature(Configuration configuration, Class<?> mapperInterface, Method method) {
3 this.rowBoundsIndex = getUniqueParamIndex(method, RowBounds.class);
4 this.resultHandlerIndex = getUniqueParamIndex(method, ResultHandler.class);
5 this.paramNameResolver = new ParamNameResolver(configuration, method);
6 }
7 private Integer getUniqueParamIndex(Method method, Class<?> paramType) {
8 Integer index = null;
9 final Class<?>[] argTypes = method.getParameterTypes();
10 for (int i = 0; i < argTypes.length; i++) {
11 if (paramType.isAssignableFrom(argTypes[i])) {
12 if (index == null) {
13 index = i;
14 } else {
15 throw new BindingException(method.getName() + " cannot have multiple " + paramType.getSimpleName() + " parameters");
16 }
17 }
18 }
19 return index;
20 }
21 public boolean hasRowBounds() {
22 return rowBoundsIndex != null;
23 }
24}如果方法入参中有RowBounds则会逻辑分页,如果没有指定则使用默认RowBounds即不限制数量。说不限制其实也限制了, 就是Integer.MAX_VALUE 😂
1public class DefaultSqlSession implements SqlSession {
2 @Override
3 public <E> List<E> selectList(String statement, Object parameter) {
4 return this.selectList(statement, parameter, RowBounds.DEFAULT);
5 }
6}
7
8public class RowBounds {
9
10 public static final int NO_ROW_OFFSET = 0;
11 public static final int NO_ROW_LIMIT = Integer.MAX_VALUE;
12 public static final RowBounds DEFAULT = new RowBounds();
13
14 private final int offset;
15 private final int limit;
16
17 public RowBounds() {
18 this.offset = NO_ROW_OFFSET;
19 this.limit = NO_ROW_LIMIT;
20 }
21}那么逻辑分页的处理游标的地方在哪里呢? 因为前面我们已经对Mybatis的所有执行流程分析过了,所以这个时候我们应该有自己的思考了。 应该是在jdbc执行后 处理返回数据的时候,那么应该就是在DefaultResultSetHandler中。直接看源码吧。
- line(4-16) 用于处理偏移量, 如从第四页开始,则执行next跳过前三行。
- line(17-19) 处理限制数量,如最大查询5行,如果返回值中大于5就返回false就不在添加数据。
- line(25) 填过偏移量
- line(26) 判断limit
1
2public class DefaultResultSetHandler implements ResultSetHandler {
3
4 private void skipRows(ResultSet rs, RowBounds rowBounds) throws SQLException {
5 if (rs.getType() != ResultSet.TYPE_FORWARD_ONLY) {
6 if (rowBounds.getOffset() != RowBounds.NO_ROW_OFFSET) {
7 rs.absolute(rowBounds.getOffset());
8 }
9 } else {
10 for (int i = 0; i < rowBounds.getOffset(); i++) {
11 if (!rs.next()) {
12 break;
13 }
14 }
15 }
16 }
17 private boolean shouldProcessMoreRows(ResultContext<?> context, RowBounds rowBounds) {
18 return !context.isStopped() && context.getResultCount() < rowBounds.getLimit();
19 }
20
21 private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping)
22 throws SQLException {
23 DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
24 ResultSet resultSet = rsw.getResultSet();
25 skipRows(resultSet, rowBounds);
26 while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) {
27 ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null);
28 Object rowValue = getRowValue(rsw, discriminatedResultMap, null);
29 storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
30 }
31 }
32}好了,知道了这些我们就开始分析我们要如何使用插件了吧。对就是拦截ResultSetHandler,利用反射的方法,将默认的 RowBounds添加limit限制。
1 /**
2 * 那我们就拦截处理结果.
3 * 启用反射修改默认的RowBounds limit属性
4 */
5 @Intercepts(@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}))
6 public static class DefaultRowBoundsHandler implements Interceptor {
7
8 @Override
9 public Object intercept(Invocation invocation) throws Throwable {
10 Object target = invocation.getTarget();
11 Field rowBounds = target.getClass().getDeclaredField("rowBounds");
12 rowBounds.setAccessible(true);
13 RowBounds originRowBounds = (RowBounds) rowBounds.get(target);
14 // 如果是默认的则替换下
15 if (originRowBounds.equals(RowBounds.DEFAULT)) {
16 MetaObject metaObject = MetaObject.forObject(originRowBounds, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(), new DefaultReflectorFactory());
17 metaObject.setValue("limit", 2);
18 }
19 return invocation.proceed();
20 }
21 }
22 @Test
23 public void limitAddRowBounds(){
24 // 读取配置信息(为什么路径前不用加/,因为是相对路径。maven编译后的资源文件和class文件都是在一个包下,所以不用加/就是当前包目录)
25 InputStream mapperInputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream("example01/mybatisConfig.xml");
26 // 生成SqlSession工厂,SqlSession从名字上看就是,跟数据库交互的会话信息,负责将sql提交到数据库进行执行
27 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(mapperInputStream, "development");
28 // 获取Mybatis配置信息
29 Configuration configuration = sqlSessionFactory.getConfiguration();
30 // 添加上我们的拦截器
31 configuration.addInterceptor(new DefaultRowBoundsHandler());
32 // 参数: autoCommit,从名字上看就是是否自动提交事务
33 SqlSession sqlSession = sqlSessionFactory.openSession(false);
34 // 获取Mapper
35 TUserMapper mapper = configuration.getMapperRegistry().getMapper(TUserMapper.class, sqlSession);
36 // 如果自己加了RowBounds,则不自动加limit
37 RowBounds rowBounds = new RowBounds(0, 3);
38 List<TUser> users1 = mapper.selectRowBounds(rowBounds);
39 System.out.println(users1.size());
40 // 如果不加显示,默认limit = 2
41 List<TUser> users = mapper.selectAll();
42 System.out.println(users.size());
43 }好了,到这里逻辑分页已经搞定了。注意奥,这里只拦截了 ResultSetHandler#handleResultSets 其他两个没有拦截。
注意奥这里只是一个思路,其实解决还有几种方法,我们要学会举一反三,比如我们也可以拦截 Executor#query 直接修改入参中的RowBounds参数。
2.2 物理分页
物理分页就是给sql添加上参数。那么sql信息都在哪里呢? 就在下图中。

那么我们如何能修改参数呢? 当然就是从下面两个类中利用反射来给sql增加上limit了。那么我们在哪里拦截呢?
首先确定拦截地方,首先上面两个类。RawSqlSource(占位符)、DynamicSqlSource(变量符)。都属于MappedStatement的内部属性,只要我们能 拿到MappedStatement就可以了。

其中Executor中就可以。那么我们开始操作吧。
- line(14) RawSqlSource 占位符是最好处理的,内部属性就是StaticSqlSource,而StaticSqlSource中的sql是现成的直接造就行了。
- line(26) DynamicSqlSource 变量符,稍微有点难搞,因为你不能直接拿到sql,所以我们只能去重写它。如下。
- line(48-63) 从DynamicContext拿到原生sql然后,跟上面一样。
1 /**
2 * 那我们就拦截处理结果.
3 * 启用反射修改默认的RowBounds limit属性
4 */
5 @Intercepts(@Signature(type = Executor.class, method = "query",
6 args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
7 public static class PhysicalHandler implements Interceptor {
8
9 @Override
10 public Object intercept(Invocation invocation) throws Throwable {
11 Object[] args = invocation.getArgs();
12 MappedStatement ms = (MappedStatement) args[0];
13 SqlSource sqlSource = ms.getSqlSource();
14 if (sqlSource instanceof RawSqlSource) {
15 MetaObject rawSqlSource = ms.getConfiguration().newMetaObject((RawSqlSource) sqlSource);
16 Object staticSqlSource = rawSqlSource.getValue("sqlSource");
17 MetaObject metaObject = ms.getConfiguration().newMetaObject(staticSqlSource);
18 String sql = (String) metaObject.getValue("sql");
19 if (sql.indexOf("limit") <= 0) {
20 String limitSql = sql + " limit 2";
21 System.out.println(limitSql);
22 metaObject.setValue("sql", limitSql);
23 }
24 }
25 // 如果是动态sql,则需要解析
26 if (sqlSource instanceof DynamicSqlSource) {
27 MetaObject metaObject = ms.getConfiguration().newMetaObject(ms);
28 LimitDynamicSqlSource limitDynamicSqlSource = new LimitDynamicSqlSource((DynamicSqlSource) sqlSource);
29 metaObject.setValue("sqlSource", limitDynamicSqlSource);
30 }
31 return invocation.proceed();
32 }
33 }
34
35 public static class LimitDynamicSqlSource implements SqlSource {
36
37 private final Configuration configuration;
38
39 private final SqlNode rootSqlNode;
40
41 public LimitDynamicSqlSource(DynamicSqlSource dynamicSqlSource) {
42 MetaObject metaObject = MetaObject.forObject(dynamicSqlSource, new DefaultObjectFactory(), new DefaultObjectWrapperFactory(), new DefaultReflectorFactory());
43 this.configuration = (Configuration) metaObject.getValue("configuration");
44 this.rootSqlNode = (SqlNode) metaObject.getValue("rootSqlNode");
45 }
46
47 @Override
48 public BoundSql getBoundSql(Object parameterObject) {
49 DynamicContext context = new DynamicContext(configuration, parameterObject);
50 rootSqlNode.apply(context);
51 SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
52 Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
53 String sql = context.getSql();
54 String limitSql = sql;
55 // 给原生sql增加limit
56 if (sql.indexOf("limit") <= 0) {
57 limitSql = sql + " limit 2";
58 System.out.println(limitSql);
59 }
60 SqlSource sqlSource = sqlSourceParser.parse(limitSql, parameterType, context.getBindings());
61 BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
62 context.getBindings().forEach(boundSql::setAdditionalParameter);
63 return boundSql;
64 }
65 }好了我们直接来验证下吧。
1 /**
2 * 物理分页
3 * 就是拼装sql
4 */
5 @Test
6 public void physicalLimit() {
7 // 读取配置信息(为什么路径前不用加/,因为是相对路径。maven编译后的资源文件和class文件都是在一个包下,所以不用加/就是当前包目录)
8 InputStream mapperInputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream("example01/mybatisConfig.xml");
9 // 生成SqlSession工厂,SqlSession从名字上看就是,跟数据库交互的会话信息,负责将sql提交到数据库进行执行
10 SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(mapperInputStream, "development");
11 // 获取Mybatis配置信息
12 Configuration configuration = sqlSessionFactory.getConfiguration();
13 // 添加上我们的拦截器
14 configuration.addInterceptor(new PhysicalHandler());
15 // 参数: autoCommit,从名字上看就是是否自动提交事务
16 SqlSession sqlSession = sqlSessionFactory.openSession(false);
17 // 获取Mapper
18 TUserMapper mapper = configuration.getMapperRegistry().getMapper(TUserMapper.class, sqlSession);
19 List<TUser> users = mapper.selectAll();
20 System.out.println(users.size());
21 }好了,到这里我们就实现了动态修改sql了。重要的是思路, 思路决定出路。要学会举一反三。本篇所有的代码示例都在
com.test.plugin.LimitPluginTest