如何防止反应型Java应用程序停顿

445 阅读18分钟

现代应用程序必须在高负载和大量并发用户的情况下顺利工作。传统的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 调用会向源发出一个信号,然后源开始发出数据,并流经管道。这被称为执行时间订阅时间

一个冷的发布者为每个订阅产生数据。那么,如果你订阅两次,它就会产生两次数据。下面的所有例子都将实例化冷发布器。另一方面,一个热发布器立即或在第一次订阅时开始发射数据。迟来的订阅者会收到他们订阅后发出的数据。对于热发布器家族来说,在你订阅之前确实有事情发生。

操作员mapflatMap

操作员就像流水线上的工作站。它们使描述处理链上的转换或中间步骤成为可能。每个操作符都是一个装饰器,它将之前的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;
    }
}

FluxMono 的操作符有丰富的词汇,你可以在参考文档中找到。这个方便的指南可以帮助你选择正确的运算符。"我需要哪个运算符?"。

用于切换线程的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并行-4subscription5 的操作,在并行-1中执行。

再次观察一下,一个给定的订阅的所有操作是如何在同一个线程中执行的。

同时使用publishOnsubscribeOn

当使用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"
}