精通Spring Boot 3 : 3. Spring Boot 网络开发 (1)
在本章中,我们将回顾如何使用我们的两个项目 My Retro App 和 Users App 来创建 Spring Boot Web 应用程序的特性。Spring 生态系统提供了两种 Web 开发方式:一种是使用 Servlet 堆栈,另一种是使用反应堆栈。在本章中,我们将重点讨论使用 Servlet 堆栈的 Web 开发,而在第 7 章中将介绍反应堆栈。
Spring MVC 框架
Spring MVC 技术自 Spring Framework 创建以来就一直存在,它为 web 应用程序引入了 MVC(模型-视图-控制器)模式,不仅适用于后端,也适用于前端,使用 HTML 模板引擎创建可以访问后端对象(模型)并加以利用的视图(视图/JSP 页面)。
创建一个 Spring Boot 网络应用程序意味着需要包含 spring-boot-starter-web 依赖项,这将引入所有 Spring MVC 相关的依赖项,如 spring-web、spring-web-mvc,以及一个可以立即运行的嵌入式应用服务器(无需外部部署),默认使用的网络服务器是 Apache Tomcat,还有更多功能。
Spring MVC 的主要类之一是 DispatcherServlet。这个 servlet 实现了前端控制器模式,负责处理所有请求并将责任委托给标记为 @Controller 或 @RestController 的组件。
在一个常规的 Spring Web 应用中,需要在 web.xml 文件中声明 DispatcherServlet 类,并通过声明一个上下文 XML 文件来添加配置,以便发现用于请求映射的组件(如 GET、POST、PUT、DELETE、PATCH 等 HTTP 方法)、视图解析、异常处理等功能。
Spring MVC 提供了多种注册组件的方法,以处理 HTTP 请求映射(通过 XML、JavaConfig 或注解)。它引入了 @Controller 和 @RestController 注解。
通常情况下,使用@Controller 注解(类标记注解)时,您的类方法必须返回一个 Model 对象或要渲染的 View 接口名称。如果您希望返回其他对象(例如 JSON、XML 等),则还需要在方法中添加@ResponseBody 注解;这将为您在请求/响应场景中的内容协商提供所需的一切。如果您使用@RestController 注解,则不再需要@ResponseBody 注解,因为 Spring Boot 会默认将 JSON 作为内容解析器;换句话说,如果您需要创建 RESTful API,您需要在类中使用@RestController。
如前所述,@Controller 和 @RestController 注解是用于标记类的标记,这些类注册组件以处理所有 HTTP 请求。所有处理请求的方法都应标记为 @GetMapping(用于 HTTP GET 请求)、@PostMapping(用于 HTTP POST 请求)、@PutMapping、@DeleteMapping 或其他许多注解之一。有一个注解 @RequestMapping,可以同时配置多个请求场景(此注解也可以用作类的标记)。
Spring Boot MVC 自动配置功能
使用 Spring Boot,您无需关心之前部分的内容,因为当您的应用程序运行时,自动配置功能会自动设置所有默认值。让我们来看看自动配置在背后做了些什么:
- 静态内容支持:这意味着您可以在名为/static(默认)或/public、/resources、/META-INF/resources 的目录中添加静态内容,例如 HTML、JavaScript、CSS、媒体等,这些目录应位于您的类路径或当前目录中。Spring Boot 会自动识别这些静态内容并在请求时提供。您可以通过修改 spring.mvc.static-path-pattern 属性或 spring.web.resources.static-locations 属性轻松更改此设置。Spring Boot 和 Web 应用程序的一个很酷的功能是,如果您创建一个 index.html 文件,Spring Boot 会自动提供该文件,而无需注册其他 bean 或进行额外配置。
- HttpMessageConverters:如果您正在使用常规的 Spring MVC 应用程序并希望获得 JSON 响应,您需要为 HttpMessageConverters bean 创建必要的配置(XML 或 JavaConfig)。Spring Boot 默认提供此支持,因此您无需手动配置;这意味着您将默认获得 JSON 格式(因为 spring-boot-starter-web 启动器提供了 Jackson 库作为依赖项)。如果 Spring Boot 的自动配置发现您的类路径中有 Jackson XML 扩展,它会将 XML HttpMessageConverter 添加到转换器中,这样您的应用程序就可以根据请求的内容类型提供服务,无论是 application/JSON 还是 application/XML。
- JSON 序列化和反序列化:如果您希望对 JSON 的序列化和反序列化有更好的控制,Spring Boot 提供了一种简单的方法,您可以通过扩展 JsonSerializer和/或 JsonDeserializer来创建自己的序列化器,并使用@JsonComponent 注解您的类,以便将其注册使用。Spring Boot 的另一个特点是支持 Jackson;默认情况下,Spring Boot 将日期字段序列化为 2024-05-01T23:31:38.141+0000,但您可以通过更改 spring.jackson.date-format=yyyy-MM-dd 属性来修改此默认行为,例如(您可以使用任何日期格式模式);前面的值生成的输出为 2024-05-01。
- 路径匹配与内容协商:Spring MVC 应用程序的一个特点是能够根据后缀响应不同的内容类型,并进行内容协商。如果你请求的路径是 /api/retros.json 或 /api/retros.pdf,内容类型将分别设置为 application/json 和 application/pdf;响应将是 JSON 格式或 PDF 文件。换句话说,Spring MVC 支持 .* 后缀模式匹配,例如 /api/retros.*。而 Spring Boot 默认情况下是禁用这个功能的。如果你希望启用它,可以通过设置 spring.mvc.contentnegotiation.favor-parameter=true 属性来手动添加参数(默认值为 false);你也可以像这样请求 /api/retros?format=xml(format 是默认的参数名称,但你可以通过 spring.mvc 进行更改)。contentnegotiation.parameter-name=myparam)。这会将内容类型设置为 application/xml。
- 错误处理:Spring Boot 使用 /error 映射来创建一个白标页面,显示所有全局错误。您可以通过创建自定义页面来改变这一行为。您需要在 src/main/resources/public/error/ 目录下创建自定义 HTML 页面,例如 500.html 或 404.html。如果您正在开发一个 RESTful 应用程序,Spring Boot 将以 JSON 格式进行响应。当您使用 @ControllerAdvice 或 @ExceptionHandler 注解时,Spring Boot 还支持 Spring MVC 来处理错误。您可以通过实现 ErrorPageRegistrar 并将其注册为 Spring bean 来定义自定义错误页面。
- 模板引擎支持:Spring Boot 支持 Apache FreeMarker、Groovy 模板、Thymeleaf 和 Mustache。当您添加 spring-boot-starter-<template> 依赖时,Spring Boot 会自动配置所有的 bean,以启用所有的视图解析器和文件处理器。默认情况下,Spring Boot 会查找 src/main/resources/templates/ 路径。您可以通过使用 spring..prefix 属性来覆盖此设置。</template>
让我们通过主要项目来回顾这些功能。
我的复古应用程序项目
在第二章中,我们创建了基本的我的复古应用程序配置,没有任何行为或其他类,仅仅是一些属性来演示 Spring Boot 的一些特性。在本节中,我们将通过添加依赖项、更多类以及关于 Web 的所有内容,包括一个 Rest API,来完成这个项目。
让我们开始为项目添加一些依赖项。请打开 build.gradle 文件,并将其内容替换为列表 3-1 中的内容。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://repo.spring.io/snapshot' }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Properties
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
// Web
implementation 'org.webjars:bootstrap:5.2.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
test {
testLogging {
events "passed", "skipped", "failed"
showExceptions true
exceptionFormat "full"
showCauses true
showStackTraces true
showStandardStreams = false
}
}
列表 3-1 build.gradle 文件
让我们一起审查一下新的 build.gradle 文件:
- spring-boot-starter-web:这个依赖为我的复古应用程序提供了所有必要的 JAR 文件,包括 spring-web、spring-webmvc、tomcat、Jackson(用于 JSON 操作)模块等。这个启动器使我们能够使用创建 Web 应用程序所需的注解(如 @RestController、@GetMapping 等)。当然,所有内容都会自动为我们配置好。
- spring-boot-starter-validation:这个依赖引入了 jakarta-validation JAR,帮助我们验证发送和创建的对象是否符合我们领域的需求,即 RetroBoard 和 Card 类(将在本节后面讨论)。
- spring-boot-starter-aop:这个依赖引入了用于面向切面编程(AOP)的 JAR,这是 Spring 用来实现一些出色的逻辑、行为和自动配置,从而使您的应用程序顺利运行的方式。AOP 将帮助我们减少代码的分散和纠缠,使我们的代码更加清晰易懂。
- lombok:这个库可以帮助我们消除 Java 中的冗余代码(例如所有的 getter 和 setter、toString()、构造函数等),使我们的代码更加简洁易读。它不会影响应用的性能,并且学习起来也很简单。
- 这个依赖项提供了预处理自定义属性(在前一章中使用)和为支持 IntelliSense 的 IDE 添加提示的功能,使其能够理解我们正在设置的属性的含义。
- 这个依赖项会从 Bootstrap 项目(https://getbootstrap.com/)引入 CSS 和 JavaScript 文件,并将它们作为资源添加到 webjars/resource 文件夹中。虽然我们不会使用 Spring 来构建前端(因为我们将使用 JavaScript),但我们希望为首页增添一些美观的设计。
- 测试 > testLogging: 这些声明有助于我们在从命令行执行测试任务时记录所有日志信息。它将为我们提供每个单独测试的更多详细信息。我们在之前的章节中已经添加了这个内容。
添加依赖项
当 Spring Boot 启动时,自动配置会检查 build.gradle 中的所有依赖项,并为我们配置和连接所有内容,使我们的 Web 应用程序准备就绪。
NoteLombok (https://projectlombok.org/) 将在整本书中使用。这个库不会对应用程序的性能产生任何影响。当然,您可以选择移除它,接受 Java 的冗长。
到目前为止,My Retro App 项目拥有如图 3-1 所示的目录和包结构。这一结构足以展示我们之前讨论的功能。
现在,我们将通过采用图 3-2 所示的目录/包结构来改进我们的项目。
接下来,我们将详细审查每个包以及我们要创建的所有类。让我们从我们的领域/模型类开始。回顾第 2 章,我们有两个主要类:RetroBoard 和 Card,以及一个枚举类型:CardType。
因此,让我们在 board 包中创建这些类和枚举,从 RetroBoard 开始。列表 3-2 展示了 RetroBoard 类。
package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import lombok.Singular;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Builder
@Data
public class RetroBoard {
private UUID id;
@NotNull
@NotBlank(message = "A name must be provided")
private String name;
@Singular
private List<Card> cards;
}
源代码 3-2src/main/java/com/apress/myretro/board/RetroBoard.java
RetroBoard 类仅包含三个字段:一个 UUID(用于用户 ID),一个字符串(用于名称),以及一个列表(用于卡片);请注意,RetroBoard 可以包含 0 到 N 张卡片。此外,RetroBoard 还具有以下注解:
- @Builder:这个注解将生成一个流畅的 API,用于创建 RetroBoard 实例。这个注解是 Lombok 库的一部分。
- @Data:这个注解会生成所有的 getter 和 setter 方法,以及 toString()、hashCode()和 equals(Object o)方法。这使得我们的代码更加简洁,并且不会影响应用程序的性能。这个注解也属于 Lombok 库。
- @NotNull:这个注解是雅加达项目的一部分,用于标记一个字段必须有值(即,表示它不能为 null)。
- @NotBlank:这个注解同样是雅加达项目的一部分,允许您标记一个字段以便进行后续处理,以检查该字段是否为空(即,它需要有一个值)。您可以添加一条消息,说明为什么该字段不能留空或为 null。
- 这个来自 Lombok 库的注解可以帮助通过将单个元素添加到列表中来构建单个元素。需要注意的是,一旦 RetroBoard 实例构建完成,List将变成一个不可修改的列表;请牢记这一点。
列表 3-3 展示了 Card 类的定义。
package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import java.util.UUID;
@Builder
@Data
public class Card {
private UUID id;
@NotBlank(message = "A comment must be provided always")
@NotNull
private String comment;
@NotNull(message = "A CardType HAPPY|MEH|SAD must be provided")
private CardType cardType;
}
列表 3-3 源代码:src/main/java/com/apress/myretro/board/Card.java
再次,Card 是一个非常简单的类,包含三个字段。它与 CardType 类型有关系,并标记为该字段不能为空。
列表 3-4 展示了 CardType,这是一个仅包含三个元素的枚举:HAPPY、MEH 和 SAD。
package com.apress.myretro.board;
public enum CardType {
HAPPY,MEH,SAD
}
列表 3-4 源代码:src/main/java/com/apress/myretro/board/CardType.java
我们需要保存关于 RetroBoard 和 Cards 对象的信息。因此,在本章中,我们将所有操作都在内存中进行;我们将使用 java.util.Map,这样可以通过键进行更快的查找。接下来,让我们看看我们的持久化包。我们将创建一个 Repository 接口(见清单 3-5)以及实现该接口的 RetroBoardRepository(见清单 3-6)。
package com.apress.myretro.persistence;
import java.util.Optional;
public interface Repository<D,ID> {
D save(D domain);
Iterable<D> findAll();
void delete(ID id);
}
列表 3-5 源代码:src/main/com/apress/myretro/persistence/Repository.java
如列表 3-5 所示,Repository 接口包含占位符,方便您使用实际的领域/模型及其类型作为主键。
列表 3-6 展示了实现了 Repository 接口的 RetroBoardRepository 类。
package com.apress.myretro.persistence;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.CardType;
import com.apress.myretro.board.RetroBoard;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Respository
public class RetroBoardRepository implements Repository<RetroBoard,UUID> {
private Map<UUID,RetroBoard> retroBoardMap = new HashMap<>(){{
put(UUID.fromString("9DC9B71B-A07E-418B-B972-40225449AFF2"),
RetroBoard.builder()
.id(UUID.fromString("9DC9B71B-A07E-418B-B972-40225449AFF2"))
.name("Spring Boot 3.0 Meeting")
.card(Card.builder()
.id(UUID.fromString("BB2A80A5-A0F5-4180-A6DC-80C84BC014C9"))
.comment("Happy to meet the team")
.cardType(CardType.HAPPY)
.build())
.card(Card.builder()
.id(UUID.fromString("011EF086-7645-4534-9512-B9BC4CCFB688"))
.comment("New projects")
.cardType(CardType.HAPPY)
.build())
.card(Card.builder()
.id(UUID.fromString("775A3905-D6BE-49AB-A3C4-EBE287B51539"))
.comment("When to meet again??")
.cardType(CardType.MEH)
.build())
.card(Card.builder()
.id(UUID.fromString("896C093D-1C50-49A3-A58A-6F1008789632"))
.comment("We need more time to finish")
.cardType(CardType.SAD)
.build())
.build()
);
}};
@Override
public RetroBoard save(RetroBoard domain) {
if (domain.getId()==null)
domain.setId(UUID.randomUUID());
this.retroBoardMap.put(domain.getId(),domain);
return domain;
}
@Override
public Optional<RetroBoard> findById(UUID uuid) {
return Optional.ofNullable(this.retroBoardMap.get(uuid));
}
@Override
public Iterable<RetroBoard> findAll() {
return this.retroBoardMap.values();
}
@Override
public void delete(UUID uuid) {
this.retroBoardMap.remove(uuid);
}
}
列表 3-6 源代码:src/main/com/apress/myretro/persistence/RetroBoardRepository.java
RetroBoardRepository 类有一个标记,即 @Repository 注解,这在应用程序启动时至关重要,因为它将被注入到我们稍后在本节中创建的服务中。请注意列表 3-6 中 builder() 方法的使用,该方法是通过 RetroBoard 领域/模型类中的 @Builder 注解生成的。这个方法允许我们使用流式 API 来创建实例。retroBoardMap 将作为内存中的持久化存储。
在我的复古应用中,我们将能够以一种友好的方式展示任何错误,例如当找不到 RetroBoard 或 Card 时,或者当我们设置的验证(使用@NotNull 和@NotBlank)未满足条件时被触发。
现在创建异常包,并添加 RetroBoardNotFoundException、CardNotFoundException 和 RetroBoardResponseEntityExceptionHandler 类。首先,列表 3-7 展示了 RetroBoardNotFoundException 类。
package com.apress.myretro.exception;
public class RetroBoardNotFoundException extends RuntimeException{
public RetroBoardNotFoundException(){
super("RetroBoard Not Found");
}
public RetroBoardNotFoundException(String message) {
super(String.format("RetroBoard Not Found: {}", message));
}
public RetroBoardNotFoundException(String message, Throwable cause) {
super(String.format("RetroBoard Not Found: {}", message), cause);
}
}
列表 3-7 源代码:src/main/com/apress/myretro/exception/RetroBoardNotFoundException.java
RetroBoardNotFoundException 类继承自 RunTimeException,并提供了一些固定的消息以便于处理。当我们尝试查找一个不存在的 UUID 的 RetroBoard 时,将会抛出该类的实例。
列表 3-8 展示了 CardNotFoundException 类,当 RetroBoard 中的卡片未找到时会抛出此异常。
package com.apress.myretro.exception;
public class CardNotFoundException extends RuntimeException{
public CardNotFoundException() {
super("Card Not Found");
}
public CardNotFoundException(String message) {
super(String.format("Card Not Found: {}", message));
}
public CardNotFoundException(String message, Throwable cause) {
super(String.format("Card Not Found: {}", message), cause);
}
}
列表 3-8 源码路径: src/main/com/apress/myretro/exception/CardNotFoundException.java
package com.apress.myretro.exception;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class RetroBoardResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(value
= { CardNotFoundException.class,RetroBoardNotFoundException.class })
protected ResponseEntity<Object> handleNotFound(
RuntimeException ex, WebRequest request) {
Map<String, Object> response = new HashMap<>();
response.put("msg","There is an error");
response.put("code",HttpStatus.NOT_FOUND.value());
response.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-mm-dd HH:mm:ss")));
Map<String, String> errors = new HashMap<>();
errors.put("msg",ex.getMessage());
response.put("errors",errors);
return handleExceptionInternal(ex, response,
new HttpHeaders(), HttpStatus.NOT_FOUND, request);
}
}
列表 3-9 源代码:src/main/com/apress/myretro/exception/RetroBoardResponseEntityException.java
以下列表对 RetroBoardResponseEntityExceptionHandler 类进行了详细说明:
- @ControllerAdvice:该注解利用 AOP 实现环绕通知(这意味着它会拦截控制器中定义的所有方法调用,并在 try-catch 块中执行这些调用;如果发生异常,将调用 handleNotFound 方法,在这里您可以添加自己的逻辑来处理错误)。它还注册以捕获运行时抛出的任何错误,并声明一个用@ExceptionHandler 注解标记的方法。
- @ExceptionHandler:该注解用于捕获作为参数值声明的任何异常。在这种情况下,它指示在抛出 RetroBoardNotFoundException 或 CardNotFoundException 时执行 handleNotFound 方法。
- ResponseEntity:这个注解是从 HttpEntity 类扩展而来的,表示一个包含头部和主体的 HTTP 请求或响应实体。如您在列表 3-9 中所见,我们正在创建一个 Map,默认会将其转换为 JSON 格式。
- 该方法属于扩展类,将负责准备所有通用处理并创建 ResponseEntity。
正如您所见,RetroBoardResponseEntityExceptionHandler 类提供了一种特殊的方式来处理在查找 RetroBoard 或 Card 对象时发生的网络错误,甚至在请求或响应开始时也可能出现这些错误。
接下来,创建服务包,并按照清单 3-10 的示例添加 RetroBoardService 类。
package com.apress.myretro.service;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.CardNotFoundException;
import com.apress.myretro.persistence.Repository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
@AllArgsConstructor
@Service
public class RetroBoardService {
Repository<RetroBoard,UUID> repository;
public RetroBoard save(RetroBoard domain) {
if (domain.getCards() == null)
domain.setCards(new ArrayList<>());
return this.repository.save(domain);
}
public RetroBoard findById(UUID uuid) {
return this.repository.findById(uuid).get();
}
public Iterable<RetroBoard> findAll() {
return this.repository.findAll();
}
public void delete(UUID uuid) {
this.repository.delete(uuid);
}
public Iterable<Card> findAllCardsFromRetroBoard(UUID uuid) {
return this.findById(uuid).getCards();
}
public Card addCardToRetroBoard(UUID uuid, Card card){
if (card.getId() == null)
card.setId(UUID.randomUUID());
RetroBoard retroBoard = this.findById(uuid);
List<Card> cardList = new ArrayList<>(retroBoard.getCards());
cardList.add(card);
retroBoard.setCards(cardList);
return card;
}
public Card findCardByUUIDFromRetroBoard(UUID uuid, UUID uuidCard){
RetroBoard retroBoard = this.findById(uuid);
Optional<Card> card = retroBoard.getCards().stream().filter(c -> c.getId().equals(uuidCard)).findFirst();
if (card.isPresent())
return card.get();
throw new CardNotFoundException();
}
public void removeCardFromRetroBoard(UUID uuid, UUID cardUUID){
RetroBoard retroBoard = this.findById(uuid);
List<Card> cardList = new ArrayList<>(retroBoard.getCards());
cardList.removeIf(card -> card.getId().equals(cardUUID));
retroBoard.setCards(cardList);
}
}
列表 3-10 源代码:src/main/com/apress/myretro/service/RetroBoardService.java
RetroBoardService 类将帮助我们处理应用程序所需的所有业务逻辑。让我们来分析一下:
- @AllArgsConstructor:这个注解来自 Lombok 库,用于创建一个构造函数,该构造函数使用字段作为参数。换句话说,它创建了一个以 Repository作为参数的构造函数。其代码片段大致如下:
public RetroBoardService(Repository<RetroBoard,UUID> repository) {
this.repository = repository;
}
- 我们正在使用 Repository 接口,因此 Spring 框架会通过查看所有注册的 Spring bean 来注入该接口的实现;我们在列表 3-6 中使用 @Repository 注解注册了这个 bean,这意味着 RetroBoardRepository 的实现将被注入,并能够访问内存中的持久性。能够使用或更换不同的实现而仍然保持相同的代码,这是 Spring 框架的一个出色特性。
- @Service:这个注解是一个标识类为 Spring bean 的特殊标记,以便可以在代码的其他地方进行注入或使用。在这种情况下,我们将在我们的 Web 控制器中使用这个服务。
现在,在继续我的复古应用程序中的其他类之前,请再次查看 RetroBoardService 代码(见清单 3-10),并查找 findById(UUID)方法。首先,请注意我们调用了 repository.findById(UUID)方法。如果查看清单 3-6,函数返回一个 Optional.ofNullable,这意味着值可能为 null,而我们立即调用了.get(),这将导致 NullPointerException。那么,我们该如何处理这个问题并将其转换为未找到异常呢?我们将使用 AOP 来实现这一点。此外,请注意在 RetroBoardService 代码中,findById 在其他几个方法中也被调用,我们可以实现一个简单的逻辑来捕获错误、处理它或抛出异常。 这个想法会导致我们把代码到处散布和纠缠,而我们并不想这样。接下来的部分将提供解决方案。
AOP 的拯救
面向切面编程可以帮助我们将这个问题放在一个独立的类中,使逻辑更加清晰,避免代码的散乱和纠缠。
接下来,创建建议包和 RetroBoardAdvice 类。请参阅列表 3-11。
package com.apress.myretro.advice;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.RetroBoardNotFoundException;
import com.apress.myretro.persistence.RetroBoardRepository;
import com.apress.myretro.service.RetroBoardService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Component
@Aspect
public class RetroBoardAdvice {
@Around("execution(* com.apress.myretro.persistence.RetroBoardRepository.findById(java.util.UUID))")
public Object checkFindRetroBoard(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("[ADVICE] findRetroBoardById");
Optional<RetroBoard> retroBoard = (Optional<RetroBoard>) proceedingJoinPoint.proceed(new Object[]{
UUID.fromString(proceedingJoinPoint.getArgs()[0].toString())
});
if (retroBoard.isEmpty())
throw new RetroBoardNotFoundException();
return retroBoard;
}
}
列表 3-11 源代码:src/main/com/apress/myretro/advice/RetroBoardAdvice.java
以下列表详细介绍了 RetroBoardAdvice 类:
- @Slf4j:这个来自 Lombok 库的注解会创建一个日志记录器的实例,方便你使用。在这种情况下,它仅仅记录被拦截的方法名称(通过调用 proceedingJoinPoint.getSignature().getName()方法)。
- @Component:这个注解是一个标识,指示 Spring 框架将其注册为 Spring bean,并在需要时使用。
- @Aspect:这个注解是一个标记,表明一个类具有切面,并告诉 Spring 框架该类包含一个通知(前置、后置、环绕、返回后、抛出后),该通知会根据匹配器的声明拦截特定的方法。在后台,Spring 会为这些类创建代理,并处理与 AOP 相关的所有内容。
- @Around 注解是创建 Advice 的众多注解之一。在这种情况下,它会在方法执行之前拦截调用(根据与方法匹配的执行声明),创建一个 ProceedingJoinPoint 实例,执行您标记了此注解的方法并处理您的逻辑。然后,您可以执行实际调用,进行更多逻辑处理,并返回结果。相关的注解包括@Before 建议,它会在实际调用之前执行您的方法逻辑,@After 建议,它会在调用之后执行您的方法逻辑,以及@AfterReturning 建议,它会在调用返回时执行逻辑。
- 执行:这是建议的关键所在。这个关键字需要一个模式匹配,以识别需要建议的方法。在这种情况下,我们要查找每个返回类型为(*)的任意方法,并寻找在 com.apress.myretro.repository.RetroBoardRepository.findById 中以 UUID 作为参数的特定方法。虽然在这种情况下很简单,但我们也可以使用这样的表达式:* com.apress.*..*.find*(*)。这意味着查找在 apress 包及其子包中的任何类,以及任何以 find 为前缀的方法,这些方法可以接受任意数量的参数,无论其类型如何。
- ProceedingJoinPoint:这是一个接口,其实现知道如何获取被建议的对象(RetroBoardRepository.findById);它持有实际对象,我们可以调用 proceed 方法来执行它,并获取结果返回。请注意,我们可以对结果或发送的参数进行操作。只有 Around Advice 必须使用这个 ProceedingJoinPoint。
因此,当我们指示服务查找一个保存在内存中的 RetroBoard 实例时,它会拦截对 RetroBoardRepository 的调用(建议/环绕),并执行 checkFindRetroBoard 方法。它会从 ProceedingJoinPoint 中收集所有信息,我们继续进行实际调用,并从 Optional 类中获取可能的空值,然后检查它是否为空。如果为空,则抛出 RetroBoardNotFoundException;否则返回结果。
通过这个建议,我们专注于检查,避免代码的重复(纠缠和分散),使我们的代码更加清晰易懂。
这只是 AOP 范式强大功能的一个小例子。您可以在方法上使用自定义注解和建议。例如,您可以创建一个@Cache 注解,并对每个使用该注解的方法应用环绕通知。
如果你想深入了解 AOP,我推荐《Pro Spring 6》(Apress,2023)或 Spring 文档,在那里你可以找到关于建议类型的详细解释,以及你可以用它们实现的其他功能(https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop)。
现在是进行一些网络逻辑的时刻!