Spring5-软件架构-全-

Spring5 软件架构(全)

原文:zh.annas-archive.org/md5/45D5A800E85F86FC16332EEEF23286B1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

今天我们依赖于可以应用于不同场景的不同软件架构风格。在本书中,我们将回顾最常见的软件架构风格以及它们如何使用 Spring Framework 来实现,这是 Java 生态系统中最广泛采用的框架之一。

一开始,我们将回顾一些与软件架构相关的关键概念,以便在深入技术细节之前理解基本理论。

本书适合人群

本书旨在面向有经验的 Spring 开发人员,他们希望成为企业级应用程序的架构师,以及希望利用 Spring 创建有效应用蓝图的软件架构师。

本书涵盖内容

第一章,今日软件架构,概述了如何管理当今的软件架构以及为什么它们仍然重要。它讨论了软件行业最新需求如何通过新兴的架构模型来处理,以及它们如何帮助您解决这些新挑战。

第二章,软件架构维度,回顾了与软件架构相关的维度以及它们如何影响应用程序构建过程。我们还将介绍用于记录软件架构的 C4 模型。

第三章,Spring 项目,介绍了一些最有用的 Spring 项目。了解您的工具箱中有哪些工具很重要,因为 Spring 提供了各种工具,可以满足您的需求,并可用于提升您的开发过程。

第四章,客户端-服务器架构,涵盖了客户端-服务器架构的工作原理以及可以应用此架构风格的最常见场景。我们将介绍各种实现,从简单的客户端,如桌面应用程序,到现代和更复杂的用途,如连接到互联网的设备。

第五章,MVC 架构,介绍了 MVC,这是最流行和广为人知的架构风格之一。在本章中,您将深入了解 MVC 架构的工作原理。

第六章,事件驱动架构,解释了与事件驱动架构相关的基本概念,以及它们如何使用实践方法处理问题。

第七章,管道和过滤器架构,重点介绍了 Spring Batch。它解释了如何构建管道,这些管道封装了一个独立的任务链,旨在过滤和处理大量数据。

第八章,微服务,概述了如何使用 Spring Cloud 堆栈实现微服务架构。它详细介绍了每个组件以及它们如何相互交互,以提供完全功能的微服务架构。

第九章,无服务器架构,介绍了互联网上许多现成可用的服务,可以作为软件系统的一部分使用,使公司可以专注于他们自己的业务核心问题。本章展示了一种围绕一系列第三方服务构建应用程序的新方法,以解决身份验证、文件存储和基础设施等常见问题。我们还将回顾什么是 FaaS 方法以及如何使用 Spring 实现它。

《第十章》容器化您的应用程序解释了容器是近年来最方便的技术之一。它们帮助我们摆脱手动服务器配置,并允许我们忘记与构建生产环境和服务器维护任务相关的头痛。本章展示了如何生成一个准备好用于生产的构件,可以轻松替换、升级和交换,消除了常见的配置问题。通过本章,我们还将介绍容器编排以及如何使用 Kubernetes 处理它。

《第十一章》DevOps 和发布管理解释了敏捷是组织团队和使他们一起更快地构建产品的最常见方法之一。DevOps 是这些团队的固有技术,它帮助他们打破不必要的隔离和乏味的流程,让团队有机会负责从编写代码到在生产环境中部署应用程序的整个软件开发过程。本章展示了如何通过采用自动化来实现这一目标,以减少手动任务并使用自动化管道部署应用程序,负责验证编写的代码、提供基础设施,并在生产环境中部署所需的构件。

《第十二章》监控解释了一旦应用程序发布,出现意外行为并不罕见,因此及时发现并尽快修复是至关重要的。本章提供了一些建议,涉及可以用来监控应用程序性能的技术和工具,考虑到技术和业务指标。

《第十三章》安全解释了通常安全是团队在开发产品时不太关注的领域之一。开发人员在编写代码时应该牢记一些关键考虑因素。其中大部分都是相当明显的,而其他一些则不是,因此我们将在这里讨论所有这些问题。

《第十四章》高性能解释了在应用程序表现出意外行为时,处理生产中的问题比什么都更令人失望。在本章中,我们将讨论一些简单的技术,可以通过每天应用简单的建议来摆脱这些烦人的问题。

充分利用本书

在阅读本书之前,需要对 Java、Git 和 Spring Framework 有很好的理解。深入了解面向对象编程是必要的,尽管一些关键概念在前两章中进行了复习。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Software-Architecture-with-Spring-5.0。我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/SoftwareArchitecturewithSpring5_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“这个对象由Servlet接口表示。”

代码块设置如下:

@RunWith(SpringRunner.class)
@SpringBootTest
public class ContextAwareTest {

    @Autowired
    ClassUnderTest classUnderTest;

    @Test
    public void validateAutowireWorks() throws Exception {
        Assert.assertNotNull(classUnderTest);
    }
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

@Service
public class MyCustomUsersDetailService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) 
       throws UsernameNotFoundException {
        Optional<Customer> customerFound = findByUsername(username);
        ...
    }
}

任何命令行输入或输出都以以下方式编写:

$ curl -X POST http://your-api-url:8080/events/<EVENT_ID

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“从管理面板中选择系统信息。”

警告或重要说明会以这种方式出现。

提示和技巧会以这种方式出现。

联系我们

第一章:今天的软件架构

在本章中,我们将回顾软件架构是什么,以及为什么它今天仍然很重要。我们还将讨论近年来引导软件开发世界的新业务需求,以及它们如何影响整个软件行业。

软件和技术每天都在发展,引入了新的需求,企业必须满足这些需求,以保持在竞争激烈的市场中的竞争力。无论其核心业务如何,每个有竞争力的公司都必须转向技术。在线交易和全球客户只是必须掌握的一些挑战,以保持领先地位。

为了支持这些新需求,我们一直在发现新的工作方式。已经进行了重大的变革,并被采纳,直接影响了我们的软件开发生命周期。这些变化的一些例子反映在我们如何在以下阶段工作上:

  • 收集需求

  • 组织团队

  • 设计软件架构

  • 编写代码

  • 部署应用程序

在本章中,我们将重新审视软件架构的基本概念,这些概念已经存在很长时间,而且今天仍然很重要。

本章将涵盖以下主题:

  • 定义软件架构

  • 创建架构时常见的错误

  • 架构和架构师

  • 软件架构原则

  • 应用高内聚和低耦合来创建组件

  • SOLID 原则

  • 康威定律

  • 为自己选择合适的技术

  • 新技术趋势

定义软件架构

无论某人是否在团队中担任软件架构师的角色,每个应用程序都有一个需要有人负责的架构。这是一个重要的步骤,因为它帮助我们避免编写纠缠不清的代码,这使得软件系统在未来无法发展。

首先要明确的是:为了知道为什么需要记住软件架构,我们首先需要了解它是什么,以及为什么它很重要。

在软件中,“架构”这个词很难定义。作者们经常从建筑行业借用定义,这是错误的。软件架构不仅仅是关于图表,比如建筑或房屋的计划 - 它不仅仅是这些。它关乎技术甚至非技术人员对整个团队正在创建的应用程序的共享知识,模块如何连接以塑造它,以及围绕它的所有复杂和重要元素。良好的软件架构主要关注业务需求,而不是框架、编程语言、图表和编程范式。当然,我们需要这些,因为我们使用它们来创建应用程序。但是,它们不必定义我们构思软件的基本原则。相反,这个角色应该根据业务需求来发挥作用。

应用程序的长期成功主要取决于其架构,这必须是为了支持一组明确定义的业务需求而创建的,正如前面提到的。由于应用程序需要解决这些特定需求,它们必须引导应用程序的架构。然而,有两种主要情况下,我们基于技术而不是业务需求来指导软件架构决策:

  • 我知道我的领域

  • 我想保持领先

我知道我的领域

当我们使用已知的框架和编程语言创建软件架构时,而不是密切关注业务需求时,就会出现这种情况。

假设 ABC 公司需要一个用于处理大型日志文件中文本的应用程序。如果有人要求处理这个需求,那么他们在开发过程中会选择一种他们熟悉的编程语言,而不是在其他地方寻找最佳方法。

想象一下负责创建此应用程序的人已经精通 JavaScript。在这种情况下,您认为使用 Node JS 或另一个在服务器上运行的 JavaScript 框架编写代码来编写操作日志文件的应用程序是个好主意吗?我并不是说这是不可能的 - 您可以做到。但是,您认为使用这种方法创建的应用程序能够比使用 Perl、Python 或 C 等语言编写的系统表现和扩展得更好吗?这并不是说 JavaScript 很糟糕 - 只是重要的是要知道这种方法不适合 JavaScript。

我想保持领先

我们都希望保持技术领先,利用编程世界中的最新趋势来拥有更好的技术背景,从而获得很酷的工作。有些人倾向于在编写应用程序时牢记这一点。让我们使用前一节中提到的操作日志文件的应用程序示例来解释这种情况。

假设您被要求解决我们在“我了解我的土地”部分提到的问题。在这种情况下,您唯一关心的是技术。例如,假设您想尝试最新的 PHP 版本中的最新功能。在这种情况下,您将使用 PHP 构建此应用程序。尽管自从 Facebook 开始向其添加新功能以来,这种编程语言在过去几年中一直在改进,但使用 PHP 编写应用程序来操作大型日志文件的想法是疯狂的。正如您可能知道的那样,这种编程语言旨在创建其他类型的应用程序,主要是那些必须使用 Web 浏览器访问且没有高事务要求的应用程序。

再次,您可以使用 PHP 编写应用程序来操作大型日志文件,但是当需要更多功能时会发生什么?您认为以这种方式创建的软件架构能够快速响应新需求和本示例中使用的应用程序的固有特性吗?

预测未来

虽然我们在创建应用程序时无法预测每个细节,但我们可以牢记一些明显的假设,以避免明显的错误,就像在前面的部分中暴露的那些错误一样。即使您使用了错误的方法创建了一个应用程序,软件架构过程的一部分是定期评估代码库并根据此采取纠正措施。这很重要,因为现有的软件架构需要不断发展以避免变得无用。在开发过程中,因为我们不想错过已建立的项目截止日期,我们经常使用FIXMETODO标签。但是,我们应该密切关注这些并尽快采取行动,因为它们代表随着时间推移而恶化的技术债务。想象一下在下一个迭代中摆脱最近引入的债务有多容易。现在,想象一下,如果添加了该债务的开发人员不再在项目上工作,甚至不在同一家公司内,那将会有多困难。

请记住,这些标签代表一种债务,债务会随着时间的推移而增加利息。

改进现有软件架构的过程有时倾向于比从头开始创建一个新的更有趣。这是因为您现在对业务需求以及应用程序在生产中的表现有更多信息。

当您向现有应用程序添加新功能时,您将会发现最初的想法有多好。如果添加新功能的过程简单,并且只需要对其结构进行少量更改,那么我们可以得出结论,软件架构正在很好地发挥作用。否则,如果我们需要对原始设计的基本部分进行重大更改,我们可以说最初的想法和假设都是错误的。然而,在这一点上,负责产品的团队应该有足够的责任心,使其不断发展,而不是编写额外的补丁来支持新功能。

尽管修补某些东西听起来与使其发展类似,但实际上并不是。这个想法在《构建进化架构》一书中得到了清楚的解释,该书由 Neal Ford、Rebecca Parsons 和 Patrick Kua 撰写。

积极的团队不断应用变化,使其能够更好地支持现有和新功能,而不是坐等混乱失控。更改最初的设计没有错,总是值得的。以下图表说明了这个过程,应用于几何形状:

演变的原始设计

现在我们知道业务需求必须引导应用程序架构,我们可以得出结论,如果它无法支持新功能,那么新的商机将被错过,使应用程序及其架构变得无用。

架构和架构师

在敏捷和 DevOps 方法出现之前,架构师通常专注于创建编写代码的标准和规则。过去,常常会发现编写代码的架构师,但这种方法在编程方面目前已经过时。在过去的几年里,架构师的概念已经消失,这要归功于创建团队的新兴模式。敏捷运动在软件行业已经存在一段时间,帮助我们重新思考我们如何构建软件和组织团队。

如今,几乎不可能找到有架构师与之合作的软件团队。此外,使用独立样式(一个任务必须在开始新任务之前完成)的组织中,将不同的人员组成不同的团队的想法正在消失。几年前,我们有明确定义的角色,甚至为以下角色设立了专门的部门:

  • 业务分析师

  • 开发人员

  • QA 工程师

  • 架构师

  • 数据库管理员

  • 在基础设施上工作的人员

  • 运营

  • 安全

以下图表显示了团队使用独立样式的工作方式:

作为独立团队工作的团队

前面的列表在特定情况下也在增长。使用独立样式工作的团队过去通常致力于制作定义的工件,如文档、UML 图和通常不完整的其他东西。

这种方法正在改变,现在更常见的是由小型和多学科团队负责照顾应用程序的每一个细节。这种方法有助于创建具有强大技能的积极团队,使我们能够确保软件架构一直在发生。

显然,并非每个团队成员都具备从收集需求到将应用程序部署到生产环境的所有阶段所需的技能,但他们之间的沟通使我们能够减少技术差距,并更好地理解应用程序的整体情况。这是软件架构中最重要的方面之一。

这种共享的知识帮助团队不断改进现有的软件架构,克服最复杂的问题。负责编写软件的所有团队都可以理解正在开发的系统的细节,而不是将这一责任委托给一个人或一个部门。这种方法可能导致我们依赖于可能与应用程序创建的业务背景略有不同的人或团队。这是因为曾经参与项目但现在由于同时参与多个项目而不再积极参与的人无法完全理解每个系统的所有细节。

软件架构原则

遵循两个简单的原则可以改进软件架构,但通常很难实现:

  • 低耦合

  • 高内聚

无论使用什么编程语言、范例或工具来设计应用程序,这两个原则都应该在构建软件架构组件时指导你。

为了构建塑造你的架构的组件,始终遵循指导方针是值得的。即使在存在多年后,这些指导方针仍然相关,并且在创建组件时应始终考虑它们。在这一部分,我谈论的是 SOLID 原则和康威定律,我们将在本章后面更详细地讨论它们。现在是时候更详细地了解组件是什么了。

组件

组件是解决一个问题的一组函数、数据结构和算法。这意味着用于构建组件的所有代码和工件都与彼此具有高内聚性;规则是创建组件的类或文件应该同时且出于同样的原因而进行更改。

软件架构是由许多组件构建的,你不应该担心拥有过多的组件。你写的组件越多,就越自由地将它们分配给不同的开发人员甚至不同的团队。可以使用许多较小的组件创建大型软件架构,这些组件可以独立开发和部署。

一旦我们将这些组件连接在一起,它们就允许我们创建所需的软件架构。

如下图所示,我们可以将组件看作是拼图的一部分,它们汇聚在一起形成一个应用程序:

组件构成了一个更大的应用程序

连接的组件定义了应用程序的架构,它们的设计描述了每个组件内部是如何创建的。在这里,必须使用模式设计和 SOLID 原则来创建良好的设计。

低耦合

低耦合指的是组件之间依赖于它们的低层结构而不是它们的接口的程度,从而在它们之间创建了紧密的耦合。让我们通过一个简单的例子来更容易理解。想象一下,你需要处理下一个用户故事:

作为银行客户,我希望通过电子邮件或传真收到我的银行对账单,以避免必须打开银行应用程序。

正如你可能会发现的,开发人员应该解决这个问题的两个方面:

  • 增加在系统中保存用户偏好的能力

  • 通过使用请求的通知渠道向客户发送银行对账单的可能性

第一个要求似乎非常直接。为了测试这个实现,我们可以使用一些相当简单的东西,比如以下代码:

@Test 
public void 
theNotificationChannelsAreSavedByTheDataRepository() 
throws Exception 
{ 
  // Test here 
} 

对于第二个要求,我们需要读取这些首选通知渠道,并使用它们发送银行对账单。将指导这个实现的测试看起来像下面这样:

@Test 
public void 
theBankStatementIsSendUsingThePreferredNotificationChannels() 
 throws Exception 
{ 
  // Test here 
} 

现在是时候展示一个紧密耦合的代码,以便理解这个问题。让我们看一下以下的实现:

public void sendBankStatement(Customer customer) 
{
  List<NotificationChannel> preferredChannels = customerRepository
  .getPreferredNotificationChannels(customer);
  BankStatement bankStatement = bankStatementRepository
  .getCustomerBankStatement(customer);
  preferredChannels.forEach
  (
    channel -> 
    {
      if ("email".equals(channel.getChannelName())) 
      {
        notificationService.sendByEmail(bankStatement);
      } 
      else if ("fax".equals(channel.getChannelName())) 
      {
        notificationService.sendByFax(bankStatement);
      }
    }
  );
}

请注意,此代码与NotificationService类的实现紧密耦合;它甚至知道此服务具有的方法的名称。现在,想象一下,我们需要添加一个新的通知渠道。为了使此代码工作,我们需要添加另一个if语句,并从此类调用相应的方法。即使示例是指紧密耦合的类,这种设计问题经常发生在模块之间。

我们现在将重构此代码并展示其低耦合版本:

public void sendBankStatement(Customer customer) 
{
  List<NotificationType> preferredChannels = customerRepository
  .getPreferredNotificationChannels(customer);
  BankStatement bankStatement = bankStatementRepository
  .getCustomerBankStatement(customer);
  preferredChannels.forEach
  (
    channel ->
    notificationChannelFactory
    .getNotificationChannel(channel)
    .send(bankStatement)
  );
}

这一次,获取通知渠道的责任被传递给了Factory类,无论需要哪种类型的渠道。我们需要从channel类知道的唯一细节是它有一个send方法。

以下图表显示了发送通知的类是如何重构的,以使用不同的渠道发送通知,并在通知渠道的实现前支持一个接口:

重构后的类

这个小但重要的改变导致我们封装了用于发送通知的机制的细节。这只暴露了一个明确定义的接口,应该被其他类使用。

尽管我们已经展示了使用类的示例,但同样的原则也适用于组件,并且应该使用相同的策略来实现它们并避免它们之间的耦合。

高内聚

高内聚原则也有一个非常简单的定义:一个组件应该执行一个且仅执行一个明确定义的工作。尽管描述非常简单,但我们经常会感到困惑并违反这个原则。

在前面的例子中,我们有NotificationService,负责通过电子邮件和传真发送通知。当我们识别到这个原则的违反时,and这个词对我们可能会有所帮助。现在我们有两个不同的类(每个通知渠道一个),可以说我们的类只有一个责任。

同样,对于组件也是如此,另一个保持相同想法的原因是,您可能会有每个组件只完成一个特定的要求。例如,如果我们所有的客户都只想通过电子邮件收到他们的银行对账单,您认为依赖于具有发送传真能力的类是否可以接受?

尽管前面的问题可能看起来不重要,但想象一下,您解决了使用传真作为通知机制发送通知的现有问题,并且随后错误地引入了一个新问题,以便通过电子邮件发送通知。

请记住,组件塑造了您的软件架构,架构师应该以最大化团队生产力的方式设计它们。将您的组件与高内聚原则对齐是一个很好的方法,可以将它们分开,并允许团队独立地在应用程序的不同部分上工作。创建具有明确责任的各种组件的能力将使在解决其他问题和添加新功能时更容易,并且也会使您更不容易引入错误。

关于前面的例子,您可能想知道为什么NotificationChannel类显然要使用BankStatement参数发送通知。

常识告诉我们,我们需要用任何其他通用类型替换这个类。允许应用程序发送不同类型的通知可能会有所帮助,而不仅仅是银行对账单:这可能包括缺点,或者当账户收到新存款时。即使支持新需求的想法看起来像是你可能想在这个阶段包含在程序中的东西,但应用程序目前并不需要这种能力。这就是为什么我们现在不需要添加这个功能。相反,当这变得必要时,这个设计应该发展;这样,我们遵循了 KISS 原则(https://www.techopedia.com/definition/20262/keep-it-simple-stupid-principle-kiss-principle)并且只构建最基本的功能来使应用程序工作。

SOLID 原则

SOLID 是一个缩略词,代表着指导良好软件设计的五个基本原则。这个设计与塑造软件架构的组件的创建有关。

2004 年,Michael Feathers 向这些原则的作者 Robert C. Martin 建议了这个缩略词。创建它们的过程花了他大约 20 年的时间,在这期间,许多原则被添加、删除和合并,以实现一个强大的名为 SOLID 的原则集。让我们审查每一个原则,并提供一个简明清晰的解释,这将有助于准确理解我们如何使用它们。

我们将使用术语“模块”来配合模块塑造组件的想法,并且我们将引用面向对象编程(OOP)世界的术语,比如类和接口,以便更精确地解释模块。

单一职责原则(SRP)

SRP 与我们之前审查的高内聚性密切相关。这个原则背后的想法是,一个模块应该只因一个原因而被改变。

这个定义让我们得出结论,一个模块应该只有一个职责。验证你的设计是否实现了这个原则的一种方法是回答以下问题:

  • 模块的名称是否代表其公开的功能?

答案应该是肯定的。例如,如果模块的名称指的是领域,那么模块应该包含领域类和围绕模块名称本身的领域对象的一些功能。例如,你不会希望有支持审计元素或任何其他超出你正在处理的模块范围的代码。如果模块支持额外的功能,支持这些额外功能的代码可能需要移动到现有的审计模块,或者需要创建一个新的审计模块。

  • 当需要进行新的更改时,模块的多少部分会受到影响?

对这个问题的答案应该是很多;模块中的所有类都高度相关,一个新的更改会因此改变它们。期望的行为通过公开接口阻止被更改,但后台实现通常是不稳定的。

开闭原则(OCP)

OCP 很容易写,但很难解释。因此,我将首先写下以下定义,然后再描述它:

可以通过扩展而不是修改向现有模块添加新功能。

听起来很简单,不是吗?为了从实际角度理解这个概念,有必要重新审视我们上一个例子。让我们通过回答以下问题来检查我们是否遵循了这个原则:

  • 为了支持新的通知渠道,我们需要什么?

我们需要编写一个新的类(模块),这个类应该实现一个现有的接口。注意到开闭原则与提供的答案是如何合理的。为了在我们的应用程序中支持新的通知渠道,我们需要创建一个新的类,但不需要修改现有的代码。根据我们之前进行的重构,如果我们需要支持这个需求,我们必须调整现有的服务来发送通知。

验证这一原则实现程度的一些问题如下:

    • 我需要在我的代码中添加一个新的IF语句吗?

不。如果你想要添加一个新功能,你会编写一个新的类而不是修改现有的类。这是因为你是在添加而不是改变功能。

    • 为了支持一个新功能,我需要修改多少代码?

希望只是一点点。在理想的世界里,你不需要修改任何东西,但有时为了支持现实世界中的新功能,可能需要改变一些部分。规则是,如果你要添加一个新功能,你的原始设计应该能够以最小的改动来支持这个需求。如果不是这样,建议重构或更改初始设计。

    • 我的源代码文件应该有多大?

大型源代码文件是一个坏主意,也没有理由让它们变得庞大。如果你的源代码文件有成百上千行,重新审视你的函数,并考虑将代码移动到一个新文件中,以使源代码文件变得更小且易于理解。

    • 我应该在我的代码中使用抽象吗?

这是一个棘手的问题。如果对于某个东西你只有一个具体的实现,那么就不需要有一个抽象类或接口。编写代码和想象新的可能场景都是不可取的,但如果你至少有两个相互关联的具体实现,你必须考虑为它们编写一个抽象。例如,如果我们只需要发送电子邮件通知,那就没有理由为此编写一个接口。然而,由于我们通过两种不同的渠道发送通知,我们肯定需要一个抽象来处理它们。

里氏替换原则

里氏替换原则(LSP)有一个花哨的定义:

模块 A 可以被模块 B 替换,只要 B 是 A 的子类型。

明确定义的契约大大支持这一定义,并帮助我们减少模块之间的耦合。以下问题可以帮助你确定这一原则的实现程度:

  • 模块之间是使用抽象还是具体实现进行互动的?

在这里,答案应该是模块不应该与任何选项互动。没有理由使用它们的具体实现而不是它们的接口来建立模块之间的互动。

  • 我应该强制转换对象以便使用它们吗?

希望不需要。如果需要,那是因为接口设计不好,应该创建一个新的接口来避免这种行为。也不希望使用instanceOf函数。

  • 模块之间的互动是否由IF语句引导?

没有理由这样做。你的模块应该以一种可以通过接口和正确的依赖注入来解决它们的具体实现的方式相互连接。

接口隔离原则(ISP)

接口隔离原则的主要动机与精益运动一致,即用更少的资源创建价值至关重要。以下是它的简短定义:

避免不使用的东西。

你可能已经看到类(模块)实现了一些方法实现的接口,例如以下内容:

public  class Abc implements Xyz 
{ 
  @Override 
  public void doSomething(Param a) 
  { 
 throw new UnsupportedOperationException 
    ("A good explanation here"); 
  } 
  // Other method implementations 
} 

另一个选择是comment as implementation,如下所示:

public  class Abc implements Xyz 
{ 
  @Override 
  public void doSomething(Param a) 
  { 
 // This method is not necessary here because of ... 
  } 
  // Other method implementations 
} 

前面的例子成功地描述了创建这一原则的问题。解决这个问题的最佳方法是创建更一致的接口,符合其他解释的原则。这个问题的主要问题与有空方法实现无关,而是具有根本没有被使用的额外功能。

假设一个应用程序依赖于XYZ库,系统只使用了可用功能的 10%。如果应用了新的更改来解决其他 90%存在的问题,那么修改后的代码对应用程序正在使用的部分构成风险,即使它与之没有直接关联。

以下问题将帮助您确定您的表现如何:

  • 我是否有空的或愚蠢的实现,就像前面提到的那样?

请不要回答 YES。

  • 我的接口有很多方法吗?

希望不是,因为这将使在具体实现中实现所有抽象方法变得更加困难。如果你有很多方法,请参考下一个问题。

  • 所有方法名称是否与接口名称一致?

方法名称应该与接口名称一致。如果一个或多个方法根本没有意义,那么应该创建一个新的接口来放置它们。

  • 我可以将这个接口分成两个而不是一个吗?

如果是的话,继续做。

  • 我从所有公开函数中使用了多少个功能?

如果与接口交互的模块只使用了少量公开函数,那么其他函数可能应该移动到另一个接口,甚至移动到新模块。

依赖反转(DI)原则

现在是时候定义依赖反转原则了:

模块应该依赖于抽象而不是具体实现。

抽象代表模块的高级细节,模块之间的交互应该在这个级别进行。低级细节是不稳定的,不断发展的。我们之前说过,进化的模块没有问题,但当然,我们不希望因为低级细节而破坏模块之间的交互,一个很好的方法是使用抽象而不是具体实现。以下问题将帮助您确定您的表现如何:

  • 我的模块中有抽象吗?

正如本章前面讨论的那样,许多具体实现应该在其前面有一个抽象。然而,当涉及到一个特定的实现时,情况可能并非如此。

  • 我是自己每次都创建新实例吗?

这里的答案应该是否定的。负责应用程序内部依赖注入的框架或机制负责执行此操作。

康威定律

Mel Conway 在 1968 年发表了一篇至今仍然相关的论文,阐述了公司应该朝着的方向。长期以来,我们一直致力于为一切定义规则,例如以下内容:

  • 你应该在什么时间到达办公室

  • 人们应该工作的最少小时数

  • 每周工作几天

  • 在工作时间穿什么类型的服装是合适的

这些规则适用于任何类型的公司,在许多情况下,它们仍然具有相关性。在 IT 世界(尤其是软件行业)中,我们创建了另一套规则来指导我们的团队(如果你不想感到无聊,可以随意避免阅读这些规则):

  • 业务分析师应该创建具有明确定义结构的用例,使开发人员可以忽略业务细节,专注于流程的技术部分。

  • 开发人员应该遵循产品软件架构师多年前编写的标准文档。

  • 每天写的代码行数应该表明开发人员的生产力。

  • 当你创建一个新的数据库对象时,你必须更新现有的可信数据库字典。

  • 一旦你的代码准备好推送,使用电子邮件模板请求 QA 团队进行审查。经过他们的批准后,再次与设计团队重复此过程,然后再次与架构团队重复此过程。

  • 对推送的代码进行任何更改都将迫使你重复前面规则中解释的过程。

  • 在完成编码分配的用例后,不要忘记 UML 图。并非所有图都是必需的,只有最重要的图,比如这里列出的图:

  • 类图

  • 对象图

  • 包图

  • 组件图

  • 序列图

  • 部署图

在某些情况下,前面列出的图表将更大。幸运的是,现在情况已经改变,不再使用迫使我们编写大量文件并创建不注意的不同图表的疯狂流程。在这些前提下,Mel Conway 在他的论文中写道:

“任何设计系统的组织最终都会产生一个结构与组织沟通结构相同的设计。”

Conway 的论点仍然相关,并且自那时以来一直影响着我们构建团队以创建成功项目并避免浪费资源的方式。

人们组成团队,如何安排这些人以创建成功的团队的问题在过去几年中已有多种回答。所有这些答案都建议建立小型和多学科团队,这些团队应该足够小,可以用一块披萨来供应,并且多学科足够,以避免在 SDLC 期间创建孤立。

这样,公司正在促进团队内的共享文化和持续学习。团队不断从成功和失败中学习。他们直接相互交流,而不是使用中介或其他通信协议。

团队定义了业务边界,使他们能够使用明确定义的接口进行通信,由于通信是由他们自己直接管理的,快速反馈将使他们能够在必要时解决问题并采取纠正措施。

为自己选择合适的技术

在本章的前面,我们定义了软件架构是什么,以及围绕它的相关元素是什么。我们还提到,框架、编程语言、范例等并不是应该指导你的软件架构的基本元素。许多人支持尽可能推迟尽可能多的技术决策的想法,以便使你的设计对新选项开放,这是值得做的。然而,你不能永远推迟这些选择。

市场上有很多框架可用。其中许多是新的,但旧的框架仍然可用。即使在过程的开始阶段,当所有这些都只是细节时,你也需要仔细选择你将用来构建软件架构的框架,因为这个细节将根据你实现的功能来解决业务需求,使你的生活更轻松(或更困难)。我将向你展示在决定使用哪个框架时需要考虑的一些因素:

  • 有多少文档可用?

这是一个重要的考虑因素。在这里,你必须考虑为供应商编写了多少文档,以及在线有多少课程(不仅供应商提供的,还有其他开发人员提供的)。如果你能找到书籍、文章和展示,总是值得探索,因为它们将使你了解你决定使用的工具。

  • 你选择的技术周围的社区有多大?

有很多人致力于改进产品是你应该欣赏的。你的选择不仅应得到供应商的支持,还应得到其他开发人员和公司的支持,他们使用产品来解决他们的需求。

  • 使用你心目中的定义的技术编写测试是否困难?

无论你的编程风格是什么,将测试包括在你的 SDLC 中总是有益的。你还将受益于为软件的另一个方面(或至少单元测试、集成测试、功能测试和负载测试)包括测试。如果你的框架使这项任务变得困难,最好选择另一个。如果你正在使用依赖注入框架 ABC,这应该被测试,但如果这些测试很难编写,你就不会想浪费时间在这上面。考虑到这一点,Spring 对测试有很好的支持,我们将在后续章节中使用实际操作来介绍这一点。

  • 我可以插入组件以添加更多功能吗?

你可能会想“如果我想添加一个新组件,我可以简单地包含一个 JAR 文件”。在某些情况下,这是正确的,而在其他情况下,你需要发现一整套依赖项来使其工作。这是一个痛苦的过程,因为有时你需要特定版本的特定库,这更难以自己解决,这不是你应该花太多时间的事情。Spring 包括 Spring Boot,它有一种很好的方法来以简单的方式向你的项目添加依赖。你只需要在应用程序创建过程中指示 Spring 你想要使用 JPA(例如),Spring 本身就能够找出使其工作所需的所有依赖项。

当你第一次寻找合适的构件来启动你的应用程序时,可能会在 Maven 上遇到一些困难。Spring 的好消息是,你可以使用 Spring Initializer,在几次点击中启动你的应用程序。你可以参考start.spring.io获取更多详情。

  • 公司使用这个产品做什么?

即使市场上充斥着看起来很有前途的新工具,当选择技术和框架时,你也不会想要赌博。在选择框架或技术之前,我鼓励你观看一些 YouTube 上的会议视频。如果有机会,最好能去参加其中之一。你还将受益于阅读关于特定技术的论文、展示和案例研究,以及哪些公司正在使用这些技术。你甚至可以根据这些信息开始建立类比,以便弄清楚特定技术对你的适应程度。

然而,多年来,我看到人们如何使用 Spring 来满足不同行业的业务需求。

这个框架是成熟的,并不断发展,以拥抱软件行业中的新编程风格和新技术。例如,最新版本的 Spring 包括对 Java 世界和整个行业引入的最新功能的支持,如响应式编程、最新的 Java 版本,甚至对其他变得流行的编程语言的支持,如 Kotlin 和 Groovy。

新趋势

在过去几年里,许多编程语言已经出现,以解决新的业务需求,其中许多在 JVM 上运行,这给 Java 开发人员带来了重大优势,使得接受新的编程语言变得不那么困难。

新兴的软件架构的出现并非偶然。业务已经扩展到全球,这使得扩展旧应用程序变得更具挑战性。这种方法迫使我们重新思考如何划分业务边界,以便提供可扩展的服务来解决业务需求。由于我们需要向全球客户提供服务,云出现了,如今我们甚至可以选择区域来减少应用程序的延迟。

随着云计算准备就绪,X 作为服务范式出现了。我们现在有针对特定要求创建的服务,比如在线支付、身份验证、数据存储等。这导致了无服务器架构的创建;通过这些,公司更多地关注他们的业务需求,而不是那些被其他公司解决并作为现成服务提供的细节。

拥有世界各地的客户意味着有更多的数据需要存储,改进的数据存储正在取代旧的关系模型。NoSQL 被迫被构想出来,而像规范化这样的推荐技术已被这些模型取代,使以前良好的做法和建议现在完全无用。这一运动甚至迫使围绕它产生了新的职业。我们目前正在研究这些数据并使其有价值。数据科学家如今变得很受欢迎,他们的角色是识别数据背后隐藏的其他业务机会,以及基于此需要采取什么行动的 IT 人员。

让客户快速消费服务是公司正在寻找的功能,而会话界面正在引导我们走向正确的道路。包含软件的设备允许人们使用他们的语音建立对话(如 Alexa、Cortana 和 Siri 等),为消费服务提供了更简单、更快速的新可能性。SDK 工具目前适用于许多编程语言的开发人员,因为多语言开发人员如今是最常见的。

并非所有企业都需要拥抱这些新趋势。然而,这些新选择正在向公司介绍一个充满机遇的世界,这将使它们比不拥抱这些趋势的公司具有优势。

摘要

在本章中,我们探讨了与软件架构相关的基本概念。即使这些原则在行业中已经存在一段时间,它们仍然是相关的,而且在处理架构方面时值得考虑。需要记住的是,高内聚和低耦合是指如何连接组件来塑造软件架构,而 SOLID 原则适用于每个组件的设计。

总之,在本章中,我们讨论了软件行业如何发展以应对公司目前面临的新业务挑战。在下一章中,我们将深入了解软件架构的维度,并学习如何使用 C4 模型来记录软件架构。

第二章:软件架构维度

在上一章中,您了解到软件架构是团队在构建产品或服务时的共享知识,以及围绕这一概念的其他重要方面。架构师的工作是与整个团队分享这些知识。即使团队没有专门的架构师,个人通常最终会成为系统架构的负责人。

在本章中,我们将审查软件架构维度以及它们如何影响我们的应用程序。我们还将介绍一种用于记录软件架构并使团队更容易共享和理解架构的模型。最终,这将使他们能够理解软件架构的整体情况。

本章将涵盖以下主题和子主题:

  • 软件架构维度:

  • 业务维度

  • 数据维度

  • 技术维度

  • 操作维度

  • C4 模型:

  • 上下文图

  • 容器图

  • 组件图

  • 类图

维度

根据谷歌的说法,“维度”一词有几个含义。让我们使用以下定义,它适用于我们将在本节讨论的上下文中:

“情况、问题或事物的一个方面或特征。”

从这个定义开始,我们将把维度视为影响和指导我们构建的软件架构的方面或特征。

在上一章中,我们谈到了在制定解决方案时理解业务领域的重要性。当然,当生成能够满足所有业务需求的系统时,这种知识是不够的。您还需要考虑从技术角度支持这些解决方案的机制,同时不要忘记业务需求。作为技术人员,我们需要提供一个能够随着时间推移而发展的解决方案,以满足新的业务需求并有效实现目标。

以下列表包括在制定软件架构过程中最常见的维度:

  • 业务

  • 数据

  • 技术

  • 操作

根据您正在处理的解决方案的上下文,您可以向此列表添加一些额外的要点。当您从技术角度查看产品时,这四个维度在很大程度上是相互关联的,并且应该被负责系统的整个团队理解。

业务维度

这是我们构建软件时最关键的方面,这就是为什么软件行业一直在发明新的方法来收集需求。在这个维度内,应该有效地完成两项相关活动,如下所示:

  • 管理用户需求并清晰了解业务领域模型

  • 识别和跟踪业务指标

管理用户需求

几年前,我们习惯于编写用例,最近几年已经改名为“用户故事”。然而,这里的关键并不在于名称,无论您使用老式方法(如 Ration Unified Process(RUP))还是最前沿的框架(如 Scrum)来构建项目,都没有关系。了解业务领域并拥有产品将使团队能够开发成功的项目。

你可能知道,RUP 是一个软件开发框架,定义了一系列阶段,并且每个阶段都有大量的文档。这里的想法是确定在每个阶段生成什么样的文档。这项任务很繁琐,团队经常会定义大量无用且耗时的文档,而没有为产品提供任何附加值。作为创建文档的替代方案,我们将在本章后面讨论 C4 模型。

多年来,已经有很多书籍介绍了如何管理用户需求。其中两本是 Alistair Cockburn 的《编写有效的用例》和 Mike Cohn 的《用户故事应用》。这些书是最相关的,您应该考虑阅读并将它们作为您的图书馆的一部分,以便在必要时用作参考来源。

高效地收集用户需求的过程应该是项目愿景和目标的一部分。与尽可能多参与项目的人进行头脑风暴会有利于让负责软件实施的团队区分“最小可行产品”(MVP)和期望的可有可无的功能,这些功能将作为新版本的一部分在 MVP 版本完成后实施。

了解正在构建的软件的 MVP 至关重要;它应该为您提供满足用户需求的最少功能。一旦确定了这些功能,还需要为其定义验收标准。从这里构建的产品将用作从业务人员那里获取反馈(以纠正任何误解)的基础,并且还将用于添加新功能(以扩展解决方案)。

今天,我们还依靠缺陷跟踪系统将用户需求编写为具有不同分类的工单,例如缺陷、用户故事和突发事件等。这些工单用于更好地了解实施一个功能需要多长时间,以及涉及多少缺陷。

以这种方式处理业务需求为我们提供了有用的信息,可以在以后进行分析,以改进团队的绩效以及其组织方式。有很多关于如何管理工单的解释,但如果您想更好地了解缺陷跟踪的原则,我鼓励您阅读 Yegor Bugayenko 撰写的一篇有用的文章,该文章可在www.yegor256.com/2014/11/24/principles-of-bug-tracking.html上找到。

识别和跟踪业务指标

收集了业务需求之后,业务维度的另一部分出现了,其中包括一种识别解决的业务问题周围的基本指标的方法。这些指标应该根据业务领域确定并表达,以便了解应用程序如何满足其设计的业务需求。

让我们重新审视前一章中使用的一个例子。假设银行目前正在使用邮局向客户发送每月的银行对账单。在这种情况下,您预先知道成本和实现目标所涉及的任务。此外,您甚至知道您有多少客户以及根据特定日期应该打印多少纸张。在系统实施后,您将希望知道所有客户是否都收到了他们的银行对账单。因此,您将希望实施一种机制来识别应用程序发送了多少银行对账单,以及哪种通知渠道更受欢迎。这些信息将在不久的将来用于识别新的商机,发现系统存在问题的时间,并监控应用程序的投资回报率。毕竟,系统的实施是由企业需求引导的,您必须验证这些需求是否得到满足。

一旦应用程序投入生产,评估应用程序在实际环境中的业务健康状况的一个绝佳技术是构建机器人。这些机器人会像普通用户一样使用您的应用程序;您至少应该围绕应用程序最重要的功能创建机器人。否则,您如何知道您的应用程序是否正常工作呢?

通过执行定期检查来实现这个目标,这些检查将向您发送通知并提供获得的结果。这种简单的技术将让您确信应用程序正在按预期工作,并为您的客户提供服务——他们是系统的目的。

数据维度

数据被认为是任何业务中最关键的资产之一,这就是为什么你必须投入大量时间来找出处理它的最佳方法。

如今,在选择我们的数据处理方法时,我们有很多选择。在过去几年里,许多种数据库和数据存储已经被创建,包括以下内容:

  • 文件云存储

  • 关系数据库

  • 面向文档的数据库

  • 实时数据库

  • 图数据库

  • 内存数据库

你的选择应该取决于你要解决的问题,而不是取决于像 Facebook、Google 和 Amazon 这样有影响力的在线公司所使用的选项。

记住,不同的业务需求需要不同的方法。

你现在可能想知道应该选择什么样的数据存储。对这个问题最常见的答案是,这取决于上下文。

然而,依赖于上下文可能不是理想的答案,因为它并没有提供太多指导。考虑到这一点,可以给出的最佳建议是尽量多地进行类比,以找出最佳的数据存储方法。要记住的一点是:不要因为 NoSQL 数据库固有的最终一致性而感到害怕。我见过很多人因为这个原因而放弃这种类型的数据库。你必须明白,最终一致性根本不是技术问题,而是业务问题。让我解释一下为什么。

考虑第一章中提到的例子,《今日软件架构》,假设你被要求在一个具有以下描述的系统中实现一个新功能:

"我们注意到通知渠道并不总是按预期工作,所以我们决定在这种情况下使用备用渠道。例如,如果用户将电子邮件配置为首选渠道,那么如果失败,应该使用短信渠道。另一方面,如果用户将短信配置为首选通知渠道,那么如果失败,应该使用电子邮件通知作为备用。"

注意,这个要求不符合标准的用户故事格式:

"作为<用户类型>,我希望<目标>,以便<原因>..."

然而,对于负责处理它们的团队来说,这些要求很容易理解和实现。因此,我之前提到,即使你仍在处理用例或用户故事,业务需求也是最重要的方面。

一个不需要最终一致性的例子是 Facebook 帖子的排序,其中每个帖子都有时间戳。在这里,当一个人给帖子添加评论时,他们认为他们看到的是他们上面的最后一条评论,但几秒钟后,他们会看到其他评论确实在他们的评论之前添加了。当这种情况发生时,可能会令人困惑。然而,不对评论顺序施加原子性要求允许 Facebook 全球扩展数据库,每秒覆盖数百万帖子。相比之下,对于转账交易,需要原子事务以保持一致性,避免欺诈或浪费金钱。

总之,你首先必须了解你想要完成的业务需求,尽可能多地将其与市场上可用的选项进行类比,然后从这些选项中做出选择。一旦你做出了决定,就值得依靠框架来让你与你选择的数据存储进行交互。幸运的是,Spring Data 支持大量的数据存储选项。我们将在下一章讨论使用这个 Spring 项目的好处。

技术维度

这一维度涉及深入探讨技术细节。让我们讨论一些有用的问题,你将不得不回答以实现这个目标,如下:

  • 我应该选择什么样的软件架构风格?

目前有很多选择。本书的后续章节将详细解释其中的许多选择,你可能会在那里找到答案。

  • 哪种编程语言适合我的应用程序?

市场上有许多编程语言承诺是最好的。因此,你必须避免仅仅因为它是最新的或最新的而选择一个。相反,你必须选择一个广为人知的适合你的。

依靠庞大的工具生态系统始终是必要的,并且应该成为你决策的一部分。你决策的另一部分应该是找到合作伙伴的难度。你不太可能想要使用不太熟悉的编程语言来构建你的软件。毕竟,你希望创建一个长期存在的应用程序,这意味着许多人将参与编写代码,使其随着时间的推移而发展。

由于这本书的重点是 Spring 平台,我将讨论使用 Java 和 Java 虚拟机(JVM)的好处。

我们都知道 Java 是一种得到广泛支持的编程语言,已经被用来构建大量的企业应用程序;这个事实让我们有信心说它已经足够成熟,可以编写几乎任何类型的企业软件。另一方面,JVM 建立在“一次编写,到处运行”的前提下。这一点很重要,因为目前有相当一部分企业应用程序正在 Linux 服务器上运行;然而,这并不意味着你需要强迫你的团队使用 Linux。相反,他们可以继续使用他们喜欢的操作系统,因为 JVM 可以在 Windows、Linux 和 Mac 上运行。

在过去的几年里,许多编程语言已经被编写并广泛采用来解决不同类型的问题。其中许多运行在 JVM 上,比如 Scala、Groovy、Kotlin 和 Jython,因为这样做带来的好处。所有这些编程语言的编译代码都转换成了字节码,可以与 Java 代码互动,引入了新的机会。尝试新的编程语言总是一个好主意,看看它们在不同的场景中如何工作,以及如何满足不同的需求。例如,Groovy 是一种友好的编程语言,简单易用。在接下来的章节中,我们将使用在 JVM 上运行的不同编程语言开发一些示例应用程序。这些示例将帮助你将 Groovy 作为你的工具箱的一部分。

  • 哪种框架适合我?

即使 Java 世界拥有大量的框架,我们仍然鼓励你使用 Spring,不仅因为这本书是关于它的,而且因为它提供了以下好处:

  • 许多之前列出的编程语言都得到了支持

  • Spring 提供了几乎任何类型的应用程序构建的机会

  • 学习曲线不是什么大问题

  • 它对单元测试和集成测试有很好的支持

  • Spring 项目使你的解决方案能够成长(我们将在下一章讨论这些)

  • 它与你选择的 IDE 的集成非常出色

  • 它有一个伟大的社区

  • 在互联网上有大量关于 Spring 的学习资源

  • 它提供了与最常见的 Java 框架(如 Hibernate、iBatis、Atomikos、Vaadin 和 Thymeleaf)的平滑集成

如果这个列表对您来说还不够,随时在 Google 中输入“为什么我应该使用 Spring”,您会得到一个惊喜,并且会有信心使用 Spring 框架。

运营维度

这个维度指的是将您的架构组件映射到服务器上。这些服务器可以在本地或云上运行。在过去几年中,云计算变得越来越重要,现在,我们可以说,对于每个企业来说,依赖云上的服务几乎是必不可少的。

您的软件架构组件的映射将取决于它们的功能以及组件之间的交互方式。

如何部署应用程序

这一点非常重要,因为部署 Rest API 不同于部署分布式数据库或大型单体应用程序。为了更好地了解部署组件的最佳方法,您需要研究支持它的产品。这可能非常简单;例如,通过部署一个可以像常规 Java 应用程序一样运行的 Spring Boot 应用程序,使用以下广为人知的命令:

java -jar artifact_name.jar

然而,在其他情况下,一些产品提供了作为集群部署的机会,您需要考虑所有可用的选项以及您的需求。根据您的软件需求有多高,您将需要有更少或更多的节点来满足用户的需求。您可能已经注意到,即使这个维度也是从业务中衍生出来的。

您的组件之间的交互如何发生

让我们想象一下,我们有一个常规的 Web 应用程序,将信息保存在数据库中。这个 Web 应用程序部署在服务器 A 上,数据库部署在服务器 B 上。常识告诉我们,无论这两台服务器位于同一个数据中心还是不同的数据中心,延迟都不会相同。另一个考虑因素当然是最终用户的位置。如今,云计算提供了选择在哪里部署组件的机会,这取决于您的需求,这在提供更好的用户体验时非常有帮助。

处理基础设施

考虑了这些因素之后,需要考虑的另一个方面是如何管理基础设施。

我们都知道,当我们需要从头开始启动新服务器时,总是一件头疼的事,因为我们需要安装操作系统和所有必需的工具,使我们的应用程序正常工作。其中一些需要特定的文件、目录、环境变量和其他工件才能正常工作,这使得这个过程变得更加复杂。幸运的是,下一节讨论的基础设施即代码方法将帮助我们减少新服务器的配置工作量,并为我们带来其他好处,例如以下几点:

  • 了解基础设施

  • 版本控制

  • 测试

了解基础设施

文件用于存储所需的配置和步骤,以可执行脚本的形式来配置服务器。当需要对现有服务器进行新的调整时,想法是使用脚本文件进行这些更改,而不是直接在服务器上进行。这将使我们获得以下好处:

  • 不可变服务器

  • 轻松应用更改

  • 拥有多个相同的服务器

  • 快速从头开始重建新服务器,无错误

此外,技术人员将能够阅读和理解这些脚本,从而增加对新基础设施配置过程的共享理解,这是非常好的。

版本控制

使用版本控制系统(VCS)对编写的脚本进行版本控制将使我们能够跟踪脚本文件的更改;这有助于增加正在用于塑造你的基础设施的编写代码的可审计性。在版本控制过程中,可以(也应该)触发构建来验证编写的代码。

版本控制的另一个好处是在需要时可以回滚更改。想象一下,你正在编写代码来升级你的服务器;如果在这个过程中引入了问题,你总是可以进行回滚,并继续使用上一个稳定版本,直到问题解决。

测试

如果没有经过测试,就无法知道某个东西是否按预期工作。将基础设施视为代码使我们能够测试用于实现此目标的代码,以验证并确保其按预期工作。否则,你将需要手动进行这些验证,并考虑在此级别涉及的调试过程,以确定错误的位置。即使你可以在没有测试的情况下拥有基础设施作为代码,也强烈建议对已创建的脚本运行测试。

采用基础设施作为代码的方法将帮助我们充分利用计算机和系统,以使这个过程对我们来说变得不那么繁琐,人们应该只在出现问题时才在数据中心前工作。这也将帮助你以一种快速简便的方式保持你的基础设施更新。如果你想深入了解如何有效地采用这种方法,我鼓励你阅读 Kief Morris 的《基础设施作为代码》一书。

云与本地

在使用云上服务器和使用本地服务器之间做出选择是一个重大决定,受到你的业务的限制和需求的影响。出于安全原因,一些公司受限于使用本地基础设施,这可能是因为对云安全管理方式的误解。无论如何,这种限制都使得任何将基础设施迁移到云上的尝试都变得无效。

另一方面,如果你有机会在这两个选项之间进行选择,我鼓励你使用云。它提供了许多好处,比如按需付费,这将使你在应用程序首次发布期间节省大量资金。一些服务根据你的需求和所使用软件的许可模式,每小时收取几美分的费用。例如,使用免费和专有软件与使用带有 Windows 或 Linux 的服务器是不同的。同样,使用关系型数据库管理系统(RDBMS),如 MariaDB 或 Oracle,也是不同的。

即使你选择使用云,你也需要考虑一些因素,以便根据所需的功能选择适合你的云服务提供商。一些云服务提供商,如 AWS,提供了大量的计算、存储、管理工具、分析等服务,而其他一些,如 Heroku,提供了足够的功能,取决于你的需求。选择一个提供商仅仅因为它提供更多服务并不是一个好主意,因为这也意味着更高的成本。即使不同供应商提供的服务数量相似,使用前面提到的供应商部署应用程序的过程的简单性也是显著的。

部署你的应用程序

编写不会被投入生产的代码是没有意义的。无论你是将应用程序部署到云上还是在本地环境中,你都可以使用一些技术和工具来自动化部署过程。这将帮助你减少所需的工作量。

在几年前的软件系统部署过程中,整个应用程序编写团队不得不与运营团队一起坐在一起,以防万一出了问题。因此,部署日期过去对于项目中涉及的技术和业务人员来说都是可怕的。幸运的是,这种情况已经改变了。让我们回顾一下这种变化是如何发生的。

当我们进入这个领域时,自动化是必须的。有许多 CI 工具可用于创建流水线,这将帮助您自动化部署。其中最广泛使用的是 Jenkins、Travis CI、Go CD、Codeship 和 Bamboo 等。借助这些工具,您可以创建一个通常包括以下内容的流水线:

  1. 下载源代码

  2. 编译代码

  3. 运行一组定义的测试

  4. 部署代码

主要步骤是第三步,涉及不同类型的测试,比如这里列出的测试:

  • 单元测试

  • 集成测试

  • 功能测试

  • 性能测试

如果你在应用程序中包含更多的测试,你将获得更多的信心。这是摆脱部署恐惧的唯一途径。毕竟,如果你的测试验证功能按预期工作,就没有理由担心部署。

这些 CI 工具还包括支持发送有关流水线的通知,生成围绕代码的指标,执行配置脚本,并完成一些与部署相关的其他步骤。这些流水线通常由提交触发,也可以被调度。

采用 CI 工具是朝着更好地自动化和管理部署的第一步。在这一点上,你将希望采用持续集成、持续交付和 DevOps 等实践,我们将在第十一章中深入解释,DevOps 和发布管理

C4 模型

一般来说,如果某事物不可见,它就不会产生期望的效果。即使是使用最尖端技术生产的最先进软件,如果工作在其上的团队无法理解它,那么它就是完全无用的。团队所付出的所有努力都将是浪费时间。

仅仅设计软件架构是不够的。它必须以一种允许整个团队正确使用它的方式与整个团队共享。架构师制作的文档今天代表了他们,当他们应该做其他事情而不是回答关于软件架构的一百个问题时,它代表了他们,明天当他们离开项目,其他人负责其演进和维护时,它也代表了他们。

敏捷宣言的第二个原则(agilemanifesto.org)是“团队应该重视可工作的软件,而不是全面的文档。”这经常被人们错误地解释为不应该产生任何文档。相反,这个原则背后的想法是鼓励团队只产生有价值的文档,这正是 C4 模型所寻求的。

这个模型提供了一种向整个团队传达系统设计的简单方法。它从高层视角开始,并可以用来深入到(或将要)生产的软件的最小细节。这个模型提出了四个图表,如下:

  • 上下文图

  • 容器图

  • 组件图

  • 类图

上下文图

上下文图提供了用户和其他软件系统的整体情况,以及它所互动的情况。为了保持简单易懂,所有技术元素都应该避免。上下文图应该足够简单,以便非技术人员能够理解。

以下显示了一个为第一章中提出的示例进行上下文化的图表,今日软件架构

上下文图

容器图

容器是负责承载代码或数据的单元。因此,这个图表展示了应用中涉及的容器,提供了它们如何相互交互的高层细节,以及一些其他技术细节来说明系统的工作原理。让我们看看这个图表如何适用于我们的例子:

容器图

组件图

这个图表的理念是展示容器是如何由组件和它们之间的交互所塑造的。我们例子的组件图如下:

组件图

类图

由于 C4 模型的主要理念是去除不必要的图表,类图应该被避免,只有在必要时才应该用于说明应用程序的具体细节。这个图表是为技术人员设计的,当应用程序中有一些元素需要人们密切关注时可以使用它;它也可以用于澄清可能导致混淆的代码中的特定部分。

虽然这个图表对我们的例子来说并不必要,但我们将展示它以作说明用途:

类图

正如你可能已经注意到的,我们提出的这四个图表并不难创建,而且在获得对系统更好理解时是有帮助的。即使它们很简单,定期审查这些图表以确保它们是最新的总是一个好主意。过时的文档可能导致误解,而不是改善对系统的理解。

请随意避免创建任何你认为不必要的图表。投入时间建立不必要的工件是应该避免的事情。

摘要

在本章中,我们讨论了与软件架构相关的四个主要维度,并看了它们如何影响我们构建应用程序的方式。我们还回顾了用于记录系统架构的 C4 模型,使用了一种精简的方法,帮助我们避免浪费时间创建不必要的文档。

在下一章中,我们将回顾 Spring 项目以及它们如何用于创建满足不同业务需求的应用程序。

第三章:Spring 项目

在本章中,我们将回顾一些 Spring 项目,简要解释每个项目,并探讨它们可能被使用的一些场景。

本章将涵盖以下主题:

  • 为什么出现了 Spring

  • Spring 项目:

  • Spring Initializr

  • Spring Boot 简介

  • 使用开发者工具避免重新部署

  • Spring Data

  • 使用 Spring Integration 支持 EIP

  • Spring Batch

  • 使用 Spring Security 保护应用程序

  • 拥抱(Spring)HATEOAS

  • Spring Cloud 和微服务世界

  • 响应式和 Spring

  • 响应式 Spring Data

  • 响应式 REST 服务

为什么出现了 Spring

正如你可能知道的,Spring 是为了简化 J2EE 世界的所有复杂性而创建的。它被创建为一个依赖注入框架,作为 EJB 堆栈的替代品,分布式对象在大多数应用程序中是不必要的。传统的 J2EE 方法在用于引导应用程序时引入了很多复杂性,当用于解决业务需求时,这甚至更加复杂。因此,我们留下了难以测试且开发和维护成本过高的应用程序。

Spring 和 J2EE 是在 Java 还没有注解时创建的,因此需要大量的 XML 文件来连接类。幸运的是,在 Java 开发工具(JDK)的 1.5 版本中引入了注解,这有助于减少这些描述文件的需求。

Spring 的发展速度比 JEE 快,因为它不必满足与 JEE 所需的大型委员会交流的正式性。当需要将新功能纳入 JEE 规范时,必须创建 JSR 文档,并经 JCP 批准。这样做的主要动机是确保规范的不同版本之间的向后和向前兼容性。另一方面,Spring 是一个不断发展的项目,考虑到软件行业不断变化的性质。

当需要新功能时,它要么作为现有项目的一部分,要么创建一个由 Spring 项目支持的新项目。不必担心兼容性问题,因为 Spring 被设计为可以在任何 servlet 容器上运行,如 Apache Tomcat、Jetty 等。这与 JEE 应用程序相反,后者只能在实现 Java EE 规范并提供标准 Java EE 服务的服务器上运行。

Spring 项目

Spring 项目利用了一个生态系统的工具,可以用来创建不同类型的应用程序,以实现不同的目标。所有这些项目都围绕 Spring 构建,这是一个合法的模块化框架,可以将单独的 Spring 项目插入,以使应用程序处理更多的技术需求。如果您对 Spring 项目的完整列表感兴趣,可以访问它们的主页Spring.io/projects

我们将回顾最常用的 Spring 项目来构建企业应用程序,但首先,我们将介绍 Spring Initializr,这是 Spring 开发人员首选的网站之一。

Spring Initializr

当我们计划从头开始创建一个新项目时,我们倾向于考虑使用哪种构建工具,使用哪种框架等。最困难的任务之一是找到使项目工作的正确依赖关系。这就是 Spring Initializr 的创建目的。这个出色的 Spring 倡议使得可以在几分钟甚至几秒钟内启动应用程序,无论你喜欢哪个版本。Spring Initializr 可以在 Web 上使用,也可以在您喜欢的 IDE(Eclipse 或 IntelliJ)上使用,甚至有一个很酷的 CLI 工具。我喜欢的方法是 Web,下面的截图说明了原因:

Spring Initializr 主页

在页面顶部,您可以选择您喜欢的构建工具。可用的选项有 Maven 和 Gradle。接下来的选项允许您选择您喜欢的编程语言(目前支持 Java、Groovy 和 Kotlin)。网页顶部的最后一个选项询问您想要使用哪个 Spring Boot 版本。在此部分,甚至包括快照和里程碑版本。在项目元数据部分,您可以指定项目的组和构件名称。依赖项部分有一个搜索依赖项的文本字段,有助于定义您想要包含在应用程序中的 Spring 项目。如果您想了解更多,请点击切换到完整版本的链接;这将显示所有可用依赖项的大列表。

所有这些项目都是使用 Spring Boot 框架创建的,这使得创建独立应用程序并准备投入生产变得容易。现在,让我们快速了解一下 Spring Boot。

Spring Boot 简介

Spring Boot 框架旨在使以下任务更加容易:

  • Servlet 容器集成

  • 自动配置

  • 依赖管理

Servlet 容器集成

以前,我们创建了.war文件,然后将它们放入相应的 servlet 容器部署目录中。然而,Spring Boot 包含了一个嵌入式 servlet 容器,这样就不再需要这样做了。其思想是生成一个包含所有相关依赖项的 JAR 文件,然后将其作为常规 Java 应用程序执行。虽然仍然可以使用生成 WAR 文件的旧方法,但不建议这样做。

自动配置

Spring Boot 始终尝试根据您添加的依赖项自动配置应用程序。例如,如果 H2 是您依赖项的一部分,将自动配置使用内存数据库的数据源。您始终可以通过使用注释、环境变量、配置文件甚至在运行.jar文件时使用参数来覆盖这些默认配置。

依赖管理

每个 Spring Boot 版本都包含一个经过精心筛选的依赖项列表。因此,您甚至不需要知道哪些构件和版本是应用程序的一部分。您始终可以选择覆盖这些依赖项,但通常是不必要的。这种方法使我们能够轻松升级 Spring Boot 应用程序。

通过运行以下curl命令来创建一个简单的 Spring Boot 应用程序:

curl https://start.Spring.io/starter.zip -o Spring-boot-demo.zip

上述命令将下载一个包含以下文件结构的.zip文件:

Spring Boot 项目结构

让我们快速查看这些文件。

mvnw 和 mvnw.cmd

这两个文件是 Maven 包装器的一部分(github.com/takari/maven-wrapper)。这里的想法是避免强制开发人员从头开始安装 Maven,而是提供一个内置脚本,能够下载正确的版本并使其准备好工作。

pom.xml

该文件包含运行 Spring Boot 应用程序所需的必要依赖项。让我们按照以下方式查看文件的内容:

<?xml version="1.0" encoding="UTF-8"?>
  ...
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>Spring-boot-starter-parent</artifactId>
    <version>1.5.8.RELEASE</version>
    <relativePath/>
  </parent>
  ...
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>Spring-boot-starter</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>Spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
  ...
</project>

parent pom 部分为应用程序提供了必要的依赖项和插件管理。

Spring-boot-starter依赖项包含了您需要启动项目并使用一组受控的传递依赖项的所有依赖项。还有其他启动器,您可能想要使用,这取决于您的项目需要(例如,JPA、队列、安全等)。

Spring-boot-starter-test依赖项包含了整套测试所需的依赖项。它将允许您编写单元测试和集成测试。

DemoApplication.java

这是一个带有main方法的简单类,负责运行应用程序。由于@SpringBootApplication注解,可以以这种方式执行这个main类,它启用了所有必需的自动配置,如下所示:

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

application.properties 文件

在这个文件中,您必须定义应用程序的所有配置属性。例如,如果您正在与 SQL 数据库交互,该文件将具有诸如 JDBC URL、数据库用户名、密码等属性。如果您愿意,可以将其扩展名从.properties更改为.yml,以便通过使用 YAML 格式(www.yaml.org/start.html)使其更具表现力。

DemoApplicationTests.java

作为奖励,以下是一个简单的集成测试示例,您可以将其用作指南,为新代码编写测试。由于注释,编写这种测试相对简单:

@RunWith(SpringRunner.class)
@SpringBootTest
public class DemoApplicationTests 
{
  @Test
  public void contextLoads() 
  {
  }
}

上述解释应该足以为您提供简要概述,以便您了解 Spring Boot 是什么以及其好处是什么。现在,是时候审查其他您会喜欢的 Spring 项目了。

使用开发人员工具避免重新部署

这个模块很棒,因为它旨在帮助您在开发 Spring Boot 应用程序时避免重新部署。它类似于 JRebel,但这个产品是完全免费的,您可以通过简单添加以下依赖项将其作为应用程序的一部分包含进来:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-devtools</artifactId>
  <optional>true</optional>
</dependency>

一旦添加了依赖项,您只需重新编译类即可触发应用程序重新启动。根据您的 IDE 配置,此过程将自动完成或手动完成。

Spring Data

该项目为您提供了一个额外的访问数据存储的抽象层;它有一堆接口,您需要扩展这些接口,以利用 Spring Data 提供的内置功能。当您扩展这些接口时,所有围绕数据存储的标准操作都将准备就绪。

Spring Data 支持关系型和非关系型数据库、MapReduce 框架和基于云的数据服务等技术。这些技术由模块支持;如果您对现有模块的完整列表感兴趣,可以访问projects.Spring.io/Spring-data/

让我们通过使用 SQL 数据库(如 H2)来玩 Spring Data。假设您想为国家数据库表构建创建、读取、更新、删除(CRUD)操作。使用这个框架,您只需要创建实体类和一个空接口,该接口扩展了 Spring Data 提供的CrudRepository接口,如下所示:

@Component
public interface CountryRepository extends CrudRepository<Country, Integer> {
}

由于CrudRepository接口中包含了所有的 CRUD 操作,您不需要实现任何内容;您只需要使用它的功能。让我们看看它的运行方式,如下所示:

@SpringBootApplication
public class SpringDataDemoApplication 
{
  @Bean
  InitializingBean populateDatabase(CountryRepository
  countryRepository) 
  {
    return () -> 
    {
      countryRepository.save(new Country(1, "USA"));
      countryRepository.save(new Country(2, "Ecuador"));
    };
  }
  @Bean
  CommandLineRunner queryDatabase(CountryRepository 
  countryRepository) 
  {
    return args -> 
    {
      countryRepository.findAll()
      .forEach(System.out::println);
    };
  }
  public static void main(String[] args) 
  {
    SpringApplication.run(SpringDataDemoApplication.class,args);
  }
}

我们有两个使用先前创建的存储库接口的Bean。第一个方法将运行,并将向表中插入两行。第二个方法将查询表中的所有行,然后在控制台中打印它们。运行此应用程序后,当应用程序启动时,您将在控制台中看到以下输出:

...
Country [id: 1 name: USA ]
Country [id: 2 name: Ecuador ]
...

Spring Data 还具有更多功能;它还让您有机会以一种迷人的方式创建查询。假设您想按名称过滤国家。在这种情况下,您需要将该方法添加到您的接口存储库中,如下所示:

@Component
public interface CountryRepository extends CrudRepository<Country, Integer> 
{
 List<Country> findByName(String name); }

然后,我们可以以以下方式使用先前的方法:

countryRepository.findByName("USA")

这个方法根本没有实现,这是一个很大的优势。Spring Data 使用方法的名称来生成所需的实现,让我们忘记这些类型的查询的琐碎实现。有许多接口提供更多的功能,如分页、排序和响应式扩展。

使用 Spring Integration 支持 EIPs

集成很重要,因为应用程序旨在相互交互。强迫它们独立工作会使它们变得无用。

通常会发现一些公司有他们自己的内部开发的软件,以解决他们特定的业务需求;但是,由于某些情景往往对于不止一个公司是共同的,因此有第三方服务可以满足这些需求。由于这些系统提供的功能是可用的,我们必须找到一种方法使这些应用程序作为一个单一系统工作,这就是企业集成模式(EIP)发挥作用的地方。

EIP 提供了针对不同上下文中可以应用的经常出现的问题的成熟解决方案,具体取决于特定的业务需求进行轻微修改。互联网上有大量这些模式的目录,而在这个领域必读的是 Gregor Hohpe 和 Bobby Woolf 的书《企业集成模式》。该书采用技术无关的方法,解释了大量模式以及示例场景。

一旦理解了 EIP 的理论,您会发现 Spring Integration 非常方便用于实现它们;它将具有之前讨论过的 Spring Framework 固有的所有优势。

当我们讨论集成时,可以考虑使用三步方法。让我们开始审查以下显示这三个步骤的图表:

EIP

以下是作为前述过程一部分执行的步骤列表:

  1. 有一个数据源,从中提取信息;有时需要进行轮询,以请求数据。

  2. 摄入的数据根据需要进行过滤、转换、组合、分解、路由等。EIP 就是在这里使用的。

  3. 处理后的数据已准备好交付或存储,具体取决于需要什么。

Spring Integration 提供了内置支持,用于从队列、数据库、系统文件、FTP 服务器和许多其他选项中检索或发送信息。此外,如果需要,您可以编写自己的实现并将其插入,以使其作为流程的一部分工作。Spring 提供的 DSL 使阅读和实现 EIP 变得容易。

Spring Batch

无论我们使用什么类型的架构,有时我们都需要处理大量数据并应用一些转换使其有用。这种处理通常发生在我们需要从一个或多个数据源中整合(或简单处理)数据,使其可用于特定的业务目的。

这些批处理过程需要一组明确定义的步骤来实现所需的目标。使用 Spring Batch,您可以通过使用由读取、处理和写入处理数据的步骤组成的作业来实现它们。一个作业可以有多个所需的步骤,如下图所示:

Spring Batch - 作业结构

读取步骤

在这种情况下,信息是使用 Spring Batch 的内置ItemReader对象从外部数据源读取的。ItemReader对象将提供一个<T>对象,稍后将被使用。

处理步骤

在这里,数据处理是由一个ItemProcessor对象完成的,它可以转换和操作从ItemReader对象读取的<T>数据。ItemProcessor可以返回与读取的相同的<T>对象,或者如果需要的话,可以返回完全不同的<O>对象。

写入步骤

一旦处理步骤完成,就可以使用ItemWriter对象,将处理阶段获得的<O>转换对象写入。

Spring 提供了与传统数据源交互的能力,例如以下内容:

  • 文件

  • JMS 提供者

  • 数据库

使用 Spring Batch,一个很酷的功能是它提供了重新运行和跳过作业的机会,因为它有自己的数据库,其中存储了执行作业的状态。

由于 Spring Batch 旨在处理大量数据,为了加速处理,该框架提供了将信息作为数据块处理的机会。这也使得可以减少处理所需的服务器资源。

使用 Spring Security 保护应用程序

Spring Security 是一个可扩展的框架,可用于保护 Java 应用程序。它还可以用于处理身份验证和授权,并且它使用一种声明式风格,完全不会侵入现有代码。

该框架支持不同的身份验证方法,例如以下方法:

  • LDAP

  • JDBC

  • 内存中

您还可以通过实现AuthenticationProvider接口添加自定义的身份验证机制,如下所示:

@Component
public class CustomAuthenticationProvider 
implements AuthenticationProvider 
{
  @Override
  public Authentication authenticate(Authentication 
  authentication)
  throws AuthenticationException 
  {
    // get the entered credentials
    String username = authentication.getName();
    String password = authentication.getCredentials().toString();
    // check the entered data
    if ("user".equals(username) && "password".
    equals(password)) 
    {
      return new UsernamePasswordAuthenticationToken(
      username, password, new ArrayList<>());
    }
    ...
  }
  ...
}

在上面的例子中,userpassword硬编码字符串预期作为凭据,以便成功的身份验证过程,并且您应该用必要的逻辑替换该验证。

上述身份验证机制遵循基本身份验证模型,这是 Web 应用程序的首选模型。但是,当您编写 API 时,您将需要其他方法来处理安全性。一个很好的选择是使用基于令牌的模型,例如 JWT 或 OAuth,我们将在后续章节中进行审查和实施。

拥抱(Spring)HATEOAS

在谈论 REST 主题时,讨论 Leonard Richardson 创建的成熟度模型总是值得的,该模型规定了 REST API 应该完成的三个步骤才能被认为是成熟的:

  • 资源

  • HTTP 动词

  • 超媒体控制:HATEOAS

在这一部分,我们将重点放在最后一个元素上。HATEOAS旨在提供关于我们可以使用什么的信息,使用作为资源的一部分包含的附加统一资源标识符(URIs)

让我们重新访问我们的银行示例,以便从实际角度解释 HATEOAS。假设您有以下 URI 来查询客户的银行对账单:http://your-api/customer/{customer_id}/bankStatements

[
  {
    "accountStatusId": 1,
    "information": "Some information here"
  },
  {
    "accountStatusId": 2,
    "information": "Some information here"
  }
]

另外,假设 API 具有重新发送银行对账单或将其标记为失败的能力。根据先前提到的有效负载提供的信息,无法了解这些操作。这就是 HATEOAS 可以使用的地方,让我们的 API 用户了解这些附加功能的存在。应用 HATEOAS 后,有效负载将如下所示:

{
  "_embedded": 
  {
    "bankStatementList": 
    [
      {
        "bankStatementId": 1,
        "information": "Some information here",
        "_links": 
        {
          "markAsFailed": 
          [
            {
              "href": "http://localhost:8080/customer/
              1/bankStatements/1/markAsFailed"
            }, 
            {
              "href": "http://localhost:8080/customer/
              1/bankStatements/1/markAsFailed"
            }
          ],
          "resend": 
          [
            {
              "href": "http://localhost:8080/customer/
              1/bankStatements/1/resend"
            }, 
            {
              "href": "http://localhost:8080/customer/
              1/bankStatements/1/resend"
            }
          ]
        }
      }, 
      ...
        }
      }
    ]
  }
}

请注意,在应用 HATEOAS 作为 API 的一部分之前,了解这些操作的存在是多么容易。

Spring Cloud 和微服务世界

该项目提供了一套工具来处理分布式应用程序。Spring Cloud 主要用于微服务世界,我们将在第八章中深入研究微服务。该项目由提供不同功能的模块组成,可以根据您的需求一次性全部采用,也可以逐个采用。让我们简要地回顾一些 Spring Cloud 中最常见的模块,并看看它们是如何工作的。

配置服务器

该模块提供了一个集中的工具,用于存储应用程序工作所需的所有配置。在 Java 世界中,拥有存储所有必需配置的.properties.yml文件是非常常见的。

Spring 提供了创建不同配置文件的能力,以处理不同的环境,使用之前提到的扩展名的文件。但是,它还可以选择将所有配置集中在服务器中,您可以在其中存储值甚至加密信息。当客户端需要访问此秘密信息时,配置服务器具有解密信息并使其可用于客户端的能力。此外,您可以动态更改配置值。存储此配置的文件位于 Git 存储库中,这使我们能够考虑应用于配置的更改的额外好处。

服务注册表

服务注册表就像云的电话簿,可以找出服务的位置以及有多少个实例可用于处理传入请求。

Spring 支持大多数常见的服务注册表,包括以下内容:

  • Zookeeper

  • Consul

  • Netflix Eureka

使用服务注册表提供以下好处:

  • Sophisticated load balancing, such as availability zone awareness

  • 客户端负载均衡

  • 请求路由

边缘服务

边缘服务充当代理。它旨在接收所有传入请求并在将其发送到负载均衡器、防火墙等后面的服务之前对其进行有用处理。

有两种主要类型的边缘服务:

  • 微代理

  • API 网关

使用边缘服务的好处之一是,您可以在集中的位置管理所有特定客户端的详细信息,而不是在每个服务中编写代码来处理这些详细信息。例如,如果您需要针对移动客户端进行特定考虑,这是执行此操作的理想位置。

微代理

微代理是一种边缘服务,仅检索传入请求,然后将请求重定向到相应服务。

这种类型的边缘服务的经典示例涉及处理跨域资源共享(CORS),如en.wikipedia.org/wiki/Cross-origin_resource_sharing中定义的。您可能知道,CORS 限制了从与资源所在不同的域请求资源时的访问。您可以允许每个服务上的资源访问,或者您可以利用边缘服务器,以便允许从其他域请求服务。

API 网关

API 网关的使用可以在重定向到相应服务之前转换传入请求。不仅可以修改请求,还可以提供响应。

网关还可以作为门面工作,应在将响应发送给客户端之前协调一些服务。当我们处理这种特定用例时,我们可以实现断路器模式以更具防御性。

断路器

断路器是一种用于处理失败调用的模式。如果发生错误,通常可以抛出异常并让用户知道出了问题,但也可以使用替代路径提供替代响应。例如,假设服务 A 失败了。现在,您可以调用类似于服务 A 的替代服务 B,以向客户端提供有效响应,从而改善用户体验,而不是返回失败响应。

Reactive 和 Spring

Reactive 编程是围绕一个简单概念构建的范式,即使用事件传播变化。这种编程风格在 JavaScript 等编程语言中已经使用了一段时间,其主要好处之一是其异步和非阻塞行为。

为了在 Java 世界中采用这种编程范式,创建了 Reactive Stream 规范,遵循了 Reactive Manifesto(www.reactivemanifesto.org)中声明的目标,该宣言是几年前编写的。

该规范主要由四个接口组成,如下所示:

  • 发布者

  • 订阅者

  • 订阅

  • 处理器

让我们简要回顾一下这些接口。

发布者

该接口具有一个简单的方法,可以注册订阅者,当数据可供消费时,订阅者最终会接收到数据。以下是Publisher接口的代码:

public interface Publisher<T> 
{
  public void subscribe(Subscriber<? super T> s);
}

订阅者

这个接口是发生操作的地方。以下方法的名称是自描述的:

public interface Subscriber<T> 
{
  public void onSubscribe(Subscription s);
  public void onNext(T t);
  public void onError(Throwable t);
  public void onComplete();
}

使用前面提到的每个方法,您可以注册一个回调,在适当的情况下调用它,如下所示:

  • onSubscribe:当订阅过程发生时执行此方法

  • onNext:当接收到新事件时执行此方法

  • onError:当发生错误时执行此方法

  • onComplete:当生产者完成并且没有更多结果可接收时执行此方法

订阅

当您想要请求对Publisher接口的订阅时,应使用此接口,指定要向上游请求的元素数量;当订阅者不再对接收数据感兴趣时,应调用cancel方法:

public interface Subscription 
{
  public void request(long n);
  public void cancel();
}

处理器

processor接口实现了两个额外的接口:PublisherSubscriber。此接口用于订阅和发布事件。

项目反应器

该项目是 Reactive Streams 规范的实现,Spring Framework 首选。还有适配器,如果需要,可以使用其他实现,但通常是不必要的。

项目反应器也可以用于实现反应式应用程序,而不使用 Spring。

当我们注册处理事件的函数时,我们倾向于嵌套回调,这使得难以理解书面代码。为了简化这类要求,Reactor 有自己的一套操作符(访问goo.gl/y7kcgS查看所有可用操作符的完整列表)。这些操作符允许我们以更清晰的方式与 API 交互,而无需将回调函数链接在一起。

有两个主要的生产者类处理结果,可以应用操作符:

  • Mono

  • Flux

Mono

Mono 表示单个或空值(0...1)的异步结果。

以下图表摘自项目反应器文档,指示了Mono对象如何发出项目:

由 Mono 对象发出的项目

前面的图表说明了以下流程:

  • 产生了一个新值

  • 对产生的值应用了一个操作符

  • 结果被传递

以下示例显示了如何使用空值:

@Test
public void givenAnEmptyMono_WhenTheDefaultIfEmptyOperatorIsUsed_ 
ThenTheDefaultValueIsDeliveredAsResult() throws Exception 
{
  String defaultMessage = "Hello world";
  Mono<String> emptyMonoMessageProduced = Mono.empty();
  Mono<String> monoMessageDelivered = emptyMonoMessageProduced
  .defaultIfEmpty(defaultMessage);
  monoMessageDelivered.subscribe(messageDelivered ->
  Assert.assertEquals(defaultMessage, messageDelivered));
}

Flux

Flux 表示 0 到n个项目的异步序列。

我们将再次借用项目反应器文档中的图表,解释了Flux对象如何发出项目:

由 Flux 对象发出的项目

前面的图表说明了以下过程:

  • 至少已产生了六个值

  • 对产生的值应用了一个操作符

  • 结果被传递

在下面的例子中,我们将首先将每个产生的值转换为大写,以便传递这些值:

@Test
public void givenAListOfCapitalizedStrings_WhenThe
FlatMapConvertsToUpperCaseTheStrings_ThenTheStringsAre
InUpperCase() throws Exception 
{
  List<String> namesCapitalized = Arrays.asList("John", 
  "Steve", "Rene");
  Iterator<String> namesCapitalizedIterator = namesCapitalized.
  iterator();
  Flux<String> fluxWithNamesCapitalized = Flux.fromIterable
  (namesCapitalized);
  Flux<String> fluxWithNamesInUpperCase = fluxWithNamesCapitalized
  .map(name -> name.toUpperCase());
  fluxWithNamesInUpperCase.subscribe 
  (
    nameInUpperCase -> 
    {
      String expectedString =namesCapitalizedIterator.
      next().toUpperCase();                
 Assert.assertEquals(expectedString, nameInUpperCase);
    }
  );
}

反压

反压是一种机制,允许我们指定一次要读取的所需元素数量。当您对具有定义数量的n元素的数据块感兴趣时,就会使用它。数据以块的形式传递,直到整个数据集被达到。

假设您想要从一个包含十个元素的Flux对象中获取三个元素的数据块。在这种情况下,您将检索数据四次,如下例所示:

@Test
public void givenAFluxWith10Elements_WhenBack
PressureAsksForChunksOf3Elements_ThenYouHave4Chunks()
throws Exception 
{
  List<Integer> digitsArray = Arrays.asList(1, 2, 3, 4, 
  5, 6, 7, 8, 9, 0);
  Flux<Integer> fluxWithDigits = Flux.fromIterable
  (digitsArray);
  fluxWithDigits.buffer(3)
  .log()
  .subscribe
  (
    elements -> 
    {
 Assert.assertTrue(elements.size() <= 3);
    }
  );
}

以下是日志生成的输出:

[ INFO] (main) onSubscribe(FluxBuffer.BufferExactSubscriber)
[ INFO] (main) request(unbounded)
[ INFO] (main) onNext([1, 2, 3])
[ INFO] (main) onNext([4, 5, 6])
[ INFO] (main) onNext([7, 8, 9])
[ INFO] (main) onNext([0])
[ INFO] (main) onComplete()

如前所述,Spring 5 通过使用 Reactor 项目支持反应式编程。我们有能力将其作为 Spring MVC 和 Spring Data 的一部分来使用。

反应式 Spring Data

由于 Reactor 可以与 Spring Data 一起使用,因此我们可以充分利用这种反应式编程模型。这意味着您可以持久化表示为FluxMono对象的数据。让我们来看下面的例子,使用 MongoDB 实现:

@Test
public void findAllShouldFindTheTotalAmountOfRecordsInserted() 
{
  int quantityOfEntitiesToPersistAsFlux = 100;
  // Saving a Flux with 100 items
  repository.saveAll
  (
    Flux.just(generateArrayWithElements
    (quantityOfEntitiesToPersistAsFlux))
  )
  .then()
  .block();
  // Saving a Mono
  repository.saveAll(Mono.just(new Customer("Rene")))
  .then()
  .block();
  List<String> customerIds = repository.findAll()
  .map(customer -> customer.getId())
  .collectList()
  .block();
  int totalAmountOfInserts = quantityOfEntitiesTo
  PersistAsFlux + 1;
 Assert.assertEquals(totalAmountOfInserts, customerIds.size());
}

请注意,提供的信息表示为FluxMono对象,查询的数据以Flux对象的形式获取,并使用 map 运算符进行操作,以仅恢复 ID 作为List<String>来验证创建的实体数量。

反应式 REST 服务

通过使用WebFlux作为 Spring Web 堆栈的一部分,我们添加了反应式 REST 服务。这使我们能够实现能够以流的形式传递信息的端点。

让我们从实际角度来看看这是如何工作的。假设您想要检索用户经常推送的通知。如果不使用反应式方法,您可以在发出请求之前检索到所有创建的通知;但是,使用反应式方法,您可以不断接收新的通知,这意味着如果创建了新的通知,您将立刻收到它。让我们分析下面的代码片段:

@GetMapping(value = "/{singer}/comments", produces = 
MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Comment> querySingerComments(@PathVariable 
String singer) 
{
  // generate one flux element per second
  Flux<Long> intervalToGenerateComments = 
 Flux.interval(Duration.ofSeconds(1));
  Flux<Comment> comments = Flux.fromStream(Stream.generate(()
  ->new Comment(composeComment(singer), new Date())));
  return Flux.zip(intervalToGenerateComments, comments)
  .map(fluxTuple -> fluxTuple.getT2());
}

首先,注意生成的内容。这是一个流值,而不是 JSON、XML 或任何其他内容类型。接下来,我们模拟每秒创建一个新评论(查看粗体代码)。在过程结束时,该信息通过端点传递。您可以使用以下curl命令尝试一下:

curl http://localhost:8080/jlo/comments

现在,您可以看到每秒都在检索一个新评论。这个功能为我们的应用程序开辟了新的机会和功能。

总结

在本章中,我们审查了 Spring 中一些常见的项目,以及每个项目的简要说明和用例。我们还研究了 Spring Reactor 项目及其相关特性,这些特性可以使用 Spring Data 来实现。然后,我们看了如何编写 RESTful Web 服务。

有了您所掌握的所有知识,现在是时候深入下一章,审查一些架构风格并学习如何使用 Spring 框架来实现它们了。

第四章:客户端-服务器架构

客户端-服务器架构是当今最常见的架构风格之一,并且已经以许多不同的方式使用。

当我们听到客户端-服务器架构这个术语时,我们经常会想到提供 UI 用于编辑复杂数据库的旧应用程序,其中大部分业务逻辑驻留。然而,事实是,这种架构风格为几乎每种现代架构风格提供了基础支持,包括微服务、事件驱动架构或任何分布式计算系统。

在本章中,我们将回顾客户端-服务器架构的工作原理,以及如何实现它。我们将使用 Spring 框架构建服务器端,然后使用 Java 编写与服务器交互的客户端。

本章将涵盖以下内容:

  • 理解客户端-服务器架构

  • 何处应用客户端-服务器架构

  • 实现客户端-服务器架构:

  • 使用 Spring 编写服务器

  • 介绍 Spring 执行器

  • 监控应用程序的健康状况

  • 使用 Java FX 和 Android 编写客户端

  • 测试实现的代码

理解客户端-服务器架构

在客户端-服务器架构中,每个运行的进程都是服务器或客户端。它们通过定义的通信渠道在网络中相互交互。我们都使用过电子邮件服务,并且了解这样的服务是如何工作的;这是客户端-服务器架构的典型例子,如下图所示:

电子邮件服务组件

现在,我们将简要概述前面图表中的每个组件,以解释它们如何适用于客户端-服务器架构风格。前面的图表由以下部分组成:

  • 服务器(1

  • 请求有效载荷(2)

  • 访问服务器资源的客户端(3

服务器

服务器负责处理接收到的请求(应符合预定义格式),然后生成结果。

一旦数据被检索,整个过程开始,检查请求之前进行处理。这个过程从验证和授权检查开始,验证客户端的身份。然后开始验证过程,以审查客户端提供的输入,并测试提供的请求主体以验证其结构。之后,执行验证数据是否符合业务逻辑约束的检查。最后,服务器处理请求。

这些步骤使得在应用程序中实现一定程度的可靠性成为可能,因为恶意或损坏的请求根本不会被处理,这些请求最终会破坏数据或使系统变得不一致。

服务器提供的响应通常是稍后由客户端使用的服务或资源。当请求未能成功处理时,将向客户端发送包含合理信息的响应。

高性能服务器用于支持所需的处理。服务器位于本地或基于云的基础设施中。

扩展

一旦服务器投入生产,监控其资源消耗和与应用程序相关的业务指标是个好主意。如果我们发现任何异常或高流量,我们应该考虑扩展服务器以提供更好的用户体验。

由于客户端可以是任何能够连接到服务器的设备,包括独立的计算机,我们可能会突然有数百万个客户端访问服务器。当应用程序在一台机器上运行时,应用程序的客户端和服务器部分之间的资源消耗平衡是固定的。然而,一旦客户端和服务器可以独立扩展,客户端的规模就变得远远超过服务器的容量。今天,客户端只需要与一个用户进行交互。因此,他们很容易获得足够的资源。然而,服务器可能被要求支持跨广泛、动态范围的客户端数量。在这种情况下,扩展成为一个重要的技术要求。

我们有两种选项来扩展服务器,如下所示:

  • 垂直

  • 水平

对于因其自身特性而无法部署在多个节点上的服务,我们可以考虑垂直扩展。一个节点可以由运行服务的计算机或进程来表示。

在这种扩展选项中,我们只能通过增加更多资源(如 RAM、CPU、硬盘等)来扩展服务,如下图所示:

垂直扩展

我们的一个明确限制是,我们只能增加运行服务的唯一进程的功率。

另一方面,如果您有一个无状态的服务,比如 REST API,它可以部署在多个节点上,从而可以水平扩展服务。这种方法允许我们更好地扩展应用程序,但负载均衡器应该放在它们的前面,以便使用算法适当地路由请求。一个典型的算法是轮询,它将请求均匀地分配给所有可用的节点。

下图显示了服务器在负载均衡器后面排列,使用水平扩展方法:

水平扩展

请求

请求是客户端向服务器发送的一段信息。客户端和服务器必须就它们用于通信的协议达成一致,以便允许它们相互交互。

为了促进数据交换,建议产品供应商提供 SDK(或某种库)。例如,如果您想从 Java 应用程序与数据库进行交互,那么有以库形式编码的驱动程序可供使用。此外,数据库供应商还为不同的编程语言、桌面应用程序或 UI 提供与服务器交互的驱动程序,如 pgAdmin 或 MySQL Workbench。

提供 SDK 并不是必须的;即使提供了 SDK,易于理解的文档也会避免在服务器和客户端之间引入顺从关系。

顺从关系是领域驱动设计提出的一个术语。它表明一个服务有一个复杂和庞大的模型,当服务器引入新的变化或发布新的功能时,强制下游依赖关系进行修改。修改应该发生,因为编写自己的模型适应或与服务器交互的机制所需的工作量非常大,难以实现。

客户端

可以用于应用程序客户端的选项有很多。以电子邮件为例,众所周知,计算机操作系统中包含原生应用程序,移动设备如智能手机、iPad 或平板电脑也可以配置为与现有的电子邮件服务器进行交互。有两种类型的客户端,如下所示:

  • Fat clients

  • Thin clients

Fat clients 具有实现逻辑,负责执行一些验证、格式化数据和履行其他相关职责。它们旨在使最终用户与服务器之间的交互更加容易。

想象一下运行 Outlook 的 Windows PC。这代表了一个典型的厚客户端的例子。相比之下,与 Web 邮件站点进行通信的 Web 浏览器是一个瘦客户端的典型例子。

我们还可以将厚客户端与在我们的手机上运行的本机应用程序进行比较,当它们无法与服务器建立通信时,它们可以部分工作;与之相反,像 Web 浏览器这样的瘦客户端是绝对无用的。

在厚客户端类别中,我们还有中间件,它通常消耗多个服务并编排请求以实现业务目标。最常见的例子是作为 SOA 架构的一部分常用的企业服务总线ESB)。

瘦客户端非常简单,并且具有一个简单的机制,可以与服务器进行交互。一个常见的例子是使用curl命令通过 HTTP(S)协议与 Rest-API 进行交互。

网络

网络是一种支持服务器和客户端之间通信的媒介,遵循请求-响应消息传递模式,其中客户端通过这种媒介向服务器发送请求,服务器通过这种媒介响应请求。网络的一个典型例子是互联网,它使我们能够与连接到它的所有设备进行通信。今天,有大量设备可以连接到互联网,包括计算机、平板电脑、智能手机、Arduino、树莓派等。这些设备的使用已经推动了物联网IoT)的发展,使我们有机会创新并创建一个新的应用时代。还有其他类型的网络,如蓝牙、LiFi、局域网等,可以根据业务需求允许客户端和服务器之间的交互。

在哪里应用客户端-服务器架构

有许多情况下可以使用客户端-服务器架构风格。让我们回顾一些典型的例子,以更好地理解这种方法。

如前所述,数据库通常适用于这种架构风格。目前,市场上有许多数据库供应商,其中大多数只提供垂直扩展的机会。这种方法的两个经典例子是 SQL Server 和 PostgreSQL。然而,也有水平扩展的选项。按照这种模型的最著名的数据库是 Cassandra,这是 Facebook 创建的数据库,后来被采纳为 Apache 项目。这个数据库使用环模型连接不同的节点,数据存储在其中。通过这种方式,您可以根据需要添加尽可能多的节点,以支持高可用性。

像 Slack 这样的聊天服务是使用云的客户端-服务器架构的经典例子。这个聊天软件几乎为任何计算机操作系统提供客户端,也为移动平台提供客户端;甚至可以直接在浏览器上使用,如果您不想在设备上安装本机应用程序。

代理也是这种架构风格的一个有趣的应用。代理是负责将客户端发送的信息发送到服务器的软件部分,无需人类交互。例如,New Relic(newrelic.com/)是一个用于监控服务器和应用程序健康状况的应用性能监控和管理APM),使用代理发送数据。

假设您想要监视现有的 Java 应用程序。为了实现这个目标,您只需要在应用程序启动时添加 New Relic 代理,使用javaagent选项。这样,代理将不断向 New Relic 发送信息,这将为我们提供与内存和 CPU 消耗、响应时间等相关的信息。在这种情况下,处理代理发送的数据的服务器也在云中。

物联网也严重依赖于客户端-服务器架构的使用,其中具有传感器(或其他机制)的小型设备不断向负责分析数据的服务器发送信息,以执行操作,具体取决于所需的操作。

使用 Spring 实现客户端-服务器架构

现在您对客户端-服务器架构有了更好的理解,我们将编写一个遵循此图表的示例:

客户端-服务器架构示例

我们的应用程序的功能将是简单的。服务器将公开一个包含客户银行对账单的端点,然后我们将编写几个客户端来使用该信息。

服务器

使用 Spring 框架构建服务器端的选项有很多,包括以下内容:

  • SOAP Web 服务

  • RESTful Web 服务

  • 公共对象请求代理架构CORBA

  • 套接字

  • AMQP

SOAP Web 服务

在 REST 风格出现之前,开发人员广泛实现了 SOAP Web 服务,它们严重依赖于 XML 的使用。还有一堆库可用于处理它们,包括 Apache CXF 和 JAX-WS。以下屏幕截图代表了一个简单加法操作的请求有效载荷:

请求有效载荷

以下屏幕截图显示了响应的外观:

响应有效载荷

前面的例子取自www.dneonline.com/calculator.asmx?op=Add

这些 XML 文件遵循 SOAP Web 服务使用的Web 服务描述语言WSDL)格式。

RESTful Web 服务

另一方面,目前更受欢迎的是 RESTful 风格,有很多公共 API 使用它。常见的例子是 GitHub 和 Yahoo 等公司。这种风格基于 HTTP 动词的功能,使人们很容易理解它们的工作原理。例如,以下 HTTP 请求可以查询 GitHub 的存储库:

GET https://api.github.com/users/{{GITHUB_USERNAME}}/repos

这种风格于 2000 年出现,由 Roy Fielding 的博士论文解释了 REST 原则,并规定了良好设计的 Web 应用程序应该如何行为。使用 HTTP 动词的方法在下表中描述:

HTTP 方法/动词 用途
GET 列出指定 URI 下的所有资源
POST 在指定的 URI 中创建新资源
PUT 用另一个资源替换指定 URI 下的现有资源
DELETE 删除指定 URI 中的资源
PATCH 部分更新驻留在指定 URI 中的资源

CORBA

CORBA 是一个非常古老的标准,旨在允许用不同编程语言编写的应用程序相互交互。由于需要所有必需的管道代码来实现目标,使用这个标准很困难。CORBA 如今不再流行,但一些遗留应用程序仍然使用它与主要用 Cobol 编写的旧代码进行交互,Cobol 是编写银行核心的首选编程语言之一。

套接字

套接字是一种常见的协议,随着 WebSockets 的出现变得更加流行,它在服务器和客户端之间建立了全双工通信通道。这种协议通常用于包括 Slack 在内的信使应用程序的典型场景。

AMQP

使用 AMQP 或任何类似的消息传递协议的应用程序旨在允许异构应用程序之间的互操作性,采用异步方法。有许多商业和开源实现,如 AWS-SQS/SNS 和 RabbitMQ 等,可以用于实现使用此模型的应用程序。我们将在第六章中详细审查这个工作原理,事件驱动架构。这种方法的基本概念是使用消息代理来接收消息,然后将它们分发给订阅者。

对于我们的示例,我们将选择 RESTful Web 服务,这是目前很受欢迎的选择。为了实现我们的目标,我们将使用 Spring Boot(引导我们的应用程序)以及 Spring Data(使用 H2 持久化信息,H2 是一个内存数据库)。我们的应用程序将使用 JSON Web Tokens RFC(tools.ietf.org/html/rfc7519)进行安全保护。

JWT 是一个开放标准,旨在允许服务器对客户进行身份验证。另一个用例是验证消息的完整性。为了将 JWT 用作身份验证机制,客户端应该将他们的凭据发送到服务器,服务器将以字符串形式的令牌回应他们。这个令牌应该用于后续的请求。当执行它们时,如果提供的令牌无效或过期,我们将从服务器收到 401 未经授权的状态代码。否则,请求将成功:

JWT 身份验证流程

由于此应用程序的功能不需要大量的计算或实时处理,我们使用 Groovy 作为编程语言。Groovy 的语法与 Java 非常相似,但具有大量内置功能,可以避免编写冗长的代码。与 Groovy 一起,我们将使用 Spock 作为测试框架。这将使我们能够使用行为驱动开发(BDD)方法编写高度表达性的测试,使用givenwhenthen语法。BDD 的主要思想是通过具有帮助理解测试失败原因的表达性测试名称,减少对测试方法正在测试什么的不确定性。

BDD 方法基于用户故事的结构,其思想是编写能清楚表明正在测试什么的测试。一个经典的例子是由 BDD 的创始人 Dan North 提供的,以以下与 ATM 工作相关的用户故事为例:

标题 - 客户取款 场景 1 - 账户有余额 场景 2 - 账户透支超过透支限额
作为客户,我想从 ATM 取款,这样我就不必在银行排队等候。 假设账户有余额,卡片有效,取款机有现金,当客户请求现金时,确保账户被借记,现金被发放,卡片被归还。 假设账户透支,卡片有效,当客户请求现金时,确保显示拒绝消息。确保不发放现金并归还卡片。

通过使用 Spock,前面的验证可以很容易地用代码表达。让我们检查我们实现的测试之一,以了解它是如何工作的:

def "when the credentials are not found, an UNAUTHORIZED code is returned"() 
{
  given:
  def nonExistentCredentials = 
  new Credentials(username: "foo", password: "bar")
  def loginService = Mock(LoginService)
  loginService.login(nonExistentCredentials) >> 
  {
    throw new LoginException()
  }
  def securityController = new SecurityController(loginService)
  when:
  def response = securityController.auth(nonExistentCredentials)
  then:
  response.statusCode == HttpStatus.UNAUTHORIZED
}

正如您所看到的,测试使用 Spock 提供的givenwhenthen语法,能够很好地解释自己。

Spock 还允许使用模拟,无需额外的库,如 Mockito,因为这个功能是内置的。如果您对 Spock 想了解更多,我鼓励您访问spockframework.org/

实现服务器

让我们为我们的示例实现服务器项目。我们将以模块的形式组织其功能,以便易于演变和理解。为简单起见,我们将添加一个简单的功能,稍后将由不同的应用程序客户端使用。服务器示例将有三个模块,如下所示:

  • 银行业务

  • 银行 API

  • 银行客户端

银行业务

此模块包含构建我们应用程序所需的所有领域对象;将它们作为另一个模块保持分离是个好主意。通过这样做,您可以稍后将模块包含为其他模块的依赖项,这将有助于避免重复编写相同的代码。以下图表显示了此模块的内容:

银行业务模块

如您所见,此模块仅包含两个类。Credentials类用作有效负载,用于验证用户并检索 JSON Web 令牌,而BalanceInformation类包含查询客户账户余额的有效负载结果。

银行 API

银行 API 模块包含服务器公开的功能,稍后将由不同的应用程序客户端使用;该功能将可用于 RESTful Web 服务。让我们回顾一下此 API 的项目结构:

银行 API 模块

如前所述,此模块完全使用 Groovy 实现,这就是为什么所有文件都具有.groovy扩展名。项目结构在这里更为重要,因为项目分为balanceconfigsecurity包,这使得理解它们的目的相当简单。以这种方式组织代码总是值得的,以便易于理解。

我们之前提到不仅应提供 SDK,而且还应强烈推荐提供适当的文档。编写文档的繁琐部分在于您需要将其与项目中添加的新功能保持同步。为了实现这一目标,我们已将 Swagger 集成到我们的应用程序中。这是一个有用的工具,可以生成一个网站,其中包含消费应用程序端点的示例。此外,当需要时,它还为每个端点创建有效负载演示,如下图所示:

自动生成的 Swagger UI

该门户网站可在http://localhost:8080/swagger-ui.html上使用。

现在,让我们简要回顾一下每个模块中屏幕截图中列出的包。

边界

boundaries包含应用程序公开的功能,用于与客户端进行交互。在这种情况下,我们将放置我们服务的端点。

领域

领域包含此模块所需的领域对象。放置在这里的类不会在任何其他地方使用,这就是为什么将它们放在银行业务模块中没有意义的原因。

持久化

顾名思义,我们将在此包中编写持久化信息所需的代码。由于我们应用程序的持久化存储是数据库,并且我们已经定义了要使用 Spring-data,我们在这里有我们的 Spring-data 存储库。

服务

我们已将所需的业务逻辑放入此包中。这是与许多类进行交互以实现业务需求的地方。

监控服务器

我们之前提到监控对于了解应用程序在实际中的表现非常重要。幸运的是,Spring 有actuator,这是一个可以轻松附加到现有 Spring Boot 应用程序的库,只需添加以下依赖项:

compile("org.springframework.boot:spring-boot-starter-actuator")

Spring Boot 执行器提供了一堆准备好供使用的端点,并提供有关应用程序的有用信息。让我们在下表中审查其中一些:

端点 简要描述
/health 这提供了有关应用程序状态及其主要依赖项(如数据库或消息系统)的简要信息。
/autoconfig 这提供了关于 Spring 框架为应用程序提供的自动配置的信息。请记住,Spring 更喜欢约定胜过配置,所以你会在这里找到大量的默认值。
/beans 这显示了作为应用程序上下文的一部分配置的 Spring bean 列表。
/dump 这在请求端点的确切时刻执行线程转储。
/env 这列出了服务器中配置的所有变量。作为.properties/.yml文件的一部分提供的值以及提供给运行应用程序的参数也会列出。
/metrics 这显示了应用程序中公开的可用端点周围的一些指标。
/trace 这提供了有关最后 100 个(默认值)请求的信息,包括有关请求和响应的详细信息。

如果您对默认可用的端点完整列表感兴趣,我鼓励您访问docs.spring.io/spring-boot/docs/current/reference/html/production-ready-endpoints.html

所有前述的端点主要可以配置三个参数:

  • id:这是端点标识

  • sensitive:这表示 Spring 执行器是否应强制执行安全性

  • enabled:这表示 Spring 执行器端点是否可用

如果要配置端点,必须在配置(.properties/.yml)文件中使用以下条目:

endpoints.endpoint_name.property

以下要点扩展了这个想法:

  • endpoints:这是一个常量值。

  • endpoint_name:这应该替换为所需的端点。

  • property:这可以是idsensitiveenabled

例如,假设您想要启用health端点,将其重命名为status,并且不强制执行security。为了满足这个要求,配置应该如下所示:

endpoints.health.id = status
endpoints.health.sensitive = false
endpoints.health.enabled = true

所有端点默认情况下都是启用的,除了/shutdown,它旨在优雅地停止应用程序。

此外,Spring 执行器也可以配置生成业务指标。这是一个很棒的功能,可以与其他工具集成,从而可以使用图形界面可视化收集的指标。我们将在第十二章中详细审查此功能,监控

测试

到目前为止,我们已经介绍了单元测试来验证代码是否按预期工作。但是,我们希望添加更多的测试。毕竟,我们在系统中包含的测试越多,我们就会获得越多的信心。

由于我们正在编写一个 rest API,我们将创建一个简单的脚本,定期测试我们的端点,从而确保应用程序始终正常工作。为了实现这个目标,我们的测试将遵循一个简单的流程:

  1. 使用端点对用户进行身份验证。

  2. 验证响应中的状态代码。

  3. 从响应体中获取令牌。

  4. 使用令牌击中余额端点。

  5. 验证响应中的状态代码。

实现这个目标的最简单方法是使用 Postman(www.getpostman.com/)。这是一个方便的工具,可以尝试 RESTful web 服务,并为它们创建测试。

让我们讨论为验证用户身份而生成的端点的测试,如下面的屏幕截图所示:

在 Postman 中对身份验证端点进行测试

前面代码的前三行检查了检索到的状态码,第 5 行将检索到的响应体作为名为jwt-token的变量存储。

使用前面的代码,我们可以将这个变量的值注入到后续的请求中,并执行任何我们想要的验证。

一旦所有测试都创建好了,我们可以生成一个链接,指向包含它们的集合,如下面的截图所示:

Postman 集合链接

有了这个链接,测试集合可以一遍又一遍地执行,使用一个名为 Newman 的命令行集成运行器(www.npmjs.com/package/newman)和以下命令:

newman run https://www.getpostman.com/collections/8930b54ce719908646ae

下面的截图显示了 Newman 命令的执行结果:

Newman 命令执行的结果

这个工具可以与任何 CI 服务器集成,比如 Jenkins,以定期安排任务来验证应用程序的健康状况,这将给我们带来信心,确保我们的应用程序一直在工作。

银行客户端

由于我们的服务器是使用 RESTful web 服务实现的,有很多选项可以编写客户端,并使用 Netflix Feign、OkHttp、Spring Rest Template 和 Retrofit 等库来消耗它们。

因此,客户端可以有自己实现的机制来消耗服务。这种方法并不坏;实际上,我们应该保持开放,编写自己的工具与服务器交互的决定应该是客户端的选择,以避免前面描述的顺从关系。然而,提供一个内置的 SDK 或库与服务器交互并减少所需的工作量总是一个好主意,这就是我们有银行客户端模块的原因。

产品供应商通常提供 SDK。例如,AWS 提供了支持多种编程语言的 SDK,配合开发者指南文件,解释了如何使用它们。这有助于加速和鼓励其他开发者构建应用程序来采用产品。另一个例子是 Google Firebase,它是一个实时数据库,提供了准备在不同平台上使用的 SDK;它有一个网站,上面有出色的演示,让开发者能够理解它的工作原理和如何使用它。

这个银行客户端模块是使用一个名为 Retrofit 的库实现的(square.github.io/retrofit/),它可以编写类型安全的 HTTP 客户端,几乎可以用于任何类型的 Java 应用程序。这也提供了许多好处,比如:

  • 支持移动应用,比如 Android

  • 易于阅读并且解释自身良好的代码

  • 支持同步和异步资源消耗

  • 与转换器的顺畅集成,比如 GSON

让我们来看一下实现的客户端,以便消耗这些终端。

认证终端客户端

为了消耗 RESTful web 服务,我们只需要创建一个带有一些注解的接口,提供一些元数据:

public interface SecurityApi 
{
  @POST("/api/public/auth")
  Call<String> login(@Body Credentials credentials);
}

很容易理解认证终端使用POST HTTP 动词。它位于 URI/api/public/auth,并且需要一个Credentials对象作为请求体。

账户余额终端客户端

在这种情况下,我们将消耗一个终端,位于 URI/api/secure/balance,使用GET HTTP 动词,并要求在请求中使用令牌作为标头:

public interface BankingApi 
{
  @GET("/api/secure/balance")
  Call<BalanceInformation> queryBalance(@Header("x-auth-token") 
  String token);
}

正如你可能已经注意到的,这个模块使用了BalanceInformationCredentials类,所以我们不需要再次编写它们;我们只需要将银行域模块作为依赖添加进来。

你可能想知道在哪里指定服务器的 IP 地址和端口,这是在Retrofit对象中完成的,如下所示:

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://IP:PORT")
        .addConverterFactory(GsonConverterFactory.create(gson))
        .build();

在客户端的实现中,我们将审查如何使用Retrofit对象与接口一起进行请求。

客户端

现在我们已经实现了服务器,我们将构建三个客户端,如下所示:

  • JavaFX 客户端

  • 安卓客户端

  • 瘦客户端,使用 CURL

这些客户端将使用 HTTP 协议发送请求并检索响应。由于我们编写了一个客户端模块,与服务器的交互将非常简单。

JavaFX 客户端

这个客户端是一个简单的 JavaFX 应用程序,它依赖于 banking-client 模块与服务器进行交互。我们可以说这个客户端是一种类似于 fat client 的客户端,因为它有一些代码用于简化与服务器的交互。

让我们在以下截图中审查项目结构:

JavaFX 客户端项目结构

这个项目非常简单,只有两个屏幕,允许用户输入他们的凭据然后查询他们的账户余额。

Retrofit 提供了进行同步和异步请求的功能。在这个客户端中,我们将使用同步请求,如下所示:

SecurityApi api = BankClient.getRetrofit().create(SecurityApi.class);
Call<String> call = api.login(
            new Credentials(username.getText(), password.getText()));
Response<String> response = call.execute();
// do something with the response

execute方法允许进行同步请求。Retrofit对象包含将与客户端接口中提供的部分 URI 一起使用的基本 URI,以形成命中端点的完整 URI。

这个客户端应用程序的流程如下截图所示:

JavaFX 客户端应用程序

安卓客户端

安卓客户端还使用提供的 banking-client 模块与服务器进行交互,但在这种情况下,需要使用异步方法进行请求(这个要求来自安卓的工作方式)。我们也可以说这是一个 fat client,通过之前提供的定义来看。

让我们在以下截图中审查该项目的结构:

安卓客户端项目结构

Activity类包含编写异步请求的代码,如下所示:

SecurityApi api = BankClient.getRetrofit().create(SecurityApi.class);
Call<String> call = api.login(new Credentials(username, password));
call.enqueue(new Callback<String>() 
{
  @Override
  public void onResponse(Call<String> call, 
  Response<String> response)
  {
    // do something with the reponse
  }
  @Override
  public void onFailure(Call<String> call, Throwable t) 
  {
    // handle the error properly
  }
}

enqueue方法允许异步地命中端点,并注册两个回调函数,这些函数将根据响应是失败还是成功而执行。

这个客户端应用程序的执行流程如下截图所示:

安卓客户端应用程序

瘦客户端

如前所述,还有瘦客户端,它们不包括大量与服务器交互的代码;curl是一个瘦 RESTful web 服务客户端的很好的例子。

为了与服务器提供的端点进行交互,我们可以使用两个curl命令,如下所示:

  • 以下代码提供了检索认证 JWT 令牌的功能:
$ curl -H "Content-Type: application/json" \
-X POST -d '{"username":"rene","password":"rene"}' \
http://localhost:8080/api/public/auth
  • 以下代码提供了使用 JWT 令牌查询用户账户余额的功能:
$ curl -H "x-auth-token: JWT_TOKEN" \
-X GET http://localhost:8080/api/secure/balance

对于这种类型的客户端,我们不必编写自己的代码;与服务器的交互没有花哨的前端,这可能是好事(例如,在 API 用于其他中间件的情况下)。

正如你所看到的,我们的客户端-服务器架构实现是简单的,但它使用了所有必要的部分使其工作。在这种情况下,我们使用 HTTP 协议作为通信渠道。然而,根据你实现的服务器类型,可能会有所不同,并且也可能会影响认证机制。例如,当你使用消息代理(如 RabbitMQ)允许服务器和客户端之间的交互时,用于建立通信的协议是 AMQP,这是一种不同的协议(与 HTTP 相比)。

你的应用程序将拥有的客户端类型也会影响你构建解决方案的方式。假设你正在使用代理作为客户端;一个更安全的身份验证机制将基于证书而不是令牌,就像前面的例子中所示。

总结

在本章中,我们回顾了什么是客户端-服务器架构以及如何使用 Spring 框架实现它们。要记住的一个重要方面是,当我们按照这种架构风格构建应用程序时,值得提供一个 SDK 来使服务器资源易于消耗。

提供适当的文档可以帮助客户端编写他们自己的代码与服务器进行交互,如果有必要的话。在这种情况下,我们将避免在服务器和客户端之间引入一种顺从的关系。我们还探讨了 Spring Actuator,这是一个可以用来添加提供有关应用程序信息的端点的库。此外,我们还回顾了如何使用 Postman 创建测试,以便定期评估应用程序的健康状况。

最后,我们使用 Retrofit 实现的库创建了一些客户端,这大大减少了消耗服务器资源的工作量。

在下一章中,我们将回顾 MVC 架构以及如何使用 Spring 编写它们。

第五章:模型-视图-控制器架构

在本章中,我们将深入研究当今框架中使用的最常见的架构模式之一。

模型-视图-控制器MVC)架构模式是由 Trygve Reenskaug 于 1979 年制定的。这是对图形用户界面进行组织化工作的最早尝试之一。尽管从那时起已经过去了许多年,但这种模式在最现代的 UI 框架中仍然非常受欢迎。这是因为它旨在构建几乎任何类型的应用程序,包括最常见的应用程序类型,如移动应用程序、桌面应用程序和 Web 应用程序。

这种模式的流行主要归结于易于理解。MVC 提供了一种将应用程序分成三个不同组件的绝佳方法,我们将在本章中进行审查。

在本章中,我们将涵盖以下主题:

  • MVC 的元素:

  • 模型

  • 查看

  • 控制器

  • 使用 MVC 架构的好处

  • 常见陷阱

  • 使用 MVC 实现应用程序:

  • Spring MVC

  • 测试

  • UI 框架:Thymeleaf

  • 保护 MVC 应用程序:

  • 基本身份验证

  • HTTP 和 HTTPS

MVC

支持 MVC 模式的想法是作为 Trygve Reenskaug 研究的一部分而发展的,他得出了以下关键思想:

“MVC 被构想为解决用户控制大型和复杂数据集的问题的一般解决方案。最困难的部分是找到不同架构组件的良好名称。模型-视图-编辑器是第一套。”

计算机科学中最大的问题之一与命名有关,这就是为什么最初的名称是模型-视图-编辑器。后来演变成了 MVC,如前面的链接中所述:

“经过长时间的讨论,特别是与 Adele Goldberg 的讨论,我们最终确定了模型-视图-控制器这些术语。”

MVC 是一种软件架构模式,可以在应用程序的领域对象(业务逻辑所在的地方)和用于构建 UI 的元素之间建立明确的分离。

牢记这个概念,这些部分之间的隔离和关注点的分离非常重要。它们也构成了使用这种模式构建应用程序的基本原则。在接下来的章节中,让我们来看看应用程序的业务逻辑和表示层如何适应 MVC 模式。

模型(M)

在这种情况下,模型代表了表达支持应用程序固有要求的业务逻辑所需的领域对象。在这里,所有用例都被表示为现实世界的抽象,并且一个明确定义的 API 可供任何一种交付机制(如 Web)使用。

关于传统应用程序,与数据库或中间件交互的所有逻辑都是在模型中实现的。然而,模型(MVC 中的 M)应该暴露易于理解的功能(从业务角度)。我们还应该避免构建贫血模型,这些模型只允许与数据库交互,并且对于项目其他成员来说很难理解。

一旦应用程序的这一部分被编码,我们应该能够创建任何允许用户与模型交互的 UI。此外,由于 UI 可能彼此不同(移动应用程序、Web 和桌面应用程序),模型应该对所有这些都是不可知的。

在理想的世界中,一个独立的团队将能够构建应用程序的这一部分,但在现实生活中,这种假设完全是错误的。需要与负责构建 GUI 的团队进行交互,以创建一个能够满足所有业务需求并公开全面 API 的有效模型。

视图(V)

视图是模型(MVC 中的 M)的视觉表示,但有一些细微的差异。作为这些差异的一部分,视图倾向于删除、添加和/或转换特定的模型属性,目的是使模型对与视图交互的用户可理解。

由于模型有时很复杂,可以使用多个视图来表示其一部分,反之亦然,模型的许多部分可以作为视图的一部分。

控制器(C)

控制器是应用程序的最终用户和模型实现的业务逻辑之间的链接。控制器是负责接受用户输入并确定应调用模型的哪个部分以实现定义的业务目标的对象。作为这种交互的结果,模型经常会发生变化,并且应该使用控制器将这些变化传播到视图中。

视图和模型之间绝对不能直接通信,因为这构成了对这种模式工作方式的违反。

牢记前面的提示,所有通信应按照 MVC 模式的特定顺序进行,从视图传递信息到控制器,从控制器传递信息到模型,而不是直接从模型到视图,如下面的交互图所示:

MVC 交互图

为了传播这些变化,视图元素与控制器中的表示绑定在一起,这样就可以根据需要对其进行操作。当模型更新时,更新视图的过程会发生,并且通常涉及重新加载数据或在视图中隐藏/显示某些元素。

当需要将更改传播到视图中的多个元素时,各种控制器可以协同工作以实现目标。在这些情况下,观察者设计模式的简单实现通常可以有助于避免纠缠的代码。

以下图表是这种模式中的部分如何排列的图形表示,无论是在演示层还是业务逻辑层:

MVC 图形表示

使用 MVC 的好处

MVC 为使用它实现的应用程序提供了许多好处;主要好处是关注点的清晰分离,每个应用程序部分都有单一的责任,从而避免混乱的代码并使代码易于理解。

虽然控制器和视图在使用 MVC 构建应用程序的可视表示时是相互关联的,但模型是绝对隔离的。这使得可以重用相同的模型来创建不同类型的应用程序,包括但不限于以下内容:

  • 移动

  • 网络

  • 桌面

你可能会认为使用这种模型开发的项目可以依靠在开发阶段同时但分别工作的团队,这在某些情况下是正确的,但并不是普遍规则。如前所述,跨团队的有效沟通仍然对整体构建应用程序是必要的。

常见陷阱

当我们使用 MVC 开发应用程序时,通常会发现项目按照 MVC 首字母缩写结构化,如下图所示:

MVC 项目结构

此目录结构表示以下内容:

  • 项目名称是abc-web

  • 这是一个 Web 应用程序

  • 该应用程序使用 MVC 架构(结构)

不幸的是,这些观点都没有为负责创建或维护应用程序的团队提供有意义的信息。这是因为一个项目的团队并不关心文件组织。相反,根据业务规则、用例或与业务本身相关的其他因素来组织代码要更有用得多,而不是技术方面。

考虑到这个想法,我们建议一个更有用的目录结构如下:

可理解的项目结构

从这个图表中,我们可以推断出以下几点:

  • 这是一个会计系统。

  • 项目的主要特点与以下内容相关:

  • Income

  • Expenses

  • 报告

使用前面图表中显示的项目布局,如果我们被要求修复一个不再工作的报告,我们可以考虑审查报告文件夹。这种方法有助于减少完成项目任务所需的时间和精力。

我们可以得出结论,第二个项目结构提供的信息比第一个更有用和实用,因为第一个根本没有提供有关业务的信息。

项目的每个部分都应该传达有关业务的信息,而不是关于使用的交付机制或模式。

这些细节很小,但很重要。在本书的开头,我们提到一个良好的架构是围绕业务需求构建的,架构追求的任何目标都应该被整个团队理解。我们应该以实现这个目标为目标来处理每一个细节。记住:细节很重要。

使用 MVC 实现应用程序

现在你已经了解了 MVC 架构背后的理论,是时候将你学到的概念付诸实践,看看 Spring 框架如何实现它们。我们将从回顾 Spring MVC 开始,这是一个允许我们实现这种架构风格的项目。

Spring MVC

Spring 通过 Spring MVC 提供对 MVC 架构模式的支持。这个 Spring 项目允许整合大量的 UI 框架,以构建表单和相关组件,使用户能够与应用程序进行交互。

Spring MVC 是建立在 servlet API 之上的,它旨在创建 Web 应用程序。没有办法使用它来创建桌面或任何其他类型的应用程序。尽管 MVC 架构模式可以应用于所有这些应用程序,但 Spring MVC 只专注于 Web。

Spring MVC 正式称为 Spring Web MVC。

尽管 Spring MVC 支持大量的视图技术,但最常用的技术往往是 Thymeleaf,因为它的集成非常顺畅。但是,你也可以使用其他框架,比如以下的:

  • JSF

  • FreeMarker

  • Struts

  • GWT

Spring MVC 是围绕前端控制器模式设计的,它依赖于一个对象来处理所有传入的请求并提供相应的响应。在 Spring MVC 的情况下,这个对象由Servlet实现,由org.springframework.web.servlet.DispatcherServlet类表示。

这个Servlet负责将请求委托给控制器,并在屏幕上呈现相应的页面,带有所需的数据。以下图表显示了DispatcherServlet如何处理请求:

DispatcherServlet 请求处理

在前面的图表中,我们可以看到Controller是一个 Java 类,View是一个 HTML 文件。在后一种情况下,我们还可以使用任何tag-library/template-engine标签,它将被编译为在 Web 浏览器中呈现的 HTML 代码。

在 Spring 中,使用@Controller注解在类名上创建一个控制器,如下面的代码片段所示:

import org.springframework.stereotype.Controller;

@Controller
public class DemoController 
{
  ...
}

现在,这个类被标记为一个控制器,我们需要指示将处理什么请求映射,并作为请求处理的一部分需要执行什么操作。为了支持这个功能,我们需要使用@RequestMapping注解编写一个简单的方法,如下面的代码所示:

@RequestMapping(value = "/ABC", method = RequestMethod.GET)
public String handleRequestForPathABC() {
    // do something
    return "ui-template";
}

正如您所看到的,前面的方法处理来自/ABC路径的传入请求,一旦处理完成,将提供一个ui-template,以在浏览器上呈现。

这个操作是由 Spring MVC 使用视图解析器完成的,它将查找渲染名为ui-template.html的文件。如果需要,您还可以编写自定义解析器来为视图添加后缀或前缀。

当我们需要从控制器传递数据到视图时,我们可以使用Model对象,由 Spring 视图解析器启用。这个对象可以填充任何您想在视图中使用的数据。同样,当用户从视图提交数据时,这个对象将填充输入的信息,控制器可以使用它来执行任何所需的逻辑。

为了从控制器发送数据到视图,我们需要在处理请求的方法中将Model对象作为参数包含,如下所示:

@RequestMapping(value = "/ABC", method = RequestMethod.GET)
public String passDataToTheView(Model Model) {
    Model.addAttribute("attributeName", "attributeValue");
    // do something
    return "ui-template";
}

所有模板都可以使用${...}语法(称为表达式语言)读取从控制器传递的属性:

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Title</title>
    </head>
    <body>
        ${attributeName} 
    </body>
</html>

或者,如果您想要将数据从视图组件传递到控制器,您必须在视图中填充一个对象(例如使用表单),如下所示:

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Title</title>
    </head>
    <body>
        <form action="#" th:action="@{/process}"   
        th:object="${myObject}">
            <label for="name">Name:</label>
            <input type="text" id="name" th:field="*{name}"/>
            <button type="submit">OK</button>
         </form>
    </body>
</html>

一旦对象字段被填充并且提交按钮被按下,请求将被发送,以便我们可以声明一个方法来处理请求:

@RequestMapping(value = "/process", method = POST)
public String processForm(@ModelAttribute MyObject myObject) {
    String name = myObject.getName();
    // do something
    return "ui-template";
}

在这种情况下,您可能已经注意到我们使用@ModelAttribute来捕获请求中发送的数据。

测试

测试对我们的应用程序至关重要。当我们使用 Spring MVC 时,我们可以依赖spring-test模块来添加对上下文感知的单元测试和集成测试的支持,这意味着我们可以依赖注解来连接依赖项。我们还可以使用@Autowired注解来测试特定组件。

以下是一个示例,演示了编写一个上下文感知的测试有多简单:

@RunWith(SpringRunner.class)
@SpringBootTest
public class ContextAwareTest {

    @Autowired
    ClassUnderTest classUnderTest;

    @Test
    public void validateAutowireWorks() throws Exception {
        Assert.assertNotNull(classUnderTest);
    }
}

让我们回顾一下粗体字的代码,以了解它是如何工作的:

  • 前两个注解为我们完成了所有的工作;它们将允许我们在 Servlet 容器内运行我们的测试,并且用于测试的 Spring Boot 注解将以与在生产中运行的代码相同的方式连接所有类。

  • 由于我们添加了前面提到的注解,现在我们可以使用@Autowired注解来连接我们想要测试的组件。

  • 代码验证了被测试的类已成功实例化,并且准备好被使用。这也意味着类中的所有依赖项都已成功连接。

这是一个测试代码的简单方法,该代码必须与数据库、消息代理服务器或任何其他中间件进行交互。用于验证与数据库服务器交互的方法使用内存数据库,例如 H2,用于传统 SQL 数据库(如 PostgreSQL 或 MySQL);还有用于 NoSQL 数据库的选项,例如嵌入式 Cassandra 或 Mongo。

另一方面,当您需要测试与其他第三方软件的集成时,一个很好的方法是使用沙盒。沙盒是一个类似于生产环境的环境,供软件供应商用于测试目的。这些沙盒通常部署在生产环境中,但它们也有一些限制。例如,与支付相关的操作不会在最后阶段处理。

当您没有任何方法在自己的环境中部署应用程序时,这种测试方法是有用的,但当然,您需要测试集成是否与您的应用程序正常工作。

假设您正在构建一个与 Facebook 集成的应用程序。在这种情况下,显然不需要进行任何更改,以便在自己的测试环境中部署 Facebook 实例。这是沙盒环境适用的完美例子。

请记住,沙盒测试集成使用第三方软件。如果您是软件供应商,您需要考虑提供允许客户以测试模式尝试您的产品的沙盒。

Spring MVC 测试还具有流畅 API,可以编写高度表达性的测试。该框架提供了一个MockMvc对象,可用于模拟最终用户请求,然后验证提供的响应。常见用例包括以下内容:

  • 验证 HTTP 代码状态

  • 验证响应中的预期内容

  • URL 重定向

以下代码片段使用MockMvc对象来测试先前描述的示例:

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class RedirectionTest 
{
  @Autowired
 private MockMvc mockMvc;
  @Test
  public void contentAndRedirectionTest() throws Exception 
  {
 this.mockMvc.perform(get("/urlPage"))
 .andExpect(redirectedUrl("/expectedUrlPage") .andDo(print()).andExpect(status().isOk())
    .andExpect(
      content().string(containsString("SomeText")))
    );
  }
}

让我们快速审查粗体字中的代码,以了解其工作原理:

  • AutoConfigureMockMvc注解生成了在测试中使用MockMvc对象所需的所有基础代码。

  • MockMvc对象已自动装配并准备就绪。

  • MockMvc提供的流畅 API 用于验证响应的预期状态代码。我们还在测试简单的重定向,以及重定向完成后页面上预期的内容。

测试覆盖率

当我们讨论测试时,经常会听到术语测试覆盖率。这是一个用于检查测试套件执行了多少代码的度量标准,有助于确定未经测试的代码的替代路径,并因此容易出现错误。

假设您正在编写一个具有if语句的方法。在这种情况下,您的代码有两条可选路径要遵循;因此,如果您想实现 100%的覆盖率,您需要编写测试来验证代码可以遵循的所有可选路径。

有许多有用的库可用于测量代码的覆盖率。在本章中,我们将介绍 Java 世界中最流行的库之一;该库称为 JaCoCo(www.eclemma.org/jacoco/)。

为了使 JaCoCo 成为我们应用程序的一部分,我们需要将其作为插件包含在内,使用我们首选的构建工具。

以下是使用 Gradle 包含 JaCoCo 所需的配置:

apply plugin: "jacoco"
jacoco 
{
  toolVersion = "VERSION"
} 

以下是使用 Maven 包含 JaCoCo 所需的配置:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>VERSION</version>
</plugin>

一旦 JaCoCo 作为项目的一部分被包含进来,我们将有新的任务可用于测量我们的代码覆盖率。通过执行以下 Gradle 任务来生成覆盖率报告:

$ ./gradlew test jacocoTestReport

生成的覆盖率报告将以 HTML 格式提供,如下截图所示:

JaCoCo 报告

尽管我们确实希望为我们的代码实现高覆盖率,但我们需要小心编写什么类型的测试,因为考虑到这种方法,我们可能会被诱使编写无用的测试,只是为了实现 100%的覆盖率。

为了充分理解我在这里谈论的内容,让我们审查 JaCoCo 为域包中的一个类生成的报告:

域类的测试覆盖率报告

报告显示,某些方法根本没有测试。其中一些方法对于任何 Java 对象都是标准的,其他方法只是 getter 和 setter(访问器),不需要进行测试。编写 getter 和 setter 通常会导致构建贫血的领域模型,并且大多数情况下,这仅用于使代码与依赖于 Java Beans 约定的框架兼容。因此,没有必要编写测试来覆盖 getter 和 setter。

我看到有人仅为这些方法编写测试,以实现 100%的覆盖率,但这是一个无用且不切实际的过程,应该避免,因为它对代码或编写的测试质量没有任何价值。

现在,让我们来审查一下具有一些值得测试逻辑的类的报告:

服务类的 JaCoCo 覆盖率报告

令人惊讶的是,这个类有 100%的覆盖率。让我们回顾一下这个类的相关测试,如下所示:

@RunWith(MockitoJUnitRunner.class)
public class BankingUserDetailServiceTest 
{
  @Mock
  CustomerRepository customerRepository;
  @InjectMocks
  BankingUsersDetailService bankingUsersDetailService;
 @Test(expected = UsernameNotFoundException.class)
  public void whenTheUserIsNotFoundAnExceptionIsExpected() 
  throws Exception 
  {
    String username = "foo";
    Mockito.when(customerRepository.findByUsername(username))
    .thenReturn(Optional.empty());
    bankingUsersDetailService.loadUserByUsername(username);
  }
  @Test
  public void theUserDetailsContainsTheInformationFromTheFoundCustomer
  () throws Exception 
  {
    String username = "foo";
    String password = "bar";
    Customer customer = 
    new Customer(username, password, NotificationType.EMAIL);
    Mockito.when(customerRepository.findByUsername(username))
    .thenReturn(Optional.of(customer));
    UserDetails userDetails = bankingUsersDetailService
    .loadUserByUsername(username);
 Assert.assertEquals(userDetails.getUsername(), username);
    Assert.assertEquals(userDetails.getPassword(), password);
    Assert.assertEquals(userDetails.getAuthorities()
 .iterator().next().getAuthority(), "ROLE_CUSTOMER");
  }
}

我们并不总是能够达到 100%的覆盖率,就像在这个例子中一样。然而,一个很好的度量标准往往是 80%。您必须将之前提到的百分比视为建议,而不是规则;如果您验证您的测试是否涵盖了所有需要的逻辑,有时低于 80%的值也是可以接受的。

您需要聪明地使用生成的报告来弄清楚需要测试的逻辑,然后着手解决,而不是为结果感到沮丧。

使用这种工具的好处之一是,您可以将其集成为持续集成服务器的一部分,以生成始终可见的报告。通过这种方式,报告可以用于不断检查覆盖率是增加还是下降,并采取行动。我们将在第十一章 DevOps 和发布管理中更详细地讨论这个话题。

UI 框架

当您使用 Spring MVC 时,您可以选择从大量的技术中构建您的网页。根据您选择的框架,您需要添加相应的配置,以便让 Spring 知道您的选择。

正如我们所知,Spring 支持代码配置,因此您需要添加一些注解和/或配置类来使您的框架工作。如果您想避免这些步骤,您可以使用 Thymeleaf;这个框架可以很容易地集成到现有的 Spring 应用程序中,包括 Thymeleaf starter 依赖项。根据所使用的工具,需要使用不同的代码行,如下所示:

  • 在使用 Gradle 时,依赖项如下:
compile('org.springframework.boot:spring-boot-starter-thymeleaf')
  • 在使用 Maven 时,依赖项如下:
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

应用程序启动后,Spring Boot 将为您完成所有无聊的工作,为您的应用程序准备使用 Thymeleaf。

Thymeleaf

Thymeleaf 是一个相对较新的模板引擎;第一个版本于 2011 年发布。Thymeleaf 与 HTML 非常相似,不需要任何 servlet 容器即可在浏览器中预览内容。这被利用来允许设计人员在不部署应用程序的情况下工作应用程序的外观和感觉。

让我们回顾一下如何将使用 HTML 和 Bootstrap 构建的 Web 模板转换为 Thymeleaf 模板,以便看到这个模板引擎并不具有侵入性。以下代码代表一个非常基本的 HTML 模板:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>Default title</title>
    <meta name="viewport" content="width=device-width, 
    initial-scale=1"/>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/
    bootstrap/3.3.7/css/bootstrap.min.css"/>
    <script src="img/jquery.min.js"></script>
    <script src="img/bootstrap.min.js"></script>
  </head>
  <body>
    <nav class="navbar navbar-inverse">
      <div class="container-fluid">
        <div class="navbar-header">
          <a class="navbar-brand" href="#">MVC Demo</a>
        </div>
        <ul class="nav navbar-nav">
          <li><a href="/index">Home</a></li>
          <li><a href="/notifications">My notification channels</a> 
          </li>
        </ul>
        <ul class="nav navbar-nav navbar-right">
          <li>
            <a href="/login"><span class="glyphicon glyphicon-user"> 
            </span>  Login</a>
          </li>
          <li>
            <a href="/logout">
              <span class="glyphicon glyphicon-log-in"></span>
                Logout
            </a>
          </li>
        </ul>
      </div>
    </nav>
    <div class="container">
      <div class="row">
        <div class="col-md-3"></div>
        <div class="col-md-6">
          Page content goes here
        </div>
        <div class="col-md-3"></div>
      </div>
    </div>
  </body>
</html>

由于这是一个常规的 HTML 文件,您可以在浏览器中打开它,看看它的样子:

HTML 和 Bootstrap 模板

现在,让我们实现一些要求,使我们的模板以更现实的方式工作:

  • 仅当用户登录时,注销选项才应出现

  • 如果用户未登录,则不应出现“我的通知渠道”选项

  • 一旦用户登录,登录选项就不应该出现

  • 一旦用户登录,主页选项应该显示一个欢迎消息,使用他们的用户名

在创建 Web 应用程序时,这些要求是微不足道的,幸运的是,它们也很容易使用 Thymeleaf 实现。

为了在用户登录后显示/隐藏网页中的某些元素,我们需要包含一个额外的库来处理这些内容。

要使用 Gradle 包含库,请使用以下命令:

compile('org.thymeleaf.extras:thymeleaf-extras-springsecurity4')

要使用 Maven 包含库,请使用以下命令:

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>

现在,我们需要在 HTML 文件中添加一个标签声明,以便使用 Thymeleaf 和新增加的新扩展:

<html lang="en"

      >

一旦我们包含了这些标签,我们将能够使用提供的内置功能。当您需要根据用户是否已登录来隐藏/显示某个元素时,您可以使用isAuthenticated()条件,如下所示:

<ul class="nav navbar-nav navbar-right">
    <li sec:authorize="!isAuthenticated()">
        <a href="/login"><span class="glyphicon glyphicon-user"></span>  Login</a>
    </li>
    <li sec:authorize="isAuthenticated()">
        <a href="/logout">
            <span class="glyphicon glyphicon-log-in"></span>
              Logout
        </a>
    </li>
</ul>

根据分配的用户角色限制访问也是相当常见的。使用添加的扩展来实现这些检查也很容易,如下面的代码所示:

<li sec:authorize="hasRole('ROLE_ADMIN')"><a href="/a">Admins only</a></li>
<li sec:authorize="hasRole('ROLE_EDITOR')"><a href="/b">Editors only</a></li>

最后,如果您需要在 Web 页面上显示用户名,您可以在 HTML 文件中使用以下标签:

<p>Hello, <span sec:authentication="name"></span>!</p>

另外,一旦模板由我们的设计师或前端专家创建完成,我们将希望在整个应用程序中使用它,以保持一致的外观和感觉。为了实现这个目标,我们需要定义模板中哪些部分将使用layout标签来替换特定内容:

<div class="col-md-6" layout:fragment="content">
    Page content goes here
</div>

然后页面将需要定义模板名称和应该显示在定义片段中的内容,如下所示:

<!DOCTYPE html>
<html lang="en"

 layout:decorator="default-layout">
<head>
    <title>Home</title>
</head>
<body>
<div layout:fragment="content">
    // Content here
</div>
</body>
</html>

我们之前提到 Thymeleaf 根本不具有侵入性,我们将向您展示为什么。一旦使用 Thymeleaf 标签实现了所有期望的逻辑,您可以再次使用常规浏览器打开模板,而无需将应用程序部署在 Servlet 容器中。您将得到以下结果:

Thymeleaf 和 Bootstrap 模板

我们有重复的菜单选项,我们仍然可以看到登录和注销选项,因为浏览器无法解释 Thymeleaf 标签。然而,好消息是,引入的代码并没有对模板造成任何伤害。这正是为什么您的 Web 设计师可以继续工作并在浏览器中预览的原因。无论您在模板中引入了多少 Thymeleaf 标签,这些标签对现有的 HTML 代码都不具有侵入性。

保护 MVC 应用程序

安全是软件开发中的关键方面,如果我们想要避免将我们的应用程序暴露给常见的攻击,我们需要认真对待它。此外,我们可能希望限制非授权人员的访问。我们将在第十三章 安全中审查一些保持软件安全的技术。与此同时,您将学习如何使用 Spring Security 保护 MVC 应用程序。

到目前为止,我们已经审查了如何使用 Thymeleaf 和 Spring MVC 构建 Web 应用程序。在处理 Web 应用程序时,最常见的身份验证机制之一是基本身份验证。让我们更详细地讨论一下这个问题。

基本身份验证

基本身份验证,或基本访问验证,是用于限制或提供对服务器中特定资源的访问的机制。在 Web 应用程序中,这些资源通常是网页,但这种机制也可以用于保护 RESTful Web 服务。然而,这种方法并不常见;基于令牌的不同机制更受青睐。

当网站使用基本身份验证进行保护时,用户需要在请求网站页面之前提供他们的凭据。用户凭据仅仅是用户名和密码的简单组合,使用 Base64 算法进行编码,计算出应该在身份验证标头中的值。服务器稍后将使用这个值来验证用户是否经过身份验证并获得访问所请求资源的授权。如果用户经过身份验证,这意味着提供的用户名和密码组合是有效的;被授权意味着经过身份验证的用户有权限执行特定操作或查看单个页面。

使用这种身份验证机制的一个问题是,当用户在身份验证过程中将凭据发送到服务器时,凭据是以明文形式发送的。如果请求被拦截,凭据就会暴露出来。以下截图清楚地显示了这个问题;在这种情况下,使用了一个名为 Wireshark 的工具来拦截请求(www.wireshark.org):

拦截的 HTTP 请求

可以通过使用安全版本的 HTTP 来轻松解决此问题,其中需要证书来加密服务器和浏览器之间交换的数据。证书应由受信任的证书颁发机构CA)颁发,并应位于服务器上。浏览器有一个受信任的 CA 根证书列表,在建立安全连接时进行验证。一旦证书验证通过,地址栏将显示一个挂锁,如下图所示:

地址栏中显示的挂锁

如下图所示,HTTPS 协议使用8443端口,而不是标准的80端口,后者用于 HTTP:

地址栏使用 HTTPS

出于开发目的,您可以生成自己的证书,但浏览器会显示警告,指示无法验证证书;您可以添加异常以使用 HTTPS 打开请求的页面。

以下图表显示了使用 HTTPS 协议建立连接的过程:

HTTPS 连接

中间的挂锁代表了数据在计算机网络中传输时的加密,使其无法阅读。以下截图显示了使用 Wireshark 拦截数据的样子:

拦截的 HTTPS 请求

正如您所看到的,这些拦截的数据很难理解。通过这种方式,发送的所有信息都受到保护,即使在传输过程中被捕获,也不能轻易阅读。这种攻击被称为中间人攻击,是最常见的攻击类型之一。

实施基本身份验证

现在您已经了解了与基本身份验证相关的基础知识以及其工作原理,让我们来看看如何在 Spring MVC 应用程序中实现它。

首先,我们需要包含 Spring Security 的起始依赖项。

可以在 Gradle 中包含如下:

compile('org.springframework.boot:spring-boot-starter-security')

可以在 Maven 中包含如下:

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

添加了这个依赖项后,Spring Boot 将为我们完成所有繁琐的工作,我们不需要做任何事情来保护应用程序。如果我们不添加任何额外的配置,Spring 将为测试生成一个用户,并且密码将打印在控制台上。这种情况在开发的早期阶段非常完美。

另一方面,如果我们需要自定义的方式来允许或限制用户访问,我们只需要实现loadUserByUsername方法,该方法是UserDetailsService接口的一部分。

实现相当简单;该方法检索提供的username,并且使用该用户名,您需要返回一个带有用户信息的UserDetails对象。

让我们来看一个例子,如下所示:

@Service
public class MyCustomUsersDetailService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Customer> customerFound = findByUsername(username);
        if (customerFound.isPresent()) {
            Customer customer = customerFound.get();
            User.UserBuilder builder = User
                    .withUsername(username)
                    .password(customer.getPassword())
                    .roles(ADD_YOUR_ROLES_HERE);
            return builder.build();
        } else {
            throw new UsernameNotFoundException("User not found.");
        }
    }
}

findByUsername方法负责在数据库或其他存储中查找您需要的用户。一旦您定制了用户的位置,您就必须处理网页的授权。这可以通过实现WebSecurityConfigurerAdapter接口来完成,如下面的代码所示:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
         httpSecurity.authorizeRequests()
             .antMatchers("/index").permitAll()
             .antMatchers("/guest/**").permitAll()
 .antMatchers("/customers/**").hasAuthority("ROLE_CUSTOMER")
             .anyRequest().authenticated()
             .and()
             .formLogin()
 .loginPage("/login")
            .failureUrl("/login?error")
            .successForwardUrl("/home")
             .usernameParameter("username").passwordParameter("password")
                .permitAll()
             .and()
 .logout().logoutSuccessUrl("/logout")
             .and()
             .csrf(); 
    }
}

让我们来审查加粗显示的代码:

  • 我们正在配置一个路径来授予任何用户访问权限,无论请求是否经过身份验证

  • CUSTOMER角色的用户限制访问的配置已添加到customers路径下的所有页面

  • 配置了登录页面,以及成功和失败的认证尝试的页面转发

  • 提供了/logout URL,用于在注销过程发生后重定向用户

如您所见,一旦实现了前面的配置类,您将拥有所有必要的内容来保护应用程序中的网页。

我们之前提到,一个好的方法是使用 HTTPS 来加密在浏览器和服务器之间发送的数据。为了实现这个目标,Spring Boot 提供了将以下配置属性添加到application.properties文件中的能力:

server.port: 8443
server.ssl.key-store: keystore.p12
server.ssl.key-store-password: spring
server.ssl.keyStoreType: PKCS12
server.ssl.keyAlias: tomcat

让我们回顾一下这个文件中的配置:

  • 如前所述,HTTPS 使用8443端口。

  • 下一个参数允许指定数字证书名称。

  • 密钥库密码也应提供。请注意,当执行应用程序时,可以将此值作为参数提供。更好的方法是从配置服务器获取这些值,而不是将它们硬编码在application.properties文件中。

  • 此参数用于指定生成证书时使用的存储类型。

  • 最后一个参数对应于数字证书的别名。

请注意,代码不应该被修改以在应用程序中启用 HTTPS。

为了测试的目的,可以使用标准 Java 安装的一部分的密钥工具来创建自签名证书,如下面的屏幕截图所示:

自签名证书创建

摘要

在本章中,我们探讨了与 MVC 架构及其工作相关的概念。我们还讨论了人们在使用这种架构风格构建应用程序时容易犯的错误。

然后,我们回顾了如何使用 Spring MVC 创建应用程序,查看了不同的测试以及如何使用 Spring 提供的功能来实现它们。我们还回顾了如何在 Spring MVC 中使用 Thymeleaf 来构建 Web 应用程序的用户界面。为了完成本章,我们讨论了一些安全概念,包括如何在 Spring MVC 应用程序中应用它们。

在下一章中,您将了解事件驱动架构,这种架构变得非常流行。

第六章:事件驱动架构

事件驱动架构EDA)基于每次应用程序更改状态时创建的命令和事件。根据 Martin Fowler 的说法,有四种模式用于使用这种方法构建软件系统。

在本章中,我们将学习这四种模式,并看看如何将消息传递联系在一起,以充分利用基于消息的编程模型。即使这不是一个要求,消息传递也可以用来为使用基于事件驱动的架构风格构建的应用程序增加更多功能。

在本章中,我们将讨论以下主题:

  • 事件驱动架构的基本概念和关键方面:

  • 命令

  • 事件

  • 在事件驱动架构中使用的常见模式:

  • 事件通知

  • 事件携带状态传输

  • 事件溯源

  • CQRS

基本概念和关键方面

在深入了解事件驱动架构的细节之前,我们将首先学习一些围绕它们的关键方面。

使用这种方法创建的应用程序是根据两个不同但相关的概念开发的:

  • 命令

  • 事件

让我们简要定义一下这些概念。

命令

命令是在应用程序中执行的操作,作为成功或失败执行的结果会发出一个或多个事件。我们可以将这些操作看作是旨在修改系统状态的操作。

命令被称为操作。如果我们考虑到它们的预期用途,这是非常合理的。以下列表显示了一些此类命令的示例:

  • 转账

  • 更新用户信息

  • 创建一个账户

强烈建议您使用现在时态的动词来命名命令,就像这些例子所示。

事件

事件是应用程序中命令执行的结果。这些事件用作订阅者接收通知的机制。事件是不可变的,不应该被修改,因为它们被设计为保留应用程序状态如何随时间变化的日志信息。

在命名事件时,经验法则是使用过去时态,例如以下内容:

  • 资金转移

  • 用户信息已更新

  • 账户已创建

事件不关心它们创建后将执行什么操作。这使得可以解耦系统但仍通知订阅者。这样,我们可以解耦应用程序,因为订阅者负责根据需要执行一个或多个操作,一旦他们被通知事件的创建。

在这一点上,我们可以得出结论,我们可以解耦应用程序,因为订阅者负责根据需要执行一个或多个操作,一旦他们被通知事件的创建。我们还可以推断,事件是通过将责任委托给其他系统来逆转依赖关系的绝佳方式。

以下图表显示了命令如何发出事件以及这些事件的订阅者如何被通知:

事件的创建和传播

现在我们对事件有了更好的理解,让我们回顾一下本章开头提到的四种模式,以便使用基于事件驱动的架构风格创建应用程序。

事件驱动架构的模式

当人们谈论事件驱动架构时,他们经常提到以下模式之一:

  • 事件通知

  • 事件携带状态传输

  • 事件溯源

  • CQRS

有时,在同一系统中会同时使用多个模式,具体取决于业务需求。让我们回顾每种模式,以便确定可以使用它们的场景。

事件通知

事件通知模式通过在执行命令后向订阅者发出事件来工作。这可以与观察者模式进行比较,观察者模式中,您观察到一个具有许多监听器或订阅者列表的主题,在观察对象的状态发生变化时会自动通知它们。

这种行为被事件总线库广泛使用,允许应用程序中的组件之间进行发布-订阅通信。这些库的最常见用例是针对 UI,但它们也适用于后端系统的其他部分。下图演示了事件如何发送到总线,然后传播到之前注册的所有订阅者:

事件总线

使用此事件通知机制有两个主要好处:

  • 解耦的系统和功能

  • 倒置的依赖关系

为了更好地理解这些好处,让我们想象一下我们的银行应用程序需要处理以下需求:

银行希望为使用移动应用的客户提供转账的机会。这将包括在我们银行拥有的账户之间转账,或者转账到外部银行。一旦执行此交易,我们需要使用客户首选的通知渠道通知客户有关交易状态。

银行还有一个应用程序,由呼叫中心工作人员使用,通知我们的代理客户的余额。当客户的账户余额高于预定金额时,呼叫中心系统将提醒代理,然后代理将致电客户,让他们意识到可以将他们的钱投资到银行。最后,如果交易涉及外部银行,我们也需要通知他们交易状态。

使用经典方法编写应用程序,我们可以正确构建一个系统,在转账发生后,所有在转账应用程序边界内列出的后置条件都得到执行,如下图所示:

耦合的转账应用程序

正如我们从上图中看到的,转账应用程序需要知道一旦交易发生,必须满足的所有后置条件;使用这种方法,我们最终将编写所有必要的代码与其他系统进行交互,这将导致应用程序与其他系统耦合。

另一方面,使用事件通知模式,我们可以解耦转账应用程序,如下图所示:

解耦的转账应用程序

在上图中,我们可以看到一旦执行<Transfer money>命令,就会发出<Money transferred>事件,并通知所有订阅的系统。通过这样做,我们可以摆脱系统之间的耦合。

这里需要注意的重要一点是,转账应用程序甚至不需要知道其他软件系统的存在,并且所有后置条件都在该应用程序的边界之外得到满足。换句话说,解耦的系统导致我们倒置依赖关系。

解耦的系统和倒置的依赖关系听起来很棒,但这种方法的隐含缺点是您会失去可见性。这是因为发出事件的应用程序对于发布事件后执行的进程一无所知,也没有用于读取其他系统的代码。

通常无法识别下游依赖关系,并且通常使用一些技术来在不同日志之间关联事件,以减轻这一噩梦。

耦合的系统提供有关下游依赖的所有信息,并且难以演变。相反,解耦的系统对下游依赖一无所知,但它们提供了独立演变系统的机会。

现在我们已经了解了支持事件通知模式的基本概念,我们可以说,实现这种应用程序最显而易见的技术是使用 RabbitMQ、AWS SQS/SNS、MSMQ 等消息系统。这些都是 Spring Cloud Stream 项目下的 Spring 支持的。在我们的案例中,我们将使用 RabbitMQ,可以通过添加以下依赖来支持:

<dependency>
   <groupId>org.springframework.cloud</groupId> 
   <artifactId>spring-cloud-stream-binder-rabbit</artifactId> </dependency>

为了使 RabbitMQ 的设置过程可访问,本章提供的代码包括一个 Docker Compose 文件,应使用docker-compose up命令执行。我们将在第十章中看到 Docker Compose 是什么以及它是如何工作的,容器化您的应用程序

Spring Cloud Stream 建立在 Spring Integration 之上,提供了轻松生产和消费消息的机会,以及使用 Spring Integration 的所有内置功能的机会。我们将使用这个项目来实现前面提到的银行应用程序的示例,因此我们需要添加以下依赖项:

<dependency> 
    <groupId>org.springframework.cloud</groupId> 
    <artifactId>spring-cloud-stream</artifactId> 
</dependency>

转账应用程序将公开一个端点,允许转账。一旦完成这笔交易,就需要向其他应用程序发送事件通知。Spring Cloud Stream 使得可以使用@Output注解定义消息通道,如下所示:

public interface EventNotificationChannel 
{
 @Output  MessageChannel moneyTransferredChannel();
}

这个接口可以被注释并在任何地方使用。让我们看看如何在控制器中使用它,以公开转账功能:

@RestController
public class TransferController 
{
  private final MessageChannel moneyTransferredChannel;
  public TransferController(EventNotificationChannel channel) 
  {
    this.moneyTransferredChannel = channel.moneyTransferredChannel();
  }
  @PostMapping("/transfer")
  public void doTransfer(@RequestBody TransferMoneyDetails
  transferMoneyDetails) 
  {
    log.info("Transferring money with details: " +
    transferMoneyDetails);
    Message<String> moneyTransferredEvent = MessageBuilder
 .withPayload
    ("Money transferred for client with id: " + transferMoneyDetails.getCustomerId()).build();
    this.moneyTransferredChannel.send(moneyTransferredEvent);
  }
}

当我们使用事件通知模式时要记住的一件事是,发出事件的应用程序只提供关于执行的命令的非常基本的信息。在这种情况下,<转账完成>事件包含应该稍后用于查询更多信息并确定是否需要执行其他操作的客户端 ID。这个过程总是涉及与其他系统、数据库等的一个或多个额外交互。

订阅者也可以利用 Spring Cloud Stream。在这种情况下,应该使用@Input注解如下:

public interface EventNotificationChannel 
{
  @Input
  SubscribableChannel subscriptionOnMoneyTransferredChannel();
}

使用 Spring Integration,可以执行完整的集成流程来处理传入的消息:

@Bean
IntegrationFlow integrationFlow(
            EventNotificationChannel eventNotificationChannel) {
    return IntegrationFlows.from
        (eventNotificationChannel
            .subscriptionOnMoneyTransferredChannel()).
                handle(String.class, new GenericHandler<String>() {
            @Override
            public Object handle(String payload, 
            Map<String, Object> headers) {

 // Use the payload to find the transaction and determine
            // if a notification should be sent to external banks 
     }
         }).get();
}

一旦检索到消息,就应该用它来查询有关交易的其他信息,并确定是否应该向外部银行发送通知。这种方法有助于减少有效负载的大小。它还有助于避免发送通常是不必要的和对其他系统无用的信息,但会增加源应用程序检索的流量。

在最坏的情况下,每个产生的事件都将至少检索一个额外的请求,要求交易详情,如下图所示:

下游依赖请求交易详情

在我们的示例中,每个产生的事件都将至少有三个来自依赖系统的其他请求。

事件携带状态传输

与之前讨论的事件通知模式相比,事件携带状态传输模式有一个小的变化。在这里,事件包含与执行的命令相关的非常基本的信息。在这种情况下,事件包含有关执行的命令的所有信息,用于避免通过依赖系统进一步处理而联系源应用程序。

这种模式为我们带来了以下好处:

  • 提高应用程序性能

  • 减少源应用程序的负载

  • 增加系统的可用性

让我们在接下来的部分讨论每个要点。

提高应用程序性能

在前面的例子中,一旦事件被下游系统产生和检索,就需要执行额外的操作来获取与交易相关的详细信息。这决定了作为流程的一部分需要执行的操作。这个额外的操作涉及与源应用程序建立通信。在某些情况下,这一步可能只需要几毫秒,但响应时间可能会更长,这取决于网络流量和延迟。这将影响依赖系统的性能。

因此,源应用程序提供的负载大小增加,但需要的流量减少。

减少对源应用程序的负载

由于作为产生事件的一部分包含了与执行命令相关的所有信息,因此无需再向源应用程序请求更多信息。因此,请求减少,减轻了源应用程序的负载。

在最佳情况下,产生的事件与检索到的请求之间的关系是 1:1。换句话说,一个请求会产生一个事件,但根据依赖系统需要在检索事件时请求多少额外信息,情况可能更糟。

为了避免这种额外负载,所有下游系统通常都有自己的数据存储,其中事件信息被持久化,如下图所示:

下游依赖持久化事件数据

使用这种方法时,每个下游系统只存储与自身相关的数据,提供的其余信息会被忽略,因为对于系统来说是无用的,根本不会被使用。

增加系统的可用性

在消除了一旦检索到事件就需要请求额外数据的需要之后,可以自然地假设系统的可用性已经提高,因为无论其他系统是否可用,事件都将被处理。引入这一好处的间接后果是现在系统中的最终一致性。

最终一致性是一种模型,用于在系统中实现高可用性,如果给定数据没有进行新的更新,一旦检索到一条信息,所有访问该数据的实例最终将返回最新更新的值。

下图显示了系统如何在不将这些更改传播到下游依赖项的情况下改变其数据:

数据更新不会传播

为了使前面的例子遵循这种方法,我们只需要在负载的一部分中包含额外的信息。以前,我们只发送了一个带有clientIdString;现在我们将以以下方式涵盖完整的TransactionMoneyDetails

@RestController
public class TransferController 
{
  private final MessageChannel moneyTransferredChannel;
  public TransferController(EventNotificationChannel channel) 
  {
    this.moneyTransferredChannel = channel.moneyTransferredChannel();
  }
  @PostMapping("/transfer")
  public void doTransfer(@RequestBody TransferMoneyDetails 
  transferMoneyDetails) 
  {
    // Do something
 Message<TransferMoneyDetails> moneyTransferredEvent = 
 MessageBuilder.withPayload(transferMoneyDetails).build();
 this.moneyTransferredChannel.send(moneyTransferredEvent);
  }
}

Message类可以支持任何应该在<>中指定的对象,因为这个类是使用 Java 的泛型类型特性实现的。

下游依赖系统也应该被修改,使它们能够检索对象而不是简单的字符串。由于处理传入消息的Handler也支持泛型,我们可以通过对代码进行小的更改来实现这个功能,如下所示:

@Bean
IntegrationFlow integrationFlow(EventNotificationChannel eventNotificationChannel) 
{
  return IntegrationFlows
  .from(eventNotificationChannel
  .subscriptionOnMoneyTransferredChannel())
  .handle(TransferMoneyDetails.class, new GenericHandler
  <TransferMoneyDetails>() 
  {
    @Override
    public Object handle(TransferMoneyDetails payload, Map<String, 
    Object> map) 
    {
      // Do something with the payload
      return null;
    }
  }).get();
}

事件溯源

事件溯源是另一种使用基于事件驱动方法实现应用程序的方式,其中功能的核心基于产生事件的命令,一旦处理完毕,这些事件将改变系统状态。

我们可以将命令看作是在系统内执行的交易的结果。这个交易会因以下因素而不同:

  • 用户操作

  • 来自其他应用程序的消息

  • 执行的定期任务

使用事件源方法创建的应用程序存储与执行命令相关的事件。还值得存储产生事件的命令。这样可以将它们全部相关联,以便了解所创建的边界。

存储事件的主要原因是在任何时间点重建系统状态时使用它们。使这项任务变得更容易的方法是定期为存储系统状态的数据库生成备份,这有助于避免重新处理应用程序开始工作以来创建的所有事件的需要。相反,我们只需要处理在生成数据库快照之后执行的事件集。

让我们回顾以下一系列图表,以了解这是如何工作的。第一个图表显示一旦执行Command A,就会创建三个“事件”,并且在处理每个事件后生成一个新的“状态”:

一旦执行 Command A,生成的事件和应用程序状态

下一个图表代表了一个相似的过程。在这种情况下,由于Command B的执行,创建了两个“事件”:

作为 Command B 执行的结果生成的事件和应用程序状态

到目前为止,我们的应用程序有五个状态:

  • 状态 A

  • 状态 B

  • 状态 C

  • 状态 D

  • 状态 E

假设我们对“事件 b-1”感兴趣,因为在执行时应用程序崩溃了。为了实现这个目标,我们有两个选择:

  • 逐个处理事件,并在“事件 b-1”执行期间研究应用程序行为,如下图所示:

处理所有事件重建应用程序状态

  • 在恢复数据库快照后处理其余事件,并在“事件 b-1”执行期间研究应用程序行为,如下图所示:

从数据库快照重建应用程序状态

显然,第二种方法更有效。定期任务通常负责在一定时间后创建数据库快照,并且应该建立一个管理现有快照的策略。例如,您可以建立一个策略,在每天午夜创建一个新的快照,并在最适合您业务的时间后清除旧的快照。

正如您可能已经意识到的那样,我们系统的真相来源是事件存储,这使我们能够随时重建应用程序状态。由于事件被用来生成系统状态,我们可以完全依赖事件存储。然而,我们还应该考虑一个事实,即系统内的事件执行也需要与另一个应用程序进行交互。在这种情况下,如果重放该事件,您应该考虑其他系统将如何受到影响。在这里,我们将得到以下两种情况之一:

  • 在其他应用程序中执行的操作是幂等的

  • 其他应用程序将受到影响,因为将生成新的事务

在第一种情况下,由于操作是幂等的,我们根本不必担心。这是因为另一个执行不会影响其他系统。在第二种情况下,我们应该考虑创建补偿操作的方法或者忽略这些交互的方法,以避免影响其他系统。

在遵循这种方法后,我们将获得以下固有的好处:

  • 可用于审计目的的数据存储

  • 一个很好的日志级别

  • 调试应用程序将更容易

  • 历史状态

  • 回到以前的状态的能力

事件溯源应用程序的典型示例是版本控制系统(VCS),如 Git、Apache 子版本、CVS 或任何其他版本控制系统,其中存储了应用于源代码文件的所有更改。此外,提交代表了允许我们在需要时撤消/重做更改的事件。

为了尽可能简单地理解,您可以将事件溯源应用程序视为以与版本控制系统管理文件更改相同的方式管理数据更改。您还可以将git push操作视为事件溯源系统中的命令。

现在我们已经解释了事件溯源背后的概念,是时候深入了解允许我们理解如何按照这种方法实现系统的细节了。虽然有不同的方法来创建事件溯源应用程序,但我将在这里解释一种通用的方法。重要的是要记住,这种方法应根据您的业务的特定需求或假设进行更改。

我们提到事件溯源系统应该至少有两个存储数据的地方。其中一个将用于保存事件和命令信息,另一个将用于保存应用程序状态——我们说至少两个,因为有时需要多个存储选项来持久化应用程序的系统状态。由于系统检索的输入以执行其业务流程非常不同,我们应该考虑使用支持使用 JSON 格式存储数据的数据库。按照这种方法,应作为事件溯源系统中执行的命令的一部分存储的最基本数据如下:

  • 唯一标识符

  • 时间戳

  • 以 JSON 格式检索的输入数据

  • 用于关联命令的任何附加数据

另一方面,应存储的建议数据事件如下:

  • 唯一标识符

  • 时间戳

  • 事件的相关数据以 JSON 格式

  • 生成事件的命令的标识符

正如我们之前提到的,根据您的业务需求,您可能需要添加更多字段,但前面提到的字段在任何情况下都是必要的。关键在于确保您的数据稍后能够被处理以在需要时重新创建应用程序状态。几乎任何 NoSQL 数据库都支持将数据存储为 JSON,但一些 SQL 数据库,如 PostgreSQL,也可以很好地处理这种格式的数据。

关于系统状态的决定,选择 SQL 或 NoSQL 技术完全取决于您的业务;您不必因为应用程序将使用事件溯源方法而改变主意。此外,您的数据模型结构也应该取决于业务本身,而不是取决于生成将存储在那里的数据的事件和命令。还值得一提的是,一个事件将生成将存储在系统状态数据模型的一个或多个表中的数据,并且在这些方面根本没有限制。

当我们考虑命令、事件和状态时,通常会提出一个问题,即信息持久化的顺序。这一点可能是一个有趣的讨论,但您不必太担心数据持久化的顺序。您可以选择在任何数据存储实例中同步或异步地持久化数据。

异步方法有时会让我们认为我们最终会得到不一致的信息,但事实是两种方法都可能导致这一点。我们应该考虑从这些崩溃中恢复我们的应用程序的机制,例如适当的日志记录。良好的日志记录对于恢复我们系统的数据非常有帮助,就像我们为使用事件源以外的任何方法构建的应用程序一样。

现在是时候回顾一些代码,把我们之前讨论过的概念付诸实践了。让我们构建一个应用程序,允许我们开设一个新的银行账户。所需的输入数据如下:

  • 客户姓名

  • 客户姓氏

  • 开设账户的初始金额

  • 账户类型(储蓄/活期)

创建账户后,我们的应用程序状态应该反映出一个新的客户和一个新创建的银行账户。

作为我们应用程序的一部分,我们将有一个命令:CreateCustomerCommand。这将生成两个事件,名为CustomerCreatedAccountCreated,如下图所示:

命令执行

执行此命令后,需要发生一些事情:

  • 应保存命令

  • 上述事件应该使用相关信息创建

  • 应保存事件

  • 应处理事件

这个过程的相关代码如下所示:

public class CreateCustomerCommand extends Command {

    public void execute() {

        String commandId = UUID.randomUUID().toString();
        CommandMetadata commandMetadata 
            = new CommandMetadata(commandId, getName(), this.data);
 commandRepository.save(commandMetadata);

        String customerUuid = UUID.randomUUID().toString();

        JSONObject customerInformation = getCustomerInformation();
        customerInformation.put("customer_id", customerUuid);

        // CustomerCreated event creation EventMetadata customerCreatedEvent 
 = new EventMetadata(customerInformation, ...);        // CustomerCreated event saved eventRepository.save(customerCreatedEvent);        // CustomerCreated event sent to process eventProcessor.process(customerCreatedEvent);

        JSONObject accountInformation = getAccountInformation();
        accountInformation.put("customer_id", customerUuid);

        // AccountCreated event creation
 EventMetadata accountCreatedEvent 
 = new EventMetadata(accountInformation, ...);        // AccountCreated event saved eventRepository.save(accountCreatedEvent);        // AccountCreated event sent to process eventProcessor.process(accountCreatedEvent);

    }
    ...
}

事件处理完毕后,应生成系统状态。在这种情况下,意味着应创建一个新的客户和一个新的账户,如下图所示:

处理事件后生成的系统状态

为了实现这个目标,我们有一个非常基本的实现,根据事件名称执行代码指令,如下所示:

@Component
public class EventProcessor {

    public void process(EventMetadata event) {
        if ("CustomerCreated".equals(event.getEventName())) {
            Customer customer = new Customer(event);
            customerRepository.save(customer);
        } else if ("AccountCreated".equals(event.getEventName())) {
            Account account = new Account(event);
            accountRepository.save(account);
        }
    }
    ...
}

如果您想看看应用程序的工作原理,可以执行以下CURL命令:

$ curl -H "Content-Type: application/json" \
 -X POST \
 -d '{"account_type": "savings", "name": "Rene", "last_name": "Enriquez", "initial_amount": 1000}' \
 http://localhost:8080/customer

您将在控制台中看到以下消息:

COMMAND INFORMATION
id: 8782e12e-92e5-41e0-8241-c0fd83cd3194 , name: CreateCustomer , data: {"account_type":"savings","name":"Rene","last_name":"Enriquez","initial_amount":1000} 
EVENT INFORMATION
id: 71931e1b-5bce-4fe7-bbce-775b166fef55 , name: CustomerCreated , command id: 8782e12e-92e5-41e0-8241-c0fd83cd3194 , data: {"name":"Rene","last_name":"Enriquez","customer_id":"2fb9161e-c5fa-44b2-8652-75cd303fa54f"} 
id: 0e9c407c-3ea4-41ae-a9cd-af0c9a76b8fb , name: AccountCreated , command id: 8782e12e-92e5-41e0-8241-c0fd83cd3194 , data: {"account_type":"savings","account_id":"d8dbd8fd-fa98-4ffc-924a-f3c65e6f6156","balance":1000,"customer_id":"2fb9161e-c5fa-44b2-8652-75cd303fa54f"}

您可以通过在 URL:http://localhost:8080/h2-console中使用 H2 web 控制台执行 SQL 语句来检查系统状态。

以下截图显示了查询账户表的结果:

从账户表中查询结果

以下截图显示了查询客户表的结果:

从客户表中查询结果

事件源应用程序的最关键测试是在数据被删除后能够重新创建状态。您可以通过使用以下 SQL 语句从表中删除数据来运行此测试:

DELETE FROM CUSTOMER;
DELETE FROM ACCOUNT;

在 H2 控制台中执行这些操作后,可以通过运行以下CURL命令重新创建状态:

$ curl -X POST http://localhost:8080/events/<EVENT_ID> 

请注意,您需要用前面 URL 中列出的<EVENT_ID>替换控制台中执行命令时列出的值。

CQRS

命令查询职责分离CQRS)是一种模式,其主要思想是通过创建分离的接口来与系统的数据存储交互,从而创建用于读取和写入数据的分离数据结构和操作。

CQRS 实际上并不是基于事件,但由于它经常与事件源实现一起使用,因此值得提到它适用的场景。有三种主要用例,其中处理和查询信息的接口分离将会很有用:

  • 复杂的领域模型

  • 查询和持久化信息的不同路径

  • 独立扩展

复杂的领域模型

这种情景指的是检索到的输入在数据库中简单管理和持久化的系统。然而,在将信息提供给用户之前,需要进行许多转换,使数据对业务有用和全面。

想象一个系统,其中代码由大量实体对象组成,这些对象使用 ORM 框架将数据库表映射为持久化信息。这种系统涉及许多使用 ORM 执行的写入和读取操作,以及作为系统一部分运行的一些操作,用于将检索到的数据(以实体对象的形式)转换为数据传输对象(DTO),以便以有意义的方式为业务提供信息。

以下图表显示了从数据库到业务服务的数据流,设计遵循这种方法:

使用实体对象和 DTO 的数据流

转换数据并不是什么大问题。在使用 ORM 的系统中,最大的问题是实体对象带来包含在转换过程中被忽略的无用信息的列,这会给数据库和网络带来不必要的开销。另一方面,在上图中,我们可以看到在实际获取所请求的数据之前,需要一个大的过程将数据库表映射为对象。解决这个问题的一个好方法是用存储过程或纯查询语句替换 ORM 框架执行的读操作,从数据库中仅检索所需的数据。

以下图表显示了如何用 DOTs 替换实体对象:

使用 DTO 的数据流

很明显,这种方法更简单,更容易实现。所需的代码量甚至大大减少。我并不是在得出 ORM 框架不好的结论——实际上,其中许多都非常棒,像 Spring Data 这样的项目提供了大量内置功能。然而,根据业务需求,纯 JDBC 操作有时对系统更有益。

查询和持久化信息的不同路径

在构建应用程序时,我们经常发现自己在使用系统提供的信息之前对检索到的输入进行大量验证。

应用于检索数据的常见验证包括以下内容:

  • 验证非空值

  • 特定文本格式,如电子邮件

  • 检查以验证字符串长度

  • 数字中允许的最大小数位数

有许多机制可用于在我们的代码中实现这种验证。其中最流行的是基于第三方库的,依赖于可以使用正则表达式进行扩展以适用于特定场景的注解。甚至有一个作为平台的一部分可以用于验证类字段的规范,称为 Bean Validation。这目前是Java 规范请求JSR380的一部分(beanvalidation.org/)。

当用户或外部系统提供数据时,有必要进行所有这些验证,但是当从数据库中读取信息并返回给用户时,就没有必要继续执行这些检查。此外,在某些情况下,例如事件溯源,一旦检索到数据,会执行一些命令,创建事件,最终持久化信息。

在这些场景中,显然持久化和读取信息的过程是不同的,它们需要分开的路径来实现它们的目标。

以下图表显示了应用程序如何使用不同路径来持久化和检索数据:

使用不同路径持久化和查询的数据

从上图可以快速注意到有多少处理是不必要的,因为它绝对是不必要的。此外,用于查询和处理信息的领域模型通常不同,因为它们旨在实现不同的目标。

独立扩展

如今,常常听到开发人员、软件架构师和技术人员讨论创建独立服务来解决不同的需求。创建独立服务支持独立扩展的方法,因为它使得可以分别扩展创建的服务。

在这种情况下,主要的想法是创建可以独立构建和部署的独立系统。这些不同应用程序的数据源可以是相同的,也可以是不同的,这取决于需求是什么。这里最常见的情况是两个系统使用相同的数据存储,因为应用的更改应该立即反映出来。否则,延迟的数据可能会在应用程序的正常运行过程中引起混乱或错误。

让我们想象一个在线商店。假设你向购物车中添加了许多商品,在结账后,你意识到支付的金额比所需的金额要低,因为在结账过程中并未考虑所有商品。这是应用程序中不希望出现的行为。

另一方面,在某些情况下,使用不同的数据存储是可以接受的,因为检索延迟数小时或数天的数据已足以满足应用程序相关的业务需求。想象一下,你的任务是创建一个报告,显示人们倾向于在哪些月份请假。当然,一个数据库如果没有最新的更改,稍微落后于应用程序的当前状态,也可以完美地工作。当我们有这种需求时,我们可以使用报告数据库(有关更多详细信息,请参见martinfowler.com/bliki/ReportingDatabase.html)来检索信息。这种方法通常用于当应用程序旨在提供执行报告信息以做出战略决策时,而不是获取数据库表中所有现有记录的列表。

拥有独立的系统来查询和处理信息使我们能够在两个系统上实现独立的扩展能力。当其中一个系统需要更多资源进行处理时,这是非常有用的。让我们以前面提到的在线商店为例,人们总是在寻找要购买的商品,进行比较,检查尺寸、价格、品牌等等。

在前面的例子中,检查订单的请求次数少于检查商品信息的请求次数。因此,在这种情况下,拥有独立的系统可以避免不必要地浪费资源,并且可以只增加更多资源或服务实例,以处理流量最大的服务。

总结

在本章中,我们介绍了事件驱动架构以及用于实现使用这种架构风格的应用程序的四种常见模式。我们详细解释了每种模式,并编写了一些代码来理解它们如何使用 Spring Framework 实现。同时,我们还研究了一些可以利用它们的用例,并学习了它们如何帮助我们减少作为系统需求一部分引入的复杂性。

作为这些模式的一部分,我们谈到了事件溯源,在微服务世界中越来越受欢迎,我们将在《微服务》的第八章中学习更多相关内容。

第七章:管道和过滤器架构

在本章中,我们将回顾一个有用的范式架构,名为管道和过滤器,并学习如何使用 Spring 框架实现应用程序。

我们还将解释如何构建一个封装了独立任务链的管道,旨在过滤和处理大量数据,重点放在使用 Spring Batch 上。

本章将涵盖以下主题:

  • 管道和过滤器概念介绍

  • 上船管道和过滤器架构

  • 管道和过滤器架构的用例

  • Spring Batch

  • 使用 Spring Batch 实现管道

我们将首先介绍管道和过滤器架构及其相关概念。

介绍管道和过滤器概念

管道和过滤器架构是指上世纪 70 年代初引入的一种架构风格。在本节中,我们将介绍管道和过滤器架构,以及过滤器和管道等概念。

Doug McIlroy 于 1972 年在 Unix 中引入了管道和过滤器架构。这些实现也被称为管道,它们由一系列处理元素组成,排列在一起,以便每个元素的输出是下一个元素的输入,如下图所示:

如前图所示,管道和过滤器架构由几个组件组成,称为过滤器,它们可以在整个过程中转换(或过滤)数据。然后,数据通过连接到每个组件的管道传递给其他组件(过滤器)。

过滤器

过滤器是用于转换(或过滤)从前一个组件通过管道(连接器)接收的输入数据的组件。如下图所示,每个过滤器都有一个输入管道和一个输出管道:

这个概念的另一个特点是,过滤器可以有多个输入管道和多个输出管道,如下图所示:

管道

管道是过滤器的连接器。管道的作用是在过滤器和组件之间传递消息或信息。我们必须记住的是,流动是单向的,数据应该被存储,直到过滤器可以处理它。如下图所示,在过滤器之间可以看到连接器:

管道和过滤器架构风格用于将较大的过程、任务或数据分解为一系列由管道连接的小而独立的步骤(或过滤器)。

上船管道和过滤器架构

基于我们最近在企业应用领域介绍的管道和过滤器概念,我们在多种场景中使用这种架构,以处理需要处理的大量数据(或大文件)触发的多个步骤(或任务)。当我们需要对数据进行大量转换时,这种架构非常有益。

为了理解管道和过滤器的工作原理,我们将回顾一个经典的例子,即处理工资单记录。在这个例子中,一条消息通过一系列过滤器发送,每个过滤器在不同的事务中处理消息。

当我们应用管道和过滤器方法时,我们将整个过程分解为一系列可以重复使用的独立任务。使用这些任务,我们可以改变接收到的消息的格式,然后我们可以将其拆分以执行单独的事务。通过这样做,我们可以提高过程的性能、可伸缩性和可重用性。

这种架构风格使得创建递归过程成为可能。在这种情况下,一个过滤器可以包含在自身内部。在过程内部,我们可以包含另一个管道和过滤器序列,如下图所示:

在这种情况下,每个过滤器通过管道接收输入消息。然后,过滤器处理消息并将结果发布到下一个管道。这个可重复的过程将根据我们的业务需求继续多次。我们可以添加过滤器,接受或省略接收到的输入,并根据我们的业务需求将任务重新排序或重新排列成新的顺序。在下一节中,我们将详细介绍应用管道和过滤器架构风格的最常见用例。

管道和过滤器架构的用例

管道和过滤器架构的最常见用例如下:

  • 将一个大的过程分解为几个小的独立步骤(过滤器)

  • 通过多个过滤器以并行处理来扩展可以独立扩展的进程的系统

  • 转换输入或接收到的消息

  • 将过滤应用于企业服务总线ESB)组件作为集成模式

Spring Batch

Spring Batch 是一个完整的框架,用于创建强大的批处理应用程序(projects.spring.io/spring-batch/)。我们可以创建可重用的函数来处理大量数据或任务,通常称为批量处理。

Spring Batch 提供了许多有用的功能,例如以下内容:

  • 日志记录和跟踪

  • 事务管理

  • 作业统计

  • 管理过程;例如,通过重新启动作业,跳过步骤和资源管理

  • 管理 Web 控制台

该框架旨在通过使用分区功能管理大量数据并实现高性能的批处理过程。我们将从一个简单的项目开始,以解释 Spring Batch 的每个主要组件。

如 Spring Batch 文档中所述(docs.spring.io/spring-batch/trunk/reference/html/spring-batch-intro.html),使用该框架的最常见场景如下:

  • 定期提交批处理

  • 并发批处理用于并行处理作业

  • 分阶段的企业消息驱动处理

  • 大规模并行批处理

  • 故障后手动或定时重新启动

  • 依赖步骤的顺序处理(具有工作流驱动批处理的扩展)

  • 部分处理:跳过记录(例如,在回滚时)

  • 整批事务:适用于批量大小较小或现有存储过程/脚本的情况

在企业应用程序中,需要处理数百万条记录(数据)或从源中读取是非常常见的。该源可能包含具有多个记录的大文件(例如 CSV 或 TXT 文件)或数据库表。在每条记录上,通常会应用一些业务逻辑,执行验证或转换,并完成任务,将结果写入另一种输出格式(例如数据库或文件)。

Spring Batch 提供了一个完整的框架来实现这种需求,最大程度地减少人工干预。

我们将回顾 Spring 批处理的基本概念,如下所示:

  • 作业封装了批处理过程,必须由一个或多个步骤组成。每个步骤可以按顺序运行,并行运行,或进行分区。

  • 步骤是作业的顺序阶段。

  • JobLauncher 负责处理正在运行的作业的 JobExecution。

  • JobRepository 是 JobExecution 的元数据存储库。

让我们创建一个简单的使用 Spring Batch 的作业示例,以了解其工作原理。首先,我们将创建一个简单的 Java 项目并包含spring-batch依赖项。为此,我们将使用其初始化程序创建一个 Spring Boot 应用程序(start.spring.io),如下截图所示:

请注意,我们添加了 Spring Batch 的依赖项。您可以通过在依赖项框中的搜索栏中输入Spring Batch并点击Enter来执行此操作。在所选的依赖项部分将出现一个带有 Batch 字样的绿色框。完成后,我们将点击生成项目按钮。

项目的结构将如下所示:

如果我们查看初始化器添加的依赖项部分,我们将在pom.xml文件中看到spring-batch启动器,如下所示:

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

如果我们不使用 Spring Boot,我们可以显式添加spring-batch-core作为项目依赖项。以下是使用 Maven 的样子:

<dependencies>

  <dependency>

    <groupId>org.springframework.batch</groupId>

    <artifactId>spring-batch-core</artifactId>

    <version>4.0.1.RELEASE</version>

  </dependency>

</dependencies>

或者,我们可以使用 Gradle 来完成这个过程,如下所示:

dependencies

{

  compile 'org.springframework.batch:spring-batch-core:4.0.1.RELEASE'

}

项目将需要一个数据源;如果我们尝试在没有数据源的情况下运行应用程序,我们将在控制台中看到错误消息,如下所示:

为了解决这个问题,我们将在pom.xml文件中添加一个依赖项,以配置嵌入式数据源。为了测试目的,我们将使用 HSQL(hsqldb.org/)如下所示:

<dependency>
   <groupId>org.hsqldb</groupId>
   <artifactId>hsqldb</artifactId>
   <scope>runtime</scope>
</dependency>

现在,我们需要将@EnabledBatchProcessing@Configuration注解添加到应用程序中:


@SpringBootApplication
@EnableBatchProcessing @Configuration
public class SimpleBatchApplication {

接下来,我们将使用JobBuildFactory类设置我们的第一个作业,其中包含一个基于 Spring Batch 的任务流程,使用StepBuilderFactory类:

@Autowired
private JobBuilderFactory jobBuilderFactory;

@Autowired
private StepBuilderFactory stepBuilderFactory;

Job方法将显示它正在启动,如下所示:

@Bean
public Job job(Step ourBatchStep) throws Exception {
   return jobBuilderFactory.get("jobPackPub1")
         .incrementer(new RunIdIncrementer())
         .start(ourBatchStep)
         .build();
}

一旦Job被创建,我们将向Job添加一个新的任务(Step),如下所示:

@Bean
public Step ourBatchStep() {
   return stepBuilderFactory.get("stepPackPub1")
         .tasklet(new Tasklet() {
            public RepeatStatus execute(StepContribution contribution, 
            ChunkContext chunkContext) {
               return null;
            }
         })
         .build();
}

以下代码显示了应用程序类的样子:

@EnableBatchProcessing
@SpringBootApplication
@Configuration
public class SimpleBatchApplication {

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

   @Autowired
   private JobBuilderFactory jobBuilderFactory;

   @Autowired
   private StepBuilderFactory stepBuilderFactory;

   @Bean
   public Step ourBatchStep() {
      return stepBuilderFactory.get("stepPackPub1")
            .tasklet(new Tasklet() {
               public RepeatStatus execute
                (StepContribution contribution, 
                    ChunkContext chunkContext) {
                  return null;
               }
            })
            .build();
   }

   @Bean
   public Job job(Step ourBatchStep) throws Exception {
      return jobBuilderFactory.get("jobPackPub1")
            .incrementer(new RunIdIncrementer())
            .start(ourBatchStep)
            .build();
   }
}

为了检查一切是否正常,我们将运行应用程序。为此,我们将在命令行上执行以下操作:

$ mvn spring-boot:run

或者,我们可以通过运行 maven 来构建应用程序,如下所示:

$ mvn install

接下来,我们将在终端上运行我们最近构建的 jar,如下所示:

$ java -jar target/simple-batch-0.0.1-SNAPSHOT.jar

不要忘记在构建或运行应用程序之前安装 Maven 或 Gradle 和 JDK 8。

最后,我们将在控制台中看到以下输出:

注意控制台输出。为此,我们运行名为jobPackPub1的作业,并执行名为stepPackPub1的 bean。

现在,我们将更详细地查看以下步骤背后的组件:

  • ItemReader 代表了步骤输入的检索

  • ItemProcessor 代表了对项目的业务处理

  • ItemWriter 代表了步骤的输出

以下图表显示了 Spring Batch 主要元素的整体情况:

现在,我们将通过使用 ItemReader、ItemProcessor 和 ItemWriter 来完成我们的示例。通过使用和解释这些组件,我们将向您展示如何使用 Spring Batch 实现管道和过滤器架构。

使用 Spring Batch 实现管道

现在我们已经说明了 Spring Batch 是什么,我们将通过以下步骤实现工资文件处理用例(如前一节中定义的):

  • 编写一个从 CSV 电子表格导入工资数据的流程

  • 使用业务类转换文件元组

  • 将结果存储在数据库中

以下图表说明了我们的实现:

首先,我们将使用 Spring 初始化器(start.spring.io)创建一个新的干净项目,就像我们在上一节中所做的那样:

记得像之前的例子一样,将Batch引用添加到我们的项目中。

不要忘记在pom.xml文件中将数据库驱动程序添加为依赖项。出于测试目的,我们将使用 HSQL(hsqldb.org/)。

<dependency>
   <groupId>org.hsqldb</groupId>
   <artifactId>hsqldb</artifactId>
   <scope>runtime</scope>
</dependency>

如果您想使用其他数据库,可以参考 Spring Boot 文档中提供的详细说明(docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html)。

现在,我们将创建输入数据作为文件,将输出结构作为数据库表,如下图所示:

我们将在资源文件夹(src/main/resources/payroll-data.csv)中添加一个 CSV 文件,内容如下:

0401343844,USD,1582.66,SAVING,3550891500,PAYROLL MARCH 2018,JAIME PRADO
1713430133,USD,941.21,SAVING,2200993002,PAYROLL MARCH 2018,CAROLINA SARANGO
1104447619,USD,725.20,SAVING,2203128508,PAYROLL MARCH 2018,MADALAINE RODRIGUEZ
0805676117,USD,433.79,SAVING,5464013600,PAYROLL MARCH 2018,BELEN CALERO
1717654933,USD,1269.10,SAVING,5497217100,PAYROLL MARCH 2018,MARIA VALVERDE
1102362626,USD,1087.80,SAVING,2200376305,PAYROLL MARCH 2018,VANESSA ARMIJOS
1718735793,USD,906.50,SAVING,6048977500,PAYROLL MARCH 2018,IGNACIO BERRAZUETA
1345644970,USD,494.90,SAVING,6099018000,PAYROLL MARCH 2018,ALBERTO SALAZAR
0604444602,USD,1676.40,SAVING,5524707700,PAYROLL MARCH 2018,XIMENA JARA
1577777593,USD,3229.75,SAVING,3033235300,PAYROLL MARCH 2018,HYUN WOO
1777705472,USD,2061.27,SAVING,3125662300,PAYROLL MARCH 2018,CARLOS QUIROLA
1999353121,USD,906.50,SAVING,2203118265,PAYROLL MARCH 2018,PAUL VARELA
1878363820,USD,1838.30,SAVING,4837838200,PAYROLL MARCH 2018,LEONARDO VASQUEZ

我们项目的结构如下所示:

这个电子表格包含交易的标识、货币、账号、账户类型、交易描述、受益人电话和受益人姓名。这些内容以逗号分隔显示在每一行上。这是一个常见的模式,Spring 可以直接处理。

现在,我们将创建数据库结构,用于存储工资单处理的结果。我们将在资源文件夹(src/main/resources/schema-all.sql)中添加以下内容:

DROP TABLE PAYROLL IF EXISTS;

CREATE TABLE PAYROLL  (
    transaction_id BIGINT IDENTITY NOT NULL PRIMARY KEY,
    person_identification VARCHAR(20),
    currency VARCHAR(20),
    tx_ammount DOUBLE,
    account_type VARCHAR(20),
    account_id VARCHAR(20),
    tx_description VARCHAR(20),
    first_last_name VARCHAR(20)
);

我们将创建的文件将遵循此模式名称:schema-@@platform@@.sql。Spring Boot 将在启动期间运行 SQL 脚本;这是所有平台的默认行为。

到目前为止,我们已经创建了输入数据作为.csv文件,以及输出存储库,用于存储我们完整的工资单流程。因此,我们现在将创建过滤器,并使用 Spring Batch 带来的默认管道。

首先,我们将创建一个代表我们业务数据的类,包括我们将接收的所有字段。我们将命名为PayRollTo.java工资单传输对象):

package com.packpub.payrollprocess;

public class PayrollTo {

    private Integer identification;

    private String currency;

    private Double ammount;

    private String accountType;

    private String accountNumber;

    private String description;

    private String firstLastName;

    public PayrollTo() {
    }

    public PayrollTo(Integer identification, String currency, Double ammount, String accountType, String accountNumber, String description, String firstLastName) {
        this.identification = identification;
        this.currency = currency;
        this.ammount = ammount;
        this.accountType = accountType;
        this.accountNumber = accountNumber;
        this.description = description;
        this.firstLastName = firstLastName;
    }

    // getters and setters

    @Override
    public String toString() {
        return "PayrollTo{" +
                "identification=" + identification +
                ", currency='" + currency + '\'' +
                ", ammount=" + ammount +
                ", accountType='" + accountType + '\'' +
                ", accountNumber='" + accountNumber + '\'' +
                ", description='" + description + '\'' +
                ", firstLastName='" + firstLastName + '\'' +
                '}';
    }
}

现在,我们将创建我们的过滤器,它在 Spring Batch 中表示为处理器。与框架提供的开箱即用行为类似,我们首先将专注于转换输入数据的业务类,如下图所示:

在每一行包括我们的文件表示为PayrollTo类之后,我们需要一个过滤器,将每个数据文件转换为大写。使用 Spring Batch,我们将创建一个处理器,将转换数据文件,然后将数据发送到下一步。因此,让我们创建一个PayRollItemProcessor.java对象,实现org.springframework.batch.item.ItemProcessor<InputObject, OutputObjet>接口,如下所示:

package com.packpub.payrollprocess;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.item.ItemProcessor;

public class PayRollItemProcessor implements 
                    ItemProcessor<PayrollTo, PayrollTo> {

    private static final Logger log = LoggerFactory
                    .getLogger(PayRollItemProcessor.class);

    @Override
    public PayrollTo process(PayrollTo payrollTo) throws Exception {

        final PayrollTo resultTransformation = new PayrollTo();
        resultTransformation.setFirstLastName
            (payrollTo.getFirstLastName().toUpperCase());
        resultTransformation.setDescription
            (payrollTo.getDescription().toUpperCase());
        resultTransformation.setAccountNumber
            (payrollTo.getAccountNumber());
        resultTransformation.setAccountType(payrollTo.getAccountType());
        resultTransformation.setCurrency(payrollTo.getCurrency());
        resultTransformation.setIdentification
            (payrollTo.getIdentification());

        // Data Type Transform
        final double ammountAsNumber = payrollTo.getAmmount()
                                                    .doubleValue();
        resultTransformation.setAmmount(ammountAsNumber);

        log.info
            ("Transforming (" + payrollTo + ") into (" 
                                + resultTransformation + ")");
        return resultTransformation;
    }
}

根据 API 接口,我们将接收一个传入的PayrollTo对象,然后将其转换为大写的PayrollTo,用于firstLastNamedescription属性。

输入对象和输出对象的类型不同并不重要。在许多情况下,一个过滤器将接收一种消息或数据,需要为下一个过滤器提供不同类型的消息或数据。

现在,我们将创建我们的批处理作业,并使用一些 Spring Batch 的开箱即用功能。例如,ItemReader具有一个有用的 API 来处理文件,ItemWriter可用于指定如何存储生成的数据:

最后,我们将使用作业连接所有流数据。

使用 Spring Batch,我们需要专注于我们的业务(就像在PayRollItemProcessor.java类中所做的那样),然后将所有部分连接在一起,如下所示:

@Configuration
@EnableBatchProcessing
public class BatchConfig {

    @Autowired
    public JobBuilderFactory jobBuilderFactory;

    @Autowired
    public StepBuilderFactory stepBuilderFactory;

 // READ THE INPUT DATA
    @Bean
    public FlatFileItemReader<PayrollTo> reader() {
        return new FlatFileItemReaderBuilder<PayrollTo>()
                .name("payrollItemReader")
                .resource(new ClassPathResource("payroll-data.csv"))
                .delimited()
                .names(
                    new String[]{
                        "identification", "currency", "ammount",
                        "accountType", "accountNumber", "description",
                        "firstLastName"})
                .fieldSetMapper(
                    new BeanWrapperFieldSetMapper<PayrollTo>() {{
                    setTargetType(PayrollTo.class);
                }})
                .build();
    }

 // PROCESS THE DATA
    @Bean
    public PayRollItemProcessor processor() {
        return new PayRollItemProcessor();
    }

 // WRITE THE PRODUCED DATA
    @Bean
    public JdbcBatchItemWriter<PayrollTo> writer(DataSource dataSource) {
        return new JdbcBatchItemWriterBuilder<PayrollTo>()
                .itemSqlParameterSourceProvider(
                    new BeanPropertyItemSqlParameterSourceProvider<>())
                .sql(
                    "INSERT INTO PAYROLL (PERSON_IDENTIFICATION,
                        CURRENCY, TX_AMMOUNT, ACCOUNT_TYPE, ACCOUNT_ID, 
                        TX_DESCRIPTION, FIRST_LAST_NAME) VALUES 
                    (:identification,:currenxcy,:ammount,:accountType,
                     :accountNumber, :description, :firstLastName)")
                .dataSource(dataSource)
                .build();
    }

    @Bean
    public Job importPayRollJob(JobCompletionPayRollListener listener, Step step1) {
        return jobBuilderFactory.get("importPayRollJob")
                .incrementer(new RunIdIncrementer())
                .listener(listener)
                .flow(step1)
                .end()
                .build();
    }

    @Bean
    public Step step1(JdbcBatchItemWriter<PayrollTo> writer) {
        return stepBuilderFactory.get("step1")
                .<PayrollTo, PayrollTo> chunk(10)
                .reader(reader())
                .processor(processor())
                .writer(writer)
                .build();
    }
}

有关 Spring Batch ItemReaders 和 ItemWriters 的详细说明,请访问docs.spring.io/spring-batch/trunk/reference/html/readersAndWriters.html

让我们来看一下Step bean 的工作原理:

@Bean
    public Step step1(JdbcBatchItemWriter<PayrollTo> writer)
 {
        return stepBuilderFactory.get("step1")
                .<PayrollTo, PayrollTo> chunk(10)
                .reader(reader())
 .processor(processor())
 .writer(writer)
                .build();
 }

首先,它配置步骤以每次读取10 条记录的数据块,然后配置步骤与相应的readerprocessorwriter对象。

我们现在已经实现了我们计划的所有管道和过滤器,如下图所示:

最后,我们将添加一个监听器,以检查我们处理的工资单数据。为此,我们将创建一个JobCompletionPayRollListener.java类,该类扩展了JobExecutionListenerSupport类,并实现了afterJob(JobExecution jobExecution)方法。

现在,我们将回顾我们从处理的数据中处理了多少insert操作:


@Component
public class JobCompletionPayRollListener 
            extends JobExecutionListenerSupport {

    private static final Logger log = 
        LoggerFactory.getLogger(JobCompletionPayRollListener.class);

    private final JdbcTemplate jdbcTemplate;

    @Autowired
    public JobCompletionPayRollListener(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
 log.info(">>>>> PAY ROLL JOB FINISHED! ");

            jdbcTemplate
            .query(
                "SELECT PERSON_IDENTIFICATION, CURRENCY, TX_AMMOUNT,                          ACCOUNT_TYPE, ACCOUNT_ID, TX_DESCRIPTION, 
                        FIRST_LAST_NAME FROM PAYROLL",
                    (rs, row) -> new PayrollTo(
                            rs.getInt(1),
                            rs.getString(2),
                            rs.getDouble(3),
                            rs.getString(4),
                            rs.getString(5),
                            rs.getString(6),
                            rs.getString(7))
            ).forEach(payroll -> 
                log.info("Found <" + payroll + "> in the database.")
                );
        }
    }
}

为了检查一切是否正常,我们将执行应用程序,使用以下命令:

$ mvn spring-boot:run

或者,我们可以使用 maven 构建应用程序,如下所示:

$ mvn install

接下来,我们将在终端上运行最近构建的jar

$ java -jar target/payroll-process-0.0.1-SNAPSHOT.jar

最后,我们将在控制台上看到以下输出。该输出代表已实现为 ItemProcessor 的过滤器,用于转换数据:

我们还可以通过监听器来验证我们的流程,该监听器实现为JobExecutionListenerSupport,打印存储在数据库中的结果:

我们可以将 Spring Batch 应用程序打包成 WAR 文件,然后运行一个 servlet 容器(如 Tomcat)或任何 JEE 应用程序服务器(如 Glassfish 或 JBoss)。要将.jar文件打包成 WAR 文件,请使用spring-boot-gradle-pluginspring-boot-maven-plugin。对于 Maven,您可以参考 Spring Boot 文档(docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#build-tool-plugins-maven-packaging)。对于 Gradle,您可以参考docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/html/#packaging-executable-wars

摘要

在本章中,我们讨论了管道和过滤器架构的概念,其实施的主要用例,以及如何在企业应用程序中使用它。此外,您还学会了如何使用 Spring Batch 实现架构,以及如何管理不同数量的数据并将流程拆分为较小的任务。

在下一章中,我们将回顾容器化应用程序的重要性。

第八章:微服务

我们不断寻找新的方法来创建软件系统,以满足既支持他们业务需求的应用程序的满意客户,又受到尖端技术挑战的开发人员。满足这两种目标用户的平衡很重要;这使我们能够实现业务目标,避免失去技术娴熟的开发人员。

另一方面,作为开发人员,我们也在努力创建模块和专门的库,以满足特定的技术或业务需求。稍后,我们将在不同的项目中重用这些模块和库,以符合“不要重复自己”(DRY)原则。

以这个介绍作为出发点,我们将回顾微服务架构如何解决这些问题以及更多内容。在本章中,我们将讨论以下主题:

  • 微服务原则

  • 建模微服务

    • 如何使用 Spring Cloud 实现微服务:
  • 支持动态配置

  • 启用服务发现和注册

  • 边缘服务

  • 断路器模式和 Hystrix

微服务原则

在网上有很多微服务的定义。经常出现的一个是以下内容:

“微服务是小型且自主的服务,能够良好地协同工作。”

让我们从这个定义开始,更详细地了解它的含义。

大小

微服务一词中包含“微”这个词,让我们认为服务的大小必须非常小。然而,几乎不可能使用诸如代码行数、文件数量或特定可部署工件的大小等指标来定义服务的正确大小。相反,使用以下想法要简单得多:

“一个服务应专注于做好一件事。”

  • Sam Newman

那“一件事”可以被视为一个业务领域。例如,如果您正在为在线商店构建系统,它们可能涵盖以下业务领域:

  • 客户管理

  • 产品目录

  • 购物车

  • 订单

这个想法是构建一个能够满足特定业务领域所有需求的服务。最终,当业务领域变得太大,无法仅作为一个微服务处理时,您可能还会将一个服务拆分为其他微服务。

自主的

自主性在谈论微服务时非常重要。微服务应该有能力独立于其周围的其他服务进行更改和演变。

验证微服务是否足够自主的最佳方法是对其进行更改并部署服务的新版本。部署过程不应要求您修改除服务本身之外的任何内容。如果在部署过程中需要重新启动其他服务或其他任何内容,您应考虑消除这些额外步骤的方法。另一方面,服务的自主性也与构建它的团队的组织有关。我们将在本章后面详细讨论这一点。

良好协同工作

在孤立地构建不相互交互的系统是不可能的。即使我们正在构建不同业务领域需求的独立服务,最终我们也需要使它们作为一个整体进行交互,以满足业务需求。这种交互是通过使用应用程序编程接口(API)来实现的。

“API 是程序员可以用来创建软件或与外部系统交互的一组命令、函数、协议和对象。它为开发人员提供了执行常见操作的标准命令,因此他们不必从头编写代码。”

单片应用程序往往进行数据库集成。这是应该尽量避免的事情;任何所需的服务之间的交互应该只使用提供的服务 API 来完成。

优势

微服务提供了许多值得了解的优势,以了解公司可能如何受益。最常见的优势如下:

  • 符合单一责任原则

  • 持续发布

  • 独立可伸缩性

  • 增加对新技术的采用

符合单一责任原则

使用微服务涉及创建单独的组件。每个组件都设计为解决特定的业务领域模型。因此,该领域模型定义了服务的单一责任。服务不应违反其限制,并且应该使用其他微服务提供的 API 请求任何超出其范围的信息。每个微服务应该暴露一个 API,其中包含所有必需的功能,以允许其他微服务从中获取信息。

持续发布

由于大型的单片应用程序处理许多业务领域模型,它们由大量的源代码和配置文件组成。这会产生需要大量时间才能部署的大型构件。此外,大型单片应用程序通常涉及分布在世界各地的大型团队,这使得沟通困难。在开发新功能或修复应用程序中的错误时,这会成为一个问题。微服务能够轻松解决这个问题,因为一个团队将负责一个或多个服务,并且一个服务很少由多个团队编写。这意味着新版本可以在团队内计划,这使得他们能够更快更频繁地推出新版本。

此外,即使代码中的最小更改也需要部署大型构件,这使得整个应用程序在部署过程中不可用。然而,对于微服务,只需部署具有漏洞修补程序或新功能的服务。部署速度快,不会影响其他服务。

独立可伸缩性

如果我们需要扩展一个单片应用程序,整个系统应该部署在不同的服务器上。服务器应该非常强大,以使应用程序能够良好运行。并非所有功能都具有相同的流量,但由于所有代码都打包为单个构件,因此无法仅扩展所需的功能。使用微服务,我们有自由只扩展我们需要的部分。通常可以找到云提供商提供通过按需提供更多服务器或在需要时自动添加更多资源来扩展应用程序的机会。

新技术的增加采用

并非所有业务领域模型都是相等的,这就是为什么需要不同的技术集。由于一个微服务只应处理一个领域模型的需求,因此不同的服务可以轻松采用不同的技术。通常可以找到公司使用不同的编程语言、框架、云提供商和数据库来编写他们的微服务。此外,我们有能力为小型应用程序尝试新技术,然后可以在其他地方使用。由于采用新技术,公司最终会拥有异构应用程序,如下图所示:

异构应用程序使我们能够使用正确的技术集创建专门的系统来解决特定的业务需求。因此,我们最终会拥有易于部署和独立扩展的小构件。

缺点

尽管微服务具有我们之前列出的所有优点,但重要的是要理解它们也有一些缺点。让我们回顾一下这些,并考虑如何处理它们:

  • 选择太多

  • 一开始慢

  • 监控

  • 事务和最终一致性

选择太多

由于您有机会选择要使用哪种技术构建微服务,您可能会因为可用选项的广泛多样而感到不知所措。这可以通过仅使用少量新技术而不是一次性尝试将它们全部整合来解决。

一开始慢

在采用微服务的过程中,您必须构建整个生态系统以使它们运行。您需要寻找连接分布式系统、保护它们并使它们作为一个整体运行的新方法。编写一个应用程序来完成所有这些工作更容易。然而,几个月后,其他微服务将重复使用您一开始投入的所有工作,这意味着流程速度显著加快。要充分利用这种创建系统的方式,重要的是尝试新的部署应用程序的方式,使其按需扩展,监控和记录它们。还重要的是审查处理业务核心的微服务的功能。这些系统有时最终成为半单体应用,应该拆分以便更容易管理。

监控

监控单个应用比监控许多不同服务的实例更容易。重要的是创建仪表板和自动化工具,提供指标以使这项任务更容易完成。当出现新错误时,很难弄清楚问题出在哪里。应该使用良好的日志跟踪机制来确定应用的哪个服务未按预期工作。这意味着您不必分析所有服务。

事务和最终一致性

尽管大型单体应用有着明确定义的事务边界,而且我们在编写微服务时经常使用两阶段提交等技术,但我们必须以另一种方式来满足这些要求。

我们应该记住,每个微服务都拥有自己的数据存储,并且我们应该仅使用它们的 API 来访问它们的数据。保持数据最新并在操作不符合预期时使用补偿事务是很重要的。当我们编写单体应用时,许多操作作为单个事务执行。对于微服务,我们需要重新思考操作和事务,使它们适应每个微服务的边界。

建模微服务

作为开发人员,我们总是试图创建可重用的组件来与系统或服务交互,以避免重复编写代码。到目前为止,我们构建的大多数单体应用都遵循了三层架构模式,如下图所示:

三层架构

当需要对使用此模型构建的应用进行更改时,通常需要修改所有三层。根据应用程序的创建方式,可能需要进行多次部署。此外,由于大型单体应用共享许多功能,通常会发现有多个团队在其上工作,这使得它们更难快速发展。有时,专门的团队会在特定层上工作,因为这些层由许多组件组成。通过这种方式,可以水平应用更改以使应用程序增长和发展。

使用微服务,应用程序在特定业务领域周围建模,因此应用程序在垂直方向上发展。以下图表显示了在线商店应用程序的一些微服务:

微服务图表

这些名称本身就解释了微服务的意图和相关功能集合。仅通过阅读名称,任何人都可以理解它们的功能;如何执行任务以及它们如何实现在这一点上是无关紧要的。由于这些服务围绕着一个明确定义的业务领域构建,当需要进行新更改时,只有一个服务应该被修改。由于不止一个团队应该在一个微服务上工作,与大型单体相比,使它们发展变得更容易。负责服务的团队深刻了解特定服务的工作方式以及如何使其发展。

负责微服务的团队由该服务业务领域的专家组成,但不擅长其周围其他服务的技术。毕竟,技术选择包括细节;服务的主要动机是业务领域。

加速

我们在本章前面提到,基于微服务开发应用程序在开始阶段是一个耗时的过程,因为您从头开始。无论您是开始一个新项目还是将现有的遗留应用程序拆分为单独的微服务,您都必须完成将应用程序从开发到生产的所有必要步骤。

加速开发过程

让我们从开发阶段开始。当您在旧应用程序上工作时,通常在编写第一行代码之前,您必须经历以下步骤:

  1. 在本地机器上安装所需的工具。

  2. 设置所有必需的依赖项。

  3. 创建一个或多个配置文件。

  4. 发现所有未列入文档的缺失部分。

  5. 加载测试数据。

  6. 运行应用程序。

现在,假设您是作为一个团队的一部分,拥有用不同编程语言编写并使用不同数据库技术的许多微服务。您能想象在编写第一行代码之前需要多少努力吗?

使用微服务应该能够为您提供更快的解决方案,但所需的所有设置使其在最初变得更慢。对于大型单片应用程序,您只需要设置一个环境,但对于异构应用程序,您将需要设置许多不同的环境。为了有效地解决这个问题,您需要拥抱自动化文化。您可以运行脚本来代替手动执行所有上述步骤。这样,每当您想要在不同的项目上工作时,您只需要执行脚本,而不是重复列出的所有步骤。

市场上有一些非常酷的工具,比如 Nanobox(https://nanobox.io)、Docker Compose(https://docs.docker.com/compose/)和 Vagrant(https://www.vagrantup.com)。这些工具可以通过运行单个命令提供类似于生产环境的环境,从而帮助您。

采用前面提到的工具将对开发团队的生产力产生巨大影响。您不希望开发人员浪费时间提供自己的环境;相反,您希望他们编写代码为产品添加新功能。

拥抱测试

让我们谈谈编写代码的过程。当我们在大型单体上工作时,每次发布新功能或错误修复时都需要通知许多人。在极端情况下,QA 团队需要自行检查整个环境,以确保新更改不会影响应用程序的现有功能。想象一下为多个微服务的每次发布重复执行此任务会耗费多少时间。因此,您需要将测试作为开发过程的必要部分。

有许多不同级别的测试。让我们来看一下 Jason Huggins 在 2005 年引入的金字塔测试,如下图所示:

金字塔测试

金字塔底部的测试很容易且快速编写和执行。运行单元测试只需要几分钟,对验证隔离的代码片段是否按预期工作很有用。另一方面,集成测试对验证代码在与数据库、第三方应用程序或其他微服务交互时是否正常工作很有用。这些测试需要几十分钟才能运行。最后,端到端(e2e)测试帮助您验证代码是否符合最终用户的预期。如果你正在编写一个 REST API,e2e 测试将使用不同的数据验证 API 的 HTTP 响应代码。这些测试通常很慢,而且它们一直在变化。

理想情况下,所有新功能都应该经过所有这些测试,以验证您的代码在进入生产之前是否按预期工作。你写的测试越多,你就会获得越多的信心。毕竟,如果你覆盖了所有可能的情况,还会出什么问题呢?此外,Michael Bryzek 提出了在生产中进行测试的想法(有关更多信息,请参见www.infoq.com/podcasts/Michael-Bryzek-testing-in-production)。这有助于您通过定期执行自动化任务或机器人来评估您的服务是否正常运行,以在生产中运行系统的关键部分。

投入生产

你必须以与自动化开发环境相同的方式自动化生产环境。如今,公司普遍使用云提供商部署其系统,并使用 API 驱动工具提供服务器。

安装操作系统并添加所需的依赖项以使应用程序工作必须自动化。如果要提供多台服务器,只需多次执行相同的脚本。Docker、Puppet 和 Chef 等技术可以帮助你做到这一点。使用代码提供环境的间接好处是,你将拥有使应用程序工作所需的所有依赖项的完美文档。随着时间的推移,这些脚本可以得到改进。它们存储在版本控制系统中,这样就很容易跟踪对它们所做的每一次更改。我们将在第十一章 DevOps 和发布管理中进一步讨论这一点。

实施微服务

现在我们对微服务的定义和用途有了很好的理解,我们将开始学习如何使用 Spring Framework 实施微服务架构。在接下来的几节中,我们将看一些到目前为止还没有涉及的重要概念。最好从实际角度来接触这些概念,以便更容易理解。

动态配置

我们都曾经在使用不同配置文件或相关元数据的应用程序上工作,以允许你指定使应用程序工作的配置参数。当我们谈论微服务时,我们需要以不同的方式来处理这个配置过程。我们应该避免配置文件,而是采用由 Heroku 提出的十二要素应用程序配置风格(在12factor.net中概述)。当我们使用这种配置风格时,我们希望将每个环境中不同的属性外部化,并使其易于创建和更改。

默认情况下,Spring Boot 应用程序可以使用命令行参数、JNDI 名称或环境变量工作。Spring Boot 还提供了使用.properties.yaml配置文件的能力。为了以安全的方式处理配置变量,Spring Boot 引入了@ConfigurationProperties注释,它允许您将属性映射到普通的 Java 对象POJOs)。应用程序启动时,它会检查所有配置是否已提供、格式是否正确,并符合@Valid注释要求的需求。让我们看看这个映射是如何工作的。

假设您的应用程序中有以下application.yaml文件:

middleware:
  apiKey: ABCD-1234
  port: 8081

event-bus:
  domain: event-bus.api.com
  protocol: http

现在,让我们使用@ConfigurationProperties注释将这些变量映射到两个不同的 POJO 中。让我们从给定的中间件配置开始:

@Data
@Component
@ConfigurationProperties("middleware")
public class Middleware 
{
  private String apiKey;
  private int port;
}

以下代码片段代表了eventBus配置部分所需的类:

@Data
@Component
@ConfigurationProperties("event-bus")
public class EventBus 
{
  private String domain;
  private String protocol;
}

使用 lombok 的@Data注释来避免编写标准访问器方法。现在,您可以打印这些类的.toString()结果,并且您将在控制台中看到以下输出:

EventBus(domain=event-bus.api.com, protocol=http)
Middleware(apiKey=ABCD-1234, port=8081)

将所有这些配置变量硬编码可能很有用。这意味着当您想要在另一个环境中部署应用程序时,您可以通过提供额外的参数来简单地覆盖它们,如下所示:

$ java -Dmiddleware.port=9091 -jar target/configuration-demo-0.0.1-SNAPSHOT.jar

在运行.jar文件之前,我们在文件中覆盖了一个配置变量,因此您将得到如下所示的输出:

EventBus(domain=event-bus.api.com, protocol=http)
Middleware(apiKey=ABCD-1234, port=9091)

尽管这种配置很容易实现,但对于微服务或一般的现代应用程序来说还不够好。首先,在应用任何更改后,您需要重新启动应用程序,这是不可取的。最糟糕的是,您无法跟踪您所应用的更改。这意味着如果提供了环境变量,就无法知道是谁提供的。为了解决这个问题,Spring 提供了一种集中所有配置的方法,使用 Spring Cloud 配置服务器。

该服务器提供了一种集中、记录和安全的方式来存储配置值。由于它将所有配置值存储在可以是本地或远程的 Git 存储库中,因此您将免费获得与版本控制系统相关的所有好处。

实施配置服务器

Spring Cloud 配置服务器是建立在常规 Spring Boot 应用程序之上的。您只需要添加以下附加依赖项:

compile('org.springframework.cloud:spring-cloud-config-server')

添加依赖项后,您需要使用应用程序中的附加注释来激活配置服务器,如下面的代码所示:

@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication 
{
  public static void main(String[] args) 
  {
    SpringApplication.run(ConfigServerApplication.class, args);
  }
}

最后,您需要提供存储微服务配置的 Git 存储库 URL,存储在application.yaml文件中,如下所示:

spring:
  cloud:
    config:
      server:
        git:
          uri: https://github.com/enriquezrene/spring-architectures-config-server.git

前面的 Git 存储库有单独的配置文件来管理每个微服务的配置。例如,configuration-demo.properties文件用于管理配置演示微服务的配置。

实施配置客户端

配置客户端是常规的 Spring Boot 应用程序。您只需要提供服务器配置 URI 以读取集中配置,如下所示:

spring:
  application:
 name: configuration-demo
  cloud:
    config:
 uri: http://localhost:9000

以下代码片段显示了一个 REST 端点,读取集中配置并将读取的值作为自己的响应提供:

@RestController
@RefreshScope
public class ConfigurationDemoController {

 @Value("${configuration.dynamicValue}")
    private String dynamicValue;

    @GetMapping(path = "/dynamic-value")
    public ResponseEntity<String> readDynamicValue() {
        return new ResponseEntity<>(this.dynamicValue, HttpStatus.OK);
    }
}

以下屏幕截图显示了存储在 Git 存储库中的配置文件:

存储在 Git 存储库中的配置文件

一旦您对前面的端点执行请求,它将产生以下输出:

$ curl http://localhost:8080/dynamic-value 
Old Dynamic Value

更改存储在 Git 中的文件中的配置变量的值,如下面的屏幕截图所示:

应用更改后的配置文件

如果您访问端点,将检索到与之前相同的输出。为了重新加载配置,您需要通过使用POST请求命中/refresh端点来重新加载配置变量,如下代码所示:

$ curl -X POST http://localhost:8080/actuator/refresh
["config.client.version","configuration.dynamicValue"]

重新加载配置后,端点将使用新提供的值提供响应,如下输出所示:

$ curl http://localhost:8080/dynamic-value
New Dynamic Value

服务发现和注册

过去,我们的应用程序存在于单个物理服务器上,应用程序与实施它的后端之间存在 1:1 的关系。在这种情况下,查找服务非常简单:您只需要知道服务器的 IP 地址或相关的 DNS 名称。

后来,应用程序被分布,这意味着它们存在于许多物理服务器上以提供高可用性。在这种情况下,服务与后端服务器之间存在 1:N的关系,其中N可以表示多个。传入请求使用负载均衡器进行管理,以在可用服务器之间路由请求。

当物理服务器被虚拟机替换时,使用相同的方法。负载均衡器需要一些配置来注册新的可用服务器并正确路由请求。这项任务过去由运维团队执行。

今天,常见的是在容器中部署应用程序,我们将在第十章中进一步讨论,容器化您的应用程序。容器每毫秒都在不断提供和销毁,因此手动注册新服务器是不可能的任务,必须自动化。为此,Netflix 创建了一个名为 Eureka 的项目。

介绍 Eureka

Eureka 是一个允许您自动发现和注册服务器的工具。您可以将其视为一个电话目录,其中所有服务都注册了。它有助于避免在服务器之间建立直接通信。例如,假设您有三个服务,它们都相互交互。使它们作为一个整体工作的唯一方法是指定服务器或其负载均衡器的 IP 地址和端口,如下图所示:

服务相互交互

如前图所示,交互直接发生在服务器或它们的负载均衡器之间。当添加新服务器时,应手动或使用现有的自动化机制在负载均衡器中注册它。此外,使用 Eureka,您可以使用在其上注册的服务名称建立通信。以下图表显示了相同的交互如何与 Eureka 一起工作:

使用 Eureka 注册的服务

这意味着当您需要在服务之间建立通信时,您只需要提供名称而不是 IP 地址和端口。当一个服务有多个实例可用时,Eureka 也将作为负载均衡器工作。

实现 Netflix Eureka 服务注册表

由于 Eureka 是为了允许与 Spring Boot 平稳集成而创建的,因此可以通过添加以下依赖项来简单实现服务注册表:

compile
 ('org.springframework.cloud:spring-cloud-starter-netflix-eureka-server')

application类也应该被修改,以指示应用程序将作为 Eureka 服务器工作,如下所示:

@EnableEurekaServer
@SpringBootApplication
public class ServiceRegistryApplication 
{
  public static void main(String[] args) 
  {
    SpringApplication.run(ServiceRegistryApplication.class, args);
  }
}

运行应用程序后,您可以在http://localhost:8901/看到 Web 控制台,如下截图所示:

Eureka Web 控制台

实现服务注册表客户端

之前,我们提到过负载均衡器曾经用于通过使用多个服务器作为后端来提供高可伸缩性。Eureka 以相同的方式工作,但主要好处是当服务器的更多实例被提供时,您不需要在服务注册表中添加任何配置。相反,每个实例都应让 Eureka 知道它想要注册。

注册新服务非常简单。您只需要包含以下依赖项:

compile
 ('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')

服务应用程序类应包括一个附加的注解,如下所示:

@EnableDiscoveryClient
@SpringBootApplication
public class MoviesServiceApplication 
{
  public static void main(String[] args) 
  {
    SpringApplication.run(MoviesServiceApplication.class, args);
  }
}

最后,您需要在application.properties文件中指定 Eureka 服务器 URI,如下所示:

# This name will appear in Eureka
spring.application.name=movies-service
eureka.client.serviceUrl.defaultZone=http://localhost:8901/eureka

运行此 Spring Boot 应用程序后,它将自动在 Eureka 中注册。您可以通过刷新 Eureka Web 控制台来验证这一点。您将看到服务已注册,如下截图所示:

Eureka 中注册的实例

一旦服务注册,您将希望消费它们。使用 Netflix Ribbon 是消费服务的最简单方式之一。

Netflix Ribbon

Ribbon 是一个客户端负载均衡解决方案,与 Spring Cloud 生态系统无缝集成。它可以通过指定服务名称来消费使用 Eureka 暴露的服务。由于所有服务器实例都在 Eureka 中注册,它将选择其中一个来执行请求。

假设我们有另一个名为cinema-service的服务。假设该服务有一个端点,可以用来按 ID 查询电影院。作为电影院负载的一部分,我们希望包括movies-service中所有可用的电影。

首先,我们需要添加以下依赖项:

compile('org.springframework.cloud:spring-cloud-starter-netflix-ribbon')

然后,作为application类的一部分,我们需要创建一个新的RestTemplate bean,以便注入以消费 Eureka 中可用的服务:

@EnableDiscoveryClient
@SpringBootApplication
public class CinemaServiceApplication 
{
  public static void main(String[] args) 
  {
    SpringApplication.run(CinemaServiceApplication.class, args);
  }
 @LoadBalanced
  @Bean
  RestTemplate restTemplate() 
  {
 return new RestTemplate();
  }
}

RestTemplate短语是用于消费 RESTful web 服务的客户端。它可以执行对movies-service的请求如下:

@RestController
public class CinemasController 
{
  private final CinemaRepository cinemaRepository;
 private final RestTemplate restTemplate;
  public CinemasController(CinemaRepository cinemaRepository,
  RestTemplate restTemplate) 
  {
    this.cinemaRepository = cinemaRepository;
 this.restTemplate = restTemplate;
  }
  @GetMapping("/cinemas/{cinemaId}/movies")
  public ResponseEntity<Cinema> queryCinemaMovies   
  (@PathVariable("cinemaId") Integer cinemaId) 
  {
    Cinema cinema = cinemaRepository.findById(cinemaId).get();
    Movie[] movies = restTemplate
    .getForObject(
 "http://movies-service/movies", Movie[].class);
    cinema.setAvailableMovies(movies);
    return new ResponseEntity<>(cinema, HttpStatus.OK);
  }
}

请注意服务名称的指定方式,我们不必提供任何其他信息,如 IP 地址或端口。这很好,因为在新服务器按需创建和销毁时,确定这些信息将是不可能的。

边缘服务

边缘服务是一个中间组件,对外部世界和下游服务都是可见的。它作为一个网关,允许周围所有服务之间的交互。以下图表显示了边缘服务的使用方式:

边缘服务

请注意,所有传入请求都直接指向边缘服务,后者将稍后查找正确的服务以正确重定向请求。

边缘服务以不同的方式使用,根据周围的服务添加额外的行为或功能。最常见的例子是跨域资源共享(CORS)(developer.mozilla.org/en-US/docs/Web/HTTP/CORS)过滤器。您可以向边缘服务添加 CORS 过滤器,这意味着下游服务不需要实现任何内容。假设我们想允许来自域abc.com的传入请求。我们可以将此逻辑作为边缘服务的一部分实现,如下图所示:

使用边缘服务的 CORS 过滤器

在这里,我们可以看到所有逻辑只添加在一个地方,下游服务不必实现任何内容来管理所需的行为。

边缘服务还用于许多其他需求,我们将在下一节讨论。市场上有许多不同的边缘服务实现。在下一节中,我们将讨论 Netflix 的 Zuul,因为它与 Spring Cloud 集成得很顺畅。

介绍 Zuul

Zuul 是 Netflix 创建的边缘服务,其功能基于过滤器。Zuul 过滤器遵循拦截器过滤器模式(如www.oracle.com/technetwork/java/interceptingfilter-142169.html中所述)。使用过滤器,您可以在路由过程中对 HTTP 请求和响应执行一系列操作。

Zuul 是一个来自电影的门卫的名字(请参阅ghostbusters.wikia.com/wiki/Zuul了解更多详情),它确切地代表了这个项目的功能,即门卫的功能。

您可以在四个阶段应用过滤器,如下图所示:

Zuul 过滤器

让我们回顾一下这些阶段:

  • pre:在请求被处理之前

  • route:在将请求路由到服务时

  • post:在请求被处理后

  • error:当请求发生错误时

使用这些阶段,您可以编写自己的过滤器来处理不同的需求。pre阶段的一些常见用途如下:

  • 认证

  • 授权

  • 速率限制

  • 请求正文中的翻译和转换操作

  • 自定义标头注入

  • 适配器

route阶段的一些常见过滤器用途如下:

  • 金丝雀发布

  • 代理

一旦一个请求被微服务处理,就会有两种情况:

  • 处理成功

  • 请求处理过程中发生错误

如果请求成功,将执行与post阶段相关的所有过滤器。在此阶段执行的一些常见过滤器用途如下:

  • 响应有效负载中的翻译和转换操作

  • 存储与业务本身相关的度量标准

另一方面,当请求处理过程中发生错误时,所有error过滤器都将被执行。此阶段过滤器的一些常见用途如下:

  • 保存请求的相关元数据

  • 出于安全原因,从响应中删除技术细节

上述观点只是每个阶段过滤器的一些常见用途。在编写针对您需求的过滤器时,请考虑您自己的业务。

为了编写一个 Zuul 过滤器,应该扩展ZuulFilter类。这个类有以下四个需要实现的抽象方法:

public abstract class ZuulFilter 
implements IZuulFilter, Comparable<ZuulFilter> 
{
  public abstract String filterType();
  public abstract int filterOrder();
 public abstract boolean shouldFilter();
  public abstract Object run() throws ZuulException;
  ...
}

粗体显示的两个方法并不是直接在ZuulFilter类中声明的,而是从IZuulFilter接口继承而来,这个接口是由这个类实现的。

让我们回顾一下这些方法,以了解 Zuul 过滤器的工作原理。

首先,您有filterType方法,需要在其中指定要执行当前过滤器的阶段。该方法的有效值如下:

  • pre

  • post

  • route

  • error

您可以自己编写上述值,但最好使用FilterConstant类,如下所示:

@Override
public String filterType() 
{
  return FilterConstants.PRE_TYPE;
}

所有阶段都列在我们之前提到的类中:

public class FilterConstants 
{ 
  ...
  public static final String ERROR_TYPE = "error";
  public static final String POST_TYPE = "post";
  public static final String PRE_TYPE = "pre";
  public static final String ROUTE_TYPE = "route";
}

filterOrder方法用于定义将执行过滤器的顺序。每个阶段通常有多个过滤器,因此通过使用该方法,可以为每个过滤器配置所需的顺序。最高值表示执行顺序较低。

通过使用org.springframework.core.Ordered接口,可以轻松配置执行顺序,该接口有两个值可用作参考:

package org.springframework.core;
public interface Ordered 
{
  int HIGHEST_PRECEDENCE = -2147483648;
  int LOWEST_PRECEDENCE = 2147483647;
  ...
}

shouldFilter方法用于确定是否应执行过滤逻辑。在这个方法中,你可以使用RequestContext类来访问请求信息,如下所示:

RequestContext ctx = RequestContext.getCurrentContext();
// do something with ctx 

这个方法应该返回一个布尔值,指示是否应执行run方法。

最后,run方法包含在过滤器中应用的逻辑。在这个方法中,你也可以使用RequestContext类来执行所需的逻辑。

例如,让我们使用之前实现的端点来查询电影院放映的电影:

curl http://localhost:8701/cinemas-service/cinemas/1/movies

以下是一个简单的实现,用于打印请求的方法和 URL:

@Override
public Object run() throws ZuulException {
    RequestContext ctx = RequestContext.getCurrentContext();
    HttpServletRequest request = ctx.getRequest();
    log.info("Requested Method: {}", request.getMethod());
    log.info("Requested URL: {}", request.getRequestURL());
    return null;
}

一旦请求被处理,你将得到以下输出:

PRE FILTER
Requested Method: GET
Requested URL: http://localhost:8701/cinemas-service/cinemas/1/movies

CAP 定理

在 2000 年的分布式计算原理研讨会SPDC)上,Eric Brewer 提出了以下理论:

“一个共享数据系统不可能同时提供这三个属性中的两个以上(一致性、高可用性和分区容错)。”

  • Eric Brewer

让我们来回顾一下这三个属性。

一致性

一个一致的系统能够在每次后续操作中报告其当前状态,直到状态被外部代理显式更改。换句话说,每个read操作应该检索到上次写入的数据。

高可用性

高可用性指的是系统在从外部代理检索任何请求时始终能够提供有效的响应能力。在理想的情况下,系统应该始终能够处理传入的请求,从不产生错误。至少应该以对用户不可感知的方式处理它们。

分区容错

一个分区容错的分布式系统应该始终保持运行,即使与其节点之一的通信无法建立。

Brewer 的理论可以应用于任何分布式系统。由于微服务架构是基于分布式计算概念的,这意味着这个理论也适用于它们。

尽管理论表明系统无法同时实现所有三个属性,我们应该构建能够优雅处理故障的系统。这就是断路器模式可以应用的地方。

断路器

断路器模式旨在处理系统与其他运行在不同进程中的系统进行远程调用时产生的故障。该模式的主要思想是用一个能够监视故障并产生成功响应的对象来包装调用,如下图所示:

断路器模式

请注意,断路器模式在无法与目标服务建立连接时提供替代响应。让我们看看如何使用 Hystrix 来实现这种模式并将其纳入我们的应用程序。

Hystrix

Hystrix 是 Netflix 于 2011 年创建的一个库。它是为了处理与外部服务交互时的延迟和连接问题而创建的。Hystrix 的主要目的是在通信问题发生时提供一种替代方法来执行。它可以这样实现:

@Service
public class MoviesService {

    private final RestTemplate restTemplate;

    public MoviesService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @HystrixCommand(fallbackMethod = "emptyMoviesArray")
    public Movie[] getMovies(){
        return restTemplate.getForObject
            ("http://movies-service/movies", Movie[].class);
    }

    public Movie[] emptyMoviesArray(){
        Movie movie = new Movie();
        movie.setId(-1);
        movie.setName("Coming soon");
        return new Movie[]{movie};
    }
}

注意getMovies方法如何尝试与另一个服务交互以获取电影列表。该方法用@HystrixCommand(fallbackMethod = "emptyMoviesArray")进行了注释。fallbackMethod值指示在与其他服务通信期间发生错误时要使用的替代方法。在这种情况下,替代方法提供了一个硬编码的电影数组。这样,你可以在需要与外部服务交互时避免级联故障。通过优雅地处理故障,这为最终用户提供了更好的体验。

摘要

在本章中,我们讨论了微服务的原则、优势和缺点。之后,我们学习了如何对微服务进行建模,并讨论了一些与分布式计算相关的重要概念,这些概念是这种架构风格固有的。最后,我们回顾了 CAP 定理以及如何在与其他服务交互时优雅地处理故障。在下一章中,我们将探讨无服务器架构风格,这也可以作为您的微服务环境的一部分进行集成。

第九章:无服务器架构

无服务器架构正在成为 IT 系统构建中的一种流行趋势。因此,人们经常讨论亚马逊网络服务(AWS)、谷歌云和微软 Azure 等云提供商。

在本章中,我们将探讨无服务器架构的含义,以及这种新的构建系统的方式如何帮助我们在更短的时间内满足业务需求,从而减少构建业务解决方案所需的工作量。我们还将看看如何利用现成的第三方服务和实现自定义功能,从而创建可以部署在云上的无状态函数,从而大大减少到达生产所需的时间。

在本章中,我们将涵盖以下主题:

  • 无服务器架构简介

  • 基础设施和文件存储

  • 好处和陷阱

  • 后端即服务

  • 函数即服务

  • 对无服务器架构的担忧:

  • 供应商锁定问题

  • 安全问题

  • 框架支持

  • 故障排除

  • 无服务器架构的示例和常见用途

  • 使用无服务器架构实施应用程序:

  • 如何使用 Spring 编写函数

  • 使用 AWS Lambda 和 Azure 的适配器

无服务器架构简介

无服务器架构是通过亚马逊的倡议诞生的。该公司希望推广一个开发团队可以自主、小型和自我管理的环境,使其能够从编写代码到在生产环境中交付和交付整个软件开发周期。

无服务器架构有时被误解为部署软件系统而无需物理服务器的概念。要理解这个想法,您可以查看 Martin Fowler 的博客中对无服务器的定义:

“重要的是要理解,无服务器架构是开发人员将业务逻辑编码为函数的方法,忘记了服务器的配置和扩展问题,其中逻辑将被执行。”

无服务器和 FaaS 的常见示例包括:

  • 认证

  • 短信通知

  • 电子邮件服务

另一方面,在无服务器的世界中,通常会创建应用程序,其中采用第三方服务作为系统的一部分(而不是从头开始创建服务)。这些服务通常被称为后端即服务(BaaS)或移动后端即服务(MBaaS)。

采用相同的方法,我们可以将自定义业务逻辑编码为可以部署在云上的函数。这些服务被称为函数即服务(FaaS)。

以下图表说明了第三方服务和自定义功能是如何被不同的软件系统创建、部署和消费的:

第三方服务和自定义功能

基础设施和文件存储

基础设施和文件存储也被视为无服务器,因为拥有系统的业务(或个人)不必购买、租用或配置服务器或虚拟机来使用它们。

作为开发人员,如果我们采用老式方法(使用本地环境提供所有基础设施),我们必须为我们想要部署软件系统的每个环境设置所有软件和硬件要求。这个配置过程必须在所有环境中重复进行,直到我们进入生产阶段,在这一点上,我们必须处理其他功能,如扩展和监控。在许多情况下,我们的基础设施将被低效利用,这是一种浪费金钱的行为,因为我们购买了强大的服务器来部署不需要太多资源的应用程序。

好处和陷阱

采用无服务器架构方法创建应用程序为我们提供了许多好处,但也有一些缺点需要解决。让我们先来回顾一下好处:

  • 使用无服务器架构的开发人员可以主要专注于代码,可以忘记与服务器供应有关的一切,这是云提供商自己处理的任务。

  • 代码的扩展是短暂的,意味着它可以根据检索的请求数量进行扩展和启动或关闭。

  • 根据定义,用于编写业务逻辑的所有功能必须是无状态的,因此松散耦合。这样,任务就可以专注于明确定义的责任。

  • 功能可以通过事件异步触发。

  • 我们只需支付所消耗的计算时间。

  • 这些功能的功能是基于事件驱动模型的。

  • 开发者可以以透明的方式实现无限扩展。

另一方面,也存在一些缺点:

  • 缺乏可用作参考的文档和展示

  • 当需要同时使用多个服务时引入的延迟问题

  • 某些功能仅在特定的云服务提供商中可用。

  • 供应商锁定

为了解决供应商锁定的问题,强烈建议在无服务器架构的一部分使用多云方法。多云策略涉及使用多个云提供商。这很重要,因为通过它,我们可以利用不同供应商和不同产品的优势。例如,Google 提供了出色的机器学习服务,AWS 提供了各种标准服务,微软 Azure 为远程调试等功能提供了出色的功能。另一方面,云无关的策略建议我们尽可能避免依赖特定的云提供商,以便在需要时自由部署系统。然而,这将很难实现,因为这意味着以更通用的方式设计系统,忽略提供额外优势的特定供应商功能。

后端即服务

使用 BaaS 方法的最简单情景是创建单页应用程序SPA)或与云中可用服务交互的移动应用程序。

通常可以找到应用程序,其中认证过程委托给第三方服务,使用标准协议(如 OAuth),将信息持久存储在云数据库(如 Google Firebase),或通过短信服务(如 Twilio)发送通知。

BaaS 可以帮助我们解决一些问题,以便我们可以在不必担心应用程序的服务器或虚拟机的情况下部署到生产环境。此外,BaaS 还为我们提供了整个基础设施和节点,例如以下内容:

  • 负载均衡器

  • 数据库用于存储我们的数据(NoSQL 或 RDBMS)

  • 文件系统

  • 队列服务器

BaaS 还满足以下要求:

  • 备份

  • 复制

  • 补丁

  • 规模

  • 高可用性

另一方面,BaaS 也增加了作为服务的新产品的诞生,包括以下内容:

  • Firebase:这为我们提供了分析、数据库、消息传递和崩溃报告等功能

  • Amazon DynamoDB:这个键值存储是非关系型数据库

  • Azure Cosmos DB:这是一个全球分布的多模型数据库服务

随着所有这些变化和新工具,我们必须接受一种新的思维方式,并打破构建应用程序的范式。由于无服务器是一种新技术,建议进行实验,从使用应用程序的一小部分开始。想想您当前应用程序中的三个例子,这些例子使用无服务器方法进行重构将会很有趣。现在,与您的团队商讨并组织一个架构对抗(http://architecturalclash.org/)研讨会,以确定您的想法是否可行。

函数即服务

自 2014 年以来,AWS Lambda 的使用越来越受欢迎。在某些情况下,甚至可以使用 FaaS 方法构建整个应用程序;在其他情况下,该方法用于解决特定要求。

函数形式部署的代码在事件发生时被执行。一旦事件发生,代码被执行,然后函数被关闭。因此,函数本质上是无状态的,因为没有状态或上下文可以与其他应用程序共享。

FaaS 是短暂的,意味着当需要执行函数时,云提供商将自动使用与函数相关的元数据来提供环境。这将根据处理需求进行扩展,并且一旦处理完成,执行环境将被销毁,如下图所示:

短暂的 FaaS 过程

使用 FaaS 方法实现代码将为您提供以下好处:

  • 您不必担心主机配置

  • 透明的按需扩展

  • 自动启动/关闭

  • 您只需为您使用的部分付费

关于无服务器架构的担忧

新的技术趋势有时会产生韧性和担忧,但它们也提供了实验和为应用程序和业务获益的机会。

服务器无架构涉及的最常见问题如下:

  • 供应商锁定

  • 安全性

  • 框架支持

  • 故障排除

供应商锁定

在供应商锁定方面,主要问题是无法将新服务作为供应商的无服务器架构的一部分。这个问题归结为对与云提供商绑定的恐惧。

建议尽可能使用您选择的云提供商的许多功能。您可以通过开始一个试点并评估云提供商来做到这一点;在将更多代码移至云之前,一定要创建一个利弊评估。

不要因为这个问题而放弃使用无服务器架构。相反,建议开始一个概念验证并评估云提供商。无服务器是一种新技术,将随着时间的推移而发展,有办法保持 FaaS 的独立性,例如使用 Spring Cloud 功能。我们将在本章的后面部分的一个示例中进行这方面的工作。

最后,您应该明白,从一个供应商转移到另一个供应商(从云到云)并不像过去(当我们将应用程序或传统代码转移到本地环境时)那么困难。

安全性

安全性是一个关键问题,与应用程序的架构无关,无服务器也不例外。由于我们在云中创建函数作为服务,我们需要在我们的身份验证、执行授权和 OWASP 方面小心。然而,在这种情况下,云提供商(如 AWS 或 Azure)为我们提供了开箱即用的指南和实践,以减少我们的担忧。

在无服务器中需要考虑的另一个安全问题是缺乏明确定义的安全边界。换句话说,当一个函数的安全边界结束并且另一个函数开始时,不同的云提供商提供不同的方法来使这些函数作为一个整体工作;例如,AWS 通过使用称为 API 网关的服务来实现这一点。这个 API 用于编排和组合创建的 FaaS。另一方面,正如一切都是短暂的一样,许多这些问题可能会消失,因为 FaaS 中的短暂概念是每次调用 FaaS 时都会创建、运行和销毁函数的请求都是隔离的。

为了澄清任何疑虑,我们将开始将部分代码移动到无服务器/函数即服务,创建一个实验性的开发,并在对该概念更有信心时逐步增加。

框架支持

有几个框架正在努力创建开发无服务器架构的环境,而不依赖于云提供商。根据我的经验,最好创建函数作为服务,尽可能地利用云平台。由于函数是具有清晰输入或输出的小段代码,最好使用您感到舒适的语言和技术,甚至尝试新技术或编程语言,以确定它们的优劣。

在这个阶段,无服务器支持多种语言来构建函数。目前,部署 FaaS 的最常见选项如下:

  • AWS Lamba

  • Azure 函数

  • Google 函数

Java 开发人员的一个好处是,大多数云提供商都支持 Java 作为一种编程语言来部署函数。此外,Spring Framework 有一个名为 Spring Functions 的项目,可以用来编写函数;我们将在本章后面使用这个项目来实现一些功能。

使用 Spring Functions 的一个好处是,我们可以在本地机器上开发和测试我们的代码,然后使用适配器包装代码,以便在云提供商上部署它。

故障排除

一旦应用程序(或在本例中的函数)部署到生产环境中,需要考虑的关键方面之一是如何跟踪、查找和修复错误。对于无服务器来说,这可能会很棘手,因为我们正在处理一个更为分隔的场景,我们的系统有一些未分成服务和微服务的小部分。几个函数是逻辑和代码的小部分。为了解决这个问题,每个云提供商都有工具来监视和跟踪函数,处理短暂环境中的错误。如果我们组合了几个函数的逻辑,我们将不得不应用聚合日志记录等技术,并使用工具来收集与执行的代码相关的信息。我们将在第十二章中审查一些处理这个概念的技术,监控

示例和常见用例

即使无服务器架构为我们提供了许多好处,这些好处也不能应用于所有情况。当应用程序同时使用传统服务器(本地或基于云的)部署的后端和用于特定需求的 FaaS 或第三方服务时,使用混合模型是非常常见的。

无服务器架构可以应用于以下一些常见场景:

  • 处理 webhooks

  • 应该在特定情况下安排或触发的任务或工作

  • 数据转换,例如:

  • 图像处理、压缩或转换

  • 语音数据转录成文本,比如 Alexa 或 Cortana

  • 基于移动后端作为服务方法的移动应用程序的某种逻辑

  • 单页应用程序

  • 聊天机器人

另一方面,无服务器架构不适用于以下情况:

  • 需要大量资源(如 CPU 和内存)的长时间运行的进程

  • 任何阻塞进程

采用无服务器架构为 SPA 提供支持

单页应用程序(SPA)为采用无服务器架构方法提供了最适合的场景之一。毕竟,它们不涉及太多编码的业务逻辑,它们主要提供和消费由其他地方部署的服务提供的内容。

例如,假设我们需要构建一个应用程序来向用户发送世界杯比赛结果。在这个例子中,我们需要满足以下要求:

  • 认证

  • 数据存储

  • 通知机制

采用无服务器架构方法,这些要求可以由以下服务提供商解决:

  • 认证:Google OAuth

  • 数据存储:Google Firebase

  • 通知机制

  • 短信,使用 Twilio

  • 电子邮件,使用 SparkPost

以下图表说明了如何将前述服务(Google OAuth、Firebase、Twilo 和 SparkPost)作为应用程序的一部分使用:

集成不同的第三方应用程序

前面的图表显示了一些最知名的服务提供商,但在互联网上还有很多其他选择。

前述服务的一个好处是它们都提供了一个可以直接从 SPA 中使用的 SDK 或库,包括常见的 JavaScript 库,如 Angular。

使用 Spring Cloud Functions 实现 FaaS

在 Spring 项目的支持下,您会发现 Spring Cloud Function 项目(cloud.spring.io/spring-cloud-function/),它旨在使用无服务器架构模型实现应用程序。

使用 Spring Cloud Function,我们可以编写可以在支持 FaaS 的不同云提供商上启动的函数。无需从头学习新东西,因为 Spring Framework 的所有核心概念和主要功能,如自动配置、依赖注入和内置指标,都以相同的方式应用。

一旦函数编码完成,它可以部署为 Web 端点、流处理器,或者简单的任务,这些任务由特定事件触发或通过调度程序触发。

通过 SPA 的一个例子,我们可以使用第三方服务、现有的 REST API 和自定义函数来实现一个应用程序。以下图表说明了如何使用前面提到的所有选项来创建一个应用程序:

将 FaaS 集成到应用程序中

让我们来看看前面图表中的组件是如何工作的:

  • 认证由第三方服务提供

  • 应用程序使用驻留在 REST API 中的业务逻辑

  • 自定义函数可以作为 SPA 的一部分使用

以下图表说明了函数的工作原理:

函数即服务

让我们来回顾图表的每个部分:

  • 函数提供了一种使用事件驱动编程模型的方式。

  • 我们可以以对开发人员透明的方式进行无限扩展。这种扩展将由我们用来部署函数的平台处理。

  • 最后,我们只需支付函数在执行过程中消耗的时间和资源。

使用 Spring 的函数

Spring Cloud Function 为我们带来了四个主要功能,详细描述在官方文档中(github.com/spring-cloud/spring-cloud-function),这里值得一提:

  • 它提供了包装@Beans类型的函数、消费者和供应商的能力。这使得可以将功能公开为 HTTP 端点,并通过监听器或发布者进行流消息传递,使用消息代理如 RabbitMQ、ActiveMQ 或 Kafka。

  • 它提供了编译的字符串,这些字符串将被包装为函数体。

  • 我们可以部署一个带有我们的函数的 JAR 文件,带有一个独立的类加载器,它将在单个 Java 虚拟机上运行。

  • 它为支持无服务器架构的不同云提供商提供适配器,例如以下:

  • AWS Lambda

  • Open Whisk

  • Azure

编写示例

现在,我们将创建一个掩码银行帐户号码的函数。让我们从头开始创建一个新的 Spring Boot 应用程序,使用 Spring Initializr 网站(start.spring.io):

Spring Initializr 网站

目前,作为项目的一部分,不需要包含额外的依赖项。项目结构非常简单,如下所示:

为了使用 Spring 编写函数,我们必须将 Spring Cloud Function 项目作为依赖项包含进来;首先,让我们添加一些属性来指定我们将要使用的版本,如下所示:

  <parent>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-parent</artifactId>
 <version>1.5.11.RELEASE</version>
      <relativePath/>
   </parent>

   <properties>
      <project.build.sourceEncoding>UTF-
      8</project.build.sourceEncoding>
      <project.reporting.outputEncoding>UTF-
      8</project.reporting.outputEncoding>
      <java.version>1.8</java.version>
      <spring-cloud-function.version>
        1.0.0.BUILD-SNAPSHOT
      </spring-cloud-function.version>
 <reactor.version>3.1.2.RELEASE</reactor.version>
 <wrapper.version>1.0.9.RELEASE</wrapper.version>
   </properties>

请注意,我们将将 Spring 版本降级为 1.5.11 RELEASE,因为 Spring Cloud Function 目前尚未准备好在 Spring Boot 2 中使用。

现在,我们将添加依赖项,如下所示:

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-function-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-function-compiler</artifactId>
</dependency>

然后,我们必须在依赖管理部分中添加一个条目,以便 Maven 自动解析所有传递依赖项:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-function-dependencies</artifactId>
      <version>${spring-cloud-function.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

最后,我们将包含一些插件,这些插件将允许我们通过将以下条目添加为pom.xml文件的一部分来包装编码的函数:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-deploy-plugin</artifactId>
      <configuration>
        <skip>true</skip>
      </configuration>
    </plugin>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <dependencies>
        <dependency>
          <groupId>org.springframework.boot.experimental</groupId>
          <artifactId>spring-boot-thin-layout</artifactId>
          <version>${wrapper.version}</version>
        </dependency>
      </dependencies>
    </plugin>
  </plugins>
</build>

现在,我们已经准备好实现一个掩码帐户号码的函数。让我们回顾以下代码片段:

package com.packtpub.maskaccounts;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.function.context.FunctionScan;
import org.springframework.context.annotation.Bean;
import reactor.core.publisher.Flux;

import java.util.function.Function;

@FunctionScan
@SpringBootApplication
public class MaskAccountsApplication 
{
  public static void main(String[] args) {
    SpringApplication.run(MaskAccountsApplication.class, args);
  }

  @Bean
  public Function<Flux<String>, Flux<String>> maskAccounts() 
  {
 return flux -> 
    {
 return flux
      .map(value -> 
        value.replaceAll("\\w(?=\\w{4})", "*")
      );
 };
 }
}

@FunctionScan注释用于允许 Spring Function 适配器找到将部署为云提供商中的函数的 bean。

一旦函数编码完成,我们将使用application.properties文件进行注册,如下所示:

spring.cloud.function.stream.default-route: maskAccounts
spring.cloud.function.scan.packages: com.packtpub.maskaccounts

现在,是时候使用以下步骤在本地执行函数了:

  1. 生成 artifact:
$ mvn install
  1. 执行生成的 artifact:
$ java -jar target/mask-accounts-0.0.1-SNAPSHOT.jar

现在,您应该看到类似以下的输出:

控制台输出

让我们尝试使用以下CURL命令执行函数:

$ curl -H "Content-Type: text/plain" http://localhost:8080/maskAccounts -d 37567979
%****7979

因此,我们将获得一个掩码帐户号码:****7979

在下一节中,我们将回顾如何使用不同的云提供商部署函数。

为了在任何云提供商上创建帐户,例如 AWS 或 Azure,您将需要信用卡或借记卡,即使提供商提供免费套餐也是如此。

适配器

Spring Cloud Function 为不同的云提供商提供适配器,以便使用函数部署编码的业务逻辑。目前,有以下云提供商的适配器:

  • AWS Lambda

  • Azure

  • Apache OpenWhisk

在下一节中,我们将介绍如何使用这些适配器。

AWS Lambda 适配器

该项目旨在允许部署使用 Spring Cloud Function 的应用程序到 AWS Lambda(aws.amazon.com/lambda/)。

该适配器是 Spring Cloud Function 应用程序的一层,它使我们能够将我们的函数部署到 AWS 中。

您可以在 GitHub 上找到项目的源代码,链接如下:github.com/spring-cloud/spring-cloud-function/tree/master/spring-cloud-function-adapters/spring-cloud-function-adapter-aws

在使用 AWS Lambda 适配器之前,我们必须将其添加为项目的依赖项。让我们首先在pom.xml文件中定义一些属性:

<aws-lambda-events.version>
    2.0.2
</aws-lambda-events.version>
<spring-cloud-stream-servlet.version>
    1.0.0.BUILD-SNAPSHOT
</spring-cloud-stream-servlet.version>
<start-class>
    com.packtpub.maskaccounts.MaskAccountsApplication
</start-class>

现在,我们必须为 AWS 添加所需的依赖项:

<dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-function-adapter-aws</artifactId>
</dependency>
<dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-events</artifactId>
      <version>${aws-lambda-events.version}</version>
      <scope>provided</scope>
    </dependency>
<dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-core</artifactId>
      <version>1.1.0</version>
      <scope>provided</scope>
</dependency>

现在,将其添加到dependency管理部分,如下所示:

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-stream-binder-servlet</artifactId>
  <version>${spring-cloud-stream-servlet.version}</version>
</dependency>

最后,将其添加到plugin部分,如下所示:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <configuration>
    <createDependencyReducedPom>false</createDependencyReducedPom>
    <shadedArtifactAttached>true</shadedArtifactAttached>
    <shadedClassifierName>aws</shadedClassifierName>
  </configuration>
</plugin>

接下来,我们将编写一个作为 AWS 适配器工作的类。该适配器应该扩展SpringBootRequestHandler类,如下所示:

package com.packtpub.maskaccounts;

public class Handler 
    extends SpringBootRequestHandler<Flux<String>, Flux<String>> {

}

一旦适配器编写完成,我们将需要修改先前实现的函数作为MaskAccountsApplication.java文件的一部分。在这里,我们将更改方法的名称为function,函数的输入和输出将是具有 setter 和 getter 的普通旧 Java 对象(POJOs),如下所示:

package com.packtpub.maskaccounts;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.function.context.FunctionScan;
import org.springframework.context.annotation.Bean;

import java.util.function.Function;

@FunctionScan
@SpringBootApplication
public class MaskAccountsApplication {

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

    @Bean
    public Function<In, Out> function() {
            return value -> new Out(value.mask());
    }
}

class In {

    private String value;

    In() {
    }

    public In(String value) {
        this.value = value;
    }

    public String mask() {
        return value.replaceAll("\\w(?=\\w{4})", "*");
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

class Out {

    private String value;

    Out() {
    }

    public Out(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

为了包装编码的函数,我们必须创建一个 JAR 文件,使用以下 Maven 目标:

$ mvn clean package

一旦 JAR 文件创建完成,我们可以使用 AWS 提供的命令行界面(CLI)aws.amazon.com/cli/)上传生成的 JAR 文件,运行以下命令:

$ aws lambda create-function --function-name maskAccounts --role arn:aws:iam::[USERID]:role/service-role/[ROLE] --zip-file fileb://target/mask-accounts-aws-0.0.1-SNAPSHOT-aws.jar --handler org.springframework.cloud.function.adapter.aws.SpringBootStreamHandler --description "Spring Cloud Function Adapter for packt Mastering Architecting Spring 5" --runtime java8 --region us-east-1 --timeout 30 --memory-size 1024 --publish

[USERID]引用基于您的 AWS 账户和[ROLE]引用。如果您对如何创建 AWS 账户有任何疑问,请访问aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/

有关 AWS lambda create-function的更多信息,请参阅docs.aws.amazon.com/cli/latest/reference/lambda/create-function.html

如果您没有设置 AWS 账户的凭据,您将收到一个错误消息,指出无法找到凭据。您可以通过运行aws configure命令来配置凭据。

不要忘记,您需要创建一个具有权限运行 AWS Lambda 的角色的 AWS 用户。

一旦函数成功部署,您将在控制台中看到类似以下的输出:

输出处理

最近部署的函数现在将在 AWS Lambda 控制台中列出,如下所示:

AWS Lambda 控制台

如果您在 Web 控制台中看不到最近部署的函数,则必须检查创建函数的位置。在本例中,我们使用us-east-1地区,这意味着函数部署在北弗吉尼亚。您可以在 AWS Lambda 控制台顶部的名称旁边检查此值。

最后,我们将在 AWS Lambda 控制台中测试我们的结果。在测试部分,创建一些输入以进行蒙版处理,如下所示:

{"value": "37567979"}

预期结果如下:

{"value": "****7979"}

在 AWS 控制台中,您将看到以下结果:

maskAccount 函数的 AWS 控制台测试结果

Azure 适配器

在本节中,我们将回顾如何将先前编码的函数部署到 Azure,这是 Microsoft 支持的云提供商。Azure 通过使用 Microsoft Azure Functions(azure.microsoft.com/en-us/services/functions/)支持函数。

Azure 适配器是在 Spring Cloud Function 项目上编写的一层。您可以在 GitHub 上找到该项目的源代码(github.com/spring-cloud/spring-cloud-function/tree/master/spring-cloud-function-adapters/spring-cloud-function-adapter-azure)。

让我们首先将以下属性添加为pom.xml文件的一部分,在属性部分:

<functionAppName>function-mask-account-azure</functionAppName><functionAppRegion>westus</functionAppRegion>
<start-class>
    com.packtpub.maskaccounts.MaskAccountsApplication
</start-class>

现在,让我们添加此适配器所需的依赖项,如下所示:

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-function-adapter-azure</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-function-web</artifactId>
  <scope>provided</scope>
</dependency>
<dependency>
  <groupId>com.microsoft.azure</groupId>
  <artifactId>azure-functions-java-core</artifactId>
  <version>1.0.0-beta-2</version>
  <scope>provided</scope>
</dependency>

然后,我们将添加一些插件以允许适配器工作,如下所示:

<plugin>
  <groupId>com.microsoft.azure</groupId>
  <artifactId>azure-functions-maven-plugin</artifactId>
  <configuration>
    <resourceGroup>java-functions-group</resourceGroup>
    <appName>${functionAppName}</appName>
    <region>${functionAppRegion}</region>
    <appSettings>
      <property>
        <name>FUNCTIONS_EXTENSION_VERSION</name>
        <value>beta</value>
      </property>
    </appSettings>
  </configuration>
</plugin>
<plugin>
  <artifactId>maven-resources-plugin</artifactId>
  <executions>
    <execution>
      <id>copy-resources</id>
      <phase>package</phase>
      <goals>
        <goal>copy-resources</goal>
      </goals>
      <configuration>
        <overwrite>true</overwrite>
        <outputDirectory>${project.build.directory}/azure-
        functions/${functionAppName}
        </outputDirectory>
        <resources>
          <resource>
            <directory>${project.basedir}/src/main/azure</directory>
            <includes>
              <include>**</include>
            </includes>
          </resource>
        </resources>
      </configuration>
    </execution>
  </executions>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <configuration>
    <createDependencyReducedPom>false</createDependencyReducedPom>
    <shadedArtifactAttached>true</shadedArtifactAttached>
    <shadedClassifierName>azure</shadedClassifierName>
    <outputDirectory>${project.build.directory}/azure-
    functions/${functionAppName}</outputDirectory>
  </configuration>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-assembly-plugin</artifactId>
  <executions>
    <execution>
      <id>azure</id>
      <phase>package</phase>
      <goals>
        <goal>single</goal>
      </goals>
      <inherited>false</inherited>
      <configuration>
        <attach>false</attach>
        <descriptor>${basedir}/src/assembly/azure.xml</descriptor>
        <outputDirectory>${project.build.directory}/azure- 
        functions</outputDirectory>
        <appendAssemblyId>false</appendAssemblyId>
        <finalName>${functionAppName}</finalName>
      </configuration>
    </execution>
  </executions>
</plugin>

最后,我们将创建一个适配器,该适配器应该扩展自AzureSpringBootRequestHandler类。扩展类将为我们提供输入和输出类型,使 Azure 函数能够检查类并执行任何 JSON 转换以消耗/生成数据:

public class Handler 
    extends AzureSpringBootRequestHandler<Flux<String>,Flux<String>> {

    public Flux<String> execute
                    (Flux<String>in, ExecutionContext context) {
        return handleRequest(in, context);
    }
}

现在,我们将修改MaskAccountsApplication.java文件中的编码函数;我们将更改函数的输入和输出,以便使用具有 setter 和 getter 的普通旧 Java 对象:

package com.packtpub.maskaccounts;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.function.context.FunctionScan;
import org.springframework.context.annotation.Bean;

import java.util.function.Function;

@FunctionScan
@SpringBootApplication
public class MaskAccountsApplication {

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

    @Bean
    public Function<In, Out> maskAccount() {
            return value -> new Out(value.mask());
    }
}

class In {

    private String value;

    In() {
    }

    public In(String value) {
        this.value = value;
    }

    public String mask() {
        return value.replaceAll("\\w(?=\\w{4})", "*");
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

class Out {

    private String value;

    Out() {
    }

    public Out(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

然后我们必须为 Azure 工具创建一个 JSON 配置,因此我们将在src/main文件夹后面的新文件夹中创建一个名为function.json的 JSON 文件,文件名为函数(maskAccount)。此文件将用于让 Azure 了解我们要部署的函数,通过指定将用作入口点的 Java 类。src文件夹应如下所示:

function.json文件的内容将如下所示:

{
   "scriptFile": "../mask-accounts-azure-1.0.0.BUILD-SNAPSHOT-azure.jar",
   "entryPoint": "com.packtpub.maskaccounts.Handler.execute",
"bindings": [
 {
 "type": "httpTrigger",
 "name": "in",
 "direction": "in",
 "authLevel": "anonymous",
 "methods": [
 "get",
 "post"
 ]
 },
 {
 "type": "http",
 "name": "$return",
 "direction": "out"
 }
 ],
 "disabled": false
}

可以使用 Maven 插件为非 Spring 函数创建 JSON 文件,但是该工具与当前版本的适配器不兼容。

在生成将要部署的构件之前,我们必须创建一个assembly文件,这是我们正在使用的 Azure Maven 插件所需的。

assembly文件应放在src/assembly目录中;文件将被命名为azure.xml,并包含以下内容:

<assembly
   xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
   <id>azure</id>
   <formats>
      <format>zip</format>
   </formats>
   <baseDirectory></baseDirectory>
   <fileSets>
      <fileSet>
         <directory>${project.build.directory}/azure-functions/${functionAppName}</directory>
         <outputDirectory></outputDirectory>
         <includes>
            <include>*-azure.jar</include>
            <include>**/*.json</include>
         </includes>
      </fileSet>
   </fileSets>
</assembly>

现在,可以使用以下 Maven 目标创建 JAR 文件:

$ mvn clean package

该函数可以在本地部署进行测试,通过使用以下命令将 JAR 文件作为常规 Java 应用程序运行:

$ java -jar target/mask-accounts-azure-0.0.1-SNAPSHOT.jar

然后您将看到应用程序正在运行,如下所示:

本地运行的 Spring 应用程序的输出

让我们尝试使用以下curl命令来测试该功能:

$ curl -H "Content-Type: text/plain" localhost:8080/maskAccount -d '{"value": "37567979"}'

您将看到以下输出:

或者,我们可以使用 Azure Functions Core Tools 将我们的函数部署到 Azure。

要做到这一点,首先,您必须使用提供在github.com/azure/azure-functions-core-tools#installing上的信息安装所有所需的工具。安装了所需的工具后,您可以使用终端中的以下命令登录到 Azure:

$ az login

在输入了您的凭据之后,您将在控制台上看到以下输出:

将编码的函数部署到 Azure 非常简单;您只需执行以下 Maven 命令:

$ mvn azure-functions:deploy

现在,您可以使用以下curl命令尝试部署的函数:

$ curl https://<azure-function-url-from-the-log>/api/maskAccount -d '{"value": "37567979"}'

<azure-function-url-from-the-log>是在运行mvn azure-functions:deploy命令后获得的 URL。例如,在以下屏幕截图中,您可以看到https://function-mask-account-azure.azurewebsites.net/URL:

执行curl命令后,收到的输出将如下所示:

输出处理

我们还可以在 Azure Functions 控制台上测试相同的函数,就像我们在 AWS Lambda 上做的那样。

总结

在本章中,我们讨论了无服务器架构背后的概念。您了解了如何使用 Spring Cloud Functions 实现函数,并且我们回顾了可以用于在不同云提供商(如 AWS Lambda 和 Microsoft Azure Functions)部署函数的适配器。

在下一章中,我们将描述容器是什么,以及您如何使用它们来容器化应用程序。

第十章:将应用程序容器化

容器正在成为软件开发的关键因素之一,改变了开发人员编写和部署 IT 系统的方式。主要用于解决与设置环境相关的问题。

当你需要管理多个容器和多实例环境时,使用容器可能会让人感到不知所措。然而,一些非常酷的工具已经发布,旨在完成这些容器编排任务。在本章中,我们将一起看看这些工具,以及以下主题:

  • 容器

  • 基本概念

  • 基本命令

  • 构建你自己的镜像

  • 容器化应用程序:Docker Gradle 插件

  • 注册表:发布镜像

  • 配置多容器环境:Docker Compose

  • 使用 Kubernetes 进行容器编排

  • Pods

  • 标签

  • 复制控制器

  • 服务

容器

容器提供了一种轻量级的虚拟化方法,它提供了应用程序运行所需的最低限度。在过去,虚拟机曾经是设置环境和运行应用程序的主要选择。然而,它们需要完整的操作系统才能工作。另一方面,容器重用主机操作系统来运行和配置所需的环境。让我们通过查看下图来更多地了解这个概念:

虚拟机和容器

在上图中,我们可以看到左侧是虚拟机VMs),右侧是容器。让我们从学习虚拟机是如何工作开始。

虚拟机需要使用分配给虚拟机的硬件的自己的操作系统,这由 hypervisor 支持。上图显示了三个虚拟机,这意味着我们需要安装三个操作系统,每个虚拟机一个。当你在虚拟机中运行应用程序时,你必须考虑应用程序和操作系统将消耗的资源。

另一方面,容器使用主机操作系统提供的内核,还使用虚拟内存支持来隔离所有容器的基本服务。在这种情况下,不需要为每个容器安装整个操作系统;这是一种在内存和存储使用方面非常有效的方法。当你使用容器运行应用程序时,你只需要考虑应用程序消耗的资源。

容器体积小,可以用几十兆来衡量,只需要几秒钟就可以被配置。相比之下,虚拟机的体积以几十 GB 来衡量,但它们甚至需要几分钟才能开始工作。你还需要考虑操作系统许可证费用——当你使用虚拟机时,你必须为每个安装的操作系统付费。使用容器时,你只需要一个操作系统,所有容器都将使用它来运行。

市场上目前有不同的容器可用,但 Docker 是目前最流行的实现。因此,我们将选择这个选项来解释本章中的所有概念。

基本概念

在本节中,我们将回顾一些基本概念和命令,这些命令你在日常使用中会经常用到。这应该有助于你理解本章的其余内容。

容器和镜像

谈到 Docker 时,人们经常使用容器镜像这两个术语。这两个术语之间的区别很简单:容器是镜像的一个实例,而镜像是一个不可变的文件,本质上是容器的快照。从面向对象编程OOP)的角度来看,我们可以说镜像就像类,容器是这些类的实例。例如,假设你有一个由 CentOS 和 Java 8 组成的 Docker 镜像。使用这个镜像,你可以创建一个容器来运行一个 Spring Boot 应用程序,另一个容器来运行一个 JEE 应用程序,如下图所示:

Docker 镜像和容器

基本命令

Docker 有一大堆命令来执行使用容器和镜像的不同操作。然而,并不需要熟悉所有这些命令。我们现在将回顾一些你需要了解的最常用的命令。

运行容器

我们之前提到过,容器是镜像的实例。当你想要运行一个 Docker 容器时,你可以使用以下命令:

docker run IMAGE_NAME

互联网上有大量的 Docker 镜像可用。在创建自定义镜像之前,你应该首先查看 Docker Hub 上可用的镜像列表(hub.docker.com/)。

Docker Hub 是一个基于云的注册服务,允许你链接到代码仓库,构建你的镜像并对其进行测试。它还存储手动推送的镜像,并链接到 Docker Cloud,以便你可以将镜像部署到你的主机上。Docker Hub 为容器和镜像的发现、分发和变更管理;用户和团队协作;以及整个开发流程中的工作流自动化提供了一个集中的资源。

让我们假设你想要使用nginx运行一个容器。在这种情况下,你只需要在终端中执行以下命令:

docker run nginx

运行这个命令后,Docker 将尝试在本地找到镜像。如果它找不到,它将在所有可用的注册表中查找镜像(我们稍后会谈论注册表)。在我们的情况下,这是 Docker Hub。你在终端中应该看到的第一件事是类似于以下内容的输出:

⋊> ~ docker run nginx
 Unable to find image 'nginx:latest' locally
 latest: Pulling from library/nginx
 f2aa67a397c4: Downloading [==================================> ] 15.74MB/22.5MB
 3c091c23e29d: Downloading [=======> ] 3.206MB/22.11MB
 4a99993b8636: Download complete

执行这个操作后,你将得到一个类似于d38bbaffa51cdd360761d0f919f924be3568fd96d7c9a80e7122db637cb8f374的字符串,它代表了镜像 ID。

一些用于运行容器的有用标志如下:

  • -d标志将镜像作为守护进程运行

  • -p标志将镜像端口连接到主机

例如,以下命令可以让你将nginx作为守护进程运行,并将容器的端口80映射到主机的端口32888

docker run -p 32888:80 -d nginx

现在你将再次控制终端,并且你可以在http://localhost:32888/URL 中看到nginx的主页,如下截图所示:

Nginx 主页

容器只包含软件和服务,这些软件和服务对它们的工作是绝对必要的,这就是为什么你会发现它们甚至不包括 SSH 入口。如果你想进入一个容器,你可以使用-it标志,在容器内执行命令如下:

⋊> ~ docker run -it nginx /bin/bash
# Now you're inside the container here
root@0c546aef5ad9:/#

使用容器

如果你有兴趣检查主机上运行的所有容器,你可以使用以下ps命令:

docker ps

上面的命令将列出主机上运行的所有容器。如果你还想检查那些没有运行的镜像,你可以使用-a标志。执行上面的命令后,你将在终端中得到一个类似于以下截图的输出:

Docker ps 命令输出

前面截图的第一列解释了以下列表中的信息。这个输出中最有用的部分是 CONTAINER ID,它可以用来执行以下操作:

  • 重新启动容器:
docker restart <CONTAINER ID> 
  • 停止容器:
docker stop <CONTAINER ID> 
  • 启动容器:
docker start <CONTAINER ID> 
  • 删除容器:
docker rm <CONTAINER ID>

这些是最常用的命令,它们提供了你在使用 Docker 容器时所需要的一切。

使用镜像

Docker 还有一些命令,允许你的系统与镜像一起工作。最常用的命令如下:

  • 列出主机上所有可用的镜像:
⋊> ~ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
nginx latest ae513a47849c 4 weeks ago 109MB
  • 删除镜像:
⋊> ~ docker rmi nginx
Untagged: nginx:latest
Untagged: nginx@sha256:0fb320e2a1b1620b4905facb3447e3d84ad36da0b2c8aa8fe3a5a81d1187b884
Deleted: sha256:ae513a47849c895a155ddfb868d6ba247f60240ec8495482eca74c4a2c13a881
Deleted: sha256:160a8bd939a9421818f499ba4fbfaca3dd5c86ad7a6b97b6889149fd39bd91dd
Deleted: sha256:f246685cc80c2faa655ba1ec9f0a35d44e52b6f83863dc16f46c5bca149bfefc
Deleted: sha256:d626a8ad97a1f9c1f2c4db3814751ada64f60aed927764a3f994fcd88363b659
  • 下载镜像:
⋊> ~ docker pull <IMAGE NAME>

构建你自己的镜像

在互联网上,我们可以找到许多准备好使用的 Docker 镜像。这些镜像是使用一个名为 Dockerfile 的配置文件创建的,它包含了为容器进行配置的所有指令。

作为这个文件的一部分,你会发现以下常用命令:

  • FROM

  • MAINTAINER

  • RUN

  • ENV

  • EXPOSE

  • CMD

让我们逐个审查所有这些命令,以了解它们的工作原理。

FROM 命令

FROM命令用于指定 Dockerfile 将用于构建新镜像的基础 Docker 镜像。例如,如果您想基于 Debian 创建自定义镜像,您应该在文件中添加以下行:

FROM debian:stretch-slim 

MAINTAINER 命令

MAINTAINER命令完全用于文档目的,其中包含了 Dockerfile 的作者姓名以及他们的电子邮件,如下所示:

MAINTAINER  Your Name <your@email.com>

RUN 命令

Dockerfile 通常有多个RUN命令作为其一部分。这些命令旨在作为系统 bash 命令的一部分执行,并主要用于安装软件包。例如,以下RUN命令用于安装 Java 8:

RUN \ 
 echo oracle-java8-installer shared/accepted-oracle-license-v1-1 
 select true | debconf-set-selections && \ 
 add-apt-repository -y ppa:webupd8team/java && \ 
 apt-get update && \ 
 apt-get install -y oracle-java8-installer && \ 
 rm -rf /var/lib/apt/lists/* && \ 
 rm -rf /var/cache/oracle-jdk8-installer

上述命令取自名为oracle-java8的镜像提供的 Dockerfile(github.com/dockerfile/java/blob/master/oracle-java8/Dockerfile)。

这个命令很容易阅读,每一行描述了安装过程是如何进行的。最后两行从容器中删除了一些不再需要的目录。

所有安装都是作为单行完成的,因为每个RUN命令生成一个新的层。例如,在RUN命令中,我们可以看到一次执行了六条指令。如果我们逐条运行这些指令,最终会得到六个镜像,每个镜像都包含了基础镜像以及执行的RUN命令。我们不会在本书中详细讨论层,但如果您感到好奇,我强烈鼓励您阅读有关它们的内容:docs.docker.com/storage/storagedriver/#images-and-layers

ENV 命令

ENV命令用于在系统中创建环境变量。以下ENV命令作为前面提到的 Dockerfile 的一部分,用于定义JAVA_HOME变量:

ENV JAVA_HOME /usr/lib/jvm/java-8-oracle

EXPOSE 命令

EXPOSE命令定义了我们将从容器中公开的端口。例如,如果您想公开端口8032777,您需要在 Dockerfile 中添加以下行:

EXPOSE 80 32777

CMD 命令

CMD命令用于指定容器启动后应执行的命令。例如,如果要使用标准的java -jar命令运行 Java 应用程序,需要在文件中添加以下行:

CMD java - jar your-application.jar

完成 Dockerfile 后,应该运行build命令在本地创建镜像,如下所示:

docker build -t <docker-image-name>

容器化应用程序

一个 docker 化的应用程序是一个基本的可部署单元,可以作为整个应用程序生态系统的一部分进行集成。当您将应用程序 docker 化时,您将不得不创建自己的 Dockerfile,并包含所有必需的指令来使您的应用程序工作。

在上一节中,我们提到,可以使用FROM命令使用现有的基础镜像创建一个容器。您还可以复制基础镜像的 Dockerfile 内容,但这种做法是不鼓励的,因为在创建镜像时已经编写了代码,复制代码是没有意义的。

强烈建议您在 DockerHub 中找到官方镜像。由于 Dockerfile 可用,您应该始终阅读它以避免安全问题,并充分了解镜像的工作原理。

在将应用程序 docker 化之前,重要的是要使系统使用环境变量而不是配置文件。这样,您可以创建可以被其他应用程序重用的镜像。使用 Spring Framework 的最大优势之一是能够使用不同的方法来配置您的应用程序。这是我们在第八章中所做的,微服务,当时我们使用配置服务器来集中所有应用程序配置。Spring 使我们能够使用本地配置文件作为应用程序的一部分,并且我们可以稍后使用环境变量覆盖这些配置值。

现在让我们看看如何将 Spring Boot 应用程序 docker 化。

在第一步中,我们将创建 Dockerfile 来运行我们的应用程序。该文件的内容如下所示:

FROM java:8 
WORKDIR / 
ARG JAR_FILE 
COPY ${JAR_FILE} app.jar 
EXPOSE 8080 
ENTRYPOINT ["java","-jar","app.jar"]

让我们简要回顾一下 Dockerfile 中列出的命令:

命令 描述
FROM java:8 使用基本的java:8镜像
WORKDIR 镜像文件系统中的默认目录
ARG 我们将使用一个参数来指定 JAR 文件
COPY 提供的文件将被复制到容器中作为app.jar
EXPOSE 容器的端口 8080 被暴露
ENTRYPOINT 在容器内运行 Java 应用程序

这个 Dockerfile 应该位于项目的根目录。以下截图显示了项目的布局:

项目布局

应用程序 JAR 位于PROJECT/build/libs目录下。通过使用 Gradle wrapper 运行bootRepackage任务生成此构件,如下所示:

./gradlew clean bootRepackage

一旦构件被创建,就该是时候通过运行以下命令来创建 Docker 镜像了:

$ docker build -t spring-boot:1.0 . --build-arg JAR_FILE=build/libs/banking-app-1.0.jar

一旦命令完成,镜像应该存在于本地。您可以通过运行docker images命令来检查:

Docker 镜像控制台输出

请注意,java镜像也存在。这是在spring-boot镜像构建过程中下载的。然后,我们可以通过运行以下命令创建一个使用最近创建的镜像的容器:

$ docker run -p 8081:8080 -d --name banking-app spring-boot:1.0

您现在可以访问部署在容器中的应用程序,网址为http://localhost:8081/index。以下截图显示了这个应用程序:

应用程序部署在容器中

镜像的构建过程可以并且应该使用您喜欢的构建工具进行自动化。Gradle 和 Maven 都有插件可以作为应用程序的一部分集成。让我们看看如何为这个任务集成 Gradle 插件。

Docker Gradle 插件

即使生成 Docker 镜像时,使用 Docker 命令并不难或复杂;尽可能自动化所有这些步骤总是一个好主意。Docker Gradle 插件非常有用,可以完成这个任务。让我们学习如何将其作为应用程序的一部分。

首先,我们需要在buildscript部分内包含包含插件的仓库和插件本身作为依赖项,如下所示:

buildscript 
{
  ...
  repositories 
  {
    ...
    maven 
    {
      url "https://plugins.gradle.org/m2/"
    }
  }
  dependencies 
  {
    ...
    classpath('gradle.plugin.com.palantir.gradle.docker:gradledocker:
    0.13.0')
  }
}

稍后,插件应该以与任何其他插件相同的方式应用到项目中——使用其 ID。这在以下代码中显示:

apply plugin: 'com.palantir.docker'

可以使用官方文档中描述的参数来自定义镜像构建过程,网址为github.com/palantir/gradle-docker。为了简化,我们只会在docker块中指定所需的镜像名称,如下所示:

docker 
{
  name "enriquezrene/spring-boot-${jar.baseName}:${version}"
  files jar.archivePath
  buildArgs(['JAR_FILE': "${jar.archiveName}"])
}

正如你可能已经注意到的那样,我们现在正在使用build.gradle文件中可用的变量,比如生成的 JAR 名称及其版本。

现在插件已经完全集成到项目中,您可以通过运行以下 Gradle 任务来构建镜像:

$ ./gradlew build docker

您还可以检查最近创建的镜像,如下屏幕截图所示:

docker 镜像控制台输出

将所有这些步骤自动化是个好主意,因为这提供了可以在将来改进的免费文档。

注册表

正如我们所见,Docker 帮助我们复制用于部署应用程序的设置,但它也帮助我们分发应用程序以在不同环境中使用。可以使用注册表执行此任务。

注册表是负责托管和分发 Docker 镜像的服务。Docker 使用的默认注册表是 Docker Hub。市场上还有其他可用作 Docker 注册表的选项,包括以下内容:

  • Quay

  • Google 容器注册表

  • AWS 容器注册表

Docker Hub 非常受欢迎,因为它以您甚至都没有注意到的方式工作。例如,如果您正在创建一个容器,而本地存储库中不存在该镜像,它将自动从 Docker Hub 下载该镜像。所有现有的镜像都是由其他人创建并发布在这些注册表中。同样,我们可以发布我们自己的镜像,以便通过私有存储库使其对组织内的其他人可用。或者,您也可以将它们发布在公共存储库中。您还可以使用诸如 Nexus、JFrog 等解决方案在自己的硬件上自行托管 Docker 注册表。

Docker Hub 有一个免费计划,允许您创建无限数量的公共存储库和一个私有存储库。如果需要,它还提供另一个计划,可以让您拥有更多的私有存储库。我们使用 Docker Hub 来处理 Docker,就像我们使用 GitHub 来处理 Git 存储库一样。

发布镜像

要在 Docker Hub 中发布 Docker 镜像,您需要创建一个帐户,然后使用终端和docker login命令登录 Docker Hub。输入凭据后,您应该在终端中看到类似以下代码的输出:

$ docker login 
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: enriquezrene
Password:
Login Succeeded

现在您已登录,可以使用docker push命令将镜像推送到注册表,如下代码所示:

$ docker push <docker-hub-username/docker-image:tag-version>

当未指定标签版本时,默认使用latest值。在我们的情况下,应该对build.gradle文件进行一些小的更改,以附加 Docker Hub 所需的docker-hub-username前缀,如下代码所示:

docker 
{
  name "enriquezrene/spring-boot-${jar.baseName}:${version}"
  files jar.archivePath
  buildArgs(['JAR_FILE': "${jar.archiveName}"])
}

再次生成镜像后,您应该使用docker login命令从终端登录 Docker Hub,稍后可以推送镜像,如下代码所示:

# Login into Docker Hub
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: <username>
Password: <password>
Login Succeeded
# Push the image
$ docker push enriquezrene/spring-boot-banking-app:1.0

镜像推送后,您可以通过输入以下命令在任何其他计算机上拉取并运行容器:

$ docker run enriquezrene/spring-boot:1.0

这将从 Docker Hub 下载镜像并在本地运行应用程序。同样,我们可以重复此过程在任何其他计算机上部署应用程序。

以下屏幕截图显示了在 Docker Hub 上推送的镜像的外观:

Docker 镜像推送到 Docker Hub

应该使用持续集成服务器自动化push命令。一个好主意是在分支合并到master标签或在版本控制系统中创建新标签时执行此命令。您应该始终避免使用默认的latest标签值。相反,您应该使用自动过程自己创建版本号,就像我们在上一节中使用 Gradle 插件所做的那样。

集成插件还具有使用dockerPush Gradle 任务推送镜像的功能。

为多容器环境进行配置

当我们使用分布式应用程序时,我们面临的最大问题之一是难以提供应用程序工作所需的所有依赖关系。例如,假设您正在开发一个将信息存储在 MySQL 数据库中并使用 RabbitMQ 发送消息的应用程序,如下图所示:

具有 RabbitMQ 和 MySQL 依赖项的应用程序

在这种情况下,如果团队中的所有开发人员都希望在本地使整个环境工作,他们都需要在他们的计算机上安装 MySQL 和 RabbitMQ。

安装一些工具并不难,但一旦您的应用程序开始有越来越多的依赖关系,这项任务就变成了一场噩梦。这正是 Docker Compose 要解决的问题。

Docker Compose

Docker Compose 是一个工具,它允许您定义和执行多容器 Docker 环境。这意味着您应用程序中的每个依赖都将被容器化并由此工具管理。Docker Compose 诞生于一个名为FIG的独立开源项目,后来作为 Docker 家族的一部分进行了整合。目前,最新的 Compose 版本是 2.4。

在上面的例子中,您需要运行一些额外的服务:MySQL 和 RabbitMQ。

使用 Docker Compose 时,您可以在docker-compose.yaml文件中构建应用程序服务,然后使用此配置文件启动和停止所有这些服务,而不是逐个安装上述服务。这个配置文件使用了易于理解的 YAML 语法。

获取 RabbitMQ 和 MySQL 服务在本地运行所需的配置文件内容如下:

mysql:
 image: mysql
 ports:
 - "3306:3306"
 environment:
 - MYSQL_ROOT_PASSWORD=my-password

rabbitmq:
 image: rabbitmq:management
 ports:
 - "5672:5672"
 - "15672:15672"

同样,我们可以在配置文件中添加尽可能多的服务。docker-compose.yaml文件的用例是不言自明的,值得一提的是,该文件具有特定的配置,这些配置在 Dockerfile 中没有定义,比如端口映射。运行这个文件并不难:您只需要使用 Docker Compose 中的up命令,就像下面的代码所示:

$ docker-compose up

作为一个良好的实践,建议您在项目中提供一个docker-compose.yaml文件。这样,团队成员可以轻松地进行配置。

连接容器

当您运行分布式应用程序时,您必须连接不同的服务以使它们一起工作。为了满足这个要求,您需要知道服务的主机名或 IP 地址,以及其他配置变量。服务的可用顺序也很重要。让我们考虑以下简单的应用程序:

服务依赖关系

上面的图表示了最简单的应用程序;它只依赖于一个数据库服务器。在这个例子中,应用程序需要一些数据库配置参数,比如 IP 地址、端口等。当然,在启动应用程序之前,数据库服务应该是可用的;否则,应用程序将无法启动。

为了解决这个简单的需求,您可以在您的docker-compose.yaml文件中使用以下两个选项:

  • links

  • depends_on

links

links选项可以用来通过它们的名称连接各种容器。这样,您根本不需要知道它们的主机名或 IP 地址。

depends_on

使用depends_on选项,您可以指定服务启动的顺序。如果需要,一个服务可以依赖于多个服务。

让我们来看一下以下使用了这两个选项的docker-compose.yaml文件:

version: '3.1'
services:
    database:
        image: mysql:5
        ports:
            - "3306:3306"
        volumes:
          # Use this option to persist the MySQL data in a shared 
          volume.
            - db-data:/host/absolute/path/.mysql
        environment:
            - MYSQL_ROOT_PASSWORD=example
            - MYSQL_DATABASE=demo

    application:
        image: enriquezrene/docker-compose-banking-app:1.0
        ports:
            - "8081:8080"
 depends_on:
            - database
        environment:
            - spring.datasource.url=jdbc:mysql://database:3306/demo
            - spring.datasource.password=example
 links:
            - database

volumes:
 db-data:

上述代码中的depends_onlinks选项已经用粗体标出。从这可以很容易地理解,应用程序在数据库服务器启动后连接到数据库。

enriquezrene/docker-compose-banking-app: 1.0 镜像中有一个运行在其中的 Spring Boot 应用程序。作为这个应用程序的一部分,我们有一个名为application.properties的配置文件,内容如下:

spring.thymeleaf.cache=false
spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.username=root
spring.datasource.url=jdbc:mysql://localhost:3306/demo
spring.datasource.password=root

您可能会注意到密码和数据源 URL 参数已经提供。但是,Spring 提供了使用环境变量覆盖这些配置的能力,就像我们在docker-compose.yaml文件中所做的那样。

Docker Compose 易于使用,并且具有与 Docker 相同的选项。让我们快速回顾一些命令,以便开始使用它。

这个命令允许我们启动配置文件中列出的所有容器:

docker-compose up

up命令还允许使用-d标志将所有进程作为守护进程运行。如果您愿意,您可以从docker-compose.yaml文件中只启动一个服务,指定服务名称。假设我们只想运行数据库服务器。允许您执行此操作的命令如下:

$ docker-compose up database

这样,您可以为 Docker Compose 中可用的其他命令指定服务名称。

一旦服务启动,您可以使用以下命令列出所有正在运行的容器:

$ docker-compose ps

如果您想停止所有已启动的命令,您需要使用以下命令:

$ docker-compose stop

Docker Compose 由一大堆命令组成。要获取完整的参考资料,您可以访问docs.docker.com/compose/reference/

使用 Kubernetes 进行容器编排

Kubernetes 为使用 Docker 容器的环境引入了一套新的概念。我们可以说 Kubernetes 在生产中做的是 Docker Compose 在开发中做的,但实际上远不止于此。Kubernetes 是一个开源系统,最初是为 Google Cloud Engine 创建的,但您可以在 AWS 或任何其他云提供商中使用它。它旨在远程管理不同环境中的 Docker 集群。

Kubernetes 引入了以下主要概念:

  • Pods

  • 复制控制器

  • 服务

  • 标签

Pod

pod 是 Kubernetes 引入的一个新概念。一个 pod 由一组相关的容器组成,代表一个特定的应用程序。这是 Kubernetes 中最基本的单位;您不必一直考虑容器,因为在这里您应该专注于 pod。

让我们考虑一个名为 XYZ 的应用程序,它将其信息存储在一个数据库中,该数据库提供了一个 REST API,该 API 由其 UI 使用,如下图所示:

带有其依赖项的 XYZ 应用程序

很明显,我们需要三个单独的服务来使这个应用程序工作。如果我们在处理 Docker,我们会说我们需要三个不同的容器,但从 Kubernetes 的角度来看,所有这三个容器代表一个单独的 pod。这种抽象使我们能够更轻松地管理分布式应用程序。为了创建一个 pod 定义,您应该创建一个描述 pod 中所有容器的.yaml文件。我们之前提到的 XYZ 应用程序的示例描述在以下代码中:

apiVersion: v1
kind: Pod
metadata:
    name: application-xyz
spec:
    containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80 

        - name: database
          image: mysql
          volumeMounts:
            - name: mysql-data
              mountPath: /path

        - name: api
          image: <your-api-image>

创建文件后,您可以使用以下 Kubernetes 命令执行 pod:

kubectl create -f <file-name.yaml>

标签

一旦组织内的应用程序数量增加,管理所有这些应用程序往往会成为一场噩梦。想象一下,您只有十五个微服务和两个环境:一个用于暂存,另一个用于生产。在这种情况下,要识别所有正在运行的 pod 将会非常困难,您需要记住所有 pod 名称以查询它们的状态。

标签旨在使此任务更容易。您可以使用它们为 pod 打上易于记忆的标签名称,并且对您来说是有意义的。由于标签是键-值对,您有机会使用任何您想要的内容,包括environment:<environment-name>。让我们来看看下面的application-xyz-pod.yaml示例文件:

apiVersion: v1
kind: Pod
metadata:
    name: application-xyz
 labels:
 environment:production
 otherLabelName: otherLabelValue
spec:
    containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80 

        - name: database
          image: mysql
          volumeMounts:
            - name: mysql-data
              mountPath: /path

        - name: api
          image: <your-api-image>

粗体中的代码显示了标签如何创建。在这里,您可以添加尽可能多的标签。让我们使用以下命令创建这个 pod:

kubectl create -f application-xyz-pod.yaml 

一旦 pod 被创建,您可以使用以下命令使用标签查找它:

kubectl get pod -l environment=production

复制控制器

乍一看,人们可能会认为我们应该关心 pod,但 Kubernetes 建议使用另一个称为复制控制器的抽象。

在生产中永远不会运行一个 pod 实例。相反,您将运行许多 pod 以提供高可用性并支持所有流量。复制控制器旨在确保指定数量的 pod 正在运行。在野外运行服务通常会出现问题,有时主机会崩溃,导致一个或多个 pod 不可用。复制控制器会不断监视系统以查找此类问题,当一个 pod 崩溃时,它会自动为此 pod 创建一个新的副本,如下图所示:

复制服务和 pod

复制控制器也对推出新的应用程序版本很有用。您可以轻松关闭与特定副本关联的所有 pod,然后打开新的 pod。

让我们来看看下面的文件,它展示了一个复制控制器的示例:

apiVersion: v1
kind: ReplicationController
metadata:
    name: application-xyz-rc
spec:
    replicas: 3
    selector:
 tier:front-end    template:
        metadata: 
            label:
                env:production
        spec:
            containers:             
               ...

该文件的内容与 pod 非常相似;主要区别在于指定的 Docker 服务的种类。在这种情况下,它使用了ReplicaController值。稍后,我们将定义所需的副本数量,并且选择器部分可以用来指定标签。

使用此文件,可以通过运行kubectl create命令来创建副本,如下所示:

kubectl create -f application-xyz-rc.yaml 

您可以验证在需要时如何创建 pod。您可以使用以下命令删除一个 pod:

kubectl delete pod <pod-name>

然后,您可以使用以下命令查询可用的 pod:

kubectl get pod

服务

在生产中通常会有许多复制服务来提供良好的用户体验。然而,无论此过程涉及多少主机或图像,我们都需要为所有这些功能提供一个唯一的入口点:这就是 Kubernetes 服务的目的。

Kubernetes 服务充当特定应用程序的端点和负载均衡器。由于服务位于一组复制的 pod 的前面,它将在所有可用的实例之间分发流量。

请记住,pod 和 Docker 容器是短暂的,我们不能依赖它们的 IP 地址。这就是为什么 Kubernetes 服务对于持续提供服务非常重要。

让我们看一个 Kubernetes 服务的配置文件示例:

apiVersion: v1
kind: Service
metadata:
    name: application-xyz-service
spec:
    ports: 
        port:80
        targetPort: 80
        protocol: TCP
    selector:
 tier:front-end

第 2 行的kind配置条目具有一个新值—在本例中,该值为Service。选择器指示与此服务关联的副本容器,其余的配置参数都是不言自明的。使用此文件,您可以使用kubectl create命令如下:

kubectl create -f application-xyz-service.yaml

此外,如果您不想为服务创建文件,可以直接使用以下命令公开现有的复制控制器:

kubectl expose rc application-xyz-rc

总结

在本章中,我们开始回顾容器的基本概念以及它们如何应用于 Docker,这是用于容器化应用程序的最流行的产品之一。

然后,我们学习了如何自动化这个过程,并将其作为 Java 应用程序构建过程的一部分,使用 Gradle 作为构建工具。自动化背后的主要意图是为了与 DevOps 原则保持一致;我们将在下一章节详细讨论 DevOps。在本章末尾,我们看了其他 Docker 工具,它们可以自动化开发环境中的配置过程,并学习了 Kubernetes 以及它在生产环境中的应用。在下一章中,我们将回顾 DevOps 和发布管理的概念。

第十一章:DevOps 和发布管理

DevOps 是一种重要的技术,可以帮助团队防止他们的工作变得孤立。它还有助于消除整个软件开发周期中的乏味流程和不必要的官僚主义。这种技术在整个软件开发过程中使用,从编写代码到将应用程序部署到生产环境。

本章将演示如何通过采用自动化来实现这些目标,以减少手动任务的数量,并使用自动化管道部署应用程序,负责验证编写的代码,提供基础设施,并将所需的构件部署到生产环境。在本章中,我们将审查以下主题:

  • 孤立

  • DevOps 文化动机

  • DevOps 采用

  • 采用自动化

  • 基础设施即代码

  • 使用 Spring Framework 应用 DevOps 实践

  • 发布管理管道

  • 持续交付

孤立

几年前,软件行业使用瀑布模型来管理系统开发生命周期(SDLC)。瀑布模型包括许多阶段,如收集需求、设计解决方案、编写代码、验证代码是否符合用户需求,最后交付产品。为了在每个阶段工作,创建了不同的团队和角色,包括分析师、开发人员、软件架构师、QA 团队、运维人员、项目经理等。每个角色都负责产出并将其交付给下一个团队。

使用瀑布模型创建软件系统所需的步骤如下:

  1. 分析师收集软件需求

  2. 软件架构师仔细审查需求,并扩展文档,提供有关将使用的工具和技术、必须编写的模块以创建系统、显示组件如何连接以作为整体运行的图表等信息

  3. 开发人员按照架构师发布的指令编写应用程序

  4. QAs 必须验证创建的软件是否按预期工作

  5. 运维团队部署软件

从这些步骤中可以看出,在每个阶段,不同的团队正在产出明确定义的产出,并将其交付给下一个团队,形成一个链条。这个过程完美地描述了团队使用孤立心态的工作方式。

这个软件生产过程乍一看似乎不错。然而,这种方法有几个缺点。首先,在每个阶段都不可能产生完美的产出,通常会产生不完整的构件。因此,专注于自己流程的团队和部门开始对组织中其他人的工作付出较少关注。如果团队的成员对其他团队内发生的问题感到责任较小,那么在这个领域就会出现冲突的墙壁,因为每个团队都独立工作,彼此之间有几道障碍,导致沟通中断,从而破坏信息的自由流动。

如何打破孤立

在前一节中,我们看到团队如何组织以产生产出。很明显,每个团队成员基本上具有与其他团队成员相同的技能。因此,要求分析团队编写某个功能的代码或提供基础设施将应用程序部署到生产环境是不可能的。

打破孤立的第一步是创建多学科团队。这意味着团队应该有不同技能的成员,这些技能将帮助团队解决不同的问题和需求。

理想情况下,每个团队成员都应具备处理任何需求的必要技能。然而,这个目标几乎是不可能实现的。

一旦你有了一个跨学科团队,你很容易会发现在同一个团队中有人以信息孤岛的方式工作。为了解决这个问题,你需要制定一个计划,让每个成员都能够将更多的技能纳入他们的技能组合中。例如,你可以让开发人员与 QA 专家一起使用配对编程技术。这样,开发人员将学习 QA 专家的思维方式,而 QA 将获得开发技能。

跨学科团队在整个软件开发生命周期的各个阶段创建了协作的环境。

DevOps 文化

对于 DevOps 有很多定义。我们将使用以下定义:

“DevOps 是一种鼓励运营和开发团队共同合作的文化,而不会削弱每个团队具有的特定技能和责任。”

这意味着软件开发团队要对他们所产生的代码负责和拥有权。DevOps 改变了人们在软件开发生命周期中的组织方式和他们遵循的流程。

这种文化消除了信息孤岛,因为它要求所有角色都参与到软件开发生命周期中,并共同合作,如下图所示:

打破组织中的信息孤岛

动机

为了理解采用 DevOps 的动机,让我们看一个在开发软件的公司和组织中经常遇到的常见现实场景。

假设我们在一家尚未采用 DevOps 或持续集成CI)和持续部署CD)实践的公司工作。让我们想象一下,这家公司有以下团队负责发布一个功能或新软件:

  • 开发团队:该团队使用代表新功能或错误修复的分支将代码编写并提交到源代码版本控制系统中。

  • 运维团队:该团队在不同的环境中安装构件,例如通过测试和生产。

  • QA 团队:该团队验证所产生的构件从最终用户和技术角度是否按预期工作,并批准或拒绝所产生的代码。

每当开发人员发布功能和错误修复时,都会重复这个常见的流程。在首次经历这个常见流程时,我们意识到存在一些问题,包括以下问题:

  • 不同的环境:代码开发的环境通常与暂存和生产环境具有不同的环境和配置。

  • 沟通:基于 DevOps 实践形成跨学科团队将帮助我们打破组织中的信息孤岛。否则,团队之间缺乏沟通是通过会议、电话会议和/或电子邮件解决的。

  • 不同的行为:在生产环境中产生的错误数量与在开发环境中产生的错误数量不同。也有一些情况下错误根本无法重现。

正如我们所看到的,这里有几个问题需要解决。让我们看看如何解决上述每一个问题:

  • 不同的环境:通过基础设施即代码实践,我们可以创建文件,使每个环境都能够使用不可变服务器,这是我们将在关于基础设施即代码的未来部分中讨论的概念。

  • 沟通:基于 DevOps 实践形成跨学科团队将帮助我们打破组织中的信息孤岛。

  • 不同的行为:使用基础设施即代码方法,我们将能够创建不可变服务器,保证不同环境(如开发、测试和生产)中的相同行为。

  • 上市时间:应用持续交付CD)使我们能够尽快将新功能部署到生产环境。

这些都是现实场景中常见的问题,这就是为什么一些组织正在采用 DevOps。这从打破信息孤岛开始,对开发团队有很多好处。例如,它允许他们尽快部署,减少错误。它还允许他们快速应对变化,使流程更加高效。因此,我鼓励您的组织打破信息孤岛,变得敏捷,以快速生产高质量的应用程序。

DevOps 采用

DevOps 的采用符合组织释放应用程序更快的需求,最小化与将软件交付到生产环境相关的错误和风险。作为这一过程的一部分,我们需要增加自动化测试应用程序的流程数量,并强烈建议我们去除手动流程,以避免人为干预,这可能导致错误的产生。

可以自动化的一些流程包括环境配置和部署流程。让我们来看一下 SDLC 的改进:

瀑布方法与敏捷方法和 DevOps

然而,为了更快地交付软件,我们必须解决一些问题。首先,我们需要拥抱自动化文化。自动化文化迫使我们使用许多工具,我们将在下一节介绍,并且我们需要理解,由于微服务的崛起,DevOps 已经成为我们流程的一个重要部分,因为微服务具有更复杂和分布式的系统。然而,不要忘记,DevOps 的主要目标是合作,而不仅仅是自动化。

拥抱自动化

拥抱自动化是 DevOps 采用的关键因素之一。有几种工具可以帮助我们进行这一过程。

我们需要找到帮助我们在整个 SDLC 的所有阶段自动化流程的工具。这些阶段如下图所示:

组织中的流水线

在组织内,流水线的设计旨在保持软件交付流程简单。第一步是识别不同的阶段,就像我们在前面的图表中所做的那样,然后我们应该选择合适的工具,让我们能够自动化每个阶段。让我们回顾一下各个阶段和与每个阶段相关的工具/软件:

  • 代码(Git、SVN 等)。

  • 构建(Maven、Gradle、npm 等)。

  • 测试自动化。这也可能包括集成测试(JUnit、Postman、Newman、JFrog、Selenium、Cucumber、Gherkin 等)。

  • 部署(Ansible、Vagrant、Docker、Chef、Puppet 等)。

  • 监控(我们将在第十二章中深入讨论监控)。

  • 持续集成和持续部署(Jenkins、Hudson 等)。

  • 代码分析(Sonatype、Jacoco、PMD、FindBugs 等)。

正如我们在第十章中所学到的,容器化您的应用程序,我们知道如何基于容器提供环境,并且我们需要理解,我们创建的示例也可以应用于基础设施即代码的概念,我们将在下一节中讨论。

基础设施即代码

基础设施即代码是指创建文件以及环境定义和程序的过程,这些将用于配置环境。DevOps 概念开始使用这些脚本或文件存储库与代码一起,以便我们可以确定哪些代码将部署在哪个环境中。使用这些实践,我们可以确保所有服务器和环境是一致的。

一个典型的组织或团队将在多个环境中部署他们的应用程序,主要用于测试目的。当我们有开发、暂存和生产环境时,开发人员面临的最大问题是每个环境都不同,需要不同的属性。

这些属性可能包括以下配置,以及其他配置:

  • 服务器名称

  • IP 地址和端口号

  • 服务器队列连接

  • 数据库连接

  • 凭据

软件开发的现代时代突然给我们带来了在构建基础设施时的可测试性、可重复性和透明度。如今的一个关键目标是在本地或云环境中仅使用物理服务器资源重新创建或构建完整的软件环境。

作为其结果,我们应该能够创建数据库实例,用脚本或备份文件中的初始数据填充它们,并重新构建我们的源代码以创建可以随时部署的构件。

有许多工具可以用来应用基础设施即代码的概念:

  • 对于配置同步,我们可以使用 Chef、Puppet 或 Ansible

  • 对于容器化服务器,我们可以使用 Docker 部署新的应用程序版本

我们将要拥抱的一些关键好处如下:

  • 不可变服务器,通过在基础设施中重建服务器来应用更改,而不是修改现有服务器

  • 对基础设施进行更改的测试,这涉及使用文件在我们应用程序和基础设施的不同阶段进行测试来复制环境

以下图表显示了重新创建每个阶段环境的这两个关键好处的主要思想:

不可变基础设施

自动化的服务器配置过程给我们带来以下好处:

  • 可以自动重新创建任何环境或服务器

  • 配置文件可以存储凭据或自定义配置,每个环境可能不同

  • 在不同阶段环境将始终相同

在接下来的部分,我们将创建一些基础设施即代码的示例。

Spring 应用程序和 DevOps 实践

Spring 提供了与 DevOps 原则一致的开箱即用功能。让我们看看其中一些。

首先,我们将使用start.spring.io上提供的 Spring Initializr 创建一个新的 Spring Boot 应用程序。

支持不同环境

在交付应用程序的常见场景是我们在开发环境(几乎总是我们自己的计算机)上编写应用程序,然后将应用程序部署在不同的测试和生产环境中。Spring 配置文件允许我们在每个环境中使用不同的配置。我们可以使用本地配置文件作为应用程序的一部分,然后稍后,我们可以使用环境变量覆盖这些配置值。这通常是因为我们在部署配置的每个环境中使用不同的凭据和配置。

在为我们需要部署应用程序的每个不同环境创建不同的 Spring 配置文件之前,我们将在/main/resources/static文件夹后面添加一个index.html静态页面,标签如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Welcome devops</title>
</head>

在接下来的步骤中,我们将展示 Spring 在 DevOps 方面提供的一些功能。我们还将完成一个练习,为 Docker 容器提供将配置为支持不同环境(如开发、测试和生产环境)的层。

首先,我们将为我们的应用程序创建一个不同的配置文件。例如,我们可以使用三个文件分别命名为application-dev.propertiesapplication-test.propertiesapplication-production.properties/infra-as-code/src/main/resources文件夹中创建不同的配置文件,用于开发、测试和生产:

为了了解 Spring 配置文件的工作原理,我们将更改应用程序正在使用的端口。用于配置端口的属性是server.port。让我们按照以下方式更改我们拥有的每个不同文件的值:

application-dev.properties

server.port = 8090

application-test.properties

server.port = 8091

application-production.properties

server.port = 8092

选择配置文件

在运行支持不同配置文件的应用程序之前,您需要选择要使用的配置文件。可以使用 JVM 参数spring.profiles.active标志来选择配置文件,如下所示:

$ java -Dspring.profiles.active=dev -jar target/infra-as-code-0.0.1-SNAPSHOT.jar

最后,您可以使用与提供的配置文件相关联的端口在浏览器中检查应用程序。spring.profiles.active标志的有效值如下:

  • dev

  • production

  • test

如果您没有为该标志提供任何值,则将使用application.properties中的配置。

这是一个探索 Spring 中配置文件的简单示例。请记住,使用配置文件,我们还可以配置数据源、队列、bean 以及您需要的任何内容。您始终可以使用环境变量覆盖任何提供的配置变量。

此外,正如我们在第十章中看到的,容器化您的应用程序,我们可以将 Spring Boot 应用程序 docker 化,并借此了解不可变服务器以及如何测试基础架构更改。

在本节中,我们将学习使用 Vagrant (www.vagrantup.com/)版本 1.7.0 或更高版本重新创建基础架构的类似方法。这可能需要虚拟化软件(例如 VirtualBox:www.virtualbox.org/)。

另一个可以执行相同任务的工具是 Ansible (ansible.com/),本章不涉及该工具。

Vagrant

Vagrant 是一个旨在重新创建虚拟环境的工具,主要用于开发。其功能基于 VirtualBox,并且可以使用诸如 Chef、Salt 或 Puppet 之类的配置工具。

它还可以与不同的提供者一起使用,例如 Amazon EC2、DigitalOcean、VMware 等。

Vagrant 使用一个名为Vagrantfile的配置文件,其中包含所有需要配置所需环境的配置。一旦创建了上述配置文件,就可以使用vagrant up命令使用提供的指令安装和配置环境。

Vagrant 必须在继续之前安装在机器上。要做到这一点,请按照工具的文档中提供的步骤进行操作www.vagrantup.com/intro/getting-started/install.html

使用 Vagrant 工作

现在,我们将在应用程序的根目录中创建一个Vagrantfile配置文件来创建一个简单的环境。我们将提供一个 Linux 发行版环境,即 Ubuntu。Vagrantfile的内容如下:

# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|

  config.vm.box = "hashicorp/precise32"

  config.vm.network :forwarded_port, guest: 8090, host: 8090
  config.vm.network "public_network", ip: "192.168.1.121"
  #config.vm.synced_folder "target","/opt"

  config.vm.provider "virtualbox" do |vb|
    vb.customize ["modifyvm", :id, "--memory", "2048"]
  end

  # provision
  config.vm.provision "shell", path:"entrypoint.sh"

end

请注意Vagrantfile的第 6 行:

config.vm.box = "hashicorp/precise32"

我们正在从已构建的 VM box hashicorp/precise32 创建我们的 Linux 环境。

在继续使用 Vagrant 提供环境之前,我们将创建一个ssh文件,该文件将为我们安装 JDK 8。在项目的根目录下,创建一个名为entrypoint.sh的文件,内容如下:

#!/usr/bin/env bash
sudo apt-get update

echo "Install Java 8.."
sudo apt-get install -y software-properties-common python-software-properties

echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | sudo /usr/bin/debconf-set-selections
sudo add-apt-repository ppa:webupd8team/java -y

sudo apt-get update

sudo apt-get install oracle-java8-installer
echo "Set env variables for Java 8.."
sudo apt-get install -y oracle-java8-set-default

# Start our simple web application with specific JVM_ARGS and SPRING_PROFILE
echo "Run our springboot application."
java -Dspring.profiles.active=dev -jar /vagrant/target/infra-as-code-0.0.1-SNAPSHOT.jar

然后,为了创建虚拟机并提供 VM,我们将在控制台上运行以下命令:

vagrant up

在第一次尝试时,下载盒子和配置服务器将需要一些时间。在这些过程之间,您将被问及要使用哪个网络接口来配置您的服务器,问题是网络桥接到哪个接口?。然后您可以选择对您的机器更方便的选项。

在我们的执行的整个输出结束时,我们将在配置的服务器上看到我们的 Spring 应用程序正在运行,如下图所示:

现在我们可以在浏览器中检查我们的应用是否在端口8090http://localhost:8090/)上运行。您可以通过以下命令访问ssh来检查 Vagrant 中运行的 Java 进程:

vagrant ssh

这将在我们的配置服务器上打开一个ssh会话,让我们可以在控制台中看到已经创建的进程:

vagrant@precise32:~$ ps aux | grep java

结果的输出将是我们正在运行的 Java 进程,如下图所示:

要停止虚拟机,可以在控制台中使用vagrant halt命令:

vagrant halt

要销毁创建的虚拟机,可以输入以下内容:

vagrant destroy

我们刚学会使用 Vagrant 将基础设施表示为代码。我们可以使用不同的工具为不同阶段创建所需的环境或服务器;我们可以在上一章中回顾这一点。在下一节中,我们将创建发布管理过程的示例。

发布管理

要将您的代码带到生产环境,必须计划好这个过程。

这个规划过程称为发布管理。在整个过程中,我们需要关注现有服务的完整性和一致性,确保我们系统的运行。

为了了解发布管理过程中涉及的步骤,我们将看一下以下概念:

  • 流水线

  • 持续集成

  • 持续交付和持续部署

流水线

流水线是我们必须经历的一系列步骤来实现目标。我们在第七章中看过这个概念,管道和过滤器架构。在这个上下文中,相同的概念用于执行我们发布管理过程中的一系列步骤。流水线将在不同环境中协助我们进行软件交付过程。我们将创建一个由五个阶段组成的简单流水线:

  • 自动构建我们的项目

  • 运行测试(如单元测试和集成测试)

  • 部署到暂存环境

  • 运行验收测试

  • 部署到生产环境(包括在云端或本地服务器上部署我们的应用程序)

以下图表显示了流水线的外观:

CI/CD 流水线

每个阶段可能有一个或多个任务或作业,例如创建数据库模式,使用 Vagrant 为盒子进行配置,克隆 Docker 容器等。

上述图表分为两部分:

  • 持续集成

  • 持续部署

在接下来的几节中,我们将简要介绍这两个概念。

持续集成

持续集成(CI)是指开发人员尽可能经常将他们生成的代码合并到主分支中的做法。合并的代码应该没有错误,并且还应该为业务提供价值。

使用 CI,我们可以通过运行一组自动化测试来自动验证提交的代码。当我们使用这种做法时,我们正在处理一个 CI 代码库,避免了过去在安排特定日期和时间发布构建时出现的问题。

采用 CI 方法,最重要的目标是自动化测试,以确保每次将新提交推送到主源代码分支时应用程序都不会出现故障。

持续交付和持续部署

CD 是基于 CI 的一个过程。作为 CD 过程的一部分,我们需要其他步骤,这些步骤是将应用程序部署到生产环境所需的,包括配置和提供服务器(基础设施即代码)、验收测试以及为生产环境准备构建。

在生产环境中进行部署时,持续部署过程与持续交付过程不同,不需要人类干预。

现在,我们将创建一个基于我们简单流水线的示例。为了专注于 CI 和 CD 的流程,我们将使用上一章节的Docker Compose部分中创建的项目,该部分向您展示了如何将应用程序容器化。该项目包括一个完整的环境,已经准备好使用,并且已经包含了自动化测试。

自动化流水线

如前所述,我们将需要几个工具来自动化我们示例的流水线。为此,我们将使用以下工具:

  • 我们的代码的 GitHub 存储库:我们可以将我们的代码推送到存储库并创建一个自动启动构建和测试的合并

  • 使用 Gradle 或 Maven 构建我们的项目

  • 使用 Junit、Postman 和 Newman 进行自动化测试

  • 使用 Docker 部署到容器中的 Jenkins 作为我们的 CI 和 CD 的自动化服务器

首先,我们将把我们的代码推送到存储库。为此,我们将使用 GitHub。如果还没有,请创建一个帐户。

打开终端并转到我们应用程序的根文件夹。为了方便起见,我们将从我们的机器上推送存储库,因此我们将初始化我们的项目作为存储库。在命令行中执行以下操作:

$ git init

命令的输出将如下所示:

Initialized empty Git repository in /Users/alberto/TRABAJO/REPOSITORIES/banking-app/.git/

然后,我们将把所有文件添加到一个新的本地存储库中,如下面的代码所示:

$ git add –A

现在我们将在本地提交我们的代码,如下面的代码所示:

$ git commit -m initial

我们本地提交的输出将打印以下初始行:

[master (root-commit) 5cc5f44] initial  40 files changed, 1221 insertions(+)

要推送我们的代码,我们需要在 GitHub 帐户中创建一个存储库。我们可以通过转到存储库部分,点击绿色的创建存储库按钮,并填写存储库的名称和描述来创建一个新的存储库,如下面的屏幕截图所示:

创建一个 GitHub 存储库

现在我们有了我们存储库的 URL,例如https://github.com/$YOUR_GITHUB_USER/bank-app。我们创建的存储库的结果将如下屏幕截图所示:

GitHub 存储库

根据 GitHub 给出的说明,我们现在需要使用命令行将我们的代码推送到存储库:

$ git remote add origin https://github.com/lasalazarr/banking-app.git

然后,我们将从本地存储库推送我们的更改到我们的 GitHub 存储库,如下面的代码所示:

$ git push -u origin master

现在我们可以在我们的 GitHub 帐户存储库上查看我们的代码,并根据建议添加一个README文件来解释应用程序的目的。

在下一节中,我们将在继续练习之前先看一下 CI 服务器的概念。

Jenkins

Jenkins 是一个负责自动化我们流水线的持续集成服务器。在与我们的 Git 存储库集成以自动构建我们的应用程序之前,让我们先回顾一下 CI 服务器背后的关键概念:

  • 流水线: 流水线由一系列按顺序发生的步骤组成。流水线也是我们可以并行执行任务的地方。

  • 作业: 这是一个小的工作单元,例如运行测试拉取我们的代码

  • 队列: 这代表了 CI 服务器在有能力运行时将运行的所有排队作业。

  • 插件: 这些是我们可以添加到我们的 CI 服务器的功能。例如,我们可以使用一个插件连接到我们的 Git 存储库。

  • 主/从: 主机可以将工作委派给从机器来扩展我们的 CI。

Jenkins 有不同的分发方法。我们可以在jenkins.io/download/上查看更多关于这个项目的细节。在我们的示例中,我们将使用一个准备好的 Docker 镜像。

由于我们已经安装了 Docker,我们可以通过运行以下命令在命令行中拉取 Jenkins 镜像:

$ docker pull jenkins/jenkins

现在我们可以通过运行以下命令来查看我们的镜像:

$ docker images

现在我们将通过在命令行中运行以下命令来从容器中运行我们的 Jenkins 主服务器:

$ docker run -p 8080:8080 -p 50000:50000 -v jenkins_home:/var/jenkins_home jenkins/jenkins:lts

注意控制台输出的生成的管理员密码,如下截图所示:

生成 Jenkins 密码

我们现在可以看到我们的 Jenkins 服务器正在使用http://localhost:8080/运行。

第一步是粘贴我们刚在控制台上看到的管理员密码,如下截图所示:

解锁 Jenkins

现在我们将安装建议的插件,这将需要一些时间。然后我们将继续创建一个管理员用户和 URL 的过程。

我们将启用构建触发,因此我们将配置我们的 Jenkins 实例以接收来自 GitHub 的推送通知。为此,请按照以下步骤进行:

  1. 转到 Jenkins 主页(http://localhost:8080),然后点击左侧菜单中的New item图标。

  2. 输入项目名称并选择自由风格项目。完成后,点击“OK”按钮,如下截图所示:

  1. Jenkins 将显示一个页面,应该在该页面上配置作业步骤。首先,输入项目的描述和 GitHub URL 存储库,如下截图所示:

  1. 输入您的 GitHub 用户帐户的凭据,如下截图所示:

  1. 最后,在页面底部选择 Gradle 作为项目的构建工具:

创建的作业可以配置为在每次向 GitHub 提交代码时触发。该作业将下载代码,运行测试,并使用 Gradle 生成可部署的工件(JAR 文件)。您可以在此作业中添加额外的步骤来在 Docker Hub 中构建、标记和推送 Docker 镜像,然后自动部署到本地或基于云的服务器。

总结

在本章中,我们熟悉了 DevOps 文化的含义以及它如何影响组织的流程。我们还学习了如何自动化服务器的仪器化过程,使用基础设施即代码等技术来实现自动化。此外,我们学习了如何构建能够从存储库获取最新实施功能、验证代码、在不同层面运行测试并将应用程序推向生产的流水线。在下一章中,我们将探讨围绕应用程序监控的关注点,看看为什么关心它们如此重要。

第十二章:监控

一旦应用程序部署到生产环境中,监控就是其中一个关键方面。在这里,我们需要控制不常见和意外的行为;了解应用程序的工作方式至关重要,这样我们就可以尽快采取行动,以解决任何不希望的行为。

本章提供了一些建议,涉及可用于监视应用程序性能的技术和工具,考虑到技术和业务指标。

在本章中,我们将涵盖以下主题:

  • 监控

  • 应用程序监控

  • 业务监控

  • 监控 Spring 应用程序

  • APM 应用程序监控工具

  • 响应时间

  • 数据库指标

  • JVM 指标

  • Web 事务

监控

每个应用程序都是为了解决特定的业务需求和实现特定的业务目标而创建的,因此定期评估应用程序以验证是否实现了这些目标至关重要。作为这一验证过程的一部分,我们希望使用可以为我们提供以下因素的见解的指标来衡量我们应用程序的健康状况和性能:

  • 应用程序监控:当我们谈论应用程序的健康状况时,了解正在使用的资源量,例如 CPU、内存消耗、线程或 I/O 进程,是很重要的。识别潜在的错误和瓶颈对于知道我们是否需要扩展、调整或重构我们的代码是很重要的。

  • 业务监控:这些指标有助于了解有关业务本身的关键业务指标。例如,如果我们有一个在线商店,我们想知道我们是否实现了既定的销售目标,或者在银行应用程序中,我们想知道在某个分支机构、渠道等处收到了多少交易和客户。

我们将使用在第五章中创建的银行应用程序,模型-视图-控制器架构,作为一个示例,列出一些可以应用于它的监控概念。让我们开始展示如何使用 Spring 框架带来的开箱即用的工具来监视上述应用程序。

监控 Spring 应用程序

Spring 框架具有一些内置功能,用于监视和提供指标以了解应用程序的健康状况。我们有多种方法可以做到这一点,因此让我们来审查其中一些:

  • 我们可以使用一种老式的方法,即围绕方法创建拦截器来记录我们想要记录的一切。

  • Spring 执行器可以与 Spring Boot 应用程序一起使用。使用此库,我们可以查看应用程序的健康状况;它提供了一种通过 HTTP 请求或 JMX 监视应用程序的简单方法。此外,我们可以使用工具对生成的数据进行索引,并创建有助于理解指标的图表。有很多选项可以创建图表,包括:

  • ELK Stack(ElasticSearch、Logstash 和 Kibana)

  • Spring-boot-admin

  • Prometheus

  • Telegraph

  • Influx 和

  • Graphana 等等

Spring 执行器可以作为现有 Spring Boot 应用程序的一部分集成,将以下依赖项添加为build.gradle文件的一部分:

compile('org.springframework.boot:spring-boot-starter-actuator')

如果我们使用Maven,我们将在pom.xml文件中添加以下依赖项:

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

执行器支持许多配置,这些配置必须在application.properties文件中提供。我们将向该文件添加一些属性,以提供元数据,例如应用程序的名称、描述和版本。此外,我们将在另一个端口上运行执行器端点,并禁用安全模型:

info.app.name=Banking Application Packt
info.app.description=Spring boot banking application
info.app.version=1.0.0
management.port=8091
management.address=127.0.0.1
management.security.enabled=false

然后,在运行应用程序之后,执行器提供的一些端点将可用。让我们来审查其中一些:

  • 健康:此端点在http://localhost:8091/health URL 中提供有关应用程序健康状况的一般信息:

健康端点结果

  • 信息:此端点提供有关应用程序元数据的信息,该信息先前在application.properties文件中进行了配置。信息可在http://localhost:8080/info上找到:

信息端点结果

  • 指标:提供有关操作系统、JVM、线程、加载的类和内存的信息。我们可以在http://localhost:8080/metrics上查看此信息:

指标端点结果

  • 跟踪:提供有关最近对我们应用程序发出的请求的信息。我们可以在http://localhost:8080/trace上查看此信息:

跟踪端点结果

如果我们想要查看所有端点,可以在 spring 的官方文档中找到:docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#production-ready-endpoints

正如我们在执行器库中看到的那样,我们可以在某个时间获得应用程序的快照,了解应用程序的状态和健康状况,甚至追踪最常用的端点。

有时,提供的信息就足够了。如果您希望拥有图形并检查历史数据,您应该集成我们之前提到的工具。

Spring Actuator 还提供了收集有关应用程序的自定义指标的功能;这对于收集业务指标非常有帮助。例如,如果我们正在使用一个应用程序来创建储蓄账户,我们可以收集指标来了解正在创建多少个账户。然后,在开设更多的分支机构后,我们可以看到创建了多少个账户,并了解它对业务本身的影响。

在收集业务指标时的关键因素是了解对业务而言什么是重要的。为了完成这项任务,与业务人员一起合作非常重要。

业务指标对于了解发布新功能后我们产生的影响非常有帮助。它还有助于理解意外行为或错误。想象一下,您使用不同的电子邮件提供程序推出了新的应用程序版本;您应该比较更改后传递的电子邮件数量与更改电子邮件提供程序之前传递的电子邮件数量。如果您发现这些数字有很大的差异,您需要检查发生了什么,因为差异不应该太大。如果您想了解如何创建自定义指标,我鼓励您访问此链接:docs.spring.io/spring-boot/docs/current/reference/html/production-ready-metrics.html

市场上有许多工具可供我们在不更改代码的情况下监控应用程序,这些工具被称为应用程序性能管理工具(APM)。我们将在下一节中讨论这些工具的工作原理。

应用程序性能管理(APM)工具

云端监控和工具的兴起带来了巨大的发展;有一些工具和公司专门致力于 APM 工具。其中一些基于 JVM 和字节码仪器,如今这些工具甚至可以测量我们应用程序的用户体验。目前最受欢迎的工具有以下几种:

所有这些工具都使我们能够监视我们的应用程序层、健康状况(CPU、内存、线程、I/O)、数据库和顶级 SQL 查询。它们还允许我们检测瓶颈、业务指标和响应时间。例如,我们将使用 New Relic 监视我们的应用程序。

New Relic

New Relic 是一个为我们整个环境提供仪表化的工具,而不仅仅是我们的应用程序。因此,我们可以监视我们应用程序的整个环境,包括数据库、应用程序服务器、负载均衡器等因素。

例如,我们将在以下链接创建一个试用账户(newrelic.com/signup)。注册了 New Relic 账户后,您将被引导到控制面板,如下截图所示:

我们将按以下步骤继续这个过程:

  1. 选择监视应用程序并接受 14 天免费试用:

  1. 选择 Java 应用程序选项:

  1. 生成许可证密钥,下载并安装代理。在这里,我们将在应用程序的根目录中创建一个名为newrelic的文件夹,并复制最近下载的 ZIP 文件的内容:

  1. 现在,我们将用我们的密钥许可证和应用程序名称替换newrelic.yml,如下截图所示:

  1. 重新启动您的应用程序,包括javaagent参数,如下所示:
-javaagent:/full/path/to/newrelic.jar
  1. 在我们的情况下,使用代理运行应用程序将如下所示:
java -javaagent:newrelic/newrelic.jar -jar build/libs/banking-app-1.0.jar

最后,我们可以看到我们的新遗物仪表板,与我们在newrelic.yaml文件中定义的名称相同(Banking App Monitoring Packt)。这将包含我们应用程序的所有信息:

您还可以多次导航到应用程序,以查看 APM 提供的更多数据。

然后,我们可以深入了解提供的信息,包括以下内容:

  • 响应时间:

  • 数据库指标:

  • JVM 指标:

  • Web 交易:

您可以从左侧菜单中探索所有选项卡,以查看我们应用程序的更多指标。正如我们所学到的,有了所有这些工具,我们可以确保应用程序的健康,并检查我们是否摆脱了问题和瓶颈。然后,您可以继续探索 APM。

摘要

在本章中,我们学习了如何从技术和业务角度收集有用的指标。我们还学习了如何使用 APM 来监视我们的环境,并获取我们需要的信息,以了解最常用交易的健康状况、状态和统计信息,包括我们应用程序的响应时间。所有这些信息将帮助我们在生产中维护我们的应用程序,并迅速应对任何可能的性能问题。

在下一章中,我们将审查安全实践以及如何使用 Spring 编写它们。

第十三章:安全

安全是开发团队在开发产品时经常忽视的领域。开发人员在编写代码时应牢记一些关键考虑因素。本章列出的大多数考虑因素都是显而易见的,但也有一些不是,因此我们将讨论所有这些考虑因素。

本章将涵盖以下主题:

  • 为什么安全作为应用程序架构的一部分很重要

  • 保持软件安全的关键建议:

  • 认证和授权

  • 加密

  • 数据输入验证

  • 敏感数据

  • 社会工程学

  • 渗透测试

  • 认证作为服务

我们将首先介绍安全作为应用程序架构的重要性。

为什么安全作为应用程序架构的一部分很重要

在过去的几年里,我看到许多组织或公司在已经投入生产后才审查其软件安全问题的案例。这通常发生在他们的系统面临安全问题或业务因停机或数据泄露而损失资金时。

众所周知,安全问题和流程应作为软件开发生命周期(SDLC)的一部分。由于安全是应该考虑的每个应用程序的一个方面,因此必须确保我们的应用程序和代码具有安全约束,使我们能够在所有阶段(设计、开发、测试和部署)对我们的软件感到自信:

安全作为 SDLC 的一部分

我们的主要目标应该是在将应用程序交付到生产环境之前防止其被 compromise。这样可以避免暴露敏感数据,并确保应用程序在设计时考虑了可能的漏洞。理想情况下,我们应该在系统被客户使用之前解决所有安全问题。作为开发人员,我们大多数时候只收到功能需求。然而,有时我们并没有收到安全需求。在开发我们的代码和应用程序时,我们必须像关注性能、可扩展性和其他非功能性需求一样关注安全性。

编写旨在避免安全威胁的软件时需要牢记的一些关键方面如下:

  • 系统很难解密

  • 系统安全应该在 SDLC 的每个阶段进行测试。

  • 应对应用程序执行渗透测试

  • 系统应确保端到端的安全通信

  • 应用程序代码中应用反网络钓鱼实践

在下一节中,我们将提供一系列应该遵循的建议,以解决在 SDLC 过程中的安全问题。

关键安全建议

有几种类型的攻击可以针对系统或网络,并可用于建立通信。常见的例子包括病毒、恶意软件、网络钓鱼、定向网络钓鱼、拒绝服务(DoS)等。每年都会发现更多复杂的攻击,目标各异。在本节中,我们将重点关注保护 Web 和移动应用程序的代码和环境的关键安全建议。

有几种可以用来确保 Web 和移动应用程序安全的流程和模型。在接下来的章节中,我们将探讨保护软件免受常见安全威胁的主要建议。

认证和授权

认证的最简单定义是验证用户身份的过程;授权是验证经过身份验证的用户可以做什么的过程。例如,当我们在计算机上以用户身份登录时,我们被授予访问权限,允许我们对可用资源执行操作(包括文件、应用程序等)。

在我们创建的应用程序中,身份验证是验证对应用程序的访问权限的过程,授权是保护我们的资源的过程,如页面、网络服务、数据库、文件、队列等。在身份验证过程中,我们验证使用应用程序的人的身份。身份验证包括诸如在提供有效凭据之前防止对我们应用程序的访问、多因素身份验证(如安全图像)、一次性密码(OTP)、令牌等过程。

关于实现,我们已经在之前的章节中使用了 Spring Security 创建了一些应用程序示例,Spring Security 是一个可扩展的框架,可用于保护 Java 应用程序。Spring Security 也可以用于处理身份验证和授权,使用一种对我们现有代码不具有侵入性的声明式样式。

今天,有几个身份行业标准、开放规范和协议,规定了如何设计身份验证和授权机制,包括以下内容:

  • 基本身份验证:这是最常见的方法,涉及在每个请求中发送用户名和密码。我们已经在我们的银行应用示例中使用了 Spring Security 实现了这种方法,我们在第十章 容器化您的应用程序,第十一章 DevOps 和发布管理和第十二章 监控中使用了它。

  • JSON Web TokensJWT):这是一个开放标准,定义了如何建立一个安全的机制,在两个参与者之间安全地交换消息(信息)。这里有几个经过充分测试的库可供使用,我们在第四章 客户端-服务器架构中创建了一个示例。该序列可以如下所示:

JWT 身份验证流程

如前所述,前面的序列图可以帮助我们理解令牌验证的过程。对于身份验证,客户端应该将其凭据发送到服务器,服务器将以字符串形式响应一个令牌。这个令牌应该用于后续的请求。当它们被执行时,如果提供的令牌无效或过期,我们将从服务器收到 401 未经授权的状态代码。否则,请求将成功。我们之前提到的身份验证机制遵循基本身份验证模型,这是 Web 应用程序的首选。然而,当您编写 API 时,您将需要其他方法,以处理基于令牌使用的安全性(如 JWT)。如果您不编写 API,您的应用程序可以使用 JSON Web Tokens RFC(tools.ietf.org/html/rfc7519)进行安全保护。

今天,这是验证移动应用程序、现代单页应用程序(SPA)和 REST API 的最常见方法。

让我们回顾一些围绕使用令牌的身份验证机制创建的标准:

  • OAuth开放授权):这是一种基于令牌的身份验证和授权的开放标准,可以使用第三方参与者委托身份验证过程。只有在您有三方:您自己、您的用户和需要您的用户数据的第三方应用程序开发人员时,才应使用此标准。

  • OAuth 2:这是 OAuth 标准的更发达版本,允许用户在不提供凭据的情况下授予有限访问权限,以将资源从一个应用程序转移到另一个应用程序。每当您使用 Google 或 GitHub 帐户登录网站时,都应该使用此标准。这样做时,您将被问及是否同意分享您的电子邮件地址或帐户。

  • 完整请求签名:这是由 AWS 身份验证推广的,也在第九章中探讨了无服务器架构,当我们演示将函数作为服务FaaS)部署到 AWS 时。我们使用这个概念通过在服务器和客户端之间共享一个秘密。客户端使用共享的秘密对完成的请求进行签名,服务器对其进行验证。有关更详细的信息,请访问docs.aws.amazon. com/general/latest/gr/sigv4_si gning.html

密码学

密码学是将文本信息转换为不可理解的文本,反之亦然:从加密文本到可理解的文本。在我们的应用程序中,我们使用密码学来创建数据的保密性并保护它免受未经授权的修改。

我们使用加密来加密客户端和服务器之间的通信。这是通过使用传输层安全(TLS)协议的公钥加密来完成的。TLS 协议是安全套接字层(SSL)协议的后继者。

数据输入验证

数据输入验证是指控制每个集成或层中接收的数据的过程。我们需要验证数据输入,以避免在系统中创建任何不一致性。换句话说,我们应该验证应用程序中的数据是一致的,并且不会遇到与 SQL 注入、资源对应用程序或服务器的控制等问题。更高级的技术包括白名单验证和输入类型验证。

敏感数据

这种做法涉及保护敏感数据并确定如何以正确的方式进行。数据敏感性涉及使用加密来保护数据的机密性或完整性和冗余。

例如,通常在我们的应用程序用于连接到数据库的密码中使用无意义的文本,因此我们通过保持凭据加密来使这个建议准确。另一个例子可能涉及在银行应用程序上工作并需要呈现信用卡号。在这种情况下,我们会加密该数字,甚至可能掩盖该数字,使其对人类不可读。

社会工程

为了帮助您理解什么是社会工程,我们将提供一个简单的定义;即,对一个人的心理操纵,以便该人提供机密信息。

以这个定义为起点,社会工程已经成为应用程序难以控制的安全问题。这是因为失败的关键在于用户是一个人类,有能力被分析和操纵,以便交出秘密信息或凭据,从而可能访问系统。

OWASP 十大

开放式 Web 应用程序安全项目(OWASP)十大列出了 Web 应用程序中最重要的十个安全风险,并由 OWASP 组织每三年发布和更新一次。我们需要遵循 OWASP 清单,以确保我们的 Web 应用程序不会留下安全漏洞。清单可以在www.owasp.org/images/7/72/OWASP_Top_10-2017_%28en%29.pdf.pdf.找到。

2017 年发布的最新清单包括以下方面:

  • A1: 注入

  • A2: 身份验证和会话管理出现问题

  • A3: 跨站脚本XSS

  • A4: 不安全的直接对象引用

  • A5: 安全配置错误

  • A6: 敏感数据暴露

  • A7: 缺失功能级访问控制

  • A8: 跨站请求伪造CSRF

  • A9: 使用已知漏洞的组件

  • A10: 未经验证的重定向和转发

要测试和验证其中几个漏洞,我们可以使用 Burp 套件(portswigger.net/burp)。这个过程很容易理解,并且将检查应用程序中大多数已知的安全漏洞。作为一个工具,Burp 随 Kali Linux 发行版一起提供,我们将在下一节中解释。

渗透测试

渗透测试(pen test)是对系统进行模拟攻击以评估其安全性。对于这个测试,我们可以使用像 Kali Linux(www.kali.org/)这样的工具,它是一个基于 Debian 的 Linux 发行版,具有用于验证 OWASP 前 10 名等多种工具的渗透测试平台。

Kali 有一个广泛的工具列表,可用于多种用途,如无线攻击、信息收集、利用和验证 Web 应用程序等。如果您想查看详细的工具列表,请访问以下链接:tools.kali.org/tools-listing。团队在将应用程序交付到生产环境之前应提供渗透测试。

在下一节中,我们将创建一个基于 Spring Security 的 Java 应用程序。我们将使用 Auth0 作为身份验证和授权服务平台,这是一个基于 OAuth2 标准和 JWT 的第三方授权。

身份验证和授权作为服务

我们将使用 Auth0 作为身份验证和授权服务的提供者。我们将创建一个示例来保护我们的应用程序;您不必是安全专家才能做到这一点。以下截图来自 Auth0 入门指南:

Auth0 身份验证和身份验证过程

当我们插入或连接到 Auth0 后,这将成为用于验证其身份并将所需信息发送回应用程序的身份验证和授权服务器,每当用户尝试进行身份验证时。

我们不仅限于 Java;Auth0 为不同的技术和语言提供了多个 SDK 和 API。

使用 Auth0 创建身份验证和授权服务的示例的步骤如下:

  1. 在 Auth0 上创建您的免费开发者帐户:auth0.com/

  2. 登录到 Auth0 门户并创建一个应用程序:

Auth0 创建应用程序

  1. 为应用程序命名,然后选择“常规 Web 应用程序”选项,其中包括 Java 应用程序(您还可以创建原生移动应用程序、单页应用程序和物联网IoT)):

  1. 选择一个使用 Spring Security 的示例应用程序。

  2. 点击“下载应用程序”并将项目文件夹更改为packt-secure-sample

要运行示例,我们需要在我们创建的应用程序的设置选项卡中设置回调 URLhttp://localhost:3000/callback)。

要在控制台上运行此示例,请在示例目录中执行以下命令:

# In Linux / macOS./gradlew clean bootRun
# In Windowsgradlew clean bootRun

您可以在以下 URL 查看应用程序,http://localhost:3000/

请注意,应用程序登录页面会重定向到 Auth0。当我们通过第三方应用程序登录,通过我们的 Google 帐户或通过 Auth0 提供的凭据登录后,我们将看到生成的令牌的以下结果:

您现在已经学会了如何使用 Auth0 作为身份验证和授权服务的平台,使用 OAuth2 和 JWT 等标准。

总结

在本章中,我们解释了如何应用安全准则和实践,以涵盖您的应用程序可能遇到的最常见安全问题。在这里,我们涵盖了身份验证和授权、加密、数据输入验证、敏感数据、OWASP 十大安全风险、社会工程和渗透测试。这些概念和方法将加强您的应用程序的安全性。

在下一章中,我们将回顾高性能技术和建议,以完成使用 Spring 5 创建应用程序的旅程。

第十四章:高性能

当应用程序以意外的方式表现时,不得不处理生产中的问题比任何事情都更令人失望。在本章中,我们将讨论一些简单的技术,可以应用这些技术来摆脱这些令人讨厌的问题,将简单的建议应用到您的日常工作中,以照顾您的应用程序的性能。在本章中,我们将讨论以下主题:

  • 为什么性能很重要

  • 可扩展性

  • 可用性

  • 性能

  • 使您的软件远离性能问题的关键建议

  • 应用程序分析

  • SQL 查询优化

  • 负载测试

让我们从介绍性能的重要性开始。

为什么性能很重要

在过去的 20 年里,作为顾问,我访问了几家政府机构、银行和金融机构,建立了一个共同因素,即在生产中工作的应用程序缺乏性能,并且我发现了一些常见问题,如果您在 SDLC 的一部分中使用一套良好的实践,这些问题是可以避免的。

关注性能很重要,因为它给公司、项目发起人和客户带来了巨大的麻烦,因为面临这个问题的应用程序会在多个层面上带来不满。

在给出建议之前,我们将审查和了解可扩展性、可用性和性能的非功能性需求。

可扩展性

这描述了系统处理高工作负载并根据工作需求增加其容量以解决更多请求的能力。

水平扩展性

通过添加具有系统所有功能的额外节点来解决这个问题,重新分配请求,如下图所示:

水平扩展性

垂直扩展性

我们通过向节点或服务器添加资源(如 RAM、CPU 或硬盘等)来使用垂直扩展,以处理系统的更多请求。我看到的一个常见做法是向数据库服务器添加更多硬件,以更好地执行正在使用它的多个连接;我们只能通过添加更多资源来扩展服务,如下图所示:

垂直扩展性

高可用性

这指的是系统持续提供服务或资源的能力。这种能力直接与服务级别协议(SLA)相关。

SLA 是根据系统的维护窗口计算的,SLA 定义了系统是否应该扩展或扩展。

性能

这是系统对在给定时间间隔内执行任何操作的响应能力。作为软件系统的一部分,我们需要开始定义可衡量的性能目标,如下所示:

  • 最小或平均响应时间

  • 平均并发用户数量

  • 高负载或并发时每秒的请求次数

作为开发人员,我们今天面临的主要挑战是我们的应用程序必须处理的客户和设备数量,甚至更重要的是,我们的应用程序是否将在互联网上运行还是仅在内部网络中运行。下图显示了应用程序通常部署和使用的拓扑结构:

对系统的高负载请求

在了解性能、可扩展性和可用性的主要概念之后,让我们回顾一些增加应用程序性能的关键建议。

避免性能问题的关键建议

通常使用负载测试工具、应用程序性能监视器APM)和分析工具来查找和解决软件系统中的性能问题。为了模拟生产中的用户数量,我们需要运行负载测试-为系统的最常用功能创建场景,并同时跟踪和监视应用程序健康状况-测量 CPU、RAM、IO、堆使用、线程和数据库访问等资源。在这个过程的输出中,我们可以给出一些关键建议,以避免软件出现性能问题。

在接下来的部分中,我们将解释我们可能遇到的最常见的瓶颈以及如何避免它们。

识别瓶颈

企业应用程序每天变得更加复杂。当业务成功时,支持该业务的应用程序将拥有更多用户,这意味着每天都会收到更大的负载,因此我们需要注意可能出现的性能瓶颈。

理解术语瓶颈,我们将给出一个简单的定义。在软件系统中,当应用程序或系统的功能开始受到单个组件的限制时,就会出现瓶颈,就像比较瓶颈减慢整体水流一样。

换句话说,如果我们的应用程序开始表现缓慢或开始超出预期的响应时间,我们就可以看到瓶颈。这可能是由于不同类型的瓶颈引起的,例如以下情况:

  • CPU:当此资源繁忙且无法正确响应系统时会发生这种情况。当我们开始看到 CPU 利用率在较长时间内超过 80%时,通常会开始出现这种瓶颈。

  • 内存:当系统没有足够的 RAM 或快速 RAM 时会发生这种情况。有时应用程序日志显示内存不足异常或内存泄漏问题。

  • 网络:与必要带宽的缺乏有关

  • 应用程序本身、代码问题、太多未受控制的异常、资源使用不当等

使用 APM 来识别瓶颈是一个不错的方法,因为 APM 可以在不减慢应用程序性能的情况下收集运行时信息。

要识别瓶颈,我们可以使用一些实践方法;负载测试和监控工具,或分析工具。接下来的部分将解释分析工具。

应用程序性能分析

我们可以查看我们的代码,并开始分析我们怀疑存在性能问题的系统部分,或者我们可以使用分析工具并获取有关整个系统的信息。这些工具收集运行时数据,并监视 CPU、内存、线程、类和 I/O 的资源消耗。

有几种可用于分析 Java 应用程序的工具,包括以下内容:

  • 与 JVM 一起提供的工具,如 VisualVM、JStat、JMap 等

  • 专门的工具,如 JProfiler、Java Mission Control 和 Yourkit

  • 轻量级分析器,如 APM 中提供的那些,就像我们在第十二章中看到的那样,监控,使用 New Relic

Visual VM

这是作为 JDK 的一部分集成的可视化工具,具有分析应用程序的能力。让我们运行我们之前章节中的银行应用程序,并查看我们可以使用它收集哪些信息。

要运行我们之前的银行应用程序,请转到项目文件夹,并通过命令行运行以下命令:java -jar build/libs/banking-app-1.0.jar

现在,我们将使用 VisualVM 收集有关 JVM 的一些指标。我们可以通过以下命令从控制台运行此工具:

$ cd JAVA_HOME/bin
$ jvisualvm

我们应该看到类似以下截图的屏幕:

Java VisualVM

使用“本地”菜单选项,您必须附加要监视的 Java 进程。在这种情况下,我们将选择 banking-app-1.0.jar。然后,我们应该看到应用程序使用的资源的摘要:

VisualVM CPU、RAM、类和线程

还有一个选项卡提供有关线程的信息,如下面的屏幕截图所示:

VisualVM 线程

我们可以使用任何我们感觉舒适的工具;一个很好的起点,也是一个易于使用的工具是 Jprofiler,但所有的工具都给我们提供类似的信息。我们需要了解并遵循我们应用程序中发现的任何瓶颈可能引发的问题。

在生产中调试性能问题可能是一项困难的任务,在某些情况下很难找到和修复。我们需要一个让我们信任的工具来理解瓶颈,因此我们需要尝试不同的工具并进行负载测试,以找到适合我们的正确工具。

在您知道有必要优化之前不要进行优化;首先运行应用程序并运行负载测试,看看我们是否可以满足性能的非功能性需求。

SQL 查询优化

优化企业应用程序的查询和数据访问层对于避免瓶颈和性能问题至关重要。我们可以使用 New Relic 作为 APM,这将帮助我们使用数据库访问图形来检测瓶颈和性能问题。通过这些图形,我们可以找到应用程序使用的 SQL 语句,找到延迟事务或阻塞表,如果我们继续深入信息,还可以找到使用最多的 SQL 语句和管理的连接数,如下面的屏幕截图所示:

来自 New Relic 的数据库指标

从应用程序中,我们可以识别最常用的查询,并寻找优化的机会。我们需要索引或重构我们的代码以获得更好的性能。另一方面,如果不使用 APM 或分析工具,我们可以使用许多技术来改进我们的 SQL 和数据访问层,例如以下内容:

  • 审查 SQL 语句:这通过分析器或 APM 逐个审查和优化执行的 SQL 语句,应用索引,选择正确的列类型,并在必要时使用本地查询优化关系。

  • JDBC 批处理:这使用prepared语句进行批处理,一些数据库如 Oracle 支持prepared语句的批处理。

  • 连接管理:这审查连接池的使用,并测量和设置正确的池大小。

  • 扩展和扩展:这在可扩展性部分有解释。

  • 缓存:这使用内存缓冲结构来避免磁盘访问。

  • 避免 ORM对象关系映射ORM)工具用于将数据库表视为 Java 对象以持久化信息。然而,在某些情况下,最好使用普通的 SQL 语句来避免不必要的连接,从而提高应用程序和数据库的性能。

在下一部分,我们将看看如何模拟虚拟用户以创建应用程序的负载测试。

负载测试示例

负载测试用于检查应用程序在一定数量的并发用户使用后的行为;并发用户的数量是指应用程序在生产中将具有的用户数量。您应该始终定义一个性能测试套件,使用以下工具测试整个应用程序:

  • Neoload

  • Apache JMeter

  • Load Runner

  • 负载 UI

  • Rational Performance Tester

我们需要定义一个负载测试和配置文件作为我们应用程序的流水线的一部分,并在我们进行性能改进之前和之后运行它。我们将使用 Neoload 创建一个示例,以审查我们应用程序示例中的这些关键建议。

首先,我们需要定义一个场景来运行负载测试;在我们的情况下,我们将使用第十二章中的银行应用程序,监控,它已经准备好使用,并定义一个功能常见的场景,如下所示:

  1. 用户将使用以下凭据登录:rene/rene

  2. 然后,用户将点击菜单通知。

  3. 最后,用户将点击注销链接。

首先,我们将从以下 URL 下载 Neoload:www.neotys.com/download.

Neoload 为我们提供了一个试用版本,我们可以模拟最多 50 个虚拟并发用户。

安装 Neoload 后,我们将打开应用程序并创建一个项目:

然后,我们将点击开始录制,并选择我们将用于记录应用程序的浏览器:

然后,在浏览器中,我们将输入我们应用程序的 URL:http://localhost:8080/login,并作为用户导航到我们客户的通知集。因此,流程如下:

  1. 登录

  2. 点击菜单通知

  3. 点击注销

选择我们正在记录的主机,即本地主机,并按照下一步的说明进行操作,直到结束。最后,我们将点击停止录制按钮,并且我们应该在左侧菜单中看到我们的操作已记录:

然后,我们将通过点击悬停在用户图标上方的复选图标来运行记录的场景:

我们应该看到我们的场景在没有错误的情况下运行,模拟一个并发用户,如下截图所示:

现在,让我们生成负载测试,创建一个人口(模拟用户场景):

然后,点击运行时图标,以使用 10 个并发用户在 2 分钟内运行负载测试:

然后,点击播放图标:

最后,在测试完成后,我们可以检查结果;在负载测试期间,我们访问了 670 页并发出了 890 个请求,使用 20 个并发用户:

另一方面,在使用 VisualVM 进行负载测试时,我们可以检查应用程序的性能,并查看它在检查线程时的表现,如下截图所示:

我们将发现,使用虚拟用户模拟时,JVM、内存和线程看起来与在应用程序上导航时有所不同。

在运行负载测试时,监控应用程序的所有资源是值得的,以确定问题可能出现的位置。

最后,我们已经学会了使用性能分析工具或 APM,除了负载测试工具,可以确保我们的应用程序和系统在将代码发布到生产环境之前进行性能改进。

在添加代码以改进应用程序性能后,总是一个好主意运行性能测试,以检查更改的实施情况。

总结

在本章中,我们解释了可伸缩性、可用性和性能的含义。我们还学会了如何应用一些技术和工具,以避免在生产中处理性能问题,因此,我们如何改进我们的应用程序以实现更好的响应时间。

posted @ 2024-05-24 10:54  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报