《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 21
9.10 内容协商
读者在测试 9.9 节的数据校验过程中如果使用 API 工具来测试 /save 请求时的校验异常效果,会发现 API 工具收到的响应内容与使用浏览器提交表单的形式不同:浏览器表单提交后收到的响应是 Spring Boot 渲染的空白错误提示页,而使用 API 工具收到的是一段 JSON 数据。

这种能适应不同客户端偏好的形式而展示相应类型数据的特性叫 "内容协商",这个特性来自 WebMvc,在 Spring Boot 中又得以发扬光大。这个特性非常重要,希望读者能正确认识并理解该特性。
9.10.1 内容协商机制
借助一个虚拟场景来解释内容协商机制。一个对外提供的 Web 服务和几种不同类型的客户端,通过浏览器发送某一个请求时期望返回 HTML 格式的数据以供浏览器渲染,移动端则在发送相同请求时期望返回 JSON 格式的数据,第三方对接的系统则期望收到 XML 格式的数据。为了能用同一套后端的接口逻辑同时满足上述 3 种格式的数据,需要借助一些措施让 WebMvc 感知到不同的客户端所需的数据格式。WebMvc 提供了两种途径来传递响应体的数据格式:基于请求头的内容协商和基于请求参数的内容协商。

9.10.2 基于请求头的内容协商
默认情况下 WebMvc 已经开启了基于请求头的内容协商,它对于客户端的内容协商要求是:每次发送请求时请求头中携带 "Accept",即声明当前请求的客户端希望接收的数据类型,例如对于浏览器而言它希望接收到的数据类型为 text/html,那么浏览器在发送请求时就会携带请求头Accept:text/html;对于移动端而言它希望接收 JSON 格式的数据,那么发送请求时应当携带 Accept:application/json 的请求头,其他格式的数据同理。
下面通过一个简单的示例测试一下效果。为了能使当前的 Spring Boot 工程支持 XML 格式的数据响应,需要先导入一个 Jackson 适配 XML 格式的数据格式化依赖。

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yangjunbo</groupId>
<artifactId>springboot-webmvc-a</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-webmvc-a</name>
<description>springboot-webmvc-a</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
之后找到 9.6 节中编写的 RestfulDepartmentController,给它添加一个 findAll 方法让其返回 departmentList,这样就得到了一个简单的列表查询的接口。之后尝试分别通过浏览器和 API 工具发送 /department/findAll 请求,通过观察发送请求时的请求头可以发现,浏览器更期望接收text/html、application/xml 等格式的数据,所以浏览器接收到的数据是 XML 格式的;API 工具没有明确指定,所以 WebMvc 予以 JSON 格式的数据响应。倘若使用 API 工具发送请求时显式指定请求头中 Accept为application/xml,则也可以得到 XML 格式的数据。

java
package com.yangjunbo.springboot.webmvc.examplef;
import com.yangjunbo.springboot.webmvc.examplec.Department;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/department")
public class RestfulDepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@GetMapping("/{id}")
public Department findById(@PathVariable("id") String id) {
return departmentList.stream().filter(i -> i.getId().equals(id)).findAny().orElse(null);
}
@PostMapping("/")
public void save(Department department) {
departmentList.add(department);
}
@PutMapping("/{id}")
public void update(Department department, @PathVariable("id") String id) {
departmentList.stream().filter(i -> i.getId().equals(id)).findAny().ifPresent(i -> {
// 将修改的department属性复制到原来的数据,即相当于修改
BeanUtils.copyProperties(department, i, "id");
});
}
@DeleteMapping("/{id}")
public void delete(@PathVariable("id") String id) {
departmentList.stream().filter(i -> i.getId().equals(id)).findAny().ifPresent(i -> departmentList.remove(i));
}
@PostMapping("/saveJson")
public String saveJson(@RequestBody Department department) {
System.out.println(department);
departmentList.add(department);
return "success";
}
@GetMapping("/findAll")
public List<Department> findAll() {
return departmentList;
}
}



