关于后端:扒一扒Retryable注解很优雅有点意思

44次阅读

共计 10610 个字符,预计需要花费 27 分钟才能阅读完成。

你好呀,我是歪歪。

前几天我 Review 代码的时候发现我的项目外面有一坨逻辑写的十分的不好,一眼望去几乎就是俊俏之极。

我都不晓得为什么会有这样的代码存在我的项目外面,于是我看了一眼提交记录筹备叫对应的共事问问,为什么会写出这样的代码。

而后 …

那一坨代码是我 2019 年的时候提交的。

我细细的思考了一下,过后如同因为对我的项目不相熟,而后其余的我的项目外面又有一个相似的性能,我就间接 CV 大法搞过来了,外面的逻辑也没细看。

嗯,原来是历史起因,能够了解,能够了解。

代码外面次要就是一大坨重试的逻辑,各种硬编码,各种辣眼睛的补丁。

特地是针对重试的逻辑,到处都有。所以我决定用一个重试组件优化一波。

明天就带大家卷一下 Spring-retry 这个组件。

俊俏的代码

先简略的说一下俊俏的代码大略长什么样子吧。

给你一个场景,假如你负责领取服务,须要对接内部的一个渠道,调用他们的订单查问接口。

他们给你说:因为网络问题,如果咱们之间交互超时了,你没有收到我的任何响应,那么依照约定你能够对这个接口发动三次重试,三次之后还是没有响应,那就应该是有问题了,你们依照异样流程解决就行。

假如你不晓得 Spring-retry 这个组件,那么你大概率会写出这样的代码:

逻辑很简略嘛,就是搞个 for 循环,而后异样了就发动重试,并对重试次数进行查看。

而后搞个接口来调用一下:

发动调用之后,日志的输入是这样的,高深莫测,十分清晰:

失常调用一次,重试三次,一共能够调用 4 次。在第五次调用的时候抛出异样。

完全符合需要,自测也实现了,能够间接提交代码,交给测试同学了。

十分完满,然而你有没有想过,这样的代码其实十分的不优雅。

你想,如果再来几个相似的“超时之后能够发动几次重试”需要。

那你这个 for 循环是不是失去处的搬来搬去。就像是这样似的,俊俏不堪:

实话实说,我以前也写过这样的丑代码。

然而我当初是一个有代码洁癖的人,这样的代码必定是不能忍的。

重试应该是一个工具类一样的通用办法,是能够抽离进去的,剥离到业务代码之外,开发的时候咱们只须要关注业务代码写的巴巴适适就行了。

那么怎么抽离呢?

你说巧不巧,我明天给你分享这个的货色,就把重试性能抽离的十分的好:

https://github.com/spring-pro…

用上 spring-retry 之后,咱们下面的代码就变成了这样:

只是加上了一个 @Retryable 注解,这玩意几乎简略到令人发指。

一眼望去,十分的优雅!

所以,我决定带大家扒一扒这个注解。看看他人是怎么把“重试”这个性能抽离成一个组件的,这比写业务代码有意思。

我这篇文章不会教大家怎么去应用 spring-retry,它的性能十分的丰盛,写用法的文章曾经十分多了。我想写的是,当我会应用它之后,我是怎么通过源码的形式去理解它的。

怎么把它从一个只会用的货色,变成简历上的那一句:翻阅过相干源码。

然而你要压根都不会用,都没听过这个组件怎么办呢?

没关系,我理解一个技术点的第一步,肯定是先搭建出一个非常简单的 Demo。

没有跑过 Demo 的一律当做无所不知解决。

先搭 Demo

我最开始也是对这个注解无所不知的。

所以,对于这种状况,废话少说,先搞个 Demo 跑起来才是王道。

然而你记住搭建 Demo 也是有技巧的:间接去官网或者 github 上找就行了,那外面有最权威的、最简洁的 Demo。

比方 spring-retry 的 github 上的 Quick Start 就十分简洁易懂。

它别离提供了注解式开发和编程式开发的示例。

咱们这里次要看它的注解式开发案例:

外面波及到三个注解:

  • @EnableRetry:加在启动类上,示意反对重试性能。
  • @Retryable:加在办法上,就会给这个办法赋能,让它有用重试的性能。
  • @Recover:重试实现后还是不胜利的状况下,会执行被这个注解润饰的办法。

