后端接口校验参数异常统一处理
Web Api 开发不可避免的会遇到参数校验,如何统一处理是需要解决的问题。

2021-06-01 补充:捕捉文件上传大小限制的异常和参数 key 都不传的情况。

前言

工作的项目中,先前的同事其实已经实现了参数校验异常统一处理。部分实现代码如下。

//全局异常处理类
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = BaseException.class)
    public S2VJSONResult baseHandler(BaseException e) {
        return S2VJSONResult.msg(false, 400, e.getMessage());
    }
//接口
@PostMapping("/transfer/company")
S2VJSONResult transferCompany(@Validated @RequestBody AdminTransferCompanyVO vo,
                              BindingResult bindingResult) {

    if (bindingResult.hasErrors()) {
        return S2VJSONResult.msg(false, 400, bindingResult.getFieldError().getDefaultMessage());
    }
//参数实体类
public class AdminTransferCompanyVO {

    @NotNull(message = "客户 ID 不能为空")
    private List<Integer> companyIds;
    @NotNull(message = "目标客户经理 ID 不能为空")
    private Integer targetCustomerId;
    @NotNull(message = "管理员 ID 不能为空")
    private Integer operationCustomerId;
//统一返回响应
public class S2VJSONResult<T> {

    private Integer code;//状态码
    private Boolean isSuccess;//状态
    private String message;//消息
    private T data;//数据对象

    public static <T> S2VJSONResult<T> all(Boolean success, Integer code,String message,T data) {
        return new S2VJSONResult<>(success, code, message,  data);
    }

    public static <T> S2VJSONResult<T> msg(Boolean success, Integer code,String message) {
        return new S2VJSONResult<>(success, code, message);
    }

整理了一下其思路,大概如下:

  1. 利用 @RestControllerAdvice 全局处理异常
  2. Spring MVC 会将验证结果放入 BindResult
  3. 封装报错信息返回统一错误响应

我个人很懒,感觉有些地方还是重复代码了,不够优雅,比如 BindingResult 每个接口都要加,既然是异常应该就能够捕捉到,为什么不全局处理?验证的 message 每个字段都要写,可否在不写的情况下,返回特定模板的报错信息?

全局处理

要解决我的问题,思路也很清晰,只要知道校验失败具体抛出的是什么异常,获取异常中有用的的报错信息,然后利用 @RestControllerAdvice 全局处理一下就好了。

这里遇到个坑,同事写的全局处理类继承了 ResponseEntityExceptionHandler,而我要特定捕捉的异常都在这里面,所以我一开始的时候,老是报重复处理异常的错误。。。

1. 处理 RequestParam 和 PathVariable 方式

Controller 层代码示例如下,将需要的约束添加到 @RequestParam 注解的前面,此时需要在类上添加注解 @Validated 才能实现验证。

@Controller
@RequestMapping("/report1")
@ResponseBody
@Validated
public class TestController {

    @PostMapping("/test1")
    S2VJSONResult<String> test1(@Max(3) @RequestParam("id") Integer id){
        return S2VJSONResult.all(true, 200, "success", JSON.toJSONString(id));
    }

    @PostMapping("/test2/{id}")
    S2VJSONResult<String> test2(@Max(value = 3, message = "别瞎填!") @PathVariable("id") Integer id){
        return S2VJSONResult.all(true, 200, "success", JSON.toJSONString(id));
    }

这种情况会抛出 ConstraintViolationException 异常。Spring 会处理这种异常并抛出报错信息,

{
    "timestamp": "2021-02-02 11:34:45",
    "status": 500,
    "error": "Internal Server Error",
    "message": "test1.id: 最大不能超过3",
    // "message": "test2.id: 别瞎填!",
    "path": "/pcg/report1/test1"
}

可以看到 message 字段中有较为完整的报错信息,可以直接拿来用。统一异常处理代码如下,

import javax.validation.ConstraintViolationException;
/**
  * 处理 @RequestParam @PathVariable 参数验证失败
  *
  * @param e
  * @return S2VJSONResult<T>
  * @Author zrr
  * @Date 2021/2/2 10:48
  * @throws
  */
@ExceptionHandler(value = ConstraintViolationException.class)
public <T> S2VJSONResult<T> handlerConstraintViolationException(ConstraintViolationException e) {
    return S2VJSONResult.msg(false, 400, "参数验证未通过(" + e.getMessage() + ")");
}

另外,用户也存在参数 key 都不传的情况,这个时候的异常也需要捕捉。

import org.springframework.web.bind.MissingServletRequestParameterException;
    /**
     * 处理 @RequestParam 参数验证失败(整个不传)
     * @param e
     * @param <T>
     * @return
     */
    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    public <T> S2VJSONResult<T> handlerMissingServletRequestParameterException(MissingServletRequestParameterException e) {
        return S2VJSONResult.msg(false, 400, "参数验证未通过(" + e.getMessage() + ")");
    }

封装报错信息,统一返回响应的结果如下,

{
    "code": 400,
    "isSuccess": false,
    // "message": "参数验证未通过(test1.id: 最大不能超过3)",
    "message": "参数验证未通过(test2.id: 别瞎填!)",
    "data": null
}

基本实现了我想要的结果,不需要通过 BindingResult,只需要添加注解就可以校验参数,并且不写 message 也可以返回较为清楚的报错信息。

但是这只是传参的一种情况,其他的情况抛出的异常还不一样,需要再次处理。

2. 处理实体类接收

接口传入很多参数的时候,通常代码会用实体类接收。Controller 层代码示例如下,这种情况无需在类上添加注解 @Validated

    @PostMapping("/test3")
    S2VJSONResult<String> test3(@Validated ReportVO vo){
        return S2VJSONResult.all(true, 200, "success", JSON.toJSONString(vo));
    }

实体类添加相应的约束,


import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.io.Serializable;

public class ReportVO implements Serializable {

    @NotNull
    private Integer cgeId;

    @NotEmpty(message = "baseinfoSqrxm 不能为空")
    private String baseinfoSqrxm;
}

这种情况会抛出 BindException 异常。Spring 仍然会处理这种异常并抛出报错信息,

{
    "timestamp": "2021-02-02 13:58:18",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
            "codes": [
                "NotNull.reportVO.cgeId",
                "NotNull.cgeId",
                "NotNull.java.lang.Integer",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "reportVO.cgeId",
                        "cgeId"
                    ],
                    "arguments": null,
                    "defaultMessage": "cgeId",
                    "code": "cgeId"
                }
            ],
            "defaultMessage": "不能为null",
            "objectName": "reportVO",
            "field": "cgeId",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        }
    ],
    "message": "Validation failed for object='reportVO'. Error count: 1",
    "path": "/pcg/report1/test3"
}

