Java-反应流教程-全-

Java 反应流教程(全)

原文:Reactive Streams in Java

协议:CC BY-NC-SA 4.0

一、反应流简介

Reactive Streams 是一项倡议,旨在为无阻塞背压异步流处理提供标准。这包括针对运行时环境(JVM 和 JavaScript)以及网络协议的努力。

reactive-streams.org

反应式流的核心是努力为响应速度快、每秒钟能够处理许多请求的应用程序提供管理背压的能力(跳过或排队太快而无法处理的数据的能力)。异步意味着处理可以在许多线程中进行,而不需要停下来从文件或 web 请求中读取数据。尽管已经存在许多异步处理的实现,比如 Java 的 Future、CompletableFuture 和 parallel streams,但是它们中的大多数都没有对异步处理背压的标准支持。

反应流是一个统一的标准,它抽象了现有的并发方法。此外,通过使用一个标准,不同的反应流实现可以在一个应用程序中进行互操作。

Java 9+版本

Java 9 是 Java 的一个重要版本,它包括了 Jigsaw 项目,该项目代表了核心 JDK (Java 开发工具包)的一个巨大重组,以及一种定义代码依赖关系的新的改进方法。与运行时错误相反,当依赖项丢失时,这提供了编译时错误,这对于软件开发效率是一个巨大的改进。Java 9 也为反应流引入了统一的接口。

Java 9 包括以下主要特性:

  • 语言更新

  • 支持反应流

  • 模块化(拼图项目)

  • Java REPL (jshell)

出于本书的目的,我们将关注第二个项目,并涵盖什么是反应流以及应该如何使用它们。尽管在撰写本文时情况并非如此,但在不久的将来,Java 中所有反应流的实现都有望实现 Java 9 API。我们将讨论 Java 10 和 11 中的变化如何影响我们未来的代码。

流动

JDK 增加了对反应流的支持。在java.util.concurrent.Flow类中添加了几个接口:

  • Publisher<T> :由订户接收的项目(和相关控制消息)的生产者

  • Subscriber<T> :消息的接收者

  • Processor<T,R> :既充当Subscriber又充当Publisher的组件

  • Subscription :消息控制链接发布者和订阅者

JDK 不包括实际执行情况;然而,已经有一些实现了。Java 虚拟机(JVM)上当前值得注意的反应流规范的实现是项目反应器(在 Spring 5 中集成) Akka 流RxJava ,所有这些我们都将在本书中涉及。

这本书的代码

本书中使用的代码示例可以在我位于 GitHub 的知识库中找到。请随意下载这个开源代码,并使用它。如果您还没有 GitHub 帐户,您可以免费创建一个。在您自己的机器上安装 Git 会有所帮助。然后使用 GitHub 登录页面上指定的git clone命令,并使用任何您认为与您兼容的集成开发环境(IDE)——甚至文本编辑器也可以。

二、Java 中现有的并发模型

随着多核处理器变得越来越标准,不同的并发编程模型在 Java 中变得越来越流行。尽管 Java 中并发的核心模型是线程,但是已经构建了多个抽象级别来实现更简单的开发。

这些模型中的每一个都有不同的方法来保护值不被一个以上的线程同时修改,我们将在本章中讨论。

突出的并发模型

在 Java 和 JVM 中有几个可靠的并发模型。随着时间的推移,引入了更高级别的模型来简化并发性。其中一些模型如下:

  • 同步和受苦(在 Java 中使用synchronize关键字)

  • 期货和执行服务

  • 软件事务内存(STM) (Clojure)

  • 基于角色的模型(Akka)

  • 反应流(RxJava、反应器等。)

用 Java 同步

Java 中并发编程的原始风格包括每当共享资源被修改时使用synchronized关键字。这种编程风格的运行时行为非常不可预测,并且难以测试。你必须处理以下问题:

  • 编译时不会给出警告或错误。

  • 如果不小心,可能会出现死锁。

  • 很难确保你把每件事都做对了,错误可能会随机出现。

综上所述,synchronize关键字太低级了,用不上(不用就好!).1

Java 未来

你可能听说过 Java 中的java.util.concurrent.Future接口。也许你已经用过了。这个接口是在 Java 1.5 中添加的,它保存异步计算的结果。它包含检查异步计算是已完成还是仍在进行中、等待计算完成、阻塞调用直到计算完成(带有可选超时)以及检索计算结果的方法。

未来界面的缺点

这个界面有很多问题:

  • 在使用 Java 的 Future 时,我们倾向于在isDone()上循环,它会捆绑线程,或者调用get(),它会完全阻塞线程。

  • 使用最多的是ExecutorService#submit(...)(它用返回nullget()方法返回一个Future)。

  • 一般来说,当“走向异步”时,我们并不关心结果,或者我们想要对结果做些什么(因此我们想要类似于延续的东西)。

  • 我们需要回调——消除轮询(isDone)和阻塞的需要。(芭乐的ListenableFuture提供这个。)

  • 异步方法应该总是返回 void。

出于这些原因,如果您进行任何并发编程,您应该使用 Java 8 中引入的CompletableFuture(接下来将介绍)、Java 7 并发 API ( ForkJoinPoolForkJoinTask)或其他并发框架。

可完成的未来

CompletableFuture<T>实现了Future<T>接口和CompletionStage<T>接口,后者弥补了Future<T>的许多不足。这些方法遵循函数式风格,允许开发人员链接方法调用,而不是声明一步一步的过程。

CompletionStage包括以下方法(为简洁起见,省略了一般类型),每个方法都具有返回类型CompletionStage,以允许链接:

  • acceptEither(CompletionStage, Consumer):当此阶段(当前未来)或给定阶段完成时,执行给定的消费者。

  • applyToEither(CompletionStage, Function):类似于acceptEither,但是使用一个Function将一个值转换成另一个值。

  • exceptionally(Function):如果 stage 抛出异常,则给定函数异常处理并返回值。

  • handle(BiFunction):使用给定的函数处理成功和失败条件,并返回一个值。

  • runAfterBoth(CompletionStage, Runnable):在本阶段(当前未来)和给定阶段完成后运行给定的Runnable

  • runAfterEither(CompletionStage, Runnable):与acceptEither类似,只是使用了一个Runnable

  • thenAccept(Consumer):在此阶段(当前未来)正常完成后运行给定的消费者。如果你熟悉承诺,这类似于承诺并发模型中的“那么”。

  • thenAcceptBoth(CompletionStage, BiConsumer):在本阶段(当前未来)和给定阶段正常完成后,运行具有两个输出的给定双消费器。

  • thenApply(Function):阶段正常完成后,使用给定函数转换值。

  • thenCombine(CompletionStage, BiFunction):在两个阶段正常完成后,使用给定函数转换两个值。

  • thenRun(Runnable):本阶段完成后运行给定的Runnable

  • whenComplete(BiConsumer):使用给定的消费者来处理成功和失败的情况。

这些方法的异步版本也可以在方法名中添加“Async”。对于“异步”版本,将使用给定未来的标准执行模型,而不是当前线程。

您可以在CompletableFuture上使用以下任何静态方法创建一个实例:

  • CompletableFuture completedFuture(value):返回一个已经用给定值完成的新的CompletableFuture

  • CompletableFuture runAsync(Runnable):返回一个新的CompletableFuture,它是由运行在ForkJoinPool.commonPool()中的任务异步完成的。

  • CompletableFuture runAsync(Runnable, Executor):返回一个新的CompletableFuture,它是在给定执行器中运行的任务在运行给定动作后异步完成的。

  • CompletableFuture supplyAsync(Supplier):返回一个新的CompletableFuture,它是由运行在ForkJoinPool.commonPool()中的任务异步完成的,其值是调用给定的Supplier得到的。

更多详情,请参见文档

Clojure 中的 STM

Java 没有很好的内置并发支持。JVM (Java 虚拟机)的其他语言,如 Scala 和 Clojure,都是在考虑到并发性的基础上构建的。然而,我们可以在 Java 中直接使用 Scala 和 Clojure 的并发模型。

STM ( 软件事务内存)导致了状态和身份的分离。例如,给定时间的股票价格是不可变的。在 STM 中,你必须使用一个事务来修改任何东西。我们可以包含 Clojure jars 并在 Java 中使用它们。例如,在下面的代码中,referenceToAmount只能在事务内部修改:

import clojure.lang.*
Ref referenceToAmount;
LockingTransaction.runInTransaction(new Callable() {
      referenceToAmount.set(value);
});

现在,如果您试图在事务之外修改Ref,您将会得到一个错误。这使得并发编程更加容易,因为在同步块之外修改数据是不可能的。

演员

基于 Scala 的 actor 框架 Akka 也可以从 Java 中使用。Akka 也被游戏框架使用。它包括演员的概念。参与者可以接收和处理消息,并保证接收发送给他们的消息。它们一次处理一条消息,这样它们的状态就不会被系统的其他部分看到。

下面的代码显示了一个使用 Akka 框架和一个参与者的简单示例:

import akka.actor.*
public class XActor extends UntypedActor {
  public void onReceive(Object message) throws Exception {
    if (message instanceof String)
      System.out.println((String) message);
    }
  }
  public static void main(String... args) {
    ActorSystem system = ActorSystem.create("MySystem");
    ActorRef actor = system.actorOf(new Props(XActor.class), "actor");
    // the message could be anything implementing Serializable
    actor.tell("Message String");
}

一个 Actor 在概念上运行在一个专用的线程中,所以它一次只能做一件事。这使得并发更容易实现。消息被传递给参与者,并在队列中等待,直到给定的参与者准备好处理它。消息可以是任何可序列化的对象。

Groovy GPars

值得注意的是,Actor 和 STM 并发模式并不局限于 Scala 和 Clojure。

Groovy 的 GPars 库也实现了这些模式,也可以从 Java 中使用。它还有领域特定语言(DSL ),封装了 Java 的 JSR 166 特性,比如 Fork-Join 框架,使它们更容易使用。

您可以使用 GPars 以下列方式过滤、映射和缩减数组:

GParsPool.withPool {
  // a map-reduce functional style (students is a Collection)
  def bestGpa = students.parallel
    .filter{ s -> s.graduationYear == 2017 }
    .map{ s -> s.gpa }
    .max()
}

在这个例子中,学生是一个具有graduationYeargpa的类。这个代码找到 2017 年最高的 GPA。静态方法GParsPool.withPool接受一个闭包,并用几个方法扩充任何集合(使用 Groovy 的分类机制)。并行方法实际上从给定的集合中创建了一个ParallelArray (JSR-166 ),并用一个薄薄的包装来使用它。

反应流

反应式流为高度并发的异步应用程序提供了抽象,并支持背压。

虽然它们可以与前面的任何并发模型一起使用,但它们试图提供足够的功能来完全满足任何实现(在其他并发模型之上)。但是,由于它们是以多线程方式运行的,所以如果修改共享状态,就必须确保代码中的线程安全。尽量避免使用其他方法(例如,使用LockingTransaction或同步块),而是留在反应流模型中。反应式流使用发布者和订阅者的概念,以及各种反压力策略来模拟并发性。我们将涵盖这些概念。

  • 发布者以一定的速率发出事件。

  • 订阅者可能在不同的线程上观察这些事件,并对它们进行处理。

  • 一些框架使用其他词(如源和接收器)来表示发布者和订阅者。

正如我们将看到的,许多反应流框架允许与其他现有的并发模型(如 futures)进行互操作,以允许两者之间的平稳过渡。

三、常见概念

每个反应流框架都使用共同的概念来构成反应流的主干。一旦了解了标准方法(如 filter、map、delay 和 buffer)的功能,就可以使用方法链以简单而简洁的语法执行复杂的流转换。

本章试图阐明这些概念中最重要的一个。它没有涵盖所有可用的方法。

单词可观察的用于表示反应数据流。虽然 Observable 在 RxJava 中是一种类型,但是这个和其他反应流库有其他类型,比如 Reactor 中的通量和 Akka 流中的,它们代表数据流。反应流中的一切都是从流开始的。

冷热

当您开始使用反应流时,您需要掌握热可观测量与冷可观测量的概念。你正在处理的是哪种类型,它们之间的相互作用会导致问题,这并不总是显而易见的。

热可观测性是不可重复的。无论是否有订户,它都会立即开始创建数据。通常,它涉及到与外部世界的数据交互,比如鼠标输入、数据读取或 web 请求。

冷可观察是可以重复的,并且直到被订阅才开始。这可能是范围、文件数据或热观测数据的缓存记录。

热可观测量通常是使用背压流量控制策略(如节流、缓冲或窗口)的候选。

反压力

背压是指当流中的事件/数据太多,下游无法处理时发生的情况。打个比方,想想在一些城市的高峰时段,当交通陷入停滞时,或者当地铁列车满员时,会发生什么。当这种情况发生在您的应用程序中时,它会导致大问题,如OutOfMemory异常或线程饥饿和超时。背压策略帮助您主动处理这些问题,以避免这些问题。

有多种背压策略,但主要的是节流、窗口、缓冲和丢弃。最容易理解的是删除:您只需删除可以处理的项目(使用一些标准,如最早的或最新的)。本章还列出了其他策略(节流、窗口和缓冲区)。

过滤器

Filter 只接受那些与给定谓词匹配的元素。

img/470926_1_En_3_Figa_HTML.jpg

任何/所有

Any 返回一个布尔值,如果流中的任何元素匹配给定的谓词,则该值为真。如果所有元素都匹配,All 返回 true。这两个只有在终止(非无限)流时才有意义。

地图

Map 将数据从一种形式转换成另一种形式。这对于数据元素的任何基本操作都很有用。img/470926_1_En_3_Figb_HTML.jpg

FlatMap/ConcatMap

FlatMap 将数据从一个表单映射到其他表单的流中,然后将结果流组织在一起。当您希望根据子流的结果将一个数据流转换为新流时,这很有用。例如,您可能想要将一个运动队的流转换成这些队的所有运动员的流。

ConcatMap 非常类似,但是保留了传入流的顺序,而 flatMap 急切地订阅每个新流,并按照结果到达的顺序合并结果。

img/470926_1_En_3_Figc_HTML.jpg

耽搁

此方法将数据延迟一段固定的时间。

img/470926_1_En_3_Figd_HTML.jpg

缓冲器

Buffer 在一段时间内保存数据,并将其放在一个列表中,然后观察每个列表。

img/470926_1_En_3_Fige_HTML.jpg

Buffer 也是一种反压力策略,如果生成的元素太多,订户无法处理,它会缓存流中的所有元素。在这种情况下,缓冲区保留在内存中,不会影响流的数据类型。如果使用了 buffer,您可以选择删除或者忽略任何超过缓冲区最大大小的元素。

窗户

窗口很像缓冲区,但它产生的是可观察对象而不是列表。

img/470926_1_En_3_Figf_HTML.jpg

采取

当某个条件为真时,Take while (takeWhile)获取所有元素,当条件为假时,结束流。通常还有一个 take(n)方法,它在结束流之前获取一定数量的元素。

img/470926_1_En_3_Figg_HTML.jpg

最近的

“Latest”是一种反压力策略,如果生成的元素太多,订户无法处理,则只从流中取出最后一个元素。

img/470926_1_En_3_Figh_HTML.jpg

德本尼斯

