react api_设计,实施和使用React式API

react api

重要要点

  • 在进行React式设计之前,请确保使用React式编程适合您的项目
  • React性方法总是返回某些东西,因为它们建立了执行框架,但没有开始执行
  • 响应式编程允许您声明性地声明操作之间的顺序和并行关系,从而将执行优化推入框架
  • 错误是流程中的头等舱物品,应直接处理
  • 由于许多流是异步的,因此在使用同步框架进行测试时必须格外小心
本文摘自SpringOne的演讲。 您可以在此处查看该演讲的视频。

在过去的几年中,Java世界大力推动了React式编程。 无论是使用非阻塞API的NodeJS开发人员的成功,引发延迟的微服务的爆炸式增长,还是仅仅是希望更有效地利用计算资源,许多开发人员都开始将React式编程视为可行的编程模型。

幸运的是,当涉及到React式框架以及如何正确使用它们时,Java开发人员会无所适从。 编写响应式代码的方法不是很多,但是其中存在麻烦。 编写React式代码的“正确”方法很少。

在本文中,我们旨在为您提供一些有关如何编写React式代码的建议。 这些意见来自多年开发大型React式API的经验,尽管它们可能并不完全适合您,但我们希望它们为您开始React式之旅提供一些指导。

本文中的示例均来自Cloud Foundry Java Client 。 该项目将Project Reactor用于其React框架。 我们选择Reactor作为Java客户端是因为它与Spring团队紧密集成,但是我们讨论的所有概念都适用于其他React式框架,例如RxJava 。 如果您对Cloud Foundry有所了解,这将有所帮助,但这不是必需的。 这些示例具有不言自明的命名方式,将引导您完成每个示例所展示的React性概念。

响应式编程是一个广泛的主题,并且远远超出了本文的范围,但是出于我们的目的,让我们将其广义地定义为一种比传统的命令式编程风格更流畅的方式来定义事件驱动系统。 目标是将命令式逻辑转换为易于理解和推理的异步,非阻塞,功能样式。

为这些行为设计的许多命令性API(线程,NIO回调等)不容易正确可靠地使用,在许多情况下,使用这些API仍需要在应用程序代码中进行大量的显式管理。 React框架的承诺是可以在幕后处理这些问题,从而使开发人员可以编写主要侧重于应用程序功能的代码。

我应该使用React式编程吗?

设计React式API时要问自己的第一个问题是,您是否还想要React式API! 响应式API绝对不是所有事物的正确选择。 React式编程有明显的缺点(调试是当今最大的调试,但框架IDE都在进行此操作)。 取而代之的是,当获得的价值大大超过不利因素时,您选择React式API。 在做出此判断时,有两种显而易见的模式,React式编程非常适合。

联网

网络请求固有地(相对)涉及较大的延迟,并且等待这些响应返回通常是系统中最大的资源浪费。 在非响应式应用程序中,那些等待的请求通常会阻塞线程并消耗堆栈内存,从而闲置地等待响应的到达。 远程故障和超时通常不会系统地和明确地处理,因为提供的API并不容易做到。 最后,来自远程调用的有效负载通常具有未知且无界的大小,从而导致堆内存耗尽。 响应式编程与无阻塞IO相结合,解决了这类问题,因为它为您提供了一种清晰明确的API。

高度并行的操作

高度并行的操作(如网络请求或可并行化的CPU密集型计算)的协调也很合适。 响应式框架虽然允许显式管理线程,但在让它们自动管理线程时表现出色。 像.flatMap()这样的运算符可以透明地并行化行为,从而最大程度地利用可用资源。

大规模应用

多年来,每个连接一个线程的servlet模型为我们服务良好。 但是,通过微服务,我们开始看到大规模扩展的应用程序(单个无状态应用程序的25、50,甚至100个实例)即使在CPU使用空闲时也能处理连接负载。 选择无阻塞IO,并通过React性编程使其可口,从而打破了这种联系,并使可用资源的使用效率更高。 要明确的是,这里的优势通常令人吃惊。 与基于Tomcat的具有八个线程的相同应用程序相比,在具有数百或数千个线程的Tomcat上构建的应用程序通常需要更多的实例。

