大家好,我是伟伟。是的,正如标题所描述的,我试图通过这篇文章教你如何阅读源代码。事情大概是这样的。前段时间收到一位读者的类似这样的示例代码:他说他知道这三种情况的回滚是这样的:insertTestNoRollbackFor:不会回滚insertTestRollback:会返回RollinsertTest:会回滚。他说,在代码执行之前,他也知道为什么前两个一个不会回滚,另一个会回滚。因为抛出的异常与@Transactional中的注解相呼应。但是第三个会不会回滚,他不知道为什么还没执行就回滚了。执行完就回滚了,他也不知道为什么会回滚。我告诉他:源代码下没有秘密。让他看看这部分源码,了解一下它的原理,不然这个地方又抛出一个异常,不知道会不会回滚。但是他说他根本看不懂源码,也找不到下手的方法。所以,在这个问题上,我打算写这样一篇文章,试图教给大家一种阅读源码的方法。让你找到一个好的切入点,或者突破口。但需要提前说明的是,阅读源码的方式有很多种。这篇文章只是从我个人的角度阅读源码的众多方法中的一种。大海中的一滴水就像森林中一棵树的树干。它只是上部叶脉中的一个小叉子。对于啃源码这件事,并没有所谓的“一招吃天下”的秘诀。如果非要让我给个密,那就只有一句话:啃源码的过程一定很无聊,尤其是在啃自己接触不多的框架源码的时候,线索很多,又得开始摸索,所以一定要耐得住寂寞。那么,如果非要让我多加一句,那就是:调试源码,必须亲亲!自从!移动!手!只看相关文章,不一步一步debug源码,那你就相当于寂寞了。自己动手的第一步是做一个demo。用“黑话”来说,这个Demo就是你的抓手。有了握把,就可以做到理论与实践相结合。手多了,才能沉淀出可复用的方法论,最终赋能自己。构建演示因此,第一步必须先构建演示。项目结构非常简单。标准的三层结构:主要是一个Controller,一个Service,然后一个本地数据库连接它就完全够用了。已经:Student对象是从表中映射出来的,我随机创建了两个字段,主要是为了演示:代码少,十分钟能搭建好吗?你甚至可以在中间摸鱼几分钟。如果你不想只用这么点东西就搭建一个极其简单的demo,然后自己调试,直接看文章亲眼调试,那我只能说:在我们正式开始调试代码之前,我们还是有必要明确一下调试的目的:想知道Spring的@Transactional注解是否回滚异常的具体逻辑。带着问题调试源码是最简单的收获方式,而且你的问题越具体,收获越快。您的问题越笼统,就越容易迷失在源代码中。方法论重点调试调用栈的过程本身就是不断断点的过程。再说一遍:自己调试的过程就是不断打断点的过程。断点怎么打谁都知道,断点在哪里打这个东西是很有讲究的。在我们的demo中,第一个断点的位置很好判断,打在事务方法的入口处:一般来说,调试业务代码的时候,都是沿着断点调试。但是当你去阅读框架代码时,你必须回头看。什么是“回头看”?当你的程序停在断点处时,你会发现在IDEA中有这么一个部分:这个调用栈是你调试过程中非常非常非常重要的一个部分。它表示在当前断点位置结束的程序调用链接。为了让你完全理解这句话,我给你看一张图:我在test6方法下了一个断点,调用栈就是从test6方法为终点到main方法为起点的程序调用链接观点。当你点击调用栈时,你会发现程序也会动起来:“跟随”的动作可以理解为站在断点处“回头看”的过程。了解了调用栈是做什么用的,我们再仔细看看当前demo下调用栈里写的是什么:标①的地方是TestController方法,是程序的入口。标有②的地方从包名可以看出是StringAOP相关的方法。在标出③的地方,可以看到交易相关的逻辑。④处为当前断点。好吧,在这里,我想让你简单回顾一下你调试代码的目的是什么?想知道Spring的@Transactional注解是如何判断异常是否回滚的吗?那么,我们是不是应该主要关注标有③的地方呢?也就是对应这行:我必须特别强调这个地方:要保持目标明确,很多人之所以迷失在源码中,就是在不知不觉中被源码带走了。比如有人看到标有②的部分,就是AOP的部分。我以为这些东西对我来说很熟悉。书中写到Spring的事务是基于AOP实现的。让我去看看这部分代码。当您进入AOP时,这条路开始有点不对劲。你明白我的意思吗?即使在这个过程中,你已经阅读了这部分的源码,了解了更多关于AOP和事务的关系,但是这部分并没有解决你的“关于回滚的判断”问题。然而,越来越多的真实情况可能是这样的。点击AOP部分,可以看到类名是CglibAopProxy:你说的是这个,你对Cglib很熟悉。和JDK动态代理是一对好兄弟,都是老套路。那么你可能会再次点击AopProxy界面,找到JdkDynamicAopProxy:然后你恍然大悟:哦,当我什么都没配置的时候,当前版本的SpringBoot默认使用Cglib作为动态代理的实现。哎,我怎么记得我背的八股文默认用的是JDK呢?上网查查,查查。哦,原来是这样的:SpringBoot1.x默认使用JDK动态代理。从SpringBoot2.x开始,为了解决使用JDK动态代理可能导致的类型转换异常,默认使用CGLIB。在SpringBoot2.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]forJDBCtransactionSwitchingJDBCConnection[HikariProxyConnection@982684417wrappingcom.mysql.cj.jdbc.ConnectionImpl@75]手动提交...==>准备:插入学生(姓名,家庭)值(?,?)HikariPool-1-池统计(总计=1,活动=1,空闲=0,等待=0)==>Parameters:why(String),199CaoshiStreet-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:AcquiredConnection[HikariProxyConnection@982684417wrappingcom.mysql.cj.jdbc.ConnectionImpl@751a148c]forJDBCtransaction首先我们从日志中可以知道对应的输出类是DataSourceTransactionManager类,然后找到这个类,根据关键字搜索:没有找到这行代码吗?或者我们可以直接坚持创造奇迹的真理,做一个暴力的全局搜索,我们也可以找到这行代码:或者修改日志输出格式来获取行号。当我们修改日志格式为:logging.pattern.console=%d{dd-MM-yyyyHH:mm:ss.SSS}%magenta([%thread])%highlight(%-5level)%logger.%M:%L-%msg%n控制台日志变成这样:org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin:263-AcquiredConnection[HikariProxyConnection@1569067488wrappingcom.mysql.cj.jdbc.ConnectionImpl@19a49539]forJDBC事务可以直观的看出。这行log是DataSourceTransactionManager类的doBegin方法,在第263行输出的,然后你去找找,发现没有问题。这就是案发现场:早跟你说这么多,就是让你找到这行日志输出的地方。现在,我找到了,然后呢?那么肯定是这里断点,然后重启程序,重新发起调用。这样,你就可以得到另一个调用栈:那么,你会从调用栈中看到一些我们熟悉的东西:朋友,这不是呼应了前面写的“关注调用栈的方法论”吗?这不就是一套组合拳,不就是一种沉淀的、可复用的方法论吗?对于俚语,我们也可以造两个句子。Methodology查看调用的地方除了前面两种方法外,我有时直接看我想看的方法和在框架中调用的地方。比如在我们的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多看几眼,就可能找到突破口”的现象,早期确实是我运气好,现在已经是习惯了。一些知名框架的JavaDocs真的写的很清楚,里面隐藏了很多关键信息,是最权威最正确的信息。阅读官网文档比阅读技术博客要安全得多。探索答案前面介绍的是在代码调试中寻找突破口的方法。既然有了突破,那接下来怎么办呢?很简单,调试,反复调试。从这个方法入手,一步步调试:org.springframework.transaction.interceptor.TransactionInterceptor#invoke如果你真的想有所收获,这是一个需要你自己动手的步骤,必须一行一行的看Aprocess,然后就可以知道大概的处理流程了。我就不详细解释了,只是给大家划重点:框架化的部分就是执行业务逻辑,然后根据业务逻辑的处理结果去不同的逻辑。抛异常,这样走:completeTransactionAfterThrowing正常执行,这样走:commitTransactionAfterReturning那么,我们问题的答案就隐藏在completeTransactionAfterThrowing中。继续调试。进入该方法后,可以看到已经获取了交易相关的信息和当前异常:该方法中,大致的逻辑是,当标①的地方为真时,返回标②的地方roll交易,否则在标有③的地方提交交易:因此,标有①的部分非常重要,里面隐藏着我们问题的答案。另外,我在这里再说一件事。在我们的例子中,这个方法,也就是当前调试的方法,是不会回滚的:而这个方法是会回滚的:也就是这两个方法在这个地方会走不同的逻辑,所以当遇到if-else调试时需要注意构建不同的case,尽可能多的覆盖代码逻辑。继续调试,会进入标注①的rollbackOn方法,来到这个方法:org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn这里隐藏了问题的最终答案,里面的代码逻辑相对比较大约。核心逻辑是循环rollbackRules,里面包含了我们在代码中配置的回滚规则,在循环体中取ex,也就是我们程序抛出的异常,匹配规则,最后选出一个赢家:如果赢家is如果为空,则遵循默认逻辑。如果是RuntimeException或Error的子类,则需要回滚:如果有赢家,判断赢家是否为不需要回滚的配置。问题是:赢家是怎么来的?答案隐藏在这个递归调用中:一句话描述就是:看当前抛出的异常和配置规则中的rollbackFor或noRollbackFor哪个更接近。这里的距离指的是父类和子类的关系。例如,这是相同的情况:我们抛出一个RuntimeException,它是0来自noRollbackFor=RuntimeException.class。RuntimeException是Exception的子类,所以距离rollbackFor=Exception.class是1。所以,赢家是noRollbackFor,明白吗?那么,我们再来看这个案例:根据前面的“距离”分析,NullPointerException是RuntimeException的子类,它们之间的距离为1。而NullPointerException到Exception的距离是2:所以,rollbackFor=RuntimeException.class的距离更短,所以赢家是rollbackFor。把赢家放在这个判断中,return为真:return!(NoRollbackRuleAttribute的获胜者实例);所以,这就是它会回滚的原因:嗯,这里可能你晕了,晕是对的。去debug这部分代码,自己摸一摸,就明白了。最后,关于“日志记录”方法的补丁。之前说过,如果将日志级别调整为Debug,可能会有意想不到的发现。现在,我再告诉你一件事,如果Debug找不到信息,你可以尝试调整为trace:logging.level.root=trace比如我们调整为trace时,可以看到“谁是赢家”?”这样的信息:当然,trace级别的日志更多。所以,来,和我一起大声朗读一遍:啃源码的过程一定很枯燥,尤其是在啃你接触不多的框架源码的时候,有太多东西让你必须开始,所以你必须耐得住寂寞。之前的作业我主要是想教大家一个阅读源码时寻找突破点的技巧。这个突破点,说白了,就是第一个有效断点应该打到的地方。@Cacheable和@Async都可以通过我之前教的方法来理解。因为它们的底层逻辑和@Transactional是一样的。所以,现在分配两个作业。有了这组组合,让我们开始使用@Cacheable和@Async,并开发我们自己的方法。@Cacheable:@Async:最后附上我之前写的几篇文章,也是用上面提到的方法定位源码,很舒服。有兴趣的可以看看:《我是真没想到,这个面试题居然从11年前就开始讨论了,而官方今年才表态。》《确实很优雅,所以我要扯下这个注解的神秘面纱。》《关于Request复用的那点破事儿。研究明白了,给你汇报一下。》《千万千万不要在方法上打断点!太坑了!》好了,这篇文章就到这里。觉得对自己有点帮助就求个免费点赞,是不是过分了点?
