Spring-Boot-启动指南-三-

71 阅读1小时+

Spring Boot 启动指南(三)

原文:zh.annas-archive.org/md5/8803f34bb871785b4bbbecddf52d5733

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:使用 Project Reactor 和 Spring WebFlux 进行响应式编程

本章介绍了响应式编程,讨论了它的起源和存在的原因,并展示了 Spring 如何引领开发和推进众多工具和技术的发展,使其成为多种使用情况下最佳解决方案之一。具体来说,我展示了如何使用 Spring Boot 和 Project Reactor 驱动对 SQL 和 NoSQL 数据库的数据库访问,将响应式类型与 Thymeleaf 等视图技术集成,并使用 RSocket 将进程间通信提升到意想不到的新水平。

代码检出检查

请查看代码库中的 chapter8begin 分支开始。

响应式编程简介

虽然一本完整的关于响应式编程的论述可以——并且已经,以及将会——占据一整本书,但理解为什么它是一个如此重要的概念是至关重要的。

在典型的服务中,每个请求都会创建一个线程来处理。每个线程都需要资源,因此应用程序能够管理的线程数量是有限的。以一个简化的例子来说,如果一个应用程序可以服务 200 个线程,那么该应用程序可以同时接受来自最多 200 个独立客户端的请求,但不多。任何额外尝试连接服务的请求必须等待线程变得可用。

对于连接的 200 个客户端的性能可能满足要求,也可能不满足,这取决于多种因素。毋庸置疑的是,对于客户端应用程序发起的第 201 个及更多的并发请求,由于服务在等待可用线程时会发生阻塞,响应时间可能会显著恶化。这种可扩展性的硬性停止可以在没有警告和简单解决方案的情况下从非问题变为危机,并且像传统的“投入更多实例来解决问题”的解决方法引入了压力缓解和需要解决的新问题。响应式编程的出现就是为了解决这一可扩展性危机。

响应式宣言指出,响应式系统是:

  • 响应的

  • 弹性的

  • 弹性的

  • 消息驱动的

简而言之,响应式系统的四个关键点结合在一起(在宏观层面上)形成了一个最大程度可用、可扩展和高性能的系统,有效地执行任务所需的最少资源。

从系统层面上说,即多个应用程序/服务共同工作以满足各种使用情况,我们可能注意到大多数挑战涉及应用程序之间的通信:一个应用程序响应另一个应用程序,请求到达时应用程序/服务的可用性,服务根据需求扩展或缩减,一个服务通知其他感兴趣的服务有更新/可用信息等。解决应用程序间交互潜在问题可以在很大程度上减轻和/或解决前面提到的可扩展性问题。

这一观察表明,通信是问题的主要潜在来源,因此也是解决问题的最大机会,这导致了响应式流倡议的发起。响应式流(RS)倡议关注服务之间的交互——即流,包括四个关键元素:

  • 应用程序编程接口(API)

  • 规范

  • 实现示例

  • 技术兼容性套件(TCK)

API 仅包含四个接口:

  • Publisher:事物的创建者

  • Subscriber:事物的消费者

  • Subscription:发布者和订阅者之间的合同

  • Processor:同时包括 Subscriber 和 Publisher,用于接收、转换和分发事物

这种精简的简洁性至关重要,同样重要的是 API 仅由接口而非实现组成。这允许在不同平台、语言和编程模型之间实现各种互操作的实现。

文本规范详细说明了 API 实现的预期和/或必需行为。例如:

If a Publisher fails it MUST signal an onError.

实现示例对于实现者是有用的辅助工具,提供了在创建特定 RS 实现时使用的参考代码。

或许最关键的部分是技术兼容性套件。 TCK 使实现者能够验证和展示其 RS 实现(或其他人的实现)与规范的兼容性水平及当前存在的任何缺陷。知识就是力量,识别出与规范不完全兼容的任何问题可以加速解决,同时向当前库使用者提供警告,直到问题得到解决。

Project Reactor

虽然 JVM 有几种可用的响应式流实现,但是 Project Reactor 是其中最活跃、最先进和性能最优的之一。 Reactor 已被全球许多小型组织和全球科技巨头开发和部署的图书馆、API 和应用程序采纳,并提供了许多关键项目的基础,包括 Spring 的 WebFlux 响应式 Web 能力和 Spring Data 的多个开源和商业数据库的响应式数据库访问。 Reactor 还允许从堆栈顶部到底部以及侧面创建端到端响应式管道,增加了开发和采纳的强劲动力。这是一个百分之百的解决方案。

这为什么重要?

从堆栈的顶部到底部,从最终用户到最低层计算资源,每个交互都提供了一个潜在的粘附点。如果用户的浏览器与后端应用程序之间的交互是非阻塞的,但是应用程序必须等待与数据库的阻塞交互,那么结果就是一个阻塞系统。与应用程序之间的通信情况相同;如果用户的浏览器与后端服务 A 通信,但是服务 A 阻塞等待来自服务 B 的响应,用户获得了什么?可能很少,甚至可能什么都没有。

开发人员通常可以看到切换到 Reactive Streams 为他们和他们的系统带来的巨大潜力。与此相对应的是,相对于命令式编程构造和工具的相对新颖性,以及这种变化所需要的思维方式的变化,可能需要开发人员进行调整和更多的工作,至少在短期内是这样。只要所需的努力明显超过了可扩展性的好处,以及在整体系统中应用反应流的广度和深度,这仍然是一个容易的决定。在系统的所有应用程序中具有反应式管道是两个方面的乘数。

Project Reactor 对 Reactive Streams 的实现简洁而简单,构建在 Java 和 Spring 开发人员已经熟悉的概念之上。类似于 Java 8+的 Stream API,Reactor 最好通过声明性的、链式的操作符使用,通常与 lambda 一起使用。与更为程序化、命令式的代码相比,它首先感觉有些不同,然后相当优雅。熟悉Stream会加速适应和欣赏。

Reactor 将反应流Publisher的概念进行了特殊化,提供了类似于命令式 Java 的构造。与为需要反应流的所有东西使用通用的Publisher不同——将其视为按需的、动态的Iterable——Project Reactor 定义了两种类型的Publisher

Mono:: 发出 0 或 1 个元素 Flux:: 发出 0 到n个元素,一个定义的数量或无限的

这与命令式构造完美地契合。例如,在标准 Java 中,一个方法可以返回类型为 T 的对象或Iterable<T>。使用 Reactor,同样的方法将返回一个Mono<T>或一个Flux<T>——一个对象或可能很多,或者在反应性代码的情况下,这些对象的Publisher

Reactor 也非常自然地适用于 Spring 的观点。根据用例,从阻塞到非阻塞代码的转换可能就像改变项目依赖项和一些方法返回值一样简单,如前所示。本章的示例演示了如何做到这一点,以及向外扩展——向上、向下和横向——从单个反应性应用程序转移到反应性系统,包括反应性数据库访问,以实现最大的收益。

比较 Tomcat 和 Netty

在 Spring Boot 的命令式世界中,Tomcat 是默认的 Servlet 引擎,用于 Web 应用程序,尽管在这个级别上,开发人员还可以选择像 Jetty 和 Undertow 这样的替代方案。作为默认选项,Tomcat 是一个非常合理的选择,因为它经过验证、性能优越,并且 Spring 团队的开发人员已经(仍在)为优化和演进 Tomcat 的代码库做出贡献。它是 Boot 应用程序的出色 Servlet 引擎。

话虽如此,Servlet 规范的许多迭代从根本上是同步的,没有异步能力。Servlet 3.0 开始通过异步请求处理来解决这个问题,但仍然只支持传统的阻塞 I/O。规范的 3.1 版本增加了非阻塞 I/O,使其适用于异步,因此也适用于响应式应用程序。

Spring WebFlux 是 Spring 对应 Spring WebMVC(包名)的响应式实现,通常简称为 Spring MVC 的对应物。Spring WebFlux 基于 Reactor 构建,并使用 Netty 作为默认的网络引擎,就像 Spring MVC 使用 Tomcat 监听和处理请求一样。Netty 是一个经过验证和高性能的异步引擎,Spring 团队的开发人员也为 Netty 做出贡献,以紧密集成 Reactor 并保持 Netty 的功能和性能处于前沿。

就像 Tomcat 一样,您也有选择权。任何 Servlet 3.1 兼容的引擎都可以与 Spring WebFlux 应用程序一起使用,如果您的任务或组织需要的话。然而,Netty 凭借其领先地位和大多数用例的优势,通常是最佳选择。

反应式数据访问

正如之前提到的,最终目标是实现全面的可扩展性和最佳的系统范围吞吐量,这依赖于完全端到端的响应式实现。在最低级别,这取决于数据库访问。

多年来,设计数据库以减少争用和系统性能阻塞的工作已经付出了很多努力。即使在这项令人印象深刻的工作中,许多数据库引擎和驱动程序仍然存在问题,其中包括在没有阻塞请求应用程序的情况下执行操作以及复杂的流控制/反压机制。

分页构造已被用来解决这些约束,但它们并不完美。使用带分页的命令式模型通常需要为每一页发出一个不同范围和/或约束的查询。这需要每次都发出新请求和新响应,而不是像Flux那样的继续操作。类比是从水池中每次舀一杯水(命令式方法)与直接打开水龙头来重新灌满杯子。与“去获取,带回”的命令式操作不同,在响应式情景下,水已经等待流动。

R2DBC 与 H2

在现有版本的 PlaneFinder 中,我使用 Java Persistence API(JPA)和 H2 数据库来存储(在内存中的 H2 实例中)从我监视的本地设备中检索到的飞机位置。JPA 是基于命令规范构建的,因此本质上是阻塞的。看到需要一种非阻塞的响应式方式与 SQL 数据库交互,几位行业领导者和知名人士联手创建和演进了响应式关系数据库连接(R2DBC)项目。

像 JPA 一样,R2DBC 是一个开放的规范,可以与其提供的服务提供者接口(SPI)一起使用,供供应商或其他感兴趣的方进行驱动程序的开发,并为下游开发人员创建客户端库。与 JPA 不同,R2DBC 基于 Project Reactor 的响应式流实现,并且完全响应式和非阻塞。

更新 PlaneFinder

与大多数复杂系统一样,我们目前并不控制整个分布式系统的所有方面和节点。像大多数复杂系统一样,越完全地采纳一种范式,从中可以获得的收益就越多。我会从通信链的起点尽可能接近的地方开始这段“响应式之旅”:在 PlaneFinder 服务中。

重构 PlaneFinder 以使用响应式流 Publisher 类型,如 MonoFlux,是第一步。我会继续使用现有的 H2 数据库,但为了“响应式化”它,需要删除 JPA 项目依赖,并将其替换为 R2DBC 库。我将更新 PlaneFinder 的 pom.xml Maven 构建文件如下:

<!--	Comment out or remove this 	-->
<!--<dependency>-->
<!--    <groupId>org.springframework.boot</groupId>-->
<!--	<artifactId>spring-boot-starter-data-jpa</artifactId>-->
<!--</dependency>-->

<!--	Add this  	    		    -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>

<!--	Add this too  	    	    -->
<dependency>
    <groupId>io.r2dbc</groupId>
    <artifactId>r2dbc-h2</artifactId>
    <scope>runtime</scope>
</dependency>

PlaneRepository 接口必须更新为扩展 ReactiveCrudRepository 接口,而不是其阻塞的对应项 CrudRepository。这个简单的更新如下所示:

public interface PlaneRepository
    extends ReactiveCrudRepository<Aircraft, String> {}

PlaneRepository 的更改会向外扩散,这自然而然地导致下一个停靠点,即 PlaneFinderService 类,其中 getAircraft() 方法返回 PlaneRepository::saveAll 的结果(当找到飞机时),或者 saveSamplePositions() 方法的结果(否则)。将返回值从阻塞的 Iterable<Aircraft> 替换为 Flux<Aircraft>,用于 getAircraft()saveSamplePositions() 方法再次正确指定方法返回值。

public Flux<Aircraft> getAircraft() {
    ...
}

private Flux<Aircraft> saveSamplePositions() {
    ...
}

由于 PlaneController 类的方法 getCurrentAircraft() 调用 PlaneFinderService::getAircraft,现在它返回 Flux<Aircraft>。这需要对 PlaneController::getCurrentAircraft 的签名进行更改如下:

public Flux<Aircraft> getCurrentAircraft() throws IOException {
    ...
}

使用 H2 与 JPA 是一个相当成熟的事务;涉及的规范、相关的 API 和库已经开发了大约十年。R2DBC 是一个相对较新的开发,虽然支持正在迅速扩展,但在 Spring Data JPA 对 H2 的支持中,还有一些功能尚未实现。这并不会增加太多负担,但在选择使用关系数据库(如 H2)时,需要记住这一点,要以响应式的方式进行。

目前,要使用 H2 与 R2DBC,必须为应用程序创建和配置一个ConnectionFactoryInitializer bean。实际上,配置只需要两个步骤:

  • 将连接工厂设置为(已自动配置的)ConnectionFactory bean,作为参数注入

  • 配置数据库“填充器”以执行一个或多个脚本,以初始化或重新初始化数据库,如所需。

请记住,使用 Spring Data JPA 与 H2 时,使用相关的@Entity类来在 H2 数据库中创建相应的表。当使用 H2 与 R2DBC 时,通过标准的 SQL DDL(数据定义语言)脚本手动完成此步骤。

DROP TABLE IF EXISTS aircraft;

CREATE TABLE aircraft (id BIGINT auto_increment primary key,
callsign VARCHAR(7), squawk VARCHAR(4), reg VARCHAR(8), flightno VARCHAR(10),
route VARCHAR(30), type VARCHAR(4), category VARCHAR(2),
altitude INT, heading INT, speed INT, vert_rate INT, selected_altitude INT,
lat DOUBLE, lon DOUBLE, barometer DOUBLE, polar_distance DOUBLE,
polar_bearing DOUBLE, is_adsb BOOLEAN, is_on_ground BOOLEAN,
last_seen_time TIMESTAMP, pos_update_time TIMESTAMP, bds40_seen_time TIMESTAMP);
注意

这是一个额外的步骤,但并非没有先例。许多 SQL 数据库在与 Spring Data JPA 结合使用时都需要这一步;H2 是个例外。

下面是DbConxInit或数据库连接初始化器类的代码。需要的 bean 创建方法是第一个——initializer()——产生所需的ConnectionFactoryInitializer bean。第二个方法生成一个CommandLineRunner bean,一旦类被配置,就会被执行。CommandLineRunner 是一个具有单个抽象方法 run() 的函数接口。因此,我提供了一个 lambda 作为其实现,用一个Aircraft填充(然后列出)PlaneRepository的内容。目前,我已经注释掉了init()方法的@Bean注解,因此该方法从未被调用,CommandLineRunner bean 从未被生成,并且示例记录从未被存储:

import io.r2dbc.spi.ConnectionFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer;
import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator;

@Configuration
public class DbConxInit {
    @Bean
    public ConnectionFactoryInitializer
            initializer(@Qualifier("connectionFactory")
            ConnectionFactory connectionFactory) {
        ConnectionFactoryInitializer initializer =
            new ConnectionFactoryInitializer();
        initializer.setConnectionFactory(connectionFactory);
        initializer.setDatabasePopulator(
            new ResourceDatabasePopulator(new ClassPathResource("schema.sql"))
        );
        return initializer;
    }

//    @Bean // Uncomment @Bean annotation to add sample data
    public CommandLineRunner init(PlaneRepository repo) {
        return args -> {
            repo.save(new Aircraft("SAL001", "N12345", "SAL001", "LJ",
                    30000, 30, 300,
                    38.7209228, -90.4107416))
                .thenMany(repo.findAll())
                    .subscribe(System.out::println);
        };
    }
}

CommandLineRunner lambda 需要一些解释。

结构本身是一个典型的 lambda 表达式,如 x -> { <在此执行的代码> },但其中包含的代码具有一些有趣的 Reactive Streams 特定特性。

第一个声明的操作是repo::save,它保存提供的内容——在本例中是一个新的Aircraft对象——并返回一个Mono<Aircraft>。可以简单地subscribe()到这个结果并打印日志/输出来验证。但是养成的一个好习惯是保存所有所需的示例数据,然后查询存储库以生成所有记录。这样做允许完全验证此时表的最终状态,并应显示所有记录。

请记住,响应式代码不会阻塞,那么我们如何确保所有先前的操作在继续之前都已完成呢?在这种情况下,我们如何确保在尝试检索所有记录之前所有记录都已保存?在 Project Reactor 中,有一些操作符等待完成信号,然后继续链中的下一个函数。then()操作符等待一个Mono作为输入,然后接受另一个Mono继续进行。在之前的示例中显示的thenMany()操作符等待任何上游Publisher的完成,并继续播放一个新的Flux。在生成CommandLineRunner bean 的init方法中,repo.findAll()生成一个Flux<Aircraft>,如预期地填充了账单。

最后,我订阅来自repo.findAll()Flux<Aircraft>输出,并将结果打印到控制台。不需要记录结果,事实上,一个简单的subscribe()就能满足启动数据流的要求。但是为什么需要订阅呢?

除了少数例外情况外, Reactive Streams Publisher 冷发布者,这意味着如果没有订阅者,它们不会执行任何工作或消耗任何资源。这最大化了效率和可伸缩性,这是完全合理的,但对于刚接触响应式编程的人来说,这也提供了一个常见的陷阱。如果不是将Publisher返回给调用代码进行订阅和使用,务必添加subscribe()来激活生成Publisher或操作链。

最后,由于 JPA 和 R2DBC 以及它们支持的 H2 代码之间的差异,需要对领域类Aircraft进行一些更改。 JPA 使用的@Entity注解不再需要,主键关联成员变量id@GeneratedValue注解现在也不再需要。从 PlaneFinder 从 JPA 迁移到使用 H2 的 R2DBC 时,移除这两个及其关联的导入语句是唯一需要的更改。

为了适应之前显示的CommandLineRunner bean(如果需要样本数据),以及其字段限制的构造函数调用,我在Aircraft中创建了一个额外的构造函数来匹配。请注意,只有在您希望创建一个不提供所有参数的Aircraft实例时,才需要这样做,如构造函数 Lombok 基于@AllArgsConstructor注解所要求的。请注意,我从这个有限参数构造函数调用所有参数构造函数:

    public Aircraft(String callsign, String reg, String flightno, String type,
                    int altitude, int heading, int speed,
                    double lat, double lon) {

        this(null, callsign, "sqwk", reg, flightno, "route", type, "ct",
                altitude, heading, speed, 0, 0,
                lat, lon, 0D, 0D, 0D,
                false, true,
                Instant.now(), Instant.now(), Instant.now());
    }

现在是时候验证我们的工作了。

从 IDE 中启动 PlaneFinder 应用程序后,我回到终端窗口中的 HTTPie 来测试更新后的代码:

mheckler-a01 :: OReilly/code » http -b :7634/aircraft
[
    {
        "altitude": 37000,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": "EDV5123",
        "category": "A3",
        "flightno": "DL5123",
        "heading": 131,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-09-19T21:40:56Z",
        "lat": 38.461505,
        "lon": -89.896606,
        "polar_bearing": 156.187542,
        "polar_distance": 32.208164,
        "pos_update_time": "2020-09-19T21:40:56Z",
        "reg": "N582CA",
        "route": "DSM-ATL",
        "selected_altitude": 0,
        "speed": 474,
        "squawk": "3644",
        "type": "CRJ9",
        "vert_rate": -64
    },
    {
        "altitude": 38000,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A4",
        "flightno": "FX3711",
        "heading": 260,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-09-19T21:40:57Z",
        "lat": 39.348558,
        "lon": -90.330383,
        "polar_bearing": 342.006425,
        "polar_distance": 24.839372,
        "pos_update_time": "2020-09-19T21:39:50Z",
        "reg": "N924FD",
        "route": "IND-PHX",
        "selected_altitude": 0,
        "speed": 424,
        "squawk": null,
        "type": "B752",
        "vert_rate": 0
    },
    {
        "altitude": 35000,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-09-19T21:41:11Z",
        "callsign": "JIA5304",
        "category": "A3",
        "flightno": "AA5304",
        "heading": 112,
        "id": 3,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-09-19T21:41:12Z",
        "lat": 38.759811,
        "lon": -90.173632,
        "polar_bearing": 179.833023,
        "polar_distance": 11.568717,
        "pos_update_time": "2020-09-19T21:41:11Z",
        "reg": "N563NN",
        "route": "CLT-RAP-CLT",
        "selected_altitude": 35008,
        "speed": 521,
        "squawk": "6506",
        "type": "CRJ9",
        "vert_rate": 0
    }
]

确认重构后的响应式 PlaneFinder 正常工作后,我们现在可以转向 Aircraft Positions 应用程序。

更新 Aircraft Positions 应用程序

目前aircraft-positions项目使用 Spring Data JPA 和 H2,就像当它是一个阻塞应用程序时的 PlaneFinder 一样。虽然我可以将 Aircraft Positions 更新为使用 R2DBC 和 H2,就像 PlaneFinder 现在所做的那样,但这需要对aircraft-positions项目进行重构,为了探索其他反应式数据库解决方案,这是一个绝佳的机会。

MongoDB 经常处于数据库创新的前沿,事实上,它是第一个为其同名数据库开发完全反应式驱动程序的任何类型的数据库提供商之一。使用 Spring Data 和 MongoDB 开发应用几乎没有摩擦,这反映了其反应式流支持的成熟性。对于飞行器位置的反应式重构,MongoDB 是一个自然的选择。

对构建文件(在本例中为pom.xml)进行一些更改是有必要的。首先,我删除了 Spring MVC、Spring Data JPA 和 H2 的不必要的依赖项:

  • spring-boot-starter-web

  • spring-boot-starter-data-jpa

  • h2

接下来,我为未来的反应式版本添加以下依赖项:

  • spring-boot-starter-data-mongodb-reactive

  • de.flapdoodle.embed.mongo

  • reactor-test

注意

由于WebClientspring-boot-starter-webflux已经是一个依赖项,所以不需要额外添加。

正如第六章中所述,我将在此示例中使用嵌入式 MongoDB。由于嵌入式 MongoDB 通常仅用于测试,因此通常包括一个“测试”的范围;由于我在应用程序执行期间使用此功能,因此我会从构建文件中省略或删除该范围限定符。更新后的 Maven pom.xml 依赖关系如下所示:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>de.flapdoodle.embed</groupId>
        <artifactId>de.flapdoodle.embed.mongo</artifactId>
    </dependency>
    <dependency>
        <groupId>io.projectreactor</groupId>
        <artifactId>reactor-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

通过命令行或 IDE 快速刷新依赖项,我们就可以开始重构了。

我首先从对AircraftRepository接口的非常简单的更改开始,将其更改为扩展阻塞CrudRepositoryReactiveCrudRepository

public interface AircraftRepository extends ReactiveCrudRepository<Aircraft, Long> {}

更新PositionController类是一个相当小的任务,因为WebClient已经使用反应式流Publisher类型进行交流。我定义了一个局部变量Flux<Aircraft> aircraftFlux,然后链式调用所需的声明操作来清除先前检索到的飞行器位置,检索新的位置,将它们转换为Aircraft类的实例,过滤掉没有列出飞行器注册号的位置,并将它们保存到嵌入式 MongoDB 存储库中。然后,我将aircraftFlux变量添加到Model中以供用户界面使用,并返回 Thymeleaf 模板的名称进行渲染:

@RequiredArgsConstructor
@Controller
public class PositionController {
    @NonNull
    private final AircraftRepository repository;
    private WebClient client
        = WebClient.create("http://localhost:7634/aircraft");

    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        Flux<Aircraft> aircraftFlux = repository.deleteAll()
                .thenMany(client.get()
                        .retrieve()
                        .bodyToFlux(Aircraft.class)
                        .filter(plane -> !plane.getReg().isEmpty())
                        .flatMap(repository::save));

        model.addAttribute("currentPositions", aircraftFlux);
        return "positions";
    }
}

最后,需要对领域类Aircraft本身进行一些小的更改。类级别的@Entity注解是 JPA 特定的;MongoDB 使用的相应注解是@Document,表示类的实例将存储为数据库中的文档。此外,先前使用的@Id注解引用了javax.persistence.Id,在没有 JPA 依赖项的情况下消失了。将import javax.persistence.Id;替换为import org.springframework.data.annotation.Id;保留了与 MongoDB 一起使用的表标识符上下文。完整的类文件如下所示以供参考:

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.Instant;

@Document
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Aircraft {
    @Id
    private Long id;
    private String callsign, squawk, reg, flightno, route, type, category;

    private int altitude, heading, speed;
    @JsonProperty("vert_rate")
    private int vertRate;
    @JsonProperty("selected_altitude")
    private int selectedAltitude;

    private double lat, lon, barometer;
    @JsonProperty("polar_distance")
    private double polarDistance;
    @JsonProperty("polar_bearing")
    private double polarBearing;

    @JsonProperty("is_adsb")
    private boolean isADSB;
    @JsonProperty("is_on_ground")
    private boolean isOnGround;

    @JsonProperty("last_seen_time")
    private Instant lastSeenTime;
    @JsonProperty("pos_update_time")
    private Instant posUpdateTime;
    @JsonProperty("bds40_seen_time")
    private Instant bds40SeenTime;
}

运行 PlaneFinder 和 Aircraft Positions 应用程序后,返回浏览器选项卡并在地址栏中输入*http://localhost:8080*,加载页面,结果如图 8-1 所示。

sbur 0801

图 8-1. 飞机位置应用程序登陆页面,index.html

点击点击这里链接加载飞机位置报告页面,如图 8-2 所示。

sbur 0802

图 8-2. 飞机位置报告页面

每次定期刷新时,页面将重新查询 PlaneFinder,并根据需要更新报告,但有一个非常重要的区别:供显示的多个飞机位置不再是完全形成的、阻塞的List,而是 Reactive Streams 的Publisher,具体是Flux类型。接下来的部分将进一步讨论这一点,但现在重要的是要意识到这种内容协商/适应是无需开发人员努力的。

响应式 Thymeleaf

如第七章中所述,现在绝大多数前端 Web 应用程序都是使用 HTML 和 JavaScript 开发的。这并不改变使用视图技术/模板来实现其目标的许多生产应用程序的存在;也并不意味着这些技术不继续简单有效地满足一系列要求。在这种情况下,对模板引擎和语言进行适应以适应 Reactive Streams 的情况非常重要。

Thymeleaf 在三个不同的层次上支持 RS,允许开发人员选择最适合其需求的一种。如前所述,可以将后端处理转换为利用响应式流,并让 Reactor 通过Publisher(如MonoFlux)向 Thymeleaf 提供值,而不是Object<T>Iterable<T>。这并不会导致响应式前端,但如果关注的主要是将后端逻辑转换为使用响应式流,以消除阻塞并在服务之间实现流控制,则这是部署支持用户界面应用程序的一种无摩擦入门方式,需要的工作量最少。

Thymeleaf 还支持分块和数据驱动模式,以支持 Spring WebFlux,两者都涉及使用 Server Sent Events 和一些 JavaScript 代码来实现数据向浏览器的提供。虽然这两种模式都是完全有效的,但为了实现所需的结果可能需要大量的 JavaScript,这可能会使权衡倾向于模板化+HTML+JavaScript,而不是 100% HTML+JavaScript 前端逻辑。当然,这个决定在很大程度上取决于需求,并应由负责创建和支持该功能的开发人员来决定。

在上一节中,我演示了如何将后端功能迁移到 RS 构造中,以及 Spring Boot 如何使用 Reactor+Thymeleaf 在前端保持功能,帮助简化阻塞应用系统的转换,并最小化停机时间。这足以满足当前的用例,使我们能够在返回(在即将到来的章节中)扩展前端功能之前,考虑进一步改进后端功能的方法。

RSocket 用于完全响应式的进程间通信

本章中,我已经为使用 Reactive Streams 在不同应用程序之间进行进程间通信奠定了基础。虽然创建的分布式系统确实使用了响应式构造,但系统尚未发挥其潜力。通过使用基于更高级别的基于 HTTP 的传输跨越网络边界会由于请求-响应模型而带来限制,甚至仅仅升级到 WebSocket 也无法解决所有问题。RSocket 的创建是为了灵活且强大地消除进程间通信的不足。

什么是 RSocket?

RSocket 是几个行业领导者和尖端创新者合作的结果,是一个可以在 TCP、WebSocket 和 Aeron 传输机制上使用的高速二进制协议。RSocket 支持四种异步交互模型:

  • 请求-响应

  • 请求-流

  • 火而忘

  • 请求通道(双向流)

RSocket 建立在反应式流范式和 Project Reactor 之上,可以实现完全互联的应用程序系统,同时提供增加灵活性和韧性的机制。一旦两个应用程序/服务之间建立连接,客户端与服务器的区别消失了,它们实际上是对等的。任何一方都可以启动四种交互模型之一,并适应所有用例:

  • 一个 1:1 的交互,其中一方发出请求并从另一方接收响应

  • 一个 1:N 的交互,其中一方发出请求并从另一方接收一系列响应

  • 一个 1:0 的交互,其中一方发出请求

  • 一个完全双向的通道,双方都可以自由地发送请求、响应或任何类型的数据流