可以看到 defaultMessage 中有相应的报错信息,但是不够完整,可以将字段名 field 一起拼接后作为报错信息,统一异常处理代码如下,

import org.springframework.validation.BindException;
/**
  * 处理实体校验失败(multipart/form-data、x-www-form-urlencoded)
  *
  * @param e
  * @return S2VJSONResult<T>
  * @Author zrr
  * @Date 2021/2/2 10:50
  * @throws
  */
@ExceptionHandler(value = BindException.class)
public <T> S2VJSONResult<T> handlerBindException(BindException e) {
    FieldError fieldError = e.getBindingResult().getFieldError();
    assert fieldError != null;
    String message = MessageFormat.format("参数 {0} 有误,{1}", fieldError.getField(), fieldError.getDefaultMessage());
    return S2VJSONResult.msg(false, 400, message);
}

其中,e.getBindingResult() 中会有所有的参数报错信息,对我来说感觉一个就够了,所以我只取了一个,统一错误响应后的结果为,

{
    "code": 400,
    "isSuccess": false,
    "message": "参数 baseinfoSqrxm 有误,baseinfoSqrxm 不能为空",
    // "message": "参数 cgeId 有误,不能为null",
    "data": null
}

3. 处理 RequestBody 情况

RequestBody 情况和上面很类似,但抛出的异常不一样,仍然需要单独处理。Controller 层代码示例如下,

@PostMapping("/test4")
S2VJSONResult<String> test4(@Validated @RequestBody ReportVO vo){
    return S2VJSONResult.ok(JSON.toJSONString(vo));
}

这种情况会抛出 MethodArgumentNotValidException 异常。默认报错消息同 BindException,处理方式基本一致。

import org.springframework.web.bind.MethodArgumentNotValidException;
/**
  * 处理 @RequestBody 参数验证失败(application/json)
  *
  * @param e
  * @return S2VJSONResult<T>
  * @Author zrr
  * @Date 2021/2/2 10:49
  * @throws
  */
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public <T> S2VJSONResult<T> handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    FieldError fieldError = e.getBindingResult().getFieldError();
    assert fieldError != null;
    String message = MessageFormat.format("参数 {0} 有误,{1}", fieldError.getField(), fieldError.getDefaultMessage());
    return S2VJSONResult.msg(false, 400, message);
}

这三种情况基本涵盖了传参的所有方式,后面若是有遗漏的情况,也是同样的原理,再加上就好了。

