项目概况

第11篇:Mybatis查询限制插件设计

2026-01-295 min read后端技术
Mybatis

一、实现目标

目标: 针对Mysql数据库实现动态修改sql的能力,增加上limit的查询限制。

二、知识扩展

首先下了解下有那些的分页技术。

2.1 物理分页

所谓物理分页是数据库直接提供了分页的预发, 如mysql的limit,oracle的rownum,好处是效率高;不好的地方就是不同数据库有不同的语法。

2.2 逻辑分页

逻辑分页利用游标分页,好处是所有数据库都统一,坏处就是效率低。

二、实现分析

首先我们先易后难,先说逻辑分页。

2.1 逻辑分页

首先我们看下Mybatis中当执行查询时候的代码,当返回是list时候。会走到executeForMany方法中。 该方法主要判断是否需要进行逻辑分页。代码不难,看就完了。

java
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,如下代码。

java
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 😂

java
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
java
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限制。

java
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然后,跟上面一样。
java
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 }

好了我们直接来验证下吧。

java
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