9.10.3 基于请求参数的内容协商
如果感觉基于请求头的内容协商形式上有些麻烦,则可以选择第二种方案:基于请求参数的内容协商。与基于请求头不同,基于请求参数的机制需要手动开启,找到 Spring Boot 的配置文件,在其中声明一个配置:spring.mvc.contentnegotiation.favor-parameter=true。开启后 WebMvc 会提供一个特殊的参数 format,通过发送请求时传递不同的 format 值,可以得到不同格式的数据响应。比如开启后发送 /department/findAll?format=json 请求,无论是使用浏览器还是 API 工具,得到的都是 JSON 格式的数据;而发送 /department/ findAll?format=xml 请求时,得到的总是 XML 格式的数据。由此可见,基于请求参数的内容协商的优先级高于请求头的方式。
另外,如果想替换 WebMvc 默认的内容协商参数名,可以通过调整配置属性 spring.mvc. contentnegotiation.parameter-name 进行修改,比如将参数名改为"aspecialformat"后,触发内容协商时发送的请求就应该是 /department/findAll?aspecialformat=json。
9.11 异常处理
数据校验虽然已经完成,但是现在还有一个用户体验差的问题:每次校验失败时都会抛出异常,用户看到满屏的异常信息必定会疑惑又郁闷。如何将这些可预见和不可预见的异常都在后端消除掉,转而给用户提供一个相对友好的响应呢?这就需要使用 WebMvc 的统一异常处理机制。因为无法预知所有异常情况的发生,所以异常处理的核心点是如何能让 WebMvc 处理抛给它的异常。
9.11.1 异常处理思路分析
9.11.2 @ExceptionHandler
WebMvc 提供了一个可以快速声明和处理异常的核心注解:@ExceptionHandler,使用它可以声明式捕获指定的异常。下面先来看一个简单的使用方式。
1.Controller 中声明异常处理
为了能人为构造一个异常,接下来修改 list 方法,在方法体中添加一个 int i=1/0; 即可引发除零异常。编写后重启应用,浏览器中访问 /department/list 即可收到除零异常引发的 Spring Boot 默认处理。