正如你所见,RSocket 非常灵活。作为一种性能重点的二进制协议,它也非常快速。此外,RSocket 具有韧性,使得可以重新建立断开的连接,并在通信中自动恢复中断的地方。而且由于 RSocket 建立在 Reactor 之上,使用 RSocket 的开发人员可以真正将单独的应用程序视为完全集成的系统,因为网络边界不再对流量控制施加任何限制。

Spring Boot 以其传说中的自动配置,可以说为 Java 和 Kotlin 开发人员提供了使用 RSocket 的最快、最友好的方式。

把 RSocket 投入使用

目前,PlaneFinder 和 Aircraft Positions 应用都使用基于 HTTP 的传输进行通信。将这两个 Spring Boot 应用程序转换为使用 RSocket 是明显的下一步。

将 PlaneFinder 迁移到 RSocket

首先,我将 RSocket 依赖添加到 PlaneFinder 的构建文件中:

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

快速进行 Maven 重新导入后,就可以开始重构代码了。

暂时,我会保留 /aircraft 的现有端点,并在 PlaneController 中添加一个 RSocket 端点。为了将 REST 端点和 RSocket 端点放置在同一个类中,我将 @RestController 注解中内置的功能解耦成其组成部分:@Controller@ResponseBody

将类级别的 @RestController 注解替换为 @Controller 意味着对于我们希望直接返回 JSON 对象的任何 REST 端点(例如与 getCurrentAircraft() 方法关联的现有 /aircraft 端点),需要向方法中添加 @ResponseBody。这种看似退步的优势在于,然后可以在同一个 @Controller 类中定义 RSocket 端点,将 PlaneFinder 的入口点和出口点放在一个且仅有一个位置:

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;

import java.io.IOException;
import java.time.Duration;

@Controller
public class PlaneController {
    private final PlaneFinderService pfService;

    public PlaneController(PlaneFinderService pfService) {
        this.pfService = pfService;
    }

    @ResponseBody
    @GetMapping("/aircraft")
    public Flux<Aircraft> getCurrentAircraft() throws IOException {
        return pfService.getAircraft();
    }

    @MessageMapping("acstream")
    public Flux<Aircraft> getCurrentACStream() throws IOException {
        return pfService.getAircraft().concatWith(
                Flux.interval(Duration.ofSeconds(1))
                        .flatMap(l -> pfService.getAircraft()));
    }
}

为了创建一个重复发送飞行器位置的流,首先和随后每一秒的流,我创建了getCurrentACStream()方法,并使用@MessageMapping注解它作为一个 RSocket 端点。请注意,由于 RSocket 映射不像 HTTP 地址/端点那样建立在根路径之上,因此在映射中不需要斜杠(/)。

在定义了端点和服务方法之后,下一步是为 RSocket 指定一个端口来监听连接请求。我在 PlaneFinder 的application.properties文件中执行此操作,为基于 HTTP 的server.port添加了一个用于spring.rsocket.server.port的属性值:

server.port=7634
spring.rsocket.server.port=7635

有了这个单个的 RSocket 服务器端口分配,Spring Boot 就足以配置包含应用程序为 RSocket 服务器的所有必要 bean 并执行所有必要的配置。请注意,虽然 RSocket 连接中涉及的两个应用程序中必须有一个起初充当服务器,但一旦连接建立,客户端(发起连接的应用程序)和服务器(监听连接的应用程序)之间的区别就消失了。

通过这些少量更改,PlaneFinder 现在已准备好使用 RSocket。只需启动应用程序即可准备好接收连接请求。

将飞行器位置迁移到 RSocket

再次,添加 RSocket 的第一步是将 RSocket 依赖项添加到构建文件中——在这种情况下是针对飞行器位置应用程序:

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

不要忘记在继续之前使用 Maven 重新导入并激活项目中的更改。现在,进入代码部分。

类似于我在 PlaneFinder 中所做的,我重构了PositionController类以创建所有进出口的单一点。用@Controller替换类级别的@RestController注解允许包含 RSocket 端点以及基于 HTTP 的(但在这种情况下是模板驱动的)端点,该端点激活positions.html Thymeleaf 模板。

为了使飞行器位置能够作为 RSocket 客户端运行,我通过构造函数注入一个RSocketRequester.Builder bean 来创建一个RSocketRequesterRSocketRequester.Builder bean 是由 Spring Boot 自动创建的,因为将 RSocket 依赖项添加到项目中。在构造函数中,我使用该构建器通过tcp()方法创建到 PlaneFinder 的 RSocket 服务器的 TCP 连接(在本例中)。

注意

在我需要注入一个(RSocketRequester.Builder)用于创建一个不同对象(RSocketRequester)的 bean 时,我必须创建一个构造函数。现在我有了构造函数,我移除了类级别的@RequiredArgsConstructor和成员变量级别的@NonNull Lombok 注解,简单地将AircraftRepository添加到我编写的构造函数中。无论哪种方式,Spring Boot 都会自动装配该 bean,并将其分配给repository成员变量。

要验证 RSocket 连接是否正常工作并且数据正在流动,我创建了一个基于 HTTP 的端点 /acstream,指定它将作为结果返回一系列服务器发送事件(SSE),并使用 @ResponseBody 注解指示响应将直接包含 JSON 格式化的对象。使用在构造函数中初始化的 RSocketRequester 成员变量,我指定了要匹配 PlaneFinder 中定义的 RSocket 端点的 route,发送了一些 data(可选;在这个特定请求中我没有传递任何有用的数据),并检索从 PlaneFinder 返回的 AircraftFlux

import org.springframework.http.MediaType;
import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;

@Controller
public class PositionController {
    private final AircraftRepository repository;
    private final RSocketRequester requester;
    private WebClient client =
            WebClient.create("http://localhost:7634/aircraft");

    public PositionController(AircraftRepository repository,
                              RSocketRequester.Builder builder) {
        this.repository = repository;
        this.requester = builder.tcp("localhost", 7635);
    }

    // HTTP endpoint, HTTP requester (previously created)
    @GetMapping("/aircraft")
    public String getCurrentAircraftPositions(Model model) {
        Flux<Aircraft> aircraftFlux = repository.deleteAll()
                .thenMany(client.get()
                        .retrieve()
                        .bodyToFlux(Aircraft.class)
                        .filter(plane -> !plane.getReg().isEmpty())
                        .flatMap(repository::save));

        model.addAttribute("currentPositions", aircraftFlux);
        return "positions";
    }

    // HTTP endpoint, RSocket client endpoint
    @ResponseBody
    @GetMapping(value = "/acstream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<Aircraft> getCurrentACPositionsStream() {
        return requester.route("acstream")
                .data("Requesting aircraft positions")
                .retrieveFlux(Aircraft.class);
    }
}

要验证 RSocket 连接是否可行,并且 PlaneFinder 是否正在向 Aircraft Positions 应用程序提供数据,我启动了 Aircraft Positions 并返回到终端和 HTTPie,添加了 -S 标志到命令中,以在数据到达时将其作为流进行处理,而不是等待响应体完成。下面是结果的一个示例,由于篇幅限制已编辑:

mheckler-a01 :: ~ » http -S :8080/acstream
HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
transfer-encoding: chunked

data:{"id":1,"callsign":"RPA3427","squawk":"0526","reg":"N723YX","flightno":
"UA3427","route":"IAD-MCI","type":"E75L","category":"A3","altitude":36000,
"heading":290,"speed":403,"lat":39.183929,"lon":-90.72259,"barometer":0.0,
"vert_rate":64,"selected_altitude":0,"polar_distance":29.06486,
"polar_bearing":297.519943,"is_adsb":true,"is_on_ground":false,
"last_seen_time":"2020-09-20T23:58:51Z",
"pos_update_time":"2020-09-20T23:58:49Z","bds40_seen_time":null}

data:{"id":2,"callsign":"EDG76","squawk":"3354","reg":"N776RB","flightno":"",
"route":"TEB-VNY","type":"GLF5","category":"A3","altitude":43000,"heading":256,
"speed":419,"lat":38.884918,"lon":-90.363026,"barometer":0.0,"vert_rate":64,
"selected_altitude":0,"polar_distance":9.699159,"polar_bearing":244.237695,
"is_adsb":true,"is_on_ground":false,"last_seen_time":"2020-09-20T23:59:22Z",
"pos_update_time":"2020-09-20T23:59:14Z","bds40_seen_time":null}

data:{"id":3,"callsign":"EJM604","squawk":"3144","reg":"N604SD","flightno":"",
"route":"ENW-HOU","type":"C56X","category":"A2","altitude":38000,"heading":201,
"speed":387,"lat":38.627464,"lon":-90.01416,"barometer":0.0,"vert_rate":-64,
"selected_altitude":0,"polar_distance":20.898095,"polar_bearing":158.9935,
"is_adsb":true,"is_on_ground":false,"last_seen_time":"2020-09-20T23:59:19Z",
"pos_update_time":"2020-09-20T23:59:19Z","bds40_seen_time":null}

这证实了数据通过 RSocket 连接从 PlaneFinder 流向 Aircraft Positions,使用 request-stream 模型进行 Reactive Streams。一切正常。

代码检出检查

要获取完整的章节代码,请从代码存储库中检出 chapter8end 分支。

总结

响应式编程为开发人员提供了一种更好地利用资源的方式,在一个日益分布式的互联系统世界中,扩展可伸缩性的主要关键在于将扩展机制扩展到应用程序边界之外并进入通信渠道。响应式流倡议,特别是 Project Reactor,作为最大化系统范围可伸缩性的强大、高效和灵活的基础。

在本章中,我介绍了响应式编程,并演示了 Spring 如何引领众多工具和技术的发展和进步。我解释了阻塞和非阻塞通信以及提供这些功能的引擎,例如 Tomcat、Netty 等。

接下来,我演示了如何通过重构 PlaneFinder 和 Aircraft Positions 应用程序来使用 Spring WebFlux/Project Reactor 实现对 SQL 和 NoSQL 数据库的响应式数据库访问。Reactive Relational Database Connectivity (R2DBC) 提供了对 Java Persistence API (JPA) 的响应式替代,并与多个 SQL 数据库配合使用;MongoDB 和其他 NoSQL 数据库提供了与 Spring Data 和 Spring Boot 无缝配合的响应式驱动程序。

本章还讨论了响应式类型的前端集成选项,并演示了如果您的应用程序仍在使用生成的视图技术,则 Thymeleaf 提供了有限的迁移路径。未来的章节将考虑其他选项。

最后,我演示了如何通过 RSocket 将进程间通信提升到意想不到的新水平。通过 Spring Boot 的 RSocket 支持和自动配置,可以提供快速的性能、可伸缩性、弹性和开发者生产力的快捷路径。

在接下来的章节中,我将深入探讨测试:Spring Boot 如何实现更好、更快、更容易的测试实践,如何创建有效的单元测试,以及如何磨练和专注于测试以加快构建和测试周期。

第九章:测试 Spring Boot 应用程序以提高生产就绪性

本章讨论和演示了测试 Spring Boot 应用程序的核心方面。尽管测试的主题有许多方面,但我专注于测试 Spring Boot 应用程序的基本元素,这些元素显著提高了每个应用程序的生产就绪性。主题包括单元测试,使用 @SpringBootTest 编写有效单元测试的方法,以及使用 Spring Boot 测试切片来隔离测试对象并简化测试。

代码检出检查

请查看代码库中的分支 chapter9begin 以开始。

单元测试

单元测试作为其他类型应用程序测试的前导,是有充分理由的:单元测试使开发人员能够在开发+部署周期的最早阶段发现和修复错误,并因此以最低的成本修复它们。

简而言之,单元测试 包括验证一个定义的代码单元,尽可能和合理地隔离。随着大小和复杂性的增加,测试的结果数量呈指数增长;减少每个单元测试中的功能量使得每个测试更加可管理,从而增加考虑所有可能结果的可能性。

只有在成功且足够地实现了单元测试后,才应将集成测试、UI/UX 测试等加入混合中。幸运的是,Spring Boot 集成了简化和优化单元测试的功能,并默认在每个使用 Spring Initializr 构建的项目中包含这些功能,使得开发人员能够快速入门并“做正确的事情”。

引入 @SpringBootTest

到目前为止,我主要关注了使用 Spring Initializr 创建的项目中 src/main/java 下的代码,从主应用程序类开始。然而,在每个 Initializr 生成的 Spring Boot 应用程序中,还有一个相应的 src/test/java 目录结构,并且有一个预先创建的(但尚空)测试文件。

也命名为与主应用程序类相对应 - 例如,如果主应用程序类命名为 MyApplication,则主测试类将是 MyApplicationTest - 这种默认的 1:1 对应有助于组织和保持一致性。在测试类内部,Initializr 创建一个单一的测试方法,为空以提供清洁的起点,以便开发从干净的构建开始。您可以添加更多的测试方法,或者更通常地创建其他测试类以并行其他应用程序类,并在每个类中创建 1 个或多个测试方法。

通常情况下,我会鼓励测试驱动开发(TDD),即先编写测试,然后编写代码使测试通过。由于我坚信在介绍 Spring Boot 如何处理测试之前,先理解 Spring Boot 的关键方面非常重要,所以我相信读者会容许我在介绍本章材料之前延迟的做法。

考虑到这一点,让我们回到飞机位置应用程序并编写一些测试。

为了以最清晰、最简洁的方式展示 Spring Boot 提供的广泛的测试功能,我回到了使用 JPA 版本的 AircraftPositions,并将其作为本章测试重点的基础。还有一些其他与测试相关的主题,它们在本项目中没有被完全体现,但会在接下来的章节中进行介绍。

飞机位置应用的重要单元测试

在 AircraftPositions 中,目前只有一个类具有可以被视为有趣行为的类。PositionController公开了一个 API,直接或通过 Web 界面向最终用户提供当前飞机位置,并且该 API 可能执行包括以下操作的动作:

  • 从 PlaneFinder 获取当前飞机位置

  • 将位置存储在本地数据库中

  • 从本地数据库检索位置

  • 直接返回当前位置或通过将它们添加到文档的Model以供网页使用

暂且忽略该功能与外部服务交互的事实,它还触及从用户界面到数据存储和检索的应用程序堆栈的每一层。回顾一个良好的测试方法应该隔离和测试小而紧密功能块的原则,很明显,需要采取迭代的测试方法,从当前代码状态和没有测试的状态向最终优化应用程序组织和测试的状态迈进。这种方法准确反映了典型的面向生产的项目。

