我正在参加「启航方案」

0. 从 Web 的参数接纳说起

咱们常用来获取 Web 参数的注解有以下三个:

注解 阐明
@RequestParam 获取 URL “?” 后所带着的参数,如:localhost:8080/user?id=1
@PathVariable 主要配合 RESTful 风格运用,能够获取 URL 途径上所包括的参数,如:localhost:8080/user/{id}
@RequestBody 用于接纳恳求体中的参数,常用于 application/json 类型的 POST 恳求

本文别离以这三个注解为动身点,以参数校验、反常处理为主线,将一些相关的琐碎知识点串联起来。

1. 从 @RequestParam 动身

1.1 required 校验非 null 引发的反常

@RequestParam 注解供给 required 特点来设置参数是否必需,默许值 true ,即无需特别注明 required 特点,在恳求参数缺失时,就会抛出反常。

@GetMapping(value = "user", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getUserInfo(@RequestParam String id) {
    // doSomething()
}

咱们用 Postman 来测试上述代码,当不传递参数 id 时,将得到如下呼应信息,状况码为 400 :

知识串联:Spring Boot 参数校验与异常处理

在服务日志中,咱们能够看到反常的提示信息:

知识串联:Spring Boot 参数校验与异常处理

1.2 反常提示信息 message 哪里去了?

部分小伙伴儿会遇到跟我相同的问题,反常的提示信息仅能在服务日志中看到,并没有包括在呼应体中,那么 message 哪里去了?

这是因为 Spring Boot 版别形成的,查阅了 Spring Boot 的 版别日志 ,2.5.x 起,默许的反常呼应信息中的 message 特点被移除了:

知识串联:Spring Boot 参数校验与异常处理

假如仍然期望反常呼应时显现具体的提示信息,则需求添加如下装备:

server:
  error: 
    include-message: always

有了上述装备,message 就回来了:

知识串联:Spring Boot 参数校验与异常处理

1.3 server.error.include- 还有其它装备么?

已然咱们能够通过装备在反常呼应体中添加 message ,那么还有什么其他可装备的信息么?

基于 Spring Boot 2.7.8 ,将反常呼应信息做如下整理:

特点名 特点阐明 固定 / 可装备 装备项及默许值
timestamp 反常产生时的时间 固定
status http 呼应状况码 固定
error 与状况码对应的反常原因 固定
path 反常产生时的恳求途径 固定
message 反常的提示信息 可装备 server.error.include-message = never
exception 反常的类名 可装备 server.error.include-exception = false
trace 反常盯梢仓库信息 可装备 server.error.include-stacktrace = never
errors BindingResult 中的过错信息 可装备 server.error.include-binding-errors = never

除了 server.error.include-exception 是布尔值外,其它三项装备可选值如下:

可选值 装备阐明
never 反常呼应体中不会包括对应的信息
always 反常呼应体中包括对应的信息
on-param 当恳求参数中包括相应的参数名(messagetraceerrors),且参数值不为 false 时,反常呼应体中将包括对应的信息

on-param 的作用如下图:

知识串联:Spring Boot 参数校验与异常处理

更多的细节与阐明,可阅读相关源码:
[spring-boot-x.x.x.jar] org.springframework.boot.web.servlet.error.DefaultErrorAttributes
[spring-boot-x.x.x.jar] org.springframework.boot.web.error.ErrorAttributeOptions
[spring-boot-autoconfigure-x.x.x.jar] org.springframework.boot.autoconfigure.web.ErrorProperties

1.4 如安在反常呼应体中添加自定义信息?

如安在上述 8 个特点的基础上,扩展自定义的信息呢?

  • 创立一个类,承继 org.springframework.boot.web.servlet.error.DefaultErrorAttributes (此处需求特别注意基类的途径,简略过错引用为 org.springframework.boot.web.reactive.error.DefaultErrorAttributes );

  • 通过 @Component 注解将类交托于 Spring 管理;

  • 重写 getErrorAttributes 办法,调用基类同名办法,在获取的 Map 成果集中进行自定义信息的扩展。

@Component
public class MyErrorAttributes extends DefaultErrorAttributes {
    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String,Object> map = super.getErrorAttributes(webRequest, options);
        map.put("system", "XXXX 系统");
        map.put("company", "XXXX 公司");
        return map;
    }
}
知识串联:Spring Boot 参数校验与异常处理

