开源重试组件Attempt

Posted by Jason Lee on 2022-01-20

开源

想做开源软件很久了,奈何一直没有启动起来。一来是没有契机,二来也没有想法。2020年由于一些变故,我从某团离职后来到了新公司。负责一个To B业务, 由于并发量和我以前负责的项目没法比,同时又是个to B的项目,因此
省去了火急火燎的去处理线上问题的时间。因此,更多的时间我拿来思考和总结。

同时也参加了软件架构师的培训中。虽然各大互联网企业对软考证书似乎并不care,但是我的目的不仅仅如此,是想通过宏观、系统的学习,重新将这个架构行业最精髓的理论知识重新拾起来(大学光顾着玩了)。

思考有时候让人着迷。将思考融入设计,然后付诸实现,这是意见多么让人兴奋的事情。每每看到有优秀的开源组件问世,我都会无比的激动。看着这些莫名又有点兴奋的代码,放佛同作者进行探讨,那一刻,我们不仅仅是程序员,还是一个设计师,一个先驱者。

技术真的能改变世界。怀着对技术崇敬和感恩之心,我步履蹒跚的追逐先驱们的身影,愿我辈豪杰辈出,生生不息。

想法

Attempt是一个很小的重试组件,与市面上的一些成熟的轮子相比也许不值得一体,但是由于是0-1的开阔,因此对本人的意义非凡。其他的就不扯淡了,作为第一个我的开源组件和本木西笔记的第一个技术推送,下面就Attempt的一些功能和设计做一个简要的说明。

本文就不对 Attempt 组件做详细的剖析,感兴趣的可以去clone下代码阅读,也欢迎 start 和 discuss。

Attempt简介

Attempt 是对标 SpringRetryGuavaRetry 轮子的一个轻量的不能在轻量的组件。通过Attempt 你可以轻易将一个方法调用变成一个具有异常重试的调用。同时Attempt 支持带有重试的轮询策略,让你的轮询任务更加的稳定。

Quick Start

增加依赖

1
2
3
4
5
<dependency>
<groupId>io.github.icefrozen</groupId>
<artifactId>Attempt</artifactId>
<version>0.1.0</version>
</dependency>

代理方式构建重试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

public class UserService {
public int count = 0;
public User queryUser (int id) {
count ++;
throw new RuntimeException("queryUser error");
}

public static void main(String[] args) {
UserService userService = new UserService();
AttemptBuilder.Retry<UserService> userRetry = new AttemptBuilder.Retry<UserService>(userService);
UserService userServiceAttempt = userRetry.build();
try {
User user = userServiceAttempt.queryUser(1);
} catch (RuntimeException e) {
System.out.println(e.getMessage()); // queryUser 发生异常
// 注意这里拿到是 userService原始对象
System.out.println(userService.count); //原始方法已经调用3
}
}
}

可以看到,当我们执行 userServiceAttempt 代理类的时候,遇到异常会自动重试三次。 如果三次还是失败,则将异常抛出。 因此count为3。

AttemptBuilder 可以使得对象中的成员方法有重试的行为,那么我们如何对静态方法赋予重试的功能呢? 或者说,我重试某一类方法或者一个静态类如何做呢?

使用匿名函数构建静态方法重试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserService {
public static int staticCount = 0;
public static User queryUserStatic () {
staticCount ++;
throw new RuntimeException("queryUser error");
}

public static void main(String[] args) {
UserService userService = new UserService();
try {
AttemptBuilder.retry(
() -> UserService.queryUserStatic())
.exec(); // count = 3
} catch (RuntimeException e) {
// ... staticCount > 3 之后,抛出异常
}
}
}

轮询策略

假设有这么一个场景,你上传了一个任务,服务方并不支持异步回调或者消息队列的方式通知你任务否执行完毕,那么你需要一个轮询策略,用于知道该任务的情况。为了稳定性,你需要满足一下几个特点:

  • 如果查询进度的过程中失败了,那么需要为了让任务进行下去,必须要进行一个重试,比如说遇到了超时。

  • 如果超时3(重试最大次数暂定为3次)次,那么直接报错,返回失败。

  • 如果超时没有超过3次(重试最大次数暂定为3次)在重试期间,网络恢复了,那么要要清除重试3次的历史,否则下次遇到超时的时候就会失败。

根据上面的场景Attempt可以很容易构建出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 任务服务
TaskService taskService = new TaskService()

AttemptBuilder.Polling<TaskService> taskServicePollBuilder = new AttemptBuilder.Polling<>(taskService);

// 设定轮询停止条件
TaskService taskServicePoll = taskServicePollBuilder.endPoint(context -> {
// 获取上一次结果
AttemptResult result = context.getLastResult();
if (result.isSuccess()) {
Integer progress = result.getRetValue(Integer.class);
return progress == 100; // progress < 100 poll continue
}
return false;
})
// 设置轮询最大次数 和异常类型
.maxPollCount(100)
.registerExceptionRetryTime(Exception.class, 3)
.build();

// 执行调用
Integer integer = taskServicePoll.queryProgress();

Attempt架构

Attempt 设计初衷就是想摒弃大段大段的繁琐依赖,将重试性能压榨到极致,因此 Attempt 并没有引入过多的依赖,能手写的尽量不去依赖,设计能简单的就尽量不去复杂问题。简单的设计也必将会单来更为可靠的性能。

轻量级

Attempt 实际依赖一共有以下三个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>

<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>${cglib.version}</version>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>

如果你想用 retry 功能,又不想引入 Spring 和Guava这个庞大的体系,Attempt是一个不错的选择。

可扩展

Attempt,但是层次清晰,预留了足够的扩展可能。Attempt总体架构如下图:

  • AttemptInvoker

