响应式API的设计、实现和应用

2018-03-20 17:01:48 InfoQ  点击量: 评论 (0)
这篇文章来自于SpringOne的一个演讲。在过去的几年里,Java世界中在大力推动响应式编程的。无论是NodeJS开发人员使用非阻塞api的成功,还是
这篇文章来自于SpringOne的一个演讲。

在过去的几年里,Java世界中在大力推动响应式编程的。无论是NodeJS开发人员使用非阻塞api的成功,还是引发延迟的微服务的爆炸式增长,还是仅仅是想要更有效地利用计算资源,许多开发人员都开始将响应式编程看作一种可行的编程模型。 

幸运的是,涉及到响应式框架以及如何正确使用它们时,Java开发人员被选择给宠坏了。没有太多编写响应式代码的“错误”方法,但是,这同时也是问题所在;也没多少编写响应式代码的“正确”方法。

在本文中,我们的目的是给你一些关于如何编写响应式代码的意见。这些观点来自多年来开发一个大规模的响应式API的经验,虽然它们可能并不适合你,但我们希望它们在你开始你的响应式之旅时能给你一些方向。

本文中的示例都来自于Cloud Foundry Java客户端。这个项目使用Reactor项目的响应式框架。我们为这个Java客户端选择Reactor的原因,是因为它与Spring团队有紧密的集成,但是我们讨论的所有概念也都适用于其他的响应式框架,比如RxJava。如果你对Cloud Foundry有一些了解,这将很有帮助,但这不是必需的。这些例子有自解释性命名,在解释每个响应式概念时它们将助你更好地理解。

响应式编程是一个巨大的主题,它远远超出了本文的范围,但是为了实现我们的目的,让我们宽泛地把它定义为一种用更流畅的方式定义事件驱动系统的方法,而不是传统的命令式编程风格。其目标是将命令式逻辑转换为异步、非阻塞、函数式的样式,这种样式更容易理解和推理。 

为这些做法(threads、NIO、callbacks等等)设计的命令式API并未考虑如何正确、可靠和方便地使用,许多情况下,在应用程序代码中使用这些API仍需要大量显式地管理。响应式框架的承诺是,这些关注点可以在幕后处理,从而让开发人员能够把主力精力放在应用程序功能代码的编写上。

我应该使用响应式编程吗?

在设计响应式API时,首先要问自己的问题是,你是否想要一个响应式API! 响应式api不可能适用于所有的一切。响应式编程有显而易见的缺点(目前最大的问题是调试,但框架和ide都正在积极解决此问题)。相反,当价值明显大于缺点时,你就选择响应式API吧。在作出这个判断时,有几个用于响应式编程的模式非常适合。

网络化

网络请求本质上就撇不开(相对)较大的延迟,而且等待这些响应返回通常是系统中最大的资源浪费。在非响应式应用程序中,那些等待中的请求通常会阻塞线程并消耗堆栈内存,空闲着等待响应到达。远程故障和超时通常没有得到系统地、明确地处理,因为提供的API不容易做到这一点。最后,远程调用的负载通常是未知的、无边界的,导致堆内存耗尽。响应式编程与非阻塞IO相结合,解决了这类问题,因为它为你提供了一个清晰的和显式的API。

高并发操作

它也很适合用于协调高并发操作(如网络请求或可并行化cpu密集型计算)。响应式框架,虽然允许显式管理线程,但采用自动线程管理也很出色。像.flatmap()这样的操作符透明地并行化行为,最大化地利用可用资源。

大规模可扩展应用

每个链接一个线程的servlet 模型已经为我们服务了很多年了。但是,随着微服务的出现,我们已经开始看到应用程序大规模地扩展(25、50甚至100个单个无状态应用程序的实例)来处理连接负载,即使CPU使用率处于空闲状态。选择非阻塞IO加响应式编程效果更佳,打破了链接与线程间的这种联系,使可用资源得到更有效的利用。很明显,这样的优势通常是惊人的。它常常需要在Tomcat上构建一个应用程序的更多实例,这些应用程序需要成百上千的线程来处理相同的负载,就像同一应用程序构建在拥有8个线程的Netty上一样。

虽然以上所列不能完全用来评判响应式编程在哪里适用,但关键是要记住,如果你的应用不适合以上任何一种,那么你用它可能只是徒增复杂度,而不会增加任何价值。

响应式API应该返回什么?

如果你回答了第一个问题,判定出你的应用会从响应式API得到收益,那么就到了设计API的时候了。决定你的响应式API应该返回什么基本类型是一个好的起点。

Java世界中的所有响应式框架(包括Java 9的Flow)都是在响应式流程规范之上通信的。这个规范定义了一个低级的交互API,但是它不被认为是一个响应式框架(也就是说,它未针对流指定可用的操作符)。