当您只想要流安静一段时间后的元素时,去抖对于有噪声的流是有用的,例如,文本输入或其他用户输入。如果流在给定的持续时间内保持沉默,那么它只给出最后一个元素。

img/470926_1_En_3_Figi_HTML.jpg

虽然反应器似乎没有“去抖”,但可以使用 sampleTimeout 进行近似计算。例如,以下内容相当于一秒钟的去抖:

flux.sampleTimeout(x ->
        Mono.just(0).delayElement(
                Duration.of(1, ChronoUnit.SECONDS)))

先节流

Throttle first(rx Java 中的 throttle first)在给定的持续时间内从流中丢弃任何元素(在发出第一个元素之后)。Throttle last 非常类似,只是在该时间段内发出最后一个元素,而不是第一个元素。Reactor 有类似的方法,取样先取样。Akka 流 有一个类似的方法叫做节流

img/470926_1_En_3_Figj_HTML.jpg

四、RxJava

RxJava 是反应式编程的开源库,是react vex项目的一部分。ReactiveX 包括几种不同语言的实现,包括 RxJS、RxRuby、RxSwift、RxPHP、RxGroovy 等等。

RxJava 2 被重新构建以与反应流规范兼容,并且比 RxJava 1.x 更可取,因为它被安排在生命周期结束时使用。从版本 1 到版本 2 有许多可能会令人困惑的变化。为了避免混淆,我们将重点放在 RxJava 2 上。

入门指南

首先,使用 src/main/java/下的源代码创建一个新项目,如果使用 Maven,创建一个“pom.xml ”,如果使用 gradle,创建一个“build.gradle”文件。

如果您有一个 Maven 版本,请将以下内容添加到您的 pom 文件中:

<dependency>
 <groupId>io.reactivex.rxjava2</groupId>
 <artifactId>rxjava</artifactId>
 <version>2.2.2</version>
</dependency>

对于 Gradle 构建,将以下内容添加到您的 Gradle 构建文件的依赖项中:

compile 'io.reactivex.rxjava2:rxjava:2.2.2'

接下来,使用以下导入创建一个新的类文件:

import io.reactivex.*;
import io.reactivex.schedulers.*;
import io.reactivex.functions.*;
import org.reactivestreams.Publisher;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import java.util.*;
import java.io.*;

易流动的

RxJava 中的基本入口类是io.reactivex.Flowable<T>(大致相当于io.reactivex.Observable<T>)。它实现了反应流模式(Publisher ),并提供了工厂方法、中间操作符和使用反应数据流的能力。

以下示例演示了如何使用 RxJava 对一系列数字进行简单计算:

  1. 创建一个从 1 到 64 的范围。

  2. 调用方法observeOn来决定使用哪个调度程序。这决定了流将在哪个或哪些线程上运行。从“computation()”返回的调度程序尽可能利用所有可用的处理器。

  3. map方法转换每个值。在这种情况下,我们计算平方。

  4. 最后,我们通过调用“subscribe”方法来启动流程。在这种情况下,blockingSubscribe一直阻塞到整个流程完成,我们将每个值添加到“squares”列表中。这意味着 squares 列表将在 return 语句之前填充。否则,流将在不同的线程上运行,并且 squares 列表中的值在任何给定时间都是不可预测的。

public static List doSquares() {
        List squares = new ArrayList();
        Flowable.range(1, 64) //1
         .observeOn(Schedulers.computation()) //2
         .map(v -> v * v) //3
         .blockingSubscribe(squares::add); //4
        return squares;
}

结果列表将包含从 1 到 64 的数字的平方值:1,4,9,16,25,36,49,…,4096。

并行计算

如果你像前面的例子一样将一个可流动的绑定到一个调度器,它将会连续运行,而不是并行运行。要并行运行每个计算,您可以使用flatMap将每个计算分解成单独的可流动的,如下所示:

  1. 用一个 lambda 表达式调用flatMap,该表达式接受一个值并返回另一个可流动变量。just(…)方法接受任意数量的对象,并返回一个可流动的对象,该对象将发出这些对象,然后完成。

  2. 调用doOnError来处理出现的错误。

  3. 在一个流程完成后调用doOnComplete来执行一些事情。这仅适用于具有明确结尾的流动数据,如范围。结果列表将具有与上一个示例相同的值,但是因为我们使用了 flatMap,所以结果值的顺序不一定相同。

public static List doParallelSquares() {
        List squares = new ArrayList();
        Flowable.range(1, 64)
         .flatMap(v -> //1
                Flowable.just(v)
                .subscribeOn(Schedulers.computation())
                .map(w -> w * w)
         )
         .doOnError(ex -> ex.printStackTrace()) //2
         .doOnComplete(() ->
                System.out.println("Completed")) //3
        .blockingSubscribe(squares::add);

        return squares;
}

调度程序

对于一些繁重的计算,您可能希望在后台运行它们,同时在单独的线程中呈现结果,以便不阻塞 UI 或呈现线程。对于这种情况,您可以对一个调度器使用subscribeOn方法,对另一个调度器使用observeOn方法。

  1. 从一个可调用的(函数接口(SAM)简单地返回一个值)创建一个新的可流动的。

  2. 使用“IO”调度程序运行可流动程序。这个调度程序使用一个缓存线程池,这对于 I/O(例如,读写磁盘或网络传输)非常有用。

  3. 使用单线程调度程序观察可流动的结果。

  4. 最后,订阅生成的前景可流动,以启动流动并将结果打印到标准输出。调用 runComputation()的结果将在一秒钟后打印出来。

public static void runComputation() throws Exception {
        Flowable<String> source = Flowable.fromCallable(
         () -> { //1
                Thread.sleep(1000);
                return "Done";
         });
        source.doOnComplete(
         () ->         System.out.println("Completed runComputation"));
        Flowable<String> background =
                source.subscribeOn(Schedulers.io()); //2

        Flowable<String> foreground =
                background.observeOn(Schedulers.single()); //3

        foreground.subscribe(System.out::println,
                Throwable::printStackTrace); //4
}

出版商

对于重要的问题,您可能需要创建自己的发布者。只有当您希望对反应性流的请求/响应特性进行精细控制时,您才会这样做,而且没有必要使用 RxJava。

对于下面的例子,假设您想使用 RxJava 中的自定义发布器写入或读取一个文件。

首先,我们使用以下方法将一系列数字写入文件:

public static void writeFile(File file) {
  try (PrintWriter pw = new PrintWriter(file)) {
    Flowable.range(1, 100)
        .observeOn(Schedulers.newThread())
        .blockingSubscribe(pw::println);
  } catch (FileNotFoundException e) {
    e.printStackTrace();
  }
}

这里我们使用 try-with-resources 块和blockingSubscribe将范围写入文件。

第二,我们想从文件中读取。在本例中,使用“IO”调度程序将文件内容打印到标准输出:

public static void readFile(File file) {
  try (final BufferedReader br = new BufferedReader(
        new FileReader(file))) {
    Flowable<String> flow = Flowable.fromPublisher(
        new FilePublisher(br));
    flow.observeOn(Schedulers.io())
        .blockingSubscribe(System.out::println);
  } catch (IOException e) {
    e.printStackTrace();
  }
}

发布者实现接受订阅者的订阅方法。订户接口上有几个方法,首先要调用的是onSubscribe(Subscription)。为了在反应流中实现背压,创建了订阅接口,它只有两个方法,request(n)用于请求接下来的 n 个元素,cancel 用于取消订阅。

static class FilePublisher implements Publisher<String> {
 BufferedReader reader;
 public FilePublisher(BufferedReader reader)
        { this.reader = reader; }
 @Override
 public void subscribe(Subscriber<? super String> subscriber){
  subscriber.onSubscribe(
        new FilePublisherSubscription(this, subscriber));
 }
 public String readLine() throws IOException {
  return reader.readLine();
 }
}
static class FilePublisherSubscription
        implements Subscription {
 FilePublisher publisher;
 Subscriber<? super String> subscriber;
 public FilePublisherSubscription( FilePublisher publisher,
        Subscriber<? super String> subscriber) {
  this.publisher = publisher;
  this.subscriber = subscriber;
 }

 @Override
 public void request(long n) {
  try {
   String line;
   for (int i = 0; i < n && publisher != null
      && (line = publisher.readLine()) != null; i++) {
    if (subscriber != null) subscriber.onNext(line);
   }
  } catch (IOException ex) {
   subscriber.onError(ex);
  }
  subscriber.onComplete();
 }
 @Override
 public void cancel() {
  publisher = null;
 }

}

这个例子展示了如何实现一个包含背压支持的文件读取发布器。类似的方法可以用于任何发布者/订阅实现。

现在当我们用 File 对象调用 readFile(File)时,文件的内容将被读取并打印出来。通过以下方式使用 RxJava 也可以达到相同的效果:

  1. Single 很像一个只能发出一种元素的可观察对象。这里我们从 file 参数创建一个实例。

  2. 我们使用 Schedulers.io(),因为我们正在读取一个文件。

  3. 接下来,我们使用构造函数引用语法从原始文件实例化 FileReader 和 BufferedReader。

  4. 这里我们使用 flatMapPublisher 方法,它是 flatMap 的一个变体,只存在于“Single”上,并返回一个可流动的。

  5. 我们使用“fromIterable”创建一个新的 flow,它将使用 BufferedReader 读取文件的每一行。我们使用“Stream.generate ”,因为它反复调用由“readLineSupplier”方法给出的给定供应商。

  6. 当 readLine()返回 null 时,文件读取完成,但是迭代器不能提供 null,所以我们使用“EOF”来代替。我们使用它作为“takeWhile”的谓词,在该点终止流。

  7. 这里我们在处理每个元素时打印出当前线程的名称。

  8. 最后,我们再次使用 blockingSubscribe 将输出打印到标准输出。在实际应用中,我们很可能会做一些更有趣的事情。

Single<BufferedReader> readerSingle = Single.just(file) //1
         .observeOn(Schedulers.io()) //2
       .map(FileReader::new)
       .map(BufferedReader::new); //3
Flowable<String> flowable =
    readerSingle.flatMapPublisher(reader -> //4
      Flowable.fromIterable( //5
             () ->
      Stream.generate(readLineSupplier(reader)).iterator()
    ).takeWhile(line -> !"EOF".equals(line))); //6
flowable
       .doOnNext(it -> System.out.println("thread="
      + Thread.currentThread().getName())) //7
    .doOnError(ex -> ex.printStackTrace())
    .blockingSubscribe(System.out::println); //8

“readLineSupplier”方法定义如下:

private static Supplier<String>
      readLineSupplier(BufferedReader reader) {
    return () -> { try {
       String line = reader.readLine();
       return line == null ? "EOF" : line;
    } catch (IOException ex)
      { throw new RuntimeException(ex); }};
}

对给定文件运行此代码的结果将是文件的每一行都打印出来,并且“thread = RxCachedThreadScheduler-1”也为每一行打印一次。

反压力

热可观测量通常是使用背压流量控制策略(如节流、缓冲或窗口)的候选。除了这些选项之外,您还可以通过反压策略将可观察值转换为可流动值。

您可以使用toFlowable(strategy)方法将任何可观察值转换为有背压支持的可流动值。这样做是为了减轻上游(或发布者)比下游(或订阅者)能够处理的更快地发出项目的任何问题。

处理背压有五种主要策略:

  • LATEST :只保留最新发出的物品,这意味着如果它们来得太快,你可能会错过一些物品。

  • 丢弃:如果新的物品来的太快,丢弃它们。

  • BUFFER :将项目保存在内存中直到某个点(通常你提供一个限制)。

  • 错误:让流因错误条件而终止。

  • 没有任何策略:如果没有任何策略,发布者实际上会被告知放慢速度(request(n)将不会被调用,或者会被调用一个较小的数字)。这只能在有意义的情况下起作用。

例如:

  1. 从某个发布者创建一个可观察对象。

  2. 使用最新策略将可观察值转换为可流动值(其他可用值包括丢弃、缓冲和错误)。

Observable.fromPublisher(pub) //1
.toFlowable(BackpressureStrategy.LATEST) //2

使用toFlowable(BackpressureStrategy.ERROR)会导致背压事件发生时出错(发布的项目多于已处理的项目)。

同样,可流动类有以下方法来处理流动中任何点的背压:

  • onBackpressureLatest()

  • onBackpressureDrop()

  • onBackpressureBuffer()

它还有几个重载方法,用于提供缓冲区的配置,如容量或达到容量时要执行的操作。

有关该主题的更多信息,请参见 RxJava 背压文档

处理错误

有几种方法可以处理 RxJava 流中的错误:

  • 使用“dooner error(Consumer super Throwable>)”处理错误而不修改流。

  • 通过用 onErrorReturnItem(T)返回一个固定值来恢复。

  • 通过返回一个基于 onErrorReturn(函数)异常的值来恢复。

  • 通过返回带有 onErrorResumeNext(Publisher)的新发布者进行恢复。

  • 处理订阅服务器中的错误。

测试

RxJava 2 包含内置的、测试友好的解决方案,如 TestSubscriber 和 TestObserver。

  • TestSubscriber :记录事件的订阅者,您可以根据这些事件做出断言

  • TestObserver :记录事件的观察器,您可以根据这些事件做出断言

  • TestScheduler :可以用来严格控制与 RxJava 相关的测试执行

testsubscriver

例如,您可以创建一个 TestSubscriber,只需在任何可流动的:

TestSubscriber<Integer> ts =
  Flowable.range(1, 5).test();
assertEquals(5, ts.valueCount());

调用“valueCount()”返回流发出的项目总数,在本例中为 5。

TestSubscriber 还有大量以“assert”开头的其他方法,如 assertError,可用于断言某些事情的发生。例如:

Flowable<Integer> flowable = Flowable.create(source -> {
  source.onNext(1);
  source.onError(new RuntimeException());
}, BackpressureStrategy.LATEST);
TestSubscriber<Integer> ts = flowable.test();
ts.assertSubscribed();
ts.assertError(RuntimeException.class);

这里我们调用“assertError(Class)”和预期由可流动的抛出的异常类型。如果没有抛出,将抛出 AssertionError,导致测试失败。

测试观察者

同样,您可以通过对任何可观察对象调用“test()”来创建 TestObserver:

TestObserver<Integer> ts =
Observable.range(1, 5).test();
assertEquals(5, ts.valueCount());

TestObserver 和 TestSubscriber 都扩展了 BaseTestConsumer,因此具有大部分相同的方法。

测试调度程序

TestScheduler 可用于测试与时间相关的流。例如:

  1. 创建测试调度程序。

  2. 创建一个可观察的间隔,每秒钟发出一个数字。

  3. 创建一个只有四个字符串的可观察对象。

  4. 将这两个可观测量压缩在一起,组合成一个字符串“index-string”。

  5. 在我们的 TestScheduler 上订阅来自步骤 4 的可观察对象,并调用“test()”来获得 TestObserver 的一个实例。

  6. 通过调用值为 2.3 秒的“advanceTimeBy”来操作 TestScheduler,以便“tick”可观察值应该发出两个值。

  7. 断言没有错误,并且发出了我们期望的值。

TestScheduler scheduler = new TestScheduler(); //1
Observable<Long> tick = Observable
  .interval(1, TimeUnit.SECONDS, scheduler); //2
