跳到主要内容

处理错误

Doubao 中英对照 Handling Errors

目录

提示

要快速浏览可用于错误处理的操作符,请参阅相关的操作符决策树。

在 Reactive Streams 中,错误是终止事件。一旦发生错误,它就会停止序列,并沿操作符链向下传播到最后一步——您定义的 Subscriber 及其 onError 方法。

这类错误在应用层仍应得到处理。例如,您可能会在 UI 中显示错误通知,或在 REST 端点中发送有意义的错误载荷。因此,订阅者的 onError 方法应当始终被定义。

注意

如果未定义,onError 会抛出 UnsupportedOperationException。您可以用 Exceptions.isErrorCallbackNotImplemented 方法进一步检测并分类它。

Reactor 还提供了在链中间处理错误的其他方式,即错误处理操作符。以下示例展示了如何这样做:

Flux.just(1, 2, 0)
.map(i -> "100 / " + i + " = " + (100 / i)) //this triggers an error with 0
.onErrorReturn("Divided by zero :("); // error handling example
important

在学习错误处理操作符之前,您必须记住:响应式序列中的任何错误都是终止事件。即使使用了错误处理操作符,它也不会让原始序列继续。相反,它会把 onError 信号转换为一个新序列(即回退序列)的开端。换句话说,它替换的是它上游那个已终止的序列。

现在我们可以逐一考察各种错误处理方式。在相关之处,我们会与命令式编程的 try 模式做类比。

1. 错误处理操作符​

您可能熟悉在 try-catch 块中处理异常的若干种方式。其中最主要的包括以下几种:

  • 捕获并返回一个静态默认值。

  • 捕获并用一个回退方法执行另一条路径。

  • 捕获并动态计算一个回退值。

  • 捕获、包装为 BusinessException 并重新抛出。

  • 捕获、记录一条错误专属消息并重新抛出。

  • 使用 finally 块清理资源,或使用 Java 7 的“try-with-resource”构造。

所有这些在 Reactor 中都有对应物,形式就是错误处理操作符。在考察这些操作符之前,我们首先想在响应式链与 try-catch 块之间建立一种类比。

订阅时,链末尾的 onError 回调类似于一个 catch 块。在那里,一旦抛出 Exception,执行就跳到 catch,如下例所示:

Flux<String> s = Flux.range(1, 10)
.map(v -> doSomethingDangerous(v)) 1
.map(v -> doSecondTransform(v)); 2
s.subscribe(value -> System.out.println("RECEIVED " + value), 3
error -> System.err.println("CAUGHT " + error) 4
);
  • 执行一个可能抛出异常的转换。

  • 如果一切顺利,执行第二个转换。

  • 打印每个成功转换后的值。

  • 一旦出错,序列终止并显示一条错误消息。

前面的例子在概念上类似于下面这个 try-catch 块:

try {
for (int i = 1; i < 11; i++) {
String v1 = doSomethingDangerous(i); 1
String v2 = doSecondTransform(v1); 2
System.out.println("RECEIVED " + v2);
}
} catch (Throwable t) {
System.err.println("CAUGHT " + t); 3
}
  • 如果在这里抛出异常……

  • ……循环的其余部分被跳过……

  • ……执行直接跳到这里。

建立类比之后,我们可以看看各种错误处理情形及其对应的操作符。

1.1. 静态回退值​

“捕获并返回一个静态默认值”的对应物是 onErrorReturn。以下示例展示了如何使用它:

try {
return doSomethingDangerous(10);
}
catch (Throwable error) {
return "RECOVERED";
}

以下示例展示了对应的 Reactor 写法:

Flux.just(10)
.map(this::doSomethingDangerous)
.onErrorReturn("RECOVERED");

您还可以选择对异常应用一个 Predicate,以决定是否进行恢复,如下例所示:

Flux.just(10)
.map(this::doSomethingDangerous)
.onErrorReturn(e -> e.getMessage().equals("boom10"), "recovered10"); 1
  • 仅当异常消息为 "boom10" 时才恢复

1.2. 捕获并吞噬错误​

如果您甚至不想用一个回退值替换异常,而是想忽略它、只传播迄今已产生的元素,那么您想要的本质上就是用一个 onComplete 信号替换 onError 信号。这可以通过 onErrorComplete 操作符完成:

Flux.just(10,20,30)
.map(this::doSomethingDangerousOn30)
.onErrorComplete(); 1
  • 通过把 onError 转为 onComplete 来恢复

与 onErrorReturn 一样,onErrorComplete 也有一些变体,让您可以基于异常的类或基于一个 Predicate 来筛选要回退的异常。

1.3. 回退方法​

如果您想要的不止是一个单一默认值,并且有另一种(更安全的)处理数据的方式,可以使用 onErrorResume。这相当于“捕获并用一个回退方法执行另一条路径”。

例如,如果您的常规流程是从一个外部且不可靠的服务获取数据,但您还保留了同一份数据的本地缓存——它可能稍微过时一些,但更可靠——您可以这样做:

String v1;
try {
v1 = callExternalService("key1");
}
catch (Throwable error) {
v1 = getFromCache("key1");
}

String v2;
try {
v2 = callExternalService("key2");
}
catch (Throwable error) {
v2 = getFromCache("key2");
}

以下示例展示了对应的 Reactor 写法:

Flux.just("key1", "key2")
.flatMap(k -> callExternalService(k) 1
.onErrorResume(e -> getFromCache(k)) 2
);
  • 对每个 key,异步调用外部服务。

  • 如果外部服务调用失败,则回退到该 key 的缓存。请注意,无论源错误 e 是什么,我们总是应用同一个回退。

与 onErrorReturn 一样,onErrorResume 也有一些变体,让您可以基于异常的类或基于一个 Predicate 来筛选要回退的异常。它接受一个 Function,这一点还让您可以根据遇到的错误选择要切换到哪个不同的回退序列。以下示例展示了如何这样做:

Flux.just("timeout1", "unknown", "key2")
.flatMap(k -> callExternalService(k)
.onErrorResume(error -> { 1
if (error instanceof TimeoutException) 2
return getFromCache(k);
else if (error instanceof UnknownKeyException) 3
return registerNewEntry(k, "DEFAULT");
else
return Flux.error(error); 4
})
);
  • 该函数允许动态地选择如何继续。

  • 如果源超时,则使用本地缓存。

  • 如果源提示 key 未知,则创建一个新条目。

  • 在所有其他情况下,“重新抛出”。

1.4. 动态回退值​

即使您没有另一种(更安全的)处理数据的方式,您可能仍想根据收到的异常计算出一个回退值。这相当于“捕获并动态计算一个回退值”。

例如,如果您的返回类型(MyWrapper)有一个专门用于持有异常的变体(想想 Future.complete(T success) 与 Future.completeExceptionally(Throwable error) 的区别),您可以实例化那个持有错误的变体并传入异常。

一个命令式示例如下所示:

try {
Value v = erroringMethod();
return MyWrapper.fromValue(v);
}
catch (Throwable error) {
return MyWrapper.fromError(error);
}

您可以用与回退方法方案相同的方式在响应式中这样做,即使用 onErrorResume,只需一点点样板代码,如下所示:

erroringFlux.onErrorResume(error -> Mono.just( 1
MyWrapper.fromError(error) 2
));
  • 由于您期望用一个 MyWrapper 来表示错误,您需要为 onErrorResume 提供一个 Mono<MyWrapper>。我们用 Mono.just() 来做到这一点。

  • 我们需要根据异常计算出这个值。在这里,我们通过用一个相关的 MyWrapper 工厂方法包装异常来实现。

1.5. 捕获并重新抛出​

“捕获、包装为 BusinessException 并重新抛出”在命令式世界中如下所示:

try {
return callExternalService(k);
}
catch (Throwable error) {
throw new BusinessException("oops, SLA exceeded", error);
}

在“回退方法”示例中,flatMap 内的最后一行给了我们一个提示,可以用响应式方式达到同样效果,如下所示:

Flux.just("timeout1")
.flatMap(k -> callExternalService(k))
.onErrorResume(original -> Flux.error(
new BusinessException("oops, SLA exceeded", original))
);

然而,用 onErrorMap 有一种更直接的方式达到同样效果:

Flux.just("timeout1")
.flatMap(k -> callExternalService(k))
.onErrorMap(original -> new BusinessException("oops, SLA exceeded", original));