在Reactor 项目中有两种主要的类型。Flux类型表示流经该系统的0到N个值。Mono类型表示0到1个值。在Java客户端中,我们几乎只使用Mono,因为它清楚地映射到单个请求、单个响应模型。

Flux<Application> listApplications() {...}

Flux<String> listApplicationNames() {
  return listApplications()
    .map(Application::getName);
}

void printApplicationName() {
  listApplicationNames()
    .subscribe(System.out::println);
}

在本例中,listApplications()方法执行一个网络调用,并返回0到N个应用程序实例的Flux。然后,我们使用.map()操作符将每个应用程序转换为其名称的字符串。然后将以应用程序命名的Flux消费并输出到控制台。

Flux<Application> listApplications() {...}

Mono<List<String>> listApplicationNames() {
  return listApplications()
    .map(Application::getName)
    .collectList();
}

Mono<Boolean> doesApplicationExist(String name) {
  return listApplicationNames()
    .map(names -> names.contains(name));
}

Mono并不像Flux那样有一个流,但是因为它们在概念上是一个元素的流,所以我们使用的操作符通常有相同的名称。在这个例子中,除了映射到应用程序名称的Flux之外,我们还将这些名称收集到一个List中。在这种情况下,包含该列表的Mono可以被转换为一个boolean值,表示其中是否包含某个名称。这可能与直觉不符,但是如果你正在处理的项目在逻辑上是一个项目的集合,而不是它们的流,那么返回一个集合的Mono也很正常(例如Mono>)。

与命令式API不同,void不是一个适当的响应式返回类型。相反,每一个方法都必须返回一个Flux或者一个Mono。这可能看起来很奇怪(仍然有一些行为没有任何返回呀!),但这是一个响应流基本操作的结果。调用响应式API的代码执行(例如.flatmap ().map()…)是构建了一个数据到流的结构,但实际上并没有转换数据。只有在最后,当.subscribe()被调用时,数据才会开始向流转换,并在随之完成转换。这种惰性执行正是为什么基于lambdas构建响应式编程的原因,以及为什么总要有返回类型,因为必须得有一些东西去.subscribe()。

void delete(String id) {
  this.restTemplate.delete(URI, id);
}

public void cleanup(String[] args) {
  delete("test-id");
}

上面这种的命令式阻塞示例可以返回void,因为它的网络调用会立即开始执行,直到接收到响应时才返回。

Mono<Void> delete(String id) {
  return this.httpClient.delete(URI, id);
}

public void cleanup(String[] args) {
  CountDownLatch latch = new CountDownLatch(1);

  delete("test-id")
    .subscribe(n -> {}, Throwable::printStackTrace, () -> latch::countDown);

  latch.await();
}

在这个响应式示例中,网络调用直到.subscribe()被调用后才开始,在delete()之后返回,因为它是用来生成调用的结构,而不是调用本身的结果。在本例中,我们使用返回0个条目的Mono,并在收到响应后才发出onComplete()的信号,这就相当于void返回类型了。

方法的范围

一旦你决定了你的API需要返回什么,你就需要考虑你的每个方法(API和实现)将会做什么了。在该Java客户端上,我们发现把方法设计小且可复用会带来收益。它使每一种方法更容易组成更大的操作。这还能让它们更灵活地组合成并行或顺序操作。此外,它还使潜在的复杂流程更具可读性。

Mono<ListApplicationsResponse> getPage(int page) {
  return this.client.applicationsV2()
    .list(ListApplicationsRequest.builder()
      .page(page)
      .build());
}

void getResources() {
  getPage(1)
    .flatMapMany(response -> Flux.range(2, response.getTotalPages() - 1)
      .flatMap(page -> getPage(page))
      .startWith(response))
    .subscribe(System.out::println);
}

这个例子演示了我们如何调用一个分页的API。第一个getPage()请求检索结果的第一页。在结果的第一页中包括我们需要检索的页面总数,以获得完整的结果。因为getPage()方法是小的、可重用的,而且没有其他额外作用,所以我们可以重用该方法,并可以通过totalPages并行为第2页进行调用!

顺序和并行协调

现在,几乎所有显著的性能改进都来自对并发性的提升。我们知道这一点,但许多系统的并发要么仅涉及传入的连接,要么根本不并发。大部分这种情况都是源自这样一个事实,那就是实现一个高度并发的系统又困难又容易出错。响应式编程的一个重要优点是,你可以定义操作之间的顺序和并行关系,并让框架确定利用可用资源的最佳方式

大云网官方微信售电那点事儿

责任编辑:售电衡衡

免责声明:本文仅代表作者个人观点,与本站无关。其原创性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
我要收藏
个赞