SpringBoot-微服务学习手册-一-

77 阅读1小时+

SpringBoot 微服务学习手册(一)

原文:Learn Microservices with Spring Boot

协议:CC BY-NC-SA 4.0

一、设置场景

微服务如今越来越受欢迎,应用也越来越广泛。这并不令人惊讶;这种软件架构风格有很多优点,比如灵活性和易于扩展。将微服务映射到一个组织中的小团队中也可以提高开发效率。然而,继续只知道好处的微服务冒险是一个错误的决定。分布式系统引入了额外的复杂性,因此您需要了解您所面临的情况,并提前做好准备。你可以从互联网上的许多书籍和文章中获得许多知识,但当你亲自动手编写代码时,情况就变了。

这本书以实用的方式涵盖了微服务的一些最重要的概念,但没有解释这些概念。首先,我们定义一个用例:要构建的应用。然后我们从一个小的整体开始,基于一些合理的推理。一旦我们有了最少的应用,我们就会评估是否值得迁移到微服务,以及迁移的好方法是什么。随着第二个微服务的引入,我们分析了他们通信的选项。然后,我们可以描述和实现事件驱动架构模式,通过通知系统的其他部分发生了什么,而不是显式地调用其他部分来采取行动,从而实现松散耦合。当我们达到这一点时,我们注意到一个设计糟糕的分布式系统有一些缺陷,我们必须用一些流行的模式来修复:服务发现、路由、负载平衡、可追溯性等等。将它们一个一个地添加到我们的代码库中,而不是将它们一起呈现,有助于我们理解这些模式。我们还使用 Docker 为云部署准备了这些微服务,并比较了运行应用的不同平台选择。

一步一步来的好处是,当需要解决概念的时候停下来,你会明白每个工具试图解决哪个问题。这就是为什么进化的例子是这本书的重要部分。您也可以不用编写一行代码就能理解这些概念,因为源代码在整个章节中都有介绍和解释。

本书包含的所有代码都可以在 GitHub 上的项目书-微服务-v2 中找到。有多个可用的存储库,分为章和节,这使您更容易看到应用是如何发展的。这本书包括每一部分所涉及的版本的注释。

你是谁?

让我们先从这个开始:这本书对你来说有多有趣?这本书很实用,我们来玩这个游戏吧。如果你认同这些陈述中的任何一个,这本书可能对你有好处:

  • “我想学习如何用 Spring Boot 构建微服务,以及如何使用相关工具。”

  • “每个人都在谈论微服务,但我还不知道什么是微服务。我只读过理论性的解释或炒作的文章。尽管我在 IT 行业工作,但我无法理解它的优势……”

  • “我想学习如何设计和开发 Spring Boot 应用,但我找到的要么是带有太简单示例的快速入门指南,要么是类似于官方文档的冗长书籍。我希望通过更现实的项目导向方法来学习这些概念。”

  • “我找到了一份新工作,他们正在使用微服务架构。我一直主要从事大型单体项目,所以我希望获得一些知识和指导,以了解那里的一切是如何工作的,以及这种架构的利弊。”

  • “每次我去自助餐厅,开发人员都在谈论微服务、网关、服务发现、容器、弹性模式等。如果我不明白同事们在说什么,我就无法再与他们交往。”(这个是个笑话;不要因为这个而读这本书,尤其是如果你对编程不感兴趣的话。)

关于阅读本书所需的知识,以下主题你应该很熟悉:

  • Java(我们用 Java 14)

  • Spring(你不需要很强的经验,但是你至少应该知道依赖注入是如何工作的)

  • Maven(如果你了解 Gradle,你也会很好)

这本书与其他书籍和指南有什么不同?

软件开发人员和架构师阅读许多技术书籍和指南,要么是因为我们对学习新技术感兴趣,要么是因为我们的工作需要它。我们无论如何都需要这样做,因为这是一个不断变化的世界。我们可以在那里找到各种各样的书籍和指南。好的通常是那些能让你快速学习的,不仅能教会你如何做事,还能教会你为什么要那样做。仅仅因为新技术是新的就使用新技术是错误的做法;你需要理解它们背后的原因,这样你才能以最好的方式使用它们。

这本书使用了这种哲学:它浏览了代码和设计模式,解释了遵循一种方法而不遵循其他方法的原因。

学习:一个渐进的过程

如果你看看网上的指南,你会很快注意到它们不是现实生活中的例子。通常,当你将这些案例应用到更复杂的场景时,它们并不适合。指南太肤浅,无法帮助你建立一些真实的东西。

另一方面,书在这方面做得更好。有很多好书围绕一个例子解释概念;它们很好,因为如果你看不到代码,将理论概念应用于代码并不总是容易的。这些书中有些书的问题是它们不如指南实用。您需要首先阅读它们来理解概念,然后编写(或查看)示例,这通常是作为一个整体给出的。当你直接看到最终版本时,很难将概念付诸实践。这本书停留在实践方面,从代码开始,通过章节发展,以便你一个一个地掌握概念。我们在暴露解决方案之前先掩盖问题。

由于这种增量式的概念呈现方式,这本书也允许你边学习边编码,并自己思考面临的挑战。

这是一本指南还是一本书?

你面前的这些页面不能被称为指南:你不会花 15 或 30 分钟来完成它们。此外,每章都介绍了所有必需的主题,为添加新代码奠定基础。但这也不是一本典型的书,在这本书中,你会经历一些孤立的概念,并用一些分散的代码片段来说明,这些代码片段是专门为这种情况而制作的。相反,您从一个现实生活中还不是最佳的应用开始,在了解了您可以从该过程中获得的好处之后,您将学习如何改进它。

这并不意味着你不能只是坐下来阅读它,但是如果你同时编码并使用所提供的选项和选择会更好。这是这本书的一部分,使它类似于指南。

无论如何,为了简单起见,从现在开始我们称它为一本书。

从基础到高级主题

这本书首先关注一些基本概念来理解其余的主题(第章第 2 ): Spring Boot、测试、日志等等。然后,它涵盖了如何使用众所周知的分层设计来设计和实现生产就绪的 Spring Boot 应用,并深入到如何实现 REST API、业务逻辑和数据库存储库(第 3 和 5 章)。在这样做的时候,你会看到 Spring Boot 内部是如何工作的,所以它对你来说不再是魔法了。您还将学习如何用 React 构建一个基本的前端应用(第四章),因为这将帮助您直观地了解后端架构如何影响前端。在那之后,这本书进入了微服务世界,在一个不同的 Spring Boot 应用中引入了第二个功能。这个实例可以帮助您分析在决定迁移到微服务之前应该考虑的因素(第六章)。然后,您将了解同步和异步通信微服务之间的区别,以及事件驱动架构如何帮助您保持系统组件的解耦(第七章)。从那里开始,这本书将带您经历适用于分布式系统的工具和框架之旅,以实现重要的非功能性需求:弹性、可伸缩性、可追溯性和部署到云中等等(第章第八部分)。

如果您已经熟悉了 Spring Boot 应用及其工作原理,您可以快速浏览前几章,将注意力更多地放在本书的第二部分。在那里,我们涵盖了更高级的主题,如事件驱动设计、服务发现、路由、分布式跟踪、Cucumber 测试等。然而,请注意我们在第一部分中建立的基础:测试驱动的开发,对最小可行产品(MVP)的关注,以及整体优先。

骷髅尖兵带着 Spring Boot,专业的道

首先,这本书指导你使用 Spring Boot 创建一个应用。所有内容主要集中在后端,但是您将使用 React 创建一个简单的 web 页面来演示如何将公开的功能用作 REST API。

需要指出的是,我们创建“快捷代码”并不仅仅是为了展示 Spring Boot 的特性:这不是本书的目的。我们使用 Spring Boot 作为教授概念的工具,但是我们可以使用任何其他的框架,这本书的思想仍然有效。

您将学习如何按照众所周知的三层模式设计和实现应用。您可以通过一个增量示例和实际操作代码来实现这一点。在编写应用时,我们还会暂停几次,深入了解 Spring Boot 如何用这么少的代码工作的细节(自动配置、启动器等)。).

测试驱动开发

在第一章中,我们使用测试驱动开发(TDD)来将前提条件映射到技术特性。这本书试图以一种你可以从一开始就看到好处的方式展示这种技术:为什么在编写代码之前考虑测试用例总是一个好主意。JUnit 5、AssertJ 和 Mockito 将帮助我们高效地构建有用的测试。

计划如下:您将学习如何首先创建测试,然后使它们失败,最后实现使它们工作的逻辑。

微服务

一旦你的第一个应用准备好了,我们将引入第二个应用,它将与现有的功能进行交互。从那时起,您将拥有一个微服务架构。如果你只有其中一项,那么试图去了解微服务的优势是没有任何意义的。现实生活中的场景总是功能被分割成不同服务的分布式系统。像往常一样,为了保持实用性,我们将为我们的案例研究分析具体情况,以便您了解迁移到微服务是否符合您的需求。

这本书不仅涵盖了拆分系统的原因,还涵盖了这种选择带来的弊端。一旦决定迁移到微服务,您将了解应该使用哪些模式来为分布式系统构建良好的架构:服务发现、路由、负载平衡、分布式跟踪、容器化和其他一些支持机制。

事件驱动系统

微服务不一定需要的另一个概念是事件驱动架构。本书使用它,因为它是一种非常适合微服务架构的模式,并且您将基于好的示例做出选择。您将看到同步和异步通信之间的区别,以及它们的主要优缺点。

这种异步的思维方式引入了新的代码设计方式,最终一致性是要接受的关键变化之一。您将在编写项目代码时看到它,使用 RabbitMQ 在微服务之间发送和接收消息。

非功能需求

当您在现实世界中构建应用时,您必须考虑一些与功能没有直接关系的需求,但是这些需求使您的系统更加健壮,在出现故障的情况下继续工作,或者确保数据完整性,例如。

许多这些非功能性需求都与软件可能出错的事情有关:网络故障导致部分系统无法访问,高流量导致后端容量崩溃,外部服务没有响应,等等。

在本书中,您将学习如何实现和验证模式,以使系统更具弹性和可伸缩性。此外,我们将讨论数据完整性的重要性以及帮助我们保证数据完整性的工具。

学习如何设计和解决所有这些非功能性需求的好处在于,它是适用于任何系统的知识,不管您使用的是什么编程语言和框架。

在线内容

对于这本书的第二版,我决定创建一个在线空间,在那里您可以不断学习与微服务架构相关的新主题。在这个网页上,您将找到新的指南,这些指南扩展了涵盖分布式系统其他重要方面的实际用例。此外,使用最新依赖项的新版本存储库将在那里发布。

你可以在网上找到的第一个指南是关于用 Cucumber 测试分布式系统的。这个框架帮助我们构建人类可读的测试脚本,以确保我们的功能端到端地工作。

请访问 https://tpd.io/book-extra 了解关于这本书的所有额外内容和新更新。

摘要

本章介绍了这本书的主要目标:通过简单的开始,然后通过开发一个示例项目来增长你的知识,来教你微服务架构的主要方面。

我们还简要介绍了这本书的主要内容:从 monolith-first 到 Spring Boot 的微服务,测试驱动开发,事件驱动系统,通用架构模式,非功能需求,以及 Cucumber 的端到端测试(在线)。

下一章将从我们学习道路的第一步开始:复习一些基本概念。

二、基本概念

这本书遵循一种实用的方法,因此所涉及的大多数工具都是在我们需要时介绍的。但是,我们将分别讨论一些核心概念,因为它们要么是我们不断发展的示例的基础,要么在代码示例中广泛使用,即 Spring、Spring Boot、测试库、Lombok 和日志记录。这些概念值得单独介绍,以避免我们学习过程中的长时间中断,这就是为什么本章对它们进行了概述。

请记住,接下来的部分并不打算为您提供这些框架和库的完整知识库。本章的主要目标是,要么你在头脑中刷新概念(如果你已经学过的话),要么你掌握基础知识,这样你就不需要在阅读其余章节之前查阅外部参考资料。

Spring

Spring 框架是简化软件开发的大量库和工具,即依赖注入、数据访问、验证、国际化、面向方面的编程等。对于 Java 项目来说,这是一个受欢迎的选择,它也可以与其他基于 JVM 的语言一起工作,比如 Kotlin 和 Groovy。

