flydoging 2020-06-07
使用mybatis逆向工程的时候,delete方法的使用姿势不对,导致表被清空了,在生产上一刷新后发现表里没数据了,一股凉意从脚板心直冲天灵盖。
于是开发了一个拦截器,并写下这篇文章记录并分享。
你用过 mybatis 逆向工程(mybatis-generator-maven-plugin)生成相关文件吗?
就像这样式儿的:
可以看到逆向工程帮我们生成了实体类、Mapper 接口和 Mapper.xml。
用起来真的很方便,我用了好几年了,但是前段时间翻车了。
具体是怎么回事呢,我给大家摆一下。
先说一下需求吧。就是在做一次借据数据迁移的过程中,要先通过 A 服务的接口拿到所有的借据和对应的还款计划数据,然后再对这些借据进行核查,如果不满足某些添加,就需要从表中删除借据和对应的还款计划。
借据和对应的还款计划存放在两张表中,用借据号来关联。
而上线之后,我在一片欢声笑语中把还款计划表清空了,而这个必现的问题,在测试阶段同学还没有测试出来。
事情发生后我赶紧找到了 DBA 协助修复数据:
是怎么回事呢,为了模拟这个场景,我在本地创建了两张表,订单表(orderInfo)和订单扩展表(orderInfoExt),他们之间用订单号进行关联:
仅仅是做演示,所以两张表是非常简单的,
我们假设现在表里面的这条订单号为 2020060666666 的数据经过判断是错误数据,我当时写的代码体现在单元测试里面是这样的:
看出问题了吗?
第 42 行用的 example 对象还是 OrderInfo 的 example。而真正的 OrderInfoExt 对象的 exampleExt 对象没有进行任何赋值的操作。
为什么会出现这样的乌龙呢?
都怪 idea 太智能了!(强行找个借口)
我只需要打一个 ex 然后回个车.... example 就出现在代码里面了。
而这种没有参数的 example 传进去,在 mapper.xml 里面是这样处理的:
执行一下,看看效果:
看到 delete from order_info_ext 语句。你说你慌不慌?
当然在线上的服务器肯定是看不到执行的 SQL 的,但是当报警短信一条一条接着来的时候,当连上数据库一看表,发现数据没了的时候。
你说你慌不慌?
反正我一刷新后发现表里没数据了,一股凉意从脚板心直冲天灵盖。这种时候都还是要小小的心慌一下,先大喊一声“卧槽!数据怎么没了?”
然后赶紧报备,准备找 DBA 捞数据吧。
还好,本次误删不影响正常业务。
数据恢复过程就不说了,聊一下这事发生后我的一点思考吧。
哦,对了,还得说一下测试同学为什么没有发现这个问题。这个问题确实是一个必现的问题,测试案例上也写了这个测试点。
但是测试同学查看数据的时候用的是 select 语句,查询条件给的是确实需要被删除的数据 。
然后分别在两个表里面执行后发现:数据确实是没了。
是的,是数据确实是没了。整个表都干净了。
看着测试妹子惊慌失措的样子,我还能怎么说呢?
这锅,不甩了,我自己背下来吧。
我们先看看逆向工程帮我们生成的接口:
我相信用过 mybatis 逆向工程的朋友们,一看到这几个接口就知道了:哟,这都是老朋友了。
当我再去重新审视这些接口的时候我会发现其实还有会有一些问题的。
比如 delete 这样的高危语句我们还是需要尽量的手写 xml。
比如 updateByExample 同样存在由于误操作没有 where 条件,导致全表更新的情况。
比如 select 语句是查出了整个对象,但是有时间我们可能只需要对象里面的某个值而已。
比如 select 语句针对大表、关键表操作的时候,不能从代码的角度限定 SQL 必须带上索引字段查询。
上面的这些问题我们怎么处理呢?
我的建议是不要使用 mybatis 的逆向工程,全都手写。
开个玩笑。我们肯定不能因噎废食,何况逆向工程确实是帮我们做了很多工作,极大的方便我们这样的 CRUD Boy 进行 CRUD。
所以,我想 mybatis 的逆向工程肯定是有什么配置来控制生成哪些接口的,别问为什么,问就是直觉。
因为要是让我去开发这样的一个插件,我肯定也会提供对应的开关配置。
我现在的想法是不让它给我生成 delete 相关的接口,这个接口用起来我心里害怕。
所以怎么配置呢?
我们去它的 DTD 文件里面找一下嘛:
这个文件不长,一共也才 213 行,你能发现这一块东西:
你用脚指头想也能知道,这就是我们要找的开关配置。从 DTD 文件的描述中来看,这个几个参数是配置在 table 标签里面的。
我们去试一下:
果然是这样的。然后我们进行相关配置如下:
再生成一下:
果然,delete 相关的接口没了。
然后我们程序中真的需要 delete 操作的时候,再自己去手写 xml 文件。
那你自己写的 xml 文件也忘记写 where 条件了这么办?
这个月工资别领了。自己好好反思反思。
当然,就算你真的忘记写了,下面这个拦截器还能给你兜个底,帮你一把。
其实这个方案是我想到的第一个方案。导致上面问题的原因很简单嘛,就是执行了delete 语句却没有 where 条件。
那么我们可以拦截到这个 SQL 语句,然后对其进行两个判断:
是否是 delete 语句。 如果是,是否包含 where 条件。
那么问题来了,我们怎么去拦截到这个 SQL 呢?
答案就是我们可以开发一个 mybatis 插件呀,就像分页插件那样。
插件,听起来很高端的样子,其实他就是个拦截器。实现起来非常简单。
先去官网上看一下:
中文:https://mybatis.org/mybatis-3/zh/configuration.html#plugins
英文:https://mybatis.org/mybatis-3/configuration.html
在官网上,对于插件这一模块的描述是这样的:
通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。
正如官网说的这样,插件开发、使用起来是非常简单的。只需要三步:
1.实现 Interceptor 接口。
2.指定想要拦截的方法签名。
3.配置这个插件。
基于上面这三步,大家先看一下我们这插件怎么写,以及这个插件的效果。
先说明一下本文涉及到的源码 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 里面为什么要这样配置?
我们先看看 @Intercepts 注解:
里面是个数组,可以配置多个 Signature。所以,其实这样配置也是可以的:
关键的地方在于 @Signature 怎么配置:
这个问题,我们放到下一节去讨论。
上面一小节我们知道了对于开发插件而言,难点在于 @Signature 怎么配置。
其实这也不能叫难点,只能说你不知道能配置什么,比较茫然而已。这一小节就来回答这个问题。
要知道怎么配置就必须要了解mybatis 这四大对象:Executor、ParameterHandler 、ResultSetHandler 、StatementHandler 。
官网上说:
MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)
那官网上说的这四大对象分别是拿来干啥用的呢?
Executor:Mybatis 的执行器,用于进行增删改查的操作。
ParameterHandler :参数处理器,用于处理 SQL 语句中的参数对象。
ResultSetHandler:结果处理器,用于处理 SQL 语句的返回结果。
StatementHandler :数据库的处理对象,用于执行SQL语句
知道拦截的四大对象了,我们就可以先揭秘一下上面的这个注解配置的是啥了:
type 字段存放的是 class 对象,其取值范围就是上面说的四大对象。
method 字段存放的是 class 对象的具体方法。
args 存放的是具体方法的参数。
看到这几个参数你想到了什么?有没有条件反射式的想到反射?如果没有的话你再咂摸咂摸,看看能不能品出一点反射的味道。
本文用拦截器的目的是判断 delete 语句中是否有 where 条件,因此经过上面的分析,Executor 对象就能满足我们的需求。
所以在本文示例中 @Signature 的 type 字段就是 Executor.class。
那 method 字段我们放哪个方法呢?放 delete 吗?
这就得看看 Executor 对象的方法有哪些:
可以看到其中并没有 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插件的原理就是:动态代理加上责任链。
先看一下 Plugin 类的动态代理:
标号为 ① 的地方一看就知道,InvocationHandler,JDK 动态代理,没啥说的。
标号为 ② 的地方是 wrap 方法,生成 Plugin 代理对象。
标号为 ③ 的地方是 invoker 方法,圈起来的目的是想说是在这里判断当前方法是否是需要被拦截的方法。如果是则用代理对象走拦截器逻辑,如果不是则用目标对象,走正常逻辑。
给大家看一下这个地方的 debug 效果:
一个平平无奇的 if 判断,是拦截器的关键。为什么这个地方多说了几句呢?
因为其实这就是细节的地方。当面试的时候面试官问你:mybatis 是怎么判断是否需要拦截这个方法的时候你能答上来。说明你是真的看过源码。
责任链是怎么体现的呢?
就是这个地方: org.apache.ibatis.plugin.InterceptorChain
你看又学到一招,mybatis 里面的设计模式还有责任链。
我们看一下 pluginAll 方法的调用方:
这个地方就体现出之前官网说的了:
插件是作用于这四大对象的:Executor、ParameterHandler 、ResultSetHandler 、StatementHandler 。
上面框起来的这四个框,就是插件调用的地方。
那么插件在什么时候被加载,或者说什么是被注册上的呢?
还是回到拦截链这个类上去:
pluginAll 方法我们已经知道有哪些地方调用了。这个方法里面其实还有两个考点。
第一就是 interceptor 这个 List 集合的定义,用了 final 修饰。所以要注意 final 修饰基本类型和引用类型的区别,被 final 修饰的引用类型变量内部的内容是可以发生变化的。
第二就是 getInterceptors 返回的是一个不可修改的 List 。所以,要对集合 interceptors 进行修改,只能通过 addInterceptor 方法进行元素添加,保证了这个集合是可控的。
所以,我们只需要知道哪里调用了 addInterceptor 方法,哪里就是插件被注册的地方。
一个是 SqlSessionFactoryBean ,一个是 XMLConfigBuilder。
使用 XML 配置是这样的:
熟悉 mybatis 的朋友们肯定知道,无非就是对于标签的解析而已。
解析到 plugins 标签,则进入 pluginElement 方法中,在这个方法里面调用 addInterceptor:
本文没有使用 XML 的形式配置,所以我们主要看一下 SqlSessionFactoryBean。
怎么看呢?
不要盲目的走入源码,加个断点看调用链,跟着调用链去走就很清晰了。
在这个地方加一个断点:
然后 debug 起来,你就可以看到整个调用链了:
然后我们根据上面的调用链,我们就可以找到源头了:
在 MybatisAutoConfiguration 的构造方法里面初始化了 interceptors。
而 interceptorsProvider.getIfAvailable() 方法也解释了为什么我们只需要在程序里面这样注入我们的拦截器就可以被找到了:
对 getIfAvailable 方法不熟悉的朋友可以去补一下这块的知识,我这里只是给大家看一下这个方法上的注释:
当然,你这样去注入的话有可能会不生效,你就会大骂一声:写的什么垃圾玩意,配置上了也不对呀。
别着急呀,我还没说完呢。你看看是不是有自定义的 SqlSessionFactory 在项目里。
看一下注入 SqlSessionFactory 的源码上面的那个注解了吗?
@ConditionalOnMissingBean ,看名字也知道了,当你的项目里面没有自定义的 SqlSessionFactory 的时候,才会由源码给你注入,这个时候才会正在的注册上插件:
如果你有自定义的 SqlSessionFactory,那么请手动调用 factory.setPlugins 方法。
所以,总结一下插件的三种配置方法:
1.xml方式配置。
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>
找了一圈原因,最后发现了这个 issue:
github.com/mybatis/spring-boot-starter/issues/180
这个“奇异博士”头像的用户提出了和我一样的问题:
然后下面的回答是这样的:
别问,问就是不支持。请使用 @Bean 的方式。
点个“赞”吧,周更很累的,不要白嫖我,需要一点正反馈。
才疏学浅,难免会有纰漏,如果你发现了错误的地方,还请你指出来,我对其加以修改。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。
我是 why,一个被代码耽误的文学创作者,不是大佬,但是喜欢分享,是一个又暖又有料的四川好男人。
欢迎关注我的微信公众号:why技术。在这里我会分享一些java技术相关的知识,用匠心敲代码,对每一行代码负责。偶尔也会荒腔走板的聊一聊生活,写一写书评、影评。感谢你的关注,愿你我共同进步。