Observable<String> observable =
  Observable.just("foo", "bar", "biz", "baz") //3
  .zipWith(tick, (string, index) -> index + "-" + string);//4
TestObserver<String> testObserver = observable
  .subscribeOn(scheduler).test();//5
scheduler.advanceTimeBy(2300, TimeUnit.MILLISECONDS);//6
testObserver.assertNoErrors(); //7
testObserver.assertValues("0-foo", "1-bar");
testObserver.assertNotComplete();

使用 TestScheduler 的好处是使 RxJava 流表现得好像经过了一定的时间,尽管它并没有。这使得我们可以测试依赖于任何时间量(几小时或几天)的 RxJava 逻辑,并且我们的测试仍然快速运行。例如,前面的测试运行时间不到十分之一秒。

五、Reactor

Project Reactor 是 Spring 的 Reactive Streams 实现(在版本 3 及更高版本中)。它有两家主要发行商,Flux < T >和 Mono < T >。它还使用了与 RxJava 非常相似的调度器。

Spring 框架与 Reactor 有许多集成,这使得它更容易与其他 Spring 项目一起使用,如 Spring Data 和 Spring Security。

入门指南

如果您有一个 Maven 版本,请将以下内容添加到 pom 文件中:

<dependency>
  <groupId>io.projectreactor</groupId>
  <artifactId>reactor-core</artifactId>
  <version>3.1.9.RELEASE</version>
</dependency>
<dependency>
  <groupId>io.projectreactor</groupId>
  <artifactId>reactor-test</artifactId>
  <version>3.1.9.RELEASE</version>
  <scope>test</scope>
</dependency>

对于 Gradle 构建,将以下内容添加到 Gradle 构建文件的依赖项中:

compile 'io.projectreactor:reactor-core:3.1.9.RELEASE'
testCompile 'io.projectreactor:reactor-test:3.1.9.RELEASE'

流量

通量是 Reactor 反应流的主要入口点,类似于 RxJava 的可观测值。Mono 就像是一个通量,只不过是零到一个元素。单声道和通量乐器org.reactivestreams.Publisher

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

与 RxJava 非常相似,Reactor 使用调度程序来决定运行哪个线程。

例如,您可以创建如下所示的范围,并在“Schedulers.parallel()”上发布,这为并行执行提供了线程缓存:

Flux.range(1, 100)
  .publishOn(Schedulers.parallel())
  .subscribe(v -> System.out.println(v));

前面的代码将打印出数字 1 到 100。

Reactor 中的错误处理也非常类似于 RxJava。以下方法可用于 Flux 或 Mono(为简洁起见,省略了一般类型):

  • onErrorResume(Function):接受异常并返回一个不同的发布者作为后备或辅助流。

  • onErrorMap(Function):接受异常并允许您修改它,或者返回一个全新的异常。

  • onErrorReturn(T):提供出现错误时使用的默认值。

  • doOnError(Consumer<? super Throwable>):允许您处理错误,而不会以任何方式影响底层流。

错误总是通量或单声道的结束事件,应由订户处理。然而,很多时候,如在前面的例子中,错误是不可能的,因此不需要处理。

单声道的

单声道很像通量,但只有一个或零个元素。可以把它想象成 Java 8 的可选类到反应流世界的翻译。例如,下面将打印出值“hello”:

Mono.just("hello").subscribe(v -> System.out.println(v));

Mono 与 Flux 非常相似,只是它有如下方法

  • justOrEmpty(T) :取可空值,转换成单声道。如果为空,结果与Mono.empty()相同。

  • justOrEmpty(Optional) :取一个Optional,直接转换成Mono

不像 Java 的OptionalMono可以处理错误和其他事情。例如,返回Mono的方法可能会执行以下操作:

return Mono.error(new RuntimeException("your error"))

相应的代码可以像处理 Flux (using onErrorResume, onErrorMap, or onErrorReturn)一样处理来自单声道的错误。

创建通量或单声道

您可以从固定数据(冷数据)或以编程方式从动态数据(热数据)创建通量。

以下是产生冷流的一些不同方法:

  1. 从值列表中创建通量。

  2. 从一个迭代中产生一个通量。

  3. 创建一个从 1 到 64 的范围。

    Here’s how to create a simple Mono:

    Mono<String> noData = Mono.empty(); //1
    Mono<String> data = Mono.just("foo"); //2
    
    
  4. 创建一个空的单声道。

  5. 用一个元素创建一个单声道。

Flux<String> flux1 = Flux.just("a", "b", "foobar"); //1
List<String> iterable = Arrays.asList("a", "b", "foobar");
Flux<String> flux2 = Flux.fromIterable(iterable); //2
Flux<Integer> numbers = Flux.range(1, 64); //3

您可以使用generatecreatepush方法中的一种以编程方式创建热通量或冷通量。如果数据是连续的,比如用户输入、WebSocket 或网络数据包,那么它将被认为是热的。

generate方法(在一个品种中)需要一个Supplier和一个BiFunction。该函数将当前状态和用于发布流的下一个状态的SynchronousSink<T>作为参数。例如,下面使用了一个AtomicLong实例来递增数字 0 到 10,并提供每个数字的平方:

  1. AtomicLong 的构造器被用作提供者。

  2. 递增后,将数字的平方提供给接收器。

  3. 当数字为 10 时,调用 complete()方法,该方法对任何订户调用onComplete,关闭流量。

Flux<Long> squares = Flux.generate(
  AtomicLong::new, //1
  (state, sink) -> {
    long i = state.getAndIncrement();
    sink.next(i * i); //2
    if (i == 10) sink.complete(); //3
    return state;
});

create 方法接受一个使用 next、error 和 complete 方法公开 FluxSink 实例的消费者 super FluxSink>。这允许你以任何你认为合适的方式任意地发布数据到一个 Flux 上。

前面的代码将产生从 0 到 10 的数字平方的通量。

例如,下面演示了如何注册一个处理消息列表的MessageListener:

Flux<String> bridge = Flux.create(sink -> {
 messageProcessor.register(
  new MessageListener<String>() {
  public void handle(List<String> chunks) {
  for(String s : chunks) {
   sink.next(s);
  }
 }
 public void processComplete() {
  sink.complete();
 }
 public void processError(Throwable e) {
  sink.error(e);
 }
});
});

这里 sink 的类型是 FluxSink 。如果前面代码中处理的消息有单线程源,可以使用push方法代替create。push 方法与 create 具有相同的类型签名,因此它的使用方式类似。FluxSink 的方法返回 FluxSink,允许方法链接,所以下面的例子是可能的:

Flux.push((FluxSink sink) -> {
  sink.next(1).next(2).next(3).complete();
}).subscribe(System.out::println);

这将只打印出值 1、2 和 3。

调度程序

reactor.core.scheduler包下的 Schedulers 类为调度程序提供了许多静态方法,这些方法决定了您的代码将在哪个或哪些线程上运行。

以下是一些静态方法及其含义:

  • Schedulers.immediate():当前线程。

  • 一个单独的,可重复使用的线程。请注意,该方法对所有调用方重用同一个线程,直到调度程序被释放。如果您想要一个每个调用专用的线程,那么对每个调用使用Schedulers.newSingle()

  • Schedulers.newSingle():每次被底层 Flux 调用时创建一个新线程。

  • Schedulers.elastic():弹性线程池。它根据需要创建新的工作池,并重用空闲的工作池。闲置时间过长(默认值为 60 秒)的工作池将被释放。例如,对于 I/O 阻塞工作,这是一个很好的选择。Schedulers.elastic()是给阻塞进程一个自己的线程的简便方法,这样它就不会占用其他资源。

  • 固定的工人群体。它会创建与 CPU 核心数量一样多的工作线程。

  • 创建一个调度器来使用给定的执行器,允许您使用 Java 执行器的丰富知识。

例如,让我们以生成正方形为例,让它并行运行:

  1. 首先,我们使用Flux.range获取从 1 到 64 的范围,并调用flatMap(它采用一个 lambda 表达式,将范围内的每个值转换为一个新的反应器类型,在本例中为 Mono)。

  2. 使用Schedulers.newSingle(name),我们为每个值创建一个新的单线程,传递给subscribeOn将导致映射表达式在那个单线程上执行。请记住,我们在这里描述的是单声道的执行,而不是初始流量。

  3. 为了以防万一,我们提供了使用doOnError的异常处理代码。

  4. 当整个执行完成时,我们使用doOnComplete打印出“完成”。

  5. 最后,我们订阅通量(没有这一步,什么都不会发生)并将结果添加到我们的正方形列表中。

List<Integer> squares = new ArrayList<>();
Flux.range(1, 64).flatMap(v -> // 1
Mono.just(v)
 .subscribeOn(Schedulers.newSingle("comp"))
 .map(w -> w * w)) //2
 .doOnError(ex -> ex.printStackTrace()) // 3
 .doOnComplete(() -> System.out.println("Completed")) // 4
 .subscribeOn(Schedulers.immediate())
 .subscribe(squares::add); //5

运行这段代码的结果是,squares 列表中有从 1 到 64 的每个方块的值。

在这里,我们再次看到在反应流中,任何东西都可以变成一个流,甚至是一个值。通过为范围内的每个值创建一个 Mono,我们能够使用 Reactor 来声明我们希望每个计算使用哪种线程。在这种情况下,由于我们使用了newSingle,所有的处理都将通过一个新线程并行完成,处理所有 64 个值。

然而,这可能不是最有效的实现,因为创建大量线程会导致大量开销。相反,我们应该使用Schedulers.parallel(),这样就可以精确地计算出 CPU 可以处理的线程数量。这样,Reactor 会为您处理细节。

拉事件

如果您有更多的“拉”的情况(事件是通过轮询源创建的),您可以使用 Flux.create(FluxSink)方法。例如,下面的代码创建了一个 Flux,它轮询一个通道(某个虚构的类,表示来自 Reactor 外部的字符串流)以获取新事件:

  1. 当使用给定的数目发出请求时,轮询来自通道的事件。这个“n”是请求的项目数。

  2. 当流量被取消时,调用通道的 cancel 方法。

  3. 为 onDispose 提供了 channel.close 方法,以供完成、出错或取消时调用。

  4. 最后,将接收器的“next”方法注册为通道的侦听器。

Flux<String> bridge = Flux.create(sink -> {
sink.onRequest(n -> channel.poll(n)) //1
  .onCancel(channel::cancel) // 2
  .onDispose(channel::close); // 3
  channel.register(sink::next); //4
});

请记住,不会无缘无故地多次调用传递给 onRequest 的消费者。它将被某个数字(例如 256)调用,然后不再被调用,直到大量的项目被发布到该通量(即,sink.next 被调用多次)。

提醒:本书中使用的代码示例可从 GitHub 上获得。

处理背压

像所有反应流的实现一样,Reactor 具有处理背压的能力。只需在通量(或其他未列出的通量)上使用以下方法之一来指定您想要使用的背压策略:

  • onBackpressureBuffer() :缓冲所有项目,直到它们可以被下游处理。

  • onBackpressureBuffer(maxSize):缓冲项目直到给定的计数。

  • onBackpressureBuffer(maxSize,BufferOverflowStrategy) :缓冲最多到给定计数的项目,并允许您指定当缓冲区已满时使用的策略。BufferOverflowStrategy 是一个具有三个值的枚举:DROP_OLDEST,它丢弃缓冲区中最旧的项 DROP _ LATEST,它丢弃较新的项;ERROR,它将终止带有错误的流。

  • onbackpressurelast():类似于只保存最后添加的项目的缓冲区。如果下游跟不上上游,那么只会给下游最新的元素。

  • onBackpressureError() :如果上游生成的项目多于下游请求的项目,则通过 Exceptions.failWithOverflow()中的 IllegalStateException 以错误(调用下游订户的 onError)结束流量。

  • onBackpressureDrop() :删除超出请求的任何项目。例如,在 UI 代码中,这对于删除不能立即处理的用户输入非常有用。

  • onBackpressureDrop(Consumer):丢弃任何超出请求数量的商品,并为每个丢弃的商品调用给定的消费者。

对于这些方法中的每一种,这种策略只适用于生产速度超过处理速度的情况。如果不是这种情况,例如,对于冷流,没有背压策略是必要的。

例如,我们可能需要一个先前创建的名为“bridge”的流量,它是一个用户输入流,可以缓冲多达 256 个条目,如下所示:

bridge.onBackpressureBuffer(256)

还要记住,反应器并不神奇,在考虑背压策略时应该小心。

Reactor 有优秀的在线文档供参考。

语境

从版本 3.1.0 开始,Reactor 提供了一个高级特性,有点类似于 ThreadLocal,但它应用于 Flux 或 Mono,而不是线程:上下文。

Reactor 的上下文很像一个不可变的映射或键/值存储。它是从订阅者到订阅者透明地存储的。上下文是特定于反应器的,不与其他反应流实现一起工作。

当设置上下文时,您不应该在流程开始时定义它。这是因为它从订阅服务器开始,并向上游传递。例如,不要这样做:

// this is WRONG!
Flux<Integer> flux =
  Flux.just(1).subscriberContext(Context.of("pid", 123));

相反,您应该将它定义到末尾,因为它会沿着链“向后”传播。例如:

  1. 创造一个只有一个值的通量。

  2. 使用 flatMap,访问上下文,并使用它通过“pid”键创建一个字符串值。我们使用 Mono,subscriberContext()上的静态方法,通过对其调用“getOrDefault”来访问上下文中的值。

  3. 这使用了 StepVerifier (我们将在接下来讨论)来验证我们得到了预期的值。StepVerifier 在使用“subscriberContext”方法设置上下文后订阅流量。

  4. 使用值“1 pid: 123”调用“expectNext ”,这是我们通过在上下文中使用键“pid”设置值 123 所得到的结果。

Flux<Integer> flux = Flux.just(1); //1
Flux<String> stringFlux = flux.flatMap(i ->
  Mono.subscriberContext().map(ctx -> i + " pid: " +
    ctx.getOrDefault("pid", 0))); //2
// supply context here:
StepVerifier.create( //3
  stringFlux.subscriberContext(Context.of("pid", 123)))
    .expectNext("1 pid: 123") //4
    .verifyComplete();

上下文对于存储流的外围数据很有用,但仍然很重要。例如,有时我们有一些表示动作或发起动作的用户的标识符,我们希望将它包含在日志输出中(就像 MDC 在 logback 中的用途)。

测试

自动化测试总是一个好主意,如果有工具来直接测试反应流就更好了。幸运的是,Reactor 附带了一些专门用于测试的元素,这些元素被收集到我们前面提到的它们自己的工件中:reactor-test。

Reactor 测试的两个主要用途如下:

  • 使用 StepVerifier 测试序列是否遵循给定的场景

  • 用 TestPublisher 生成数据,以测试下游操作符(包括您自己的操作符)的行为

步骤验证器

Reactor 的 StepVerifier 可以用来验证 Reactor 发布者的行为(Flux 或 Mono)。StepVerifier 是一个用于测试的接口,可以使用 StepVerifier 本身的几种静态方法之一来创建。

下面是一个利用 StepVerifier 进行 JUnit 测试的简单示例:

  1. 创建一个 Mono 包装一个模拟实际错误状态的 RuntimeException。

  2. 创建一个包装单声道的 StepVerifier。

  3. 声明 onError 事件是预期的,并且异常的错误消息是“Error”。

  4. 最后必须调用 verify()。如果没有达到任何预期,这将抛出 AssertionError。