AttemptInvoker 用于执行一些静态方法,其中包括一个 ThrowSafetyFunctionInvoke 用于不会让人恶心的异常,当我们使用lamda表达式去重试一些静态变量的时候,可以帮我们捕获异常。

ThrowSafetyFunctionInvoke 就是一个扩展了

注解的类。并且捕捉了**Exception**
1
2
3
4
5
6

```java
@FunctionalInterface
public interface ThrowSafetyFunctionInvokerSupplier<T> extends ThrowSafetyFunctionInvoker<T> {
T get() throws Exception;
}

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StaticMethodThrowExceptionBean {
public static Object throwException() throws Exception {
count++;
throw new Exception("StaticMethodThrowExceptionBean's throwException");
}
}
// 你可以这样写
AttemptBuilder.retry(StaticMethodThrowExceptionBean::throwException).exec();

// 而不是恶心cat exception
AttemptBuilder.retry(()-> {
try {
StaticMethodThrowExceptionBean.throwException);
} catch (Exception e) {}
}).exec();
  • Attempt

AttemptInvoker 下封装了Attempt, 在代理方式构建方法中,Attempt 保存着我们的原始对象和构建对象。同时封装了 AttemptExecutor 执行器。

  • AttemptExecutor

执行器是执行 Attempt 主要方法,其本质是一个切面函数,将用户的调用方法进行动态拦截。策略如下图:

  • Executor首先调用start 方法,之后询问策略是否结束,策略会根据context山下文找到调用的历史和约束,例如次数,异常次数等。
  • 如果此次调用满足继续,则进行真正方法调用。在调用真正方法的时候,无论是否异常,均会封装成一个 AttemptResult 对象并记录。
  • 当策略判定此次调用结束之后,会进行状态的清理,和异常的抛出。如果我们置顶不抛出异常,则会返回一个默认的值

AttemptExecutor 是具体的执行策略,也是切面方法。AttemptExecutor中有三大组件。

  • Stategy (Attempt策略)

    stategy 是具体的重试策略,他是一个接口,用于实现各种Attempt策略,目前Attempt默认提供两种Attempt策略,分笔试 Retry 和 Polling 两种策略。 Polling 是基于Retry 策略的扩展,是带有重试的轮询策略。 Retry 就是我们的主要策略。 接口如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public interface AttemptStrategy {
    //1、是否结束
    boolean isEnd(AttemptContext context);
    // 一次调用声明周期结束后 true 进入循环,false 返回
    boolean back(AttemptResult record, AttemptContext context);
    // 策略名称
    String name();
    // 获取属性集合
    BaseAttemptPropertyViewer<? extends BaseAttemptPropertyViewer<?>> properties();
    // 获取状态
    AttemptStatus status();
    // 设置状态
    AttemptStatus status(AttemptStatus status);
    }

当调用结束之后,如果调用失败,将要进入回退中,所谓回退,就是调用之后休息多久再次调用,Attempt 提供了一下几种回退策略

  • NoBackOffPolicy 空回退,不进行任何回退,立刻下次重试。
  • FixedSleepingBackOffPolicy 睡眠固定毫秒数的重试
  • RangeDescSleepingBackOffPolicy 睡眠时间按照范围递减的策略
  • SleepingBackOffPolicy 一般睡眠回退策略,提供了初始值,递减参数和最大值。

具体是实现细节,这里不再展开。

  • Context (Attempt上下文)

Context 上下文记录这本次重试的历史,包括耗时,次数,异常次数等。他提供接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface AttemptContext {
AttemptResult getLastResult();
int record(AttemptResult record);
int record(Class<? extends Throwable> e);
void reset();
void clean();
AttemptResult getResult();
int getExecuteCount();
int getExceptionCount(Throwable t);
default int getExceptionCount() {
return getExceptionCount(null);
}
List<AttemptResult> getResults();
}

对于每次重试,Attempt 会拦截调用结果或者捕获异常,并封装成一个 AttemptResult 加入到一个容器中。

AttemptContext 的具体实现中 ResultContext 类中,增加了 AttemptResultContainer 容器,用于记录每次调用的 AttemptResult 结果。 当然,我们可以给AttemptResultContainer 一个很大的值,这就以为我们可以保存更多的 中间结果, 除非你知道你自己在做什么,否则这是不推荐的。

此外,AttemptContext 还要提供重试次数,调用次数,异常情况等诸多统计信息,供给 AttemptStrategy 使用,用于判断此次 AttemptExecutor 是否可以继续执行。

  • Listener (监听器)

Listener 贯穿于整个 Attempt 的生命周期,包括 InvokeListenerExecutorListener 两种类型,分别是环绕代理方法和Executor 的这个声明周期,提供一些扩展。比如Timeout 机制,就是在Listener里面扩展的。

Timeout机制目前是不完善的,如果目标方法sleep了,那么将无法报出超时异常,这一点在后续的版本中会优化。

性能高

既然是对标 Attempt 是对标 SpringRetryGuavaRetry, 难免需要进行对比。我采用的JMH的方法测试三者之间的性能。

在遇到异常重试300次的情况下,数据如下:

Benchmark Mode Cnt Score Error Units
AttemptVsSpringRetry.testAttempt avgt 10 165.921 ± 26.558 ns/op
AttemptVsSpringRetry.testGuavaRetry avgt 10 909259.747 ± 323278.426 ns/op
AttemptVsSpringRetry.testSpringRetry avgt 10 50681.819 ± 2848.606 ns/op

公众号

我的公众号成立了,欢迎大家点赞关注。



支付宝打赏 微信打赏

赞赏一下