Spring5-软件架构-一-

47 阅读8分钟

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 原则(www.techopedia.com/definition/…

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 编写它们。