背景
当前项目需要在不同环境下部署,不同环境下有不同的数据库,有pg、oracle、mysql等,项目中的所有sql均为pg数据库相关的sql,由于oracle数据库比较特殊所以需要兼容相关的sql。批量插入的语句,pg和oracle有着较大的差别,不能同一条语句兼顾两种数据库:,所以需要查找方案来解决。
pg/mysql的批量插入sql的Mybatis写法【平常的mysql写法】:
INSERT INTO
table1
(
item1,item2
)
VALUES
<foreach collection="list" item="item" index="index" separator=",">
(
#{item.item1},#{item.item2}
)
</foreach>
oracle批量插入sql的Mybat写法【需要 select from dual 来编写, 使用 union 分割 】:
INSERT INTO
table1
(
item1,item2
)
<foreach collection="list" item="item" index="index" separator="union">
select
#{item.item1},#{item.item2}
from dual
</foreach>
方法1
网上教程:https://blog.csdn.net/qq_35981283/article/details/79503571
Mybatis标签中有个databaseId可以指定对应的数据库,可以兼容多种数据库,但是需要写两条sql来兼容,目前项目中有太多太多的批量插入语句了,手动复制粘贴的话然后再加入对应的语句的话,太耗人力了,说不定复制个三天三夜都复制不完,而且会出错,到时候哭天抢地就来不及了。所以重复性的工作为何不交给程序来解决呢。
方法2
暗自思考(菜鸡的想法就是这么单纯):
1.项目都是用的Mybatis来搭建的,而Mybatis能兼容oracle、mysql等数据库,这么强大的一个东西,总能有些扩展或者插件啥的吧
2.假设能扩展吧,是不是能够拦截到对应的sql然后修改对应的sql然后再让Mybatis按照我修改之后的sql来执行了呢?赶紧百度起来吧。
emmm,百度之后,果然有这个东西,Mybatis提供了一个Interceptor的东西让使用者进行一系列的操作,类似于Spring的Aop,哦豁,赶紧动起来!
这时得回想起我们的目的啦,我们的目的是把批量的插入的语句在oracle的数据库下转为对应oracle的插入语句,我们不妨设为以下的步骤
1.判断是不是oracle环境,不是的话自然而然啥也不用操作啦
2.获取到对应的sql,判断是不是插入的sql,不是插入sql同样啥也不同操作
3.进行一系列的操作,获取到对应的oracle批量插入的sql然后给Mybatis执行
4.执行后续的操作
如果需要直接看代码的话,直接点左边的目录栏即可
话不多说,先上主体代码
SqlInterceptor【增加了各个语句的兼容】
@Component
@Slf4j
@Order(1)
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class SqlConvertInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
//当前业务,兼容pg 和 oracle,需要兼容oracle的批量插入语句
// 如果当前的数据库不是oracle,则直接放行
// 【如果后面有其他数据库兼容,则加入到对应的枚举类中】
SqlConvertEnum convertorEnum = SqlConvertEnum.findConvertorByType(DbUtil.getDatabaseType());
if (ObjectUtils.isEmpty(convertorEnum)) {
log.info("----当前是{}数据库,跳过sql转化----", DbUtil.getDatabaseType());
return invocation.proceed();
}
//获取 StatementHandler 和 MappedStatement
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = MetaObject
.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
new DefaultReflectorFactory());
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// 如果已经兼容了对应的数据库也不用转化
// 如果不是插入语句则不需要转化
// 【如果后续需要进行其他转化,再创建自己的sql类型枚举和接口方法,这里只有批量插入语句需要转化】
if (convertorEnum.getDataBaseId().equals(mappedStatement.getDatabaseId())) {
return invocation.proceed();
}
//获取BoundSql (Mybatis转化过后的Sql在这里)
BoundSql boundSql = statementHandler.getBoundSql();
//设置兼容后的插入语句【使用反射进行设置值】
Field declaredField = boundSql.getClass().getDeclaredField("sql");
declaredField.setAccessible(true);
declaredField.set(boundSql, getConvertSql(boundSql.getSql(), convertorEnum.getConverterClass(), mappedStatement.getSqlCommandType()));
return invocation.proceed();
}
/**
* 获取转化sql
* @param sql
* @param covertCLass
* @param sqlCommandType
* @return
*/
private String getConvertSql(String sql, Class<? extends SqlConverter> covertCLass, SqlCommandType sqlCommandType) {
SqlConverter sqlConverter = SpringUtil.getBean(covertCLass);
switch (sqlCommandType) {
case INSERT :
return sqlConverter.convertInsert(sql);
case DELETE:
return sqlConverter.convertDelete(sql);
case UPDATE:
return sqlConverter.convertUpdate(sql);
case SELECT:
return sqlConverter.convertSelect(sql);
default:
return sql;
}
}
}
Intercepts注解
既然类似于Aop,那总得有切点切面吧,所以在网上的文章里看到这个注解,指定了对应的Class,方法名,方法的入参,对反射熟悉的同学应该就知道这个就是方法的签名,而注解的命名也很形象地命名为了签名,当前这个拦截器的先拦截的是RoutingStatementHandler。
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
这个拦截器注解能按顺序拦到的是 newExecutor ->
StatementHandler ->
ParameterHandler ->
ResultSetHandler ->
StatementHandler,这里就不展开讲了,想了解可以阅读文章:https://www.cnblogs.com/FraserYu/p/11044062.html
1.判断是不是oracle数据库
判断是否是oracle数据,使用项目中的一个工具类判断是不是oracle,
如果是单数据源的话也可以用jdbc的url前缀来判断是否是oracle数据库
SqlInterceptorEnum 这个枚举是用于日后的扩展使用的,里面添加不同的数据库类型,便于不同数据库的兼容
// --1
// 如果当前的数据库不是oracle,则直接放行
// 【如果后面有其他数据库兼容,则加入到对应的枚举类中】
SqlConvertEnum convertorEnum = SqlConvertEnum.findConvertorByType(DbUtil.getDatabaseType());
if (ObjectUtils.isEmpty(convertorEnum)) {
log.info("----当前是{}数据库,跳过sql转化----", DbUtil.getDatabaseType());
return invocation.proceed();
}
SqlConvertEnum
【如果是自行实现数据库类型的话,可以把databaseType
设为jdbc的前缀】
public enum SqlConvertEnum {
/**
* Oracle 数据库
*/
ORACLE(DatabaseType.ORACLE, OracleSqlConverter.class);
/**
* 数据库类型
*/
private DatabaseType databaseType;
/**
* sql转换class
*/
private Class<? extends SqlConverter> converterClass;
/**
* databaseId 用于判断sql的执行数据库
*/
private String databaseId;
SqlConvertEnum(DatabaseType dataBaseType, Class<? extends SqlConverter> converterClass) {
this.databaseType = dataBaseType;
this.converterClass = converterClass;
}
public Class<? extends SqlConverter> getConverterClass() {
return this.converterClass;
}
/**
* 根据对应的数据库类型获取对应的枚举
* @param databaseType 数据库类型
* @return 转化枚举
*/
public static SqlConvertEnum findConvertorByType(DatabaseType databaseType) {
for (SqlConvertEnum sqlConvertEnum : SqlConvertEnum.values()) {
if (sqlConvertEnum.databaseType.equals(databaseType)) {
return sqlConvertEnum;
}
}
return null;
}
}
2.获取到对应的sql
判断是不是插入的sql,不是插入sql同样啥也不同操作
MappedStatement 里面有对应的sqlCommandType
可以用来判断是什么类型的SQL,SqlCommandType这个枚举类是Mybatis自带的
// --2
//获取 StatementHandler 和 MappedStatement
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = MetaObject
.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
new DefaultReflectorFactory());
//先拦截到RoutingStatementHandler,里面有个StatementHandler类型的delegate变量,其实现类是BaseStatementHandler,然后就到BaseStatementHandler的成员变量mappedStatement
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// 如果不是插入语句则不需要转化
// 【如果后续需要进行其他转化,再创建自己的sql类型枚举和接口方法,这里只有批量插入语句需要转化】
if (!SqlCommandType.INSERT.equals(mappedStatement.getSqlCommandType())) {
return invocation.proceed();
}
3.获取对应oracle的sql并交给Mybatis执行【逻辑编辑一下即可】
//--3
//获取BoundSql (Mybatis转化过后的Sql在这里)
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
//设置兼容后的插入语句【使用反射进行设置值】
Field declaredField = boundSql.getClass().getDeclaredField("sql");
declaredField.setAccessible(true);
SqlConverter convertor = SpringUtil.getBean(convertorEnum.getConverterClass());
declaredField.set(boundSql, convertor.convertInsert(sql));
由于后面关于转化太多内容啦,这边先讲一下反射设置值的问题,因为sql这个属性是私有的而且并没有对外开放set方法,所以只能将其设为公开的declaredField.setAccessible(true);
,然后再进行赋值。
这个转化是这次的重头戏啦,所以,不妨让我来分析下整个的处理过程,以及我写这段代码的一些想法。
起初想着,这个拦截拦截到的sql是实实在在Mybatis已经转化好的完整SQL,还想着怎么防止sql注入的问题,怎么拿到括号中的value值,要是括号中的value值有多个括号,或者多个单引号岂不是GG,这样越写越大的话岂不是就成了数据库的sql解析器了。
自己想总会出错,然后百度了一下BoundSql
中拿到的是啥,发现里面是Mybatis用问号代替变量值的sql,用于Mybatis自己后续处理的,这岂不是美滋滋,基本上只要处理括号和问号的问题就好了呀,然后防止程序员自己写入的带括号的字符串常量就可,真的天助我也,不用处理sql注入的问题就已经很好了。
接下来的代码可能会有点长【已经只截方法了】,忍一下,看看呗,也不难。
这段代码主要是用来拼接sql的,基本上并不难,重点在于CommonSqlUtil.getValues(sql);
方法,拼接sql的解析请看下面分解
每日一个小技巧,如果在编码中出现大量的字符串拼接,就不要用+号了,直接使用StringBuilder吧,因为每次+号都会生成一个StringBuilder再append,到不如直接使用StringBuilder来得容易
/**
* ORACLE 批量插入需要的关键字拼接
*/
private static final String SELECT_STR = "SELECT ";
private static final String INSERT_END_STR = " FROM DUAL ";
private static final String UNION_STR = "UNION ";
//转化insert语句
public String convertInsert(String sql) {
//用oracle中的批量语句代替
//查找values的位置,将后面全部括号里的东西取出,然后再用对应的数据进行封装
//获取前面的sql,这段sql与Oracle的相同
String prefix = CommonSqlUtil.getInsertPrefix(sql);
//获取insert语句中要插入的值的列表
List<String> valueList = CommonSqlUtil.getValues(sql);
//如果只有一条值的,则返回原sql,不用拼接
if (valueList.size() == 1) {
return sql;
}
//拼接sql
StringBuilder sqlBuilder = new StringBuilder().append(prefix);
boolean start = true;
for (String value : valueList) {
if (!start) {
sqlBuilder.append(UNION_STR);
}
else {
start = false;
}
sqlBuilder.append(SELECT_STR).append(value).append(INSERT_END_STR);
}
return sqlBuilder.toString();
}
接下来贴上CommonSqlUtil
的代码,首先来分析一下,【自己也可以先把代码看完了在来看分析】
分析:
我们再把两种sql拿下来对比(Mybatis处理后的sql)
pg / mysql:
INSERT INTO
table1
(
item1,item2,item3
)
VALUES
(
?,?,'00A'
)
(
?,?,'00A'
)
oracle
INSERT INTO
table1
(
item1,item2,item3
)
select
?,?,'00A'
from dual
union
select
?,?,'00A'
from dual
根据一轮的分析后,发现,只要拿到pg的sql里的values中的值【也就是 ?,?,'00A'
】,然后再用select from dual拼接起来,然后再用union分隔即可,所以上面拼接的代码就是把 select , , from dual, union 按Oracle的语法拼接起来 。
所以重点来了,怎么取到对应的values中值呢 ?
遇到这种规律sql的字符串,第一时间想到正则表达式,找了一圈,貌似没有什么结果,都说这个正则表达式很难写,要兼容很多种情况,所以,放弃了这种想法。
随后,只能自己写个算法来解决这个问题啦。
首先呢,得先排除table那边的括号,我们需要的是values关键字后面的括号,,所以只需要匹配第二个左括号即可
整个语句中,其实就只是问号,括号,逗号,常量值。问号,和常量值可以归为一类,是我们需要拿到的值,那么剩下的就是括号,用作分隔用的逗号是没用的。
所以就想到了用括号匹配的算法来实现【leetcode上面有这道题】,用一个栈来保存左括号【如果字符为左括号时则进栈】,当遇到右括号的时候将左括号弹出匹配,当栈为空时就完成了一个完整括号的匹配,则可记录一条关于value的String放入到对应的List中。
当然,根据sql的正确性,这个算法是相对简单的,但是目前这个算法还没有兼容 类似这种情况的常量 '(‘
,就是常量中包含左括号,右括号的情况,所以如果需要兼容这种情况的话,需要程序员自己添加变量来添加,即用Mybatis中的?
来代替常量。
CommonSqlUtil
public final class CommonSqlUtil {
private CommonSqlUtil() {
}
/**
* 获取insert语句前缀,只获取到insert到最后一个括号
* @param sql 当前sql
* @return 获取insert语句前缀
*/
public static String getInsertPrefix(String sql) {
return sql.substring(0, sql.indexOf(")") + 1);
}
/**
* 使用栈实现获取value中括号的值【当前用于oracle】
* 【目前只处理了Mybatis中处理好sql中的问号以及字符串常量中没有括号的情况(如果需要有括号,自行用变量代替)】
* 例子:(?,?,?,'00A') -> ?,?,?,'00A'
*
* @param sql 整条insert语句
* @return value中括号的值
*/
public static List<String> getValues(String sql) {
//从第二个括号开始取值
String subSql = sql.substring(sql.indexOf(")"));
String valueSql = subSql.substring(subSql.indexOf("("));
//获取value关键字后面括号中的值组成一个Stirng的list
List<String> values = new ArrayList<>();
Stack<Character> brackets = new Stack<>();
StringBuilder splitValue = new StringBuilder();
for (Character c : valueSql.toCharArray()) {
//左括号进栈
if ('(' == c) {
brackets.push(c);
}
else if (')' == c) {
//右括号则将左括号出栈,清空builder
brackets.pop();
values.add(splitValue.toString());
splitValue.delete(0, splitValue.length());
}
else if (!brackets.empty()) {
//只有进入括号中才将值放入,排除括号外的逗号
splitValue.append(c);
}
}
return values;
}
}
4.执行后续的操作
这一步就是让Mybatis继续走自己调用的责任链啦,到这里就处理完成了。
// --4
return invocation.proceed();
相关设计
为了符合设计模式的开闭模式,这里使用了工厂模式以及单例模式【配合Spring的Bean工厂使用】,这边使用SqlConvertEnum
来管理对应的数据库类型以及转化服务的Class,以下展示对应的抽象接口类,以及对应的实现类【完整代码】。
SqlConverter
//Sql转化抽象类,提供了对应的抽象转化接口,可以转化select,update、insert、delete语句
public interface SqlConverter {
/**
* 转化select语句
* @param sql 需要转化的sql
* @return 转化后的sql
*/
String convertSelect(String sql);
/**
* 转化update语句
* @param sql 需要转化的sql
* @return 转化后的sql
*/
String convertUpdate(String sql);
/**
* 转化delete语句
* @param sql 需要转化的sql
* @return 转化后的sql
*/
String convertDelete(String sql);
/**
* 转化insert语句
* @param sql 需要转化的sql
* @return 转化后的sql
*/
String convertInsert(String sql);
}
OracleSqlConverter
//转化为oracle相关sql的实现类
@Service
public class OracleSqlConverter implements SqlConverter {
/**
* ORACLE 批量插入需要的关键字拼接
*/
private static final String SELECT_STR = "SELECT ";
private static final String INSERT_END_STR = " FROM DUAL ";
private static final String UNION_STR = "UNION ";
@Override
public String convertSelect(String sql) {
return sql;
}
@Override
public String convertUpdate(String sql) {
return sql;
}
@Override
public String convertDelete(String sql) {
return sql;
}
@Override
public String convertInsert(String sql) {
//用oracle中的批量语句代替
//查找values的位置,将后面全部括号里的东西取出,然后再用对应的数据进行封装
//获取前面的sql,这段sql与Oracle的相同
String prefix = CommonSqlUtil.getInsertPrefix(sql);
//获取insert语句中要插入的值的列表
List<String> valueList = CommonSqlUtil.getValues(sql);
//如果只有一条值的,则返回原sql,不用拼接
if (valueList.size() == 1) {
return sql;
}
//拼接sql
StringBuilder sqlBuilder = new StringBuilder().append(prefix);
boolean start = true;
for (String value : valueList) {
if (!start) {
sqlBuilder.append(UNION_STR);
}
else {
start = false;
}
sqlBuilder.append(SELECT_STR).append(value).append(INSERT_END_STR);
}
return sqlBuilder.toString();
}
}
设计过程以及代码整理过程
其实好的代码都是一步步改过来的,就像写作文一般,需要不断地修改以及改进才能出现一篇好的作文。
刚开始的时候,我是将全部代码写在一个类里面,用main函数一步步调试转化函数,然后再连接上oracle数据库进行调试。
其实写完也就200行左右的代码,也对不同操作进行了函数分割【以下为没有调整过的一个拦截器,代码其实功能都符合业务需求,觉得没有必要看的可以不看那段代码,因为实在是烂代码,哈哈哈】。但是看着总觉得不对劲,总感觉这个类承担了他不应该承担的事,又进行sql的分析,又进行sql的转化,他的功能应该就是简简单单的四步才对,怎么干了那么多事呢?而且要是日后又要加select、update、delete的转化怎么办?万一又有别的数据库需要转化怎么办,那这代码岂不是越来越冗长,加的功能越来越多?想想,还是别留那么大的坑给后面的人吧,自己辛苦点做点可扩展啥的,也算是一种锻炼,所以做了以下的分析:
1.原本sql的分析,可以抽出来一个工具类,因为有可能其他的服务也需要
2.不同数据库的不同语句的转化,可以抽象成一个接口类,可以有不同数据库不同语句的实现,拦截器调用时也能抽象地调用接口即可,不用关注实现。
3.一些常量的设置,当前只是判断了oracle的数据库,而且是用jdbc的前缀来判断的,可以使用枚举类来做到扩展数据库类型时不必修改拦截器代码,同时也可以映射对应实现类,让一个枚举该做到的都做了,可以减少代码的增加以及修改。
4.可以使用源码中有的常量,比如sql的类型在mybatis中肯定会有判断,所以,可以使用SqlCommandType
来判断语句的类型
5.寻找Value关键字部分可以使用一个List 来管理需要查找的值,循环查找即可,这样也可以避免一直if-else下去。
总结:工厂模式+单例模式+反射 可以解决大部分的可扩展以及多态问题,可以很好地让代码符合开闭原则,用起来!
OracleSqlInterceptor【原始未修改整理过的代码】
@Component
@Slf4j
@Order(1)
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class OracleSqlInterceptor implements Interceptor {
@Value("${jdbc.url}")
private String jdbcType;
@Override
public Object intercept(Invocation invocation) throws Throwable {
//当前业务,兼容pg 和 oracle,需要兼容oracle的批量插入语句
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = MetaObject
.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
new DefaultReflectorFactory());
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
//如果当前的数据库不是oracle,则直接放行
if (!jdbcType.startsWith("jdbc:oracle:")) {
log.info("----当前不是oracle数据库,跳过转化----");
return invocation.proceed();
}
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
//如果不是插入语句则放行【仅支持全大写或全小写的情况】
if (!sql.startsWith("insert") || !sql.startsWith("INSERT")) {
return invocation.proceed();
}
//开始兼容批量插入语句,并设置boundsql
Field declaredField = boundSql.getClass().getDeclaredField("sql");
declaredField.setAccessible(true);
declaredField.set(boundSql, convertOracleInsertSql(sql));
log.info("---转换后的sql为:{}", boundSql.getSql());
return invocation.proceed();
}
/**
* Oracle Insert语句转化
*
* @param sql 传入的pg的sql
* @return 转化后的sql
*/
public String convertOracleInsertSql(String sql) {
//用oracle中的批量语句代替
//查找values的位置,将后面全部括号里的东西取出,然后再用对应的数据进行封装
//获取前面的sql,这段sql与Oracle的相同
String prefix = sql.substring(0, getKeywordValueIndex(sql));
//排除table中的括号,取后面的括号
String subSql = sql.substring(getKeywordValueIndex(sql));
String valueSql = subSql.substring(subSql.indexOf("("));
List<String> valueList = getValues(valueSql);
//拼接sql
StringBuilder sqlBuilder = new StringBuilder().append(prefix);
String selectValue = "SELECT ";
String endValue = " FROM DUAL ";
String unionValue = "UNION ";
boolean start = true;
for (String value : valueList) {
if (!start) {
sqlBuilder.append(unionValue);
}
else {
start = false;
}
sqlBuilder.append(selectValue).append(value).append(endValue);
}
return sqlBuilder.toString();
}
// public static void main(String[] args) {
// String sql = "insert into table(id, name, age) values(?,?,?),(?,?,?),(?,?,?)";
// System.out.println(convertOracleInsertSql(sql));
// }
/**
* 使用栈实现获取value中括号的值
*
* @param sql
* @return
*/
public List<String> getValues(String sql) {
List<String> values = new ArrayList<>();
Stack<Character> brackets = new Stack<>();
StringBuilder splitValue = new StringBuilder();
for (Character c : sql.toCharArray()) {
if ('(' == c) {
//左括号进栈
brackets.push(c);
}
else if (')' == c) {
//右括号则将左括号出栈,清空builder
brackets.pop();
values.add(splitValue.toString());
splitValue.delete(0, splitValue.length());
}
else if (!brackets.empty()) {
//只有进入括号中才将值放入,排除括号外的逗号
splitValue.append(c);
}
}
return values;
}
/**
* 查找关键字value的位置
* @param sql
* @return
*/
public int getKeywordValueIndex(String sql) {
//先找values,再找value
if (sql.indexOf("values") != -1) {
return sql.indexOf("values");
}
else if (sql.indexOf("VALUES") != -1) {
return sql.indexOf("VALUES");
}
else if (sql.indexOf("value") != -1) {
return sql.indexOf("value");
}
else {
return sql.indexOf("VALUE");
}
}
}
相关深入阅读以及参考
【Mybatis拦截器执行过程】:https://www.cnblogs.com/FraserYu/p/11044062.html
【Mybatis拦截器修改语句】:https://blog.csdn.net/qq_22200097/article/details/82942908
【Oracle批量插入】:https://www.cnblogs.com/hjm0928/p/10254894.html
【设计模式】:http://c.biancheng.net/design_pattern/
摘录
后记 ---【Mybatis拦截器修改语句】
当我们需要改变sql的时候,显然我们要在预编译SQL(prepare方法前加入修改的逻辑)。
当我们需要修改参数的时候我们可以在调用parameterize方法前修改逻辑。或者使用ParameterHandler来改造设置参数。
我们需要控制组装结果集的时候,也可以在query方法前后加入逻辑,或者使用ResultHandler来改造组装结果。
分页插件可以拦截Executor的方法进行。
最后分享高中化学老师教的一句话:读书是先将书读薄,然后再将书读厚。也就是先取其精华,再将精华扩展成不同的东西,说着有点像java的抽象编程喔。