虽然这不应该被认为是React式编程有用的地方的详尽列表,但是这里要记住的关键是,如果您的应用程序不属于这些类别之一,那么您可能会增加复杂性而没有获得任何价值。

React性API应该返回什么?

如果您已经回答了第一个问题,并且确定您的应用程序将从响应式API中受益,那么该设计API的时候了。 一个不错的起点是确定您的React式API应该返回哪些原语。

Java世界中的所有响应框架(包括Java 9的Flow )都在Reactive Streams Specification上融合。 该规范定义了一个低级的互操作API,但并未被视为React性框架(即,它未指定可用于流的运算符)。

一切都基于Project Reactor中的两种主要类型。 Flux<T>类型表示流过系统的0到N个值。 Mono<T>类型表示0到1的值。 在Java客户端内部,我们几乎完全使用Mono因为它清楚地映射到单个请求,单个响应模型。

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

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

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

在此示例中, listApplications()方法执行网络调用,并向N个Application实例返回0的Flux 。 然后,我们使用.map()运算符将每个Application转换为其名称的String 。 然后,将使用该应用程序名称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. 然后,在这种情况下,包含该ListMono可以转换为一个Boolean指示是否在其中包含名称。 这可能是违反直觉的,但是如果您正在使用的项目在逻辑上是项目的集合而不是它们的流,则通常返回一个Mono的集合(例如Mono<List<String>> )。

与命令式API不同, void不是适当的React性返回类型。 相反,每个方法都必须返回FluxMono 。 这可能看起来很奇怪(仍然有没有返回的行为!),但这是React流的基本操作的结果。 调用React式API的代码( eg .flatMap().map()... )的执行正在建立一个数据流通过的框架,但实际上并未转换数据。 只有最后,当.subscribe()时,数据才开始在流中移动,并随操作进行转换。 这种懒惰的执行是为什么在lambda之上构建React式编程,以及为什么总是返回类型的原因; .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();
}

在此React式示例中,直到delete()返回之后才调用.subscribe() ,网络调用才开始,因为它是进行该调用的框架,而不是调用本身的结果。 在这种情况下,通过使用Mono<Void>返回0项,我们等效于void返回类型,仅在收到响应后才在onComplete()发信号。

方法范围

一旦确定了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页!

顺序和并行协调

如今,几乎所有重要的性能改进都来自于并发。 我们知道这一点,但是许多系统要么仅在传入连接方面是并发的,要么根本就不是并发的。 这种情况的大部分可以追溯到以下事实:实施高度并发的系统既困难又容易出错。 响应式编程的主要优点之一是,您可以定义操作之间的顺序和并行关系,并让框架确定使用可用资源的最佳方式。

再看一下前面的例子; 确保对getPage()的第一次调用在每个其他页面的后续调用之前顺序发生。 另外,由于对getPage()后续调用发生在.flatMapMany() ,因此该框架负责以最佳方式对它们的执行进行多线程处理,并将结果重新结合在一起,从而传播可能发生的任何错误。

条件逻辑

与命令式编程不同,错误被视为React式编程中的值。 这意味着它们通过流程的操作。 这些错误可以一直传递给使用者,或者流可以基于它们改变行为。 这种行为变化可以表现为错误的转变或基于错误的新结果。

public Mono<AppStatsResponse> getApplication(GetAppRequest request) {
  return client.applications()
    .statistics(AppStatsRequest.builder()
      .applicationId(request.id())
      .build())
    .onErrorResume(ExceptionUtils.statusCode(APP_STOPPED_ERROR),
      t -> Mono.just(AppStatsResponse.builder().build()));
}