注意

由于正在使用的应用程序实际上从未真正“完成”,因此测试也永远不会“完成”。随着应用程序代码的演变,必须审查测试并可能进行修订、删除或添加,以保持测试效果。

我首先创建了一个与PositionController类相似的测试类。不同的 IDE 之间创建测试类的机制不同,当然也可以手动创建。由于我主要使用 IntelliJ IDEA 进行开发,我使用CMD+N键盘快捷键或右键单击,然后选择“Generate”打开 Generate 菜单,然后选择“Test…”选项来创建测试类。IntelliJ 随后显示如图 9-1 所示的弹出窗口。

sbur 0901

图 9-1. 从 PositionController 类发起的创建测试弹出窗口

创建测试弹出窗口中,我保留了默认的“测试库”选项设置为 JUnit 5。自从 Spring Boot 版本 2.2 正式发布以来,JUnit 版本 5 一直是 Spring Boot 应用程序单元测试的默认选项。还支持许多其他选项,包括 JUnit 3 和 4、Spock 和 TestNG 等,但是 JUnit 5 及其 Jupiter 引擎是一个强大的选项,提供了几种功能:

  • 更好地测试 Kotlin 代码(与以前的版本相比)

  • 为所有包含的测试进行一次性实例化/配置/清理测试类更加高效,使用@BeforeAll@AfterAll方法注解。

  • 支持 JUnit 4 和 5 测试(除非明确排除了 JUnit 4 的依赖项)

JUnit 5 的 Jupiter 引擎是默认的,提供了旧版引擎以向后兼容 JUnit 4 单元测试。

我保留了建议的类名PositionControllerTest,选中了生成setup/@BeforetearDown/@After方法的复选框,并选中了在 Figure 9-2 中显示的生成getCurrentAircraftPositions()方法的测试方法的复选框。

sbur 0902

图 9-2. 选择所需选项创建测试弹出窗口

一旦点击 OK 按钮,IntelliJ 将创建PositionControllerTest类,并打开 IDE,如下所示:

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

class PositionControllerTest {

    @BeforeEach
    void setUp() {
    }

    @AfterEach
    void tearDown() {
    }

    @Test
    void getCurrentAircraftPositions() {
    }
}

为了在事后尽快构建测试套件,我首先仅仅尽可能地复制了PositionController方法getCurrentAircraftPositions()的现有操作,其上下文与其已成功运行的相同(字面上的)上下文:Spring Boot ApplicationContext

我首先在类级别添加了@SpringBootTest注解。由于最初的目标是尽可能地重现应用程序执行时存在的行为,我指定了一个选项来启动一个嵌入式服务器,并让其监听一个随机端口。为了测试 Web API,我计划使用WebTestClient,它类似于应用程序中使用的WebClient,但专注于测试:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient

目前只有一个单元测试,还没有设置/拆卸所需的内容,我把注意力转向了getCurrentAircraftPositions()测试方法:

@Test
void getCurrentAircraftPositions(@Autowired WebTestClient client) {
    assert client.get()
            .uri("/aircraft")
            .exchange()
            .expectStatus().isOk()
            .expectBody(Iterable.class)
            .returnResult()
            .getResponseBody()
            .iterator()
            .hasNext();
}

需要注意的第一件事是,我为方法内部自动装配了一个WebTestClient bean。我所做的这点极少,仅需使用@AutoConfigureWebTestClient注解即可,该注解放置在类级别,指示 Spring Boot 创建并自动配置WebTestClient

作为@Test方法的全部内容是评估紧随其后的表达式的断言语句。对于这个测试的第一次迭代,我使用 Java 的assert来验证客户端操作链的最终结果是一个boolean true 值,因此测试通过。

表达式本身使用了注入的 WebTestClient bean,在 PositionControllergetCurrentAircraftPositions() 方法上发出 GET 请求到本地端点 /aircraft。一旦请求/响应交换完成,将检查 HTTP 状态码以确保响应是“OK”(200),验证响应体是否包含一个 Iterable,并获取响应。由于响应包含一个 Iterable,因此我使用 Iterator 来确定 Iterable 中是否至少包含一个值。如果是,测试通过。

警告

当前测试中至少有几个小的妥协。首先,按照当前的编写方式,如果供应飞机位置的外部服务(PlaneFinder)不可用,测试将失败,即使 AircraftPositions 中被测试的所有代码都是正确的。这意味着测试不仅仅是测试其目标功能,而是测试了更多内容。其次,由于我仅测试返回带有 1 个或多个元素的可迭代对象,并未检查元素本身,因此测试的范围有些有限。这意味着在 Iterable 中返回任何一种类型的元素,或者是带有无效值的有效元素,都将导致测试通过。我将在接下来的迭代中解决所有这些缺点。

执行测试会提供与图 9-3 类似的结果,表明测试已通过。

sbur 0903

图 9-3. 第一个测试通过

这是一个很好的开始,但是甚至这一个单一的测试也可以显著改进。在扩展我们的单元测试授权之前,让我们清理一下这个测试。

为了更好的测试重构

在绝大多数情况下,为了运行少量测试而加载带有嵌入式服务器和应用程序中所有功能的ApplicationContext是不合适的。正如前面提到的,单元测试应该专注于并在可能的范围内尽可能自包含。表面积越小,外部依赖越少,测试的目标性就越强。这种激光般的关注带来了几个好处,包括更少的被忽视的场景/结果,更高的测试特异性和严谨性,更可读因此更易理解的测试,以及同样重要的速度。

我之前提到过写低价值和无价值的测试是适得其反的,尽管这意味着什么是依赖于上下文的。然而,有一件事可能会阻止开发人员添加有用的测试,那就是执行测试套件所需的时间。一旦达到某个阈值,这种边界也与上下文有关,开发人员可能会因为增加已经显著的构建时间负担而犹豫不前。幸运的是,Spring Boot 有几种方法可以同时提高测试质量和减少测试执行时间。

如果不需要使用 WebClientWebTestClient 来满足 AircraftPosition API 的需求,下一个合乎逻辑的步骤可能是移除类级别 @SpringBootTest 注解中的 webEnvironment 参数。这将导致在 PositionControllerTest 类的测试中加载一个基本的 ApplicationContext,使用 MOCK web 环境,从而减少所需的占用空间和加载时间。由于 WebClient 是 API 的关键部分,因此 WebTestClient 成为测试它的最佳方式,我将用 @WebFluxTest 替换类级别的 @SpringBootTest@AutoConfigureWebTestClient 注解,以简化 ApplicationContext 的同时自动配置并提供 WebTestClient 访问:

@WebFluxTest({PositionController.class})

还有一点需要注意的是 @WebFluxTest 注解:除其他事项外,它还可以接受一个 controllers 参数,指向要为注解测试类实例化的 @Controller bean 类型数组。实际上可以省略 controllers = 部分,正如我所做的那样,只留下 @Controller 类型的数组,本例中仅有一个 PositionController

重新审视代码以隔离行为

正如前面提到的,PositionController 的代码涉及多次数据库调用,并直接使用 WebClient 访问外部服务。为了更好地隔离 API 和底层操作,使 mocking 更精细、更容易和更清晰,我重构了 PositionController,移除了直接定义和使用 WebClient 的部分,并将 getCurrentAircraftPositions() 方法的整体逻辑移到 PositionRetriever 类中,然后注入到并由 PositionController 使用:

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@AllArgsConstructor
@RestController
public class PositionController {
    private final PositionRetriever retriever;

    @GetMapping("/aircraft")
    public Iterable<Aircraft> getCurrentAircraftPositions() {
        return retriever.retrieveAircraftPositions();
    }
}

第一个可模拟版本的 PositionRetriever 主要由先前在 PositionController 中的代码组成。这一步的主要目标是便于模拟 retrieveAircraftPositions() 方法;通过将这段逻辑从 PositionControllergetCurrentAircraftPositions() 方法中移除,可以模拟上游调用而不是 web API,从而实现对 PositionController 的测试:

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@AllArgsConstructor
@Component
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebClient client =
            WebClient.create("http://localhost:7634");

    Iterable<Aircraft> retrieveAircraftPositions() {
        repository.deleteAll();

        client.get()
                .uri("/aircraft")
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(ac -> !ac.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        return repository.findAll();
    }
}

通过对代码进行这些更改,可以修订现有的测试,将飞机位置应用程序的功能与外部服务隔离开来,并专注于通过 mocking 访问 web API 所涉及的其他组件/功能,从而简化和加速测试执行。

完善测试

由于重点是测试 Web API,所以最好尽可能多地模拟非实际 Web 交互的逻辑。现在PositionController::getCurrentAircraftPositions调用PositionRetriever来在请求时提供当前飞机位置,因此PositionRetriever是要模拟的第一个组件。Mockito 的@MockBean注解——Mockito 已经自动包含在 Spring Boot 测试依赖中——用模拟的替身替换了通常在应用程序启动时创建的PositionRetriever bean,然后自动注入:

@MockBean
private PositionRetriever retriever;
注意

模拟的 bean 在每次执行测试方法后会自动重置。

然后我转向提供飞机位置的方法PositionRetriever::retrieveAircraftPositions。由于我现在注入了用于测试而不是真实对象的PositionRetriever模拟对象,因此我必须为retrieveAircraftPositions()方法提供一个实现,以便在被PositionController调用时以可预测且可测试的方式响应。

我创建了一对飞机位置,以用作PositionControllerTest类中测试的样本数据,并在setUp()方法中声明Aircraft变量并为其分配代表性值。

    private Aircraft ac1, ac2;

    @BeforeEach
    void setUp(ApplicationContext context) {
        // Spring Airlines flight 001 en route, flying STL to SFO,
        //   at 30000' currently over Kansas City
        ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
                "STL-SFO", "LJ", "ct",
                30000, 280, 440, 0, 0,
                39.2979849, -94.71921, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL,
        //   at 40000' currently over Denver
        ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8560963, -104.6759263, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());
    }
注意

在开发应用程序的实际操作中,检索的飞机位置数量几乎总是超过一个,通常远远超过一个。请记住,在测试中使用的样本数据集应至少返回两个位置。在后续迭代中,应考虑为类似生产应用程序的额外测试考虑涉及零、一个或非常大量位置的边缘情况。

现在回到retrieveAircraftPositions()方法。Mockito 的when...thenReturn组合在满足指定条件时返回指定的响应。现在已定义了示例数据,我可以提供条件和响应,以便调用PositionRetriever::retrieveAircraftPositions时返回:

@BeforeEach
void setUp(ApplicationContext context) {
    // Aircraft variable assignments omitted for brevity

    ...

    Mockito.when(retriever.retrieveAircraftPositions())
        .thenReturn(List.of(ac1, ac2));
}

有了相关的方法模拟后,现在是时候将注意力转回PositionControllerTest::getCurrentAircraftPositions中的单元测试。

由于我已指示测试实例加载了带有类级注释@WebFluxTest(controllers = {PositionController.class})PositionController bean,并创建了模拟的PositionRetriever bean 并定义了其行为,因此现在可以重构检索位置的测试部分,并对将返回的内容有一定的把握:

@Test
void getCurrentAircraftPositions(@Autowired WebTestClient client) {
    final Iterable<Aircraft> acPositions = client.get()
            .uri("/aircraft")
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(Aircraft.class)
            .returnResult()
            .getResponseBody();

    // Still need to compare with expected results
}

所示的操作链应检索由ac1ac2组成的List<Aircraft>。为了确认正确的结果,我需要将实际结果acPositions与预期结果进行比较。其中一种简单的比较方法是:

assertEquals(List.of(ac1, ac2), acPositions);

这将正确运行,并且测试将通过。在这个中间步骤中,我还可以通过将实际结果与通过对AircraftRepository进行模拟调用获得的结果进行比较,进一步推进事情。通过将以下代码添加到类、setUp()方法和getCurrentAircraftPositions()测试方法中,会产生类似(通过)的测试结果:

@MockBean
private AircraftRepository repository;

@BeforeEach
void setUp(ApplicationContext context) {
    // Existing setUp code omitted for brevity

    ...

    Mockito.when(repository.findAll()).thenReturn(List.of(ac1, ac2));
}

@Test
void getCurrentAircraftPositions(@Autowired WebTestClient client) {
    // client.get chain of operations omitted for brevity

    ...

    assertEquals(repository.findAll(), acPositions);
}
注意

这种变体也会导致通过的测试,但它在某种程度上违反了专注测试的原则,因为现在我将存储库测试的概念与测试 Web API 混合在一起。由于它实际上并没有使用CrudRepository::findAll方法而只是模拟了它,所以测试它并没有增加任何可识别的价值。但是,您可能在某些时候会遇到这类测试,所以我认为值得展示和讨论。

当前的PlaneControllerTest的工作版本现在应该如下所示:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.ApplicationContext;
import org.springframework.test.web.reactive.server.WebTestClient;

import java.time.Instant;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@WebFluxTest(controllers = {PositionController.class})
class PositionControllerTest {
    @MockBean
    private PositionRetriever retriever;

    private Aircraft ac1, ac2;

    @BeforeEach
    void setUp(ApplicationContext context) {
        // Spring Airlines flight 001 en route, flying STL to SFO,
        //    at 30000' currently over Kansas City
        ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
                "STL-SFO", "LJ", "ct",
                30000, 280, 440, 0, 0,
                39.2979849, -94.71921, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        // Spring Airlines flight 002 en route, flying SFO to STL,
        //    at 40000' currently over Denver
        ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
                "SFO-STL", "LJ", "ct",
                40000, 65, 440, 0, 0,
                39.8560963, -104.6759263, 0D, 0D, 0D,
                true, false,
                Instant.now(), Instant.now(), Instant.now());

        Mockito.when(retriever.retrieveAircraftPositions())
            .thenReturn(List.of(ac1, ac2));
    }

    @Test
    void getCurrentAircraftPositions(@Autowired WebTestClient client) {
        final Iterable<Aircraft> acPositions = client.get()
                .uri("/aircraft")
                .exchange()
                .expectStatus().isOk()
                .expectBodyList(Aircraft.class)
                .returnResult()
                .getResponseBody();

        assertEquals(List.of(ac1, ac2), acPositions);
    }
}

再次运行会产生一个通过的测试,并且结果与图 9-4 中显示的类似。

sbur 0904

图 9-4. AircraftRepository::getCurrentAircraftPositions 的新、改进的测试

随着满足应用程序/用户需求所需的 Web API 的扩展,应首先指定单元测试(在创建实现这些需求的实际代码之前),以确保正确的结果。