有点神奇是不是?为啥向 Spring 容器中加了个 Bean 就完结了?不会和原有的 DefaultErrorAttributes 抵触么?

咱们能够在源码( [spring-boot-autoconfigure-x.x.x.jar] org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration )中找到答案:

@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
public DefaultErrorAttributes errorAttributes() {
    return new DefaultErrorAttributes();
}

@ConditionalOnMissingBean 注解表名了仅当 Spring 在缺失 ErrorAttributes 类型实例的情况下,才会创立一个 DefaultErrorAttributes 实例。当咱们现已供给了一个承继于 DefaultErrorAttributes (该类完结了 ErrorAttributes 接口)的实例,默许的自然就不会创立了。

2. 从 @PathVariable 动身

2.1 利诱的 required 特点

@RequestParamrequired 能够协助咱们完结参数的非 null 校验,@PathVariable 注解相同供给了 required 特点(默许值也为 true ),咱们当然等待它能有相同的体现,但事实却并非如此:

(1)当 URL 中的某一级途径彻底作为参数的值时,不传递该参数,则会因为恳求途径匹配失利而回来 404 过错,而并非参数校验失利:

@GetMapping(value = "item/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getItemInfo1(@PathVariable String id) {
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

(2)当 URL 中的某一级途径,不仅仅由参数占位符组成,还包括一些其他的固定字符,此刻不传递该参数,参数会被初始化为空字符串, required 校验相同没有成功:

@GetMapping(value = "item/i_{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getItemInfo2(@PathVariable String id) {
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

2.2 手动判断非空并抛出反常

已然 @PathVariablerequired 没有办法帮咱们完结参数的校验,那咱们只能自行通过代码完结了。

关于字符串的非空校验,有非常多的办法,下面列举出几种笔者常用的办法,当判定参数为空时,则手动抛出反常:

if (id != null && id.trim().length() != 0) {
    throw new IllegalArgumentException("参数 {id} 不能为空。");
}
if (!org.springframework.util.StringUtils.hasText(id)) {
    throw new IllegalArgumentException("参数 {id} 不能为空。");
}
if (!org.apache.commons.lang.StringUtils.isNotBlank(id)) {
    throw new IllegalArgumentException("参数 {id} 不能为空。");
}
知识串联:Spring Boot 参数校验与异常处理

2.3 能否更优雅一点? => 运用断语

if...throw... 这样的代码,显得有些笨拙了,咱们能否有更为优雅一点的办法呢?Spring 的 断语(Assert) 能够帮到咱们。

所谓 “断语” ,就是断定某个实践的运行值和预想的值相同,若不相同则抛出反常。

先来看断语怎么简化了咱们的代码:

Assert.hasText(id, "参数 {id} 不能为空。");

查看 Assert 的源码,不难发现,if...throw... 这样的逻辑代码,Assert 帮咱们完结了,因而能够使咱们的代码愈加简洁。源码如下:

public static void hasText(@Nullable String text, String message) {
    if (!StringUtils.hasText(text)) {
        throw new IllegalArgumentException(message);
    }
}

Assert 常用的办法整理如下:

办法名(参数列表) 办法作用
isTrue(boolean, String / Supplier<String>) 逻辑断语,假如条件为假则抛出 IllegalArgumentException
state(boolean, String / Supplier<String>) isTrue ,但抛出的反常类型为 IllegalStateException
isNull(Object, String / Supplier<String>) 假定目标不为 null
notNull(Object, String / Supplier<String>) 假定目标为 null
isInstanceOf(Class<?>, Object, String / Supplier<String>) 假定目标实例为指定类型
isAssignable(Class<?>, Class<?>, String / Supplier<String>) 假定类型为指定类型的子类或接口完结
hasLength(String, String / Supplier<String>) 假定文本至少包括一个字符(可为空白字符)
hasText(String, String / Supplier<String>) 假定文本至少包括一个非空白字符
doesNotContain(String, String / Supplier<String>) 假定文本不包括指定的文本片段
notEmpty(Object[] / Collection<?> / Map<?, ?>, String / Supplier<String>) 假定数组、调集、Map 不为 null 且至少包括一个元素
noNullElements(Object[] / Collection<?>, String / Supplier<String>) 假定数组、调集本身不为 null ,且不包括为 null 的元素

3. 从 @RequestBody 动身

3.1 仍然先看看 required 特点

@RequestBody 相同供给 required 特点,默许值 true ,与 @RequestParam 相同,能够校验参数是否为 null :

@PostMapping(value = "user", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> addUser(@RequestBody UserDTO user) {
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

不过很显然此项校验没有太大的实践意义,因为 @RequestBody 常被咱们用于接纳前端的 JSON 数据并映射为后端的 Bean 目标,相比于校验 Bean 目标是否为 null ,咱们更为关注的是 Bean 目标中的某些特点是否为 null 。

3.2 引进校验结构

为了完结 Bean 目标内部特点的校验,咱们引进校验结构,添加如下依赖:

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

咱们能够看一下 spring-boot-starter-validation 的依赖关系:

知识串联:Spring Boot 参数校验与异常处理
  • jakarta.validation-api 是 Bean Validation 的标准;
  • hibernate-validator 是对 Bean Validation 标准的完结与扩展,咱们所运用的校验功用主要就是 hibernate-validator 在起作用。

3.3 供给了哪些束缚规矩?

从源码中咱们能够找到可运用的束缚注解:

知识串联:Spring Boot 参数校验与异常处理

Bean Validation 标准注解整理如下:

分类 注解 作用阐明
空值查看 @Null / @NotNull 只能 / 不能为 null
@NotEmpty 非 null ,且字符串和数组的 length 、Collection 和 Map 的 size 大于 0
@NotBlank 字符串不能为 null 且至少有一个非空字符
Boolean 查看 @AssertTrue / @AssertFalse 值有必要为 true / false
长度查看 @Size(min, max) 长度有必要在 min 和 max 之间,作用于字符串 、Collection 、Map 、数组
日期查看 @Past / @Future 有必要是一个曩昔的 / 将来的日期
@PastOrPresent / @FutureOrPresent 有必要是一个曩昔或当前的 / 将来或当前的日期
数值查看 @Min(value) / @Max(value) 有必要小于等于 / 大于等于指定数值
@DecimalMin(value) / @DecimalMax(value) 有必要小于等于 / 大于等于指定数值(支撑作用于字符串)
@Digits(integer, fraction) 有必要为小数,且整数部分精度不能超过 integer ,小数部分精度不能超过 fraction
@Positive / @Negative 有必要为正数 / 负数
@PositiveOrZero / @NegativeOrZero 有必要为正数或0 / 负数或0
其它查看 @Email 有必要是电子邮箱地址
@Pattern(regexp) 有必要契合正在表达式

将所需的束缚注解,加在 Bean 目标的特点上:

@NotBlank(message = "账号不能为空")
@Size(max = 30, min = 4, message = "账号长度在 4 ~ 30 之间")
private String account;

3.4 使校验收效:添加 @Valid 或 @Validated

为了让束缚收效,咱们还需求在 @RequestBody 所接纳的参数目标前,添加 @Valid@Validated 注解:

@PostMapping(value = "user", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> addUser(@RequestBody @Valid UserDTO user) {
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

能够看到,@Valid(或 @Validated )校验未通过期,会抛出 MethodArgumentNotValidException 反常,呼应状况码 400。

3.5 具体束缚提示信息的花式玩法

上图中,message 仅会奉告哪个 Bean 目标校验失利,以及有多少条束缚规矩没有校验通过,但并不会奉告更多细节。

针对具体的束缚提示信息,能够有以下几种玩法:

3.5.1 【玩法一】 include-binding-errors

  • @Valid(或 @Validated )注解参数目标
  • server.error.include-binding-errors 设置为 alwayson-param

这样就能够将具体的束缚提示信息通过 errors 回来。但这样需求前端额定对 errors 进行解析处理,并不友好。

知识串联:Spring Boot 参数校验与异常处理

3.5.2 【玩法二】 BindingResult

  • @Valid(或 @Validated )注解参数目标
  • 在所校验的参数目标后,紧跟一个 BindingResult 目标,用来接纳校验成果
  • 办法内部对 BindingResult 进行人工处理,能够遍历其间的提示信息进行拼接,作为办法的回来信息进行正常回来(即呼应状况码 200 );也能够粗暴的将第一条提示信息以反常的形式抛出
@PostMapping(value = "user", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> addUser(@RequestBody @Valid UserDTO user, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        throw new IllegalArgumentException(bindingResult.getAllErrors().get(0).getDefaultMessage());
    }
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

当添加了 BindingResult 目标后,校验失利时,程序将不再抛出反常,并持续履行。因而需求在办法内部对 BindingResult 人为干与。但很显然,这种办法会使代码变得愈加笨重。

3.5.3 【玩法三】反常拦截器

  • @Valid(或 @Validated )注解参数目标
  • 创立全局的拦截器类,运用 @ControllerAdvice 注解该类
  • 创立 MethodArgumentNotValidException 反常的拦截处理办法,运用 @ExceptionHandler 注解该办法
  • 通过办法参数 MethodArgumentNotValidException 能够获取到 BindingResult ,从而获取所有束缚提示信息进行遍历与拼接
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> methodArgumentNotValidExceptionHandler
            (HttpServletRequest request, MethodArgumentNotValidException e) {
        Map<String, Object> map = new LinkedHashMap<>();
        map.put("timestamp", System.currentTimeMillis());
        map.put("status", HttpStatus.BAD_REQUEST.value());
        map.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase());
        map.put("path", request.getRequestURI());
        map.put("exception", MethodArgumentNotValidException.class);
        BindingResult result = e.getBindingResult();
        String message = result.getFieldErrors()
				.stream()
				.map(FieldError::getDefaultMessage)
				.collect(Collectors.joining(";"));
        map.put("message", message);
        return map;
    }
}
知识串联:Spring Boot 参数校验与异常处理

运用反常拦截器能够对前端回来一致、标准的反常信息,如上面的代码,将 MethodArgumentNotValidException 一致处理为 BAD_REQUEST ,所回来的字段项也依照 Spring Boot 标准的反常信息进行模拟(当然,你也彻底能够使其以 200 状况码回来,格局上也能够恣意发挥)。

3.5.4 【玩法四】 “奇技淫巧”

  • @Valid(不能够用 @Validated 代替)注解参数目标
  • 在所校验的参数目标后,紧跟一个 BindingResult 目标,用来接纳校验成果
  • 办法内部对 BindingResult 不做任何处理
  • Controller 类上运用 @Validated 注解(不能够用 @Valid 代替)
@RestController
@Validated
public class DemoController {
    @PostMapping(value = "user", produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, Object> addUser(@RequestBody @Valid UserDTO user, BindingResult bindingResult) {
        // doSomething()
    }
}
知识串联:Spring Boot 参数校验与异常处理

校验未通过期,会抛出 ConstraintViolationException 反常,呼应状况码 500 ,由校验结构完结多条束缚提示信息的拼接,并通过 message 回来。

3.6 @Valid 与 @Validated 有啥区别?

@Valid 是标准的 JSR-303 标准( Bean Validation 标准)注解,而 @Validated 是由 Spring 供给的关于 JSR-303 的一个变种,供给了分组功用(能够根据不同的分组选用不同的校验机制)。

@Valid 能够用在办法、字段、结构器和参数上, @Validated 只能用在类型、办法、参数上。

在大多数相对简略的校验场景中,这两个注解并没有太大的不同。

3.7 嵌套校验怎么完结?

当 Bean 目标中嵌套了 Bean 目标,需求在内嵌的 Bean 特点上添加 @Valid 注解(只能是 @Valid@Validated 是无法作用在字段上的)。

public class UserDTO {
    @NotBlank(message = "账号不能为空")
    @Size(max = 30, min = 2, message = "账号长度在 2 ~ 30 之间")
    private String account;
    @Valid
    private CompanyDTO company;
}
public class CompanyDTO {
    @NotBlank(message = "单位名称不能为空")
    @Length(max = 30, min = 4, message = "单位名称长度在 4 ~ 30 之间")
    private String name;
}
知识串联:Spring Boot 参数校验与异常处理

3.8 List 校验怎么完结?

@Valid 作用的目标是 Java Bean ,而 List<E> 并不是 Java Bean ,因而直接运用 @Valid 去注解 List<E> 类型的参数,无法起到咱们预想的校验作用。

有三种处理办法:

3.8.1 【办法一】又见 “奇技淫巧”

  • @Valid(不能够用 @Validated 代替)注解 List<E> 目标
  • List<E> 目标后,能够跟一个 BindingResult 目标,也能够不跟,并没有什么影响
  • Controller 类上运用 @Validated 注解(不能够用 @Valid 代替)
@RestController
@Validated
public class DemoController {
    @PostMapping(value = "users", produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, Object> addUsers(@RequestBody @Valid List<UserDTO> list) {
        // doSomething()
    }
}
知识串联:Spring Boot 参数校验与异常处理

校验未通过期,由校验结构完结多条束缚提示信息的拼接,并通过 message 回来,但 message 的拼接格局无法改动,且不会走一致的反常拦截器(拦截器拦截的是 MethodArgumentNotValidException ,而通过结构的干与后,反常转变为 ConstraintViolationException )。

3.8.2 【办法二】封装 ListWrapper

  • List<E> 作为成员特点,封装进 Java Bean 中,并运用 @Valid 注解该特点
  • 封装类供给结构函数
  • Controller 运用封装类接纳前端入参,并运用 @Valid(或 @Validated )注解该参数目标
public class ListWrapper<E> {
    @Valid
    private List<E> list;
    public ListWrapper() {
        super();
        this.list = new ArrayList<>();
    }
    public ListWrapper(List<E> list) {
        super();
        this.list = list;
    }
    // getter、setter
}
@PostMapping(value = "users", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> addUsers(@RequestBody @Validated ListWrapper<UserDTO> listWrapper) {
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

需求注意,因为对 List<E> 进行了封装,因而前端传参时也需求多出一层包装。

选用封装 ListWrapper 这种办法,结构仍然抛出 MethodArgumentNotValidException 反常,因而能够被一致的反常拦截器拦截处理。只不过处理逻辑需求进一步细化,否则在多条数据校验不通过期,按原有的处理逻辑所生成的 message 就会像图中那样,未指明过错对应的数据,非常的不友好。

3.8.3 【办法三】自定义 List

  • 自定义一个 ValidList<E> ,完结 List<E> 接口
  • List<E> 作为成员特点,封装进 Java Bean 中,并运用 @Valid 注解该特点
  • 封装类供给结构函数
  • 运用成员特点所对应的办法,对 List<E> 接口的办法逐个进行重写完结
  • Controller 运用封装类接纳前端入参,并运用 @Valid(或 @Validated )注解该参数目标
public class ValidList<E> implements List<E> {
    @Valid
    private List<E> list;
    public ValidList() {
        super();
        this.list = new ArrayList<>();
    }
    public ValidList(@Valid List<E> list) {
        super();
        this.list = list;
    }
    @Override
    public boolean add(E arg0) {
        return this.list.add(arg0);
    }
    // 此处省略其它需求重写的办法......
}
@PostMapping(value = "users", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> addUsers(@RequestBody @Validated ValidList<UserDTO> list) {
    // doSomething()
}
知识串联:Spring Boot 参数校验与异常处理

自定义的 List ,即是 Java Bean ,又具有 List 的特性,相比上一种封装的办法显得更为优雅一些,前端传参时也不需求额定的包装了。

不过运用自定义 List 这种办法的缺陷也很明显,就是无法取得具体的校验束缚信息,体现在以下两点:

  • 在参数列表中添加 BindingResult ,结构仍抛出反常,无法进入办法内部,得到 BindingResult
  • 结构抛出的反常类型并不是 MethodArgumentNotValidException ,后端控制台实践类型为 NotReadablePropertyException ,前端呼应所显现反常类型为 IllegalStateException ,不管哪一种,都不包括 BindingResult 信息

由此,若需求具体的校验束缚信息,则不能选用自定义 List 这种办法。