在此示例中,请求获取正在运行的应用程序的统计信息。 如果一切都按预期工作,则将响应传递回使用者。 但是,如果收到错误(带有特定的状态代码),则返回空响应。 使用者永远不会看到错误,并且执行将使用默认值进行,就好像从未发出错误信号一样。

如前所述,在不发送任何项目的情况下完成流程是有效的。 这通常等效于返回null (在特殊情况下,其返回类型为void )。 像错误情况一样,没有任何项目的完成可以一直传递给使用者,或者流可以基于它们来改变行为。

public Flux<GetDomainsResponse> getDomains(GetDomainsRequest request) {
  return requestPrivateDomains(request.getId())
    .switchIfEmpty(requestSharedDomains(request.getId()));
}

在此示例中, getDomains()返回的域可能在两个不同的存储桶之一中。 首先搜索私有域,如果成功完成,尽管没有任何结果,然后搜索共享域。

public Mono<String> getDomainId(GetDomainIdRequest request) {
  return getPrivateDomainId(request.getName())
    .switchIfEmpty(getSharedDomainId(request.getName()))
    .switchIfEmpty(ExceptionUtils.illegalState(
      "Domain %s not found", request.getName()));
}

也可能是没有项目指示错误情况。 在此示例中,如果找不到私有域或共享域,则将生成一个新的IllegalStateException并将其传递给使用者。

但是,有时您希望不是基于错误或空虚来做出决定,而是基于值本身来做出决定。 虽然可以使用运算符来实现此逻辑,但事实证明,它比值得的更为复杂。 在这种情况下,您应该只使用命令式条件语句。

public Mono<String> getDomainId(String domain, String organizationId) {
  return Mono.just(domain)
    .filter(d -> d == null)
    .then(getSharedDomainIds()
      .switchIfEmpty(getPrivateDomainIds(organizationId))
      .next()  // select first returned
      .switchIfEmpty(ExceptionUtils.illegalState("Domain not found")))
    .switchIfEmpty(getPrivateDomainId(domain, organizationId)
      .switchIfEmpty(getSharedDomainId(domain))
      .switchIfEmpty(
          ExceptionUtils.illegalState("Domain %s not found", domain)));
}

本示例返回给定组织(层次结构容器)内给定域名的ID。 但是这里有一个变化-如果域为null ,则返回范围为组织的共享域或私有域中第一个的ID。 如果域不为null ,则搜索显式域名,并返回其ID。 如果您发现此代码令人困惑,请不要失望,我们也一样!

public Mono<String> getDomainId(String domain, String organizationId) {
  if (domain == null) {
    return getSharedDomainIds()
      .switchIfEmpty(getPrivateDomainIds(organizationId))
      .next()
      .switchIfEmpty(ExceptionUtils.illegalState("Domain not found"));
  } else {
    return getPrivateDomainId(domain, organizationId)
      .switchIfEmpty(getSharedDomainId(domain))
      .switchIfEmpty(
          ExceptionUtils.illegalState("Domain %s not found", domain));
    }
}

该示例是等效的,但使用命令式条件语句。 更可理解的是,您不同意吗?

测试中

实际上,最有用的流程将是异步的。 在测试时,这是有问题的,因为测试框架在返回异步结果之前很久就积极地同步,注册通过或失败。 为了弥补这一点,您必须阻塞主线程,直到返回结果,然后将这些结果移至主线程以进行断言。

@Test
public void noLatch() {
  Mono.just("alpha")
    .subscribeOn(Schedulers.single())
    .subscribe(s -> assertEquals("bravo", s));
}

在非主线程上发出String此示例意外地通过。 该测试通过的根本原因(显然不应通过)是noLatch方法将完成而不会引发AssertionError

@Test
public void latch() throws InterruptedException {
  CountDownLatch latch = new CountDownLatch(1);
  AtomicReference<String> actual = new AtomicReference<>();

  Mono.just("alpha")
    .subscribeOn(Schedulers.single())
    .subscribe(actual::set, t -> latch.countDown(), latch::countDown);

  latch.await();
  assertEquals("bravo", actual.get());
}