@Test
public void testStepVerifier_Mono_error() {
  Mono<String> monoError = Mono.error(
new RuntimeException("error")); //1
  StepVerifier.create(monoError) //2
    .expectErrorMessage("error") //3
    .verify(); //4
}

我们也可以创建一个只有一个字符串的单声道并验证它,例如:

  1. 创建一个单声道包装一个值,“foo”。

  2. 创建一个包装单声道的 StepVerifier。

  3. 用“foo”调用 Expect onNext。

  4. 调用 verifyComplete()的效果与 verify()相同,但也要求调用 onComplete。

@Test public void testStepVerifier_Mono_foo() {
Mono<String> foo = Mono.just("foo"); //1
StepVerifier.create(foo) //2
  .expectNext("foo") //3
  .verifyComplete(); //4
}

在这里,我们将使用三个值测试流量,如果测试时间过长,将会超时:

  1. 创造一个只有三个数字的通量。

  2. 创建包裹焊剂步进检验器。

  3. 为每个预期值调用 expectNext。

  4. 调用 expectComplete 以期望调用 onComplete。

  5. 最后,必须在最后调用 verify()。这种验证变化采用持续时间超时值。这是 10 秒钟。在发布者可能永远不会调用 onComplete 的情况下,这有助于防止测试挂起。

@Test public void testStepVerifier_Flux() {
Flux<Integer> flux = Flux.just(1, 4, 9); //1
StepVerifier.create(flux) //2
  .expectNext(1) //3
  .expectNext(4)
  .expectNext(9)
  .expectComplete() //4
  .verify(Duration.ofSeconds(10)); //5
}

测试发布者

TestPublisher 类提供了为测试目的提供微调数据的能力。TestPublisher 是一个 Reactive Streams Publisher ,但是可以使用 flux()或 mono()方法将其转换为 Flux 或 Mono。

TestPublisher 有以下方法:

  • next(T)和 next(T,T…) :触发 1-n on next 信号。

  • emit(T…) :与 next 相同,也以 onComplete 信号终止。

  • complete() :以 onComplete 信号终止。

  • 错误(可抛出):以一个 onError 信号终止。

下面演示了如何使用 TestPublisher:

  1. 创建 TestPublisher 实例。

  2. 将其转化为通量。

  3. 创建新列表。出于测试目的,我们将使用该列表从发布者处收集值。

  4. 使用 onNext 和 onError 的两个 lambda 表达式订阅发布服务器。这将把发布者发出的每个值添加到列表中。

  5. 从 TestPublisher 发出值“foo”和“bar”。

  6. 断言列表中添加了两个值,它们是我们所期望的。

TestPublisher<Object> publisher = TestPublisher.create(); //1
Flux<Object> stringFlux = publisher.flux(); //2
List list = new ArrayList(); //3
stringFlux.subscribe(next -> list.add(next), ex ->
  ex.printStackTrace()); //4
publisher.emit("foo", "bar"); //5
assertEquals(2, list.size()); //6
assertEquals("foo", list.get(0));
assertEquals("bar", list.get(1));

请注意,在发出任何值之前,您必须订阅 TestPublisher。

六、Akka 流

Akka 流 在更大的 Akka 并发项目中实现反应流标准。

Akka 流 的理念是提供一个最小且一致的应用程序编程接口(API ),这是一个非常复杂的接口,也就是说它被分解成可以用多种方式组合的部分。

与 RxJava 和 Reactor 不同,Akka 流中的流(流)的拓扑一旦被物化就不可改变。这意味着您必须显式地将一个流转换成一个反应流接口,以拥有一个动态拓扑(我们将在后面介绍)。

尽管 Akka 流 在基于 Scala 的应用程序中最为常见,但它有一个特定于 Java 的 API,文档允许您选择 Java 或 Scala 作为目标语言,并提供了具体的示例。

Akka 流 使用源和接收器的概念来大致对应于其他反应式流框架的发布者和订阅者。它还有流的概念,大致相当于处理器和图形,就像是流、汇或源的蓝图。

入门指南

如果您有一个 Maven 版本,请将以下内容添加到您的 pom 文件中:

<dependency>
        <groupId>com.typesafe.akka</groupId>
        <artifactId>akka-stream_2.12</artifactId>
        <version>2.5.16</version>
</dependency>
<dependency>
        <groupId>com.typesafe.akka</groupId>
        <artifactId>akka-stream-testkit_2.12</artifactId>
        <version>2.5.16</version>
        <scope>test</scope>
</dependency>

对于 Gradle 构建,将以下内容添加到您的 Gradle 构建文件的依赖项中:

compile 'com.typesafe.akka:akka-stream_2.12:2.5.16'
testCompile 'com.typesafe.akka:akka-stream-testkit_2.12:2.5.16'

使用以下导入:

import akka.stream.*;
import akka.stream.javadsl.*;

在本例中,我们将获取一个消息流,并提取所有以错误开头的消息:

  1. 我们创建 Akka ActorSystem 来定义多线程执行环境。我们提供了一个可选的名称“反应消息”,并为 ActorSystem 提供了一个逻辑名称。

  2. 执行环境(类似于 RxJava 中的调度器)在这里被称为具体化器。与 RxJava 不同,开发人员通过在源或流上调用 async()和 mapAsync(int,Function)等方法来控制并发性。

  3. 我们只过滤掉错误消息。

  4. 尽管不是必需的,我们在每个消息上调用 toString 来说明如何使用 map 方法。

  5. 最后,我们使用 runWith 并传入一个打印出每个错误消息的接收器。

final ActorSystem system = ActorSystem.create(
"reactive-messages"); //1
final Materializer mat = ActorMaterializer.create(system); //2
Source<String, NotUsed> messages = Source
.single("Error: test message");
final Source<String, NotUsed> errors =
        messages.filter(m -> m.startsWith("Error")) //3
        .map(m -> m.toString()); //4
errors.runWith(Sink.foreach(System.out::println), mat); //5

虽然这里我们使用的是 foreach 接收器,但是可以使用任何接收器,包括用户定义的接收器。

为了避免与 Scala 中现有的 flatMap 发生概念冲突,Akka 流 使用 flatMapConcat、flatMapMerge 和 MapConcat。mapConcat 方法需要从函数返回的 Iterables,而不是 streams。另外两种方法顾名思义,要么合并流,要么顺序追加流。

ActorMaterializer

Akka 流中的 ActorMaterializer 类似于其他两个反应流实现中的调度器,但并不相同。与调度程序不同,没有几个预定义的单例可供选择;相反,您通常应该为整个应用程序创建一个并指定一些常规设置。

ActorMaterializer 通过以下方式创建:

  1. 创建 ActorSystem。

  2. 可以选择创建 ActorMaterializerSettings。这允许您配置 Akka 流使用的内部设置,以增强特定项目的性能。

  3. 将最大固定缓冲区大小设置为 100。具有显式缓冲区的流元素(如 mapAsync、mapAsyncUnordered、flatMapMerge、Source.actorRef、Source.queue 等)。)将使用该值作为初始固定缓冲区大小。默认值非常大,以使故障更早发生,而不是在向上扩展时。例如,如果您想使用少量内存,您可以更改它。

  4. 设置内部流缓冲区的初始和最大大小。这里我们将初始值设置为 8,最大值设置为 16,这是默认值。

  5. 最后用给定的设置和 ActorSystem 创建 ActorMaterializer。

public static Materializer createMaterializer() {
 final ActorSystem system = ActorSystem.create(); // 1
 ActorMaterializerSettings settings =
  ActorMaterializerSettings.create(system) //2
        .withMaxFixedBufferSize(100) //3
        .withInputBuffer(8, 16); //4
 return ActorMaterializer.create(settings,system);//5

汇、流和图

Akka 流的一个有趣之处在于,它的每一部分都是可以定义的,不可变的,并且可以独立重用。为此,Akka 流 有流、图、源和汇的概念。

  • :流既有输入又有输出。因此,您可以定义一个只包含将要传输的数据类型的流,而不包含实际的数据。它类似于既是发布者又是订阅者的 org . react vestreams . processor。

  • :图可以定义流的任意分支和重组。图是不可变的、线程安全的和可重用的。自包含(没有输入或输出)的图是可运行图,并且可以被具体化。

  • :一个源只有一个输出。它是一个数据源,类似于发布者,可以用许多不同的方式创建。

  • Sink :如前所述,Sink 是流的终点。它代表了我们对数据的处理。它只有一个输入。

使用 Flow,您可以独立于定义任何源来定义接收器。例如,此接收器将保存到一个文件:

public Sink<String, CompletionStage<IOResult>> lineSink(
        String filename) {
  return Flow.of(String.class)
    .map(s -> ByteString.fromString(s.toString() + "\n"))
    .toMat(FileIO.toPath(Paths.get(filename)),
         Keep.right());
}

首先,我们创建一个“字符串”类型的流;这声明了您所期望的类型。其次,我们将每个字符串映射到一个字节字符串。此时,类型现在是流量。最后,我们调用 toMat(to materialized 的缩写)使用现有的接收器将结果写入文件(FileIO 是 Akka 流 Java DSL 的一部分)。我们指定 Keep.right()来保持辅助信息不被 toPath 发现。

接收器一旦定义,就可以多次使用。请注意,定义接收器不会完成任何操作。在我们用一些资源实现它之前,还没有拯救发生。例如:

public void saveTextFile(List<String> text) {
        Sink<String, CompletionStage<IOResult>> sink =
                lineSink("testfile.txt");
        Source.from(text).runWith(sink, materializer);
}

这个方法将获取一个字符串列表,从它们中创建一个源,然后使用 lineSink 方法中的接收器将它们保存到一个文件中。

可以使用 GraphDSL 创建图表。例如,使用前面定义的方法,我们可以创建一个 SinkShape 的图形,如下所示:

  1. 用 Flow 调用 builder.add 以获得 FlowShape。这里我们创建了一个异步流。

  2. 通过调用我们的 lineSink 方法创建一个新的接收器

  3. 从水槽创建一个水槽形状

  4. 将 flowShape 的输出链接到 sinkShape。

  5. 使用 flowShape 的输入返回一个新的 SinkShape。我们现在已经创建了一个 SinkShape 的图形,它将文本行保存到一个文件中。

public Graph<SinkShape<String>, NotUsed> createFileSinkGraph() {
   return GraphDSL.create(builder -> {
    FlowShape<String, String> flowShape = builder
     .add(Flow.of(String.class).async()); //1
    var sink = lineSink("testfile.txt"); //2
    var sinkShape = builder.add(sink); //3

    builder.from(flowShape.out()).to(sinkShape); //4
    return new SinkShape<>(flowShape.in()); //5
  });
}

我们可以通过调用 Sink.fromGraph 创建一个 Sink 来使用此图:

public void saveTextFileUsingGraph(List<String> text) {
  Sink.fromGraph(createFileSinkGraph())
    .runWith(Source.from(text), materializer);
}

前面的代码将使用我们创建的图来创建一个新的接收器,并使用从给定列表创建的源来运行它,从而保存文本,列表的每个元素一行。

反压力

可以在流上定义背压策略,以描述当产生太多元素时该做什么。例如,我们可以缓冲我们的消息流:

messages
        .buffer(100, OverflowStrategy.dropHead())

这将缓冲 100 个元素,丢弃最旧的(dropHead)。你应该选择最适合你的问题空间的策略。

其他选项包括

  • dropTail() :从缓冲区中删除最新的元素。

  • dropBuffer() :一种激进策略,一旦缓冲区满了就丢弃整个缓冲区。

  • dropNew() :当缓冲区已满时,删除任何新元素。

  • 背压():如果缓冲区已满,该策略将导致背压信号被推送到上游。换句话说,从上游请求的数量将下降到零,直到缓冲区不再满。

  • fail() :当缓冲区已满时,使流完全失败。

与反应流 API 的互操作

由于 Akka 流 不可变的拓扑需求,熟悉其他 Reactive Streams 库的人可能会感到惊讶。

为了从 Akka 流拓扑中获取发布者或订阅者,必须使用相应的 Sink.asPublisher 或 Source.asSubscriber 元素。

必须使用 Sink . asppublisher(asppublisher)创建接收器。WITH_FANOUT)(用于启用扇出支持),其中需要广播行为来与其他反应式流实现进行互操作。如果“作为一个出版商。而不使用 _FANOUT ”,则生成的发布服务器将只允许一个订阅服务器。

Akka 流 流也可以使用 Flow 的 top Processor()方法转换为处理器;但是,它也仅限于一个订户。

为了避开这些限制,并在 Akka 流中创建动态流处理,可以使用 MergeHub、BroadcastHub 和 PartitionHub。

MergeHub、BroadcastHub 和 PartitionHub

对于需要有多个数据消费者或生产者的动态定义的数据流,Akka 流 有以下类别:

  • MergeHub 允许任意数量的流进入单个接收器。

  • 一组动态的消费者可以使用 BroadcastHub 来消费来自一个公共生产者的元素。

  • PartitionHub 可用于将元素从一个公共生产者路由到一组动态消费者。消费者的选择是通过一个函数完成的,每个元素只能被路由到一个消费者。

例如,下面是一个简单的 MergeHub 用例:

  1. 将打印到控制台的简单消费者。

  2. 将 MergeHub 源连接到消费者。这将在运行时具体化为相应的接收器。缓冲区大小由每个生产者决定。

  3. 最后,我们必须运行并具体化 runnableGraph 来获取接收器。这个接收器可以被具体化任意次,进入其中的每个元素都将被步骤 1 中定义的“消费者”消费。

Sink<String, CompletionStage<Done>> consumer =
        Sink.foreach(System.out::println); //1
int bufferSize = 8;
RunnableGraph<Sink<String, NotUsed>> runnableGraph =
    MergeHub.of(String.class, bufferSize)
        .to(consumer); //2
Sink<String, NotUsed> toConsumer =
    runnableGraph.run(materializer); //3

有关 MergeHub、BroadcastHub 和 PartitionHub 的更多信息,请参见文档

测试

Akka 流 包括一个 testkit,帮助您围绕应用程序创建测试。它包括以下内容:

  • TestKit :有一个在每次测试之间关闭 ActorSystem 的有用方法

  • TestSink :支持使用 TestSubscriber 直接探测 Akka 流源。探测< T >实例

  • TestSource :启用使用 TestPublisher 探测接收器。探测器<字符串>探测器实例

将以下导入添加到测试类中:

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;
import akka.NotUsed;
import akka.actor.ActorSystem;
import akka.japi.Pair;
import akka.stream.ActorMaterializer;
import akka.stream.javadsl.*;
import akka.stream.testkit.*;
import akka.stream.testkit.javadsl.TestSink;
import akka.testkit.javadsl.TestKit;
import org.junit.*;

定义我们的安装和拆卸方法:

ActorSystem system;
ActorMaterializer materializer;
@Before
public void setup() {
  system = ActorSystem.create();
  materializer = ActorMaterializer.create(system);
}
@After
public void tearDown() {
  TestKit.shutdownActorSystem(system);
}