看完 git 上的 Quick Start 之后,我很快就搭了一个 Demo 进去。

如果你之前不理解这个组件的应用办法的话,我强烈建议你也搭一个,十分的简略。

首先是引入 maven 依赖:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
    <version>1.3.1</version>
</dependency>

因为该组件是依赖于 AOP 给你的,所以还须要引入这个依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    <version>2.6.1</version>
</dependency>

而后是代码,就这么一点,就够够的了:

最初把我的项目跑起来,调用一笔,的确是失效了,执行了 @Recover 润饰的办法:

然而日志就只有一行,也没有看到重试的操作,未免有点太简陋了吧?

我以前感觉无所谓,急不可待的冲到源码外面去一顿狂翻,左看右看。

我是怎么去狂翻源码做呢?

就是间接看这个注解被调用的中央,就像是这样:

调用的中央不多,的确也很容易就定位到上面这个要害的类:

org.springframework.retry.annotation.AnnotationAwareRetryOperationsInterceptor

而后在相应的地位打上断点,开始跑程序,进行 debug:

然而我当初不会这么猴急了,作为一个老程序员,当初就成熟了很多,不会先急着去卷源码,会先多从日志外面开掘一点货色进去。

我当初遇到这个问题的第一反馈就是调整日志级别到 debug:

logging.level.root=debug

批改日志级别重启并再次调用之后,就能看到很多有价值的日志了:

基于日志,能够间接找到这个中央:

org.springframework.retry.support.RetryTemplate#doExecute

在这里打上断点进行调试,才是最合适的中央。

这也算是一个调试小技巧吧。 以前我常常疏忽日志外面的输入,感觉一大坨难得去看,其实认真去剖析日志之后你会发现这外面有十分多的有价值的货色,比你一头扎到源码外面无效多了。

你要是不信,你能够去试着看一下 Spring 事务相干的 debug 日志,我感觉那是一个十分好的案例,打印的那叫一个清晰。

从日志就能推动你不同隔离级别下的 debug 的过程,还能放弃清晰的链路,不会有杂乱无序的感觉。

好了,不扯远了。

咱们再看看这个日志,这个输入你不感觉很相熟吗?

这不和刚刚咱们后面呈现的一张图片神似吗?

看到这里一丝笑容浮现在我的嘴角:小样,我盲猜你源码外面必定也写了一个 for 循环。如果循环外面抛出异样,那么就检测是否满足重试条件,如果满足则持续重试。不满足,则执行 @Recover 的逻辑。

要是猜错了,我间接把电脑屏幕给吃了。

好,flag 先立在这里了,接下来咱们去撸源码。

等等,先停一下。

如果说咱们后面找到了 Debug 第一个断点打的地位,那么真正进入源码调试之前,还有一个十分要害的操作,那就是我之前一再强调的,肯定要带着比拟具体的问题去翻源码。

而我后面立下的 flag 其实就是我的问题:我先给出一个猜测,再去找它是不是这样实现的,具体到代码上是怎么实现。

所以再梳理了一下我的问题:

  • 1. 找到它的 for 循环在哪里。
  • 2. 它是怎么判断应该要重试的?
  • 3. 它是怎么执行到 @Recover 逻辑的?

当初能够开始发车了。

翻源码

源码之下无机密。

首先咱们看一下后面找到的 Debug 入口:

org.springframework.retry.support.RetryTemplate#doExecute

从日志外面能够直观的看出,这个办法外面必定就蕴含我要找的 for 循环。

然而 …

很遗憾,并不是 for 循环,而是一个 while 循环。问题不大,意思差不多:

打上断点,而后把我的项目跑起来,跑到断点的中央我最关怀的是上面的调用堆栈:

被框起来了两局部,一部分是 spring-aop 包外面的内容,一部分是 spring-retry。

而后咱们看到 spring-retry 相干的第一个办法:

祝贺你,如果说后面通过日志找到了第一个打断点的地位,那么通过第一个断点的调用堆栈,咱们找到了整个 retry 最开始的入口处,另外一个断点就应该打在上面这个办法的入口处:

org.springframework.retry.annotation.AnnotationAwareRetryOperationsInterceptor#invoke