4. 处理集合

参数是集合如 list 的时候,@Validated 失效,但 @Valid 可以支持。

import javax.validation.Valid;

public S2VJSONResult<Object> batchUpdate(@RequestBody List<@Valid ProductModuleVO> list){
    productModuleService.batchUpdate(list);
    return S2VJSONResult.ok();
}

此时,抛出的异常为 ConstraintViolationException,和第 1 种情况相同。

5. 处理文件上传大小异常

可能由于没有继承 ResponseEntityExceptionHandler,若是项目配置了文件上传大小限制,当超出时,系统没有捕捉异常,需要手动捕捉。

spring:
  servlet:
    multipart:
      max-file-size: 10MB
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.beans.factory.annotation.Value;

@Value("${spring.servlet.multipart.max-file-size}")
private String maxFileSize;

/**
    * 处理文件上传大小限制
    *
    * @param e
    * @return com...entity.S2VJSONResult<T>
    * @Author zrr
    * @Date 2021/5/6 11:48
    * @throws
    */
@ExceptionHandler(value = MaxUploadSizeExceededException.class)
public <T> S2VJSONResult<T> handlerMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
    logger.error("catch MaxUploadSizeExceededException {}", e.getMessage());
    return S2VJSONResult.msg(false, 400, "文件大小超出限制(" + maxFileSize + ")");
}

校验失败立即返回

上述三种默认情况都是校验全部字段后再返回报错信息,可以通过配置实现当一个参数校验有误时立即返回,提高效率。

import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;

/**
 * valid
 *
 * @author: zrr
 * @date: 2021/2/1 16:30
 */
@Configuration
public class ValidConfig {
    @Bean
    public Validator validator() {
        ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class)
                .configure()
                .failFast(true)  //立即返回
                .buildValidatorFactory();
        return validatorFactory.getValidator();
    }
}

Validated 验证组

接口通常有新增和更新,它们字段一致但是约束可能不同,若想要利用同一个实体类,可以通过组的方式实现共用。注意 @Valid 不支持组只有 @Validated 可以。

首先新增两个接口组,

import javax.validation.groups.Default;
public interface ValidGroupCreate extends Default {
}
import javax.validation.groups.Default;
public interface ValidGroupUpdate extends Default {
}

这里我继承了 Default.class 默认分组,可以实现参数没有标明组的情况时,也可以使用组。可以减少一些代码量。

实体类添加约束如下:

public class ReportVO implements Serializable {

    @NotNull
    private Integer cgeId;

    @NotEmpty(groups = ValidGroupCreate.class)
    private String baseinfoSqrxm;

    @NotEmpty(groups = ValidGroupUpdate.class)
    private String baseinfoSfzhm;

    @NotEmpty(groups = {ValidGroupCreate.class, ValidGroupUpdate.class})
    private String baseinfoHyzk;

再然后 Controller 层指定组即可,不填的话则是默认分组,

@PostMapping("/test3")
S2VJSONResult<String> test3(@Validated(ValidGroupUpdate.class) ReportVO vo){
    return S2VJSONResult.all(true, 200, "success", JSON.toJSONString(vo));
}

注解用法

在此列举一些常用的校验注解,方便查看。

  • @NotNull 验证带注释的属性值不为 null。
  • @AssertTrue 验证带注释的属性值为 true。
  • @Size 验证带注释的属性值的大小在属性 min 和 max 之间;可以应用于 String, Collection, Map和数组属性。
  • @Min 验证带注释的属性的值不小于 value 属性。
  • @Max 验证带注释的属性的值不大于 value 属性。
  • @Email 验证带注释的属性是有效的电子邮件地址。
  • @NotEmpty 验证该属性不为 null 或为空;可以应用于 String, Collection, Map或Array值。
  • @NotBlank 只能应用于文本值,并验证该属性不是null还是空白。
  • @Positive@PositiveOrZero 适用于数值,并验证它们严格为正,或包括 0 在内为正。
  • @Negative@NegativeOrZero 适用于数值,并验证它们严格为负数,或为负数(包括0)。
  • @Past@PastOrPresent 验证日期值是过去还是现在(包括现在);可以应用于日期类型,包括 Java 8中添加的日期类型。
  • @Future@FutureOrPresent 验证日期值是将来的日期还是将来的日期(包括现在)。
  • @Pattern 字段或属性的值必须与 regexp 元素中定义的正则表达式匹配。

后续

可以创建自定义约束,目前项目大多用到就是非空判断,暂时先不弄

参考


Last modified on 2021-02-02