测试片段

我已经多次提到过专注测试的重要性,Spring 还有另一种机制可以帮助开发人员快速而轻松地完成这项工作:测试片段。

Spring Boot 测试依赖spring-boot-starter-test中内置了几个注解,自动配置这些功能片段。所有这些测试片段注解都以类似的方式工作,加载一个ApplicationContext和为指定的片段合理的选择组件。例如:

  • @JsonTest

  • @WebMvcTest

  • @WebFluxText(先前介绍过)

  • @DataJpaTest

  • @JdbcTest

  • @DataJdbcTest

  • @JooqTest

  • @DataMongoTest

  • @DataNeo4jTest

  • @DataRedisTest

  • @DataLdapTest

  • @RestClientTest

  • @AutoConfigureRestDocs

  • @WebServiceClientTest

在早期的一节中,利用@WebFluxTest来执行和验证 Web API,我提到了测试数据存储互动并将其从测试中排除,因为它专注于测试 Web 互动。为了更好地展示数据测试以及测试片段如何有助于针对特定功能,接下来我会进行探讨。

由于当前的 Aircraft Positions 使用 JPA 和 H2 来存储和检索当前位置,因此 @DataJpaTest 完全适用。我开始使用 IntelliJ IDEA 为测试创建一个新类,打开 AircraftRepository 类,并使用与之前相同的方法创建测试类:CMD+N,选择“Test…”,将 JUnit5 作为“Testing Library”,保留其他默认值,并选择 setUp/@BeforetearDown/@After 选项,如 图 9-5 所示。

sbur 0905

图 9-5. 为 AircraftRepository 创建测试弹出窗口
注意

由于 Spring Data Repository bean 通过自动配置向 Spring Boot 应用程序提供通用方法,因此不显示任何方法。下面,我将添加测试方法来演示这些方法的使用,如果您创建自定义 repository 方法,则也可以(并且应该)对其进行测试。

单击“OK”按钮生成测试类 AircraftRepositoryTest

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

class AircraftRepositoryTest {

    @BeforeEach
    void setUp() {
    }

    @AfterEach
    void tearDown() {
    }
}

首要任务当然是向 AircraftRepositoryTest 类添加测试切片注解 @DataJpaTest

@DataJpaTest
class AircraftRepositoryTest {

    ...

}

添加此单个注解后,执行测试将扫描 @Entity 类并配置 Spring Data JPA repository — 在 Aircraft Positions 应用程序中分别是 AircraftAircraftRepository。如果类路径中存在嵌入式数据库(如此处的 H2),测试引擎也会对其进行配置。通常不会扫描用 @Component 注解标记的类以进行 bean 创建。

为了测试实际的 repository 操作,repository 不能被模拟;由于 @DataJpaTest 注解加载和配置了一个 AircraftRepository bean,因此无需模拟它。我使用 @Autowire 注入 repository bean,并像之前的 PositionController 测试中一样,声明 Aircraft 变量最终作为测试数据:

@Autowired
private AircraftRepository repository;

private Aircraft ac1, ac2;

为了设置这个 AircraftRepositoryTest 类中将存在的测试的适当环境,我创建两个 Aircraft 对象,将每个分配给已声明的成员变量,并在 setUp() 方法中使用 Repository::saveAll 将它们保存到 repository 中。

@BeforeEach
void setUp() {
    // Spring Airlines flight 001 en route, flying STL to SFO,
    // at 30000' currently over Kansas City
    ac1 = new Aircraft(1L, "SAL001", "sqwk", "N12345", "SAL001",
            "STL-SFO", "LJ", "ct",
            30000, 280, 440, 0, 0,
            39.2979849, -94.71921, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    // Spring Airlines flight 002 en route, flying SFO to STL,
    // at 40000' currently over Denver
    ac2 = new Aircraft(2L, "SAL002", "sqwk", "N54321", "SAL002",
            "SFO-STL", "LJ", "ct",
            40000, 65, 440, 0, 0,
            39.8560963, -104.6759263, 0D, 0D, 0D,
            true, false,
            Instant.now(), Instant.now(), Instant.now());

    repository.saveAll(List.of(ac1, ac2));
}

接下来,我创建一个测试方法来验证在执行 AircraftRepository bean 上的 findAll() 后返回的结果确实是预期的内容:一个包含在测试的 setUp() 方法中保存的两个飞机位置的 Iterable<Aircraft>

@Test
void testFindAll() {
    assertEquals(List.of(ac1, ac2), repository.findAll());
}
注意

List 扩展 Collection,而 Collection 又扩展 Iterable

运行此测试将提供一个通过的结果,类似于在 图 9-6 中显示的内容。

sbur 0906

图 9-6. findAll() 的测试结果

类似地,我创建了一个测试AircraftRepository方法来查找特定 ID 字段的记录,即findById()。由于测试类的setUp()中调用了Repository::saveAll方法,所以应存储两条记录,我会查询这两条记录并与预期值进行验证。

@Test
void testFindById() {
    assertEquals(Optional.of(ac1), repository.findById(ac1.getId()));
    assertEquals(Optional.of(ac2), repository.findById(ac2.getId()));
}

运行testFindById()测试会显示通过,如图 9-7 所示。

sbur 0907

图 9-7. findById()的测试结果

最后,在所有测试运行完成后,需要进行一些清理工作。我在tearDown()方法中添加了一个语句,用于删除AircraftRepository中的所有记录:

@AfterEach
void tearDown() {
    repository.deleteAll();
}

请注意,在这种情况下,真的没有必要从存储库中删除所有记录,因为它是 H2 数据库的内存实例,在每次测试之前都会重新初始化。然而,这代表了通常会放置在测试类的tearDown()方法中的操作类型。

AircraftRepositoryTest中执行所有测试会产生类似于图 9-8 中显示的通过结果。

sbur 0908

图 9-8. AircraftRepositoryTest中所有测试的测试结果

应用程序在不断演化的过程中,测试永远不会完成。然而,对于目前在 Aircraft Positions 中存在的功能,本章节中编写的测试提供了一个良好的代码验证起点,并在应用程序功能增加时进行持续扩展。

代码检出检查

若要获取完整的章节代码,请查看代码库中的chapter9end分支。

总结

本章讨论并演示了测试 Spring Boot 应用程序的核心方面,重点是测试 Spring Boot 应用程序的基本方面,这些方面最大程度地提高了每个应用程序的生产准备就绪性。涵盖的主题包括单元测试,使用@SpringBootTest进行整体应用程序测试,如何使用 JUnit 编写有效的单元测试以及使用 Spring Boot 测试片段来隔离测试主题并简化测试。

下一章将探讨诸如认证和授权等安全概念。然后,我将演示如何为自包含应用程序实现基于表单的身份验证,以及如何利用 OpenID Connect 和 OAuth2 来实现最大安全性和灵活性,所有这些都将使用 Spring Security。

第十章:保护您的 Spring Boot 应用程序

理解认证和授权的概念对构建安全应用程序至关重要,为用户验证和访问控制提供基础。Spring Security 结合认证和授权的选项与 HTTP 防火墙、过滤器链、广泛使用的 IETF 和万维网联盟(W3C)标准及用于交换的选项等其他机制,帮助锁定应用程序。采用安全的开箱即用思维方式,Spring Security 利用 Boot 强大的自动配置来评估开发者的输入和可用的依赖关系,以在最小的努力下为 Spring Boot 应用程序提供最大的安全性。

本章介绍并解释了安全的核心方面以及它们如何适用于应用程序。我演示了多种将 Spring Security 集成到 Spring Boot 应用程序中以增强应用程序安全姿态的方法,弥补了覆盖中的危险漏洞并减少了攻击面积。

代码结帐检查

请从代码库中检出分支 chapter10begin 开始。

认证和授权

经常一起使用,认证授权 这两个术语相关但又是独立的关注点。

认证

表示、展示或证实某事物(如身份、艺术品或金融交易)的真实性、真实性或真实性的行为、过程或方法;验证某事物的真实性的行为或过程。

授权

1: 授权 的行为 2: 授权的工具:批准

授权 的第一个定义指向 授权 以获取更多信息:

授权

1: 通过或像通过某种公认或适当的权威(如习惯、证据、个人权利或监管权力)背书、授权、证明或允许的习惯由时间授权的 2: 尤其是具有法律权威的投资 3: 古老:证明

授权 的定义反过来指向 证明 以获取更多信息。

尽管有些有趣,但这些定义并不十分清晰。有时,词典定义可能没有我们期望的那么有帮助。我自己的定义如下。

认证

证明某人是他们所声称的人

授权

验证某人是否有权访问特定资源或操作

认证

简单来说,认证 是证明某人(或某物)是其所声称的人(或物,如设备、应用程序或服务)。

认证的概念在物理世界中有几个具体的例子。如果您曾经需要展示像员工工牌、驾驶执照或护照等身份证明来证明您的身份,那么您已经进行了认证。证明某人是其声称的人是一个我们在多种情况下都习以为常的程序,而在物理级别和应用程序级别的认证概念差异微不足道。

身份验证通常涉及以下一项或多项:

  • 你所是的东西

  • 你所知道的东西

  • 你所拥有的东西

注意

这三个因素可以单独使用,也可以组合起来构成多因素身份验证(MFA)。

身份验证在物理世界和虚拟世界中发生的方式当然是不同的。与物理世界中经常发生的人类注视照片 ID 并将其与您当前的外貌进行比较不同,身份验证到应用程序通常涉及键入密码,插入安全密钥或提供生物特征数据(虹膜扫描,指纹等)。这些数据可以更容易地由软件进行评估,而不是与照片的外观进行比较,目前比较难以实现。尽管如此,两种情况下都会对提供的数据进行比较,并且匹配会提供积极的身份验证。

授权

一旦一个人通过身份验证,他们就有可能获得一个或多个个人可以使用的资源和/或允许执行的操作。

注意

在这种情况下,一个人可能是(而且很可能是)一个人类,但是对于应用程序,服务,设备等,根据上下文,相同的概念和访问考虑都适用。

一旦个人的身份得到证明,该个人就会获得对应用程序的一般级别的访问权限。从那里,现在已验证的应用程序用户可以请求访问某些内容。然后,应用程序必须以某种方式确定用户是否被允许访问该资源,即授权。如果是这样,则授予用户访问权限;如果不是,则通知用户,他们缺乏权限导致其请求被拒绝。

Spring Security 简介

除了提供可靠的身份验证和授权选项外,Spring Security 还提供了其他几种机制,帮助开发人员确保其 Spring Boot 应用程序的安全性。由于自动配置,Spring Boot 应用程序根据提供的信息启用每个适用的 Spring Security 功能,甚至由于缺乏更具体的指导。安全功能当然可以根据开发人员的需要进行调整或放宽,以适应其组织的具体要求。

Spring Security 的功能远远超出了本章详尽介绍的范围,但有三个关键功能对理解 Spring Security 模型及其基础至关重要。它们是 HTTP 防火墙,安全过滤器链以及 Spring Security 对 IETF 和 W3C 标准的广泛使用以及对请求和相应的选项。

HTTP 防火墙

虽然确切的数字很难获得,但许多安全妥协始于使用格式不正确的 URI 进行请求,以及系统对其的意外响应。这实际上是应用程序的第一道防线,因此在考虑进一步努力保护应用程序之前,应首先解决这个问题。

自 5.0 版本起,Spring Security 已经包含了一个内置的 HTTP 防火墙,用于审查所有入站请求的问题格式。如果请求存在任何问题,如不良的头部值或格式不正确,则请求将被丢弃。除非开发者进行了覆盖,否则默认实现使用的是名为StrictHttpFirewall的适当命名的实现,快速关闭应用程序安全配置中的第一个且可能最容易被利用的漏洞。

安全过滤器链

Spring Security 提供了一个更为具体、更高级别的入站请求过滤器链,用于处理成功通过 HTTP 防火墙的正常形式的请求。

简而言之,对于大多数应用程序,开发者通过指定一系列过滤器条件,使得入站请求通过这些条件直到匹配一个过滤器。当请求与过滤器匹配时,将评估其相应的条件,以确定是否满足请求。例如,如果到达特定 API 端点的请求与过滤器链中的某个条件匹配,则将检查发出请求的用户是否具有访问所请求资源的适当角色/权限。如果是,则处理请求;如果不是,则通常使用 403 Forbidden 状态码拒绝请求。

如果一个请求通过链中定义的所有过滤器而不匹配任何过滤器,则该请求将被丢弃。

请求和响应头部

IETF 和 W3C 创建了多个基于 HTTP 的交换规范和标准,其中几个与信息安全的安全交换相关。这些头部用于请求或指示特定行为,并定义了允许的值和行为响应。Spring Security 广泛使用这些头部详情来增强您的 Spring Boot 应用程序的安全性姿态。

了解到不同的用户代理可能支持这些标准和规范的一些或全部,Spring Security 通过检查所有已知的头部选项并在请求中查找它们,在响应中适用时提供它们,采用了尽可能覆盖的最佳实践方法。

使用 Spring Security 实现基于表单的身份验证和授权

每天都有无数使用“something you know”身份验证方法的应用程序被使用。无论是用于组织内部的应用程序,直接通过互联网提供给消费者的 Web 应用程序,还是移动设备本地的应用程序,输入用户 ID 和密码对开发者和非开发者来说都是熟悉的例行公事。在大多数情况下,这种提供的安全性已经足以完成手头的任务。

Spring Security 为 Spring Boot 应用程序提供了出色的开箱即用(OOTB)支持,通过自动配置和易于理解的抽象来进行密码验证。本节演示了通过重构Aircraft Positions应用程序以使用 Spring Security 实现基于表单的身份验证的各种起始点。

添加 Spring Security 依赖项

在创建新的 Spring Boot 项目时,通过 Spring Initializr 添加一个更多的依赖项,即Spring Security,可以在不对新应用程序进行额外配置的情况下提供顶级安全性,如图 10-1 所示。

sbur 1001

图 10-1. Spring Initializr 中的 Spring Security 依赖项

更新现有应用程序稍微复杂一点。我将在Aircraft Positionspom.xml Maven 构建文件中添加与 Initializr 添加的两个互补依赖项,一个是用于 Spring Security 本身,另一个用于测试它:

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

将 Spring Security 添加到类路径中并且没有代码或配置更改应用时,我重新启动Aircraft Positions进行快速功能检查。这提供了一个很好的机会,了解 Spring Security 在开发者方面所做的工作。

运行PlaneFinderAircraft Positions两个应用后,我返回终端,并再次调用Aircraft Positions的*/aircraft*端点,如下所示:

mheckler-a01 :: ~ » http :8080/aircraft
HTTP/1.1 401
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=347DD039FE008DE50F457B890F2149C0; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

{
    "error": "Unauthorized",
    "message": "",
    "path": "/aircraft",
    "status": 401,
    "timestamp": "2020-10-10T17:26:31.599+00:00"
}
注:

为了清晰起见,已删除了一些响应头。

正如你所见,我无法再访问*/aircraft端点,因为我的请求收到了401 Unauthorized的响应。由于/aircraft*端点目前是从Aircraft Positions应用程序访问信息的唯一途径,这有效地意味着该应用程序已经完全保护免受未经授权的访问。这是一个好消息,但重要的是要理解这是如何发生的,以及如何为合法用户恢复所需的访问权限。

正如我之前提到的,Spring Security 采用“默认安全”的思路,在开发者使用它在 Spring Boot 应用程序中的每个级别配置甚至零配置时尽可能安全。当 Spring Boot 在类路径中找到 Spring Security 时,安全性将使用合理的默认值进行配置。即使没有定义任何用户或指定任何密码或开发者未作出任何其他努力,项目中包含 Spring Security 都表明其目标是创建一个安全的应用程序。

可以想象,这些信息非常少。但是,Spring Boot+Security 自动配置创建了一些基本安全功能的关键 Bean,基于表单认证和使用用户 ID 和密码进行用户授权。从这个逻辑假设合理地得出的下一个问题是:使用什么用户?什么密码?

返回到Aircraft Positions应用程序的启动日志,可以在以下行找到其中一个问题的答案:

Using generated security password: 1ad8a0fc-1a0c-429e-8ed7-ba0e3c3649ef

如果在应用程序中未指定用户 ID 和密码,也未提供其他访问方式,则启用安全性的 Spring Boot 应用程序将默认使用一个名为user的单一用户帐户,并在每次应用程序启动时生成一个新的唯一密码。回到终端窗口,我尝试再次访问应用程序,这次使用提供的凭据:

mheckler-a01 :: ~ » http :8080/aircraft
    --auth user:1ad8a0fc-1a0c-429e-8ed7-ba0e3c3649ef
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
Set-Cookie: JSESSIONID=94B52FD39656A17A015BC64CF6BF7475; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 40000,
        "barometer": 1013.6,
        "bds40_seen_time": "2020-10-10T17:48:02Z",
        "callsign": "SWA2057",
        "category": "A3",
        "flightno": "WN2057",
        "heading": 243,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:48:06Z",
        "lat": 38.600372,
        "lon": -90.42375,
        "polar_bearing": 207.896382,
        "polar_distance": 24.140226,
        "pos_update_time": "2020-10-10T17:48:06Z",
        "reg": "N557WN",
        "route": "IND-DAL-MCO",
        "selected_altitude": 40000,
        "speed": 395,
        "squawk": "2161",
        "type": "B737",
        "vert_rate": -64
    },
    {
        "altitude": 3500,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": "N6884J",
        "category": "A1",
        "flightno": "",
        "heading": 353,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:47:45Z",
        "lat": 39.062851,
        "lon": -90.084965,
        "polar_bearing": 32.218696,
        "polar_distance": 7.816637,
        "pos_update_time": "2020-10-10T17:47:45Z",
        "reg": "N6884J",
        "route": "",
        "selected_altitude": 0,
        "speed": 111,
        "squawk": "1200",
        "type": "P28A",
        "vert_rate": -64
    },
    {
        "altitude": 39000,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": "ATN3425",
        "category": "A5",
        "flightno": "",
        "heading": 53,
        "id": 3,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:48:06Z",
        "lat": 39.424159,
        "lon": -90.419739,
        "polar_bearing": 337.033437,
        "polar_distance": 30.505314,
        "pos_update_time": "2020-10-10T17:48:06Z",
        "reg": "N419AZ",
        "route": "AFW-ABE",
        "selected_altitude": 0,
        "speed": 524,
        "squawk": "2224",
        "type": "B763",
        "vert_rate": 0
    },
    {
        "altitude": 45000,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-10-10T17:48:06Z",
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 91,
        "id": 4,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-10T17:48:06Z",
        "lat": 39.433982,
        "lon": -90.50061,
        "polar_bearing": 331.287125,
        "polar_distance": 32.622134,
        "pos_update_time": "2020-10-10T17:48:05Z",
        "reg": "N30GD",
        "route": "",
        "selected_altitude": 44992,
        "speed": 521,
        "squawk": null,
        "type": "GLF4",
        "vert_rate": 64
    }
]
注意