Spring 如此受欢迎的原因之一是,它为软件开发的许多方面提供了内置实现,从而节省了大量时间,例如:

  • Spring Data 简化了关系数据库和 NoSQL 数据库的数据访问。

  • Spring Batch 为大量记录提供强大的处理能力。

  • Spring Security 是一个安全框架,它将安全特性抽象到应用中。

  • Spring Cloud 为开发者提供工具,快速构建分布式系统中的一些常用模式。

  • Spring Integration 是企业集成模式的一种实现。它使用轻量级消息传递和声明性适配器促进了与其他企业应用的集成。

正如你所看到的,Spring 被分成不同的模块。所有模块都构建在核心 Spring 框架之上,为软件应用建立了一个通用的编程和配置模型。这个模型本身是选择框架的另一个重要原因,因为它促进了良好的编程技术,例如使用接口而不是类,通过依赖注入来分离应用层。

Spring 中的一个关键主题是反转控制(IoC)容器,它由ApplicationContext接口支持。Spring 在您的应用中创建了这个“空间”,您和框架本身可以在其中放置一些对象实例,如数据库连接池、HTTP 客户端等。这些被称为bean的对象可以在应用的其他部分使用,通常通过它们的公共接口从特定的实现中抽象出代码。从其他类的应用上下文中引用这些 beans 之一的机制就是我们所说的依赖注入,在 Spring 中,这可以通过 XML 配置或代码注释来实现。

Spring Boot

Spring Boot 是一个利用 Spring 快速创建基于 Java 语言的独立应用的框架。它已经成为构建微服务的流行工具。

Spring 和其他相关的第三方库中有如此多的可用模块可以与框架结合,这对软件开发来说是非常强大的。然而,尽管做了很多努力来简化 Spring 配置,您仍然需要花一些时间来设置应用所需的一切。有时,您只是需要一遍又一遍地使用相同的配置。应用的引导,也就是配置 Spring 应用使其启动并运行的过程,有时会很乏味。Spring Boot 的优势在于,它通过提供自动为您设置的默认配置和工具,消除了大部分流程。主要的缺点是,如果你太依赖这些默认值,你可能会失去控制和意识到发生了什么。我们将在书中揭示一些 Spring Boot 的实现,以展示它在内部是如何工作的,这样您就可以随时掌控一切。

Spring Boot 提供了一些预定义的启动包,就像是 Spring 模块和一些第三方库和工具的集合。例如,spring-boot-starter-web帮助您构建一个独立的 web 应用。它将 Spring 核心 Web 库与 Jackson (JSON 处理)、验证、日志、自动配置,甚至一个嵌入式 Tomcat 服务器以及其他工具组合在一起。

除了启动器之外,自动配置在 Spring Boot 中也起着关键作用。这个特性使得向应用添加功能变得极其容易。按照同样的例子,仅仅通过包含 web starter,您将得到一个嵌入式 Tomcat 服务器。不需要配置任何东西。这是因为 Spring Boot 自动配置类会扫描您的类路径、属性、组件等。,并基于此加载一些额外的 beans 和行为。

为了能够为您的 Spring Boot 应用管理不同的配置选项,框架引入了概要文件。例如,在开发环境和生产环境中使用数据库时,可以使用配置文件为要连接的主机设置不同的值。此外,您可以使用不同的概要文件进行测试,其中您可能需要公开应用的附加功能或模拟部分。我们将在第八章中更详细地介绍个人资料。

我们将使用 Spring Boot Web 和数据启动器来快速构建一个具有持久存储的 Web 应用。Test starter 将帮助我们编写测试,因为它包括一些有用的测试库,如 JUnit 和 AssertJ。然后,我们将通过添加 AMQP 启动器为我们的应用添加消息传递功能,它包括一个消息代理集成(RabbitMQ ),我们将使用它来实现一个事件驱动的架构。在第八章中,我们将包括不同类型的启动器,归入 Spring Cloud 家族。我们将利用其中的一些工具来实现分布式系统的通用模式:路由(Spring Cloud Gateway)、服务发现(Consul)和负载平衡(Spring Cloud Load Balancer)等等。现在不要担心所有这些新术语;当我们在实际例子上取得进展时,我们将详细解释它们。

下一章将基于一个实际的例子详细介绍这些启动器和 Spring Boot 自动配置是如何工作的。

龙目岛和爪哇

本书中的代码示例使用了 Project Lombok,这是一个基于注释生成 Java 代码的库。将 Lombok 包括在本书中的主要原因是教育意义:它保持了代码样本的简洁,减少了样板文件,因此读者可以专注于它所关注的内容。

让我们用第一个简单的类作为例子。我们想要创建一个具有两个因素的不可变乘法挑战类。参见清单 2-1 。

public final class Challenge {

    // Both factors
    private final int factorA;
    private final int factorB; 

    public Challenge(int factorA, int factorB) {
        this.factorA = factorA;
        this.factorB = factorB;
    }

    public int getFactorA() {
        return this.factorA;
    }

    public int getFactorB() {
        return this.factorB;
    }

    public boolean equals(final Object o) {
        if (o == this) return true;
        if (!(o instanceof Challenge)) return false; 

        final Challenge other = (Challenge) o;
        if (this.getFactorA() != other.getFactorA()) return false;
        if (this.getFactorB() != other.getFactorB()) return false;
        return true;
    }

    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        result = result * PRIME + this.getFactorA();
        result = result * PRIME + this.getFactorB();

        return result;
    }

    public String toString() {
        return "Challenge(factorA=" + this.getFactorA() + ", factorB=" + this.getFactorB() + ")";
    }
}

Listing 2-1The Challenge Class in Plain Java

正如您所看到的,整个类都有一些经典的样板代码:构造函数、getters 以及equalshashCodetoString方法。它们并没有给这本书增加多少内容,但是我们需要它们来让代码工作。

同一个类可以用 Lombok 化简为它的最小表达式。参见清单 2-2 。

import lombok.Value;

@Value
public class Challenge {

    // Both factors
    int factorA;
    int factorB;
}

Listing 2-2The Challenge Class Using Lombok

Lombok 提供的@Value注释集合了这个库中的一些其他注释,我们也可以单独使用。以下每个注释都指示 Lombok 在 Java 构建阶段之前生成代码块:

  • 用所有现有字段创建一个构造函数。

  • @FieldDefaults使我们的领域privatefinal

  • @GetterfactorAfactorB生成 getters。

  • 包含了一个简单的连接字段的实现。

  • 默认情况下,@EqualsAndHashCode使用所有字段生成基本的equals()hashCode()方法,但是我们也可以定制它。

Lombok 不仅将我们的代码减到最少,而且当您需要修改这些类时,它也会有所帮助。向 Lombok 中的Challenge类添加一个新字段意味着添加一行(不包括该类的用法)。如果我们使用普通的 Java 版本,我们需要给构造函数添加新的参数,添加equalshashCode方法,并添加一个新的 getter。这不仅意味着额外的工作,而且容易出错:例如,如果我们忘记了等于方法中的额外字段,我们就会在应用中引入一个 bug。

像许多工具一样,Lombok 也有批评者。不喜欢 Lombok 的主要原因是,由于向类中添加代码很容易,您可能最终会添加您并不真正需要的代码(例如 setters 或额外的构造函数)。此外,你可能会认为拥有一个好的代码生成 IDE 和一个重构助手或多或少会有帮助。请记住,要正确使用 Lombok,您需要您的 IDE 提供对它的支持。这可能是自然发生的,也可能是通过插件发生的。例如,在 IntelliJ 中,你必须下载并安装 Lombok 插件。项目中的所有开发人员都必须使他们的 IDE 适应 Lombok,所以尽管这很容易做到,但你可以看到这是一个额外的不便。

在接下来的章节中,我们将主要使用这些 Lombok 特性:

  • 我们用@Value注释不可变的类。

  • 对于数据实体,我们分别使用前面描述的一些注释。

  • 我们为 Lombok 添加了@Slfj4注释,以使用 Java API (SLF4J)的标准简单日志外观创建一个日志记录器。本章中的“日志记录”一节给出了关于这些概念的更多背景知识。

在任何情况下,当我们查看代码示例时,我们将描述这些注释做什么,所以您不需要深入了解它们如何工作的更多细节。

如果您喜欢普通的 Java 代码,只需使用本书中 Lombok 的代码注释作为参考,就可以知道您的类中需要包含哪些额外的代码。

Java 记录

从 JDK 14 开始,Java 记录功能在预览模式下可用。如果我们使用这个特性,我们也可以用简洁的方式用纯 Java 编写我们的Challenge类。

public record Challenge(int factorA, int factorB) {}

然而,在撰写本书时,这个特性还没有与其他库和框架完全集成。此外,与 Java 记录相比,Lombok 增加了一些额外的特性和更好的选项粒度。由于这些原因,我们不会在本书中使用记录。

测试基础

在这一节中,我们将介绍一些重要的测试方法和库。我们将在下一章把它们付诸实践,所以先学习(或复习)基本概念是有好处的。

测试驱动开发

本书的第一个实用章节鼓励你使用测试驱动开发 (TDD)。这种技术可以帮助你首先关注你的需求和期望,然后再关注实现。作为一名开发人员,它让您思考在某些情况或用例下代码应该做什么。在现实生活中,TDD 也帮助你明确模糊的需求,丢弃无效的需求。

鉴于这本书是由实际案例驱动的,你会发现 TDD 非常适合主要目的。

行为驱动开发

除了先写测试再写逻辑的想法之外,行为驱动开发 (BDD)为测试带来了更好的结构和可读性。

在 BDD 中,我们根据 Given-When-Then 结构编写测试。这消除了开发人员和业务分析师在将用例映射到测试时的隔阂。分析师可以直接阅读代码,并识别出正在测试的内容。

请记住,像 TDD 一样,BDD 本身是一个开发过程,而不仅仅是编写测试的一种方式。它的主要目标是促进对话,以改进需求及其测试用例的定义。在本书中,我们关于 BDD 的重点将放在测试结构上。参见清单 2-3 中这些测试的例子。

@Test
public void getRandomMultiplicationTest() throws Exception {
    // given
    given(challengeGeneratorService.randomChallenge())

            .willReturn(new Challenge(70, 20));

    // when
    MockHttpServletResponse response = mvc.perform(
            get("/multiplications/random")
                    .accept(MediaType.APPLICATION_JSON))
            .andReturn().getResponse();

    // then
    then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
    then(response.getContentAsString())
            .isEqualTo(json.write(new Challenge(70, 20)).getJson());

}

Listing 2-3An Example of a BDD Test Case Using a Given-When-Then Structure

单元测试

本书中的代码使用 JUnit 5 进行单元测试。Spring Boot 测试入门包含了这些库,所以我们不需要在依赖项中包含它。

一般来说,单元测试背后的思想是你可以单独验证你的类(单元)的行为。在本书中,我们将为每个放置逻辑的类编写单元测试。

在 JUnit 5 的所有特性中,我们将主要使用下面列出的基本特性:

  • @BeforeEach@AfterEach分别表示每次测试前后应该执行的代码。

  • 对于代表我们想要执行的测试的每一个方法。

  • @ExtendsWith在类级别添加 JUnit 5 扩展。我们将用它来将 Mockito 扩展和 Spring 扩展添加到我们的测试中。

莫基托

Mockito 是一个用于 Java 单元测试的模仿框架。当您模仿一个类时,您正在用一些预定义的指令覆盖该类的真实行为,这些指令指示它们的方法应该为它们的参数返回什么或做什么。这是编写单元测试的一个重要要求,因为您只想验证一个类的行为,并模拟它的所有交互。

用 Mockito 模仿一个类的最简单的方法是在 JUnit 5 的一个字段中使用与MockitoExtension结合的@Mock注释。参见清单 2-4 。

@ExtendWith(MockitoExtension.class)
public class MultiplicationServiceImplTest {

    @Mock
    private ChallengeAttemptRepository attemptRepository;

    // [...] -> tests
}

Listing 2-4MockitoExtension

and Mock Annotation Usage

