持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第18天,点击查看活动详情
避免在使用 Project Reactor 时由于混合反应式和非反应式逻辑而导致的意外行为。
反应式发布者(Flux和Mono)是惰性的,因此在有人请求之前不会发布或处理任何元素。理解这种区别是必不可少的,因为在编写实际应用程序时,我们希望所有(或大部分)业务逻辑在请求时执行。在这篇文章中,我们将展示不遵守此规则会导致哪些问题以及如何缓解这些问题。
租车服务示例
为了举例说明这一点,我们使用一个非常简单的虚拟汽车租赁服务实现。该服务接受由客户姓名、年龄和电子邮件地址以及汽车型号组成的输入。它首先检查客户是否年满 18 岁(因此合法允许租车),然后将租赁请求保存到数据库中,最后生成 PDF 收据并将其通过电子邮件发送给客户。
此流程由以下rentCar方法实现:
private static Mono<UUID> rentCar(CarRentalRequest request) {
if (request.getCustomerAge() > 18) {
UUID rentalId = UUID.randomUUID(); // Generate an ID for the new rental
return saveCarRental(rentalId, request) // Save the rental entity to the database
.then(buildAndSendPdfReceipt(rentalId, request)) // Generate and send PDF report
.then(Mono.just(rentalId)); // Return the ID of the new rental
} else {
return Mono.error(new RuntimeException("Must be 18 to rent a car"));
}
}
private static Mono<Void> buildAndSendPdfReceipt(UUID rentalId, CarRentalRequest carRentalRequest) {
byte[] pdfReceipt = buildPdfReceipt(rentalId, carRentalRequest);
return sendPdfReceipt(pdfReceipt, carRentalRequest.getCustomerEmail());
}
然后我们可以调用这个方法来创建发布者。此外,我们希望确保将工作委托给单独的调度程序,以便主线程可以继续处理其他请求。我们可以使用操作符来完成此subscribeOn操作(它会在整个管道中更改执行上下文,包括上方和下方,因此顶级发布者将Scheduler通过此操作符在集合上生成元素)。最后,我们提供了一个请求者,它定义了成功执行的逻辑以及错误响应(subscribe()方法中的两个 lambda 参数)。
CarRentalRequest request = new CarRentalRequest("Alice", 30, "Hyundai i30", "alice@mail.com");
rentCar(request)
.subscribeOn(Schedulers.boundedElastic())
.subscribe(s -> log.info("Car rented successfully, rental ID: {}", s),
e -> log.error("Could not rent car: {}", e.getMessage(), e));
考虑到这个实现,让我们仔细看看第一个问题。
陷阱 1:不正确的执行上下文
通过仔细观察该buildAndSendPdfReceipt方法,可以很容易地猜到它buildPdfReceipt是一种同步的、非反应性的方法:它不返回任何反应类型,它只是返回一个byte[]表示 PDF 文档的方法。这种方法可能看起来像
private static byte[] buildPdfReceipt(UUID rentalId, CarRentalRequest request) {
log.info("Build PDF receipt");
// Create and return the PDF receipt document
...
}
但是,如果我们运行这个示例,我们会得到以下输出:
21:25:38.961 [main] INFO com.reactordemo.carrental.CarRentalService - Build PDF receipt
21:25:38.986 [boundedElastic-1] INFO com.reactordemo.carrental.CarRentalService - Car rented successfully, rental ID: d5b689dd-fa91-486c-b835-44bc2583d53a
如果我们注意日志中显示每个语句的当前线程的部分(在方括号中),我们会注意到请求者逻辑在有界弹性调度程序中的线程上正确执行 - boundedElastic-1。但是,创建 PDF 的工作似乎是在main线程上执行的!那么为什么会这样呢?
答案就在于上面提到的组装和请求之间的区别。我们再来看看buildAndSendPdfReceipt方法:
private static Mono<Void> buildAndSendPdfReceipt(UUID rentalId, CarRentalRequest carRentalRequest) {
byte[] pdfReceipt = buildPdfReceipt(rentalId, carRentalRequest);
return sendPdfReceipt(pdfReceipt, carRentalRequest.getCustomerEmail());
}
当执行此方法时,我们只需要组装反应式管道,即以声明方式定义要执行的步骤以创建 PDF 报告。在这个阶段,我们不应该做生成此报告的实际工作,这仅在有人请求此发布者时才会发生。不幸的是,这里不是这种情况——调用buildPdfReceipt是在方法体中进行的,以及其余的汇编代码。这样做的一个后果是我们在上面看到的不正确的执行上下文。整个管道在main线程上组装,而发布的元素在boundedElastic调度程序上处理。但是自从调用buildPdfReceipt是在装配时进行的,这个可能很耗时的操作现在也将在main螺纹上进行。这可能很危险,因为在许多现实生活场景中,我们倾向于拥有更多的线程/资源专用于处理反应性管道而不是组装它们,并且保持组装线程忙碌会对我们应用程序的整体吞吐量和性能产生负面影响。
解决此问题的fromCallable一种方法是使用以下方法:
private static Mono<Void> buildAndSendPdfReceipt(UUID rentalId, CarRentalRequest carRentalRequest) {
return Mono.fromCallable(() -> buildPdfReceipt(rentalId, carRentalRequest))
.flatMap(pdfReceipt -> sendPdfReceipt(pdfReceipt, carRentalRequest.getCustomerEmail()));
}
正如我们所知,发布者只会在有人请求时开始生成元素,因此buildPdfReceipt现在调用作为整个管道的一部分,在所需的调度程序上进行。实际上,再次运行应用程序会产生以下结果:
21:54:49.955 [boundedElastic-1] INFO com.reactordemo.carrental.CarRentalService - Build PDF receipt
21:54:49.956 [boundedElastic-1] INFO com.reactordemo.carrental.CarRentalService - Car rented successfully, rental ID: a3bb873e-4943-407a-967f-9fa1c1d0d235
在许多复杂的实际应用中,此类问题可能很难被发现。避免它们的一种好方法是确保反应式方法(即组装管道的方法,通常具有反应式返回类型)不直接调用非反应式方法。相反,他们应该只组装反应式管道,最好是在一个流畅的语句中,并且所有对非反应式方法的调用都应该从反应式运算符(fromCallable, fromRunnable, map,filter等)中进行。
陷阱 2:不正确的异常处理
在设计和实现任何类型的应用程序时,我们总是希望通过尝试恢复或以其他方式向用户显示适当的错误消息来确保我们可以优雅地处理错误。在我们简单的汽车租赁服务中,我们创建了一个带有错误处理程序 lambda 的请求者,它记录来自上游的错误。期望是管道中任何地方可能发生的任何错误都将导致描述问题的日志语句。
为了测试这一点,让我们考虑以下输入:
CarRentalRequest request = new CarRentalRequest("Bob", null, "Hyundai i30", "bob@mail.com")
请注意,在这种情况下,客户的年龄被错误地设置为null。即便如此,我们希望这可能导致的任何错误都将被正确拦截和记录。不幸的是,现在运行此代码会产生以下输出:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.reactordemo.carrental.CarRentalService$CarRentalRequest.getCustomerAge()" is null
at com.reactordemo.carrental.CarRentalService.rentCar(CarRentalService.java:27)
at com.reactordemo.carrental.CarRentalService.entryPoint(CarRentalService.java:19)
at com.reactordemo.carrental.ReactorDemoApplication.main(ReactorDemoApplication.java:10)
这表明我们的无效输入产生了一个未在任何地方捕获的 NPE。但为什么?为什么没有为这个异常调用我们的错误处理程序?为了理解这一点,让我们再看看我们的主要反应管道:
private static Mono<UUID> rentCar(CarRentalRequest request) {
if (request.getCustomerAge() > 18) {
UUID rentalId = UUID.randomUUID();
return saveCarRental(rentalId, request)
.then(buildAndSendPdfReceipt(rentalId, request))
.then(Mono.just(rentalId));
} else {
return Mono.error(new RuntimeException("Must be 18 to rent a car"));
}
}
很明显,异常发生在if语句的条件中,我们检查年龄是否大于 18。但请注意,作为管道执行的一部分,此检查不会正确发生。相反,检查是作为组装管道的一部分进行的。因此,这里发生的任何错误都不会被视为处理管道中的元素失败,而是首先无法组装管道。再一次,这个问题可以通过简单地定义所有特定于反应管道内元素处理(包括检查)的逻辑来避免。
private static Mono<UUID> rentCar(CarRentalRequest request) {
return Mono.just(request)
.<CarRentalRequest>handle((req, sink) -> {
if (req.getCustomerAge() > 18) {
sink.next(req);
} else {
sink.error(new RuntimeException("Must be 18 to rent a car"));
}
})
.flatMap(req -> {
UUID rentalId = UUID.randomUUID();
return saveCarRental(rentalId, req)
.then(buildAndSendPdfReceipt(rentalId, req))
.then(Mono.just(rentalId));
});
}
在最初的实现中,有两个与处理组装时正在执行的请求相关的功能:年龄检查和 ID 生成。我们现在已经将它们分别移到了管道中的handle和flatMap运算符中。应用此修复后,执行会产生以下输出:
12:48:46.627 [boundedElastic-1] ERROR com.reactordemo.carrental.CarRentalService - Could not rent car: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.reactordemo.carrental.CarRentalService$CarRentalRequest.getCustomerAge()" is null
java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "com.reactordemo.carrental.CarRentalService$CarRentalRequest.getCustomerAge()" is null
at com.reactordemo.carrental.CarRentalService.lambda$rentCarFixed$2(CarRentalService.java:40)
当然,抛出 NPE 而不是验证输入并产生更有意义的错误并不理想。尽管如此,我们可以看到异常现在在管道内的请求时间被抛出,这意味着它最终将被我们的错误处理程序捕获,正如预期的那样。
结论
在这篇文章中,我们分析了两种情况,其中组装时间和订阅时间逻辑的不正确分离会导致我们的应用程序出现不良行为。为了减轻此类问题和其他问题,我们提出如下明确的分离:
- 作为响应式方法(组装响应式管道的方法,即具有响应式返回类型)的一部分,避免执行除了严格构建管道之外的任务。
- 此类方法的一个好做法是确保它们仅组装和返回反应式管道,最好在单个流式语句中。
- 任何重要的逻辑(通常与元素处理而不是组装管道有关),例如输入验证或映射、对其他同步方法的调用等,都应作为管道的一部分执行。这可以使用大量的运算符来实现,本文举例说明了其中的一些运算符,例如
handle、flatMap、fromCallable。