Reactor 3.Introduction to Reactive Programming

93 阅读12分钟

3.Introduction to Reactive Programming

Source:projectreactor.io/docs/core/r…

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还会有一些别的问题:

  • 调用Futureget()方法容易再次导致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结束这个过程。请记住,直到subsciberpublisher订阅,什么都不会发生。

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,你把PublisherSubscriber联系在一起,这会触发数据在这个链路上流动。实现的方式是: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 序列了分为两大类:hotcold。这个区分主要与Reactive stream怎么响应subscriber有关。

  • 一个Cold序列对于每一个subscriber会重新开始,包括在数据源。例如,如果源包装了一个HTTP请求,会为每一个订阅创建一个新的HTTP请求
  • 一个Hot序列对于每一个subscriber不会重新开始,后续的subscriber会接受到自他们订阅后释放的信号。请注意,一些hot reactive stream 可以缓存或者重放历史所发送的信号 全部或部分。从一般视角看,hot序列甚至可以在没有subscriber的情况下发送发送数据,这是“在订阅之前什么都不会发生”规则的例外。

更多关于Reacotr context中hot vs cold的更多信息,请看this reactor-specific section.