该示例虽然笨拙,但使用CountDownLatch来确保在流完成之前,不返回latch()方法。 释放闩锁后,将在主线程中进行AssertionError ,该AssertionError将引发AssertionError从而导致测试失败。

您会看到该代码并拒​​绝以这种方式实施所有测试,这是可以原谅的。 我们当然做到了。 幸运的是,Reactor提供了一个StepVerifier类来促进测试。

React式设计的测试不仅需要阻塞。 您通常需要断言多个值和预期的错误,同时确保意外的错误会导致测试失败。 StepVerifier解决了每个问题。

@Test
public void testMultipleValues() {
  Flux.just("alpha", "bravo")
    .as(StepVerifier::create)
    .expectNext("alpha")
    .expectNext("bravo")
    .expectComplete()
    .verify(Duration.ofSeconds(5));
}

在此示例中, StepVerifier用于期望精确地发出alphabravo ,然后流程完成。 如果未发出任何一个,则发出了一个额外的元素,或生成了一个错误,则测试将失败。

@Test
public void shareFails() {
  this.domains
    .share(ShareDomainRequest.builder()
      .domain("test-domain")
      .organization("test-organization")
      .build())
    .as(StepVerifier::create)
    .consumeErrorWith(t -> assertThat(t)
      .isInstanceOf(IllegalArgumentException.class)
      .hasMessage("Private domain test-domain does not exist"))
    .verify(Duration.ofSeconds(5));
}

本示例使用StepVerifier的一些更高级的功能,不仅断言已发出错误信号,而且还断言这是IllegalArgumentException ,并且消息与预期的相符。

CountDownLatches

关于React式框架要记住的关键一件事是它们只能协调自己的操作和线程模型。 React式编程所处的许多执行环境将使单个线程(例如Servlet容器)的寿命更长。 在这些环境中,React式编程的异步特性不成问题。 但是,在某些环境中(例如上面的测试示例),进程将在任何单个线程之前结束。

public static void main(String[] args) {
  Mono.just("alpha")
    .delaySubscription(Duration.ofSeconds(1))
    .subscribeOn(Schedulers.single())
    .subscribe(System.out::println);
}

就像测试方法一样,此main()方法将在发出alpha之前终止。

public static void main(String[] args) throws InterruptedException {
  CountDownLatch latch = new CountDownLatch(1);

  Mono.just("alpha")
    .delaySubscription(Duration.ofSeconds(1))
    .subscribeOn(Schedulers.single())
    .subscribe(System.out::println, t -> latch.countDown(),
               latch::countDown);

    latch.await();
}

就像在测试示例中一样, CountDownLatch可以确保主线程不会在流之前终止,而不管它在哪个线程上执行终止。

阻塞流

今天,在可预见的将来,在响应式编程中与阻塞API进行交互是很普遍的。 为了在两者之间架起桥梁,可以在等待结果时进行阻塞。 但是,以这种方式桥接到阻塞API时,会失去React式编程的某些优点(例如有效使用资源)。 因此,您将希望使代码尽可能长时间保持响应,仅在最后一刻才阻塞。 同样值得注意的是,这种想法的逻辑结论是可以使React性API处于阻塞状态,但决不能使阻塞性API成为React性。

Mono<User> requestUser(String name) {...}

User getUser(String name) {
  return requestUser(name)
    .block();
}

在此示例中, .block()用于将单个结果从Mono桥接到命令式返回类型。

Flux<User> requestUsers() {...}

List<User> listUsers() {
  return requestUsers()
    .collectList()
    .block();
}

与前面的示例类似, .block()用于将结果桥接为命令式返回类型,但是在此之前,必须将Flux收集到单个List

错误处理

