你好呀,我是歪歪。
是的,正如标题描绘的这样,我试图经过这篇文章,教会你如何阅览源码。
工作大约是这样的,前段时间,我收到了一个读者发来的类似于这样的示例代码:
他说他知道这三个事例的回滚状况是这样的:
- insertTestNoRollbackFor:不会回滚
- insertTestRollback:会回滚
- insertTest:会回滚
他说在没有履行代码之前,他也知道前两个为什么一个不会回滚,一个会回滚。由于抛出的反常和 @Transactional 里边的注解照应上了。
可是第三个到底会不会回滚,没有履行之前,他不知道为什么会回滚。履行之后,回滚了,他也不知道为什么回滚了。
我告知他:源码之下无隐秘。
让他去看看这部分源码,理解它的原理,不然这个当地抛出一个其他的反常,又不知道会不会回滚了。
可是他说他完全不会看源码,找不到下手的角度。
所以,就这个问题,我方案写这样的一篇文章,试图教会你一种阅览源码的办法。让你找到一个好的切入点,或许说突破口。
可是需要事先说明的是,阅览源码的办法十分的多,这篇文章仅仅站在我个人的角度介绍阅览源码的许多办法中的一种,九牛一毛,就像是一片树林里边的一棵树的树干上的一叶叶片的叶脉中的一个小分叉罢了。
关于啃源码这件事儿,没有一个所谓的“一招吃遍天下”的秘诀,假如你非要让我给出一个秘诀的话,那么就只要一句话:
啃源码的进程,一定是十分单调的,特别是啃自己接触不多的结构源码的时分,千丝万缕,也得下手去捋,所以一定要耐得住孤寂才行。
然后,假如你非得让我再弥补一句的话,那么便是:
调试源码,一定要亲!自!动!手!仅仅去看相关的文章,而没有自己一步步的去调试源码,那你相当于看了个孤寂。
亲身着手的第一步便是搞个 Demo 出来。用“黑话”来说,这个 Demo 便是你的抓手,有了抓手你才能打出一套理论结合实际的组合拳。抓手多了,就能沉积出可复用的办法论,终究为自己赋能。
建立 Demo
所以,第一步必定是先把 Demo 给建立起来,项目结构十分的简略,标准的三层结构:
主要是一个 Controller,一个 Service,然后搞个本地数据库给接上,就完全够够的了:
Student 对象是从表里边映射过来的,随意弄了两个字段,主要是演示用:
就这么一点代码,给你十分钟,你是不是就能建立好了?中间乃至还能摸几分钟鱼。
要是只要这么一点东西的、极其简略的 Demo 你都不想自己亲身着手搭一下,然后自己去调试的话,仅仅是经过阅览文章来肉眼调试,那么我只能说:
在正式开端调试代码之前,咱们还得清晰一下调试的意图:想要知道 Spring 的 @Transactional 注解关于反常是否应该回滚的判别逻辑具体是怎样样的。
带着问题去调试源码,是最简单有收成的,并且你的问题越具体,收成越快。你的问题越抽象,就越简单在源码里边迷失。
办法论之重视调用栈
自己 Debug 的进程便是不断的打断点的进程。
我再说一次:自己 Debug 的进程便是不断的打断点的进程。
打断点咱们都会打,断点打在哪些当地,这个玩意就很讲究了。
在咱们的这个 Demo 下,第一个断点的位置十分好判别,就打在事务办法的入口处:
一般来说,咱们调试事务代码的时分,都是顺着断点往下调试。可是当你去阅览结构代码的时分,你得往回看。
什么是“往回看”呢?
当你的程序在断点处停下的时分,你会发现 IDEA 里边有这样的一个部分:
这个调用栈是你在调试的进程中,一个十分十分十分重要的部分。
它表示的是以当时断点位置为结尾的程序调用链路。
为了让你完全的理解这句话,我给你看一张图:
我在 test6 办法中打上断点,调用栈里边便是以 test6 办法为结尾到 main 办法为起点的程序调用链接。
当你去点击这个调用栈的时分,你会发现程序也会跟着动:
“跟着动”的这个动作,你能够理解为你站着断点处“往回看”的进程。
当你理解了调用栈是干啥的了之后,咱们再具体看看在当时的 Demo 下,这个调用栈里边都有写啥:
标号为 ① 的当地,是 TestController 办法,也便是程序的入口。
标号为 ② 的当地,从包名称能够看出是 String AOP 相关的办法。
标号为 ③ 的当地,就能够看到是事务相关的逻辑了。
标号为 ④ 的当地,是当时断点处。
好,到这儿,我想让你简略的回顾一下你来调试代码的意图是什么?
是不是想要知道 Spring 的 @Transactional 注解关于反常是否应该回滚的判别逻辑具体是怎样样的。
那么,咱们是不是应该主要把重视的要点放在标号为 ③ 的当地?
也便是对应到这一行:
这个当地我一定要特别的着重一下:要坚持目标清晰,许多人在源码里边迷失的原因便是不知不觉间被源码牵着走远了。
比方,有人看到标号为 ② 的部分,也便是 AOP 的部分,一想着这玩意我眼熟啊,书上写过 Spring 的事务是根据 AOP 完成的,我去看看这部分代码吧。
当你走到 AOP 里边去的时分,路就开端有点走偏了。你理解我意思吧?
即便在这个进程中,你翻阅了这部分的源码,的确了解到了更多的关于 AOP 和事务之间的联系,可是这个部分并不处理你“关于回滚的判别”这个问题。
然而更多更真实的状况可能是这样的,当你点到 AOP 这部分的时分,你一看这个类名称是 CglibAopProxy:
你一细嗦,Cglib 你也了解啊,它和 JDK 动态署理是一对好兄弟,都是老陈腔滥调了。
然后你可能又会点击到 AopProxy 这个接口,找到 JdkDynamicAopProxy:
接着你恍然大悟:哦,我在什么都没有装备的状况下,当时版本的 SpringBoot 默许运用的是 Cglib 作为动态署理的完成啊。
诶,我怎样记得我背的陈腔滥调文默许是运用 JDK 呢?
网上查一下,查一下。
哦,本来是这么一回事儿啊:
- SpringBoot 1.x,默许运用的是 JDK 动态署理。
- SpringBoot 2.x 开端,为了处理运用 JDK 动态署理可能导致的类型转化反常而默许运用 CGLIB。
- 在 SpringBoot 2.x 中,假如需要默许运用 JDK 动态署理能够经过装备项spring.aop.proxy-target-class=false来进行修正,proxyTargetClass装备已无效。
刚刚提到了一个 spring.aop.proxy-target-class 装备,这是个啥,咋装备啊?
查一下,查一下…
喂,醒一醒啊,朋友,走远了啊。还记得你调试源码的意图吗?
假如你关于 AOP 这个部分感兴趣,能够先进行简略的记录,可是不要去深入的追寻。
不要觉得自己仅仅随意看看,不要紧。横竖正是由于这些“随意看看”导致你在源码里边忙了半响感觉这波学到了,可是停下来一想:我 TM 刚刚看了些啥来着?我的问题怎样还没处理?
我为什么要把这部分十分翔实,乃至于接近烦琐的写一遍,便是由于这个便是初看源码的朋友最简单犯的错误。
特别着重一下:捉住主要矛盾,处理主要问题。
好,回到咱们经过调用栈找到的这个和事务相关的办法中:
org.springframework.transaction.interceptor.TransactionInterceptor#invoke
这个办法,便是咱们要打第二个断点,或许说这才是真实的第一个断点的当地。
然后,重启项目,重新建议恳求,从这个当地就能够进行正向的调试,也便是从结构代码一步步的往事务代码履行。
比方这个办法接着往下 Debug,就来到了这个当地:
org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction
找到了这个当地,你就算是无限的接近于问题的本相了。
这个部分我必定会讲的,可是在这儿先按下不表,究竟这并不是本文最重要的东西。
本文最重要的是,我再次重申一遍:我试图想要教会你一种阅览源码的办法,让你找到一个好的切入点,或许说突破口。
由于这个事例比较简略,所以很简单找到真实的第一个利于调试的断点。
假如遇到一些复杂的场景、呼应式的编程、异步的调用等等,可能会循环往复的履行上面的动作。
剖析调用栈,打断点,重启。
再剖析调用栈,再打断点,再重启。
办法论之死盯日志
其实我发现很少有人会去注意结构打印的日志,就像是很少有人会去仔细阅览源码上的 Javadoc 相同。
可是其实经过调查日志输出,也是一个很好的寻觅阅览源码突破口的办法。
咱们要做的,便是确保 Demo 尽量的单纯,不要有太多的和本次排查无关的代码和依赖引进。
然后把日志等级修正为 debug:
logging.level.root=debug
接着,便是建议一次调用,然后耐着性质去看日志。
仍是咱们的这个 Demo,建议一次调用之后,控制台输出了许多的日志,我给你搞个缩略图看看:
咱们已知的是这儿边大约率是有头绪的,有没有什么办法尽量快的找出来呢?
有,可是通用性不强。所以假如经历不行丰富的话,那么最好的办法便是一行行的去找。
前面我也说过了:啃源码的进程,一定是十分单调的。
所以你一定会找到这样的日志输出:
AcquiredConnection[HikariProxyConnection@982684417wrappingcom.mysql.cj.jdbc.ConnectionImpl@751a148c]forJDBCtransaction
SwitchingJDBCConnection[HikariProxyConnection@982684417wrappingcom.mysql.cj.jdbc.ConnectionImpl@751a148c]tomanualcommit
...
==>Preparing:insertintostudent(name,home)values(?,?)
HikariPool-1-Poolstats(total=1,active=1,idle=0,waiting=0)
==>Parameters:why(String),草市街199号-insertTestNoRollbackFor(String)
<==Updates:1
...
CommittingJDBCtransactiononConnection[HikariProxyConnection@982684417wrappingcom.mysql.cj.jdbc.ConnectionImpl@751a148c]
ReleasingJDBCConnection[HikariProxyConnection@982684417wrappingcom.mysql.cj.jdbc.ConnectionImpl@751a148c]aftertransaction
这几行日志,不便是正对应着 Spring 事务的开启和提交吗?
有了日志,咱们完全能够根据日志去找对应的日志输出的当地,比方咱们现在要找这一行日志输出对应的代码:
o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@982684417 wrapping com.mysql.cj.jdbc.ConnectionImpl@751a148c] for JDBC transaction
首要,咱们能够根据日志知道对应输出的类是 DataSourceTransactionManager 这个类。
然后找到这个类,依照要害词查找:
不就找到这一行代码了吗?
或许咱们直接秉承大力出奇观的真理,来一个暴力的全局查找,也是能搜到这一行代码的:
再或许修正一下日志输出格式,把行号也搞出来嘛。
当咱们把日志格式修正为这样之后:
logging.pattern.console=%d{dd-MM-yyyy HH:mm:ss.SSS} %magenta([%thread]) %highlight(%-5level) %logger.%M:%L – %msg%n
控制台的日志就变成了这样:
org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin:263 – Acquired Connection [HikariProxyConnection@1569067488 wrapping com.mysql.cj.jdbc.ConnectionImpl@19a49539] for JDBC transaction
很直观的就看出来了,这行日志是 DataSourceTransactionManager 类的 doBegin 办法,在 263 行输出的。
然后你找过去,发现没有任何缺点,这便是案发现场:
我前面给你说这么多,便是为了让你找到这一行日志输出的当地。
现在,找到了,然后呢?
然后必定便是在这儿打断点,然后重启程序,重新建议调用了啊。
这样,你又能得到一个调用栈:
然后,你会从调用栈中看到一个咱们了解的东西:
朋友,这不就和前面写的“办法论之重视调用栈”照应起来了吗?
这不便是一套组合拳吗,不便是沉积出的可复用的办法论吗?
黑话,咱们也是能够整两句的。
办法论之查看被调用的当地
除了前面两种办法之外,我有时分也会直接看我要阅览部分的办法,在结构中被哪些当地调用了。
比方在咱们的 Demo 中,咱们要阅览的代码十分的清晰,便是 @Transactional 注解。
所以直接看一下这个注解在哪些当地用到了:
有的时分调用的当地会十分的少,乃至只要一两处,那么直接在调用的当地打上断点就对了。
尽管 @Transactional 注解一眼望去也是有许多的调用,可是仔细一看大多是测验类。排除测验类、JavaDoc 里边的备注和自己项目中的运用之后,只剩下很明显的这三处:
看起来很接近本相,可是很遗憾,这儿仅仅在项目发动的时分解析注解罢了。和咱们要调研的当地,差的还有点远。
这个时分就需要一点经历了,一看预兆不对,立马转化思路。
什么是预兆不对呢?
你在这几个当地打上断点了,仅仅在项目发动的进程中断点起作用了,建议调用的时分并没有在断点处停下,说明建议调用的时分并不会触发这部分逻辑,预兆不对。
顺着这个思路想,在我的 Demo 中抛出了反常,那么 rollbackFor 和 noRollbackFor 这两个参数大约率是会在调用的时分被用到,对吧?
所以当你去看 rollbackFor 被调用的时分只要咱们自己写的事务代码在调用:
怎样办呢?
这个时分就要靠一点命运了。
是的,靠命运。
你都点到 rollbackFor 这个办法来了,你也看了它被调用的当地,在这个进程中你大约率会瞟到几眼它对应的 JavaDoc:
org.springframework.transaction.annotation.Transactional#rollbackFor
然后你会发现在 JavaDoc 里边提到了 rollbackOn 这个办法:
org.springframework.transaction.interceptor.DefaultTransactionAttribute.rollbackOn(Throwable)
到这儿一看,你发现这是一个接口,它有好多个完成类:
怎样办呢?
早期的时分,由于不知道具体的完成类是哪个,我是在每个完成类的入口处都打上断点,尽管是笨办法,可是总是能起作用的。
后来我才发现,本来能够直接在接口上打断点:
然后,重启项目,建议调用,第一次会停在咱们办法的入口:
F9,越过当时断点之后,来到了这个当地:
这儿便是我前面在接口上打的办法断点,走到了这个完成类中:
org.springframework.transaction.interceptor.DelegatingTransactionAttribute
然后,要害的就来了,咱们又有一个调用栈了,又从调用栈中看到一个咱们了解的东西:
朋友,组合拳这不又打起来了?突破口不就又找到了?
关于“瞟到几眼对应的 JavaDoc ,然后就可能找到突破口”的这个现象,早期对我来说的确是命运,可是现在已经是一个习惯了。一些闻名结构的 JavaDoc 真的写的很清楚的,里边躲藏了许多要害信息,并且是最权威的正确信息,读官网文档,比读技术博客稳当的多。
探索答案
前面我介绍的都是找到代码调试突破口的办法。
现在突破口也有了,接下来应该怎样办呢?
很简略,调试,重复的调试。从这个办法开端,一步一步的调试:
org.springframework.transaction.interceptor.TransactionInterceptor#invoke
假如你真的想要有所收成的话,这是一个需要你亲身去着手的过程,必须要有逐行阅览的一个进程,然后才能知道大约的处理流程。
我就不进行具体解读了,仅仅把要点给咱们画一下:
框起来的部分,便是去履行事务逻辑,然后根据事务逻辑的处理结果,去走不同的逻辑。
抛反常了,走这个办法:completeTransactionAfterThrowing
正常履行完毕了,走这个办法:commitTransactionAfterReturning
所以,咱们问题的答案就藏在 completeTransactionAfterThrowing 里边。
持续调试,进入这个办法之后,能够看到它拿到了事务和当时反常相关的信息:
在这个办法里边,大体的逻辑是当标号为 ① 的当地为 true 的时分,就在标号为 ② 的当地回滚事务,否则就在标号为 ③ 的当地提交事务:
因此,标号为 ① 的部分就很重要了,这儿边就藏着咱们问题的答案。
别的,在这儿多说一句,在咱们的事例中,这个办法,也便是当时调试的办法是不会回滚的:
而这个办法是会回滚的:
也便是这两个办法在这个当地会走不同的逻辑,所以你在调试的时分遇到 if-else 就需要注意,去构建不同的事例,以覆盖尽量多的代码逻辑。
持续往下调试,会进入到标号为 ① 的 rollbackOn 办法里边,来到这个办法:
org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn
这儿,就藏着问题的终极答案,并且这儿边的代码逻辑相对比较的绕。
核心逻辑便是经过循环 rollbackRules,这儿边装的是咱们在代码中装备的回滚规矩,在循环体中拿 ex,也便是咱们程序抛出的反常,去匹配规矩,最终挑选一个 winner:
假如 winner 为空,则走默许逻辑。假如是 RuntimeException 或许是 Error 的子类,就要进行回滚:
假如有 winner,判别 winner 是否是不用回滚的装备,假如是,则取反,返回 false,表示不进行回滚:
那么问题的冠军就在于:winner 怎样来的?
答案就藏着这个递归调用中:
一句话描绘便是:看当时抛出的反常和装备的规矩中的 rollbackFor 和 noRollbackFor 谁间隔更近。这儿的间隔指的是父类和子类之间的联系。
比方,仍是这个事例:
咱们抛出的是 RuntimeException,它间隔 noRollbackFor=RuntimeException.class 为 0。RuntimeException 是 Exception 的子类,所以间隔 rollbackFor = Exception.class 为 1。
所以,winner 是 noRollbackFor,能理解吧?
然后,咱们再看一下这个事例:
根据前面的“间隔”的剖析,NullPointerException 是 RuntimeException 的子类,它们之间的间隔是 1。而 NullPointerException 到 Exception 的间隔是 2:
所以,rollbackFor=RuntimeException.class 这个的间隔更短,所以 winner 是 rollbackFor。
而把 winner 放到这个判别中,返回是 true:
return !(winner instanceof NoRollbackRuleAttribute);
所以,这便是它为什么会回滚的原因:
好了,到这儿你有可能是晕的,晕就对了,去调试这部分代码,亲身摸一遍,你就搞的明理解白了。
最终,再给“死盯日志”的办法论打个补丁吧。
前面我说了,日志等级调整到 Debug 也许会有意外发现。现在,我要再给你说一句,假如 Debug 没有查到信息,能够试着调整到 trace:
logging.level.root=trace
比方,当咱们调整到 trace 之后,就能够看到“ winner 到底是谁”这样的信息了:
当然了,trace 等级下日志更多了。
所以,来,再跟我大声的读一遍:
啃源码的进程,一定是十分单调的,特别是啃自己接触不多的结构源码的时分,千丝万缕,也得下手去捋,所以一定要耐得住孤寂才行。
作业
我前面主要是试图教你一种阅览源码时,寻觅突破点的技术。这个突破点,说白了便是第一个有效的断点到底应该打在哪里。
你用前面我教的办法,也能把 @Cacheable 和 @Async 都玩理解。由于它们的底层逻辑和 @Transactional 是相同的。
所以,现在安置两个作业。
拿着这套组合拳,去上手玩一玩 @Cacheable 和 @Async 吧,沉积出归于自己的办法论。
@Cacheable:
@Async:
最终,再附上几个我之前写过的文章,里边也用到了前面提到的几个办法定位源码,老舒服了。有兴趣能够看看:
《我是真没想到,这个面试题竟然从11年前就开端讨论了,而官方今年才表态。》
《的确很优雅,所以我要扯下这个注解的奥秘面纱。》
《关于Request复用的那道破事儿。研究理解了,给你报告一下。》
《千万千万不要在办法上打断点!太坑了!》
好了,本文就到这儿啦。假如你觉得对你有一丝丝协助的话,求个免费的赞,不过火吧?
“本文正在参加「金石方案」”