现在我们可以使用 TestSink 编写测试来探测任何源代码。例如:

  1. 创建 TestSink 实例。

  2. 创建我们想要测试的源。在真正的测试中,这将来自您的产品代码的某个部分。

  3. 使用 TestSink 运行源代码,并使用结果 TestSubscriber。探测实例以请求一个值,并期望它是“test”。调用 expectComplete()意味着我们期望源发送“on-complete”信号,如果没有,它将通过 AssertionError 发送。

@Test
public void test_a_source() {
  Sink<Object, TestSubscriber.Probe<Object>> sink =
        TestSink.probe(system); //1
  Source<Object, NotUsed> sourceUnderTest =
        Source.single("test"); //2
  sourceUnderTest.runWith(sink, materializer) //3
        .request(1)
        .expectNext("test")
        .expectComplete();
}

我们还可以使用 TestSource.probe(ActorSystem)方法测试接收器,如下所示:

  1. 获取我们想要测试的接收器的实例。

  2. 使用 sinkUnderTest 创建并具体化 TestSource,并使用 Keep.both()保留具体化的值和辅助值。

  3. 获取对 TestPublisher 的引用。Probe 和 CompletionStage(未来),因为我们稍后会用到它们。

  4. 在探测器上调用几个方法来期待接收器请求的数据,发送一些数据,然后在带有异常实例的探测器上调用 sendError。

  5. 将上一步中的 CompletionStage 转换为 CompletableFuture,并调用“get ”,超时两秒钟(以防基础 Future 永远不会完成)。

  6. 最后,断言抛出了异常,并显示消息“boom!”

Sink<String, CompletionStage<List<String>>>
        sinkUnderTest = Sink.seq(); //1
final Pair<TestPublisher.Probe<String>,
 CompletionStage<List<String>>> stagePair =
   TestSource.<String>probe(system)
        .toMat(sinkUnderTest, Keep.both()) //2
        .run(materializer);
final TestPublisher.Probe<String> probe =
        stagePair.first(); //3
final CompletionStage<List<String>> future =
        stagePair.second();
probe.expectRequest(); //4
probe.sendNext("test");
probe.sendError(new Exception("boom!"));
try {
  future.toCompletableFuture().get(2, TimeUnit.SECONDS); //5
  assert false;
} catch (ExecutionException ee) {
  final Throwable exception = ee.getCause();
  assertEquals(exception.getMessage(), "boom!"); //6
}

七、Android 和 RxJava

RxAndroidRxBindingRxLifecycle 为 Android 提供 RxJava 绑定。这使得在 Android 应用程序中使用 RxJava 变得更加容易。

自从 Android Studio 2.4 发布以来,它已经支持使用 Java 8 的 lambda 语法,我们可以在 RxJava 相关代码中大量使用该语法。

RxBinding 是一个开源的 Java 绑定 API 库,用于平台和支持库中的 Android UI 小部件。

在本章中,我们将使用 Android Studio 构建一个包含 RxAndroid、RxBinding、RxLifecycle 和 RxJava 的简单示例应用程序。该代码可在 GitHub 上获得。

入门指南

如果你还没有,去下载你的操作系统最新的安卓工作室,安装并运行它。Android Studio 启动后,执行以下步骤开始:

  1. 通过从菜单中选择文件➤新项目创建一个新项目,并给它一个名称(如 RxAndroidTest)。

img/470926_1_En_7_Figa_HTML.jpg

  1. 选择 8.0(奥利奥)作为目标版本。

img/470926_1_En_7_Figb_HTML.jpg

  1. 出现提示时,选择“登录活动”,当它说“添加一个活动到手机”。

img/470926_1_En_7_Figc_HTML.jpg

  1. 然后,单击左侧的模块名称(如“app”),按下 F4,然后确保您的 Java 版本至少设置为 8(允许 lambdas)。

img/470926_1_En_7_Figd_HTML.jpg

项目启动后,将所需的依赖项添加到构建文件(app/build.gradle)中:

implementation
 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation
 'io.reactivex.rxjava2:rxjava:2.2.2'
implementation
 'com.jakewharton.rxbinding2:rxbinding:2.1.1'

因为 RxAndroid 版本很少,所以建议您也明确依赖 RxJava 的最新版本来获得错误修复和新功能(最新的 2.x 版本请参见 RxJava GitHub )。

此外,要启用 Java 8 代码风格,您可能需要在 build.gradle 的“android”块下添加以下内容:

compileOptions {
 sourceCompatibility '1.8'
 targetCompatibility '1.8'
}

安卓 SDK

在编译项目之前,您需要安装一个或多个版本的 Android 软件开发工具包(SDK)。

为此,请选择“文件”菜单,然后选择“设置...”然后在搜索框中输入“SDK”,选择“Android SDK”。确保至少安装一个 Android SDK 并接受许可。

img/470926_1_En_7_Fige_HTML.jpg

Android 调度程序

RxAndroid 提供了 AndroidSchedulers,让你可以在 Android 特有的线程上运行,比如主线程。例如:

Observable.just("one", "two", "three", "four")
 .observeOn(AndroidSchedulers.mainThread())
 .subscribe(each ->
        System.out.println(each.toUpperCase()));

这将在 Android 的主线程上运行这个可观察对象的动作。这很有用,因为对 UI 的更新应该发生在主线程上。

要找出代码在哪个线程上执行,只需使用 Thread.currentThread()。getName()。例如,我们可以用下面的代码替换前面代码中的最后一行,以打印出当前线程的名称:

System.out.println(
        Thread.currentThread().getName())

你也可以使用 AndroidSchedulers 来创建一个围绕任意 Looper 的调度器。例如:

Looper looper = Looper.myLooper();
RxView.clicks(button)
  .observeOn(AndroidSchedulers.from(looper))
  .subscribe();

rxbinding(外部参考)

使用 RxBinding,您可以轻松地将 Android UI 事件转换成 RxJava Observables。首先,将以下导入添加到 LoginActivity.java:

import com.jakewharton.rxbinding2.view.*;
import com.jakewharton.rxbinding2.widget.*;
import io.reactivex.Observable;

例如,让我们用一个按钮来订阅点击事件。打开“LoginActivity.java”,找到以“Button mEmailSignInButton”开头的那一行。

查找并注释掉以下代码:

Button mEmailSignInButton = (Button)
        findViewById(R.id.email_sign_in_button);
mEmailSignInButton.setOnClickListener(
        new OnClickListener() {
        @Override
        public void onClick(View view) {
         attemptLogin();
        }
});

这可以使用 RxAndroid 替换为以下内容:

Button button = (Button) findViewById(R.id.email_sign_in_button);
RxView.clicks(button).subscribe(event -> {
        attemptLogin();
});

我们还可以观察编辑文本上的文本变化,例如:

RxTextView.textChangeEvents(editText)
        .subscribe(e -> log(e.text().toString()));

使用这些绑定,我们可以以不同的方式将可观测量组合在一起,以实现我们的最终目标。例如,添加以下代码:

Observable<TextViewTextChangeEvent>
        emailChangeObservable =
        RxTextView.textChangeEvents(mEmailView);
Observable<TextViewTextChangeEvent>
        passwordChangeObservable =
        RxTextView.textChangeEvents(mPasswordView);
// force-disable the button
button.setEnabled(false);
Disposable d = Observable.combineLatest(
        emailChangeObservable, passwordChangeObservable,
        (emailObservable, passwordObservable) -> {
        boolean emailCheck =
       emailObservable.text().length() >= 3;
        boolean passwordCheck =
       passwordObservable.text().length() >= 3;
        return emailCheck && passwordCheck;
}).subscribe(
        enabled -> button.setEnabled(enabled));

在这个例子中,只有当两个表单都超过三个字符时,提交按钮才是可点击的。

上面(d)的可处置实例保存了对视图的引用,因此我们必须取消订阅流或使其终止,以防止内存泄漏。这可以通过使用 RxLifecycle 库以一致的方式实现。

生命周期

RxLifecycle 是一个开源库,用于绑定 Android 组件的生命周期事件。例如,这对于删除订阅和避免销毁/暂停事件时的内存泄漏非常有用。

要开始使用 RxLifecycle,请将以下依赖项添加到“build.gradle”文件中:

implementation 'com.trello.rxlifecycle2:rxlifecycle:2.2.2'
implementation
'com.trello.rxlifecycle2:rxlifecycle-android:2.2.2'
implementation
'com.trello.rxlifecycle2:rxlifecycle-components:2.2.2'

接下来,将以下导入添加到您的活动中:

import com.trello.rxlifecycle2.components.support\
        .RxAppCompatActivity;

然后更改 LoginActivity 以扩展“Rx”等效项(在本例中为 RxAppCompatActivity):

public class LoginActivity extends RxAppCompatActivity implements LoaderCallbacks<Cursor> {

最后,您现在可以使用“compose”和 RxLifecycle 将序列绑定到生命周期事件。例如:

@Override
public void onResume() {
super.onResume();
Observable<Long> mySequence = Observable.interval(200, TimeUnit.MILLISECONDS);
mySequence
 .doOnNext(x -> System.out.println(
        "poll the server"))
 .observeOn(AndroidSchedulers.mainThread())
 .compose(bindToLifecycle())
 .subscribe();
}

这里的“mySequence”可以是任何 RxJava 类型,比如 Observable、flow、Single 或 Maybe。在这种情况下,“Observable.interval”将每隔 200 毫秒发出一个值。

RxLifecycle 确定结束序列的合适时间,例如:如果在 START 期间订阅,将在 STOP 时终止;如果您在暂停后订阅,它将在下一个销毁事件时终止。

RxLifecycle 将在适当的时候终止序列,并根据原始序列的类型产生以下结果:

  • 可观察,可流动,可能:未完成时发射()

  • 单一且可完成的:发出一个错误(CancellationException)

在前面的示例中,通过将代码放在“onResume”中,这将导致我们的轮询在 Resume 之后发生,并在暂停事件时停止。

把它放在一起

让我们使用 RxLifecycle 和 RxAndroid 来改进之前的代码:

  1. 我们使用和以前一样的“组合测试”来确保两个输入都至少有三个字符。

  2. 我们使用 RxActivity 实例绑定到生命周期,以便我们的可观察对象在适当的时候停止。

  3. 我们在 Android 主线程上观察。

  4. 最后,我们订阅对流做我们想做的事情,在本例中是启用或禁用“登录”按钮。

Observable.combineLatest(
        emailChangeObservable,
        passwordChangeObservable,
        (emailObservable, passwordObservable) -> {
        boolean emailCheck =
       emailObservable.text().length() >= 3;
        boolean passwordCheck =
       passwordObservable.text().length() >= 3;
        return emailCheck && passwordCheck; //1
})
.compose(bindToLifecycle()) //2
.observeOn(AndroidSchedulers.mainThread()) //3
.subscribe(
        enabled -> button.setEnabled(enabled)); //4

由于我们在“onCreate”方法中调用了“bindToLifecycle ”, rx life cycle 将导致序列在“相反的”动作上终止,在本例中为“onDestroy”。这将释放我们对电子邮件和密码视图的引用,防止内存泄漏。

使用 RxJava

使用基本的 RxJava 操作,我们可以改善“嘈杂的”数据输入,以防止像意外双击这样的事情导致一个动作发生两次。

使用“反跳”操作符,我们可以延迟一个事件动作,直到流在指定的时间内保持安静。例如,在按钮点击时,我们可以设置 500 毫秒(半秒)的去抖。这将在按钮被单击然后半秒钟没有被单击之后运行操作:

RxView.clicks(button).debounce(500,
        TimeUnit.MILLISECONDS)

与延迟动作的反跳不同,“throttleFirst”操作符用于防止在第一个事件发出后的某个时间间隔内重复事件。ThrottleFirst 在防止按钮被重复单击时出现双重动作,但仍然在第一次单击时应用动作方面非常有用。例如,按如下方式使用 throttleFirst:

RxView.clicks(button).throttleFirst(1,
        TimeUnit.SECONDS)

前面的代码通过过滤掉第一次点击后一秒内发生的任何点击,允许点击事件。

测试

为了全面测试我们的应用程序,我们应该运行一个虚拟系统。按" Shift+F10 "或单击"运行➤运行..."菜单并选择一种电话类型。如果您尚未下载系统映像,则需要通过单击“创建新虚拟设备”按钮并按照向导进行操作来下载。选择一个系统映像,然后单击“完成”。

img/470926_1_En_7_Figf_HTML.jpg

关于创建 Android 应用程序的更多内容超出了本书的范围。

要了解更多,请查阅一本好书或阅读 Google 的在线文档。

八、Spring Boot 和 Reactor

Spring Boot 极大地简化了基于 Spring 的应用或微服务的创建。

它采用自以为是的方法,为您可能需要的一切提供合理的默认值,可以让您快速启动并运行。它使用注释(不需要 XML)并且不生成代码。

通过 WebFlux,我们可以使用 HTTP 或 WebSocket 连接快速创建异步、非阻塞和事件驱动的应用程序。Spring 在其许多 API 中使用了自己的 Reactive Streams 实现 Reactor(带有 Flux 和 Mono)。当然,如果您愿意,可以在应用程序中使用另一个实现,比如 RxJava。

在这一章中,我们将看看如何使用 Spring Boot、WebFlux 和 Reactor 以及 MongoDB 持久层来实现一个完整的项目。

入门指南

启动 Spring Boot 项目有几种方式。其中包括以下内容:

  1. 转到 Spring Initializr 并从那里创建一个项目模板。还有一些工具,比如 Spring Tool Suite,利用了 IDE 中的 Spring 初始化器。

  2. 创建自己的基于 Maven 的项目。

  3. 创建自己的基于 Gradle 的项目。

出于本书的目的,我们将选择第三个选项,并创建一个基于 Java 的渐变项目。

Spring Boot 是高度可定制的,你可以为你的项目添加任何你想要的“启动器”(网络、邮件、freemarker、安全等)。).这使得它尽可能的轻便。

我们将创建一个基于 WebFlux 的项目,它将 Spring 的 Reactor 项目与 MongoDB 一起使用,以便拥有一个完全反应式的 web 应用程序。

这个项目的代码可以在 GitHub 上的 adamldavis/humblecode 获得。

Gradle Plugin

使用 WebFlux 的 Spring Boot 最基本的 Gradle build 如下所示:

  1. 您可能注意到的第一件事是缺少指定的版本;Spring Boot 为您提供这些,并确保一切都是兼容的基础上指定的 Spring Boot 版本。您也不需要指定主类。这是通过注释确定的。

  2. 我们包含了“webflux”启动器来启用 Spring 的 WebFlux 和“reactor-test ”,以允许我们更容易地测试基于 reactor 的代码。

  3. 我们在这里包含 Lombok 项目只是为了简化模型类。Lombok 为您提供了自动生成样板代码的注释,如 getters 和 setters。

  4. 在这里,我们包括了使用 MongoDB 和 Reactor 集成的 Spring Data start。

  5. 我们包含了“spring-boot-starter-test”工件来帮助我们测试应用程序。

  6. 我们包含了“reactor-test ”,使测试与 reactor 相关的代码变得更加容易。

