3.Introduction to Reactive Programming
Reactor是Reactive编程范式的一种实现,可以总结为:
Reactive 编程是一种异步编程,关心数据流和change的传递。它可以轻松表达静态数据流(如 arrays)和动态数据流(如 event emitters)
Microsoft在.NET生态中创造了Reactive Extension(RX)库,这是第一个Reactive编程范式的实现。接着,RxJava在JVM上实现了Reactive编程。随着时间发展,通过Reactive Stream的努力,对Java上Reactive编程做了规范化,定义一些列的interface和交互规则。其interface已经集成到Java9的Flow类下。
Reactive编程经常出现在面向对象的编程语言中,作为观察者设计模式的扩展。你还可以将核心的Reactive stream模式和Iterator设计模式进行比较,两者有着明显的区别,例如,当Iterator是拉数据,Reactive stream是推数据。
Iterator属于一种命令式编程模式,Iterable可以访问其中数据,实际上,何时去访问下一个数据取决于开发者。在Reactive streams中,与之相对应是Publisher-Subscriber,但是是由Publisher来通知Subscrriber新数据的到来,这种推的概念就是成为Reactive的关键。另外,在推送的数据上的operations是通过声明表现的而不是命令式:编程者表达计算处理的逻辑而不是详细描述控制流。
除了推送数据,Reactive 编程对执行出错和执行正常完成这种行为进行了定义。Publisher可以向Subscriber推送数据,通过调用onNext方法,也可以提示错误,通过调用onError方法或者提示完成,通过调用onComplete方法。错误和执行完成都会终止sequence。可以将该过程描述为:
onNext x 0..N [onError | onComplete]
这种方式是非常灵活的。可以是没有数据,也可以只有一个数据,也可以N个数据(包括无限的数据序列,例如,时长持续的滴答声)
但是我们首先要弄清为什么我们需要这么一个异步Reactive库。
3.1 Blocking Can Be Wasteful
当今的应用可能同时被大量的用户访问,尽管应用的能力不断加强,软件运行效率依旧是一个至关重要的话题。
通常由两种方式来提高程序的运行效率:
- 并行 使用更多的线程和硬件资源
- 对当前使用的资源进行优化
通过,Java开发者编写的都是阻塞的代码。如果不遇到瓶颈,阻塞代码并没有什么问题。当遇到瓶颈期,就使用多线程这种方式运行同样的阻塞代码,但这种方式会很快遇到竞争和并发一系列问题。
blocking会浪费资源。如果你仔细观察,只要应用牵扯到一些IO,例如数据库请求或网络请求,资源会被浪费,因为在请求过程中 这些线程都是在等待数据 什么都没做。
所以,并行这种方式也并非银弹。尽量利用硬件的能力是有必要的,但并行这种方式是复杂的,并容易造成资源浪费。
3.2 Asynchronnicity to the Rescue
之前提到的第二种方法,寻找更高效的方法,可以是解决资源浪费的一种方式。通过编写异步,非阻塞的代码,可以让执行过程切换到使用同一资源的task上,当它执行完的时候再回到当前的线程中。
那我们在JVM编写异步代码呢?Java提供了两种异步编程模型:
- Callbacks:没有返回值的异步方法,但是有一个额外的
callback参数,当result是可用时,它会被调用。例如 Swing的EventListener。 - Futures:立马返回
Future<T>的异步方法。它会计算出一个T类型的结果,被Future包裹。该结果不会是立马可用的,可通过轮询直到该结果可用。例如,ExecutorService运行Callable<T>任务会得到Future对象。
这些技术是足够好的吗?并不是对所有的使用场景都好用,这两种方式都有些限制。
Callbacks 很难组合在一起,会很快导致代码很难阅读和维护(如众所周知的 Callback Hell)。
可以考虑这么个场景:在界面上展示一个用户top 5 favorites,如果没有favorite 就展示一些建议。这个过程由三个服务处理,一个给出favorite IDs,一个拉取favorite detail,还有一个提供一些建议以及detail。
Example 5. Example of Callback Hell
userService.getFavorites(userId, new Callback<List<String>>() {
public void onSuccess(List<String> list) {
if (list.isEmpty()) {
suggestionService.getSuggestions(new Callback<List<Favorite>>() {
public void onSuccess(List<Favorite> list) {
UiUtils.submitOnUiThread(() -> {
list.stream()
.limit(5)
.forEach(uiList::show);
});
}
public void onError(Throwable error) {
UiUtils.errorPopup(error);
}
});
} else {
list.stream()
.limit(5)
.forEach(favId -> favoriteService.getDetails(favId,
new Callback<Favorite>() {
public void onSuccess(Favorite details) {
UiUtils.submitOnUiThread(() -> uiList.show(details));
}
public void onError(Throwable error) {
UiUtils.errorPopup(error);
}
}
));
}
}
public void onError(Throwable error) {
UiUtils.errorPopup(error);
}
});
这种代码量很大,很难阅读而且有很多重复的部分。Reactor等价的是
Example 6. Example of Reactor code equivalent to callback code
userService.getFavorites(userId) ①
.flatMap(favoriteService::getDetails) ②
.switchIfEmpty(suggestionService.getSuggestions()) ③
.take(5) ④
.publishOn(UiUtils.uiThreadScheduler()) ⑤
.subscribe(uiList::show, UiUtils::errorPopup); ⑥
① 我们以favorite IDs的flow开始,异步地将它们转为details
②Favorite objects。现在我们有了Favorite object的flow
③如果Favorite object的flow是空的,则通过suggestionService获取
④我们只关心flow中的5个元素
⑤最后,我们想在UI线程中处理每一个数据
⑥通过描述最后数据应该怎么处理(在UI list中展示)以及如果出现错误 应该怎么做来trigger 这个flow
如果你想确保favorite IDs 是在小于800ms的时间获取到的,如果超过了,就行缓存中取,应该怎么做呢?在基于callbacks的例子中,这将是个复杂的任务,但是在Reactor仅需要添加一个timeout操作符。
Example 7. Example of Reactor code with timeout and fallback
userService.getFavorites(userId)
.timeout(Duration.ofMillis(800)) ①
.onErrorResume(cacheService.cachedFavoritesFor(userId)) ②
.flatMap(favoriteService::getDetails) ③
.switchIfEmpty(suggestionService.getSuggestions())
.take(5)
.publishOn(UiUtils.uiThreadScheduler())
.subscribe(uiList::show, UiUtils::errorPopup);
①如果上面部分在超过800ms没有发出任何东西,将会抛出一个错误
②如果出现错误,fall back to cacheService
③链路剩余的部分与之前示例相似
Future会比callback好一点,但它们在代码组合方面依旧欠缺,尽管Java 8的CompletableFuture带来了一些提升。将多个Future对象编排在一起并非易事,同事Future还会有一些别的问题:
- 调用
Future的get()方法容易再次导致blocking的情况 - 它们不支持懒计算
- 不能支持多值以及缺少良好的错误处理
如下例所示:我们得到一个IDs的列表,我们从中想要获取一个name和一个statistic,并把他们组成对,所有的步骤都是异步的。下面例子用一些CompletableFuture实现了功能
Example 8. Example of CompletableFuture combination
CompletableFuture<List<String>> ids = ifhIds(); ①
CompletableFuture<List<String>> result = ids.thenComposeAsync(l -> { ②
Stream<CompletableFuture<String>> zip =
l.stream().map(i -> { ③
CompletableFuture<String> nameTask = ifhName(i); ④
CompletableFuture<Integer> statTask = ifhStat(i); ⑤
return nameTask.thenCombineAsync(statTask, (name, stat) -> "Name " + name + " has stats " + stat); ⑥
});
List<CompletableFuture<String>> combinationList = zip.collect(Collectors.toList()); ⑦
CompletableFuture<String>[] combinationArray = combinationList.toArray(new CompletableFuture[combinationList.size()]);
CompletableFuture<Void> allDone = CompletableFuture.allOf(combinationArray); ⑧
return allDone.thenApply(v -> combinationList.stream()
.map(CompletableFuture::join) ⑨
.collect(Collectors.toList()));
});
List<String> results = result.join(); ⑩
assertThat(results).contains(
"Name NameJoe has stats 103",
"Name NameBart has stats 104",
"Name NameHenry has stats 105",
"Name NameNicole has stats 106",
"Name NameABSLAJNFOAJNFOANFANSF has stats 121");
① 由一个future开始,它给了一个id列表
②想对这些ids进行一些异步处理
③对id列表进行遍历
④异步地得到对应的name
⑤异步地得到对应statistic
⑥将两个结果合并在一起
⑦现在有了代表所有task的future。要执行这些tasks,我们需要将这个list转为数组
⑧将数组传给CompletableFuture.allOf,它输出的Future会在所有task都完成的时候完成。
⑨由于allOf返回的是CompletableFuture<Void>,所以我们又重新对future的列表进行了重新遍历,使用join()来收集他们的结果(这里并不会阻塞,因为allOf确保了所有的future都已经完成)
⑩一旦整个异步pipeline被触发,我们等待它完成并返回我们可以assert的结果列表。
因为Reactor由一些合并数据的操作符,这个过程可以简化为
Example 9. Example of Reactor code equivalent to future code
Flux<String> ids = ifhrIds(); ①
Flux<String> combinations =
ids.flatMap(id -> { ②
Mono<String> nameTask = ifhrName(id); ③
Mono<Integer> statTask = ifhrStat(id); ④
return nameTask.zipWith(statTask, ⑤
(name, stat) -> "Name " + name + " has stats " + stat);
});
Mono<List<String>> result = combinations.collectList(); ⑥
List<String> results = result.block(); ⑦
assertThat(results).containsExactly( ⑧
"Name NameJoe has stats 103",
"Name NameBart has stats 104",
"Name NameHenry has stats 105",
"Name NameNicole has stats 106",
"Name NameABSLAJNFOAJNFOANFANSF has stats 121"
);
①这次,我们由一个异步提供的ids 序列开始
②对于序列的每个元素,异步地进行处理它
③得到相应name
④得到相应statistic
⑤将两个值异步地进行合并
⑥ 当这些值可用的时候,将它们聚合为一个list
⑦在生产环境中,我们可能会继续对Flux进行合并或对它订阅。也有可能,会返回结果Mono。由于我们是在进行测试,我们使用block,等待这个过程完成,就可以直接返回聚合后的值列表
⑧assert the result
使用callback和使用Future的风险是相似的,这些风险正是Reactive 编程使用Publisher-Subscriber要解决的。
3.3 From Imperative to Reactive Programming
Reactive库,例如Reactor,旨在解决这些JVM上传统的异步方式的弊端,同时还关注一些额外的方面:
- 可组合性和可读性
- 有丰富的操作符来操作Data
- 直到你subscribe,什么都不会发生
- 背压或者消费者提示生产者 数据生产速度过快的能力
- High level but hign value的抽象
3.3.1 Composability and Readability
所谓"composability",我们指的是编排多个异步任务的能力,一个任务的结果作为后续任务的输入,或者,采用fork-join的风格运行多个任务,除此之外,可以在higher-level 系统中将这些异步任务作为分离的构件进行复用。
编排任务的能力是和代码的可读性以及可维护性紧密相关的。随着异步进程层在数量和复杂性上的增加,代码上的组合和阅读变得越来越困难。callback模式是简单的,但其主要缺点是对于复杂的处理 需要嵌套的callback会变得越来越深,即所谓的回调地狱。
Reacot提供了丰富的组合选项,代码反映了抽象的处理过程是如何组织的,并且尽量让所有东西在同一次级(使嵌套最小化)。
3.3.2 The Assembly line Analogy (装配线比喻)
你可以将被Reactive 应用处理的数据看做数据在通过一条流水线。Reactor既是传送带,也是工作站。原材料由Publisher生产,最终成为一个完成的产品 推送到Subscriber。
原材料可以经过很多变换和一些中间步骤,或者成为一条大流水线的一部分,将中间产物聚合在一起。如果中间出现了故障和阻塞(如对产品打包花费了较长时间),受影响的工作站可以发信号给上游来限制原材料的流通。
3.3.3 Operators
在Reactor,operators在我们的装配线比喻中就是哪些工作站,每个operator向publisher添加操作,把前一步的publisher包装为一个新的实例。整个链路就是这样连接起来,从第一个publisher产生的数据沿着链路向下走。最终,由subscriber结束这个过程。请记住,直到subsciber 对publisher订阅,什么都不会发生。
Tip
operators创建新的实例,理解这一概念可以使你避免一些常见的错误,这些错误让你觉得你在链路中使用的operator没有被使用,如下面的疑问:
C.2. I Used an Operator on my Flux but it Doesn’t Seem to Apply. What Gives?
Reactor的operators 是一些装饰器。他们返回一个不同的实例,对源序列进行包装并添加行为。这就是为什么使用operator的最好方式是将这些调用链接起来。
比较下面两个实例:
Example 25. without chaining (incorrect)
Flux<String> flux = Flux.just("something", "chain");
flux.map(secret -> secret.replaceAll(".", "*"));
flux.subscribe(next -> System.out.println("Received: " + next));
Example 26. without chaining (correct)
Flux<String> flux = Flux.just("something", "chain");
flux = flux.map(secret -> secret.replaceAll(".", "*"));
flux.subscribe(next -> System.out.println("Received: " + next));
下面的例子是更好的,因为它更简单
Example 27. with chaining (best)
Flux.just("something", "chain")
.map(secret -> secret.replaceAll(".", "*"))
.subscribe(next -> System.out.println("Received: " + next));
第一个版本会输出下面内容
Received: something
Received: chain
剩下的两个版本会输出所期望的值
Received: *********
Received: *****
3.3.4 Nothing Happens Util You subscribe()
在Reactor,当你编写Publisher 链路时,默认情况下数据不会开始发出到其中。实际上,你编写了你异步处理的抽象描述(这在重用性和组合性上非常有用)
通过subscribing,你把Publisher和Subscriber联系在一起,这会触发数据在这个链路上流动。实现的方式是:Subscriber发出一个请求信号,该信号会向上传播,一直到源Publisher。
3.3.5 Backpressure
将信号向上传播还用来实现 backpressure,我们在装配线比喻中将其比作一个feedback信号 当工作站运行的比上流的工作站慢时。
Reactive Stream 规范定义的机制为:一个subscriber可以工作在无限制模式下,该模式下,让源尽可能快地推送所有数据,或者它可以使用request机制来通知源它准备处理最多n个元素。
中间的operators也可以在途中改变这个请求。如一个buffer operator 可以将元素分组为十个一批。如果subscriber请求一个buffer,源是可以产生十个元素的。一些operator还实现了预取策略,这避免了来回request(1),这种策略在当请求之前生产元素开销不大时是非常有用的。
这将push模型转为push-pull混合模式,在这模式下,下游如果为就绪状态下可以从上游拉取n个元素。但是如果上游元素未准备就绪,下游就会被push元素当上游有元素产生时。
3.3.6 Hot vs Cold
Rx系列库将Reactive 序列了分为两大类:hot 和 cold。这个区分主要与Reactive stream怎么响应subscriber有关。
- 一个Cold序列对于每一个subscriber会重新开始,包括在数据源。例如,如果源包装了一个HTTP请求,会为每一个订阅创建一个新的HTTP请求
- 一个Hot序列对于每一个subscriber不会重新开始,后续的subscriber会接受到自他们订阅后释放的信号。请注意,一些hot reactive stream 可以缓存或者重放历史所发送的信号 全部或部分。从一般视角看,hot序列甚至可以在没有subscriber的情况下发送发送数据,这是“在订阅之前什么都不会发生”规则的例外。
更多关于Reacotr context中hot vs cold的更多信息,请看this reactor-specific section.