说真的,察看日志加调用栈这个最简略的组合拳用好了,调试绝大部分源码的过程中都不会感觉特地的乱。

找到了入口了,咱们就从接口处接着看源码。

这个 invoke 办法一进来首先是试着从缓存中获取该办法是否之前被胜利解析过,如果缓存中没有则解析以后调用的办法上是否有 @Retryable 注解。

如果是被 @Retryable 润饰的,返回的 delegate 对象则不会是 null。所以会走到 retry 包的代码逻辑中去。

而后在 invoke 这里有个小细节,如果 recoverer 对象不为空,则执行带回调的。如果为空则执行没有 recoverCallback 对象办法。

我看到这几行代码的时候就大胆猜想:@Recover 注解并不是必须的。

于是我兴奋的把这个办法注解掉并再次运行我的项目,发现还真是,有点不一样了:

在我没有看其余文章、没有看官网介绍,仅通过一个简略的示例就挖掘到他的一个用法之后,这属于意外播种,也是看源码的一点小乐趣。

其实源码并没有那么可怕的。

然而看到这里的时候另外一个问题就随之而来了:

这个 recoverer 对象看起来就是我写的 channelNotResp 办法,然而它是在什么时候解析到的呢?

按下不表,前面再说,事不宜迟是找到重试的中央。

在以后的这个办法中再往下走几步,很快就能到我后面说的 while 循环中来:

次要关注这个 canRetry 办法:

org.springframework.retry.RetryPolicy#canRetry

点进去之后,发现是一个接口,领有多个实现:

简略的介绍一下其中的几种含意是啥:

  • AlwaysRetryPolicy:容许有限重试,直到胜利,此形式逻辑不当会导致死循环
  • NeverRetryPolicy:只容许调用 RetryCallback 一次,不容许重试
  • SimpleRetryPolicy:固定次数重试策略,默认重试最大次数为 3 次,RetryTemplate 默认应用的策略
  • TimeoutRetryPolicy:超时工夫重试策略,默认超时工夫为 1 秒,在指定的超时工夫内容许重试
  • ExceptionClassifierRetryPolicy:设置不同异样的重试策略,相似组合重试策略,区别在于这里只辨别不同异样的重试
  • CircuitBreakerRetryPolicy:有熔断性能的重试策略,需设置 3 个参数 openTimeout、resetTimeout 和 delegate
  • CompositeRetryPolicy:组合重试策略,有两种组合形式,乐观组合重试策略是指只有有一个策略容许即能够重试,乐观组合重试策略是指只有有一个策略不容许即不能够重试,但不论哪种组合形式,组合中的每一个策略都会执行

那么这里问题又来了,咱们调试源码的时候这么有多实现,我怎么晓得应该进入哪个办法呢?

记住了,接口的办法上也是能够打断点的。你不晓得会用哪个实现,然而 idea 晓得:

这里就是用的 SimpleRetryPolicy 策略,即这个策略是 Spring-retry 的默认重试策略。

t == null || retryForException(t)) && context.getRetryCount() < this.maxAttempts

这个策略的逻辑也非常简单:

  • 1. 如果有异样,则执行 retryForException 办法,判断该异样是否能够进行重试。
  • 2. 判断以后已重试次数是否超过最大次数。

在这里,咱们找到了管制重试逻辑的中央。

下面的第二点很好了解,第一点阐明这个注解和事务注解 @Transaction 一样,是能够对指定异样进行解决的,能够看一眼它反对的选项:

留神 include 外面有句话我标注了起来,意思是说,这个值默认为空。且当 exclude 也为空时,默认是所有异样。

所以 Demo 外面尽管什么都没配,然而抛出 TimeoutException 也会触发重试逻辑。

又是一个通过翻源码开掘到的知识点,这玩意就像是摸索彩蛋似的,难受。

看完判断是否能进行重试调用的逻辑之后,咱们接着看一下真正执行业务办法的中央:

org.springframework.retry.RetryCallback#doWithRetry

一眼就能看进去了,这外面就是应该十分相熟的动静代理机制,这里的 invocation 就是咱们的 callChannel 办法:

从代码咱们晓得,callChannel 办法抛出的异样,在 doWithRetry 办法外面会进行捕捉,而后间接扔出去:

这里其实也很好了解的,因为须要抛出异样来触发下一次的重试。

然而这里也裸露了一个 Spring-retry 的弊病,就是必须要通过抛出异样的形式来触发相干业务。

听着如同也是没有故障,然而你想想一下,假如渠道方说如果我给你返回一个 500 的 ErrorCode,那么你也能够进行重试。

这样的业务场景应该也是比拟多的。

如果你要用 Spring-retry 会怎么做?

是不是得写出这样的代码:

if(errorCode==500){throw new Exception("手动抛出异样");
}

意思就是通过抛出异样的形式来触发重试逻辑,算是一个不是特地优雅的设计吧。

其实依据返回对象中的某个属性来判断是否须要重试对于这个框架来说扩大起来也不算很难的事件。

你想,它这里原本就能拿到返回。只须要提供一个配置的入口,让咱们通知它当哪个对象的哪个字段为某个值的时候也应该进行重试。

当然了,大佬必定有本人的想法,我这里都是一些不成熟的高见而已。其实另外的一个重试框架 Guava-Retry,它就反对依据返回值进行重试。

不是本文重点就不扩大了。

接着往下看 while 循环中捕捉异样的局部。

外面的逻辑也不简单,然而上面框起来的局部能够留神一下:

这里又判断了一次是否能够重试,是干啥呢?

是为了执行这行代码:

backOffPolicy.backOff(backOffContext);

它是干啥的?

我也不晓得,debug 看一眼,最初会走到这个中央:

org.springframework.retry.backoff.ThreadWaitSleeper#sleep

在这里执行睡眠 1000ms 的操作。

我一下就懂了,这玩意在这里给你留了个抓手,你能够设置重试间隔时间的抓手。而后默认给你赋能 1000ms 后重试的性能。

而后我在 @Retryable 注解外面找到了这个货色:

这玩意一眼看不懂是怎么配置的,然而它下面的注解叫我看看 Backoff 这个玩意。

它长这样:

这货色看起来就好了解多了,先不论其余的参数吧,至多我看到了 value 的默认值是 1000。

我狐疑就是这个参数管制的指定重试距离,所以我试了一下:

果然是你小子,又让我挖到一个彩蛋。

在 @Backoff 外面,除了 value 参数,还有很多其余的参数,他们的含意别离是这样的:

  • delay:重试之间的等待时间 (以毫秒为单位)
  • maxDelay:重试之间的最大等待时间 (以毫秒为单位)
  • multiplier:指定提早的倍数
  • delayExpression:重试之间的等待时间表达式
  • maxDelayExpression:重试之间的最大等待时间表达式
  • multiplierExpression:指定提早的倍数表达式
  • random:随机指定延迟时间

就不一一给你演示了,有趣味本人玩去吧。

因为丰盛的重试工夫配置策略,所以也依据不同的策略写了不同的实现:

通过 Debug 我晓得了默认的实现是 FixedBackOffPolicy。

其余的实现就不去细钻研了,我次要是抓次要链路,先把整个流程买通,之后本人玩的时候再去看这些枝干的局部。

在 Demo 的场景下,期待一秒钟之后再次发动重试,就又会再次走一遍 while 循环,重试的主链路就这样梳理分明了。

其实我把代码折叠一下,你能够看到就是在 while 循环外面套了一个 try-catch 代码块而已:

这和咱们之前写的丑代码的骨架是一样的,只是 Spring-retry 把这部分代码进行裁减并且藏起来了,只给你提供一个注解。

当你只拿到这个注解的时候,你把它当做一个黑盒用的时候会惊呼:这玩意真牛啊。

然而当初当你抽丝剥茧的翻一下源码之后,你就会说:就这?不过如此,我感觉也能写进去啊。

到这里后面抛出的问题中的前两个曾经比拟清晰了:

问题一:找到它的 for 循环在哪里。

没有 for 循环,然而有个 while 循环,其中有一个 try-catch。

问题二:它是怎么判断应该要重试的?

判断要触发重试机制的逻辑还是非常简单的,就是通过抛出异样的形式触发。

然而真的要不要执行重试,才是一个须要仔细分析的重点。

Spring-retry 有十分多的重试策略,默认是 SimpleRetryPolicy,重试次数为 3 次。