然后,我们可以使用静态方法Mockito.when来定义定制行为。参见清单 2-5 。

import static org.mockito.Mockito.when;
// ...
when(attemptRepository.methodThatReturnsSomething())
    .thenReturn(predefinedResponse);

Listing 2-5Defining Custom Behavior with Mockito’s when

然而,我们将使用来自BDDMockito的替代方法,也包含在 Mockito 依赖项中。这给了我们一种可读性更好的、BDD 风格的编写单元测试的方法。参见清单 2-6 。

import static org.mockito.BDDMockito.given;
// ...
given(attemptRepository.methodThatReturnsSomething())
    .willReturn(predefinedResponse);

Listing 2-6Using given to Define Custom Behavior

在某些情况下,我们还需要检查对模拟类的预期调用是否被调用。对于 Mockito,我们使用verify()来表示。参见清单 2-7 。

import static org.mockito.Mockito.verify;
// ...
verify(attemptRepository).save(attempt);

Listing 2-7Verifying an Expected Call

作为一些额外的背景,很高兴知道还有一个verify()的 BDD 变体,叫做then()。不幸的是,当我们将来自 AssertJ 的BDDMockitoBDDAssertions结合起来时,这种替换可能会令人困惑(下一节将介绍)。由于在本书中我们将更广泛地使用断言而不是验证,我们将选择verify来更好地区分它们。

清单 2-8 展示了一个使用 JUnit 5 和 Mockito 进行测试的完整示例,该测试基于我们将在本书后面实现的一个类。现在,您可以忽略then断言;我们很快就会到达那里。

package microservices

.book.multiplication.challenge;

import java.util.Optional;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import microservices.book.multiplication.event.ChallengeSolvedEvent;
import microservices.book.multiplication.event.EventDispatcher;
import microservices.book.multiplication.user.User;
import microservices.book.multiplication.user.UserRepository;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.*;

@ExtendWith(MockitoExtension.class)
public class ChallengeServiceImplTest {

  private ChallengeServiceImpl challengeServiceImpl;

  @Mock
  private ChallengeAttemptRepository

attemptRepository;

  @Mock
  private UserRepository userRepository;

  @Mock
  private EventDispatcher eventDispatcher;

  @BeforeEach
  public void setUp() {
    challengeServiceImpl = new ChallengeServiceImpl(attemptRepository,
        userRepository, eventDispatcher);
  }

  @Test
  public void checkCorrectAttemptTest() {
    // given
    long userId = 9L, attemptId = 1L;
    User user = new User("john_doe");
    User savedUser = new User(userId, "john_doe");
    ChallengeAttemptDTO attemptDTO =
        new ChallengeAttemptDTO(50, 60, "john_doe", 3000);
    ChallengeAttempt attempt =
        new ChallengeAttempt(null, savedUser, 50, 60, 3000, true);
    ChallengeAttempt storedAttempt =
        new ChallengeAttempt(attemptId, savedUser, 50, 60, 3000, true);
    ChallengeSolvedEvent event = new ChallengeSolvedEvent(attemptId, true,
        attempt.getFactorA(), attempt.getFactorB(), userId,
        attempt.getUser().getAlias());
    // user does not exist, should be created

    given(userRepository.findByAlias("john_doe"))
        .willReturn(Optional.empty());
    given(userRepository.save(user))
        .willReturn(savedUser);
    given(attemptRepository.save(attempt))
        .willReturn(storedAttempt);

    // when
    ChallengeAttempt resultAttempt =
        challengeServiceImpl.checkAttempt(attemptDTO);

    // then
    then(resultAttempt.isCorrect()).isTrue();
    verify(userRepository).save(user);
    verify(attemptRepository).save(attempt);
    verify(eventDispatcher).send(event); 

  }

}

Listing 2-8A Complete Unit Test with JUnit5 and Mockito

维护

用 JUnit 5 验证预期结果的标准方法是使用断言。

assertEquals("Hello, World!", actualGreeting);