buildscript {
 ext {
  springBootVersion = '2.0.4’
 }
 repositories {
  mavenCentral()
 }
 dependencies {
  classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
 }
}
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'groovy'
apply plugin: 'idea'
dependencies { //1
 compile('org.springframework.boot:spring-boot-starter-webflux') //2
 compile('org.codehaus.groovy:groovy')
 compileOnly('org.projectlombok:lombok') //3
 compile('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') //4
 testCompile('org.springframework.boot:spring-boot-starter-test') //5
 testCompile('io.projectreactor:reactor-test') //6
}

请记住,为了使后端完全反应性,我们与数据库的集成需要是异步的。这不是每种类型的数据库都能做到的。在这种情况下,我们使用 MongoDB。

在撰写本文时,Spring“只”为 Redis、MongoDB 和 Cassandra 提供反应式集成。要做到这一点,只需在“starter”编译依赖项中为您想要的数据库切换“mongodb”即可。PostgreSQL 有一个可用的异步驱动程序, postgres-async-driver ,所以将来可能会支持它。

任务

Spring Boot 插件为构建添加了几个任务。

要运行该项目,请运行“gradle bootRun”(默认情况下在端口 8080 上运行)。查看命令行输出,了解有用的信息,比如应用程序运行在哪个端口上。例如,最后四行可能如下所示:

2018-09-28 15:23:41.813  INFO 19132 --- [main] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup
2018-09-28 15:23:41.876  INFO 19132 --- [server-epoll-13] r.ipc.netty.tcp.BlockingNettyContext  : Started HttpServer on /0:0:0:0:0:0:0:0%0:8003
2018-09-28 15:23:41.876  INFO 19132 --- [main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port(s): 8003
2018-09-28 15:23:41.879  INFO 19132 --- [main] c.h.humblecode.HumblecodeApplication : Started HumblecodeApplication in 3.579 seconds (JVM running for 4.029)

当您准备好部署时,运行“gradle bootRepackage ”,它构建了一个胖 jar,在一个 jar 中包含了运行完整应用程序所需的所有内容。

回弹应用

主类是通过用@SpringBootApplication 注释它来指定的。例如,创建一个名为 HumblecodeApplication 的类,并将其放入 com.humblecode 包中,然后放入以下内容:

  1. main 方法调用 SpringApplication.run 来启动应用程序。

  2. 可以使用方法上的@Bean 注释直接创建 Bean。这里我们创建了一个简单的元素流

package com.humblecode;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.*;
import org.springframework.context.annotation.Bean;
import reactor.core.publisher.Flux;

@SpringBootApplication
public class HumblecodeApplication {

  public static void main(String[] args) { //1
    SpringApplication.run(
        HumblecodeApplication.class, args);
  }
  @Bean
  public Flux<String> exampleBean() { //2
           return Flux.just("example");
  }
}

@SpringBootApplication 注释告诉 Spring 许多事情:

  1. 使用自动配置。

  2. 使用组件扫描。它将扫描所有包和子包中用 Spring 注释注释的类。

  3. 这个类是一个基于 Java 的配置类,因此您可以在返回 bean 的方法上使用@Bean 注释来定义 Bean。

自动配置

Spring Boot 考虑应用程序的运行时,并根据许多因素(如类路径上的库)自动配置应用程序。

它遵循的格言是:“如果每个人都必须做,那么为什么每个人都必须做?”

例如,要创建一个典型的 MVC web 应用程序,您需要添加一个配置类和多个依赖项,并配置一个 Tomcat 容器。使用 Spring Boot,您需要添加的只是一个依赖项和一个控制器类,它会自动添加一个嵌入式 Tomcat 实例。

配置文件可以定义为属性文件、yaml 和其他方式。首先,在“src/main/resources”下创建一个名为“application.properties”的文件,并添加以下内容:

server.port=8003
app.name=Humble Code

这会将服务器设置为在端口 8003 上运行,并设置一个用户定义的属性 app。名称,可以是任意值。

稍后,您可以添加自己的配置类来更好地配置应用程序中的安全性等内容。例如,下面是 SecurityConfig 类的开头,它将在您的应用程序中启用 Spring 安全性:

@EnableWebFluxSecurity
public class SecurityConfig

稍后,我们将探索如何为 WebFlux 项目增加安全性。

我们的领域模型

在本节中,我们将实现一个非常简单的网站,它带有一个用于在线学习的 restful API。每门课程都有一个价格(以美分计)、一个名称和一个部分列表。

我们将使用以下领域模型课程类定义:

  1. 前两个注释是 Lombok 注释。@Data 告诉 Lombok 为每个字段添加 getters 和 setters、一个 toString()方法、equals 和 hashCode()方法以及一个构造函数。

  2. @Document 注释是 Spring Data mongo 注释,用来声明这个类代表一个 mongo 文档。

  3. @Id 注释表示该文档的 Id 属性。

import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.*;
import java.util.*;
@Data //1
@AllArgsConstructor
@Document //2
public class Course {
 @Id UUID id = UUID.randomUUID(); //3
 public String name;
 public long price = 2000; // $20.00 is default price
 public final List<Segment> segments = new ArrayList<>();
 public Course(String name) {this.name = name;}
}

安装 MongoDB 后,可以用以下命令启动它:

mongod –dbpath data/ --fork \
        --logpath ∼/mongodb/logs/mongodb.log

反应免疫储存

首先,我们需要创建一个到后端数据库的接口,在本例中是 MongoDB。

使用我们包含的 spring-boot-starter-data-MongoDB-reactive 依赖项,我们可以简单地创建一个扩展 ReactiveMongoRepository 的新接口,Spring 将生成支持我们使用标准命名方案定义的任何方法的代码。通过返回反应器类别,如 Flux 或 Mono,这些方法将自动反应。

例如,我们可以创建一个课程存储库:

  1. 第一个泛型类型是这个存储库存储的类型(课程),第二个是课程 ID 的类型。

  2. 该方法查找名称与给定搜索字符串匹配的所有课程。

  3. 该方法查找具有给定名称的所有课程。如果我们确定名称是唯一的,我们可以使用 Mono findByName(字符串名称)。

import com.humblecode.humblecode.model.Course;
import org.springframework.data.mongodb.\
repository.ReactiveMongoRepository;
import reactor.core.publisher.Flux;
import java.util.UUID;
public interface CourseRepository extends
ReactiveMongoRepository<Course, UUID> { //1
Flux<Course> findAllByNameLike(String searchString); //2
Flux<Course> findAllByName(String name); //3
}

简单地通过扩展 ReactiveMongoRepository 接口,我们的库将拥有大量有用的方法,如 findById、insert 和保存所有返回的反应器类型(Mono 或 Flux)。

控制器

接下来,我们需要制作一个基本的控制器来呈现我们的视图模板。

用@Controller 注释一个类,创建一个 web 控制器。例如:

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
public class WebController {
  @GetMapping("/")
  public Mono<String> hello() {
        return Mono.just("home");
  }
}

由于前面的方法返回由 Mono 包装的字符串“home ”,它将呈现相应的视图模板(位于 src/main/resources/templates 下),如果我们有一个的话;否则它只会返回字符串本身。

GetMapping 批注与使用@RequestMapping(path = "/",method = RequestMethod)相同。获取)。

默认情况下,基于 WebFlux 的 Spring Boot 应用程序使用嵌入式 Netty 实例,尽管您可以将其配置为使用 Tomcat、Jetty 或 Undertow。

使用嵌入式容器意味着容器只是另一个“bean ”,这使得配置更加容易。可以使用“application.properties”和其他应用程序配置文件对其进行配置。

接下来,我们想向我们的存储库添加一些初始数据,这样就有东西可以看了。我们可以通过添加一个用@PostConstruct 注释的方法来实现这一点,该方法只在计数为零时向 courseRepository 添加数据:

  1. 从 CourseRepository(类型为 Mono )获取计数。

  2. 调用“blockOptional()”,它将一直阻塞,直到 Mono 返回值并将输出转换为可选的

  3. 仅当值为零时才保留该值。

  4. 如果它是零,我们创建一个包含三个我们想要保存的课程对象的流。

  5. 使用 flatMap 将这些课程映射到存储库的“save”方法。

  6. 指定要用作 Schedulers.single()的调度程序。

  7. 订阅这个流,让它执行。

@PostConstruct
public void setup() {
  courseRepository.count() //1
  .blockOptional() //2
  .filter(count -> count == 0) //3
  .ifPresent(it -> //4
    Flux.just(
    new Course("Beginning Java"),
    new Course("Advanced Java"),
    new Course("Reactive Streams in Java"))
  .doOnNext(c -> System.out.println(c.toString()))
  .flatMap(courseRepository::save) //5
  .subscribeOn(Schedulers.single()) //6
  .subscribe()
); //7
}

这里的代码混合使用了 Java 8 的可选接口和 Reactor。请注意,我们必须调用 Flux 上的 subscribe,否则它永远不会执行。我们在这里通过调用不带参数的 subscribe()来实现这一点。

查看模板

在任何 Spring Boot 项目中,我们可以使用许多视图模板渲染器中的一个。在这种情况下,我们将 freemarker spring starter 包含到依赖项下的构建文件中:

compile('org.springframework.boot:spring-boot-starter-freemarker')

我们将模板放在 src/main/resources/templates 下。下面是模板文件 home.ftl 的重要部分(为简洁起见,省略了一些):

<article id="content" class="jumbotron center"></article>
<script type="application/javascript">
jQuery(document).ready(HC.loadCourses);
</script>

这将调用相应的 JavaScript 从我们的 RestController 中获取课程列表,我们将在后面定义。loadCourses 函数的定义如下:

  1. 首先我们调用我们的 restful API,稍后我们将定义它。

  2. 由于我们使用的是 jQuery,它会自动确定响应是 JSON 并解析返回的数据。

  3. 使用 forEach,我们构建了一个 HTML 列表来显示每门课程,并提供了一个链接来加载每门课程。

  4. 我们更新 DOM 以包含我们构建的列表。

  5. 这里我们指定了错误处理函数,以防 HTTP 请求出错。

jQuery.ajax({method: 'get',
        url: '/api/courses'}).done( //1
  function(list) { //2
    var ul = jQuery(
        '<ul class="courses btn-group"></ul>');
    list.forEach((crs) => { //3
      ul.append(
'<li class="btn-link" onclick="HC.loadCourse(\"+
      crs.id + '\'); return false">'
     + crs.name + ': <i>' + crs.price + '</i></li>')
  });
  jQuery('#content').html(ul); //4
}).fail( errorHandler ); //5

尽管我们在这里使用的是 jQuery,但是我们也可以选择任何 JavaScript 框架。对于 Spring Boot,JavaScript 文件应该存储在 src/main/resources/static/js。

简单易用的

默认情况下,Spring 将来自@RestController 的数据编码到 JSON 中,因此相应的 CourseControl 是这样定义的:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.*;
import java.util.*;
@RestController
public class CourseControl {
 final CourseRepository courseRepository;
 public CourseControl(
        CourseRepository        courseRepository) {
        this.courseRepository = courseRepository;
 }
 @GetMapping("/api/courses")
 public Flux<Course> getCourses() {
        return courseRepository.findAll();
 }
 @GetMapping("/api/course/{id}")
 public Mono<Course> getCourse(
        @PathVariable("id") String id) {
        return courseRepository.findById(
        UUID.fromString(id));
 }
}

注意我们如何从 RestController 直接返回像 Flux 这样的反应器数据类型,因为我们使用的是 WebFlux。这意味着每个 HTTP 请求都是非阻塞的,并使用 Reactor 来确定在哪些线程上运行操作。

现在我们有了阅读课程的能力,但我们还需要保存和更新课程的能力。

因为我们正在制作一个 restful API,所以我们使用@PostMapping 来处理保存新实体的 HTTP POST,使用@PutMapping 来处理更新的 PUT。

下面是如何设置 save 方法来使用 JSON 值映射(使用映射只是为了保持代码简单):

@PostMapping(value = "/api/course",
        consumes = MediaType.APPLICATION_JSON_VALUE)
public Mono<Course> saveCourse(
        @RequestBody Map<String,Object> body) {
        Course course = new Course((String)
                body.get("name"));
        course.price = Long.parseLong(
                body.get("price").toString());
        return courseRepository.insert(course);
}

注意,insert 方法返回一个 Reactor Mono 实例。您可能还记得,Mono 只能返回一个实例,否则会因出错而失败。

相应的 JavaScript 代码将类似于上一个示例,除了 ajax 调用更像下面这样(假设“name”和“price”是输入的 id):

var name = jQuery('#name').val();
var price = jQuery('#price').val();
jQuery.ajax({method: 'post', url: '/api/course/',
data: {name: name, price: price}})

下面是一个使用给定“id”的 PUT 请求将激活的更新方法,该方法还需要一个 JSON 值映射:

@PutMapping(value = "/api/course/{id}",
        consumes = MediaType.APPLICATION_JSON_VALUE)
public Mono<Course> updateCourse(
        @RequestParam("id") String id,
        @RequestBody Map<String,Object> body) {
        Mono<Course> courseMono = courseRepository
                .findById(UUID.fromString(id));
        return courseMono.flatMap(course -> {
          if (body.containsKey("price"))
    course.price =
Long.parseLong(
                body.get("price").toString());
          if (body.containsKey("name")) course.name=
                (String) body.get("name");
          return courseRepository.save(course);
        });
}

请注意我们在这里如何使用 flatMap 来更新课程并返回 save 方法的结果,该方法也返回一个 Mono。如果我们使用 map,返回类型将是 Mono >通过使用 flatMap,我们将其“展平”为 Mono ,这是我们在这里想要的返回类型。

进一步配置

在实际的应用程序中,我们很可能想要覆盖应用程序的许多默认配置。例如,我们希望实现自定义的错误处理和安全性。

首先,为了定制 WebFlux,我们添加了一个扩展 WebFluxConfigurationSupport 的类,并用@EnableWebFlux 进行了注释(这里该类被命名为 WebFluxConfig,但它可以被命名为任何名称)。添加那个注释不仅告诉 Spring Boot 启用 WebFlux,还告诉他查看这个类的额外配置。例如:

import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.config.*;
import org.springframework.web.server.*;
import reactor.core.publisher.Mono;

@EnableWebFlux
public class WebFluxConfig extends WebFluxConfigurationSupport {
  @Override
  public WebExceptionHandler
                responseStatusExceptionHandler() {
    return (exchange, ex) -> Mono.create(
      callback -> {
        exchange.getResponse().setStatusCode(
                HttpStatus.I_AM_A_TEAPOT);
           System.err.println(ex.getMessage());
           callback.success(null);
         });
  }
}

这里我们覆盖 responseStatusExceptionHandler,将状态代码设置为 418 (我是茶壶),这是一个实际存在的 HTTP 状态代码。您可以重写许多方法来提供自己的自定义逻辑。

最后,没有某种形式的安全性,任何应用程序都是不完整的。首先确保将 Spring 安全依赖项添加到您的构建文件中:

compile('org.springframework.boot:spring-boot-starter-security')

