《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 21

《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 管理。valuebasePackages 的别名。
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 匹配并响应。

相关推荐
Sam_Deep_Thinking19 分钟前
聊聊开闭原则,以及它在Spring里的样子
java·后端·spring·程序员·开闭原则
xieliyu.26 分钟前
计算机网络‑IP 协议解析:核心特性总结
网络·笔记·网络协议·学习·tcp/ip·计算机网络
陈年老古董31 分钟前
PyTorch 实现 MNIST 手写数字识别学习笔记
笔记·python·深度学习·学习
SimonKing32 分钟前
开源神器 Navop:数据库+SSH+终端+AI,一个应用全搞定
java·后端·程序员
爱码猿35 分钟前
SpringBoot+MybatisPlus动态数据源
java·spring boot·后端
风123456789~36 分钟前
【架构设计】3.2 信息化系统的典型应用 3/6
笔记·系统架构设计
小义_36 分钟前
JDK 深度解析
java·linux·开发语言·python·面试
风123456789~1 小时前
【架构设计】第3章 信息系统基础知识 1/6
笔记·系统架构设计
奈斯先生Vector1 小时前
从“能调用”到“可运营”:AI Agent 进入多模型时代后的架构升级
java·javascript·数据库·人工智能·算法·架构·aigc