Java-企业级微服务-全-
Java 企业级微服务(全)
原文:Enterprise Java Microservices
译者:飞龙
第一部分. 微服务基础
什么是微服务?微服务由单个在单个进程中执行的部署组成。微服务与传统 Enterprise Java 应用程序有何不同?在什么情况下使用微服务是合适的?这些问题只是我们将在前五章中解决的一些问题。
第一部分还探讨了 Enterprise Java 微服务的运行时选项,在结束之前,介绍了如何测试微服务并将它们部署到云中。
第一章. Enterprise Java 微服务
本章涵盖
-
Enterprise Java 历史
-
微服务与分布式架构
-
迁移到微服务的模式
-
Enterprise Java 微服务
在你深入之前,让我们回顾一下,我希望你在本书的学习过程中达到什么目标。我们都知道没有免费的午餐,所以我不假装微服务很容易。本章介绍了微服务——它们的概念、优点和缺点——为你构建技术知识提供一个基础。第二章和第三章提供了一个 RESTful 端点微服务的示例,并涵盖了你的 Enterprise Java 微服务的运行时和部署选项。
那么,什么是Enterprise Java 微服务?简而言之,它是将 Enterprise Java 应用于微服务开发的结果。本章的后半部分和本书的其余部分将详细探讨这意味着什么。
在你学习了微服务的基础之后,你将深入研究在 Enterprise Java 中使用以减轻微服务缺点和复杂性的工具和技术。在更熟悉微服务之后,你将查看一个现有的 Enterprise Java 应用程序,以及它如何迁移以利用微服务。最后几章涉及与安全和事件流相关的更高级微服务主题。
1.1. Enterprise Java——简史
如果你正在阅读这本书,你很可能已经是一位经验丰富的 Enterprise Java 开发者。如果你不是,我赞赏并赞扬你想要拓宽视野到 Enterprise Java 的愿望!
1.1.1. 什么是 Enterprise Java?
对于那些对 Enterprise Java 新手,或者需要复习的人来说,什么是 Enterprise Java?Enterprise Java是一组 API 及其实现,可以提供从 UI 到数据库的应用程序整个堆栈,通过 Web 服务与外部应用程序进行通信,并与内部遗留系统集成,仅举几个例子,其目标是支持企业的业务需求。尽管使用 Java 本身可以实现这样的结果,但重写所有为应用程序所需的基础低级架构将是繁琐且容易出错的,并且会严重影响企业及时交付价值的能力。
在 Java 首次发布 20 多年后的不久,各种框架开始出现,以解决开发者对低级架构的关注。这些框架允许开发者专注于使用特定于应用程序的代码来交付业务价值。
企业 Java
许多框架已经出现又消失,但有两个框架在多年中一直是最受欢迎的:Java 平台,企业版(Java EE)和 Spring。这两个框架占到了企业使用企业 Java 的大部分开发工作。
Java EE 包含许多规范,每个规范都有一个或多个实现。Spring 是一组库,其中一些库封装了 Java EE 规范。
1.1.2. 典型的企业 Java 架构
在企业 Java 的早期,我们的应用程序都是绿地开发,因为没有扩展任何现有代码。
定义
绿地指的是开发一个全新的应用程序,没有任何需要考虑的现有代码,不包括可能需要的任何通用库。
绿地开发为开发一个应用程序的清洁分层架构提供了最大的机会。通常,架构师会设计一个类似于图 1.1 所示的架构。
图 1.1. 典型的企业 Java 应用程序架构

在这里,你可能会认出你过去工作过的熟悉架构片段:一个视图层,一个控制器,可能使用一个可重用的业务服务,最后是和数据库交互的模型。你还可以看到应用程序被打包成 WAR 文件,但每个层可能都有多种打包组合,包括 JAR 和 EAR。通常,视图和控制器被打包在 WAR 文件中。业务服务和模型被打包在 JAR 文件中,这些 JAR 文件可能位于 WAR 或 EAR 内部。
随着时间的推移,我们继续使用企业 Java 开发绿地应用程序,但最终大多数企业主要是增强现有应用程序。从那天起,许多企业 Java 应用程序由于维护工作而成为企业的遗产负担——这并不是因为 Java 本身存在缺陷或不足,尽管确实存在一些,而是因为开发者不是最擅长对现有应用程序和系统进行架构变更。对于有数百名架构师和开发者通过其大门的企业来说,这个问题更加复杂,因为每个人都会带来他们自己的偏好和扩展现有应用程序的模式。
注意
我并不是坐在象牙塔里贬低开发者。很多时候,我在没有完全掌握现有功能的情况下就做出了关于如何实现特性的决定——这并非出于任何意图或恶意,而是因为编写代码的人已经不再在该公司工作,因此无法询问代码相关的问题,也因为文档可能缺乏或不够直观。这种情况下,开发者不得不做出判断,是否已经充分理解现有系统,以便进行修改。再加上管理层的截止日期压力,这种情况变得更加充满问题。
随着时间的推移,许多企业 Java 应用程序偏离了图 1.1 中所示的清晰架构,变成了更接近图 1.2 的混乱意大利面。在图 1.2 中,你可以看到层内功能之间的清晰边界已经变得模糊,导致每个层的组件不再有明确的目的。
这种情况是许多企业目前所处的境地。企业中的应用程序可能只有少数符合这种模式,但这个混乱的意大利面问题是必须解决的,以便应用程序能够在不产生重大成本的情况下促进未来的发展。
1.1.3. 什么是单体?
什么是定义企业 Java 应用程序为单体?一个单体是指所有组件都包含在单个可部署单元中的应用程序,并且通常具有 3-18 个月的发布周期。一些应用程序甚至可能有两年的发布周期,这并不适合敏捷企业。单体通常随着时间的推移从尝试快速迭代增强应用程序,而不考虑不同部分或组件之间适当的边界,而逐渐演变。一个应用程序是单体的指标可能包括以下内容:
-
由于相互交织的行为,多个 WAR 文件是单个部署的一部分
-
包含可能数十个其他 WAR 和 JAR 文件的 EAR 文件,以提供所有必要的功能
图 1.2 是一个单体吗?它当然是一个单体,而且是一个非常糟糕的单体,因为组件之间的功能分离变得模糊不清。
图 1.2. 企业 Java 意大利面

为什么上述因素会使一个应用程序成为单体?当你的应用程序占用空间小的时候,一个可部署的单体应用程序是完全可以接受的,但是当你有数千个类和数十个第三方库时,应用程序就变得无限复杂。即使对应用程序的微小更改进行测试,也需要大量的回归测试来确保应用程序的其他部分没有受到影响。即使回归测试是自动化的,这仍然是一项巨大的任务。
应用程序是否是单体也部分取决于其架构。将应用程序归类为单体并不是基于磁盘上应用程序的大小,或者执行单体所使用的运行时大小。这完全取决于该应用程序在内部组件方面的架构方式。
发布节奏是企业的一个推动因素。如果一个应用程序每 3-18 个月才发布一次,那么业务(无论是有意还是无意)都会关注那些需要大量时间开发的大功能变更。没有动力去请求一些可以在几小时或几天内完成并发布的微小调整,即使是最简单的变更也需要数月才能达到生产环境。
由开发和测试变更所需时间决定的发布节奏直接影响企业敏捷性和对变化环境的响应能力。例如,如果竞争对手开始以比你的企业低 15%的价格销售与你企业相同的商品,你能做出反应吗?为了降低产品的销售价格而进行几个月的简单变更可能会对底线产生灾难性的后果。如果那个商品是最大的卖家,而企业在价格上无法竞争三个月,那么在价格变更发布时,企业甚至可能处于破产的边缘。
除了发布节奏,还必须注意,关于微服务与单体的讨论与对大小的限制无关。你可以有一个 100MB 大小的微服务,或者一个只有 20MB 的单体。定义更多的是组件之间的依赖耦合,这导致了更新单个组件而无需对许多组件进行级联更新的好处。这种解耦使得发布节奏更快。
虽然看起来单体企业 Java 应用都是一片阴霾和末日,但这真的是情况吗?在许多情况下,企业继续使用或开发单体是有意义的。你如何知道是否应该坚持使用单体?
-
你的企业可能只有少数几个正在积极开发和维护的应用程序。 当你有这么少的应用程序时,显著增加开发和测试以及发布的负担可能并不合理。
-
如果当前的开发团队有十几个人,将他们分成一两个人的微服务团队可能不会带来任何好处。 在某些情况下,这种分割甚至可能是有害的。Basecamp (
basecamp.com/) 是一个很好的例子,它是一个由 12 人团队开发的单体,目前的状态已经很好。 -
你的企业是否需要每周甚至每天发布多个版本? 如果不是,并且现有的单体有清晰的组件分离,那么减少发布节奏可能就是提高业务敏捷性和价值的全部需求。
对于企业来说,是否坚持使用单体架构取决于当前的环境和长期目标。
1.1.4. 与单体架构相关的问题有哪些?
通常,类似于图 1.1 中的那种建筑设计是一个好主意,但同时也存在一些缺点:
-
无法扩展单个组件— 这可能看起来不是一个主要问题,但某些因素可以改变不良扩展的影响。如果一个应用程序的单个实例需要大量的内存或空间,将其扩展到相当数量的节点需要大量的硬件投资。
-
单个组件的性能— 在包含许多组件的单个部署中,一个组件的性能可能比其他组件差。这样,一个组件就会减慢整个系统的速度,这不是一个好的情况,而且运维团队也不会高兴。
-
单个组件的可部署性— 当整个应用程序是一个单一部署时,任何更改都需要重新部署整个应用程序,即使只有一个组件中的一行更改。这对业务敏捷性不利,通常会导致需要数月时间才能完成的一次更新部署中包含许多更改。
-
代码复杂性增加— 当一个应用程序有多个组件时,它们之间的功能边界很容易变得模糊。进一步模糊组件之间的分离会增加代码的复杂性,无论是在代码执行方面还是在开发者理解代码意图方面。
-
准确测试应用程序的难度— 当应用程序的复杂性增加时,确保任何更改没有引起回归所需的测试量和时间也会增加。看似最小且最不重要的更改很容易导致完全无关的组件中出现未预见的错误和问题。
所有这些问题给企业带来了巨大的成本,同时也减缓了他们利用新机会的速度。但与从头开始相比,这些潜在的缺点仍然很小。
如果一个企业有一个经过十年或更长时间发展并增加了新功能的应用程序,尝试用绿色项目来替换它将需要数百人年的努力。这是企业继续维护现有单体架构的一个巨大因素。
当用更现代的替代品替换单体架构的成本过高时,该应用程序就会在企业中根深蒂固。它成为了一个关键应用程序,任何停机都会对业务造成影响。随着持续的增强和修复,这种情况会变得更加复杂。
反过来,一些单体已经运行多年,并且可以由少数几个开发者轻松管理,无需太多努力。也许它们处于维护模式,没有进行大量的功能开发。这些单体现状很好。如果它没有坏,就别修它。
对于那些即使企业知道它们在业务敏捷性和成本上花费很大,也无法用绿色项目替换的笨重单体,你该怎么办?如何更新它们以使用更新的框架和技术,使它们不会成为遗留系统?我们将在下一节回答这些问题。
1.2. 微服务和分布式架构
在深入探讨微服务和分布式架构的定义之前,让我们回顾一下在使用它们时图 1.2 可能的样子;参见图 1.3。这种描述确实通过将它们拆分为具有明确边界的独立微服务,澄清了组件之间的分离。
图 1.3. 企业 Java 微服务

那么,我所说的微服务是什么意思呢?一个微服务由一个在单个进程中执行的单一部署组成,与其他部署和进程隔离,支持特定业务功能的实现。每个微服务都专注于边界上下文内的所需任务,这是一种逻辑上分离企业各种领域模型的方法。我们将在本章后面更详细地介绍这一点。
从定义中,你可以看到,微服务本身并不是有用的。只有当你有许多松散耦合的微服务协同工作以满足应用程序的需求时,它才变得有用。包含许多微服务并相互通信的微服务架构也可以被称为分布式架构。
要使微服务变得有用,它需要能够轻松地从其他微服务和整个系统的组件中使用。当微服务试图完成太多事情时,这是不可能实现的。你希望微服务专注于单一任务。
1.2.1. 做好一件事
1978 年,Douglas McIlroy,最著名的成就是开发 UNIX 管道和各种 UNIX 工具,记录了 UNIX 哲学,其中一部分是,“让每个程序做好一件事”。这种相同的哲学被微服务开发者所采纳。微服务不是应用程序开发的“大杂烩”;你不能把所有东西都扔进去,并期望它们以最佳水平运行。在这种情况下,你会有一个单体微服务,也称为分布式单体!
一个设计良好的微服务应该有一个单一的任务去执行,这个任务足够细粒度,提供业务能力或增加业务价值。超出单一任务将使我们回到企业 Java 单体的问题,我们不想重蹈覆辙。
对于微服务来说,确定足够细粒度的任务并不总是容易。在本章的后面部分,我们将讨论领域驱动设计作为辅助定义这种粒度的方法。
1.2.2. 什么是分布式架构?
一个 分布式架构 由多个相互协作的组件组成,这些组件共同构成了一个跨进程、甚至经常跨网络边界的应用程序的完整功能。分布式可以是应用程序的任何部分,例如 RESTful 端点、消息队列和 Web 服务,但它绝对不仅限于这些组件。
图 1.4 展示了微服务分布式架构可能的样子。在这个描述中,微服务实例被描述为处于 运行时,但这并不规定实例的打包方式。它可以打包为 uber jar 或 Linux 容器,但还有许多其他选项。运行时纯粹是为了界定微服务的操作环境,表明微服务是独立运行的。
图 1.4. 典型的微服务架构

注意
uber jar,也称为 fat jar,表示 JAR 文件包含多个应用程序或库,并且可以从命令行使用java -jar运行。
1.2.3. 为什么你应该关心分布式?
现在你已经看到了分布式架构,让我们来看看一些好处:
-
服务是位置无关的。 不论服务物理位置在哪里,服务都可以定位并与其他服务进行通信。这种位置无关性使得服务可以位于相同的虚拟硬件、相同的物理硬件、相同的数据中心、不同的数据中心,甚至公共云中,并且它们都表现得好像它们在同一个 JVM 中一样。位置无关性的主要缺点是它们之间进行网络调用所需的时间额外增加,并且由于添加新的网络调用的性质,你降低了成功完成的几率。
-
服务是语言无关的。 虽然这本书主要关注企业 Java,但我们并不那么天真,认为服务不会需要或希望用不同的语言开发。当服务不需要在相同的环境中运行时,你可以为不同的服务使用不同的语言。
-
服务部署小且用途单一。 当部署较小,测试所需的努力就较少,这使得将部署的发布周期缩短到一周或更少成为可能。拥有小型、单一用途的部署使得企业能够更容易地以近乎实时的方式对业务需求做出反应。
-
新服务是通过现有服务功能的重新组合来定义的。 在你的架构中拥有离散的分布式服务大大增强了你以新的方式重新组合这些服务以创造额外价值的能力。这种重新组合可以简单到部署单个新服务,结合已经部署的一小部分服务。这使你能够在更短的时间内为业务创造新的东西。
听起来很棒——你如何现在就能开发分布式应用程序呢?你需要稍微收紧一下缰绳。是的,分布式确实在很大程度上改善了多年来我们在企业 Java 中遇到的问题,但它也带来了自己的挑战。开发分布式应用程序绝不是万能的,你很容易就会自食其果。
你已经看到了分布式的一些好处,但大多数事情都不是免费的午餐——分布式架构更是如此。如果你有一堆通过通信和没有耦合相互操作的服务,这可能会带来什么问题?
-
对于服务来说,地理位置的独立性是很好的,但他们如何找到彼此呢?你需要一种逻辑上定义服务的方法,无论它们的物理位置或 IP 地址可能是什么。有了发现方法,你可以通过逻辑名称定位服务,而忽略其物理位置。服务发现就起到了这个作用。本书的第二部分介绍了如何使用服务发现。
-
如何处理故障而不影响客户?当服务失败时,你需要一种优雅地降级功能的方法,而不是让应用程序崩溃。你需要服务的弹性和容错性,以便在服务失败时提供替代方案。第二部分介绍了如何为你的服务提供容错性和弹性。
-
拥有数百或数千个服务,而不是少数几个应用程序,给运维带来了额外的负担。大多数运维团队没有处理如此大量服务的经验。如何减轻一些这种复杂性?监控需要在这里扮演重要角色——特别是自动化监控。你需要自动化监控数百个服务以减轻运维负担,同时尽可能提供关于整个系统的实时信息。
1.2.4. 如何协助开发微服务?
微服务开发很困难,那么你能做些什么来让它更容易呢?没有一劳永逸的方法来让它变得容易,但本节介绍了几个使微服务开发更易于管理的选项。
1.2.5. 产品而非项目
Netflix 自从在 Adrian Cockroft 的领导下重写其整个架构以来,一直是其微服务产品而非项目理念的坚定支持者。
这些年来,我们一直在开发项目而不是产品。为什么?因为我们开发的应用程序满足了一组需求,然后将其移交给运营。该应用程序可能需要两周或两年时间来开发,但如果最终是将其移交给运营团队解散,那么它仍然是一个项目。一些团队成员可能会保留一段时间来处理维护请求和增强功能,但这种努力仍然被视为项目,随后是许多小项目。
那么,如何开发一个 产品 呢?开发一个产品意味着一个团队在整个生命周期内拥有它,无论是 2 个月还是 20 年。团队将开发它、发布它、管理应用程序的运营方面、解决生产问题——几乎一切。
为什么区分项目和产品很重要?拥有一个产品意味着对应用程序开发方式有更大的责任感。如何?你希望半夜被叫醒,因为一个应用程序失败了?我知道我不希望这样!
从项目转向产品如何帮助开发微服务?当你寻求一周或更短的发布周期,这是真正的微服务典型的,对于不熟悉代码库的开发者来说,很难达到那种发布频率,就像 项目 方法一样。
1.2.6. 持续集成和交付
没有持续集成和交付,开发微服务会变得困难得多。
持续集成 指的是确保对源存储库的任何更改或提交都会导致应用程序的新构建,包括该应用程序的所有相关测试。这提供了快速反馈,以确定更改是否破坏了应用程序,前提是测试足够充分以发现它。
持续交付 是一个相对较新的现象,它源自 DevOps 运动,其中应用程序更改在环境之间持续交付,包括生产环境,以确保应用程序更改的快速交付。可能有一个手动步骤来批准构建进入生产,但并不总是如此。对于关键用户应用程序,可能需要手动步骤,而对于其他应用程序则不太需要。持续交付通常通过构建管道提供,该管道可以包括自动或手动步骤,例如手动步骤来批准生产版本的发布。
持续集成和交付,简称 CI/CD,是促进短期发布周期的关键工具。为什么?它们使开发者能够以自动化的方式在过程中早期发现可能的错误。但更重要的是,CI/CD 显著减少了确定代码块已准备好投入生产到它对用户可见之间的时间。如果发布过程需要一天或两天才能完成,那么这不利于每天多次或每天至少一次的发布。
CI/CD 的另一个重要好处是能够更渐进地交付功能。目标不仅仅是能够更快地物理发布代码;能够部署更小的功能片段对于最小化风险至关重要。如果一个小改动在生产中导致失败,撤销该改动是一个相对容易的任务。
1.3. 演进到微服务模式的策略
你已经了解了具有现有单体架构的企业 Java,你也学习了分布式架构中的微服务。但你是如何从一种过渡到另一种的呢?本节深入探讨了可以应用于将现有单体拆分为多个微服务的问题的模式。
1.3.1. 领域驱动设计
领域驱动设计(DDD)是一套用于建模我们对软件中领域理解的模式和方法的集合。其关键部分是边界上下文模式(martinfowler.com/bliki/BoundedContext.html),它允许你将系统的一部分在单次建模中隔离开来。
这个主题过于广泛,无法在这本书的几小段文字中涵盖,尤其是考虑到已经有许多书籍专门讨论领域驱动设计(DDD)。但在这里,我们将简要介绍它,作为使用微服务进行开发的谜题中的一部分。领域驱动设计(DDD)是一套用于建模我们对软件中领域理解的模式和方法的集合。其中关键的部分是边界上下文模式(martinfowler.com/bliki/BoundedContext.html),它允许你将系统的一部分在单次建模中隔离开来。
一个足够大的应用程序或系统可以被划分为多个边界上下文,使得设计和开发在任何时候都能专注于给定边界上下文的核心理念。这种模式承认,在任何时候为整个企业提出一个领域模型都是困难的,因为存在太多的复杂性。将这样的模型划分为可管理的边界上下文,提供了一种在不关心其余部分(可能未知)的领域模型的情况下,专注于模型一部分的方法。图 1.5 是一个帮助你理解 DDD 背后概念的示例。
图 1.5. 商店领域模型

假设你有一个想要开发微服务的商店,其领域模型包括订单、订单中的项目、产品以及该产品的供应商。当前的领域模型结合了产品定义的不同方式。从订单的角度来看,它不关心谁供应产品,当前有多少库存,制造商的价格,或任何仅与业务管理相关的其他信息。相反,管理方面也不一定关心一个产品可能关联的订单数量。
图 1.6 显示你现在在每个边界上下文中都有产品;每个都代表产品的一个不同视图。订单边界上下文只有产品代码和描述等信息。所有业务所需的产品信息都在产品边界上下文中。
图 1.6. 分离的边界上下文

在某些情况下,边界上下文的领域模型中可能存在清晰的分割,但在其他情况下,不同的模型之间将存在共性,就像前面的例子中那样。在这种情况下,重要的是要考虑,尽管领域模型的一部分在边界上下文中是共享的,但一个领域可以被归类为 所有者。
定义了领域的一部分的所有者之后,使其领域对外部边界上下文可用就变得必要了——但要以不隐式地将两个边界上下文联系起来的方式。这确实使得处理边界变得更加复杂,但像事件溯源这样的模式可以帮助解决这个问题。
注意
事件溯源 是在应用程序中为每个状态变化触发事件的实践,这通常以某种格式记录为日志。这样的日志可以用来重建整个数据库结构,或者在本例中,作为填充外部拥有的部分领域模型的方式。
所有这些边界上下文是如何相互配合的?每个边界上下文都是更大整体的一部分,即上下文图。上下文图 是应用程序的全局视图,标识了所有所需的边界上下文以及它们应该如何相互通信和集成。
在这个例子中,因为你已经将产品分成了两部分,你需要从产品到订单边界上下文的数据馈送,以便能够用适当的数据填充产品。
正如你在我们的示例中看到的,在边界上下文中共享领域模型的一个附带好处是,每个上下文都可以拥有对相同数据的自己的视图。应用程序不再被迫以与其所有者相同的方式查看数据的一部分。当领域只需要记录中所有者可能持有的数据的一个小子集时,这可以提供巨大的好处。关于领域驱动设计和边界上下文的更多信息,我推荐 Debasish Ghosh 的《功能性和反应式领域建模》(Manning, 2016)。
1.3.2. 大爆炸模式
在企业中迁移到微服务时的 大爆炸模式 一直以来都是最复杂和最具挑战性的。它涉及到将现有单体中的每一块拆分成微服务,使得从一种到另一种有一个单一的切换。
由于部署是一个单一的切换——大爆炸——到生产,为这种变化开发可能需要与单体开发一样长的时间。当然,到过程结束时,你已经迁移到了微服务,但与其他迁移到微服务的模式相比,这种模式对大多数企业来说道路会更加坎坷——特别是考虑到在两种部署模型之间移动所需的内部流程和程序变更。这种突然的变化可能会对企业的运营造成创伤,并可能造成损害。
大爆炸模式不建议作为大多数企业迁移的手段,尤其是对于那些还没有微服务经验的企业。
1.3.3. Strangler 模式
Strangler 模式基于马丁·福勒定义的 Strangler 应用程序(www.martinfowler.com/bliki/StranglerApplication.html)。马丁将此模式描述为通过逐步在现有系统的边缘创建新系统来重写现有系统的方式。新系统在几年内缓慢增长,直到旧系统被扼杀至不存在。
你可能会发现与大爆炸模式类似的结果——不一定是坏事——但这是在更长的时间跨度内实现的,同时仍然在过渡期间提供业务价值。这种方法与大爆炸模式相比,显著降低了风险。通过监控应用程序随时间的发展,你可以随着每个新实现的微服务的学习来调整实现微服务的方式。这是大爆炸模式之外的另一个巨大优势:能够调整和应对可能出现在流程或程序中的问题。采用大爆炸方法,企业会一直绑定到其流程,直到一切切换完成。
1.3.4. 混合模式
现在你已经看到了大爆炸模式和 Strangler 模式,让我们来看看混合模式。我感觉这个模式将成为企业迁移到和发展微服务的主要模式。
这种模式与 Strangler 模式有相似之处,不同之处在于你永远不会完全扼杀原始单体。你保留单体中的一些功能,并将其与新的微服务集成。图 1.7 显示了请求通过现有的企业 Java 单体和新微服务架构的路径:
-
用户从浏览器发出请求,指定他们希望看到的应用程序视图。
-
视图会调用控制器以检索构建自身可能需要的信息。
-
控制器调用业务服务,可能从不同的来源聚合数据。
-
业务服务然后将请求传递到微服务环境,在那里它进入网关。
-
网关根据定义的路由规则将请求路由到适当的微服务。
-
微服务接收请求并在调用另一个微服务之前对其进行一些自己的处理。
-
链接中的最后一个微服务与数据存储层交互以读取/写入记录。
如图 1.7 所示的架构为增长和及时交付业务价值提供了很大的灵活性。需要高性能和高可用性的组件可以部署到微服务环境中。那些迁移到新架构成本过高的组件可以继续部署在企业 Java 平台上。
图 1.7. 企业 Java 和微服务混合架构

您将在本书的后面部分关注混合模式,当您将现有的企业 Java 应用程序迁移到使用微服务时。
1.4. 什么是企业 Java 微服务?
正如我在本章开头提到的,企业 Java 微服务是纯粹使用企业 Java 开发的微服务。那么,让我们通过一个简单的例子来看看它在实际中的应用。
让我们创建一个简单的 RESTful Java EE 微服务,该服务使用 CDI 和 JAX-RS。这个微服务通过一个 RESTful 端点按名称问候用户;返回的消息是通过注入的 CDI 服务提供的(列表 1.1)。
列表 1.1. CDI 服务
@RequestScoped *1*
public class HelloService {
public String sayHello(String name) { *2*
return "Hello " + name;
}
}
-
1 CDI 注解表示您希望为每个 servlet 请求创建一个新的 HelloService 实例。在这种情况下,因为您没有存储状态,它很容易被@ApplicationScoped 替代。
-
2 服务方法接受单个参数并返回前缀为“Hello”的结果
前面的服务定义了一个单一的sayHello()方法,该方法返回与name参数值结合的Hello。
然后,您可以将该服务@Inject到您的控制器中。
列表 1.2. JAX-RS 端点
@ApplicationScoped *1*
@Path("/hello") *2*
public class HelloRestController {
@Inject *3*
private HelloService helloService;
@GET *4*
@Path("/{name}") *5*
@Produces("text/plain") *6*
public String sayHello(@PathParam("name") String name) { *7*
return helloService.sayHello(name); *8*
}
}
-
1 CDI 注解表示您只需要整个应用程序的单个实例
-
2 定义了此控制器的 RESTful URL 路径。在这种情况下,它被设置为“/hello”。
-
3 注入一个 HelloService 实例,您可以使用它。
-
4 定义方法处理的 HTTP 请求类型
-
5 指定方法的 URL 路径。您还可以指定一个名为 name 的参数,该参数可以传递到请求的 URL 上。
-
6 该方法仅产生文本响应。
-
7 将名为 name 的路径参数分配为方法参数
-
8 在注入的服务上调用 sayHello,传递 name 参数值
如果您之前开发过 JAX-RS 资源,您会认出前面代码中的所有内容。这意味着什么?这意味着您可以使用企业 Java 开发微服务,就像您在开发企业 Java 应用程序一样。使用现有企业 Java 知识开发微服务的能力是使用企业 Java 进行微服务的一个重大优势。
这个微服务示例被简化了,因为你只处理等式的生产者一侧。如果服务还消费了其他微服务,它就会更复杂。但你在本书的第二部分中会了解到这一点。
尽管前面的例子是用 Java EE API 实现的,但它同样可以用 Spring 来实现。
1.4.1. 为什么企业 Java 适合微服务
你已经看到了开发一个企业 Java 微服务的 RESTful 端点是多么容易,但你为什么要这样做呢?你不会更愿意使用专门为微服务构建的新颖框架或技术吗?现在你有大量的选择:Go、Rust 和 Node.js 只是其中的一些例子。
在某些情况下,使用较新的技术可能更有意义。但如果企业通过现有应用程序、开发人员等在 Enterprise Java 上有重大投资,那么继续使用该技术更有意义,因为开发者开发微服务时可以少学一样东西。而且,我说的技术并不是指 Java EE 或 Spring 本身;更多的是指技术提供的 API 以及开发者对这些 API 的熟悉程度。如果相同的 API 可以用于单体、微服务或即将成为开发者思维共享的下一个热门词汇,那么这比重新学习每种开发情况下的 API 要有价值得多。
如果一个开发者是第一次为企业构建微服务,使用开发者已经熟悉和理解的技术可以让开发者专注于微服务的要求——而不必担心同时学习语言或框架的细微差别。
使用已经存在了近 20 年的技术也具有显著的优势。为什么?存在了那么长时间的技术几乎可以保证在不久的将来不会消失。有人能说出 Cobol 吗?
对于企业来说,知道他们正在开发和投资的技术在短短几年内不会过时,这是一件非常令人欣慰的事情。这种风险通常是企业不愿意投资于极新技术的原因。虽然不能使用最新和最好的技术可能会让人感到沮丧,但它确实有优势,至少对于企业来说是这样。
在选择用于开发微服务的技术时,企业并不是唯一需要考虑的因素。你还需要考虑以下内容:
-
市场上的开发者和技能经验——如果你没有足够大的资源池可供选择,那么选择特定技术进行微服务开发是没有意义的。有大量的开发者拥有企业 Java 经验,因此使用它是具有优势的。
-
供应商支持— 选择一种技术来开发微服务固然很好,但如果没有任何供应商提供对该技术的支持,那就很困难。困难之处在于,企业希望有一个供应商可以 24/7 提供技术支持,通常是在生产环境中。没有供应商支持,企业需要雇佣那些直接从事该技术工作的人,以确保他们能够解决生产中微服务的任何问题。
-
变更成本— 如果一个企业已经使用企业 Java 开发了十年或更长时间,并且有一群在该时间段内参与过项目的稳定开发者,那么企业放弃这一历史,开辟一条使用不同技术的全新道路是否合理?尽管在某些情况下,这样做是有道理的,但大多数企业应该坚持经验和技能,即使迁移到微服务。
-
现有的运营经验和基础设施— 除了开发者外,拥有多年企业 Java 运营经验同样关键。应用程序不会自我监控和修复,尽管那会很理想。需要雇佣或重新培训运营人员学习新的语言和框架可能和为开发者做同样的事情一样耗时。
概述
-
微服务由单个部署在单个进程中执行的单个进程组成。
-
企业 Java 单体是一个所有组件都包含在单个部署中的应用程序。
-
企业 Java 微服务是使用企业 Java 框架开发的微服务。
-
企业 Java 单体不适合快速发布周期。
-
实施微服务并非万能药,需要额外的考虑才能成功实施。
-
从单体迁移到微服务,最佳方式是使用混合模式。
-
在决定实施微服务时,不应忽视企业使用企业 Java 开发的历史。
第二章. 开发简单的 RESTful 微服务
本章涵盖
-
介绍 Cayambe 单块石
-
开发简单的 RESTful 应用程序
-
将简单的 RESTful 应用程序打包为微服务
-
理解企业 Java 微服务开发
本章将向您介绍 Cayambe 单块石。Cayambe 单块石将在本书开发企业 Java 微服务的过程中提供帮助,每个微服务将成为第十章(kindle_split_020.xhtml#ch10)中新的混合单体的一部分。
2.1. Cayambe 单块石
Cayambe 是一个已经停止维护 15 年的电子商务应用程序,需要严重的现代化。从图 2.1(#ch02fig01)中的主页很容易看出,它与现代网站的外观并不完全相同。
图 2.1. Cayambe 主页

如图 2.2 所示,Cayambe 是一个由三个 WAR 文件、一个用于 UI 的通用 JAR 文件以及包含 EJB(企业 JavaBeans)和 DAO(数据访问对象)以与数据库交互的 JAR 文件组成的 EAR 部署。
图 2.2. Cayambe 单体架构

在整本书中,你将致力于将 Cayambe 迁移到一系列部署,如图 2.3 所示。第十章概述了 Cayambe 的更多细节;在第十章中,你将集成单体与你在接下来的章节中开发的微服务。
图 2.3. Cayambe 未来架构

2.2. 新管理站点
作为 Cayambe 现代化的部分,你将分离站点的管理,使得站点的客户方面可以扩展,而无需同时扩展管理方面。
第一项任务是开发一个 JAX-RS RESTful 微服务,以提供必要的行政端点,并使用 ReactJS 开发一个新的 UI。对于那些已经熟悉 JAX-RS 的人来说,你会看到一些先前知识的重复。
图 2.4 是 Cayambe 当前的行政界面。在 UI 中查看或更新类别是不可能的,除非是主要类别运输。这远非理想情况,因此你将首先开发一个新的管理站点和微服务来处理产品类别的管理。
图 2.4. 旧的 Cayambe 管理界面

图 2.5 显示了使用 ReactJS 的新管理界面,以及以树状结构显示的类别数据。
图 2.5. 新的 Cayambe 管理界面

图 2.6 显示了当你完成本书后,你在此章节中开发的 RESTful 微服务将如何融入新的 Cayambe 架构。
让我们深入创建所需的 RESTful 微服务,以便启用新接口的工作。
图 2.6. Cayambe 管理微服务和 UI

2.2.1. 用例
对于本章,你将专注于开发管理部分的类别管理,但你也会在某个时候将其他方面从之前的管理站点迁移过来。这样做可以将你所学的内容简化为一个单一的问题域,而不是多个,专注于实现类别管理所需的代码。
作为类别管理的一部分,你需要支持在类别上执行创建、读取、更新和删除(CRUD)操作。这个过程当然不是开发 RESTful 端点中最有趣的部分,但大多数服务在其核心都需要某种类型的 CRUD。
UI 将调用微服务上的 CRUD 操作来维护类别。微服务的 RESTful 端点可以从任何客户端调用,但你将展示它们如何通过你的 UI 进行操作。图 2.7 详细说明了在 UI 中管理类别时的状态和它们之间的转换。
图 2.7. 类别管理状态流

2.2.2. 应用程序架构
目前忽略微服务,你的应用程序架构将类似于图 2.8。在表示层,你使用 ReactJS 来构建 UI,尽管我们不会在本章中涵盖 UI 的开发。API 层包含使用 JAX-RS 为类别提供的 RESTful 端点。最后,你在数据层中有类别的 JPA 实体,它与物理数据库交互。API 层负责与数据层交互以持久化记录更新。
图 2.8. 类别管理架构

你本可以将 API 层分离出来,并在数据层之上使用业务层中的服务,但我选择通过移除不必要的层来简化它。通常,所有这些层都会打包在一个 WAR 文件中,以便部署到应用程序服务器。
当你转向构建微服务时,架构是如何变化的?参见图 2.9。
图 2.9. 类别管理微服务架构

在这里,你可以看到你的服务器端层包含在一个单独的微服务中。然后,你的 UI 位于自己的 WAR 文件中,用于打包和部署 UI 到单独的运行时。现在,应用程序架构已分为可独立部署的组件:UI、微服务和数据库。
注意
在这次操作中,你选择将 UI 打包成 WAR 文件,但由于它仅包含 HTML/CSS/JS,你可以使用任何方式来打包和部署静态网站。
由于你已将 UI 和服务拆分到单独的运行时中,你需要添加对跨源资源共享(CORS)的支持。如果不这样做,浏览器将阻止 UI 向微服务发起 HTTP 请求。为此,你的微服务需要一个过滤器。
列表 2.1. CORSFilter
@Provider
public class CORSFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) throws IOException {
responseContext.getHeaders().add("Access-Control-Allow-Origin", "*");
responseContext.getHeaders()
.add("Access-Control-Allow-Headers", "origin, content-type, accept,
authorization");
responseContext.getHeaders().add("Access-Control-Allow-Credentials",
"true");
responseContext.getHeaders()
.add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS,
HEAD");
responseContext.getHeaders().add("Access-Control-Max-Age", "1209600");
}
}
小贴士
请记住 UI 从哪里获取数据,以及是否需要考虑 CORS。如果不这样做,当 RESTful 调用失败时,可能会轻易导致令人沮丧的 UI 错误,看似没有原因。另一方面,如果你的 UI 正在使用 API 网关与微服务交互,API 网关可以提供配置来直接处理 CORS,而不是在微服务中处理。
2.2.3. 使用 JAX-RS 创建 RESTful 端点
为了保持微服务简单,你将专注于 RESTful 端点、API 层,并忽略数据库所需的 JPA 实体开发。你将假设另一位开发者已经为你编写了它们!放心,这位开发者已经将它们包含在项目代码中。
除了 JPA 实体之外,开发者还提供了一个包含初始类别(在启动时用于填充数据库)的方便的load.sql文件。
图 2.10 显示了在本节中你将开发的内容。本节代码可在书籍示例代码的/chapter2/admin 目录中找到。
图 2.10. 类别管理—API 层

在本节中,你将开发CategoryResource。你的CategoryResource将专注于从 RESTful 端点使类别数据的 CRUD 操作可用。它指定了控制器的 RESTful @Path为/ category。你定义EntityManager以通过 CDI 注入,然后提供了一种在数据库上执行操作的方法。
注意
尽管许多人会争论 CRUD 不适合 RESTful 服务,但开发者通常将其用作将 RESTful 附加到现有 CRUD 的手段。Leonard Richardson 在 Richardson 成熟度模型中也定义了许多 REST 级别。作为模型中最复杂和最困难的级别,超媒体作为应用程序状态引擎(HATEOAS)。本书中的示例不符合 REST 的 HATEOAS 级别,主要是因为这不是许多企业开发者在其日常工作中所熟悉的。有关成熟度模型的更多信息,请参阅mng.bz/vMPk和restfulapi.net/richardson-maturity-model/。
默认情况下,所有 JAX-RS 资源实例仅在请求的基础上激活。如果你不改变这一点,每个请求都将花费时间创建必要的EntityManager实例以进行注入。这不会对性能产生巨大影响,但如果你能避免它,你应该避免。为了避免EntityManager的重新创建,你需要将其标记为@ApplicationScoped。这告诉运行时你希望CategoryResource的生命周期由 CDI 管理,而不是由 JAX-RS 管理。你需要定义一个 JAX-RS 应用程序类来定义你的微服务的根路径。
列表 2.2. AdminApplication
@ApplicationPath("/admin") *1*
public class AdminApplication extends Application {
}
- 1 定义应用程序根的 RESTful URL
对于这个类,你需要做的就这些。因为你要求 CDI 管理CategoryResource的生命周期,所以你不需要在 JAX-RS 中配置任何单例。现在是你开发所需用于类别 CRUD 操作的 RESTful 端点的时候了。
查看所有类别
应用程序的主屏幕是一个类别树。在屏幕上填充列表需要 RESTful 端点从数据库检索所有类别。
列表 2.3. 在 CategoryResource 上 @GET
@Path("/")
public class CategoryResource {
@PersistenceContext(unitName = "AdminPU") *1*
private EntityManager em;
@GET *2*
@Path("/categorytree") *3*
@Produces(MediaType.APPLICATION_JSON) *4*
public CategoryTree tree() throws Exception { *5*
return em.find(CategoryTree.class, 1); *6*
}
...
}
-
1 指定你想要 EntityManager 的特定持久化单元,AdminPU
-
2 @GET 表示该方法将仅接受 HTTP GET 请求。
-
3 端点的 RESTful URL 设置为 /categorytree。
-
4 表示该方法返回已序列化为 JSON 的数据
-
5 返回一个作为根类别的 CategoryTree。所有其他类别都将作为根的子类别检索。
-
6 使用注入的 EntityManager 查找主键为 1 的 CategoryTree 实例
删除类别
在你拥有一个类别后,你需要能够删除不再使用的旧类别。为此,你需要添加一个 RESTful 端点以从数据库中删除类别,如列表 2.4 所示。
列表 2.4. 在 CategoryResource 上 @DELETE
@Path("/")
public class CategoryResource {
...
@DELETE *1*
@Produces(MediaType.APPLICATION_JSON)
@Path("/category/{categoryId}") *2*
@Transactional *3*
public Response remove(@PathParam("categoryId") Integer categoryId)
throws Exception {
try {
Category entity = em.find(Category.class, categoryId); *4*
em.remove(entity); *5*
} catch (Exception e) {
return Response
.serverError()
.entity(e.getMessage())
.build(); *6*
}
return Response
.noContent()
.build(); *7*
}
...
}
-
1 @DELETE 表示该方法将仅接受 HTTP DELETE 请求。
-
2 定义该方法接受一个参数,并将其命名为 categoryId
-
3 在执行此端点时需要存在事务
-
4 根据你作为参数接收的 categoryId 查找类别实例
-
5 从持久化中删除 Category 实例
-
6 如果遇到异常,则使用 JAX-RS 响应返回包含异常消息的服务器错误
-
7 如果类别成功删除,则返回一个空响应
添加类别
有时需要添加新的类别。为此,你有一个 RESTful 端点可以将新类别添加到你的数据库中。
列表 2.5. 在 CategoryResource 上 @POST
@Path("/")
public class CategoryResource {
...
@POST *1*
@Path("/category")
@Consumes(MediaType.APPLICATION_JSON) *2*
@Produces(MediaType.APPLICATION_JSON) *3*
@Transactional
public Response create(Category category) throws Exception {
if (category.getId() != null) { *4*
return Response
.status(Response.Status.CONFLICT)
.entity("Unable to create Category, id was already set.")
.build();
}
try {
em.persist(category); *5*
} catch (Exception e) {
return Response
.serverError()
.entity(e.getMessage())
.build();
}
return Response
.created(new URI(category.getId().toString()))
.build(); *6*
}
...
}
-
1 @POST 表示该方法将仅接受 HTTP POST 请求。
-
2 表示该方法将仅接受可以序列化为类别实例的 JSON
-
3 该方法还返回一个序列化为 JSON 的类别。
-
4 如果类别已设置 ID,则返回 409 响应状态以指示与尝试创建的记录冲突
-
5 将新类别持久化到数据库中
-
6 作为响应的一部分,将位置路径设置为具有其标识符的新类别
此外,CategoryResource 定义了 RESTful 端点以检索和更新类别。附加方法的代码可在第二章源代码中找到。
2.2.4. 运行它
尽管你已表明你的 RESTful 端点是一个管理微服务,但你开发的代码中没有任何东西阻止它被构建为 WAR 并部署到应用程序服务器。
由于你只与一个微服务进行 UI 通信,因此它与现有的企业 Java 开发(使用 WAR)之间没有区别。这种相似性的优点是,如果微服务生产者不需要进行代码更改,则将现有的企业 Java 代码迁移到微服务更容易。
为了让我们的示例更具微服务感,你将使用 Thorntail 将其打包为一个 uber jar。Thorntail 提供了一种将你的应用程序打包为 WAR 或 EAR,然后部署到完整的 Java EE 应用服务器的方法的替代方案。它允许你从 WildFly 中选择所需的组件,并将它们打包成一个可以从命令行运行的 uber jar。第三章详细介绍了 Thorntail 的功能。要运行微服务,你需要在 pom.xml 中添加列表 2.6 中的插件。
列表 2.6。Maven 插件配置
<plugin>
<groupId>io.thorntail</groupId>
<artifactId>thorntail-maven-plugin</artifactId>
<version>${version.thorntail}</version> *1*
<executions>
<execution>
<id>package</id>
<goals>
<goal>package</goal> *2*
</goals>
</execution>
</executions>
<configuration>
<properties>
<thorntail.port.offset>1</thorntail.port.offset> *3*
</properties>
</configuration>
</plugin>
-
1 Thorntail 的最新版本
-
2 在执行时运行插件的打包目标。
-
3 指定端口偏移量为 1,以便你的微服务将在端口 8081 上启动。
你只需做这些就能提供一个从目录中运行微服务以及将其打包为 uber jar 的方法。那么你应该如何运行什么?需要运行两个部分:一个用于 UI,一个用于微服务。如果你想要直接对 RESTful 端点执行测试,而不使用 UI,你只需要启动微服务。
启动微服务
打开一个终端或命令窗口,导航到书中示例代码的/chapter2/admin 目录。从该目录运行以下命令:
mvn thorntail:run
这将启动包含你的 RESTful 端点的管理微服务。在日志显示微服务已部署后,你可以打开浏览器并访问 http://localhost:8081/admin/category。你的浏览器将加载类别数据并以 JSON 格式显示。现在你知道微服务正在运行,让我们运行 UI。
启动 UI
打开一个终端,导航到书中示例代码的/chapter2/ui 目录。从该目录运行以下命令:
mvn package
java -jar target/chapter2-ui-thorntail.jar
这个将 UI 打包成一个 uber jar,然后启动包含仅 UI 代码的 web 服务器的 uber jar。在日志显示已部署后,你可以打开浏览器并访问以下链接:
http://localhost:8080
你的浏览器将加载包含 Cayambe 类别数据的 UI,如图 2.5 所示。可以通过在每个终端窗口中按 Ctrl-C 来停止微服务和 UI。
摘要
-
你可以使用 JAX-RS 开发一个类别管理微服务。
-
使用企业 Java 可以轻松创建 RESTful 微服务。
-
在企业 Java 和微服务之间开发 RESTful 端点没有区别。
-
企业 Java 经验可以轻松转移到开发企业 Java 微服务。
第三章。适用于微服务的 Just enough Application Server
本章涵盖
-
什么是 Just enough Application Server?
-
什么是 MicroProfile?
-
哪些运行时支持 JeAS?
-
JeAS 运行时如何比较?
本章探讨了 Just enough Application Server(JeAS)背后的理念以及我们作为开发者使用 JeAS 开发企业 Java 微服务的运行时选项。我们将从定义 JeAS 以及它与 Java EE 的比较开始。为了帮助讨论,我们将描述一个假设的微服务,该微服务需要几个规范,以便评估各种 JeAS 运行时提供的功能。作为比较的一部分,我们将详细说明每个 JeAS 运行时以及它们在我们开发海滩度假购物应用时的差异。
3.1. Just enough Application Server
“Just enough Application Server”(JeAS)这个术语在过去的几年里偶尔被使用,但通常与通过手动删除功能来定制完整应用服务器相关。只有随着微服务的流行,JeAS 才对企业 Java 变得至关重要。本节将涵盖 JeAS 的含义、其优势以及在每个 JeAS 运行时开发的示例。
3.1.1. JeAS 是什么意思?
假设你需要开发一个与企业信息系统(EIS)交互的微服务,例如 SAP,以检索员工的人力资源(HR)信息。对于这个微服务,你选择了使用 JAX-RS、CDI 和 JMS。如果你要为部署到典型的 Java EE 应用服务器开发这样的微服务,它很可能会基于完整的 Java EE 平台,如图 3.1 所示。
图 3.1. 全 Java EE 平台的微服务规范使用

如你所见,在完整平台中有许多你未使用的规范,尽管你不需要它们,但它们仍然存在。完整平台包含 33 个 JSR。这可能是你并不总是需要的许多规范。
也许有一个 Java EE 配置可以用来精简它?目前你只有一个选项。让我们尝试 Web Profile 看看效果如何;参见图 3.2。
图 3.2. Java EE Web Profile 的微服务规范使用

这样做减少了未使用的规范,但现在你面临的问题是 JMS 不再是 Web Profile 的一部分。你仍然可以在部署微服务时添加 JMS 的实现,但它不再是堆栈的自动部分,可能需要额外的配置,而这在完整平台中是不需要的。
答案是什么?JeAS 能帮忙吗?那么,Just enough Application Server(JeAS)究竟是什么呢?简单来说,JeAS 颠倒了应用服务器和应用之间的关系,确保你只打包应用服务器中应用所需的部分。以我们之前的微服务示例为例,你知道你需要 JMS,因此你选择了完整的应用服务器平台。但你清楚你的应用永远不会使用该应用服务器的大部分功能。
Java EE 配置
自 Java EE 6 以来,我们为开发者提供了一种配置文件和全平台可供选择作为他们的应用程序服务器。尽管您可能不熟悉这些选项,以下提供了全平台和 Web 配置文件包含哪些规范的概述。
| 功能 | Web 配置文件 | 全平台 |
|---|---|---|
| EJB (Local) | ✓ | ✓ |
| JTS/JTA | ✓ | ✓ |
| 集群 | ✓ | ✓ |
| Servlet | ✓ | ✓ |
| JSF | ✓ | ✓ |
| JPA | ✓ | ✓ |
| JBDC | ✓ | ✓ |
| CDI | ✓ | ✓ |
| Bean 验证 | ✓ | ✓ |
| JAX-RS | ✓ | ✓ |
| JSON-P | ✓ | ✓ |
| EJB (远程) | ✓ | |
| JCA | ✓ | |
| JAX-WS | ✓ | |
| JAXB | ✓ | |
| JMS | ✓ | |
| JavaMail | ✓ | |
| JAX-RPC | ✓ | |
| JAXR | ✓ | |
许多应用程序服务器提供了通过删除组件及其相关配置来精简其分发的灵活性。我过去曾与许多客户合作,他们采取了这种方法。但找到正确的组合以确保应用程序服务器正常工作需要一定的试错方法。在某些情况下,甚至可能有一个您想删除但无法删除的组件,通常因为这个组件是应用程序服务器的一个关键部分。
为许多不同的应用程序快速定制应用程序服务器很快就会变成一组复杂的、不同的配置,这些配置需要被管理和维护。在这些情况下,开发者通常更喜欢简化他们的生活,选择全平台,而不是花时间尝试精简应用程序服务器。他们选择接受不使用应用程序服务器所有组件带来的额外开销。
几年来,几个应用程序服务器,如 WildFly,一直在努力减少未使用组件的占用空间。尽管各种组件所需的依赖项仍然在类路径上,但应用程序服务器足够聪明,如果部署的应用程序不需要这些类,就不会将它们加载到内存中。不幸的是,这只能做到如此,因为许多组件对于应用程序服务器的功能来说过于核心,无论应用程序可能需要什么。
那么 JeAS 在您的微服务架构中与 图 1.4 的关系如何?请看 图 3.3。
图 3.3. JeAS 作为微服务架构的运行时

如您所见,JeAS 的重点是微服务所需的运行时。JeAS 运行时旨在为微服务提供一个精简的应用程序服务器,但它的打包方式可能因实现而异。
JeAS 运行时提供了一种简单且易于管理的方法,仅包含应用程序服务器中您应用程序所需的部分。一些运行时在包含内容方面比其他运行时更灵活,我们将在稍后介绍这些细节。
选择的 JeAS 运行时可能会影响微服务可用的打包方式。然而,驱动因素始终应该是 JeAS 运行时支持的内容,而不是它需要如何打包。
3.1.2. 优点是什么?
在上一节中,您看到了当您的应用程序依赖于较小的 Web 配置文件时,可能会多么痛苦。它需要您引入额外的库并将它们配置为与整个应用服务器一起工作。
作为开发者,我们希望有效地利用我们的时间,开发新功能或修复错误。我们不想无休止地根据应用程序之间的不同需求配置应用服务器。更常见的是,我们会为了简单起见选择完整平台。
使用完整平台有什么大不了的?当然,今天您可能不会使用很多部分,但您计划有一天会使用,对吧?当然,在某些情况下,应用程序可能会增长到包括使用一个或两个原始设计之外的额外规范。一个应用程序突然增长到包括完整平台的所有规范的可能性非常小。如果确实如此,可能需要重新设计应用程序,因为它包含的功能太多,不适合单个应用程序。因此,完整平台应用程序服务器的大部分功能都将被闲置。
如果应用服务器不是“一刀切”的,那岂不是很好?这是 JeAS 旨在解决的问题之一,通过允许开发者选择给定应用程序所需的应用服务器功能或规范。
这有什么关系?如果一个应用程序只需要 servlets,它可以部署到仅提供 servlets 的 JeAS 运行时。使用 JeAS 运行时,如果应用程序需要添加一个功能,例如 JAX-RS,开发者可以选择将此功能作为独立部分添加到 JeAS 运行时。不再需要仅在两个 Java EE 选项之间选择,或者尝试自己定制应用服务器。
这种灵活性意味着 JeAS 运行时具有很大的好处:
-
减小打包大小—**与部署到应用服务器的应用程序捆绑包相比。
-
减少分配的内存—**减少的程度将取决于许多因素,例如不再加载的类数量。
-
减少安全足迹—**为各种功能打开的端口更少,运行的服务也更少。此外,潜在的关键漏洞(CVE)的暴露面积显著减少。
-
应用程序之间的分离度更高—**许多应用程序通常部署到单个应用服务器。
-
简化升级—**升级仅影响单个应用程序。
应用之间的更大分离可以意味着很多事情,因此需要额外的解释。多年来,企业 Java 应用在生产环境中部署,应用服务器很少只包含一个应用。通常,一个应用服务器会在单个实例中运行从几个到几十个应用。
如图 3.4 所示,JeAS 运行时在提供不同微服务之间的隔离方面比传统 Java EE 应用服务器中的应用提供了更大的隔离。
图 3.4. 传统 Java EE 与 JeAS 运行时对比

为什么会是这种情况呢?从历史上看,最大的原因是成本——不仅仅是应用服务器的成本,通常并不便宜,还包括运行单个应用服务器所需的全部物理硬件。当然,在过去十年中,随着虚拟机的改进和近年来容器技术的兴起,生产环境所需的物理硬件数量显著下降——随之而来的是,企业生产环境的成本也相应降低。
在 JeAS 运行时中,现在可以(暂时忽略容器),在单个物理硬件上运行它们的多个实例。每个在其自身进程中运行的 JeAS 运行时与其他运行时隔离,防止了应用服务器中常见的一个问题:即一个应用的失败会导致整个应用服务器以及其上运行的所有应用以不可恢复的方式失败。
3.1.3. Eclipse MicroProfile
对于在过去几年中关注企业 Java 和微服务领域发展的人来说,你很可能已经听说过 Eclipse MicroProfile。这是一个由 Red Hat、IBM、Tomitribe、Payara 和伦敦 Java 社区合作发起的社区倡议,旨在“优化企业 Java 以适应微服务”。自其最初形成以来,该社区已迁移至 Eclipse 基金会。
从最初的 JAX-RS、CDI 和 JSON-P 构成基础 Java EE 技术的第一个版本开始,我们现在已经超过了 1.3 版本,包括到目前为止的八个新的 MicroProfile 规范。表 3.1 详细说明了每个 MicroProfile 版本中包含的规范。
表 3.1. 每个版本中的 MicroProfile 规范
| 规范 | 1.0 (2016 年 9 月) | 1.1 (2017 年 7 月) | 1.2 (2017 年 9 月) | 1.3 (2018 年 1 月) |
|---|---|---|---|---|
| JAX-RS | ✓ | ✓ | ✓ | ✓ |
| CDI | ✓ | ✓ | ✓ | ✓ |
| JSON-P | ✓ | ✓ | ✓ | ✓ |
| 配置 | ✓ | ✓ | ✓ | |
| 容错 | ✓ | ✓ | ||
| JWT 传播 | ✓ | ✓ | ||
| 指标 | ✓ | ✓ | ||
| 健康检查 | ✓ | ✓ | ||
| 开放追踪 | ✓ | |||
| 开放 API | ✓ | |||
| 类型安全的 REST 客户端 | ✓ |
社区有一个目标,大约每季度发布一个新版本。项目很好地坚持了这个时间表,尽管在 1.0 版本发布后,项目提交给 Eclipse 基金会时出现了延误。需要时间让 Eclipse 基金会对所有现有项目代码和文档进行审查,这是基金会要求的。
Eclipse MicroProfile 为企业 Java 微服务创建规范,其好处是微服务可以在支持 Eclipse MicroProfile 的 JeAS 运行时之间移植。当然,会有一些 JeAS 运行时不实现这些规范,还有一些提供了比定义的更多灵活性的运行时。目标不是涵盖企业 Java 微服务开发的每一个可能用例,而是合作确定一个有偏见的堆栈应该包含哪些内容,以覆盖大多数用例。
在过去的 18 个月里,MicroProfile 社区为解决企业 Java、微服务和云的问题提供了功能。它以协作和包容的方式做到了这一点,随着项目的推进,越来越多的个人贡献者和供应商加入了这一努力。
3.2. 选择足够的应用服务器
现在是时候评估一些最受欢迎的企业 Java 微服务运行时了。您将跟随一个简单的微服务示例应用程序的开发,以展示不同框架之间的差异,包括代码的不同以及每个框架带来的功能集。
每个运行时的示例应用程序的完整代码都可在本书的源代码中找到(github.com/kenfinnigan/ejm-samples)。
3.2.1. 沙滩度假示例应用程序
我们的沙滩度假示例应用程序将是一个简单的购物车,它具有 RESTful 接口,并且有一个类代表购物车中的项目。您将预先填充购物车的内容,包括每个人在沙滩度假时都需要的项目!为了保持简单,您只需在您的 CartItem 中存储名称和数量。
列表 3.1. CartItem
public class CartItem {
private String itemName; *1*
private Integer itemQuantity; *2*
public CartItem(String name, Integer qty) { *3*
this.itemName = name;
this.itemQuantity = qty;
}
public String getItemName() {
return itemName;
}
public CartItem itemName(String itemName) {
this.itemName = itemName;
return this;
}
public Integer getItemQuantity() {
return itemQuantity;
}
public CartItem itemQuantity(Integer itemQuantity) {
this.itemQuantity = itemQuantity;
return this;
}
public CartItem increaseQuantity(Integer itemQuantity) { *4*
this.itemQuantity = this.itemQuantity + itemQuantity;
return this;
}
}
-
1 项目的名称。
-
2 需要购买的数量。
-
3 使用提供的名称和数量构建
CartItem的实例。 -
4 通过指定数量增加数量的便捷方法
你还需要的是你的 RESTful 接口。为了保持简单,你不会使用数据库来存储项目;数据将仅在内存中保留。你的CartController将为你初始化一个项目列表,作为购物车的基础。列表 3.2 是你控制器代码的最简单形式,这样你可以清楚地看到每个框架在类和方法中需要什么。它提供了三个你将通过 REST 提供的功能:all()、addOrUpdateItem()和getItem()。addOrUpdateItem()方法是最复杂的,因为它处理向购物车中现有项目添加数量或添加全新的项目。
列表 3.2. CartController
public class CartController {
private static List<CartItem> items = new ArrayList<>();
static { *1*
items.add(new CartItem("sunscreen", 3));
items.add(new CartItem("towel", 1));
items.add(new CartItem("hat", 5));
items.add(new CartItem("umbrella", 1));
}
public List<CartItem> all() throws Exception {
return items; *2*
}
public String addOrUpdateItem(String itemName, Integer qty) throws
Exception {
Optional<CartItem> item = items.stream()
.filter(i -> i.getItemName().equalsIgnoreCase(itemName))
.findFirst(); *3*
if (item.isPresent()) { *4*
Integer total =
item.get().increaseQuantity(qty).getItemQuantity();
return "Updated quantity of '" + itemName + "' to " + total;
}
items.add(new CartItem(itemName, qty)); *5*
return "Added '" + itemName + "' to shopping cart";
}
public CartItem getItem(String itemName) throws Exception {
return items.stream()
.filter(i -> i.getItemName().equalsIgnoreCase(itemName))
.findFirst()
.get(); *6*
}
}
-
1 为海滩度假填充购物车中常用的项目
-
2 返回购物车中的所有当前项目
-
3 将所有购物车项目流式传输以找到名称匹配的项目
-
4 检查是否通过名称找到了一个项目。如果是,则更新数量。
-
5 项目未在购物车中找到,因此添加它。
-
6 过滤所有购物车项目以找到名称匹配的项目
现在,你已经涵盖了你在海滩度假购物应用程序中需要的两个主要类。在接下来的章节中,你将根据它们的特定要求更新这两个类以适应每个 JeAS 运行时。
注意
以下示例并不总是遵循 REST HTTP 动词和语义的正确使用。这些示例展示了运行时的比较,而不是正确的 REST 模式。
3.2.2. Dropwizard——原始的具有明确观点的微服务运行时
Dropwizard 通过提供开发者构建微服务所需的小型 JeAS 运行时,具有明确的观点。对于 Dropwizard 来说,这意味着以下内容:
-
Eclipse Jetty 作为 HTTP 服务器
-
Jersey 用于 RESTful 端点
-
Jackson 用于将数据转换为 JSON 或从 JSON 转换
-
Hibernate Validator
-
Dropwizard Metrics 用于在生产中提供对代码行为的洞察
除了前面提到的库之外,Dropwizard 还提供了额外的库,以简化微服务的开发。查看www.dropwizard.io获取完整的列表。
如果你的应用程序需要 Dropwizard 为你提供的库,你需要将必要的 Maven 依赖项添加到你的项目中,并添加那些库可能需要的任何配置。
注意
Dropwizard 始于 2011 年初,是第一个为微服务组合具有明确观点的 JeAS 运行时的项目。现在,Dropwizard 已经超过了 1.3.0 版本。
让我们回到之前提到的使用 JAX-RS、CDI 和 JMS 的示例微服务。显示了使用 Dropwizard 的微服务的外观。
图 3.5. Dropwizard 中的微服务使用

当开发使用 Java EE 中的 JAX-RS 之外的其他技术的微服务时,很明显,你需要添加和集成的其他所有内容。虽然这是可能的,但这可能不是最实用的选项,因为它需要在能够开发任何代码之前进行大量的初始项目设置。
正是因为这个原因,Thorntail 而不是 Dropwizard,是我首选的企业 Java 微服务运行时。Dropwizard 只覆盖了所需功能的一小部分。这尤其适用于将现有的企业 Java 应用程序转换为微服务,因为你不希望需要重写所有代码以使用不同的技术。理想情况下,你希望将现有应用程序以不同的方式打包,以便与 JeAS 运行时一起使用。
回到我们的海滩度假购物车,让我们通过使用 Maven 架构生成项目来创建你的 Dropwizard 项目:
mvn archetype:generate -DarchetypeGroupId=io.dropwizard.archetypes
-DarchetypeArtifactId=java-simple -DarchetypeVersion=1.0.9
现在你有了你的项目,让我们修改基本的代码,使其可以与 Dropwizard 一起使用。第一个更改很简单:为你的 CartItem 实体添加默认构造函数。
你的 CartController 需要修改以使其成为 RESTful。首先,你需要定义控制器可从其访问的 RESTful 路径。
列表 3.3. CartController RESTful 路径
@Path("/")
public class CartController {
}
现在你需要将 JAX-RS 注解添加到你的方法中。
列表 3.4. 带有注解的 CartController 方法
@GET *1*
@Produces(MediaType.APPLICATION_JSON)
public List<CartItem> all() throws Exception {}
@GET
@Path("/add") *2*
public String addOrUpdateItem(
@QueryParam("item") String itemName,
@QueryParam("qty") Integer qty) throws Exception { *3*
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/get/{itemName}")
public CartItem getItem(
@PathParam("itemName") String itemName) throws Exception { *4*
}
-
1 表示该方法仅支持 HTTP GET 请求
-
2 端点可通过 /add 访问。
-
3 将通过 URL 传递的参数,例如 /add?item=hat&qty=2
-
4 定义为 URL 路径一部分的参数,/get/hat。
你添加的 JAX-RS 注解没有惊喜,因为它们通常用于 RESTful 端点。你的三个方法都被标注为 @GET。all() 和 getItem() 方法都生成 JSON 输出,所以你添加了 @Produces 来指示正确的媒体类型为 JSON。addOrUpdateItem() 方法可以通过 URL 路径 /add 访问,并且你在方法参数中添加了必要的 @QueryParam 定义。它在调用时根据你传递给 @QueryParam 的名称将 URL 查询字符串参数映射到你的方法参数。最后,getItem() 指定了一个 URL 路径,它定义了路径参数 @Path("/get/{itemName}"),然后通过在参数上设置 @PathParam("itemName") 将其传递给你的方法。
注意
CartController.addOrUpdateItem() 使用 @GET 定义,这打破了正常的 RESTful 语义;它不是一个幂等操作,因为你正在修改数据。但我纯粹为了简单起见选择了这条路线,因为你的对象模型只有两个字段,这使你能够直接从浏览器 URL 调用端点,从而无需使用 curl 或浏览器扩展来 POST 测试数据。
现在,您需要添加自定义类,以便 Dropwizard 知道要运行什么以及如何配置。首先,您需要创建一个配置类,该类指定了应用程序所需的任何特定环境参数。在这个例子中,您不担心环境参数,因此该类可以是空的。
列表 3.5. Chapter3Configuration
public class Chapter3Configuration extends Configuration {
}
最后,您需要从 Dropwizard 扩展 Application,以便您可以指定需要运行的内容。
列表 3.6. Chapter3Application
public class Chapter3Application extends Application<Chapter3Configuration> {
public static void main(final String[] args) throws Exception { *1*
new Chapter3Application().run(args);
}
@Override
public String getName() {
return "chapter3";
}
@Override
public void initialize(final Bootstrap<Chapter3Configuration> bootstrap) { *2*
}
@Override
public void run(final Chapter3Configuration configuration,
final Environment environment) {
final CartController resource = new CartController();
environment.jersey().register(resource); *3*
}
}
-
1 由 Dropwizard 用于启动您的应用程序。
-
2 配置在运行之前需要设置的任何应用程序部分。
-
3 使用 Jersey 注册您的 RESTful 端点实例。
现在您已经构建了应用程序,如何运行它?通过使用 Maven 架构创建项目,它已经添加了构建 uber jar 所需的插件。您需要做的只是确保 pom.xml 中的插件引用了您在需要 mainClass 的任何配置中创建的应用程序类。现在构建应用程序:
mvn clean package
并运行应用程序:
java -jar target/chapter3-dropwizard-1.0-SNAPSHOT.jar server
现在可以通过在浏览器中访问 http://localhost:8080/ 来访问应用程序。这将返回购物车中当前项目的列表。您可以通过导航到 http://localhost:8080/get/hat 查看购物车中 hat 的详细信息。使用 http://localhost:8080/add?item=towel&qty=1 更新现有项目的数量,或者使用 http://localhost:8080/add?item=kite&qty=2 向您的购物车添加新项目。
Dropwizard 还有许多我们没有在这里介绍的其他功能,例如度量标准和健康检查,因此请查看 www.dropwizard.io/1.0.0/docs/index.html 以获取更多信息。
3.2.3. Payara Micro—一个瘦化的 JAR 格式的 Java EE 应用服务器
Payara Micro 与 Dropwizard 类似,它提供了一个有观点的 JeAS 运行时,其中堆栈是定义好的。您想要使用的任何附加库都需要直接添加到应用程序中。
Payara Micro 与您将要查看的其他运行时相比,具有不同的部署模型,因为 Payara 提供了一个可以直接执行的发行版。Payara 的发行版就像一个预构建的应用程序服务器,但它可以通过 java -jar payara-micro.jar 启动,并通过 --deploy myApp.war 作为同一命令的一部分部署一个 WAR 文件。如果没有 --deploy,则发行版将像普通应用程序服务器一样启动,但没有部署任何内容。
注意
Payara Micro 是由 Payara 在提供对 GlassFish v4.x 的修复和增强功能时开发的,它最初作为 Payara Server 的一个子集于 2015 年 5 月首次发布。现在,Payara Micro 已经超过了 5.181 版本。
在传统应用程序服务器中部署应用程序时,拥有一个可部署的应用程序分发版本确实有优势。最大的优势是 Payara Micro 分发可以用作 Docker 层。这使得创建包含该层的 Docker 镜像成为可能,然后可以使用该镜像多次打包不同的应用程序。
这种类型 JeAS 运行时的主要缺点是,无法移除额外的组件。例如,如果您的应用程序只需要 servlets,则无法移除 JAX-RS 等部分。这种方法的优点可能超过这种缺点,但这是一个企业根据其情况做出的决定。我们稍后会介绍一种更灵活的 JeAS 方法。
Payara Micro 提供了什么?图 3.6 将 Payara Micro 分发与 Web Profile 进行了比较。
图 3.6. 与 Web Profile 相比的 Payara Micro

让我们看看您的 JAX-RS、CDI、JMS 微服务如何使用 Payara Micro;请参阅 图 3.7。
图 3.7. Payara Micro 中的微服务使用情况

因为 Payara Micro 不包含 JMS 规范,您需要自己向微服务中添加一个实现。这不是一个大问题,但如果它们已经包含在分发中,则需要包含额外实现会更容易。但这样您又回到了应用程序服务器组件存在但未使用的问题。
要创建您的 Payara Micro 项目,您创建一个常规 Maven WAR 项目,就像您正在开发一个将被部署到应用程序服务器的 Java EE 应用程序一样。您可以添加一个 Maven 依赖项
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-web-api</artifactId>
<version>7.0</version>
<scope>provided</scope>
</dependency>
并获取到应用程序可能需要的所有 API。
因为您还希望使用 JAXB,与 Jackson 一起,您需要添加以下依赖项:
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>2.23.1</version>
</dependency>
现在您已经有了项目,让我们修改基本代码,使其可以与 Payara Micro 一起使用。对于 CartItem 实体,您需要将其标识为可映射到 JAXB,创建一个默认构造函数,并使用正确命名的设置方法。
列表 3.7. 带有 JAXB 映射的 CartItem
@XmlRootElement *1*
public class CartItem {
public CartItem() {
}
...
public CartItem setItemName(String itemName) {
this.itemName = itemName;
return this;
}
...
public CartItem setItemQuantity(Integer itemQuantity) { *2*
this.itemQuantity = itemQuantity;
return this;
}
}
-
1 允许将 Java 类作为 JAXB 映射元素
-
2 方法从 itemQuantity() 更改为 setItemQuantity()
注意
Payara Micro 要求一个实体使用正确的设置方法,就像您在 列表 3.7 中所做的那样。包含 Builder 模式类型的命名设置方法的实体无法正确地序列化为 JSON。
如 列表 3.8 所示,您的 CartController 需要进行相同的修改,以便使其成为 RESTful,就像您为 Dropwizard 做的那样。两者都使用 JAX-RS API 来实现 RESTful 端点。
列表 3.8. 带有 Payara 的 CartController
@Path("/")
public class CartController {
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<CartItem> all() throws Exception {}
@GET
@Path("/add") *1*
public String addOrUpdateItem(
@QueryParam("item") String itemName,
@QueryParam("qty") Integer qty) throws Exception { *2*
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/get/{itemName}")
public CartItem getItem(
@PathParam("itemName") String itemName) throws Exception { *3*
}
}
-
1 端点可通过 /add 访问。
-
2 将要传递到 URL 上的方法参数,例如 /add?item=hat&qty=2
-
3 定义为 URL 路径一部分的参数,/get/hat。
现在你的 RESTful 端点已经定义好了,你需要告诉运行时你希望使其可用。使用 Payara Micro,你可以通过一个自定义的 JAX-RS Application 类来注册你的资源。
列表 3.9. 使用 Payara 的 JaxrsApplication
@ApplicationPath("/")
public class JaxrsApplication extends Application {
@Override
public Set<Class<?>> getClasses() {
Set<Class<?>> resources = new HashSet<>();
resources.add(CartController.class);
return resources;
}
}
你为整个应用程序指定一个 URL 路径,然后将你的 CartController 类添加到应用程序使其对 JAX-RS 运行时实例化的类集合中。
现在你已经开发了这个应用程序,让我们来运行它。在你能够运行它之前,你需要从 www.payara.fish/downloads 下载 Payara Micro 运行时。
注意
下载运行时后,将其文件重命名为 payara-micro.jar 并删除版本信息是有意义的。你不需要这些信息来在本地运行文件,省略这些信息可以使命令行更容易阅读。
因为它是一个常规的 Maven WAR 项目,所以你按照常规方式构建它:
mvn clean package
然后运行应用程序:
java -jar payara-micro.jar --deploy target/chapter3.war
现在可以在浏览器中通过 http://localhost:8080/chapter3/ 访问应用程序。这会返回购物车中当前项目的列表。你可以通过导航到 http://localhost:8080/chapter3/get/hat 查看购物车中 hat 的详细信息。使用 http://localhost:8080/chapter3/add?item=towel&qty=1 更新现有项目的数量,或者使用 http://localhost:8080/chapter3/add?item=kite&qty=2 向购物车添加新项目。
3.2.4. Spring Boot—有观点的 Spring 微服务
Spring Boot 产生于通过遵循约定来移除样板配置的需求。还引入了注解,以提供一种无需配置即可启用 Spring Boot 各部分的方法。
Spring Boot 为你的项目提供了许多作为依赖项的启动器,这些启动器结合了相关的库、框架和配置,以便你在开发微服务时可能需要使用到的许多功能。例如,spring-boot-starter-data-jpa 依赖项引入了使用 Spring 和 JPA 访问数据库所需的所有内容。所有可用的启动器的完整列表可以在 GitHub 仓库中找到:mng.bz/cuQ3。或者查看 start.spring.io,在那里你可以根据你的应用程序需要的启动器创建一个 Maven 项目。
图 3.8 展示了你的 JAX-RS、CDI、JMS 微服务如何使用 Spring Boot。这个微服务最大的挑战可能是重写使用 CDI 的现有代码,以使用 Spring 依赖注入代替。有可用的选项使 CDI 在 Spring 内部工作,但如果你希望项目保持为基于 Spring 的项目,将其重写为使用 Spring 注入更有意义。
图 3.8. Spring Boot 中的微服务使用

使用 启动器,Spring Boot 能够提供一个灵活的 JeAS 运行时环境,可以根据应用程序不断变化的需求进行扩展或缩减。修改应用程序的功能只需添加或删除 Spring Starter 依赖项并重新构建应用程序即可。
如果您不确定可能需要哪些特定的启动器,请访问 start.spring.io 查看选项。该网站包含一个项目生成器,是查看可用的启动器及其提供的功能类型或可能解决的用例的绝佳场所。启动器适用于常规开发任务,如数据库访问,也适用于微服务编程模式,如断路器和服务发现。
注意
Spring Boot 于 2012 年 10 月启动,并已超过版本 1.5.10。
您可以使用 start.spring.io 创建一个包含 Web 启动器的项目。这将为您提供包含以下依赖项的 pom.xml 文件:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
现在您有了项目,让我们修改基本代码,使其可以与 Spring Boot 一起使用。对于 CartItem 实例,您只需向其中添加 @XmlRootElement。对于您的 CartController,您需要添加必要的注解使其成为 RESTful 端点。这与您的基于 JAX-RS 的注解类似,但名称略有不同。
列表 3.10. CartController 基于 Spring Boot
@RestController *1*
public class CartController {
@RequestMapping( *2*
method = RequestMethod.GET,
path = "/",
produces = "application/json")
public List<CartItem> all() throws Exception {}
@RequestMapping(
method = RequestMethod.GET,
path = "/add",
produces = "application/json")
public String addOrUpdateItem(
@RequestParam("item") String itemName, *3*
@RequestParam("qty") Integer qty) throws Exception {
}
@RequestMapping(
method = RequestMethod.GET,
path = "/get/{itemName}", *4*
produces = "application/json")
public CartItem getItem(
@PathVariable("itemName") String itemName) *5*
throws Exception {
}
}
-
1 告诉 Spring 该类将提供 RESTful 端点方法
-
2 方法将在 HTTP GET 请求的 / 路径上可用。
-
3 一个名为 item 的 URL 查询参数将被映射到这个方法参数。
-
4 预期在 /get/ 之后为该端点映射 URL 路径变量。
-
5 URL 路径变量将被映射到这个方法参数。
您控制器上的每种方法都提供与您的其他 JAX-RS 示例相同的详细信息,但仅在一个注解中。@RequestMapping 包含了您使用 @GET、@Produces 和 @Path 的 JAX-RS 示例中所有信息。另一个区别是,JAX-RS 中的 @QueryParam 在 Spring 中对应于 @RequestParam,而 JAX-RS 中的 @PathParam 在 Spring 中对应于 @PathVariable。
注意
Spring 为 @RequestMapping 也提供了快捷方式。您可以使用 @GetMapping(path = "/", produces = "application/json") 而不是 @RequestMapping(method = RequestMethod.GET, path = "/", produces = "application/json")。
您的 RESTful 端点现在已经定义,所以最后您创建您的 Spring Boot 应用程序类。
列表 3.11. Chapter3SpringBootApplication
@SpringBootApplication
public class Chapter3SpringBootApplication {
public static void main(String[] args) {
SpringApplication.run(Chapter3SpringBootApplication.class, args);
}
}
您在这里所做的只是说明 main() 应该激活 @SpringBootApplication。您将在该类中添加您想要激活的 Spring Boot 各个部分的附加注解。
现在您已经开发了这个应用程序,让我们来运行它。使用 Spring Boot,您有几种运行应用程序的选项:
-
从命令行运行
-
构建项目和运行 uber jar
提供多个执行选项允许开发者选择最适合他们情况的方法。例如,在进行大量迭代开发时,从命令行运行可能更快,因为它不需要在每次更改时都构建项目。但是,当开发者想要验证类似生产环境的行为时,运行 uber jar 将提供更准确的生产反映。并不是说一种方法比另一种方法有问题,但总是更倾向于在生产部署前在一个反映生产执行方式的环境和方式中验证应用程序。
要在没有使用 Maven 构建你的应用程序的情况下从命令行运行,你可以使用以下命令启动 Spring Boot 服务器:
mvn spring-boot:run
这使用 Spring Boot 的 Maven 插件来执行应用程序,就像它已经被打包成一个 uber jar 一样。
另一种方法是构建一个 uber jar。你像往常一样构建 Maven 项目:
mvn clean package
然后运行应用程序:
java -jar target/chapter3-spring-boot-1.0-SNAPSHOT.jar
现在可以在浏览器中通过 http://localhost:8080/访问应用程序。这将返回购物车中当前项目的列表。你可以通过导航到 http://localhost:8080/get/hat 查看购物车中帽子的详细信息。使用 http://localhost:8080/add?item=towel&qty=1 更新现有项目的数量,或者使用 http://localhost:8080/add?item=kite&qty=2 向购物车添加新项目。
3.2.5. Thorntail——最灵活的 JeAS 运行时
Thorntail 的诞生源于利用 WildFly 应用程序服务器内模块化的愿望。这一努力使得不同的模块组可以被收集并安装到服务器中以供使用。这也使得 Thorntail 成为可用的最灵活的 Java EE 运行时。现在,选择与你的应用程序一起使用的单个 Java EE 功能变得非常简单。
Thorntail 定义了应用程序可以包含的每个依赖项,例如 JPA、JAX-RS 和 Java EE 的大部分内容。除了 Java EE 依赖项之外,Thorntail 还提供了有助于开发企业 Java 微服务的库的依赖项,例如 Swagger、Keycloak 以及其他框架和库。
如果你不确定你的应用程序可能需要的 Thorntail 依赖项,你有几个选择。你可以通过访问wildfly-swarm.io/generator并选择你需要的微服务功能类型来生成一个骨架项目。这里有 Java EE 功能和非 Java EE 功能的选择,例如 Eclipse MicroProfile、Hibernate Search、容错性和安全性等,仅举几例。
如果你不确定需要哪些依赖项,开发你的 Thorntail 应用程序的另一种选择是将 Maven 插件添加到你的 pom.xml 中,并允许插件自动检测依赖项。自动检测 会检查你的应用程序代码以确定正在使用哪些 API,因此需要哪些依赖项。这通常是使用 Thorntail 的最简单方法,尤其是在从现有的 Java EE 应用程序转换时;因为它允许应用程序的其他部分保持不变,因为你只向 pom.xml 中添加了一个新的插件。
注意
虽然 自动检测 对于入门来说很容易,但这确实意味着与直接指定依赖项相比,插件打包应用程序要慢一些。
在开发者对可用的依赖项更加熟悉或需要插件无法检测到的依赖项后,切换到使用直接 Maven 依赖项很容易。查看构建项目时的日志输出是查看插件检测到的依赖项的一个简单方法。然后可以使用该列表作为需要添加的 Maven 依赖项的集合。
注意
Thorntail 于 2015 年 2 月成立,现已超过 2.2.0.Final 版本。该项目于 2018 年 5 月从 WildFly Swarm 重命名为 Thorntail。
图 3.9 展示了你的 JAX-RS、CDI、JMS 微服务如何在 Thorntail 中使用规范。在这里,你可以看到 Thorntail 提供了你的微服务所需的一切——不多也不少。Thorntail 提供了理想的 JeAS 运行时,因为它总是给你足够的资源,让你的微服务运行。没有其他 JeAS 运行时能如此紧密地匹配你的应用程序需求。其他运行时有一些未使用的部分,或者需要你在应用程序中包含额外的库。
图 3.9. Thorntail 中的微服务使用

要创建你的项目,你将使用一个基本的 Maven WAR 项目,并添加以下插件定义。
列表 3.12. 插件配置
<plugin>
<groupId>io.thorntail</groupId>
<artifactId>thorntail-maven-plugin</artifactId> *1*
<version>2.2.0.Final</version> *2*
<executions>
<execution>
<goals>
<goal>package</goal> *3*
</goals>
</execution>
</executions>
</plugin>
-
1 Thorntail 插件的工件 ID
-
2 Thorntail 的版本
-
3 在打包阶段执行插件。
然后你在提供的范围内添加 Java EE Web API:
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-web-api</artifactId>
<version>7.0</version>
<scope>provided</scope>
</dependency>
现在你有了你的项目,让我们修改基本的代码,使其能够与 Thorntail JeAS 运行时一起运行。
对于 CartItem 实体,你只需要向其中添加 @XmlRootElement。CartController 需要与你为 Payara Micro 和 Dropwizard 添加的相同的 JAX-RS 注解。最后,你需要一个 Application 类来激活 JAX-RS。
列表 3.13. Thorntail 下的 JaxrsApplication
@ApplicationPath("/")
public class JaxrsApplication extends Application {
}
现在你已经开发了这个应用程序,让我们来运行它。使用 Thorntail,你有几种运行应用程序的选择:
-
从命令行运行
-
构建项目和运行 fat jar
与 Spring Boot 类似,Thorntail 提供了根据开发者的需求运行应用程序的灵活性。在没有使用 Maven 构建您的应用程序的情况下,您可以使用以下方式启动 Thorntail JeAS 运行时:
mvn thorntail:run
这使用 Thorntail 的 Maven 插件来执行应用程序,就像它被打包成一个超级 JAR 一样。
另一种方法是构建一个超级 JAR。你像往常一样构建 Maven 项目:
mvn clean package
并运行应用程序:
java -jar target/chapter3-thorntail.jar
现在您可以在浏览器中访问 http://localhost:8080/ 以访问应用程序。这返回购物车中当前项目的列表。您可以通过导航到 http://localhost:8080/get/hat 查看购物车中帽子的详细信息。使用 http://localhost:8080/add?item=towel&qty=1 更新现有项目的数量,或使用 http://localhost:8080/add?item=kite&qty=2 向购物车添加新项目。
3.2.6. 它们是如何比较的?
您已经查看了一些 JeAS 运行时以及每个运行时为简单应用程序(暴露几个 RESTful 端点)的代码差异。让我们比较一下 JeAS 运行时的某些功能表 3.2。
表 3.2. JeAS 运行时比较
| 功能 | Dropwizard | Payara Micro | Spring Boot | Thorntail |
|---|---|---|---|---|
| 依赖注入 (DI) | ✓ | ✓ | ✓ | |
| 超级 JAR 打包 | ✓ | ✓ | ✓ | |
| WAR 部署 | ✓ | ✓ | ✓ | |
| Maven 插件运行 | ✓ | ✓ | ||
| 项目生成器 | ✓ | ✓ | ✓ | |
| 自动检测依赖项 | ✓ | |||
| Java EE API | ✓ | ✓ |
当涉及到为您的应用程序或企业选择最佳的 JeAS 运行时,许多因素都会发挥作用。以下是一些更关键的因素:
-
您是否有 Java EE 或 Spring 的经验和知识?
-
生产中首选的打包方法是什么?
-
您是否有任何非 JeAS 运行时框架的经验?
这些只是影响选择哪个 JeAS 框架作为应用程序首选的一些因素。可能 Thorntail 是具有先前 Java EE 经验的开发者的首选选择,但寻找不需要许多 Java EE API 的简单堆栈的开发者可能会选择 Dropwizard。
摘要
-
JeAS 允许打包足够的运行时以及一个微服务。在本章中涵盖的运行时中,Thorntail 是最可定制的 JeAS 运行时。
-
您通过使用 JeAS 运行时选择企业 Java 应用程序服务器的一部分,仅选择您需要的部分。
-
JeAS 运行时是 RESTful 微服务的完美部署方法。
-
MicroProfile 为云原生微服务开发提供了关键功能。
第四章. 微服务测试
本章涵盖
-
您需要考虑哪些类型的测试?
-
哪些工具适用于微服务?
-
为微服务实现单元测试
-
为微服务实现集成测试
-
使用消费者驱动的合同测试
从哪里开始!对于任何事物,都可以实施如此多种类和级别的测试。进一步复杂化的是,不同的人可能会有不同的观点,特别是关于各种测试类型应该实现什么目标。
让我们就测试类型达成共识,并共同理解它们对我们所有人的意义!在本章中,你将只关注与我们目的相关的测试类型。测试类型太多,难以全部涵盖;这会变得令人不知所措。
然后,你将使用在第二章中创建的管理服务来展示可以使用微服务执行的测试类型。
4.1. 你需要哪种类型的测试?
本章涵盖了三种测试类型:
-
单元测试专注于测试您的微服务的内部结构。
-
集成测试涵盖了您的整个服务,以及它与外部服务(如数据库)交互的方式。
-
消费者驱动的契约测试处理您的微服务消费者与微服务本身之间的边界,通过定义契约的 Pact 文档。
重要的是要注意,单元和集成测试远非新概念。它们已经软件开发的组成部分几十年了。集成测试在微服务中的应用可能会通过更多的外部集成点增加其复杂性,但我们的开发方式并没有发生很大变化。
为什么我会选择这三种测试类型来重点关注,尽管有数十种类型可供选择?我并不是说这三种是唯一需要关注的类型,但它们确实对于确保微服务尽可能健壮至关重要。单元和集成测试专注于确保您作为微服务的开发者所编写的代码符合为微服务概述的要求。消费者驱动的契约测试将视角转向从微服务外部看,以确保微服务可以正确处理客户端传递给您的任何内容。尽管这可能不是微服务的要求之一,但客户端可能期望的行为与已开发的行为略有不同。图 4.1 展示了三种测试类型在您的代码中的位置。
图 4.1. 测试类型

关于任何类型测试的关键点在于,你并不是为了乐趣或一次性执行而编写测试。编写任何测试的目的和好处是能够持续地对代码进行执行,代码在变化和修改时也是如此,通常作为持续集成过程的一部分,该过程定期构建你的代码。你为什么想让这些测试一直运行在旧代码和更新后的代码上?简单来说,这是因为它减少了进入生产代码的错误或缺陷的数量。正如我在第一章中提到的,你可以做任何减少你被要求处理生产缺陷次数的事情,这对你是更好的。
4.2. 单元测试
通常由开发者在编写代码时创建,单元测试用于测试类及其方法内部的行为。这样做通常需要模拟或存根来模仿外部系统的行为。
注意
存根和模拟是你可以使用的工具,可以使与外部服务(如数据库)交互的代码进行单元测试成为可能,而无需数据库。尽管它们服务于相同的目的,但它们以不同的方式运行。存根是开发者手工编写的服务的实现,为每个方法返回预定义的响应。模拟提供了更大的灵活性,因为每个测试都可以为特定测试设置你期望方法返回的内容,然后验证模拟是否按预期行事。使用模拟进行测试要求每个测试设置被调用服务的期望,并在之后进行验证,但这样可以节省你将所有可能的测试情况写入存根中的时间。
你为什么需要单元测试呢?你需要确保类上的方法按预期执行功能。如果一个方法有传递给它的参数,这些参数应该得到验证以确保它们是合适的。这可能只是确保值不是null那么简单,也可能像验证电子邮件地址格式那样复杂。同样,你需要验证传递特定输入作为参数返回的结果是否符合你的预期。单元测试是测试的最低级别,但通常是正确执行的最关键部分。如果你的最小代码单元,即方法,没有按预期执行,那么你的整个服务可能会运行不正确。
在这个测试级别上,最流行和最广泛使用的框架是 JUnit (junit.org/) 和 TestNG (testng.org/doc/)。JUnit 存在的时间最长,也是 TestNG 被创建的灵感来源。它们的功能之间没有太多差异,在某些情况下,甚至注解的名称也没有太多差异!
它们最大的区别在于目标。JUnit 的关注点纯粹是单元测试,并且是推动测试驱动开发采用的一个巨大动力。TestNG 旨在支持比单元测试更广泛的测试用例。
开发者选择哪个完全是个人选择。在任何时候,JUnit 或 TestNG 可能比另一个有更多功能,但其他很快就会赶上。这样的来回已经发生了很多次。
从第二章的代码已经被复制到/chapter4/admin,以便你可以在添加测试后看到代码中的差异。这尤其重要,可以展示出修复任何令人讨厌的错误所需的相关的代码更改。对于编写我们的单元测试,我使用 JUnit,仅仅因为我在我职业生涯中使用了最多的这个框架,并且我对它最熟悉。
管理微服务目前专注于Category模型的 CRUD 操作,并有一个 JAX-RS 资源来提供与它交互的 RESTful 端点。
由于你正在处理单元测试,Category是你可以测试的唯一有效代码,无需模拟数据库。当然,你可以模拟EntityManager来测试 JAX-RS 资源,但最好是将其与数据库一起作为集成测试的一部分进行全面测试。
你需要做的第一件事是在 pom.xml 中添加测试依赖项:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.easytesting</groupId>
<artifactId>fest-assert</artifactId>
<scope>test</scope>
</dependency>
现在我们来看看Category的一些单元测试,因为它是运行时任何方法执行堆栈中的最低层。
列表 4.1. CategoryTest
public class CategoryTest {
@Test
public void categoriesAreEqual() throws Exception { *1*
LocalDateTime now = LocalDateTime.now();
Category cat1 = createCategory(1, "Top", Boolean.TRUE, now); *2*
Category cat2 = createCategory(1, "Top", Boolean.TRUE, now);
assertThat(cat1).isEqualTo(cat2); *3*
assertThat(cat1.equals(cat2)).isTrue();
assertThat(cat1.hashCode()).isEqualTo(cat2.hashCode());
}
@Test
public void categoryModification() throws Exception { *4*
LocalDateTime now = LocalDateTime.now();
Category cat1 = createCategory(1, "Top", Boolean.TRUE, now);
Category cat2 = createCategory(1, "Top", Boolean.TRUE, now);
assertThat(cat1).isEqualTo(cat2);
assertThat(cat1.equals(cat2)).isTrue();
assertThat(cat1.hashCode()).isEqualTo(cat2.hashCode());
cat1.setVisible(Boolean.FALSE);
assertThat(cat1).isNotEqualTo(cat2);
assertThat(cat1.equals(cat2)).isFalse();
assertThat(cat1.hashCode()).isNotEqualTo(cat2.hashCode());
}
@Test
public void categoriesWithIdenticalParentIdAreEqual() throws Exception { *5*
LocalDateTime now = LocalDateTime.now();
Category parent1 = createParentCategory(1, "Top", now);
Category parent2 = createParentCategory(1, "Tops", now);
Category cat1 = createCategory(5, "Top", Boolean.TRUE, now, parent1);
Category cat2 = createCategory(5, "Top", Boolean.TRUE, now, parent2);
assertThat(cat1).isEqualTo(cat2);
assertThat(cat1.equals(cat2)).isTrue();
assertThat(cat1.hashCode()).isEqualTo(cat2.hashCode());
}
private Category createCategory(Integer id, String name, Boolean visible,
LocalDateTime created, Category parent) { *6*
return new TestCategoryObject(id, name, null,
visible, null, parent, created, null, 1);
}
}
-
1 测试验证两个 Category 实例在所有方面都被视为相等。
-
2 在测试中使用了辅助方法来创建所需的任何 Category 实例
-
3 使用 Fest Assertions 的流畅方法简化测试代码
-
4 测试确保在调用 setter 后 Category 不同。
-
5 测试具有相同 ID 的父 Category 是否被视为相等。
-
6 用于创建测试用例的 Category 实例的辅助方法。
你可能已经注意到在测试类的createCategory()方法中,你实例化了TestCategoryObject类。它从哪里来的?TestCategoryObject在我们的测试中有一个重要的用途。因为它扩展了Category,你可以直接设置只有 getter 方法的Category上的字段,例如id和version。这允许你保留Category的重要不可变部分,同时仍然能够设置和更改测试所需的Category属性。TestCategoryObject提供了两个构造函数,允许你设置Category的 ID,这在测试中非常有用。查看章节代码(在 GitHub 上或从www.manning.com/books/enterprise-java-microservices下载)以获取完整的代码列表。
4.3. 什么是不可变性?
不可变性是面向对象编程中的一个概念,用于确定一个对象的状态是否可以被改变。如果一个对象在创建后不能被改变,则认为其状态是不可变的。
在我们的案例中,Category并非完全不可变,但id、created和version是您希望不可变的字段。因此,Category只为它们定义了 getter 方法,没有 setter 方法。
要从/chapter4/admin 内部使用 Maven 运行测试,您运行以下命令:
mvn test -Dtest=CategoryTest
当使用来自第二章的现有代码运行CategoryTest时,您会看到失败!categoriesWithIdenticalParentIdAreEqual()测试失败,因为它没有将两个类别视为相等。
对于任何测试失败,都存在两种可能的情况。您在测试中是否做出了错误的断言,或者您的代码中是否存在错误?
在这种情况下,您期望具有相同 ID 但不同名称的Category对象相等吗?一种直觉可能认为不应该相等,它们不应该相等。但针对这种情况,您需要记住,ID 是Category的唯一标识符,因此您会期望只有一个具有任何特定 ID 的Category对象存在。因此,很明显,您的测试断言是正确的,因为Category的名称可能在后续请求中已被修改,但您的代码在确定Category是否相等的方式中存在一个错误。
让我们看看您目前在Category上自动生成的equals()实现:
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Category category = (Category) o;
return Objects.equals(id, category.id) &&
Objects.equals(name, category.name) &&
Objects.equals(header, category.header) &&
Objects.equals(visible, category.visible) &&
Objects.equals(imagePath, category.imagePath) &&
Objects.equals(parent, category.parent) &&
Objects.equals(created, category.created) &&
Objects.equals(updated, category.updated) &&
Objects.equals(version, category.version);
}
您可以看到,您正在比较每个Category实例的parent的全部内容。正如您在测试中看到的那样,具有相同 ID 但不同名称的父Category将失败等性测试。
从我们之前讨论的内容来看,比较一个父类别的整个状态与另一个父类别的状态是没有意义的。总有可能在检索一个类别实例之后,另一个类别实例被检索,并且在两次检索之间,父类别可能被更新为不同的名称。尽管 ID 相同,但两个实例之间的其他状态不同。
您可以通过只关注父类别的 ID,而不是整个对象状态来解决这个问题:
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Category category = (Category) o;
return Objects.equals(id, category.id) &&
Objects.equals(name, category.name) &&
Objects.equals(header, category.header) &&
Objects.equals(visible, category.visible) &&
Objects.equals(imagePath, category.imagePath) &&
(parent == null ? category.parent == null
: Objects.equals(parent.getId(), category.parent.getId())) &&
Objects.equals(created, category.created) &&
Objects.equals(updated, category.updated) &&
Objects.equals(version, category.version);
}
在这里,您已修改了父类等性检查,以验证任一父类是否为null,然后再比较 ID 值是否相等。这个更改使您的代码更加健壮,且错误倾向降低。
需要对Category.hashCode()进行类似的更改,以确保在为Category实例生成哈希值时仅包含父类别 ID。
您刚刚看到一些简短的单元测试如何通过减少潜在的错误可能性来帮助改进您的内部代码。让我们再进一步,编写一些集成测试!
4.4. 集成测试
集成测试类似于单元测试,并使用相同的框架,但它也用于测试微服务与外部系统的交互。这可能包括数据库、消息系统、其他微服务,或者几乎任何它需要与之通信的、不是微服务内部代码的东西。如果你有使用模拟或存根与外部系统集成的单元测试,作为集成测试的一部分,模拟和存根将被替换为对实际系统的调用。移除模拟或存根将使你的代码暴露于之前未测试过的执行路径,同时随着你需要测试那些外部系统中错误处理的需要,引入更多的测试场景。
根据微服务集成的系统类型,可能无法在本地开发者的机器上执行这些测试。集成测试非常适合持续改进环境,那里资源更丰富,所需的任何系统都可以安装。
通过集成测试,你可以扩展你打算测试和验证的范围,并确保它按预期工作。它还允许你使用外部系统作为测试的一部分,而不是模拟任何外部系统。使用微服务在生产中依赖的实际服务和系统进行测试,可以极大地提高你对代码更改投入生产不会导致错误的信心。你不会在生产系统上运行集成测试,但可以在与生产设置和数据紧密相似的系统上运行它们。
为了帮助开发集成测试,你将使用 Arquillian。Arquillian 是一个高度可扩展的 JVM 测试平台,它允许轻松创建集成、功能和验收测试。Arquillian 的核心存在许多扩展,用于处理特定的框架,例如 JSF,或者用于与 Selenium 集成进行浏览器测试。有关 Arquillian 可用所有扩展的详细信息,请参阅 arquillian.org/。
我选择 Arquillian 来帮助进行集成测试,因为它有助于尽可能接近地复制生产环境,而不必在生产环境中进行。你的服务将在与生产相同的运行时容器中启动,因此你的服务可以访问 CDI 注入、持久性或你的服务需要的任何运行时组件。
要能够使用 Arquillian 进行集成测试,你需要将必要的依赖项添加到你的 pom.xml 文件中:
<dependency>
<groupId>io.thorntail</groupId>
<artifactId>arquillian</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.junit</groupId>
<artifactId>arquillian-junit-container</artifactId>
<scope>test</scope>
</dependency>
第一个依赖项添加了 Thorntail 在 Arquillian 测试中使用的运行时容器,第二个添加了 Arquillian 和 JUnit 之间的集成。为了 Arquillian 能够 部署 任何内容,它需要访问运行时容器。Thorntail 的 arquillian 依赖项将自身注册为运行时容器,使 Arquillian 能够将其部署到其中。如果没有这些之一,你将无法在运行时容器中执行集成测试。
为了简化在测试中执行 HTTP 请求所需的代码,你将使用 REST Assured,这也需要添加到你的 pom.xml 中:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
你的集成测试的重点将是 JAX-RS Resource 类,因为它定义了消费者将与之交互的 RESTful 端点,以及持久化对数据库的更改。通过集成测试,我们关注微服务交互的提供方——你只是在验证你的服务 API 是否按你设计的方式工作。这并不考虑消费者对你 API 的期望;这将在消费者驱动的合同测试中处理。
首先,如列表 4.2 所示,你需要创建一个测试来验证从数据库中正确检索所有类别。这个单一的集成测试将验证你的外部 API 是否返回预期的信息,以及验证你的持久化代码是否正确读取数据库条目以返回。如果这两个方面中的任何一个没有按预期工作,测试将失败。
列表 4.2. 在 4.1 列表中检索所有类别
@RunWith(Arquillian.class) *1*
@DefaultDeployment *2*
@RunAsClient *3*
@FixMethodOrder(MethodSorters.NAME_ASCENDING) *4*
public class CategoryResourceTest {
@Test
public void aRetrieveAllCategories() throws Exception {
Response response = *5*
when()
.get("/admin/category")
.then()
.extract().response();
String jsonAsString = response.asString();
List<Map<String, ?>> jsonAsList =
JsonPath.from(jsonAsString).getList("");
assertThat(jsonAsList.size()).isEqualTo(21); *6*
Map<String, ?> record1 = jsonAsList.get(0); *7*
assertThat(record1.get("id")).isEqualTo(0);
assertThat(record1.get("parent")).isNull();
assertThat(record1.get("name")).isEqualTo("Top");
assertThat(record1.get("visible")).isEqualTo(Boolean.TRUE);
}
}
-
1 使用 Arquillian 运行器进行 JUnit 测试。
-
2 根据 Thorntail 项目的类型(WAR 或 JAR)创建 Arquillian 部署。
-
3 你正在测试微服务的 RESTful 端点,因此你作为客户端执行测试。
-
4 根据名称顺序运行测试方法。
-
5 REST Assured 执行 HTTP 请求的流畅方法
-
6 验证你是否收到了预期的所有数据库类别。
-
7 从列表中检索单个类别记录,然后验证其值。
在你的测试中的第一行是告诉 JUnit,通过 @RunWith,你想要使用 Arquillian 测试运行器。@DefaultDeployment 通知 Thorntail 与 Arquillian 集成以创建一个 Arquillian 部署来执行测试,这将使用 Maven 项目的类型来创建用于部署的 WAR 或 JAR。
测试类上的另一个关键注解是 @RunAsClient。这个注解告诉 Arquillian,你想要将部署视为黑盒,并从容器外部执行测试。如果不包含这个注解,将向 Arquillian 表明测试是打算在容器内执行的。还可能混合使用 @RunAsClient 在单个测试方法上,但在这个案例中,你是在容器外部完全测试。
测试本身在 "/admin/category" 上执行 HTTP GET 请求,并将响应 JSON 转换为包含键/值对的映射列表。你验证你得到的列表的大小与你知道数据库中存在的 Category 记录数相匹配,然后从列表中检索第一个映射并断言 Category 的详细信息与数据库中的顶级类别匹配。
与单元测试一样,你使用以下命令执行集成测试:
mvn test
随着测试的执行,你会看到 Thorntail 容器启动,并执行 SQL 语句将初始类别记录插入数据库,正如在第二章中讨论的那样。在这个第一次测试运行中,你有一个成功的测试 Category-ResourceTest,以及现有的 CategoryTest 单元测试。
让我们添加一个直接检索单个类别的测试,同时将你接收到的 JSON 映射到 Category 对象上以验证反序列化是否正常工作。这个测试与之前的测试不同,因为它使用 JPA 的 EntityManager 上的不同方法来检索单个 Category 而不是所有类别。双重奖励是,你不仅测试了你的 JAX-RS 资源上的额外方法,而且还验证了你的持久化和数据库实体是否正确定义。
列表 4.3. 在 CategoryResourceTest 中检索类别
@Test
public void bRetrieveCategory() throws Exception {
Response response =
given()
.pathParam("categoryId", 1014) *1*
.when()
.get("/admin/category/{categoryId}") *2*
.then()
.extract().response();
String jsonAsString = response.asString();
Category category = JsonPath.from(jsonAsString).getObject("",
Category.class); *3*
assertThat(category.getId()).isEqualTo(1014);
assertThat(category.getParent().getId()).isEqualTo(1011);
assertThat(category.getName()).isEqualTo("Ford SUVs");
assertThat(category.isVisible()).isEqualTo(Boolean.TRUE);
}
-
1 在请求中设置一个参数用于 categoryId。
-
2 指定在 URL 路径中应添加 categoryId 的位置。
-
3 通过反序列化将你收到的 JSON 转换为 Category 实例。
如果你现在再次执行测试,你的新测试会失败,并出现以下错误:
com.fasterxml.jackson.databind.JsonMappingException: Unexpected token
(START_OBJECT), expected VALUE_STRING: Expected array or string.
在日志中的错误之后是您收到的 JSON 消息,但最后它引用了导致问题的数据片段,ejm.chapter4.admin.model.Category ["created"]。从这一点,你知道测试在将 Category 上的 created 字段反序列化为 LocalDateTime 实例时遇到了问题。
为了解决这个问题,你需要给 JSON 序列化库,在这种情况下是 Jackson,提供帮助以将你的 LocalDateTime 实例转换为库知道如何反序列化的 JSON。为了给 Jackson 提供帮助,你需要注册一个 JAX-RS 提供者,向 Jackson 添加配置 JavaTimeModule。不过,首先,你需要向 pom.xml 添加一个依赖项,使其可用:
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
现在,让我们看看提供者:
列表 4.4. ConfigureJacksonProvider
@Provider *1*
public class ConfigureJacksonProvider implements
ContextResolver<ObjectMapper> { *2*
private final ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule()); *3*
@Override
public ObjectMapper getContext(Class<?> type) {
return mapper;
}
}
-
1 将类标识为 JAX-RS 提供者。
-
2 指定此提供者用于解析 ObjectMapper 实例
-
3 将 JavaTimeModule 与 Jackson mapper 注册以正确序列化 LocalDateTime。
重新运行 mvn test,你会看到测试通过。另一个通过测试解决的错误!
你现在已经涵盖了从你的 RESTful 端点检索类别的两种情况。让我们看看你的 JAX-RS 资源是否也能存储数据。
列表 4.5. 在 CategoryResourceTest 中创建类别
@Test
public void cCreateCategory() throws Exception {
Category bmwCategory = new Category();
bmwCategory.setName("BMW");
bmwCategory.setVisible(Boolean.TRUE);
bmwCategory.setHeader("header");
bmwCategory.setImagePath("n/a");
bmwCategory.setParent(new TestCategoryObject(1009));
Response response =
given()
.contentType(ContentType.JSON) *1*
.body(bmwCategory) *2*
.when()
.post("/admin/category");
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(201); *3*
String locationUrl = response.getHeader("Location"); *4*
Integer categoryId = Integer.valueOf(
locationUrl.substring(locationUrl.lastIndexOf('/') + 1) *5*
);
response =
when()
.get("/admin/category")
.then()
.extract().response();
String jsonAsString = response.asString();
List<Map<String, ?>> jsonAsList =
JsonPath.from(jsonAsString).getList("");
assertThat(jsonAsList.size()).isEqualTo(22); *6*
response =
given()
.pathParam("categoryId", categoryId) *7*
.when()
.get("/admin/category/{categoryId}") *8*
.then()
.extract().response();
jsonAsString = response.asString();
Category category =
JsonPath.from(jsonAsString).getObject("", Category.class); *9*
assertThat(category.getId()).isEqualTo(categoryId); *10*
...
}
-
1 指明您正在发送 JSON 到 HTTP 请求。
-
2 将您创建的类别实例设置为请求的主体。
-
3 确认您收到了状态码为 201 的响应,并且已成功创建了类别。
-
4 位置将是为您创建的类别的 URL。
-
5 从位置中提取您创建的类别的 ID。
-
6 断言检索到的类别总数现在是 22 而不是 21。
-
7 为新的 GET 请求设置路径参数,即从位置检索到的类别 ID。
-
8 设置请求的路径,定义需要替换类别 ID 参数的位置。
-
9 将您收到的 JSON 反序列化为类别实例。
-
10 验证类别的 ID 与您从位置提取的 ID 相匹配。
前面的测试首先创建一个新的Category实例,并在其上设置适当的值,包括设置具有id为1009的父类别。接下来,您向Category的 RESTful 端点提交 POST 请求以创建新记录。您验证收到的响应是否正确,并提取类别的新的id。然后,您检索所有类别并验证现在有 22 条记录而不是 21 条,最后检索新记录并验证其信息与您创建时提交的信息相同。
让我们再次运行mvn test以查看您的代码是否存在任何错误!这次,您的测试失败,因为它期望收到状态码为 201 的 HTTP 响应,但您收到了 500。发生了什么?如果您通过终端输出回溯,您可以看到微服务遇到了错误:
Caused by: org.hibernate.TransientPropertyValueException:
object references an unsaved transient instance
- save the transient instance before flushing:
ejm.chapter4.admin.model.Category.parent ->
ejm.chapter4.admin.model.Category
您可以看到它无法保存您指定的父类别的链接。这是因为您提供给 POST 的实例上没有任何数据可以帮助持久层理解这个实例已经被保存。
要解决这个问题,您需要在尝试保存新的类别之前,从持久层检索父类别的持久化对象。
列表 4.6. CategoryResource
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public Response create(Category category) throws Exception {
if (category.getId() != null) {
return Response
.status(Response.Status.CONFLICT)
.entity("Unable to create Category, id was already set.")
.build();
}
Category parent;
if ((parent = category.getParent()) != null && parent.getId() != null) {*1*
category.setParent(get(parent.getId())); *2*
}
try {
em.persist(category);
} catch (Exception e) {
return Response
.serverError()
.entity(e.getMessage())
.build();
}
return Response
.created(new URI("category/" + category.getId().toString()))
.build();
}
-
1 在尝试检索之前,检查您是否有一个父类别及其 ID。
-
2 获取父类别并将其设置到新的类别实例上。
您所做的一切就是在create()方法中添加了从持久层检索有效父类别的功能,然后将它设置到新的类别实例上。该方法中的其他一切都与第二章中的内容相同。
重新运行mvn test,您现在看到所有测试都通过了!让我们添加一个额外的测试,以查看您的错误处理是否能够正确拒绝一个无效的请求。
列表 4.7. 在CategoryResourceTest中未能创建类别
@Test
public void dFailToCreateCategoryFromNullName() throws Exception {
Category badCategory = new Category(); *1*
badCategory.setVisible(Boolean.TRUE);
badCategory.setHeader("header");
badCategory.setImagePath("n/a");
badCategory.setParent(new TestCategoryObject(1009));
Response response =
given()
.contentType(ContentType.JSON)
.body(badCategory)
.when()
.post("/admin/category");
assertThat(response).isNotNull();
assertThat(response.getStatusCode()).isEqualTo(400); *2*
...
response =
when()
.get("/admin/category")
.then()
.extract().response();
String jsonAsString = response.asString();
List<Map<String, ?>> jsonAsList =
JsonPath.from(jsonAsString).getList("");
assertThat(jsonAsList.size()).isEqualTo(22); *3*
}
-
1 创建一个未设置名称的类别实例。
-
2 应该收到 HTTP 状态码 400。
-
3 验证数据库中仍然只有 22 个类别。
使用这个新的测试方法运行 mvn test 会导致失败。你的测试期望得到一个 400 响应码,但你收到了 500。
滚动查看终端输出,你看到如下内容:
Caused by: javax.validation.ConstraintViolationException:
Validation failed for classes [ejm.chapter4.admin.model.Category]
during persist time for groups [javax.validation.groups.Default, ]
List of constraint violations:[
ConstraintViolationImpl{
interpolatedMessage='may not be null', propertyPath=name,
rootBeanClass=class ejm.chapter4.admin.model.Category,
messageTemplate='{javax.validation.constraints.NotNull.message}'
}
]
虽然这是你预期在日志中看到的正确错误,但你的微服务并没有正确处理这个错误。在完成 RESTful 方法后,事务试图提交数据库更改,但失败了,因为你没有有效的Category实例。
你需要提前验证发生的点,以便你的方法可以正确处理它并返回你想要的响应码(列表 4.8)。
列表 4.8. CategoryResource.create()
try {
em.persist(category);
em.flush(); *1*
} catch (ConstraintViolationException cve) { *2*
return Response *3*
.status(Response.Status.BAD_REQUEST)
.entity(cve.getMessage())
.build();
} catch (Exception e) {
return Response
.serverError()
.entity(e.getMessage())
.build();
}
-
1 清除实体管理器中存在的更改。
-
2 捕获任何特定的约束异常。
-
3 返回带有 400 状态码和错误信息的响应。
你在这里所做的所有事情只是修改了create()以清除实体管理器中的更改,这触发了验证,然后捕获任何约束违规并返回一个响应。现在使用这个更改运行mvn test允许测试通过,因为它现在返回了正确的响应码。
集成测试是所有微服务都需要的关键部分。正如你所看到的,它能够快速识别与外部系统(如数据库)集成时可能出现的潜在故障点,这些故障点是由现有代码未编写来处理的情况引起的。与数据库集成并通过 HTTP 请求传输数据是两种常见的用途,在这些用途中,你现有代码中的问题可能会暴露出来。
开发者都是人,我们会犯错误。适当的集成测试是确保你开发的代码符合预期的关键方式。通常,让不同的开发者创建这些类型的测试是个好主意,因为另一个开发者不会有任何关于代码如何工作的先入为主的观念,他们只会关注测试微服务的所需功能。
4.5. 消费者驱动的契约测试
在开发微服务时,你并不一定有真实的服务消费者来测试。但如果一个服务可以提供消费者将在请求中传递的详细信息以及预期的响应,那么你可以针对你的真实服务执行这些预期,以确保你满足它们。还有什么比消费者指定你用来测试的期望来验证你的服务 API 工作得更好呢!
消费者驱动的契约测试使用这种方法,因为你正在测试消费者和提供者以确保他们之间传递了适当的信息。你如何做到这一点?图 4.2 展示了如何使用模拟服务器捕获消费者的请求,并返回为该请求定义的响应。
图 4.2. 对客户端请求的模拟响应

请记住,返回的响应是消费者开发者认为应该返回的内容。这种期望可能很容易与服务的响应不同,但再次强调,发现这类问题是这种测试的好处。
通过执行图 4.2 中所示的内容,可以创建一个合同,说明消费者在与提供者微服务通信时期望发送和接收的内容。图 4.3 显示了如何在您的服务上重新播放这些请求,并且服务根据其实际代码返回响应。然后可以将从服务收到的每个响应与预期内容进行比较,以确保消费者和提供者对应该发生的事情达成一致。
图 4.3. 发送到微服务的请求

测试这些概念的一个流行工具是 Pact (docs.pact.io/),你将在列表 4.10 中使用它。这个过程听起来很复杂,但使用 Pact 时并不太糟糕。Pact 是一系列框架,它使得创建和使用消费者驱动的合同测试变得容易。
首件事是创建一个尝试与行政微服务集成的消费者,如下所示。在 chapter4/admin-client 中,您有以下消费者。
列表 4.9. AdminClient
public class AdminClient {
private String url;
public AdminClient(String url) { *1*
this.url = url;
}
public Category getCategory(final Integer categoryId) throws IOException { *2*
URIBuilder uriBuilder;
try {
uriBuilder = new URIBuilder(url).setPath("/admin/category/" +
categoryId);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
String jsonResponse =
Request
.Get(uriBuilder.toString())
.execute()
.returnContent().asString();
if (jsonResponse.isEmpty()) {
return null;
}
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.readValue(jsonResponse, Category.class); *3*
}
}
-
1 AdminClient 的构造函数,它接受表示管理微服务的 URL
-
2 通过 ID 检索单个类别的方法
-
3 使用 Jackson 将响应 JSON 映射到 Category,并注册 JavaTimeModule。
您现在有一个基本的客户端,可以与行政微服务交互。为了使 Pact 为它创建必要的合同,您需要将其添加到 pom.xml 中作为依赖项:
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-consumer-junit_2.12</artifactId>
<scope>test</scope>
</dependency>
这个依赖项指定您将使用 JUnit 生成合同。让我们创建一个 JUnit 测试来生成合同。
列表 4.10. ConsumerPactTest
public class ConsumerPactTest extends ConsumerPactTestMk2 { *1*
private Category createCategory(Integer id, String name) { *2*
Category cat = new TestCategoryObject(id,
LocalDateTime.parse("2002-01-01T00:00:00"), 1);
cat.setName(name);
cat.setVisible(Boolean.TRUE);
cat.setHeader("header");
cat.setImagePath("n/a");
return cat;
}
@Override
protected RequestResponsePact createPact(PactDslWithProvider builder) {*3*
Category top = createCategory(0, "Top");
Category transport = createCategory(1000, "Transportation");
transport.setParent(top);
Category autos = createCategory(1002, "Automobiles");
autos.setParent(transport);
Category cars = createCategory(1009, "Cars");
cars.setParent(autos);
Category toyotas = createCategory(1015, "Toyota Cars");
toyotas.setParent(cars);
ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule());
try {
return builder
.uponReceiving("Retrieve a category")
.path("/admin/category/1015")
.method("GET")
.willRespondWith()
.status(200)
.body(mapper.writeValueAsString(toyotas))
.toPact(); *4*
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
@Override
protected String providerName() { *5*
return "admin_service_provider";
}
@Override
protected String consumerName() { *6*
return "admin_client_consumer";
}
@Override
protected PactSpecVersion getSpecificationVersion() { *7*
return PactSpecVersion.V3;
}
@Override
protected void runTest(MockServer mockServer) throws IOException { *8*
Category cat = new
AdminClient(mockServer.getUrl()).getCategory(1015);
assertThat(cat).isNotNull();
assertThat(cat.getId()).isEqualTo(1015);
assertThat(cat.getName()).isEqualTo("Toyota Cars");
assertThat(cat.getHeader()).isEqualTo("header");
assertThat(cat.getImagePath()).isEqualTo("n/a");
assertThat(cat.isVisible()).isTrue();
assertThat(cat.getParent()).isNotNull();
assertThat(cat.getParent().getId()).isEqualTo(1009);
}
}
-
1 将 ConsumerPactTestMk2 扩展到具有 Pact 和 JUnit 所需的集成钩子。
-
2 用于创建具有所需创建日期的类别的辅助方法
-
3 返回消费者期望的 Pact。
-
4 根据接收到的请求定义应接收的响应。
-
5 为提供者设置一个独特的名称。
-
6 为消费者设置一个独特的名称。
-
7 应该为合同使用哪个版本的 Pact 规范
-
8 运行 AdminClient 针对 Pact 模拟服务器,并验证预期的结果。
虽然这里有很多内容,但列表归结为以下几点:
-
一个识别从对管理微服务的请求中应该返回什么的方法,给定它接收的特定响应。这是 Pact 用于模拟合同创建过程中提供者一侧的方法。
-
一个使用您的客户端代码与 Pact 的模拟服务器交互的方法,并验证您收到的响应对象具有适当的值。
然后,运行 mvn test 将执行 JUnit Pact 测试,并在 /chapter4/admin-client/target/pacts 生成一个 JSON 文件。
列表 4.11. Pact JSON 输出
{
"provider": {
"name": "admin_service_provider"
},
"consumer": {
"name": "admin_client_consumer"
},
"interactions": [
{
"description": "Retrieve a category",
"request": {
"method": "GET",
"path": "/admin/category/1015"
},
"response": {
"status": 200,
"body": {
"id": 1015,
"name": "Toyota Cars",
"header": "header",
"visible": true,
"imagePath": "n/a",
"parent": {
"id": 1009,
"name": "Cars",
"header": "header",
"visible": true,
"imagePath": "n/a",
...
],
"metadata": {
"pact-specification": {
"version": "3.0.0"
},
"pact-jvm": {
"version": "3.5.8"
}
}
}
注意
为了简洁起见,我只包括了生成的 JSON 的开头,因为所有响应数据都很长。
使用这个生成的 JSON 文件,你现在可以设置消费者驱动合同测试的另一部分:验证提供者是否按消费者期望的方式工作。
为了简化,我手动将生成的 JSON 复制到了 /chapter4/admin/src/test/resources/pacts。对于需要持续集成的严肃测试,Pact 有其他方式来存储 JSON,以便在运行提供者测试时自动检索。
为了验证提供者,因为你需要一个正在运行的 admin 微服务实例,你将使用 Maven 执行 Pact 验证。验证将在 Maven 的集成测试阶段进行。首先,你修改你的 pom.xml 文件,在集成测试阶段启动和停止 Thorntail 容器。
列表 4.12. Thorntail Maven 插件执行集成测试
<plugin>
<groupId>io.thorntail</groupId>
<artifactId>thorntail-maven-plugin</artifactId>
<executions>
<execution>
<id>start</id>
<phase>pre-integration-test</phase>
<goals>
<goal>start</goal> *1*
</goals>
<configuration> *2*
<stdoutFile>target/stdout.log</stdoutFile>
<stderrFile>target/stderr.log</stderrFile>
</configuration>
</execution>
<execution>
<id>stop</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal> *3*
</goals>
</execution>
</executions>
</plugin>
-
1 在 Maven 的预集成测试阶段启动微服务。
-
2 定义微服务的日志位置。
-
3 在集成测试阶段停止微服务。
接下来,你将 Pact 插件添加到执行针对你的提供者的合同。
列表 4.13. Pact Maven 插件执行
<plugin>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-provider-maven_2.12</artifactId>
<configuration>
<serviceProviders>
<serviceProvider> *1*
<name>admin_service_provider</name>
<protocol>http</protocol>
<host>localhost</host>
<port>8081</port>
<path>/</path>
<pactFileDirectory>src/test/resources/pacts</pactFileDirectory> *2*
</serviceProvider>
</serviceProviders>
</configuration>
<executions>
<execution>
<id>verify-pacts</id>
<phase>integration-test</phase> *3*
<goals>
<goal>verify</goal> *4*
</goals>
</execution>
</executions>
</plugin>
-
1 定义管理微服务提供者的位置。
-
2 设置 Pact 合同文件可以找到的目录。
-
3 Pact 验证在 Maven 的集成测试阶段运行。
-
4 使用 Pact 插件的 verify 目标。
运行 mvn verify 将执行你之前定义的所有测试,但也将作为最后一步运行 Pact 验证。你应该在终端中看到表示它成功的输出:
returns a response which
has status code 200 (OK)
has a matching body (OK)
好吧,这很顺利,但如果它不起作用会是什么样子?为了尝试这一点,你可以在 CategoryResource.get() 方法中,在当前的 return 语句之前添加以下代码,以便为你的 Pact 测试返回不同的类别:
if (categoryId.equals(1015)) {
return em.find(Category.class, 1010);
}
如果你现在再次使用 mvn verify 运行测试,你将看到包含以下内容的测试失败输出:
returns a response which
has status code 200 (OK)
has a matching body (FAILED)
Failures:
0) Verifying a pact between admin_client_consumer and admin_service_provider
- Retrieve a category returns a response which has a matching body
$.parent.parent.parent.parent -> Type mismatch: Expected Map Map(parent
-> null, name -> Top, visible -> true, imagePath -> n/a, version -> 1,
id -> 0, updated -> null, header -> header, created -> List(2002, 1, 1,
0, 0)) but received Null null
Diff:
-{
- "parent": null,
- "name": "Top",
- "visible": true,
- "imagePath": "n/a",
- "version": 1,
- "id": 0,
- "updated": null,
- "header": "header",
- "created": [
- 2002,
- 1,
- 1,
- 0,
- 0
- ]
-}
+
$.parent.parent.parent.name -> Expected 'Transportation' but received
'Top'
$.parent.parent.parent.id -> Expected 1000 but received 0
$.parent.parent.name -> Expected 'Automobiles' but received
'Transportation'
$.parent.parent.id -> Expected 1002 but received 1000
$.parent.name -> Expected 'Cars' but received 'Automobiles'
$.parent.id -> Expected 1009 but received 1002
$.name -> Expected 'Toyota Cars' but received 'Trucks'
$.id -> Expected 1015 but received 1010
这条日志消息提供了关于它在每个类别中找到的至关重要的数据(如 ID 和名称)的详细信息,以及它与 Pact 合同定义的差异。
如前所述,这种差异可能是消费者方面的无效假设或提供者中的错误的结果。这种失败真正表明的是,消费者和提供者方面的开发者需要讨论 API 需要如何操作。
4.6. 额外阅读
如我之前提到的,还有许多其他类型的测试我不会涉及。其中两个关键的是用户验收测试和端到端测试。尽管它们对于确保进行充分的测试都至关重要,但它们超出了本书的范围,因为它们涉及更高层次的测试。关于使用微服务的测试的更多信息,我推荐 Alex Soto Bueno、Jason Porter 和 Andy Gumbrecht 的《Testing Java Microservices》(Manning,2018)。
4.7. 额外练习
这里有一些额外的测试,你可以编写来尝试不同的测试方法,并帮助改进示例代码的代码!
-
向
CategoryResourceTest添加一个方法来验证更新Category的能力。 -
向
CategoryResourceTest添加一个方法来验证Category可以从数据库中成功删除。 -
向
AdminClient添加方法以检索所有类别、添加类别、更新类别和删除类别。然后在ConsumerPactTest.createPact()中添加新方法的需求/响应对,并更新ConsumerPactTest.runTest()以执行和验证每个方法。
如果你承担了这些练习中的任何一个,并希望看到它们包含在本书的代码中,请向 GitHub 上的项目提交一个 pull 请求。
摘要
-
单元测试很重要,但测试的需求并不止于此。你需要尽可能真实地测试服务的各个方面。
-
Arquillian 是一个简化需要与运行时容器交互并提供近似生产级执行的更复杂测试的出色框架。
-
微服务测试的关键是确保微服务定义的合同、它暴露的 API 不仅针对微服务打算暴露的内容进行测试,而且还要针对客户端期望传递和接收的内容进行测试。
第五章. 云原生开发
本章涵盖
-
云为什么很重要?
-
什么是云原生开发?
-
你需要什么来将你的微服务部署到云中?
-
你的应用程序如何在云中扩展?
-
你能在生产之前在云中测试你的应用程序吗?
在本章中,你将扩展来自第四章的管理服务,使其能够部署到本地云环境,然后对该环境中部署的服务进行测试。
首先,你将学习云的含义以及你必须从中选择的云服务提供商。你还将探索在本地机器上运行云的选项。在你选择了一种云类型之后,你将修改来自第四章的管理服务以部署到云中。完成部署后,你将扩展应用程序以展示它如何处理额外的负载,并通过在云中部署应用程序运行测试来完成。
5.1. 云到底是什么?
云和云计算已经存在于软件工程领域几十年了。这些术语通常用于指代分布式计算的平台。直到 20 世纪 90 年代初,它们的使用才变得更加普遍。
云的一些关键好处如下:
-
成本效益—**大多数云服务提供商根据消耗的 CPU 时间来向企业收费。与物理机器相比,这显著降低了运行环境的总体成本。
-
可扩展性—**云服务提供商提供按需扩展和缩减单个服务的方法,确保你不会有过多的或过少的容量。由于社交媒体的普及,信息的传播可以非常迅速,因此能够立即扩展相同配置的实例来处理即时短期负载至关重要。当购买和配置一台机器需要数月时间时,企业如何快速扩展?在这种情况下,云服务提供商将通过复制具有相同内存、CPU 配置等的实例来提供扩展。
-
选择自由—**如果你在一家只使用 Java 进行开发的企业的员工,因为这是其运维团队知道如何管理的方式,你如何尝试新的编程语言,如 Node.js 或 Go?云以前所未有的方式将额外的语言带到了你的指尖。你不需要有维护新语言环境的内部经验;这正是云服务提供商的作用所在!
5.2. 服务模型
图 5.1 展示了云服务的多种服务模型,以及应用程序在该模型中的位置。在这个示例中,应用程序在服务器上有代码。如果你有一个纯移动或基于浏览器的应用程序,它通过软件即服务(SaaS)与一个或多个服务交互,它仍然是一个应用程序,但不是像这里所描述的那样。在这种情况下,应用程序可能是一个可执行的 JAR 文件,或者是一个部署到应用服务器的 WAR 或 EAR 文件。
图 5.1. 云中的服务模型

让我们简要描述每一层:
-
基础设施即服务 (IaaS)—**提供对包括计算资源、数据分区、扩展、安全和备份在内的网络基础设施的抽象。IaaS 通常涉及运行虚拟机的虚拟机管理程序。要使用 IaaS,需要构建一个可以部署到环境中的虚拟机。一些知名的 IaaS 提供商包括亚马逊网络服务、OpenStack、谷歌计算引擎和微软 Azure。
-
平台即服务(PaaS)—— 位于 IaaS 之上,提供包括操作系统、各种编程语言的执行环境、数据库和 Web 服务器的开发环境。PaaS 可以节省开发者购买、安装和配置硬件和软件以部署应用程序的环境。流行的 PaaS 提供商包括 Red Hat OpenShift、Amazon Web Services、Google App Engine、IBM Bluemix、Cloud Foundry、Microsoft Azure 和 Heroku。
-
软件即服务(SaaS)—— 根据需要或按需提供应用程序的常见组件,有时甚至提供整个应用程序。SaaS 通常按使用付费。作为 SaaS 提供的可以是从营销相关的利基服务到从开始到结束管理整个业务的全套 SaaS。存在许多 SaaS 提供商,并且每天都有更多出现。其中一些知名的有Salesforce.com、Eloqua、NetSuite 和 Cloud9。
在过去几年中——随着容器(尤其是作为容器解决方案的 Docker 的增长和普及)的兴起——云服务模型中已经创建了一个新的层次。
图 5.2 介绍了作为 PaaS 提供商新基础的容器即服务(CaaS)。CaaS 利用容器技术,如 Docker,简化了多个应用程序或服务的部署、扩展和管理。容器允许您将任何应用程序或服务打包到其自己的操作系统环境中,无论可能需要什么自定义软件或配置,同时还能减少与传统虚拟机相比生成的镜像大小。
图 5.2. 云中的容器服务模型

CaaS(容器即服务)的另一个主要优势,以及容器的一般优势,是它们的不可变性。因为容器镜像是从特定版本的容器派生出来的,所以以任何方式更新该容器都需要构建一个新的容器镜像和版本。可变部署长期以来一直是部署到内部管理的服务器的问题,因为操作可能会在系统上更新某些内容,从而可能破坏应用程序。不可变容器镜像可以随后通过 CI/CD 流程发送,以验证容器在发布到生产之前是否按预期运行。
目前,最受欢迎的 CaaS 提供商是 Kubernetes。Kubernetes 由 Google 创建,并受到了其内部管理容器化应用程序的方式的强烈影响。之前的 PaaS 提供商已经转向在 CaaS 之上构建,特别是 Kubernetes。Red Hat OpenShift 就是这样一种 PaaS,现在它利用 Kubernetes 作为其 CaaS。
CaaS 是管理部署的最佳方式,但你并不总是想要一个如此低级别的解决方案。通常,我们理想的环境是在 CaaS 之上构建的 PaaS,例如 Red Hat OpenShift。
5.3. 云原生开发
你可能之前听说过 云原生开发 这个术语,但新术语总是层出不穷,所以澄清定义是有好处的。云原生开发 是指为部署到云环境中的应用或服务进行开发的过程,在那里它可以利用松散耦合的云服务。
转向这种类型的开发需要在开发时改变思维方式,因为你不再关心应用程序所需的外部服务的细节。你所需要知道的是,云中将有服务,例如数据库,可供你的应用程序使用,以及你可能需要连接它的环境变量。
你还可以从另一个角度看待云原生开发,即它抽象掉了你的应用或服务正常运行所需的大部分内容。云原生开发允许开发者将精力集中在增加业务价值的事情上,通过专注于开发业务逻辑而不是管道代码。
尽管大多数云服务提供商没有提供,但服务目录的概念正是为了这个目的被引入到 Kubernetes 中。一个 服务目录 提供了可以在云中连接的服务定义,以及连接它们所需的配置变量。然后,一个服务可以指定它需要连接的外部服务的标准。这些标准可能包括 数据库 和 postgresql,这将从服务目录中转换为一个 PostgreSQL 数据库实例。
这个概念与我们多年来为数据库提供的特定环境配置并没有太大的不同。但随着服务目录工作的继续,我们可能会达到这样一个点,即通过特定环境变量连接到外部服务的应用程序不再需要。数据库客户端可以被注入到服务中,配置已经由服务目录设置好。
云原生开发听起来很棒,但如果你每次都必须将其部署到云中才能快速测试和调试你的服务,该怎么办?这不会减慢你的开发速度吗?是的,每次代码更改都要部署到云中查看影响,可能会略微减慢开发速度,甚至更多。
但如果你能将云,或者与生产中使用的云尽可能相似的东西带到你的本地开发机器上呢?这无疑会加快从代码更改到看到实际效果之间的往返时间。云提供商提供这样的服务吗?其中一些确实提供了!
Minikube 是第一个提供可以在本地机器上运行的单一节点 Kubernetes 集群的产品。所需的所有东西是在您的机器上安装的虚拟环境,例如 VirtualBox、Hyper-V 或 xhyve 驱动程序,Minikube 可以使用它们在您的机器上创建虚拟集群。
自从 Minikube 出现以来,Minishift 被创建出来,以扩展 Minikube,使其内置一个单节点 OpenShift 集群的 PaaS。Minishift 使用 OpenShift 的上游,即 OpenShift Origin,作为 PaaS。回顾 CaaS 在服务模型中的位置,图 5.3 显示了 Minishift 提供的内容。
图 5.3. Minishift 提供的内容

直接使用 CaaS,如 Kubernetes,并没有什么问题,但使用 PaaS 在之上还有好处。一个主要的好处是有一个很好的 UI 来可视化部署的内容。由于其设置简单,并且我们希望使用 PaaS 而不是 CaaS,我们将使用 Minishift 来创建我们的本地云环境。
5.4. 将应用部署到云
除了云可能提供的服务模型之外,云还可以使用三种主要的部署模型:
-
私有云—仅供单个企业使用的云,通常内部托管。
-
公有云—云中的服务通过公共网络提供。与私有云相比,主要区别在于安全性。无论是微服务还是数据库,它们都需要有更严格的安全措施,因为这些服务可以在公共网络上访问。
-
混合云—公共云和私有云的组合。也有可能每个云都可以使用不同的提供商。混合云部署模型正在迅速成为最常见的方式,因为它提供了两者的最佳结合,尤其是在需要快速增加容量和扩展时。
Minishift 本质上为您提供了一个在本地机器上运行的私有云实例。但 Minishift 内部的 PaaS,即 OpenShift,与在公共云或混合云部署中使用的 PaaS 相同。唯一的区别是它是本地运行的。
无论您是使用云来部署微服务、单体应用还是其他任何东西,将部署推送到云的方式都是一样的。唯一的区别可能是一个微服务更有可能有一个 CI/CD 流程,该流程会自动将发布版本推送到生产环境。而单体应用的部署可能需要比自动部署更多的协调。
5.5. 启动 Minishift
您需要做的第一件事是在您的本地机器上安装 Minishift。请访问mng.bz/w6g8并按照说明安装必要的先决条件,如果它们尚未安装,然后安装 Minishift。
注意
示例已在 Minishift 1.12.0 和 OpenShift 3.6.1 上进行了测试。
安装 Minishift 后,打开一个终端窗口,并使用默认设置启动它:
minishift start
默认情况下,这会给你提供一个带有两个虚拟 CPU、2GB RAM 和 20GB 硬盘空间的虚拟机。终端在启动时会提供 Minishift 正在执行的操作的详细信息,包括正在安装的 OpenShift Origin 版本。安装完成后,最后的输出将提供 Web 控制台的 URL 和开发者和管理员账户的登录凭证:
OpenShift server started.
The server is accessible via web console at:
https://192.168.64.11:8443
You are logged in as:
User: developer
Password: <any value>
To login as administrator:
oc login -u system:admin
对于你需要做的绝大多数事情,无论是通过 Web 控制台还是通过 OpenShift 命令行界面(CLI),你只需要开发者的凭证。还有一个方便的方法可以启动 OpenShift Web 控制台,无需记住 URL 和端口号:
minishift console
这个命令会直接在浏览器窗口中打开 Web 控制台的登录页面。登录后,控制台看起来像图 5.4。
图 5.4. OpenShift web 控制台

默认情况下,一个新的 OpenShift 实例会为你设置一个名为“我的项目”的空项目。你可以选择删除它并创建自己的项目,或者使用它;选择其实并不重要。
你现在有一个可以部署服务的云平台,但首先你需要让你的服务能够部署到这个云平台上。
5.6. 微服务云部署
你将使用在第四章中更新的管理服务,并添加必要的配置以支持部署到云平台。
到目前为止,部署你的应用程序最简单的方法是使用 fabric8 Maven 插件(maven.fabric8.io/)。这个插件的一个巨大好处是它可以将 Java 应用程序带到 OpenShift 或 Kubernetes!你可以从无配置部署过渡到添加你可能需要的任何配置。
让我们从修改你的 pom.xml 文件开始,将其包含在名为openshift的配置文件中。
列表 5.1. OpenShift 部署的 Maven 配置文件
<profile>
<id>openshift</id>
<build>
<plugins>
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>fabric8-maven-plugin</artifactId> *1*
<version>3.5.33</version>
<executions>
<execution>
<goals>
<goal>resource</goal> *2*
<goal>build</goal> *3*
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
-
1 fabric8 Maven 插件的名称
-
2 创建 Kubernetes 或 OpenShift 资源描述符
-
3 在容器中生成应用程序的 Docker 镜像
插件中定义的目标会告知它你希望它执行的操作。有了这个配置,插件将为 OpenShift 创建必要的资源描述符,然后使用 Docker 构建一个包含你的部署的容器镜像。代码所做的工作与直接使用 Docker 创建镜像没有区别,但无需每次都记住正确的命令!
我提到插件为你生成资源描述符,但它们是什么?看看这个列表。
列表 5.2. service-chapter5-admin.json
{
"apiVersion":"v1",
"kind":"Service",
"metadata": {
"annotations": {
"fabric8.io/git-branch":"master",
"fabric8.io/git-commit":"377ac684babee220885246de1700d76e3d11a8ab",
"fabric8.io/iconUrl":"img/icons/wildfly.svg",
"fabric8.io/scm-con-url":"scm:git:git@github.com:kenfinnigan/ejm-
samples.git/chapter5/chapter5-admin",
"fabric8.io/scm-devcon-url":"scm:git:git@github.com:kenfinnigan/ejm-
samples.git/chapter5/chapter5-admin",
"fabric8.io/scm-tag":"HEAD",
"fabric8.io/scm-url":"https://github.com/kenfinnigan/ejm-
samples/chapter5/chapter5-admin",
"prometheus.io/port":"9779",
"prometheus.io/scrape":"true"
},
"creationTimestamp":"2017-11-21T01:47:02Z",
"finalizers":[],
"labels": {
"app":"chapter5-admin",
"expose":"true",
"group":"ejm",
"provider":"fabric8",
"version":"1.0-SNAPSHOT"
},
"name":"chapter5-admin",
"namespace":"myproject",
"ownerReferences":[],
"resourceVersion":"3074",
"selfLink":"/api/v1/namespaces/myproject/services/chapter5-admin",
"uid":"decf5db7-ce5d-11e7-994e-0afca351eb6b"
},
"spec": {
"clusterIP":"172.30.221.166",
"deprecatedPublicIPs":[],
"externalIPs":[],
"loadBalancerSourceRanges":[],
"ports": [
{
"name":"http",
"port":8080,
"protocol":"TCP",
"targetPort":8080
}
],
"selector": {
"app":"chapter5-admin",
"group":"ejm",
"provider":"fabric8"
},
"sessionAffinity":"None",
"type":"ClusterIP"
},
"status": {
"loadBalancer": {
"ingress":[]
}
}
}
这只是插件可能创建的许多资源描述符之一,具体取决于指定的选项。你不需要为每个部署的微服务手动创建这么长的文件!fabric8 Maven 插件的优点是它隐藏了你不需要知道的所有样板配置,除非你想要了解。
如果需要更精细的服务配置控制,可以使用自定义 YAML 文件来实现,这些文件由插件用于生成必要的 JSON。这超出了本书的范围,但更多信息可在 fabric8 网站上找到,maven.fabric8.io/。
虽然 Minishift 已经启动,但在你可以使用 fabric8 Maven 插件部署你的服务之前,你还需要做一件事。你需要在终端中登录到 OpenShift,因为 fabric8 Maven 插件使用凭证在 OpenShift 内创建资源。这只需要一次,或者直到你的认证会话过期,你需要再次登录。
要登录,你需要安装 OpenShift CLI。有两种方法可以实现:
-
将.minishift/cache/oc/v3.6.0 目录添加到你的路径中,因为
oc二进制文件是由 Minishift 为你检索的。 -
直接从www.openshift.org/download.html下载 CLI。
CLI 安装后,你可以在终端中进行身份验证:
oc login
你将被提示输入用户 ID developer 和密码的任何值。
目前你将使用默认的 My Project,因此你可以使用以下命令将 admin 服务部署到 OpenShift:
mvn clean fabric8:deploy -Popenshift
你调用了 fabric8 的deploy目标,该目标将在 pom.xml 中定义的resource和build目标之后执行。你还指定了openshift配置文件,以便 fabric8 Maven 插件可用。
在终端中,你会看到通常的 Maven 构建日志,其中混合了 fabric8 插件的消息,告诉你它正在为部署到 OpenShift 生成什么。在它完成部署服务后,你可以在控制台中打开 My Project,查看你部署的服务所有详细信息,如图 5.5 所示。
在这里,你可以轻松地一眼看到你服务中的各种信息:
-
部署的名称
-
部署所使用的 Docker 镜像
-
创建 Docker 镜像的构建
-
从容器中暴露的端口
-
运行中的 pod 数量以及它们是否健康
-
指向你的部署的外部路由
注意
pod是一组使用共享存储和网络基础设施的容器(例如 Docker 容器),相当于具有协同应用程序的物理或虚拟机。
图 5.5. OpenShift web 控制台,显示 admin 服务

点击外部流量路由 URL 将在服务的根 URL 处打开一个新的浏览器窗口。因为 admin 服务在/路径下不提供任何内容,所以在你能够看到从数据库检索到的 JSON 数据之前,你需要修改浏览器中的 URL 以包含/admin/category。
当管理服务运行正常时,你能扩展该服务运行的实例数量吗?在 OpenShift 控制台中,扩展非常简单。你只需展开 chapter5-admin 部署的章节,如果尚未展开,如 图 5.6 所示。然后点击蓝色圆圈旁边的箭头,该圆圈表示当前 pod 的数量。如前所述,“pod”是 Kubernetes 对容器化部署的术语,但本质上它是给定服务的实例数量。
图 5.6. 管理服务 pod 实例

在这里,你可以看到 pod 的数量已从默认的 1 个增加到 3 个。打开几个私有浏览器窗口,并在每个窗口中多次点击 /admin/category 的端点。然后返回到 OpenShift 控制台,查看正在运行的每个 pod 的日志。你应该会看到针对不同 pod 执行的 SQL 调用。
如果你想要删除管理服务,你可以像从 OpenShift 中删除它一样轻松地删除它:
mvn fabric8:undeploy -Popenshift
警告
要取消部署你的服务,你没有使用 Maven clean 目标。作为部署的一部分,fabric8 将文件存储在 /target 目录中,这些文件包含已部署到 OpenShift 的所有资源的详细信息。如果你在 undeploy 运行之前清理它们,fabric8 完全不知道它试图取消部署什么,并且无法执行任何操作。
你现在可以在 Minishift 内部本地部署和取消部署管理服务,但你能否以同样的方式执行测试?这正是下一节要讨论的内容!
5.7. 云中测试
由于你能够使用 Minishift 将管理服务部署到本地云,你也能使用该本地云来测试它吗?你当然可以!
为了帮助开发与 OpenShift 集成的测试,你将使用来自 Arquillian 生态系统的一个扩展,称为 Arquillian Cube (arquillian.org/arquillian-cube/)。Arquillian Cube 通过提供控制 Docker 容器执行的钩子,使你能够运行针对 Docker 容器内代码的测试。尽管 OpenShift 远不止 Docker,因为它使用 Docker 作为其容器镜像,但你仍然可以使用 Arquillian Cube 来控制部署并对它执行测试。
在云中执行测试与通过集成测试所能实现的效果相比有什么好处?这都归结于想要在一个尽可能接近生产环境的条件下测试你的微服务。如果你将微服务部署到云中的生产环境,你发现此类部署问题的最佳机会是将测试也部署到云中。为了能够做到这一点,你需要在 pom.xml 中添加以下内容。
列表 5.3. Arquillian Cube 依赖项
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.arquillian.cube</groupId>
<artifactId>arquillian-cube-bom</artifactId> *1*
<version>1.12.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.arquillian.cube</groupId>
<artifactId>arquillian-cube-openshift</artifactId> *2*
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId> *3*
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId> *4*
<version>3.0.0</version>
<scope>test</scope>
</dependency>
</dependencies>
-
1 导入所有 Arquillian Cube 依赖项,以便它们可用。
-
2 将主 Arquillian Cube 组件作为测试依赖项添加到项目中。
-
3 从 Arquillian Cube 中排除 Undertow 作为传递依赖项。它会干扰 Thorntail。
-
4 添加 Awaitility 测试依赖项,以帮助等待端点可用。
尽管目前第五章的代码中已经移除了测试,但您仍然想要能够在云外运行测试,因此您需要一个单独的配置文件来激活您为云、OpenShift 准备的测试:
<profile>
<id>openshift-it</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
在这里,您告诉 Maven 您想要执行maven-failsafe-plugin的测试,integration-test目标,然后verify结果。
现在是时候创建您的测试了!您将创建一个类似于第四章中集成测试之一的测试,但它将针对您的云、OpenShift 执行,而不是本地实例。由于 fail-safe 插件需要在测试类名中包含IT以激活它,所以您将命名为CategoryResourceIT。
列表 5.4. CategoryResourceIT
@RunWith(Arquillian.class)
public class CategoryResourceIT {
@RouteURL("chapter5-admin") *1*
private URL url;
@Before *2*
public void verifyRunning() {
await()
.atMost(2, TimeUnit.MINUTES) *3*
.until(() -> {
try {
return get(url + "admin/category").statusCode() ==
200;
} catch (Exception e) {
return false;
}
});
RestAssured.baseURI = url + "/admin/category"; *4*
}
@Test
public void testGetCategory() throws Exception {
Response response =
given()
.pathParam("categoryId", 1014)
.when()
.get("{categoryId}")
.then()
.statusCode(200)
.extract().response(); *5*
String jsonAsString = response.asString();
Category category = JsonPath.from(jsonAsString).getObject("",
Category.class);
assertThat(category.getId()).isEqualTo(1014); *6*
assertThat(category.getParent().getId()).isEqualTo(1011);
assertThat(category.getName()).isEqualTo("Ford SUVs");
assertThat(category.isVisible()).isEqualTo(Boolean.TRUE);
}
}
-
1 注入指向 chapter5-admin OpenShift 路由的 URL。
-
2 在测试之前执行该方法,以确保您已准备好进行测试。
-
3 等待不超过 2 分钟,直到/admin/category 响应 200 状态码。
-
4 设置用于 RestAssured 的根 URL。
-
5 获取 ID 为 1014 的类别,确保您收到了 200 响应。
-
6 确认您收到的类别详情与您预期的相符。
是时候测试它了!
首先,您需要确保 Minishift 正在运行,并且您最近已经使用oc login登录。认证是会过期的!如果一切就绪,您将运行以下命令:
mvn clean install -Popenshift,openshift-it
在这里,您将激活openshift和openshift-it的配置文件。openshift-it配置文件将执行您的测试,但如果没有openshift配置文件,管理员服务将不会部署到 OpenShift!如果服务成功部署且测试通过,终端应显示一个没有错误的 Maven 构建完成。
您刚刚只是触及了 fabric8 Maven 插件和 Minishift 所能做到的一小部分,但您已经有了坚实的基础,可以开始自行进一步探索。因为您可能还需要一段时间才会再次使用 Minishift,所以我们现在就停止它:
minishift stop
5.8. 额外练习
这里有一些额外的练习,可以帮助您加深对 OpenShift 的理解,并有助于改进示例代码:
-
修改管理员服务的部署,以便在 OpenShift 上运行时使用 PostgreSQL 或 MySQL。
-
为
CategoryResourceIT添加创建Category的测试方法,以及另一个失败的名称验证测试方法。
如果您接受这些练习并希望看到它们包含在书籍的代码中,请向 GitHub 上的项目提交一个 pull request。
摘要
-
您可以通过选择使用 CaaS 内部平台的 PaaS 来利用不可变容器镜像。
-
Minishift 在您的本地机器上提供了一个带有 OpenShift 的云环境,以简化微服务的执行和测试,无需配置大量机器。
-
fabric8 Maven 插件移除了在 OpenShift 或 Kubernetes 中定义资源和服务所需的所有样板代码,以减少在云中看到微服务运行之前所需的配置障碍。
第二部分. 实施企业 Java 微服务
在第二部分中,我们通过涵盖诸如消费其他微服务、服务注册和发现、容错和安全性等主题,更深入地探讨了微服务开发。
这六章也涵盖了从 Cayambe 单体应用中开发微服务混合体,使用你在整本书中开发的微服务。最后,当你学习到在微服务及其混合体之间共享数据时,你将添加 Kafka 的数据流。
第六章. 消费微服务
本章涵盖
-
如何消费微服务
-
消费微服务时的选择
消费微服务对许多人来说意味着很多不同的事情。微服务的客户端可以是脚本、网页、其他微服务,或者几乎所有可以发起 HTTP 请求的东西。如果我们涵盖了所有这些,那么这一章将是一本完整的书!
开发一个微服务很有趣,但直到你引入许多相互交互的微服务之前,它并不能让你走得很远。为了使两个服务能够相互交互,你需要一种方法,使得一个服务可以调用另一个服务。
本章提供了使用基于 Java 的库来消费另一个微服务的示例,但所示方法同样适用于任何通用 Java 客户端消费微服务。
在企业 Java 中,两个服务将直接通过服务调用进行交互,如图 6.1 所示。
图 6.1. 企业 Java 业务服务调用

服务调用可以通过以下方式实现:
-
使用 EJB 时的
@EJB注入 -
@Inject与 CDI -
通过
static方法或变量获取服务实例 -
Spring 依赖注入,无论是基于 XML 还是基于注解
所有这些选项都需要你的两个服务位于同一个 JVM 和运行时环境中,如图 6.1 所示。
在图中,一个微服务正在调用另一个微服务。在图中,它们位于相同的微服务环境中,但不必如此。回顾图 1.4,图 6.2 突出了本章的重点,即解决两个在不同运行时环境中的微服务如何进行通信的问题。
图 6.2. 消费微服务

在我们的特定情况下,你将使用第二章中的新 Cayambe 管理微服务,并使用不同的库为其开发客户端。图 6.3 说明了微服务客户端的位置;你使用一种临时方式检索分类数据,直到最终解决方案到位。
图 6.3. Cayambe 管理微服务客户端

你将首先通过使用直接处理 HTTP 请求的低级库来了解如何消费微服务。因为它们处理 HTTP 请求,所以可以与不暴露 RESTful 端点的微服务一起使用。然后,你将学习专门设计来简化调用 RESTful 端点所需代码的客户端库。它们在 HTTP 请求上提供了更高层次的抽象,这显著简化了客户端代码,正如你将在我们的示例中看到的那样。本节代码可以在书籍示例代码的/chapter6 目录中找到。对于每个客户端库,将实现一个服务,该服务调用你在第二章中创建的CategoryResource RESTful 端点,然后将接收到的数据作为对调用者的响应返回。
提示
你可以将CategoryResource启动的端口设置为防止与其他微服务发生端口冲突。你将swarm.port.offset属性在 Maven 插件中设置为1。
这些服务中的每一个都需要一个对象来表示从管理服务接收到的类别 JSON。为了方便起见,每个客户端库 Maven 模块都将有自己的Category对象,该对象将在从管理服务反序列化响应时使用。
列表 6.1. Category模型类
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id") *1*
public class Category {
protected Integer id;
protected String name;
protected String header;
protected Boolean visible;
protected String imagePath;
protected Category parent;
private Collection<Category> children = new HashSet<>(); *2*
protected LocalDateTime created = LocalDateTime.now();
protected LocalDateTime updated;
protected Integer version;
...
}
-
1 将键定义为 Category 的 ID,该 ID 用于反序列化作为 JSON 接收到的子集合
-
2 初始化子集合,以确保即使它是空的,你也能始终拥有一个有效的集合
为了简洁,省略了获取器和设置器方法,但Category类的完整源代码可以在书籍的源代码中找到。
此外,这些服务中的每一个都需要访问ExecutorService以在新的线程上提交工作。你希望使用 Java EE 提供的,以便所有服务都以相同的方式检索:
private ManagedExecutorService executorService() throws Exception {
InitialContext ctx = new InitialContext();
return (ManagedExecutorService)
ctx.lookup("java:jboss/ee/concurrency/executor/default");
}
这通过名称进行简单的 JNDI 查找以获取服务,并返回你可以用于提交工作的实例。
备注
ExecutorService是由 WildFly 定义的。你不需要做任何事情就可以通过 JNDI 检索它。
你的服务也可以直接创建一个新的Thread来执行任何所需的工作,但这样你的新线程就会在 Java EE 线程池管理之外。这是问题吗?不一定,但如果运行时的线程池大小几乎与可用的 JVM 线程一样大,你可能会遇到问题。在这种情况下,当在 Java EE 线程池之外创建线程时,你可能会耗尽所有可用的 JVM 线程。一般来说,最好不直接创建线程,而是使用ExecutorService。
因为你想展示同步和异步使用场景如何导致消费微服务的不同客户端代码,每个使用客户端库的资源将包含两个端点:
-
/sync—同步处理调用者的请求 -
/async—异步处理调用者的请求
传统上,服务被开发出来以同步方式与完成响应所需的任何其他资源进行通信。随着企业对提高性能和可扩展性的需求不断增加,我们在服务中转向了更大的异步行为。在本章和本书的剩余部分,你将了解同步和异步的使用模式。增强微服务的优势也需要一定程度的异步行为;否则,你将最小化它们分布式特性的优势。如果你选择那条路线,那么你不妨坚持使用单体架构!
注意
你的每个微服务定义了一个名为categoryUrl的字段,该字段硬编码为 http://localhost:8081/admin/categorytree。这不是你投入生产时会做的事情,但它有助于简化示例。在后面的章节中,你将看到如何使用服务发现来连接到其他服务。
6.1. 使用 Java 客户端库消费微服务
在本节中,你将看到如何消费使用较低级别库直接处理 HTTP 请求的微服务的示例。尽管这会导致更冗长和额外的数据处理,但它确实提供了最大的灵活性,关于如何进行调用。例如,如果微服务需要与许多类型的 HTTP 资源通信,那么使用这些库可能是一个更好的选择,因为为 RESTful 端点交互添加另一个库是没有意义的。
6.1.1. java.net
java.net 包中的类从一开始就是 JDK 的一部分。尽管这些年来它们得到了增强和更新,但它们专注于低级 HTTP 交互。它们绝不是为 RESTful 端点消费而设计的,因此需要一定程度的繁琐代码。
让我们看看DisplayResource的第一个方法。
列表 6.2. 使用 java.net 的DisplayResource
@GET
@Path("/sync")
@Produces(MediaType.APPLICATION_JSON)
public Category getCategoryTreeSync() throws Exception {
HttpURLConnection connection = null;
try {
URL url = new URL(this.categoryUrl); *1*
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET"); *2*
connection.setRequestProperty("Accept", MediaType.APPLICATION_JSON);*3*
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { *4*
throw new RuntimeException("Request Failed: HTTP Error code: " +
connection.getResponseCode());
}
return new ObjectMapper() *5*
.registerModule(new JavaTimeModule()) *6*
.readValue(connection.getInputStream(), Category.class); *7*
} finally {
assert connection != null;
connection.disconnect() *8*
}
}
-
1 创建一个指向你的 CategoryResource 的 URL。
-
2 将 HTTP GET 设置为连接的请求方法。
-
3 将“application/json”设置为响应中你将接受的媒体类型。
-
4 检查非 OK 响应代码。
-
5 创建一个新的 ObjectMapper 来执行 JSON 反序列化。
-
6 注册 JavaTimeModule 以处理 JSON 到 LocalDateTime 实例的转换。
-
7 将你在响应中收到的 InputStream 传递给 ObjectMapper 进行反序列化,到一个 Category 实例中。
-
8 关闭与 CategoryResource 的连接。
尽管你处理的是一个简单的 RESTful 端点,但与之通信的客户端代码肯定不是,这只是一个同步操作!
下一个列表显示了如果你想要异步处理对微服务的客户端请求,前面的代码将如何改变。
列表 6.3. 使用 java.net 异步的DisplayResource
@GET
@Path("/async")
@Produces(MediaType.APPLICATION_JSON)
public void getCategoryTreeAsync(
@Suspended final AsyncResponse asyncResponse) *1*
throws Exception {
executorService().execute(() -> { *2*
HttpURLConnection connection = null;
try {
// The code to open the connection, check the status code,
// and process the response is identical to the synchronous
// example and has been removed.
asyncResponse.resume(category); *3*
} catch (IOException e) {
asyncResponse.resume(e); *4*
} finally {
assert connection != null;
connection.disconnect();
}
});
}
-
1 请求处理将异步进行。
-
2 将 lambda 表达式传递给执行器进行处理。
-
3 使用反序列化的 Category 实例恢复 AsyncResponse。
-
4 使用异常恢复 AsyncResponse。
列表中介绍了你之前未见过的新概念——即 @Suspended 和 AsyncResponse。这两个部分是 JAX-RS 处理客户端请求异步的核心。@Suspended 通知 JAX-RS 运行时,客户端的 HTTP 请求应挂起,直到有响应准备就绪。AsyncResponse 指示开发者如何通知运行时响应已准备就绪或未能完成。
它看起来是什么样子?请查看 图 6.4。
下面是 图 6.4 中每个步骤所发生的情况:
-
从浏览器或其他客户端收到了 HTTP 请求。
-
getCategoryTreeAsync()触发在单独的线程中执行代码。在getCategoryTreeAsync()完成后,客户端请求被挂起,处理该请求的 HTTP 请求线程变得可用以处理其他请求。 -
向外部微服务发出 HTTP 请求。
图 6.4. JAX-RS
AsyncResponse处理![]()
-
从外部微服务接收到了 HTTP 响应。
-
响应数据传递给
asyncResponse.resume()。 -
客户端请求在 HTTP 请求线程中被重新激活,并构建响应。
-
响应返回给浏览器,或返回给发起请求的任何客户端。
警告
在 RESTful 端点中使用 @Suspended 并不能阻止调用端点的客户端阻塞。它仅通过允许更大的请求吞吐量来使请求的服务器端受益。不使用 @Suspended,JAX-RS 资源只能处理与可用线程数量一样多的请求,因为每个请求都会阻塞线程,直到方法完成。
现在你已经构建了服务,可以启动它们。
切换到书籍示例代码的 /chapter6/admin 目录并运行以下命令:
mvn thorntail:run
CategoryResource 将启动并在浏览器中可用,地址为 http://localhost:8081/admin/categorytree。
现在开始你的 DisplayResource。切换到 /chapter6/java-net 目录并运行以下命令:
mvn thorntail:run
现在可以通过在浏览器中访问它们来访问微服务:http://localhost:8080/sync 和 http://localhost:8080/async。在浏览器中打开上述任一 URL 都会显示当前存在于管理微服务中的类别树。
6.1.2. Apache HttpClient
使用 Apache HttpClient,你得到了对 java.net 中使用的类的抽象,最小化了与底层 HTTP 连接交互所需的代码。DisplayResource 中的代码与之前的代码没有太大不同,但它提高了代码的可读性。
例如,让我们看看你的第一个 DisplayResource 方法。
列表 6.4. 使用 HttpClient 的 DisplayResource
@GET
@Path("/sync")
@Produces(MediaType.APPLICATION_JSON)
public Category getCategoryTreeSync() throws Exception {
try (CloseableHttpClient httpclient = HttpClients.createDefault()) { *1*
HttpGet get = new HttpGet(this.categoryUrl); *2*
get.addHeader("Accept", MediaType.APPLICATION_JSON); *3*
return httpclient.execute(get, response -> { *4*
int status = response.getStatusLine().getStatusCode();
if (status >= 200 && status < 300) { *5*
return new ObjectMapper() *6*
.registerModule(new JavaTimeModule())
.readValue(response.getEntity().getContent(),
Category.class);
} else {
throw new ClientProtocolException("Unexpected response
status: " + status);
}
});
}
}
-
1 在 try-with-resources 语句内创建一个 HTTP 客户端。
-
2 使用 CategoryResource URL 端点创建一个 HttpGet 实例。
-
3 指定你将接受 JSON 响应。
-
4 执行 HttpGet,传递一个用于响应的处理程序。
-
5 验证响应代码是否为 OK。
-
6 从响应中提取 HttpEntity。使用 ObjectMapper 将实体转换为 Category 实例。
即使是这样一个简短的示例,你也能看到在发起 HTTP 请求时客户端代码有多简单。现在让我们看看使用 @Suspended 时代码会变得多么简单。
列表 6.5. 使用 HttpClient 和 @Suspended 的 DisplayResource
@GET
@Path("/async")
@Produces(MediaType.APPLICATION_JSON)
public void getCategoryTreeAsync(@Suspended final AsyncResponse
asyncResponse) throws Exception {
executorService().execute(() -> { *1*
try (CloseableHttpClient httpclient = HttpClients.createDefault()) {
HttpGet get = new HttpGet(this.categoryUrl);
// The code to initiate the HTTP GET request and convert the
HttpEntity
// is identical to the synchronous example and has been removed.
asyncResponse.resume(category); *2*
} catch (IOException e) {
asyncResponse.resume(e);
}
});
}
-
1 在单独的线程中执行你的调用代码。
-
2 使用接收到的类别恢复 AsyncResponse。
再次强调,这种方法与我们的同步示例类似,但你使用 @Suspended 和 AsyncResponse 来告诉 JAX-RS 你希望在调用外部微服务时挂起 HTTP 请求。
如果你已经在 http://localhost:8081 上运行了你的 CategoryResource 微服务,你现在可以使用 Apache HttpClient 启动你的新微服务。
警告
在运行此微服务之前,你需要停止任何之前运行的微服务,因为它们使用相同的端口。
切换到 /chapter6/apache-httpclient 目录并运行以下命令:
mvn thorntail:run
现在可以通过浏览器访问微服务:http://localhost:8080/sync 和 http://localhost:8080/async。就像你之前使用的微服务一样,你将看到一个当前存在于管理微服务中的类别树。
在本节中,你查看了一些专注于直接使用 URL 和 HTTP 请求方法的客户端库。它们在与 HTTP 资源交互时非常出色,但在处理 RESTful 端点时则较为冗长。你能找到进一步简化客户端代码的客户端库吗?
6.2. 使用 JAX-RS 客户端库消费微服务
本节介绍了将抽象级别提升到 HTTP 以上的客户端库。这两个库都提供了专门为与 JAX-RS 端点通信而设计的 API。
6.2.1. JAX-RS 客户端
JAX-RS 在多年的时间里被定义为 Java EE 的 JSR 311 和 JSR 339 规范的一部分。作为这些规范的一部分,JAX-RS 有一个客户端 API,它为开发者提供了一种更干净的方式来从 JAX-RS 资源调用 RESTful 端点。
那么,使用 JAX-RS 客户端库的好处是什么?它允许你忘记连接到 RESTful 微服务所需的低级 HTTP 连接,并专注于所需的元数据,如下所示:
-
HTTP 方法
-
要传递的参数
-
参数和返回类型的 MediaType 格式
-
必要的 Cookie
-
消费 RESTful 微服务所需的任何其他元数据
当使用 JAX-RS 客户端库时,你需要注册一个提供者来处理响应中 JSON 到 LocalDateTime 实例的反序列化。为此,你需要以下列表,你将在我们后续的示例中使用它。
列表 6.6. ClientJacksonProvider
public class ClientJacksonProvider implements ContextResolver<ObjectMapper> { *1*
private final ObjectMapper mapper = new ObjectMapper() *2*
.registerModule(new JavaTimeModule()); *3*
@Override
public ObjectMapper getContext(Class<?> type) {
return mapper; *4*
}
}
-
1 为 ObjectMapper 实例提供 ContextResolver。
-
2 创建一个新的 ObjectMapper 实例。
-
3 注册 JavaTimeModule 以处理 LocalDateTime 转换。
-
4 当请求时返回你创建的 ObjectMapper 实例。
再次,你从你的同步示例端点开始。
列表 6.7. 使用 JAX-RS 客户端的 DisplayResource
@GET
@Path("/sync")
@Produces(MediaType.APPLICATION_JSON)
public Category getCategoryTreeSync() {
Client client = ClientBuilder.newClient(); *1*
return client
.register(ClientJacksonProvider.class) *2*
.target(this.categoryUrl) *3*
.request(MediaType.APPLICATION_JSON) *4*
.get(Category.class); *5*
}
-
1 创建一个 JAX-RS 客户端。
-
2 注册你在 列表 6.6 中定义的提供者。
-
3 将客户端的目标设置为 CategoryResource URL。
-
4 指定响应应返回 JSON。
-
5 发送 HTTP GET 请求并将响应体转换为 Category。
当将前面的列表与任何纯 Java 客户端库进行比较时,你将有一个显著简化且更连贯的代码片段,用于调用外部微服务。
这重要吗?从执行请求和处理响应所需的功能角度来看,一点也不重要。但这一点与开发者理解现有代码或开发新代码的容易程度相比,远没有那么关键。我会把它留给你来判断,但我知道我宁愿看到之前的例子,也不愿看到我们迄今为止看到的任何其他东西。
JAX-RS 客户端库同样可以改善你代码的异步可读性吗?参见下一列表。
列表 6.8. 使用 JAX-RS 客户端和 @Suspended
@GET
@Path("/async")
@Produces(MediaType.APPLICATION_JSON)
public void getCategoryTreeAsync(@Suspended final AsyncResponse
asyncResponse) throws Exception {
executorService().execute(() -> {
Client client = ClientBuilder.newClient();
try {
Category category = client.target(this.categoryUrl)
.register(ClientJacksonProvider.class)
.request(MediaType.APPLICATION_JSON)
.get(Category.class);
asyncResponse.resume(category);
} catch (Exception e) {
asyncResponse.resume(Response *1*
.serverError()
.entity(e.getMessage())
.build());
}
});
}
- 1 返回你构造的响应,包括异常消息,而不是仅仅传递异常。
与所有异步使用一样,你指定 @Suspended 和 AsyncResponse。你还使用 ManagedExecutorService 为处理你的调用提供一个新线程,并通过 asyncResponse.resume() 设置响应。
你还可以使用 JAX-RS 客户端库本身的异步功能。
列表 6.9. 使用 JAX-RS 客户端和 InvocationCallback 的 DisplayResource
@GET
@Path("/asyncAlt")
@Produces(MediaType.APPLICATION_JSON)
public void getCategoryTreeAsyncAlt(@Suspended final AsyncResponse
asyncResponse) {
Client client = ClientBuilder.newClient();
WebTarget target = client.target(this.categoryUrl)
.register(ClientJacksonProvider.class);
target.request(MediaType.APPLICATION_JSON)
.async() *1*
.get(new InvocationCallback<Category>() { *2*
@Override
public void completed(Category result) {
asyncResponse.resume(result);
}
@Override
public void failed(Throwable throwable) {
throwable.printStackTrace();
asyncResponse.resume(Response
.serverError()
.entity(throwable.getMessage())
.build());
}
});
}
-
1 指示你希望调用是异步的。
-
2 传递带有完成和失败处理方法的 InvocationCallback。
第二个异步版本改变了你的代码中哪些部分在新线程中执行,但它并没有改变最终结果。在 getCategoryTreeAsync() 中,你将所有 RESTful 端点代码传递到一个新线程,以便 HTTP 请求线程可以几乎与处理一样快地被解除阻塞。getCategoryTreeAsyncAlt() 的不同之处在于它只在新的线程中执行对外部微服务的 HTTP 请求。所有用于发送 HTTP 请求的设置代码都发生在客户端请求相同的线程中。
由于 getCategoryTreeAsyncAlt() 使用了为客户端打开的最长时间 HTTP 请求线程,它通过使每个客户端在比必要更长的时间内阻塞线程来降低 RESTful 端点的吞吐量。尽管影响可能很小,但如果请求数量足够大,这种影响是存在的。
为什么展示一个 较差 的方法,它会对吞吐量产生负面影响?首先,作为一种展示有多种方式可以实现类似目标的方式。其次,许多微服务可能没有足够多的并发客户端请求,以至于这种性能影响是明显的并导致问题。在这种情况下,开发者可能更愿意选择回调而不是任何其他替代方案——因为当某个选项不会影响性能时,这是一个合理的选择。
在切换到使用 JAX-RS 客户端库时,您简化了调用代码,并使其更容易理解。这当然比使用底层库更令人愉快,但它确实在如何灵活使用方面付出了代价。
会失去什么样的灵活性?对于大多数用例来说,JAX-RS 客户端库不会造成任何影响,但调用使用二进制协议的微服务可能会更困难。根据协议的不同,可能需要开发自定义处理程序和提供者,或者集成提供此类功能的额外第三方库。
切换到 /chapter6/jaxrs-client 目录并运行以下命令:
mvn thorntail:run
现在可以通过在浏览器中访问它们来访问微服务:http://localhost:8080/sync 和 http://localhost:8080/async。就像我们之前的示例一样,您将看到当前存在于管理微服务中的类别树。
6.2.2. RESTEasy 客户端
RESTEasy 是 JAX-RS 规范的实现,它不仅可在 WildFly 中使用,还可以单独使用。尽管其客户端库的许多部分与 JAX-RS 客户端 API 提供的相同,但 RESTEasy 提供了一个特别有趣的功能,值得注意。
使用 JAX-RS 客户端库,您可以通过链式调用方法来指定想要调用的 RESTful 端点,从而构建端点、URL 路径、参数、返回类型、媒体类型等的画面。这样做并没有什么问题,但对于更熟悉使用 JAX-RS 创建 RESTful 端点的开发者来说,这并不十分自然。
使用 RESTEasy,您可以重新创建想要与之通信的 RESTful 端点作为接口,并为您生成该接口的代理。这个过程允许您将外部微服务的接口用作如果它存在于您的代码库中一样。
对于您的外部 CategoryResource 微服务,您会创建如下所示的接口。
列表 6.10. CategoryService
@Path("/admin/categorytree")
public interface CategoryService {
@GET
@Produces(MediaType.APPLICATION_JSON)
Category getCategoryTree();
}
这里的代码没有什么特别之处。它看起来像任何其他的 JAX-RS 端点类,除了它是一个接口并且没有方法实现。另一个好处是只需要在接口上定义你的微服务需要的方法。例如,如果一个外部微服务有五个端点,而你的微服务只需要使用一个,那么定义该外部微服务的接口只需要一个方法。你不需要定义整个外部微服务。
这有什么优势吗?当然有!它允许你有一个专注于定义你需要消费的外部微服务的明确定义。如果该微服务上的方法被更新而你不需要,那么你不需要更新你的接口,因为你没有使用那些端点。
注意
采用这种方法,可以在服务和客户端之间共享相同的接口。服务将为实际的端点代码提供接口的实现。
| |
警告
虽然这种做法是可能的,但不推荐在微服务中使用,因为它变成了两个微服务都依赖的独立库,这会引入发布时间和顺序问题。这是一条危险的道路,最终只会给企业带来持续的痛苦。因此,最好是复制你需要调用的方法。
现在你已经定义了一个映射到你的外部微服务的接口,它该如何使用呢?
列表 6.11. 使用 RESTEasy 的 DisplayResource
@GET
@Path("/sync")
@Produces(MediaType.APPLICATION_JSON)
public Category getCategoryTreeSync() {
ResteasyClient client = new ResteasyClientBuilder().build(); *1*
ResteasyWebTarget target = client.target(this.categoryUrl) *2*
.register(ClientJacksonProvider.class);
CategoryService categoryService = target.proxy(CategoryService.class); *3*
return categoryService.getCategoryTree(); *4*
}
-
1 使用 RESTEasy 创建客户端。
-
2 设置请求的目标 URL 基路径。
-
3 生成你的 CategoryService 的代理实现。
-
4 通过你的代理调用 CategoryResource。
采用这种方法,你将设置所有请求参数(如 URL 路径、媒体类型和返回类型)的操作转移到你的 CategoryService 接口上。现在与代理交互的客户端代码表现得就像是一个本地方法调用。通过将常见的请求参数值集中到一个地方,你在代码中获得了进一步的简化。这在微服务可能需要在不同的 RESTful 端点调用相同的对外部微服务时尤为重要,因为你不希望在不改变信息的地方重复信息。
让我们看看一些使用你的代理接口的异步示例。
列表 6.12. 使用 RESTEasy 和 @Suspended 的 DisplayResource
@GET
@Path("/async")
@Produces(MediaType.APPLICATION_JSON)
public void getCategoryTreeAsync(@Suspended final AsyncResponse
asyncResponse) throws Exception {
executorService().execute(() -> {
ResteasyClient client = new ResteasyClientBuilder().build();
try {
ResteasyWebTarget target = client.target(this.categoryUrl)
.register(ClientJacksonProvider.class);
CategoryService categoryService =
target.proxy(CategoryService.class);
Category category = categoryService.getCategoryTree();
asyncResponse.resume(category);
} catch (Exception e) {
asyncResponse.resume(Response
.serverError()
.entity(e.getMessage())
.build());
}
});
}
在同步和异步 RESTful 端点之间,你需要做的唯一改变是 JAX-RS 异步要求的 @Suspended 和 @AsyncResponse,将客户端代码提交到单独的线程进行处理,并在 asyncResponse.resume() 上设置成功或失败。
你在使用 RESTEasy 客户端库时采用的代理方法的一个缺点是,它不支持在调用外部微服务时执行回调。因此,使用 RESTEasy 的 getCategoryTreeAsyncAlt() 将与使用 JAX-RS 客户端库时相同。
切换到 /chapter6/resteasy-client 目录并运行以下命令:
mvn thorntail:run
现在可以通过 http://localhost:8080/sync 和 http://localhost:8080/async 访问微服务。每个 URL 都会返回当前存在于管理微服务中的分类树,作为结果。
现在我们已经介绍了几种提供更高层次抽象以与 RESTful 端点交互的客户端库。示例展示了这些库在减少代码复杂性和提高可读性方面的好处。
摘要
-
基于 Java 的客户端库,如 java.net 和 Apache HttpClient,提供了对 Java 网络的低级别访问,但比必要的代码更冗长。
-
基于 JAX-RS 的客户端库提供了一个抽象,使得消费微服务变得更加容易。
第八章. 容错和监控策略
本章涵盖
-
什么是延迟?
-
为什么微服务需要具备容错能力?
-
电路断路器是如何工作的?
-
有哪些工具可以减轻分布式故障的影响?
你将使用前几章中的示例来扩展 Stripe 和 Payment 的功能,包括在探索容错和监控概念时加入故障缓解。当你的 Payment 微服务通过网络与外部系统通信时,容错尤为重要。你需要预料到在网络通信中可能会出现故障和超时。
8.1. 分布式架构中的微服务故障
图 8.1 回顾了你的微服务分布式架构的外观。
图 8.1. 分布式架构中的微服务

这种分布式架构与故障有何关联?由于你的微服务包含更小的业务逻辑块,而不是包含所有内容的单体,你最终会拥有显著更多的服务需要维护。你不再处理可能只与单个后端服务通信的 UI,该服务处理所有需求。更有可能的是,相同的 UI 现在正在与数十个甚至更多的微服务集成,这些微服务需要与之前的单体一样可靠。
但你的微服务在生产环境中不会失败,对吧?在生产环境中,什么都不会失败!我们可能都曾在某个时刻说过类似的话,通常是在我们被生产环境中的重大故障咬了一口之后!一朝被蛇咬,十年怕井绳!
为什么在没有生产故障的先前经验的情况下,我们倾向于对生产系统的可靠性做出宏伟的声明?部分原因可能是因为我们天生乐观,但主要还是缺乏经验。如果你从未不得不在深夜处理应用程序的生产问题,那么就很难理解关于系统可靠性的合理担忧。
呼叫器噩梦
我记得在 20 世纪 90 年代末——是的,那时候我在 IT 行业——新手最可怕的经历就是被分配到值班。没有什么比凌晨 2 点左右接到故障作业需要修复的通知更糟糕的了,然后试图在早上 8 点员工到达办公室之前完成它们!这些只是夜间的批量作业,但接到电话的焦虑感是可怕的。
我只能想象收到一个生产故障的呼叫(如果呼叫器今天仍然存在)来处理一个需要解决的实时应用程序,因为它正在影响业务的 24/7 运行!
这里有一些关于生产系统,尤其是分布式架构,你可能会错误地相信的声明:
-
*计算设备网络是可靠的。如果不考虑网络故障的可能性,应用程序在等待不会到达的响应时可能会停滞。更糟糕的是,当网络再次可用时,应用程序将无法重试任何失败的操作。
-
*请求得到响应没有延迟(称为零延迟)。忽略网络延迟以及相关的网络数据包丢失,可能会导致带宽浪费和网络数据包丢失增加,而网络流量在无限制增长的情况下。
-
*网络上的可用带宽没有限制。如果客户端发送的数据太多,或者请求太多,可用的网络带宽可能会缩小到出现瓶颈并降低应用程序吞吐量的程度。延迟对网络吞吐量的影响可能持续几秒钟或持续存在。
-
*整个网络对可能的攻击,无论是外部还是内部,都是安全的。忽视恶意用户,如不满的员工,可能试图对应用程序造成损害的可能性是幼稚的。同样,一个曾经内部的应用程序,如果没有经过适当的安全审查就公开提供,很容易受到外部威胁。即使是对端口的防火墙规则的无害更改也可能无意中使其对外部可访问。
-
*计算设备在网络上的位置和布局永远不会改变。当网络发生变化,设备被移动到不同的位置时,可用的带宽和延迟可能会降低。
-
*一切只有一个管理员。在企业内部,如果不同网络有不同的管理员,可能会实施冲突的安全策略。在这种情况下,需要跨不同安全网络通信的客户端需要了解双方的要求才能成功通信。
-
*零传输成本。尽管通过网络传输物理数据可能没有成本,但在网络建成后维护网络是有成本的。
-
*整个网络是同质的。在同质网络中,网络上的每个设备都使用类似的配置和协议。异质网络可能导致本列表前三个点中描述的问题。
所有这些陈述都被称为分布式计算的谬误(www.rgoarchitects.com/Files/fallacies.pdf)。
8.2. 网络故障
尽管网络可能以许多方式失败,但在这个部分,你将专注于网络延迟和超时。之前,我提到零延迟是分布式计算的谬误之一,这等于在请求和执行请求时没有延迟。
为什么延迟对你的微服务很重要?它几乎影响你的微服务可能想要做的任何事情:
-
调用另一个微服务
-
等待异步消息
-
从数据库读取
-
向数据库写入
如果不考虑网络中存在延迟的事实,你会假设所有消息和数据通信都是几乎瞬时的,假设参与通信的网络设备足够接近。
超时是你在开发微服务时需要留意的另一个关键网络故障来源。超时可能与高延迟相关;请求没有及时得到响应,不仅因为网络延迟,还因为消费微服务的问题。如果你调用的微服务已经宕机,正在经历高负载,或者因为其他任何原因失败,当你尝试消费它时,你将注意到问题,最常见的形式是超时。无法预测超时何时发生,因此你的代码需要意识到超时会发生,以及当你收到超时时你想要如何处理这种情况。
你是立即重试还是稍后延迟重试?你假设标准响应并继续进行吗?
你特别希望减轻这些网络故障。否则,你将你的微服务,以及整个应用程序,暴露在无法恢复的意外网络故障中,除了重新启动服务外没有其他恢复手段。因为你不能每次网络问题发生时都重启服务,你需要开发你的代码以防止重启成为你唯一的选项。
8.3. 防御故障
在考虑如何减轻失败时,你当然可以自己实现所需的功能。但你可能不是所有最佳实现方式的专家。即使你是,完成这种实现也需要超过短期的开发周期。你更愿意开发更多的应用程序!尽管你可能能够使用许多不同的库,但在这个案例中,你将使用 Netflix 开源软件中的 Hystrix。
8.3.1. 什么是 Hystrix?
Hystrix 是一个旨在隔离远程系统、服务和库的访问点、阻止级联故障以及在分布式系统中提供弹性的延迟和容错库。无论在何处失败是不可避免的,例如在分布式系统中,Hystrix 库提高了这些环境中微服务的弹性。
Hystrix 有很多功能,那么这个库是如何做到的呢?我们无法在本章中涵盖 Hystrix 的所有内容;那需要一本完全属于自己的书。但本节提供了 Hystrix 如何执行隔离的高级视图。
图 8.2 展示了一个微服务处理多个用户请求负载的视图。这个微服务需要与外部服务进行通信。在这种情况下,你开发的微服务很容易因为等待外部服务 2 响应而被阻塞。更糟糕的是,你可能会使外部服务过载到完全停止工作的程度。
图 8.2. 不使用 Hystrix 处理的微服务用户请求

正是在这里,Hystrix 发挥作用,作为中间人并调解你的外部通信,以减轻各种故障。图 8.3 通过将HystrixCommand实例包装在外部服务调用中,并将配置用于定义其行为(例如,可用的线程数)来将 Hystrix 添加到图中。
在图 8.3 中,每个外部服务都有不同数量的线程可供相应的HystrixCommand使用。这表明某些服务可能比其他服务更容易过载,你需要限制你发送的并发请求数量。
图 8.3. 使用 Hystrix 处理用户请求的微服务

通过将外部服务 2 包装到HystrixCommand中,你限制了从你的微服务并发调用它的请求数量。尽管你为与该特定外部服务的交互增加了缓解措施,但你只是增加了请求在微服务中失败的可能性,因为你拒绝了发送到外部服务的额外请求!这种情况可能很好,也可能不好;结果取决于外部请求处理你的请求的速度。
这确实提出了一个重要观点。在整个生态系统中的单个微服务中添加故障缓解并不那么有益。使您的微服务成为分布式网络中的更好公民是很好的,但如果网络中的其他人没有相同的缓解措施来与您的微服务交互,您只是将瓶颈和故障点移动了位置。因此,故障缓解成为企业级关注的问题,或者至少在所有相互通信的微服务组中是至关重要的。
您可以在图 8.3 中看到的 Hystrix 的另一个优点是它提供的外部服务之间的隔离。如果对外部服务 2 的调用没有限制,那么它很可能消耗掉 JVM 中所有可用的线程,从而阻止您的微服务处理不需要与外部服务 2 交互的请求!
在本章的剩余部分,我们的方法将是概述故障缓解策略背后的理论,然后展示该策略如何在 Hystrix 中实现。您知道您需要在代码中缓解网络故障,那么您有哪些策略可供选择?
8.3.2. 电路断路器
如果您对家中电气面板中保险丝的工作方式有所了解,您就会理解电路断路器的原理。图 8.4 显示,除非保险丝被跳闸打开,否则电流会无阻碍地通过保险丝流动。
图 8.4. 电气电路断路器状态

电气面板和软件之间唯一的区别是,软件电路断路器将根据已定义的阈值自动关闭,这些阈值指示电路变得不健康时的水平。
图 8.5 显示了缓解调用外部服务时故障的更大流程的初始部分。随着您进入本章,流程中还将添加更多部分,提供额外的功能以帮助缓解。这一部分主要关注提供电路断路器。
图 8.5. 基本电路的故障缓解流程

当电路断路器处于闭合状态时,所有请求将继续通过流程。当电路断路器处于开路状态时,请求将提前退出流程。您可以在图 8.5 中看到,您的电路断路器需要电路健康数据,这些数据用于确定电路应该开启还是关闭。除了图 8.5 中的状态外,电路断路器还可以处于半开状态。参见图 8.6。
图 8.6. 电路断路器状态

这里是电路断路器状态之间的转换:
-
所有请求都能无阻碍地通过,因为电路处于闭合状态。
-
当达到故障阈值时,电路变为开路状态。
-
当电路处于开启状态时,所有请求都会被拒绝,快速失败。
-
电路的开启超时时间到期。电路移动到半开启状态,以允许单个请求通过。
-
请求失败或成功:
-
单个请求失败,将电路状态恢复为开启。
-
单个请求成功,将电路状态恢复为关闭。
-
在半开启状态下,断路器仍然是官方关闭的。但一旦达到睡眠超时时间,将允许单个请求通过。这个单个请求的成功或失败将决定状态是否回到关闭(单个请求成功),或者是否保持开启状态,直到下一次超时间隔到达时再次尝试。
断路器只是允许或阻止请求通过的一种方式。使其按您期望的方式运行的关键部分是电路健康数据。如果没有捕获任何电路健康数据,无论有多少请求失败或是什么原因,断路器都将始终保持关闭状态。
Hystrix 为断路器提供了合理的默认设置,以处理任何请求的超时、网络拥塞和延迟。让我们看看一个简单的 Hystrix 断路器。
列表 8.1. StockCommand
public class StockCommand extends HystrixCommand<String> { *1*
private final String stockCode;
public StockCommand(String stockCode) {
super(HystrixCommandGroupKey.Factory.asKey("StockGroup")); *2*
this.stockCode = stockCode;
}
@Override
protected String run() throws Exception { *3*
// Execute HTTP request to retrieve current stock price
}
}
-
1 将
String指定为HystrixCommand类型。 -
2 Hystrix 仪表板中用于分组数据的唯一键
-
3 调用外部服务的执行
您可以使用以下代码同步调用此命令:
String result = new StockCommand("AAPL").execute();
如果您更喜欢异步执行,您可以使用以下方法:
Future<String> fr = new StockCommand("AAPL").queue();
String result = fr.get();
在每个示例中,您都期望从执行请求中只得到一个结果,无论您是同步调用还是异步调用。因此,您选择扩展HystrixCommand,它适用于单响应执行。
如果您期望多个响应而不是一个,会发生什么?股票价格变化非常频繁,所以每次您想要更新时,不是不断地执行另一个调用不是很好吗?
您需要修改您的断路器以支持返回一个可以发出多个响应的Observable的命令。您将订阅此Observable以处理接收到的每个响应。将每个响应的处理识别为反应式执行。
定义
Reactive是一个形容词,意为对情况做出反应,而不是创造或控制它。当您使用Observable并监听从中发出的结果时,您是在对每个发出的结果做出反应。这种方法的优点是,您在等待每个结果发出时不会阻塞。
让我们修改您的命令以提供一个Observable。
列表 8.2. StockObservableCommand
public class StockObservableCommand extends HystrixObservableCommand<String> { *1*
private final String stockCode;
public StockObservableCommand(String stockCode) {
super(HystrixCommandGroupKey.Factory.asKey("StockGroup")); *2*
this.stockCode = stockCode;
}
@Override
protected Observable<String> construct() { *3*
// Return an Observable that executes an HTTP Request
}
}
-
1 将
String指定为HystrixObservableCommand类型。 -
2 Hystrix 仪表板中用于分组数据的唯一键
-
3 返回一个执行调用外部服务的
Observable。
如果你希望命令在创建 Observable 时立即执行,你可以请求一个热 Observable:
Observable<String> stockObservable =
new StockObservableCommand(stockCode).observe();
通常,热 Observable 会发出响应,无论是否有订阅者,这使得在没有订阅者的情况下,响应可能会完全丢失。但 Hystrix 使用 ReplaySubject 来为你捕获这些响应,允许它们在你订阅 Observable 时回放给你的监听器。
你也可以使用一个 冷 Observable:
Observable<String> stockObservable =
new StockObservableCommand(stockCode).toObservable();
对于冷 Observable,执行不会在订阅者订阅之前触发。这保证了任何订阅者都将接收到 Observable 所产生的所有通知。
应该使用哪种类型的 Observable 取决于你的情况。如果一个监听器可以承受错过一些初始数据,特别是如果他们不是 Observable 的第一个订阅者,那么热 Observable 是合适的。然而,如果你希望监听器接收所有数据,那么冷 Observable 是更好的选择。
注意
虽然 HystrixCommand 支持从其非响应式方法 execute() 和 queue() 返回一个 Observable,但它们总是只发出单个值。
8.3.3. 隔舱
软件中的隔舱提供了与船舶中类似的策略,通过隔离不同的部分来防止一个部分的故障影响其他部分。对于船舶来说,单个水密舱的故障不会蔓延到其他部分,因为它们由隔舱分开。
软件隔舱是如何达到相同的效果的?通过减轻微服务正在经历或即将经历的压力。隔舱 允许你限制对组件或服务的并发调用数量,以防止网络因请求而饱和,这会增加系统中所有请求的延迟。图 8.7 将隔舱策略作为你流程中的下一步添加。
图 8.7. 带有隔舱的故障缓解流程

你可以在任何断路器之后添加隔舱。如果断路器是开启的,不需要检查隔舱,因为你处于错误状态。当你处于关闭状态时,隔舱可以防止执行过多的请求,这可能会创建网络瓶颈。
你可能需要调用数据库服务来执行非常密集和耗时的计算,例如。如果你知道外部服务可能需要 10 秒来响应,你不想向该服务发送超过每分钟六个请求。如果你发送超过六个,你的请求将被排队等待后续处理,这会导致你的微服务无法释放对客户端请求的控制。这是一个难以打破的恶性循环,可能会导致微服务中的级联故障。图 8.7 中的隔舱执行其检查并指示你是否可以继续处理请求或是否需要拒绝。
你会如何实现软件舱壁?两种最常见的方法是计数器和线程池。计数器允许你设置在任何时候可以同时活跃的最大并行请求数量。线程池也限制了同时活跃的并行请求数量,但通过限制池中可用于执行请求的线程数量。对于线程池舱壁,会创建一个特定的池来处理对特定外部服务的请求,允许不同的外部服务相互隔离,同时也与执行你的微服务的线程隔离。
被拒绝的请求的详细信息会提供给断路器健康数据,以便更新计数器,以便在下一次需要计算断路器状态时使用。
作为软件舱壁,Hystrix 为线程池(THREAD)和计数器(SEMAPHORE)提供了执行策略。默认情况下,HystrixCommand使用THREAD,而HystrixObservableCommand使用SEMAPHORE。
HystrixObservableCommand不需要通过线程进行舱壁处理,因为它已经通过Observable在单独的线程中执行。你可以使用THREAD与HystrixObservableCommand一起使用,但这样做并不增加安全性。如果你想在SEMAPHORE中运行StockCommand,它看起来会像以下列表。
列表 8.3. 使用SEMAPHORE的StockCommand
public class StockCommand extends HystrixCommand<String> {
private final String stockCode;
public StockCommand(String stockCode) {
super(Setter *1*
.withGroupKey(HystrixCommandGroupKey.Factory.asKey("StockGroup"))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter()
.withExecutionIsolationStrategy(
HystrixCommandProperties.ExecutionIsolationStrategy.SEMAPHORE *2*
)
)
);
this.stockCode = stockCode;
}
...
}
-
1 使用 Setter 作为流畅接口来为 Hystrix 定义额外的配置
-
2 将执行隔离策略设置为 SEMAPHORE。
列表说明了如何为 Hystrix 设置额外的配置以自定义特定命令的行为。在实践中,你不会使用SEMAPHORE与HystrixCommand一起使用,因为它无法设置执行应该持续多长时间的超时。没有超时,如果消费的服务未能及时提供响应,你很容易发现自己陷入死锁系统。
8.3.4. 回退
目前,当你的断路器或舱壁不执行请求时,会返回一个错误响应。虽然这并不理想,但总比你的微服务处于等待超时状态要好。
如果你能提供一个简单的响应来代替失败,那岂不是很好?在某些情况下,可能确实无法为这些情况提供通用的响应,但通常这是可能的,并且是有益的。
在图 8.8 中,你可以看到在断路器和舱壁失败路径上的回退处理。如果你想要消费的微服务有一个已注册的回退处理器,它的响应会返回给你。如果没有,则返回原始错误。
图 8.8. 带有回退处理的故障缓解流程

让我们看看如何为StockCommand实现回退处理器。
列表 8.4. 带有回退的StockCommand
public class StockCommand extends HystrixCommand<String> {
...
@Override
protected String getFallback() { *1*
// Return previous days cached stock price, no network call.
}
}
- 1 覆盖默认的抛出失败异常的回退。
当你处理HystrixObservableCommand时,实现回退处理程序会有所不同,但并不太多。
列表 8.5. 带有回退的StockObservableCommand
public class StockObservableCommand extends HystrixObservableCommand<String> {
...
@Override
protected Observable<String> resumeWithFallback() { *1*
// Return previous days cached stock price as an Observable,
no network call.
}
}
- 1 返回 Observable
而不是 String 以匹配命令响应类型
8.3.5. 请求缓存
虽然它并不能直接减轻失败,但请求缓存可以通过减少你对其他微服务发出的请求数量来防止 bulkhead 和其他失败的发生。
它是如何做到这一点的呢?通过请求缓存,之前的请求及其响应可以被缓存,这样你就可以匹配未来的请求,并从缓存中返回响应。图 8.9 显示了请求缓存位于其他缓解策略之前,因为它减少了需要通过流程后续阶段的请求数量。
图 8.9. 带有请求缓存的失败缓解流程

请求缓存提供了减少通过你的缓解流程的请求数量和提高返回响应速度的双重好处。启用请求缓存并不适用于所有情况,但在返回的数据根本不会改变或在你微服务完成任务期间不太可能改变的情况下是有益的。
这种解决方案特别适用于参考数据或用于检索用户账户等情况,因为它允许你的微服务在无需担心增加网络流量的情况下,根据需要多次调用外部微服务。这种方法还简化了微服务内部方法和服务接口,因为你不再需要在调用中传递数据以防止额外的调用。有了请求缓存,你就没有额外的调用风险。
要在 Hystrix 中启用请求缓存,你需要做两件事。首先,你需要激活HystrixRequestContext,这样你就有缓存响应的手段:
HystrixRequestContext context = HystrixRequestContext.initializeContext();
这个调用需要在执行任何 Hystrix 命令之前发生。对于我们的情况,你将在稍后看到的 JAX-RS 端点方法中进行第一次调用。其次,你需要定义用于缓存请求及其响应的键。
列表 8.6. 带有请求缓存的StockCommand
public class StockCommand extends HystrixCommand<String> {
private final String stockCode;
...
@Override
protected String getCacheKey() { *1*
return this.stockCode;
}
}
- 1 使用你在请求中使用的股票代码覆盖请求缓存的键
8.3.6. 将所有内容整合在一起
在你目前的流程中,你已经有了请求缓存、断路器、bulkhead 和回退。图 8.10 显示了它们在实际调用中的位置。
图 8.10. 整个失败缓解流程

在这里,你添加“执行”来表示你正在调用外部服务。执行过程中遇到的任何失败或超时都会反馈到回退处理中,同时也提供失败数据到电路健康数据。然后,断路器使用这些信息来确定是否达到了错误阈值,电路应该切换到开启状态。
图 8.11 进一步展示了 Hystrix 如何在你的微服务、服务 A 和你要消费的服务 B 之间集成时提供这些功能。
图 8.11. 具有故障缓解的微服务调用

当请求进入你的服务 A 方法或端点时,你创建一个请求并将其传递给 Hystrix。请求通过之前启用的任何检查,然后才在服务 B 上执行。服务 B 的响应返回到你的服务 A 方法,进行任何必要的处理,然后你为客户端构造响应。
如你所见,在许多点上,Hystrix 可以提供不同的或缓存的响应,而无需直接调用服务 B。这样的流程在直接减少失败的同时,也减少了导致失败的因素。一个例子是使用请求缓存来减少微服务负载。
虽然你已经看到了 Hystrix 如何实现这些故障缓解功能,但提供相同功能的其他库或框架应该以类似的方式运行。但其他库或框架实现所需缓解的方式可能差异很大。
8.3.7. Hystrix 仪表板
太棒了——你现在可以改善分布式架构中微服务的可靠性。但你怎么确定特定的微服务是否持续导致失败?或者你是否需要调整设置以减少错误并处理额外的负载?
听起来你需要一种方法来监控你的容错库的性能。恰好 Hystrix 提供了 SSEs(服务器端事件),提供了关于特定微服务的许多详细信息。你可以看到并分析一切——运行微服务的宿主数量、处理的请求、失败、超时等等。
Hystrix 还提供了一种可视化所有这些事件的方法:Hystrix 仪表板,如图 8.12 所示。Hystrix 仪表板提供了它从每个注册流接收到的 SSEs 的视觉表示。你将很快看到流是什么。
图 8.12 显示了StockCommand的信息。在如此小的 UI 中有很多数据点,但其中一些最重要的如下:
图 8.12. Hystrix 仪表板的单个电路

-
过去 10 秒内的错误百分比—100%
-
运行微服务的宿主数量—1
-
过去 10 秒内的成功请求—0
-
在过去 10 秒内被拒绝的短路请求—40
-
过去 10 秒内的失败—0
-
电路是开启还是关闭——开启
提示
您可以在 github.com/Netflix/Hystrix/wiki/Dashboard 找到每个电路的每个度量指标的详细信息。
让我们看看仪表板的实际运行情况。切换到 /hystrix-dashboard 目录并构建项目:
mvn clean package
然后运行仪表板:
java -jar target/hystrix-dashboard-thorntail.jar
启动仪表板后,打开浏览器并导航到 http://localhost:8090/。为了使仪表板能够可视化度量数据,它需要从您的断路器获取这些数据!对于单个电路,您可以通过将 http://localhost:8080/hystrix.stream 直接添加到主输入框中,来直接添加 SSE 流,如图 8.13 所示。点击添加流按钮,然后点击监控流。主页面将加载,但直到您启动微服务,流中不会接收到任何 SSE,因此可视化尚未出现。
图 8.13. Hystrix 仪表板主页

切换到 /chapter8/stock-client 目录并启动微服务:
mvn thorntail:run
在另一个浏览器窗口中,您可以访问 http://localhost:8080/single/AAPL 以请求由代码 AAPL 表示的当前股票价格详情。在 URL 路径中可以使用任何有效的股票代码。
如果您刷新页面或以其他方式多次请求,您可以通过切换回 Hystrix 仪表板来查看您电路上的数据。
您的 stock-client 内置了处理特定 Hystrix 功能的机制。例如,每第十个请求将抛出异常返回给您的消费微服务,而每第二个请求将被暂停 10 秒以触发超时。这允许您看到失败如何在仪表板上表示。
要查看请求缓存的工作方式,您可以访问 http://localhost:8080/single/AAPL/4。注意在控制台中,只对外部服务发出了单个请求,并且浏览器收到的每个响应都有一个相同的请求编号。
要完全看到电路的实际运行情况,您需要多次调用该服务:
curl http://localhost:8080/single/AAPL/?[1-100]
这将连续对您的服务进行 100 次调用,允许您在看到请求进入时监控仪表板上的电路。您会注意到错误过多导致断路器打开的点。然后您会立即看到所有剩余的请求通过不调用微服务而是返回回退来短路。如果您在通过浏览器访问服务之前等待几秒钟,然后您会看到断路器尝试请求,成功,并再次切换到关闭状态。
在 StockCommand 中调整设置以查看电路行为如何变化。书中示例代码中的一个例子是将 StockCommand 修改为设置可用于消费微服务的线程数量。
列表 8.7. 带有线程配置的 StockCommand
super(Setter
.withGroupKey(HystrixCommandGroupKey.Factory.asKey("StockGroup"))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter()
.withCircuitBreakerRequestVolumeThreshold(10)
.withCircuitBreakerSleepWindowInMilliseconds(10000)
.withCircuitBreakerErrorThresholdPercentage(50)
)
.andThreadPoolPropertiesDefaults(
HystrixThreadPoolProperties.Setter()
.withCoreSize(1) *1*
)
);
- 1 指定必须使用单个线程
使用StockCommand的列表 8.7 构造函数重新运行你的测试,显示请求被ThreadPool拒绝。
在查看 Hystrix 仪表板后,我们应该都认识到这样的工具在我们的工具箱中是多么关键。将 Hystrix 添加到你的外部调用中为这些执行提供了容错级别,但它并不是万无一失的。你需要持续实时监控你的微服务,以跟踪即将出现的问题并观察可以通过调整断路器设置来解决的问题。
如果你没有充分利用 Hystrix 仪表板提供的内容,尤其是在实时监控方面,你将无法获得使用容错库的所有好处。
8.4. 将 Hystrix 添加到你的支付微服务
你已经看到了如何实现 Hystrix 以及从仪表板查看其指标。你的 Stripe 微服务并不非常可靠,所以让我们在支付中使用 Hystrix 来确保你不会过度受到其失败或超时的负面影响!
前面的章节已经介绍了 Hystrix 提供的各种功能,以帮助进行故障缓解。当将 Hystrix 添加到支付时,你将充分利用 Hystrix 提供的完整流程。
在接下来的每个部分中,你需要你的 Stripe 微服务正在运行,所以让我们现在开始。首先,你需要确保 Minishift 环境正在运行,并且你已经使用 OpenShift 客户端登录。然后切换到/chapter8/stripe 目录并运行以下命令:
mvn clean fabric8:deploy -Popenshift -DskipTests
8.4.1. 使用 RESTEasy 客户端的 Hystrix
让我们修改第七章中的 Payment,使用HystrixCommand与 Stripe 交互。
列表 8.8. StripeCommand
public class StripeCommand extends HystrixCommand<ChargeResponse> {
private URI serviceURI;
private final ChargeRequest chargeRequest;
public StripeCommand(URI serviceURI, ChargeRequest chargeRequest) { *1*
super(Setter
.withGroupKey(HystrixCommandGroupKey.Factory.asKey("StripeGroup"))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter()
.withCircuitBreakerRequestVolumeThreshold(10)
.withCircuitBreakerSleepWindowInMilliseconds(10000)
.withCircuitBreakerErrorThresholdPercentage(50)
)
);
this.serviceURI = serviceURI;
this.chargeRequest = chargeRequest;
}
public StripeCommand(URI serviceURI, *2*
ChargeRequest chargeRequest, HystrixCommandProperties.Setter
commandProperties) {
super(Setter
.withGroupKey(HystrixCommandGroupKey.Factory.asKey("StripeGroup"))
.andCommandPropertiesDefaults(commandProperties)
);
this.serviceURI = serviceURI;
this.chargeRequest = chargeRequest;
}
@Override
protected ChargeResponse run() throws Exception { *3*
ResteasyClient client = new ResteasyClientBuilder().build();
ResteasyWebTarget target = client.target(serviceURI);
StripeService stripeService = target.proxy(StripeService.class);
return stripeService.charge(chargeRequest);
}
@Override
protected ChargeResponse getFallback() {
return new ChargeResponse(); *4*
}
}
-
1 将 Stripe URL 和 ChargeRequest 传递到命令中并设置属性。
-
2 允许调用者设置 Hystrix 属性的重载构造函数
-
3 等同于第七章中
PaymentServiceResource的方法,因为不再在 JAX-RS Resource 中进行调用 -
4 如果有问题,回退到空的 ChargeResponse。
现在你已经有了你的StripeCommand,PaymentServiceResource与第七章相比有何不同?
列表 8.9. PaymentServiceResource
@Path("/")
@ApplicationScoped
public class PaymentServiceResource {
....
@POST
@Path("/sync")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public PaymentResponse chargeSync(PaymentRequest paymentRequest) throws
Exception {
Payment payment = setupPayment(paymentRequest);
ChargeResponse response = new ChargeResponse();
try {
URI url = getService("chapter8-stripe");
StripeCommand stripeCommand = new StripeCommand( *1*
url,
paymentRequest.getStripeRequest(),
HystrixCommandProperties.Setter()
.withExecutionIsolationStrategy(
HystrixCommandProperties.ExecutionIsolationStrategy.SEMAPHORE
)
.withExecutionIsolationSemaphoreMaxConcurrentRequests(1)
.withCircuitBreakerRequestVolumeThreshold(5)
);
response = stripeCommand.execute(); *2*
payment.chargeId(response.getChargeId());
} catch (Exception e) {
payment.chargeStatus(ChargeStatus.FAILED);
}
em.persist(payment);
return PaymentResponse.newInstance(payment, response);
}
@POST
@Path("/async")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public void chargeAsync(@Suspended final AsyncResponse asyncResponse,
PaymentRequest paymentRequest) throws Exception {
Payment payment = setupPayment(paymentRequest);
URI url = getService("chapter8-stripe");
StripeCommand stripeCommand =
new StripeCommand(url, paymentRequest.getStripeRequest()); *3*
stripeCommand
.toObservable() *4*
.subscribe( *5*
(result) -> {
payment.chargeId(result.getChargeId());
storePayment(payment);
asyncResponse.resume(PaymentResponse.newInstance(payment,
result));
},
(error) -> {
payment.chargeStatus(ChargeStatus.FAILED);
storePayment(payment);
asyncResponse.resume(error);
}
);
}
....
}
-
1 实例化命令并设置 Hystrix 属性。
-
2 在命令执行()上阻塞。
-
3 使用默认的 Hystrix 属性实例化命令。
-
4 获取命令的 Observable。
-
5 订阅 Observable,传递成功和失败方法。
你的PaymentServiceResource已经表明,当期望只有一个响应时,你可以轻松地在同步和异步执行模式之间切换,使用相同的HystrixCommand实现。
从你的第七章版本到这个版本,重构并不多,主要是将消耗外部微服务的代码提取到一个新的方法和类StripeCommand中。
现在您已经重构了资源,让我们运行它!切换到 /chapter8/resteasy-client 目录并运行以下命令:
mvn clean fabric8:deploy -Popenshift
如果 Hystrix Dashboard 仍在运行,请返回主页以便您可以添加新的流。如果它尚未运行,请像本章早期那样重新启动它。
从 OpenShift 控制台复制 chapter8-resteasy-client 的 URL,将其粘贴到 Hystrix Dashboard 主页上的文本框中,并添加 hystrix.stream 作为 URL 后缀。点击添加流然后监控流。
由于您尚未发出任何请求,Hystrix Dashboard 不会立即显示任何内容。为了练习 Payment 服务,您可以执行单个请求或多个请求,后者在仪表板中更容易看到结果,尤其是如果它们的执行可以自动化。
使用之前章节 8.4-resteasy-client 的 URL,您可以访问服务的同步版本 (/sync) 或异步版本 (/async)。在任一或两个端点启动一系列请求后,Hystrix Dashboard 将显示已成功和失败的请求的所有详细信息。
8.4.2. 使用 Ribbon 客户端的 Hystrix
您的 RESTEasy 客户端需要一点修改以添加 Hystrix 支持。现在您将查看 Ribbon 客户端所需的操作。
首先,您需要更新 Stripe 微服务的接口定义,以便利用 Hystrix 注解与 Ribbon 结合。
列表 8.10. StripeService
@ResourceGroup(name = "chapter8-stripe")
public interface StripeService {
StripeService INSTANCE = Ribbon.from(StripeService.class);
@TemplateName("charge")
@Http(
method = Http.HttpMethod.POST,
uri = "/stripe/charge",
headers = {
@Http.Header(
name = "Content-Type",
value = "application/json"
)
}
)
@Hystrix( *1*
fallbackHandler = StripeServiceFallbackHandler.class
)
@ContentTransformerClass(ChargeTransformer.class)
RibbonRequest<ByteBuf> charge(@Content ChargeRequest chargeRequest);
}
- 1 将 Hystrix 功能添加到您的 Ribbon HTTP 请求中,并带有回退处理程序
这很简单——只需几行额外的代码!
注意
Hystrix 注解仅可用于与 Netflix Ribbon 结合使用。
目前,代码无法编译,因为您没有回退处理程序的类。让我们添加它。
列表 8.11. StripeServiceFallbackHandler
public class StripeServiceFallbackHandler implements FallbackHandler<ByteBuf> {
@Override
public Observable<ByteBuf> getFallback( *1*
HystrixInvokableInfo<?> hystrixInfo,
Map<String, Object> requestProperties) {
ChargeResponse response = new ChargeResponse();
byte[] bytes = new byte[0];
try {
bytes = new ObjectMapper().writeValueAsBytes(response); *2*
} catch (JsonProcessingException e) {
e.printStackTrace();
}
ByteBuf byteBuf =
UnpooledByteBufAllocator.DEFAULT.buffer(bytes.length);
byteBuf.writeBytes(bytes); *3*
return Observable.just(byteBuf); *4*
}
}
-
1 实现 getFallback() 以在回退情况下返回您选择的任何内容。
-
2 创建一个空的 ChargeResponse 用于回退,并将其转换为 byte[]。
-
3 将 byte[] 写入您在上一个步骤中创建的 ByteBuf。
-
4 创建一个 Observable,它返回作为单个结果的 ByteBuf 内容。
您还需要更新 PaymentServiceResource,来自 第七章。但不是这样!使用 Hystrix 与 Ribbon 结合使用注解的一个优点是,您从 第七章 的 PaymentServiceResource 完全不需要更改。一个很大的优点是,您可以在使用 Ribbon 的现有微服务中轻松添加 Hystrix 而无需重构。只需添加一个额外的注解,如果需要,还可以添加一个回退处理程序。
是时候运行它了!切换到 /chapter8/ribbon-client 目录并运行以下命令:
mvn clean fabric8:deploy -Popenshift
就像 RESTEasy 客户端示例一样,您可以通过浏览器打开并访问服务的/sync 或/async URL,使用 OpenShift 控制台中的服务基本 URL。然后,您可以更新 Hystrix 仪表板以使用此新流,执行一些请求,并查看仪表板如何变化。
就像您部署到 Minishift 的其他示例一样,完成后,您需要取消部署以释放资源:
mvn fabric8:undeploy -Popenshift
摘要
-
当考虑部署到分布式架构时,延迟和容错性很重要,因为它可能会对微服务的吞吐量和速度产生不利影响。
-
您消费微服务的代码可以用 Hystrix 包装,以集成容错特性,如回退、请求缓存和隔离舱。
-
Hystrix 本身并不是最高容错性的万能药。通过像 Hystrix 仪表板这样的工具进行实时监控对于成功提高整体容错性至关重要。
第九章. 保护微服务
本章涵盖
-
理解为什么你需要安全的微服务
-
保护微服务
-
消费一个受保护的微服务
-
从 UI 与受保护的微服务交互
在本章中,您将通过向它们添加各种类型的安全性来扩展先前的示例。首先,您将了解在设计和发展微服务时可能需要考虑的不同类型的安全性。
9.1. 保护微服务的重要性
保护微服务是一项至关重要的任务,需要在开发初期就考虑。如果不这样做,后期集成安全性的开发时间会更长。为什么?不设计安全性会导致代码可能需要在以后进行重大重构才能实现。
尽管在典型企业 Java 应用程序的开发之前不考虑安全性可能会轻易增加几个月的开发时间,但至少在微服务中,您通常需要重构的代码要少得多。即便如此,不是最好一开始就设计安全性并节省时间吗?
9.1.1. 为什么安全性很重要?
作为企业开发者,我们经常被要求开发各种应用程序,应用程序的最终用户可能是内部或外部,有时两者都是。图 9.1 展示了一组内部用户使用的微服务。
图 9.1. 内部用户

有这些要求,你就可以忽略安全性,对吧?错!
即使您只为内部用户开发微服务,您能保证围绕您的微服务的安全性会保持吗?如果或当任何防止外部网络入侵的安全屏障被破坏时会发生什么?
图 9.2 展示了如果网络安全被破坏,恶意用户(网络外部)将如何无限制地访问微服务。
图 9.2. 恶意外部用户

安全是一个不应该被理所当然地认为存在的功能,无论可能实施哪些预防措施。一个常见的误解是安全是无可挑剔的,这当然不是事实。
再次查看图 9.2,如果你不认为内部网络是安全的,你更倾向于在自己的微服务中添加额外的安全措施,以防止未经授权的访问。如果纯粹用于内部目的的每个应用程序或微服务都不包括自己的安全预防措施,那么你已将外部网络边界的安全性变成了一个单点故障。
这还不包括你可能在内部网络中有一个恶意用户的情况,如图 9.3 所示!尽管内部恶意用户可能并不常见,但这种情况不能被忽视。这种情况可能由许多原因引起:不满的员工或企业间谍活动最有可能。
几乎没有开发的应用程序不需要安全。这些应用程序大多仅限于提供对公众已经可用的只读数据的读取。
图 9.3. 恶意内部用户

这是一个相当狭窄的应用定义,你可以忽略安全。你的企业每天在建造多少这样的应用?可能没有!企业在其整个生命周期中开发或将要开发的应用程序类型,数量会非常少。静态数据也是公开可用的,这不会引起企业的兴趣。
所有这些意味着什么?这意味着没有任何应用程序或微服务可以完全忽视安全,任何时候都不可以。
9.1.2. 安全需要解决哪些问题?
既然你知道你需要安全,你需要解决哪些问题?这本身可能就是一本书的主题!因为你不是要为微服务重新创作《战争与和平》,所以你会关注那些最有兴趣的领域。
认证和授权是我们认为与微服务最相关的两个安全方面。在你深入探讨之前,你需要概述每个这些术语的含义。
认证在图 9.1、9.2 和 9.3 中展示。它仅涉及用户是否有权访问应用程序或微服务。无论该应用程序或微服务可能托管在哪里,或者用户是否属于企业或外部,认证纯粹关注用户能否访问应用程序。
如果微服务不需要区分用户是否允许访问,那么认证就是所需的全部。但如果经过认证的用户需要访问应用程序或微服务的不同部分,那么你还需要授权。
图 9.4 提供了一个用于微服务授权的用户角色示例。
图 9.4. 多个用户角色用于授权

你可以看到 Admin、Manager 和 User 这些角色,这些都是可能需要的典型角色。对于你的微服务可能需要的角色可能会有所不同,可能从零到多个,这取决于需求。
企业也可能拥有微服务,如图 9.5 所示。在这种情况下,你有一个由内部用户管理的微服务,其角色为 Admin。但微服务的用户对企业来说是外部的。
图 9.5. 内部和外部用户角色

从整个可能包含许多微服务的应用程序的角度来看,你通常需要认证和授权的混合来满足安全需求。对于应用程序中的单个微服务,你可能只需要关注用户请求的认证,而无需更多。
无论你的微服务可能需要什么——无论是认证、授权还是两者兼而有之——在设计阶段就需要考虑安全性,以确保它不会成为最后一刻的担忧。
那么你是如何将安全性添加到你的微服务中的呢?你当然可以开发自己的安全解决方案,但在许多情况下这并不理想。你需要花费时间开发、维护等等。开发自己的安全解决方案不仅会导致你想要开发的微服务开发延迟,而且还会给未来的开发者增加额外的维护负担。
你想要做的是利用一个由大量开发者开发和维护的稳定项目,该项目提供了你需要的处理安全用例。尽管你可能有很多这样的项目选项,但在这本书中,我们将选择 Keycloak。
9.2. 使用 Keycloak
Keycloak 是一个开源项目,为现代应用程序和服务提供身份和访问管理。向应用程序添加认证和确保服务安全可以以最小的麻烦实现。
9.2.1. 理解 Keycloak 的功能
Keycloak 提供了许多功能。以下是四个与微服务开发最相关的功能:
-
单点登录—— 允许用户对 Keycloak 进行认证,而不是对每个单独的应用程序或服务进行认证。用户登录 Keycloak 后,可以访问任何通过 Keycloak 进行认证的应用程序或服务。
-
社交登录—— 使用 Keycloak 启用社交登录非常简单!在管理控制台中配置社交网络即可。无需代码或应用程序更改。
-
用户联合— 如果您的用户在 LDAP 或 Active Directory 中注册,他们可以很容易地与 Keycloak 联合。如果用户存储在不同的类型中,例如关系数据库,您还可以开发自己的提供程序来访问您的用户。
-
标准协议— 默认情况下,Keycloak 提供了对 OpenID Connect、OAuth 2.0 和安全断言标记语言(SAML)的支持。
关于 Keycloak 及其所有功能的详细信息可以在其网站上找到,www.keycloak.org。
9.2.2. 设置 Keycloak
您需要做的第一件事是为您的微服务和应用程序下载 Keycloak 服务器以进行集成。就我们的目的而言,您有两种方法可以做到这一点。您可以下载一个为 Keycloak 定制的完整 WildFly 发行版,或者下载一个使用 Thorntail 构建的 Keycloak 服务器。为了保持微服务的方式,请选择 Thorntail 版本。我们示例所需的版本可以从mng.bz/s6r9下载。
下载后,在单独的端口上启动此版本,以免干扰您自己的微服务:
java -Dswarm.http.port=9090 -jar keycloak-2018.1.0-swarm.jar
当服务器启动后,在浏览器中导航到 http://localhost:9090/auth/。您将看到一个类似于图 9.6 的屏幕。
图 9.6. 设置 Keycloak 管理员用户

在 Keycloak 服务器上输入管理员账户的用户名和密码。然后点击创建。接下来,点击管理控制台链接以查看图 9.7 中的登录屏幕。
图 9.7. 登录 Keycloak 管理控制台

输入您在设置管理员账户时提供的凭据,然后点击登录按钮。
图 9.8 显示了 Keycloak 管理控制台的主屏幕。从这里,您可以修改和调整 Keycloak 的所有部分以满足您的需求。默认情况下,您会获得一个主域。
图 9.8. Keycloak 管理控制台

由于主域包含管理员用户,因此对于使用应用程序或微服务进行身份验证的用户,不使用此域是一种良好的做法。
Keycloak 域
Keycloak 域管理一组用户,包括他们的凭据、角色和组。域彼此隔离,只负责管理他们关联的用户。
域提供了一种将用户分组分离用于不同目的的方法。您可能有一个用于财务微服务的域,另一个用于人员管理微服务的域。这种分离确保了来自每个域的用户保持分离,但由单个 Keycloak 实例进行管理。
根据您的需求,Keycloak 足够灵活,可以处理应用程序或微服务所需的任何情况。对于典型的应用程序开发,尽管对于微服务仍然相关,但通常需要认证用户并在调用服务时使用其凭证。
图 9.9 展示了在 UI 内部对用户进行认证的请求路径。
图 9.9. 通过 UI 进行用户认证

认证步骤如下:
-
用户请求登录应用程序 UI。
-
UI 重定向到 Keycloak 进行登录。Keycloak 返回可用于发出认证请求的令牌。
-
用户选择加载需要认证的视图。
-
Keycloak 提供的 bearer 令牌 被添加到请求的 HTTP 头部。
-
从请求中提取令牌并将其传递给 Keycloak 进行验证。如果令牌有效,受保护的微服务能够处理请求。如果令牌无效,则返回 HTTP 401 状态码,表示未经授权的用户发出了请求。
定义
bearer 令牌 是一种具有特殊行为属性的安全令牌。任何持有令牌的实体都可以像持有相同令牌的其他实体一样使用它。使用 bearer 令牌不需要持有人证明对加密密钥的占有。
在前面的过程中,有一个微服务对自身进行认证以向受保护的微服务发出请求的微小变化。图 9.10 说明了这种变化。
图 9.10. 微服务认证

这个过程的不同之处在于,调用受保护微服务的任何调用者都不包含或接收来自用户的认证令牌:
-
请求被一个未受保护的微服务接收。
-
未受保护的微服务对 Keycloak 进行自身认证。
-
认证令牌通过请求的 HTTP 头部传递给受保护的微服务。
-
从请求中提取令牌并将其传递给 Keycloak 进行验证。如果令牌有效,受保护的微服务可以处理请求。如果令牌无效,则返回 HTTP 401 状态码,表示未经授权的用户发出了请求。
本章的剩余部分将提供这两个场景的示例。让我们看看如何使用 Keycloak 保护一些微服务。
9.3. 保护 Stripe 微服务
在本节中,您将了解如何在图 9.10 的场景中实现认证。来自第八章的 Stripe 和 Payment 微服务将采用与图 9.10 类似的安保措施。Payment 微服务将基于该章节中的 RESTEasy 客户端版本。让我们回顾一下之前的场景,这次是 Stripe 和 Payment;请参见图 9.11。
图 9.11. 带有 Stripe 和 Payment 的微服务认证

9.3.1. 配置 Keycloak
在您的 Keycloak 服务器运行后,下一步是为您的微服务定义一个区域。
登录到管理控制台后,将鼠标悬停在左上角的 Master 区域名称上,以显示添加区域按钮,如图 9.12 所示。
图 9.12. 在 Keycloak 中访问添加区域按钮

点击添加区域按钮,打开用于创建区域的屏幕。图 9.13 显示此屏幕。
图 9.13. 创建区域

点击选择文件选项,从书籍代码存储库的 /chapter9/keycloak 目录中定位 cayambe-realm.json。然后点击打开。
图 9.14 显示您将在 Keycloak 中创建的区域。要执行导入,您需要点击创建,以便 cayambe-realm.json 的内容被导入,并存在一个 Cayambe 区域。
图 9.14. 导入 Cayambe 区域

使用 Cayambe 区域,您正在利用 Keycloak 服务账户功能。此功能允许客户端通过 Keycloak 进行身份验证,而无需用户任何交互。此功能对于直接由用户触发的管理任务非常有用,例如需要身份验证的预定作业。
现在您的区域已创建,让我们看看您导入的 JSON 的部分,以便您可以看到 Keycloak 设置了什么。
列表 9.1. cayambe-realm.json
"realm": "cayambe", *1*
"enabled": true, *2*
...
"users": [
{
"username": "service-account-payment-service", *3*
"enabled": true,
"serviceAccountClientId": "payment-authz-service", *4*
"realmRoles": [
"stripe-service-access" *5*
]
}
],
"roles": {
"realm": [
{
"name": "stripe-service-access", *6*
"description": "Stripe service access privileges"
}
]
},
"clients": [
{
"clientId": "payment-authz-service", *7*
"secret": "secret", *8*
"enabled": true,
"standardFlowEnabled": false,
"serviceAccountsEnabled": true *9*
},
{
"clientId": "stripe-service", *10*
"enabled": true,
"bearerOnly": true *11*
}
]
-
1 指定区域名称为 cayambe
-
2 确保在加载后您的区域已启用
-
3 服务账户用户的唯一用户名
-
4 定义将使用服务账户进行身份验证的 clientId
-
5 应分配给服务账户用户的角色。
-
6 定义 stripe-service-access 区域角色
-
7 为您的支付微服务分配的唯一 clientId。
-
8 用于身份验证服务账户用户的密钥
-
9 为客户端启用 Keycloak 的服务账户功能
-
10 将被保护的 Stripe 微服务的客户端 ID。
-
11 标识客户端仅验证 bearer 令牌,但无法检索它们
您在此处定义的所有名称和 ID 在您创建的区域中都是唯一的,但它们本身没有意义。它们只是文本。
重要的是,区域中某个服务的客户端 ID 与服务配置中的规范相匹配(下一节将介绍)。有了这个,您的 Keycloak 服务器就准备好处理 Stripe 和支付的认证了。
9.3.2. 保护 Stripe 资源
第一步是保护 Stripe 微服务,以确保您在访问 Stripe API 时没有适当的身份验证。一旦您知道您已正确连接到服务,您将添加必要的身份验证。
如果您从 第八章 中的代码开始,您不需要修改 StripeResource 来添加安全性。非常酷,对吧?您可以在不修改其代码的情况下向现有的 RESTful 端点添加安全性!这是怎么做到的?
立即让 Maven 知道您想要使用 Keycloak 与您的 Thorntail 微服务一起使用。为此,您需要在您的 pom.xml 中添加一个依赖项:
<dependency>
<groupId>io.thorntail</groupId>
<artifactId>keycloak</artifactId>
</dependency>
唯一的其他任务是定义 Keycloak 的位置、其配置以及需要保护的内容。幸运的是,您可以使用 Thorntail 在一个文件中完成所有这些操作!您将一个 project-defaults.yml 文件添加到 Stripe 微服务的 src/main/resources 目录中,其内容如下所示。
列表 9.2. project-defaults.yml
swarm:
keycloak:
secure-deployments:
chapter9-stripe.war: *1*
realm: cayambe *2*
bearer-only: true *3*
auth-server-url: http://192.168.1.13:9090/auth *4*
ssl-required: external
resource: stripe-service *5*
enable-cors: true
deployment:
chapter9-stripe.war: *6*
web:
security-constraints:
- url-pattern: /stripe/charge/* *7*
roles: [ stripe-service-access ] *8*
-
1 定义 chapter9-stripe.war 部署的 Keycloak 配置章节
-
2 您的部署用于身份验证的领域——在本例中为 cayambe
-
3 将您的微服务标识为仅带 bearer。
-
4 服务域所在的服务器上的 Keycloak 服务器 URL。当服务部署到 Minishift 时,您不使用 localhost。
-
5 将此资源标识为 stripe-service,它对应于 cayambe-realm.json 中的客户端 ID。
-
6 定义 chapter9-stripe.war 部署特定配置的章节。这相当于可以在 web.xml 中提供的内容。
-
7 保护来自此微服务的 /stripe/charge URL 模式。
-
8 只有具有 stripe-service-access 角色的用户才能成功执行对这一微服务的请求。
现在您的 Stripe 微服务已经从未经身份验证的访问中得到了保护!让我们试一试。切换到 /chapter9/serviceauth/stripe 目录并运行以下命令:
mvn thorntail:run
尝试打开浏览器到 http://localhost:8080/stripe/charge,它将指示 未授权。从浏览器发送不带 bearer 令牌的 HTTP 请求会导致您的请求被拒绝,因为您没有进行适当的身份验证。
要查看更多细节,您可以使用显示 HTTP 网络调用的浏览器插件,或者从终端使用 curl。
列表 9.3. curl 的 Stripe 输出
$ curl -v http://localhost:8080/stripe/charge
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /stripe/charge HTTP/1.1 *1*
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized *2*
< Expires: 0
< Connection: keep-alive
< WWW-Authenticate: Bearer realm="cayambe"
< Cache-Control: no-cache, no-store, must-revalidate
< Pragma: no-cache
< Content-Type: text/html;charset=UTF-8
< Content-Length: 71
< Date: Sun, 25 Feb 2018 03:22:53 GMT
<
* Connection #0 to host localhost left intact
<html><head><title>Error</title></head><body>Unauthorized</body></html> *3*
-
1 HTTP 请求头
-
2 HTTP 响应头
-
3 HTTP 响应体
现在更容易看出您收到了一个 401 HTTP 响应代码,表示您尝试未经授权访问 URL。现在 Stripe 已经得到了适当的保护,另一个微服务如何在不接收用户凭证的情况下访问它?
您也可以按照以下方式将 Stripe 部署到 Minishift:
mvn clean fabric8:deploy -Popenshift
9.3.3. 在支付资源中进行身份验证
本章的支付微服务是从 第八章 中的 RESTEasy 客户端派生出来的。您只需进行一些小的修改,就可以使其对 Keycloak 进行身份验证。
要能够使用 Keycloak 对支付进行身份验证,您需要向 Keycloak Authz 客户端添加一个依赖项:
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-authz-client</artifactId>
<version>3.4.0.Final</version>
</dependency>
这个依赖项提供了你需要用于通过 Keycloak 进行身份验证的所有实用类。现在你需要定义你正在与之交互的 Keycloak,以及哪个 Payment 微服务位于 Cayambe 领域内。为此,你需要在 src/main/resources 目录下创建一个 keycloak.json 文件。
列表 9.4. Payment 服务的 keycloak.json
{
"realm": "cayambe", *1*
"auth-server-url": "http://192.168.1.13:9090/auth", *2*
"resource": "payment-authz-service", *3*
"credentials": {
"secret": "secret" *4*
}
}
-
1 你的部署用于身份验证的领域——在本例中,为 cayambe。
-
2 cayambe 领域所在的 Keycloak 服务器的 URL。
-
3 将此资源标识为 payment-authz-service,它对应于 cayambe-realm.json 中的 Client ID。
-
4 需要传递给 Keycloak 以验证此客户端的凭证。
这就是你需要做的所有配置。接下来,添加用于通过 Keycloak 进行身份验证的代码。因为你现在使用 Hystrix,你需要将身份验证处理添加到StripeCommand中。
列表 9.5. StripeCommand—getAuthzClient方法
private AuthzClient getAuthzClient() { *1*
if (this.authzClient == null) { *2*
try {
this.authzClient = AuthzClient.create(); *3*
} catch (Exception e) {
throw new RuntimeException("Could not create authorization
client.", e);
}
}
return this.authzClient;
}
-
1 添加一个用于检索 Keycloak AuthzClient 的辅助方法。
-
2 如果你还没有创建 AuthzClient,请继续。
-
3 创建 AuthzClient,它使用 keycloak.json 中的信息进行身份验证。
在拥有AuthzClient之后,你现在可以检索一个访问令牌,你可以将其添加到你对 Stripe 发出的任何请求中。为此,你必须修改StripeCommand中的run()方法,在你获得Resteasy-Client实例后添加一个请求过滤器。
列表 9.6. StripeCommand—run方法
protected ChargeResponse run() throws Exception {
ResteasyClient client = new ResteasyClientBuilder().build();
client.register((ClientRequestFilter) clientRequestContext -> { *1*
List<Object> list = new ArrayList<>();
list.add("Bearer " +
getAuthzClient().obtainAccessToken().getToken()); *2*
clientRequestContext.getHeaders().put(HttpHeaders.AUTHORIZATION,
list); *3*
});
ResteasyWebTarget target = client.target(serviceURI);
StripeService stripeService = target.proxy(StripeService.class);
return stripeService.charge(chargeRequest);
}
-
1 注册一个匿名 ClientRequestFilter 以修改 HTTP 请求。
-
2 使用 AuthzClient 从 Keycloak 检索一个访问令牌,将 Bearer 前缀添加到令牌中,并将其添加到列表中。
-
3 将你创建的列表添加到请求的 AUTHORIZATION HTTP 头部。
你只需做这些就能在向 Stripe 发出的任何请求上传递一个 bearer 令牌。很简单,对吧?
9.3.4. 测试你的受保护微服务
现在你已经设置了 Stripe 和 Payment,是时候查看所有正在运行并相互交互的服务了。如果你还没有启动 Keycloak 服务器和 Stripe,请再次启动它们,确保将 Stripe 部署到 Minishift。
然后,你需要通过切换到/chapter9/serviceauth/payment-service 目录并运行以下命令来启动 Payment:
mvn clean fabric8:deploy -Popenshift
打开 OpenShift 控制台以检索 Payment 的 URL。然后使用你在第七章和第八章中使用的相同工具对/sync 和/async 端点执行 HTTP POST 操作。如果你直接尝试访问 Stripe 微服务,你仍然会收到 HTTP 响应代码 401,表示你未授权。
要查看从支付功能调用 Stripe 时的 HTTP 头部信息,你需要拦截请求或找到其他方式来输出它。在这种情况下,你需要修改 Stripe 以直接输出 HTTP 请求和响应头部。
让我们在/chapter9/serviceauth/stripe 的 project-defaults.yml 中取消注释以下内容:
undertow:
servers:
default-server:
hosts:
default-host:
filter-refs:
request-dumper:
filter-configuration:
custom-filters:
request-dumper:
class-name: io.undertow.server.handlers.RequestDumpingHandler
module: io.undertow.core
重启 Stripe,然后在 Payment 上发出另一个 HTTP POST 请求。在 OpenShift 控制台中,找到 Stripe 服务条目,点击 pod 状态右侧的三个点。从那里,选择查看日志,您应该会看到 Stripe 的输出,如下所示:
----------------------------REQUEST---------------------------
URI=/stripe/charge
characterEncoding=null
contentLength=63
contentType=[application/json]
header=Accept=application/json
header=Connection=Keep-Alive
header=Authorization=Bearer
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJCTTRFT3FlZXU1bGowaWZw
cHR0aWtEejdnakhsNzBjd2hreGY4c05
NWU1NIn0.eyJqdGkiOiJmNDIyNmJlYS1hNWE2LTQ0NDgtOTBiZS1kNmI4NGUwY2FlOWUiLCJ
leHAiOjE1MTk1MzI0MTksIm5iZiI6MC
wiaWF0IjoxNTE5NTMyMzU5LCJpc3MiOiJodHRwOi8vMTkyLjE2OC4xLjEzOjkwOTAvYXV0aC
9yZWFsbXMvY2F5YW1iZSIsImF1ZCI6I
nBheW1lbnQtYXV0aHotc2VydmljZSIsInN1YiI6IjljZjAyOTQ5LTgxMzctNGM1Ny04MTY4L
TVhMzlhMDczMTRlMCIsInR5cCI6IkJl
YXJlciIsImF6cCI6InBheW1lbnQtYXV0aHotc2VydmljZSIsImF1dGhfdGltZSI6MCwic2Vz
c2lvbl9zdGF0ZSI6IjI5MGM3MTJiLTJ
kMzItNGZjMi05YWJjLTIxOGFlNTk2MjQwMiIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnM
iOltdLCJyZWFsbV9hY2Nlc3MiOnsicm
9sZXMiOlsic3RyaXBlLXNlcnZpY2UtYWNjZXNzIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnt9LC
JwcmVmZXJyZWRfdXNlcm5hbWUiOiJzZ
XJ2aWNlLWFjY291bnQtcGF5bWVudC1zZXJ2aWNlIn0.fO-mOqigv661fSj-
HNtVGixm_63QYw6Yl5Yo-
BpDy7vLNQ5uLnWXLTovkiCnOfB8K1mNlAgWM-h5Nwc7IUCy7MJtMg-
5L0ts0OOQRknIi42QrEN2kSTvQuTwJCtuhmQqfaV23rpn5SG7hf-
5RVFnpgq3ElfEMW2fs7Ygnv-
FlQ1Ls7Ns_uKZ7iH7kpwHl30xvXK_Lid9NXEyZI3e-
7DcpFZPvALRt5_xBJOZk2ZfdITBVKxKc3g7r78ndmK1rnC8ar6t8Fplba2pUv_HYrMvthGp6
XUwALr31qQcAmBS4Oua-
qRJr2oa7SwSPfkYBsdR_BvPO1rM2R9h8VSYb_5z-A
header=Content-Type=application/json
header=Content-Length=63
header=User-Agent=Apache-HttpClient/4.5.2 (Java/1.8.0_141)
header=Host=chapter9-stripe:8080
locale=[]
method=POST
protocol=HTTP/1.1
queryString=
remoteAddr=/172.17.0.5:47052
remoteHost=172.17.0.5
scheme=http
host=chapter9-stripe:8080
serverPort=8080
--------------------------RESPONSE--------------------------
contentLength=56
contentType=application/json
header=Expires=0
header=Connection=keep-alive
header=Cache-Control=no-cache, no-store, must-revalidate
header=Pragma=no-cache
header=Content-Type=application/json
header=Content-Length=56
header=Date=Sun, 25 Feb 2018 04:19:24 GMT
status=200
==============================================================
9.4. 捕获用户认证
为了了解您如何使用用户凭据调用受保护的微服务,让我们为 Cayambe 的新管理界面进行保护。
在这种情况下,已经决定需要一些用户能够从系统中删除类别。这看起来是合理的。但您不希望所有有权访问的人都能删除类别。这绝对不是理想的结果!
为了实现这一目标,您需要进行一些代码修改:
-
保护 JAX-RS 资源上的 HTTP DELETE 方法。
-
通过 Keycloak 集成以将用户登录到 UI。
-
在树中的类别 UI 上添加一个删除按钮,但仅在用户具有管理员角色时启用它。
9.4.1. 配置 Keycloak
在您之前设置 Cayambe 领域时,我没有展示它,但该领域已经根据您进行用户认证的需求进行了设置。现在让我们具体了解与用户认证相关的部分细节。
列表 9.7. cayambe-realm.json
"realm": "cayambe", *1*
...
"users": [
{
"username": "ken",
...
"realmRoles": [ *2*
"admin",
"user",
"offline_access"
],
...
},
{
"username": "bob",
...
"realmRoles": [ *3*
"user",
"offline_access"
],
...
}
],
"roles": {
"realm": [ *4*
{
"name": "user",
"description": "User privileges"
},
{
"name": "admin",
"description": "Administrator privileges"
}
]
},
"clients": [
{
"clientId": "cayambe-admin-ui", *5*
"enabled": true,
"publicClient": true, *6*
"baseUrl": "http://localhost:8080", *7*
"redirectUris": [
"http://localhost:8080/*"
]
},
{
"clientId": "cayambe-admin-service", *8*
"enabled": true,
"bearerOnly": true
}
]
-
1 指定要使用的领域名为 cayambe。
-
2 创建了一个名为 ken 的用户,该用户具有领域角色用户和管理员。
-
3 创建了一个名为 bob 的用户,该用户具有领域角色用户。
-
4 定义用户和管理员领域角色。
-
5 您 UI 的客户端 ID。
-
6 publicClient 表示客户端具有将用户登录到 Keycloak 的能力。
-
7 应用程序的基本 URL。
-
8 UI 使用的 JAX-RS 端点的客户端 ID。
现在,您已经准备好对应用程序所需的更改进行操作。
9.4.2. 保护类别删除
从第六章的 admin 目录中获取代码,您只需进行一些小的修改,就可以像对 Stripe 所做的那样进行保护。再次提醒,您需要在 Thorntail 中添加 Keycloak 的 Maven 依赖项:
<dependency>
<groupId>io.thorntail</groupId>
<artifactId>keycloak</artifactId>
</dependency>
接下来,您通过 project-defaults.yml 配置与 Keycloak 的集成。
列表 9.8. project-defaults.yml
swarm:
keycloak:
secure-deployments:
chapter9-admin.war:
realm: cayambe *1*
auth-server-url: http://192.168.1.13:9090/auth *2*
ssl-required: external
resource: cayambe-admin-service *3*
bearer-only: true
deployment:
chapter9-admin.war: *4*
web:
security-constraints:
- url-pattern: /admin/category/* *5*
methods: [ DELETE ]
roles: [ admin ]
-
1 您部署所使用的认证领域——在本例中,为 cayambe。
-
2 cayambe 领域所在 Keycloak 服务器的 URL。
-
3 将此资源标识为 cayambe-admin-service,它对应于 cayambe-realm.json 中的客户端 ID。
-
4 定义 chapter9-admin.war 的特定部署配置部分。这相当于可以作为 web.xml 的一部分提供的内容。
-
5 请求保护从该微服务中比/admin/category 更深的所有 URL 模式,针对 HTTP DELETE 方法。只有具有管理员角色的用户才能执行具有定义的 URL 和方法的请求。
为了通过 REST 安全删除类别,你需要做到这一步,但你会更进一步,提供有关谁在进行删除的详细信息。
通过从 Thorntail 添加 Keycloak 依赖项,你可以在微服务中检索发出请求的用户的详细信息。这很好,因为能够审计谁在做什么,尽管对于我们的目的,你将打印信息到控制台。
列表 9.9. CategoryResource
@DELETE
@Produces(MediaType.APPLICATION_JSON)
@Path("/category/{categoryId}")
@Transactional
public Response remove(
@PathParam("categoryId") Integer categoryId,
@Context SecurityContext context) throws Exception { *1*
String username = "";
if (context.getUserPrincipal() instanceof KeycloakPrincipal) { *2*
KeycloakPrincipal<KeycloakSecurityContext> kp =
(KeycloakPrincipal<KeycloakSecurityContext>)
context.getUserPrincipal(); *3*
username = kp.getKeycloakSecurityContext().getToken().getName(); *4*
}
try {
Category entity = em.find(Category.class, categoryId);
em.remove(entity);
System.out.println(username + " is deleting category with id: " +
categoryId); *5*
} catch (Exception e) {
return Response
.serverError()
.entity(e.getMessage())
.build();
}
return Response
.noContent()
.build();
}
-
1 将 JAX-RS SecurityContext 注入为方法参数。这让你可以访问来自 HTTP 请求的安全信息。
-
2 检查用户主体是否为 KeycloakPrincipal 类型,这是你预期的类型。
-
3 将用户主体检索到 KeycloakPrincipal。
-
4 从 HTTP 请求的令牌中获取发起请求的用户的用户名。
-
5 打印一个简单的审计消息,说明谁正在删除哪个地址。
9.4.3. 在 UI 中认证用户
现在由于你的 RESTful 端点对类别删除是安全的,你可以从应用程序 UI 中提供该功能。要查看你对 UI 所做的更改,请查看代码中第九章的 /chapter9/admin_ui/ui 目录。
在这种情况下,你选择通过将 NPM 依赖项添加到 package.json 中的 keycloak-js 来包含 Keycloak 提供的 JavaScript。你也可以直接从服务器下载适当的 JavaScript,从 http://localhost:9090/auth/js/keycloak.js。
与你的基于 Java 的服务一样,你需要一个 keycloak.json 文件来配置我们与 Keycloak 服务器的连接。
列表 9.10. Admin UI 的 keycloak.json
{
"realm": "cayambe",
"auth-server-url": "http://192.168.1.13:9090/auth",
"ssl-required": "external",
"resource": "cayambe-admin-ui",
"public-client": true
}
这段代码现在应该很熟悉了,因为它涵盖了连接到 Keycloak 的典型要求。它将 cayambe-admin-ui 定义为你之前作为客户端 ID 指定的资源,在导入到 Keycloak 中的 cayambe-realm.json 文件中。
在 keycloak.json 文件就绪后,你可以初始化你的 Keycloak 连接。
列表 9.11. keycloak-service.js
import Keycloak from 'keycloak-js'; *1*
const keycloakAuth = Keycloak('/keycloak.json'); *2*
keycloakAuth.init({ onLoad: 'check-sso' }) *3*
.success((authenticated) => { *4*
// Handle successful initialization
})
.error(() => {
// Handle failure to initialize
});
-
1 从 keycloak-js NPM 模块导入 Keycloak 对象。
-
2 创建 Keycloak 对象,并告诉它配置文件 keycloak.json 的位置。
-
3 使用 check-sso 初始化 Keycloak,它只检查用户是否当前已登录。
-
4 如果你成功连接到 Keycloak,你会收到一个认证参数,告诉你用户是否已认证。
作为 列表 9.11 中的 success() 处理的一部分,你想要设置你稍后需要的变量。其中之一是检索登录 Keycloak 的 URL,因为你需要将 URL 添加到 UI 中:
this.auth.loginUrl = this.auth.authz.createLoginUrl();
然后,你可以将此值传递到你的 ReactJS 组件的页面标题中,以便你可以提供登录链接:
<li className="dropdown">
<a className="dropdown-toggle nav-item-iconic"
href={this.props.login}>Login</a>
</li>
this.props.login被设置为 Keycloak 登录 URL 的值,您在this.auth.loginUrl中设置。您还希望将有关当前登录用户的信息添加到页面标题中,并提供一种让该用户注销的方式。探索 JavaScript 并了解它是如何工作的将成为您的练习。
最后一个步骤是在 UI 中提供一个按钮来删除一个类别。CategoryListContainer是一个 ReactJS 组件,它将为adminRole属性设置一个布尔值,以指示用户是否具有该角色。
然后,您只需要 HTML 代码来根据此属性启用和禁用按钮:
<button disabled={!this.props.adminRole} className="btn btn-danger"
onClick={() => this.props.onDelete(category.id)}>Delete</button>
这基本上完成了大部分 UI 工作,除了将您为认证用户拥有的令牌传递给任何需要它的请求。让我们现在就做这件事。
您需要修改 ReactJS 的delete动作,以便在请求中设置令牌,就像您在 Payment 微服务中之前所做的那样。在 JavaScript 中,这个过程是相似的。
列表 9.12. 删除管理员类别
import axios from 'axios'; *1*
const ROOT_URL = 'http://localhost:8081'; *2*
if (store.getState().securityState.authenticated) { *3*
store.getState().securityState.keycloak.getToken() *4*
.then(token => {
axios.delete(`${ROOT_URL}/admin/category/${id}`, { *5*
headers: {
'Authorization': 'Bearer ' + token *6*
}
})
.then(response => {
// Handle success response
})
.catch(error => {
// Handle errors
});
})
.catch(error => {
dispatch(notifyError("Error updating token", error));
});
} else {
dispatch(notifyError("User is not authenticated", ""));
}
-
1 导入一个 NPM 模块以帮助进行 HTTP 调用
-
2 定义地址微服务的 RESTful 端点的根 URL
-
3 检查是否存在认证用户
-
4 从 keycloak-service.js 检索一个认证令牌
-
5 定义您要执行的 HTTP DELETE 请求
-
6 将您从 keycloak.getToken()接收到的令牌设置到请求的授权头中
您忘记 UI 和 Keycloak 是如何交互的吗?让我们再次查看图 9.15。
图 9.15. 通过 UI 进行用户认证

每当 UI 在您的 RESTful 端点上调用delete时,如果存在,令牌将被设置在请求上。目前,UI 的其他请求不会传递令牌,但如果需要保护额外的端点或记录有关发起请求的用户的信息,则可以以类似的方式添加。
9.4.4. 测试新的 UI 和服务是否都正常工作
是时候尝试新的 UI 了。如果 Keycloak 还没有运行,请使用本章中之前使用的命令启动它。启动您的 Admin 服务的 RESTful 端点,切换到/chapter9/admin_ui/admin,然后运行以下命令:
mvn thorntail:run
最后,您可以运行 UI!您想要模拟一个生产构建,因此您需要一个单独的命令来构建并启动 UI:
mvn clean install
java -jar target/chapter9-ui-thorntail.jar
现在,您可以导航到 http://localhost:8080,您将看到应用程序的主页,如图 9.16 所示。
图 9.16. Cayambe Admin 屏幕

您可以看到您的类别,就像之前一样,但现在您在右上角还有一个登录链接,以及每个类别的禁用删除按钮。
点击登录,您将被重定向到 Keycloak 进行身份验证。将用户名输入为bob,密码输入为password。您将被重定向回您的应用程序,并且现在您已经认证成功,如图 9.17 所示。
图 9.17. 使用用户角色登录的 Cayambe Admin

虽然您已经认证,但删除按钮仍然处于禁用状态。因为 Bob 只有用户角色,您没有权限删除类别。
要了解如何删除一个类别,让我们通过点击右上角的用户详情退出 Bob,然后从选项中选择登出。
现在,让我们使用之前相同的密码以 ken 身份登录;参见图 9.18。
图 9.18. 使用管理员角色登录时的 Cayambe 管理员界面

删除按钮现在呈鲜艳的红色,表示您可以使用它。点击它将删除您选择的类别,您将看到该类别被删除,以及一条消息通知您类别已成功删除。
我在本章中没有涵盖很多 ReactJS 代码,例如检查令牌有效性和在它即将过期时刷新它的代码。请查看书中源代码中为应用程序提供的所有 Java-Script 代码。
摘要
-
无论微服务是为内部用户还是外部用户设计的,保护您的微服务都是至关重要的。您无法预测所有可能尝试通过您的微服务造成损害的恶意用户类型。
-
Keycloak 可以接受载体令牌,提供授权客户端,并提供为保护您的微服务配置的简单方法。
-
您可以在没有用户的情况下对 Keycloak 进行身份验证,这对于当接收方受到保护时进行微服务到微服务的调用是必不可少的。
-
您可以将 Keycloak 集成到应用程序 UI 中以提供身份验证,并将令牌传递到受保护的 RESTful 端点。
第十章. 构建微服务混合架构
本章涵盖
-
运行 Cayambe 单体
-
使用混合方法将微服务集成到 Cayambe 中
-
修改 Cayambe 以集成您的微服务
-
在混合云中运行集成的 Cayambe
本章首先向您展示旧版的 Cayambe 以及如何在本地运行它。然后,在介绍了一些关于使用混合方法集成微服务理论之后,您将重新审视您希望实现的新 Cayambe 架构。接下来,您将深入实施混合方法,使用本书迄今为止开发的微服务。最后,您将带着焕然一新的 Cayambe 单体以及所需的微服务,在云端运行它们。
10.1. Cayambe 单体
图 10.1 从用户的角度提供了一个 Cayambe 主页的提醒。
图 10.1. Cayambe 主页

Cayambe (sourceforge.net/projects/cayambe/) 被描述为“使用 Java Servlets & JSP & EJB 的 J2EE 电子商务解决方案”。它是基于 JDK 1.2 构建的,并使用 Apache Struts v1。现有的代码,最后更新于 15 年前,可以在 cayambe.cvs.sourceforge.net/viewvc/cayambe/ 找到,已被下载并导入到本书的代码仓库中的 /cayambe 目录下。
我在寻找兼容版本的 Apache Struts 以及使其在 JDK 8 上编译所需的更改方面遇到了初步挑战!我还解决了一些小错误,以确保基本 UI 尽可能地功能齐全(考虑到我没有参与 Cayambe 的创建,尽可能做到这一点)。
注意
需要进行的更改以编译和运行原始 Cayambe 代码超出了本书的范围。但您可以通过查看代码的 Git 提交历史来查看这些更改 mng.bz/4MZ5。
图 10.2 提供了 Cayambe 当前架构的代码层的详细视图。您从用于 UI 的 JavaServer Pages (JSP) 开始;这些页面与 Struts 表单和操作交互。反过来,它们与一层代表者交互,这些代表者与被称为 后端 的企业 JavaBeans (EJB) 通信,因为它们不涉及面向用户的代码。最后,EJB 在提供数据库持久性的数据访问对象 (DAO) 上执行调用。
图 10.2 提供了对 Cayambe 内部众多层的深入了解,以及每一层的哪些部分相互交互。例如,您可以看到 Struts 表单和操作对于 Admin WAR 和 Cart WAR 都使用相同的代表类来处理类别和产品。尽管这种情况在旧代码中很典型,但您应该使用如 DDD(领域驱动设计)这样的设计工具,这在第一章(kindle_split_010.xhtml#ch01)中已讨论过,以将管理域模型与用户下订单分离。您可能希望某些数据(如类别和产品)仅适用于网站管理员,而不适用于试图下订单的用户。
图 10.2. Cayambe 代码结构

10.2. 运行 Cayambe 单体
在本地运行 Cayambe 需要以下先决条件:
-
您可以从
mng.bz/uZdC下载 WildFly 11.0.0.Final -
您可以从
dev.mysql.com/downloads/connector/j/下载 MySQL Connector for Java -
本地或 Docker 容器中的运行中的 MySQL 服务器
10.2.1. 数据库设置
在运行中的 MySQL 服务器上,您现在可以设置数据库并加载数据。
列表 10.1. 创建数据库并加载数据
mysql -h127.0.0.1 -P 32768 -uroot *1*
create user 'cayambe'@'172.17.0.1' identified by 'cayambe'; *2*
grant all privileges on *.* to 'cayambe'@'172.17.0.1' with grant option; *3*
create database cayambe; *4*
use cayambe; *5*
source \cayambe\sql\mysql.sql *6*
source \cayambe\sql\test_data.sql *7*
-
1 以 root 用户身份连接到运行在 localhost 上 32768 端口的 MySQL 服务器。这可能在您的环境中不同。
-
2 创建一个名为 cayambe 的用户,密码为 cayambe。
-
3 授予 MySQL 服务器中 cayambe 用户权限。
-
4 创建一个名为 cayambe 的数据库。
-
5 切换到使用您刚刚创建的数据库。
-
6 执行 mysql.sql 中的 SQL 脚本来创建 Cayambe 所需的所有表。
-
7 执行 test_data.sql 中的 SQL 脚本来将初始数据加载到表中。
通过这些步骤,您现在有一个可以用于 Cayambe 的数据库。下一个任务是配置 WildFly 以能够访问您刚刚设置的数据库。
10.2.2. WildFly 设置
在您将 WildFly 11.0.0.Final 下载文件提取到您选择的目录之后,您需要提供一些设置,以便 WildFly 知道 MySQL 驱动器的位置。为此,您需要在 WildFly 提取的位置内创建 /modules/system/layers/base/com/mysql/main。
在您刚刚创建的目录中,复制您之前下载的 MySQL 连接器 for Java JAR 文件。在同一个目录中,创建此文件。
列表 10.2. 单体模式的 MySQL 驱动器模块.xml
<?xml version="1.0" encoding="UTF-8"?>
<module name="com.mysql"> *1*
<resources>
<resource-root path="mysql-connector-java-5.1.43-bin.jar"/> *2*
</resources>
<dependencies> *3*
<module name="javax.api"/>
<module name="javax.transaction.api"/>
</dependencies>
</module>
-
1 将模块名称设置为 com.mysql,与您创建的目录结构相匹配
-
2 MySQL 连接器 for Java JAR 的路径。您的 JAR 可能需要不同的版本号。
-
3 WildFly 中 JDBC 驱动器所需的某些依赖项
您在这里所做的是创建一个 JBoss 模块定义,该定义由 WildFly 使用。JBoss Modules 是 WildFly 管理类加载器核心的开源项目,它通过在类加载器之间分离类来防止冲突。对于此示例,您不需要了解 JBoss Modules 如何工作。您需要了解的是如何创建一个新的模块,就像您在这里所做的那样,以将 JDBC 驱动器添加到 WildFly 中。
最后,您需要告诉 WildFly 关于新的数据库驱动程序,并定义一个 Cayambe 可以用来与数据库通信的新数据源。所有 WildFly 配置都位于 standalone.xml 中。您需要在 WildFly 安装目录的 /standalone/configuration/ 中找到 standalone.xml 并打开文件进行编辑。找到数据源的子系统部分,并用以下内容替换整个部分。
列表 10.3. standalone.xml 片段
<subsystem >
<datasources>
<datasource jndi-name="java:jboss/datasources/ExampleDS"
pool-name="ExampleDS" enabled="true" use-java-context="true"> *1*
<connection-url>jdbc:h2:mem:test;DB_CLOSE_DELAY=-
1;DB_CLOSE_ON_EXIT=FALSE</connection-url>
<driver>h2</driver>
<security>
<user-name>sa</user-name>
<password>sa</password>
</security>
</datasource>
<datasource jta="true" jndi-name="java:/Climb" pool-name="MySqlDS"
enabled="true" use-ccm="true"> *2*
<connection-url>jdbc:mysql://localhost:32768/cayambe</connection-url> *3*
<driver-class>com.mysql.jdbc.Driver</driver-class>
<driver>mysql</driver> *4*
<security>
<user-name>cayambe</user-name> *5*
<password>cayambe</password>
</security>
<validation>
<valid-connection-checker
class-name="org.jboss.jca.adapters.jdbc.extensions.mysql
.MySQLValidConnectionChecker"/>
<background-validation>true</background-validation>
<exception-sorter class-
name="org.jboss.jca.adapters.jdbc.extensions.mysql
.MySQLExceptionSorter"/>
</validation>
</datasource>
<drivers>
<driver name="h2" module="com.h2database.h2"> *6*
<xa-datasource-class>org.h2.jdbcx.JdbcDataSource</
xa-datasource-class>
</driver>
<driver name="mysql" module="com.mysql"> *7*
<xa-datasource-class>com.mysql.jdbc.jdbc2.optional.MysqlXADataSource</
xa-datasource-class>
</driver>
</drivers>
</datasources>
</subsystem>
-
1 WildFly 中现有的 ExampleDS 数据源。它尚未被修改。
-
2 Cayambe 的数据源在 JNDI 名称 java:/Climb 下可访问
-
3 数据库的 MySQL 连接 URL。需要根据您的环境进行修改。
-
4 mysql 是驱动器定义的名称,它添加在列表的末尾。
-
5 在 MySQL 中为数据库创建的安全凭证
-
6 ExampleDS 的现有 h2 驱动器
-
7 指向您之前创建的 com.mysql 模块的 mysql 驱动器定义
这就是您需要做的所有配置 WildFly 以与 Cayambe 一起工作的步骤。
10.2.3. 运行 Cayambe
您几乎准备好启动 Cayambe 并看到它运行了。但首先您需要构建 EAR 部署。图 10.3 提醒您从部署角度来看 Cayambe 的样子,这是您在 第二章 中首次看到的。
图 10.3. Cayambe 单体部署

Cayambe 使用 EAR(企业应用程序存档)作为打包部署的手段。EAR 允许 Cayambe 包含多个 WAR 文件,以及可以共享的通用 JAR 库。
注意
尽管 EAR 是打包 Java EE 部署的首选方法,但当前更常见的是 WAR 部署。这并不意味着 EAR 没有被使用,无论是出于选择还是遗留代码,但 EAR 的使用不如以前普遍。
要构建 Cayambe,您需要切换到书籍代码的 /cayambe 目录并运行以下命令:
mvn clean install
Maven 将构建项目代码中包含的每个 JAR 和 WAR 文件,并将其打包成 EAR 文件供您部署。构建完成后,将 /cayambe-ear/target/cayambe.ear 复制到 WildFly 安装目录的 /standalone/deployments 中。
现在从 WildFly 安装根目录运行以下命令以启动 WildFly,包括您的部署:
./bin/standalone.sh
当 WildFly 启动时,会在控制台输出大量消息,然后您的部署开始。消息停止后,WildFly 准备接受 Cayambe 的流量,您应该会看到一个包含如下内容的消息:
WFLYSRV0025: WildFly Full 11.0.0.Final (WildFly Core 3.0.8.Final) started in
6028ms
您可以通过 http://localhost:8080 访问用户站点,通过 http://localhost:8080/admin 访问管理站点。
10.3. Cayambe 混合——单体与微服务
在 第一章 中,您学习了用于单体的混合模式,其中现有单体可以将现有功能迁移到微服务环境中。这种模式允许那些需要更高可扩展性或性能的单体部分被有效地分离,而无需重建整个单体以进行改进。让我们回顾一下使用混合模式的单体可能看起来是什么样子;参见 图 10.4。
图 10.4. 企业 Java 和微服务混合架构

注意
在这个特定实例中,您不会使用一个面向所有微服务的网关。
能够将单体应用拆分成多个部分,同时分离这些部分可能部署的位置,这确实有好处。虽然这样做会增加开销,至少在网络调用和性能方面,但优势通常大于任何缺点。当这些优势围绕关键方面,如持续交付和发布节奏时,这一点尤其正确。
在 第二章 中,您开发了一个新的管理 UI,以及用于与数据交互的 RESTful 端点。在 第七章 中,您引入了一个单独的微服务来处理卡片支付,以便更容易地与外部系统集成。最后,在 第九章 中,您为您最初在第二章 第二章 中创建的管理 UI 添加了安全性。
它们是如何结合在一起的?图 10.5 表示了 Cayambe 混合单体提出的架构。您将结合原始单体的大块内容以及您在本书中开发的新微服务。这种架构确实已经从起点走了很长的路,但您仍然有一些工作要做。
图 10.5. 提出的 Cayambe 混合单体

那么,您在 图 10.5 中到底做了什么?您想要在结账过程中集成支付微服务以处理卡片支付,并且您希望 UI 从 Admin 而不是存储数据本身检索类别信息。除了新的微服务之外,您还用一个新的 UI 替换了管理界面,因此您可以从 Cayambe 中删除旧的界面。
让我们看看每个集成的需求。Cayambe 混合单体及其微服务的所有代码都包含在本书的代码中,位于 /chapter10。
10.3.1. 集成支付微服务
由于集成了支付微服务(特别是您正在使用外部支付提供商——在这种情况下,Stripe),您不再需要存储客户的信用卡信息。这是一个巨大的好处,因为存储信用卡信息的规则和限制可能很难执行,将这项责任转交给在该领域专门化的公司更容易。
由于您不需要存储这些信息,让我们将其从 Cayambe 的 billing_info 表中删除。您修改 /sql/cayambe/mysql.sql,以便删除以下列:
-
name_on_card -
card_type -
card_number -
card_expiration_month -
card_expiration_year -
authorization_code
您将这些列替换为一个用于 card_charge_id 的单列。在更改数据库中存储的内容时,您还需要更新传递这些值的类。
列表 10.4. OrderDAO
public class OrderDAO {
public void Save(OrderVO orderVO)
{
...
StringBuffer sqlBillingInfo = new StringBuffer(512);
sqlBillingInfo.append("insert into billing_info ");
sqlBillingInfo.append("(order_id,name,address1,address2,city,state,
zipcode,country,name_on_card,");
sqlBillingInfo.append("card_charge_id,phone,email) "); *1*
sqlBillingInfo.append("values ('" );
sqlBillingInfo.append(orderId);
sqlBillingInfo.append("','");
...
sqlBillingInfo.append(orderVO.getBillingInfoVO().getCountry());
sqlBillingInfo.append("','");
sqlBillingInfo.append(orderVO.getBillingInfoVO().getCardChargeId()); *2*
sqlBillingInfo.append("','");
...
}
...
public OrderVO getOrderVO( OrderVO orderVO )
{
...
b.setCardChargeId( rs.getString("billing_info.card_charge_id") );*3*
...
}
}
-
1 从选择语句中删除现有的卡片列,并添加 card_charge_id。
-
2 删除对删除列设置值的调用,并用 getCardChargeId() 替换新字段。
-
3 删除旧卡片数据列的检索,并添加一个用于 card_charge_id 的列。
在这里,您修改OrderDAO,该 DAO 直接与数据库交互以存储和检索订单数据。因为您已经修改了BillingInfoVO上的方法,所以您现在也需要在那里进行更改,如列表 10.5 所示。
列表 10.5. BillingInfoVO
public class BillingInfoVO implements Serializable {
private Long billingId = null;
private Long orderId = null;
private String name = null;
private String address = null;
private String address2 = null;
private String city = null;
private String state = null;
private String zipCode = null;
private String country = null;
private String phone = null;
private String email = null;
private String cardToken = null; *1*
private String cardChargeId = null;
...
public void setCardToken ( String _cardToken ) { cardToken = _cardToken; }*2*
public String getCardToken () { return cardToken; }
public void setCardChargeId ( String _cardChargeId ) { cardChargeId =
_cardChargeId; }
public String getCardChargeId () { return cardChargeId; }
}
-
1 将 nameOnCard、cardType、cardExpirationMonth、cardExpirationYear 和 authorizationCode 替换为 cardToken 和 cardChargeId。cardToken 用于从 UI 传递令牌,您很快就会看到。
-
2 删除之前提到的字段的 getter 和 setter 方法,并为 cardToken 和 cardChargeId 添加它们。
现在您已经修改了传递的数据对象,让我们添加从 Cayambe 单体内部调用 Payment 客户端代理所需的代码。
为了能够发送和接收 JSON 并将其转换为对象,您需要ChargeStatus、PaymentRequest和PaymentResponse。为了方便起见,您已经从 Payment 微服务中复制了这些文件,因此您已经有了它们。接下来,您需要一个表示支付的接口。
列表 10.6. PaymentService
@Path("/")
public interface PaymentService {
@POST
@Path("/sync") *1*
@Consumes(MediaType.APPLICATION_JSON) *2*
@Produces(MediaType.APPLICATION_JSON)
PaymentResponse charge(PaymentRequest paymentRequest); *3*
}
-
1 您正在使用 Payment 微服务的/sync 端点。
-
2 RESTful 端点将消费和产生 JSON。
-
3 要代理的方法将调用 Payment。
为了定义外部 Payment 微服务,您需要这些所有内容。现在让我们看看如何将其集成到现有的 Struts 代码中。在保存订单之前处理卡交易,您需要从/cayambe-hybrid/checkout 内部修改SubmitOrderAction。
列表 10.7. SubmitOrderAction
public class SubmitOrderAction extends Action *1*
{
public ActionForward perform( ActionMapping mapping, ActionForm form,
HttpServletRequest request, HttpServletResponse response )
throws IOException, ServletException
{
...
OrderActionForm oaf = (OrderActionForm)form;
try {
delegate = new CheckOutDelegate();
OrderVO orderVO = new OrderVO();
orderVO = (OrderVO)oaf.toOrderVO();
orderVO.setCartVO( (CartVO) session.getAttribute("Cart") );
// Call Payment Service
ResteasyClient client = new ResteasyClientBuilder().build();
ResteasyWebTarget target =
client.target("http://cayambe-payment-service-
myproject.192.168.64.33.nip.io"); *2*
PaymentService paymentService = target.proxy(PaymentService.class); *3*
PaymentResponse paymentResponse =
paymentService.charge(new PaymentRequest() *4*
.amount((long) (orderVO.getCartVO().getTotalCost()
* 100))
.cardToken(oaf.getCardToken())
.orderId(Math.toIntExact(orderVO.getOrderId()))
);
orderVO.getBillingInfoVO().setCardChargeId(paymentResponse.getChargeId());*5*
delegate.Save ( orderVO );
CartDelegate cartDelegate = new CartDelegate();
cartDelegate.Remove( orderVO.getCartVO() );
} catch(Exception e) {
forwardMapping = CayambeActionMappings.FAILURE;
errors.add( ActionErrors.GLOBAL_ERROR, new
ActionError("error.cart.UpdateCartError") );
}
return mapping.findForward( forwardMapping );
}
}
-
1 用于处理订单提交的现有 Struts 操作类
-
2 创建一个 ResteasyClient,在 OpenShift 中调用 Payment。
-
3 创建 PaymentService 的代理实例。
-
4 调用 Payment,传递一个包含订单金额和 Stripe 的 cardToken 的 PaymentRequest。
-
5 将从 Payment 返回的 chargeId 设置到 BillingInfoVO 上。
上述代码将熟悉于第六章、第七章和第八章,因为您在那些示例中也使用了 RESTEasy 客户端代理生成。
在创建PaymentRequest实例时,您调用了oaf.getCardToken(),它包含您处理 Stripe 请求所需的卡令牌。但您需要更新OrderActionForm以提供该信息。
OrderActionForm位于/cayambe-hybrid/web-common。您需要删除以下字段及其关联的 getter 和 setter:
-
nameOnCard -
cardNumber -
cardType -
cardExpirationMonth -
cardExpirationYear
最后,您添加了一个类型为String的cardToken字段,以及它的 getter 和 setter。
让我们修改结账页面以捕获信用卡详情,在调用 Stripe 检索代表信用卡的cardToken之前。为此,您需要更新位于/cayambe-hybrid/checkout 中的 CheckOutForm.jsp。
列表 10.8. CheckOutForm.jsp
...
<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
var stripe = Stripe({STRIPE_PUBLISH_KEY}); *1*
var elements = stripe.elements(); *2*
...
var card = elements.create('card', {style: style});
function stripeTokenHandler(token) {
// Insert the token ID into the form so it gets submitted to the server
var cardToken = document.getElementById('cardToken');
cardToken.value = token.id; *3*
// Submit the form
document.getElementById('orderForm').submit(); *4*
};
document.body.onload = function() {
card.mount('#card-element'); *5*
card.addEventListener('change', function(event) { *6*
var displayError = document.getElementById('card-errors');
if (event.error) {
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
});
var form = document.getElementById('orderForm'); *7*
form.addEventListener('submit', function(event) {
event.preventDefault();
stripe.createToken(card).then(function(result) { *8*
if (result.error) {
// Inform the user if there was an error.
var errorElement = document.getElementById('card-errors');
errorElement.textContent = result.error.message;
} else {
stripeTokenHandler(result.token); *9*
}
});
});
};
</script>
<form:form name="OrderForm" styleId="orderForm"
type="org.cayambe.web.form.OrderActionForm"
action="SubmitOrder.do" scope="request">
...
<tr>
<th align="right">
<label for="card-element">
Enter card details
</label>
</th>
<td align="left">
<div id="card-element"> *10*
<!-- A Stripe Element will be inserted here. -->
</div>
<!-- Used to display form errors. -->
<div id="card-errors" role="alert"></div>
<form:hidden property="cardToken" styleId="cardToken"/> *11*
</td>
</tr>
...
</form:form>
-
1 创建一个 Stripe JavaScript 实例,传入一个发布者密钥。
-
2 初始化 Stripe 的预构建 UI 组件。
-
3 将您从 Stripe 收到的令牌 ID 设置到 cardToken 元素上。
-
4 获取 orderForm 并提交它。
-
5 当文档加载时,将 Stripe 卡元素挂载到 card-element div 上。
-
6 在 UI 组件上添加一个事件监听器来处理 Stripe 错误。
-
7 在 orderForm 上添加一个提交事件监听器。
-
8 提交事件监听器请求 Stripe 从 UI 中的卡元素创建令牌。
-
9 如果 Stripe 返回成功,调用 stripeTokenHandler 函数。
-
10 用于容纳 Stripe 为您创建的卡元素的 div。
-
11 添加一个隐藏表单字段,将 Stripe 卡令牌传递到 OrderActionForm。
备注
如何将 Stripe 的 UI 元素集成到网站中的完整细节可以在 stripe.com/docs/stripe-js 找到。
除了将卡捕获添加到表单的表中之外,您还删除了所有捕获每条信用卡信息的现有字段。
为了使前面的 CheckOutForm.jsp 正常工作,您还需要修改 /cayambe-hybrid/checkout 中的 struts-forms.tld,为表单和隐藏标签都添加 styleId。这允许您设置一个将被添加到生成的 HTML 元素的 id 属性中的名称。
这就是您需要为 Payment 微服务集成所做的所有更改。现在,是时候集成 Admin 了!
10.3.2. 集成 Admin 微服务
要集成 Admin,您需要做类似于 Payment 的事情——至少在这一点上,您希望提供发送和接收对象到 Admin 所需的类,以及从表示 Admin 的接口生成代理。
除了能够调用 Admin 之外,您还需要将类别检索集成到 Cayambe 单体中,无论当前在哪里使用类别。在查看 Cayambe 中类别的定义时,您会发现类别是一个独立的数据库表,而类别/父级关系存在于另一个表中。
您还会看到类别从 Cayambe 单体中的各个层被调用,并且 Category EJB 提供了许多与分布在许多 Java 类中的类别交互的方式。这种情况并不利于 Admin 的顺利集成,至少不是像 Payment 那样集成。
由于集成将需要在几乎整个堆栈中进行大量代码更改,你决定尽管这种增强很有益,但与之相关的风险太多。在希望敏捷灵活的同时,你不想因为处理问题而被拖累数周或数月来集成 Admin。这些问题可能包括实际代码集成的问题,以及在 Cayambe 中测试新更改的大量时间——除了回归测试以确保更改不会对代码的其他部分产生连锁反应。这是令人遗憾的,但有时为了生产代码的稳定性,需要做出这样的艰难决定。
难道我浪费了这本书的大部分内容,让你编写一个你不会使用的新的 Admin UI 和服务?远非如此!在第十一章中,你将通过使用事件流来最小化你的风险担忧,允许你在 Cayambe 单体中保留现有代码,同时仍然利用新的 Admin UI 和微服务。
10.3.3. 新的行政 UI
你已经看到了新的行政 UI 及其相关的微服务,但在 Cayambe 单体中已经存在一个行政部分。你从/cayambe-hybrid/admin 中移除了内容,因为你不再需要现有的行政 UI。接下来,你移除了/cayambe-hybrid/cayambe-ear 中存在的所有对 Admin.war 的引用,因为这个 WAR 不再是 EAR 的依赖项,也不需要打包在其中。
10.3.4. Cayambe 混合总结
图 10.6 展示了你当前所处的完整情况,以及尚未开发的剩余部分。你将在第十一章中添加剩余部分。
图 10.6. Cayambe 混合单体

10.4. 将一切部署到混合云
由于你已经将 Cayambe 单体转换为混合型,部署一切变得更加复杂——但你也在手动完成所有工作。在实际环境中,你希望部署自动化,以使过程更加简单。
本节涵盖了 Cayambe 混合需要设置、配置或部署以运行的所有部分。你需要做的第一件事是让 Minishift 运行。它也应该从一个干净的 OpenShift 环境开始,以移除可能存在的任何服务。你需要在本地 OpenShift 中获得尽可能多的空间!所以,让我们删除你现有的任何 Minishift 虚拟机(虚拟机),从头开始:
> minishift delete
> minishift start --cpus 3 --memory 4GB
与 Minishift 之前执行的版本相比,主要区别在于你指定了三个虚拟 CPU 和 4GB 的内存。这是必要的,以确保你有足够的空间安装本章节和下一章节所需的服务。
10.4.1. 数据库
让我们创建一个 MySQL 数据库来存储你的数据!运行minishift console并登录到 OpenShift 控制台。
打开默认的 My Project。点击顶部附近的添加到项目菜单项并选择浏览目录。这提供了 OpenShift 可以为您安装的所有预构建镜像类型,如图 10.7 所示。
图 10.7. 在 OpenShift 控制台中选择浏览目录选项

点击底部行中的数据存储框,以查看可用的不同数据存储。在打开的数据存储页面中,如图 10.8 所示,点击 MySQL(持久)框中的选择按钮。
您将看到一个包含 MySQL 配置的页面,其中大部分可以保留默认设置。您需要设置的唯一选项是 MySQL 连接用户名、MySQL 连接密码和 MySQL 根用户密码。输入这些字段的值,记下这些信息,然后点击创建。
警告
不要使用cayambe作为 MySQL 连接用户名,因为这会与您稍后需要创建的用户冲突。
图 10.8. OpenShift 控制台—数据存储

一两分钟后,OpenShift 中将有一个 MySQL 服务可用。为了能够远程访问服务并设置 Cayambe 所需的数据库、表和数据,请打开终端窗口,使用oc login登录到 OpenShift CLI,然后运行oc get pods。该命令返回一个类似以下列表:
NAME READY STATUS RESTARTS AGE
mysql-1-xq98q 1/1 Running 0 2m
您需要复制 MySQL pod 的名称,在本例中为 mysql-1-xq98q,以连接到它:
oc rsh mysql-1-xq98q
在 pod 内部,您然后可以运行以下命令以打开 MySQL 实例的命令提示符:
mysql -u root -p$MYSQL_ROOT_PASSWORD -h $HOSTNAME
在 MySQL pod 内部,$MYSQL_ROOT_PASSWORD和$HOSTNAME被定义为环境变量,因此您不需要记住它们来连接到 MySQL 实例。现在您已经进入 MySQL,让我们设置您所需的数据!
管理微服务数据
以下命令为管理数据库创建一个cayambe-admin用户,授予用户对cayambe_admin数据库的所有权限,创建数据库,并最终切换到使用该数据库:
create user 'cayambe-admin' identified by 'cayambe-admin';
grant all privileges on cayambe_admin.* to 'cayambe-admin' with grant option;
create database cayambe_admin;
use cayambe_admin;
在cayambe_admin数据库的上下文中,您现在可以执行一些 SQL 来创建表并填充初始数据。
打开/chapter10/sql/admin/mysql.sql,并将内容粘贴到您已登录 MySQL 的终端窗口中。您应该会看到 SQL 语句快速闪过,如果一切顺利,则没有错误!现在表已经存在,用/chapter10/sql/admin/data.sql 进行相同的操作以加载数据。
支付微服务数据
您现在为cayambe-payment用户和cayambe_payment数据库运行一组类似的命令:
create user 'cayambe-payment' identified by 'cayambe-payment';
grant all privileges on cayambe_payment.* to 'cayambe-payment' with grant
option;
create database cayambe_payment;
use cayambe_payment;
现在打开/chapter10/sql/payment-service/mysql.sql,并将内容粘贴到您已登录 MySQL 的终端窗口中。这将创建所需的两个表,并为 JPA 需要的 ID 序列生成器设置一个初始值。
Cayambe 单体数据
最后,为cayambe用户和数据库运行一组类似的命令:
create user 'cayambe' identified by 'cayambe';
grant all privileges on cayambe.* to 'cayambe' with grant option;
create database cayambe;
use cayambe;
打开/chapter10/sql/cayambe/mysql.sql 并将其内容粘贴到终端窗口中,这将创建 Cayambe 单体所需的所有表。然后复制/chapter10/sql/cayambe/test_data.sql 的内容以加载初始测试数据。
10.4.2. 安全性
您已经有一个 Keycloak 服务器,它是作为第九章的一部分设置的,所以您将重用它:
/chapter9/keycloak> java -Dswarm.http.port=9090 -jar keycloak-2018.1.0-
swarm.jar
打开 http://localhost:9090/auth/ 并登录到管理控制台。从左侧导航菜单中选择“客户端”选项。从可用客户端列表中点击 cayambe-admin-ui 以打开其详细信息。您需要做的只是更新指定新管理 UI 运行的三个 URL,将端口从 8080 更改为 8090。
10.4.3. 微服务
现在是时候开始将微服务部署到 OpenShift 了。
Admin 微服务
由于 Admin 微服务是从前面的章节中引入的,所以除了部署之外,您不需要对它做任何事情!
/chapter10/admin> mvn clean fabric8:deploy -Popenshift
在此微服务部署后,您应该在 OpenShift 控制台中看到它。
Stripe 微服务
与 Admin 一样,你不需要在 Stripe 代码中做任何事情,所以你只需部署它:
/chapter10/stripe> mvn clean fabric8:deploy -Popenshift
支付微服务
接下来,您需要部署 Payment,这与其他部署方式相同:
/chapter10/payment-service> mvn clean fabric8:deploy -Popenshift
10.4.4. Cayambe 混合
现在您已经准备好为 Cayambe 混合设置 WildFly 应用程序了。您可以使用本章前面下载的 WildFly 11 和 MySQL 连接器 JAR 文件,并将它们解压缩到一个新目录中。
在它们全部提取后,创建一个与/wildfly-11.0.0.Final/modules/system/layers/base/com/mysql/main 相匹配的目录结构。到那个目录中,复制 MySQL 连接器的 JAR 文件,并创建一个包含以下内容的 module.xml 文件。
列表 10.9. 混合版 MySQL 驱动模块.xml
<?xml version="1.0" encoding="UTF-8"?>
<module name="com.mysql">
<resources>
<resource-root path="mysql-connector-java-5.1.43-bin.jar"/> *1*
</resources>
<dependencies>
<module name="javax.api"/>
<module name="javax.transaction.api"/>
</dependencies>
</module>
- 1 这里引用的特定版本需要与您复制到目录中的文件相匹配。
接下来,您需要向 WildFly 提供它需要配置 Cayambe 数据源的信息。打开/wildfly-11.0.0.Final/standalone/configuration/standalone.xml,并用以下内容替换当前数据源的子系统配置。
列表 10.10. standalone.xml
<subsystem >
<datasources>
<datasource jndi-name="java:jboss/datasources/ExampleDS" pool-
name="ExampleDS"
enabled="true" use-java-context="true">
<connection-url>jdbc:h2:mem:test;DB_CLOSE_DELAY=-
1;DB_CLOSE_ON_EXIT=FALSE</connection-url>
<driver>h2</driver>
<security>
<user-name>sa</user-name>
<password>sa</password>
</security>
</datasource>
<datasource jta="true" jndi-name="java:/Climb" pool-name="MySqlDS"
enabled="true" use-ccm="true">
<connection-url>jdbc:mysql://localhost:53652/cayambe</connection-url>
<driver-class>com.mysql.jdbc.Driver</driver-class>
<driver>mysql</driver>
<security>
<user-name>cayambe</user-name>
<password>cayambe</password>
</security>
<validation>
<valid-connection-checker
class-name="org.jboss.jca.adapters.jdbc.extensions.mysql
.MySQLValidConnectionChecker"/>
<background-validation>true</background-validation>
<exception-sorter class-name="org.jboss.jca.adapters.jdbc.extensions.mysql
.MySQLExceptionSorter"/>
</validation>
</datasource>
<drivers>
<driver name="h2" module="com.h2database.h2">
<xa-datasource-class>org.h2.jdbcx.JdbcDataSource</
xa-datasource-class>
</driver>
<driver name="mysql" module="com.mysql">
<xa-datasource-
class>com.mysql.jdbc.jdbc2.optional.MysqlXADataSource</xa-datasource-
class>
</driver>
</drivers>
</datasources>
</subsystem>
列表 10.10 与列表 10.3 几乎相同,除了一个区别:Cayambe 的 MySQL 实例端口号设置为 53652。您可能会想知道这个端口号的来源,因为它不是一个标准的 MySQL 端口。好吧,您将通过在 OpenShift 中转发mysql服务端口来定义该端口,以便您可以访问它:
oc port-forward {mysql-pod-name} 53652:3306
注意
如果现有的转发端口关闭,或者您的机器重新启动,您必须在 WildFly 能够找到数据库之前重新运行此命令。
10.4.5. Cayambe EAR
现在 WildFly 已经设置好了,让我们部署修改后的 Cayambe 混合 EAR。首先您需要构建它!
/chapter10/cayambe-hybrid> mvn clean install
构建完成后,将 /cayambe-hybrid/cayambe-ear/target/cayambe.ear 复制到 /wildfly-11.0.0.Final/standalone/deployments。现在您开始启动 WildFly:
/wildfly-11.0.0.Final/bin/standalone.sh
启动 WildFly 后,现在可以通过打开 http://localhost:8080 来尝试 Cayambe UI。
10.4.6. 管理界面
最后要启动的是新的管理界面,因为您已经从之前启动了 Admin 微服务。
对于大部分代码,它与您在第九章中使用的代码相同,但有两次小的修改。您调整了 UI 运行的端口为 8090,这样就不会与主 UI 冲突,并且您还修改了 /chapter10/admin-ui/app/actions/category-actioxns.js 中的 ROOT_URL,使其成为 OpenShift 控制台中 cayambe-admin-service 显示的 URL。
注意
请确保从 URL 中删除尾随斜杠。
是时候开始启动管理界面了:
/chapter10/admin-ui> mvn clean package
/chapter10/admin-ui> npm start
摘要
-
您设置了并运行了 Cayambe 单体,以展示在您进行任何修改之前的代码。
-
您将本书中开发的所有微服务集成到 Cayambe 中,并对 Cayambe 进行了必要的修改以实现集成。
-
您了解到,尽管您可能想集成一个微服务(在这种情况下,是 Admin),但有时这样做可能会增加太多风险,因此需要考虑其他选项。
-
您部署了少量微服务以及 Cayambe 混合体,并使它们共同运行。
第十一章. 使用 Apache Kafka 进行数据流
本章涵盖
-
理解 Apache Kafka 的数据流
-
使用数据流简化架构
-
将数据流集成到 Cayambe 混合体中
在第十章中,您组装了 Cayambe 混合体,将精简后的单体与您的新微服务相结合。本章通过将其切换到使用数据流来简化 Cayambe 混合体中管理数据的访问。
首先,您将了解数据流及其如何使开发人员和架构师都受益。带着这些课程,您将开发一个用于上一章 Cayambe 混合体的数据流解决方案,完成从单体到混合体的旅程。
11.1. Apache Kafka 能为您做什么?
在深入研究 Apache Kafka、您将要用于记录和处理数据流的解决方案之前,您需要对数据流有一些背景知识。否则,作为企业 Java 开发人员,Apache Kafka 做什么以及它是如何工作的将完全陌生。
11.1.1. 数据流
数据流不仅仅是指 Netflix 如何将电影播放到您的所有设备上。它还指来自可能成千上万个来源的持续生成数据流;每条数据或记录都很小,并且按接收顺序存储。这可能听起来有很多术语,但数据流仍然相对较新,并且总是有新的使用方式在构思中。
哪些类型的数据适用于数据流?简而言之,几乎任何类型的数据在数据流的背景下都可能是有用的。常见的例子包括来自车辆传感器的测量数据、股市的实时股价,以及来自社交网络和网站的流行话题。
数据流的一个常见用例是当你拥有大量数据或记录,并且你想分析它们以寻找模式或趋势时。可能大量数据可以完全忽略,只有关键数据片段是相关的。同一组数据记录也可能有不同的用途,这取决于可能消费它的系统!例如,一个捕捉页面访问事件流的电子商务网站可以使用相同的数据不仅记录用户在购买前访问的页面数量,还可以分析每个页面在所有用户中的查看次数。这就是数据流的美丽之处:你可以用同一组数据解决不同的问题。
图 11.1 展示了数据作为流接收的方式。
图 11.1. 数据流管道

特定类型的数据可能来自许多来源,并且按照系统记录数据流的接收顺序添加到流的末尾或管道中。在流中插入特定记录的概念是不存在的。所有东西都是按照接收的顺序添加到末尾的。
虽然你有多种记录和处理数据流的方法,但本章将专注于 Apache Kafka,让我们现在看看它。
定义
为了明确本章中使用的术语,术语“数据流”、“数据流”和“流式传输”都指的是同一件事:从源捕获数据的过程。
11.1.2. Apache Kafka
Apache Kafka (kafka.apache.org/)最初由 LinkedIn 于 2010 年开发,作为其中心数据管道的核心。目前,该管道每天处理超过 2000 亿条消息!在 2011 年初,Apache Kafka 被提议作为 Apache 的一个开源项目,并在 2012 年底结束了孵化阶段。在短短几年内,许多企业开始使用 Apache Kafka,包括 Apple、eBay、Netflix、Spotify 和 Uber。
什么是 Apache Kafka?它是一个分布式流平台。我这里所说的“分布式”是什么意思?Apache Kafka 可以将单个数据流的数据分区到集群内的多个服务器上。此外,每个分区都在服务器之间复制,以实现该数据的容错性。
Apache Kafka 有许多配置方式,包括其分布方式和容错级别,但这些内容超出了本章的范围。对于完整详情,请参阅 Apache Kafka 文档kafka.apache.org/documentation/。
作为流媒体平台,Apache Kafka 提供的关键功能如下:
-
发布和订阅记录流。
-
以容错和持久的方式存储记录流。
-
在发生时处理记录流。
在其核心,Apache Kafka 是一个分布式提交日志:它不会在数据被记录到流中时通知源,直到数据被提交到日志中。正如我之前提到的,分布式指的是日志或流中的每个提交被分散到分区并复制。
另一种描述 Apache Kafka 的方式是将其视为没有衣服的数据库:数据处于前沿,没有隐藏。数据库在其核心使用提交日志,就像 Apache Kafka 一样,用于跟踪更改和作为从服务器故障中恢复以重建数据库的手段。在 Apache Kafka 中,数据库的衣服(表、索引等)已经被剥离,只剩下提交日志。这使得 Apache Kafka 比常规数据库更易于消费和访问。
Apache Kafka 还使用了企业 Java 开发者熟悉的语义,这些开发者已经集成了消息系统。有生产者,它们生成记录或事件,这些事件被添加到流中,相当于之前图 11.1 中存在的多个源。每个记录流被称为主题,任何从流中读取记录的东西都是一个消费者。图 11.2 展示了生产者和消费者如何与 Apache Kafka 集成。
图 11.2. Apache Kafka 集成(来自kafka.apache.org/intro.html)

此外,连接器使数据库或其他系统能够成为发送到 Apache Kafka 的记录的来源。最后,流处理器能够从一个或多个主题中流式传输记录,对数据进行某种类型的转换,然后将输出到一个或多个不同的主题。
记录是什么?
现在你已经了解了一些构成 Apache Kafka 的组件,让我们定义一下记录的含义。流中的每个记录由一个键、一个值和一个时间戳组成。键和值在目的上很简单,但为什么需要一个时间戳呢?时间戳对于 Apache Kafka 知道记录何时被接收(当涉及到分区时将变得更加关键)是至关重要的。
在继续之前,你还需要了解关于记录的额外概念。每个记录在数据流中都是不可变的:一旦记录被添加到数据流中,你无法编辑、修改或从数据流中删除记录。你所能做的就是为同一个键提供更新记录,设置不同的值。
图 11.3 扩展了图 11.1 中的流,展示了实时股价流的可能记录。在图 11.3 中,你可以看到没有针对键 RHT 的单个记录。目前有三个,它们的值都不同。这就是数据流的不可变性。如果流不是不可变的,那么很可能会有一个键为 RHT 的记录,它将不断更新为新值。
图 11.3。不可变数据流

不可变数据流的一个大优点是,对于同一个键,你可以有一个变化的历史记录。当然,在某些情况下,你可能只关心某个事物的当前值。但更常见的是,了解历史并能够确定随时间的变化是至关重要的。
记录也被持久化:日志保留在文件系统中,允许在未来任何时间点处理记录。话虽如此,记录保留的时间长度是有限制的。每个记录只保留到特定主题的保留策略允许的时间。一个主题可以被定义为无限期保留记录,前提是磁盘空间不是问题,或者几天后无论是否被消费都会被清除。
主题是如何工作的?
Kafka 中的主题与一个类别或类型相关,这种记录可以被发布和消费。例如,你会使用一个主题来实时股价,并为车辆传感器的测量使用另一个单独的主题。
每个主题被划分为一个或多个分区,这些分区位于 Kafka 集群中的一个或多个服务器上。一个分区是一个单一的逻辑数据流,或主题,例如你在图 11.1 和 11.3 中看到的,它被分割成多个物理数据流。
图 11.4 显示了一个分区。主题的分区增加了在特定主题写入或读取时可以实现的并行性。该图展示了一个被分割成三个分区的单一主题。每个分区代表一个有序且不可变的记录序列,这些记录不断追加,从而在数据流内创建一个结构化的变更事件日志。给定分区中的每个记录都被分配一个称为偏移量的顺序 ID 号。偏移量唯一地标识了特定分区内的记录。
图 11.4。主题分区(摘自kafka.apache.org/intro.html)

开发者需要特别注意 Kafka 记录的一个关键点是与之关联的键的定义。如果键在业务上下文中不是真正唯一的,那么键和时间戳组合之间可能会发生重叠——特别是由于 Kafka 保证所有具有相同键的记录都放置在同一个分区上,确保所有键的记录都按顺序存储在单个分区上。
图 11.5 展示了生产者和消费者如何与主题分区交互。
图 11.5. 主题生产者和消费者(摘自 kafka.apache.org/intro.html)

如前所述,生产者总是将新记录写入分区的末尾。消费者通常按顺序处理记录,但能够指定从哪个偏移量开始处理。例如,在图 11.5 中,消费者 B 可能从偏移量 0 开始读取,现在正在处理偏移量 11。消费者 A 在偏移量 9,但可能只从该偏移量开始读取记录,而不是从 0 开始。
图 11.5 介绍了一些关于消费者的概念,值得详细阐述,以便你熟悉它们能做什么:
-
消费者可以从任何偏移量开始读取一个主题,包括从非常开始,偏移量 0。
-
通过在读取记录时指定消费者组,可以对消费者进行负载均衡。
-
消费者组是多个消费者的逻辑分组,确保同一消费者组内的每个记录只被单个消费者读取。
11.2. 使用流式传输简化单体架构
图 11.6 简要回顾了迄今为止与 Cayambe 混合架构一起开发和集成的内容。灰色部分将在本章中完成,并通过 Apache Kafka 主题将 Admin 和 Cayambe 数据库链接起来,以消除 Cayambe 数据库直接管理类别的需求。这使得能够简单地从数据库 A 将数据传输到数据库 B。
在图 11.6 中没有数据流的情况下,你有几种替代方案:
-
修改 Cayambe JAR 以从 Admin 数据库检索记录。除了不同服务与同一数据库交互是一种不良的数据设计之外,你还在第十章中发现,在这种情况下,这样的更改需要大量的代码更改才能完成。
-
开发一个计划任务以从 Admin 数据库提取所有记录,然后清除并将这些记录插入到 Cayambe 数据库中。这种方法实现起来更简单,但会导致数据不同步的时期,以及当运行该任务的作业正在执行时,Cayambe 中的数据将不可用。根据数据变化的频率,这可能是一个可接受的解决方案,尽管任何计划中的停机时间都远远不是理想的。
-
修改 Admin 微服务以更新 Cayambe 数据库中的记录。虽然这比第一个选项更容易实现,但这种解决方案容易受到事务问题和知道两个更新是否都成功的影响。它需要 Admin 微服务对成功或失败以及如何适当地处理数据库调用中的失败有更多的智能,以便回滚另一个。
为了正确支持 图 11.6 中的模型,你希望能够将数据库更改事件转换为 Kafka 中的记录以便你处理。这种解决方案对 Admin 微服务的影响最小,同时仍然允许你消费其数据。你需要的是一个可以为你做这件事的 Kafka 连接器。
图 11.6. 当前 Cayambe 混合单体

有任何工具可以实现这一点吗?是的,当然有!Debezium 是一个开源项目,用于将数据库中的更改流出到 Kafka。
注意
Debezium 是一个用于变更数据捕获的分布式平台。你可以启动 Debezium,将其指向你的数据库,并在完全独立的应用程序中对这些数据库上的每个插入、更新或删除做出反应。Debezium 允许你消费数据库行级更改,而不会对当前直接执行这些数据库更新的应用程序产生任何影响或更改。Debezium 的一个巨大好处是,任何消费数据库更改的应用程序或服务都可以进行维护而不会丢失任何更改。Debezium 仍然将更改记录到 Kafka 中,以便在服务再次可用时进行消费。关于 Debezium 的详细信息可以在 debezium.io/ 找到。
为了更好地理解 Apache Kafka、数据流以及它们如何集成到你的微服务中,你在这个章节中不会为 Cayambe 混合型实现 Debezium。我将把它留给你作为额外的练习。
对于 Cayambe 混合型,你将直接将事件生产到 Apache Kafka,然后在另一侧消费它们。图 11.7 展示了你要对架构进行的更改,以支持展示 Apache Kafka 的工作方式更多细节。
图 11.7. Cayambe 混合单体

你正在向 Admin 微服务添加代码以产生将被发送到 Apache Kafka 主题的事件。然后你有一个 Kafka 微服务来消费这些事件并更新 Cayambe 数据库中的更改。
你仍然会使用数据流来将所需数据从一个地方移动到另一个地方,尽管不使用类似 Debezium 的方法在真实生产使用中可能效率略低,但有助于理解正在发生的事情。
11.3. 部署和使用 Kafka 进行数据流
在查看如何实现与 Kafka 集成的微服务之前,让我们在 OpenShift 上启动并运行 Kafka!如果你还没有运行 Minishift,让我们现在启动它,就像你在第十章中做的那样:
> minishift start --cpus 3 --memory 4GB
11.3.1. OpenShift 上的 Kafka
Minishift 启动并运行后,启动 OpenShift 控制台并登录。在你的现有项目中,点击添加到项目,然后点击导入 YAML/JSON。
将/chapter11/resources/Kafka_OpenShift.yml 的内容粘贴到文本框中,其中一些片段如本列表所示。
列表 11.1. Kafka OpenShift 模板
apiVersion: v1
kind: Template
metadata:
name: strimzi *1*
annotations:
openshift.io/display-name: "Apache Kafka (Persistent storage)"
description: >-
This template installs Apache Zookeeper and Apache Kafka clusters. For
more information
see https://github.com/strimzi/strimzi
tags: "messaging,datastore"
iconClass: "fa pficon-topology"
template.openshift.io/documentation-url:
"https://github.com/strimzi/strimzi"
message: "Use 'kafka:9092' as bootstrap server in your application"
...
objects:
- apiVersion: v1
kind: Service
metadata:
name: kafka *2*
spec:
ports:
- name: kafka
port: 9092 *3*
targetPort: 9092
protocol: TCP
selector:
name: kafka
type: ClusterIP
...
- apiVersion: v1
kind: Service
metadata:
name: zookeeper *4*
spec:
ports:
- name: clientport
port: 2181 *5*
targetPort: 2181
protocol: TCP
selector:
name: zookeeper
type: ClusterIP
...
-
1 strimzi 是将在 OpenShift 中出现的应用程序的名称。
-
2 定义了 kafka 服务
-
3 kafka 服务将在端口 9092 上可用。
-
4 定义了 zookeeper 服务
-
5 ZooKeeper 服务在端口 2181 上可用。
稍等一下,ZooKeeper 在那里做什么?之前可没提到!没错,之前确实没有提到。ZooKeeper 是一个实现细节,因为它被 Kafka 内部用作分布式键/值存储。这不是你需要与之交互的东西。你在这里看到它是因为你正在扮演运维人员角色,为自己设置 Kafka。
/chapter11/resources/Kafka_OpenShift.yml 最初是从mng.bz/RqUn复制的,但被修改为只有一个 Kafka 代理而不是三个。因此,它不支持主题复制,但你的 OpenShift 实例运行 Kafka 所需的资源更少!
在将修改后的文件内容粘贴到弹出窗口后,点击创建,然后继续,你将看到一个表单,你可以指定不同的默认值。现在,保持它们不变,并在页面底部点击创建。OpenShift 现在将配置一个包含单个代理的 Kafka 集群,你可以在主控制台页面下的 strimzi 应用程序中看到它。
警告
完成必要的 Docker 镜像下载并启动容器可能需要一些时间。如果 ZooKeeper 尚未运行,Kafka 集群最初失败,请不要担心。给定时间,它将重新启动,一切都将按预期运行。
在所有 Pod 启动后,打开一个终端窗口并登录到 OpenShift 客户端(如果你还没有登录的话)。你需要检索所有 OpenShift 服务以找到 ZooKeeper 的 URL:
> oc get services
NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kafka 172.30.225.60 <none> 9092/TCP 5h
kafka-headless None <none> 9092/TCP 5h
zookeeper 172.30.93.118 <none> 2181/TCP 5h
zookeeper-headless None <none> 2181/TCP 5h
从列表中,你可以看到 ZooKeeper 的 URL 是 172.30.93.118。返回 OpenShift 控制台,从菜单选项中选择应用程序和 Pod。这提供了一个 OpenShift 中运行中的 Pod 列表。对于单个代理,应该只有一个 kafka-* Pod。点击该 Pod,然后选择终端标签页,你应该会看到类似于图 11.8 的内容。
图 11.8. OpenShift pod 终端

要使用 Kafka,你需要为你的记录创建一个主题。让我们在终端标签页中做这件事:
./bin/kafka-topics.sh --create --topic category_topic --replication-factor
1 --partitions 1 --zookeeper 172.30.93.118:2181
你使用 Kafka 脚本来创建一个名为category_topic的主题,该主题只有一个分区和一个副本。你只指定单个副本和分区,因为你在集群中只有一个代理。例如,如果你在集群中有三个代理,你可以使用三个分区和 2 的副本因子。
11.3.2. Admin 微服务
现在 Kafka 已经运行并且你的主题已经创建,是时候修改 Admin 微服务以将事件发送到该主题了!
为了帮助将你的企业 Java 代码与 Kafka 集成,你将使用一个库,该库将 Kafka 的拉取方法转换为推送方法。这个库仍然处于初级阶段,但很容易使用,因为它去除了直接使用 Kafka API 时所需的大量样板代码。它被编写为一个 CDI 扩展,并以 Maven 工件的形式提供给你使用。代码可在github.com/aerogear/kafka-cdi找到。
将 Kafka 的拉取方法转换为推送方法有什么优势?这对于我们这些更熟悉企业 Java 开发的人来说是有益的,因为在 CDI 编程模型中,我们能够监听事件并在接收到事件时执行操作。这正是我们使用的 Kafka 库为我们带来的,即每次向主题写入新记录时都能监听事件,就像它是 CDI 事件监听器一样。
你需要做的第一件事是更新 Admin 微服务的 pom.xml 以使用新的依赖项:
<dependency>
<groupId>org.aerogear.kafka</groupId>
<artifactId>kafka-cdi-extension</artifactId>
<version>0.0.10</version>
</dependency>
接下来,你修改 CategoryResource 以连接到 Kafka 主题,并生成要附加到其上的记录。
列表 11.2. CategoryResource
@Path("/")
@ApplicationScoped
@KafkaConfig(bootstrapServers =
"#{KAFKA_SERVICE_HOST}:#{KAFKA_SERVICE_PORT}") *1*
public class CategoryResource {
...
@Producer
private SimpleKafkaProducer<Integer, Category> producer; *2*
...
public Response create(Category category) throws Exception {
...
producer.send("category_topic", category.getId(), category); *3*
...
public Response remove(@PathParam("categoryId") Integer categoryId,
@Context SecurityContext context) throws Exception {
...
producer.send("category_topic", categoryId, null); *4*
...
}
-
1 识别你连接到的 Kafka 服务器。你可以使用环境变量来指定主机和端口,因为你在与 Kafka 相同的 OpenShift 命名空间中部署微服务。
-
2 注入一个接受整数作为键和类别作为值的 CDI 生产者。
-
3
create()方法被修改为在创建新的类别之后调用send()方法。这表示你将记录发送到的主题,以及键和值。 -
4
remove()方法以类似的方式进行修改。与create()方法的主要区别在于你传递了一个 null 值,因为没有有效的值了。
在 Admin 微服务所做的更改之后,你现在可以部署它了!在部署微服务之前,你需要确保 Keycloak 正在运行,因为你的微服务使用它来保护删除端点。为此,你需要运行以下命令:
/chapter9/keycloak> java -Dswarm.http.port=9090 -jar
keycloak-2018.1.0-swarm.jar
如果数据库文件尚未从目录中删除,Keycloak 应该启动并记住你之前安装的所有设置。随着 Keycloak 再次运行,你现在可以部署 Admin:
/chapter11/admin> mvn clean fabric8:deploy -Popenshift
微服务启动并运行后,你可以使用新的管理界面,或者通过 Postman 直接使用 HTTP 请求来更新和删除类别。你怎么知道 Admin 微服务是否正确地将记录放入 Kafka 主题?因为你没有任何消费者消费这些记录!
幸运的是,Kafka 提供了一个可以在控制台中使用的消费者,用于查看主题的内容。在 OpenShift 控制台中,你回到 kafka-* 容器,就像之前一样,并选择终端标签页。在命令行中,运行以下命令:
./bin/kafka-console-consumer.sh --bootstrap-server 172.30.225.60:9092 –
from-beginning --topic category_topic
或者,你也可以连接到 kafka-* 容器,并远程运行命令:
oc rsh kafka-<identifier>
./bin/kafka-console-consumer.sh --bootstrap-server 172.30.225.60:9092 –
from-beginning --topic category_topic
你使用了之前从 OpenShift 服务列表中检索到的 Kafka 服务 IP 地址和端口来指定 Kafka 的位置。接下来,你告诉脚本你想要从开始消费所有记录,这与从偏移量 0 开始相同。最后,你给出主题的名称。如果一切正常,你应该会看到通过 Admin 微服务所做的每个更改都对应一个记录。
我们已经涵盖了 Kafka 主题的生产方面。现在让我们看看消费方面。
11.3.3. Kafka 消费者
书中代码的 /chapter11/kafka-consumer/ 目录中包含了 Kafka 消费者的所有代码。与生产者类似,你需要在 pom.xml 中添加 kafka-cdi-extension 依赖项。pom.xml 的其余部分包含常规的 Thorntail 插件和依赖项,以及用于部署到 OpenShift 的 fabric8 Maven 插件。你还指定了一个 MySQL JDBC 驱动依赖项,以便你可以更新 Cayambe 数据库中的记录。
要连接到 Cayambe 数据库,你需要定义一个 DataSource。
列表 11.3. project-defaults.yml
swarm:
datasources:
data-sources:
CayambeDS: *1*
driver-name: mysql *2*
connection-url: jdbc:mysql://mysql:3306/cayambe *3*
user-name: cayambe *4*
password: cayambe
valid-connection-checker-class-name:
org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLValidConnectionChecker
validate-on-match: true
background-validation: false
exception-sorter-class-name:
org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter
-
1 JNDI 中 DataSource 的名称。
-
2 使用从 MySQL JDBC 驱动依赖项创建的模块
-
3 OpenShift 上 MySQL 数据库实例的 URL
-
4 Cayambe 数据库的凭据
最后,你创建一个类来处理从 Kafka 主题接收到的记录。
列表 11.4. CategoryEventListener
@ApplicationScoped
@KafkaConfig(bootstrapServers =
"#{KAFKA_SERVICE_HOST}:#{KAFKA_SERVICE_PORT}") *1*
public class CategoryEventListener {
private static final String DATASOURCE =
"java:/jboss/datasources/CayambeDS"; *2*
@Consumer(topics = "category_topic", keyType = Integer.class,
groupId = "cayambe-listener", offset = "earliest") *3*
public void handleEvent(Integer key, Category category) { *4*
if (null == category) {
// Remove Category
executeUpdateSQL("delete from category where category_id = " + key); *5*
// Remove from Category Hierarchy
executeUpdateSQL("delete from category_category where category_id = " +
key); *6*
executeUpdateSQL("delete from category_category where parent_id = " +
key);
} else {
boolean update = rowExists("select * from category where category_id = "
+ key); *7*
if (update) {
// Update Category
executeUpdateSQL("update category set name = '" + category.getName()*8*
+ "' header = '" + category.getHeader()
+ "' image = '" + category.getImagePath()
+ "' where category_id = " + key);
} else {
// Create Category *9*
executeUpdateSQL("insert into category (id,name,header,visible,image)
values("
+ key + ",'" + category.getName() + "', '"
+ category.getHeader() + "', " +
(category.isVisible() ? 1 : 0)
+ ", '" + category.getImagePath() + "')");
executeUpdateSQL("insert into category_category (category_id,
parent_id)"
+ " values (" + category.getId() + "," +
category.getParent().getId() + ")");
}
}
}
private void executeUpdateSQL(String sql) { *10*
Statement statement = null;
Connection conn = null;
try {
conn = getDatasource().getConnection();
statement = conn.createStatement();
statement.executeUpdate(sql);
statement.close();
conn.close();
} catch (Exception e) {
...
}
}
boolean rowExists(String sql) { *11*
Statement statement = null;
Connection conn = null;
ResultSet results = null;
try {
conn = getDatasource().getConnection();
statement = conn.createStatement();
results = statement.executeQuery(sql);
return results.next();
} catch (Exception e) {
...
}
return false;
}
private DataSource getDatasource() { *12*
if (null == dataSource) {
try {
dataSource = (DataSource) new InitialContext().lookup(DATASOURCE);
} catch (NamingException ne) {
ne.printStackTrace();
}
}
return dataSource;
}
private DataSource dataSource = null;
}
-
1 如你在生产者中做的那样,你为配置定义 Kafka 主机和端口。
-
2 使用 project-defaults.yml 创建的 CayambeDS 的 JNDI 名称。它由 getDatasource() 使用,因此你可以使用更改后的类别更新 Cayambe 数据库。
-
3 @Consumer 将方法标识为接受 Kafka 主题记录,并提供将它们连接到 Kafka API 所需的配置。它定义了你想要记录的主题的名称、键的类型、一个唯一的消费者组标识符,以及你想要从主题的开始偏移量处开始。
-
4 接收 Kafka 记录的方法,带有传递给键和值类型的参数
-
5 执行删除类别的 SQL 语句。
-
6 执行从类别层次结构中删除类别的 SQL 语句,无论是作为子节点还是父节点。
-
7 执行 SQL 以确定是否存在类别 ID 的行。确定你是否正在更新或插入记录。
-
8 执行 SQL 更新数据库中类别字段的操作。
-
9 执行 SQL 将新类别插入数据库,并将其插入到类别层次结构中。
-
10 处理 SQL 更新执行的方法。
-
11 检查数据库中是否存在 Category 行的方法。
-
12 从 JNDI 获取 DataSource 的方法。
CategoryEventListener 通过定义键类型、值类型、你正在处理的主题、消费者组以及你想要从流中从头开始处理所有记录,注册一个方法来监听 Kafka 事件。当你收到 Kafka 记录时,然后确定你是否需要删除一个类别,值是 null,或者我们正在处理一个新或更新的记录。
为了区分更新和新的类别,你需要在 Cayambe 中对现有类别执行 SQL 语句,以查看此记录是否存在。如果存在,则是一个更新记录;如果不存在,则是一个新记录。
如果你不想运行 SQL 语句来确定你是否正在处理更新或新类别,你可以将 Kafka 中记录的值类型更改为封装对象。Category 实例、当前值可以是一个新类型上的字段,带有指示正在处理哪种更改事件的标志。
现在你已经完成了 Kafka 消费者的开发,你准备好看到它协同工作!但在你部署刚刚创建的 Kafka 消费者之前,为了看到它们发生的视觉变化,值得从 第十章 启动 Cayambe 混合模式,如下所示:
/wildfly-11.0.0.Final/bin/standalone.sh
Cayambe 启动后,打开浏览器并浏览类别树。你应该注意到你通过 Admin 微服务所做的任何更改都是不可见的,这是有道理的,因为你还没有激活更新 Cayambe 数据库的任何更改的过程。所以现在让我们启动你的 Kafka 消费者:
/chapter11/kafka-consumer> mvn clean fabric8:deploy -Popenshift
当 pod 变得可用时,它应该处理 Kafka 主题上所有现有的记录,因为你指定它从主题的最早偏移量开始。你可以打开服务的日志,查看为每个处理的记录打印的控制台语句。
使用 Kafka 消费者处理过的记录,返回 Cayambe UI 并刷新页面。在浏览类别树并找到通过 Admin 微服务更改的类别时,你会注意到它们现在根据你之前所做的操作已更新或删除。
你已经成功地将两个系统之间的数据解耦,一个拥有数据(Admin 微服务),另一个以只读方式消费其副本。作为额外的好处,只要 Kafka 生产者和消费者正在运行,数据永远不会过时。
11.4. 额外练习
如本章前面所述,作为额外练习,尝试将 Cayambe 混合型转换为使用 Debezium 直接处理数据库条目,而不是在 Admin 微服务中由您生成记录。
这也将为当前解决方案提供另一个优势,因为每当需要时,类别层次结构都可以完全从 Kafka 主题记录中重建。该层次结构将包含所有您最初为加载数据库而进行的初始插入记录,以及自那时以来发生的任何插入、更新和删除操作。
摘要
-
数据流通过使单独的组件或微服务保持解耦,同时仍然使用相同的数据,从而简化了架构。
-
您可以使用 Apache Kafka 的数据流技术在微服务和应用程序之间共享数据,而无需通过 REST 调用来检索它。
注意
关于使用 Spring Boot 开发微服务的更多详细信息,请参阅附录。
附录。Spring Boot 微服务
在整本书中,我们一直专注于使用 Thorntail 为企业 Java 开发微服务。本附录提供了使用 Spring Boot 开发微服务的详细信息。其中包含来自 Craig Walls 所著的《Spring Boot in Action》(Manning, 2015)的片段。如果你特别关注 Spring Boot 微服务,查看这本书以获取更多细节将是有益的(见 www.manning.com/books/spring-boot-in-action)。
Spring Boot 项目的解剖结构
本节包含来自《Spring Boot in Action》第 2.1.1 节的片段,概述了 Spring Boot 应用程序及其要求。
检查一个新初始化的 Spring Boot 项目
图 1 展示了 Spring Boot 阅读列表项目的结构。
图 1. 阅读列表项目的结构

首先要注意的是,项目结构遵循典型的 Maven 或 Gradle 项目的布局。主要应用程序代码位于目录树的 src/main/java 分支,资源位于 src/main/resources 分支,测试代码位于 src/test/java 分支。目前,你没有任何测试资源,但如果有,你将把它们放在 src/test/resources。
深入挖掘,你会在项目中看到一些散布的文件:
-
build.gradle— Gradle 构建规范
-
ReadingListApplication.java— 应用程序的引导类和主要 Spring 配置类
-
application.properties— 配置应用程序和 Spring Boot 属性的地方
-
ReadingListApplicationTests.java— 一个基本的集成测试类
构建规范包含许多 Spring Boot 的优点需要挖掘,所以我将把它留到最后检查。相反,我们将从 ReadingListApplication.java 开始。
引导 Spring
ReadingListApplication 类在 Spring Boot 应用程序中扮演两个角色:配置和引导。首先,它是中心 Spring 配置类。尽管 Spring Boot 自动配置消除了许多 Spring 配置的需求,但你至少需要一小部分 Spring 配置来启用自动配置。正如你在这段代码中可以看到的,只有一行配置代码。
列表 1. ReadingListApplication
package readinglist;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication *1*
public class ReadingListApplication {
public static void main(String[] args) {
SpringApplication.run(ReadingListApplication.class, args); *2*
}
}
-
1 启用组件扫描和自动配置。
-
2 引导应用程序。
@SpringBootApplication 启用 Spring 组件扫描和 Spring Boot 自动配置。实际上,@SpringBootApplication 结合了三个其他有用的注解:
-
Spring 的
@Configuration—使用基于 Spring 的 Java 配置将类指定为配置类。尽管你在这本书中不会写很多配置,但当你需要时,你将优先选择基于 Java 的配置而不是 XML 配置。 -
Spring 的
@ComponentScan——启用组件扫描,以便自动发现和注册你编写的 Web 控制器类和其他组件为 Spring 应用程序上下文中的 bean。在本附录的后面部分,你将编写一个简单的 Spring MVC 控制器,它将被标记为@Controller,以便组件扫描可以找到它。 -
Spring Boot 的
@EnableAutoConfiguration——这个谦逊的小注解几乎可以命名为@Abracadabra,因为它是一行配置,它启用了 Spring Boot 自动配置的魔力。这一行让你不必编写在其他情况下所需的页面配置。
在 Spring Boot 的旧版本中,你需要在ReadingListApplication类上标注这三个注解。但自从 Spring Boot 1.2.0 以来,@SpringBootApplication就足够了。
如我所说,ReadingListApplication也是一个引导类。运行 Spring Boot 应用程序有几种方法,包括传统的 WAR 文件部署。但到目前为止,这里的main()方法使你能够从命令行以可执行 JAR 文件的形式运行你的应用程序。它将ReadingListApplication类的引用以及命令行参数传递给SpringApplication.run(),以启动应用程序。
即使你没有编写任何应用程序代码,你仍然可以在这一点上构建应用程序并尝试它。构建和运行应用程序的最简单方法是使用 Gradle 的bootRun任务:
$ gradle bootRun
bootRun任务来自 Spring Boot 的 Gradle 插件。或者,你也可以使用 Gradle 构建项目,并在命令行中用 Java 运行它:
$ gradle build
...
$ java -jar build/libs/readinglist-0.0.1-SNAPSHOT.jar
应用程序应该可以正常运行并启用监听 8080 端口的 Tomcat 服务器。如果你想的话,可以将浏览器指向 http://localhost:8080,但由于你还没有编写控制器类,你将遇到 HTTP 404(未找到)错误和错误页面。然而,在附录完成之前,这个 URL 将为你提供阅读列表应用程序。
你几乎永远不会需要修改 ReadingListApplication.java。如果你的应用程序需要任何超出 Spring Boot 自动配置提供的 Spring 配置,通常最好将其写入单独的@Configuration配置类中。(它们将通过组件扫描被拾取和使用。)然而,在极其简单的情况下,你也可以将自定义配置添加到 ReadingListApplication.java 中。
测试 Spring Boot 应用程序
Initializr 还为你提供了一个骨架测试类,帮助你开始编写应用程序的测试。但是,如以下列表所示,ReadingListApplicationTests不仅仅是一个测试的占位符。它还作为如何编写 Spring Boot 应用程序测试的示例。@SpringApplicationConfiguration加载 Spring 应用程序上下文。
列表 2. ReadingListApplicationTests
package readinglist;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import readinglist.ReadingListApplication;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(
classes = ReadingListApplication.class) *1*
@WebAppConfiguration
public class ReadingListApplicationTests {
@Test
public void contextLoads() { *2*
}
}
-
1 通过 Spring Boot 加载上下文。
-
2 测试上下文是否加载。
在典型的 Spring 集成测试中,您会使用 @ContextConfiguration 注解来注解测试类,以指定测试应该如何加载 Spring 应用程序上下文。但为了充分利用 Spring Boot 的魔法,应使用 @SpringApplicationConfiguration 注解。正如您在列表 2 中可以看到的,ReadingListApplicationTests 使用了 @SpringApplicationConfiguration 注解来从 ReadingListApplication 配置类加载 Spring 应用程序上下文。
ReadingListApplicationTests 还包括一个简单的测试方法 contextLoads()。实际上,它是一个空方法。但这对验证应用程序上下文在没有任何问题的情况下加载是足够的。如果 ReadingListApplication 中定义的配置良好,测试将通过。如果存在任何问题,测试将失败。
随着我们完善应用程序,您将添加一些自己的测试。但 contextLoads() 方法是一个不错的起点,并验证了应用程序在此阶段提供的所有功能。
配置应用程序属性
Initializr 给您的 application.properties 文件最初是空的。此文件是可选的,因此您可以完全删除它而不会影响应用程序。但将其保留也没有任何害处。
您肯定会在以后有机会向 application.properties 中添加条目。目前,如果您想对 application.properties 进行探索,请尝试添加以下行:
server.port=8000
通过这一行,您正在配置嵌入的 Tomcat 服务器监听 8000 端口,而不是默认的 8080 端口。您可以通过再次运行应用程序来确认这一点。
这表明,application.properties 文件对于对 Spring Boot 自动配置的内容进行细粒度配置非常有用。但您也可以用它来指定应用程序代码使用的属性。
需要注意的主要一点是,您从未明确要求 Spring Boot 为您加载 application.properties。由于 application.properties 存在,它将被加载,并且其属性将可用于配置 Spring 和应用程序代码。
Spring Boot 启动器依赖项
本节提供了有关 Spring Boot 启动器及其使用的信息。
使用启动器依赖项
为了理解 Spring Boot 启动器依赖项的好处,让我们假设它们不存在。如果没有 Spring Boot,您会在构建中添加哪些依赖项?为了支持 Spring MVC,您需要哪些 Spring 依赖项?您还记得 Thymeleaf 的组和工件 ID,或者任何外部依赖项吗?您应该使用哪个版本的 Spring Data JPA?所有这些是否兼容?
哎呀。没有 Spring Boot 启动器依赖项,你有一些作业要做。你想要的只是开发一个使用 Thymeleaf 视图的 Spring 网络应用程序,并通过 JPA 持久化其数据。但在你能够写下第一行代码之前,你必须弄清楚需要在构建规范中放入什么来支持你的计划。
经过深思熟虑(可能还从具有类似依赖项的另一个应用程序的构建中复制粘贴了很多),你最终在你的 Gradle 构建规范中得到了以下依赖项块:
compile("org.springframework:spring-web:4.1.6.RELEASE")
compile("org.thymeleaf:thymeleaf-spring4:2.1.4.RELEASE")
compile("org.springframework.data:spring-data-jpa:1.8.0.RELEASE")
compile("org.hibernate:hibernate-entitymanager:jar:4.3.8.Final")
compile("com.h2database:h2:1.4.187")
这个依赖项列表是好的,甚至可能工作。但你怎么知道呢?你选择的那些依赖项的版本是否甚至彼此兼容?它们可能兼容,但你不会知道,直到你构建并运行应用程序。而且你怎么知道依赖项列表是完整的呢?在没有写下一行代码的情况下,你离测试你的构建还有很长的路要走。
让我们退一步,回顾一下你想要做什么。你正在寻找具有以下特性的应用程序:
-
它是一个网络应用程序。
-
它使用 Thymeleaf。
-
它通过 Spring Data JPA 将数据持久化到关系型数据库。
如果你能在构建中指定这些事实,让构建整理出你需要的东西,那会简单得多吗?这正是 Spring Boot 启动器依赖项所做的。
指定基于功能的依赖项
Spring Boot 通过提供几十个启动器依赖项来解决项目依赖项复杂性。启动器依赖项本质上是一个 Maven POM,它定义了对其他库的传递依赖,这些库共同提供对特定功能的支持。许多这些启动器依赖项的命名是为了表明它们提供的功能面或类型。
例如,阅读列表应用程序将是一个网络应用程序。与其向项目构建中添加几个单独选择的库依赖项,不如简单地声明这是一个网络应用程序。你可以通过向构建中添加 Spring Boot 的网络启动器来实现这一点。
你还希望使用 Thymeleaf 进行网络视图,并使用 JPA 持久化数据。因此,你需要在构建中包含 Thymeleaf 和 Spring Data JPA 启动器依赖项。
为了测试目的,你还希望有库,使你能够在 Spring Boot 的上下文中运行集成测试。因此,你还需要对 Spring Boot 的测试启动器的测试时间依赖项。
总体而言,你在 Gradle 构建中拥有 Initializr 提供的以下五个依赖项:
dependencies {
compile "org.springframework.boot:spring-boot-starter-web"
compile "org.springframework.boot:spring-boot-starter-thymeleaf"
compile "org.springframework.boot:spring-boot-starter-data-jpa"
compile "com.h2database:h2"
testCompile("org.springframework.boot:spring-boot-starter-test")
}
如你之前所见,将依赖项添加到应用程序构建中最简单的方法是在 Initializr 中选择 Web、Thymeleaf 和 JPA 复选框。但如果你在初始化项目时没有这样做,你当然可以返回并通过编辑生成的 build.gradle 或 pom.xml 来添加它们。
通过传递依赖项,添加这四个依赖项相当于向构建中添加了几十个单独的库。其中一些传递依赖项包括 Spring MVC、Spring Data JPA 和 Thymeleaf,以及那些依赖项声明的任何传递依赖项。
关于这四个启动器依赖项最重要的注意事项是,它们只具体到需要的程度。你不会说你想要 Spring MVC;你只是说你想要构建一个网络应用程序。你不会指定 JUnit 或其他测试工具;你只是说你想要测试你的代码。Thymeleaf 和 Spring Data JPA 启动器稍微具体一些,但这只是因为没有更不具体的方式来声明你想要 Thymeleaf 和 Spring Data JPA。在这个构建中的四个启动器只是 Spring Boot 提供的许多启动器依赖项中的一小部分。
在任何情况下,你都不需要指定版本。启动器依赖项的版本由你使用的 Spring Boot 版本决定。启动器依赖项本身决定了它们拉入的各种传递依赖项的版本。
未知各种库的版本可能会让你感到有些不安。但请放心,Spring Boot 已经经过测试,确保所有拉入的依赖项都是兼容的。只需指定启动器依赖项,而不必担心需要维护哪些库及其版本,这可以是一种解放。
但如果你必须知道你得到的是什么,你总是可以从构建工具中找到答案。在 Gradle 的情况下,dependencies任务会给你一个包含你的项目使用的每个库及其版本的依赖项树:
$ gradle dependencies
你可以通过 Maven 构建的dependency插件的tree目标获得类似的依赖项树:
$ mvn dependency:tree
在大多数情况下,你无需关心每个 Spring Boot 启动器依赖项提供的具体内容。通常,知道网络启动器使你能够构建一个网络应用程序,Thymeleaf 启动器使你能够使用 Thymeleaf 模板,以及 Spring Data JPA 启动器通过使用 Spring Data JPA 将数据持久化到数据库就足够了。
但如果尽管 Spring Boot 团队进行了测试,选择启动器依赖项的库仍然存在问题,你该如何覆盖启动器?
覆盖启动器传递依赖项
最终,启动器依赖项只是构建中与其他依赖项一样的依赖项。你可以使用构建工具的功能来选择性地覆盖传递依赖项的版本、排除传递依赖项,当然也可以指定 Spring Boot 启动器未涵盖的库的依赖项。
例如,考虑 Spring Boot 的 web 启动器。在其他方面,web 启动器间接依赖于 Jackson JSON 库。如果你正在构建一个消费或生成 JSON 资源表示的 REST 服务,这个库非常有用。但是,如果你使用 Spring Boot 构建一个更传统的面向人类的 Web 应用程序,你可能不需要 Jackson。即使包含它不会造成任何伤害,你也可以通过排除 Jackson 作为间接依赖项来减少构建的体积。
如果你使用 Gradle,你可以像这样排除间接依赖项:
compile("org.springframework.boot:spring-boot-starter-web") {
exclude group: 'com.fasterxml.jackson.core'
}
在 Maven 中,你可以使用<exclusions>元素排除间接依赖项。以下 Spring Boot web 启动器的<dependency>具有<exclusions>以排除 Jackson:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
</exclusion>
</exclusions>
</dependency>
相反,也许在构建中包含 Jackson 是可以的,但你可能想针对 web 启动器引用的 Jackson 的不同版本进行构建。假设 web 启动器引用了 Jackson 2.3.4,但你更愿意使用版本 2.4.3。使用 Maven,你可以在项目的 pom.xml 文件中直接表达所需的依赖项,如下所示:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.4.3</version>
</dependency>
Maven 总是优先选择最近的依赖项,这意味着由于你在项目构建中表达了此依赖项,它将优先于另一个依赖项间接引用的依赖项。
类似地,如果你使用 Gradle 进行构建,你可以在 build.gradle 文件中指定 Jackson 的新版本,如下所示:
compile("com.fasterxml.jackson.core:jackson-databind:2.4.3")
这个依赖项在 Gradle 中可以工作,因为它比 Spring Boot 的 web 启动器间接引用的版本更新。但是,假设你不想使用 Jackson 的新版本,而想使用旧版本。与 Maven 不同,Gradle 更喜欢依赖项的最新版本。因此,如果你想使用 Jackson 的旧版本,你必须将旧版本作为依赖项在构建中表达,并排除它被 web 启动器依赖项间接解析:
compile("org.springframework.boot:spring-boot-starter-web") {
exclude group: 'com.fasterxml.jackson.core'
}
compile("com.fasterxml.jackson.core:jackson-databind:2.3.1")
在任何情况下,当覆盖由 Spring Boot 启动器依赖项间接引入的依赖项时,请谨慎行事。尽管不同版本可能运行良好,但了解启动器选择的版本已经过测试,可以很好地协同工作,这会让人感到非常安心。你应该只在特殊情况下(例如,修复较新版本中的错误)覆盖这些间接依赖项。
现在你已经准备好了空的项目结构和构建规范,是时候开始开发应用程序本身了。在这个过程中,你将让 Spring Boot 处理配置细节,而你则专注于编写提供阅读列表功能的代码。
开发 Spring Boot 应用程序
在列表 3 中,你将进一步开发一个 Spring Boot 应用程序,内容来自《Spring Boot 实战》第 2.3.1 节。
专注于应用程序功能
要获得对 Spring Boot 自动配置的欣赏,一种方法是我可以在接下来的几页中向您展示在没有 Spring Boot 的情况下所需的配置。但几本关于 Spring 的优秀书籍可以向您展示这一点,再次展示也不会帮助您更快地编写阅读列表应用程序。
而不是浪费时间讨论 Spring 配置,请知道 Spring Boot 会为您处理这些,所以让我们看看如何利用 Spring Boot 自动配置来让您专注于编写应用程序代码。我想不出比开始编写阅读列表应用程序的代码更好的方法了。
定义领域
您应用程序的核心领域概念是读者阅读列表中的一本书。因此,您需要定义一个表示书的实体类。列表 3 展示了如何定义 Book 类型。
列表 3. Book 类
package readinglist;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Book {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private String reader;
private String isbn;
private String title;
private String author;
private String description;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getReader() {
return reader;
}
public void setReader(String reader) {
this.reader = reader;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
如您所见,Book 类是一个简单的 Java 对象,具有一些属性来描述一本书以及必要的访问器方法。它被注解为 @Entity,表明它是一个 JPA 实体。id 属性被注解为 @Id 和 @GeneratedValue,以表明该字段是实体的标识符,并且其值将自动提供。
定义存储库接口
接下来,您需要定义一个存储库,通过该存储库将 ReadingList 对象持久化到数据库。由于您正在使用 Spring Data JPA,这项任务只是创建一个扩展 Spring Data JPA 的 JpaRepository 接口的接口:
package readinglist;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ReadingListRepository extends JpaRepository<Book, Long> {
List<Book> findByReader(String reader);
}
通过扩展 JpaRepository,ReadingListRepository 继承了 18 个用于执行常见持久化操作的方法。JpaRepository 接口使用两个参数进行参数化:存储库将与之工作的领域类型以及其 ID 属性的类型。此外,我还添加了一个 findByReader() 方法,可以通过读者的用户名来查找阅读列表。
如果您想知道谁将实现 ReadingListRepository 及其继承的 18 个方法,不必过于担心。Spring Data 提供了自己的特殊魔法,使得只需定义一个接口就可以定义存储库。当应用程序启动时,该接口将在运行时自动实现。
创建网络界面
现在您已经定义了应用程序的领域,并且有一个用于将领域对象持久化到数据库的存储库,剩下要做的就是创建网络前端。以下列表中的 Spring MVC 控制器将处理应用程序的 HTTP 请求。
列表 4. ReadingListController
package readinglist;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import java.util.List;
@Controller
@RequestMapping("/")
public class ReadingListController {
private ReadingListRepository readingListRepository;
@Autowired
public ReadingListController(
ReadingListRepository readingListRepository) {
this.readingListRepository = readingListRepository;
}
@RequestMapping(value="/{reader}", method=RequestMethod.GET)
public String readersBooks(
@PathVariable("reader") String reader,
Model model) {
List<Book> readingList =
readingListRepository.findByReader(reader);
if (readingList != null) {
model.addAttribute("books", readingList);
}
return "readingList";
}
@RequestMapping(value="/{reader}", method=RequestMethod.POST)
public String addToReadingList(
@PathVariable("reader") String reader, Book book) {
book.setReader(reader);
readingListRepository.save(book);
return "redirect:/{reader}";
}
}
ReadingListController 被注解为 @Controller,以便被组件扫描并自动注册为 Spring 应用程序上下文中的一个 bean。它还被注解为 @RequestMapping,将所有处理方法映射到基本 URL 路径 “/”。
控制器有两个方法:
-
readersBooks()——通过从仓库(它被注入到控制器的构造函数中)检索指定路径中的读者的Book列表来处理针对/{reader}的 HTTPGET请求。它将Book列表放入模型中的books键下,并以readingList作为要渲染模型的视图的逻辑名称返回。 -
addToReadingList()——处理针对/{reader}的 HTTPPOST请求,将请求体中的数据绑定到一个Book对象上。此方法将Book对象的reader属性设置为读者的名字,然后通过仓库的save()方法保存修改后的Book。最后,通过指定重定向到/{reader}(将由其他控制器方法处理)来返回。
readersBooks()方法通过返回readingList作为逻辑视图名称来结束。因此,你也必须创建那个视图。我在这个项目的开始时就决定我们会使用 Thymeleaf 来定义应用程序视图,所以下一步是在 src/main/resources/templates 中创建一个名为 readingList.html 的文件,内容如下。
列表 5. readingList.html
<html>
<head>
<title>Reading List</title>
<link rel="stylesheet" th:href="@{/style.css}"></link>
</head>
<body>
<h2>Your Reading List</h2>
<div th:unless="${#lists.isEmpty(books)}">
<dl th:each="book : ${books}">
<dt class="bookHeadline">
<span th:text="${book.title}">Title</span> by
<span th:text="${book.author}">Author</span>
(ISBN: <span th:text="${book.isbn}">ISBN</span>)
</dt>
<dd class="bookDescription">
<span th:if="${book.description}"
th:text="${book.description}">Description</span>
<span th:if="${book.description eq null}">
No description available</span>
</dd>
</dl>
</div>
<div th:if="${#lists.isEmpty(books)}">
<p>You have no books in your book list</p>
</div>
<hr/>
<h3>Add a book</h3>
<form method="POST">
<label for="title">Title:</label>
<input type="text" name="title" size="50"></input><br/>
<label for="author">Author:</label>
<input type="text" name="author" size="50"></input><br/>
<label for="isbn">ISBN:</label>
<input type="text" name="isbn" size="15"></input><br/>
<label for="description">Description:</label><br/>
<textarea name="description" cols="80" rows="5">
</textarea><br/>
<input type="submit"></input>
</form>
</body>
</html>
这个模板定义了一个 HTML 页面,从概念上分为两部分。页面的顶部是读者阅读列表中的书籍列表。底部是一个读者可以使用它来添加新书到阅读列表的表单。
为了美观,Thymeleaf 模板引用了一个名为 style.css 的样式表。该文件应创建在 src/main/resources/static 中,如下所示:
body {
background-color: #cccccc;
font-family: arial,helvetica,sans-serif;
}
.bookHeadline {
font-size: 12pt;
font-weight: bold;
}
.bookDescription {
font-size: 10pt;
}
label {
font-weight: bold;
}
这个样式表很简单,并没有过度设计以使应用程序看起来很漂亮。但它满足了我们的需求,正如你很快就会看到的,它还用来演示 Spring Boot 的自动配置功能。
信不信由你,这是一个完整的应用程序。附录中已经向你展示了每一行代码。翻回前面的页面,看看你是否能找到任何配置。除了列表 1 中的三行配置(它启用了自动配置)之外,你不需要编写任何 Spring 配置。
尽管没有 Spring 配置,但这个完整的 Spring 应用程序已经准备好运行。让我们启动它,看看它的样子。
Spring Boot 测试
本节提供了有关使用 Spring Boot 进行测试的信息,通过模拟 Spring MVC 的部分。本节包含来自Spring Boot 实战第 4.2.1 节的内容。
模拟 Spring MVC
自 Spring 3.2 以来,Spring 框架已经有一个有用的功能,可以通过模拟 Spring MVC 来测试 Web 应用程序。这使得可以在不运行实际 servlet 容器中的控制器的情况下对控制器执行 HTTP 请求。相反,Spring 的 Mock MVC 框架模拟了足够的 Spring MVC,使得应用程序几乎就像在 servlet 容器中运行一样——但它并不是。
要在你的测试中设置 Mock MVC,你可以使用MockMvcBuilders。这个类提供了两个静态方法:
-
standaloneSetup()—构建一个 Mock MVC 来服务于一个或多个手动创建和配置的控制台 -
webAppContextSetup()—使用 Spring 应用程序上下文构建一个 Mock MVC,该上下文可能包括一个或多个配置好的控制台
这两个选项之间的主要区别在于,standaloneSetup() 预期您手动实例化和注入您想要测试的控制台,而 webAppContextSetup() 则从一个 WebApplicationContext 的实例开始工作,这个实例本身可能是由 Spring 加载的。前者在某种程度上更类似于单元测试,您可能会仅针对单个控制台进行聚焦测试。然而,后者允许 Spring 加载您的控制台以及它们的依赖项,以进行全面的集成测试。
为了我们的目的,您将使用 webAppContextSetup(),这样您就可以测试 ReadingListController,因为它已经被实例化并从 Spring Boot 自动配置的应用程序上下文中注入。
webAppContextSetup() 接收一个 WebApplicationContext 作为参数。因此,您需要使用 @WebAppConfiguration 注解测试类,并使用 @Autowired 将 WebApplicationContext 注入到测试中作为一个实例变量。此列表显示了您的 Mock MVC 测试的起点。
列表 6. MockMvcWebTests
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(
classes = ReadingListApplication.class)
@WebAppConfiguration *1*
public class MockMvcWebTests {
@Autowired
private WebApplicationContext webContext; *2*
private MockMvc mockMvc;
@Before
public void setupMockMvc() {
mockMvc = MockMvcBuilders *3*
.webAppContextSetup(webContext)
.build();
}
}
-
1 启用 web 上下文测试
-
2 注入 WebApplicationContext
-
3 设置 MockMvc
@WebAppConfiguration 注解声明由 SpringJUnit4ClassRunner 创建的应用程序上下文应该是一个 WebApplicationContext(而不是基本非 Web 的 ApplicationContext)。
setupMockMvc() 方法被 JUnit 的 @Before 注解标记,表示它应该在任何测试方法之前执行。它将注入的 WebApplicationContext 传递给 webAppContextSetup() 方法,然后调用 build() 来生成一个 MockMvc 实例,并将其分配给实例变量以供测试方法使用。
现在您有了 MockMvc,您就可以编写测试方法了。让我们从一个简单的测试方法开始,该方法对 /readingList 执行 HTTP GET 请求,并断言模型和视图符合您的期望。以下 homePage() 测试方法就是您需要的:
@Test
public void homePage() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/readingList"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.view().name("readingList"))
.andExpect(MockMvcResultMatchers.model().attributeExists("books"))
.andExpect(MockMvcResultMatchers.model().attribute("books",
Matchers.is(Matchers.empty())));
}
如您所见,在这个测试方法中使用了大量的静态方法,包括来自 Spring 的 MockMvcRequestBuilders 和 MockMvcResultMatchers 的静态方法,以及来自 Hamcrest 库的 Matchers。在深入探讨这个测试方法的细节之前,让我们添加一些静态导入,以便代码更容易阅读:
import static org.hamcrest.Matchers.*;
import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
在放置了这些静态导入之后,测试方法可以重写如下:
@Test
public void homePage() throws Exception {
mockMvc.perform(get("/readingList"))
.andExpect(status().isOk())
.andExpect(view().name("readingList"))
.andExpect(model().attributeExists("books"))
.andExpect(model().attribute("books", is(empty())));
}
现在测试方法几乎读起来很自然。首先,它对/readingList 执行一个GET请求。然后它期望请求成功(isOk()断言 HTTP 200 响应码)并且视图有一个逻辑名称为readingList。它还断言模型包含一个名为books的属性,但该属性是一个空集合。这一切都很直接。
需要注意的主要一点是,应用程序从未部署到 Web 服务器。相反,它在模拟的 Spring MVC 中运行,足以处理通过MockMvc实例抛给它的 HTTP 请求。
真的很酷,对吧?让我们再试一个测试方法。这次你将通过向新书籍发送 HTTP POST请求来让它更有趣。你应该期望在POST请求处理完毕后,请求将被重定向回/readingList,并且模型中的books属性将包含新添加的书籍。以下列表显示了如何使用 Spring 的 Mock MVC 进行此类测试。
列表 7. MockMvcWebTests
@Test
public void postBook() throws Exception {
mockMvc.perform(post("/readingList") *1*
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("title", "BOOK TITLE")
.param("author", "BOOK AUTHOR")
.param("isbn", "1234567890")
.param("description", "DESCRIPTION"))
.andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", "/readingList"));
Book expectedBook = new Book(); *2*
expectedBook.setId(1L);
expectedBook.setReader("craig");
expectedBook.setTitle("BOOK TITLE");
expectedBook.setAuthor("BOOK AUTHOR");
expectedBook.setIsbn("1234567890");
expectedBook.setDescription("DESCRIPTION");
mockMvc.perform(get("/readingList")) *3*
.andExpect(status().isOk())
.andExpect(view().name("readingList"))
.andExpect(model().attributeExists("books"))
.andExpect(model().attribute("books", hasSize(1)))
.andExpect(model().attribute("books",
contains(samePropertyValuesAs(expectedBook))));
}
-
1 执行 POST 请求
-
2 设置预期书籍
-
3 执行 GET 请求
这个测试稍微复杂一些;它是一个方法中的两个测试。第一部分发布书籍并断言该请求的结果。第二部分对主页执行一个新的GET请求并断言新创建的书籍在模型中。
在发布书籍时,你必须确保将内容类型设置为application/x-www-form-urlencoded(使用MediaType.APPLICATION_FORM_URLENCODED),因为当书籍在运行的应用程序中发布时,浏览器将发送这种内容类型。然后你使用MockMvcRequestBuilders's param()方法设置模拟提交的表单字段。请求执行后,你断言响应是重定向到/readingList。
假设测试方法的大部分都通过了,你继续到第二部分。首先,你设置一个包含预期值的Book对象。你将使用这个对象与获取主页后模型中的值进行比较。
然后你对/readingList 执行一个GET请求。大部分情况下,这与之前测试主页的方式没有不同,只是你检查模型中有一个项目,并且该项目与预期的Book相同。如果是这样,那么你的控制器似乎在将书籍发布给它时完成了保存书籍的工作。
摘要
-
从 Spring Boot 实战 中选择的内容涵盖了使用 Spring Boot 开发微服务的更多细节。
-
在 Spring Boot 实战(www.manning.com/books/spring-boot-in-action)中可以找到关于开发 Spring Boot 微服务的更多细节。



浙公网安备 33010602011771号