然而须要特地留神的是它这个“3 次”是总调用次数为三次。而不是第一次调用失败后再调用三次,这样就共计 4 次了。对于到底调用几次的问题,还是得分分明才行。

而且也不肯定是抛出了异样就必定会重试,因为 Spring-retry 是反对对指定异样进行解决或者不解决的。

可配置化,这是一个组件应该具备的根底能力。

还是剩下最初一个问题:它是怎么执行到 @Recover 逻辑的?

接着怼源码吧。

Recover 逻辑

首先要阐明的是 @Recover 注解并不是一个必须要有的货色,后面咱们也剖析了,就不再赘述。

然而这个性能用起来的确是不错的,绝大部分异样都应该有对应的兜底措施。

这个货色,就是来执行兜底的动作的。

它的源码也非常容易找到,就紧跟在重试逻辑之后:

往下 Debug 几步你就会走到这个中央来:

org.springframework.retry.annotation.RecoverAnnotationRecoveryHandler#recover

又是一个反射调用,这里的 method 曾经是 channelNotResp 办法了。

那么问题就来了:Spring-retry 是怎么晓得我的重试办法就是 channelNotResp 的呢?

认真看下面的截图中的 method 对象,不难发现它是办法的第一行代码产生的:

Method method = findClosestMatch(args, cause.getClass());

这个办法从名字和返回值上看叫做找一个最相近的办法。然而具体不太明确啥意思。

跟进去看一眼它在干啥:

这个外面有两个要害的信息,一个叫做 recoverMethodName,当这个值为空和不为空的时候走的是两个不同的分支。

还有一个参数是 methods,这是一个 HashMap:

这个 Map 外面放的就是咱们的兜底办法 channelNotResp:

而这个 Map 不论是走哪个分支都是须要进行遍历的。

这个 Map 外面的 channelNotResp 是什么时候放进去的呢?

很简略,看一下这个 Map 的 put 办法调用的中央就完事了:

就这两个 put 的中央,源码位于下面这个办法中:

org.springframework.retry.annotation.RecoverAnnotationRecoveryHandler#init

从截图中能够看出,这里是在找 class 外面有没有被 @Recover 注解润饰的办法。

我在第 172 行打上断点,调试一下看一下具体的信息,你就晓得这里是在干什么了。

在你发动调用之后,程序会在断点处停下,至于是怎么走到这里的,后面说过,看调用堆栈,就不再赘述了。

对于这个 doWith 办法,咱们把调用堆栈往上看一步,就晓得这里是在解析咱们的 RetryService 类外面的所有办法:

当解析到 channelNotResp 办法的时候,会辨认出该办法上标注了 @Recover 注解。

但从源码上看,要进行进一步解析,要满足 if 条件。而 if 条件除了要有 Recover 之外,还须要满足这个货色:

method.getReturnType().isAssignableFrom(failingMethod.getReturnType())

isAssignableFrom 办法是判断是否为某个类的父类。

就是的 method 和 failingMethod 别离如下:

这是在查看被 @Retryable 标注的办法和被 @Recover 标注的办法的返回值是否匹配,只有返回值匹配才阐明这是一对,应该进行解析。

比方,我把源码改成这样:

当它解析到 channelNotRespStr 办法的时候,会发现尽管被 @Recover 注解润饰了,然而返回值并不统一,从而晓得它并不是指标办法 callChannel 的兜底办法。

源码外面的惯例套路罢了。

再退出一个 callChannelSrt 办法,在下面的源码中 Spring-retry 就能帮你解析出谁和谁是一对:

接着看一下如果满足条件,匹配上了,if 外面在干啥呢?

这是在获取办法上的入参呀,然而认真一看,也只是为了获取第一个参数,且这个参数要满足一个条件:

Throwable.class.isAssignableFrom(parameterTypes[0])

必须是 Throwable 的子类,也就说说它必须是一个异样。用 type 字段来承接,而后上面会把它给存起来。

第一次看的时候必定没看懂这是在干啥,没关系,我看了几次看明确了,给你分享一下,这里是为了这一大节最开始呈现的这个办法服务的:

在这外面获取了这个 type,判断如果 type 为 null 则默认为 Throwable.class。

如果有值,就判断这里的 type 是不是以后程序抛出的这个 cause 的同类或者父类。