如前所述,为了清晰起见,已删除了一些响应标头。

使用正确的默认用户 ID 和生成的密码,我收到了200 OK的响应,并再次可以访问*/aircraft*端点,从而访问了Aircraft Positions应用程序。

回到Aircraft Positions应用程序,当前应用程序安全状态存在几个问题。首先,只有一个定义的用户,需要访问该应用程序的多个人员必须全部使用该单一帐户。这与安全责任和甚至身份验证的原则背道而驰,因为没有单一的个体可以唯一证明他们是谁。再谈责任问题,如果发生漏洞,如何确定是谁造成或贡献了漏洞?更不用说,如果发生漏洞,锁定唯一用户帐户将禁止所有用户访问;目前没有办法避免这种情况。

现有安全配置的次要问题是如何处理单一密码。每次应用程序启动时,都会自动生成新密码,然后必须与所有用户共享。虽然尚未讨论应用程序的扩展性,但每个启动的Aircraft Positions实例将生成一个唯一密码,需要用户输入该特定应用程序实例的密码。显然可以并且应该做出一些改进。

添加认证

Spring Security 使用UserDetailsService的概念作为其认证能力的核心。UserDetailsService是一个接口,具有一个loadUserByUsername(String username)方法(在实现时)返回一个满足UserDetails接口的对象,从中可以获取关键信息,如用户的名称、密码、授予用户的权限和账户状态。这种灵活性允许使用各种技术进行多种实现;只要UserDetailsService返回UserDetails,应用程序就不需要知道底层实现细节。

要创建一个UserDetailsService bean,我创建一个配置类,在其中定义一个 bean 创建方法。

首先,我创建一个名为SecurityConfig的类,并使用@Configuration进行注解,以便 Spring Boot 能够找到并执行其中的 bean 创建方法。用于身份验证的 bean 是实现UserDetailsService接口的 bean,因此我创建一个名为authentication()的方法来创建并返回该 bean。这是第一次,有意不完整的代码:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig {
    @Bean
    UserDetailsService authentication() {
        UserDetails peter = User.builder()
                .username("peter")
                .password("ppassword")
                .roles("USER")
                .build();

        UserDetails jodie = User.builder()
                .username("jodie")
                .password("jpassword")
                .roles("USER", "ADMIN")
                .build();

        System.out.println("   >>> Peter's password: " + peter.getPassword());
        System.out.println("   >>> Jodie's password: " + jodie.getPassword());

        return new InMemoryUserDetailsManager(peter, jodie);
    }
}

UserDetailServiceauthentication()方法中,我使用User类的builder()方法创建了两个实现UserDetails接口要求的应用对象,并指定了用户名、密码和用户拥有的角色/权限。然后我使用build()方法构建这些用户,并将每个用户分配给一个局部变量。

接下来,我仅仅为演示目的显示密码。这有助于展示本章中的另一个概念,但仅供演示目的

警告

记录密码是一种最坏的反模式。永远不要在生产应用程序中记录密码。

最后,我创建了一个InMemoryUserDetailsManager,使用这两个创建的User对象,并将其作为 Spring bean 返回。InMemoryUserDetailsManager实现了UserDetailsManagerUserDetailsPasswordService接口,使得可以进行用户管理任务,如确定特定用户是否存在、创建、更新和删除用户,以及更改/更新用户的密码。我使用InMemoryUserDetailsManager是为了在演示概念时更加清晰(因为没有外部依赖),但任何实现UserDetailsService接口的 bean 都可以作为认证 bean 提供。

重新启动Aircraft Positions,我尝试进行身份验证,并检索当前飞机位置的列表,结果如下(为简洁起见,删除了一些标题):

mheckler-a01 :: ~ » http :8080/aircraft --auth jodie:jpassword
HTTP/1.1 401
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Length: 0
Expires: 0
Pragma: no-cache
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

这促使一些故障排除。返回到 IDE,堆栈跟踪中有一些有用的信息:

java.lang.IllegalArgumentException: There is no PasswordEncoder
    mapped for the id "null"
	at org.springframework.security.crypto.password
        .DelegatingPasswordEncoder$UnmappedIdPasswordEncoder
            .matches(DelegatingPasswordEncoder.java:250)
                ~[spring-security-core-5.3.4.RELEASE.jar:5.3.4.RELEASE]

这为问题的根源提供了一个提示。检查记录的密码(友情提醒:记录密码仅供演示目的)得到了确认:

>>> Peter's password: ppassword
>>> Jodie's password: jpassword

显然 这些密码是明文的,没有进行任何编码。实现工作和安全的身份验证的下一步是在 SecurityConfig 类中添加一个密码编码器,如下所示:

private final PasswordEncoder pwEncoder =
        PasswordEncoderFactories.createDelegatingPasswordEncoder();

创建和维护安全应用程序的一个挑战在于,安全性必须不断进化。Spring Security 正是出于这个必要性,不仅仅有一个指定的编码器可供插入;它使用一个具有多个可用编码器的工厂,并委托其中一个进行编码和解码任务。

当然,这意味着在前面的示例中,如果没有指定编码器,则必须作为默认值服务。目前 BCrypt 是(非常好的)默认值,但是 Spring Security 编码器架构的灵活委托性质使得在标准演变和/或需求变化时可以轻松地将一个编码器替换为另一个。这种方法的优雅性允许在应用程序用户登录时轻松地将凭据从一个编码器迁移到另一个编码器,从而再次减少了一些虽然对组织至关重要但并不直接提供价值的任务。

现在我已经放置了一个编码器,下一步是使用它来加密用户密码。这可以通过简单地调用密码编码器的 encode() 方法,并传递明文密码来完成,然后收到加密结果。

提示

严格来说,加密一个值也会对该值进行编码,但并非所有编码器都会加密。例如,哈希编码一个值但不一定加密它。也就是说,Spring Security 支持的每种编码算法也都会进行加密;然而,为了支持旧的应用程序,某些支持的算法远不如其他算法安全。始终选择当前推荐的 Spring Security 编码器或选择由 PasswordEncoderFactories.createDelegatingPasswordEncoder() 提供的默认编码器。

经过修订的 SecurityConfig 类的身份验证版本如下所示:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig {
    private final PasswordEncoder pwEncoder =
            PasswordEncoderFactories.createDelegatingPasswordEncoder();

    @Bean
    UserDetailsService authentication() {
        UserDetails peter = User.builder()
                .username("peter")
                .password(pwEncoder.encode("ppassword"))
                .roles("USER")
                .build();

        UserDetails jodie = User.builder()
                .username("jodie")
                .password(pwEncoder.encode("jpassword"))
                .roles("USER", "ADMIN")
                .build();

        System.out.println("   >>> Peter's password: " + peter.getPassword());
        System.out.println("   >>> Jodie's password: " + jodie.getPassword());

        return new InMemoryUserDetailsManager(peter, jodie);
    }
}

我重新启动 Aircraft Positions,然后再次尝试进行身份验证并检索当前飞机位置列表,结果如下(为简洁起见,某些标题和结果已删除):

mheckler-a01 :: ~ » http :8080/aircraft --auth jodie:jpassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 24250,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 118,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T16:13:26Z",
        "lat": 38.325119,
        "lon": -90.154159,
        "polar_bearing": 178.56009,
        "polar_distance": 37.661127,
        "pos_update_time": "2020-10-12T16:13:24Z",
        "reg": "N168ZZ",
        "route": "FMY-SUS",
        "selected_altitude": 0,
        "speed": 404,
        "squawk": null,
        "type": "LJ60",
        "vert_rate": 2880
    }
]

这些结果证实了认证已成功(由于空间限制,故意使用不正确的密码进行失败的场景被省略),有效用户可以再次访问暴露的 API。

回顾并且现在查看已编码的密码,我注意到在 IDE 输出中类似以下值:

>>> Peter's password:
    {bcrypt}$2a$10$rLKBzRBvtTtNcV9o8JHzFeaIskJIPXnYgVtCPs5H0GINZtk1WzsBu
>>> Jodie's password: {
    bcrypt}$2a$10$VR33/dlbSsEPPq6nlpnE/.ZQt0M4.bjvO5UYmw0ZW1aptO4G8dEkW

登录的值确认了代码中指定的两个示例密码已经由委托密码编码器成功编码,使用 BCrypt

授权

现在,Aircraft Positions应用程序成功地认证用户,并仅允许这些用户访问其暴露的 API。然而,当前安全配置存在一个相当大的问题:访问 API 的任何部分都意味着可以访问所有部分,无论用户拥有的角色/权限,或者更确切地说,无论用户拥有的角色。

作为这个安全漏洞的一个非常简单的例子,我在Aircraft Position的 API 中添加了另一个端点,通过克隆、重命名和重新映射PositionController类中现有的getCurrentAircraftPositions()方法作为第二个端点。完成后,PositionController如下所示:

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@AllArgsConstructor
@RestController
public class PositionController {
    private final PositionRetriever retriever;

    @GetMapping("/aircraft")
    public Iterable<Aircraft> getCurrentAircraftPositions() {
        return retriever.retrieveAircraftPositions();
    }

    @GetMapping("/aircraftadmin")
    public Iterable<Aircraft> getCurrentAircraftPositionsAdminPrivs() {
        return retriever.retrieveAircraftPositions();
    }
}

目标是只允许具有“ADMIN”角色的用户访问第二个方法getCurrentAircraftPositionsAdminPrivs()。虽然在这个例子的当前版本中,返回的值与getCurrentAircraftPositions()返回的值相同,但随着应用程序的扩展,这种情况可能不会持续,这个概念仍然适用。