如前所述,错误是流经系统的值。 这意味着永远没有合适的点来捕获异常。 但是,您应该作为流的一部分或作为订阅者来处理它们。 .subscribe()方法具有0到3个参数,使您可以在每个项目到达时对其进行处理,如果生成则处理错误,并处理流的完成。

public static void main(String[] args) throws InterruptedException {
  CountDownLatch latch = new CountDownLatch(1);

  Flux.concat(Mono.just("alpha"), Mono.error(new IllegalStateException()))
    .subscribe(System.out::println, t -> {
      t.printStackTrace();
      latch.countDown();
    }, latch::countDown);

  latch.await();
}

在此示例中,值和错误都传递给订户。 重要的是要记住,在使用CountDownLatch时,仅调用onError()onComplete()中的一个。 因此,在错误和成功情况下都必须释放闩锁。

可组合方法参考

可以想象,任何严重依赖lambda的编程模型都容易遭受“回调地狱”的影响。 但是,只要有一些纪律和方法参考,就没有。 任何一位合理的Ruby开发人员都会告诉您的是,就可读性而言,小型私有方法(甚至是单行代码!)确实很有价值。 如果您很好地命名方法并使用方法引用语法,则可以创建易于阅读的流程。

public Flux<ApplicationSummary> list() {
  return Mono
    .zip(this.cloudFoundryClient, this.spaceId)
    .flatMap(function(DefaultApplications::requestSpaceSummary))
    .flatMapMany(DefaultApplications::extractApplications)
    .map(DefaultApplications::toApplicationSummary);
}

在此示例中,该流程读取得很好。 为了获得Flux<ApplicationSummary>我们首先传入cloudFoundryClientspaceId 。 我们使用这些请求空间摘要,从该空间摘要中提取应用程序,然后将每个应用程序映射到一个应用程序摘要。 对于任何单独的操作,我们都不知道它的行为,但是此时我们不需要。 如果需要,IDE可以很容易地遍历这些方法引用,但是此代码不会使每个方法的实现杂乱无章。

点自由风格

在本文中,您可能已经注意到我们使用了非常紧凑的样式。 这称为Pointfree样式 。 它的主要好处是,它可以帮助开发人员考虑组合功能(高级关注点),而不是重新整理数据(低级关注点)。 我们不会说这是编写React式编程时的硬性要求,但是我们发现大多数人(最终)都喜欢它。

Mono<Void> deleteApplication(String name) {
  return PaginationUtils
    .requestClientV2Resources(page -> this.client.applicationsV2()
      .list(ListApplicationsRequest.builder()
        .name(name)
        .page(page)
        .build()))
    .single()
    .map(applicationResource -> applicationResource.getMetadata().getId())
    .flatMap(applicationId -> this.client.applicationsV2()
      .delete(DeleteApplicationRequest.builder()
        .applicationId(applicationId)
        .build()));
}

如果看这个例子,您可以想象在许多地方可以分配变量,返回结果,并且通常看起来更像传统的命令式代码。 但是,这不太可能提高其可读性。 取而代之的是,添加更多的花括号,分号,等号和return语句,同时标识数据来自何处以及更明确地指向何处,可能会混淆流本身的实际点。

响应式编程是一个广泛的主题,几乎每个人都刚刚开始使用它。 到目前为止,编写React式代码时很少有“错误”的答案,但是与此同时,大量的选择使许多开发人员对最佳入门方法感到困惑。 我们希望我们的意见来自于大型项目的经验,可以帮助您进行响应式旅程,并鼓励您通过试验并将发现的成果回馈社区来推动最先进的技术发展。

Ben Hale是Pivotal的Cloud Foundry Java Experience团队的负责人,负责在Cloud Foundry上运行的Java应用程序周围的生态系统。

Paul Harris是Pivotal的Cloud Foundry Java客户端的首席开发人员,负责启用协调和管理Cloud Foundry的Java应用程序。

翻译自: https://www.infoq.com/articles/Designing-Implementing-Using-Reactive-APIs/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1

react api

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值