java
package com.yangjunbo.springboot.webmvc.examplec;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
public class DepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@RequestMapping("/department/list")
public String list(Model model) {
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list2")
public String list2(ModelMap modelMap) {
modelMap.put("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list3")
public ModelAndView list3(ModelAndView mav) {
mav.addObject("deptList", this.departmentList);
mav.setViewName("deptList");
return mav;
}
@RequestMapping("/department/list4")
public String list4(HttpServletRequest request) {
request.setAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list5")
public String list5(String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list6")
public String list3(Department department, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(department.getName())) {
stream = stream.filter(i -> i.getName().contains(department.getName()));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list7")
public String list3(@RequestParam(value = "dept_name", required = false,
defaultValue = "") String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
model.addAttribute("name", name);
return "dept/deptList";
}
@RequestMapping("/department/list8")
public String list8(Model model) {
int i = 1/0;
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
}

准备工作完毕后,下面使用 @ExceptionHandler 来处理这个异常。在 DepartmentController 中声明一个 handleArithmeticException方法并标注 @ExceptionHandler,这个注解需要传入要捕获的异常,随后就可以在该方法的入参中得到捕获的异常。代码中仅仅将获取的异常进行栈信息打印,随后借助 @ResponseBody 直接返回一个相对友好的提示信息。

java
package com.yangjunbo.springboot.webmvc.examplec;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
public class DepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@RequestMapping("/department/list")
public String list(Model model) {
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list2")
public String list2(ModelMap modelMap) {
modelMap.put("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list3")
public ModelAndView list3(ModelAndView mav) {
mav.addObject("deptList", this.departmentList);
mav.setViewName("deptList");
return mav;
}
@RequestMapping("/department/list4")
public String list4(HttpServletRequest request) {
request.setAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list5")
public String list5(String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list6")
public String list3(Department department, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(department.getName())) {
stream = stream.filter(i -> i.getName().contains(department.getName()));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list7")
public String list3(@RequestParam(value = "dept_name", required = false,
defaultValue = "") String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
model.addAttribute("name", name);
return "dept/deptList";
}
@RequestMapping("/department/list8")
public String list8(Model model) {
int i = 1/0;
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@ExceptionHandler(ArithmeticException.class)
@ResponseBody
public String handleArithmeticException(ArithmeticException e) {
e.printStackTrace();
return "请求出现错误,请稍后再试";
}
}
编写完毕后重启应用,再次访问 /department/list8 后浏览器并没有呈现 Spring Boot 提供的错误页面,取而代之的是代码中返回的错误提示,说明异常处理机制已经生效。

与普通的 Handler 方法类似,被 @ExceptionHandler 标注的方法参数中可以注入 HttpServletRequest 与 HttpServletResponse 对象,代码中仅仅打印了异常的栈信息,实际项目开发中更多的是结合日志输出;方法的返回值同样可以跳转到页面视图,或者在方法上标注@ResponseBody 将方法返回值转换为 JSON 响应给客户端。
2.编写 errorPage 页面展示异常信息
如果是基于前后端分离的应用开发,上面其实就已经完成了(因为后端不需要控制视图跳转),而前后端不分离的应用开发中需要后端引导页面间的跳转逻辑,所以当出现异常时应当跳转到一个相对友好的错误页面,而不是将错误信息直接展示给用户。下面改造handleArithmeticException 方法,使其跳转到一个特殊的错误展示页面,并提示相应的错误信息。代码提供了一个最简单的错误页跳转和展示,在 resources 目录下新建一个 error 文件夹,并新建一个 errorPage.html 文件,简单编写一个相对友好的错误提示即可。

html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>错误页面</title>
</head>
<body>
<h2>对不起,系统出现错误</h2>
</body>
</html>

java
package com.yangjunbo.springboot.webmvc.examplec;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
public class DepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@RequestMapping("/department/list")
public String list(Model model) {
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list2")
public String list2(ModelMap modelMap) {
modelMap.put("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list3")
public ModelAndView list3(ModelAndView mav) {
mav.addObject("deptList", this.departmentList);
mav.setViewName("deptList");
return mav;
}
@RequestMapping("/department/list4")
public String list4(HttpServletRequest request) {
request.setAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list5")
public String list5(String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list6")
public String list3(Department department, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(department.getName())) {
stream = stream.filter(i -> i.getName().contains(department.getName()));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list7")
public String list3(@RequestParam(value = "dept_name", required = false,
defaultValue = "") String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
model.addAttribute("name", name);
return "dept/deptList";
}
@RequestMapping("/department/list8")
public String list8(Model model) {
int i = 1/0;
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@ExceptionHandler(ArithmeticException.class)
//@ResponseBody
public String handleArithmeticException(ArithmeticException e) {
//e.printStackTrace();
//return "请求出现错误,请稍后再试";
e.printStackTrace();
return "error/errorPage";
}
}
重启应用后再次访问 /department/list8 即可看到错误页面,原有的 Spring Boot 提供的错误页面被成功取代。
如果需要在错误页面上展示一些异常信息,也可以通过在跳转时向 Model 中设置属性来实现,编码方式与页面数据传递没有区别。被@ExceptionHandler 注解标注的方法同样可以注入 Model 或 ModelAndView 等 WebMvc 提供的对象,代码中展示了一个简单的异常信息示例,编写完毕后重启应用,刷新页面后可以发现除零错误信息被正确显示出来。

java
package com.yangjunbo.springboot.webmvc.examplec;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
public class DepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@RequestMapping("/department/list")
public String list(Model model) {
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list2")
public String list2(ModelMap modelMap) {
modelMap.put("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list3")
public ModelAndView list3(ModelAndView mav) {
mav.addObject("deptList", this.departmentList);
mav.setViewName("deptList");
return mav;
}
@RequestMapping("/department/list4")
public String list4(HttpServletRequest request) {
request.setAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list5")
public String list5(String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list6")
public String list3(Department department, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(department.getName())) {
stream = stream.filter(i -> i.getName().contains(department.getName()));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list7")
public String list3(@RequestParam(value = "dept_name", required = false,
defaultValue = "") String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
model.addAttribute("name", name);
return "dept/deptList";
}
@RequestMapping("/department/list8")
public String list8(Model model) {
int i = 1/0;
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@ExceptionHandler(ArithmeticException.class)
//@ResponseBody
public String handleArithmeticException(ArithmeticException e, Model model) {
//e.printStackTrace();
//return "请求出现错误,请稍后再试";
e.printStackTrace();
model.addAttribute("errorMessage", e.getMessage());
return "error/errorPage";
}
}

html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>错误页面</title>
</head>
<body>
<h2>对不起,系统出现错误</h2>
<h3>具体错误原因:</h3>
<p th:text="${errorMessage}"></p>
</body>
</html>

9.11.3 @ControllerAdvice
单一的异常处理可以通过 9.11.2 节的内容完成,但如果一个应用中的 Controller 非常多,不可能在每个 Controller 中都声明相同或相似的异常处理,为此就需要有一个全局的统一异常处理机制。WebMvc 提供了另外一个注解:@ControllerAdvice,这个注解通常不作为接收客户端请求的处理器,而是专门用于增强其他 Controller 的特殊控制器(有些AOP的意味)。下面通过一个简单的示例展示 @ControllerAdvice 配合@ExceptionHandler 的使用方式。
假设需要全局统一捕获 ArithmeticException 异常,可以单独声明一个 GlobalControllerResolver 类,标注 @ControllerAdvice。经过此番修改后其他 Controller 中便不需要单独的异常处理逻辑,而全部交由 GlobalControllerResolver 集中处理。重启应用后刷新页面,可以发现异常处理的效果依然存在,说明全局的异常处理机制已经生效。

java
package com.yangjunbo.springboot.webmvc.examplec;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalControllerResolver {
@ExceptionHandler(ArithmeticException.class)
public String handleArithmeticException(ArithmeticException e, Model model) {
e.printStackTrace();
model.addAttribute("errorMessage", e.getMessage());
return "error/errorPage";
}
}


除了最基本的使用,@ControllerAdvice 中有几个属性需要关注,如表所示。
| 属性名 | 类型 | 说明 |
|---|---|---|
value / basePackages |
String[] |
指定一个或多个基础包名。只有位于这些包及其子包下的 Controller 才会被该 Advice 管理。value 是 basePackages 的别名。 |
basePackageClasses |
Class<?>[] |
basePackages 的类型安全替代方案。指定一个或多个类,该 Advice 将作用于这些类所在的包及其子包。 |
assignableTypes |
Class<?>[] |
指定一个或多个 Controller 类型(可以是接口或父类)。只有继承或实现了这些类型的 Controller 才会被管理。 |
annotations |
Class<? extends Annotation>[] |
指定一个或多个注解。只有被这些注解标记的 Controller 才会被该 Advice 管理。 |
9.11.4 多种异常处理共存
截止到 9.11.3 节,只处理了一种异常,如果需要同时处理多种异常,并且这些异常可能还有继承关系,则必然会出现多种异常处理共存的现象。WebMvc 对多种异常处理的应对非常完备,完全可以在一个全局异常处理器中同时声明多种不同的异常,即便这些异常包含复杂的继承关系。WebMvc 会根据捕获到的异常从具体到抽象逐级检索,直到获得一个可以应对的异常处理器,或者在没有检索到之后抛出该异常。
为了验证上述说法,可以在 GlobalControllerResolver 类中同时声明几种异常的处理,再在 DepartmentController 中构造几个不同的异常抛出。随后重启应用并分别访问三个接口,可以发现三次响应的页面错误信息分别为 "出现计算错误"(有完全匹配的异常类型)、"出现运行时异常"(有完全匹配的异常类型)、"出现预期之处的错误"(子类不匹配但父类匹配),完全符合我们之前的定论。

java
package com.yangjunbo.springboot.webmvc.examplec;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Controller
public class DepartmentController {
private List<Department> departmentList = new ArrayList<>();
@PostConstruct
public void init() {
Department dept1 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门1", "123321");
departmentList.add(dept1);
Department dept2 = new Department(UUID.randomUUID().toString().replaceAll("-", ""), "测试部门2", "1234567");
departmentList.add(dept2);
}
@RequestMapping("/department/list")
public String list(Model model) {
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list2")
public String list2(ModelMap modelMap) {
modelMap.put("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list3")
public ModelAndView list3(ModelAndView mav) {
mav.addObject("deptList", this.departmentList);
mav.setViewName("deptList");
return mav;
}
@RequestMapping("/department/list4")
public String list4(HttpServletRequest request) {
request.setAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list5")
public String list5(String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list6")
public String list3(Department department, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(department.getName())) {
stream = stream.filter(i -> i.getName().contains(department.getName()));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
return "deptList";
}
@RequestMapping("/department/list7")
public String list3(@RequestParam(value = "dept_name", required = false,
defaultValue = "") String name, Model model) {
Stream<Department> stream = this.departmentList.stream();
if (StringUtils.hasText(name)) {
stream = stream.filter(i -> i.getName().contains(name));
}
model.addAttribute("deptList", stream.collect(Collectors.toList()));
model.addAttribute("name", name);
return "dept/deptList";
}
@RequestMapping("/department/list8")
public String list8(Model model) {
int i = 1/0;
model.addAttribute("deptList", this.departmentList);
return "deptList";
}
@RequestMapping("/department/list9")
public String list9(HttpServletRequest request, Model model) {
throw new RuntimeException("显式抛出异常");
}
@RequestMapping("/department/list10")
public String list10(HttpServletRequest request, Model model) {
Object obj = null;
obj.toString();
model.addAttribute("deptList", departmentList);
return "dept/deptList";
}
//@ExceptionHandler(ArithmeticException.class)
//@ResponseBody
public String handleArithmeticException(ArithmeticException e, Model model) {
//e.printStackTrace();
//return "请求出现错误,请稍后再试";
e.printStackTrace();
model.addAttribute("errorMessage", e.getMessage());
return "error/errorPage";
}
}

java
package com.yangjunbo.springboot.webmvc.examplec;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class GlobalControllerResolver {
@ExceptionHandler(ArithmeticException.class)
public String handleArithmeticException(ArithmeticException e, Model model) {
e.printStackTrace();
model.addAttribute("errorMessage", e.getMessage());
return "error/errorPage";
}
@ExceptionHandler(RuntimeException.class)
public String handleRuntimeException(RuntimeException e, Model model) {
e.printStackTrace();
model.addAttribute("errorMessage", "出现运行时异常");
return "error/errorPage";
}
@ExceptionHandler(Exception.class)
public String handleException(Exception e, Model model) {
e.printStackTrace();
model.addAttribute("errorMessage", "出现预期之外的错误");
return "error/errorPage";
}
}



9.11.5 Spring Boot 的异常处理扩展
前面讲解的都是 WebMvc 原生的异常处理机制,Spring Boo t在整合 WebMvc 时对异常处理做出进一步扩展,这使得 Spring Boot 应用在异常处理方面更加强大。本章的最后一起了解 Spring Boot 对原有异常处理的扩展,同时针对 Spring Boot 作出的扩展逻辑给出最佳实践。
1.不能处理错误时的转发
通常来讲处理异常的方式是通过 @ExceptionHandler 实现的,而应用出现无法匹配和处理的异常时,Spring Boot 会把这个请求转发到一个特殊的路径:/error,由它来执行后续的错误处理动作。之前没有学习异常处理时,当抛出异常时看到的 Spring Boot 默认错误页面就是 Spring Boot中 /error 对应的处理器做出的响应。
如果需要对默认的错误处理请求路径进行修改,可以在全局配置文件中声明 server.error.path 属性,只要配置的请求路径与应用中现有的接口路径不冲突即可。
2.异常处理的内容协商
异常情况转发到 /error 后,Spring Boot 会根据当前请求的客户端类型决定返回何种类型的数据,这个机制还是 9.11 节的内容协商,针对浏览器发送的请求会返回之前见到的错误提示页面,而针对 API 工具和代码方式的请求则会响应 JSON 数据。
以响应 JSON 数据为例,Spring Boot 默认提供的异常属性包括 timestamp 当前异常的时间戳(如果对 ObjectMapper 进行定制则会返回具体格式化后的数据)、status 本次请求的响应状态码、error 错误响应信息、message 异常提示信息(注意是 Exception 中的信息)、path 本次请求的路径,这些信息均由 Spring Boot 构造。
3.错误响应页面的匹配
如果异常处理的内容协商结果为响应 HTML,则意味着浏览器会收到一个错误信息页面,Spring Boot 在这里又有一部分扩展机制,具体如下。
(1)【精确匹配】当确定 WebMvc 无法处理异常时,Spring Boot 先根据当前响应的状态码和是否整合模板引擎去定位 HTML 错误页面,如果整合了 Thymeleaf/FreeMarker 等模板引擎,则会尝试从 resources/templates/error 下获取一个特殊文件【状态码.html】,如响应 404 则会尝试获取 404.html,获取成功则将该 HTML 文件响应给浏览器;如果没有整合模板引擎,则会从静态资源目录 resources/static 下获取,并在获取成功后响应。
(2)【模糊匹配】如果 (1) 没有获取与响应状态码完全匹配的HTML文件,则会根据状态码的第一位数值尝试获取 4xx.html/5xx.html(如响应 404则尝试获取 4xx.html),获取的规则与 (1) 相同,都是在有模板引擎整合时从 resources/templates/error 下获取,没有模板引擎时从resources/static 下获取,只要获取成功就响应给浏览器。
(3)【约定视图】如果 (2) 仍然无法获取 HTML 文件,则会直接从 resources/templates 中获取一个名为 error.html 的文件,这个文件名是固定的且不可修改,如果可以匹配到该文件则响应,否则继续向下执行。
(4)【兜底视图】执行到这里证明没有任何现有的 HTML 文件可供响应,Spring Boot 会响应一个默认的兜底视图,即前面看到的错误提示页面。
4.最佳实践
了解了主要的 WebMvc 和 Spring Boot 的异常处理机制,结合项目开发中的两种主流场景,总结出如下最佳实践,分场景来看。
(1) 前后端分离开发。这种场景下后端应用不需要考虑视图跳转,只负责接收请求和响应数据即可,此时所有的异常发生后均应当以 JSON(或其他形式)数据的方式返回给客户端,异常处理的方式使用 @ControllerAdvice+@ExceptionHandler+@ResponseBody 将所有异常信息都响应出去即可。
@ControllerAdvice 还有一个派生注解 @RestControllerAdvice,其作用等同于 @ControllerAdvice+@ResponseBody。
(2) 前后端不分离开发。这种场景下需要后端应用参与页面跳转的控制,所以项目中可能同时存在 Ajax 请求和视图跳转夹带数据的情况,此时我们就不能 "一刀切" 返回 JSON 数据,而是可以借助 Spring Boot 扩展机制中的内容协商,将所有出现异常时的处理逻辑最终都转发给 /error 请求,由 Spring Boot 接管决定响应页面或者 JSON 数据;对于需要跳转异常页面的逻辑,可以结合响应状态码有针对性地制作一些友好的 HTML错误页面,以提供给 Spring Boot 匹配并响应。