重新启动Aircraft Positions应用程序并返回命令行,我首先以用户 Jodie 的身份登录,以验证对新端点的访问,预期的访问已确认(由于空间限制,省略了第一个端点的访问确认以及部分标题和结果)。

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth jodie:jpassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 24250,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 118,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T16:13:26Z",
        "lat": 38.325119,
        "lon": -90.154159,
        "polar_bearing": 178.56009,
        "polar_distance": 37.661127,
        "pos_update_time": "2020-10-12T16:13:24Z",
        "reg": "N168ZZ",
        "route": "FMY-SUS",
        "selected_altitude": 0,
        "speed": 404,
        "squawk": null,
        "type": "LJ60",
        "vert_rate": 2880
    },
    {
        "altitude": 38000,
        "barometer": 1013.6,
        "bds40_seen_time": "2020-10-12T20:24:48Z",
        "callsign": "SWA1828",
        "category": "A3",
        "flightno": "WN1828",
        "heading": 274,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T20:24:48Z",
        "lat": 39.348862,
        "lon": -90.751668,
        "polar_bearing": 310.510201,
        "polar_distance": 35.870036,
        "pos_update_time": "2020-10-12T20:24:48Z",
        "reg": "N8567Z",
        "route": "TPA-BWI-OAK",
        "selected_altitude": 38016,
        "speed": 397,
        "squawk": "7050",
        "type": "B738",
        "vert_rate": -128
    }
]

接下来,我以 Peter 的身份登录。Peter 不应该能够访问映射到*/aircraftadmin*的getCurrentAircraftPositionsAdminPrivs()方法。但情况并非如此;目前,作为经过身份验证的用户,Peter 可以访问一切:

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth peter:ppassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 24250,
        "barometer": 0.0,
        "bds40_seen_time": null,
        "callsign": null,
        "category": "A2",
        "flightno": "",
        "heading": 118,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T16:13:26Z",
        "lat": 38.325119,
        "lon": -90.154159,
        "polar_bearing": 178.56009,
        "polar_distance": 37.661127,
        "pos_update_time": "2020-10-12T16:13:24Z",
        "reg": "N168ZZ",
        "route": "FMY-SUS",
        "selected_altitude": 0,
        "speed": 404,
        "squawk": null,
        "type": "LJ60",
        "vert_rate": 2880
    },
    {
        "altitude": 38000,
        "barometer": 1013.6,
        "bds40_seen_time": "2020-10-12T20:24:48Z",
        "callsign": "SWA1828",
        "category": "A3",
        "flightno": "WN1828",
        "heading": 274,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-12T20:24:48Z",
        "lat": 39.348862,
        "lon": -90.751668,
        "polar_bearing": 310.510201,
        "polar_distance": 35.870036,
        "pos_update_time": "2020-10-12T20:24:48Z",
        "reg": "N8567Z",
        "route": "TPA-BWI-OAK",
        "selected_altitude": 38016,
        "speed": 397,
        "squawk": "7050",
        "type": "B738",
        "vert_rate": -128
    }
]

为了让Aircraft Positions应用程序不仅能够简单地认证用户,还能检查用户是否有权限访问特定资源,我重构了SecurityConfig来执行这项任务。

第一步是用@EnableWebSecurity替换类级注解@Configuration@EnableWebSecurity是一个元注解,包含了被移除的@Configuration,仍然允许在注解类中创建 bean 方法;但它还包括@EnableGlobalAuthentication注解,允许 Spring Boot 为应用程序自动配置更多安全性。这为Aircraft Positions应用程序为定义授权机制本身做好了准备。

我重构了SecurityConfig类,使其扩展WebSecurityConfigurerAdapter,这是一个抽象类,具有许多对扩展应用程序 Web 安全性基本配置有用的成员变量和方法。特别是,WebSecurityConfigurerAdapter有一个configure(HttpSecurity http)方法,为用户授权提供了基本实现:

protected void configure(HttpSecurity http) throws Exception {
    // Logging statement omitted

    http
        .authorizeRequests()
            .anyRequest().authenticated()
            .and()
        .formLogin().and()
        .httpBasic();
}

在前述实现中,发出了以下指令:

  • 授权任何经过身份验证的用户的请求。

  • 提供简单的登录和注销表单(开发者可以覆盖的表单)。

  • 针对非浏览器用户代理(例如命令行工具)启用了 HTTP 基本认证。

如果开发人员未提供授权详细信息,则此方法提供了合理的安全姿态。下一步是提供更具体的信息,从而覆盖此行为。

我使用 IntelliJ for Mac 的CTRL+O键盘快捷键或单击右鼠标按钮,然后生成以打开生成菜单,然后选择“Override methods…”选项来显示可重写/可实现的方法。选择具有签名configure(http:HttpSecurity):void的方法将生成以下方法:

@Override
protected void configure(HttpSecurity http) throws Exception {
    super.configure(http);
}

然后,我用以下代码替换了对超类方法的调用:

// User authorization
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .mvcMatchers("/aircraftadmin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .and()
            .httpBasic();
}

configure(HttpSecurity http)方法的实现执行以下操作:

  • 使用String模式匹配器,将请求路径与*/aircraftadmin*及其以下所有路径进行比较。

  • 如果匹配成功,且用户具有“ADMIN”角色/权限,则授权用户发出请求。

  • 对于任何已认证用户,均可完成其他请求

  • 提供简单的登录和注销表单(由开发人员创建的可重写表单)。

  • 启用非浏览器用户代理(命令行工具等)的 HTTP 基本身份验证。

这种最小授权机制将两个过滤器放置在安全过滤器链中:一个用于检查路径匹配和管理员权限,另一个用于所有其他路径和已认证用户。分层方法允许捕获相当简单、易于理解的复杂场景逻辑。

(用于基于表单的安全性的)SecurityConfig类的最终版本如下:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration
    .EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration
    .WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final PasswordEncoder pwEncoder =
            PasswordEncoderFactories.createDelegatingPasswordEncoder();

    @Bean
    UserDetailsService authentication() {
        UserDetails peter = User.builder()
                .username("peter")
                .password(pwEncoder.encode("ppassword"))
                .roles("USER")
                .build();

        UserDetails jodie = User.builder()
                .username("jodie")
                .password(pwEncoder.encode("jpassword"))
                .roles("USER", "ADMIN")
                .build();

        System.out.println("   >>> Peter's password: " + peter.getPassword());
        System.out.println("   >>> Jodie's password: " + jodie.getPassword());

        return new InMemoryUserDetailsManager(peter, jodie);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/aircraftadmin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .httpBasic();
    }
}

现在确认所有操作按预期进行。我重新启动Aircraft Positions应用程序,并作为 Jodie 从命令行访问*/aircraftadmin*端点(由于篇幅原因省略了第一个端点访问确认;部分标题和结果也因简洁起见而省略):

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth jodie:jpassword
HTTP/1.1 200
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

[
    {
        "altitude": 36000,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-10-13T19:16:10Z",
        "callsign": "UPS2806",
        "category": "A5",
        "flightno": "5X2806",
        "heading": 289,
        "id": 1,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-13T19:16:14Z",
        "lat": 38.791122,
        "lon": -90.21286,
        "polar_bearing": 189.515723,
        "polar_distance": 9.855602,
        "pos_update_time": "2020-10-13T19:16:12Z",
        "reg": "N331UP",
        "route": "SDF-DEN",
        "selected_altitude": 36000,
        "speed": 374,
        "squawk": "6652",
        "type": "B763",
        "vert_rate": 0
    },
    {
        "altitude": 25100,
        "barometer": 1012.8,
        "bds40_seen_time": "2020-10-13T19:16:13Z",
        "callsign": "ASH5937",
        "category": "A3",
        "flightno": "AA5937",
        "heading": 44,
        "id": 2,
        "is_adsb": true,
        "is_on_ground": false,
        "last_seen_time": "2020-10-13T19:16:13Z",
        "lat": 39.564148,
        "lon": -90.102459,
        "polar_bearing": 5.201331,
        "polar_distance": 36.841422,
        "pos_update_time": "2020-10-13T19:16:13Z",
        "reg": "N905J",
        "route": "DFW-BMI-DFW",
        "selected_altitude": 11008,
        "speed": 476,
        "squawk": "6270",
        "type": "CRJ9",
        "vert_rate": -2624
    }
]

由于具有“ADMIN”角色,Jodie 可以按预期访问*/aircraftadmin*端点。接下来,我尝试使用 Peter 的登录。请注意,由于篇幅原因省略了第一个端点访问确认;为简洁起见,某些标题也已省略:

mheckler-a01 :: ~ » http :8080/aircraftadmin --auth peter:ppassword
HTTP/1.1 403
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Expires: 0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block

{
    "error": "Forbidden",
    "message": "",
    "path": "/aircraftadmin",
    "status": 403,
    "timestamp": "2020-10-13T19:18:10.961+00:00"
}

这正是应该发生的事情,因为 Peter 只有“USER”角色,而不是“ADMIN”。系统正常运行。

代码检出检查

请从代码仓库的分支chapter10forms检出一个完整的基于表单的示例。

实施 OpenID Connect 和 OAuth2 进行身份验证和授权

尽管基于表单的身份验证和内部授权对于许多应用程序非常有用,但在许多情况下,“您所知道的”身份验证方法可能不够理想,甚至不足以实现所需的安全级别。一些例子包括但不限于以下情况:

  • 需要身份验证但不需要了解用户任何信息(或因法律或其他原因不想了解用户信息的)的免费服务

  • 在不认为单因素身份验证足够安全的情况下,需要和/或需要多因素身份验证(MFA)支持的情况

  • 关于创建和维护用于管理密码、角色/权限和其他必要机制的安全软件基础设施的关注

  • 对于妥协事件的责任问题的关注

对于这些问题或目标,没有简单的答案,但是一些公司已经构建并维护了强大且安全的基础设施资产,用于验证用户和验证权限,并提供给广大用户以低廉或零成本的一般使用。像 Okta 这样的领先安全供应商以及其他需要经过验证用户和权限验证的企业:Facebook、GitHub 和 Google 等等。Spring Security 支持所有这些选项,以及通过 OpenID Connect 和 OAuth2 提供更多选项。

OAuth2 是为第三方授权用户访问指定资源(如基于云的服务、共享存储和应用程序)提供手段而创建的。OpenID Connect 在 OAuth2 的基础上添加了一致的、标准化的身份验证,使用以下一种或多种因素之一:

  • 你知道的东西,例如密码

  • 你拥有的东西,比如硬件密钥

  • 你是某个人,比如生物特征识别器

Spring Boot 和 Spring Security 支持由 Facebook、GitHub、Google 和 Okta 提供的 OpenID Connect 和 OAuth2 实现的开箱即用的自动配置,由于 OpenID Connect 和 OAuth2 的公布标准以及 Spring Security 的可扩展架构,额外的提供者可以很容易地进行配置。我在接下来的示例中使用了 Okta 的库和身份验证+授权机制,但是提供者之间的差异基本上是主题的变化。请随意使用最适合您需求的安全提供程序。

在这个例子中,我重构飞机位置以作为 OpenID Connect 和 OAuth2 客户端应用程序,利用 Okta 的能力来验证用户并获取用户访问由资源服务器公开的资源的权限。然后,我重构 PlaneFinder 以根据从飞机位置(客户端)应用程序的请求中提供的凭据提供其资源——作为 OAuth2 资源服务器。

飞机位置客户端应用程序

我通常从堆栈中最后的应用程序开始,但在这种情况下,由于与用户获得(或被拒绝)访问资源相关的流程,我认为相反的方法更有价值。

用户访问使用某种机制对其进行身份验证的客户端应用程序。一旦经过身份验证,用户对资源的请求将被转发到所谓的资源服务器,该服务器保存和管理所述资源。这是大多数人反复遵循并感到非常熟悉的逻辑流程。通过按照相同顺序启用安全性——先客户端,然后是资源服务器——它与我们自己的预期流程完美对齐。

将 OpenID Connect 和 OAuth2 依赖项添加到飞机位置

与基于表单的安全性一样,当在 Spring Initializr 中创建一个新的 Spring Boot 客户端项目以启动 OpenID Connect 和 OAuth2 在绿地客户端应用中时,可以通过 Spring Initializr 简单地添加额外的依赖项,如图 10-2 所示。

sbur 1002

图 10-2. 在 Spring Initializr 中使用 Okta 配置 OpenID Connect 和 OAuth2 客户端应用所需的依赖项。

更新现有的应用程序只需要更多的努力。因为我正在替换当前的基于表单的安全性,所以首先删除我在上一节中添加的 Spring Security 的现有依赖项。然后,我添加两个与 Initializr 添加的相同的依赖项,一个是用于 OAuth2 客户端(包括 OpenID Connect 认证部分和其他必要组件),另一个是用于 Okta 的依赖项,因为我们将使用他们的基础设施来认证和管理权限,到 Aircraft Positionspom.xml Maven 构建文件中:

<!--	Comment out or remove this 	-->
<!--<dependency>-->
<!--	<groupId>org.springframework.boot</groupId>-->
<!--	<artifactId>spring-boot-starter-security</artifactId>-->
<!--</dependency>-->

<!--	Add these  	    		    -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>com.okta.spring</groupId>
    <artifactId>okta-spring-boot-starter</artifactId>
    <version>1.4.0</version>
</dependency>
注意

当前包含的 Okta 的 Spring Boot Starter 库版本为 1.4.0. 这是经过测试并与当前版本的 Spring Boot 良好配合的版本。当开发人员手动向构建文件添加依赖项时,一个好的实践习惯是访问 Spring Initializr,选择当前版本(当时的)的 Boot,添加 Okta(或其他具体版本)依赖项,并 探索 项目以确认当前推荐的版本号。

一旦刷新构建,就是重构代码的时候了,使 Aircraft Positions 能够与 Okta 进行身份验证并获取用户权限。

重构 Aircraft Positions 以进行身份验证和授权

配置当前 Aircraft Positions 作为 OAuth2 客户端应用程序实际上有三件事需要做:

  • 删除基于表单的安全配置。

  • 在用于访问 PlaneFinder 端点的创建的 WebClient 中添加 OAuth2 配置。

  • 指定 OpenID Connect+OAuth2 注册的客户端凭据和安全提供者的 URI(在本例中为 Okta)。

我首先一起处理前两者,首先完全删除 SecurityConfig 类的主体。如果仍希望或需要通过本地提供的 Aircraft Positions 访问控制资源,则 SecurityConfig 当然可以保留原样或进行一些微小修改;但是,对于本示例,PlaneFinder 扮演资源服务器的角色,因此应控制或拒绝对请求资源的访问价值。 Aircraft Positions 只是一个用户客户端,与安全基础设施合作,使用户能够进行身份验证,然后将资源请求传递给资源服务器。

我将@EnableWebSecurity注解替换为@Configuration,因为不再需要本地认证的自动配置。此外,从类头部删除了extends WebSecurityConfigurerAdapter,因为此版本的Aircraft Positions应用程序不再限制对其端点的请求,而是通过请求将用户的权限传递给 PlaneFinder,使其可以将这些权限与每个资源允许的权限进行比较,并据此采取行动。