再强调一遍,从这个办法从名字和返回值上看,咱们晓得是要找一个最相近的办法,后面我说具体不太明确啥意思都是为了给你铺垫了一大堆 methods 这个 Map 是怎么来的。

其实我心里明镜儿似的,早就想扯下它的面纱了。

来,跟着我的思路马上就能看到葫芦里到底卖的是什么酒了。

你想,findClosestMatch,这个 Closest 是 Close 的最高级,示意最靠近的意思。

既然有最靠近,那么必定是有几个货色放在一起,这外面只有一个是最符合要求的。

在源码中,这个要求就是“cause”,就是以后抛出的异样。

而“几个货色”指的就是这个 methods 装的货色外面的 type 属性。

还是有点晕,对不对,别慌,上面这张图片一进去,马上就不晕了:

拿这个代码去套“Closest”这个玩意。

首先,cause 就是抛出的 TimeoutException。

而 methods 这个 Map 外面装的就是三个被 @Recover 注解润饰的办法。

为什么有三个?

好问题,阐明我后面写的很烂,导致你看的不太明确。没事,我再给你看看往 methods 外面 put 货色的局部的代码:

这三个办法都满足被 @Recover 注解的条件,且同时也满足返回值和指标办法 callChannel 的返回值统一的条件。那就都得往 methods 外面 put,所以是三个。

这里也解释了为什么兜底办法是用一个 Map 装着呢?

我最开始感觉这是“兜底办法”的兜底策略,因为永远要把用户当做那啥,你不晓得它会写出什么神奇的代码。

比方我下面的例子,其实最初失效的肯定是这个办法:

@Recover
public void channelNotResp(TimeoutException timeoutException) throws Exception {log.info("3. 没有获取到渠道的返回信息, 发送预警!");
}

因为它是 Closest。

给你截个图,示意我没有乱说:

然而,校稿的时候我发现这个中央不对,并不是用户那啥,而是真的有可能会呈现一个 @Retryable 润饰的办法,针对不同的异样有不同的兜底办法的。

比方上面这样:

当 num=1 的时候,触发的是超时兜底策略,日志是这样的:

http://localhost:8080/callCha…

当 num>1 的时候,触发的是空指针兜底策略,日志是这样的:

妙啊,真的是妙不可言啊。

看到这里我感觉对于 Spring-retry 这个组件算是入门了,有了一个根本的把握,对于骨干流程是摸的个七七八八,简历上能够用“把握”了。

后续只须要把大的枝干处和细节处都摸一摸,就能够把“把握”批改为“相熟”了。

有点瑕疵

最初,再补充一个有点瑕疵的货色。

再看一下它解决 @Recover 的办法这里,只是对办法的返回值进行了解决:

我过后看到这里的第一眼的时候就觉不对劲,少了对一种状况的判断,那就是:泛型。

比方我搞个这玩意:

按理来说我心愿的兜底策略是 channelNotRespInt 办法。

然而执行之后你就会发现,是有肯定几率选到 channelNotRespStr 办法的:

这玩意不对啊,我明明想要的是 channelNotRespInt 办法来兜底呀,为什么没有选正确呢?

因为泛型信息曾经没啦,老铁:

假如咱们要反对泛型呢?

从 github 上的形容来看,目前作者曾经开始着力于这个办法的钻研了:

从 1.3.2 版本之后会反对泛型的。

然而目前 maven 仓库外面最高的版本还是在 1.3.1:

想看代码怎么办?

只有把源码拉下来看一眼了。

间接看这个类的提交记录:

org.springframework.retry.annotation.RecoverAnnotationRecoveryHandler

能够看到判断条件产生了变动,减少了对于泛型的解决。

我这里就是指个路,你要是有趣味去钻研就把源码拉下来看一下。具体是怎么实现的我就不写了,写的太长了也没人看,先留个坑在这里吧。

次要是写到这里的时候女朋友催着我去打乒乓球了。她属于是人菜瘾大的那种,昨天才把她给教会,明天竟然扬言要打我个 11-0,看我不好好的削她一顿,杀她个片甲不留。

本文已收录至集体博客,外面全是优质原创,欢送大家来瞅瞅:

https://www.whywhy.vip/

正文完
 0