不仅断言所有类型的对象相等,而且验证真/假、空、超时前执行、抛出异常等。你可以在断言 Javadoc ( https://tpd.io/junit-assert-docs )中找到它们。

尽管 JUnit 断言在大多数情况下已经足够了,但是它们不像 AssertJ 提供的那样易于使用和阅读。这个库实现了编写断言的流畅方式,并提供了额外的功能,因此您可以编写更简洁的测试。

在其标准形式中,前面的示例如下所示:

assertThat(actualGreeting).isEqualTo("Hello, World!");

然而,正如我们在前面的章节中提到的,我们想要利用 BDD 语言方法。因此,我们将使用 AssertJ 中包含的BDDAssertions类。这个类包含所有assertThat案例的等价方法,重命名为then

then(actualGreeting).isEqualTo("Hello, World!");

在本书中,我们将主要从 AssertJ 的一些基本主张。如果你有兴趣扩展你关于 AssertJ 的知识,你可以从官方文档页面( https://tpd.io/assertj )开始。

在 Spring Boot 测试

JUnit 5 和 AssertJ 都包含在spring-boot-starter-test中,所以我们只需要在我们的 Spring Boot 应用中包含这个依赖项就可以使用它们。然后,我们可以使用不同的测试策略。

在 Spring Boot,编写测试最流行的方式之一是利用@SpringBootTest注释。它将启动一个 Spring 上下文,并使所有已配置的 beans 可用于测试。如果您正在运行集成测试,并且想要验证应用的不同部分是如何协同工作的,这种方法很方便。

当测试应用的特定部分或单个类时,最好使用简单的单元测试(根本不用 Spring)或更细粒度的注释,如@WebMvcTest,专注于控制器层测试。这是我们将在书中使用的方法,所以当我们到达那里时,我们将更详细地解释它。

现在,让我们只关注本章中描述的库和框架之间的集成。

  • Spring 测试库(包含在 Spring Boot 测试启动工具中)带有一个SpringExtension,因此您可以通过@ExtendWith注释将 Spring Integration 到 JUnit 5 测试中。

  • Spring Boot 测试包引入了@MockBean注释,我们可以用它来替换或添加 Spring 上下文中的 bean,就像 Mockito 的@Mock注释替换给定类的行为一样。这有助于单独测试应用层,这样您就不需要将 Spring 上下文中所有真正的类行为放在一起。在测试我们的应用控制器时,我们将看到一个实际的例子。

记录

在 Java 中,我们可以通过使用System.outSystem.err打印流将消息记录到控制台。

System.out.println("Hello, standard output stream!");

这被认为对于一个 12 因素应用( https://tpd.io/12-logs )来说已经足够好了,这是一组流行的编写云原生应用的最佳实践。原因是,最终,一些其他工具将从系统级的标准输出中收集它们,并在外部框架中聚合它们。

因此,我们将把日志写到标准和错误输出中。但这并不意味着我们必须坚持 Java 中简单丑陋的变体。

大多数专业的 Java 应用都使用 LogBack 之类的日志实现。而且,考虑到 Java 有多种日志框架,选择一个通用的抽象比如 SLF4J 就更好了。

好消息是 Spring Boot 已经为我们设置了所有的日志配置。默认实现是登录回退,Spring Boot 预配置的消息格式如下:

2020-03-22 10:19:59.556  INFO 93532 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''

也支持 SLF4J 记录器。要使用记录器,我们通过LoggerFactory创建它。它需要的唯一参数是一个名称。默认情况下,通常使用工厂方法,该方法获取类本身并从中获取记录器名称。参见清单 2-9 。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class ChallengeServiceImpl {

  private static final Logger log = LoggerFactory.getLogger(ChallengeServiceImpl.class);

  public void dummyMethod() {
    var name = "John";
    log.info("Hello, {}!", name);
  }

}

Listing 2-9Creating and Using a Logger with SLF4J

正如您在示例中看到的,记录器支持通过花括号占位符替换参数。

考虑到我们在本书中使用的是 Lombok,我们可以用一个简单的注释:@Slf4j替换那行代码,在我们的类中创建一个日志记录器。这有助于保持我们的代码简洁。默认情况下,Lombok 会创建一个名为log的静态变量。见清单 2-10 。

import lombok.extern.slf4j.Slf4j;

@Slf4j
class ChallengeServiceImpl {

  public void dummyMethod() {
    var name = "John";
    log.info("Hello, {}!", name);
  }

}

Listing 2-10Using a Logger with Lombok

总结和成就

在这一章中,我们回顾了将在书中用到的一些基本库和概念:Spring Boot、Lombok、JUnit 和 AssertJ 测试以及日志。这些只是您将在旅程中学到的一小部分,但它们是单独介绍的,以避免在主要学习路径中长时间停顿。所有其他的主题,更多的是与我们不断发展的架构相关,在我们浏览书页的时候会详细解释。

如果你仍然觉得你有一些知识差距,不要担心。下一章中的实用代码示例将通过提供额外的上下文帮助你理解这些概念。

章节成就:

  • 你回顾了关于 Spring 和 Spring Boot 的核心观点。

  • 您已经了解了我们将如何在书中使用 Lombok 来减少样板代码。

  • 您学习了如何使用像 JUnit、Mockito 和 AssertJ 这样的工具来实现测试驱动的开发,以及如何在 Spring Boot 中集成这些工具。

  • 您回顾了一些日志记录基础知识,以及如何在 Lombok 中使用日志记录器。

三、一个基本的 Spring Boot 应用

我们可以直接开始编写代码,但是,即使这样做很实用,也远远不能成为现实。相反,我们将定义一个我们想要构建的产品,并将它分成小块。这种面向需求的方法在整本书中都有使用,以使它更加实用。在现实生活中,您总是会有这些业务需求。

我们想要一个 web 应用来鼓励用户每天锻炼大脑。首先,我们将向用户展示两位数的乘法,每次用户访问页面时都会显示一次。他们将键入他们的别名(简称)和他们对操作结果的猜测。这个想法是他们应该只用心算。在他们发送数据后,网页会提示用户猜测是否正确。

此外,我们希望尽可能保持用户的积极性。为了实现这一点,我们将使用一些游戏化。对于每个正确的猜测,我们给用户分数,他们将在一个排名中看到自己的分数,这样他们就可以与其他人竞争。

这是我们将构建的完整应用的主要思想,我们的产品愿景。但我们不会一次建成。这本书将模拟一种敏捷的工作方式,在这种方式中,我们将需求分解成用户故事,即小块的功能,这些功能本身就有价值。我们将遵循这种方法,使本书尽可能贴近现实生活,因为绝大多数 IT 公司都使用敏捷。

先从简单的开始,先把重点放在乘法求解逻辑上。考虑这里的第一个用户故事。

用户故事 1

作为该应用的用户,我想使用心算解决一个随机乘法问题,所以我锻炼了我的大脑。

为了实现这一点,我们需要构建一个 web 应用的最小框架。因此,我们将把用户故事分成几个子任务。

  1. 创建具有业务逻辑的基本服务。

  2. 创建一个基本的 API 来访问这个服务(REST API)。

  3. 创建一个基本的网页,要求用户解决计算。

在这一章中,我们将关注 1 和 2。在创建了我们的第一个 Spring Boot 应用的框架之后,我们将使用测试驱动开发来构建这个组件的主要逻辑:生成乘法挑战并验证用户解决这些挑战的尝试。然后,我们将添加实现 REST API 的控制器层。您将了解这种分层设计的优势。

我们的学习路径包括一些关于 Spring Boot 最重要的特性之一的推理:自动配置。我们将使用我们的实际案例来看看,例如,应用如何包含它自己的嵌入式 web 服务器,仅仅是因为我们向我们的项目添加了一个特定的依赖项。

设置开发环境

我们将在本书中使用 Java 14。确保你至少从官方下载页面( https://tpd.io/jdk14 )获得那个版本的 JDK。按照操作系统的说明安装它。

好的 IDE 也方便开发 Java 代码。如果你有更喜欢的,就用它。否则,您可以下载例如 IntelliJ IDEA 或 Eclipse 的社区版本。

在本书中,我们还将使用 HTTPie 来快速测试我们的 web 应用。它是一个命令行工具,允许我们与 HTTP 服务器进行交互。您可以按照 https://tpd.io/httpie-install 处的说明下载适用于 Linux、Mac 或 Windows 的软件。或者,如果你是一个curl用户,你也可以很容易地将这本书的http命令映射到curl命令。

框架网络应用

是时候写点代码了!Spring 提供了一种构建应用框架的奇妙方式:Spring Initializr。这是一个网页,允许我们选择要在我们的 Spring Boot 项目中包含哪些组件和库,它将结构和依赖项配置生成到一个我们可以下载的 zip 文件中。我们将在书中多次使用 Initializr,因为它节省了从头创建项目的时间,但是如果您喜欢,您也可以自己创建项目。

源代码:第三章

您可以在 GitHub 的chapter03资源库中找到本章的所有源代码。

https://github.com/Book-Microservices-v2/chapter03

我们导航到 https://start.spring.io/ ,填写一些数据,如图 3-1 所示。

img/458480_2_En_3_Fig1_HTML.jpg

图 3-1

用 Spring Initializr 创建 Spring Boot 项目

本书中的所有代码都使用 Maven、Java 和 Spring Boot 版本 2.3.3,所以让我们坚持使用它们。如果 Spring Boot 版本不可用,您可以选择更新版本。在这种情况下,如果您想使用与书中相同的版本,记得稍后在生成的pom.xml文件中更改它。您也可以继续使用其他 Java 和 Spring Boot 版本,但是本书中的一些代码示例可能不适合您。查看在线图书资源( https://tpd.io/book-extra )了解关于兼容性和升级的最新消息。

给组(microservices.book)和工件(multiplication)一些值。选择 Java 14。不要忘记从列表或搜索工具中添加依赖项 Spring Web、Validation 和 Lombok。你已经知道了 Lombok 的用途,你将在本章看到其他两个依赖项的作用。这就是我们目前所需要的。

生成项目并提取 ZIP 内容。multiplication文件夹包含运行应用所需的一切。现在您可以用您最喜欢的 IDE 打开它,通常是通过选择pom.xml文件。

这些是我们将在自动生成的包中找到的主要元素:

  • Maven 的pom.xml文件包含应用元数据、配置和依赖项。这是 Maven 用来构建应用的主文件。我们将分别检查 Spring Boot 添加的一些依赖项。在这个文件中,您还可以找到使用 Spring Boot 的 Maven 插件构建应用的配置,该插件也知道如何将其所有依赖项打包到一个独立的.jar文件中,以及如何从命令行运行这些应用。

  • 有 Maven 包装器。这是 Maven 的独立版本,所以你不需要安装它来构建你的应用。这些是用于基于 Windows 和 UNIX 的系统的.mvn文件夹和mvnw可执行文件。

  • 我们会找到一个HELP.md文件,里面有一些 Spring Boot 文档的链接。

  • 假设我们将使用 Git 作为版本控制系统,包含的.gitignore有一些预定义的排除,所以我们不会将编译的类或任何 IDE 生成的文件提交到存储库中。

  • src文件夹遵循标准的 Maven 结构,将代码分成子文件夹maintest。两个文件夹都可能包含他们各自的javaresources孩子。在这种情况下,我们的主代码和测试都有一个源文件夹,主代码有一个资源文件夹。

    • 在我们的主源中有一个默认创建的类,MultiplicationApplication。它已经用@SpringBootApplication进行了注释,并且包含了启动应用的main方法。这是定义 Spring Boot 应用主类的标准方式,详见参考文档( https://tpd.io/sb-annotation )。我们稍后会看一看这个类。

    • 在 resources 文件夹中,我们找到两个空的子文件夹:statictemplates。您可以安全地删除它们,因为它们旨在包含我们不会使用的静态资源和 HTML 模板。

    • application.properties文件是我们可以配置 Spring Boot 应用的地方。我们稍后将在这里添加一些配置参数。

既然我们已经了解了这个骨架的不同部分,让我们试着让它行走。要运行此应用,您可以使用您的 IDE 界面或使用项目根文件夹中的以下命令:

multiplication $ ./mvnw spring-boot:run

从终端运行命令

在本书中,我们使用$字符来表示命令提示符。该字符之后的所有内容都是命令本身。有时,需要强调的是,您必须在工作区的给定文件夹中运行该命令。在这种情况下,您会在$字符前找到文件夹名称(例如multiplication $)。当然,你的工作空间的具体位置可能会有所不同。

还要注意,根据您使用的是基于 UNIX 的操作系统(如 Linux 或 Mac)还是 Windows,一些命令可能会有所不同。本书中显示的所有命令都使用基于 UNIX 的系统版本。

当我们运行这个命令时,我们使用了包含在项目主文件夹(mvnw)中的 Maven 包装器,目标是(Maven 可执行文件旁边的内容)spring-boot:run。这个目标是由 Spring Boot 的 Maven 插件提供的,也包含在 Initializr 网页生成的pom.xml文件中。Spring Boot 应用应该会成功启动。日志中的最后一行应该是这样的:

INFO 4139 --- [main] m.b.m.MultiplicationApplication: Started MultiplicationApplication in 6.599 seconds (JVM running for 6.912)

太好了。我们不用写一行代码就能运行第一个 Spring Boot 应用!然而,我们还不能做太多的事情。这个应用在做什么?我们很快就会知道的。

Spring Boot 自动配置

在我们的 skeleton 应用的日志中,您还可以找到这样一行日志:

INFO 30593 --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer: Tomcat initialized with port(s): 8080 (http)

由于 Spring 中的自动配置特性,当我们添加 web 依赖时,我们得到的是一个使用 Tomcat 的可独立部署的 web 应用。

正如我们在前一章介绍的,Spring Boot 自动设置库和默认配置。当我们依赖所有这些默认值时,这为我们节省了大量时间。其中一个约定是,当我们将 Web starter 添加到项目中时,添加一个现成的 Tomcat 服务器。

为了学习更多关于 Spring Boot 自动配置的知识,让我们一步一步地看看这个具体的例子是如何工作的。也可使用图 3-2 获得一些有用的视觉帮助。

我们自动生成的 Spring Boot 应用有一个用@SpringBootApplication注释的主类。这是一个快捷方式注释,因为它集合了其他几个注释,其中就有@EnableAutoConfiguration。顾名思义,通过这个,我们可以启用自动配置功能。因此,Spring 激活了这个智能机制,从您自己的代码和您的依赖项中找到并处理用@Configuration注释进行了注释的类。

我们的项目包括依赖关系spring-boot-starter-web。这是 Spring Boot 的主要组件之一,它拥有构建 web 应用的工具。在这个工件的依赖项中,Spring Boot 的开发人员添加了另一个启动器,spring-boot-starter-tomcat。见清单 3-1 或网上来源( https://tpd.io/starter-web-deps )。

plugins {
    id "org.springframework.boot.starter"
}

description = "Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container"

dependencies {
    api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter"))
    api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-json"))
    api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-tomcat"))
    api("org.springframework:spring-web")
    api("org.springframework:spring-webmvc")

}

Listing 3-1Web Starter Dependencies

正如你所看到的,Spring Boot 工件使用 Gradle(从 2.3 版本开始),但是你不需要知道具体的语法来理解依赖关系是什么。如果我们现在检查spring-boot-starter-tomcat工件的依赖项(在清单 3-2 或在线资源中,在 https://tpd.io/tomcat-starter-deps ),我们看到它包含一个不属于 Spring 家族的库tomcat-embed-core。这是一个 Apache 库,我们可以用它来启动 Tomcat 嵌入式服务器。它的主要逻辑包含在一个名为Tomcat的类中。

plugins {
    id "org.springframework.boot.starter"
}

description = "Starter for using Tomcat as the embedded servlet container. Default servlet container starter used by spring-boot-starter-web"

dependencies {
    api("jakarta.annotation:jakarta.annotation-api")
    api("org.apache.tomcat.embed:tomcat-embed-core") {
        exclude group: "org.apache.tomcat", module: "tomcat-annotations-api"
    }
    api("org.glassfish:jakarta.el")

    api("org.apache.tomcat.embed:tomcat-embed-websocket") {
        exclude group: "org.apache.tomcat", module: "tomcat-annotations-api"
    }
}

Listing 3-2Tomcat Starter Dependencies

回到依赖关系的层次结构,spring-boot-starter-web也依赖于spring-boot-starter(参见清单 3-1 和图 3-2 获得一些上下文帮助)。那是核心 Spring Boot 启动器,其中包括神器spring-boot-autoconfigure(见清单 3-3 或网上来源,在 https://tpd.io/sb-starter )。那个 Spring Boot 工件有一整套注释有@Configuration的类,它们负责整个 Spring Boot 魔法的很大一部分。有一些类用于配置 web 服务器、消息代理、错误处理程序、数据库等等。在 https://tpd.io/auto-conf-packages 查看完整的软件包列表,更好地了解支持的工具。

plugins {
    id "org.springframework.boot.starter"
}

description = "Core starter, including auto-configuration support, logging and YAML"

dependencies {
    api(project(":spring-boot-project:spring-boot"))
    api(project(":spring-boot-project:spring-boot-autoconfigure"))
    api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-logging"))
    api("jakarta.annotation:jakarta.annotation-api")
    api("org.springframework:spring-core")
    api("org.yaml:snakeyaml")

}

Listing 3-3Spring Boot’s Main Starter

对我们来说,负责嵌入式 Tomcat 服务器自动配置的相关类是ServletWebServerFactoryConfiguration。查看清单 3-4 显示其最相关的代码片段,或者查看在线提供的完整源代码( https://tpd.io/swsfc-source )。

@Configuration(proxyBeanMethods = false)
class ServletWebServerFactoryConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
    @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
    static class EmbeddedTomcat {

        @Bean
        TomcatServletWebServerFactory tomcatServletWebServerFactory(
                ObjectProvider<TomcatConnectorCustomizer> connectorCustomizers,
                ObjectProvider<TomcatContextCustomizer> contextCustomizers, 

                ObjectProvider<TomcatProtocolHandlerCustomizer<?>> protocolHandlerCustomizers) {
            TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
            factory.getTomcatConnectorCustomizers()
                    .addAll(connectorCustomizers.orderedStream().collect(Collectors.toList()));
            factory.getTomcatContextCustomizers()
                    .addAll(contextCustomizers.orderedStream().collect(Collectors.toList()));
            factory.getTomcatProtocolHandlerCustomizers()
                    .addAll(protocolHandlerCustomizers.orderedStream().collect(Collectors.toList()));
            return factory;
        }

    }
    // ...
}

Listing 3-4ServletWebServerFactoryConfiguration Fragment

这个类定义了一些内部类,其中一个是EmbeddedTomcat。如你所见,这个注释是这样的:

@ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })

Spring 处理@ConditionalOnClass注释,如果在类路径中可以找到被链接的类,那么这个注释用于在上下文中加载 beans。在这种情况下,条件是匹配的,因为我们已经看到了Tomcat类是如何通过 starter 层次结构进入我们的类路径的。因此,Spring 加载了在EmbeddedTomcat中声明的 bean,结果是一个TomcatServletWebServerFactory

该工厂包含在 Spring Boot 的核心工件(spring-boot,包含在spring-boot-starter中的一个依赖项)中。它用一些默认配置设置了一个 Tomcat 嵌入式服务器。这是创建嵌入式 web 服务器的逻辑最终存在的地方。

img/458480_2_En_3_Fig2_HTML.jpg

图 3-2

自动配置示例:嵌入式 Tomcat

再次重述一下,Spring 扫描我们所有的类,假设满足了EmbeddedTomcat中规定的条件(Tomcat 库包含的依赖项),它在上下文中加载一个TomcatServletWebServerFactory bean。这个 Spring Boot 类使用默认配置启动一个嵌入式 Tomcat 服务器,在端口 8080 上公开一个 HTTP 接口。

可以想象,这种相同的机制适用于数据库、web 服务器、消息代理、云原生模式、安全性等许多其他库。在 Spring Boot,您可以找到多个可以作为依赖项添加的启动器。当您这样做时,自动配置机制开始发挥作用,并且您获得了开箱即用的额外行为。许多配置类是以其他类的存在为条件的,就像我们分析的那些,但是还有其他条件类型,例如,application.properties文件中的参数值。

自动配置是 Spring Boot 的一个关键概念。一旦你理解了它,许多开发者认为神奇的特性对你来说就不再是秘密了。我们浏览了这些细节,因为了解这种机制非常重要,这样您就可以根据自己的需要配置它,避免出现许多您不想要或根本不需要的行为。一个好的做法是,仔细阅读您正在使用的 Spring Boot 模块的文档,并熟悉它们允许的配置选项。

如果你没有完全理解这个概念,不要担心;在本书中,我们将多次回到自动配置机制。原因是我们将向我们的应用添加额外的特性,为此,我们需要向我们的项目添加额外的依赖项,并分析它们引入的新行为。

三层,三层架构

我们实践旅程的下一步是设计如何在不同的类中构建我们的应用和建模我们的业务逻辑。

多层架构将为我们的应用提供一个更适合生产的外观。大多数现实世界的应用都遵循这种架构模式。在 web 应用中,三层设计是最流行的一种,并且得到了广泛的扩展。这三层如下:

  • 客户层:这一层负责用户界面。通常,这就是我们所说的前端

  • 应用层:这包含所有的业务逻辑,以及与之交互的接口和持久化的数据接口。这映射到我们所说的后端

  • 数据存储层:是数据库、文件系统等。,它保存应用的数据。

在本书中,我们主要关注应用层,尽管我们也会用到其他两层。如果我们现在放大,应用层通常使用三层来设计。

  • 业务层:这包括对我们的领域和业务细节建模的类。这是应用的智能所在。有时这一层分为两部分:领域(实体)和提供业务逻辑的应用(服务)。

  • 表示层:在我们的例子中,它将由Controller类来表示,这些类将向 web 客户端提供功能。我们的 REST API 实现将驻留在这里。

  • 数据层:这一层将负责将我们的实体保存在数据存储中,通常是数据库。它通常可以包括数据访问对象 (DAO)类,它们处理直接映射到数据库中的行的对象,或者存储库类,它们是以域为中心的,因此它们可能需要从域表示转换到数据库结构。

我们现在的目标是将这个模式应用到乘法 web 应用中,如图 3-3 所示。

img/458480_2_En_3_Fig3_HTML.jpg

图 3-3

三层,三层架构应用于我们的 Spring Boot 项目

使用这种软件架构的优点都与实现松耦合有关。

  • 所有层都是可互换的(例如,为文件存储解决方案更改数据库,或者从 REST API 更改为任何其他接口)。这是一项关键资产,因为它使得代码库的发展变得更加容易。此外,你可以用测试模拟来替换完整的层,这使得你的测试简单,正如我们将在本章后面看到的。

  • 领域部分是孤立的,独立于其他任何东西。它没有混合接口或数据库细节。

  • 有明确的职责划分:一个类处理对象的数据库存储,一个单独的类用于 REST API 实现,另一个类用于业务逻辑。

Spring 是构建这种类型架构的绝佳选择,它具有许多现成的特性,可以帮助我们轻松创建一个生产就绪的三层应用。它为我们的类提供了三个原型注释,映射到这个设计的每一层,所以我们可以使用它们来实现我们的架构。

  • @Controller注释用于表示层。在我们的例子中,我们将使用控制器实现一个 REST 接口。

  • @Service注释用于实现业务逻辑的类。

  • @Repository注释用于数据层,即与数据库交互的类。

当我们用这些变体注释类时,它们变成了 Spring 管理的组件。当初始化 web 上下文时,Spring 扫描您的包,找到这些类,并将它们作为 beans 加载到上下文中。然后,我们可以使用依赖注入来连接(或注入)这些 beans,例如,使用来自我们的表示层(控制器)的服务。我们将很快在实践中看到这一点。

为我们的领域建模

让我们从建模我们的业务领域开始,因为这将帮助我们构建我们的项目。

领域定义和领域驱动设计

我们的第一个 web 应用负责生成乘法挑战并验证用户的后续尝试。让我们定义这三个业务实体。

  • 挑战:包含乘法挑战的两个要素

  • 用户:识别将尝试解决挑战的人

  • 挑战尝试:代表用户尝试通过挑战解决操作

我们可以对这些域对象及其关系进行建模,如图 3-4 所示。

img/458480_2_En_3_Fig4_HTML.jpg

图 3-4

商业模式

这些对象之间的关系如下:

  • 用户和挑战是独立的实体。他们没有任何证明。

  • 挑战尝试总是针对给定的用户和给定的挑战。从概念上讲,如果生成的挑战数量有限,则同一挑战可能会有多次尝试。此外,同一个用户可以创建多次尝试,因为他们可以根据需要多次使用 web 应用。

在图 3-4 中,您还可以看到我们如何将这三个对象分成两个不同的域:用户和挑战。寻找域边界(也称为有界上下文;参见 https://tpd.io/bounded-ctx )定义对象之间的关系是设计软件的基本任务。这种基于领域的设计方法被称为领域驱动设计 (DDD)。它帮助您构建一个模块化的、可伸缩的、松散耦合的架构。在我们的例子中,用户和挑战是完全不同的概念。挑战,以及他们的尝试,都与用户有关,但它们加在一起有足够的相关性,属于他们自己的领域。

为了让 DDD 更清晰,我们可以考虑这个小系统的一个进化版本,其中其他域与用户或挑战相关。例如,我们可以通过创建域朋友并对用户之间的关系和交互进行建模来引入社交网络功能。如果我们将域用户和挑战混为一谈,这种演变将更难完成,因为新的域与挑战无关。

关于 DDD 的额外阅读,你可以得到 Eric Evans 的书( https://tpd.io/ddd-book )或者下载免费的 InfoQ 小册子( https://tpd.io/ddd-quickly )。

微服务和领域驱动设计

设计微服务时的一个常见错误是认为每个域必须立即划分到不同的微服务中。然而,这可能导致过早的优化,并且从软件项目的开始就导致指数级的复杂性增加。

我们将深入了解关于微服务和整体优先方法的更多细节。目前,重要的是建模域是一项至关重要的任务,但是分割域并不需要将代码分割成微服务。在我们的第一个应用中,我们将两个域放在一起,但不会混淆。我们将使用一个简单的分割策略:根级包。

领域类别

是时候创建类ChallengeChallengeAttemptUser了。首先,我们将根包(microservices.book.multiplication)分成两部分:userschallenges,遵循我们为乘法应用确定的域。然后,我们用这两个包中选择的名称创建三个空类。见清单 3-5 。

+- microservices.book.multiplication.user
|  \- User.java
+- microservices.book.multiplication.challenge
|  \- Challenge.java
|  \- ChallengeAttempt.java

Listing 3-5Splitting Domains by Creating Different Root Packages

因为我们在创建 skeleton 应用时添加了 Lombok 作为依赖项,所以我们可以使用它来保持我们的域类非常小,正如上一章所描述的。请记住,您可能需要在您的 IDE 中添加一个插件,以获得与 Lombok 的完全集成;否则,你可能会从 linter 得到错误。例如,在 IntelliJ 中,您可以通过选择首选项➤插件并搜索 Lombok 来安装官方 Lombok 插件。

Challenge类保存乘法的两个因子。我们添加了 getters,一个包含所有字段的构造函数,以及toString()equals()hashCode()方法。见清单 3-6 。

package microservices.book.multiplication.challenge;

import lombok.*;

/**
 * This class represents a Challenge to solve a Multiplication (a * b).
 */
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class Challenge {
    private int factorA; 

    private int factorB;
}

Listing 3-6The Challenge Class

User类具有相同的 Lombok 注释、用户标识符和友好别名(例如,用户的名字)。参见清单 3-7 。

package microservices.book.multiplication.user;

import lombok.*;

/**
 * Stores information to identify

the user.
 */
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class User {
    private Long id;
    private String alias;
}

Listing 3-7The User Class

尝试也有一个id,用户输入的值(resultAttempt,以及是否正确。见清单 3-8 。我们通过userId将它链接到用户。请注意,我们这里也有两个挑战因素。我们这样做是为了避免通过challengeId引用一个挑战,因为我们可以简单地“动态”生成新的挑战,并将它们复制到这里以保持我们的数据结构简单。因此,正如你所看到的,我们有多种选择来实现我们在图 3-4 中描述的业务模型。为了对与用户的关系进行建模,我们使用一个引用;为了模拟挑战,我们将数据嵌入到尝试中。当我们讨论数据持久性时,我们将在第五章中更详细地分析这个决定。

package microservices.book.multiplication.challenge;

import lombok.*;
import microservices.book.multiplication.user.User;

/**
 * Identifies the attempt from a {@link User} to solve a challenge.
 */
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
public class ChallengeAttempt {
    private Long id;
    private Long userId;
    private int factorA;
    private int factorB;
    private int resultAttempt;
    private boolean correct;
}

Listing 3-8The ChallengeAttempt Class

业务逻辑

一旦我们定义了领域模型,就该考虑业务逻辑的另一部分了:应用服务。

我们需要什么

查看了我们的需求后,我们需要以下内容:

  • 一种产生中等复杂度乘法问题的方法。让我们把 11 到 99 之间的所有因子都做出来。

  • 一些检查尝试是否正确的功能。

随机挑战

让我们为我们的业务逻辑将测试驱动开发付诸实践。首先,我们编写一个基本接口来生成随机挑战。参见清单 3-9 。

package microservices.book.multiplication.challenge;

public interface ChallengeGeneratorService {

  /**
   * @return a randomly-generated challenge with factors between 11 and 99
   */
  Challenge randomChallenge();

}

Listing 3-9The ChallengeGeneratorService Interface

我们也将这个接口放在challenge包中。现在,我们编写这个接口的一个空实现,它包装了一个 Java 的Random。见清单 3-10 。除了无参数构造函数之外,我们还通过第二个接受随机对象的构造函数使我们的类可测试。

package microservices.book.multiplication.challenge;

import org.springframework.stereotype.Service;

import java.util.Random;

@Service
public class ChallengeGeneratorServiceImpl implements ChallengeGeneratorService {

    private final Random random;

    ChallengeGeneratorServiceImpl() {
        this.random = new Random();
    }

    protected ChallengeGeneratorServiceImpl(final Random random) {
        this.random = random; 

    }

    @Override
    public Challenge randomChallenge() {
        return null;
    }
}

Listing 3-10An Empty Implementation of the ChallengeGeneratorService Interface

为了指示 Spring 在上下文中加载这个服务实现,我们用@Service来注释这个类。我们可以稍后通过使用接口而不是实现将该服务注入到其他层中。这样,我们就保持了松耦合,因为我们可以交换实现,而不需要改变其他层中的任何东西。我们将很快将依赖注入付诸实践。现在,让我们关注 TDD,让randomChallenge()实现保持空白。

下一步是为此编写一个测试。我们在同一个包中创建一个类,但是这次是在test源文件夹中。参见清单 3-11 。

package microservices.book.multiplication.challenge;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 

import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Random;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
public class ChallengeGeneratorServiceTest {

    private ChallengeGeneratorService challengeGeneratorService;

    @Spy
    private Random random; 

    @BeforeEach
    public void setUp() {
        challengeGeneratorService = new ChallengeGeneratorServiceImpl(random);
    }

    @Test
    public void generateRandomFactorIsBetweenExpectedLimits() {
        // 89 is max - min range
        given(random.nextInt(89)).willReturn(20, 30);

        // when we generate a challenge
        Challenge challenge = challengeGeneratorService.randomChallenge();

        // then the challenge contains factors as expected
        then(challenge).isEqualTo(new Challenge(31, 41)); 

    }

}

Listing 3-11Creating the Unit Test Before the Real Implementation

在前一章中,我们回顾了如何使用 Mockito 用 JUnit 5 的@Mock注释和MockitoExtension类替换给定类的行为。在这个测试中,我们需要替换一个对象的行为,而不是一个类。我们用@Spy来存根一个对象。Mockito 扩展将有助于使用空构造函数创建一个Random实例,并为我们清除它以覆盖行为。这是让我们的测试工作的最简单的方法,因为实现随机生成器的基本 Java 类不能在接口上工作(我们可以简单地用模仿而不是窥探)。

通常,我们在一个用@BeforeEach标注的方法中初始化所有测试所需的东西,所以这发生在每个测试开始之前。这里我们构造了传递这个存根对象的服务实现。

唯一的测试方法是按照 BDD 风格用given()设置前提条件。生成 11 到 99 之间的随机数的方法是得到一个 0 到 89 之间的随机数,然后在上面加 11。因此,我们知道应该用89调用random来生成范围11, 100内的数字,所以我们在第一次调用时覆盖该调用以返回20,第二次调用时返回30。然后,当我们调用randomChallenge()时,我们期望它从random(我们的存根对象)获得随机数 20 和 30,并因此返回一个带有3141Challenge对象。

所以,我们做了一个测试,当你运行它的时候很明显会失败。我们来试试吧;您可以从项目的根文件夹中使用 IDE 或 Maven 命令。

multiplication$ ./mvnw test

不出所料,测试会失败。参见清单 3-12 中的结果。

Expecting:
 <null>
to be equal to:
 <Challenge(factorA=20, factorB=30)>
but was not.
Expected :Challenge(factorA=20, factorB=30)
Actual   :null

Listing 3-12Error Output After Running the Test for the First Time

现在,我们只需要通过测试。在我们的例子中,解决方案非常简单,我们需要在实现测试的时候解决它。稍后,我们将看到更多有价值的 TDD 案例,但是这个案例已经帮助我们开始这种工作方式。参见清单 3-13 。

@Service
public class ChallengeGeneratorServiceImpl implements ChallengeGeneratorService {

    private final static int MINIMUM_FACTOR = 11;
    private final static int MAXIMUM_FACTOR = 100;

    // ...

    private int next() {
        return random.nextInt(MAXIMUM_FACTOR - MINIMUM_FACTOR) + MINIMUM_FACTOR;
    }

    @Override
    public Challenge randomChallenge() {
        return new Challenge(next(), next());

    }
}

Listing 3-13Implementing a Valid Logic to Generate Challenges

现在,我们再次运行测试,这次它通过了:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

测试驱动开发就是这么简单。首先,您设计测试,这些测试在开始时会失败。然后,你实现你的逻辑让他们通过。在现实生活中,当您从定义需求的人那里获得构建测试用例的帮助时,您会得到最大的收获。您可以编写更好的测试,从而更好地实现您真正想要构建的东西。

尝试验证

为了满足业务需求的第二部分,我们实现了一个接口来验证用户的尝试。参见清单 3-14 。

package microservices.book.multiplication.challenge;

public interface ChallengeService {

    /**
     * Verifies if an attempt

coming from the presentation layer is correct or not.
     *
     * @return the resulting ChallengeAttempt object
     */
    ChallengeAttempt verifyAttempt(ChallengeAttemptDTO resultAttempt);

}

Listing 3-14The ChallengeService Interface

正如您在代码中看到的,我们将一个ChallengeAttemptDTO对象传递给了verifyAttempt方法。这个类还不存在。数据传输对象(dto)在系统的不同部分之间传送数据。在这种情况下,我们使用 DTO 对表示层所需的数据进行建模,以创建一个尝试。见清单 3-15 。来自用户的尝试没有字段correct,也不需要知道用户的 ID。我们还可以使用 dto 来验证数据,我们将在构建控制器时看到这一点。

package microservices.book.multiplication.challenge;

import lombok.Value;

/**
 * Attempt coming from the user
 */
@Value
public class ChallengeAttemptDTO {

    int factorA, factorB; 

    String userAlias;
    int guess;

}

Listing 3-15The ChallengeAttemptDTO Class

这一次我们使用 Lombok 的@Value,一个快捷方式注释,用一个全参数构造函数和toStringequalshashCode方法创建一个不可变的类。它还会将我们的字段设置为private final;这就是为什么我们不需要添加这一点。继续使用 TDD 方法,我们在ChallengeServiceImpl接口实现中创建无为逻辑。见清单 3-16 。

package microservices.book.multiplication.challenge;

import org.springframework.stereotype.Service;

@Service
public class ChallengeServiceImpl implements ChallengeService {

    @Override
    public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
        return null;
    }
}

Listing 3-16An Empty ChallengeService Interface Implementation

现在,我们为这个类编写一个单元测试,所以我们验证它对正确和错误的尝试都有效。参见清单 3-17 。

package microservices.book.multiplication.challenge;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.BDDAssertions.then; 

public class ChallengeServiceTest {

    private ChallengeService challengeService; 

    @BeforeEach
    public void setUp() {
        challengeService = new ChallengeServiceImpl();
    }

    @Test
    public void checkCorrectAttemptTest() {
        // given
        ChallengeAttemptDTO attemptDTO =
                new ChallengeAttemptDTO(50, 60, "john_doe", 3000);

        // when
        ChallengeAttempt resultAttempt =
                challengeService.verifyAttempt(attemptDTO); 

        // then
        then(resultAttempt.isCorrect()).isTrue();
    }

    @Test
    public void checkWrongAttemptTest() {
        // given
        ChallengeAttemptDTO attemptDTO =
                new ChallengeAttemptDTO(50, 60, "john_doe", 5000);

        // when
        ChallengeAttempt resultAttempt =
                challengeService.verifyAttempt(attemptDTO); 

        // then
        then(resultAttempt.isCorrect()).isFalse();
    }
}

Listing 3-17Writing the Test to Verify Challenge Attempts

50 和 60 相乘的结果是 3,000,因此第一个测试用例的断言期望correct字段为真,而第二个测试期望错误的猜测为假(5,000)。

让我们现在执行测试。您可以使用 IDE,或者使用 Maven 命令来指定要运行的测试的名称。

multiplication$ ./mvnw -Dtest=ChallengeServiceTest test

您将看到类似如下的输出:

[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR]   ChallengeServiceTest.checkCorrectAttemptTest:28 NullPointer
[ERROR]   ChallengeServiceTest.checkWrongAttemptTest:42 NullPointer
[INFO]
[ERROR] Tests run: 2, Failures: 0, Errors: 2, Skipped: 0

正如所预见的,两个测试都将抛出一个空指针异常。

然后,我们回到服务实现,并让它工作。参见清单 3-18 。

@Override
public ChallengeAttempt verifyAttempt(ChallengeAttemptDTO attemptDTO) {
    // Check if the attempt is correct
    boolean isCorrect = attemptDTO.getGuess() ==
            attemptDTO.getFactorA() * attemptDTO.getFactorB();

    // We don't use identifiers for now
    User user = new User(null, attemptDTO.getUserAlias());

    // Builds the domain object. Null id for now.
    ChallengeAttempt checkedAttempt = new ChallengeAttempt(null,
            user,
            attemptDTO.getFactorA(),
            attemptDTO.getFactorB(),
            attemptDTO.getGuess(),
            isCorrect
    );

    return checkedAttempt;
}

Listing 3-18Implementing the Logic to Verify Attempts

我们现在保持简单。后来,这个实现应该处理更多的任务。我们需要创建一个用户或找到一个现有的用户,将该用户连接到新的尝试,并将其存储在数据库中。

现在,再次运行测试以验证它是否通过:

[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 s - in microservices.book.multiplication.challenge.ChallengeServiceTest

同样,我们成功地使用 TDD 构建了验证挑战尝试的逻辑。

Users领域在第一个用户故事的范围内不需要任何业务逻辑,所以让我们进入下一层。

表示层

本节将介绍表示层。

休息

我们没有从服务器构建 HTML,而是决定像在真正的软件项目中通常所做的那样接近表示层:在它们之间有一个 API 层。通过这样做,我们不仅可以向其他后端服务公开我们的功能,还可以保持后端和前端完全隔离。通过这种方式,我们可以从一个简单的 HTML 页面和普通的 JavaScript 开始,然后在不改变后端代码的情况下迁移到一个完整的前端框架。

在所有可能的 API 选择中,现在最流行的是表述性状态转移(REST)。它通常构建在 HTTP 之上,所以它使用 HTTP 动词来执行 API 操作:GET、POST、PUT、DELETE 等。我们将在本书中构建 RESTful web 服务,它们只是符合 REST 架构风格的 web 服务。因此,我们遵循 URL 和 HTTP 动词的一些约定,这些约定已经成为事实上的标准。见表 3-1 。

表 3-1

REST APIs 的约定

|

HTTP 动词

|

对集合的操作,例如/挑战

|

对项目的操作,例如挑战/3

| | --- | --- | --- | | 得到 | 获取项的完整列表 | 获取该项 | | 邮政 | 创建新项目 | 不适用 | | 放 | 不适用 | 更新项目 | | 删除 | 删除整个集合 | 删除项目 |

编写 REST APIs 有几种不同的风格。表 3-1 显示了本书中最基本的操作和一些约定选择。通过 API 传输的内容还有多个方面:分页、空处理、格式(例如 JSON)、安全性、版本控制等。如果你对这些约定对于一个真实的组织来说可能变得多详细感到好奇,你可以看看 Zalando 的 API 指南( https://tpd.io/api-zalando )。

用 Spring Boot 休息 API

用 Spring 构建 REST API 是一项简单的任务。不出所料,@Controller原型有一个专门用于构建 REST 控制器的特性,叫做@RestController

为了对不同 HTTP 动词的资源和映射进行建模,我们使用了@RequestMapping注释。它适用于类级别和方法级别,因此我们可以用简单的方式构建我们的 API 上下文。为了更简单,Spring 提供了类似于@PostMapping@GetMapping等变体。,所以我们甚至不需要指定 HTTP 动词。

每当我们想要将请求的主体传递给我们的方法时,我们就使用@RequestBody注释。如果我们使用自定义类,Spring Boot 将尝试反序列化它,使用传递给方法的类型。默认情况下,Spring Boot 使用 JSON 序列化格式,尽管当通过Accept HTTP 头指定时,它也支持其他格式。在我们的 web 应用中,我们将使用所有的 Spring Boot 默认设置。

我们还可以用请求参数定制我们的 API,并从请求路径读取值。让我们以这个请求为例:

GET http://ourhost.com/challenges/5?factorA=40

这些是它不同的部分:

  • GET是 HTTP 动词。

  • http://ourhost.com/ 是运行 web 服务器的主机。在这个例子中,应用从根上下文/提供服务。

  • /challenges/是由应用创建的 API 上下文,用于提供该领域的功能。

  • /5被称为路径变量。在这种情况下,它用标识符5表示Challenge对象。

  • factorA=40是一个请求参数及其值。

为了处理这个请求,我们可以创建一个控制器,将 5 作为路径变量challengeId,40 作为请求参数factorA。见清单 3-19 。

@RestController
@RequestMapping("/challenges")
class ChallengeAttemptController {

    @GetMapping("/{challengeId}")
    public Challenge getChallengeWithParam(@PathVariable("challengeId") Long challengeId,
                                           @RequestParam("factorA") int factorA) {...}
}

Listing 3-19An Example of Using Annotations to Map REST API URLs

提供的功能不止于此。我们还可以验证 REST 控制器与javax.validation API 集成的请求。这意味着我们可以对反序列化期间使用的类进行注释,以避免空值,或者当我们从客户端获得请求时,强制数字在给定的范围内,这只是一个例子。

不要担心引入的新概念的数量。我们将在接下来的章节中用实际的例子来介绍它们。

设计我们的 API

我们可以使用需求来设计我们需要在 REST API 中公开哪些功能。

  • 一个接口来获得一个随机的,中等复杂度的乘法

  • 从给定用户的别名发送给定乘法猜测值的端点

这些是挑战的读取操作和创建尝试的动作。请记住,乘法挑战和尝试是不同的资源,我们将 API 一分为二,并为这些动作分配相应的动词:

  • GET /challenges/random将返回随机生成的挑战。

  • POST /attempts/将是我们向端点发送解决挑战的尝试。

这两种资源都属于challenges域。最终,我们还需要一个/users映射来与我们的用户一起执行操作,但是我们将它留到以后,因为我们不需要它来完成第一个需求(用户故事)。

API 优先的方法

在实现 API 契约之前,在您的组织内定义和讨论它通常是一个好的实践。您应该包括端点、HTTP 动词、允许的参数以及请求和响应主体示例。这样,其他开发人员和客户可以验证公开的功能是否是他们需要的,并在您浪费时间实现错误的解决方案之前给出反馈。这种策略被称为 API First ,有行业标准来编写 API 规范,比如 OpenAPI。

如果想了解更多关于 API 第一OpenAPI 的内容,请看 https://tpd.io/apifirst ,来自 Swagger,规范的最初创造者。

我们的第一个控制器

让我们创建一个产生随机挑战的控制器。我们在服务层中已经有了那个操作,所以我们只需要从控制器中使用那个方法。这就是我们在表示层应该做的:保持它与任何业务逻辑隔离。我们将只使用它来建模 API 和验证传递的数据。参见清单 3-20 。

package microservices.book.multiplication.challenge;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

/**
 * This class implements a REST API to get random challenges
 */
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/challenges")
class ChallengeController {

    private final ChallengeGeneratorService challengeGeneratorService;

    @GetMapping("/random")
    Challenge getRandomChallenge() {
        Challenge challenge = challengeGeneratorService.randomChallenge();
        log.info("Generating a random challenge: {}", challenge);
        return challenge;
    }
}

Listing 3-20The ChallengeController Class

@RestController注释告诉 Spring 这是一个建模 REST 控制器的专用组件。它是@Controller@ResponseBody的组合,指示 Spring 将这个方法的结果作为 HTTP 响应体。在 Spring Boot,默认情况下,如果没有其他指示,响应将被序列化为 JSON 并包含在响应体中。

我们还在类级别添加了一个@RequestMapping("/challenges"),所以所有的映射方法都会添加这个作为前缀。

在我们的控制器中还有两个 Lombok 注释。

  • @RequiredArgsConstructor创建一个带有ChallengeGeneratorService作为参数的构造函数,因为该字段是私有的和最终的,Lombok 认为这是必需的。Spring 使用依赖注入,所以它会尝试找到实现这个接口的 bean,并将它连接到控制器。在这种情况下,它将采用唯一的候选服务ChallengeGeneratorServiceImpl

  • Slf4j创建一个名为log的记录器。我们用它来打印一条消息,以控制台生成的挑战。

方法getRandomChallenge()@GetMapping("/random")注释。这意味着该方法将处理对上下文/challenges/random的 GET 请求,第一部分来自类级注释。它只是返回一个Challenge对象。

现在让我们再次运行我们的 web 应用,并做一个快速的 API 测试。从 IDE 中运行MultiplicationApplication类,或者从控制台中使用mvnw spring-boot:run

使用 HTTPie(参见第二章,我们通过在端口 8080 (Spring Boot 的默认端口)上对localhost(我们的机器)做一个简单的 GET 请求来尝试我们的新端点。参见清单 3-21 。

$ http localhost:8080/challenges/random
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Sun, 29 Mar 2020 07:59:00 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "factorA": 39,
    "factorB": 36
}

Listing 3-21Making a Request to the Newly Created API

我们得到了一个 HTTP 响应,它包含头部和主体,这是一个 challenge 对象的良好序列化的 JSON 表示。我们做到了!我们的应用终于有所作为了。

自动序列化如何工作

在介绍自动配置在 Spring Boot 是如何工作的时候,我们看了一下 Tomcat 嵌入式服务器的例子,我们提到作为spring-boot-autoconfigure依赖项的一部分,还包含了更多的自动配置类。因此,这另一个魔法负责将Challenge序列化为正确的 JSON HTTP 响应,对您来说应该不再是一个谜了。无论如何,让我们看看这是如何工作的,因为它是 Spring Boot web 模块的核心概念。还有,现实生活中定制这种配置也挺常见的。

Spring Boot Web 模块的许多重要逻辑和默认值都在WebMvcAutoConfiguration类中(参见 https://tpd.io/mvcauto-source )。这个类将上下文中所有可用的 HTTP 消息转换器收集在一起供以后使用。在清单 3-22 中可以看到这个类的一个片段。

@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
    this.messageConvertersProvider
            .ifAvailable((customConverters) -> converters.addAll(customConverters.getConverters()));
}

Listing 3-22A Fragment of WebMvcAutoConfiguration Class Provided by Spring Web

HttpMessageConverter接口( https://tpd.io/hmc-source )包含在核心的spring-web工件中,它定义了转换器支持哪些媒体类型,哪些类可以相互转换,以及进行转换的readwrite方法。

这些转换器来自哪里?更多自动配置类。Spring Boot 包含了一个JacksonHttpMessageConvertersConfiguration类( https://tpd.io/jhmcc-source ),它有一些逻辑来加载一个MappingJackson2HttpMessageConverter类型的 bean。这个逻辑以类路径中存在类ObjectMapper为条件。该类是 Jackson 库的核心类,是 Java 最流行的 JSON 序列化实现。ObjectMapper包含在jackson-databind依赖项中。该类位于类路径中,因为它的工件是包含在spring-boot-starter-json中的依赖项,而后者本身包含在spring-boot-starter-web中。

还是那句话,最好用图表来理解这一切。见图 3-5 。

img/458480_2_En_3_Fig5_HTML.jpg

图 3-5

Spring Boot Web JSON 自动配置

默认的ObjectMapper bean 是在JacksonAutoConfiguration ( https://tpd.io/jac-source )类中配置的。那里的一切都是以灵活的方式设置的。如果我们想要定制一个特定的特性,我们不需要考虑整个层次结构。通常,这只是一个覆盖默认 beans 的问题。

例如,如果我们想将 JSON 属性命名改为 snake-case 而不是 camel-case,我们可以在应用配置中声明一个定制的ObjectMapper,它将被加载而不是默认的。这就是我们在清单 3-23 中所做的。

@SpringBootApplication
public class MultiplicationApplication {

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

    @Bean
    public ObjectMapper objectMapper() {
        var om = new ObjectMapper();
        om.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
        return om;
    }
}

Listing 3-23Injecting Beans in the Context to Override Defaults in Spring Boot

通常,我们会将这个 bean 声明添加到一个单独的用@Configuration注释的类中,但是这段代码对于这个简单的例子来说已经足够好了。如果您再次运行应用并调用端点,您将获得 snake-case 中的因子属性。参见清单 3-24 。

$ http  localhost:8080/challenges/random
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Sun, 29 Mar 2020 10:05:00 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "factor_a": 39,
    "factor_b": 36
}

Listing 3-24Verifying Spring Boot Configuration Changes with a New Request

如您所见,通过覆盖 beans 来定制 Spring Boot 配置非常容易。这种特殊情况是可行的,因为默认的ObjectMapper@ConditionalOnMissingBean进行了注释,这使得 Spring Boot 只有在上下文中没有定义相同类型的其他 bean 时才加载该 bean。记住删除这个自定义ObjectMapper,因为我们现在只使用 Spring Boot 的默认设置。

您可能已经错过了这些控制器的 TDD 方法。我们首先介绍一个简单的控制器实现的原因是,在开始测试策略之前,您更容易掌握控制器在 Spring Boot 如何工作的概念。

用 Spring Boot 测试控制器

我们的第二个控制器将实现 REST API 来接收来自前端的解决挑战的尝试。对于这一个,是时候回到测试驱动的方法了。首先,我们创建一个新控制器的空壳。参见清单 3-25 。

package microservices.book.multiplication.challenge;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * This class provides

a REST API to POST the attempts from users.
 */
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
class ChallengeAttemptController {

    private final ChallengeService challengeService;

}

Listing 3-25An Empty Implementation of the ChallengeAttemptController

与前面的实现类似,我们使用 Lombok 添加带有服务接口的构造函数。Spring 会注入相应的 bean ChallengeServiceImpl

现在让我们用预期的逻辑编写一个测试。请记住,测试控制器需要一个稍微不同的方法,因为中间有一个 web 层。有时,我们希望验证一些特性,如验证、请求映射或错误处理,这些特性由我们配置,但由 Spring Boot 提供。因此,我们通常希望单元测试不仅涵盖类本身,还涵盖它周围的所有特性。

在 Spring Boot,有多种实施控制器测试的方法:

  • 而不运行嵌入式服务器。我们可以使用不带参数的@SpringBootTest,或者更好的是,@WebMvcTest来指示 Spring 有选择地只加载所需的配置,而不是整个应用上下文。然后,我们使用 Spring 测试模块中包含的专用工具MockMvc来模拟请求。

  • 运行嵌入式服务器。在这种情况下,我们使用@SpringBootTest,其webEnvironment参数设置为RANDOM_PORTDEFINED_PORT。然后,我们必须对服务器进行真正的 HTTP 调用。Spring Boot 包含了一个类TestRestTemplate,它有一些有用的特性来执行这些测试请求。当您想要测试一些您可能已经定制的 web 服务器配置(例如,定制 Tomcat 配置)时,此选项很有用。

最好的选择通常是第一个,并选择一个带有@WebMvcTest的细粒度配置。我们获得了围绕控制器的所有配置,而无需为每次测试花费额外的时间来启动服务器。如果你想获得所有这些不同选项的额外知识,请查看 https://tpd.io/sb-test-guide

我们可以为一个有效请求和一个无效请求编写一个测试,如清单 3-26 所示。

package microservices.book.multiplication.challenge;

import microservices.book.multiplication.user.User;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.BDDAssertions.then;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

@ExtendWith(SpringExtension.class)
@AutoConfigureJsonTesters
@WebMvcTest(ChallengeAttemptController.class)
class ChallengeAttemptControllerTest {

    @MockBean
    private ChallengeService challengeService; 

    @Autowired
    private MockMvc mvc;

    @Autowired
    private JacksonTester<ChallengeAttemptDTO> jsonRequestAttempt;
    @Autowired
    private JacksonTester<ChallengeAttempt> jsonResultAttempt;

    @Test
    void postValidResult() throws Exception {
        // given
        User user = new User(1L, "john");
        long attemptId = 5L;
        ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(50, 70, "john", 3500);
        ChallengeAttempt expectedResponse = new ChallengeAttempt(attemptId, user, 50, 70, 3500, true);
        given(challengeService
                .verifyAttempt(eq(attemptDTO)))
                .willReturn(expectedResponse);

        // when
        MockHttpServletResponse response = mvc.perform(
                post("/attempts").contentType(MediaType.APPLICATION_JSON)
                        .content(jsonRequestAttempt.write(attemptDTO).getJson()))
                .andReturn().getResponse();

        // then
        then(response.getStatus()).isEqualTo(HttpStatus.OK.value());
        then(response.getContentAsString()).isEqualTo(
                jsonResultAttempt.write(
                        expectedResponse
                ).getJson());
    }

    @Test
    void postInvalidResult() throws Exception {
        // given an attempt with invalid input data

        ChallengeAttemptDTO attemptDTO = new ChallengeAttemptDTO(2000, -70, "john", 1);

        // when
        MockHttpServletResponse response = mvc.perform(
                post("/attempts").contentType(MediaType.APPLICATION_JSON)
                        .content(jsonRequestAttempt.write(attemptDTO).getJson()))
                .andReturn().getResponse();

        // then
        then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
    }

}

Listing 3-26Testing the Expected ChallengeAttemptController Logic

这段代码中有一些新的注释和助手类。让我们一个一个来复习。

  • 确保我们的 JUnit 5 测试加载了 Spring 的扩展,这样我们就可以使用测试上下文。

  • @AutoConfigureJsonTesters告诉 Spring 为我们在测试中声明的一些字段配置类型为JacksonTester的 beans。在我们的例子中,我们使用@Autowired从测试上下文中注入两个JacksonTesterbean。当通过这个注释得到指示时,Spring Boot 负责构建这些实用程序类。一个JacksonTester可以用来序列化和反序列化对象,使用与应用在运行时相同的配置(即ObjectMapper)。

  • @WebMvcTest,以控制器类为参数,让 Spring 把这个当做表示层测试。因此,它将只加载控制器周围的相关配置:验证、序列化程序、安全性、错误处理程序等。(参见 https://tpd.io/test-autoconf 获取包含的自动配置类的完整列表)。

  • 附带 Spring Boot 测试模块,通过允许你模拟你没有测试的其他层和 beans 来帮助你开发合适的单元测试。在我们的例子中,我们用 mock 替换上下文中的服务 bean。我们使用BDDMockitogiven()在测试方法中设置预期的返回值。

  • 你可能很熟悉。这是 Spring 中的一个基本注释,让它将上下文中的 bean 注入(或连接)到字段中。它过去在所有使用 Spring 的类中都很常见,但是从 4.3 版开始,如果字段是在构造函数中初始化的,并且类只有一个构造函数,那么它可以从字段中省略。

  • 当我们做一个不加载真实服务器的测试时,MockMvc类是我们在 Spring 中用来模拟对表示层的请求的。它是由测试上下文提供的,所以我们可以在测试中注入它。

有效尝试测试

现在我们可以关注测试用例以及如何让它们通过。第一个测试为有效的尝试设置场景。它创建 DTO,作为从 API 客户端发送的数据,并带有有效的结果。它使用 BDDMockito 的given()来指定,当使用与 DTO 相等的参数(Mockito 的eq)调用服务(模拟 bean)时,它应该返回预期的ChallengeAttempt响应。

我们用助手类MockMvcRequestBuilders中包含的静态方法post构建 POST 请求。我们的目标是预期路径/attempts。内容类型设置为application/json,其主体为 JSON 格式的序列化 DTO。我们使用连线的JacksonTester进行序列化。然后,mvc通过perform()发出请求,我们得到调用.andReturn()的响应。如果我们也将MockMvc用于断言,我们也可以调用方法andExpect(),但是最好使用像 AssertJ 这样的专用断言库来单独完成它们。

在测试的最后一部分,我们验证 HTTP 状态代码应该是 200 OK,并且结果必须是预期响应的序列化版本。同样,我们为此使用了一个JacksonTester对象。

当我们执行测试时,测试失败,并显示 404 NOT FOUND。参见清单 3-27 。这个请求没有实现,所以服务器不能简单地找到一个逻辑来映射 POST 映射。

Expecting:
 <404>
to be equal to:
 <200>
but was not.

Listing 3-27The ChallengeAttemptControllerTest Fails

然后,我们回到ChallengeAttemptController并实现这个映射。参见清单 3-28 。

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/attempts")
class ChallengeAttemptController {

    private final ChallengeService challengeService;

    @PostMapping
    ResponseEntity<ChallengeAttempt> postResult(@RequestBody ChallengeAttemptDTO challengeAttemptDTO) {
        return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
    }
}

Listing 3-28Adding the Working Implementation to ChallengeAttemptController

这是一个简单的逻辑,只需要调用服务层。我们的方法用不带参数的@PostMapping进行了注释,因此它将处理对已经在类级别设置的上下文路径的 POST 请求。注意,这里我们使用一个ResponseEntity作为返回类型,而不是直接使用ChallengeAttempt。另一个选择也可行。我们在这里使用这种新的方式来展示使用ResponseEntity静态构建器构建不同类型的响应的方法。

就这样!第一个测试用例现在将通过。

验证控制器中的数据

第二个测试用例postInvalidResult(),检查应用是否应该接受负数或超出范围的尝试。它期望我们的逻辑返回一个 400 错误请求,当错误发生在客户端时,这是一个很好的实践,就像这样。参见清单 3-29 。

// then
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());

Listing 3-29Verifying That the Client Gets a BAD REQUEST Status Code

如果您在控制器中实现我们的 POST 映射之前运行它,它会失败,并显示一个 NOT FOUND 状态代码。有了实现,它也会失败。然而,在这种情况下,结果更糟。参见清单 3-30 。

org.opentest4j.AssertionFailedError:
Expecting:
 <200>
to be equal to:
 <400>
but was not.

Listing 3-30Posting

an Invalid Request Returns a 200 OK Status Code

我们的应用只是接受无效的尝试并返回一个 OK 状态。这是错误的;我们不应该将这种尝试传递给服务层,而应该在表示层拒绝它。为此,我们将使用与 Spring Integration 的 Java Bean 验证 API ( https://tpd.io/bean-validation )。

在我们的 DTO 类中,我们添加了一些 Java 验证注释来指示什么是有效的输入。见清单 3-31 。所有这些注释都在jakarta.validation-api库中实现,可以通过spring-boot-starter-validation在我们的类路径中获得。该启动器是 Spring Boot Web 启动器(spring-boot-starter-web)的一部分。

package microservices.book.multiplication.challenge;

import lombok.Value;

import javax.validation.constraints.*;

/**
 * Attempt coming from the user
 */
@Value
public class ChallengeAttemptDTO {

    @Min(1) @Max(99)
    int factorA, factorB;
    @NotBlank
    String userAlias;
    @Positive
    int guess;

}

Listing 3-31Adding Validation Constraints to Our DTO Class

在那个包中有很多可用的约束( https://tpd.io/constraints-source )。我们使用@Min@Max来定义乘法因子的允许值的范围,@NotBlank来确保我们总是得到一个别名,而@Positive用于猜测,因为我们知道我们只处理肯定的结果(我们也可以在这里使用预定义的范围)。

让这些约束发挥作用的一个重要步骤是通过控制器方法参数中的@Valid注释将它们与 Spring Integration。参见清单 3-32 。只有当我们添加这个时,Spring Boot 才会分析约束,如果不匹配,就会抛出一个异常。

@PostMapping
ResponseEntity<ChallengeAttempt> postResult(
        @RequestBody @Valid ChallengeAttemptDTO challengeAttemptDTO) {
    return ResponseEntity.ok(challengeService.verifyAttempt(challengeAttemptDTO));
}

Listing 3-32Using the @Valid Annotation to Validate Requests

您可能已经猜到,当对象无效时,会有自动配置来处理错误并构建预定义的响应。默认情况下,错误处理程序构造一个带有400 BAD_REQUEST状态代码的响应。

从 Spring Boot 版本 2.3 开始,默认情况下,验证消息不再包含在错误响应中。这可能会让调用者感到困惑,因为他们不知道请求到底出了什么问题。不包括它们的原因是这些消息可能会将信息暴露给恶意的 API 客户端。出于我们的教育目的,我们希望启用验证消息,所以我们将向我们的application.properties文件添加两个设置。见清单 3-33 。这些属性都列在了 Spring Boot 官方文档中( https://tpd.io/server-props ),我们很快就能看到他们是怎么做的。

server.error.include-message=always
server.error.include-binding-errors=always

Listing 3-33Adding Validation Logging Configuration to the application.properties File

为了验证我们所有的验证配置,现在让我们再次运行测试。这一次它会过去,您会看到一些额外的日志,如清单 3-34 所示。

[Field error in object 'challengeAttemptDTO' on field 'factorB': rejected value [-70];
[...]
[Field error in object 'challengeAttemptDTO' on field 'factorA': rejected value [2000];
[...]

Listing 3-34An Invalid Request Causes Now the Expected Result

处理用户发送尝试的 REST API 调用的控制器现在正在工作。如果我们再次启动应用,我们可以通过 HTTPie 命令使用这个新的端点。首先,我们像以前一样要求随机挑战。然后,我们尝试解决它。参见清单 3-35 。

$ http -b :8080/challenges/random
{
    "factorA": 58,
    "factorB": 92
}
$ http POST :8080/attempts factorA=58 factorB=92 userAlias=moises guess=5400
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/json
Date: Fri, 03 Apr 2020 04:49:51 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "correct": false,
    "factorA": 58,
    "factorB": 92,
    "id": null,
    "resultAttempt": 5400,
    "user": {
        "alias": "moises",
        "id": null
    }
}

Listing 3-35Running a Standard Use Case for the Application Using HTTPie Commands

第一个命令使用参数-b只打印响应的正文。如你所见,我们也可以省略localhost,HTTPie 会默认使用它。

为了发送尝试,我们在 URL 前使用了POST参数。JSON 是 HTTPie 中的默认内容类型,所以我们可以简单地传递key=value参数,这个工具会将其转换成合适的 JSON。不出所料,我们得到了一个序列化的ChallengeAttempt对象,表明结果不正确。

我们还可以尝试一个无效的请求,看看 Spring Boot 是如何处理验证错误的。参见清单 3-36 。

$ http POST :8080/attempts factorA=58 factorB=92 userAlias=moises guess=-400
HTTP/1.1 400
Connection: close
Content-Type: application/json
Date: Sun, 16 Aug 2020 07:30:10 GMT
Transfer-Encoding: chunked

{
    "error": "Bad Request",
    "errors": [
        {
            "arguments": [
                {
                    "arguments": null,
                    "code": "guess",
                    "codes": [
                        "challengeAttemptDTO.guess",
                        "guess"
                    ],
                    "defaultMessage": "guess"
                }
            ],
            "bindingFailure": false,
            "code": "Positive",
            "codes": [
                "Positive.challengeAttemptDTO.guess",
                "Positive.guess",
                "Positive.int",
                "Positive"
            ],
            "defaultMessage": "must be greater than 0",
            "field": "guess",
            "objectName": "challengeAttemptDTO",
            "rejectedValue": -400
        }
    ],
    "message": "Validation failed for object="challengeAttemptDTO". Error count: 1",
    "path": "/attempts",
    "status": 400,
    "timestamp": "2020-08-16T07:30:10.212+00:00"
}

Listing 3-36Error Response Including Validation Messages

这是一个相当冗长的回答。主要原因是所有的绑定错误(那些由验证约束引起的)都被添加到错误响应中。这是我们用server.error.include-binding-errors=always打开的。此外,root message字段还为客户端提供了出错原因的总体描述。默认情况下省略了这个描述,但是我们使用属性server.error.include-message=always启用了它。

如果这个响应进入用户界面,您需要在前端解析这个 JSON 响应,获取无效的字段,并可能显示defaultMessage字段。更改这个默认消息非常简单,因为您可以用约束注释覆盖它。让我们修改ChallengeAttemptDTO中的注释,然后用相同的无效请求再试一次。见清单 3-37 。

@Positive(message = "How could you possibly get a negative result here? Try again.")
int guess;

Listing 3-37Changing the Validation Message

在这种情况下,Spring Boot 处理错误的方式是偷偷在上下文中添加一个@Controller:BasicErrorController(参见 https://tpd.io/bec-source )。这一个使用类DefaultErrorAttributes ( https://tpd.io/dea-source )来编写错误响应。如果你想深入了解如何定制这个行为的更多细节,你可以看看 https://tpd.io/cust-err-handling

总结和成就

我们从我们将在本书中构建的应用的需求开始这一章。然后,我们划分了范围,选择了第一个开发项目:生成随机挑战并允许用户猜测结果的功能。

您了解了如何创建 Spring Boot 应用的框架,以及关于软件设计和架构的一些最佳实践:三层和三层架构、领域驱动设计、测试驱动/行为驱动开发、JUnit 5 的基本单元测试,以及 REST API 设计。在本章中,您关注了应用层,并以 REST API 的形式实现了域对象、业务层和表示层。见图 3-6 。

img/458480_2_En_3_Fig6_HTML.jpg

图 3-6

章节 3 后的应用状态

本章还介绍了 Spring Boot 的一个核心概念:自动配置。现在你知道大部分 Spring Boot 魔术师住在哪里了。将来,您应该能够在参考文档中找到自己的方法来覆盖任何其他配置类中的其他默认行为。

我们还在 Spring Boot 体验了其他特性,比如实现@Service@Controller组件,用MockMvc测试控制器,以及通过 Java Bean Validation API 验证输入。

为了完成我们的第一个 web 应用,我们需要构建一个用户界面。稍后,我们还将讨论数据层,以确保我们可以持久化用户和尝试。

章节成就:

  • 您了解了如何按照三层设计构建一个结构合理的 Spring Boot 应用。

  • 基于两个带有支持图的实际例子:Tomcat 嵌入式服务器和 JSON 序列化默认值,您理解了 Spring Boot 的自动配置是如何工作的,这是揭示其魔力的关键。

  • 您按照领域驱动的设计技术建模了一个示例业务案例。

  • 您使用测试驱动开发方法开发了第一个应用三层中的两层(服务控制器)。

  • 您使用了最重要的 Spring MVC 注释,通过 Spring Boot 实现了 REST API。

  • 您学习了如何在 Spring 中使用 MockMVC 测试控制器层。

  • 您向 API 添加了验证约束,以防止无效输入。