SpringBoot 微服务学习手册(三)
七、事件驱动架构
在前一章中,我们分析了微服务之间的接口如何在紧密耦合方面发挥关键作用。乘法微服务调用游戏化微服务,成为流程的编排者。如果有其他服务也需要为每次尝试检索数据,我们将需要从乘法应用向这些服务添加额外的调用,从而创建一个具有中央大脑的分布式整体。当我们检查一个假设的后端扩展时,我们详细讨论了这个问题。
在本章中,我们将基于发布-订阅模式,探讨设计这些接口的不同方式。该方法被称为事件驱动架构。发布者对事件进行分类和发送,而不知道系统中接收它们的部分,即订阅者,而不是将数据发送到特定的目的地。这些事件消费者也不需要知道发布者的逻辑。这种范式的改变使得我们的系统具有松耦合和可伸缩性,但也给我们的系统带来了新的挑战。
本章的目标是理解事件驱动架构的核心概念,它们的优势,以及使用它们的结果。像往常一样,我们将按照动手实践的方法将这些知识应用到我们的系统中。
核心概念
本节强调事件驱动架构的核心概念。
消息代理
事件驱动架构中的一个关键元素是消息代理。在这种类型的体系结构中,系统组件与代理通信,而不是直接相互连接。这就是我们如何保持它们之间的松散耦合。
消息代理通常包括路由功能。它们允许创建多个“通道”,因此我们可以根据我们的需求来分离消息。一个或多个发布者可以在这些通道的每一个中生成消息,并且这些消息可以被一个或多个订阅者(或者甚至没有订阅者)消费。在本章中,我们将更详细地了解什么是消息以及您可能想要使用的不同的消息传递拓扑。有关使用消息代理的一些典型场景的概念视图,请参见图 7-1 。
图 7-1
消息代理:高级视图
这些概念一点都不新鲜。已经活跃了一段时间的开发人员现在肯定能在企业服务总线(ESB)架构中发现类似的模式。总线模式促进了系统不同部分之间的通信,提供了数据转换和映射、消息排队和排序、路由等。
关于 ESB 体系结构和基于消息代理的体系结构之间的确切区别,仍然存在一些争议。一个被广泛接受的区别是,在 ESB 中,通道本身在系统中有更大的相关性。服务总线为通信设置协议标准,并将数据转换和路由到特定的目标。一些实现可以处理分布式事务。在某些情况下,他们甚至有一个复杂的 UI 来建模业务流程,并将这些规则转换成配置和代码。通常,ESB 架构倾向于将系统的大部分业务逻辑集中在总线内部,因此它们成为系统的编排层。见图 7-2 。
图 7-2
ESB 架构将业务逻辑集中在总线内部
将所有的业务逻辑转移到同一个组件中,并在系统中有一个中央编排器,这是容易失败的软件架构模式。遵循这一路线的系统会出现单点故障,并且随着时间的推移,它们的核心部分(在本例中是总线)变得更难维护和发展,因为整个组织都依赖于它。嵌入总线的逻辑往往会变得一塌糊涂。这是 ESB 体系结构在过去几年中名声如此之差的原因之一。
基于这些糟糕的经历,许多人现在倾向于放弃这种集中编排的、过于智能的消息传递通道,而使用消息代理实现一种更简单的方法,仅用于不同组件之间的通信。
在这一点上,您可能认为 ESB 是复杂的通道,而消息代理是简单的通道。但是,我之前提到过,有一点争议,所以并不是那么容易划那条线。一方面,您可以使用 ESB 平台,但保持业务逻辑适当隔离。另一方面,Kafka 等一些现代消息平台提供了一些工具,允许您在通道中嵌入一些逻辑。如果需要,您可以使用可能包含业务逻辑的函数来转换消息。您还可以像处理数据库一样查询通道中的数据,并且可以根据需要处理输出。例如,基于消息中包含的一些数据,您可以决定将它从特定的通道中取出,并将其移到另一个具有不同格式的通道中。因此,您可以在通常与不同架构模式(ESB/消息代理)相关联的工具之间进行切换,但仍然可以类似地使用它们。这个想法已经给了我们即将到来的章节的核心要点的早期介绍:首先你需要理解模式,然后你可以选择最适合你需求的工具。
我建议您尽可能避免在沟通渠道中包含业务逻辑。遵循领域驱动的设计方法,在分布式系统中保持逻辑的位置。这就是我们将在我们的系统中做的:我们将引入一个消息代理来保持我们的服务的松散耦合和可伸缩性,将业务流程保持在每个微服务内部。
事件和消息
在事件驱动的架构中,事件表明系统中发生了一些事情。事件由拥有发生这些事件的域的业务逻辑发布到消息通道(例如,消息代理)。架构中对给定事件类型感兴趣的其他组件订阅该通道以使用所有后续事件实例。如您所见,事件与发布-订阅模式相关,因此它们也链接到消息代理或总线。我们将使用消息代理实现一个事件驱动的架构,所以让我们把重点放在那个特定的案例上。
另一方面,消息是一个更通用的术语。许多人对消息和事件进行了区分,前者是直接寻址到系统组件的元素,后者是反映给定域中发生的事实的信息片段,没有特定的收件人。然而,当我们通过消息代理发送事件时,从技术角度来看,事件实际上是一条消息(因为没有事件代理这种东西)。为了简单起见,我们将在本书中使用术语消息来指代通过消息代理传递的一般信息,当我们指代遵循事件驱动设计的消息时,我们将使用事件。
请注意,没有什么可以阻止我们使用 REST APIs 对事件进行建模和发送(类似于我们在应用中所做的)。然而,这无助于减少紧耦合:生产者需要了解消费者,以便将事件指向他们。
当我们在消息代理中使用事件时,我们可以更好地隔离软件架构中的所有组件。发布者和订阅者不需要知道彼此。这非常适合微服务架构,因为我们希望尽可能保持微服务的独立性。通过这种策略,我们可以引入新的微服务来消费来自通道的事件,而无需修改发布这些事件的微服务或其他订阅者。
在事件中思考
请记住,消息代理和一些带后缀Event的类的引入不会使我们的架构自动“事件驱动”。我们必须在事件中设计我们的软件思维,如果我们不习惯,这需要努力。让我们使用我们的应用对此进行更深入的分析。
在第一个场景中,假设我们已经创建了一个游戏化 API 来为给定的用户分配分数和徽章。见图 7-3 的上部。然后,乘法微服务将调用这个 API,updateScore,不仅意识到这个微服务的存在,而且成为其部分业务逻辑的所有者(通过为解决的尝试分配分数)。这是人们从微服务架构开始,并来自命令式编程风格时,经常犯的错误。他们倾向于通过微服务之间的 API 调用来改变方法调用,实现远程过程调用(RPC)模式,有时甚至没有注意到这一点。为了改善微服务之间的耦合,我们可以引入一个消息代理。然后,我们将 REST API 调用替换为一条指向游戏化微服务的消息,即UpdateScore消息。但是,我们会通过这种改变来改进系统吗?不多。该消息仍然有一个特定的目的地,因此它不能被任何新的微服务重用。此外,系统的两个部分保持紧密耦合,并且,作为副作用,我们用异步接口替换了同步接口,引入了额外的复杂性(正如我们在前一章中看到的,这一章也将进一步阐述)。
图 7-3
命令式方法:REST 与 message
第二个场景基于我们当前的实现。见图 7-4 。我们将一个ChallengeSolvedDTO对象从乘法传递到游戏化,所以我们尊重我们的域边界。我们不在第一个服务中包含游戏化逻辑。然而,我们仍然需要直接解决游戏化,所以紧密耦合仍然存在。随着消息代理的引入,我们可以解决这个问题。乘法微服务可以向通用通道发送一个ChallengeSolvedDTO,并继续执行其逻辑。我们的第二个微服务可以订阅这个频道并处理消息(在概念上已经是一个事件)来计算新的分数和徽章。添加到系统中的新微服务可以透明地订阅该频道,如果它们也对ChallengeSolvedDTO消息感兴趣的话,例如,生成报告或向用户发送消息。
图 7-4
事件:休息与消息
我们的第一个场景实现了一个命令模式,其中乘法微服务指示对游戏化微服务做什么(也称为编排)。第二个场景通过发送关于已经发生的事情的通知以及上下文数据来实现事件模式。消费者将处理这些数据,这可能会触发他们的业务逻辑,结果可能会触发其他事件。这种方法有时被称为编排,与编排相对。当我们的软件架构基于这些事件驱动的设计时,我们称之为事件驱动架构。
如您所见,为了实现真正的事件驱动架构,我们必须重新思考可能以命令式表达的业务流程,并将它们定义为(重新)动作和事件。我们不仅应该使用 DDD 定义域,还应该将它们之间的交互建模为事件。如果您想了解更多有助于您开展这些设计会议的技术,请查看 https://tpd.io/event-storming 。
在继续之前,让我再次强调一个重要的观点:您不需要改变系统中的每一个通信接口来遵循事件驱动的风格。在某些事件不适合的情况下,您可能需要实现命令和请求/响应模式。不要试图强迫一个只适合作为命令的业务需求人为地表现为一个事件。在技术方面,不要害怕在更有意义的用例中使用 REST APIs,比如需要同步响应的命令。
微服务并不总是最佳解决方案(三)
当您构建一个主要使用命令式、目标接口的微服务架构时,所有这些系统组件之间有许多硬依赖。许多人将这种场景称为分布式单片,因为你仍然有单片应用的缺点:紧耦合,因此修改微服务的灵活性较低。
如果您需要一些时间在您的组织中建立一个事件驱动的思维模式,您可以建立一个模块化系统,并开始跨模块实现事件模式。然后,你从一次学习一件事情并保持可控的复杂性中受益。一旦实现了松散耦合,就可以将模块拆分成微服务。
异步消息传递
在前一章中,我们专门用了一节来分析将同步接口改为异步接口的影响。随着消息代理作为构建事件驱动架构的工具的引入,异步消息传递的采用是不言而喻的。发布者发送事件,不等待任何事件消费者的响应。这将使我们的架构保持松散耦合和可伸缩性。见图 7-5 。
图 7-5
使用消息代理的异步流程
然而,我们也可以使用消息代理并保持流程同步。让我们再次以我们的系统为例。我们计划用消息代理替换 REST API 接口。然而,我们可以创建两个通道来接收来自游戏化微服务的响应,而不是创建一个通道来发送我们的事件。参见图 7-6 。在我们的代码中,我们可以阻塞请求的线程,并在继续处理之前等待确认。
图 7-6
使用消息代理进行同步处理
这实际上是消息代理之上的请求/响应模式。这种组合在某些用例中很有用,但是在事件驱动的方法中不推荐使用。主要原因是我们再次获得了紧密耦合:乘法微服务需要了解订户及其数量,以确保它接收到所有响应。我们仍然有一些优势,如可伸缩性(我们将在后面详述),但是我们可以应用其他模式来提高同步接口的可伸缩性,如负载平衡器(我们将在下一章中看到)。因此,在我们的流程无论如何都需要同步的情况下,我们可以考虑使用一个更简单的同步接口,比如 REST API。参见表 7-1 总结如何结合模式和工具。请记住,这只是一个建议。正如我们已经分析过的,您可能有自己的偏好来使用不同的工具实现这些模式。
表 7-1
结合模式和工具
|模式
|
类型
|
履行
| | --- | --- | --- | | 请求/回应 | 同步的 | 应用接口 | | 需要阻止的命令 | 同步的 | 应用接口 | | 不需要阻止的命令 | 异步的 | 消息代理 | | 事件 | 异步的 | 消息代理 |
值得注意的是,尽管端到端的通信可以是异步的,但是我们将从我们的应用中获得与消息代理的同步接口。这是一个重要的特征。当我们发布一个消息时,我们希望在继续做其他事情之前确保代理收到了它。这同样适用于订阅者,在订阅者那里,代理在消费消息之后要求确认,以将它们标记为已处理,并转移到下一个消息。这两个步骤对于保证我们数据的安全和系统的可靠性至关重要。我们将在本章的后面用我们的实际案例来解释这些概念。
反应系统
反应式这个词可以在多种上下文中使用,根据所指的技术层有不同的含义。反应式系统最广为接受的定义将其描述为一套应用于软件架构的设计原则,以使系统具有响应性(及时响应)、弹性(出现故障时保持响应)、弹性(适应不同工作负载下的响应)和消息驱动(确保松散耦合和边界隔离)。这些设计原则都列在了反应宣言里( https://tpd.io/rmanifesto )。在构建我们的系统时,我们将遵循这些模式,因此我们可以宣称我们正在构建一个反应式系统。
另一方面,反应式编程指的是编程语言中围绕未来(或承诺)、反应流、反压力等模式使用的一套技术。有一些流行的库可以帮助你用 Java 实现这些模式,比如 Reactor 或 RxJava。使用反应式编程,您可以将您的逻辑分成一组更小的块,这些块可以异步运行,然后组合或转换结果。这带来了并发性的提高,因为当你并行处理任务时,你可以走得更快。
切换到反应式编程不会使您的架构反应式。它们在不同的层次上工作:反应式编程有助于实现组件内部和并发性方面的改进。反应式系统是组件之间更高层次的变化,有助于构建松散耦合、有弹性和可伸缩的系统。参见 https://tpd.io/react-sys-prg 了解两种技术之间差异的更多细节。
事件驱动的利与弊
在前一章中,我们讨论了迁移到微服务的利弊。我们获得了灵活性和可伸缩性,但我们面临着新的挑战,如最终的一致性、容错和部分更新。
采用事件驱动的消息代理模式有助于应对这些挑战。让我们用实际例子简单描述一下如何实现。
-
微服务之间的松散耦合:我们已经知道如何让乘法服务不知道游戏化服务。第一个向代理发送一个事件,游戏化订阅并响应该事件,为用户更新分数和徽章。
-
可伸缩性:正如我们将在本章中看到的,添加给定应用的新实例来横向扩展我们的系统是很容易的。此外,在我们的架构中引入新的微服务也很容易。他们可以订阅事件并独立工作,例如在我们分析的假设情况下:我们可以基于现有服务触发的事件生成报告或发送电子邮件。
-
容错和最终一致性:如果我们让消息代理足够可靠,我们可以用它来保证最终的一致性,即使系统组件出现故障。如果游戏化微服务宕机一段时间,它可以在稍后恢复时赶上事件,因为代理可以持久化消息。这给了我们一些灵活性。我们将在本章末尾看到这一点。
另一方面,采用基于事件的设计模式证实了我们对最终一致性的选择。我们避免创建阻塞的、强制性的流程。相反,我们使用简单通知其他组件的异步流程。正如我们所看到的,这需要一种不同的思维方式,所以我们(可能还有我们的 API 客户端)接受数据状态可能在所有微服务中不一致。
此外,随着消息代理的引入,我们正在向系统中添加一个新的组件。我们不能简单地说消息代理没有失败,所以我们必须让系统为新的潜在错误做好准备。
-
掉话:可能是
ChallengeSolvedEvent永远达不到游戏化的情况。如果你正在构建一个不应该错过事件的系统,你应该配置代理来实现至少一次保证。该策略确保消息至少由代理传递一次,尽管它们可能是重复的。 -
重复消息:在某些情况下,消息代理可能不止一次地发送一些只发布一次的消息。在我们的系统中,如果我们得到事件两次,我们将错误地增加分数。因此,我们不得不考虑让事件消费幂等。在计算中,如果一个操作可以被调用多次而没有不同的结果,那么它就是幂等的。在我们的情况下,一个可能的解决方案是标记我们已经在游戏化端(例如,在数据库中)处理的事件,并忽略任何重复的事件。一些像 RabbitMQ 和 Kafka 这样的经纪人也提供一个很好的最多一次保证,如果我们正确配置他们,这有助于防止重复。
-
无序消息:即使代理可以尽最大努力避免无序消息,如果出现故障或者由于我们软件中的错误,这种情况仍然会发生。我们必须编写代码来应对这种情况。如果可能的话,尽量避免假设事件将按照它们发布的时间顺序被消费。
-
经纪人的停机时间:在最坏的情况下,经纪人也可能变得不可用。发布者和订阅者都应该尝试处理这种情况(例如,使用重试策略或缓存)。我们也可以将服务标记为不健康,并停止接受新的操作(我们将在下一章讨论)。这可能意味着整个系统停机,但可能是比接受部分更新和不一致数据更好的选择。
在前面的每一个要点中提出的这些示例解决方案是弹性模式。其中一些可以转化为编码任务,我们应该这样做,以使我们的系统即使在失败的情况下也能工作,例如等幂、重试或健康检查。正如我们已经提到的,良好的弹性在分布式系统(如微服务架构)中非常重要,因此在设计会议期间,了解这些模式以便为不愉快的流程带来解决方案总是很方便的。
事件驱动系统的另一个缺点是可追溯性变得更加困难。我们调用 REST API,这可能会触发事件;然后,可能会有组件对这些事件作出反应,随后发布一些其他事件,这个链继续下去。当我们只有几个分布式进程时,知道不同微服务中的什么事件导致什么动作可能不是问题。然而,当系统增长时,在事件驱动的微服务架构中,拥有这些事件和动作链的整体视图是一个很大的挑战。我们需要这个视图,因为我们希望能够调试出错的操作,并找出我们触发给定流程的原因。幸运的是,有工具可以实现分布式跟踪:一种我们链接事件和动作并将它们可视化为动作/反应链的方式。例如,Spring 家族有 Spring Cloud Sleuth,这是一个在日志中自动注入一些标识符(span IDs)并在我们发出/接收 HTTP 调用、通过 RabbitMQ 发布/消费消息等时传播这些标识符的工具。然后,如果我们使用集中式日志记录,我们可以使用标识符链接所有这些进程。我们将在下一章讨论这些策略。
消息模式
我们可以在消息传递平台中确定几种模式,我们可以根据我们想要实现的目标来应用这些模式。让我们从一个高层次的角度来详细描述它们,而不涉及任何特定平台的实现细节。您可以使用图 7-7 作为理解这些概念的指南,这些概念将在接下来的页面中详细介绍。
图 7-7
消息模式
发布-订阅
在这种模式中,不同的订阅者接收相同消息的副本。例如,我们的系统中可能有多个对ChallengeSolvedEvent感兴趣的组件,比如游戏化微服务和假设的报告微服务。在这种情况下,重要的是配置这些订阅者,使它们接收相同的消息。每个订阅者将带着不同的目的处理事件,这样就不会导致重复的操作。
请注意,这种模式更适合事件,而不适合发送给特定服务的消息。
工作队列
这种模式也被称为竞争消费者模式。在这种情况下,我们希望在同一个应用的多个实例之间分割工作。
如图 7-7 所示,我们可以拥有同一个微服务的多个副本。然后,目的是平衡它们之间的负载。每个实例将使用不同的消息,处理它们,并可能将结果存储在数据库中。图中的数据库提醒我们,同一个组件的多个副本应该共享同一个数据层,所以拆分工作是安全的。
过滤
同样常见的是,有些订阅者对一个频道中发布的所有消息都感兴趣,而有些订阅者只对其中的某些消息感兴趣。这就是图 7-7 中第二个用户的情况。我们能想到的最简单的选择是,根据应用中包含的一些过滤逻辑,在它们被消费后立即丢弃它们。相反,一些消息代理还提供现成的过滤功能,因此组件可以使用给定的过滤器将自己注册为订阅者。
数据持久性
如果代理持久化消息,订阅者不需要一直运行来消耗所有数据。每个订阅者在代理中都有一个相关的标记,以了解他们消费的最后一条消息是什么。如果他们不能在给定的时间获得消息,数据流稍后可以从他们离开的地方继续。
即使在所有订户都检索到特定消息之后,您也可能希望将它存储在代理中一段时间。如果您希望新订户获得在他们存在之前发送的消息,这是很有用的。此外,如果您希望为订阅者“重置标记”,从而导致所有消息都被重新处理,则在给定时间段内保留所有消息会很有帮助。例如,这可以用于修复损坏的数据,但是当订阅者不是幂等的时,这也可能是一个有风险的操作。
在一个将所有操作建模为事件的系统中,您可以从事件持久性中获益更多。假设您清除了任何现有数据库中的所有数据。理论上,你可以从头开始重放所有事件,并重新创建相同的状态。因此,您根本不需要在数据库中保存给定实体的最后状态,因为您可以将它视为多个事件的“集合”。简而言之,这就是活动采购的核心理念。我们不会深入这项技术的细节,因为它增加了额外的复杂性,但是如果你想了解更多,请查看 https://tpd.io/eventsrc。
消息代理协议、标准和工具
多年来,出现了一些与消息代理相关的消息传递协议和标准。这是一个包含一些流行示例的精简列表:
-
高级消息队列协议(AMQP) :这是一种有线级协议,将消息的数据格式定义为字节流。
-
消息队列遥测传输(MQTT) :这也是一种协议,由于它可以用很少的代码实现,并且可以在有限的带宽条件下工作,因此它已经成为物联网(IoT)设备的流行协议。
-
面向流文本的消息协议(STOMP) :这是一个基于文本的协议,类似于 HTTP,但面向消息中间件。
-
Java 消息服务(JMS) :和之前的不一样,JMS 是一个 API 标准。它关注于消息传递系统应该实现的行为。因此,我们可以找到使用不同底层协议连接到消息代理的不同 JMS 客户机实现。
下面是一些流行的软件工具,它们实现了其中的一些协议和标准,或者拥有自己的协议和标准:
-
RabbitMQ 是一个开源的消息代理实现,支持 AMQP、MQTT 和 STOMP 等协议。它还提供了 JMS API 客户端,并具有强大的路由配置。
-
Mosquitto 是一个实现 MQTT 协议的 Eclipse 消息代理,因此它是物联网系统的一个流行选择。
-
Kafka 最初是由 LinkedIn 设计的,它在 TCP 上使用自己的二进制协议。尽管 Kafka 核心特性没有提供与传统消息代理相同的功能(例如,路由),但当对消息中间件的需求很简单时,它是一个强大的消息平台。它通常用在处理大量数据流的应用中。
在任何需要在不同工具之间进行选择的情况下,您都应该熟悉它们的文档,并分析您的需求如何从其功能中受益:您计划处理的数据量、交付保证(最少一次,最多一次)、错误处理策略、分布式设置的可能性等。当用 Java 和 Spring Boot 构建事件驱动架构时,RabbitMQ 和 Kafka 都是流行的工具。此外,Spring 框架集成了这些工具,所以从编码的角度来看,使用它们很容易。
在本书中,我们使用 RabbitMQ 和 AMQP 协议。主要原因是这种组合提供了各种各样的配置可能性,因此您可以学习其中的大多数选项,并在以后选择的任何其他消息传递平台中重用这些知识。
AMQP 和 RabbitMQ
RabbitMQ 对 AMQP 协议版本 0.9.1 提供本地支持,并通过插件支持 AMQP 1.0 版本。我们将使用包含的 0.9.1 版本,因为它更简单,支持更好; https://tpd.io/amqp1见。
我们现在来看看 AMQP 0.9.1 的主要概念。如果你想更详细地了解概念,我建议你参考 RabbitMQ 文档中的 https://tpd.io/amqp-c 。
总体描述
如本章前面所述,发布者是系统中向代理发布消息的组件或应用。消费者,也称为订户,接收并处理这些消息。
图 7-8
rabbitmq:概念
AMQP 还定义了交换、队列和绑定。参见图 7-8 以更好地理解这些概念。
-
交换机是发送消息的实体。它们按照交换类型和规则定义的逻辑路由到队列,称为绑定。如果在代理重新启动后交换仍然存在,则交换可以是持久的,如果不存在,则交换是暂时的。
-
队列是 AMQP 中存储要使用的消息的对象。队列可以有零个、一个或多个使用者。队列也可以是持久的或暂时的,但是请记住,持久的队列并不意味着它的所有消息都是持久的。为了使消息在代理重启后仍然存在,它们还必须作为持久性消息发布。
-
绑定是将发布到交易所的消息路由到特定队列的规则。因此,我们说一个队列被绑定到一个给定的交换。一些交换类型支持可选的绑定键,以确定发布到交换的哪些消息应该在给定的队列中结束。在这种意义上,您可以将绑定键视为过滤器。另一方面,发布者可以在发送消息时指定路由键,因此如果使用这些配置,可以根据绑定键对它们进行适当的过滤。路由关键字由点分隔的单词组成,如
attempt.correct。绑定键具有类似的格式,但是它们可能包括模式匹配器,这取决于交换类型。
交换类型和路由
我们可以使用几种交换类型。图 7-9 显示了每种交换类型的示例,结合了由绑定关键字定义的不同路由策略,以及每条消息对应的路由关键字。
图 7-9
交换类型:示例
-
默认交易所由经纪人预先申报。所有创建的队列都通过与队列名称相同的绑定键绑定到此交换。从概念的角度来看,这意味着如果我们将目的地队列的名称用作路由关键字,就可以在考虑目的地队列的情况下发布消息。从技术上讲,这些消息仍然要经过交换。这种设置不常用,因为它破坏了整个路由目的。
-
直接交换通常用于单播路由。与默认交换的区别在于,您可以使用自己的绑定键,也可以使用相同的绑定键创建多个队列。然后,这些队列都将获得路由关键字与绑定关键字匹配的消息。从概念上讲,我们在发布消息时使用它,但我们不需要知道有多少个队列会收到消息。
-
扇出交换不使用路由键。它将所有消息路由到绑定到交换的所有队列,因此非常适合广播场景。
-
话题交换最灵活。我们可以使用一个模式,而不是使用给定的值将队列绑定到这个交换。这允许订阅者注册队列来使用一组经过过滤的消息。模式可以使用
#匹配任何一组单词,或者使用*只匹配一个单词。 -
报头交换使用消息报头作为路由关键字,以获得更好的灵活性,因为我们可以设置一个或多个报头的匹配条件,以及全匹配或任意匹配配置。因此,标准路由关键字被忽略。
正如我们所看到的,我们在本章前面描述的发布-订阅和过滤模式适用于这些场景。图中的直接交换示例可能看起来像工作队列模式,但它不是。这个例子是为了说明,在 AMQP 0.9.1 中,负载平衡发生在同一队列的使用者之间,而不是队列之间。为了实现工作队列模式,我们通常不止一次地订阅同一个队列。参见图 7-10 。
图 7-10
AMQP 的工作队列
消息确认和拒绝
AMQP 为消费者应用定义了两种不同的确认模式。理解它们很重要,因为在消费者发送确认后,消息会从队列中删除。
第一种选择是使用自动确认。使用这种策略,当消息被发送到应用时,它们被认为是已传递的。第二个选项叫做显式确认,它包括等待直到应用发送一个 ACK 信号。第二个选项更能保证所有消息都得到处理。消费者可以读取消息,运行一些业务逻辑,保存相关数据,甚至在向代理发送确认信号之前触发一个后续事件。在这种情况下,只有在消息被完全处理后,才会将其从队列中删除。如果消费者在发送信号之前死亡(或者有错误),代理将尝试将消息传递给另一个消费者,或者,如果没有,它将等到有可用的消费者。
消费者也可以拒绝消息。例如,假设一个消费者实例由于网络错误而无法访问数据库。在这种情况下,使用者可以拒绝该消息,指定是应该重新排队还是丢弃该消息。请注意,如果导致消息拒绝的错误持续一段时间,并且没有其他使用者可以成功处理它,我们可能会陷入 requeue-rejection 的无限循环中。
设置 RabbitMQ
现在我们已经学习了主要的 AMQP 概念,是时候下载并安装 RabbitMQ 代理了。
转到 RabbitMQ 下载页面( https://tpd.io/rabbit-dl ),选择适合您的操作系统的版本。在本书中,我们将使用 RabbitMQ 版本 3.8.3。RabbitMQ 是用 Erlang 编写的,所以如果您的系统的二进制安装中没有包含这个框架,您可能需要单独安装它。
一旦我们遵循下载页面上的所有说明,我们必须启动代理。您的操作系统的下载页面中也应该包括所需的步骤。例如,在 Windows 中,RabbitMQ 是作为一项服务安装的,您可以从“开始”菜单中启动/停止它。在 macOS 中,你必须从命令行运行命令。
RabbitMQ 包含了一些标准插件,但并不是所有的插件都是默认启用的。作为一个额外的步骤,我们将启用管理插件,这使我们能够访问一个 Web UI 和一个 API 来监控和管理代理。从代理安装文件夹中的sbin文件夹,我们必须执行以下命令:
$ rabbitmq-plugins enable rabbitmq_management
然后,当我们重启代理时,我们应该能够导航到http://localhost:15672并看到一个登录页面。因为我们在本地运行,所以我们可以使用默认的用户名和密码值:guest / guest。RabbitMQ 支持定制对代理的访问控制;如果您想了解更多关于用户授权的细节,请勾选 https://tpd.io/rmq-ac 。图 7-11 显示了我们登录后的 RabbitMQ 管理插件 UI。
图 7-11
rabbitmq 管理插件 UI
从这个 UI 中,我们可以监控排队的消息、处理速率、关于不同注册节点的统计数据等。工具栏使我们能够访问许多其他功能,如队列和交换的监控和管理。我们甚至可以从这个界面创建或删除这些实体。我们将改为以编程方式创建交换和队列,但是这个工具对于理解我们的应用如何与 RabbitMQ 一起工作非常有用。
在主要部分“概述”中,我们可以看到节点列表。我们只是在本地安装了它,所以只有一个名为rabbit@localhost的节点。我们可以通过网络添加更多的 RabbitMQ 代理实例,然后在不同的机器上建立一个分布式集群。这将为我们提供更好的可用性和容错能力,因为代理可以复制数据,所以如果节点宕机或出现网络分区,我们仍然可以运行。官方 RabbitMQ 文档中的集群指南( https://tpd.io/rmq-cluster )描述了可能的配置选项。
Spring AMQP 和 Spring Boot
因为我们正在用 Spring Boot 构建我们的微服务,所以我们将使用 Spring 模块连接到 RabbitMQ 消息代理。在这种情况下,Spring AMQP 项目是我们正在寻找的。这个模块包含两个工件:spring-rabbit,它是一组与 RabbitMQ 代理一起工作的实用程序,以及spring-amqp,它包含所有的 AMQP 抽象,因此我们可以使我们的实现独立于供应商。目前,Spring 只提供了 AMQP 协议的 RabbitMQ 实现。
和其他模块一样,Spring Boot 为 AMQP 提供了额外的实用程序,比如自动配置:spring-boot-starter-amqp。这个 starter 使用前面描述的两个构件,所以它隐含地假设我们将使用 RabbitMQ 代理(因为它是唯一可用的实现)。
我们将使用 Spring 来声明我们的交换、队列和绑定,并生成和消费消息。
解决方案设计
在描述本章中的概念时,我们已经快速预览了我们将要构建的内容。参见图 7-12 。该图仍然包括序列号,以表明乘法微服务对客户端的响应可能发生在游戏化微服务处理消息之前。这是一个异步的,最终一致的流程。
图 7-12
使用消息代理的异步流程
如图所示,我们将创建一个主题类型的尝试交换。在像我们这样的事件驱动架构中,这使我们能够灵活地发送带有特定路由关键字的事件,并允许消费者订阅所有事件或在其队列中设置自己的过滤器。
从概念上讲,乘法微服务拥有尝试交换。它将使用它来发布与来自用户的尝试相关的事件。原则上,它会发布正确和错误的条目,因为它不知道任何关于消费者逻辑的事情。另一方面,游戏化微服务用适合其要求的绑定键声明一个队列。在这种情况下,该路由关键字用作过滤器,只接收正确的尝试。如上图所示,我们可能有多个游戏化微服务实例在同一个队列中消费。在这种情况下,代理将在所有实例之间平衡负载。
假设有一个不同的微服务也对ChallengeSolvedEvent感兴趣,这个微服务需要声明自己的队列来使用相同的消息。例如,我们可以引入 Reports 微服务,它创建一个“报告”队列,并使用绑定键attempt.*(或#)来消耗正确和错误的尝试。
如您所见,我们可以很好地结合发布-订阅和工作队列模式,以便多个微服务可以处理相同的消息,并且同一个微服务的多个实例可以在它们之间分担负载。此外,通过让发布者负责交换,订阅者负责队列,我们构建了一个事件驱动的微服务架构,通过引入消息代理实现了松散耦合。
让我们创建一个完成计划所需的任务列表:
-
将新的 starter 依赖项添加到我们的 Spring Boot 应用中。
-
移除向游戏化和相应控制器显式发送挑战的 REST API 客户端。
-
将
ChallengeSolvedDTO重命名为ChallengeSolvedEvent。 -
在乘法微服务上申报兑换。
-
改变乘法微服务的逻辑,发布一个事件,而不是调用 REST API。
-
在游戏化微服务上声明队列。
-
包括从队列中获取事件的消费者逻辑,并将其连接到现有的服务层,以处理分数和徽章的正确尝试。
-
相应地重构测试。
在本章的最后,我们还将尝试新的设置,体验 RabbitMQ 引入的负载平衡和容错优势。
添加 AMQP 启动器
为了在我们的 Spring Boot 应用中使用 AMQP 和 RabbitMQ 特性,让我们将相应的启动器添加到我们的pom.xml文件中。清单 7-1 展示了这种新的依赖关系。
<dependencies>
<!-- ... existing dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
</dependencies>
Listing 7-1Adding the AMQP Starter to Both Spring Boot Projects
源代码
您可以在 GitHub 的chapter07资源库中找到本章的所有源代码。
https://github.com/Book-Microservices-v2/chapter07见。
这个启动器包括前面提到的spring-rabbit和spring-amqp库。传递依赖spring-boot-autoconfigure,我们从前面的章节中知道,包括一些类,负责连接 RabbitMQ 和设置一些方便的缺省值。
在这种情况下,最有趣的一个类就是RabbitAutoConfiguration(见 https://tpd.io/rabbitautocfg )。它使用了在RabbitProperties类中定义的一组属性(见 https://tpd.io/rabbitprops ),我们可以在application.properties文件中覆盖这些属性。在那里,我们可以找到预定义的端口(15672)、用户名(guest)和密码(guest)。自动配置类为RabbitTemplate对象构建连接工厂和配置器,我们可以用它们向 RabbitMQ 发送(甚至接收)消息。我们将使用抽象接口AmqpTemplate(参见 https://tpd.io/amqp-temp-doc )。
自动配置包还包括一些默认配置,用于使用替代机制接收消息:RabbitListener注释。我们将在编写 RabbitMQ 订户代码时更详细地介绍这一点。
来自乘法的事件发布
先来关注一下我们的发行商,乘法微服务。添加新的依赖项后,我们可以包含一些额外的配置。
-
交换名称:在配置中有它是很有用的,以防我们需要根据我们运行应用的环境来修改它,或者在应用之间共享它,我们将在下一章中看到。
-
日志设置:我们添加它们是为了在 app 与 RabbitMQ 交互时查看额外的日志。为此,我们将把
RabbitAdmin类的日志级别改为DEBUG。这个类与 RabbitMQ 代理交互,以声明交换、队列和绑定。
此外,我们可以删除指向游戏化服务的属性;我们不再需要直接调用它了。清单 7-2 显示了所有的属性变更。
# ... all properties above remain untouched
# For educational purposes we will show the SQL in console
# spring.jpa.show-sql=true <- it's time to remove this
# Gamification service URL <-- We remove this block
# service.gamification.host=http://localhost:8081
amqp.exchange.attempts=attempts.topic
# Shows declaration of exchanges, queues, bindings, etc.
logging.level.org.springframework.amqp.rabbit.core.RabbitAdmin = DEBUG
Listing 7-2Adjusting application.properties in the Multiplication Microservice
现在我们将交换声明添加到 AMQP 的一个单独的配置文件中。Spring 模块为此提供了一个方便的构建器ExchangeBuilder。我们所做的是添加一个我们想要在代理中声明的主题类型的 bean。此外,我们将使用这个配置类将预定义的序列化格式切换到 JSON。在我们开始解释之前,请参见清单 7-3 。
package microservices.book.multiplication.configuration;
import org.springframework.amqp.core.ExchangeBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configures RabbitMQ via AMQP
abstraction to use events in our application.
*/
@Configuration
public class AMQPConfiguration {
@Bean
public TopicExchange challengesTopicExchange(
@Value("${amqp.exchange.attempts}") final String exchangeName) {
return ExchangeBuilder.topicExchange(exchangeName).durable(true).build();
}
@Bean
public Jackson2JsonMessageConverter producerJackson2MessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
Listing 7-3Adding AMQP Configuration Beans
我们使主题持久化,所以在 RabbitMQ 重启后,它将保留在代理中。此外,我们将其声明为主题交换,因为这是我们在事件驱动系统中设想的解决方案。由于已知的@Value注释,该名称从配置中提取。
通过注入类型为Jackson2JsonMessageConverter的 bean,我们用 JSON 对象序列化程序覆盖了默认的 Java 对象序列化程序。我们这样做是为了避免 Java 对象序列化的各种缺陷。
-
这不是一个我们可以在编程语言之间使用的标准。如果我们要引入一个不是用 Java 编写的消费者,我们必须寻找一个特定的库来执行跨语言反序列化。
-
它在消息头中使用硬编码的完全限定类型名。反序列化程序希望 Java bean 位于同一个包中,并且具有相同的名称和字段。这一点也不灵活,因为我们可能希望按照良好的域驱动设计实践,只反序列化一些属性,并保留我们自己的事件数据版本。
Jackson2JsonMessageConverter使用了 AMQP Spring 预先配置的杰克逊的ObjectMapper。然后,RabbitTemplate实现将使用我们的 bean,这个类序列化对象并将对象作为 AMQP 消息发送给代理。在订户端,我们可以受益于 JSON 格式的流行,使用任何编程语言反序列化内容。我们也可以使用自己的对象表示,忽略消费者端不需要的属性,从而减少微服务之间的耦合。如果发布者在有效负载中包含新字段,订阅者不需要做任何更改。
JSON 不是 Spring AMQP 消息转换器支持的唯一标准。你也可以使用 XML 或者谷歌的协议缓冲区(又名 protobuf )。我们将在我们的系统中坚持使用 JSON,因为它是一个扩展的标准,而且它也有利于教育目的,因为有效载荷是可读的。在性能至关重要的实际系统中,您应该考虑高效的二进制格式(例如 protobuf)。数据序列化格式对比见 https://tpd.io/dataser 。
我们的下一步是移除GamificationServiceClient类。然后,我们还想重命名现有的ChallengeSolvedDTO,使其成为一个事件。我们不需要修改任何字段,只需要修改名称。见清单 7-4 。
package microservices.book.multiplication.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedEvent {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 7-4Renaming ChallengeSolvedDTO as ChallengeSolvedEvent
此处显示的命名约定是事件的良好实践。它们代表一个已经发生的事实,所以名字应该用过去式。此外,通过添加Event后缀,很明显我们使用的是事件驱动的方法。
接下来,我们在服务层中创建一个新组件来发布事件。这相当于我们已经移除的 REST 客户端,但是这次我们与消息代理通信。我们用@Service原型注释这个新类ChallengeEventPub,并使用构造函数注入来连接一个AmqpTemplate对象和交换的名称。完整的源代码见清单 7-5 。
package microservices.book.multiplication.challenge;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class ChallengeEventPub {
private final AmqpTemplate amqpTemplate;
private final String challengesTopicExchange;
public ChallengeEventPub(final AmqpTemplate amqpTemplate,
@Value("${amqp.exchange.attempts}")
final String challengesTopicExchange) {
this.amqpTemplate = amqpTemplate;
this.challengesTopicExchange = challengesTopicExchange;
}
public void challengeSolved(final ChallengeAttempt challengeAttempt) {
ChallengeSolvedEvent event = buildEvent(challengeAttempt);
// Routing Key is 'attempt.correct' or 'attempt.wrong'
String routingKey = "attempt." + (event.isCorrect() ?
"correct" : "wrong");
amqpTemplate.convertAndSend(challengesTopicExchange,
routingKey,
event);
}
private ChallengeSolvedEvent buildEvent(final ChallengeAttempt attempt) {
return new ChallengeSolvedEvent(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
}
}
Listing 7-5The ChallengeSolvedEvent’s Publisher
仅仅是一个定义 AMQP 标准的接口。底层实现是RabbitTemplate,它使用了我们之前配置的 JSON 转换器。我们计划在ChallengeServiceImpl类中从主挑战服务逻辑调用challengeSolved方法。该方法使用辅助方法buildEvent将域对象转换为事件对象,并使用amqpTemplate转换(到 JSON)和发送带有给定路由关键字的事件。这个是attempt.correct还是attempt.wrong取决于用户是否正确。
正如我们所看到的,由于提供了AmqpTemplate / RabbitTemplate和默认配置,使用 Spring 和 Spring Boot 向代理发布消息很简单,默认配置抽象了到代理的连接、消息转换、交换声明等。
我们代码中唯一缺少的部分是将质询逻辑与这个发布者的类连接起来。我们只需要用新的ChallengeEventPub替换我们在ChallengeServiceImpl中使用的注入的GamificationServiceClient服务,并使用新的方法调用。我们也可以重写注释来澄清我们不是在调用游戏化服务,而是为我们系统中任何可能感兴趣的组件发送一个事件。参见清单 7-6 。
@Slf4j
@RequiredArgsConstructor
@Service
public class ChallengeServiceImpl implements ChallengeService {
private final UserRepository userRepository;
private final ChallengeAttemptRepository attemptRepository;
private final ChallengeEventPub challengeEventPub; // replaced
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// ...
// Stores the attempt
ChallengeAttempt storedAttempt = attemptRepository.save(checkedAttempt);
// Publishes an event to notify potentially interested subscribers
challengeEventPub.challengeSolved(storedAttempt);
return storedAttempt;
}
// ...
}
Listing 7-6Modifying the ChallengeServiceImpl Class to Send the New Event
锻炼
修改现有的ChallengeServiceTest来验证它使用新的服务,而不是移除的 REST 客户端。
与其把ChallengeEventPubTest作为一个练习放在一边,不如把它写进书里,因为它提出了一个新的挑战。我们希望检查我们将要模拟的AmqpTemplate是否是用期望的路由键和事件对象调用的,但是我们不能从方法外部访问这些数据。让方法返回一个带有这些值的对象看起来像是让代码过多地适应我们的测试。在这种情况下,我们可以使用 Mockito 的ArgumentCaptor类(参见 https://tpd.io/argcap )来捕获传递给 mock 的参数,这样我们可以在以后断言这些值。
此外,由于我们在访问测试的旅程中做了短暂的休息,我们将介绍 JUnit 的另一个特性:参数化测试(参见 https://tpd.io/param-tests )。我们验证正确和错误尝试的测试用例是相似的,所以我们可以为这两种情况编写一个通用测试,并为断言使用一个参数。参见清单 7-7 中的ChallengeEventPubTest源代码。
package microservices.book.multiplication.challenge;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.amqp.core.AmqpTemplate;
import microservices.book.multiplication.user.User;
import static org.assertj.core.api.BDDAssertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ChallengeEventPubTest {
private ChallengeEventPub challengeEventPub;
@Mock
private AmqpTemplate amqpTemplate;
@BeforeEach
public void setUp() {
challengeEventPub = new ChallengeEventPub(amqpTemplate,
"test.topic");
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
public void sendsAttempt(boolean correct) {
// given
ChallengeAttempt attempt = createTestAttempt(correct);
// when
challengeEventPub.challengeSolved(attempt);
// then
var exchangeCaptor = ArgumentCaptor.forClass(String.class);
var routingKeyCaptor = ArgumentCaptor.forClass(String.class);
var eventCaptor = ArgumentCaptor.forClass(ChallengeSolvedEvent.class);
verify(amqpTemplate).convertAndSend(exchangeCaptor.capture(),
routingKeyCaptor.capture(), eventCaptor.capture());
then(exchangeCaptor.getValue()).isEqualTo("test.topic");
then(routingKeyCaptor.getValue()).isEqualTo("attempt." +
(correct ? "correct" : "wrong"));
then(eventCaptor.getValue()).isEqualTo(solvedEvent(correct));
}
private ChallengeAttempt createTestAttempt(boolean correct) {
return new ChallengeAttempt(1L, new User(10L, "john"), 30, 40,
correct ? 1200 : 1300, correct);
}
private ChallengeSolvedEvent solvedEvent(boolean correct) {
return new ChallengeSolvedEvent(1L, correct, 30, 40, 10L, "john");
}
}
Listing 7-7A Parameterized Test to Check
Behavior for Correct and Wrong Attempts
作为订阅者的游戏化
现在我们已经完成了发布者的代码,我们转到订阅者的代码:游戏化微服务。简而言之,我们需要替换现有的接受事件订阅者尝试的控制器。这意味着创建一个 AMQP 队列,并将其绑定到我们之前在乘法微服务中声明的主题交换。
首先,让我们填写配置设置。我们在这里还删除了显示查询的属性,并为 RabbitMQ 添加了额外的日志记录。然后,我们设置新队列和交换的名称,它与我们添加到先前服务中的值相匹配。参见清单 7-8 。
# ... all properties above remain untouched
amqp.exchange.attempts=attempts.topic
amqp.queue.gamification=gamification.queue
# Shows declaration of exchanges, queues, bindings, etc.
logging.level.org.springframework.amqp.rabbit.core.RabbitAdmin = DEBUG
Listing 7-8Defining Queue and Exchange Names in Gamification
为了声明新队列和绑定,我们还将使用一个名为AMQPConfiguration的配置类。请记住,我们还应该在消费者一方申报交换。尽管订户在概念上并不拥有交换,但我们希望我们的微服务能够以任何给定的顺序启动。如果我们没有在游戏化微服务上声明交换,并且经纪人的实体还没有初始化,我们就被迫在之前启动乘法微服务。当我们声明队列时,交换必须在那里。自从我们使交换持久化以来,这只是第一次适用,但在代码中声明微服务需要的所有交换和队列是一个好的做法,因此它不依赖于任何其他交换和队列。注意,RabbitMQ 实体的声明是一个幂等运算;如果实体在那里,操作没有任何效果。
我们还需要在消费者端进行一些配置,以使用 JSON 反序列化消息,而不是默认的消息转换器提供的格式。让我们看看清单 7-9 中配置类的完整源代码,稍后我们将详细介绍一些部分。
package microservices.book.gamification.configuration;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListenerConfigurer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.handler.annotation.support.DefaultMessageHandlerMethodFactory;
import org.springframework.messaging.handler.annotation.support.MessageHandlerMethodFactory;
@Configuration
public class AMQPConfiguration {
@Bean
public TopicExchange challengesTopicExchange(
@Value("${amqp.exchange.attempts}") final String exchangeName) {
return ExchangeBuilder.topicExchange(exchangeName).durable(true).build();
}
@Bean
public Queue gamificationQueue(
@Value("${amqp.queue.gamification}") final String queueName) {
return QueueBuilder.durable(queueName).build();
}
@Bean
public Binding correctAttemptsBinding(final Queue gamificationQueue,
final TopicExchange attemptsExchange) {
return BindingBuilder.bind(gamificationQueue)
.to(attemptsExchange)
.with("attempt.correct");
}
@Bean
public MessageHandlerMethodFactory messageHandlerMethodFactory() {
DefaultMessageHandlerMethodFactory factory = new DefaultMessageHandlerMethodFactory();
final MappingJackson2MessageConverter jsonConverter =
new MappingJackson2MessageConverter();
jsonConverter.getObjectMapper().registerModule(
new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
factory.setMessageConverter(jsonConverter);
return factory;
}
@Bean
public RabbitListenerConfigurer rabbitListenerConfigurer(
final MessageHandlerMethodFactory messageHandlerMethodFactory) {
return (c) -> c.setMessageHandlerMethodFactory(messageHandlerMethodFactory);
}
}
Listing 7-9The AMQP Configuration for the Gamification Microservice
交换、队列和绑定的声明对于所提供的构建器来说很简单。我们声明一个持久队列,使其在代理重启后仍然存在,其名称来自配置值。Bean 对Binding的声明方法使用了 Spring 注入的另外两个 Bean,并将它们与值attempt.correct链接起来。如前所述,我们只对处理分数和徽章的正确尝试感兴趣。
接下来,我们设置了一个MessageHandlerMethodFactory bean 来替换默认 bean。我们实际上使用默认工厂作为基线,但是用一个MappingJackson2MessageConverter实例替换它的消息转换器,这个实例处理从 JSON 到 Java 类的消息反序列化。我们微调了它包含的ObjectMapper,并添加了ParameterNamesModule,以避免必须为我们的事件类使用空构造函数。注意,当通过 REST APIs 传递 d to 时(我们之前的实现),我们不需要这样做,因为 Spring Boot 在 web 层自动配置中配置这个模块。但是,它不会为 RabbitMQ 这样做,因为 JSON 不是默认选项;因此,我们需要明确地配置它。
这一次,我们不会使用AmqpTemplate来接收消息,因为它是基于轮询的,这会不必要地消耗网络资源。相反,我们希望代理在有消息时通知订阅者,所以我们选择异步选项。AMQP 抽象不支持这一点,但是spring-rabbit组件提供了两种异步使用消息的机制。最简单、最流行的是@RabbitListener注释,我们将使用它从队列中获取事件。为了配置监听器使用 JSON 反序列化,我们必须用一个使用我们的自定义MessageHandlerMethodFactory的实现来覆盖 bean RabbitListenerConfigurer。
我们的下一个任务是将ChallengeSolvedDTO重命名为ChallengeSolvedEvent。参见清单 7-10 。从技术上讲,不需要使用相同的类名,因为 JSON 格式只指定了字段名和值。然而,这是一个很好的实践,因为这样你就可以很容易地在你的项目中找到相关的事件类。
package microservices.book.gamification.challenge;
import lombok.Value;
@Value
public class ChallengeSolvedEvent {
long attemptId;
boolean correct;
int factorA;
int factorB;
long userId;
String userAlias;
}
Listing 7-10Renaming ChallengeSolvedDTO as ChallengeSolvedEvent in Gamification
遵循域驱动的设计实践,我们可以调整该事件的反序列化字段。例如,对于游戏化的业务逻辑,我们不需要userAlias,所以我们可以将它从消费的事件中移除。由于 Spring Boot 默认配置了ObjectMapper来忽略未知属性,这种策略不需要配置任何其他东西就可以工作。不在微服务之间共享这个类的代码是一个好的实践,因为它还允许松散耦合、向后兼容和独立部署。想象一下,乘法微服务将发展并存储额外的数据,例如,更难挑战的第三个因素。这个额外的因素将被添加到发布事件的代码中。好消息是,通过对每个域使用不同的事件表示,并将映射器配置为忽略未知属性,游戏化微服务在这种变化后仍将工作,而无需更新其事件表示。
现在让我们编写事件消费者的代码。如前所述,我们将为此使用@RabbitListener注释。我们可以将这个注释添加到方法中,使其在消息到达时充当消息的处理逻辑。在我们的例子中,我们只需要指定要订阅的队列名,因为我们已经在一个单独的配置文件中声明了所有的 RabbitMQ 实体。可以选择在这个注释中嵌入这些声明,但是代码看起来不再那么整洁了(如果你好奇的话,请看 https://tpd.io/rmq-listener )。
检查清单 7-11 中消费者的来源,然后我们将涵盖最相关的部分。
package microservices.book.gamification.game;
import org.springframework.amqp.AmqpRejectAndDontRequeueException;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import microservices.book.gamification.challenge.ChallengeSolvedEvent;
@RequiredArgsConstructor
@Slf4j
@Service
public class GameEventHandler {
private final GameService gameService;
@RabbitListener(queues = "${amqp.queue.gamification}")
void handleMultiplicationSolved(final ChallengeSolvedEvent event) {
log.info("Challenge Solved Event received: {}", event.getAttemptId());
try {
gameService.newAttemptForUser(event);
} catch (final Exception e) {
log.error("Error when trying to process ChallengeSolvedEvent", e);
// Avoids the event to be re-queued and reprocessed.
throw new AmqpRejectAndDontRequeueException(e);
}
}
}
Listing 7-11The RabbitMQ Consumer’s Logic
如您所见,实现 RabbitMQ 订阅者所需的代码量很少。我们可以使用 configuration 属性将队列名称传递给RabbitListener注释。Spring 处理这个方法并分析参数。假设我们指定了一个ChallengeSolvedEvent类作为预期的输入,Spring 会自动配置一个反序列化器,将来自代理的消息转换成这个对象类型。它将使用 JSON,因为我们在AMQPConfiguration类中覆盖了默认的RabbitListenerConfigurer。
从消费者的代码中,你也可以推断出我们的错误处理策略是什么。默认情况下,Spring 基于RabbitListener注释构建的逻辑将在方法无异常完成时向代理发送确认。在 Spring Rabbit 中,这被称为AUTO确认模式。如果我们想在处理 ACK 信号之前就发送它,我们可以把它改成NONE,或者如果我们想完全控制它,我们可以把它改成MANUAL(然后我们必须注入一个额外的参数来发送这个信号)。我们可以在工厂级别(全局配置)或监听器级别(通过向RabbitListener注释传递额外的参数)设置这个参数和其他配置值。这里我们的错误策略是使用默认值AUTO,但是捕捉任何可能的异常,记录错误,然后重新抛出一个AmqpRejectAndDontRequeueException。这是 Spring AMQP 提供的一个快捷方式,用于拒绝消息并告诉代理不要重新排队。这意味着,如果游戏化的消费者逻辑出现意外错误,我们将会丢失信息。这在我们的情况下是可以接受的。如果我们想要避免这种情况,我们也可以通过重新抛出一个含义相反的异常ImmediateRequeueAmqpException来设置我们的代码重试几次,或者使用 Spring AMQP 中可用的一些工具,如错误处理程序或消息恢复器来处理这些失败的消息。更多详细信息,请参见 Spring AMQP 文档中的异常处理部分( https://tpd.io/spring-amqp-exc )。
我们可以用RabbitListener注释做很多事情。以下是一些包含的功能:
-
声明交换、队列和绑定。
-
用相同的方法从多个队列接收消息。
-
通过用
@Header(对于单个值)或@Headers(对于映射)注释额外的参数来处理消息头。 -
例如,注入一个
Channel参数,这样我们就可以控制确认。 -
通过从侦听器返回值来实现请求-响应模式。
-
将注释移动到类级别,并对方法使用
@RabbitHandler。这种方法允许我们配置多种方法来处理来自同一个队列的不同消息类型。
有关这些用例的详细信息,请查看 Spring AMQP 文档( https://tpd.io/samqp-docs )。
锻炼
为新的GameEventHandler类创建一个测试。检查服务是否被调用,以及其逻辑中的异常是否会导致再次引发预期的 AMQP 异常。该解决方案包含在为本章提供的源代码中。
现在我们有了订户的逻辑,我们可以安全地移除GameController类。然后,我们重构现有的GameService接口及其实现GameServiceImpl,以接受重命名后的ChallengeSolvedEvent。其余的逻辑可以保持不变。参见清单 7-12 中的结果newAttemptForUser方法。
@Override
public GameResult newAttemptForUser(final ChallengeSolvedEvent challenge) {
// We give points only if it's correct
if (challenge.isCorrect()) {
ScoreCard scoreCard = new ScoreCard(challenge.getUserId(),
challenge.getAttemptId());
scoreRepository.save(scoreCard);
log.info("User {} scored {} points for attempt id {}",
challenge.getUserAlias(), scoreCard.getScore(),
challenge.getAttemptId());
List<BadgeCard> badgeCards = processForBadges(challenge);
return new GameResult(scoreCard.getScore(),
badgeCards.stream().map(BadgeCard::getBadgeType)
.collect(Collectors.toList()));
} else {
log.info("Attempt id {} is not correct. " +
"User {} does not get score.",
challenge.getAttemptId(),
challenge.getUserAlias());
return new GameResult(0, List.of());
}
}
Listing 7-12The Updated Newattemptforuser Method Using the Event Class
我们可以取消对正确尝试的检查,但这样我们会过于依赖乘法微服务上的正确路由。如果我们保留它,每个人都更容易阅读代码并知道它做什么,而不必弄清楚有一个基于路由键的过滤逻辑。我们可以从代理的路由中受益,但是请记住,我们不想在通道中嵌入太多的行为。
随着这些变化,我们完成了在微服务中切换到事件驱动架构所需的修改。请记住,更名为ChallengeSolvedEvent的 DTO 会影响更多的职业。我们省略了它们,因为您的 IDE 应该会自动处理这些更改。让我们再次回顾一下我们对系统所做的更改列表:
-
我们将新的 AMQP 启动器依赖项添加到我们的 Spring Boot 应用中,以使用 AMQP 和 RabbitMQ。
-
我们移除了 REST API 客户端(乘法中)和控制器(游戏化中),因为我们使用 RabbitMQ 切换到了事件驱动的架构。
-
我们把
ChallengeSolvedDTO改名为ChallengeSolvedEvent。重命名导致了其他类和测试的修改,但是这些修改是不相关的。 -
我们在两个微服务中都声明了新的话题交换。
-
我们改变了乘法微服务的逻辑,发布一个事件,而不是调用 REST API。
-
我们在游戏化微服务上定义了新队列。
-
我们在游戏化微服务中实现了 RabbitMQ 消费者逻辑。
-
我们相应地重构了测试,以使它们适应新的界面。
请记住,您可以在本书的在线资源库中找到本章中显示的所有代码。
情景分析
让我们用新的事件驱动系统尝试几个不同的场景。我们的目标是证明通过消息代理引入新的架构设计带来了真正的优势。
概括地说,请参见图 7-13 了解我们系统的当前状态。
图 7-13
逻辑视图
本节中的所有场景都要求我们按照以下步骤启动整个系统:
-
请确保 RabbitMQ 服务正在运行。否则,启动它。
-
运行两个微服务应用:乘法和游戏化。
-
运行 React 的用户界面。
-
从浏览器进入 RabbitMQ 管理 UI 的
http://localhost:15672/,使用guest/guest登录。
快乐之花
我们还没有看到我们的系统与新的消息代理一起工作。这是我们要尝试的第一件事。在此之前,我们先查看一下游戏化微服务的日志。您应该会看到一些新的日志行,如清单 7-13 所示。
INFO 11686 --- [main] o.s.a.r.c.CachingConnectionFactory: Attempting to connect to: [localhost:5672]
INFO 11686 --- [main] o.s.a.r.c.CachingConnectionFactory: Created new connection: rabbitConnectionFactory#7c7e73c5:0/SimpleConnection@2bf2d6eb [delegate=amqp://guest@127.0.0.1:5672/, localPort= 63651]
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : Initializing declarations
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : declaring Exchange 'attempts.topic'
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : declaring Queue 'gamification.queue'
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : Binding destination [gamification.queue (QUEUE)] to exchange [attempts.topic] with routing key [attempt.correct]
DEBUG 11686 --- [main] o.s.amqp.rabbit.core.RabbitAdmin : Declarations finished
Listing 7-13Spring Boot Application Logs Showing the Initialization for Rabbitmq
当我们使用 Spring AMQP 时,通常会记录前两行。它们表明与代理的连接是成功的。如前所述,我们不需要添加任何连接属性,如主机或凭证,因为我们使用的是默认值。因为我们将RabbitAdmin类的日志级别更改为DEBUG,所以剩下的日志行都在这里。这些是不言自明的,包括我们创建的交换、队列和绑定的值。
在乘法方面,还没有 RabbitMQ 日志。原因是只有当我们发布第一条消息时,连接和交换声明才会发生。这意味着话题交流是由游戏化微服务先声明的。还好我们准备了代码,不介意引导顺序。
我们现在可以看看 RabbitMQ UI,看看当前的状态。在“连接”选项卡上,我们将看到一个由游戏化微服务创建的连接。参见图 7-14 。
图 7-14
RabbitMQ UI:单一连接
如果我们切换到交换选项卡,我们将看到类型为topic的attempts.topic交换,并被声明为持久的 (D)。见图 7-15 。
图 7-15
rabbitmq ui:交换列表
现在,单击 exchange 名称会将我们带到详细页面,在这里我们甚至可以看到一个显示绑定队列和相应绑定键的基本图形。参见图 7-16 。
图 7-16
rabbitq ui:exchange 详细信息
Queues 选项卡显示最近创建的队列及其名称,也配置为 durable。参见图 7-17 。
图 7-17
rabbitmq ui:伫列清单
在我们看了所有东西是如何被初始化的之后,让我们导航到我们的 UI 并发送一些正确和不正确的尝试。如果您愿意,您可以稍微欺骗一下,至少运行这个命令十次,这将产生十次正确的尝试。
$ http POST :8080/attempts factorA=15 factorB=20 userAlias=test1 guess=300
在倍增日志中,我们现在应该看到它是如何连接到代理并声明交换的(由于它已经在那里了,所以没有任何效果)。
游戏化应用的日志应该反映事件的消耗和相应的更新分数。参见清单 7-14 。
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 50
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test1 scored 10 points for attempt id 50
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 51
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test1 scored 10 points for attempt id 51
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 52
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test1 scored 10 points for attempt id 52
INFO 11686 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 53
...
Listing 7-14Logs of the Gamification Microservice After Receiving New Events
RabbitMQ 管理器中的 Connections 选项卡此时显示来自两个应用的连接。参见图 7-18 。
图 7-18
RabbitMQ UI:两个连接
此外,如果我们转到 Queues 选项卡并单击队列名称,我们可以看到代理中发生的一些活动。您可以在 Overview 面板上将过滤器更改为最后 10 分钟,以确保捕获所有事件。参见图 7-19 。
图 7-19
rabbitmq ui:队列详细信息
太棒了。我们的系统与消息代理完美配合。正确的尝试被路由到游戏化应用所声明的队列。该微服务也订阅该队列,因此它获取发布到交换的事件,并处理它们以分配新的分数和徽章。之后,正如在新的变化之前已经发生的那样,我们的 UI 将在下一次请求游戏化的 REST 端点检索排行榜时获得更新的统计数据。参见图 7-20
图 7-20
UI:使用消息代理的应用
游戏化变得不可用
我们的系统之前的实现是有弹性的,因为我们在上一章之后离开了它,如果游戏化微服务不可用,它也不会失败。但是,在这种情况下,我们会错过事件期间发送的所有尝试。让我们看看引入消息代理后会发生什么。
首先,确保你停止了游戏化微服务。然后,我们可以使用 UI 或命令行技巧再发送十次尝试。让我们使用别名test-g-down:
$ http POST :8080/attempts factorA=15 factorB=20 userAlias=test-g-down guess=300
RabbitMQ UI 中的 Queue detail 视图现在显示十个排队的消息。这个数字不会像以前一样归零。这是因为队列仍然在那里,但是没有消费者将这些消息分派到那里。见图 7-21
图 7-21
rabbitmq ui:消息已清除
我们还可以检查乘法微服务的日志,验证没有错误。它向代理发布消息,并向 API 客户机返回 OK 响应。我们实现了松散耦合。乘法 app 不需要知道消费者是否有空。现在整个过程是异步的,由事件驱动的。
当我们再次恢复游戏化服务时,我们将在日志中看到它是如何在启动后立即接收到来自代理的所有事件消息的。然后,这个服务只是触发它的逻辑,分数就相应更新了。这次我们没有遗漏任何数据。清单 7-15 显示了您再次启动游戏后游戏化日志的摘录。
INFO 24808 --- [ main] m.b.g.GamificationApplication : Started GamificationApplication in 3.446 seconds (JVM running for 3.989)
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-g-down scored 10 points for attempt id 61
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 62
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-g-down scored 10 points for attempt id 62
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 63
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-g-down scored 10 points for attempt id 63
INFO 24808 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 64
...
Listing 7-15The Application Consumes the Pending Events After Becoming Available Again
您还可以验证排行榜如何再次显示用户test-g-down的更新分数。我们使我们的系统不仅具有弹性,而且能够在出现故障后恢复。RabbitMQ 接口中的队列细节也显示了排队消息的零计数器,因为它们都已经被使用了。
可以想象,RabbitMQ 允许我们配置消息在丢弃之前可以在队列中保留多长时间(生存时间,TTL)。如果愿意,我们还可以为队列配置最大长度。默认情况下,没有设置这些参数,但是我们可以为每个消息(在发布时)或者在声明队列时启用它们。参见清单 7-16 中的示例,了解我们如何配置我们的队列,使其具有 6 小时的自定义 TTL 和 25000 条消息的最大长度。这只是一个例子,说明您熟悉代理的配置是多么重要,因此您可以根据自己的需要进行调整。
@Bean
public Queue gamificationQueue(
@Value("${amqp.queue.gamification}") final String queueName) {
return QueueBuilder.durable(queueName)
.ttl((int) Duration.ofHours(6).toMillis())
.maxLength(25000)
.build();
}
Listing 7-16An Example Queue Configuration Showing Some Extra Parameter Options
消息代理不可用
让我们更进一步,在队列中有消息等待交付时关闭代理。为了测试这个场景,我们应该遵循以下步骤:
-
停止游戏化微服务。
-
使用用户别名
test-rmq-down发送几次正确的尝试,并在 RabbitMQ UI 中验证队列正在保存这些消息。 -
停止 RabbitMQ 代理。
-
发送一次额外的正确尝试。
-
启动游戏化微服务。
-
大约十秒钟后,再次启动 RabbitMQ 代理。
这个手动测试的结果是,只有我们在代理关闭时发送的尝试没有被处理。实际上,我们将从服务器得到一个 HTTP 错误响应,因为我们没有在发布者内部以及位于ChallengeServiceImpl的主服务逻辑中捕捉到任何潜在的异常。我们可以添加一个 try/catch 子句,这样我们仍然能够做出响应。然后,策略将是无声地抑制错误。一个可能更好的方法是实现一个定制的 HTTP 错误处理程序来返回一个特定的错误响应,比如503 SERVICE UNAVAILABLE,以表明当我们失去与代理的连接时,系统不可操作。如你所见,我们有多种选择。在一个真实的组织中,最好的方法是讨论这些备选方案,并选择一个更适合您的非功能性需求的方案,如可用性(我们希望挑战特性尽可能长时间可用)或数据完整性(我们希望每次发送尝试都有一个分数)。
我们测试的第二个观察结果是,当代理不可用时,两个微服务都不会崩溃。而是游戏化微服务每隔几秒就不断重试连接,乘法微服务在新的尝试请求到来时也是如此。当我们再次启动代理时,两个微服务都恢复了连接。这是 Spring AMQP 项目中包含的一个很好的特性,可以在连接不可用时尝试恢复连接。
如果您执行这些步骤,您还会看到即使在代理重新启动后,还有未完成的消息要发送时,使用者如何获得消息。游戏化微服务重新连接到 RabbitMQ,这个服务发送排队的事件。这不仅是因为我们声明了持久交换和队列,还因为 Spring 实现在发布所有消息时使用了持久交付模式。如果我们使用RabbitTemplate(而不是AmqpTemplate)来发布消息,这是我们也可以自己设置的消息属性之一。请参见清单 7-17 中的示例,了解我们如何更改交付模式,以使我们的消息在代理重启后无法存活。
MessageProperties properties = MessagePropertiesBuilder.newInstance()
.setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT)
.build();
rabbitTemplate.getMessageConverter().toMessage(challengeAttempt, properties);
rabbitTemplate.convertAndSend(challengesTopicExchange,
routingKey,
event);
Listing 7-17Example of How to Change the Delivery Mode to Nonpersistent
这个例子也说明了为什么知道我们使用的工具的配置选项是重要的。将所有消息作为持久消息发送给我们带来了一个很好的优势,但是它在性能上有额外的代价。如果我们将 RabbitMQ 实例的集群配置为适当分布,那么整个集群宕机的可能性将会很小,因此我们可能更愿意接受潜在的消息丢失以提高性能。还是那句话,这个要看你的要求;例如,错过一些分数与错过网上商店的订单是不同的。
交易性
我们之前的测试暴露了一个不希望的情况,但是很难发现它。当我们在代理关闭时发送尝试时,我们得到一个带有 500 错误代码的服务器错误。这给 API 客户端留下了尝试没有被正确处理的印象。但是,它被部分处理了。
让我们再次测试这一部分,但是,这一次,我们将检查数据库条目。我们只需要乘法微服务运行,代理停止。然后,我们用一个用户别名test-tx发送一个尝试,以再次获得错误响应。参见清单 7-18 。
$ http POST :8080/attempts factorA=15 factorB=20 userAlias=test-tx guess=300
HTTP/1.1 500
[...]
{
"error": "Internal Server Error",
"message": "",
"path": "/attempts",
"status": 500,
"timestamp": "2020-05-24T10:48:37.861+00:00"
}
Listing 7-18Error Response When the Broker Is Unreachable
现在,我们在http://localhost:8080/h2-console导航到乘法数据库的 H2 控制台。确保您使用 URL jdbc:h2:file:~/multiplication进行连接。然后,我们运行这个查询,从用户别名为test-tx的两个表中获取所有数据:
SELECT * FROM USER u, CHALLENGE_ATTEMPT a WHERE u.ALIAS = 'test-tx' AND u.ID = a.USER_ID
查询给我们一个结果,如图 7-22 所示。这意味着即使我们得到了一个错误响应,尝试还是被存储了。这是一种不好的做法,因为 API 客户端不知道质询的结果,所以它不能显示正确的消息。然而,挑战被挽救了。然而,如果我们的代码在试图将消息发送给代理之前持久化该对象,这就是预期的结果。
图 7-22
H2 控制台:尽管出现故障,记录仍被存储
相反,我们可以将服务方法verifyAttempt中包含的整个逻辑视为一个事务。数据库事务可以回滚(不执行)。如果我们在调用存储库中的save方法后仍然得到一个错误,这就是我们想要的。使用 Spring 框架很容易做到这一点,因为我们只需要在代码中添加一个 Java 事务 API (JTA)注释javax.transaction.Transactional。见清单 7-19 。
@Transactional
@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
// ...
}
Listing 7-19Adding the @Transactional Annotation to the Service Logic in Multiplication
如果用@Transactional注释的方法中有异常,事务将被回滚。如果我们需要给定服务中的所有方法都是事务性的,我们可以在类级别添加这个注释。
应用此更改后,您可以再次尝试相同的场景步骤。构建并重启乘法微服务,并在代理关闭时发送新的尝试,这一次使用不同的别名。如果您运行相应的查询来查看尝试是否被存储,您会发现这次没有。由于抛出了异常,Spring 回滚了数据库操作,所以它永远不会被执行。
Spring 还支持 RabbitMQ 事务,包括发布者端和订阅者端。当我们在用@Transactional注释的方法范围内用 AmqpTemplate 或它的 RabbitTemplate 实现发送消息时,并且当我们在通道(RabbitMQ)中启用事务性时,即使在发送消息的方法调用之后发生了异常,这些消息也不会到达代理。在消费者方面,也可以使用事务来拒绝已经处理过的消息。在这种情况下,需要设置队列来重新排队被拒绝的消息(这是默认行为)。Spring AMQP 文档中的事务部分详细解释了它们是如何工作的;https://tpd.io/rmq-tx见。
在许多像我们这样的情况下,我们可以简化事务的策略,并将其仅限于数据库。
-
在发布时,如果我们只有一个代理操作,我们可以在流程结束时发布消息。在发送消息之前或发送消息时发生的任何错误都将导致数据库操作回滚。
-
在订户端,如果有异常,消息将被默认拒绝,如果我们需要,我们可以重新排队。然后,我们还可以在我们的
newAttemptForUser的服务方法中使用Transactional注释,这样数据库操作也会在出现故障时回滚。
微服务内的本地事务性对于保持数据一致性和避免域内部分完成的流程至关重要。因此,当需要多个步骤与外部组件(如数据库或消息代理)进行交互时,您应该考虑到业务逻辑中可能出错的地方。
锻炼
将@Transactional注释添加到GameServiceImpl服务中,这样要么同时存储记分卡和徽章,要么在出现问题时什么都不存储。我们已经决定如果不能处理消息就丢弃它们,所以我们不需要消息代理操作的事务性。
扩展微服务
到目前为止,我们一直在运行每个微服务的单个实例。正如我们在本书前面所描述的,微服务架构的主要优势之一是我们可以独立地扩展系统的各个部分。我们还将这一特性列为使用消息代理引入事件驱动方法的好处:我们可以透明地添加发布者和订阅者的更多实例。然而,我们还不能宣称我们的架构支持添加每个微服务的更多副本。
让我们关注一下为什么我们的应用不能处理多个实例的第一个原因:数据库。当我们横向扩展微服务时,所有副本应该共享数据层,并将数据存储在一个公共位置,而不是每个实例都是独立的。我们说微服务一定是无状态的。原因是不同的请求或消息可能在不同的微服务实例中结束。例如,我们不应该假设来自同一个用户的两次尝试将由同一个乘法实例来处理,因此我们不能在两次尝试之间保持任何内存状态。见图 7-23
图 7-23
向上扩展:界面问题
好消息是,我们的微服务已经是无状态的,我们独立处理每个请求或消息,结果最终保存在数据库中。然而,我们有一个技术问题。如果我们在端口 9080 上启动第二个乘法实例,它将无法启动,因为它试图创建一个新的数据库实例。这不是我们想要的,因为它应该连接到跨副本共享的公共数据库服务器。让我们重现这个错误。首先,照常运行乘法微服务(我们的第一个实例)。
要在本地启动给定服务的第二个实例,我们只需要覆盖server.port参数,这样可以避免端口冲突。您可以从您的 IDE 或使用乘法微服务目录中的命令行来完成此操作。
$ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
当您启动第二个复制副本时,日志会提示以下错误:
[...] Database may be already in use: null. Possible solutions: close all other connection(s); use the server mode [90020-200]
发生这个错误是因为我们使用了 H2 数据库引擎,默认情况下,它被设计为嵌入式进程,而不是服务器。无论如何,H2 支持服务器模式,正如错误消息所暗示的。我们唯一需要做的事情是向我们用来从微服务连接到数据库的两个 URL 添加一个参数。然后,引擎第一次启动时,它将允许其他实例使用同一个文件,从而使用同一个数据库。请记住将这一更改应用于乘法和游戏化微服务。见清单 7-20 。
# ... other properties
# Creates the database in a file (adding the server mode)
spring.datasource.url=jdbc:h2:file:~/multiplication;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=true;
Listing 7-20Enabling the Server Mode in H2 to Connect from Multiple Instances
现在,我们可以启动每个微服务的多个实例,它们将跨副本共享相同的数据层。第一个问题解决了。
我们面临的第二个挑战是负载平衡。如果我们启动每个应用的两个实例,我们如何从用户界面连接到它们?同样的问题也适用于我们在前一章结束时在两个微服务之间的 REST API 调用:我们将调用哪个游戏化实例来发送尝试?如果我们想在副本之间平衡系统的 HTTP 流量,我们需要别的东西。在下一章,我们将详细讨论 HTTP 负载平衡。
现在,让我们关注消息代理如何帮助我们实现 RabbitMQ 消息订阅者之间的负载平衡。见图 7-24
图 7-24
向上扩展:界面问题
图 7-24 中有四个编号接口。正如我们所说的,我们将在下一章看到如何实现 HTTP 负载平衡器模式,所以让我们来看看接口 3 和 4 如何处理多个副本。
像 RabbitMQ 这样的消息代理支持来自多个来源的消息发布。这意味着我们可以在同一个主题交换中发布多个倍增微服务事件。这是透明的:这些实例打开不同的连接,声明交换(只在第一次创建),发布数据,而不需要知道还有其他发布者。在订户端,我们已经了解了 RabbitMQ 队列如何被多个消费者共享。当我们启动游戏化微服务的多个实例时,所有实例都声明相同的队列和绑定,代理足够聪明,可以在它们之间进行负载平衡。
因此,我们在消息级别解决了负载平衡问题。原来我们什么都不需要做。现在,让我们看看这在实践中是如何工作的。
按照前面场景中相同的步骤启动每个微服务、UI 和 RabbitMQ 服务的一个实例。然后,在两个单独的终端中运行清单 7-21 中的命令,进行与上图 7-23 所示相同的设置,每个微服务有两个副本。请记住,您需要从每个相应的微服务的主文件夹中执行它们。
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
[... logs ...]
gamification $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9081"
[... logs ...]
Listing 7-21Starting a Second Instance of Each Microservice
一旦我们启动并运行了所有实例,在 UI 中使用相同的新别名输入四次正确的尝试。请注意,这些尝试只会击中我们的乘法微服务的第一个实例,但是事件消耗在两个游戏化副本之间是平衡的。检查日志以验证每个应用应该如何处理两个事件。此外,由于数据库是跨实例共享的,所以 UI 从运行在端口 8081 的实例请求排行榜并不重要。此实例将聚合所有副本存储的所有记分卡和徽章。参见图 7-25
图 7-25
向上扩展:第一次测试
如图 7-25 所示,我们还可以验证多个发布者协同工作使用命令行向乘法微服务的第二个实例发送正确的尝试。让我们向位于端口 9080 的实例发送几个调用,并检查它们是如何被处理的。正如所料,在这种情况下,消息在订阅者之间也是平衡的。参见清单 7-22 中调用第二个实例的例子。
$ http POST :9080/attempts factorA=15 factorB=20 userAlias=test-multi-pub guess=300
Listing 7-22Sending a Correct Attempt to the Second Instance of Multiplication
这是一个伟大的成就。我们演示了 message broker 如何帮助实现良好的系统可伸缩性,并实现了一个 worker 队列模式,其中多个订阅者实例在它们之间分担负载。
因此,我们也提高了弹性。在上一节“游戏化变得不可用”中,我们停止了游戏化实例,并看到当它再次变得活跃时,它将如何赶上未决事件。随着多个实例的引入,如果其中一个实例不可用,代理将自动将所有消息定向到其他实例。你可以通过现在停止游戏化的第一个实例(运行在端口 8081 上)来尝试。然后,发送两次正确的尝试,并在日志中检查第二个实例是如何成功处理它们的。通过这个测试,您还可以验证这种弹性的改进目前仅限于事件消费者接口。UI 无法平衡负载或检测到一个副本关闭。因此,UI 不会显示排行榜,因为浏览器正在尝试访问第一个游戏化实例。我们将在下一章解决这些问题。
总结和成就
本章介绍了一个通常与微服务架构相关的重要概念:事件驱动的软件模式。为了给你一个完整的背景,我们首先关注我们可以用来实现它的最流行的工具之一:消息代理。
我们了解了消息代理如何帮助我们实现微服务之间的松散耦合,就像过去几年类似的模式帮助其他面向服务的架构一样。事件模式通过对一种不指向任何特定目标的消息类型进行建模,向松散耦合迈进了一步,因为它只表示特定领域中发生的事实。然后,不同的消费者可以订阅这些事件流并对其做出反应,可能触发他们自己的业务逻辑,这可能会产生其他事件,等等。我们看到了如何将事件驱动的策略与发布-订阅和工作队列模式相结合,从而在域之间形成清晰的界限,并提高系统的可伸缩性。
RabbitMQ 及其 AMQP 实现提供了一些我们用来构建新架构的工具:发布事件消息的交换、订阅事件消息的队列以及用可选过滤器链接事件消息的绑定。我们不仅学习了关于这些消息传递实体的核心概念,还学习了关于消息确认、消息拒绝和持久性的一些配置选项。请记住,您可能需要微调 RabbitMQ 配置,以适应您的功能性和非功能性需求。
多亏了 Spring Boot 抽象,本章的编码部分仍然很简单。我们通过 Spring AMQP 将 RabbitMQ 集成到我们的 Spring Boot 应用中。我们将代理实体声明为 beans,利用AmqpTemplate来发布消息,并使用@RabbitListener注释来消费它们。乘法微服务已经不知道游戏化微服务了;它只是在尝试被处理时发布一个事件。我们最终实现了与新的事件驱动软件架构的松散耦合。
本章的相关部分是最后一节,我们通过不同的场景展示了我们实现的模式确实帮助我们提高了弹性和可伸缩性,前提是我们在构建代码时考虑了这些非功能性需求。
关于本章中的概念的好消息是,一旦你掌握了它们,你就可以将它们应用到使用不同技术的其他系统中。例如,基于 Scala 和 Kafka 的事件驱动软件架构面临着同样的挑战,并且通常需要类似的模式:同一个 Kafka 主题的多个订阅者,消费者之间的负载平衡(使用消费者组),配置交付保证,比如最少一次和最多一次,等等。请记住,使用不同的工具,您可能会得到不同的利弊。
在这个阶段,我希望您已经观察到构建一个好的软件架构的重要部分是理解设计模式以及它们如何与功能和非功能需求相关联。只有在您了解这些模式之后,您才能分析实现它们的工具和框架,比较它们提供的特征。
有时,我们可能希望构建一个事件驱动的架构,因为我们认为这是最好的技术解决方案,但它可能不是我们业务需求的最佳模式。我们应该避免这些情况,因为软件将倾向于发展以适应真实的商业案例,这可能会导致许多问题。我见过微服务架构被它们之间的同步调用所困扰,要么是因为需求没有适应最终的一致性,要么是因为根据功能需求,这甚至是不可能的。在跳入技术解决方案之前,花足够的时间分析您想要解决的问题,并且对承诺解决所有可能需求的新架构模式持怀疑态度。
当我们开发我们的系统时,我们遇到了一些我们还不能解决的新挑战。我们需要带有不可用实例检测的 HTTP 负载平衡。此外,我们的 UI 直接指向每个微服务,因此它知道后端结构。然后,我们也感觉到如何管理我们的系统在诸如启动它或在多个地方检查日志等方面变得越来越困难。微服务架构的复杂性开始变得更加明显。在下一章,我们将介绍一些帮助我们处理这种复杂性的模式和工具。
章节成就:
-
您学习了事件驱动架构的核心概念。为此,您已经对消息代理如何工作有了很好的了解。
-
您已经了解了事件驱动架构的优缺点,知道在未来的项目中什么时候应用这种模式是有意义的。
-
您了解了如何根据您的用例实现不同的消息传递模式。
-
在我们的实际案例中,您使用 RabbitMQ 消息代理应用了所有学到的概念。
-
您了解了 Spring Boot 如何抽象出 RabbitMQ 的许多功能,允许您只添加一些代码就可以做很多事情。
-
您重构了一个紧耦合的系统,并将其转换成一个合适的事件驱动架构。
-
您使用了这个应用来理解弹性是如何工作的,如何扩展您的消费者,以及如何处理事务性。
八、微服务架构中的常见模式
当然,您已经意识到,在前两章中,我们是如何从满足功能需求的解决方案过渡到不增加任何业务功能的模式实现的。然而,它们对于我们的系统更具可伸缩性、更具弹性或具有更好的性能是必不可少的。
当我们完成游戏化微服务并将其逻辑连接到 web 客户端时,我们完成了用户故事 3 中新请求的功能。然而,新的功能需求与其他非功能需求一起出现:系统容量、可用性和组织灵活性。
经过一些分析,我们决定转移到基于微服务的分布式系统架构,因为这种方法会给我们的案例研究带来优势。
我们尽可能简单地开始,通过 HTTP 同步连接服务,并将用户界面指向我们的两个微服务。然后,使用我们的实际例子,我们看到了这种方法如何破坏我们的计划,因为微服务之间的紧密耦合和无法扩展。为了解决这种情况,我们采用了异步通信和最终一致性,并引入了消息代理来实现事件驱动的架构设计。结果,我们解决了紧耦合的挑战,现在我们的微服务被很好地隔离了。我们甚至部分讨论了负载平衡,因为代理正在为事件消费者处理它。
当我们继续我们的架构时,我们简要地描述了有助于我们实现目标的其他模式,比如网关模式或 HTTP 接口的负载平衡器。除了这些明确的需求,我们还没有介绍微服务架构的其他一些基本实践:服务发现、健康检测、配置管理、日志记录、跟踪、端到端测试等。
在这一章中,我们将使用我们的系统作为例子来研究所有这些模式。这将有助于你在深入研究解决方案之前理解问题。我们对微服务常用模式和工具的探索才刚刚开始。
门
我们已经知道网关模式将在我们的架构中解决的一些问题。
-
我们的 React 应用需要指向多个后端微服务,以便与它们的 API 进行交互。这是错误的,因为前端应该将后端视为具有多个 API 的单个服务器。然后,我们不暴露我们的架构,从而使它更加灵活,以防我们将来想要做出改变。
-
如果我们引入后端服务的多个实例,我们的 UI 不知道如何平衡它们之间的负载。如果其中一个实例不可用,它也不知道如何将所有请求重定向到不同的后端实例。尽管在我们的 web 客户端中实现负载平衡和弹性模式在技术上是可能的,但这是我们应该放在后端的逻辑。我们只实现一次,它对任何客户端都有效。此外,我们尽可能保持前端的逻辑简单。
-
根据当前的设置,如果我们在系统中添加用户身份验证,我们将需要验证每个后端微服务中的安全凭证。将这个逻辑放在我们后端的边缘似乎更符合逻辑,在那里验证 API 调用,并将简单的请求传递给其他微服务。只要我们确保其余的后端服务不能从外部访问,它们就不需要考虑安全问题。
在本章的第一部分,我们将在系统中引入网关微服务来解决这些问题。网关模式集中了 HTTP 访问,并负责将请求代理到其他底层服务。通常,网关根据一些配置的规则(也称为谓词)来决定将请求路由到哪里。此外,这个路由服务可以在请求和响应通过时对其进行修改,其中包含一些被称为过滤器的逻辑。我们将很快在实现中使用规则和过滤器,以便更好地理解它们。请参见图 8-1 了解网关如何融入我们的系统。
图 8-1
网关:高级概述
有时人们将网关称为边缘服务,因为它是其他系统访问我们后端的方式,它将外部流量路由到相应的内部微服务。如前所述,网关的引入通常会限制对其他后端服务的访问。对于本章的第一部分,我们将跳过这个限制,因为我们直接在机器上运行所有的服务。当我们引进集装箱化时,我们将改变这一点。
SpringCloud 网关
Spring Cloud 是 Spring 家族中的一个独立项目组,它提供工具来快速构建分布式系统中所需的通用模式,比如我们的微服务案例。这些模式也被称为云模式,尽管即使您在自己的服务器中部署微服务,它们也是适用的。在本章中,我们将利用几个 Spring Cloud 项目。如果您想查看完整的列表,请查看参考文档中的概述页面( https://tpd.io/scloud )。
对于网关模式,Spring Cloud 提供了两种选择。第一种选择是使用 Spring Cloud 长期支持的集成:Spring Cloud 网飞。这是一个包括几个工具的项目,这些工具是网飞开发者多年来作为开源软件(OSS)发布和维护的。如果你想了解更多关于这些工具的信息,你可以看看网飞 OSS 网站( https://tpd.io/noss )。网飞 OSS 中实现网关模式的组件是 Zuul,它与 Spring 的集成通过 Spring Cloud 网飞 Zuul 模块实现。
在这本书的第二版中,我们不会使用 SpringCloud 网飞。主要原因是,Spring 似乎正在远离网飞 OSS 工具集成,并用集成了替代工具的其他模块,甚至是它们自己的实现来取代它们。对这种变化的一种可能的解释是,网飞将其一些项目置于维护模式,如 Hystrix(断路)和 Ribbon(负载平衡),因此它们不再处于积极的开发中。这个决定也会影响网飞堆栈中的其他工具,因为它们实现了经常一起使用的模式。一个例子是服务发现工具 Eureka,它依靠 Ribbon 来实现负载平衡。
我们将选择一个更新的替代方案来实现网关模式:Spring Cloud Gateway。在这种情况下,对 Zuul 的替换是一个独立的 Spring 项目,因此它不依赖于任何外部工具。
知道了模式,你就可以交换工具了
请记住,从这本书中学到的最重要的东西是微服务架构模式,以及从实用的角度介绍它们的原因。您可以使用市场上的任何其他替代产品,如 Nginx、HAProxy、Kong Gateway 等。
Spring Cloud Gateway 项目定义了一些核心概念(也如图 8-2 所示)。
图 8-2
网关:路由、谓词和过滤器
-
谓词:决定将请求路由到哪里的评估条件。云网关提供了一堆基于请求路径、请求头、时间、远程主机等的条件构建器。你甚至可以把它们组合成表达式。它们也被称为路由谓词,因为它们总是应用于一条路由。
-
Route :如果请求匹配指定的谓词,它将被代理到 URI。例如,它可以向内部微服务端点发送外部请求,我们将在后面的实践中看到这一点。
-
过滤器:一个可选的处理器,可以连接到一个路由(路由过滤器),也可以全局应用(全局过滤器)到所有的请求。过滤器允许修改请求(传入过滤器)和响应(传出过滤器)。Spring Cloud Gateway 中有许多内置的过滤器,因此您可以添加或删除请求中的头,限制来自给定主机的请求数量,或者在将响应返回给请求者之前转换来自代理服务的响应。
为了定义这个配置,我们使用 Spring Boot 的应用属性。然而,这一次我们将使用 YAML 格式,因为它在定义路线时可读性更强。云网关文档定义了谓词、路由和过滤器的特定符号。此外,在定义谓词和过滤器时,我们有两个选项:快捷方式和完全扩展的配置。他们两个工作一样;唯一的区别是,您可以使用单行表达式,并通过快捷方式版本避免额外的 YAML。如果你想知道它们之间的比较,可以查看文档中的“快捷符号”( http://tpd.io/gw-notation )一节。见清单 8-1 中使用快捷符号定义两条路线的示例配置块。请继续阅读关于它们如何工作的详细解释。
spring:
cloud:
gateway:
routes:
- id: old-travel-conditions
uri: http://oldhost/travel
predicates:
- Before=2021-01-01T10:00:00.000+01:00[Europe/Madrid]
- Path=/travel-in-spain/**
- id: change-travel-conditions
uri: http://somehost/travel-new
predicates:
- After=2021-01-01T10:00:00.000+01:00[Europe/Madrid]
- Path=/travel-in-spain/**
filters:
- AddResponseHeader=X-New-Conditions-Apply, 2021-Jan
Listing 8-1An Example of Routing Configuration in Spring Cloud Gateway
假设在http://my.travel.gateway/网关是外部可访问的。这个示例配置定义了共享一个路径路由断言(包含在网关中)的两个路由;见 https://tpd.io/pathpred 。任何以http://my.travel.gateway/travel-in-spain/开头的请求都被这个谓词定义捕获。每个路由中的附加条件分别由一个 Before ( https://tpd.io/befpred )和一个 After route ( https://tpd.io/aftpred )谓词定义,它们决定将请求代理到哪里。
-
如果请求发生在西班牙时间 2021 年 1 月 1 日上午 10 点之前,它将被代理给
http://oldhost/travel-conditions/。例如,请求http://my.travel.gateway/travel-in-spain/tapas被代理给http://oldhost/travel/tapas。 -
此后发生的任何请求都被
change-travel-conditions路由捕获,因为它使用了对应的谓词After。在这种情况下,前面显示的相同请求将被代理到http://somehost/travel-new/tapas。另外,额外的filter将添加一个响应头X-New-Conditions-Apply,值为2021-Jan。
请记住,本例中的http://oldhost和http://somehost不需要从外部访问;它们只对我们后端的网关和其他内部服务可见。
内置的谓词和过滤器允许我们满足对网关的各种需求。在我们的应用中,我们将主要使用路径路由谓词,根据它们调用的 API,将外部请求代理到相应的微服务。
如果您想扩展关于 Spring Cloud Gateway 功能的知识,请查看参考文档( https://tpd.io/gwdocs )。
网关微服务
代码源
本章中的代码源被分成四个部分。通过这种方式,您可以更好地理解系统是如何逐步发展的。第一部分的源代码,包括网关实现,都在项目chapter08a中。
可以想象,Spring Boot 为 Spring Cloud Gateway 提供了一个入门包。只有将这个启动器依赖项添加到一个空的 Spring Boot 应用中,我们才能获得一个随时可用的网关微服务。实际上,Gateway 项目是建立在 Spring Boot 之上的,所以它只能在 Spring Boot 应用中工作。出于这个原因,在这种情况下,自动配置逻辑位于核心 Spring Cloud Gateway 工件中,而不是位于 Spring Boot 的autoconfigure包中。类名是GatewayAutoConfiguration(参见 https://tpd.io/gwautocfg ),在其他任务中,它读取application.yml配置并构建相应的路由过滤器、谓词等。
我们将像往常一样,通过 Spring Initializr 的网站构建这个新的微服务(参见 https://tpd.io/spring-start )。选择网关依赖,将工件命名为网关,如图 8-3 所示。
图 8-3
创建网关微服务
下载 zip 文件后,我们将它的内容复制到我们的主工作区文件夹中,与乘法和游戏化微服务处于同一级别。将项目作为一个额外的模块加载到您的工作区中,并花点时间研究一下生成的pom.xml文件的内容。与其他项目相比,您会看到一个新的dependencyManagement节点和一个新的属性供 Spring Cloud 版本使用(Hoxton)。文件的主要变化见清单 8-2 。我们需要这个额外的 Maven 配置,因为 Spring Cloud 工件没有直接在 Spring Boot 的父项目中定义。
<?xml version="1.0" encoding="UTF-8"?>
<project>
<!-- ... -->
<name>gateway</name>
<properties>
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
<!-- ... -->
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- ... -->
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- ... -->
</project>
Listing 8-2Spring Cloud Gateway Dependencies in Maven
下一步是将application.properties文件的扩展名更改为application.yml,并添加一些配置,以将属于乘法微服务的所有端点代理到该应用,对于游戏化的端点也是如此。我们还将这个新服务的服务器端口更改为 8000,以避免本地部署时的端口冲突。此外,我们将为 UI 添加一些 CORS 配置,以允许从其来源发出请求。Spring Cloud Gateway 有一个基于配置的风格( https://tpd.io/gwcors )来使用globalcors属性实现这一点。在清单 8-3 中可以看到所有这些变化。
server:
port: 8000
spring:
cloud:
gateway:
routes:
- id: multiplication
uri: http://localhost:8080/
predicates:
- Path=/challenges/**,/attempts,/attempts/**,/users/**
- id: gamification
uri: http://localhost:8081/
predicates:
- Path=/leaders
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "http://localhost:3000"
allowedHeaders:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
Listing 8-3Gateway Configuration: First Approach
该文件中的路由将使网关的行为如下:
-
任何到
http://localhost:8000/attempts或在http://localhost:8000/attempts下的请求将被代理到部署在本地http://localhost:8080/的乘法微服务。位于同一微服务中的其他 API 上下文也会发生同样的情况,比如challenges和users。 -
对
http://localhost:8000/leaders的请求将被转换为对游戏化微服务的请求,游戏化微服务使用相同的主机(localhost)但端口是 8081。
或者,可以编写一个更简单的配置,不需要路由到每个微服务的端点的明确列表。我们可以通过使用网关的另一个允许捕获路径段的特性来做到这一点。如果我们得到一个 API 调用,比如http://localhost:8000/multiplication/attempts,我们可以提取multiplication作为一个值,并使用它映射到相应服务的主机和端口。然而,只有当每个微服务只包含一个 API 域时,这种方法才有效。在任何其他情况下,我们将向客户公开我们的内部架构。在我们的例子中,我们会要求客户端调用http://localhost:8000/multiplication/users,而我们更希望它们指向http://localhost:8000/users,并隐藏用户域仍然存在于乘法的可部署单元中的事实。
其他项目的变化
随着网关微服务的引入,我们可以将所有针对外部请求的配置保留在同一个服务中。这意味着我们不再需要向倍增和游戏化微服务添加 CORS 配置。我们可以在网关中保留这个配置,因为其他两个服务被放在这个新的代理服务之后。因此,我们可以从现有的项目文件夹中删除两个WebConfiguration文件。清单 8-4 展示了游戏化微服务中的文件内容。记得删除的不仅是这个,还有乘法微服务里的等价类。
package microservices.book.gamification.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
/**
* Enables Cross-Origin Resource Sharing (CORS)
* More info: http://docs.spring.io/spring/docs/current/spring-framework-reference/html/cors.html
*/
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("http://localhost:3000");
}
}
Listing 8-4The WebConfiguration Class That We Can Remove
我们还需要更改 React 应用,使两个服务指向同一个主机/端口。参见清单 8-5 和 8-6 。我们还可以根据我们对 UI 结构的偏好重构GameApiClient和ChallengesApiClient类:一个服务调用所有端点,或者一个服务调用每个 API 上下文(挑战、用户等)。).我们不再需要两个不同的服务器 URL,因为 UI 现在将后端视为具有多个 API 的单个主机。
class GameApiClient {
static SERVER_URL = 'http://localhost:8000';
static GET_LEADERBOARD = '/leaders';
// ...
}
Listing 8-6Changing the Gamification API URL to Point to the Gateway
class ChallengesApiClient {
static SERVER_URL = 'http://localhost:8000';
static GET_CHALLENGE = '/challenges/random';
static POST_RESULT = '/attempts';
// ...
}
Listing 8-5Changing the Multiplication API URL to Point to the Gateway
运行网关微服务
要运行我们的整个应用,我们必须在列表中添加一个额外的步骤。记住,使用 Maven 包装器,所有 Spring Boot 应用都可以从您的 IDE 或命令行执行。
-
运行 RabbitMQ 服务器。
-
启动乘法微服务。
-
启动游戏化微服务。
-
启动新的网关微服务。
-
从
challenges-frontend文件夹用npm start运行前端 app。
在本章中,这个列表将继续增长。需要强调的是,您不需要遵循前面步骤的顺序,因为您甚至可以同时运行所有这些流程。在开始阶段,系统可能不稳定,但最终会准备好的。Spring Boot 将重新尝试连接 RabbitMQ,直到它正常工作。
当您访问 UI 时,您不会注意到任何变化。排行榜已加载,您可以像往常一样发送尝试。验证请求是否被代理的一种方法是查看浏览器的开发工具中的 Network 选项卡,并选择任何请求到后端,以查看 URL 现在如何以http://localhost:8000开始。第二个选择是向网关添加一些跟踪日志配置,这样我们就可以看到发生了什么。请参见清单 8-7 了解您可以添加到网关项目中的application.yml文件中的配置,以启用这些日志。
# ... route config
logging:
level:
org.springframework.cloud.gateway.handler.predicate: trace
Listing 8-7Adding Trace-Level Logs to the Gateway
如果您使用这个新配置重新启动网关,您将看到网关处理的每个请求的日志。这些日志是检查所有定义的路由以查看是否有匹配请求模式的路由的结果。参见清单 8-8 。
TRACE 48573 --- [ctor-http-nio-2] RoutePredicateFactory: Pattern "[/challenges/**, /attempts, /attempts/**, /users/**]" does not match against value "/leaders"
TRACE 48573 --- [ctor-http-nio-2] RoutePredicateFactory: Pattern "/leaders" matches against value "/leaders"
TRACE 48573 --- [ctor-http-nio-2] RoutePredicateFactory: Pattern "/users/**" matches against value "/users/72,49,60,101,96,107,1,45"
Listing 8-8Gateway Logs with Pattern-Matching Messages
现在,让我们再次删除这个日志配置,以避免过于冗长的输出。
后续步骤
我们已经有了一些新的优势。
-
前端仍然不知道后端的结构。
-
外部请求的通用配置保持不变。在我们的例子中,这是 CORS 设置,但也可能是其他常见问题,如用户身份验证、指标等。
接下来,我们将在网关中引入负载平衡,这样它可以在每个服务的所有可用实例之间分配流量。利用这种模式,我们将为系统增加可伸缩性和冗余性。然而,要使负载平衡正常工作,我们需要一些先决条件。
-
网关需要知道给定服务的可用实例。我们的初始配置直接指向一个特定的端口,因为我们假设只有一个实例。如果有多个副本,会是什么样子呢?我们不应该在路由配置中包含硬编码列表,因为实例的数量应该是动态的:我们希望透明地增加和减少新的实例。
-
我们需要实现后端组件健康的概念。只有这样,我们才能知道一个实例何时未准备好处理流量,并切换到任何其他正常的实例。
为了满足第一个先决条件,我们需要引入服务发现模式,使用一个公共注册表,不同的分布式组件可以访问该注册表以了解可用的服务以及在哪里可以找到它们。
对于第二个先决条件,我们将使用 Spring Boot 执行器。我们的微服务将公开一个指示它们是否健康的端点,因此其他组件将会知道。这是我们接下来要做的,因为这也是服务发现的一个要求。
健康
在生产环境中运行的系统永远不会安全地避免错误。网络连接可能会失败,或者微服务实例可能会由于代码中的错误导致的内存不足问题而崩溃。我们决心建立一个有弹性的系统,所以我们希望通过像冗余(同一微服务的多个副本)这样的机制来应对这些错误,以最大限度地减少这些事件的影响。
那么,我们如何知道微服务何时不工作呢?如果它公开了一个接口(比如 REST API 或 RabbitMQ),我们可以与一个样本探针进行交互,看看它是否对它做出反应。但是,我们在选择该探测器时应该小心,因为我们希望涵盖所有可能会使我们的微服务过渡到不健康状态(不起作用)的场景。与其将确定服务是否工作的逻辑泄露给调用者,不如提供一个标准的、简单的探测接口来判断服务是否健康。由服务的逻辑根据它使用的接口的可用性、它自己的可用性和错误的严重性来决定何时转换到不健康状态。如果服务甚至不能提供响应,调用者也可以认为它是不健康的。请参见图 8-4 了解该健康界面的高级概念视图。
图 8-4
健康:高级概述
许多工具和框架都需要这个简单的接口约定来确定服务的健康程度(不仅仅是微服务)。例如,负载平衡器可以暂时停止将流量转移到不响应健康探测或以非就绪状态响应的实例。如果实例不正常,服务发现工具可能会将其从注册表中删除。像 Kubernetes 这样的容器平台可以决定重启一个服务,如果它在一段配置的时间内不健康的话(我们将在本章后面解释什么是容器平台)。
Spring Boot 执行器
与我们应用的其他方面一样,Spring Boot 提供了一个开箱即用的解决方案来使我们的微服务报告其健康状态:Spring Boot 执行器。实际上,这不是 Actuator 包含的唯一特性;它还可以公开其他端点来访问关于我们的应用的不同数据,如配置的记录器、HTTP 跟踪、审计事件等。它甚至可以打开一个允许您关闭应用的管理端点。
执行器端点可以独立启用或禁用,它们不仅可以通过 web 界面使用,还可以通过 Java 管理扩展(JMX)使用。我们将把重点放在我们将用作 REST API 端点的 web 接口上。默认配置只公开两个端点:info和health。第一个目的是提供关于应用的一般信息,您可以使用贡献者( https://tpd.io/infocb )来丰富这些信息。健康端点是我们现在感兴趣的。它输出我们的应用的状态,为了解决这个问题,它使用健康指示器( https://tpd.io/acthealth )。
有多个内置的健康指示器可以帮助了解应用的整体健康状态。这些指示器中有许多是特定于某些工具的,因此只有当我们在应用中使用这些工具时,它们才可用。这是由 Spring Boot 的自动配置控制的,我们已经知道它可以检测我们是否在使用某些类并注入一些额外的逻辑。
让我们用一个实际的例子来看看它是如何工作的:包含在 Spring Boot 执行器工件中的RabbitHealthIndicator类。参见清单 8-9 以获得其源代码的概述(也可在 http://tpd.io/rhi-source 在线获得)。健康检查实现使用了一个RabbitTemplate对象,这是 Spring 与 RabbitMQ 服务器交互的方式。如果这段代码可以访问 RabbitMQ 服务器的版本,健康检查就通过了(它不会抛出异常)。
public class RabbitHealthIndicator extends AbstractHealthIndicator {
private final RabbitTemplate rabbitTemplate;
public RabbitHealthIndicator(RabbitTemplate rabbitTemplate) {
super("Rabbit health check failed");
Assert.notNull(rabbitTemplate, "RabbitTemplate must not be null");
this.rabbitTemplate = rabbitTemplate;
}
@Override
protected void doHealthCheck(Health.Builder builder) throws Exception {
builder.up().withDetail("version", getVersion());
}
private String getVersion() {
return this.rabbitTemplate
.execute((channel) -> channel.getConnection()
.getServerProperties().get("version").toString());
}
}
Listing 8-9The RabbitHealthIndicator Included in Spring Boot Actuator
如果我们使用 RabbitMQ,这个指示器会自动注入到上下文中。它有助于整体健康状况。工件spring-boot-actuator-autoconfigure(Spring Boot 致动器依赖的一部分)中包含的RabbitHealthContributorAutoConfiguration类负责这一点。见清单 8-10 (也可在 http://tpd.io/rhc-autoconfig )。这个配置以一个RabbitTemplate bean 的存在为条件,这意味着我们正在使用 RabbitMQ 模块。它创建了一个HealthContributor bean,在本例中是一个RabbitHealthIndicator,它将被整体健康自动配置检测和聚合。
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RabbitTemplate.class)
@ConditionalOnBean(RabbitTemplate.class)
@ConditionalOnEnabledHealthIndicator("rabbit")
@AutoConfigureAfter(RabbitAutoConfiguration.class)
public class RabbitHealthContributorAutoConfiguration
extends CompositeHealthContributorConfiguration<RabbitHealthIndicator, RabbitTemplate> {
@Bean
@ConditionalOnMissingBean(name = { "rabbitHealthIndicator", "rabbitHealthContributor" })
public HealthContributor rabbitHealthContributor(Map<String, RabbitTemplate> rabbitTemplates) {
return createContributor(rabbitTemplates);
}
}
Listing 8-10How Spring Boot Autoconfigures the RabbitHealthContributor
我们将很快看到这在实践中是如何工作的,因为我们将在下一节将 Spring Boot 致动器添加到我们的微服务中。
请记住,您可以配置执行器端点的多个设置,也可以创建自己的健康指示器。如需完整的功能列表,请查阅 Spring Boot 致动器官方文档( https://tpd.io/sbactuator )。
包括微服务中的致动器
代码源
引入健康端点、服务发现和负载平衡的代码源位于存储库chapter08b中。
将健康端点添加到我们的应用就像将pom.xml文件中的依赖项添加到我们的项目:spring-boot-starter-actuator一样简单。参见清单 8-11 。我们将这个新的工件添加到我们所有的 Spring Boot 应用中:乘法、游戏化和网关微服务。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Listing 8-11Adding Spring Boot Actuator to Our Microservices
默认配置在/actuator上下文中公开了health和info web 端点。这对于我们来说已经足够了,但是如果需要的话,可以通过属性进行调整。重新构建并重启后端应用,以验证这一新功能。我们可以使用命令行或浏览器来轮询每个服务的健康状态,只需切换端口号即可。请注意,我们没有通过网关公开/health端点,因为这不是我们想要向外部公开的特性,而是我们系统内部的特性。乘法微服务的请求和响应见清单 8-12 。
$ http :8080/actuator/health
HTTP/1.1 200
Content-Type: application/vnd.spring-boot.actuator.v3+json
{
"status": "UP"
}
Listing 8-12Testing the /health Endpoint for the First Time
如果我们的系统运行正常,我们将获得UP值和一个 HTTP 状态代码200。我们接下来要做的是停止 RabbitMQ 服务器并再次尝试同样的请求。我们已经看到,Actuator 项目包含一个健康指示器来检查 RabbitMQ 服务器,所以这个应该会失败,导致聚合健康状态切换到DOWN。如果我们在 RabbitMQ 服务器停止时发出请求,这确实是我们将得到的结果。参见清单 8-13 。
$ http :8080/actuator/health
HTTP/1.1 503
Content-Type: application/vnd.spring-boot.actuator.v3+json
{
"status": "DOWN"
}
Listing 8-13The Status of Our App Switches to DOWN When RabbitMQ Is Unreachable
请注意,返回的 HTTP 状态代码也更改为 503,服务不可用。因此,调用者甚至不需要解析响应体;它可以只检查响应代码是否为 200,以确定应用是否正常。您还可以在乘法应用的输出中看到从RabbitHealthIndicator检索服务器版本的失败尝试的日志。参见清单 8-14 。
2020-08-30 10:20:04.019 INFO 59277 --- [io-8080-exec-10] o.s.a.r.c.CachingConnectionFactory : Attempting to connect to: [localhost:5672]
2020-08-30 10:20:04.021 WARN 59277 --- [io-8080-exec-10] o.s.b.a.amqp.RabbitHealthIndicator : Rabbit health check failed
org.springframework.amqp.AmqpConnectException: java.net.ConnectException: Connection refused
at org.springframework.amqp.rabbit.support.RabbitExceptionTranslator.convertRabbitAccessException(RabbitExceptionTranslator.java:61) ~[spring-rabbit-2.2.10.RELEASE.jar:2.2.10.RELEASE]
[...]
Caused by: java.net.ConnectException: Connection refused
at java.base/sun.nio.ch.Net.pollConnect(Native Method) ~[na:na]
[...]
Listing 8-14Rabbit Health Check Failing in the Multiplication Microservice
Spring Boot 应用仍然存在,并且可以从错误中恢复。如果我们启动 RabbitMQ 服务器并再次检查健康状态,它将切换到UP。应用不断尝试建立与服务器的连接,直到成功为止。这正是我们想要的健壮系统的行为:如果微服务有问题,它应该标记它,让其他组件知道;同时,它应该尝试从错误中恢复,并在可能的情况下再次切换到健康状态。
服务发现和负载平衡
既然我们有能力知道服务是否可用,我们就可以在系统中集成服务发现和负载平衡。
服务发现模式由两个主要概念组成。
图 8-5
服务发现:模式概述
- 服务注册中心:一个包含可用服务列表、服务所在地址和一些额外元数据(如名称)的中心位置。它可能包含不同服务的条目,但也可能包含同一服务的多个实例。在最后一种情况下,访问注册中心的客户机可以通过查询一个服务别名来获得可用实例的列表。例如,乘法微服务的各种实例可以用同一个别名
multiplication注册。然后,当查询该值时,将返回所有实例。图 8-5 所示的例子就是如此。
** 注册器:负责在注册中心注册服务实例的逻辑。它可以是一个外部运行的进程,观察我们的微服务的状态,或者它可以作为一个库嵌入到服务本身中,就像我们的情况一样。*
*在图 8-5 中,我们看到了一个有三个服务的服务注册的例子。服务器的 DNS 地址host1在端口 8080 有一个乘法实例,在端口 8081 有一个游戏化实例。另一台机器host2有第二个乘法实例,位于端口 9080。所有这些实例都知道它们的位置,并使用注册器将它们相应的 URIs 发送到服务注册中心。然后,注册中心客户端可以使用服务的名称(例如,multiplication)简单地询问服务的位置,注册中心返回实例及其位置的列表(例如,host1:8080,host2:9080)。我们将很快在实践中看到这一点。
负载平衡模式与服务发现密切相关。如果多个服务在注册时使用相同的名称,这意味着有多个副本可用。我们希望平衡它们之间的流量,这样我们就可以增加系统的容量,并且由于增加了冗余,在出现错误的情况下使系统更有弹性。
其他服务可以从注册表中查询给定的服务名,检索列表,然后决定调用哪个实例。这种技术被称为客户端发现,它意味着客户端知道服务注册并自己执行负载平衡。请注意,在此定义中,客户端是指应用、微服务、浏览器等。,它希望对另一个服务执行 HTTP 调用。见图 8-6 。
图 8-6
客户端服务发现
另一方面,服务器端发现通过提供一个预先知道的惟一地址,从客户端抽象出所有这些逻辑,调用者可以在这个地址找到给定的服务。当它们发出请求时,负载均衡器会截获它,它知道注册表。这个平衡器将把请求代理给其中一个副本。见图 8-7 。
图 8-7
服务器端服务发现
通常,在微服务架构中,您会看到两种方法的结合,或者只是服务器端发现。当 API 客户端在我们的系统之外时,客户端发现不能很好地工作,因为我们不应该要求外部客户端与服务注册中心交互并自己进行负载平衡。通常,网关承担这一责任。因此,我们的 API 网关将连接到服务注册中心,并将包含一个负载平衡器,以便在实例之间分配负载。
对于我们后端中的任何其他服务到服务的通信,我们可以将它们全部连接到注册表以进行客户端发现,或者将每个服务集群(及其所有实例)抽象为具有负载平衡器的唯一地址。后者是 Kubernetes 等一些平台选择的技术,其中每个服务都被分配一个唯一的地址,不管有多少副本以及它们位于哪个节点(我们将在本章稍后回到这一点)。
我们的微服务不再互相调用,但是如果需要的话,实现客户端发现会很简单。Spring Boot 具有连接到服务注册中心和实现负载平衡器的集成(类似于我们将在网关中做的)。
正如我们已经提到的,任何到我们后端的非内部 HTTP 通信都将使用服务器端发现方法。这意味着我们的网关不仅会路由流量,还会负责负载平衡。参见图 8-8 ,其中也包括我们在需要军种间通信时选择的解决方案。该图还介绍了我们将选择用来实现服务发现、Consul 的工具的名称,以及我们将添加到依赖项中的 Spring Cloud 项目,以集成该工具并包括一个简单的负载平衡器。我们很快就会了解它们。
图 8-8
网关和服务发现集成
领事
许多工具实现了服务发现模式:Consul、Eureka、Zookeeper 等。也有完整的平台将这种模式作为其特性之一,我们将在后面描述。
在 Spring 生态系统中,网飞的尤里卡一直是最受欢迎的选择。然而,由于前面提到的原因(组件处于维护模式,Spring 开发人员开发的新工具),这种偏好不再是一个合理的选择。我们将使用 Consul,这是一个提供服务发现和其他功能的工具,并且通过 Spring Cloud 模块进行了很好的集成。此外,我们将利用本章后面的其他 Consul 特性来实现微服务架构中的另一种模式,即集中式配置。
首先,让我们安装 Consul 工具,该工具在下载页面( https://tpd.io/dlconsul )可用于多个平台。一旦你安装了它,你可以用清单 8-15 中的命令在开发模式下运行 Consul 代理。
$ consul agent -node=learnmicro -dev
==> Starting Consul agent...
Version: 'v1.7.3'
Node ID: '0a31db1f-edee-5b09-3fd2-bcc973867b65'
Node name: 'learnmicro'
Datacenter: 'dc1' (Segment: '<all>')
Server: true (Bootstrap: false)
Client Addr: [127.0.0.1] (HTTP: 8500, HTTPS: -1, gRPC: 8502, DNS: 8600)
Cluster Addr: 127.0.0.1 (LAN: 8301, WAN: 8302)
Encrypt: Gossip: false, TLS-Outgoing: false, TLS-Incoming: false, Auto-Encrypt-TLS: false
...
Listing 8-15Starting the Consul Agent in Development Mode
日志应该显示一些关于服务器和一些启动操作的信息。我们在开发模式下运行代理,因为我们在本地使用它,但是一个合适的 Consul 生产设置应该包括一个具有多个数据中心的集群。这些数据中心可以运行一个或多个代理,其中每个服务器只有一个代理充当服务器代理。代理使用协议在它们之间进行通信,以同步信息并通过一致意见选举领导者。所有这些设置确保了高可用性。如果数据中心变得不可达,代理会注意到这一点,并选举新的领导者。如果您想了解更多关于在生产中部署 Consul 的信息,请查看部署指南( https://tpd.io/consulprod )。我们将坚持本书中的开发模式,使用独立的代理。
正如我们在输出中看到的,Consul 在端口 8500 上运行一个 HTTP 服务器。它提供了一个 RESTful API,我们可以用它来进行服务注册和发现,以及其他功能。此外,它提供了一个 UI,如果我们从浏览器导航到http://localhost:8500,就可以访问这个 UI。参见图 8-9 。
图 8-9
领事 UI
“服务”部分显示已注册服务的列表。由于我们还没有做任何事情,唯一可用的服务就是 consul 服务器。其他选项卡向我们展示了可用的 Consul 节点、我们将在本章后面使用的键/值功能,以及一些额外的 Consul 特性,如 ACL 和 intentions,这些我们在本书中不会用到。
您还可以通过 REST API 访问可用服务的列表。例如,使用 HTTPie,我们可以请求一个可用服务的列表,这会暂时输出一个空的响应体。参见清单 8-16 。
$ http -b :8500/v1/agent/services
{}
Listing 8-16Requesting the List of Services from Consul
服务 API 允许我们列出服务、查询它们的信息、了解它们是否健康、注册它们以及取消注册它们。我们不会直接使用这个 API,因为 Spring Cloud Consul 模块会为我们做这件事,我们很快会谈到。
Consul 包括验证所有服务状态的功能:健康检查特性。它提供了多种选项,我们可以使用这些选项来确定健康状况:HTTP、TCP、脚本等。可以想象,我们的计划是让 Consul 通过 HTTP 接口联系我们的微服务,更具体地说是在/actuator/health端点上。运行状况检查位置是在服务注册时配置的,Consul 会定期触发它们,也可以进行自定义。如果服务未能响应或以非正常状态响应(非 2XX),Consul 会将该服务标记为不健康。我们很快就会看到一个实际的例子。如果你想知道更多关于如何配置它们的信息,请阅读 Consul 文档中的检查页面( https://tpd.io/consul-checks )。
SpringCloud 领事
我们不需要使用 Consul API 来注册服务、定义健康检查或访问注册表来查找服务地址。所有这些功能都被 Spring Cloud Consul 项目抽象了,所以我们所需要的就是在我们的 Spring Boot 应用中包含相应的启动器,并且如果我们选择不使用默认值,就配置一些设置。
我们将使用的 Spring Cloud Consul 版本仍然附带网飞的 Ribbon,作为实现负载平衡器模式的一个包含依赖项。正如我们前面提到的,这个工具处于维护模式,Spring 文档不鼓励使用它(参见 https://tpd.io/no-ribbon )。我们将在下一节详细介绍我们将使用的替代方案。现在,为了保持我们的项目整洁,我们将使用 Maven 来排除 Ribbon 的 starter 上的传递性依赖。参见清单 8-17 。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
Listing 8-17Adding the Spring Cloud Consul Discovery Dependency in Maven
稍后,我们将把这种依赖性添加到网关项目中。对于另外两个微服务,我们首次添加了 Spring Cloud 依赖项。因此,我们需要将dependencyManagement节点添加到我们的pom.xml文件和 Spring Cloud 版本中。参见清单 8-18 了解所需的添加内容。
<project>
<!-- ... -->
<properties>
<!-- ... -->
<spring-cloud.version>Hoxton.SR7</spring-cloud.version>
</properties>
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- ... -->
</project>
Listing 8-18Adding Consul Discovery to Multiplication and Gamification
Consul 包含的 Spring Boot 自动配置默认值对我们来说很好:服务器位于http://localhost:8500。如果您想检查这些默认值,请参见ConsulProperties的源代码( https://tpd.io/consulprops )。如果我们需要更改它们,我们可以使用这些和其他在spring.cloud.consul前缀下可用的属性。关于您可以覆盖的设置的完整列表,请查看 Spring Cloud Consul 的参考文档( https://tpd.io/consulconfig )。
然而,我们的应用中需要一个新的配置属性:应用名称,由spring.application.name属性指定。到目前为止,我们还不需要它,但是 Spring Cloud Consul 使用它来注册具有该值的服务。这是我们必须添加到乘法项目内的application.properties文件中的行:
spring.application.name=multiplication
确保将这一行也添加到游戏化微服务的配置中,这次使用值gamification。在网关项目中,我们使用 YAML 属性,但是变化是相似的。
spring:
application:
name: gateway
现在,让我们开始乘法和游戏化微服务,看看他们如何注册自己相应的健康检查。记得启动 RabbitMQ 服务器和 Consul 代理。我们仍然需要对网关服务进行一些更改,所以我们还不需要启动它。在应用日志中,您应该会看到新的一行,如清单 8-19 所示(注意,这只有一行,但是非常冗长)。
INFO 53587 --- [main] o.s.c.c.s.ConsulServiceRegistry: Registering service with consul: NewService{id='gamification-8081', name="gamification", tags=[secure=false], address='192.168.1.133', meta={}, port=8081, enableTagOverride=null, check=Check{script='null', dockerContainerID="null", shell="null", interval="10s", ttl="null", http='http://192.168.1.133:8081/actuator/health', method="null", header={}, tcp="null", timeout="null", deregisterCriticalServiceAfter="null", tlsSkipVerify=null, status="null", grpc="null", grpcUseTLS=null}, checks=null}
Listing 8-19Gamification’s Log Line Showing the Consul Registration Details
这一行显示了应用启动时通过 Spring Cloud Consul 进行的服务注册。我们可以看到请求的内容:由服务名和端口组成的唯一 ID、可以对多个实例进行分组的服务名、本地地址,以及通过 HTTP 对 Spring Boot 执行器公开的服务健康端点的地址进行的配置健康检查。默认情况下,Consul 验证该检查的时间间隔设置为 10 秒。
在 Consul 服务器端(参见清单 8-20 ),日志在开发模式下被默认设置为调试级别,因此我们可以看到 Consul 是如何处理这些请求并触发检查的。
[DEBUG] agent.http: Request finished: method=PUT url=/v1/agent/service/register?token=<hidden> from=127.0.0.1:54172 latency=2.424765ms
[DEBUG] agent: Node info in sync
[DEBUG] agent: Service in sync: service=gamification-8081
[DEBUG] agent: Check in sync: check=service:gamification-8081
Listing 8-20Consul Agent Logs
一旦我们使用这个新配置启动了两个微服务,我们就可以访问 Consul 的 UI 来查看更新后的状态。参见图 8-10 。
图 8-10
领事中列出的服务
现在导航到服务,单击“乘法”,然后单击显示在那里的唯一的“乘法”行。您将看到服务的运行状况检查。我们可以验证 Consul 如何从 Spring Boot 应用获得 OK 状态(200)。参见图 8-11 。
图 8-11
服务运行状况检查
我们还可以启动其中一个微服务的第二个实例,看看注册表是如何管理它的。如果您覆盖了端口,您可以从您的 IDE 或者直接从命令行来实现。参见清单 8-21 中如何启动乘法微服务的第二个实例的示例。如你所见,我们可以使用 Spring Boot 的 Maven 插件覆盖服务器端口(更多细节见 https://tpd.io/mvn-sb-props )。
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
[... logs ...]
Listing 8-21Running a Second Instance of then Multiplication Microservice from the Command Line
在领事注册表中,仍然会有一个单独的multiplication服务。如果我们单击此服务,我们将导航到 instances 选项卡。见图 8-12 。在这里,我们可以看到两个实例,每个实例都有相应的运行状况检查。请注意,当端口为默认值:8080时,Spring Boot 不会在 ID 中使用该端口。
图 8-12
领事登记处有多个实例
从 Consul 获取服务列表的 API 请求现在检索这两个服务,包括关于乘法应用的两个实例的信息。请参见清单 8-22 以获得简短的回应。
$ http -b :8500/v1/agent/services
{
"gamification-8081": {
"Address": "192.168.1.133",
"EnableTagOverride": false,
"ID": "gamification-8081",
"Meta": {},
"Port": 8081,
"Service": "gamification",
...
"Weights": {
"Passing": 1,
"Warning": 1
}
},
"multiplication": {
"Address": "192.168.1.133",
"EnableTagOverride": false,
"ID": "multiplication",
"Meta": {},
"Port": 8080,
"Service": "multiplication",
...
"Weights": {
"Passing": 1,
"Warning": 1
}
},
"multiplication-9080": {
"Address": "192.168.1.133",
"EnableTagOverride": false,
"ID": "multiplication-9080",
"Meta": {},
"Port": 9080,
"Service": "multiplication",
...
"Weights": {
"Passing": 1,
"Warning": 1
}
}
}
Listing 8-22Retrieving Registered
Services Using the Consul API
你可以想象如果我们没有 Spring 抽象,我们将如何使用 Consul 作为客户端服务。首先,所有服务都需要知道 HTTP 主机和端口才能到达注册中心。然后,如果服务想要与游戏化 API 交互,它将使用 Consul 的服务 API 来获取可用实例的列表。API 还有一个端点来检索给定服务标识符的当前健康状态信息。遵循客户端发现方法,服务将应用负载平衡(例如,循环)并从列表中挑选一个健康的实例。然后,知道了请求的目标地址和端口,客户端服务就可以执行请求。我们不需要实现这个逻辑,因为 Spring Cloud Consul 已经为我们实现了,包括我们将在下一节讨论的负载平衡。
鉴于 Gateway 是我们系统中唯一调用其他服务的服务,我们将在这里实践 Consul 的服务发现逻辑。然而,在此之前,我们需要引入我们仍然缺少的模式:负载平衡器。
Spring Cloud 负载平衡器
我们将实现一种客户端发现方法,其中后端服务查询注册表,并在有多个可用实例时决定调用哪个实例。这最后一部分是我们可以自己构建的逻辑,但是依靠工具来为我们做这件事更容易。Spring Cloud load balancer 项目是 Spring Cloud Commons 的一个组件,它与服务发现集成(Consul 和 Eureka)相集成,以提供一个简单的负载平衡器实现。默认情况下,它会自动配置一个循环负载平衡器,反复检查所有实例。
正如我们前面提到的,网飞的 Ribbon 曾经是实现负载平衡器模式的首选。因为它处于维护模式,所以让我们放弃这个选项,选择 Spring 的负载平衡器实现。Ribbon 和 Spring Cloud 负载平衡器都作为依赖项包含在 Spring Cloud Consul starter 中,但是我们可以使用配置标志或显式排除其中一个依赖项来在两者之间切换(就像我们在添加 Consul starter 时所做的那样)。
为了在两个应用之间进行负载平衡调用,我们可以在创建一个RestTemplate对象时简单地使用@LoadBalanced注释。然后,当执行对服务的请求时,我们使用服务名作为 URL 中的主机名。Spring Cloud Consul 和负载平衡器组件将完成剩下的工作,查询注册表并按顺序选择下一个实例。
在我们转向事件驱动的方法之前,我们曾经有一个从乘法服务到游戏化服务的调用,所以让我们以那个为例。清单 8-23 展示了我们如何在客户端——乘法微服务中集成服务发现和负载平衡。这也在图 8-8 中进行了说明。如您所见,我们只需要声明一个用@LoadBalanced注释配置的RestTemplate bean,并使用 URL http://gamification/attempts。注意,您不需要指定端口号,因为在联系注册表之后,它将包含在解析的实例 URL 中。
@Configuration
public class RestConfiguration {
@LoadBalanced
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
}
@Slf4j
@Service
public class GamificationServiceClient {
private final RestTemplate restTemplate;
public GamificationServiceClient(final RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public boolean sendAttempt(final ChallengeAttempt attempt) {
try {
ChallengeSolvedDTO dto = new ChallengeSolvedDTO(attempt.getId(),
attempt.isCorrect(), attempt.getFactorA(),
attempt.getFactorB(), attempt.getUser().getId(),
attempt.getUser().getAlias());
ResponseEntity<String> r = restTemplate.postForEntity(
"http://gamification/attempts", dto,
String.class);
log.info("Gamification service response: {}", r.getStatusCode());
return r.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.error("There was a problem sending the attempt.", e);
return false;
}
}
}
Listing 8-23Example of How to Use a RestTemplate with Load Balancing Capabilities
我们不会走这条路,因为我们已经摆脱了微服务之间的 HTTP 调用,但是对于那些需要服务间 HTTP 交互的场景,这是一个很好的方法。通过服务发现和负载平衡,您可以降低失败的风险,因为您增加了至少有一个实例可用于处理这些同步请求的机会。
我们的计划是在网关中集成服务发现和负载平衡。参见图 8-13 。
图 8-13
我们系统中的网关、服务发现和负载平衡
网关中的服务发现和负载平衡
在我们的应用中包含了 Spring Cloud Consul starter 之后,他们正在联系注册中心来发布他们的信息。然而,我们仍然有网关使用显式地址/端口组合来代理请求。是时候在那里集成服务发现和负载平衡了。
首先,我们将 Spring Cloud Consul 依赖项添加到 Gateway 项目中。参见清单 8-24 。同样,我们排除 Ribbon,因为我们将使用 Spring Cloud 负载平衡器。
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
Listing 8-24Adding Spring Cloud Consul Discovery to the Gateway
为了利用这些新模式,我们需要做的就是向application.yml文件添加一些配置。我们可以将变化分为三组。
-
全局设置:我们给应用命名,并确保我们使用 Spring Cloud 负载平衡器实现。此外,我们将添加一个配置参数来指示服务发现客户机只检索健康的服务。
-
路由配置:我们不使用显式的主机和端口,而是切换到具有 URL 模式的服务名称,这也支持负载平衡。
-
弹性:万一网关无法将请求代理给服务,我们希望它重试几次。我们将详细阐述这个主题。
请参见清单 8-25 ,获取我们新网关配置(application.yml)的完整源代码,包括这些更改。
server:
port: 8000
spring:
application:
name: gateway
cloud:
loadbalancer:
ribbon:
# Not needed since we excluded the dependency, but
# still good to add it here for better readability
enabled: false
consul:
enabled: true
discovery:
# Get only services that are passing the health check
query-passing: true
gateway:
routes:
- id: multiplication
uri: lb://multiplication/
predicates:
- Path=/challenges/**,/attempts,/attempts/**,/users/**
- id: gamification
uri: lb://gamification/
predicates:
- Path=/leaders
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "http://localhost:3000"
allowedHeaders:
- "*"
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
default-filters:
- name: Retry
args:
retries: 3
methods: GET,POST
Listing 8-25Gateway Configuration Including Load Balancing
当query-passing参数设置为true时,Spring 实现将使用带有过滤器的 Consul API 来检索那些通过健康检查的服务。我们只想将请求代理到健康的实例。在服务不经常轮询更新的服务列表的情况下,值false可能有意义。在这种情况下,最好获得完整的列表,因为我们不知道它们的最新状态,并且有处理不健康实例的机制(例如重试,我们很快就会知道)。
最相关的更改是应用于 URL 的更改。如你所见,现在我们使用类似于lb://multiplication/的 URL。由于我们添加了 Consul 客户端,应用将使用服务 API 将服务名multiplication解析为可用的实例。特殊方案lb告诉 Spring 应该使用负载均衡器。
除了基本配置之外,我们还添加了一个适用于所有请求的网关过滤器,因为它位于default-filters节点下:重试GatewayFilter(详见 https://tpd.io/gwretry )。该过滤器拦截错误响应,并再次透明地重试请求。当与负载平衡器结合使用时,这意味着请求将被代理到下一个实例,因此我们很容易获得一个良好的弹性模式(重试)。我们配置这个过滤器,使我们使用的 HTTP 方法最多重试三次,这足以涵盖大多数失败情况。如果所有重试都失败,网关会向客户端返回一个错误响应(服务不可用),因为它无法代理请求。
您可能想知道为什么需要在服务发现客户机中包含重试,尽管我们配置它只获取健康的实例。理论上,如果他们都很健康,所有的呼叫都会成功。为了理解这一点,我们必须回顾一下 Consul(以及其他典型的服务发现工具)是如何工作的。每个服务都向自己注册一个配置好的健康检查,每十秒钟轮询一次(默认值,但我们也可以更改它)。注册中心无法实时了解服务何时未准备好处理流量。可能的情况是,Consul 成功地检查了一个给定实例的健康状况,然后一个实例立即停止运行。注册中心将这个实例列为健康状态几秒钟(对于我们的配置几乎是 10 秒钟),直到它在下一次检查中注意到它不可用。因为我们也想在这段时间内最小化请求错误,所以我们可以利用重试模式来处理这些情况。一旦注册表得到更新,网关将不会在服务列表中获得不健康的实例,因此不再需要重试。请注意,缩短检查之间的时间可以减少错误数量,但会增加网络流量。
断路器
在某些情况下,当您知道某个给定的服务失败后,您可能不想继续尝试该服务的未来请求。通过这样做,您可以节省响应超时所浪费的时间,并减轻目标服务的潜在拥塞。当没有其他合适的弹性机制(如带有健康检查的服务注册中心)时,这对于外部服务调用尤其有用。
对于这些情况,您可以使用断路器。当一切正常时,电路闭合*。在可配置的请求失败次数后,电路变为打开。然后,甚至不尝试请求,断路器实现返回预定义的响应。有时,电路可以切换到半开以再次检查目标服务是否工作。在这种情况下,电路将转换到关闭。如果仍然失败,它返回到打开状态。查看 https://tpd.io/cbreak 了解更多关于此模式的信息。*
*在应用新配置后,网关微服务连接到 Consul,以查找其他微服务的可用实例及其网络位置。然后,它基于 Spring Cloud 负载平衡器中包含的简单循环算法来平衡负载。再次检查图 8-8 以获得完整的概述。
鉴于我们添加了 Consul starter,网关服务也在 Consul 中注册了自己。这不是绝对必要的,因为其他服务不会调用网关,但是检查它的状态对我们来说仍然是有用的。或者,我们可以将配置参数spring.cloud.consul.discovery.register设置为false以继续使用服务发现客户端特性,但禁用网关服务的注册。
在我们的设置中,所有外部 HTTP 流量(不是微服务之间的)都通过localhost:8000通过网关微服务。在生产环境中,我们通常会在端口 80(或者 443,如果我们使用 HTTPS)上公开这个 HTTP 接口,并使用一个 DNS 地址(例如,bookgame.tpd.io)指向我们的服务器所在的 IP。然而,将有一个单一的入口供公众进入,这使得这项服务成为我们系统的一个关键部分。它必须尽可能地高度可用。如果网关服务瘫痪,我们的整个系统都会瘫痪。
为了降低风险,我们可以引入 DNS 负载平衡(指向多个 IP 地址的主机名)来增加网关的冗余。然而,当其中一台主机没有响应时,它依靠客户端(如浏览器)来管理 IP 地址列表和处理故障转移(参见 https://tpd.io/dnslbq 了解解释)。我们可以将其视为网关之上的额外一层,它增加了客户端发现(对 IP 地址列表的 DNS 解析)、负载平衡(从列表中选择一个 IP 地址)和容错(在超时或出错后尝试另一个 IP 地址)。这不是典型的方法。
亚马逊、微软或谷歌等云提供商提供路由和负载平衡模式作为具有高可用性保证的托管服务,因此这也是确保网关始终保持运行的一种替代方法。另一方面,Kubernetes 允许您在自己的网关上创建一个负载均衡器,因此您也可以向该层添加冗余。我们将在本章末尾看到更多关于平台实现的内容。
体验服务发现和负载平衡
让我们将服务发现和负载平衡特性付诸实践。
在运行我们的应用之前,我们将为UserController(乘法)和LeaderBoardController(游戏化)添加一个日志行,以便在日志中快速查看与它们的 API 的交互。参见清单 8-26 和 8-27 。
@Slf4j
@RestController
@RequestMapping("/leaders")
@RequiredArgsConstructor
class LeaderBoardController {
private final LeaderBoardService leaderBoardService;
@GetMapping
public List<LeaderBoardRow> getLeaderBoard() {
log.info("Retrieving leaderboard");
return leaderBoardService.getCurrentLeaderBoard();
}
}
Listing 8-27Adding
a Log Line to LeaderBoardController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{idList}")
public List<User> getUsersByIdList(@PathVariable final List<Long> idList) {
log.info("Resolving aliases for users {}", idList);
return userRepository.findAllByIdIn(idList);
}
}
Listing 8-26Adding a Log Line to UserController
现在,让我们运行完整的系统。所需的步骤与之前相同,只是添加了运行服务注册中心的新命令:
-
运行 RabbitMQ 服务器。
-
在开发模式下运行 Consul 代理。
-
启动乘法微服务。
-
启动游戏化微服务。
-
启动新的网关微服务。
-
运行前端 app。
一旦我们运行了最小化设置,我们就为运行业务逻辑的每个服务添加了一个额外的实例:乘法和游戏化。请记住,您需要覆盖server.port属性。从一个终端,你可以在两个独立的标签或窗口中使用清单 8-28 中显示的命令(注意你运行每个命令的文件夹是不同的)。
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9080"
[... logs ...]
gamification $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=9081"
[... logs ...]
Listing 8-28Running Two Additional Instances of Multiplication and Gamification
所有实例都将在注册表中发布它们的详细信息(Consul)。此外,它们都可以作为注册中心客户机来检索给定服务名的不同实例的详细信息以及它们的位置。在我们的系统中,这只能从网关完成。启动图 8-14 中的两个额外实例后,查看 Consul UI 中的服务概述(节点/服务部分)。
图 8-14
领事:多个实例
验证网关的负载平衡器是否工作很简单:检查两个游戏化服务实例的日志。通过新添加的日志行,您可以快速看到两者如何从 UI 获得与排行榜更新相关的交替请求。如果您刷新浏览器页面几次以强制新的质询请求,您会看到类似的行为。清单 8-29 、 8-30 、 8-31 和 8-32 显示了乘法实例和游戏化实例的日志的同一时间窗口的摘录。如您所见,请求每五秒钟在可用实例之间交替。
2020-08-29 09:04:58.107 INFO 10222 --- [nio-9081-exec-4] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:06.927 INFO 10222 --- [nio-9081-exec-6] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:14.010 INFO 10222 --- [nio-9081-exec-8] m.b.g.game.LeaderBoardController : Retrieving leaderboard
Listing 8-32Logs for Gamification, Second Instance (Port 9081)
2020-08-29 09:05:03.208 INFO 9928 --- [nio-8081-exec-6] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:09.006 INFO 9928 --- [nio-8081-exec-8] m.b.g.game.LeaderBoardController : Retrieving leaderboard
2020-08-29 09:05:19.014 INFO 9928 --- [io-8081-exec-10] m.b.g.game.LeaderBoardController : Retrieving leaderboard
Listing 8-31Logs for Gamification, First Instance (Port 8081)
2020-08-29 09:05:09.009 INFO 10138 --- [nio-9080-exec-7] m.b.m.challenge.ChallengeController : Generating a random challenge: Challenge(factorA=58, factorB=96)
2020-08-29 09:05:14.040 INFO 10138 --- [nio-9080-exec-8] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
2020-08-29 09:05:24.042 INFO 10138 --- [io-9080-exec-10] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
Listing 8-30Logs for Multiplication, Second Instance (Port 9080)
2020-08-29 09:05:06.957 INFO 9999 --- [nio-8080-exec-6] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
2020-08-29 09:05:09.090 INFO 9999 --- [nio-8080-exec-7] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
2020-08-29 09:05:19.033 INFO 9999 --- [nio-8080-exec-9] m.b.multiplication.user.UserController : Resolving aliases for users [125, 49, 72, 60, 101, 1, 96, 107, 3, 45, 6, 9, 14, 123]
Listing 8-29Logs for Multiplication, First Instance (Port 8080)
这是一个伟大的成就:我们扩大了我们的系统,一切都像预期的那样工作。HTTP 流量现在在所有实例中均衡分布,类似于我们的 RabbitMQ 设置在消费者中分发消息的方式。我们刚刚顺利地将系统容量增加了一倍。
实际上,我们可以启动任意多的两个微服务实例,负载将透明地分布在所有微服务上。此外,通过网关,我们使我们的 API 客户端不知道我们的内部服务,我们可以轻松地实现跨领域的关注,如用户认证或监控。
我们还应该检查我们是否实现了其他非功能性需求:弹性、高可用性和容错。让我们开始制造一些混乱。
为了检查当服务变得意外不可用时会发生什么,我们可以通过 IDE 或者在终端中使用 Ctrl-C 信号来停止它们。然而,这并没有涵盖我们在现实生活中可能遇到的所有潜在事件。当我们这样做时,Spring Boot 应用会正常停止,因此它有机会从 Consul 中注销自己。我们想模拟一个重大事件,比如网络问题或服务突然终止。我们必须模仿的最佳选择是终止给定实例的 Java 进程。要知道要终止哪个进程,我们可以检查日志。默认的 Spring Boot 回退配置在每个日志行的日志级别(例如,INFO)后打印进程 ID。例如,这一行表示我们正在运行游戏化微服务,进程 ID 为97817:
2020-07-19 09:10:27.279 INFO 97817 --- [main] m.b.m.GamificationApplication : Started GamificationApplication in 5.371 seconds (JVM running for 11.054)
在 Linux 或 Mac 系统中,您可以使用kill命令终止进程,传递参数-9来强制立即终止。
$ kill -9 97817
如果您正在运行 Windows,您可以使用带有/F标志的taskkill命令来强制终止它。
> taskkill /PID 97817 /F
既然你知道了如何制造破坏,那就干掉游戏化微服务的一个实例进程。确保您在浏览器中打开了 UI,以便它不断向后端发出请求。您将看到在您杀死其中一个实例后,另一个实例如何接收所有请求并成功响应它们。用户甚至不会注意到这一点。在游戏化服务中调用 API 的排行榜仍然在工作。用户也可以发送新的挑战;所有尝试都将在唯一可用的实例中结束。这里发生的事情是,网关中的重试过滤器透明地执行第二个请求,由于负载均衡器,该请求被路由到健康的实例。参见图 8-15 。
图 8-15
弹性:重试模式
我们还想验证我们引入的模式是如何协作实现这一成功结果的。为此,让我们暂时删除网关中的重试过滤器配置。参见清单 8-33 。
# We can comment this block of the configuration
# default-filters:
# - name: Retry
# args:
# retries: 3
# methods: GET,POST
Listing 8-33Commenting a Block of Configuration in the Gateway
然后,重新构建并重启网关服务(以应用新的配置),并重复类似的场景。确保再次启动游戏化的第二个实例,并给网关服务一些时间来开始向它路由流量。然后,在查看 UI 时,杀死它的一个实例。这次您将看到的是,其他所有请求都无法完成,导致显示排行榜时出现交替错误。发生这种情况是因为 Consul 需要一些时间(配置的健康检查间隔)来检测服务是否关闭。同时,网关仍然得到两个健康的实例,并将一些请求代理到一个失效的服务器。参见图 8-16 。我们刚刚删除的重试机制透明地处理了这个错误,并向列表中的下一个实例发出第二个请求,这个实例仍然在工作。
图 8-16
弹性:无重试模式
它在我的机器上工作
请注意,如果您在运行状况检查之前意外地终止了进程,Consul 将会立即注意到这个问题。在这种情况下,您可能看不到 UI 中的错误。您可以再次尝试相同的场景,或者您可以在 Spring Cloud Consul 中配置一个更长的健康检查间隔(通过应用属性),这样您就有更大的机会重现这个错误场景。
如果我们导航到服务,Consul registry UI 也会反映出运行状况检查失败。参见图 8-17 。
图 8-17
领事 UI:终止服务后运行状况检查失败
我们完成了学习道路上的一个重要里程碑:我们在微服务架构中实现了可伸缩性。此外,我们通过使用服务发现注册表的负载平衡器实现了适当的容错,该服务发现注册表知道我们系统中不同组件的健康状况。我希望实用的方法能帮助你理解所有这些关键概念。
每个环境的配置
正如在第二章中介绍的,Spring Boot 的一个主要优点是能够配置概要文件。配置文件是一组您可以根据需要启用的配置属性。例如,在本地测试时,您可以在连接到本地 RabbitMQ 服务器和在生产环境中部署真正的 RabbitMQ 服务器之间进行切换。
为了引入一个新的rabbitprod概要文件,我们可以创建一个名为application-rabbitprod.properties的文件。Spring Boot 使用application-{profile}命名约定(用于properties和 YAML 格式)来允许我们在单独的文件中定义概要文件。参见清单 8-34 中我们可以包括的一些示例属性。如果我们将此配置文件用于生产环境,我们可能希望使用不同的凭证、要连接的节点集群、安全接口等。
spring.rabbitmq.addresses=rabbitserver1.tpd.network:5672,rabbitserver2.tpd.network:5672
spring.rabbitmq.connection-timeout=20s
spring.rabbitmq.ssl.enabled=true
spring.rabbitmq.username=produser1
Listing 8-34Example of a Separate Properties File to Override Default Values in Production
当我们在目标环境中启动应用时,我们必须确保启用这个概要文件。为此,我们使用属性spring.profiles.active。Spring Boot 将基本配置(在application.properties中)与该文件中的值聚合在一起。在我们的例子中,所有额外的属性都将被添加到最终的配置中。我们可以使用 Spring Boot 的 Maven 插件命令来为乘法微服务启用这个新的配置文件:
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=rabbitprod"
正如您所想象的,我们所有的微服务在每个环境中都有很多通用的配置值。不仅 RabbitMQ 的连接细节可能是相同的,而且我们还添加了一些额外的值,比如交换名(amqp.exchange.attempts)。这同样适用于数据库的通用配置,或者我们希望应用于所有微服务的任何其他 Spring Boot 配置。
我们可以将这些值保存在每个微服务、每个环境和每个工具的单独文件中。例如,这四个文件可能包括在登台和生产环境中对 RabbitMQ 和 H2 数据库的不同配置。
-
application-rabbitprod.properties -
application-databaseprod.properties -
application-rabbitstaging.properties -
application-databasestaging.properties
然后,我们可以在任何需要的地方跨微服务复制它们。通过将配置分组到单独的概要文件中,我们可以很容易地重用这些值。
然而,保留所有这些副本仍然需要大量的维护工作。如果我们想改变这些公共配置块中的一个值,我们必须替换每个项目文件夹中的相应文件。
一个更好的方法是将这个配置放在系统中的一个公共位置,让应用在启动前同步它的内容。然后,我们为每个环境保留一个集中的配置,所以我们只需要调整一次值。参见图 8-18 。好消息是,这是一种众所周知的模式,被称为外化(或集中式)配置,因此有现成的解决方案来构建集中式配置服务器。
图 8-18
集中式配置:概述
在为 Spring 寻找配置服务器模式时,通过简单的 web 搜索得出的第一个解决方案是 Spring Cloud Config Server 项目。这是 Spring Cloud 家族中包含的一个原生实现,它允许您将一组配置文件分布在文件夹中,并通过 REST API 公开。在客户端,使用这种依赖关系的项目访问配置服务器并请求相应的配置资源,这取决于它们的活动概要文件。对于我们的系统来说,这个解决方案的唯一缺点是我们需要创建另一个微服务来充当配置服务器并公开集中的文件。
另一种方法是使用 Consul KV,这是默认 Consul 包中包含的一个特性,我们还没有探索它。Spring Cloud 还集成了这个工具来实现一个集中式配置服务器。我们将选择这种方法来重用组件,并使我们的系统尽可能简单,Consul 结合了服务发现、健康检查和集中配置。
咨询中的配置
Consul KV 是随 Consul 代理一起安装的键/值存储。与服务发现特性一样,我们可以通过 REST API 和用户界面来访问该功能。当我们将 Consul 设置为集群时,该特性也受益于复制,因此由于服务无法获取其配置而导致数据丢失或停机的风险更小。
这个简单的功能也可以从浏览器访问,因为它包含在我们已经安装的 consul 代理中。代理运行时,导航到http://localhost:8500/ui/dc1/kv(键/值选项卡)。现在,单击创建。您将看到编辑器创建了一个新的键/值对,如图 8-19 所示。
图 8-19
Consul:创建键/值对
我们可以使用 toggle 在代码和普通编辑器之间切换。代码编辑器在一些符号中支持语法着色,包括 YAML。注意,如文本字段下的图 8-19 所示,如果我们在键名末尾添加一个正斜杠字符,我们也可以创建文件夹。我们很快就会付诸实施。
Consul KV REST API 还允许我们通过 HTTP 调用创建键/值对和文件夹,并使用它们的键名检索它们。如果你想知道它是如何工作的,可以查看 http://tpd.io/kv-api 。与服务发现特性一样,我们不需要直接与这个 API 交互,因为我们将使用一个 Spring 抽象来与 Consul KV:Spring Cloud Consul Config 通信。
SpringCloud 领事配置
用 Consul KV 实现集中配置的 SpringCloud 项目是 SpringCloud Consul Config。要使用这个模块,我们需要向我们的项目添加一个新的 Spring Cloud 依赖项:spring-cloud-starter-consul-config。这个工件包括自动配置类,这些类在启动我们的应用的早期阶段,即特殊的“引导”阶段,试图找到 Consul 代理并读取相应的 KV 值。它使用这个阶段是因为我们希望 Spring Boot 将集中配置值应用于其初始化的其余部分(例如,连接到 RabbitMQ)。
Spring Cloud Consul Config 期望每个配置文件映射到 KV store 中的一个给定键。它的值应该是一组 YAML 或普通格式的 Spring Boot 配置值(.properties)。
我们可以配置一些设置来帮助我们的应用在服务器中找到相应的键。这些是最相关的:
-
前缀:这是领事 KV 中存储所有概要文件的根文件夹。默认值为
config。 -
格式:指定值(Spring Boot 配置)是 YAML 还是属性语法。
-
默认上下文:这是所有应用作为公共属性使用的文件夹的名称。
-
配置文件分隔符:按键可以组合多个配置文件。在这种情况下,您可以指定想要用作分隔符的字符(例如,用逗号
prod,extra-logging)。 -
数据键:这是保存属性或 YAML 内容的键的名称。
与配置服务器设置相关的所有配置值必须放在我们每个应用的单独文件中,文件名为bootstrap.yml或bootstrap.properties(取决于我们选择的格式)。见图 8-20 。
图 8-20
配置服务器属性:解释
请记住,如上图所示,连接到配置服务器的应用配置(在引导文件中)与合并本地属性(如application.properties)和从配置服务器下载的属性所产生的应用配置是不同的。因为第一个是元配置,它不能从服务器下载,所以我们必须在相应的bootstrap配置文件中跨项目复制这些值。
如果没有例子,所有这些概念都很难理解,让我们用我们的系统来解释 Consul Config 是如何工作的。
实施集中配置
代码源
来自 Consul 的集成集中式配置解决方案的代码源位于存储库chapter08c中。
首先,我们需要将新的启动器添加到乘法、游戏化和网关微服务中。参见清单 8-35 。
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-config</artifactId>
</dependency>
</dependencies>
Listing 8-35Adding the Spring Cloud Consul Config Dependency to Our Microservices
通过这样做,我们的应用将在引导阶段尝试连接到 Consul,并使用 Consul KV 自动配置中提供的缺省值从 KV 存储中获取配置文件属性。但是,我们将覆盖其中的一些设置,而不是使用默认值,因为这样会使解释更加清晰。
在乘法和游戏化项目中我们使用的是properties格式,所以让我们保持一致,在同一层创建一个单独的文件,命名为bootstrap.properties。在这两个应用中,我们将设置相同的设置。见清单 8-36 。
spring.cloud.consul.config.prefix=config
spring.cloud.consul.config.format=yaml
spring.cloud.consul.config.default-context=defaults
spring.cloud.consul.config.data-key=application.yml
Listing 8-36The new bootstrap.properties File in Multiplication and Gamification
注意,我们选择 YAML 作为远程配置的格式,但是我们的本地文件是.properties格式。那根本不是问题。Spring Cloud Consul Config 可以将包含在 remote application.yml键中的值与以不同格式存储在本地的值进行合并。
然后,我们在 Gateway 项目的一个bootstrap.yml文件中创建等效的设置,其中我们使用 YAML 进行应用配置。参见清单 8-37 。
spring:
cloud:
consul:
config:
data-key: application.yml
prefix: config
format: yaml
default-context: defaults
Listing 8-37The New bootstrap.yml File in the Gateway Project
通过这些设置,我们的目标是将所有配置存储在 Consul KV 中名为config的根文件夹中。在里面,我们将有一个defaults文件夹,其中可能包含一个名为application.yml的密钥,其配置适用于我们所有的微服务。我们可以为每个应用或者我们想要使用的应用和配置文件的每个组合创建额外的文件夹,并且每个文件夹都可以包含带有应该添加或覆盖的属性的application.yml键。为了避免在配置服务器中混淆格式,我们将坚持使用 YAML 语法。再次回顾之前的图 8-20 以更好地理解配置的整体结构。到目前为止,我们所做的是将bootstrap文件添加到乘法、游戏化和网关中,这样它们就可以连接到配置服务器并找到外部化的配置(如果有的话)。为了支持这种行为,我们也向所有这些项目添加了 Spring Cloud Consul Config starter 依赖项。
举一个更有代表性的例子,我们可以创建清单 8-38 中所示的层次结构,作为 Consul KV 中的文件夹和键。
+- config
| +- defaults
| \- application.yml
| +- defaults,production
| \- application.yml
| +- defaults,rabbitmq-production
| \- application.yml
| +- defaults,database-production
| \- application.yml
| +- multiplication,production
| \- application.yml
| +- gamification,production
| \- application.yml
Listing 8-38An Example Configuration Structure in the Configuration Server
然后,如果我们使用等于production,rabbitmq-production,database-production的活动配置文件列表运行乘法应用,处理顺序如下(从低到高的优先级):
-
基线值是那些包含在访问配置服务器的项目的本地
application.properties中的值,在这个例子中是乘法。 -
然后,Spring Boot 合并并覆盖包含在
defaults文件夹中的application.yml键中的远程值,因为它适用于所有服务。 -
下一步是合并所有活动概要文件的默认值。这意味着所有符合
defaults,{profile}模式的文件:defaults,production、defaults,rabbitmq-production、defaults,database-production。请注意,如果指定了多个概要文件,则最后一个概要文件的值有效。 -
之后,它会按照模式
{application},{profile}为相应的应用名称和活动配置文件寻找更具体的设置。在我们的例子中,键multiplication,production匹配模式,所以它的配置值将被合并。优先顺序与之前相同:枚举中的最后一个配置文件胜出。
请参见图 8-21 中的直观表示,它肯定会帮助您理解所有配置文件是如何应用的。
图 8-21
配置堆栈示例
因此,结构化配置值的实用方法如下:
-
当您想要为所有环境的所有应用添加全局配置时,例如定制 JSON 序列化时,请使用
defaults。 -
使用带有代表一个
{tool}-{environment}对的概要文件名的defaults,{profile},为每个环境的给定工具设置公共值。例如,在我们的例子中,RabbitMQ 连接值可以包含在rabbitmq-production中。 -
使用配置文件名为
{environment}的{application},{profile},为给定环境中的应用设置特定设置。例如,我们可以使用multiplication,production中的属性来减少生产中乘法微服务的日志记录。
实践中的集中配置
在上一节中,我们将新的 starter 依赖项添加到我们的项目中,并添加了额外的bootstrap配置属性来覆盖一些 Consul 配置默认值。如果我们启动一个服务,它将连接到 Consul,并尝试使用 Consul 的键/值 API 检索配置。例如,在乘法应用的日志中,我们会看到一个新的行,其中包含了 Spring 试图在远程配置服务器(Consul)中找到的属性源的列表。参见清单 8-39 。
INFO 54256 --- [main] b.c.PropertySourceBootstrapConfiguration : Located property source: [BootstrapPropertySource {name='bootstrapProperties-config/multiplication/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults/'}]
Listing 8-39Multiplication Logs, Indicating the Default Configuration Sources
该日志行可能会产生误导,因为在打印该行时,那些属性源并没有真正定位。只是候选人名单。他们的名字符合我们之前描述的模式。假设我们在启动乘法应用时没有启用任何配置文件,它将尝试在config/defaults和config/multiplication下查找配置。正如本练习所证明的,我们不需要在 Consul 中创建匹配所有可能候选项的键。不存在的键将被忽略。
让我们开始在 Consul 中创建一些配置。在 UI 的 Key/Value 选项卡上,单击 Create 并输入config/来创建根文件夹,其名称与我们在设置中配置的名称相同。由于我们在最后添加了/字符,Consul 知道它必须创建一个文件夹。见图 8-22 。
图 8-22
Consul:创建配置根文件夹
现在,通过单击新创建的项目导航到config文件夹,并创建一个名为defaults的子文件夹。见图 8-23 。
图 8-23
Consul:创建默认文件夹
再次通过单击导航到新创建的文件夹。您将会看到config/defaults的内容,目前是空的。在这个文件夹中,我们必须创建一个名为application.yml的键,并将我们希望默认应用于所有应用的值放在那里。请注意,我们决定使用一个看起来像文件名的键名,以便更好地区分文件夹和配置内容。让我们添加一些日志配置来启用 Spring 包的DEBUG级别,该包的类输出一些有用的环境信息。参见图 8-24 。
图 8-24
Consul:将配置添加到默认值
乘法应用现在应该选择这个新属性。为了验证它,我们可以重启它并检查日志,在这里我们将看到对org.springframework.core.env包的额外日志记录,特别是来自PropertySourcesPropertyResolver类的:
DEBUG 61279 --- [main] o.s.c.e.PropertySourcesPropertyResolver : Found key 'spring.h2.console.enabled' in PropertySource 'configurationProperties' with value of type String
这证明服务到达了集中配置服务器(Consul)并应用了包含在现有预期密钥中的设置,在本例中为config/defaults。
为了更有趣,让我们为乘法应用启用一些配置文件。从命令行,您可以执行以下操作:
multiplication $ ./mvnw spring-boot:run -Dspring-boot.run.arguments="--spring.profiles.active=production,rabbitmq-production"
使用这个命令,我们用production和rabbitmq-production概要文件运行应用。日志显示了要查找的结果候选键。见清单 8-40 。
INFO 52274 --- [main] b.c.PropertySourceBootstrapConfiguration : Located property source: [BootstrapPropertySource {name='bootstrapProperties-config/multiplication,rabbitmq-production/'}, BootstrapPropertySource {name='bootstrapProperties-config/multiplication,production/'}, BootstrapPropertySource {name='bootstrapProperties-config/multiplication/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults,rabbitmq-production/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults,production/'}, BootstrapPropertySource {name='bootstrapProperties-config/defaults/'}]
Listing 8-40Multiplication Logs, Indicating All Candidate Property Sources After Enabling Extra Profiles
为了更好的可视化,让我们将属性源名称提取为一个列表。该列表遵循与日志中相同的顺序,从最高优先级到最低优先级。
-
config/multiplication,rabbitmq-production/ -
config/multiplication,production/ -
config/multiplication/ -
config/defaults,rabbitmq-production/ -
config/defaults,production/ -
config/defaults/
正如我们在上一节中所描述的,Spring 寻找由组合defaults和每个概要文件产生的键,然后它寻找应用名称和每个概要文件的组合。到目前为止,我们只添加了一个键config/defaults,所以这是服务选择的唯一一个键。
在现实生活中,我们可能不希望将日志添加到生产环境中的所有应用中。为了实现这一点,我们可以配置production概要文件来恢复我们之前所做的。因为这个配置有更高的优先级,它将覆盖以前的值。转到 Consul UI,在config文件夹中创建一个名为defaults,production的密钥。在内部,您必须创建一个键application.yml,它的值应该是 YAML 配置,以将包的日志级别设置回INFO。见图 8-25 。
图 8-25
咨询:将配置添加到默认值,生产
当我们使用相同的最后一个命令(启用production概要文件)重新启动应用时,我们将看到该包的调试日志记录是如何消失的。
请记住,与我们在这个简单的日志示例中所做的一样,我们可以添加 YAML 值来调整我们的 Spring Boot 应用中的任何其他配置参数,以使它们适应production环境。另外,请注意我们如何使用前面列出的六种可能的组合中的任何一种来处理配置的范围,这是我们通过添加两个活动概要文件得到的。例如,我们可以在名为defaults,rabbitmq-production的键中添加只适用于 RabbitMQ 的值。最具体的组合是multiplication,rabbitmq-production和multiplication,production。如果需要,再次查看图 8-21 以获得一些视觉帮助。
为了证明配置不限于日志记录,让我们假设我们希望在部署到生产时在不同的端口(例如 10080)上运行乘法微服务。为了让它工作,我们只需要在 Consul 的multiplication,production键中添加一个application.yml键,并更改server.port属性。参见图 8-26 。
图 8-26
领事:增加配置到乘法,生产
下次我们在生产配置文件处于活动状态时启动乘法应用,我们将看到它是如何在这个新指定的端口上运行的:
INFO 29019 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 10080 (http) with context path ''
通过这个练习,我们完成了对集中式配置模式的概述。现在,我们知道了如何最大限度地减少公共配置的维护,以及如何使应用适应它们运行的环境。请参见图 8-27 了解我们系统的更新架构视图,包括新的配置服务器。
图 8-27
高级概述:配置服务器
请注意,应用现在在启动时依赖于配置服务器。幸运的是,我们可以将 Consul 配置为在生产中高度可用,正如我们在讨论服务发现模式时提到的(检查 https://tpd.io/consulprod )。此外,Spring Cloud Consul 在默认情况下使用重试机制,所以我们的应用会在 consult 不可用时不断重试连接。这种依赖性仅在开始时存在;如果 Consul 在您的应用运行时关闭,它们会继续使用最初加载的配置。
注意:咨询配置和测试
默认情况下,我们项目中的集成测试将使用相同的应用配置。这意味着如果 Consul 没有运行,我们的控制器测试和 Initializr 创建的默认@SpringBootTest将会失败,因为它们一直在等待配置服务器可用。您也可以很容易地禁用测试的 Consul Config 如果你好奇的话,可以查看一下 https://github.com/Book-Microservices-v2/chapter08c 。
集中式日志
我们的系统中已经有多个生成日志的组件(乘法、游戏化、网关、咨询和 RabbitMQ),其中一些可能运行多个实例。大量日志输出独立运行,这使得很难获得系统活动的整体视图。如果用户报告一个错误,很难找出哪个组件或实例失败了。在单个屏幕上安排多个日志窗口暂时会有帮助,但当您的微服务实例数量增加时,这不是一个可行的解决方案。
为了正确维护像我们的微服务架构这样的分布式系统,我们需要一个中心位置,在那里我们可以访问所有的聚合日志并在它们之间进行搜索。
日志聚合模式
基本上,我们的想法是将应用的所有日志输出发送到系统中的另一个组件,该组件将使用它们并将它们放在一起。此外,我们希望将这些日志保存一段时间,因此这个组件应该有一个数据存储。理想情况下,我们应该能够浏览这些日志,搜索并过滤出每个微服务、实例、类等的消息。为此,许多工具都提供了连接到聚合日志存储的用户界面。参见图 8-28 。
图 8-28
日志聚集:概述
在实现集中式日志记录方法时,一个常见的最佳实践是让应用逻辑不知道这种模式。服务应该使用公共接口(例如 Java 中的Logger)输出消息。将这些日志传送到中央聚合器的日志代理独立工作,捕获应用产生的输出。
市场上有这种模式的多种实现,包括免费的和付费的解决方案。其中最受欢迎的是 ELK stack,这是 Elastic ( https://tpd.io/elastic )产品组合的别名:Elasticsearch(具有强大文本搜索功能的存储系统)、Logstash(将来自多个来源的日志引导到 Elasticsearch 的代理)和 Kibana(管理和查询日志的 UI 工具)。
尽管随着时间的推移,设置 ELK 堆栈变得越来越容易,但这仍然不是一项简单的任务。因此,我们不会在本书中使用 ELK 实现,因为它很容易扩展到涵盖整个章节。无论如何,我建议您在阅读完这本书后查看 ELK 文档( https://tpd.io/elk ),这样您就可以学习如何建立一个生产就绪的日志系统。
日志集中化的简单解决方案
代码源
本章的其余代码源在资源库chapter08d中。它包括添加集中式日志、分布式跟踪和容器化的变化。
我们要做的是建立一个新的微服务来聚合所有 Spring Boot 应用的日志。简单地说,它没有数据层来保存日志;它只是从其他服务接收日志行,并将它们一起打印到标准输出中。这个基本解决方案将帮助我们演示这个模式和下一个模式,分布式跟踪。
为了引导日志输出,我们将使用系统中已经有的工具:RabbitMQ。为了捕获应用中的每一行日志并作为 RabbitMQ 消息发送,我们将受益于 Logback,这是我们在 Spring Boot 使用的日志实现。假设这个工具是由外部配置文件驱动的,我们不需要修改应用中的代码。
在 Logback 中,将日志行写到特定目的地的逻辑部分被称为追加器。这个日志库包括一些内置的附加器,用于将消息打印到控制台(ConsoleAppender)或文件(FileAppender和RollingFileAppender)。我们不需要配置它们,因为 Spring Boot 在其依赖项中包含了一些默认的回退配置,并且还设置了打印的消息模式。
对我们来说,好消息是 Spring AMQP 提供了一个 Logback AMQP 日志附加器,它完全满足了我们的需求:它接受每个日志行,并向 RabbitMQ 中的给定交换生成一条消息,带有我们可以定制的格式和一些额外选项。
首先,让我们准备需要添加到应用中的日志返回配置。Spring Boot 允许我们通过在应用资源文件夹(src/main/resources)中创建一个名为logback-spring.xml的文件来扩展默认设置,该文件将在应用初始化时被自动提取。参见清单 8-41 。在这个文件中,我们导入现有的默认值,并为所有级别为 INFO 或更高级别的消息创建和设置一个新的 appender。AMQP 附加器文档( https://tpd.io/amqp-appender )列出了所有参数及其含义;让我们详细列出我们需要的。
-
applicationId:我们将它设置为应用名,这样我们可以在聚合日志时区分来源。 -
host:这是运行 RabbitMQ 的主机。由于每个环境都不同,我们将这个值连接到 Spring 属性spring.rabbitmq.host。Spring 允许我们通过标签springProperty来做到这一点。我们给这个回退属性命名为rabbitMQHost,并且我们使用语法${rabbitMQHost:-localhost}来使用属性值(如果设置了的话)或者使用默认值localhost(默认值是用:-分隔符设置的)。 -
routingKeyPattern:这是每条消息的路由键,如果我们想在消费者端过滤,为了更大的灵活性,我们将它设置为 applicationId 和 level(用%p表示)的连接。 -
exchangeName:我们在 RabbitMQ 中指定交易所的名称来发布消息。默认会是话题交换,所以我们可以称之为logs.topic。 -
declareExchange:我们将它设置为true来创建交换,如果它还不存在的话。 -
durable:也将此设置为true,这样交换就不会受到服务器重启的影响。 -
deliveryMode:我们将它设为PERSISTENT,这样日志消息会被存储起来,直到被聚合器使用。 -
generateId:我们将它设置为true,这样每条消息都有一个唯一的标识符。 -
charset:将它设置为UTF-8是一个很好的做法,以确保各方使用相同的编码。
清单 8-41 显示了游戏化项目中logback-spring.xml文件的全部内容。请注意我们是如何将一个带有自定义pattern的layout添加到新的 appender 中的。这样,我们可以对我们的消息进行编码,不仅包括消息(%msg),还包括一些额外的信息,比如时间(%d{HH:mm:ss.SSS})、线程名([%t])和日志类(%logger{36})。如果您对模式符号感兴趣,可以查看 Logback 的参考文档( https://tpd.io/logback-layout )。文件的最后一部分配置根记录器(默认的)来使用在一个包含文件中定义的CONSOLE appender 和新定义的AMQP appender。
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<springProperty scope="context" name="rabbitMQHost" source="spring.rabbitmq.host"/>
<appender name="AMQP"
class="org.springframework.amqp.rabbit.logback.AmqpAppender">
<layout>
<pattern>%d{HH:mm:ss.SSS} [%t] %logger{36} - %msg</pattern>
</layout>
<applicationId>gamification</applicationId>
<host>${rabbitMQHost:-localhost}</host>
<routingKeyPattern>%property{applicationId}.%p</routingKeyPattern>
<exchangeName>logs.topic</exchangeName>
<declareExchange>true</declareExchange>
<durable>true</durable>
<deliveryMode>PERSISTENT</deliveryMode>
<generateId>true</generateId>
<charset>UTF-8</charset>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="AMQP" />
</root>
</configuration>
Listing 8-41New logback-spring.xml File in the Gamification Project
现在,我们必须确保将这个文件添加到我们的三个 Spring Boot 项目中:乘法、游戏化和网关。在其中的每一个中,我们必须相应地改变applicationId值。
除了这个日志生成器的基本设置之外,我们可以调整 appender 用来连接 RabbitMQ 的类的日志级别,如WARN。这是一个可选步骤,但是当 RabbitMQ 服务器不可用时(例如,在启动我们的系统时),它可以避免数百个日志。由于 appender 是在引导阶段配置的,我们将根据项目将这个配置设置添加到相应的bootstrap.properties和boostrap.yml文件中。参见清单 8-42 和 8-43 。
logging:
level:
org.springframework.amqp.rabbit.connection.CachingConnectionFactory: WARN
Listing 8-43Reducing RabbitMQ Logging Level in the Gateway
logging.level.org.springframework.amqp.rabbit.connection.CachingConnectionFactory = WARN
Listing 8-42Reducing RabbitMQ Logging Level in Multiplication and Gamification
下次我们启动应用时,所有日志不仅会输出到控制台,还会作为消息输出到 RabbitMQ 中的logs.topic交换。您可以通过在localhost:15672访问 RabbitMQ Web UI 来验证这一点。见图 8-29 。
图 8-29
RabbitMQ UI:日志交换
使用日志并打印它们
现在,我们已经将所有日志一起发布到了一个 exchange,我们将构建消费者端:一个新的微服务,它使用所有这些消息并将它们一起输出。
首先,导航到 Spring Initializr 站点 start.spring.io ( https://start.spring.io/ )并使用我们为其他应用选择的相同设置创建一个logs项目:Maven 和 JDK 14。在依赖项列表中,我们添加了 Spring for RabbitMQ、Spring Web、验证、Spring Boot 执行器、Lombok 和 Consul 配置。注意,我们不需要使这个服务可被发现,所以我们不添加 Consul 发现。见图 8-30 。
图 8-30
创建日志微服务
一旦我们将这个项目导入到我们的工作空间中,我们就添加一些配置,以便能够连接到配置服务器。我们现在不打算添加任何特定的配置,但这样做可以使它与其他微服务保持一致。在main/src/resources文件夹中,复制我们包含在其他项目中的bootstrap.properties文件的内容。此外,让我们在application.properties文件中设置应用名称和专用端口。参见清单 8-44 。
spring.application.name=logs
server.port=8580
Listing 8-44Adding Content to the application.properties File in the New Logs Application
我们需要一个 Spring Boot 配置类来声明交换、我们希望从中消费消息的队列,以及使用绑定键模式将队列附加到主题交换的绑定对象,以消费所有这些内容。见清单 8-45 。请记住,由于我们将日志记录级别添加到了路由关键字中,所以我们也可以调整这个值,例如,只获取错误。无论如何,在我们的情况下,我们订阅所有消息(#)。
package microservices.book.logs;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AMQPConfiguration {
@Bean
public TopicExchange logsExchange() {
return ExchangeBuilder.topicExchange("logs.topic")
.durable(true)
.build();
}
@Bean
public Queue logsQueue() {
return QueueBuilder.durable("logs.queue").build();
}
@Bean
public Binding logsBinding(final Queue logsQueue,
final TopicExchange logsExchange) {
return BindingBuilder.bind(logsQueue)
.to(logsExchange).with("#");
}
}
Listing 8-45AMQPConfiguration Class in the Logs Application
下一步是使用@RabbitListener创建一个简单的服务,该服务使用相应的 log.info ()、log.error()或log.warn(),将作为 RabbitMQ 消息头传递的接收消息的日志记录级别映射到 Logs 微服务中的日志记录级别。注意,我们在这里使用了@Header注释来提取 AMQP 头作为方法参数。我们还使用日志记录Marker将应用名称(appId)添加到日志行中,而不需要将其作为消息的一部分连接起来。这是 SLF4J 标准中向日志添加上下文值的一种灵活方式。参见清单 8-46 。
package microservices.book.logs;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Service;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class LogsConsumer {
@RabbitListener(queues = "logs.queue")
public void log(final String msg,
@Header("level") String level,
@Header("amqp_appId") String appId) {
Marker marker = MarkerFactory.getMarker(appId);
switch (level) {
case "INFO" -> log.info(marker, msg);
case "ERROR" -> log.error(marker, msg);
case "WARN" -> log.warn(marker, msg);
}
}
}
Listing 8-46The Consumer Class That Receives All Log Messages via RabbitMQ
最后,我们定制这个新的微服务产生的日志输出。因为它将聚合来自不同服务的多个日志,所以最相关的属性是应用名称。这次我们覆盖了 Spring Boot 的默认设置,在一个logback-spring.xml文件中为输出标记、级别和消息的CONSOLE appender 定义了一个简单的格式。参见清单 8-47 。
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
[%-15marker] %highlight(%-5level) %msg%n
</Pattern>
</layout>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
Listing 8-47The LogBack Configuration for the Logs Application
这就是我们在这个新项目中需要的所有代码。现在,我们可以构建源代码,并使用系统中的其余组件启动这个新的微服务。
-
运行 RabbitMQ 服务器。
-
在开发模式下运行 Consul 代理。
-
启动乘法微服务。
-
启动游戏化微服务。
-
启动网关微服务。
-
启动日志微服务。
-
运行前端 app。
一旦我们启动这个新的微服务,它将消耗其他应用产生的所有日志消息。在实践中,你可以解决一个挑战。您将在 Logs 微服务的控制台中看到清单 8-48 中所示的日志行。
[multiplication ] INFO 15:14:20.203 [http-nio-8080-exec-1] m.b.m.c.ChallengeAttemptController - Received new attempt from test1
[gamification ] INFO 15:14:20.357 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 122
[gamification ] INFO 15:14:20.390 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test1 scored 10 points for attempt id 122
Listing 8-48Centralized Logs in the New Logs Application
这个简单的日志聚合器并没有花费我们太多时间,现在我们可以在同一个源中搜索日志,并看到来自我们所有服务的近乎实时的输出流。请参见图 8-31 了解包含这一新组件的高级架构图的更新版本。
图 8-31
高级概述:集中式日志
如果我们为日志聚合选择一个现有的解决方案,整个步骤将是相似的。许多这样的工具,比如 ELK stack,可以通过定制的附加器与 Logback 集成来获取日志。然后,在非基于云的日志聚合器的情况下,我们还需要在我们的系统中部署日志服务器,就像我们对我们创建的基本微服务所做的那样。
分布式跟踪
将所有的日志放在一个地方是一个伟大的成就,它提高了可观察性,但是我们还没有适当的 ?? 可追溯性。在前一章中,我们描述了一个成熟的事件驱动系统如何拥有跨越不同微服务的进程。了解许多并发用户和多个事件链的情况可能会成为一项不可能完成的任务,尤其是当这些事件链的分支包含触发相同操作的多种事件类型时。
为了解决这个问题,我们需要在同一个流程链中关联所有的操作和事件。一种简单的方法是在所有 HTTP 调用、RabbitMQ 消息和处理不同动作的 Java 线程中注入相同的标识符。然后,我们可以在所有相关的日志中打印这个标识符。
在我们的系统中,我们使用用户标识符。如果我们认为我们未来的所有功能都将围绕用户动作构建,我们可以在每个事件和调用中传播一个userId字段。然后,我们可以在不同的服务中记录日志,这样我们就可以将日志与特定用户相关联。这肯定会提高可追溯性。然而,我们也可能在短时间内发生来自同一个用户的多个动作,例如,在一秒钟内两次尝试解一个乘法(一个快用户,但是你明白这个想法),分布在多个实例中。在这种情况下,将很难区分跨我们的微服务的单个流。理想情况下,我们应该为每个动作设置一个惟一的标识符,这个标识符是在链的起点生成的。此外,如果我们可以透明地传播它,而不必在我们的所有服务中显式地建模这种可追溯性问题,那就更好了。
正如在软件开发中多次发生的那样,我们不是第一个应对这一挑战的人。这又是一个好消息,因为这意味着我们可以毫不费力地使用解决方案。在这种情况下,在 Spring 中实现分布式跟踪的工具称为 Sleuth。
SpringCloud 侦探
Sleuth 是 Spring Cloud 家族的一部分,它使用 Brave 库( https://tpd.io/brave )来实现分布式追踪。它通过关联被称为的工作单元跨越来构建跨不同组件的跟踪。例如,在我们的系统中,一个 span 检查乘法微服务中的尝试,另一个 span 根据 RabbitMQ 事件添加分数和徽章。每个区间都有一个不同的唯一标识符,但是两个区间都是同一个跟踪的一部分,所以它们有相同的跟踪标识符。此外,每个 span 都链接到其父级,除了根 span,因为它是原始动作。见图 8-32 。
图 8-32
分布式跟踪:简单的例子
在更进化的系统中,可能存在复杂的轨迹结构,其中多个跨度具有相同的父跨度。见图 8-33
图 8-33
分布式跟踪:树示例
为了透明地注入这些值,Sleuth 使用 SLF4J 的映射诊断上下文(MDC)对象,这是一个日志上下文,其生命周期仅限于当前线程。该项目还允许我们在这个上下文中注入我们自己的字段,因此我们可以传播它们并在日志中使用这些值。
Spring Boot 在 Sleuth 中自动配置了一些内置的拦截器来自动检查和修改 HTTP 调用和 RabbitMQ 消息。它还集成了 Kafka、gRPC 和其他通信接口。这些拦截器都以类似的方式工作:对于传入的通信,它们检查是否有跟踪头添加到调用或消息中,并将它们放入 MDC 当作为客户机进行调用或发布数据时,这些拦截器从 MDC 获取这些字段,并向请求或消息添加头。
Sleuth 有时会与 Zipkin 结合使用,Zipkin 是一种使用跟踪采样来测量每个跨度以及整个链中所花费的时间的工具。这些样本可以发送到 Zipkin 服务器,该服务器公开了一个 UI,您可以使用该 UI 来查看跟踪层次结构以及每个服务完成其工作所需的时间。我们不会在本书中使用 Zipkin,因为它不会给带有 trace 和 span 标识符的集中式日志记录系统增加太多价值,如果您检查记录的时间戳,还可以知道每个服务花费的时间。无论如何,您可以按照参考文档中的说明( http://tpd.io/spans-zipkin )轻松地将 Zipkin 集成到我们的示例项目中。
实现分布式跟踪
如前所述,Spring Cloud Sleuth 为 REST APIs 和 RabbitMQ 消息提供了拦截器,Spring Boot 为我们自动配置了它们。在我们的系统中使用分布式跟踪并不难。
首先,让我们将相应的 Spring Cloud starter 添加到我们的网关、乘法、游戏化和日志微服务中。参见清单 8-49 了解我们必须添加到pom.xml文件中的依赖关系。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
Listing 8-49Adding Spring Cloud Sleuth to All Our Spring Boot Projects
只有通过添加这种依赖关系,Sleuth 才会将跟踪和跨度标识符注入到每个受支持的通信通道和 MDC 对象中。默认的 Spring Boot 日志记录模式也会自动调整为在日志中打印跟踪和跨度值。
为了使我们的日志更详细,并查看运行中的跟踪标识符,让我们向ChallengeAttemptController添加一个日志行,以便在每次用户发送尝试时打印一条消息。参见清单 8-50 中的变更。
@PostMapping
ResponseEntity<ChallengeAttempt> postResult(
@RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {
log.info("Received new attempt from {}", challengeAttemptDTO.getUserAlias());
return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}
Listing 8-50Adding a Log Line to ChallengeAttemptController
此外,我们还希望在集中式日志中包含跟踪和父标识符。为此,让我们手动将 MDC 上下文中的属性X-B3-TraceId和X-B3-SpanId(由 Sleuth 使用 Brave 注入)添加到 Logs 项目的logback-spring.xml文件中的模式中。这些头是 OpenZipkin 的 B3 传播规范的一部分(参见 http://tpd.io/b3-headers 了解更多细节),它们被 Sleuth 的拦截器包含在 MDC 中。我们需要为我们的 Logs 微服务手动执行此操作,因为我们没有在此日志配置文件中使用 Spring Boot 默认值。见清单 8-51 。
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
[%-15marker] [%X{X-B3-TraceId:-},%X{X-B3-SpanId:-}] %highlight(%-5level) %msg%n
</Pattern>
</layout>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
Listing 8-51Adding Trace Fields to Each Log Line Printed by the Logs Application
一旦我们重启所有的后端服务,Sleuth 将会尽自己的一份力量。让我们使用终端直接向后端发送一个正确的尝试。
$ http POST :8000/attempts factorA=15 factorB=20 userAlias=test-user-tracing guess=300
然后,我们检查日志服务的输出。我们将看到两个字段显示了跨乘法和游戏化微服务的公共跟踪标识符,fa114ad129920dc7。每条线也有自己的跨度 ID。参见清单 8-52 。
[multiplication ] [fa114ad129920dc7,4cdc6ab33116ce2d] INFO 10:16:01.813 [http-nio-8080-exec-8] m.b.m.c.ChallengeAttemptController - Received new attempt from test-user-tracing
[multiplication ] [fa114ad129920dc7,f70ea1f6a1ff6cac] INFO 10:16:01.814 [http-nio-8080-exec-8] m.b.m.challenge.ChallengeServiceImpl - Creating new user with alias test-user-tracing
[gamification ] [fa114ad129920dc7,861cbac20a1f3b2c] INFO 10:16:01.818 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 126
[gamification ] [fa114ad129920dc7,78ae53a82e49b770] INFO 10:16:01.819 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test-user-tracing scored 10 points for attempt id 126
Listing 8-52Centralized Logs with Trace Identifiers
正如您所看到的,只需很少的努力,我们就获得了一个强大的功能,允许我们在分布式系统中辨别不同的进程。可以想象,当我们将所有日志及其跟踪和跨度输出到更复杂的集中式日志工具(如 ELK)时,效果会更好,我们可以使用这些标识符来执行过滤文本搜索。
集装箱化
到目前为止,我们一直在本地执行我们所有的 Java 微服务,React 前端、RabbitMQ 和 Consul。为此,我们需要安装 JDK 来编译源代码和运行 JAR 包,Node.js 来构建和运行 UI,RabbitMQ 服务器(包括 Erlang)和 Consul 的代理。随着我们架构的发展,我们可能需要引入其他工具和服务,它们肯定有自己的安装过程,可能会因操作系统及其版本的不同而有所不同。
作为一个总体目标,我们希望能够在多种环境中运行我们的后端系统,不管它们运行的是什么操作系统版本。理想情况下,我们希望受益于“一次构建,随处部署”的策略,并避免在我们希望部署系统的每个环境中重复所有的配置和安装步骤。此外,部署过程应该尽可能简单。
过去,打包整个系统以便在任何地方运行的一种常见方法是创建一个虚拟机(VM)。有几种创建和运行虚拟机的解决方案,它们被称为虚拟机管理程序。虚拟机管理程序的一个优势是,一台物理机可以同时运行多个虚拟机,并且它们都共享硬件资源。每个虚拟机都需要自己的操作系统,然后通过虚拟机管理程序连接到主机的 CPU、RAM、硬盘等。
在我们的例子中,我们可以从 Linux 发行版开始创建一个 VM,并在那里设置和安装运行我们的系统所需的所有工具和服务:Consul、RabbitMQ、Java 运行时、JAR 应用等。一旦我们知道虚拟机工作了,我们就可以把它转移到任何其他运行虚拟机管理程序的计算机上。因为这个包包含了它所需要的一切,所以它在不同的主机上应该是一样的。见图 8-34 。
图 8-34
虚拟机部署:单一
然而,将所有东西都放在同一个虚拟机中并不太灵活。如果我们想要扩展我们的系统,我们必须进入虚拟机,添加新的实例,并确保我们分配更多的 CPU、内存等。我们需要知道一切是如何工作的,所以部署过程不再那么容易了。
更动态的方法是为每个服务和工具配备单独的虚拟机。然后,我们添加一些网络配置,以确保它们可以相互连接。由于我们使用服务发现和动态扩展,我们可以添加更多运行微服务的虚拟机实例(例如,乘法-VM),它们将被透明地使用。这些新实例只需要使用它们的地址(在 VM 网络中)在 Consul 中注册它们自己。见图 8-35 这比单个虚拟机要好,但考虑到每个虚拟机都需要自己的操作系统,这是一种巨大的资源浪费。此外,这将在虚拟机协调方面带来许多挑战:监控虚拟机、创建新实例、配置网络、存储等。
图 8-35
虚拟机部署:多个
随着容器化技术在 2010 年代初的发展,虚拟机已被淘汰,容器已成为最受欢迎的应用虚拟化方式。容器要小得多,因为不需要操作系统;它们运行在主机的 Linux 操作系统之上。
另一方面,像 Docker 这样的容器化平台的引入极大地促进了云和本地部署,使用易于使用的工具来打包应用,将它们作为容器运行,并在公共注册表中共享它们。让我们更详细地探索这个平台的特性。
码头工人
在本书中不可能涵盖 Docker 平台的所有概念,但是让我们尝试给出一个足够好的概述,以理解它如何促进分布式系统的部署。官网的入门页面( https://tpd.io/docker-start )是从这里继续学习的好地方。
在 Docker 中,我们可以将我们的应用和它可能需要的任何支持组件打包成映像。这些可以基于你从 Docker 注册表中提取的其他现有图像,因此我们可以重用它们并节省大量时间。官方公开的图片注册中心是 Docker Hub ( https://tpd.io/docker-hub )。
例如,倍增微服务的 Docker 映像可以基于现有的 JDK 14 映像。然后,我们可以在它上面添加由 Spring Boot 打包的 JAR 文件。为了创建图像,我们需要一个Dockerfile,以及 Docker CLI 工具的一组指令。清单 8-53 展示了乘法微服务的示例Dockerfile可能是什么样子。这个文件应该放在项目的根文件夹中。
FROM openjdk:14
COPY ./target/multiplication-0.0.1-SNAPSHOT.jar /usr/src/multiplication/
WORKDIR /usr/src/multiplication
EXPOSE 8080
CMD ["java", "-jar", "multiplication-0.0.1-SNAPSHOT.jar"]
Listing 8-53A Basic Dockerfile to Create a Docker Image for the Multiplication Microservice
这些指令告诉 Docker 使用公共注册中心(Docker Hub, https://tpd.io/docker-jdk )中官方openjdk镜像的版本14作为基础(FROM)。然后,它将可分发的.jar文件从当前项目复制到图像中的/usr/src/multiplication/文件夹(COPY)。第三条指令WORKDIR将图像的工作目录更改为这个新创建的文件夹。命令EXPOSE通知 Docker 这个映像公开了一个端口 8080,我们在这里服务 REST API。最后,我们定义了用CMD运行这个映像时要执行的命令。这只是运行一个.jar文件的经典 Java 命令,按照预期的语法分成三部分。您可以在Dockerfile中使用许多其他指令,如您在参考文档中所见( https://tpd.io/dockerfile-ref )。
要构建映像,我们必须下载并安装标准 Docker 安装包附带的 Docker CLI 工具。按照 Docker 网站上的说明( https://tpd.io/getdocker )获取适合您的操作系统的软件包。下载并启动后,Docker 守护程序应该作为后台服务运行。然后,您可以从终端使用 Docker 命令构建和部署映像。例如,清单 8-54 中所示的命令基于我们之前创建的Dockerfile构建了multiplication图像。注意,作为先决条件,你必须确保在一个.jar文件中构建和打包应用,例如通过从项目的根文件夹运行./mvnw clean package。
multiplication$ docker build -t multiplication:1.0.0 .
Sending build context to Docker daemon 59.31MB
Step 1/5 : FROM openjdk:14
---> 4fba8120f640
Step 2/5 : COPY ./target/multiplication-0.0.1-SNAPSHOT.jar /usr/src/multiplication/
---> 2e48612d3e40
Step 3/5 : WORKDIR /usr/src/multiplication
---> Running in c58cde6bda82
Removing intermediate container c58cde6bda82
---> 8d5457683f2c
Step 4/5 : EXPOSE 8080
---> Running in 7696319884c7
Removing intermediate container 7696319884c7
---> abc3a60b73b2
Step 5/5 : CMD ["java", "-jar", "multiplication-0.0.1-SNAPSHOT.jar"]
---> Running in 176cd53fe750
Removing intermediate container 176cd53fe750
---> a42cc81bab51
Successfully built a42cc81bab51
Successfully tagged multiplication:1.0.0
Listing 8-54Building
a Docker Image Manually
正如您在输出中看到的,Docker 处理文件中的每一行并创建一个名为multiplication:1.0.0的图像。这张图片只在本地可用,但是如果我们希望其他人使用它,我们可以将图片推送到一个远程位置,我们将在后面解释。
一旦构建了 Docker 映像,就可以将其作为容器运行,容器是映像的运行实例。例如,这个命令将在我们的机器上运行一个 Docker 容器:
$ docker run -it -p 18080:8080 multiplication:1.0.0
如果图像在本地不可用,Docker 中的run命令会提取图像,并作为容器在 Docker 平台上执行。-it标志用于附加到容器的终端,所以我们可以看到命令行的输出,也可以用 Ctrl+C 信号停止容器。-p选项是公开内部端口 8080,因此可以从端口 18080 中的主机访问它。这些只是我们在运行容器时可以使用的几个选项;您可以从命令行使用docker run --help来查看它们。
当我们启动这个容器时,它将运行在 Docker 平台之上。如果您运行的是 Linux 操作系统,容器将使用主机的本地虚拟化功能。当在 Windows 或 Mac 上运行时,Docker 平台在两者之间设置了一个 Linux 虚拟化层,如果这些操作系统可用,它可能会使用这些操作系统的原生支持。
不幸的是,我们的容器不能正常工作。它不能连接 RabbitMQ 或 Consul,即使我们在码头工人的主机(我们的电脑)上安装并运行它们。清单 8-55 显示了容器日志中这些错误的摘录。记住,默认情况下,Spring Boot 试图在localhost找到 RabbitMQ 主机,和 Consul 一样。在一个容器中,localhost指的是自己的容器,除了 Spring Boot app,没有别的。此外,容器是运行在 Docker 平台网络上的孤立单元,因此它们无论如何都不应该连接到运行在主机上的服务。
2020-08-29 10:03:44.565 ERROR [,,,] 1 --- [ main] o.s.c.c.c.ConsulPropertySourceLocator : Fail fast is set and there was an error reading configuration from consul.
2020-08-29 10:03:45.572 ERROR [,,,] 1 --- [ main] o.s.c.c.c.ConsulPropertySourceLocator : Fail fast is set and there was an error reading configuration from consul.
2020-08-29 10:03:46.675 ERROR [,,,] 1 --- [ main] o.s.c.c.c.ConsulPropertySourceLocator : Fail fast is set and there was an error reading configuration from consul.
[...]
Listing 8-55The Multiplication Container Can’t Reach Consul at Localhost
为了正确设置我们的后端系统在 Docker 中运行,我们必须将 RabbitMQ 和 Consul 部署为容器,并使用 Docker 网络在它们之间连接所有这些不同的实例。见图 8-36
图 8-36
我们的码头集装箱后端
在学习如何实现这一点之前,让我们探索一下 Spring Boot 如何帮助我们建立 Docker 图像,这样我们就不需要自己准备Dockerfile了。
Spring Boot 和构建包
从版本 2.3.0 开始,Spring Boot 的 Maven 和 Gradle 插件可以选择使用云原生构建包( https://tpd.io/buildpacks )来构建开放容器倡议(OCI)映像,该项目旨在帮助打包您的应用,以便将其部署到任何云提供商。您可以在 Docker 和其他容器平台中运行生成的图像。
Buildpacks 插件的一个很好的特性是,它根据项目的 Maven 配置准备一个计划,然后打包一个 Docker 映像,准备好进行部署。此外,它以某种方式在层中构建映像,以便它们可以被您的应用的未来版本重用,甚至可以被使用相同工具构建的其他微服务映像重用(例如,具有所有 Spring Boot 核心库的层)。这有助于加快测试和部署。
如果我们从命令行运行build-image目标,例如从游戏化的项目文件夹,我们可以看到构建包的运行:
gamification $ ./mvnw spring-boot:build-image
您应该从 Maven 插件中看到一些额外的日志,它现在正在下载一些所需的图像并构建应用图像。如果一切顺利,您应该在最后看到这一行:
[INFO] Successfully built image 'docker.io/library/gamification:0.0.1-SNAPSHOT'
Docker 标签被设置为我们在pom.xml文件中指定的 Maven 工件的名称和版本:gamification:0.0.1-SNAPSHOT。前缀docker.io/library/是所有公共 Docker 图像的默认前缀。我们可以为这个插件定制多个选项,你可以查看参考文档( https://tpd.io/buildpack-doc )了解所有细节。
就像我们之前为我们自己构建的图像运行容器一样,我们现在可以为这个由 Spring Boot 的 Maven 插件生成的新图像运行容器:
$ docker run -it -p 18081:8081 gamification:0.0.1-SNAPSHOT
不出所料,容器也会输出同样的错误。请记住,应用不能连接到 RabbitMQ 和 Consul,它需要这两个服务才能正常启动。我们会尽快解决这个问题。
对于您自己的项目,您应该考虑使用云原生构建包与维护您自己的 Docker 文件的利弊。如果您计划使用这些标准 OCI 映像部署到支持它们的公共云,这可能是一个好主意,因为您可以节省大量时间。Buildpacks 还负责在可重用层中组织您的图像,因此您可以避免自己这样做。此外,您可以定制插件使用的基本构建映像,因此您可以灵活地定制这个过程。然而,如果您想要完全控制您正在构建的东西以及您想要包含在您的图像中的工具和文件,那么自己定义Dockerfile指令可能会更好。正如我们之前看到的,这对于一个基本的设置来说并不难。
在 Docker 中运行我们的系统
让我们为系统中的每个组件构建或找到一个 Docker 映像,这样我们就可以将它部署为一组容器。
-
乘法、游戏化、网关和日志微服务:我们将使用带有构建包的 Spring Boot Maven 插件来生成这些 Docker 映像。
-
RabbitMQ :我们可以使用包含管理插件(UI)的官方 RabbitMQ 镜像版本运行一个容器:
rabbitmq:3-management(参见 Docker Hub )。 -
领事:也有官方码头工人形象。我们将使用 Docker Hub 中的标签
consul:1.7.2(https://tpd.io/consul-docker)。此外,我们将运行第二个容器来加载一些配置,作为集中式配置的键/值对。更多细节将在其特定部分给出。 -
前端:如果我们想在 Docker 中部署完整的系统,我们还需要一个 web 服务器来托管 React 构建中生成的 HTML/JavaScript 文件。我们可以用 Nginx 这样的轻量级静态服务器,用它的官方 Docker 镜像
nginx:1.19(见 Docker Hub,https://tpd.io/nginx-docker)。在这种情况下,我们将以nginx为基础构建我们自己的映像,因为我们也需要复制生成的文件。
因此,我们的计划需要构建六个不同的 Docker 映像,并使用两个公共映像。见图 8-37 。
图 8-37
高级概述:集装箱化系统
对接微服务
首先,让我们为 Spring Boot 应用构建所有的图像。从每个项目文件夹中,我们需要运行以下命令:
$ ./mvnw spring-boot:build-image
注意,Docker 必须在本地运行,与 Consul 和 RabbitMQ 一样,这样测试才能通过。一旦您生成了所有的图像,您可以通过运行docker images命令来验证它们在 Docker 中都是可用的。参见清单 8-56 。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
logs 0.0.1-SNAPSHOT 2fae1d82cd5d 40 years ago 311MB
gamification 0.0.1-SNAPSHOT 5552940b9bfd 40 years ago 333MB
multiplication 0.0.1-SNAPSHOT 05a4d852fa2d 40 years ago 333MB
gateway 0.0.1-SNAPSHOT d50be5ba137a 40 years ago 313MB
Listing 8-56Listing Docker Images Generated with Cloud Native Buildpacks
如您所见,图像是用旧日期生成的。这是 Buildpacks 的一个特性,使构建可复制:每次构建这个映像时,它们都获得相同的创建日期,并且它们也位于列表的末尾。
将用户界面停驻
下一步是在challenges-frontend文件夹中创建一个Dockerfile,这是 React 应用的根目录。我们只需要两条指令,一条是基本图像(Nginx ),另一条是将所有 HTML/JavaScript 文件放入图像的COPY命令。我们将它们复制到 Nginx web 服务器默认用来提供内容的文件夹中。参见清单 8-57 。
FROM nginx:1.19
COPY build /usr/share/nginx/html/
Listing 8-57A Simple Dockerfile to Create an Image for the front End’s Web Server
在创建 Docker 映像之前,让我们确保为前端生成最新的工件。为了编译 React 项目,我们必须执行以下命令:
challenges-frontend $ npm run build
一旦生成了build文件夹,我们就可以创建 Docker 图像了。我们将分配一个名称和一个带有-t标志的标签,并且我们使用.来表示Dockerfile位于当前文件夹中。
challenges-frontend $ docker build -t challenges-frontend:1.0 .
将配置导入程序归档
现在,让我们准备一个 Docker 映像来加载一些预定义的集中式配置。我们将有一个运行服务器的 Consul 容器,它可以直接使用官方图像。我们的计划是运行一个额外的容器来执行 Consul CLI 以加载一些 KV 数据:一个docker概要文件。这样,当在 Docker 中运行我们的微服务时,我们可以使用这个预加载的配置文件配置,因为它们需要不同的 RabbitMQ 主机参数,例如。
为了获得我们想要以文件格式加载的配置,我们可以在本地 Consul 服务器中创建它,并通过 CLI 命令导出它。我们使用 UI 来创建config根,以及一个名为defaults,docker的子文件夹。在内部,我们创建一个名为application.yml的键,其配置如清单 8-58 所示。此配置执行以下操作:
-
将 RabbitMQ 的主机设置为
rabbitmq,这将覆盖默认值localhost。稍后,我们将确保消息代理的容器在该地址可用。 -
覆盖分配给正在运行的服务的实例标识符,以便在服务注册中心使用。默认的 Spring Consul 配置将应用名和端口号连接在一起,但是这种方法对容器不再有效。当我们在 Docker 中运行同一个服务的多个实例(作为容器)时,它们都使用同一个内部端口,所以它们最终会有相同的标识符。为了解决这个问题,我们可以使用一个随机整数作为后缀。Spring Boot 通过特殊的
random属性符号支持这一点(详见https://tpd.io/random-properties的文档)。
spring:
rabbitmq:
host: rabbitmq
cloud:
consul:
discovery:
instance-id: ${spring.application.name}-${random.int(1000)}
Listing 8-58The YAML Configuration to Connect Our Apps in Docker Containers to RabbitMQ
图 8-38 显示了通过 Consul UI 添加的内容。
图 8-38
咨询用户界面:准备导出配置
下一步是使用不同的终端将配置导出到文件中。为此,请执行以下命令:
$ consul kv export config/ > consul-kv-docker.json
现在,让我们在工作区的根目录下创建一个名为docker的新文件夹来放置我们所有的 Docker 配置。在里面,我们创建了一个名为consul的子文件夹。应该将使用前面的命令生成的 JSON 文件复制到那里。然后,用清单 8-59 中的指令添加一个新的Dockerfile。
FROM consul:1.7.2
COPY ./consul-kv-docker.json /usr/src/consul/
WORKDIR /usr/src/consul
ENV CONSUL_HTTP_ADDR=consul:8500
ENTRYPOINT until consul kv import @consul-kv-docker.json; do echo "Waiting for Consul"; sleep 2; done
Listing 8-59Dockerfile Contents for the Consul Configuration Loader
参见清单 8-60 以及docker文件夹的结果文件结构。
+- docker
| +- consul
| \- consul-kv-docker.json
| \- Dockerfile
Listing 8-60Creating the Consul Docker Configuration in a Separate Folder
清单 8-59 中的 Dockerfile 步骤使用 Consul 作为基础映像,因此 CLI 工具是可用的。JSON 文件被复制到映像中,工作目录被设置到与文件相同的位置。然后,ENV指令为 Consul CLI 设置一个新的环境变量,以使用远程主机访问服务器,在本例中位于consul:8500。这将是 Consul 服务器容器(我们将很快看到主机如何获得consul名称)。最后,这个容器的ENTRYPOINT(启动时运行的命令)是一个遵循until [command]; do ...; sleep 2; done模式的内联 shell 脚本。该脚本运行一个命令,直到成功为止,重试间隔为两秒钟。主命令是consul kv import @consul-kv-docker.json,将文件内容导入 KV 存储器。我们需要在一个循环中执行它,因为当这个 Consul 配置导入器运行时,Consul 服务器可能还没有启动。
为了在注册表中获得导入程序映像,我们必须构建它并给它一个名称。
docker/consul$ docker build -t consul-importer:1.0 .
我们将很快详细介绍如何在 Docker 中运行这个导入器,以将配置加载到 Consul 中。
复合坞站
一旦我们构建了所有的映像,我们需要将我们的系统作为一组容器来运行,所以是时候学习如何一起启动所有这些容器并进行通信了。
我们可以使用单独的 Docker 命令来启动所有需要的容器,并为它们建立相互连接的网络。然而,如果我们想告诉其他人如何启动我们的系统,我们需要给他们一个脚本或文档,其中包含所有这些命令和指令。幸运的是,Docker 中有一种更好的方法来对容器配置和部署指令进行分组:Docker Compose。
使用 Compose,我们使用 YAML 文件来定义基于多个容器的应用。然后,我们使用命令docker-compose运行所有服务。Docker Compose 默认与 Windows 和 Mac 版本的 Docker Desktop 一起安装。如果你运行的是 Linux 或者你在 Docker 发行版中找不到它,按照 Docker 网站上的说明( https://tpd.io/compose-install )安装它。
作为第一个例子,参见清单 8-61 ,了解我们需要在系统中作为容器运行的 RabbitMQ 和 Consul 服务的 YAML 定义。这个 YAML 定义必须添加到一个新文件docker-compose.yml中,我们可以在现有的docker文件夹中创建这个文件。我们将使用版本 3 的组合语法;完整参考可在 https://tpd.io/compose3 获得。请继续阅读关于该语法如何工作的高级细节。
version: "3"
services:
consul-dev:
image: consul:1.7.2
container_name: consul
# The UDP port 8600 is used by Consul nodes to talk to each other, so
# it's good to add it here even though we're using a single-node setup.
ports:
- '8500:8500'
- '8600:8600/udp'
command: 'agent -dev -node=learnmicro -client=0.0.0.0 -log-level=INFO'
networks:
- microservices
rabbitmq-dev:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- '5672:5672'
- '15672:15672'
networks:
- microservices
networks:
microservices:
driver: bridge
Listing 8-61A First Version of the docker-compose.yml File with RabbitMQ and Consul
在services部分,我们定义了其中的两个:consul-dev和rabbitmq-dev。我们可以为我们的服务使用任何名称,所以我们为这两个服务添加了-dev后缀,以表明我们正在开发模式下运行它们(没有集群的独立节点)。这两个服务使用不是我们创建的 Docker 映像,但是它们在 Docker Hub 中作为公共映像可用。我们第一次检查集装箱时就会发现。如果我们不指定command来启动容器,将使用图像中的默认。默认命令可以在用于构建图像的Dockerfile中指定。RabbitMQ 服务就是这样,默认情况下启动服务器。对于领事图像,我们定义了自己的命令,它类似于我们目前使用的命令。不同之处在于它还包括一个降低日志级别的标志,以及代理在 Docker 网络中工作所需的参数client。这些说明可以在 Docker 图像的文档中找到( https://tpd.io/consul-docker )。
两个服务都定义了一个container_name参数。这很有用,因为它设置了容器的 DNS 名称,所以其他容器可以通过这个别名找到它。在我们的例子中,这意味着应用可以使用地址rabbitmq:5672连接到 RabbitMQ 服务器,而不是默认的地址localhost:5672(它现在指向与我们在前面章节中看到的相同的容器)。每个服务中的ports参数允许我们以host-port:container-port的格式向主机系统公开端口。我们在这里包括两个服务器都使用的标准工具,所以我们仍然可以从我们的桌面访问它们(例如,分别在端口 8500 和 15672 上使用它们的 UI 工具)。请注意,我们映射到主机中的相同端口,这意味着我们不能让 RabbitMQ 和 Consul 服务器进程同时在本地运行(就像我们到目前为止一直在做的那样),因为这会导致端口冲突。
在这个文件中,我们还定义了一个名为microservices的类型为bridge的网络。该驱动程序类型是默认类型,用于连接独立容器。然后,我们使用每个服务定义中的参数networks将microservices网络设置为他们可以访问的网络。实际上,这意味着这些服务可以相互连接,因为它们属于同一个网络。Docker 网络与主机网络相隔离,所以我们不能访问任何服务,除了那些我们用ports参数明确公开的服务。这很好,因为这是我们在引入网关模式时所缺少的良好实践之一。
现在,我们可以使用这个新的docker-compose.yml文件来运行 Consul 和 RabbitMQ Docker 容器。我们只需要从终端执行docker-compose命令:
docker $ docker-compose up
Docker Compose 自动获取docker-compose.yml而不指定名称,因为这是它期望的默认文件名。所有集装箱的输出是附加到当前码头和集装箱。如果我们想让它们作为守护进程在后台运行,我们只需要在命令中添加-d标志。在我们的例子中,对于consul和rabbitmq容器,我们将在终端输出中看到所有的日志。参见清单 8-62 中的示例。
Creating network "docker_microservices" with driver "bridge"
Creating consul ... done
Creating rabbitmq ... done
Attaching to consul, rabbitmq
consul | ==> Starting Consul agent...
consul | Version: 'v1.7.2'
consul | Node ID: 'a69c4c04-d1e7-6bdc-5903-c63934f01f6e'
consul | Node name: 'learnmicro'
consul | Datacenter: 'dc1' (Segment: '<all>')
consul | Server: true (Bootstrap: false)
consul | Client Addr: [0.0.0.0] (HTTP: 8500, HTTPS: -1, gRPC: 8502, DNS: 8600)
consul | Cluster Addr: 127.0.0.1 (LAN: 8301, WAN: 8302)
consul | Encrypt: Gossip: false, TLS-Outgoing: false, TLS-Incoming: false, Auto-Encrypt-TLS: false
consul |
consul | ==> Log data will now stream in as it occurs:
[...]
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: list of feature flags found:
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] drop_unroutable_metric
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] empty_basic_get_metric
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] implicit_default_bindings
rabbitmq | 2020-07-30 05:36:28.785 [info] <0.8.0> Feature flags: [ ] quorum_queue
rabbitmq | 2020-07-30 05:36:28.786 [info] <0.8.0> Feature flags: [ ] virtual_host_metadata
rabbitmq | 2020-07-30 05:36:28.786 [info] <0.8.0> Feature flags: feature flag states written to disk: yes
rabbitmq | 2020-07-30 05:36:28.830 [info] <0.268.0> ra: meta data store initialised. 0 record(s) recovered
rabbitmq | 2020-07-30 05:36:28.831 [info] <0.273.0> WAL: recovering []
rabbitmq | 2020-07-30 05:36:28.833 [info] <0.277.0>
rabbitmq | Starting RabbitMQ 3.8.2 on Erlang 22.2.8
[...]
Listing 8-62Docker Compose Logs, Showing the Initialization of Both Containers
我们还可以验证如何在localhost:8500从您的浏览器访问 Consul UI。这一次,从容器中为网站提供服务。它的工作方式完全相同,因为我们将端口暴露给同一个主机的端口,并且它被 Docker 重定向。
要停止这些容器,我们可以按 Ctrl+C,但这可能会使 Docker 在两次执行之间保持某种状态。为了正确地关闭它们并删除它们在 Docker volumes (容器定义的存储数据的单元)中创建的任何潜在数据,我们可以从不同的终端运行清单 8-63 中的命令。
docker $ docker-compose down -v
Stopping consul ... done
Stopping rabbitmq ... done
Removing consul ... done
Removing rabbitmq ... done
Removing network docker_default
WARNING: Network docker_default not found.
Removing network docker_microservices
Listing 8-63Stopping Docker Containers and Removing Volumes with Docker Compose
我们的下一步是将我们为将配置加载到 Consul KV 中而创建的映像添加到 Docker Compose 文件中。参见清单 8-64 。
version: "3"
services:
consul-importer:
image: consul-importer:1.0
depends_on:
- consul-dev
networks:
- microservices
consul-dev:
# ... same as before
rabbitmq-dev:
# ... same as before
networks:
microservices:
driver: bridge
Listing 8-64Adding the Consul Importer Image to the docker-compose.yml File
这一次,图像consul-importer:1.0不是公开的;Docker Hub 中没有。但是,它在本地 Docker 注册表中是可用的,因为我们之前构建了它,所以 Docker 可以通过我们之前定义的名称和标签找到它。
我们可以用参数depends_on在组合文件中建立依赖关系。在这里,我们使用它使这个容器在运行 Consul 服务器的consul-dev容器之后启动。无论如何,这并不能保证在consul-importer运行时服务器已经准备好了。原因是 Docker 只知道容器何时启动,但不知道 consul 服务器何时启动并准备好接受请求。这就是我们向导入程序映像添加脚本的原因,该脚本会重试导入,直到成功为止(参见清单 8-59 )。
当您使用这个新配置再次运行docker-compose up时,您也将看到这个新容器的输出。最后,您应该看到加载配置的行,然后 Docker 将通知这个容器成功退出(代码为 0)。参见清单 8-65 。
docker $ docker-compose up
[...]
consul-importer_1 | Imported: config/
consul-importer_1 | Imported: config/defaults,docker/
consul-importer_1 | Imported: config/defaults,docker/application.yml
docker_consul-importer_1 exited with code 0
[...]
Listing 8-65Running docker-compose for the Second Time to See the Importer’s Logs
新容器作为一个功能运行,而不是作为一个持续运行的服务。这是因为我们替换了consul映像中的默认命令,该命令在它们的内部Dockerfile中定义为将服务器作为一个进程运行,替换为一个简单加载配置然后结束的命令(它不是一个无限运行的进程)。Docker 知道容器没什么可做的了,因为命令已经退出了,所以没有必要让容器保持活动状态。
我们还可以知道一个docker-compose配置的运行容器是什么。为了获得这个列表,我们可以从不同的终端执行docker-compose ps。见清单 8-66 。
docker $ docker-compose ps
Name Command State Ports
--------------------------------------------------------------------
consul docker-e[...] Up 8300/tcp, [...]
docker_consul-importer_1 /bin/sh [...] Exit 0
rabbitmq docker-e[...] Up 15671/tcp, [...]
Listing 8-66Running docker-compose ps to See the Status of the Containers
输出(为了更好的可读性而进行了裁剪)还详细描述了容器使用的命令、其状态和公开的端口。
如果我们在http://localhost:8500/ui用浏览器导航到 Consul UI,我们将看到配置是如何被正确加载的,并且我们有一个config条目,它带有嵌套的defaults,docker子文件夹和相应的application.yml键。见图 8-39 。我们的进口商运作良好。
图 8-39
领事容器内的 Docker 配置
让我们继续 Docker Compose 中的前端定义。这个很容易;我们只需要添加我们基于 Nginx 构建的映像,并通过重定向到内部端口来公开端口 3000,在基本映像中默认为 80(根据 https://tpd.io/nginx-docker )。参见清单 8-67 。您可以更改公开的端口,但是记得相应地调整网关中的 CORS 配置(或者重构它,以便它可以通过外部属性进行配置)。
version: "3"
services:
frontend:
image: challenges-frontend:1.0
ports:
- '3000:80'
consul-importer:
# ... same as before
consul-dev:
# ... same as before
rabbitmq-dev:
# ... same as before
networks:
microservices:
driver: bridge
Listing 8-67Adding the Web Server to the docker-compose.yml File
为了使整个系统工作,我们需要将 Spring Boot 微服务添加到 Docker 合成文件中。我们将配置它们使用我们创建的同一个网络。这些容器中的每一个都需要到达consul和rabbitmq容器才能正常工作。为此我们将使用两种不同的策略。
-
对于 Consul 设置,Spring 中的集中配置特性要求服务知道在引导阶段在哪里可以找到服务器。我们需要覆盖在本地
bootstrap.yml中使用的属性spring.cloud.consul.host,并将其指向consul容器。我们将通过环境变量来做到这一点。在 Spring Boot,如果您设置了一个与现有属性匹配的环境变量,或者一个遵循特定命名约定的环境变量(比如SPRING_CLOUD_CONSUL_HOST),那么它的值会覆盖本地配置。更多详情请参见 Spring Boot 文档中的https://tpd.io/binding-vars。 -
对于 RabbitMQ 配置,我们将使用
docker概要文件。假设微服务将连接到 Consul,配置服务器有一个针对defaults,docker的预加载条目,所有微服务都将使用那里的属性。还记得我们在那个概要文件中将 RabbitMQ 主机更改为rabbitmq,容器的 DNS 名称。为了激活每个微服务中的docker概要文件,我们将使用 Spring Boot 属性来启用概要文件,通过环境变量:SPRING_PROFILES_ACTIVE=docker传递。
此外,对于 Compose 中 Spring Boot 容器的配置,还有一些额外的注意事项:
-
在
localhost:8000上,除了网关服务,我们不想将后端服务直接暴露给主机。因此,我们不会在乘法、游戏化和对数中添加ports部分。 -
此外,我们将为后端容器使用
depends_on参数来等待,直到consul-importer运行,因此在 Spring Boot 应用启动时,docker配置文件的 Consul 配置将可用。 -
我们还将包含
rabbitmq作为这些服务的依赖项,但是请记住,这并不能保证 RabbitMQ 服务器在我们的应用启动之前就准备好了。Docker 只验证容器是否已经启动。幸运的是,作为一种恢复技术,Spring Boot 在默认情况下会重试连接到服务器,所以最终,系统会变得稳定。
请参见清单 8-68 了解启动我们的系统所需的完整 Docker Compose 配置。
version: "3"
services:
frontend:
image: challenges-frontend:1.0
ports:
- '3000:80'
multiplication:
image: multiplication:0.0.1-SNAPSHOT
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
gamification:
image: gamification:0.0.1-SNAPSHOT
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
gateway:
image: gateway:0.0.1-SNAPSHOT
ports:
- '8000:8000'
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
logs:
image: logs:0.0.1-SNAPSHOT
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
consul-importer:
image: consul-importer:1.0
depends_on:
- consul-dev
networks:
- microservices
consul-dev:
image: consul:1.7.2
container_name: consul
ports:
- '8500:8500'
- '8600:8600/udp'
command: 'agent -dev -node=learnmicro -client=0.0.0.0 -log-level=INFO'
networks:
- microservices
rabbitmq-dev:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- '5672:5672'
- '15672:15672'
networks:
- microservices
networks:
microservices:
driver: bridge
Listing 8-68docker-compose.yml File with Everything Needed to Run Our Complete System
是时候测试我们作为 Docker 容器运行的完整系统了。像以前一样,我们运行docker-compose up命令。我们将在输出中看到许多日志,由同时启动的多个服务生成,或者紧接在被定义为依赖项的服务之后。
您可能注意到的第一件事是,一些后端服务在尝试连接 RabbitMQ 时会抛出异常。这是预料中的情况。如前所述,RabbitMQ 的启动时间可能比微服务应用长。在rabbitmq容器准备就绪后,这个问题会自动修复。
您还可能遇到由于系统中没有足够的内存或 CPU 来一起运行所有容器而导致的错误。这并不罕见,因为每个微服务容器最多可以占用 1GB 的 RAM。如果你不能运行所有这些容器,我希望书中的解释仍然有助于你理解一切是如何一起工作的。
要了解系统的状态,我们可以使用 Docker 提供的聚合日志(附加的输出)或来自logs容器的输出。要尝试第二个选项,我们可以从不同的终端使用另一个 Docker 命令,docker-compose logs [container_name]。参见清单 8-69 。请注意,我们的服务名是logs,这解释了单词 repetition。
docker $ docker-compose logs logs
[...]
logs_1 | [gamification ] [aadd7c03a8b161da,34c00bc3e3197ff2] INFO 07:24:52.386 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Triggering deferred initialization of Spring Data repositories?
logs_1 | [multiplication ] [33284735df2b2be1,bc998f237af7bebb] INFO 07:24:52.396 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Triggering deferred initialization of Spring Data repositories?
logs_1 | [multiplication ] [b87fc916703f6b56,fd729db4060c1c74] INFO 07:24:52.723 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Spring Data repositories initialized!
logs_1 | [multiplication ] [97f86da754679510,9fa61b768e26aeb5] INFO 07:24:52.760 [main] m.b.m.MultiplicationApplication - Started MultiplicationApplication in 44.974 seconds (JVM running for 47.993)
logs_1 | [gamification ] [5ec42be452ce0e04,03dfa6fc3656b7fe] INFO 07:24:53.017 [main] o.s.d.r.c.DeferredRepositoryInitializationListener - Spring Data repositories initialized!
logs_1 | [gamification ] [f90c5542963e7eea,a9f52df128ac5c7d] INFO 07:24:53.053 [main] m.b.g.GamificationApplication - Started GamificationApplication in 45.368 seconds (JVM running for 48.286)
logs_1 | [gateway ] [59c9f14c24b84b32,36219539a1a0d01b] WARN 07:24:53.762 [boundedElastic-1] o.s.c.l.core.RoundRobinLoadBalancer - No servers available for service: gamification
Listing 8-69Checking the Logs of the Logs Container
此外,您还可以通过查看 Consul UI 的服务列表来监控服务状态,该列表可在localhost:8500获得。在那里,您将看到健康检查是否通过,这意味着服务已经在提供服务并连接到 RabbitMQ。见图 8-40 。
图 8-40
Consul UI:检查容器健康
如果您单击其中一个服务(例如gamification,您将看到主机地址现在是 docker 网络中容器的地址。见图 8-41 。这是用于服务相互连接的容器名称的替代名称。实际上,Consul 中的这种动态主机地址注册允许我们拥有一个给定服务的多个实例。如果我们使用一个container_name参数,我们不能启动多个实例,因为它们的地址会冲突。
在这种情况下,应用使用 Docker 的主机地址,因为 Spring Cloud 会检测应用何时在 Docker 容器上运行。然后,Consul Discovery 库在注册时使用这个值。
图 8-41
consular ui:dock container address(停靠容器地址)
一旦容器变成绿色,我们就可以用浏览器导航到localhost:3000并开始玩我们的应用。它的工作原理和以前一样。当我们解决一个挑战时,我们会在日志中看到事件是如何被游戏化消耗的,游戏化会增加分数和徽章。前端通过网关访问,这是唯一向主机公开的微服务。见图 8-42 。
图 8-42
Docker 上运行的应用
我们没有添加任何持久性,所以当我们关闭容器时,所有的数据都将消失。如果您想扩展 Docker 和 Docker Compose 的知识,可以考虑添加卷来存储 DB 文件(参见 https://tpd.io/compose-volumes )。此外,在执行docker-compose down时,不要忘记移除-v标志,这样卷在两次执行之间被保留。
使用 Docker 扩展系统
使用 Docker Compose,我们还可以通过一个命令来扩展和缩减服务。首先,让我们像以前一样启动系统。如果您已经关闭了它,请执行以下命令:
docker$ docker-compose up
然后,从一个不同的终端,我们再次运行带有scale参数的命令,指示服务名和我们想要获得的实例数量。我们可以在一个命令中多次使用该参数。
docker$ docker-compose up --scale multiplication=2 --scale gamification=2
现在,检查这个新终端的日志,看看 Docker Compose 如何为multiplication和gamification服务启动一个额外的实例。你也可以在 Consul 中验证这一点。见图 8-43 。
图 8-43
咨询 UI:游戏化的两个容器
感谢 Consul Discovery、我们的网关模式、Spring Cloud 负载平衡器和 RabbitMQ 消费者的负载平衡,我们将再次让我们的系统在多个实例之间正确地平衡负载。您可以通过解决一些来自 UI 的问题或者直接执行一些对网关服务的 HTTP 调用来验证这一点。如果您选择终端选项,您可以多次运行这个 HTTPie 命令:
$ http POST :8000/attempts factorA=15 factorB=20 userAlias=test-docker-containers guess=300
在日志中,您将看到multiplication_1和multiplication_2如何处理来自 API 的请求。同样的情况也发生在gamification_1和gamification_2上,它们也从代理的队列中获取不同的消息。见清单 8-70 。
multiplication_1 | 2020-07-30 09:48:34.559 INFO [,85acf6d095516f55,956486d186a612dd,true] 1 --- [nio-8080-exec-8] m.b.m.c.ChallengeAttemptController : Received new attempt from test-docker-containers
logs_1 | [multiplication ] [85acf6d095516f55,31829523bbc1d6ea] INFO 09:48:34.559 [http-nio-8080-exec-8] m.b.m.c.ChallengeAttemptController - Received new attempt from test-docker-containers
gamification_1 | 2020-07-30 09:48:34.570 INFO [,85acf6d095516f55,44508dd6f09c83ba,true] 1 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 7
gamification_1 | 2020-07-30 09:48:34.572 INFO [,85acf6d095516f55,44508dd6f09c83ba,true] 1 --- [ntContainer#0-1] m.b.gamification.game
.GameServiceImpl : User test-docker-containers scored 10 points for attempt id 7
logs_1 | [gamification ] [85acf6d095516f55,8bdd9b6febc1eda8] INFO 09:48:34.570 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 7
logs_1 | [gamification ] [85acf6d095516f55,247a930d09b3b7e5] INFO 09:48:34.572 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test-docker-containers scored 10 points for attempt id 7
multiplication_2 | 2020-07-30 09:48:35.332 INFO [,fa0177a130683114,f2c2809dd9a6bc44,true] 1 --- [nio-8080-exec-1] m.b.m.c.ChallengeAttemptController : Received new attempt from test-docker-containers
logs_1 | [multiplication ] [fa0177a130683114,f5b7991f5b1518a6] INFO 09:48:35.332 [http-nio-8080-exec-1] m.b.m.c.ChallengeAttemptController - Received new attempt from test-docker-containers
gamification_2 | 2020-07-30 09:48:35.344 INFO [,fa0177a130683114,298af219a0741f96,true] 1 --- [ntContainer#0-1] m.b.gamification.game.GameEventHandler : Challenge Solved Event received: 7
gamification_2 | 2020-07-30 09:48:35.358 INFO [,fa0177a130683114,298af219a0741f96,true] 1 --- [ntContainer#0-1] m.b.gamification.game.GameServiceImpl : User test-docker-containers scored 10 points for attempt id 7
logs_1 | [gamification ] [fa0177a130683114,2b9ce6cab6366dfb] INFO 09:48:35.344 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameEventHandler - Challenge Solved Event received: 7
logs_1 | [gamification ] [fa0177a130683114,536fbc8035a2e3a2] INFO 09:48:35.358 [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] m.b.g.game.GameServiceImpl - User test-docker-containers scored 10 points for attempt id 7
Listing 8-70Scalability in Action with Docker Containers
共享 Docker 图像
到目前为止,我们构建的所有图像都存储在本地机器中。这无助于我们实现“一次构建,随处部署”的战略目标。然而,我们很亲密。
我们已经知道 Docker Hub,这是一个公共注册中心,我们从这里下载了 RabbitMQ 和 Consul 的官方映像,以及我们的微服务的基础映像。因此,如果我们上传自己的图片,每个人都可以看到。如果你同意的话,你可以在hub.docker.com创建一个免费账户,然后开始上传(用 Docker 的术语来说就是推送)你的自定义图片。如果你想限制对你的图像的访问,他们还提供建立私有存储库的计划,托管在他们的云中。实际上,Docker Hub 并不是你存储 Docker 图片的唯一选择。您也可以按照“部署注册服务器”页面上的说明( https://tpd.io/own-registry )部署自己的注册中心,或者选择不同云供应商提供的在线解决方案之一,如亚马逊的 ECR 或谷歌云的容器注册中心。
在 Docker 注册表中,您可以使用标签保存图像的多个版本。例如,我们的 Spring Boot 映像从pom.xml文件中获得了版本号,因此它们获得了由初始化器创建的默认版本(例如multiplication:0.0.1-SNAPSHOT)。我们可以在 Maven 中保留我们的版本控制策略,但是我们也可以使用docker tag命令手动设置标签。此外,我们可以使用多个标签来引用同一个 Docker 图像。一种常见的做法是给 Docker 图像添加标签latest,以指向注册表中图像的最新版本。作为 Docker 图像版本控制的示例,请参见领事图像的可用标签列表( https://tpd.io/consul-tags )。
为了将 Docker 的命令行工具与注册表连接起来,我们使用了docker login命令。如果我们想连接到私有主机,我们必须添加主机地址。否则,如果我们连接到集线器,我们可以使用普通命令。参见清单 8-71 。
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: [your username]
Password: [your password]
Login Succeeded
Listing 8-71Logging In to Docker Hub
登录后,您可以将图像推送至注册表。请记住,要使它工作,您必须用您的用户名作为前缀来标记它们,因为这是 Docker Hub 的命名约定。让我们按照预期的模式更改其中一个图像的名称。此外,我们将修改版本标识符为0.0.1。在这个例子中,注册的用户名是learnmicro。
$ docker tag multiplication:0.0.1-SNAPSHOT learnmicro/multiplication:0.0.1
现在,您可以使用docker push命令将这个映像推送到注册表中。参见清单 8-72 中的示例。
$ docker push learnmicro/multiplication:0.0.1
The push refers to repository [docker.io/learnmicro/multiplication]
abf6a2c86136: Pushed
9474e9c2336c: Pushing [================================> ] 37.97MB/58.48MB
9474e9c2336c: Pushing [========> ] 10.44MB/58.48MB
5cd38b221a5e: Pushed
d12f80e4be7c: Pushed
c789281314b6: Pushed
2611af6e99a7: Pushing [==================================================>] 7.23MB
02a647e64beb: Pushed
1ca774f177fc: Pushed
9474e9c2336c: Pushing [=================================> ] 39.05MB/58.48MB
8713409436f4: Pushing [===> ] 10.55MB/154.9MB
8713409436f4: Pushing [===> ] 11.67MB/154.9MB
7fbc81c9d125: Waiting
8713409436f4: Pushing [====> ] 12.78MB/154.9MB
9474e9c2336c: Pushed
6c918f7851dc: Pushed
8682f9a74649: Pushed
d3a6da143c91: Pushed
83f4287e1f04: Pushed
7ef368776582: Pushed
0.0.1: digest: sha256:ef9bbed14b5e349f1ab05cffff92e60a8a99e01c412341a3232fcd93aeeccfdc size: 4716
Listing 8-72Pushing an Image to the Public Registry, the Docker Hub
从这一刻起,任何能够访问注册表的人都可以提取我们的图像,并将其用作一个容器。如果我们像示例中那样使用 hub 的公共注册中心,图像就可以公开使用了。如果你很好奇,你可以通过访问 Docker Hub 的链接( https://hub.docker.com/r/learnmicro/multiplication )来验证这张图片是否真的在线。见图 8-44 。
图 8-44
坞站中心中的乘法坞站图像
实际上,我们之前描述的所有 Docker 图像都已经在我的帐户下的公共注册表中可用,前缀为learnmicro/。所有这些第一个版本都被标记为0.0.1。这使得任何 Docker 用户无需构建任何东西就可以获得完整系统的版本并运行。他们只需要使用我们在清单 8-68 中使用的同一个docker-compose.yml文件的一个版本,图像名称替换为指向公共注册表中的现有图像。所需的更改见清单 8-73 。
version: "3"
services:
frontend:
image: learnmicro/challenges-frontend:0.0.1
# ...
multiplication:
image: learnmicro/multiplication:0.0.1
# ...
gamification:
image: learnmicro/gamification:0.0.1
# ...
gateway:
image: learnmicro/gateway:0.0.1
# ...
logs:
image: learnmicro/logs:0.0.1
# ...
consul-importer:
image: learnmicro/consul-importer:0.0.1
# ...
consul-dev:
# same as before
rabbitmq-dev:
# same as before
networks:
# ...
Listing 8-73Changing the docker-compose.yml File to Use Publicly Available Images
我们实现了这一部分的目标。部署我们的应用变得很容易,因为唯一的必要条件是有 Docker 支持。这为我们提供了很多可能性,因为大多数管理和编排分布式系统的平台都支持 Docker 容器部署。在下一部分,我们将学习一些关于平台的基础知识。
平台和云原生微服务
在这一章中,我们讨论了一些模式,这些模式是正确的微服务架构的基础:路由、服务发现、负载平衡、健康报告、集中日志记录、集中配置、分布式跟踪和容器化。
如果我们花一点时间来分析我们系统中的组件,我们会意识到支持三个主要功能部分变得多么复杂:Web UI 和多元化和游戏化后端域。即使我们为许多这些模式采用了流行的实现,我们仍然必须配置它们,甚至部署一些额外的组件,以使我们的系统工作。
此外,我们还没有涉及聚类策略。如果我们在一台机器上部署所有的应用,那么出现问题的风险很高。理想情况下,我们希望复制组件,并将它们分布在多个物理服务器上。幸运的是,我们有工具来管理和协调服务器集群中的不同组件,无论是在您自己的硬件中还是在云中。最流行的替代方法工作在容器级或应用级,我们将分别描述它们。
集装箱平台
首先,让我们关注像 Kubernetes、Apache Mesos 或 Docker Swarm 这样的容器平台。在这些平台中,我们要么直接部署容器,要么使用包装结构,为特定工具提供额外的配置。例如,Kubernetes 中的一个部署单元是一个 pod ,它的定义(为了简单起见,一个部署)可以定义一个要部署的 Docker 容器列表(通常只有一个)和一些额外的参数来设置分配的资源,将 pod 连接到网络,或者添加外部配置和存储。
此外,这些平台通常集成了我们应该已经熟悉的模式。同样,让我们以 Kubernetes 为例,因为它是最受欢迎的选项之一。该列表从较高的角度介绍了它的一些特性:
-
跨构成集群的多个节点的容器编排。当我们在 Kubernetes (pod)中部署一个工作单元时,平台决定在哪里实例化它。如果整个节点死亡或者我们正常地关闭它,Kubernetes 会根据我们对并发实例的配置,找到另一个地方来放置这个工作单元。
-
路由 : Kubernetes 使用入口控制器,允许我们将流量路由到部署的服务。
-
负载平衡:Kubernetes 中的所有 pod 实例通常被配置为使用相同的 DNS 地址。然后,有一个名为 kube-proxy 的组件负责平衡各 pod 之间的负载。其他服务只需要调用一个通用的 DNS 别名,例如
multiplication.myk8scluster.io。这是一种服务器端发现和负载平衡策略,适用于每个服务器组件。 -
自我修复 : Kubernetes 使用 HTTP 探针来确定服务是否有效和就绪。如果它们不是,我们可以配置它来清除那些僵尸实例,并启动新的实例来满足我们的冗余配置。
-
联网:与 Docker Compose 类似,Kubernetes 使用暴露的端口,并提供我们可以配置的不同网络拓扑。
-
集中配置:容器平台提供了像 ConfigMaps 这样的解决方案,所以我们可以将配置层从应用中分离出来,因此可以根据环境进行更改。
除此之外,Kubernetes 在安全性、集群管理和分布式存储管理等方面还有其他内置功能。
因此,我们可以在 Kubernetes 中部署我们的系统,并从所有这些特性中受益。此外,我们可以去掉我们构建的一些模式,将这些责任留给 Kubernetes。
知道如何配置和管理 Kubernetes 集群的人可能永远不会建议您像我们使用 Docker Compose 那样部署裸容器;相反,他们会直接从 Kubernetes 设置开始。然而,容器编排平台引入的额外复杂性永远不应该被低估。如果我们非常了解工具,我们肯定可以快速地建立并运行我们的系统。否则,我们将不得不钻研大量带有自定义 YAML 语法定义的文档。
无论如何,我建议您学习这些容器平台中的一个是如何工作的,并尝试在那里部署我们的系统以进入实际方面。它们在许多组织中很受欢迎,因为它们从应用中抽象出了所有的基础结构层。开发人员可以专注于从编码到构建容器的过程,基础设施团队可以专注于管理不同环境中的 Kubernetes 集群。
应用平台
现在,让我们来介绍一种不同类型的平台:应用运行时平台。这些提供了更高层次的抽象。基本上,我们可以编写自己的代码,构建一个.jar文件,然后将它直接推送到一个环境中,使其随时可供使用。应用平台负责其他一切:将应用容器化(如果需要),在集群节点上运行它,提供负载平衡和路由,保护访问等。这些平台甚至可以聚合日志,并提供其他工具,如消息代理和数据库即服务。
在这个层面上,我们可以找到类似 Heroku 或者 CloudFoundry 这样的解决方案。我们可以选择在自己的服务器中管理这些平台,但最受欢迎的选择是云提供的解决方案。原因是我们可以在几分钟内将我们的产品或服务投入使用,而无需考虑许多模式实现或基础设施方面。
云提供商
为了完成平台和工具的景观,我们不得不提到云解决方案,如 AWS,Google,Azure,OpenShift 等。其中许多还为我们在本章中涉及的模式提供了实现:网关、服务发现、集中式日志、容器化等。
此外,他们通常也提供托管 Kubernetes 服务。这意味着,如果我们喜欢在容器平台级别工作,我们可以不需要手动设置这个平台。当然,这意味着除了我们使用的云资源(机器实例、存储等)之外,我们还必须为这项服务付费。).
请参见图 8-45 中的第一个示例,了解我们如何在云提供商中部署像我们这样的系统。在第一种情况下,我们选择只为一些低级服务付费,比如存储和虚拟机,但是我们安装了自己的 Kubernetes、数据库和 Docker 注册表。这意味着我们避免支付额外的托管服务,但我们必须自己维护所有这些工具。
图 8-45
使用云提供商:示例 1
现在检查图 8-46 中的替代设置。在第二种情况下,我们可以使用云提供商提供的一些额外的托管服务:Kubernetes、网关、Docker 注册表等。例如,在 AWS 中,我们可以使用一个名为 Amazon API Gateway 的网关即服务解决方案将流量直接路由到我们的容器,或者我们也可以选择一个带有自己的路由实现的 Amazon Elastic Kubernetes 服务。在任何一种情况下,我们都避免了以支付更多的云计算服务为代价来实现这些模式和维护这些工具。然而,请考虑到,从长远来看,您可能会通过这种方法节省资金,因为如果您决定采用这种方法,您需要有人来维护所有的工具。
图 8-46
使用云提供商:示例 2
做决定
鉴于有大量的选择,我们应该针对我们的具体情况分析每个抽象层次的利弊。可以想象,高层次的抽象比我们自己在较低层次构建解决方案更昂贵。另一方面,如果我们选择最便宜的选项,我们可能会花更多的钱来设置、维护和改进它。此外,如果我们计划将我们的系统部署到云中,我们应该比较每个供应商的成本,因为可能会有很大的差异。
通常,一个好主意是使用高级解决方案开始一个项目,这转化为托管服务和/或应用平台。它们可能更贵,更难定制,但你可以更快地试用你的产品或服务。然后,如果项目进展顺利,如果在成本方面值得的话,您可以决定获得这些服务的所有权。
云原生微服务
无论我们选择什么选项来部署我们的微服务,我们知道我们应该尊重一些良好的实践,以确保它们在云中正常工作(嗯,理想情况下,在任何环境中):数据层隔离、无状态逻辑、可伸缩性、弹性、简单日志记录等。当我们在本书中学习新的主题时,我们已经考虑了所有这些方面。
我们遵循的许多模式通常包含在云原生微服务的不同定义中。因此,我们可以在应用上贴上这个标签。
然而,术语云原生的太过雄心勃勃,在我看来有时会令人困惑。它被用来包装软件开发的多个方面的一系列术语和技术:微服务、事件驱动、持续部署、基础设施即代码、自动化、容器、云解决方案等。
云原生作为应用的广泛分类的问题是,它可能会导致人们认为他们需要所有包含的模式和方法来实现预期的目标。微服务?当然,这是新标准。事件驱动?为什么不呢?基础设施作为代码?去吧。看起来只有当我们能勾选所有的框,我们才能说我们做的是云原生应用。所有这些模式和技术都有好处,但是您的服务或产品需要所有这些吗?也许不是。您可以构建一个结构良好的整体,用它制作一个容器,并在几分钟内将其部署到云中。最重要的是,你可以自动化所有的过程来建造整块石头,并把它投入生产。那是云原生的纳米石吗?您在任何地方都找不到这个定义,但这并不意味着它不是适合您的特定情况的解决方案。
结论
这一章引导我们经历了一次微服务模式和工具的奇妙之旅。在每一部分中,我们分析了当前实施所面临的问题。然后,我们了解了众所周知的模式,这些模式可以解决这些挑战,同时也有助于使我们的系统具有可伸缩性、弹性、更易于分析和部署等特性。
对于这些模式中的大多数,我们选择了可以很容易地与 Spring Boot 集成的解决方案,因为这是我们实际案例的选择。例如,它的自动配置特性帮助我们快速建立了与作为服务发现注册中心和集中配置服务器的 Consul 的连接。尽管如此,这些模式适用于许多不同的编程语言和框架来构建微服务,因此您可以重用所有学到的概念。
我们的微服务架构变得成熟,一切都开始协同工作:网关将流量透明地路由到我们的微服务的多个实例,这些实例可以根据我们的需求动态分配。所有的日志输出都被传送到一个中心位置,在那里我们还可以看到每个进程的完整轨迹。
我们还使用 Docker 引入了容器化,这有助于准备我们的服务,以便轻松地部署到多个环境中。此外,我们了解了诸如 Kubernetes 和基于云的服务这样的容器平台如何帮助实现我们的非功能性需求:可伸缩性、弹性等。
在这一点上,你可能会问自己,如果有更简单的方法通过容器和应用平台或云中的托管服务来实现相同的结果,我们为什么要花几乎一整章(很长的一章)来了解所有这些常见的微服务模式。原因很简单:您需要知道模式是如何工作的,以便完全理解您正在应用的解决方案。如果您直接从完整的平台或云解决方案开始,您只能获得特定于供应商的高级视图。
通过本章,我们最终完成了在第六章开始的微服务架构的实施。当时,我们决定停止在小块中包含额外的逻辑,并为游戏化领域创建一个新的微服务。这三章帮助我们理解了迁移到微服务的原因,如何正确地隔离和沟通它们,以及如果我们想要项目成功,我们应该考虑哪些模式。
章节成就:
-
您了解了如何使用网关将流量路由到您的微服务,并在它们的实例之间提供负载平衡。
-
您使用服务发现、HTTP 负载平衡器和 RabbitMQ 队列扩展了微服务架构。
-
您通过检查每个实例的健康状况来发现它们何时不工作,从而使系统具有弹性。此外,您引入了重试来避免丢失请求。
-
您看到了如何使用外部配置服务器覆盖每个环境的配置。
-
您实现了跨微服务分布式跟踪的集中式日志,因此您可以轻松地从头到尾跟踪一个流程。
-
您将我们的微服务架构中的所有模式与 Spring Cloud 家族的项目相集成:Spring Cloud Gateway、Spring Cloud Load Balancer、Spring Cloud Consul(发现和配置)和 Spring Cloud Sleuth。
-
您了解了如何使用 Spring Boot 2.3 和云原生构建包为我们的应用创建 Docker 映像。
-
您看到了 Docker 和 Compose 如何帮助我们在任何地方部署微服务架构。此外,您看到了使用 Docker 创建新实例是多么容易。
-
您将我们在书中遵循的方法与其他替代方法(如容器平台和应用平台)进行了比较,这些替代方法已经包含了一些分布式架构所需的模式。
-
您已经理解了为什么我们在本章的每一步中都引入了新的模式和工具。**