接下来,在SecurityConfig类中创建了一个WebClient bean,以在整个Aircraft Positions应用程序中使用。目前这不是硬性要求,因为我可以将 OAuth2 配置直接整合到分配给PositionRetriever成员变量的WebClient的创建中,而且这样做确实有其合理的理由。尽管如此,PositionRetriever需要访问一个WebClient,但是配置WebClient来处理 OpenID Connect 和 OAuth2 配置远超出了PositionRetriever的核心任务:检索飞机位置。

为身份验证和授权创建和配置WebClient非常适合名为SecurityConfig的类的范围内:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.registration
    .ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web
    .OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client
    .ServletOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class SecurityConfig {
    @Bean
    WebClient client(ClientRegistrationRepository regRepo,
                     OAuth2AuthorizedClientRepository cliRepo) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction filter =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction
                    (regRepo, cliRepo);

        filter.setDefaultOAuth2AuthorizedClient(true);

        return WebClient.builder()
                .baseUrl("http://localhost:7634/")
                .apply(filter.oauth2Configuration())
                .build();
    }
}

client() bean 创建方法中,自动装配了两个 bean:

  • ClientRegistrationRepository,一个 OAuth2 客户端列表,由应用程序指定使用,通常在类似application.yml的属性文件中配置。

  • OAuth2AuthorizedClientRepository,一个 OAuth2 客户端列表,表示已认证用户并管理该用户的OAuth2AccessToken

在创建和配置WebClient bean 的方法内部,我执行以下操作:

  1. 我使用两个注入的存储库初始化了一个过滤器函数。

  2. 我确认应使用默认的授权客户端。这通常是情况——毕竟,已认证用户通常是希望访问资源的资源所有者——但是可以选择为涉及委派访问的用例使用不同的授权客户端。我指定 URL 并将配置为 OAuth2 的过滤器应用于WebClient构建器,并构建WebClient,将其作为 Spring bean 返回并添加到ApplicationContext。现在,启用了 OAuth2 的WebClient可以在整个Aircraft Positions应用程序中使用。

由于WebClient bean 现在通过一个 bean 创建方法由应用程序创建,我现在移除了在PositionRetriever类中创建和直接分配WebClient对象的语句,并将其替换为一个简单的成员变量声明。使用 Lombok 的@AllArgsConstructor注解在类上,Lombok 自动为该类生成的“所有参数构造函数”添加了一个WebClient参数。由于ApplicationContext中有一个WebClient bean,Spring Boot 会自动将其注入到PositionRetriever中,并自动分配给WebClient成员变量。重新构造后的PositionRetriever类现在如下所示:

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@AllArgsConstructor
@Component
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebClient client;

    Iterable<Aircraft> retrieveAircraftPositions() {
        repository.deleteAll();

        client.get()
                .uri("/aircraft")
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(ac -> !ac.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        return repository.findAll();
    }
}

在本节的早些时候,我提到了ClientRegistrationRepository的使用,这是一个指定应用程序使用的 OAuth2 客户端列表。有多种方法可以填充此存储库,但通常是将条目指定为应用程序属性。在这个例子中,我将以下信息添加到Aircraft Positionapplication.yml文件中(这里显示了虚拟值):

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: <your_assigned_client_id_here>
            client-secret: <your_assigned_client_secret_here>
        provider:
          okta:
            issuer-uri: https://<your_assigned_subdomain_here>
                            .oktapreview.com/oauth2/default

有了这些信息,Aircraft Positions应用程序的ClientRegistrationRepository将有一个单独的 Okta 条目,在用户尝试访问该应用程序时将自动使用它。

提示

如果定义了多个条目,将在第一次请求时呈现一个网页,提示用户选择一个提供程序。

我对Aircraft Positions做了另一个小改动(以及对PositionRetriever的一个小的下游改动),只是为了更好地演示成功和失败的用户授权。我复制了当前在PositionController类中定义的唯一端点,将其重命名,并分配一个映射,暗示“仅管理员”访问:

import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@AllArgsConstructor
@RestController
public class PositionController {
    private final PositionRetriever retriever;

    @GetMapping("/aircraft")
    public Iterable<Aircraft> getCurrentAircraftPositions() {
        return retriever.retrieveAircraftPositions("aircraft");
    }

    @GetMapping("/aircraftadmin")
    public Iterable<Aircraft> getCurrentAircraftPositionsAdminPrivs() {
        return retriever.retrieveAircraftPositions("aircraftadmin");
    }
}

为了适应使用单个方法访问 PlaneFinder 两个端点的需求,在PositionRetriever中,我将其retrieveAircraftPositions()方法修改为接受一个动态路径参数String endpoint,并在构建客户端请求时使用它。更新后的PositionRetriever类如下所示:

import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@AllArgsConstructor
@Component
public class PositionRetriever {
    private final AircraftRepository repository;
    private final WebClient client;

    Iterable<Aircraft> retrieveAircraftPositions(String endpoint) {
        repository.deleteAll();

        client.get()
                .uri((null != endpoint) ? endpoint : "")
                .retrieve()
                .bodyToFlux(Aircraft.class)
                .filter(ac -> !ac.getReg().isEmpty())
                .toStream()
                .forEach(repository::save);

        return repository.findAll();
    }
}

现在,Aircraft Positions已经是一个完全配置的 OpenID Connect 和 OAuth2 客户端应用程序。接下来,我将重构 PlaneFinder,使其成为一个 OAuth2 资源服务器,在用户授权时提供资源。

PlaneFinder 资源服务器

在任何涉及更改依赖项的重构中,开始的地方是构建文件。

将 OpenID Connect 和 OAuth2 依赖项添加到 Aircraft Positions

正如之前提到的,在创建新的 Spring Boot OAuth2 资源服务器时,可以通过 Spring Initializr 简单地添加另一个或两个依赖项到绿地客户端应用程序,如图 10-3 所示。

sbur 1003

图 10-3. 使用 Okta 在 Spring Initializr 中的 OAuth2 资源服务器的依赖项

更新现有的 PlaneFinder 应用程序非常简单。我在 PlaneFinder 的pom.xml Maven 构建文件中添加了与 Initializr 添加的 OAuth2 资源服务器和 Okta 相同的两个依赖项,因为我们将使用它们的基础设施来验证权限。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>com.okta.spring</groupId>
    <artifactId>okta-spring-boot-starter</artifactId>
    <version>1.4.0</version>
</dependency>

一旦我刷新了构建,现在是时候重构代码,使 PlaneFinder 能够验证与入站请求提供的用户权限相匹配的用户权限,并授予(或拒绝)对 PlaneFinder 资源的访问。

为资源授权重构 PlaneFinder

到此为止,使用 Okta 为我们的分布式系统启用 OpenID Connect 和 OAuth2 身份验证和授权的大部分工作已经完成。正确重构 PlaneFinder 以执行 OAuth2 资源服务器的职责需要很少的工作:

  • 整合 JWT(JSON Web Token)支持

  • 将 JWT 中传递的权限与指定资源的访问所需权限进行比较

这两个任务可以通过创建一个名为SecurityWebFilterChain的单一 bean 来完成,Spring Security 将使用该 bean 来检索、验证和比较入站请求的 JWT 内容与所需权限。

再次,我创建一个SecurityConfig类,并使用@Configuration对其进行注释,以提供一个独立的位置用于 bean 创建方法。接下来,我创建一个名为securityWebFilterChain()的方法,如下所示:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http
                .authorizeExchange()
                .pathMatchers("/aircraft/**").hasAuthority("SCOPE_closedid")
                .pathMatchers("/aircraftadmin/**").hasAuthority("SCOPE_openid")
                .and().oauth2ResourceServer().jwt();

        return http.build();
    }
}

为了创建过滤器链,我自动装配了 Spring Boot 安全自动配置提供的现有ServerHttpSecurity bean。当spring-boot-starter-webflux在类路径中时,此 bean 用于支持 WebFlux 的应用程序。

注意

如果类路径中没有 WebFlux,则应用程序将使用HttpSecurity bean 及其相应的方法,就像本章早些时候在基于表单的身份验证示例中所做的那样。

然后,我配置ServerHttpSecurity bean 的安全标准,指定如何处理请求。为此,我提供了两个资源路径以匹配请求及其所需的用户权限;我还启用了使用 JWT 作为 OAuth2 资源服务器支持的配置。

注意

JWT 有时被称为bearer tokens,因为它们携带用户对资源的授权。

最后,我从ServerHttpSecurity bean 构建SecurityWebFilterChain,并将其返回,使其在整个 PlaneFinder 应用程序中作为一个 bean 可用。

当请求到达时,过滤器链将请求的资源路径与链中指定的路径进行比较,直到找到匹配项。一旦匹配成功,应用程序将使用 OAuth2 提供者(在此例中为 Okta)验证令牌的有效性,然后比较包含的权限与访问映射资源所需的权限。如果匹配成功,则授予访问权限;如果不匹配,则应用程序返回403 Forbidden状态码。

你可能已经注意到第二个pathMatcher指定了一个在 PlaneFinder 中尚不存在的资源路径。我将此路径添加到PlaneController类中,仅仅是为了能够提供成功和失败权限检查的示例。

OAuth2 提供程序可能包括几个默认权限,包括openidemailprofile等。在示例过滤器链中,我检查一个不存在的权限closedid(对于我的提供程序和 OAuth2 权限配置而言),因此任何请求资源路径以*/aircraft开头的请求将失败。按目前的编写方式,对于路径以/aircraftadmin*开头并携带有效令牌的任何入站请求将成功。

注意

Spring Security 在 OAuth2 提供的权限之前添加“SCOPE_”,将 Spring Security 内部的作用域概念与 OAuth2 权限一对一映射。对于使用 Spring Security 与 OAuth2 的开发者来说,这一点很重要,但实际上没有实际的区别。

为了完成代码重构,我现在在 PlaneFinder 的PlaneController类中添加了前面路径匹配引用的*/aircraftadmin端点映射,简单地复制现有的/aircraft*端点的功能,以演示具有不同访问条件的两个端点:

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import reactor.core.publisher.Flux;

import java.io.IOException;
import java.time.Duration;

@Controller
public class PlaneController {
    private final PlaneFinderService pfService;

    public PlaneController(PlaneFinderService pfService) {
        this.pfService = pfService;
    }

    @ResponseBody
    @GetMapping("/aircraft")
    public Flux<Aircraft> getCurrentAircraft() throws IOException {
        return pfService.getAircraft();
    }

    @ResponseBody
    @GetMapping("/aircraftadmin")
    public Flux<Aircraft> getCurrentAircraftByAdmin() throws IOException {
        return pfService.getAircraft();
    }

    @MessageMapping("acstream")
    public Flux<Aircraft> getCurrentACStream() throws IOException {
        return pfService.getAircraft().concatWith(
                Flux.interval(Duration.ofSeconds(1))
                        .flatMap(l -> pfService.getAircraft()));
    }
}

最后,我必须指示应用程序去哪里访问 OAuth2 提供程序,以验证传入的 JWT。关于这个的操作可能会有所不同,因为 OAuth2 提供程序端点的规范具有一定的灵活性,但是 Okta 贴心地实现了一个发行者 URI,作为一个配置的中心 URI,从中可以获得其他必要的 URI。这减少了应用开发者添加单个属性的负担。

我已将application.properties文件从键值对格式转换为application.yml,允许属性的结构化树,稍微减少了重复。请注意,这是可选的,但在属性键中出现重复时非常有用:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://<your_assigned_subdomain_here>.oktapreview.com/
              oauth2/default
  rsocket:
    server:
      port: 7635

server:
  port: 7634

现在所有元素都已就位,我重新启动 PlaneFinder OAuth2 资源服务器和Aircraft Positions OpenID Connect + OAuth2 客户端应用程序来验证结果。在浏览器中加载Aircraft Positions的*/aircraftadmin* API 端点(*http://localhost:8080/aircraftadmin*),我被重定向到 Okta 进行身份验证,如图 10-4 所示。

sbur 1004

图 10-4. OpenID Connect 提供程序提供的登录提示(Okta)

一旦我提供有效的用户凭据,Okta 将经过身份验证的用户(我)重定向到客户端应用程序飞机位置。我请求的端点进而从 PlaneFinder 请求飞机位置,并传递由 Okta 提供的 JWT。一旦 PlaneFinder 将请求的路径匹配到资源路径并验证 JWT 及其包含的权限后,它将当前的飞机位置响应给飞机位置客户端应用程序,后者再将其提供给我,如图 10-5 所示。

sbur 1005

图 10-5. 成功返回当前飞机位置

如果我请求一个没有授权的资源会发生什么?为了看到一个授权失败的例子,我试图访问飞机位置的*/aircraft端点,网址为http://localhost:8080/aircraft*,结果如图 10-6 所示。注意,由于我已经经过身份验证,因此无需重新认证即可继续访问飞机位置应用程序。

sbur 1006

图 10-6. 授权失败的结果

注意,响应未提供有关无法检索结果的详细信息。通常认为,避免泄露可能为潜在的恶意行为者提供有助于最终妥协的信息是一个良好的安全实践。但是,访问飞机位置的日志,我看到了以下额外的信息:

Forbidden: 403 Forbidden from GET http://localhost:7634/aircraft with root cause

这恰好是预期的响应,因为 PlaneFinder 的过滤器匹配请求资源路径在或者低于*/aircraft时预期的未定义权限closedid*未提供。

这些例子被精简到最大限度,但它们代表了使用受尊敬的第三方安全提供者进行 OpenID Connect 认证和 OAuth2 授权的关键方面。其他所有定制和扩展此类认证和授权的方法都建立在这些基本原则和步骤之上,适用于 Spring Boot 应用程序。

代码检查

欲获取完整的章节代码,请查看代码库的chapter10end分支。

概要

理解认证和授权的概念对于构建安全应用程序至关重要,这为用户验证和访问控制奠定了基础。Spring Security 将认证和授权选项与 HTTP 防火墙、过滤器链、广泛使用的 IETF 和 W3C 标准以及交换选项等机制结合起来,帮助确保应用程序的安全性。采用安全开箱即用的理念,Spring Security 利用 Boot 强大的自动配置来评估开发者的输入和可用依赖项,以尽量少的工作量提供 Spring Boot 应用程序的最大安全性。

本章讨论了安全的几个核心方面以及它们如何适用于应用程序。我演示了多种将 Spring Security 整合到 Spring Boot 应用程序中的方法,以加强应用程序的安全姿态,填补覆盖范围中的危险漏洞,并减少攻击面。

下一章将探讨部署 Spring Boot 应用程序到各种目标位置的方法,并讨论它们的相对优点。我还将演示如何创建这些部署工件,提供它们的最佳执行选项,并展示如何验证它们的组件和来源。