现代应用程序必须在高负载和大量并发用户的情况下顺利工作。传统的Java应用程序运行阻塞代码,一个常见的扩展方法是增加可用线程的数量。当延迟问题出现时,许多额外的线程就会闲置,浪费资源。
一种不同的方法是通过编写异步非阻塞代码来提高效率,在异步进程完成的同时让执行切换到另一个任务。
Project Reactor是VMware的一个Java框架,实现了Reactive Streams规范。这个倡议为JVM和JavaScript运行时提供了一个带有非阻塞反压的异步流处理标准。它是Spring生态系统和WebFlux应用中反应堆的基础。一些研究表明,在Spring应用程序中,性能差异可以忽略不计,除非该应用程序每秒发出超过500个API请求。因此,做异步编程并不是强制性的,但它是特定用例的正确方法。
在这篇文章中,我们将总结一些核心的Reactor概念,介绍Scheduler抽象,并描述如何用它来封装阻塞代码,防止Java反应式应用程序停滞。
反应器执行模型
Reactor是一个用于进行异步编程的API。你把你的数据处理描述为操作者的流动,组成了一个数据处理管道。
使用流水线的比喻,来自源头的初始数据流,被称为Publisher ,经过转换步骤,最终将结果推送给消费者,被称为Subscriber 。一个Publisher 产生数据,一个Subscriber 聆听数据。Reactor还支持通过背压进行流量控制,所以一个Subscriber 可以对它能够消费的数量发出信号。
在你订阅之前什么都不会发生
一般来说,当你实例化一个Publisher (一个Flux 或一个Mono )时,你是在描述一个异步的处理管道。当组合操作符时,没有处理发生--你在描述意图。这被称为装配时间。你只有在subscribe ,才会触发通过该管道的数据流。subscribe 调用会向源发出一个信号,然后源开始发出数据,并流经管道。这被称为执行时间或订阅时间。
一个冷的发布者为每个订阅产生数据。那么,如果你订阅两次,它就会产生两次数据。下面的所有例子都将实例化冷发布器。另一方面,一个热发布器立即或在第一次订阅时开始发射数据。迟来的订阅者会收到他们订阅后发出的数据。对于热发布器家族来说,在你订阅之前确实有事情发生。
操作员map 和flatMap
操作员就像流水线上的工作站。它们使描述处理链上的转换或中间步骤成为可能。每个操作符都是一个装饰器,它将之前的Publisher ,包装成一个新的实例。为了避免错误,使用运算符的首选方式是连锁调用。将下一个运算符应用于上一个运算符的结果。有些运算符是实例方法,有些是静态方法。
map() 和flatMap() 都是实例方法运算符。你可能从函数式编程或Java Streams中熟悉这些操作的概念。在反应式世界中,它们有自己的语义。
map() 方法通过对每个项目应用一个同步函数,在一对一的基础上对发出的项目进行转换。请看下面的例子:
public class MapTest {
private static Logger logger = LoggerFactory.getLogger(MapTest.class);
@Test
public void mapTest() {
Flux.range(1, 5)
.map(v -> transform(v))
.subscribe(y -> logger.info(y));
}
private String transform(Integer i){
return String.format("%03d", i);
}
}
**注意:**你可以在GitHub上找到这个测试和本教程的所有代码。
在上面的代码中,从1到5的整数范围内创建了一个通量。map() 运算器被传递给一个转换函数,该函数以前导零为格式。请注意,转换方法的返回类型不是发布者,而且转换是同步的--一个简单的方法调用。该转换函数不能引入延迟。
flatMap() 方法将发出的项目异步转化为发布器,然后通过合并将这些内部发布器平铺成一个Flux ,项目可以交错排列。这个操作者不一定保留原来的排序。传递给flatMap() 的映射器函数将输入序列转换为N个序列。这个操作符适合于为每个项目运行一个异步任务。
在下面的例子中,通过字典API,使用flatMap() ,将一个单词列表映射到其语音:
@SpringBootTest
@ActiveProfiles("test")
public class FlatMapTest {
private static Logger logger = LoggerFactory.getLogger(FlatMapTest.class);
public static class Word {
private String word;
private List<Phonetic> phonetics;
private List<Meaning> meanings;
public String getWord() {
return word;
}
public void setWord(String word) {
this.word = word;
}
public List<Phonetic> getPhonetics() {
return phonetics;
}
public void setPhonetics(List<Phonetic> phonetics) {
this.phonetics = phonetics;
}
public List<Meaning> getMeanings() {
return meanings;
}
public void setMeanings(List<Meaning> meanings) {
this.meanings = meanings;
}
}
public static class Meaning {
private String partOfSpeech;
private List<Definition> definitions;
public String getPartOfSpeech() {
return partOfSpeech;
}
public void setPartOfSpeech(String partOfSpeech) {
this.partOfSpeech = partOfSpeech;
}
public List<Definition> getDefinitions() {
return definitions;
}
public void setDefinitions(List<Definition> definitions) {
this.definitions = definitions;
}
}
public static class Definition {
private String definition;
private String example;
public String getDefinition() {
return definition;
}
public void setDefinition(String definition) {
this.definition = definition;
}
public String getExample() {
return example;
}
public void setExample(String example) {
this.example = example;
}
}
public static class Phonetic {
private String text;
private String audio;
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public String getAudio() {
return audio;
}
public void setAudio(String audio) {
this.audio = audio;
}
}
@Configuration
public static class FlatMapTestConfig {
@Bean
public WebClient webClient(){
WebClient webClient = WebClient.builder()
.baseUrl("https://api.dictionaryapi.dev/api/v2/entries/en_US")
.build();
return webClient;
}
}
@Autowired
private WebClient webClient;
@Test
public void flatMapTest() throws InterruptedException {
List<String> words = new ArrayList<>();
words.add("car");
words.add("key");
words.add("ballon");
Flux.fromStream(words.stream())
.flatMap(w -> getPhonetic(w))
.subscribeOn(Schedulers.boundedElastic())
.subscribe(y -> logger.info(y));
Thread.sleep(5000);
}
private Mono<String> getPhonetic(String word){
Mono<String> response = webClient.get()
.uri("/" + word).retrieve().bodyToMono(Word[].class)
.map(r -> r[0].getPhonetics().get(0).getText());
return response;
}
}
Flux 和Mono 的操作符有丰富的词汇,你可以在参考文档中找到。这个方便的指南可以帮助你选择正确的运算符。"我需要哪个运算符?"。
用于切换线程的Reactor调度器
Project Reactor提供了在线程和执行环境方面微调异步处理的工具。
Scheduler 是在ExecutionService 上的一个抽象,它允许立即、延迟或以固定的时间速率提交一个任务。我们将在下面分享一些提供的各种默认调度器的例子。执行环境是由Scheduler 选择定义的。
调度器是通过subscribeOn() 和publishOn() 操作符来选择的,它们会切换执行上下文。subscribeOn() 改变源实际开始生成数据的线程。它改变了链的根部,影响到前面的操作者,在链的上游;也影响到后续的操作者。
使用publishOn() ,可以切换管道描述中的后续操作者的上下文,在链的下游,覆盖任何调度器分配的subscribeOn() 。
前面的调度器例子将使用TestUtils.debug 来记录线程名称、函数和元素:
public class TestUtils {
public static Logger logger = LoggerFactory.getLogger(TestUtils.class);
public static <T> T debug(T el, String function) {
logger.info("element "+ el + " [" + function + "]");
return el;
}
}
无执行环境
Schedulers.immediate() 可以被看作是无执行调度器,流水线在当前线程中执行:
@Test
public void immediateTest() {
logger.info("Schedulers.immediate()");
Flux.range(1, 5)
.map(v -> debug(v, "map"))
.subscribeOn(Schedulers.immediate())
.subscribe(w -> debug(w,"subscribe"));
}
上面的测试将记录类似的内容:
2021-07-29 09:36:05.905 - INFO [main] - element "1" [map]
2021-07-29 09:36:05.906 - INFO [main] - element "1" [subscribe]
2021-07-29 09:36:05.906 - INFO [main] - element "2" [map]
2021-07-29 09:36:05.906 - INFO [main] - element "2" [subscribe]
2021-07-29 09:36:05.906 - INFO [main] - element "3" [map]
2021-07-29 09:36:05.907 - INFO [main] - element "3" [subscribe]
2021-07-29 09:36:05.907 - INFO [main] - element "4" [map]
2021-07-29 09:36:05.907 - INFO [main] - element "4" [subscribe]
2021-07-29 09:36:05.907 - INFO [main] - element "5" [map]
2021-07-29 09:36:05.907 - INFO [main] - element "5" [subscribe]
一切都在主线程中执行。
单一可重复使用的线程
@Test
public void singleTest() throws InterruptedException {
logger.info("Schedulers.single()");
Flux.range(1, 5)
.map(v -> debug(v, "map"))
.publishOn(Schedulers.single())
.subscribe(w -> debug(w,"subscribe"));
Thread.sleep(5000);
}
当运行上面的测试时,你会看到类似这样的日志:
2021-07-13 23:19:16.456 - INFO [main] - element 1 [map]
2021-07-13 23:19:16.457 - INFO [main] - element 2 [map]
2021-07-13 23:19:16.457 - INFO [main] - element 3 [map]
2021-07-13 23:19:16.457 - INFO [main] - element 4 [map]
2021-07-13 23:19:16.457 - INFO [main] - element 5 [map]
2021-07-13 23:19:16.457 - INFO [single-1] - element 1 [subscribe]
2021-07-13 23:19:16.465 - INFO [single-1] - element 2 [subscribe]
2021-07-13 23:19:16.465 - INFO [single-1] - element 3 [subscribe]
2021-07-13 23:19:16.466 - INFO [single-1] - element 4 [subscribe]
2021-07-13 23:19:16.466 - INFO [single-1] - element 5 [subscribe]
正如你所看到的,map() 函数在主线程中执行,而传递给subscribe() 的消费者代码在单1线程中执行。publishOn() 之后的所有内容都将在传递的调度器中执行。
你可能已经注意到在测试的最后有一个Thread.sleep() 的调用。由于部分工作被传递给另一个线程,sleep() 调用延迟了应用程序的退出,所以工作线程可以完成任务。
有边界的弹性线程池
有界弹性线程池是I/O阻塞工作的更好选择,例如,读取文件,或进行阻塞的网络调用。如果不能避免,它的作用是帮助处理遗留的阻塞代码。
@Test
public void boundedElasticTest() throws InterruptedException {
logger.info("Schedulers.boundedElastic()");
List<String> words = new ArrayList<>();
words.add("a.txt");
words.add("b.txt");
words.add("c.txt");
Flux flux = Flux.fromArray(words.toArray(new String[0]))
.publishOn(Schedulers.boundedElastic())
.map(w -> scanFile(debug(w, "map")));
flux.subscribe(y -> debug(y, "subscribe1"));
flux.subscribe(y -> debug(y, "subscribe2"));
Thread.sleep(5000);
}
public String scanFile(String filename){
InputStream stream = getClass().getClassLoader().getResourceAsStream(filename);
Scanner scanner = new Scanner(stream);
String line = scanner.nextLine();
scanner.close();
return line;
}
上面的测试应该记录与此类似的内容:
2021-07-15 00:41:51.443 - INFO [boundedElastic-1] - element "a.txt" [map]
2021-07-15 00:41:51.443 - INFO [boundedElastic-2] - element "a.txt" [map]
2021-07-15 00:41:51.446 - INFO [boundedElastic-2] - element "A line in file a.txt" [subscribe2]
2021-07-15 00:41:51.447 - INFO [boundedElastic-2] - element "b.txt" [map]
2021-07-15 00:41:51.447 - INFO [boundedElastic-2] - element "A line in file b.txt" [subscribe2]
2021-07-15 00:41:51.447 - INFO [boundedElastic-2] - element "c.txt" [map]
2021-07-15 00:41:51.448 - INFO [boundedElastic-2] - element "A line in file c.txt" [subscribe2]
2021-07-15 00:41:51.448 - INFO [boundedElastic-1] - element "A line in file a.txt" [subscribe1]
2021-07-15 00:41:51.449 - INFO [boundedElastic-1] - element "b.txt" [map]
2021-07-15 00:41:51.451 - INFO [boundedElastic-1] - element "A line in file b.txt" [subscribe1]
2021-07-15 00:41:51.452 - INFO [boundedElastic-1] - element "c.txt" [map]
2021-07-15 00:41:51.453 - INFO [boundedElastic-1] - element "A line in file c.txt" [subscribe1]
正如你在上面的代码中看到的,flux() 被订阅了两次。由于publishOn() 是在任何操作者之前调用的,所有的东西都将在一个有边界的弹性线程的上下文中执行。另外,注意到两个订阅的执行可以交错进行。
可能令人困惑的是,每个订阅都被分配了一个有边界的弹性线程来执行。所以在这种情况下,"subscription1 "在boundedElastic-1中执行,"subscription2 "在boundedElastic-2中执行。一个给定的订阅的所有操作都在同一个线程中执行。
上面的Flux 的实例化产生了一个Cold Publisher,一个为每个订阅重新生成数据的发布者。这就是为什么上面的两个订阅都会处理所有的值。
固定的工作者池
Schedulers.parallel() 提供了一个为并行工作而调整的工作者池,有多少个CPU核就有多少个工作者:
@Test
public void parallelTest() throws InterruptedException {
logger.info("Schedulers.parallel()");
Flux flux = Flux.range(1, 5)
.publishOn(Schedulers.parallel())
.map(v -> debug(v, "map"));
flux.subscribe(w -> debug(w,"subscribe1"));
flux.subscribe(w -> debug(w,"subscribe2"));
flux.subscribe(w -> debug(w,"subscribe3"));
flux.subscribe(w -> debug(w,"subscribe4"));
flux.subscribe(w -> debug(w,"subscribe5"));
Thread.sleep(5000);
}
上面的测试会记录类似于下面几行的内容。
2021-07-15 22:57:33.435 - INFO [parallel-2] - element "1" [map]
2021-07-15 22:57:33.435 - INFO [parallel-2] - element "1" [subscribe2]
2021-07-15 22:57:33.435 - INFO [parallel-1] - element "1" [map]
2021-07-15 22:57:33.435 - INFO [parallel-2] - element "2" [map]
2021-07-15 22:57:33.435 - INFO [parallel-2] - element "2" [subscribe2]
2021-07-15 22:57:33.435 - INFO [parallel-1] - element "1" [subscribe1]
2021-07-15 22:57:33.435 - INFO [parallel-1] - element "2" [map]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "2" [subscribe1]
2021-07-15 22:57:33.436 - INFO [parallel-2] - element "3" [map]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "3" [map]
2021-07-15 22:57:33.436 - INFO [parallel-2] - element "3" [subscribe2]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "3" [subscribe1]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "4" [map]
2021-07-15 22:57:33.436 - INFO [parallel-2] - element "4" [map]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "4" [subscribe1]
2021-07-15 22:57:33.436 - INFO [parallel-2] - element "4" [subscribe2]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "5" [map]
2021-07-15 22:57:33.436 - INFO [parallel-2] - element "5" [map]
2021-07-15 22:57:33.436 - INFO [parallel-1] - element "5" [subscribe1]
2021-07-15 22:57:33.436 - INFO [parallel-2] - element "5" [subscribe2]
2021-07-15 22:57:33.438 - INFO [parallel-3] - element "1" [map]
2021-07-15 22:57:33.438 - INFO [parallel-3] - element "1" [subscribe3]
2021-07-15 22:57:33.439 - INFO [parallel-3] - element "2" [map]
2021-07-15 22:57:33.439 - INFO [parallel-3] - element "2" [subscribe3]
2021-07-15 22:57:33.439 - INFO [parallel-3] - element "3" [map]
2021-07-15 22:57:33.439 - INFO [parallel-3] - element "3" [subscribe3]
2021-07-15 22:57:33.441 - INFO [parallel-4] - element "1" [map]
2021-07-15 22:57:33.441 - INFO [parallel-3] - element "4" [map]
2021-07-15 22:57:33.442 - INFO [parallel-4] - element "1" [subscribe4]
2021-07-15 22:57:33.445 - INFO [parallel-1] - element "1" [map]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "2" [map]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "2" [subscribe4]
2021-07-15 22:57:33.445 - INFO [parallel-1] - element "1" [subscribe5]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "3" [map]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "3" [subscribe4]
2021-07-15 22:57:33.445 - INFO [parallel-1] - element "2" [map]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "4" [map]
2021-07-15 22:57:33.445 - INFO [parallel-1] - element "2" [subscribe5]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "4" [subscribe4]
2021-07-15 22:57:33.445 - INFO [parallel-1] - element "3" [map]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "5" [map]
2021-07-15 22:57:33.445 - INFO [parallel-4] - element "5" [subscribe4]
2021-07-15 22:57:33.445 - INFO [parallel-1] - element "3" [subscribe5]
2021-07-15 22:57:33.442 - INFO [parallel-3] - element "4" [subscribe3]
2021-07-15 22:57:33.450 - INFO [parallel-3] - element "5" [map]
2021-07-15 22:57:33.450 - INFO [parallel-3] - element "5" [subscribe3]
2021-07-15 22:57:33.450 - INFO [parallel-1] - element "4" [map]
2021-07-15 22:57:33.450 - INFO [parallel-1] - element "4" [subscribe5]
2021-07-15 22:57:33.450 - INFO [parallel-1] - element "5" [map]
2021-07-15 22:57:33.450 - INFO [parallel-1] - element "5" [subscribe5]
正如你所看到的,不同的订阅执行交错进行,由于我只用四个CPU核进行测试,似乎有四个工作者:并行-1、并行-2、并行-3和并行-4。subscription5 的操作,在并行-1中执行。
再次观察一下,一个给定的订阅的所有操作是如何在同一个线程中执行的。
同时使用publishOn 和subscribeOn
当使用publishOn() 和subscribeOn() 操作符时,subscribe 调用接受了一个消费者。消费者将在publishOn() 所选择的上下文中执行,它定义了下游处理的调度器,包括消费者的执行。一开始,这可能会让人感到困惑,所以请看下面的例子:
@Test
public void subscribeOnWithPublishOnTest() throws InterruptedException {
Flux.range(1, 3)
.map(v -> debug(v, "map1"))
.publishOn(Schedulers.parallel())
.map(v -> debug(v * 100, "map2"))
.subscribeOn(Schedulers.boundedElastic())
.subscribe(w -> debug(w,"subscribe"));
Thread.sleep(5000);
}
日志应该是下面几行的样子:
2021-07-29 08:40:30.583 - INFO [boundedElastic-1] - element "1" [map1]
2021-07-29 08:40:30.584 - INFO [parallel-1] - element "100" [map2]
2021-07-29 08:40:30.585 - INFO [parallel-1] - element "100" [subscribe]
2021-07-29 08:40:30.585 - INFO [boundedElastic-1] - element "2" [map1]
2021-07-29 08:40:30.585 - INFO [parallel-1] - element "200" [map2]
2021-07-29 08:40:30.585 - INFO [parallel-1] - element "200" [subscribe]
...
正如你所看到的,第一个map() 操作符在boundedElastic-1线程中执行,第二个map() 操作符和subscribe() 消费者在并行-1线程中执行。请注意,subscribeOn() 操作符是在publishOn() 之后调用的,但它无论如何都会影响到链的根和前面的操作符publishOn() 。
上面的例子中,一个简化的大理石图可能是这样的。
反应式Java Spring服务
在Spring WebFlux中,假定应用程序不会阻塞,所以非阻塞服务器使用一个小的固定大小的线程池来处理请求,名为事件循环工作者。
在一个理想的反应式场景中,所有的架构组件都是无阻塞的,所以不需要担心事件循环会冻结**(反应器熔断**)。但有时,你将不得不处理遗留的阻塞代码,或阻塞库。
所以现在,让我们在一个反应式Java应用程序中尝试使用反应式调度器。创建一个具有Okta安全性的Spring WebFlux服务。该服务将暴露一个端点来返回一个随机整数。
该实现将调用JavaSecureRandom 阻塞代码。首先,使用Spring Initializr下载一个Spring Boot Maven项目。你可以用下面的HTTPie命令来做:
http -d https://start.spring.io/starter.zip \
bootVersion==2.5.3 \
baseDir==reactive-service \
groupId==com.okta.developer.reactive \
artifactId==reactive-service \
name==reactive-service \
packageName==com.okta.developer.reactive \
javaVersion==11 \
dependencies==webflux,okta
解压该项目:
unzip reactive-service.zip
cd reactive-service
在你开始之前,你需要一个免费的Okta开发者账户。安装Okta CLI,运行okta register ,注册一个新账户。如果你已经有一个账户,运行okta login 。然后,运行okta apps create 。选择默认的应用程序名称,或者根据你的需要进行更改。 选择Web,然后按Enter键。
选择Okta Spring Boot Starter。 接受为您提供的默认Redirect URI值。也就是说,登录重定向为http://localhost:8080/login/oauth2/code/okta ,注销重定向为http://localhost:8080 。
Okta CLI是做什么的?
Okta CLI将在您的Okta机构中创建一个OIDC网络应用。它将添加您指定的重定向URI,并授予Everyone组的访问权。当它完成后,您会看到如下输出。
Okta application configuration has been written to:
/path/to/app/src/main/resources/application.properties
打开src/main/resources/application.properties ,查看你的应用程序的发行者和证书。
okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS
注意:你也可以使用Okta管理控制台来创建你的应用程序。更多信息请参见创建一个Spring Boot应用程序。
创建包com.okta.developer.reactive.service ,并添加SecureRandomService 接口。
package com.okta.developer.reactive.service;
import reactor.core.publisher.Mono;
public interface SecureRandomService {
Mono<Integer> getRandomInt();
}
和实现SecureRandomServiceImpl 。
package com.okta.developer.reactive.service;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.security.SecureRandom;
@Service
public class SecureRandomServiceImpl implements SecureRandomService {
private SecureRandom secureRandom;
public SecureRandomServiceImpl() {
secureRandom = new SecureRandom();
}
@Override
public Mono<Integer> getRandomInt() {
return Mono.just(secureRandom.nextInt());
}
}
思考一下getRandomInt() 的实现。很难抵制用发布器包装任何东西的诱惑。Mono.just(T data) ,创建一个Mono ,它将发出指定的项目。但是这个项目是在实例化的时候被捕获的。这将在装配时调用阻塞代码,也许是在一个事件循环线程的上下文中。
现在,让我们继续前进,创建包com.okta.developer.reactive.controller ,并为数据添加一个SecureRandom 类。
package com.okta.developer.reactive.controller;
public class SecureRandom {
private String value;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public SecureRandom(Integer value){
this.value = value.toString();
}
}
创建SecureRandomController 类。
package com.okta.developer.reactive.controller;
import com.okta.developer.reactive.service.SecureRandomService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
public class SecureRandomController {
@Autowired
private SecureRandomService secureRandomService;
@GetMapping("/random")
public Mono<SecureRandom> getSecureRandom(){
return secureRandomService.getRandomInt().map(i -> new SecureRandom(i));
}
}
在pom中添加spring-security-test 的依赖性。
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
添加一个SecureRandomControllerTest 。
package com.okta.developer.reactive.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@ActiveProfiles("test")
public class SecureRandomControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
public void testGetSecureRandom() {
webTestClient.mutateWith(mockOidcLogin())
.get()
.uri("/random")
.exchange()
.expectStatus().isOk();
}
}
用运行。
./mvnw test -Dtest=SecureRandomControllerTest
测试应该通过,但**如何确保REST调用不会冻结服务的事件循环?**使用BlockHound!。Blockhound是一个Java代理,可以检测来自非阻塞线程的阻塞调用,与Project Reactor内置集成,并支持JUnit平台。
将Blockhound的依赖性添加到pom.xml 。
<dependency>
<groupId>io.projectreactor.tools</groupId>
<artifactId>blockhound-junit-platform</artifactId>
<version>1.0.6.RELEASE</version>
<scope>test</scope>
</dependency>
据报道,当Spring Security被启用时,BlockHound不能检测到阻塞调用。
禁用测试配置文件的安全性。添加文件src/test/resources/application-test.yml ,内容如下。
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration
- org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration
- org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration
重新运行测试,你应该看到以下错误。
reactor.blockhound.BlockingOperationError: Blocking call! java.io.FileInputStream#readBytes
at java.base/java.io.FileInputStream.readBytes(FileInputStream.java) ~[na:na]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
|_ checkpoint ⇢ HTTP GET "/random" [ExceptionHandlingWebHandler]
Stack trace:
at java.base/java.io.FileInputStream.readBytes(FileInputStream.java) ~[na:na]
at java.base/java.io.FileInputStream.read(FileInputStream.java:279) ~[na:na]
at java.base/java.io.FilterInputStream.read(FilterInputStream.java:133) ~[na:na]
at java.base/sun.security.provider.NativePRNG$RandomIO.readFully(NativePRNG.java:424) ~[na:na]
at java.base/sun.security.provider.NativePRNG$RandomIO.ensureBufferValid(NativePRNG.java:526) ~[na:na]
at java.base/sun.security.provider.NativePRNG$RandomIO.implNextBytes(NativePRNG.java:545) ~[na:na]
at java.base/sun.security.provider.NativePRNG.engineNextBytes(NativePRNG.java:220) ~[na:na]
at java.base/java.security.SecureRandom.nextBytes(SecureRandom.java:741) ~[na:na]
at java.base/java.security.SecureRandom.next(SecureRandom.java:798) ~[na:na]
如上面的日志所示,SecureRandom.next() 产生了对FileInputStream.readBytes() 的调用,该调用是阻塞的。
SecureRandomService 返回一个Mono 发布者,但它是一个Impostor Reactive Service!
return Mono.just(secureRandom.nextInt());
上面的实现在前面有逻辑,首先是计算secureRandom.nextInt() ,然后是装配。
什么才是包裹阻塞调用的正确方法?让工作发生在另一个调度器中。
封闭式调用的封装
正如Reactor文档中解释的那样,阻塞的封装需要发生在服务中。调度器的分配也必须发生在实现内部:
创建类SecureRandomReactiveImpl 。
package com.okta.developer.reactive.service;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.security.SecureRandom;
@Service
@Primary
public class SecureRandomReactiveImpl implements SecureRandomService {
private SecureRandom secureRandom;
public SecureRandomReactiveImpl() {
secureRandom = new SecureRandom();
}
@Override
public Mono<Integer> getRandomInt() {
return Mono.fromCallable(secureRandom::nextInt)
.subscribeOn(Schedulers.boundedElastic());
}
}
再次运行测试,BlockHound异常应该不会发生。避开前面的逻辑,组装流水线。一切都应该是好的。
最后,我们来做一个端到端的测试。用Maven运行该应用程序:
./mvnw spring-boot:run
转到http://localhost:8080/random ,你应该看到Okta的登录页面。
用你的Okta账户登录,你应该看到类似这样的响应:
{
"value": "-611020335"
}