时间过的真是快,一周又要结束了。那么,你比上周更博学了吗?先来一个简短的荒腔走板,给冰冷的技术文注入一丝色彩。
上面这张照片是我上周六的傍晚写完文章后出门跑步的时候拍的。
当时我路过这个地方看到这个画面的时候一眼就被吸引住了,直接停下了奔跑的脚步。
这是一个还没有开发完成的草坪。远处的高楼,就是成都的软件园区。软件园和草坪之间只有一条河间隔。河的对岸是工作,河的这边是生活。
我拍这个照片的时候只是觉得和谐,随手一拍。但是现在再看,不知道为什么感觉到的却是深邃的孤独。
一个玩手机的阿姨,一只孤独的狗。热闹是他们的,我什么都没有。
你用过 Mybatis 逆向工程(mybatis-generator-maven-plugin)生成相关文件吗?
可以看到逆向工程帮我们生成了实体类、Mapper 接口和 Mapper.xml。
用起来真的很方便,我用了好几年了,但是前段时间翻车了。
先说一下需求吧。就是在做一次借据数据迁移的过程中,要先通过 A 服务的接口拿到所有的借据和对应的还款计划数据,然后再对这些借据进行核查,如果不满足某些添加,就需要从表中删除借据和对应的还款计划。
借据和对应的还款计划存放在两张表中,用借据号来关联。
而上线之后,我在一片欢声笑语中把还款计划表清空了,而这个必定出现的问题,在测试阶段同学还没有测试出来。
是怎么回事呢,为了模拟这个场景,我在本地创建了两张表,订单表(orderInfo)和订单扩展表(orderInfoExt),他们之间用订单号进行关联:
我们假设现在表里面的这条订单号为 2020060666666 的数据经过判断是错误数据,我当时写的代码体现在单元测试里面是这样的:
第 42 行用的 example 对象还是 OrderInfo 的 example。而真正的 OrderInfoExt 对象的 exampleExt 对象没有进行任何赋值的操作。
我只需要打一个 ex 然后回个车.... example 就出现在代码里面了。
而这种没有参数的 example 传进去,在 mapper.xml 里面是这样处理的:
看到 delete from order_info_ext 语句。你说你慌不慌?
当然在线上的服务器肯定是看不到执行的 SQL 的,但是当报警短信一条一条接着来的时候,当连上数据库一看表,发现数据没了的时候。
反正我一刷新后发现表里没数据了,一股凉意从脚板心直冲天灵盖。这种时候都还是要小小的心慌一下,先大喊一声“卧槽!数据怎么没了?”
数据恢复过程就不说了,聊一下这事发生后我的一点思考吧。
哦,对了,还得说一下测试同学为什么没有发现这个问题。这个问题确实是一个必现的问题,测试案例上也写了这个测试点。
但是测试同学查看数据的时候用的是 select 语句,查询条件给的是确实需要被删除的数据 。
我相信用过 mybatis 逆向工程的朋友们,一看到这几个接口就知道了:哟,这都是老朋友了。
当我再去重新审视这些接口的时候我会发现其实还有会有一些问题的。
比如 delete 这样的高危语句我们还是需要尽量的手写 xml。
比如 updateByExample 同样存在由于误操作没有 where 条件,导致全表更新的情况。
比如 select 语句是查出了整个对象,但是有时间我们可能只需要对象里面的某个值而已。
比如 select 语句针对大表、关键表操作的时候,不能从代码的角度限定 SQL 必须带上索引字段查询。
我的建议是不要使用 mybatis 的逆向工程,全都手写。
开个玩笑。我们肯定不能因噎废食,何况逆向工程确实是帮我们做了很多工作,极大的方便我们这样的 CRUD Boy 进行 CRUD。
所以,我想 mybatis 的逆向工程肯定是有什么配置来控制生成哪些接口的,别问为什么,问就是直觉。
因为要是让我去开发这样的一个插件,我肯定也会提供对应的开关配置。
我现在的想法是不让它给我生成 delete 相关的接口,这个接口用起来我心里害怕。
这个文件不长,一共也才 213 行,你能发现这一块东西:
你用脚指头想也能知道,这就是我们要找的开关配置。从 DTD 文件的描述中来看,这个几个参数是配置在 table 标签里面的
然后我们程序中真的需要 delete 操作的时候,再自己去手写 xml 文件。
那你自己写的 xml 文件也忘记写 where 条件了这么办?
当然,就算你真的忘记写了,下面这个拦截器还能给你兜个底,帮你一把。
其实这个方案是我想到的第一个方案。导致上面问题的原因很简单嘛,就是执行了delete 语句却没有 where 条件。
那么我们可以拦截到这个 SQL 语句,然后对其进行两个判断:
答案就是我们可以开发一个 mybatis 插件呀,就像分页插件那样。
插件,听起来很高端的样子,其实他就是个拦截器。实现起来非常简单。
中文:https://mybatis.org/mybatis-3/zh/configuration.html#plugins
英文:https://mybatis.org/mybatis-3/configuration.html
通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。
正如官网说的这样,插件开发、使用起来是非常简单的。只需要三步:
基于上面这三步,大家先看一下我们这插件怎么写,以及这个插件的效果。
先说明一下本文涉及到的源码 mybatis 版本是 3.4.0。
本文用拦截器的目的是判断 delete 语句中是否有 where 条件。所以,开发出来的插件长这样:
@Slf4j
@Intercepts({
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class}),
})
public class CheckSQLInterceptor implements Interceptor {
private static String SQL_WHERE = "where";
@Override
public Object intercept(Invocation invocation) throws Throwable {
//获取方法的第0个参数,也就是MappedStatement。@Signature注解中的args中的顺序
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
//获取sql命令操作类型
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
final Object[] queryArgs = invocation.getArgs();
final Object parameter = queryArgs[1];
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
String sql = boundSql.getSql();
if (SqlCommandType.DELETE.equals(sqlCommandType)) {
//格式化sql
sql = sql.replace("\n", "");
if (!sql.toLowerCase().contains(SQL_WHERE)) {
sql = sql.replace(" ", "");
log.info("删除语句中没有where条件,sql为:{}", sql);
throw new Exception("删除语句中没有where条件");
}
}
return invocation.proceed();
}
@Override
public Object plugin(Object o) {
return Plugin.wrap(o, this);
}
@Override
public void setProperties(Properties properties) {
}
}
再把插件注册上(注册插件还有其他的方法,后面会讲到,这里只是展示Bean注入的方式):
删除语句中没有where条件,sql为:delete from order_info_ext
这样,我们的扩展表的数据就保住了。在测试阶段,测试同学就一定能扯出来问题,瞟一眼日志就明白了。
就算测试同学忘记测试了,在生产上也不会执行成功,抛出异常后还会有报警短信通知到相应的开发负责人,及时登上服务器去处理。
我们再说回代码,你说说看:当你拿到上面这段代码后,最迷惑的地方是哪里?
其中的逻辑是很简单的了。没有什么特别的地方,我想大多数人拿到这段代码迷惑的地方在于这个地方吧:
这个 @Intercepts 里面的 @Signature 里面为什么要这样配置?
里面是个数组,可以配置多个 Signature。所以,其实这样配置也是可以的:
上面一小节我们知道了对于开发插件而言,难点在于 @Signature 怎么配置。
其实这也不能叫难点,只能说你不知道能配置什么,比较茫然而已。这一小节就来回答这个问题。
要知道怎么配置就必须要了解mybatis 这四大对象:Executor、ParameterHandler 、ResultSetHandler 、StatementHandler 。
MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
1. Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
2. ParameterHandler (getParameterObject, setParameters)
3. ResultSetHandler (handleResultSets, handleOutputParameters)
4. StatementHandler (prepare, parameterize, batch, update, query)
1. Executor:Mybatis 的执行器,用于进行增删改查的操作。
2. ParameterHandler :参数处理器,用于处理 SQL 语句中的参数对象。
3. ResultSetHandler:结果处理器,用于处理 SQL 语句的返回结果。
4. StatementHandler :数据库的处理对象,用于执行SQL语句
知道拦截的四大对象了,我们就可以先揭秘一下上面的这个注解配置的是啥了:
1. type 字段存放的是 class 对象,其取值范围就是上面说的四大对象。
2. method 字段存放的是 class 对象的具体方法。
看到这几个参数你想到了什么?有没有条件反射式的想到反射?如果没有的话你再咂摸咂摸,看看能不能品出一点反射的味道。
本文用拦截器的目的是判断 delete 语句中是否有 where 条件,因此经过上面的分析,Executor 对象就能满足我们的需求。
所以在本文示例中 @Signature 的 type 字段就是 Executor.class。
那 method 字段我们放哪个方法呢?放 delete 吗?
可以看到其中并没有 delete 方法,和 SQL 执行相关的,看起来只有 query和 update。
但是,我们可以大胆猜测一下呀:delete 也是一种 update。
可以看到 delete 方法确实是调用了 update 方法。
所以在本文案例中 @Signature 的 method 字段放的是 update 方法。
已经知道具体的方法了,那 args 放的就是方法的入参,所以这段配置就是这样来的:
真的,我觉得这属于手摸手教学系列了。经过这个简单的案例,我希望大家能做到一通百通。
接下来带大家看看我们常用的分页插件 pageHelper 是怎么做的吧。
其实你用脚指头也能想到,分页插件肯定是拦截的查询方法,我们只是需要去验证一下就可以。
引入 pageHelper 后可以看到 Interceptor 的多了两个实现:
我们看一下 PageInterceptor 方法吧:
对吧,拦截了两个 query 方法,一个参数是 4 个,一个参数是 6 个:
同时,在 intercept 的实现里面有一部分是这样写的:
4 个参数和 6 个参数是做了单独处理的,至于为什么要这样处理,至于为什么要拦截两个 query 方法,说起来又是一个很长的故事了。
https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/Interceptor.md
好了,还是那句话:如果要写出好的 mybatis 插件,必须知道 @Signature 怎么去配置。配置后能拦截哪些东西,你心里应该是有点数的。
前面我们知道拦截器怎么写了,接下来简单的分析一波原理。
前几天我看到一个观点是说看开源框架的源码建议从 mybatis 看起。我是很赞成这个观点的,确实是优雅,而容易看懂。能品出很多设计模式的使用。
一句话总结 mybatis插件的原理就是:动态代理加上责任链。
标号为 ① 的地方一看就知道,InvocationHandler,JDK 动态代理,没啥说的。
标号为 ② 的地方是 wrap 方法,生成 Plugin 代理对象。
标号为 ③ 的地方是 invoker 方法,圈起来的目的是想说是在这里判断当前方法是否是需要被拦截的方法。如果是则用代理对象走拦截器逻辑,如果不是则用目标对象,走正常逻辑。
一个平平无奇的 if 判断,是拦截器的关键。为什么这个地方多说了几句呢?
因为其实这就是细节的地方。当面试的时候面试官问你:mybatis 是怎么判断是否需要拦截这个方法的时候你能答上来。说明你是真的看过源码。
org.apache.ibatis.plugin.InterceptorChain
你看又学到一招,mybatis 里面的设计模式还有责任链。
插件是作用于这四大对象的:Executor、ParameterHandler 、ResultSetHandler 、StatementHandler 。
那么插件在什么时候被加载,或者说什么是被注册上的呢?
pluginAll 方法我们已经知道有哪些地方调用了。这个方法里面其实还有两个考点。
1. 第一就是 interceptor 这个 List 集合的定义,用了 final 修饰。所以要注意 final 修饰基本类型和引用类型的区别,被 final 修饰的引用类型变量内部的内容是可以发生变化的。
2. 第二就是 getInterceptors 返回的是一个不可修改的 List 。所以,要对集合 interceptors 进行修改,只能通过 addInterceptor 方法进行元素添加,保证了这个集合是可控的。
所以,我们只需要知道哪里调用了 addInterceptor 方法,哪里就是插件被注册的地方。
一个是 SqlSessionFactoryBean ,一个是 XMLConfigBuilder。
熟悉 Mybatis 的朋友们肯定知道,无非就是对于标签的解析而已。
解析到 plugins 标签,则进入 pluginElement 方法中,在这个方法里面调用 addInterceptor:
本文没有使用 XML 的形式配置,所以我们主要看一下 SqlSessionFactoryBean。
不要盲目的走入源码,加个断点看调用链,跟着调用链去走就很清晰了。
然后 debug 起来,你就可以看到整个调用链了:
在 MybatisAutoConfiguration 的构造方法里面初始化了 interceptors。
而 interceptorsProvider.getIfAvailable() 方法也解释了为什么我们只需要在程序里面这样注入我们的拦截器就可以被找到了:
对 getIfAvailable 方法不熟悉的朋友可以去补一下这块的知识,我这里只是给大家看一下这个方法上的注释:
当然,你这样去注入的话有可能会不生效,你就会大骂一声:写的什么垃圾玩意,配置上了也不对呀。
别着急呀,我还没说完呢。你看看是不是有自定义的 SqlSessionFactory 在项目里。
看一下注入 SqlSessionFactory 的源码上面的那个注解了吗?
@ConditionalOnMissingBean ,看名字也知道了,当你的项目里面没有自定义的 SqlSessionFactory 的时候,才会由源码给你注入,这个时候才会正在的注册上插件:
如果你有自定义的 SqlSessionFactory,那么请手动调用 factory.setPlugins 方法。
2. 如果没有自定义 SqlSessionFactory 直接 @Bean 注入拦截器即可。
3. 如果有自定义 SqlSessionFactory 需要在自定义的地方手动调用 factory.setPlugins 方法。
其实我尝试过第四种方法,在application.properties 里面配置:
这种配置方式才是符合 SpringBoot 思想的配置。才是真正的丝滑,润物无声的丝滑。
它调用的是 getInterceptors 方法,我就知道肯定是有问题了:
Failed to bind properties under 'mybatis.configuration.interceptors' to java.util.List<org.apache.ibatis.plugin.Interceptor>
github.com/mybatis/spring-boot-starter/issues/180
更多精彩推荐
☞苹果或在 WWDC 宣布放弃英特尔转向自研 5nm ARM 芯片,这次时机成熟了?
☞滴滴辟谣被美团收购;苹果提交认证 9 款新手机;VS Code 1.46 发布 | 极客头条
☞热评 | 警惕新基建热潮中的区块链项目烂尾