乐趣区

关于后端:PageHelper这种情况下有坑

因为最近工作切实太忙,肝了一段时间后,终于有空来写点文章了。闲话少说,进入本期内容的正题。
省流版本:PagerHelper 在与 PostgreSQL 搭配应用时,会有坑!
你如果想晓得具体是什么样的一个坑,往下看,我来给大伙儿开展说说。

背景
最近在做一个我的项目,惯例的 SpringBoot+Mybatis 架构,数据库应用的是 PostgreSQL,并且有一些场景须要应用到分页查问,很天然的就应用了 PageHelper 这个分页工具,如果你是初学者或者还没有应用过 PageHelper,能够理解一下。PageHelper
到这里所有都很顺利,然而聪慧如你肯定猜到了,要有什么事件产生。
我须要实现一个数据权限的性能,依据每个用户的数据权限不同,管制每个用户只能查问到本人有权限的用户。
比方用户 A 只能拜访他所在组织的数据,用户 B 是一个高级用户,能查问到多个组织的数据,用户 C 是超级管理员,能查问到所有数据等。
实现这个性能我采纳的形式是在进行查问时,依照用户的权限不同,减少对应的查问条件。
比方业务办法中 SQL 是这样的:
select * from t_data where data_id = 1 limit 10 offset 0
复制代码
我须要批改成:
select * from t_data where data_id = 1 and data_part in (1,2,3) limit 10 offset 0
复制代码
这里须要强调一点,就是我不仅要对 t_data 这一张表减少条件,还要对其余很多张表加条件,并且可能每张表加的条件还不一样。
我采纳了 MyBatis 的拦截器性能,在拦截器中对立对 SQL 进行解决,增加数据权限条件。
原以为所有会和我的预期一样,没想到中途踩了一个坑,让我折腾了半早晨。
以上是踩坑的背景,接下来看一下代码。
代码实现
我在拦截器中是这样做的。
MyBatis 拦截器实现

/**

  • 数据权限拦截器
  • 拦挡所有 MyBatis 的查询方法
  • @author 小黑说
  • @version 1.0
    */

@Intercepts({

@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 DataPermissionInterceptor implements Interceptor {

private static final Logger log = LoggerFactory.getLogger(DataPermissionInterceptor.class);

@Override
public Object intercept(Invocation invocation) throws Throwable {
    // 这是一个提前解决好的以后用户数据权限的汇合,寄存在 ThreadLocal 中。List<Condition> conditions = DataConditionHelper.getConditions();

    // 如果 conditions 为空代表该用户没有数据权限的限度,能够查问所有数据。if (CollectionUtils.isEmpty(conditions)) {return invocation.proceed();
    }

    Object[] args = invocation.getArgs();
    MappedStatement ms = (MappedStatement) args[0];
    Object parameter = args[1];
    RowBounds rowBounds = (RowBounds) args[2];
    ResultHandler resultHandler = (ResultHandler) args[3];
    Executor executor = (Executor) invocation.getTarget();
    CacheKey cacheKey;
    BoundSql boundSql;
    if (args.length == 4) {boundSql = ms.getBoundSql(parameter);
        cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
    } else {cacheKey = (CacheKey) args[4];
        boundSql = (BoundSql) args[5];
    }

    // 对以后查问语句进行数据权限加强解决
    String newSql = handleSql(boundSql.getSql());

    // 从新 new 一个查问语句对象
    BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject());
    // 把新的查问 SQL 放到 statement 里
    MappedStatement newMs = newMappedStatement(ms, new BoundSqlSqlSource(newBoundSql));

    for (ParameterMapping mapping : boundSql.getParameterMappings()) {String prop = mapping.getProperty();
        if (boundSql.hasAdditionalParameter(prop)) {newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
        }
    }
    return executor.query(newMs, parameter, rowBounds, resultHandler, cacheKey, newBoundSql);
}

/**
* 加强 SQL
*/
private String handleSql(String origSql) {SQLExpr sqlExpr = SQLUtils.toSQLExpr(origSql, JdbcConstants.POSTGRESQL);
    DataPermissionSelectVisitor selectVisitor = new DataPermissionSelectVisitor();
    sqlExpr.accept(selectVisitor);
    return sqlExpr.toString();}

@Override
public Object plugin(Object target) {if (target instanceof Executor) {return Plugin.wrap(target, this);
    }
    return target;
}

}

