SpringBoot-与-Angular-全-

SpringBoot 与 Angular(全)

原文:zh.annas-archive.org/md5/4c4ef0cbff55503f4e8fb3ed35ecf38a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Spring Boot 为 JavaScript 用户提供了一个平台,只需几行代码就能让应用程序启动运行。同时,Angular 是一个基于组件的框架,使得构建 Web 应用程序的前端变得容易。本书解释了 Spring Boot 和 Angular 如何协同工作,帮助您快速有效地创建全栈应用程序。

在本书中,您将首先探索为什么 Spring Boot 和 Angular 是需求旺盛的框架,然后通过专家解决方案和最佳实践来构建您自己的 Web 应用程序。关于后端,您将了解 Spring Boot 如何通过让 Spring 框架和 Spring Boot 扩展承担繁重的工作来提高应用程序的构建效率,同时在使用 Spring Data JPA 和 Postgres 依赖项将数据保存或持久化到数据库中。在前端,您将使用 Angular 构建项目架构,构建响应式表单,并添加身份验证以防止恶意用户从应用程序中窃取数据。

最后,您将了解如何使用 Mockito 测试服务,使用持续集成和持续部署部署应用程序,以及如何将 Spring Boot 和 Angular 集成到一个单一包中,以便在本书结束时,您将能够构建自己的全栈 Web 应用程序。

本书面向对象

本书面向忙碌的 Java Web 开发者和 TypeScript 开发者,他们对于开发 Angular 和 Spring Boot 应用程序的经验不多,但希望了解构建全栈 Web 应用程序的最佳实践。

本书涵盖内容

第一章Spring Boot 和 Angular – 大全景,作为 Spring Boot 和 Angular 当前状态的简要回顾,为您展示 Java Spring Boot 和 Angular Web 开发的未来。您还将看到 Vue.js 作为应用程序的稳定性和可靠性,以及编写和维护 Vue.js 框架的团队。

第二章设置开发环境,教您如何设置计算机的开发环境以构建后端和前端 Web 应用程序。在开始应用程序开发之前,我们将转向不同的 IDE 和文本编辑器来编写代码,并确保一切都已经设置好。

第三章进入 Spring Boot,揭示了 Spring Boot 的内部工作原理以及如何使用 Spring Initializr 启动项目。本章还将向您介绍依赖注入和 IoC 容器概念。本章还将探讨 Bean 和注解的工作方式。

第四章, 设置数据库和 Spring Data JPA,帮助你将 Java Spring Boot 连接到数据库。本章将描述 Spring Data JPA 以及如何在项目中添加 Spring Data JPA 和 Postgres 依赖项。本章还将展示如何使用配置文件将 Java Spring Boot 连接到 Postgres 数据库实例。

第五章, 使用 Spring 构建 API,展示了如何启动和运行 Java Spring Boot 应用。本章还将展示如何为应用添加模型并在编写路由器和控制器时使用它们。之后,本章将解释如何使用 Redis 进行缓存以提高应用性能。

第六章, 使用 OpenAPI 规范记录 API,涵盖了 Java Spring Boot 应用 API 的文档部分。本章还将展示如何在应用中包含 Swagger UI 以提供 API 文档的图形界面。

第七章, 使用 JWT 添加 Spring Boot 安全,详细说明了 CORS 是什么以及如何在 Spring Boot 应用中添加 CORS 策略。本章描述了 Spring 安全、认证和授权。本章还将演示 JSON web tokens 的工作原理以及身份即服务IaaS)是什么。

第八章, 在 Spring Boot 中记录事件,解释了什么是日志以及实现日志的流行包。本章还将教你日志的保存位置以及如何处理日志。

第九章, 在 Spring Boot 中编写测试,全部关于为 Java Spring Boot 应用编写测试。本章将描述 JUnit 和 AssertJ。本章还将教你如何编写测试,如何测试存储库,以及如何使用 Mockito 测试服务。

第十章, 设置我们的 Angular 项目和架构,主要关注如何组织特性和模块,如何构建组件,以及如何添加 Angular Material。

第十一章, 构建响应式表单,展示了如何构建响应式表单、基本表单控件和分组表单控件。本章还将解释如何使用 FormBuilder 和验证表单输入。

第十二章, 使用 NgRx 管理状态,涵盖了复杂应用中的状态管理。本章还将介绍 NgRx 以及如何在 Angular 应用中设置和使用它。

第十三章, 使用 NgRx 保存、删除和更新,描述了如何使用 NgRx 删除项目,如何使用 NgRx 添加项目,以及如何使用 NgRx 更新项目。

第十四章在 Angular 中添加身份验证,探讨了如何添加用户登录和注销、检索用户配置文件信息、保护应用程序路由以及调用具有受保护端点的 API。

第十五章在 Angular 中编写测试,说明了如何编写基本的 Cypress 测试以及如何模拟 HTTP 请求进行测试。

第十六章使用 Maven 打包后端和前端,展示了如何利用 Maven 前端插件将 Angular 和 Spring Boot 集成到一个包中。

第十七章部署 Spring Boot 和 Angular 应用程序,描述了 CI/CD 和 GitHub Actions。本章还将向您展示如何为 Spring Boot 和 Angular 应用程序创建 CI 工作流程或管道。

要充分利用本书

你应该确保你对 HTML、CSS、JavaScript、TypeScript、Java 和 REST API 有基本的了解。你不需要对提到的要求有中级或高级知识。

本书涵盖的软件/硬件 操作系统要求
Node.js(LTS 版本) Windows、macOS 或 Linux
Angular Windows、macOS 或 Linux
Java 17 Windows、macOS 或 Linux
Visual Studio Code Windows、macOS 或 Linux
IntelliJ IDEA Windows、macOS 或 Linux
Google Chrome Windows、macOS 或 Linux

如果你在开发应用程序时安装运行时、SDK 或任何软件工具时遇到问题,不要失去希望。错误很常见,但在 Google 上搜索错误信息在解决某些问题时对开发者有很大帮助。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将有助于您避免与代码复制和粘贴相关的任何潜在错误

在 stackblitz.com 或 codesandbox.io 上尝试使用 Angular,以查看 Angular 的外观和感觉,而无需在您的计算机上安装任何东西。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Spring-Boot-and-Angular。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在 https://github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色图像 PDF 文件。您可以从这里下载:packt.link/pIe6D

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Spring Boot 只需要spring-boot-starter-web,这是一个 Spring Starter,以便我们的应用程序运行。”

代码块设置如下:

@Configuration
public class AppConfig
{
   @Bean
   public Student student() {
       return new Student(grades());
    }
   @Bean
   public Grades grades() {
      return new Grades();
    }
}

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

dependencies {
   implementation 'org.springframework.boot:spring-boot-
   starter-data-jpa'
   runtimeOnly 'com.h2database:h2'
   runtimeOnly 'org.postgresql:postgresql'
}

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

rpm -ivh jdk-17.interim.update.patch_linux-x64_bin.rpm

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“选择Spring Initializr,这将打开具有相同网页界面的表单。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Spring Boot 3 和 Angular》,我们很乐意听听您的想法!请选择www.amazon.in/review/create-review/?asin=180324321X为这本书留下您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯以及每天收件箱中的精彩免费内容。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

图片

packt.link/free-ebook/9781803243214

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件

第一部分:Spring Boot 和 Angular 开发概述

本部分包含一个真实场景,介绍如何启动一个 Web 应用程序项目。以下章节包含在本部分中:

  • 第一章, Spring Boot 和 Angular – 全景图

  • 第二章, 设置开发环境

第一章:Spring Boot 和 Angular – 整体图景

首先,我们想感谢您购买这本书的副本,这本书是为开发者编写的,旨在学习如何使用行业开发标准构建全栈 Web 应用程序。这本书是根据我们从培训和研讨会中开发的应用程序定制的。那么,让我们开始我们的冒险之旅吧。

本章将作为一个简短的回顾,涉及 Java Spring Boot 和 Angular 的基础知识,以便您对如何进行这些框架的 Web 开发有一个大致的了解。您还将了解到社区的大小以及 Angular 所提供的支持使其在开发应用程序时变得可靠。

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

  • 介绍 Spring Boot

  • 使用 Spring Boot 的优势

  • Java 17 的新特性

  • 介绍 Angular

  • 使用 Angular 的优势

技术要求

我们将要构建的应用程序的 GitHub 仓库可以在github.com/PacktPublishing/Spring-Boot-and-Angular找到。

每一章都有一个目录,其中包含项目的完成部分。

注意

从第一章“Spring Boot 和 Angular – 整体图景”到第四章“设置数据库和 Spring Data JPA”,将不会有目录可用,因为我们将要涵盖的大部分主题将包括理论和一些示例代码。实际项目将在第五章“使用 Spring 构建 API”开始。

介绍 Spring Boot

Spring Boot 是来自 Pivotal 的开源微框架。它是一个面向开发者的企业级框架,用于在Java 虚拟机JVMs)上创建独立应用程序。其主要重点是缩短您的代码,以便您更容易运行应用程序。

该框架扩展了 Spring 框架,为您提供了配置应用程序的更具意见性的方式。此外,它还内置了自动配置功能,根据您的设置配置 Spring 框架和第三方包。Spring Boot 利用这些知识来避免配置时的代码错误,因为它在设置我们的应用程序时减少了样板代码。

现在,让我们讨论使用 Spring Boot 的主要优势。

使用 Spring Boot 的优势

以下是用 Spring Boot 开发应用程序的四个主要优势:

  • 自动配置:当您配置 Spring Boot 应用程序时,它会下载运行应用程序所需的所有依赖项。它还会根据您应用的设置配置 Spring 框架,包括相关的第三方包。因此,Spring Boot 避免了样板代码和配置错误,您可以直接开始开发 Spring 应用程序。

  • 有观点的方法:Spring Boot 采用一种基于应用程序需求来安装依赖项的窄方法。它将为您的应用程序安装所有必需的包,并摒弃了手动配置的想法。

  • Spring starters:您可以在初始化过程中选择一系列的启动依赖项来定义应用程序预期的需求。Spring Starter 的一个例子是 Spring Web,它允许您在不配置运行应用程序所需的依赖项的情况下初始化基于 Spring 的 Web 应用程序。相反,它将自动安装 Apache Tomcat Web 服务器和 Spring Security 以提供认证功能。

  • 创建独立应用程序:Spring Boot 可以运行没有外部 Web 服务器依赖项的独立应用程序。例如,我们可以嵌入服务器,如 Tomcat,并运行应用程序。

Spring 和 Spring Boot 之间的区别

那么,Spring 和 Spring Boot 之间的区别是什么?在开始使用 Spring Boot 之前,您需要了解 Spring 框架吗?让我们从第一个问题开始。

以下表格展示了两个框架之间的区别:

C:\Users\Seiji Villafranca\AppData\Local\Microsoft\Windows\INetCache\Content.MSO\943B62F6.tmp spring-boot-logo - THE CURIOUS DEVELOPER
开发者配置项目的依赖项。 使用 Spring Starters,Spring Boot 将配置运行应用程序所需的所有依赖项。
Spring 是构建应用程序的Java EE 框架 Spring Boot 通常用于构建REST API
Spring 通过提供如 Spring JDBC、Spring MVC 和 Spring Security 等模块简化了 Java EE 应用程序的开发。 Spring Boot 提供了依赖项的配置,减少了模块布局的样板代码,这使得运行应用程序更加容易。
依赖注入(DI)和控制反转(IOC)是 Spring 构建应用程序的主要特性。 Spring Boot Actuator是一个功能,可以公开有关您的应用程序的操作信息,例如指标和流量。

我们可以确定 Spring Boot 是建立在 Spring 之上的,主要区别在于 Spring Boot 会自动配置我们运行 Spring 应用所需的依赖项。所以,要回答关于在开始使用 Spring Boot 之前是否需要学习 Spring 框架的问题,答案是——Spring Boot 只是 Spring 的一个扩展,由于其有见地的方法,它使得配置更快。

现在,让我们看看在 Spring 和 Spring Boot 中配置一个 Web 应用所需的依赖关系。

Spring 和 Spring Boot 的依赖项示例

在 Spring 中,我们应用运行所需的最低依赖项是Spring WebSpring Web MVC

<dependency>
     <groupId>org.springframework</groupId>
     <artifactId>spring-web</artifactId>
     <version>5.3.5</version>
</dependency>
<dependency>
     <groupId>org.springframework</groupId>
     <artifactId>spring-webmvc</artifactId>
<version>5.3.5</version>
</dependency>

Spring Boot 只需要spring-boot-starter-web,这是我们应用运行所需的 Spring Starter。必要的依赖项在构建时自动添加,因为 Starter 将负责配置:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.4</version>
</dependency>

在 Spring 中,我们需要考虑的另一件事是为我们的应用在服务器上运行定义一些配置,例如分发器 servlet 和映射:

public class SpringInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext container) {
AnnotationConfigWebApplicationContext context =
    new AnnotationConfigWebApplicationContext();
context.setConfigLocation("com.springexample");
container.addListener(new        ContextLoaderListener(context));
 ServletRegistration.Dynamic dispatcher =
     container.  addServlet("dispatcher",
         new  DispatcherServlet(context));
 dispatcher.setLoadOnStartup(1);
 dispatcher.addMapping("/");
   }
}

在初始化分发器 servlet 之后,我们还需要使用@EnableWebMvc,并有一个带有@Configuration注解的Configuration类,我们将为应用实例化一个视图解析器。

在配置类中将创建一个新的InternalResourceViewResolver()实例。这将是一个 Spring 的 bean。在这里,所有位于/WEB-INF/view路径下且具有.jsp文件扩展名的文件都将被解析:

@EnableWebMvc
@Configuration
public class SpringWebConfig implements WebMvcConfigurer {
   @Bean
   public ViewResolver viewResolver() {
       InternalResourceViewResolver bean =
           new  InternalResourceViewResolver();
   bean.setViewClass(JstlView.class);
   bean.setPrefix("/WEB-INF/view/");
   bean.setSuffix(".jsp");
   return bean;
   }
}

在 Spring Boot 中,所有这些配置都将被省略,因为这段代码已经包含在 Spring Starter 中。我们只需要定义一些属性,以便使用 Web Starter 使我们的应用运行:

spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp

定义了这些属性后,我们的应用将运行,因为所有必要的配置,如Web 初始化器MVC 配置,都已包含在内。

因此,我们已经讨论了 Spring Boot 的优势,同时,也讨论了 Spring Boot 与 Spring 框架之间的主要区别以及它是如何减少配置时的样板代码的。

如您可能已经知道,Spring 的主要语言是 Java,Java 17 现在已经发布。在下一节中,我们将了解 Java 17 的新特性。

Java 17 的新特性是什么?

我们已经决定在这本书中讨论 Java 17,因为这是 Java 的下一个长期支持LTS)版本,这意味着这个版本将得到更长时间的维护。它于 2021 年 9 月 14 日发布,并包含了一些新的安全和开发特性。

让我们看看已经包含的一些新特性,以及应用于 Java 17 的一些修改。

密封类

permits关键字用于标识我们想要授予权限的特定类,如下面的示例所示:

public sealed class Animal permits Cat, Dog, Horse

外部函数和内存 API

引入了一个新的 API,用于访问和使用 Java 运行时之外代码,它通过应用外部函数(JVM 之外代码)和安全的访问外部内存(JVM 不处理内存)来实现。该 API 允许 Java 应用程序在不使用Java 本地 接口JNI)的情况下调用本地库。

该 API 旨在用纯 Java 开发模型替换 JNI,并在访问堆外数据时提供更好的性能,同时省略不安全操作。

外部内存

Java 当前的一个常见问题是访问堆外数据。堆外数据是指存储在 Java 运行时之外内存中的数据。我们可以将其称为第三方库。访问这些数据对于性能至关重要,因为 Java 垃圾收集器只处理堆内数据,这使得它们可以避免垃圾收集的不确定性。以下 API 用于处理堆外数据:

  • ByteBuffer API:此 API 允许你在堆外数据中创建直接 ByteBuffer,以便数据可以在 Java 运行时之外进行管理。然而,ByteBuffer 的主要缺点是其最大大小为 2 GB,并且它不会及时释放,导致应用程序的运行时性能变慢。

  • Sun.misc.Unsafe API:Unsafe API 公开了对堆外数据进行操作的操作。由于即时编译器JIT)优化了访问操作,因此此 API 使此过程效率更高。然而,不建议使用 Unsafe API,因为我们正在允许访问任何内存位置。

  • 外部函数和内存 API:此 API 解决了访问内存位置和牺牲运行时性能的困境,因为它提供了应用程序可以执行以下操作的类和接口:

    • 分配外部内存

    • 操作和访问外部内存

    • 调用外部函数

使用 switch 语句进行模式匹配

模式匹配是测试 switch 语句中的模式和复杂表达式的想法。这个新特性允许 switch 语句以更可扩展和灵活的方式使用,以接受复杂表达式。

Applet API

Applet API在 Java 中很少使用,因为所有浏览器都已移除对 Java 浏览器插件的支持。

实验性的 AOT 和 JIT 编译器

由于其功能使用有限,实验性的基于 Java 的即时编译器AOT)和即时编译器JIT)已被移除。

这些只是 Java 17 中应用的一些更改。现在,让我们了解 Angular,这是当今最顶尖的 JavaScript 框架之一,以及使用 Angular 框架开发前端的优势。

介绍 Angular

Angular 是一个由 Google 维护的免费开源 JavaScript 框架。它主要用于开发 Web 应用程序,并通过插件扩展了其功能,使其能够创建移动和桌面应用程序。Angular 使用基于组件的代码,是渐进式的,并提供了许多库和扩展,这些库和扩展可以缩短开发大型应用程序的时间。

在撰写本文时,Angular 构建前端应用非常流行。它是三星、Upwork、PayPal 和 Google 等大型知名公司开发应用程序的主要框架。它还拥有一个非常活跃的社区,在 GitHub 上有 76,000 个星标,大约有 1,500 人为该框架做出贡献。此外,它拥有数千个功能齐全的 NPM 库,您可以使用这些库来加速您的开发。

Angular 的历史

在成为 Angular 之前,Google 开发的第一个框架是 AngularJS 或 Angular 版本 1。尽管开发者通常对此感到困惑,因为他们认为 AngularJS 和 Angular 很相似,但 AngularJS 是由 Google 员工 Miško Hevery 以开源框架的形式发布的,他正在开发 AngularJS 以加快 Web 应用程序的开发。

使用 JavaScript 或 Dart 的 AngularJS 由于其社区变得更加广泛而变得流行。同时,发布了 Ionic 框架,允许开发者使用 AngularJS 构建原生移动应用程序。

伟大的重写

JavaScript 技术的快速和快速发展影响了 AngularJS 的流行度,团队在框架方面走到了尽头——没有进一步改进的空间。从 2014 年到 2015 年,Google 团队和社区决定使用该框架支持移动和大型企业应用程序。他们的第一步是 伟大的重写,而不是增加 AngularJS 的设计。伟大的重写是 Angular 2.0 或简称为 Angular 的发布。

行动的难题

许多应用程序已经在 AngularJS 上运行,这意味着如果发布了一个全新的 Angular 版本,AngularJS 用户的支持将结束。因此,这里的主要问题之一是,“这些应用程序在几年后会如何得到支持?

另一个出现的问题是,没有直接从 AngularJS 迁移到 Angular 2.0 的方法,这对开发者来说很困难。这对团队来说是一个巨大的步骤——以至于在每次发布中都引入了新的概念和重大更改。

框架的回归

尽管迁移 Angular 很痛苦,但 Google 创建的企业应用程序得到了支持。到 2018 年左右,随着框架拥有大量可用于构建大型应用程序的功能,这变得更加稳定。此外,它不依赖于第三方库来创建表单和调用 HTTP 请求,因为所有依赖项都已包含在内。Google 还发布了一些文档,以帮助开发者将 AngularJS 迁移到 Angular 的最新版本。

Angular 非常受欢迎,在开发企业应用程序方面非常有效。现在,让我们看看 Angular 的优势以及为什么它对开发有效。

使用 Angular 的优势

Angular 是一个基于组件的框架,这意味着我们将应用程序的部分开发成更小的块,我们可以在整个应用程序中重用这些块。这个特性通过确保没有太多重复代码来减少样板代码和代码错误。Angular 的一个主要优势是其语言。让我们更深入地了解一下。

基于 TypeScript 的框架

Angular 是一个基于 TypeScript 语言的框架。这种语言是一个显著的优势,因为 TypeScript 提供了对开发有益的功能。此外,它还是 JavaScript 的超集,它添加了新的概念,使代码可维护和有效:

图 1.1 – TypeScript – 一种超集语言

图 1.1 – TypeScript – 一种超集语言

如我们所见,TypeScript 是建立在 ES6 和 JavaScript 之上的,旨在为开发添加更多功能。TypeScript 的某些组件包括泛型、类型和接口,这些我们都知道与面向对象编程OOP)直接相关。现在,让我们看看另一个优势。

静态类型数据

TypeScript 可以定义静态类型数据,这使得变量可以严格类型化。与纯 JavaScript 相比,编译器会在编译时提醒你任何类型相关的错误——也就是说,哪些错误是在运行时捕获的。因此,TypeScript 可以通过在编译时提示这些问题来避免生产中的错误。

可预测性和可维护性

由于 TypeScript 是强类型的,这有助于可预测性的概念。例如,一个变量被声明为数字类型。因此,在整个应用程序中它始终保持数字类型,函数将指定如何实现它们,因为所有参数也都是严格类型。此外,TypeScript 也是可维护的,因为它让开发者能够在编译时调试应用程序。

IDE 支持

由于 TypeScript 正在变得越来越广泛地使用,越来越多的 IDE 支持它。IDE 提供了诸如代码导航、自动完成和插件等几个功能。

Microsoft Visual Studio 是用于 TypeScript 的主要 IDE。然而,一些 IDE 和编辑器也支持运行 TypeScript:

  • Atom:一个跨平台编辑器

  • Eclipse:一个具有 TypeScript 插件的 IDE

  • Visual Studio Code:微软的一个轻量级跨平台编辑器

面向对象编程(OOP)

TypeScript 是一种面向对象的语言,这意味着它支持诸如类、接口和继承等概念。面向对象编程(OOP)在将我们的应用程序开发成对象时非常可扩展,如果我们正在开发不断增长的应用程序,这可以是一个优势。

早期发现的错误

浏览器不能直接理解 TypeScript。相反,它们使用转换器,将这些代码编译成纯 JavaScript。在这里,所有与语法和类型相关的错误都会被捕获,使得开发者可以专注于代码逻辑。

这些只是 TypeScript 语言的优点。现在,让我们看看 Angular 自身的优势。

支持大型企业级应用

Angular 被视为一个一站式包框架,因为构建应用程序所需的大部分标准功能已经包含在内。这包括模块。例如,要在 Angular 应用程序中使用表单,我们必须导入 FormsModuleReactiveormsModule。其他例子包括导航和路由。Angular 提供了 RouterModule,这样您就可以在应用程序内创建路由。

单页应用程序

Angular 是一个单页应用程序SPA),这意味着当用户从一个页面导航到另一个页面时,页面不会重新加载,因为数据是由服务器获取的。此外,客户端的资源是独立的,并且已经在浏览器中加载,这有助于提高应用程序的加载性能。

渐进式网页应用(PWAs)

渐进式网页应用PWAs)如今正成为一种趋势。它们是一种允许网页应用在移动应用以及不同平台(包括在线和离线)上运行解决方案。由于 Angular 的脚图,配置 Angular 为 PWA 非常简单——只需一行代码,您的 Angular 应用程序就配置好了。使用 PWA Builder,PWAs 还可以上传到 Android Play 商店和 Microsoft Store。

以下命令使用 Angular CLI 将我们的应用程序转换为 PWA:

ng add @angular/pwa

Angular CLI

我们不需要从头创建或配置 Angular。相反,我们可以使用 Angular CLI,它有助于安装运行我们的 Angular 应用程序所需的依赖项。尽管脚图功能负责创建所需的文件、安装包以及配置我们应用程序所需的值,但 Angular CLI 为模块组件服务指令生成样板代码,以加快开发速度。

在以下代码中,我们使用 npm 安装 Angular CLI 并使用 ng 命令生成我们的代码:

//command for installing angular CLI
npm install -g @angular/cli
//command for creating a new Angular App
ng new –project-name
// command for creating a new Component
ng generate component –component-name
// command for creating a new Service
ng generate service –component-name
// command for creating a new Module
ng generate module –component-name

基于模块和组件的框架

Angular 被分组到模块中,这使得代码结构更容易维护。此外,应用程序的每个部分都可以按其功能分组,并放置在单个模块中,这使得导航应用程序的功能更容易。它还有利于单元测试,因为代码是单独测试的,从而允许进行完全的质量控制。

将代码作为组件创建可以促进代码的可重用性和减少模板代码。让我们看看一个导航菜单的例子:

<!— Example code for nav bar -->
<nav class="navbar navbar-default">
  <div class="container-fluid">
    <div class="navbar-header">
      <a class="navbar-brand" href="#">Nav bar</a>
    </div>
    <ul class="nav navbar-nav">
      <li class="active"><a href="#">Home</a></li>
      <li><a href="#">Home</a></li>
      <li><a href="#">About</a></li>
      <li><a href="#">Contact Us</a></li>
    </ul>
  </div>
</nav>

导航栏必须出现在我们应用程序的每一页上。这个过程将导致代码冗余,这意味着我们不得不反复重复这段代码。然而,在 Angular 中,它已经被开发成一个组件,允许我们在应用程序的不同部分重用代码。为导航栏代码分配了一个特定的选择器,并用作组件的 HTML 标签,如下面的代码所示:

<!—example selector for the navigation bar component-->
<app-navigation-bar/>

支持跨平台

Angular 用于构建网页、原生移动和桌面应用程序的应用程序。现在,通过像 Ionic、NativeScript 和 Electron 这样的框架,这是可能的。除了 PWA 之外,Ionic 和 NativeScript 还用于使用 Angular 创建移动应用程序。另一方面,Electron 是一个框架,它使用类似的代码库将您的 Angular 应用程序转换为桌面应用程序。这个特性使得 Angular 作为一个单一框架非常灵活,因为它可以覆盖您应用程序的所有平台。

网页组件

Angular 支持网页组件,也称为Angular 元素。在这里,想法是将应用程序分解成更小的部分,并将其分发到独立的应用程序或包中,这些应用程序或包可以在其他应用程序中分发和使用。Angular 元素涵盖了微前端的概念。每个元素都有一个部署管道。这个组件也可以用于不同的 JavaScript 框架,如 React 和 Vue。

支持懒加载

在客户端浏览器中加载所有 JavaScript 代码可能会引入一些问题。如果应用程序变得更加庞大,更多的代码会被打包到一个块中。我们不希望将所有代码都初始化,因为这会导致我们的应用程序在首次启动时加载缓慢。我们只想按需加载所需的内容。Angular 的懒加载功能解决了这个问题。它只加载特定路由所需的应用程序模块、组件、服务、指令和其他元素。这个功能减少了用户首次打开应用程序时的加载时间。

在以下代码中,我们定义了一些路由作为一个数组,我们将新的路由作为对象添加。为了启用懒加载,我们必须使用loadChildren属性按需加载模块:

const route: Routes = [
    {
     path: "about",
 loadChildren: () =>
   import("./src/app/AboutModule").then(m =>
      m.AboutModule)
    },
   {
     path: "contact",
     loadChildren: () =>
       import("./src/app/ContactModule").then(m =>
         m.ContactModule)
    }
];

在前面的代码中,当用户导航到about路径时,它只会加载包含该特定路由资源的AboutModule。除非用户导航到contact路径,否则它不会加载ContactModule下的资源。

摘要

在本章中,你了解到 Spring Boot 是 Spring 框架的一个开源框架扩展,它主要解决了在配置 Spring 框架时产生的样板代码问题。此外,它还提供了Spring Starters,开发者可以使用这些 Starters 让 Spring Boot 自动配置所需的依赖。

另一方面,Angular 是一个基于组件的框架,它使用 TypeScript 语言构建,以赋予它面向对象的能力。此外,它具有跨平台支持,允许开发者创建可在网页、移动设备和桌面应用程序上运行的应用程序。由于 Angular 被多家大型公司使用,并得到 Google 和庞大社区的支持,因此它是 JavaScript 框架中的佼佼者。

在下一章中,你将学习必须安装到你的计算机上的软件,并设置全栈开发的环境。

第二章:设置开发环境

在上一章中,您对 Spring Boot 进行了简要了解及其优势。我们还探讨了 Java 17 的最新特性。对于 Angular 也是如此;您对 Angular 有了一个概述,以及使用它开发前端应用程序的好处。

本章将指导您如何设置计算机的开发环境以开发您的全栈 Java 和 Angular 应用程序。我们将探讨不同的 IDE 和文本编辑器来编写代码,并确保在开始开发之前一切配置就绪。

从一开始就正确安装所有内容将帮助我们避免问题,并使我们能够不间断地编写代码。

本章将涵盖以下主题:

  • 安装 VS Code 和 IntelliJ IDEA

  • 安装 Java 17

  • 安装 SDKMAN

  • 使用 Java 17 设置 IntelliJ IDEA

  • 安装 REST Client VS Code 或 JetBrains 和 Angular DevTools

  • 安装 Git 版本控制

技术要求

以下是需要安装的软件链接:

安装 VS Code 和 IntelliJ IDEA

本节将指导您安装和配置 VS Code 或 IntelliJ IDEA。我们将查看文本编辑器和 IDE 的功能以及您可以在开发过程中使用的插件。

VS Code

https://code.visualstudio.com/download 下载 VS Code 安装程序。我们建议无论您的机器操作系统如何,都应安装 VS Code,因为 VS Code 轻量级但提供了许多用于 Angular 开发的插件。此外,VS Code 是 JavaScript 开发者最常用的文本编辑器。编辑器支持 TypeScript、代码格式化和代码导航,并提供了许多扩展,您可以在开发 Angular 应用程序时使用。以下是一些在开发过程中可以使用的有价值扩展:

  • Code Spell Check:这是一个用于检查我们源代码拼写的扩展,有助于我们避免因拼写错误引起的问题。

  • Prettier: 这是一个代码格式化工具,在每次保存后都会在我们的文件上应用适当的对齐和缩进。

  • Angular 代码片段: 这是对 Angular 开发者来说非常流行的扩展,因为它为 Angular、TypeScript 和 HTML 添加了代码片段,使用代码片段在开发过程中动态生成代码可以节省大量时间。

  • Angular 文件: 这是一个非常有价值的扩展,尤其是在你不太熟悉 Angular CLI 命令时;这个扩展将添加一个菜单,你可以在这里生成 Angular 组件、模块和服务,而无需使用 CLI。

  • Angular2-Switcher: 这个扩展允许我们使用快捷键在 Angular 文件之间导航。

  • REST 客户端: 这是 VS Code 中用于测试后端 API 的扩展。我们可以在 VS Code 中使用 REST 客户端发送请求,而无需使用第三方应用程序。

  • JSON to TS: 这个扩展非常有用,因为它可以自动将 JSON 对象转换为前端中的 TypeScript 模型。

  • Angular 语言服务: 这是 Angular 开发中必不可少的插件之一。该扩展提供了 Angular 代码补全Angular 诊断消息转到定义 功能,这些功能使开发更加高效。

这标志着 VS Code 的安装和配置完成。现在,您的 VS Code 文本编辑器已设置为适用于 Angular 开发。

IntelliJ IDEA

这个 IDE 是使用 Java 开发应用程序时最受欢迎的 IDE 之一。IDE 提供了多项功能,使开发更加容易和快速。以下是由 IntelliJ IDEA 提供的工具列表:

  • 终端: IntelliJ IDEA 内置了终端。我们可以根据所使用的开发语言在终端中执行任何命令。可以通过按下 Alt + F12 来访问终端。

  • 快速修复: IntelliJ IDEA 会实时检测语法错误。有了这个功能,IntelliJ IDEA 还会为开发者提供快速修复建议,以便轻松纠正代码中的错误。当 IntelliJ IDEA 检测到错误时,可以通过一个小灯泡访问快速修复。

  • IntelliSense: IntelliSense 也被称为 智能代码补全。IntelliJ IDEA 分析代码片段,并显示建议以动态完成代码。

  • 高级重构: IntelliJ IDEA 提供了广泛的重构选项。IDE 为开发者提供了自动重构代码的能力。重构也可以在重构菜单中访问。

  • 导航和搜索: 这是 IntelliJ IDEA 最常用的功能之一,特别方便,尤其是在大型项目中。它帮助开发者找到并导航资源,并且可以搜索 IDE 中所有可用的控件。

  • 检测重复项: 这有助于开发者找到代码中的重复项,并为开发者提供建议。

您应该前往 https://www.jetbrains.com/idea/download/ 下载安装程序。此外,您还可以下载社区版,这是 Visual Studio 的免费版本。

成功下载 IDE 后,运行安装程序,过程非常直接。它将在我们的终端中自动安装 IDE。在成功安装 IntelliJ IDEA 后,我们将安装插件/扩展,这将有助于我们整个开发过程。

要安装插件,请打开 IntelliJ IDEA 并按 Ctrl + Alt + S。这将打开 设置 窗口,然后你应该转到 插件 菜单以访问市场:

图 2.1 – IntelliJ IDEA 插件市场

图 2.1 – IntelliJ IDEA 插件市场

它将列出我们可以为我们的开发添加的所有可用插件。我们将安装以下插件:

  • Lombok:一个扩展,提供注释,防止再次编写 getter 和 setter 方法;注释提供了一个功能齐全的构建器。

  • Maven Helper:一个用于 Maven 项目的工具,帮助分析冲突的依赖项。它可以以树状结构显示已安装的依赖项,并允许开发者检查它们之间的关系。

  • Junit 5 Mockito 代码生成器:一个扩展,帮助我们生成在编写测试时常用的样板代码。

  • Eclipse 代码格式化工具:一个将 Eclipse 代码格式化工具直接集成到 IntelliJ IDEA 中的工具。当开发者在使用 Eclipse 和 IntelliJ IDE 时,这个工具非常有用,可以解决标准代码风格的问题。

  • Tabnine:一个允许开发者根据一百万个 Java 程序完成代码行的工具。它有助于减少标准错误,并使开发者能够在人工智能的帮助下更快地编码。

  • 在 IDE 中直接忽略 .gitignore 而不必逐个输入路径。

这标志着 IntelliJ IDEA 的安装和配置结束。您机器上的 IDE 已经设置完毕。

安装 Java 17

本节将解释 Java 17 是什么,并指导你在 Windows、macOS 和 Linux 上安装套件。

Java 17 是构建使用 Java 编程语言的应用程序和组件所需的 Java 开发工具包JDK)。它是最新的 长期支持LTS)版本,这意味着供应商(Oracle)将长期支持该版本,包括修复安全漏洞。

Windows 安装

执行以下步骤在 Windows 上安装 Java 17:

  1. 前往 https://www.oracle.com/java/technologies/downloads/#jdk17-windows:

图 2.2 – Java 17 安装程序

图 2.2 – Java 17 安装程序

  1. 根据您的操作系统选择安装程序,并点击链接下载:

下载成功后,打开安装程序,它将提示您一个逐步安装过程,您可以按照它进行操作。

图 2.3 – Java 17 安装设置

图 2.3 – Java 17 安装设置

  1. 点击 下一步,这将询问您在机器上放置 JDK 17 的位置。您可以选择您首选的路径来设置 JDK,但默认路径通常被使用。

图 2.4 – Java 17 安装目标文件夹

图 2.4 – Java 17 安装目标文件夹

  1. 现在,在安装成功后,这将在您的机器上自动安装 JDK 17。以下文件也将被复制到该位置:

    "C:\Program Files\Common Files\Oracle\Java\javapath\java.exe"
    
    "C:\Program Files\Common Files\Oracle\Java\javapath\javaw.exe"
    
    "C:\Program Files\Common Files\Oracle\Java\javapath\javac.exe"
    
    "C:\Program Files\Common Files\Oracle\Java\javapath\jshell.exe"
    

我们现在已经在 Windows 机器上成功安装了 Java 17。现在,我们将向您展示在 macOS 上安装 Java 17 的步骤。

macOS 安装

在 macOS 中选择 Java 17 安装程序时有一些事情需要考虑。首先,我们需要知道 JDK 17 的安装包含表示 功能临时更新 版本的版本表示法。例如,如果您正在安装临时 1、更新 1 和补丁 1,那么安装程序名称将是以下格式:

  • macOS x64 系统

jdk-17.1.1.1_macos-x64_bin.dmg

jdk-17.1.1.1_macos-x64_bin.tar.gz

  • macOS aarch64 (64 位 ARM) 系统

jdk-17\. 1.1.1_macos-aarch64_bin.dmg

jdk-17\. 1.1.1_macos-aarch64_bin.tar.gz

执行以下步骤以在 macOS 上安装 Java 17:

  1. 要下载安装程序,请访问 https://www.oracle.com/java/technologies/downloads/#jdk17-mac 并根据您的操作系统架构选择安装程序。您可以下载 .dmgtar.gz 安装程序。

  2. 双击 .dmg 文件以启动安装程序。一个 .pkg 文件。双击 JDK 17\. pkg 图标以启动安装。

  3. 点击 继续,安装窗口将出现。点击 安装 后,将显示以下消息:安装程序正在尝试安装新软件。输入您的密码以 允许此操作

  4. 接下来,输入您的管理员用户名和密码,然后点击 安装软件以继续,这将自动在您的机器上安装软件。

我们现在已经在 macOS 上成功安装了 Java 17。现在,我们将向您展示在 Linux 平台上安装 Java 17 的步骤。

Linux 安装

当在 Linux 系统上安装 Java 17 时,我们还需要注意版本表示法,就像在 macOS 上选择安装程序时一样。版本格式表示 功能临时更新 版本。例如,如果我们正在下载临时 1、更新 1 和补丁 2,那么安装程序名称将是以下格式:

  • jdk-17.1.1.2_linux-x64_bin.tar.gz

  • jdk-17.1.1.2_aarch64_bin.tar.gz

您可以使用存档文件或 Red Hat 软件包管理器RPM)在 Linux 平台上安装 JDK:

  • 存档文件 (.tar.gz) 将在任何位置安装一个私有版本的 JDK,而不会影响其他 JDK 安装。该捆绑包适用于 Linux x86 和 Linux aarch64。

  • RPM 软件包 (.rpm) 将执行 JDK 的系统级安装,并要求用户具有 root 权限。

Linux 平台的 64 位

要为 64 位 Linux 平台安装 JDK,tar.gz 存档文件,也称为tarball,可以从以下 URL 下载:https://www.oracle.com/java/technologies/downloads/#jdk17-linux。

我们看到的文件如下:

  • jdk-17.interim.update.patch_linux-x64_bin.tar.gz

  • jdk-17.interim.update.patch_linux-aarch64_bin.tar.gz

下载 tarball 文件成功后,接受许可协议,并将.tar.gz文件放置在您想要安装 JDK 的目录中。

解压文件,按照以下方式安装下载的 JDK:

tar zxvf jdk-17.interim.update.patch_linux-x64_bin.tar.gz

或者使用以下代码:

tar zxvf jdk-17.interim.update.patch_linux-aarch64_bin.tar.gz

安装 JDK 成功后,我们可以看到它现在安装在一个名为jdk-17.interim.patch.update的目录中。

RPM 基于 Linux 平台的 64 位

我们可以使用 RPM 的二进制文件为 RPM 基于 Linux 平台安装 JDK,例如 Oracle 和 Red Hat。在安装 JDK 之前,我们必须首先确保我们有 root 权限。您可以通过运行su命令并输入超级密码来获得 root 权限。

RPM 文件可以从 https://www.oracle.com/java/technologies/downloads/#jdk17-linux 下载,我们得到以下文件:

  • jdk-17.interim.update.patch_linux-x64_bin.rpm

  • jdk-17.interim.update.patch_linux-aarch64_bin.rpm

使用以下命令安装包:

rpm -ivh jdk-17.interim.update.patch_linux-x64_bin.rpm

或者使用以下命令:

rpm -ivh jdk-17.interim.update.patch_linux-aarch64_bin.rpm

执行命令后,JDK 现在已成功安装在我们的机器上。然后,对于未来的版本升级,我们可以执行以下命令:

rpm -Uvh jdk-17.interim.update.patch_linux-x64_bin.rpm

或者使用以下命令:

rpm -Uvh jdk-17.interim.update.patch_linux-aarch64_bin.rpm

我们已经完成了在不同操作系统上安装和配置 JDK 17 的工作。在下一节中,我们将安装 SDKMAN。

安装 SDKMAN

本节将解释 SDKMAN 在开发 Java 应用程序中的目的。本节还将指导您在 Windows、macOS 和 Linux 上安装 SDKMAN。

SDKMAN软件开发工具包管理器)是我们机器上管理 Java 并行版本的工具。我们可以在计算机上安装多个 Java 版本。您也可以使用 SDKMAN 直接安装 Java。它将自动安装最新稳定版本或您指定的版本。

SDKMAN 主要针对 Unix 操作系统创建,但它也支持其他操作系统的 Bash 和 ZSH 外壳。

SDKMAN 功能

以下为 SDKMAN 的功能:

  • SDKMAN 使 Java 的安装变得更容易。我们只需执行想要安装的版本的命令,它就会完成所有工作。

  • SDKMAN 还支持 Java 开发包。它可以安装 JVM 的 SDK,例如GroovyKotlin

  • SDKMAN 可以在所有 Unix 平台上运行:macOS、Cygwin、Solaris 和 Linux。

SDKMAN 命令

要查看 SDKMAN 支持的所有 SDK,我们可以执行以下命令:

sdk list

命令将列出我们可以在机器上下载的所有 SDK 和 Java 库管理器。

要安装特定 SDK,例如,我们想要安装 Java 的最新 SDK,请执行以下命令:

sdk install java

如果我们想安装特定版本的 SDK,我们将在命令中指定版本,如下所示:

sdk install java 15-open

当我们在计算机上安装多个版本时,要切换版本,我们将执行以下命令:

sdk default java 15-open

macOS 和 Linux 上的安装

在 macOS 和 Linux 上安装 SDKMAN 只需要几个命令。为了安装 SDKMAN,我们将执行以下命令:

curl -s https://get.sdkman.io | bash

在遵循所有安装 SDKMAN 的说明后,打开一个新的终端并执行以下命令:

source "$HOME/.sdkman/bin/sdkman-init.sh"

命令将在您的终端中成功安装管理器。要检查安装是否成功,我们可以执行以下命令:

sdk version

这将显示您机器上安装的当前 SDKMAN 版本。

Windows 上的安装

在 Windows 上安装 SDKMAN 需要几个步骤,因为 SDKMAN 需要 Bash 工具。在这种情况下,我们首先需要的是一个Git Bash 环境MinGW):

  1. 首先,我们将安装7-Zip,安装成功后,我们将在 Git Bash 环境中执行以下命令以创建符号链接,将7-Zip作为zip命令:

    ln -s /c/Program\ Files/7-Zip/7z.exe /c/Program\ Files/Git/mingw64/bin/zip.exe
    

现在我们可以使用在 Linux 中使用的命令来安装 SDKMAN。

  1. 要安装 SDKMAN,我们将执行以下命令:

    export SDKMAN_DIR="/c/sdkman" && curl -s "https://get.sdkman.io" | bash
    
  2. 安装后,重新启动 Git Bash 外壳以运行 SDKMAN 命令。我们可以执行以下命令以验证我们的安装是否成功:

    sdk version
    

它将在我们的终端中提示已安装的 SDKMAN 当前版本。

我们已经完成了在不同操作系统上安装和配置 SDKMAN 的工作。在下一节中,我们将配置 IntelliJ IDEA 以使用已安装的 Java 17。

使用 Java 17 设置 IntelliJ IDEA

在前面的章节中,我们已经在我们的机器上安装了 IDE(IntelliJ IDEA)和 Java 17。现在,我们将指导您如何在新项目和现有项目中配置 Java 17。

在新项目中使用 Java 17

在我们的新 Java 项目中使用 Java 17,我们只需要以下几个步骤:

  1. 打开您的 IntelliJ IDEA 终端并选择文件 | 新建 | 新建项目

图 2.5 – 在 IntelliJ IDEA 中创建新项目

图 2.5 – 在 IntelliJ IDEA 中创建新项目

我们将看到前面的模式窗口,并选择我们需要开发的项目类型。我们还可以看到我们可以为我们的项目选择所需的 SDK 版本。

  1. 我们将使用 Java 17,因此我们需要选择OpenJDK-17

  2. 点击下一步并配置您的项目名称和目录。这将使用所选的 SDK 版本设置您的 Java 应用程序。

我们现在已成功配置了我们的新项目,并使用 JDK 17。现在我们想要配置现有项目以使用 JDK 17。

在现有项目中使用 Java 17

在将我们的项目从旧版本升级到 Java 17 后,我们需要遵循几个步骤以确保我们的应用程序能够正常工作。首先,请记住,这个升级到 Java 17 的步骤只是升级的一般配置。它取决于项目的当前版本。此外,您的项目正在使用一些已经过时的代码和依赖项。

我们只需几个步骤就可以在我们的现有 Java 项目中使用 Java 17:

  1. 打开您的 IntelliJ IDEA IDE 并打开您的现有项目。

  2. 在菜单中选择 文件 并选择 项目结构。我们将看到一个模态窗口,我们可以在这里配置我们的 项目设置

图 2.6 – IntelliJ IDEA 项目设置

图 2.6 – IntelliJ IDEA 项目设置

  1. 项目 SDK 字段下,我们将选择 openjdk-17 以在我们的项目中使用 Java 17。我们还可以选择 项目语言级别 以使用 Java 17 的一些新功能,例如 密封类switch 的模式匹配。记住,在更改 项目语言级别 时,请确保在 模块 选项卡下的模块也设置了相同的级别。

现在我们已经用 Java 17 配置好了我们的项目。在下一节中,我们将安装 Java 和 Angular 开发中的一些有用工具,例如 REST 客户端和 Angular DevTools。

安装 REST 客户端 VS Code 或 JetBrains 和 Angular DevTools

在本节中,我们将指导您安装 REST 客户端Angular DevTools。REST 客户端是 RESTful API 的一个重要工具。它是一个用于向您开发的 API 发送 HTTP 请求的工具,以调试代码在端点的流程以及其响应。有几种第三方平台用于 API 测试,例如 Postman,但 REST 客户端可以直接安装到您的 IDE 中。

另一方面,Angular DevTools 是一个用于 Angular 的 Chrome 扩展程序,它为 Angular 应用程序提供调试和性能分析功能。该扩展程序支持 Angular 版本 9 及更高版本,并且也支持 Ivy。

在 VS Code 中安装 REST 客户端

我们只需遵循以下步骤即可在 VS Code 中安装 REST 客户端:

  1. 首先,打开 VS Code 编辑器并转到 扩展 选项卡。

  2. 搜索 REST 客户端

  3. 选择由 胡浩茂 开发的 REST 客户端 并安装扩展。

图 2.7 – REST 客户端安装

图 2.7 – REST 客户端安装

安装完成后,我们可以通过在项目中创建 HTTP 文件并编写 HTTP 请求的端点来使用扩展。以下是一个使用 REST 客户端的示例格式:

GET https://test.com/users/1 HTTP/1.1
###
GET https://test.com /blogs/1 HTTP/1.1
###
POST https://test.com/rate HTTP/1.1
content-type: application/json
{
    "name": "sample",
    "rate": 5:
}

我们现在已成功在 VS Code 中安装了 REST 客户端,并可以测试 RESTful API。现在,我们将向您展示如何安装 Angular DevTools,这对于调试 Angular 应用程序非常重要。

安装 Angular DevTools

Angular DevTools 是一个 Chrome 扩展。要安装扩展,你可以直接从chrome.google.com/webstore/detail/angular-devtools/ienfalfjdbdpebioblfackkekamfmbnh安装,并按照以下步骤操作:

  1. 点击添加到 Chrome,我们就可以直接在我们的浏览器上使用 Angular DevTools。记住,我们只能在开发模式下运行的 Angular 应用程序中使用 Angular DevTools。

  2. 在我们的开发者工具中,我们将看到添加了一个名为Angular的新标签页。

  3. 运行你的 Angular 项目并选择Angular标签页。

图 2.8 – Angular DevTools 安装

图 2.8 – Angular DevTools 安装

Angular DevTools 将有两个标签页:组件标签页,它将显示你的应用程序结构,以及分析器标签页,用于识别应用程序性能和瓶颈。

我们已成功安装 Angular DevTools。最后但同样重要的是,我们将安装 Git 版本控制,这对于代码版本化和团队协作将非常有用。

安装 Git 版本控制

安装Git,一个分布式版本控制系统,将是我们在开发中需要的最后一件事。Git 对于开发者来说很重要,因为它用于保存项目不同版本和存储库的不同阶段。Git 还可以帮助你在代码中做了更改导致你的应用程序损坏且无法修复时,回滚存储库的最近工作版本。

访问 http://git-scm.com/,然后在屏幕上点击下载按钮以下载和安装 Git。

现在你已经从本节的最后部分学习了关于 Git 版本控制的内容,包括在哪里获取它、它做什么以及为什么它至关重要。让我们总结一下。

摘要

通过这些,我们已经到达了本章的结尾。让我们回顾一下你学到的宝贵内容。你学习了如何安装 VS Code、其特性以及我们可以在 Angular 开发中使用的必要扩展。

你还学习了如何安装 IntelliJ IDEA、其特性以及我们将在 Java 开发中使用的插件。你还学习了如何安装 Java 17 并将其配置为与 IntelliJ IDEA 中的新项目和现有项目一起使用。

SDKMAN 是一个开发工具包管理器,它使我们能够切换 JDK 版本并直接安装 Java 开发包。REST 客户端是一个用于测试 RESTful API 的工具,无需在我们的机器上下载任何第三方工具。

Angular DevTools 是一个为 Angular 提供的 Chrome 扩展,它为 Angular 应用程序提供调试和分析功能。最后但同样重要的是,Git 版本控制是一个创建代码历史记录的工具,你可以快速回滚或创建应用程序的新版本。

在下一章中,我们将重点关注 Spring Boot 及其特性。

第二部分:后端开发

本部分包含了一个开发 Java Spring Boot 2.5 应用程序的真实场景。以下章节包含在本部分中:

  • 第三章, 进入 Spring Boot

  • 第四章, 设置数据库和 Spring Data JPA

  • 第五章, 使用 Spring 构建 API

  • 第六章, 使用 OpenAPI 规范记录 API

  • 第七章, 使用 JWT 添加 Spring Boot 安全

  • 第八章, 在 Spring Boot 中记录事件

  • 第九章, 在 Spring Boot 中编写测试

第三章:进入 Spring Boot

在上一章中,你学习了如何使用 Java 和你的 Angular 应用程序设置你的开发环境来开发你的 REST API。我们还安装了 SDKMAN! 来管理多个版本的 Java,一个 REST 客户端来测试 API 而不使用第三方工具,Angular DevTools 来调试你的 Angular 应用程序,以及 Git 用于代码版本控制和协作。

本章将教你 Spring Boot 的概念。我们将深入探讨 Spring Boot 的基础和我们需要学习的开发后端应用程序的基本知识。我们还将学习如何使用 Spring Initializr 创建 Spring Boot 项目。

本章将涵盖以下主题:

  • 理解 Spring Boot

  • 使用 Spring Initializr

  • 依赖注入

  • Bean 和注解

技术要求

完成本章你需要以下内容:

  • 用于构建后端:JetBrains 的 IntelliJ IDEA 和 Java 17 SDK

  • 用于生成 Java 项目:Spring Initializr

注意

由于这里的大部分主题都是理论,并包含一些示例代码,因此第 1 到 4 章将不会有存储库目录。实际的应用程序项目将从 w使用 Spring 构建 API 开始。

理解 Spring Boot

我们已经在 第一章 中讨论了 Spring 的概述,Spring Boot 和 Angular – 大图景。在本节中,我们将更深入地了解 Spring Boot 的基本概念,以便构建你的后端应用程序,但首先,让我们回顾一下 Spring Boot 是什么以及它的显著优势。

Spring Boot 是来自 Pivotal 的开源微框架。它是一个面向企业级开发者的框架,用于在 Java 虚拟机JVMs)上创建独立应用程序。它的主要重点是缩短你的代码长度,以便你更容易运行应用程序。

该框架扩展了 Spring 框架,允许以更有见地的方来配置你的应用程序。此外,它还内置了自动配置功能,可以根据你的设置配置 Spring 框架和第三方包。

这里是 Spring Boot 的显著优势:

  • 自动配置:当配置你的 Spring Boot 应用程序时,它会下载运行应用程序所需的所有依赖项。

  • 有见地的方法:Spring Boot 使用一种基于应用程序需求的有见地的方法来安装依赖项。手动配置被移除,因为它添加了应用程序所需的包。

  • Spring starters:我们可以在初始化过程中选择一系列的启动依赖项来定义应用程序预期的需求。一个例子是 Spring Web,它允许我们初始化一个基于 Spring 的 Web 应用程序。

现在,我们已经了解了 Spring Boot 及其优势。接下来,让我们讨论 Spring Boot 的架构。

Spring Boot 架构

Spring Boot 由不同的层和类组成,用于处理后端中的数据和逻辑。以下是其四个层及其用途:

  1. 表示/显示层:表示层负责将 JSON 参数解释为对象。这一层是上层,也负责处理身份验证和 HTTP 请求。在完成 JSON 转换和身份验证后,我们现在将转向业务层。

  2. 业务层:正如其名所示,业务层处理应用程序中的所有业务逻辑。它由执行授权和额外验证的服务类组成。

  3. 持久层:持久层主要负责将对象从数据库行转换为存储逻辑,以插入数据。

  4. 数据库层:数据库层执行 创建、读取、更新和删除CRUD)操作。该层可以由多个数据库组成。

Spring Boot 架构依赖于 Spring 框架。该框架使用其所有功能,例如 Spring DAOimpl 类。

现在,让我们讨论 Spring Boot 流程架构,我们将看到数据如何在应用程序内部处理。

Spring Boot 流程架构

Spring Boot 流程架构将解释 HTTP 请求的处理方式和层之间的通信方式。流程由控制器、服务层、数据库和模型组成。为了更好地理解,让我们看一下以下图表。

图 3.1 – Spring Boot 流程架构

图 3.1 – Spring Boot 流程架构

在 Spring Boot 流程架构中,首先发生的是客户端向控制器发送一个请求(一个 HTTPS 请求)。控制器映射请求并决定如何处理它。接下来,它调用服务层,在那里执行所有业务逻辑,并从存储库类中获取操作所需的额外依赖项。服务层还负责对表示为模型的数据执行逻辑,并将由 JPA 用于插入数据库。

我们已经学习了 Spring Boot 架构的流程。现在,我们将讨论 表示状态转换REST)及其概念。

理解 REST

在我们开始构建我们的后端应用程序之前,我们必须首先了解 REST 的概念,因为这是我们为后端应用客户端应用程序可消费而将应用的主要架构方法。

REST 是一种旨在使网络服务更有效的网络服务。它允许通过 统一资源标识符URI)直接访问应用程序,并提供 XML 或 JSON 格式的资源,使其更加灵活。

URI 是两个应用程序之间通信的地方。将其视为后端和前端通信的桥梁。客户端(前端)请求一个资源,并以 XML 或 JSON 格式返回一个响应。请求资源使用以下 HTTP 方法:

  • GET:这用于获取和读取资源。

  • POST:这会创建一个新的资源。

  • PUT:这会更新现有资源。

  • DELETE:这会删除一个资源。

让我们用一个简单的现实世界示例(一个博客应用程序)来举例,其中我们使用 HTTP 方法通过提供的端点访问资源:

  • GET /user/{id}/blogs:这会获取特定用户的博客列表。

  • POST /user/{id}/blog:这为特定用户创建一个博客。

  • PATCH /user/{id}/blog/{blog_id}:这会更新特定用户的一个现有博客。

  • DELETE /user/{id}/blog/{blog_id}:这会删除特定用户的一个现有博客。

在前面的示例中,我们使用 HTTP 方法和端点请求资源。端点在响应体中以 XML 或 JSON 的形式返回一个对象。REST 还支持标准状态码,这将定义我们的请求是否成功。以下是一些常用状态码的列表:

  • 200:请求成功的状态

  • 201:表示一个对象已成功创建

  • 400:表示一个错误的请求——通常发生在请求体无效时

  • 401:未经授权访问资源

  • 404:表示资源未找到

  • 500:表示内部服务器错误

状态码是客户端应用程序在 HTTP 调用后将要执行的有用指示,概述了我们可以如何使用 REST 在客户端和服务器通信中。

图 3.2 – 客户端和服务器应用程序之间的通信

图 3.2 – 客户端和服务器应用程序之间的通信

在本节中,我们学习了 Spring Boot 的概念和架构。我们还现在知道了 REST 的理念以及它是如何提供后端解决方案的。在下一节中,我们将使用 Spring Initializr 生成我们的新 Spring Boot 项目。

使用 Spring Initializr

本节将解释Spring Initializr是什么以及如何配置和启动我们的项目。Spring Initializr 是一个可以即时生成 Spring Boot 项目的 Web 应用程序。Spring Initializr 将配置构建文件,包含运行我们的项目所需的依赖项,仅关注应用程序中的代码。Spring Initializr 通过侧边的Spring Boot CLI帮助我们配置应用程序,使得设置项目更加容易。Spring Initializr 生成一个更传统的 Java 结构。

有几种方法可以使用 Spring Initializr:

  • 通过基于 Web 的界面

  • 通过 IntelliJ IDEA

我们将讨论生成我们的 Spring Boot 应用程序的不同方法。

基于 Web 的界面

使用 Spring Initializr 的第一种方式是通过基于 Web 的界面。应用程序可以通过 start.spring.io 访问。一旦打开链接,您将看到以下表单:

图 3.3 – Spring Initializr

图 3.3 – Spring Initializr

表单将要求您提供一些关于您项目的基本信息。第一个问题是,您在 Maven 和 Gradle 之间如何选择来构建您的项目? 应用程序还需要有关您将使用哪种语言、工件名称、项目名称和要使用的包名称以及构建应用程序时将使用的 JDK 版本的信息。

现在,在界面的右侧,您将看到 添加依赖项 按钮。添加依赖项 功能是 Spring Initializr 最重要的功能之一,因为它将允许我们根据项目的需求选择依赖项。例如,如果我们需要一个具有 JPA 访问的数据库,我们应该添加 Spring Data JPA。

因此,我们在以下示例中添加了 LombokSpring WebSpring Data JPAPostgreSQL 驱动程序Spring Data Reactive Redis。我们将在构建示例应用程序的过程中讨论每个依赖项。

图 3.4 – 使用依赖项生成 Spring Boot

图 3.4 – 使用依赖项生成 Spring Boot

在前面的示例中,我们可以看到我们已经在我们的项目中添加了所需的依赖项。最后一步是通过点击 生成 按钮来生成我们的应用程序;这将下载一个包含我们的应用程序的 zip 文件。在生成项目之前,我们可以点击 探索 按钮来检查我们的项目结构和验证配置。

成功下载生成的 Spring Boot 应用程序后,我们将解压文件,现在我们可以使用我们选择的 IDE 打开 Spring Boot 项目。最后,我们准备好编写代码了,但首先,让我们看看 Spring Initializr 生成的项目结构。

图 3.5 – 生成的 Spring Boot 应用程序

图 3.5 – 生成的 Spring Boot 应用程序

从生成的项目中我们可以看到,其中包含的应用程序代码并不多。然而,项目包括以下内容:

  • DemoApplication.java:一个包含应用程序启动 main() 函数的类

  • DemoApplicationTests.java:一个空的 JUnit 测试类,用于单元测试

  • Pom.xml:一个包含应用程序所需依赖项的 Maven 构建规范

  • Application.properties:一个用于添加配置属性的属性文件

我们可以在生成的项目中看到包括空目录,例如 static 文件夹;这很重要,因为这个文件夹用于放置 CSS 和 JavaScript 等静态内容文件。

我们已经成功使用网络界面生成了我们的 Spring Boot 项目。现在,我们将直接在 IntelliJ IDEA 中使用 Spring Initializr。

通过 IntelliJ IDEA

另一种生成我们的 Spring Boot 项目的办法是直接在 IntelliJ IDEA 中使用 Spring Initializr;请注意,这仅在 IntelliJ 的 Ultimate 版本中可用。如果你使用的是 Community 版本,你可以在以下链接中安装 Spring Assistant:plugins.jetbrains.com/plugin/10229-spring-assistant。这将添加一个 Spring Assistant 选项来生成你的 Spring Boot 项目。

执行以下步骤:

  1. 在打开 IntelliJ IDEA 并开始生成项目时选择 新建项目,这将打开一个新模态窗口。

  2. 选择 Spring Initializr,这将打开一个与 Spring Initializr 相同的网络界面表单。

  3. 它将要求提供诸如项目名称、将要使用的语言、工件名称以及用于构建项目的 SDK 版本等详细信息:

图 3.6 – 使用 IntelliJ IDEA 与 Spring Initializr 的表单

图 3.6 – 使用 IntelliJ IDEA 与 Spring Initializr 的表单

我们可以在前面的图中看到,我们已经为我们的项目填写了所有必要的详细信息。

  1. 点击 下一步 按钮将带我们转到 依赖项 选择页面。我们将选择用于 Spring Boot 开发的依赖项,这些依赖项与我们在 Sprint Initializr 界面中输入的相同。

  2. 在成功检查依赖项后,点击 完成,我们的 Spring Boot 应用程序配置完成。最后,我们准备好编写代码。

我们已经通过 Spring Initializr 网络界面和内置的 IntelliJ IDEA 成功生成了 Spring Boot 应用程序。在下一节中,我们将学习 Spring Boot 中最重要且最常用的概念之一——依赖注入。

依赖注入

我们已经成功生成了自己的 Spring Boot 项目,现在,我们将开始学习 Spring 的概念,其中最重要的概念之一是我们需要理解的 依赖注入。随着我们使用 Spring Boot 开发后端,我们将在整个开发过程中主要使用依赖注入,因为这使我们的 Java 程序模块化,并使实现之间的切换更加容易。

依赖注入是面向对象编程语言的一个基本特性,但首先,让我们讨论一下控制反转的概念,这正是依赖注入试图实现的目标。

控制反转

控制反转IoC)是面向对象编程语言中使用的模式。IoC 是反转程序流程的概念,它用于解耦应用程序中的组件,使代码可重用且模块化。因此,IoC 设计模式将为我们提供一种将自定义类注入到应用程序中其他类的方法。

注入的类将在我们应用程序的不同部分实例化。我们不是让我们的类决定其实现或进行代码修复,而是允许依赖注入改变类的流程、性能和代码,具体取决于情况。因此,IoC 主要提供灵活性和模块化,但也为设计应用程序提供了其他几个优点:

  • 控制对象的生命周期,我们可以将一些对象定义为单例,而一些对象可以有它们的实例。

  • 由于代码减少了可重用组件,这使得应用程序更容易维护。

  • 组件的测试更加容易管理,因为我们可以隔离组件并模拟它们的依赖项,而不覆盖将不包括在单元测试中的其他代码。

我们已经了解了 IoC 模式及其在开发应用程序中的优势。现在,我们将使用依赖注入,这允许我们实现这种模式。

依赖注入的基本原理

我们已经讨论了 IoC 的工作原理,它是通过允许实现通过向对象提供依赖项来决定的。因此,这个想法主要是依赖注入。我们允许对象或类接受其他依赖项,这些依赖项可以提供不同类的实现,而无需再次编写它们,使我们的代码更加灵活和可重用。依赖注入可以通过不同的方式实现,以下是一些实现方法。

基于构造器的依赖注入

基于构造器的依赖注入可以通过创建一个具有构造函数的对象类来实现,该构造函数的参数类型表示我们可以设置的依赖项。

让我们看一下以下代码示例:

package com.springexample;
/* Class for Student */
public class Student {
   private Grades grades;
   public Student(grades: Grades) {
      this.grades = grades;
   }
   public void retrieveGrades() {
      grades.getGrades();
   }
}

在前面的示例中,Student类有一个构造函数public Student() {},它接受一个类型为Grades的参数。构造函数允许我们在Student中注入一个Grades对象,使得Grades对象的所有实现都可以在Student对象中访问。现在,我们已经访问了Student中的getGrades()方法。要使用Student对象,我们将执行以下示例:

package com.springexample;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class Main {
   public static void main(String[] args) {
      ApplicationContext context =
        new  ClassPathXmlApplicationContext("Beans.xml");
      Student student =
        (Student) context.getBean("student");
      student.retrieveGrades();
   }
}

在前面的示例中,我们可以看到我们在主类中通过获取Beans.xml文件中的 Bean 来实例化一个新的学生。Beans.xml文件是我们基于构造器注入的主要配置文件,我们将在这里定义我们的 Bean 及其依赖项。

让我们看看以下示例,了解Beans.xml的格式:

<?xml version = "1.0" encoding = "UTF-8"?>
<beans xmlns =
   "http://www.springframework.org/schema/beans"
   xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation =
     "http://www.springframework.org/schema/beans
      http://www.springframework.org/schema/beans/
      spring-beans-3.0.xsd">
   <!-- Definition for student bean -->
   <bean id = "student"
     class = "com.springexample.Student">
     <constructor-arg ref = "grades"/>
   </bean>
   <!-- Definition for grades bean -->
   <bean id = "grades"
     class ="com.springexample.Grades"></bean>
</beans>

在前面的示例中,我们已经将StudentGrades对象定义为 Bean。唯一的区别是Student对象有一个constructor-arg属性,它引用了成绩;这表明我们正在将Grades对象注入到我们的Student对象中。

我们已经通过使用Beans.xml配置实现了基于构造器的依赖注入。我们还可以直接在我们的代码中使用注解来配置我们的 Bean 及其依赖项。

让我们看看如何使用注解配置 bean 和依赖关系的以下示例:

@Configuration
public class AppConfig
{
   @Bean
   public Student student() {
       return new Student(grades());
    }
   @Bean
   public Grades grades() {
      return new Grades();
    }
}

在前面的示例中,我们可以看到我们没有使用 XML,而是使用了注解来识别我们的 bean 和配置。例如,@Configuration注解表示AppConfig类是 bean 定义的来源,而@Bean注解定义了我们的应用程序中的 bean。随着我们继续本章,我们将深入讨论注解和 bean。

我们已经成功地学习了如何通过使用Bean.xml和注解来实现基于构造函数的依赖注入。现在,让我们继续到基于 setter 的依赖注入的实现。

基于 setter 的依赖注入

当容器调用我们的类的 setter 方法时,可以实现依赖项的注入。因此,我们不会为类创建构造函数,而是创建一个设置对象依赖项的功能。

让我们看看一个基本的代码示例:

package com.springexample;
/* Class for Student */
public class Student {
   private Grades grades;
   public void setGrades(grades: Grades) {
      this.grades = grades;
   }
   public Grades getGrades() {
      return grades;
   }
   public void retrieveGrades() {
      grades.getGrades();
   }
}

在前面的示例中,我们可以看到我们创建了一个名为setGrades()的 setter 方法,它接受一个Grades对象,其主要功能是为grades依赖项设置一个值。

我们不是使用带参数的构造函数,而是使用 setter 来将我们的依赖项注入到我们的对象中。

要使用Student对象,让我们看看以下示例:

package com.springexample;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class Main {
   public static void main(String[] args) {
      ApplicationContext context = new
        ClassPathXmlApplicationContext("Beans.xml");
      Student student =
        (Student) context.getBean("student");
      student.retrieveGrades();
   }
}

在前面的示例中,我们可以看到它与使用 setter-based 对象和 constructor-based 对象的方式相同。这里的区别在于我们在Bean.xml中如何配置我们的 bean。

让我们看看 setter-based 依赖注入的Beans.xml示例:

<?xml version = "1.0" encoding = "UTF-8"?>
<beans xmlns =
   "http://www.springframework.org/schema/beans"
   xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation =
     "http://www.springframework.org/schema/beans
      http://www.springframework.org/schema/beans/
      spring-beans-3.0.xsd">
   <!-- Definition for student bean -->
   <bean id = "student"
     class =   "com.springexample.Student">
     <property name="grades" ref = "grades"/>
   </bean>
   <!-- Definition for grades bean -->
   <bean id = "grades"
     class ="com.springexample.Grades"></bean>
</beans>

在前面的示例中,我们在Beans.xmlStudentGrades对象中配置了 bean。这里唯一的区别在于我们声明依赖项的方式。我们使用property标签而不是constructor-arg来定义我们的依赖项。

我们已经成功地使用 setter-based 依赖注入创建了我们的对象,现在,我们将讨论基于字段的依赖注入。

基于字段的依赖注入

如其名所示,@Autowired注解用于注入。

让我们看看以下将依赖项注入字段的示例:

package com.springexample;
/* Class for Student */
public class Student {
   @Autowired
   private Grades grades;
}

在前面的示例代码中,我们没有创建构造函数或 setter 方法来注入我们的依赖项。相反,我们只使用了@Autowired注解来注入Grades对象。

从表面上看,基于字段的注入可能很简洁,我们的代码中只有注解,方法更少,但我们的@Autowired依赖项背后发生了许多事情。例如,它使用反射来注入比构造函数和基于 setter 的注入成本更高的依赖项;它还违反了单一职责原则。我们可以在字段中直接添加更多依赖项而无需警告。

我们已经学习了依赖注入的基础知识以及在我们 Java 应用程序中实现它的不同方式。现在,我们将讨论 Spring 中注解和 bean 的概念及其重要性。

注解与 bean

注解与 bean 是开发 Spring 应用程序的基本组成部分。它们被认为是 Spring 的构建块,使我们的代码更简洁、易于维护。

Spring 注解用于定义不同类型的 bean。它们只是标记我们代码的一种元数据形式,提供信息。相反,bean 是实例化和创建的对象,可以被其他 bean 注入。我们将在这个部分继续讨论。

注解类型

Spring 中的注解根据其功能被分为不同的类型。以下是根据其各自类别分组的注解。

核心注解

org.springframework.beans.factory.annotationorg.springframework.context.annotation 包。以下是一个核心注解列表:

  • @Required:这个注解应用于 bean 的设置方法,意味着在配置时必须注入依赖。否则,将抛出 BeanInitializationException。让我们看看如何使用 @Required 注解的以下示例:

    public class Car
    
    {
    
    private String brand;
    
    @Required
    
          public void setBrand(String brand)
    
          {
    
            this.brand = brand;
    
    }
    
         public Integer getBrand()
    
         {
    
           return brand;
    
         }
    
    }
    

在前面的示例中,我们可以看到 setBrand() 方法被注解为 @Required;这表示在初始化时必须填充品牌。

  • @Autowired:我们在依赖注入(DI)中多次遇到了 @Autowired 注解,这主要用于在不使用构造函数和设置方法的情况下注入依赖。让我们看看如何使用 @Autowired 注解的以下示例:

    package com.springexample;
    
    public class Car {
    
       @Autowired
    
       private Brand brand;
    
    }
    

在前面的示例中,我们可以看到 @Autowired 直接应用于字段。这是因为注解使用反射来注入依赖,涉及的过程比构造函数和设置方法更多。

  • @ComponentScan:这个注解是一个类级别的注解,用来指示我们想要扫描的包。@ComponentScan 可以接受关于要扫描的特定包的参数,如果不提供任何参数,将允许当前包及其所有子包。让我们看看如何使用 @ComponentScan 的以下示例:

    @Configuration
    
    @ComponentScan
    
    public class SpringApp
    
      {
    
       private static ApplicationContext
    
         applicationContext;
    
       @Bean
    
       public SpringBean springBean()
    
       {
    
          return new SpringBean();
    
       }
    
       public static void main(String[] args) {
    
         applicationContext = new
    
           AnnotationConfigApplicationContext(
    
            SpringComponentScanApp.class);
    
      }
    
    }
    

在前面的示例中,我们可以看到 @ComponentScan 注解被应用于 Spring 的 App 类,通常与 @Configuration 注解一起实现。假设 SpringApp 位于 com.example.spring.app 包下;这将扫描该包及其子包,如果存在现有的 bean。

  • @ComponentScan:这个注解也是一个类级别的注解,用来指示一个类是 Spring 容器在运行时将处理的 bean 定义源。让我们看看如何使用 @ComponentScan 注解的以下示例:

    @Configuration
    
    public class SpringApp {
    
        @Bean(name="demoBean")
    
        public DemoBean service()
    
        {
    
        }
    
    }
    

在前面的示例中,我们可以看到 @Configuration 注解被应用于 SpringApp 类,这表示 SpringApp 将是 bean 的来源。

  • @Bean:这个注解是一个方法级注解,用于告诉方法生成一个 bean。让我们看看以下如何使用 @Bean 注解的示例:

    @Configuration
    
    public class AppConfig {
    
        @Bean
    
        public BeanExample beanExample() {
    
            return new BeanExampleImlp();
    
        }
    
    }
    

在前面的示例中,@Bean 注解被应用于 beanExample 方法。一旦 JavaConfig 遇到该方法,它将被执行并将返回值注册为 BeanFactory 中的一个 bean,如果没有指定名称,则名称将与方法名称相同。

@Bean 注解也可以在 Spring XML 中配置,等效的配置如下:

<beans>
    <bean name="transferService"
      class="com.acme.TransferServiceImpl"/>
</beans>

类型注解

类型注解主要用于在应用程序上下文中动态创建 Spring bean。以下是一系列类型注解的列表:

  • @Component:这是主要的类型注解。与 @Bean 注解一样,@Component 注解用于定义一个 bean 或 Spring 组件。两者的区别在于 @Component 应用在类级别,而 @Bean 应用在方法级别。

另一个区别是,如果类位于 Spring 容器外部,@Component 类不能用来创建 bean,而我们可以使用 @Bean 注解即使在类位于 Spring 容器外部的情况下也能创建 bean。让我们看看以下如何使用 @Component 注解的示例:

@Component
public class Car
{
.......
}

我们可以在前面的示例中看到,@Component 注解被应用于 Car 类。这意味着这将创建一个运行时的 car bean。我们还需要记住,@Component 注解不能与 @Configuration 注解一起使用。

  • @Service:这个注解用于服务层,表示一个类用于执行业务逻辑、进行计算和调用外部 API。@Service 是一种 @Component 注解。

  • @Repository:这个注解用于直接访问数据库的类。这是一个表示执行数据访问对象角色的类的指示。

  • @Controller:这些注解用于 Spring 控制器类。它也是一种 @Component 注解,用于 Spring MVC 和使用 @RequestMapping 注解的方法,后者用于 REST。

Spring Boot 注解

这些注解是专门为 Spring Boot 创建的,这主要是几个注解的组合。以下是一系列 Spring Boot 注解的列表:

  • @EnableAutoConfiguration:这个注解用于自动配置类路径中存在的 bean,然后配置它以运行方法。这个注解现在很少使用,因为 @SpringBootApplication 已经在 Spring 1.2.0 中发布。

  • @SpringBootApplication:这个注解是 @EnableAutoConfiguration@ComponentsScan@Configuration 的组合。

REST 注解

这些是用于创建端点、指定 HTTP 请求和序列化返回对象的专用注解。以下列表显示了不同的 REST 注解:

  • @RequestMapping: 这用于创建端点和映射网络请求。注解可以在类或方法中使用。

  • @GetMapping: 这将映射 HTTP GET请求,用于获取数据,并且它与@RequestMapping(method = RequestMethod.GET)等价。

  • @PostMapping: 这将映射 HTTP POST请求,用于创建数据,并且它与@RequestMapping(method = RequestMethod.POST)等价。

  • @PostMapping: 这将映射 HTTP PUT请求,用于更新数据,并且它与@RequestMapping(method = RequestMethod.PUT)等价。

  • @DeleteMapping: 这将映射 HTTP PUT请求,用于删除数据,并且它与@RequestMapping(method = RequestMethod.DELETE)等价。

  • @DeleteMapping: 这将映射 HTTP PATCH请求,用于对数据进行部分更新,并且它与@RequestMapping(method = RequestMethod.PATCH)等价。

  • @RequestBody: 这用于将 HTTP 请求绑定到方法参数中的对象。Spring 框架将参数的 HTTP 请求体与@RequestBody注解绑定。

  • @ResponseBody: 这将方法的返回值附加到响应体上。该注解表示返回对象应该被序列化为 JSON 或 XML 格式。

  • @PathVariable: 这用于从 URI 中获取值。在方法中可以定义多个@PathVariable实例。

  • @RequestParam: 这用于从 URL 中获取查询参数。

  • @RequestHeader: 这用于提取关于传入 HTTP 请求头部的详细信息。我们在方法参数中使用这个注解。

  • @RestController: 这是@Controller@ResponseBody注解的组合。这个注解的重要性在于它防止了在每个方法上使用@ResponseBody注解。

我们已经了解了不同类型的注解及其在 Spring 中的应用。现在,我们将在下一节讨论并理解 Spring 应用程序中 bean 的实际定义和重要性。

理解 bean

我们在上一节已经多次遇到了 bean。我们学习了如何使用@Bean@Component注解创建和初始化 bean,但主要问题是,bean 在 Spring 应用程序中的主要用途是什么?

bean是 Spring 框架的核心概念,我们需要理解它。了解其目的和功能对于有效地使用 Spring 框架至关重要。

在 Spring 中定义 bean,它是一个由 Spring IoC 容器管理的对象,构成了应用程序的骨干。这些是我们主要用来处理数据和注入依赖以创建多个实现的对象。为了更好地理解,让我们看看一些 bean 的例子。

假设我们有一个名为Car的域类:

public class Car
{
  private Brand brand;
  public Car (Brand brand)
  {
  this.brand = brand;
  }
}

在示例中,我们可以看到汽车需要一个Brand依赖。Brand类有如下代码:

public class Brand
{
  private String name;
  private int year;
  public Address(String name, int year)
  {
     this.name = name;
     this.year = year;
  }
}

典型的做法是在创建新的Car类实例时创建一个新的Brand类实例,并将其作为参数传递。这种方法可以正常工作,但当有多个类时可能会引起问题。因此,更好的做法是,对象不是自己构建依赖项,而是可以从 IoC 容器中以 bean 的形式检索它们的依赖项。

因此,我们只需要使用注解或 XML 配置 bean 和依赖项,以标识特定对象所需的依赖项。让我们将前面的示例转换为 bean:

@Component
public class Car
{
 . . . .
}

我们将使用@Component注解来标注Car类,以将其识别为Bean

@Configuration
@ComponentScan(basePackageClasses = Car.class)
public class Config
{
  @Bean
  public Brand getBrand() {
   return new Brand("Toyota", 2021);
  }
}

接下来,我们需要做的是创建一个配置类。在前面的示例中,我们已经使用@Configuration@ComponentScan注解来标注这个类,以标识这是一个配置类;这将生成一个类型为BrandBean,配置了Brand类作为Bean。我们只需要从应用程序上下文中拉取 bean,依赖项已经注入:

ApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
Car car = context.getBean("car", Car.class);
// execute function
car.getName()
car.getYear()

在前面的示例代码中,我们可以看到我们已经从应用程序上下文中提取了Car对象。因此,我们可以自动使用Brand依赖项的 getter 方法;这意味着 IoC 容器管理着 bean 及其依赖项。

摘要

通过这种方式,我们已经到达了本章的结尾。让我们回顾一下你学到的宝贵知识。你已经学习了 Spring Boot 的基础知识、其架构和 REST 的基本知识。你还学习了如何使用 Spring Initializr 创建自己的 Spring Boot 项目。

依赖注入允许对象或类接受其他依赖项,这些依赖项可以实现不同的类,而无需再次编写它们。注解定义了不同类型的 bean;它们只是标记我们的代码以提供信息的一种元数据形式。

最后,bean 是 Spring IoC 容器管理的应用程序的骨架对象。

在下一章中,我们将学习如何设置数据库并使用 Spring Data JPA。

第四章:设置数据库和 Spring Data JPA

在上一章中,您学习了 Spring Boot 的基本知识,以开发我们的后端应用程序,例如依赖注入、bean 和注解。此外,我们现在知道如何使用 Spring Initializr 创建 Spring Boot 项目。

本章将教授您如何通过添加 PSQL 容器和 PostgreSQL 依赖项以及使用Java 持久化 APIJPA)来访问数据,将您的 Spring Boot 应用程序连接到数据库。

本章将涵盖以下主题:

  • 使用 PSQL 容器连接到数据库

  • Spring Data JPA

  • 添加 Spring Data JPA 和 PostgreSQL 依赖项

  • 连接到数据库

技术要求

完成本章所需的内容如下:

使用 PSQL 容器连接到数据库

本节将教授我们如何通过使用传统方法、安装程序或通过 Docker 容器在我们的终端中设置和配置我们的 PostgreSQL。但首先,让我们讨论一下 PostgreSQL 是什么以及它的优势是什么。

PostgreSQL

PostgreSQL是一个开源的对象关系型数据库系统,使用 SQL 语言来存储和处理复杂和大量工作负载。PostgreSQL 还支持SQL(关系型)JSON(非关系型)查询。由于其灵活性和丰富的功能,它通常用作地理空间和分析应用程序的主要数据存储。其社区已经持续改进并支持它超过 20 年,为数据库系统添加更多功能和可靠性。

PostgreSQL 的灵活性意味着它在开发应用程序中得到了广泛应用。以下是一些日常用例:

  • 科学数据:研究项目在存储数据方面可能要求较高,这需要有效且高效的处理。PostgreSQL 提供了分析功能和强大的 SQL 引擎,可以处理大量数据。

  • 金融行业:PostgreSQL 因其分析能力和易于与数学软件(如 MATLAB 和 R)集成而用于金融公司。

  • Web 应用程序:PostgreSQL 也广泛应用于 Web 应用程序中,因为现在的应用程序需要处理数千条数据。它与现代 Web 框架(如 Node.js、Hibernate PHP 和 Django)兼容。

  • 政府 GIS 数据:PostgreSQL 提供如 PostGIS 等扩展,提供处理几何数据的功能。

PostgreSQL 的功能

下面是 PostgreSQL 提供的一些功能列表:

  • 兼容多种数据类型:PostgreSQL 兼容多种数据类型:

    • 结构化:数组、日期和时间、通用唯一识别码UUIDs)和范围

    • 自定义:自定义类型和组合

    • 原语:字符串、整数、数值和布尔值

    • 几何:多边形、圆、线和点

    • 文档:XML、JSON/JSONB 和键值

  • 支持 SQL 的不同功能:它提供了 SQL 的各种功能,如下所示:

    • 多种索引,如 B 树和表达式

    • SQL 子查询

    • 复杂 SQL 查询

    • 多版本并发控制MVCC):

      • 表分区
  • UNIQUE

  • NOT NULL

  • 安全数据库:它遵循标准安全协议,包括以下内容:

    • 身份验证,如轻量级目录访问协议LDAP)、SCRAM-SHA-256 和安全支持提供程序接口(SSPI

    • 支持列和行级安全* 高度可扩展:它提供了几个功能,使其可修改,如下所示:

    • JSON/SQL 路径表达式

    • 存储过程和函数

    • 与外部数据包装器的兼容性

现在我们已经了解了 PostgreSQL 的功能和用例,让我们继续在我们的终端上安装它。

安装 PostgreSQL

我们有两种方法可以在开发终端上设置我们的 PostgreSQL。两种方法如下:

  • 传统方法:直接从 PostgreSQL 网站下载安装程序。

  • Docker 容器上的 PostgreSQL:直接将我们的应用程序连接到容器。

传统方法 – 在 Windows、macOS 和 Linux 上安装

PostgreSQL 主要是在类 Unix 平台上开发的。然而,它被设计成可移植的,可以在 Windows 和 macOS 平台上安装。

我们需要采取的第一步是通过此 URL 下载 PostgreSQL 安装程序:www.enterprisedb.com/downloads/postgres-postgresql-downloads

图 4.1 – PostgreSQL 安装

图 4.1 – PostgreSQL 安装

三个操作系统的步骤相同,我们只需要配置一些设置:

  1. 点击最新版本(14.1)并下载安装程序,具体取决于您的操作系统。

  2. 成功下载后,打开安装程序,点击下一步,并指定 PostgreSQL 的安装路径:

图 4.2 – PostgreSQL 安装程序(指定安装路径)

图 4.2 – PostgreSQL 安装程序(指定安装路径)

在前面的示例中,我们选择了默认的安装路径。再次点击下一步,这将询问我们想要安装哪些组件。我们选择的组件如下:

  • PostgreSQL 服务器:安装我们的数据库将运行的服务器

  • pgAdmin 4:一个用于与数据库交互的 GUI 管理工具

  • Stack Builder:一个 GUI,允许我们下载和安装与 PostgreSQL 兼容的驱动程序

  • 命令行工具:提供使用命令行工具与 PostgreSQL 交互:

图 4.3 – PostgreSQL 安装程序(选择所需组件)

图 4.3 – PostgreSQL 安装程序(选择所需组件)

在前面的示例中,我们已经检查了所有组件,因为我们将在整个开发过程中需要它们。

  1. 再次点击 下一步,您将被要求指定存储数据的目录:

图 4.4 – PostgreSQL 安装程序(选择数据目录)

图 4.4 – PostgreSQL 安装程序(选择数据目录)

在前面的示例中,我们可以看到默认路径与 PostgreSQL 安装的位置相同,并且它已经创建了一个名为 data 的新文件夹。建议您使用默认路径。

  1. 点击 postgres)。

  2. 点击 postgres 数据库。

  3. 再次点击 下一步,现在您将被询问数据库集群应使用哪个区域设置:

图 4.5 – PostgreSQL 安装程序(选择数据库集群的区域设置)

图 4.5 – PostgreSQL 安装程序(选择数据库集群的区域设置)

在前面的示例中,我们已经选择了 [默认区域设置] 作为我们的数据库区域设置。

  1. 再次点击 下一步,这将显示我们已配置的所有设置;在继续之前,请确保所有细节都是正确的。

  2. 审查后,点击 下一步,现在将在我们的终端中安装 PostgreSQL。

安装完成后,我们可以通过检查当前安装的版本来验证 PostgreSQL 是否已成功安装。

  1. 要做到这一点,请打开 服务器端口数据库用户名密码

  2. 由于我们已经使用了默认设置,我们可以按 Enter 键直到密码确认。在成功验证我们的密码后,执行 select version() 命令以显示当前安装的 PostgreSQL:

图 4.6 – PostgreSQL 安装程序(显示 PostgreSQL 的版本)

图 4.6 – PostgreSQL 安装程序(显示 PostgreSQL 的版本)

在前面的示例中,我们可以看到我们已经成功在我们的终端上安装了 PostgreSQL 版本 13.4

现在,让我们学习如何使用 Docker 安装和配置 PostgreSQL。

Docker 容器中的 PostgreSQL

我们已经使用传统安装程序在我们的终端上安装了 PostgreSQL;现在,我们将学习如何使用 Docker 配置 PostgreSQL。这种方法将帮助我们跳过配置 PostgreSQL 的复杂步骤,以便我们从开发开始,并提供数据库管理的 GUI:

  1. 您需要做的第一步是在您的终端上安装 Docker。您可以在以下链接安装 Docker:docs.docker.com/get-docker/。有关 Docker 的系统要求和安装步骤的文档,您可以参考此链接:https://docs.dockerocker.com/desktop/windows/install/。

  2. 在成功安装 Docker 后,打开 Docker Desktop 并在终端上启动 Docker。然后,打开您的命令行并执行以下命令:

    Docker run --name postgresql-container -p 5434:5434 -e POSTGRES_PASSWORD=pass -d postgres
    

上述命令将从 Docker-hub 拉取 PSQL。命令中的 postgresql-container 部分可以替换,因为这是一个我们可以定义的容器名称。POSTGRES_PASSWORD 参数是 postgres 管理员的密码,我们也可以配置它。

  1. 执行命令后,我们可以通过执行 Docker ps -a 命令或查看 Docker Desktop 来验证新创建的容器,以检查正在运行的容器列表:

图 4.7 – 使用 Docker 安装 PostgreSQL

图 4.7 – 使用 Docker 安装 PostgreSQL

在前面的示例中,我们已执行 Docker ps -a 命令,我们可以看到我们的 PostgreSQL 镜像已被拉取:

图 4.8 – 使用 Docker 安装 PostgreSQL(在 Docker Desktop 中查看容器)

图 4.8 – 使用 Docker 安装 PostgreSQL(在 Docker Desktop 中查看容器)

  1. 我们还可以在 Docker Desktop 中查看拉取的 postgresql-container 并在我们的终端中验证其状态。

我们已成功使用 Docker 配置了 PostgreSQL。我们可以通过创建一个新的服务器并使用我们的终端 IP 地址和端口来连接到我们的 pgAdmin

我们已在终端中配置了 PostgreSQL 数据库。现在,我们将了解 Spring Data JPA 及其在开发 Spring 应用程序中的重要性。

Spring Data JPA

Spring Data JPAJava 持久层 API)是广泛使用的规范,用于管理 Java 应用程序中的关系数据。它有助于开发 Spring,因为它通过不实现读写操作来减少样板代码。它还处理了基于 JDBC 的数据库访问和对象关系映射的复杂过程。

在讨论 Spring Data JPA 之前,让我们讨论其明显的优势以及为什么它通常在 Spring 开发中被广泛使用。

Spring Data JPA 的优势

以下为 Spring Data JPA 的优势:

  • 无代码仓库:Spring Data JPA 推崇 无代码 仓库,这意味着我们不需要编写仓库模式,这会生成大量重复的代码。它提供了一组接口,我们可以使用这些接口来扩展我们的类,以应用数据特定的实现。

例如,在我们的应用程序中有一个 BlogRepository 类;当我们用 CrudRepository<Blog, Long> 接口扩展它时,它将具有以下功能的方法:

  • 持久化、更新和删除一个或多个博客实体

  • 通过主键查找一个或多个博客

  • 计算所有博客

  • 验证单个博客是否存在

使用 Spring Data JPA 提供的接口扩展仓库包括所有数据相关的方法,这使我们能够更多地关注业务逻辑。

  • 样板代码减少: Spring Data JPA 提供了内置的实现方法。正如第一项优点所述,我们只需要关注业务逻辑,不再需要编写接口下的读写操作,这也可以防止人为错误,因为所有实现都已经为我们注册。

  • findBy,Spring 将解析名称并创建一个查询:

    public interface BlogRepository extends   CrudRepository<Blog, Long> {
    
    Blog findByAuthor(String author);
    
    }
    

在前面的示例中,我们创建了一个findByAuthor()方法,这将允许 Spring 生成一个查询并将参数设置为绑定参数值。一旦我们调用该方法,它将执行查询。

Spring Data JPA 提供的仓库

Spring Data JPA 提供了提供不同数据相关实现方法的仓库。以下是一些仓库:

  • CrudRepository: 提供基本操作(创建、读取、更新、删除)的接口仓库。

  • PagingAndSortingRepository: 扩展CrudRepostiory并添加一个名为findAll的方法,该方法可以排序结果并以分页方式检索。

  • JpaRepository: 添加特定的 JPA 方法,并具有CrudRepositoryPagingAndSortingRepository的所有功能。它还添加了如flush()这样的方法,该方法刷新持久化上下文,以及deleteInBatch(),该方法可以批量删除记录。

我们已经了解了我们可以与 Spring Data JPA 一起使用的不同仓库。现在我们将看看 Spring Boot 上的 Spring Data JPA。

Spring Boot 上的 Spring Data JPA

为了在我们的应用程序中实现 Spring Data JPA,我们需要以下组件:

  • Entity: 这是一个简单的类,用于定义我们的模型。它将被用作 JPA 实体,并带有主键生成。

例如,我们将通过创建一个普通的类并添加@Entity注解来创建一个Villain实体,以表示Villain类是一个 JPA 实体。该实体将被用作扩展我们的仓库的类型:

@Entity
public class Villain {
  @Id
  @GeneratedValue(strategy = GenerationType.AUTO,
                  generator   = "UUID")
  @Column(nullable = false, updatable = false)
  private UUID id;
  @NotNull(message = "First Name is required")
  private String firstName;
  private String lastName;
  private String house;
  private String knownAs;
}

我们可以在前面的示例中看到,我们的Villain类被注解为@Entity,表示它是一个 JPA 实体。我们还定义了一个UUID类型的id字段,并用@Id注解标注,表示这是主键,以及@GeneratedValue,其中我们指定这是使用strategy = GenerationType自动生成的。AUTO和生成的 ID 应该是UUID类型,使用generator = "UUID"

  • Repository: 这是一个接口,我们需要用它来扩展 JPA 仓库,以便实体具有内置操作。

在前面的示例中,我们有一个Villain实体。为了实现 CRUD 操作,我们将创建一个VillainRepository接口,并使用CrudRepository扩展它,类型为VillainUUID

@Repository
public interface VillainRepository extends CrudRepository<Villain, UUID> {
  // custom composite repository here
}
  • 服务: 这是我们将使用我们创建的仓库的地方。我们可以使用@Autowired注解注入仓库并调用 JPA 和自定义方法:

    @Service
    
    public class VillainService {
    
    private final VillainRepository villainRepository;
    
    @Autowired
    
    public VillainService (VillainRepository villainRepository) {
    
      this. villainRepository = villainRepository;
    
    }
    
      public Iterable<Villain> findAllVillains() {
    
        return villainRepository.findAll();
    
      }
    
      public Villain findVillainById(UUID id) {
    
        return findOrThrow(id);
    
      }
    

在前面的示例中,我们可以看到我们使用@Autowired注解在VillainService中注入了VillainRepository

让我们继续使用相同的文件进行以下方法的操作:

public void removeVillainById(UUID id) {
    villainRepository.deleteById(id);
  }
  public Villain addVillain(Villain villain) {
    return villainRepository.save(villain);
  }
  public void updateVillain(UUID id, Villain villain) {
    findOrThrow(id);
    villainRepository.save(villain);
  }
  private Villain findOrThrow(final UUID id) {
    return villainRepository
      .findById(id)
      .orElseThrow(
        () -> new NotFoundException("Villain by id " +
          id + " was not found")
      );
  }
}

我们还通过使用内置的 JPA 实现创建了方法,例如save()deleteById()findAll()findById(),这些方法可以在CrudRepository接口中找到。现在,服务可以被注入到我们的控制器或其他服务中以使用这些方法。

我们现在已经了解了 Spring Data JPA、其优势以及 Spring JPA 上的实现概述。在下一节中,我们将学习如何将 Spring Data JPA 和 PostgreSQL 依赖项添加到我们的 Spring Boot 应用程序中。

添加 Spring Data JPA 和 PostgreSQL 依赖项

本节将向我们的应用程序添加 Spring Data JPA、PostgreSQL 和其他有价值的依赖项。我们将使用 Spring Initializr 和一个现有的 Spring Boot 项目添加这些依赖项。

使用 Spring Initializr 添加

使用 Spring Initializr 创建 Spring Boot 应用程序后添加依赖项很简单。我们只需要在生成项目之前在 Initializr 上选择依赖项:

  1. 首件事是访问start.spring.io/或您的 IntelliJ IDEA(对于 Ultimate 用户)以打开 Spring Initializr(关于使用 Spring Initializr部分的回顾,请参阅第三章进入 Spring Boot)。

  2. 选择您的项目是否将使用 Maven 或 Gradle,并设置所需的配置,包括工件名称描述包名打包方式和项目的Java版本。

  3. 接下来,点击右上角的添加依赖并选择以下依赖项:

    • Spring Data JPA:这个依赖项用于添加用于内置数据存储相关实现的 Spring Data JPA。

    • H2 数据库:这是一个支持 JDBC API 和 R2DBC 访问的内存数据库,通常用于单元测试

    • PostgreSQL 驱动程序:这是一个 JDBC 和 R2DBC 驱动程序,它将允许 Java 应用程序连接到 PostgreSQL 数据库:

图 4.9 – 在 Spring Initializr 中添加依赖项

图 4.9 – 在 Spring Initializr 中添加依赖项

在成功添加依赖项后,我们可以看到我们的依赖项已经列出。

  1. 点击生成,这将下载我们已生成的项目。

  2. 解压 ZIP 文件并在您的 IDE 中打开项目。如果您使用 Maven 开发项目,请在src文件夹中打开pom.xml文件,或者如果您使用的是build.gradle,它也位于src文件夹中:

图 4.10 – Spring Boot 应用程序(pom.xml 的视图)

图 4.10 – Spring Boot 应用程序(pom.xml 的视图)

在前面的示例中,我们可以看到 Spring Boot 应用程序是用 Maven 生成的,我们可以看到我们的pom.xml文件已经包含了我们在 Spring Initializr 中添加的依赖项:

图 4.11 – Spring Boot 应用程序(build.gradle 的视图)

图 4.11 – Spring Boot 应用程序(build.gradle 的视图)

现在,在前面的示例中,Spring Boot 应用程序是用 Gradle 生成的,我们可以看到依赖项列表已经添加到build.gradle文件下。

我们现在将向现有 Spring Boot 应用程序添加依赖项。

添加现有项目

在前面的示例中,我们添加了依赖项以使用 Spring Initializr 生成我们的 Spring Boot 应用程序。现在,我们将向现有应用程序添加依赖项。向现有 Spring 应用程序添加依赖项很简单;我们只需要修改pom.xml(Maven)或build.gradle(Gradle)文件。

要使用 Maven 安装 Spring Data JPA、H2 数据库和 PostgreSQL 驱动程序,我们将以 XML 形式添加依赖项,如下所示:

<dependencies>
   <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
   </dependency>
   <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
   </dependency>
   <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
   </dependency>
   <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
   </dependency>
</dependencies>

对于使用 Gradle 的 Spring 应用程序,我们将按以下方式添加依赖项:

dependencies {
   implementation 'org.springframework.boot:spring-boot-
   starter-data-jpa'
   runtimeOnly 'com.h2database:h2'
   runtimeOnly 'org.postgresql:postgresql'
}

IntelliJ 将自动识别添加的依赖项并为项目安装它们,我们可以成功构建并即时运行 Spring Boot 应用程序。

我们已经学习了如何将 Spring Data JPA 和 PostgreSQL 驱动程序添加到我们的 Spring Boot 应用程序中。在下一节中,我们将学习如何将我们的 Spring Boot 应用程序连接到我们的 PostgreSQL 数据库。

连接到数据库

我们已经配置了我们的 PostgreSQL 数据库,并使用所需的依赖项初始化了 Spring Boot 应用程序。现在,我们将学习如何将我们的 PostgreSQL 连接到我们的应用程序。我们可以通过两种方式连接到我们的数据库 – 第一种是使用 Spring JDBC,另一种是 Spring Data JPA。Spring Data JPA 是连接到我们的数据库最方便的方式,但我们将在本节中演示这两种方法。

配置数据库属性

我们需要做的第一件事是在我们的 Spring Boot 应用程序中配置数据库属性。我们需要通过在application.properties文件中添加以下源代码来指定数据库的服务器 URL、管理员用户名和密码:

spring.datasource.url=jdbc:postgresql://localhost:5432/springDB
spring.datasource.username=postgres
spring.datasource.password=password

在前面的示例中,我们可以看到我们已经为我们的 PostgreSQL 配置了基本的连接设置。URL 上的springDB将是 PostgreSQL 中数据库的名称,它应该已经存在于我们的服务器上。

使用 Spring JDBC 进行连接

连接到数据库的第一种方法是使用 Spring JDBC。我们将为我们的应用程序添加一个额外的依赖项以使用此方法。

要添加 JDBC,我们将以下代码添加到我们的pom.xml(Maven)或build.gradle(Gradle)文件中:

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

在成功添加 JDBC 依赖项后,我们现在可以使用JdbcTemplate在我们的应用程序中执行查询:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.jdbc.core.JdbcTemplate;
@SpringBootApplication
public class AwesomeJavaProject  {
    @Autowired
    private JdbcTemplate jdbcTemplate;
    public static void main(String[] args) {
        SpringApplication.run(AwesomeJavaProject .class,
                              args);
    }
    @Override
    public void run(String... args) throws Exception {
        String sql = "INSERT INTO blog (title, author,
          body) VALUES ("+ "'Awesome Java Project',
                        'Seiji Villafranca', 'This is an
                         awesome blog for java')";
        int rows = jdbcTemplate.update(sql);
    }
}

在前面的示例中,我们可以在我们的应用程序中执行数据库语句,如INSERT,并调用update()方法来修改数据库中的数据。

使用 Spring Data JPA 进行连接

第二种方法是使用 Spring Data JPA 插件。我们需要采取的第一步是向application.properties文件添加额外的详细信息:

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.format_sql=true

在添加了新的设置之后,我们现在可以在我们的应用程序中为特定的表创建EntityRepository – 例如,我们有一个Blog表:

package net.codejava;
import javax.persistence.*;
@Entity
@Table(name = "blog")
public class Blog {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String title;
    private String body;
    private String author;
}

在前面的示例中,我们创建了一个Blog类,并用@Entity@Table注解来表示这是一个与我们的数据库表相连的对象:

package net.codejava;
import org.springframework.data.JPA.repository.JpaRepository;
public interface BlogRepository extends JpaRepository<Blog, Integer> {
}

在创建我们的实体之后,我们为博客创建了存储库,该存储库可以由 JPA 提供的存储库扩展。现在,BlogRepository可以被注入到我们的服务或控制器中,以读取、添加、修改或删除数据库中的数据。

摘要

这就结束了本章的内容。让我们回顾一下你学到的宝贵知识。你学习了如何使用安装程序或 Docker 容器在你的本地机器上设置 PostgreSQL。

你还了解了 Spring Boot 中 Spring Data JPA 的概念和优势,以及如何将其添加到你的应用程序中,这对于创建具有 CRUD 功能的服务的代码量较少非常有帮助。

最后但同样重要的是,你学习了如何使用 JDBC 和 Spring Data JPA 将你的 Spring Boot 应用程序与 PostgreSQL 数据库连接起来。

在下一章中,我们将学习如何启动我们的服务器,如何在代码中添加控制器、模型和服务,以及关于 Redis 缓存的介绍。

第五章:使用 Spring 构建 API

在上一章中,你学习了 PostgreSQL 的概念和优势,并使用安装程序或 Docker 容器在你的本地机器上设置了它。你知道如何在我们的项目中配置 Spring Data Java 持久性 APISpring Data JPA),并使用其提供的存储库以更少的样板代码在我们的数据库上执行 创建、读取、更新删除CRUD)操作。最后,你还学会了如何使用 Java 数据库连接JDBC)驱动程序和 Spring Data JPA 将你的应用程序与 PostgreSQL 连接。

本章将创建你的 Spring Boot 应用程序编程接口API)项目;我们将专注于编码,创建我们的模型,并添加控制器和服务来开发我们的端点。我们还将添加 远程字典服务器Redis)进行缓存,以帮助提高我们的应用程序性能。

本章将涵盖以下主题:

  • 启动服务器

  • 添加模型

  • 编写服务

  • 添加控制器

  • 添加 Redis 进行缓存

技术要求

本章没有技术要求。

本章完成版本的链接可以在以下位置找到:

github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-05/superheroes

启动服务器

在本节中,我们将尝试在我们的服务器上运行我们的 Spring Boot 应用程序,但首先,让我们回顾一下上一章:我们学习了如何配置 Spring Data JPA 并将我们的应用程序连接到我们的 PostgreSQL 数据库,并且——最重要的是——我们已经在我们的应用程序中安装了所有需要的依赖项。在运行 Spring Boot 应用程序之前,这些都是先决条件。

在以下示例中,我们将使用一个名为 spring-boot-superheroes 的应用程序。我们将使用 Spring Initializr 生成一个新的 Spring Boot 应用程序,并带有相同的依赖项。在整个开发我们的应用程序的过程中,我们还将向你展示编写 API 不同部分的几种方法,例如如何编写模型、服务和控制器。这些是目前在行业中使用的最常见方法。但首先,让我们继续运行我们的 Spring Boot 应用程序。

我们假设你已经生成了带有所需依赖项的 Spring Boot 应用程序。然而,如果你错过了这一部分或者不确定是否在你的生成项目中包含了所有依赖项,那么让我们再次列出我们在上一章中安装的所有依赖项,如下所示:

  • Spring Data JPA:用于内置数据存储相关实现的 Spring Data JPA 依赖项。

  • PostgreSQL 驱动程序:一个 JDBC 和 响应式关系型数据库连接R2DBC)驱动程序,它将允许 Java 应用程序连接到 PostgreSQL 数据库。

  • H2 数据库:一个支持 JDBC API 和 R2DBC 访问的内存数据库;这通常用于单元测试。

如果你已成功初始化应用程序并添加了列出的依赖项,请打开你首选的 集成开发环境IDE);在下面的示例中,我们将使用 IntelliJ 进行 Spring Boot。然后,按照以下步骤操作:

  1. 展开项目文件夹;我们将看到里面有几个文件夹,如下面的截图所示:

图 5.1 – Spring Boot 应用程序的项目结构

图 5.1 – Spring Boot 应用程序的项目结构

在前面的截图中,我们可以看到我们的 Spring Boot 应用程序中的文件和文件夹。我们可以在 src/main/java 下找到名为 SuperHeroesApplication 的主类。这个主类将用于在服务器上运行我们的应用程序。

application.properties 也是一个重要的文件,我们需要对其进行配置,因为这个文件放置了所有连接到我们数据库所必需的属性。

  1. 打开 application.properties 文件,我们应该设置以下配置:

    spring.main.allow-bean-definition-overriding=true
    
    spring.datasource.url=jdbc:postgresql://localhost:5432/{{databasename}}
    
    spring.datasource.username=postgres
    
    spring.datasource.password=pass
    
    spring.jpa.hibernate.ddl-auto=update
    
    spring.jpa.show-sql=true
    
    spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
    
    spring.jpa.properties.hibernate.format_sql=true
    

此配置将允许我们连接到我们本地机器上的 PostgreSQL 数据库。请记住,数据库应该存在于我们的 PostgreSQL 服务器上;否则,我们的应用程序将无法成功运行。如果你还没有创建你的数据库,打开 pgAdmin 并输入你的主密码;在左侧面板中,展开 服务器 部分。

  1. 你将看到你本地机器上的 PostgreSQL 实例。右键单击实例,选择 postgres 用户。这将现在访问我们服务器上的数据库,如下面的截图所示:

图 5.2 – 使用 pgAdmin 访问 PostgreSQL 服务器

图 5.2 – 使用 pgAdmin 访问 PostgreSQL 服务器

  1. 在成功访问服务器后,右键单击 数据库,选择 创建,然后点击 数据库;这将打开以下模态:

图 5.3 – 创建数据库

图 5.3 – 创建数据库

在前面的截图中,我们已将数据库命名为 SpringDevDB 并在 所有者 字段中设置了 postgres 用户值。点击 保存,我们的数据库现在已在服务器上设置好。

  1. 我们 Spring Boot 应用程序现在已准备好在服务器上运行;在你的 IntelliJ IDE 中,点击面板右上角的 添加配置。这将打开一个模态,我们将在此配置我们的入口点以运行应用程序,如下面的截图所示:

图 5.4 – 为 Spring Boot 添加配置

图 5.4 – 为 Spring Boot 添加配置

  1. 点击 SuperheroesApplication,这将自动填充程序参数为类的正确完全限定名称,如下面的截图所示:

图 5.5 – 应用程序的配置

图 5.5 – 应用程序的配置

点击 应用,然后点击 确定以保存配置更改。

  1. 在 IDE 的右上角面板中选择创建的配置,通过点击绿色的播放图标来运行项目。应用程序将在默认端口 8080 上安装的 Tomcat 服务器上运行;我们也可以使用终端来检查 Spring Boot 项目是否已成功在服务器上启动。您可以在以下屏幕截图中看到正在运行的过程:

图 5.6 – Spring Boot 日志

图 5.6 – Spring Boot 日志

在前面的屏幕截图中,我们可以看到我们的应用程序已成功启动;我们已连接到我们的 PostgreSQL 数据库,现在我们可以开始编写我们的代码。

我们将在下一节尝试创建我们的应用程序模型。

添加模型

在本节中,我们现在将编写我们的应用程序代码,我们将首先创建的是模型。简单来说,模型是应用程序的对象;模型将充当我们的实体,并定义数据库中的表。

一旦我们创建了模型并运行了应用程序,这将自动在数据库中生成表,这有助于注解,这一点将在整个示例中讨论。

使用 DTOs 和 Lombok 创建模型

我们将首先向您展示如何使用 Lombok 和 数据传输对象DTOs)编写模型。首先,我们将讨论 DTOs。

DTOs

DTOs 负责在进程之间传输数据以减少方法调用的数量。DTOs 是 普通的 Java 对象POJOs),通常由数据访问器组成。

DTOs 对于创建我们实体的表示非常有用,以便为客户端提供视图,同时不影响模式和设计。让我们来看一个 DTOs 的用例示例。您可以看到如下:

public class Blog {
private String id;
private String title;
private String description;
private String author;
public Blog(String title, String description,
            String author) {
   this.name = title;
   this.description = description
   this.author = author
}

在前面的代码中,我们创建了一个示例领域模型,它将代表我们数据库中的实体。有些情况下,我们可能不希望在发送数据到客户端时包含某些信息,这就是 DTOs 发挥作用的地方。我们将为用于获取和创建数据的博客模型创建两个 DTOs,如下所示:

Public class BlogDTO {
     private String title;
     private String description;
}

在前面的示例 DTO 中,我们创建了一个 BlogDTO 类,它将用于检索数据;我们的目标是隐藏作者的名字,因此不将其作为字段包含在 DTO 中。代码如下所示:

Public class BlogCreationDTO {
     private String title;
     private String description;
     private String author;
}

我们创建的下一个 DTO 是 BlogCreationDTO,它将创建一个新的博客。我们可以看到创建新博客所需的所有字段都已包含。

创建的 DTOs 将在以下章节中用于我们的控制器。

Lombok

Lombok 是一个第三方库,用于通过注解减少样板代码。Lombok 允许我们避免重复的代码,尤其是在创建模型,如获取和设置方法时。

让我们比较一下没有使用 Lombok 的模型和使用 Lombok 的模型,如下所示:

public class Blog {
     private String title;
     private String description;
     private String author;
     public Blog() {}
     public Blog(String title, String description,
                 String author) {
     super()
       this.name = title;
       this.description = description
       this.author = author
}
     public String getAuthor() {return author;}
     public void setAuthor(String author) {
     this.author = author; }
     public String getTitle() {return title;}
     public void setTitle(String title) {
     this.title = title; }
     public String getDescription() {return description;}
     public void setDescription(String description) {
     this.description = description; }
    @Override public String toString()
    {return "Blog ["
            + "author=" + author + ", title=" + title
            + ", " + " description =" + description + "]";
    }
}

在前面的代码示例中,我们没有使用 Lombok 创建了一个博客模型;我们可以看到我们已经为每个字段创建了一个 getter 和 setter 方法,我们还创建了带有和不带有参数的构造函数。这个例子中的代码仍然是可管理的。

尽管如此,如果我们的模型需要包含更多字段,我们需要为新的字段创建 setter 和 getter,在我们的模型内部创建更多样板代码,从而牺牲我们代码的可维护性。

类和注解应该如下所示:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Blog {
     private String title;
     private String description;
     private String author;
}

在前面的屏幕截图中,我们使用了 Lombok 创建我们的博客模型,我们可以看到模型中已经省略了相当多的代码。@Data注解生成了 setter 和 getter 方法、toString()方法和带有必需参数的构造函数。

@AllArgsConstructor@NoArgsConstructor负责生成带有所有和没有参数的构造函数。

使用 Lombok 的优势在之前的示例中已经很明显;它使代码更容易阅读和更少出错,促进了轻松的清理和维护,并提供了无力的日志记录和调试。

模型应用

现在我们已经了解了 DTO 和 Lombok 的重要性,让我们回到我们的 Spring Boot 应用程序。在java文件夹下,右键单击包并选择。这将显示一个小窗口,允许您输入一个新包。

在这个例子中,我们将创建一个名为antiHero的新包。在完成新包后,我们将在antiHero下创建两个新的包,分别命名为dtoentity。您可以在下面的屏幕截图中看到这些包:

图 5.7 – 创建包后的项目结构

图 5.7 – 创建包后的项目结构

我们的项目结构现在应该看起来就像前面屏幕截图中所展示的那样。让我们首先创建我们的实体;右键单击我们创建的实体包,然后单击AntiHeroEntity并单击确定按钮。该过程在下面的屏幕截图中进行了说明:

图 5.8 – 创建实体

图 5.8 – 创建实体

我们将在entity包下看到一个新生成的类,它将具有以下代码:

package com.example.springbootsuperheroes.superheroes.antiHero.entity;
import javax. persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "anti_hero_entity")
public class AntiHeroEntity {
}

使用@Entity@Table注解自动生成了一个实体,这些注解将用于将此模型识别为数据库中的对象。当前的代码将有一些问题,指出实体没有主键PK);在这种情况下,我们将向我们的模型添加以下内容:

  • @Data:Lombok 注解,将设置 getter 和 setter 方法、toString()方法和@RequiredArgsConstructor

  • @AllArgsConstructor:用于生成具有所有字段作为参数的模型构造函数的 Lombok 注解。

  • @NoArgsConstructor:用于生成没有参数的模型构造函数的 Lombok 注解。

  • @Id:位于javax.persistence.*下,这将确定模型的 PK。

  • @GeneratedValue:用于主键,以确定将使用哪种生成类型。

  • @NotNull:位于javax.validation.constraints下,用于验证特定字段不能为 null。

应该通过将以下代码添加到pom.xml中来安装依赖项:

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

在将依赖项添加到pom.xml后,右键单击您的项目并选择Maven | Reload project以安装新的依赖项。

在成功添加 Lombok 注解、PK、验证和字段到我们的模型后,我们将得到以下代码:

package com.example.springbootsuperheroes
.superheroes.antiHero.entity;
…
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
…

在添加所有前面的包之后,我们现在可以开始编写我们的类和注解,如下所示:

@Data
@Entity
@Table
@AllArgsConstructor
@NoArgsConstructor
public class AntiHeroEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO,
                    generator = "UUID")
    @Column(nullable = false, updatable = false)
    private UUID id;
    @NotNull(message = "First Name is required")
    private String firstName;
    private String lastName;
    private String house;
    private String knownAs;
    private String createdAt =
      new SimpleDateFormat("dd-MM-yyyy HH:mm:ss z")
            .format(new Date());
}

在前面的代码块中,我们可以看到我们添加了一个UUID类型的 PK;一旦我们将新的反英雄数据插入到我们的数据库中,它将自动生成。类型是通过@GeneratedValue注解定义的,其中我们还指明了策略将使用自动生成器。我们还添加了几个将用于存储反英雄信息的字段。

我们已经成功创建了我们的实体;现在,我们将为反英雄实体创建一个 DTO。右键单击dto包,选择AntiHeroDto。在成功创建 DTO 后,我们将以下代码放入其中:

package com.example.superheroes.antiHero.dto;
import java.util.UUID;
import javax.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AntiHeroDto {
  private UUID id;
  @NotNull(message = "First Name is required")
  private String firstName;
  private String lastName;
  private String house;
  private String knownAs;
}

在前面的代码示例中,我们添加了只希望在将反英雄数据发送到客户端时显示的字段;在这种情况下,我们在我们的 DTO 中移除了createdAt字段。我们还添加了@Getter@Setter注解来生成我们的 DTO 的 getter 和 setter 方法,最后,我们还添加了@NotNull验证来匹配我们的约束与实体。

我们已经使用 Lombok 在我们的应用程序中成功创建了 DTO 和模型。在下一节中,我们将使我们的服务负责 Spring Boot 应用程序的 CRUD 功能。

编写服务

在本节中,我们现在将编写我们的应用程序服务,但首先,让我们讨论 Spring Boot 中服务的主要目的。服务是我们放置所有业务逻辑的类;这是我们将在 JPA 仓库的帮助下编写我们的 CRUD 功能的地方。在这种情况下,我们不仅将创建我们的服务类,还将创建我们的 JPA 仓库。

JPA 仓库

第四章中所述,设置数据库和 Spring Data JPA,JPA 仓库是管理 Java 应用程序中关系数据的广泛使用的规范。它通过不实现读写操作来减少样板代码,从而帮助开发 Spring。

在 Spring Boot 中创建 JPA 仓库非常简单;话虽如此,JPA 库提供了如CrudRepository之类的类,我们可以用来扩展我们的类。让我们在我们的 Spring Boot 应用程序示例中创建一个。按照以下步骤进行:

  1. 右键点击我们的 antiHero 包,选择 New,然后点击 Package 选项。

  2. 创建一个名为 repository 的新包。

  3. 在创建新包后,右键点击 repository 包,选择 New,然后点击 Class 选项。

  4. 当小模态弹出时,切换到 AntiHeroRepository。以下代码将被生成:

    package com.example.springbootsuperheroes.superheroes.antiHero.repository;
    
    import com.example.springbootsuperheroes.superheroes.antiHero.entity.AntiHeroEntity;
    
    import org.springframework.data.repository.CrudRepository;
    
    import java.util.UUID;
    
    public interface AntiHeroRepository {
    
    }
    

我们已经创建了我们的接口,但这只是一个简单的接口。我们将通过 CrudRepository 类扩展我们的接口,将其转换为 JPA 仓库,如下所示:

public interface AntiHeroRepository extends CrudRepository<AntiHeroEntity, UUID> {
}

AntiHeroRepository,因为我们已经通过 CrudRepository<Type, ID> 扩展了它,将具有以下功能的方法:

  • 持久化、更新和删除博客实体之一。

  • 通过主键(PK)查找一个或多个博客。

  • 统计所有博客的数量。

  • 验证单个博客是否存在。

我们已成功创建我们的 JPA 仓库;我们的下一步是进行服务。我们的目标是创建一个服务,该服务将能够获取整个列表,通过主键(PK)获取单个数据库,插入新数据,更新选定数据,以及删除数据。我们可以通过以下由 JPA 仓库提供的方法来实现这一点:

  • findAll(): 获取特定实体中的所有现有数据。

  • findById(Id): 通过主键(PK)查找特定的数据库。

  • save(): 在表中插入新数据。

  • save(data): 更新表中的现有数据。

  • deleteById(id): 通过主键(PK)删除表中的特定数据。

现在我们已经成功识别出我们服务所需的方法,接下来创建我们的服务。右键点击 antiHero 包并创建一个名为 service 的新包;在创建新包后,在 service 包下创建一个名为 AntiHeroService 的新类,并将以下方法放在服务中:

public class AntiHeroService {
    private final AntiHeroRepository repo;
    public Iterable<AntiHeroEntity> findAllAntiHeroes() {
        return repo.findAll();
    }
    public AntiHeroEntity findAntiHeroById(UUID id) {
        return findById(id);
    }
    public void removeAntiHeroById(UUID id) {
        repo.deleteById(id);
    }
    public AntiHeroEntity addAntiHero(
      AntiHeroEntity antiHero) {
        return repo.save(antiHero);
    }
    public void updateAntiHero(UUID id,
      AntiHeroEntity antiHero) {
        repo.save(antiHero);
    }
}

让我们讨论前面的代码;我们已向我们的服务添加了几个方法,这些方法在此处有更详细的解释:

  • Iterable<AntiHeroEntity> findAllAntiHeroes(): 此方法从 AntiHeroRepository 调用 findAll(),返回 AntiHeroEntityIterable 实例。

  • AntiHeroEntity findAntiHeroById(UUID id): 此方法从 AntiHeroRepository 调用 findById(id),根据 Id 值返回单个 AntiHeroEntity 实例。

  • removeAntiHeroById(UUID id): 此方法从 AntiHeroRepository 调用 deleteById(id),根据 Id 值删除单个 AntiHeroEntity 实例。

  • void AntiHeroEntity addAntiHero(AntiHeroEntity antiHero): 此方法从 AntiHeroRepository 调用 save() 并在数据库中插入新的 AntiHeroEntity 实例。

  • void updateAntiHero(UUID id, AntiHeroEntity antiHero): 此方法从 AntiHeroRepository 调用 save(antiHero),在数据库中更新特定的 AntiHeroEntity 实例。

在成功添加我们的服务的方法之后,我们将向AntiHeroService添加@AllArgsConstructor@Service注解。@AllArgsConstructor是 Lombok 的一个注解,它将生成一个需要每个字段一个参数的构造函数;在我们的例子中,这将生成以下代码:

public AntiHeroService(AntiHeroRepository repo) {
  this.repo = repo;
}

现在,这将允许我们将AntiHeroRepository依赖项连接到我们的服务上。

另一方面,当我们希望 Spring 上下文根据其分类自动检测类时,@Service注解是功能性的。

我们现在已经成功创建了具有 CRUD 方法的服务,但如果在调用服务时出了问题怎么办?例如,我们传递的反英雄的标识符ID)可能不存在。我们希望捕获错误并向用户返回一个可理解的消息。现在,我们将为我们的服务创建运行时异常处理。

运行时异常

运行时异常也被称为未检查异常。这些是编程错误,将在我们应用程序的当前执行中发生。这应该直接由开发者来预防。

这里列出了一些已知的运行时异常:

  • IndexOutOfBoundsException:当我们访问一个数组字符串或可迭代的索引超出范围时,会发生此异常,如下面的代码片段所示:

    String[] array = new String[100];
    
    String name = array[200]; // throws index out of bounds as array variable only has a length of 100
    
  • IllegalArgumentException:当方法被传递了一个非法参数时,会发生此异常,如下面的代码片段所示:

    public class Hero {
    
       int number;
    
       public void givePositiveNumber(int number) {
    
          if(number < 0)  throw new
    
            IllegalArgumentException(
    
              Integer.toString(number));
    
          else { m = number; }
    
       }
    
       public static void main(String[] args) {
    
          Hero h = new Hero();
    
          // throws illegal argument exception as -1 is a
    
          // negative number
    
          h.givePositiveNumber(-1);
    
    }
    
       }
    
  • NullPointerException:当访问一个不指向任何对象或简单地为 null 的变量时,会发生此异常,如下面的代码片段所示:

    public void doAction(ExampleObject obj) {   obj.doActionOnMethod();
    
    }
    
    // call doAction()
    
    // throws null pointer exception as we are accessing a
    
    // method on a null object
    
    doAction(null)
    

这些只是我们在应用程序中使用的常见运行时异常;我们也可以使用RunTimeException类来创建运行时异常。在这个例子中,让我们创建一个NotFoundException运行时异常,我们将使用它来获取一个不存在的 ID。按照以下步骤进行:

  1. main包下,创建一个名为exception的新包;在成功创建包之后,创建一个名为NotFoundException的类。我们将使用RunTimeException类扩展我们创建的类,并将添加以下代码:

    public class NotFoundException extends RuntimeException {
    
        public NotFoundException(String message) {
    
            super(message);
    
        }
    
        public NotFoundException(String message,
    
                                 Throwable cause) {
    
            super(message, cause);
    
        }
    
        public NotFoundException(Throwable cause) {
    
            super(cause);
    
        }
    
    }
    

在前面的代码片段中,我们已声明了几个带有不同参数的NotFoundException方法;在成功创建我们的自定义异常后,我们现在可以在我们的服务中使用它。

  1. 返回到AntiHeroService,我们将添加以下方法:

    private AntiHeroEntity findOrThrow(final UUID id) {
    
        return repo
    
                .findById(id)
    
                .orElseThrow(
    
                   () -> new NotFoundException("Anti-hero
    
                         by id " + id + " was not found")
    
                );
    
    }
    

在前面的代码示例中,我们创建了一个名为findOrThrow()的新方法;这同样是在调用findById()方法。唯一的区别是它检查给定的 ID 是否存在于数据库中;否则,它将根据我们的代码抛出某些内容。在这种情况下,我们希望抛出一个NotFoundException运行时异常。

  1. 在此之后,我们可以在findAntiHeroById()中使用它,通过将findById()替换为findOrThrow()方法,并将其添加到updateAntiHero()deleteAntiHero()方法中,以在更新或删除之前检查数据是否存在。完成此操作的代码如下所示:

    public AntiHeroEntity findAntiHeroById(UUID id) {
    
      return findOrThrow(id);
    
    }
    
    public void updateAntiHero(UUID id,
    
                               AntiHeroEntity antiHero) {
    
      findOrThrow(id);
    
      repo.save(antiHero);
    
    }
    

我们现在已经成功创建了我们的应用程序服务。下一节将讨论如何在我们的代码中创建控制器和定义端点。

添加控制器

本节将讨论控制器在应用程序中的使用以及如何创建它们。控制器负责拦截传入的请求并将请求的有效负载转换为数据的内部结构;这也是我们将定义前端应用程序可访问的端点路径的地方。

在创建控制器时,我们将使用几个注解,如下所述:

  • @RestController:这是@Controller注解的专用形式;与使用@Controller注解相比,它已经包含了@Controller@ResponseBody注解,不需要在每个方法上指定@ResponseBody注解。

  • @RequestMapping:这是一个用于将超文本传输协议HTTP)请求映射到表现层状态转移REST)控制器方法上的注解。它也是我们定义控制器基本路径的地方。

  • @GetMapping:这个注解将 HTTP GET请求映射到方法上;该注解是@RequestMapping(method = RequestMethod.GET)的快捷方式。

  • @PutMapping:这个注解将 HTTP PUT请求映射到方法上;该注解是@RequestMapping(method = RequestMethod.PUT)的快捷方式。

  • @PostMapping:这个注解将 HTTP POST请求映射到方法上;该注解是@RequestMapping(method = RequestMethod.POST)的快捷方式。

  • @DeleteMapping:这个注解将 HTTP DELETE请求映射到方法上;该注解是@RequestMapping(method = RequestMethod.DELETE)的快捷方式。

  • @PathVariable:这个注解用于获取端点参数的值。

  • @Valid:这个注解用于检查对象的合法性;它通常用于请求体上,以检查传递的请求是否是有效的对象。

我们已经成功确定了我们将用于我们控制器的注释。现在我们可以创建我们的控制器了。在antiHero包下,创建一个名为controller的新包,完成controller包后,创建一个名为AntiHeroController的新类。

AntiHeroController类中,我们将使用@AllArgsConstructor@RestController@RequestMapping注释,如下所示:

@AllArgsConstructor
@RestController
@RequestMapping("api/v1/anti-heroes")
public class AntiHeroController {
}

我们的控制器的配置现在已经完成;下一步是将我们的依赖项放入我们的控制器中。我们使用了@AllArgsContructor注解。我们不需要创建带有参数的构造方法;我们只需要定义依赖项。

首先,我们将确定包含所有 CRUD 逻辑的AntiHeroService类。下一个是ModelMapper;这很重要,因为我们需要在将实体用作响应时将其转换为 DTO,反之亦然,如果我们想从请求体中读取对象。ModelMapper将轻松地将实体的值映射到具有相同属性的 DTO 对象。

要安装依赖项,我们只需将以下代码添加到我们的pom.xml文件中:

<!--Dto mapper-->
<dependency>
   <groupId>org.modelmapper</groupId>
   <artifactId>modelmapper</artifactId>
   <version>2.3.9</version>
</dependency>

在成功添加ModelMapper依赖项后,我们需要在配置中将我们的ModelMapper依赖项定义为 Bean,以便在应用程序中使用。为此,我们将在main包下创建一个新的名为config的包,并创建一个名为ModelMapperConfig的新类。创建新类后,我们将添加一个带有@Bean注解的新方法,并返回一个ModelMapper的新实例。代码如下所示:

@Configuration
public class ModelMapperConfig {
  @Bean
  public ModelMapper modelMapper() {
    return new ModelMapper();
  }
}

我们的所有配置都已经完成,现在,我们可以将AntiHeroServiceModelMapper添加到我们的控制器中,如下所示:

@AllArgsConstructor
@RestController
@RequestMapping("api/v1/anti-heroes")
public class AntiHeroController {
    private final AntiHeroService service;
     private final ModelMapper mapper;
}

我们现在已经有了带有所需依赖项的控制器。现在,让我们创建两个函数,将我们的实体转换为 DTO,或者反过来。正如之前提到的,我们将使用ModelMapper依赖项来创建这些方法,在这种情况下,我们将添加以下代码:

private AntiHeroDto convertToDto(AntiHeroEntity entity) {
  return mapper.map(entity, AntiHeroDto.class);
}
private AntiHeroEntity convertToEntity(AntiHeroDto dto) {
  return mapper.map(dto, AntiHeroEntity.class);
}

我们在先前的代码示例中创建了两个函数。首先,我们创建了convertToDto()方法,它将给定的AntiHeroEntity实例转换为AntiHeroDto实例,我们使用了ModelMappermap()方法来映射实体的值。第二个函数是convertToEntity()方法,它将 DTO 转换为实体。

现在,我们可以为我们的 CRUD 方法创建映射。让我们首先创建一个方法,该方法将根据id值返回一个特定的实体;我们将使用@GetMapping注解来标识这将使用GET请求,并将/{id}作为参数添加,表示我们可以将实体 ID 作为动态参数传递到端点中。

在创建方法时,我们将使用@PathVariable注解来获取端点中/{id}的值,并将其定义为UUID类型。最后,我们将在AntiHeroService下调用service.findAntiHeroById()函数,并将检索到的 ID 传递给数据库中的实体,我们将使用convertToDto()函数将其转换为 DTO。代码如下所示:

@GetMapping("/{id}")
public AntiHeroDto getAntiHeroById(@PathVariable("id") UUID id) {
  return convertToDto(service.findAntiHeroById(id));
}

现在,为了创建create映射,我们将使用@PostMapping注解来标识这将使用POST请求,我们将使用@RequestBody注解来获取请求体中的对象,我们还可以使用@Valid注解来检查该对象是否是一个有效的实体。

在创建函数时,我们将调用convertToEntity()方法将对象转换为实体,并将调用service.addAntiHero()方法将转换后的实体插入到数据库中。代码如下所示:

@PostMapping
public AntiHeroDto postAntiHero(@Valid @RequestBody AntiHeroDto antiHeroDto) {
  var entity = convertToEntity(antiHeroDto);
  var antiHero = service.addAntiHero(entity);
  return convertToDto(antiHero);
}

下一步我们需要创建的是PUT映射。我们将使用@PutMapping注解来标识这将使用PUT请求,这与我们创建GET映射的方式相同。我们将添加/{id}作为参数,并且我们还将使用@RequestBody注解来获取请求体中的对象,以及使用@PathVariable注解来获取参数中id的值。

在实现函数时,我们还将调用convertToEntity()方法,并调用service.updateAntiHero(id, entity)方法来使用 DTO 值更新特定实体。代码如下所示:

@PutMapping("/{id}")
public void putAntiHero(
  @PathVariable("id") UUID id,
  @Valid @RequestBody AntiHeroDto antiHeroDto
) {
  if (!id.equals(antiHeroDto.getId())) throw new
    ResponseStatusException(
    HttpStatus.BAD_REQUEST,
    "id does not match."
  );
  var antiHeroEntity = convertToEntity(antiHeroDto);
  service.updateAntiHero(id, antiHeroEntity);
}

接下来,我们将创建DELETE映射。我们将使用@DeleteMapping注解来标识这将使用DELETE请求。我们还将添加/{id}作为参数来接收需要删除的实体的 ID,并添加@PathVariable注解来获取id的值。

要实现该方法,我们只需调用service.removeAntiHeroById()方法来删除数据库中的特定实体,如下所示:

@DeleteMapping("/{id}")
public void deleteAntiHeroById(@PathVariable("id") UUID id) {
  service.removeAntiHeroById(id);
}

最后,我们需要创建一个方法,该方法将返回数据库中的所有实体。我们可以实现这一点的其中一种方法是通过使用StreamSupport将检索到的列表转换为流,并将每个对象转换为 DTO,但首先,我们将创建一个带有@GetMapping注解的返回List<AntiHeroDto>类型的方法。完成方法后,我们现在将调用service.findAllAntiHeroes()方法来获取数据库中的实体。由于这返回一个Iterable实例,我们将将其转换为流,并使用Collectors.toList()将其转换为列表。代码如下所示:

  var antiHeroList = StreamSupport
    .stream(service.findAllAntiHeroes().spliterator(),
            false)
    .collect(Collectors.toList());

在成功检索并将数据转换为列表后,我们希望将每个对象转换为 DTO。我们可以通过将列表转换为流来实现这一点。调用convertToDto()方法并将其再次转换为列表,如下所示:

  antiHeroList
    .stream()
    .map(this::convertToDto)
    .collect(Collectors.toList());

我们将在响应中返回转换后的列表,现在我们的方法将如下所示:

@GetMapping
public List<AntiHeroDto> getAntiHeroes() {
  var antiHeroList = StreamSupport
    .stream(service.findAllAntiHeroes().spliterator(),
            false)
    .collect(Collectors.toList());
  return antiHeroList
    .stream()
    .map(this::convertToDto)
    .collect(Collectors.toList());
}

我们已经在控制器上成功创建了映射,我们的代码应该看起来像这样:

@AllArgsConstructor
@RestController
@RequestMapping("api/v1/anti-heroes")
public class AntiHeroController {
    private final AntiHeroService service;
    private final ModelMapper mapper;
…
    public AntiHeroDto getAntiHeroById(
      @PathVariable("id") UUID id) {
        return convertToDto(service.findAntiHeroById(id));
    }
    @DeleteMapping("/{id}")
    public void deleteAntiHeroById(
      @PathVariable("id") UUID id) {
        service.removeAntiHeroById(id);
    }
    @PostMapping
    public AntiHeroDto postAntiHero(
       @Valid @RequestBody AntiHeroDto antiHeroDto) {
        var entity = convertToEntity(antiHeroDto);
        var antiHero = service.addAntiHero(entity);
…
        return mapper.map(dto, AntiHeroEntity.class);
    }
}

我们已经完成了我们的 Spring Boot 应用程序,我们创建了我们的模型和 DTO,它定义了我们的对象结构,我们构建了负责业务逻辑的服务,我们还创建了映射我们应用程序中 HTTP 请求的控制器,因此我们的端点将按预期工作。

尽管如此,我们可以在性能方面改进我们的后端,我们可以通过缓存机制来实现这一点。在下一节中,我们将讨论 Redis 的概念和应用。

添加 Redis 进行缓存

在本节中,我们现在将讨论 Redis,它可以提高我们 REST 应用程序的性能。Redis 是一个开源的内存键值数据存储,允许数据驻留在内存中以实现低延迟和更快的数据访问。与传统数据库相比,Redis 不需要磁盘访问,所有数据都缓存在内存中,这提供了更快的响应。

现在,它被广泛使用,尤其是在接收数百万请求的大型应用程序中。它与不同的数据结构兼容,如字符串、列表、集合、散列、位图和地理空间,并且与 发布/订阅Pub/Sub)兼容,用于实时聊天应用程序。

安装 Redis 服务器

在我们的 Spring Boot 应用程序中使用 Redis 之前,我们需要在我们的终端中安装 Redis 服务器。让我们讨论如何在不同的操作系统上安装 Redis。

macOS

在 macOS 系统中安装 Redis 非常简单;我们可以使用 Homebrew 来安装 Redis 并执行以下命令:

brew install redis

在成功安装后,我们可以使用以下命令设置 Redis 服务器自动启动:

brew services start redis

我们已经在 macOS 上成功安装并运行了我们的 Redis 服务器。

Ubuntu Linux

对于在 Ubuntu OS 上安装 Redis,我们将执行以下命令:

sudo apt-get install redis-server

这将自动在端口 6739 上安装并启动 Redis 服务器,并且我们已经成功在 Linux 上安装并运行了我们的 Redis 服务器。

Windows

对于在 Windows 上安装 Redis,我们可以从以下链接安装不同版本:github.com/microsoftarchive/redis/releases,下载 .zip.msi 文件,并将其解压到您选择的目录。运行 Redis-server.exe 文件以在端口 6739 上启动 Redis 服务器。

因此,我们已经成功在 Windows 上安装并运行了我们的 Redis 服务器。我们现在可以在我们的 Spring Boot 应用程序中使用 Redis。

在 Spring Boot 上配置 Redis

我们已经在本地机器上成功配置并启动了 Redis 服务器;我们的下一步是将在开发的 Spring Boot 项目中使用 Redis。我们现在将遵循以下步骤:

  1. 我们需要做的第一件事是包含 Redis 依赖项;为了实现这一点,我们需要将以下代码添加到我们的 pom.xml 文件中:

    <!-- Redis -->
    
    <dependency>
    
       <groupId>org.springframework.data</groupId>
    
       <artifactId>spring-data-redis</artifactId>
    
       <version>2.4.5</version>
    
    </dependency>
    
    <dependency>
    
       <groupId>redis.clients</groupId>
    
       <artifactId>jedis</artifactId>
    
       <version>3.5.1</version>
    
       <type>jar</type>
    
    </dependency>
    

在成功添加 Redis 依赖项后,我们将添加我们的 config 包。

  1. 创建一个名为 RedisConfig 的类。我们将使用 @Configuration 注解来标识这个类是否有 Bean 定义方法,这些方法将在应用程序执行时启动。我们还将向我们的类添加以下方法:

    @Bean
    
    JedisConnectionFactory jedisConnectionFactory() {
    
      RedisStandaloneConfiguration
    
        redisStandaloneConfiguration =
    
          new RedisStandaloneConfiguration();
    
      return new JedisConnectionFactory(
    
        redisStandaloneConfiguration);
    
    }
    

jedisConnectionFactory() 是用于识别 Redis 服务器连接属性的函数;由于我们没有指定连接属性,它使用默认值。

尽管如此,如果我们的 Redis 服务器托管在不同的服务器上,在不同的端口上,或者有用户名和密码,我们可以使用以下方法:

  • redisStandaloneConfiguration.setHostName("host"):这设置了 Redis 服务器运行的统一资源定位符URL)的主机。

  • redisStandaloneConfiguration.setPort("port"):这设置了应用程序将连接的端口。

  • redisStandaloneConfiguration.setUsername("username"):这设置了 Redis 服务器的用户名。

  • redisStandaloneConfiguration.setPassword("password"):这设置了 Redis 服务器的密码。

下一步是使用连接工厂创建一个 Redis 模板;这用于 Redis 数据交互。它允许在 Redis 服务器中存储的对象和二进制数据之间自动序列化和反序列化。

  1. 我们将创建一个方法,它也将使用@Bean注解;我们将创建一个新的 Redis 模板,并使用以下代码设置连接工厂:

    RedisTemplate<UUID, Object> template = new RedisTemplate<>();
    
    template.setConnectionFactory(jedisConnectionFactory());
    

在成功创建连接工厂的模板实例后,我们还可以根据数据结构定义序列化器。如果我们想使用默认的序列化器,即JdkSerializationRedisSerializer,我们只需返回一个模板实例。

  1. 在以下代码片段中,我们为不同的数据结构使用了不同的序列化器:

    @Bean
    
      public RedisTemplate<UUID, Object> redisTemplate() {
    
        RedisTemplate<UUID, Object> template =
    
          new RedisTemplate<>();
    
        template.setConnectionFactory(
    
          jedisConnectionFactory());
    
        template.setKeySerializer(
    
          new StringRedisSerializer());
    
        template.setHashKeySerializer(
    
          new StringRedisSerializer());
    
        template.setHashKeySerializer(
    
          new JdkSerializationRedisSerializer());
    
        template.setValueSerializer(
    
          new JdkSerializationRedisSerializer());
    
        template.setEnableTransactionSupport(true);
    
        template.afterPropertiesSet();
    
        return template;
    
      }
    
    }
    
  2. 我们需要做的最后一件事是将@RedishHash注解添加到我们的实体上。这作为标记对象作为一个聚合根存储在 Redis 哈希中;在我们的例子中,我们将它在AntiHeroEntity上使用,如下所示:

    @RedishHash("AntiHero")
    
    public class AntiHeroEntity {
    
    }
    

这样,我们就可以成功地在 Spring Boot 应用程序上使用 Redis 服务器来缓存数据,随着操作的执行。

摘要

通过这种方式,我们已经到达了本章的结尾。让我们回顾一下你学到的宝贵知识。你学会了如何使用 IntelliJ 在 Tomcat 服务器上启动 Spring Boot 应用程序。你还知道了如何通过创建实体、使用 Lombok 和 DTO、编写带有 CRUD 逻辑的服务以及使用 JPA 仓库、创建控制器(使用 HTTP 注解和ModelMapper将实体转换为 DTO,反之亦然)来逐步创建一个完整的 Spring Boot 应用程序。

最后,你也学会了如何配置 Redis 服务器并在 Spring Boot 应用程序中使用它。

你在这里学到的技能将使你的代码因为 Lombok 和 JPA 仓库而易于阅读和简洁。

在下一章中,我们将学习如何使用springdoc-openapi和 Swagger UI 为我们的 API 创建文档。

第六章:使用 OpenAPI 规范记录 API

在上一章中,我们学习了如何开发我们的 Spring Boot 应用程序。首先,我们使用 IntelliJ 配置了应用程序在服务器上运行。然后,我们开发了 REST 应用程序的不同部分,例如模型和实体,它们作为对象;服务,它们包含业务逻辑并调用 JPA 仓库在数据库中执行 CRUD 操作;以及控制器,它们定义了端点。我们还学习了如何应用 Redis,它为我们的 REST API 添加了缓存机制以改善性能。

本章将专注于创建我们的 Spring Boot 项目的文档。我们将专注于配置 springdoc-openapi 和 Swagger UI,并为我们开发的端点实时生成文档。

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

  • 设置 springdoc-openapi

  • 设置 Swagger UI

技术要求

以下链接将带您进入本章的完成版本:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-06/superheroes

设置 springdoc-openapi

在本节中,我们将配置我们的 Spring Boot 应用程序中的 springdoc-openapi。由于我们在上一章中开发了 REST API,接下来我们需要做的是为我们的端点创建文档。这是一个至关重要的部分,尤其是在开发行业中,因为它将告诉开发者可以实现哪些端点,所需的请求及其格式,以及调用端点时预期的响应体。这也减少了关于 API 集成的错误和冲突,因为可用的端点是透明的。然而,手动创建文档的主要缺点是它既繁琐又容易出错。这就是 springdoc-openapi 发挥作用的地方。

首先,让我们讨论一下 springdoc-openapi 是什么。springdoc-openapi 是一个库,它自动生成 Spring Boot 项目的 API 文档。这种自动化之所以可能,是因为该库使用注解、类结构和 Spring 配置来识别可用的 API。

springdoc-openapi 可以生成必要的文档,以 JSON/YAML 和 HTML API 的形式,可以通过我们应用程序上生成的新 URL 查看。它还支持几个框架和协议,包括以下内容:

  • spring-boot

  • JSR-303,特别是用于 @NotNull@Min@Max@Size

  • swagger-ui

  • OAuth 2

现在,让我们讨论 springdoc-openapi 的属性和插件。

springdoc-openapi 的属性

我们可以根据我们的偏好修改 .springdoc-openapi 的行为和设置。它有不同的属性,我们可以在 application.properties 文件下设置它们的值。

这里是 springdoc-openapi 中常用的一些属性:

  • springdoc.swagger-ui.path:默认值是 /swagger-ui.html。它定义了访问 HTML 文档的路径。

  • springdoc.swagger-UI.enabled:默认值是 true。它启用或禁用 swagger-UI 端点。

  • springdoc.swagger-ui.configUrl:默认值是 /v3/api-docs/swagger-config。这是一个检索外部配置文档的 URL。

  • springdoc.swagger-ui.layout:默认值是 BaseLayout。这是 Swagger UI 用于显示文档的最高级布局。

  • springdoc.swagger-ui.tryItOutEnabled:默认值是 false。它启用或禁用 尝试使用 部分,用户可以在其中测试端点。

  • springdoc.swagger-ui.filter:默认值是 false。它启用或禁用过滤并添加一个文本框来放置过滤条件。它可以是布尔值或字符串;这将作为过滤表达式。

  • springdoc.swagger-ui.operationsSorter:这将对 API 的操作列表进行排序。值可以是 'alpha'(按路径字母数字排序)、'method'(按 HTTP 方法排序)或一个将标识排序标准的函数。

  • springdoc.swagger-ui.tagsSorter:这将对 API 的操作列表进行排序。值可以是 'alpha'(按路径字母数字排序)或一个将标识排序标准的函数。

springdoc-openapi 的插件

springdoc-openapi 还有一些插件,我们可以使用它们来生成文档。让我们看看。

springdoc-openapi-maven-plugin

springdoc-openapi-maven-plugin 在构建时生成 JSON 和 YAML OpenAPI 描述。该插件也在集成阶段工作。要启用该插件,我们需要将以下声明添加到 pom.xml 文件的 <plugin> 部分:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <version>${spring-boot-maven-plugin.version}</version>
    <configuration>
       <jvmArguments>
         -Dspring.application.admin.enabled=true

       </jvmArguments>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>start</goal>
                <goal>stop</goal>
            </goals>
        </execution>
    </executions>
</plugin>

我们刚刚添加了 spring-boot-maven-plugin 插件。复制前面的代码并将其粘贴到您的 .pom 文件中。

现在,让我们在 spring-boot-maven-plugin 插件代码块下方添加 springdoc-openapi-maven-plugin 的 1.4 版本:

<plugin>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-maven-plugin</artifactId>
    <version>1.4</version>
    <executions>
        <execution>
            <id>integration-test</id>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
</plugin>

我们还可以通过指定以下属性来自定义 openapi-maven-plugin 的行为:

  • attachArtifact:默认值是 false。它将 API 文档部署到仓库。

  • apiDocsUrl:默认值是 http://localhost:8080/v3/api-docs。这是指向生成的 JSON 或 YAML 描述的本地 URL。

  • outputDir:默认值是 project.build.directory。这是生成 OpenAPI 描述的位置。

  • outputFileName:默认值是 openapi.json。这指定了生成 OpenAPI 描述时的文件名。

  • skip:如果设置为 true,则跳过执行。

  • headers:默认值是 empty。它指定了请求中要发送的头部列表。

以下代码示例显示了如何使用这些属性:

<plugin>
 <groupId>org.springdoc</groupId>
 <artifactId>springdoc-openapi-maven-plugin</artifactId>
 <version>${version}</version>
 <executions>
  <execution>
   <id>integration-test</id>
   <goals>
    <goal>generate</goal>
   </goals>
  </execution>
 </executions>
 <configuration>
  <apiDocsUrl>
    http://localhost:8080/v3/api-docs</apiDocsUrl>
  <outputFileName>openapi.json</outputFileName>
  <outputDir>/home/springdoc/output-folder</outputDir>
  <skip>false</skip>
  <headers>
    <header1-key>header1-value</header1-key>
  </headers>
 </configuration>
</plugin>

在前面的 XML 代码示例中,我们添加了几个属性以应用 OpenAPI 的自定义配置。我们手动设置了输出文件名、目录和用于生成 API 文档的标题。

springdoc-openapi-gradle-plugin

springdoc-openapi-gradle-plugin 从 Gradle 构建中为 Spring Boot 应用程序生成 OpenAPI 规范。要启用插件,我们必须在我们的 plugins 部分放置以下代码:

plugins {
      id "org.springframework.boot" version "${version}"
      id "org.springdoc.openapi-gradle-plugin"
          version "${version}"
}

一旦添加了插件和依赖项,以下任务将被创建:

  • generateOpenApiDocs:将运行以生成 OpenAPI 文档的任务。generateOpenApiDocs 对应用程序的文档 URL 进行 REST 调用,以将 OpenAPI 文档存储为 JSON 格式。

  • forkedSpringBootRun:Spring Boot 应用程序使用此任务在后台运行

我们还可以通过指定以下属性来自定义 openapi-graven-plugin 的行为:

  • apiDocsUrl:可以下载 Open API 文档的 URL

  • outputDir:文档生成的目录

  • outputFileName:生成的输出文件名

  • waitTimeInSeconds:在调用 REST API 生成 OpenAPI 文档之前等待 Spring Boot 应用程序启动的秒数

  • forkProperties:运行您的 Spring Boot 应用程序所需的系统属性

  • groupedApiMappings:一组 URL,可以从这些 URL 下载 OpenAPI 文档

要使用这些属性,我们必须使用 generateOpenApiDocs 指定它们:

openApi {
    apiDocsUrl.set("https://localhost:4000/api/docs")
    outputDir.set(file("$buildDir/docs"))
    outputFileName.set("swagger-test.json")
    waitTimeInSeconds.set310)
    forkProperties.set("-Dspring.profiles.active=special")
    groupedApiMappings.set(
      ["https://localhost:8000/v3/api-docs/group-1" to
       "swagger-group-1.json",
       "https://localhost:8000/v3/api-docs/group-2" to
       "swagger-group-2.json"])
}

通过这样,我们已经了解了在 OpenAPI 文档中我们可以使用的属性和插件。现在,让我们为我们的 Spring Boot 应用程序配置插件。

配置 springdoc-openapi

现在,我们将在我们的 Spring Boot 应用程序中安装和配置 springdoc-openapi。首先,我们必须将依赖项添加到我们之前创建的项目中。转到 pom.xml 文件,并添加以下 XML 代码:

 <dependency>
      <groupId>org.springdoc</groupId>
 <artifactId>springdoc-openapi-ui</artifactId>         <version>1.6.4</version>
</dependency>

在成功安装 OpenAPI 依赖项后,我们可以运行我们的 Spring Boot 项目。一旦服务器启动,我们可以访问 http://localhost:8080/v3/api-docs/ 来获取 JSON 格式的 OpenAPI 文档。您将看到所有端点和它们相关的 HTTP 请求都显示为 JSON 对象。以下是我们项目生成的 JSON 文档片段:

{
  "servers": [
    {
      "url": "http://localhost:8080/",
      "description": "Generated server url"
    }
  ],
  "paths": {
    "/api/v1/anti-heroes/{id}": {
      "get": {
        "tags": [
          "anti-hero-controller"
        ],
        "operationId": "getAntiHeroById",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "$ref": "#/components/schemas/AntiHeroDto" …… // other created paths UPDATE DELETE and CREATE Inputs

在这里,我们可以看到生成的 JSON 对象显示了项目中的可用端点。它显示了操作 ID,这是方法的默认名称;它还指定了所需的参数类型和端点的可能响应。

我们已经使用了 OpenAPI 可以访问的默认 URL。我们可以通过应用程序的 springdoc.api-docs.path property.properties 文件来更改 URL。例如,我们将其设置为 springdoc.api-docs.path=rest-docs,这意味着我们现在可以通过 http://localhost:8080/rest-docs/ 访问 JSON 文档。

我们也可以通过访问 http://localhost:8080/v3/api-docs.yaml 来获取文档的 YAML 版本。

通过这样,我们已经成功使用 springdoc-openapi 生成我们端点的文档。在下一节中,我们将学习如何配置、访问和使用 Swagger UI。

设置 Swagger UI

Swagger UI 是一个文档工具,它允许用户直接从浏览器中调用项目中可用的 API。这是一个更互动的工具,它使得 API 的使用更加详细和实用。Swagger UI 也是开源的,这使得更多的社区能够支持这个工具。

安装和使用 Swagger UI

Swagger UI 已经包含在 springdoc-openapi-ui 依赖项中。我们已经通过添加以下代码来包含 OpenAPI 扩展代码:

<dependency>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-ui</artifactId>
      <version>1.6.4</version>
</dependency>

OpenAPI 依赖项包括 Swagger UI 扩展;我们可以通过以下 URL 访问 UI:http://localhost:8080/swagger-ui.html。这将打开 Swagger UI 的 OpenAPI 定义 页面:

图 6.1 – Swagger UI 的 OpenAPI 定义页面

图 6.1 – Swagger UI 的 OpenAPI 定义页面

在这里,我们可以看到我们的 Swagger UI 已经成功访问。我们在 Spring Boot 项目中创建的端点及其 HTTP 方法也显示出来。让我们讨论 Swagger UI 文档的不同部分。

在 Swagger UI 中,我们首先可以看到它包含文本输入,以及 v3/api-docs。这意味着我们使用 OpenAPI 库生成的 JSON 文档正在被 Swagger 用于获取可用的端点。

我们可以更改它并访问包含 OpenAPI 文档的 URL,该文档以 JSON 或 YAML 格式存在。接下来我们将看到的是我们项目中可用的端点列表。在先前的例子中,我们在 Swagger UI 中列出了我们开发的五个端点。这不是一个列表,因为 Swagger UI 工具是互动的,允许我们尝试可用的端点。

让我们看看以下示例:

图 6.2 – 对反英雄的 POST 请求

图 6.2 – 对反英雄的 POST 请求

我们可以展开 /api/v1/anti-heroes 端点来在我们的数据库中创建一个新的反英雄对象,因为这是使用 POST HTTP 方法。我们需要在请求体中传递的对象的架构被指定。它定义了属性的名字和类型。在这种情况下,反英雄实体具有以下架构:

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "firstName": "string",
  "lastName": "string",
  "house": "string",
  "knownAs": "string"
}

在示例端点中也指定了可能的响应。可能的响应状态为 200,表示成功。它还将返回数据库中的新创建实体。

我们想测试端点并将一些示例数据插入到数据库中。为此,我们必须点击右上角的 Try it out 按钮,然后点击 Execute 按钮来调用端点。一旦 API 调用成功,我们将看到以下输出:

图 6.3 – POST 请求的响应

图 6.3 – POST 请求的响应

在这里,我们可以看到 API 成功返回,因为它返回了一个 代码200 的新创建的数据库实体。

我们可以通过访问 pgAdmin 来检查我们的端点是否已成功将数据插入到我们的表中,如下面的截图所示:

图 6.4 – 验证是否已从 POST 请求中插入数据

图 6.4 – 验证是否已从 POST 请求中插入数据

在前面的例子中,我们可以看到我们的数据已经成功插入到我们的表中。现在,我们可以通过获取、更新或删除数据库中创建的数据来测试其他可用的端点。

我们已经成功导航通过 Swagger UI 工具并与之交互,但我们也可以通过使用属性来修改 Swagger UI,以符合我们的偏好和需求,就像我们可以对 OpenAPI 文档所做的那样。我们还可以修改 URL 以访问 Swagger UI;例如,我们可以在 application.properties 文件中放置 springdoc.swagger-ui.path=/{custom-path}.html

我们还可以修改的其他行为是我们的端点的排序行为。我们可以根据 alpha(按字母数字顺序排列)或 method(按方法排列)来更改端点在列表中的排列方式,或者我们可以使用自定义函数来更改排序方法。为此,我们可以在 application.properties 文件中放置 springdoc.swagger-ui.operationsSorter=(排序行为)

在这个例子中,我们将使用 springdoc.swagger-ui.operationsSorter=method。我们将看到以下输出:

图 6.5 – 按方法排列的端点

图 6.5 – 按方法排列的端点

如我们所见,我们的端点现在按照 HTTP 方法排列。

在 Swagger UI 中显示分页信息

Swagger UI 也可以与使用分页的端点集成。我们可以指定页码、每页列表的大小和排序表达式。为了在 Swagger UI 中集成分页参数,我们需要添加 springdoc-open-data-rest 依赖项。

要添加库,我们必须将以下代码添加到我们的 pom.xml 文件中:

<dependency>
   <groupId>org.springdoc</groupId>
   <artifactId>springdoc-openapi-data-rest</artifactId>
     <version>1.6.4</version>
</dependency>	

在成功添加库之后,让我们修改 AntiHeroesController 下的 getAntiHeroes 方法,以便我们有一个工作的分页功能:

…. import org.springframework.data.domain.Pageable;
@GetMapping
public List<AntiHeroDto> getAntiHeroes(Pageable pageable) {
    int toSkip = pageable.getPageSize() *
                 pageable.getPageNumber();
    var antiHeroList = StreamSupport
            .stream(service.findAllAntiHeroes().spliterator(), false)
            .skip(toSkip).limit(pageable.getPageSize())
            .collect(Collectors.toList());
    return antiHeroList
            .stream()
            .map(this::convertToDto)
            .collect(Collectors.toList());
}

现在,让我们扩展使用 GET 方法的 api/v1/anti-heroes

图 6.6 – 反英雄的 GET 方法

图 6.6 – 反英雄的 GET 方法

在这里,我们可以看到 参数 部分有一个 pageable 属性,并且我们可以指定我们想要检索的页面、每页的大小和排序表达式。现在,我们可以执行它以从数据库中检索数据。

摘要

有了这个,我们就到达了本章的结尾。让我们回顾一下你学到的宝贵知识。

首先,你学习了springdoc-openapi提供的功能和属性,以及如何配置和使用 OpenAPI 规范来生成我们 API 调用的 JSON 和 YAML 文档。你还学习了如何访问 Swagger UI,这是一个可以直接在浏览器中调用 API 的交互式文档工具。我们模拟了发送测试调用和修改某些行为,例如域名 URL 和排序顺序。这种为生成 API 文档的新知识在现实世界的应用中非常有用。开发者可以利用这些知识轻松地识别可消费的 API,以及它们的参数和对象响应。

在下一章中,我们将学习跨源资源共享CORS)、Spring Security 以及JSON Web TokensJWTs)的概念。

第七章:添加 Spring Boot 安全性与 JWT

在上一章中,我们主要学习了如何在 Spring Boot 项目中为创建的 API 生成自动化文档。我们学习了如何添加和使用 springdoc-openapi 的特性和属性,配置项目上的插件,以及访问生成的 JSON 和 YAML 文档。我们还学习了如何实现 Swagger UI,使我们的文档交互式,并允许我们在浏览器上直接测试端点。

本章现在将关注我们应用程序的安全方面。我们将讨论 跨源资源共享CORS)的概念以及它如何保护我们的应用程序。我们还将讨论 Spring Boot 中 Spring Security 的特性和实现、JSON Web TokenJWT)的概念以及 身份即服务IDaaS)。

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

  • 理解 CORS

  • 添加 CORS 策略

  • 理解 Spring Security

  • Spring Boot 中的身份验证和授权

  • IDaaS

技术要求

本章代码的完成版本链接在此:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-07/superheroes

理解 CORS

当我们作为开发者创建应用程序时,可能已经多次遇到过 CORS 这个术语。然而,我们可能会提出诸如 CORS 是做什么的?或者在应用程序中实现 CORS 的优势是什么等问题。带着这些问题,在本节中,我们将深入探讨 CORS 的概念和特性,并了解它是如何用于保护我们的应用程序的。

CORS 是一种基于头部的机制,允许服务器定义一组允许访问应用程序资源的域、方案或端口。CORS 通常用于 REST API。不同的前端应用程序可以访问我们后端应用程序下的 API,尤其是在复杂的架构中。我们不希望我们的 API 被未知的应用程序访问,而 CORS 负责保护这部分。

让我们来看一个简单的跨源请求示例。假设我们有一个前端应用程序,其域为 https://domain-one.com,以及一个后端应用程序,其域为 domain-two.com。我们可以看到我们的应用程序由不同的域提供服务,一旦前端应用程序向后端发送请求,这被视为跨源请求。

我们永远不应该忘记,浏览器默认会限制跨源请求,并且只有请求资源的源包含适当的 CORS 头部并且被后端应用程序允许时,才允许请求同源资源。这只是 CORS 的工作原理的一个简单示例。让我们更详细地了解一下 CORS 的概念。

CORS 的工作原理

CORS 是一个基于头部的机制,这意味着实现跨源共享的第一步是添加新的 HTTP 头部,这些头部将描述允许访问资源的来源列表。这些头部可以被视为我们沟通的关键。HTTP 头部分为两类,如下所示:

  • 请求头部

  • 响应头部

请求头部

请求头部是客户端为了使用 CORS 机制所必需的头部。它们如下所示:

  • Origin:这表示请求客户端的来源或简单地表示前端应用程序的主机。

  • Access-Control-Request-Method:这个头部在预检请求中使用,用于指示用于发送请求的 HTTP 方法。

  • Access-Control-Request-Headers:这个头部在预检请求中使用,用于指示请求中使用的 HTTP 头部列表。

让我们看看使用请求头部的一个请求示例:

curl -i -X OPTIONS localhost:8080/api/v1 \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: Content-Type, Accept' \
-H 'Origin: http://localhost:4200

响应头部

响应头部是服务器随响应发送的头部。它们如下所示:

  • Access-Control-Allow-Origin:这是一个用于指定服务器上资源访问来源的头部。

  • Access-Control-Expose-headers:这个头部指示浏览器可以访问的头部。

  • Access-Control-Max-Age:这是一个指示预检请求过期信息的头部。

  • Access-Control-Allow-Credentials:这是一个指示当请求具有有效凭据时浏览器可以访问响应的头部。

  • Access-Control-Allow-Headers:这个头部指示在请求中允许使用的头部列表。

  • Access-Control-Allow-Methods:这是一个指示在服务器中允许使用的请求方法列表的头部。

让我们看看给定头部我们希望得到的响应示例:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Vary: Access-Control-Request-Headers
Access-Control-Allow-Headers: Content-Type, Accept
Content-Length: 0
Date: Sun, 16 Nov 2022 3:41:08 GMT+8
Connection: keep-alive

这些是我们将使用以允许 CORS 机制的标准头部,但存在几种不同的场景,在这些场景中跨源共享是有效的。

简单请求

这些请求不会触发 CORS 预检请求,并且由于没有初始请求,将直接发送到服务器进行验证。要考虑一个请求是简单的,它应该满足以下条件:

  • 使用 POSTGET 方法。

  • 包含可以手动设置的头部,例如 AcceptAccept-LanguageContent-LanguageContent-Type

  • Content-Type 应该具有以下类型之一:text/plainmultipart/form-dataapplication/x-www-form-urlencoded

  • 请求中没有使用 ReadableStream 对象。

让我们看看一个简单请求的示例:

GET /content/test-data/ HTTP/1.1
Host: example.host
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://frontend.com

这个请求将在客户端和服务器之间执行一个简单的交换。作为响应,服务器返回带有 Access-Control-Allow-Origin: * 的头部,这意味着资源或端点可以被任何来源访问。

预检请求

浏览器会发送一个测试或第一个 HTTP 请求,使用OPTIONS方法来验证请求是否被允许或安全。预请求总是发生在跨源请求上,因为预请求会检查是否允许或允许不同的源访问资源。

让我们看看一个预请求的例子:

OPTIONS /content/test-data/ HTTP/1.1
Host: example.host
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

之前的例子显示,预请求使用OPTIONS请求方法来执行预请求。OPTIONS方法用于从服务器获取更多信息,以确定实际请求是否被允许。

我们还可以看到Access-Control-Request-MethodAccess-Control-Request-Headers被识别。这表示实际请求中要使用的请求头部和请求方法。

这是头部信息:

HTTP/1.1 204 No Content
Date: Sun, 16 Nov 2022 3:41:08 GMT+8
Server: Apache/2
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

现在,在之前的例子中,这是一个预请求返回的示例响应。Access-Control-Allow-Origin表示只有指定域名(例如示例中的https://frontend.com)的资源访问是被允许的。Access-Control-Allow-Methods确认POSTGET是有效的请求方法。Access-Control-Allow-Headers确保X-PINGOTHERContent-Type是实际请求中适当的头部。

我们已经学习了 CORS 的基本概念;现在,我们将在下一节中实现 Spring Boot 应用程序中的 CORS。

添加 CORS 策略

我们已经了解了 CORS 的工作原理及其对我们应用程序安全性的优势。现在,我们将在我们的 Spring Boot 项目中配置和实现一个 CORS 策略。

在我们的项目中配置 CORS 有几种方法。我们将逐一讨论。

每个方法的 CORS 应用

我们可以在单个端点上启用 CORS;这意味着我们可以为其他端点指定不同的允许源。让我们看看以下例子:

@CrossOrigin
@GetMapping
public List<AntiHeroDto> getAntiHeroes(Pageable pageable) {
  ..code implementation
}

在我们的 Spring Boot 项目中,我们有getAntiHeroes()方法。为了在特定方法上启用 CORS,我们将使用@CrossOrigin注解。我们可以看到我们没有配置任何其他设置,并且这适用以下情况:

  • 所有源都被允许。

  • 允许的 HTTP 方法是配置给该方法的方法(在这个方法中,允许的 HTTP 方法是GET)。

  • 预请求响应的时间被缓存了 30 分钟。

我们也可以通过添加originmethodsallowedHeadersexposedHeadersallowedCredentialsmaxAge的值来指定 CORS 策略的配置:

@CrossOrigin(origin = "origin.example")
@GetMapping
public List<AntiHeroDto> getAntiHeroes(Pageable pageable) {
  ..code implementation
}

控制器级别的 CORS 应用

在之前的配置中,我们是在每个方法上添加 CORS。现在,我们将在控制器级别添加 CORS 策略。让我们看看以下例子:

@CrossOrigin
@AllArgsConstructor
@RestController
@RequestMapping("api/v1/anti-heroes")
public class AntiHeroController {
.. methods
}

我们可以看到在类级别添加了@CrossOrigin。这意味着 CORS 策略将被添加到AntiHeroController下的所有方法。

控制器和方法的 CORS 应用

我们可以在我们的应用程序中同时应用控制器和方法级别的 CORS 应用。让我们看看以下示例:

@CrossOrigin(allowedHeaders = "Content-type")
@AllArgsConstructor
@RestController
@RequestMapping("api/v1/anti-heroes")
public class AntiHeroController {
    private final AntiHeroService service;
    private final ModelMapper mapper;
    @CrossOrigin(origins = "http://localhost:4200")
    @GetMapping
    public List<AntiHeroDto> getAntiHeroes(Pageable   pageable) {
… code implementation
     }

在我们的示例中,我们可以看到我们在控制器和方法级别都应用了 @CrossOrigin 注解,@CrossOrigin(allowedHeaders = "Content-type") 将应用于 AntiHeroController 下的所有方法,而 @CrossOrigin(origins = http://localhost:4200) 仅应用于 getAntiHeroes() 方法,因此其他方法将允许所有来源。

全局 CORS 配置

我们可以实现的最后一种实现 CORS 策略的方法是使用 CorsFilter

  1. 第一步是为我们的 CORS 策略添加一个配置类。为此,请访问我们项目的 config 文件夹并创建一个名为 CorsConfig 的新类。我们将添加 @Configuration 注解以在应用程序启动时将此类识别为配置类,并且我们应该有以下的代码:

    @Configuration
    
    public class CorsConfig {
    
    }
    
  2. 下一步是创建我们的 CorsFilter Bean。我们将仅创建一个带有 @Bean 的新方法,该方法返回一个 CorsFilter 对象:

    @Bean
    
    CorsFilter corsFilter() {
    
    CorsConfiguration corsConfiguration =
    
      new CorsConfiguration();
    
    }
    
  3. corsFilter() 方法下,我们将放置所有的 CORS 设置。我们将实例化一个 CorsConfiguration 对象,我们将通过调用几个方法来设置其属性。我们将使用的方法如下:

    • setAllowCredentials() 方法指示浏览器是否应在跨源请求中发送诸如 cookies 之类的凭据。这意味着如果我们检索 cookies 和 跨站请求伪造CSRF)令牌,我们希望将此选项设置为 true

      corsConfiguration.setAllowCredentials(true);
      
    • setAllowedOrigins() 方法允许我们设置可以访问我们的端点的允许来源。这些是受信任的前端应用程序的域名。在以下示例中,我们已将 http://localhost:4200 设置为,这将是我们的前端应用程序的开发服务器:

      corsConfiguration.setAllowedOrigins(Arrays.asList("http://localhost:4200"));
      
    • setAllowedHeaders() 方法允许我们配置 HTTP 请求中允许的头列表。在前面的示例中,我们已设置了一些可以在请求中使用的头:

      corsConfiguration.setAllowedHeaders(
      
              Arrays.asList(
      
                      "Origin",
      
                      "Access-Control-Allow-Origin",
      
                      "Content-Type",
      
                      "Accept",
      
                      "Authorization",
      
                      "Origin, Accept",
      
                      "X-Requested-With",
      
                      "Access-Control-Request-Method",
      
                      "Access-Control-Request-Headers"
      
              )
      
      );
      
    • setExposedHeaders() 方法允许我们指定来自服务器的响应头列表。我们可以使用此方法通过安全措施限制响应中的头:

      corsConfiguration.setExposedHeaders(
      
              Arrays.asList(
      
                      "Origin",
      
                      "Content-Type",
      
                      "Accept",
      
                      "Authorization",
      
                      "Access-Control-Allow-Origin",
      
                      "Access-Control-Allow-Origin",
      
                      "Access-Control-Allow-Credentials"
      
              )
      
      );
      
    • setAllowedMethods() 方法将允许我们添加授权用于访问端点的 HTTP 请求方法。在以下示例中,我们已将 GETPOSTPUTDELETEOPTIONS 配置为允许的方法,因为我们只构建一个简单的 创建、读取、更新和删除CRUD)应用程序:

      corsConfiguration.setAllowedMethods(
      
              Arrays.asList("GET", "POST", "PUT", "DELETE",
      
                            "OPTIONS")
      
      );
      
  4. 我们需要做的最后一步是注册 CORS 配置。我们将实例化一个新的urlBasedCorsConfigurarationSource(),并使用registerCorsConfiguration()方法进行注册。第一个参数是"/**",表示配置适用于应用程序中找到的所有方法,第二个参数是corsConfiguration,这是我们创建的配置:

    var urlBasedCorsConfigurationSource =
    
      new UrlBasedCorsConfigurationSource();
    
    urlBasedCorsConfigurationSource.registerCorsConfiguration(
    
            "/**",
    
            corsConfiguration
    
    );
    
    return new CorsFilter(urlBasedCorsConfigurationSource);
    
  5. 注册后,我们将使用配置源作为CorsFilter的参数,以下是成功配置 CORS 设置后我们的corsFilter()方法将如何看起来:

    @Bean
    
    CorsFilter corsFilter() {
    
        CorsConfiguration corsConfiguration =
    
          new CorsConfiguration();
    
        corsConfiguration.setAllowCredentials(true);
    
        corsConfiguration.setAllowedOrigins(
    
          Arrays.asList("http://localhost:4200"));
    
        corsConfiguration.setAllowedHeaders(
    
          Arrays.asList("Origin",
    
            "Access-Control-Allow-Origin",
    
            "Content-Type","Accept","Authorization",
    
            "Origin, Accept","X-Requested-With",
    
            "Access-Control-Request-Method",
    
            "Access-Control-Request-Headers"));
    
        corsConfiguration.setExposedHeaders(
    
          Arrays.asList( "Origin","Content-Type",
    
          "Accept","Authorization",
    
          "Access-Control-Allow-Origin",
    
          "Access-Control-Allow-Origin",
    
          "Access-Control-Allow-Credentials"));
    
        corsConfiguration.setAllowedMethods(
    
           Arrays.asList("GET", "POST", "PUT", "DELETE",
    
                         "OPTIONS")
    
        );
    
        var urlBasedCorsConfigurationSource =
    
          new UrlBasedCorsConfigurationSource();
    
        urlBasedCorsConfigurationSource
    
          .registerCorsConfiguration(
    
                "/**",
    
                corsConfiguration
    
        );
    
        return new CorsFilter(
    
          urlBasedCorsConfigurationSource);
    
    }
    

在启动我们的应用程序后,我们现在将 CORS 配置应用于我们项目中的所有方法。我们已经成功地在我们的应用程序中实现了 CORS 策略,但这只是我们保护应用程序的一部分。

在下一节中,我们将讨论 Spring Security 的概念以及如何在 Spring Boot 项目中实现它。

理解 Spring Security

Spring Security 是一个在 Spring Boot 应用程序中广泛使用的应用级安全框架。它是一个灵活的认证框架,为 Java 应用程序提供了大多数标准安全需求。Spring Security 因其允许开发人员即时与其他模块集成不同的授权和认证提供者而受到欢迎。

由于我们在应用程序中使用 Spring Security,我们不需要从头开始编写安全相关的任务,因为 Spring Security 在幕后提供了这些功能。

让我们进一步讨论 Spring Security 的概念。

Spring Security 的特性

Spring Security 主要关注将认证和授权集成到应用程序中。为了比较这两个概念,认证指的是验证用户可以访问您的应用程序并识别用户是谁。这主要指的是登录页面本身。另一方面,授权用于更复杂的应用程序;这关系到特定用户可以在您的应用程序中执行的操作或动作。

授权可以通过集成角色来实现用户访问控制。Spring Security 还提供了不同的密码编码器——单向变换密码——如下所示:

  • BCryptPasswordEncoder

  • Argon2PasswordEncoder

  • Pbkdf2PasswordEncoder

  • SCryptPasswordEncoder

上述列表是最常用的密码编码器,当使用 Spring Security 时可以直接访问。它还提供了不同的功能,将帮助您满足安全需求,如下所示:

  • 轻量级目录访问协议LDAP):一种在互联网协议上包含和访问分布式目录信息服务协议。

  • 记住我:此功能提供了一种从单台机器记住用户的能力,以防止再次登录。

  • 单点登录SSO):此功能允许用户使用单个账户访问多个应用程序,集中管理用户信息。

  • 软件本地化:此功能使我们能够用我们喜欢的语言开发用户界面。

  • HTTP 认证:此功能提供授权。

  • 基本访问认证:此功能提供基本的认证过程,请求需要用户名和密码。

  • 摘要访问认证:此功能提供更安全的认证,在访问资源之前确认用户的身份。

  • Web 表单认证:将生成一个表单,可以直接从网页浏览器中验证用户凭据。

Spring Security 为应用程序提供了一系列功能。在这种情况下,Spring Security 的设计根据其功能被划分为独立的 Java 归档JAR)文件,只需安装我们开发所需的部分。

以下是在 Spring Security 模块中包含的 JAR 文件列表:

  • spring-security-core:应用程序使用 Spring Security 的标准要求。Spring-security-core 包含核心认证类和接口。

  • spring-security-web:此 JAR 文件用于 Web 认证和基于 URL 的访问控制。它位于 org.springframework.security.web 下。

  • spring-security-config:此 JAR 文件用于实现 Spring Security 配置,使用 XML 和 Java。所有类和接口都位于 org.springframework.security.config 下。

  • spring-security-ldap:此 JAR 文件是我们在应用程序中实现 LDAP 所必需的。所有类和接口都位于 org.springframework.security.ldap 下。

  • spring-security-oauth2-core:此 JAR 文件用于实现 OAuth 2.0 授权框架和 OpenID Connect Core。类位于 org.springframework.security.oauth2.core 下。

  • spring-security-oauth2-client:此 JAR 文件提供 OAuth 登录和 OpenID 客户端支持。所有类和接口都位于 org.springframework.security.oauth2.client 下。

  • spring-security-openid:此 JAR 文件用于支持 OpenID Web 认证,以验证外部 OpenID 服务器上的用户。

  • spring-security-test:此 JAR 文件用于支持 Spring Security 应用程序的测试。

  • spring-security-cas:此 JAR 文件实现了与 CAS SSO 服务器的 Web 认证。所有类和接口都位于 org.springframewok.security.cas 下。

  • spring-security-acl:此 JAR 文件用于将安全集成到应用程序的域对象中。我们可以访问位于 org.springframework.security.acls 下的类和接口。

我们现在已了解了 Spring Security 提供的不同功能和模块。在下一节中,我们将学习如何在 Spring Boot 应用程序中使用 Spring Security 实现身份验证和授权。

Spring Boot 中的身份验证和授权

我们已经在上一节中讨论了 Spring Security 的概念;现在,我们将学习如何将 Spring Security 集成到我们的 Spring Boot 应用程序中。随着我们继续到示例中,我们将使用 Spring Boot Security 的所有模块和功能。

身份验证和授权是我们实现应用程序安全时遇到的最常见概念。这是我们为使应用程序安全而应用的两个验证。

配置 Spring Boot 并实现身份验证

我们将首先在我们的应用程序中实现身份验证。我们首先需要将 Spring Boot Security 依赖项添加到我们的项目中。要添加依赖项,我们将在 pom.xml 中添加以下内容:

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

重新加载项目以安装新的依赖项并运行服务器。让我们尝试访问 localhost:8080 以在浏览器中打开 Spring Boot 应用程序项目。正如我们所见,由于我们已安装 Spring Boot Security,现在已将登录页面应用于我们的项目:

图 7.1 – 从 Spring Boot Security 集成的登录页面

图 7.1 – 从 Spring Boot Security 集成的登录页面

要为登录创建凭据,我们可以在 application.properties 文件下配置用户名和密码,通过放置以下设置:

spring.security.user.name=admin
spring.security.user.password=test

在前面的示例中,我们已使用 admin 作为用户名,test 作为密码进行我们的 Spring Boot Security 登录,这将允许我们成功登录到我们的应用程序。

我们现在已成功为我们的项目设置了 Spring Boot Security,这会自动将身份验证应用于我们的端点。下一步我们需要做的是添加我们的安全配置;我们希望覆盖默认配置并为我们应用程序的其他端点实现一个定制的登录端点。

要开始配置,让我们首先在配置文件下创建一个名为 SecurityConfig 的新类。我们将使用 WebSecurityConfigurerAdapter 扩展我们的新 SecurityConfig 类。此适配器允许我们覆盖和自定义 WebSecurityHttpSecurity 的配置,并在扩展类之后,我们将覆盖前两个方法:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    //We will place the customized userdetailsservice here in the following steps
}
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

我们将在 WebSecurityConfigurerAdapter 上重写的第一个方法是 configure(AuthenticationManagerBuilder auth) 方法,它接受 AuthenticationManagerBuilder,用于构建 LDAP 认证、基于 JDBC 的认证、添加自定义的 UserDetailsService 以及添加 AuthenticationProviders。在这种情况下,我们将使用它来访问 userDetailsServiceMethod() 以自定义我们的认证。我们将在以下步骤中这样做,因为我们尚未创建我们的修改后的 UseDetailsService

第二种方法是 authenticationManagerBean();我们重写此方法以将 AuthenticationManager 作为我们的应用程序中的一个 bean 暴露出来,我们将在稍后的 AuthenticateController 中使用它。下一步是实现我们想要的 HTTP 请求的配置。为了实现这一点,我们将重写 configure(HttpSecurity http) 方法。

HttpSecurity 允许我们调用方法来实现对基于 Web 的安全请求的 HTTP 请求的配置。默认情况下,安全配置将应用于所有 HTTP 请求,但我们可以通过使用 requestMatcher() 方法仅设置特定的请求。HttpSecurity 与 Spring Security XML 配置相同。

让我们讨论 HttpSecurity 下的标准方法:

  • csrf(): 启用 CSRF 保护。默认情况下是启用的。

  • disable(): 禁用初始配置;在调用此方法后可以应用新版本。

  • antMatcher("/**"): 默认情况下,我们的配置将应用于所有 HTTP 请求。我们可以使用 antMatcher() 方法来指定我们想要应用配置的 URL 模式。

  • antMatchers("/**"): 与 antMatcher() 方法类似,但接受一个我们想要应用配置的模式列表。

  • permitAll(): 指定任何人都可以访问任何 URL 端点。

  • anyRequest(): 适用于任何类型的 HTTP 请求。

  • authenticated(): 指定任何已认证用户都可以访问任何 URL 端点。

  • exceptionHandling(): 异常处理配置。

  • sessionManagement(): 此方法通常用于管理一个用户可以有多少个活动会话;例如,我们配置 sessionManagement().maximumSessions(1).expiredUrl("/login?expired"),这表示当用户在另一个终端登录并尝试登录到另一个实例时,它将自动将其从另一个实例注销。

  • sessionCreationPolicy(): 允许我们为会话何时创建创建一个策略;可能的值有 ALWAYSIF_REQUIREDNEVERSTATELESS

在我们的代码中,让我们为我们的安全配置一个基本配置。让我们将以下代码放在 configure(HttpSecurity http) 方法中:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        // first chain
        .csrf()
        .disable()
        // second chain
        .antMatcher("/**")
        .authorizeRequests()
        // third chain
        .antMatchers("/**")
        .permitAll()
        // fourth chain
        .and()
        .sessionManagement()
        .sessionCreationPolicy(
          SessionCreationPolicy.STATELESS);
}

在前面的配置示例中,我们已经为我们的应用程序实现了几个配置。你可以注意到,我们已经将方法分成了链。这是为了展示这些方法之间的关系。

第一个链,.csrf().disable(),禁用了 CSRF 保护的使用。这只是一个例子,不建议在构建应用程序时禁用 CSRF。

这可以通过在 hasRole() 方法中指定角色来修改,通过限制基于分配的角色来限制用户。第三个链 .antMatchers("/**").permitAll() 指示任何用户都可以访问所有 URL,最后,sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 指示 Spring Security 不应该创建任何会话。

我们已经成功创建了 SecurityConfig,它包含了我们所有 Spring Security 的配置;我们的代码应该看起来像以下这样:

@AllArgsConstructor
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
     // removed some code for brevety
    @Override
    protected void configure(HttpSecurity http) throws
      Exception {
        http
                // first chain
                .csrf()
                .disable()
                // second chain
                .antMatcher("/**")
                .authorizeRequests()
                // third chain
                .antMatchers("/**")
                .permitAll()
                // fourth chain
                .and()
                .sessionManagement()
                .sessionCreationPolicy(
                  SessionCreationPolicy.STATELESS);
    }
}

现在,我们将继续下一步,创建我们用户实体的端点。

创建用户端点

当实现 CRUD 时,我们需要创建我们的用户端点。我们需要开发这些端点,以便它们将用于在我们数据库中注册新用户。在这个例子中,我们将重复讨论如何开发端点的步骤,如 w使用 OpenAPI 规范记录 API,但我们还将为用户实体创建整个 CRUD 功能。

让我们创建一个新的用户包,并在用户包下创建控制器、数据、实体、仓库和服务包。

创建用户实体

让我们先创建用户实体,在实体包下创建一个名为 UserEntity 的新类,我们将放置以下代码:

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserEntity {
  @Id
  @GeneratedValue(strategy = GenerationType.AUTO,
                  generator = "UUID")
  @Column(nullable = false, updatable = false)
  private UUID id;
  @Column(unique = true)
  private String email;
  private String mobileNumber;
  private byte[] storedHash;
  private byte[] storedSalt;
  public UserEntity(String email, String mobileNumber) {
    this.email = email;
    this.mobileNumber = mobileNumber;
  }
}

在前面的例子中,我们已经为 UserEntity 分配了几个属性。我们用 @Entity 注解来表示这是一个 storedHashstoredSalt 属性。storedHashstoredSalt 将用于散列和验证用户的密码。

创建用户 DTO

在创建用户实体后,我们将在数据包下创建 UserDto,并将以下代码放置其中:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
  private UUID id;
  private String email;
  private String mobileNumber;
  private String password;
}

创建用户仓库

下一步我们需要做的是创建我们用户的仓库。在仓库包下,创建一个名为 UserRepository 的新类,我们将通过添加以下代码来使用 JPARepository 扩展这个类:

@Repository
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
  @Query(
    "" +
    "SELECT CASE WHEN COUNT(u) > 0 THEN " +
    "TRUE ELSE FALSE END " +
    "FROM UserEntity u " +
    "WHERE u.email = ?1"
  )
  Boolean selectExistsEmail(String email);
  UserEntity findByEmail(String email);
}

在前面的例子中,我们通过扩展 UserRepository 使用 JPARepository 赋予了我们的仓库所有 CRUD 功能。我们还创建了两个带有 @Query 注解的方法,用于检查电子邮件地址是否已存在。

创建用户服务

下一步是创建我们的用户服务,我们将在这里实现应用程序的业务逻辑。在服务包下,我们将在创建服务后创建一个名为UserService的新类。

我们将为构造函数添加@AllArgsConstructor以注入依赖项,并使用@Service注解让 Spring 知道这是一个服务层,我们还将在此注解和依赖注入之后将ModelMapperUserRepository注入到我们的服务中。

我们可以创建两个方法,允许我们将实体转换为 DTO,反之亦然,具体代码如下:

private UserDto convertToDto(UserEntity entity) {
  return mapper.map(entity, UserDto.class);
}
private UserEntity convertToEntity(UserDto dto) {
  return mapper.map(dto, UserEntity.class);
}

现在,我们将创建基本 CRUD 功能性的代码:

  • 获取所有用户: 要获取所有用户,我们将放置以下代码:

    public List<UserDto> findAllUsers() {
    
      var userEntityList =
    
        new ArrayList<>(repo.findAll());
    
      return userEntityList
    
        .stream()
    
        .map(this::convertToDto)
    
        .collect(Collectors.toList());
    
    }
    

示例代码返回所有用户列表转换为 DTO。

  • 通过 ID 获取用户: 要通过 ID 获取特定用户,我们将放置以下代码:

    public UserDto findUserById(final UUID id) {
    
      var user = repo
    
        .findById(id)
    
        .orElseThrow(
    
          () -> new NotFoundException("User by id " + id +
    
                                      " was not found")
    
        );
    
      return convertToDto(user);
    
    }
    

此示例方法使用用户仓库的findByID()方法检索特定用户。

  • createSalt()方法,它将允许我们为用户的密码创建盐。

让我们把createSalt()方法的代码放进来:

private byte[] createSalt() {
  var random = new SecureRandom();
  var salt = new byte[128];
  random.nextBytes(salt);
  return salt;
}

下一个方法是createPasswordHash(),它将允许我们哈希用户的密码。我们使用 SHA-512 哈希算法和提供的盐来创建该方法。以下是对createPasswordHash()实现的代码:

private byte[] createPasswordHash(String password, byte[] salt)
  throws NoSuchAlgorithmException {
  var md = MessageDigest.getInstance("SHA-512");
  md.update(salt);
  return md.digest(
    password.getBytes(StandardCharsets.UTF_8));
}

最后一个方法是createUser()方法本身。我们首先检查是否提供了密码,然后使用我们创建的selectExistsEmail()方法检查电子邮件地址是否已存在。接下来,在所有验证都通过之后,使用createSalt()方法创建盐,并使用createPasswordHash()方法哈希密码。最后,将新用户保存到数据库中。以下是对createUser()实现的代码:

public UserDto createUser(UserDto userDto, String password)
  throws NoSuchAlgorithmException {
  var user = convertToEntity(userDto);
  if (password.isBlank()) throw new
    IllegalArgumentException(
    "Password is required."
  );
  var existsEmail =
    repo.selectExistsEmail(user.getEmail());
  if (existsEmail) throw new   BadRequestException(
    "Email " + user.getEmail() + " taken"
  );
  byte[] salt = createSalt();
  byte[] hashedPassword =
    createPasswordHash(password, salt);
  user.setStoredSalt(salt);
  user.setStoredHash(hashedPassword);
  repo.save(user);
  return convertToDto(user);
}
  • updateUser()deleteUser()。这是一个不同的方法,我们可以实现它,以赋予我们编辑用户详细信息或从数据库中删除用户的能力。

让我们看看以下代码实现:

public void updateUser(UUID id, UserDto userDto, String password)
  throws NoSuchAlgorithmException {
  var user = findOrThrow(id);
  var userParam = convertToEntity(userDto);
  user.setEmail(userParam.getEmail());
  user.setMobileNumber(userParam.getMobileNumber());
  if (!password.isBlank()) {
    byte[] salt = createSalt();
    byte[] hashedPassword =
      createPasswordHash(password, salt);
    user.setStoredSalt(salt);
    user.setStoredHash(hashedPassword);
  }
  repo.save(user);
}
public void removeUserById(UUID id) {
  findOrThrow(id);
  repo.deleteById(id);
}
private UserEntity findOrThrow(final UUID id) {
  return repo
    .findById(id)
    .orElseThrow(
      () -> new NotFoundException("User by id " + id +
                                  " was not found")
    );
}

我们已经创建了用户实体所需的所有服务。现在,最后一步是创建我们的控制器。

创建用户控制器

用户最后的需要是创建控制器。我们将在注解的服务下创建findAllUsers()findUserById()deleteUserById()createUser()putUser()的方法,具体 HTTP 请求。

让我们看看以下代码实现:

@AllArgsConstructor
@RestController
public class UserController {
  private final UserService userService;
  @GetMapping("/api/v1/users")
  public Iterable<UserDto> getUsers() {
    return userService.findAllUsers();
  }
  @GetMapping("/api/v1/users/{id}")
  public UserDto getUserById(@PathVariable("id") UUID id) {
    return userService.findUserById(id);
  }
  @DeleteMapping("/api/v1/users/{id}")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public void deleteUserById(@PathVariable("id") UUID id) {
    userService.removeUserById(id);
  }
  @PostMapping("/register")
  @ResponseStatus(HttpStatus.CREATED)
  public UserDto postUser(@Valid @RequestBody UserDto
                          userDto)
    throws NoSuchAlgorithmException {
    return userService.createUser(userDto,
                                  userDto.getPassword());
  }
  @PutMapping("/api/v1/users/{id}")
  public void putUser(
    @PathVariable("id") UUID id,
    @Valid @RequestBody UserDto userDto
  ) throws NoSuchAlgorithmException {
    userService.updateUser(id, userDto,
                           userDto.getPassword());
  }

我们已经成功创建了用户实体的端点;现在,我们可以使用/register端点来为有效的身份验证创建新用户。现在,我们将使用 JWT 创建登录端点。

JWT

JWT 是一种安全的 URL 方法,用于数据通信。JWT 可以看作是一个包含大量信息的 JSON 对象的加密字符串。它包括一个额外的结构,由使用 JSON 格式的头部和负载组成。JWT 可以使用 消息认证码MAC)进行加密或签名。JWT 通过组合头部和负载 JSON 创建,整个令牌是 Base64-URL 编码的。

何时使用 JWT

JWT 主要用于无法维护客户端状态的 RESTful Web 服务,因为 JWT 包含一些与用户相关的信息。它可以向服务器提供每个请求的状态信息。JWT 在需要客户端认证和授权的应用程序中被使用。

JWT 示例

让我们看看以下 JWT 示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIi wibmFtZSI6IlNlaWppIFZpbGxhZnJhbmNhIiwiaWF0IjoxNTE2MjM5MDIyfQ.uhmdFM4ROwnerVam-zdYojURqrgL7WQRBRj-P8kVv6s

给定示例中的 JWT 由三个部分组成——我们可以注意到它被一个点 (.) 字符分隔。第一个字符串是编码的头部,第二个字符串是编码的负载,最后一个字符串是 JWT 的签名。

下面的块是一个解码结构的示例:

// Decoded header
{
  "alg": "HS256",
  "typ": "JWT"
}
// Decoded Payload
{
  "sub": "1234567890",
  "name": "Seiji Villafranca",
  "iat": 1516239022
}
// Signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret-key
)

在前面的示例中,我们可以看到这三个部分都是 JSON 对象,头部包含用于签名 JWT 的算法,负载包含可以用来定义状态的信息,签名则编码了附加了密钥的头部和负载。

JWT 实现

我们已经了解了 JWT 的概念和使用;现在,我们将在 Spring Boot 项目中实现 JWT 生成。我们希望创建一个认证端点,当提交有效凭证时,将返回有效的 JWT。

第一步是将 JWT 依赖项添加到我们的 Spring Boot 项目中。

让我们在 pom.xml 中添加以下 XML 代码:

<dependency>
   <groupId>io.jsonwebtoken</groupId>
   <artifactId>jjwt-impl</artifactId>
   <version>0.11.2</version>
</dependency>
<dependency>
   <groupId>io.jsonwebtoken</groupId>
   <artifactId>jjwt-jackson</artifactId>
   <version>0.11.2</version>
</dependency>

接下来,我们需要在我们的项目包下创建一个名为 jwt 的包,创建完成后,再创建名为 controllersfiltersmodelsservicesutil 的包。我们将开始为我们的认证端点制作必要的模型。

创建认证模型

我们需要为认证创建三个模型。第一个模型是用于请求的,下一个是用于响应的,最后是一个包含用户信息和实现 Spring Security 的 UserDetails 的模型。

对于请求模型,在 models 包下创建一个名为 AuthenticationRequest 的新类。模型的实现如下所示:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthenticationRequest implements Serializable {
  private String email;
  private String password;
}

请求只需要电子邮件地址和密码,因为这些是我们需要验证的凭证。

然后,对于响应模型,创建一个名为 AuthenticationResponse 的新类;模型的实现如下所示:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthenticationResponse implements Serializable {
  private String token;
}

响应模型只包含令牌;在凭证验证后,JWT 被返回。

最后,对于用户主体模型,创建一个名为 UserPrincipal 的新类;模型的实现如下所示:

@AllArgsConstructor
public class UserPrincipal implements UserDetails {
  private final UserEntity userEntity;
  @Override
  public Collection<? extends GrantedAuthority>
    getAuthorities() {
    return null;
  }
  @Override
  public String getPassword() {
    return null;
  }
  @Override
  public String getUsername() {
    return this.userEntity.getEmail();
  }
  // Code removed for brevity. Please refer using the
  // GitHub repo.
  @Override
  public boolean isEnabled() {
    return false;
  }
}

使用主体模型实现 UserDetails,因为这将是我们用于 Spring Security 的自定义用户。我们已经覆盖了几个方法,例如 getAuthorities(),它检索用户的授权列表,isAccountNonLocked(),它检查用户是否被锁定,isAccountNonExpired(),它验证用户是否有效且尚未过期,以及 isEnabled(),它检查用户是否活跃。

创建认证工具

我们需要创建我们的认证工具;工具将负责 JWT 的创建、验证和过期检查,以及信息的提取。我们将使用以下方法来验证我们的令牌。

我们将在 util 包下创建一个名为 JwtUtil 的类,并使用 @Service 注解来标注它。让我们从 util 所需的方法开始。

让我们创建创建有效令牌所需的前两个方法:

private String createToken(Map<String, Object> claims, String subject) {
  Keys.
  return Jwts
    .builder()
    .setClaims(claims)
    .setSubject(subject)
    .setIssuedAt(new Date(System.currentTimeMillis()))
    .setExpiration(new Date(System.currentTimeMillis() +
                            1000 * 60 * 60 * 10))
          .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
    .compact();
}
public String generateToken(UserDetails userDetails) {
  Map<String, Object> claims = new HashMap<>();
  return createToken(claims, userDetails.getUsername());
}

上述实现调用了 JWT 扩展的几个方法:

  • builder() 方法负责 JWT 的构建。

  • setClaims() 方法用于设置 JWT 的声明。

  • setSubject() 方法用于设置主题;在这种情况下,该值是用户的电子邮件地址。

  • setIssuedAt() 方法用于设置 JWT 创建的日期。

  • setExpiration() 方法用于设置 JWT 的过期日期。

  • signWith() 方法使用提供的密钥和算法对 JWT 进行签名。

下一个我们需要实现的方法是声明提取。我们将主要使用此方法来获取有用的信息,例如主题和令牌的过期时间。

让我们看看以下代码实现:

public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
  final Claims claims = extractAllClaims(token);
  return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
  return Jwts
    .parserBuilder()
    .setSigningKey(SECRET_KEY)
     .build()
    .parseClaimsJws(token)
    .getBody();
}

extractAllClaims() 方法接收令牌并使用应用程序提供的密钥。我们已经调用了 parseClaimsJWS() 方法从 JWT 中提取声明。

现在,我们将创建提取和检查令牌是否过期以及使用我们创建的 extractClaims() 方法提取用户名的方法。

让我们看看以下代码实现:

public Date extractExpiration(String token) {
  return extractClaim(token, Claims::getExpiration);
}
private Boolean isTokenExpired(String token) {
  return extractExpiration(token).before(new Date());
}
public String extractUsername(String token) {
  return extractClaim(token, Claims::getSubject);
}

我们已经使用了 getExpirationgetSubject 内置函数从声明中获取过期日期和主题。

最后,我们将创建一个方法来验证令牌尚未过期或有效用户正在使用该令牌。

让我们看看以下代码实现:

public Boolean validateToken(String token,
                             UserDetails userDetails) {
  final String username = extractUsername(token);
  return (
    username.equals(userDetails.getUsername()) &&
      !isTokenExpired(token)
  );
}

创建认证服务

现在,我们将创建我们的认证服务,因为我们知道服务负责我们应用程序的逻辑。我们将创建以下方法,这些方法将使用哈希验证密码是否正确,检查用户是否有有效的凭据,并提供一个将覆盖默认认证的方法。

第一步是在服务包下创建一个名为ApplicationUserDetailsService的新类,我们将使用 Spring Security 中的UserDetailsService来实现这个类。我们将重写loadUserByUsername()方法并执行以下代码:

@Override
public UserDetails loadUserByUsername(String email)
  throws UsernameNotFoundException {
  return new UserPrincipal(
    userService.searchByEmail(email));
}

上一段代码调用了searchByEmail()方法,这是我们自定义的实现,用于检查用户是否存在,并且我们将返回一个UserPrincipal对象。

下一步是创建verifyPasswordHash()方法,用于验证用户的密码。

让我们看看以下代码实现:

private Boolean verifyPasswordHash(
  String password,
  byte[] storedHash,
  byte[] storedSalt
) throws NoSuchAlgorithmException {
  // Code removed for brevety. Please refer to the GitHub
  // repo
  for (int i = 0; i < computedHash.length; i++) {
    if (computedHash[i] != storedHash[i]) return false;
  }
  // The above for loop is the same as below
  return MessageDigest.isEqual(computedHash, storedHash);
}

我们创建的方法接受密码、存储的盐和用户的哈希。我们首先检查storedHash的长度是否为 64,storedSalt的大小是否为 128,以验证它是否为 64 字节。我们将使用存储的盐和密码的消息摘要来获取计算出的哈希,最后,我们将检查密码是否匹配,通过查看计算出的哈希和存储的哈希是否相等。

我们需要实现的最后一个方法是authenticate()方法。这是我们的认证端点将调用的主要方法。

让我们看看以下代码实现:

public UserEntity authenticate(String email, String password)
  throws NoSuchAlgorithmException {
  if (
    email.isEmpty() || password.isEmpty()
  ) throw new BadCredentialsException("Unauthorized");
  var userEntity = userService.searchByEmail(email);
  if (userEntity == null) throw new
      BadCredentialsException("Unauthorized");
  var verified = verifyPasswordHash(
    password,
    userEntity.getStoredHash(),
    userEntity.getStoredSalt()
  );
  if (!verified) throw new
      BadCredentialsException("Unauthorized");
  return userEntity;
}

该方法首先使用searchByEmail()方法检查用户是否存在,并使用我们创建的verifyPasswordHash()方法检查密码是否有效。

创建认证控制器

现在,我们将创建我们的认证控制器。这将创建我们的登录的主要端点。第一步是在控制器包下创建一个名为AuthenticateController的新类,接下来,我们将实现authenticate()方法如下:

@RestController
@AllArgsConstructor
class AuthenticateController {
  private final AuthenticationManager
    authenticationManager;
  private final JwtUtil jwtTokenUtil;
  private final ApplicationUserDetailsService
    userDetailsService;
  @RequestMapping(value = "/authenticate")
  @ResponseStatus(HttpStatus.CREATED)
  public AuthenticationResponse authenticate(
    @RequestBody AuthenticationRequest req
  ) throws Exception {
    UserEntity user;
    try {
      user = userDetailsService.authenticate(
        req.getEmail(), req.getPassword());
    } catch (BadCredentialsException e) {
      throw new Exception("Incorrect username or password",
                           e);
    }

然后,我们通过调用userDetailsService中的loadUserByUsername方法来获取用户详情,但不要忘记像这样传递用户的电子邮件地址:

    var userDetails = userDetailsService.loadUserByUsername(user.getEmail());
    System.out.println(userDetails);
    var jwt = jwtTokenUtil.generateToken(userDetails);
    return new AuthenticationResponse(jwt);
  }
}

authenticate()方法接受一个AuthenticationRequest体,它需要一个电子邮件地址和密码。我们将使用之前创建的service.authenticate()来检查凭据是否有效。一旦确认,我们可以使用我们的工具中的generateToken()生成令牌,并返回一个AuthenticationResponse对象。

创建认证过滤器

最后一步是我们需要完成的步骤是创建我们的认证过滤器。我们将使用过滤器来验证每个带有有效 JWT 的请求头部的 HTTP 请求。我们需要确保每个请求只调用一次过滤器。我们可以通过使用OncePerRequestFilter来实现这一点。我们将扩展我们的过滤器类,以确保过滤器只为特定请求执行一次。

现在,让我们创建我们的认证过滤器;首先,在filters包下创建一个名为JwtRequestFilter的类,然后我们将使用OncePerRequestFilter扩展这个类,然后我们将重写doFilterInternal()方法,该方法具有HttpServletRequestHttpServletResponseFilterChain参数。我们还将注入ApplicationUserDetailsServiceJwtUtil以进行凭证和令牌验证。

我们编写的代码将如下所示:

@AllArgsConstructor
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
  private final ApplicationUserDetailsService
    userDetailsService;
  private final JwtUtil jwtUtil;
  @Override
  protected void doFilterInternal(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain chain
  ) throws ServletException, IOException {
  }
}

现在,对于方法的实现,我们首先需要从请求头中提取 JWT。让我们实现以下代码:

//JWT Extraction
final String authorizationHeader =
  request.getHeader("Authorization");
    String username = null;
    String token = null;
    if (
      authorizationHeader != null &&
        authorizationHeader.startsWith("Bearer ")
    ) {
      token = authorizationHeader.substring(7);
      username = jwtUtil.extractUsername(token);
    }

上述代码从头部通过带有授权键的 JWT 中检索,当检索到令牌时,我们将提取用户名以检查用户是否存在。

然后,下一步是使用检索到的用户名加载用户详情,并检查令牌是否有效且尚未过期。如果令牌有效,我们将从用户详情和授权用户列表中创建一个UsernamePasswordAuthenticationToken

我们将在我们的安全上下文中设置新的已认证主体;让我们看一下以下代码实现:

//JWT Extraction section
// JWT Validation and Creating the new
// UsernamePasswordAuthenticationToken
if (
      username != null &&
      SecurityContextHolder.getContext()
        .getAuthentication() == null
    ) {
      UserDetails userDetails =
        this.userDetailsService
         .loadUserByUsername(username);
      if (jwtUtil.validateToken(token, userDetails)) {
        var usernamePasswordAuthenticationToken =
          new UsernamePasswordAuthenticationToken(
          userDetails,
          null,
          userDetails.getAuthorities()
        );
        usernamePasswordAuthenticationToken.setDetails(
          new WebAuthenticationDetailsSource()
            .buildDetails(request)
        );
        SecurityContextHolder
          .getContext()
          .setAuthentication(
            usernamePasswordAuthenticationToken);
      }
    }
    chain.doFilter(request, response);
  }

我们已经成功为我们的请求创建了一个过滤器,并且我们的认证端点都已经配置好了。我们唯一需要做的是完成我们的配置。我们希望修改UserDetailsService以使用我们自定义的认证。

为了实现这一点,我们将回到我们的SecurityConfig文件,并在我们的configure(AuthenticationManagerBuilder auth)方法上放置以下代码实现:

private final ApplicationUserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService);
}

下一步,我们需要添加我们创建的过滤器;在configure(HttpSecurity http)方法下,我们将放置以下代码:

private final JwtRequestFilter jwtFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
   …. Http security configurations
   http.addFilterBefore(jwtFilter,
     UsernamePasswordAuthenticationFilter.class);
}

现在我们已经完成了安全配置,我们的最后一步是为我们的反英雄端点添加认证。向反英雄端点发出请求时需要有效的 JWT。

为了实现这一点,我们将使用@PreAuthorize("isAuthenticated()")注解AntiHeroController来配置端点与认证过程:

@PreAuthorize("isAuthenticated()")
public class AntiHeroController {
… methods
}

我们已经在我们的应用程序中成功实现了 Spring Security 和 JWT;让我们模拟创建的端点。

我们将向反英雄控制器发送一个 HTTP GET请求以获取所有反英雄的列表:

图 7.2 – 获取反英雄列表时 403 禁止

图 7.2 – 获取反英雄列表时 403 禁止

当我们向其中一个反英雄发送一个示例请求时,现在这将返回一个 403 错误,因为它需要我们请求头中的有效令牌。在这种情况下,我们需要使用/register端点创建一个新的用户:

图 7.3 – 用户注册

图 7.3 – 用户注册

创建用户成功后,现在这是一个有效的凭证,我们可以使用/authenticate端点进行登录:

图 7.4 – 新凭证登录

图 7.4 – 新凭证登录

我们可以在先前的例子中看到,我们的登录是成功的,并且/authenticate端点返回了一个有效的令牌。我们现在可以使用这个令牌在请求头中发送请求到反英雄端点:

图 7.5 – 反英雄端点成功返回列表

图 7.5 – 反英雄端点成功返回列表

我们可以在先前的例子中看到,我们在授权头中使用了生成的令牌,并且收到了 200 响应并返回了反英雄列表。

我们现在已经成功地为我们的 Spring Boot 应用程序使用 Spring Security 创建了自定义的验证和授权。在下一节中,我们将讨论一个与安全相关的附加主题,称为 IDaaS。

IDaaS

在前一节中,我们使用 Spring Security 创建了自定义的登录验证。我们利用了 Spring Security 的一些功能,并使用 JWT 来存储用户状态和验证凭证。然而,这个例子不足以作为我们应用程序实现验证的可靠和安全的方式。

现今的大型和企业级应用程序需要几个安全特性来防止可能出现的漏洞。这些特性可能包括架构和其他服务的实现,如单点登录和多因素认证(MFA)。这些特性可能难以处理,可能需要几个迭代来修改,从而导致更长的开发时间。这就是 IDaaS 发挥作用的地方。

IDaaS 是一种交付模式,允许用户从云端连接、验证和使用身份管理服务。IDaaS 有助于加快开发过程,因为所有验证和授权过程都在幕后提供。

它通常被大型和企业级应用程序使用,因为它提供了优势和特性。IDaaS 系统利用云计算的力量来处理身份访问管理IAM),确保正确的用户访问资源。这对公司来说非常有帮助,因为它们不需要担心由于网络安全威胁的适应而变得非常繁重的安全和 IAM 责任。

IDaaS 类型

市场上有多种类型的 IDaaS 可用;一些提供商只为客户端提供目录,其他提供包括单点登录和多因素认证(MFA)在内的多套功能,但我们将 IDaaS 分为两个类别:

  • 基本 IDaaS

  • 企业 IDaaS

基本 IDaaS

小型和中等企业通常使用基本的 IDaaS。它通常提供单点登录和多因素认证(MFA)以及一个云目录来存储凭证。

基本的 IDaaS 提供商还包含一个更直观的界面,使用户能够处理配置和管理任务。

企业 IDaaS

与基本的 IDaaS 相比,企业 IDaaS 更为复杂,并被大型和企业级业务所使用。这通常用于扩展组织的 IAM 基础设施,并为 Web、移动和 API 环境提供访问管理。

一个 IDaaS 应该具备以下五个要求:

  • 单点登录(SSO):使用单一认证让用户能够访问所有平台和应用程序

  • 多因素认证(MFA):增加安全层,要求用户出示两份有效的证据来证明其身份

  • 云目录:提供一个云目录,其中可以存储数据和凭证

  • 访问安全:基于策略的应用程序管理,用于提高安全性

  • 配置:提供使用跨域身份管理SCIM)系统自动在应用程序和服务提供商之间交换用户身份的能力

这些就是 IDaaS 所需的五个特性。

如果您想知道可以使用哪些 IDaaS 的示例,以下是一些服务提供商:

  • 谷歌云身份:谷歌云身份为用户提供了一系列安全功能,以启用身份验证、访问管理和授权。它是一个具有多个安全功能的企业 IDaaS,例如 SSO、MFA、自动用户配置和上下文感知访问。

要了解更多关于谷歌云身份的信息,您可以访问cloud.google.com/identity.

  • Okta 工作身份:Okta 是市场上领先的 IDaaS 提供商之一。它也是一家企业级 IDaaS 提供商,拥有多个基本和高级功能,例如 SSO、MFA、通用目录、B2B 集成和 API 访问管理。

Okta 和 Auth0 于 2021 年联合起来,提供通用登录、无密码认证和机器到机器通信等身份平台和解决方案。

要了解更多关于 Auth0 和 Okta 的信息,您可以访问以下链接:auth0.com/www.okta.com/workforce-identity/.

  • Azure Active Directory:Azure Active Directory 也是一个企业级 IDaaS 解决方案,与其他提供商相同。它提供了一系列安全解决方案,具有多个功能,如身份治理、统一身份管理和无密码认证,并且最好的是有一个免费使用的入门级服务。

要了解更多关于 Azure Active Directory 的信息,您可以访问azure.microsoft.com/en-us/services/active-directory/.

摘要

至此,我们已经到达了本章的结尾。让我们回顾一下你所学到的宝贵知识。你学习了 CORS 的概念和重要性,以及它是如何为访问资源提供安全性的。我们讨论了在 Spring Boot 应用中实现 CORS 的不同方法,这些方法包括在方法级别、控制器级别,以及两者的结合方式。

我们还学习了 Spring Security 的概念和特性,并讨论了在我们应用中实现自定义认证和授权的实现方法。最后,我们还了解了 IDaaS,这是一种允许用户从云端连接、认证和使用身份管理服务的交付模式。

在下一章中,我们将学习如何将事件记录器集成到我们的 Spring Boot 应用中。

第八章:Spring Boot 中的日志事件

在上一章中,我们讨论了在确保我们的应用程序安全方面跨源资源共享CORS)的想法、功能和实现。我们还学习了JSON Web TokensJWTs)以及如何通过创建认证端点来生成一个。

本章将重点介绍在 Spring Boot 应用程序中记录事件。我们将讨论用于记录和配置 Spring Boot 的流行包,它们在哪里保存,以及我们在开发应用程序时如何处理日志。

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

  • 开始使用 SLF4J 和 Log4j2

  • 设置 SLF4J 和 Log4j2

  • 使用日志

技术要求

本章代码的最终版本可以在以下链接中查看:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-08/superheroes

开始使用 SLF4J 和 Log4j2

日志记录被认为是开发应用程序最重要的方面之一。其重要性往往被低估,更糟糕的是,我们忘记将其应用于我们的应用程序。

事件日志在大多数技术行业中都有应用,尤其是在提供企业应用程序的行业中。它按照既定标准应用,以防止复杂的调试,并使我们更容易理解所阅读的代码。一个编写良好且结构化的日志可以造福开发者,尤其是在维护或调试其他开发者的代码时。而不是全面搜索错误,记录将加速调试过程,提供有关错误发生的位置和原因以及我们的应用程序中发生的情况的信息。

随着语言和框架的改进,日志记录也得到了发展;在后端开发中,已经开发了几种日志框架,以提供更灵活的日志功能。我们将讨论的一些示例框架是 Java Spring Boot 的 SLF4J 和 Log4j2。在继续之前,让我们讨论日志框架的功能。

日志框架的功能

日志框架为我们提供了以下三个功能,以便在我们的应用程序中显示和捕获事件:

  • 日志记录器:获取消息和元数据

  • 格式化器:格式化从日志记录器检索到的消息

  • 处理器:负责在调试控制台打印消息或将它们插入数据库供开发者使用

日志框架还会以不同的严重程度显示消息,使开发者能够快速识别发生了哪个事件。日志框架中的严重程度如下:

  • FATAL:级别 1;被认为是一个可能导致应用程序暂停或终止的关键问题

  • ERROR:应用程序中的运行时错误

  • WARNING:通常显示已弃用 API 的日志

  • INFO:显示应用程序运行时事件的日志

  • DEBUG:显示应用程序流程信息的日志

  • TRACE:显示应用程序流程的更详细信息

使用 SLF4J 进行日志记录

在 Java 中,与 Java 一起使用的流行日志框架之一是java.util.logging包,或 Java 自己的日志引擎 JUL,仅使用单个依赖项。这意味着我们可以根据需要从一种日志框架切换到另一种日志框架。

使用 SLF4J 有以下几个优点:

  • SLF4J 使我们能够在运行时或部署时从一种框架切换到另一种框架。

  • SLF4J 有一个迁移工具,允许我们将使用 Java 类库的现有项目从 Log4j 迁移到 SLF4J。

  • SLF4J 支持参数化日志记录消息,以绑定日志的动态值。

  • SLF4J 将应用程序与日志框架解耦。在开发我们的应用程序时,我们不需要担心使用的是哪种日志框架。

SLF4J 的方法和类

SLF4J 提供了几个类和方法来显示具有严重级别的消息、分析执行时间,或者简单地返回日志记录器的实例。让我们讨论提供的方法和类。

日志记录器接口

日志记录器接口主要用于显示带有严重级别的消息或日志。这也是 SLF4J API 的入口点。

  • void debug(String message): 在DEBUG级别记录消息

  • void error(String message): 在ERROR级别记录消息

  • void info(String message): 在INFO级别记录消息

  • void trace(String message): 在TRACE级别记录消息

  • void warn(String message): 在WARN级别记录消息

LoggerFactory 类

LoggerFactory类是 SLF4J 的实用类,通常用于使用 JUL 和 Log4j 等框架创建日志记录器。

Logger getLogger(String name) 生成具有指定名称的日志记录器对象。以下示例使用了getLogger()方法:

private static final org.SLF4J.Logger log = org.SLF4J.LoggerFactory.getLogger(LogClass.class);

Profiler 类

Profiler类主要用于识别我们应用程序中特定任务的执行时间,也称为穷人的剖析器

可以使用多种方法:

  • void start(string name) 创建一个新的具有特定名称的子级计时器,并停止之前创建的计时器。

  • TimeInstrument stop() 停止最近的子级和全局计时器,并将返回当前执行时间。

  • void log() 使用日志记录器记录当前时间仪器的详细信息。

  • void print() 打印当前时间仪器的详细信息。

SLF4J 的功能

SLF4J 具有几个使日志在调试中更有用的功能。它提供了对参数化日志记录的支持,允许我们在消息中显示动态值。另一个功能是分析,它通常用于测量不同属性,如应用程序中特定任务的内存和执行时间。

让我们讨论每个功能的理念和实现。

参数化日志记录

要在 SLF4J 中实现参数化日志记录,我们将在消息中包含占位符 {},其中我们想要传递值。

让我们看看以下示例:

public class LoggerExample {
   public static void main(String[] args) {
      //Creating the Logger object
      Logger logger =
        LoggerFactory.getLogger(LoggerExample.class);
      String name = "seiji";
      //Logger using info level
      logger.info("Hello {}, here is your log", name);
   }

在前面的示例中,我们在消息中创建了一个参数来显示 name 变量的值。一旦我们执行应用程序,输出将如下所示:

INFO: Hello seiji, here is your log

参数化日志记录还支持消息中的多个参数,如下例所示:

public class LoggerExample {
   public static void main(String[] args) {
      //Creating the Logger object
      Logger logger =
      LoggerFactory.getLogger(LoggerExample.class);
      Integer x = 3;
      Integer y = 5;
      //Logging the information
      logger.info("The two numbers are {} and {}", x, y);
      logger.info("The sum of the two number is" + (x +
                   y));
   }

在前面的示例中,我们可以在单个日志中显示 xy 变量。我们还可以在我们的消息中直接执行操作。输出将如下所示:

INFO: The two numbers are 3 and 5
INFO: The sum of the two numbers is 8

性能分析

SLF4J 还提供了性能分析功能,用于测量应用程序中特定任务的内存使用、使用情况和执行时间。性能分析功能可以通过名为 Profiler 的类使用。

要在我们的代码中实现性能分析器,我们必须执行以下步骤:

  1. 使用特定名称的 Profiler。一旦我们这样做,请记住我们已经启动了一个全局计时器。以下示例展示了如何创建一个新的 Profiler

    Profiler profiler = new Profiler("ExampleProfiler");
    
  2. start() 方法。请记住,启动子计时器将终止其他正在运行的计时器。以下示例展示了如何启动计时器:

    profiler.start("Example1");
    
    class.methodExample();
    
  3. stop() 方法用于停止运行中的计时器和全局计时器。这将同时返回时间仪器:

    TimeInstrument tm = profiler.stop();
    
  4. print() 方法用于显示时间仪器的内容和信息。

现在我们已经了解了 SLF4J 的概念、功能和优势,我们将讨论一个名为 Log4j2 的框架。

使用 Log4j2 进行日志记录

Log4j2 是与 Java 一起使用的最常用的日志框架之一。由于 SLF4J 是日志框架的抽象,因此 Log4j2 可以与 SLF4J 一起使用。Log4j2 非常灵活,提供了不同的方式来存储日志信息以进行调试;它还支持异步日志记录,并以严重级别显示日志,以便快速识别消息的重要性。

让我们讨论 Log4j2 的以下功能:

  • Log4j2 日志记录器

  • Log4j2 Appenders

  • Log4j2 布局

  • Log4j2 标记

  • Log4j2 过滤器

Log4j2 日志记录器

LogRecord 实例。这意味着记录器负责分发消息。要创建 Log4j2 日志记录器,我们只需要以下代码:

Logger log = LogManager.getLogger(ExampleClass.class);

在创建新的 Logger 之后,我们现在可以使用它来调用几个方法,例如 info() 来分发消息。

Log4j2 Appenders

Appenders 负责放置由 Logger 分发的日志。在 Log4j2 中,有多种 Appenders 帮助我们决定将日志存储在哪里。

这里是 Log4j2 可用的某些 Appenders:

  • ConsoleAppender:将日志写入控制台(System.outSystem.err)。这是默认的 Appender。

  • FileAppender:使用 FileManager 将日志写入文件。

  • JDBCAppender:使用 JDBC 驱动程序将日志写入数据库。

  • HTTPAppender:将日志写入特定的 HTTP 端点。

  • KafkaAppender:将日志写入 Apache Kafka。

  • AsyncAppender: 封装另一个 Appender 并使用另一个线程来写入日志,使其异步日志记录。

  • SyslogAppender: 将日志写入 syslog 目的地。

您可以访问以下链接中的 Log4j2 文档,了解其他可用的 Appenders:https://logging.apache.org/log4j/2.x/manual/appenders.html。

Log4j2 布局

Appender 使用Layouts来格式化 LogEvent 的输出。Log4j2 有多个布局可供选择来格式化我们的日志:

  • %d{HH:mm: ss} %msg%n; 模式将给出以下结果:

    14:25:30 Example log message
    
  • CSV: 使用 CSV 格式生成日志的布局。

  • HTML: 用于生成 HTML 格式日志的布局。

  • JSON: 用于生成 JSON 格式日志的布局。

  • XML: 用于生成 XML 格式日志的布局。

  • YAML: 用于生成 YML 格式日志的布局。

  • Syslog: 用于生成与 syslog 兼容格式的日志的布局。

  • Serialized: 使用 Java 序列化将日志序列化为字节数组。

Log4j2 标记

IMPORTANT标记,可以指示 Appender 需要将日志存储在不同的目的地。

让我们看看如何创建和使用标记的示例:

public class Log4j2Marker {
    private static Logger LOGGER =
      LoggerFactory.getLogger(Log4j2Marker.class);
    private static final Marker IMPORTANT =
      MarkerFactory.getMarker("IMPORTANT");
    public static void main(String[] args) {
        LOGGER.info("Message without a marker");
        LOGGER.info(IMPORTANT,"Message with marker"
    }
}

在前面的示例中,我们可以使用MarkerFactory.getLogger()方法创建一个新的标记。要使用新的标记,我们可以将其应用于特定的记录器,以指示对重要事件的特定操作。

Log4j2 过滤器

Log4j2 过滤器是用于显示记录器的另一个非常有价值的功能。这使我们能够根据给定的标准控制我们想要说或发布的日志事件。在执行过滤器时,我们可以将其设置为ACCEPTDENYNEUTRAL值。以下是我们可以使用来显示记录器的过滤器:

  • Threshold: 使用严重级别对日志事件应用过滤

  • Time: 对给定时间范围内的日志事件应用过滤

  • Regex: 根据给定的正则表达式模式过滤日志事件

  • Marker: 根据给定的标记过滤日志事件

  • Composite: 提供了一种机制来组合多个过滤器

  • Dynamic Threshold: 使用严重级别和包括附加属性对日志事件应用过滤

在下一节中,我们将配置我们项目中的日志框架。

设置 SLF4J 和 Log4j2

我们现在将在我们的 Spring Boot 应用程序中实现几个日志框架,包括LogbackLog4j2,请记住 SLF4J 已经包含在内。

配置 Logback

创建我们的 Spring Boot 应用程序后,spring-boot-starter-logging依赖项已经包含在内。我们需要采取的第一步是创建我们的 Logback 配置文件。

在我们的项目中,在resources文件夹下,我们将添加logback-spring.xml文件。这是我们放置 Logback 配置的地方。以下是一个示例配置:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <property name="LOGS" value="./logs" />
    <!—Please refer to the logback-spring.xml of
       the GitHub repo. Thank you. -->
    <!-- LOG everything at INFO level -->
    <root level="info">
        <appender-ref ref="RollingFile" />
        <appender-ref ref="Console" />
    </root>
    <logger name="com.example" level="trace"
     additivity="false">
        <appender-ref ref="RollingFile" />
        <appender-ref ref="Console" />
    </logger>
</configuration>

在前面的 XML 中,定义了几个配置来格式化我们的日志事件。我们创建了两个 Appender – ConsoleRollingFile。配置这两个 Appender 标签将在 System.out 和文件输出中创建日志。

我们还使用了一种修改日志显示外观和格式的模式。在这个例子中,我们使用了%black(%d{ISO8601}) %highlight(%-5level) [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable模式来在System.Out中显示日志。它以黑色显示日期,以高亮显示严重级别,以蓝色显示线程名称,以黄色显示类名称,并将消息分配给日志。

在成功配置 Logback 之后,我们可以运行应用程序并看到控制台中的日志:

图 8.1 – 使用 Logback 的日志事件

图 8.1 – 使用 Logback 的日志事件

我们现在将使用 Log4j2 框架来处理我们的日志。

配置 Log4j2

我们还可以在我们的应用程序中使用不同的框架来处理日志事件。在这个例子中,我们将使用 Log4j2 来处理我们的日志:

  1. 第一步是将Log4j2依赖项添加到我们的pom.xml文件中。为此,我们将添加以下代码:

    <dependency>
    
       <groupId>org.springframework.boot</groupId>
    
       <artifactId>spring-boot-starter-Log4j2</artifactId>
    
    </dependency>
    
  2. 在成功添加依赖项后,我们必须在我们的 Spring Boot 应用程序中排除spring-boot-starter-logging依赖项,这样我们就可以覆盖 Logback 并使用 Log4j2 框架来处理日志事件。

要做到这一点,我们必须向org.springframework.boot组下的依赖项中添加以下 XML 代码:

<exclusions>
   <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>
        spring-boot-starter-logging</artifactId>
   </exclusion>
</exclusions>
  1. 在包含Log4j2依赖项后,我们将向resources文件夹中添加一个名为log4j2-spring.xml的文件,并添加以下 XML 配置:

    <?xml version="1.0" encoding="UTF-8"?>
    
    <Configuration>
    
        <!—Please refer to the log4j2-spring.xml  of the
    
           GitHub repo. -->
    
        <Loggers>
    
            <Root level="info">
    
                <AppenderRef ref="Console" />
    
                <AppenderRef ref="RollingFile" />
    
            </Root>
    
            <Logger name="com.example"
    
              level="trace"></Logger>
    
        </Loggers>
    
    </Configuration>
    

上述配置几乎与使用 Logback 实现的配置相同。我们还创建了两个 Appender – ConsoleRollingFile;唯一的显著区别是日志事件的模式。我们现在已成功配置 Log4j2。当我们运行应用程序时,我们将看到以下日志输出:

图 8.2 – 使用 Log4j2 的日志事件

图 8.2 – 使用 Log4j2 的日志事件

使用 Log4j2 框架配置和修改我们的日志配置后,我们现在将使用它来将日志添加到我们的代码中。

使用日志

我们现在可以使用我们在 Spring Boot 应用程序中配置的日志框架来定义代码不同部分的日志。为此,我们必须首先创建一个新的日志记录器实例。

例如,当用户尝试获取所有反英雄的列表时创建一个日志。在AntiHeroeController中,我们将添加以下代码来创建一个新的日志记录器实例:

private static final Logger LOGGER = LoggerFactory.getLogger(AntiHeroController.class);

我们还必须意识到LoggerFactoryLogger应该在 SLF4J 依赖项下。始终建议使用SLF4J,因为这是对日志框架的抽象,使得在它们之间切换更加容易。

在这种情况下,我们的导入应该是这样的:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

一旦我们创建了一个新的日志记录器实例,现在我们就可以在我们的方法中使用它了,例如,如果我们想在用户尝试获取反英雄列表时显示日志。

为了实现这一点,在 getAntiHeroes() 方法下,我们将添加以下代码:

public List<AntiHeroDto> getAntiHeroes(Pageable pageable) {
    int toSkip = pageable.getPageSize() *
                 pageable.getPageNumber();
    //SLF4J
    LOGGER.info("Using SLF4J: Getting anti hero
                list - getAntiHeroes()");
    // Mapstruct is another dto mapper, but it's not
    // straightforward
    var antiHeroList = StreamSupport
              .stream(
                service.findAllAntiHeroes().spliterator(),
                false)
            .skip(toSkip).limit(pageable.getPageSize())
            .collect(Collectors.toList());
    return antiHeroList
            .stream()
            .map(this::convertToDto)
            .collect(Collectors.toList());
}

在前面的示例中,我们已经调用了 info(String message)。每次用户调用获取反英雄端点时,都会显示日志。我们还可以调用以下方法:

  • trace():在 TRACE 级别显示日志事件

  • debug(): 在 DEBUG 级别显示日志事件

  • warn(): 在 WARN 级别显示日志事件

  • error(): 在 ERROR 级别显示日志事件

  • getName(): 获取日志记录器的名称

  • isInfoEnabled(): 检查日志记录器是否在 INFO 级别启用

  • isDebugEnabled(): 检查日志记录器是否在 DEBUG 级别启用

  • isWarnEnabled(): 检查日志记录器是否在 WARN 级别启用

  • isErrorEnabled(): 检查日志记录器是否在 ERROR 级别启用

Lombok 中的注解

现在我们来看看 Lombok,我们 Spring Boot 应用程序中的一个库,如何帮助我们。Lombok 可以通过使用注解简化我们的代码,但它还提供了 SLF4J 和 Log4j2 的注解,如下所示:

  • @log4j2:这个注解将在我们的类中生成一个新的 Log4j2 实例。以下示例代码将被生成:

    public class LogExample {
    
           private static final org.SLF4J.Logger log =
    
             org.SLF4J.LoggerFactory.getLogger(
    
              LogExample.class);
    
       }
    
  • @slf4j:这个注解将在我们的类中生成一个新的 SLF4J 实例。以下示例代码将被生成:

    public class LogExample {
    
      private static final org.SLF4J.Logger log =
    
         org.SLF4J.LoggerFactory.getLogger(
    
           LogExample.class);
    
       }
    
  • 建议使用 slf4j 注解,因为它允许切换日志框架。

一旦我们在类中使用了注解,我们就不需要创建新的实例,我们可以在方法中直接使用日志:

//LOMBOK SLF4J
log.info("Using SLF4J Lombok: Getting anti-hero list - getAntiHeroes()");

摘要

本章已解释了日志记录器的概念和重要性,以及它们如何帮助开发者进行调试和维护应用程序。它介绍了 Log4j2,这是一个为 Spring Boot 提供多个功能的第三方框架,例如 AppendersFiltersMarkers,这些可以帮助开发者对日志事件进行分类和格式化。它还介绍了 SLF4J 的概念,SLF4J 是日志框架的抽象,允许我们在运行时或部署期间在不同的框架之间切换。

在下一章中,我们将学习如何在我们的 Spring Boot 应用程序中实现单元测试的概念和集成。

第九章:在 Spring Boot 中编写测试

在上一章中,你已经了解了日志记录器的重要性、它们的概念以及它们如何帮助开发者调试和维护应用程序。你已经学习了 Log4j2,这是一个为 Spring Boot 提供的第三方框架,它提供了诸如AppendersFiltersMarkers等特性,可以帮助开发者对日志事件进行分类和格式化。我们还讨论了 SLF4J,它是对日志框架的抽象,允许我们在运行时或部署期间在多个框架之间切换,最后,我们使用 XML 配置和 Lombok 实现了并配置了日志框架。

本章将现在专注于为我们的 Spring Boot 应用程序编写单元测试;我们将讨论最常用的 Java 测试框架 JUnit 和AssertJ,并在我们的应用程序中实现它们。我们还将集成 Mockito 到我们的单元测试中,以进行对象和服务模拟。

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

  • 理解 JUnit 和 AssertJ

  • 编写测试

  • 在服务中使用 Mockito 编写测试

技术要求

本章完成版本的链接在此:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-09

理解 JUnit 和 AssertJ

在每个应用程序开发之后,测试总是下一步,这是在将我们的应用程序交付或部署到生产环境之前最重要的任务之一。测试阶段对公司来说至关重要,因为这确保了他们产品的质量和有效性。

由于这是基本流程之一,测试过程中应该几乎没有错误空间,而手动测试是不够的,因为手动测试容易出错,并且有更大的机会错过应用程序中存在的问题。这就是单元测试发挥作用的地方——单元测试是自动化测试,允许开发者为单个类或实体编写测试。

这是一种回归测试的形式,它会运行所有测试以验证在应用代码经过几次更改或更新后,代码是否仍然通过测试用例。单元测试有助于保持我们应用程序的质量,因为它们带来了以下好处:

  • 速度:与手动测试相比,单元测试将节省更多时间,因为这是可编程的,并且将在短时间内提供结果。

  • 成本降低:单元测试是自动化的,这意味着将需要更少的测试人员来测试应用程序。

  • 错误减少:单元测试将显著减少犯错误的数量,因为测试不是由人类手动完成的。

  • 可编程性:单元测试可以生成复杂的测试,以检测应用程序中的隐藏信息。

单元测试现在在前后端开发中都广泛使用,尤其是在 Java 中,因为它们的优点和测试。Java 中已经存在几个测试框架,但我们将讨论第一个也是最常用的框架,JUnit

JUnit 框架

JUnit 是一个回归测试框架,主要用于为 Java 应用程序中的单个类编写测试和断言;它提倡“先测试后编码”的理念,即我们需要在实现之前为要测试的代码创建测试数据。JUnit 也是一个开源框架,这使得它更加可靠。

有一个庞大的社区支持此框架,它使用断言来测试预期结果,并使用注解来识别测试方法,它可以有效地利用并集成到 Maven 和 Gradle 项目中。

让我们讨论我们将用于编写测试的 JUnit 特性:

  • setUp(): 此方法在每次测试被调用之前执行。

  • tearDown(): 此方法在每次测试被调用之后执行:

    public class JavaTest extends TestCase {
    
       protected int value1, value2;
    
       // will run before testSubtract and testMultiply
    
       protected void setUp(){
    
          value1 = 23;
    
          value2 = 10;
    
       }
    
       public void testSubtract(){
    
          double result = value1 - value2;
    
          assertTrue(result == 13);
    
       }
    
       public void testMultiply(){
    
          double result = value1 * value2;
    
          assertTrue(result == 230);
    
       }}
    

在前面的代码示例中,我们可以看到定义了两个测试方法,分别是testSubtract()testMultiply(),在每个方法被调用之前。setUp()设置将首先被调用,以分配value1value2变量的值。

  • @RunWith@Suite注解用于运行测试。让我们看看以下示例:

    //JUnit Suite Test
    
    @RunWith(Suite.class)
    
    @Suite.SuiteClasses({
    
       TestOne.class, TestTwo.class
    
    });
    
    public class JunitTestSuite {
    
    }
    
    public class TestOne {
    
       int x = 1;
    
       int y = 2;
    
       @Test
    
       public void TestOne() {
    
          assertEquals(x + y, 3);
    
       }
    
    }
    
    public class TestTwo {
    
       int x = 1;
    
       int y = 2;
    
       @Test
    
       public void TestTwo() {
    
          assertEquals(y - x, 1);
    
       }
    
    }
    

在前面的代码示例中,我们可以看到我们定义了两个类,其中一个带有@Test注解的方法;测试方法将一起执行,因为我们已经使用@Suite.SuiteClasses方法将它们捆绑在一起。

  • runClasses()方法用于在特定类中运行测试用例。让我们看看以下基本示例:

    public class JUnitTestRunner {
    
       public static void main(String[] args) {
    
          Result result =
    
            JUnitCore.runClasses(TestJunit.class);
    
          for (Failure failure : result.getFailures()) {
    
             System.out.println(failure.toString());
    
          }
    
          System.out.println(result.wasSuccessful());
    
       }
    
    }
    
  • :JUnit 类主要用于编写我们应用程序的测试;这些包括以下内容:

    • 断言包括断言方法集

    • 测试用例包括包含运行多个测试的设置的测试用例

    • 测试结果包括收集执行测试用例的所有结果的方法

JUnit 中的断言

Assert类,以及一些来自 Assert 的基本方法如下:

  • void assertTrue(boolean condition): 验证条件是否为true

  • void assertFalse(boolean condition): 验证条件是否为false

  • void assertNotNull(Object obj): 检查对象是否不为 null

  • void assertNull(Object obj): 检查对象是否为 null

  • void assertEquals(Object obj1, Object obj2): 检查两个对象或原始数据是否相等

  • void assertArrayEquals(Array array1, Array array2): 验证两个数组是否彼此相等

注解

注解是元标签,我们将其添加到方法和类中;这为 JUnit 提供了额外信息,说明哪些方法应该在测试方法之前和之后运行,哪些将在测试执行期间被忽略。

这里是 JUnit 中我们可以使用的注解:

  • @Test: 该注解用于一个public void方法,表示该方法是一个可以执行的测试用例。

  • @Ignore: 该注解用于忽略未执行的测试用例。

  • @Before: 该注解用于一个public void方法,在每次测试用例方法之前运行该方法。如果我们想声明所有测试用例都使用的类似对象,这通常会被使用。

  • @After: 该注解用于一个public void方法,在每次测试用例方法之后运行该方法;如果我们想在运行新的测试用例之前释放或清理多个资源,这通常会被使用。

  • @BeforeClass: 该注解允许一个public static void方法在所有测试用例执行之前运行一次。

  • @AfterClass: 该注解允许一个public static void方法在所有测试用例执行之后运行一次。

让我们通过一个带有注解及其执行顺序的示例测试来看看:

public class JunitAnnotationSequence {
   //execute once before all test
   @BeforeClass
   public static void beforeClass() {
      System.out.println("beforeClass()");
   }
   //execute once after all test
   @AfterClass
   public static void  afterClass() {
      System.out.println("afterClass()");
   }
   //execute before each test
   @Before
   public void before() {
      System.out.println("before()");
   }
   //execute after each test
   @After
   public void after() {
      System.out.println("after()");
   }
   @Test
   public void testMethod1() {
      System.out.println("testMethod1()");
   }
   @Test
   public void testMethod2() {
      System.out.println("testMethod2();");
   }
}

在前面的代码示例中,我们有一个JunitAnnotationSequence类,它有几个注解方法。当我们执行测试时,我们将得到以下输出:

beforeClass()
before()
testMethod1()
after()
before()
testMethod2()
after()
afterClass()

在前面的示例中,我们可以看到,使用@BeforeClass@AfterClass注解的方法只调用一次,它们在测试执行的开始和结束时被调用。另一方面,使用@Before@After注解的方法在每个测试方法的开始和结束时被调用。

我们已经学习了单元测试中 JUnit 的基础知识;现在,让我们讨论 AssertJ 的概念。

使用 AssertJ

在上一部分,我们刚刚探讨了 JUnit 的概念和功能,我们了解到,仅使用 JUnit,我们可以通过Assert类应用断言,但通过使用 AssertJ,我们可以使断言更加流畅和灵活。AssertJ是一个主要用于编写断言的库;它的主要目标是提高测试代码的可读性,并简化测试的维护。

让我们比较一下在 JUnit 和 AssertJ 中如何编写断言:

  • JUnit 检查条件是否返回true

    Assert.assertTrue(condition)
    
  • AssertJ 检查条件是否返回true

    Assertions.assertThat(condition).isTrue()
    

在前面的示例中,我们可以看到,在 AssertJ 中,我们将在assertThat()方法中始终传递要比较的对象,然后调用下一个方法,即实际的断言。让我们看看在 AssertJ 中我们可以使用哪些不同类型的断言。

布尔断言

truefalse。断言方法如下:

  • isTrue(): 检查条件是否为true

    Assertions.assertThat(4 > 3).isTrue()
    
  • isFalse(): 检查条件是否为false

    Assertions.assertThat(11 > 100).isFalse()
    

字符断言

字符断言用于将对象与字符进行比较或检查字符是否在 Unicode 表中;断言方法如下:

  • isLowerCase(): 检查给定的字符是否为小写:

    Assertions.assertThat('a').isLowerCase();
    
  • isUpperCase(): 检查字符是否为大写:

    Assertions.assertThat('a').isUpperCase();
    
  • isEqualTo(): 检查两个给定的字符是否相等:

    Assertions.assertThat('a').isEqualTo('a');
    
  • isNotEqualTo(): 检查两个给定的字符是否不相等:

    Assertions.assertThat('a').isEqualTo('b');
    
  • inUnicode(): 检查字符是否包含在 Unicode 表中:

    Assertions.assertThat('a').inUniCode();
    

这些只是AbstractCharacterAssert下可用的一些断言。对于完整的文档,您可以访问joel-costigliola.github.io/assertj/core-8/api/org/assertj/core/api/AbstractCharacterAssert.html.

类断言

类断言用于检查特定类的字段、类型、访问修饰符和注解。以下是一些类断言方法:

  • isNotInterface(): 验证该类不是接口:

    Interface Hero {}
    
    class Thor implements Hero {}
    
    Assertions.assertThat(Thor.class).isNotInterface()
    
  • isInterface(): 验证该类是接口:

    Interface Hero {}
    
    class Thor implements Hero {}
    
    Assertions.assertThat(Hero.class).isInterface()
    
  • isPublic(): 验证该类是公开的:

    public class Hero {}
    
    protected class AntiHero {}
    
    Assertions.assertThat(Hero.class).isPublic()
    
  • isNotPublic(): 验证该类不是公开的:

    public class Hero {}
    
    protected class AntiHero {}
    
    Assertions.assertThat(Hero.class).isNotPublic()
    

这些只是AbstractClassAssert下可用的一些断言。对于完整的文档,您可以访问joel-costigliola.github.io/assertj/core-8/api/org/assertj/core/api/AbstractClassAssert.html.

迭代器断言

迭代器断言用于根据其长度和内容验证迭代器或数组对象。以下是一些迭代器断言方法:

  • contains(): 展示迭代器包含指定的值:

    List test = List.asList("Thor", "Hulk",
    
                            "Dr. Strange");
    
    assertThat(test).contains("Thor");
    
  • isEmpty(): 验证给定的迭代器长度是否大于0

    List test = new List();
    
    assertThat(test).isEmpty();
    
  • isNotEmpty(): 验证给定的迭代器长度是否为0

    List test = List.asList("Thor", "Hulk",
    
                            "Dr. Strange");
    
    assertThat(test).isNotEmpty ();
    
  • hasSize(): 验证迭代器的长度是否等于指定的值:

    List test = List.asList("Thor", "Hulk",
    
                            "Dr. Strange");
    
    assertThat(test).hasSize(3);
    

这些只是AbstractIterableAssert下可用的一些断言。对于完整的文档,您可以访问此处提供的链接:joel-costigliola.github.io/assertj/core-8/api/org/assertj/core/api/AbstractIterableAssert.html.

文件断言

文件断言用于验证文件是否存在、是否可写或可读,并验证其内容。以下是一些文件断言方法:

  • exists(): 证明文件或目录存在:

    File file = File.createTempFile("test", "txt");
    
    assertThat(tmpFile).exists();
    
  • isFile(): 验证给定的对象是否是文件(提供目录将导致测试失败):

    File file = File.createTempFile("test", "txt");
    
    assertThat(tmpFile).isFile();
    
  • canRead(): 验证给定的文件是否可由应用程序读取:

    File file = File.createTempFile("test", "txt");
    
    assertThat(tmpFile).canRead();
    
  • canWrite(): 验证给定的文件是否可由应用程序修改:

    File file = File.createTempFile("test", "txt");
    
    assertThat(tmpFile).canWrite();
    

这些只是AbstractFileAssert下可用的一些断言。对于完整的文档,您可以访问此处提供的链接:joel-costigliola.github.io/assertj/core-8/api/org/assertj/core/api/AbstractFileAssert.html.

映射断言

映射断言用于根据其条目、键和大小检查映射。以下是一些映射断言方法:

  • contains(): 验证映射是否包含给定的条目:

    Map<name, Hero> heroes = new HashMap<>();
    
    Heroes.put(stark, iron_man);
    
    Heroes.put(rogers, captain_america);
    
    Heroes.put(parker, spider_man);
    
    assertThat(heroes).contains(entry(stark, iron_man),
    
      entry(rogers, captain_america));
    
  • containsAnyOf(): 验证映射是否至少包含一个条目:

    Map<name, Hero> heroes = new HashMap<>();
    
    Heroes.put(stark, iron_man);
    
    Heroes.put(rogers, captain_america);
    
    Heroes.put(parker, spider_man);
    
    assertThat(heroes).contains(entry(stark, iron_man), entry(odinson, thor));
    
  • hasSize(): 验证映射的大小是否等于给定的值:

    Map<name, Hero> heroes = new HashMap<>();
    
    Heroes.put(stark, iron_man);
    
    Heroes.put(rogers, captain_america);
    
    Heroes.put(parker, spider_man);
    
    assertThat(heroes).hasSize(3);
    
  • isEmpty(): 验证给定的映射是否为空:

    Map<name, Hero> heroes = new HashMap<>();
    
    assertThat(heroes).isEmpty();
    
  • isNotEmpty(): 验证给定的映射不为空:

    Map<name, Hero> heroes = new HashMap<>();
    
    Heroes.put(stark, iron_man);
    
    Heroes.put(rogers, captain_america);
    
    Heroes.put(parker, spider_man);
    
    assertThat(heroes).isNotEmpty();
    

这些只是AbstractMapAssert下可用的一些断言。对于完整的文档,你可以访问这里提供的链接:joel-costigliola.github.io/assertj/core-8/api/org/assertj/core/api/AbstractMapAssert.html

我们已经学习了使用 AssertJ 的不同断言方法;现在,我们将在 Spring Boot 应用程序中实现并编写我们的单元测试。

编写测试

在本节中,我们现在将开始在 Spring Boot 应用程序中编写我们的单元测试。当我们回到我们的应用程序时,服务仓库是我们应用程序的基本部分,我们需要在这里实现单元测试,因为服务包含业务逻辑并且经常被修改,尤其是在添加新功能时。仓库包括 CRUD 和其他操作的方法。

我们将在编写单元测试时采用两种方法。第一种方法是使用内存数据库,如 H2,在运行单元测试时存储我们创建的数据。第二种方法是使用 Mockito 框架来模拟我们的对象和仓库。

使用 H2 数据库进行测试

我们将在编写测试时实施的第一个方法是使用 JUnit 和 AssertJ 与 H2 数据库。H2 数据库是一个内存数据库,允许我们在系统内存中存储数据。一旦应用程序关闭,它将删除所有存储的数据。H2 通常用于概念验证或单元测试。

我们已经在第四章中添加了 H2 数据库,设置数据库和 Spring Data JPA,但如果你错过了这部分,为了我们能够添加 H2 依赖项,我们将在pom.xml文件中添加以下内容:

<dependency>
<groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope>
</dependency>

在成功添加依赖项后,我们将在test/java文件夹下添加我们的h2配置。我们将添加一个新的资源包并创建一个新的应用程序来完成此操作。我们将使用属性文件进行单元测试,并将以下配置放置在其中:

spring.datasource.url=jdbc:h2://mem:testdb;DB_CLOSE_DELAY=-1
spring.datasource.username={username}
spring.datasource.password={password}
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.properties.hibernate.format_sql=true

在前面的示例配置中,首先,我们指定我们想要使用spring.datasource.url属性将数据存储在test.mv.db文件中。我们还可以使用spring.datasource.usernamespring.datasource.password属性覆盖我们的H2控制台的用户名和密码,并且我们还指定了在应用程序启动时创建表,在应用程序停止时删除表。

测试服务

现在,我们将在我们的test/java文件夹下创建一个包。这是我们编写测试的地方。我们将从主文件夹创建一个类似的包。在这种情况下,我们将创建com.example.springbootsuperheroes.superheroes.antiHero.h2.service。在新建的包下,我们将创建一个名为AntiHeroH2ServiceTest的新类,我们将在这里开始编写对AntiHeroService的测试。

我们需要采取的第一个步骤是使用@DataJpaTest注解注解我们的类。该注解允许服务通过禁用完整的自动配置并仅应用与测试相关的配置来专注于 JPA 组件。下一步是添加我们的AntiHeroService的依赖项,即AntiHeroRepository。我们将声明一个新的AntiHeroRepository并使用@Autowired注解注入依赖项,我们还将声明AntiHeroService,因为这是我们需要测试的服务。我们将有以下的代码:

@DataJpaTest
public class AntiHeroH2ServiceTest {
    @Autowired
    private AntiHeroRepository repo;
    private AntiHeroService service;
}

在注入我们的依赖项并注解我们的类之后,我们接下来想要考虑的是在运行每个测试之前我们想要有哪些可能的属性;在这种情况下,我们希望在运行测试用例之前创建一个AntiHeroService的实例。为了实现这一点,我们将创建一个带有@BeforeEach注解的方法,并使用AutoWired AntiHeroRepository作为参数创建一个新的AntiHeroService实例:

@BeforeEach
public void setup() {
    service = new AntiHeroService(repo);
}

现在,我们可以为我们的服务编写一个测试用例;我们的目标是编写对AntiHeroService拥有的每个方法的测试。

让我们看看AntiHeroService的方法列表:

  • Iterable<AntiHeroEntity> findAllAntiHeroes: 应返回反英雄的列表。

  • AntiHeroEntity addAntiHero(AntiHeroEntity antiHero): 应添加一个新的反英雄实体。

  • void updateAntiHero(UUID id, AntiHeroEntity antiHero): 应根据给定的 ID 更新反英雄。

  • AntiHeroEntity findAntiHeroById(UUID id): 应返回具有给定 ID 的反英雄;如果未找到,则返回NotFoundException

  • void removeAntiHeroById(UUID id): 应根据给定的 ID 从数据库中删除反英雄。

让我们先为findAllAntiHeroes()方法编写一个测试。该方法的可能测试用例是检查方法是否成功检索数据库中的所有反英雄。为了测试这个场景,我们首先需要将单个实体或一系列测试反英雄实体添加到我们的 H2 数据库中。我们可以调用findAllAntiHeroes()方法来检索数据库中刚添加的实体。让我们看看下面的示例单元测试:

@Test
public void shouldFindAllAntiHero() {
    AntiHeroEntity antiHero = new AntiHeroEntity();
    antiHero.setFirstName("Eddie");
    antiHero.setLastName("Brock");
    antiHero.setHouse("MCU");
    service.addAntiHero(antiHero);
    Iterable<AntiHeroEntity> antiHeroList =
      service.findAllAntiHeroes();
    AntiHeroEntity savedAntiHero =
      antiHeroList.iterator().next();
    assertThat(savedAntiHero).isNotNull();
}

在前面的代码示例中,我们可以看到我们首先创建了一个新的反英雄实例,作为数据库内存中的示范数据。我们使用addAntiHero()方法将数据添加到我们的数据库中。在成功插入数据后,我们可以使用findAllAntiHeroes()方法检查或断言是否可以检索到新创建的反英雄。在这个场景中,我们检索了反英雄列表中的第一条数据。我们使用assertThat(savedAntiHero).isNotNull()来验证列表的第一个元素不是 null。

现在,让我们为addAntiHero()方法编写一个测试。我们将为以下方法创建的测试与为findAllAntiHeroes()方法创建的测试大致相似。该方法的可能测试用例是检查实体是否成功添加到我们的数据库中。

让我们看看以下示例单元测试:

@Test
public void shouldAddAntiHero() {
    AntiHeroEntity antiHero = new AntiHeroEntity();
    antiHero.setFirstName("Eddie");
    antiHero.setLastName("Brock");
    antiHero.setHouse("MCU");
    service.addAntiHero(antiHero);
    Iterable<AntiHeroEntity> antiHeroList =
      service.findAllAntiHeroes();
    AntiHeroEntity savedAntiHero =
      antiHeroList.iterator().next();
    assertThat(antiHero).isEqualTo(savedAntiHero);
}

在前面的代码示例中,我们创建了一个新的反英雄实体,并使用addAntiHero()方法将其插入到我们的数据库中。在添加最新数据后,我们可以检索列表并验证我们的新数据是否在数据库中。在给定场景中,我们检索了反英雄列表中的第一条数据,并使用assertThat(antiHero).isEqualTo(savedAntiHero);来检查我们检索到的数据是否与我们实例化的数据相等。

接下来,现在让我们编写updateAntiHeroMethod();的测试。该方法的可能测试用例是检查该方法是否成功修改了我们数据库中特定实体的信息。

让我们看看满足此测试用例的示例单元测试:

@Test
public void shouldUpdateAntiHero() {
    AntiHeroEntity antiHero = new AntiHeroEntity();
    antiHero.setFirstName("Eddie");
    antiHero.setLastName("Brock");
    antiHero.setHouse("MCU");
    AntiHeroEntity savedAntiHero  =
      service.addAntiHero(antiHero);
    savedAntiHero.setHouse("San Francisco");
    service.updateAntiHero(savedAntiHero.getId(),
                           savedAntiHero);
    AntiHeroEntity foundAntiHero =
      service.findAntiHeroById(savedAntiHero.getId());
    assertThat(foundAntiHero.getHouse()).isEqualTo(
      "San Francisco");
}

在前面的代码示例中,我们创建了一个新的反英雄实体,并使用addAntiHero()方法将其插入到我们的数据库中。在添加实体后,我们更新了添加的反英雄的住宅信息为"San Francisco",并使用updateAntiHeroMethod()将其保存到数据库中。最后,我们使用其 ID 检索了修改后的反英雄,并通过添加assertThat(foundAntiHero.getHouse()).isEqualTo("San Francisco");断言来验证住宅信息是否已修改。

接下来,我们现在将为removeAntiHeroById()方法创建一个单元测试。该方法可能的测试用例是验证具有相应 ID 的实体是否已成功从数据库中删除。

让我们看看满足此测试用例的示例单元测试:

@Test
public void shouldDeleteAntiHero() {
    assertThrows(NotFoundException.class, new Executable() {
        @Override
        public void execute() throws Throwable {
            AntiHeroEntity savedAntiHero  =
              service.addAntiHero(antiHero);
            service.removeAntiHeroById(
              savedAntiHero.getId());
            AntiHeroEntity foundAntiHero =
              service.findAntiHeroById(
                savedAntiHero.getId());
            assertThat(foundAntiHero).isNull();
        }
    });
}

在前面的例子中,我们可以看到我们在编写单元测试时添加了一些额外的元素;我们创建了一个新的Executable()实例,其中放置了我们的主要代码。我们用NotFoundException.class断言了我们的Executable()。这样做的主要原因是我们期望findAntiHeroByID()将返回NotFoundException错误,因为我们已经从我们的数据库中删除了实体。

记住,在断言错误时,我们应该使用assertThrows()

我们已经成功为我们的服务编写了测试,现在,我们将实现仓库级别的单元测试。

测试仓库

为我们的应用程序的仓库编写测试与我们在服务级别编写测试的方式大致相同;我们也把它们当作服务来对待,并在仓库中添加了额外的方法时对它们进行测试。

我们将采用的例子是编写我们的UserRepository的单元测试。让我们回顾一下UserRepository拥有的方法:

  • Boolean selectExistsEmail(String email): 当用户存在给定邮箱时返回true

  • UserEntity findByEmail(String email): 当给定邮箱存在于数据库中时返回用户

要开始编写我们的测试,首先,我们将在com.example.springbootsuperheroes.superheroes包下创建一个名为user.repository的新包,并创建一个名为UserRepositoryTest的新类。在成功创建仓库后,我们将使用@DataJPATest注解该类,使其仅关注 JPA 组件,并使用@Autowired注解注入AntiHeroRepostiory

我们现在的类将如下所示:

@DataJpaTest
class UserRepositoryTest {
    @Autowired
    private UserRepository underTest;
}

现在,在成功注入仓库后,我们可以编写我们的测试。首先,我们想要为selectExistsEmail()方法编写一个测试。该方法的可能测试用例是,如果邮箱存在于我们的数据库中,它应该返回true

让我们看看以下示例代码:

@Test
void itShouldCheckWhenUserEmailExists() {
    // give
    String email = "seiji@gmail.com";
    UserEntity user = new UserEntity(email, "21398732478");
    underTest.save(user);
    // when
    boolean expected = underTest.selectExistsEmail(email);
    // then
    assertThat(expected).isTrue();
}

在示例单元测试中,我们已经将一个示例用户实体添加到我们的数据库中。selectExistsEmail()方法预期将返回true。这应该检索给定邮箱添加的用户。

下一个测试是为findByEmail()方法;这几乎与为selectExistsEmail()方法创建的测试相同。唯一需要修改的是断言,因为我们期望返回User类型的值。

让我们看看以下示例代码:

@Test
void itShouldFindUserWhenEmailExists() {
    // give
    String email = "dennis@gmail.com";
    UserEntity user = new UserEntity(email, "21398732478");
    underTest.save(user);
    // when
    UserEntity expected = underTest.findByEmail(email);
    // then
    assertThat(expected).isEqualTo(user);
}

我们已经使用 JUnit、AssertJ 和 H2 数据库成功为我们的服务和仓库编写了测试。在下一节中,我们将使用 JUnit 和 AssertJ 的第二个实现来编写单元测试,并使用 Mockito。

使用 Mockito 在服务中编写测试

在前面的章节中,我们使用 H2 数据库创建了我们的单元测试;在这个方法中,我们将完全省略数据库的使用,并在单元测试中利用模拟的概念来创建样本数据。我们将通过使用 Mockito 来实现这一点。Mockito 是一个 Java 模拟框架,允许我们隔离测试类;它不需要任何数据库。

这将使我们能够从模拟的对象或服务中返回虚拟数据。Mockito 非常有用,因为它使得单元测试变得更加简单,尤其是在大型应用程序中,因为我们不想同时测试服务和依赖项。以下是使用 Mockito 的其他好处:

  • 支持返回值:支持模拟返回值。

  • 支持异常:可以在单元测试中处理异常。

  • 支持注解:可以使用注解创建模拟。

  • 安全于重构:重命名方法名称或更改参数的顺序不会影响测试,因为模拟是在运行时创建的。

让我们探索 Mockito 的不同功能,以编写单元测试。

添加行为

Mockito 包含 when() 方法,我们可以用它来模拟对象的返回值。这是 Mockito 最有价值的功能之一,因为我们可以为服务或仓库定义一个虚拟的返回值。

让我们看看以下代码示例:

public class HeroTester {
   // injects the created Mock
   @InjectMocks
   HeroApp heroApp = new HeroApp();
   // Creates the mock
   @Mock
   HeroService heroService;
   @Test
   public void getHeroHouseTest(){
      when(heroService.getHouse())).thenReturn(
        "San Francisco ");
   assertThat(heroApp.getHouse()).isEqualTo(
     "San Francisco");
 }
}

在前面的代码示例中,我们可以看到我们在测试中模拟了 HeroService。我们这样做是为了隔离类,而不是测试 Heroservice 本身的功能;我们想要测试的是 HeroApp 的功能。我们通过指定模拟返回 thenReturn() 方法为 heroService.getHouse() 方法添加了行为。在这种情况下,我们期望 getHouse() 方法返回 "San Francisco" 的值。

验证行为

我们可以从 Mockito 中使用的下一个功能是单元测试中的行为验证。这允许我们验证模拟的方法是否被调用并带有参数执行。这可以通过使用 verify() 方法来实现。

让我们以相同的类示例为例:

public class HeroTester {
   // injects the created Mock
   @InjectMocks
   HeroApp heroApp = new HeroApp();
   // Creates the mock
   @Mock
   HeroService heroService;
   @Test
   public void getHeroHouseTest(){
      when(heroService.getHouse())).thenReturn(
        "San Francisco ");
   assertThat(heroApp.getHouse()).isEqualTo(
     "San Francisco");
   verify(heroService).getHouse();
 }
}

在前面的代码示例中,我们可以看到我们在代码中添加了 verify(heroService).getHouse()。这验证了我们是否调用了 getHouse() 方法。我们还可以验证该方法是否带有一些给定的参数被调用。

期望调用

times(n) 方法。同时,我们还可以使用 never() 方法验证它是否被调用。

让我们看看以下示例代码:

public class HeroTester {
   // injects the created Mock
   @InjectMocks
   HeroApp heroApp = new HeroApp();
   // Creates the mock
   @Mock
   HeroService heroService;
   @Test
   public void getHeroHouseTest(){
     // gets the values of the house
     when(heroService.getHouse())).thenReturn(
       "San Francisco ");
    // gets the value of the name
    when(heroService.getName())).thenReturn("Stark");
   // called one time
   assertThat(heroApp.getHouse()).isEqualTo(
     "San Francisco");
   // called two times
   assertThat(heroApp.getName()).isEqualTo("Stark");
   assertThat(heroApp.getName()).isEqualTo("Stark");
   verify(heroService, never()).getPowers();
   verify(heroService, times(2)).getName();
 }
}

在前面的代码示例中,我们可以看到我们使用了 times(2) 方法来验证 heroServicegetName() 方法是否被调用了两次。我们还使用了 never() 方法,它检查 getPowers() 方法是否没有被调用。

Mockito 除了 times()never() 之外,还提供了其他方法来验证预期的调用次数,这些方法如下:

  • atLeast (int min): 验证方法是否至少被调用n

  • atLeastOnce (): 验证方法是否至少被调用一次

  • atMost (int max): 验证方法是否最多被调用n

异常处理

Mockito 还在单元测试中提供了异常处理;它允许我们在模拟上抛出异常以测试应用程序中的错误。

让我们看看下面的示例代码:

public class HeroTester {
   // injects the created Mock
   @InjectMocks
   HeroApp heroApp = new HeroApp();
   // Creates the mock
   @Mock
   HeroService heroService;
   @Test
   public void getHeroHouseTest(){
   doThrow(new RuntimeException("Add operation not
           implemented")).when(heroService.getHouse()))
   .thenReturn("San Francisco ")
  assertThat(heroApp.getHouse()).isEqualTo(
    "San Francisco");
 }
}

在前面的示例中,我们配置了heroService.getHouse(),一旦被调用,就抛出RunTimeException。这将允许我们测试并覆盖应用程序中的错误块。

我们已经了解了 Mockito 中可用的不同功能。现在,让我们继续在我们的 Spring Boot 应用程序中编写我们的测试。

Spring Boot 中的 Mockito

在本节中,我们现在将在我们的 Spring Boot 应用程序中实现 Mockito 以编写单元测试。我们将再次为我们的服务编写测试,并在我们的test/java文件夹下创建另一个包,该包将用于使用 Mockito 进行单元测试;我们将创建com.example.springbootsuperheroes.superheroes.antiHero.service。在新创建的包下,我们将创建一个名为AntiHeroServiceTest的新类,我们将开始编写对AntiHeroService的测试。

在成功创建我们的类之后,我们需要使用@ExtendWith(MockitoExtension.class)注解我们的类,以便能够使用 Mockito 的方法和功能。下一步是模拟我们的AntiHeroRepository并将其注入到我们的AntiHeroRepositoryService中。为了完成这个任务,我们将使用@Mock注解来声明仓库,并使用@InjectMocks注解来声明服务,此时我们的类将如下所示:

@ExtendWith(MockitoExtension.class)
class AntiHeroServiceTest {
    @Mock
    private AntiHeroRepository antiHeroRepository;
    @InjectMocks
    private AntiHeroService underTest;
}

在前面的示例中,我们成功模拟了我们的仓库并将其注入到我们的服务中。现在,我们可以开始在测试中模拟仓库的返回值和行为。

让我们在AntiHeroService中添加一些示例测试;在一个示例场景中,我们将为addAntiHero()方法编写一个测试。这个测试的可能用例是验证仓库中的save()方法是否被调用,并且反英雄是否成功添加。

让我们看看下面的示例代码:

@Test
void canAddAntiHero() {
    // given
    AntiHeroEntity antiHero = new AntiHeroEntity(
            UUID.randomUUID(),
            "Venom",
            "Lakandula",
            "Tondo",
            "Datu of Tondo",
            new SimpleDateFormat(
              "dd-MM-yyyy HH:mm:ss z").format(new Date())
    );
    // when
    underTest.addAntiHero(antiHero);
    // then
    ArgumentCaptor<AntiHeroEntity>
    antiHeroDtoArgumentCaptor =
      ArgumentCaptor.forClass(
            AntiHeroEntity.class
    );
    verify(antiHeroRepository).save(
      antiHeroDtoArgumentCaptor.capture());
    AntiHeroEntity capturedAntiHero =
      antiHeroDtoArgumentCaptor.getValue();
    assertThat(capturedAntiHero).isEqualTo(antiHero);
}

在前面的示例中,第一步始终是创建一个样本实体,我们可以将其用作添加新反英雄的参数;在调用我们正在测试的addAntiHero()方法之后,我们使用verify()方法验证了AntiHeroRepositorysave()方法是否被调用。

我们还使用了ArgumentCaptor来捕获我们之前使用的方式中使用的参数值,这些值将被用于进一步的断言。在这种情况下,我们断言捕获的反英雄等于我们创建的反英雄实例。

概述

通过这些,我们已经到达了本章的结尾。让我们回顾一下你学到的宝贵知识;你学习了 JUnit 的概念,JUnit 是一个提供诸如固定值、测试套件和用于测试我们应用中方法的类等功能的测试框架。你还学习了 JUnit 中 AssertJ 的应用,它为我们的单元测试提供了更灵活的断言对象的方式;最后,你还学习了 Mockito 的重要性,它为我们提供了模拟对象和服务的能力。

在下一章中,我们将使用 Angular 开发我们的前端应用程序。我们将讨论如何组织我们的功能和模块,在 Angular 文件结构中构建我们的组件,并将 Angular Material 添加到用户界面中。

第三部分:前端开发

本部分包含了一个开发 Angular 13 应用程序的真实场景。以下章节包含在本部分中:

  • 第十章, 设置我们的 Angular 项目和架构

  • 第十一章, 构建响应式表单

  • 第十二章, 使用 NgRx 管理状态

  • 第十三章, 使用 NgRx 进行保存、删除和更新

  • 第十四章, 在 Angular 中添加身份验证

  • 第十五章, 在 Angular 中编写测试

第十章:设置我们的 Angular 项目和架构

在上一章中,你学习了关于 JUnit 的概念,它是一个提供诸如固定值、测试套件和用于测试我们应用程序中方法的类等功能的测试框架。你还学习了与 JUnit 结合使用 AssertJ 的应用,它为我们单元测试中的对象断言提供了一种更灵活的方式,最后,你还了解了 Mockito 的重要性,它为我们提供了模拟对象和服务的能力,从而在单元测试中省略了数据库的使用。

在本章中,我们将开始使用 Angular 构建我们的前端应用程序;我们将探讨 Angular 的主要基础,例如组件、模块、指令和路由。我们还将指出组织我们的 Angular 项目的最佳实践。

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

  • 组织特性和模块

  • 结构化组件

  • 添加 Angular material

技术要求

本章完成版本的链接在此:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-10/superheroes

组织特性和模块

在本节中,我们将讨论如何组织和结构化我们的 Angular 项目,使其优化且易于维护。由于 Angular 被认为是 模型视图任意MVW)框架,Angular 开发者有自由选择在项目开发中实施他们选择的模式。这可能会令人困惑,因为当你从一个项目切换到另一个项目时,你会遇到不同的结构和标准。为了解决这个困境,我们将介绍业界常用的一种结构或某种典型的基准结构,你通常会在 Angular 项目中找到这种结构。

然而,在我们继续讨论主要主题之前,让我们首先讨论如何创建我们的 Angular 项目以及在我们编码 Angular 之前需要了解的基本概念。如果你已经熟悉 Angular,可以跳过这部分内容,直接进入 组织文件夹结构 部分。

生成 Angular 项目

我们可以使用名为 Angular CLI 的工具创建或设置我们的 Angular 项目的依赖项。我们可以通过使用一个负责下载依赖项并生成我们 Angular 项目运行所需文件的单一命令来构建项目。Angular CLI 是一个方便的工具,因为它还提供了几个命令,可以帮助我们在 Angular 中生成样板代码。

要安装 Angular CLI,我们应该确保我们的机器上已安装 Node.js,然后执行 npm install -g @angular/cli 命令。执行命令后,我们可以验证我们的 Angular CLI 是否已成功安装——我们将有一个新的全局 ng 命令,我们可以使用它来检查已安装 CLI 的版本。

要检查版本,我们将执行 ng --version 命令,并将得到以下输出:

图 10.1 – 安装的 Angular CLI 版本

图 10.1 – 安装的 Angular CLI 版本

在前面的输出中,我们可以看到在执行 ng -- version 命令后,显示了我们机器上安装的 Angular CLI 和 Node.js 的版本。目前,我们已安装 Angular CLI 版本 13,这意味着一旦我们搭建了一个 Angular 项目,它将使用 版本 13

在成功安装 Angular CLI 之后,我们现在可以执行一些针对我们项目的命令。以下是一些我们可以在 Angular CLI 中使用的命令:

  • ng new <project name> [options]:创建或搭建新的 Angular 项目

  • ng serve <project> [options]:构建并托管您的 Angular 应用程序

  • ng generate <schematic> [options]:使用特定的图生成和修改文件

我们可以生成的某些图如下:

  • 组件

  • 模块

  • 指令

  • 守卫

  • ng build<project> [options]:将 Angular 应用程序编译到名为 dist 的输出目录,该目录将用于生产

  • ng test <project> [options]:在 Angular 项目中运行单元测试

这些只是 Angular CLI 中最常用的命令之一。对于完整的命令,您可以访问 Angular 的文档,网址为 https://angular.io/cli。

我们知道可以在 Angular CLI 中使用的命令。现在,让我们通过在期望的路径上执行 ng new superheroes 命令来生成我们的 Angular 项目。这将提出几个问题,例如“您想要添加 Angular 路由吗?”和“您想使用哪种样式表格式?”对于这些问题,我们可以选择 Syntactically Awesome Style Sheet (SASS),因为我们需要路由和 SASS 来构建我们的应用程序。

在这一步之后,这将现在为名为 superheroes 的新 Angular 项目搭建框架,并负责配置 web pack、创建所需的设置以及下载项目的依赖项。搭建完成后,在 Visual Studio Code 或您偏好的任何 IDE 中打开 superheroes 项目。我们将看到 Angular 应用程序已配置并准备好在我们的本地服务器上运行。

图 10.2 – 搭建后的文件夹结构和安装的依赖项

图 10.2 – 搭建后的文件夹结构和安装的依赖项

要运行我们的项目,我们可以使用 Ctrl + ** 快捷键打开 VS Code 终端,并执行 ng serve命令。我们还可以使用package.json文件中定义的脚本。在这种情况下,我们可以执行npm run start` 来运行我们的应用程序。我们将在以下屏幕截图中的终端中看到 Angular 是否在我们的本地服务器上成功运行:

图 10.3 – 搭建后的文件夹结构和安装的依赖项

图 10.3 – 搭建后的文件夹结构和安装的依赖项

在成功运行我们的 Angular 应用程序后,现在我们可以使用浏览器中的默认 URL(http://localhost:4200)打开应用程序,我们将看到 Angular 项目的默认页面:

图 10.4 – Angular 默认页面

图 10.4 – Angular 默认页面

我们已成功配置并启动了本地的 Angular 应用程序。现在,让我们讨论我们将用于构建应用程序的概念。

Angular 特性

Angular 框架是一个基于组件的框架,它允许我们开发可重用的组件,以促进代码的可重用性和可维护性。它提供了许多将使我们的前端开发更强大的功能。在本节中,我们将讨论 Angular 的基本特性和基础,这些是 Angular 的构建块;请注意,我们不会在这里讨论所有功能,因为我们更关注项目的组织。

要了解更多关于特性的信息,您可以访问 Angular 的官方文档:https://angular.io/start。

组件

@Component 装饰器,它分配了描述组件 HTML、CSS 和选择器的几种类型的元数据。

以下是为生成组件的命令:

ng generate component <component-name>
ng g c <component-name>

以下是一个组件的代码示例:

import { Component } from '@angular/core';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'superheroes';
}

组件有一个名为数据绑定的功能,它允许我们将数据传递到视图中。数据绑定可以用来向用户显示值、响应用户事件以及修改样式。Angular 绑定分为两组:

  • 单向绑定

  • 双向绑定

单向绑定

从名称可以推断出,数据在这里只单向流动。它可以是从组件到视图或相反。

在 Angular 中实现单向绑定有几种方法,其中两种最常见的方法是使用插值和属性绑定。

插值

{{}}(双大括号)用于 HTML 代码中的表达式。

让我们看看以下示例代码:

// app.component.ts
export class AppComponent {
  title = 'superheroes';
}
<!—app.component.html ->
<!-- INTERPOLATE TITLE -->
<span> Title:  {{title}} </span>

在前面的示例中,我们使用了插值来在视图中显示title变量的值。使用插值,我们还可以在模板表达式中使用运算符。

属性绑定

classhrefdisabledsrc

让我们看看以下示例代码,了解如何使用属性绑定:

// app.component.ts
export class AppComponent {
  isDisabled = true;
}
<!—app.component.html ->
<button [disabled]="isDisabled">Can't be clicked</button>

在前面的示例中,我们将isDisabled变量绑定到按钮的禁用属性。由于我们将isDisabled的值设置为true,按钮将被禁用。

双向绑定

双向绑定是双向数据流。对模型所做的任何更改都将同时反映在视图中,并且任何涉及视图的修改都将更新到组件中。双向数据绑定有助于处理表单,因为我们希望模型在表单值更新后也更新,反之亦然。

要实现双向绑定,使用ngModel指令。

ngModel

ngModel 是一个在 Angular 中用于实现双向绑定的指令。这位于 @angular/forms 下的表单模块中。一旦 ngModel 绑定到输入字段或其他表单元素,它就会给该元素添加属性绑定和事件绑定。让我们看看下面的示例代码:

// app.component.ts
export class AppComponent {
  model = 'seiji';
}
<!—app.component.html ->
<input [(ngModel)]="model"/>

在前面的示例代码中,我们使用 ngModel 指令将模型值绑定到一个输入元素上。这种语法也被称为 盒子里的香蕉,它用方括号和圆括号包围 ngModel。该指令将通过属性绑定将模型值与输入字段绑定,并通过利用 ngModelChange 来监听输入值的变化。

指令

指令是 Angular 的一个功能,帮助我们操作 文档对象模型DOM)。我们可以修改 DOM 元素的布局、行为和视图。指令分为三个部分:组件是其中一种分类,其他两种是属性结构指令。

结构指令

结构指令是可以通过添加、更新或删除元素来修改 DOM 布局的指令,以下是一些 Angular 中的结构指令:

  • *ngIf:一个用于根据条件在 DOM 中添加或删除元素的指令:

    <div *ngIf="condition">Will show if the condition is true</div>
    
  • *ngFor:一个用于从特定列表中迭代的项重复 HTML 元素的指令:

    // this will display all the users for each row
    
    <tr *ngFor="let user of users;">
    
        <td>{{user.firstName }}</td>
    
        <td>{{user.lastName}}</td>
    
    </tr>
    
  • *ngSwitch:一个允许我们使用开关机制添加或删除 HTML 元素的指令。如果提供的表达式匹配,则将显示 HTML 元素:

    //evaluates the hero variable and displays the name of the hero base on its value
    
    <div [ngSwitch]="hero">
    
       <div *ngSwitchCase="'Dr. Strange'">
    
          Stephen Strange</div>
    
       <div *ngSwitchCase="'Hawkeye'">Clint Barton</div>
    
       <div *ngSwitchCase="'Hulk'">Bruce Banner</div>
    
    </div>
    

属性指令

属性指令是用于更改或修改元素外观或行为的指令。与结构指令相比,属性指令不能在 DOM 中添加或删除元素。

下面列出了一些 Angular 中的属性指令:

  • ngClass:一个用于向 HTML 元素添加 CSS 类或从其中删除它们的指令;这允许我们动态地更改元素的外观:

    //adds an error class on the input element if the
    
    //control value is invalid
    
    <input type="text" [ngClass]="control.isInvalid ? 'error': ''" />
    
  • ngStyle:一个允许我们更改 HTML 元素样式的指令:

    // the color of the element will base on the value of
    
    // the color variable
    
    <div [ngStyle]="{'color': color}"> Angular Framework </div>
    

模块

模块是 Angular 框架的一个基本特性。随着我们的应用程序变得更加复杂,它将包含大量的组件、指令和服务。这将影响应用程序代码库的可维护性。Angular 框架提供了一种组织和分组块的方法,称为模块。

Angular 框架中的模块帮助我们开发应用程序,促进关注点的分离。它们允许我们根据其功能对块进行分类和组织。Angular 也是使用模块构建的;@angular/core 框架是主要的 Angular 模块,它提供了 Angular 的核心功能和服务的实现。

创建一个模块

我们将使用 @NgModule 装饰器来创建一个模块。它由几种类型的元数据组成,允许我们定义创建的模块中包含的组件、服务、管道和其他模块。

以下示例代码展示了模块可用的属性:

@NgModule({
  declarations:[],
  imports:[],
  providers:[],
  exports: [],
  bootstrap:[],
  entrycomponents:[]
})

让我们接下来讨论每个属性的职能:

  • 声明:这是我们将应用程序的组件、指令和管道放置的地方。记住,组件、指令和管道必须在单个模块中声明。

  • 提供者:这是我们将服务放置的地方,以便它们可以用于依赖注入。

  • 导入:这是我们将一个或多个其他模块放置到我们的应用程序中的地方。一旦我们导入一个特定的模块,导入模块中的所有组件、管道和指令都可以使用。

  • 导出:这是我们将组件、指令和管道放置的地方,以便在导入后可供其他模块使用。

  • (AppModule),因为根模块的职责是在应用程序启动时加载第一个视图。

  • 入口组件:这是我们将应该在我们应用程序中动态加载的组件放置的地方。

以下图显示了 NgModule 在 Angular 应用程序中的工作方式:

图 10.5 – Angular 模块流程图

图 10.5 – Angular 模块流程图

服务和依赖注入

服务也是 Angular 中的一个有价值的功能。这是可以在应用程序的不同组件中重用的代码。

服务的主要职责如下:

  • 在不同的组件上重用逻辑

  • 实现 API 通信和数据访问

  • 提倡单一职责,因为它将组件的独立功能分离出来

要在应用程序中创建一个服务,我们将创建一个类并用 @Injectable 装饰器注解它。为了在应用程序的根级别注册该服务,我们将以下内容添加到我们的 @Injectable 装饰器中:

@Injectable({
  providedIn: 'root',
 })

一旦我们在根中设置了 providedIn 元数据的值,这将创建一个在整个应用程序中共享的服务实例。另一方面,如果我们想在特定模块中提供该服务,我们将该服务放置在 @NgModule 的提供者元数据中:

@NgModule({
    providers: [Service1]
})

现在我们已经讨论了 Angular 的一些基本特性,接下来我们将关注如何构建你的 Angular 应用程序结构。

创建 Angular 文件夹结构

Angular 框架被认为是一个 MVW 框架,这意味着有许多可能的方式来构建我们的应用程序。在这种情况下,我们将讨论一种最佳实践或最常用的结构,这可以帮助你的 Angular 应用程序具有可扩展性和可维护性。

在前面的部分中,我们了解到 Angular 块可以被分组和组织成模块;模块是构建我们的 Angular 应用程序的一个很好的起点。我们可以实施的第一步是根据其功能对模块进行分组和分类。下面是模块的分类。

根模块

AppModule 位于 src/app 文件夹下。

功能模块

功能模块是我们放置应用特定功能的模块。这意味着我们代码的大部分内容都在这个模块中。我们将在应该包含它们的模块下创建组件、管道和指令,我们还可以通过将具有路由的组件放在页面文件夹中来分离组件。

让我们看看一个名为 AntiHeroModule 的功能模块的示例文件夹结构:

├── src
│   ├── app
│   │   ├── anti-hero
│   │   │   ├── components
│   │   │   │   ├── shared.component.ts
│   │   │   ├── directives
│   │   │   │   ├── first.directive.ts
│   │   │   │   ├── another.directive.ts
│   │   │   ├── pages
│   │   │   │   ├── form
│   │   │   │   │   ├── form.component.ts
│   │   │   │   ├── list
│   │   │   │   │   ├── list.component.ts
│   │   │   │   ├── anti-hero.component.ts
│   │   │   │   ├── anti-hero.component.html
│   │   │   │   ├── anti-hero.component.css
│   │   │   │   ├── index.ts
│   │   │   ├── pipes
│   │   │   │   ├── first.pipe.ts
│   │   │   ├── anti-hero.module.ts
│   │   │   ├── anti-hero.routing.module.ts
│   │   │   ├── index.ts

在这里的文件夹结构中,我们将我们的反英雄模块分成了几个部分。第一个文件夹是 components 文件夹,它包含在这个模块中共享的所有组件。这些也可以称为 组件,我们将在下一节中讨论。

接下来的两个是 directivespipes 文件夹,它们包含在 AntiHeroModule 中使用的指令和管道。最后,pages 文件夹包括 AntiHeroModule 中具有直接路由的组件。这些也可以称为 index.ts 文件,被称为桶文件,它将为导出组件、指令和管道提供一个集中位置。

共享模块

共享模块是一个在整个应用程序中使用和共享的模块;它由我们在应用程序的不同部分需要使用的组件、管道和指令组成。请记住,共享模块不应依赖于应用程序中的其他模块。

共享模块是在 src/app/shared 文件夹下创建的。

让我们看看我们应用程序中共享模块的示例文件夹结构:

├── src
│   ├── app
│   │   ├── shared
│   │   │   ├── layout
│   │   │   │   ├── footer
│   │   │   │   │   ├── footer.component.ts
│   │   │   │   │   ├── footer.component.html
│   │   │   │   ├── header
│   │   │   │   │   ├── header.component.ts
│   │   │   │   │   ├── header.component.html
│   │   │   │   ├── layout.module.ts
│   │   │   ├── index.ts

在前面的文件夹结构中,我们可以看到我们创建了两个名为 footernavbar 的组件;这些是在应用程序中最常见的共享组件之一。

核心模块

核心模块是一个在整个应用程序中共享服务的模块。这些是单例服务,应用程序中只有一个实例。通常,包含在核心模块中的服务是身份验证服务。

由于它应该只有一个实例,因此核心模块必须只导入到应用程序的根模块中。

我们可以向我们的核心模块添加以下代码以防止它被导入到其他模块中:

@NgModule({})
export class CoreModule {
  constructor(@Optional() @SkipSelf() core:CoreModule ){
    if (core) {
        throw new Error("Core module should only be
                         imported to the Root Module")
    }
  }
}

在前面的代码示例中,我们向构造函数中添加了 CoreModule 参数,并使用 @Optional@SkipSelf 装饰器——如果核心返回一个值表示 CoreModule 已经被导入到根模块中,这将抛出一个错误。

现在我们继续学习如何在 Angular 应用程序中实现结构。

实现结构

现在我们已经学习了我们的 Angular 应用程序的不同模块类别,让我们将这些文件夹结构应用到我们的超级英雄项目中。

我们的目标是创建一个具有简单创建、读取、更新和删除CRUD)功能的前端应用程序,用于英雄和反英雄。

首先,我们将在app目录下创建共享特性和core文件夹,并在完成三个主要类别后,我们将为每个模块制作所需的块。

特性模块下的块

我们希望在特性模块下创建块;首先我们需要搭建的是AntiHeroModule。执行ng g m anti-hero命令以在src/app文件夹下生成模块。

现在,在反英雄文件夹下,我们将创建以下文件夹:

  • components:这将包含在这个模块中共享的组件。

  • pipes:这将包含反英雄模块使用的所有管道。

  • directives:这将包含反英雄模块将使用的所有指令。

  • pages:这将包含具有直接路由的组件。

在创建文件夹后,我们现在将制作反英雄模块的页面组件。我们将添加两个页面,第一个页面将用于显示反英雄列表,第二个页面将是一个表单,允许我们查看、创建或修改列表中选定的英雄。我们可以执行ng g c anti-hero/pages/pages/formng g c anti-hero/pages/list命令来创建这两个页面。这将创建两个新的组件,formlist,在pages文件夹下。

在成功创建页面组件后,我们还将为我们的反英雄模块添加一个路由模块。我们将执行ng g m anti-hero/anti-hero-routing --flat命令,并将以下代码放置在我们的路由模块中:

import {NgModule} from "@angular/core";
import {RouterModule, Routes} from "@angular/router";
import {ListComponent} from "./pages/list/list.component";
import {FormComponent} from "./pages/form/form.component";
const routes: Routes = [
  {
    path: "",
    component: ListComponent,
  },
  {
    path: "form",
    component: FormComponent,
  },
];
@NgModule({
  declarations: [ListComponent, FormComponent],
  imports: [RouterModule.forChild(routes)],
  exports:[RouterModule]
})
export class AntiHeroRoutingModule {}

在前面的示例代码中,我们为我们的formlist页面定义了路由。这意味着页面组件将在我们的应用程序中拥有直接路由,并且我们也将ListComponent定义为该模块的基础路由。

在成功创建页面组件和定义路由后,我们希望我们的根模块(AppModule)有一个指向AntiHeroModule的路由。

为了实现这一点,我们将以下代码放置在app-routing.module.ts中:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
  {
    path: "",
    redirectTo: "anti-heroes",
    pathMatch: "full",
  },
  {
    path: "anti-heroes",
    loadChildren: () =>
      import("./anti-hero/anti-hero.module").then((m) =>
             m.AntiHeroModule),
  }
];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule { }

在前面的示例代码中,我们使用了懒加载来为AntiHeroModule创建一个路由。一旦我们访问{baseUrl}/anti-heroes URL,这将加载AntiHeroModule并重定向到基础路由,即ListComponent。我们还使用了RouterModule.forRoot()来导入路由,因为这是根模块。

在成功定义了AppModule的路由后,我们现在可以看到我们应用程序的当前结构:

图 10.6 – 创建反英雄特性后的文件夹结构

图 10.6 – 创建反英雄特性后的文件夹结构

现在我们已经完成了 Angular 中的功能模块,我们只有pages文件夹。随着我们开发应用程序,我们将添加其他块,如组件和指令。下一步是创建共享模块。

共享模块下的块

现在,我们的下一个目标是创建共享模块下的块。我们将共享模块定义为在整个应用程序中共享的组件、指令和管道,并且它们不能依赖于其他模块。为了创建我们的共享模块,我们将执行ng g m shared命令。这将创建一个新的共享文件夹和该新共享文件夹中的一个模块文件。

现在在完成共享模块后,我们可以生成将在这个模块中分类的块。在我们的应用程序中,我们可以将navbarfooter作为共享组件包含在内,因为它们将在我们应用程序的每个部分中使用。

我们将执行ng g c shared/layout/navbarng g c shared/layout/footer命令来构建navbarfooter。我们可以看到FooterComponentNavbarComponent被自动添加到shared.module.ts文件中,因为 Angular 检测到离组件最近的模块:

@NgModule({
  declarations: [
    NavbarComponent,
    FooterComponent
  ],
  imports: [CommonModule]
})
export class SharedModule { }

记得在NgModuleexports元数据中添加navbarfooter组件,并且我们将导入不同的模块中的共享模块:

@NgModule({
  declarations: [
    NavbarComponent,
    FooterComponent
  ],
  exports: [NavbarComponent, FooterComponent]
  imports: [CommonModule]
})

根据我们应用程序的需求,我们还可以通过执行ng g c shared/directive/directive-nameng g c shared/pipes/pipe-name命令在共享文件夹下添加共享指令和管道。在成功创建块后,我们将有以下文件夹结构:

图 10.7 – 创建共享模块后的文件夹结构

图 10.7 – 创建共享模块后的文件夹结构

我们还必须记住,共享模块不需要路由模块,因为在我们应用程序中没有需要路由的组件。

核心模块下的块

我们需要创建的最后一个模块是核心模块。核心模块是我们在整个应用程序中共享的服务,并且只有一个实例。一个总是进入核心模块的服务是认证服务。

在完成核心模块后,我们将执行ng g m core;命令来创建我们的共享模块。我们将通过运行ng g s core/services/authenticate命令来构建认证服务。

在成功创建认证服务后,我们将将其放在core.module.ts文件下,以便将服务包含在模块中。根据我们应用程序的需求,我们还可以通过添加一个models文件夹在核心模块下添加共享模型。现在,我们有以下文件夹结构:

图 10.8 – 创建共享模块后的文件夹结构

图 10.8 – 创建共享模块后的文件夹结构

在我们通过应用程序的开发过程创建认证服务的具体内容时,我们将使用这个结构来构建项目的其他部分。现在,我们将讨论如何在 Angular 中构建我们的组件结构。

组件结构

我们已经通过根据其用途和功能对模块进行分类来构建我们的 Angular 应用程序结构,这将有利于代码的可重用性和可维护性。然而,仍然存在在特定模块下创建大量组件的可能性,这将进一步提高应用程序的可维护性。在本节中,我们将讨论在组件级别构建 Angular 架构的另一种策略。

智能与哑或展示组件

构建 Angular 应用程序最常见和推荐的组件级别架构是智能与哑组件架构。在上一节中,我们根据在应用程序中的使用方式将模块分为不同的类别。

这种架构也提供了相同的概念。我们将组件分为两种不同的类型——即,智能组件展示组件

让我们讨论每种组件类型的特征。

智能组件

智能组件也被称为应用级组件或容器组件。这些组件的主要职责是与服务通信并从请求中获取数据。由于它们是智能的,它们包含获取应用程序数据所需的所有依赖项和订阅。

智能组件可以被认为是具有直接路由的应用程序页面组件,它们是包含哑组件的父组件。让我们看看以下创建智能组件的示例代码:

@Component({
  selector: 'app-home',
  template: `
    <h2>User List</h2>
    <div>
        <table class="table">
            <tbody>
            <tr (click)="selectUser(user)" *ngFor="let user
             of users">
                <td> {{user.firstName}} </td>
                <td>
                    <span>{{user.lastName}}</span>
                </td>
            </tr>
            </tbody>
        </table>
    </div>
`,
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
  users: User[] = [];
  constructor(private userService: UserService) {
  }
  ngOnInit() {
      this. userService.getUsers()
          .subscribe(users => this.users = users);
  }
  selectUser(user: User) {
     // action
  }
}

在前面的示例代码中,我们创建了一个名为 HomeComponent 的组件,该组件将在表格中显示用户列表。我们还注入了 UserService 以从 HTTP 请求中获取用户。我们知道这个组件将成功显示用户,但我们可以看到我们的模板很庞大,并且随着我们向该组件添加更多功能,它可能会变得过于复杂。

我们想要做的是省略仅用于展示目的的元素。在这种情况下,我们希望从我们的 HomeComponent 中移除表格,并将有以下的代码:

@Component({
  selector: 'app-home',
  template: `
    <h2>User List</h2>
    <div>
       <!—we will place that dumb component here later-->
    </div>
`,
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
  users: User[] = [];
  constructor(private userService: UserService) {
  }
  ngOnInit() {
      this. userService.getUsers()
          .subscribe(users => this.users = users);
  }
  selectUser(user: User) {
     // action
  }
}

在前面的重构代码中,我们移除了显示用户列表的表格。我们只希望智能组件处理依赖注入、订阅和操作。我们现在已经成功创建了我们的智能组件,下一步是创建展示组件。

哑或展示组件

哑组件,也称为展示组件,负责在应用程序中显示数据。它们不应该有依赖项和订阅,因为它们的唯一目的是在视图中展示数据。

让我们创建之前遗漏的表格作为哑组件:

@Component({
  selector: 'users-list',
  template: `
        <table class="table">
            <tbody>
            <tr (click)="selectUser(user)" *ngFor="let user
             of users">
                <td> {{user.firstName}} </td>
                <td>
                    <span>{{user.lastName}}</span>
                </td>
            </tr>
            </tbody>
        </table>
  `,
  styleUrls: ['./users-list.component.css']
})
export class UsersListComponent {
  @Input()
  users: User[];
  @Output('user')
  userEmitter = new EventEmitter<User>();
   selectUser(user:User) {
        this.userEmitter.emit(user);
    }
}

在前面的示例代码中,我们为显示用户列表的表格创建了一个单独的组件。由于哑组件没有注入依赖项,该组件需要从智能组件接收数据。为了实现这一点,我们添加了一个 @Input 绑定属性来接收来自 HomeComponent 的用户列表;另一方面,我们还在智能组件中添加了一个 @Output 绑定属性,以便将操作冒泡到父组件或智能组件。

记住,哑组件不能有任何逻辑或操作;在这种情况下,我们将使用 EventEmitter 在父组件中传递事件,父组件将负责完成所需的步骤。在示例代码中,一旦点击行,我们就将用户传递给 userEmitterHomeComponent 将检索它。

在成功创建 UserListComponent 之后,我们现在可以在 HomeComponent 中使用它,并且我们将得到以下代码:

@Component({
  selector: 'app-home',
  template: `
    <h2>User List</h2>
    <div>
       <users-list users="users"
        (user)="selectUser($event)"/>
    </div>
`,
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
  users: User[] = [];
  constructor(private userService: UserService) {
  }
  ngOnInit() {
      this. userService.getUsers()
          .subscribe(users => this.users = users);
  }
  selectUser(user: User) {
     // action
  }
}

在前面的示例代码中,我们可以看到我们使用了 UsersListComponent 作为 HomeComponent 的子组件。它接受 HomeComponent 检索到的用户列表,并在点击特定行时发出一个事件。有了这个,我们现在已经完成了对智能和哑组件概念的讨论。

现在让我们将架构应用到我们的应用程序中。

在项目中实现智能和哑组件

现在我们将在我们的 Angular 项目中实现智能和哑组件架构。我们将在 AntiHeroModule 下创建我们的组件。我们已经在 pages 文件夹中创建了我们的 formlist 组件。

我们将始终将我们的智能组件放在 pages 文件夹中,因为它们将是应用程序中的容器组件。下一步是创建我们的哑组件。我们将创建两个哑组件,即 anti-hero-formanti-hero-list 组件。为了创建组件,执行 ng g c anti-hero/components/anti-hero-form anti-hero/components anti-hero-list 命令。在成功生成两个哑组件后,让我们放置以下代码。

对于 AntiHeroListComponent,我们将以下代码放入 anti-hero-list.component.html 文件中:

<table>
    <thead>
        <th *ngFor="let item of headers">{{item.headerName}}</th>
    </thead>
    <tbody>
        <tr (click)="selectAntiHero(item)"
 *ngFor ="let item of antiHeroes">
           <ng-container *ngFor="let header of headers">
               <td>{{item[header.fieldName]}}</td>
           </ng-container>
        </tr>
    </tbody>
</table>

在前面的 HTML 代码中,我们创建了一个表格,其中标题和项绑定到 antiHeroes 变量。我们还绑定了标题和反英雄的关键值来动态显示值。

现在,让我们为接收和发出数据添加到我们的 AntiHeroList 组件中的属性:

anti-hero-list.component.ts

export class AntiHeroListComponent implements OnInit {
  @Input() headers: Array<{headerName: string, fieldName:
    keyof AntiHero}> = [];
  @Input() antiHeroes: Array<AntiHero> = [];
  @Output() antiHero = new EventEmitter();
  constructor() { }
  ngOnInit(): void {
  }
  selectAntiHero(antiHero: AntiHero) {
    this.antiHero.emit(antiHero);
  }
}

现在,在反英雄组件的TypeScript文件中,我们定义了三个属性,这些属性是我们哑组件需要用于接收数据和向智能组件发射事件的。

第一个属性是headers属性,它具有@Input注解。这将获取一个包含{headerName: string, fieldName: keyof AntiHero}类型的数组,该数组将被迭代以显示列标题并显示反英雄项目每个字段的值。第二个属性是antiHeroes,它也具有@Input注解。这将接受要在每行显示的反英雄列表,最后是antiHero属性,它被注解为@Output。当用户点击单行时,这将向父组件发射所选的反英雄。

我们还在anti-hero/models/anti-hero.interface.ts中添加了一个名为AntiHero的接口,该接口将用于转换对象类型。

我们将为interface有以下代码:

export interface AntiHero {
    firstName: string;
    lastName: string;
    house: string;
    kownAs: string;
}

在前面的代码示例中,我们创建了一个AntiHero接口,该接口将用作我们对象的蓝图。反英雄对象的属性与我们定义在 Spring 应用程序中相同的属性相同。

在为我们的对象创建接口之后,我们现在将在AntiHeroModule中声明和导入我们的组件和模块。

让我们看一下以下代码:

anti-hero.module.ts

@NgModule({
  declarations: [
    AntiHeroListComponent,
    AntiHeroFormComponent,
    ListComponent,
    FormComponent
  ],
  imports: [
    CommonModule,
    AntiHeroRoutingModule,
  ]
})
export class AntiHeroModule { }

在我们的anti-hero.module.ts文件中,我们想要确保声明了我们的智能组件和哑组件;否则,在编译时将会有错误。我们还想要检查是否导入了AntiHeroRoutingModule以使用路由。

让我们现在添加额外的样式来改善我们应用程序的用户界面。让我们看一下以下代码:

anti-hero-list.component.scss

table, th, td {
    border: 1px solid;
    border-collapse: collapse;
    border: 1px solid;
}

我们还在组件中添加了一些简单的 CSS 代码来美化我们的表格。现在,我们已经成功创建了AntiHeroListComponent。下一步是在ListComponent页面上使用这个展示组件。让我们看一下以下代码示例:

export class ListComponent implements OnInit {
  // sample data of anti-hero
  antiHeroes: AntiHero[] = [
    {
      firstName: "Eddie",
      lastName: "Brock",
      house: "New York",
      kownAs: "Venom"
    }
  ]
  headers: {headerName: string, fieldName: keyof
            AntiHero}[] = [
    {headerName: "First Name", fieldName: "firstName"},
    {headerName: "Last Name", fieldName: "lastName"},
    {headerName: "House", fieldName: "house"},
    {headerName: "Known As", fieldName: "kownAs"},
  ]
  constructor() { }
  ngOnInit(): void {
  }
  selectAntiHero(antiHero: AntiHero) {}
}

ListComponentTypeScript文件中,我们创建了标题的定义以及一个反英雄列表的示例列表,用于显示反英雄列表。这将是临时的,因为我们只是想测试我们的展示组件是否成功显示了反英雄列表。我们还创建了selectAntiHero()函数,以便在选择了特定的反英雄后进行实现。

让我们现在为AntiHeroList定义输入和输出属性。让我们看一下以下代码:

list.component.html

<!-- Dumb component anti hero list -->
<app-anti-hero-list [antiHeroes]="antiHeroes" [headers]="headers" (antiHero)="selectAntiHero($event)"></app-anti-hero-list>

现在,在ListComponent的 HTML 文件中,我们将headersantiHeroes绑定到app-anti-hero-list的属性上。我们还使用了selectAntiHero()函数来捕获事件,一旦antiHero发射了动作。

在成功实现我们的展示组件后,我们可以运行应用程序并在浏览器中打开应用程序。我们应该看到以下结果:

图 10.9 – AntiHeroList 展示组件

图 10.9 – AntiHeroList 展示组件

我们可以在结果中看到,我们的展示组件已经成功显示了来自父组件的数据。对于表单组件,我们将在下一章中实现其功能,因为创建表单将是一个不同的主题。

现在,我们已经了解了智能和愚笨组件的概念、结构和实现。在下一节中,我们将使用一个 UI 框架来帮助我们改进 Angular 应用程序的界面。

添加 Angular Material

我们已经使用 核心功能共享 架构在模块级别以及智能和愚笨架构在组件级别组织了我们的 Angular 应用程序。现在,我们准备通过为我们的组件添加样式来自定义和改进外观和 UI。我们都知道,从头开始编写 CSS 代码并开发基础样式对我们开发者来说又是一个挑战。这给我们带来了额外的努力,而不仅仅是关注代码的逻辑方面。这就是 Angular Material 拯救我们的时刻!

Angular Material 是由 Google 创建的一个库,它为 Angular 应用程序提供了一系列 UI 组件,例如表格、卡片、输入和日期选择器。这意味着我们不必从头开始为组件添加样式,因为来自材料库的组件列表已经准备好使用。

Angular Material 在其内部拥有广泛的增长组件。它提供了包含可用于 Angular 应用程序的组件的模块,这些组件可以导入到特定的应用程序模块中;组件是可重用的,并且易于根据其外观和感觉进行自定义,因为它们具有内置的我们可以使用的属性。

让我们在 Angular 项目中配置 Angular Material。

配置 Angular Material

在 Angular 项目中配置 Angular Material 很容易,因为它提供了一个脚本来安装 Angular Material 的所有依赖项。要安装 Angular Material,我们将执行以下命令:

ng add @angular/material

执行命令后,它将询问一些问题,然后安装资源:

  • 选择预构建的主题名称或“自定义”以创建自定义主题:Angular Material 提供预构建的主题,或者您可以配置您自己的自定义主题。

  • 设置全局 Angular Material 字体样式:选择 将应用 Angular Material 的全局字体样式。

  • 在我们的根模块中,有 BrowserAnimationsModule。当我们想要使用 Angular Material 的动画时,这是很重要的。

完成所有问题后,它将现在安装 Angular Material。这将为我们项目执行以下操作:

  1. 将依赖项添加到 package.json (@angular/material@angular/cdk)。

  2. Roboto 字体添加到 index.html 文件中:

    <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
    
  3. 将 Material Design 图标字体添加到 index.html 文件中:

    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    
  4. 添加以下 CSS 样式:

    • htmlbodyheight设置为100%

    • 设置Roboto为默认字体

    • 从主体中移除边距:

      html, body { height: 100%; }
      
      body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
      

在我们的应用程序中成功安装 Angular Material 后,我们现在可以准备在应用程序中使用这些组件。

实现 Angular Material

我们现在将在我们的 Angular 项目中实现 Angular Material 组件。正如我们之前提到的,Angular Material 提供了一系列我们可以用于构建应用程序的组件。在这个示例项目中,我们只定义我们将要使用的组件。让我们列出我们将要实现的组件。

按钮组件

使用材料设计增强的本地<button><a>元素。

我们可以使用以下代码导入按钮组件:

import {MatButtonModule} from '@angular/material/button';

下面是按钮组件的示例:

<div class="example-button-row">
    <button mat-raised-button>Basic</button>
    <button mat-raised-button
      color="primary">Primary</button>
    <button mat-raised-button
      color="accent">Accent</button>
    <button mat-raised-button color="warn">Warn</button>
    <button mat-raised-button disabled>Disabled</button>
    <a mat-raised-button href="
      https://material.angular.io/target=" _blank>Link</a>
 </div>

在前面的材料按钮代码示例中,我们可以看到我们正在使用材料设计的内置指令来更改按钮的样式和外观。示例代码将产生以下输出:

图 10.10 – 材料按钮的示例输出

图 10.10 – 材料按钮的示例输出

图标组件

此组件允许我们在应用程序中添加基于矢量的图标,并支持图标字体和 SVG 图标。

我们可以使用以下代码导入图标组件:

import {MatIconModule} from '@angular/material/icon';

下面是图标组件的示例:

<mat-icon color="primary">delete</mat-icon>
<mat-icon color="accent">fiber_new</mat-icon>
<mat-icon color="warn">pageview</mat-icon>

在前面的代码示例中,我们可以通过使用mat-icon组件来创建图标。它有几个输入属性,如颜色,允许我们自定义图标的颜色。示例代码将产生以下输出:

图 10.11 – 材料图标的示例输出

图 10.11 – 材料图标的示例输出

表格组件

此组件允许我们添加具有材料设计样式的数据表。材料表基于CDK 数据表的基础。有关如何实现 CDK 数据表的更多信息,请参阅 https://material.angular.io/cdk/table/overview 的文档。

我们可以使用以下代码导入表格组件:

import {MatTableModule} from '@angular/material/table';

下面是表格组件的示例:

<table mat-table [dataSource]="data" class="mat-elevation-z8">
  <!-- Position Column -->
  <ng-container matColumnDef="id">
    <th mat-header-cell *matHeaderCellDef> ID </th>
    <td mat-cell *matCellDef="let element">
     {{element.position}} </td>
  </ng-container>
  <!-- Name Column -->
  <ng-container matColumnDef="name">
    <th mat-header-cell *matHeaderCellDef> Name </th>
    <td mat-cell *matCellDef="let element">
      {{element.name}} </td>
  </ng-container>
  <tr mat-header-row *matHeaderRowDef="columns"></tr>
  <tr mat-row *matRowDef="let row; columns: columns;"></tr>
</table>c

在前面的示例中,我们可以看到表格使用了几个属性。第一个属性是dataSource属性,它将接受要显示的数据列表。下一个属性是matColumnDef,它定义了应包含在绑定到matHeaderRowDef属性的columns变量中的每个列的字段名。最后,matHeaderCellDefmattCelDef属性显示了实际的列名和相关的值,如下面的屏幕截图所示:

图 10.12 – 材料表格的示例输出

图 10.12 – 材料表格的示例输出

工具栏组件

此组件允许我们添加具有材料设计样式的工具栏。这通常用作标题、标题和导航按钮的容器。

我们可以使用以下代码导入工具栏组件:

import {MatToolbarModule} from '@angular/material/toolbar';

这里是工具栏组件的示例:

  <p>
    <mat-toolbar color="primary">
      <button mat-icon-button class="example-icon"
        aria-label="Example icon-button with menu icon">
        <mat-icon>menu</mat-icon>
      </button>
      <span>Angular CRUD</span>
    </mat-toolbar>
  </p>

在前面的代码示例中,我们使用mat-toolbar组件创建了一个工具栏元素。mat-toolbar组件使用内容投影,允许我们自定义其内容。示例代码将产生以下输出:

图 10.13 – 材料工具栏的示例输出

图 10.13 – 材料工具栏的示例输出

表单字段组件

此组件允许我们将材料组件包装起来,以应用文本字段样式,如下划线、提示信息和浮动标签。以下组件可以在<mat-form-field>内部使用:

  • input matNativeControl><textarea matNativeControl>:可以通过添加import {MatInputModule} from '@angular/material/input';来使用

  • <mat-select>:可以通过添加import {MatSelectModule} from '@angular/material/select';来使用

  • <mat-chip-list>:可以通过添加import {MatChipsModule} from '@angular/material/chips';来使用

这里是一个表单字段组件的示例:

<p>
  <mat-form-field appearance="legacy">
    <mat-label>Legacy form field</mat-label>
    <input matInput placeholder="Placeholder">
  </mat-form-field>
</p>
<p>
  <mat-form-field appearance="standard">
    <mat-label>Standard form field</mat-label>
    <input matInput placeholder="Placeholder">
  </mat-form-field>
</p>
<p>
  <mat-form-field appearance="fill">
    <mat-label>Fill form field</mat-label>
    <input matInput placeholder="Placeholder">
  </mat-form-field>
</p>
<p>
  <mat-form-field appearance="outline">
    <mat-label>Outline form field</mat-label>
    <input matInput placeholder="Placeholder">
  </mat-form-field>
</p>

在前面的代码示例中,我们使用mat-form-field组件创建了一个工具栏元素。mat-form-field组件应包含mat-label组件和一个带有matInput指令的输入元素。示例代码将产生以下输出:

图 10.14 – 材料表单字段的示例输出

图 10.14 – 材料表单字段的示例输出

有关 Angular Material 组件列表的更多信息,请参阅 https://material.angular.io/components 的文档。

现在我们已经列出了我们将在应用程序中使用的材料组件,让我们将材料设计应用到我们的组件上。

我们需要做的第一步是创建我们的材料模块。材料模块将被包含在共享模块中,这样我们就可以在整个应用程序中使用材料设计组件。要在我们的 Angular 应用程序中生成材料模块,我们将执行以下命令:ng g m material。在成功生成材料模块后,我们将添加 Angular Material 中必要的模块:

@NgModule({
  imports: [
    CommonModule,
    MatToolbarModule,
    MatIconModule,
    MatButtonModule,
    MatTableModule,
    MatFormFieldModule,
    MatSelectModule,
    MatInputModule,
  ],
  exports: [
    MatToolbarModule,
    MatIconModule,
    MatButtonModule,
    MatTableModule,
    MatFormFieldModule,
    MatSelectModule,
    MatInputModule,
  ]
})
export class MaterialModule { }

我们可以在前面的示例中看到,我们也导出了材料模块,因为我们将在应用程序的不同模块中使用它们。

现在我们已经导入了我们应用程序所需的模块,让我们转换组件。

导航栏组件

导航栏组件位于共享模块下。我们将使用工具栏材料来创建我们的导航栏组件。为此,我们将放置以下代码:

<p>
    <mat-toolbar color="primary">
      </button>
      <span>Angular CRUD</span>
    </mat-toolbar>
  </p>

在前面的示例中,我们使用了mat-toolbar元素来使用工具栏材料。我们还可以添加一个颜色属性来样式化工具栏,并在其中添加额外的元素。

我们还需要在SharedModule下导入MaterialModule,以便识别MatToolbarModule,它将输出以下内容:

图 10.15 – 实施 Material 后导航栏组件的外观

图 10.15 – 实施 Material 后导航栏组件的外观

反英雄列表组件

此组件位于反英雄模块下。我们将使用表格材料来创建我们的列表组件。为了实现这一点,我们将以下代码放置在anti-hero-list.component.html

<table mat-table [dataSource]="antiHeroes" class="mat-elevation-z8">
    <!-- Data for columns -->
    <ng-container *ngFor="let item of headers"
     [matColumnDef]="item.fieldName">
      <th mat-header-cell *matHeaderCellDef>
        {{item.headerName}} </th>
      <td mat-cell *matCellDef="let element">
        {{element[item.fieldName]}} </td>
    </ng-container>
    <!-- Actions for specific item -->
    <ng-container matColumnDef="actions">
        <th mat-header-cell *matHeaderCellDef>
          Actions </th>
        <td mat-cell *matCellDef="let element">
            <button (click)="selectAntiHero(element, 0)"
              mat-raised-button color="primary">
                <mat-icon>pageview</mat-icon> View
            </button>
            &nbsp;
            <button (click)="selectAntiHero(element, 1)"
              mat-raised-button color="warn">
                <mat-icon>delete</mat-icon> Delete
            </button>
        </td>
    </ng-container>
    <tr mat-header-row *matHeaderRowDef="headerFields">
    </tr>
    <tr mat-row *matRowDef="let row; columns:
      headerFields"></tr>
  </table>

我们将此放置在anti-hero-list.component.ts

export class AntiHeroListComponent implements OnInit {
  @Input() headers: Array<{headerName: string, fieldName:
    keyof AntiHero}> = [];
  @Input() antiHeroes: Array<AntiHero> = [];
  @Output() antiHero = new EventEmitter<{antiHero:
    AntiHero, action :TableActions}>();
  headerFields: string[] = [];
  ngOnInit(): void {
    this.getHeaderFields();
  }
  getHeaderFields() {
    this.headerFields = this.headers.map((data) =>
      data.fieldName);
    this.headerFields.push("actions");
  }
  selectAntiHero(antiHero: AntiHero, action: TableActions) {
    this.antiHero.emit({antiHero, action});
  }
}

在前面的示例代码中,我们仍然在我们的应用程序中使用了相同的变量;antiHeroes变量,它保存反英雄列表,现在绑定到dataSource属性,我们还迭代了headers属性以显示列名及其相关值。最后,我们创建了一个名为headerFields的新变量,它包含要显示反英雄项值的fieldName

我们还需要在AntiHeroModule下导入MaterialModule,以便它能够识别MatTableModule,它将产生以下输出:

图 10.16 – 实施 Material 后表格组件的外观

图 10.16 – 实施 Material 后表格组件的外观

命令栏组件

我们将在反英雄模块下创建一个新的哑组件。我们将执行ng g c anti-hero/components/anti-hero-command-bar命令,并将以下代码放置在anti-hero-command-bar.html

<p>
    <mat-toolbar>
        <button mat-raised-button color="primary"
          (click)="emitAction(0)">
            <mat-icon>fiber_new</mat-icon> Create
        </button>
        &nbsp;
        <button  mat-raised-button color="warn"
          (click)="emitAction(0)">
            <mat-icon>delete</mat-icon> Delete All
        </button>
    </mat-toolbar>
  </p>

我们将此放置在anti-hero-command-bar.ts

export class AntiHeroCommandBarComponent implements OnInit {
  @Output() action = new EventEmitter<CommandBarActions>()
  constructor() { }
  ngOnInit(): void {
  }
  emitAction(action: CommandBarActions) {
    this.action.emit(action);
  }
}

在前面的示例代码中,我们同样使用了工具栏模块来创建我们的命令栏组件。由于这是一个哑组件,我们应只向其父组件发出动作,而不保留任何依赖。在成功创建命令栏后,我们将得到以下输出:

图 10.17 – 实施 Material 后命令栏组件的外观

图 10.17 – 实施 Material 后命令栏组件的外观

现在,我们将通过在以下页面上放置组件来最终确定应用程序布局:

  • app.component.html

    <app-navbar></app-navbar>
    
    <div class="container">
    
        <router-outlet></router-outlet>
    
    </div>
    
  • list.component.html

    <!—Dumb component command bar 
    
    <app-anti-hero-command-bar>
    
    </app-anti-hero-command-bar>—-- Dumb component anti hero list -->
    
    <app-anti-hero-list [antiHeroe"]="antiHer"es" [header"]="head"rs"></app-anti-hero-list>
    

在成功实现前面的代码后,我们现在将拥有以下布局:

图 10.18 – 列表组件页面布局

图 10.18 – 列表组件页面布局

我们现在已经使用 Material Design 创建了我们的反英雄页面组件。在接下来的章节中,我们将实现动作按钮和表单组件的功能。

摘要

有了这些,我们已经到达了本章的结尾。让我们回顾一下你所学到的宝贵知识;你学习了 Angular 的概念和基础知识,同时了解了如何使用 Angular CLI 搭建 Angular 项目以及创建组件、指令和模块。你还学到了一些组织 Angular 组件、模块以及 Angular 项目其他部分的最佳实践。这将对项目的可维护性非常有用,尤其是对于企业应用来说。

在下一章中,我们将学习如何在 Angular 中构建响应式表单、基本表单控件以及如何分组表单控件。我们还将实现 FormBuilder 并验证表单输入。

第十一章:构建 Reactive Forms

在上一章中,我们已经学习了如何在模块和组件级别上构建我们的 Angular 应用程序结构,这促进了代码的可维护性,尤其是在企业应用程序中。我们将模块组织为三个类别:核心模块、共享模块和功能模块。我们还把组件分为两类:智能组件和哑组件,这区分了那些检索数据并具有依赖关系的组件和仅用于展示目的的组件。

我们还讨论了如何配置和实现 Angular Material,这是一个提供现成组件和基础样式的 UI 库,用于我们的 Angular 应用程序。

在本章中,我们现在将开始学习如何使用 Angular 中的响应式表单来构建表单。我们将了解表单组、表单控件和表单数组,并在我们的表单中创建验证。

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

  • 理解响应式表单

  • 基本表单控件

  • 表单控件的分组

  • 使用 FormBuilder 服务生成控件

  • 验证表单输入

技术要求

这里是本章完成版本的链接:

github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-11

理解响应式表单

一旦我们创建了 Angular 应用程序,@angular/forms 包的优势之一。有两种可用的方式来构建表单。这些是 模板驱动的表单响应式表单;它们各自拥有自己的表单扩展对开发者来说是有利的,因为这不需要在包下安装来创建表单。

同时,我们可以确保每个 Angular 应用程序使用单个库来构建表单。在本节中,我们将更多地关注如何在我们的应用程序中实现响应式表单,因为这是在 Angular 应用程序中开发表单时常用的方法,但在继续之前,让我们先讨论模板驱动方法的简介。

模板驱动的方案

如其名所示,模板驱动的表单是在模板(HTML)中声明和验证的表单。它使用 ngForm 指令,将 HTML 表单转换为模板驱动的表单,并创建一个顶级 FormGroup,而 ngModel 指令为表单元素创建一个 FormControl

要使用模板驱动的表单,我们必须将 FormsModule 导入到我们想要使用模板驱动的表单的模块中。在下面的代码示例中,我们将 FormsModule 导入了 app.module.ts 文件:

…
import { FormsModule } from '@angular/forms';
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

我们必须记住导入 FormsModule,因为我们的应用程序将无法识别 ngFormngModel 指令。

创建模板驱动的表单

创建模板驱动的表单的第一步是创建一个 HTML 表单模板。让我们看一下以下代码示例,以了解如何进行此操作:

<form>
  <p>
    <label for="email">Email </label>
    <input type="text" id="email" name="email">
  </p>
  <p>
    <label for="firstname">First Name</label>
    <input type="text" id="firstname" name="firstname">
  </p>
  <p>
    <label for="lastname">Last Name</label>
    <input type="text" id="lastname" name="lastname">
  </p>
  <button type="submit">Submit</button>
</form>

在前面的代码示例中,我们已经创建了我们的 HTML 表单模板,并添加了三个表单元素:电子邮件、名字和姓氏输入,这些将成为我们的表单控件。我们还将这些元素包含在一个<form>标签中。

在成功创建 HTML 表单模板后,此表单将自动转换为模板驱动的表单。我们不需要在form标签上添加ngForm指令,因为 Angular 会找到我们应用程序中的所有表单标签并将其转换为模板驱动的表单,尽管我们仍然可以使用ngForm指令将其分配给局部模板变量,以便我们访问ngForm指令的属性和方法。我们还可以使用该变量模板来提交我们的表单。让我们看一下以下代码示例:

<form #userForm="ngForm">

现在,我们可以通过为每个输入添加ngModel指令将我们的元素转换为表单控件;这允许我们跟踪每个表单元素的值、验证状态和用户交互。让我们看一下以下添加了表单控件的代码示例:

<form #userForm="ngForm">
  <p>
    <label for="firstname">First Name</label>
    <input type="text" name="firstname" ngModel>
  </p>
  <p>
    <label for="lastname">Last Name</label>
    <input type="text" name="lastname" ngModel>
  </p>
  <p>
    <label for="email">Email </label>
    <input type="text" id="email" name="email" ngModel>
  </p>
</form>

最后,我们将添加一个ngSubmit事件来提交表单组件的数据。我们将ngSubmit事件添加到form标签中,并在组件类中添加一个方法来接收数据。让我们看一下以下代码示例:

<!—HTML template -- >
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<!—typescript file (Component class) -- >
onSubmit(contactForm) {
    console.log(userForm.value);
 }

在前面的代码示例中,一旦用户点击了onSubmit()方法,它将在我们的控制台中显示表单控件值作为一个 JSON 对象;这将现在允许我们使用表单值来发送数据并实现业务逻辑。

在成功实施所有步骤之后,我们现在将有一个模板驱动的表单的最终模板:

<form #userForm="ngForm"(ngSubmit)="onSubmit(userForm)">>
  <p>
    <label for="firstname">First Name</label>
    <input type="text" name="firstname" ngModel>
  </p>
  <p>
    <label for="lastname">Last Name</label>
    <input type="text" name="lastname" ngModel>
  </p>
  <p>
    <label for="email">Email </label>
    <input type="text" id="email" name="email" ngModel>
  </p>
 <button type="submit">Submit</button>
</form>

何时使用模板驱动的表单

模板驱动的表单在 Angular 应用程序中非常灵活且易于实现。然而,这种方法有一些限制,可能会对可维护性产生影响;以下列出了使用模板驱动方法构建表单的最佳场景:

  • 当从 AngularJS 迁移到 Angular2 时,使用模板驱动的表单更容易,因为两者都使用ngModel指令。

  • 模板驱动的表单更适合于简单且小的表单,因为这些表单不需要复杂的验证,因为验证是在模板级别应用的。这可能会成为一个缺点,因为它将使得在更大的应用程序上同时维护验证变得困难。它对将验证应用于表单控件有一定的限制。

在上述第二种场景中,由于复杂表单可以用响应式表单更好地处理,特别是在实现验证方面,因此选择了响应式表单而不是模板驱动的表单。现在让我们了解响应式表单的概念。

响应式方法

响应式表单是构建 Angular 应用程序中表单的第二种方法;这是最常用的方法,因为它在处理复杂表单方面比模板驱动表单更有效。响应式表单也被称为模型驱动表单,在这种表单中,我们在组件类中定义表单的结构,而不是在模板中定义。

在我们将它绑定到我们的 HTML 表单之前,我们在类中定义了验证,这意味着逻辑和验证模式现在将从 HTML 模板中分离出来,并由组件的 TypeScript 部分维护。

使用响应式表单

我们使用响应式表单的第一步是导入 ReactiveFormsModule;这通常是在应用程序的根模块或共享模块中导入。ReactiveFormsModule 包含所有指令——例如 formGroupformControlName——这些指令将允许我们实现响应式表单;这也可以在 @angular/forms 包下找到。

在成功导入 ReactiveFormsModule 之后,下一步是创建我们的 HTML 表单模板,并使用 FormGroupFormControlFormArray 创建一个模型。这些是响应式表单的三个构建块,我们将使用它们来绑定表单模板,并在此处更详细地说明:

  • FormControl:这代表表单内的单个表单元素;它存储表单元素的值,允许我们从每个输入中检索数据。

  • FormArray:这是一个表单控件的集合,允许我们动态添加和删除控件以接受来自表单的更多值。

  • FormGroup:这是一个表单控件的集合;它也可以包含另一个表单组或表单数组。

假设我们有一个 HeroesComponent,我们将在类组件中编写以下代码来创建一个 FormGroup

userForm = new FormGroup({})

在前面的代码示例中,我们已经实例化了一个新的 FormGroup 并将其分配给 userForm 变量;这只是一个表单组,我们还没有向模型中添加表单控件。要添加表单控件,我们将放置以下代码:

userForm = new FormGroup({
  email: new FormControl(),
  firstName: new FormControl(),
  lastName: new FormControl(),
});

在前面的示例中,我们可以看到我们已经向 FormGroup 中添加了三个表单控件;现在我们可以将这些绑定到应用程序中的 HTML 表单模板,以捕获表单元素的值和状态。

现在让我们使用 formGroupformControlName 指令创建一个 HTML 表单模板:

<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
   <p>
    <label for="email">Email </label>
    <input type="text" id="email" name="email"
      formControlName="email">
  </p>
  <p>
    <label for="firstname">First Name </label>
    <input type="text" id="firstname" name="firstName"
      formControlName="firstname">
  </p>
  <p>
    <label for="lastname">Last Name </label>
    <input type="text" id="lastname" name="lastname"
      formControlName="lastName">
  </p>
  <p>
    <button type="submit">Submit</button>
  </p>
</form>

在示例代码中,我们可以看到模板几乎与模板驱动表单相同。唯一的区别是我们使用 formGroupformControlName 指令来绑定我们的表单。formGroup 指令用于绑定组件类中的 userFrom 表单组;另一方面,formControlName 指令用于绑定在 userForm 表单组中定义的表单控件的值和状态。最后,我们仍然使用 ngSubmit 事件在表单中的提交按钮被点击时调用一个方法。

我们现在已经在我们的应用程序中成功创建了一个响应式表单,但这仅涵盖了响应式表单的基本功能和概念。在本章的后续部分,我们将讨论表单控件和表单组的函数和功能。

基本表单控件

本节现在将讨论更多关于响应式表单中表单控件的概念。我们已经在上一节创建了一个表单控件的示例,但现在,我们将发现更多关于 Angular 中表单控件的功能和能力。

表单控件代表表单内的单个表单元素;它们存储表单元素的值,使我们能够检索每个输入的数据。这可以是 inputtextarea 或任何接受值的元素。在 Angular 中,可以通过添加 new FormControl('') 代码来实例化表单控件;我们可以看到它接受一个参数,该参数定义了控件的值。这些值可以是 null,因为表单控件可以被重置。

表单控件就像 JSON 对象的属性,但与 JSON 相比,每个控件都有其方法,这些方法将帮助我们控制、修改和验证值。

接下来,让我们来看看表单控件的不同方法和功能。

表单控件方法

让我们来看看我们可以用于修改控件的不同的表单控件方法和它们的参数:

  • setValue():设置控件新值的方法。

参数

  • value:分配给表单控件的新的值。

  • options:一个对象,定义了控件在值变化时如何传播更改和发出事件。以下是我们可以设置在表单控件中的选项:

    • onlySelf:当设置为 true 时,控件的变化不会影响其他控件。

    • emitEvent:当设置为 true 或未提供时,当表单控件的状体和值更新时,会发出 statusChangesvalueChanges 可观察对象。

    • emitModelToViewChange:当设置为 true 或未提供时,表单控件的变化将调用 onChange 事件来更新视图。

    • emitViewToModelChange:当设置为 true 或未提供时,表单控件的变化将调用 onModelChange 事件来更新视图。

下面是使用 setValue() 方法的代码:

setValue(value: TValue, options?: { onlySelf?: boolean; emitEvent?: boolean; emitModelToViewChange?: boolean; emitViewToModelChange?: boolean; }): void
  • patchValue():修补控件的值。patchValue 方法的参数与 setValue() 方法相同。

下面是使用 patchValue() 方法的代码:

patchValue(value: TValue, options?: { onlySelf?: boolean; emitEvent?: boolean; emitModelToViewChange?: boolean; emitViewToModelChange?: boolean; }): void
  • getRawValue():检索表单控件的值。这通常用于禁用表单控件。

下面是使用 getRawValue() 方法的代码:

getRawValue(): TValue
  • reset():将表单控件重置为其默认值。它还将控件标记为原始的且未被修改的。

参数

  • formState:定义控件的初始值和禁用状态。

  • options: 一个对象,定义了控件如何传播更改以及在值更改时发出事件。我们可以在表单控件中设置以下选项:

    • onlySelf: 当设置为 true 时,控件的更改不会影响其他控件。

下面是使用 reset() 方法的代码示例:

reset(formState?: TValue | FormControlState<TValue>, options?: { onlySelf?: boolean; emitEvent?: boolean; }): void
  • registerOnChange(): 注册一个监听器,一旦表单控件的值改变就发出事件。

参数

  • function: 当值改变时被调用的方法,如下所示:

    registerOnChange(fn: Function): void
    
  • registerOnDisabledChange(): 注册一个监听器,一旦控制器的 isDisabled 状态改变就发出事件。

参数

  • function: 当禁用状态改变时被调用的方法,如下所示:

    registerOnDisabledChange(fn: (isDisabled: boolean) => void): void
    

我们现在已经了解了可以在表单控件中使用的不同方法。现在,让我们看看一些不同用法的表单控件示例。

初始化表单控件

有几种方法可以初始化我们的表单控件。我们可以设置特定表单控件的值、禁用状态和验证器。让我们看看以下示例:

  • 使用初始值初始化表单控件

    const control = new FormControl('Hero!'); console.log(control.value); // Hero
    

在前面的代码示例中,我们使用默认值 Hero 实例化了一个表单控件。我们可以通过访问从 AbstractControl 继承的 value 属性来访问该值。

  • 使用初始值和禁用状态初始化表单控件

    const control = new FormControl({ value: 'Hero', disabled: true });
    
    // get the status
    
    console.log(control.value, control.status); //Hero,
    
                                                //DISABLED
    

在前面的代码示例中,我们使用对象值实例化了一个表单控件。这初始化了表单控件的值和禁用状态。我们可以通过访问从 AbstractControl 继承的 status 属性来访问该值。

  • 使用初始值和内置验证器数组初始化表单控件

    const control = new FormControl('', [Validators.email, Validators.required);
    
    // get the status
    
    console.log(control.status); // INVALID
    

在前面的代码示例中,我们使用一个空字符串值实例化了一个表单控件。使用验证器数组的第二个参数,这将返回一个无效状态,因为不应该有空值,而应该是一个有效的电子邮件格式。

重置表单控件

我们可以使用 reset() 方法重置表单控件的值和禁用状态。让我们看看以下不同用法的代码示例:

  • 将控制器重置到 特定值

    const control = new FormControl('Tony Stark')
    
    console.log(control.value); // Tony Stark
    
    control.reset('Iron Man');
    
    console.log(control.value); // Iron Man
    

在前面的代码示例中,我们使用了一个参数的 reset() 方法。该参数允许我们将表单控件重置到特定值。

  • 重置控制器到 初始值

    const control = new FormControl('Tony Stark')
    
    console.log(control.value); // Tony Stark
    
    control.reset();
    
    console.log(control.value); // Tony Stark
    

在前面的代码示例中,我们使用不带参数的 reset() 方法。这将使用初始值重置表单控件的值。

  • 使用值和禁用状态重置控制器

    const control = new FormControl('Tony Stark'); console.log(control.value); // Tony Stark console.log(control.status); // VALID
    
    control.reset({ value: 'Iron Man', disabled: true });
    
    console.log(control.value); // Iron Man console.log(control.status); // DISABLED
    

在前面的代码示例中,我们在调用 reset() 方法时使用了一个对象参数,并指出了表单控件的值和禁用状态。在这种情况下,它将禁用控件并将状态更改为 DISABLED

监听事件

在使用表单控件时,我们可以监听多个事件,例如值变化和状态变化。让我们看看以下代码示例,了解如何监听表单控件的事件:

  • 监听 值变化

    control = new FormControl('');
    
    this.control.valueChanges.subscribe((data) => {
    
          console.log(data); // Iron Man
    
        });
    
    this.control.setValue('Iron Man')
    

在前面的代码示例中,我们调用了具有 Observable 类型的 valueChanges 属性,我们可以订阅它来监听表单控件值的更改。在这种情况下,一旦我们设置了表单控件的值,valueChanges 属性将发出新的值。

  • 监听 状态变化

    control = new FormControl('');
    
    this.control.statusChanges.subscribe((data) => {
    
          console.log(data); // DISABLED
    
        });
    
    This.control.disable ()
    

在前面的代码示例中,我们调用了具有 Observable 类型的 statusChanges 属性,我们可以订阅它来监听表单控件状态的变化。在这种情况下,一旦我们禁用表单控件,这将发出新的状态,即 DISABLED

我们已经学习了关于表单控件的功能和特性;现在,我们将讨论如何使用表单组和表单数组来分组表单控件。

分组表单控件

本节将讨论如何在我们的应用程序中分组表单控件。表单包含多个相关控件,因此有必要将它们分组以获得更好的结构。响应式表单提供了两种分组表单控件的方法,如下所示:

  • 表单组:创建一个具有固定表单控件集合的表单。表单组还可以包含另一组表单组来处理复杂表单。

  • 表单数组:创建一个具有动态表单控件的表单。它可以添加和删除表单控件,同时还可以包含其他表单数组来处理复杂表单。

创建表单组

表单组允许我们通过组来控制表单控件的值和状态。我们还可以通过其名称在表单组内部访问单个表单控件。要创建一个表单组,请按照以下步骤进行:

  1. 假设我们有一个 HeroComponent;例如,第一步是从 @angular/forms 包中导入 FormGroupFormControl 类,如下所示:

    import { FormGroup, FormControl } from '@angular/forms';
    
  2. 下一步是创建一个 FormGroup 实例。在这个例子中,我们想要创建一个新的表单组,包含 firstNamelastNameknownAs 表单控件:

    export class HeroComponent {
    
      heroForm = new FormGroup({
    
          firstName: new FormControl(''),
    
          lastName: new FormControl(''),
    
          knownAs: new FormControl('')
    
    });
    
    }
    

在前面的代码示例中,我们创建了一个名为 heroForm 的新表单组。同时,我们在 heroForm 表单中添加了三个表单控件作为对象参数。

  1. 下一步是将我们的表单组实例与视图中的表单元素绑定:

    <form [formGroup]=" heroForm ">
    
      <label for="first-name">First Name: </label>
    
      <input id="first-name" type="text"
    
        formControlName="firstName">
    
      <label for="last-name">Last Name: </label>
    
      <input id="last-name" type="text"
    
        formControlName="lastName">
    
      <label for="known-as">Known As: </label>
    
      <input id="known-as" type="text"
    
        formControlName="knownAs"> </form>
    

在前面的代码示例中,我们使用了 formGroup 指令将我们的 heroForm 表单绑定到表单元素上。我们还必须使用 formControlName 指令将每个表单控件与输入元素绑定。

  1. 最后一步是获取整个表单组的值。我们将使用 ngSubmit 事件调用一个方法,并通过访问 value 属性来检索表单值,如下所示:

    //hero.component.html
    
    <form [formGroup]="heroForm" (ngSubmit)="onSubmit()">
    
    //hero.component.ts
    
    onSubmit() {
    
    // Will display value of form group in a form of JSON
    
     console.warn(this.heroForm.value);
    
    }
    

我们已经创建并绑定了一个示例表单组,但这只是一个简单的表单组,引入了控件的线性结构。现在,让我们创建一个包含表单组的表单组。

创建嵌套表单组

表单组也可以包含另一个表单组,而不是包含控件列表。想象一个具有另一个 JSON 对象值的属性的 JSON 对象。这不能通过简单的线性表单控件来处理,我们必须创建另一组表单组来处理这种类型的对象。

让我们按照以下步骤开发嵌套表单组:

  1. 我们将使用之前的表单示例;在这种情况下,我们希望在我们的表单中添加一个新的address属性,但不是将其作为一个新的表单控件实例,而是将其声明为一个新的表单组实例:

    export class HeroComponent {
    
     heroForm = new FormGroup({
    
          firstName: new FormControl(''),
    
    lastName: new FormControl(''),
    
    knownAs: new FormControl('')
    
    address: new FormGroup({
    
        street: new FormControl('')
    
        city: new FormControl('')
    
        country: new FormControl('')
    
    })
    
    });
    
    }
    

在前面的代码示例中,我们添加了一个address属性作为新的表单组实例。我们还在表单组内部添加了新的表单控件——即streetcitycountry。现在这被认为是一个嵌套的表单组。

  1. 下一步是将嵌套表单组与视图中的表单元素绑定:

      <div formGroupName="address">
    
            <label for="street">Street: </label>
    
            <input id="street" type="text"
    
              formControlName="street">
    
            <label for="city">City: </label>
    
            <input id="city" type="text"
    
              formControlName="city">
    
            <label for="country">Country: </label>
    
            <input id="country" type="text"
    
              formControlName="country">
    
        </div>
    

在前面的代码示例中,我们使用了formGroupName指令来绑定我们的地址表单组。请记住,这个元素应该位于heroForm表单组内部;我们还使用了formControlName指令来绑定嵌套表单组下的控件。现在,我们也可以再次使用ngSubmit事件,并调用value属性,就像在之前的示例中那样,以获取整个表单的值。

我们已经使用表单组创建了简单和复杂的表单。现在,让我们讨论另一种使用表单数组来分组控件的方法。

创建表单数组

表单数组很有用,特别是如果我们想在运行时添加或删除表单中的控件。这使我们能够在应用程序中拥有灵活的表单,同时处理更复杂的一组对象。要创建表单数组,让我们看看以下步骤:

  1. 我们将使用之前的表单示例;在这种情况下,我们希望在我们的表单中添加一个新的powers属性,并将其声明为一个新的FormArray实例:

    export class HeroComponent implements OnInit {
    
     powerFormArray: FormArray;
    
     constructor() {
    
        this.powerFormArray=
    
          this.heroForm.get("powers") as FormArray;
    
     }
    
    ngOnInit() {
    
        heroForm = new FormGroup({
    
            ... controls from previous example
    
            powers: new FormArray([])
    
       })
    
     }
    
    }
    

在前面的代码示例中,我们在heroForm表单组内部创建了一个新的FormArray实例。这个实例初始化时接受一个空数组,没有任何表单控件。我们还把这个表单数组的实例赋值给一个变量,以便我们在视图中访问这个数组。

  1. 下一步是创建可以添加和删除表单数组中表单控件实例的方法:

     addPower() {
    
        (this.form.get("powers") as FormArray).push(new
    
          FormControl());
    
      }
    
      deletePower(i: number) {
    
        (this.form.get("powers") as
    
          FormArray).removeAt(i);
    
      }
    

在前面的代码示例中,我们创建了两个方法,我们将使用这些方法来处理表单数组。addPower()方法允许我们在力量表单数组中添加一个新的表单控件实例;这个方法通过名称获取表单数组的实例,并推送一个新的表单控件实例。

另一方面,deletePower()方法通过名称获取表单数组的实例,并使用removeAt()方法和要删除的控件索引来删除特定的表单控件。

  1. 最后一步是将表单数组实例与视图中的表单元素绑定:

    <ng-container formArrayName="powers">
    
       <label for="tags">Tags</label>
    
       <div class="input-group mb-3" *ngFor="let _ of
    
         powerFormArray.controls; index as i">
    
          <input type="text" class="form-control"
    
             [formControlName]="i" placeholder="Power
    
              Name">
    
          <button (click)="deletePower(i)"
    
            class="btn btn-danger"
    
            type="button">Delete</button>
    
    </div>
    
          <button class="btn btn-info me-md-2"
    
            type="button" (click)="addPower()">
    
            Add</button>
    
    </ng-container>
    

在前面的代码示例中,我们使用formArrayName指令将力量绑定到视图中的表单数组上。我们还使用了ngFor指令来迭代表单数组内的所有控件;我们还需要获取每个控件的索引,并将其传递给我们的deletePower()方法。

在成功创建表单数组后,我们现在将有一个表单的视图:

图 11.1 – 带有表单组和表单数组的英雄表单

图 11.1 – 带有表单组和表单数组的英雄表单

我们已经成功使用表单组和表单数组创建了响应式表单。现在,我们将使用FormBuilder服务来简化我们应用程序中创建表单的语法。

使用FormBuilder服务生成控件

在上一节中,我们成功使用了表单组、表单数组和表单控件创建了响应式表单。然而,正如我们从语法中看到的,创建表单变得重复。我们总是在实例化新的表单控件、表单数组和表单组实例,这在较大的表单中并不理想。FormBuilder为这个问题提供了解决方案。

这是一个可以将它注入到我们的组件中以生成组、控件和数组而不需要实例化新对象的服务。要使用FormBuilder创建响应式表单,我们将遵循以下步骤:

  1. 我们将使用FormBuilder将上一节的表单进行转换。第一步是从@angular/forms导入FormBuilder服务到我们的组件中:

    import { FormBuilder } from '@angular/forms';
    
  2. 下一步是将FormBuilder服务注入到我们的组件中:

    export class HeroComponent implements OnInit {
    
     powerFormArray: FormArray;
    
    constructor(private fb: FormBuilder) {}
    
    ... code implementation
    
    }
    
  3. 最后一步是使用FormBuilder服务的FormBuilder方法创建和生成控件:

    export class HeroComponent implements OnInit {
    
     heroForm = this.fb.group({
    
          firstName: [''],
    
         lastName: [''],
    
         knownAs: [''],
    
         address:  this.fb.group({
    
            street: [''],
    
            city: [''],
    
            country: [''],
    
        }),
    
          powers: this.fb.array([])
    
    });
    
    constructor(private fb: FormBuilder) {}
    
    ... code implementation
    
    }
    

我们可以在前面的示例中看到,我们的表单结构与上一节中创建的表单相同。主要区别在于我们正在使用FormBuilder的方法来创建表单。我们使用了group()方法来生成表单组,array()方法来生成表单数组,以及一个空字符串值的数组来生成控件并设置其默认值。

此代码的输出将相同。FormBuilder方法主要用于使我们的响应式表单更加简洁和易读。现在,我们将讨论如何为我们的控件添加验证。

验证表单输入

在上一节中,我们已经创建并简化了我们的响应式表单,但我们要使我们的表单在接收数据时更加准确,同时为用户提供一个用户友好的体验,让他们能够轻松地知道每个控件的有效值。现在,我们将学习如何为我们的响应式表单添加验证。

在响应式表单中,我们直接将验证器作为参数添加到组件类中的表单控件,而不是在模板中作为属性添加。

内置验证器

Angular 提供了几个内置的验证函数,我们可以在我们的表单中直接使用。让我们看看其中的一些:

  • static min(min: number)—要求控件的值等于或大于给定的数字:

    form = this.fb.group({
    
      name: [10, [Validators.min(4)]]
    
    });
    
    console.log(this.form.status) // returns VALID
    
    static max(max: number) – requires the value of the control to be equal to or less than the given number.
    
    form = this.fb.group({
    
      name: [3, [Validators.max (4)]]
    
    });
    
    console.log(this.form.status) // returns VALID
    
  • static required(control: AbstractControl<any, any>)—控件不得有非空值:

    form = this.fb.group({
    
      name: ['test value', [Validators.required]]
    
    });
    
    console.log(this.form.status) // returns VALID
    
  • static requiredTrue(control: AbstractControl<any, any>)—控件必须有一个值为true的值:

    form = this.fb.group({
    
      name: [true, [Validators.requiredTrue]]
    
    });
    
    console.log(this.form.status) // returns VALID
    
  • static minLength(minLength: number)—用于数组和字符串,这要求值的长度应等于或大于给定的数字:

    form = this.fb.group({
    
      name: ['test', [Validators.minLength (4)]]
    
    });
    
    console.log(this.form.status) // returns VALID
    
  • static maxLength(maxLength: number)—用于数组和字符串,这要求值的长度应等于或小于给定的数字:

    form = this.fb.group({
    
      name: ['test', [Validators.maxLength (4)]]
    
    });
    
    console.log(this.form.status) // returns VALID
    

自定义验证器

除了内置的验证器之外,我们还可以创建自定义验证器,这对于我们的表单需要更复杂的验证和检查是有帮助的。

让我们看看以下示例自定义验证器:

import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
export function checkHasNumberValidator(): ValidatorFn {
    return (control: AbstractControl): ValidationErrors |
      null => {
      const error = /\d/.test(control.value);
      return error ? {hasNumbers: {value: control.value}} :
        null;
    };
}

在前面的代码示例中,我们创建了一个名为checkHasNumberValidator()的新验证器。这个验证器的主要用途是使包含数字的控件值无效。我们检索了分配了验证器的表单控件,然后我们测试了控件的值,如果正则表达式为true,则返回一个名为hasNumbers的自定义错误。

在成功创建自定义验证器之后,我们现在可以在我们的控件中使用它,如下所示:

heroForm = this.fb.group({
     firstName: ['', [checkHasNumberValidator]],
     lastName: ['', [checkHasNumberValidator]],
     knownAs: [''],
     address:  this.fb.group({
        street: [''],
        city: [''],
        country: [''],
    }),
      powers: this.fb.array([])
});

在前面的示例代码中,我们希望我们的名字和姓氏字段仅限于字母。在这种情况下,我们将checkHasNumberValidator作为firstNamelastName控件的第二个参数使用。

让我们继续进行响应式表单的实现。

在我们的项目中实现响应式表单

现在,我们已经成功地学习了如何使用FormBuilder开发响应式表单,并且同时为我们的控件添加了验证。现在,我们将这些响应式表单实现到我们的项目中。

第一步是创建我们的表单组实例。在anti-hero/components/anti-hero-form文件下,我们将使用类组件中的FormBuilder服务创建我们的表单组,同时,我们将在我们的 HTML 模板中创建我们的表单元素。按照以下步骤进行:

  1. 通过执行以下代码创建一个表单组实例:

    export class AntiHeroFormComponent implements OnInit {
    
      @Input() selectedId = "";
    
      @Input() actionButtonLabel: string = 'Create';
    
      form: FormGroup;
    
      constructor(private fb: FormBuilder) {
    
        this.form = this.fb.group({
    
          id: [''],
    
          firstName: [''],
    
          lastName: [''],
    
          house: [''],
    
          knownAs: ['']
    
        })
    
       }
    
    <! – Please refer to the anti-hero-form.component.ts file in the GitHub repo, Thank you ->
    
    }
    
  2. 然后,创建一个 HTML 模板,如下所示:

    <! – Please refer to the anti-hero-form.component.html file in the GitHub repo, Thank you ->
    

在我们component类的实现代码中,我们首先创建了一个表单组对象。我们添加了几个类似于反英雄对象属性的控件。我们的目标是使用相同的表单来创建和更新反英雄的详细信息。在这种情况下,我们还向我们的类中添加了几个Input()绑定和方法,以帮助表单识别当前正在执行的操作:

  • selectedId: 这将接受反英雄的 ID,如果操作被更新。

  • actionButtonLabel: 这将根据正在执行的操作(CreateUpdate)而改变。

  • checkAction(): 如果 selectedId 有值,这将更改 actionButtonLabel 的值为 "Update"

  • patchDataValues(): 这将用于修补表单控件中选定反英雄的值。

  • emitAction(): 将表单值和操作值发射到父组件。

  • clear(): 调用 reset() 方法来清理表单。

  1. 下一步是在我们的表单页面组件中使用反英雄表单。在 anti-hero/pages/form 文件下,我们将把反英雄表单放入 HTML 模板中,同时检查当前路由是否包含所选反英雄的 ID 参数。以下是步骤:

    1. 将反英雄表单添加到 HTML 模板中:
    <app-anti-hero-form [selectedId]="id"></app-anti-hero-form>
    
    1. 添加激活的路由以捕获 ID:
    export class FormComponent implements OnInit {
    
      id = "";
    
      constructor(private router: ActivatedRoute) { }
    
      ngOnInit(): void {
    
        this.id = this.router.snapshot.params['id'];
    
      }
    
    }
    
  2. 下一步现在是为我们的页面表单组件创建一个路由。在 anti-hero-routing.module.ts 文件中,我们将向我们的路由添加以下条目:

      {
    
        path: "form",
    
        children: [
    
          {
    
            path: "",
    
            component: FormComponent
    
          },
    
          {
    
            path: ":id",
    
            component: FormComponent
    
          }
    
        ]
    
      },
    

在前面的代码示例中,我们创建了两个重定向到 FormComponent 的路由。第一个路由是用于 create 操作的,它有一个 baseURL/anti-heroes/form 路由,第二个路由是用于 update 操作的,它有一个 baseURL/anti-heroes/form/:id 路由。这意味着我们为两个操作使用了相同的组件,而 id 参数作为我们当前正在执行的操作的指示器。

  1. 最后一步是为 list 组件添加导航。我们将添加几个方法,这些方法将调用导航方法,根据所选操作将我们重定向到表单组件,如下所示:

    • list.component.html:

      <!-- Dumb component command bar -->
      
      <app-anti-hero-command-bar (action)="executeCommandBarAction($event)"></app-anti-hero-command-bar>
      
      <!-- Dumb component anti hero list -->
      
      <app-anti-hero-list [antiHeroes]="antiHeroes" list.component.ts:
      
      

      selectAntiHero(data: {antiHero: AntiHero, action: TableActions}) {

      
      

      <! – 请参阅 GitHub 仓库中的 list.component.ts 文件,谢谢->

      
      

      this.router.navigate(['anti-heroes', 'form',

      
      

      data.antiHero.id]);

      
      

      }

      
      

      executeCommandBarAction(action: CommandBarActions) {

      
      

      switch(action) {

      
      

      case CommandBarActions.Create: {

      
      

      this.router.navigate(["anti-heroes", "form"]);

      
      

      return;

      
      

      }

      
      

      case CommandBarActions.DeleteAll: {

      
      

      return;

      
      

      }

      
      

      default: ""

      
      

      }

      
      

      }

      
      

完成所有步骤后,我们现在将得到以下表单输出:

图 11.2 – 创建反英雄的表单 UI

图 11.2 – 创建反英雄的表单 UI

摘要

通过这一点,我们已经到达了本章的结尾;让我们回顾一下你所学到的宝贵内容。你已经了解了 Angular 反应式表单的概念和实现,我们实现了 FormGroupFormBuilderformControlName 指令来绑定输入值以捕获表单中的数据。我们还讨论了如何将表单控件分组以绑定嵌套属性,并在我们的反应式表单中创建表单数组。这主要适用于我们想要显示的某些对象具有数组值的情况。

同时,我们希望接受用户输入的一列条目。最后,我们还学习了如何实现表单控件的有效性验证来处理和验证用户输入,这将有益于用户体验并有助于避免意外错误。

在下一章中,我们将学习 Angular 应用程序中状态管理的概念和实现;我们将讨论 Redux 模式以及 NgRx 库的想法,从它们如何改进应用程序架构的角度进行讨论。

第十二章:使用 NgRx 管理状态

在上一章中,我们学习了响应式表单的概念和特性。我们实现了 FormGroupFormBuilderformControlName 来绑定应用中表单元素中的输入值。我们还讨论了如何在响应式表单中分组表单控件以绑定嵌套属性并创建表单数组。

之后,我们学习了如何为表单控件实现验证以处理和验证用户输入。这将有益于用户体验,并帮助我们避免意外错误。

在本章中,我们将向我们的应用添加一个新的食谱,并学习如何实现状态管理,特别是 NgRx,以处理我们的 Angular 应用中的数据。

本章将涵盖以下主题:

  • 理解在管理大型应用程序状态中的复杂性

  • 状态管理和全局状态

  • 开始使用并设置 NgRx

  • 编写动作

  • 编写效果

  • 编写还原器

  • 在组件中编写选择器并使用选择器分发

  • 配置存储

技术要求

以下链接将带您到本章代码的完成版本:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-12/superheroes

理解在管理大型应用程序状态中的复杂性

前端应用程序中的数据管理非常重要,就像后端应用程序和数据库中的数据管理一样重要。随着我们向应用添加更多功能,我们知道我们 Angular 项目内部工作的组件、模块和服务数量也在增长。

这也意味着应用程序中的数据流正在增长并变得复杂。复杂的数据流可能导致难以维护的应用程序、不同组件中的不一致和分散的状态,以及导致复杂代码结构的嵌套输入和输出绑定。由于在 Angular 中管理数据时可能出现的这些问题,因此引入了称为 状态管理 的解决方案,作为维护前端应用程序数据的标准解决方案。

状态管理是一个扩展或库,主要用于管理和处理前端应用程序中的数据。它引入了一种模式,其中所有使用的数据都存储在一个大对象中,该对象充当整个应用程序的状态。这个概念也被称为 单一事实来源。在这种情况下,无论我们向应用中添加多少组件或服务,总有一个单一的对象,我们可以从中检索所需的数据。您可以把这个状态比作前端应用程序的数据库。

在进行状态管理之前,让我们比较一下无状态管理和有状态管理的数据流。

无状态管理的数据流

在 Angular 中,数据流始于服务。我们调用服务中的端点来检索和管理我们应用程序所需的数据。随着功能的增加,添加和调用的服务数量也会增加,导致数据流更加复杂。让我们看看没有状态管理的数据流图示:

图 12.1 – 没有状态管理的数据流

图 12.1 – 没有状态管理的数据流

在先前的图形说明中,我们可以看到我们有四个不同的服务,它们负责管理不同类型的数据;每个功能都从这些服务中检索所需的数据。

如我们所见,由于功能在不同的源或服务中检索数据,检索是分散的。这导致多个数据流,并且随着需要更多服务和功能,它们可能会变得更大。

这也可能导致每个组件持有的数据不一致,因为数据源来自不同的服务,导致应用程序中出现一些意外的错误。现在,让我们看看有状态管理的数据流。

有状态管理的数据流

在先前的数据流中,我们看到数据流没有得到优化,这可能导致我们应用程序中出现几个问题,因为数据流的流向非常复杂。在我们实现 Angular 应用程序的状态管理时,我们将有以下数据流:

图 12.2 – 有状态管理的数据流

图 12.2 – 有状态管理的数据流

在先前的图形说明中,我们可以看到我们的应用程序中的所有服务都由状态管理处理。我们仍然使用服务从数据库中检索数据。这里的一个重要区别是,我们的功能现在是在状态中而不是直接在服务中访问所有检索到的数据。

这允许数据单向流动,并且所有在应用程序中使用的数据都有一个单一来源。采用这种方法,可以避免状态的不一致性、可能的错误和多次 API 调用。

有了这些,我们已经了解了在开发应用程序中状态管理的重要性,尤其是在开发企业级应用程序时。

现在,让我们讨论更多关于状态管理和全局状态的概念。

状态管理和全局状态

如前所述,状态管理是一个扩展或库,它允许我们以单向方式管理应用程序中的数据流。

这是因为全局状态,它将包含所有数据。为了理解状态管理是如何工作的,让我们讨论状态管理的各个构建块。

全局状态/存储

全局状态,也称为store,是状态管理中最关键的因素。全局状态的主要责任是存储所有通过 API 检索到的数据或简单地存储在应用程序中使用的所有数据。

这意味着 Angular 应用程序中的所有组件都将从全局状态中检索数据。将其视为 Angular 应用程序的数据库,但以 JSON 对象的形式,我们可以获取每个属性作为切片。

Actions

dispatch()函数,它有助于识别应该执行哪些事件,例如修改状态或调用 API 检索数据。

动作只是简单的接口;type属性标识了发出的动作。这个简单的字符串只是动作的定义,我们可以为动作添加属性,以添加我们需要的 API 或状态中的数据。

让我们看看动作接口的一个例子:

{
type: '[Blog] Add Blog',
title: string;
author: string;
content: string;
}

在前面的例子中,当创建一个新的博客时,会发出这个动作。当titleauthorcontent作为附加元数据添加时,会调用这个动作,以便传递给 effect 或 reducer。

Reducers

Reducers是状态管理的决策者。它们是决定基于动作类型处理哪些动作的那部分。Reducers 也是可以改变状态值的那部分。

Reducers 是纯函数,并同步处理状态转换;让我们看看一个 reducer 的例子:

export const blogReducer = createReducer( initialState,
  on(BlogActions.addBlog, (state, {blog})=> ({ ...state,
    blogs: […state.blogs, blog]}))
);

在前面的例子中,我们为addBlog()动作创建了一个 reducer。这允许我们在发出addBlog()动作后,在博客的状态中添加一个新的博客对象。

我们将在本章的“编写 reducer”部分更详细地讨论 reducer。

Selectors

选择器是允许我们从 store 中检索数据片段的纯函数。这是一个变化检测机制,当状态值发生变化时,它会比较状态的部分,并且只有在检测到变化时才发送状态。这是一种称为记忆化的实践。

选择器用于组件中获取 UI 中使用的数据。它返回一个 Observable,该 Observable 监听状态变化。

让我们看看一个选择器的例子:

// selector for list of blogs
// blog.selector.ts
export const selectBlogList = (state: AppState) => state.blogs;
// blog component
// blog.component.ts
blogs$ = this.store.select<Array<Blog>(selectBlogList);
   this.blogs$.subscribe(data => {
      console.log(data) // list of blogs from the state;
    });

在前面的例子中,我们为博客切片创建了一个选择器,以及一个返回blogs元数据的函数,称为selectBlogList()。我们在blog组件中将此函数用作select函数的参数,通过订阅选择器来检索数据。订阅将在博客切片的值发生变化时发出。我们将在本章的下一节更详细地讨论选择器。

Effects

副作用是NgRx库使用的特定元素;这是一个由RxJs驱动的副作用模型,它处理外部交互,如 API 调用、WebSocket 消息和时间相关事件。使用副作用,我们可以将我们的组件从与外部源交互中隔离出来,并减少它们的职责。让我们来看一个具有和没有副作用的应用程序的比较。

没有副作用的应用程序

以下是一个没有副作用的应用程序:

export class BlogPageComponent {
  movies: Blog[];
  constructor(private blogService: MoviService) {}
  ngOnInit() {
    this.blogService
        .getAll()
        .subscribe(blogs => this.blogs = blogs);
  }
}

在前面的代码示例中,我们有一个具有多个职责的组件,如下所示:

  • 管理博客的状态(组件有自己的博客状态)

  • 使用博客服务调用外部 API 以获取博客列表

  • 在组件内部修改博客的状态

这意味着每个具有服务依赖的组件也有其数据状态。现在,让我们来看一个具有副作用的应用程序示例。

具有副作用的应用程序

以下是一个具有副作用的应用程序:

export class BlogsPageComponent {
  blogs$: Observable<Blog[]> = this.store.select(state =>
    state.blog);
  constructor(private store: Store<{ blogs: Blog[] }>) {}
  ngOnInit() {
    this.store.dispatch({ type: '[Blog Page] Load Blog'});
  }
}

在前面的代码示例中,我们可以看到我们的博客页面组件的代码已经减少,同时,其职责也更加简单。现在,组件的职责是分派一个动作,这将允许副作用识别需要调用哪个服务来检索数据。

让我们来看一个博客状态的示例副作用:

@Injectable()
export class BlogEffects {
  loadBlogs$ = createEffect(() => this.actions$.pipe(
ofType('[Blog Page] Load Blog'),
  mergeMap(() => this.blogService
     .getAll().pipe(
       map(blogs => ({ type: '[Blogs API] Blogs Loaded
                      Success', payload: blogs })),
       catchError(() => EMPTY)
     ))
   )
);
  constructor(private actions$: Actions,
              private blogService: BlogService) {}
}

在前面的代码示例中,我们创建了一个名为loadBlogs$的新副作用。这个副作用负责从博客服务中调用getAll()方法来从外部端点检索博客列表。同时,它还负责分派一个新动作,将检索到的博客列表传递给 reducer 以修改存储。我们将在本章的下一节讨论如何编写副作用。

通过这样,我们已经看到了构成状态管理的所有构建块。让我们来看一个详细的可视化说明,展示在状态管理中数据是如何流动的:

图 12.3 – 使用 NgRx 状态管理时数据流的情况

图 12.3 – 使用 NgRx 状态管理时数据流的情况

在前面的图形说明中,我们可以看到我们的 UI 组件只有一个职责,那就是分派动作。如果动作需要调用 API,将调用副作用来通过服务调用 API,并在获取响应数据后,副作用还将分派一个动作来调用匹配动作类型的 reducer 以修改存储。

另一方面,如果从组件发送的动作将改变状态,它将不需要任何副作用,并调用匹配动作类型的 reducer。存储中的所有更改都将被 selectors 检测到,并发出最新的状态,用于 UI 组件。

通过这样,我们已经了解了围绕状态管理所需的概念以及数据如何与状态管理的构建块一起流动。在下一节中,我们将学习如何设置和配置 Angular 中最著名的状态管理库之一:NgRx

开始使用并设置 NgRx

要使用 NgRx 状态管理,我们必须安装 @ngrx/store 库;这将包含所有允许我们配置存储和创建还原器和操作的函数。

要安装 @ngrx/store 库,我们必须执行以下命令:

ng add @ngrx/store

此前命令将执行以下步骤:

  1. 通过添加 @ngrx/store 到依赖项来更新 package.json

  2. 运行 npm install 来安装依赖项。

  3. 通过将 StoreModule.forRoot(reducers, {}) 添加到 imports 数组来更新 src/app/app.module.ts

在执行此命令之前,请确保 @ngrx/store 的版本与您的 Angular 版本一致;在我们的项目中,我们的 Angular 版本是 13.3.0 版本,这意味着我们需要使用 @ngrx/store 的 13 版本。

也有可用的标志,允许我们使用自定义设置安装 @ngrx/store。以下是我们可以使用的标志列表:

  • --path:指定您想要导入 StoreModule 的模块的路径。

  • --project:在 angular.json 中定义的项目名称。

  • --module:包含您想要导入 StoreModule 的模块的文件名。

  • --minimal:如果设置为 true,则提供根状态管理的最小设置。它在模块中导入 StoreModule.forRoot() 并使用空对象。

  • --statePath:这是创建状态的位置。

  • --stateInterface:定义状态的接口。

将 NgRx 添加到我们的 Angular 项目中

现在,让我们将其添加到我们的 Angular 项目中。我们只想使用最小设置,因为我们将会逐步添加还原器和存储。在成功执行 ng add @ngrx/store 命令后,我们的项目中将会有以下更改:

// app.module.ts
 imports: [
    … other modules
    StoreModule.forRoot({}, {}),
  ],
// package.json
"dependencies": {
     … other dependencies
    "@ngrx/store": "¹³.2.0",
  },

在前面的代码示例中,我们可以看到 StoreModule.forRoot() 已添加,但没有添加任何对象;这意味着我们最初导入存储时没有还原器。

通过这样,我们已经成功地在我们的 Angular 项目中安装了 @ngrx/store。现在,我们将安装另一个扩展来帮助我们调试状态。

安装 NgRx DevTools

NgRx DevTools 是一个非常有价值的扩展,它为存储提供了开发者工具和仪表。它允许我们检查状态值,实现时间旅行调试,并可视地表示我们存储中数据的先前和当前值。

我们必须执行以下命令来在我们的 Angular 项目中安装 NgRx DevTools:

ng add @ngrx/store-devtools

在成功执行此命令后,我们的项目中将实施以下更改:

// app.module.ts
imports: [
… other modules
// Instrumentation must be imported after importing
// StoreModule (config is optional)
StoreDevtoolsModule.instrument({
  maxAge: 25, // Retains last 25 states
  logOnly: environment.production, // Restrict extension to
                                   // log-only mode
  autoPause: true, // Pauses recording actions and state
                   // changes when the extension window is
                   //not open
}),
],

在前面的代码示例中,我们可以看到已添加了一个名为StoreDevtoolsModule的新模块;这将允许我们在本地运行我们的应用时使用 DevTools。

使用 DevTools 的下一步是将 Redux 扩展添加到我们的浏览器中。要添加此扩展,请访问以下链接之一以获取您相应浏览器的链接:

在将此扩展添加到您首选的浏览器后,使用导入的StoreDevToolModule模块运行您的 Angular 项目将自动被此扩展检测到。它将提供一个查看状态的界面:

图 12.4 – Redux DevTools 扩展的界面

图 12.4 – Redux DevTools 扩展的界面

前面的截图显示我们的 Redux DevTools 扩展已被激活;一旦我们在浏览器标签中打开我们的 Angular 项目,我们将在编写代码时看到更多 Redux DevTools 的实际应用。

现在我们已经配置了我们的存储并在我们的应用中安装了 NgRx DevTools,我们就可以为我们的状态管理编写构建块了。

编写动作

状态管理的第一个构建块是我们将编写我们的动作。在编写动作时,我们可以遵循一些规则,以确保我们的应用中有良好的动作:

  • 前置: 编写动作应该在开发功能之前进行。这让我们对应用中应实现的内容有一个概述。

  • 划分: 我们应该始终根据事件源和相关数据对动作进行分类。

  • 众多: 编写更多动作不是问题。动作越多,对应用流程的概述就越好。

  • 事件驱动: 在分离事件描述及其处理方式时,捕获事件

  • 描述性: 始终使用类型元数据提供有意义的信息。这有助于调试状态。

让我们看看一个示例动作,该动作将设置我们状态中的博客列表:

import { createAction, props } from '@ngrx/store';
export const setBlogList = createAction(
 '[Blog] Set Blog List',
  props<{ blogs: ReadonlyArray<Blog> }>(),
);

在前面的代码示例中,我们使用了createAction()函数来创建我们的动作。createAction()函数还返回一个返回动作接口对象的函数;在这种情况下,它将返回"[Blog] Set blog list"作为动作类型,以及博客数组作为附加属性。

要分发动作,我们将使用dispatch()函数,并将setBlogList作为参数:

ngOnInit() {
     store.dispatch(setBlogList ({ blogs: this.blogs}));
}

在项目中实现动作

我们项目的首要目标是创建整个周期,从我们的 API 获取反英雄列表并将其放置在我们的存储中。第一步是获取我们需要的操作;让我们看看为这个功能应该创建的两个操作:

  • getAntiHeroList: 此操作将从我们 Spring 项目提供的外部 API 检索博客列表。

  • setAntiHeroList: 此操作将检索到的博客列表放置在我们的存储中。

现在我们已经确定了我们将创建的操作列表,在 anti-heroes 文件夹中,我们将创建一个 state/anti-hero.actions.ts 文件,我们将放置所有的操作。

让我们将以下代码放置在 anti-hero.actions.ts 文件中:

import { createAction, props } from '@ngrx/store';
import { AntiHero } from '../models/anti-hero.interface';
export enum AntiHeroActions {
  GET_ANTI_HERO_LIST = '[Anti-Hero] Get Anti-Hero list',
  SET_ANTI_HERO_LIST = '[Anti-Hero] Set Anti-Hero list',
}
export const getAntiHeroList = createAction(
    AntiHeroActions.GET_ANTI_HERO_LIST,
);
export const setAntiHeroList = createAction(
  AntiHeroActions.SET_ANTI_HERO_LIST,
  props<{ antiHeroes: ReadonlyArray<AntiHero> }>(),
);

在前面的代码示例中,我们创建了两个用于获取和设置反英雄列表的操作。第一个操作 getAntiHeroList 有一个单一参数,即类型。这个不需要任何额外的属性,因为这个操作将只通过使用效果调用 API 来检索反英雄列表。

另一方面,第二个操作 setAntiHeroList 有两个参数:类型和一个额外的属性称为 antiHero。这将通过使用 reducer 设置存储中检索到的反英雄列表的值。

在我们的代码中将动作定义枚举也是一项非常好的实践,因为这将帮助我们避免在使用定义的其他部分应用程序时出现打字错误。

通过这样,我们已经成功创建了所需的反英雄列表功能操作。现在,我们将讨论如何编写将用于调用 API 和检索反英雄列表的效果。

编写一个效果

在前面的部分中,我们成功创建了我们的操作。现在,我们将制作负责调用我们的外部 API 的效果。

效果不包括在 @ngrx/store 库中;我们将安装一个单独的库,这将允许我们使用效果函数。

要在我们的应用程序中安装效果,我们必须执行以下命令:

ng add @ngrx/effects

前面的命令将执行以下步骤:

  1. 使用 @ngrx/effects 依赖项更新 package.json 文件。

  2. 运行 npm install 以安装添加的依赖项。

  3. EffectsModule.forRoot() 添加到您的 app.module.ts 文件的 imports 数组中。

有一些标志可供使用,允许我们使用自定义安装 @ngrx/effects;以下是我们可以使用标志的列表:

  • --path: 指定您想要导入 EffectsModule 的模块的路径。

  • --project: 在 angular.json 中定义的项目名称。

  • --skipTests: 当设置为 false 时,这将创建一个测试文件。

  • --module: 包含您想要导入 EffectsModule 的模块的文件名。

  • --minimal: 如果设置为 true,则提供根效果的最低设置。它在模块中导入 EffectsModule.forRoot() 并使用空对象。

  • --group: 在 effects 文件夹内对 effects 文件进行分组。

在成功将效果依赖添加到我们的应用程序后,我们可以创建我们的效果。在anti-heroes/state文件夹下,我们必须创建一个名为anti-hero.effects.ts的新文件。我们需要做的第一件事是创建一个带有@Injectable注解的类:

@Injectable()
export class AntiHeroEffects {
  constructor(
    private actions$: Actions,
    private antiHeroService: AntiHeroService,
    private router: Router
  ) {}
}

在前面的代码示例中,我们可以看到效果也是服务类,并且可以被其他服务注入;我们已经将以下服务注入到AntiHeroEffects中:

  • Actions: 来自@ngrx/effects的服务,它返回一个我们可以分配类型的可观察对象。这将作为动作分发时的标识符。

  • AntiHeroService: 我们创建的服务,其中包含我们反英雄的外部 API,位于anti-hero/services/anti-hero.service.ts

  • Router: 用于 API 调用后进行重定向。

在创建我们的AntiHeroEffect类并注入我们的服务后,我们可以开始制作我们的效果。我们首先需要考虑的是我们需要什么类型的效果来获取反英雄,因为我们有GET_ANTI_HERO_LISTSET_ANTI_HERO_LIST动作。

我们应该创建一个类型为GET_ANTI_HERO_LIST的效果,并且可以调用AntiHeroService中的getAntiHeroes()函数。

要创建这个 API,我们可以编写以下代码:

import { Actions, createEffect, ofType } from '@ngrx/effects';
getAntiHeroes$ = createEffect(() => {
    return this.actions$.pipe(
        ofType(AntiHeroActions.GET_ANTI_HERO_LIST),
        mergeMap(() => this.antiHeroService.getAntiHeroes()
        )
    }, {dispatch: true}
  );

在前面的代码示例中,我们使用了createEffect()函数来创建我们的效果;这返回一个有两个参数的动作:

  • ofType(AntiHeroActions.GET_ANTI_HERO_LIST): 第一个参数使用ofType操作符,它定义了效果的动作类型。这意味着如果GET_ANTI_HERO_LIST动作被分发,这个效果将被调用。

  • mergeMap(() => this.antiHeroService.getAntiHeroes(): 第二个参数使用mergeMap操作符,这将允许我们调用getAntiHeroes()函数来调用端点。

因此,我们有了针对GET_ANTI_HERO_LIST动作的效果,但这还不完整。在获取到反英雄列表后,我们希望分发另一个动作来设置我们状态中的反英雄列表。为此,我们可以使用以下代码:

  mergeMap(() => this.antiHeroService.getAntiHeroes()
          .pipe(
            map(antiHeroes => ({ type: AntiHeroActions.SET_ANTI_HERO_LIST, antiHeroes })),
            catchError(() => EMPTY)
          ));

在前面的代码中,我们向mergeMap操作符添加了一个管道;这调用一个返回({ type: AntiHeroActions.SET_ANTI_HERO_LIST, antiHeroes })map操作符。这将分发另一个类型为SET_ANTI_HERO_LIST的动作,并从 API 获取的反英雄列表中包含额外的antiHeroes对象。

我们获取反英雄列表的效果已经完成。最后一步是将AntiHeroEffects添加到我们的effects模块中。正如我们可能记得的,我们的anti-heroes模块是懒加载的,这意味着我们不会将AntiHeroEffects添加到app.module.ts文件中的EffectsModule.forRoot([]);否则,我们需要在anti-hero.module.ts文件的导入中添加EffectsModule.forFeature([AntiHeroEffects])。这意味着这个effects类只在这个模块中使用。

通过这样,我们已经成功配置并创建了我们的效果以用于反英雄列表功能。在下一节中,我们将编写将修改我们状态的 reducer。

编写 reducer

NgRx 状态是不可变对象;我们不能通过直接赋值来修改它们的值,我们唯一能改变它们状态的方式是通过 reducer。

Reducer 有不同的部分需要我们实现,如下所示:

  • 定义状态属性的接口或类型

  • 由初始状态和当前动作组成的参数

  • 处理基于派发动作的状态变化的函数列表

我们将在anti-heroes/state/anti-hero.reducers.ts文件下创建这些 reducer 部分。

状态接口

状态接口定义了状态的结构;这包含属性或状态的切片。在我们的应用中,我们需要一个属性来保存反英雄列表。

为了实现接口,我们可以使用以下代码:

export interface AntiHeroState {
    antiHeroes: ReadonlyArray<AntiHero>;
}

初始状态

我们需要实现的下一个部分是初始状态;这定义了状态切片的初始值。在我们的反英雄状态中,我们将antiHeroes切片设置为空数组。

为了实现这一点,我们可以使用以下代码:

export const initialState: AntiHeroState = {
    antiHeroes: []
}

Reducer 函数

在创建我们的初始状态后,我们可以实现我们的 reducer 函数;这将持有根据派发动作的类型将被调用的函数列表。

为了实现 reducer 函数,我们可以使用以下代码:

export const antiHeroReducer = createReducer(
  initialState,
  on(setAntiHeroList, (state, { antiHeroes }) => { return {...state, antiHeroes}}),
  );

在前面的代码示例中,我们可以看到我们使用了@ngrx/store库中的createReducer()函数;这将包含所有将修改我们反英雄状态的函数。第一个参数是我们的初始状态,而第二个参数是在派发SET_ANTI_HERO_LIST动作时被调用的函数。

这意味着我们之前创建的效果在 API 成功检索到反英雄列表后会调用一次这个函数;这个函数包含两个参数——一个持有当前状态,另一个持有从 API 获取的反英雄对象列表。为了使用检索到的列表修改antiHeroes状态,我们返回了{…state, antiHeroes}

现在我们已经完成了我们状态的 reducer 编写,最后一步是在 store 中注册我们的 reducer。我们将应用与效果相同的规则;由于我们的反英雄模块是懒加载的,我们将在anti-hero.module.ts文件中注册我们的反英雄 reducer,通过添加StoreModule.forFeature('antiHeroState,' antiHeroReducer)。第一个参数是我们反英雄状态的关键,而第二个是createReducer()函数返回的函数。

通过这样,我们已经成功创建并注册了我们的 reducer 以用于反英雄列表功能。现在,让我们讨论 NgRx 选择器以获取状态以及如何在组件中派发动作。

编写选择器、使用选择器以及在组件中分发它

在上一节中,我们成功实现了可以修改我们状态值的 reducers。这意味着我们的状态包含有价值的数据,我们可以从 Angular 组件中获取这些数据;我们可以使用选择器来完成这个任务。

选择器是纯函数,它允许我们检索状态切片;我们可以使用几个辅助函数,如createSelector()createFeatureSelector(),来为我们的存储创建选择器。

选择根状态

在选择根状态时,我们将使用纯函数来创建我们的选择器。让我们看看一个选择器选择根状态下的博客列表(AppState)的示例:

// blogs.selectors.ts
export const selectBlogs = (state: AppState) => state.blogs

在前面的代码示例中,我们只创建了一个返回blogs切片的函数;当我们需要在项目的根状态下选择切片时,这是可行的。为了在我们的组件中使用创建的选择器,我们可以使用以下代码:

//blogs.page.ts
blogs$ = this.store.select(selectBlogs())
constructor(private store: Store<AppState>,){
   this.blogs$.subscribe((data) => {
      this.blogs = data;
    });
}

在前面的代码示例中,我们从@ngrx/store库中注入了StoreStore提供了一个select函数,它接受选择器作为参数,并返回一个返回由选择器定义的状态切片的观察者。

在这种情况下,我们已经从select(selectBlogs())函数订阅了blogs$观察者,以检索包含博客列表的blogs切片。

选择功能状态

在选择功能状态时,我们将使用createSelector()createFeatureSelector()函数来创建选择器。让我们看看一个选择器选择功能状态下的博客列表(BlogState)的示例:

// blogs.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
export const selectBlogsState = createFeatureSelector<BlogsState>('blogsState')
export const selectBlogs = () => createSelector
    selectBlogsState,
    (state: BlogsState) => state.blogs
)

在前面的代码示例中,第一步是创建一个将返回整个BlogState的功能选择器。在这里,我们使用了createFeatureSelector()和状态的关键字来识别我们想要选择的功能状态。

第二步是创建博客切片的主要选择器;createSelector()有两个参数,第一个是BlogState功能选择器,第二个是一个函数,其中从功能选择器返回的BlogState是参数。返回的值是博客切片。

在我们的项目中实现选择器

到目前为止,我们已经学习了如何使用createFeatureSelector()createSelector()函数创建选择器。现在,让我们在我们的项目中实现它们。首先,我们需要确定状态或切片。第一个需要选择器的切片是位于AnitHeroState下的博客切片。记住,AntiHeroState不是我们的根状态;这意味着我们将为以下状态有一个功能选择器。

我们需要的第二个选择器是antiHeroes切片的选择器,它包含从 API 检索到的反英雄数组。最后,我们想要的第三个选择器将需要根据id参数从列表中选择特定的antiHero数据。

要创建所有这些选择器,请将以下代码放置在 anti-hero/state/anti-hero.selectors.ts 文件中:

// select the AntiHeroState
export const selectAntiHeroState = createFeatureSelector<AntiHeroState>('antiHeroState')
// selecting all antiheroes
export const selectAntiHeroes = () => createSelector(
    selectAntiHeroState,
    (state: AntiHeroState) => state.antiHeroes
)
// selecting an antihero base on id
export const selectAntiHero = (id: string) => createSelector(
    selectAntiHeroState,
    (state: AntiHeroState) => state.antiHeroes.find(d =>
      d.id === id)
)

在成功创建所有选择器之后,我们可以通过在 anti-hero/pages/list.component.ts 文件中添加以下代码来使用 on

antiHeroes$ = this.store.select(selectAntiHeroes());
constructor(
    private router: Router,
    private store: Store<AppState>,
    ) { }
  ngOnInit(): void {
    this.assignAntiHeroes();
  }
  assignAntiHeroes() {
    this.antiHeroes$.subscribe((data) => {
      this.antiHeroes = data;
    });
  }

在前面的代码示例中,我们使用了 selectAntiHeroes() 选择器从状态中获取反英雄数组。antiHeroes$ 是一个 Observable,它返回一旦订阅就返回 antiHero 切片的当前状态。

最后,我们必须获取反英雄列表功能。我们可以通过在列表组件的 ngOnInit() 钩子中分发 GET_ANTI_HERO_LIST 动作来实现这一点。这将调用我们之前创建的效果,该效果调用获取反英雄列表的端点:

  ngOnInit(): void {
this. store.dispatch({type:   AntiHeroActions.GET_ANTI_HERO_LIST});
    this.assignAntiHeroes();
  }

通过这样,我们已经成功为组件创建了选择器以从状态中检索数据。在下一节中,我们将讨论我们可以为存储实现的可用配置。

配置存储

在前面的章节中,我们创建了 NgRx 的所有构建块,以完成应用程序的完整功能存储。在本节中,我们将学习如何使用运行时检查来配置 NgRx 存储。

运行时检查

运行时检查用于配置 NgRx 存储,以便开发者遵循 NgRx 和 Redux 的核心概念和最佳实践。这对新手开发者非常有用;它们会根据激活的运行时检查显示有关开发的错误。

@ngrx/store 提供了六个内置的运行时检查:

  • strictStateImmutability: 检查状态是否未被修改(默认:开启

  • strictActionImmutability: 检查动作是否未被修改(默认:开启

  • strictStateSerializability: 检查状态是否可序列化(默认:开启

  • strictActionSerializability: 检查动作是否可序列化(默认:关闭

  • strictActionWithinNgZone: 检查动作是否在 NgZone 内分发(默认:关闭

  • strictActionTypeUniqueness: 检查注册的动作类型是否唯一(默认:关闭

要更改运行时检查的默认配置,我们将使用根存储配置对象的 runtimeChecks 属性。每个运行时检查的值可以通过 true 来激活检查或通过 false 来停用检查:

@NgModule({
imports: [
  StoreModule.forRoot(reducers, {
            runtimeChecks: {
                  strictStateImmutability: true,
                  strictActionImmutability: true,
                  strictStateSerializability: true,
                  strictActionSerializability: true,
                  strictActionWithinNgZone: true,
                  strictActionTypeUniqueness: true,
                         },
         }),
       ],
})

strictStateImmutability

这是 NgRx 的第一条规则。它默认激活,运行时检查会验证开发者是否修改了状态对象。

此规则的 违规示例:

export const reducer = createReducer(initialState, on(addBlog, (state, { blog }) => ({
// Violation 1: we assign a new value to loading
state.loading = false,
 // Violation 2: `push` modifies the array
 // state.blogs.push(blog) })) );

此违规的 修复方法:

export const reducer = createReducer( initialState, on(addBlog, (state, { blog }) =>
// Fix: We are returning the state as a whole object with
// the new values
  ({ ...state,
   loading: false,
   blogs: [...state.blogs, blog],
})) );

strictActionImmutability

此运行时检查类似于 strictStateImmutability,但针对动作。此运行时检查验证开发者是否修改了动作。此检查默认激活。

此规则的 违规示例:

export const reducer = createReducer(initialState, on(addBlog, (state, { blog }) => ({
// Violation: it's not allowed to modify an action
blog.id = uniqueID();
return { ...state, blogs: [...state.blogs, blog]
} })) );

此违规的 修复方法:

//blog.actions.ts
export const addBlog = createAction( '[Blog List] Add Blog',
// Fix: we will return the object in the action with the
// new value
(description: string) =>
({ id: uniqueID(), description }) );
//blog.reducer.ts
export const reducer = createReducer(
initialState,
on(addBlog, (state, { blog }) => ({
...state,
blogs: [...state.blogs, blog],
})) );

strictStateSerializability

此运行时检查验证放置在状态中的值是否可序列化。这对于持久化状态以便将来重新激活至关重要。此检查默认不激活。

此规则 的违规示例:

export const reducer = createReducer(
initialState,
on(addBlog, (state, { blog }) => ({
...state,
blogs: [...state.blogs, blog],
// Violation: a Date type is not a serializable value.
createdOn: new Date()
})) );

针对 此违规 的修复:

export const reducer = createReducer(
initialState,
on(addBlog, (state, { blog }) => ({
...state,
blogs: [...state.blogs, blog],
// Fix: We should convert the date into a JSON Object.
createdOn: new Date().toJSON()
})) );

strictActionSerializability

此运行时检查类似于strictStateSerializability,但针对动作。它检查状态是否可序列化。这是通过 Redux DevTools 进行错误调试完成的。

此规则 的违规示例:

const createBlog = createAction(
'[Blog List] Add Blog,
blog => ({ blog,
// Violation, a function is not serializable
logBlog: () => { console.log(blog); }, }));

针对 此违规 的修复:

const createBlog = createAction(
'[Blog List] Add Blog,
// Fix: we should use props to receive parameters
 props<{blog: Blog}>()
);

strictActionWithinNgZone

此运行时检查验证动作是否由NgZone内的异步任务分发。此检查默认不激活。

此规则 的违规示例:

// Callback outside NgZone
// Violation: the createBlog actions is invoked outside the
// ngZone
callbackOutsideNgZone() {
        this.store.dispatch(createBlog ());
}

针对 此违规 的修复:

import { NgZone } from '@angular/core';
constructor(private ngZone: NgZone){}
 // use run() function to call the dispatch inside the
 // NgZone
function callbackOutsideNgZone(){
  this.ngZone.run(
    () => {  this.store.dispatch(createBlog());
  }
}

strictActionTypeUniqueness

此运行时检查防止开发者在 NgZone 中注册相同的动作类型超过一次。此检查默认不激活。

此规则 的违规示例:

//Violation: two actions have the same type
export const addBlog = createAction('[Blog] Add Blog'); export const modifyBlog = createAction('[Blog] Add Blog');

针对 此违规 的修复:

//Violation: two actions have the same type
export const addBlog = createAction('[Blog] Add Blog'); export const modifyBlog = createAction('[Blog] Modify Blog');

摘要

有了这些,我们已经到达了本章的结尾。让我们回顾一下你在应用状态管理概念和重要性方面学到的宝贵知识。

存储库作为单一的真实来源,提供单向数据流以防止不一致和错误处理订阅。

你还学习了如何使用自定义配置参数安装和配置 NgRx 存储库和 NgRx DevTools 库。最后,你了解了围绕状态管理的概念以及如何编写 NgRx 的不同块,例如动作、还原器、效果和选择器。

在下一章中,我们将通过使用 NgRx 的构建块来完成我们应用程序的 CRUD 功能。我们将通过动作、效果和还原器添加、删除和更新项目。

第十三章:使用 NgRx 进行保存、删除和更新

在上一章中,我们学习了 NgRx 的概念和功能。我们了解到状态管理的重要性,因为它为应用程序提供了一个单一的数据流源,并减少了组件的责任。我们还学习了 NgRx 的构建块,即操作、效果、reducer 和选择器。最后,我们使用 NgRx 在我们的应用程序中实现了获取和显示反英雄列表功能。

在本章中,我们将完成我们应用程序缺失的功能——通过仍然使用 NgRx 来保存、删除和更新数据。

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

  • 使用 NgRx 无副作用地删除项目

  • 使用 NgRx 通过副作用删除项目

  • 使用 NgRx 添加具有副作用的项目

  • 使用 NgRx 通过副作用更新项目

技术要求

代码完成版本的链接为 github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13/superheroes.

使用 NgRx 无副作用地删除项目

在本节中,我们将首先了解如何在 NgRx 中不使用副作用来删除项目。正如我们在上一章中学到的,副作用用于调用外部 API 以检索数据。这意味着在不使用效果的情况下,我们将通过派发一个操作来删除数据,该操作将根据派发的类型调用 reducer。本节将帮助我们了解在应用程序中使用效果时的流程和行为差异。

创建删除操作

第一步是创建删除功能的操作。在我们的项目中,在 anti-hero/state/anti-hero.actions.ts 文件中,我们将添加一个新的操作接口和一个新的删除函数。

让我们看看以下代码的实现:

export enum AntiHeroActions {
  GET_ANTI_HERO_LIST = '[Anti-Hero] Get Anti-Hero list',
  SET_ANTI_HERO_LIST = '[Anti-Hero] Set Anti-Hero list',
  REMOVE_ANTI_HERO_STATE =
   '[Anti-Hero] Remove ALL Anti-Hero (STATE)',
}
export const removeAntiHeroState = createAction(
    AntiHeroActions.REMOVE_ANTI_HERO_STATE,
  props<{ antiHeroId: string }>()
);

在前面的代码示例中,我们可以看到我们添加了一个名为 REMOVE_ANTI_HERO_STATE 的新操作。我们还创建了一个具有新创建类型的操作,该类型具有一个接受反英雄 ID 的 props 参数。ID 是 reducer 识别我们应该从我们的存储中删除哪些数据所必需的。

创建删除 reducer

现在,让我们创建用于从我们的存储中删除数据的 reducer。我们首先需要考虑的是,如果我们的 reducer 能够使用提供的 ID 从数组中删除单个数据项,它会是什么样子。我们可以实现这一点的其中一种方法是通过使用 filter() 函数从数组中提取数据。

让我们在 anti-hero/state/anti-hero.reducers.ts 文件中添加以下代码:

export const antiHeroReducer = createReducer(
  initialState,
  on(setAntiHeroList, (state, { antiHeroes }) => { return
    {...state, antiHeroes}}),
  on(removeAntiHeroState, (state, { antiHeroId }) => {
    return {...state, antiHeroes:
      state.antiHeroes.filter(data => data.id !=
                              antiHeroId)}
  }),
);

在前面的代码示例中,我们可以看到我们为我们的删除功能添加了一个新的 reducer。它接受来自removeAntiHeroState动作的反英雄 ID,并返回一个新的状态,其中已过滤掉具有给定 ID 的反英雄数据,并修改了antiHeroes值。如果 reducer 成功修改了antiHeroes状态值,任何订阅此状态变化的选择器将在组件中发出新值。

分发动作

我们需要执行的最后一个步骤是在我们的组件中分发动作。为了实现这一步骤,我们需要在点击每条反英雄数据的删除按钮时调用分发。

anti-hero/components/anti-hero-list.component.ts文件中,我们添加了emittethatch,它根据用户点击的按钮传递选定的反英雄对象和TableAction

让我们回顾一下我们为这个功能在以下文件中实现的代码:

anti-hero-list.component.ts

// See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13 /
export class AntiHeroListComponent implements OnInit {
   // other code for component not displayed
  @Output() antiHero = new EventEmitter<{antiHero:   AntiHero, action :TableActions}>();
  selectAntiHero(antiHero: AntiHero, action: TableActions)  {
    this.antiHero.emit({antiHero, action});
 }
}

anti-hero-list.component.html

// See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13
<button (click)="selectAntiHero(element, 1)" mat-raised-button color="warn">
           <mat-icon>delete</mat-icon> Delete
</button>

table-actions.enum.ts

export enum TableActions {
  View,
   Delete
}

在前面的代码示例中,我们可以看到1代表删除枚举的值。

现在,我们需要在列表组件中分发REMOVE_ANTI_HERO_STATE动作,当删除按钮发出事件时。为了实现这部分,我们将在以下文件中添加以下代码:

list.component.ts

  selectAntiHero(data: {antiHero: AntiHero, action:
    TableActions}) {
    switch(data.action) {
      case TableActions.Delete: {
        this.store.dispatch({type:
          AntiHeroActions. REMOVE_ANTI_HERO_STATE,
          antiHeroId: data.antiHero.id});
        return;
      }
      default: ""
    }
  }

list.component.html

// See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13
<!-- Dumb component anti hero list -->
<app-anti-hero-list [antiHeroes]="antiHeroes" (antiHero)="selectAntiHero($event)" [headers]="headers"></app-anti-hero-list>

在前面的代码示例中,我们创建了一个函数,该函数检查由用户触发的TableActions值。如果TableActions具有删除值,我们将分发REMOVE_ANTI_HERO_STATE并传递将由我们创建的 reducer 使用的反英雄对象的 ID。

我们现在已成功使用 NgRx 实现了我们应用程序的删除功能,但在这个案例中,我们只删除了 UI 中的项目,并没有同步数据库中的更改。

在下一节中,我们将实现使用副作用来删除数据的方法。

使用 NgRx 通过副作用删除项目

在本节中,我们将通过在我们的状态中添加副作用来改进删除功能。我们当前的删除功能仅从存储中删除数据,但不会同步数据库中的更改。这意味着如果我们刷新我们的应用程序,我们已删除的数据将再次可用。

为了同步数据库中的更改,我们应该创建一个将调用删除 API 的效果。让我们看看以下章节中我们代码的逐步更改。

创建一个新的动作类型

我们需要做的第一步是创建一个新的动作类型。NgRx 中的效果将使用新的动作类型来删除功能。

我们将在anti-hero/state/anti-hero.actions.ts文件下的AntiHeroActions枚举中添加REMOVE_ANTI_HERO_API

让我们看一下以下代码中添加的动作:

export enum AntiHeroActions {
  GET_ANTI_HERO_LIST = '[Anti-Hero] Get Anti-Hero list',
  SET_ANTI_HERO_LIST = '[Anti-Hero] Set Anti-Hero list',
  REMOVE_ANTI_HERO_API =
    '[Anti-Hero] Remove Anti-Hero (API)',
  REMOVE_ANTI_HERO_STATE =
    '[Anti-Hero] Remove Anti-Hero (STATE)',
}

在前面的代码示例中,我们可以看到为我们的动作添加了一个新的动作类型。请注意,我们不需要为这个类型创建一个新的动作,因为一旦这个动作类型被派发,我们将调用一个副作用而不是一个动作。

创建删除副作用

我们接下来需要做的步骤是创建删除功能的副作用。在 anti-hero/state/anti-hero.effect.ts 文件中,我们将添加以下代码:

 // See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13
 removeAntiHero$ = createEffect(() => {
    return this.actions$.pipe(
        ofType(AntiHeroActions.REMOVE_ANTI_HERO_API),
        mergeMap((data: { payload: string}) =>
          this.antiHeroService.deleteAntiHero(data.payload)
          .pipe(
            map(() => ({ type:
              AntiHeroActions.REMOVE_ANTI_HERO_STATE,
              antiHeroId: data.payload })),
            catchError(() => EMPTY)
          ))
        )
    }, {dispatch: true}
  );

在前面的代码示例中,我们可以看到我们为我们的删除动作创建了一个新的副作用;这个副作用的类型是 REMOVE_ANTI_HERO_API,它调用 AntiHeroService 中的 deleteAntiHero() 函数,根据传递的 ID 删除数据,一旦 API 调用成功。

该副作用将派发另一个动作,REMOVE_ANTI_HERO_STATE,这是我们之前创建的,它从存储中删除反英雄。这意味着我们从数据库中删除的数据也将从我们的 NgRx 存储中删除。

修改派发

这个功能的最后一步是在 list.component.ts 文件中修改派发的动作。在上一节中,我们直接在我们的组件中调用 REMOVE_ANTI_HERO_STATE 动作;我们将将其更改为 REMOVE_ANTI_HERO_API,因为我们现在应该调用副作用,这将调用 API,同时也会调用 REMOVE_ANTI_HERO_STATE 动作。

让我们看看以下代码示例:

 selectAntiHero(data: {antiHero: AntiHero, action: TableActions}) {
    switch(data.action) {
      case TableActions.Delete: {
        this. store.dispatch({type:
          AntiHeroActions.REMOVE_ANTI_HERO_API,
          payload: data.antiHero.id});
        return;
      }
      default: ""
    }
  }

在前面的代码示例中,我们现在在我们的列表组件中派发副作用。这将首先调用 API,然后再更新我们的应用存储;我们存储和数据库中的更改是同步的。

在下一节中,我们将实现具有副作用的数据添加到我们的应用中。

使用 NgRx 添加具有副作用的项目

在本节中,我们将使用 NgRx 实现具有副作用的 添加 功能。步骤与我们实现删除功能的方式相似。我们将逐步创建构建块并在我们的组件中创建派发逻辑。

创建动作

我们需要做的第一步是创建我们添加功能所需的动作类型和动作。为了实现动作,我们可以考虑我们是如何创建删除功能的动作的。

概念是相同的。我们需要创建两种动作类型,这些是 ADD_ANTI_HERO_APIADD_ANTI_HERO_STATE。第一种类型将由调用 API 的副作用使用,第二种类型将由修改状态的还原器使用,通过添加新创建的数据。

在创建了两种动作类型之后,我们还需要使用 createAction() 函数为 ADD_ANTI_HERO_STATE 类型创建一个动作。一旦 API 调用成功,副作用将派发这个动作。

让我们看看以下代码实现:

 // See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13
export enum AntiHeroActions {
  GET_ANTI_HERO_LIST = '[Anti-Hero] Get Anti-Hero list',
  SET_ANTI_HERO_LIST = '[Anti-Hero] Set Anti-Hero list',
  ADD_ANTI_HERO_API = '[Anti-Hero] Add Anti-Hero (API',
  ADD_ANTI_HERO_STATE = '[Ant
    i-Hero] Add Anti-Hero (STATE)',
  REMOVE_ANTI_HERO_API =
    '[Anti-Hero] Remove Anti-Hero (API)',
  REMOVE_ANTI_HERO_STATE =
    '[Anti-Hero] Remove Anti-Hero (STATE)',
}
export const addAntiHeroState = createAction(
  AntiHeroActions.ADD_ANTI_HERO_STATE,
  props<{ antiHero: AntiHero }>()
)

在前面的代码示例中,我们可以看到我们在AntiHeroActions中添加了两个新的类型。我们还创建了一个新的动作,类型为ADD_ANTI_HERO_STATE,它接受一个antiHero属性,该属性将被推送到反英雄状态中的新条目。

创建效果

我们下一步需要做的是为添加功能创建效果。在anti-hero/state/anti-hero.effect.ts文件中,我们将添加以下代码:

// add anti-heroes to the database
  addAntiHero$ = createEffect(() =>{
    return this.actions$.pipe(
        ofType(AntiHeroActions.ADD_ANTI_HERO_API),
        mergeMap((data: {type: string, payload: AntiHero})
          => this.antiHeroService.addAntiHero(data.payload)
          .pipe(
            map(antiHeroes => ({ type:
              AntiHeroActions.ADD_ANTI_HERO_STATE,
              antiHero: data.payload })),
            tap(() =>
              this.router.navigate(["anti-heroes"])),
            catchError(() => EMPTY)
          ))
        )
    }, {dispatch: true})

在前面的代码示例中,我们可以看到我们创建了一个类似于删除功能效果的效果。这个效果使用ADD_ANTI_HERO_API类型,并从antiHeroService调用addAntiHero()函数来调用 POST API 将新数据添加到数据库中。

在成功调用 POST API 之后,效果将分发ADD_ANTI_HERO_STATE动作,并将来自 API 响应的新反英雄数据传递给 reducer 进行添加。我们还添加了一个tap操作符,它调用一个navigate函数,在创建新的反英雄后将导航到列表页面。

创建 reducer

在创建效果之后,我们需要将数据库中实现的变化与我们的存储同步,reducer 将完成这项工作。

让我们看看以下代码实现:

export const antiHeroReducer = createReducer(
  initialState,
  on(setAntiHeroList, (state, { antiHeroes }) => { return
    {...state, antiHeroes}}),
  on(removeAntiHeroState, (state, { antiHeroId }) => {
    return {...state, antiHeroes:
     state.antiHeroes.filter(data => data.id !=
       antiHeroId)}
  }),
  on(addAntiHeroState, (state, {antiHero}) => {
    return {...state, antiHeroes: [...state.antiHeroes,
            antiHero]}
  }),
);

在前面的代码示例中,我们可以看到我们为添加功能添加了一个新的 reducer。它接受来自addAntiHeroState动作的新反英雄数据,并返回一个新的状态,其中antiHeroes值已被修改,新反英雄已添加到数组中。

如果 reducer 成功修改了antiHeroes状态的值,任何订阅了这个状态变化的选择器将发出新的值在组件中。

分发动作

我们需要做的最后一步是在我们的组件中分发动作。为了实现这一步,我们将在anti-hero/components/anti-hero-form.component.ts文件中调用分发动作,我们已添加了一个发射器,它传递表单的值和按钮标签以识别动作是创建还是更新。

让我们回顾一下我们为这个反英雄表单实现的代码:

export class AntiHeroFormComponent implements OnInit {
  @Input() actionButtonLabel: string = 'Create';
  @Output() action = new EventEmitter();
  form: FormGroup;
  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      id: [''],
      firstName: [''],
      lastName: [''],
      house: [''],
      knownAs: ['']
    })
   }
  emitAction() {
    this.action.emit({value: this.form.value,
                      action: this.actionButtonLabel})
  }
}

在前面的代码示例中,我们可以看到反英雄表单将表单值作为反英雄对象发出,该对象将被传递到效果中。

这也提供了当前的操作,因为我们还将使用这种反英雄表单组件进行更新。一旦按钮被点击,我们就需要在form.component.ts文件中有一个函数来分发效果。

让我们看看以下代码示例:

// form.component.html
<app-anti-hero-form [selectedAntiHero]="antiHero" (action)="formAction($event)"></app-anti-hero-form>
// form.component.ts
 formAction(data: {value: AntiHero, action: string}) {
    switch(data.action) {
      case "Create" : {
        this.store.dispatch({type:
          AntiHeroActions.ADD_ANTI_HERO_API,
          payload: data.value});
        return;
      }
      default: ""
    }
  }

在前面的代码示例中,我们可以看到我们创建了一个formAction()函数,它根据从反英雄表单组件传递的值分发一个动作。

这使用了一个switch语句,因为当动作是update时也会被调用。现在我们已经成功地为我们的应用程序使用 NgRx 的构建块创建了添加功能。

在下一节中,我们将实现具有副作用的数据修改。

使用 NgRx 更新带有副作用的项目

在本节的最后,我们将实现最后缺失的功能,即 更新 功能,我们将逐步创建构建块和组件中的分发逻辑,就像我们对 添加删除 功能所做的那样。

创建动作

我们需要做的第一步是创建更新功能所需的动作类型和动作。我们首先创建所需的两个动作类型,分别是 MODIFY_ANTI_HERO_APIMODIFY_ANTI_HERO_STATE。第一个类型将由调用 API 的副作用使用,第二个类型将由通过根据新的反英雄对象更改数据来修改状态的 reducer 使用。

在创建了两个动作类型之后,我们还需要使用 createAction() 函数为 MODIFY_ANTI_HERO_STATE 类型创建一个动作。效果将在 API 调用成功后分发此动作。

让我们看看以下代码实现:

 // See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-13
export enum AntiHeroActions {
  GET_ANTI_HERO_LIST = '[Anti-Hero] Get Anti-Hero list',
  SET_ANTI_HERO_LIST = '[Anti-Hero] Set Anti-Hero list',
  ADD_ANTI_HERO_API = '[Anti-Hero] Add Anti-Hero (API',
  ADD_ANTI_HERO_STATE =
    '[Anti-Hero] Add Anti-Hero (STATE)',
  REMOVE_ANTI_HERO_API =
    '[Anti-Hero] Remove Anti-Hero (API)',
  REMOVE_ANTI_HERO_STATE =
    '[Anti-Hero] Remove Anti-Hero (STATE)',
  MODIFY_ANTI_HERO_API =
    '[Anti-Hero] Modify Anti-Hero (API)',
  MODIFY_ANTI_HERO_STATE =
    '[Anti-Hero] Modify Anti-Hero (STATE)',
}
export const modifyAntiHeroState = createAction(
    AntiHeroActions.MODIFY_ANTI_HERO_STATE,
    props<{ antiHero: AntiHero }>()
);

在前面的代码示例中,我们可以看到我们在 AntiHeroActions 中添加了两个新类型。我们还创建了一个新的动作,其类型为 MODIFY_ANTI_HERO_STATE,它接受一个 antiHero 属性,该属性将用于修改存储中的当前值。

创建效果

下一步我们需要做的是创建 添加 功能的效果。在 anti-hero/state/anti-hero.effect.ts 文件中,我们将添加以下代码:

// modify anti-heroes in the database
   modifyAntiHero$ = createEffect(() =>{
    return this.actions$.pipe(
        ofType(AntiHeroActions.MODIFY_ANTI_HERO_API),
        mergeMap((data: {type: string, payload: AntiHero})
          => this.antiHeroService.updateAntiHero(
          data.payload.id, data.payload)
          .pipe(
            map(antiHeroes => ({ type:
                AntiHeroActions.MODIFY_ANTI_HERO_STATE,
                antiHero: data.payload })),
            tap(() =>
              this.router.navigate(["anti-heroes"])),
            catchError(() => EMPTY)
          ))
        )
    }, {dispatch: true})

在前面的代码示例中,我们可以看到我们创建了一个类似于 添加删除 功能的效果。此效果使用 MODIFY_ANTI_HERO_API 类型,并从 antiHeroService 调用 updateAntiHero() 函数来调用 PUT API 以修改具有 ID 参数的数据库中的反英雄。

在成功调用 PUT API 后,效果将分发 MODIFY_ANTI_HERO_STATE 动作,并将来自 API 响应的修改后的反英雄数据传递给 reducer,就像在 添加 效果中一样,我们同样添加了一个 tap 操作符,该操作符在修改反英雄后调用一个 navigate 函数,该函数将导航到列表页面。

创建 reducer

在创建效果之后,我们需要将数据库中实现的变化与我们的存储同步,reducer 将执行此操作。

让我们看看以下代码实现:

export const antiHeroReducer = createReducer(
  initialState,
  on(setAntiHeroList, (state, { antiHeroes }) => {
    return {...state, antiHeroes}}),
  on(removeAntiHeroState, (state, { antiHeroId }) => {
    return {...state, antiHeroes:
      state.antiHeroes.filter(data => data.id !=
                              antiHeroId)}
  }),
  on(addAntiHeroState, (state, {antiHero}) => {
    return {...state, antiHeroes: [...state.antiHeroes,
                                   antiHero]}
  }),
  on(modifyAntiHeroState, (state, {antiHero}) => {
    return {...state, antiHeroes: state.antiHeroes.map(data
      => data.id === antiHero.id ? antiHero : data)}
  }),
);

在前面的代码示例中,我们可以看到我们为更新功能添加了一个新的 reducer。它接受来自 addAntiHeroState 动作的修改后的反英雄数据,并返回包含修改后的 antiHeroes 值的新状态,其中我们使用 map() 操作符用新对象替换了给定 ID 的反英雄。

如果 reducer 成功修改了 antiHeroes 状态的值,任何订阅此状态变化的 selectors 都会在组件中发出新的值。

分发动作

我们需要做的最后一步是将动作分发到我们的组件。为了实现这一步,我们将执行与添加功能相同的步骤。我们仍然会使用anti-hero/components/anti-hero-form.component.ts文件来更新数据。

这里的唯一区别是我们将绑定所选反英雄的值到我们的表单中;反英雄表单组件应该接受一个反英雄对象,并且应该通过表单组修补值。

让我们看看下面的代码示例:

export class AntiHeroFormComponent implements OnInit {
  @Input() actionButtonLabel: string = 'Create';
  @Input() selectedAntiHero: AntiHero | null = null;
  @Output() action = new EventEmitter();
  form: FormGroup;
  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      id: [''],
      firstName: [''],
      lastName: [''],
      house: [''],
      knownAs: ['']
    })
   }
  ngOnInit(): void {
    this.checkAction();
  }
  checkAction() {
    if(this.selectedAntiHero) {
      this.actionButtonLabel = "Update";
      this.patchDataValues()
    }
  emitAction() {
    this.action.emit({value: this.form.value,
      action: this.actionButtonLabel})
  }
}

在前面的代码示例中,我们可以看到我们添加了checkAction()函数,该函数检查我们是否在反英雄表单组件中传递了一个反英雄对象。

这表明如果对象不为空,这将是一个更新动作,我们必须通过使用patchValue()方法绑定表单来在每个字段中显示所选反英雄的详细信息。

现在让我们来实现form组件的代码:

// form.component.html
<app-anti-hero-form [selectedAntiHero]="antiHero" (action)="formAction($event)"></app-anti-hero-form>
// form.component.ts
antiHero$: Observable<AntiHero | undefined>;
  antiHero: AntiHero | null = null;
  constructor(private router: ActivatedRoute,
    private store: Store<AppState>) {
    const id = this.router.snapshot.params['id'];
    this.antiHero$ = this.store.select(selectAntiHero(id));
    this.antiHero$.subscribe(d => {
      if(d) this.antiHero = d;
    });
   }
 formAction(data: {value: AntiHero, action: string}) {
    switch(data.action) {
      case "Create" : {
        this.store.dispatch({type:
          AntiHeroActions.ADD_ANTI_HERO_API,
          payload: data.value});
        return;
      }
     case "Update" : {
        this.store.dispatch({type:
          AntiHeroActions.MODIFY_ANTI_HERO_API,
          payload: data.value});
        return;
      }
      default: ""
    }
  }

在前面的代码示例中,我们可以看到我们在formAction()函数中添加了一个新的情况,它也会分发一个动作,但动作类型为MODIFY_ANTI_HERO_API

我们还使用了selectAntiHero()选择器,通过 URL 路由中的 ID 选择反英雄,该 ID 将被传递到我们的anti-hero-form.component.ts文件中。

摘要

通过这种方式,我们已经到达了这一章的结尾。让我们回顾一下我们学到的宝贵知识;我们使用 NgRx 的构建块完成了应用的 CRUD 功能,并且我们学习了在状态管理中使用和不使用副作用之间的区别。副作用对于我们的更改在存储中与数据库同步是必不可少的。

我们也一步一步地学习了如何使用我们应用所需的不同动作来创建 NgRx 的构建块。

在下一章中,我们将学习如何在 Angular 中应用安全功能,例如添加用户登录和注销、检索用户配置文件信息、保护应用路由以及调用具有受保护端点的 API。

第十四章:在 Angular 中添加认证

在上一章中,我们使用 NgRx 的构建块完成了我们的 Angular 应用程序的 CRUD 功能。我们还学习了在应用程序中编写 actions、reducers 和 effects 的逐步过程,这些将被用于修改状态值。我们还学习了在应用程序中使用和不使用 effects 的区别。Effects 对于我们与允许数据库更改同步到 NgRx 存储的外部 API 进行通信至关重要。

在本章中,我们将学习如何在我们的 Angular 应用程序中添加认证;我们将实现一个登录页面,该页面将提供有效的 JWT,保护路由,并使用 NgRx 应用 API 认证。

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

  • 添加用户认证

  • 保护路由

  • 调用 API

技术要求

本章的完整代码可以在以下位置找到:https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-14。

添加用户认证

在开发应用程序时,添加用户认证是主要要求之一。此功能允许我们限制页面和功能不被未经授权的用户访问。我们可以以不同的方式实现用户认证,其中一种实现方式是提供一个要求凭证的登录页面。

让我们一步一步地看看实现认证功能的步骤。

认证 API

让我们首先回顾一下我们在 Spring Boot 项目中创建的认证 API。认证端点如下:

  • {BASE_URL}/authenticate:主要的认证端点接受包含电子邮件和密码字段的对象,并返回一个有效的 JWT,该 JWT 将用于调用端点。以下是该端点的示例响应对象:

    // valid JWT
    
    {
    
       "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGdtYWl sLmNvbSIsImlhdCI6MTY1OTQyODk2MSwiZXhwIjoxNjU5NDY0OTYxfQ.WU_aZjmlfw--LCovx4cZ4_hcOTGiAgPnSaM0bjdv018"
    
    }
    
  • {BASE_URL}/register:创建新有效登录凭证的端点。正如在第七章中所述,使用 JWT 添加 Spring Boot 安全性,JWT 主要用于无法维护客户端状态的 RESTful 网络服务,因为它包含与用户相关的某些信息。这将在我们请求的端点头部中使用。

在我们的项目中,让我们在 core/services 文件夹下创建一个名为 AuthenticateService 的服务,通过执行以下命令:

 ng g core/services/authenticate

在成功创建服务后,我们将以下代码放置在服务中:

export class AuthenticateService {
  constructor(private http: HttpClient) { }
  // for login endpoint
  login(data: {email: string, password: string}):
    Observable<any> {
    return this.http.post<any>(
     `${environment.authURL}/authenticate`,
      data).pipe(
      tap((data: any) => data),
      catchError(err => throwError(() => err))
   )
  }
  // for register endpoint
  register(data: {email: string, password: string}):
    Observable<any> {
    return this.http.post<any>(
      `${environment.authURL}/register`, data).pipe(
      tap((data: any) => data),
      catchError(err => throwError(() => err))
   )
  }
}

AuthenticateService 将持有我们将用于登录页面的两个端点。现在,让我们为我们的应用程序创建拦截器。

HTTP 拦截器

intercept() 函数,它将使我们能够获取出站请求并调用下一个拦截器或后端。

我们将主要使用拦截器来修改端点请求的头部,这将负责为每个调用的请求添加 Authorization: Bearer {JWT} 头部。

要实现拦截器,我们将创建core/interceptors/header.interceptor.ts文件,并将以下代码放置在其中:

@Injectable()
export class HeaderInterceptor implements HttpInterceptor {
  intercept(httpRequest: HttpRequest<any>, next:
    HttpHandler): Observable<HttpEvent<any>> {
    const Authorization = localStorage.getItem('token') ?
      `Bearer ${localStorage.getItem('token')}` : '';
    if(httpRequest.url.includes('api/v1'))
    return next.handle(httpRequest.clone({ setHeaders: {
      Authorization } }));
    else
    return next.handle(httpRequest);
  }
}

在前面的代码示例中,我们已经为intercept()函数添加了一个新的实现。第一步是从我们的本地存储中检索有效的 JWT,它将被用于 HTTP 头中。我们只有在请求端点包含api/v1子字符串时才会使用 JWT,因为这些是受保护的端点。

下一步是克隆请求并添加Authorization: Bearer {JWT}头到克隆的请求中,并调用next()函数以带有添加的头的 API。

我们现在已经创建了我们的拦截器;最后一步是将拦截器添加到AppModule中。

让我们看看以下代码:

  providers: [
    { provide: HTTP_INTERCEPTORS, useClass:
               HeaderInterceptor, multi: true }
  ],

在前面的代码示例中,我们现在将拦截反英雄端点的每个 HTTP 调用,并将生成的 JWT 添加到请求头中。

认证模块

下一步是创建认证模块;这个模块将负责包含登录和注册页面,该页面将接受用户和凭据并调用认证和注册端点。

要创建认证模块,我们将执行以下命令:

ng g m auth

在成功创建认证模块后,我们将导入我们认证模块所需的几个模块:

@NgModule({
  declarations: [
  ],
  imports: [
    CommonModule,
    MaterialModule,
    FormsModule,
    ReactiveFormsModule,
    CoreModule,
  ]
});

现在,我们将创建我们模块的不同部分。

认证表单

我们将为我们的认证模块创建主要表单;这被认为是我们的模块中的哑组件,因为它将接受和发射表单的值到登录和注册页面。

要创建认证表单组件,我们将执行以下命令:

ng g c auth/components/auth-form

在成功创建组件后,我们现在将实现表单的代码。在auth-form组件的类型 Script 文件中,我们将放置以下代码:

export class AuthFormComponent {
  @Input() error: string = "";
  @Input() title: string = "Login"
  @Output() submitEmitter = new EventEmitter();
  form: FormGroup;
  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      email: [''],
      password: ['']
    })
  }
  submit() {
    this.submitEmitter.emit(this.form.value);
  }
}

在前面的代码示例中,我们可以看到我们创建了一个具有电子邮件和密码表单控件的响应式表单。我们还创建了一个发射器,它将表单的值传递给父组件,因为这个组件将被登录和注册页面使用。现在,我们将实现auth-form组件的 HTML 代码和 CSS。

注意

请参阅提供的链接以获取完整的代码实现:

github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-14/superheroes/src/app/auth/components/auth-form

在实现的代码中,我们已经将响应式表单与电子邮件和密码输入绑定。我们还创建了一个条件,按钮会根据页面当前是登录还是注册而改变。

我们已经成功创建了我们的认证表单;现在,我们将创建登录和注册页面。

登录和注册页面

登录和注册页面被认为是我们的应用程序的智能组件,因为这些是会调度调用认证 API 的动作的组件。

要创建登录和注册页面,我们将执行以下命令:

ng g c auth/page/login auth/page/register

在成功创建两个页面后,我们将运行登录和注册组件的代码:

登录页面

//TS File
export class LoginComponent{
  constructor(private authService: AuthenticateService,
    private router: Router) {
  }
  submit(data:{email:string, password:string}) {
    this.authService.login(data).subscribe((data) => {
      this.router.navigate(['/anti-heroes']);
      localStorage.setItem('token', data.token);
   });
  }
}
// HTML File
<app-auth-form (submitEmitter)="submit($event)"></app-auth-form>

注册页面

// TS File
export class RegisterComponent {
  error: string = "";
  constructor(private authService: AuthenticateService) {
  }
  submit(data: User) {
    this.authService.register(data).subscribe((data) => {
      this.router.navigate(['/']);
    });
  }
}
// HTML File
<app-auth-form title="Register" (submitEmitter)="submit($event)"></app-auth-form>

在前面的代码示例中,我们可以看到登录页面和注册页面正在使用相同的认证表单组件。一旦表单提交,它将表单值传递给login()register()函数以进行认证或创建用户。如果登录成功,我们将用户重定向到反英雄列表页面,并将 API 生成的令牌放置在本地存储中。

路由模块

下一步是创建auth-routing模块,该模块将定义认证模块的路由。要创建该模块,让我们执行以下命令:

ng g m auth/auth-routing --flat

在创建路由模块后,我们将运行以下代码:

const routes: Routes = [
  {
    path: "",
    component: LoginComponent
  },
  {
    path: "register",
    component: RegisterComponent
  }
];
@NgModule({
  declarations: [],
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class AuthRoutingModule {}

我们还需要修改我们的app-routing模块,因为我们需要基本路径重定向到登录页面;让我们实现以下修改:

const routes: Routes = [
  {
    path: "",
    redirectTo: "login",
    pathMatch: "full",
  },
  {
    path: "login",
    loadChildren: () =>
    import("./auth/auth.module").then((m) => m.AuthModule),
  },
  {
    path: "anti-heroes",
    loadChildren: () =>
      import("./anti-hero/anti-hero.module").then((m) =>
             m.AntiHeroModule),
  }
];

在前面实现的代码中,我们可以看到,一旦我们访问基本路径,现在将加载AuthModule并将我们重定向到登录页面,如图图 14.1所示。

图 14.1 – 登录页面

图 14.1 – 登录页面

我们现在应该能够使用数据库中的用户登录。如果没有创建用户,我们可以通过注册页面创建一个新的用户,一旦登录成功,我们将被重定向到反英雄列表页面,如图图 14.2所示。

图 14.2 – 反英雄列表页面

图 14.2 – 反英雄列表页面

我们还可以观察到我们的有效 JWT 已经放置在我们的本地存储中,因为 HTTP 拦截器正在使用 JWT。当我们打开应用程序发出的请求时,我们可以看到头中有生成的 JWT:

Accept:
application/json, text/plain, */*
Accept-Encoding:
gzip, deflate, br
Accept-Language:
en-AU,en-US;q=0.9,en;q=0.8,bs;q=0.7,fr-CA;q=0.6,fr;q=0.5,tl;q=0.4
Authorization:
Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGdtYWlsLmNvbSIsImlhdCI6MTY1OTY5ODM2NSwiZXhwIjoxNjU5NzM0MzY1fQ.2SDLmvQcME5Be9Xj-zTeRlc6kGfQVNCMIWUBOBS5afg

在前面的示例头中,我们可以看到Authorization头包含应用程序对每个 API 请求的有效 JWT。JWT 在头中的放置是在我们登录成功并被重定向到AntiHeromodule时完成的。

令牌验证

下一步我们需要做的是添加令牌验证来检查我们的令牌是否已经过期。为了实现这个功能,我们通过执行以下命令添加@auth0/angular-jwt库:

npm install @auth0/angular-jwt --save

@auth0/angular-jwt库提供了有用的函数,例如isTokenExpired(),它检查 JWT 是否已过期,以及decodeToken(),它从 JWT 中检索信息。

在成功安装库后,我们将以下代码添加到我们的认证服务中:

  isAuthenticated(): boolean {
    const token = localStorage.getItem('token') ?? '';
    // Check whether the token is expired and return
    // true or false
    return !this.jwtHelper.isTokenExpired(token);
  }

我们还需要将 JWT 模块导入到我们的app.module.ts文件中:

 imports: [
    JwtModule.forRoot({})
  ],

我们将在登录和注册页面上使用isAuthenticated()函数来检查我们的本地存储中是否存在 JWT。如果存在有效的 JWT,我们将重定向应用程序到反英雄列表页面。

让我们看看以下实现:

//login page (TS File)
constructor(private authService: AuthenticateService, private router: Router,) {
    this.checkJWT();
  }
checkJWT() {
    if(this.authService.isAuthenticated()) {
      this.router.navigate(['/anti-heroes'])
    }
  }

注销实现

我们需要实现的最后一个功能是注销功能。为了添加此功能,我们只需要添加一个将令牌从我们的存储中删除的函数。让我们看看以下代码实现:

authenticate.service.ts

export class AuthenticateService {
… other functions
doLogout() {
    let removeToken = localStorage.removeItem('token');
    if (removeToken == null) {
      this.router.navigate(['login']);
    }
  }

在前面的代码示例中,我们添加了一个doLogout()函数,该函数从存储中删除令牌并将应用程序重定向到登录页面。现在,让我们编辑我们的navbar组件以添加注销按钮:

navbar.component.html

<p>
    <mat-toolbar color="primary">
      <span>Angular CRUD</span>
      <span class="example-spacer"></span>
      <button *ngIf="loggedIn" (click)="submit('logout')"
        mat-icon-button>
        <mat-icon>logout</mat-icon>
      </button>
    </mat-toolbar>
  </p>

navbar.component.css

.example-spacer {
    flex: 1 1 auto;
  }

navbar.component.ts

export class NavbarComponent implements OnInit{
  @Output() actionEmitter = new EventEmitter();
  @Input() loggedIn = false;
  submit(action: string) {
    this.actionEmitter.emit(action);
  }
}

在前面的代码实现中,我们为我们的导航栏组件创建了一个发射器。这将发射我们在导航栏中触发的操作,并将其传递到我们的应用程序组件中。

最后一步是在注销按钮被点击时在我们的应用程序组件中调用doLogout()函数。让我们看看以下代码实现:

app.component.html

<app-navbar [loggedIn]="url != '/' && !url.includes('login')" (actionEmitter)="submit($event)"></app-navbar>
<div class="container">
    <router-outlet></router-outlet>
</div>

app.component.ts

export class AppComponent {
  title = 'superheroes';
  url: string = "";
  constructor(private authService: AuthenticateService,
              private router: Router){
    this.getRoute();
  }
  submit(action: string) {
    switch (action) {
      case 'logout':
        this.authService.doLogout();
        break;
      default:
        break;
    }
  }
  getRoute() {
    this.router.events.subscribe(data => {
    if(data instanceof NavigationEnd) {
      this.url = data.url;
    }
   });
  }
}

在前面的代码实现中,我们将认证服务注入到我们的应用程序组件中,并调用了doLogout()函数。如果操作是logout,我们还在路由更改上添加了一个监听器来检查我们的路由是否当前在登录或注册页面上,如果是,我们将从导航栏组件中移除注销按钮。

我们已经成功实现了应用程序的用户认证,但随着我们继续本章的学习,我们还将改进此实现。在下一节中,我们将讨论如何在 Angular 应用程序中保护路由。

保护路由

Angular 的一个基本特性是路由守卫。如果我们要保护我们的路由免受未经认证的直接访问,或者防止用户在意外导航时丢失更改,守卫非常有用。

守卫是 Angular 提供的接口,允许我们通过提供的条件来控制路由的可访问性。这些直接应用于我们想要保护的路由。

让我们看看 Angular 提供的一些守卫:

  • CanActivate:这是在我们要阻止访问的路由上实现的。

    • 方法签名

      canActivate(route:ActivatedRouteSnapshot, state:RouterStateSnapshot):Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
      

前面的代码定义了CanActivate守卫的签名。该函数接受ActivatedRouteSnapshotRouterStateSnapshot参数,并返回一个ObservablePromise,可以是BooleanUrlTree类型。

  • 创建 守卫

    export class AuthGuard implements CanActivate {,
    
    constructor(priavte auth: AuthService, private router: Router) {}
    
    canActivate(route: ActivatedRouteSnapshot, state:RouterStateSnapshot): Observable<boolean> |
    
    Promise<boolean> | boolean {
    
      // return true permitted in the route, else return
    
      // false
    
     }
    
    }
    

在前面的代码示例中,我们创建了一个名为AuthGuard的新类;我们还使用CanActivate守卫实现了它,并为所需的逻辑添加了canActivate()函数。

  • 使用 守卫

    // route-module file
    
    { path: 'hero',
    
     component: HeroComponent,
    
     canActivate: [AuthGuard]
    
    }
    

在上述代码示例中,我们使用新创建的AuthGuard类在我们的英雄路由中保护它,防止没有有效 JWT 的用户访问。

  • CanActivateChild:这与CanActivateGuard类似,但这个守卫用于防止访问子路由。一旦将其添加到父路由,守卫将保护所有子路由。

    • 方法签名:

      canActivateChild(route:ActivatedRouteSnapshot, state:RouterStateSnapshot):Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
      

上述代码示例定义了CanActivateChild守卫的签名。该函数接受ActivatedRouteSnapshotRouterStateSnapshot参数,并返回一个可以是BooleanUrlTree类型的ObservablePromise

  • 创建 守卫:

    export class AuthGuard implements CanActivateChild {
    
    constructor(private auth: AuthService, private router: Router) {}
    
    canActivateChild(route: ActivatedRouteSnapshot, state:RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    
      // return true permitted in the route, else return
    
      // false
    
     }}
    

在上述代码示例中,我们创建了一个名为AuthGuard的新类。我们还使用CanActivateChild守卫实现了它,并添加了canActivateChild()函数以实现所需的逻辑。

  • 使用 守卫:

    {
    
      path: user',
    
      canActivateChild: [AuthGuard],
    
      component: UserComponent,
    
      children: [
    
       { path: ':id', component: ProfileComponent},
    
       { path: ':id/edit', component: SettingsComponent}]
    
    }
    

在上述代码示例中,我们使用新创建的AuthGuard类在我们的用户路径中保护其子路由,这些子路由导航到ProfileComponentSettingsComponent组件,防止没有有效 JWT 的用户访问。

  • CanLoad:这个守卫用于懒加载模块。CanActivate守卫只能防止用户通过路由导航;而CanLoad守卫则防止导航到和下载懒加载的模块。

    • 方法签名:

      canLoad(route:Route,segments:UrlSegment[]):Observable<boolean>|Promise<boolean>|boolean
      

上述代码示例定义了CanLoad守卫的签名。该函数接受RouteUrlSegment[]参数,并返回一个可以是Boolean类型的ObservablePromise

  • 创建 守卫:

    import { CanLoad, Route, Router } from '@angular/router';
    
    export class AuthGuard implements CanLoad {
    
    constructor(private router: Router) {}
    
    canLoad(route:Route,segments:UrlSegment[]):Observable <boolean>|Promise<boolean>|boolean {
    
     // return true or false based on a condition to load
    
     // a module or not
    
    }}
    

在上述代码示例中,我们创建了一个名为AuthGuard的新类。我们还使用CanLoad守卫实现了它,并添加了canLoad()函数以实现所需的逻辑。

  • 使用 守卫:

      {
    
        path: "hero",
    
        loadChildren: () =>
    
          import("./hero/hero.module").then((m) =>
    
                 m.AntiHeroModule),
    
          canLoad: [AuthGuard]
    
      }
    

在上述代码示例中,我们使用新创建的AuthGuard类在我们的英雄路由中保护它,防止用户在没有有效 JWT 的情况下访问和下载资源。

  • CanDeactivate:这是一个用于防止用户离开当前路由的守卫。这在应用中填写表单等场景中非常有用,可以避免在意外导航时丢失一些更改。

    • 方法签名:

      canDeactivate(component: T, currentRoute: ActivatedRoute Snapshot, currentState: RouterStateSnapshot,nextState?: RouterStateSnapshot): Observable<boolean|UrlTree>|Promise<boolean|UrlTree>|boolean |UrlTree;
      

上述代码示例定义了CanDeactivate守卫的签名。该函数接受一个泛型组件,以及ActivatedRouteSnapshotRouterStateSnapshot参数,并返回一个可以是BooleanUrlTree类型的ObservablePromise

  • 创建 守卫:

    // CanDeactivateGuard service
    
    import { Observable } from 'rxjs/Observable';
    
    import { CanDeactivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
    
    export interface CanComponentDeactivate {
    
    canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
    
    }
    
    export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
    
      canDeactivate(component:CanComponentDeactivate,current Route:ActivatedRouteSnapshot, currentState:RouterState Snapshot, nextState?: RouterStateSnapshot): Observable <boolean> | Promise<boolean> | boolean {
    
      return component.canDeactivate();
    
    }
    
    }
    

在上述实现中,我们创建了一个接口,该接口将在CanDeactivateGuard服务的组件中使用:

export class FormComponent implements OnInit, CanComponentDeactivate {
canDeactivate(): Observable<boolean> | Promise<boolean> | boolean {
/* return true or false depends on a specific condition if you want to navigate away from this route or not.*/
}
}

在上述代码示例中,我们实现了为组件创建的接口。

  • 使用 守卫:

    { path: ':id/edit', component: FormComponent, canDeactivate: [CanDeactivateGuard] }
    

在前面的代码示例中,我们使用了新创建的CanDeactivateGuard来防止用户根据canDeactivate()函数上应用的条件从FormComponent导航出去。

我们已经了解了我们可以在应用程序中使用的不同守卫。现在,让我们在我们的 Angular 项目中实现它。

项目实施

在我们的应用程序中需要应用的第一道防线是CanLoad守卫。这是必要的,因为我们希望在没有有效的 JWT 的情况下保护我们的反英雄路由不被访问。要创建CanLoad守卫,执行以下命令:

ng g g core/guards/auth

执行命令后,选择CanLoad选项以生成新的AuthGuard类。这里我们需要更改的唯一事情是canLoad()函数的实现。我们想要应用的条件是如果 JWT 有效,则允许加载路由和模块。

让我们看看以下实现:

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanLoad {
  constructor(private router: Router, private auth:
    AuthenticateService) {}
  canLoad(route: Route, segments:UrlSegment[]):
    Observable<boolean | UrlTree> | Promise<boolean |
      UrlTree> | boolean | UrlTree {
      if (!this.auth.isAuthenticated()) {
        this. router.navigate(['login']);
        return false;
      }
      return true;
}
}

在前面的代码示例中,我们使用了isAuthenticated()函数来检查 JWT 是否有效且未过期。如果它是有效的,这将返回true并允许我们导航路由。否则,它将重定向我们到登录页面。

最后一步是在反英雄路由中应用AuthGuard类;在app-routing.module.ts文件中,我们将使用以下代码:

const routes: Routes = [
… other routes here
  {
    path: "anti-heroes",
    loadChildren: () =>
      import("./anti-hero/anti-hero.module").then((m) =>
        m.AntiHeroModule),
      canLoad: [AuthGuard]
  }
];

我们现在已经在我们的反英雄路由中成功应用了CanLoad守卫。为了测试它是否工作,我们可以尝试删除本地存储中的令牌,这将导致我们重定向到没有有效 JWT 的登录页面。

我们需要的最后一个路由守卫是CanDeactivate守卫;我们将在这个反英雄表单上应用这个守卫,以防止用户在离开表单时丢失更改。要创建我们的CanDeactivate守卫,我们将执行以下命令:

ng g g core/guards/form

执行命令后,选择CanDeactivate选项,这将生成新的FormGuard类。我们将向此类添加一个接口,我们将在form组件中使用它。

让我们看看以下代码:

export interface CanComponentDeactivate {
  canDeactivate: () => Observable<boolean> |
    Promise<boolean> | boolean;
}
@Injectable({
  providedIn: 'root'
})
export class FormGuard implements CanDeactivate<unknown> {
  canDeactivate(
    component: CanComponentDeactivate,
    currentRoute: ActivatedRouteSnapshot,
    currentState: RouterStateSnapshot,
    nextState?: RouterStateSnapshot): Observable<boolean |
      UrlTree> | Promise<boolean | UrlTree> | boolean |
      UrlTree {
      return component.canDeactivate ?
        component.canDeactivate() : true;
  }
}

在前面的代码示例中,我们创建了CanComponentDeactivate接口,该接口将由表单组件实现。这意味着条件将被放置在组件中而不是守卫中。在FormComponent中,我们将添加以下代码:

export class FormComponent implements OnInit, CanComponentDeactivate {
   … other code implementation
  canDeactivate(): Observable<boolean> | Promise<boolean> |
    boolean {
    const confirmation = window.confirm('Are you sure?');
    return confirmation;
  }
   … other code implementation
}

在前面的代码示例中,我们实现了FormComponent,并添加了我们创建的CanComponentDeactivate接口;我们添加了一个window.confirm(),这将弹出一个对话框,询问用户是否想要离开当前路由。这是一个简单的守卫实现,因为我们还可以添加其他条件,例如,如果我们只想在表单有更改时提出这个问题。

最后一步是在FormComponent路由中应用守卫。

让我们看看以下代码:

const routes: Routes = [
 … other routes
  {
    path: "form",
    children: [
      {
        path: "",
        canDeactivate: [FormGuard],
        component: FormComponent
      },
      {
        path: ":id",
        canDeactivate: [FormGuard],
        component: FormComponent
      }
    ]
  },
];

一旦我们应用了CanDeactivate守卫,从反英雄表单导航出去将弹出一个对话框,如图14**.3所示。

图片

14.3 – 在离开表单时显示对话框

我们现在已经在我们的 Angular 应用程序中成功应用了守卫;在下一节中,我们将使用 NgRx 状态管理直接改进我们的 API 认证调用。

调用 API

在之前的一节中,我们已经通过在组件中直接调用认证服务来创建了用户认证。我们还使用setItem函数将生成的 JWT 存储在我们的本地存储中,这也在我们的登录组件中发生。

我们想要实现的是减少我们组件的责任,并且正如我们记得的,我们正在使用 NgRx 状态管理来调用 API,我们组件的唯一责任是派发动作,而 NgRx 将完成剩余的工作。

在本节中,我们将通过使用 NgRx 状态管理的构建块来改进我们的 API 调用。

创建 actions

我们首先需要创建我们认证功能的 actions。我们将在auth/state文件夹中创建一个名为auth.actions.ts的文件,并将有以下的代码:

import { createAction, props } from '@ngrx/store';
export enum AuthActions {
 LOGIN = '[AUTH] Login',
 SET_TOKEN = '[AUTH] Set Token',
 CREATE_USER = '[AUTH] Create User',
 AUTH_ERROR = '[AUTH] AUTH_ERROR',
}
export const setToken = createAction(
    AuthActions.SET_TOKEN,
    props<{ token: string }>(),
);
export const setError = createAction(
    AuthActions.AUTH_ERROR,
    props<{ error: any }>(),
);

在前面的代码中,我们可以看到我们创建了四种动作类型:LOGIN类型将被用于调用登录 API 的效果;CREATE_USER类型将被用于调用注册 API 的效果;SET_TOKEN类型将被用于在登录 API 调用后,将生成的 JWT 设置到 store 中的 reducer;最后,AUTH_ERROR类型将被用于在登录或注册 API 返回错误时在 store 中设置错误。

创建 effects

在创建我们的 actions 之后,现在我们将创建用于调用登录和注册 API 的效果。我们将在auth/state文件夹中创建一个名为auth.effects.ts的文件,并将有以下的实现:

登录 Effect

@Injectable()
  loginUser$ = createEffect(() => {
    return this.actions$.pipe(
        ofType(AuthActions.LOGIN),
        mergeMap(((data: {type: string, payload: User}) =>
          this.authService.login(data.payload)
          .pipe(
            map(data => ({ type: AuthActions.SET_TOKEN,
                           token: data.token })),
            tap(() =>
              this.router.navigate(["anti-heroes"])),
            catchError(async (data) => ({ type:
              AuthActions.AUTH_ERROR, error: data.error }))
          ))
        ))
    }, {dispatch: true}

注册 Effect

  createUser$ = createEffect(() => {
    return this.actions$.pipe(
        ofType(AuthActions.CREATE_USER),
        mergeMap(((data: {type: string, payload: User}) =>
          this.authService.register(data.payload)
          .pipe(
            tap(() =>  this.router.navigate(["login"])),
            catchError(async (data) => ({ type:
              AuthActions.AUTH_ERROR, error: data.error }))
          ))
        ))
    }, {dispatch: true}
  );

在前面的代码中,我们已经为登录和注册 API 创建了效果。在loginUser$效果中,一旦登录 API 调用成功,它将派发SET_TOKEN动作并传递生成的 JWT,这也会将我们重定向到反英雄页面。

这是我们之前章节中实现的行为。另一方面,一旦注册 API 调用成功,createUser$效果将再次将我们重定向到登录页面。这是一个简单的行为,如果你希望注册成功后发生其他操作,你可以自定义。

我们还实现了AUTH_ERROR动作,当登录或注册 API 失败时将被调用。

创建 reducers

我们下一步需要做的是创建 reducers。我们将在auth/state文件夹中创建一个名为auth.reducers.ts的文件,并将有以下的实现:

export interface AuthState {
    token: string;
    error: any
}
export const initialState: AuthState = {
    token: "",
    error: null
}
export const authReducer = createReducer(
  initialState,
  on(setToken, (state, { token }) => { return {...state,
     token}}),
  on(setError, (state, { error }) => { return {...state,
     error}}),
  );

在前面的代码示例中,我们可以看到AuthState有两个字段,分别是tokenerrortoken字段将在认证 API 成功调用setToken动作后包含有效的 JWT,而error字段将包含登录或注册 API 失败时生成的错误。

创建 selectors

在创建 reducer 之后,我们现在将创建我们的 selector。在这种情况下,我们的 selector 将非常简单,因为我们只需要一个用于error字段的 selector。我们将在auth/state文件夹中创建一个名为auth.selectors.ts的文件,并且我们将有以下的实现:

import { createSelector, createFeatureSelector } from '@ngrx/store';
import { AppState } from 'src/app/state/app.state';
import { AuthState } from './auth.reducers';
export const selectAuthState = createFeatureSelector<AuthState>('authState')
export const selectError = () => createSelector(
    selectAuthState,
    (state: AuthState) => state.error
)

在前面的代码示例中,我们为我们的error字段创建了一个 selector;我们需要这个 selector 来在我们的组件中向用户显示错误信息。

在本地存储中同步

我们将要实现的下个功能是我们状态在本地存储中的同步。我们可以通过在我们的应用程序中使用localStorage.setItem()来实现这一点。然而,使用这种方法将不会是可维护的,并且存储中的值设置将位于不同的位置。

为了有更好的实现,我们将使用ngrx-store-localstorage库。为了安装库,我们将执行以下命令:

npm install ngrx-store-localstorage --save

在成功安装库之后,我们应该确定我们想要与本地存储同步的状态。在我们的例子中,我们希望auth状态中的token字段能够同步。为了实现这一点,我们在auth.module.ts文件中进行了以下代码更改:

import { localStorageSync } from 'ngrx-store-localstorage';
export function localStorageSyncReducer(reducer: ActionReducer<any>): ActionReducer<any> {
  return localStorageSync({keys: ['token']})(reducer);
}
const metaReducers: Array<MetaReducer<any, any>> = [localStorageSyncReducer];
@NgModule({
  declarations: [
   … declared components
  ],
  imports: [
   … other imported modules
    StoreModule.forFeature('authState', authReducer,
     {metaReducers}),
    EffectsModule.forFeature([AuthEffects]),
  ]
})

在前面的代码中,我们可以看到我们创建了一个专门的 reducer,它从ngrx-store-localstorage中调用localStorageSync,负责在本地存储中添加值。

我们也可以指定我们想要同步的字段,在这种情况下,我们在键数组中添加了 token。一旦 token 状态值发生变化,新的值也将被放置在我们的存储中。

分发和选择组件

最后一步是分发动作并使用 selector 来处理登录和注册组件。让我们看看以下代码实现中的登录和注册组件:

login.component.ts

export class LoginComponent{
  error$ = this.store.select(selectError());
  errorSub: Subscription | undefined;
  constructor(private store: Store, private authService:
    AuthenticateService, private router: Router,
      private _snackBar: MatSnackBar) {
    this.checkJWT();
    this.getError();
  }
  submit(data: User) {
    this.store.dispatch({type: AuthActions.LOGIN,
                         payload: data})
  }
  ngOnDestroy(): void {
    this.errorSub?.unsubscribe();
  }
  getError() {
    this.error$.subscribe(data => {
      if(data) {
        this._snackBar.open(data.message, "Error");
      }
    })
  }
… other code implementation
}

register.component.ts

export class RegisterComponent implements OnInit, OnDestroy {
  error$ = this.store.select(selectError());
  errorSub: Subscription | undefined;
  constructor(private store: Store,  private _snackBar:
              MatSnackBar) {
    this.getError();
  }
  ngOnDestroy(): void {
    this.errorSub?.unsubscribe();
  }
  submit(data: User) {
    this.store.dispatch({type: AuthActions.CREATE_USER,
                         payload: data})
  }
  getError() {
    this.errorSub = this.error$.subscribe(data => {
       if(data) {
         this._snackBar.open(data.message, "Error");
       }
     })
   }
… other code implementation
}

我们可以在前面的代码中看到,登录和注册页面几乎有相同的实现。我们已经在submit函数中移除了对登录和注册服务的调用,并用分发动作来替换。我们还使用了selectError()selector 来监听 API 是否产生了错误。

概述

通过这种方式,我们已经到达了本章的结尾;让我们回顾一下你学到的宝贵知识。

我们现在知道了如何在 Angular 应用中实现用户认证,我们使用了一个 HTTP 拦截器来拦截 HTTP 请求,以转换其头部并添加有效的 JWT 以进行 API 调用。我们还了解到了不同的路由守卫,它们允许我们保护路由免受未经授权的访问或防止在离开路由时意外丢失数据。最后,我们学习了如何通过改进 Angular 中认证的实现方式来使用 NgRx 状态管理。

下一章将教会我们如何使用 Cypress 框架在 Angular 中编写端到端测试。

第十五章:在 Angular 中编写测试

在上一章中,我们学习了如何通过添加用户身份验证、检索用户信息和保护路由来保护 Angular 应用程序。

如果我告诉你,在前端编写测试可以很有趣和令人兴奋,你会怎么想?本章将向你展示 Cypress E2E(端到端测试)的开发者体验有多好。为什么前端开发者喜欢 Cypress 框架?

本章将教你如何编写基本的 Cypress 测试和模拟 HTTP 请求进行测试。

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

  • 开始使用 Cypress

  • 编写简单的 Cypress 测试

  • 模拟 HTTP 响应和拦截 HTTP 请求

技术要求

以下链接将带你去本章的完成版本:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-15/superheroes

开始使用 Cypress

在本节中,你将了解 Cypress 是什么以及如何开始使用它。

Cypress 是一个用于测试 Web 应用的端到端无框架。你可以在 HTML 标签中编写测试 ID,并断言这些 HTML 标签是否以你预期的方式渲染。

让我们定义一下 端到端 的含义。端到端意味着用户在登录你的 Web 应用程序并完成登录、注册、结账、查看个人资料、登出、填写表格等任务后如何使用你的应用程序。

例如,你可以在不同的示例案例或场景中测试或检查你的 Web 应用程序的 UI:

  • 登陆页面的价值主张句子中包含单词 sale

  • 你网站某个部分的部件计数是你预期的

  • 在点击 清除 按钮后,结账页面上的购物车中的项目将被清除

  • 当 Web 应用程序的 URL 是 domain.com/login 时,会出现登录表单

这些场景是关于在 Web 应用程序中要测试的内容的示例。Cypress 是一个测试框架,你可以编写和运行测试,这些测试的设置和配置不需要花费太多时间。

现在,让我们看看如何安装 Cypress。

安装

要开始使用 Cypress,我们必须通过运行以下命令从 npm 仓库安装它:

npm i-D cypress @testing-library/cypress

前面的 npm 命令将在 dev 依赖包中安装 Cypress 和 cypress 测试库。@testing-library 是一组在 Web 开发中常用的测试工具,它使得开发者的生活更加轻松。

在下一节中,我们将学习我们必须添加到 package.json 文件的 npm 脚本中,以便我们可以在本章后面运行测试。

npm 脚本

为了让我们以后更容易运行 Cypress 测试,给我们的 package.json 文件添加一个新的 npm 脚本是一个好主意,以帮助我们轻松运行命令。在 package.json 文件的 scripts 块中插入以下键值对:

"test": "npx cypress open"

上述键值对帮助你通过在终端中运行npm run test命令来运行测试。在终端中运行完整的堆栈应用程序以及npm run test命令以启动 Cypress。

将会打开一个交互式浏览器应用程序,你可以在其中运行你的测试。图 15.1 展示了在运行npm run test命令后,Cypress 仪表板上的欢迎信息。在这里,我们将使用端到端测试,所以点击该框继续:

图 15.1 – Cypress 仪表板

图 15.1 – Cypress 仪表板

图 15.2 展示了将自动添加到你的 Angular 应用程序目录中的文件夹和文件。这些对于任何使用 Cypress 的 Web 应用程序都是必需的。由于我们正在使用 TypeScript,我们将添加更多内容,但我们将在稍后进行。现在,只需点击继续按钮;你将被带到以下屏幕:

图 15.2 – 配置文件

图 15.2 – 配置文件

图 15.3 展示了你可以选择不同的浏览器来运行你的端到端测试。你可以选择 Chrome、Microsoft Edge 和 Firefox。我们在这里停止,因为我们还没有编写任何测试,我们还需要帮助 Cypress 学习 TypeScript。在终端中按Ctrl + C停止 Cypress 运行:

图 15.3 – 选择浏览器

图 15.3 – 选择浏览器

图 15.4 展示了生成的cypress文件夹,其中包含它们内部的额外文件夹,以及用于编辑 Cypress 一些默认行为的cypress.config.ts文件。我们稍后会讨论cypress.config.ts文件:

图 15.4 – Cypress 文件夹和文件

图 15.4 – Cypress 文件夹和文件

Cypress 的 TypeScript

为了帮助 Cypress 理解 TypeScript,我们必须将tsconfig.json文件添加到cypress文件夹的根目录。创建一个新文件并命名为tsconfig.json;然后,写入以下配置:

{
    "extends": "../tsconfig.json",
    "compilerOptions": {
        "types":["cypress", "@testing-library/cypress"],
        "isolatedModules": false,
        "allowJs": true,
        "experimentalDecorators": true,
        "skipLibCheck": true
    },
    "include": [
        "./**/*.ts",
    ],
    "exclude": []
}

上述代码继承了我们 Angular 应用程序根目录下的tsconfig.json文件。然后,我们将cypress@testing-library/cypress添加到tsconfig.json文件中,这将帮助 Cypress 理解 Cypress 目录中的任何 TypeScript 文件。数组显示我们正在将这些 TypeScript 文件包含在目录的任何级别。

现在我们已经为 TypeScript 设置了 Cypress,让我们更新 Angular 应用程序中的cypress.config.ts文件。

更新 Cypress 配置文件

什么是 Cypress 配置文件?cypress.config.ts文件用于存储任何特定于 Cypress 的配置,例如环境、超时、文件夹/文件、屏幕截图、视频、下载、浏览器、视口等。

你通过提供任何你想要添加的可选配置来修改 Cypress 的默认行为。因此,使用以下代码更新cypress.config.ts文件:

import { defineConfig } from "cypress";
export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
    baseUrl: "http://localhost:4200",
    video: false,
  },
});

上述代码配置了 Cypress 的基本 URL,其中 Angular 运行。还有一个配置用于禁用端到端测试录制视频。我们只使用 baseUrl 和 Cypress 配置的视频属性。

在下一节中,我们将开始编写一些简单的 Cypress 测试,以帮助您在编写测试时建立信心,并了解在 Cypress 中编写测试有多容易。

编写 Cypress 测试

在本节中,我们将开始编写简单的 Cypress 测试,以了解使用 Cypress 编写测试有多有趣且容易。我们将通过在身份验证表单中的 HTML 标签中添加一个测试属性来开始测试。我们将编辑 auth-form.component.html 文件来编写 test-id 属性。以下是 auth-form.component.html 行中发生的变化:

<mat-card-title data-cy="auth-title">{{title}}</mat-card-title>

您可以看到我们在前面的代码中添加的属性。data-cy 是一个测试 ID 属性,Cypress 将使用它来定位我们想要测试的 HTML 元素。

现在我们已经添加了第一个测试 ID,让我们转到 cypress 目录内的 e2e 文件夹并创建一个新文件。文件名需要包含 .cy。将新文件命名为 anti-heroes.cy.ts,然后添加以下代码:

/// <reference types="cypress"/>
describe("Anti Heroes Page", () => {
  // basic test
  it("should display login page", () => {
    cy.visit("/");
    cy.url().should("include", "/login");
    cy.get("[data-cy=auth-title]").should("contain",
                                          "Login");
  });
});

上述代码提供了我们测试的第一份描述。首先,我们添加了对 Cypress 类型的引用,以便使用 TypeScript 获取额外的工具。

然后,我们有 describe 函数,它用于分组测试。describe 函数有两个参数。第一个参数是一个字符串,它传递给 describe 函数将使用的名称。第二个参数是一个回调函数,它将包含 describe 函数下的所有测试。

it 函数也接受一个字符串作为测试的名称和一个回调函数,用于测试的详细信息。第一个 it 函数测试如果用户访问 Angular 应用 URL 的根域名,认证用户是否可以看到登录页面。

cy 是一个对象,您可以在其中链接不同类型的命令。我们使用 visit 命令,它允许我们编写在运行测试时将用于导航到 localhost:4200 的 URL。cy.url 断言可以在登录子页面上找到 URL。我们还通过 data-cy= "auth-title" 属性测试 mat-card-title,在这个测试中包含单词 登录

如您所见,第一个测试很容易编写。编写测试的设置也很简单。但在我们运行第一个测试之前,让我们为我们的测试创建一个用户:

图 15.5 – 用于端到端测试的用户(Angular 应用登录页面)

图 15.5 – 用于端到端测试的用户(Angular 应用登录页面)

图 15.5 中,我们正在为我们的端到端测试创建一个用户。user@cypress.com 用户名只是一个虚构的电子邮件地址,您不需要使用它。您可以使用任何您想要的电子邮件地址。我们将使用 user@cypress.com 用户登录我们的应用,并像真实用户一样使用我们的应用程序。

现在,转到你的终端并运行 npm run test 命令来运行 Angular 的 E2E 测试。转到 Cypress 控板中的 Specs 部分,以找到 E2E specs 列表:

图 15.6 – E2E specs

图 15.6 – E2E specs

图 15**.6 展示了用于测试的 E2E specs 文件列表。这里只有一个 spec 文件;这是我们之前创建的那个。

点击 anti-heroes spec 运行我们创建的测试,查看它是否通过或失败:

图 15.7 – 通过的测试

图 15.7 – 通过的测试

图 15**.7 显示了我们测试中编写的两个断言都通过了。这意味着登录子页面是在用户在根域名页面上着陆后未认证用户被重定向到的位置。通过的测试还告诉我们,用户看到的表单标题是 登录 而不是 注册

如果你将 should("contain", "Login"); 改为 should("contain", "Register");,测试将失败,这表明测试是准确的,并且它不仅仅是通过我们写在测试中的任何内容。

这样,我们就完成了使用 Cypress 编写的简单 E2E 测试,并看到我们的测试通过了。在下一节中,我们将模拟 Angular 应用程序发送的 HTTP 响应和拦截 HTTP 请求,这样我们就不需要后端应用程序来运行这些测试了。

模拟 HTTP 响应和拦截 HTTP 请求

模拟测试可以帮助我们隔离并专注于测试,而不是外部依赖或行为的状态。在本节中,我们将模拟服务器的 HTTP 响应并拦截 Angular 应用程序的 HTTP 请求进行测试。我们将拦截 HTTP 请求,以便向 Angular 发送模拟响应,而不污染我们的开发数据库。

我们将首先向 cypress 目录的根目录添加一个新文件。将文件命名为 global.d.tsglobal.d.ts 文件,也称为 全局库,提供了一种方法,使接口和类型在我们的 TypeScript 代码中全局可用。

在创建 global.d.ts 文件后,在其内部写入以下代码:

/// <reference types="cypress"/>
declare namespace Cypress {
    interface Chainable {
        getCommand(url: string, responseBody: Array<any>):
          Chainable<any>;
        deleteCommand(url: string): Chainable<any>;
        postCommand(url: string, requestBody: any):
          Chainable<any>;
    }
}

上述代码允许我们使用自定义链式命令,这样我们每次在写入 cy 后都会获得 IntelliSense。

现在我们已经添加了 global.d.ts,让我们安装一个可以生成唯一通用 ID(也称为 UUID)的库,并将其用作我们创建的模拟对象的临时 ID,以响应 Angular 的 HTTP 请求。

以下 npm 命令将安装一个名为 uuidnpm 库,帮助我们生成所需的 UUID:

npm i uuid

我们还需要安装的 uuid 库的类型:

npm i -D @types/uuid

之前的 npm 命令将安装 uuid TypeScript 类型。

现在,我们需要一个文件来存放我们的 Cypress 测试中的 fixtures。fixture 是一个对象或数组固定状态,用作运行测试的基线:

  1. 前往您的应用程序中cypress目录下的fixtures文件夹。创建两个 JSON 文件,并将它们命名为anti-heroes.jsonuser.json

  2. 将文件内容从github.com/PacktPublishing/Spring-Boot-and-Angular/blob/main/Chapter-15/superheroes/cypress/fixtures/anti-heroes.json复制并粘贴到anti-heroes.json文件中。

  3. 接下来,将文件内容从github.com/PacktPublishing/Spring-Boot-and-Angular/blob/main/Chapter-15/superheroes/cypress/fixtures/user.json复制并粘贴到user.json文件中。

上述 JSON 对象是我们将要使用的对象。我们将使用这些作为响应体发送模拟响应。

  1. 现在,让我们更新cypress目录下support文件夹中的commands.ts文件。使用以下代码:

    // @ts-check
    
    ///<reference path="../global.d.ts" />
    
    /// <reference types="cypress"/>
    
    import { v4 as uuidv4 } from "uuid";
    
    Cypress.Commands.add("getCommand", (url: string, responseBody: Array<any>) => {
    
        cy.intercept("GET", url, {
    
            statusCode: 200,
    
            body: responseBody,
    
        });
    
    });
    
    Cypress.Commands.add("deleteCommand", (url: string) => {
    
        cy.intercept("DELETE", url, {
    
            statusCode: 200,
    
        });
    
    });
    
    Cypress.Commands.add("postCommand", (url: string, requestBody: any) => {
    
        requestBody.id = uuidv4();
    
        cy.intercept("POST", url, {
    
            statusCode: 201,
    
            body: requestBody,
    
        });
    
    });
    

上述代码实现了我们在global.d.ts文件中编写的自定义链式命令。getCommanddeleteCommandpostCommand需要作为字符串的 URL 来拦截任何 HTTP 请求。自定义链式命令需要一个状态,这将是一个固定值。

  1. 现在,让我们在anti-heroes.cy.ts中编写更多的测试。但首先,我们必须为我们将要编写的测试添加更多的测试 ID。

前往auth-form.component.html并使用以下代码更新代码:

<mat-card>
    <mat-card-title
      data-cy="auth-title">{{title}}</mat-card-title>
    <mat-card-content>
        <form [formGroup]="form"
          (ngSubmit)="submit()">
        <p *ngIf="error" class="error">
            {{ error }}
        </p>
        <p>
            <mat-form-field>
            <input type="text"
              matInput placeholder="Username"
              formControlName="email"
              data-cy="email">
            </mat-form-field>
        </p>
        <p>
            <mat-form-field>
            <input type="password"
              matInput placeholder="Password"
              formControlName="password"
              data-cy="password">
            </mat-form-field>
        </p>
        <div class="button">
            <button type="submit"
              mat-button data-cy="submit-login">
              {{title}}</button>
        </div>
        <p *ngIf="title == 'Login'" class="link"
          [routerLink]="['register']"
          routerLinkActive="router-link-active">
          Create account</p>
        <p *ngIf="title == 'Register'"
          class="link" [routerLink]="['']"
          routerLinkActive="router-link-active">
          Sign In</p>
        </form>
    </mat-card-content>
</mat-card>

上述代码包含四个data-cy属性,这些属性将用作目标选择器。您可以在mat-card-title、输入和button元素中找到data-cy选择器。

  1. 下一个要更新的文件将是navbar.component.html。使用以下代码更新文件:

    <p>
    
      <mat-toolbar color="primary">
    
        <span data-cy="logo">Angular CRUD</span>
    
      </mat-toolbar>
    
    </p>
    

上述代码包含一个data-cy属性,您可以在span元素中找到它。

  1. 接下来,我们需要更新anti-hero-list-component.html文件:

    <table mat-table [dataSource]="antiHeroes" class="mat-elevation-z8">
    
        <!-- Data for columns -->
    
        <ng-container *ngFor="let item of headers"
    
          [matColumnDef]="item.fieldName">
    
          <th mat-header-cell *matHeaderCellDef>
    
           {{item.headerName}} </th>
    
          <td mat-cell *matCellDef="let element"
    
            data-cy="row"> {{element[item.fieldName]}}
    
          </td>
    
        </ng-container>
    
        <!-- Actions for specific item -->
    
        <ng-container matColumnDef="actions">
    
            <th mat-header-cell *matHeaderCellDef>
    
              Actions </th>
    
            <td mat-cell *matCellDef="let element">
    
                <button (click)="selectAntiHero(element,
    
                  0)" mat-raised-button color="primary"
    
                  data-cy="view">
    
                    <mat-icon>pageview</mat-icon> View
    
                </button>
    
                &nbsp;
    
                <button (click)="selectAntiHero(element,
    
                  1)" mat-raised-button color="warn"
    
                  data-cy="delete">
    
                    <mat-icon>delete</mat-icon> Delete
    
                </button>
    
            </td>
    
        </ng-container>
    
        <tr mat-header-row
    
          *matHeaderRowDef="headerFields"></tr>
    
        <tr mat-row *matRowDef="let row;
    
          columns: headerFields"></tr>
    
      </table>
    

上述代码包含三个data-cy属性,您可以在td元素和两个button元素中找到它们。

  1. 接下来,我们必须使用以下代码编辑anti-hero-command-bar.component.html文件:

    <p>
    
      <mat-toolbar>
    
        <button mat-raised-button color="primary"
    
          (click)="emitAction(0)"data-cy="create">
    
          <mat-icon>fiber_new</mat-icon> Create
    
        </button>
    
        &nbsp;
    
        <button mat-raised-button color="warn"
    
          (click)="emitAction(1)"data-cy="delete-all">
    
          <mat-icon>delete</mat-icon> Delete All
    
        </button>
    
        <button mat-button color="danger"
    
          (click)="logOut()" data-cy="logout">
    
          <mat-icon>logout</mat-icon> logout
    
        </button>
    
      </mat-toolbar>
    
    </p>
    

上述代码包含三个选择器,您可以在button元素中找到它们。

  1. 最后一个要更新的文件是anti-hero-form.component.html

    <mat-card class="form-card">
    
      <h2>{{ selectedAntiHero ? "Update/View Hero" :
    
        "Create Hero" }}</h2>
    
      <form class="anti-hero-form" [formGroup]="form">
    
        <mat-form-field class="example-full-width"
    
          appearance="fill"><mat-label>
    
          First Name</mat-label>
    
          <input matInput formControlName="firstName"
    
            placeholder="Ex. Tony" data-cy="firstName"/>
    
        </mat-form-field><mat-form-field
    
          class="example-full-width" appearance="fill">
    
          <mat-label>Last Name</mat-label>
    
          <input matInput formControlName="lastName"
    
            placeholder="Ex. Stark" data-cy="lastName"/>
    
        </mat-form-field>
    
        <mat-form-field class="example-full-width"
    
          appearance="fill"><mat-label>House</mat-label>
    
          <input matInput formControlName="house"
    
            placeholder="Ex. California" data-cy="house"/>
    
            </mat-form-field><mat-form-field
    
            class="example-full-width" appearance="fill">
    
            <mat-label>Known As</mat-label>
    
          <input matInput formControlName="knownAs"
    
            placeholder="Ex. Iron Man" data-cy="knownAs"
    
            /></mat-form-field><div class="button-group">
    
          <button mat-raised-button color="primary"
    
           (click)="emitAction()" data-cy="action"
    
          >{{ actionButtonLabel }}</button>
    
          &nbsp;
    
          <button mat-raised-button color="warn"
    
            (click)="clear()">Clear</button>
    
        </div>
    
      </form>
    
    </mat-card>
    

上述代码包含五个输入,您可以在input元素和button元素中找到它们。

通过这样,我们已经为稍后将要测试的 HTML 元素添加了必要的测试 ID 属性。当我们开始在anti-heroes.cy.ts文件中编写测试时,我们需要提到的data-cy测试 ID。

  1. 现在,让我们开始在anti-heroes.cy.ts中编写测试。以下是新代码:

    /// <reference types="cypress"/>
    
    describe("Login Page", () => {
    
      beforeEach(() => {
    
        cy.fixture("anti-heroes").then(function (data) {
    
          /* register custom commands. */
    
          cy.getCommand("/api/v1/anti-heroes", data);
    
          cy.deleteCommand("/api/v1/anti-heroes/*");
    
        });
    
        cy.visit("/");
    
        cy.fixture("user").then((data: { email: string;
    
          password: string }) => {
    
          cy.get("[data-cy=email]").type(data.email);
    
          cy.get("[data-cy=password]")
    
            .type(data.password);
    
          cy.get("[data-cy=submit-login]").click();
    
        });
    
      });
    
      afterEach(() => {
    
        cy.get("[data-cy=logout]").click();
    
      });
    
      it.skip("should display login page", () => {
    
        cy.visit("/");
    
        cy.url().should("include", "/login");
    
        cy.get("[data-cy=auth-title]").should("contain",
    
          "Login");
    
      });
    
    });
    

上述代码展示了一个包含两个其他函数的describe函数。

describe函数内部的第一个函数被称为beforeEach,它在每次测试开始运行时都会运行。beforeEach函数保持其状态并在测试中使用它。这个函数适用于测试必须执行与其他测试相同精确操作的场景——例如,访问特定的 URL、登录以及使用自定义链式命令(如getCommanddeleteCommand)拦截 HTTP 调用。

describe函数内部的第二个函数被称为afterEach。每次测试结束时都会运行afterEach函数。这个函数适合在测试中进行清理或注销用户。beforeEachafterEach函数节省了我们大量的重复代码。

  1. 现在,让我们向anti-heroes.cy.ts文件添加一些测试。复制以下代码并将其放在我们编写的第一个测试下面:

    it("should display logo", () => { cy.get("[data-cy=logo]")
    
    .should("contain", "Angular CRUD");
    
    });
    
    it("should render anti-heroes", () => {
    
      cy.fixture("anti-heroes").then(function (data) {
    
        cy.get("[data-cy=row]").should("have.length", 24);
    
      });
    
    });
    
    it("should remove a card after clicking a delete button", () => { const index = 1;
    
      cy.get("[data-cy=delete]").eq(index).click();
    
      cy.get("[data-cy=row]").should("have.length", 20);
    
    });
    
    it("should add a new hero", () => { const firstName = "Bucky";
    
      const lastName = "Barnes";
    
      const house = "Marvel";
    
      const knownAs = "The Winter Soldier";
    
      cy.get("[data-cy=create]").click();
    
      cy.get("[data-cy=firstName]").type(firstName);
    
      cy.get("[data-cy=lastName]").type(lastName);
    
      cy.get("[data-cy=house]").type(house);
    
      cy.get("[data-cy=knownAs]").type(knownAs);
    
      cy.postCommand("/api/v1/anti-heroes", {
    
        firstName,lastName,house,knownAs,});
    
      cy.get("[data-cy=action]").click();
    
      cy.fixture("anti-heroes").then(function (data) {
    
        cy.get("[data-cy=row]").should("have.length", 24);
    
      });
    

上述代码显示了我们将要用于 Cypress 的测试。你可以看到fixture需要一个字符串,即 JSON 文件的名称。fixture中的数据是匿名函数的参数。

我们断言24是因为我们为每个使用data-cy="row"的对象有四个元素,这是我们构建用户界面上的 HTML 元素的方式。anti-heroes.json文件中也有六个对象。

新增的测试展示了我们如何使用eq关键字和索引号从渲染的 UI 列表或数组中选择特定的对象。

新增的测试还展示了如何通过调用clicktype函数将文本写入输入字段。然后,你可以使用postCommand自定义链式命令来拦截 HTTP POST 请求。

  1. 最后,在运行测试之前,通过调用skip来让 Cypress 跳过我们之前编写的简单测试,如下所示:

    it.skip("should display login page", () => {
    

上述代码将简单的测试从运行改为不运行

anti-heroes.cy.ts规范文件的完整代码可以在github.com/PacktPublishing/Spring-Boot-and-Angular/blob/main/Chapter-15/superheroes/cypress/e2e/anti-heroes.cy.ts找到。

现在,我们可以运行anti-heroes.cs.ts规范文件,看看是否一切都会通过,如下面的图所示:

图 15.8 – 通过的测试

图 15.8 – 通过的测试

图 15**.8 显示了跳过了显示登录页面的测试,而其他测试都通过了。

你可以像这样看到beforeEach函数内部的操作:

图 15.9 – BEFORE EACH DOM 快照

图 15.9 – BEFORE EACH DOM 快照

图 15**.9 显示了beforeEach函数所采取的步骤。这些步骤是 Web 应用的 DOM 快照。

让我们也检查anti-heroes.cy.ts规范文件的测试主体。你应该看到以下信息:

图 15.10 – 测试主体 DOM 快照

图 15.10 – 测试主体 DOM 快照

图 15.10 展示了在测试主体中采取的步骤。这些是你写在anti-heroes.cy.ts文件中的操作。

我们还可以看到afterEach函数内部发生了什么。你应该能看到以下输出:

图 15.11 – 每次 DOM 快照之后

图 15.11 – 每次 DOM 快照之后

图 15.11 展示了afterEach函数内部的步骤。在这里,你可以看到afterEach函数进行了注销并将用户重定向到应用程序的登录页面。

这就是编写 Cypress 测试的方法。现在,让我们总结一下本章所涵盖的内容。

摘要

通过这些,你已经到达了本章的结尾。首先,你学习了 Cypress 是什么,以及如何轻松地设置和编写端到端测试。你还学习了如何拦截 HTTP 请求和模拟 HTTP 响应。

在下一章中,你将学习如何将前端和后端应用程序打包成一个单一的可执行 JAR 文件。

第四部分:部署

本部分演示了将后端和前端应用程序以现代方式交付的方法。本部分涵盖了以下章节:

  • 第十六章使用 Maven 打包后端和前端

  • 第十七章部署 Spring Boot 和 Angular 应用程序

第十六章:使用 Maven 打包后端和前端

在上一章中,我们学习了 Cypress 是什么以及它的好处。我们还学习了如何编写 Cypress 端到端测试以及如何运行它们。最后,我们学习了如何拦截 HTTP 请求来模拟响应。

本章将教会您如何结合您的 Angular 和 Spring Boot 应用程序,然后在您的本地机器上运行它们。

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

  • 前端-maven-plugin 是什么?

  • 向 Spring Boot 和 Angular 集成添加配置

  • 打包 Spring Boot

  • 运行 JAR 文件

技术要求

以下链接将带您到本章代码的完成版本:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-16/superheroes

前端-maven-plugin 是什么?

好的——在我回答 frontend-maven-plugin 是什么的问题之前,让我们看看我们如何打包我们的应用程序。我们可以构建 Spring Boot 以生成 JAR 文件,并创建 Angular 的生产构建。

但如果我们能创建一个包含 Angular 生产构建的 Spring Boot JAR 文件会怎样?将前端和后端放入单个 JAR 文件的方法将为我们提供一种更简单的方式来部署应用程序到测试和生产环境。

这将允许我们管理 API 和前端应用程序的单一路径。为此,我们需要一个名为 frontend-maven-plugin 的 Maven 插件(github.com/eirslett/frontend-maven-plugin),它将帮助我们创建一个包含我们的后端和前端的 JAR 文件。

一些要求确保我们的后端和前端可以协同工作。在下一节中,我们将了解我们后端和前端需要哪些配置。

向 Spring Boot 和 Angular 集成添加配置

在本节中,我们将向 Spring Boot 应用程序和 Angular 应用程序中编写一些配置,以确保 Spring Boot 可以在生产环境中运行,并且 Angular 可以渲染 Web 应用程序的用户界面。

首先,让我们把 Angular 应用程序移动到我们的 Spring Boot 应用程序的目录中。

将 Angular 应用程序添加到 Spring Boot 项目中

在本节中,我们将把 Angular 应用程序移动到 Spring Boot 项目中。通过这样做,Spring Boot 项目内部将包含一个 Angular 项目。

首先,在 Spring Boot 项目中创建一个名为 frontend 的新文件夹。将 Angular 应用程序的文件和文件夹移动到 frontend 文件夹中,如下所示:

图 16.1 – Spring Boot 项目中的前端文件夹

图 16.1 – Spring Boot 项目中的前端文件夹

图 16.1 展示了 Spring Boot 项目内部 frontend 文件夹中的所有 Angular 文件和文件夹。

你可以随意命名 frontend 文件夹,只要将 frontend 文件夹的路径映射到 fileset 属性的 workingDirectory 属性,这是 Apache Maven AntRun 插件的功能,它允许你在 Maven 中运行 Ant 任务。

让我们使用两个 Maven 插件,frontend-maven-pluginmaven-antrun-plugin,我们将在下一节中需要它们。

使用 frontend-maven-plugin

在本节中,我们将使用 frontend-maven-plugin,它将本地安装 npm 和 Node.js。它还将在 frontend 文件夹中运行 npm build 命令,并复制 npm build 生成的构建文件。

那么,让我们开始吧:

  1. 前往你的 pom.xml 文件,并在你的 Maven pom 文件中的构建插件之一插入以下代码:

    <plugin>
    
       <groupId>com.github.eirslett</groupId>
    
       <artifactId>frontend-maven-plugin</artifactId>
    
       <version>1.12.1</version>
    
       <configuration>
    
          <workingDirectory>frontend</workingDirectory>
    
          <installDirectory>target</installDirectory>
    
       </configuration>
    
    // See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-16/superheroes
    
    <plugin>
    

前面的标记显示,在构建 Spring Boot 应用程序时,插件将在 frontend 工作目录中安装 Node.js v16.17.0npm CLI 8.19.1。它还将执行 npm install 命令以下载 Angular 应用程序的所有依赖包。

在执行所有必要的安装后,插件将执行下一个操作是 npm run build 命令,这将创建 Angular 应用程序的生产构建。

  1. 接下来,我们必须使用以下代码编辑 Angular 应用程序的 package.json 文件中的 npm build 脚本:

    "build": "ng build --configuration production",
    

上述代码告诉 Angular,npm run build 是用于生产构建。

  1. 我们还必须在 Angular 的环境文件夹中的 environment.prod.ts 文件中进行编辑。将代码更改为以下内容:

    export const environment = {
    
      production: true,
    
      apiURL: "http://localhost:8080/api/v1",
    
      authURL: "http://localhost:8080"
    
    };
    

apiURLauthURL 只是临时的。我们将更改它们,并在应用程序的实际部署中使用真实的 API URL 和认证 URL 属性。我们需要添加前面的代码,因为我们正在我们的应用程序中使用 apiURLauthURL 进行开发,但我们缺少生产环境的值。

当应用程序为生产环境构建时,Angular 应用程序将收集 environment.prod.ts 中的值,而不是使用 environment.ts 文件。

现在,让我们了解 maven-antrun-plugin 并再次配置我们的 .pom 文件。

使用 maven-antrun-plugin

本节将在 Spring Boot 应用程序中使用 maven-antrun-plugin。打开你的 pom.xml 文件,并在标记的 build 块中的一个插件中插入以下代码。将其放在 frontend-maven-plugin 标记下方:

<plugin>
   <artifactId>maven-antrun-plugin</artifactId>
   <executions>
      <execution>
         <phase>generate-resources</phase>
         <configuration>
            <target>
               <copy todir="${
                 project.build.directory}/classes/public">
                  <fileset dir="${project.basedir}/
                    frontend/dist/superheroes"/>
               </copy>
            </target>
         </configuration>
         <goals>
            <goal>run</goal>
         </goals>
      </execution>
   </executions>
</plugin>

在这里,maven-antrun-plugin 是一个配置,它将 "${project.basedir}/frontend/dist/superheroes" 路径下的文件和文件夹复制并粘贴到在运行任务之前的 todir="${project.build.directory}/classes/public" 路径。这将复制前端应用程序并将其放在 Spring Boot JAR 文件的根目录中。

现在,让我们配置我们的应用程序的 Spring MVC 配置。

实现 WebMvcConfigurer

在本节中,我们将通过添加配置文件将 Spring Boot 应用程序作为 Angular 应用程序的宿主。为此,我们必须将配置类添加到我们的 Spring Boot 应用程序的配置目录中,并将其命名为 MvcConfig

在创建 MvcConfig 类之后,将 WebMvcConfigurer 接口添加到文件中,如下所示:

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry
      registry) {
… //See full code on https://github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-16/superheroes
    }
}

上述代码也可以在本书的 GitHub 仓库中找到。

MvcConfig 类实现了 WebMvcConfigurer 并重写了 addResourceHandlers 方法。

"/**" 参数传递给 addResourceHandler,如下所示:

addResourceHandler("/**")

这将使配置影响所有项目路由。这部分属于应用程序的外部 URI。

然后,将外部 URI 路径映射到资源所在的目录,如下所示:

addResourceLocations("classpath:/public/")

最后,添加 new ClassPathResource("/public/index.html")。这将重定向那些未由 Spring Boot 应用程序处理的请求,这些请求原本要发送到 Angular 应用程序或前端应用程序。

我们即将将两个应用程序打包成一个。我们将在下一节中学习如何做到这一点。

打包 Spring Boot

在本节中,我们将打包 Spring Boot 应用程序和 Angular 应用程序。想法是将这两个应用程序结合起来,给您提供一个单一的 JAR 文件进行部署。让我们学习如何做到这一点。

在我们完成所有配置之后,现在只需按照相同的顺序运行以下 Maven 命令:

mvn clean
mvn package

mvn clean 命令通过删除目标目录来清理 Maven 项目,而 mvn package 命令则构建 Maven 项目并创建一个可执行的 JAR 文件。

这两个 Maven 命令就足以创建一个包含 Spring Boot 和 Angular 打包的执行 JAR 文件;请参阅 图 16.2

图 16.2 – 包含 JAR 文件的目标文件夹

图 16.2 – 包含 JAR 文件的目标文件夹

由于我们已经完成了配置 Angular 和 Spring Boot 应用程序的所有艰苦工作,因此打包应用程序很简单。

现在,我们有了 JAR 文件。在下一节中,我们将使用 Java 命令运行 JAR 文件,以查看一切是否正常。

运行 JAR 文件

在本节中,我们将运行我们打包的 JAR 文件,并查看 Angular 应用程序是否与 Spring Boot 应用程序通信。请按照以下步骤操作:

  1. 要运行应用程序,您可以使用 java 命令:

    java -jar superheroes-0.0.1-SNAPSHOT.jar
    

上述 Java CLI 命令将运行一个可执行的 JAR 文件。在 CLI 中,您将看到 Tomcat 服务器已在端口 8080 上启动。

  1. 访问 http://localhost:8080;您将被重定向到 http://localhost:8080/login,其中包含一个登录表单。图 16.3 展示了这个登录表单:

图 16.3 – 登录表单

图 16.3 – 登录表单

  1. 尝试登录并导航到反派角色页面,在那里您可以使用表单创建新的英雄或反派:

图 16.4 – 反派角色表单

图 16.4 – 反派角色表单

图 16**.4 显示,从登录到使用 CRUD 操作,反英雄表型上的所有功能都在正常工作。

有了这些,我们已经完成了 Spring Boot 和 Angular 应用程序的打包。现在,让我们总结一下我们学到了什么。

摘要

在本章中,你了解到frontend-maven-pluginantrun-maven-plugin插件可以帮助你将你的 Web 客户端和 Spring Boot 应用程序打包成一个可执行的 JAR 文件,从而使部署变得简单。你还学习了如何在本地机器上运行 JAR 文件,这有助于你为部署准备应用程序。

在下一章中,你将学习如何使用 GitHub Actions 为部署准备应用程序。你还将学习如何使用 Heroku 为应用程序创建数据库实例,然后将应用程序部署到 Heroku。

第十七章:部署 Spring Boot 和 Angular 应用程序

在上一章中,我们学习了 frontend-maven-plugin 的作用以及我们用它来做什么。然后,我们学习了在 Spring Boot 应用程序内运行 Angular 应用程序所需的配置。之后,我们学习了如何将两个应用程序打包成一个文件。最后,我们学习了如何使用 Angular 运行 Spring Boot 应用程序。

本章将向您介绍 GitHub Actions 的基础知识、Heroku 的基础知识以及如何使用 Heroku 部署应用程序。

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

  • 理解 GitHub Actions

  • GitHub Actions 的组件

  • 设置 Heroku

  • 创建 CI 工作流程或管道

技术要求

以下链接将带您到本章的完成版本:github.com/PacktPublishing/Spring-Boot-and-Angular/tree/main/Chapter-17/superheroes

理解 GitHub Actions

让我们从定义 GitHub Actions 开始。这个平台为开发者和运维人员提供工作流程自动化,以及 持续集成和持续交付CI/CD)。每当有人创建拉取请求、创建问题、成为贡献者、合并拉取请求等时,它都可以使用脚本运行一系列操作。简而言之,在您的 GitHub 工作流程中存在多个事件,您可以使用这些事件来运行一组特定的操作或脚本。

现在,让我们回顾一下 GitHub Actions 的组件。

GitHub Actions 的组件

现在我们已经了解了 GitHub Actions 是什么,让我们看看 GitHub Actions 的组件,这些组件有助于我们在事件触发时进行 DevOps 和运行工作流程。

这里是 GitHub Actions 的组件:

  • .github/workflows,可以手动运行作业、自动触发事件或通过设置计划来实现。

  • pull_requestpushschedule。然而,根据您的需求,其他事件也可能很有用。

  • 作业:作业是工作流程中一系列步骤(脚本或操作)的集合。特定的作业在整个步骤中在同一个运行器上执行。

  • 操作:操作执行当前任务或您需要的任何内容,例如检出您的存储库、构建您的应用程序、测试您的应用程序、扫描您的代码以查找任何漏洞或部署您的应用程序。

  • 运行器:运行器只是服务器。在 GitHub Actions 中,您可以选择 Ubuntu Linux、Microsoft Windows 或 macOS 运行器。然而,您并不局限于这三种操作系统。您还可以拥有自托管的运行器。

这些是我们将在 创建 CI 工作流程或管道 部分中使用的 GitHub Actions 组件。但在那之前,我们将设置 Heroku,在那里我们将部署我们的全栈应用程序。

设置 Heroku

在本节中,我们将使用 Heroku。它是一个 平台即服务PaaS)提供程序,允许我们在云中构建和运行应用程序。让我们学习如何设置 Heroku 和我们的应用程序。

创建 GitHub 和 Heroku 账户

在本节中,我们将为 GitHub 和 Heroku 创建账户。

首先,我们必须通过访问github.com/来创建一个 GitHub 账户。我们将将其用作我们项目的仓库。

然后,我们必须通过访问www.heroku.com/来创建一个 Heroku 账户。这是我们部署应用和创建数据库实例的地方。

在 Heroku 创建新应用

在 Heroku 登录后,点击页面右上角的新建按钮,然后点击创建新应用按钮来创建一个不带管道的应用:

图 17.1 – 创建新应用

图 17.1 – 创建新应用

图 17.1是您创建全栈应用新应用的地方。您可以给应用起任何名字,并选择一个区域,但不要添加管道。

接下来,我们将为我们的全栈应用添加一个数据库。

添加 Postgres 数据库

现在,让我们添加一个 Postgres 数据库:

  1. 前往资源标签页并点击查找更多插件按钮:

图 17.2 – 查找更多插件

图 17.2 – 查找更多插件

图 17.2显示了您可以找到查找更多插件按钮的位置。这是您可以找到 Heroku 插件以及用于开发、扩展和运行您应用程序的各种工具和服务的位置。一个例子可以在图 17.3中看到:

图 17.3 – Heroku Postgres

图 17.3 – Heroku Postgres

图 17.3显示了 Heroku Postgres 插件,这是一个基于 PostgreSQL 的数据库即服务DaaS)提供。点击它,安装它,然后选择免费计划并将 Heroku Postgres 插件配置到您之前创建的全栈应用中。然后,点击提交订单表单按钮。

  1. 返回您应用的资源标签页。您应该能看到 Heroku Postgres 插件。点击Heroku Postgres中的 Heroku 部分;将打开一个新标签页。

我们几乎完成了数据库的添加。我们只需要添加一个数据剪辑,这将使我们能够为数据库创建 SQL 查询。

  1. 现在点击创建数据剪辑按钮。然后,添加以下 SQL 查询:

    set transaction read write;
    
    CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
    
    CREATE TABLE user_entity
    
    (
    
        id  uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    
        username VARCHAR(50) UNIQUE  NOT NULL,
    
        password VARCHAR(50)         NOT NULL,
    
        email    VARCHAR(255) UNIQUE NOT NULL
    
    );
    
    CREATE TABLE anti_hero_entity
    
    (
    
        id  uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
    
        firstName VARCHAR(50) UNIQUE NOT NULL,
    
        lastName  VARCHAR(50) UNIQUE NOT NULL,
    
        house     VARCHAR(50) NULL,
    
        knownAs   VARCHAR(50) NULL,
    
        createdAt TIMESTAMP NULL
    
    );
    
  2. 一旦将这些 SQL 查询添加到编辑器中,点击保存并运行

这样,数据库就创建完成了。现在,让我们创建一个system.properties文件,在那里我们可以声明 Java 运行时版本和 Maven 版本。

添加系统属性

在本节中,我们将创建一个文件,该文件将使用system.properties指定 Java 版本和 Maven 版本。

进入 Spring Boot 应用的根目录,创建一个名为system.properties的文件。然后,添加以下配置:

java.runtime.version=17.0.1
maven.version=3.6.2

前面的两个配置将在 Heroku 全栈应用部署部分使用。

在下一节中,我们将获取我们应用的域名 URL 并将其添加到配置变量中。

添加配置变量

在本节中,我们将向 Heroku 添加配置变量。在我们的仓库中,我们将向 Heroku 的 config vars 部分添加 CLIENT_URL 以及前端应用程序的 environment.prod.ts 文件。按照以下步骤操作:

  1. 第一步是获取应用程序的域名,这可以通过前往 设置 选项卡来实现:

图 17.4 – 应用程序 URL

图 17.4 – 应用程序 URL

图 17.4 展示了应用程序将渲染的应用程序 URL。

  1. 复制你全栈应用程序的 URL 并前往 设置 选项卡的 配置变量 区域。点击 显示配置变量 按钮:

图 17.5 – 显示配置变量

图 17.5 – 显示配置变量

  1. CLIENT_URL 作为 添加,并将应用程序的 URL 作为 添加:

图 17.6 – 编辑配置变量

图 17.6 – 编辑配置变量

图 17.6 展示了添加环境变量或配置变量后的表单。

不要忘记给你的 JWT 密钥添加一个值。然而,请注意你的开发和生产环境应该不同。

  1. 现在,打开 environment.prod.ts 文件并添加以下代码:

    export const environment = {
    
      production: true,
    
    // your Spring API URL
    
      apiURL: "https://full stack
    
               javaangulartestapp.herokuapp.com/api/v1",
    
    // your heroku URL
    
      authURL: "https://full stack
    
                javaangulartestapp.herokuapp.com"
    
    };
    

上述代码将替换 Angular 应用程序在生产中的 apiURLauthURL 的先前 localhost:8080 地址。

接下来,我们需要将全栈应用程序仓库发布到 GitHub,因为我们将通过 GitHub 源控制来部署应用程序。

Heroku 的手动部署

在本节中,我们将检查部署后应用程序是否会无任何问题地运行。按照以下步骤操作:

  1. 为了做到这一点,前往 Heroku 控制台中应用程序的 部署 选项卡:

图 17.7 – 部署应用程序

图 17.7 – 部署应用程序

图 17.7 展示了如何通过 Heroku 控制台手动部署应用程序。使用 GitHub 作为部署方法,然后搜索你的仓库。

  1. 在选择你的全栈应用程序的仓库后,前往页面底部,在那里你会找到 手动部署 部分。然后,点击 部署分支 按钮以开始部署并在 Heroku 上运行应用程序:

图 17.8 – 手动部署

图 17.8 – 手动部署

图 17.8 展示了 Heroku 的 手动部署 部分。

  1. 为了检查一切是否正常工作,等待部署完成,然后前往应用程序的 URL。

  2. 注册一个新用户并尝试登录。确保你打开浏览器中的 网络 选项卡;你会看到请求是通过应用程序的 URL 发送的。请注意,服务器的响应具有 状态码200201

图 17.9 – Google Chrome 的网络选项卡

图 17.9 – Google Chrome 的网络选项卡

图 17.9显示了开发者工具中 Google Chrome 的网络标签。在这里,您可以看到注册请求返回状态码 201。Angular、Spring Boot 和 Heroku Postgres 数据库运行得非常好。

现在我们已经手动部署了我们的全栈应用,让我们使用 GitHub Actions 中的工作流程创建一个自动部署。

创建 CI 工作流程或管道

在本节中,我们将通过使用 GitHub Actions 中的工作流程来自动化我们的全栈应用部署。请按照以下步骤操作:

  1. 前往您项目的 GitHub 仓库,然后点击发布 Java 包并选择 Maven 工作流程:

图 17.10 – 选择工作流程

图 17.10 – 选择工作流程

图 17.10显示了构建 Maven 项目的 Maven 工作流程的基本和现成配置。

  1. 通过替换以下 YAML 配置来编辑编辑器中的 YAML 文件内容:

    name: CICD
    
    on:
    
      push:
    
        branches:
    
          - master
    
    jobs:
    
      test:
    
        name: Build and Test
    
        runs-on: ubuntu-latest
    
        steps:
    
          - uses: actions/checkout@v2
    
          - name: Set up JDK 17
    
            uses: actions/setup-java@v2
    
            with:
    
              java-version: '17.0.*'
    
              distribution: 'temurin'
    
              cache: maven
    
          - name: test with Maven
    
            run: mvn test
    
      deploy:
    
        name: Deploy to Heroku
    
        runs-on: ubuntu-latest
    
        needs: test
    
        steps:
    
          - uses: actions/checkout@v2
    
            # This is the action
    
          - uses: akhileshns/heroku-deploy@v3.12.12
    
            with:
    
              heroku_api_key: ${{secrets.HEROKU_API_KEY}}
    
              heroku_app_name: "appname" #Must be unique
    
                                         #in Heroku
    
              heroku_email: "email"
    

上述代码是我们全栈应用 CI/CD 的工作流程。它被称为CICD,因为这就是这个工作流程的目的。

工作流程有一个事件,push,如果 master 分支有推送操作,将导致工作流程运行。

工作流程还有两个作业:testdeploytest作业的步骤是检出代码,使用 Java 17 构建应用程序并运行测试。另一方面,deploy作业的步骤是检出代码并使用需要 Heroku API 密钥、应用程序名称和 Heroku 账户电子邮件的 Heroku 部署操作。

  1. 对于 Heroku API 密钥,您需要前往 Heroku 仪表板您个人资料的账户设置菜单:

图 17.11 – 账户设置

图 17.11 – 账户设置

图 17.11显示了在 Heroku 仪表板我的个人资料下的账户设置菜单。

  1. 点击账户设置,然后转到API 密钥并生成一个 API 密钥:

图 17.12 – API 密钥

图 17.12 – API 密钥

图 17.12显示了您可以在其中生成 Heroku API 密钥的位置。

复制 Heroku API 密钥,因为您需要为 GitHub Actions 创建一个新的动作密钥。

  1. 要这样做,请前往您应用的 GitHub 仓库,并为设置标签打开一个新的浏览器标签页,以免丢失您的流程配置。

  2. 然后,在文本区域中添加 API 密钥并将其命名为HEROKU_API_KEY。这是您将在工作流程的deploy作业中使用的关键:

图 17.13 – 动作密钥表单

图 17.13 – 动作密钥表单

图 17.13显示了您可以在其中添加新的动作密钥以防止敏感值被复制或被任何人读取的位置。

  1. 一旦您添加了新的密钥,请返回您的浏览器标签页,在那里您开始编辑您的流程。然后,提交您正在编辑的文件:

图 17.14 – 提交工作流程

图 17.14 – 提交工作流程

图 17.14 展示了 git pull 后它将出现在你的本地机器上。

  1. 在你提交到工作流后,CICD 将启动。你可以通过转到 动作 选项卡来查看 CI/CD 工作流的进度:

图 17.15 – 工作流状态

图 17.15 – 工作流状态

图 17.15 展示了 CI/CD 工作流的当前状态。你可以看到它正在运行 构建和测试 作业。

  1. 你也可以通过点击左侧侧边栏菜单中的职位名称来查看特定工作的进展情况:

图 17.16 – 步骤和动作状态

图 17.16 – 步骤和动作状态

图 17.16 展示了 构建和测试 作业中每个步骤的输出日志。你还可以使用 GitHub Actions 的这部分来调试导致作业和动作失败的错误。

  1. 在运行了你创建的 CI/CD 工作流中的所有作业后,你将看到工作流旁边有一个绿色的勾选图标,这意味着工作流已经通过,一切正常:

图 17.17 – 步骤和动作状态

图 17.17 – 步骤和动作状态

图 17.17 展示了你的仓库 动作 选项卡上的一个通过状态的 GitHub 工作流。

  1. 最后,为了检查部署自动化是否已将我们的应用程序部署到 Heroku 云中,我们必须回到 Heroku 的概览仪表板并查找最新的活动:

图 17.18 – 在 Heroku 账户的最新活动区域中构建成功

图 17.18 – 在 Heroku 账户的最新活动区域中构建成功

图 17.18 展示了由 GitHub Actions 触发的构建成功。你可以看到应用程序正在正常运行。

有了这些,我们已经成功使用 GitHub Actions 自动化了我们的 CI/CD 工作流。现在,让我们总结一下在这一章中学到的内容。

摘要

有了这些,我们已经到达了这本书的最后一章;让我们回顾一下你在这一章中学到的宝贵知识。

首先,你了解到 GitHub Actions 可以轻松自动化所有软件工作流并执行 CI/CD。你可以在 GitHub 上直接构建、测试和部署你的代码。你还了解到 Heroku 是一个 PaaS,它允许你完全在云端构建、运行和操作应用程序。Heroku Postgres 是 Heroku 直接提供的托管 SQL 数据库服务,你可以将其用于你的应用程序。

所以,你已经走到了这一步。感谢你完成这本书;我为你的热情和对学习新工具和事物的热情感到自豪。只要你项目的需求与你在本书中学到的问题和解决方案相匹配,你就可以将在这里学到的知识应用到项目中。

这门课程教你如何作为一个高级开发者构建 Spring Boot 2 应用程序和 Angular 13 应用程序,为公司、客户和客户带来价值。

作为下一步的建议,我推荐你购买一本关于独立 Spring Boot 2 或 Spring Boot 3 的新 Packt 书籍,或者一本 Angular 书籍,以巩固从这本书中学到的知识。

代表 Packt 团队和编辑们,我们祝愿你在职业生涯和生活的各个阶段都一切顺利。

posted @ 2025-09-12 13:55  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报