接下来,添加一个类并用“org . spring framework . security . config . annotation . web . reactive”包中的 EnableWebFluxSecurity 对其进行注释,并如下定义 beans:

  1. 这个注释告诉 Spring Security 保护您的 WebFlux 应用程序。

  2. 我们使用 ant 模式定义了允许所有用户使用的路径,其中“**”表示任何一个或多个目录。这使得每个人都可以访问主页和静态文件。

  3. 在这里,我们确保用户必须登录才能访问“/user/”路径下的任何路径。

  4. 这一行将 UserRepository 中的所有用户转换成一个列表。然后将它传递给 MapReactiveUserDetailsService,该服务为用户提供 Spring 安全性。

  5. 您必须定义一个密码编码。这里我们定义一个纯文本编码只是为了演示的目的。在实际系统中,您应该使用 StandardPasswordEncoder,或者更好的是 BcryptPasswordEncoder。

@EnableWebFluxSecurity //1
public class SecurityConfig {
 @Bean
 public SecurityWebFilterChain
  springSecurityFilterChain(ServerHttpSecurity http){
        http
        .authorizeExchange()
        .pathMatchers("/api/**", "/css/**",
                "/js/**", "/images/**", "/")
        .permitAll() //2
        .pathMatchers("/user/**")
.hasAuthority("user") //3
        .and()
        .formLogin();
        return http.build();
 }
 @Bean
 public MapReactiveUserDetailsService
        userDetailsService(
                        userRepository) {
        List<UserDetails> userDetails =
                new ArrayList<>();
        userDetails.addAll(
          userRepository.findAll().collectList()
                .block());//4
        return new                                   MapReactiveUserDetailsService(
                userDetails);
 }

 @Bean
 public PasswordEncoder myPasswordEncoder() { //5
        // never do this in production of course
        return new PasswordEncoder() {
                /*plaintext encoder*/};
 }
}

用户存储库的定义如下:

public interface UserRepository extends
 ReactiveMongoRepository<User, UUID> {}

测试

Spring Boot 为测试提供了全面的内置支持。例如,用@RunWith(SpringRunner.class)和@SpringBootTest 注释 JUnit 测试类,我们可以在整个应用程序如下运行的情况下运行集成测试:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.\
        SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.\
        TestRestTemplate;
import org.springframework.http.*;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment =
                WebEnvironment.RANDOM_PORT)
public class HumblecodeApplicationTests {
  @Autowired
  private TestRestTemplate testRestTemplate;

  @Test
  public void testFreeMarkerTemplate() {
    ResponseEntity<String> entity = testRestTemplate.getForEntity("/", String.class);

    assertThat(entity.getStatusCode())
                        .isEqualTo(HttpStatus.OK);
    assertThat(entity.getBody())
                        .contains("Welcome to");
  }

这个简单的测试启动了我们的 Spring Boot 应用程序,并验证了根页面返回 HTTP OK (200)状态代码,并且正文包含文本“Welcome to”。使用“web 环境=环境。RANDOM_PORT”指定每次运行测试时,Spring Boot 应用程序应该随机选择一个端口在本地运行。

我们还可以测试应用程序的主要功能,比如获取 JSON 中的课程列表的能力,如下测试所示:

@Test public void testGetCourses() {
  HttpHeaders headers = new HttpHeaders();
  headers.setAccept(
        Arrays.asList(MediaType.APPLICATION_JSON));
 HttpEntity<String> requestEntity =
                new HttpEntity<>(headers);
 ResponseEntity<String> response = testRestTemplate
         .exchange("/api/courses", HttpMethod.GET,
                requestEntity, String.class);
  assertThat(response.getStatusCode())
        .        isEqualTo(HttpStatus.OK);
 assertThat(response.getBody())
  .contains("\"name\":\"Beginning Java\",\"price\":2000");
}

九、Akka HTTP 和 Akka 流

当考虑使用哪个库或框架来创建利用 Akka 流的 web 应用程序时,有许多东西可供选择,Play Framework、Apache Camel 或 Akka HTTP 等等。对于这一章,我们将集中在使用 Akka HTTP。Akka HTTP 服务器是在 Akka 流之上实现的,并大量使用它。

Akka HTTP 一直致力于提供构建集成层的工具,而不是应用核心。因此,它认为自己是一套库,而不是一个框架。

-http docs 的一个子节点

Akka HTTP 采用了一种非个人化的方法,更喜欢被看作是一组库而不是一个框架。虽然这可能会使开始变得更加困难,但它允许开发人员有更多的灵活性,并对正在发生的一切有一个清晰的视图。幕后没有什么“魔法”能让它工作。

Akka HTTP 支持以下内容:

  • HTTP : Akka HTTP 实现了包括持久连接和客户端连接池的 HTTP/1.1。

  • Java 提供的工具支持 HTTPS。

  • WebSocket : Akka HTTP 在服务器端和客户端都实现了 WebSocket。

  • HTTP/2 : Akka HTTP 提供服务器端 HTTP/2 支持。