1.6. 旁侧记录或响应​

对于那些您想让错误继续传播、但又想在不修改序列的情况下对它做出反应(例如记录日志)的情形,可以使用 doOnError 操作符。这相当于“捕获、记录一条错误专属消息并重新抛出”模式,如下例所示:

try {
return callExternalService(k);
}
catch (RuntimeException error) {
//make a record of the error
log("uh oh, falling back, service failed for key " + k);
throw error;
}

doOnError 操作符,以及所有以 doOn 为前缀的操作符,有时被称为具有“副作用”。它们让您能够窥视序列的事件而不修改它们。

与前面所示的命令式示例一样,下面的示例仍然会传播错误,但确保我们至少记录下外部服务发生了故障:

LongAdder failureStat = new LongAdder();
Flux<String> flux =
Flux.just("unknown")
.flatMap(k -> callExternalService(k) 1
.doOnError(e -> {
failureStat.increment();
log("uh oh, falling back, service failed for key " + k); 2
})
3
);
  • 那个可能失败的外部服务调用……

  • ……被加上了记录日志和统计的副作用装饰……

  • ……此后,它仍以错误终止,除非我们在这里使用错误恢复操作符。

我们还可以设想有一些统计计数器,作为第二个错误副作用来递增。

1.7. 使用资源与 finally 块​

与命令式编程做的最后一个类比是清理工作,它既可以通过“使用 finally 块清理资源”来完成,也可以通过“Java 7 try-with-resource 构造”来完成,两者如下所示:

Stats stats = new Stats();
stats.startTimer();
try {
doSomethingDangerous();
}
finally {
stats.stopTimerAndRecordTiming();
}
try (SomeAutoCloseable disposableInstance = new SomeAutoCloseable()) {
return disposableInstance.toString();
}

二者在 Reactor 中都有对应物:doFinally 和 using。

doFinally 用于您希望在序列终止(以 onComplete 或 onError)或被取消时执行的副作用。它会向您提示是哪种终止触发了该副作用。以下示例展示了如何使用 doFinally:

Stats stats = new Stats();
LongAdder statsCancel = new LongAdder();

Flux<String> flux =
Flux.just("foo", "bar")
.doOnSubscribe(s -> stats.startTimer())
.doFinally(type -> { 1
stats.stopTimerAndRecordTiming();2
if (type == SignalType.CANCEL) 3
statsCancel.increment();
})
.take(1); 4
  • doFinally 接受一个表示终止类型的 SignalType。

  • 与 finally 块类似,我们总是记录计时。

  • 这里我们还仅在取消的情况下递增统计。

  • take(1) 向上游请求恰好 1 个,并在发出一个元素后取消。

另一方面,using 处理的是这样的情形:一个 Flux 派生自某个资源,并且每当处理完成时都必须对该资源进行处置。在以下示例中,我们用一个 Disposable 替换“try-with-resource”的 AutoCloseable 接口:

AtomicBoolean isDisposed = new AtomicBoolean();
Disposable disposableInstance = new Disposable() {
@Override
public void dispose() {
isDisposed.set(true); 4
}

@Override
public String toString() {
return "DISPOSABLE";
}
};

现在我们可以在它上面做响应式版的“try-with-resource”,如下所示:

Flux<String> flux =
Flux.using(
() -> disposableInstance, 1
disposable -> Flux.just(disposable.toString()), 2
Disposable::dispose 3
);
  • 第一个 lambda 生成资源。这里我们返回模拟的 Disposable。

  • 第二个 lambda 处理资源,返回一个 Flux<T>。

  • 当来自 <2> 的 Flux 终止或被取消时调用第三个 lambda,以清理资源。

  • 订阅并执行序列之后,isDisposed 这个原子布尔值变为 true。

1.8. 演示 onError 的终止特性​

为了演示所有这些操作符在错误发生时都会导致上游原始序列终止,我们可以用一个 Flux.interval 做一个更直观的例子。interval 操作符每隔 x 个时间单位发出一个节拍,并附带一个递增的 Long 值。以下示例使用了一个 interval 操作符:

Flux<String> flux =
Flux.interval(Duration.ofMillis(250))
.map(input -> {
if (input < 3) return "tick " + input;
throw new RuntimeException("boom");
})
.onErrorReturn("Uh oh");