复制代码
SQL 加强性能
在对 SQL 进行加强时,我须要将本来的 SQL 语句解析成形象语法树(AST),你能够了解为将 SQL 语句转换成一个 java 对象的树形构造,用 Java 对象示意 SQL 中的所有元素,如查问字段,WHERE,JOIN 等,而后我再对须要增加条件的表减少数据权限条件。
解析 SQL 语句曾经有很多成熟的工具,不必本人开发,我这里应用的阿里的 druid 包中的 SQLUtils。
重点介绍我减少条件的代码。
public class DataPermissionSelectVisitor extends PGASTVisitorAdapter {

private static final String TABLE_IGNORE_CHAR = "\"|'|`";

@Override
public boolean visit(SQLExprTableSource x) {
    // 数据权限条件
    List<Condition> dataConditions = DataConditionHelper.getConditions();
    if (CollectionUtils.isEmpty(dataConditions)) {return super.visit(x);
    }
    
    SQLName tableName = x.getName();
    String alias = x.getAlias();
    // 寄存能够增加的条件 sql 片段
    List<String> conditionSqls = new ArrayList<>();
    for (Condition condition : dataConditions) {String name = tableName.getSimpleName().replaceAll(TABLE_IGNORE_CHAR, "");
        if (name.equals(condition.getTableName())) {
            // 数据权限中的表名和以后 SQL 中的表名统一,则须要增加数据权限条件
            if (!StringUtils.isEmpty(alias)) {conditionSqls.add(alias.concat(".").concat(condition.buildCondition()));
            } else {conditionSqls.add(condition.buildCondition());
            }
        }
    }

    if (CollectionUtils.isEmpty(conditionSqls)) {return super.visit(x);
    }
    SQLObject parent = x.getParent();
    // 单表查问
    if (parent instanceof SQLSelectQueryBlock) {SQLSelectQueryBlock query = (SQLSelectQueryBlock) parent;
        // 创立条件对象
        SQLExpr expr = buildExpr(conditionSqls);
        // 将条件对象增加到 query 对象中
        query.addCondition(expr);
    }
    // 联表查问
    if (parent instanceof SQLJoinTableSource) {SQLJoinTableSource query = (SQLJoinTableSource) parent;
        SQLExpr expr = buildExpr(conditionSqls);
        query.addConditionn(expr);
    }
    return super.visit(x);
}

/**

* 构建条件对象
*/
private SQLExpr buildExpr(List<String> conditionSqls) {
    SQLExpr expr = null;
    for (String conditionSql : conditionSqls) {if (expr == null) {expr = SQLUtils.toSQLExpr(conditionSql, JdbcConstants.POSTGRESQL);
        } else {SQLExpr sqlExpr = SQLUtils.toSQLExpr(conditionSql, JdbcConstants.POSTGRESQL);
            expr = SQLUtils.buildCondition(SQLBinaryOperator.BooleanOr, expr, true, sqlExpr);
        }
    }
    return expr;
}

}
复制代码
只须要在拦截器中依照上面办法中应用即可。
private String handleSql(String origSql) {

// 将 SQL 解析成 SQLExpr 对象
SQLExpr sqlExpr = SQLUtils.toSQLExpr(origSql, JdbcConstants.POSTGRESQL);
// 应用 Visitor 对象的 accpet 办法,减少数据权限条件
DataPermissionSelectVisitor selectVisitor = new DataPermissionSelectVisitor();
sqlExpr.accept(selectVisitor);
return sqlExpr.toString();

}
复制代码
到这里,如同所有都很顺利,我测试了几个一些单表查问和联表查问都没有问题。
直到我测了一下一个带分页性能的查问,第一页没有问题,失常返回。

然而,从第二页开始,出问题了。

异样信息中我标注出了几个要害信息。

从这个异样信息根本能够猜到,应该是 SQL 语句中的参数占位符和传入的参数数量不匹配导致的。
个别遇到这种问题,我第一反馈是先面向网络编程一把。

很显然,网友曾经提前踩过相似的坑了。一圈找下来,根本能够必定和我猜的统一。
接下来就 debug 看看吧,初步思路是看看在应用分页查问时,减少数据权限前后的 SQL 语句的变动。
debug 走起
对 SQL 进行批改的办法:
private String handleSql(String origSql) {

// 减少条件之前
SQLExpr sqlExpr = SQLUtils.toSQLExpr(origSql, JdbcConstants.POSTGRESQL);
DataPermissionSelectVisitor selectVisitor = new DataPermissionSelectVisitor();
sqlExpr.accept(selectVisitor);
// 减少条件之后
return sqlExpr.toString();

}
复制代码
那咱们来 debug 一下看看,在减少数据权限条件之前,分页查问语句是上面这样的:

在减少数据权限之后,分页查问语句变更了上面这样:

什么鬼啊,怎么就剩 limit 了?!offset 被吃了?!

