精通-Java9-微服务-全-

精通 Java9 微服务(全)

原文:zh.annas-archive.org/md5/EB1A7415EF02ADBBA3AE87C35F6AF10F

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

微服务是设计可扩展、易于维护应用程序的下一个大趋势。它们不仅使应用程序开发变得容易,还提供了极大的灵活性,以优化利用各种资源。如果你想要构建一个企业级的微服务架构实现,那么这本书就是为你准备的!

首先通过理解核心概念和框架,然后重点关注大型软件项目的高级设计。你将逐渐过渡到设置开发环境并配置它,在实现持续集成以部署你的微服务架构之前。使用 Spring Security,你会保护微服务并有效地使用 REST Java 客户端和其他工具,如 RxJava 2.0 进行测试。我们将向你展示微服务设计最佳的模式、实践和常见原则,并学会在开发过程中故障排除和调试问题。我们将向你展示如何设计和实现响应式微服务。最后,我们将向你展示如何将单体应用程序迁移到基于微服务的应用程序。

到本书结束时,你将知道如何构建更小、更轻、更快的服务,这些服务可以很容易地在生产环境中实施。

本书涵盖内容

第一章,解决方案方法,涵盖了大型软件项目的高级设计,并帮助你理解在生产环境中遇到的常见问题以及这些问题的解决方案。

第二章,设置开发环境,介绍了如何搭建开发环境和有效地配置 Spring Boot。你还将学习如何构建一个示例 REST 服务。

第三章,领域驱动设计,教你领域驱动设计的基础知识以及它是如何通过设计示例服务实际应用的。

第四章,实现微服务,向你展示了如何编写服务代码,然后为编写好的代码编写单元测试。

第五章,部署与测试,介绍了如何部署微服务并将它们开发在 Docker 上。你还将学习为微服务编写 Java 测试客户端。

第六章,响应式微服务,展示了如何设计和实现响应式微服务。

第七章,保护微服务,涵盖了不同的安全方法和实现 OAuth 的不同方式。你还将理解 Spring Security 实现。

第八章《使用 Web 应用程序消费微服务》解释了如何使用 Knockout 开发 Web 应用程序(UI)。你需要 Bootstrap JS 库来构建一个 Web 应用程序原型,该应用程序将消费微服务以显示示例项目的数据和流程——一个小型工具项目。

第九章《最佳实践和常用原则》讨论了微服务设计原则。你将学习一种有效的微服务开发方法以及 Netflix 如何实现微服务。

第十章《故障排除指南》解释了在微服务开发过程中遇到的常见问题及其解决方案。这将帮助你顺利地跟随本书,并使学习变得迅速。

第十一章《将单体应用程序迁移到基于微服务的应用程序》向你展示了如何将单体应用程序迁移到基于微服务的应用程序。

你需要这本书什么

对于这本书,你可以使用任何操作系统(Linux、Windows 或 Mac),最低 2 GB 的 RAM。你还需要 NetBeans 带有 Java、Maven、Spring Boot、Spring Cloud、Eureka Server、Docker 和一个 CI/CD 应用程序。对于 Docker 容器,你可能需要一个单独的虚拟机或具有尽可能 16 GB 或更多 RAM 的云主机。

本书适合谁

这本书是给熟悉微服务架构的 Java 开发者的,现在希望深入研究如何在企业级有效实施微服务。预计对核心微服务元素和应用程序有一定的了解。

约定

在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理方式如下所示:"添加了produceBookingOrderEvent方法,它接受booking对象。"

代码块如下所示:

angular.module('otrsApp.restaurants', [ 
  'ui.router', 
  'ui.bootstrap', 
  'ngStorage', 
  'ngResource' 
]) 

任何命令行输入或输出如下所示:

npm install --no-optional gulp

新术语重要词汇以粗体显示。例如,在菜单或对话框中看到的屏幕上的词汇,在文本中会以这种方式出现:"在工具对话框中,选择创建 package.json、创建 bower.json 和创建 gulpfile.js。"

技巧和重要注释以这样的盒子出现。

技巧和小窍门像这样出现。

读者反馈

读者对我们的反馈总是受欢迎的。让我们知道您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您将真正从中受益的标题。要发送一般性反馈,只需给feedback@packtpub.com发电子邮件,并在消息的主题中提到本书的标题。如果您在某个主题上有专业知识,并且有兴趣撰写或贡献一本书,请查看我们的作者指南www.packtpub.com/authors

客户支持

既然您已经成为 Packt 书籍的自豪拥有者,我们有很多事情可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support注册,以便将文件直接通过电子邮件发送给您。您可以按照以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  3. 点击 Code Downloads & Errata。

  4. 在搜索框中输入书籍的名称。

  5. 选择您要下载代码文件的书籍

  6. 从下拉菜单中选择您购买本书的地方。

  7. 点击 Code Download。

一旦文件下载,请确保使用最新版本解压或提取文件夹:

  • 适用于 Windows 的 WinRAR / 7-Zip

  • 适用于 Mac 的 Zipeg / iZip / UnRarX

  • 适用于 Linux 的 7-Zip / PeaZip

该书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Mastering-Microservices-with-Java-9-Second-Edition。我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!

勘误表

虽然我们已经尽一切努力确保我们内容的准确性,但是错误确实会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——我们将非常感谢您能向我们报告。通过这样做,您可以节省其他读者的挫折感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata报告,选择您的书籍,点击勘误提交表单链接,并输入您勘误的详细信息。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分现有勘误列表中。

要查看之前提交的错误,请前往 www.packtpub.com/books/content/support 并在搜索框中输入书籍名称。所需信息将在错误部分下方出现。

盗版

互联网上版权材料的盗版是一个跨所有媒体持续存在的问题。 Packt 对我们版权和许可的保护非常重视。如果您在互联网上以任何形式发现我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求解决方案。

请通过copyright@packtpub.com联系我们,并提供疑似盗版材料的链接。

我们非常感谢您在保护我们的作者和我们提供有价值内容的能力方面所提供的帮助。

问题

如果您在这本书的任何一个方面遇到问题,可以通过questions@packtpub.com联系我们,我们将尽力解决问题。

第一章:解决方案方法

作为先决条件,你应该对微服务和软件架构风格有一个基本的理解。具备基本理解可以帮助你彻底理解概念和本书。

阅读本书后,你可以实现用于本地或云生产部署的微服务,并学习从设计、开发、测试到部署的完整生命周期,以及持续集成和部署。本书专为实际应用和激发您作为解决方案架构师的思维而编写。你的学习将帮助你开发和交付任何类型的场所的产品,包括 SaaS、PaaS 等。我们将主要使用 Java 和基于 Java 的框架工具,如 Spring Boot 和 Jetty,并且我们将使用 Docker 作为容器。

在本章中,你将学习微服务的永恒存在及其演变。它突出了本地和基于云的产品面临的重大问题以及微服务如何解决这些问题。它还解释了在开发 SaaS、企业或大型应用程序过程中遇到的常见问题及其解决方案。

本章我们将学习以下主题:

  • 微服务及其简要背景

  • 单体架构

  • 单体架构的限制

  • 微服务提供的优势和灵活性

  • 在 Docker 等容器上部署微服务

微服务的演变

马丁·福勒解释道:

微服务的术语是在 2011 年 5 月靠近威尼斯的一次软件架构师研讨会上讨论的,以描述与会者认为的一种共同的架构风格,他们中很多人最近都在探索这种风格。2012 年 5 月,同一群人决定将“微服务”(µServices)作为最合适的名称。

让我们回顾一下它是如何在过去几年中发展的。企业架构更多地是从历史的大型机计算,通过客户机-服务器架构(两层到多层)发展到服务导向架构SOA)。

从服务导向架构(SOA)到微服务的转变并非由某个行业协会定义的标准,而是许多组织实践的实用方法。SOA 最终演变为微服务。

前 Netflix 架构师阿德里安·科克洛夫特(Adrian Cockcroft)将其描述为:

细粒度 SOA。因此,微服务是强调小型短暂组件的 SOA。

同样,来自设计 X 窗口系统的成员迈克·甘卡兹(Mike Gancarz)的以下引言,定义了 Unix 哲学的一个基本原则,同样适用于微服务范式:

小即是美。

微服务与 SOA 有很多共同的特征,比如对服务和如何让一个服务与另一个服务解耦的关注。SOA 是围绕单体应用集成而演变的,通过暴露大部分基于简单对象访问协议 (SOAP) 的 API。因此,中间件如企业服务总线 (ESB) 对 SOA 非常重要。微服务更简单,尽管它们可能使用消息总线,但只是用于消息传输,其中不包含任何逻辑。它仅仅基于智能端点。

Tony Pujals 对微服务做了很好的定义:

在我的心智模型中,我想象的是自我包含(如同容器)的轻量级进程,通过 HTTP 进行通信,创建和部署相对简单,为消费者提供狭窄焦点的 API。

尽管 Tony 只提到了 HTTP,但事件驱动的微服务可能使用不同的协议进行通信。你可以使用 Kafka 来实现事件驱动的微服务。Kafka 使用的是线协议,一种基于 TCP 的二进制协议。

单体架构概述

Microservices 并不是什么新鲜事物,它已经存在了很多年。例如,Stubby 是一个基于远程 过程 调用 (RPC) 的通用基础设施,早在 2000 年代初,它就被用于连接 Google 数据中心内和跨数据中心的多个服务。它近期之所以受到关注,是因为它的流行度和可见度。在微服务变得流行之前,开发本地和云应用程序主要采用的是单体架构。

单体架构允许开发不同的组件,如表示层、应用逻辑、业务逻辑和数据访问对象 (DAO),然后你可以将它们捆绑在企业存档 (EAR) 或网络存档 (WAR) 中,或者将它们存储在单个目录层次结构中(例如,Rails、NodeJS 等)。

许多著名的应用程序,如 Netflix,都是使用微服务架构开发的。此外,eBay、Amazon 和 Groupon 也从单体架构演变为微服务架构。

既然你已经对微服务的背景和历史有了了解,那么让我们讨论一下传统方法,即单体应用开发的局限性,并比较微服务如何解决这些问题。

单体架构的限制及其微服务的解决方案

众所周知,变化是永恒的。人类总是寻求更好的解决方案。这就是微服务成为今天这个样子,并可能在未来进一步发展的原因。今天,组织正在使用敏捷方法开发应用程序——这是一个快速的开发环境,并且在云计算和分布式技术发明之后规模也更大了。许多人认为单体架构也可以达到类似的目的,并且与敏捷方法论保持一致,但微服务仍在许多方面为生产就绪应用程序提供了更好的解决方案。

为了理解单体和微服务之间的设计差异,让我们以一个餐厅预订应用程序为例。这个应用程序可能有很多服务,如客户、预订、分析等,以及常规组件,如展示和数据库。

我们将探讨三种不同的设计:传统的单体设计、带服务的单体设计以及微服务设计。

传统的单体设计

下面的图表解释了传统的单体应用程序设计。这种设计在 SOA 变得流行之前被广泛使用:

传统的单体应用程序设计

在传统的单体设计中,一切都被打包在同一个档案中,如展示代码、应用逻辑业务逻辑代码,以及DAO和相关代码,这些代码与数据库文件或其他来源交互。

带服务的单体设计

在 SOA 之后,基于服务的应用程序开始被开发,每个组件为其他组件或外部实体提供服务。下面的图表展示了带有不同服务的单体应用程序;在这里,服务与展示组件一起使用。所有服务、展示组件或任何其他组件都被捆绑在一起:

服务设计

接下来的第三种设计展示了微服务。在这里,每个组件都代表自主性。每个组件可以独立开发、构建、测试和部署。在这里,即使是应用程序用户界面UI)组件也可以是一个客户端,并消费微服务。为了我们的示例,设计层在µService 内部使用。

API 网关提供接口,不同的客户端可以访问单个服务,解决以下问题:

当你想要为同一服务发送不同响应给不同客户端时,你会怎么做?例如,一个预订服务可以为移动客户端(最小信息)和桌面客户端(详细信息)发送不同的响应,提供不同的详细信息,对第三个客户端再次发送不同的信息。

一个响应可能需要从两个或更多服务中获取信息:

在观察了所有的高级设计样本图后,你可能会发现,在单体设计中,组件是捆绑在一起的,并且耦合度很高。

所有服务都是同一个捆绑包的一部分。同样,在第二个设计图中,你可以看到第一个图的一个变体,其中所有服务可能都有自己的层并形成不同的 API,但是,如图所示,这些也都是捆绑在一起的。

相反,在微服务中,设计组件是不捆绑在一起的,并且耦合度很低。每个服务都有自己的层和DB,并且打包在单独的归档文件中。所有这些部署的服务提供它们特定的 API,如客户(Customers)、预订(Bookings)或客户(Customer)。这些 API 是即取即用的。即便是 UI 也是单独部署的,并且使用微服务进行设计。因此,它比单体应用有众多优势。我还是要提醒你,在某些特殊情况下,单体应用开发是非常成功的,如 Etsy 和点对点电子商务网站。

现在让我们讨论在使用单体应用时您可能会遇到的限制。

单一维度的可扩展性

当单体应用规模变大时,它会捆绑在一起扩展所有组件。例如,在餐厅预订桌位的应用中,即使你想要扩展桌位预订服务,它也会扩展整个应用;它不能单独扩展桌位预订服务。它没有充分利用资源。

此外,这种扩展是单一维度的。随着交易量的增加,运行更多应用副本提供了扩展。运维团队可以根据服务器农场或云中的负载,通过负载均衡器调整应用副本的数量。这些副本都会访问相同的数据源,因此增加了内存消耗,而产生的 I/O 操作使缓存效果大打折扣。

微服务赋予了灵活性,只扩展那些需要扩展的服务,并允许资源的最优利用。如我们之前提到的,当需要时,你可以只扩展桌位预订服务,而不影响其他任何组件。它还允许二维扩展;在这里,我们不仅可以增加交易量,还可以通过缓存增加数据量(平台扩展)。

开发团队可以专注于新特性的交付和 shipping,而不是担心扩展问题(产品扩展)。

正如我们之前所看到的,微服务可以帮助你扩展平台、人力和产品维度。这里的人力扩展指的是根据微服务的特定开发和关注需求,增加或减少团队规模。

使用 RESTful Web 服务开发的微服务架构使系统在服务器端是无状态的;这意味着服务器之间的通信不多,这使得系统可以水平扩展。

在失败的情况下进行发布回滚

由于单体应用程序要么打包在同一个归档文件中,要么包含在单个目录中,因此它们阻止了代码模块化的部署。例如,许多人都可能有过因一个功能失败而推迟整个发布的痛苦经历。

为了解决这些问题,微服务为我们提供了灵活性,只回滚失败的功能。这是一种非常灵活且高效的方法。例如,假设你是在线购物门户开发团队的一员,并希望基于微服务开发应用程序。你可以根据不同的领域(如产品、支付、购物车等)将应用程序进行划分,并将所有这些组件作为单独的包进行打包。一旦你单独部署了所有这些包,它们将作为可以独立开发、测试和部署的单一组件,并被称为微服务。

现在,让我们看看这如何帮助你。假设在生产环境中推出新功能、增强功能和修复程序后,你发现支付服务存在缺陷需要立即修复。由于你使用的架构是基于微服务的,因此如果你的应用程序架构允许,你可以只回滚支付服务,而不是整个发布,或者在不影响其他服务的情况下将修复程序应用于微服务支付服务。这不仅使你能够恰当地处理失败,而且还帮助您迅速将功能/修复传递给客户。

采用新技术的问题

单体应用程序主要是基于项目或产品最初开发阶段主要使用的技术进行开发和增强的。这使得在开发的后期阶段或产品成熟后(例如,几年后)引入新技术变得非常困难。此外,同一项目中依赖不同版本的同一库的不同模块使这更具挑战性。

技术每年都在进步。例如,您的系统可能设计为用 Java 实现,然后几年后,由于业务需求或利用新技术的优势,您可能希望用 Ruby on Rails 或 NodeJS 开发一个新服务。在一个现有的单体应用程序中利用新技术将非常困难。

这不仅仅是代码级别集成的問題,还包括测试和部署。可以通过重写整个应用程序来采用新技术,但这既耗时又冒险。

另一方面,由于其基于组件的开发和设计,微服务为我们提供了使用任何技术的灵活性,无论是新的还是旧的。它不会限制你使用特定的技术,为你的开发和工程活动提供了新的范式。你随时可以使用 Ruby on Rails、NodeJS 或其他任何技术。

那么,这是如何实现的呢?嗯,其实很简单。基于微服务的应用程序代码不会打包成一个单一的归档,也不会存储在单一的目录中。每个微服务都有自己的归档,并且是独立部署的。一个新的服务可以在一个隔离的环境中开发,并且可以没有任何技术问题地进行测试和部署。正如你所知,微服务也有自己的独立进程;它在不存在紧耦合的共享资源冲突的情况下完成其功能,并且进程保持独立。

由于微服务定义上是一个小型的、自包含的功能,它提供了一个尝试新技术的低风险机会。而在单体系统中,情况绝对不是这样。

你还可以让你的微服务作为开源软件提供给他人使用,如果需要,它还可以与闭源专有软件互操作,这是单体应用程序所不可能实现的。

与敏捷实践的对齐

毫无疑问,可以使用敏捷实践来开发单体应用程序,而且这样的应用程序正在被开发。可以采用持续集成(CI)持续部署(CD),但问题在于——它是否有效地使用了敏捷实践?让我们来分析以下几点:

  • 例如,当有高概率的故事相互依赖,并且有各种场景时,只有在依赖的故事完成后才能开始一个故事。

  • 随着代码规模的增加,构建所需的时间也会增加。

  • 频繁部署大型单体应用程序是一项难以实现的任务。

  • 即使你只更新了一个组件,你也必须重新部署整个应用程序。

  • 重新部署可能会对正在运行的组件造成问题,例如,作业调度器可能会改变无论组件是否受其影响。

  • 如果单个更改的组件不能正常工作或需要更多的修复,重新部署的风险可能会增加。

  • 界面开发者总是需要更多的重新部署,这对于大型单体应用程序来说是非常冒险和耗时的。

微服务可以很轻松地解决前面提到的问题,例如,UI 开发者可能有自己的 UI 组件,可以独立地开发、构建、测试和部署。同样,其他微服务也可能可以独立部署,由于它们具有自主特性,因此降低了系统失败的风险。对于开发来说,另一个优点是 UI 开发者可以利用 JSON 对象和模拟 Ajax 调用来开发 UI,这种方式是隔离的。开发完成后,开发者可以消费实际的 API 并进行功能测试。总结来说,可以说微服务开发是迅速的,并且很好地适应了企业逐步增长的需求。

开发容易度 - 可以做得更好

通常,大型单体应用程序的代码对于开发者来说是最难以理解的,新开发者需要时间才能变得高效。即使将大型单体应用程序加载到 IDE 中也是麻烦的,这会使得 IDE 变慢,并降低开发者的效率。

在一个大型单体应用程序中进行更改是困难的,并且由于代码库庞大,需要更多的时间,如果没有进行彻底的影响分析,就会有很高的 bug 风险。因此,在实施更改之前,开发者进行彻底的影响分析是一个前提条件。

在单体应用程序中,随着时间的推移,依赖关系逐渐建立,因为所有组件都捆绑在一起。因此,与代码更改(修改的代码行数)增长相关的风险呈指数级上升。

当代码库很大且有超过 100 个开发者正在工作时,由于之前提到的原因,构建产品和实施新功能变得非常困难。你需要确保一切就绪,并且一切协调一致。在这种情况下,设计良好且文档齐全的 API 会有很大帮助。

Netflix,这个按需互联网流媒体服务提供商,在他们有大约 100 人在开发应用程序时遇到了问题。然后,他们使用了云,并将应用程序拆分成不同的部分。这些最终成为了微服务。微服务源于对速度和敏捷性的渴望,以及独立部署团队的需求。

由于微组件通过暴露的 API 实现了松耦合,可以持续进行集成测试。在微服务的持续发布周期中,变化很小,开发人员可以快速地进行回归测试,然后进行审查并修复发现的缺陷,从而降低了部署的风险。这导致了更高的速度和较低的相关风险。

由于功能分离和单一责任原则,微服务使团队非常高效。你可以在网上找到许多例子,大型项目是用最小的团队规模(如八到十名开发者)开发的。

开发人员可以拥有更小的代码和更好的特性实现,从而与产品的用户建立更强的共情关系。这有助于在特性实现上取得更好的动机和清晰度。与用户的共情关系可以实现更短的反馈循环,更好地快速优先处理特性管道。更短的反馈循环也可以使缺陷检测更快。

每个微服务团队独立工作,可以无需与更多人协调就实施新功能或想法。在微服务设计中,端点失败处理也很容易实现。

最近,在一场会议中,一个团队展示了他们如何在一个为期 10 周的项目中开发了一个基于微服务的运输跟踪应用程序,包括 iOS 和 Android 应用程序,并具有 Uber 类型的跟踪功能。一家大型咨询公司为其客户提供了一个为期七个月的同一应用程序估计。这显示了微服务如何与敏捷方法和持续集成/持续部署(CI/CD)保持一致。

微服务构建管道

微服务也可以使用流行的持续集成/持续部署(CI/CD)工具如 Jenkins、TeamCity 等来构建和测试。这与在单体应用中进行构建非常相似。在微服务中,每个微服务都被当作一个小应用程序来对待。

例如,一旦您在仓库(SCM)中提交代码,CI/CD 工具就会触发构建过程:

  • 清理代码

  • 代码编译

  • 执行单元测试

  • 合同/验收测试执行

  • 构建应用程序归档/容器镜像

  • 将归档/容器镜像发布到仓库管理

  • 在各种交付环境(如 Dev、QA、Stage 等)上进行部署

  • 集成和功能测试执行

  • 其他任何步骤

然后,在pom.xml(对于 Maven)中,发布构建触发器会更改 SNAPSHOT 或 RELEASE 版本,按照正常的构建触发器描述构建工件。将工件发布到工件仓库。在仓库中为此版本打上标签。如果您使用容器镜像,则将容器镜像作为构建的一部分来构建。

使用如 Docker 之类的容器进行部署

由于微服务的设计,您需要一个提供灵活性、敏捷性和平滑性的环境,以便进行持续集成和部署,以及发货。微服务的部署需要速度、隔离管理以及敏捷的生命周期。

产品和软件也可以使用集装箱模型进行运输。集装箱是一种大型标准化容器,专为多式联运而设计。它允许货物使用不同的运输方式——卡车、铁路或船舶,而无需卸载和装载。这是一种存储和运输物品高效且安全的方式。它解决了运输问题,过去这是一个耗时、劳动密集型的过程,重复处理经常会损坏易碎物品。

运输集装箱封装了它们的内容。同样,软件容器正开始被用来封装它们的内容(产品、应用程序、依赖项等)。

以前,虚拟机VM)被用来创建可以在需要时部署的软件镜像。后来,像 Docker 这样的容器变得更为流行,因为它们既兼容传统的虚拟站系统,也兼容云环境。例如,在开发人员的笔记本电脑上部署多个虚拟机是不切实际的。构建和引导虚拟机通常是 I/O 密集型的,因此速度较慢。

容器

容器(例如,Linux 容器)提供了一个轻量级的运行时环境,该环境包括了虚拟机的核心功能和操作系统的隔离服务。这使得微服务的打包和执行变得容易且流畅。

如以下图表所示,容器作为应用程序(微服务)在操作系统内运行。操作系统位于硬件之上,每个操作系统可能具有多个容器,其中一个容器运行应用程序。

容器利用操作系统的内核接口,如cnamesnamespaces,使得多个容器能够在完全隔离的情况下共享同一个内核。这使得不需要为每个使用情况完成一个操作系统安装;结果是它消除了开销。它还使硬件得到最佳利用

容器层图

Docker

容器技术是当今发展最快的技术之一,Docker 领导这一领域。Docker 是一个开源项目,它于 2013 年启动。2013 年 8 月其互动教程发布后,10,000 名开发者尝试了它。到 2013 年 6 月其 1.0 版本发布时,它已经被下载了 275 万次。许多大型公司已经与 Docker 签署了合作伙伴协议,如微软、红帽、惠普、OpenStack,以及服务提供商如亚马逊网络服务、IBM 和谷歌。

如我们之前提到的,Docker 也利用了 Linux 内核的功能,如 cgroups 和 namespaces,以确保资源隔离和应用及其依赖的打包。这种依赖的打包使得应用能够如预期地在不同的 Linux 操作系统/发行版上运行,支持一定程度的可移植性。此外,这种可移植性允许开发者在任何语言中开发应用程序,然后轻松地将它从笔记本电脑部署到测试或生产服务器。

Docker 原生运行在 Linux 上。然而,你也可以使用 VirtualBox 和 boot2docker 在 Windows 和 MacOS 上运行 Docker。

容器只包括应用程序及其依赖项,包括基本操作系统。这使得它在资源利用方面轻量且高效。开发人员和系统管理员对容器的可移植性和高效资源利用感兴趣。

Docker 容器中的所有内容都在宿主机上以原生方式执行,并直接使用宿主机内核。每个容器都有自己的用户命名空间。

Docker 的架构

如 Docker 文档所述,Docker 架构采用客户端-服务器架构。如图所示(来源于 Docker 官网:docs.docker.com/engine/docker-overview/),Docker 客户端主要是用户界面,用于终端用户;客户端与 Docker 守护进程进行通信。Docker 守护进程负责构建、运行和分发你的 Docker 容器。Docker 客户端和守护进程可以运行在同一系统或不同机器上。

Docker 客户端和守护进程通过套接字或通过 RESTful API 进行通信。Docker 注册表是公开或私有的 Docker 镜像仓库,你可以从中上传或下载镜像,例如,Docker Hub(hub.docker.com)是一个公开的 Docker 注册表。

Docker 的架构

Docker 的主要组件包括:

  • Docker 镜像:Docker 镜像是一个只读模板。例如,一个镜像可能包含一个带有 Apache 网页服务器和你的网页应用的 Ubuntu 操作系统。Docker 镜像 是 Docker 构建组件之一,镜像用于创建 Docker 容器。Docker 提供了一种简单的方法来构建新的镜像或更新现有镜像。你也可以使用他人创建的镜像,/或扩展它们。

  • Docker 容器:Docker 容器是从 Docker 镜像创建的。Docker 的工作原理是,容器只能看到自己的进程,并且在其宿主文件系统之上有自己的文件系统层和网络堆栈,这些管道到宿主网络堆栈。Docker容器可以被运行、启动、停止、移动或删除。

部署

使用 Docker 进行微服务部署涉及三个部分:

  • 应用程序打包,例如,JAR

  • 使用 Docker 指令文件,Dockerfile 和命令docker build构建包含 JAR 和依赖项的 Docker 镜像。它有助于反复创建镜像。

  • 使用命令docker run从新构建的镜像中执行 Docker 容器。

前面的信息将帮助你理解 Docker 的基础知识。你将在第五章,部署与测试中了解更多关于 Docker 及其实际应用。源代码和参考资料,参考: docs.docker.com

总结

在本章中,你已经学习了大型软件项目的高级设计,从传统的单体应用到微服务应用。你还简要了解了微服务的历史、单体应用的局限性以及微服务所提供的优势和灵活性。我希望这一章能帮助你理解单体应用在生产环境中遇到的一些常见问题以及微服务如何解决这些问题。你还了解到了轻量级且高效的 Docker 容器,并看到了容器化是简化微服务部署的绝佳方式。

在下一章中,你将了解到如何从 IDE 设置开发环境,以及其他开发工具和不同的库。我们将处理创建基本项目并设置 Spring Boot 配置来构建和开发我们的第一个微服务。我们将使用 Java 9 作为编程语言和 Spring Boot 来完成项目。

第二章:设置开发环境

本章重点介绍开发环境的设置和配置。如果你熟悉工具和库,可以跳过本章,继续阅读第三章,领域驱动设计,在那里你可以探索领域驱动设计DDD)。

本章将涵盖以下主题:

  • NetBeans IDE 的安装和设置

  • Spring Boot 配置

  • 使用 Java 9 模块的示例 REST 程序

  • 构建设置

  • 使用 Chrome 的 Postman 扩展进行 REST API 测试

本书将只使用开源工具和框架作为示例和代码。本书还将使用 Java 9 作为编程语言,应用程序框架将基于 Spring 框架。本书利用 Spring Boot 来开发微服务。

NetBeans 的集成开发环境IDE)为 Java 和 JavaScript 提供最先进的支持,足以满足我们的需求。它多年来已经发生了很大的变化,并内置了对本书中使用的大多数技术的支持,如 Maven、Spring Boot 等。因此,我建议你使用 NetBeans IDE。不过,你也可以自由选择任何 IDE。

我们将使用 Spring Boot 来开发 REST 服务和微服务。在本书中选择 Spring 框架中最受欢迎的 Spring Boot 或其子集 Spring Cloud 是一个明智的选择。因此,我们不需要从零开始编写应用程序,它为大多数云应用程序中使用的技术提供了默认配置。Spring Boot 的概述在 Spring Boot 的配置部分提供。如果你是 Spring Boot 的新手,这绝对会帮助你。

我们将使用 Maven 作为我们的构建工具。与 IDE 一样,你可以使用任何你想要的构建工具,例如 Gradle 或带有 Ivy 的 Ant。我们将使用内嵌的 Jetty 作为我们的 Web 服务器,但另一个选择是使用内嵌的 Tomcat Web 服务器。我们还将使用 Chrome 的 Postman 扩展来测试我们的 REST 服务。

我们将从 Spring Boot 配置开始。如果你是 NetBeans 的新手或者在设置环境时遇到问题,可以参考以下部分。

NetBeans IDE 的安装和设置

NetBeans IDE 是免费且开源的,拥有庞大的用户社区。您可以从它的官方网站netbeans.org/downloads/下载 NetBeans IDE。

在撰写本书时,NetBeans for Java 9 只能作为夜间构建版本提供(可从bits.netbeans.org/download/trunk/nightly/latest/下载)。如下图所示,下载所有受支持的 NetBeans 捆绑包,因为我们将使用 JavaScript:

NetBeans 捆绑包

GlassFish 服务器和 Apache Tomcat 是可选的。必需的包和运行时环境标记为已安装(因为 NetBeans 已经在我的系统上安装了):

NetBeans 包装和运行时

下载安装程序后,执行安装文件。如下截图所示,接受许可协议,并按照其余步骤安装 NetBeans IDE:

NetBeans 许可对话框

安装和运行所有 NetBeans 捆绑包需要 JDK 8 或更高版本。本书使用 Java 9,因此我们将使用 JDK 9。您可以从 www.oracle.com/technetwork/java/javase/downloads/index.html 下载独立的 JDK 9。我不得不使用 JDK 9 的早期访问构建,因为 JDK 9 写作本书时还没有发布。它可以在 jdk.java.net/9/ 找到。

安装 NetBeans IDE 后,启动 NetBeans IDE。NetBeans IDE 应该如下所示:

NetBeans 开始页面

Maven 和 Gradle 都是 Java 构建工具。它们为您的项目添加依赖库,编译您的代码,设置属性,构建归档,并执行许多其他相关活动。Spring Boot 或 Spring Cloud 支持 Maven 和 Gradle 构建工具。然而,在本书中,我们将使用 Maven 构建工具。如果您喜欢,请随意使用 Gradle。

Maven 已经在 NetBeans IDE 中可用。现在,我们可以开始一个新的 Maven 项目来构建我们的第一个 REST 应用程序。

创建新空 Maven 项目的步骤如下:

  1. 点击文件菜单下的“新建项目”(Ctrl + Shift + N),它会打开新建项目向导。

  2. 从“类别”列表中选择 Maven。然后,从“项目”列表中选择 POM 项目,如下截图所示。然后,点击下一步按钮。

新项目向导

  1. 现在,输入项目名称为 6392_chapter2。此外,还应输入如下截图中显示的其他属性。填写完所有必填字段后,点击“完成”:

NetBeans Maven 项目属性

Aggelos Karalias 为 NetBeans IDE 开发了一个有用的插件,提供对 Spring Boot 配置属性的自动完成支持,该插件可在 github.com/keevosh/nb-springboot-configuration-support 找到。您可以从他在 keevosh.github.io/nb-springboot-configuration-support/ 的项目页面下载它。您还可以使用 Pivotal 的 Spring Tool Suite IDE (spring.io/tools) 代替 NetBeans IDE。它是一个定制的集成所有功能的基于 Eclipse 的分发版,使应用开发变得简单。

完成所有前面的步骤后,NetBeans 将显示一个新创建的 Maven 项目。你将使用这个项目来创建一个使用 Spring Boot 的示例 rest 应用程序。

  1. 要使用 Java 9 作为源,将源/二进制格式设置为 9,如下面的屏幕截图所示:

NetBeans Maven 项目属性 - 源代码

  1. 前往构建 | 编译,并确保将 Java 平台设置为 JDK 9(默认)如下:

NetBeans Maven 项目属性 - 编译

  1. 同样地,你可以在Modules文件夹中通过打开右键菜单,然后选择创建新模块的选项,添加两个名为librest的新模块。这次你应该在新项目对话框框中从类别列表中选择Maven,并从项目列表中选择 Java 应用程序。

Spring Boot 配置

Spring Boot 是开发特定于 Spring 的生产级别先进应用程序的明显选择。其网站(projects.spring.io/spring-boot/)也阐述了它的真正优势:

采用了一种有见解的观点来构建生产级别的 Spring 应用程序。Spring Boot 优先考虑约定优于配置,并旨在让你尽快运行起来。

Spring Boot 概览

Pivotal创建的 Spring Boot 是一个令人惊叹的 Spring 工具,并于 2014 年 4 月(GA)发布。它是基于 SPR-9888(jira.spring.io/browse/SPR-9888)的请求创建的,标题为改进对“无容器”的 web 应用程序架构的支持

你可能会想知道,为什么是无容器呢?因为,今天的云环境或 PaaS 提供了基于容器 web 架构的大部分功能,如可靠性、管理或扩展。因此,Spring Boot 专注于将自己打造成一个超轻量级的容器。

Spring Boot 预先配置好了,可以非常容易地制作出生产级别的 web 应用程序。Spring Initializrstart.spring.io)是一个页面,你可以选择构建工具,如 Maven 或 Gradle,以及项目元数据,如组、工件和依赖关系。一旦输入了所需字段,你只需点击生成项目按钮,就会得到你可用于生产应用程序的 Spring Boot 项目。

在这个页面上,默认的打包选项是 JAR。我们也将为我们的微服务开发使用 JAR 打包。原因非常简单:它使微服务开发更容易。想想管理并创建一个每个微服务在其自己的服务器实例上运行的基础设施有多困难。

在 Spring IOs 的一次演讲中,Josh Long 分享道:

“最好是制作 JAR,而不是 WAR。”

稍后,我们将使用 Spring Cloud,它是建立在 Spring Boot 之上的一个包装器。

我们将开发一个示例 REST 应用程序,该应用程序将使用 Java 9 模块功能。我们将创建两个模块——librestlib模块将为rest模块提供模型或任何支持类。rest模块将包括开发 REST 应用程序所需的所有类,并且还将消耗在lib模块中定义的模型类。

librest模块都是maven模块,它们的parent模块是我们的主项目6392_chapter2

module-info.java文件是一个重要的类,它管理着对其类的访问。我们将利用requiresopensexports来使用spring模块,并在我们 REST 应用程序的librest模块之间建立提供者-消费者关系。

将 Spring Boot 添加到我们的主项目中

我们将使用 Java 9 来开发微服务。因此,我们将使用最新的 Spring 框架和 Spring Boot 项目。在撰写本文时,Spring Boot 2.0.0 构建快照版本是可用的。

你可以使用最新发布的版本。Spring Boot 2.0.0 构建快照使用 Spring 5(5.0.0 构建快照版本)。

让我们来看看以下步骤,了解如何将 Spring Boot 添加到我们的主项目中。

  1. 打开pom.xml文件(在6392_chapter2 | 项目文件中可用),以将 Spring Boot 添加到您的示例项目中:
<?xml version="1.0" encoding="UTF-8"?> 
<project  

         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion> 

    <groupId>com.packtpub.mmj</groupId> 
    <artifactId>6392_chapter2</artifactId> 
    <version>1.0-SNAPSHOT</version> 
    <packaging>pom</packaging> 

    <modules> 
        <module>lib</module> 
        <module>rest</module> 
    </modules> 

    <properties> 
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 
        <spring-boot-version>2.0.0.BUILD-SNAPSHOT</spring-boot-version> 
        <spring-version>5.0.0.BUILD-SNAPSHOT</spring-version> 
        <maven.compiler.source>9</maven.compiler.source> 
        <maven.compiler.target>9</maven.compiler.target> 
        <start-class>com.packtpub.mmj.rest.RestSampleApp</start-class> 
    </properties> 
    <parent> 
        <groupId>org.springframework.boot</groupId> 
        <artifactId>spring-boot-starter-parent</artifactId> 
        <version>2.0.0.BUILD-SNAPSHOT</version> 
    </parent> 
    <dependencyManagement> 
        <dependencies> 
            <dependency> 
                <groupId>com.packtpub.mmj</groupId> 
                <artifactId>rest</artifactId> 
                <version>${project.version}</version> 
            </dependency> 
            <dependency> 
                <groupId>com.packtpub.mmj</groupId> 
                <artifactId>lib</artifactId> 
                <version>${project.version}</version> 
            </dependency> 
        </dependencies> 
    </dependencyManagement> 

    <build> 
        <plugins> 
            <plugin> 
                <groupId>org.springframework.boot</groupId> 
                <artifactId>spring-boot-maven-plugin</artifactId> 
                <version>2.0.0.BUILD-SNAPSHOT</version> 
                <executions> 
                    <execution> 
                        <goals> 
                            <goal>repackage</goal> 
                        </goals> 
                        <configuration> 
                            <classifier>exec</classifier> 
                            <mainClass>${start-class}</mainClass> 
                        </configuration> 
                    </execution> 
                </executions> 
            </plugin> 
            <plugin> 
                <groupId>org.apache.maven.plugins</groupId> 
                <artifactId>maven-compiler-plugin</artifactId> 
                <version>3.6.1</version> 
                <configuration> 
                    <source>1.9</source> 
                    <target>1.9</target> 
                    <showDeprecation>true</showDeprecation> 
                    <showWarnings>true</showWarnings> 
                </configuration> 
            </plugin> 
        </plugins> 
    </build> 
    <repositories> 
        <repository> 
            <id>spring-snapshots</id> 
            <name>Spring Snapshots</name> 
            <url>https://repo.spring.io/snapshot</url> 
            <snapshots> 
                <enabled>true</enabled> 
            </snapshots> 
        </repository> 
        <repository> 
            <id>spring-milestones</id> 
            <name>Spring Milestones</name> 
            <url>https://repo.spring.io/milestone</url> 
            <snapshots> 
                <enabled>false</enabled> 
            </snapshots> 
        </repository> 
    </repositories> 

    <pluginRepositories> 
        <pluginRepository> 
            <id>spring-snapshots</id> 
            <name>Spring Snapshots</name> 
            <url>https://repo.spring.io/snapshot</url> 
            <snapshots> 
                <enabled>true</enabled> 
            </snapshots> 
        </pluginRepository> 
        <pluginRepository> 
            <id>spring-milestones</id> 
            <name>Spring Milestones</name> 
            <url>https://repo.spring.io/milestone</url> 
            <snapshots> 
                <enabled>false</enabled> 
            </snapshots> 
        </pluginRepository> 
    </pluginRepositories> 
</project> 

你可以观察到,我们在父项目pom.xml中定义了我们的两个模块librest

  1. 如果你第一次添加这些依赖项,你需要通过在项目窗格中6392_chapter2项目的Dependencies文件夹下右键点击,下载依赖关系,如下面的屏幕截图所示:

在 NetBeans 中下载 Maven 依赖项

  1. 同样,为了解决项目问题,右键点击 NetBeans 项目6392_chapter2,选择“解决项目问题...”。它将打开如下所示的对话框。点击“解决...”按钮来解决这些问题:

解决项目问题对话框

  1. 如果你在代理后面使用 Maven,那么需要更新 Maven 主目录中的settings.xml中的proxies。如果你使用的是与 NetBeans 捆绑的 Maven,则使用<NetBeans 安装目录>\java\maven\conf\settings.xml。你可能需要重新启动 NetBeans IDE。

上述步骤将从远程 Maven 仓库下载所有必需的依赖项,如果声明的依赖项和传递依赖项在本地 Maven 仓库中不可用。如果你是第一次下载依赖项,那么它可能需要一些时间,这取决于你的互联网速度。

示例 REST 程序

我们将采用一种简单的构建独立应用程序的方法。它将所有内容打包成一个可执行的 JAR 文件,由一个main()方法驱动。在这个过程中,您使用 Spring 支持将 Jetty Servlet 容器作为 HTTP 运行时嵌入,而不是将其部署到外部实例。因此,我们将创建代替需要部署在外部 Web 服务器上的 war 的可执行 JAR 文件,这是rest模块的一部分。我们将在lib模块中定义领域模型和rest模块中相关的 API 类。

以下是librest模块的pom.xml文件。

lib模块的pom.xml文件:

<?xml version="1.0" encoding="UTF-8"?> 
<project   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion> 
    <parent> 
        <groupId>com.packtpub.mmj</groupId> 
        <artifactId>6392_chapter2</artifactId> 
        <version>1.0-SNAPSHOT</version> 
    </parent> 
    <artifactId>lib</artifactId> 
</project> 

rest模块的pom.xml文件:

    <modelVersion>4.0.0</modelVersion> 
    <parent> 
        <groupId>com.packtpub.mmj</groupId> 
        <artifactId>6392_chapter2</artifactId> 
        <version>1.0-SNAPSHOT</version> 
    </parent> 
    <artifactId>rest</artifactId> 
  <dependencies> 
        <dependency> 
            <groupId>com.packtpub.mmj</groupId> 
            <artifactId>lib</artifactId> 
        </dependency> 
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-web</artifactId> 
    ... 
    ...  

在这里,spring-boot-starter-web依赖项用于开发独立的可执行 REST 服务。

我们将在librest模块的默认包中分别添加以下module-info.java类。

lib模块的module-info.java文件:

module com.packtpub.mmj.lib { 
    exports com.packtpub.mmj.lib.model to com.packtpub.mmj.rest; 
    opens com.packtpub.mmj.lib.model; 
} 

在这里,我们导出了com.packtpub.mmj.lib.model包到com.packtpub.mmj.rest,这允许lib模型类对rest模块类进行访问。

lib模块的module-info.java文件:

module com.packtpub.mmj.rest { 

    requires spring.core; 
    requires spring.beans; 
    requires spring.context; 
    requires spring.aop; 
    requires spring.web; 
    requires spring.expression; 

    requires spring.boot; 
    requires spring.boot.autoconfigure; 

    requires com.packtpub.mmj.lib; 

    exports com.packtpub.mmj.rest; 
    exports com.packtpub.mmj.rest.resources; 

    opens com.packtpub.mmj.rest; 
    opens com.packtpub.mmj.rest.resources; 
} 

在这里,我们使用requires语句添加了所有必需的springlib包,这使得rest模块的类能够使用在springlib模块中定义的类。同时,我们导出了com.packt.mmj.restcom.packt.mmj.rest.resources包。

现在,既然您已经准备好使用 NetBeans IDE 的 Spring Boot,您可以创建一个示例 Web 服务。您将创建一个执行简单计算并生成 JSON 结果的数学 API。

让我们讨论如何调用 REST 服务并获取响应。

该服务将处理/calculation/sqrt/calculation/powerGET请求。GET请求应返回一个带有表示给定数字平方根的 JSON 体的200 OK响应。它看起来像这样:

{ 
  "function": "sqrt", 
  "input": [ 
    "144" 
  ], 
  "output": [ 
    "12.0" 
  ] 
} 

input字段是平方根函数的输入参数,内容是结果的文本表示。

您可以创建一个资源表示类,使用普通老式 Java 对象POJO)建模表示,并为输入、输出和功能数据使用字段、构造函数、设置器和获取器。由于它是一个模型,我们将在lib模块中创建它:

package com.packtpub.mmj.lib.model; 

import java.util.List; 

public class Calculation { 

    String function; 
    private List<String> input; 
    private List<String> output; 

    public Calculation(List<String> input, List<String> output, String function) { 
        this.function = function; 
        this.input = input; 
        this.output = output; 
    } 

    public List<String> getInput() { 
        return input; 
    } 

    public void setInput(List<String> input) { 
        this.input = input; 
    } 

    public List<String> getOutput() { 
        return output; 
    } 

    public void setOutput(List<String> output) { 
        this.output = output; 
    } 

    public String getFunction() { 
        return function; 
    } 

    public void setFunction(String function) { 
        this.function = function; 
    } 

} 

编写 REST 控制器类

罗伊·菲尔丁在他的博士论文中定义并引入了代表性状态传输REST)这个术语。REST 是一种软件架构风格,用于分布式超媒体系统,如 WWW。遵循 REST 架构属性的系统称为 RESTful。

现在,您将创建一个 REST 控制器来处理Calculation资源。控制器在 Spring RESTful Web 服务实现中处理 HTTP 请求。

@RestController 注解

@RestController是用于在 Spring 4 中引入的resource类的类级别注解。它是@Controller@ResponseBody的组合,因此,一个类返回领域对象而不是视图。

在下面的代码中,你可以看到CalculationController类通过返回calculation类的新实例处理GET请求/calculation

我们将为Calculation资源实现两个 URI——平方根(Math.sqrt()函数)作为/calculations/sqrt URI,幂(Math.pow()函数)作为/calculation/power URI。

@RequestMapping注解

@RequestMapping注解用于类级别以将/calculation URI 映射到CalculationController类,即,它确保对/calculation的 HTTP 请求映射到CalculationController类。基于使用@RequestMapping注解定义的路径的 URI(例如,/calculation/sqrt/144的后缀),它将映射到相应的函数。在这里,请求映射/calculation/sqrt被映射到sqrt()方法,/calculation/power被映射到pow()方法。

您可能还注意到我们没有定义这些方法将使用什么请求方法(GET/POST/PUT等)。@RequestMapping注解默认映射所有 HTTP 请求方法。您可以使用RequestMapping的 method 属性来指定方法。例如,您可以像下面这样使用POST方法写一个@RequestMethod注解:

@RequestMapping(value = "/power", method = POST) 

为了在途中传递参数,示例展示了使用@RequestParam@PathVariable注解的请求参数和路径参数。

@RequestParam注解

@RequestParam负责将查询参数绑定到控制器的方法的参数。例如,QueryParam基底和指数分别绑定到CalculationControllerpow()方法的参数be。由于我们没有为这两个查询参数使用任何默认值,所以pow()方法的这两个查询参数都是必需的。查询参数的默认值可以通过@RequestParamdefaultValue属性设置,例如,@RequestParam(value="base", defaultValue="2")。在这里,如果用户没有传递查询参数 base,那么默认值2将用于基数。

如果没有定义defaultValue,并且用户没有提供请求参数,那么RestController将返回 HTTPstatus代码400以及消息Required String parameter 'base' is not present。如果多个请求参数缺失,它总是使用第一个必需参数的引用:

{ 
  "timestamp": 1464678493402, 
  "status": 400, 
  "error": "Bad Request", 
  "exception": "org.springframework.web.bind.MissingServletRequestParameterException", 
  "message": "Required String parameter 'base' is not present", 
  "path": "/calculation/power/" 
} 

@PathVariable注解

@PathVariable帮助你创建动态 URI。@PathVariable注解允许你将 Java 参数映射到路径参数。它与@RequestMapping一起工作,在 URI 中创建占位符,然后使用相同的名字作为PathVariable或方法参数,正如你在CalculationController类的sqrt()方法中看到的。在这里,值占位符在@RequestMapping内部创建,相同的值分配给@PathVariable的值。

sqrt()方法在 URI 中以请求参数的形式接收参数,例如http://localhost:8080/calculation/sqrt/144。在这里,144值作为路径参数传递,这个 URL 应该返回144的平方根,即12

为了使用基本的检查,我们使用正则表达式"^-?+\\d+\\.?+\\d*$"来只允许参数中的有效数字。如果传递了非数字值,相应的方法会在 JSON 的输出键中添加错误消息:

CalculationController还使用正则表达式.+path变量(path参数)中允许数字值中的小数点(.):/path/{variable:.+}。Spring 忽略最后一个点之后的所有内容。Spring 的默认行为将其视为文件扩展名。

还有其他选择,例如在末尾添加一个斜杠(/path/{variable}/),或者通过设置useRegisteredSuffixPatternMatchtrue来覆盖WebMvcConfigurerAdapterconfigurePathMatch()方法,使用PathMatchConfigurer(在 Spring 4.0.1+中可用)。

CalculationController资源的代码,我们实现了两个 REST 端点:

package com.packtpub.mmj.rest.resources; 

import com.packtpub.mmj.lib.model.Calculation; 
import java.util.ArrayList; 
import java.util.List; 
import org.springframework.web.bind.annotation.PathVariable; 
import org.springframework.web.bind.annotation.RequestMapping; 
import static org.springframework.web.bind.annotation.RequestMethod.GET; 
import org.springframework.web.bind.annotation.RequestParam; 
import org.springframework.web.bind.annotation.RestController; 

/** 
 * 
 * @author sousharm 
 */ 
@RestController 
@RequestMapping("calculation") 
public class CalculationController { 

    private static final String PATTERN = "^-?+\\d+\\.?+\\d*$"; 

    /** 
     * 
     * @param b 
     * @param e 
     * @return 
     */ 
    @RequestMapping("/power") 
    public Calculation pow(@RequestParam(value = "base") String b, @RequestParam(value = "exponent") String e) { 
        List<String> input = new ArrayList(); 
        input.add(b); 
        input.add(e); 
        List<String> output = new ArrayList(); 
        String powValue; 
        if (b != null && e != null && b.matches(PATTERN) && e.matches(PATTERN)) { 
            powValue = String.valueOf(Math.pow(Double.valueOf(b), Double.valueOf(e))); 
        } else { 
            powValue = "Base or/and Exponent is/are not set to numeric value."; 
        } 
        output.add(powValue); 
        return new Calculation(input, output, "power"); 
    } 

    /** 
     * 
     * @param aValue 
     * @return 
     */ 
    @RequestMapping(value = "/sqrt/{value:.+}", method = GET) 
    public Calculation sqrt(@PathVariable(value = "value") String aValue) { 
        List<String> input = new ArrayList(); 
        input.add(aValue); 
        List<String> output = new ArrayList(); 
        String sqrtValue; 
        if (aValue != null && aValue.matches(PATTERN)) { 
            sqrtValue = String.valueOf(Math.sqrt(Double.valueOf(aValue))); 
        } else { 
            sqrtValue = "Input value is not set to numeric value."; 
        } 
        output.add(sqrtValue); 
        return new Calculation(input, output, "sqrt"); 
    } 
} 

在这里,我们只通过 URI /calculation/power/calculation/sqrt 暴露了Calculation资源的powersqrt函数。

在这里,我们使用sqrtpower作为 URI 的一部分,这仅是为了演示目的。理想情况下,这些应该作为function请求参数的值传递,或根据端点设计形成类似的内容。

这里有趣的一点是,由于 Spring 的 HTTP 消息转换器支持,Calculation对象会自动转换为 JSON。您不需要手动进行这种转换。如果 Jackson 2 在类路径上,Spring 的MappingJackson2HttpMessageConverter会将Calculation对象转换为 JSON。

制作一个可执行的示例 REST 应用程序

创建一个带有SpringBootApplication注解的RestSampleApp类。main()方法使用 Spring Boot 的SpringApplication.run()方法来启动一个应用程序。

我们将用@SpringBootApplication注解标记RestSampleApp类,这个注解隐式地添加了以下所有标签:

  • @Configuration注解将类标记为应用程序上下文 bean 定义的来源。

  • @EnableAutoConfiguration注解表明 Spring Boot 将根据类路径设置、其他 bean 和各种属性设置来添加 bean。

  • 如果 Spring Boot 在类路径中找到spring-webmvc,则会添加@EnableWebMvc注解。它将应用程序视为网络应用程序并激活诸如设置DispatcherServlet等关键行为。

  • @ComponentScan注解告诉 Spring 在给定包中寻找其他组件、配置和服务:

package com.packtpub.mmj.rest; 

import org.springframework.boot.SpringApplication; 
import org.springframework.boot.autoconfigure.SpringBootApplication; 

@SpringBootApplication 
public class RestSampleApp { 

    public static void main(String[] args) { 
        SpringApplication.run(RestSampleApp.class, args); 
    } 
} 

这个网络应用程序是 100%的纯 Java,您不必处理使用 XML 配置任何管道或基础设施的问题;相反,它使用了由 Spring Boot 简化的 Java 注解。因此,除了pom.xml用于 Maven 之外,没有一行 XML。甚至没有web.xml文件。

添加 Jetty 内嵌服务器

Spring Boot 默认提供 Apache Tomcat 作为内嵌应用程序容器。本书将使用 Jetty 内嵌应用程序容器代替 Apache Tomcat。因此,我们需要添加一个支持 Jetty 网络服务器的 Jetty 应用程序容器依赖项。

Jetty 还允许您使用类路径读取密钥或信任存储,也就是说,您不需要将这些存储保存在 JAR 文件之外。如果您使用带有 SSL 的 Tomcat,那么您需要直接从文件系统访问密钥库或信任库,但是您不能使用类路径来实现。结果是,您不能在 JAR 文件内读取密钥库或信任库,因为 Tomcat 要求密钥库(如果您使用的话)信任库)直接可访问文件系统。这本书完成后可能会发生变化。

这个限制不适用于 Jetty,它允许在 JAR 文件内读取密钥或信任存储。下面是模块restpom.xml相对部分:

<dependencies> 
<dependency> 
       <groupId>org.springframework.boot</groupId> 
           <artifactId>spring-boot-starter-web</artifactId> 
           <exclusions> 
             <exclusion> 
<groupId>org.springframework.boot</groupId> 
<artifactId>spring-boot-starter-tomcat</artifactId> 
                </exclusion> 
            </exclusions> 
</dependency> 
<dependency> 
<groupId>org.springframework.boot</groupId> 
<artifactId>spring-boot-starter-jetty</artifactId> 
</dependency> 
</dependencies>

设置应用程序构建

无论pom.xml文件是什么,我们到目前为止使用的东西已经足够执行我们的示例 REST 服务。这个服务会把代码打包成一个 JAR 文件。为了使这个 JAR 文件可执行,我们需要选择以下选项:

  • 运行 Maven 工具

  • 使用 Java 命令执行

以下部分将详细介绍它们。

运行 Maven 工具

这个方法可能不起作用,因为 Java 9、Spring Boot 2 和 Spring Framework 5 都处于早期或快照版本。如果它不起作用,请使用使用 Java 命令的项目。

在这里,我们使用 Maven 工具执行生成的 JAR 文件,具体步骤如下:

  1. 右键点击pom.xml文件。

  2. 从弹出菜单中选择“运行 Maven | 目标...”。它会打开对话框。在目标字段中输入spring-boot:run。我们在代码中使用了 Spring Boot 的发布版本。然而,如果您使用快照版本,您可以勾选“更新快照”复选框。为了将来使用,在“记住为”字段中输入spring-boot-run

  3. 下次,您可以直接点击“运行 Maven | 目标 | spring-boot-run”来执行项目:

运行 Maven 对话框

  1. 点击确定以执行项目。

使用 Java 命令执行

请确保在执行以下命令之前,Java 和JAVA_HOME已设置为 Java 9。

请查看以下步骤:

  1. 要构建 JAR 文件,请从父项目根目录(6392_chapter2)的命令提示符中执行mvn clean package命令。在这里,cleanpackage是 Maven 目标:
mvn clean package
  1. 它将在相应的目标目录中创建 JAR 文件。我们将执行在6392_chapter2\rest\target目录中生成的 JAR 文件。可以使用以下命令执行 JAR 文件:
java -jar rest\target\rest-1.0-SNAPSHOT-exec.jar

请确保您执行具有后缀exec的 JAR 文件,如前一个命令所示。

使用 Postman Chrome 扩展进行 REST API 测试

本书使用 Postman - REST Client Chrome 扩展来测试我们的 REST 服务。我使用的是 Postman 5.0.1 版本。您可以使用 Postman Chrome 应用程序或其他 REST 客户端来测试您的示例 REST 应用程序,如下面的屏幕截图所示:

Postman - Rest Client Chrome 扩展

一旦您安装了 Postman - REST Client,让我们测试我们的第一个 REST 资源。我们从开始菜单或从快捷方式中启动 Postman - REST Client。

默认情况下,嵌入式 Web 服务器在端口8080上启动。因此,我们需要使用http://localhost:8080/<资源>URL 来访问示例 REST 应用程序。例如:http://localhost:8080/calculation/sqrt/144

一旦启动,您可以在路径参数中输入Calculation REST URL 的sqrt值和144。您可以在以下屏幕截图中看到。此 URL 在 Postman 扩展的 URL(在此处输入请求 URL)输入字段中输入。默认情况下,请求方法是GET。由于我们还编写了 RESTful 服务以提供GET方法的请求,因此我们使用默认的请求方法。

一旦您准备好前面提到的输入数据,您就可以提交

通过点击发送按钮发送请求。您可以在以下屏幕截图中看到

响应代码200由您的示例 REST 服务返回。您可以在以下屏幕截图中的状态标签中找到 200 OK 代码。成功的请求

还返回了Calculation资源的 JSON 数据,在美化标签中显示

在屏幕截图中。返回的 JSON 显示了函数键的sqrt方法值。

它还显示了输入和输出列表分别为14412.0

使用 Postman 测试 Calculation(sqrt)资源

同样,我们还测试了用于计算power函数的示例 REST 服务。在 Postman 扩展中输入以下数据:

在这里,我们传递了请求参数baseexponent,分别值为24。它返回以下 JSON:

{ 
    "function": "power", 
    "input": [ 
        "2", 
        "4" 
    ], 
    "output": [ 
        "16.0" 
    ] 
} 

它返回前面 JSON 响应状态为 200,如下所示:

计算(power)资源测试使用 Postman

一些更多正测试场景

以下表格中的所有 URL 均以http://localhost:8080开头:

URL 输出 JSON
/calculation/sqrt/12344.234
{   
    "function":   "sqrt",   
    "input":   [   
        "12344.234"   
    ],   
    "output":   [   
        "111.1046083652699"   
    ]   
}   

|

/calculation/sqrt/-9344.34Math.sqrt函数的特殊场景:如果参数是NaN或小于零,则结果是NaN
{   
    "function":   "sqrt",   
    "input":   [   
        "-9344.34"   
    ],   
    "output":   [   
        "NaN"   
    ]   
}   

|

/calculation/power?base=2.09&exponent=4.5
{   
    "function":   "power",   
    "input":   [   
        "2.09",   
        "4.5"   
    ],   
    "output":   [   
        "27.58406626826615"   
    ]   
}   

|

/calculation/power?base=-92.9&exponent=-4
{   
    "function":   "power",   
    "input":   [   
        "-92.9",   
        "-4"   
    ],   
    "output":   [   
        "1.3425706351762353E-8"   
    ]   
}   

|

负测试场景

同样,您也可以执行一些负场景,如下表所示。在此表中,所有 URL 均以http://localhost:8080开头:

URL 输出 JSON
/calculation/power?base=2a&exponent=4
{   
    "function":   "power",   
    "input":   [   
        "2a",   
        "4"   
    ],   
    "output":   [   
        "Base   or/and Exponent is/are not set to numeric value."   
    ]   
}   

|

/calculation/power?base=2&exponent=4b
{   
    "function":   "power",   
    "input":   [   
        "2",   
        "4b"   
    ],   
    "output":   [   
        "Base   or/and Exponent is/are not set to numeric value."   
    ]   
}   

|

/calculation/power?base=2.0a&exponent=a4
{   
    "function":   "power",   
    "input":   [   
        "2.0a",   
        "a4"   
    ],   
    "output":   [   
        "Base   or/and Exponent is/are not set to numeric value."   
    ]   
}   

|

/calculation/sqrt/144a
{   
    "function":   "sqrt",   
    "input":   [   
        "144a"   
    ],   
    "output":   [   
        "Input   value is not set to numeric value."   
    ]   
}   

|

/calculation/sqrt/144.33$
{   
    "function":   "sqrt",   
    "input":   [   
        "144.33$"   
    ],   
    "output":   [   
        "Input   value is not set to numeric value."   
    ]   
}   

|

总结

在本章中,您已经探索了设置开发环境、Maven 配置、Spring Boot 配置等方面的各种方面。

您还学习了如何使用 Spring Boot 开发一个示例 REST 服务应用程序。我们了解到 Spring Boot 有多强大——它极大地简化了开发,以至于你只需要担心实际代码,而不需要担心编写 boilerplate 代码或配置。我们还把我们代码打包成一个带有内嵌应用容器 Jetty 的 JAR 文件。它允许它运行并访问 Web 应用程序,而无需担心部署。

在下一章中,您将学习领域驱动设计DDD)。我们使用一个可以用于其他章节的示例项目来了解 DDD。我们将使用名为在线餐桌预订系统OTRS)的示例项目来经历微服务开发的各个阶段并了解 DDD。在完成第三章,《领域驱动设计》之后,您将了解 DDD 的基础知识。

你将理解如何实际使用 DDD 设计示例服务。你还将学习如何在它之上设计领域模型和 REST 服务。以下是一些你可以查看以了解更多关于我们在此处使用的工具的链接:

第三章:领域驱动设计

本章通过参考一个样本项目来为接下来的章节定调。样本项目将被用来解释不同微服务概念。本章将通过这个样本项目来驱动不同的功能和领域服务或应用程序的组合,以解释 领域驱动设计DDD)。它将帮助你了解 DDD 的基础知识及实际应用。你还将学习使用 REST 服务设计领域模型的概念。

本章涵盖以下主题:

  • DDD 的基础知识

  • 如何使用 DDD 设计应用程序

  • 领域模型

  • 基于 DDD 的样本领域模型设计

一个良好的软件设计对于产品或服务的成功同样重要。它与产品的功能一样重要。例如,Amazon.com 提供购物平台,但其架构设计使其与其他类似站点有所不同,并促成了它的成功。这显示了软件或架构设计对产品/服务成功的重要性。DDD 是软件设计实践之一,我们将通过各种理论和实际示例来探讨它。

DDD 是一个关键的设计实践,有助于设计正在开发的产品的微服务。因此,在深入微服务开发之前,我们将首先探讨 DDD。在学习本章之后,你将了解 DDD 对于微服务开发的重要性。

领域驱动设计基础知识

企业或云应用程序解决业务问题和其他现实世界的问题。如果没有对领域的了解,这些问题是无法解决的。例如,如果你不了解股票交易所及其运作方式,就无法为在线股票交易等金融系统提供软件解决方案。因此,具备领域知识对于解决问题是必不可少的。现在,如果你想通过软件或应用程序提供解决方案,就需要借助领域知识进行设计。当我们将领域和软件设计结合起来时,就会提供一种被称为 DDD 的软件设计方法论。

当我们开发软件来实现真实世界的场景,提供领域的功能时,我们就会创建一个领域的模型。一个模型是对领域的抽象或蓝图。

埃里克·埃文斯在他于 2004 年出版的书《领域驱动设计:攻克软件内在的复杂性》中创造了 DDD 这个词汇。

设计这个模型并不是火箭科学,但它确实需要大量的努力、精炼和领域专家的投入。这是软件设计师、领域专家和开发人员共同的工作。他们组织信息,将其分成更小的部分,逻辑上进行分组,并创建模块。每个模块可以单独处理,可以使用类似的方法进行划分。这个过程可以一直持续到达到单元级别,或者无法再进行划分为止。一个复杂的项目可能会有更多的此类迭代;同样,一个简单的项目可能只会有此类迭代的单个实例。

一旦模型被定义并且文档齐全,它就可以进入下一阶段——代码设计。所以,我们这里有一个软件设计——领域模型和代码设计,以及领域模型的代码实现。领域模型提供了一个解决方案(软件/应用程序)的高级架构,而代码实现使领域模型成为一个活生生的模型。

领域驱动设计使设计和开发工作相结合。它提供了一种连续开发软件的能力,同时根据从开发过程中收到的反馈来更新设计。它解决了敏捷和瀑布方法论所提供的限制之一,使软件可维护,包括设计和代码,并保持应用程序的最小可行性。

以设计为驱动的开发方式让开发者从项目初期就参与其中,所有软件设计师与领域专家在建模过程中讨论领域的会议都会涉及到。这种方式为开发者提供了一个理解领域的正确平台,并且提供了分享领域模型实现早期反馈的机会。它消除了在后期阶段,当各方等待可交付成果时出现的瓶颈问题。

领域驱动设计的基础知识

为了理解领域驱动设计,我们可以将这些三个概念广泛地归类为:

  • 通用语言和统一建模语言(UML)

  • 多层架构

  • 工件(组件)

接下来的部分将解释通用语言和多层架构的使用和重要性。还将解释在模型驱动设计中要使用的不同工件(组件)。

通用语言

通用语言是在项目中进行沟通的共同语言。正如我们所见,设计模型是软件设计师、领域专家和开发人员的共同努力;因此,它需要一种共同的语言来进行沟通。领域驱动设计使得使用通用语言成为必要。领域模型在其图表、描述、演示、演讲和会议中使用通用语言。它消除了他们之间的误解、误解释和沟通障碍。因此,它必须包括所有图表、描述、演示、会议等——简而言之,包括所有内容。

统一建模语言UML)在创建模型时被广泛使用并且非常受欢迎。它也存在一些局限性;例如,当你从一张纸上画出成千上万的类时,很难表示类之间的关系,同时在理解它们的抽象并从中获取意义。此外,UML 图并不能表示模型的概念以及对象应该做什么。因此,UML 总是应该与其他文档、代码或其他参考资料一起使用,以便有效沟通。

传达领域模型的其他方式包括使用文档、代码等。

多层架构

多层架构是 DDD 的常见解决方案。它包含四个层次:

  1. 展示层或用户界面UI)。

  2. 应用层。

  3. 领域层。

  4. 基础设施层。

分层架构

从这里可以看出,只有领域层负责领域模型,其他层与 UI、应用逻辑等组件有关。这种分层架构非常重要。它将领域相关代码与其他层分开。

在这种多层架构中,每一层都包含相应的代码,它有助于实现松耦合,并避免不同层代码的混合。它还有助于产品/服务的长期可维护性和易于增强,因为如果改变仅针对相应层,则一层代码的变化不会影响其他组件。在多层架构中,每一层都可以容易地与其他实现交换。

展示层

这一层代表了用户界面(UI),并为交互和信息展示提供用户界面。这一层可能是一个网络应用、移动应用,或者是消耗你服务的第三方应用。

应用层

这一层负责应用逻辑。它维护和协调产品/服务的整体流程。它不包含业务逻辑或 UI。它可能持有应用对象的状态,如进行中的任务。例如,你的产品REST 服务将是这一应用层的一部分。

领域层

领域层是一个非常重要的层,因为它包含领域信息和业务逻辑。它持有业务对象的状态。它持久化业务对象的状态,并将这些持久化的状态传达给基础设施层。

基础设施层

这一层为其他所有层提供支持,负责层与层之间的通信。它包含了其他层使用的支持库。它还实现了业务对象的持久化。

为了理解不同层次之间的交互,让我们以餐厅订桌为例。最终用户通过用户界面(UI)提交订桌请求。UI 将请求传递给应用层。应用层从领域层获取餐厅、餐桌、日期等域对象。领域层从基础设施层获取这些已持久化的对象,并调用相关方法进行订桌并将其持久化回基础设施层。一旦领域对象被持久化,应用层就会向最终用户显示预订确认信息。

领域驱动设计工件(Artifacts of domain-driven design)

领域驱动设计中有七个不同的工具有助于表达、创建和检索领域模型:

  • 实体(Entities)

  • 值对象(Value objects)

  • 服务(Services)

  • 聚合(Aggregates)

  • 仓库(Repository)

  • 工厂(Factory)

  • 模块(Module)

实体(Entities)

实体(Entities)是能够被识别并在产品/服务状态变化中保持不变的一类对象。这些对象不是通过属性来识别,而是通过其身份和持续性线索来识别。这类对象被称为实体

听起来很简单,但它包含了复杂性。我们需要理解我们如何定义实体。让我们以一个订桌系统为例,其中有一个restaurant类,具有餐厅名称、地址、电话号码、成立日期等属性。我们可以取restaurant类的两个实例,它们不能通过餐厅名称来识别,因为可能有其他拥有相同名称的餐厅。同样,如果我们根据任何其他单一属性来识别,我们也找不到可以单独识别唯一餐厅的属性。如果两个餐厅具有所有相同的属性值,它们因此相同,并且可以相互替换。然而,它们并不是相同的实体,因为两者具有不同的引用(内存地址)。

相反,让我们考虑一组美国公民。每个公民都有自己的社会安全号码。这个号码不仅是唯一的,而且在其公民的一生中保持不变,并确保连续性。这个citizen对象将存在于内存中,将被序列化,并将从内存中移除并存储在数据库中。即使人死后,它仍然存在。只要系统存在,它就会在系统中保持。公民的社会安全号码与其表示形式无关,保持不变。

因此,在产品中创建实体意味着创建一个身份。现在给前例中的任何餐厅一个身份,然后使用诸如餐厅名称、成立日期和街道等属性的组合来识别它,或者添加一个标识符如restaurant_id来识别它。基本规则是两个标识符不能相同。因此,当我们为实体引入一个标识符时,我们需要确切知道它。

为对象创建唯一身份有多种方法,如下所述:

  • 使用表中的主键

  • 使用领域模块生成的自动生成 ID。领域程序生成标识符并将其分配给在不同层次之间被持久化的对象。

  • 有些现实生活中的对象本身携带用户定义的标识符。例如,每个国家都有它自己的国际直拨电话代码。

  • 复合键。这是可以用于创建标识符的一组属性,正如前面所述的restaurant对象。

实体对于领域模型非常重要,因此,它们应该从建模过程的初始阶段开始定义。

当一个对象可以通过其标识符而不是属性来识别时,代表这些对象的类应该有一个简单的定义,并且要小心生命周期连续性和身份。务必识别具有相同属性值的此类对象。定义良好的系统应对每个对象查询返回唯一结果。设计师应确保模型定义什么是同一事物。

值对象

值对象(VOs)简化了设计。实体具有诸如身份、生命周期连续性以及不定义其身份的属性等特征。与实体相反,值对象只有属性,没有概念上的身份。最佳实践是将值对象保持为不可变对象。如果可能,实体对象也应该保持不可变。

实体概念可能会让你倾向于将所有对象都当作实体来处理,即在内存或数据库中具有生命周期连续性和唯一可识别性的对象,但每个对象必须有一个实例。现在,假设你在创建客户实体对象。每个客户对象将代表餐厅的客人,这不能用于为其他客人预订订单。如果系统中有百万客户,可能会在内存中创建数百万客户实体对象。系统中不仅存在数百万个唯一可识别的对象,而且每个对象都在被跟踪。跟踪以及创建身份都是复杂的。需要一个高度可信的系统来创建和跟踪这些对象,这不仅非常复杂,而且资源消耗大。这可能会导致系统性能下降。因此,使用值对象而不是实体对象是很重要的。接下来的几段将解释原因。

应用程序并不总是需要可追踪和可识别的客户对象。有时只需某些或所有领域元素的属性。在这些情况下,应用程序可以使用值对象。这使事情变得简单并提高了性能。

由于价值对象没有身份,所以可以很容易地创建和销毁,这简化了设计——如果没有任何其他对象引用它们,价值对象就可以被垃圾回收。

让我们讨论一下价值对象的不可变性。应该设计并编写价值对象为不可变的。一旦它们被创建,在其生命周期内不应该被修改。如果你需要不同价值的 VO,或其任何对象,那么简单地创建一个新的价值对象,但不要修改原来的价值对象。在这里,不可变性继承了面向对象编程OOP)的所有重要性。如果一个价值对象是不可变的,那么它可以在不破坏其完整性的情况下被共享和使用。

常见问题解答 (FAQs)

  • 价值对象可以包含另一个价值对象吗?

    是的,可以 (Yes, it can)

  • 价值对象可以引用另一个价值对象或实体吗?

    是的,可以 (Yes, it can)

  • 我可以用不同价值对象或实体的属性创建一个价值对象吗?

    是的,你可以 (Yes, you can)

服务 (Services)

在创建领域模型的过程中,你可能会遇到各种情况,其中行为可能与任何特定对象无关。这些行为可以容纳在服务对象中。

服务对象是领域层的一部分,没有内部状态。服务对象的唯一目的是向领域提供不属于单一实体或价值对象的行为。

通用语言能帮助你在领域建模的过程中识别不同的对象、身份或价值对象,以及它们不同的属性和行为。在创建领域模型的过程中,你可能会发现不同的行为或方法不属于任何一个特定的对象。这些行为很重要,因此不能忽视。你也不能把它们添加到实体或价值对象中。给一个对象添加不属于它的行为会破坏这个对象。要记住,这种行为可能会影响各种对象。面向对象编程的使用使得能够将行为附加到一些对象上,这被称为服务

技术框架中常见服务。在 DDD 中,它们也用于领域层。服务对象没有内部状态;它的唯一目的是向领域提供行为。服务对象提供的行为不能与特定的实体或价值对象相关联。服务对象可能为一个或多个相关实体或价值对象提供一种或多种行为。在领域模型中明确定义服务是一种实践。

在创建服务时,你需要勾选以下所有要点:

  • 服务对象的行为对实体和价值对象进行操作,但不属于实体或价值对象。

  • 服务对象的行为状态不被维护,因此它们是无状态的 (Stateless)

  • 服务是领域模型的一部分

服务也可能存在于其他层中。保持领域层服务的隔离非常重要。它消除了复杂性,并使设计解耦。

让我们来看一个例子,餐厅老板想要查看他每月的餐桌预订报告。在这种情况下,他需要以管理员身份登录,在提供必要的输入字段(如持续时间)后点击显示报告按钮。

应用层将请求传递给拥有报告和模板对象的领域层,传递一些参数,如报告 ID 等。使用模板创建报告,并从数据库或其他来源获取数据。然后应用层将所有参数(包括报告 ID)传递给业务层。在这里,需要从数据库或另一个来源获取模板来根据 ID 生成报告。这个操作不属于报告对象或模板对象。因此,使用一个服务对象来执行这个操作,从数据库中获取所需的模板。

聚合

聚合领域模式与对象的生命周期相关,定义了所有权和边界。

当您通过应用程序在线预订您最喜欢的餐厅的餐桌时,您不需要担心内部系统发生的预订过程,包括搜索可用的餐厅,然后在给定日期、时间和等等上查找可用的餐桌。因此,您可以说预订应用程序是多个其他对象的聚合,并为餐桌预订系统中的所有其他对象充当

这个根实体应该是一个将对象集合绑在一起的实体,也称为聚合根。这个根对象不向外部世界传递内部对象的任何引用,并保护内部对象执行的更改。

我们需要理解为什么需要聚合器。领域模型可能包含大量的领域对象。应用程序的功能和大小越大,设计越复杂,存在的对象数量就越多。这些对象之间存在关系。一些可能具有多对多关系,一些可能具有单对多关系,其他可能具有单对一关系。这些关系在代码中的模型实现或数据库中得到强制执行,确保对象之间的关系保持不变。这些关系不仅仅是单向的,也可能是双向的。它们还可以变得更加复杂。

设计者的任务是简化模型中的这些关系。一些关系在现实领域中可能存在,但在领域模型中可能不需要。设计师需要确保领域模型中不存在此类关系。同样,通过这些约束可以减少多义性。一个约束可以完成许多对象满足关系的工作。也可能将双向关系转换为单向关系。

无论你输入多少简化,你最终可能还是会得到模型中的关系。这些关系需要在代码中维护。当一个对象被移除时,代码应该从其他地方删除对这个对象的所有引用。例如,从一个表中删除记录需要在它以外键等形式被引用的地方进行处理,以保持数据一致性并维护其完整性。另外,在数据变化时,需要强制执行不变量(规则)。

关系、约束和不变量带来了复杂性,需要在代码中有效地处理。我们通过使用由单一实体表示的聚合来找到解决方案,这个实体与一组保持数据变化一致性的对象相关联。

这个根元素是唯一可以从外部访问的对象,因此它充当了一个边界门,将内部对象与外部世界隔开。根可以引用一个或多个内部对象,而这些内部对象又可以引用其他可能有或没有与根的关系的内部对象。然而,外部对象也可以引用根,但不会引用任何内部对象。

聚合确保数据完整性并强制执行不变量。外部对象不能对内部对象做任何更改;他们只能更改根。然而,他们可以通过调用公开操作,使用根对对象内部进行更改。如果需要,根应该将内部对象的值传递给外部对象。

如果聚合对象存储在数据库中,那么查询应该只返回聚合对象。遍历关联应该在聚合根内部链接时返回对象。这些内部对象也可能引用其他聚合。

聚合根实体保持其全局身份,并在其实体内部保持局部身份。

在表预订系统中,聚合的一个简单示例是客户。客户可以暴露给外部对象,而它们的根对象包含它们的内部对象地址和联系信息。

当请求时,内部对象的价值对象,如地址,可以传递给外部对象:

客户作为聚合根

仓库

在领域模型中,在给定的时间点,可能存在许多领域对象。每个对象可能都有自己的生命周期,从对象的创建到它们的移除或持久化。每当领域操作需要一个领域对象时,它应该有效地检索所需对象的引用。如果你没有维护所有可用的领域对象,那将会非常困难。一个中心对象携带所有对象的引用,并负责返回请求的对象引用。这个中心对象被称为仓库

仓库是与数据库或文件系统等基础架构交互的点。仓库对象是领域模型中与存储(如数据库)、外部源等交互以检索持久化对象的部分。当仓库收到对对象引用的请求时,它返回现有对象的引用。如果请求的对象在仓库中不存在,那么它从存储中检索该对象。例如,如果您需要一个客户,您会查询仓库对象以提供具有 ID 31的客户。如果对象在仓库中已经存在,仓库将提供请求的客户对象,如果不存在,它将查询持久化存储,如数据库,获取它,并提供其引用。

使用仓库的主要优点是有一种一致的方法来检索对象,其中请求者不需要直接与存储(如数据库)交互。

仓库可能查询来自各种存储类型的对象,如一个或多个数据库、文件系统或工厂仓库等。在这种情况下,仓库可能有指向不同来源的不同对象类型或类别的策略:

仓库对象流程

如图所示,仓库对象流程图与基础架构层交互,并且这一接口属于领域层请求者可能属于领域层,或者应用层。仓库帮助系统管理领域对象的生命周期

工厂

工厂在简单构造函数不足以创建对象时是必需的。它帮助创建复杂对象,或者涉及创建其他相关对象的聚合。

工厂也是领域对象生命周期的组成部分,因为它是负责创建它们的部分。工厂和仓库在某种程度上是相关的,因为两者都指的是领域对象。工厂指的是新创建的对象,而仓库从内存或外部存储中返回已经存在的对象。

让我们通过使用一个用户创建过程应用程序来查看控制是如何流动的。假设一个用户使用用户名user1进行注册。这个用户创建首先与工厂交互,创建了名字user1,然后使用仓库在领域中缓存它,该仓库还将其存储在用于持久化的存储中。

当同一用户再次登录时,调用会移动到仓库进行引用。这使用存储来加载引用并将其传递给请求者。

请求者然后可以使用这个user1对象在指定餐厅和指定时间预订桌子。这些值作为参数传递,并使用仓库在存储中创建了桌子预订记录:

仓库对象流程

工厂可能会使用面向对象编程模式中的一种,例如工厂或抽象工厂模式,用于对象创建。

模块

模块是将相关业务对象分离的最佳方式。这对于大型项目来说非常合适,其中领域对象的规模更大。对于最终用户来说,将领域模型划分为模块并设置这些模块之间的关系是有意义的。一旦你理解了模块及其关系,你开始看到领域模型的更大图景,因此更容易深入理解模型。

模块还有助于高度凝聚的代码,或者保持低耦合的代码。通用语言可以用来为这些模块命名。对于预订表格系统,我们可以有不同的模块,比如用户管理、餐厅和桌子、分析和报告、评论等。

战略设计和原则

企业模型通常非常大且复杂。它可能分布在组织中的不同部门。每个部门可能有一个单独的领导团队,因此共同工作和设计可能会产生困难和协调问题。在这种情况下,维护领域模型的完整性并不是一件容易的事。

在这种情况下,统一模型并不是解决方案,大型企业模型需要划分为不同的子模型。这些子模型包含了预定义的准确关系和合同,并且非常详细。每个子模型都必须无例外地维持定义的合同。

有多种原则可以遵循以维护领域模型的完整性,这些原则如下:

  • 边界上下文

  • 持续集成

  • 上下文映射

    • 共享核心

    • 客户-供应商

    • 顺从者

    • 防腐层

    • 分道扬镳

    • 开放主机服务

    • 提炼

边界上下文

当你有不同的子模型时,当所有子模型组合在一起时,很难维护代码。你需要一个小模型,可以分配给一个单一团队。你可能需要收集相关元素并将它们分组。上下文通过应用这组条件来保持和维护为其相应子模型定义的领域术语的意义。

这些领域术语定义了创建上下文边界的模型的范围。

边界上下文似乎与前面章节中你学到的模块非常相似。实际上,模块是定义子模型发生和发展的逻辑框架的一部分。而模块负责组织领域模型的元素,并在设计文档和代码中可见。

现在,作为一名设计师,你必须确保每个子模型都有明确的定义并且保持一致。这样,你就可以独立地重构每个模型,而不会影响到其他的子模型。这使得软件设计师能够在任何时候精细和改进模型。

现在,让我们来分析我们一直在使用的表格预订示例。当您开始设计系统时,您会发现客人会访问应用程序,并在选定的餐厅、日期和时间请求表格预订。然后,后端系统会通知餐厅预订信息,同样,餐厅也会更新他们的系统关于表格预订的信息,因为餐厅也可以自己预订表格。所以,当您关注系统的细微之处时,可以看到两个领域模型:

  • 在线预订表格系统

  • 离线餐厅管理系统

它们都有自己的边界上下文,您需要确保它们之间的接口运行良好。

持续集成

当您在开发时,代码分布在许多团队和各种技术中。这些代码可能被组织成不同的模块,并为各自的子模型提供了适用的边界上下文。

这种开发方式可能会带来一定级别的复杂性,例如代码重复、代码断裂或破坏性边界上下文。这不仅是因为代码量大和领域模型大,还因为其他因素,如团队成员变化、新成员加入,或者没有完善的文档模型等。

当使用 DDD 和敏捷方法论设计和开发系统时,在编码开始之前并不会完全设计领域模型,领域模型及其元素会在一段时间内随着持续的改进和细化而发展。

因此,集成继续进行,这是当今开发的关键原因之一,因此它扮演着非常重要的角色。在持续集成中,代码频繁合并,以避免任何断裂和领域模型问题。合并的代码不仅被部署,而且它还定期进行测试。市场上有很多可用的持续集成工具,它们在预定时间合并、构建和部署代码。如今,组织更加重视持续集成的自动化。Hudson、TeamCity 和 Jenkins CI 是市场上一些流行的持续集成工具。Hudson 和 Jenkins CI 是开源工具,而 TeamCity 是商业工具。

拥有一个与每个构建关联的测试套件可以确认模型的连贯性和完整性。测试套件从物理角度定义模型,而 UML 则是从逻辑角度。它会告知您任何错误或意外结果,这需要更改代码。它还有助于尽早识别领域模型中的错误和异常。

上下文映射

上下文图帮助你理解大型企业应用程序的整体情况。它显示了企业模型中有多少个边界上下文,以及它们是如何相互关联的。因此,我们可以说任何解释边界上下文及其之间关系的图表或文档都称为上下文

上下文图帮助所有团队成员,无论他们是在同一个团队还是不同的团队,都以各种部分(边界上下文或子模型)和关系的形式理解高层次的企业模型。

这使得个人对自己执行的任务有了更清晰的了解,并可能允许他或她就模型的完整性提出任何担忧/问题:

上下文地图示例

上下文地图例图是上下文图的一个样本。在这里,Table1Table2都出现在Table Reservation ContextRestaurant Ledger Context中。有趣的是,Table1Table2在各自的边界上下文中都有各自的概念。在这里,通用语言用于将边界上下文命名为table reservationrestaurant ledger

在下一节中,我们将探讨几个可以用以来定义上下文图中不同上下文之间通信的模式。

共享核心

正如其名,边界上下文的一部分与其他的边界上下文共享。正如下面的图表所示,Restaurant实体在Table Reservation ContextRestaurant Ledger Context之间共享:

共享核心

客户-供应商

客户-供应商模式代表了两个边界上下文之间的关系,当一个边界上下文的输出需要被另一个边界上下文使用时。也就是说,一方向另一方(称为客户)提供信息。

在一个现实世界的例子中,汽车经销商在汽车制造商交付汽车之前是无法销售汽车的。因此,在这个领域模型中,汽车制造商是供应商,经销商是客户。这种关系建立了一个客户-供应商关系,因为一个边界上下文(汽车制造商)的输出(汽车)被另一个边界上下文(经销商)所需要。

在这里,客户和供应商团队应定期会面,以建立合同并形成适当的协议来相互沟通。

遵从者

这种模式与客户和供应商的模式相似,其中一方需要提供合同和信息,而另一方需要使用它们。在这里,涉及实际的团队在具有上下游关系的过程中,而不是边界上下文。

此外,上游团队由于缺乏动力,没有为下游团队提供所需的支持。因此,下游团队可能需要计划和处理永远无法获得的项目。为了解决这种情况,如果供应商提供的不够有价值的信息,客户团队可以开发自己的模型。如果供应商提供真正有价值或部分有价值的信息,那么客户可以使用接口或翻译器来消耗供应商提供信息与客户自己的模型。

反向腐蚀层

反向腐蚀层仍然是领域的一部分,当系统需要从外部系统或自己的遗留系统获取数据时使用。在这里,反向腐蚀层是与外部系统交互并使用外部系统数据在领域模型中,而不会影响领域模型的完整性和原始性。

在大多数情况下,服务可以作为反向腐蚀层使用,该层可能会使用外观模式与适配器和翻译器一起消耗内部模型外的外部领域数据。因此,您的系统总是使用服务来获取数据。服务层可以使用外观模式进行设计。这将确保它与领域模型协同工作,以提供给定格式的所需数据。服务还可以使用适配器和翻译器模式,以确保无论数据以何种格式和层次结构从外部来源发送,服务都能以所需的格式提供数据,并使用适配器和翻译器来处理层次结构。

分手

当你有一个大型企业应用程序和一个领域时,其中不同的领域没有共同元素,并且它由可以独立工作的较大子模型组成,这仍然可以作为一个单一应用程序为最终用户工作。

在这种情况下,设计师可以创建没有关系的独立模型,并在其上开发小型应用程序。当这些小型应用程序合并在一起时,它们成为一个单一的应用程序。

提供各种小型应用程序的雇主内部应用程序,例如与人力资源相关的小应用程序、问题跟踪器、交通或公司内部社交网络,是设计师可以使用分手模式的一种应用程序。

集成使用不同模型开发的应用程序将非常具有挑战性和复杂。因此,在实施此模式之前应该小心。

打开主机服务

当两个子模型相互交互时,使用翻译层。当你将模型与外部系统集成时,使用此翻译层。当你有一个子模型使用这个外部系统时,这种方式工作得很好。当这个外部系统被许多子模型使用时,需要去除额外的和重复的代码,因此需要为每个子模型的外部系统编写一个翻译层。

开放主机服务通过封装所有子模型来提供外部系统的服务。

蒸馏

正如你所知,蒸馏是净化液体的过程。同样,在 DDD 中,蒸馏是过滤掉不必要的信息,只保留有意义信息的过程。它帮助你识别核心领域和业务领域的关键概念。它帮助你过滤掉通用概念,直到获得核心领域概念。

核心领域应该由开发人员和设计师高度关注细节地进行设计、开发和实现,因为这对于整个系统的成功至关重要。

在我们的表格预订系统示例中,这是一个不大或复杂的领域应用程序,识别核心领域并不困难。这里的核心领域存在是为了共享餐厅的实时准确空闲桌子信息,并允许用户以无麻烦的过程进行预订。

示例域名服务

让我们基于我们的表格预订系统创建一个示例域名服务。正如本章所讨论的,高效的领域层是成功产品或服务的关键。基于领域层开发的项目更易于维护,高度凝聚,且松耦合。它们在业务需求变化方面提供高度可扩展性,对其他层的设计影响较低。

领域驱动开发基于领域,因此不建议使用自上而下的方法,其中首先开发 UI,然后是其他层,最后是持久化层。也不建议使用自下而上的方法,其中首先设计持久化层(如数据库),然后是其他层,最后是 UI。

首先开发一个领域模型,使用本书中描述的模式,可以在功能上为所有团队成员提供清晰度,并使软件设计师具有构建灵活、可维护且一致的系统的优势,这有助于组织以更低的维护成本推出世界级的产品。

在这里,你将创建一个餐厅服务,提供添加和检索餐厅的功能。根据实现情况,你可以添加其他功能,例如根据菜系或评分查找餐厅。

从实体开始。在这里,餐厅是我们的实体,因为每个餐厅都是独一无二的,并且有一个标识符。你可以使用一个接口,或一系列接口,来实现在我们的表格预订系统中的实体。理想情况下,如果你遵循接口分离原则,你会使用一系列接口而不是一个单一的接口。

接口分离原则ISP)指出,客户不应该被强制依赖于他们不使用的接口。

实体实现

对于第一个接口,你可以有一个抽象类或接口,该接口被所有实体所必需。例如,如果我们考虑 ID 和名称,属性对所有实体来说都是共通的。

因此,你可以使用抽象类Entity作为领域层中实体的抽象:

public abstract class Entity<T> { 

    T id; 
    String name; 
    ... (getter/setter and other relevant code)} 

基于这个,你还可以有一个继承自Entity的另一个abstract类,一个抽象类:

public abstract class BaseEntity<T> extends Entity<T> { 

    private final boolean isModified;    
    public BaseEntity(T id, String name) { 
        super.id = id; 
        super.name = name; 
        isModified = false; 
    } 
    ... (getter/setter and other relevant code) 
} 

基于前面的抽象,我们可以为餐厅管理创建Restaurant实体。

现在,由于我们正在开发表格预订系统,Table在领域模型中是另一个重要的实体。所以,如果我们遵循聚合模式,Restaurant将作为根工作,而Table实体将位于Restaurant实体内部。因此,Table实体总是通过Restaurant实体来访问。

你可以使用以下实现创建Table实体,并且可以添加你想要的属性。仅为了演示,使用了基本属性:

public class Table extends BaseEntity<BigInteger> { 

    private int capacity; 

    public Table(String name, BigInteger id, int capacity) { 
        super(id, name); 
        this.capacity = capacity; 
    } 

    public void setCapacity(int capacity) { 
        this.capacity = capacity; 
    } 

    public int getCapacity() { 
        return capacity; 
    } 
} 

现在,我们可以实现聚合器Restaurant类,如下所示。在这里,只使用了基本属性。你可以添加尽可能多的属性,也可以添加其他功能:

public class Restaurant extends BaseEntity<String> { 

    private List<Table> tables = new ArrayList<>(); 
    public Restaurant(String name, String id, List<Table> tables) { 
        super(id, name); 
        this.tables = tables; 
    } 

    public void setTables(List<Table> tables) { 
        this.tables = tables; 
    } 

    public List<Table> getTables() { 
        return tables; 
    } 

    @Override 
    public String toString() { 
        return new StringBuilder("{id: ").append(id).append(", name: ") 
                .append(name).append(", tables: ").append(tables).append("}").toString(); 
    } 
} 

仓库实现

现在我们可以实现仓库模式,正如本章所学习的那样。首先,你将创建两个接口RepositoryReadOnlyRepositoryReadOnlyRepository接口将用于提供只读操作的抽象,而Repository抽象将用于执行所有类型的操作:

public interface ReadOnlyRepository<TE, T> { 

    boolean contains(T id); 

    Entity get(T id); 

    Collection<TE> getAll(); 
} 

基于这个接口,我们可以创建Repository的抽象,执行诸如添加、删除和更新的额外操作:

public interface Repository<TE, T> extends ReadOnlyRepository<TE, T> { 

    void add(TE entity); 

    void remove(T id); 

    void update(TE entity); 
} 

前面定义的Repository抽象,可以按照适合你的方式来实现,以持久化你的对象。基础设施层中的持久化代码的变化不会影响到领域层代码,因为合同和抽象是由领域层定义的。领域层使用移除直接具体类的抽象类和接口,提供松耦合。为了演示目的,我们完全可以使用留在内存中的映射来持久化对象:

public interface RestaurantRepository<Restaurant, String> extends Repository<Restaurant, String> { 

    boolean ContainsName(String name); 
} 

public class InMemRestaurantRepository implements RestaurantRepository<Restaurant, String> { 

    private Map<String, Restaurant> entities; 

    public InMemRestaurantRepository() { 
        entities = new HashMap(); 
    } 

    @Override 
    public boolean ContainsName(String name) { 
        return entities.containsKey(name); 
    } 

    @Override 
    public void add(Restaurant entity) { 
        entities.put(entity.getName(), entity); 
    } 

    @Override 
    public void remove(String id) { 
        if (entities.containsKey(id)) { 
            entities.remove(id); 
        } 
    } 

    @Override 
    public void update(Restaurant entity) { 
        if (entities.containsKey(entity.getName())) { 
            entities.put(entity.getName(), entity); 
        } 
    } 

    @Override 
    public boolean contains(String id) { 
        throw new UnsupportedOperationException("Not supported yet."); 
     //To change body of generated methods, choose Tools | Templates. 
    } 

    @Override 
    public Entity get(String id) { 
        throw new UnsupportedOperationException("Not supported yet."); 
     //To change body of generated methods, choose Tools | Templates. 
    } 

    @Override 
    public Collection<Restaurant> getAll() { 
        return entities.values(); 
    } 

} 

服务实现

与前一种方法相同,你可以将领域服务的抽象分为两部分——主要服务抽象和只读服务抽象:

public abstract class ReadOnlyBaseService<TE, T> { 

    private final Repository<TE, T> repository; 

    ReadOnlyBaseService(ReadOnlyRepository<TE, T> repository) { 
        this.repository = repository; 
    } 
    ... 
} 

现在,我们可以使用这个ReadOnlyBaseService来创建BaseService。在这里,我们通过构造函数使用依赖注入模式将具体对象与抽象对象映射:

public abstract class BaseService<TE, T> extends ReadOnlyBaseService<TE, T> { 
    private final Repository<TE, T> _repository; 

    BaseService(Repository<TE, T> repository) { 
        super(repository); 
        _repository = repository; 
    } 

    public void add(TE entity) throws Exception { 
        _repository.add(entity); 
    } 

    public Collection<TE> getAll() { 
        return _repository.getAll(); 
    } 
} 

现在,在定义了服务抽象之后,我们可以像下面这样实现RestaurantService

public class RestaurantService extends BaseService<Restaurant, BigInteger> { 

    private final RestaurantRepository<Restaurant, String> restaurantRepository; 

    public RestaurantService(RestaurantRepository repository) { 
        super(repository); 
        restaurantRepository = repository; 
    } 

    public void add(Restaurant restaurant) throws Exception { 
        if (restaurantRepository.ContainsName(restaurant.getName())) { 
            throw new Exception(String.format("There is already a product with the name - %s", restaurant.getName())); 
        } 

        if (restaurant.getName() == null || "".equals(restaurant.getName())) { 
            throw new Exception("Restaurant name cannot be null or empty string."); 
        } 
        super.add(restaurant); 
    } 
    @Override 
    public Collection<Restaurant> getAll() { 
        return super.getAll(); 
    } 
} 

同样,你可以为其他实体编写实现。这段代码是一个基本实现,你可能会在生产代码中添加各种实现和行为。

我们可以编写一个应用类,用来执行和测试我们刚刚编写的示例领域模型代码。

RestaurantApp.java文件看起来可能像这样:

public class RestaurantApp { 

    public static void main(String[] args) { 
        try { 
            // Initialize the RestaurantService 
            RestaurantService restaurantService = new RestaurantService(new InMemRestaurantRepository()); 

            // Data Creation for Restaurants 
            Table table1 = new Table("Table 1", BigInteger.ONE, 6); 
            Table table2 = new Table("Table 2", BigInteger.valueOf(2), 4); 
            Table table3 = new Table("Table 3", BigInteger.valueOf(3), 2); 
            List<Table> tableList = new ArrayList(); 
            tableList.add(table1); 
            tableList.add(table2); 
            tableList.add(table3); 
            Restaurant restaurant1 = new Restaurant("Big-O Restaurant", "1", tableList); 

            // Adding the created restaurant using Service 
            restaurantService.add(restaurant1); 

            // Note: To raise an exception give Same restaurant name to one of the below restaurant 
            Restaurant restaurant2 = new Restaurant("Pizza Shops", "2", null); 
            restaurantService.add(restaurant2); 

            Restaurant restaurant3 = new Restaurant("La Pasta", "3", null); 
            restaurantService.add(restaurant3); 

            // Retrieving all restaurants using Service 
            Collection<Restaurant> restaurants = restaurantService.getAll(); 

            // Print the retrieved restaurants on console 
            System.out.println("Restaurants List:"); 
            restaurants.stream().forEach((restaurant) -> { 
                System.out.println(String.format("Restaurant: %s", restaurant)); 
            }); 
        } catch (Exception ex) { 
            System.out.println(String.format("Exception: %s", ex.getMessage())); 
            // Exception Handling Code 
        } 
    } 
} 

要执行此程序,可以直接从 IDE 执行,或使用 Maven 运行。它会打印出以下输出:

Scanning for projects... 

------------------------------------------------------------------------ 
Building 6392_chapter3 1.0-SNAPSHOT 
------------------------------------------------------------------------ 

--- exec-maven-plugin:1.5.0:java (default-cli) @ 6392_chapter3 --- 
Restaurants List: 
Restaurant: {id: 3, name: La Pasta, tables: null} 
Restaurant: {id: 2, name: Pizza Shops, tables: null} 
Restaurant: {id: 1, name: Big-O Restaurant, tables: [{id: 1, name: Table 1, capacity: 6}, {id: 2, name: Table 2, capacity: 4}, {id: 3, name: Table 3, capacity: 2}]} 
------------------------------------------------------------------------ 
BUILD SUCCESS 
------------------------------------------------------------------------ 

总结

在本章中,你已经学习了 DDD 的基础知识。你也探索了多层架构和不同的模式,这些模式可以用 DDD 来开发软件。到这个时候,你应该已经意识到领域模型设计对软件成功的非常重要。总之,我们通过餐厅桌位预订系统演示了一个领域服务实现。

在下一章中,你将学习如何使用设计来实现示例项目。这个示例项目的说明来源于上一章,将使用 DDD 来构建微服务。这一章不仅涵盖了编码,还包括微服务的不同方面,比如构建、单元测试和打包。到下一章结束时,示例微服务项目将准备好部署和使用。

第四章:实现微服务

本章将带你从设计阶段到我们示例项目的实现——一个在线预订餐桌系统OTRS)。在这里,你将使用上一章中解释的相同设计并将其扩展以构建微服务。在本章结束时,你不仅学会了如何实现设计,还学会了微服务的不同方面——构建、测试和打包。虽然重点是构建和实现 Restaurant 微服务,但你也可以用相同的方法来构建和实现 OTRS 中使用的其他微服务。

在本章中,我们将介绍以下主题:

  • OTRS 概览

  • 开发和实现微服务

  • 测试

我们将使用上一章中展示的领域驱动设计的关键概念。在上一章中,你看到了如何使用核心 Java 开发领域模型。现在,我们将从示例领域实现转向 Spring Framework 驱动的实现。你将利用 Spring Boot 来实现领域驱动设计概念,并将它们从核心 Java 转换为基于 Spring Framework 的模型。

此外,我们还将使用 Spring Cloud,它提供了一个通过 Spring Boot 可用的云就绪解决方案。Spring Boot 将允许你使用依赖于 Tomcat 或 Jetty 的内嵌应用程序容器,你的服务被包装为 JAR 或 WAR。这个 JAR 作为一个独立的进程执行,一个微服务,将服务于提供对所有请求的响应,并指向服务中定义的端点。

Spring Cloud 也可以轻松集成 Netflix Eureka,一个服务注册和发现组件。OTRS 将使用它进行注册和微服务的发现。

OTRS 概览

基于微服务原则,我们需要为每个功能分别拥有独立的微服务。在查看 OTRS 之后,我们可以很容易地将其划分为三个主要微服务——Restaurant 服务、预订服务和用户服务。在 OTRS 中还可以定义其他微服务。我们的重点是这三个微服务。想法是使它们独立,包括拥有自己的独立数据库。

我们可以如下总结这些服务的功能:

  • 餐厅服务:这个服务提供了对餐厅资源的功能——创建读取更新删除CRUD)操作和基于标准的选择。它提供了餐厅和餐桌之间的关联。餐厅也会提供对Table实体的访问。

  • 用户服务:这个服务,如名字所示,允许终端用户对用户实体执行 CRUD 操作。

  • 预订服务:该服务利用餐厅服务和用户服务执行预订的 CRUD 操作。它将基于指定时间段的餐桌可用性进行餐厅搜索及其相关表格的查找和分配。它创建了餐厅/餐桌与用户之间的关系:

微服务的注册和发现

前述图表展示了每个微服务如何独立工作。这就是微服务可以独立开发、改进和维护的原因,而不会影响其他服务。这些服务可以具有自己的分层架构和数据库。没有限制要求使用相同的技术、框架和语言来开发这些服务。在任何给定的时间点,您还可以引入新的微服务。例如,出于会计目的,我们可以引入一个会计服务,可以向餐厅提供簿记服务。同样,分析报告也是其他可以集成和暴露的服务。

出于演示目的,我们将只实现前述图表中显示的三个服务。

开发和实现微服务

我们将使用前章描述的领域驱动实现和方法来使用 Spring Cloud 实现微服务。让我们回顾一下关键工件:

  • 实体:这些是可识别且在产品/服务状态中保持不变的对象类别。这些对象不是由它们的属性定义,而是由它们的标识和连续性线定义。实体具有诸如标识、连续性线和不会定义它们身份的属性等特征。

  • 值对象VOs)仅包含属性,没有概念上的身份。最佳实践是保持 VOs 作为不可变对象。在 Spring 框架中,实体是纯粹的 POJOs;因此,我们也将使用它们作为 VOs。

  • 服务对象:这些在技术框架中很常见。在领域驱动设计中,这些也用于领域层。服务对象没有内部状态;它的唯一目的是向领域提供行为。服务对象提供不能与特定实体或 VOs 相关联的行为。服务对象可能向一个或多个实体或 VOs 提供一个或多个相关行为。在领域模型中明确定义服务是最佳实践。

  • 仓库对象:仓库对象是领域模型的一部分,它与存储(如数据库、外部源等)交互,以检索持久化的对象。当接收到仓库中对象的引用请求时,它返回现有的对象引用。如果请求的对象在仓库中不存在,那么它从存储中检索该对象。

下载示例代码:详细的步骤说明在本书的前言中提到。请查看。本书的代码包也托管在 GitHub 上,地址为:github.com/PacktPublishing/Mastering-Microservices-with-Java。我们还有其他来自我们丰富的书籍和视频目录的代码包,地址为:github.com/PacktPublishing/。查看它们!

每个 OTRS 微服务 API 代表一个 RESTful web 服务。OTRS API 使用 HTTP 动词(如GETPOST等),以及 RESTful 端点结构。请求和响应负载格式化为 JSON。如果需要,也可以使用 XML。

餐厅微服务

餐厅微服务将通过 REST 端点暴露给外部世界进行消费。在餐厅微服务示例中,我们会找到以下端点。根据需求可以添加尽可能多的端点:

  1. 获取餐厅 ID 的端点:

  1. 获取匹配查询参数Name的所有餐厅的端点:

  1. 创建新餐厅的端点:

同样,我们可以添加各种端点及其实现。为了演示目的,我们将使用 Spring Cloud 实现上述端点。

OTRS 实现

我们将创建一个多模块的 Maven 项目来实现 OTRS。以下堆栈将用于开发 OTRS 应用程序。请注意,在撰写本书时,只有 Spring Boot 和 Cloud 的快照构建可用。因此,在最终发布中,可能会有一个或两个变化:

  • Java 版本 1.9

  • Spring Boot 2.0.0.M1

  • Spring Cloud Finchley.M2

  • Maven Compiler Plugin 3.6.1(用于 Java 1.9)

上述所有点都在根pom.xml中提到,还包括以下 OTRS 模块:

  • eureka-service

  • restaurant-service

  • user-service

  • booking-service

pom.xml文件将如下所示:

<?xml version="1.0" encoding="UTF-8"?> 
<project   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion> 

    <groupId>com.packtpub.mmj</groupId> 
    <artifactId>6392_chapter4</artifactId> 
    <version>PACKT-SNAPSHOT</version> 
    <name>6392_chapter4</name> 
    <description>Master Microservices with Java Ed 2, Chapter 4 - Implementing Microservices</description> 

    <packaging>pom</packaging> 
    <properties> 
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> 
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> 
        <java.version>1.9</java.version> 
        <maven.compiler.source>1.9</maven.compiler.source> 
        <maven.compiler.target>1.9</maven.compiler.target> 
    </properties> 

    <parent> 
        <groupId>org.springframework.boot</groupId> 
        <artifactId>spring-boot-starter-parent</artifactId> 
        <version>2.0.0.M1</version> 
    </parent> 
    <dependencyManagement> 
        <dependencies> 
            <dependency> 
                <groupId>org.springframework.cloud</groupId> 
                <artifactId>spring-cloud-dependencies</artifactId> 
                <version>Finchley.M2</version> 
                <type>pom</type> 
                <scope>import</scope> 
            </dependency> 
        </dependencies> 
    </dependencyManagement> 

    <modules> 
        <module>eureka-service</module> 
        <module>restaurant-service</module> 
        <module>booking-service</module> 
        <module>user-service</module> 
    </modules> 

    <!-- Build step is required to include the spring boot artifacts in generated jars --> 
    <build> 
        <finalName>${project.artifactId}</finalName> 
        <plugins> 
            <plugin> 
                <groupId>org.springframework.boot</groupId> 
                <artifactId>spring-boot-maven-plugin</artifactId> 
            </plugin> 
            <plugin> 
                <groupId>org.apache.maven.plugins</groupId> 
                <artifactId>maven-compiler-plugin</artifactId> 
                <version>3.6.1</version> 
                <configuration> 
                    <source>1.9</source> 
                    <target>1.9</target> 
                    <showDeprecation>true</showDeprecation> 
                    <showWarnings>true</showWarnings> 
                </configuration> 
            </plugin> 
        </plugins> 
    </build> 

    <!-- Added repository additionally as Finchley.M2 was not available in central repository --> 
    <repositories> 
        <repository> 
            <id>Spring Milestones</id> 
            <url>https://repo.spring.io/libs-milestone</url> 
            <snapshots> 
                <enabled>false</enabled> 
            </snapshots> 
        </repository> 
    </repositories> 

    <pluginRepositories> 
        <pluginRepository> 
            <id>Spring Milestones</id> 
            <url>https://repo.spring.io/libs-milestone</url> 
            <snapshots> 
                <enabled>false</enabled> 
            </snapshots> 
        </pluginRepository> 
    </pluginRepositories> 
</project> 

我们正在开发基于 REST 的微服务。我们将实现restaurant模块。bookinguser模块是在类似的基础上开发的。

控制器类

RestaurantController类使用@RestController注解构建餐厅服务端点。我们在第二章中已经详细介绍了@RestController设置开发环境。以下是

@RestController是一个类级注解,用于资源类。它是

@Controller@ResponseBody注解的组合。它返回领域对象。

API 版本控制

随着我们前进,我想与大家分享的是,我们在 REST 端点上使用了v1前缀。这代表了 API 的版本。我还想简要介绍一下 API 版本化的重要性。版本化 API 很重要,因为 API 会随着时间的推移而改变。您的知识和经验会随着时间而提高,这导致了 API 的变化。API 的变化可能会破坏现有的客户端集成。

因此,管理 API 版本有多种方法。其中一种是在路径中使用版本,或者有些人使用 HTTP 头。HTTP 头可以是一个自定义请求头或接受头,以表示调用 API 的版本。请参考 Bhakti Mehta 所著的《RESTful Java Patterns and Best Practices》,Packt Publishing 出版,www.packtpub.com/application-development/restful-java-patterns-and-best-practices,以获取更多信息:

@RestController 
@RequestMapping("/v1/restaurants") 
public class RestaurantController { 

    protected Logger logger = Logger.getLogger(RestaurantController.class.getName()); 

    protected RestaurantService restaurantService; 

    @Autowired 
    public RestaurantController(RestaurantService restaurantService) { 
        this.restaurantService = restaurantService; 
    } 

    /** 
     * Fetch restaurants with the specified name. A partial case-insensitive 
     * match is supported. So <code>http://.../restaurants/rest</code> will find 
     * any restaurants with upper or lower case 'rest' in their name. 
     * 
     * @param name 
     * @return A non-null, non-empty collection of restaurants. 
     */ 
    @RequestMapping(method = RequestMethod.GET) 
    public ResponseEntity<Collection<Restaurant>> findByName(@RequestParam("name") String name) { 

logger.info(String.format("restaurant-service findByName() invoked:{} for {} ", restaurantService.getClass().getName(), name)); 
        name = name.trim().toLowerCase(); 
        Collection<Restaurant> restaurants; 
        try { 
            restaurants = restaurantService.findByName(name); 
        } catch (Exception ex) { 
            logger.log(Level.WARNING, "Exception raised findByName REST Call", ex); 
            return new ResponseEntity< Collection< Restaurant>>(HttpStatus.INTERNAL_SERVER_ERROR); 
        } 
        return restaurants.size() > 0 ? new ResponseEntity< Collection< Restaurant>>(restaurants, HttpStatus.OK) 
                : new ResponseEntity< Collection< Restaurant>>(HttpStatus.NO_CONTENT); 
    } 

    /** 
     * Fetch restaurants with the given id. 
     * <code>http://.../v1/restaurants/{restaurant_id}</code> will return 
     * restaurant with given id. 
     * 
     * @param retaurant_id 
     * @return A non-null, non-empty collection of restaurants. 
     */ 
    @RequestMapping(value = "/{restaurant_id}", method = RequestMethod.GET) 
    public ResponseEntity<Entity> findById(@PathVariable("restaurant_id") String id) { 

       logger.info(String.format("restaurant-service findById() invoked:{} for {} ", restaurantService.getClass().getName(), id)); 
        id = id.trim(); 
        Entity restaurant; 
        try { 
            restaurant = restaurantService.findById(id); 
        } catch (Exception ex) { 
            logger.log(Level.SEVERE, "Exception raised findById REST Call", ex); 
            return new ResponseEntity<Entity>(HttpStatus.INTERNAL_SERVER_ERROR); 
        } 
        return restaurant != null ? new ResponseEntity<Entity>(restaurant, HttpStatus.OK) 
                : new ResponseEntity<Entity>(HttpStatus.NO_CONTENT); 
    } 

    /** 
     * Add restaurant with the specified information. 
     * 
     * @param Restaurant 
     * @return A non-null restaurant. 
     * @throws RestaurantNotFoundException If there are no matches at all. 
     */ 
    @RequestMapping(method = RequestMethod.POST) 
    public ResponseEntity<Restaurant> add(@RequestBody RestaurantVO restaurantVO) { 

        logger.info(String.format("restaurant-service add() invoked: %s for %s", restaurantService.getClass().getName(), restaurantVO.getName()); 

        Restaurant restaurant = new Restaurant(null, null, null); 
        BeanUtils.copyProperties(restaurantVO, restaurant); 
        try { 
            restaurantService.add(restaurant); 
        } catch (Exception ex) { 
            logger.log(Level.WARNING, "Exception raised add Restaurant REST Call "+ ex); 
            return new ResponseEntity<Restaurant>(HttpStatus.UNPROCESSABLE_ENTITY); 
        } 
        return new ResponseEntity<Restaurant>(HttpStatus.CREATED); 
    } 
} 

服务类

RestaurantController类使用了RestaurantService接口。RestaurantService是一个定义了 CRUD 和一些搜索操作的接口,具体定义如下:

public interface RestaurantService { 

    public void add(Restaurant restaurant) throws Exception; 

    public void update(Restaurant restaurant) throws Exception; 

    public void delete(String id) throws Exception; 

    public Entity findById(String restaurantId) throws Exception; 

    public Collection<Restaurant> findByName(String name) throws Exception; 

    public Collection<Restaurant> findByCriteria(Map<String, ArrayList<String>> name) throws Exception; 
}

现在,我们可以实现我们刚刚定义的RestaurantService。它还扩展了你在上一章创建的BaseService类。我们使用@Service Spring 注解将其定义为服务:

@Service("restaurantService") 
public class RestaurantServiceImpl extends BaseService<Restaurant, String> 
        implements RestaurantService { 

    private RestaurantRepository<Restaurant, String> restaurantRepository; 

    @Autowired 
    public RestaurantServiceImpl(RestaurantRepository<Restaurant, String> restaurantRepository) { 
        super(restaurantRepository); 
        this.restaurantRepository = restaurantRepository; 
    } 

    public void add(Restaurant restaurant) throws Exception { 
        if (restaurant.getName() == null || "".equals(restaurant.getName())) { 
            throw new Exception("Restaurant name cannot be null or empty string."); 
        } 

        if (restaurantRepository.containsName(restaurant.getName())) { 
            throw new Exception(String.format("There is already a product with the name - %s", restaurant.getName())); 
        } 

        super.add(restaurant); 
    } 

    @Override 
    public Collection<Restaurant> findByName(String name) throws Exception { 
        return restaurantRepository.findByName(name); 
    } 

    @Override 
    public void update(Restaurant restaurant) throws Exception { 
        restaurantRepository.update(restaurant); 
    } 

    @Override 
    public void delete(String id) throws Exception { 
        restaurantRepository.remove(id); 
    } 

    @Override 
    public Entity findById(String restaurantId) throws Exception { 
        return restaurantRepository.get(restaurantId); 
    } 

    @Override 
    public Collection<Restaurant> findByCriteria(Map<String, ArrayList<String>> name) throws Exception { 
        throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. 
    } 
} 

仓库类

RestaurantRepository接口定义了两个新方法:containsNamefindByName方法。它还扩展了Repository接口:

public interface RestaurantRepository<Restaurant, String> extends Repository<Restaurant, String> { 

    boolean containsName(String name) throws Exception; 

    Collection<Restaurant> findByName(String name) throws Exception; 
} 

Repository接口定义了addremoveupdate三个方法。它还扩展了ReadOnlyRepository接口:

public interface Repository<TE, T> extends ReadOnlyRepository<TE, T> { 

    void add(TE entity); 

    void remove(T id); 

    void update(TE entity); 
} 

ReadOnlyRepository接口定义了getgetAll方法,分别返回布尔值、实体和实体集合。如果你想要只暴露仓库的只读抽象,这个接口很有用:

public interface ReadOnlyRepository<TE, T> { 

    boolean contains(T id); 

    Entity get(T id); 

    Collection<TE> getAll(); 
} 

Spring 框架使用@Repository注解来定义实现仓库的仓库 bean。在RestaurantRepository的情况下,可以看到使用了映射来代替实际的数据库实现。这使得所有实体都只保存在内存中。因此,当我们启动服务时,只在内存中找到两家餐厅。我们可以使用 JPA 进行数据库持久化。这是生产就绪实现的一般做法:

@Repository("restaurantRepository") 
public class InMemRestaurantRepository implements RestaurantRepository<Restaurant, String> { 
    private Map<String, Restaurant> entities; 

    public InMemRestaurantRepository() { 
        entities = new HashMap(); 
        Restaurant restaurant = new Restaurant("Big-O Restaurant", "1", null); 
        entities.put("1", restaurant); 
        restaurant = new Restaurant("O Restaurant", "2", null); 
        entities.put("2", restaurant); 
    } 

    @Override 
    public boolean containsName(String name) { 
        try { 
            return this.findByName(name).size() > 0; 
        } catch (Exception ex) { 
            //Exception Handler 
        } 
        return false; 
    } 

    @Override 
    public void add(Restaurant entity) { 
        entities.put(entity.getId(), entity); 
    } 

    @Override 
    public void remove(String id) { 
        if (entities.containsKey(id)) { 
            entities.remove(id); 
        } 
    } 

    @Override 
    public void update(Restaurant entity) { 
        if (entities.containsKey(entity.getId())) { 
            entities.put(entity.getId(), entity); 
        } 
    } 

    @Override 
    public Collection<Restaurant> findByName(String name) throws Exception { 
        Collection<Restaurant> restaurants = new ArrayList<>(); 
        int noOfChars = name.length(); 
        entities.forEach((k, v) -> { 
            if (v.getName().toLowerCase().contains(name.subSequence(0, noOfChars))) { 
                restaurants.add(v); 
            } 
        }); 
        return restaurants; 
    } 

    @Override 
    public boolean contains(String id) { 
        throw new UnsupportedOperationException("Not supported yet.");  
    } 

    @Override 
    public Entity get(String id) { 
        return entities.get(id); 
    } 

    @Override 
    public Collection<Restaurant> getAll() { 
        return entities.values(); 
    } 
} 

实体类

以下是如何定义扩展了BaseEntityRestaurant实体的:

public class Restaurant extends BaseEntity<String> { 

    private List<Table> tables = new ArrayList<>(); 

    public Restaurant(String name, String id, List<Table> tables) { 
        super(id, name); 
        this.tables = tables; 
    } 

    public void setTables(List<Table> tables) { 
        this.tables = tables; 
    } 

    public List<Table> getTables() { 
        return tables; 
    } 

    @Override 
    public String toString() { 
        return String.format("{id: %s, name: %s, address: %s, tables: %s}", this.getId(), 
                         this.getName(), this.getAddress(), this.getTables()); 
    } 

} 

由于我们使用 POJO 类来定义实体,在许多情况下我们不需要创建一个 VO。这个想法是对象的状态不应该被持久化。

以下是如何定义扩展了BaseEntityTable实体:

public class Table extends BaseEntity<BigInteger> { 

    private int capacity; 

    public Table(String name, BigInteger id, int capacity) { 
        super(id, name); 
        this.capacity = capacity; 
    } 

    public void setCapacity(int capacity) { 
        this.capacity = capacity; 
    } 

    public int getCapacity() { 
        return capacity; 
    } 

    @Override 
    public String toString() { 
        return String.format("{id: %s, name: %s, capacity: %s}", 
                         this.getId(), this.getName(), this.getCapacity());    } 

} 

以下是如何定义Entity抽象类的:

public abstract class Entity<T> { 

    T id; 
    String name; 

    public T getId() { 
        return id; 
    } 

    public void setId(T id) { 
        this.id = id; 
    } 

    public String getName() { 
        return name; 
    } 

    public void setName(String name) { 
        this.name = name; 
    } 

} 

以下是如何定义BaseEntity抽象类的。它扩展了Entity

抽象类:

public abstract class BaseEntity<T> extends Entity<T> { 

    private T id; 
    private boolean isModified; 
    private String name; 

    public BaseEntity(T id, String name) { 
        this.id = id; 
        this.name = name; 
    } 

    public T getId() { 
        return id; 
    } 

    public void setId(T id) { 
        this.id = id; 
    } 

    public boolean isIsModified() { 
        return isModified; 
    } 

    public void setIsModified(boolean isModified) { 
        this.isModified = isModified; 
    } 

    public String getName() { 
        return name; 
    } 

    public void setName(String name) { 
        this.name = name; 
    } 

} 

我们已经完成了 Restaurant 服务的实现。现在,我们将开发 Eureka 模块(服务)。

注册和发现服务(Eureka 服务)

我们需要一个所有微服务都可以注册和引用的地方——一个服务发现和注册应用程序。Spring Cloud 提供了最先进的服务注册和发现应用程序 Netflix Eureka。我们将利用它为我们的示例项目 OTRS 服务。

一旦您按照本节中的描述配置了 Eureka 服务,它将可供所有传入请求使用以在 Eureka 服务上列出它。Eureka 服务注册/列出通过 Eureka 客户端配置的所有微服务。一旦您启动您的服务,它就会通过application.yml中配置的 Eureka 服务发送 ping,一旦建立连接,Eureka 服务将注册该服务。

它还通过统一的连接方式启用微服务的发现。您不需要任何 IP、主机名或端口来查找服务,您只需要提供服务 ID 即可。服务 ID 在各个微服务的application.yml中配置。

在以下三个步骤中,我们可以创建一个 Eureka 服务(服务注册和发现服务):

  1. Maven 依赖:它需要一个 Spring Cloud 依赖,如图所示,并在pom.xml中的启动类中使用@EnableEurekaApplication注解:
<dependency> 
      <groupId>org.springframework.cloud</groupId> 
      <artifactId>spring-cloud-starter-config</artifactId> 
</dependency> 
<dependency> 
      <groupId>org.springframework.cloud</groupId> 
      <artifactId>spring-cloud-netflix-eureka-server</artifactId> 
</dependency> 
  1. 启动类:启动类App通过仅使用@EnableEurekaApplication类注解来无缝运行 Eureka 服务:
package com.packtpub.mmj.eureka.service; 

import org.springframework.boot.SpringApplication; 
import org.springframework.boot.autoconfigure.SpringBootApplication; 
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; 

@SpringBootApplication 
@EnableEurekaServer 
public class App { 

    public static void main(String[] args) { 
        SpringApplication.run(App.class, args); 
    } 
} 

pom.xml项目的<properties>标签下使用<start-class>com.packtpub.mmj.eureka.service.App</start-class>

  1. Spring 配置:Eureka 服务也需要以下 Spring 配置来实现 Eureka 服务器的配置(src/main/resources/application.yml):
server: 
  port: 8761  # HTTP port 

eureka: 
  instance: 
    hostname: localhost 
  client: 
    registerWithEureka: false 
    fetchRegistry: false 
    serviceUrl: 
        defaultZone: ${vcap.services.${PREFIX:}eureka.credentials.uri:http://user:password@localhost:8761}/eureka/ 
  server: 
    waitTimeInMsWhenSyncEmpty: 0 
    enableSelfPreservation: false 

Eureka 客户端

与 Eureka 服务器类似,每个 OTRS 服务也应该包含 Eureka 客户端配置,以便可以建立 Eureka 服务器和客户端之间的连接。没有这个,服务的注册和发现是不可能的。

您的服务可以使用以下 Spring 配置来配置 Eureka 客户端。在restaurant-service\src\main\resources\application.yml中添加以下配置:

eureka: 
  client: 
    serviceUrl: 
      defaultZone: http://localhost:8761/eureka/ 

预订和用户服务

我们可以使用RestaurantService实现来开发预订和用户服务。用户服务可以提供与用户资源相关的 CRUD 操作端点。预订服务可以提供与预订资源相关的 CRUD 操作端点和桌位可用性。您可以在 Packt 网站或 Packt Publishing GitHub 仓库上找到这些服务的示例代码。

执行

要了解我们的代码是如何工作的,我们首先需要构建它,然后执行它。我们将使用 Maven 清理包来构建服务 JAR。

现在,要执行这些服务 JAR,只需从项目根目录执行以下命令即可:

java -jar <service>/target/<service_jar_file> 

以下是一些示例:

java -jar restaurant-service/target/restaurant-service.jar 
java -jar eureka-service/target/eureka-service.jar 

我们将按以下顺序从项目根目录执行我们的服务。首先应启动 Eureka 服务;最后三个微服务的顺序可以改变:

java -jar eureka-service/target/eureka-service.jar
java -jar restaurant-service/target/restaurant-service.jar java -jar booking-service/target/booking-service.jar java -jar user-service/target/user-service.jar

测试

为了启用测试,在pom.xml文件中添加以下依赖项:

<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-test</artifactId> 
</dependency> 

为了测试RestaurantController,已添加以下文件:

  • RestaurantControllerIntegrationTests类,它使用了

    @SpringApplicationConfiguration注解以选择 Spring Boot 使用的相同配置:

@RunWith(SpringJUnit4ClassRunner.class) 
@SpringApplicationConfiguration(classes = RestaurantApp.class) 
public class RestaurantControllerIntegrationTests extends 
        AbstractRestaurantControllerTests { 

}
  • 一个abstract类来编写我们的测试:
public abstract class AbstractRestaurantControllerTests { 

    protected static final String RESTAURANT = "1"; 
    protected static final String RESTAURANT_NAME = "Big-O Restaurant"; 

    @Autowired 
    RestaurantController restaurantController; 

    @Test 
    public void validResturantById() { 
        Logger.getGlobal().info("Start validResturantById test"); 
        ResponseEntity<Entity> restaurant = restaurantController.findById(RESTAURANT); 

        Assert.assertEquals(HttpStatus.OK, restaurant.getStatusCode()); 
        Assert.assertTrue(restaurant.hasBody()); 
        Assert.assertNotNull(restaurant.getBody()); 
        Assert.assertEquals(RESTAURANT, restaurant.getBody().getId()); 
        Assert.assertEquals(RESTAURANT_NAME, restaurant.getBody().getName()); 
        Logger.getGlobal().info("End validResturantById test"); 
    } 

    @Test 
    public void validResturantByName() { 
        Logger.getGlobal().info("Start validResturantByName test"); 
        ResponseEntity<Collection<Restaurant>> restaurants = restaurantController.findByName(RESTAURANT_NAME); 
        Logger.getGlobal().info("In validAccount test"); 

        Assert.assertEquals(HttpStatus.OK, restaurants.getStatusCode()); 
        Assert.assertTrue(restaurants.hasBody()); 
        Assert.assertNotNull(restaurants.getBody()); 
        Assert.assertFalse(restaurants.getBody().isEmpty()); 
        Restaurant restaurant = (Restaurant) restaurants.getBody().toArray()[0]; 
        Assert.assertEquals(RESTAURANT, restaurant.getId()); 
        Assert.assertEquals(RESTAURANT_NAME, restaurant.getName()); 
        Logger.getGlobal().info("End validResturantByName test"); 
    } 

    @Test 
    public void validAdd() { 
        Logger.getGlobal().info("Start validAdd test"); 
        RestaurantVO restaurant = new RestaurantVO(); 
        restaurant.setId("999"); 
        restaurant.setName("Test Restaurant"); 

        ResponseEntity<Restaurant> restaurants = restaurantController.add(restaurant); 
        Assert.assertEquals(HttpStatus.CREATED, restaurants.getStatusCode()); 
        Logger.getGlobal().info("End validAdd test"); 
    } 
} 
  • 最后是RestaurantControllerTests类,它扩展了之前创建的abstract类,还创建了RestaurantServiceRestaurantRepository实现:
public class RestaurantControllerTests extends AbstractRestaurantControllerTests { 

    protected static final Restaurant restaurantStaticInstance = new Restaurant(RESTAURANT, 
            RESTAURANT_NAME, null); 

    protected static class TestRestaurantRepository implements RestaurantRepository<Restaurant, String> { 

        private Map<String, Restaurant> entities; 

        public TestRestaurantRepository() { 
            entities = new HashMap(); 
            Restaurant restaurant = new Restaurant("Big-O Restaurant", "1", null); 
            entities.put("1", restaurant); 
            restaurant = new Restaurant("O Restaurant", "2", null); 
            entities.put("2", restaurant); 
        } 

        @Override 
        public boolean containsName(String name) { 
            try { 
                return this.findByName(name).size() > 0; 
            } catch (Exception ex) { 
                //Exception Handler 
            } 
            return false; 
        } 

        @Override 
        public void add(Restaurant entity) { 
            entities.put(entity.getId(), entity); 
        } 

        @Override 
        public void remove(String id) { 
            if (entities.containsKey(id)) { 
                entities.remove(id); 
            } 
        } 

        @Override 
        public void update(Restaurant entity) { 
            if (entities.containsKey(entity.getId())) { 
                entities.put(entity.getId(), entity); 
            } 
        } 

        @Override 
        public Collection<Restaurant> findByName(String name) throws Exception { 
            Collection<Restaurant> restaurants = new ArrayList(); 
            int noOfChars = name.length(); 
            entities.forEach((k, v) -> { 
                if (v.getName().toLowerCase().contains(name.subSequence(0, noOfChars))) { 
                    restaurants.add(v); 
                } 
            }); 
            return restaurants; 
        } 

        @Override 
        public boolean contains(String id) { 
            throw new UnsupportedOperationException("Not supported yet.");
        } 

        @Override 
        public Entity get(String id) { 
            return entities.get(id); 
        } 
        @Override 
        public Collection<Restaurant> getAll() { 
            return entities.values(); 
        } 
    } 

    protected TestRestaurantRepository testRestaurantRepository = new TestRestaurantRepository(); 
    protected RestaurantService restaurantService = new RestaurantServiceImpl(testRestaurantRepository); 

    @Before 
    public void setup() { 
        restaurantController = new RestaurantController(restaurantService); 

    } 
} 

参考文献

总结

在本章中,我们学习了领域驱动设计模型如何在微服务中使用。运行演示应用程序后,我们可以看到每个微服务如何可以独立地开发、部署和测试。你可以使用 Spring Cloud 非常容易地创建微服务。我们还探讨了如何使用 Spring Cloud 与 Eureka 注册和发现组件。

在下一章中,我们将学习如何将微服务部署在容器中,例如 Docker。我们还将了解使用 REST Java 客户端和其他工具进行微服务测试。

第五章:部署和测试

在本章中,我们将接着第四章《实现微服务》的内容继续讲解。我们将向仅依赖于三个功能性服务(餐厅、用户和预订服务)以及 Eureka(服务发现和注册)的在线桌位预订系统(OTRS)应用程序添加一些更多服务,以创建一个完全功能的微服务堆栈。这个堆栈将包括网关(Zuul)、负载均衡(Ribbon 与 Zuul 和 Eureka)、监控(Hystrix、Turbine 和 Hystrix 仪表板)。你希望拥有组合 API,并了解一个微服务如何与其他微服务通信。本章还将解释如何使用 Docker 容器化微服务,以及如何使用docker-compose一起运行多个容器。在此基础上,我们还将添加集成测试。

在本章中,我们将介绍以下主题:

  • 使用 Netflix OSS 的微服务架构概述

  • 边缘服务器

  • 负载均衡微服务

  • 断路器和监控

  • 使用容器部署微服务

  • 使用 Docker 容器进行微服务集成测试

良好的微服务所需的强制服务

为了实现基于微服务的架构设计,应该有一些模式/服务需要到位。这个列表包括以下内容:

  • 服务发现和注册

  • 边缘或代理服务器

  • 负载均衡

  • 断路器

  • 监控

我们将在本章实现这些服务,以完成我们的 OTRS 系统。以下是简要概述。我们稍后详细讨论这些模式/服务。

服务发现和注册

Netflix Eureka 服务器用于服务发现和注册。我们在上一章创建了 Eureka 服务。它不仅允许你注册和发现服务,还提供使用 Ribbon 的负载均衡。

边缘服务器

边缘服务器提供一个单一的访问点,允许外部世界与你的系统交互。你的所有 API 和前端都只能通过这个服务器访问。因此,这些也被称为网关或代理服务器。这些被配置为将请求路由到不同的微服务或前端应用程序。在 OTRS 应用程序中,我们将使用 Netflix Zuul 服务器作为边缘服务器。

负载均衡

Netflix Ribbon 用于负载均衡。它与 Zuul 和 Eureka 服务集成,为内部和外部调用提供负载均衡。

断路器

一个故障或断裂不应该阻止你的整个系统运行。此外,一个服务或 API 的反复失败应该得到适当的处理。断路器提供了这些功能。Netflix Hystrix 作为断路器使用,有助于保持系统运行。

监控

使用 Netflix Hystrix 仪表板和 Netflix Turbine 进行微服务监控。它提供了一个仪表板,用于检查运行中微服务的状态。

使用 Netflix OSS 的微服务架构概述

Netflix 是微服务架构的先驱。他们是第一个成功在大规模实施微服务架构的人。他们还通过将大部分微服务工具开源,并命名为 Netflix 开源软件中心OSS),极大地提高了微服务的普及程度并做出了巨大贡献。

根据 Netflix 的博客,当 Netflix 开发他们的平台时,他们使用了 Apache Cassandra 进行数据存储,这是一个来自 Apache 的开源工具。他们开始通过修复和优化扩展为 Cassandra 做贡献。这导致了 Netflix 看到将 Netflix 项目以 OSS 的名义发布的益处。

Spring 抓住了机会,将许多 Netflix 的开源项目(如 Zuul、Ribbon、Hystrix、Eureka 服务器和 Turbine)集成到 Spring Cloud 中。这是 Spring Cloud 能够为生产就绪的微服务提供现成平台的原因之一。

现在,让我们来看看几个重要的 Netflix 工具以及它们如何在微服务架构中发挥作用:

微服务架构图

正如您在前面的图表中所看到的,对于每一种微服务实践,我们都有一个与之相关的 Netflix 工具。我们可以通过以下映射来了解它。详细信息在本章的相应部分中介绍,关于 Eureka 的部分在最后一章中有详细说明:

  • 边缘服务器:我们使用 Netflix Zuul 服务器作为边缘服务器。

  • 负载均衡:Netflix Ribbon 用于负载均衡。

  • 断路器:Netflix Hystrix 用作断路器,有助于保持系统运行。

  • 服务发现与注册:Netflix Eureka 服务器用于服务发现和注册。

  • 监控仪表板:Hystrix 监控仪表板与 Netflix Turbine 配合使用,用于微服务监控。它提供了一个仪表板,用于检查运行中微服务的状态。

负载均衡

负载均衡是服务于请求的方式,以最大化速度和容量利用率,并确保没有服务器因请求过多而超载。负载均衡器还将请求重定向到其他主机服务器,如果服务器宕机的话。在微服务架构中,微服务可以服务于内部或外部请求。基于这一点,我们可以有两种类型的负载均衡——客户端负载均衡和服务器端负载均衡。

服务器端负载均衡

我们将讨论服务器端负载均衡;在那之前,我们先讨论路由。从微服务架构的角度来看,为我们的 OTRS 应用程序定义路由机制是很重要的。例如,/(根)可以映射到我们的 UI 应用程序。同样,/restaurantapi/userapi可以分别映射到餐厅服务和用户服务。边缘服务器也执行带有负载均衡的路由。

我们将使用 Netflix Zuul 服务器作为我们的边缘服务器。Zuul 是一个基于 JVM 的路由和服务器端负载均衡器。Zuul 支持用任何 JVM 语言编写规则和过滤器,并内置了对 Java 和 Groovy 的支持。

Netflix Zuul 默认具有发现客户端(Eureka 客户端)支持。Zuul 还利用 Ribbon 和 Eureka 进行负载均衡。

外部世界(UI 和其他客户端)调用边缘服务器,使用application.yml中定义的路线调用内部服务并提供响应。如果您认为它充当代理服务器,为内部网络承担网关责任,并且为定义和配置的路线调用内部服务,那么您的猜测是正确的。

通常,建议对所有请求使用单个边缘服务器。然而,一些公司为了扩展,每个客户端使用一个边缘服务器。例如,Netflix 为每种设备类型使用一个专用的边缘服务器。

在下一章中,我们配置和实现微服务安全时,也将使用边缘服务器。

在 Spring Cloud 中配置和使用边缘服务器相当简单。您需要执行以下步骤:

  1. pom.xml文件中定义 Zuul 服务器依赖项:
<dependency> 
      <groupId>org.springframework.cloud</groupId> 
      <artifactId>spring-cloud-starter-zuul</artifactId> 
</dependency> 
  1. 在您的应用程序类中使用@EnableZuulProxy注解。它还内部使用@EnableDiscoveryClient注解;因此,它也会自动注册到 Eureka 服务器。您可以在客户端负载均衡部分的图中找到注册的 Zuul 服务器。

  2. 更新application.yml文件中的 Zuul 配置,如下所示:

  • zuul:ignoredServices:这跳过了服务的自动添加。我们可以在这里定义服务 ID 模式。*表示我们忽略所有服务。在下面的示例中,除了restaurant-service,所有服务都被忽略。

  • Zuul.routes:这包含定义 URI 模式的path属性。在这里,/restaurantapi通过serviceId属性映射到restaurant-serviceserviceId属性代表 Eureka 服务器中的服务。如果未使用 Eureka 服务器,可以使用 URL 代替服务。我们还使用了stripPrefix属性来去除前缀(/restaurantapi),结果/restaurantapi/v1/restaurants/1调用转换为在调用服务时/v1/restaurants/1:

application.yml 
info: 
    component: Zuul Server 
# Spring properties 
spring: 
  application: 
     name: zuul-server  # Service registers under this name 

endpoints: 
    restart: 
        enabled: true 
    shutdown: 
        enabled: true 
    health: 
        sensitive: false 

zuul: 
    ignoredServices: "*" 
    routes: 
        restaurantapi: 
            path: /restaurantapi/** 
            serviceId: restaurant-service 
            stripPrefix: true 

server: 
    port: 8765 

# Discovery Server Access 
eureka: 
  instance: 
    leaseRenewalIntervalInSeconds: 3 
    metadataMap: 
      instanceId: ${vcap.application.instance_id:${spring.application.name}:${spring.application.instance_id:${random.value}}} 
    serviceUrl: 
      defaultZone: http://localhost:8761/eureka/ 
    fetchRegistry: false 

请注意,Eureka 应用程序只在每台主机上注册任何服务的单个实例。您需要为metadataMap.instanceid使用以下值,以便在同一台主机上注册同一应用程序的多个实例,以便负载均衡工作:

${spring.application.name}:${vcap.application.instance_id:${spring.application.instance_id:${random.value}}}

让我们看看一个工作的边缘服务器。首先,我们将按照以下方式调用端口3402上部署的餐厅服务:

直接调用餐厅服务

然后,我们将使用部署在端口8765的边缘服务器调用同一服务。你可以看到,调用/v1/restaurants?name=o时使用了/restaurantapi前缀,并且给出了相同的结果:

使用边缘服务器调用餐厅服务

客户端负载均衡

微服务需要进程间通信,以便服务能够相互通信。Spring Cloud 使用 Netflix Ribbon,这是一个客户端负载均衡器,扮演着这一关键角色,并可以处理 HTTP 和 TCP。Ribbon 是云兼容的,并提供了内置的故障弹性。Ribbon 还允许你使用多个可插拔的负载均衡规则。它将客户端与负载均衡器集成在一起。

在上一章中,我们添加了 Eureka 服务器。Spring Cloud 默认通过 Ribbon 与 Eureka 服务器集成。这种集成提供了以下功能:

  • 当使用 Eureka 服务器时,你不需要硬编码远程服务器 URL 进行发现。这是一个显著的优势,尽管如果你需要,你仍然可以使用application.yml文件中配置的服务器列表(listOfServers)。

  • 服务器列表从 Eureka 服务器获取。Eureka 服务器用DiscoveryEnabledNIWSServerList接口覆盖了ribbonServerList

  • 查找服务器是否运行的请求被委托给 Eureka。这里使用了DiscoveryEnabledNIWSServerList接口来代替 Ribbon 的IPing

在 Spring Cloud 中,使用 Ribbon 有不同的客户端可供选择,比如RestTemplateFeignClient。这些客户端使得微服务之间能够相互通信。当使用 Eureka 服务器时,客户端使用实例 ID 代替主机名和端口来对服务实例进行 HTTP 调用。客户端将服务 ID 传递给 Ribbon,然后 Ribbon 使用负载均衡器从 Eureka 服务器中选择实例。

如以下屏幕截图所示,如果 Eureka 中有多个服务实例可用,Ribbon 根据负载均衡算法只为请求选择一个:

多服务注册 - 餐厅服务

我们可以使用DiscoveryClient来查找 Eureka 服务器中所有可用的服务实例,如下面的代码所示。DiscoveryClientSample类中的getLocalServiceInstance()方法返回 Eureka 服务器中所有可用的本地服务实例。

这是一个DiscoveryClient示例:

@Component 
class DiscoveryClientSample implements CommandLineRunner { 

    @Autowired 
    private DiscoveryClient; 

    @Override 
    public void run(String... strings) throws Exception { 
        // print the Discovery Client Description 
        System.out.println(discoveryClient.description()); 
        // Get restaurant-service instances and prints its info 
        discoveryClient.getInstances("restaurant-service").forEach((ServiceInstance serviceInstance) -> { 
            System.out.println(new StringBuilder("Instance --> ").append(serviceInstance.getServiceId()) 
                    .append("\nServer: ").append(serviceInstance.getHost()).append(":").append(serviceInstance.getPort()) 
                    .append("\nURI: ").append(serviceInstance.getUri()).append("\n\n\n")); 
        }); 
    } 
} 

当执行此代码时,它会打印以下信息。它显示了餐厅服务的两个实例:

Spring Cloud Eureka Discovery Client 
Instance: RESTAURANT-SERVICE 
Server: SOUSHARM-IN:3402 
URI: http://SOUSHARM-IN:3402 
Instance --> RESTAURANT-SERVICE 
Server: SOUSHARM-IN:3368 
URI: http://SOUSHARM-IN:3368 

下面的示例展示了这些客户端如何使用。你可以在两个客户端中看到,服务名称restaurant-service被用来代替服务主机名和端口。这些客户端调用/v1/restaurants来获取包含在名称查询参数中的餐厅名称的餐厅列表。

这是一个RestTemplate示例:

@Component
class RestTemplateExample implements CommandLineRunner {
  @Autowired
  private RestTemplate restTemplate;
  @Override
  public void run(String... strings) throws Exception {
    System.out.println("\n\n\n start RestTemplate client...");
    ResponseEntity<Collection<Restaurant>> exchange
    = this.restTemplate.exchange(
    "http://restaurant-service/v1/restaurants?name=o",
    HttpMethod.GET,
    null,
    new ParameterizedTypeReference<Collection<Restaurant>>() {
    },
    (Object) "restaurants");
    exchange.getBody().forEach((Restaurant restaurant) -> {
      System.out.println("\n\n\n[ " + restaurant.getId() + " " +  restaurant.getName() + "]");
      });
   }
}

这是一个FeignClient示例:

@FeignClient("restaurant-service")
interface RestaurantClient {
  @RequestMapping(method = RequestMethod.GET, value =  "/v1/restaurants")
  Collection<Restaurant> getRestaurants(@RequestParam("name") String name);
  }
@Component
class FeignSample implements CommandLineRunner {
  @Autowired
  private RestaurantClient restaurantClient;
  @Override
  public void run(String... strings) throws Exception {
    this.restaurantClient.getRestaurants("o").forEach((Restaurant     restaurant) -> {
      System.out.println("\n\n\n[ " + restaurant.getId() + " " +  restaurant.getName() + "]");
      });
    }
} 

所有前面的示例都将打印以下输出:

[ 1 Big-O Restaurant] 
[ 2 O Restaurant] 

为了演示目的,我们在边缘应用程序主类 Java 文件中添加了所有客户端—discovery客户端、RestTemplate客户端和FeignClient。由于我们所有这些客户端都实现了CommandLineRunner接口,这会在边缘应用程序服务启动后立即执行。

断路器与监控

通常而言,断路器是一种自动装置,用于在电气电路中作为安全措施停止电流的流动

同样的概念也用于微服务开发,称为断路器设计模式。它跟踪外部服务的可用性,如 Eureka 服务器、API 服务如restaurant-service等,并防止服务消费者对任何不可用的服务执行任何操作。

这是微服务架构的另一个重要方面,一种安全措施

(安全机制)当服务消费者对服务的调用没有响应时,这称为断路器。

我们将使用 Netflix Hystrix 作为断路器。当发生故障时(例如,由于通信错误或超时),它在服务消费者内部调用回退方法。它在服务消费者内执行。在下一节中,您将找到实现此功能的代码。

Hystrix 在服务未能响应时打开电路,并在服务再次可用之前快速失败。当对特定服务的调用达到一定阈值(默认阈值是五秒内 20 次失败),电路打开,调用不再进行。您可能想知道,如果 Hystrix 打开电路,那么它是如何知道服务可用的?它异常地允许一些请求调用服务。

使用 Hystrix 的回退方法

实现回退方法有五个步骤。为此,我们将创建另一个服务,api-service,就像我们创建其他服务一样。api-service服务将消费其他服务,如restaurant-service等,并将在边缘服务器中配置以对外暴露 OTRS API。这五个步骤如下:

  1. 启用断路器:主要消费其他服务的微服务类应该用@EnableCircuitBreaker注解标记。因此,我们将注释src\main\java\com\packtpub\mmj\api\service\ApiApp.java
@SpringBootApplication 
@EnableCircuitBreaker 
@ComponentScan({"com.packtpub.mmj.user.service", "com.packtpub.mmj.common"}) 
public class ApiApp { 
  1. 配置回退方法:用@HystrixCommand注解来配置fallbackMethod。我们将注释控制器方法来配置回退方法。这是文件:src\main\java\com\packtpub\mmj\api\service\restaurant\RestaurantServiceAPI.java
@HystrixCommand(fallbackMethod = "defaultRestaurant") 
    @RequestMapping("/restaurants/{restaurant-id}") 
    @HystrixCommand(fallbackMethod = "defaultRestaurant") 
    public ResponseEntity<Restaurant> getRestaurant( 
            @PathVariable("restaurant-id") int restaurantId) { 
        MDC.put("restaurantId", restaurantId); 
        String url = "http://restaurant-service/v1/restaurants/" + restaurantId; 
        LOG.debug("GetRestaurant from URL: {}", url); 

        ResponseEntity<Restaurant> result = restTemplate.getForEntity(url, Restaurant.class); 
        LOG.info("GetRestaurant http-status: {}", result.getStatusCode()); 
        LOG.debug("GetRestaurant body: {}", result.getBody()); 

        return serviceHelper.createOkResponse(result.getBody()); 
    }  
  1. 定义回退方法:处理失败并执行安全步骤的方法。在这里,我们只是添加了一个示例;这可以根据我们想要处理失败的方式进行修改:
public ResponseEntity<Restaurant> defaultRestaurant(
@PathVariable int restaurantId) { 
  return serviceHelper.createResponse(null, HttpStatus.BAD_GATEWAY); 
  } 
  1. Maven 依赖项:我们需要在pom.xml中为 API 服务或希望确保 API 调用的项目中添加以下依赖项:
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-hystrix</artifactId> 
</dependency> 
  1. application.yml中配置 Hystrix:我们将在我们的application.yml文件中添加以下 Hystrix 属性:
       hystrix: 
  threadpool: 
    default: 
      # Maximum number of concurrent requests when using thread pools (Default: 10) 
      coreSize: 100 
      # Maximum LinkedBlockingQueue size - -1 for using SynchronousQueue (Default: -1) 
      maxQueueSize: -1 
      # Queue size rejection threshold (Default: 5) 
      queueSizeRejectionThreshold: 5 
  command: 
    default: 
      circuitBreaker: 
        sleepWindowInMilliseconds: 30000 
        requestVolumeThreshold: 2 
      execution: 
        isolation: 
#          strategy: SEMAPHORE, no thread pool but timeout handling stops to work 
          strategy: THREAD 
          thread: 
            timeoutInMilliseconds: 6000

这些步骤应该足以确保服务调用的安全,并向服务消费者返回一个更合适的响应。

监控

Hystrix 提供了一个带有 web UI 的仪表板,提供很好的电路断路器图形:

默认的 Hystrix 仪表板

Netflix Turbine 是一个 web 应用程序,它连接到 Hystrix 应用程序集群的实例并聚合信息,实时进行(每 0.5 秒更新一次)。Turbine 使用称为 Turbine 流的流提供信息。

如果你将 Hystrix 与 Netflix Turbine 结合使用,那么你可以在 Hystrix 仪表板上获取 Eureka 服务器上的所有信息。这为你提供了有关所有电路断路器的信息的全景视图。

要使用 Turbine 和 Hystrix,只需在前面截图中的第一个文本框中输入 Turbine 的 URLhttp://localhost:8989/turbine.stream(在application.yml中为 Turbine 服务器配置了端口8989),然后点击监控流。

Netflix Hystrix 和 Turbine 使用 RabbitMQ,这是一个开源的消息队列软件。RabbitMQ 基于高级消息队列协议AMQP)工作。这是一个软件,在此软件中可以定义队列并由连接的应用程序交换消息。消息可以包含任何类型的信息。消息可以存储在 RabbitMQ 队列中,直到接收应用程序连接并消耗消息(将消息从队列中移除)。

Hystrix 使用 RabbitMQ 将度量数据发送到 Turbine。

在配置 Hystrix 和 Turbine 之前,请在你的平台上演示安装 RabbitMQ 应用程序。Hystrix 和 Turbine 使用 RabbitMQ 彼此之间进行通信。

设置 Hystrix 仪表板

我们将在 IDE 中创建另一个项目,以与创建其他服务相同的方式创建 Hystrix 仪表板。在这个新项目中,我们将添加新的 Maven 依赖项dashboard-server,用于 Hystrix 服务器。在 Spring Cloud 中配置和使用 Hystrix 仪表板相当简单。

当你运行 Hystrix 仪表板应用程序时,它会看起来像前面所示的默认 Hystrix 仪表板快照。你只需要按照以下步骤操作:

  1. pom.xml文件中定义 Hystrix 仪表板依赖项:
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-hystrix-dashboard</artifactId> 
</dependency> 
  1. 在主 Java 类中的@EnableHystrixDashboard注解为您使用它做了所有事情。我们还将使用@Controller将根 URI 的请求转发到 Hystrix 仪表板 UI URI(/hystrix),如下所示:
@SpringBootApplication 
@Controller 
@EnableHystrixDashboard 
public class DashboardApp extends SpringBootServletInitializer { 

    @RequestMapping("/") 
    public String home() { 
        return "forward:/hystrix"; 
    } 

    @Override 
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { 
        return application.sources(DashboardApp.class).web(true); 
    } 

    public static void main(String[] args) { 
        SpringApplication.run(DashboardApp.class, args); 
    } 
} 
  1. 如所示更新application.yml中的仪表板应用程序配置:
# Hystrix Dashboard properties 
spring: 
    application: 
        name: dashboard-server 

endpoints: 
    restart: 
        enabled: true 
    shutdown: 
        enabled: true 

server: 
    port: 7979 

eureka: 
    instance: 
        leaseRenewalIntervalInSeconds: 3 
        metadataMap: 
            instanceId: ${vcap.application.instance_id:${spring.application.name}:${spring.application.instance_id:${random.value}}} 

    client: 
        # Default values comes from org.springframework.cloud.netflix.eurek.EurekaClientConfigBean 
        registryFetchIntervalSeconds: 5 
        instanceInfoReplicationIntervalSeconds: 5 
        initialInstanceInfoReplicationIntervalSeconds: 5 
        serviceUrl: 
            defaultZone: http://localhost:8761/eureka/ 
        fetchRegistry: false 

logging: 
    level: 
        ROOT: WARN 
        org.springframework.web: WARN 

创建 Turbine 服务

Turbine 将所有/hystrix.stream端点聚合成一个合并的/turbine.stream,以供 Hystrix 仪表板使用,这更有助于查看系统的整体健康状况,而不是使用/hystrix.stream监视各个服务。我们将在 IDE 中创建另一个服务项目,然后在pom.xml中为 Turbine 添加 Maven 依赖项。

现在,我们将使用以下步骤配置 Turbine 服务器:

  1. pom.xml中定义 Turbine 服务器的依赖项:
<dependency> 
    <groupId> org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-turbine-stream</artifactId> 
</dependency> 
<dependency> 
     <groupId>org.springframework.cloud</groupId> 
     <artifactId>spring-cloud-starter-stream-rabbit</artifactId> 
</dependency> 
<dependency> 
     <groupId>org.springframework.boot</groupId> 
     <artifactId>spring-boot-starter-actuator</artifactId> 
</dependency> 

  1. 在您的应用程序类中使用@EnableTurbineStream注解,如

    此处显示。我们还定义了一个将返回 RabbitMQ ConnectionFactory的 Bean:

@SpringBootApplication 
@EnableTurbineStream 
@EnableEurekaClient 
public class TurbineApp { 

    private static final Logger LOG = LoggerFactory.getLogger(TurbineApp.class); 

    @Value("${app.rabbitmq.host:localhost}") 
    String rabbitMQHost; 

    @Bean 
    public ConnectionFactory connectionFactory() { 
        LOG.info("Creating RabbitMQHost ConnectionFactory for host: {}", rabbitMQHost); 
        CachingConnectionFactory cachingConnectionFactory = new CachingConnectionFactory(rabbitMQHost); 
        return cachingConnectionFactory; 
    } 

    public static void main(String[] args) { 
        SpringApplication.run(TurbineApp.class, args); 
    } 
} 
  1. 根据下面所示,更新application.yml中的 Turbine 配置:
  • server:port:Turbine HTTP 使用的主要端口

  • management:port:Turbine 执行器端点的端口:

application.yml 
spring: 
    application: 
        name: turbine-server 

server: 
    port: 8989 

management: 
    port: 8990 

turbine: 
    aggregator: 
        clusterConfig: USER-SERVICE,RESTAURANT-SERVICE 
    appConfig: user-service,restaurant-service  

eureka: 
    instance: 
        leaseRenewalIntervalInSeconds: 10 
        metadataMap: 
            instanceId: ${vcap.application.instance_id:${spring.application.name}:${spring.application.instance_id:${random.value}}} 
    client: 
        serviceUrl: 
            defaultZone: ${vcap.services.${PREFIX:}eureka.credentials.uri:http://user:password@localhost:8761}/eureka/ 
        fetchRegistry: true 

logging: 
    level: 
        root: INFO 
        com.netflix.discovery: 'OFF' 
        org.springframework.integration: DEBUG 

之前,我们使用turbine.aggregator.clusterConfig属性将用户和餐厅服务添加到一个集群中。这里,值以大写字母表示,因为 Eureka 以大写字母返回服务名称。而且,turbine.appConfig属性包含了 Turbine 用来查找实例的 Eureka 服务 ID 列表。请注意,之前的步骤总是使用默认配置创建了相应的服务器。如有需要,可以使用特定设置覆盖默认配置。

构建和运行 OTRS 应用程序

使用以下文件:..\Chapter5 \pom.xml,使用mvn clean install构建所有项目。

输出应该如下所示:

6392_chapter5 ..................................... SUCCESS [3.037s] 
online-table-reservation:common ................... SUCCESS [5.899s] 
online-table-reservation:zuul-server .............. SUCCESS [4.517s] 
online-table-reservation:restaurant-service ....... SUCCESS [49.250s] 
online-table-reservation:eureka-server ............ SUCCESS [2.850s] online-table-reservation:dashboard-server ......... SUCCESS [2.893s] 
online-table-reservation:turbine-server ........... SUCCESS [3.670s] 
online-table-reservation:user-service ............. SUCCESS [47.983s] 
online-table-reservation:api-service .............. SUCCESS [3.065s] 
online-table-reservation:booking-service .......... SUCCESS [26.496s] 

然后,命令提示符上进入<path to source>/6392_chapter5并运行以下命令:

java -jar eureka-server/target/eureka-server.jar 
java -jar turbine-server/target/turbine-server.jar 
java -jar dashboard-server/target/dashboard-server.jar 
java -jar restaurant-service/target/restaurant-service.jar 
java -jar user-service/target/user-service.jar 
java -jar booking-service/target/booking-service.jar 
java -jar api-service/target/api-service.jar 

注意:在启动 Zuul 服务之前,请确保 Eureka 仪表板上的所有服务都处于启动状态:http://localhost:8761/

java -jar zuul-server/target/zuul-server.jar 

再次检查 Eureka 仪表板,所有应用程序都应该处于启动状态。然后进行测试。

使用容器部署微服务

读完第一章《解决方案方法》后,您可能已经理解了 Docker 的要点。

Docker 容器提供了一个轻量级的运行时环境,由虚拟机的核心功能和操作系统的隔离服务组成,称为 Docker 镜像。Docker 使微服务的打包和执行变得更加简单。每个操作系统可以有多个 Docker,每个 Docker 可以运行单个应用程序。

安装与配置

如果您不使用 Linux 操作系统,Docker 需要一个虚拟化服务器。您可以安装 VirtualBox 或类似的工具,如 Docker Toolbox,使其适用于您。Docker 安装页面提供了更多关于它的细节,并告诉您如何执行。所以,请参考 Docker 网站上的 Docker 安装指南。

你可以根据你的平台,通过遵循给出的说明安装 Docker:docs.docker.com/engine/installation/

DockerToolbox-1.9.1f 是在写作时可用的最新版本。这个版本我们使用了。

具有 4GB 内存的 Docker 虚拟机

默认的虚拟机创建时会分配 2GB 的内存。我们将重新创建一个具有 4GB 内存的 Docker 虚拟机:

 docker-machine rm default
 docker-machine create -d virtualbox --virtualbox-memory 4096 default

使用 Maven 构建 Docker 镜像

有多种 Docker Maven 插件可以使用:

你可以根据你的选择使用这些方法中的任何一个。我发现由@rhuss编写的 Docker Maven 插件最适合我们使用。这个插件定期更新,并且相比其他插件拥有许多额外的功能。

在讨论docker-maven-plugin的配置之前,我们需要在application.yml中引入 Docker Spring 配置文件。这样我们在为不同平台构建服务时,工作会更加容易。我们需要配置以下四个属性:

  • 我们将使用标识为 Docker 的 Spring 配置文件。

  • 由于服务将在它们自己的容器中执行,所以嵌入式 Tomcat 之间不会有端口冲突。现在我们可以使用端口8080

  • 我们更倾向于使用 IP 地址来在我们的 Eureka 中注册服务。因此,Eureka 实例属性preferIpAddress将被设置为true

  • 最后,我们将在serviceUrl:defaultZone中使用 Eureka 服务器的主机名。

要在你的项目中添加 Spring 配置文件,请在application.yml中现有内容之后添加以下行:

--- 
# For deployment in Docker containers 
spring: 
  profiles: docker 

server: 
  port: 8080 

eureka: 
  instance: 
    preferIpAddress: true 
  client: 
    serviceUrl: 
      defaultZone: http://eureka:8761/eureka/ 

使用命令mvn -P docker clean package将生成带有 Tomcat 的8080端口的service JAR,并且该 JAR 会在 Eureka 服务器上以主机名eureka注册。

现在,让我们配置docker-maven-plugin以构建带有我们的餐厅微服务的镜像。这个插件首先必须创建一个 Dockerfile。Dockerfile 在两个地方配置——在pom.xmldocker-assembly.xml文件中。我们将在pom.xml文件中使用以下的插件配置:

<properties> 
<!-- For Docker hub leave empty; use "localhost:5000/" for a local Docker Registry --> 
  <docker.registry.name>localhost:5000/</docker.registry.name> 
  <docker.repository.name>${docker.registry.name}sourabhh /${project.artifactId}</docker.repository.name> 
</properties> 
... 
<plugin> 
  <groupId>org.jolokia</groupId> 
  <artifactId>docker-maven-plugin</artifactId> 
  <version>0.13.7</version> 
  <configuration> 
    <images> 
      <image> 
<name>${docker.repository.name}:${project.version}</name> 
        <alias>${project.artifactId}</alias> 

        <build> 
          <from>java:8-jre</from> 
          <maintainer>sourabhh</maintainer> 
          <assembly> 
            <descriptor>docker-assembly.xml</descriptor> 
          </assembly> 
          <ports> 
            <port>8080</port> 
          </ports> 
          <cmd> 
            <shell>java -jar \ 
              /maven/${project.build.finalName}.jar server \ 
              /maven/docker-config.yml</shell> 
          </cmd> 
        </build> 
        <run> 
        <!-- To Do --> 
        </run> 
      </image> 
    </images> 
  </configuration> 
</plugin> 

在 Docker Maven 插件配置之前创建一个 Dockerfile,该 Dockerfile 扩展了 JRE 8(java:8-jre)的基础镜像。这个镜像暴露了端口80808081

接下来,我们将配置docker-assembly.xml文件,该文件告诉插件哪些文件应该被放入容器中。这个文件将被放置在src/main/docker目录下:

<assembly   
  xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2 http://maven.apache.org/xsd/assembly-1.1.2.xsd"> 
  <id>${project.artifactId}</id> 
  <files> 
    <file> 
      <source>{basedir}/target/${project.build.finalName}.jar</source> 
      <outputDirectory>/</outputDirectory> 
    </file> 
    <file> 
      <source>src/main/resources/docker-config.yml</source> 
      <outputDirectory>/</outputDirectory> 
    </file> 
  </files> 
</assembly> 

前面的组装,在生成的 Dockerfile 中添加了service JAR 和docker-config.yml文件。这个 Dockerfile 位于target/docker/目录下。打开这个文件,你会发现内容与这个类似:

FROM java:8-jre 
MAINTAINER sourabhh 
EXPOSE 8080 
COPY maven /maven/ 
CMD java -jar \ 
  /maven/restaurant-service.jar server \ 
  /maven/docker-config.yml 

之前的文件可以在 restaurant-service\target\docker\sousharm\restaurant-service\PACKT-SNAPSHOT\build 目录中找到。build 目录还包含 maven 目录,其中包含 docker-assembly.xml 文件中提到的所有内容。

让我们来构建 Docker 镜像:

mvn docker:build

一旦此命令完成,我们可以使用 Docker 镜像在本地仓库中验证镜像,或者通过运行以下命令来实现:

docker run -it -p 8080:8080 sourabhh/restaurant-service:PACKT-SNAPSHOT

使用 -it 来在前台执行此命令,而不是 -d

使用 Maven 运行 Docker

要用 Maven 执行 Docker 镜像,我们需要在 pom.xml 文件中添加以下配置。<run> 块,放在 pom.xml 文件中 docker-maven-plugin 部分下的 docker-maven-plugin 块中标记的 To Do 下面:

<properties> 
  <docker.host.address>localhost</docker.host.address> 
  <docker.port>8080</docker.port> 
</properties> 
... 
<run> 
  <namingStrategy>alias</namingStrategy> 
  <ports> 
    <port>${docker.port}:8080</port> 
  </ports> 
  <wait> 
    <url>http://${docker.host.address}:${docker.port}/v1/restaurants/1</url> 
    <time>100000</time> 
  </wait> 
  <log> 
    <prefix>${project.artifactId}</prefix> 
    <color>cyan</color> 
  </log> 
</run> 

这里,我们已经定义了运行我们的 Restaurant 服务容器的参数。我们将 Docker 容器端口 80808081 映射到宿主系统的端口,这使我们能够访问服务。同样,我们也将容器的 log 目录绑定到宿主系统的 <home>/logs 目录。

Docker Maven 插件可以通过轮询管理后端的 ping URL 来检测容器是否已完成启动。

请注意,如果您在 Windows 或 MacOS X 上使用 DockerToolbox 或 boot2docker,Docker 主机不是 localhost。您可以执行 docker-machine ip default 来检查 Docker 镜像 IP。在启动时也会显示。

Docker 容器准备启动。使用以下命令使用 Maven 启动它:

mvn docker:start

使用 Docker 进行集成测试

启动和停止 Docker 容器可以通过在 pom.xml 文件中的 docker-maven-plugin 生命周期阶段绑定以下执行来实现:

<execution> 
  <id>start</id> 
  <phase>pre-integration-test</phase> 
  <goals> 
    <goal>build</goal> 
    <goal>start</goal> 
  </goals> 
</execution> 
<execution> 
  <id>stop</id> 
  <phase>post-integration-test</phase> 
  <goals> 
    <goal>stop</goal> 
  </goals> 
</execution> 

现在我们将配置 Failsafe 插件,使用 Docker 执行集成测试。这允许我们执行集成测试。我们在 service.url 标签中传递了服务 URL,这样我们的集成测试就可以使用它来执行集成测试。

我们将使用 DockerIntegrationTest 标记来标记我们的 Docker 集成测试。它定义如下:

package com.packtpub.mmj.restaurant.resources.docker; 

public interface DockerIT { 
    // Marker for Docker integration Tests 
} 

看看下面的集成 plugin 代码。你可以看到 DockerIT 被配置为包含集成测试(Failsafe 插件),而它被用于在单元测试中排除(Surefire 插件):

<plugin> 
                <groupId>org.apache.maven.plugins</groupId> 
                <artifactId>maven-failsafe-plugin</artifactId> 
                <configuration> 
                    <phase>integration-test</phase> 
                    <groups>com.packtpub.mmj.restaurant.resources.docker.DockerIT</groups> 
                    <systemPropertyVariables> 
                        <service.url>http://${docker.host.address}:${docker.port}/</service.url> 
                    </systemPropertyVariables> 
                </configuration> 
                <executions> 
                    <execution> 
                        <goals> 
                            <goal>integration-test</goal> 
                            <goal>verify</goal> 
                        </goals> 
                    </execution> 
                </executions> 
       </plugin> 
       <plugin> 
                <groupId>org.apache.maven.plugins</groupId> 
                <artifactId>maven-surefire-plugin</artifactId> 
                <configuration>             <excludedGroups>com.packtpub.mmj.restaurant.resources.docker.DockerIT</excludedGroups> 
                </configuration> 
</plugin> 

一个简单的集成测试看起来像这样:

@Category(DockerIT.class) 
public class RestaurantAppDockerIT { 

    @Test 
    public void testConnection() throws IOException { 
        String baseUrl = System.getProperty("service.url"); 
        URL serviceUrl = new URL(baseUrl + "v1/restaurants/1"); 
        HttpURLConnection connection = (HttpURLConnection) serviceUrl.openConnection(); 
        int responseCode = connection.getResponseCode(); 
        assertEquals(200, responseCode); 
    } 
} 

您可以使用以下命令执行使用 Maven 的集成测试(请确保在运行集成测试之前从项目目录的根目录运行 mvn clean install):

mvn integration-test

将镜像推送到注册表

docker-maven-plugin 下添加以下标签以将 Docker 镜像发布到 Docker hub:

<execution> 
  <id>push-to-docker-registry</id> 
  <phase>deploy</phase> 
  <goals> 
    <goal>push</goal> 
  </goals> 
</execution> 

您可以通过使用以下配置跳过 JAR 发布,为 maven-deploy-plugin

<plugin> 
  <groupId>org.apache.maven.plugins</groupId> 
  <artifactId>maven-deploy-plugin</artifactId> 
  <version>2.7</version> 
  <configuration> 
    <skip>true</skip> 
  </configuration> 
</plugin> 

在 Docker hub 发布 Docker 镜像也需要用户名和密码:

mvn -Ddocker.username=<username> -Ddocker.password=<password> deploy

您还可以将 Docker 镜像推送到您自己的 Docker 注册表。为此,请添加

如下代码所示,添加docker.registry.name标签。例如,

如果你的 Docker 注册表可在xyz.domain.com端口4994上访问,那么定义

通过添加以下代码行:

<docker.registry.name>xyz.domain.com:4994</docker.registry.name> 

这不仅完成了部署,还可以测试我们的 Docker 化服务。

管理 Docker 容器

每个微服务都将有自己的 Docker 容器。因此,我们将使用Docker Compose来管理我们的容器。

Docker Compose 将帮助我们指定容器的数量以及这些容器的执行方式。我们可以指定 Docker 镜像、端口以及每个容器与其他 Docker 容器的链接。

我们将在根项目目录中创建一个名为docker-compose.yml的文件,并将所有微服务容器添加到其中。我们首先指定 Eureka 服务器,如下所示:

eureka: 
  image: localhost:5000/sourabhh/eureka-server 
  ports: 
    - "8761:8761" 

在这里,image代表 Eureka 服务器的发布 Docker 镜像,ports代表执行 Docker 镜像的主机和 Docker 主机的映射。

这将启动 Eureka 服务器,并为外部访问发布指定的端口。

现在我们的服务可以使用这些容器(如 Eureka 的依赖容器)。让我们看看restaurant-service如何可以链接到依赖容器。很简单;只需使用links指令:

restaurant-service: 
  image: localhost:5000/sourabhh/restaurant-service 
  ports: 
    - "8080:8080" 
  links: 
    - eureka 

上述链接声明将更新restaurant-service容器中的/etc/hosts文件,每个服务占一行,restaurant-service依赖的服务(假设security容器也链接了),例如:

192.168.0.22  security 
192.168.0.31  eureka 

如果你没有设置本地 Docker 注册表,那么为了无问题或更平滑的执行,请先设置。

通过运行以下命令构建本地 Docker 注册表:

docker run -d -p 5000:5000 --restart=always --name registry registry:2

然后,为本地镜像执行推送和拉取命令:

docker push localhost:5000/sourabhh/restaurant-service:PACKT-SNAPSHOT

docker-compose pull

最后,执行 docker-compose:

docker-compose up -d

一旦所有微服务容器(服务和服务器)都配置好了,我们可以用一个命令启动所有 Docker 容器:

docker-compose up -d

这将启动 Docker Compose 中配置的所有 Docker 容器。以下命令将列出它们:

docker-compose ps
Name                                          Command
                State           Ports
-------------------------------------------------------------
onlinetablereservation5_eureka_1         /bin/sh -c java -jar         ...               Up      0.0.0.0:8761->8761/tcp

onlinetablereservation5_restaurant-service_1  /bin/sh -c java -jar       ...   Up      0.0.0.0:8080->8080/tcp

您还可以使用以下命令检查 Docker 镜像日志:

docker-compose logs
[36mrestaurant-service_1 | ←[0m2015-12-23 08:20:46.819  INFO 7 --- [pool-3-thread-1] com.netflix.discovery.DiscoveryClient    : DiscoveryClient_RESTAURANT-SERVICE/172.17
0.4:restaurant-service:93d93a7bd1768dcb3d86c858e520d3ce - Re-registering apps/RESTAURANT-SERVICE
[36mrestaurant-service_1 | ←[0m2015-12-23 08:20:46.820  INFO 7 --- [pool-3-thread-1] com.netflix.discovery.DiscoveryClient    : DiscoveryClient_RESTAURANT-SERVICE/172.17
0.4:restaurant-service:93d93a7bd1768dcb3d86c858e520d3ce: registering service... [36mrestaurant-service_1 | ←[0m2015-12-23 08:20:46.917  INFO 7 --- [pool-3-thread-1] com.netflix.discovery.DiscoveryClient    : DiscoveryClient_RESTAURANT-SERVICE/172.17

参考文献

以下链接将为您提供更多信息:

摘要

在本章中,我们学习了关于微服务管理的一系列特性:负载均衡、边缘(网关)服务器、断路器以及监控。在本章学习结束后,你应该知道如何实现负载均衡和路由。我们也学习了如何设置和配置边缘服务器。本章还介绍了另一个重要的安全机制。通过使用 Docker 或其他容器,可以使部署变得简单。本章通过 Maven 构建演示并集成了 Docker。

从测试的角度来看,我们对服务的 Docker 镜像进行了集成测试。我们还探讨了编写客户端的方法,例如RestTemplate和 Netflix Feign。

在下一章中,我们将学习如何通过身份验证和授权来保护微服务。我们还将探讨微服务安全的其他方面。

第六章:响应式微服务

在本章中,我们将使用 Spring Boot、Spring Stream、Apache Kafka 和 Apache Avro 来实现响应式微服务。我们将利用现有的 Booking 微服务来实现消息生产者,或者说,生成事件。我们还将创建一个新的微服务(Billing),用于消费由更新的 Booking 微服务产生的消息,或者说,用于消费由 Booking 微服务生成的事件。我们还将讨论 REST-based 微服务和事件-based 微服务之间的权衡。

在本章中,我们将涵盖以下主题:

  • 响应式微服务架构概述

  • 生成事件

  • 消费事件

响应式微服务架构概述

到目前为止,我们所开发的微服务是基于 REST 的。我们使用 REST 进行内部(微服务之间的通信,其中一个微服务与同一系统中的另一个微服务进行通信)和外部(通过公共 API)通信。目前,REST 最适合公共 API。对于微服务之间的通信,还有其他选择吗?实现 REST 用于微服务之间通信的最佳方法是什么?我们将在本节中讨论所有这些问题。

你可以构建完全是异步的微服务。你可以构建基于微服务的系统,这种系统将基于事件进行通信。REST 和基于事件 的微服务之间有一个权衡。REST 提供同步通信,而响应式微服务则基于异步通信(异步消息传递)。

我们可以为微服务之间的通信使用异步通信。根据需求和功能,我们可以选择 REST 或异步消息传递。考虑一个用户下订单的示例案例,这对于实现响应式微服务来说是一个非常好的案例。在成功下单后,库存服务将重新计算可用商品;账户服务将维护交易;通信服务将向所有涉及的用户(如客户和供应商)发送消息(短信、电子邮件等)。在这种情况下,一个微服务可能会根据另一个微服务执行的操作(下单)执行不同的操作(库存、账户、消息传递等)。现在,想想如果所有的这些通信都是同步的。相反,通过异步消息传递实现的响应式通信,提供了硬件资源的高效利用、非阻塞、低延迟和高吞吐量操作。

我们可以将微服务实现主要分为两组——REST-based 微服务和事件-based/消息驱动的微服务。响应式微服务是基于事件的。

响应式宣言

  • 响应式微服务基于响应式宣言(www.reactivemanifesto.org/)。响应式宣言包括四个原则,我们现在将讨论这些原则。

- 响应性

  • 响应性是及时服务请求的特征。它由延迟来衡量。生产者应及时提供响应,消费者应及时接收响应。对于请求执行的操作链中的故障,不应导致响应延迟或失败。因此,这对于服务的可用性非常重要。

- 弹性

  • 一个有弹性的系统也是一个健壮的系统。弹性原则与响应性原则相一致。微服务在遇到故障时,仍应提供响应,如果微服务的某个实例宕机,请求应由同一微服务的另一个节点处理。一个有弹性的微服务系统能够处理各种故障。所有服务都应受到监控,以检测故障,并且所有故障都应得到处理。我们在上一章使用了服务发现 Eureka 进行监控和 Hystrix 实现断路器模式。

- 弹性

  • 一个反应式的系统如果通过利用硬件和其他资源来对负载做出反应,那么它是弹性的。如果需求增加,它可以实例化微服务或微服务的新实例,反之亦然。在特别的销售日,如黑色星期五、圣诞节、排灯节等,反应式的购物应用会实例化更多的微服务节点,以分担增加请求的负载。在正常日子,购物应用可能不需要比平均更多的资源,因此它可以减少节点的数量。因此,为了有效地使用硬件,反应式系统应该是弹性的。

- 消息驱动

  • 如果反应式系统没有事情可做,它就会闲置;如果它本无任务,它就不会无用地使用资源。一个事件或消息可以使反应式微服务变得活跃,并开始处理(反应)接收到的该事件/消息(请求)。理想情况下,通信应该是异步和非阻塞的。反应式系统通过消息进行通信——异步消息传递。在本章中,我们将使用 Apache Kafka 进行消息传递。

理想情况下,反应式编程语言是实现反应式微服务的最佳方式。反应式编程语言提供异步和非阻塞调用。Java 也可以利用 Java 流功能来开发反应式微服务。Kafka 将使用 Kafka 的 Java 库和插件进行消息传递。我们已经实现了服务发现和注册服务(Eureka Server-监控),利用 Eureka 实现弹性代理服务器(Zuul),以及利用 Eureka 和 Hystrix 实现断路器(弹性响应)。在下一节中,我们将实现基于消息的微服务。

实现反应式微服务

反应式微服务响应事件执行操作。我们将修改我们的代码以产生和消费我们示例实现的事件。虽然我们将创建一个单一事件,但微服务可以有多个生产者或消费者事件。此外,微服务可以同时具有生产者和消费者事件。我们将利用 Booking 微服务中现有的功能来创建新预订(POST /v1/booking)。这将作为我们的事件源,并使用 Apache Kafka 发送此事件。其他微服务可以通过监听此事件来消费该事件。在成功预订调用后,Booking 微服务将产生 Kafka 主题(事件)amp.bookingOrdered。我们将创建一个与创建其他微服务(如 Booking)相同方式的新微服务 Billing,用于消费此事件(amp.bookingOrdered)。

产生事件

一旦产生事件,对象就会被发送到 Kafka。同样,Kafka 会将这个产生的对象发送给所有监听器(微服务)。简而言之,产生的对象通过网络传输。因此,我们需要为这些对象提供序列化支持。我们将使用 Apache Avro 进行数据序列化。它定义了以 JSON 格式表示的数据结构(架构),并为 Maven 和 Gradle 提供了一个插件,使用 JSON 架构生成 Java 类。Avro 与 Kafka 配合很好,因为 Avro 和 Kafka 都是 Apache 产品,彼此之间集成非常紧密。

让我们先定义一个代表创建新预订时通过网络发送的对象的架构。正如之前提到的用于产生事件的 Booking 微服务,我们将在 Booking 微服务的src/main/resources/avro目录中创建 Avro 架构文件bookingOrder.avro

bookingOrder.avro文件看起来像这样:

{"namespace": "com.packtpub.mmj.booking.domain.valueobject.avro", 
 "type": "record", 
 "name": "BookingOrder", 
 "fields": [ 
     {"name": "id", "type": "string"}, 
     {"name": "name", "type": "string", "default": ""}, 
     {"name": "userId", "type": "string", "default": ""}, 
     {"name": "restaurantId", "type": "string", "default": ""}, 
     {"name": "tableId", "type": "string", "default": ""}, 
     {"name": "date", "type": ["null", "string"], "default": null}, 
     {"name": "time", "type": ["null", "string"], "default": null} 
 ] 
}  

在这里,namespace代表包typerecord代表类,name代表类名,而fields代表类的属性。当我们使用此架构生成 Java 类时,它将在com.packtpub.mmj.booking.domain.valueobject.avro包中创建新的 Java 类BookingOrder.javafields中定义的所有属性都将包含在这个类中。

fields中,也有nametype,它们表示属性的名称和类型。对于所有字段,我们都使用了输入type作为string。您还可以使用其他基本类型,如booleanintdouble。此外,您可以使用复杂类型,如record(在上面的代码片段中使用)、enumarraymapdefault类型表示属性的默认值。

前面的模式将用于生成 Java 代码。我们将使用avro-maven-plugin从前面的 Avro 模式生成 Java 源文件。我们将在此插件的子pom文件(服务的pom.xml)的插件部分添加此插件:

<plugin> 
    <groupId>org.apache.avro</groupId> 
    <artifactId>avro-maven-plugin</artifactId> 
    <version>1.8.2</version> 
    <executions> 
        <execution> 
            <phase>generate-sources</phase> 
            <goals> 
                <goal>schema</goal> 
            </goals> 
            <configuration> 
               <sourceDirectory>${project.basedir}/src/main/resources/avro/</sourceDirectory> 
               <outputDirectory>${project.basedir}/src/main/java/</outputDirectory> 
            </configuration> 
        </execution> 
    </executions> 
</plugin> 

您可以看到,在configuration部分,已经配置了sourceDirectoryoutputDirectory。因此,当我们运行mvn package时,它将在配置的outputDirectory内部的com.packtpub.mmj.booking.domain.valueobject.avro包中创建BookingOrder.java文件。

现在既然我们的 Avro 模式和生成的 Java 源代码已经可用,我们将添加生成事件所需的 Maven 依赖项。

在 Booking 微服务pom.xml文件中添加依赖项:

... 
<dependency> 
    <groupId>org.apache.avro</groupId> 
    <artifactId>avro</artifactId> 
    <version>1.8.2</version> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-stream</artifactId> 
    <version>2.0.0.M1</version> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-stream-kafka</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.apache.kafka</groupId> 
    <artifactId>kafka-clients</artifactId> 
    <version>0.11.0.1</version> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-stream-schema</artifactId> 
</dependency> 
... 

在这里,我们添加了三个主要依赖项:avrospring-cloud-streamkafka-clients。此外,我们还添加了与 Kafka 的流集成(spring-cloud-starter-stream-kafka)和流支持模式(spring-cloud-stream-schema)。

现在,既然我们的依赖项已经就位,我们可以开始编写生产者实现。Booking 微服务将发送amp.bookingOrdered事件到 Kafka 流。我们将声明为此目的的消息通道。可以通过使用Source.OUTPUT@InboundChannelAdapter注解,或者通过声明 Java 接口来完成。我们将使用接口方法,因为这更容易理解且有关联。

我们将在com.packtpub.mmj.booking.domain.service.message包中创建BookingMessageChannels.java消息通道。在这里,我们可以添加所有必需的消息通道。由于我们使用单事件样本实现,我们只需声明bookingOrderOutput

BookingMessageChannels.java文件将看起来像这样:

package com.packtpub.mmj.booking.domain.message; 

import org.springframework.cloud.stream.annotation.Output; 
import org.springframework.messaging.MessageChannel; 

public interface BookingMessageChannels { 

    public final static String BOOKING_ORDER_OUTPUT = "bookingOrderOutput"; 

    @Output(BOOKING_ORDER_OUTPUT) 
    MessageChannel bookingOrderOutput(); 
} 

在这里,我们只是使用@Output注解定义了消息通道的名称,bookingOrderOutput。我们还需要在application.yaml中配置此消息通道。我们将在application.yaml文件中使用此名称定义 Kafka 主题:

spring: 
  cloud: 
    stream: 
        bindings: 
            bookingOrderOutput: 
                destination: amp.bookingOrdered 

在这里,给出了 Kafka 主题名称amp.bookingOrdered,它与bookingOrderOutput消息通道绑定。(Kafka 主题名称可以是任何字符串。我们添加amp前缀以表示异步消息传递;您可以使用带或不带前缀的 Kafka 主题名称。)

我们还需要一个消息转换器,用于将BookingOrder对象发送到 Kafka。为此,我们将在 Booking 服务的主类中创建一个@Bean注解,以返回 Spring 的MessageConverter

BookingApp.class文件中的@Bean注解看起来像这样:

... 
@Bean 
public MessageConverter bookingOrderMessageConverter() throws IOException { 
    LOG.info("avro message converter bean initialized."); 
    AvroSchemaMessageConverter avroSchemaMessageConverter = new AvroSchemaMessageConverter(MimeType.valueOf("application/bookingOrder.v1+avro")); 
    avroSchemaMessageConverter.setSchemaLocation(new ClassPathResource("avro/bookingOrder.avsc")); 
    return avroSchemaMessageConverter; 
} 
... 

您可以根据所需的模式添加更多的豆子。我们还没有在application.yaml中配置 Kafka 服务器,默认为localhost。让我们来做这件事。

application.yaml文件中配置 Kafka 服务器:

spring: 
  cloud: 
    stream: 
        kafka: 
            binder: 
                zkNodes: localhost 
            binder: 
                brokers: localhost 

在这里,我们为zkNodesbrokers都配置了localhost;您可以将其更改为托管 Kafka 的主机。

我们已经准备好将amp.bookingOrdered Kafka 主题发送到 Kafka 服务器。为了简单起见,我们将在BookingServiceImpl.java类中直接添加一个produceBookingOrderEvent方法,该方法接受Booking类作为参数(您需要在BookingService.java中添加相同的签名方法)。让我们先看看代码。

BookingServiceImpl.java文件如下:

... 
@EnableBinding(BookingMessageChannels.class) 
public class BookingServiceImpl extends BaseService<Booking, String> 
        implements BookingService { 
... 
... 
private BookingMessageChannels bookingMessageChannels; 

@Autowired 
public void setBookingMessageChannels(BookingMessageChannels bookingMessageChannels) { 
    this.bookingMessageChannels = bookingMessageChannels; 
} 

@Override 
public void add(Booking booking) throws Exception { 
    ... 
    ... 
    super.add(booking); 
    produceBookingOrderEvent(booking); 
} 
... 
...     
@Override 
public void produceBookingOrderEvent(Booking booking) throws Exception { 
    final BookingOrder.Builder boBuilder = BookingOrder.newBuilder(); 
    boBuilder.setId(booking.getId()); 
    boBuilder.setName(booking.getName()); 
    boBuilder.setRestaurantId(booking.getRestaurantId()); 
    boBuilder.setTableId(booking.getTableId()); 
    boBuilder.setUserId(booking.getUserId()); 
    boBuilder.setDate(booking.getDate().toString()); 
    boBuilder.setTime(booking.getTime().toString()); 
    BookingOrder bo = boBuilder.build(); 
    final Message<BookingOrder> message = MessageBuilder.withPayload(bo).build(); 
    bookingMessageChannels.bookingOrderOutput().send(message); 
    LOG.info("sending bookingOrder: {}", booking); 
} 
... 

在这里,我们声明了bookingMessageChannel对象,该对象通过setter方法进行自动注入。Spring Cloud Stream 注解@EnableBindingbookingOrderOutput消息通道绑定在BookingMessageChannels类中声明的bookingOrderOutput消息通道。

添加了produceBookingOrderEvent方法,该方法接受booking对象。在produceBookingOrderEvent方法内部,使用booking对象设置BookingOrder对象属性。然后使用bookingOrder对象构建消息。最后,通过bookingMessageChannels将消息发送到 Kafka。

produceBookingOrderEvent方法在预约成功保存在数据库后调用。

为了测试这个功能,您可以使用以下命令运行 Booking 微服务:

java -jar booking-service/target/booking-service.jar

确保 Kafka 和 Zookeeper 应用程序在application.yaml文件中定义的主机和端口上正确运行,以进行成功的测试。

然后,通过任何 REST 客户端向http://<host>:<port>/v1/booking发送一个预约的 POST 请求,并带有以下载荷:

{ 
                "id": "999999999999",  
                "name": "Test Booking 888",  
                "userId": "3",  
                "restaurantId": "1",  
                "tableId": "1",  
                "date": "2017-10-02",  
                "time": "20:20:20.963543300" 
} 

它将产生amp.bookingOrdered Kafka 主题(事件),如下所示,在 Booking 微服务控制台上发布日志:

2017-10-02 20:22:17.538  INFO 4940 --- [nio-7052-exec-1] c.p.m.b.d.service.BookingServiceImpl     : sending bookingOrder: {id: 999999999999, name: Test Booking 888, userId: 3, restaurantId: 1, tableId: 1, date: 2017-10-02, time: 20:20:20.963543300} 

同样,Kafka 控制台将显示以下消息,确认消息已成功由 Kafka 接收:

[2017-10-02 20:22:17,646] INFO Updated PartitionLeaderEpoch. New: {epoch:0, offset:0}, Current: {epoch:-1, offset-1} for Partition: amp.bookingOrdered-0\. Cache now contains 0 entries. (kafka.server.epoch.LeaderEpochFileCache) 

现在,我们可以移动到编写之前生成的事件的消费者代码。

消费事件

首先,我们将在父级pom.xml文件中添加新模块billing-service,并以与其他微服务相同的方式创建 Billing 微服务第五章,部署和测试。我们为 Booking 微服务编写的几乎所有反应式代码都将被 Billing 微服务重用,例如 Avro 模式和pom.xml条目。

我们将在账单微服务中以与预订微服务相同的方式添加 Avro 模式。由于账单微服务的模式命名空间(包名)将是相同的booking包,我们需要在@SpringBootApplication注解的scanBasePackages属性中添加值com.packtpub.mmj.booking。这将允许 spring 上下文也扫描预订包。

我们将在账单微服务的pom.xml中添加以下依赖项,这与我们在预订微服务中添加的依赖项相同。

账单微服务的pom.xml文件如下:

... 
... 
<dependency> 
    <groupId>org.apache.avro</groupId> 
    <artifactId>avro</artifactId> 
    <version>1.8.2</version> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-stream</artifactId> 
    <version>2.0.0.M1</version> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-stream-kafka</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.apache.kafka</groupId> 
    <artifactId>kafka-clients</artifactId> 
    <version>0.11.0.1</version> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-stream-schema</artifactId> 
</dependency> 
... 
... 

您可以参考预订服务依赖段落,了解添加这些依赖的原因。

接下来,我们将向账单微服务中添加消息通道,如下所示:

package com.packtpub.mmj.billing.domain.message; 

import org.springframework.cloud.stream.annotation.Input; 
import org.springframework.messaging.MessageChannel; 

public interface BillingMessageChannels { 

    public final static String BOOKING_ORDER_INPUT = "bookingOrderInput"; 

    @Input(BOOKING_ORDER_INPUT) 
    MessageChannel bookingOrderInput(); 
} 

这里,我们正在为预订服务中的输出消息通道添加一个输入消息通道的对端。请注意bookingOrderInput是一个带有@input注解的输入消息通道。

接下来,我们想要将bookingOrderInput通道配置为 Kafka 主题amp.BookingOrdered。为此,我们将修改application.yaml

 spring: 
  ... 
  ... 
  cloud: 
    stream: 
        bindings: 
            bookingOrderInput: 
                destination: amp.bookingOrdered 
                consumer: 
                    resetOffsets: true 
                group: 
                    ${bookingConsumerGroup} 
bookingConsumerGroup: "booking-service" 

这里,通过目标属性将 Kafka 主题添加到bookingOrderInput通道。我们还将按照在预订微服务中配置的方式在账单微服务(application.yaml)中配置 Kafka:

        kafka: 
            binder:                
                zkNodes: localhost 
            binder: 
                brokers: localhost 

现在,我们将添加一个事件监听器,该监听器将监听与bookingOrderInput消息通道绑定的流,使用 Spring Cloud Steam 库中可用的@StreamListener注解。

EventListener.java文件如下:

package com.packtpub.mmj.billing.domain.message; 

import com.packtpub.mmj.billing.domain.service.TweetMapper; 
import com.packtpub.mmj.billing.domain.service.TweetReceiver; 
import com.packtpub.mmj.billing.domain.service.WebSocketTweetReceiver; 
import com.packtpub.mmj.billing.domain.valueobject.TweetInput; 
import com.packtpub.mmj.booking.domain.valueobject.avro.BookingOrder; 
import com.packtpub.mmj.booking.domain.valueobject.avro.TweetDto; 
import org.slf4j.Logger; 
import org.slf4j.LoggerFactory; 
import org.springframework.beans.factory.annotation.Autowired; 
import org.springframework.cloud.stream.annotation.StreamListener; 

public class EventListener { 

    private static final Logger LOG = LoggerFactory.getLogger(WebSocketTweetReceiver.class); 

    @StreamListener(BillingMessageChannels.BOOKING_ORDER_INPUT) 
    public void consumeBookingOrder(BookingOrder bookingOrder) { 
        LOG.info("Received BookingOrder: {}", bookingOrder); 
    } 
} 

这里,您还可以添加其他事件监听器。例如,我们只需记录接收到的对象。根据需求,您可以添加一个额外的功能;如果需要,您甚至可以再次产生一个新事件以进行进一步处理。例如,您可以将事件产生到一家餐厅,该餐厅有一个新的预订请求,等等,通过一个管理餐厅通信的服务。

最后,我们可以使用 Spring Cloud Stream 库的@EnableBinding注解启用bookingOrderInput消息通道与流的绑定,并在BillingApp.javabilling-service模块的主类)中创建EventListener类的 bean,如下所示:

BillingApp.java可能看起来像这样:

@SpringBootApplication(scanBasePackages = {"com.packtpub.mmj.billing", "com.packtpub.mmj.booking"}) 
@EnableBinding({BillingMessageChannels.class}) 
public class BillingApp { 

    public static void main(String[] args) { 
        SpringApplication.run(BillingApp.class, args); 
    } 

    @Bean 
    public EventListener eventListener() { 
        return new EventListener(); 
    } 
} 

现在,您可以启动账单微服务并发起一个新的POST/v1/booking REST 调用。您可以在账单微服务的日志中找到接收到的对象,如下所示:

2017-10-02 20:22:17.728  INFO 6748 --- [           -C-1] c.p.m.b.d.s.WebSocketTweetReceiver       : Received BookingOrder: {"id": "999999999999", "name": "Test Booking 888", "userId": "3", "restaurantId": "1", "tableId": "1", "date": "2017-10-02", "time": "20:20:20.963543300"} 

参考文献

以下链接将为您提供更多信息:

总结

在本章中,你学习了关于响应式微服务或基于事件的微服务。这些服务基于消息/事件工作,而不是基于 HTTP 的 REST 调用。它们提供了服务之间的异步通信,这种通信是非阻塞的,并且允许更好地利用资源和处理失败。

我们使用了 Apache Avro 和 Apache Kafka 与 Spring Cloud Stream 库来实现响应式微服务。我们在现有的booking-service模块中添加了代码,用于在 Kafka 主题下生产amp.bookingOrdered消息,并添加了新的模块billing-service来消费同一个事件。

你可能想要为生产者和消费者添加一个新事件。你可以为一个事件添加多个消费者,或者创建一个事件链作为练习。

在下一章中,你将学习如何根据认证和授权来保护微服务。我们还将探讨微服务安全的其他方面。

第七章:保护微服务

正如您所知,微服务是我们部署在本地或云基础设施上的组件。微服务可能提供 API 或网络应用程序。我们的示例应用程序 OTRS 提供 API。本章将重点介绍如何使用 Spring Security 和 Spring OAuth2 保护这些 API。我们还将重点介绍 OAuth 2.0 基本原理,使用 OAuth 2.0 保护 OTRS API。要了解更多关于保护 REST API 的信息,您可以参考RESTful Java Web Services Security, Packt Publishing 书籍。您还可以参考Spring Security, Packt Publishing视频以获取有关 Spring Security 的更多信息。我们还将学习跨源请求站点过滤器和跨站脚本阻止器。

在本章中,我们将涵盖以下主题:

  • 启用安全套接层(SSL)

  • 身份验证和授权

  • OAuth 2.0

启用安全套接层

到目前为止,我们一直使用超文本传输协议HTTP)。HTTP 以明文形式传输数据,但在互联网上以明文形式传输数据是一个非常糟糕的主意。这使得黑客的工作变得容易,允许他们使用数据包嗅探器轻松获取您的私人信息,例如您的用户 ID、密码和信用卡详细信息。

我们绝对不希望妥协用户数据,因此我们将提供访问我们网络应用的最安全方式。因此,我们需要加密终端用户与应用之间交换的信息。我们将使用安全套接层SSL)或传输安全层TSL)来加密数据。

安全套接层(SSL)是一种旨在为网络通信提供安全(加密)的协议。HTTP 与 SSL 关联,以提供安全实现 HTTP,称为安全超文本传输协议,或通过 SSL 的 HTTPHTTPS)。HTTPS 确保交换数据的隐私和完整性得到保护。它还确保访问的网站的真实性。这种安全性围绕在托管应用程序的服务器、终端用户的机器和第三方信任存储服务器之间分发签名的数字证书。让我们看看这个过程是如何进行的:

  1. 终端用户使用网络浏览器向网络应用发送请求,例如twitter.com

  2. 在接收到请求后,服务器使用 HTTP 代码 302 将浏览器重定向到twitter.com

  3. 终端用户的浏览器连接到twitter.com,作为回应,服务器向终端用户的浏览器提供包含数字签名的证书

  4. 终端用户的浏览器接收到这个证书,并将其与可信的证书授权机构CA)列表进行比对以进行验证

  5. 一旦证书验证到根 CA,终端用户的浏览器与应用托管服务器之间就建立了加密通信:

安全的 HTTP 通信

尽管 SSL 在加密和 Web 应用真实性方面确保了安全,但它并不能防止钓鱼和其他攻击。专业的黑客可以解密通过 HTTPS 发送的信息。

现在,在了解了 SSL 的基本知识之后,让我们为我们的示例 OTRS 项目实现它。我们不需要为所有微服务实现 SSL。所有微服务都将通过我们的代理或 Edge 服务器访问;Zuul-Server 由外部环境访问,除了我们将在本章中介绍的新微服务 security-service,用于认证和授权。

首先,我们将在一个 Edge 服务器上设置 SSL。我们需要一个用于在嵌入式 Tomcat 中启用 SSL 的 keystore。我们将使用自签名证书进行演示。我们将使用 Java keytool 生成 keystore,使用以下命令。您也可以使用其他任何工具:

keytool -genkey -keyalg RSA -alias selfsigned -keystore keystore.jks -ext san=dns:localhost -storepass password -validity 365 -keysize 2048 

它要求提供诸如姓名、地址详情、组织等信息(见下面的屏幕截图):

keytool 生成密钥

为确保自签名证书的正常工作,请注意以下几点:

  • 使用-ext定义主题备用名称SANs)。您还可以使用 IP(例如,san=ip:190.19.0.11)。以前,通常使用应用程序部署机器的主机名作为最常见的名称(CN)。它防止了java.security.cert.CertificateException返回No name matching localhost found

  • 您可以使用浏览器或 OpenSSL 下载证书。使用keytool -importcert命令,将新生成的证书添加到位于活动JDK/JRE主目录内的jre/lib/security/cacertscacerts keystore 中。注意changeitcacerts keystore 的默认密码。运行以下命令:

keytool -importcert -file path/to/.crt -alias <cert alias> -  keystore <JRE/JAVA_HOME>/jre/lib/security/cacerts -storepass changeit 

自签名证书只能用于开发和测试目的。在生产环境中使用这些证书并不能提供所需的安全性。在生产环境中总是使用由可信签名机构提供和签名的证书。妥善保管您的私钥。

现在,在将生成的keystore.jks放入 OTRS 项目的src/main/resources目录中,与application.yml一起,我们可以像以下这样更新 Edge 服务器的application.yml信息:

server: 
    ssl: 
        key-store: classpath:keystore.jks 
        key-store-password: password 
        key-password: password 
    port: 8765 

重建 Zuul-Server JAR 以使用 HTTPS。

在 Tomcat 7.0.66+和 8.0.28+版本中,可以将 keystore 文件存储在之前的类路径中。对于旧版本,您可以使用 keystore 文件的路径作为server:ssl:key-store的值。

同样,您可以为其他微服务配置 SSL。

认证和授权

提供认证和授权是网络应用程序的默认行为。我们将在本节讨论认证和授权。过去几年发展起来的新范例是 OAuth。我们将学习和使用 OAuth 2.0 进行实现。OAuth 是一个开放授权机制,在每一个主要网络应用程序中都有实现。通过实现 OAuth 标准,网络应用程序可以访问彼此的数据。它已经成为各种网络应用程序认证自己的最流行方式。例如,在www.quora.com/上,你可以使用你的 Google 或 Twitter 登录 ID 进行注册和登录。这也更用户友好,因为客户端应用程序(例如www.quora.com/)不需要存储用户的密码。最终用户不需要记住另一个用户 ID 和密码。

OAuth 2.0 示例使用

OAuth 2.0

互联网工程任务组IETF)管理 OAuth 的标准和规格。OAuth 1.0a 是在 OAuth 2.0 之前的最新版本,它解决了 OAuth 1.0 中的会话固定安全漏洞。OAuth 1.0 和 1.0a 与 OAuth 2.0 非常不同。OAuth 1.0 依赖于安全证书和通道绑定,而 OAuth 2.0 不支持安全证书和通道绑定。它完全基于传输层安全TLS)。因此,OAuth 2.0 不提供向后兼容性。

使用 OAuth

OAuth 的各种用途如下:

  • 正如讨论的那样,它可以用于身份验证。你可能在各种应用程序中看到过它,比如显示“使用 Facebook 登录”或“使用 Twitter 登录”的消息。

  • 应用程序可以利用它来读取其他应用程序的数据,例如通过在应用程序中集成 Facebook 小部件,或者在博客上拥有 Twitter 源。

  • 或者,与前面一点相反的情况也是正确的:你允许其他应用程序访问最终用户的数据。

OAuth 2.0 规格说明 - 简洁的细节

我们将尝试以简洁的方式讨论和理解 OAuth 2.0 规格说明。首先让我们看看使用 Twitter 登录是如何工作的。

请注意,这里提到的过程是在写作时使用的,未来可能会有所变化。然而,这个过程正确地描述了 OAuth 2.0 的其中一个过程:

  1. 用户访问 Quora 主页,上面显示各种登录选项。我们将探讨点击“继续使用 Twitter”链接的过程。

  2. 当用户点击“继续使用 Twitter”链接时,Quora 在一个新窗口(在 Chrome 中)中打开,该窗口将用户重定向到www.twitter.com应用程序。在这个过程中,一些网络应用程序将用户重定向到同一个已打开的标签/窗口。

  3. 在这个新窗口/标签中,用户使用他们的凭据登录www.twitter.com

  4. 如果用户尚未授权 Quora 应用使用他们的数据,Twitter 会请求用户授权 Quora 访问用户的信息。如果用户已经授权 Quora,则跳过此步骤。

  5. 经过适当的认证后,Twitter 会将用户重定向到 Quora 的重定向 URI,并附带一个认证码。

  6. 当在浏览器中输入 Quora 的重定向 URI 时,Quora 发送客户端 ID、客户端密钥令牌和认证码(由 Twitter 在第五步发送)。

  7. 在验证这些参数后,Twitter 将访问令牌发送给 Quora。

  8. 用户在成功获取访问令牌后登录到 Quora。

  9. Quora 可能使用此访问令牌从 Twitter 检索用户信息。

你可能想知道 Twitter 是如何获得 Quora 的重定向 URI、客户端 ID 和密钥令牌的。Quora 作为客户端应用程序,Twitter 作为授权服务器。Quora 作为客户端,在注册时使用 Twitter 的 OAuth 实现来使用资源所有者(最终用户)的信息。Quora 在注册时提供一个重定向 URI。Twitter 向 Quora 提供客户端 ID 和密钥令牌。在 OAuth 2.0 中,用户信息被称为用户资源。Twitter 提供一个资源服务器和一个授权服务器。我们将在接下来的章节中讨论更多关于这些 OAuth 术语的内容。

使用 Twitter 登录的 OAuth 2.0 示例过程

OAuth 2.0 角色

OAuth 2.0 规范中定义了四个角色:

  • 资源所有者

  • 资源服务器

  • 客户端

  • 授权服务器

OAuth 2.0 角色

资源所有者

以 Quora 使用 Twitter 登录为例,Twitter 用户是资源所有者。资源所有者是拥有要共享的受保护资源(例如,用户处理、推文等)的实体。这个实体可以是应用程序或个人。我们称这个实体为资源所有者,因为它只能授予对其资源的访问权限。规范还定义,当资源所有者是个人时,它们被称为最终用户。

资源服务器

资源服务器托管受保护的资源。它应该能够使用访问令牌服务于这些资源。以 Quora 使用 Twitter 登录为例,Twitter 是资源服务器。

客户端

以 Quora 使用 Twitter 登录为例,Quora 是客户端。客户端是代表资源所有者向资源服务器请求受保护资源的应用程序。

授权服务器

授权服务器在资源所有者身份验证后,才向客户端应用程序提供不同的令牌,例如访问令牌或刷新令牌。

OAuth 2.0 没有为资源服务器与授权服务器之间的交互提供任何规范。因此,授权服务器和资源服务器可以在同一服务器上,也可以在不同的服务器上。

一个授权服务器也可以用于为多个资源服务器颁发访问令牌。

OAuth 2.0 客户端注册

客户端与授权服务器通信以获取资源访问密钥时,应首先向授权服务器注册。OAuth 2.0 规范没有指定客户端如何向授权服务器注册的方式。注册不需要客户端与授权服务器之间直接通信。注册可以使用自发行或第三方发行的断言完成。授权服务器使用其中一个断言获取所需的客户端属性。让我们看看客户端属性是什么:

  • 客户端类型(在下一节中讨论)。

  • 客户端重定向 URI,正如我们在使用 Twitter 登录 Quora 的示例中讨论的那样。这是用于 OAuth 2.0 的端点之一。我们将在端点部分讨论其他端点。

  • 授权服务器可能需要的任何其他信息,例如客户端名称、描述、标志图像、联系详情、接受法律条款和条件等。

客户端类型

规范中描述了两种客户端类型,根据它们保持客户端凭据保密的能力:保密和公共。客户端凭据是由授权服务器颁发给客户端的秘密令牌,以便与它们通信。客户端类型如下所述:

  • 保密客户端类型: 这是一个保持密码和其他凭据安全或保密的客户端应用程序。在使用 Twitter 登录 Quora 的示例中,Quora 应用服务器是安全的,并且实现了受限的访问。因此,它属于保密客户端类型。只有 Quora 应用管理员才能访问客户端凭据。

  • 公共客户端类型: 这些客户端应用程序不保持密码和其他凭据的安全或保密。任何移动或桌面上的本地应用,或者在浏览器上运行的应用,都是公共客户端类型的完美示例,因为这些应用中嵌入了客户端凭据。黑客可以破解这些应用,从而暴露客户端凭据。

客户端可以是分布式组件基础应用程序,例如,它可能同时具有网络浏览器组件和服务器端组件。在这种情况下,两个组件将具有不同的客户端类型和安全上下文。如果授权服务器不支持此类客户端,则此类客户端应将每个组件注册为单独的客户端。

客户端配置文件

根据 OAuth 2.0 客户端类型,客户端可以有以下配置文件:

  • 网络应用: 在 Quora 使用 Twitter 登录的示例中使用的 Quora 网络应用是 OAuth 2.0 网络应用客户端配置文件的完美示例。Quora 是一个运行在网络服务器上的机密客户端。资源所有者(最终用户)通过他们设备上的 HTML 用户界面在浏览器(用户代理)上访问 Quora 应用(OAuth 2.0 客户端)。资源所有者无法访问客户端(Quora OAuth 2.0 客户端)凭据和访问令牌,因为这些是存储在网络服务器上的。您可以在 OAuth 2.0 示例流程图中看到此行为,具体在以下步骤六到八中:

OAuth 2.0 客户端网络应用配置文件

  • 基于用户代理的应用: 基于用户代理的应用是公共客户端类型。在这种情况下,应用位于网络服务器上,但资源所有者将其下载到用户代理(例如,网络浏览器)上,然后在该设备上执行。在这里,下载并驻留在资源所有者设备上的用户代理中的应用与授权服务器通信。资源所有者可以访问客户端凭据和访问令牌。游戏应用是此类应用配置的一个很好的例子。用户代理应用流程如下所示:

OAuth 2.0 客户端基于用户代理的应用配置文件

  • 原生应用: 原生应用与基于用户代理的应用类似,不同之处在于这些应用是安装在资源所有者的设备上并原生执行的,而不是从网络服务器下载并在用户代理中执行。您在手机上下载的许多原生应用都属于原生应用类型。在这里,平台确保设备上的其他应用不能访问其他应用的凭据和访问令牌。此外,原生应用不应与与原生应用通信的服务器共享客户端凭据和 OAuth 令牌,如下面的图所示:

OAuth 2.0 客户端原生应用配置文件

客户端标识符

授权服务器的责任是向注册客户端提供一个唯一标识符。此客户端标识符是注册客户端提供的信息的字符串表示。授权服务器需要确保此标识符是唯一的,并且授权服务器本身不应使用它进行身份验证。

OAuth 2.0 规范没有指定客户端标识符的大小。授权服务器可以设置客户端标识符的大小,并且应该文档化其发行的大小。

客户端认证

授权服务器应根据客户端类型验证客户端。授权服务器应确定适合并满足安全要求的认证方法。它应在每个请求中只使用一种认证方法。

通常,授权服务器使用一组客户端凭据,例如客户端密码和一些密钥令牌,来认证保密客户端。

授权服务器可能与公共客户端建立客户端认证方法。然而,出于安全原因,它不能依赖这种认证方法来识别客户端。

拥有客户端密码的客户端可以使用基本 HTTP 认证。OAuth 2.0 建议不要在请求体中发送客户端凭据,但建议在需要身份验证的端点上使用 TLS 和暴力攻击保护。

OAuth 2.0 协议端点

端点不过是我们在 REST 或网络组件中使用的 URI,例如 Servlet 或 JSP。OAuth 2.0 定义了三种端点类型。其中两个是授权服务器端点,一个是客户端端点:

  • 授权端点(授权服务器端点)

  • 令牌端点(授权服务器端点)

  • 重定向端点(客户端端点)

授权端点

这个端点负责验证资源所有者的身份,并在验证后获取授权许可。我们在下一节讨论授权许可。

授权服务器要求对授权端点使用 TLS。端点 URI 必须不包含片段组件。授权端点必须支持 HTTP GET方法。

规范没有指定以下内容:

  • 授权服务器认证客户端的方式。

  • 客户端如何接收授权端点的 URI。通常,文档包含授权端点的 URI,或者在注册时客户端获取它。

令牌端点

客户端调用令牌端点,通过发送授权许可或刷新令牌来接收访问令牌。除了隐式授权外,所有授权许可都使用令牌端点。

像授权端点一样,令牌端点也需要 TLS。客户端必须使用 HTTP POST方法对令牌端点提出请求。

像授权端点一样,规范没有指定客户端如何接收令牌端点的 URI。

重定向端点

授权服务器使用重定向端点将资源所有者的用户代理(例如,网络浏览器)回退到客户端,一旦资源所有者和授权服务器之间的授权端点的交互完成。客户端在注册时提供重定向端点。重定向端点必须是绝对 URI,并且不包含片段组件。OAuth 2.0 端点如下:

OAuth 2.0 端点

OAuth 2.0 授权类型

客户端基于从资源所有者获得的授权,请求授权服务器授予访问令牌。资源所有者以授权授予的形式给予授权。OAuth 2.0 定义了四种授权授予类型:

  • 授权码授予

  • 隐式授予

  • 资源所有者密码凭证授予

  • 客户端凭据授予

OAuth 2.0 还提供了一种扩展机制来定义其他授予类型。你可以在官方 OAuth 2.0 规范中探索这一点。

授权码授予

我们在 OAuth 2.0 登录 Twitter 的示例流程中讨论的第一个样本流程显示了一个授权码授予。我们会在完整的流程中添加一些更多步骤。正如你所知,在第 8 步之后,最终用户登录到 Quora 应用。假设用户第一次登录到 Quora 并请求他们的 Quora 资料页面:

  1. 登录后,Quora 用户点击他们的 Quora 资料页面。

  2. OAuth 客户端 Quora 请求 Twitter 资源服务器中 Quora 用户(资源所有者)的资源(例如,Twitter 资料照片等),并发送在上一步中收到的访问令牌。

  3. Twitter 资源服务器使用 Twitter 授权服务器来验证访问令牌。

  4. 在成功验证访问令牌后,Twitter 资源服务器向 Quora(OAuth 客户端)提供所请求的资源。

  5. Quora 使用这些资源并显示最终用户的 Quora 资料页面。

授权码请求和响应

如果你查看全部的 13 个步骤(如下图中所示)的授权码授予流程,你可以看到客户端总共向授权服务器发起了两请求,授权服务器提供两个响应:一个用于认证令牌的请求-响应和一个用于访问令牌的请求-响应。

让我们讨论一下这些请求和响应中使用的参数:

OAuth 2.0 授权码授予流程

授权请求(第四步)到授权端点 URI:

参数 必需/可选 描述
response_type 必需 代码(必须使用此值)。
client_id 必需 它代表授权服务器在注册时颁发的客户端 ID。
redirect_uri 可选 它代表客户端在注册时提供的重定向 URI。
scope 可选 请求的范围。如果没有提供,则授权服务器根据定义的策略提供范围。
state 推荐 客户端使用此参数在请求和回调(从授权服务器)之间保持客户端状态。规范推荐此参数以防止跨站请求伪造攻击。

授权响应(第五步):

Parameter 必填/可选 描述
code 必填 授权服务器生成的授权码。授权码应在生成后过期;最大推荐生存期为 10 分钟。客户端不得使用代码超过一次。如果客户端使用它超过一次,则必须拒绝请求,并撤销基于代码发行的所有先前令牌。代码与客户端 ID 和重定向 URI 绑定。
state 必填 代表授权服务器在注册时颁发给客户端的 ID。

令牌请求(第七步)至令牌端点 URI: |

Parameter 必填/可选 描述
--- --- ---
grant_type 必填 授权码(此值必须使用)。
code 必填 从授权服务器接收的授权码。
redirect_uri 必填 如果包含在授权码请求中,则必须匹配。
client_id 必填 代表授权服务器在注册时颁发给客户端的 ID。

令牌响应(第八步): |

Parameter 必填/可选 描述
access_token 必填 授权服务器颁发的访问令牌。
token_type 必填 授权服务器定义的令牌类型。根据此,客户端可以使用访问令牌。例如,Bearer 或 Mac。
refresh_token 可选 客户端可以使用此令牌使用相同的授权授予获取新的访问令牌。
expires_in 推荐 表示访问令牌的生存期,以秒为单位。600 的值表示访问令牌的 10 分钟生存期。如果此参数未包含在响应中,则文档应突出显示访问令牌的生存期。
scope 可选/必填 如果与客户端请求的 scope 相同,则为可选。如果访问令牌的 scope 与客户端在其请求中提供的 scope 不同,则为必填,以通知客户端实际授予的访问令牌的 scope。如果客户端在请求访问令牌时未提供 scope,则授权服务器应提供默认 scope,或拒绝请求,指示无效 scope。

错误响应: |

Parameter 必填/可选 描述
error 必填 指定中的错误代码之一,例如 unauthorized_clientinvalid_scope
error_description 可选 错误简短描述。
error_uri 可选 描述错误的页面 URI。

如果客户端授权请求中传递了状态,则在错误响应中也发送一个附加的错误参数状态。 |

隐式授权 |

隐式许可流中不涉及授权码步骤。它提供隐式授权码。如果你比较隐式许可流与授权码许可流,除了授权码步骤,一切都是一样的。因此,它被称为隐式许可。让我们找出它的流程:

  1. 客户端应用程序(例如,Quora)将访问令牌请求发送给资源服务器(例如,Facebook、Twitter 等),附带客户端 ID、重定向 URI 等。

  2. 如果用户尚未认证,可能需要进行认证。在成功认证和其他输入验证后,资源服务器发送访问令牌。

  3. OAuth 客户端请求用户(资源所有者)的资源(例如,Twitter 个人资料照片等)从资源服务器,并发送在上一步收到的访问令牌。

  4. 资源服务器使用授权服务器来验证访问令牌。

  5. 在成功验证访问令牌后,资源服务器将请求的资源提供给客户端应用程序(OAuth 客户端)。

  6. 客户端应用程序使用这些资源。

隐式许可请求和响应

如果你查看了隐式许可流的所有步骤(总共六个),你可以看到客户端向授权服务器发出了总共两个请求,授权服务器提供两个响应:一个用于访问令牌的请求-响应和一个用于访问令牌验证的请求-响应。

让我们讨论这些请求和响应中使用的参数。

向授权端点 URI 的授权请求:

**参数** **必需**/**可选** **描述**
response_type 必需 令牌(必须使用此值)。
client_id 必需 它代表授权服务器在注册时发给客户端的 ID。
redirect_uri 可选 它代表客户端在注册时提供的重定向 URI。
scope 可选 请求的范围。如果没有提供,则授权服务器根据定义的策略提供范围。
state 推荐 客户端使用此参数在请求和回调(从授权服务器)之间维护客户端状态。规范建议使用它以防止跨站请求伪造攻击。

访问令牌响应:

**参数** **必需**/**可选** **描述**
--- --- ---
access_token 必需 授权服务器发行的访问令牌。
token_type 必需 授权服务器定义的令牌类型。根据此类型,客户端可以利用访问令牌。例如,Bearer 或 Mac。
refresh_token 可选 客户端可以使用该令牌来使用相同的授权许可获取新的访问令牌。
expires_in 推荐 表示访问令牌的生存期,以秒为单位。600 的值表示访问令牌的 10 分钟生存期。如果这个参数在响应中没有提供,那么文档应该强调访问令牌的生存期。
scope 可选/必填 如果与客户端请求的 scope 相同,则为可选。如果授予的访问令牌 scope 与客户端在请求中提供的 scope 不同,则为必填,以通知客户端授予的访问令牌的实际 scope。如果客户端在请求访问令牌时没有提供 scope,则授权服务器应提供默认 scope,或拒绝请求,指示无效 scope。
state 可选/必填 如果客户端授权请求中传递了状态,则为必填。

错误响应:

参数 必填/可选 描述
error 必填 定义在规范中的错误代码之一,例如 unauthorized_clientinvalid_scope
error_description 可选 错误的精简描述。
error_uri 可选 描述错误的错误页面的 URI。

在错误响应中还发送了一个额外的状态参数,如果客户端授权请求中传递了状态。

资源所有者密码凭证授权

这种流程通常用于移动或桌面应用程序。在这个授权流程中,只发起两个请求:一个用于请求访问令牌,另一个用于访问令牌验证,类似于隐式授权流程。唯一的区别是访问令牌请求中附带了资源所有者的用户名和密码。(在隐式授权中,通常在浏览器中,将用户重定向到认证页面。)让我们来看看它的流程:

  1. 客户端应用程序(例如,Quora)将访问令牌请求发送到资源服务器(例如,Facebook、Twitter 等),其中包括客户端 ID、资源所有者的用户名和密码等。在成功验证参数后,资源服务器发送访问令牌。

  2. OAuth 客户端请求资源服务器上的用户(资源所有者)的资源(例如,Twitter 个人资料照片等),并发送在上一步收到的访问令牌。

  3. 资源服务器使用授权服务器验证访问令牌。

  4. 在成功验证访问令牌后,资源服务器向客户端应用程序(OAuth 客户端)提供所请求的资源。

  5. 客户端应用程序使用这些资源。

资源所有者的密码凭证用于授权请求和响应。

如前所述,在资源所有者密码凭据授予流程的所有步骤(共五个步骤)中,您可以看到客户端向授权服务器发出了两个请求,并且授权服务器提供了两个响应:一个用于访问令牌的请求-响应,一个用于资源所有者资源的请求-响应。

让我们讨论每个请求和响应中使用的参数。

访问令牌请求到令牌端点 URI:

参数 必需/可选 描述
grant_type 必需 密码(必须使用此值)。
username 必需 资源所有者的用户名。
password 必需 资源所有者的密码。
scope 可选 请求的范围。如果未提供,则授权服务器根据定义的策略提供范围。

访问令牌响应(第一步):

参数 必需/可选 描述
access_token 必需 授权服务器颁发的访问令牌。
token_type 必需 授权服务器定义的令牌类型。基于此,客户端可以利用访问令牌。例如,Bearer 或 Mac。
refresh_token 可选 客户端可以使用此令牌使用相同的授权授予获取新的访问令牌。
expires_in 建议 以秒为单位表示访问令牌的生命周期。600 的值表示访问令牌的生命周期为 10 分钟。如果响应中未提供此参数,则文档应突出显示访问令牌的生命周期。
可选参数 可选 额外参数。

客户端凭据授予

正如其名称所示,在这里,使用客户端凭据而不是用户(资源所有者)的凭据。除了客户端凭据,它与资源所有者密码凭据授予流程非常相似:

  1. 客户端应用程序(例如 Quora)使用授予类型和范围将访问令牌请求发送到资源服务器(例如 Facebook、Twitter 等)。客户端 ID 和密码添加到授权标头。验证成功后,资源服务器发送访问令牌。

  2. OAuth 客户端从资源服务器请求用户(资源所有者)的资源(例如 Twitter 个人资料照片等),并发送上一步收到的访问令牌。

  3. 资源服务器使用授权服务器验证访问令牌。

  4. 验证访问令牌成功后,资源服务器将所请求的资源提供给客户端应用程序(OAuth 客户端)。

  5. 客户端应用程序使用这些资源。

客户端凭据授予请求和响应。

如果您查看了客户端凭据授予流程的所有步骤(共五个步骤),您可以

可以看到客户端总共向授权服务器发出了两个请求,授权服务器提供了两个响应:一个请求-响应用于访问令牌和一个请求-响应用于涉及访问令牌验证的资源。

让我们讨论一下每个这些请求和响应中使用的参数。

访问令牌请求到令牌端点的 URI:

Parameter Required/optional Description
grant_type 必需 client_credentials(必须使用此值)。
scope 可选 请求的范围。如果没有提供,则授权服务器根据定义的策略提供范围。

访问令牌响应:

Parameter Required/optional Description
access_token 必需 授权服务器颁发的访问令牌。
token_type 必需 授权服务器定义的令牌类型。根据此,客户端可以利用访问令牌。例如,Bearer 或 Mac。
expires_in 推荐 表示访问令牌的生存期,以秒为单位。600 的值表示访问令牌的 10 分钟生存期。如果没有在响应中提供此参数,则文档应突出显示访问令牌的生存期。

OAuth 使用 Spring Security 实现

OAuth 2.0 是一种保护 API 的方法。Spring Security 提供了 Spring Cloud Security 和 Spring Cloud OAuth2 组件来实现我们之前讨论的授权流。

我们将再创建一个服务,一个安全服务,它将控制认证和授权。

创建一个新的 Maven 项目,并按照以下步骤操作:

  1. pom.xml中添加 Spring Security 和 Spring Security OAuth 2 依赖项:
 <dependency> 
   <groupId>org.springframework.cloud</groupId> 
   <artifactId>spring-cloud-starter-security</artifactId> 
</dependency> 
<dependency> 
   <groupId>org.springframework.cloud</groupId> 
   <artifactId>spring-cloud-starter-oauth2</artifactId> 
</dependency> 
  1. 在您的应用程序类中使用@EnableResourceServer注解。这将允许此应用程序作为资源服务器运行。@EnableAuthorizationServer注解是我们将使用以根据 OAuth 2.0 规范启用授权服务器的另一个注解:
@SpringBootApplication 
@RestController 
@EnableResourceServer 
public class SecurityApp { 

    @RequestMapping("/user") 
    public Principal user(Principal user) { 
        return user; 
    } 

    public static void main(String[] args) { 
        SpringApplication.run(SecurityApp.class, args); 
    } 

    @Configuration 
    @EnableAuthorizationServer 
    protected static class OAuth2Config extends AuthorizationServerConfigurerAdapter { 

        @Autowired 
        private AuthenticationManager authenticationManager; 

        @Override 
        public void configure(AuthorizationServerEndpointsConfigurer endpointsConfigurer) throws Exception { 
            endpointsConfigurer.authenticationManager(authenticationManager); 
        } 

        @Override 
        public void configure(ClientDetailsServiceConfigurer clientDetailsServiceConfigurer) throws Exception { 
  // Using hardcoded inmemory mechanism because it is just an example 
            clientDetailsServiceConfigurer.inMemory() 
             .withClient("acme") 
             .secret("acmesecret") 
             .authorizedGrantTypes("authorization_code", "refresh_token", "implicit", "password", "client_credentials") 
             .scopes("webshop"); 
        } 
    } 
}
  1. 更新application.yml中的安全服务配置,如下代码所示:
  • server.contextPath:这表示上下文路径

  • security.user.password: 本示例将使用硬编码的密码。您可以为其真实应用重新配置:

application.yml 
info: 
    component: 
        Security Server 

server: 
    port: 9001 
    ssl: 
        key-store: classpath:keystore.jks 
        key-store-password: password 
        key-password: password 
    contextPath: /auth 

security: 
    user: 
        password: password 

logging: 
    level: 
        org.springframework.security: DEBUG 

现在我们已经有了我们的安全服务器,我们将使用新的api-service微服务暴露我们的 API,该服务将用于与外部应用程序和 UI 通信。

我们将修改 Zuul-Server 模块,使其也成为资源服务器。这可以通过以下步骤完成:

  1. 添加 Spring Security 和 Spring Security OAuth 2 依赖项:

    pom.xml。在此,最后两个依赖项是启用 Zuul-Server 作为资源服务器所需的:

<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-zuul</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-eureka</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-feign</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-netflix-hystrix-stream</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-bus-amqp</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-stream-rabbit</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-web</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-security</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-oauth2</artifactId>         </dependency>
  1. 在您的应用程序类中使用@EnableResourceServer注解。这将允许此应用程序作为资源服务器运行:
@SpringBootApplication 
@EnableZuulProxy 
@EnableEurekaClient 
@EnableCircuitBreaker 
@Configuration 
@EnableFeignClients 
@EnableResourceServer 
public class EdgeApp { 

    private static final Logger LOG = LoggerFactory.getLogger(EdgeApp.class); 

    static { 
        // for localhost testing only 
        LOG.warn("Will now disable hostname check in SSL, only to be used during development"); 
        HttpsURLConnection.setDefaultHostnameVerifier((hostname, sslSession) -> true); 
    } 

    @Value("${app.rabbitmq.host:localhost}") 
    String rabbitMqHost; 

    @Bean 
    public ConnectionFactory connectionFactory() { 
        LOG.info("Create RabbitMqCF for host: {}", rabbitMqHost); 
        CachingConnectionFactory connectionFactory = new CachingConnectionFactory(rabbitMqHost); 
        return connectionFactory; 
    } 

    public static void main(String[] args) { 
        SpringApplication.run(EdgeApp.class, args); 
    } 
} 
  1. 更新Zuul-Server配置文件中的application.yml,如下所示的代码。application.yml文件看起来可能会像这样:
info: 
    component: Zuul Server 

spring: 
  application: 
     name: zuul-server  # Service registers under this name 
  # Added to fix -  java.lang.IllegalArgumentException: error at ::0 can't find referenced pointcut hystrixCommandAnnotationPointcut 
  aop: 
      auto: false 

zuul: 
    ignoredServices: "*" 
    routes: 
        restaurantapi: 
            path: /api/** 
            serviceId: api-service 
            stripPrefix: true 

server: 
    ssl: 
        key-store: classpath:keystore.jks 
        key-store-password: password 
        key-password: password 
    port: 8765 
    compression: 
        enabled: true 

security: 
  oauth2: 
    resource: 
     userInfoUri: https://localhost:9001/auth/user 

management: 
  security: 
    enabled: false 
## Other properties like Eureka, Logging and so on 

这里,security.oauth2.resource.userInfoUri属性表示安全服务用户 URI。API 通过指向 API 服务的路由配置暴露给外部世界。

现在我们已经有了安全服务器,我们通过api-service微服务暴露我们的 API,该服务将用于与外部应用程序和 UI 通信。

现在,让我们测试并探索不同 OAuth 2.0 授予类型的运作方式。

我们将使用 Postman 浏览器扩展来测试不同的流程。

授权码授予

我们将在浏览器中输入以下 URL。请求授权码如下:

https://localhost:9001/auth/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://localhost:7771/1&scope=apiAccess&state=1234

在这里,我们提供客户端 ID(默认情况下,我们在安全服务中注册了硬编码的客户端)、重定向 URI、范围(在安全服务中硬编码的apiAccess值)和状态。您可能会想知道state参数。它包含了一个我们在响应中重新验证的随机数,以防止跨站请求伪造。

如果资源所有者(用户)尚未经过身份验证,它会要求输入用户名和密码。输入用户名username和密码password;我们在安全服务中硬编码了这些值。

登录成功后,它会要求您提供您的(资源所有者)批准:

OAuth 2.0 授权码授予 - 资源授予批准

选择批准并点击授权。这个操作会将应用程序重定向到http://localhost:7771/1?code=o8t4fi&state=1234

正如你所看到的,它返回了授权代码和状态。

现在,我们将使用这个代码来检索访问代码,使用 Postman Chrome 扩展。首先,我们将使用用户名作为客户端,密码作为clientsecret来添加授权头,如下所示的屏幕截图:

OAuth 2.0 授权码授予 - 访问令牌请求 - 添加身份验证

这会将Authorization头添加到请求中,值为Basic Y2xpZW50OmNsaWVudHNlY3JldA==,这是'client client-secret'的 base-64 编码。

现在,我们将向请求中添加几个其他参数,如下的屏幕截图,然后提交请求:

OAuth 2.0 授权码授予 - 访问令牌请求和响应

根据 OAuth 2.0 规范,这会返回以下响应:

{
  "access_token": "6a233475-a5db-476d-8e31-d0aeb2d003e9",
  "token_type": "bearer", 
  "refresh_token": "8d91b9be-7f2b-44d5-b14b-dbbdccd848b8", 
  "expires_in": 43199, 
  "scope": "apiAccess" 
} 

现在,我们可以使用这些信息来访问资源拥有者的资源。例如,如果https://localhost:8765/api/restaurant/1代表 ID 为1的餐厅,那么它应该返回相应的餐厅详情。

没有访问令牌,如果我们输入 URL,它会返回错误Unauthorized,消息为Full authentication is required to access this resource

现在,让我们使用访问令牌访问这个网址,如下面的截图所示:

OAuth 2.0 授权码授权 - 使用访问令牌访问 API

正如您所看到的,我们添加了带有访问令牌的授权头。

现在,我们将探讨隐式授权实现的实现。

隐式授权

隐式授权与授权码授权非常相似,除了授权码步骤之外。如果您移除授权码授权的第一个步骤(客户端应用程序从授权服务器接收授权令牌的步骤),其余步骤都相同。让我们来查看一下。

在浏览器中输入以下 URL 和参数并按 Enter。同时,请确保如果需要,添加基本认证,将客户端作为username,将密码作为password

https://localhost:9001/auth/oauth/authorize?response_type=token&redirect_uri=https://localhost:8765&scope=apiAccess&state=553344&client_id=client

在这里,我们使用以下请求参数调用授权端点:响应类型、客户端 ID、重定向 URI、范围和状态。

当请求成功时,浏览器将被重定向到以下 URL,带有新的请求参数和值:

https://localhost:8765/#access_token=6a233475-a5db-476d-8e31-d0aeb2d003e9&token_type=bearer&state=553344&expires_in=19592

在这里,我们接收到access_tokentoken_type、状态和令牌的过期持续时间。现在,我们可以利用这个访问令牌来访问 API,就像在授权码授权中使用一样。

资源所有者密码凭据授权

在这个授权中,我们请求访问令牌时提供usernamepassword作为参数,以及grant_typeclientscope参数。我们还需要使用客户端 ID 和密钥来验证请求。这些授权流程使用客户端应用程序代替浏览器,通常用于移动和桌面应用程序。

在下面的 Postman 工具截图中,已使用client_idpassword进行基本认证,并添加了授权头:

OAuth 2.0 资源所有者密码凭据授权 - 访问令牌请求和响应

一旦客户端接收到访问令牌,它可以用类似的方式使用,就像在授权码授权中使用一样。

客户端凭据授权

在这个流程中,客户端提供自己的凭据以获取访问令牌。它不使用资源所有者的凭据和权限。

正如您在下面的截图中看到的,我们直接输入只有两个参数的令牌端点:grant_typescope。授权头使用client_idclient secret添加:

OAuth 2.0 客户端凭据授权 - 访问令牌请求和响应

您可以像授权码授权中解释的那样使用访问令牌。

参考文献

更多信息,您可以参考以下链接:

摘要

在本章中,我们了解到拥有 TLS 层或 HTTPS 对所有网络流量的重要性。我们已经向示例应用程序添加了自签名的证书。我想再次强调,对于生产应用程序,您必须使用证书授权机构提供的证书。我们还探讨了 OAuth 2.0 的基本原理和各种 OAuth 2.0 授权流。不同的 OAuth 2.0 授权流是使用 Spring Security 和 OAuth 2.0 实现的。在下一章中,我们将实现示例 OTRS 项目的 UI,并探讨所有组件是如何一起工作的。

第八章:使用微服务网络应用程序消费服务

现在,在开发了微服务之后,将很有趣地看看在线表格预订系统(OTRS)提供的服务如何被网络或移动应用程序消费。我们将使用 AngularJS/Bootstrap 开发网络应用程序(UI)的原型。这个示例应用程序将显示这个示例项目的数据和流程——一个小型实用程序项目。这个网络应用程序也将是一个示例项目,并可以独立运行。以前,网络应用程序是在单个网络归档(具有 .war 扩展名的文件)中开发的,其中包含 UI 和服务器端代码。这样做的原因相当简单,因为 UI 也是使用 Java、JSP、servlet、JSF 等开发的。现在,UI 是独立使用 JavaScript 开发的。因此,这些 UI 应用程序也作为单个微服务部署。在本章中,我们将探讨这些独立 UI 应用程序是如何开发的。我们将开发并实现没有登录和授权流的 OTRS 示例应用程序。我们将部署一个功能非常有限的应用程序,并涵盖高级 AngularJS 概念。有关 AngularJS 的更多信息,请参考《AngularJS 示例》、《Chandermani》、《Packt Publishing》。

在本章中,我们将涵盖以下主题:

  • AngularJS 框架概述

  • OTRS 功能的开发

  • 设置网络应用程序(UI)

AngularJS 框架概述

现在,既然我们已经完成了 HTML5 网络应用程序的设置,我们可以了解 AngularJS 的基础知识。这将帮助我们理解 AngularJS 代码。本节描述了你可以利用的高级理解水平,以理解示例应用程序并进一步使用 AngularJS 文档或参考其他 Packt Publishing 资源。

AngularJS 是一个客户端 JavaScript 框架。它足够灵活,可以作为模型-视图-控制器MVC)或模型-视图-视图模型MVVM)使用。它还提供内置服务,如使用依赖注入模式的 $http$log

MVC

模型-视图-控制器(MVC)是一种众所周知的设计模式。Struts 和 Spring MVC 是流行的例子。让我们看看它们如何适用于 JavaScript 世界:

  • 模型:模型是包含应用程序数据的 JavaScript 对象。它们还表示应用程序的状态。

  • 视图:视图是由 HTML 文件组成的表示层。在这里,你可以显示来自模型的数据并提供用户交互界面。

  • 控制器:你可以在 JavaScript 中定义控制器,其中包含应用程序逻辑。

MVVM

MVVM 是一种针对 UI 开发的设计模式。MVVM 旨在使双向数据绑定变得更容易。双向数据绑定提供了模型和视图之间的同步。当模型(数据)发生变化时,它立即反映在视图上。类似地,当用户在视图上更改数据时,它也会反映在模型上:

  • 模型:这与 MVC 非常相似,包含业务逻辑和数据。

  • 视图:与 MVC 类似,它包含呈现逻辑或用户界面。

  • 视图模型:视图模型包含视图和模型之间的数据绑定。因此,它是视图和模型之间的接口。

模块

模块是我们为任何 AngularJS 应用程序定义的第一个东西。模块是一个包含应用程序不同部分的容器,如控制器、服务、过滤器等。AngularJS 应用程序可以写在一个单一的模块中,也可以写在多个模块中。AngularJS 模块也可以包含其他模块。

许多其他 JavaScript 框架使用main方法来实例化和连接应用程序的不同部分。AngularJS 没有main方法。它由于以下原因使用模块作为入口点:

  • 模块化:你可以根据应用程序功能或可重用组件来划分和创建应用程序。

  • 简洁性:你可能遇到过复杂且庞大的应用程序代码,这使得维护和升级成为头疼的事。不再如此:AngularJS 使代码变得简单、可读且易于理解。

  • 测试:它使单元测试和端到端测试变得容易,因为你可以覆盖配置并只加载所需的模块。

每个 AngularJS 应用程序需要有一个单一的模块来启动 AngularJS 应用程序。启动我们的应用程序需要以下三个部分:

  • 应用程序模块:一个包含 AngularJS 模块的 JavaScript 文件(app.js),如下所示:
var otrsApp = AngularJS.module('otrsApp', [ ]) 
// [] contains the reference to other modules 
  • 加载 Angular 库和应用程序模块:一个包含对其他 AngularJS 库的 JavaScript 文件的引用和一个index.html文件:
<script type="text/javascript" src="img/angular.min.js"></script> 
<script type="text/javascript" src="img/app.js"></script>
  • 应用程序 DOM 配置:这告诉 AngularJS 应用程序的 DOM 元素的启动位置。它可以以两种方式之一完成:
  1. 一个index.html文件,其中还包含一个 HTML 元素(通常是<html>)和一个具有在app.js中给出的值的ng-app(AngularJS 指令)属性:<html lang="zh" ng-app="otrsApp" class="no-js">。AngularJS 指令前缀为ng(AngularJS):<html lang="en" ng-app="otrsApp" class="no-js">

  2. 或者,如果你是以异步方式加载 JavaScript 文件的话,请使用这个命令:AngularJS.bootstrap(document.documentElement, ['otrsApp']);

一个 AngularJS 模块有两个重要的部分,config()run(),除了控制器、服务、过滤器等其他组件:

  • config()用于注册和配置模块,并只处理使用$injector的提供者和常量。$injector是 AngularJS 服务。我们在下一节介绍提供者和$injector。在这里不能使用实例。它防止在完全配置之前使用服务。

  • run()方法用于在通过前面的config()方法创建$injector之后执行代码。它只处理实例和常量。在这里不能使用提供商,以避免在运行时进行配置。

提供商和服务

让我们看一下以下的代码:

.controller('otrsAppCtrl', function ($injector) { 
var log = $injector.get('$log'); 

$log是一个内置的 AngularJS 服务,提供了日志 API。在这里,我们使用了另一个内置服务——$injector,它允许我们使用$log服务。$injector是控制器的一个参数。AngularJS 使用函数定义和正则表达式为调用者(即控制器)提供$injector服务,这正是 AngularJS 有效使用依赖注入模式的示例。

AngularJS 大量使用依赖注入模式,使用注入器服务($injector)来实例化和连接我们用在 AngularJS 应用程序中的大多数对象。这个注入器创建了两种类型的对象——服务和特殊对象。

为了简化,你可以认为我们(开发者)定义服务。相反,特殊对象是 AngularJS 项目,如控制器、过滤器、指令等。

AngularJS 提供了五种告诉注入器如何创建服务对象的食谱类型——提供商工厂服务常量

  • 提供商是核心且最复杂的食谱类型。其他的食谱都是建立在其上的合成糖。我们通常避免使用提供商,除非我们需要创建需要全局配置的可重用代码。

  • 值和常量食谱类型正如其名称所暗示的那样工作。它们都不能有依赖关系。此外,它们之间的区别在于它们的用法。在配置阶段你不能使用值服务对象。

  • 工厂和服务是最常用的服务类型。它们属于相似的类型。当我们想要生产 JavaScript 原始值和函数时,我们使用工厂食谱。另一方面,当我们要生产自定义定义的类型时,我们使用服务。

由于我们现在对服务有一定的了解,我们可以认为服务有两个常见的用途——组织代码和跨应用程序共享代码。服务是单例对象,由 AngularJS 服务工厂延迟实例化。我们已经看到了一些内置的 AngularJS 服务,比如$injector$log等。AngularJS 服务前缀为$符号。

作用域

在 AngularJS 应用程序中,广泛使用了两种作用域——$rootScope$scope

  • $rootScope 是作用域层次结构中最顶层的对象,与全局作用域相关联。这意味着您附加上它的任何变量都将无处不在可用,因此,使用 $rootScope 应该是一个经过深思熟虑的决定。

  • 控制器在回调函数中有一个 $scope 作为参数。它用于将控制器中的数据绑定到视图。其作用域仅限于与它关联的控制器使用。

控制器

控制器通过 JavaScript 的 constructor 函数定义,拥有 $scope 作为参数。控制器的主要目的是将数据绑定到视图。控制器函数也用于编写业务逻辑——设置 $scope 对象的初始状态和向 $scope 添加行为。控制器签名如下:

RestModule.controller('RestaurantsCtrl', function ($scope, restaurantService) { 

在这里,控制器是 RestModule 的一部分,控制器的名称是 RestaurantCtrl$scoperestaurantService 被作为参数传递。

过滤器

过滤器的目的是格式化给定表达式的值。在以下代码中,我们定义了 datetime1 过滤器,它接受日期作为参数并将其值更改为 dd MMM yyyy HH:mm 格式,例如 04 Apr 2016 04:13 PM

.filter('datetime1', function ($filter) { 
    return function (argDateTime) { 
        if (argDateTime) { 
            return $filter('date')(new Date(argDateTime), 'dd MMM yyyy HH:mm a'); 
        } 
        return ""; 
    }; 
});

指令

正如我们在模块部分所看到的,AngularJS 指令是带有 ng 前缀的 HTML 属性。一些常用的指令包括:

  • ng-app:这个指令定义了 AngularJS 应用程序

  • ng-model:这个指令将 HTML 表单输入绑定到数据

  • ng-bind:这个指令将数据绑定到 HTML 视图

  • ng-submit:这个指令提交 HTML 表单

  • ng-repeat:这个指令遍历集合:

<div ng-app=""> 
    <p>Search: <input type="text" ng-model="searchValue"></p> 
    <p ng-bind="searchedTerm"></p> 
</div>

UI-Router

单页应用程序SPA)中,页面只加载一次,用户通过不同的链接进行导航,而无需刷新页面。这都是因为路由。路由是一种使 SPA 导航感觉像正常网站的方法。因此,路由对 SPA 非常重要。

AngularUI 团队开发了 UI-Router,这是一个 AngularJS 的路由框架。UI-Router 并不是 AngularJS 核心的一部分。当用户在 SPA 中点击任何链接时,UI-Router 不仅会改变路由 URL,还会改变应用程序的状态。由于 UI-Router 也可以进行状态更改,因此您可以在不改变 URL 的情况下更改页面的视图。这是因为在 UI-Router 的管理下实现了应用程序状态管理。

如果我们把 SPA 看作是一个状态机,那么状态就是应用程序的当前状态。当我们创建路由链接时,我们会在 HTML 链接标签中使用 ui-sref 属性。链接中的 href 属性由此生成,并指向在 app.js 中创建的应用程序的某些状态。

我们使用 HTML div 中的 ui-view 属性来使用 UI-Router。例如,

<div ui-view></div>

开发 OTRS 功能

正如您所知,我们正在开发 SPA。因此,一旦应用程序加载,您可以在不刷新页面的情况下执行所有操作。所有与服务器的交互都是通过 AJAX 调用完成的。现在,我们将利用我们在第一部分中介绍的 AngularJS 概念。我们将涵盖以下场景:

  • 一个将显示餐厅列表的页面。这也将是我们的主页。

  • 搜索餐厅。

  • 带有预订选项的餐厅详情。

  • 登录(不是从服务器上,而是用于显示流程)。

  • 预订确认。

对于主页,我们将创建一个index.html文件和一个模板,该模板将包含中间部分(或内容区域)的餐厅列表。

主页/餐厅列表页

主页是任何网络应用程序的主要页面。为了设计主页,我们将使用 Angular-UI Bootstrap,而不是实际的 Bootstrap。Angular-UI 是 Bootstrap 的 Angular 版本。主页将分为三个部分:

  • 头部部分将包含应用程序名称、搜索餐厅表单以及顶部右角的用户名。

  • 内容或中间部分将包含餐厅列表,这些列表将使用餐厅名称作为链接。此链接将指向餐厅详情和预订页面。

  • 页脚部分将包含带有版权标志的应用程序名称。

您可能对在设计或实现之前查看主页感兴趣。因此,让我们首先看看一旦我们的内容准备就绪,它将看起来如何:

OTRS 主页带有餐厅列表

现在,为了设计我们的主页,我们需要添加以下四个文件:

  • index.html:我们的主 HTML 文件

  • app.js:我们的主 AngularJS 模块

  • restaurants.js:包含餐厅 Angular 服务的餐厅模块

  • restaurants.html:将显示列表的 HTML 模板

    餐厅

index.html

首先,我们将./app/index.html添加到我们的项目工作区。index.html文件的内容将从这里开始解释。

我在代码之间添加了注释,以使代码更具可读性,更容易理解。

index.html文件分为许多部分。在这里我们将讨论一些关键部分。首先,我们将了解如何解决旧版本的 Internet Explorer。如果您想针对大于八版的 Internet Explorer 浏览器或 IE 九版及以后的版本,那么我们需要添加以下代码块,这将阻止 JavaScript 渲染并给最终用户输出no-js

<!--[if lt IE 7]>      <html lang="en" ng-app="otrsApp" class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]--> 
<!--[if IE 7]>         <html lang="en" ng-app="otrsApp" class="no-js lt-ie9 lt-ie8"> <![endif]--> 
<!--[if IE 8]>         <html lang="en" ng-app="otrsApp" class="no-js lt-ie9"> <![endif]--> 
<!--[if gt IE 8]><!--> <html lang="en" ng-app="otrsApp" class="no-js"> <!--<![endif]--> 

然后,在添加几个meta标签和应用程序的标题之后,我们还将定义重要的meta标签viewportviewport用于响应式 UI 设计。

在内容属性中定义的width属性控制viewport的大小。它可以设置为特定的像素值,例如width = 600,或者设置为特殊的device-width值,该值在 100%的缩放比例下是屏幕的宽度。

initial-scale属性控制页面首次加载时的缩放级别。max-scalemin-scaleuser-scalable属性控制用户如何允许缩放页面:

<meta name="viewport" content="width=device-width, initial-scale=1"> 

在接下来的几行中,我们将定义我们应用程序的样式表。我们从 HTML5 模板代码中添加了normalize.cssmain.css。我们还添加了我们应用程序的自定义 CSSapp.css。最后,我们添加了 Bootstrap 3 的 CSS。除了自定义的app.css之外,其他 CSS 都在其中引用。这些 CSS 文件没有变化:

<link rel="stylesheet" href="bower_components/html5-boilerplate/dist/css/normalize.css"> 
<link rel="stylesheet" href="bower_components/html5-boilerplate/dist/css/main.css"> 
<link rel="stylesheet" href="public/css/app.css"> 
<link data-require="bootstrap-css@*" data-server="3.0.0" rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" /> 

然后,我们将使用script标签定义脚本。我们添加了现代 izer、Angular、Angular-route 和app.js,我们自己的开发的定制 JavaScript 文件。

我们已经讨论了 Angular 和 Angular-UI。app.js将在

下一节。

现代 izer 允许网络开发者在维持对不支持它们的浏览器的精细控制的同时使用新的 CSS3 和 HTML5 功能。基本上,现代 izer 在页面在浏览器中加载时执行下一代特性检测(检查这些特性的可用性)并报告结果。根据这些结果,您可以检测到浏览器中最新可用的特性,根据这些特性,您可以为最终用户提供一个界面。如果浏览器不支持一些特性,那么将向最终用户提供替代流程或 UI。

我们还将添加 Bootstrap 模板,这些模板是用 JavaScript 编写的,使用ui-bootstrap-tpls javascript文件:

<script src="img/modernizr-2.8.3.min.js"></script> 
<script src="img/angular.min.js"></script> 
<script src="img/angular-route.min.js"></script> 
<script src="img/app.js"></script> 
<script data-require="ui-bootstrap@0.5.0" data-semver="0.5.0" src="img/ui-bootstrap-tpls-0.6.0.js"></script> 

我们还可以向head标签添加样式,如下面的代码所示。这些样式允许下拉菜单正常工作:

<style> 
    div.navbar-collapse.collapse { 
      display: block; 
      overflow: hidden; 
      max-height: 0px; 
      -webkit-transition: max-height .3s ease; 
      -moz-transition: max-height .3s ease; 
      -o-transition: max-height .3s ease; 
      transition: max-height .3s ease; 
      } 
    div.navbar-collapse.collapse.in { 
      max-height: 2000px; 
      } 
</style> 

body标签中,我们使用

ng-controller属性。在页面加载时,它告诉控制器将应用程序名称告诉 Angular,如下所示:

<body ng-controller="otrsAppCtrl"> 

然后,我们定义主页的header部分。在header部分,我们将定义应用程序标题在线餐桌预订系统。此外,我们还将定义搜索餐厅的搜索表单:

<!-- BEGIN HEADER --> 
        <nav class="navbar navbar-default" role="navigation"> 

            <div class="navbar-header"> 
                <a class="navbar-brand" href="#"> 
                    Online Table Reservation System 
                </a> 
            </div> 
            <div class="collapse navbar-collapse" ng-class="!navCollapsed && 'in'" ng-click="navCollapsed = true"> 
                <form class="navbar-form navbar-left" role="search" ng-submit="search()"> 
                    <div class="form-group"> 
                        <input type="text" id="searchedValue" ng-model="searchedValue" class="form-control" placeholder="Search Restaurants"> 
                    </div> 
                    <button type="submit" class="btn btn-default" ng-click="">Go</button> 
                </form> 
        <!-- END HEADER --> 

然后,下一节,中间部分,包括我们实际绑定了不同的视图,用实际的内容注释标记。div中的ui-view属性动态地从 Angular 获取其内容,例如餐厅详情、餐厅列表等。我们还为中间部分添加了警告对话框和加载动画,根据需要显示:

<div class="clearfix"></div> 
    <!-- BEGIN CONTAINER --> 
    <div class="page-container container"> 
        <!-- BEGIN CONTENT --> 
        <div class="page-content-wrapper"> 
            <div class="page-content"> 
                <!-- BEGIN ACTUAL CONTENT --> 
                <div ui-view class="fade-in-up"></div> 
                <!-- END ACTUAL CONTENT --> 
            </div> 
        </div> 
        <!-- END CONTENT --> 
    </div> 
    <!-- loading spinner --> 
    <div id="loadingSpinnerId" ng-show="isSpinnerShown()" style="top:0; left:45%; position:absolute; z-index:999"> 
        <script type="text/ng-template" id="alert.html"> 
            <div class="alert alert-warning" role="alert"> 
            <div ng-transclude></div> 
            </div> 
        </script> 
        <uib-alert type="warning" template-url="alert.html"><b>Loading...</b></uib-alert> 
    </div> 
        <!-- END CONTAINER --> 

index.html的最后一部分是页脚。在这里,我们只是添加了静态内容和版权文本。您可以在這裡添加任何您想要的内容:

        <!-- BEGIN FOOTER --> 
        <div class="page-footer"> 
            <hr/><div style="padding: 0 39%">&copy; 2016 Online Table Reservation System</div> 
        </div> 
        <!-- END FOOTER --> 
    </body> 
</html> 

app.js

app.js是我们的主应用程序文件。因为我们已经在index.html中定义了它,

它在我们的index.html被调用时就已经加载。

我们需要注意不要将路由(URI)与 REST 端点混合。路由代表了 SPA 的状态/视图。

由于我们使用边缘服务器(代理服务器),一切都可以通过它访问,包括我们的 REST 端点。外部应用程序(包括 UI)将使用边缘服务器的宿主来访问应用程序。您可以在全局常量文件中配置它,然后在需要的地方使用它。这将允许您在单一位置配置 REST 主机并在其他地方使用它:

'use strict'; 
/* 
This call initializes our application and registers all the modules, which are passed as an array in the second argument. 
*/ 
var otrsApp = angular.module('otrsApp', [ 
    'ui.router', 
    'templates', 
    'ui.bootstrap', 
    'ngStorage', 
    'otrsApp.httperror', 
    'otrsApp.login', 
    'otrsApp.restaurants' 
]) 
/* 
  Then we have defined the default route /restaurants 
*/ 
        .config([ 
            '$stateProvider', '$urlRouterProvider', 
            function ($stateProvider, $urlRouterProvider) { 
                $urlRouterProvider.otherwise('/restaurants'); 
            }]) 
/* 
   This functions controls the flow of the application and handles the events. 
*/ 
        .controller('otrsAppCtrl', function ($scope, $injector, restaurantService) { 
            var controller = this; 

            var AjaxHandler = $injector.get('AjaxHandler'); 
            var $rootScope = $injector.get('$rootScope'); 
            var log = $injector.get('$log'); 
            var sessionStorage = $injector.get('$sessionStorage'); 
            $scope.showSpinner = false; 
/* 
   This function gets called when the user searches any restaurant. It uses the Angular restaurant service that we'll define in the next section to search the given search string. 
*/ 
            $scope.search = function () { 
                $scope.restaurantService = restaurantService; 
                restaurantService.async().then(function () { 
                    $scope.restaurants = restaurantService.search($scope.searchedValue); 
                }); 
            } 
/* 
   When the state is changed, the new controller controls the flows based on the view and configuration and the existing controller is destroyed. This function gets a call on the destroy event. 
*/ 
            $scope.$on('$destroy', function destroyed() { 
                log.debug('otrsAppCtrl destroyed'); 
                controller = null; 
                $scope = null; 
            }); 

            $rootScope.fromState; 
            $rootScope.fromStateParams; 
            $rootScope.$on('$stateChangeSuccess', function (event, toState, toParams, fromState, fromStateParams) { 
                $rootScope.fromState = fromState; 
                $rootScope.fromStateParams = fromStateParams; 
            }); 

            // utility method 
            $scope.isLoggedIn = function () { 
                if (sessionStorage.session) { 
                    return true; 
                } else { 
                    return false; 
                } 
            }; 

            /* spinner status */ 
            $scope.isSpinnerShown = function () { 
                return AjaxHandler.getSpinnerStatus(); 
            }; 

        }) 
/* 
   This function gets executed when this object loads. Here we are setting the user object which is defined for the root scope. 
*/ 
        .run(['$rootScope', '$injector', '$state', function ($rootScope, $injector, $state) { 
                $rootScope.restaurants = null; 
                // self reference 
                var controller = this; 
                // inject external references 
                var log = $injector.get('$log'); 
                var $sessionStorage = $injector.get('$sessionStorage'); 
                var AjaxHandler = $injector.get('AjaxHandler'); 

                if (sessionStorage.currentUser) { 
                    $rootScope.currentUser = $sessionStorage.currentUser; 
                } else { 
                    $rootScope.currentUser = "Guest"; 
                    $sessionStorage.currentUser = "" 
                } 
            }]) 

restaurants.js

restaurants.js代表了我们应用程序中一个用于餐厅的 Angular 服务,我们将在搜索、列表、详情等不同模块间使用它。我们知道服务的两个常见用途是组织代码和跨应用程序共享代码。因此,我们创建了一个餐厅服务,它将在不同的模块(如搜索、列表、详情等)间使用。

服务是单例对象,由 AngularJS 服务工厂延迟实例化。

以下部分初始化了餐厅服务模块并加载了所需的依赖项:

angular.module('otrsApp.restaurants', [ 
    'ui.router', 
    'ui.bootstrap', 
    'ngStorage', 
    'ngResource' 
]) 

在配置中,我们使用 UI-Router 定义了otrsApp.restaurants模块的路线和状态:

首先,我们通过传递包含指向路由 URI 的 URL、指向显示restaurants状态的 HTML 模板的 URL 以及将处理restaurants视图上事件的路由器来定义restaurants状态。

restaurants视图(route - /restaurants)之上,还定义了一个嵌套的restaurants.profile状态,它将代表特定的餐厅。例如,/restaurant/1会打开并显示代表Id 1的餐厅的概要(详情)页面。当在restaurants模板中点击链接时,这个状态会被调用。在这个ui-sref="restaurants.profile({id: rest.id})"中,rest代表了从restaurants视图中检索到的restaurant对象。

请注意,状态名是'restaurants.profile',这告诉 AngularJS UI-Router restaurants状态的概要是一个嵌套状态:

        .config([ 
            '$stateProvider', '$urlRouterProvider', 
            function ($stateProvider, $urlRouterProvider) { 
                $stateProvider.state('restaurants', { 
                    url: '/restaurants', 
                    templateUrl: 'restaurants/restaurants.html', 
                    controller: 'RestaurantsCtrl' 
                }) 
                        // Restaurant show page 
                        .state('restaurants.profile', { 
                            url: '/:id', 
                            views: { 
                                '@': { 
                                    templateUrl: 'restaurants/restaurant.html', 
                                    controller: 'RestaurantCtrl' 
                                } 
                            } 
                        }); 
            }]) 

在下一个代码部分,我们使用 Angular 工厂服务类型定义了餐厅服务。这个餐厅服务在加载时通过 REST 调用从服务器获取餐厅列表。它提供了餐厅操作的列表和搜索餐厅数据:

        .factory('restaurantService', function ($injector, $q) { 
            var log = $injector.get('$log'); 
            var ajaxHandler = $injector.get('AjaxHandler'); 
            var deffered = $q.defer(); 
            var restaurantService = {}; 
            restaurantService.restaurants = []; 
            restaurantService.orignalRestaurants = []; 
            restaurantService.async = function () { 
                ajaxHandler.startSpinner(); 
                if (restaurantService.restaurants.length === 0) { 
                    ajaxHandler.get('/api/restaurant') 
                            .success(function (data, status, headers, config) { 
                                log.debug('Getting restaurants'); 
                                sessionStorage.apiActive = true; 
                                log.debug("if Restaurants --> " + restaurantService.restaurants.length); 
                                restaurantService.restaurants = data; 
                                ajaxHandler.stopSpinner(); 
                                deffered.resolve(); 
                            }) 
                            .error(function (error, status, headers, config) { 
                                restaurantService.restaurants = mockdata; 
                                ajaxHandler.stopSpinner(); 
                                deffered.resolve(); 
                            }); 
                    return deffered.promise; 
                } else { 
                    deffered.resolve(); 
                    ajaxHandler.stopSpinner(); 
                    return deffered.promise; 
                } 
            }; 
            restaurantService.list = function () { 
                return restaurantService.restaurants; 
            }; 
            restaurantService.add = function () { 
                console.log("called add"); 
                restaurantService.restaurants.push( 
                        { 
                            id: 103, 
                            name: 'Chi Cha\'s Noodles', 
                            address: '13 W. St., Eastern Park, New County, Paris', 
                        }); 
            }; 
            restaurantService.search = function (searchedValue) { 
                ajaxHandler.startSpinner(); 
                if (!searchedValue) { 
                    if (restaurantService.orignalRestaurants.length > 0) { 
                        restaurantService.restaurants = restaurantService.orignalRestaurants; 
                    } 
                    deffered.resolve(); 
                    ajaxHandler.stopSpinner(); 
                    return deffered.promise; 
                } else { 
                    ajaxHandler.get('/api/restaurant?name=' + searchedValue) 
                            .success(function (data, status, headers, config) { 
                                log.debug('Getting restaurants'); 
                                sessionStorage.apiActive = true; 
                                log.debug("if Restaurants --> " + restaurantService.restaurants.length); 
                                if (restaurantService.orignalRestaurants.length < 1) { 
                                    restaurantService.orignalRestaurants = restaurantService.restaurants; 
                                } 
                                restaurantService.restaurants = data; 
                                ajaxHandler.stopSpinner(); 
                                deffered.resolve(); 
                            }) 
                            .error(function (error, status, headers, config) { 
                                if (restaurantService.orignalRestaurants.length < 1) { 
                                    restaurantService.orignalRestaurants = restaurantService.restaurants; 
                                } 
                                restaurantService.restaurants = []; 
                                restaurantService.restaurants.push( 
                                        { 
                                            id: 104, 
                                            name: 'Gibsons - Chicago Rush St.', 
                                            address: '1028 N. Rush St., Rush & Division, Cook County, Paris' 
                                        }); 
                                restaurantService.restaurants.push( 
                                        { 
                                            id: 105, 
                                            name: 'Harry Caray\'s Italian Steakhouse', 
                                            address: '33 W. Kinzie St., River North, Cook County, Paris', 
                                        }); 
                                ajaxHandler.stopSpinner(); 
                                deffered.resolve(); 
                            }); 
                    return deffered.promise; 
                } 
            }; 
            return restaurantService; 
        }) 

restaurants.js模块的下一部分,我们将添加两个控制器,我们在路由配置中为restaurantsrestaurants.profile状态定义了这两个控制器。这两个控制器分别是RestaurantsCtrlRestaurantCtrl,它们分别处理restaurants状态和restaurants.profiles状态。

RestaurantsCtrl控制器相当简单,它使用餐厅服务列表方法加载餐厅数据:

        .controller('RestaurantsCtrl', function ($scope, restaurantService) { 
            $scope.restaurantService = restaurantService; 
            restaurantService.async().then(function () { 
                $scope.restaurants = restaurantService.list(); 
            }); 
        }) 

RestaurantCtrl控制器负责显示给定 ID 的餐厅详情。这也负责对显示的餐厅执行预订操作。当设计带有预订选项的餐厅详情页面时,将使用这个控制器:

        .controller('RestaurantCtrl', function ($scope, $state, $stateParams, $injector, restaurantService) { 
            var $sessionStorage = $injector.get('$sessionStorage'); 
            $scope.format = 'dd MMMM yyyy'; 
            $scope.today = $scope.dt = new Date(); 
            $scope.dateOptions = { 
                formatYear: 'yy', 
                maxDate: new Date().setDate($scope.today.getDate() + 180), 
                minDate: $scope.today.getDate(), 
                startingDay: 1 
            }; 

            $scope.popup1 = { 
                opened: false 
            }; 
            $scope.altInputFormats = ['M!/d!/yyyy']; 
            $scope.open1 = function () { 
                $scope.popup1.opened = true; 
            }; 
            $scope.hstep = 1; 
            $scope.mstep = 30; 

            if ($sessionStorage.reservationData) { 
                $scope.restaurant = $sessionStorage.reservationData.restaurant; 
                $scope.dt = new Date($sessionStorage.reservationData.tm); 
                $scope.tm = $scope.dt; 
            } else { 
                $scope.dt.setDate($scope.today.getDate() + 1); 
                $scope.tm = $scope.dt; 
                $scope.tm.setHours(19); 
                $scope.tm.setMinutes(30); 
                restaurantService.async().then(function () { 
                    angular.forEach(restaurantService.list(), function (value, key) { 
                        if (value.id === parseInt($stateParams.id)) { 
                            $scope.restaurant = value; 
                        } 
                    }); 
                }); 
            } 
            $scope.book = function () { 
                var tempHour = $scope.tm.getHours(); 
                var tempMinute = $scope.tm.getMinutes(); 
                $scope.tm = $scope.dt; 
                $scope.tm.setHours(tempHour); 
                $scope.tm.setMinutes(tempMinute); 
                if ($sessionStorage.currentUser) { 
                    console.log("$scope.tm --> " + $scope.tm); 
                    alert("Booking Confirmed!!!"); 
                    $sessionStorage.reservationData = null; 
                    $state.go("restaurants"); 
                } else { 
                    $sessionStorage.reservationData = {}; 
                    $sessionStorage.reservationData.restaurant = $scope.restaurant; 
                    $sessionStorage.reservationData.tm = $scope.tm; 
                    $state.go("login"); 
                } 
            } 
        }) 

我们还在restaurants.js模块中添加了几个筛选器来格式化日期和时间。这些筛选器对输入数据执行以下格式化:

  • date1:返回输入日期,格式为dd MMM yyyy,例如,13-Apr-2016

  • time1:返回输入时间,格式为 HH:mm:ss,例如,11:55:04

  • dateTime1:返回输入日期和时间,格式为dd MMM yyyy HH:mm:ss,例如,13-Apr-2016 11:55:04

在下面的代码片段中,我们应用了这三个筛选器:

        .filter('date1', function ($filter) { 
            return function (argDate) { 
                if (argDate) { 
                    var d = $filter('date')(new Date(argDate), 'dd MMM yyyy'); 
                    return d.toString(); 
                } 
                return ""; 
            }; 
        }) 
        .filter('time1', function ($filter) { 
            return function (argTime) { 
                if (argTime) { 
                    return $filter('date')(new Date(argTime), 'HH:mm:ss'); 
                } 
                return ""; 
            }; 
        }) 
        .filter('datetime1', function ($filter) { 
            return function (argDateTime) { 
                if (argDateTime) { 
                    return $filter('date')(new Date(argDateTime), 'dd MMM yyyy HH:mm a'); 
                } 
                return ""; 
            }; 
        }); 

restaurants.html

我们需要添加为restaurants.profile状态定义的模板。正如你所见,在模板中,我们使用ng-repeat指令来遍历由restaurantService.restaurants返回的对象列表。restaurantService作用域变量在控制器中定义。'RestaurantsCtrl'与这个模板在restaurants状态中相关联:

<h3>Famous Gourmet Restaurants in Paris</h3> 
<div class="row"> 
    <div class="col-md-12"> 
        <table class="table table-bordered table-striped"> 
            <thead> 
                <tr> 
                    <th>#Id</th> 
                    <th>Name</th> 
                    <th>Address</th> 
                </tr> 
            </thead> 
            <tbody> 
                <tr ng-repeat="rest in restaurantService.restaurants"> 
                    <td>{{rest.id}}</td> 
                    <td><a ui-sref="restaurants.profile({id: rest.id})">{{rest.name}}</a></td> 
                    <td>{{rest.address}}</td> 
                </tr> 
            </tbody> 
        </table> 
    </div> 
</div> 

搜索餐厅

在主页index.html中,我们在header部分添加了搜索表单,用于搜索餐厅。搜索餐厅功能将使用前面描述的相同文件。它使用app.js(搜索表单处理程序)、restaurants.js(餐厅服务)和restaurants.html来显示搜索到的记录:

OTRS 主页带餐厅列表

带有预订选项的餐厅详情

带有预订选项的餐厅详情将作为内容区域(页面中间部分)的一部分。这部分将包含一个顶部面包屑,带有餐厅链接至餐厅列表页面,随后是餐厅的名称和地址。最后部分将包含预订部分,包含日期和时间选择框和一个预订按钮。

此页面将如下所示:

餐厅详情页面带预订选项

在这里,我们将使用在restaurants.js中声明的相同的餐厅服务。

唯一的变化将是模板,正如为restaurants.profile状态描述的那样。这个模板将使用restaurant.html定义。

restaurant.html

正如你所见,面包屑正在使用restaurants路由,这是使用ui-sref属性定义的。在这个模板中设计的预订表单在表单提交时使用ng-submit指令调用控制器RestaurantCtrl中的book()函数:

<div class="row"> 
<div class="row"> 
    <div class="col-md-12"> 
        <ol class="breadcrumb"> 
            <li><a ui-sref="restaurants">Restaurants</a></li> 
            <li class="active">{{restaurant.name}}</li> 
        </ol> 
        <div class="bs-docs-section"> 
            <h1 class="page-header">{{restaurant.name}}</h1> 
            <div> 
                <strong>Address:</strong> {{restaurant.address}} 
            </div> 
            </br></br> 
            <form ng-submit="book()"> 
                <div class="input-append date form_datetime"> 
                    <div class="row"> 
                        <div class="col-md-7"> 
                            <p class="input-group"> 
                                <span style="display: table-cell; vertical-align: middle; font-weight: bolder; font-size: 1.2em">Select Date & Time for Booking:</span> 
                                <span style="display: table-cell; vertical-align: middle"> 
                                    <input type="text" size=20 class="form-control" uib-datepicker-popup="{{format}}" ng-model="dt" is-open="popup1.opened" datepicker-options="dateOptions" ng-required="true" close-text="Close" alt-input-formats="altInputFormats" /> 
                                </span> 
                                <span class="input-group-btn"> 
                                    <button type="button" class="btn btn-default" ng-click="open1()"><i class="glyphicon glyphicon-calendar"></i></button> 
                                </span> 
                            <uib-timepicker ng-model="tm" ng-change="changed()" hour-step="hstep" minute-step="mstep"></uib-timepicker> 
                            </p> 
                        </div> 
                    </div></div> 
                <div class="form-group"> 
                    <button class="btn btn-primary" type="submit">Reserve</button> 
                </div> 
            </form></br></br> 
        </div> 
    </div> 
</div> 

登录页面

当用户在选择预订日期和时间后点击餐厅详情页面上的“预订”按钮时,餐厅详情页面会检查用户是否已经登录。如果用户没有登录,那么将显示登录页面。它的样子如下截图所示:

登录页面

我们不是从服务器上验证用户。相反,我们只是将用户名填充到会话存储和根作用域中,以实现流程。

一旦用户登录,他们将被重定向回带有持久状态的同一预订页面。然后,用户可以继续预订。登录页面基本上使用两个文件:login.htmllogin.js

登录.html

login.html模板只包含两个输入字段,分别是用户名和密码,以及登录按钮和取消链接。取消链接重置表单,登录按钮提交登录表单。

在这里,我们使用LoginCtrlng-controller指令。登录表单使用ng-submit指令提交,该指令调用LoginCtrlsubmit函数。首先使用ng-model指令收集输入值,然后使用它们的相应属性 - _email_password提交:

<div ng-controller="LoginCtrl as loginC" style="max-width: 300px"> 
    <h3>Login</h3> 
    <div class="form-container"> 
        <form ng-submit="loginC.submit(_email, _password)"> 
            <div class="form-group"> 
                <label for="username" class="sr-only">Username</label> 
                <input type="text" id="username" class="form-control" placeholder="username" ng-model="_email" required autofocus /> 
            </div> 
            <div class="form-group"> 
                <label for="password" class="sr-only">Password</label> 
                <input type="password" id="password" class="form-control" placeholder="password" ng-model="_password" /> 
            </div> 
            <div class="form-group"> 
                <button class="btn btn-primary" type="submit">Login</button> 
                <button class="btn btn-link" ng-click="loginC.cancel()">Cancel</button> 
            </div> 
        </form> 
    </div> 
</div> 

登录.js

登录模块定义在login.js文件中,该文件使用module函数包含和加载依赖项。使用config函数定义登录状态,该函数接收包含url控制器templateUrl属性的 JSON 对象。

controller内部,我们定义了取消提交操作,这些操作是从login.html模板中调用的:

angular.module('otrsApp.login', [ 
    'ui.router', 
    'ngStorage' 
]) 
        .config(function config($stateProvider) { 
            $stateProvider.state('login', { 
                url: '/login', 
                controller: 'LoginCtrl', 
                templateUrl: 'login/login.html' 
            }); 
        }) 
        .controller('LoginCtrl', function ($state, $scope, $rootScope, $injector) { 
            var $sessionStorage = $injector.get('$sessionStorage'); 
            if ($sessionStorage.currentUser) { 
                $state.go($rootScope.fromState.name, $rootScope.fromStateParams); 
            } 
            var controller = this; 
            var log = $injector.get('$log'); 
            var http = $injector.get('$http'); 

            $scope.$on('$destroy', function destroyed() { 
                log.debug('LoginCtrl destroyed'); 
                controller = null; 
                $scope = null; 
            }); 
            this.cancel = function () { 
                $scope.$dismiss; 
                $state.go('restaurants'); 
            } 
            console.log("Current --> " + $state.current); 
            this.submit = function (username, password) { 
                $rootScope.currentUser = username; 
                $sessionStorage.currentUser = username; 
                if ($rootScope.fromState.name) { 
                    $state.go($rootScope.fromState.name, $rootScope.fromStateParams); 
                } else { 
                    $state.go("restaurants"); 
                } 
            }; 
        });

预订确认

一旦用户登录并点击了预订按钮,餐厅控制器将显示带有确认信息的弹窗,如下面的截图所示:

餐厅详情页面带预订确认

设置网络应用程序

因为我们计划使用最新的技术堆栈来开发我们的 UI 应用程序,我们将使用 Node.js 和npmNode.js 包管理器),它们为开发服务器端 JavaScript 网络应用程序提供了开源运行环境。

我建议您浏览这一部分。它将向您介绍 JavaScript 构建工具和堆栈。然而,如果您已经了解 JavaScript 构建工具,或者不想探索它们,您可以跳过这一部分。

Node.js 基于 Chrome 的 V8 JavaScript 引擎,并使用事件驱动、非阻塞 I/O,使其轻量级且高效。Node.js 的默认包管理器 npm 是最大的开源库生态系统。它允许安装 Node.js 程序,并使指定和链接依赖项变得更容易:

  1. 首先,如果尚未安装,我们需要安装 npm。这是一个先决条件。你可以通过访问链接来安装 npm:docs.npmjs.com/getting-started/installing-node

  2. 要检查 npm 是否正确设置,请在命令行界面(CLI)上执行npm -v命令。它应该在输出中返回已安装的 npm 版本。我们可以切换到 NetBeans 来创建一个新的 AngularJS JS HTML5 项目。在本章撰写之时,我使用的是 NetBeans 8.1。

  3. 导航到文件|新建项目。一个新项目对话框应该会出现。选择“HTML5/JavaScript”在类别列表中,以及“HTML5/JS 应用程序”在项目选项中,如下图所示:

NetBeans - 新 HTML5/JavaScript 项目

  1. 点击“下一步”按钮。然后,在“名称和位置”对话框中输入项目名称、项目位置、

    和在项目文件夹中点击

    下一步按钮:

NetBeans 新项目 - 名称和位置

  1. 在“网站模板”对话框中,选择“下载在线模板”选项下的 AngularJS Seed 项目,然后点击“下一步”按钮。AngularJS Seed 项目可在以下网址找到:github.com/angular/angular-seed

NetBeans 新项目 - 网站模板

  1. 在“工具”对话框中,选择创建package.json、创建bower.json和创建gulpfile.js。我们将使用 gulp 作为我们的构建工具。Gulp 和 Grunt 是 JS 最流行的构建框架之二。作为一个 Java 程序员,你可以将这些工具与 Ant 相关联。两者都有自己的优点。如果你愿意,你也可以使用Gruntfile.js作为构建工具:

Netbeans 新项目 - 工具

  1. 现在,一旦你点击完成,你就可以看到 HTML5/JS 应用程序目录和文件。目录结构将如下所示:

AngularJS 种子目录结构

  1. 如果你的项目中所有必需的依赖项都没有正确配置,你还会看到一个感叹号。你可以通过右键点击项目,然后选择“解决项目问题”选项来解决项目问题:

解决项目问题对话框

  1. 理想情况下,NetBeans 会在你点击“解决...”按钮时解决项目问题。

  2. 你还可以通过为一些 JS 模块(如 Bower、gulp 和 Node)提供正确的路径来解决几个问题:

  • Bower:用于管理 OTRS 应用程序的 JavaScript 库

  • Gulp:任务运行器,用于构建我们的项目,如 ANT

  • Node:用于执行我们的服务器端 OTRS 应用程序

Bower 是一个依赖管理工具,它像 npm 一样工作。npm 用于安装 Node.js 模块,而 Bower 用于管理您的网络应用程序的库/组件。

  1. 点击工具菜单并选择选项。现在,设置 Bower、gulp 和 Node.js 的路径,如以下屏幕截图所示。要设置 Bower 路径,请点击 Bower 标签,如下面的屏幕截图所示,并更新路径:

设置 Bower 路径

  1. 要设置 Gulp 路径,请点击 Gulp 标签,如下面的屏幕截图所示,并更新路径:

设置 Gulp 路径

  1. 设置 Node 路径,请点击 Node.js 标签,如以下屏幕截图所示,并更新路径:

设置 Node 路径

  1. 完成后,package.json 将如下所示。我们对一些条目的值进行了修改,如名称、描述、依赖项等:
{ 
  "name": "otrs-ui", 
  "private": true, 
  "version": "1.0.0", 
  "description": "Online Table Reservation System", 
  "main": "index.js", 
  "license": "MIT", 
  "dependencies": { 
    "coffee-script": "¹.10.0", 
    "del": "¹.1.1", 
    "gulp-angular-templatecache": "¹.9.1", 
    "gulp-clean": "⁰.3.2", 
    "gulp-connect": "³.2.3", 
    "gulp-file-include": "⁰.13.7", 
    "gulp-sass": "².3.2", 
    "gulp-util": "³.0.8", 
    "run-sequence": "¹.2.2" 
  }, 
  "devDependencies": { 
    "coffee-script": "*", 
    "gulp-sass": "*", 
    "bower": "¹.3.1", 
    "http-server": "⁰.6.1", 
    "jasmine-core": "².3.4", 
    "karma": "~0.12", 
    "karma-chrome-launcher": "⁰.1.12", 
    "karma-firefox-launcher": "⁰.1.6", 
    "karma-jasmine": "⁰.3.5", 
    "karma-junit-reporter": "⁰.2.2", 
    "protractor": "².1.0", 
    "shelljs": "⁰.2.6" 
  }, 
  "scripts": { 
    "postinstall": "bower install", 
    "prestart": "npm install", 
    "start": "http-server -a localhost -p 8000 -c-1", 
    "pretest": "npm install", 
    "test": "karma start karma.conf.js", 
    "test-single-run": "karma start karma.conf.js  --single-run", 
    "preupdate-webdriver": "npm install", 
    "update-webdriver": "webdriver-manager update", 
    "preprotractor": "npm run update-webdriver", 
    "protractor": "protractor e2e-tests/protractor.conf.js", 
    "update-index-async": "node -e \"require('shelljs/global'); sed('-i', /\\/\\/@@NG_LOADER_START@@[\\s\\S]*\\/\\/@@NG_LOADER_END@@/, '//@@NG_LOADER_START@@\\n' + sed(/sourceMappingURL=angular-loader.min.js.map/,'sourceMappingURL=bower_components/angular-loader/angular-loader.min.js.map','app/bower_components/angular-loader/angular-loader.min.js') + '\\n//@@NG_LOADER_END@@', 'app/index-async.html');\"" 
  } 

}
  1. 然后,我们将更新bower.json,如下面的代码片段所示:
{ 
    "name": "OTRS-UI", 
    "description": "OTRS-UI", 
    "version": "0.0.1", 
    "license": "MIT", 
    "private": true, 
    "dependencies": { 
        "AngularJS": "~1.5.0", 
        "AngularJS-ui-router": "~0.2.18", 
        "AngularJS-mocks": "~1.5.0", 
        "AngularJS-bootstrap": "~1.2.1", 
        "AngularJS-touch": "~1.5.0", 
        "bootstrap-sass-official": "~3.3.6", 
        "AngularJS-route": "~1.5.0", 
        "AngularJS-loader": "~1.5.0", 
        "ngstorage": "⁰.3.10", 
        "AngularJS-resource": "¹.5.0", 
        "html5-boilerplate": "~5.2.0" 
    } 
} 
  1. 接下来,我们将修改.bowerrc文件,如下面的代码所示,以指定 Bower 将在其中存储bower.json中定义的组件的目录。我们将 Bower 组件存储在应用程序目录下:
{ 
  "directory": "app/bower_components" 
} 
  1. 接下来,我们将设置gulpfile.js。我们将使用CoffeeScript定义gulp任务。因此,我们只需在gulpfile.js中定义CoffeeScript,实际的任务将在gulpfile.coffee文件中定义。让我们看看gulpfile.js文件的内容:
require('coffee-script/register'); 
require('./gulpfile.coffee'); 
  1. 在此步骤中,我们将定义gulp配置。我们使用CoffeeScript定义gulp文件。用CoffeeScript编写的gulp文件的名称是gulpfile.coffee。默认任务定义为default_sequence
default_sequence = ['connect', 'build', 'watch']

让我们了解default_sequence任务执行的内容:

  • 根据定义的default_sequence任务,首先它会连接到服务器,然后构建网络应用程序,并监视更改。监视将帮助我们在代码中做出更改并在 UI 上立即显示。

  • 此脚本中最重要的任务是connectwatch。其他任务不言自明。所以,让我们深入了解一下它们。

  • gulp-connect:这是一个gulp插件,用于运行网络服务器。它还支持实时重新加载。

  • gulp-watch:这是一个文件监视器,使用 chokidar,并发出 vinyl 对象(描述文件的路径和内容的对象)。简而言之,我们可以说gulp-watch监视文件更改并触发任务。

gulpfile.coffee可能看起来像这样:

gulp          = require('gulp') 
gutil         = require('gulp-util') 
del           = require('del'); 
clean         = require('gulp-clean') 
connect       = require('gulp-connect') 
fileinclude   = require('gulp-file-include') 
runSequence   = require('run-sequence') 
templateCache = require('gulp-AngularJS-templatecache') 
sass          = require('gulp-sass') 

paths = 
  scripts: 
    src: ['app/src/scripts/**/*.js'] 
    dest: 'public/scripts' 
  scripts2: 
    src: ['app/src/views/**/*.js'] 
    dest: 'public/scripts' 
  styles: 
    src: ['app/src/styles/**/*.scss'] 
    dest: 'public/styles' 
  fonts: 
    src: ['app/src/fonts/**/*'] 
    dest: 'public/fonts' 
  images: 
    src: ['app/src/images/**/*'] 
    dest: 'public/images' 
  templates: 
    src: ['app/src/views/**/*.html'] 
    dest: 'public/scripts' 
  html: 
    src: ['app/src/*.html'] 
    dest: 'public' 
  bower: 
    src: ['app/bower_components/**/*'] 
    dest: 'public/bower_components' 

#copy bower modules to public directory 
gulp.task 'bower', -> 
  gulp.src(paths.bower.src) 
  .pipe gulp.dest(paths.bower.dest) 
  .pipe connect.reload() 

#copy scripts to public directory 
gulp.task 'scripts', -> 
  gulp.src(paths.scripts.src) 
  .pipe gulp.dest(paths.scripts.dest) 
  .pipe connect.reload() 

#copy scripts2 to public directory 
gulp.task 'scripts2', -> 
  gulp.src(paths.scripts2.src) 
  .pipe gulp.dest(paths.scripts2.dest) 
  .pipe connect.reload() 

#copy styles to public directory 
gulp.task 'styles', -> 
  gulp.src(paths.styles.src) 
  .pipe sass() 
  .pipe gulp.dest(paths.styles.dest) 
  .pipe connect.reload() 

#copy images to public directory 
gulp.task 'images', -> 
  gulp.src(paths.images.src) 
  .pipe gulp.dest(paths.images.dest) 
  .pipe connect.reload() 

#copy fonts to public directory 
gulp.task 'fonts', -> 
  gulp.src(paths.fonts.src) 
  .pipe gulp.dest(paths.fonts.dest) 
  .pipe connect.reload() 

#copy html to public directory 
gulp.task 'html', -> 
  gulp.src(paths.html.src) 
  .pipe gulp.dest(paths.html.dest) 
  .pipe connect.reload() 

#compile AngularJS template in a single js file 
gulp.task 'templates', -> 
  gulp.src(paths.templates.src) 
  .pipe(templateCache({standalone: true})) 
  .pipe(gulp.dest(paths.templates.dest)) 

#delete contents from public directory 
gulp.task 'clean', (callback) -> 
  del ['./public/**/*'], callback; 

#Gulp Connect task, deploys the public directory 
gulp.task 'connect', -> 
  connect.server 
    root: ['./public'] 
    port: 1337 
    livereload: true 

gulp.task 'watch', -> 
  gulp.watch paths.scripts.src, ['scripts'] 
  gulp.watch paths.scripts2.src, ['scripts2'] 
  gulp.watch paths.styles.src, ['styles'] 
  gulp.watch paths.fonts.src, ['fonts'] 
  gulp.watch paths.html.src, ['html'] 
  gulp.watch paths.images.src, ['images'] 
  gulp.watch paths.templates.src, ['templates'] 

gulp.task 'build', ['bower', 'scripts', 'scripts2', 'styles', 'fonts', 'images', 'templates', 'html'] 

default_sequence = ['connect', 'build', 'watch'] 

gulp.task 'default', default_sequence 

gutil.log 'Server started and waiting for changes' 
  1. 一旦我们准备好前面的更改,我们将使用以下命令安装gulp
npm install --no-optional gulp

要在 Windows 环境中安装 Windows 构建工具,请运行以下命令:

npm install --global --production windows-build-tools 
  1. 此外,我们将使用以下命令安装其他gulp库,如gulp-cleangulp-connect等:
npm install --save --no-optional gulp-util gulp-clean gulp-connect gulp-file-include run-sequence gulp-angular-templatecache gulp-sass del coffee-script
    • 现在,我们可以使用以下命令安装bower.json文件中定义的 Bower 依赖项:
bower install --s
  • 如果尚未安装 Bower,请使用以下命令安装:
npm install -g bower
  • 前一条命令的输出将如下所示:

  • 示例输出 - bower install --s
    • 这里是设置的最后一步。在这里,我们将确认目录结构应如下所示。我们将把srcpublished构件(在./public目录中)作为独立的目录保存。因此,下面的目录结构与默认的 AngularJS 种子项目不同:
+---app 
|   +---bower_components 
|   |   +---AngularJS 
|   |   +---AngularJS-bootstrap 
|   |   +---AngularJS-loader 
|   |   +---AngularJS-mocks 
|   |   +---AngularJS-resource 
|   |   +---AngularJS-route 
|   |   +---AngularJS-touch 
|   |   +---AngularJS-ui-router 
|   |   +---bootstrap-sass-official 
|   |   +---html5-boilerplate 
|   |   +---jquery 
|   |   \---ngstorage 
|   +---components 
|   |   \---version 
|   +---node_modules 
|   +---public 
|   |   \---css 
|   \---src 
|       +---scripts 
|       +---styles 
|       +---views 
+---e2e-tests 
+---nbproject 
|   \---private 
+---node_modules 
+---public 
|   +---bower_components 
|   +---scripts 
|   +---styles 
\---test

- 参考资料

  • 以下是一些推荐阅读的参考资料:

- 摘要

  • 在本章中,我们了解到了新的动态网络应用开发。

  • 多年来,它已经发生了彻底的变化。网络应用的前端完全使用纯 HTML 和 JavaScript 开发,而不是使用任何服务器端技术,如 JSP、servlets、ASP 等。使用 JavaScript 开发的 UI 应用程序现在有其自己的开发环境,如 npm、Bower 等。我们探讨了 AngularJS 框架来开发我们的网络应用程序。它通过提供内置特性和对 Bootstrap 以及处理 AJAX 调用的$http服务的支持,使事情变得更容易。

  • 我希望您已经掌握了 UI 开发的概述以及现代应用程序是如何与服务器端微服务集成开发的。在下一章中,我们将学习微服务设计的最优实践和常见原则。本章将提供有关使用行业实践和示例进行微服务开发的详细信息。它还将包含微服务实施出错的示例以及如何避免这些问题。

第九章:最佳实践和通用原则

在你为了获得开发微服务样本项目的经验而付出了艰辛努力之后,你可能会想知道如何避免常见错误并改进开发基于微服务的产品和服务的过程。我们可以遵循这些原则或指南来简化微服务开发的过程并避免/减少潜在的限制。我们将在本章重点关注这些关键概念。

本章分为以下三个部分:

  • 概述和心态

  • 最佳实践和原则

  • 微服务框架和工具

概述和心态

你可以在新旧产品和服务的背景下实现微服务-based 设计。与认为从头开始开发和设计新系统比修改一个已经在运行的现有系统更容易的观点相反,每种方法都有其各自的挑战和优点。

例如,由于新产品或服务不存在现有的系统设计,你有自由和灵活性去设计系统,而不必考虑其影响。然而,你对于新系统的功能和系统要求并不清晰,因为这些随着时间成熟并逐渐成形。另一方面,对于成熟的产品和服务,你对功能和系统要求有详细的知识和信息。然而,你有一个挑战,那就是减轻设计更改带来的风险影响。因此,当涉及到将生产系统从单体应用更新为微服务时,你需要比如果你正在构建一个系统时计划得更好。

从零开始。

有经验的成功的软件设计专家和架构师总是评估利弊,并且对现有运行系统做任何更改时都持谨慎态度。绝不应该仅仅因为某种设计可能很酷或者时髦就对其进行更改。因此,如果你想将现有生产系统的设计更新为微服务,在做出这个决定之前你需要评估所有的利弊。

我相信单体系统提供一个很好的平台升级到成功的微服务设计。显然,我们这里不讨论成本。你对现有系统和功能有足够的了解,这使你能够将现有系统分割并基于功能以及这些如何相互交互构建微服务。另外,如果你的单体产品已经以某种方式模块化,那么通过暴露 API 而不是应用程序二进制接口ABI)直接转换微服务可能是实现微服务架构的最简单方式。成功的基于微服务的系统更依赖于微服务和它们之间的交互协议,而不是其他任何东西。

说到这里,并不意味着如果您从头开始,就不能拥有一个成功的基于微服务的系统。然而,建议基于单体设计的新项目,这为您提供了系统的视角和理解功能。它允许您快速找到瓶颈,并指导您识别任何可以用微服务开发的有潜力的特性。在这里,我们没有讨论项目的规模,这是另一个重要的因素。我们将在下一节中讨论这一点。

在当今的云计算时代和敏捷开发世界中,从任何更改到更改上线通常只需要一个小时。在当今竞争激烈的环境中,每个组织都希望拥有快速将功能交付给用户的优势。持续开发、集成和部署是生产交付过程的一部分,这是一个完全自动化的过程。

如果您提供基于云的产品或服务,那么基于微服务的系统使团队能够敏捷地响应修复任何问题或向用户提供新功能。

因此,在决定从头开始一个新的基于微服务的项目,或者计划将现有单体系统的设计升级为基于微服务的系统之前,您需要评估所有的利弊。您必须倾听并理解团队分享的不同想法和观点,并采取谨慎的方法。

最后,我想分享拥有更好的流程和高效系统对于成功生产系统的重要性。拥有基于微服务的系统并不能保证成功的生产系统,而单体应用程序并不意味着在今天这个时代你不能拥有一个成功的生产系统。Netflix,一个基于微服务的云视频租赁服务,和 Etsy,一个单体电子商务平台,都是成功生产系统的例子(在章节的参考文献部分,您可以看到一个有趣的 Twitter 讨论链接)。因此,流程和敏捷也是成功生产系统的关键。

最佳实践和原则

正如我们在第一章所学习到的,微服务是一种实现面向服务架构SOA)的轻量级风格。除此之外,微服务并没有严格定义,这给了你开发微服务的灵活性,按照你想要的和需求来开发。同时,你需要确保遵循一些标准实践和原则,使你的工作更容易,并成功实施基于微服务的架构。

纳米服务、规模和单体

您项目中的每个微服务都应该体积小,并执行一个功能或特性(例如,用户管理),独立到足以自行执行该功能。

来自 Mike Gancarz(设计 X Window 系统的成员)的以下两句话,定义了 Unix 哲学的一个首要原则,也适用于微服务范式:

“小即是美。”

“让每个程序做好一件事。”

现在,我们如何定义在今天这个时代的大小,当你有一个框架(例如 Finangle)来减少代码行数LOC)时?此外,许多现代语言,如 Python 和 Erlang,都较为简洁。这使得决定是否要将此代码微服务化变得困难。

显然,你可能为少量的代码行实现一个微服务;这实际上不是一个微服务,而是一个纳米服务。

Arnon Rotem-Gal-Oz 将纳米服务定义如下:

“纳米服务是一个反模式,其中服务过于细粒度。纳米服务是一个其开销(通信、维护等)超过其效用的服务。”

因此,基于功能设计微服务总是有意义的。领域驱动设计使在领域层面定义功能变得更容易。

如前所述,您项目的规模是在决定是否实施微服务或确定您想要为项目拥有的微服务数量时的一个关键因素。在一个简单的小型项目中,使用单体架构是有意义的。例如,基于我们在第三章学到的领域设计,领域驱动设计,你会清楚地了解你的功能性需求,并使事实可用以绘制各种功能或特性之间的边界。例如,在我们已经实施的示例项目(在线表格预订系统;OTRS)中,只要你不希望向客户暴露 API,或者你不想将其作为 SaaS 使用,或者在你做出决定之前有许多类似的参数需要评估,使用单体设计开发相同的项目是非常容易的。

您可以稍后将在单体项目中迁移到微服务设计,当时机到来时。因此,重要的是您应该以模块化方式开发单体项目,并在每个层次和层面上实现松耦合,并确保不同功能和特性之间有预定义的接触点和边界。此外,您的数据源(如数据库)应相应地设计。即使您不打算将项目迁移到基于微服务的系统,这也将使故障修复和功能改进更容易实施。

关注前面的点将减轻您在迁移到微服务时可能遇到的任何可能的困难。

通常,大型或复杂的项目应该使用基于微服务的架构进行开发,因为它提供了许多优势,如前几章所讨论的。

我甚至建议将你的初始项目开发为单块应用;一旦你更好地理解了项目的功能和项目复杂性,然后你再将其迁移到微服务。理想情况下,一个开发好的初始原型应该为你提供功能边界,这将使你能够做出正确的选择。

持续集成和部署

你必须有一个持续集成和部署的过程。它让你能够更快地交付更改并尽早发现错误。因此,每个服务应该有自己的集成和部署过程。此外,它必须是自动化的。有许多工具可供选择,如 Teamcity、Jenkins 等,这些工具被广泛使用。它帮助你自动化构建过程——这可以尽早捕获构建失败,特别是当你将你的更改与主分支(如任何发布分支/标签或主分支)集成时。

你还可以将你的测试集成到每个自动化集成和部署过程中。集成测试测试系统的不同部分之间的交互,如两个接口(API 提供者和消费者)之间,或系统中的不同组件或模块之间,如 DAO 和数据库之间等。集成测试很重要,因为它测试模块之间的接口。首先,在孤立状态下测试单个模块。然后,执行集成测试以检查组合行为并验证需求是否正确实现。因此,在微服务中,集成测试是验证 API 的关键工具。我们将在下一节中详细介绍这一点。

最后,你可以在 CD(持续部署)机器上看到主分支的最新更改,该过程在这里部署构建。

这个过程并不会到此结束:你可以创建一个容器,比如 Docker,然后将其交给你的 WebOps 团队,或者有一个单独的过程,将其送到一个配置好的位置或者部署到 WebOps 阶段环境。从这里,一旦得到指定权限的批准,它就可以直接部署到你的生产系统。

系统/端到端测试自动化

测试是任何产品和服务的交付中的一个非常重要的部分。你不希望向客户交付有缺陷的应用程序。在过去,当瀑布模型流行时,一个组织在向客户交付之前,测试阶段通常需要 1 到 6 个月或更长时间。近年来,在敏捷过程变得流行之后,更加重视自动化。与先前的点测试类似,自动化也是强制性的。

无论你是否遵循测试驱动开发TDD),我们必须要有系统或端到端的自动化测试。测试你的业务场景非常重要,端到端测试也同样如此,它可能从你的 REST 调用开始,到数据库检查,或者从 UI 应用程序开始,到数据库检查。

如果你有公开的 API,测试你的 API 也很重要。

这样做可以确保任何更改都不会破坏任何功能,并确保无缝、无 bug 的生产交付。如上节所述,每个模块都通过单元测试进行隔离测试,以检查一切是否按预期工作,然后在不同模块之间执行集成测试,以检查预期的组合行为并验证需求是否正确实现。集成测试后,执行功能测试,以验证功能和特性需求。

所以,如果单元测试确保孤立状态下单个模块运行良好,那么集成测试确保不同模块之间的交互按预期工作。如果单元测试正常工作,那么集成测试失败的概率大大降低。同样,集成测试确保功能测试很可能成功。

假设我们总是保持所有类型的测试更新,无论是单元级测试还是端到端的测试场景。

自我监控和日志记录

一个微服务应当提供关于自身及其所依赖的各种资源状态的服务信息。服务信息包括诸如处理请求的平均、最小和最大时间、成功和失败的请求数量、能够追踪请求、内存使用情况等统计数据。

在 2015 年的 Glue Conference(Glue Con)上,Adrian Cockcroft 强调了几个对于监控微服务非常重要的实践。其中大多数对于任何监控系统都是有效的:

  • 在分析指标意义的代码上花费更多时间,而不是在收集、移动、存储和显示指标的代码上。这不仅有助于提高生产力,还提供重要的参数来微调微服务并提高系统效率。想法是开发更多的分析工具,而不是开发更多的监控工具。

  • 显示延迟的指标需要小于人类的注意力跨度。这意味着根据 Adrian 的说法,小于 10 秒。

  • 验证您的测量系统具有足够的准确性和精度。收集响应时间的直方图。

  • 准确的数据使决策更快,并允许您进行微调,直到达到精确度级别。他还建议,最好显示响应时间的图表是直方图。

  • 监控系统需要比被监控的系统更具可用性和可扩展性。

  • 这个说法说明了一切:你不能依赖一个本身不稳定或不是 24/7 可用的系统。

  • 针对分布式、短暂、云原生、容器化的微服务进行优化。

  • 将指标适合模型以理解关系。

监控是微服务架构的关键组成部分。根据项目规模,你可能会有几十个到几千个微服务(对于一个大企业的重大项目来说确实如此)。即使是为了扩展和高可用性,组织也会为每个微服务创建一个集群或负载均衡的池/容器,甚至根据版本为每个微服务创建单独的池。最终,这增加了你需要监控的资源数量,包括每个微服务实例。此外,重要的是你有一个流程,以便在任何事情出错时立即知道,或者更好的是,在事情出错之前收到警告通知。因此,构建和使用微服务架构的有效和高效的监控至关重要。Netflix 使用诸如 Netflix Atlas(处理 12 亿个指标的实时运营监控)、Security Monkey(用于监控基于 AWS 环境的网络安全)、Scumblr(情报收集工具)和 FIDO(用于分析事件和自动事件报告)等工具进行安全监控。

日志是微服务中不应忽视的重要方面。有效的日志记录至关重要。由于可能有 10 个或更多的微服务,管理日志记录是一项巨大的任务。

对于我们的示例项目,我们使用了映射诊断上下文MDC)日志记录,这在某种程度上足以满足单个微服务的日志记录。然而,我们还需要整个系统或集中日志记录的日志记录。我们还需要日志的聚合统计数据。有一些工具可以完成这项工作,例如 Loggly 或 Logspout。

请求和生成的相关事件为您提供了请求的整体视图。对于任何事件和请求的跟踪,将事件和请求与服务 ID 和请求 ID 分别关联非常重要。你还可以将事件的内容,如消息、严重性、类名等,与服务 ID 相关联。

每个微服务单独的数据存储

如果你还记得,微服务最重要的特征之一是你可以了解微服务如何与其他微服务隔离运行,最常见的是作为独立的应用程序。

遵循这一规则,建议你不要在多个微服务之间使用相同的数据库或任何其他数据存储。在大型项目中,你可能有不同的团队在同一个项目中工作,你希望每个微服务都能选择最适合自己的数据库。

现在,这也带来了一些挑战。

例如,以下内容与可能在同一项目中工作在不同微服务上的团队相关,如果该项目共享相同的数据库结构。一种可能性是,一个微服务的更改可能会影响另一个微服务的模型。在这种情况下,一个更改可能会影响依赖性微服务,所以你还需要更改依赖性模型结构。

为了解决这个问题,微服务应该基于一个 API 驱动的平台进行开发。每个微服务都会暴露出自己的 API,其他微服务可以消费这些 API。因此,你还需要开发 API,这是不同微服务集成的必要条件。

同样,由于不同的数据存储,实际项目数据也分布在多个数据存储中,这使得数据管理更加复杂,因为不同的存储系统更容易失去同步或变得不一致,外键也可能意外地改变。为了解决这个问题,你需要使用主数据管理MDM)工具。MDM 工具在后台运行,如果发现任何不一致性,会进行修复。对于 OTRS 示例,它可能会检查存储预订请求 ID 的每个数据库,以验证它们中都存在相同的 ID(换句话说,任何数据库中都没有缺失或额外的 ID)。市场上的 MDM 工具包括 Informatica、IBM MDM 高级版、Oracle Siebel UCM、Postgres(主流复制)、mariadb(主/主配置)等。

如果现有的产品都不符合你的要求,或者你对任何专有产品都不感兴趣,那么你可以自己编写。目前,API 驱动的开发和平台减少了这种复杂性;因此,微服务沿着 API 平台开发是非常重要的。

交易边界

我们在第三章中讨论了领域驱动设计概念,领域驱动设计。如果你没有完全掌握它,请复习这一部分,因为它能让你从垂直角度理解状态。由于我们关注的是基于微服务的设计,结果是我们有一个系统系统,每个微服务代表一个系统。在这种环境中,在任何给定时间找到整个系统的状态是非常具有挑战性的。如果你熟悉分布式应用,那么你可能会在这种环境中对状态感到舒适。

确立交易边界非常重要,这些边界描述了在任何给定时间哪个微服务拥有一个消息。你需要一种或一种参与事务、交易路由、错误处理程序、幂等消费者和补偿操作的方式。确保跨异质系统的一致性行为并非易事,但市场上有一些工具可以为你完成这项工作。

例如,Camel 具有出色的事务功能,可以帮助开发者轻松创建具有事务行为的服务。

微服务框架和工具

最好还是不要重新发明轮子。因此,我们想探讨一下市场上已经有哪些工具,并提供使微服务开发和部署更简单的平台、框架和特性。

在整个书籍中,我们广泛使用了 Spring Cloud,原因相同:它提供了构建微服务所需的所有工具和平台。Spring Cloud 使用 Netflix 开源软件OSS)。让我们来探索一下 Netflix OSS——一个完整的套餐。

我还添加了关于每个工具如何帮助构建良好的微服务架构的简要概述。

Netflix 开源软件(OSS)

Netflix OSS 中心是 Java 基础微服务开源项目中最受欢迎和广泛使用的开源软件。世界上最成功的视频租赁服务依赖于它。Netflix 有超过 4000 万用户,并在全球范围内使用。Netflix 是一个纯基于云的解决方案,基于微服务架构开发。可以说,每当有人谈论微服务时,Netflix 是首先出现在脑海中的名字。让我们讨论它提供的各种工具。在开发示例 OTRS 应用程序时,我们已经讨论了许多工具。然而,还有一些我们没有探索过。在这里,我们只对每个工具进行概述,而不是深入讨论。这将为您提供微服务架构的实用特性和在云中使用它的整体概念。

构建 - Nebula

Netflix Nebula 是一组使您使用 Gradle(类似 Maven 的构建工具)构建微服务变得更加容易的 Gradle 插件。对于我们的示例项目,我们使用了 Maven,因此我们在这本书中没有机会探索 Nebula。然而,探索它是很有趣的。对于开发人员来说,Nebula 最重要的功能是消除了 Gradle 构建文件中的样板代码,这使得开发者可以专注于编码。

拥有一个好的构建环境,特别是 CI/CD(持续集成和持续部署)对于微服务开发和与敏捷开发保持一致是必须的。Netflix Nebula 使您的构建变得更容易、更高效。

部署和交付 - Spinnaker 与 Aminator

一旦您的构建准备好,您希望将该构建移动到 亚马逊网络服务AWS)EC2。Aminator 创建并打包构建的镜像,形式为 亚马逊机器镜像AMI)。Spinnaker 然后将这些 AMI 部署到 AWS。

Spinnaker 是一个高速度和效率的持续交付平台,用于发布代码更改。Spinnaker 还支持其他云服务,例如 Google 计算机引擎和 Cloud Foundry。

如果你想将最新的微服务构建部署到例如 EC2 的云环境中,Spinnaker 和 Aminator 可以帮助你以自主的方式完成。

服务注册和发现 - Eureka

如我们在本书中所探讨的,Eureka 提供了一个负责微服务注册和发现的服务。除此之外,Eureka 还用于负载均衡中间层(托管不同微服务的进程)。Netflix 也使用 Eureka,以及其他工具,如 Cassandra 或 memcached,以提高其整体可用性。

微服务架构中必须要有服务注册与发现。Eureka 就是为此目的而设计的。请参阅第四章,实现微服务,以获取有关 Eureka 的更多信息。

服务通信 - Ribbon

如果进程间或服务间没有通信,微服务架构就毫无用处。Ribbon 应用提供了这一特性。Ribbon 与 Eureka 一起实现负载均衡,与 Hystrix 一起实现故障容忍或断路器操作。

Ribbon 还支持除了 HTTP 以外的 TCP 和 UDP 协议,并提供这些协议支持异步和响应式模型。它还提供了缓存和批量处理功能。

由于您将在项目中拥有许多微服务,您需要一种使用进程间或服务间通信处理信息的方法。Netflix 为这一目的提供了 Ribbon 工具。

断路器 - Hystrix

Hystrix 工具用于断路器操作,即延迟和故障容忍。因此,Hystrix 阻止级联失败。Hystrix 执行实时操作,监控服务和属性变化,并支持并发。

断路器或故障容忍是任何项目的重要概念,包括微服务。一个微服务的失败不应该使您的整个系统停止;为了防止这种情况发生,并在失败时向客户提供有意义的信息,这是 Netflix Hystrix 的职责。

边缘(代理)服务器 - Zuul

Zuul 是一个边缘服务器或代理服务器,为 UI 客户端、Android/iOS 应用程序或任何第三方消费者提供 API。从概念上讲,它是外部应用程序的门户。

Zuul 允许动态路由和监控请求。它还执行安全操作,如身份验证。它可以识别每个资源的身份验证要求,并拒绝任何不满足它们的请求。

您需要一个边缘服务器或 API 网关来处理您的微服务。Netflix Zuul 提供了这一特性。请参阅第五章,部署与测试,以获取更多信息。

操作监控 - Atlas

Atlas 是一个操作监控工具,提供近实时的时间序列数据维度信息。它捕获操作智能,提供系统内部当前发生情况的图片。它具有内存数据存储功能,允许它快速收集和报告大量指标。目前,它为 Netflix 处理了 13 亿个指标。

Atlas 是一个可扩展的工具。这就是为什么它现在可以处理 13 亿个指标,而几年前只有 100 万个指标。Atlas 不仅在读取数据方面提供可扩展性,而且在作为图表请求一部分进行聚合方面也提供可扩展性。

Atlas 使用了 Netflix Spectator 库来记录维度时间序列数据。

一旦你在云环境中部署了微服务,你就需要有一个监控系统来跟踪和监控所有的微服务。Netflix Atlas 为你完成了这项工作。

可靠性监控服务 - Simian Army

在云环境中,没有任何单一组件能保证 100% 的正常运行时间。因此,成功的微服务架构的要求是在一个云组件失败的情况下使整个系统可用。Netflix 开发了一个名为 Simian Army 的工具来避免系统失败。Simian Army 保持了云环境的安全、安全和高可用性。为了实现高可用性和安全性,它使用了各种服务(猴子)在云中产生各种故障、检测异常情况,并测试云应对这些挑战的能力。

它使用了以下服务(猴子),这些服务来自 Netflix 的博客:

  • Chaos Monkey:Chaos Monkey 是一个服务,它识别出一组系统,并在一组中的一个系统随机终止。该服务在受控的时间和间隔内运行。Chaos Monkey 只在正常工作时间运行,目的是让工程师保持警觉并能够响应。

  • Janitor Monkey:Janitor Monkey 是一个在 AWS 云中运行的服务,寻找未使用的资源进行清理。它可以扩展到与其他云提供商和云资源一起工作。服务的日程是可配置的。Janitor Monkey 通过对其应用一组规则来确定资源是否应该成为清理候选资源。如果任何规则确定资源是清理候选资源,Janitor Monkey 将标记该资源,并安排一个时间进行清理。在特殊情况下,在 Janitor Monkey 删除资源之前,你想保留一个未使用的资源更长时间,资源所有者会在清理时间前 configurable 天收到通知。

  • Conformity Monkey:Conformity Monkey 是一个在 AWS 云中运行的服务,寻找不符合最佳实践预定义规则的实例。它可以扩展到与其他云提供商和云资源一起工作。服务的日程是可配置的。如果任何规则确定该实例不符合,猴子会向实例所有者发送电子邮件通知。在某些特殊情况下,你可能想忽略特定的一致性规则的警告。

  • 安全猴:安全猴监控 AWS 账户中的策略更改和对不安全配置的警报。安全猴的主要目的是安全,尽管它也是一个跟踪潜在问题的有用工具,因为它本质上是一个变更跟踪系统。

成功的微服务架构确保你的系统始终运行,单个云组件的故障不应该导致整个系统失败。Simian Army 使用许多服务来实现高可用性。

AWS 资源监控 - Edda

在云环境中,一切都是动态的。例如,虚拟主机实例经常变化,IP 地址可能被各种应用程序重复使用,或者发生防火墙等相关变化。

Edda 是一个跟踪这些动态 AWS 资源的服务的服务。Netflix 将其命名为 Edda(意为北欧神话传说),因为它记录了云管理和部署的传说。Edda 使用 AWS API 轮询 AWS 资源并记录结果。这些记录允许你搜索并查看云如何随时间变化。例如,如果 API 服务器的任何主机出现问题,那么你需要找出那个主机是哪个团队负责的。

它提供以下功能:

  • 动态查询:Edda 提供了 REST API,并支持矩阵参数,提供字段选择器,让你只检索所需的数据。

  • 历史/变更:Edda 维护了所有 AWS 资源的历史记录。这些信息有助于分析停机的原因和影响。Edda 还可以提供关于资源当前和历史信息的不同的视图。在撰写本文时,它将信息存储在 MongoDB 中。

  • 配置:Edda 支持许多配置选项。通常,你可以从多个账户和多个区域轮询信息,并可以使用账户和区域的组合来指定位点。同样,它为 AWS、Crawler、Elector 和 MongoDB 提供不同的配置。

如果你正在使用 AWS 来托管基于微服务的产品,那么 Edda 起到了监控 AWS 资源的作用。

主机上性能监控 - Vector

Vector 是一个静态的网页应用程序,在网页浏览器内运行。它允许监控安装了性能共乘PCP)的主机的表现。Vector 支持 PCP 3.10+ 版本。PCP 收集指标并将其提供给 Vector。

它提供高分辨率的即时指标。这有助于工程师了解系统如何表现,并正确诊断性能问题。

Vector 是一个帮助你监控远程主机性能的工具。

分布式配置管理 - Archaius

Archaius 是一个分布式配置管理工具,它允许你执行以下操作:

  • 使用动态和类型化的属性。

  • 执行线程安全的配置操作。

  • 使用轮询框架检查属性变更。

  • 在配置的有序层次结构中使用回调机制。

  • 使用 JConsole 检查并操作属性,因为 Archaius 提供了 JMX MBean。

  • 当您有一个基于微服务的产品时,需要一个好的配置管理工具。Archaius 帮助在分布式环境中配置不同类型的属性。

Apache Mesos 调度器 - Fenzo

Fenzo 是 Apache Mesos 框架的 Java 编写的调度库。Apache Mesos 框架匹配并分配资源给待处理任务。以下是其关键特性:

  • 它支持长期运行的服务风格任务和批量任务。

  • 它可以自动扩展执行主机集群,基于资源需求。

  • 它支持插件,您可以根据需求创建。

  • 您可以监控资源分配失败,这使您能够调试根本原因。

成本和云利用率 - Ice

Ice 从成本和使用角度提供了云资源的鸟瞰视图。它提供了关于为不同团队提供的最新云资源配置信息,这对于优化云资源的使用非常有价值。

Ice 是一个 grail 项目。用户与 Ice UI 组件交互,该组件显示通过 Ice reader 组件发送的信息。reader 从由 Ice processor 组件生成的数据中获取信息。Ice processor 组件从详细的云账单文件中读取数据信息,并将其转换为 Ice reader 组件可读取的数据。

其他安全工具 - Scumblr 和 FIDO

与 Security Monkey 一起,Netflix OSS 还利用了 Scumblr 和完全集成的防御操作(FIDO)工具。

为了跟踪并保护您的微服务免受常规威胁和攻击,您需要一种自动化的方式来确保并监控您的微服务。Netflix Scumblr 和 FIDO 为您完成这项工作。

Scumblr

Scumblr 是一个基于 Ruby on Rails 的 Web 应用程序,允许您执行定期搜索,并针对识别的结果存储/采取行动。基本上,它收集情报,利用互联网范围内的针对性搜索浮出具体的安全问题以供调查。

Scumblr 利用 Workflowable 宝石允许为不同类型的结果设置灵活的工作流程。Scumblr 搜索使用称为“搜索提供者”的插件。它检查异常,例如以下异常。由于它是可扩展的,您可以添加尽可能多的异常:

  • 妥协的凭据

  • 漏洞/黑客讨论

  • 攻击讨论

  • 与安全相关的社交媒体讨论

完全集成的防御操作(FIDO)

FIDO 是一个安全编排框架,用于分析事件和自动化事件响应。它通过评估、评估和响应恶意软件来自动化事件响应过程。FIDO 的主要目的是处理评估当今安全堆栈中威胁和它们生成的大量警报所需的大量手动工作。

作为一个编排平台,FIDO 可以通过大幅减少检测、通知和响应网络攻击所需的手动努力,使您现有的安全工具更高效、更准确地使用。有关更多信息,您可以参考以下链接:

参考文献

总结

在本章中,我们探讨了各种最适合微服务基础产品和服务最佳实践和原则。微服务架构是云环境的结果,与基于本地的大型单片系统相比,其应用越来越广泛。我们已经确定了几个与大小、敏捷性和测试有关的原则,这些原则对于成功实施至关重要。

我们已经对 Netflix OSS 使用的各种工具有了概述,这些工具是实现微服务架构基础产品和服务所需的各种关键特性。Netflix 提供视频租赁服务,成功使用了相同的工具。

在下一章中,读者可能会遇到问题,可能会困在这些问题上。本章解释了在微服务开发过程中遇到的常见问题及其解决方案。

第十章:故障排除指南

我们已经走了这么远,我相信您享受这段具有挑战性和快乐的学习旅程中的每一个时刻。我不会说这本书在此章节后结束,而是您正在完成第一个里程碑。这个里程碑为基于微服务设计的云学习和新范式实施打开了大门。我想再次确认集成测试是测试微服务和 API 之间交互的重要方法。在您处理在线表格预订系统(OTRS)的示例应用程序时,我相信您遇到了许多挑战,尤其是在调试应用程序时。在这里,我们将介绍一些可以帮助您排除部署应用程序、Docker 容器和宿主机的故障的最佳实践和工具。

本章涵盖以下三个主题:

  • 日志记录和 ELK 栈

  • 使用 Zipkin 和 Sleuth 进行服务调用时使用相关 ID

  • 依赖关系和版本

日志记录和 ELK 栈

您能想象在生产系统上不查看日志的情况下调试任何问题吗?简单地说,不能,因为回到过去将会很困难。因此,我们需要日志记录。日志还为我们提供了关于系统的警告信号,如果它们是这样设计和编码的话。日志记录和日志分析是排除任何问题的重要步骤,也是提高吞吐量、扩展能力和监控系统健康状况的重要步骤。因此,拥有一个非常好的日志平台和策略将使调试变得有效。日志是软件开发初期最重要的关键组成部分之一。

微服务通常使用如 Docker 之类的图像容器进行部署,这些容器提供有助于您读取部署在容器内的服务日志的命令。Docker 和 Docker Compose 提供命令以分别流式传输容器内运行服务和所有容器的日志输出。请参阅以下 Docker 和 Docker Compose 的logs命令:

Docker 日志命令: 用法: docker logs [OPTIONS] <CONTAINER NAME> 获取容器的日志:

**-f, --follow 跟随日志输出**

**--help 打印用法**

**--since="" 自时间戳以来显示日志**

**-t, --timestamps 显示时间戳**

**--tail="all" 显示日志末尾的行数**

Docker Compose 日志命令: **用法:docker-compose logs [options] [SERVICE...]**

选项:

**--no-color 产生单色输出**

**-f, --follow 跟随日志输出**

**-t, --timestamps 显示时间戳**

`--tail 显示每个容器日志末尾的行数

[SERVICES...] 代表容器的服务 - 你可以指定多个`

这些命令帮助你探索运行在容器中的微服务和其它进程的日志。正如你所看到的,当你有很多服务时,使用上述命令将是一个挑战性的任务。例如,如果你有数十个或数百个微服务,跟踪每个微服务的日志将非常困难。同样,你可以想象,即使没有容器,单独监控日志也会非常困难。因此,你可以想象探索和关联数十到数百个容器的日志有多么困难。这是耗时的,并且几乎没有任何价值。

因此,像 ELK 堆栈这样的日志聚合和可视化工具就派上用场了。它将用于集中日志。我们将在下一节中探讨这一点。

简要概述

Elasticsearch、Logstash、KibanaELK)堆栈是一系列执行日志聚合、分析、可视化和监控的工具。ELK 堆栈提供了一个完整的日志平台,允许你分析、可视化和监控所有日志,包括各种产品日志和系统日志。如果你已经了解 ELK 堆栈,请跳到下一节。在这里,我们将简要介绍 ELK 堆栈中的每个工具:

ELK 概览(来源:elastic.co)

Elasticsearch

Elasticsearch 是最受欢迎的企业级全文搜索引擎之一。它是开源软件。它是可分发的,并支持多租户。单个 Elasticsearch 服务器存储多个索引(每个索引代表一个数据库),单个查询可以搜索多个索引的数据。它是一个分布式搜索引擎,并支持集群。

它易于扩展,可以提供接近实时的搜索,延迟仅为 1 秒。它使用 Java 编写,依赖于 Apache Lucene。Apache Lucene 也是免费和开源的,它为 Elasticsearch 提供了核心,也被称为信息检索软件库。

Elasticsearch API 广泛且详尽。Elasticsearch 提供基于 JSON 的架构,占用更少的存储,并以 JSON 的形式表示数据模型。Elasticsearch API 使用 JSON 文档进行 HTTP 请求和响应。

Logstash

Logstash 是一个具有实时流水线功能的开源数据收集引擎。简单来说,它收集、解析、处理和存储数据。由于 Logstash 具有数据流水线功能,它帮助你处理来自各种系统的各种事件数据,如日志。Logstash 作为一个代理运行,收集数据、解析数据、过滤数据,并将输出发送到指定应用,如 Elasticsearch,或简单的控制台标准输出。

它还拥有一个非常好的插件生态系统(图片来源于www.elastic.co

Logstash 生态系统

Kibana

Kibana 是一个开源的分析与可视化网页应用程序。它被设计用来与 Elasticsearch 协同工作。你使用 Kibana 来搜索、查看与交互存储在 Elasticsearch 索引中的数据。

这是一个基于浏览器的网络应用程序,让你执行高级数据分析并在各种图表、表格和地图中可视化你的数据。此外,它是一个零配置应用程序。因此,安装后既不需要编写任何代码,也不需要额外的基础设施。

ELK 栈设置

通常,这些工具是单独安装,然后配置成相互通信。这些组件的安装相当直接。从指定位置下载可安装的工件,并按照下一节中的安装步骤进行操作。

下面提供的安装步骤是基本设置的一部分,这是你想要运行的 ELK 栈所必需的。由于这个安装是在我的本地主机上完成的,所以我使用了主机 localhost。它可以很容易地用你想要的任何相应的主机名来替换。

安装 Elasticsearch

要安装 Elasticsearch,我们可以使用 Elasticsearch 的 Docker 镜像:

docker pull docker.elastic.co/elasticsearch/elasticsearch:5.5.1 

我们也可以按照以下步骤安装 Elasticsearch:

  1. www.elastic.co/downloads/elasticsearch下载最新的 Elasticsearch 分发版。

  2. 将它解压到系统中的所需位置。

  3. 确保安装了最新版本的 Java,并且JAVA_HOME环境变量已设置。

  4. 前往 Elasticsearch 的主页并运行bin/elasticsearch,在基于 Unix 的系统上,以及在 Windows 上运行bin/elasticsearch.bat

  5. 打开任何浏览器并输入http://localhost:9200/。成功安装后,它应该会为你提供一个类似于以下的 JSON 对象:

{ 
  "name" : "Leech", 
  "cluster_name" : "elasticsearch", 
  "version" : { 
    "number" : "2.3.1", 
    "build_hash" : "bd980929010aef404e7cb0843e61d0665269fc39", 
    "build_timestamp" : "2016-04-04T12:25:05Z", 
    "build_snapshot" : false, 
    "lucene_version" : "5.5.0" 
  }, 
  "tagline" : "You Know, for Search" 
}

默认情况下,GUI 并没有安装。你可以通过从bin目录执行以下命令来安装,确保系统连接到互联网:

  plugin -install mobz/elasticsearch-head

  1. 如果你正在使用 Elasticsearch 镜像,那么就运行 Docker 镜像(稍后,我们将使用docker-compose一起运行 ELK 栈)。

  2. 现在,你可以通过 URLhttp://localhost:9200/_plugin/head/访问 GUI 界面。你可以将localhost9200替换为你的主机名和端口号。

安装 Logstash

要安装 Logstash,我们可以使用 Logstash 的 Docker 镜像:

docker pull docker.elastic.co/logstash/logstash:5.5.1 

我们也可以通过执行以下步骤来安装 Logstash:

  1. www.elastic.co/downloads/logstash下载最新的 Logstash 分发版。

  2. 将它解压到系统中的所需位置。

    准备一个配置文件,如下所示。它指示 Logstash 从给定文件中读取输入并将其传递给 Elasticsearch(请参阅下面的config文件;Elasticsearch 由 localhost 和9200端口表示)。这是最简单的配置文件。要添加过滤器并了解更多关于 Logstash 的信息,你可以探索可用的 Logstash 参考文档www.elastic.co/guide/en/logstash/current/index.html

正如你所看到的,OTRS 的service日志和edge-server日志作为输入添加了。同样地,你也可以添加其他微服务的日志文件。

input { 
  ### OTRS ### 
  file { 
    path => "\logs\otrs-service.log" 
    type => "otrs-api" 
    codec => "json" 
    start_position => "beginning" 
  } 

  ### edge ### 
  file { 
    path => "/logs/edge-server.log" 
    type => "edge-server" 
    codec => "json" 
  } 
} 

output { 
  stdout { 
    codec => rubydebug 
  } 
  elasticsearch { 
    hosts => "localhost:9200" 
  } 
} 
  1. 在 Unix-based 系统上,前往 Logstash 主目录并运行bin/logstash agent -f logstash.conf,在 Windows 上,运行bin/logstash.bat agent -f logstash.conf。在这里,Logstash 使用agent命令执行。Logstash 代理从配置文件中提供的输入字段中的源收集数据,并将输出发送到 Elasticsearch。在这里,我们没有使用过滤器,因为否则它可能会在将数据提供给 Elasticsearch 之前处理输入数据。

同样地,你可以使用下载的 Docker 镜像来运行 Logstash(稍后,我们将使用docker-compose来一起运行 ELK 栈)。

安装 Kibana

要安装 Kibana,我们可以使用 Kibana 的 Docker 镜像:

docker pull docker.elastic.co/kibana/kibana:5.5.1 

我们还可以通过执行以下步骤来安装 Kibana 网页应用程序:

  1. www.elastic.co/downloads/kibana下载最新的 Kibana 分发版。

  2. 将其解压到系统中的所需位置。

  3. 打开 Kibana 主目录下的配置文件config/kibana.yml,并将elasticsearch.url指向之前配置的 Elasticsearch 实例。

   elasticsearch.url: "http://localhost:9200"
  1. 在 Unix-based 系统上,前往 Kibana 主目录并运行bin/kibana agent -f logstash.conf,在 Windows 上,运行bin/kibana.bat agent -f logstash.conf

  2. 如果你使用的是 Kibana 的 Docker 镜像,那么你可以运行 Docker 镜像(稍后,我们将使用 docker-compose 来一起运行 ELK 栈)。

  3. 现在,你可以通过 URLhttp://localhost:5601/从你的浏览器访问 Kibana 应用。

    要了解更多关于 Kibana 的信息,请探索 Kibana 参考文档www.elastic.co/guide/en/kibana/current/getting-started.html

正如我们遵循前面的步骤,你可能已经注意到它需要一些努力。如果你想要避免手动设置,你可以 Docker 化它。如果你不想花精力创建 ELK 栈的 Docker 容器,你可以在 Docker Hub 上选择一个。在 Docker Hub 上,有许多现成的 ELK 栈 Docker 镜像。你可以尝试不同的 ELK 容器,选择最适合你的那个。willdurand/elk是最受欢迎的容器,启动简单,与 Docker Compose 配合良好。

使用 Docker Compose 运行 ELK 栈

截至撰写本节时,elastic.co 自己的 Docker 仓库中可用的 ELK 镜像默认启用了 XPack 包。将来,这可能成为可选的。根据 ELK 镜像中 XPack 的可用性,您可以修改docker-compose-elk.yml docker-compose文件:

version: '2' 

services: 
  elasticsearch: 
    image: docker.elastic.co/elasticsearch/elasticsearch:5.5.1 
    ports: 
      - "9200:9200" 
      - "9300:9300" 
    environment: 
      ES_JAVA_OPTS: "-Xmx256m -Xms256m" 
      xpack.security.enabled: "false" 
      xpack.monitoring.enabled: "false" 
      # below is required for running in dev mode. For prod mode remove them and vm_max_map_count kernel setting needs to be set to at least 262144 
      http.host: "0.0.0.0" 
      transport.host: "127.0.0.1" 
    networks: 
      - elk 

  logstash: 
    image: docker.elastic.co/logstash/logstash:5.5.1 
    #volumes: 
    #  - ~/pipeline:/usr/share/logstash/pipeline 
    #  windows manually copy to docker cp pipleline/logstash.conf 305321857e9f:/usr/share/logstash/pipeline. restart container after that 
    ports: 
      - "5001:5001" 
    environment: 
      LS_JAVA_OPTS: "-Xmx256m -Xms256m" 
      xpack.monitoring.enabled: "false" 
      xpack.monitoring.elasticsearch.url: "http://192.168.99.100:9200" 
      command: logstash -e 'input { tcp { port => 5001 codec => "json" } } output { elasticsearch { hosts => "192.168.99.100" index => "mmj" } }' 
    networks: 
      - elk 
    depends_on: 
      - elasticsearch 

  kibana: 
    image: docker.elastic.co/kibana/kibana:5.5.1 
    ports: 
      - "5601:5601" 
    environment: 
      xpack.security.enabled: "false" 
      xpack.reporting.enabled: "false" 
      xpack.monitoring.enabled: "false" 
    networks: 
      - elk 
    depends_on: 
      - elasticsearch 

networks: 
  elk: 
    driver: bridge 

一旦保存了 ELK Docker Compose 文件,您可以使用以下命令运行 ELK 堆栈(该命令从包含 Docker Compose 文件的目录运行):

docker-compose -f docker-compose-elk.yml up -d 

前一条命令的输出如以下截图所示:

使用 Docker Compose 运行 ELK 堆栈

如果不使用卷,环境管道将无法工作。对于像 Windows 7 这样的 Windows 环境,通常很难配置卷,您可以将管道 CONF 文件复制到容器内并重新启动 Logstash 容器:

docker cp pipleline/logstash.conf <logstash container id>:/usr/share/logstash/pipeline 

在复制pipeline/logstash.conf管道 CONF 文件后,请重新启动 Logstash 容器:

input { 
  tcp { 
    port => 5001 
    codec => "json" 
  } 
} 

output { 
  elasticsearch { 
    hosts => "elasticsearch:9200" 
  } 
} 

将日志推送到 ELK 堆栈

我们已经完成了使 ELK 堆栈可供消费的工作。现在,Logstash 只需要一个可以被 Elasticsearch 索引的日志流。一旦创建了日志的 Elasticsearch 索引,就可以在 Kibana 仪表板上访问和处理日志。

为了将日志推送到 Logstash,我们需要在我们的服务代码中进行以下更改。我们需要在 OTRS 服务中添加 logback 和 logstash-logback 编码器依赖项。

pom.xml文件中添加以下依赖项:

... 
<dependency> 
    <groupId>net.logstash.logback</groupId> 
    <artifactId>logstash-logback-encoder</artifactId> 
    <version>4.6</version> 
</dependency> 
<dependency> 
    <groupId>ch.qos.logback</groupId> 
    <artifactId>logback-core</artifactId> 
    <version>1.1.9</version> 
</dependency> 
... 

我们还需要通过向src/main/resources添加logback.xml来配置 logback。

logback.xml文件将看起来像这样:

<?xml version="1.0" encoding="UTF-8"?> 
<configuration debug="true"> 
    <appender name="stash" class="net.logstash.logback.appender.LogstashTcpSocketAppender"> 
        <destination>192.168.99.100:5001</destination> 
        <!-- encoder is required --> 
        <encoder class="net.logstash.logback.encoder.LogstashEncoder" /> 
        <keepAliveDuration>5 minutes</keepAliveDuration> 
    </appender> 
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender"> 
        <encoder> 
            <pattern>%d{HH:mm:ss.SSS} [%thread, %X{X-B3-TraceId:-},%X{X-B3-SpanId:-}] %-5level %logger{36} - %msg%n</pattern> 
        </encoder> 
    </appender> 

    <property name="spring.application.name" value="nameOfService" scope="context"/> 

    <root level="INFO"> 
        <appender-ref ref="stash" /> 
        <appender-ref ref="stdout" /> 
    </root> 

    <shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"/> 
</configuration>

这里,目标是在192.168.99.100:5001上,那里托管 Logstash;根据您的配置,您可以进行更改。对于编码器,使用了net.logstash.logback.encoder.LogstashEncoder类。spring.application.name属性的值应设置为配置的服务。同样,添加了一个关闭钩子,以便在服务停止时,应释放和清理所有资源。

您希望在 ELK 堆栈可用后启动服务,以便服务可以将日志推送到 Logstash。

一旦 ELK 堆栈和服务启动,您可以检查 ELK 堆栈以查看日志。您希望在启动 ELK 堆栈后等待几分钟,然后访问以下 URL(根据您的配置替换 IP)。

为了检查 Elasticsearch 是否启动,请访问以下 URL:

http://192.168.99.100:9200/  

为了检查是否已创建索引,请访问以下任一 URL:

http://192.168.99.100:9200/_cat/indices?v 
http://192.168.99.100:9200/_aliases?pretty 

一旦完成了 Logstash 索引(您可能有一些服务端点来生成一些日志),请访问 Kibana:

http://192.168.99.100:5601/ 

ELK 堆栈实现的技巧

以下是一些实施 ELK 堆栈的有用技巧:

  • 为了避免任何数据丢失并处理输入负载的突然激增,建议在 Logstash 和 Elasticsearch 之间使用如 Redis 或 RabbitMQ 之类的代理。

  • 如果你使用集群,为 Elasticsearch 使用奇数个节点,以防止分脑问题。

  • 在 Elasticsearch 中,总是为给定数据使用适当的字段类型。这将允许您执行不同的检查;例如,int字段类型将允许您执行("http_status:<400")("http_status:=200")。同样,其他字段类型也允许您执行类似的检查。

为服务调用使用关联 ID

当你调用任何 REST 端点时,如果出现任何问题,很难追踪问题和其根本原因,因为每个调用都是对服务器的调用,这个调用可能调用另一个,依此类推。这使得很难弄清楚特定请求是如何转换的以及它调用了什么。通常,由一个服务引起的问题可能会在其他服务上产生连锁反应,或者可能导致其他服务操作失败。这很难追踪,可能需要巨大的努力。如果是单体结构,你知道你在正确的方向上,但是微服务使得难以理解问题的来源以及你应该获取数据的位置。

让我们看看我们如何解决这个问题

通过在所有调用中传递关联 ID,它允许您轻松跟踪每个请求和跟踪路由。每个请求都将有其唯一的关联 ID。因此,当我们调试任何问题时,关联 ID 是我们的起点。我们可以跟随它,在这个过程中,我们可以找出哪里出了问题。

关联 ID 需要一些额外的开发工作,但这是值得的努力,因为它在长远中帮助很大。当请求在不同微服务之间传递时,你将能够看到所有交互以及哪个服务存在问题。

这不是为微服务发明的新东西。这个模式已经被许多流行产品使用,例如微软 SharePoint。

使用 Zipkin 和 Sleuth 进行跟踪

对于 OTRS 应用程序,我们将利用 Zipkin 和 Sleuth 进行跟踪。它提供了跟踪 ID 和跨度 ID 以及一个漂亮的 UI 来跟踪请求。更重要的是,您可以在 Zipkin 中找到每个请求所花费的时间,并允许您深入挖掘以找出响应请求耗时最长的请求。

在下面的截图中,你可以看到餐厅findById API 调用所花费的时间以及同一请求的跟踪 ID。它还显示了跨度 ID:

餐厅findById API 调用的总时间和跟踪 ID

我们将遵循以下步骤来配置 OTRS 服务中的 Zipkin 和 Sleuth。

你只需要向跟踪和请求跟踪中添加 Sleuth 和 Sleuth-Zipkin 依赖项:

<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-starter-sleuth</artifactId> 
</dependency> 
<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-sleuth-zipkin</artifactId> 
</dependency> 

访问 Zipkin 仪表板,找出不同请求所花费的时间。如果默认端口已更改,请替换端口。请确保在使用 Zipkin 之前服务已经启动:

http://<zipkin host name>:9411/zipkin/ 

现在,如果 ELK 栈已经配置并运行,那么你可以使用这个跟踪 ID 在 Kibana 中找到相应的日志,如下面的屏幕截图所示。Kibana 中有一个 X-B3-TraceId 字段,用于根据跟踪 ID 过滤日志:

Kibana 仪表板 - 根据请求跟踪 ID 搜索

依赖关系和版本

在产品开发中,我们面临的两个常见问题是循环依赖和 API 版本。我们将讨论它们在微服务架构中的情况。

循环依赖及其影响

通常,单体架构有一个典型的层次模型,而微服务携带图模型。因此,微服务可能会有循环依赖。

因此,对微服务关系进行依赖检查是必要的。

让我们来看看以下两个案例:

  • 如果你在你的微服务之间有一个依赖循环,当某个事务可能卡在循环中时,你的分布式栈可能会遇到溢出错误。例如,当一个人在预订餐厅的桌子时。在这种情况下,餐厅需要知道这个人(findBookedUser),而这个人需要知道在某个时间点的餐厅(findBookedRestaurant)。如果设计不当,这些服务可能会相互调用形成循环。结果可能是由 JVM 产生的栈溢出。

  • 如果两个服务共享一个依赖项,而你以可能影响它们的方式更新那个其他服务的 API,你需要一次性更新所有三个。这会引发一些问题,比如你应该先更新哪一个?此外,你如何使这个过渡变得安全?

在设计系统时分析依赖关系

因此,在设计微服务时,确立不同服务之间的适当关系以避免任何循环依赖是非常重要的。

这是一个设计问题,必须加以解决,即使这需要对代码进行重构。

维护不同版本

当你拥有更多服务时,这意味着每个服务都有不同的发布周期,这通过引入不同版本的服务增加了这种复杂性,因为同样的 REST 服务会有不同的版本。当解决方案在一个版本中消失,在更高版本中回归时,重现问题将变得非常困难。

让我们进一步探索

API 的版本化很重要,因为随着时间的推移,API 会发生变化。你的知识和经验会随着时间而提高,这会导致 API 的变化。改变 API 可能会破坏现有的客户端集成。

因此,有许多方法可以管理 API 版本。其中一种方法是使用我们在本书中使用的路径版本;还有一些人使用 HTTP 头。HTTP 头可能是一个自定义请求头,或者您可以使用Accept Header来表示调用 API 的版本。有关如何使用 HTTP 头处理版本的信息,请参阅 Bhakti Mehta 著,Packt 出版社出版的RESTful Java Patterns and Best Practiceswww.packtpub.com/application-development/restful-java-patterns-and-best-practices

在排查任何问题时,让微服务在日志中产生版本号非常重要。此外,理想情况下,您应该避免任何微服务版本过多的实例。

参考文献

以下链接将提供更多信息:

总结

在本章中,我们探讨了 ELK 堆栈的概述和安装。在 ELK 堆栈中,Elasticsearch 用于存储来自 Kibana 的日志和服务查询。Logstash 是运行在您希望收集日志的每个服务器上的代理。Logstash 读取日志,过滤/转换它们,并将它们提供给 Elasticsearch。Kibana 从 Elasticsearch 中读取/查询数据,并以表格或图形可视化的形式呈现它们。

我们也非常理解在排查问题时拥有关联 ID 的实用性。在本章末尾,我们也发现了某些微服务设计的一些不足。由于在本书中涵盖所有与微服务相关的主题具有挑战性,因此我尽可能地包含尽可能多的相关信息,并配有精确的章节和参考文献,这使您能够进一步探索。现在,我希望让您开始在您的工作场所或个人项目中实施本章学到的概念。这不仅能为您提供实践经验,还可能使您掌握微服务。此外,您还将能够参加当地的技术聚会和会议。

第十一章:将单体应用迁移到基于微服务的应用

我们已经到了这本书的最后一章,我希望你已经享受并掌握了完整的微服务(除了数据库)开发。我试图涵盖所有必要的主题,为你提供一个微服务为基础的生产应用程序的全面视角,并允许你进行更多的探索。既然你已经了解了微服务架构和设计,你就可以很容易地区分单体应用和微服务应用,并识别出将单体应用迁移到微服务应用需要做的工作。

在本章中,我们将讨论将单体应用重构为基于微服务的应用。我假设一个现有的单体应用已经被部署并正在被客户使用。在本章结束时,你将了解可以将单体迁移到微服务的不同方法和策略。

本章涵盖以下主题:

  • 你需要迁移吗?

  • 成功迁移的方法和关键

你需要迁移吗?

这是你应该为你的迁移设定基调的第一个问题。你真的需要将现有的应用程序迁移到基于微服务的架构吗?它带来了哪些好处?后果是什么?我们如何支持现有的本地客户?现有客户是否支持并承担迁移到微服务的成本?我需要从头开始写代码吗?数据将如何迁移到新的基于微服务的系统?迁移的时间表是什么?现有团队是否有能力快速带来这种变化?我们是否可以接受在迁移期间的新功能变化?我们的流程是否能够适应迁移?等等。我相信你们脑海中会有很多类似的问题。我希望,从所有之前的章节中,你可能已经获得了关于微服务系统所需工作的良好知识。

在所有利弊之后,你的团队将决定迁移。如果答案是肯定的,本章将帮助你了解迁移的下一步。

云服务与本地部署,还是两者都提供?

你的现有产品是对云解决方案、本地解决方案,还是提供云和本地解决方案,或者你想开始提供云解决方案与本地解决方案。你的方法将基于你提供的解决方案类型。

仅限云解决方案

如果你提供云服务,那么你的迁移任务比其他两种解决方案要容易。话说回来,这并不意味着它会一帆风顺。你将完全控制迁移。你有权不考虑迁移对客户的直接影响。云客户只需使用解决方案,而不关心它是如何实现或托管的。我假设没有 API 或 SDK 的更改,显然,迁移不应涉及任何功能更改。仅在云上进行微服务迁移具有使用平稳渐进迁移的优势。这意味着你首先转换 UI 应用程序,然后是一个 API/服务,然后是下一个,依此类推。请注意,你掌控着局面。

仅本地服务解决方案

本地解决方案部署在客户的基础设施上。除此之外,你可能有许多客户在其基础设施上部署了不同版本的产品。你无法完全控制这些部署。你需要与客户合作,需要团队共同努力才能实现成功的迁移。

此外,在接触客户之前,你应该准备好一个完整的迁移解决方案。如果你的产品有不同版本,这会变得尤为困难。我建议只提供最新版本的迁移,而在你开发迁移时,只允许客户进行安全性和修补操作。是的,你不应该提供任何新功能。

云服务和本地服务

如果你的应用程序同时提供云服务和本地服务,那么将本地解决方案迁移到微服务可以与云服务同步进行,反之亦然。这意味着如果你在迁移一个方面付出了努力,你可以在另一个方面复制同样的成果。因此,除了之前提到的云或本地迁移挑战外,还需要在其他环境中进行复制。另外,有时本地客户可能有自己的定制化需求。在迁移时也需要考虑到这些需求。在这里,你应该首先将自有的云解决方案迁移到微服务,之后再复制到本地环境。

将生产/解决方案仅基于本地部署,但你想开始云部署;这是最具挑战性的。你预计要按照我的微服务设计迁移现有代码,同时确保它也支持现有的本地部署。有时,这可能是遗留技术堆栈,或者现有代码甚至可能是使用某些自有专有技术(如协议)编写的。现有设计可能不够灵活,无法拆分为微服务。这种迁移最具挑战性。应该逐步将本地解决方案迁移到微服务,首先分离 UI 应用程序并提供与 UI 应用程序交互的外部 API。如果 API 已经存在,或者你的应用程序已经分为独立的 UI 应用程序,相信我,这为迁移减轻了大量的负担。然后,你可以专注于迁移服务器端代码,包括为 UI 应用程序开发的 API。你可能会问为什么我们不能一起迁移所有 UI 应用程序、API 和服务器代码。是的,你可以这样做。但是,逐步迁移会给你带来确定性、信心和快速的失败/学习。毕竟,敏捷开发就是关于逐步开发。

如果你的现有代码不是模块化的,或者包含大量的遗留代码,那么我建议你首先重构它并使其模块化。这将使你的任务变得容易。说到这里,应该逐个模块进行。在将代码迁移到纯微服务之前,尽可能多地分解和重构代码。

我们将讨论一些方法,这些方法可能有助于你将大型复杂的单体应用程序重构为微服务。

成功迁移的方法和关键

软件现代化已经进行了很多年。为了成功进行软件现代化(迁移),已经做了大量工作。你会发现研究所有成功软件现代化的最佳实践和原则很有用。在本章中,我们将具体讨论微服务架构的软件现代化。

逐步迁移

你应该以渐进的方式将单体应用转换为微服务。你不应该一次性开始整个代码的全功能迁移。这会纠缠风险-回报比率并增加失败的可能性。它还增加了过渡时间(因此是成本)的可能性。你可能想将你的代码分成不同的模块,然后逐一开始转换每个模块。很可能你可能会想从头重新编写一些模块,如果现有代码紧密耦合且过于复杂难以重构,这就应该做。但是,从头开始编写完整的解决方案是大忌。你应该避免这样做。它增加了成本、迁移的时间以及失败的可能性。

过程自动化和工具设置

敏捷方法与微服务紧密合作。您可以使用任何敏捷过程,如 Scrum 和 Kanban,以及现代开发过程,如测试驱动开发或同行编程,进行增量开发。对于基于微服务的环境,流程自动化是必不可少的。您应该实施自动化的持续集成/持续部署和测试自动化。如果交付物的容器化还没有在 CI/CD 管道中完成,那么您应该去做。它使新开发的微服务能够与现有系统或其他新微服务成功集成。

在开始您的第一个微服务转型之前,您可能需要同时或先设置服务发现、服务网关、配置服务器或任何基于事件的系统。

试点项目

我在微服务迁移中观察到的另一个问题是,从一开始就完全不同模块的开发。理想情况下,一个小团队应该进行试点项目,将现有模块中的任何一个转换为微服务。一旦成功,相同的方法可以复制到其他模块。如果您同时开始各种模块的迁移,那么您可能在所有微服务中重复同样的错误。这增加了失败的风险和转型的持续时间。

成功迁移的团队提供了成功开发模块及其与现有单体应用程序集成的途径。如果您成功地一个接一个地将每个模块转换为微服务,那么在某个时候,您将拥有一个基于微服务的应用程序,而不是一个单体应用程序。

独立的用户界面应用程序

如果您已经有了消耗 API 的独立用户界面应用程序,那么您距离成功的迁移已经不远了。如果不是这样,这应该是第一步,将用户界面与服务器代码分离。UI 应用程序将消耗 API。如果现有应用程序没有应该被 UI 应用程序消耗的 API,那么您应该在现有代码之上编写包装 API。

请查看以下图表,反映 UI 应用程序迁移之前的呈现层:

在 UI 应用程序迁移之前

以下图表反映了 UI 应用程序迁移后的呈现层:

UI 应用程序迁移后

您可以看到,以前,UI 被包含在单体应用程序中,与业务逻辑和 DAO 一起。迁移后,UI 应用程序从单体应用程序中分离出来,并使用 API 与服务器代码通信。REST 是实现 API 的标准,可以在现有代码之上编写。

将模块迁移到微服务

现在,你有一个服务器端的单体应用程序和一个或多个 UI 应用程序。这为你提供了另一个优势,即在分离模块的同时消费 API,从而分离现有的单体应用程序。例如,在分离 UI 应用程序后,你可能将其中一个模块转换为微服务。一旦 UI 应用程序成功测试,与该模块相关的 API 调用可以路由到新转换的模块,而不是现有的单体 API。如图所示,当调用 API GET/customer/1 时,网络网关可以将请求路由到客户微服务,而不是单体应用程序。

你还可以通过比较单体和微服务模块的响应,在将基于微服务的 API 上线之前,在生产环境中进行测试。一旦我们得到一致的匹配响应,我们可以确信转换已经成功完成,并且 API 调用可以迁移到重构模块的 API。如图所示,当调用客户 API 时,部署了一个组件,该组件会调用一个新的客户微服务。然后,它比较了两次调用的响应并存储结果。这些结果可以进行分析,并对任何不一致性进行修复。当新转换的微服务的响应与现有的单体响应相匹配时,你可以停止将调用路由到现有的单体应用程序,并用新的微服务替换它。

采用这种方法,你可以将模块一个接一个地迁移到微服务,并在某个时刻,你可以将所有的单体模块迁移到微服务。

API 路由、比较和迁移

如何在迁移过程中容纳新功能

在迁移的理想场景中应避免添加新功能。只允许重要的修复和安全更改。然而,如果迫切需要实现一个新功能,那么它应该在一个单独的微服务中开发,或者以现有的单体代码模块化的方式开发,使其与现有代码的分离更容易。

例如,如果你确实需要在客户模块中添加一个新功能,而这个功能不依赖于其他模块,你只需创建一个新的客户微服务,并将其用于特定的 API 调用,无论是对外部世界还是通过其他模块。是否使用 REST 调用或事件进行进程间通信,由你自己决定。

同样,如果您需要一个具有依赖关系的新功能(例如,一个新客户功能依赖于预订功能)并且它没有被暴露为 UI 或服务 API 的 API,那么它仍然可以作为独立的微服务开发,如下面的图表所示。customer模块调用一个新开发的微服务,然后它调用booking模块进行请求处理,并将响应返回到customer模块。在这里,用于进程间通信的可以是 REST 或事件。

实现一个新模块作为微服务,调用另一个模块

参考文献

阅读以下书籍以获取有关代码重构和领域驱动设计的更多信息:

  • 《重构:改善现有代码的设计》马丁·福勒

  • 《领域驱动设计》埃里克·J·埃文斯

总结

软件现代化是前进的道路,在当前环境中,因为一切都被迁移到云,以及资源力量和容量的增加方式,基于设计的微服务比其他任何东西都更合适。我们讨论了云和本地解决方案的组合以及将这些转换为微服务的挑战。

我们还讨论了为什么渐进式开发方法在单体应用迁移到微服务方面是首选。我们谈论了成功迁移到微服务所需的各种方法和实践。

posted @   绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· 2 本地部署DeepSeek模型构建本地知识库+联网搜索详细步骤
点击右上角即可分享
微信分享提示