Vertx-实战-全-

Vertx 实战(全)

原文:Vert.x in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

前置材料

前言

我第一次在 2014 年接触到 Vert.x,当时我是 jClarity 的首席技术官,这是我与本·埃文斯和柯克·佩珀丁共同创立的一家初创公司。我们正在构建一个需要接收大量遥测数据、对其进行分析,并向最终用户展示调优建议的 SaaS。我们的用例需要非阻塞、异步通信、多租户(节省成本!)、与数据存储通信的能力,以及良好的安全 WebSocket 支持。它需要成为一个可扩展的分布式系统。这时,Vert.x 出现了!

我们的首席科学家约翰·奥利弗发现了这个灵活的框架来构建异步应用程序。Vert.x 可以做所有的事情。它凭借其 Netty 基础,具有惊人的性能,并支持所有其他功能性和非功能性要求。更好的是,它背后有一群聪明、谦逊、友好的工程师,例如这本书的作者朱利安·庞热。

Vert.x 故意是一个非强制性的框架,它不会像 Spring Boot 那样引导你走一条狭窄的道路。它更像是一个高质量工具的工具包,这些工具被设计成可以一起工作,但你必须决定如何集成它们。这就是这本书成为你不可或缺指南的地方。

书的第一部分揭示了两个主要构建块,即垂直处理单元和事件总线,以及异步编程模型如何与它们一起工作。但真正的价值在于第二部分。朱利安将引导你了解设计反应式应用程序的最佳实践,以及如何集成 Vert.x 的功能,如数据存储和 Web 栈。

对于我来说,奇怪的是,最令人印象深刻的是测试章节;测试反应式应用程序纯粹是件困难的事情,你真的会欣赏这一章!

阅读这本书是一种绝对的荣幸和快乐,即使它让我想起了我们在一些地方犯过的错误!不过,不用担心;当我们被微软收购时,我们带上了 Vert.x,这本书将成为我们完成真正全球规模故事的完美伴侣。

马丁·弗伯格——“邪恶的开发者”——Java 首席软件工程师团队经理(微软)

前言

我记得在 2012 年比利时 Devoxx 的舒适的电影室里坐着。在众多我计划参加的会议中,有一个是蒂姆·福克斯介绍他的新项目Vert.x。当时,Node.js 正成为所有炒作的焦点,回归异步编程被视为解决所有可扩展性问题的神奇解决方案。通过他的演讲,蒂姆说服了我(以及许多其他与会者),他刚刚为 JVM 上的异步编程奠定了坚实的基础,拥抱 Java 生态系统的优势,并从 Node.js 中挑选了好的想法。当时让我印象深刻的一点是,你可以编写简单的 Java 代码,并忘记复杂的基于注解的框架和应用服务器。Vert.x 感觉就像一股清新的空气,所以我一直关注着这个项目。快进几年:我现在在红帽公司的 Vert.x 团队工作,这在 2012 年时我根本想象不到!

在应用部署到虚拟化环境和容器化的时代,Vert.x 越来越相关。我们期望应用能够根据波动的流量进行扩展和缩减。我们期望应用具有低延迟。我们期望应用在其它系统失败时能够具有弹性。我们期望在给定的服务器上尽可能多地打包应用。简而言之,我们需要资源高效、可扩展和可靠的应用。

这就是反应式应用的精髓:无论是工作负载增长还是发生故障时,延迟都处于控制之下。Vert.x 是构建此类反应式应用的坚实基础,但 Vert.x 本身并不是万能的银弹。你不会通过从货架上拿一个软件栈来构建反应式应用;在架构和开发反应式应用时,你还需要一种方法论。

在这本书中,我们将探讨如何使用 Vert.x 编写反应式应用。这不仅仅关于学习 Vert.x,还包括异步编程的基础以及评估应用是否真正是反应式的技术。最后但同样重要的是,Vert.x 很有趣,你会发现这种简单性和忘记一些所谓的“最佳实践”可以带来解放。

致谢

我首先要感谢我的伴侣玛丽和我的儿子马修,他们给予了我难以置信的支持。写一本书需要从家庭生活中抽出一些时间,我很幸运能有他们在我身边。

我很感激能与红帽公司的杰出人士一起工作。感谢马克·利特尔、大卫·英厄姆、罗德尼·拉斯和朱利安·维埃,他们给了我机会,首先在红帽公司休假期间参与 Vert.x 项目,然后转到全职职位。非常感谢我的最亲密的同事朱利安·维埃、托马斯·塞吉蒙、克莱门特·埃斯科菲耶、保罗·洛佩斯、罗德尼·拉斯、斯蒂芬·埃帕尔多和弗朗西斯科·加利亚尼:与你们所有人一起工作是一种特权。

我在还是 INSA Lyon 的助理教授时就开始写这本书,我很幸运地得到了在我职业选择上的热情支持。感谢 Fabrice Valois、Frédéric Le Mouël、Nicolas Stouls、Oscar Carillo、François Lesueur 和 Éric Maurincomme。

对我来说,Martijn Verburg 为这本书撰写前言是一种荣誉。Martijn 是 Vert.x 项目中的历史人物,他在他的 jClarity 创业公司早期就证明了 Vert.x 是构建具有挑战性服务的生产级产品,后来被微软收购。非常感谢,Martijn。

Manning MEAP 项目给了我机会,在写作过程中收到很多反馈;感谢所有与我联系并提出评论、错别字和建议的人。

事实上,自从为 Manning 写作以来,我现在明白为什么他们的书如此出色。Manning 对投资作者和书籍非常认真。非常感谢我的发展编辑 Lesley Trites,她总是给予积极和建设性的指导,还要感谢与我一起开始这本书的 Kristen Watterson。感谢 Michael Stephens 对撰写关于 Java 中反应式应用程序的书籍的热情。非常感谢 Raphael Vilella 在我撰写章节时提供的准确技术反馈,以及 Evyatar Kafkafi 对其出色的技术校对。此外,还要感谢来自市场营销部门的 Candace Gillhoolley,我在 2018 年在蒙特利尔举办的反应式峰会上有幸与她见面。

向所有审稿人致谢:Michał Ambroziewicz、Andrew Buttery、Salvatore Campagna、Philippe Charrière、Ahmed Chicktay、John Clingan、Earl Benjamin Bingham、Arnaud Esteve、Damian Esteban、Leonardo Jose Gomes da Silva、Evyatar Kafkafi、Alexandros Koufoudakis、Sanket Naik、Eoghan O’Donnell、Dan Sheikh、Jerry Stralko、George Thomas、Evan Wallace、James Watson 和 Matthew Welke,你们的建议帮助使这本书变得更好。

关于这本书

异步和反应式应用程序是现代分布式系统中的一个重要主题,尤其是在逐渐转向虚拟化和容器化运行环境时,强调了资源高效、可适应和可靠的应用程序设计的需求。

异步编程是最大化硬件资源使用的关键,因为它允许我们处理比传统阻塞 I/O 体系结构更多的并发连接。服务需要满足可能在一小时内发生剧烈变化的工作负载,因此我们需要设计出自然支持水平扩展性的代码。最后但同样重要的是,当我们的服务通过网络与其他服务交互时,失败是不可避免的。接受失败是设计可靠系统的关键。

结合异步编程、水平扩展性和弹性,我们就有了今天所说的 反应式应用程序,也可以不使用营销术语概括为“可扩展且可靠的应用程序。”

话虽如此,天下没有免费的午餐,如果你有更传统的软件栈背景,转向编写异步和反应式应用程序是困难的。理解异步本身就很困难,但可扩展性和弹性对应用程序设计的影响并非微不足道。

本书旨在帮助所有背景的 Java 开发者自学构建异步和反应式应用程序的概念和实践。本书使用 Eclipse Vert.x,这是一个用于编写此类应用程序的“无魔法”工具包。开发者欣赏 Vert.x 的简单性、易于嵌入和经过实战检验的性能。

应该阅读这本书的人

本书面向熟悉 Web 开发、网络服务和 Spring 或 Java EE 等企业 Java 框架的中级 Java 开发者。不需要有异步或反应式编程的先验经验。

本书是如何组织的:路线图

《Vert.x 实战》分为两部分。

第一部分涵盖了异步编程的基础以及 Vert.x 的核心 API:

  • 第一章是 Vert.x、异步编程和 Vert.x 的介绍。如果你之前从未接触过异步编程,这一章将带你回顾 Java 中的核心非阻塞 API,并展示为什么 Vert.x 提供了一个更易于接近的编程模型。这一章还讨论了在现代分布式系统中引入反应式编程的必要性。

  • 第二章介绍了 verticles,这是在 Vert.x 中编写非阻塞代码的核心构建块。由于你有时需要调用阻塞或长时间运行的操作,这一章还为你提供了混合阻塞和非阻塞代码的工具和技术。

  • 第三章介绍了 事件总线,这是 verticles 用于通信的事件系统。事件总线的好处在于,它允许 verticles 不仅可以在单个进程中通信,还可以在集群中通信,这使得它成为一个强大的抽象。

  • 第四章讨论了异步流,重点关注 背压 的概念,这是在消费者和生产者之间调节事件流所必需的。

  • 第五章展示了如何使用除了回调之外的异步编程模型。虽然回调简单且高效,但在许多情况下,它们会使异步操作的协调变得困难。Vert.x 可以混合和匹配不同的模型:未来和承诺、反应式扩展和 Kotlin 协程。

  • 第六章回顾了事件总线,并介绍了事件总线服务,这是在事件总线之上的组件抽象。由于事件总线充当事件处理单元之间的自然界限,本章还讨论了如何在 Vert.x 中编写测试。

书的第二部分专注于构建一个现实世界的反应式应用程序:

  • 第七章展示了第二部分章节中将使用的真实反应式应用程序用例。该应用程序由多个事件驱动的微服务组成,我们将指定这些服务。

  • 第八章揭示了 Vert.x Web 堆栈的一些关键元素:设计 HTTP API、JSON Web 令牌、跨源资源共享以及与现代 Web 应用程序前端的集成。

  • 第九章全部关于消息和事件流。我们将涵盖消息代理中使用的 AMQP 协议、Apache Kafka 以及通过 SMTP 发送电子邮件。

  • 第十章涵盖了使用 Vert.x 进行数据库和持久状态管理。它展示了如何使用 MongoDB(一种所谓的 NoSQL 数据库)和 Vert.x 提供的原生响应式客户端 PostgreSQL 关系数据库。

  • 第十一章探讨了使用 RxJava 和 Apache Kafka 进行端到端实时响应式事件处理。这一章还讨论了如何将 JavaScript 网络应用程序连接到 Vert.x 事件总线,以实现统一的编程模型。

  • 第十二章高度实验性,提供了评估服务是否真正响应式的技术。通过使用负载和混沌测试工具,我们将观察服务的行为,并讨论故障缓解技术,如断路器,以及它们对服务整体行为的影响。

  • 第十三章是最后一章,讨论了在容器环境中运行的 Vert.x 应用程序。我们将讨论集群、应用程序配置和服务发现,使用简单机制。你将看到如何将 Vert.x 服务打包成容器镜像,部署到 Kubernetes 集群,并公开健康检查和指标。

第一章到第六章适用于所有读者。如果你已经对异步编程有一些经验,某些部分可以跳过。

第七章展示了基于事件驱动响应式服务的应用程序分解。

第八章到第十一章涵盖了 Vert.x 堆栈中最受欢迎的部分,应该对所有希望精通 Vert.x 的读者都有用。

第十二章是我们巩固一切知识并涉及弹性主题的地方,弹性对于构建响应式应用至关重要。这一章几乎可以独立阅读,对负载和混沌测试感兴趣的任何人都可以阅读。实际上,这一章代码较少,实践内容较多,你可以将同样的方法应用于除 Vert.x 以外的任何堆栈编写的服务。

最后,如果你对容器和 Kubernetes 不感兴趣,可以跳过第十三章。

关于代码

书中示例的源代码可以从 GitHub 仓库github.com/jponge/vertx-in-action或 Manning 网站www.manning.com/books/vertx-in-action免费下载。

样例需要 Java 8 或 11 进行编译。提供了 Maven 和 Gradle 构建。要运行第二部分章节的测试和示例,需要安装 Docker。本书的工作流程在 Unix 环境中更好:Linux、macOS 或 Microsoft Windows Subsystem for Linux(WSL)。我使用了一些您可能需要安装的命令行工具;详细信息将在相应的章节中给出。

本书包含许多源代码示例,既有编号列表,也有与普通文本混排。在这两种情况下,源代码都使用固定宽度字体如这样来格式化,以将其与普通文本区分开来。在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书中的可用页面空间。此外,当代码在文本中描述时,源代码中的注释通常已被从列表中删除。许多列表都有代码注释,突出显示重要概念。

liveBook 讨论论坛

购买《Vert.x 实战》将包括免费访问由 Manning Publications 运营的私人网络论坛,您可以在论坛上对书籍发表评论、提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/#!/book/vertx-in-action/discussion。您还可以在livebook.manning.com/#!/discussion了解更多关于 Manning 的论坛和行为准则。

Manning 对我们读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未支付报酬)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

朱利安·庞热博士是 Red Hat 的首席软件工程师,负责反应式和 Eclipse Vert.x 项目。他目前从 INSA Lyon 和 CITI 实验室休假,在那里他是计算机科学与工程副教授。他在那里担任过各种教学、研究、管理和行政职位。他在开源生态系统中拥有 20 年的经验,参与了许多项目,并创建了像 IzPack 和 Eclipse Golo 编程语言这样的项目。他还是用户组和会议的常客演讲者。他是法国克莱蒙奥弗涅大学(Université Clermont Auvergne)和澳大利亚新南威尔士大学的校友,在那里他获得了他的博士学位。

关于封面插图

《Vert.x 实战》的封面上的女性形象被标注为“库页岛女性”,或称库页岛上的女性。这幅插图取自雅克·Grasset de Saint-Sauveur(1757-1810)的作品集,名为《不同国家的服饰》,于 1797 年在法国出版。每一幅插图都是手工精心绘制和着色的。Grasset de Saint-Sauveur 收藏中的丰富多样性生动地提醒我们,仅仅 200 年前,世界的城镇和地区在文化上有多么不同。人们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。

自那以后,我们的着装方式已经改变,当时地区间的多样性已经消失。现在很难区分不同大陆、城镇、地区或国家的人们。也许我们用文化多样性换取了更加多样化的个人生活——当然,是为了更加多样化且节奏更快的技术生活。

在难以区分一本计算机书籍与另一本的时候,曼宁通过书籍封面上的设计,庆祝了计算机行业的创新和进取精神。这些设计基于两百年前丰富多样的地区生活,通过 Grasset de Saint-Sauveur 的画作得以重现。

第一部分:使用 Vert.x 的异步编程基础

建立反应式系统的第一步是采用异步编程。基于阻塞 I/O 的传统编程模型不如使用非阻塞 I/O 的模型扩展性好。用更少的资源服务更多请求非常吸引人,那么问题在哪里?确实有一个问题:如果你从未接触过异步编程,那么异步编程是一个非平凡的范式转变!

本书本部分章节将通过使用 Vert.x 工具包来教授你异步编程的基本概念。使用 Vert.x 进行异步操作思考绝对是可接近的(并且有趣!)的,我们将探讨 Vert.x 应用程序的主要构建块。

1 Vert.x、异步编程和反应式系统

本章涵盖

  • 什么是 Vert.x

  • 为什么分布式系统无法避免

  • 编程资源高效网络应用程序的挑战

  • 什么是异步和非阻塞编程

  • 反应式应用程序是什么,以及为什么异步编程不够

  • Vert.x 的替代方案

我们开发者生活在充满术语、技术和实践炒作周期的行业中。我长期以来一直教授大学生设计、编程、集成和部署应用程序的要素,并且亲眼目睹了新来者如何在新技术的汪洋大海中导航可能会多么复杂。

异步反应式是现代应用程序中的重要主题,我的目标是帮助开发者理解这些术语背后的核心概念,获得实践经验,并认识到何时这些方法有好处。我们将使用Eclipse Vert.x,这是一个用于编写异步应用程序的工具包,它还提供了针对“反应式”不同定义的解决方案。

在本书中,确保你理解这些概念是我的首要任务。虽然我希望你能够牢固地理解如何编写 Vert.x 应用程序,但我还想要确保你能够将在这里学到的技能应用到其他类似甚至可能竞争的技术中,无论是现在还是五年后。

1.1 分布式和网络化是常态

20 年前,部署可以在单机上独立运行并执行所有操作的业务应用是很常见的。这类应用通常具有图形用户界面,并拥有本地数据库或自定义文件管理来存储数据。当然,这只是一个夸张的说法,因为当时网络已经投入使用,并且业务应用可以利用网络上的数据库服务器、网络化文件存储以及各种远程代码操作。

现在,应用程序更自然地通过 Web 和移动界面向最终用户暴露。这自然引入了网络,因此产生了分布式系统。此外,面向服务的架构通过向其他服务发出请求来重用某些功能,这些服务可能由第三方提供商控制。例如,将消费者应用程序中的身份验证委托给流行的账户提供商,如 Google、Facebook 或 Twitter,或将支付处理委托给 Stripe 或 PayPal。

1.2 不生活在孤岛上

图 1.1 是现代应用程序的一个虚构描述:一组相互交互的网络服务。以下是一些这些网络化服务:

  • 类似 PostgreSQL 或 MongoDB 这样的数据库存储数据。

  • 类似 Elasticsearch 这样的搜索引擎允许查找之前已索引的信息,例如目录中的产品。

  • 类似于 Amazon S3 的持久化存储服务提供文档的持久和复制数据存储。

  • 消息服务可以是

    • 一个用于程序发送电子邮件的 SMTP 服务器。

    • 一个用于通过消息平台(如 Slack、Telegram 或 Facebook Messenger)与用户交互的机器人。

    • 用于应用程序到应用程序集成的集成消息协议,如 AMQP。

  • 类似于 Keycloak 的身份管理服务为用户和服务交互提供身份验证和角色管理。

  • 使用 Micrometer 等库进行监控可以暴露健康状态、指标和日志,以便外部编排工具可以维护适当的服务质量,可能通过启动新的服务实例或终止失败的服务实例来实现。

图片

图 1.1 一个网络化应用程序/服务

在本书的后面部分,您将看到典型服务的示例,例如 API 端点、流处理程序和边缘服务。1 前面的列表当然不是详尽的,但关键点是服务很少孤立存在,因为它们需要通过网络与其他服务进行通信才能运行。

1.3 网络上没有免费的午餐

计算中可能出现问题的正是网络:

  • 带宽可能会波动很大,因此服务之间密集的数据交互可能会受到影响。并非所有服务都能在同一个数据中心内享受快速带宽,即便如此,它仍然比同一台机器上进程之间的通信要慢。

  • 延迟波动很大,因为服务需要与处理特定请求的附加服务进行通信,所以所有由网络引起的延迟都会加到总的请求处理时间上。

  • 不可用性不应被视为理所当然:网络会失败。路由器会失败。代理会失败。有时有人碰到网络电缆并将其断开。当网络失败时,向另一个服务发送请求的服务可能无法确定是另一个服务还是网络出现了故障。

从本质上讲,现代应用程序是由分布式和网络化服务组成的。它们通过网络访问,这些网络本身会引入问题,每个服务都需要维护多个传入和传出的连接。

1.4 阻塞 API 的简单性

服务需要管理与其他服务和请求者的连接。管理并发网络连接的传统和广泛使用的模型是为每个连接分配一个线程。这是许多技术中的模型,例如 Jakarta EE 中的 Servlets(在版本 3 之前的添加)、Spring 框架(在版本 5 之前的添加)、Ruby on Rails、Python Flask 以及更多。这种模型的优势在于简单性,因为它具有 同步 的特性。

让我们来看一个例子,其中 TCP 服务器将输入文本回显给客户端,直到它看到 /quit 终端输入(如列表 1.3 所示)。

服务器可以使用书中的完整示例项目中的 Gradle run任务来运行(在终端中./gradlew run -PmainClass=chapter1.snippets.SynchronousEcho)。通过使用netcat命令行工具,我们可以发送和接收文本。

列表 1.1 netcat会话的客户端输出

$ netcat localhost 3000
Hello, Vert.x!           ❶
Hello, Vert.x!           ❷
Great
Great
/quit
/quit
$

❶ 这一行是命令行上的用户输入。

❷ 这一行是由 TCP 服务器发送的。

提示:您可能需要在您的操作系统上安装netcat(或nc)。

在服务器端,我们可以看到以下跟踪。

列表 1.2 服务器端跟踪

$ ./gradlew run -PmainClass=chapter1.snippets.SynchronousEcho
(...)
~ Hello, Vert.x!
~ Great
~ /quit

下面的列表中的代码提供了 TCP 服务器实现。这是java.io包的经典用法,该包提供了同步 I/O API。

列表 1.3 同步回显 TCP 协议

public class SynchronousEcho {
  public static void main(String[] args) throws Throwable {
    ServerSocket server = new ServerSocket();
    server.bind(new InetSocketAddress(3000));
    while (true) {                              ❶
      Socket socket = server.accept();
      new Thread(clientHandler(socket)).start();
    }
  }

  private static Runnable clientHandler(Socket socket) {
    return () -> {
      try (
        BufferedReader reader = new BufferedReader(
          new InputStreamReader(socket.getInputStream()));
        PrintWriter writer = new PrintWriter(
          new OutputStreamWriter(socket.getOutputStream()))) {
        String line = "";
        while (!"/quit".equals(line)) {
          line = reader.readLine();             ❷
          System.out.println("~ " + line);
          writer.write(line + "\n");            ❸
          writer.flush();
        }
      } catch (IOException e) {
        e.printStackTrace();
      }
    };
  }
}

❶ 主要应用程序线程扮演着接受线程的角色,因为它接收所有新连接的套接字对象。当没有挂起的连接时,操作会阻塞。为每个连接分配一个新的线程。

❷ 从套接字读取可能会阻塞分配给连接的线程,例如,当读取的数据不足时。

❸ 向套接字写入时也可能阻塞,例如,直到底层 TCP 缓冲区数据已通过网络发送。

服务器使用主线程来接受连接,并为每个连接分配一个新的线程来处理 I/O。I/O 操作是同步的,因此线程可能会在 I/O 操作上阻塞。

1.5 阻塞 API 浪费资源,增加成本

列表 1.3 中的代码的主要问题是它为每个传入连接分配一个新的线程,而线程并不是廉价的资源。线程需要内存,并且线程越多,对操作系统内核调度器的压力就越大,因为它需要为线程分配 CPU 时间。我们可以通过使用线程池在连接关闭后重用线程来改进列表 1.3 中的代码,但我们在任何给定时间点仍然需要n个线程来处理n个连接。

图 1.2

图 1.2 线程和阻塞 I/O 操作

这在图 1.2 中得到了说明,您可以看到三个并发网络连接的三个线程随时间变化的 CPU 使用率。如readLinewrite之类的输入/输出操作可能会阻塞线程,这意味着线程被操作系统挂起。这种情况发生有两个原因:

  • 读取操作可能正在等待从网络到达的数据。

  • 如果缓冲区因之前的写操作而已满,写操作可能需要等待缓冲区被清空。

现代操作系统可以正确处理几千个并发线程。并非每个网络服务都会面临如此多的并发请求的负载,但当我们谈论数万个并发连接时,这种模型很快就会显示出其局限性。

还需要记住,我们通常需要的线程比传入的网络连接多。以一个具体的例子来说明,假设我们有一个提供特定产品最佳价格的 HTTP 服务,它是通过请求四个其他 HTTP 服务的价格来实现的,如图 1.3 所示。这类服务通常被称为 边缘服务API 网关。按顺序请求每个服务并选择最低价格会使我们的服务非常慢,因为每个请求都会增加我们自己的服务延迟。有效的方法是从我们的服务开始四个并发请求,然后等待并收集它们的响应。这相当于启动四个额外的线程;如果我们有 1,000 个并发网络请求,在最坏的情况下,我们可能需要使用多达 5,000 个线程,这时所有请求都需要同时处理,我们没有使用线程池或从边缘服务到请求服务的持久连接。

图 1.3 边缘服务中的请求处理

图 1.3 边缘服务中的请求处理

最后但同样重要的是,应用程序通常部署到容器化或虚拟化环境中。这意味着应用程序可能看不到所有可用的 CPU 核心,并且它们分配的 CPU 时间可能有限。进程可用的内存也可能受到限制,因此拥有太多的线程也会消耗内存预算。这类应用程序必须与其他应用程序共享 CPU 资源,所以如果所有应用程序都使用阻塞 I/O API,那么在流量增加时,可能会有太多线程需要管理和调度,这需要启动更多的服务器/容器实例,这直接导致运营成本增加。

1.6 使用非阻塞 I/O 的异步编程

我们可以转向 非阻塞 I/O 而不是等待 I/O 操作完成。你可能已经通过 C 中的 select 函数体验过这种非阻塞 I/O。

非阻塞 I/O 的理念是请求一个(阻塞的)操作,然后继续做其他任务,直到操作结果准备好。例如,非阻塞读取可能请求网络套接字上的多达 256 字节,执行线程会做其他事情(比如处理另一个连接),直到数据被放入缓冲区,准备在内存中消费。在这个模型中,许多并发连接可以在单个线程上复用,因为网络延迟通常超过读取传入字节所需的 CPU 时间。

Java 很早就有了 java.nio (Java NIO) 包,它提供了文件和网络上的非阻塞 I/O API。回到我们之前关于 TCP 服务回显传入数据的例子,列表 1.4 到 1.7 展示了使用 Java 非阻塞 I/O 的可能实现。

列表 1.4 异步版本的 echo 服务:主循环

public class AsynchronousEcho {
  public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();

    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.bind(new InetSocketAddress(3000));
    serverSocketChannel.configureBlocking(false);                     ❶
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);   ❷

    while (true) {
      selector.select();                                              ❸
      Iterator<SelectionKey> it = selector.selectedKeys().iterator();
      while (it.hasNext()) {
        SelectionKey key = it.next();
        if (key.isAcceptable()) {                                     ❹
          newConnection(selector, key);
        } else if (key.isReadable()) {                                ❺
          echo(key);
        } else if (key.isWritable()) {                                ❻
          continueEcho(selector, key);
        }
        it.remove();                                                  ❼
      }
    }
  }
  // (...)

❶ 我们需要将通道置于非阻塞模式。

❷ 选择器将通知传入的连接。

❸ 这收集了所有非阻塞 I/O 通知。

❹ 我们有一个新的连接。

❺ 套接字已收到数据。

❻ 套接字再次准备好写入。

❼ 需要手动删除选择键,否则它们将在下一次循环迭代中再次可用。

列表 1.4 展示了服务器套接字通道准备代码。它打开服务器套接字通道并将其设置为非阻塞,然后注册一个 NIO 键选择器以处理事件。主循环遍历已准备好处理事件的选择键,并根据事件类型(新连接、数据到达或数据可以再次发送)将它们调度到专门的方法。

列表 1.5 异步版本的 echo 服务:接受连接

private static class Context {                                    ❶
    private final ByteBuffer nioBuffer = ByteBuffer.allocate(512);
    private String currentLine = "";
    private boolean terminating = false;
  }

  private static final HashMap<SocketChannel, Context> contexts = 
  ➥ new HashMap<>();

  private static void newConnection(Selector selector, SelectionKey key) 
  ➥ throws IOException {
    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) 
    ➥ key.channel();
    SocketChannel socketChannel = serverSocketChannel.accept();
    socketChannel
      .configureBlocking(false)
      .register(selector, SelectionKey.OP_READ);                  ❷
    contexts.put(socketChannel, new Context());                   ❸
  }

❶ Context 类保存与处理 TCP 连接相关的状态。

❷ 我们将通道设置为非阻塞并声明对读取操作的兴趣。

❸ 我们将所有连接状态保存在一个哈希表中。

列表 1.5 展示了如何处理新的 TCP 连接。对应于新连接的套接字通道被配置为非阻塞,然后在哈希表中跟踪以供进一步参考,其中它与某个 上下文对象 关联。上下文取决于应用程序和协议。在我们的情况下,我们跟踪当前行和连接是否正在关闭,并维护一个特定于连接的 NIO 缓冲区用于读取和写入数据。

列表 1.6 异步版本的 echo 服务:回显数据

private static final Pattern QUIT = Pattern.compile("(\\r)?(\\n)?/quit$");

  private static void echo(SelectionKey key) throws IOException {
    SocketChannel socketChannel = (SocketChannel) key.channel();
    Context context = contexts.get(socketChannel);
    try {
      socketChannel.read(context.nioBuffer);
      context.nioBuffer.flip();
      context.currentLine = context.currentLine + 
      ➥ Charset.defaultCharset().decode(context.nioBuffer);
      if (QUIT.matcher(context.currentLine).find()) {
        context.terminating = true;                             ❶
      } else if (context.currentLine.length() > 16) {
        context.currentLine = context.currentLine.substring(8);
      }
      context.nioBuffer.flip();                                 ❷
      int count = socketChannel.write(context.nioBuffer);
      if (count < context.nioBuffer.limit()) {                  ❸
        key.cancel();
        socketChannel.register(key.selector(), SelectionKey.OP_WRITE);
      } else {
        context.nioBuffer.clear();
        if (context.terminating) {
          cleanup(socketChannel);
        }
      }
    } catch (IOException err) {
      err.printStackTrace();
      cleanup(socketChannel);
    }
  }

❶ 如果我们找到一个以 /quit 结尾的行,我们将终止连接。

❷ Java NIO 缓冲区需要进行位置操作:缓冲区中有读取的数据,因此要将它写回客户端,我们需要翻转并返回到起始位置。

❸ 可能会发生的情况是并非所有数据都可以写入,因此我们停止寻找读取操作,并声明对通知的兴趣,该通知指示何时可以再次写入通道。

列表 1.6 包含 echo 方法的代码。处理非常简单:我们从客户端套接字读取数据,然后尝试将其写回。如果写入操作只是部分完成,我们停止进一步的读取,声明对知道套接字通道何时可写的兴趣,并确保所有数据都已写入。

列表 1.7 异步版本的 echo 服务:继续和关闭

private static void cleanup(SocketChannel socketChannel) throws IOException {
    socketChannel.close();
    contexts.remove(socketChannel);
  }

  private static void continueEcho(Selector selector, SelectionKey key) 
  ➥ throws IOException {
    SocketChannel socketChannel = (SocketChannel) key.channel();
    Context context = contexts.get(socketChannel);
    try {
      int remainingBytes = context.nioBuffer.limit() - context.nioBuffer.position();
      int count = socketChannel.write(context.nioBuffer);
      if (count == remainingBytes) {                       ❶
        context.nioBuffer.clear();
        key.cancel();
        if (context.terminating) {
          cleanup(socketChannel);
        } else {
          socketChannel.register(selector, SelectionKey.OP_READ);
        }
      }
    } catch (IOException err) {
      err.printStackTrace();
      cleanup(socketChannel);
    }
  }
}

❶ 我们保持在这个状态,直到所有数据都已写回。然后我们取消写入兴趣并声明读取兴趣。

最后,列表 1.7 展示了关闭 TCP 连接和完成写入缓冲区的方法的代码。在 continueEcho 中所有数据都已写入后,我们再次注册对读取数据的兴趣。

正如这个示例所示,使用非阻塞 I/O 是可行的,但与使用阻塞 API 的初始版本相比,它显著增加了代码的复杂性。echo 协议需要两个状态来读取和写回数据:读取或完成写入。对于更复杂的 TCP 协议,你可以很容易地预测需要更复杂的状态机。

还需要注意的是,与大多数 JDK API 一样,java.nio仅关注它所做的事情(在这里,I/O API)。它不提供高级协议特定的辅助工具,例如编写 HTTP 客户端和服务器。此外,java.nio不指定线程模型,这对于正确利用 CPU 核心仍然很重要,它也不处理异步 I/O 事件或阐述应用程序处理逻辑。

注意:这就是为什么在实际中,开发者很少直接处理 Java NIO。像 Netty 和 Apache MINA 这样的网络库解决了 Java NIO 的不足,许多工具包和框架都是建立在它们之上的。你很快会发现,Eclipse Vert.x 就是其中之一。

1.7 事件驱动处理的复用:事件循环的案例

处理异步事件的流行线程模型是事件循环。与我们在之前的 Java NIO 示例中那样轮询可能到达的事件不同,事件被推送到一个事件循环

如图 1.4 所示,事件在到达时被排队。它们可以是 I/O 事件,例如数据准备好消费或缓冲区已完全写入套接字。它们也可以是任何其他事件,例如定时器触发。一个线程被分配给一个事件循环,处理事件不应执行任何阻塞或长时间运行的操作。否则,线程会阻塞,违背了使用事件循环的目的。

图片

图 1.4 使用事件循环处理事件

事件循环非常流行:在浏览器中运行的 JavaScript 代码是在事件循环之上运行的。许多图形界面工具包,如 Java Swing,也有事件循环。

实现事件循环很简单。

列表 1.8 使用简单的事件循环

public static void main(String[] args) {
  EventLoop eventLoop = new EventLoop();
  new Thread(() -> {                                            ❶
    for (int n = 0; n < 6; n++) {
      delay(1000);
      eventLoop.dispatch(new EventLoop.Event("tick", n));
    }
    eventLoop.dispatch(new EventLoop.Event("stop", null));
  }).start();
  new Thread(() -> {                                            ❷
    delay(2500);
    eventLoop.dispatch(new EventLoop.Event("hello", "beautiful world"));
    delay(800);
    eventLoop.dispatch(new EventLoop.Event("hello", "beautiful universe"));
  }).start();
  eventLoop.dispatch(new EventLoop.Event("hello", "world!"));   ❸
  eventLoop.dispatch(new EventLoop.Event("foo", "bar"));
  eventLoop
    .on("hello", s -> System.out.println("hello " + s))         ❹
    .on("tick", n -> System.out.println("tick #" + n))
    .on("stop", v -> eventLoop.stop())
    .run();
  System.out.println("Bye!");
}

private static void delay(long millis) {                        ❺
  try {
    Thread.sleep(millis);
  } catch (InterruptedException e) {
    throw new RuntimeException(e);
  }
}

❶ 一个每秒向事件循环调度事件的第一个线程

❷ 一个每 2500 毫秒和 3300 毫秒调度两个事件的第二个线程

❸ 从主线程派发的事件

❹ 定义为 Java lambda 函数的事件处理器

❺ 此方法将可能抛出的检查异常包装为未检查异常,以避免将异常处理逻辑污染主方法代码。

列表 1.8 中的代码展示了使用事件循环 API 的使用情况,其执行会给出以下控制台输出。

列表 1.9 事件循环示例的控制台输出

hello world!
No handler for key foo
tick #0
tick #1
hello beautiful world
tick #2
hello beautiful universe
tick #3
tick #4
tick #5
Bye!

更复杂的事件循环实现是可能的,但以下列表中的实现依赖于事件队列和处理程序映射。

列表 1.10 简单事件循环实现

public final class EventLoop {
  private final ConcurrentLinkedDeque<Event> events = new ConcurrentLinkedDeque<>();
  private final ConcurrentHashMap<String, Consumer<Object>> handlers = new 
  ➥ ConcurrentHashMap<>();

  public EventLoop on(String key, Consumer<Object> handler) {     ❶
    handlers.put(key, handler);
    return this;
  }

  public void dispatch(Event event) { events.add(event); }        ❷
  public void stop() { Thread.currentThread().interrupt(); }

  public void run() {
    while (!(events.isEmpty() && Thread.interrupted())) {         ❸
      if (!events.isEmpty()) {
        Event event = events.pop();
        if (handlers.containsKey(event.key)) {
          handlers.get(event.key).accept(event.data);
        } else {
          System.err.println("No handler for key " + event.key);
        }
      }
    }
  }
}

❶ 处理器存储在一个映射中,其中每个键都有一个处理器。

❷ 调度是将事件推送到队列中。

❸ 事件循环查找事件并根据事件键找到处理器。

事件循环在调用run方法的线程上运行,可以使用dispatch方法从其他线程安全地发送事件。

最后但同样重要的是,一个事件简单来说就是一对键和数据的组合,如下所示,这是EventLoop的静态内部类。

列表 1.11 简单的事件循环实现

public static final class Event {
  private final String key;
  private final Object data;

  public Event(String key, Object data) {
    this.key = key;
    this.data = data;
  }
}

1.8 什么是反应式系统?

到目前为止,我们已经讨论了以下内容:

  • 利用异步编程和非阻塞 I/O 来处理更多并发连接并使用更少的线程

  • 使用一个线程模型进行异步事件处理(事件循环)

通过结合这两种技术,我们可以构建可伸缩和资源高效的程序。现在让我们讨论一下什么是反应式系统以及它是如何超越“仅仅”异步编程的。

反应式系统的四个特性在《反应式宣言》中得到了体现:响应性弹性可伸缩性消息驱动(www.reactivemanifesto.org/)。我们不会在这本书中重述宣言,所以这里简要说明一下这些特性是什么:

  • 可伸缩性 -- 可伸缩性是应用程序能够与可变数量的实例一起工作的能力。这很有用,因为可伸缩性允许应用程序通过启动新实例和跨实例负载均衡流量来响应流量峰值。这对代码设计有有趣的影响,因为实例之间的共享状态需要被很好地识别和限制(例如,服务器端 Web 会话)。对于实例报告指标很有用,这样协调器就可以根据网络流量和报告的指标来决定何时启动或停止实例。

  • 弹性 -- 弹性部分是弹性的反面。当一个弹性实例崩溃时,通过将流量重定向到其他实例,弹性自然地实现了,如果需要的话,还可以启动一个新的实例。但话又说回来,弹性还有更多。当一个实例由于某些条件无法满足请求时,它仍然会尝试以降级模式进行响应。根据应用领域,它可能可以使用较旧的缓存值进行响应,甚至可以响应空或默认数据。也可能将请求转发到某些其他非错误实例。在最坏的情况下,实例可以响应错误,但必须及时。

  • 响应性 -- 响应性是弹性和弹性的结合结果。一致的反应时间提供了强大的服务级别协议保证。这既得益于在需要时启动新实例的能力(以保持可接受的反应时间),也得益于实例在出现错误时仍然能够快速响应。重要的是要注意,如果一个组件依赖于非可伸缩的资源,如单个中央数据库,则响应性是不可能的。确实,如果它们都向一个很快就会过载的资源发出请求,那么启动更多实例并不能解决问题。

  • 消息驱动 --使用异步消息传递而不是像远程过程调用这样的阻塞范式是弹性性和弹性的关键推动者,这导致了响应性。这也使得消息可以被发送到更多的实例(使系统具有弹性),并控制消息生产者和消息消费者之间的流量(这就是反馈压力,我们将在本书后面探讨它)。

一个反应式系统表现出这四个特性,这使得系统可靠资源高效

异步是否意味着反应式?

这是一个重要的问题,因为异步通常被宣传为软件问题的神奇疗法。显然,反应式意味着异步,但反之不一定成立。

作为(并非如此)虚构的例子,考虑一个购物网站应用程序,用户可以将商品放入购物车。这通常是通过将商品存储在服务器端 Web 会话中实现的。当会话存储在内存或本地文件中时,即使系统内部使用非阻塞 I/O 和异步编程,系统也不是反应式的。实际上,应用程序的一个实例不能接管另一个实例,因为会话是应用程序状态,在这种情况下,该状态没有被复制并在节点之间共享。

这个例子的一种反应式变体会使用内存网格服务(例如,Hazelcast、Redis 或 Infinispan)来存储 Web 会话,以便将传入的请求路由到任何实例。

1.9 反应式还意味着什么?

由于反应式是一个流行的术语,它也被用于非常不同的目的。您刚刚看到了反应式系统是什么,但还有两个其他流行的反应式定义,总结在表 1.1 中。

表 1.1 所有反应式事物

反应式? 描述
系统 依赖性强、消息驱动、弹性好、响应快的应用程序。
编程 一种对变化和事件做出反应的方式。电子表格程序是反应式编程的一个很好的例子:当单元格数据发生变化时,依赖于受影响单元格的公式所在的单元格会自动重新计算。在本书的后面部分,您将看到 RxJava,这是一个流行的 Java 反应式扩展 API,它极大地帮助协调异步事件和数据处理。还有函数式反应式编程,这是一种编程风格,我们在这本书中不会涉及,但 Stephen Blackheath 和 Anthony Jones 的《函数式反应式编程》(Manning, 2016)是一本极好的资源。
当系统交换连续的数据流时,会出现经典的生产者/消费者问题。提供反馈压力机制特别重要,以便消费者可以在发出太快时通知生产者。通过反应式流(www.reactive-streams.org),主要目标是实现系统之间最佳的数据吞吐量。

1.10 什么是 Vert.x?

根据 Vert.x 网站 (vertx.io/),“Eclipse Vert.x 是在 JVM 上构建反应式应用程序的工具包。”

由 Tim Fox 于 2012 年发起,Vert.x 是一个现在由供应商中立的 Eclipse 基金会培养的项目。虽然最初的项目迭代旨在成为“JVM 上的 Node.js”,但 Vert.x 自那时起已经显著偏离,转向提供针对 JVM 特定性的异步编程基础。

Vert.x 的本质

如您从本章前面的部分所猜测的那样,Vert.x 的重点是处理异步事件,这些事件主要来自非阻塞 I/O,而线程模型在事件循环中处理事件。

非常重要的是要理解 Vert.x 是一个 工具包 而不是一个 框架:它不提供您应用程序的预定义基础,因此您可以在更大的代码库中使用 Vert.x 作为库。Vert.x 在您应该使用的构建工具、您想要如何结构化代码、您打算如何打包和部署它等方面几乎没有意见。一个 Vert.x 应用程序是模块的集合,提供您确切需要的东西,没有更多。如果您不需要访问数据库,那么您的项目就不需要依赖于数据库相关的 API。

Vert.x 项目的组织结构是可组合的模块,图 1.5 展示了一个随机 Vert.x 应用的结构:

  • 一个核心项目,名为 vertx-core,提供了异步编程、非阻塞 I/O、流式处理以及方便访问网络协议(如 TCP、UDP、DNS、HTTP 或 WebSockets)的 API。

  • 一组属于社区支持的 Vert.x 栈的模块,例如更好的 Web API (vertx-web) 或数据客户端 (vertx-kafka-clientvertx-redisvertx-mongo 等),为构建各种应用程序提供了功能。

  • 一个更广泛的生态系统项目提供了更多的功能,例如与 Apache Cassandra 连接、非阻塞 I/O 在系统进程之间通信等。

图 1.5 Vert.x 应用程序结构的概述

Vert.x 是一种 多语言 支持,因为它支持大多数流行的 JVM 语言:JavaScript、Ruby、Kotlin、Scala、Groovy 等。有趣的是,这些语言不仅通过与 Java 的互操作性得到支持,而且正在生成惯用绑定,因此您可以编写在 Vert.x 中仍然感觉自然的 Vert.x 代码。例如,Scala 绑定使用 Scala future API,而 Kotlin 绑定利用自定义 DSL 和带命名参数的函数来简化某些代码结构。当然,您还可以在同一个 Vert.x 应用程序中混合和匹配不同的支持语言。

1.11 您的第一个 Vert.x 应用程序

我们终于可以编写 Vert.x 应用程序了!

让我们继续使用本章中已以各种形式使用的 echo TCP 协议。它仍然会在端口 3000 上公开一个 TCP 服务器,任何数据都会发送回客户端。我们将添加两个其他功能:

  • 每隔五秒将显示打开的连接数。

  • 在端口 8080 上运行的 HTTP 服务器将返回一个字符串,表示当前打开的连接数。

1.11.1 准备项目

虽然在这个例子中不是严格必要的,但使用构建工具会更简单。在这本书中,我将使用 Gradle 举例,但你可以在书籍的源代码 Git 仓库中找到等效的 Maven 构建描述符。

对于这个项目,我们需要的唯一第三方依赖项是 vertx-core 艺术品及其依赖项。这个艺术品位于 Maven Central 的 io.vertx 组标识符下。

一个集成开发环境(IDE)如 IntelliJ IDEA Community Edition 非常好,它知道如何创建 Maven 和 Gradle 项目。你也可以同样使用 Eclipse、NetBeans,甚至是 Visual Studio Code。

小贴士:你还可以使用 Vert.x 启动器 Web 应用程序在 https://start.vertx.io 生成项目骨架以下载。

对于本章,让我们使用 Gradle。一个合适的 build.gradle.kts 文件看起来像下面的列表。

列表 1.12 构建 和 运行 VertxEcho 的 Gradle 配置

plugins {
  java
  application
}

repositories {
  mavenCentral()
}

dependencies {
  implementation("io.vertx:vertx-core:VERSION")    ❶
}

java {
  sourceCompatibility = JavaVersion.VERSION_1_8
}

application {
  mainClassName = "chapter1.firstapp.VertxEcho"    ❷
}

❶ 将 VERSION 替换为 Vert.x 的发布版本,如 3.9.1 或 4.0.0。

❷ 这是包含 main 方法的类的完全限定名称,这样我们就可以使用运行 Gradle 任务。

小贴士:你可能比 Gradle 更熟悉 Apache Maven。这本书使用 Gradle,因为它是一个现代、高效且灵活的构建工具。它还使用简洁的领域特定语言来编写构建文件,这在书籍的上下文中比 Maven XML 文件表现更好。你将在源代码 Git 仓库中找到与 Gradle 相当的 Maven 构建描述符。

1.11.2 VertxEcho

VertxEcho 类的实现如列表 1.15 所示。你可以使用 Gradle 的 run 任务(gradle run./gradlew run)运行应用程序,如下所示。

列表 1.13 运行 VertxEcho

$ ./gradlew run

> Task :run
We now have 0 connections
We now have 0 connections
We now have 0 connections
We now have 1 connections
We now have 1 connections
Jul 07, 2018 11:44:14 PM io.vertx.core.net.impl.ConnectionBase
SEVERE: Connection reset by peer
We now have 0 connections
<=========----> 75% EXECUTING [34s]
> :run

小贴士:如果你更喜欢 Maven,请从书籍源代码 Git 仓库中的 chapter1 文件夹运行 mvn compile exec:java 而不是 ./gradlew run

你当然可以使用 netcat 命令与该服务交互以回显文本,你还可以通过以下列表中的示例发送 HTTP 请求来查看打开的连接数。

列表 1.14 通过 TCP 和 HTTP 与 VertxEcho 交互

$ netcat localhost 3000
Hello from Tassin-La-Demi-Lune, France
Hello from Tassin-La-Demi-Lune, France

$ http :8080
HTTP/1.1 200 OK
content-length: 25

We now have 0 connections

小贴士:http 命令来自 httpie.org 的 HTTPie 项目。这个工具是 curl 的开发者友好替代品,你可以在你的操作系统上轻松安装它。

让我们现在看看 VertxEcho 的代码。

列表 1.15 VertxEcho 类的实现

package chapter1.firstapp;

import io.vertx.core.Vertx;
import io.vertx.core.net.NetSocket;

public class VertxEcho {

  private static int numberOfConnections = 0;                        ❶

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();

    vertx.createNetServer()
      .connectHandler(VertxEcho::handleNewClient)                    ❷
      .listen(3000);

    vertx.setPeriodic(5000, id -> System.out.println(howMany()));    ❸

    vertx.createHttpServer()
      .requestHandler(request -> request.response().end(howMany()))  ❹
      .listen(8080);
  }

  private static void handleNewClient(NetSocket socket) {
    numberOfConnections++;
    socket.handler(buffer -> {                                       ❺
      socket.write(buffer);
      if (buffer.toString().endsWith("/quit\n")) {
        socket.close();
      }
    });
    socket.closeHandler(v -> numberOfConnections--);                 ❻
  }

  private static String howMany() {
    return "We now have " + numberOfConnections + " connections";
  }
}

❶ 正如你将在下一章中看到的,事件处理器总是在同一个线程上执行,因此不需要 JVM 锁或使用 AtomicInteger。

❷ 创建 TCP 服务器需要为每个新连接传递一个回调。

❸ 这定义了一个周期性任务,回调每五秒执行一次。

❹ 与 TCP 服务器类似,HTTP 服务器通过提供为每个 HTTP 请求执行的回调来配置。

❺ 每当缓冲区准备好消费时,都会调用缓冲区处理器。这里我们只是将其写回,并使用方便的字符串转换助手来查找终止命令。

❻ 另一个事件是连接关闭。我们减少一个在连接时增加的连接计数器。

这个例子很有趣,因为它只有很少的代码行。它围绕着一个普通的 Java main 方法展开,因为没有框架来引导启动。我们只需要创建一个 Vertx 上下文,它进而提供了创建任务、服务器、客户端等方法,你将在下一章中了解到。

虽然在这里不明显,但事件循环正在管理事件的处理,无论是新的 TCP 连接、缓冲区的到达、新的 HTTP 请求,还是正在触发的周期性任务。此外,每个事件处理器都在同一个(事件循环)线程上执行。

1.11.3 回调的作用

正如你在列表 1.15 中看到的,回调是 Vert.x 用来通知应用程序代码异步事件并将其传递给某些处理器的首选方法。结合 Java 中的 lambda 表达式,回调为定义事件处理提供了一种简洁的方式。

你可能听说过或经历过臭名昭著的 回调地狱,其中回调嵌套在回调中,导致代码难以阅读和理解。

列表 1.16 回调地狱示例

dothis(a -> {
  dothat(b -> {
    andthis(c -> {
      andthat(d -> {
        alsothis(e -> {
          alsothat(f -> {
            // ...
          });
        });
      });
    });
  });
});

请放心:尽管 Vert.x 核心 API 确实使用回调,但 Vert.x 提供了对更多编程模型的支持。回调是事件驱动 API 中通知的规范手段,但正如你将在接下来的章节中看到的,可以在回调之上构建其他抽象,例如 future 和 promise、响应式扩展和协程。

虽然回调有其问题,但在嵌套级别较低的情况下,它们仍然是一个非常优秀的编程模型,具有最小的调度开销。

1.11.4 那这是一个响应式应用程序吗?

这是一个非常好的问题。重要的是要记住,虽然 Vert.x 是构建响应式应用程序的工具包,但使用 Vert.x API 和模块并不能“自动魔法”地使应用程序成为响应式的。然而,Vert.x 提供的基于事件驱动、非阻塞的 API 确实满足了第一个条件。

简短的回答是,不,这个应用程序不是响应式的。弹性不是问题,因为唯一可能出现的错误是 I/O 相关的——它们只是简单地导致丢弃连接。该应用程序也是响应式的,因为它不执行任何复杂的处理。如果我们对 TCP 和 HTTP 服务器进行基准测试,我们会得到非常好的延迟,低偏差和非常少的异常值。以下列表显示了使用 wrkgithub.com/wg/wrk)从终端运行的一个不完美但很有说明性的快速基准测试。

列表 1.17 使用 wrk 的基准测试会话输出

$ wrk --latency http://localhost:8080/
Running 10s test @ http://localhost:8080/
  2 threads and 10 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   136.98us  106.91us   7.26ms   97.37%
    Req/Sec    36.62k     4.09k   45.31k    85.64%
  Latency Distribution
     50%  125.00us
     75%  149.00us
     90%  199.00us
     99%  340.00us
  735547 requests in 10.10s, 44.89MB read
Requests/sec:  72830.90
Transfer/sec:      4.45MB

不响应的罪魁祸首显然是弹性。确实,如果我们创建新的实例,每个实例都会维护自己的连接计数器。计数器的范围是应用程序,因此它应该在所有实例之间共享的全局计数器。

正如这个示例所示,设计响应式应用程序比仅仅实现响应和资源高效的系统要微妙得多。确保应用程序可以运行尽可能多的可替换实例,这出人意料地更有吸引力,尤其是在我们需要考虑 实例状态应用程序状态 以确保实例可以互换时。

如果我是 Windows 用户怎么办?

wrk 是一个在类似 Linux 和 macOS 的 Unix 系统上运行的命令行工具。

在这本书中,我们更倾向于使用 Unix 风格的工具和命令行界面,而不是图形用户界面。我们将使用功能强大、直观,并由活跃的开源社区维护的 Unix 工具。

幸运的是,您不必离开 Windows 就能从这些工具中受益!虽然其中一些工具在 Windows 上原生运行,但从 Windows 10 开始,您可以安装 Windows Subsystem for Linux (WSL) 并从真正的 Linux 环境中受益,同时保持您更传统的 Windows 桌面环境。微软将 WSL 作为 Windows 开发者的一个主要功能进行推广,我只能建议您花些时间熟悉它。您可以查看微软的 WSL 常见问题解答以获取更多详细信息:docs.microsoft.com/en-us/windows/wsl/faq

1.12 什么是 Vert.x 的替代方案?

正如您将在本书中看到的那样,Vert.x 是构建端到端响应式应用程序的有力技术。响应式应用程序开发是一个热门话题,了解原则比盲目成为某一特定技术的专家更重要。您在本书中学到的知识可以轻松转移到其他技术,我强烈建议您去探索它们。

这里列出了 Vert.x 在异步和响应式编程中最受欢迎的替代方案:

  • Node.js -- Node.js 是一个用于编写异步 JavaScript 应用程序的事件驱动运行时环境。它基于 Google Chrome 所使用的 V8 JavaScript 引擎。乍一看,Vert.x 和 Node.js 有很多相似之处。然而,它们之间存在着很大的差异。Vert.x 默认运行多个事件循环,而 Node.js 则不是。此外,JVM 拥有更好的 JIT 编译器和垃圾回收器,因此 JVM 更适合长时间运行的过程。最后但同样重要的是,Vert.x 支持 JavaScript。

  • Akka -- Akka 是对 actor 模型的忠实实现。它运行在 JVM 上,主要提供 Scala API,尽管也在推广 Java 绑定。Akka 特别有趣,因为演员是消息驱动的且位置透明,演员还提供了对错误恢复感兴趣的管理功能。Akka 明确针对响应式应用程序的设计。正如你将在本书中看到的那样,Vert.x 在这项任务上同样能胜任。Vert.x 有一个名为 verticles 的概念,这是一种松散的演员形式,用于处理异步事件。有趣的是,Vert.x 在 TechEmpower 等现有基准测试(www.techempower.com/benchmarks/)中比 Akka 和大多数替代品都要快得多。

  • Spring Framework -- 较老且广泛使用的 Spring Framework 现在集成了响应式堆栈。它基于 Project Reactor,这是一个与 RxJava 非常相似的响应式编程 API。Spring 响应式堆栈的焦点本质上在于响应式编程 API,但它并不一定导致端到端的响应式应用程序。Spring Framework 的许多部分都使用了阻塞 API,因此必须格外小心以限制对阻塞操作的暴露。Project Reactor 是 RxJava 的一个有吸引力的替代品,但 Spring 响应式堆栈与这个 API 相关联,并且可能并不总是表达某些异步结构的最优方式。Vert.x 提供了更多的灵活性,因为它支持回调、未来、Java CompletionStage、Kotlin 协程、RxJava 和纤维。这意味着使用 Vert.x,更容易为特定任务选择正确的异步编程模型。同样,与 Akka 类似,Vert.x 在 TechEmpower 基准测试中保持显著的速度优势,并且应用程序启动速度比基于 Spring 的应用程序更快。

  • Quarkus -- Quarkus 是一个用于开发在容器环境(如 Kubernetes)中运行得特别出色的 Java 应用程序的新框架( quarkus.io)。确实,在这样的环境中,启动时间和内存消耗是关键的成本节约因素。Quarkus 在编译时采用技术,以便在运行传统的 Java 虚拟机和原生可执行文件时获得合理的收益。它基于流行的库,如 Hibernate、Eclipse MicroProfile、RESTEasy 和 Vert.x。Quarkus 统一了命令式和反应式编程模型,Vert.x 是该框架的基石。Vert.x 不仅用于提供网络堆栈的一些组件的动力;一些客户端模块直接基于 Vert.x 的那些,例如 Quarkus 邮件服务和反应式路由。你还可以在 Quarkus 应用程序中使用 Vert.x API,反应式和命令式之间的统一可以帮助你连接这两个世界。Vert.x 和 Quarkus 有不同的编程范式:Vert.x 将吸引那些更喜欢工具箱方法的开发者,或者那些对 Node.js 有亲和力的开发者。相比之下,Quarkus 将吸引那些更喜欢具有依赖注入和约定优于配置的偏见的堆栈方法的开发者。最终,这两个项目是协同工作的,你用 Vert.x 开发的任何内容都可以在 Quarkus 中重用。

  • Netty -- Netty 框架为 JVM 提供了非阻塞 I/O API。与使用原始 NIO API 相比,它提供了抽象和平台特定的错误修复。它还提供了线程模型。Netty 的目标是低延迟和高性能的网络应用程序。虽然你当然可以用 Netty 构建反应式应用程序,但 API 仍然相当低级。Vert.x 是建立在 Netty 之上的许多技术之一(Spring Reactive 和 Akka 都有 Netty 集成),你可以通过 Vert.x 的更简单 API 获得所有 Netty 的性能优势。

  • 脚本语言 -- 脚本语言,如 Python 和 Ruby,也提供了非阻塞 I/O 库,例如 Async(Ruby)和 Twisted(Python)。你当然可以用它们构建反应式系统。再次强调,JVM 性能是 Vert.x 的优势之一,同时它还支持使用替代 JVM 语言(Ruby 由 Vert.x 正式支持)。

  • 本地语言 -- 本地语言再次变得流行起来。不再使用传统的 C/C++ 语言,Go、Rust 和 Swift 正在获得越来越多的关注。它们都符合构建高度可扩展应用程序的要求,并且确实可以用于创建反应式应用程序。尽管如此,这些语言中最有效的库相当低级,最终基于 JVM 的 Vert.x/Netty 组合在基准测试中仍然具有优势。

以下书籍是许多先前主题的良好资源:

  • 《Mike Cantelon、Marc Harter、T.J. Holowaychuk 和 Nathan Rajlich 著的 Node.js in Action》(Manning,2013 年)

  • 《Akka 实战》 由 Raymond Roestenburg、Rob Bakker 和 Rob Williams 著(Manning, 2016)

  • 《响应式应用开发》 由 Duncan K. DeVore、Sean Walsh 和 Brian Hanafee 著(Manning, 2018)

  • 《Spring 实战》 第五版,由 Craig Walls 著(Manning, 2018)

  • 《Netty 实战》 由 Norman Maurer 和 Marvin Allen Wolfthal 著(Manning, 2015)

  • 《Go 实战》 由 William Kennedy 与 Brian Ketelsen 和 Erik St. Martin 合著(Manning, 2015)

  • 《Rust 实战》 由 Tim McNamara 著(Manning, 2019)

  • 《Swift 深度解析》 由 Tjeerd in 't Veen 著(Manning, 2018)

在下一章中,我们将剖析 Vert.x 异步编程的基础。

摘要

  • 异步编程允许你在单个线程上多路复用多个网络连接。

  • 管理非阻塞 I/O 比基于阻塞 I/O 的等效命令式代码更复杂,即使是对于简单的协议也是如此。

  • 事件循环和反应器模式简化了异步事件处理。

  • 一个响应式系统既可扩展又具有弹性,即使在负载和故障要求下,也能以一致的延迟产生响应。

  • Vert.x 是一个易于使用、高效的工具包,用于在 JVM 上编写异步和响应式应用程序。


  1. 对于已经熟悉微服务模式的读者来说,在我看来,“边缘服务”比“API 网关”是一个更好的术语。

2 Verticles:Vert.x 的基本处理单元

本章涵盖

  • verticles 是什么

  • 如何编写、配置和部署 verticles

  • Vert.x 的线程模型

  • 如何混合 Vert.x 和非 Vert.x 线程

简单来说,verticle是 Vert.x 中的基本处理单元。verticle 的作用是封装一个技术功能单元以处理事件,例如暴露 HTTP API 并响应请求,在数据库之上提供存储库接口,或向第三方系统发出请求。与像 Enterprise JavaBeans 这样的技术中的组件类似,verticles 可以被部署,并且它们有自己的生命周期。

异步编程是构建响应式应用的关键,因为它们需要扩展,而 verticles 在 Vert.x 中对于结构(异步)事件处理代码和业务逻辑是基本的。

2.1 编写 verticle

如果您熟悉actor 并发模型,您将在 Vert.x verticles 和 actors 之间找到相似之处。1 简单来说,在 actor 模型中,自主实体(actors)通过发送和响应消息专门与其他实体通信。Vert.x verticles 和 actors 之间的相似性并非偶然巧合:verticles 具有可能在接收事件时更新的私有状态,它们可以部署其他 verticles,并且它们可以通过消息传递进行通信(下一章将详细介绍)。verticles 不一定遵循 actor 的正统定义,但公平地说,Vert.x 至少受到了 actor 的启发。

由于 verticles 是 Vert.x 中的一个关键概念,我们将探讨它们是如何工作的。在此之前,我们将编写一个小型 verticle 来处理两种类型的事件:周期性计时器和 HTTP 请求。

2.1.1 准备项目

我们将使用一个通用的项目来展示本章中的所有示例,并使用以下列表中的 Gradle 项目描述符。

列表 2.1 第二章示例的 Gradle build.gradle.kts

plugins {
  java
}

repositories {
  mavenCentral()
}

dependencies {
  implementation("io.vertx:vertx-core:VERSION")                   ❶
  implementation("ch.qos.logback:logback-classic:1.2.3")          ❷
}

tasks.create<JavaExec>("run") {                                   ❸
  main = project.properties.getOrDefault("mainClass", 
  ➥ "chapter2.hello.HelloVerticle") as String
  classpath = sourceSets["main"].runtimeClasspath
  systemProperties["vertx.logger-delegate-factory-class-name"] = 
  ➥ "io.vertx.core.logging.SLF4JLogDelegateFactory"              ❹
}

java {
  sourceCompatibility = JavaVersion.VERSION_1_8
}

❶ 这是 Vert.x 核心库依赖。将“VERSION”替换为最近的发布号,如 3.9.0。

❷ logback-classic 依赖提供了 SLF4J 日志记录器 API 和 logback 实现。

❸ 这将允许您从命令行运行使用 Gradle 的示例。

❹ 这确保了 Vert.x 本身也使用 SLF4J 日志记录。

Gradle 构建对于 Java 项目来说非常简单。由于我们将运行多个示例,我们不会依赖于 Gradle application插件,而是定义自己的自定义run任务,其中我们可以传递要执行的类的名称。我们还将使用它来确保日志配置正确且统一到 SLF4J。

列表 2.2 Logback 配置以减少 Netty 的冗余

<configuration>                                                        ❶
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%level [%thread] %logger{0} - %msg%n</pattern>          ❷
    </encoder>
  </appender>

  <logger name="io.netty" level="warn"/>                               ❸

  <root level="debug">
    <appender-ref ref="STDOUT"/>
  </root>
</configuration>

❶ 这定义了一个将事件发送到控制台的后缀。

❷ 该模式定义了日志事件的样式。

❸ 我们丢弃比警告更冗余的 Netty 日志事件。

提示:Vert.x 使用 Netty,并且 Netty 的默认 Logback 配置中的日志记录相当详细。我们可以通过创建一个 src/main/resources/logback.xml 文件并添加配置来减少日志条目数量,如列表 2.2 中所示。为了使本书中的日志样本更短,我还删除了事件日期并缩短了记录器类名($logger{0})。请参阅 Logback 文档以了解如何配置它 (logback.qos.ch/manual/index.html)。

2.1.2 组件类

整个组件和应用程序都包含在以下 Java 类中。

列表 2.3 一个示例组件

package chapter2.hello;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Vertx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloVerticle extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(HelloVerticle.class);
  private long counter = 1;

  @Override
  public void start() {
    vertx.setPeriodic(5000, id -> {                    ❶
      logger.info("tick");
    });

    vertx.createHttpServer()
      .requestHandler(req -> {                         ❷
        logger.info("Request #{} from {}", counter++, 
        ➥ req.remoteAddress().host());
        req.response().end("Hello!");
      })
      .listen(8080);
    logger.info("Open http://localhost:8080/");
  }

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();                       ❸
    vertx.deployVerticle(new HelloVerticle());         ❹
  }
}

❶ 这定义了一个每五秒执行一次的周期性任务。

❷ HTTP 服务器会在每次请求时调用此处理器。

❸ 我们需要一个全局的 Vert.x 实例。

❹ 这是部署组件的最简单方法。

这个组件定义了两个事件处理器:一个用于每五秒执行一次的周期性任务,另一个用于处理 HTTP 服务器中的 HTTP 请求。main 方法实例化一个全局的 Vert.x 实例并部署一个组件实例。

在 Java 中定义组件通常是通过特化 AbstractVerticle 类来完成的。理论上您可以实现 Verticle 接口,但 AbstractVerticle 提供了 Vert.x 用户所需的所有事件处理、配置和执行管道。

注意:由于 Vert.x 是一个库而不是框架,您可以从 main 方法或任何其他类中创建 Vert.x 实例,然后部署组件。

组件的生命周期由启动和停止事件组成。AbstractVerticle 类提供了可以重写的 startstop 方法:

  • start 方法通常包含处理器的设置和初始化,如列表 2.3 中设置周期性任务处理器和启动 HTTP 服务器。

  • 当需要执行清理任务时,例如关闭打开的数据库连接,会实现 stop 方法。

默认情况下,这些方法不执行任何操作。

2.1.3 运行和初步观察

应用程序可以作为常规 Java 应用程序启动,通过 IDE 或命令行运行 main 方法。要在命令行中使用 Gradle 运行它,可以使用以下命令:

$ ./gradlew run -PmainClass=chapter2.hello.HelloVerticle

我再次假设您将从 Unix shell 中运行此程序,无论是 Linux、macOS 还是通过 WSL 的 Windows。如果您从传统的 Windows 终端运行命令,有一个 Gradle 的 .bat 文件,因此您需要将 ./gradlew 替换为 gradlew.bat

一旦应用程序运行,您可以使用网络浏览器在 http://localhost:8080/ 上执行几个 HTTP 请求,或者通过使用 curl 和 HTTPie 等命令行工具。日志将类似于以下列表中所示。

列表 2.4 运行 HelloVerticle 时的示例日志输出

INFO [vert.x-eventloop-thread-0] HelloVerticle - Open http://localhost:8080/❶
INFO [vert.x-eventloop-thread-0] HelloVerticle - tick                       ❷
INFO [vert.x-eventloop-thread-0] HelloVerticle - 
➥ Request #1 from 0:0:0:0:0:0:0:1                                          ❸
INFO [vert.x-eventloop-thread-0] HelloVerticle - 
➥ Request #2 from 0:0:0:0:0:0:0:1
INFO [vert.x-eventloop-thread-0] HelloVerticle - 
➥ Request #3 from 0:0:0:0:0:0:0:1
INFO [vert.x-eventloop-thread-0] HelloVerticle - 
➥ Request #4 from 0:0:0:0:0:0:0:1
INFO [vert.x-eventloop-thread-0] HelloVerticle - tick

❶ HTTP 服务器现在已准备就绪。

❷ 周期性任务事件日志

❸ HTTP 请求事件日志

提示:在一些剩余的示例中,我已经缩短了类定义。我特别移除了与列表 2.3 中类似的包定义、导入和main方法。要查看完整的源代码,请查阅本书的代码仓库。

我们使用的 Logback 配置显示了与事件相关联的线程名称。我们已经在日志条目中检查了 Vert.x 垂直结构的一个重要属性:事件处理发生在单个事件循环线程上。周期性任务和 HTTP 请求处理都发生在日志中显示为vert.x -eventloop-thread-0的线程上。

这种设计的明显好处是,垂直实例总是在同一线程上执行事件处理,因此不需要使用线程同步原语。在多线程设计中,更新counter字段可能需要synchronized块或使用java.util.concurrent.AtomicLong。这里没有这样的问题,因此可以安全地使用普通long字段。

准备方法,如createHttpServersetTimer,可能从非 Vert.x 线程中调用。这可能会发生在你直接使用Vertx对象而没有垂直结构,或者当你编写单元测试时。然而,这并不是一个问题,因为Vertx类方法的使用是线程安全的。

图 2.1 显示了垂直结构、处理器、Vert.x 和事件源之间的(简化)交互。每条箭头代表参与者之间的方法调用。例如,HelloVerticle通过在Vertx对象上调用setPeriodic来创建周期性任务处理器,这反过来又使用内部 Vert.x 计时器创建周期性任务。然后,计时器定期回调HelloVerticle中的timerHandler处理器。

图片

图 2.1 列表 2.3 的执行

注意,我将requestHandlerlisten的调用表示为对Vertx对象的调用,这是一个快捷方式;实际上,它们是在实现HttpServer接口的对象上。实际类是 Vert.x 内部的,并且由于它不服务于添加另一个参与者,我将其合并到Vertx中。

2.2 关于垂直结构更多内容

关于编写和部署垂直结构,还有更多需要了解的内容:

  • 当事件循环被阻塞时会发生什么?

  • 在存在异步初始化工作的情况下,你如何延迟生命周期完成的通告?

  • 你如何部署和卸载垂直结构?

  • 你如何传递配置数据?

我们将使用非常简单但专注的示例来涵盖这些主题。

2.2.1 阻塞和事件循环

处理器回调是在事件循环线程上运行的。代码在事件循环上运行时,应尽可能少地占用时间,以便事件循环线程可以处理更多的事件。这就是为什么不应该在事件循环上发生长时间运行或阻塞 I/O 操作。

话虽如此,在第三方库中使用时,可能并不总是容易发现阻塞代码。Vert.x 提供了一个检查器,用于检测事件循环被阻塞的时间过长。

为了说明这一点,让我们看看当我们在一个事件处理程序回调中引入无限循环时会发生什么。

列表 2.5 一个事件循环被阻塞的示例

public class BlockEventLoop extends AbstractVerticle {

  @Override
  public void start() {
    vertx.setTimer(1000, id -> {
      while (true);                ❶
    });
  }

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();
    vertx.deployVerticle(new BlockEventLoop());
  }
}

❶ 无限循环!

列表 2.5 中的代码定义了一个一秒定时器,并且处理程序回调进入了一个无限循环。

列表 2.6 运行列表 2.5 时的日志输出

WARN [vertx-blocked-thread-checker] BlockedThreadChecker - Thread 
➥ Thread[vert.x-eventloop-thread-0,5,main] has been blocked for 2871 
➥ ms, time limit is 2000
WARN [vertx-blocked-thread-checker] BlockedThreadChecker - Thread 
➥ Thread[vert.x-eventloop-thread-0,5,main] has been blocked for 3871 ms, 
➥ time limit is 2000                                                      ❶
(...)
WARN [vertx-blocked-thread-checker] BlockedThreadChecker - Thread 
➥ Thread[vert.x-eventloop-thread-0,5,main] has been blocked for 5879 
➥ ms, time limit is 2000
io.vertx.core.VertxException: Thread blocked
  at chapter2.blocker.BlockEventLoop.lambda$start$0(BlockEventLoop.java:11)
  at chapter2.blocker.BlockEventLoop$$Lambda$10/152379791.handle(Unknown 
  ➥ Source)
(...)

❶ 线程检查器不开心了。

列表 2.6 显示了运行列表 2.5 代码时的典型日志输出。如您所见,当事件循环线程运行无限循环时,开始出现警告,因此它无法处理其他事件。经过一些迭代(默认为五秒),警告中包含了堆栈跟踪转储,因此您可以清楚地识别出代码中的问题。请注意,这只是一个警告。事件循环线程检查器不能杀死执行时间过长的处理程序。

当然,有时您可能需要使用阻塞或长时间运行的代码,而 Vert.x 提供了解决方案来运行这样的代码而不阻塞事件循环。这是第 2.3 节的主题。

配置 Vert.x 阻塞线程检查器

默认情况下,在阻塞的线程检查器抱怨之前的时间限制是两秒,但可以配置为不同的值。在一些环境,例如嵌入式设备中,处理能力较慢,因此对于它们来说增加线程检查器的阈值是正常的。

您可以使用系统属性来更改设置:

  • -Dvertx.options.blockedThreadCheckInterval=5000 将间隔更改为五秒。

  • -Dvertx.threadChecks=false 禁用了线程检查器。

注意,这个配置是全局的,并且不能根据每个 verticle 进行微调。

2.2.2 生命周期事件的异步通知

到目前为止,我们已经查看了一些使用 start() 生命周期方法的示例。这些方法中的约定是,一个 verticle 成功完成了其 start 生命周期事件处理,除非方法抛出异常。stop() 方法也是如此。

然而,存在一个问题:startstop 方法中的某些操作可能是异步的,因此它们可能在调用 start()stop() 返回之后完成。

让我们看看如何正确通知延迟成功或失败的调用者。一个很好的例子是启动 HTTP 服务器,这是一个非阻塞操作。

列表 2.7 异步启动生命周期方法的示例

public class SomeVerticle extends AbstractVerticle {

   @Override
  public void start(Promise<Void> promise) {           ❶
    vertx.createHttpServer()
      .requestHandler(req -> req.response().end("Ok"))
      .listen(8080, ar -> {
        if (ar.succeeded()) {                          ❷
          promise.complete();                          ❸
        } else {
          promise.fail(ar.cause());                    ❹
        }
      });
  }
}

❶ Promise 是 void 类型,因为 Vert.x 只对部署完成感兴趣,没有值需要携带。

❷ 支持异步结果的 listen 变体指示操作是否失败。

❸ 使用 complete() 来标记 Promise 为完成状态(当 Promise 不是 void 类型时,可以传递一个值)。

❹ 如果监听操作失败,我们将 Promise 标记为失败并传播错误。

列表 2.7 展示了当 verticle 启动时报告异步通知的示例。这很重要,因为启动 HTTP 服务器可能会失败。确实,TCP 端口可能被另一个进程使用,在这种情况下,HTTP 服务器无法启动,因此 verticle 没有成功部署。为了报告异步通知,我们使用listen方法的变体,当操作完成时调用回调。

AbstractVerticle中的startstop方法支持具有io.vertx.core.Promise类型参数的变体。正如其名所示,Vert.x 的Promise是对future 和 promise模型进行异步结果处理的改编。2 一个promise用于写入异步结果,而一个future用于查看异步结果。给定一个Promise对象,您可以调用future()方法来获取类型为io.vertx.core.Future的 future。

在列表 2.7 中,Promise对象被设置为在 verticle 成功完成其startstop生命周期时完成。如果出现错误,Promise对象将因异常描述错误而失败,verticle 部署也会失败。

图片

图 2.2 使用 Promise 和listen处理器的 HTTP 服务器启动序列图

为了更好地理解这里发生的情况,图 2.2 说明了 verticle、Vert.x 对象和负责调用start方法的内部 Vert.x 部署器对象之间的交互。我们可以检查部署器等待 Promise 完成以确定部署是否成功,即使在调用start方法返回之后也是如此。相比之下,图 2.3 显示了不使用接受Promise对象变体的start时的交互。部署器无法通知错误。

图片

图 2.3 无 Promise 和listen处理器的 HTTP 服务器启动序列图

提示:使用异步方法变体,如列表 2.7 中的listen方法,来通知错误是一种良好的健壮性实践。如果这允许我减少代码示例的冗长性,我将在本书的其余部分不总是这样做。

2.2.3 部署 verticles

到目前为止,我们一直是从单个 verticle 类中嵌入的main方法部署 verticles。

Verticles 总是通过Vertx对象部署(和取消部署)。您可以从任何方法中这样做,但部署由 verticles 组成的应用的典型方式如下:

  1. 部署main verticle。

  2. main verticle 部署其他 verticles。

  3. 部署的 verticles 可以进一步部署其他 verticles。

注意,虽然这听起来像是分层结构,但 Vert.x 没有正式的父/子 verticles 概念。

为了说明这一点,让我们定义一些 verticles。

列表 2.8 一个用于部署的示例垂直结构

public class EmptyVerticle extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(EmptyVerticle.class);

  @Override
  public void start() {
    logger.info("Start");    ❶
  }

  @Override
  public void stop() {
    logger.info("Stop");
  }
}

❶ 我们记录垂直结构启动时的情况。

列表 2.8 定义了一个简单的垂直结构。它除了在启动和停止时记录日志外,没有做任何有趣的事情。

列表 2.9 一个部署和卸载其他垂直结构的垂直结构

public class Deployer extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(Deployer.class);

  @Override
  public void start() {
    long delay = 1000;
    for (int i = 0; i < 50; i++) {
      vertx.setTimer(delay, id -> deploy());                ❶
      delay = delay + 1000;
    }
  }

  private void deploy() {
    vertx.deployVerticle(new EmptyVerticle(), ar -> {       ❷
      if (ar.succeeded()) {
        String id = ar.result();
        logger.info("Successfully deployed {}", id);
        vertx.setTimer(5000, tid -> undeployLater(id));     ❸
      } else {
        logger.error("Error while deploying", ar.cause());
      }
    });
  }

  private void undeployLater(String id) {
    vertx.undeploy(id, ar -> {                              ❹
      if (ar.succeeded()) {
        logger.info("{} was undeployed", id);
      } else {
        logger.error("{} could not be undeployed", id);
      }
    });
  }
}

❶ 我们每秒部署一个新的 EmptyVerticle 实例。

❷ 部署垂直结构是一个异步操作,deploy 方法有一个支持异步结果的变体。

❸ 五秒后我们将卸载一个垂直结构。

❹ 卸载与部署非常相似。

列表 2.9 定义了一个垂直结构,它从 2.8 部署了EmptyVerticle类的 50 个实例。定时器的使用使我们能够将每个部署分开一秒。deploy方法使用另一个定时器在垂直结构部署五秒后卸载。部署为垂直结构分配一个唯一的标识符字符串,以后可以用于卸载。

列表 2.10 用于部署Deployer垂直结构的主体类

public static void main(String[] args) {
  Vertx vertx = Vertx.vertx();
  vertx.deployVerticle(new Deployer());
}

最后但同样重要的是,Deployer垂直结构本身可以从main方法和类中部署,如列表 2.10 所示。运行此示例会产生如下列表中的日志条目。

列表 2.11 运行列表 2.10 中代码的日志摘录

INFO [vert.x-eventloop-thread-1] EmptyVerticle - Start
INFO [vert.x-eventloop-thread-0] Deployer - Successfully deployed 
➥ 05553394-b6ce-4f47-9076-2c6648d65329                            ❶
INFO [vert.x-eventloop-thread-2] EmptyVerticle - Start
INFO [vert.x-eventloop-thread-0] Deployer - Successfully deployed 
➥ 6d920f33-f317-4964-992f-e712185fe514
(...)
INFO [vert.x-eventloop-thread-0] Deployer - 
➥ 8153abb7-fc64-496e-8155-75c27a93b56d was undeployed             ❷
INFO [vert.x-eventloop-thread-13] EmptyVerticle - Start
INFO [vert.x-eventloop-thread-0] Deployer - Successfully deployed 
➥ 0f69ccd8-1344-4b70-8245-020a4815cc96
(...)

❶ 已部署一个垂直结构。

❷ 已卸载一个垂直结构。

您可以从vert.x-eventloop-thread-0线程中看到日志条目;它们对应于Deployer垂直结构。然后您可以看到来自EmptyVerticle实例的生命周期日志事件;它们使用其他事件循环线程。

有趣的是,我们从Deployer部署了 50 个垂直结构,但日志中出现的垂直结构可能比线程少。默认情况下,Vert.x 创建的事件循环线程数是 CPU 核心数的两倍。如果您有 8 个核心,那么 Vert.x 应用程序有 16 个事件循环。垂直结构到事件循环的分配是循环分配的。

这给我们一个有趣的教训:虽然垂直结构始终使用相同的事件循环线程,但事件循环线程被多个垂直结构共享。这种设计导致运行应用程序的线程数量可预测。

小贴士:可以调整应有多少个事件循环可用,但无法手动将给定的垂直结构分配到特定的事件循环。在实际情况中,这不应该成为问题,但最坏的情况下,您总是可以计划垂直结构的部署顺序。

2.2.4 传递配置数据

应用程序代码通常需要配置数据。一个很好的例子是连接到数据库服务器的代码:它通常需要一个主机名、TCP 端口、登录名和密码。由于这些值从一个部署配置到另一个部署配置会有所不同,因此这种配置需要从配置API 中访问。

当 Vert.x 节点被部署时,可以传递此类配置数据。你将在本书的后面看到,可以使用一些更高级的配置形式,但 Vert.x 核心 API 已经提供了一种非常有用的通用 API。

配置需要以 JSON 数据的形式传递,使用 io.vertx.core.json 包中的 JsonObjectJsonArray 类实现的 Vert.x JSON API。

列表 2.12 将配置数据传递给节点

public class SampleVerticle extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(SampleVerticle.class);

  @Override
  public void start() {
    logger.info("n = {}", config().getInteger("n", -1));           ❶
  }

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();
    for (int n = 0; n < 4; n++) {
      JsonObject conf = new JsonObject().put("n", n);              ❷
      DeploymentOptions opts = new DeploymentOptions()
        .setConfig(conf)                                           ❸
        .setInstances(n);                                          ❹
      vertx.deployVerticle("chapter2.opts.SampleVerticle", opts);  ❺
    }
  }
}

config() 返回 JsonObject 配置实例,访问器方法支持可选的默认值。在这里,如果 JSON 对象中没有“n”键,则返回 -1。

❷ 我们创建一个 JSON 对象,并为“n”键放入一个整数值。

❸ 部署选项允许对节点有更多的控制,包括传递配置数据。

❹ 我们可以一次性部署多个实例。

❺ 由于我们部署了多个实例,我们需要使用节点的完全限定类名(FQCN)来指向节点,而不是使用 new 操作符。对于仅部署一个实例,你可以选择使用 new 创建的实例或使用 FQCN。

列表 2.12 展示了部署多个节点并传递配置数据的示例。运行示例会给出列表 2.13 中的输出,你可以检查配置数据的不同值。

列表 2.13 当在列表 2.12 中运行代码时的示例执行输出

INFO [vert.x-eventloop-thread-2] SampleVerticle - n = 2
INFO [vert.x-eventloop-thread-5] SampleVerticle - n = 3
INFO [vert.x-eventloop-thread-4] SampleVerticle - n = 3
INFO [vert.x-eventloop-thread-1] SampleVerticle - n = 2
INFO [vert.x-eventloop-thread-3] SampleVerticle - n = 3
INFO [vert.x-eventloop-thread-0] SampleVerticle - n = 1

2.3 当代码需要阻塞时

在事件循环上运行代码的基本规则是,它不应该阻塞,并且应该运行“足够快”。你之前已经看到,默认情况下,Vert.x 会检测并警告事件循环被阻塞得太久。

必然会有一些情况,你很难避免阻塞代码。这可能是因为你正在使用具有另一种线程模型的第三方库,例如某些网络服务的驱动程序。Vert.x 提供了两种处理此类情况的方法:工作节点和 executeBlocking 操作。

2.3.1 工作节点

工作节点(Worker Verticles)是一种特殊的节点形式,它们不在事件循环上执行。相反,它们在工作线程上执行,即从特殊的工作线程池中取出的线程。你可以定义自己的工作线程池并将工作节点部署到它们中,但在大多数情况下,使用默认的 Vert.x 工作线程池就足够了。

工作节点处理事件的方式与事件循环节点相同,只不过它可能需要任意长的时间来完成。理解以下两点很重要:

  • 工作节点不绑定到单个工作线程,因此与事件循环节点不同,连续的事件可能不会在同一个线程上执行。

  • 工作节点在同一时间只能由单个工作线程访问。

简单来说,就像事件循环节点一样,工作节点是单线程的,但与事件循环节点不同的是,线程可能并不总是相同的。

列表 2.14 一个示例工作节点

public class WorkerVerticle extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(WorkerVerticle.class);

  @Override
  public void start() {
    vertx.setPeriodic(10_000, id -> {
      try {
        logger.info("Zzz...");
        Thread.sleep(8000);                               ❶
        logger.info("Up!");
      } catch (InterruptedException e) {
        logger.error("Woops", e);
      }
    });
  }

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();
    DeploymentOptions opts = new DeploymentOptions()
      .setInstances(2)
      .setWorker(true);                                  ❷
    vertx.deployVerticle("chapter2.worker.WorkerVerticle", opts);
  }
}

❶ 我们可以阻塞而不会收到警告!

❷ 创建一个工作垂直是一个部署选项标志。

列表 2.14 展示了这样一个例子:一个工作垂直部署了两个实例。每隔 10 秒,代码会阻塞 8 秒。运行此示例会产生类似于列表 2.15 的输出。正如您所看到的,不同的工作线程被用于连续的事件。

列表 2.15 运行列表 2.14 的示例输出

INFO [vert.x-worker-thread-2] WorkerVerticle - Zzz...
INFO [vert.x-worker-thread-3] WorkerVerticle - Zzz...
INFO [vert.x-worker-thread-3] WorkerVerticle - Up!
INFO [vert.x-worker-thread-2] WorkerVerticle - Up!
INFO [vert.x-worker-thread-5] WorkerVerticle - Zzz...
INFO [vert.x-worker-thread-4] WorkerVerticle - Zzz...
INFO [vert.x-worker-thread-4] WorkerVerticle - Up!
INFO [vert.x-worker-thread-5] WorkerVerticle - Up!
(...)

警告 当部署垂直时,有一个选项可以启用工作垂直的多线程,这允许垂直同时处理多个事件,打破了单线程处理的假设。这始终被认为是一种相当高级的使用方式,许多用户最终以错误的方式使用它并捕获并发错误。该功能不再公开文档化,甚至可能在未来的 Vert.x 版本中消失。鼓励用户简单地调整工作池大小以匹配工作负载,而不是启用工作多线程。

2.3.2 executeBlocking操作

工作垂直是运行阻塞任务的一个合理选项,但并不总是有意义将阻塞代码提取到工作垂直中。这样做可能会导致执行小任务的工人垂直类数量激增,而且每个类可能不是一个合理的独立功能单元。

运行阻塞代码的另一种选项是使用Vertx类的executeBlocking方法。此方法接受一些要执行的阻塞代码,将其卸载到工作线程,并将结果作为新事件发送回事件循环,如图 2.4 所示。

图 2.4 executeBlocking调用中的交互

下面的列表展示了其示例用法。

列表 2.16 使用executeBlocking

public class Offload extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(Offload.class);

  @Override
  public void start() {
    vertx.setPeriodic(5000, id -> {
      logger.info("Tick");
      vertx.executeBlocking(this::blockingCode, this::resultHandler);   ❶
    });
  }

  private void blockingCode(Promise<String> promise) {                  ❷
    logger.info("Blocking code running");
    try {
      Thread.sleep(4000);
      logger.info("Done!");
      promise.complete("Ok!");                                          ❸
    } catch (InterruptedException e) {
      promise.fail(e);
    }
  }

  private void resultHandler(AsyncResult<String> ar) {                  ❹
    if (ar.succeeded()) {
      logger.info("Blocking code result: {}", ar.result());
    } else {
      logger.error("Woops", ar.cause());
    }
  }
}

executeBlocking接受两个参数:要运行的代码和运行后的回调。

❷ 阻塞代码接受任何类型的 Promise 对象。它最终用于传递结果。

❸ Promise 对象需要完成或失败,以标记阻塞代码执行的结束。

❹ 在事件循环上处理结果只是另一个异步结果。

下面的列表展示了运行列表 2.16 中代码的一些示例输出。正如您所看到的,执行被卸载到工作线程,但结果处理仍然发生在事件循环上。

列表 2.17 运行列表 2.16 时的示例输出

INFO [vert.x-eventloop-thread-0] Offload - Tick
INFO [vert.x-worker-thread-0] Offload - Blocking code running
INFO [vert.x-worker-thread-0] Offload - Done!
INFO [vert.x-eventloop-thread-0] Offload - Blocking code result: Ok!
INFO [vert.x-eventloop-thread-0] Offload - Tick
INFO [vert.x-worker-thread-1] Offload - Blocking code running
INFO [vert.x-worker-thread-1] Offload - Done!
INFO [vert.x-eventloop-thread-0] Offload - Blocking code result: Ok!
INFO [vert.x-eventloop-thread-0] Offload - Tick
INFO [vert.x-worker-thread-2] Offload - Blocking code running
(...)

提示 默认情况下,连续的executeBlocking操作的结果会按照executeBlocking调用的顺序进行处理。executeBlocking有一个带有额外boolean参数的变体,当它设置为false时,结果一旦可用就会作为事件循环事件提供,而不考虑executeBlocking调用的顺序。

2.4 那么,垂直中到底有什么?

到目前为止,你已经看到了如何编写垂直,如何部署和配置它们,以及如何处理阻塞代码。通过在示例中使用信息日志,你已经看到了 Vert.x 线程模型中的元素。

现在是一个很好的时机来回顾一下垂直内部的结构,并确保你在离开这一章时对垂直的工作原理和如何正确使用它们有一个全面的理解。

2.4.1 垂直和它们的环境

图 2.5 给出了垂直及其环境之间关系的一个概述。

垂直对象本质上是由两个对象的组合:

  • 垂直所属的 Vert.x 实例

  • 一个专门上下文实例,允许事件被调度到处理器

图 2.5 事件循环垂直及其环境

Vert.x 实例公开了声明事件处理器的核心 API。我们已经在之前的代码示例中使用了它,例如 setTimersetPeriodiccreateHttpServerdeployVerticle 等方法。Vert.x 实例被多个垂直共享,并且通常每个 JVM 进程只有一个 Vertx 实例。

上下文实例持有执行处理器的线程访问权限。事件可能来自各种来源,如计时器、数据库驱动程序、HTTP 服务器等。因此,它们通常是从其他线程触发的,例如 Netty 接受线程或计时器线程。

用户定义回调中的事件处理通过上下文进行。上下文实例允许我们在垂直事件循环线程上调用处理器,从而尊重 Vert.x 线程模型。

工作垂直的情况并没有太大的不同,除了处理器是在工作线程池中的一个工作线程上执行的,如图 2.6 所示。它们仍然是垂直,就像它们的事件循环对应物一样,代码可以假设单线程访问。只是没有稳定性来决定哪个工作线程将被用于处理工作垂直的事件。

图 2.6 一个工作垂直和它的环境

2.4.2 更多关于上下文的内容

可以使用 Vertx 类中的 getOrCreateContext() 方法访问上下文对象。虽然上下文几乎总是与垂直相关联,但可以在垂直之外创建事件循环上下文。正如方法名所暗示的

  • 从类似于垂直的上下文线程中调用 getOrCreateContext() 返回上下文。

  • 从非上下文线程中调用 getOrCreateContext() 会创建一个新的上下文。

列表 2.18 展示了一个示例,其中创建了一个全局的 Vertx 实例,并在 JVM 进程的主线程上对 getOrCreateContext 进行了两次调用。每次调用之后都跟着一个对 runOnContext 的调用,这允许我们在上下文线程上运行一段代码块。

列表 2.18 创建无垂直方向的上下文

Vertx vertx = Vertx.vertx();

vertx.getOrCreateContext()
  .runOnContext(v -> logger.info("ABC"));    ❶

vertx.getOrCreateContext()
  .runOnContext(v -> logger.info("123"));

❷ Lambda 在 Vert.x 上下文线程上执行。

正如你在下一个列表中可以看到的,每个上下文都被分配到一个事件循环。

列表 2.19 运行列表 2.18 的示例输出

INFO [vert.x-eventloop-thread-1] ThreadsAndContexts - 123
INFO [vert.x-eventloop-thread-0] ThreadsAndContexts - ABC

上下文对象支持更多操作,例如持有上下文范围内的任意键/值数据并声明异常处理程序。以下列表展示了这样一个示例,其中 foo 键持有字符串 bar,并声明了一个异常处理程序来捕获和处理在事件循环线程上执行处理程序时的异常。

列表 2.20 使用上下文数据和异常处理

Vertx vertx = Vertx.vertx();
Context ctx = vertx.getOrCreateContext();
ctx.put("foo", "bar");

ctx.exceptionHandler(t -> {
  if ("Tada".equals(t.getMessage())) {
    logger.info("Got a _Tada_ exception");
  } else {
    logger.error("Woops", t);
  }
});

ctx.runOnContext(v -> {
  throw new RuntimeException("Tada");
});

ctx.runOnContext(v -> {
  logger.info("foo = {}", (String) ctx.get("foo"));
});

当事件处理分散在多个类中时,上下文数据可能很有用。否则,使用类字段会更简单(并且更快!)。

当事件处理可能抛出异常时,异常处理程序很重要。默认情况下,异常只是由 Vert.x 记录,但在执行自定义操作以处理错误时,覆盖上下文异常处理程序是有用的。

列表 2.21 运行列表 2.20 的示例输出

INFO [vert.x-eventloop-thread-0] ThreadsAndContexts - Got a _Tada_ exception
INFO [vert.x-eventloop-thread-0] ThreadsAndContexts - foo = bar

运行列表 2.20 中的代码会产生与列表 2.21 相似的输出。

2.4.3 桥接 Vert.x 和非-Vert.x 线程模型

在编写 Vert.x 应用程序时,你可能不需要处理 Vert.x 上下文。然而,有一种情况是最有意义的:当你必须使用具有自己线程模型的第三方代码,并且你想让它与 Vert.x 正确工作。

下一个列表中的代码示例展示了如何创建一个非-Vert.x 线程。通过传递从垂直结构中获取的上下文,我们能够从运行在非-Vert.x 线程上的代码中执行一些代码回到事件循环。

列表 2.22 混合不同的线程模型

public class MixedThreading extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(MixedThreading.class);

  @Override
  public void start() {
    Context context = vertx.getOrCreateContext();     ❶
    new Thread(() -> {
      try {
        run(context);
      } catch (InterruptedException e) {
        logger.error("Woops", e);
      }
    }).start();                                       ❷
  }

  private void run(Context context) throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(1);
    logger.info("I am in a non-Vert.x thread");
    context.runOnContext(v -> {                       ❸
      logger.info("I am on the event-loop");
      vertx.setTimer(1000, id -> {
        logger.info("This is the final countdown");
        latch.countDown();
      });
    });
    logger.info("Waiting on the countdown latch...");
    latch.await();
    logger.info("Bye!");
  }
}

❶ 我们获取垂直结构的上下文,因为 start 是在事件循环线程上运行的。

❷ 我们启动了一个普通的 Java 线程。

runOnContext 确保我们在垂直结构事件循环线程上运行一些代码。

下面的列表中的日志显示了这一点。

列表 2.23 运行列表 2.22 的示例输出

INFO [Thread-3] MixedThreading - I am in a non-Vert.x thread
INFO [Thread-3] MixedThreading - Waiting on the countdown latch...
INFO [vert.x-eventloop-thread-0] MixedThreading - I am on the event-loop
INFO [vert.x-eventloop-thread-0] MixedThreading - This is the final countdown
INFO [Thread-3] MixedThreading - Bye!

当你需要将非-Vert.x 线程模型集成到你的应用程序中时,可以使用具有垂直结构上下文并发出 runOnContext 调用的技术。

小贴士:此示例展示了上下文的另一个重要属性:在定义处理程序时它们会被传播。确实,使用 runOnContext 运行的代码块在 1 秒后设置了一个定时器处理程序。你可以看到,处理程序是使用定义它的同一个上下文执行的。

下一章将讨论事件总线,这是垂直结构之间相互通信的特权方式,并在 Vert.x 应用程序中阐述事件处理。

摘要

  • 垂直结构是 Vert.x 应用程序中异步事件处理的核心组件。

  • 事件循环垂直结构处理异步 I/O 事件,应该避免阻塞和长时间运行的操作。

  • 工作垂直结构可以用来处理阻塞 I/O 和长时间运行的操作。

  • 通过使用事件循环上下文,可以混合使用 Vert.x 和非-Vert.x 线程的代码。


  1. 关于演员并发模型,更多内容请参阅 Carl Hewitt、Peter Bishop 和 Richard Steiger 在 1973 年发表的文章,该文章介绍了该模型:“用于人工智能的通用模块化 ACTOR 形式化”,载于第 3 届国际人工智能联合会议(IJCAI’73)论文集,第 235-245 页(Morgan Kaufmann,1973 年)。

  2. 介绍了承诺和未来概念的文章是 B. Liskov 和 L. Shrira 的“Promises:在分布式系统中对高效异步过程调用的语言支持”,载于 R.L. Wexelblat 编著的 ACM SIGPLAN 1988 年程序设计语言设计和实现会议(PLDI’88)论文集,第 260-267 页(ACM,1988 年)。

3 事件总线:Vert.x 应用程序的骨干

本章涵盖的内容

  • 事件总线是什么

  • 如何在事件总线上进行点对点、请求/响应和发布/订阅通信

  • 用于网络中 verticle 到 verticle 通信的分布式事件总线

上一章介绍了verticles。Vert.x 应用程序由一个或多个 verticles 组成,每个 verticle 形成一个处理异步事件的单元。根据功能和技术的关注点专门化 verticles 是很常见的,例如,有一个 verticle 用于公开 HTTP API,另一个用于处理数据存储。这种设计也鼓励为了可扩展性目的部署给定 verticle 的多个实例。

我们尚未涵盖的是 verticles 如何相互通信。例如,如果更大的 Vert.x 应用程序要做任何有用的事情,HTTP API verticle 需要与数据存储 verticle“交谈”。

连接各个节点并确保它们可以协作是事件总线的作用。这在构建响应式应用程序时非常重要——事件总线提供了一种在进程内部以及跨多个节点通过网络透明地分配事件处理工作的方式。

3.1 什么是事件总线?

事件总线是一种以异步方式发送和接收消息的手段。消息被发送到并从目的地检索。目的地只是一个自由形式的字符串,例如incoming.purchase.ordersincoming-purchase-orders,尽管前者带有点号的格式更受欢迎。

消息有一个主体,可选的头部用于存储元数据,以及一个过期时间戳,在此之后如果尚未处理,它们将被丢弃。

消息主体通常使用 Vert.x JSON 表示法进行编码。使用 JSON 的优势在于它是一种易于通过网络传输的序列化格式,所有编程语言都理解它。还可能使用 Java 原始类型和字符串类型,特别是当用于编写 verticles 的 JVM 语言有直接绑定时。最后但同样重要的是,可以注册自定义编码器/解码器(codec)以支持更专业的消息主体序列化形式。例如,你可以编写一个将 Java 对象转换为自定义二进制编码的 codec。然而,这样做很少有用,JSON 和字符串数据覆盖了大多数 Vert.x 应用程序的需求。

事件总线允许在 verticles 之间解耦。不需要一个 verticle 访问另一个 verticle 类——所需的一切只是就目的地名称和数据表示达成一致。另一个好处是,由于 Vert.x 是多语言的,事件总线允许用不同语言编写的 verticles 之间进行通信,而无需任何复杂的语言互操作性层,无论是同一 JVM 进程内的通信还是跨网络的通信。

事件总线的一个有趣特性是它可以扩展到应用程序进程之外。你将在本章中看到事件总线也可以在集群的分布式成员之间工作。在本书的后面部分,你将看到如何将事件总线扩展到嵌入式或外部消息代理、远程客户端,以及运行在网页浏览器中的 JavaScript 应用程序。

通过事件总线进行的通信遵循三种模式:

  • 点对点消息

  • 请求/响应消息

  • 发布/订阅消息

3.1.1 事件总线仅仅是另一个消息代理吗?

熟悉面向消息中间件的读者会发现事件总线与消息代理之间有明显的相似之处。毕竟,事件总线表现出熟悉的消息模式,如发布/订阅模式,这种模式在集成分布式和异构应用程序中很受欢迎。

简短的回答是,不,Vert.x 事件总线不是 Apache ActiveMQ、RabbitMQ、ZeroMQ 或 Apache Kafka 的替代品。更长的解释是,它是一个应用于应用程序内部 verticle 到 verticle 通信的事件总线,而不是应用于应用程序到应用程序通信的消息总线。正如你将在本书后面看到的那样,Vert.x 与消息代理集成,但事件总线不能替代此类中间件。具体来说,事件总线不执行以下操作:

  • 支持消息确认

  • 支持消息优先级

  • 支持消息持久性以从崩溃中恢复

  • 提供路由规则

  • 提供转换规则(模式适应、分散/收集等)

事件总线简单地携带易失性事件,这些事件由 verticles 异步处理。

并非所有事件都是平等的,有些可能会丢失,而有些则不会。在我们编写反应式应用程序的过程中,你将看到在哪里使用数据复制或与事件总线结合使用消息代理,如 Apache Kafka。1

事件总线是一个简单且快速的事件传输器,我们可以利用它进行大多数 verticle 到 verticle 的交互,而对于不能丢失的事件,则转向更昂贵的中间件。

对于熟悉消息模式的读者,可能希望快速浏览下一三个小节,甚至跳过它们。

3.1.2 点对点消息

消息由生产者发送到目的地,例如图 3.1 中的a.b.c。目的地名称是自由形式的字符串,但在 Vert.x 社区中,惯例是使用分隔点。例如,我们可以使用datastore.new-purchase-orders将新的采购订单发送到数据库中存储。

在点对点消息中,一个可能的多消费者之一选择一条消息并处理它。图 3.1 展示了消息M1M2M3

图片

图 3.1 通过事件总线进行点对点消息

消息以轮询方式在消费者之间分发,因此它们以相等的比例分割消息处理。这就是为什么在图 3.1 中,第一个消费者处理M1M3,而第二个消费者处理M2。请注意,没有公平机制来将更少的消息分配给过载的消费者。

3.1.3 请求-回复消息

在 Vert.x 中,请求-回复消息通信模式是点对点消息的一种变体。在点对点消息中发送消息时,可以注册一个回复处理程序。当你这样做时,事件总线生成一个临时目的地名称,该名称仅用于期望得到回复的请求消息生产者和最终将接收并处理消息的消费者之间的通信。

这种消息模式非常适合模拟远程过程调用,但由于响应是以异步方式发送的,因此不需要一直等待它返回。例如,一个 HTTP API verticle 可以向数据存储 verticle 发送请求以获取一些数据,而数据存储 verticle 最终返回一个回复消息。

这种模式在图 3.2 中得到了说明。当一条消息期望得到回复时,事件总线生成一个回复目的地,并在消息到达消费者之前将其附加到消息上。如果你想检查回复目的地名称,可以通过事件总线消息 API 进行,但你很少需要知道目的地,因为你只需在消息对象上调用一个reply方法。当然,消息消费者需要编程以在应用此模式时提供回复。

图 3.2 通过事件总线进行请求-回复消息

3.1.4 发布/订阅消息

在发布/订阅通信中,生产者和消费者之间的解耦程度更高。当一条消息发送到目的地时,所有订阅者都会收到它,如图 3.3 所示。消息M1M2M3分别由不同的生产者发送,所有订阅者都会收到这些消息,这与点对点消息的情况不同(见图 3.1)。在事件总线上,无法为发布/订阅通信指定回复处理程序。

图 3.3 通过事件总线进行发布/订阅消息

当你不确定有多少 verticle 和处理器会对特定事件感兴趣时,发布/订阅非常有用。如果你需要消息消费者返回到发送事件的实体,请选择请求-回复。否则,选择点对点与发布/订阅是功能需求的问题,主要是所有消费者是否应该处理一个事件,或者只有一个消费者应该处理。

3.2 示例中的事件总线

让我们使用事件总线并看看我们如何在独立的垂直结构之间进行通信。我们将使用的示例涉及几个温度传感器。当然,我们不会使用任何硬件。相反,我们将让温度通过伪随机数演变。我们还将提供一个简单的网络界面,其中温度及其平均值将实时更新。

图 3.4 网络界面截图

网络界面的截图如图 3.4 所示。它显示了四个传感器的温度,并保持它们的平均值最新。网络界面和服务器之间的通信将使用 server-sent events,这是一种简单而有效的协议,大多数网络浏览器都支持。2

图 3.5 示例架构概述

图 3.5 展示了应用架构的概述。图中显示了两个带有排序序列 [1, 2, 3](正在发送温度更新)和 [a, b, c, d](请求温度平均值计算)的并发事件通信。

应用程序围绕四个垂直结构构建:

  • HeatSensor 以非固定速率生成温度测量值,并将它们发布到订阅 sensor.updates 目的地的订阅者。每个垂直结构都有一个唯一的传感器标识符。

  • Listener 监控新的温度测量值并使用 SLF4J 记录它们。

  • SensorData 记录每个传感器的最新观测值。它还支持请求-响应通信:向 sensor.average 发送消息将触发基于最新数据的平均值计算,并将结果作为响应发送。

  • HttpServer 提供 HTTP 服务器并服务于网络界面。每当观察到新的温度测量值时,它都会将其推送到客户端,并定期请求当前平均值并更新所有已连接的客户端。

3.2.1 温度传感器垂直结构

以下列表显示了 HeatSensor 垂直结构类的实现。

列表 3.1 Heatsensor 垂直实现

public class HeatSensor extends AbstractVerticle {
  private final Random random = new Random();
  private final String sensorId = UUID.randomUUID().toString();   ❶
  private double temperature = 21.0;

  @Override
  public void start() {
    scheduleNextUpdate();
  }

  private void scheduleNextUpdate() {
    vertx.setTimer(random.nextInt(5000) + 1000, this::update);    ❷
  }

  private void update(long timerId) {
    temperature = temperature + (delta() / 10);
    JsonObject payload = new JsonObject()
      .put("id", sensorId)
      .put("temp", temperature);
    vertx.eventBus().publish("sensor.updates", payload);          ❸
    scheduleNextUpdate();                                         ❹
  }

  private double delta() {                                        ❺
    if (random.nextInt() > 0) {
      return random.nextGaussian();
    } else {
      return -random.nextGaussian();
    }
  }
}

❶ 传感器标识符使用 UUID 生成。

❷ 更新安排在 1 到 6 秒之间的随机延迟。

❸ 发布向订阅者发送消息。

❹ 我们安排下一次更新。

❺ 这计算一个随机正数或负数以略微修改当前温度。

HeatSensor 垂直结构类不使用任何真实的温度模型,而是使用随机增减。因此,如果你运行足够长时间,它可能会报告荒谬的值,但这在我们通过反应式应用程序的旅程中并不重要。

通过 Vertx 上下文和 eventBus() 方法访问事件总线。由于这个垂直结构不知道发布的值将用于什么,我们使用 publish 方法将它们发送到 sensor.updates 目的地的订阅者。我们还使用 JSON 编码数据,这是与 Vert.x 一致的。

现在我们来看一个消费温度更新的竖直类。

3.2.2 监听竖直类

下面的列表展示了 Listener 竖直类的实现。

列表 3.2 Listener 竖直类实现

public class Listener extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(Listener.class);
  private final DecimalFormat format = new DecimalFormat("#.##");        ❶

  @Override
  public void start() {
    EventBus bus = vertx.eventBus();
    bus.<JsonObject>consumer("sensor.updates", msg -> {                  ❷
      JsonObject body = msg.body();                                      ❸
      String id = body.getString("id");
      String temperature = format.format(body.getDouble("temp"));
      logger.info("{} reports a temperature ~{}C", id, temp);            ❹
    });
  }
}

❶ 我们不需要完整的双精度值,所以我们将所有温度格式化为两位小数的字符串表示。

consumer 方法允许订阅消息,一个回调处理所有事件总线消息。

❸ 消息负载在主体中。

❹ 我们只是记录日志。

Listener 竖直类的作用是记录所有温度测量值,所以它所做的只是监听在 sensor.updates 目标上接收到的消息。由于 HeatSensor 类中的发射器使用发布/订阅模式,Listener 不仅仅是唯一可以接收消息的竖直类。

在这个例子中,我们没有利用消息头,但可以使用它们来处理不属于消息体的任何元数据。一个常见的头是“操作”,以帮助接收者了解消息的内容。例如,给定 database.operations 目标,我们可以使用操作头来指定我们是否打算查询数据库、更新条目、存储新条目或删除之前存储的条目。

现在我们来看另一个消费温度更新的竖直类。

3.2.3 传感器数据竖直类

下面的列表展示了 SensorData 竖直类的实现。

列表 3.3 Sensordata 竖直类实现

public class SensorData extends AbstractVerticle {
  private final HashMap<String, Double> lastValues = new HashMap<>();   ❶

  @Override
  public void start() {                                                 ❷
    EventBus bus = vertx.eventBus();
    bus.consumer("sensor.updates", this::update);
    bus.consumer("sensor.average", this::average);
  }

  private void update(Message<JsonObject> message) {                    ❸
    JsonObject json = message.body();
    lastValues.put(json.getString("id"), json.getDouble("temp"));
  }

  private void average(Message<JsonObject> message) {                   ❹
    double avg = lastValues.values().stream()
      .collect(Collectors.averagingDouble(Double::doubleValue));
    JsonObject json = new JsonObject().put("average", avg);
    message.reply(json);                                                ❺
  }
}

❶ 我们通过每个传感器的唯一标识符存储其最新的测量值。

start 方法只声明了两个事件总线目标处理器。

❸ 当接收到新的测量值时,我们从 JSON 主体中提取数据。

❹ 对于平均请求的传入消息没有使用,所以它可以只包含一个空的 JSON 文档。

reply 方法用于回复消息。

SensorData 类有两个事件总线处理器:一个用于传感器更新,另一个用于平均温度计算请求。在一种情况下,它更新 HashMap 中的条目,在另一种情况下,它计算平均值并回复消息发送者。

下一个竖直类是 HTTP 服务器。

3.2.4 HTTP 服务器竖直类

HTTP 服务器很有趣,因为它通过事件总线从 SensorData 竖直类请求温度平均值,并实现了服务器发送事件协议来消费温度更新。

让我们从这个竖直类实现的骨干开始。

服务器实现

下面的列表展示了一个启动 HTTP 服务器并声明请求处理器的经典例子。

列表 3.4 HTTP 服务器竖直类实现的序言

public class HttpServer extends AbstractVerticle {
  @Override
  public void start() {
    vertx.createHttpServer()
      .requestHandler(this::handler)
      .listen(config().getInteger("port", 8080));        ❶
  }

  private void handler(HttpServerRequest request) {
    if ("/".equals(request.path())) {
      request.response().sendFile("index.html");         ❷
    } else if ("/sse".equals(request.path())) {
      sse(request);                                      ❸
    } else {
      request.response().setStatusCode(404);             ❹
    }
  }
// (...)
}

❶ HTTP 服务器端口默认配置为 8080。

sendFile 方法允许将任何本地文件的内容流式传输到客户端。这会自动关闭连接。

❸ 服务器发送事件将使用 /sse 资源,我们提供了一个处理这些请求的方法。

❹ 任何其他触发都会返回 HTTP 404(未找到)响应。

处理器处理三种情况:

  • 向浏览器提供 Web 应用程序

  • 为服务器发送事件提供资源

  • 对于任何其他资源路径返回 404 错误

提示:手动根据请求的资源路径和 HTTP 方法分发自定义操作是繁琐的。正如你稍后将会看到的,vertx-web模块提供了一个更友好的路由器API,方便声明处理器。

Web 应用程序

让我们现在看看客户端应用程序,它由 HTTP 服务器提供。Web 应用程序适合在以下列表中显示的单个 HTML 文档中(我移除了不相关的 HTML 部分,如页眉和页脚)。

列表 3.5 Web 应用程序代码

<div id="avg"></div>
<div id="main"></div>
<script language="JavaScript">
  const sse = new EventSource("/sse")                        ❶
  const main = document.getElementById("main")
  const avg = document.getElementById("avg")

  sse.addEventListener("update", (evt) => {                  ❷
    const data = JSON.parse(evt.data)                        ❸
    let div = document.getElementById(data.id);
    if (div === null) {
      div = document.createElement("div")                    ❹
      div.setAttribute("id", data.id)
      main.appendChild(div)
    }
    div.innerHTML = `<strong>${data.temp.toFixed(2)}</strong> 
    ➥ (<em>${data.id}</em>)`                                ❺
  })

  sse.addEventListener("average", (evt) => {                 ❻
    const data = JSON.parse(evt.data)
    avg.innerText = `Average = ${data.average.toFixed(2)}`
  })
</script>

❶ EventSource 对象处理服务器发送事件。

❷ 这个回调监听更新类型的服务器发送事件。

❸ 响应数据是纯文本,由于服务器将发送 JSON,我们需要解析它。

❹ 如果传感器没有用于显示数据的 div,我们创建它。

❺ 这将更新一个温度 div。

❻ 这个回调监听平均类型的服务器发送事件。

前面的列表中的 JavaScript 代码处理服务器发送事件并响应更新显示的内容。我们本可以使用许多流行的 JavaScript 框架之一,但有时回到基础是好的。

注意:你可能已经注意到列表 3.5 使用了现代版本的 JavaScript,有箭头函数、没有分号和字符串模板。这段代码应该在任何最近的 Web 浏览器上正常工作。我在 Mozilla Firefox 63、Safari 12 和 Google Chrome 70 上进行了测试。

支持服务器发送事件

让我们现在关注服务器发送事件的工作原理,以及如何使用 Vert.x 轻松实现它们。

服务器发送事件是一个非常简单但有效的协议,允许服务器向其客户端推送事件。该协议基于文本,每个事件都是一个包含事件类型和一些数据的块:

event: foo
data: bar

每个块事件由一个空行分隔,因此连续的两个事件看起来像这样:

event: foo
data: abc

event: bar
data: 123

使用 Vert.x 实现服务器发送事件非常简单。

列表 3.6 支持服务器发送事件

private void sse(HttpServerRequest request) {
  HttpServerResponse response = request.response();
  response
    .putHeader("Content-Type", "text/event-stream")             ❶
    .putHeader("Cache-Control", "no-cache")                     ❷
    .setChunked(true);

  MessageConsumer<JsonObject> consumer = 
  ➥ vertx.eventBus().consumer("sensor.updates");               ❸
  consumer.handler(msg -> {
    response.write("event: update\n");                          ❹
    response.write("data: " + msg.body().encode() + "\n\n");
  });

  TimeoutStream ticks = vertx.periodicStream(1000);             ❺
  ticks.handler(id -> {
    vertx.eventBus().<JsonObject>request("sensor.average", "", 
    ➥ reply -> {                                               ❻
      if (reply.succeeded()) {
        response.write("event: average\n");
        response.write("data: " + reply.result().body().encode() + "\n\n");
      }
    });
  });

  response.endHandler(v -> {                                    ❼
    consumer.unregister();
    ticks.cancel();
  });
}

❶ 服务器发送事件指定了 text/event-stream MIME 类型。

❷ 由于这是一个实时流,我们需要防止浏览器和代理缓存它。

❸ 我们调用没有处理器的消费者,因为我们需要一个对象在客户端断开连接时取消订阅。

❹ 发送事件块只是发送文本。

❺ 我们每秒更新一次平均值,因此需要一个周期性定时器。由于它需要取消,我们也使用一个没有处理器的形式来获取一个对象。

❻ 请求发送一个期望得到响应的消息。回复是一个异步对象,因为它可能失败了。

❼ 当客户端断开连接(或刷新页面)时,我们需要注销事件总线消息消费者并取消计算平均值的周期性任务。

列表 3.6 提供了处理对 /sse 资源 HTTP 请求的 sse 方法的实现。它为每个温度更新请求声明一个消费者,并推送新事件。它还声明了一个周期性任务,以请求-响应方式查询 SensorData verticle 并维护平均值。

由于这两个处理程序是针对 HTTP 请求的,我们需要能够在连接丢失时停止它们。这可能是由于浏览器标签页被关闭,或者简单地页面重新加载。为此,我们获取 对象,并为每个对象声明一个处理程序,就像我们处理接受回调的表单一样。你将在下一章中看到如何处理流对象,以及它们何时有用。

我们还可以使用命令行工具,如 HTTPie 或 curl,对运行中的应用程序进行操作,以查看事件流,如下所示。

列表 3.7 使用 HTTPie 列出 SSE 事件

$ http http://localhost:8080/sse --stream     ❶
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Type: text/event-stream
Transfer-Encoding: chunked

event: average                                ❷
data: {"average":21.132465880152044}          ❸

event: update
data: {"id":"3fa8321d-7600-42d3-b114-9fb6cdab7ecd","temp":21.043921061475107}

event: update
data: {"id":"8626e13f-9114-4f7d-acc3-bd60b00f3028","temp":21.47111113365458}

event: average
data: {"average":21.123126848463464}

❶ 使用 --stream 标志可以将响应流式传输到控制台,而不是等待服务器结束连接。

❷ 每个事件都有一个类型。

❸ 由于 JSON 只是文本,它作为事件数据传输得很好。

警告:在撰写本文时,除了微软的浏览器外,所有主流网页浏览器都支持服务器发送事件。有一些 JavaScript polyfills 提供了微软浏览器缺失的功能,尽管有一些限制。

3.2.5 启动应用程序

现在我们已经准备好了所有 verticle,我们可以将它们组装成一个 Vert.x 应用程序。以下列表显示了用于启动应用程序的主类。它部署了四个传感器 verticle 和每个其他 verticle 的一个实例。

列表 3.8 用于启动应用程序的主类

public class Main {
  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();
    vertx.deployVerticle("chapter3.HeatSensor", new 
    ➥ DeploymentOptions().setInstances(4));          ❶
    vertx.deployVerticle("chapter3.Listener");        ❷
    vertx.deployVerticle("chapter3.SensorData");
    vertx.deployVerticle("chapter3.HttpServer");
  }
}

❶ 我们启动了四个传感器。

❷ 我们正在使用使用反射实例化 verticle 类的 deployVerticle 的变体。

运行此类的 main 方法允许我们通过网页浏览器连接到 http://localhost:8080/。当你这样做时,你应该看到一个类似于图 3.4 的图形界面,并且有连续的实时更新。控制台日志也将显示温度更新。

3.3 聚类和分布式事件总线

到目前为止,我们使用的事件总线是 本地 的:所有通信都在同一个 JVM 进程内发生。更有趣的是使用 Vert.x 聚类 并从 分布式 事件总线中受益。

3.3.1 Vert.x 中的聚类

Vert.x 应用程序可以在聚类模式下运行,其中一组 Vert.x 应用程序节点可以在网络上协同工作。它们可能是同一应用程序的节点实例,并具有相同的已部署 verticle 集合,但这不是必需的。一些节点可以有一组 verticle,而其他节点可以有不同的集合。

图 3.6 展示了 Vert.x 聚类的概览。一个 集群管理器 确保节点可以通过事件总线交换消息,从而实现以下功能集:

  • 组成员资格和发现允许发现新的节点,维护当前节点的列表,并检测节点何时消失。

  • 共享数据允许在集群范围内维护映射和计数器,以便所有节点共享相同的值。分布式锁对于节点之间的一些协调形式非常有用。

  • 订阅拓扑允许知道每个节点对哪些事件总线目的地感兴趣。这对于在分布式事件总线中高效地分发消息非常有用。如果一个节点在目的地a.b.c上没有消费者,就没有必要从该目的地向该节点发送事件。

基于 Hazelcast、Infinispan、Apache Ignite 和 Apache ZooKeeper,有几个针对 Vert.x 的集群管理器实现。从历史上看,Hazelcast 曾是 Vert.x 的集群管理器,然后添加了其他引擎。它们都支持相同的 Vert.x 集群抽象,包括成员资格、共享数据和事件总线消息传递。它们在功能上是等效的,所以你必须根据你的需求和限制来选择一个。如果你不确定该选择哪一个,我建议选择 Hazelcast,这是一个好的默认选择。

图 3.6 Vert.x 集群概述

图 3.6 Vert.x 集群概述

最后,如图 3.6 所示,节点之间的事件总线通信是通过直接 TCP 连接,使用自定义协议进行的。当一个节点向目的地发送消息时,它会与集群管理器一起检查订阅拓扑,并将消息分发给有该目的地订阅者的节点。

你应该使用哪个集群管理器?

对于“你应该使用哪个集群管理器”这个问题,没有好的答案。这取决于你是否需要与某个库进行特殊集成,以及你需要部署的环境类型。比如说,如果你需要在你的代码中使用 Infinispan API,而不仅仅是将 Infinispan 作为 Vert.x 的集群管理器引擎,你应该选择 Infinispan 来满足这两个需求。

你还应该考虑你的部署环境。如果你部署到使用 Apache ZooKeeper 的环境,也许选择依赖它作为 Vert.x 集群管理器也是一个不错的选择。

默认情况下,一些集群管理器使用多播通信进行节点发现,这在某些网络中可能被禁用,尤其是在像 Kubernetes 这样的容器化环境中。在这种情况下,你需要配置集群管理器以在这些环境中工作。

如前所述,如果有疑问,请选择 Hazelcast,并检查项目文档以获取特定的网络配置信息,例如在部署到 Kubernetes 时。你总是可以在以后切换到另一个集群管理器实现。

3.3.2 从事件总线到分布式事件总线

让我们回到本章前面开发的温度传感器应用程序。迁移到分布式事件总线对 verticles 来说是透明的。

我们将准备两个主类,具有不同的垂直部署,如图 3.7 所示:

  • 四个HeatSensor实例,一个 8080 端口的HttpServer实例

  • 四个HeatSensor实例,一个Listener实例,一个SensorData实例,以及一个 8081 端口的HttpServer实例(这样您就可以在同一主机上运行和测试它)

图片

图 3.7 集群应用程序概述

目标是展示通过在集群模式下启动每种部署的一个实例,verticles 之间的通信就像它们在同一个 JVM 进程中运行一样。通过网页浏览器连接到任一实例将显示八个传感器的相同数据视图。同样,第二个实例上的Listener verticle 将从第一个实例获取温度更新。

我们将使用 Infinispan 作为集群管理器,但您也可以使用另一个。假设您的项目是用 Gradle 构建的,您需要将vertx-infinispan作为依赖项添加:

implementation("io.vertx:vertx-infinispan:version")

下面的列表显示了主类FirstInstance的实现,我们可以使用它来启动一个节点,该节点不部署所有应用程序 verticle。

列表 3.9 第一个实例的主类代码

public class FirstInstance {
  private static final Logger logger = 
  ➥ LoggerFactory.getLogger(FirstInstance.class);

  public static void main(String[] args) {
    Vertx.clusteredVertx(new VertxOptions(), ar -> {     ❶
      if (ar.succeeded()) {
        logger.info("First instance has been started");
        Vertx vertx = ar.result();                       ❷
        vertx.deployVerticle("chapter3.HeatSensor", 
        ➥ new DeploymentOptions().setInstances(4));
        vertx.deployVerticle("chapter3.HttpServer");
      } else {
        logger.error("Could not start", ar.cause());     ❸
      }
    });
  }
}

❶ 启动集群 Vert.x 应用程序是一个异步操作。

❷ 成功后,我们检索 Vertx 实例。

❸ 失败的可能原因是缺少集群管理器库。

如您所见,以集群模式启动应用程序需要调用clusteredVertx方法。其余部分只是经典的 verticle 部署。

第二个实例的主方法代码非常相似,如下所示。

列表 3.10 第二个实例的主类代码

public class SecondInstance {
  private static final Logger logger = 
  ➥ LoggerFactory.getLogger(SecondInstance.class);

  public static void main(String[] args) {
    Vertx.clusteredVertx(new VertxOptions(), ar -> {
      if (ar.succeeded()) {
        logger.info("Second instance has been started");
        Vertx vertx = ar.result();
        vertx.deployVerticle("chapter3.HeatSensor", 
        ➥ new DeploymentOptions().setInstances(4));
        vertx.deployVerticle("chapter3.Listener");
        vertx.deployVerticle("chapter3.SensorData");
        JsonObject conf = new JsonObject().put("port", 8081);     ❶
        vertx.deployVerticle("chapter3.HttpServer", 
        ➥ new DeploymentOptions().setConfig(conf));
      } else {
        logger.error("Could not start", ar.cause());
      }
    });
  }
}

❶ 我们使用不同的端口,以便您可以在同一主机上启动两个实例。

两个主类都可以在同一主机上运行,两个实例将相互发现。像以前一样,您可以从您的 IDE 启动它们,或者在不同的终端中运行gradle run -PmainClass=chapter3.cluster.FirstInstancegradle run -PmainClass= chapter3.cluster.SecondInstance

提示:如果您使用 IPv6 并遇到问题,可以将-Djava.net.preferIPv4Stack=true标志添加到 JVM 参数中。

默认情况下,Vert.x Infinispan 集群管理器配置为使用网络广播进行发现,因此当它们在同一台机器上运行时,两个实例会相互发现。您也可以使用同一网络上的两台机器。

警告:网络广播在云环境中和数据中心很少工作。在这些情况下,集群管理器需要配置为使用其他发现和组成员资格协议。在 Infinispan 的情况下,文档中有具体的细节,请参阅infinispan.org/documentation/

图 3.8 显示了运行在一个浏览器连接到端口 8080 的实例,另一个浏览器连接到端口 8081 的第二个实例的应用程序,我们看到了来自后台Listener verticle 的日志。如图所示,两个实例都显示了来自八个传感器的事件,第一个实例的平均温度已更新,因此它可以与第二个实例上的SensorData verticle 进行交互。

图 3.8

图 3.8 集群模式下应用程序的截图

分布式事件总线是一个有趣的工具,因为它对 verticles 是透明的。

提示:事件总线 API 有localConsumer方法用于声明仅在集群运行时本地工作的消息处理器。例如,针对目的地a.b.c的消费者将不会接收到来自集群中另一个实例发送到该目的地的消息。

下一章讨论异步数据和事件流。

摘要

  • 事件总线是 verticles 通信的首选方式,它使用异步消息传递。

  • 事件总线实现了发布/订阅(一对多)和点对点(多对一)通信。

  • 虽然它看起来像传统的消息代理,但事件总线不提供持久性保证,因此只能用于临时数据。

  • 集群允许网络实例以透明的方式通过分布式事件总线进行通信,并将工作负载扩展到多个应用实例。


1.关于使用 Kafka 的详细讨论,请参阅 Dylan Scott 的《Kafka in Action》(Manning,2019)。

2.W3C 关于服务器发送事件的规范可在www.w3.org/TR/eventsource找到。

4 异步数据和事件流

本章涵盖

  • 为什么流是在事件之上一个有用的抽象

  • 背压是什么,以及为什么它是异步生产者和消费者基本的原因

  • 如何从流中解析协议数据

到目前为止,我们一直在使用回调处理事件,来自各种来源,如 HTTP 或 TCP 服务器。回调允许我们一次处理一个事件。

处理来自 TCP 连接、文件或 HTTP 请求的传入数据缓冲区并不太不同:你需要声明一个回调处理程序,该处理程序响应每个事件并允许自定义处理。

话虽如此,大多数事件需要作为一系列而不是孤立的事件来处理。处理 HTTP 请求的主体是一个很好的例子,因为需要组装不同大小的多个缓冲区来重新构成完整的主体有效负载。

由于反应式应用程序处理非阻塞 I/O,高效且正确的流处理是关键。在本章中,我们将探讨为什么流会带来挑战,以及 Vert.x 如何提供全面的统一流模型。

4.1 统一流模型

Vert.x 提供了一种跨多种资源类型的统一流抽象,例如文件、网络套接字等。读取流是可读取的事件源,而写入流是发送事件的目的地。例如,HTTP 请求是一个读取流,而 HTTP 响应是一个写入流。

Vert.x 中的流涵盖了广泛的来源和目的地,包括表 4.1 中列出的那些。

表 4.1 Vert.x 常见读取和写入流

流资源 读取支持 写入支持
TCP 套接字
UDP 数据报
HTTP 请求和响应
WebSockets
文件
SQL 结果
Kafka 事件
定时器

读取和写入流是通过io.vertx.core.streams包中的ReadStreamWriteStream接口定义的。你将主要处理实现这两个接口的 API,而不是自己实现它们,尽管如果你想要连接到某些第三方异步事件 API,你可能必须这样做。

这些接口可以看作每个都包含两个部分:

  • 读取或写入数据的基本方法

  • 背压管理方法,我们将在下一节中介绍

表 4.2 列出了读取流的基本方法。它们定义了回调,用于通知三种类型的事件:读取了一些数据,发生了异常,以及流已结束。

表 4.2 ReadStream基本方法

方法 描述
handler(Handler<T>) 处理类型为T的新读取值(例如,Bufferbyte[]JsonObject等)
exceptionHandler(Handler<Throwable>) 处理读取异常
endHandler(Handler<Void>) 当流结束时调用,无论是所有数据都已读取还是因为发生了异常

类似地,列表 4.3 中列出的写入流的必要方法允许我们写入数据、结束流并在出现异常时通知我们。

表 4.3 WriteStream 必要方法

方法 描述
write(T) 写入类型为 T 的数据(例如,Bufferbyte[]JsonObject 等)
exceptionHandler(Handler<Throwable>) 处理写入异常
end() 结束流
end(T) 写入类型为 T 的数据,然后结束流

我们在之前的章节中已经操纵了流,而没有意识到这一点,例如使用 TCP 和 HTTP 服务器。

The java.io APIs form a classic stream I/O abstraction for reading and writing data from various sources in Java, albeit using blocking APIs. It is interesting to compare the JDK streams with the Vert.x non-blocking stream APIs.

假设我们想要读取文件的内容并将其输出到标准控制台输出。

列表 4.1 使用 JDK I/O API 读取文件

public static void main(String[] args) {
  File file = new File("build.gradle.kts");
  byte[] buffer = new byte[1024];
  try (FileInputStream in = new FileInputStream(file)) {   ❶
    int count = in.read(buffer);
    while (count != -1) {
      System.out.println(new String(buffer, 0, count));
      count = in.read(buffer);
    }
  } catch (IOException e) {
    e.printStackTrace();
  } finally {
    System.out.println("\n--- DONE");                      ❷
  }
}

❶ 使用 try-with-resources 我们确保无论执行是否正常完成或异常完成,都会调用 reader.close()。

❷ 读取完成后,我们在控制台插入两行。

列表 4.1 展示了使用 JDK I/O 流读取文件并将内容输出到控制台的经典示例,同时注意可能的错误。我们将数据读取到缓冲区中,然后立即将缓冲区内容写入标准控制台,然后再回收缓冲区以进行下一次读取。

以下列表显示了与列表 4.1 相同的代码,但使用 Vert.x 异步文件 API。

列表 4.2 使用 Vert.x 流读取文件

public static void main(String[] args) {
  Vertx vertx = Vertx.vertx();
  OpenOptions opts = new OpenOptions().setRead(true);          ❶
  vertx.fileSystem().open("build.gradle.kts", opts, ar -> {    ❷
    if (ar.succeeded()) {
      AsyncFile file = ar.result();                            ❸
      file.handler(System.out::println)                        ❹
        .exceptionHandler(Throwable::printStackTrace)          ❺
        .endHandler(done -> {                                  ❻
          System.out.println("\n--- DONE");
          vertx.close();
        });
    } else {
      ar.cause().printStackTrace();
    }
  });
}

❶ 使用 Vert.x 打开文件需要选项,例如文件是处于读取、写入、追加模式,等等。

❷ 打开文件是一个异步操作。

❸ AsyncFile 是 Vert.x 异步文件接口。

❹ 新缓冲区数据的回调

❺ 当出现异常时调用的回调

❻ 当流结束时调用的回调

在这种情况下,方法是声明式的,因为我们定义了读取流时不同类型事件的处理器。我们正在被推送数据,而在列表 4.1 中,我们从流中拉取数据。

这种差异乍一看可能只是外观上的,一个例子中数据是被拉入的,而另一个例子中数据是被推入的。然而,这种差异是重大的,我们需要理解它,以便掌握异步流,无论是使用 Vert.x 还是其他解决方案。

这引出了背压的概念。

4.2 什么是背压?

背压是一种机制,允许事件的消费者向事件的生产者**信号它正在以比消费者处理它们更快的速度发出事件。在反应式系统中,背压用于暂停或减慢生产者,以便消费者避免在无界内存缓冲区中积累未处理的事件,从而可能耗尽资源。

要了解为什么背压对异步流很重要,让我们以一个用于下载 Linux 发行版镜像的 HTTP 服务器为例,并考虑没有实施任何背压管理策略的实现。

Linux 发行版镜像通常以.iso 文件的形式分发,很容易达到几个 GB。实现一个能够分发此类文件的服务器将涉及以下操作:

  1. 打开一个 HTTP 服务器。

  2. 对于每个传入的 HTTP 请求,找到相应的文件。

  3. 对于从文件中读取的每个缓冲区,将其写入 HTTP 响应体。

图 4.1 提供了使用 Vert.x 如何工作的说明,尽管这也适用于任何非阻塞 I/O API。数据缓冲区从文件流中读取,然后传递给处理器。处理器不太可能做任何事情,而是直接将每个缓冲区写入 HTTP 响应流。每个缓冲区最终被写入底层的 TCP 缓冲区,要么直接写入,要么作为更小的块。由于 TCP 缓冲区可能已满(要么是因为网络,要么是因为客户端正忙),因此有必要维护一个待写入的缓冲区缓冲区(图 4.1 中的写入队列)。记住,写入操作是非阻塞的,因此需要缓冲。这听起来是一个非常简单的处理管道,那么可能出什么问题呢?

图 4.1

图 4.1 无背压信号的情况下在流之间读取和写入数据

从文件系统中读取通常速度快且低延迟,并且给定几个读取请求,操作系统可能会将一些页面缓存到 RAM 中。相比之下,写入网络要慢得多,带宽取决于最弱的网络链路。延迟也会发生。

由于读取速度远快于写入速度,写入缓冲区,如图 4.1 所示,可能会迅速变得非常大。如果我们有数千个并发连接来下载 ISO 镜像,我们可能在写入缓冲区队列中积累了大量缓冲区。实际上,我们可能在 JVM 进程内存中有几个 GB 的 ISO 镜像,等待通过网络写入!写入队列中的缓冲区越多,进程消耗的内存就越多。

这里的风险显然是耗尽,要么是因为进程消耗了所有可用的物理内存,要么是因为它在内存受限的环境中运行,如容器。这增加了消耗过多内存甚至崩溃的风险。

如您可能猜到的,一个解决方案是背压信号,它使读取流能够适应写入流的吞吐量。在先前的例子中,当 HTTP 响应写入队列变得过大时,它应该能够通知文件读取流它正在运行得太快。在实践中,暂停源流是管理背压的好方法,因为它在不会积累新项目的同时,为写入写入缓冲区中的项目提供了时间。

提示 阻塞 I/O API 通过阻塞执行线程直到 I/O 操作完成来隐式地提供背压。当缓冲区满时,写入操作会阻塞,这阻止了阻塞的线程在写入操作完成之前拉取更多数据。

表 4.4 列出了ReadStream的背压管理方法。默认情况下,读取流尽可能快地读取数据,除非它被暂停。处理器可以暂停并恢复读取流以控制数据流。

表 4.4 ReadStream 背压管理方法

方法 描述
pause() 暂停流,防止进一步的数据被发送到处理器。
resume() 重新开始读取数据并将其发送到处理器。
fetch(n) 请求读取(最多)n个元素。在调用fetch(n)之前必须暂停流。

当读取流被暂停时,可以请求获取一定数量的元素,这是一种异步拉取的形式。这意味着处理器可以使用fetch请求元素,并设置自己的节奏。你将在本章的最后部分看到具体的例子。

在任何情况下,调用resume()都会使流尽可能快地开始推送数据。

表 4.5 显示了WriteStream对应的背压管理方法。

表 4.5 WriteStream 背压管理方法

方法 描述
setWriteQueueMaxSize(int) 定义在被视为满之前写入缓冲队列的最大大小。这是一个要写入的排队 Vert.x 缓冲区的大小,而不是实际字节数,因为排队缓冲区可能具有不同的大小。
boolean writeQueueFull() 指示写入缓冲队列的大小是否已满。
drainHandler(Handler<Void>) 定义一个回调,指示何时写入缓冲队列已排空(通常是在其最大大小的一半时)。

写缓冲队列达到最大大小时被认为是满的。写队列有默认大小,你很少需要调整它们,但如果你想的话可以这样做。注意,仍然可以进行写入,数据将在队列中积累。写入器应该在队列满时进行检查,但对于写入没有强制执行。当写入器知道写入队列已满时,可以通过排水处理器通知数据可以再次写入。通常这发生在写入队列的一半被排空时。

现在你已经看到了ReadStreamWriteStream提供的背压操作,以下是通过 HTTP 提供 ISO 镜像的示例中控制流的方法:

  1. 对于每个读取缓冲区,将其写入 HTTP 响应流。

  2. 检查写入缓冲队列是否已满。

  3. 如果已满

    1. 暂停文件读取流。

    2. 安装一个排水处理器,当调用时恢复文件读取流。

注意,这种后压力管理策略并不总是你所需要的:

  • 可能存在这样的情况,当写入队列满时丢弃数据在功能上是正确的,甚至可能是所希望的。

  • 有时事件源不支持暂停,就像 Vert.x 的 ReadStream 一样,即使这可能导致内存耗尽,你也必须在丢弃数据或缓冲之间做出选择。

处理后压力的适当策略取决于你正在编写的代码的功能需求。一般来说,你将更喜欢 Vert.x 流提供的流控制,但如果没有可能,你需要采用另一种策略。

现在我们将所见的一切组装成一个应用程序。

4.3 制作音乐流式点唱机

我们将通过音乐流式点唱机的例子(见图 4.2)来说明 Vert.x 流和后压力管理。

图 4.2 点唱机应用程序概览

理念是点唱机存储了一些本地的 MP3 文件,客户端可以通过 HTTP 连接来收听流。单个文件也可以通过 HTTP 下载。反过来,播放、暂停和安排歌曲的时间是通过一个简单基于文本的 TCP 协议来控制的。所有连接的播放器都将同时收听相同的音频,除了由于播放器放置的缓冲造成的微小延迟。

此示例将使我们能够看到我们如何处理自定义流速度和不同的后压力管理策略,以及如何解析流。

4.3.1 特性和用法

我们将要构建的应用程序可以通过书籍 GitHub 仓库中的代码使用 Gradle 任务运行,如列表 4.3 的控制台输出所示。

note 如果你想让点唱机有音乐可播放,你需要将一些 MP3 文件复制到项目目录中名为 tracks/的文件夹中。

列表 4.3 运行点唱机应用程序

$ ./gradlew run -PmainClass=chapter4.jukebox.Main                 ❶

> Task :run
[vert.x-eventloop-thread-0] chapter4.jukebox.Jukebox - Start      ❷
[vert.x-eventloop-thread-1] chapter4.jukebox.NetControl - Start

❶ 主类是 chapter4.jukebox.Jukebox。

❷ 我们正在部署两个垂直结构。

在此应用程序中部署了两个垂直结构:

  • 点唱机 提供了主要的音乐流式逻辑和 HTTP 服务器接口,供音乐播放器连接使用。

  • NetControl 提供了一个基于文本的 TCP 协议,用于远程控制点唱机应用程序。

图 4.3 VLC 连接到点唱机

要收听音乐,用户可以连接一个播放器,如 VLC(见图 4.3),或者甚至直接在 http://localhost:8080/ 打开网页浏览器。

另一方面,播放器可以通过像 netcat 这样的工具进行控制,使用纯文本命令列出所有文件、安排播放曲目以及暂停或重新启动流。列表 4.4 显示了使用 netcat 的交互会话。

列表 4.4 使用 netcat 控制点唱机

$ netcat localhost 3000                                     ❶
/list                                                       ❷
Daniela-La-Luz-Did-you-Ever-(Original-Mix).mp3
The-Revenge-Let-Love-Take-The-Blame-(Original-Mix).mp3
intro.mp3
SQL-Surrender-(Original-Mix).mp3
/schedule SQL-Surrender-(Original-Mix).mp3                  ❸
/pause                                                      ❹
/play                                                       ❺
/schedule Daniela-La-Luz-Did-you-Ever-(Original-Mix).mp3    ❻
^C                                                          ❼

❶ 控制 TCP 服务器监听在端口 3000。

❷ 此命令列出所有文件。

❸ 安排将文件添加到播放列表中。

❹ 这将暂停所有连接播放器的流。

❺ 这将恢复流。

❻ 当第一首歌曲结束后,我们安排另一首歌曲。

❼ 我们可以用 Ctrl+C 安全地退出 netcat 会话。

提示 netcat 可能在你的 Unix 环境中作为 nc 提供。我并不知道在 WSL 环境之外,有哪个友好且等效的工具适用于 Windows。

最后,我们希望能够通过 HTTP 下载我们知道的任何 MP3 文件名:

curl -o out.mp3 http://localhost:8080/download/intro.mp3

现在我们来剖析实现的各种部分。

4.3.2 HTTP 处理:整体视图

将会有许多代码片段涉及 HTTP 服务器处理,因此查看图 4.4 以了解接下来的代码片段如何组合在一起是很好的。

图片

图 4.4 HTTP 服务器处理的整体视图

有两种类型的传入 HTTP 请求:客户端要么直接通过名称下载文件,要么想要加入音频流。处理策略非常不同。

在下载文件的情况下,目标是直接从文件读取流复制到 HTTP 响应写入流。这将通过背压管理来完成,以避免过度的缓冲。

流式传输稍微复杂一些,因为我们需要跟踪所有流式传输者的 HTTP 响应写入流。计时器定期从当前 MP3 文件中读取数据,并将数据复制并写入每个流式传输者。

让我们看看这些部分是如何实现的。

4.3.3 Jukebox 垂直基本

下一个列表显示 Jukebox 垂直类的状态是由播放状态和播放列表定义的。

列表 4.5 Jukebox 类的状态

private enum State {PLAYING, PAUSED}

private State currentMode = State.PAUSED;

private final Queue<String> playlist = new ArrayDeque<>();

枚举类型 State 定义了两种状态,而 Queue 保存所有计划播放的下一条曲目。再次强调,Vert.x 线程模型确保单线程访问,因此不需要并发集合和临界区。

Jukebox 垂直的 start 方法(列表 4.6)需要配置一些与可以从 TCP 文本协议使用的命令和动作相对应的事件总线处理程序。稍后我们将剖析的 NetControl 垂直处理 TCP 服务器的内部,并向事件总线发送消息。

列表 4.6 在 Jukebox 垂直中设置事件总线处理程序

@Override
public void start() {
  EventBus eventBus = vertx.eventBus();
  eventBus.consumer("jukebox.list", this::list);
  eventBus.consumer("jukebox.schedule", this::schedule);
  eventBus.consumer("jukebox.play", this::play);
  eventBus.consumer("jukebox.pause", this::pause);

  // (...more later!)
}

注意,因为我们已经抽象化了通过事件总线传输命令,所以我们很容易插入新的方式来控制 jukebox,例如使用移动应用程序、Web 应用程序等。

下一个列表提供了播放/暂停和调度处理程序。这些方法直接操作播放和播放列表状态。

列表 4.7 Jukebox 垂直中的播放/暂停和调度操作

private void play(Message<?> request) {
  currentMode = State.PLAYING;
}

private void pause(Message<?> request) {
  currentMode = State.PAUSED;
}

private void schedule(Message<JsonObject> request) {
  String file = request.body().getString("file");
  if (playlist.isEmpty() && currentMode == State.PAUSED) {    ❶
    currentMode = State.PLAYING;
  }
  playlist.offer(file);
}

❶ 这允许我们在没有播放曲目且安排了新的曲目时自动恢复播放。

列出可用文件稍微复杂一些,如下一个列表所示。

列表 4.8 列出 Jukebox 垂直中的所有可用文件

private void list(Message<?> request) {
  vertx.fileSystem().readDir("tracks", ".*mp3$", ar -> {   ❶
    if (ar.succeeded()) {
      List<String> files = ar.result()
        .stream()
        .map(File::new)
        .map(File::getName)
        .collect(Collectors.toList());
      JsonObject json = new JsonObject().put("files", new JsonArray(files));
      request.reply(json);                                 ❷
    } else {
      logger.error("readDir failed", ar.cause());
      request.fail(500, ar.cause().getMessage());          ❸
    }
  });
}

❶ 我们异步获取 tracks/ 文件夹中所有以 .mp3 结尾的文件。

❷ 我们构建一个 JSON 响应。

❸ 这是在事件总线上的请求/回复通信中发送失败代码和错误消息的示例。

4.3.4 进入 HTTP 连接

有两种类型的进入 HTTP 客户端:要么他们想要音频流,要么他们想要下载文件。

HTTP 服务器在垂直结构的start方法中启动(见下一条列表)。

列表 4.9 在Jukebox垂直结构中设置 HTTP 服务器

@Override
public void start() {
  EventBus eventBus = vertx.eventBus();
  eventBus.consumer("jukebox.list", this::list);
  eventBus.consumer("jukebox.schedule", this::schedule);
  eventBus.consumer("jukebox.play", this::play);
  eventBus.consumer("jukebox.pause", this::pause);

  vertx.createHttpServer()
    .requestHandler(this::httpHandler)
    .listen(8080);

   // (...more later!)    ❶
}

❶ 我们将在 MP3 流中进一步扩展这一点。

Vert.x HTTP 服务器使用的请求处理程序如下所示。它将 HTTP 请求转发到openAudioStreamdownload实用方法,这些方法完成请求并继续。

列表 4.10 HTTP 请求处理程序和分配器

private void httpHandler(HttpServerRequest request) {
  if ("/".equals(request.path())) {
    openAudioStream(request);
    return;
  }
  if (request.path().startsWith("/download/")) {
    String sanitizedPath = request.path().substring(10).replaceAll("/", "");❶
    download(sanitizedPath, request);
    return;
  }
  request.response().setStatusCode(404).end();                              ❷
}

❶ 此字符串替换防止了恶意尝试从其他目录读取文件(想想有人愿意读取 /etc/passwd)。

❷ 当没有匹配项时,我们返回 404(未找到)响应。

openAudioStream方法的实现如下所示。它将流准备为分块模式,设置适当的内容类型,并将响应对象保留以供以后使用。

列表 4.11 处理新的流播放器

private final Set<HttpServerResponse> streamers = new HashSet<>();   ❶

private void openAudioStream(HttpServerRequest request) {
  HttpServerResponse response = request.response()
    .putHeader("Content-Type", "audio/mpeg")
    .setChunked(true);                                               ❷
  streamers.add(response);
  response.endHandler(v -> {
    streamers.remove(response);                                      ❸
    logger.info("A streamer left");
  });
}

❶ 我们跟踪所有当前的流式传输者在一个 HTTP 响应的集合中。

❷ 它是一个流,因此长度是未知的。

❸ 当流退出时,它就不再被跟踪。

4.3.5 尽可能高效地下载

下载文件是一个完美的例子,其中可以使用背压管理来协调源流(文件)和汇流(HTTP 响应)。

下面的列表显示了如何查找文件,当文件存在时,我们将最终的下载任务转发到downloadFile方法。

列表 4.12 下载方法

private void download(String path, HttpServerRequest request) {
  String file = "tracks/" + path;
  if (!vertx.fileSystem().existsBlocking(file)) {      ❶
    request.response().setStatusCode(404).end();
    return;
  }
  OpenOptions opts = new OpenOptions().setRead(true);
  vertx.fileSystem().open(file, opts, ar -> {
    if (ar.succeeded()) {
      downloadFile(ar.result(), request);
    } else {
      logger.error("Read failed", ar.cause());
      request.response().setStatusCode(500).end();
    }
  });
}

❶ 除非你在一个网络文件系统上,否则可能的阻塞时间微乎其微,所以我们避免嵌套回调级别。

downloadFile方法的实现如下所示。

列表 4.13 下载文件

private void downloadFile(AsyncFile file, HttpServerRequest request) {
  HttpServerResponse response = request.response();
  response.setStatusCode(200)
    .putHeader("Content-Type", "audio/mpeg")
    .setChunked(true);

  file.handler(buffer -> {
    response.write(buffer);
    if (response.writeQueueFull()) {                ❶
      file.pause();                                 ❷
      response.drainHandler(v -> file.resume());    ❸
    }
  });

  file.endHandler(v -> response.end());
}

❶ 写得太快!

❷ 通过暂停读取流来应用背压

❸ 当排空时恢复

在两个流之间复制数据时处理背压。当策略是暂停源流而不丢失任何数据时,这通常这样做,因此相同的代码可以重写如下所示。

列表 4.14 管道辅助工具

HttpServerResponse response = request.response();
response.setStatusCode(200)
  .putHeader("Content-Type", "audio/mpeg")
  .setChunked(true);

file.pipeTo(response);         ❶

❶ 从文件到响应的数据管道

管道在可暂停的ReadStreamWriteStream之间复制数据时处理背压。它还管理源流和两个流上的错误。列表 4.14 的代码正好与列表 4.13 中显示的内容相同,但没有样板代码。还有其他pipeTo变体,用于指定自定义处理程序。

4.3.6 读取 MP3 文件,但不要太快

MP3 文件包含一个包含艺术家名称、流派、比特率等元数据的标题。随后是几个包含压缩音频数据的帧,解码器可以将这些数据转换为脉冲编码调制数据,最终可以转换为声音。

MP3 解码器对错误非常健壮,因此如果它们在文件中间开始解码,它们仍然能够确定比特率,并且将与下一个帧对齐以开始解码音频。你甚至可以将多个 MP3 文件连接起来并发送给玩家。只要所有文件都使用相同的比特率和立体声模式,音频就会被解码。

这对我们来说很有趣,因为我们正在设计一个音乐流媒体点唱机:如果我们的文件已经以相同的方式编码,我们只需依次推送播放列表中的每个文件,解码器就可以很好地处理音频。

为什么仅后压力是不够的

将 MP3 数据传输给许多连接的玩家并不像看起来那么简单。主要问题是确保所有当前和未来的玩家都大致在同一时间收听相同的音乐。所有玩家都有不同的本地缓冲策略,以确保即使在网络延迟的情况下也能流畅播放,但如果服务器简单地以尽可能快的速度推送文件,则并非所有客户端都会同步。更糟糕的是,当新玩家连接时,它可能什么也收不到可以播放的,而当前玩家在他们的缓冲区中可能还有几分钟的音乐剩余。为了提供合理的播放体验,我们需要控制读取文件的速度,为此我们将使用计时器。

这在图 4.5 中得到了说明,该图显示了在流中没有速率控制时会发生什么。在两种情况下,假设玩家 A 在开始时加入了流,而玩家 B 在 10 秒后加入。在没有读取速率控制的情况下,我们发现自己处于与下载 MP3 文件类似的情况。我们可能已经设置了后压力来确保在将 MP3 数据块复制到连接的客户端时高效地使用资源,但流式传输体验将会非常糟糕。

图 4.5 无速率控制和有速率控制的流

由于我们基本上是以尽可能快的速度流式传输数据,玩家 A 发现其内部缓冲区几乎充满了当前文件的所有数据。虽然它可能在 0 分 15 秒的位置播放,但它已经接收到了超过 3 分钟标记的数据。当玩家 B 加入时,它开始从文件中较远的位置接收 MP3 数据块,因此它从 3 分 30 秒的位置开始播放。如果我们将我们的推理扩展到多个文件,新加入的玩家可能根本接收不到数据,而之前已经连接的玩家可能在他们的内部缓冲区中有多首歌曲要播放。

相比之下,如果我们控制 MP3 文件的读取速率,从而控制 MP3 数据块被复制和写入连接玩家的速率,我们就可以确保它们都大致处于相同的位置。

在这里,速率控制主要是确保所有玩家都能以足够快的速度接收数据,以便他们可以不间断地播放,但又不能太快,以免缓冲过多的数据。

速率限制流实现

让我们看看完整的 Jukebox 垂直 start 方法,因为它显示了所需的计时器。

列表 4.15 Jukebox 垂直类 start 方法

@Override
public void start() {
  EventBus eventBus = vertx.eventBus();
  eventBus.consumer("jukebox.list", this::list);
  eventBus.consumer("jukebox.schedule", this::schedule);
  eventBus.consumer("jukebox.play", this::play);
  eventBus.consumer("jukebox.pause", this::pause);

  vertx.createHttpServer()
    .requestHandler(this::httpHandler)
    .listen(8080);

  vertx.setPeriodic(100, this::streamAudioChunk);     ❶
}

streamAudioChunk 定期推送新的 MP3 数据(100 毫秒完全是经验值,所以请随意调整)。

除了连接事件总线处理程序和启动 HTTP 服务器之外,start 方法还定义了一个计时器,以便每 100 毫秒进行数据流传输。

接下来,我们可以看看 streamAudioChunk 方法的实现。

列表 4.16 流式传输文件块

private AsyncFile currentFile;
private long positionInFile;

private void streamAudioChunk(long id) {
  if (currentMode == State.PAUSED) {
    return;
  }
  if (currentFile == null && playlist.isEmpty()) {
    currentMode = State.PAUSED;
    return;
  }
  if (currentFile == null) {
    openNextFile();
  }
  currentFile.read(Buffer.buffer(4096), 0, positionInFile, 4096, ar -> {   ❶
    if (ar.succeeded()) {
      processReadBuffer(ar.result());                                      ❷
    } else {
      logger.error("Read failed", ar.cause());
      closeCurrentFile();
    }
  });
}

❶ 在 I/O 操作之间不能重用缓冲区,因此我们需要一个新的缓冲区。

❷ 这就是数据被复制到所有播放器的地方。

为什么是这些值?

为什么我们每 100 毫秒读取数据?为什么读取 4096 字节的缓冲区?

我在我的笔记本电脑上通过经验发现这些值对于 320 KBps 恒定比特率 MP3 文件来说效果很好。它们确保了测试中没有丢失,同时防止播放器缓冲过多数据,从而在音频流中结束几秒钟。

在运行示例时,请随意调整这些值。

streamAudioChunk 函数的代码读取最多 4096 字节的数据块。由于该方法每秒会被调用 10 次,因此它还需要检查是否正在播放任何内容。processReadBuffer 方法以如下所示的方式流式传输数据。

列表 4.17 将数据块流式传输到播放器

private void processReadBuffer(Buffer buffer) {
  positionInFile += buffer.length();
  if (buffer.length() == 0) {                       ❶
    closeCurrentFile();
    return;
  }
  for (HttpServerResponse streamer : streamers) {
    if (!streamer.writeQueueFull()) {               ❷
      streamer.write(buffer.copy());                ❸
    }
  }
}

❶ 这发生在文件末尾到达时。

❷ 再次遇到背压

❸ 记住,缓冲区不能被重用。

对于每个发送到播放器的 HTTP 响应流,该方法会复制读取的数据。请注意,我们在这里还有另一个背压管理的情况:当客户端的写入队列已满时,我们简单地丢弃数据。在播放器的端,这将导致音频丢失,但由于服务器上的队列已满,这意味着播放器将会有延迟或丢失。丢弃数据是可以的,因为 MP3 解码器知道如何恢复,并且这确保了播放将与其他播放器保持紧密的时间同步。

警告:一旦写入,Vert.x 缓冲区就不能被重用,因为它们被放置在写入队列中。重用缓冲区总会导致错误,所以在这里不要寻找不必要的优化。

最后,以下列表中的辅助方法可以启用文件的打开和关闭。

列表 4.18 打开和关闭文件

private void openNextFile() {
  OpenOptions opts = new OpenOptions().setRead(true);
  currentFile = vertx.fileSystem()
    .openBlocking("tracks/" + playlist.poll(), opts);    ❶
  positionInFile = 0;
}

private void closeCurrentFile() {
  positionInFile = 0;
  currentFile.close();
  currentFile = null;
}

❶ 再次,我们使用阻塞变体,但对于打开文件来说,这很少会成为问题。

4.4 简单流的解析

到目前为止,我们对音乐盒示例的分析主要集中在用于下载和流式传输 MP3 数据的 Jukebox 垂直上。现在是时候分析 NetControl 垂直了,它在一个 TCP 服务器上暴露端口 3000,用于接收文本命令以控制音乐盒播放的内容。从异步数据流中提取数据是一个常见的需求,Vert.x 提供了有效的工具来完成这项工作。

我们文本协议中的命令形式如下:

/action [argument]

这些是操作:

  • /list--列出可播放的文件

  • /play--确保流播放

  • /pause--暂停流

  • /schedule file--将 file 添加到播放列表的末尾

每个文本行可以恰好有一个命令,因此该协议被认为是换行符分隔的。

我们需要一个解析器来处理这个问题,因为缓冲区以块的形式到达,很少对应于每一行。例如,第一次读取的缓冲区可能包含以下内容:

ettes.mp3
/play
/pa

下一个可能看起来像这样:

use
/schedule right-here-righ

并且可能接着是:

t-now.mp3

我们实际上想要的是关于的推理,所以解决方案是将到达的缓冲区连接起来,并在换行符上再次分割,这样我们就有每个缓冲区一行。而不是手动组装中间缓冲区,Vert.x 提供了一个方便的解析辅助工具,即 RecordParser 类。解析器消耗缓冲区,并以新的缓冲区形式发出解析后的数据,要么通过查找分隔符,要么通过处理固定大小的块。

在我们的情况下,我们需要在流中查找换行分隔符。以下列表显示了如何在 NetControl 竖线中使用 RecordParser

列表 4.19 基于 TCP 服务器流的 recordparser

@Override
public void start() {
  vertx.createNetServer()
    .connectHandler(this::handleClient)
    .listen(3000);
}

private void handleClient(NetSocket socket) {
  RecordParser.newDelimited("\n", socket)                ❶
    .handler(buffer -> handleBuffer(socket, buffer))     ❷
    .endHandler(v -> logger.info("Connection ended"));
}

❶ 通过查找新行进行解析。

❷ 现在缓冲区是行。

解析器既是读取流也是写入流,因为它充当两个流之间的适配器。它消耗来自 TCP 套接字的中间缓冲区,并以新的缓冲区形式发出解析后的数据。这是相当透明的,简化了竖线其余部分的实现。

在下一个列表中,每个缓冲区都已知是一个行,因此我们可以直接处理命令。

列表 4.20 处理解析后的缓冲区

private void handleBuffer(NetSocket socket, Buffer buffer) {
  String command = buffer.toString();                         ❶
  switch (command) {
    case "/list":
      listCommand(socket);
      break;
    case "/play":
      vertx.eventBus().send("jukebox.play", "");
      break;
    case "/pause":
      vertx.eventBus().send("jukebox.pause", "");
      break;
    default:
      if (command.startsWith("/schedule ")) {
        schedule(command);
      } else {
        socket.write("Unknown command\n");
      }
  }
}

❶ 使用默认字符集进行缓冲区到字符串的解码

简单的命令在 case 子句中,其他命令在以下列表中显示的单独方法中。

列表 4.21 其他命令

private void schedule(String command) {
  String track = command.substring(10);                         ❶
  JsonObject json = new JsonObject().put("file", track);
  vertx.eventBus().send("jukebox.schedule", json);
}

private void listCommand(NetSocket socket) {
  vertx.eventBus().request("jukebox.list", "", reply -> {
    if (reply.succeeded()) {
      JsonObject data = (JsonObject) reply.result().body();
      data.getJsonArray("files")
        .stream().forEach(name -> socket.write(name + "\n"));   ❷
    } else {
      logger.error("/list error", reply.cause());
    }
  });
}

❶ 前面的 10 个字符用于 /schedule 和一个空格。

❷ 我们将每个文件名写入标准控制台输出。

4.5 解析复杂流

流可以比纯文本行更复杂,RecordParser 也可以简化我们与这些流的工作。让我们以键/值数据库存储为例,其中每个键和值都是字符串。

在此类数据库中,我们可能有条目如 1 -> {foo}2 -> {bar, baz},其中 12 是键。为这种类型的数据结构定义序列化方案有无数种方式,所以想象我们必须使用表 4.6 中的流格式。

表 4.6 数据库流格式

数据 描述
魔法标题 用字节序列 1, 2, 3, 和 4 来标识文件类型
版本 表示数据库流格式版本的整数
名称 以换行符结尾的字符串形式的数据库名称
密钥长度 表示下一个密钥字符数的整数
密钥名称 密钥名称的字符序列
值长度 表示下一个值字符数的整数
值的字符序列
(...) 剩余的 {key, value} 序列

格式混合了二进制和文本记录,因为流以魔数、版本号、名称开始,然后是一系列键和值条目。虽然格式本身在某些方面可能存在问题,但它是一个很好的例子,可以说明更复杂的解析。

首先,让我们有一个程序,它将数据库写入文件,包含两个键/值条目。下面的列表显示了如何使用 Vert.x 文件系统 API 打开文件、向缓冲区追加数据,然后写入它。

列表 4.22 将示例数据库写入文件

AsyncFile file = vertx.fileSystem().openBlocking("sample.db",
  new OpenOptions().setWrite(true).setCreate(true));
Buffer buffer = Buffer.buffer();

buffer.appendBytes(new byte[] { 1, 2, 3, 4});   ❶
buffer.appendInt(2);                            ❷
buffer.appendString("Sample database\n");       ❸

String key = "abc";                             ❹
String value = "123456-abcdef";
buffer
  .appendInt(key.length())
  .appendString(key)
  .appendInt(value.length())
  .appendString(value);

key = "foo@bar";                                ❺
value = "Foo Bar Baz";
buffer
  .appendInt(key.length())
  .appendString(key)
  .appendInt(value.length())
  .appendString(value);

file.end(buffer, ar -> vertx.close());

❶ 魔数

❷ 版本

❸ 数据库名称

❹ 第一个条目

❺ 第二个条目

在这个例子中,我们数据很少,所以我们使用了一个单独的缓冲区,我们在将其写入文件之前完全准备好了它,但我们同样可以使用一个用于头部的缓冲区,以及每个键/值条目的新缓冲区。

写入很简单,但如何读取它呢?RecordParser 的有趣特性是它的解析模式可以即时切换。我们可以开始解析固定大小为 5 的缓冲区,然后切换到基于制表符的解析,然后是 12 字节的数据块,等等。

解析逻辑通过将其分解为对应于解析状态的方法来表达得更好:一个用于解析数据库名称的方法,一个用于解析值条目的方法,等等。

下面的列表打开我们之前写入的文件,并将 RecordParser 对象置于固定模式,因为我们正在寻找表示魔数头部的四个字节的序列。我们安装的处理程序在读取到魔数时被调用。

列表 4.23 读取数据库流,步骤 1

AsyncFile file = vertx.fileSystem().openBlocking("sample.db",
  new OpenOptions().setRead(true));

RecordParser parser = RecordParser.newFixed(4, file);           ❶
parser.handler(header -> readMagicNumber(header, parser));

❶ 我们首先想读取魔数。

下一个列表提供了进一步方法的实现。

列表 4.24 读取数据库流,步骤 2

private static void readMagicNumber(Buffer header, RecordParser parser) {
  logger.info("Magic number: {}:{}:{}:{}", header.getByte(0), 
  ➥ header.getByte(1), header.getByte(2), header.getByte(3));
  parser.handler(version -> readVersion(version, parser));
}

private static void readVersion(Buffer header, RecordParser parser) {
  logger.info("Version: {}", header.getInt(0));
  parser.delimitedMode("\n");                            ❶
  parser.handler(name -> readName(name, parser));
}

private static void readName(Buffer name, RecordParser parser) {
  logger.info("Name: {}", name.toString());
  parser.fixedSizeMode(4);
  parser.handler(keyLength -> readKey(keyLength, parser));
}

❶ 解析器模式可以即时切换。

readMagicNumber 方法从缓冲区中提取魔数的前四个字节。我们知道缓冲区正好是四个字节,因为解析器处于固定大小模式。

下一个条目是数据库版本,它是一个整数,所以我们不需要更改解析器模式,因为整数是四个字节。一旦读取了版本,readVersion 方法切换到分隔符模式以提取数据库名称。然后我们开始查找键长度,因此在 readName 中需要一个固定大小的模式。

下面的列表读取键名、值长度和正确的值,finishEntry 将解析器设置为查找整数并委托给 readKey

列表 4.25 读取数据库流,步骤 3

private static void readKey(Buffer keyLength, RecordParser parser) {
  parser.fixedSizeMode(keyLength.getInt(0));
  parser.handler(key -> readValue(key.toString(), parser));
}

private static void readValue(String key, RecordParser parser) {
  parser.fixedSizeMode(4);
  parser.handler(valueLength -> finishEntry(key, valueLength, parser));
}

private static void finishEntry(String key, Buffer valueLength, 
➥ RecordParser parser) {
  parser.fixedSizeMode(valueLength.getInt(0));
  parser.handler(value -> {
    logger.info("Key: {} / Value: {}", key, value);
    parser.fixedSizeMode(4);
    parser.handler(keyLength -> readKey(keyLength, parser));
  });
}

下一个列表显示了使用列表 4.23 到 4.25 中的解析方法读取数据库文件时的某些示例输出。

列表 4.26 读取数据库流的日志

DatabaseReader - Magic number: 1:2:3:4
DatabaseReader - Version: 2
DatabaseReader - Name: Sample database
DatabaseReader - Key: abc / Value: 123456-abcdef
DatabaseReader - Key: foo@bar / Value: Foo Bar Baz

这种即时解析模式和处理器更改形成了一种非常简单但有效的方法来解析复杂的流。

小贴士 你可能会想知道如何在解析器已经从读取流中获取了一些进一步数据的情况下,动态地更改解析模式。记住,我们处于事件循环中,因此解析器处理程序一次处理一个解析记录。当我们从,比如说,分隔符模式切换到固定大小模式时,下一个记录是通过基于字节数而不是查找字符串来处理剩余的流数据而发出的。同样的推理也适用于从固定大小模式切换到分隔符模式。

4.6 关于流获取模式的一则简短说明

在我们结束本章之前,让我们回到我故意留出的ReadStream接口的细节。

在 Vert.x 3.6 中引入的获取模式,我在本章前面提到,允许流消费者请求一定数量的数据项,而不是流将数据项推送到消费者。这是通过暂停流然后请求在需要时获取的变量数量的项目来实现的。

我们可以用获取模式重写点唱机文件流代码,但仍然需要一个计时器来控制节奏。在这种情况下,手动读取一个 4096 字节的缓冲区或请求获取 4096 字节并没有太大的区别。

相反,让我们回到数据库读取示例。列表 4.23 至 4.25 中的读取流推送了事件。切换到获取模式并拉取数据不需要太多更改。以下列表显示了流初始化代码。

列表 4.27 将读取流置于获取模式

RecordParser parser = RecordParser.newFixed(4, file);
parser.pause();                                           ❶
parser.fetch(1);                                          ❷
parser.handler(header -> readMagicNumber(header, parser));

❶ 流不会推送事件。

❷ 我们请求一个元素(在这里,是一个缓冲区)。

记住RecordParser装饰了文件流。它被暂停,然后fetch方法请求一个元素。由于解析器会发出解析数据的缓冲区,在这个例子中请求一个元素意味着请求一个四字节的缓冲区(魔术数字)。最终,解析器处理程序将被调用以处理请求的缓冲区,直到再次调用fetch方法之前不会发生任何事情。

以下列表显示了两个解析处理方法及其对获取模式的适应。

列表 4.28 按需获取流数据

private static void readMagicNumber(Buffer header, RecordParser parser) {
  logger.info("Magic number: {}:{}:{}:{}", header.getByte(0), 
  ➥ header.getByte(1), header.getByte(2), header.getByte(3));
  parser.handler(version -> readVersion(version, parser));
  parser.fetch(1);                                           ❶
}
// (...)

private static void finishEntry(String key, Buffer valueLength, 
➥ RecordParser parser) {
  parser.fixedSizeMode(valueLength.getInt(0));
  parser.handler(value -> {
    logger.info("Key: {} / Value: {}", key, value);
    parser.fixedSizeMode(4);
    parser.handler(keyLength -> readKey(keyLength, parser));
    parser.fetch(1);
  });
  parser.fetch(1);
}

❶ 这里的一项是一个解析记录。

这两种模式之间的唯一区别是我们需要通过调用fetch来请求元素。在编写 Vert.x 应用程序时,你可能不太需要玩转获取模式,但如果你需要手动控制读取流,它是一个有用的工具。

在许多情况下,只需要数据被推送,请求者可以通过在需要暂停时发出信号来管理背压。如果你有一个请求者更容易让源知道它能处理多少项的情况,那么拉取数据是管理背压的更好选择。Vert.x 流在这里非常灵活。

下一章将重点介绍 Vert.x 异步编程中除了回调以外的其他模型。

摘要

  • Vert.x 流模型异步事件和数据流,并且可以在推送拉取/获取模式下使用。

  • 压力反馈管理对于确保异步系统之间事件协调交换至关重要,我们通过多设备 MP3 音频流和直接下载来阐述了这一点。

  • 流可以解析简单和复杂的数据,这里以音频流服务的网络控制界面为例。

5 超越回调

本章涵盖

  • 回调及其局限性,如网关/边缘服务示例所示

  • 未来和承诺--一个简单的模型,用于链式异步操作

  • 反应式扩展--一个更强大的模型,特别适合组合异步事件流

  • Kotlin 协程--对异步代码执行流程的语言级支持

在开发反应式应用程序时,您需要编写各种业务逻辑,并且并非所有逻辑都容易用异步形式表达。虽然回调是异步事件通知的简单形式,但它们很容易使异步代码变得复杂。

让我们看看为什么回调不总是最好的异步编程模型的实际例子。然后我们将探讨 Vert.x 支持的多种选项。

5.1 组合异步操作:边缘服务示例

我们将以一个 边缘服务 为例,说明如何使用不同的异步编程模型组合异步操作。

边缘服务也经常被称为 API 网关。它是一个充当其他服务门面的服务,因此请求者只需处理一个服务接口,而不是与每个服务交谈。边缘服务还可以执行其他任务,例如数据转换和与其他服务的交互,因此它不仅方便地聚合来自多个服务的数据。

5.1.1 场景

让我们回顾一下第三章中使用的热传感器垂直结构。假设我们有几个热传感器,我们想要公开一个 API 来获取和聚合所有传感器的热数据。这是一个非常简单但有效的边缘服务示例,因为它抽象了请求者需要了解和联系所有传感器的需求。为了使事情更有趣,我们还将有一个 快照 服务,在将传感器值返回给请求者之前捕获并记录这些值。整个场景如图 5.1 所示。

图片

图 5.1 边缘服务场景

请求者向边缘服务发出请求,边缘服务随后从传感器服务中获取温度数据。每个传感器都公开了 HTTP/JSON API,边缘服务将所有响应聚合在一个更大的 JSON 文档中。然后,该文档被发送到快照服务,然后再发送回请求者。这些交互总结在图 5.2 中。

图片

图 5.2 边缘、传感器和快照服务之间的交互

这个例子使我们能够对并行和顺序操作进行推理:

  • 并行异步操作:获取热传感器数据

  • 顺序异步操作:聚合热传感器数据,将其发送到快照服务,然后将其返回给请求者

5.1.2 热传感器垂直结构

我们可以将我们的热传感器作为多个独立进程部署,每个进程都公开一个 HTTP API。为了简化我们的示例,我们将它们部署在同一个进程中,尽管 HTTP 服务器监听不同的 TCP 端口。

以下 HeatSensor 类是对我们之前使用的类的简单修改。列表 5.1 显示了类的序言,直接从第三章的代码移植过来。

列表 5.1 热传感器垂直

public class HeatSensor extends AbstractVerticle {

  private final Random random = new Random();
  private final String sensorId = UUID.randomUUID().toString();     ❶
  private double temperature = 21.0;

  private void scheduleNextUpdate() {
    vertx.setTimer(random.nextInt(5000) + 1000, this::update);
  }

  private void update(long timerId) {
    temperature = temperature + (delta() / 10);
    scheduleNextUpdate();
  }

  private double delta() {
    if (random.nextInt() > 0) {
      return random.nextGaussian();
    } else {
      return -random.nextGaussian();
    }
  }
// (...)

❶ 每个传感器都有一个生成的唯一标识符。

代码保持了通过随机量更新温度的逻辑,随机延迟在一到六秒之间。

以下列表显示了添加以公开 HTTP API 的代码。

列表 5.2 热传感器垂直 HTTP API 代码

@Override
public void start() {
  vertx.createHttpServer()
    .requestHandler(this::handleRequest)
    .listen(config().getInteger("http.port", 3000));     ❶
  scheduleNextUpdate();
}

private void handleRequest(HttpServerRequest req) {
  JsonObject data = new JsonObject()
    .put("id", id)
    .put("temp", temp);
  req.response()
    .putHeader("Content-Type", "application/json")
    .end(data.encode());
}

❶ 服务器 TCP 端口是可配置的。

这是一个非常直接使用 Vert.x HTTP 服务器的例子,HTTP 端口通过配置传递。响应以 JSON 编码。

5.1.3 快照服务垂直

快照服务也公开了一个 HTTP 服务器,如下列所示。

列表 5.3 快照服务垂直

public class SnapshotService extends AbstractVerticle {
  private final Logger logger = LoggerFactory.getLogger(SnapshotService.class);

  @Override
  public void start() {
    vertx.createHttpServer()
      .requestHandler(req -> {
        if (badRequest(req)) {
          req.response().setStatusCode(400).end();
        }
        req.bodyHandler(buffer -> {                      ❶
          logger.info("Latest temperatures: {}", 
          ➥ buffer.toJsonObject().encodePrettily());
          req.response().end();
        });
      })
      .listen(config().getInteger("http.port", 4000));
  }

  private boolean badRequest(HttpServerRequest req) {
    return !req.method().equals(HttpMethod.POST) ||
      !"application/json".equals(req.getHeader("Content-Type"));
  }
}

❶ 这等待整个体被接收,而不是组装中间缓冲区。

HTTP 请求处理器期望一个 HTTP POST 请求,使用一个体处理器提取体,并记录接收到的数据。

定义了这两个垂直之后,现在可以开始有趣的部分了,我们可以看看如何制作我们的边缘服务。

5.2 回调

我们首先将使用回调函数来实现边缘服务,就像我们从这本书开始做的那样。

我们项目需要的依赖项是 Vert.x CoreVert.x Web Client(简化 HTTP 请求的创建),以及 Logback。以下列表显示了 Gradle 构建的依赖项。当使用 Maven 或任何其他兼容的构建工具时,工件完全相同。

列表 5.4 边缘服务依赖项(回调版本)

dependencies {
  implementation("io.vertx:vertx-core:${vertxVersion}")         ❶
  implementation("io.vertx:vertx-web-client:${vertxVersion}")
  implementation("ch.qos.logback:logback-classic:1.2.3")
}

❶ 将 ${vertxVersion} 替换为您选择的当前 Vert.x 版本。

注意:除了 chapter5.future.CollectorService 类以外的所有类都在 Vert.x 3.9 上编译。此类需要较新的基于 Vert.x 4 的未来 API,如第 5.3.2 节中提到的 Vert.x 未来和承诺。

5.2.1 实现

我们将从 CollectorService 垂直类实现的序言开始。

列表 5.5 回调实现序言

public class CollectorService extends AbstractVerticle {

  private final Logger logger = 
  ➥ LoggerFactory.getLogger(CollectorService.class);
  private WebClient webClient;

  @Override
  public void start() {
    webClient = WebClient.create(vertx);     ❶
    vertx.createHttpServer()
      .requestHandler(this::handleRequest)
      .listen(8080);
  }
  // (...)
}

❶ Vert.x 网络客户端需要一个 vertx 上下文。

start 方法首先创建一个 WebClient 实例,然后在端口 8080 上启动一个 HTTP 服务器。Web 客户端类来自 vertx-web-client 模块,与 Vert.x 核心 API 中的 HTTP 客户端相比,大大简化了 HTTP 请求的创建。它特别简化了 HTTP 体处理和转换:你可以将体转换为纯文本、JSON 或通用 Vert.x 缓冲区。

HTTP 请求处理器是以下列表中显示的 handleRequest 方法。

列表 5.6 使用回调处理请求

private void handleRequest(HttpServerRequest request) {
  List<JsonObject> responses = new ArrayList<>();        ❶
  AtomicInteger counter = new AtomicInteger(0);          ❷
  for (int i = 0; i < 3; i++) {
    webClient
      .get(3000 + i, "localhost", "/")                   ❸
      .expect(ResponsePredicate.SC_SUCCESS)              ❹
      .as(BodyCodec.jsonObject())                        ❺
      .send(ar -> {
        if (ar.succeeded()) {
          responses.add(ar.result().body());
        } else {
          logger.error("Sensor down?", ar.cause());
        }
        if (counter.incrementAndGet() == 3) {            ❻
          JsonObject data = new JsonObject()
            .put("data", new JsonArray(responses));
          sendToSnapshot(request, data);
        }
      });
  }
}

❶ 我们需要一个列表来收集 JSON 响应。

❷ 我们还需要一个计数器来跟踪响应,因为当有错误时,响应的数量可能少于请求的数量。

❸ 这会在资源 / 上对 localhost 和端口 3000 + i 发出 HTTP GET 请求。

❹ 当 HTTP 状态码不在 2xx 范围内时,此谓词会触发错误。

❺ 这将体视为 JSON 对象并执行自动转换。

❻ 当所有请求(或错误)都已接收后,我们可以进行下一步操作。

此方法展示了使用网络客户端 API 执行 HTTP 请求是多么容易。主要困难在于协调并行 HTTP 请求。我们需要一个循环来发出请求,由于它们是异步的,我们还需要跟踪接收到的响应数量和响应值。这是通过有一个响应列表和使用响应计数器来完成的。请注意,我们在这里使用 AtomicInteger 不是因为并发,而是因为我们需要一个对象来从回调中增加一个整数。

一旦所有响应都已接收,我们可以进行下一步操作,即将数据发送到快照服务。

列表 5.7 向快照服务发送数据

private void sendToSnapshot(HttpServerRequest request, JsonObject data) {
  webClient
    .post(4000, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .sendJsonObject(data, ar -> {
      if (ar.succeeded()) {
        sendResponse(request, data);
      } else {
        logger.error("Snapshot down?", ar.cause());
        request.response().setStatusCode(500).end();      ❶
      }
    });
}

❶ 如果发生错误,我们在此处使用 500 状态码结束 HTTP 请求。

此方法实现简单地使用网络客户端发出 HTTP POST 请求。

成功后,代码将移动到 sendResponse 方法以结束 HTTP 请求,如下所示。

列表 5.8 发送响应

private void sendResponse(HttpServerRequest request, JsonObject data) {
  request.response()
    .putHeader("Content-Type", "application/json")
    .end(data.encode());                            ❶
}

❶ 提供紧凑的 JSON 文本表示

5.2.2 运行

要运行边缘服务,我们首先需要部署 verticles,如下所示。

列表 5.9 主方法

Vertx vertx = Vertx.vertx();

vertx.deployVerticle("chapter5.sensor.HeatSensor",
  new DeploymentOptions().setConfig(new JsonObject()
    .put("http.port", 3000)));                         ❶

vertx.deployVerticle("chapter5.sensor.HeatSensor",
  new DeploymentOptions().setConfig(new JsonObject()
    .put("http.port", 3001)));

vertx.deployVerticle("chapter5.sensor.HeatSensor",
  new DeploymentOptions().setConfig(new JsonObject()
    .put("http.port", 3002)));

vertx.deployVerticle("chapter5.snapshot.SnapshotService");
vertx.deployVerticle("chapter5.callbacks.CollectorService");

❶ 每个实例可以使用不同的端口号。

我们可以使用 HTTPie 来发出 HTTP 请求以测试服务,如下所示。

列表 5.10 调用边缘服务

$ http :8080                   ❶
HTTP/1.1 200 OK
Content-Type: application/json
content-length: 224

{
    "data": [
        {
            "id": "66e310a6-9068-4552-b4aa-6130b3e17cb6",
            "temp": 21.118902894421108
        },
        {
            "id": "3709b24b-cef2-4341-b64a-af68b11e2c0d",
            "temp": 20.96576368750857
        },
        {
            "id": "79f9fa27-b341-4ce5-a335-03caef6e8935",
            "temp": 21.01792006568459
        }
    ]
}

❶ 使用 HTTPie,:8080 是 http://localhost:8080 的快捷方式。

在服务器端,我们可以检查快照服务是否输出了一些日志,如下所示。

列表 5.11 边缘服务的日志

15:10:25.576 SnapshotService - Lastest temperatures: {    ❶
  "data" : [ {
    "id" : "66e310a6-9068-4552-b4aa-6130b3e17cb6",
    "temp" : 21.118902894421108
  }, {
    "id" : "3709b24b-cef2-4341-b64a-af68b11e2c0d",
    "temp" : 20.96576368750857
  }, {
    "id" : "79f9fa27-b341-4ce5-a335-03caef6e8935",
    "temp" : 21.01792006568459
  } ]
}

❶ 每个条目都有聚合的 JSON 数据。

5.2.3 “回调地狱”不是问题

当讨论回调时,许多人会尖叫“回调地狱!”。回调地狱是指使用嵌套回调来链式异步操作,导致代码难以理解,因为嵌套太深。使用嵌套回调进行错误处理尤其困难。

虽然这是真的,但可以通过为每个异步操作回调使用一个方法来轻松缓解回调地狱,就像我们在 handleRequestsendToSnapshotsendResponse 方法中所做的那样。每个方法只做一件事,我们避免了回调的嵌套。

以下列表显示了与前面等效的代码,但紧凑地作为一个带有嵌套回调的单个部分。

列表 5.12 嵌套回调的变体

List<JsonObject> responses = new ArrayList<>();
AtomicInteger counter = new AtomicInteger(0);
for (int i = 0; i < 3; i++) {
  webClient
    .get(3000 + i, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .as(BodyCodec.jsonObject())
    .send(ar -> {                                 ❶
      if (ar.succeeded()) {
        responses.add(ar.result().body());
      } else {
        logger.error("Sensor down?", ar.cause());
      }
      if (counter.incrementAndGet() == 3) {       ❷
        JsonObject data = new JsonObject()
          .put("data", new JsonArray(responses));
        webClient
          .post(4000, "localhost", "/")
          .expect(ResponsePredicate.SC_SUCCESS)
          .sendJsonObject(data, ar1 -> {          ❸
            if (ar1.succeeded()) {                ❹
              request.response()
                .putHeader("Content-Type", "application/json")
                .end(data.encode());
            } else {
              logger.error("Snapshot down?", ar1.cause());
              request.response().setStatusCode(500).end();
            }
          });
      }
    });
}

❶ 与其他并行发送传感器请求。

❷ 所有 HTTP 响应已接收。

❸ 向快照服务发送帖子。

❹ 回应请求者。

嵌套回调当然不会使代码更易读,但我认为真正的问题在于函数式代码与异步协调代码交织在一起。你需要从循环、回调和分支中解析出三个 HTTP 请求是并行进行的,并且它们的处理结果被组装、发送到第三方服务,然后作为响应返回。

回调并不完美,但一点点的纪律可以使代码更易读,尤其是当你只有异步操作的顺序组合时,比如sendToSnapshot将工作传递给sendResponse

现在我们来看看其他可能比回调更有趣的异步编程模型。

5.3 Futures 和 promises

你已经因为 verticle start方法的签名而接触到了 Vert.x 的futurespromises。你也许在其他语言中,如 JavaScript,也接触到了它们。我们将进一步探讨这个模型,并看看它们是如何成为 Vert.x 中组合异步操作的有趣原语。

Vert.x 实现了一个与 Barbara Liskov 和 Liuba Shrira 原始研究成果一致的 futures 和 promises 模型。1 他们引入了 promises 作为异步远程过程调用的一种语言抽象。

Promise 持有某个计算的结果值,但目前还没有值。Promise 最终会完成,带有结果值或错误。在异步 I/O 的上下文中,Promise 是异步操作结果的理想选择。反过来,future 允许你读取最终可以从 Promise 中获取的值。

总结:Promise 用于写入最终值,future 用于在它可用时读取它。现在让我们看看它在 Vert.x 中是如何工作的。

5.3.1 Vert.x 中的 futures 和 promises

Promise 是由即将执行异步操作的代码创建的。例如,假设你想报告一个异步操作已完成,不是现在,而是在五秒后。在 Vert.x 中,你会使用计时器来做这件事,并使用 promise 来持有结果,如以下列表所示。

列表 5.13 从 promise 创建 promise

Promise<String> promise = Promise.promise();              ❶
vertx.setTimer(5000, id -> {                              ❷
  if (System.currentTimeMillis() % 2L == 0L) {
    promise.complete("Ok!");                              ❸
  } else {
    promise.fail(new RuntimeException("Bad luck..."));    ❹
  }
});
// (...)                                                  ❺

❶ 创建 promise。

❷ 异步操作

❸ 使用值完成 promise。

❹ 使用异常使 promise 失败。

❺参见列表 5.14。

这里异步操作是一个五秒的计时器,之后 promise 完成。根据当前时间是否为奇数或偶数,promise 会完成带有值或失败带有异常。这很好,但我们实际上如何从 promise 中获取值呢?

当结果可用时想要做出反应的代码需要一个 future 对象。Vert.x future 是从 promise 创建的,然后传递给想要读取值的代码,如下一列表所示,这是列表 5.13 的其余部分。

列表 5.14 从 promise 创建 future

Future<String> future = promise.future();                   ❶
return future;
// (...)

future
  .onSuccess(System.out::println)                           ❷
  .onFailure(err -> System.out.println(err.getMessage()));  ❸

❶ 从一个承诺中派生出一个未来,然后返回它。

❷ 当承诺完成时的回调

❸ 当未来失败时的回调

Future 接口定义了两个方法,onSuccessonFailure,用于处理值和错误。当我们运行相应的代码时,我们会在五秒后看到打印出“Ok!”或“Bad luck...”。

我们可以使用未来执行更高级的异步操作,如下面的列表所示。

列表 5.15 高级未来组合操作

promise.future()
  .recover(err -> Future.succeededFuture("Let's say it's ok!"))   ❶
  .map(String::toUpperCase)                                       ❷
  .flatMap(str -> {                                               ❸
    Promise<String> next = Promise.promise();
    vertx.setTimer(3000, id -> next.complete(">>> " + str));
    return next.future();
  })
  .onSuccess(System.out::println);

❶ 使用另一个值从错误中恢复。

❷ 将一个值映射到另一个值。

❸ 与另一个异步操作组合。

当承诺失败时调用 recover 操作,它用于用另一个值替换错误。你可以将 recover 视为 Java 中的 catch 块的等价物,其中你可以处理错误。在这里,我们简单地使用一个成功的未来提供一个恢复值,但在更高级的情况下,当没有可以恢复的操作时,你也可以报告一个 失败的 未来。

map 操作使用一个函数转换一个值,而 flatMap 与另一个异步操作组合。你可以将 flatMap 视为“然后”。在这里,操作在三个秒后将字符串值前加上“>>>”。我们还可以看到典型的承诺/未来模式,我们首先创建一个承诺,然后执行一个最终完成承诺的异步操作,最后返回一个未来,以便值可以被其他代码消费。

5.3.2 Vert.x 4 中的基于未来的 API

Vert.x 4 将 Vert.x 的未来对象引入到核心 API 中,与回调一起使用。虽然回调仍然是标准模型,但大多数 API 都提供了返回 Future 的变体。

这意味着给定一个方法,void doThis(Handler<AsyncResult<T>>),存在一个形式为 Future<T> doThis() 的变体。以下列表中展示了良好的示例,其中我们启动了一个 HTTP 服务器。

列表 5.16 使用未来方法启动 HTTP 服务器

@Override
public void start(Promise<Void> promise) {
  vertx.createHttpServer()
    .requestHandler(this::handleRequest)
    .listen(8080)                           ❶
    .onFailure(promise::fail)               ❷
    .onSuccess(ok -> {                      ❸
      System.out.println("http://localhost:8080/");
      promise.complete();
    });
}

❶ 返回一个 Future

❷ 当服务器无法启动时调用

❸ 成功时调用

我们在早期示例中看到的 listen 方法接受了一个回调,而在这里它返回一个 Future<HttpServer>。然后我们可以链式调用 onFailureonSuccess 来定义服务器启动或发生错误时应该做什么。

注意:您可以从 Vert.x 3.8 开始使用新的承诺/未来接口,但基于未来的 API 只在 Vert.x 4 中可用。

5.3.3 与 CompletionStage API 的互操作性

Vert.x 的未来对象也与 JDK 中 java.util.concurrent 包的 CompletionStage 接口互操作。CompletionStage 接口代表异步操作中的一个步骤,因此你可以将其视为一个未来,特别是有一个名为 CompletableFuture 的类实现了 CompletionStage。例如,Java 11 中的 HTTP 客户端 API 提供了返回 CompletableFuturesendAsync 方法,以便进行异步 HTTP 请求。

当你需要与使用 CompletionStage 在其 API 中的库交互时,Vert.x futures 和 CompletionStage 之间的互操作性很有用。

注意:Vert.x Future 接口不是 CompletionStage 的子类型。在准备 Vert.x 4 的路线图时,Vert.x 团队考虑过这一点,但最终我们选择了自己的接口定义,因为 CompletionStage 对线程模型更为中立。确实,“async”后缀的方法提供了可以传递执行器(如 CompletionStage<Void> thenRunAsync(Runnable,Executor))的变体,而无需执行器参数的变体默认调度到 ForkJoinPool 实例。这些方法允许轻易地从 Vert.x 事件循环或工作线程池中退出,因此我们选择提供互操作性,而不是在 Vert.x API 中直接使用 CompletionStage

以下列表展示了如何从 Vert.x Future 转换到 CompletionStage

列表 5.17 从 Vert.x FutureCompletionStage

CompletionStage<String> cs = promise.future().toCompletionStage();   ❶
cs
  .thenApply(String::toUpperCase)                                    ❷
  .thenApply(str -> "~~~ " + str)
  .whenComplete((str, err) -> {                                      ❸
    if (err == null) {
      System.out.println(str);
    } else {
      System.out.println("Oh... " + err.getMessage());
    }
  });

❶ 将 Future 转换为 CompletionStage

thenApply 与 Vert.x Future 中的 map 类似。

❸ 接收一个值或一个错误

在这里,我们将字符串结果转换为大写,在其前面添加一个字符串,并最终调用 whenComplete。请注意,这是一个 BiConsumer,你需要测试哪个值或异常参数是 null,以确定承诺是否成功完成。同样重要的是要注意,除非你调用异步的 CompletionStage 方法,否则调用将在 Vert.x 线程上执行。

最后但同样重要的是,你可以将 CompletionStage 转换为 Vert.x Future,如下所示。

列表 5.18 从 CompletionStage 到 Vert.x Future

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {   ❶
  try {
    Thread.sleep(5000);
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
  return "5 seconds have elapsed";
});

Future
  .fromCompletionStage(cf, vertx.getOrCreateContext())                 ❷
  .onSuccess(System.out::println)
  .onFailure(Throwable::printStackTrace);

❶ 从异步操作创建一个 CompletableFuture。

❷ 转换为 Vert.x future,并在 Vert.x 上下文中调度。

CompletableFuture 实现 CompletionStagesupplyAsync 将调用调度到默认的 ForkJoinPool。该池中的一个线程将被使用,在返回一个字符串之前将睡眠五秒钟,这个字符串将是 CompletableFuture 的结果。fromCompletionStage 方法将转换为 Vert.x Future。该方法有两个变体:一个带有 Vert.x 上下文以调用 Future 方法,如 onSuccess 在上下文中,另一个调用将在完成提供的 CompletionStage 实例的任何线程上发生。

5.3.4 使用 Vert.x futures 的收集器服务

回到边缘服务示例,我们可以利用使用 Future 的 Vert.x API。我们将使用列表 5.16 中的早期 verticle start 方法。

首先,我们可以在以下列表中定义 fetchTemperature 方法以从服务获取温度。

列表 5.19 使用基于未来的 API 获取温度

private Future<JsonObject> fetchTemperature(int port) {
  return webClient
    .get(port, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .as(BodyCodec.jsonObject())
    .send()                      ❶
    .map(HttpResponse::body);    ❷
}

❶ Future

❷ 提取并返回仅包含正文的内容。

此方法返回一个 JsonObject 的 future,为了实现这一点,我们使用了返回 future 的 WebClient.send 方法的变体,然后将结果映射以提取仅包含 JSON 数据。

温度数据在下面的 handleRequest 方法中收集。

列表 5.20 使用基于未来的 API 收集温度

private void handleRequest(HttpServerRequest request) {
  CompositeFuture.all(                                   ❶
    fetchTemperature(3000),                              ❷
    fetchTemperature(3001),
    fetchTemperature(3002))
    .flatMap(this::sendToSnapshot)                       ❸
    .onSuccess(data -> request.response()                ❹
      .putHeader("Content-Type", "application/json")
      .end(data.encode()))
    .onFailure(err -> {                                  ❺
      logger.error("Something went wrong", err);
      request.response().setStatusCode(500).end();
    });
}

❶ 组合多个未来。

❷ 获取温度。

❸ 与另一个异步操作链式调用。

❹ 处理成功情况。

❺ 处理失败情况。

你可以使用 CompositeFuture 将多个未来合并成一个。all 静态方法会生成一个当所有未来都完成时完成,任何未来失败时失败的未来。还有 anyjoin 方法,它们有不同的语义。

一旦所有温度都成功接收,flatMap 的调用会将数据发送到快照服务,这是一个异步操作。sendToSnapshot 方法的代码如下所示。

列表 5.21 使用基于未来的 API 向快照服务发送数据

private Future<JsonObject> sendToSnapshot(CompositeFuture temps) {
  List<JsonObject> tempData = temps.list();
  JsonObject data = new JsonObject()
    .put("data", new JsonArray()
      .add(tempData.get(0))
      .add(tempData.get(1))
      .add(tempData.get(2)));
  return webClient
    .post(4000, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .sendJson(data)                        ❶
    .map(response -> data);
}

❶ 基于未来的变体

这段代码与 fetchTemperature 的代码类似,因为我们使用了一个返回 FutureWebClient 方法。部署 verticles 的主方法代码与回调变体相同,只是我们部署了一个不同的 CollectorService verticle:

// (...)
vertx.deployVerticle("chapter5.future.CollectorService");

现在让我们继续探讨反应式扩展,另一种异步编程模型。

5.4 反应式扩展

反应式扩展是 可观察 设计模式的扩展形式。2 它们最初由 Erik Meijer 在 Microsoft .NET 生态系统中推广。现代应用程序越来越多地由异步事件流组成,不仅限于服务器,还包括 Web、桌面和移动客户端。3 事实上,我们可以将图形用户界面事件视为应用程序必须响应的事件流。

反应式扩展由三件事定义:

  • 观察事件或数据流(例如,观察传入的 HTTP 请求)

  • 通过组合操作符转换流(例如,将多个 HTTP 请求流合并为一个)

  • 订阅流并对事件和错误做出反应

ReactiveX 创新为许多语言提供了通用的 API 和实现,包括后端和前端项目 (reactivex.io/)。RxJS 项目为浏览器中的 JavaScript 应用程序提供反应式扩展,而像 RxJava 这样的项目为 Java 生态系统提供了一个通用的反应式扩展实现。

Vert.x 为 RxJava 1 和 2 版本提供绑定。建议使用版本 2,因为它支持背压,而版本 1 不支持。

5.4.1 RxJava 简述

让我们探索 RxJava 的基础知识,看看它做什么以及它如何与 Vert.x 优雅地集成。

tip Timo Tuominen 的 RxJava for Android Developers (Manning, 2019) 是学习 RxJava 的一个可靠资源。

可观察类型

首先,RxJava 2 提供了五种不同类型的可观察源,列在表 5.1 中。

表 5.1 RxJava 中的可观察类型

类型 描述 示例
Observable<T> 类型为 T 的事件流。不支持背压。 计时事件,无法应用背压的观察源,如 GUI 事件
Flowable<T> 可以应用背压的类型为 T 的事件流 网络数据,文件系统输入
Single<T> 发出类型为 T 的确切一个事件的源 通过键从数据存储中获取条目
Maybe<T> 可能发出类型为 T 的一个事件,或没有 通过键从数据存储中获取条目,但键可能不存在
Completable 通知某些操作已完成,但没有给出任何值 删除文件

你有时会读到 源。热源是一个无论是否有订阅者都会发出事件的源。冷源是一个只有在第一次订阅后才开始发出事件的源。周期性计时器是一个热源,而要读取的文件是一个冷源。对于冷源,你可以获取所有事件,但对于热源,你将只能获取订阅后发出的那些事件。

基本示例

我们将从列表 5.22 中的简单示例开始,如图 5.3 所示。

图 5.3 列表 5.22 的 RxJava 管道

列表 5.22 一个 RxJava 的第一个示例

Observable.just(1, 2, 3)              ❶
  .map(Object::toString)              ❷
  .map(s -> "@" + s)                  ❸
  .subscribe(System.out::println);    ❹

❶ 这是一个预定义序列的观察者。

❷ 我们将它们映射为字符串。

❸ 我们转换字符串。

❹ 对于每个项目,我们将其打印到标准输出。

运行列表 5.22 中的代码会产生以下控制台输出:

@1
@2
@3

这个示例创建了一个包含三个整数的观察者。just 工厂方法创建了一个 Observable<Integer> 源。然后我们使用两个 map 操作符来转换流。第一个将 Observable<Integer> 转换为 Observable<String>。第二个在每一项前添加 @ 字符。最后,subscribe 执行订阅,对每个项目调用 System.out.println

源可能会发出错误,在这种情况下,订阅者可以被通知。考虑以下列表中的观察者。

列表 5.23 使用 RxJava 进行错误处理

Observable.<String>error(() -> new RuntimeException("Woops"))   ❶
  .map(String::toUpperCase)                                     ❷
  .subscribe(System.out::println, Throwable::printStackTrace);  ❸

❶ 观察者发出一个错误。

❷ 这个方法永远不会被调用。

❸ 将打印堆栈跟踪。

字符串值的观察者将发出一个错误。map 操作符永远不会被调用,因为它只操作值,不操作错误。我们可以看到 subscribe 现在有两个参数;第二个是处理错误的回调。在这个例子中,我们只是打印堆栈跟踪,但在网络应用程序中,例如,我们会进行错误恢复。

注意:使用just工厂方法在示例和测试中非常好,但在现实场景中,你可能需要将更复杂的源适配以产生 RxJava 可观察类型的事件。为此,有一个通用的Publisher接口,你可以实现它来使用fromPublisher方法(而不是just)向订阅者发出项目。还有适配器方法用于 JDK 未来、可迭代对象以及从 JDK 可调用对象生成项目。

生命周期

之前的例子没有显示可观察对象的完整生命周期。一旦订阅发生,就会发出零个或多个项目。然后流以错误或完成通知结束。

让我们看看一个更详细的例子。

列表 5.24 在 RxJava 中处理所有生命周期事件

Observable
  .just("--", "this", "is", "--", "a", "sequence", "of", "items", "!")
  .doOnSubscribe(d -> System.out.println("Subscribed!"))               ❶
  .delay(5, TimeUnit.SECONDS)                                          ❷
  .filter(s -> !s.startsWith("--"))
  .doOnNext(System.out::println)                                       ❸
  .map(String::toUpperCase)
  .buffer(2)                                                           ❹
  .subscribe(
    System.out::println,
    Throwable::printStackTrace,
    () -> System.out.println(">>> Done"));                             ❺

❶ 可以插入操作,例如当发生订阅时。

❷ 这延迟了事件发射五秒钟。

❸ 另一个动作,在这里称为流中每个流动的项目

❹ 这组将事件 2 个一组。

❺ 当流完成时调用

运行前面的代码会得到以下输出。

列表 5.25 运行列表 5.24 的输出

Subscribed!           ❶
doOnNext: this
doOnNext: is
next: [THIS, IS]
doOnNext: a
doOnNext: sequence
next: [A, SEQUENCE]
doOnNext: of
doOnNext: items
next: [OF, ITEMS]
doOnNext: !
next: [!]
~Done~

❶ 这是五秒钟的唯一输出。然后出现下一行。

这个例子展示了subscribe的形式,其中可以处理所有事件:一个事件、一个错误和流的完成。该示例还展示了进一步的运算符:

  • doOnSubscribedoOnNext是动作(可能有副作用),可以在项目通过流时触发。

  • delay允许在流中进一步发射事件时延迟。

  • buffer将事件(到列表中)分组,因此我们在这里以成对的事件接收。

当然,RxJava 的内容远不止我们在这个部分讨论的,但我们已经涵盖了足够的内容,可以深入到 Vert.x 和 RxJava 的集成。

5.4.2 RxJava 和 Vert.x

Vert.x 中的 RxJava 集成可在vertx-rx-java2模块中找到。在 Gradle(以及类似地,在 Maven 中),可以将依赖项添加为

implementation("io.vertx:vertx-rx-java2:version")

官方 Vert.x 堆栈中的所有项目 API 都支持 RxJava。RxJava API 是从核心 API 自动生成的。有几个习惯用法转换规则到 RxJava API,但作为一个简单的例子,当你有

void foo(String s, Handler<AsyncResult<String>> callback)

翻译到 RxJava 是

Single<String> foo(String s)

RxJava API 位于io.vertx.reactivex的子包中。例如,AbstractVerticle的 RxJava 版本是io.vertx.reactivex.core.AbstractVerticle

让我们看看使用 RxJava API 的一个示例 verticle。

列表 5.26 RxJava 和 Vert.x API

public class VertxIntro extends AbstractVerticle {
  @Override
  public Completable rxStart() {                                 ❶

    Observable
      .interval(1, TimeUnit.SECONDS, RxHelper.scheduler(vertx))  ❷
      .subscribe(n -> System.out.println("tick"));

    return vertx.createHttpServer()
      .requestHandler(r -> r.response().end("Ok"))
      .rxListen(8080)                                            ❸
      .ignoreElement();                                          ❹
  }
}

❶ rxStart 使用 Completable 而不是 Future 通知部署成功。

❷ 调度器强制执行 Vert.x 线程模型。

❸ 这是一个 RxJava 的 listen(port, callback)变体。

❹ 这从 Single 返回一个 Completable。

这个例子打开了一个经典的 HTTP 服务器,对任何请求都回复 Ok。有趣的部分是 RxJava 的 AbstractVerticle 变体有一个 rxStart(和 rxStop)方法,用于通知部署成功。在我们的情况下,当 HTTP 服务器启动时,verticle 已成功部署,因此我们返回一个 Completable 对象。你可以检查以 rx 前缀开头的方法是否对应于支持 RxJava 的生成方法。如果你检查 RxJava API,你会注意到原始方法(包括回调)仍然存在。

这个例子中另一个有趣的部分是每秒发出事件的观察者。它本质上与 Vert.x 定时器类似。RxJava API 中有几个操作符方法接受一个 scheduler 对象,因为它们需要延迟异步任务。默认情况下,它们从它们管理的内部工作线程池中回调,这打破了 Vert.x 线程模型假设。我们可以始终传递一个 Vert.x 调度器以确保事件仍然在原始上下文事件循环中被回调。

5.4.3 RxJava 中的收集器服务

我们现在可以回到我们的边缘服务示例,并用 RxJava 重新编写 CollectorService verticle 类。

首先,我们将更新导入以使用 io.vertx.reactivex.* 包。由于 verticle 启动了一个 HTTP 服务器,我们可以利用 rxStart 如下。

列表 5.27 RxJava 收集器服务前缀

@Override
public Completable rxStart() {
  webClient = WebClient.create(vertx);
  return vertx.createHttpServer()
    .requestHandler(this::handleRequest)
    .rxListen(8080)                       ❶
    .ignoreElement();                     ❷
}

❶ 一个 Single

❷ 一个 Completable

下一步是编写一个并行获取温度的方法,然后将响应组装成一个 JSON 对象。就像回调版本一样,我们可以有一个获取单个温度的方法。代码如下所示。

列表 5.28 使用 RxJava 获取温度

private Single<HttpResponse<JsonObject>> fetchTemperature(int port) {
  return webClient
    .get(port, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .as(BodyCodec.jsonObject())
    .rxSend();                    ❶
}

❶ 这返回一个 Single。

同样,与回调版本的区别在于我们使用 rxSend(它返回一个 Single)而不是 send(它使用回调)。

下一个列表展示了一个方法,该方法组合并行异步 HTTP 请求并根据响应组装一个 JSON 对象。

列表 5.29 使用 RxJava 收集温度请求

private Single<JsonObject> collectTemperatures() {
  Single<HttpResponse<JsonObject>> r1 = fetchTemperature(3000);
  Single<HttpResponse<JsonObject>> r2 = fetchTemperature(3001);
  Single<HttpResponse<JsonObject>> r3 = fetchTemperature(3002);

  return Single.zip(r1, r2, r3, (j1, j2, j3) -> {    ❶
    JsonArray array = new JsonArray()
      .add(j1.body())
      .add(j2.body())
      .add(j3.body());
    return new JsonObject().put("data", array);      ❷
  });
}

❶ zip 操作符组合了三个响应。

❷ 值是 zip 操作符的响应,封装在一个 Single 中。

通过使用 fetchTemperature 来获取单个响应,我们获得观察单个 HTTP 响应的 Single 对象。为了组合结果,我们使用 zip 操作符,它接受可分割的源并将结果组合为另一个 Single 对象。当所有 HTTP 响应都可用时,zip 操作符将值传递给一个必须产生值的函数(任何类型)。返回的值然后是 zip 操作符发出的 Single 对象。在这里,我们使用 Vert.x 网络客户端为我们转换成 JSON 的 HTTP 响应体构建一个 JSON 数组,然后我们将数组包装在一个 JSON 对象中。

注意,zip 函数有多个重载定义,参数数量不同,以应对两个来源、三个来源等情况。当代码需要处理未定义数量的来源时,有一个变体接受来源列表,并且传递给 zip 函数的函数接受值列表。

这引出了处理 HTTP 请求的方法的定义,该方法收集温度,发布到快照服务,然后响应请求者。代码如下所示。

列表 5.30 RxJava 收集器服务 HTTP 处理器

private void handleRequest(HttpServerRequest request) {
  Single<JsonObject> data = collectTemperatures();
  sendToSnapshot(data).subscribe(json -> {              ❶
    request.response()
      .putHeader("Content-Type", "application/json")
      .end(json.encode());
  }, err -> {                                           ❷
    logger.error("Something went wrong", err);
    request.response().setStatusCode(500).end();
  });
}

❶ 我们将数据发送到快照服务。

❷ 我们有一个错误管理的单一点。

此方法还执行订阅:成功返回 JSON 数据给请求者,失败则返回 HTTP 500 错误。重要的是要注意,订阅触发对传感器服务的 HTTP 请求,然后是对快照服务的请求,等等。在订阅之前,RxJava 可观察管道只是处理事件的“食谱”。

最后缺少的部分是将数据发送到快照服务的方法。

列表 5.31 使用 RxJava 向快照服务发送数据

private Single<JsonObject> sendToSnapshot(Single<JsonObject> data) {
  return data.flatMap(json -> webClient                              ❶
    .post(4000, "localhost", "")
    .expect(ResponsePredicate.SC_SUCCESS)
    .rxSendJsonObject(json)                                          ❷
    .flatMap(resp -> Single.just(json)));                            ❸
}

❶ 一旦我们有了 JSON 数据,我们就发出一个 HTTP 请求。

❷ 这发送一个 JSON 对象,然后报告 HTTP 请求响应。

❸ 这使我们能够返回 JSON 对象而不是 HTTP 请求响应。

此方法引入了 flatMap 操作符,这对于函数式编程爱好者来说很熟悉。如果你觉得 flatMap 听起来很神秘,不要担心;在组合顺序异步操作的情况下,你只需将flatMap读作“然后”即可。

由于 data 发射一个 JSON 对象,flatMap 操作符允许我们在 JSON 对象发射后使用网络客户端发出一个 HTTP 请求。在向快照服务发出 HTTP 请求并成功后,我们需要另一个(嵌套的)flatMap。确实,rxSendJsonObject 给出一个只发射 HTTP 响应的单个可观察对象。然而,我们需要 JSON 对象,因为它必须在将数据发送到快照服务成功后返回给请求者,所以第二个 flatMap 允许我们这样做,并将其重新注入到管道中。这是 RxJava 中非常常见的模式。

运行边缘服务的 RxJava 版本与运行回调版本没有区别。我们所需做的只是将 CollectorService 的部署更改如下:

vertx.deployVerticle("chapter5.reactivex.CollectorService");

与回调版本相比,与服务交互产生相同的结果。

mapflatMap 的区别

flatMap 来自“flatten”和“map”操作符。为了更好地理解其工作原理,让我们用 JavaScript 数组来阐述 flatMap(你可以使用 node 或直接从网页浏览器控制台测试它)。

使用 let a = [1, 2, 3], a 是一个包含值 1, 2, 和 3 的数组。现在假设对于每个值,我们想要将其乘以 10100。使用 map,我们可以写出 let b = a.map(x => [x * 10, x * 100]),这将给我们一个数组的数组:[[ 10, 100 ], [ 20, 200 ], [ 30, 300 ]]

如果我们只想得到值而不是嵌套数组,这并不太方便,因此我们可以“扁平化” bb.flat(),这将给我们 [10, 100, 20, 200, 30, 300]。您可以直接使用 a.flatMap(x => [x * 10, x * 100]) 得到相同的结果。

这直接转换为其他操作,如 HTTP 客户端请求或数据库调用,因为 flatMap 避免了嵌套的可观察对象的可观察对象。

5.5 Kotlin 协程

最后要探索的异步编程模型是 Kotlin 编程语言中的 协程。(有关 Kotlin 的更多信息,请参阅 Dmitry Jemerov 和 Svetlana Isakova 的 Kotlin in Action [Manning, 2017])。

协程很有趣,因为它们在很多情况下允许我们编写看起来像常规非异步代码的异步代码。此外,Kotlin 有一个很好的协程实现,这对于 Java 开发者来说很容易理解,而且既然我们说 Vert.x 是多语言的,这本书在某些时候不得不展示没有 Java 的 Vert.x!

5.5.1 协程是什么?

术语 协程 首次出现在 Melvin Conway 在 1963 年关于 COBOL 编译器设计的论文中。4 许多语言都支持协程或某种形式的协程:Python(生成器)、C#(async/await 操作符)、Go(goroutines)以及更多。存在使用字节码插装实现的 Java,并且 Java 的未来版本将支持协程,归功于 Project Loom (openjdk.java.net/projects/loom/)。

协程可以在未来的某个时间点挂起和恢复其执行。它可以被视为一个具有多个入口和出口点的函数,其执行堆栈可以被恢复。协程非常适合异步模型,因为它们可以在需要异步操作的结果时挂起,并在结果可用时恢复。

为了使事情更具体,让我们看看在 Kotlin 中使用协程。首先,考虑以下代码。

列表 5.32 协程 hello world

import kotlinx.coroutines.*

suspend fun hello(): String {     ❶
  delay(1000)                     ❷
  return "Hello!"
}

fun main() {
  runBlocking {                   ❸
    println(hello())
  }
}

❶ 此函数可以被挂起。

❷ 此函数是挂起的,并且不会阻塞调用线程。

❸ 这允许等待协程代码完成。

delay 函数的调用不会阻塞调用线程,因为该方法可以被挂起。当时间过去后,该方法会再次被调用,并在下一行继续执行,返回一个字符串。在回调世界中,delay 函数将需要一个回调参数,该参数将不得不将返回的字符串传递给调用者,可能使用另一个回调。

这里有一个更详细的例子。

列表 5.33 协程示例

fun main() = runBlocking {
  val job1 = launch { delay(500) }                 ❶
  fun fib(n: Long): Long = if (n < 2) n else fib(n - 1) + fib(n - 2)
  val job2 = async { fib(42) }                     ❷

  job1.join()                                      ❸
  println("job1 has completed")
  println("job2 fib(42) = ${job2.await()}")        ❹
}

❶ 启动一个任务

❷ 启动一个返回值的任务

❸ 等待任务完成

❹ 在任务完成时获取值

在这个例子中,job1 使用 launch 创建,它并行执行一些代码。它等待 500 毫秒。对 job2 也适用,除了 async 是用于返回值的代码块。它计算 42 的斐波那契值,这需要一些时间。作业上的 joinawait 方法允许我们等待这些作业完成。最后但同样重要的是,main 函数被 runBlocking 调用包裹。这是因为正在调用挂起方法,所以执行必须等待所有协程完成。

我们只是对 Kotlin 和协程进行了初步了解,但这应该足以查看 Vert.x 集成。要深入了解 Kotlin,您还可以阅读 Pierre-Yves Saumont 的《Kotlin 的乐趣》(Manning,2019)。

5.5.2 Vert.x 和 Kotlin 协程

Vert.x 为 Kotlin 协程提供了一级支持。要在 Gradle 项目中使用它们,您需要以下列表中显示的依赖项和配置。

列表 5.34 Vert.x Kotlin 协程依赖项和配置摘录

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
  kotlin("jvm") version "kotlinVersion"                                    ❶
}

dependencies {
  // (...)
  implementation("io.vertx:vertx-lang-kotlin:${vertxVersion}")
  implementation("io.vertx:vertx-lang-kotlin-coroutines:${vertxVersion}")  ❷
  implementation(kotlin("stdlib-jdk8"))                                    ❸
}

val compileKotlin: KotlinCompile by tasks
compileKotlin.kotlinOptions.jvmTarget = "1.8"                              ❹
// (...)

❶ 将 kotlinVersion 替换为当前的 Kotlin 版本(它们发布得很频繁)。

❷ 将 vertxVersion 替换为当前的 Vert.x 版本。

❸ 这引入了 JDK 8 Kotlin API。

❹ 默认情况下,Kotlin 编译为 JDK 6 字节码以实现 Android 兼容性。JDK 8 字节码更好。

再次强调,协程绑定是从回调 API 生成的。惯例是,对于任何具有回调的方法,都会生成一个带有后缀 Await 的 Kotlin 挂起方法。给定

void foo(String s, Handler<AsyncResult<String>> callback)

以下方法将存在于 Kotlin 协程绑定中:

suspend fun String fooAwait(String s)

有一个名为 io.vertx.kotlin.coroutines.CoroutineVerticle 的 verticle 基类,其中 startstop 方法是挂起的,因此您可以直接从它们使用协程。通过使用 CoroutineVerticle,您还可以在 verticle 事件循环线程上而不是在工作池的线程上执行协程。默认的 Kotlin 协程是在工作池的线程上执行的。

提示:如果您用 Kotlin 编写 Vert.x 代码,您也可以直接从 Kotlin 使用 RxJava。还有一个辅助 RxKotlin 库,它使一些 RxJava API 在 Kotlin 中更加符合语言习惯。

5.5.3 使用协程的边缘服务

让我们看看使用 Kotlin 协程实现的边缘服务的一个示例。前言如下所示。

列表 5.35 协程收集服务前言

class CollectorService : CoroutineVerticle() {
  private val logger = LoggerFactory.getLogger(CollectorService::class.java)
  private lateinit var webClient: WebClient       ❶

  override suspend fun start() {
    webClient = WebClient.create(vertx)
    vertx.createHttpServer()
      .requestHandler(this::handleRequest)
      .listenAwait(8080)                          ❷
  }
  // (...)
}

lateinit 表示该字段将在构造函数中不会被初始化。

❷ 这等待 HTTP 服务器启动;否则它将抛出一个包含错误的异常。

与其他实现相比,没有太大的区别,除了start方法是挂起的,HTTP 服务器使用listenAwait启动。由于该方法调用是挂起的,执行将在 HTTP 服务器运行时恢复,该方法返回 HTTP 服务器实例,我们在这里简单地忽略它。

下一个列表展示了fetchTemperaturesendToSnapshot方法针对协程的代码。

列表 5.36 HTTP 请求和协程

private suspend fun fetchTemperature(port: Int): JsonObject {
  return webClient
    .get(port, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .`as`(BodyCodec.jsonObject())              ❶
    .sendAwait()
    .body()
}

private suspend fun sendToSnapshot(json: JsonObject) {
  webClient
    .post(4000, "localhost", "/")
    .expect(ResponsePredicate.SC_SUCCESS)
    .sendJsonAwait(json)
}

❶ “as”是 Kotlin 中的一个关键字,所以当用作方法名时必须转义。

这两种方法现在看起来更像传统的命令式代码。fetchTemperature返回一个值(一个 JSON 对象),尽管它是异步的,因为在调用sendAwait时方法中的执行被挂起。

在以下列表中,异步操作看起来并不像异步操作,这种错觉更加明显,它包含了边缘服务的核心逻辑。

列表 5.37 协程收集器 HTTP 处理器

private fun handleRequest(request: HttpServerRequest) {
  launch {
    try {
      val t1 = async { fetchTemperature(3000) }                    ❶
      val t2 = async { fetchTemperature(3001) }
      val t3 = async { fetchTemperature(3002) }

      val array = Json.array(t1.await(), t2.await(), t3.await())   ❷
      val json = json { obj("data" to array) }                     ❸

      sendToSnapshot(json)
      request.response()
        .putHeader("Content-Type", "application/json")
        .end(json.encode())

    } catch (err: Throwable) {                                     ❹
      logger.error("Something went wrong", err)
      request.response().setStatusCode(500).end()
    }
  }
}

❶ 获取每个温度是异步的。

❷ 等待所有值

❸ Vert.x 有一个小的 Kotlin DSL 来简化 JSON 对象的创建。

❹ 使用经典 try/catch 结构进行错误管理

这段代码非常自然地表达了温度是异步获取的,它们的值被收集在一个 JSON 对象中,调用快照服务,最终结果被发送给请求者。尽管如此,异步操作中仍有许多暂停点。此外,错误管理是一个熟悉的try/catch结构。

你可能已经注意到了包裹整个方法代码的launch函数调用。这是因为虽然start方法是挂起的,但 HTTP 请求处理器不是挂起函数类型,它将在 Kotlin 协程上下文之外被调用。调用launch确保创建了一个协程上下文,因此可以调用挂起方法。此外,协程自动附加到一个上下文中,确保事件在 verticle 事件循环线程上运行(归功于CoroutineVerticle的内部机制)。

注意协程并非魔法,它们的实现需要特殊的编译器和运行时库支持。Kotlin 编译器将挂起函数分割成许多函数。分割点是挂起函数调用,其余的函数最终结束在另一个函数(称为“延续”)中。然后有一个有限状态机,它确定挂起函数恢复时调用哪个分割函数。Kotlin 协程的设计提案在 GitHub 上mng.bz/Qxvj

5.6 我应该使用哪种模型?

我们刚刚介绍了三种不同的异步编程模型,这些模型通常比回调更好。对于编写 Vert.x 应用程序应该使用哪种模型,并没有明确的答案。选择哪种模型基本上取决于你试图实现什么。

这就是 Vert.x 的伟大之处:你可以用 RxJava 编写一个 verticle,因为它使该 verticle 的函数式需求代码更简单,你可以为另一个 verticle 使用 Kotlin 协程。你可以在同一应用程序中混合和匹配模型。

未来和承诺是组合异步操作的一种简单而有效的模型。它们从版本 4 开始内置到 Vert.x 核心 API 中,并提供处理异步结果的必要工具:转换值(map)、从错误中恢复(recover/otherwise)、链式(flatMap)和组合(CompositeFuture)。它们还提供了与 JDK 中的CompletionStage的互操作性。

RxJava 允许你以函数式和声明式的方式对事件流进行推理。它在错误管理和恢复方面特别强大。有操作符用于重试失败的操作、处理超时以及在出错的情况下将处理切换到另一个值或管道。然而,在构建长链(有时是嵌套的)操作符时,存在“单子地狱”的固有风险,这使得代码更难以阅读。将处理拆分为多个方法是好策略。如果你不熟悉函数式编程惯例,像zipflatMapconcatMap这样的操作符可能并不一定有意义。此外,并非所有处理都容易表示为管道,尤其是在涉及条件分支的情况下。

Kotlin 协程的优势在于生成的代码看起来不像异步代码。简单的错误管理案例可以用熟悉的try/catch块来表示。尽管这里没有提到,但 Kotlin 协程支持类似于 Go 编程语言的通道和选择器,这允许协程之间的消息传递。然而,更复杂的错误管理,如重试,需要手动表示。最后但同样重要的是,了解协程和异步编程的工作方式仍然很重要。

再次强调,没有明确的答案,因为所有模型都有其优缺点,但凭借你自己的经验和偏好,你可能会认识到在特定情况下应该使用哪种模型。在本书的剩余部分,我们将根据所编写的示例使用不同的模型,但这并不意味着你不能用你偏好的模型重写它们。

摘要

  • 当涉及到组合异步操作时,回调存在表达力的限制,并且如果不加以适当注意,它们可能会使代码更难以理解。

  • 并行和顺序异步操作可以使用其他异步编程模型进行组合:未来和承诺、响应式扩展和协程。

  • 响应式扩展有一套丰富的可组合操作符,并且它们特别适合于事件流。

  • 未来和承诺非常适合简单链式异步操作。

  • Kotlin 协程提供了对异步操作的语言级别支持,这是另一个有趣的选择。

  • 没有普遍适用的异步编程模型,因为它们各自都有它们偏好的用例。Vert.x 的好处是你可以根据你的问题域混合和匹配这些模型。


1.B. Liskov 和 L. Shrira, “承诺:在分布式系统中对高效异步过程调用的语言支持,” 在 R.L. Wexelblat 编,ACM SIGPLAN 1988 年程序设计语言设计和实现会议(PLDI’88)论文集,第 260-267 页 (ACM, 1988).

2.Erich Gamma, Richard Helm, Ralph Johnson, 和 John Vlissides, 设计模式:可重用面向对象软件的元素 (Addison-Wesley Professional, 1995).

3.Erik Meijer, “你的鼠标是一个数据库,” Queue 10, 3 (三月 2012), mng.bz/v96M.

4.Melvin E. Conway, “可分离转换图编译器的设计,” Communications of the ACM 6, 7 (七月 1963), 396-408, mng.bz/4B4V.

6 超越事件总线

本章涵盖

  • 如何在事件总线之上暴露服务

  • 对 verticles 和事件总线服务的异步测试

事件总线是 Vert.x 中表达事件处理的基本工具,但还有更多!事件总线服务对于暴露类型化接口而不是普通消息很有用,尤其是在预期事件总线目的地有多个消息类型时。测试也是一个重要的概念,我们将探讨与传统的测试相比,异步 Vert.x 代码测试的不同之处。

在本章中,我们将回顾一个早期的示例,将其重构为事件总线服务,并进行测试。

6.1 使用服务 API 回顾热传感器

在第三章中,我们使用热传感器作为示例。我们有一个SensorData verticle,它保存每个传感器的最后观察值,并使用事件总线上的请求/回复通信计算平均值。以下列表显示了用于计算温度平均值的代码。

列表 6.1 基于事件总线的事件平均计算 API

private void average(Message<JsonObject> message) {        ❶
  double avg = lastValues.values().stream()
    .collect(Collectors.averagingDouble(Double::doubleValue));
  JsonObject json = new JsonObject().put("average", avg);
  message.reply(json);                                     ❷
}

❶ 我们从事件总线接收事件。

❷ 我们回复事件。

此代码与 Vert.x 事件总线 API 紧密耦合,因为它需要接收消息并回复它。任何愿意调用average的软件组件都必须通过事件总线发送消息并期待响应。

但如果我们能够有一个带有可调用方法的常规 Java 接口,而不是必须通过事件总线发送和接收消息呢?下一条列表中提出的接口将完全与事件总线无关。

列表 6.2 热传感器 API 作为 Java 接口

public interface SensorDataService {
  void valueFor(String sensorId, Handler<AsyncResult<JsonObject>>     ❶
  void average(Handler<AsyncResult<JsonObject>> handler);             ❷
}

❶ 异步请求传感器值。

❷ 异步请求平均值。

提出的接口具有带有尾随回调参数的方法,因此调用者将异步地收到响应和错误的通知。Handler<AsyncResult<T>>类型在 Vert.x API 中常用于回调,其中T可以是任何类型,但通常是 JSON 类型。

列表 6.2 的界面是我们通过事件总线服务所追求的目标。让我们修改热传感器示例,将事件总线交互替换为SensorDataService类型的 Java 接口。

6.2 RPCs(远程过程调用)的回归

你可能已经熟悉远程过程调用,这是分布式计算中的一种流行抽象。1 RPCs 是在调用运行在另一台机器(服务器)上的函数时引入的,以隐藏网络通信。想法是本地函数充当代理,通过网络将带有调用参数的消息发送到服务器,然后服务器调用真实函数。然后,响应被发送回代理,客户端有一种调用常规、本地函数的错觉。

Vert.x 事件总线服务是一种异步 RPC形式:

  • 服务封装了一组操作,如列表 6.2 中的SensorDataService

  • 服务通过常规 Java API 描述,其中包含公开操作的方法。

  • 请求者或实现者都不需要直接处理事件总线消息。

图 6.1 展示了调用 SensorDataService 接口 average 方法时涉及的各个组件。客户端代码在服务代理上调用 average 方法。这是一个实现了 SensorDataService 接口的对象,然后向事件总线上的 sensor.data-service 目标(这可以配置)发送消息。消息正文包含方法调用参数值,因为 average 只接受回调,所以正文为空。消息还有一个 action 头,指示正在调用哪个方法。

图 6.1 服务代理的工作方式

代理处理器监听 sensor.data-service 目标,并根据消息的动作头和正文分发方法调用。这里使用实际的 SensorDataService 实现并调用 average 方法。然后,代理处理器通过 average 方法回调传递的值回复事件总线消息。反过来,客户端通过服务代理接收回复,该代理将回复传递给客户端调用上的回调。

这种模型可以简化处理事件总线,尤其是在需要公开许多操作时。因此,将 Java 接口定义为 API 而不是手动处理消息是有意义的。

6.3 定义服务接口

列表 6.2 包含了我们想要的 SensorDataService 接口,但还需要添加一些代码。为了开发事件总线服务,你需要

  • 编写一个遵守一些约定的 Java 接口

  • 编写实现

Vert.x 不依赖于运行时的字节码工程或反射进行魔法操作,因此需要编写和编译服务代理和处理器。幸运的是,Vert.x 提供了代码生成器,因此你将在编译时生成服务代理和处理器,而不是自己编写。

完整的 SensorDataService 接口在以下列表中详细说明。

列表 6.3 传感器数据服务

@ProxyGen                                                                  ❶
public interface SensorDataService {

  static SensorDataService create(Vertx vertx) {                           ❷
    return new SensorDataServiceImpl(vertx);
  }

  static SensorDataService createProxy(Vertx vertx, String address) {      ❸
    return new SensorDataServiceVertxEBProxy(vertx, address);
  }

  void valueFor(String sensorId, Handler<AsyncResult<JsonObject>> handler);❹

  void average(Handler<AsyncResult<JsonObject>> handler);                  ❺
}

❶ 此注解用于生成事件总线代理。

❷ 创建服务实例的工厂方法

❸ 创建代理的工厂方法

❹ 带参数和回调的操作

❺ 不带参数和回调的操作

使用 @ProxyGen 注解标记事件总线服务接口,以便生成代理代码。

你还需要定义一个 package-info.java 文件,并使用 @ModuleGen 注解来标记包定义以启用注解处理器,如以下列表所示。

列表 6.4 包信息文件和启用代码生成

@ModuleGen(groupPackage = "chapter6", name = "chapter6")     ❶
package chapter6;

import io.vertx.codegen.annotations.ModuleGen;

❶ 启用具有代码生成的模块。

服务接口中的方法需要遵循一些约定,特别是最后一个参数必须是回调。你可能会倾向于使用返回值而不是回调,但请记住,我们正在处理异步操作,所以我们需要回调!对于服务接口来说,既有服务实现(create)又有代理(createProxy)的工厂方法是惯用的。这些方法大大简化了获取代理或发布服务的代码。

SensorDataServiceVertxEBProxy类是由 Vert.x 代码生成器生成的,如果你查看它,你会看到事件总线操作。还有一个名为SensorDataServiceVertxProxyHandler的类也被生成,但只有 Vert.x 会使用它,而不是你的代码。

让我们现在看看SensorDataServiceImpl类中的实际服务实现。

6.4 服务实现

以下服务实现是第三章代码的直接改编。

列表 6.5 SensorDataService的实现

class SensorDataServiceImpl implements SensorDataService {

  private final HashMap<String, Double> lastValues = new HashMap<>();

  SensorDataServiceImpl(Vertx vertx) {                                    ❶
    vertx.eventBus().<JsonObject>consumer("sensor.updates", message -> {  ❷
      JsonObject json = message.body();
      lastValues.put(json.getString("id"), json.getDouble("temp"));
    });
  }

  @Override
  public void valueFor(String sensorId, Handler<AsyncResult<JsonObject>> 
  ➥ handler) {
    if (lastValues.containsKey(sensorId)) {
      JsonObject data = new JsonObject()
        .put("sensorId", sensorId)
        .put("value", lastValues.get(sensorId));
      handler.handle(Future.succeededFuture(data));                       ❸
    } else {
      handler.handle(Future.failedFuture("No value has been observed for " + 
      ➥ sensorId));
    }
  }

  @Override
  public void average(Handler<AsyncResult<JsonObject>> handler) {
    double avg = lastValues.values().stream()
      .collect(Collectors.averagingDouble(Double::doubleValue));
    JsonObject data = new JsonObject().put("average", avg);
    handler.handle(Future.succeededFuture(data));
  }
}

❶ 我们传递 Vert.x 上下文。

❷ 要接收传感器更新的通知,我们仍然需要订阅事件总线。

❸ 我们不传递回复的消息,而是使用异步结果。

与第三章的代码相比,我们主要用通过完成的未来对象传递异步结果来替换了事件总线代码。这段代码也不包含对服务代理处理程序代码的引用,该代码正在生成中。

提示 列表 6.5 中的代码没有异步操作。在更复杂的服务中,你很快就会遇到向数据库、HTTP 服务、消息代理或甚至通过事件总线到另一个服务的异步调用。一旦你有一个响应准备好,你将结果或错误传递给方法回调,就像我们在SensorDataServiceImpl中做的那样。

6.5 启用代理代码生成

服务代理生成是在编译时使用javacapt注解处理完成的。需要两个 Vert.x 模块:vertx-service-proxyvertx-codegen

要使 Vert.x 代码生成与 Gradle 中的注解处理一起工作,你需要一个类似于以下配置的配置。

列表 6.6 代码生成的 Gradle 配置

dependencies {
  implementation("io.vertx:vertx-core:$version")
  implementation("io.vertx:vertx-codegen:$version")
  implementation("io.vertx:vertx-service-proxy:$version")

  annotationProcessor("io.vertx:vertx-service-proxy:$version")        ❶
  annotationProcessor("io.vertx:vertx-codegen:$version:processor")
  // (...)
}

tasks.getByName<JavaCompile>("compileJava") {
  options.annotationProcessorGeneratedSourcesDirectory = 
  ➥ File("$projectDir/src/main/generated")                           ❷
}

❶ 这是注解处理的范围。

❷ 这允许你自定义文件生成的位置。

现在每当 Java 类被编译时,都会生成代理类。你可以在项目的 src/main/generated 文件夹中看到这些文件。

如果你查看 SensorDataServiceVertxProxyHandler 的代码,你会在 handle 方法中看到一个 switch 块,其中使用 action 报头将方法调用调度到服务实现方法。同样,在 SensorDataServiceVertxEBProxyaverage 方法中,你会看到发送消息通过事件总线调用该方法的代码。SensorDataServiceVertxProxyHandlerSensorDataServiceVertxEBProxy 的代码实际上是你必须编写的,如果你必须实现自己的事件总线服务系统。

6.6 部署事件总线服务

事件总线服务需要部署到 verticles,并且需要定义事件总线地址。以下列表显示了如何部署服务。

列表 6.7 部署服务

public class DataVerticle extends AbstractVerticle {

  @Override
  public void start() {
    new ServiceBinder(vertx)                                               ❶
      .setAddress("sensor.data-service")                                   ❷
      .register(SensorDataService.class, SensorDataService.create(vertx)); ❸
  }
}

❶ 将服务绑定到地址

❷ 服务的事件总线地址

❸ 我们公开了一个服务实现。

部署就像绑定到地址并传递服务实现一样简单。我们可以使用 SensorDataService 接口的 create 工厂方法来做这件事。

你可以在一个 verticle 上部署多个服务。部署功能相关的事件总线服务是有意义的,这样 verticle 就是一个连贯的事件处理单元。

通过调用相应的工厂方法并传递正确的事件总线目标,可以获取服务代理以发出方法调用,如下所示。

列表 6.8 获取服务代理

SensorDataService service = SensorDataService
  .createProxy(vertx, "sensor.data-service");

service.average(ar -> {
  if (ar.succeeded()) {
    System.out.println("Average = " + ar.result());
  } else {
    ar.cause().printStackTrace();
  }
});

服务接口遵循回调模型,因为这是对(异步)服务接口的规范定义。

6.7 超越回调的服务代理

在上一章中,我们探讨了除了回调之外的其他异步编程模型,但我们设计了具有回调的事件总线服务。好消息是,你可以利用代码生成来获取,比如说,RxJava 或 Kotlin 协程版本的服务代理。甚至更好的是,你不需要做很多额外的工作!

要使这生效,你需要将 @VertxGen 注解添加到你的服务接口中,如下所示。

列表 6.9 将 @VertxGen 添加到服务接口

@ProxyGen             ❶
@VertxGen             ❷
public interface SensorDataService {
  // (...)
}

❶ 生成服务代理

❷ 允许代码生成

当这个注解存在时,Vert.x Java 注解处理器将启用代码生成,所有合适的代码生成器在构建时都可用。

要生成 RxJava 绑定,我们需要添加以下列表中的依赖项。

列表 6.10 RxJava 代码生成的依赖项

dependencies {
  // (...)
  implementation("io.vertx:vertx-rx-java2:$version")             ❶
  annotationProcessor("io.vertx:vertx-rx-java2-gen:$version")    ❷
}

❶ Vert.x RxJava 2 模块

❷ RxJava 2 Vert.x 代码生成器

当我们编译项目时,会生成一个 chapter6.reactivex.SensorDataService 类。这是一个小的适配器,它将原始的回调 API 桥接到 RxJava。该类包含来自原始 SensorDataService API 的所有方法(包括 create 工厂方法),以及以 rx 前缀的方法。

给定接受回调的average方法,RxJava 代码生成器创建了一个没有参数的rxAverage方法,它返回一个Single对象。同样,valueFor被翻译为rxValueFor,这是一个接受String参数(传感器标识符)并返回Single对象的方法。

下一个列表展示了生成 RxJava API 的示例用法。

列表 6.11 使用SensorDataService的 RxJava 变体

SensorDataService service = SensorDataService
  .createProxy(vertx, "sensor.data-service");      ❶

service.rxAverage()                                ❷
  .delaySubscription(3, TimeUnit.SECONDS, RxHelper.scheduler(vertx))
  .repeat()
  .map(data -> "avg = " + data.getDouble("average"))
  .subscribe(System.out::println);

❶ chapter6.reactivex.SensorDataService 的一个实例

rxAverage() 返回一个 Single<JsonObject>

这里创建的 RxJava 管道每三秒创建一个新的订阅,并将平均值提取为字符串,然后显示在标准输出上。

注意:你必须始终使用回调 API 为接口和实现开发事件总线服务。然后代码生成器将其转换为其他模型。

现在你已经知道了如何开发事件总线服务,让我们转向测试 verticle 和服务的主题。

6.8 测试与 Vert.x

自动化测试在设计软件时至关重要,Vert.x 应用程序也需要进行测试。在测试 Vert.x 代码时,主要困难在于操作的异步性。除此之外,测试是经典的:它们有一个设置阶段和一个测试执行及验证阶段,随后是清理阶段。

由于事件总线,verticle 与其他系统部分相对隔离,这在测试环境中非常有用:

  • 事件总线允许你向 verticle 发送事件,使其处于所需状态,并观察它产生的事件。

  • 当 verticle 部署时传递给它的配置允许你为以测试为中心的环境调整一些参数(例如,使用内存数据库)。

  • 可以部署具有可控行为的mock verticle 来替代具有许多依赖关系的 verticle(例如,数据库、连接到其他 verticle 等)。

因此,无论测试的 verticle 是在同一 JVM 内部署还是在集群模式下,测试 verticle 更多的是集成测试而不是单元测试。我们需要将 verticle 视为不透明的盒子,通过事件总线与之通信,并可能通过连接到 verticle 公开的网络协议。例如,当一个 verticle 公开 HTTP 服务时,我们可能会在测试中发出 HTTP 请求来检查其行为。

在这本书中,我们将只关注测试的 Vert.x 特定方面。如果你在更广泛的测试主题方面缺乏经验,我建议阅读 Lasse Koskela(Manning,2013 年)所著的《Effective Unit Testing》一书。

6.8.1 使用 JUnit 5 与 Vert.x 结合

Vert.x 支持经典的 JUnit 4 测试框架以及更近期的 JUnit 5。Vert.x 提供了一个名为 vertx-junit5 的模块,支持 JUnit 框架的 5 版本(junit.org/junit5/)。要在 Vert.x 项目中使用它,你需要添加 io.vertx:vertx-junit5 依赖项,以及可能的一些 JUnit 5 库。

在 Gradle 项目中,需要更新 dependencies 部分,如下所示。

列表 6.12 使用 JUnit 5 和 Vert.x 进行 Gradle 构建

dependencies {
  // (...)
  testCompile("org.junit.jupiter:junit-jupiter-api:$junit5Version")        ❶
  testCompile("io.vertx:vertx-junit5:$vertxVersion")                       ❷
  testCompile("org.assertj:assertj-core:3.11.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") ❸
}

tasks.named<Test>("test") {
  useJUnitPlatform()                                                       ❹
}

❶ JUnit 5 API 将 $junit5Version 替换为当前的 JUnit 5 版本。

❷ Vert.x JUnit 5 支持库

❸ 这被 Gradle 用于运行测试。

❹ 这使得 JUnit 5 在 Gradle 中得到支持。

vertx-junit5 库已经依赖了 junit-jupiter-api,但在构建中固定版本是一个好的实践。junit-jupiter-engine 模块需要在 Gradle 的 testRuntime 范围内。最后,JUnit 5 可以与任何断言 API 一起使用,包括其内置的 API,AssertJ 是其中之一。

6.8.2 测试 DataVerticle

我们需要两个测试用例来检查 DataVerticle 的行为,以及由此扩展的 SensorDataService 的行为:

  • 当没有传感器时,平均值应该是 0,并且请求任何传感器标识符的值必须引发错误。

  • 当有传感器时,我们需要检查平均值和单个传感器的值。

图 6.2 显示了测试环境的交互。测试用例有一个代理引用来调用 SensorDataService。实际的 DataVerticle verticle 在 sensor.data-service 目的地部署。它可以从测试中发出 valueForaverage 方法调用。由于 DataVerticle 在事件总线接收来自传感器的消息,我们可以发送任意消息,而不是部署我们无法控制的实际 HeatSensor verticle。模拟 verticle 通常就像发送它可能会发送的消息类型一样简单。

图片

图 6.2 隔离 SensorDataService

下面的列表显示了测试类的前言。

列表 6.13 SensorDataServiceTest 的前言

@ExtendWith(VertxExtension.class)                                     ❶
class SensorDataServiceTest {

  private SensorDataService dataService;                              ❷

  @BeforeEach                                                         ❸
  void prepare(Vertx vertx, VertxTestContext ctx) {
    vertx.deployVerticle(new DataVerticle(), ctx.succeeding(id -> {   ❹
      dataService = SensorDataService.createProxy(vertx,
        "sensor.data-service");                                       ❺
      ctx.completeNow();                                              ❻
    }));
  }
// (,,,)

❶ Vert.x 的 JUnit 5 扩展

❷ 我们的代理引用

❸ 设置方法,在每个测试之前执行

❹ 我们部署了一个内部公开服务的 verticle,并期望部署成功(成功)。

❺ 我们获得一个代理引用。

❻ 我们通知设置已完成。

JUnit 5 支持扩展以提供额外的功能。特别是,扩展可以将参数注入测试方法中,并且它们可以拦截生命周期事件,如测试方法调用之前和之后。VertxExtension 类通过以下方式简化了测试用例的编写:

  • 注入带有默认配置的 Vertx 的可使用实例

  • 注入 VertxTestContext 对象以处理 Vert.x 代码的异步特性

  • 确保等待 VertxTestContext 成功或失败

prepare方法在每次测试用例之前执行,以准备测试环境。我们在这里用它来部署DataVerticle垂直组件,然后获取服务代理并将其存储在dataService字段中。由于部署垂直组件是一个异步操作,prepare方法注入了一个Vertx上下文和一个VertxTestContext对象来通知其完成。为了避免测试永远等待,有一个超时(默认为 30 秒)。

提示 JUnit 5 之前版本的用户可能会对类和测试方法是包私有感到惊讶;这与 JUnit 5 的惯例相符。

你可以在以下列表中看到当没有部署传感器时的第一个测试用例。

列表 6.14 无传感器的测试用例

@Test
void noSensor(VertxTestContext ctx) {                                 ❶
  Checkpoint failsToGet = ctx.checkpoint();                           ❷
  Checkpoint zeroAvg = ctx.checkpoint();

  dataService.valueFor("abc", ctx.failing(err -> ctx.verify(() -> {   ❸
    assertThat(err.getMessage()).startsWith("No value has been observed");
    failsToGet.flag();
  })));

  dataService.average(ctx.succeeding(data -> ctx.verify(() -> {
    double avg = data.getDouble("average");
    assertThat(avg).isCloseTo(0.0d, withPercentage(1.0d));
    zeroAvg.flag();
  })));
}

❶ VertxTestContext 允许你在测试中处理异步操作,以报告成功和失败。

❷ 检查点主要用于确保异步操作在特定行通过。

❸ 失败是 Handler的帮助者,verify 封装了断言。

这个测试用例假设没有部署任何传感器,因此尝试获取任何传感器值都必须失败。我们通过查找不存在于传感器abc的温度值来检查这种行为。然后我们检查平均值是否为 0。

检查点被标记以标记测试执行达到了某些行。当所有声明的检查点都已标记时,测试成功完成。当断言失败、抛出意外异常或(可配置的)延迟过期且未标记所有检查点时,测试失败。

为什么异步测试是不同的

测试异步操作与你可能熟悉的常规测试略有不同。测试执行中的默认契约是测试运行器线程调用测试方法,当抛出异常时它们会失败。断言方法抛出异常来报告错误。

由于deployVerticlesend等操作是异步的,测试运行器线程在它们有机会完成之前就退出了方法。VertxExtension类通过等待VertxTestContext报告成功或失败来处理这个问题。为了避免测试永远等待,有一个超时(默认为 30 秒)。

最后,我们有一个当有传感器时的测试用例。

列表 6.15 有传感器的测试用例

@Test
void withSensors(Vertx vertx, VertxTestContext ctx) {
  Checkpoint getValue = ctx.checkpoint();
  Checkpoint goodAvg = ctx.checkpoint();

  JsonObject m1 = new JsonObject().put("id", "abc").put("temp", 21.0d);  ❶
  JsonObject m2 = new JsonObject().put("id", "def").put("temp", 23.0d);

  vertx.eventBus()                                                       ❷
    .publish("sensor.updates", m1)
    .publish("sensor.updates", m2);

  dataService.valueFor("abc", ctx.succeeding(data -> ctx.verify(() -> {
    assertThat(data.getString("sensorId")).isEqualTo("abc");
    assertThat(data.getDouble("value")).isEqualTo(21.0d);
    getValue.flag();
  })));

  dataService.average(ctx.succeeding(data -> ctx.verify(() -> {
    assertThat(data.getDouble("average")).isCloseTo(22.0, 
    ➥ withPercentage(1.0d));                                            ❸
    goodAvg.flag();
  })));
}

❶ 模拟传感器的消息

❷ 我们发送消息。

❸ AssertJ 为具有误差范围的浮点值提供了断言。

这个测试通过在事件总线发送伪造的传感器数据更新来模拟具有标识符abcdef的两个传感器,就像传感器会做的那样。然后我们在断言中有了确定性,可以检查valueForaverage方法的行为。

6.8.3 运行测试

你可以从你的 IDE 运行测试。你也可以使用 Gradle 运行它们:gradlew test

Gradle 在 build/reports/tests/test/index.html 生成一个可读的测试报告。当你用网页浏览器打开文件时,你可以检查所有测试是否通过,如图 6.3 所示。

图 6.3 测试报告

注意,Gradle 的test任务是build任务的依赖,因此当项目完全构建时,测试总是会被执行。

摘要

  • 事件总线服务和代理通过提供异步服务接口来抽象事件总线通信。

  • 对于事件总线服务,除了回调之外,还可以生成其他类型的绑定:RxJava、Kotlin 协程等。

  • 测试异步代码和服务比传统的命令式情况更具挑战性,Vert.x 为 JUnit 5 提供了专门的支持。


  1. 布鲁斯·杰伊·尼尔森,“远程过程调用”,博士论文,卡内基梅隆大学,宾夕法尼亚州匹兹堡,美国。AAI8204168。

第二部分:使用 Vert.x 开发响应式服务

恭喜你,现在你已经熟悉了使用 Vert.x 和其核心 API 的异步编程!现在是时候深入探索 Vert.x 堆栈的主要组件,并研究用于构建高级 HTTP API 和使用数据库、身份验证、消息传递等模块了。

我们将在这些章节中,在多个基于事件驱动的微服务之上开发一个响应式应用程序。第十二章是我们将使响应式成为现实的地方:在前面的章节中,我们编写了可伸缩的服务,但我们还需要确保应用程序即使在发生故障时也能保持弹性和响应性。你将接触到一种使用负载和混沌测试来观察服务行为的方法,我们还将讨论不同的故障缓解策略。

最后但同样重要的是,我们将通过部署到容器和 Kubernetes 集群来结束本书。

7 设计一个反应性应用程序

本章涵盖

  • 反应性应用程序是什么

  • 介绍在第二部分中使用的反应性应用程序场景

本书的第一部分教你使用 Vert.x 进行 异步编程。这是编写可伸缩和资源高效应用程序的关键。

现在是时候探索是什么使一个应用程序 具有反应性,因为我们追求的是 可伸缩性可靠性。为了做到这一点,我们将以下章节的重点放在从几个事件驱动的微服务中开发一个完全反应性应用程序上。在本章中,我们将指定这些服务。

7.1 什么是使应用程序具有反应性的因素

在前面的章节中,我们介绍了一些 reactive 的元素:

  • 压力反馈,作为异步流处理中调节事件吞吐量的必要成分

  • 反应式编程作为一种组合异步操作的方法

现在是时候探索最后一个方面:反应性应用程序。在第一章中,我总结了 反应性宣言,它宣称反应性应用程序是 响应性弹性可伸缩性消息驱动 的。

反应性应用程序的关键特性是它们在面临繁重的工作负载和其他服务的故障时仍然保持 响应性。通过“响应性”,我们指的是服务响应的延迟保持可控。一个良好的响应性例子是一个在 99%的百分位数内响应时间在 500 毫秒内的服务,前提是考虑到服务的功能需求和操作限制,500 毫秒是一个合理的数字。

随着工作负载的增加,几乎总是会导致延迟降低,但在反应性应用程序的情况下,目标是避免在服务受到压力时发生延迟爆炸。本书的第一部分主要教你使用 Vert.x 进行异步编程,这是应对增长工作负载的关键成分。你看到异步处理事件允许你在单个线程上复用数千个打开的网络连接。这种模型(如果正确实现!)比传统的“每个连接一个线程”模型更加资源友好和可伸缩。

所以 Vert.x 为我们在 JVM 上进行异步编程提供了一个基础,以满足繁重的工作负载,但如何处理故障呢?这是我们必须要面对的另一个核心挑战,而且答案不是我们可以从货架上拿出的魔法工具。假设我们有一个与数据库通信的服务,由于内部问题(如死锁)变得无响应。在服务被通知错误之前,会有一些时间流逝,可能以 TCP 连接超时的方式出现。在这种情况下,延迟会爆炸。相比之下,如果数据库宕机,我们会立即收到 TCP 连接错误:延迟非常好,但由于服务无法与其数据库通信,它无法处理请求。

你将在本部分的最后一章中看到如何进行“当事情出错时会发生什么”的实验,我们将讨论保持服务响应性的可能解决方案。你可能倾向于对所有调用其他服务的调用(包括数据库)实施严格的超时,或者在每个地方使用断路器(更多内容将在最后一章中介绍),但更分析性的方法将帮助你看到如果需要,应该使用哪种解决方案,以及何时使用。同时,也很重要从服务功能需求和应用程序领域来审视失败:对失败的响应可能并不总是错误。例如,如果我们无法从传感器获取最新的温度更新,我们可能提供最后已知值并附上时间戳,以便请求者有所有必要的数据上下文。

现在是时候构建一个响应式应用程序了,这不仅是为了探索 Vert.x 堆栈的一些元素,也是为了学习如何具体构建响应式应用程序。

7.2 10k 步挑战场景

我们将在接下来的章节中实现的应用程序支持一个(并非那么)虚构的健身追踪器挑战。假设我们想要构建一个应用程序来跟踪和评分用户的步数,如图 7.1 所示。

图 7.1 10k 步挑战应用程序和参与者概述

图 7.1 中描述的应用程序将按以下方式工作:

  • 用户佩戴连接的计步器,以跟踪他们走了多少步。

  • 计步器定期向管理挑战的应用程序发送步数更新。

  • 目标是每天至少走 10,000 步,当用户这样做时,每天都会收到一封电子邮件。

  • 用户可以选择在过去的 24 小时内公开列出步数排名。

  • 参与者还可以连接到网络应用程序来查看他们的数据和更新他们的信息,例如他们的城市以及他们是否希望出现在公共排名中。

网络应用程序允许新用户通过提供他们的设备标识符以及一些基本信息(如他们的城市以及他们是否希望出现在公共排名中)来注册(图 7.2)。

图 7.2 用户网络应用程序注册表单截图

连接后,用户可以更新一些基本详情,并提醒他们总步数、月步数和日步数(图 7.3)。

图 7.3 用户网络应用程序用户详情页截图

此外,还有一个独立的网络应用程序,提供公共仪表板(图 7.4)。

图 7.4 公共仪表板网络应用程序截图

仪表板提供了过去 24 小时内公共个人资料的排名、当前计步器设备更新吞吐量以及按城市划分的趋势。仪表板中显示的所有信息都是实时更新的。

7.3 一个应用程序,多种服务

应用程序被分解为一系列(微)服务,它们如图 7.5 所示相互交互。每个服务完成单一的功能目的,并且可能被另一个应用程序使用。有四个公共服务:两个面向用户的 Web 应用程序、一个用于接收计步器设备更新的服务以及一个用于公开 HTTP API 的服务。公共 API 被用户 Web 应用程序使用,同样,移动应用程序也可以连接到它。有四个内部服务:一个用于管理用户配置文件、一个用于管理活动数据、一个用于通过电子邮件祝贺用户,以及一个用于计算连续事件的各种统计数据。

图 7.5

图 7.5 应用程序架构概述

注意:你可能听说过命令查询责任分离(CQRS)和事件溯源,这些是事件驱动架构中发现的模式。1 CQRS 定义了如何读取和写入信息,而事件溯源则是将应用程序状态作为一系列事实来体现。我们提出的应用架构与这两个概念都相关,但由于它并不严格符合定义,我更喜欢将其称为“事件驱动微服务架构”。

所有服务都由 Vert.x 提供支持,我们还需要一些第三方中间件,如图 7.5 中标记的“基础设施服务”。我们将使用两种不同类型的数据库:一种文档型数据库(MongoDB)和一种关系型数据库(PostgreSQL)。我们需要一个 SMTP 服务器来发送电子邮件,Apache Kafka 用于某些服务之间的事件流处理。由于摄入服务可能从 HTTP 和 AMQP 接收更新,我们还将使用 ActiveMQ Artemis 服务器。

图 7.5 中有两种类型的箭头。事件流显示了服务之间的重要事件交换。例如,摄入服务向 Kafka 发送事件,而事件统计服务既消费又生产 Kafka 事件。我还指出了依赖关系:例如,公共 API 服务依赖于用户配置文件和活动服务,而这些服务又依赖于它们自己的数据库以实现数据持久化。

我们可以通过观察设备更新如何影响仪表板 Web 应用的“城市趋势排名”,如图 7.6 所示,来展示服务之间交互的一个例子。

图 7.6

图 7.6 从设备更新到城市趋势更新

一切始于步数计向摄入服务发送更新,该服务验证更新是否包含所有所需数据。摄入服务然后将更新发送到 Kafka 主题,步数计设备得到确认,以便它知道更新已被接收并将被处理。更新将由多个在该特定 Kafka 主题上监听的消费者处理,其中之一是活动服务。该服务将数据记录到 PostgreSQL 数据库,然后向另一个 Kafka 主题发布一个记录,记录步数计当天记录的步数。此记录被事件统计服务拾取,该服务观察五个秒窗口内的更新,按城市分割它们,并汇总步数。然后,它发布一个更新,其中包含观察到的特定城市的步数增量作为另一个 Kafka 记录。然后,该记录被仪表板 Web 应用程序消费,最终向所有连接的 Web 浏览器发送更新,这些浏览器随后更新显示。

关于应用架构

当你深入研究服务的规范和实现时,你可能会发现分解有时有点人为。例如,用户配置文件和活动服务完全可以合并为一个,从而节省一些从两个服务中合并数据的请求。记住,分解是为了教学目的,并展示 Vert.x 堆栈的相关元素。

从(微)服务构建应用程序需要一些妥协,特别是当一些服务可能是预存在的,你必须按其现状处理它们,或者你有限的能力去进化它们。

你还可能会发现,提出的架构不是一个很好地分层的一个,有些服务很好地解耦,而有些其他服务对其他服务的依赖性更强。再次强调,这是出于教学目的故意为之。在大多数情况下,现实世界中的应用程序必须做出妥协,以交付可工作的软件,而不是追求架构的完美。

7.4 服务规范

让我们讨论应用服务的功能和技术规范。对于每个服务,我们将考虑以下要素:

  • 功能概述

  • 如果有的话,API 描述

  • 技术要点,包括崩溃恢复

  • 规模化和部署考虑因素

7.4.1 用户配置文件服务

用户配置文件服务管理唯一用户的配置文件数据。用户通过以下信息识别:

  • 一个用户名(必须是唯一的)

  • 一个密码

  • 一个电子邮件地址

  • 一个城市

  • 步数计设备标识符(必须是唯一的)

  • 用户是否希望出现在公共排名中

该服务公开一个 HTTP API,并将数据持久化在 MongoDB 数据库中(见图 7.7)。

图片

图 7.7 用户配置文件服务

此服务属于 CRUD(用于 创建读取更新删除)服务的类别,这些服务位于数据库之上。表 7.1 识别了 HTTP API 的不同元素。

表 7.1 用户配置文件 HTTP API

目的 路径 方法 数据 响应 状态码
注册新用户 /register POST 注册 JSON 文档 N/A 成功时返回 200,当用户名或设备标识符已存在时返回 409,技术错误时返回 500
获取用户详细信息 /<username> GET N/A JSON 格式的用户数据 成功时返回 200,用户名不存在时返回 404,技术错误时返回 500
更新某些用户详细信息 /<username> PUT JSON 格式的用户数据 N/A 成功时返回 200,技术错误时返回 500
凭据验证 /authenticate POST JSON 格式的凭据 N/A 成功时返回 200,认证失败时返回 401
通过设备查找用户 /owns/<deviceId> GET N/A 包含拥有设备用户名的 JSON 数据 成功时返回 200,设备不存在时返回 404,技术错误时返回 500

此服务不应公开暴露;它旨在由其他服务消费。没有设置认证机制。此服务旨在提供对数据库操作的伪装。服务和数据库都可以独立扩展。

注意:表 7.1 中描述的 API 不遵循 表示状态转换(REST)接口的架构原则。一个 RESTful 接口会暴露用户资源,例如 /user/<username>,而不是通过 /register 资源上的 POST 请求注册新用户。忠实于 REST 结构和更自由的 HTTP API 结构都是有效的选择。

7.4.2 数据摄取服务

数据摄取服务收集计步器设备更新,并将带有更新数据的记录转发到 Kafka 流,以便其他服务处理事件。服务从 HTTP API 或 AMQP 队列接收设备更新,如图 7.8 所示。该服务是一种 协议适配器调解者,因为它将一个协议(HTTP 或 AMQP)的事件转换为另一个协议(Kafka 记录流)。

图片

图 7.8 数据摄取服务

设备更新是一个包含以下条目的 JSON 文档:

  • 设备标识符

  • 一个同步标识符,它是一个单调递增的长整数,设备在每次成功同步时更新

  • 自上次同步以来的步数

HTTP API 支持单个操作,如表 7.2 所示。

表 7.2 数据摄取服务 HTTP API

目的 路径 方法 数据 响应 状态码
摄入计步器更新 /ingest POST JSON 文档 N/A 成功时返回 200,技术错误时返回 500

AMQP 客户端从 step-events 地址接收消息。HTTP API 和 AMQP 客户端中的 JSON 数据相同。

该服务旨在公开暴露,以便它可以接收计步器更新。我们假设将使用某些反向代理,提供加密和访问控制。例如,通过 HTTPS 的设备更新可以使用客户端证书检查来过滤掉未经授权或未打补丁的设备。

AMQP 和 HTTP 客户端只有在记录已写入 Kafka 后才会收到确认。在 HTTP 的情况下,这意味着设备只有在收到 HTTP 200 响应后才能认为同步成功。服务不会检查重复项,因此设备可以安全地认为摄取操作是幂等的。正如你将看到的,保持数据一致性的角色是活动服务,而不是摄取服务。

该服务可以独立于 AMQP 和 Kafka 服务器/集群进行扩展。如果服务在做出某种形式的确认之前崩溃,客户端可以安全地重试,因为操作是幂等的。

7.4.3 活动服务

活动服务跟踪计步器发送的步数活动更新。服务将事件存储到 PostgreSQL 数据库中,并提供 HTTP API 来收集一些统计数据,例如特定设备的每日、每月和总步数。更新来自 Kafka 主题,该主题由摄取服务提供数据(见图 7.9)。

活动服务图

图 7.9 活动服务

活动服务还会发布关于当前日设备步数的事件。这样,其他服务可以订阅相应的 Kafka 主题并接收通知,而不是需要定期轮询活动服务以获取更新。

HTTP API 在表 7.3 中展示。

表 7.3 活动服务 HTTP API

目的 路径 方法 数据 响应 状态码
设备的总步数 /device id/total GET N/A JSON 文档 成功时返回 200,设备不存在时返回 404,技术错误时返回 500
特定月份设备的步数 /device id/year/month GET N/A JSON 文档 成功时返回 200,设备不存在时返回 404,技术错误时返回 500
特定日期设备的步数 /device id/year/month/day GET N/A JSON 文档 成功时返回 200,设备不存在时返回 404,技术错误时返回 500
过去 24 小时内按步数递减的设备排名 /ranking-last-24-hours GET N/A JSON 文档 成功时返回 200,技术错误时返回 500

大多数操作是对特定设备的查询。正如你将在另一章中看到的,最后一次操作提供了一个高效的查询,用于获取设备的排名,这在仪表板服务启动时非常有用。

发送到 daily.step.updates Kafka 主题的事件包含以下信息,在一个 JSON 文档中:

  • 设备标识符

  • 时间戳

  • 当天记录的步数

对于每个传入的设备更新,需要按照以下顺序执行三个操作:

  • 数据库插入

  • 获取当前设备当天步数的数据库查询

  • Kafka 记录写入

这些操作中的每一个都可能失败,我们没有设置分布式事务代理。我们确保了幂等性和正确性,如下所示:

  • 我们只在最后一个操作完成后才确认 Kafka 中的传入设备更新记录。

  • 数据库模式对存储的事件施加了一些唯一性约束,因此如果事件正在再次处理,插入操作可能会失败。

  • 我们将重复插入错误视为正常情况以实现幂等性,并继续执行后续步骤,直到它们全部完成。

  • 成功将每日步数更新记录写入 Kafka 后,我们可以确认初始设备更新记录,系统可以继续处理其他传入的记录。

活动服务不打算公开,因此就像用户资料服务一样,没有设置身份验证。它可以独立于数据库进行扩展。

7.4.4 公共 API

此服务公开了一个公共 HTTP API,供其他服务消费。它本质上是对用户资料和活动服务的门面,如图 7.10 所示。

图 7.10 公共 API

该服务是一种边缘服务API 网关,因为它将请求转发并组合到其他服务。由于这是一个公共 HTTP API,该服务需要对其大多数操作进行身份验证。为此,我们将使用JSON Web Tokens (tools .ietf.org/html/rfc7519),我们将在第八章中讨论,包括服务实现。由于我们希望公共 API 可以从任何 HTTP 客户端使用,包括在网页浏览器中运行的 JavaScript 代码,我们需要支持跨源资源共享,或 CORS (fetch.spec.whatwg.org/#http-cors-protocol)。我们将在适当的时候深入探讨细节。HTTP API 操作在表 7.4 中描述。

表 7.4 公共 API HTTP 接口

目的 路径 方法 数据 响应 状态码
注册新用户和设备 /register POST JSON 格式的注册数据文档 N/A 成功时返回 200,否则返回 502
获取用于 API 的 JWT 令牌 /token POST JSON 格式的凭据文档 JWT 令牌(纯文本) 成功时返回 200,否则返回 401
获取用户数据(需要有效的 JWT) /<username> GET N/A JSON 文档 成功时返回 200,未找到时返回 404,否则返回 502
更新用户数据(需要有效的 JWT) /<username> PUT JSON 文档 N/A 成功时返回 200,未找到时返回 404,否则返回 502
用户总步数(需要有效的 JWT) /<username>/total GET N/A JSON 文档 成功时返回 200,未找到时返回 404,否则返回 502
用户一个月内的总步数(需要有效的 JWT) /<username>/<year>/<month> GET N/A JSON 文档 成功时返回 200,未找到时返回 404,其他情况返回 502
用户一天内的总步数(需要有效的 JWT) /<username>/<year>/<month>/<day> GET N/A JSON 文档 成功时返回 200,未找到时返回 404,其他情况返回 502

注意,请求路径将带有/api/v1前缀,因此请求令牌是向/api/v1/tokenPOST请求。在公共 API 的 URL 中始终有一些版本控制方案是一个好主意。JWT 令牌仅限于用于获取它的用户名,因此用户B不能执行,例如,向/api/v1/A/ 2019/07/14的请求。

公共 API 服务可以扩展到多个实例。在生产环境中,负载均衡 HTTP 代理应将请求分发到这些实例。在服务中不需要维护任何状态,因为它将请求转发和组合到其他服务。

7.4.5 用户 Web 应用程序

用户 Web 应用程序提供了一种方式,让用户可以注册、更新他们的详细信息,并检查一些关于他们活动的基本数据。如图 7.11 所示,有一个后端通过 HTTP 向 Web 浏览器提供 Web 应用程序的静态资源。

图 7.11 用户 Web 应用程序

前端是一个用 JavaScript 和 Vue.JS 框架编写的单页应用程序。它由用户 Web 应用程序服务提供,并且所有与应用程序后端的交互都通过调用公共 API 服务来完成。

因此,这个服务更像是 Vue.JS 应用程序而不是 Vert.x 应用程序,尽管看到 Vert.x 如何以最小的努力提供静态内容仍然很有趣。我们本可以选择其他流行的 JavaScript 框架,或者甚至完全不使用框架。我发现 Vue.JS 是一个简单而高效的选择。此外,由于 Vue.JS 拥抱响应式惯用用法,它使得从后端 API 到前端的应用程序完全响应。

该服务本身仅提供静态文件,因此可以扩展到多个实例,并在生产环境中放在负载均衡器后面。在服务器端也没有状态,无论是在服务中还是在使用的公共 API 中。是前端应用程序在用户的 Web 浏览器中存储一些状态。

7.4.6 事件统计服务

事件统计服务响应 Kafka 主题中选定的事件,生成统计数据并将它们作为 Kafka 记录发布给其他服务,如图 7.12 所示。

图 7.12 事件统计服务

该服务执行以下计算:

  • 基于五秒的时间窗口,它根据接收到的incoming.steps主题上的事件数量计算设备更新的吞吐量,然后向event-stats.throughput主题发出记录。

  • daily.step.updates 主题上接收的事件包含来自设备当天步数的数量数据。这些数据缺少用户数据(姓名、城市等),因此对于每个事件,该服务查询用户配置文件服务以丰富原始记录的用户数据,然后将其发送到 event-stats.user-activity.updates 主题。

  • 该服务通过处理来自 event-stats.user-activity.updates 主题的事件,在五秒的时间窗口内计算城市趋势,并为每个城市将其聚合的步数更新发布到 event-stats.city-trends.updates 主题。

Kafka 记录可以自动批量确认,因为再次处理记录几乎没有损害,尤其是对于吞吐量和城市趋势计算。为了确保活动更新恰好产生一个记录,可以进行手动确认,尽管偶尔的重复记录不应影响消费服务。

事件统计服务并不打算公开,它不提供任何接口供其他服务使用。最后,由于计算的性质,该服务应作为单个实例部署。

7.4.7 庆祝服务

庆祝服务的角色是监控设备在一天内达到至少 10,000 步的情况,然后向所有者发送祝贺电子邮件,如图 7.13 所示。

图片

图 7.13 庆祝服务

该服务调用用户配置文件服务以获取与设备关联的用户的电子邮件,然后它联系 SMTP 服务器发送电子邮件。

注意,我们可以重用由事件统计服务提供数据的 event-stats.user-activity.updates Kafka 主题,因为它丰富了从 daily.step.updates 收到的消息,包括电子邮件地址。这两个主题的 Kafka 记录键的生产实现细节使得使用 daily.step.updates 中的记录,然后从用户配置文件服务获取电子邮件,来确保每天最多向用户发送一条消息变得简单。这也不会增加太多的网络和处理开销,因为用户只需在给定的一天内至少收到一条包含至少 10,000 步的活动更新电子邮件。

此服务不应公开,也不暴露任何 API。在生产环境中,单个实例应该足够,但该服务可以被扩展到多个实例,它们共享相同的 Kafka 消费者组,以便它们可以在彼此之间分配工作负载。

7.4.8 仪表板 Web 应用程序

仪表板 Web 应用程序提供有关传入更新吞吐量、城市趋势和公共用户排名的实时更新。如图 7.14 所见,该服务消费事件统计服务发出的 Kafka 记录,并定期将更新推送到 Web 应用程序。

图片

图 7.14 仪表板 Web 应用程序

Web 应用程序使用 Vue.JS 框架编写,就像之前描述的用户 Web 应用程序一样。前端和后端使用 Vert.x 事件总线连接,因此 Vert.x 和 Vue.JS 代码库可以使用相同的编程模型进行通信。

来自 Kafka 主题的吞吐量和城市趋势更新直接转发到 Vert.x 事件总线,因此连接的 Web 应用程序客户端可以实时接收更新。后端维护有关过去 24 小时内所有已公开其配置文件的用户步数的内存数据。排名每 5 秒更新一次,结果通过事件总线推送到 Web 应用程序,以便在连接的 Web 浏览器中更新排名。

由于后端是通过 Kafka 主题事件驱动的,一个很好的问题是当服务启动时(或从崩溃中恢复时)会发生什么。确实,在全新启动时,我们没有过去 24 小时的步数数据,我们只会从服务的启动时间接收更新。

当服务启动时,我们需要一个水合阶段,在这个阶段中,我们查询活动服务并获取过去 24 小时的排名。然后我们需要查询用户配置文件服务以获取排名中的每个条目,因为我们需要将每个设备与一个用户配置文件关联起来。这是一个可能代价高昂的操作,但这种情况不应该经常发生。

注意,等待水合完成并不会阻止处理用户活动更新,因为最终在更新内存数据时,只有来自 Kafka 记录或水合数据的最新值才会生效。

仪表板 Web 应用程序服务旨在公开暴露。如果需要,它可以扩展到多个实例,并且可以放在 HTTP 代理负载均衡器后面。

7.5 运行应用程序

要运行应用程序,您需要运行所有基础设施服务和所有微服务。应用程序的完整源代码可以在源代码仓库的 part2-steps-challenge 文件夹中找到。

首先,必须在您的机器上安装 Docker,因为构建应用程序需要在执行测试套件时启动容器。可以使用gradle assemble命令使用 Gradle 构建应用程序,或者如果您还想在构建过程中运行测试并且 Docker 正在运行,可以使用gradle build

一旦构建了应用程序服务,您将需要运行所有基础设施服务,如 PostgreSQL、MongoDB、Apache Kafka 等。您可以通过从 Docker 容器运行它们来极大地简化任务。为此,docker-compose.yml 文件描述了要使用 Docker Compose 运行的几个容器,Docker Compose 是一个简单有效的工具,可以同时管理多个容器。运行 docker-compose up 将启动所有容器,而 docker-compose down 将停止并删除它们。您还可以在运行 Docker Compose 的终端中按 Ctrl+C,它将停止容器(但不会删除它们,因此可以再次以当前状态启动)。

提示:在 macOS 和 Windows 上,我建议安装 Docker Desktop。大多数 Linux 发行版都提供 Docker 作为软件包。请注意,docker 需要以 root 用户身份运行,因此在 Linux 上您可能需要将您的用户添加到特殊组中,以避免使用 sudo。官方 Docker 文档提供了故障排除说明(docs.docker.com/engine/install/linux-postinstall/)。在所有情况下,请确保您可以作为用户成功运行 docker run hello-world 命令。

我们需要运行的容器镜像如下:

  • MongoDB 配有初始化脚本以准备集合和索引

  • PostgreSQL 配有初始化脚本以创建模式

  • 来自 Strimzi 项目的 Apache Kafka 和 Apache ZooKeeper 镜像(见 strimzi.io

  • ActiveMQ Artemis

  • MailHog,一个适合集成测试的 SMTP 服务器(github.com/mailhog/MailHog

所有微服务都打包为自包含的可执行 JAR 文件。例如,您可以按以下方式运行活动服务:

$ java -jar activity-service/build/libs/activity-service-all.jar

话虽如此,手动启动所有服务并不方便,因此项目还包含一个 Procfile 文件来运行所有服务。该文件包含服务名称和相关 shell 命令的行。您可以使用 Foreman 工具来运行服务(github.com/ddollar/foreman)或兼容的工具,如 Hivemind(github.com/DarthSim/hivemind):

$ foreman start

这非常方便,因为您可以从两个终端窗口运行所有服务,如图 7.15 所示。

图 7.15 使用 Docker Compose 和 Foreman 运行微服务和基础设施服务

Foreman 还可以从 Procfile 生成各种系统服务描述符:initab、launchd、systemd 等。最后,Foreman 是用 Ruby 编写的,但在项目页面上还列出了其他语言的端口。

提示:Foreman 简化了运行所有服务的过程,但您不必使用它。您可以在命令行上单独运行每个服务。Procfile 的内容将显示每个服务的确切命令。

  1. 下一章将通过构建一组(不完美!)覆盖 Web、API、消息、数据和连续流处理主题的微服务来阐述实现响应式应用的挑战。在下一章中,我们将探讨用于实现本章描述的一些服务的 Web 堆栈。

3. 摘要

    1. 响应式应用专注于在各种工作负载和存在其他服务故障的情况下控制延迟。
    1. 一个响应式应用可以被分解为一系列独立扩展的事件驱动微服务。

  1. 对于介绍,请参阅马丁·福勒关于 CQRS(martinfowler.com/bliki/CQRS.html)和事件溯源(martinfowler.com/eaaDev/EventSourcing.html)的文章。

8 网络堆栈

本章涵盖

  • 构建边缘服务和公共 API

  • Vert.x 网络客户端

  • JSON Web 令牌(JWT)和跨源资源共享(CORS)

  • 使用 Vert.x 将 Vue.js 响应式应用程序提供和集成

  • 使用 REST Assured 测试 HTTP API

响应式应用程序通常使用 HTTP,因为它是一个非常通用的协议,而 Vert.x 提供了对 Web 技术的全面支持。Vert.x 网络堆栈提供了许多用于构建 Web 应用程序后端的工具。这包括高级路由、身份验证、HTTP 客户端等。本章将指导你如何公开使用JSON Web 令牌(JWT)进行访问控制的 HTTP API,向其他服务发出 HTTP 请求,并构建一个连接到 HTTP API 的响应式单页应用程序。

注意:本书不涵盖以下从 Vert.x 网络堆栈中值得注意的元素,这些元素对于构建本书的这一部分的应用程序不是必需的:正则表达式路由、cookies、服务器端会话、服务器端模板渲染和跨站请求伪造保护。你可以在官方文档中了解更多关于这些主题的详细信息:vertx.io/

8.1 公开公共 API

让我们首先回顾一下公共 API 服务的作用,如图 8.1 所示。这个服务是一个边缘服务(或服务网关,取决于你更喜欢如何命名),因为它公开了一个 HTTP API,但它本质上组合了其他服务中找到的功能。在这种情况下,使用了用户资料和活动服务。这两个服务是应用程序内部的,并且没有公开暴露。它们也没有任何形式的身份验证和访问控制,这是公共 API 在大多数操作中无法承担的。

图 8.1 公共 API 概述

以下 Vert.x 模块需要实现公共 API:

  • vertx-web,提供高级 HTTP 请求处理功能

  • vertx-web-client,用于向用户资料和活动服务发出 HTTP 请求

  • vertx-auth-jwt,用于生成和处理 JSON Web 令牌(JWT)以及执行访问控制

公共 API 服务的完整源代码可以在本书源代码仓库的 part2-steps-challenge/public-api 文件夹中找到。

我们将从 Vert.x 网络路由开始。

8.1.1 路由 HTTP 请求

Vert.x 核心提供了一个非常底层的 HTTP 服务器 API,其中你需要为所有类型的 HTTP 请求传递一个请求处理器。如果你只使用 Vert.x 核心,你需要手动检查请求的路径和方法。这在简单情况下是可以的,这也是我们在一些早期章节中做的事情,但它很快就会变得复杂。

vertx-web模块提供了一个可以作为 Vert.x HTTP 服务器请求处理器的router,并根据请求路径(例如,/foo)和 HTTP 方法(例如,POST)将 HTTP 请求调度到合适的处理器。这如图 8.2 所示。

图片

图 8.2 路由 HTTP 请求

下面的列表显示了如何初始化并设置路由作为 HTTP 请求处理程序。

列表 8.1 初始化和使用路由作为 HTTP 请求处理程序

Router router = Router.router(vertx);
// (...)                               ❶

vertx.createHttpServer()
  .requestHandler(router)              ❷
  .listen(8080);

❶ 定义路由

❷ 路由器只是另一个 HTTP 请求处理程序。

Router 类提供了一个流畅的 API 来描述基于 HTTP 方法和路径的 路由,如下面的列表所示。

列表 8.2 定义路由

BodyHandler bodyHandler = BodyHandler.create();               ❶
router.post().handler(bodyHandler);                           ❷
router.put().handler(bodyHandler);

String prefix = "/api/v1";

router.post(prefix + "/register").handler(this::register);    ❸
router.post(prefix + "/token").handler(this::token);
// (...) defines jwtHandler, more later

router.get(prefix + "/:username/:year/:month")                ❹
  .handler(jwtHandler)                                        ❺
  .handler(this::checkUser)
  .handler(this::monthlySteps);
// (...)

❶ BodyHandler 是一个预定义的处理程序,用于提取 HTTP 请求体有效载荷。

❷ 在这里,bodyHandler 被用于所有 HTTP POST 和 PUT 请求。

❸ 注册方法处理 /api/v1/register POST 请求。

❹ 我们可以通过在元素前加上“:”来提取路径参数。

❺ 处理程序可以链式调用。

Vert.x 路由器的一个有趣特性是处理程序可以链式调用。根据列表 8.2 中的定义,一个发往 /api/v1/registerPOST 请求首先通过一个 BodyHandler 实例。这个处理程序对于轻松解码 HTTP 请求体有效载荷很有用。下一个处理程序是 register 方法。

列表 8.2 还定义了发往 monthlyStepsGET 请求的路由,其中请求首先通过 jwtHandler,然后是 checkUser,如图 8.3 所示。这对于分解 HTTP 请求,分步骤处理关注点很有用:jwtHandler 检查请求中是否存在有效的 JWT 令牌,checkUser 检查 JWT 令牌是否授予访问资源的权限,而 monthlySteps 检查用户一个月内走了多少步。

图片

图 8.3 每月步骤端点的路由链

注意,checkUserjwtHandler 都将在第 8.2 节中讨论。

提示:io.vertx.ext.web.handler 包包含有用的实用处理程序,包括 BodyHandler。它特别提供了 HTTP 认证、CORS、CSRF、favicon、HTTP 会话、静态文件服务、虚拟主机和模板渲染的处理程序。

8.1.2 制作 HTTP 请求

现在我们来深入了解处理程序的实现。由于公共 API 服务将请求转发到用户配置文件和活动服务,我们需要使用 Vert.x 网络客户端来执行 HTTP 请求。如前所述,Vert.x 核心 API 提供了低级 HTTP 客户端,而 vertx-web-client 模块中的 WebClient 类提供了一个更丰富的 API。

创建一个网络客户端实例就像这样:

WebClient webClient = WebClient.create(vertx);

WebClient 实例通常存储在 verticle 类的私有字段中,因为它可以用来执行多个并发 HTTP 请求。整个应用程序使用 RxJava 2 绑定,因此我们可以利用它们来组合异步操作。正如你将在后面的示例中看到的,RxJava 绑定有时会带来处理错误管理的附加功能。

下面的列表显示了 register 路由处理程序的实现。

列表 8.3 在路由处理程序中使用 Vert.x 网络客户端

private void register(RoutingContext ctx) {
  webClient
    .post(3000, "localhost", "/register")                       ❶
    .putHeader("Content-Type", "application/json")              ❷
    .rxSendJson(ctx.getBodyAsJson())                            ❸
    .subscribe(
      response -> sendStatusCode(ctx, response.statusCode()),   ❹
      err -> sendBadGateway(ctx, err));
}

private void sendStatusCode(RoutingContext ctx, int code) {
  ctx.response().setStatusCode(code).end();
}

private void sendBadGateway(RoutingContext ctx, Throwable err) {
  logger.error("Woops", err);
  ctx.fail(502);
}

❶ 方法与 HTTP 方法(GET、POST 等)匹配。

❷ 可以传递 HTTP 头。

❸ 这将请求从 Vert.x Buffer 转换为 JsonObject。

❹ RxJava 的订阅触发请求。

这个例子演示了如何使用路由器处理 HTTP 请求,以及如何使用 Web 客户端。RoutingContext类封装了关于 HTTP 请求的详细信息,并通过response方法提供 HTTP 响应对象。HTTP 头可以在请求和响应中设置,一旦调用end方法,响应就会被发送。可以指定状态码,尽管默认情况下它将是200(OK)。

你可以看到getBodyAsJson将 HTTP 请求体转换为JsonObject,而rxSendJson则发送一个带有JsonObject作为体的 HTTP 请求。默认情况下,Vert.x Buffer对象在请求和响应中都携带体,但有一些辅助方法可以将它们转换为StringJsonObjectJsonArray

下一个列表提供了一个用于 HTTP GET请求到/api/v1/:username的路由处理器方法,其中:username是一个路径参数。

列表 8.4 获取并转发用户详情

private void fetchUser(RoutingContext ctx) {
  webClient
    .get(3000, "localhost", "/" + ctx.pathParam("username"))      ❶
    .as(BodyCodec.jsonObject())                                   ❷
    .rxSend()
    .subscribe(
      resp -> forwardJsonOrStatusCode(ctx, resp),
      err -> sendBadGateway(ctx, err));
}

private void forwardJsonOrStatusCode(RoutingContext ctx, 
➥ HttpResponse<JsonObject> resp) {
  if (resp.statusCode() != 200) {
    sendStatusCode(ctx, resp.statusCode());
  } else {
    ctx.response()
      .putHeader("Content-Type", "application/json")
      .end(resp.body().encode());                                 ❸
  }
}

❶ 提取路径参数

❷ 将响应转换为 JsonObject

❸ 使用一些内容结束响应

这个例子展示了as方法,它使用BodyCodec将 HTTP 响应转换为除Buffer之外的其他类型。你还可以看到 HTTP 响应的end方法可以接受一个参数,即响应内容。它可以是StringBuffer。虽然通常情况下响应是在单个end方法调用中发送的,但你可以在最终end调用关闭 HTTP 响应之前,使用write方法发送中间片段,如下所示:

response.write("hello").write(" world").end();

8.2 使用 JWT 令牌进行访问控制

JSON Web Token (JWT)是一个用于在各方之间安全传输 JSON 编码数据的开放规范(tools.ietf.org/html/rfc7519)。JWT 令牌使用对称共享密钥或非对称公钥/私钥对进行签名,因此始终可以验证它们包含的信息没有被修改。这非常有趣,因为 JWT 令牌可以用来持有诸如身份和授权许可之类的声明。JWT 令牌可以作为 HTTP 请求的一部分通过Authorization HTTP 头进行交换。

让我们看看如何使用 JWT 令牌,它们包含哪些数据,以及如何使用 Vert.x 验证和颁发它们。

提示 JWT 是 Vert.x 支持的一种协议。Vert.x 提供了vertx-auth-oauth2模块用于 OAuth2,这是 Google、GitHub 和 Twitter 等公共服务提供商中流行的协议。如果你需要将应用程序与这些服务集成(例如,在访问用户的 Gmail 账户数据时),或者当你的应用程序想要通过 OAuth2 授予第三方访问权限时,你将对此感兴趣。

8.2.1 使用 JWT 令牌

为了说明使用 JWT 令牌,让我们与公共 API 交互,并使用密码123作为用户foo进行认证,以获取 JWT 令牌。以下列表显示了 HTTP 响应。

列表 8.5 获取 JWT 令牌

$ http :4000/api/v1/token username=foo password=123      ❶
HTTP/1.1 200 OK
Content-Type: application/jwt
content-length: 496

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkZXZpY2VJZCI6ImExYjIiLCJpYXQiOjE1NjUx
➥ Njc0NzUsImV4cCI6MTU2NTc3MjI3NSwiaXNzIjoiMTBrLXN0ZXBzLWFwaSIsInN1YiI6ImZvb
➥ yJ9.J_tn2BjMNYE6eFSHwSJ9e8DoCEUr_xMSlYAyBSy1-E_pouvDq4lp8QjG51cJoa5Gbrt1bg
➥ tDHinJsLncG1RIsGr_cz1rQw8_GlI_-GdhqFBw8dVjlsgykSf5tfaiiRwORmz7VH_AAk-935aV
➥ lxMg4mxkbOvN4YDxRLhLb4Y78TA47F__ivNsM4gLD8CHzOUmTEta_pjpZGzsErmYvzDOV6F7rO
➥ ZcRhZThJxLvR3zskrtx83iaNHTwph53bkHNOQzC66wxNMar_T4HMRWzqnrr-sFIcOwLFsWJKow
➥ c1rQuadjv-ew541YQLaVmkEcai6leZLwCfCTcsxMX9rt0AmOFg

❶ 使用密码 123 作为用户 foo 进行认证

JWT 令牌的 MIME 类型为application/jwt,它是纯文本。我们可以传递令牌来发送请求,如下所示。

列表 8.6 使用 JWT 令牌访问资源

http :4000/api/v1/foo Authorization:'Bearer 
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkZXZpY2VJZCI6ImExYjIiLCJpYXQiOjE1NjUx➥ Njc0NzUsImV4cCI6MTU2NTc3MjI3NSwiaXNzIjoiMTBrLXN0ZXBzLWFwaSIsInN1YiI6ImZvb
➥ yJ9.J_tn2BjMNYE6eFSHwSJ9e8DoCEUr_xMSlYAyBSy1-E_pouvDq4lp8QjG51cJoa5Gbrt1bg
➥ tDHinJsLncG1RIsGr_cz1rQw8_GlI_-GdhqFBw8dVjlsgykSf5tfaiiRwORmz7VH_AAk-935a
➥ VlxMg4mxkbOvN4YDxRLhLb4Y78TA47F__ivNsM4gLD8CHzOUmTEta_pjpZGzsErmYvzDOV6F7
➥ rOZcRhZThJxLvR3zskrtx83iaNHTwph53bkHNOQzC66wxNMar_T4HMRWzqnrr-sFIcOwLFsWJK
➥ owc1rQuadjv-ew541YQLaVmkEcai6leZLwCfCTcsxMX9rt0AmOFg'
HTTP/1.1 200 OK                                            ❶
Content-Type: application/json
content-length: 90

{
    "city": "Lyon",
    "deviceId": "a1b2",
    "email": "foo@bar.com",
    "makePublic": true,
    "username": "foo"
}

❶ 我们可以访问资源,因为我们有一个针对用户 foo 的有效令牌。

提示:令牌值适合单行,并且Bearer和令牌之间只有一个空格。

令牌通过Authorization HTTP 头部传递,并且其值以Bearer为前缀。在这里,令牌允许我们访问资源/api/v1/foo,因为令牌是为用户foo生成的。如果我们尝试不使用令牌,或者尝试访问其他用户的资源,如下列所示,我们将被拒绝访问。

列表 8.7 无匹配 JWT 令牌访问资源

http :4000/api/v1/abc Authorization:'Bearer 
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJkZXZpY2VJZCI6ImExYjIiLCJpYXQiOjE1NjUx➥ Njc0NzUsImV4cCI6MTU2NTc3MjI3NSwiaXNzIjoiMTBrLXN0ZXBzLWFwaSIsInN1YiI6ImZvb
➥ yJ9.J_tn2BjMNYE6eFSHwSJ9e8DoCEUr_xMSlYAyBSy1-E_pouvDq4lp8QjG51cJoa5Gbrt1b
➥ gtDHinJsLncG1RIsGr_cz1rQw8_GlI_-GdhqFBw8dVjlsgykSf5tfaiiRwORmz7VH_AAk-935
➥ aVlxMg4mxkbOvN4YDxRLhLb4Y78TA47F__ivNsM4gLD8CHzOUmTEta_pjpZGzsErmYvzDOV6F
➥ 7rOZcRhZThJxLvR3zskrtx83iaNHTwph53bkHNOQzC66wxNMar_T4HMRWzqnrr-sFIcOwLFsW
➥ JKowc1rQuadjv-ew541YQLaVmkEcai6leZLwCfCTcsxMX9rt0AmOFg'
HTTP/1.1 403 Forbidden                                        ❶
content-length: 0

❶ 我们因为传递了一个针对用户 foo 的(有效)令牌而被拒绝访问用户 abc 的资源。

8.2.2 JWT 令牌中包含什么?

到目前为止一切顺利,但令牌字符串中包含什么呢?

如果你仔细看,你会看到 JWT 令牌字符串是一行大文本,分为三部分,每部分之间由点分隔。这三部分的形式为header.payload.signature

  • header是一个 JSON 文档,指定了令牌的类型和使用的签名算法。

  • payload是一个包含声明的 JSON 文档,这些声明是 JSON 条目,其中一些是规范的一部分,而另一些可以是自由形式的。

  • signature是头部和payload的签名,使用共享密钥或私钥,具体取决于你选择的算法。

头部和payload使用Base64算法进行编码。如果你解码列表 8.5 中获得的 JWT 令牌,头部包含以下内容:

{
  "typ": "JWT",
  "alg": "RS256"
}

这就是payload包含的内容:

{
  "deviceId": "a1b2",
  "iat": 1565167475,
  "exp": 1565772275,
  "iss": "10k-steps-api",
  "sub": "foo"
}

这里,deviceId是用户foo的设备标识符,sub主题(用户foo),iat是令牌签发日期,exp是令牌过期日期,而iss是令牌发行者(我们的服务)。

签名允许你检查头部和payload的内容是否由发行者签名且未被修改,只要你知道公钥。这使得 JWT 令牌成为 API 中授权和访问控制的绝佳选择;包含所有所需声明的令牌是自包含的,无需对每个请求与身份管理服务(如 LDAP/OAuth 服务器)进行检查。

重要的是要理解,任何拥有 JWT 令牌的人都可以解码其内容,因为Base64不是一个加密算法。你绝不应该在令牌中放入敏感数据,如密码,即使它们通过像 HTTPS 这样的安全通道传输。同样重要的是设置令牌过期日期,以便受损的令牌不能无限期地使用。处理 JWT 令牌过期的策略有很多,比如在后台维护受损令牌列表,以及将短期过期期限与客户端频繁的有效性扩展请求相结合,其中发行者重新发送令牌,但带有扩展的exp声明。

8.2.3 使用 Vert.x 处理 JWT 令牌

为了颁发和检查令牌,我们首先需要一对公钥和私钥,这样我们就可以签署 JWT 令牌。您可以使用以下列表中的 shell 脚本生成这些密钥。

列表 8.8 生成 RSA 2048 位公钥和私钥

#!/bin/bash
openssl genrsa -out private.pem 2048
openssl pkcs8 -topk8 -inform PEM -in private.pem -out 
➥ private_key.pem -nocrypt
openssl rsa -in private.pem -outform PEM -pubout -out public_key.pem

下一个列表展示了用于将 PEM 文件作为字符串读取的辅助类。

列表 8.9 读取 RSA 密钥的辅助类

class CryptoHelper {

  static String publicKey() throws IOException {
    return read("public_key.pem");
  }

  static String privateKey() throws IOException {
    return read("private_key.pem");
  }

  private static String read(String file) throws IOException {
    Path path = Paths.get("public-api", file);
    if (!path.toFile().exists()) {                                             ❶
      path = Paths.get("..", "public-api", file);
    }
    return String.join("\n", Files.readAllLines(path, StandardCharsets.UTF_8));❷
  }
}

❶ 这允许我们从服务文件夹或应用程序项目根目录运行服务。

❷ 将所有行连接起来,用换行符分隔

注意到CryptoHelper中的代码使用了阻塞 API。由于此代码仅在初始化时运行一次,且 PEM 文件较小,我们可以承受对事件循环的潜在但可忽略的阻塞。

然后,我们可以创建一个 Vert.x JWT 处理器,如下所示。

列表 8.10 创建 JWT 处理器

String publicKey = CryptoHelper.publicKey();
String privateKey = CryptoHelper.privateKey();

jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions()          ❶
  .addPubSecKey(new PubSecKeyOptions()
    .setAlgorithm("RS256")
    .setBuffer(publicKey))
  .addPubSecKey(new PubSecKeyOptions()
    .setAlgorithm("RS256")
    .setBuffer(privateKey)));

JWTAuthHandler jwtHandler = JWTAuthHandler.create(jwtAuth);   ❷

❶ jwtAuth 是 JWTAuth 类型的一个私有字段。

❷ Vert.x 路由处理器用于 JWT 认证

JWT 处理器可用于需要 JWT 认证的路由,因为它解码Authorization头以提取 JWT 数据。

下一个列表回顾了一个具有处理器链中的处理器的路由。

列表 8.11 路由中的 JWT 处理器

router.get(prefix + "/:username/:year/:month")
  .handler(jwtHandler)                           ❶
  .handler(this::checkUser)
  .handler(this::monthlySteps);

❶ JWT 处理器

JWT 处理器支持来自vertx-auth-common模块的常用认证 API,它为不同类型的认证机制(如数据库、OAuth 或 Apache .htdigest文件)提供了一个统一的视图。处理器将认证数据放入路由上下文中。

以下列表展示了checkUser方法的实现,其中我们检查 JWT 令牌中的用户是否与 HTTP 请求路径中的用户相同。

列表 8.12 检查是否存在有效的 JWT 令牌

private void checkUser(RoutingContext ctx) {
  String subject = ctx.user().principal().getString("sub");    ❶
  if (!ctx.pathParam("username").equals(subject)) {            ❷
    sendStatusCode(ctx, 403);
  } else {
    ctx.next();                                                ❸
  }
}

❶ 从 JWT 令牌中获取用户名

❷ 在 HTTP 请求路径中指定的用户名

❸ 传递给下一个处理器

这提供了一个简单的关注点分离,因为checkUser处理器专注于访问控制,并在授予访问权限时通过调用next将控制权委托给链中的下一个处理器,或者在错误的用户尝试访问资源时,以 403 状态码结束请求。

确认访问控制正确后,以下列表中的monthlySteps方法可以专注于向活动服务发送请求。

列表 8.13 获取月度步数数据

private void monthlySteps(RoutingContext ctx) {
  String deviceId = ctx.user().principal().getString("deviceId");     ❶
  String year = ctx.pathParam("year");
  String month = ctx.pathParam("month");
  webClient
    .get(3001, "localhost", "/" + deviceId + "/" + year + "/" + month)
    .as(BodyCodec.jsonObject())
    .rxSend()
    .subscribe(
      resp -> forwardJsonOrStatusCode(ctx, resp),
      err -> sendBadGateway(ctx, err));
}

❶ 来自 JWT 令牌

设备标识符从 JWT 令牌数据中提取出来,并传递给 Web 客户端请求。

8.2.4 使用 Vert.x 发行 JWT 令牌

最后,但同样重要的是,我们需要生成 JWT 令牌。为此,我们需要向用户配置文件服务发出两个请求:首先我们需要检查凭证,然后收集配置文件数据以准备令牌。

以下列表展示了/api/v1/token路由的处理程序。

列表 8.14 JWT 令牌创建路由处理程序

private void token(RoutingContext ctx) {
  JsonObject payload = ctx.getBodyAsJson();                ❶
  String username = payload.getString("username");
  webClient
    .post(3000, "localhost", "/authenticate")              ❷
    .expect(ResponsePredicate.SC_SUCCESS)
    .rxSendJson(payload)
    .flatMap(resp -> fetchUserDetails(username))           ❸
    .map(resp -> resp.body().getString("deviceId"))
    .map(deviceId -> makeJwtToken(username, deviceId))     ❹
    .subscribe(
      token -> sendToken(ctx, token),
      err -> handleAuthError(ctx, err));
}

private void sendToken(RoutingContext ctx, String token) {
  ctx.response().putHeader("Content-Type", "application/jwt").end(token);
}

private void handleAuthError(RoutingContext ctx, Throwable err) {
  logger.error("Authentication error", err);
  ctx.fail(401);
}

❶ 我们从/api/v1/token 请求中提取凭证。

❷ 我们首先发起一个认证请求。

❸ 成功后,我们再次请求以获取配置文件数据。

❹ 我们准备令牌。

这是一个典型的 RxJava 异步操作组合,使用flatMap来链式请求。您还可以看到 Vert.x 路由器的声明式 API,其中我们可以指定我们期望第一个请求是成功的。

以下列表展示了fetchUserDetails的实现,该实现是在认证请求成功后获取用户配置文件数据。

列表 8.15 获取用户详细信息

private Single<HttpResponse<JsonObject>> fetchUserDetails(String username) {
  return webClient
    .get(3000, "localhost", "/" + username)
    .expect(ResponsePredicate.SC_OK)         ❶
    .as(BodyCodec.jsonObject())
    .rxSend();
}

❶ 我们期望成功。

最后,下一个列表展示了如何准备 JWT 令牌。

列表 8.16 准备 JWT 令牌

private String makeJwtToken(String username, String deviceId) {
  JsonObject claims = new JsonObject()                          ❶
    .put("deviceId", deviceId);
  JWTOptions jwtOptions = new JWTOptions()
    .setAlgorithm("RS256")
    .setExpiresInMinutes(10_080) // 7 days
    .setIssuer("10k-steps-api")                                 ❷
    .setSubject(username);
  return jwtAuth.generateToken(claims, jwtOptions);
}

❶ 我们的定制声明

❷ 一个在 JWT 规范中的声明

JWTOptions类提供了 JWT RFC 中常见声明的相关方法,例如发行者、过期日期和主题。您可以看到我们没有指定令牌的发行时间,尽管JWTOptions中有一个方法可以做到这一点。jwtAuth对象在这里做了正确的事情,并代表我们添加了它。

8.3 跨源资源共享(CORS)

我们有一个公开的 API,它将请求转发到内部服务,并且这个 API 使用 JWT 令牌进行身份验证和访问控制。我在命令行上也演示了我们可以与 API 交互。实际上,任何第三方应用程序都可以通过 HTTP 与我们的 API 通信:手机应用程序、另一个服务、桌面应用程序等等。您可能会认为 Web 应用程序也可以从运行在 Web 浏览器中的 JavaScript 代码中与 API 通信,但事实(幸运的是!)并非如此简单。

8.3.1 问题是什么?

网络浏览器强制执行安全策略,其中之一就是同源策略。假设我们从 https://my.tld:4000/js/app.js 加载 app.js:

跨源资源共享 (CORS) 是一种机制,允许服务允许来自其他 的传入请求 (fetch.spec.whatwg.org/)。例如,暴露 https://other.tld/123 的服务可以指定允许来自 https://my.tld:4000 的代码或来自 任何 源的跨源请求。这允许当请求源允许时,网络浏览器可以继续执行跨源请求;否则,它将拒绝请求,这是默认行为。

当触发跨源请求时,例如加载一些 JSON 数据、图片或网络字体,网络浏览器会向服务器发送带有请求资源的请求,并传递一个 Origin HTTP 头部。然后服务器响应一个带有允许源的 Access-Control-Allow-Origin HTTP 头部,如图 8.4 所示。

图 8.4 CORS 交互示例

"*" 的值表示任何源都可以访问资源,而像 https://my.tld 这样的值则表示只有来自 https://my.tld 的跨源请求被允许。在图 8.4 中,请求成功并带有 JSON 有效负载,但如果 CORS 策略禁止调用,当尝试执行跨源请求时,app.js 代码将收到错误。

根据跨源 HTTP 请求的类型,网络浏览器会执行 简单预检 请求。图 8.4 中的请求是一个简单的请求。相比之下,一个 PUT 请求需要预检请求,因为它可能产生副作用(PUT 表示修改资源),因此必须向资源发送预检 OPTIONS HTTP 请求以检查 CORS 策略,然后允许时再执行实际的 PUT 请求。预检请求提供了更多细节,例如允许的 HTTP 头部和方法,因为服务器可以,例如,有一个禁止执行 DELETE 请求或包含 ABC 头部的 CORS 策略。我建议阅读 Mozilla 的“跨源资源共享 (CORS)”文档 (mng.bz/X0Z6),因为它提供了详细且易于理解的浏览器和服务器之间 CORS 交互的解释。

8.3.2 使用 Vert.x 支持 CORS

Vert.x 随带一个可用的 CORS 处理器,名为 CorsHandler 类。创建 CorsHandler 实例需要三个设置:

  • 允许的源模式

  • 允许的 HTTP 头部

  • 允许的 HTTP 方法

下面的列表展示了如何在 Vert.x 路由器中安装 CORS 支持。

列表 8.17 在路由器中安装 CORS 支持

Set<String> allowedHeaders = new HashSet<>();        ❶
allowedHeaders.add("x-requested-with");
allowedHeaders.add("Access-Control-Allow-Origin");
allowedHeaders.add("origin");
allowedHeaders.add("Content-Type");
allowedHeaders.add("accept");
allowedHeaders.add("Authorization");

Set<HttpMethod> allowedMethods = new HashSet<>();    ❷
allowedMethods.add(HttpMethod.GET);
allowedMethods.add(HttpMethod.POST);
allowedMethods.add(HttpMethod.OPTIONS);
allowedMethods.add(HttpMethod.PUT);

router.route().handler(CorsHandler                   ❸
  .create("*")
  .allowedHeaders(allowedHeaders)
  .allowedMethods(allowedMethods));

❶ 允许的 HTTP 头部集合

❷ 允许的 HTTP 方法集合

❸ 适用于所有源的 CORS 处理器

HTTP 方法是我们 API 所支持的。你可以看到,例如,我们不支持DELETE。由于所有路由都是 API 的一部分,并且应该可以从任何类型的应用程序(包括网页浏览器)访问,因此已经为所有路由安装了 CORS 处理器。允许的头部应该与你的 API 需求相匹配,同时也应该与客户端可能传递的内容类型或代理可能注入的头部相匹配,以及用于分布式跟踪目的的头部。

我们可以通过向 API 支持的某个路由发出 HTTP OPTIONS预检请求来检查 CORS 是否得到适当的支持。

列表 8.18 检查 CORS 支持

$ http OPTIONS :4000/api/v1/token Origin:'http://foo.tld'
HTTP/1.1 405 Method Not Allowed
access-control-allow-origin: *
content-length: 0

通过指定一个origin HTTP 头部,CORS 处理器会在响应中插入一个access-control-allow-origin HTTP 头部。HTTP 状态码是 405,因为特定的路由不支持OPTION HTTP 方法,但这不是问题,因为当网页浏览器进行预检请求时,它们只对与 CORS 相关的头部感兴趣。

8.4 现代网络前端

我们已经讨论了公共 API 中的有趣点:如何使用 Vert.x 网络客户端进行 HTTP 请求,如何使用 JWT 令牌,以及如何启用 CORS 支持。现在是时候看看我们如何公开用户网络应用程序(在第七章中定义),以及该应用程序如何连接到公共 API。

应用程序是用 Vue.js JavaScript 框架编写的。Vert.x 用于提供应用程序的编译资源:HTML、CSS 和 JavaScript。

相应的源代码位于书籍源代码仓库的 part2-steps-challenge/user-webapp 文件夹中。

8.4.1 Vue.js

Vue.js 值得有一本书来专门介绍,如果你对这个框架感兴趣,我们建议你阅读 Erik Hanchett 和 Benjamin Listwon 的《Vue.js in Action》(Manning,2018)。由于我们正在使用 Vue.js 作为两个作为更大 10k steps 应用程序一部分开发的网络应用程序的 JavaScript 框架,所以我将在这里提供一个快速概述。

Vue.js 是一个现代 JavaScript 前端框架,类似于 React 或 Angular,用于构建现代网络应用程序,包括单页应用程序。它是响应式的,因为组件模型的变化会触发用户界面的变化。假设我们在网页上显示温度。当相应的数据发生变化时,温度会更新,Vue.js 会负责(大多数)完成这项工作的管道。

Vue.js 支持组件,其中可以将 HTML 模板、CSS 样式和 JavaScript 代码组合在一起,如下面的列表所示。

列表 8.19 Vue.js 组件的画布

<template>
  <div id="app">
    {{ hello }}                     ❶
  </div>
</template>

<style scoped>                      ❷
  div {
    border: solid 1px black;
  }
</style>

<script>
  export default {
    data() {
      return {
        hello: "Hello, world!"      ❸
      }
    }
  }
</script>

❶ 被 hello 属性的值替换

❷ 组件本地的 CSS 规则

❸ hello 属性的初始定义

可以使用 Vue.js 命令行界面创建 Vue.js 项目(cli.vuejs.org/):

$ vue create user-webapp

然后,可以使用 yarn 构建工具安装依赖项(yarn install),以自动实时重新加载的方式开发项目(yarn run serve),以及构建项目的生产版本 HTML、CSS 和 JavaScript 资产(yarn run build)。

8.4.2 Vue.js 应用程序结构和构建集成

用户网络应用程序是一个单页应用程序,有三个不同的屏幕:登录表单、用户详情页面和注册表单。

关键 Vue.js 文件如下:

  • src/main.js--入口点

  • src/router.js--将分发到三个不同屏幕组件的 Vue.js 路由

  • src/DataStore.js--一个使用网络浏览器本地存储 API 保存应用程序存储的对象,所有屏幕共享

  • src/App.vue--挂载 Vue.js 路由的主组件

  • src/views--包含三个屏幕组件:Home.vueLogin.vueRegister.vue

Vue.js 路由配置如下所示。

列表 8.20 Vue.js 路由配置

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import Login from './views/Login.vue'
import Register from './views/Register.vue'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',        ❶
      name: 'home',     ❷
      component: Home   ❸
    },
    {
      path: '/login',
      name: 'login',
      component: Login
    },
    {
      path: '/register',
      name: 'register',
      component: Register
    },
  ]
})

❶ 组件路径

❷ 组件名称

❸ 组件引用

应用程序代码与为用户网络应用程序提供服务的 Vert.x 应用程序位于同一模块中,因此你将在 src/main/java 下找到常规的 Java 源文件,以及一个 Gradle build.gradle.kts 文件。Vue.js 编译资源(yarn build)必须复制到 src/main/resources/webroot/assets,以便基于 Vert.x 的服务提供它们。

这使得在单个项目中存在两个构建工具,幸运的是,它们可以和平共存。实际上,从 Gradle 中调用 yarn 非常容易,因为 com.moowork.node Gradle 插件提供了一个自包含的 Node 环境。以下列表显示了用户网络应用程序 Gradle 构建文件的 Node 相关配置。

列表 8.21 使用 com.moowork.node Gradle 插件

import com.moowork.gradle.node.yarn.YarnTask
apply(plugin = "com.moowork.node")                             ❶
tasks.register<YarnTask>("buildVueApp") {                      ❷
  dependsOn("yarn_install")                                    ❸
  // (...)                                                     ❹
  args = listOf("build")                                       ❺
}
tasks.register<Copy>("copyVueDist") {                          ❻
  dependsOn("buildVueApp")
  from("$projectDir/dist")
  into("$projectDir/src/main/resources/webroot/assets")
}
val processResources by tasks.named("processResources") {      ❼
  dependsOn("copyVueDist")
}
val clean by tasks.named<Delete>("clean") {                    ❽
  delete("$projectDir/dist")
  delete("$projectDir/src/main/resources/webroot/assets")
}
// (...)

❶ 使用 Node 插件

❷ 创建一个调用 yarn 的任务

❸ 添加了对先运行 yarn install 的依赖

❹ 你可以在完整源代码中找到的 Gradle 缓存指令

❺ 调用 yarn build

❻ 复制编译资源的任务

❼ 确保构建项目时也构建 Vue.js 应用程序。

❽ 为 Vue.js 编译资源执行额外清理任务

buildVueAppcopyVueDist 任务作为常规项目构建任务的一部分被插入,因此项目构建了 Java Vert.x 代码和 Vue.js 代码。我们还自定义了 clean 任务以删除生成的资源。

8.4.3 后端集成示例

让我们看看一个 Vue.js 组件:如图 8.5 所示的登录屏幕。

图 8.5 登录屏幕截图

该组件的文件位于 src/views/Login.vue。该组件显示登录表单,提交时必须调用公共 API 获取 JWT 令牌。成功后,必须在本地存储 JWT 令牌,然后切换视图到 home 组件。出错时,必须停留在登录表单并显示错误信息。

组件的 HTML 模板部分如下所示。

列表 8.22 登录组件 HTML 模板

<template>
  <div>
    <div class="alert alert-danger" role="alert" 
    ➥ v-if="alertMessage.length > 0">                                   ❶
      {{ alertMessage }}                                                 ❷
    </div>
    <form v-on:submit="login">                                           ❸
      <div class="form-group">
        <label for="username">User name</label>
        <input type="username" class="form-control" id="username" 
 ➥ placeholder="somebody123" v-model="username">                ❹
      </div>
      <div class="form-group">
        <label for="password">Password</label>
        <input type="password" class="form-control" id="password" placeholder="abc123" v-model="password">
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <div>
      <p>...or <router-link to="/register">register</router-link></p>    ❺
    </div>
  </div>
</template>

❶ 根据 alertMessage 组件数据值有条件地显示 div 块。

❷ 模板语法用于渲染 alertMessage 的值

❸ 在表单提交时调用登录方法。

v-model将字段值绑定到用户名组件数据。

<router-link>允许链接到另一个组件。

组件的 JavaScript 部分提供了组件数据声明以及login方法实现。我们使用 Axios JavaScript 库对公共 API 进行 HTTP 客户端调用。以下列表提供了组件 JavaScript 代码。

列表 8.23 登录组件 JavaScript 代码

import DataStore from '../DataStore'
import axios from 'axios'

export default {
  data() {                                                             ❶
    return {
      username: '',
      password: '',
      alertMessage: ''
    }
  },
  methods: {                                                           ❷
    login: function () {
      if (this.username.length === 0 || this.password.length === 0) {  ❸
        return
      }
      axios
        .post("http://localhost:4000/api/v1/token", {                  ❹
          username: this.username,
          password: this.password
        })
        .then(response => {
          DataStore.setToken(response.data)                            ❺
          DataStore.setUsername(this.username)
          this.$router.push({name: 'home'})                            ❻
        })
        .catch(err => this.alertMessage = err.message)                 ❼
    }
  }
}

❶ 组件数据声明

❷ 组件方法声明

❸ 如果任一字段为空,尝试对公共 API 进行身份验证是没有意义的。

❹ 以 JSON 有效负载的形式使用凭证发出身份验证请求。

❺ 如果成功,从响应中存储令牌和用户名。

❻ 告诉路由器更改组件。

❼ 当 alertMessage 的值改变时,触发错误消息的响应式显示

当用户在用户名和密码字段中输入文本时,组件数据属性会更新,并在表单提交时调用login方法。如果调用成功,应用程序将移动到home组件。

以下列表来自 Home.vue 组件的代码,展示了如何使用 JWT 令牌获取用户的总步数。

列表 8.24 使用 Axios 与 JWT 令牌

axios
  .get(`http://localhost:4000/api/v1/${DataStore.username()}/total`, {
    headers: {
      'Authorization': `Bearer ${DataStore.token()}`          ❶
    }
  })
  .then(response => this.totalSteps = response.data.count)    ❷
  .catch(err => {
    if (err.response.status === 404) {
      this.totalSteps = 0
    } else {
      this.alertMessage = err.message
    }
  })

❶ 从登录组件获取的值中传递令牌。

❷ 更新组件数据,触发视图刷新。

现在我们来看看如何使用 Vert.x 提供 Web 应用程序资源。

8.4.4 使用 Vert.x 提供静态内容

Vert.x 代码除了启动 HTTP 服务器和提供静态内容外,没有太多要做。以下列表显示了UserWebAppVerticle类的rxStart方法的内容。

列表 8.25 使用 Vert.x 提供静态内容

@Override
public Completable rxStart() {
  Router router = Router.router(vertx);

  router.route().handler(StaticHandler.create("webroot/assets"));    ❶
  router.get("/*").handler(ctx -> ctx.reroute("/index.html"));       ❷

  return vertx.createHttpServer()
    .requestHandler(router)
    .rxListen(HTTP_PORT)
    .ignoreElement();
}

❶ 在类路径中解析 webroot/assets 的静态内容。

❷ 别名 /* 到 /index.html.

StaticHandler在内存中缓存文件,除非在调用create方法时进行其他配置。在开发模式下禁用缓存很有用,因为你可以修改静态资源的内容,并通过在网页浏览器中重新加载来查看更改,而无需重新启动 Vert.x 服务器。默认情况下,静态文件是从类路径中的 webroot 文件夹解析的,但你可以通过指定 webroot/assets 来覆盖它,就像我们做的那样。

既然我们已经讨论了如何使用 Vert.x Web 栈,现在是时候关注测试组成响应式应用程序的服务了。

8.5 编写集成测试

测试是一个非常重要的关注点,尤其是在 10k 步挑战反应式应用程序的制作中涉及多个服务时。测试用户 Web 应用程序服务是否正确提供静态内容是没有意义的,但拥有涵盖与公共 API 服务交互的测试至关重要。让我们讨论如何为该服务编写集成测试。

公共 API 源代码揭示了一个 IntegrationTest 类。它包含几个按顺序排列的测试方法,用于检查 API 行为:

  1. 注册一些用户。

  2. 为每个用户获取 JWT 令牌。

  3. 获取用户数据。

  4. 尝试获取另一个用户的数据。

  5. 更新用户数据。

  6. 检查用户的一些活动统计信息。

  7. 尝试检查另一个用户的活动。

由于公共 API 服务依赖于活动和用户配置文件服务,我们需要在测试执行期间使用 fake 服务来模拟它们,或者像数据库一样与所有依赖项一起部署。任何一种方法都可以。在本部分的章节中,我们有时会创建一个模拟服务来运行我们的集成测试,有时我们只是部署实际的服务。

在这种情况下,我们将部署实际的服务,并且需要以自包含和可重复的方式从 JUnit 5 中进行。我们首先需要添加项目依赖项,如下所示。

列表 8.26 运行集成测试的测试依赖项

testImplementation(project(":user-profile-service"))                         ❶
testImplementation(project(":activity-service"))
testImplementation("io.vertx:vertx-pg-client:$vertxVersion")                 ❷
testImplementation("org.testcontainers:junit-jupiter:$testContainersVersion")❸

testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version")
testImplementation("io.vertx:vertx-junit5:$vertxVersion")
testImplementation("io.vertx:vertx-junit5-rx-java2:$vertxVersion")
testImplementation("io.rest-assured:rest-assured:$restAssuredVersion")       ❹
testImplementation("org.assertj:assertj-core:$assertjVersion")

❶ 依赖另一个项目模块

❷ 这用于在 PostgreSQL 中插入数据。稍后会有更多介绍。

❸ 这是运行 Docker 容器的。

❹ 一个用于测试 HTTP 服务的优秀 DSL 库

这些依赖项为我们提供了两个用于编写测试的有用工具:

  • Testcontainers 是一个在 JUnit 测试中运行 Docker 容器的项目,因此我们将能够使用像 PostgreSQL 或 Kafka 这样的基础设施服务(www.testcontainers.org)。

  • REST Assured 是一个专注于测试 HTTP 服务的库,提供了一个方便的流畅 API 来描述请求和响应断言(rest-assured.io)。

测试类的序言如下所示。

列表 8.27 集成测试类的序言

@ExtendWith(VertxExtension.class)                                    ❶
@TestMethodOrder(OrderAnnotation.class)                              ❷
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("Integration tests for the public API")
@Testcontainers                                                      ❸
class IntegrationTest {

  @Container
  private static final DockerComposeContainer CONTAINERS =
    new DockerComposeContainer(new File("../docker-compose.yml"));   ❹
  // (...)
}

❶ 使用 Vert.x JUnit 5 支持。

❷ 测试方法必须按顺序运行。

❸ 使用 Testcontainers 支持。

❹ 从 Docker Compose 文件启动容器。

Testcontainers 提供了启动一个或多个容器的大量选择。它支持通用的 Docker 镜像、针对常见基础设施(PostgreSQL、Apache Kafka 等)的专用类,以及 Docker Compose。在这里,我们重用 Docker Compose 描述符来运行整个应用程序(docker-compose.yml),在运行第一个测试之前启动文件中描述的容器。所有测试执行完毕后,容器将被销毁。这非常有趣——我们可以编写针对实际基础设施服务的集成测试,这些服务将在生产中使用。

prepareSpec 方法被 @BeforeAll 注解,用于准备测试。它为活动服务在 PostgreSQL 数据库中插入一些数据,然后部署用户配置文件和活动垂直服务。它还从 REST Assured 准备一个 RequestSpecification 对象,如下所示。

列表 8.28 准备 REST Assured 请求规范

requestSpecification = new RequestSpecBuilder()
  .addFilters(asList(new ResponseLoggingFilter(), new RequestLoggingFilter()))❶
  .setBaseUri("http://localhost:4000/")
  .setBasePath("/api/v1")                                                     ❷
  .build();

❶ 所有请求和响应都将被记录,这对于跟踪错误很有用。

❷ 这避免了在请求中重复所有 URL 的基本路径。

此对象在所有测试方法之间共享,因为它们都必须向 API 发送请求。我们启用对所有请求和响应的记录,以便更容易调试,并将 /api/v1 设置为所有请求的基本路径。

测试类维护一个用户注册的哈希表,稍后用于调用,以及一个 JWT 令牌的哈希表。

列表 8.29 集成测试的实用哈希表

private final HashMap<String, JsonObject> registrations = new 
➥ HashMap<String, JsonObject>() {                               ❶
  {
    put("Foo", new JsonObject()
      .put("username", "Foo")
      .put("password", "foo-123")
      .put("email", "foo@email.me")
      .put("city", "Lyon")
      .put("deviceId", "a1b2c3")
      .put("makePublic", true));
    // (...)
};

private final HashMap<String, String> tokens = new HashMap<>();  ❷

❶ 用户

❷ 一旦检索到 JWT 令牌

以下列表是第一个测试,其中注册了来自 registrations 哈希表的用户。

列表 8.30 注册用户的测试

@Test
@Order(1)
@DisplayName("Register some users")
void registerUsers() {
  registrations.forEach((key, registration) -> {
    given(requestSpecification)
      .contentType(ContentType.JSON)
      .body(registration.encode())     ❶
      .post("/register")               ❷
      .then()
      .assertThat()
      .statusCode(200);                ❸
  });
}

❶ 我们将 JSON 数据编码为字符串。

❷ 向 /api/v1/register 发送 HTTP POST 请求

❸ 断言状态码是 200。

REST Assured 流畅 API 允许我们表达我们的请求,然后对响应进行断言。我们可以将响应作为文本或 JSON 提取以进行进一步的断言,如下所示,这是从检索 JWT 令牌的测试方法中提取的。

列表 8.31 获取 JWT 令牌的测试代码

JsonObject login = new JsonObject()
  .put("username", key)
  .put("password", registration.getString("password"));

String token = given(requestSpecification)
  .contentType(ContentType.JSON)
  .body(login.encode())
  .post("/token")
  .then()
  .assertThat()
  .statusCode(200)
  .contentType("application/jwt")    ❶
  .extract()                         ❷
  .asString();
assertThat(token)                    ❸
  .isNotNull()
  .isNotBlank();

tokens.put(key, token);

❶ 断言响应中的内容类型头与 JWT 令牌匹配。

❷ 提取响应。

❸ 在字符串上使用 AssertJ 断言

测试获取一个令牌,然后断言该令牌既不是 null 值也不是空白字符串(空或包含空格)。提取 JSON 数据的方式类似,如下所示。

列表 8.32 使用 REST Assured 提取 JSON

JsonPath jsonPath = given(requestSpecification)
  .headers("Authorization", "Bearer " + tokens.get("Foo"))         ❶
  .get("/Foo/total")
  .then()
  .assertThat()
  .statusCode(200)
  .contentType(ContentType.JSON)
  .extract()
  .jsonPath();

assertThat(jsonPath.getInt("count")).isNotNull().isEqualTo(6255);  ❷

❶ 传递 JWT 令牌。

❷ 与 JSON 表示形式一起工作。

测试获取用户 Foo 的总步骤数,提取 JSON 响应,然后检查步骤数(JSON 响应中的 count 键)等于 6255。

集成测试可以使用 Gradle (./gradlew :public-api:test) 或从开发环境运行,如图 8.6 所示。

图片

图 8.6 从 IntelliJ IDEA 运行集成测试

您现在对使用 Vert.x 网络堆栈来公开端点和消费其他服务有了很好的理解。下一章将重点介绍 Vert.x 的消息和事件流堆栈。

摘要

  • Vert.x 网络模块使得构建支持 CORS 和对其他服务进行 HTTP 调用的边缘服务变得简单。

  • JSON 网络令牌在公共 API 的授权和访问控制中非常有用。

  • Vert.x 对前端应用程序框架没有偏好,但它很容易集成 Vue.js 前端应用程序。

  • 通过结合由 Testcontainers 管理的 Docker 容器和 Rest Assured 库,您可以编写针对 HTTP API 的集成测试。

9 使用 Vert.x 进行消息和事件流

本章涵盖

  • 使用 AMQP 进行消息传递

  • 使用 Apache Kafka 进行事件流

  • 发送电子邮件

  • 使用消息和事件流中间件进行集成测试

反应式应用程序非常适合消息和事件流技术。到目前为止,我们主要关注的是暴露 HTTP API 的服务。但尽管 HTTP 是与服务交互的灵活和有效的协议,它不应该是唯一的选择。

使用消息和事件流集成 Vert.x 基于的服务有几种选择。本章介绍了 AMQP 消息代理和 Apache Kafka。我们还将讨论使用 SMTP 服务器发送电子邮件。

在本章中,我们将深入了解 ingester 和祝贺服务的实现。ingester 通过 HTTP 和 AMQP 从设备接收步骤更新,并将它们作为 Kafka 事件转发到系统中。祝贺服务监听特定的 Kafka 事件,以确定用户是否在一天内达到了 10,000 步,并发送祝贺电子邮件。

9.1 使用 Vert.x 超越 HTTP 的事件驱动服务

HTTP 是作为事件驱动服务的网络接口的明智选择,尤其是当服务提供 API 时。消息和事件流中间件为解耦和集成服务提供了有用的工具。它们通常比 HTTP 更适合在服务之间交换大量事件。

9.1.1 Vert.x 提供的内容

Vert.x 提供了消息代理、与 Apache Kafka 的事件流以及用于事件总线的通用 TCP 协议的客户端。

消息代理

消息中间件在服务到服务的通信中可能比 HTTP 更有效,具有更好的吞吐量,并且当消费者或生产者服务暂时不可用时,还可以提供持久性保证。Vert.x 提供了几个模块来进行与消息中间件的集成工作:

  • 一个 高级消息队列协议 (AMQP) 客户端

  • 一个 简单文本导向的消息协议 (STOMP) 客户端

  • 一个 RabbitMQ 客户端

  • 一个 消息队列遥测传输 (MQTT) 客户端

AMQP 是消息中间件的标准协议,由 Apache ActiveMQ、JBoss A-MQ、Windows Azure Service Bus、RabbitMQ 等大量代理实现。Vert.x 为 RabbitMQ 和其扩展提供了一个专门的客户端。请注意,也可以使用 Vert.x AMQP 客户端与 RabbitMQ 一起使用,因为它除了 RabbitMQ 特定的服务器外,还公开了一个 AMQP 服务器。

STOMP 是一种基于文本的消息中间件协议。它的功能比 AMQP 少,但对于简单的消息传递可能已经足够。它得到了流行消息代理的支持。

MQTT 是一种为机器到机器的发布/订阅交互设计的协议。它因为使用低带宽而在嵌入式/物联网设备中非常流行。

Kafka 事件流

Vert.x 提供了对 Apache Kafka 的支持,它是事件流中间件的流行实现。

初看起来,事件流中间件类似于消息系统,但它允许有趣的架构模式,因为不同的服务可以以自己的节奏消费相同的事件集。消息代理支持发布/订阅机制,使多个服务能够消费相同的事件,但事件流中间件也有随意回放事件的能力。回滚事件流是一个独特的功能。事件流中间件还允许在不影响其他服务的情况下,将新服务插入到处理管道中。

您可以使用事件流中间件就像消息中间件一样,但它不仅仅是服务之间传递事件。

事件总线 TCP 适配器

最后但同样重要的是,Vert.x 通过简单的 TCP 协议提供事件总线适配器,支持 JavaScript、Go、C#、C 和 Python 的绑定。这使我们能够使用事件总线与非 Java 应用程序进行连接。本书中不会介绍此事件总线适配器,但您可以从官方 Vert.x 文档中轻松学习如何使用它。从 Vert.x 的角度来看,这实际上只是事件总线,只不过某些事件可以在 JVM 之外产生和消费。

9.1.2 我们将使用的中间件和服务

10k 步挑战应用程序使我们能够探索用于消息传递的 AMQP,用于事件流处理的 Kafka,以及通过 SMTP 服务器发送电子邮件:

  • 摄取服务使用 AMQP,因为它通过 HTTP 或 AMQP 接收计步器设备更新。

  • Kafka 用于在应用程序的许多服务之间传递事件。

  • SMTP 用于向用户发送祝贺邮件。

如前一章所述,Docker Compose 可以用于启动本地开发所需的中间件服务:Apache Kafka(它还需要 Apache ZooKeeper)、Apache ActiveMQ Artemis 和 MailHog(一个友好的 SMTP 服务器测试)。当然,如果您想的话,您可以自己安装和运行每个服务,但使用 Docker 启动可丢弃的容器可以提供简化的开发体验。

在 Vert.x 方面,我们将使用以下模块来构建我们的服务:

  • vertx-amqp-client--AMQP 客户端

  • vertx-kafka-client--Apache Kafka 客户端

  • vertx-mail-client--将发送电子邮件的 SMTP 客户端

9.1.3 什么是 AMQP(以及消息代理)?

高级消息队列协议(AMQP)是一个广泛使用的网络协议,用于支持开放规范的消息中间件。该协议本身是二进制的,基于 TCP,并支持身份验证和加密。在项目中,我们将使用 Apache ActiveMQ,它支持 AMQP。

消息代理是服务集成的一种经典形式,因为它们通常支持消息队列和发布/订阅通信。它们允许服务通过消息传递进行通信,并且代理确保消息的持久性。

图 9.1 展示了设备、收集步数事件的 AMQP 队列和摄取服务之间的交互。

图 9.1 AMQP 队列概述

消息可以被设置为 持久化,这样即使代理崩溃也不会丢失。生产者和消费者可以使用确认来确保消息已被正确发送或检索并处理。代理还提供各种服务质量特性,例如过期日期和高级路由。根据代理的不同,消息可以从一种表示形式转换为另一种表示形式,例如从二进制格式转换为 JSON。一些代理还支持将多个消息聚合到一个消息中,或者相反,将一个消息分割成多个。

注意:如果您是 ActiveMQ 的初学者,我建议阅读 Bruce Snyder、Dejan Bosanac 和 Rob Davies 所著的 ActiveMQ in Action(Manning,2011 年)。

9.1.4 什么是 Kafka?

Apache Kafka 是基于分布式日志的事件流中间件。虽然这可能听起来很复杂,但您真正需要理解的是 Kafka 提供事件记录流,其中生产者可以追加新记录,消费者可以在流中前后移动。例如,来自计步器的步数更新形成了一个流,其中每个事件都是设备发送的更新,而摄入服务生成这些事件。另一方面,各种消费者可以查看该流上的事件,以填充数据库、计算统计数据等。事件在一段时间内保持流状态,或者直到流太大而必须丢弃其最旧的记录。

Kafka 支持分布式服务之间的发布/订阅交互,如图 9.2 所示。在 Kafka 集群中,事件从 topics发布消费,这些 topics 将相关事件分组。topics 被分割成 复制分区,它们是有序的事件序列。每个事件通过其在事件日志中的 偏移量 位置来标识,该位置实现了其分区。

图片

图 9.2 Kafka 主题概述

消费者从分区中拉取事件。它们可以跟踪最后消费的偏移量,但也可以任意地定位到分区中的任何随机位置,甚至可以重放从开始以来的所有事件。此外,消费者组可以通过从不同的分区读取和并行处理事件来分担工作。

容易想到 Kafka 是一个像 ActiveMQ 一样的 消息 系统,在某些情况下 Kafka 确实是一个非常优秀的消息中间件,但它仍然应该被视为 中间件。

在消息代理中,当消息从队列中消费或过期时,消息会消失。Kafka 分区最终会驱逐记录,要么使用分区大小限制(例如 2 GB),要么使用某些驱逐延迟(例如两周)。Kafka 记录应被视为“半持久”,因为它们最终会消失。可以配置主题中的分区以永久保留事件,但这相当罕见,因为预期事件在消费时会产生持久效果。例如,接收服务生成传入的步数更新记录,活动服务将这些记录转换为数据库中的长期事实。Kafka 的另一个有趣特性是,可以随意重放主题,因此新服务可以以自己的节奏加入并消费流。

注意:如果您是 Apache Kafka 的新手,建议阅读 Dylan Scott 的《Kafka in Action》(Manning,2017)。

让我们现在深入了解接收服务。

9.2 通过 HTTP 和 AMQP 可靠地接收消息

一切都从接收服务开始,因为它接收计步器的步数更新。在我们的(虚构的)应用中,我们可以预期会有多种类型的计步器可用,并且它们具有不同的通信能力。例如,一些设备可以直接通过互联网与接收服务通信,而其他设备可能需要连接到一个网关,该网关将更新转发到接收服务。

正因如此,我们提供了两个接口来接收设备更新:

  • 设备可以连接到接收服务提供的 HTTP API。

  • 设备可以将更新转发到消息代理,接收服务从代理接收更新。

一旦收到更新,就必须对其进行验证,然后发送到 Kafka 主题。探索 AMQP 和 HTTP 接口都很有趣,因为我们可以在它们的实现中看到相似之处,同时也能看到在确认设备更新方面的差异。

9.2.1 从 AMQP 接收

我们将从 AMQP 接收开始。我们首先需要创建一个连接到代理的 AMQP 客户端。以下列表显示了客户端配置代码。

列表 9.1 AMQP 客户端配置

private AmqpClientOptions amqpConfig() {
  return new AmqpClientOptions()
    .setHost("localhost")
    .setPort(5672)
    .setUsername("artemis")                    ❶
    .setPassword("simetraehcapa");
}
// (...)

AmqpClientOptions amqpOptions = amqpConfig();
AmqpReceiverOptions receiverOptions = new AmqpReceiverOptions()
  .setAutoAcknowledgement(false)               ❷
  .setDurable(true);                           ❸

❶ 凭证是 Docker 镜像中的默认凭证。

❷ 我们将手动确认传入的消息。

❸ 我们希望使用持久消息。

我们在这里使用的 amqpConfig 方法提供了一个具有硬编码值的配置。这对于我们在本书中进行的测试来说很棒,但当然,对于生产环境,您需要从外部来源解决凭证、主机名和端口号。这些可以是环境变量或注册服务,例如 Apache ZooKeeper 或 Consul。我们还设置了持久消息的连接,并声明了手动确认,因为我们希望在写入 Kafka 主题失败时重试消息处理。

下一步是设置用于传入 AMQP 消息的事件处理管道。我们使用 RxJava 将消息调度到处理函数,记录错误并从错误中恢复,如下面的列表所示。

列表 9.2 AMQP 事件处理管道

AmqpClient.create(vertx, amqpOptions)                                      ❶
  .rxConnect()
  .flatMap(conn -> conn.rxCreateReceiver("step-events", receiverOptions))  ❷
  .flatMapPublisher(AmqpReceiver::toFlowable)                              ❸
  .doOnError(this::logAmqpError)                                           ❹
  .retryWhen(this::retryLater)                                             ❺
  .subscribe(this::handleAmqpMessage);                                     ❻

❶ 创建一个 AMQP 客户端。

❷ 从步骤事件目的地创建一个消息接收器。

❸ 创建一个 AMQP 消息的 Flowable

❹ 错误记录

❺ 重试逻辑

❻ 分发传入消息的订阅

这个管道很有趣,因为它完全是声明式的。它从创建客户端开始,然后获取 step-events 持久队列的接收器和消息流。从那里我们声明在接收到消息或错误时要做什么。我们还通过使用 Java 方法引用而不是 lambda 表达式来保持代码简短和干净。但 logAmqpErrorretryLaterhandleAmqpMessage 方法具体做什么呢?

记录消息并不复杂。

列表 9.3 记录 AMQP 错误

private void logAmqpError(Throwable err) {
  logger.error("Woops AMQP", err);           ❶
}

❶ 记录错误和堆栈跟踪。

错误是会发生的。例如,我们可能会失去与 AMQP 代理的连接。在这种情况下,错误会沿着管道传递,logAmqpError 会记录它,但 doOnError 允许错误传播给订阅者。

然后,我们需要重新连接到 AMQP 代理并继续接收事件,这在 RxJava 中相当于重新订阅源。我们可以使用 retryWhen 操作符来实现,因为它允许我们定义自己的策略。如果你只想重试几次,甚至总是重试,那么 retry 就更简单。下面的列表显示了我们在重新订阅之前引入了 10 秒的延迟。

列表 9.4 使用延迟重新订阅从错误中恢复

private Flowable<Throwable> retryLater(Flowable<Throwable> errs) {
  return errs.delay(10, TimeUnit.SECONDS, RxHelper.scheduler(vertx));   ❶
}

❶ 使用调度器参数在 Vert.x 事件循环上处理事件是很重要的。

retryLater 操作符的工作方式如下:

  • 它接受一个错误 Flowable 作为输入,因为我们处于 AMQP 消息的 Flowable 中。

  • 它返回一个 Flowable任何东西,其中

    • 发出 onCompleteonError 不会触发重新订阅。

    • 发出 onNext(无论值是什么)都会触发重新订阅。

为了延迟重新订阅 10 秒,我们使用 delay 操作符。它最终会发出一个值,因此 onNext 会被调用,并发生重新订阅。当然,你可以考虑更复杂的处理程序,比如限制重试次数或使用指数退避策略。我们将大量使用这种模式,因为它极大地简化了错误恢复逻辑。

9.2.2 将 AMQP 消息转换为 Kafka 记录

下面的列表包含处理传入 AMQP 消息、验证它们并将它们作为 Kafka 记录推送的方法。

列表 9.5 处理 AMQP 消息

private void handleAmqpMessage(AmqpMessage message) {
  if (!"application/json".equals(message.contentType()) || 
  ➥ invalidIngestedJson(message.bodyAsJsonObject())) {                     ❶
    logger.error("Invalid AMQP message (discarded): {}", 
    ➥ message.bodyAsBinary());
    message.accepted();
    return;
  }
  JsonObject payload = message.bodyAsJsonObject();
  KafkaProducerRecord<String, JsonObject> record = makeKafkaRecord(payload);❷
  updateProducer.rxSend(record).subscribe(
    ok -> message.accepted(),                                               ❸
    err -> {
      logger.error("AMQP ingestion failed", err);
      message.rejected();                                                   ❹
    });
}

❶ 检查有效的 JSON 消息。

❷ 准备 Kafka 记录。

❸ 确认 AMQP 消息。

❹ 拒绝 AMQP 消息。

handleAmqpMessage方法首先对传入的 AMQP 消息进行一些验证,然后准备一个 Kafka 记录。当 Kafka 记录被写入时,AMQP 消息被确认,如果记录无法写入,则被拒绝。

提示:在列表 9.5 及其后续服务中,我们将直接处理JsonObject数据表示。鉴于我们主要复制和转换数据,将 JSON 表示转换为 Java 域类(如IngestionData类)几乎没有意义。当然,如果您必须执行一些更复杂的业务逻辑,并且抽象的成本是合理的,您当然可以执行此类映射。

invalidIngestedJson方法检查 JSON 数据是否包含所有必需条目,如下所示。

列表 9.6 检查有效 JSON 数据

private boolean invalidIngestedJson(JsonObject payload) {
  return !payload.containsKey("deviceId") ||                ❶
    !payload.containsKey("deviceSync") ||
    !payload.containsKey("stepsCount");
}

❶ 检查 JSON 条目

下一个列表中的makeKafkaRecord方法将 AMQP 消息的 JSON 转换为针对incoming-steps主题的 Kafka 记录。

列表 9.7 准备 Kafka 记录

private KafkaProducerRecord<String, JsonObject> makeKafkaRecord(JsonObject 
➥ payload) {
  String deviceId = payload.getString("deviceId");
  JsonObject recordData = new JsonObject()                                  ❶
    .put("deviceId", deviceId)
    .put("deviceSync", payload.getLong("deviceSync"))
    .put("stepsCount", payload.getInteger("stepsCount"));
  return KafkaProducerRecord.create("incoming.steps", deviceId, recordData);❷
}

❶ 我们复制 JSON 数据。

❷ 包含 deviceId 键和 JSON 数据的记录

我们可以避免手动复制所有 JSON 条目,只需将 AMQP 消息中的 JSON 传递到 Kafka 记录中。然而,这有助于确保没有额外数据最终出现在 Kafka 记录中。

updateProducer字段的数据类型为KafkaProducer<String, JsonObject>,因为它使用字符串键和 JSON 有效负载来生成消息。通过以下方式传递来自Map的配置来创建KafkaProducer实例。

列表 9.8 配置 Kafka 生产者

Map<String, String> kafkaConfig() {
  Map<String, String> config = new HashMap<>();
  config.put("bootstrap.servers", "localhost:9092");
  config.put("key.serializer", 
  ➥ "org.apache.kafka.common.serialization.StringSerializer");      ❶
  config.put("value.serializer", 
  ➥ "io.vertx.kafka.client.serialization.JsonObjectSerializer");    ❷
  config.put("acks", "1");
  return config;
}
// (...)

// in rxStart()
updateProducer = KafkaProducer.create(vertx, kafkaConfig());         ❸

❶ 用于从字符串序列化值的类

❷ 用于从 Vert.x JsonObject 序列化值的类

❸ 创建一个 Vert.x Kafka 生产者。

配置特别指定了序列化器(或反序列化器)类,因为 Kafka 记录需要映射到 Java 类型。StringSerializer来自 Kafka 客户端库,它将 Java 字符串序列化为 Kafka 数据,而JsonObjectSerializer来自 Vert.x,它序列化JsonObject数据。您需要为您的键和值指定正确的序列化器类。类似地,当从 Kafka 主题读取时,您还需要配置反序列化器。

提示:Vert.x Kafka 模块封装了 Apache Kafka 项目的 Java 客户端,所有配置键/值对都与 Kafka Java 客户端文档中的匹配。

9.2.3 从 HTTP 摄取

从 HTTP 摄取的代码与使用 AMQP 摄取的代码非常相似。最显著的区别是需要设置 HTTP 状态码,以便发送更新的设备知道摄取已失败,必须稍后重试。

我们首先需要一个 HTTP 服务器和路由器。

列表 9.9 HTTP 服务器用于摄取

Router router = Router.router(vertx);
router.post().handler(BodyHandler.create());        ❶
router.post("/ingest").handler(this::httpIngest);

return vertx.createHttpServer()
  .requestHandler(router)
  .rxListen(HTTP_PORT)
  .ignoreElement();

❶ BodyHandler 解码 HTTP 请求体。

下一个列表显示了httpIngest方法,它与handleAmqpMessage非常相似。

列表 9.10 从 HTTP 摄取更新

private void httpIngest(RoutingContext ctx) {
  JsonObject payload = ctx.getBodyAsJson();
  if (invalidIngestedJson(payload)) {                                    ❶
    logger.error("Invalid HTTP JSON (discarded): {}", payload.encode());
    ctx.fail(400);                                                       ❷
    return;
  }
  KafkaProducerRecord<String, JsonObject> record = makeKafkaRecord(payload);
  updateProducer.rxSend(record).subscribe(
    ok -> ctx.response().end(),                                          ❸
    err -> {
      logger.error("HTTP ingestion failed", err);
      ctx.fail(500);                                                     ❹
    });
}

❶ 检查 JSON 条目。

❷ 坏 JSON;让请求者知道这一点。

❸ 成功摄取

❹ 摄取失败;让请求者知道这一点。

HTTP 状态码对于让客户端知道负载是否不正确(400),摄入是否由于某些(暂时性)错误而失败(500),或者摄入是否成功(200)非常重要。

摄入服务是使用不同输入协议进行集成的一个很好的例子。现在让我们通过祝贺服务来探索更多 Apache Kafka 与 Vert.x 的集成。

9.3 发送祝贺邮件

当摄入服务 生产 Kafka 事件时,祝贺服务 消费 Kafka 事件。

活动服务在收到设备更新时生成每日步数事件。每个事件包含当天为原始设备记录的步数。祝贺服务可以观察这些事件,当它们发送到 daily.step.updates Kafka 主题时,并且它可以针对步数超过 10,000 的事件。

9.3.1 监听每日步数更新事件

发送到 daily.step.updates Kafka 主题的事件是包含以下内容的 JSON 数据:

  • deviceId 是设备标识符。

  • timestamp 是事件在活动服务中被生产时的戳记。

  • stepsCount 是当天的步数。

Kafka 记录还有一个键,它是几个参数的连接:deviceId:year-month-day。在这个方案中,2019 年 10 月 6 日生产的设备 1a2b 的所有记录都有键 1a2b:2019-10-06。正如你很快就会看到的,键不仅有助于确保给定设备的事件按顺序消费,而且还有助于确保我们每天不会发送超过一封祝贺邮件。

处理每日步数事件的管道如图 9.3 所示。

图 9.3 从每日步数到祝贺邮件的管道

每日步数更新从 daily.step.updates Kafka 主题流出来,然后

  1. 我们丢弃步数少于 10,000 的事件。

  2. 我们丢弃已经处理过相同键的事件。

  3. 我们发送邮件。

以下列表包含相应的 RxJava 管道。

列表 9.11 接收和处理每日步数更新的 Kafka RxJava 管道

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumerConfig("congrats-service"))
  .subscribe("daily.step.updates")                            ❶
  .toFlowable()
  .filter(this::above10k)                                     ❷
  .distinct(KafkaConsumerRecord::key)                         ❸
  .flatMapSingle(this::sendmail)                              ❹
  .doOnError(err -> logger.error("Woops", err))
  .retryWhen(this::retryLater)                                ❺
  .subscribe(mailResult -> logger.info("Congratulated {}", 
➥ mailResult.getRecipients()));                              ❻

❶ 订阅 Kafka 主题。

❷ 过滤掉步数少于 10,000 的事件。

❸ 丢弃已经处理过相同键的先前事件。

❹ 异步操作发送邮件

❺ 错误时重试。

❻ 记录每次成功的祝贺。

前面的列表使用 RxJava 绑定将 Kafka 主题订阅为 Flowable Kafka 记录。然后我们使用 filter 组合器过滤掉步数少于 10,000 的记录,并使用以下列表中的断言方法。

列表 9.12 至少有 10,000 步事件的断言

private boolean above10k(KafkaConsumerRecord<String, JsonObject> record) {
  return record.value().getInteger("stepsCount") >= 10_000;                ❶
}

❶ 对 JSON 数据进行断言。

列表 9.11 中的 distinct 组合器确保在 filter 之后仅保留每个 Kafka 记录键的一个事件,这是为了避免在给定的一天向用户发送超过一封祝贺邮件,因为我们很容易有一个包含 10,100 步的第一事件,随后又有一个包含 10,600 步的另一个事件,等等。请注意,这种设计并非 100% 无懈可击,因为它需要在内存中存储已处理的键值,并且在服务重启时可能会意外地发送第二封电子邮件。与仅为了跟踪用户最后一次收到电子邮件的时间而使用持久数据存储相比,在我们的示例中这是一个合理的权衡。

管道中的其余部分使用类似的事件处理和 retryWhen 逻辑来在错误发生时重新订阅。sendmail 方法是一个发送电子邮件的异步操作——让我们看看它是如何工作的。

9.3.2 发送电子邮件

vertx-mail-client 模块提供了一个 SMTP 客户端。以下列表显示了如何创建这样的客户端。

列表 9.13 创建 SMTP 客户端

MailClient mailClient = MailClient.createShared(vertx, MailerConfig.config());❶

❶ 创建一个共享实例

与许多其他 Vert.x 客户端一样,我们通过工厂方法获取一个实例,传递一个 Vertx 上下文以及一些参数。

MailerConfig 类提供了一个方法来检索配置数据,如下所示。

列表 9.14 邮件客户端配置

class MailerConfig {
  static MailConfig config() {
    return new MailConfig()
      .setHostname("localhost")    ❶
      .setPort(1025);              ❷
  }
}

❶ 服务器主机

❷ 服务器端口

再次,这些硬编码的值对于测试目的和保持我们的代码简单是合适的。这些值是用于连接到 MailHog,即我们从 Docker 容器中使用的测试 SMTP 服务器。MailConfig 类支持更多的配置选项,如 SSL、认证方法、凭证等。

每日步骤更新 Kafka 事件适用于一个设备;它不包含所有者名称或电子邮件地址。在我们能够发送电子邮件之前,我们必须首先从用户配置文件服务中获取缺失的信息(名称和电子邮件)。因此,我们需要对该服务发出两个请求:

  • 一个形式为 /owns/deviceId 的请求,用于获取用户名

  • 一个形式为 /username 的请求,用于获取用户配置文件并检索电子邮件地址

sendmail 方法如下所示。

列表 9.15 sendmail 方法的实现

private Single<MailResult> sendmail(KafkaConsumerRecord<String, JsonObject> 
➥ record) {
  String deviceId = record.value().getString("deviceId");          ❶
  Integer stepsCount = record.value().getInteger("stepsCount");
  return webClient
    .get(3000, "localhost", "/owns/" + deviceId)                   ❷
    .as(BodyCodec.jsonObject())
    .rxSend()
    .map(HttpResponse::body)                                       ❸
    .map(json -> json.getString("username"))                       ❹
    .flatMap(this::getEmail)                                       ❺
    .map(email -> makeEmail(stepsCount, email))                    ❻
    .flatMap(mailClient::rxSendMail);                              ❼
}

❶ 提取设备标识符。

❷ 准备一个请求以查找设备的所有者。

❸ 提取正文,它是一个 JsonObject。

❹ 提取用户名值。

❺ 获取用户电子邮件的异步操作

❻ 准备电子邮件消息。

❼ 异步发送电子邮件。

sendmail 方法是另一个 RxJava 管道,它组合了异步操作和数据处理,如图 9.4 所示。

图片

图 9.4 准备并发送祝贺邮件的异步操作

它首先向用户配置文件服务发出 HTTP 请求,找到设备所有者的用户名。然后准备另一个请求以获取用户配置文件数据以获取电子邮件地址。以下列表提供了getEmail方法的实现。

列表 9.16 请求检索电子邮件地址

private Single<String> getEmail(String username) {
  return webClient
    .get(3000, "localhost", "/" + username)
    .as(BodyCodec.jsonObject())
    .rxSend()                                ❶
    .map(HttpResponse::body)
    .map(json -> json.getString("email"));   ❷
}

❶ 发送请求。

❷ 仅保留电子邮件地址。

下一步是准备一个电子邮件,封装在MailMessage实例中,如下面的makeEmail方法的实现所示。

列表 9.17 准备电子邮件消息

private MailMessage makeEmail(Integer stepsCount, String email) {
  return new MailMessage()
    .setFrom("noreply@tenksteps.tld")                           ❶
    .setTo(email)                                               ❷
    .setSubject("You made it!")                                 ❸
    .setText("Congratulations on reaching " + stepsCount + " 
    ➥ steps today!\n\n- The 10k Steps Team\n");                ❹
}

❶ 发送者地址

❷ 收件人地址

❸ 主题

❹ 消息体

注意,对于更高级的电子邮件格式化,你可以使用模板引擎而不是文本。

现在你已经知道了如何在 Vert.x 中执行消息传递和事件流,让我们不要忘记集成测试,以确保摄入和祝贺服务都能正确工作。

9.4 集成测试

测试摄入服务包括通过 AMQP 和 HTTP 发送设备更新,并观察 Kafka 主题。相反,测试祝贺服务包括向 Kafka 主题发送事件,并观察电子邮件。

9.4.1 摄入测试

测试摄入服务需要通过 AMQP 或 HTTP 发送消息,然后检查是否已发出 Kafka 记录,如图 9.5 所示。

图 9.5 Ponge

图 9.5 摄入集成测试概述

摄入服务源代码中的IntegrationTest类使用 JUnit 5 和 Docker 容器启动 AMQP 代理、Apache Kafka 和 Apache ZooKeeper。以下列表显示了测试准备。

列表 9.18 摄入测试准备

@BeforeEach
void setup(Vertx vertx, VertxTestContext testContext) {
  kafkaConsumer = KafkaConsumer.create(vertx, kafkaConfig());          ❶
  amqpClient = AmqpClient.create(vertx, amqClientOptions());          ❷
  KafkaAdminClient adminClient = KafkaAdminClient.create(vertx, 
  ➥ kafkaConfig());                                                   ❸
  vertx
    .rxDeployVerticle(new IngesterVerticle())                          ❹
    .delay(500, TimeUnit.MILLISECONDS, RxHelper.scheduler(vertx))
    .flatMapCompletable(id -> 
    ➥ adminClient.rxDeleteTopics(singletonList("incoming.steps")))    ❺
    .onErrorComplete()
    .subscribe(testContext::completeNow, testContext::failNow);
}

❶ Kafka 消费者

❷ AMQP 客户端

❸ 客户端管理 Kafka

❹ 部署摄入 verticle。

❺ 如果存在,删除所有incoming.steps主题。

准备包括部署IngesterVerticle verticle,然后删除任何现有的incoming.steps主题。这确保测试不会因剩余的 Kafka 事件而相互污染。注意onErrorComplete组合器:它确保进度,因为删除主题在它们不存在时会引发错误。我们希望在incoming.steps不存在时运行测试,这通常是第一个测试运行的情况。当然,onErrorComplete可能会掩盖IngesterVerticle部署失败,但我们在测试执行中会发现这一点。

以下列表显示了测试案例的前言,其中正在摄入格式良好的 AMQP 消息。

列表 9.19 AMQP 摄入测试前言

@Test
@DisplayName("Ingest a well-formed AMQP message")
void amqIngest(VertxTestContext testContext) {
  JsonObject body = new JsonObject().put("deviceId", "123")
    .put("deviceSync", 1L).put("stepsCount", 500);
  amqpClient.rxConnect()                                              ❶
    .flatMap(connection -> connection.rxCreateSender("step-events"))  ❷
    .subscribe(sender -> {
        AmqpMessage msg = AmqpMessage.create()                        ❸
          .durable(true)
          .ttl(5000)
          .withJsonObjectAsBody(body).build();
        sender.send(msg);                                             ❹
      },
      testContext::failNow);
  // (...)
}

❶ 打开 AMQP 客户端连接。

❷ 创建一个发送到步骤事件目标的发送者。

❸ 创建一个 AMQP 消息。

❹ 发送消息。

AMQP 客户端发送的消息格式良好,因为其体中包含所有必需的 JSON 条目。

完成此操作后,我们需要检查是否已发送 Kafka 记录,如下所示。

列表 9.20 AMQP 摄入测试:检查 Kafka 记录

kafkaConsumer.subscribe("incoming.steps")                      ❶
  .toFlowable()
  .subscribe(
    record -> testContext.verify(() -> {                       ❷
      assertThat(record.key()).isEqualTo("123");
      JsonObject json = record.value();
      assertThat(json.getString("deviceId")).isEqualTo("123");
      assertThat(json.getLong("deviceSync")).isEqualTo(1L);
      assertThat(json.getInteger("stepsCount")).isEqualTo(500);
      testContext.completeNow();                               ❸
    }),
    testContext::failNow);                                     ❹

❶ 订阅 Kafka 主题。

❷ 对 Kafka 记录执行断言。

❸ 测试通过。

❹ 在任何错误发生时失败测试。

当然,我们还需要测试发送不正确的消息会发生什么,比如一个空的 JSON 文档。我们必须检查没有 Kafka 记录被发出,如下所示。

列表 9.21 摄取一个坏的 JSON 文档

@Test
@DisplayName("Ingest a badly-formed AMQP message and observe no Kafka record")
void amqIngestWrong(Vertx vertx, VertxTestContext testContext) {
  JsonObject body = new JsonObject();                             ❶
  // (...)                                                        ❷

  kafkaConsumer.subscribe("incoming.steps")
    .toFlowable()
    .timeout(3, TimeUnit.SECONDS, RxHelper.scheduler(vertx))      ❸
    .subscribe(
      record -> testContext.failNow(new 
      ➥ IllegalStateException("We must not get a record")),
      err -> {
        if (err instanceof TimeoutException) {                    ❹
          testContext.completeNow();
        } else {
          testContext.failNow(err);
        }
      });
}

❶ 空的 JSON

❷ 发送它(与列表 9.20 中的代码相同)

❸ 等待三秒钟。

❹ 检查这确实是我们预期的错误!

在 RxJava 管道中的超时很重要,因为我们需要留出一些时间来确保没有 Kafka 记录被发送。IntegrationTest 类的其余部分相当相似,有两个针对 HTTP 摄取的测试用例:一个检查当发送正确的有效负载时会发生什么,另一个是有效负载是一个空的 JSON 文档。

9.4.2 庆祝邮件测试

测试庆祝服务的功能比摄取更复杂,因为测试环境中涉及更多的组件,如图 9.6 所示。

图 9.6 庆祝服务集成测试概览

目标是发送 Kafka 记录,然后观察发送的(或未发送的)电子邮件。有趣的是,MailHog 不仅仅是一个 SMTP 服务器;它还提供了一个 Web 界面和 HTTP API 来模拟电子邮件收件箱。这允许我们通过发送 Kafka 记录来执行测试,然后检查收件箱中接收到的电子邮件。

CongratsTest 类具有一个 prepare 初始化方法,该方法创建一个 Kafka 生产者(用于发送 Kafka 事件)和一个 Vert.x Web 客户端(用于查询收件箱)。prepare 方法中准备环境的步骤如下所示。

列表 9.22 准备庆祝服务集成测试

KafkaAdminClient adminClient = KafkaAdminClient.create(vertx, conf);
adminClient
  .rxDeleteTopics(Arrays.asList("incoming.steps", "daily.step.updates"))    ❶
  .onErrorComplete()
  .andThen(vertx.rxDeployVerticle(new CongratsVerticle()))                  ❷
  .ignoreElement()
  .andThen(vertx.rxDeployVerticle(new FakeUserService()))                   ❸
  .ignoreElement()
  .andThen(webClient.delete(8025, "localhost", "/api/v1/messages").rxSend())❹
  .ignoreElement()
  .subscribe(testContext::completeNow, testContext::failNow);

❶ 删除 Kafka 主题。

❷ 部署 verticle。

❸ 部署一个模拟用户账户服务。

❹ 从收件箱中删除所有消息。

我们首先删除现有的 Kafka 主题,然后部署要测试的 verticle。我们还部署了一个 verticle 来模拟用户配置文件服务,并通过向 MailHog 实例发送 HTTP DELETE 请求来删除收件箱中的所有消息。

在测试源中找到的 FakeUserService verticle 提供了一个具有最小功能级别的 HTTP 服务,以替换我们的测试中真实的用户配置文件服务。所有查找设备所有者的请求都指向用户 Foo,检索用户 Foo 的详细信息只提供用户名和电子邮件。以下列表显示了包含用于回答用户详细信息请求的 Foo 用户信息和 CongratsVerticle 运作所需的 JSON 条目的代码摘录。

列表 9.23 FakeUserService 类摘录

router.get("/:username").handler(this::username);             ❶
//(...)

private void username(RoutingContext ctx) {
  logger.info("User data request {}", ctx.request().path());
  JsonObject notAllData = new JsonObject()                    ❷
    .put("username", "Foo")
    .put("email", "foo@mail.tld");
  ctx.response()
    .putHeader("Content-Type", "application/json")
    .end(notAllData.encode());
}

❶ 用户配置文件信息路由

❷ 只包含服务测试所需数据的 JSON

这样我们就能为测试提供良好的祝贺服务隔离。我们也可以部署真实用户配置文件服务,但这将涉及到准备包含一些数据的数据库。当你能够这样做时,总是用模拟服务替换依赖服务更好。

下一个列表显示了检查在 Kafka 记录中少于 10,000 步的情况下没有发送电子邮件的完整测试用例。

列表 9.24 检查在少于 10,000 步的情况下没有发送邮件

@Test
@DisplayName("No email must be sent below 10k steps")
void checkNothingBelow10k(Vertx vertx, VertxTestContext testContext) {
  producer
    .rxSend(record("123", 5000))                                           ❶
    .ignoreElement()
    .delay(3, TimeUnit.SECONDS, RxHelper.scheduler(vertx))                 ❷
    .andThen(webClient
      .get(8025, "localhost", "/api/v2/search?kind=to&query=foo@mail.tld") ❸
      .as(BodyCodec.jsonObject()).rxSend())
    .map(HttpResponse::body)
    .subscribe(
      json -> {
        testContext.verify(() -> 
        ➥ assertThat(json.getInteger("total")).isEqualTo(0));             ❹
        testContext.completeNow();
      },
      testContext::failNow);
}

❶ 设备 123 的 Kafka 记录和 5000 步

❷ 在消息发送后等待三秒钟。

❸ 查询所有邮件,查找 foo@mail.tld 的电子邮件。

❹ 检查没有消息。

MailHog API 允许我们检查发送了哪些消息。接下来的列表检查是否发送了超过 10,000 步的电子邮件。

列表 9.25 检查是否发送了超过 10,000 步的电子邮件

producer
  .rxSend(record("123", 11_000))                                 ❶
  .ignoreElement()
  .delay(3, TimeUnit.SECONDS, RxHelper.scheduler(vertx))
  .andThen(webClient
    .get(8025, "localhost", "/api/v2/search?kind=to&query=foo@mail.tld")
    .as(BodyCodec.jsonObject()).rxSend())
  .map(HttpResponse::body)
  .subscribe(
    json -> {
      testContext.verify(() -> 
      ➥ assertThat(json.getInteger("total")).isEqualTo(1));     ❷
      testContext.completeNow();
    },
    testContext::failNow);

❶ 包含 11,000 步的记录

❷ 我们必须有一个消息。

checkNotTwiceToday 方法中的最后一个测试用例检查了对于超过 10,000 步的两个连续记录只发送了一封电子邮件。由于代码的冗长,我没有在这里重现代码,但你可以从书籍的源代码仓库中获取它。

这就完成了使用消息和事件流设计的两个服务的实现和测试。下一章将专注于 Vert.x 和数据源。

摘要

  • AMQP 是一个用于消息代理的标准协议,你看到了如何使用 Vert.x 和 Apache ActiveMQ 消费和产生 AMQP 消息。

  • Apache Kafka 是一种允许服务随意回放事件的流式事件中间件。Vert.x 提供了与 Kafka 的高效集成。

  • RxJava 允许你以声明式的方式编写事件处理管道,并具有内置的错误恢复功能。

  • 我们通过从测试中发送消息来替换外部组件,探索了使用 AMQP、Kafka 和测试容器编写集成测试的策略。

  • MailHog 是一个测试友好的 SMTP 服务器,它提供了一个方便的 API 来检查发送了哪些电子邮件。

10 使用数据库进行持久状态管理

本章涵盖

  • 使用 MongoDB 存储数据和认证用户

  • 使用 Vert.x 从 PostgreSQL

  • 测试与数据库交互的事件驱动服务的集成测试策略

反应式应用程序倾向于无状态设计,但状态必须被管理。

数据库在大多数应用程序中都是必不可少的,因为数据需要被存储、检索和查询。数据库可以存储各种数据,如应用程序状态、事实或用户凭证。市场上存在不同类型的数据库:有些是通用型的,而有些则针对某些类型的用例、访问模式和数据进行专门化。

在本章中,我们将通过深入了解用户和服务活动的实现来探索 Vert.x 的数据库和状态管理。这些服务将使我们能够使用面向文档的数据库(MongoDB)和关系型数据库(PostgreSQL)。你还将看到如何使用 MongoDB 进行用户认证,以及如何为数据驱动的服务编写集成测试。

10.1 数据库和 Vert.x

Vert.x 提供了广泛的客户端来连接数据源。这些客户端包含与服务器通信的驱动程序,并且可能提供高效的连接管理,如连接池。这对于构建各种服务很有用,从由数据源支持的 API 到混合数据源、消息和 API 的集成服务。

10.1.1 Eclipse Vert.x 堆栈提供的内容

Eclipse Vert.x 项目提供了表 10.1 中列出的数据客户端模块。

表 10.1 Eclipse Vert.x 支持的数据客户端模块

标识符 描述
vertx-mongo-client MongoDB 是一个面向文档的数据库。
vertx-jdbc-client 支持任何提供 JDBC 驱动程序的关系型数据库。
vertx-pg-clientvertx-mysql-client 通过专门的 Vert.x 反应式驱动程序访问 PostgreSQL 和 MySQL 关系型数据库。
vertx-redis-client Redis 是一种多用途的数据结构存储。
vertx-cassandra-client Apache Cassandra 是一个针对大量数据定制的数据库。

你可以在更大的 Vert.x 社区中找到其他类型数据源的驱动程序。这些超出了 Eclipse 基金会项目的范围。

MongoDB 是一个流行的面向文档的数据库;它与 Vert.x 很好地匹配,因为它操作 JSON 文档。Redis 是一个内存中的数据结构存储,具有可配置的磁盘数据快照,可以用作缓存、数据库和消息代理。Apache Cassandra 是一个多节点、复制的数据库,旨在存储大量数据。Cassandra 非常适合以数百个或甚至数个千兆字节计量的数据库。当然,你也可以用它来存储几个千兆字节的数据,但在这些情况下,更传统的数据库可能就足够了。

谈到“传统”的关系数据库,Vert.x 可以连接到任何有 JDBC 驱动的“东西”。话虽如此,JDBC 是一种基于多线程设计和阻塞 I/O 的较老协议。Vert.x 中的 JDBC 支持将数据库调用卸载到工作线程池,并将结果推回到事件循环上下文中。这是为了避免阻塞事件循环,因为 JDBC 调用确实会阻塞。这种设计限制了可伸缩性,因为需要工作线程,但对于中等负载来说应该没问题。

如果你使用 PostgreSQL 或 MySQL,Vert.x 提供了自己的反应式驱动程序。这些驱动程序实现了每个数据库服务器的网络协议,并且它们完全以异步方式构建,使用 Netty,这是 Vert.x 的网络基础。这些驱动程序在延迟和并发连接方面都提供了出色的性能。它们也非常稳定,并实现了数据库的当前协议和功能。你应该优先考虑 PostgreSQL 和 MySQL 的 Vert.x 反应式驱动程序客户端,并在需要连接到其他数据库时使用 JDBC 客户端。

如果你正在寻找一个可靠的数据库,PostgreSQL 可能是一个不错的选择。PostgreSQL 通用性强,多年来在各种小型和大型项目中都得到了应用。当然,你可以将其用作传统的关系数据库,但它也支持作为一等对象的 JSON 文档,以及通过 PostGIS 扩展的地理对象。

10.1.2 关于数据/对象映射的注意事项,以及为什么你可能并不总是需要它

在我们深入探讨使用 MongoDB 的用户配置文件服务设计和实现之前,我想简要讨论一些企业 Java 开发中已经确立的惯例,并解释为什么,在追求简单和效率的过程中,本章中的代码故意偏离了所谓的最佳实践。

10k 步挑战的代码可能会让你感到惊讶,因为它不执行对象数据映射,即任何数据都必须映射到表示应用域的某些 Java 对象模型,例如数据传输对象 (DTOs)。1 例如,一些表示计步器更新的 JSON 数据在进一步处理之前会被映射到 DeviceUpdate Java 类。在这里,我们将直接在 JsonObject 实例中操作数据,这些数据在 HTTP、Kafka 和数据库接口之间流动。我们不会将设备更新 JSON 数据映射到 DeviceUpdate;我们将使用该数据的 JsonObject 表示形式进行工作。

Vert.x 允许你从 Java 类中进行数据映射,但除非对象模型包含一些重要的业务逻辑或可以被第三方库中的某些处理利用,我认为进行任何形式的数据绑定都没有太大价值。我主张这种设计有几个原因:

  • 它使我们免于编写除了暴露简单的获取器和设置器之外没有功能性的类。

  • 它避免了为通常寿命较短的对象分配不必要的资源(例如,处理 HTTP 请求的生命周期)。

  • 数据并不总是容易映射到对象模型,你可能对所有的数据不感兴趣,而只是对一些选定的条目感兴趣。

  • 在关系型数据库的情况下,对象和模型之间有一些众所周知的不匹配,这可能导致复杂的映射和由于查询过多而导致的性能不佳。2

  • 这最终导致代码更加函数式

如果你有疑问,总是问问自己你是否真的需要一个对象模型,或者数据表示是否足够好,足以处理你正在进行的处理工作。如果你的对象模型只包含获取器和设置器,那么这可能是一个好迹象,表明(至少最初)你可能不需要它。

让我们深入了解在用户配置文件服务中使用 MongoDB。

10.2 使用 MongoDB 的用户配置文件服务

用户配置文件服务管理用户数据,如姓名、电子邮件和城市,并且它还用于验证用户的登录/密码凭据。此服务被其他需要检索和关联用户信息的数据服务使用。

用户服务利用 MongoDB 的两个目的:

  • 存储用户数据:用户名、密码、电子邮件、城市、设备标识符以及数据是否应出现在公共排名中

  • 验证用户名加密码组合

MongoDB 在这里是一个很好的选择,因为它是一个文档数据库;每个用户都可以表示为一个文档。我们将使用vertx-mongo-client模块连接到 MongoDB 实例,并使用vertx-auth-mongo模块进行身份验证。

10.2.1 数据模型

vertx-auth-mongo模块是一个现成的解决方案,用于在 MongoDB 数据库上执行用户身份验证,因为它管理了正确存储和检索凭据的所有复杂性。它实现了模块vertx-auth-common的通用身份验证接口。它特别处理存储带有salt值的密码加密哈希,因为存储实际密码从来不是一个好主意。根据vertx-auth-mongo模块中定义的约定,目标数据库中的每个用户都有一个文档,包含以下条目:

  • username--用户名的字符串

  • salt--一个用于保护密码的随机数据字符串

  • password--通过从实际密码加上salt值计算 SHA-512 哈希得到的字符串

  • roles--一个定义角色(例如“管理员”)的字符串数组

  • permissions--一个定义权限(例如“can_access_beta”)的字符串数组

在我们的案例中,我们不会使用角色和权限,因为所有用户都将平等,所以这些条目将是空数组。我们不需要处理盐和密码哈希处理的复杂性,因为这是由身份验证模块处理的。

虽然这个数据模型由vertx-auth-mongo指定,但并没有阻止我们向代表用户的文档中添加更多字段。因此,我们可以添加以下条目:

  • city--用户所在城市的字符串

  • deviceId--计步器设备标识符的字符串

  • email--用户电子邮件地址的字符串

  • makePublic--一个布尔值,表示用户是否希望出现在公共排名中

我们还将使用 MongoDB 索引强制执行两个完整性约束:usernamedeviceId必须在所有文档中是唯一的。这避免了重复的用户名以及两个用户使用相同的设备。这将在注册新用户时提出一个正确性挑战,因为我们无法使用任何事务机制。当deviceId唯一性约束阻止重复插入时,我们需要回滚部分数据插入。

现在我们来看看如何使用 Vert.x MongoDB 客户端和 Vert.x 身份验证支持。

10.2.2 用户配置 API 垂直和初始化

UserProfileApiVerticle类公开了用户配置服务的 HTTP API。它包含三个重要字段:

  • mongoClient,类型为MongoClient,用于连接到 MongoDB 服务器。

  • authProvider,类型为MongoAuthentication,用于使用 MongoDB 执行身份验证检查。

  • userUtil,类型为MongoUserUtil,用于简化新用户的创建。

我们从rxStart垂直初始化方法中初始化这些字段(因为我们使用 RxJava),如下一列表所示。

列表 10.1 初始化 MonbgoDB 客户端和身份验证提供者

mongoClient = MongoClient.createShared(vertx, mongoConfig());   ❶

authProvider = MongoAuthentication.create(mongoClient,
  new MongoAuthenticationOptions());                            ❷

userUtil = MongoUserUtil.create(mongoClient,                    ❸
  new MongoAuthenticationOptions(), new MongoAuthorizationOptions());

❶ 基于某些配置创建一个客户端。

❷ 在 MongoDB 上创建一个身份验证提供者。

❸ 在 MongoDB 数据库中创建用户的辅助函数

身份验证提供者依赖于 MongoDB 客户端实例,配置方式如下一列表所示。我们为身份验证提供者传递空配置选项,因为我们遵循 Vert.x MongoDB 身份验证模块的约定。同样,对于帮助我们添加用户的实用工具也是如此。

列表 10.2 MongoDB 客户端配置方法

private JsonObject mongoConfig() {
  return new JsonObject()
    .put("host", "localhost")      ❶
    .put("port", 27017)
    .put("db_name", "profiles");   ❷
}

❶ 我们将在本地进行测试。

profiles是数据库名称,但我们可以使用其他任何名称。

由于我们公开了 HTTP API,我们将使用 Vert.x web 路由器来配置服务要处理的各个路由,如下一列表所示。

列表 10.3 用户配置服务 HTTP 路由

Router router = Router.router(vertx);
BodyHandler bodyHandler = BodyHandler.create();
router.post().handler(bodyHandler);
router.put().handler(bodyHandler);
router.post("/register")
  .handler(this::validateRegistration)             ❶
  .handler(this::register);
router.get("/:username").handler(this::fetchUser);
router.put("/:username").handler(this::updateUser);
router.post("/authenticate").handler(this::authenticate);
router.get("/owns/:deviceId").handler(this::whoOwns);

❶ 处理逻辑被分为两个链式处理器。

注意,我们在注册时使用了两个链式处理器。第一个处理器用于数据验证,第二个处理器用于实际的处理逻辑。但验证逻辑中有什么?

10.2.3 验证用户输入

注册是一个关键步骤,因此我们必须确保数据有效。我们必须检查传入的数据(一个 JSON 文档)是否包含所有必需的字段,并且它们都是有效的。例如,我们需要检查电子邮件地址实际上是一个电子邮件地址,并且用户名不为空且不包含不想要的字符。

下面的列表中的 validateRegistration 方法将验证委托给辅助方法 anyRegistrationFieldIsMissinganyRegistrationFieldIsWrong

列表 10.4 注册验证方法

private void validateRegistration(RoutingContext ctx) {
  JsonObject body = jsonBody(ctx);
  if (anyRegistrationFieldIsMissing(body) || 
  ➥ anyRegistrationFieldIsWrong(body)) {
    ctx.fail(400);                          ❶
  } else {
    ctx.next();                             ❷
  }
}

❶ 注册失败,因此我们以状态码 400 结束 HTTP 请求。

❷ 调用链中的下一个处理器。

当任何验证步骤失败时,我们以 400 HTTP 状态码响应;否则,我们调用下一个处理器,在我们的例子中将是 register 方法。

anyRegistrationFieldIsMissing 方法的实现相当简单。我们检查提供的 JSON 文档是否包含所需的字段,如下所示。

列表 10.5 检查缺失的 JSON 字段

private boolean anyRegistrationFieldIsMissing(JsonObject body) {
  return !(body.containsKey("username") &&                         ❶
    body.containsKey("password") &&
    body.containsKey("email") &&
    body.containsKey("city") &&
    body.containsKey("deviceId") &&
    body.containsKey("makePublic"));
}

❶ 检查所有字段是否存在。

anyRegistrationFieldIsWrong 方法将检查委托给正则表达式,如下面的列表所示。

列表 10.6 验证特定字段

private final Pattern validUsername = Pattern.compile("\\w[\\w+|-]*");    ❶
// (...)
private boolean anyRegistrationFieldIsWrong(JsonObject body) {
  return !validUsername.matcher(body.getString("username")).matches() ||  ❷
    !validEmail.matcher(body.getString("email")).matches() ||
    body.getString("password").trim().isEmpty() ||                        ❸
    !validDeviceId.matcher(body.getString("deviceId")).matches();
}

❶ 有效的用户名正则表达式,如 abca-b-c 等。

❷ 正则表达式匹配

trim 移除字符串开头和结尾的空白字符,然后检查是否为空。

validDeviceId 正则表达式与 validUsername 相同。验证电子邮件地址(validEmail)是一个更复杂的正则表达式。我选择使用来自 Open Web Application Security Project (OWASP) 的一个安全正则表达式来完成这个目的 (www.owasp.org/index.php/OWASP_Validation_Regex_Repository)。

现在我们已经验证了数据,是时候注册用户了。

10.2.4 在 MongoDB 中添加用户

在数据库中插入新用户需要两个步骤:

  1. 我们需要请助手插入一个新用户,因为它还将处理其他方面,如哈希密码和盐值。

  2. 我们需要更新用户文档以添加认证提供者模式不需要的额外字段。

由于这是一个两步数据插入过程,我们无法使用任何事务管理功能,因此我们需要自己确保数据完整性,如图 10.1 所示。

图 10.1

图 10.1 成功添加用户的步骤

幸运的是,RxJava 使得错误管理声明式,所以我们不需要处理异步操作的嵌套条件,这会使用回调或承诺/未来变得复杂。

register 方法首先从 HTTP 请求中提取 JSON 有效负载,然后是创建用户的用户名和密码,如下所示。

列表 10.7 register 方法的序言

private void register(RoutingContext ctx) {
  JsonObject body = jsonBody(ctx);               ❶
  String username = body.getString("username");
  String password = body.getString("password");

  userUtil
    .rxCreateUser(username, password)            ❷
  // (...)
}

❶ 提取 JSON 主体。

❷ 插入一个新用户。

记住,register 在验证之后被调用,所以我们期望 JSON 数据是好的。我们向身份验证提供者传递用户名和密码。还有一个表单,其中 rxCreateUser 接受两个额外的列表来定义角色和权限。然后助手用新的文档填充数据库。

接下来,我们必须运行一个查询来更新新创建的文档并附加新条目。MongoDB 查询在下面的列表中显示,并表示为 JSON 对象。

列表 10.8 更新新用户的 MongoDB 查询

JsonObject extraInfo = new JsonObject()
  .put("$set", new JsonObject()              ❶
    .put("email", body.getString("email"))
    .put("city", body.getString("city"))
    .put("deviceId", body.getString("deviceId"))
    .put("makePublic", body.getBoolean("makePublic")));

❶ 这是 MongoDB 的 $set 操作符。

因此,我们必须将 rxInsertUser 操作与 MongoDB 更新查询链式执行,知道 rxInsertUser 返回一个 Single<String>,其值是新文档的标识符。下面的列表展示了使用 RxJava 的完整用户添加处理过程。

列表 10.9 使用 RxJava 的完整用户添加处理

userUtil
  .rxCreateUser(username, password)                           ❶
  .flatMapMaybe(docId -> insertExtraInfo(extraInfo, docId))   ❷
  .ignoreElement()
  .subscribe(
    () -> completeRegistration(ctx),                          ❸
    err -> handleRegistrationError(ctx, err));                ❹

❶ 用户插入查询

❷ 更新查询

❸ HTTP 200

❹ 处理错误

flatMapMaybe 操作符允许我们链式执行两个查询。

下一个列表中展示了 insertExtraInfo 方法,它返回一个 MaybeSource,因为查找和更新文档可能在没有找到匹配文档的情况下不保留结果。

列表 10.10 insertExtraInfo 方法的实现

private MaybeSource<? extends JsonObject> insertExtraInfo(JsonObject 
➥ extraInfo, String docId) {
  JsonObject query = new JsonObject().put("_id", docId);
  return mongoClient
    .rxFindOneAndUpdate("user", query, extraInfo)     ❶
    .onErrorResumeNext(err -> {
      return deleteIncompleteUser(query, err);        ❷
    });
}

❶ 查找并更新文档。

❷ 手动回滚

注意,更新查询可能会失败;例如,如果另一个用户已经使用相同的标识符注册了设备。在这种情况下,我们需要手动回滚并移除由身份验证提供者创建的文档,否则数据库中会有一个不完整的文档。下面的列表展示了 deleteIncompleteUser 方法的实现。

列表 10.11 deleteIncompleteUser 方法的实现

private boolean isIndexViolated(Throwable err) {
  return err.getMessage().contains("E11000");         ❶
}

private MaybeSource<? extends JsonObject> deleteIncompleteUser(JsonObject 
➥ query, Throwable err) {
  if (isIndexViolated(err)) {
    return mongoClient
      .rxRemoveDocument("user", query)                ❷
      .flatMap(del -> Maybe.error(err));              ❸
  } else {
    return Maybe.error(err);                          ❹
  }
}

❶ 这是索引约束违规的技术代码。

❷ 移除文档。

❸ 将结果替换为原始异常并传播它。

err 是另一种错误,我们需要传播它。

我们需要在异常消息中的技术代码来区分索引违规错误和其他类型的错误。在前一种情况下,必须删除先前数据,因为我们想处理它并恢复;在后一种情况下,这是另一种错误,我们无法做太多,所以我们需要传播它。

最后,下一个列表中显示的 handleRegistrationError 方法需要检查错误以响应适当的 HTTP 状态码。

列表 10.12 handleRegistrationError 方法的实现

private void handleRegistrationError(RoutingContext ctx, Throwable err) {
  if (isIndexViolated(err)) {
    logger.error("Registration failure: {}", err.getMessage());
    ctx.fail(409);                                              ❶
  } else {
    logger.error("Woops", err);
    ctx.fail(500);                                              ❷
  }
}

❶ 错误是因为用户提供了现有的用户名或设备标识符。

❷ 这是一个技术错误。

如果请求失败是因为用户名或设备标识符已被占用,或者由于某些技术错误而失败,那么通知请求者是很重要的。在一种情况下,错误是请求者的责任,在另一种情况下,服务是罪魁祸首,请求者可以稍后再次尝试。

10.2.5 用户认证

使用用户名和密码进行用户身份验证非常简单。我们只需要查询身份验证提供者,如果成功,它将返回一个 io.vertx.ext.auth.User 实例。在我们的案例中,我们对查询权限或角色不感兴趣——我们只想检查身份验证是否成功。

假设发送到 /authenticate 的 HTTP POST 请求有一个包含 usernamepassword 字段的 JSON 主体,我们可以按以下方式执行身份验证请求。

列表 10.13 验证用户

private void authenticate(RoutingContext ctx) {
  authProvider.rxAuthenticate(jsonBody(ctx))                   ❶
    .subscribe(
      user -> completeEmptySuccess(ctx),
      err -> handleAuthenticationError(ctx, err));
}

private void completeEmptySuccess(RoutingContext ctx) {
  ctx.response().setStatusCode(200).end();                     ❷
}

private void handleAuthenticationError(RoutingContext ctx, Throwable err) {
  logger.error("Authentication problem {}", err.getMessage());
  ctx.response().setStatusCode(401).end();                     ❸
}

❶ 身份验证方法

❷ 成功

❸ 报告身份验证失败。

身份验证请求的结果是一个 User,或者如果失败,则是一个异常。根据结果,我们以 200 或 401 状态码结束 HTTP 请求。

10.2.6 获取用户数据

/username 的 HTTP GET 请求必须返回与该用户关联的数据(例如,/foo/bar 等)。为此,我们需要准备一个 MongoDB 查询并以 JSON 响应返回数据。

我们需要一个 MongoDB “find” 查询来定位用户文档。为此,我们需要两个 JSON 文档:

  • 基于数据库文档中 username 字段值进行查询的查询文档

  • 一个文档用于指定应返回的字段。

以下代码执行此类查询。

列表 10.14 在 MongoDB 中获取用户数据

JsonObject query = new JsonObject()
  .put("username", username);               ❶

JsonObject fields = new JsonObject()
  .put("_id", 0)                            ❷
  .put("username", 1)                       ❸
  .put("email", 1)
  .put("deviceId", 1)
  .put("city", 1)
  .put("makePublic", 1);

mongoClient
  .rxFindOne("user", query, fields)         ❹
  .toSingle()
  .subscribe(
    json -> completeFetchRequest(ctx, json),
    err -> handleFetchError(ctx, err));

❶ 我们想要精确匹配用户名。

❷ 我们不想要文档标识符。

❸ 我们想要重复用户名。

❹ 查找一个文档。

指定哪些字段应包含在响应中,并对此进行明确是很重要的。在我们的案例中,我们不希望泄露文档标识符,因此我们在 fields 文档中将它设置为 0。我们还明确列出我们希望以 1 值返回的字段。这也确保了密码和盐值等身份验证字段不会意外泄露。

下一个列表显示了完成获取请求和 HTTP 响应的两个方法。

列表 10.15 完成用户获取请求

private void completeFetchRequest(RoutingContext ctx, JsonObject json) {
  ctx.response()
    .putHeader("Content-Type", "application/json")
    .end(json.encode());                                           ❶
}

private void handleFetchError(RoutingContext ctx, Throwable err) {
  if (err instanceof NoSuchElementException) {                     ❷
    ctx.fail(404);
  } else {
    fail500(ctx, err);
  }
}

❶ 通过转发 JSON 结果成功完成。

❷ 如果用户不存在,则返回 404 错误,如果遇到技术错误,则返回 500 错误。

正确处理错误情况并区分不存在用户和技术错误是很重要的。

让我们看看更新用户的案例。

10.2.7 更新用户数据

更新用户数据与获取数据类似,因为我们需要两个 JSON 文档:一个用于匹配文档,另一个用于指定需要更新的字段。以下列表显示了相应的代码。

列表 10.16 使用 MongoDB 更新用户数据

JsonObject query = new JsonObject().put("username", username);    ❶
JsonObject updates = new JsonObject();
if (body.containsKey("city")) {                                   ❷
  updates.put("city", body.getString("city"));
}
if (body.containsKey("email")) {
  updates.put("email", body.getString("email"));
}
if (body.containsKey("makePublic")) {
  updates.put("makePublic", body.getBoolean("makePublic"));
}

if (updates.isEmpty()) {                                          ❸
  ctx.response().setStatusCode(200).end();
  return;
}

updates = new JsonObject().put("$set", updates);                  ❹
mongoClient
  .rxFindOneAndUpdate("user", query, updates)                     ❺
  .ignoreElement()
  .subscribe(
    () -> completeEmptySuccess(ctx),
    err -> handleUpdateError(ctx, err));

❶ 我们想要通过用户名进行匹配。

❷ 我们选择性地检查每个允许的字段以进行更新。

❸ 如果未指定任何允许的字段,我们快速返回。

❹ 在 MongoDB 中使用 $set 操作符来更新数据。

❺ 我们搜索并更新一个文档。

由于更新请求是一个来自 HTTP 请求的 JSON 文档,如果我们不小心,总有可能遭受外部攻击。恶意用户可能会在请求中构建一个包含密码或用户名更新的 JSON 文档,因此我们测试更新中是否存在每个允许的字段:cityemailmakePublic。然后我们创建一个仅包含这些字段的更新 JSON 文档,而不是重用通过 HTTP 收到的 JSON 文档,并向 Vert.x MongoDB 客户端发送更新请求。

我们现在已经涵盖了 Vert.x 中 MongoDB 的典型用法,以及如何将其用于认证目的。让我们继续讨论 PostgreSQL 和活动服务。

10.3 使用 PostgreSQL 的活动服务

活动服务存储从计步器接收到的所有步数更新。它是一个响应新步数更新事件(以存储数据)的服务,并且可以被其他服务查询以获取特定设备在特定日期、月份或年份的步数。

活动服务在设备更新被摄取服务接受后使用 PostgreSQL 存储活动数据。PostgreSQL 非常适合这个目的,因为 SQL 查询语言使得计算聚合数据变得容易,例如计算特定月份设备的步数。

服务被拆分为两个独立的垂直线:

  • EventsVerticle 监听来自 Kafka 的传入活动更新,然后将数据存储在数据库中。

  • ActivityApiVerticle 提供了一个用于查询活动数据的 HTTP API。

我们本可以将所有代码放在单个垂直线上,但这种解耦使得代码更易于管理,因为每个垂直线都有一个明确的目的。EventsVerticle 执行对数据库的写入操作,而 ActivityApiVerticle 执行读取操作。

10.3.1 数据模型

数据模型并不复杂,可以放入单个 关系 stepevent 中。创建 stepevent 表的 SQL 指令如下所示。

列表 10.17 创建 stepevent 表的 SQL 指令

CREATE TABLE IF NOT EXISTS stepevent
(
  device_id VARCHAR,
  device_sync BIGINT,
  sync_timestamp timestamptz,            ❶
  steps_count INTEGER,
  PRIMARY KEY (device_id, device_sync)   ❷
);

❶ 带有时区的日期时间

❷ 复合主键

主键根据设备标识符(device_id)和设备同步计数器(device_sync)唯一标识活动更新。事件的时间戳被记录(sync_timestamp),最后存储步数(steps_count)。

提示:如果你来自一个大量使用 对象关系映射器 (ORMs) 的背景,你可能会对前面的数据库模式感到惊讶,尤其是它使用的是复合主键而不是某个自动增长的数字。你可能首先需要考虑你的关系模型在范式方面的适当设计,然后再看看如何在代码中处理数据,无论是使用集合和/或反映数据的对象。如果你对这个主题感兴趣,维基百科提供了数据库规范化的良好介绍:en.wikipedia.org/wiki/Database_normalization

10.3.2 打开连接池

vertx-pg-client 模块包含了一个 PgPool 接口,该接口模拟了一个连接到 PostgreSQL 服务器的连接池,其中每个连接都可以用于后续的查询。PgPool 是客户端执行 SQL 查询的主要访问点。

以下列表显示了如何创建 PostgreSQL 连接池。

列表 10.18 创建 PostgreSQL 连接池

PgPool pgPool = PgPool.pool(vertx, PgConfig.pgConnectOpts(), 
➥ new PoolOptions());                             ❶
// (...)

public static PgConnectOptions pgConnectOpts() {   ❷
  return new PgConnectOptions()
    .setHost("localhost")
    .setDatabase("postgres")
    .setUser("postgres")
    .setPassword("vertx-in-action");
}

❶ 创建连接池。

❷ 连接配置

连接池的创建需要一个 Vert.x 上下文,一组连接选项,如主机、数据库和密码,以及池选项。池选项可以调整以设置最大连接数以及等待队列的大小,但这里默认值是足够的。

然后,使用 pool 对象执行对数据库的查询,正如你接下来将看到的。

10.3.3 设备更新事件的寿命

EventsVerticle 负责监听 incoming.steps 主题上的 Kafka 记录,其中每个记录是通过摄入服务从设备接收到的更新。对于每个记录,EventsVerticle 必须执行以下操作:

  • 将记录插入到 PostgreSQL 数据库中。

  • 生成包含设备每日步数的更新记录。

  • 将其作为新的 Kafka 记录发布到 daily.step.updates Kafka 主题。

这在图 10.2 中得到了说明。

图片

图 10.2 记录设备更新和生成更新事件的步骤

这些步骤由以下列表中定义的 RxJava 管道进行建模。

列表 10.19 EventsVerticle 中处理更新的 RxJava 管道

eventConsumer
  .subscribe("incoming.steps")                    ❶
  .toFlowable()
  .flatMap(this::insertRecord)                    ❷
  .flatMap(this::generateActivityUpdate)          ❸
  .flatMap(this::commitKafkaConsumerOffset)       ❹
  .doOnError(err -> logger.error("Woops", err))
  .retryWhen(this::retryLater)
  .subscribe();

❶ 订阅 Kafka 主题。

❷ 在数据库中插入一条新记录。

❸ 查询数据库以发布另一条记录。

❹ 将记录提交到 Kafka。

这个 RxJava 管道让人联想到我们在消息和事件堆栈中看到的那些,因为我们组合了三个异步操作。这个管道从 Kafka 读取,插入数据库记录 (insertRecord),生成写入 Kafka 的查询 (generateActivityUpdate),并将其提交 (commitKafkaConsumerOffset)。

10.3.4 插入新记录

下一个列表显示了插入记录的 SQL 查询。

列表 10.20 插入步事件 SQL 查询

static String insertStepEvent() {
  return "INSERT INTO stepevent VALUES($1, $2, current_timestamp, $3)";    ❶
}

❶ $n 是值元组的第 n 个条目。

小贴士 Vert.x 不指定任何对象关系映射工具。使用纯 SQL 是一个很好的选择,但如果您想将代码从数据库的特定性中抽象出来,并使用 API 来构建查询而不是使用字符串,我建议查看 jOOQ (www.jooq.org/)。您甚至可以在社区中找到一个 Vert.x/jOOQ 集成模块。

我们使用一个具有静态方法的类来定义 SQL 查询,因为它比我们代码中的普通字符串常量更方便。该查询将用作预处理语句,其中以$符号为前缀的值将从值元组中获取。由于我们使用预处理语句,这些值可以免受 SQL 注入攻击。

insertRecord方法为每个新的 Kafka 记录调用,方法体如下所示。

列表 10.21 insertRecord方法的实现

JsonObject data = record.value();     ❶

Tuple values = Tuple.of(              ❷
  data.getString("deviceId"),
  data.getLong("deviceSync"),
  data.getInteger("stepsCount"));

return pgPool
  .preparedQuery(insertStepEvent())   ❸
  .rxExecute(values)                  ❹
  .map(rs -> record)                  ❺
  .onErrorReturn(err -> {             ❻
    if (duplicateKeyInsert(err)) {
      return record;
    } else {
      throw new RuntimeException(err);
    }
  })
  .toFlowable();

❶ Kafka 记录的 JSON 体

❷ 元组结构

❸ 插入请求

❹ 使用参数执行请求。

❺ 在generateActivityUpdate方法中重新映射 Kafka 记录以进行处理。

❻ 优雅地处理重复插入。

我们首先从记录中提取 JSON 体,然后准备一个值元组作为参数传递给列表 10.20 中的 SQL 查询。查询的结果是一个行集,但由于这不是SELECT查询,所以我们不关心结果。相反,我们只是将结果重新映射到原始 Kafka 记录值,这样generateActivityUpdate方法就可以重用它。

onErrorReturn运算符允许我们优雅地处理重复插入。可能的情况是在服务重启后,我们将重新播放一些我们已处理的 Kafka 事件,因此INSERT查询将失败,而不是创建具有重复主键的条目。

下面的列表中的duplicateKeyInsert方法展示了我们如何区分重复键错误和其他技术错误。

列表 10.22 检测重复键错误

private boolean duplicateKeyInsert(Throwable err) {
  return (err instanceof PgException) &&
    "23505".equals(((PgException) err).getCode());    ❶
}

❶ 重复键插入尝试的技术代码错误

我们再次需要在异常消息中搜索技术错误代码,如果它与 PostgreSQL 重复键错误相对应,那么onErrorReturn将原始 Kafka 记录放入管道,而不是让错误传播。

10.3.5 生成设备的每日活动更新

在记录已插入到 RxJava 处理管道之后的下一步是查询数据库以找出当天已经执行了多少步。然后,这些信息被用来准备一个新的 Kafka 记录并将其推送到daily.step.updates Kafka 主题。

对应于该操作的 SQL 查询由以下列表中的stepsCountForToday方法指定。

列表 10.23 获取当前设备步骤计数的 SQL 查询

static String stepsCountForToday() {
  return "SELECT current_timestamp, coalesce(sum(steps_count), 0) 
➥ FROM stepevent WHERE " +                                       ❶
    "(device_id = $1) AND" +
    "(date_trunc('day', sync_timestamp) = date_trunc('day', 
    ➥ current_timestamp))";                                      ❷
}

❶ 如果没有匹配的条目,步骤计数将为 0。

❷ 匹配当前天的记录,截断小时、分钟和秒。

此请求计算给定设备标识符在当前日期所采取的步数之和(或 0)。

下一个列表显示了generateActivityUpdate方法的实现,它拾取insertRecord方法转发的原始 Kafka 记录。

列表 10.24 generateActivityUpdate方法实现

String deviceId = record.value().getString("deviceId");                    ❶
LocalDateTime now = LocalDateTime.now();
String key = deviceId + ":" + now.getYear() + "-" + now.getMonth() + "-" + 
➥ now.getDayOfMonth();                                                    ❷

return pgPool
  .preparedQuery(stepsCountForToday())
  .rxExecute(Tuple.of(deviceId))                                           ❸
  .map(rs -> rs.iterator().next())                                         ❹
  .map(row -> new JsonObject()                                             ❺
    .put("deviceId", deviceId)
    .put("timestamp", row.getTemporal(0).toString())
    .put("stepsCount", row.getLong(1)))
  .flatMap(json -> 
 ➥ updateProducer.rxSend(KafkaProducerRecord.create("daily.step.updates", 
 ➥ key, json)))                                                         ❻
  .map(rs -> record)
  .toFlowable();

❶ 从原始 Kafka 记录中提取设备标识符。

❷ 新 Kafka 记录的关键

❸ 包含一个值的元组的预处理语句

❹ 我们期望只有一行。

❺ 从行值创建一个新的 JsonObject。

❻ 组合 Kafka 发送操作。

这段代码展示了我们如何在SELECT查询之后操作行。查询的结果是RowSet,在这里通过第一个map运算符的rs参数物化,并且可以逐行迭代。由于查询返回单行,我们可以通过在RowSet迭代器上调用next直接访问第一行和唯一一行。然后我们通过类型和索引访问行元素,构建一个JsonObject,该对象创建发送到daily.step.updates主题的 Kafka 记录。

10.3.6 活动 API 查询

ActivityApiVerticle类公开了活动服务的 HTTP API--所有路由都导向 SQL 查询。我不会展示所有这些。我们将关注设备的月度步数,通过 HTTP GET请求到/:deviceId/:year/:month来处理。接下来的 SQL 查询将展示。

列表 10.25 月度步数 SQL 查询

static String monthlyStepsCount() {
  return "SELECT sum(steps_count) FROM stepevent WHERE" +
    "(device_id = $1) AND" +
    "(date_trunc('month', sync_timestamp) = $2::timestamp)";    ❶
}

❶ 需要将值合并为时间戳。

下一个列表显示了stepsOnMonth方法,它根据年和月路径参数执行 SQL 查询。

列表 10.26 处理月度步数请求

private void stepsOnMonth(RoutingContext ctx) {
  try {
    String deviceId = ctx.pathParam("deviceId");
    LocalDateTime dateTime = LocalDateTime.of(
      Integer.parseInt(ctx.pathParam("year")),
      Integer.parseInt(ctx.pathParam("month")),
      1, 0, 0);
    Tuple params = Tuple.of(deviceId, dateTime);           ❶
    pgPool.preparedQuery(SqlQueries.monthlyStepsCount())
      .rxExecute(params)
      .map(rs -> rs.iterator().next())
      .subscribe(
        row -> sendCount(ctx, row),                        ❷
        err -> handleError(ctx, err));                     ❸
  } catch (DateTimeException | NumberFormatException e) {  ❹
    sendBadRequest(ctx);
  }
}

❶ 查询参数元组

❷ 基于行数据的 JSON 响应

❸ 发送 HTTP 400 错误

❹ 当 URL 参数不是数字或不会产生有效日期时

查询结果再次是RowSet,我们从 SQL 查询中知道只能返回一行,所以我们使用map运算符来提取它。sendCount方法将数据作为 JSON 文档发送,而handleError方法产生 HTTP 500 错误。当年份或月份 URL 参数不是数字或不会产生有效日期时,sendBadRequest产生 HTTP 400 响应,让请求知道错误。

现在是时候转向集成测试策略了。当我们需要预先填充 PostgreSQL 数据库时,我还会向你展示一些其他数据客户端方法,例如 SQL 批查询。

10.4 集成测试

测试用户配置文件服务涉及向相应的 API 发出 HTTP 请求。活动服务有两个方面:一个涉及 HTTP API,另一个涉及构建 Kafka 事件并观察持久状态和产生事件的效应。

10.4.1 测试用户配置文件服务

用户配置文件测试依赖于发出影响服务状态和数据库的 HTTP 请求(例如,创建用户),然后发出进一步的 HTTP 请求来执行一些断言,如图 10.3 所示。

图 10.3 测试用户配置文件服务

集成测试再次依赖于 Testcontainers,因为我们需要一个运行的 MongoDB 实例。一旦容器运行,我们需要在运行任何测试之前将 MongoDB 数据库准备为 干净状态。这很重要,以确保测试不受先前测试执行留下的数据的影响。

IntegrationTest 类的 setup 方法执行测试准备。

列表 10.27 用户配置文件集成测试设置

@BeforeEach
void setup(Vertx vertx, VertxTestContext testContext) {
  JsonObject mongoConfig = new JsonObject()
    .put("host", "localhost")
    .put("port", 27017)
    .put("db_name", "profiles");
  mongoClient = MongoClient.createShared(vertx, mongoConfig);

  mongoClient
    .rxCreateIndexWithOptions("user", new JsonObject().put("username", 1),  ❶
      new IndexOptions().unique(true))
    .andThen(mongoClient.rxCreateIndexWithOptions("user",
      new JsonObject().put("deviceId", 1), new IndexOptions().unique(true)))❷
    .andThen(dropAllUsers())                                                ❸
    .flatMapSingle(res -> 
 ➥ vertx.rxDeployVerticle(new UserProfileApiVerticle()))
    .subscribe(
      ok -> testContext.completeNow(),
      testContext::failNow);
}

❶ 确保我们对 username 有索引。

❷ 确保我们对 deviceId 有索引。

❸ 删除所有用户。

我们首先连接到 MongoDB 数据库,然后确保我们有两个索引,用于 usernamedeviceId 字段。然后我们从 profiles 数据库中删除所有现有文档(见列表 10.28),并在成功完成初始化阶段之前部署 UserProfileApiVerticle 实例。

列表 10.28 删除 MongoDB 数据库中的所有用户

private Maybe<MongoClientDeleteResult> dropAllUsers() {
  return mongoClient.rxRemoveDocuments("user", new JsonObject());    ❶
}

❶ 无条件匹配一个空的 JSON 查询文档。

IntegrationTest 类提供了预期成功和预期失败的操作的不同测试用例。RestAssured 用于编写 HTTP 请求的测试规范,如下所示。

列表 10.29 测试验证缺失用户

@Test
@DisplayName("Failing at authenticating an unknown user")
void authenticateMissingUser() {
  JsonObject request = new JsonObject()    ❶
    .put("username", "Bean")
    .put("password", "abc");

  with()
    .spec(requestSpecification)
    .contentType(ContentType.JSON)
    .body(request.encode())
    .post("/authenticate")
    .then()
    .assertThat()
    .statusCode(401);                      ❷
}

❶ 此用户不存在。

❷ 我们期望得到一个 HTTP 401 状态码。

authenticateMissingUser 方法检查使用无效凭据进行身份验证是否会导致 HTTP 401 状态码。

另一个例子是以下测试,其中我们检查当我们尝试两次注册一个用户时会发生什么。

列表 10.30 测试两次注册用户

given()
  .spec(requestSpecification)
  .contentType(ContentType.JSON)
  .accept(ContentType.JSON)
  .body(basicUser().encode()))     ❶
  .when()
  .post("/register")
  .then()
  .assertThat()
  .statusCode(200);                ❷

given()
  .spec(requestSpecification)
  .contentType(ContentType.JSON)
  .accept(ContentType.JSON)
  .body(basicUser().encode())
  .when()
  .post("/register")
  .then()
  .assertThat()
  .statusCode(409);                ❸

❶ 此方法为用户返回一个预定义的 JSON 对象。

❷ 第一次尝试是正常的。

❸ 第二次尝试不正常!

我们也可以查看数据库并检查每次操作后存储的数据。由于我们需要覆盖 HTTP API 的所有功能用例,因此在集成测试中只关注 HTTP API 更为直接。然而,在某些情况下,数据库上的 API 可能不会暴露存储数据的一些重要影响,在这些情况下,您将需要连接到数据库以进行进一步的断言。

10.4.2 测试活动服务 API

测试活动服务 API 与测试用户配置文件服务非常相似,只是我们使用 PostgreSQL 而不是 MongoDB。

我们首先需要确保数据模式定义如列表 10.17 所示。为此,当 PostgreSQL 容器启动时,会自动运行 init/postgres/setup.sql 中的 SQL 脚本。这是因为容器镜像指定了在启动时将运行在 /docker-entrypoint-initdb.d/ 中找到的任何 SQL 脚本,而我们使用的 Docker Compose 文件将 init/postgres 挂载到 /docker-entrypoint-initdb.d/,因此 SQL 文件在容器中可用。

一旦数据库准备好了预定义的数据,我们就发出 HTTP 请求来进行断言,如图 10.4 所示。

图 10.4 测试活动服务 API

我们再次依赖 Testcontainers 启动一个 PostgreSQL 服务器,然后我们依赖测试设置方法来准备数据如下。

列表 10.31 准备活动服务 API 测试

String insertQuery = "INSERT INTO stepevent 
➥ VALUES($1, $2, $3::timestamp, $4)";                              ❶
LocalDateTime now = LocalDateTime.now();
List<Tuple> data = Arrays.asList(                                   ❷
  Tuple.of("123", 1, LocalDateTime.of(2019, 4, 1, 23, 0), 6541),
  Tuple.of("123", 2, LocalDateTime.of(2019, 5, 20, 10, 0), 200),
  Tuple.of("123", 3, LocalDateTime.of(2019, 5, 21, 10, 10), 100),
  Tuple.of("456", 1, LocalDateTime.of(2019, 5, 21, 10, 15), 123),
  Tuple.of("123", 4, LocalDateTime.of(2019, 5, 21, 11, 0), 320),
  Tuple.of("abc", 1, now.minus(1, ChronoUnit.HOURS), 1000),
  Tuple.of("def", 1, now.minus(2, ChronoUnit.HOURS), 100),
  Tuple.of("def", 2, now.minus(30, ChronoUnit.MINUTES), 900),
  Tuple.of("abc", 2, now, 1500)
);
PgPool pgPool = PgPool.pool(vertx, PgConfig.pgConnectOpts(), 
➥ new PoolOptions());

pgPool.query("DELETE FROM stepevent")                               ❸
  .rxExecute()
  .flatMap(rows -> 
➥ gPool.preparedQuery(insertQuery).rxExecuteBatch(data)            ❹
  .ignoreElement()
  .andThen(vertx.rxDeployVerticle(new ActivityApiVerticle()))       ❺
  .ignoreElement()
  .andThen(Completable.fromAction(pgPool::close))                   ❻
  .subscribe(testContext::completeNow, testContext::failNow);

❶ 插入数据的查询

❷ 数据库的条目集

❸ 确保没有事件被留下。

❹ 插入我们的数据。

❺ 部署 API 垂直服务。

❻ 关闭连接池。

我们希望有一个我们控制的数据库和数据集,其中包含设备 123456abcdef 在不同时间点的活动。例如,设备 123 在 2019/05/21 11:00 记录了 320 步,这是该设备与后端成功同步的第四次。然后我们可以执行以下列表中的 HTTP API 检查,检查 2019 年 5 月设备 123 的步数。

列表 10.32 检查设备 123 在指定月份的步数

JsonPath jsonPath = given()
  .spec(requestSpecification)
  .accept(ContentType.JSON)
  .get("/123/2019/05")                                 ❶
  .then()
  .assertThat()
  .statusCode(200)
  .extract()
  .jsonPath();

assertThat(jsonPath.getInt("count")).isEqualTo(620);   ❷

❶ 查询 URL

❷ 检查 JSON 结果。

活动 HTTP API 是服务的只读部分,因此现在让我们看看服务的另一部分。

10.4.3 测试活动服务的イベント处理

测试 EventsVerticle 的 Kafka 事件处理部分的技巧与我们在上一章中做的是非常相似的:我们将发送一些 Kafka 记录,然后观察服务产生的 Kafka 记录。

通过为特定设备发送多个步数更新,我们应该观察到服务产生了累积当前日步数的更新。由于服务既消费又产生反映数据库当前状态的 Kafka 记录,我们不需要执行 SQL 查询--观察是否产生了正确的 Kafka 记录就足够了。图 10.5 提供了测试的概述。

图 10.5 测试活动服务的事件处理

集成测试类 (EventProcessingTest) 再次使用 TestContainers 启动所需的服务:PostgreSQL、Apache Kafka 和 Apache ZooKeeper。在运行任何测试之前,我们必须使用以下列表中的测试准备代码从干净状态开始。

列表 10.33 事件处理集成测试的准备工作代码

consumer = KafkaConsumer.create(vertx, 
➥ KafkaConfig.consumer("activity-service-test-" + 
➥ System.currentTimeMillis()));
producer = KafkaProducer.create(vertx, KafkaConfig.producer());
KafkaAdminClient adminClient = KafkaAdminClient.create(vertx, 
➥ KafkaConfig.producer());
PgPool pgPool = PgPool.pool(vertx, PgConfig.pgConnectOpts(), 
➥ new PoolOptions());

pgPool.query("DELETE FROM stepevent")                            ❶
  .rxExecute()
  .flatMapCompletable(rs -> 
  ➥ adminClient.rxDeleteTopics(Arrays.asList("incoming.steps", 
  ➥ "daily.step.updates")))                                     ❷
  .andThen(Completable.fromAction(pgPool::close))                ❸
  .onErrorComplete()
  .subscribe(
    testContext::completeNow,
    testContext::failNow);

❶ 从数据库中删除数据。

❷ 删除 Kafka 主题。

❸ 关闭数据库连接池。

我们需要确保 PostgreSQL 数据库为空,并且我们用于接收和发送事件的 Kafka 主题已被删除。然后我们可以专注于测试方法,我们将为设备 123 发送两个步数更新。

在此之前,我们必须首先订阅 daily.step.updates Kafka 主题,其中 EventsVerticle 类将发送 Kafka 记录。以下列表显示了测试用例的第一部分。

列表 10.34 事件垂直测试用例的第一部分

consumer.subscribe("daily.step.updates")
  .toFlowable()
  .skip(1)                                           ❶
  .subscribe(record -> {                             ❷
    JsonObject json = record.value();
    testContext.verify(() -> {                       ❸
      assertThat(json.getString("deviceId")).isEqualTo("123");
      assertThat(json.containsKey("timestamp")).isTrue();
      assertThat(json.getInteger("stepsCount")).isEqualTo(250);
    });
    testContext.completeNow();
  }, testContext::failNow);

❶ 跳过第一次更新。

❷ 获取第二次更新。

❸ 执行一些断言。

由于我们发送了两个更新,我们跳过了发出的记录,并且只对第二个进行断言,因为它应该反映两个更新的步骤总和。前面的代码正在等待事件产生,因此我们现在需要部署EventsVerticle并发送以下两个更新。

列表 10.35 事件垂直测试用例的第二部分

vertx
  .rxDeployVerticle(new EventsVerticle())      ❶
  .flatMap(id -> {                             ❷
    JsonObject steps = new JsonObject()
      .put("deviceId", "123")
      .put("deviceSync", 1L)
      .put("stepsCount", 200);
    return producer.rxSend(KafkaProducerRecord.create("incoming.steps", 
    ➥ "123", steps));
  })
  .flatMap(id -> {                             ❸
    JsonObject steps = new JsonObject()
      .put("deviceId", "123")
      .put("deviceSync", 2L)
      .put("stepsCount", 50);
    return producer.rxSend(KafkaProducerRecord.create("incoming.steps", 
    ➥ "123", steps));
  })
  .subscribe(ok -> {
  }, testContext::failNow);

❶ 部署 EventsVerticle。

❷ 第一次更新

❸ 第二次更新

测试以EventsVerticle正确地向daily.step.updates Kafka 主题发送正确的更新而完成。我们再次注意到 RxJava 如何以声明式方式组合异步操作并确保错误处理被清楚地识别。我们实际上有两个 RxJava 管道,任何错误都会导致测试上下文失败。

note 如果第一次更新在午夜之前发送,第二次更新在午夜之后发送,则此测试可能会出现微小的漏洞窗口导致失败。在这种情况下,第二个事件将不会是两个事件步骤的总和。这种情况非常不可能发生,因为两个事件将在几毫秒内发出,但仍然,可能会发生。

谈到事件流,下一章将重点介绍 Vert.x 的高级事件处理服务。

摘要

  • Vert.x MongoDB 客户端允许您存储和查询文档。

  • Vert.x 还可以使用 MongoDB 进行身份验证并安全地存储用户凭据、角色和权限。

  • Vert.x 提供了一个高效的响应式驱动程序用于 PostgreSQL。

  • 你并不总是需要一个对象关系映射器。直接使用 SQL 和关系数据可以简单且高效。

  • 在执行集成测试之前确保数据库中的状态清洁是很重要的。


1.有关 DTO 的信息,请参阅 Martin Fowler 的“数据传输对象”文章,martinfowler.com/eaaCatalog/dataTransferObject.html

2.参见 Ted Neward 的“计算机科学的越南”,blogs.tedneward.com/post/the-vietnam-of-computer-science/

11 端到端实时反应式事件处理

本章涵盖

  • 结合 RxJava 操作符和 Vert.x 客户端以支持高级处理

  • 使用 RxJava 操作符在事件流上执行内容丰富和聚合数据处理

  • 将 Vert.x 事件总线扩展到网络应用程序以统一后端和前端通信模型

  • 在流处理环境中管理状态

在本章中,我们将探讨高级反应式流处理,其中应用程序状态根据事件进行实时变化。通过对事件进行转换和聚合,我们将计算有关更大 10k 步应用程序中正在发生的事情的实时统计数据。您还将看到事件流如何通过在 Vert.x 事件总线下统一 Java 和 JavaScript 代码来影响实时网络应用程序。

本章首先探讨使用 RxJava 操作符和 Vert.x 客户端的高级流处理。然后,我们将讨论通过事件总线连接的实时网络应用程序的主题,最后我们将讨论在连续事件的环境中正确处理状态(尤其是 初始 状态)的技术。

11.1 使用 Kafka 和 RxJava 进行高级流数据处理

在前面的章节中,我们使用了 RxJava 操作符来处理各种类型的事件:HTTP 请求、AMQP 消息和 Kafka 记录。RxJava 是一个多功能的响应式编程库,它特别适合使用 Flowable 类型处理背压流的事件流。Kafka 为事件流提供了坚实的中间件,而 Vert.x 提供了一个丰富的反应式客户端生态系统,这些客户端可以连接到其他服务、数据库或消息系统。

事件统计 服务是一个事件驱动的响应式服务,它消费 Kafka 记录并产生一些统计信息作为其他 Kafka 记录。我们将探讨如何使用 RxJava 操作符高效地处理事件流上的三个常见操作:

  • 丰富数据

  • 在时间窗口内聚合数据

  • 通过使用键或函数对元素进行分组来聚合数据

11.1.1 丰富每日设备更新以生成用户更新

daily.step.updates Kafka 主题由活动服务发送的记录填充。这些记录包含三个条目:设备标识符、记录产生的时间戳和步数。

每当活动服务处理设备更新时,它将更新存储到 PostgreSQL 数据库中,然后产生一个 Kafka 记录,其中包含对应设备的当前天的步数。例如,当设备 abc 收到在 11:25 记录的 300 步更新时,它将向 daily.step.updates 发送一个 Kafka 记录,其中包含对应设备 abc 的当天步数。

事件统计服务消费这些事件,以便用用户数据丰富它们,这样其他服务就可以实时更新关于任何用户当前记录的步数。为此,我们从daily.step.updates Kafka 主题中获取记录,并添加来自用户 API 的数据:用户名、电子邮件、城市以及数据是否公开。丰富后的数据随后作为记录发送到event-stats.user-activity.updates主题。丰富数据的过程在图 11.1 中展示。

图片

图 11.1 使用用户数据丰富设备更新

提示:这是 Gregor Hohpe 和 Bobby Woolf 在其开创性的《企业集成模式》(Addison-Wesley Professional,2003 年)一书中提出的内容丰富器消息模式的实现技术。

对于每个传入的 Kafka 记录,我们执行以下操作:

  1. 向用户配置文件 API 发送请求以确定设备属于谁。

  2. 向用户配置文件 API 发送另一个请求以获取用户的所有数据,并将其与传入的记录数据合并。

  3. 将丰富后的记录写入event-stats.user-activity.updates Kafka 主题,并提交。

下一个列表显示了相应的 RxJava 管道。

列表 11.1 用于生成用户更新的 RxJava 管道

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumer("event-stats-user-activity-updates"))
  .subscribe("daily.step.updates")                              ❶
  .toFlowable()
  .flatMapSingle(this::addDeviceOwner)                          ❷
  .flatMapSingle(this::addOwnerData)                            ❸
  .flatMapCompletable(this::publishUserActivityUpdate)          ❹
  .doOnError(err -> logger.error("Woops", err))
  .retryWhen(this::retryLater)
  .subscribe();

❶ 订阅源 Kafka 主题。

❷ 从记录中获取设备所有者信息。

❸ 获取用户数据并将其与记录合并。

❹ 提交到目标 Kafka 主题。

RxJava 管道使用flatMapSingleflatMapCompletable组合异步操作。这是因为发送 HTTP 请求会产生一个(单一)结果,而提交 Kafka 记录是一个没有返回值的操作(因此它是可完成的)。您还可以看到来自早期章节的常见错误处理逻辑,包括延迟重新订阅。

下一个列表展示了addDeviceOwner方法的实现。

列表 11.2 添加设备所有者

private Single<JsonObject> addDeviceOwner(KafkaConsumerRecord<String, 
➥ JsonObject> record) {
  JsonObject data = record.value();                                    ❶
  return webClient
    .get(3000, "localhost", "/owns/" + data.getString("deviceId"))     ❷
    .as(BodyCodec.jsonObject())
    .rxSend()
    .map(HttpResponse::body)                                           ❸
    .map(data::mergeIn);                                               ❹
}

❶ 这是传入的 Kafka 记录。

❷ 向用户配置文件 API 发送 HTTP 请求。

❸ 提取 HTTP 响应体(一个 JsonObject)。

❹ 返回 JSON 数据合并。

此方法发送一个结果为 JSON 对象的 HTTP 请求,并返回源 Kafka 记录的 JSON 数据与请求结果数据的合并。

一旦完成,我们就知道记录中的设备属于谁,因此我们可以通过另一个请求从用户配置文件 API 获取用户数据,如下所示。

列表 11.3 添加所有者数据

private Single<JsonObject> addOwnerData(JsonObject data) {
  String username = data.getString("username");              ❶
  return webClient
    .get(3000, "localhost", "/" + username)                  ❷
    .as(BodyCodec.jsonObject())
    .rxSend()
    .map(HttpResponse::body)
    .map(data::mergeIn);                                     ❸
}

❶ 这是addDeviceOwner方法返回的数据。

❷ 发送 HTTP 请求。

❸ 合并数据。

此方法遵循与addDeviceOwner相同的模式,因为它将前一个操作的结果作为参数,向用户配置文件 API 发送 HTTP 请求,然后返回合并后的数据。

最后一个操作是publishActivityUpdate方法,如下所示。

列表 11.4 发布用户活动更新 Kafka 记录

private CompletableSource publishUserActivityUpdate(JsonObject data) {
  return producer.rxWrite(                                               ❶
    KafkaProducerRecord.create("event-stats.user-activity.updates", 
    ➥ data.getString("username"), data));
}

❶ 编写 Kafka 记录。

实现将 Kafka 记录写入目标 event-stats.user-activity.updates 主题。

11.1.2 使用时间窗口聚合计算设备更新摄入吞吐量

摄入服务接收来自 HTTP 和 AMQP 的传入设备更新,然后将它们发布到 incoming.steps Kafka 主题。摄入吞吐量是仪表板指标中典型的值,该值经常更新为每秒摄入的设备更新数量。这是衡量更大应用程序压力水平的好指标,因为每个更新都会触发其他微服务处理的事件。

为了计算摄入吞吐量,我们需要监听 incoming.steps 主题上的记录,在固定时间窗口内聚合记录,并计算接收到的记录数量。这如图 11.2 所示。

图像

图 11.2 从摄入记录计算吞吐量

以下列表显示了计算吞吐量并将结果发布到 event-stats.throughput Kafka 主题的 RxJava 管道。

列表 11.5 计算摄入吞吐量的 RxJava 管道

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumer("event-stats-throughput"))
  .subscribe("incoming.steps")                               ❶
  .toFlowable()
  .buffer(5, TimeUnit.SECONDS, RxHelper.scheduler(vertx))    ❷
  .flatMapCompletable(this::publishThroughput)               ❸
  .doOnError(err -> logger.error("Woops", err))
  .retryWhen(this::retryLater)
  .subscribe();

❶ 订阅源 Kafka 主题。

❷ 在五秒窗口中缓冲记录

❸ 计算并发布吞吐量。

buffer 操作符是 RxJava 中可用的几个聚合操作符之一。它会在一个时间段内聚合事件,然后将结果作为 List 传递。您可以看到我们从 RxHelper 类传递了一个 Vert.x 调度器;这是因为 buffer 会延迟事件处理,并且默认情况下将在 RxJava 特定的线程上调用下一个操作符。Vert.x 调度器确保操作符从原始 Vert.x 上下文中调用,以保留 Vert.x 线程模型。

一旦 buffer 聚合了最后五秒内的所有 Kafka 记录,publishThroughput 方法就会计算并发布吞吐量,如下所示。

列表 11.6 发布摄入吞吐量

private CompletableSource publishThroughput(List<KafkaConsumerRecord<String, 
➥ JsonObject>> records) {
  KafkaProducerRecord<String, JsonObject> record = 
➥ KafkaProducerRecord.create("event-stats.throughput",
    new JsonObject()                                             ❶
      .put("seconds", 5)
      .put("count", records.size())
      .put("throughput", (((double) records.size()) / 5.0d)));   ❷
  return producer.rxWrite(record);                               ❸
}

❶ 将有效载荷作为 JSON 对象

❷ 计算吞吐量。

❸ 写入 Kafka 记录。

给定 records 列表,我们可以轻松计算吞吐量并发布新的记录。我们注意指出记录数量和时间窗口大小(以秒为单位),以便事件消费者拥有所有信息,而不仅仅是原始结果。

11.1.3 使用聚合区分器和时间窗口计算每城市趋势

现在我们来看另一种基于 RxJava 操作符的数据聚合形式,通过计算每城市趋势。更具体地说,我们将定期计算当前每天每个城市记录了多少步。为此,我们可以重用由相同的事件统计服务发布的 event-stats.user-activity.updates Kafka 主题中的事件,因为它们包含了用户今天记录的步数,以及其他数据,包括城市。

我们可以重用 buffer 操作符,如列表 11.5 所示,然后遍历记录列表。对于每条记录,我们可以在一个散列表条目中更新,其中键是城市,值是步数。然后我们可以根据散列表中的值为每个城市发布一个更新。

然而,我们可以通过 groupBy 操作符编写一个更符合 RxJava 风格的处理管道,如下一列表和图 11.3 所示。

列表 11.7 使用 RxJava 管道计算每个城市的趋势

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumer("event-stats-city-trends"))
  .subscribe("event-stats.user-activity.updates")
  .toFlowable()
  .groupBy(this::city)                                               ❶
  .flatMap(group -> 
  ➥ group.buffer(5, TimeUnit.SECONDS, RxHelper.scheduler(vertx)))   ❷
  .flatMapCompletable(this::publishCityTrendUpdate)                  ❸
  .doOnError(err -> logger.error("Woops", err))
  .retryWhen(this::retryLater)
  .subscribe();

❶ 按城市分组。

❷ 以五秒的窗口缓冲。

❸ 发布 Kafka 记录。

图 11.3 Ponge

图 11.3 从用户活动记录中计算每个城市的趋势

当事件进入管道时,groupBy 操作符根据记录中找到的城市值将它们分配到 中(该 判别器)。你可以将 groupBy 视为 SQL 语句中 GROUP BY 的等价物。过滤函数 city 在下一列表中显示,并从 Kafka 记录中提取城市值。

列表 11.8 根据城市值进行过滤

private String city(KafkaConsumerRecord<String, JsonObject> record) {
  return record.value().getString("city");
}

列表 11.7 中的 groupBy 操作符返回一个 GroupedFlowableFlowable,其中每个 GroupedFlowable 是一个为城市分组记录而专门设计的流式处理程序,这是通过 groupBy 使用 city 函数分发的。对于每个组,flatMap 操作符随后用于将事件分组到五秒的时间窗口中,这意味着每个城市的步数每五秒更新一次。

最后,publishCityTrendUpdate 方法准备一个新的记录,其中包含每个城市的更新统计数据,如下一列表所示。

列表 11.9 发布每个城市的统计数据

private CompletableSource 
➥ publishCityTrendUpdate(List<KafkaConsumerRecord<String, 
➥ JsonObject>> records) {
  if (records.size() > 0) {                                       ❶
    String city = city(records.get(0));                           ❷
    Long stepsCount = records.stream()
      .map(record -> record.value().getLong("stepsCount"))        ❸
      .reduce(0L, Long::sum);                                     ❹
    KafkaProducerRecord<String, JsonObject> record = 
    ➥ KafkaProducerRecord.create("event-stats.city-trend.updates", 
    ➥ city, new JsonObject()
      .put("timestamp", LocalDateTime.now().toString())
      .put("seconds", 5)
      .put("city", city)
      .put("stepsCount", stepsCount)
      .put("updates", records.size()));
    return producer.rxWrite(record);                              ❺
  } else {
    return Completable.complete();                                ❻
  }
}

❶ 检查时间窗口内是否已收到记录。

❷ 所有记录具有相同的城市,因此第一个识别它。

❸ 提取步数。

❹ 计算总和。

❺ 写入 Kafka 记录。

❻ 如果没有记录,则报告已完成的操作。

publishCityTrendUpdate 方法接收给定城市和时间窗口的 Kafka 记录列表。我们首先必须检查是否有记录,因为没有记录就没有事情可做。有了记录,我们可以使用 Java 流与 reduce 操作符计算总和,然后准备一个包含多个条目的 Kafka 记录:一个时间戳,时间窗口持续时间(以秒为单位),城市,记录的步数,以及时间窗口内观察到的更新数量。完成这些后,我们将记录写入 event-stats.city-trend.updates Kafka 主题。

现在我们已经了解了如何使用 RxJava 和 Vert.x 进行高级事件流处理,让我们看看我们如何将事件传播到反应式 Web 应用程序。

11.2 实时反应式 Web 应用程序

如第七章所述,仪表板 Web 应用程序从统计服务中消费事件并显示以下内容:

  • 摄入吞吐量

  • 公共用户的排名

  • 每个城市的趋势

此应用程序实时更新,一旦接收到新数据,就在后端服务和网页浏览器之间实现了端到端集成的一个很好的案例。该应用程序是一个微服务,如图 11.4 所示。

图片

图 11.4 反应式 Web 应用程序概述

仪表板服务由两部分组成:

  • Vue.js 应用程序

  • 一个 Vert.x 服务执行以下操作:

    • 提供 Vue.js 资源

    • 连接到 Kafka 并将更新转发到 Vert.x 事件总线

    • 连接的网页浏览器和 Vert.x 事件总线之间的桥梁

让我们从 Kafka 到事件总线的转发开始。

11.2.1 将 Kafka 记录转发到 Vert.x 事件总线

通过率和城市趋势更新都直接转发到 Vue.js 应用程序代码。这些是在event-stats.throughputevent-stats.city-trend.updatesKafka 主题上接收到的记录。

DashboardWebAppVerticle中,我们按照以下方式实施 RxJava 管道。

列表 11.10 转发通过率和城市趋势更新的 RxJava 管道

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumerConfig("dashboard-webapp-throughput"))
  .subscribe("event-stats.throughput")                           ❶
  .toFlowable()
  .subscribe(record -> 
  ➥ forwardKafkaRecord(record, "client.updates.throughput"));   ❷

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumerConfig("dashboard-webapp-city-trend"))
  .subscribe("event-stats.city-trend.updates")
  .toFlowable()
  .subscribe(record -> forwardKafkaRecord(record, 
  ➥ "client.updates.city-trend"));

❶订阅 Kafka 主题。

❷转发到事件总线。

这两个 RxJava 管道没有复杂的逻辑,因为它们将数据转发到client.updates.throughputclient.updates.city-trend事件总线目标。

下一个列表显示了forwardKafkaRecord方法的实现。

列表 11.11 将 Kafka 记录转发到事件总线

private void forwardKafkaRecord(KafkaConsumerRecord<String, JsonObject> 
➥ record, String destination) {
  vertx.eventBus().publish(destination, record.value());      ❶
}

❶ 发布到事件总线。

由于 Kafka 记录值是JsonObject类型,因此无需进行数据转换即可将其发布到 Vert.x 事件总线。

11.2.2 桥接事件总线和 Web 应用程序

仪表板 Web 应用程序启动一个 HTTP 服务器,如下面的摘录所示。

列表 11.12 仪表板服务 HTTP 服务器

Router router = Router.router(vertx);                             ❶
// (...) RxJava pipelines to forward Kafka records

// (...) Event bus bridge setup                                   ❷

router.route().handler(StaticHandler.create("webroot/assets"));   ❸
router.get("/*").handler(ctx -> ctx.reroute("/index.html"));      ❹

return vertx.createHttpServer()                                   ❺
  .requestHandler(router)
  .rxListen(HTTP_PORT)
  .ignoreElement();

❶一个 Vert.x 网络路由器用于分发 HTTP 请求

❷参见列表 11.13。

❸从 webroot/assets 资源文件夹中提供静态文件。

❹ 将流量重定向到/*到/index.html。

❺ 启动 HTTP 服务器。

列表 11.12 显示了一个用于提供静态文件的 HTTP 服务器。这只是一个摘录:我们现在需要了解 Vert.x 事件总线如何连接到 Web 应用程序。

Vert.x 使用 SockJS 库(github.com/sockjs)提供事件总线集成。SockJS 是 WebSocket 协议(tools.ietf.org/html/rfc6455)的模拟库,它允许浏览器和服务器在持久连接的基础上双向通信。Vert.x 核心 API 提供对 WebSocket 的支持,但 SockJS 很有趣,因为市场上并非所有浏览器都正确支持 WebSocket,一些 HTTP 代理和负载均衡器可能会拒绝 WebSocket 连接。SockJS 尽可能使用 WebSocket,并在必要时回退到其他机制,如 HTTP 长轮询、AJAX、JSONP 或 iframe。

Vert.x Web 模块提供了一个用于 SockJS 连接的处理器,它桥接事件总线,因此可以在服务器端(在 Vert.x 中)和客户端(在 JavaScript 中)使用相同的编程模型。以下列表显示了如何配置它。

列表 11.13 配置 SockJS 事件总线网桥

SockJSHandler sockJSHandler = SockJSHandler.create(vertx);       ❶
SockJSBridgeOptions bridgeOptions = new SockJSBridgeOptions()
  .addInboundPermitted(new PermittedOptions()
  ➥ .setAddressRegex("client.updates.*"))                       ❷
  .addOutboundPermitted(new PermittedOptions()
  ➥ .setAddressRegex("client.updates.*"));                      ❸
sockJSHandler.bridge(bridgeOptions);                             ❹
router.route("/eventbus/*").handler(sockJSHandler);              ❺

❶ SockJS 处理器

❷ 接受以 client.updates 开头的目标的事件总线消息。

❸ 接受以 client.updates 开头的目标的事件总线消息。

❹ 安装网桥。

❺ SockJS 客户端端点

网桥依赖于 SockJS 客户端连接的处理器,并设置了一组权限,仅允许桥接某些事件总线目标。确实,出于安全和性能原因,限制连接的 Web 应用程序和后端之间流动的事件非常重要。在这种情况下,我决定只有以 client.updates 开头的目标将可用。

在 Web 应用程序端,Vert.x 项目提供了 vertx3-eventbus-client 库,可以通过手动下载或使用 npm(Node 包管理器)等工具下载。使用这个库,我们可以连接到事件总线,如下面的列表所示。

列表 11.14 使用 JavaScript SockJS 事件总线客户端

import EventBus from 'vertx3-eventbus-client'              ❶

const eventBus = new EventBus("/eventbus")                 ❷
eventBus.enableReconnect(true)                             ❸

eventBus.onopen = () => {                                  ❹
  eventBus.registerHandler("a.b.c", (err, message) => {    ❺
    console.log(`Received: ${message.body}`)
  })

  eventBus.publish("d.e.f", {                              ❻
    book: "Vert.x in Action",
    comment: "A great book!"
  })
}

❶ 导入 JavaScript 模块。

❷ 连接到事件总线端点。

❸ 自动重连当连接丢失时。

❹ 当与事件总线的连接建立时被调用

❺ 注册事件总线目标处理器。

❻ 向事件总线发布消息。

在源代码仓库的 part2-steps-challenge/dashboard-webapp/src/App.vue 文件中,你可以找到使用 Vert.x 事件总线在 Vue.js 组件中的完整代码。正如你所见,JavaScript 代码中具有相同的编程模型;我们可以注册事件总线处理器并发布消息,就像在 Vert.x 代码中做的那样。

11.2.3 从 Kafka 到实时 Web 应用程序更新

仪表板使用 Vue.js,就像你之前看到的公共 Web 应用程序服务一样。整个应用程序基本上都包含在项目源代码中的 App.vue 组件中。组件数据模型由以下三个条目组成。

列表 11.15 Vue.js 组件的数据模型

data() {
  return {
    throughput: 0,        ❶
    cityTrendData: {},    ❷
    publicRanking: []     ❸
  }
},

❶ 当前吞吐量

❷ 城市趋势数据

❸ 公共排名

当从 Vert.x 事件总线接收到事件时,这些条目会被更新。为此,我们使用 Vue.js 的 mounted 生命周期回调来连接到事件总线,然后按照以下方式注册处理器。

列表 11.16 Vue.js 组件中的事件总线处理器

mounted() {
  eventBus.onopen = () => {
    eventBus.registerHandler("client.updates.throughput", (err, message) => {❶
      this.throughput = message.body.throughput                              ❷
    })
    eventBus.registerHandler("client.updates.city-trend", (err, message) => {
      const data = message.body
      data.moment = moment(data.timestamp)
      this.$set(this.cityTrendData, message.body.city, data)
    })
    eventBus.registerHandler("client.updates.publicRanking", (err, message) 
    ➥ => {
      this.publicRanking = message.body
    })
  }
},

❶ 订阅吞吐量更新。

❷ 更新模型。

处理器根据从事件总线接收到的内容更新模型。由于 Vue.js 是一个响应式 Web 应用程序框架,当数据模型发生变化时,界面也会更新。例如,当 throughput 的值发生变化时,下面列表中 HTML 模板显示的值也会变化。

列表 11.17 通过 Vue.js HTML 模板进行吞吐量

(...)
<h4>
  <span class="badge badge-pill badge-dark">{{ throughput }}</span>     ❶
  device updates per second
</h4>
(...)

❶ 绑定到吞吐量数据值

城市趋势视图的渲染是一个更复杂的模板。

列表 11.18 城市趋势 vue.js HTML 模板

<h4>Trends</h4>
<table class="table table-sm table-hover">
  <thead>
  <tr>
    <th scope="col">City</th>
    <th scope="col">Steps</th>
  </tr>
  </thead>
  <transition-group name="city-trends" tag="tbody">
    <tr v-for="item in cityTrendRanking" v-bind:key="item.city">     ❶
      <td scope="row">{{ item.city }}</td>                          ❷
      <td>
        +{{ item.stepsCount }}                                      ❸
        <span class="text-secondary font-weight-lighter">
        ({{ item.moment.format("ddd    hh:mm:ss") }})               ❹
        </span>
      </td>
    </tr>
  </transition-group>
</table>

❶ 遍历所有城市条目。

❷ 城市名称

❸ 步数

❹ 使用 Moment.js 库格式化时间戳。

该模板遍历所有城市数据并为每个城市渲染一个表格行。当一个城市有更新时,城市行会通过item.city绑定进行更新,这确保了由v-for循环生成的行中的唯一性。transition-group标签是 Vue.js 特有的,用于动画目的:当数据顺序改变时,行顺序会随着动画改变。循环遍历cityTrendRanking,这是一个在以下列表中显示的计算属性。

列表 11.19 计算排名属性

computed: {
  cityTrendRanking: function () {
    const values = Object.values(this.cityTrendData).slice(0)
    values.sort((a, b) => b.stepsCount - a.stepsCount)          ❶
    return values
  }
},

❶ 按步数排序。

cityTrendRanking计算属性根据步数对条目进行排名,因此仪表板显示步数最多的城市在最上面。

吞吐量和城市趋势每五秒更新一次,更新来自 Kafka 记录和 JSON 有效载荷被转发到仪表板 Web 应用程序。这效果很好,因为更新频繁且覆盖了聚合数据,但正如你将看到的,用户的排名会更复杂。

11.3 流和状态

仪表板 Web 应用程序显示了用户在过去 24 小时内所走步数的实时排名。用户可以根据事件统计服务产生的更新并发送到event-stats.user-activity .updates Kafka 主题的更新进行排名。

11.3.1 一系列更新

发送到event-stats.user-activity.updates的每个记录都包含给定用户的最新步数。仪表板服务可以观察这些事件,更新其状态以跟踪给定用户所走的步数,并相应地更新全局排名。问题在于我们需要一些初始状态,因为当它启动(或重启)时,仪表板服务不知道之前的更新。

我们可以将 Kafka 订阅者配置为从流的开始处重新启动,但这可能涉及几天甚至几周的数据。当仪表板服务启动时重新播放所有记录从理论上讲可以让我们计算准确的排名,但这将是一个昂贵的操作。此外,我们需要等待所有记录被处理完毕后再向连接的 Web 应用程序发送更新,因为这会在事件总线上产生大量流量。

另一种解决方案是先询问活动服务当前日的排名情况,这是一个内置在服务中的简单 SQL 查询。我们将这个阶段称为激活阶段。然后,我们可以随着从event-stats.user-activity.updates Kafka 主题接收更新来更新排名。

11.3.2 激活排名状态

仪表板服务维护一个publicRanking字段,它是一个映射,键是用户名,值是最新用户更新条目作为 JSON 数据。当服务启动时,此集合为空,因此第一步是用数据填充它。

要实现这一点,需要在DashboardWebAppVerticle初始化方法(rxStart)中调用hydrate方法,紧随 Kafka 消费者设置之后,如列表 11.10 所示。此方法通过调用活动和用户配置文件服务来组装排名数据,如下面的列表所示。

列表 11.20 hydrate方法的实现

WebClient webClient = WebClient.create(vertx);
webClient
  .get(3001, "localhost", "/ranking-last-24-hours")              ❶
  .as(BodyCodec.jsonArray())
  .rxSend()
  .delay(5, TimeUnit.SECONDS, RxHelper.scheduler(vertx))         ❷
  .retry(5)                                                      ❸
  .map(HttpResponse::body)
  .flattenAsFlowable(Functions.identity())
  .cast(JsonObject.class)
  .flatMapSingle(json -> whoOwnsDevice(webClient, json))         ❹
  .flatMapSingle(json -> fillWithUserProfile(webClient, json))   ❺
  .subscribe(
    this::hydrateEntryIfPublic,                                  ❻
    err -> logger.error("Hydration error", err),
    () -> logger.info("Hydration completed"));

❶ 活动服务排名端点

❷ 允许服务启动时的延迟。

❸ 如果活动服务不可用,允许重试五次。

❹ 对于每个设备排名条目,找到所有者。

❺ 填充用户详细信息。

❻ 只跟踪选择公开的用户。

hydrate方法的实现依赖于获取过去 24 小时内设备的排名。服务返回一个按步数数量排序的 JSON 数组。我们在发出请求前允许任意五秒的延迟,并在活动服务不可用时允许重试五次。一旦我们有了排名数据,whoOwnsDevice方法(列表 11.21)和fillWithUserProfile方法(列表 11.22)将计步器相关的数据与用户关联起来。最后,列表 11.23 中的hydrateEntryIfPublic方法使用选择公开排名的用户的数据填充publicRanking集合。

列表 11.21 查找设备所有者

private Single<JsonObject> whoOwnsDevice(WebClient webClient, 
➥ JsonObject json) {
  return webClient
    .get(3000, "localhost", "/owns/" + json.getString("deviceId"))    ❶
    .as(BodyCodec.jsonObject())
    .rxSend()
    .retry(5)
    .map(HttpResponse::body)
    .map(resp -> resp.mergeIn(json));                                 ❷
}

❶ 查找设备所有者的请求。

❷ 合并 JSON 数据。

whoOwnsDevice方法执行 HTTP 请求以确定设备的所有者,然后合并生成的 JSON 数据。此时,我们需要填充剩余的用户数据,这通过fillWithUserProfile方法完成,如下所示。

列表 11.22 向排名数据添加用户数据

private Single<JsonObject> fillWithUserProfile(WebClient webClient, 
➥ JsonObject json) {
  return webClient
    .get(3000, "localhost", "/" + json.getString("username"))   ❶
    .as(BodyCodec.jsonObject())
    .rxSend()
    .retry(5)
    .map(HttpResponse::body)
    .map(resp -> resp.mergeIn(json));                           ❷
}

❶ 获取用户数据。

❷ 合并 JSON 数据。

此代码与whoOwnsDevice方法的代码非常相似。

最后但同样重要的是,下面的列表中的hydrateEntryIfPublic方法向publicRanking集合添加数据。

列表 11.23 公开用户数据的水合

private void hydrateEntryIfPublic(JsonObject data) {
  if (data.getBoolean("makePublic")) {                      ❶
    data.put("timestamp", Instant.now().toString());        ❷
    publicRanking.put(data.getString("username"), data);    ❸
  }
}

❶ 只存储公开用户。

❷ 插入更新操作的本地时间戳。

❸ 存储用户数据。

水合是一个异步启动的过程,当 verticle 启动时,最终publicRanking集合将包含准确的数据。请注意,在这个阶段,我们尚未将任何排名数据推送到仪表板 Web 应用程序客户端。现在让我们看看接下来会发生什么。

11.3.3 从更新流中定期更新排名

用户排名每五秒更新一次。为此,我们收集五秒内的用户更新,更新公共排名数据,并将结果推送到仪表板 Web 应用程序。我们按五秒的时间跨度批量处理数据以控制仪表板刷新,但你可以减少时间窗口,甚至完全去掉它,如果你想要一个更活跃的仪表板。下一个列表展示了用于管理此过程的 RxJava 管道。

列表 11.24 用于更新用户排名的 RxJava 管道

KafkaConsumer.<String, JsonObject>create(vertx, 
➥ KafkaConfig.consumerConfig("dashboard-webapp-ranking"))
  .subscribe("event-stats.user-activity.updates")                ❶
  .toFlowable()
  .filter(record -> record.value().getBoolean("makePublic"))     ❷
  .buffer(5, TimeUnit.SECONDS, RxHelper.scheduler(vertx))        ❸
  .subscribe(this::updatePublicRanking);                         ❹

❶ 订阅更新。

❷ 仅保留公共用户。

❸ 在五秒内分组事件。

❹ 更新排名并推送数据。

使用 filter 操作符仅保留用户数据为公共的 Kafka 记录,而 buffer 操作符创建五秒的事件窗口。

下一个列表展示了处理这些事件批次的 updatePublicRanking 方法的实现。

列表 11.25 公共排名维护过程

private void updatePublicRanking(List<KafkaConsumerRecord<String, 
➥ JsonObject>> records) {
  copyBetterScores(records);                                                 ❶
  pruneOldEntries();                                                         ❷
  vertx.eventBus().publish("client.updates.publicRanking", computeRanking());❸
}

❶ 合并数据。

❷ 丢弃旧数据。

❸ 计算排名并发送到事件总线

该方法将过程描述为三个步骤:

  1. 使用收集到的数据更新排名数据。

  2. 丢弃旧条目。

  3. 计算新的排名并通过事件总线发送到连接的 Web 应用程序。

下一个列表展示了 copyBetterScores 方法的实现。

列表 11.26 更新排名数据

private void copyBetterScores(List<KafkaConsumerRecord<String, JsonObject>> 
➥ records) {
  for (KafkaConsumerRecord<String, JsonObject> record : records) {
    JsonObject json = record.value();
    long stepsCount = json.getLong("stepsCount");                          ❶
    JsonObject previousData = publicRanking.get(json.getString("username"));
    if (previousData == null || previousData.getLong("stepsCount") < 
    ➥ stepsCount) {                                                       ❷
      publicRanking.put(json.getString("username"), json);
    }
  }
}

❶ 获取建议的步数更新数量。

❷ 仅在步数更多时更新。

前面的方法在收集到的条目步数高于前一个条目时更新 publicRanking 集合,因为可能存在水合过程和用户更新之间的冲突。

下一个列表展示了 pruneOldEntries 方法。

列表 11.27 修剪旧数据

private void pruneOldEntries() {
  Instant now = Instant.now();                                ❶
  Iterator<Map.Entry<String, JsonObject>> iterator = 
  ➥ publicRanking.entrySet().iterator();
  while (iterator.hasNext()) {                                ❷
    Map.Entry<String, JsonObject> entry = iterator.next();
    Instant timestamp = 
    ➥ Instant.parse(entry.getValue().getString("timestamp"));
    if (timestamp.until(now, ChronoUnit.DAYS) >= 1L) {        ❸
      iterator.remove();
    }
  }
}

❶ 获取当前时间。

❷ 遍历所有排名数据。

❸ 一天后删除条目。

此方法简单地遍历 publicRanking 集合中所有排名数据条目,并删除超过一天的条目。

排名由 computeRanking 方法生成,如下所示。

列表 11.28 计算排名

private JsonArray computeRanking() {
  List<JsonObject> ranking = publicRanking.entrySet()
    .stream()
    .map(Map.Entry::getValue)                          ❶
    .sorted(this::compareStepsCountInReverseOrder)     ❷
    .map(json -> new JsonObject()                      ❸
      .put("username", json.getString("username"))
      .put("stepsCount", json.getLong("stepsCount"))
      .put("city", json.getString("city")))
    .collect(Collectors.toList());
  return new JsonArray(ranking);                       ❹
}

❶ 提取 publicRanking 中的值。

❷ 按递减的步数排序。

❸ 复制值。

❹ 包装为 JSON 数组。

该方法对公共排名数据进行排序,并生成一个 JSON 数组,其中条目按逆序排列(第一个值是过去 24 小时内步数最多的用户,依此类推)。

用于比较和排序条目的 compareStepsCountInReverseOrder 方法如下所示。

列表 11.29 比较用户数据与其步数

private int compareStepsCountInReverseOrder(JsonObject a, JsonObject b) {
  Long first = a.getLong("stepsCount");
  Long second = b.getLong("stepsCount");
  return second.compareTo(first);          ❶
}

❶ 委托给 java.lang.Long 类中的 compareTo

b 的步数少于 a 时,比较返回 -1,当它们相等时返回 0,当 b 的步数多于 a 时返回 1。

下一个列表展示了用于渲染用户排名表的 Vue.js 模板。

列表 11.30 Vue.js 中的用户排名模板

<h4>Public ranking (last 24 hours)</h4>
<table class="table table-sm table-hover">
  <thead>
  <tr>
    <th scope="col">Name</th>
    <th scope="col">From</th>
    <th scope="col">Steps</th>
  </tr>
  </thead>
  <transition-group name="public-ranking" tag="tbody">
  <tr v-for="item in publicRanking" v-bind:key="item.username">     ❶
    <td scope="row">{{ item.username }}</td>
    <td>{{ item.city }}</td>
    <td>{{ item.stepsCount }}</td>
  </tr>
  </transition-group>
</table>

❶ 遍历数据。

Web 应用的 Vue.js 代码通过事件总线接收排名数组并更新publicRanking数据条目。每当发生这种情况时,显示都会更新以反映这些变化。就像城市趋势表一样,条目会根据它们的顺序变化使用动画移动。

这完成了从 Kafka 记录到反应式 Web 应用的端到端流处理。下一章将重点介绍反应式系统中的弹性和容错性。

摘要

  • RxJava 提供了高级操作符如buffergroupBy,可以将它们组合起来执行聚合数据处理。

  • 微服务不必暴露 HTTP API。事件统计服务仅消费和产生 Kafka 记录。

  • 有一些流处理工作可以在流的任何位置开始,例如计算吞吐量,而其他工作则需要一些初始状态,例如维护过去 24 小时内用户的实时排名。

  • Vert.x 事件总线可以通过 SockJS 协议扩展到 Web 应用,提供跨服务和 Web 代码库相同的通信模型。

  • Vert.x 允许你构建端到端反应式系统,其中事件在服务中触发计算并影响面向用户的 Web 应用。

12 通过负载和混沌测试实现响应性

本章涵盖

  • 使用 Locust 模拟用户

  • 使用 Hey 进行 HTTP 端点负载测试

  • 使用 Pumba 进行混沌测试

  • 使用显式超时、断路器和缓存来减轻故障

我们现在已经涵盖了 10k 步挑战应用程序的所有重要技术部分:如何构建 Web API、Web 应用程序和边缘服务,以及如何使用数据库和执行事件流处理。通过使用 Vert.x 的异步和响应式编程,我们可以期待构成应用程序的服务集是 响应式的:随着工作负载的增长而可扩展,并在发生故障时具有弹性。

我们构建的服务实际上是否是响应式的?现在让我们通过测试和实验来发现这一点,并看看我们可以在哪些方面做出改进以实现响应式。

为了做到这一点,我们将使用负载测试工具来对服务进行压力测试并测量延迟。然后,我们将使用混沌测试工具添加故障,以查看这如何影响服务行为,并将讨论几种修复我们识别出的问题的选项。您也将能够在自己的项目中应用这种方法。

软件版本

本章是用以下工具版本编写和测试的:

  • Locust 1.0.3

  • Python 3.8.2

  • Hey 0.1.3

  • Pumba 0.7.2

12.1 初始实验:性能是否良好?

本章大量基于实验,因此我们需要生成一些工作负载来评估应用程序如何应对繁重的工作负载和故障。有许多负载测试工具,而且并不总是容易选择一个。有些工具在针对特定请求(例如,“每秒发送 500 个请求到 /api/hello 的延迟是多少”)对服务进行压力测试方面非常出色。有些工具通过提供脚本功能提供了更多的灵活性(例如,“模拟一个登录的用户,然后向购物车添加项目,然后执行购买”)。最后,有些工具做了所有这些,但由于这些工具的实现方式,报告的指标可能不准确。

我已经选择了两个流行且易于使用的工具在本章中使用:

  • Locust --一个多功能的负载测试工具,通过用 Python 编写的脚本模拟用户 (locust.io/)

  • Hey --一个可靠的 HTTP 负载生成器 (github.com/rakyll/hey)

这两个工具可以一起使用,也可以单独使用。Locust 允许我们通过用 Python 编写的脚本模拟与应用程序交互的用户的工作负载,而 Hey 则为我们提供了在压力下特定 HTTP 端点行为的精确指标。

小贴士:Both Locust 和 Hey 都在 Linux、macOS 和 Windows 上工作。通常,如果您是 Windows 用户,我建议您使用 Windows Subsystem for Linux (WSL)。

12.1.1 负载测试前的考虑事项

在我们运行负载测试工具之前,我想讨论一些必须考虑的点,以确保获得具有代表性的结果。最重要的是,我们需要谨慎地解释它们。

首先,当你按照第七章概述的方式运行 10k 步骤应用时,所有服务都在本地运行,而第三方中间件和服务则在 Docker 容器中运行。这意味着实际上所有内容都在同一台机器上运行,避免了真正的网络通信。例如,当用户配置文件服务与 MongoDB 通信时,它将通过虚拟网络接口进行,但永远不会达到实际的网络接口,因此没有波动延迟或数据丢失。我们将在本章后面使用其他工具来模拟网络问题,并更精确地了解我们的服务行为。

接下来,你很可能会在你的笔记本电脑或台式机上执行这些实验。记住,真实的服务器在硬件和软件配置方面都不同于你的工作站,因此你可能会进行低于服务在生产环境中实际能够处理的负载测试。例如,当我们直接从容器中使用 PostgreSQL 时,我们不会进行任何调整,这在生产环境中我们会进行。更普遍地说,从容器中运行中间件服务对于开发目的来说很方便,但在生产中我们会以不同的方式运行它们,无论是否使用容器。此外,请注意,我们将不进行任何 JVM 调整来运行基于 Vert.x 的服务。在生产环境中,你至少需要调整内存设置并调整垃圾收集器。

此外,每个服务都将作为一个单独的实例运行,verticles 也将是单个实例。它们都已被设计为可以与多个实例一起工作,但部署,比如说,两个摄入服务的实例,也要求部署一个 HTTP 反向代理来在两个实例之间分配流量。

最后但同样重要的是,最好使用两台机器来运行负载测试:一台运行应用程序,另一台运行负载测试工具。如果你更方便的话,你可以在单台机器上执行测试,但请记住这些要点:

  • 你将不会通过网络,这会影响结果。

  • 被测试的服务和负载测试工具将竞争操作系统资源(CPU 时间、网络、打开的文件描述符等),这也影响结果。

我在本章中展示的结果是基于使用两台苹果 MacBook 笔记本电脑进行的实验,这些电脑几乎不能算作是生产级的服务器。我还使用了一个家庭 WiFi 网络,这不如以太网有线连接好,尤其是在稳定延迟方面。最后,macOS 对进程可以打开的文件描述符数量有非常低的限制(256),因此我必须使用 ulimit 命令来提高这些服务和负载测试工具的限制,否则可能会因为打开太多连接而产生与服务代码无关的错误。我将向您展示如何做到这一点,并且根据您的系统,您可能需要使用这种技术来运行实验。

12.1.2 使用 Locust 模拟用户

Locust 是一个通过模拟用户与服务交互来生成工作负载的工具。您可以使用它进行演示、测试和性能测量。

您的机器上需要安装 Python 的最新版本。如果您是 Python 新手,您可以阅读 Naomi Ceder 的 Exploring Python Basics(Manning,2019)或浏览网上众多的教程。在撰写本文时,我使用的是 Python 3.8.2。

您可以通过在命令行中运行 pip install locust 来安装 Locust,其中 pip 是标准的 Python 软件包管理器。

我们将要使用的 Locust 文件是 locustfile.py,它可以在书的 Git 仓库的 part2-steps-challenge/load-testing 文件夹中找到。我们将模拟图 12.1 中展示的用户行为:

  1. 每个新用户都是通过随机数据和一组预定义的城市生成的。

  2. 新创建的用户通过公共 API 进行注册。

  3. 用户在注册后的第一次请求中获取 JWT 令牌,然后定期发送请求:

    • 用户发送步数更新(其请求的 80%)。

    • 用户获取其个人资料数据(其请求的 5%)。

    • 用户获取其总步数(其请求的 5%)。

    • 用户获取当前日期的步数(其请求的 10%)。

这种活动涵盖了大多数服务:触发事件交换,以及 API 查询触发对活动和服务用户资料的调用。

图片

图 12.1 Locust 中模拟用户的活动

locustfile.py 文件定义了两个类。UserBehavior 定义了用户执行的任务,而 UserWithDevice 则在 0.5 到 2 秒的随机延迟之间运行这些任务。这是请求之间的相对较短延迟,以增加每秒请求的整体数量。

运行 Locust 测试有两个参数:

  • 要模拟的用户数量

  • 孵化率,即在初始爬坡阶段每秒创建的新用户数量

如第七章所述,你需要从 part2-steps-challenge 文件夹中使用终端中的docker-compose up命令运行容器服务。然后你可以在另一个终端中运行所有基于 Vert.x 的服务。如果你安装了 foreman,可以使用foreman start,或者可以使用 Procfile 中的命令运行所有服务。

下面的列表显示了执行初始预热运行的命令。

列表 12.1 Locust 预热运行

$ cd part2-steps-challenge/load-testing
$ ulimit -n 10000                                 ❶
$ locust --headless \                             ❷
    --host http://192.168.0.23 \                  ❸
    --users 50 --hatch-rate 1 --run-time 3m       ❹

❶ 将每个进程的打开文件描述符数量提高到 10,000 个。

❷ 不要启动 Locust 的 Web 界面。

❸ 将此替换为运行服务的机器的 IP 地址(或者在最坏的情况下,使用 localhost)。

❹ 50 个客户端,每秒 1 个新客户端,执行 3 分钟

进行这样的预热运行很重要,因为运行各种服务的 JVM 需要在开始高效运行代码之前有一些工作负载。之后,你可以运行更大的工作负载,以获得对你服务性能的第一估计。

下面的列表显示了运行 5 分钟测试的命令,使用 150 个客户端和每秒 2 个新用户的孵化率。

列表 12.2 Locust 运行

$ mkdir data/
$ locust --headless \
    --host http://192.168.0.23 \
    --users 150 --hatch-rate 2 --run-time 5m \
    --csv data/locust-run                        ❶

❶ 将结果输出到 CSV 文件中。

让我们运行实验并收集结果。我们将得到每种类型请求的各种指标,例如平均响应时间、最小/最大时间、中位数时间等。一个有趣的指标是给定百分比的延迟。

让我们以第 80 百分位的延迟为例。这是 80%的请求观察到的最大延迟。如果这个延迟是 100 毫秒,这意味着 80%的请求花费的时间少于 100 毫秒。同样,如果第 95 百分位的延迟是 150 毫秒,这意味着 95%的请求花费的时间最多为 150 毫秒。第 100 百分位揭示了观察到的最坏情况。

在测量性能时,我们通常对第 95 百分位和第 100 百分位之间的延迟感兴趣。假设第 90 百分位的延迟是 50 毫秒,但在第 95 百分位是 3 秒,在第 99 百分位是 20 秒。在这种情况下,我们明显存在性能问题,因为我们观察到大量不良延迟。相比之下,在第 90 百分位观察到 50 毫秒的延迟,在第 99 百分位观察到 70 毫秒的延迟,表明服务具有非常一致的行为。

在负载下,一个服务行为的延迟分布比平均延迟更能说明问题。我们真正感兴趣的不是最佳情况,而是那些我们观察到最差结果的情况。图 12.2 展示了我在 5 分钟内使用 150 个用户进行的一次运行延迟报告。

图 12.2 Locust 运行

图 12.2 使用 Locust 在 5 分钟内观察到的 150 个用户的延迟

图表包含第 95、98、99 和 100 个百分位数的值。报告的延迟在所有请求的第 99 个百分位数下都低于 200 毫秒,这在条件不完美且没有调整的情况下听起来是合理的。第 100 个百分位数的值显示了观察到的最差响应时间,并且它们都在 500 毫秒以下。

我们可以增加用户数量以进一步增加应用程序的压力,但我们不会使用 Locust 进行精确的负载测试。如果你增加用户数量,你将很快看到延迟和错误开始增加。这并不是由于正在测试的应用程序,而是由于撰写本文时 Locust 的限制:

  • Locust 的网络堆栈效率不高,所以我们很快就会达到并发用户的限制。

  • 与许多负载测试工具一样,Locust 受到“协调遗漏”问题的影响,这是一个由于忽略了请求实际发出之前的时间而导致的测量时间不正确的问题。1

为了进行准确的负载测试,我们因此必须使用另一个工具,而 Hey 就是一个不错的选择。

提示:Locust 仍然是一个很好的工具,可以产生少量负载,甚至可以自动化项目的演示。一旦启动并模拟用户,你就可以连接到仪表板 Web 应用程序并实时查看更新。

12.1.3 使用 Hey 进行 API 压力测试

Hey 比 Locust 简单得多,因为它不能运行脚本,它专注于对 HTTP 端点进行压力测试。然而,它是一个在压力下获取端点准确测量的优秀工具。

我们仍然会在旁边使用 Locust 来模拟少量用户。这将生成系统跨所有服务和中间件的活动,因此我们的测量不会在空闲系统中进行。

我们将使用两种不同的请求来对公共 API 端点进行压力测试:

  • 获取用户的总步骤数。

  • 进行身份验证并获取 JWT 令牌。

这很有趣,因为要获取用户的步骤数,公共 API 服务需要向活动服务发出 HTTP 请求,该服务随后查询 PostgreSQL 数据库。获取 JWT 令牌涉及更多工作,因为用户配置文件服务需要在进行一些加密工作之前查询两次,最后返回 JWT 令牌。因此,这些请求的总延迟受到 HTTP API、用户和活动服务以及最终数据库中完成的工作的影响。

注意:这里的目的是不是识别服务的最大吞吐量和最佳延迟的限制。我们想要有一个基线来观察服务在持续负载下的行为,这将在以后帮助我们描述各种类型故障和缓解策略的影响。

由于 Hey 不能运行脚本,我们必须专注于一个用户,并将对 Hey 的调用封装在 shell 脚本中。你将在 part2-steps-challenge/load-testing 文件夹中找到辅助脚本。第一个脚本是 create-user.sh,如下所示。

列表 12.3 创建用户的脚本

#!/bin/bash
http http://localhost:4000/api/v1/register \     ❶
  username="loadtesting-user" \
  password="13tm31n" \
  email="loadtester@my.tld" \
  deviceId="p-123456-abcdef" \
  city="Lyon" \
  makePublic:=true

for n in `seq 10`; do                            ❷
  http http://localhost:3002/ingest \
    deviceId="p-123456-abcdef" \
    deviceSync:=$n \
    stepsCount:=1200
done

❶ 注册 loadtesting-user 用户。

❷ 发布 10 次包含 1,200 步的更新。

此脚本确保用户 loadtesting-user 已创建,并且已记录了一些更新。

下面的列表中 run-hey-user-steps.sh 脚本使用 Hey 并获取用户 loadtesting-user 的总步数。

列表 12.4 运行 Hey 和负载测试以获取用户总步骤数的脚本

#!/bin/bash
hey -z $2 \                                           ❶
    -o csv \                                          ❷
    -H 'Authorization: Bearer <TOKEN>' \              ❸
    http://$1:4000/api/v1/loadtesting-user/total \    ❹
    > data/hey-run-steps-z$2.csv                      ❺

❶ 运行持续时间(10s5m 等)

❷ 启用 CSV 输出。

❸ 传递用户 loadtesting-user 的 JWT 令牌。

❹ 服务的 URL,其中主机名是一个变量

❺ 将 CSV 输出重定向到文件。

下面的列表中 run-hey-token.sh 脚本类似,执行身份验证请求以获取 JWT 令牌。

列表 12.5 运行 Hey 和负载测试以获取 JWT 令牌的脚本

#!/bin/bash
hey -z $2 \
    -m POST \                ❶
    -D auth.json \           ❷
    -T application/json \    ❸
    -o csv \
    http://$1:4000/api/v1/token \
    > data/hey-run-token-z$2.csv

❶ 指定这是一个 HTTP POST 请求。

❷ 发送包含 loadtesting-user 用户凭证的 auth.json 文件内容。

❸ 指定有效载荷是某些 JSON 数据。

我们现在准备好对用户总步骤数端点进行运行。在我的情况下,我使用第二台笔记本电脑进行实验,而我的主要笔记本电脑运行服务,当我在测试时,它的 IP 地址是 192.168.0.23。首先,我们将使用 Locust 获取一些轻量级背景工作负载,再次确保系统不是完全空闲:

$ locust --headless --host http://192.168.0.23 --users 20 --hatch-rate 2

在另一个终端中,我们将使用 Hey 运行五分钟的测试:

./run-hey-user-steps.sh 192.168.0.23 5m

一旦我们收集了结果,分析它们最好的方式是处理数据并绘制图表。你将在 part2-steps-challenge/load-testing 文件夹中找到执行此操作的 Python 脚本。图 12.3 显示了此实验的图表。

图片

图 12.3 用户总步骤数负载测试报告

该图包含三个子图:

  • 随时间变化的请求延迟的散点图

  • 与请求延迟图具有相同刻度的吞吐量图

  • 95% 到 100% 分位数的延迟分布

当吞吐量高时,99.99% 分位数的延迟非常好。与 Locust 的 100 用户负载相比,使用 Hey 我们可以得到更好的结果。我们可以看到一些与更高延迟响应相关的短吞吐量下降,但在这些条件下没有什么需要担心的。这些下降可能是由各种因素引起的,包括 PostgreSQL、WiFi 网络,或 JVM 垃圾收集器运行。使用更好的硬件(运行 Linux、有线网络、一些 JVM 调优和正确配置的 PostgreSQL 数据库服务器)很容易得到更好的结果。

我们可以运行另一个负载测试实验,获取 JWT 令牌:

./run-hey-token.sh 192.168.0.23 5m

结果显示在图 12.4 中。

图片

图 12.4 JWT 令牌负载测试报告

这些结果再次显示了一致的行为,尽管与步骤计数端点相比,延迟更高,吞吐量更低。这很容易解释,因为有两个 HTTP 请求到用户配置文件服务,然后必须生成并签名令牌。HTTP 请求主要是 I/O 密集型,而令牌签名需要在事件循环上执行 CPU 密集型工作。五分钟运行的结果是一致的。

可以安全地得出结论,测试的服务实现能够在负载下提供稳定的性能。你可以尝试增加 Hey 的工作者数量,看看在大负载下会发生什么(参见 hey 工具的 -c 标志)。你也可以使用增加的请求速率进行延迟测量(参见 -q 标志),但请注意,默认情况下 Hey 不进行速率限制,所以在之前的运行中 Hey 使用了默认的 50 个工作者(默认值)。

可伸缩性只是反应性的半部分,因此现在让我们看看我们的服务在出现故障的情况下,如何处理相同的工作负载。

12.2 让我们进行一些混沌工程

严格来说,混沌工程是指在生产系统中自愿引入故障的实践,以观察它们对意外应用程序、网络和基础设施故障的反应。例如,你可以尝试关闭数据库、停止服务、引入网络延迟,甚至中断网络之间的流量。而不是等待生产中出现故障,在周日早上 4 点叫醒值班的服务可靠性工程师,你可以选择主动地定期自己引入故障。

在软件投入生产之前,你还可以进行混沌工程,因为核心原则保持不变:运行带有一定工作负载的软件,引入某种形式的故障,并观察软件的表现。

12.2.1 测试计划

我们需要一个可重复的场景来评估服务,因为它们将在正常和故障阶段之间交替。我们将根据图 12.5 中的计划引入故障。

图片

图 12.5 测试计划

我们将在五分钟的时间段内运行与之前章节相同的负载测试实验。变化之处在于,我们将将其分为五个阶段,每个阶段为一分钟:

  1. 数据库在第一分钟内正常工作。

  2. 我们将在第二分钟为所有数据库流量引入三秒(± 500 毫秒)的网络延迟。

  3. 我们将在第三分钟恢复到正常性能。

  4. 我们将停止两个数据库四分钟。

  5. 我们将在第五和最后一分钟恢复到正常性能。

网络延迟增加了延迟,但它们也模拟了一个开始变得不响应的过载数据库或服务。在极端的延迟值下,它们还可以模拟一个不可达的主机,其中建立 TCP 连接需要很长时间才能失败。另一方面,停止数据库模拟了服务已关闭而其主机仍然在线的情况,这应该会导致快速的 TCP 连接错误。

我们将如何引入这些故障?

12.2.2 使用 Pumba 进行混沌测试

Pumba 是一个用于在 Docker 容器中引入故障的混沌测试工具 (github.com/alexei-led/pumba)。它可以用于以下操作:

  • 杀死、移除和停止容器

  • 暂停容器中的进程

  • 压力容器资源(例如,CPU、内存或文件系统)

  • 仿真网络问题(数据包延迟、丢失、重复、损坏等)

Pumba 是一个非常方便的工具,你可以下载并在你的机器上运行。唯一的依赖是 Docker 必须运行。

在我们的测试计划中,我们关注两种类型的故障,因为它们对我们来说最相关。你可以轻松地玩其他类型的故障。

在本地运行 10k 步应用的情况下,让我们使用 Pumba 并对 MongoDB 数据库流量添加一些延迟。让我们使用 load-testing/fetch-token.sh 脚本获取 JWT 令牌,如下所示。

列表 12.6 获取 JWT 令牌

$ load-testing/fetch-token.sh      ❶
HTTP/1.1 200 OK
Content-Type: application/jwt
content-length: 528

<VALUE OF THE TOKEN>

❶ 位于 part2-steps-challenge 文件夹中

在另一个终端中,让我们使用以下命令引入延迟。

列表 12.7 使用 Pumba 引入一些网络延迟

$ pumba netem \                           ❶
    --duration 1m \                       ❷
    --tc-image gaiadocker/iproute2 \      ❸
    delay --time 3000 --jitter 500 \      ❹
    part2-steps-challenge_mongo_1         ❺

❶ netem 是网络问题仿真的子命令。

❷ 将会有一分钟延迟。

❸ 一个辅助 Docker 镜像

❹ 三秒延迟 ± 500 毫秒

❺ 目标容器的名称(你可以使用正则表达式来针对多个容器等)

Pumba 现在应该已经运行了一分钟。再次尝试获取 JWT 令牌;命令应该明显比之前花费更多时间,如下所示。

列表 12.8 带网络延迟获取令牌

$ time ./fetch-token.sh        ❶
HTTP/1.1 200 OK
Content-Type: application/jwt
content-length: 528

<TOKEN VALUE>

./fetch-token.sh  0.27s user 0.08s system 5% cpu 6.157 total

❶ 使用 time 来测量进程执行时间。

由于等待 I/O,进程花费了 6.157 秒来获取令牌。同样,你可以使用以下命令停止一个容器。

列表 12.9 使用 Pumba 停止容器

$ pumba stop --restart \       ❶
  --duration 1m \
  part2-steps-challenge_mongo_1

❶ 停止容器,然后重启。

如果你再次运行脚本以获取令牌,你将需要等待,而在日志中你会看到一些错误,因为 MongoDB 容器已关闭,如下所示。

列表 12.10 带停止的数据库服务器获取令牌

time ./fetch-token.sh
HTTP/1.1 200 OK
Content-Type: application/jwt
content-length: 528

<TOKEN VALUE>

./fetch-token.sh  0.25s user 0.07s system 0% cpu 57.315 total     ❶

❶ 这花了很长时间!

服务现在不响应。我的请求花费了 57.315 秒来完成,因为它必须等待数据库恢复。

通过运行测试计划,我们可以更清楚地了解,当这些故障发生并且系统处于负载测试之下时会发生什么。

12.2.3 我们(目前)还没有弹性

要运行这些实验,你将使用与本章前面相同的 shell 脚本启动 Hey。你最好使用两台机器。part2-steps-challenge/负载测试文件夹包含一个 run-chaos.sh shell 脚本,通过在正确的时间调用 Pumba 来自动化测试计划。关键是同时启动 run-chaos.sh 和 Hey 脚本(例如,run-hey-token.sh)。

图 12.6 显示了服务在获取用户总步数时的行为。结果显示,当 Pumba 运行时,响应性明显不足。

图片

图 12.6 总步数负载测试失败情况

在网络延迟的阶段,我们看到延迟迅速增加,达到近 20 秒,之后吞吐量崩溃。这里发生的情况是,请求被排队等待在公共 API 和用户配置文件服务中响应,直到系统停止。数据库延迟在 2.5 秒到 3.5 秒之间,这在实践中可能会暂时发生。当然,由于负载测试,这个问题被大大放大了,但任何有一定持续流量的服务即使有较小的延迟也可能表现出这种行为。

在数据库关闭的阶段,我们看到了整个模拟故障期间的错误。虽然对错误感到惊讶很难,但我们也可以看到系统并没有完全停止。尽管如此,这还远非完美,因为降低的吞吐量是请求需要“一些”时间才能得到错误信号的一个迹象,而其他请求则等待超时,或者当数据库重新启动时最终完成。

现在我们来看看图 12.7,看看获取 JWT 令牌的过程。

图片

图 12.7 JWT 令牌负载测试失败情况

网络延迟也会导致系统停止,但在散点图中我们没有观察到相同的形状。这是由于此类请求的服务本身吞吐量较低,以及需要两个 HTTP 请求的事实。请求堆积,等待响应到达,一旦延迟停止,系统再次开始运行。更有趣的是,我们在数据库停止的阶段没有观察到错误。因为系统正在等待数据库,所以不再有请求被服务。

从这两个实验中,我们可以看到在存在失败的情况下,服务变得无响应,因此它们不是反应式的。好消息是,有方法可以解决这个问题,所以让我们看看我们如何再次变得反应式,再次以公共 API 作为参考。你将能够将技术外推到其他服务。

12.3 从“可扩展”到“可扩展且具有弹性”

为了使我们的应用程序具有弹性,我们必须对公共 API 进行更改,并确保在检测到失败时能够快速响应。我们将探讨两种方法:强制执行超时,然后使用断路器。

12.3.1 强制执行超时

前面的实验观察表明,在等待数据库恢复到正常条件或 TCP 错误出现时,请求会堆积。一种初步的方法是在 HTTP 客户端请求中强制执行短超时,这样当用户配置文件或活动服务响应时间过长时,它们会快速失败。更改非常简单:我们只需要向 Vert.x 网络客户端发出的 HTTP 请求中添加超时,如图 12.11 所示。

提示:您可以在 Git 仓库的 chapter12/public-api-with-timeouts 分支中找到相应的代码更改。

列表 12.11 带超时的totalSteps方法实现

private void totalSteps(RoutingContext ctx) {
  String deviceId = ctx.user().principal().getString("deviceId");
  webClient
    .get(3001, "localhost", "/" + deviceId + "/total")
    .timeout(5000)                                      ❶
    .as(BodyCodec.jsonObject())
    .rxSend()
    .subscribe(
      resp -> forwardJsonOrStatusCode(ctx, resp),
      err -> sendBadGateway(ctx, err));
}

❶ 五秒后超时

fetchUserDetailstoken方法中的更改相同。五秒的超时相对较短,并确保快速通知错误。

直观上看,这应该会提高公共 API 服务的响应性,并避免吞吐量停滞。让我们通过再次运行混沌测试实验来查看会发生什么,如图 12.8 所示。

与图 12.6 中的实验相比,我们在失败期间仍然有大幅降低的吞吐量,但至少我们看到了错误报告,这得益于超时强制执行。我们还看到最大延迟低于六秒,这与五秒的超时相符。

图片

图 12.8 带有失败和超时的总步骤数负载测试

现在我们来看看 JWT 令牌负载测试的行为,如图 12.9 所示。这次运行确认了我们观察到的结果:超时被强制执行,确保在失败期间仍有部分请求被服务。然而,最坏情况下的延迟比没有超时的情况更差:网络延迟将向用户配置文件服务发送两个 HTTP 请求的时间拉长,因此较高的值对应于第二个请求超时的情况。

图片

图 12.9 带有失败和超时的 JWT 令牌负载测试

在提高响应性方面,超时比没有超时更好,但我们不能将我们的公共 API 服务称为具有弹性。我们需要的是一种让服务“知道”正在发生故障的方法,这样它就会快速失败,而不是等待超时发生。这正是断路器的作用所在!

12.3.2 使用断路器

断路器的目的是防止前一部分中观察到的问题,即对无响应系统的请求堆积,导致分布式服务之间发生级联错误。断路器充当发出(网络)请求的代码(如 RPC 调用、HTTP 请求或数据库调用)与要调用的服务之间的代理形式。

图 12.10 展示了断路器作为有限状态机的工作方式。这个想法相当简单。断路器从 关闭 状态开始,对于每个请求,它观察请求是否成功。失败可能是由于报告了错误(例如,TCP 超时或 TCP 连接错误),或者是因为操作完成得太慢。

电路断路器配置图

图 12.10 断路器状态机

一旦报告了一定数量的错误,断路器将进入 开启 状态。从这里开始,所有操作都会通知由于电路开启而导致的故障。这避免了向无响应的服务发出进一步请求,从而允许快速错误响应,尝试替代恢复策略,并减轻服务端和请求端的压力。

断路器在经过一些 重置 超时后离开 开启 状态并进入 半开启 状态。在 半开启 状态下的第一个请求确定服务是否已恢复。与 开启 状态不同,半开启 状态是我们再次开始执行实际操作的地方。如果成功,断路器将返回到 关闭 状态并继续正常服务。如果不成功,在它返回到 半开启 状态之前将开始另一个重置周期,并检查服务是否恢复。

提示:您可以在 Git 仓库的 chapter12/public-api-with-circuit-breaker 分支中找到这里讨论的代码更改。

Vert.x 提供了 vertx-circuit-breaker 模块,需要将其添加到公共 API 项目中。我们将使用两个断路器:一个用于令牌生成请求,另一个用于对活动服务的调用(例如获取用户的总步数)。以下列表显示了在 PublicApiVerticlerxStart 方法中创建断路器的代码。

列表 12.12 创建断路器

String tokenCircuitBreakerName = "token-circuit-breaker";
tokenCircuitBreaker = CircuitBreaker.create(
  tokenCircuitBreakerName, vertx, circuitBreakerOptions());                 ❶

tokenCircuitBreaker
  .openHandler(v -> this.logBreakerUpdate("open", tokenCircuitBreakerName));❷
tokenCircuitBreaker
  .halfOpenHandler(v -> this.logBreakerUpdate("half open", 
  ➥ tokenCircuitBreakerName));                                             ❸
tokenCircuitBreaker
  .closeHandler(v -> 
  ➥ this.logBreakerUpdate("closed", tokenCircuitBreakerName));             ❹

❶ 使用名称和选项创建断路器。

❷ 进入开启状态时的回调

❸ 进入半开启状态时的回调

❹ 进入关闭状态时的回调

tokenCircuitBreakerName 引用是一个 CircuitBreaker 类型的字段。还有一个名为 activityCircuitBreaker 的字段用于活动服务断路器,代码是相同的。可以在状态变化时可选地设置回调。为了诊断目的,记录这些状态变化是一个好主意。

以下列表显示了断路器配置。

列表 12.13 配置断路器

private CircuitBreakerOptions circuitBreakerOptions() {
  return new CircuitBreakerOptions()
    .setMaxFailures(5)                ❶
    .setMaxRetries(0)                 ❷
    .setTimeout(5000)                 ❸
    .setResetTimeout(10_000);         ❹
}

❶ 五次失败后开启。

❷ 不要重试失败的操作。

❸ 五秒后报告超时失败。

❹ 重置超时设置为 10 秒。

我们将在包括操作在五秒后超时(为了与前述实验保持一致)的五次失败后打开断路器。重置超时设置为 10 秒,这将让我们能够频繁地检查服务状态。这个值应该是多长取决于你的上下文,但你可以预期,长时间的超时会增加服务在降级模式或报告错误时运行的时间,而短值可能会降低使用断路器的有效性。

下面的列表显示了修改后的token方法,代码被封装在断路器调用中。

列表 12.14 使用断路器的 token 方法实现

private void token(RoutingContext ctx) {
  tokenCircuitBreaker.<String>rxExecute(promise -> {    ❶
    JsonObject payload = ctx.getBodyAsJson();
    String username = payload.getString("username");
    webClient                                           ❷
      .post(3000, "localhost", "/authenticate")
// (...)                                                ❸
      .subscribe(promise::complete, err -> {            ❹
        if (err instanceof NoStackTraceThrowable) {     ❺
          promise.complete("");
        } else {
          promise.fail(err);                            ❻
        }
      });
  }).subscribe(                                         ❼
    token -> sendToken(ctx, token),
    err -> handleAuthError(ctx, err));
}

❶ 执行返回字符串的操作。

❷ 定期网络客户端调用

❸ 网络客户端和 RxJava 操作符,如前述代码。

❹ 成功完成令牌。

❺ 检查服务是否返回了非 200 状态码并完成。

❻ 由于其他错误而失败。

❼ 发送令牌或处理认证错误。

断路器执行一个操作,这里是对用户配置文件服务发起两个 HTTP 请求,然后生成 JWT 令牌。操作的结果是一个 JWT 令牌值的Single<String>。执行方法传递一个承诺给封装的代码,以便它可以通知操作是否成功。

handleAuthError方法必须修改如下,以检查任何错误的来源。

列表 12.15 处理认证错误

private void handleAuthError(RoutingContext ctx, Throwable err) {
  if (err instanceof OpenCircuitException) {                               ❶
    logger.error("Circuit breaker is open: {}", tokenCircuitBreaker.name());
    ctx.fail(504);
  } else if (err instanceof TimeoutException) {                            ❷
    logger.error("Circuit breaker timeout: {}", tokenCircuitBreaker.name());
    ctx.fail(504);
  } else {                                                                 ❸
    logger.error("Authentication error", err);
    ctx.fail(401);
  }
}

❶ 断路器处于开启状态。

❷ 操作超时。

❸ 定期认证错误

断路器会报告开路条件和操作超时,并使用专用异常。在这些情况下,我们报告 HTTP 500 状态码或经典的 401,以便请求者知道失败是否由于凭证错误。

这很好,但断路器对我们系统的实际影响是什么?让我们通过在 JWT 令牌生成上运行实验来查看。结果如图 12.11 所示。

图片

图 12.11 JTW 令牌负载测试,包括故障和断路器

断路器的影响是显著的:在故障期间,服务现在高度响应!在故障期间,我们获得了高吞吐量,因为当断路器打开时,服务现在会快速失败。有趣的是,我们可以看到当断路器处于半开状态时尝试发起请求:这些是定期出现的高延迟错误点。我们还可以看到,第 99.99 百分位数的延迟与之前的运行相比已经回到了较低的值。

这都很好,但关于获取用户总步骤数怎么办?

12.3.3 弹性和回退策略

断路器使 JWT 令牌生成即使在失败的情况下也能响应,因此端点现在是完全响应式的。然而,它并没有提供很多回退策略:如果我们无法与用户配置文件服务通信,我们就无法验证用户并生成 JWT 令牌。这就是为什么断路器总是报告错误。

我们可以在向活动服务发出请求时采用相同的策略,并简单地报告错误。然而,我们可以通过缓存数据和向请求者提供旧值来提供进一步的弹性。回退策略取决于功能需求:如果没有认证工作,我们无法生成 JWT 令牌,但如果我们有缓存中的旧步骤计数数据,我们当然可以提供一些。

我们将使用高效的内存中 Caffeine 缓存库 (github.com/ben-manes/caffeine)。这个库提供了管理缓存数据的可配置策略,包括计数、访问和基于时间的驱逐策略。我们可以在 Java HashMap 中缓存数据,但如果没有实施适当的驱逐策略,这会很快使我们面临内存耗尽问题。

下面的列表显示了如何创建最多包含 10,000 个条目的缓存,其中键是字符串,值是长整数。

列表 12.16 创建缓存

private Cache<String, Long> stepsCache = Caffeine.newBuilder()
  .maximumSize(10_000)                                          ❶
  .build();

❶ 缓存最多 10,000 个条目。

在下面的列表中,我们使用cacheTotalSteps方法向缓存中添加条目,当达到 10,000 个条目的限制时,Caffeine 会驱逐较旧的条目。

列表 12.17 缓存总步骤

private void cacheTotalSteps(String deviceId, HttpResponse<JsonObject> resp) {
  if (resp.statusCode() == 200) {
    stepsCache.put("total:" + deviceId, resp.body().getLong("count"));     ❶
  }
}

❶ 就像在常规 Java 映射中一样存储数据。

前面的方法用于下面的totalSteps方法,其中代码被断路器调用封装。

列表 12.18 使用断路器实现 totalSteps 方法

private void totalSteps(RoutingContext ctx) {
  String deviceId = ctx.user().principal().getString("deviceId");
  activityCircuitBreaker.<Void>executeWithFallback(promise -> {   ❶
    webClient
      .get(3001, "localhost", "/" + deviceId + "/total")
      .expect(ResponsePredicate.SC_OK)
      .as(BodyCodec.jsonObject())
      .rxSend()
      .subscribe(resp -> {
        cacheTotalSteps(deviceId, resp);                          ❷
        forwardJsonOrStatusCode(ctx, resp);
        promise.complete();
      }, err -> {
        tryToRecoverFromCache(ctx, deviceId);                     ❸
        promise.fail(err);
      });
  }, err -> {                                                     ❹
    tryToRecoverFromCache(ctx, deviceId);
    return null;
  });
}

❶ 执行的变体,带有一个回退

❷ 缓存总步骤。

❸ 尝试从缓存中恢复。

❹ 回退。

我们现在使用一个不返回任何值的断路器,因此使用Void参数类型。executeWithFallback方法允许我们在断路器打开时提供回退,这样我们就可以尝试从缓存中恢复一个值。这在上面的列表中的tryToRecoverFromCache方法中完成。

列表 12.19 缓存恢复的实现

private void tryToRecoverFromCache(RoutingContext ctx, String deviceId) {
  Long steps = stepsCache.getIfPresent("total:" + deviceId);
  if (steps == null) {                                        ❶
    logger.error("No cached data for the total steps of device {}", deviceId);
    ctx.fail(502);
  } else {                                                    ❷
    JsonObject payload = new JsonObject()
      .put("count", steps);
    ctx.response()
      .putHeader("Content-Type", "application/json")
      .end(payload.encode());
  }
}

❶ 将缓存数据作为成功响应发送。

❷ 发送错误,因为我们没有数据。

通过在tryToRecoverFromCache方法中从缓存中恢复,我们并不总是发送错误。如果我们有缓存中的数据,我们仍然可以提供响应,尽管可能是一个过时的值。

note 使用断路器回退直接在活动服务中缓存步骤计数和从旧值恢复也可以完成。

现在是时候检查服务在获取步骤计数时的行为。首先,让我们进行一次冷启动运行,其中数据库最初处于关闭状态,服务刚刚启动。图 12.12 显示了一分钟后的数据库启动后的两分钟运行。

图片

图 12.12 包含故障、断路器和冷启动的总步骤计数负载测试

服务立即开始出现一些错误,然后断路器打开,此时服务以非常低的延迟持续提供错误。请记住,服务还没有缓存任何数据。

当数据库启动时,我们可以看到延迟峰值,错误变为成功,然后服务能够正常响应。请注意,在前几秒钟的成功中,JVM 将开始优化与数据库通信的代码,因此吞吐量有所提高。

图片

图 12.13 包含故障和断路器的总步骤计数负载测试

图 12.13 显示了整个五分钟测试计划中的服务行为。由于测试计划从数据库正常运行开始,服务成功缓存了测试用户的数据。这就是为什么在整个运行过程中我们没有出现任何错误。当网络延迟出现时,我们看到了一些成功,但延迟较高,这实际上影响了 99.99 百分位以上的最后几个百分位。这些是由于断路器在发起 HTTP 请求时报告超时,但请注意,断路器不能取消HTTP 请求。因此,我们有一些 HTTP 请求正在等待一个无响应的活动服务,而此时断路器同时使用一些缓存数据完成相应的 HTTP 响应。

图 12.14 显示了将断路器与五秒超时结合使用对 Web 客户端 HTTP 请求的影响(请参阅 Git 仓库的 chapter12/public-api-with-circuit-breaker-and-timeouts 分支)。

图片

图 12.14 包含故障、超时和断路器的总步骤计数负载测试

这明显提高了结果,因为我们不再有大约 20 秒的最坏情况延迟。除此之外,延迟和吞吐量在整个运行过程中保持一致,并且几乎不受大约 4 分钟时数据库停止的影响。

备注:断路器是一个非常有用的工具,可以避免级联故障,但你不必将每个网络操作都包裹在断路器中。每个抽象都有成本,断路器确实增加了间接层次。相反,最好使用混沌测试并确定它们最有可能对整体系统行为产生积极影响的地方。

我们现在有一个反应式服务:它不仅资源高效且可扩展,而且对故障具有弹性。服务在所有情况下都能持续响应,延迟得到控制。

下一章和最后一章讨论在容器环境中运行 Vert.x 应用程序。

摘要

  • 一个反应式服务不仅仅是可扩展的;它必须具有弹性和响应性。

  • 压力测试和混沌测试工具是分析服务行为的关键,无论是处于正常条件下的操作,还是周围有它所依赖的网络和服务的故障。

  • 断路器是保护服务免受无响应服务和网络故障影响的最有效工具。

  • 一个具有弹性的服务不仅在能够快速通知错误时是响应的;它仍然可能能够成功响应,例如,如果应用程序领域允许,可以使用缓存数据。


  1. Gil Tene,“如何不测量延迟”,2015 年在 Strange Loop 会议上发表的演讲;www.youtube.com/watch?v=lJ8ydIuPFeU

13 最终笔记:容器原生 Vert.x

本章涵盖

  • 使用 Jib 高效构建容器镜像

  • 配置 Vert.x 集群以在 Kubernetes 集群中工作

  • 将 Vert.x 服务部署到 Kubernetes 集群

  • 使用 Skaffold 和 Minikube 进行本地开发

  • 公开健康检查和指标

到现在为止,您应该对反应式应用程序是什么以及 Vert.x 如何帮助您构建可扩展、资源高效和具有弹性的服务有一个坚实的理解。在本章中,我们将讨论与在 Kubernetes 集群容器环境中部署和运行 Vert.x 应用程序相关的一些主要问题。您将学习如何准备 Vert.x 服务以便在 Kubernetes 中良好运行,以及如何使用高效的工具打包容器镜像并在本地运行它们。您还将学习如何公开健康检查和指标,以更好地将服务集成到容器环境中。

考虑到本书的核心目标是教授您自我学习反应式概念和实践,本章是可选的。然而,Kubernetes 是一个流行的部署目标,学习如何使 Vert.x 应用程序成为此类环境中的首选公民是值得的。

在本章中,我将假设您对容器、Docker 和 Kubernetes 有基本的了解,这些内容在其他书籍中有深入介绍,例如 Marko Lukša 的 Kubernetes in Action 第二版(Manning,2020)和 Jeff Nickoloff 以及 Stephen Kuenzli 编著的 Docker in Action 第二版(Manning,2019)。如果您对这些问题了解不多,您仍然能够理解和运行本章中的示例,并且您将在学习过程中了解一些 Kubernetes 基础知识,但我不将花费时间解释 Kubernetes 的核心概念,如 podsservices,或描述 kubectl 命令行工具的细微差别。

工具版本

本章是在以下工具版本下编写和测试的:

  • Minikube 1.11.0

  • Skaffold 1.11.0

  • k9s 0.20.5(可选)

  • Dive 0.9.2(可选)

13.1 云中的热传感器

在本章的最后,我们将回到基于热传感器的用例,因为它将比处理 10k 步挑战应用程序更简单。

在这个场景中,热传感器会定期发布温度更新,可以使用 API 从所有传感器检索最新的温度,并且可以识别出温度异常的传感器。该应用程序基于三个微服务,您可以在源代码 Git 仓库中找到这些微服务,如图 13.1 所示。

这里是每个服务的作用:

  • heat-sensor-service--代表一个通过 Vert.x 事件总线发布温度更新的热传感器。它提供了一个 HTTP API 用于获取当前温度。

  • sensor-gateway--通过 Vert.x 事件总线收集所有热传感器服务的温度更新。它提供了一个 HTTP API 用于检索最新的温度值。

  • heat-api--一个用于检索最新温度值和检测温度不在预期范围内的传感器的 HTTP API。

热传感器服务需要扩展以模拟多个传感器,而传感器网关和 API 服务只需每个实例一个即可正常工作。换句话说,后两者不共享状态,因此如果工作负载需要,它们也可以扩展到多个实例。

图 13.1 用例概述

热 API 是唯一旨在集群外部暴露的服务。传感器网关是集群内部服务。热传感器服务应仅作为集群内部的实例部署,但它们不需要负载均衡器。Vert.x 集群管理器使用 Hazelcast。

让我们快速查看这些服务实现中值得注意的代码部分。

13.1.1 热传感器服务

热传感器服务基于本书早期章节中的代码,特别是第三章的内容。从scheduleNextUpdate方法中设置的定时器调用的update方法已更新如下。

列表 13.1 新的update方法

private void update(long tid) {
  temp = temp + (delta() / 10);
  vertx.eventBus().publish(targetDestination, makeJsonPayload());    ❶
  logger.info("{} new temperature is {}", id, temp);
  scheduleNextUpdate();
}

private JsonObject makeJsonPayload() {                               ❷
  return new JsonObject()
    .put("id", id)
    .put("timestamp", System.currentTimeMillis())
    .put("temp", temp);
}

❶ 发布到事件总线。

❷ 准备一个 JSON 温度更新有效负载。

我们仍然有相同的逻辑,并将 JSON 温度更新文档发布到事件总线。我们还引入了makeJsonPayload方法,因为它也用于 HTTP 端点,如下所示。

列表 13.2 通过 HTTP 获取热传感器数据

private void handleRequest(RoutingContext ctx) {
  ctx.response()
    .putHeader("Content-Type", "application/json")
    .end(makeJsonPayload().encode());                ❶
}

❶ 发送 JSON 数据

最后,我们在HeatSensor verticle 的start方法中从环境变量获取服务配置,如下所示。

列表 13.3 从环境变量获取传感器配置

Map<String, String> env = System.getenv();                                ❶
int httpPort = Integer.parseInt(env.getOrDefault("HTTP_PORT", "8080"));   ❷
targetDestination = env.getOrDefault("EB_UPDATE_DESTINATION", 
➥ "heatsensor.updates");                                                 ❸

❶ 访问环境变量。

❷ 获取 HTTP 端口。

❸ 获取事件总线目标。

环境变量很棒,因为当运行服务时很容易覆盖它们。由于它们以 Java Map的形式暴露,我们可以利用getOrDefault方法来设置默认值。

Vert.x 还提供了vertx-config模块(本书未涉及),如果您需要更高级的配置,如合并文件、环境变量和分布式注册表。您可以在 Vert.x 网站文档中了解更多信息(vertx.io/docs/)。然而,对于大多数情况,使用 Java System类解析几个环境变量要简单得多。

13.1.2 传感器网关

传感器网关通过 Vert.x 事件总线通信从热传感器服务收集温度更新。首先,它从环境变量中获取配置,如列表 13.3 所示,因为它需要一个 HTTP 端口号和一个事件总线目标来监听。start方法设置了一个事件总线消费者,如下所示。

列表 13.4 网关事件总线消费者

vertx.eventBus().<JsonObject>consumer(targetDestination, message -> {   ❶
  JsonObject json = message.body();
  String id = json.getString("id");
  data.put(id, json);                                                   ❷
  logger.info("Received an update from sensor {}", id);
});

❶ 注册一个处理程序。

❷ 将其放入映射中。

每个传入的 JSON 更新都放入一个data字段中,该字段是一个HashMap<String, JsonObject>,用于存储每个传感器的最后更新。

HTTP API 通过/data端点暴露收集到的传感器数据,该端点由以下代码处理。

列表 13.5 网关数据请求 HTTP 处理器

private void handleRequest(RoutingContext ctx) {
  JsonArray entries = new JsonArray();
  for (String key : data.keySet()) {                            ❶
    entries.add(data.get(key));
  }
  JsonObject payload = new JsonObject().put("data", entries);   ❷
  ctx.response()
    .putHeader("Content-Type", "application/json")
    .end(payload.encode());
}

❶ 在 JSON 数组中收集条目。

❷ 将数组放入 JSON 文档并发送。

此方法通过将所有收集到的数据组装成一个数组来准备 JSON 响应,然后将其包装在 JSON 文档中。

13.1.3 Heat API

此服务提供所有传感器数据,或者仅提供温度超出预期正确值范围的服务数据。为此,它向传感器网关发送 HTTP 请求。

配置再次通过环境变量提供,如下所示。

列表 13.6 Heat API 配置环境变量

Map<String, String> env = System.getenv();
int httpPort = Integer.parseInt(env.getOrDefault("HTTP_PORT", "8080"));
String gatewayHost = env.getOrDefault("GATEWAY_HOST", "sensor-gateway");     ❶
int gatewayPort = Integer.parseInt(env.getOrDefault("GATEWAY_PORT", "8080"));❷
lowLimit = Double.parseDouble(env.getOrDefault("LOW_TEMP", "10.0"));         ❸
highLimit = Double.parseDouble(env.getOrDefault("HIGH_TEMP", "30.0"));       ❹

❶ 传感器网关地址

❷ 传感器网关端口号

❸ 正确的温度下限

❹ 正确的温度上限

服务使用环境变量解析传感器网关地址以及正确的温度范围。正如你稍后将会看到的,我们可以在将服务部署到集群时覆盖这些值。

start方法配置 Web 客户端向传感器网关发送 HTTP 请求,并使用 Vert.x Web 路由器暴露 API 端点。

列表 13.7 Heat API Web 客户端和路由

webClient = WebClient.create(vertx, new WebClientOptions()
  .setDefaultHost(gatewayHost)                              ❶
  .setDefaultPort(gatewayPort));

Router router = Router.router(vertx);                       ❷
router.get("/all").handler(this::fetchAllData);
router.get("/warnings").handler(this::sensorsOverLimits);

❶ 预绑定 Web 客户端主机和端口以发送请求。

❷ 暴露 API 端点的路由器

数据通过 HTTP GET请求从传感器网关获取,如下所示。

列表 13.8 获取传感器数据

private void fetchData(RoutingContext routingContext, 
➥ Consumer<HttpResponse<JsonObject>> action) {
  webClient.get("/data")                                    ❶
    .as(BodyCodec.jsonObject())
    .expect(ResponsePredicate.SC_OK)
    .timeout(5000)
    .send(ar -> {
      if (ar.succeeded()) {
        action.accept(ar.result());                         ❷
      } else {
        routingContext.fail(500);                           ❸
        logger.error("Could not fetch data", ar.cause());
      }
    });
}

❶ 向/data 发送请求。

❷ 调用动作处理器。

❸ 处理错误。

fetchData方法通用,第二个参数提供了一个自定义动作,因此我们暴露的两个 HTTP 端点可以重用请求逻辑。

fetchAllData方法的实现如下所示。

列表 13.9 获取所有传感器数据

private void fetchAllData(RoutingContext routingContext) {
  fetchData(routingContext, resp -> {
    routingContext.response()
      .putHeader("Content-Type", "application/json")
      .end(resp.body().encode());
  });
}

此方法除了完成带有 JSON 数据的 HTTP 请求外,没有做任何特别的事情。

下面的sensorsOverLimits方法更有趣,因为它会过滤数据。

列表 13.10 过滤超出范围的传感器数据

private void sensorsOverLimits(RoutingContext routingContext) {
  Predicate<JsonObject> abnormalValue = json -> {
    Double temperature = json.getDouble("temp");
    return (temperature <= lowLimit) || (highLimit <= temperature);
  };
  fetchData(routingContext, resp -> {
    JsonObject data = resp.body();
    JsonArray warnings = new JsonArray();      ❶
    data.getJsonArray("data").stream()         ❷
      .map(JsonObject.class::cast)             ❸
      .filter(abnormalValue)                   ❹
      .forEach(warnings::add);                 ❺
    data.put("data", warnings);                ❻
    routingContext.response()
      .putHeader("Content-Type", "application/json")
      .end(data.encode());
  });
}

❶ 用于收集超出限制数据的数组

❷ 使用 Java 流过滤条目。

❸ 从 Object 转换为 JsonObject。

❹ 根据温度值进行过滤。

❺ 将其添加到数组中。

❻ 组装最终的 JSON 响应。

sensorsOverLimits方法仅保留温度不在预期范围内的条目。为此,我们采用功能处理方法,使用 Java 集合流,然后返回响应。请注意,如果所有传感器值都正确,响应 JSON 文档中的data数组可能为空。

现在你已经看到了三个服务实现中的主要有趣点,我们可以继续讨论如何在 Kubernetes 集群中实际部署它们。

13.1.4 在本地集群中部署

运行本地 Kubernetes 集群有许多方法。Docker Desktop 内嵌 Kubernetes,因此如果您已经在您的机器上运行它,可能只需要它来运行 Kubernetes。

Minikube 是 Kubernetes 项目提供的另一个可靠选项 (minikube.sigs.k8s.io/docs/)。它在 Windows、macOS 或 Linux 上部署一个小型虚拟机,这使得它非常适合创建用于开发的可丢弃集群。如果发生任何问题,您可以轻松地销毁集群并重新开始。

Minikube 的另一个好处是它为 Docker 守护进程提供了环境变量,因此您可以将本地构建的容器镜像直接放在集群内部。在其他 Kubernetes 配置中,您需要将镜像推送到私有或公共注册表,这可能会减慢开发反馈循环,尤其是在通过慢速互联网连接将几百兆字节推送到公共注册表时。

我假设您将在这里使用 Minikube,但请随意使用任何其他选项。

提示:如果您之前从未使用过 Kubernetes,欢迎加入!尽管通过阅读本节您不会成为 Kubernetes 专家,但运行命令应该仍然能给您一个大致的了解。一旦超越庞大的生态系统和术语,Kubernetes 的主要概念相当简单。

下面的列表显示了如何创建具有四个 CPU 和 8 GB 内存集群的方法。

列表 13.11 创建 Minikube 集群

$ minikube start --cpus=4 --memory=8G --addons ingress       ❶
  minikube v1.9.2 on Darwin 10.15.4
  MINIKUBE_ACTIVE_DOCKERD=minikube
  Automatically selected the hyperkit driver. Other choices: 
  ➥ docker, virtualbox
  Starting control plane node m01 in cluster minikube
  Creating hyperkit VM (CPUs=4, Memory=8192MB, Disk=20000MB) ...
  Preparing Kubernetes v1.14.0 on Docker 19.03.8 ...
  Enabling addons: default-storageclass, ingress, storage-provisioner
  Done! kubectl is now configured to use "minikube"

❶ 启用 ingress 扩展。

标志和输出将根据您的操作系统和软件版本而有所不同。您可能需要通过查看当前的 Minikube 文档(在您阅读此章节时可能已更新)来调整这些设置。我分配了四个 CPU 和 8 GB 的内存,因为在我的笔记本电脑上这很舒适,但您可能只需要一个 CPU 和更少的 RAM。

您可以通过运行 minikube dashboard 命令来访问一个 Web 仪表板。使用 Minikube 仪表板,您可以查看各种 Kubernetes 资源,甚至执行一些(有限的)操作,例如调整服务的上下文或查看日志。

还有另一个我发现特别高效且可以推荐的仪表板:K9s (k9scli.io)。它作为一个命令行工具,可以非常快速地在 Kubernetes 资源之间切换,访问 pod 日志,更新副本数量等等。

Kubernetes 有一个名为 kubectl 的命令行工具,您可以使用它执行任何操作:部署服务、收集日志、配置 DNS 等。kubectl 是 Kubernetes 的瑞士军刀。我们可以使用 kubectl 应用每个服务 k8s/ 文件夹中找到的 Kubernetes 资源定义。我将在稍后描述 k8s/ 文件夹中的资源。如果您是 Kubernetes 新手,您现在需要了解的是,这些文件告诉 Kubernetes 如何部署本章中的三个服务。

有一个更好的工具可以改善您的本地 Kubernetes 开发体验,名为 Skaffold (skaffold.dev)。与使用 Gradle(或 Maven)构建服务和打包它们,然后使用kubectl部署到 Kubernetes 相比,Skaffold 能够为我们完成所有这些工作,避免了不必要的构建使用缓存,执行部署,汇总所有日志,并在退出时清理一切。

您首先需要在您的机器上下载并安装 Skaffold。Skaffold 与 Minikube 无缝工作,因此不需要任何额外的配置。它只需要一个 skaffold.yaml 资源描述符,如下所示(并在 Git 仓库的第十三章文件夹的根目录中包含)。

列表 13.12 Skaffold 配置

apiVersion: skaffold/v1
kind: Config
metadata:
  name: chapter13
build:
  artifacts:
    - image: vertx-in-action/heat-sensor-service     ❶
      jib:
        type: gradle
        project: heat-sensor-service                 ❷
      context: .
    - image: vertx-in-action/sensor-gateway
      jib:
        type: gradle
        project: sensor-gateway
      context: .
    - image: vertx-in-action/heat-api
      jib:
        type: gradle
        project: heat-api
      context: .
deploy:
  kubectl:
    manifests:
      - "**/k8s/*.yaml"                              ❸

❶ 要生成的容器镜像的名称

❷ 包含源代码的项目

❸ 还应用 YAML 文件。

从第十三章文件夹中,您可以运行 skaffold dev,它将构建项目、部署容器镜像、公开日志以及监视文件更改。图 13.2 显示了 Skaffold 运行的截图。

图片

图 13.2 Skaffold 运行服务的截图

恭喜,您现在已经在您的(本地)集群中运行了服务!

您不必使用 Skaffold,但为了获得良好的本地开发体验,这是一个您可以信赖的工具。它隐藏了一些kubectl命令行界面的复杂性,并且它弥合了项目构建工具(如 Gradle 或 Maven)与 Kubernetes 环境之间的差距。

以下列表显示了一些检查集群中部署的服务的命令。

列表 13.13 检查公开的服务

$ minikube tunnel                            ❶
$ kubectl get services                       ❷
NAME                 TYPE          CLUSTER-IP    EXTERNAL-IP   PORT(S)           AGE
heat-api             LoadBalancer  10.103.127.60 10.103.127.60 8080:31673/TCP    102s
heat-sensor-service  ClusterIP     None          <none>        8080/TCP,5701/TCP 102s
kubernetes           ClusterIP     10.96.0.1     <none>        443/TCP            42m
sensor-gateway       ClusterIP     10.108.31.235 <none>        8080/TCP,5701/TCP 102s

❶ 网络隧道,在单独的终端中运行

❷ 获取服务。

minikube tunnel 命令对于访问 LoadBalancer 服务非常重要,并且应该在单独的终端中运行。请注意,它可能需要您输入密码,因为该命令需要调整您当前的网络设置。

您还可以使用以下 Minikube 命令来获取LoadBalancer服务的 URL,而无需minikube tunnel

$ minikube service heat-api --url
http://192.168.64.12:31673

提示:服务的 IP 地址在您的机器上可能会有所不同。随着您删除和创建新的服务,它们也会发生变化,所以不要对 Kubernetes 中的 IP 地址做出任何假设。

这之所以有效,是因为 Minikube 也将LoadBalancer服务作为NodePort暴露在 Minikube 实例 IP 地址上。当使用 Minikube 时,这两种方法都是等效的,但使用minikube tunnel的方法更接近您在生产集群中会得到的结果,因为服务是通过集群外部 IP 地址访问的。

现在您有了访问 heat API 服务的方法,您可以发出一些请求。

列表 13.14 与 heat API 服务交互

$ http 10.103.127.60:8080/all           ❶
HTTP/1.1 200 OK
Content-Type: application/json
content-length: 402

<JSON DATA>

$ http 10.103.127.60:8080/warnings      ❷
HTTP/1.1 200 OK
Content-Type: application/json
content-length: 11

<JSON DATA>

❶ 获取所有数据。

❷ 获取超出范围的传感器数据。

您还可以使用端口转发来访问传感器网关,如下所示。

列表 13.15 与传感器网关交互

$ kubectl port-forward services/sensor-gateway 8080     ❶
$ http :8080/data                                       ❷
HTTP/1.1 200 OK
Content-Type: application/json
content-length: 400

<JSON data>

❶ 从服务到本地端口的端口转发(在单独的终端中运行)。

❷ 调用服务。

必须在另一个终端中运行 kubectl port-forward 命令,并且只要它在运行,本地端口 8080 就会转发到集群内部的服务网关。这对于访问集群中运行的所有内容来说非常方便,而无需将其暴露为 LoadBalancer 服务。

最后,我们可以进行 DNS 查询以查看热传感器无头服务是如何解析的。以下列表使用了一个包含 dig 工具的第三方镜像,该工具可以用来发送 DNS 请求。

列表 13.16 DNS 查询以发现无头热传感器服务

$ kubectl run --image tutum/dnsutils dns -it --rm -- bash               ❶
root@dns:/# dig +short heat-sensor-service.default.svc.cluster.local    ❷
172.17.0.8
172.17.0.12
172.17.0.11
172.17.0.9
root@dns:/#

❶ 安装了 dig 的镜像

❷ 运行一个 DNS 查询。

现在如果我们增加副本的数量,如以下列表所示,我们可以看到 DNS 反映了这一变化。

列表 13.17 增加热传感器服务副本的数量

$ kubectl scale deployment/heat-sensor-service --replicas 5     ❶
$ kubectl run --image tutum/dnsutils dns -it --rm -- bash
root@dns:/# dig +short heat-sensor-service.default.svc.cluster.local
172.17.0.11
172.17.0.12
172.17.0.8
172.17.0.13
172.17.0.9
root@dns:/#

❶ 扩展到五个副本。

此外,如果我们像在列表 13.14 中那样发送 HTTP 请求,我们可以看到有来自五个传感器的数据。

现在我们已经部署了服务并与它们进行了交互,让我们看看 Kubernetes 中 Vert.x 服务的部署是如何工作的。

13.2 在 Kubernetes 中使服务工作

在 Kubernetes 中使服务 工作 对于大多数情况来说相当透明,特别是当它被设计为对目标运行时环境无感知时。无论它是在容器中、虚拟机中还是在裸机上运行,都不应该成为问题。尽管如此,由于 Kubernetes 的工作方式,仍有一些方面需要适应和配置。

在我们的情况下,唯一需要进行的重大适应是配置集群管理器,以便实例可以自我发现,并且可以在分布式事件总线之间发送消息。其余的只是构建服务的容器镜像并将 Kubernetes 资源描述符写入以部署服务的问题。

让我们从讨论构建容器镜像开始。

13.2.1 构建容器镜像

构建容器镜像有很多方法,技术上基于 OCI Image Format (OCIIF; github.com/opencontainers/image-spec)。构建此类镜像的最基本方法是编写一个 Dockerfile 并使用 docker build 命令来构建镜像。请注意,Dockerfile 描述符可以被其他工具如 Podman (podman.io/) 或 Buildah (github.com/containers/buildah) 使用,因此实际上您不需要 Docker 来构建容器镜像。

因此,你可以选择一个带有 Java 的基础镜像,然后复制一个自包含的可执行 Jar 文件以运行。虽然这种方法简单且效果良好,但它意味着每次源代码发生变化时,你都需要构建一个新的镜像层,其大小与包含所有依赖项(如 Vert.x、Netty 等)的 Jar 文件大小相当。一个服务的编译类通常只有几千字节,而一个自包含的 Jar 文件则有几兆字节。

或者,你可以创建一个包含多个阶段和层的 Dockerfile,或者使用 Jib 这样的工具自动为你完成等效操作(github.com/GoogleContainerTools/jib)。如图 13.3 所示,Jib 将不同的层组装成一个容器镜像。

项目依赖项放置在基础镜像之上;它们通常比应用程序代码和资源大,而且它们也不太经常改变,除非升级版本或添加新的依赖项。当一个项目有快照依赖项时,它们会作为固定版本依赖项之上的一个层出现,因为新的快照频繁出现。资源和类文件更改得更频繁,它们在磁盘上的使用通常较少,因此它们最终位于顶部。这种巧妙的分层方法不仅节省了磁盘空间,还提高了构建时间,因为层通常可以重用。

图 13.3 使用 Jib 的容器镜像层

Jib 提供了 Maven 和 Gradle 插件,并通过从项目中提取信息来构建容器镜像。Jib 还很棒,因为它完全用 Java 编写,并且不需要 Docker 来构建镜像,因此你可以不使用任何第三方工具来生成容器镜像。它还可以将容器镜像发布到注册表和 Docker 守护程序,这在开发中非常有用。

一旦应用了 Jib 插件,你只需要几个配置元素,如下所示为 Gradle 构建示例(Maven 版本类似,但使用 XML 实现)。

列表 13.18 配置 Jib Gradle 插件

jib {
  from {
    image = "adoptopenjdk/openjdk11:ubi-minimal-jre"     ❶
  }
  to {
    image = "vertx-in-action/heat-sensor"                ❷
    tags = setOf("v1", "latest")                         ❸
  }
  container {
    mainClass = "chapter13.sensor.HeatSensor"            ❹
    jvmFlags = listOf("-noverify", 
    ➥ "-Djava.security.egd=file:/dev/./urandom")        ❺
    ports = listOf("8080", "5701")                       ❻
    user = "nobody:nobody"                               ❼
  }
}

❶ 基础镜像

❷ 图片名称

❸ 镜像标签

❹ 要运行的主类

❺ JVM 调优标志

❻ 容器需要暴露的端口

❽ 以此用户身份运行。

基础镜像来自 AdoptOpenJDK 项目,该项目发布了多个 OpenJDK 构建(adoptopenjdk.net)。在这里,我们使用 OpenJDK 11 作为 Java 运行时环境(JRE)而不是完整的 Java 开发工具包(JDK)。这节省了磁盘空间,因为我们只需要运行时,而 JDK 镜像比 JRE 镜像大。ubi-minimal 部分是因为我们使用基于 Red Hat Universal Base Image 的 AdoptOpenJDK 构建变体,其中“minimal”变体最小化了嵌入的依赖项。

Jib 需要知道要执行的主类以及要暴露在容器外的端口。在热量传感器和传感器网关服务的情况下,我们需要暴露端口 8080 以供 HTTP 服务使用,以及端口 5701 以供与 Hazelcast 的 Vert.x 聚合。JVM 调优仅限于禁用 JVM 字节码验证器,以便略微加快启动速度,并使用 /dev/urandom 进行随机数生成(默认的 /dev/random 伪文件可能在容器启动时由于熵不足而阻塞)。最后,我们以用户 nobody 和组 nobody 运行,以确保进程在容器内以非特权用户身份运行。

你可以构建一个镜像并按以下所示进行检查。

列表 13.19 将服务容器镜像构建到 Docker 守护进程

$ ./gradlew :heat-sensor-service:jibDockerBuild               ❶
(...)

$ docker image inspect vertx-in-action/heat-sensor:latest     ❷
(...)

❶ 为热量传感器服务构建一个容器镜像并将其推送到本地 Docker 守护进程。

❷ 检查容器镜像。

所有三种服务的容器镜像都是按照相同的方式构建的。唯一的配置差异是热量 API 服务只暴露端口 8080,因为它不需要集群管理器。

提示:如果你对生成容器镜像的三种服务的不同层的内容感兴趣,可以使用 Dive(github.com/wagoodman/dive)这样的工具。

说到聚类,还有配置工作要做!

13.2.2 聚类和 Kubernetes

在第三章中使用的 Hazelcast 和 Infinispan 默认使用多播通信来发现节点。这对于本地测试和许多裸机服务器部署来说很棒,但在 Kubernetes 集群中无法使用多播通信。如果你在 Kubernetes 上以原样运行容器,热量传感器服务和传感器网关实例将无法通过事件总线进行通信。

当然,这些集群管理器可以被配置为在 Kubernetes 中执行服务发现。我们将简要介绍 Hazelcast 的情况,其中有两种可能的发现模式:

  • Hazelcast 可以连接到 Kubernetes API,以监听和发现与请求匹配的 pod,例如所需的标签和值。

  • Hazelcast 可以定期进行 DNS 查询以发现给定 Kubernetes(无头)服务的所有 pod。

DNS 方法更为有限。

相反,让我们使用 Kubernetes API 并配置 Hazelcast 使用它。默认情况下,Hazelcast Vert.x 集群管理器从 cluster.xml 资源中读取配置。以下列表显示了 heat-sensor-service/src/main/resource/cluster.xml 文件的相关配置摘录。

列表 13.20 Hazelcast 发现的 Kubernetes 配置

(...)
<join>
  <multicast enabled="false"/>                                              ❶
  <tcp-ip enabled="false" />
  <discovery-strategies>
    <discovery-strategy enabled="true"
     class="com.hazelcast.kubernetes.HazelcastKubernetesDiscoveryStrategy"> ❷
      <properties>
        <property name="service-label-name">vertx-in-action</property>      ❸
        <property name="service-label-value">chapter13</property>           ❹
      </properties>
    </discovery-strategy>
  </discovery-strategies>
</join>
(...)

❶ 禁用多播通信。

❷ 启用 Kubernetes 发现策略。

❸ 将服务与标签 vertx-in-action 匹配。

❹ 将服务与标签 vertx-in-action 的值 chapter13 匹配。

我们禁用了默认的发现机制,并启用了 Kubernetes 的发现机制。在这里,Hazelcast 形成了属于具有 vertx-in-action 标签且值为 chapter13 的服务的 pod 集群。由于我们打开了端口 5701,pod 将能够连接。请注意,配置对于传感器网关是相同的。

由于 Hazelcast 需要从 Kubernetes API 中读取,我们需要确保我们有使用 Kubernetes 基于角色的访问控制 (RBAC) 的权限。为此,我们需要应用以下列表中的 ClusterRoleBinding 资源和 k8s/rbac.yaml 文件。

列表 13.21 RBAC 授予 Kubernetes API 查看访问权限

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding                   ❶
metadata:
  name: default-cluster
roleRef:
  apiGroup: rbac.authorization.k8s.io      ❷
  kind: ClusterRole
  name: view
subjects:
  - kind: ServiceAccount
    name: default
    namespace: default

❶ 资源类型

❷ 查看角色参考

我们最后需要确保热传感器和网关服务在启用集群的情况下运行。在这两种情况下,代码是相似的。以下列表显示了热传感器服务的 main 方法。

列表 13.22 启用热传感器服务的集群功能

public static void main(String[] args) throws UnknownHostException {
  String ipv4 = InetAddress.getLocalHost().getHostAddress();         ❶
  VertxOptions options = new VertxOptions()
    .setEventBusOptions(new EventBusOptions()                        ❷
      .setHost(ipv4)                                                 ❸
      .setClusterPublicHost(ipv4));                                  ❹
  Vertx.clusteredVertx(options, ar -> {                              ❺
    if (ar.succeeded()) {
      ar.result().deployVerticle(new HeatSensor());
    } else {
      logger.error("Could not start", ar.cause());
    }
  });
}

❶ 获取主机的 IPv4 地址。

❷ 自定义事件总线选项。

❸ 设置主机地址。

❹ 设置其他节点需要通信的主机。

❺ 以集群模式启动 Vert.x。

我们启动一个集群化的 Vert.x 上下文,并传递选项来自定义事件总线配置。在大多数情况下,你在这里不需要进行任何额外的调整,但在 Kubernetes 的上下文中,集群可能会解析为 localhost 而不是实际的 IPv4 地址。这就是为什么我们首先解析 IPv4 地址,然后将事件总线配置的主机设置为该地址,这样其他节点就可以与之通信。

提示:列表 13.22 中执行的事件总线网络配置将在未来的 Vert.x 版本中自动完成。我在这里展示它是因为它可以帮助你在 Kubernetes 之外的环境中调试分布式事件总线配置问题。

13.2.3 Kubernetes 部署和服务资源

现在你已经知道如何将你的服务放入容器中,以及如何确保 Vert.x 集群在 Kubernetes 中工作,我们需要讨论资源描述符。实际上,Kubernetes 需要一些描述符来部署容器镜像到 pod 中并公开服务。

让我们从热传感器服务的 部署描述符 开始,如下所示。

列表 13.23 热传感器服务部署描述符

apiVersion: apps/v1
kind: Deployment                                                ❶
metadata:
  labels:
    app: heat-sensor-service
  name: heat-sensor-service                                     ❷
spec:
  selector:
    matchLabels:
      app: heat-sensor-service
  replicas: 4                                                   ❸
  strategy:
    type: RollingUpdate
    rollingUpdate:                                              ❹
      maxSurge: 1
      maxUnavailable: 1
  template:
    metadata:
      labels:
        app: heat-sensor-service
    spec:
      containers:
        - image: vertx-in-action/heat-sensor-service:latest     ❺
          name: heat-sensor-service

❶ 这是一个部署资源。

❷ 部署的名称

❸ 默认部署四个实例。

❹ Hazelcast 的滚动更新配置

❺ 部署的容器镜像

此部署描述符默认部署了四个 vertx-in-action/ heat-sensor-service 容器镜像的 pod。部署 pod 是一个好的第一步,但我们还需要一个 服务定义 来映射这些 pod。这对于 Hazelcast 尤为重要:记住,这些实例通过具有标签 vertx-in-action 且值为 chapter13 的 Kubernetes 服务来自我发现。

当部署通过逐步替换旧配置的 pod 为新配置的 pod 来更新时,Kubernetes 执行 滚动更新。最好将 maxSurgemaxUnavailable 的值设置为 1。这样做时,Kubernetes 会依次替换 pod,因此集群状态会平稳地转移到新 pod。您可以避免此配置,并让 Kubernetes 在滚动更新时更加激进,但集群状态可能在一段时间内不一致。

以下列表显示了 服务资源定义

列表 13.24 热传感器服务定义

apiVersion: v1
kind: Service
metadata:
  labels:
    app: heat-sensor-service
    vertx-in-action: chapter13     ❶
  name: heat-sensor-service
spec:
  clusterIP: None                  ❷
  selector:
    app: heat-sensor-service       ❸
  ports:                           ❹
    - name: http
      port: 8080
    - name: hazelcast
      port: 5701

❶ 用于 Hazelcast 发现的标签

❷ 我们想要一个“无头”服务。

❸ 匹配具有此标签/值对的 pod

❹ 要公开的端口

服务描述符公开了一个 无头 服务,这意味着在 pod 之间没有负载均衡。因为每个服务都是一个传感器,它们不能互相替代。无头服务可以通过返回所有 pod 列表的 DNS 查询来发现。您在列表 13.16 中看到了如何使用 DNS 查询来发现无头服务。

传感器网关的部署描述符几乎与热传感器服务的描述符相同,您可以在下面的列表中看到。

列表 13.25 传感器网关部署描述符

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: sensor-gateway
  name: sensor-gateway
spec:
  selector:
    matchLabels:
      app: sensor-gateway
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  template:
    metadata:
      labels:
        app: sensor-gateway
    spec:
      containers:
        - image: vertx-in-action/sensor-gateway:latest        ❶
          name: sensor-gateway

❶ 容器镜像

除了名称外,您还可以注意到我们没有指定副本计数,默认值为 1。服务定义如下所示。

列表 13.26 传感器网关服务定义

apiVersion: v1
kind: Service
metadata:
  labels:
    app: sensor-gateway
    vertx-in-action: chapter13
  name: sensor-gateway
spec:
  type: ClusterIP          ❶
  selector:
    app: sensor-gateway
  ports:
    - name: http
      port: 8080
    - name: hazelcast
      port: 5701

❶ 集群内部负载均衡

现在我们公开了一个进行负载均衡的服务。如果我们启动更多的 pod,流量将在它们之间进行负载均衡。ClusterIP 服务进行负载均衡,但它不会暴露在集群外部。

热 API 部署与我们已经完成的部署非常相似,除了有配置要传递环境变量。以下列表显示了 spec.template.spec.containers 部分描述符中的有趣部分。

列表 13.27 热 API 部署摘录

spec:
  containers:
    - image: vertx-in-action/heat-api:latest
      name: heat-api
      env:                                   ❶
        - name: LOW_TEMP                     ❷
          value: "12.0"
        - name: HIGH_TEMP
          value: "32.0"
        - name: GATEWAY_HOST
          valueFrom:
            configMapKeyRef:                 ❸
              name: sensor-gateway-config
              key: gateway_hostname
        - name: GATEWAY_PORT
          valueFrom:
            configMapKeyRef:
              name: sensor-gateway-config
              key: gateway_port

❶ 定义环境变量。

❷ 覆盖 LOW_TEMP 环境值。

❸ 从 ConfigMap 资源中获取值。

环境变量可以直接通过值传递,例如 LOW_TEMP,或者通过 ConfigMap 资源的中介传递,如下所示。

列表 13.28 配置映射示例

apiVersion: v1
kind: ConfigMap
metadata:
  name: sensor-gateway-config                                   ❶
data:
  gateway_hostname: sensor-gateway.default.svc.cluster.local    ❷
  gateway_port: "8080"

ConfigMap 资源名称

gateway_hostname 键的值

通过通过 ConfigMap 传递环境变量,我们可以更改配置,而无需更新 heat API 部署描述符。注意 gateway _hostname 的值:这是在 Kubernetes 集群内部使用 DNS 解析服务时使用的名称。在这里 default 是 Kubernetes 命名空间,svc 指定一个服务资源,而 cluster.local 解析为 cluster.local 域名(记住我们正在使用本地开发集群)。

最后,以下列表显示了如何将热传感器 API 作为外部负载均衡服务暴露出来。

列表 13.29 热 API 服务定义

apiVersion: v1
kind: Service
metadata:
  labels:
    app: heat-api
  name: heat-api
spec:
  type: LoadBalancer       ❶
  selector:
    app: heat-api
  ports:
    - name: http
      port: 8080

❶ 外部负载均衡。

LoadBalancer服务在集群外部暴露。它也可以通过Ingress映射到主机名,但这不是我们将要涵盖的内容。1

我们现在已经涵盖了将服务部署到 Kubernetes 的过程,所以你可能认为我们已经完成了。当然,服务在 Kubernetes 中运行得很好,但我们可以通过做两件事来使集成更好!

13.3 Kubernetes 的一等公民

正如你所见,我们部署的服务在 Kubernetes 中运行良好。但话虽如此,我们可以通过做两件事来使它们成为 Kubernetes 的一等公民:

  • 暴露健康和就绪性检查

  • 暴露指标

这很重要,以确保集群了解服务的行为,以便它可以重启服务或进行扩展和缩减。

13.3.1 健康检查

当 Kubernetes 启动一个 Pod 时,它假设它可以在公开的端口上处理请求,并且只要进程在运行,应用程序就运行良好。如果进程崩溃,Kubernetes 会重启其 Pod。此外,如果进程消耗了过多的内存,Kubernetes 会将其杀死并重启其 Pod。

我们可以通过让进程通知 Kubernetes 其状态来做得更好。健康检查中有两个重要概念:

  • 活跃性检查 允许一个服务报告它是否正在正确工作,或者它是否失败并需要重启。

  • 就绪性检查 允许一个服务报告它已准备好接受流量。

活跃性检查很重要,因为一个进程可能正在运行,但可能卡在致命错误中,或者卡在,比如说,一个无限循环中。活跃性探测可以基于文件、TCP 端口和 HTTP 端点。当探测失败超过阈值时,Kubernetes 会重启 Pod。

热传感器服务和传感器网关可以使用 HTTP 提供简单的健康检查报告。只要 HTTP 端点在响应,就意味着服务正在运行。以下列表显示了如何向这些服务添加健康检查功能。

列表 13.30 简单 HTTP 健康检查探测

// In the verticle start method:
router.get("/health").handler(this::healthCheck);                          ❶

// (...)
private final JsonObject okStatus = new JsonObject().put("status", "UP");  ❷

private void healthCheck(RoutingContext ctx) {                             ❸
  logger.info("Health check");
  ctx.response()
    .putHeader("Content-Type", "application/json")
    .end(okStatus.encode());
}

❶ 为健康检查添加路由。

❷ JSON 有效负载表示服务正在运行

❸ 用于健康检查的 Vert.x Web 处理器

使用 HTTP 探测,Kubernetes 对响应的 HTTP 状态码感兴趣:200 表示检查成功,其他任何东西都表示存在问题。返回包含status字段和值UPDOWN的 JSON 文档是一种松散的约定。文档中还可以包含其他数据,例如来自各种检查的消息。这些数据在用于诊断目的时最有用。

然后,我们必须让 Kubernetes 了解探测,如下列所示。

列表 13.31 热传感器服务活跃性探测

# (...)
spec:
  containers:
    - image: vertx-in-action/heat-sensor-service:latest
      name: heat-sensor-service
      livenessProbe:                  ❶
        httpGet:                      ❷
          path: /health
          port: 8080
        initialDelaySeconds: 15       ❸
        periodSeconds: 15             ❹
        timeoutSeconds: 5             ❺

❶ 定义活跃性探测。

❷ 指定 HTTP 端点。

❸ 在进行检查之前的初始延迟

❹ 检查之间的间隔

❺ 检查超时

在这里,活性和就绪性检查在 15 秒后开始,每 15 秒发生一次,并在 5 秒后超时。我们可以通过查看热传感器服务 Pod 的日志来检查这一点。

列表 13.32 日志中的健康检查

$ kubectl logs -f heat-sensor-service-6944f78b84-2tpnx | grep 
➥ 'Health check'                                               ❶
2020-05-02 17:27:54,218 INFO [vert.x-eventloop-thread-1] 
➥ chapter13.sensor.HeatSensor - Health check
2020-05-02 17:28:09,182 INFO [vert.x-eventloop-thread-1] 
➥ chapter13.sensor.HeatSensor - Health check
2020-05-02 17:28:24,181 INFO [vert.x-eventloop-thread-1] 
➥ chapter13.sensor.HeatSensor - Health check
2020-05-02 17:28:39,182 INFO [vert.x-eventloop-thread-1] 
➥ chapter13.sensor.HeatSensor - Health check

❶ Pod 名称在您的机器上可能会有所不同。

要获取 Pod 名称并检查日志,您可以查看 kubectl logs 的输出。在这里我们看到检查确实每 15 秒发生一次。

热 API 的情况更有趣,因为我们可以定义活性和就绪性检查。API 需要传感器网关,因此它的就绪性取决于网关的就绪性。首先,我们必须为活性和就绪性检查定义两个路由,如下一个列表所示。

列表 13.33 热 API 服务的健康检查路由

router.get("/health/ready").handler(this::readinessCheck);     ❶
router.get("/health/live").handler(this::livenessCheck);       ❷

❶ 就绪性检查

❷ 活性检查

livenessCheck 方法的实现与列表 13.30 相同:如果服务响应,则表示它处于活动状态。没有条件会导致服务响应但需要重启。然而,如果传感器网关不可用,服务可能无法接受流量,这将在以下就绪性检查中报告。

列表 13.34 热 API 服务的就绪性检查

private void readinessCheck(RoutingContext ctx) {
  webClient.get("/health")                                       ❶
    .expect(ResponsePredicate.SC_OK)
    .timeout(5000)
    .send(ar -> {
      if (ar.succeeded()) {
        logger.info("Readiness check complete");
        ctx.response().setStatusCode(200)                        ❷
          .putHeader("Content-Type", "application/json")
          .end(okStatus.encode());
      } else {
        logger.error("Readiness check failed", ar.cause());
        ctx.response().setStatusCode(503)                        ❸
          .putHeader("Content-Type", "application/json")
          .end(new JsonObject()
            .put("status", "DOWN")
            .put("reason", ar.cause().getMessage()).encode());   ❹
      }
    });
}

❶ 向传感器网关发送请求。

❷ 发送 200 状态。

❸ 报告一个失败。

❹ 在报告中给出错误信息。

要执行就绪性检查,我们向传感器网关的健康检查端点发送请求。我们实际上可以发送任何其他请求,以便我们知道服务是否可用。然后我们用 HTTP 200 或 503 响应就绪性检查。

部署资源中的配置如下所示。

列表 13.35 配置热 API 服务的健康检查

# (...)
spec:
  containers:
    - image: vertx-in-action/heat-api:latest
      name: heat-api
      # (...)
      livenessProbe:
        httpGet:
          path: /health/live
          port: 8080
        initialDelaySeconds: 1
        periodSeconds: 15
        timeoutSeconds: 5
      readinessProbe:           ❶
        httpGet:
          path: /health/ready
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5

❶ 定义一个就绪性探针。

如您所见,就绪性探针的配置与活性和就绪性探针非常相似。我们已定义 initialDelaySeconds 为五秒;这是因为初始 Hazelcast 发现需要几秒钟,所以传感器网关在此完成之前还没有部署其垂直结构。

我们可以通过关闭所有传感器网关的实例来检查效果,如下所示。

列表 13.36 将传感器网关缩放到 0 个副本

$ kubectl scale deployment/sensor-gateway --replicas 0     ❶
deployment.extensions/sensor-gateway scaled
$ kubectl get pods                                         ❷
NAME                                   READY   STATUS    RESTARTS   AGE
heat-api-5dbcc84795-ccb8d              0/1     Running   0          55m
heat-sensor-service-6946bc8f6f-2k7lv   1/1     Running   0          55m
heat-sensor-service-6946bc8f6f-d9hd8   1/1     Running   0          55m
heat-sensor-service-6946bc8f6f-rhdbg   1/1     Running   0          55m
heat-sensor-service-6946bc8f6f-xd28p   1/1     Running   0          55m

❶ 缩放到 0。

❷ 列出默认命名空间中的所有 Pod。

在列出 Pod 之前,您应该等待几秒钟,并观察热 API Pod 被标记为 0/1 就绪。这是因为就绪性检查失败了,所以 Pod 将不再接收流量。您可以尝试运行以下查询并立即看到错误:

$ http $(minikube service heat-api --url)/warnings

现在,如果我们缩放到一个实例,我们将恢复到工作状态,如下所示。

列表 13.37 将传感器网关扩展到 1 个副本

$ kubectl scale deployment/sensor-gateway --replicas 1      ❶
deployment.extensions/sensor-gateway scaled
$ kubectl get pods
NAME                                   READY   STATUS    RESTARTS   AGE
heat-api-5dbcc84795-ccb8d              1/1     Running   0          63m
heat-sensor-service-6946bc8f6f-2k7lv   1/1     Running   0          63m
heat-sensor-service-6946bc8f6f-d9hd8   1/1     Running   0          63m
heat-sensor-service-6946bc8f6f-rhdbg   1/1     Running   0          63m
heat-sensor-service-6946bc8f6f-xd28p   1/1     Running   0          63m
sensor-gateway-6b7cd8bbcb-btl4k        1/1     Running   0          2m18s

❶ 扩展到 1 个实例。

您现在可以再次成功发送 HTTP 请求。

note 执行健康或就绪性检查时采取的操作取决于您的服务做什么。作为一个一般规则,您应该执行一个没有副作用系统的操作。例如,如果您的服务需要在数据库连接断开时报告失败的健康检查,一个安全的操作是执行一个小型的 SQL 查询。相比之下,执行数据插入 SQL 查询有副作用,这可能不是您检查数据库连接是否工作的方式。

13.3.2 指标

Vert.x 可以配置为报告各种项目的指标,如事件总线通信、网络通信等。监控指标很重要,因为它们可以用来检查服务的表现并触发警报。例如,您可以为给定 URL 端点的吞吐量或延迟超过阈值时导致 Kubernetes 扩展服务的警报。

我将向您展示如何从 Vert.x 中暴露指标,但其他主题,如可视化、警报和自动扩展,非常复杂,并且超出了本书的范围。

Vert.x 通过 JMX、Dropwizard、Jolokia 和 Micrometer 等流行技术暴露指标。

我们将使用 Micrometer 和 Prometheus。Micrometer (micrometer.io/) 很有趣,因为它是一个在指标报告后端(如 InfluxDB 和 Prometheus)之上的抽象。Prometheus 是一个在 Kubernetes 生态系统中流行的指标和警报项目 (prometheus.io/)。它还以 拉取 模式工作:Prometheus 被配置为定期从服务中收集指标,因此您的服务不会受到 Prometheus 不可用的影响。

当传感器网关接收事件总线和 HTTP 流量时,我们将添加指标;它是用例中最受请求的服务。为此,我们首先必须添加以下两个依赖项。

列表 13.38 添加指标支持

implementation("io.vertx:vertx-micrometer-metrics:$vertxVersion")           ❶
implementation("io.micrometer:micrometer-registry-prometheus:$mpromVersion")❷

❶ Vert.x Micrometer 支持

❷ Prometheus 的 Micrometer 支持

当从 main 方法启动 Vert.x 时,传感器网关需要集群和指标。我们需要按照以下方式启用指标。

列表 13.39 启用 Micrometer/Prometheus 指标

VertxOptions options = new VertxOptions()
  .setEventBusOptions(new EventBusOptions()              ❶
    .setHost(ipv4)
    .setClusterPublicHost(ipv4))
  .setMetricsOptions(new MicrometerMetricsOptions()      ❷
    .setPrometheusOptions(new VertxPrometheusOptions()
      .setPublishQuantiles(true)                         ❸
      .setEnabled(true))
    .setEnabled(true));

❶ 事件总线配置,就像之前一样

❷ 使用 Prometheus 启用 Micrometer 指标。

❸ 还发布指标分位数。

我们现在必须定义一个 HTTP 端点,以便指标可用。Vert.x Micrometer 模块提供了一个 Vert.x 网络处理程序,使其变得简单,如下面的列表所示。

列表 13.40 通过 HTTP 暴露指标端点

router.route("/metrics")                          ❶
  .handler(ctx -> {
    logger.info("Collecting metrics");            ❷
    ctx.next();
  })
  .handler(PrometheusScrapingHandler.create());   ❸

❶ 在 /metrics 路径上暴露。

❷ 记录请求。

❸ 预制处理程序

截获指标请求并记录它们是一个好主意。这在配置 Prometheus 以检查它是否正在收集任何指标时很有用。

您可以使用端口转发来测试输出。

列表 13.41 测试指标报告

$ kubectl port-forward services/sensor-gateway 8080     ❶

$ http :8080/metrics                                    ❷

❶ 在一个终端中进行端口转发

❷ 检查指标输出。

Prometheus 指标以简单的文本格式公开。正如你在运行前面的命令时可以看到的,默认情况下会报告许多有趣的指标,如响应时间、打开的连接等。你还可以使用 Vert.x Micrometer 模块 API 定义自己的指标,并像默认指标一样公开它们。

你可以在本书的 Git 仓库的 13/ k8s-metrics 文件夹中找到配置 Prometheus 操作员以从传感器网关消耗指标的说明和 Kubernetes 描述符。你还可以找到一个指向使用 Grafana 创建类似图 13.4 的仪表板的指针。

图片

图 13.4 使用 Grafana 的指标仪表板

Grafana 是一个流行的仪表板工具,可以从许多来源消费数据,包括 Prometheus 数据库(grafana.com/)。你所需要做的就是连接可视化和查询。幸运的是,仪表板可以作为 JSON 文档共享。如果你想重现图 13.4 中的仪表板,请检查 Git 仓库中的指针。

13.4 开始的结束

所有美好的事物都有结束的时候,这一章标志着我们使用 Vert.x 迈向反应式应用的旅程的结束。我们这本书从异步编程和 Vert.x 的基础开始。异步编程是构建可扩展服务的关键,但它也带来了挑战,你看到了 Vert.x 如何帮助使这种编程风格变得简单和愉快。本书的第二部分,我们使用一个现实的应用场景来研究 Vert.x 的关键模块,包括数据库、Web、安全、消息传递和事件流。这使得我们能够构建一个由多个微服务组成的端到端反应式应用。到本书结束时,你看到了一个基于负载和混沌测试组合的方法,以确保服务的弹性和响应性。这很重要,因为反应式不仅仅是关于可扩展性,还关于编写能够处理失败的服务。我们以在 Kubernetes 集群中部署 Vert.x 服务的注意事项结束,这是 Vert.x 非常适合的事情。

当然,我们没有涵盖 Vert.x 中的所有内容,但你可以轻松地在项目的网站和文档中找到你的路径。Vert.x 社区很欢迎,你可以通过邮件列表和聊天进行联系。最后但同样重要的是,你通过阅读这本书学到的许多技能可以应用到 Vert.x 以外的技术中。反应式技术不是你可以从货架上拿来的。像 Vert.x 这样的技术只能让你达到反应式的一半;在系统设计中,需要一种工艺和心态来实现稳定的可扩展性、容错性和最终的反应性。

在更个人化的方面,我希望您阅读这本书的经历和我写作这本书的经历一样愉快。我期待在在线讨论中收到您的反馈,如果我们有幸一起参加活动,我将非常高兴亲自见到您!

玩得开心,保重!

摘要

  • Vert.x 应用程序可以轻松部署到 Kubernetes 集群,无需 Kubernetes 特定的模块。

  • Vert.x 分布式事件总线通过配置集群管理器发现模式在 Kubernetes 中工作。

  • 使用 Minikube、Skaffold 和 Jib 等工具,可以拥有快速、本地的 Kubernetes 开发体验。

  • 在集群中操作服务时,公开健康检查和指标是一种良好的实践。


1.有关 Ingress 和其他 Kubernetes 主题的更多信息,请参阅 Marko Lukša 的《Kubernetes 实战》第二版(Manning,2020)。

posted @ 2025-11-20 09:29  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报