沉着一下,这个分页语句有点不对劲。
这里须要阐明一下,postgreSQL 的分页语句写法和 MySQL 的有点区别,在 MySQL 中分页语句的写法是:
— MySQL 分页语法
SELECT * from tableName where 1=1 limit 10 offset 10;
SELECT * from tableName where 1=1 limit 0 , 10;
SELECT * from tableName where 1=1 limit 10;
复制代码
然而 postgreSQL 的分页语法是这样的:
SELECT * FROM t_privilege_role limit 10 offset 10;
SELECT * FROM t_privilege_role offset 10 limit 10;
复制代码
乍一看如同一样,然而认真一看是有区别的。
postgreSQL 的分页语句必须有 limit 和 offset,并且地位能够调换,官网规范是 limit ? offset ? 写法;
MySQL 的分页语句能够省略 offset,不省略的状况下必须是 limit 在前 offset 在后;
所以下面 debug 中的 SQL 语句的分页条件是 OFFSET ? LIMIT ?。
因为我应用的是 PageHelper 插件,所以这个条件是插件帮我主动增加下来的。
那回到 debug 中的景象,为什么 offset 条件没有了呢?是不是在 druid 的 SQLUtils 解析 SQL 的时候出问题了呢?持着狐疑态度我又 debug 一遍,发现在 SQLUtils 解析完 SQL 后,分页条件就曾经变了。

也就是说,SQLUtils 并没有解析出 offset ? limit ? 中的 offset ?,因为 SQLUtils 是依照官网规范的分页语法进行解析的。
PageHelper 的分页能改吗?
那 PageHelper 是怎么样来给查问语句加的分页条件呢?能不能让分页条件改成 limit ? offset ? 呢?
PageHelper 减少分页条件的原理,其实也是应用了 MyBatis 的拦截器性能,在拦截器中依据用户设置的分页参数,增加上分页条件。
因为不同的数据库的分页语句语法存在差别,所以须要指定 PageHelper 应用哪个数据库的方言。咱们个别会在配置文件中进行指定。
// pagehelper 方言,mysql,oracle,postgresql 等
pagehelper.helperDialect=postgresql
复制代码
这个方言参数,最终在代码中是有一个具体的实现类。

如上图,在 PageHelper 中默认对这些数据库进行了反对。咱们来看一下 postgreSQL 的实现形式。

能够看到,对 PostgreSqlDialect 是继承了 MySqlDialect,并且分页参数如果起始页不是 0 的话,应用的是 offset ? limit ?,咱们顺便看一下 MySqlDialect 是如何实现的。

能够看出在 MySqlDialect 中依照 MySQL 的语法做了实现,并且起始页不为 0 时采纳的是 limit ?,? 语法。
由此,我权且判断一下,应该是 pageHelper 的开发者在前期对 postgreSQL 进行反对,偷懒了一下,间接继承了 MySqlDialect,采纳了 postgreSQL 的另一种分页语法 offset ? limit ?。
为了证实我这个判断,我专门到 pageHelper 的 github 下来找看看有没有人提过相似的 issue,你别说,还真找到了。

有人在应用 mybatis-plus 时,也会因为这个分页语法的问题导致分页性能呈现问题。
并且这位 outian 敌人曾经提交了批改代码,在 v5.3.1 版本中解决了这个问题。
解决方案
如果你应用的 pageHelper 是 v5.3.1 版本以下,并且应用的数据库是 postgreSQL,那你就要小心了,快去检查一下是不是踩了这个坑。
那要怎么解决呢?有两个方法:

降级 PageHelper 版本到 v5.3.1+ 版本;
自定义 PostgreSqlDialect;

当然,这两种形式当初来说都很好做,降级版本我不确定会不会有什么新的问题,如果你比拟大胆,能够降级一下做好功能测试;
这里次要说第二种形式,其实和下面那位解决这个问题的敌人根本一样。
首先自定义一个方言类,继承 AbstractHelperDialect;
而后在类加载时,将这个方言类注册到 PageHelper 的方言中;
最初将这个方言配置到配置文件中 pagehelper.helperDialect=customerpostgresql。

官网的修复办法也是将 postgreSQLDialect 依照这种形式批改。
最初
最初简略总结下,应用 postgreSQL 数据库,在应用 PageHelper 做分页时,如果 PageHelper 的版本低于 5.3.1,那么在拦截器场景下会有问题,须要通过降级或者革新 postgreSQLDialect 的形式解决,如果你的我的项目中正在应用,能够提前排查,避免出现问题。
以上就是本期的全部内容,心愿对你有所帮忙。

退出移动版