flux.subscribe(System.out::println);
Thread.sleep(2100); 1
  • 请注意,interval 默认运行在一个定时器 Scheduler 上。如果我们想在 main 类中运行这个示例,就需要在这里加一个 sleep 调用,以免应用在尚未产生任何值的情况下就退出。

前面的示例每 250ms 打印一行,如下所示:

tick 0
tick 1
tick 2
Uh oh

即使再多运行一秒钟,interval 也不会再发出任何节拍。序列确实已被错误终止。

1.9. 重试​

关于错误处理,还有一个值得关注的操作符,您可能会忍不住在前一节描述的情形中使用它。顾名思义,retry 让您可以重试一个产生错误的序列。

需要记住的是,它的工作方式是重新订阅上游的 Flux。这实际上是一个不同的序列,而原始序列仍然是终止的。为验证这一点,我们可以复用前面的例子,并追加一个 retry(1) 来重试一次,而不是使用 onErrorReturn。以下示例展示了如何这样做:

Flux.interval(Duration.ofMillis(250))
.map(input -> {
if (input < 3) return "tick " + input;
throw new RuntimeException("boom");
})
.retry(1)
.elapsed() 1
.subscribe(System.out::println, System.err::println); 2

Thread.sleep(2100); 3
  • elapsed 把每个值与自上一个值发出以来的时长关联起来。

  • 我们还想看到何时出现 onError。

  • 确保我们有足够时间完成 4x2 个节拍。

前面的示例产生如下输出:

259,tick 0
249,tick 1
251,tick 2
506,tick 0 1
248,tick 1
253,tick 2
java.lang.RuntimeException: boom
  • 一个新的 interval 从 tick 0 开始。多出的 250ms 时长来自第 4 个节拍,也就是引发异常及随后重试的那一个。

从前面的例子可以看到,retry(1) 只是重新订阅了原始的 interval 一次,从 0 重新开始节拍。第二次时,由于异常仍然发生,它就放弃并把错误传播到下游。

retry 还有一个更高级的版本(名为 retryWhen),它使用一个“伴随” Flux 来判断某个特定失败是否应该重试。这个伴随 Flux 由操作符创建,但由用户装饰,以定制重试条件。

伴随 Flux 是一个 Flux<RetrySignal>,它被传给一个 Retry 策略/函数,后者作为 retryWhen 的唯一参数提供。作为用户,您定义该函数并让它返回一个新的 Publisher<?>。Retry 类是一个抽象类,但如果您想用一个简单 lambda 转换伴随 Flux,它提供了一个工厂方法(Retry.from(Function))。

重试周期如下进行:

  1. 每次发生错误(即具备重试可能)时,会有一个 RetrySignal 被发射到伴随 Flux 中,而该伴随 Flux 已被您的函数装饰。这里有一个 Flux,让您可以鸟瞰迄今的所有尝试。RetrySignal 提供对错误及其周边元数据的访问。

  2. 如果伴随 Flux 发出一个值,就发生一次重试。

  3. 如果伴随 Flux 完成,则错误被吞噬,重试周期停止,最终序列也随之完成。

  4. 如果伴随 Flux 产生一个错误(e),则重试周期停止,最终序列以 e 出错。

前两种情形之间的区别很重要。仅仅完成伴随 Flux 实际上会吞噬一个错误。考虑下面这种用 retryWhen 模拟 retry(3) 的方式:

Flux<String> flux = Flux
.<String>error(new IllegalArgumentException()) 1
.doOnError(System.out::println) 2
.retryWhen(Retry.from(companion -> 3
companion.take(3))); 4
  • 这会持续产生错误,从而引发重试尝试。

  • 重试之前的 doOnError 让我们能够记录并看到所有失败。

  • Retry 由一个非常简单的 Function lambda 适配而来。

  • 这里,我们把前三个错误视为可重试的(take(3)),然后放弃。

实际上,前面的例子得到的是一个空的 Flux,但它是成功完成的。由于在同一个 Flux 上用 retry(3) 会以最后一个错误终止,这个 retryWhen 示例与 retry(3) 并不完全相同。

要达到相同的行为,还需要一些额外技巧:

AtomicInteger errorCount = new AtomicInteger();
Flux<String> flux =
Flux.<String>error(new IllegalArgumentException())
.doOnError(e -> errorCount.incrementAndGet())
.retryWhen(Retry.from(companion -> 1
companion.map(rs -> { 2
if (rs.totalRetries() < 3) return rs.totalRetries(); 3
else throw Exceptions.propagate(rs.failure()); 4
})
));
  • 我们通过从一个 Function lambda 适配而来定制 Retry,而不是提供一个具体类。

  • 伴随 Flux 发出 RetrySignal 对象,其中携带迄今的重试次数和最后一次失败。

  • 为了允许三次重试,我们考虑 < 3 的索引并返回一个要发出的值(这里我们简单地返回该索引)。

  • 为了以错误终止序列,我们在这三次重试之后抛出原始异常。

提示

您可以使用 Retry 中暴露的构建器,以更流畅的方式达到同样效果,并实现更精细调优的重试策略。例如:errorFlux.retryWhen(Retry.max(3));。

提示

您可以使用类似代码实现“指数退避并重试”模式,如FAQ所示。

核心提供的 Retry 辅助类 RetrySpec 和 RetryBackoffSpec 都支持高级定制,例如:

  • 为可触发重试的异常设置 filter(Predicate)

  • 通过 modifyErrorFilter(Function) 修改之前设置的过滤器

  • 在重试触发周围触发一个副作用(例如围绕重试触发做记录,即在退避延迟前后),前提是该重试被确认有效(doBeforeRetry() 和 doAfterRetry() 是叠加的)

  • 在重试触发周围触发一个异步 Mono<Void>,这允许在基础延迟之上叠加异步行为,但因此会进一步延迟触发(doBeforeRetryAsync 和 doAfterRetryAsync 是叠加的)

  • 通过 onRetryExhaustedThrow(BiFunction) 在达到最大尝试次数时定制异常。默认使用 Exceptions.retryExhausted(…),可以用 Exceptions.isRetryExhausted(Throwable) 区分

  • 启用对瞬时错误的处理(见下文)

1.9.1. 对瞬时错误进行重试​

一些长生命周期的源可能会出现零星的错误突发,随后是较长一段一切运行平稳的时间。本文档把这种错误模式称为瞬时错误。

在这种情形下,理想做法是孤立地处理每一次突发,使下一次突发不会继承上一次的重试状态。例如,采用指数退避策略时,每次后续突发都应从最小退避 Duration 开始延迟重试,而不是从一个不断增长的退避开始。

表示 retryWhen 状态的 RetrySignal 接口有一个 totalRetriesInARow() 值,可用于此。与通常单调递增的 totalRetries() 索引不同,这个次级索引在每次通过重试从错误中恢复时(即一次重试尝试最终导致一个传入的 onNext 而不是再次 onError 时)重置为 0。

在 RetrySpec 或 RetryBackoffSpec 中把 transientErrors(boolean) 配置参数设为 true 时,所得策略会利用那个 totalRetriesInARow() 索引,从而实际处理瞬时错误。这些 spec 从该索引计算重试模式,因此实际上 spec 的所有其他配置参数都独立地应用于每一次错误突发。

AtomicInteger errorCount = new AtomicInteger(); 1
Flux<Integer> transientFlux = httpRequest.get() 2
.doOnError(e -> errorCount.incrementAndGet());

transientFlux.retryWhen(Retry.max(2).transientErrors(true)) 3
.blockLast();
assertThat(errorCount).hasValue(6); 4
  • 为便于说明,我们将统计重试序列中的错误数量。

  • 我们假设有一个 http 请求源,例如一个流式端点,它有时会连续失败两次,然后恢复。

  • 我们在该源上使用 retryWhen,配置为至多重试 2 次,但处于 transientErrors 模式。

  • 最终得到一个有效响应,transientFlux 在 errorCount 中记录了 6 次尝试之后成功完成。

如果没有 transientErrors(true),配置的最大尝试次数 2 会被第二次突发超过,整个序列最终会失败。

备注

如果您想在本地尝试这一点而不使用真实的 http 远程端点,可以把一个伪 httpRequest 方法实现为 Supplier,如下所示:

final AtomicInteger transientHelper = new AtomicInteger();
Supplier<Flux<Integer>> httpRequest = () ->
Flux.generate(sink -> { 1
int i = transientHelper.getAndIncrement();
if (i == 10) { 2
sink.next(i);
sink.complete();
}
else if (i % 3 == 0) { 3
sink.next(i);
}
else {
sink.error(new IllegalStateException("Transient error at " + i)); 4
}
});
  • 我们 generate 一个带有错误突发的源。

  • 当计数器达到 10 时,它会成功完成。

  • 如果 transientHelper 这个原子量是 3 的倍数,我们发出 onNext,从而结束当前这一次突发。

  • 在其他情况下,我们发出一个 onError。也就是三次中有两次,于是形成 2 个 onError 被 1 个 onNext 打断的突发。

2. 在操作符或函数中处理异常​

一般来说,所有操作符自身都可能包含会触发异常的代码,或调用同样可能失败的用户定义回调,因此它们都包含某种形式的错误处理。

根据经验法则,非受检异常总是通过 onError 传播。例如,在 map 函数内抛出一个 RuntimeException 会转换为一个 onError 事件,如下列代码所示:

Flux.just("foo")
.map(s -> { throw new IllegalArgumentException(s); })
.subscribe(v -> System.out.println("GOT VALUE"),
e -> System.out.println("ERROR: " + e));

前面的代码打印出如下内容:

ERROR: java.lang.IllegalArgumentException: foo
提示

您可以通过使用一个 hook,在 Exception 被传给 onError 之前对它进行调整。

然而,Reactor 定义了一组异常(例如 OutOfMemoryError),它们始终被视为致命的。请参阅 Exceptions.throwIfFatal 方法。这些错误意味着 Reactor 无法继续运行,它们会被抛出而不是传播。

备注

在内部,也存在一些非受检异常仍然无法传播的情形(最明显的是在订阅和请求阶段),这是由于并发竞争可能导致重复的 onError 或 onComplete 条件。当这些竞争发生时,无法传播的错误会被“丢弃”。这些情形在一定程度上仍可通过可定制的 hook 来管理。请参阅丢弃 hook。

您可能会问:“那么受检异常呢?”

例如,如果您需要调用某个声明了 throws 异常的方法,您仍必须在 try-catch 块中处理这些异常。不过,您有几种选择:

  1. 捕获异常并从中恢复。序列正常继续。

  2. 捕获异常,把它包装成一个非受检异常,然后抛出它(中断序列)。Exceptions 工具类可以帮您做到这一点(接下来就会讲到)。

  3. 如果您需要返回一个 Flux(例如您处于 flatMap 中),把异常包装到一个产生错误的 Flux 中,如下所示:return Flux.error(checkedException)。(序列同样会终止。)

Reactor 有一个 Exceptions 工具类,您可以用它来确保只有当异常是受检异常时才进行包装:

  • 必要时使用 Exceptions.propagate 方法包装异常。它还会先调用 throwIfFatal,并且不会包装 RuntimeException。

  • 使用 Exceptions.unwrap 方法获取原始的未包装异常(回溯到 Reactor 专属异常层次结构的根因)。

考虑下面这个 map 示例,它使用了一个可能抛出 IOException 的转换方法:

public String convert(int i) throws IOException {
if (i > 3) {
throw new IOException("boom " + i);
}
return "OK " + i;
}

现在设想您想在一个 map 中使用该方法。您现在必须显式捕获异常,而且您的 map 函数不能重新抛出它。因此,您可以把它作为一个 RuntimeException 传播到 map 的 onError 方法,如下所示:

Flux<String> converted = Flux
.range(1, 10)
.map(i -> {
try { return convert(i); }
catch (IOException e) { throw Exceptions.propagate(e); }
});

之后,当订阅前面这个 Flux 并对错误做出反应时(例如在 UI 中),如果您想对 IOException 做特殊处理,可以还原回原始异常。以下示例展示了如何这样做:

converted.subscribe(
v -> System.out.println("RECEIVED: " + v),
e -> {
if (Exceptions.unwrap(e) instanceof IOException) {
System.out.println("Something bad happened with I/O");
} else {
System.out.println("Something bad happened");
}
}
);