  • Multipart : Akka HTTP 已经对 multipart/*有效载荷进行了建模。它提供流式多部分解析器和呈现器,例如用于解析文件上传,并提供类型化模型来访问这种有效载荷的细节。

  • 服务器发送事件(SSE) :通过提供或消费(基于 Akka 流的)事件流的编组来支持。

  • JSON:Java 中基于 Jackson 的模型支持与 JSON 之间的编组。

  • Gzip 和 Deflate 内容编码。

它还有一个测试库来帮助测试。

对于我们的示例项目,我们将使用 Akka HTTP 以及 Akka 流 和 WebSockets 来创建一个带有假存储库的实时聊天机器人 web 服务器。

入门指南

虽然你可以使用 SBT (Scala 的构建工具)、Maven 或许多其他构建工具,但这里我们使用的是 Gradle。

首先创建一个名为“build.gradle”的构建文件,包含以下内容:

  1. 指定插件。

  2. 设置应用程序的名称。

  3. 使用静态 void main 方法设置主类以运行。

  4. 将 Java 版本设置为 10。

  5. 设置要使用的 Akka HTTP 和 Akka 版本的变量。

  6. 指定此项目所需的所有依赖项,包括用于测试的 akka-http-testkit、akka-stream-testkit、junit 和 assertj。

apply plugin: 'java' //1
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'application'

group = 'com.github.adamldavis'
applicationName = 'akka-http-java' //2
version = '0.0.1-SNAPSHOT'
mainClassName = 'com.github.adamldavis.akkahttp.WebApp' //3
// requires Gradle 4.7+
sourceCompatibility = 1.10 //4
targetCompatibility = 1.10

repositories {
    mavenCentral()
}
ext {
    akkaHttpVersion = '10.1.5' //5
    akkaVersion = '2.5.12'
}

dependencies {
  compile "com.typesafe.akka:akka-http_2.12:$akkaHttpVersion" //6
  compile "com.typesafe.akka:akka-http-jackson_2.12:$akkaHttpVersion"
  compile "com.typesafe.akka:akka-stream_2.12:$akkaVersion"

  testCompile "com.typesafe.akka:akka-http-testkit_2.12:$akkaHttpVersion"
  testCompile "com.typesafe.akka:akka-stream-testkit_2.12:$akkaVersion"
  testCompile 'junit:junit:4.12'
  testCompile "org.assertj:assertj-core:3.11.1"
}

然后创建一个名为 WebApp 的类,并从以下导入开始:

import akka.NotUsed;
import akka.actor.ActorSystem;
import akka.http.javadsl.ConnectHttp;
import akka.http.javadsl.Http;
import akka.http.javadsl.ServerBinding;
import akka.http.javadsl.model.*;
import akka.http.javadsl.server.*;
import akka.stream.ActorMaterializer;
import akka.stream.javadsl.Flow;
import akka.stream.javadsl.Source;
import akka.util.ByteString;

接下来,使该类扩展 AllDirectives 以启用 Java DSL,并添加如下所示的 main 方法:

  1. 为此应用程序创建 ActorSystem。

  2. 使用这个系统,创建一个 Http 实例,它是 Akka HTTP 服务器。

  3. 为了访问所有指令,我们需要一个定义路由的实例。

  4. 启动服务器,将其绑定到 localhost 上的端口 5010,并使用前面代码中定义的 routeFlow。

  5. 最后,我们添加一个 shutdown 钩子来解除服务器的绑定并关闭 ActorSystem。

public static void main(String[] args) {
  ActorSystem system = ActorSystem.create("routes");//1
  final Http http = Http.get(system); //2
  final ActorMaterializer materializer =
        ActorMaterializer.create(system);
  var app = new WebApp(); //3
  final Flow<HttpRequest, HttpResponse, NotUsed>
        routeFlow = app.joinedRoutes()
        .flow(system, materializer);
  final CompletionStage<ServerBinding> binding =
        http.bindAndHandle(routeFlow,
                ConnectHttp.toHost("localhost", 5010),
                materializer); //4
  System.out.println("Server online at http://localhost:5010/\nUse Ctrl+C to stop");
  // add shutdown Hook to terminate system:
  Runtime.getRuntime().addShutdownHook(new Thread(() -> { //5
        System.out.println("Shutting down...");
        binding.thenCompose(ServerBinding::unbind)
               .thenAccept(unbound -> system.terminate());
  }));
}

要运行应用程序,只需在命令行中使用命令“gradle run”。

路线

可以使用服务器 DSL 定义路由,使用简单的名称,如“route”、“path”和“get”。在您的路由中匹配的第一个路径将导致您的处理程序为该路由运行。如果没有匹配的路由,默认情况下将返回 HTTP 状态为 404(未找到)的响应。

例如,下面的方法定义了一个匹配“/hello”的路由:

private Route createHelloRoute() {
  return route(
        path("hello", () ->
                get(() ->
                  complete(HttpEntities.create(
                  ContentTypes.TEXT_HTML_UTF8,
                  "<h1>Say hello to akka-http</h1>"))
        )));
}

这条路线只是返回一个简单的 HTML 实体,如前面的代码所示。我们通过使用 ContentType 和字符串调用 HttpEntities.create 来创建 HttpEntity。“complete”方法表示响应由给定的参数完成,并被重载以接受许多不同的值,如 String、StatusCode、HttpEntity 或 HttpResponse。它还有一个变量,带有 Iterable 类型的附加参数来指定响应的头。这里我们使用的是完整的(HttpEntity)类型。

HttpEntities.create 方法也被重载以接受字符串、字节字符串、字节数组、路径、文件或 Akka 流源

我们可以通过运行我们的应用程序,然后使用“curl localhost:5010/hello”命令来测试路由。我们应该得到以下输出:

<h1>Say hello to akka-http</h1>

可以使用允许组合路由的重载“route”方法将路由组合成一条路由。例如:

private Route joinedRoutes() {
  return route(createHelloRoute(),
        createRandomRoute(),
        createWebsocketRoute());
}

这里我们提供了一条结合了我们定义的三条路线的路线。

因为 Akka HTTP 是建立在 Akka 流之上的,所以我们可以向任何路由提供无限的字节流。Akka HTTP 将使用 HTTP 的内置速率限制规范,在内存使用不变的情况下提供一个流。以下方法为路径“/random”上的请求提供了一个随机数流:

  1. 这里我们使用 Stream.generate 生成一个无限字节流,然后使用 Source.fromIterator 将其转换为 Source。

  2. 使用 ByteString 将每个数字转换成一个字节块。

private Route createRandomRoute() {
  final Random rnd = new Random();
  Source<Integer, NotUsed> numbers = //1
  Source.fromIterator(() ->
    Stream.generate(rnd::nextInt).iterator());
  return route(
    path("random", () ->
      get(() ->
        complete(
        HttpEntities.create(
                ContentTypes.TEXT_PLAIN_UTF8,
                numbers.map(x ->
                  ByteString.fromString(x + "\n")))) //2
        )));
}

我们可以在应用程序运行时使用命令“curl-limit-rate 1k 127 . 0 . 0 . 1:5010/random”来测试这个路由(将下载速率限制在 1 千字节/秒)。

求转发到

最后,我们可以使用“handleWebSocketMessages”创建一个 WebSocket 处理路由,如下所示:

public Route createWebsocketRoute() {
  return path("greeter", () ->
    handleWebSocketMessages(
        WebSocketExample.greeter())
  );
}

WebSocketExample 中的“greeter”方法定义了一个处理程序,该处理程序将传入的消息视为一个名称,并以对该名称的问候作为响应:

public static
Flow<Message, Message, NotUsed> greeter() {
  return Flow.<Message>create()
    .collect(new JavaPartialFunction<>() {
      @Override
      public Message apply(Message msg,
                boolean isCheck) {
      if (isCheck) {
        if (msg.isText()) return null;
        else throw noMatch();
      } else {
        return handleTextMessage(
                msg.asTextMessage());
      }
    }});
}
public static TextMessage
        handleTextMessage(TextMessage msg) {
  if (msg.isStrict()) {
        return TextMessage.create("Hello " +
                msg.getStrictText());
  } else {
        return TextMessage.create(Source.single(
        "Hello ").concat(msg.getStreamedText()));
  }
}

关于 JavaPartialFunction,需要知道的重要一点是,它可以用 isCheck 作为 true 或 false 多次调用。如果 isCheck 为 true,它只是检查您的 JavaPartialFunction 是否处理给定的类型,这就是为什么如果消息不是 TextMessage 类型(isText 返回 false),我们会“抛出 noMatch()”。

由于复杂的 WebSocket 协议,测试 WebSockets 更加复杂。接下来,我们将构建一个聊天应用程序来演示 WebSockets。

我们的领域

对于这个示例应用程序,我们将构建一个简单的聊天服务器。核心域模型是如下的 ChatMessage:

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class ChatMessage {
  final String username;
  final String message;

  @JsonCreator
  public ChatMessage(
        @JsonProperty("username") String username,
        @JsonProperty("message") String message) {
        this.username = username;
        this.message = message;
  }
  // toString, equals, and hashCode omitted for
  // brevity
  public String getUsername() { return username; }
  public String getMessage() { return message; }
}

这个 ChatMessage 对象是不可变的,只保存用户名和消息的值。

我们将使用 Jackson 进行与 JSON 的相互转换,因此我们有一些注释来允许这种情况发生。

我们的仓库

出于演示的目的,我们的存储库不会实际保存,而只是模拟一个长时间运行的操作,并打印出保存的消息。其代码如下:

import java.util.concurrent.*;

public class MessageRepository {
  public CompletionStage<ChatMessage> save(
        ChatMessage message) {
  return CompletableFuture.supplyAsync(() -> {
    try { Thread.sleep(500); }
    catch (InterruptedException e)
        { e.printStackTrace(); }
    System.out.println("saving message: " + message);
    return message; });
  }
}

它使用 Java 的 CompletableFuture 来执行一个异步动作,并在该动作中休眠半秒钟。在实际的应用程序中,我们希望将聊天消息保存到某种数据库中,这可能需要一些时间来阻塞。

聊天服务器

聊天服务器的主要入口点将是 chat server 类。

它从以下导入开始:

akka.NotUsed;
akka.actor.ActorSystem;
akka.http.javadsl.model.ws.Message;
akka.http.javadsl.model.ws.TextMessage;
akka.japi.JavaPartialFunction;
akka.stream.*;
akka.stream.javadsl.*;
com.fasterxml.jackson.databind.ObjectMapper;
org.reactivestreams.Publisher;
java.util.concurrent.*;

为了简洁起见,我们将跳过这些字段,因为它们可以从构造函数中派生出来。ChatServer 构造函数进行了一些非常重要的初始化,我们将使用这些初始化在客户端之间传播 ChatMessages:

  1. 这里我们使用 Java 的内置运行时类初始化一个 int 属性 parallelism。我们将它设置为可用处理器的数量,因为这将允许我们在并行处理中利用每个处理器。

  2. 创建 ActorMaterializer。

  3. 为了简单起见,我们在这里使用 Java 10 的“var ”,因为完整类型非常长。在接收器上使用静态方法“asPublisher”会创建一个接收器,该接收器也可以充当 org . react vestreams . publisher。默认情况下,它只允许一个订阅者,因此使用 WITH_FANOUT 可允许多个订阅者。我们必须调用 preMaterialize 来访问 Publisher 和 Sink 的实际实例。

  4. 因为我们希望多个客户端将 ChatMessages 推入一个接收器,所以我们必须使用 MergeHub。与上一步非常相似,您必须用一个实体化器运行 MergeHub 来访问 Sink 实例。

public ChatServer(ActorSystem actorSystem) {
 parallelism =
        Runtime.getRuntime().availableProcessors(); //1
 this.actorSystem = actorSystem;
 materializer = ActorMaterializer.create(
        actorSystem); //2
 var asPublisher = Sink.<ChatMessage>asPublisher(
        AsPublisher.WITH_FANOUT); //3
 var publisherSinkPair =
        asPublisher.preMaterialize(materializer);
 publisher = publisherSinkPair.first();
 sink = publisherSinkPair.second();
 mergeHub = MergeHub.of(ChatMessage.class,
        BUFFER_SIZE).to(sink); //4
 mergeSink = mergeHub.run(materializer);
}

MergeHub 和 Publisher

虽然这看起来很复杂,但我们在这里使用 MergeHub 和 asPublisher 所做的只是允许多个流使用同一个 Sink,从而推送到 Publisher 的一个实例。

通过这种方式,我们可以将每个新的 WebSocket 连接发送到一个接收器中,并订阅一个中央发布者,我们将在接下来看到这一点。

WebSocket 流

对于我们的聊天服务器应用程序,我们需要创建一个主流程。我们用下面的代码(为简洁起见,省略了一些)类似于前面的定义(添加了一个图形):

  1. 创造流动。类型声明描述了流接收消息并输出 ChatMessage,并且不使用补充数据类型。我们添加了一个给定大小的缓冲区 BUFFER_SIZE,它可以是我们系统的内存所能处理的最大值。在 JavaPartialFunction 中,调用我们将在后面定义的 storeMessageFromContent。

  2. 使用 mapAsync 展开 CompletionStage 。该调用允许使用并行度数量的并发线程并行运行数据库保存。

  3. 使用 GraphDSL 创建 FlowShape。此图将使用前面的 savingFlow 保存所有 ChatMessages 并将其放入 mergeSink,但使用 ChatServer 发布者的输出,以便每个客户端都可以获得每个 ChatMessage。

  4. 创建 toMessage FlowShape,它将 ChatMessage 转换为 JSON,然后将其包装在 TextMessage 中。

  5. 通过将 mergeSink 添加到图表的构建器来创建“sinkInlet”。同样以类似的方式创建“publisherOutput”和“saveFlow”。

  6. 将 saveFlow 的输出连接到 sinkInlet。

  7. 将 publisherOutput 输出连接到 toMessage 的入口。

  8. 使用保存流的入口和消息流的出口定义流图。

public Flow<Message, Message, NotUsed> flow() {

Flow<Message, ChatMessage, NotUsed> savingFlow =
  Flow.<Message>create() //1
  .buffer(BUFFER_SIZE, OverflowStrategy.backpressure())
  .collect(new
        JavaPartialFunction<Message,
        CompletionStage<ChatMessage>>() {
  @Override
  public CompletionStage<ChatMessage>
                apply(Message msg, boolean isCheck) {
    if (msg.isText()) {
      TextMessage textMessage = msg.asTextMessage();
      return storeMessageFromContent(
                CompletableFuture.completedFuture(
                textMessage.getStrictText()));
    } else if (isCheck)
      throw noMatch();
    return CompletableFuture.completedStage(
                new ChatMessage(null, null));
    }

  })
  .mapAsync(parallelism, stage -> stage) // 2
  .filter(m -> m.username != null);
final Graph<FlowShape<Message,Message>, NotUsed>graph = //3
  GraphDSL.create(builder -> {
    final FlowShape<ChatMessage, Message>
                toMessage = //4
                builder.add(Flow.of(ChatMessage.class)
                .map(jsonMapper::writeValueAsString)
                .async()
                .map(TextMessage::create));
    Inlet<ChatMessage> sinkInlet =
        builder.add(mergeSink).in(); //5
    Outlet<ChatMessage> publisherOutput = builder
        .add(Source.fromPublisher(publisher)).out();
    FlowShape<Message, ChatMessage> saveFlow =
        builder.add(savingFlow);
    builder.from(saveFlow.out()).toInlet(sinkInlet);//6
    builder.from(publisherOutput)
        .toInlet(toMessage.in()); // 7
    return new FlowShape<>(saveFlow.in(),
        toMessage.out()); // 8
  });
return Flow.fromGraph(graph);
}

诸如“storeMessageFromContent”之类的帮助器方法(和字段)定义如下:

  1. 方法 parseContent 返回一个流,该流使用 Jackson 的 ObjectMapper,jsonMapper,将字符串转换为 ChatMessage 的实例,我们将在后面定义。

  2. storeChatMessages 方法返回一个使用 mapAsyncUnordered 和 messageRepository 上的 save 方法的接收器(允许以任何顺序并行保存)。

  3. 这一行将流具体化为一个只保留最后一个元素输入的接收器。这是可行的,因为它只给出了一个元素。

  4. 方法 storeMessageFromContent 通过从给定的 CompletionStage 创建源开始。

  5. 然后,使用 via(Flow)将该字符串转换为 ChatMessage。

  6. 最后,它使用 whenComplete 打印出保存的每条消息,并处理任何错误。虽然这里我们只是打印堆栈跟踪,但在生产系统中,您应该使用日志记录或其他方法来从错误中恢复。

  7. 创建一个 singleton MessageRepository 和 ObjectMapper,用于将 ChatMessages 与 JSON 相互转换。

private Flow<String, ChatMessage, NotUsed> parseContent() { //1
  return Flow.of(String.class)
        .map(line -> jsonMapper.readValue(line,
                ChatMessage.class));
}
private Sink<ChatMessage, CompletionStage<ChatMessage>> storeChatMessages() {
  return Flow.of(ChatMessage.class)
        .mapAsyncUnordered(parallelism,
                messageRepository::save) //2
        .toMat(Sink.last(), Keep.right()); //3
}
CompletionStage<ChatMessage> storeMessageFromContent(
                CompletionStage<String> content) {
  return Source.fromCompletionStage(content) //4
                .via(parseContent())
                .runWith(storeChatMessages(),
                         materializer) //5
                .whenComplete((message, ex) -> { //6
                  if (message != null) System.out
                    .println("Saved message: "+message);
                  else { ex.printStackTrace(); }
                });
}
final MessageRepository messageRepository =
        new MessageRepository();
final ObjectMapper jsonMapper =
        new ObjectMapper(); //7

我们还更新了 WebApp 中的“createWebsocketRoute”方法,以使用我们的新流程:

return path("chatws", () ->
        handleWebSocketMessages(chatServer.flow())
);

网络客户端

为了让最终用户使用我们的 WebSocket,我们必须有某种前端。为此,我们在“src/main/resources/akkahttp”下创建一个“index.html”文件,其内容如下:

  1. 创建 WebSocket 连接。

  2. 在我们的“submitChat”函数中,用用户名和消息构造一个名为“msg”的对象。

  3. 将 msg 对象作为 JSON 格式的字符串发送。

  4. 将消息输入元素留空,以告知用户消息已发送,并允许输入新的消息。

  5. 定义 WebSocket 的 onmessage 事件处理程序,它将聊天消息追加到页面中。

  6. 最后,我们为用户的输入创建表单。

<!DOCTYPE html>
<html>
<head>
<title>Hello Akka HTTP!</title>
<script>
var webSocket =
  new WebSocket("ws://localhost:5010/chatws"); //1
function submitChat() {
  var msg = { // 2
    username: document.getElementById("u").value,
    message: document.getElementById("m").value
  };
  webSocket.send(JSON.stringify(msg)); //3
  document.getElementById("m").value = ""; //4
}
webSocket.onmessage = function (event) { //5
  console.log(event.data);
  var content = document.getElementById("content");
  content.innerHTML = content.innerHTML
        + '<br>' +   event.data;
}

</script>
</head>
<body>
 <form> <!--6-->
  Username:<input type="text" id="u"
        name="username"><br>
  Message: <input type="text" id="m"
        name="message"><br>
  <input type="button" value="Submit"
        onclick="submitChat()">
 </form>
<div id="content"></div>
</body>
</html>

虽然这是一个非常简单的接口,但它仅仅是为了演示强大的后端。有了这个简单的聊天服务器,我们可以同时处理成千上万的用户。

在真实的应用程序中,您可以改进界面,添加错误处理和其他功能,如搜索、聊天室和安全性。

我们还需要更新路由来服务这个文件。用以下内容更新 createHelloRoute 方法:

  1. 使用 getResourceAsStream 从类路径中读取文件。

  2. 使用 Java 的 InputStream 的 readAllBytes 方法从文件中读取所有字节。

  3. 将字节数组转换为 Akka HTTP 的字节字符串。

final Source<String,NotUsed> file =
        Source.single("/akkahttp/index.html");
return route(
  path("hello", () ->
    get(() ->
      complete(
          HttpEntities
            .create(ContentTypes.TEXT_HTML_UTF8,
              file.map(f ->
                WebApp.class.getResourceAsStream(f)) //1
                  .map(stream -> stream.readAllBytes()) //2
                  .map(bytes -> ByteString.fromArray(bytes))))//3
        )));

您可以通过运行 WebApp 并在几个浏览器中访问“http://localhost:5010/hello”来测试应用程序。

测试

除了我们的标准 Akka HTTP 和 Akka 流 导入之外,我们还添加了以下导入:

akka.testkit.javadsl.TestKit;
akka.util.ByteString;
com.github.adamldavis.akkahttp.*;
org.junit.*;
java.util.*;
java.util.concurrent.*;
static org.assertj.core.api.Assertions.assertThat;

我们的 ChatServerTest 类的核心是下面的安装和拆卸:

  1. 在每次测试之前,我们做以下工作:创建 ActorSystem。

  2. 创建聊天服务器。

  3. 创建一个 ActorMaterializer,我们将用于测试。

  4. 每次测试后,我们使用 Akka TestKit 关闭 TestKit ActorSystem。

ChatServer chatServer;
ActorSystem actorSystem;
ActorMaterializer materializer;
@Before
public void setup() {
 actorSystem = ActorSystem.create("test-system"); //1
 chatServer = new ChatServer(actorSystem);//2
 materializer = ActorMaterializer.create(actorSystem);//3
}
@After
public void tearDown() {
 TestKit.shutdownActorSystem(actorSystem);//4
}

然后,我们定义一个类似下面的测试,简单地确保 ChatMessage 作为 JSON 编码的 TextMessage 被复制到流的输出:

  1. 创建一个 ConcurrentLinkedDeque(命名列表)来保存消息,以避免任何多线程问题(这可能是多余的)。

  2. 调用 flow()来获取我们想要测试的 WebSocket 流。

  3. 用 JSON 编码的聊天消息创建一个文本消息。虽然我们在这里只创建了一个,但是在其他测试中,我们可以使用 Source.range 创建许多,然后像下面这样映射:Source.range(1,100)。map(I-> text message . create(JSON msg(I)))。

  4. 创建 testSink,将每条消息添加到我们之前定义的列表中。

  5. 使用源、接收器和实体化器调用 flow.runWith。这是测试流开始的地方。

  6. 我们必须调用 toCompletableFuture()。超时进入我们的 CompletionStage,以便用测试结果重新连接当前线程。否则,它将永远运行下去,因为底层发布者(由 MergeHub 和 Sink.asPublisher 支持)没有定义停止点。

  7. 断言输出 TextMessage 按照预期编码成 JSON。

@Test
public void flow_should_copy_messages() throws ExecutionException, InterruptedException {
 final Collection<Message> list = new
        ConcurrentLinkedDeque<>(); //1
 Flow<Message, Message, NotUsed> flow = chatServer.flow(); //2
 assertThat(flow).isNotNull();
 List<Message> messages =
   Arrays.asList(TextMessage.create(jsonMsg(0))); //3
 Graph<SourceShape<Message>, ?> testSource =
        Source.from(messages);
 Graph<SinkShape<Message>, CompletionStage<Done>>
        testSink = Sink.foreach(list::add); //4
 CompletionStage<Done> results = flow.runWith(testSource,
        testSink, materializer).second(); //5
 try {
  results.toCompletableFuture().get(2, TimeUnit.SECONDS); //6
 } catch (TimeoutException te) {
  System.out.println("caught expected: " +
        te.getMessage());
 }

 Iterator<Message> iterator = list.iterator();
 assertThat(list.size()).isEqualTo(1);

 assertThat(iterator.next()
        .asTextMessage().getStrictText())
        .isEqualTo("{\"username\":\"foo\",”+
                “\"message\":\"bar0\"}"); //7
}
static final String jsonMsg(int i) {
 return "{\"username\": \"foo\", \"message\": \"bar"
        + i + "\"}";
}

GitHub 上的完整代码有更多的测试,但是这应该给你一个如何测试一个基于 Akka HTTP 的项目的好主意。

十、总结

有许多方法可以比较不同的编程库,其中许多是主观的。问十个不同的程序员,你可能会得到十个不同的答案。

你可能会比较图书馆的易用性,社区的规模,工作的受欢迎程度,灵活性,性能,或者一些更高的概念,如完整性或内聚性,或者许多其他方面。如果您确实关注性能,请记住有无数种方法可以比较性能,任何差异都可能是由于程序员对这些库的理解有限造成的。出于本书的目的,我们将简短地看一下每个库的独特优势。

RxJava

RxJava 的优势在于它是更大的 Rx 项目的一部分。例如,如果开发人员熟悉 RxJS,迁移到 RxJava 可能会容易得多。它似乎也是唯一一个使用流行的现有开源库来构建 Android 应用程序的反应式流库。

Reactor

Project Reactor 是更大的 Spring Framework 库套件的一部分。正因如此,对于已经在使用 Spring 的人来说可能更熟悉,它与 Spring Data 之类的其他项目有很好的集成。使用 Spring WebFlux,我们可以非常容易地创建一个非阻塞的异步应用程序,并使用一个后台 MongoDB、Redis 或 Cassandra 数据库。

Akka 流

Akka 流 的优势在于它是更大的 Akka 项目的一部分。它在 Scala 语言中也有很好的支持。因此,熟悉 Scala 或 Akka 的开发人员可能会发现使用起来容易得多。它还具有图形的独特概念。有了图和相关的 DSL,程序员可以用流构建大型复杂的图,这在其他反应流库中可能很难做到。

结论

这些库中的任何一个都是构建反应式、异步、非阻塞、容错应用程序的绝佳选择,选择使用哪一个在很大程度上取决于项目和团队。

posted @ 2024-08-06 16:37  绝不原创的飞龙  阅读(11)  评论(0编辑  收藏  举报