Spring boot使用Javax.validation和ControllerAdvice来进行参数校验

1,910 阅读2分钟

对于写Java的同学来说,参数校验是繁琐且重复性很高的代码。很多时候我们的业务代码编写之前先要进行很多的参数校验,浪费了大量的时间和精力。而java中其实已经内置了参数校验的工具,本篇文章主要介绍如何使用Javax.validation来进行参数校验。

@validated注解

@validated是一套帮助我们继续对传输的参数进行数据校验的注解,通过配置Validation可以很轻松的完成对数据的约束。看到一下注解的源码,我们可以看到@Validated注解可以作用在类、方法和参数上。

@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
 Class<?>[] value() default {};
}

废话不多说,我们直接举个例子来看看@validated到底好不好用。实际项目中很常见的应用是分页查询的接口,通常分页查询至少需要当前页和页大小这两个字段。通常我们会把分页请求需要的参数封装成一个PageQuery。一个常见的分页参数类,在很多接口中需要使用。我们就可以给这样的参数加上@Validated注解。表示此类开启参数校验

public Result<Page<Map<String, Object>>> getPage(@Validated PageQuery pageQuery) {  
 // 设置页大小,当前页  
    Page<Map<String, Object>> page = new Page<>(pageQuery.getCurrentPage(), pageQuery.getPageSize());  
    page.setRecords(service.findPage(page, pageQuery));  
    return this.responseBody(Result.ResponseEnum.GET_SUCCESS, page);  
}

在类的字段上,我们定义校验的规则和返回的错误提示。@validated中所有的校验注解,可以参考下面的表格。

限制说明
@Null限制只能为null
@NotNull限制必须不为null
@AssertFalse限制必须为false
@AssertTrue限制必须为true
@DecimalMax(value)限制必须为一个不大于指定值的数字
@DecimalMin(value)限制必须为一个不小于指定值的数字
@Digits(integer,fraction)限制必须为一个小数,且整数部分的位数不能超过integer,小数部分的位数不能超过fraction
@Max(value)限制必须为一个不大于指定值的数字
@Min(value)限制必须为一个不小于指定值的数字
@Past限制必须是一个过去的日期
@Future限制必须是一个将来的日期
@Pattern(value)限制必须符合指定的正则表达式
@Size(max,min)限制字符长度必须在min到max之间
@NotEmpty验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)
@NotBlank验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的空格
@Email验证注解的元素值是Email,也可以通过正则表达式和flag指定自定义的email格式

对于页大小,我们限制为非空且不能小于1;对于当前页我们限制为非空。

public class PageQuery implements Serializable {  
 private static final long serialVersionUID = 1L;  
  
/**  
 * 页大小  
 */  
 @NotNull(message = "页大小不能为空")  
 @Min(message = "页大小不能小于1", value = 1)  
 Integer pageSize;  
  
/**  
 * 当前页  
 */  
 @NotNull(message = "当前页不能为空")  
 Integer currentPage;  
 

我们用一个明显校验不通过的参数来请求下这个接口,看看会返回什么。我在请求参数中不传递pageSize参数,然后发送请求。

{
    "timestamp": "2021-12-16T03:09:36.238+0000",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
            "codes": [
                "NotNull.pageQuery.currentPage",
                "NotNull.currentPage",
                "NotNull.java.lang.Integer",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "pageQuery.currentPage",
                        "currentPage"
                    ],
                    "arguments": null,
                    "defaultMessage": "currentPage",
                    "code": "currentPage"
                }
            ],
            "defaultMessage": "当前页不能为空",
            "objectName": "pageQuery",
            "field": "currentPage",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        },
        {
            "codes": [
                "NotNull.pageQuery.env",
                "NotNull.env",
                "NotNull.java.lang.String",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "pageQuery.env",
                        "env"
                    ],
                    "arguments": null,
                    "defaultMessage": "env",
                    "code": "env"
                }
            ],
            "defaultMessage": "设备所属环境信息不能为空",
            "objectName": "pageQuery",
            "field": "env",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        },
        {
            "codes": [
                "NotNull.pageQuery.pageSize",
                "NotNull.pageSize",
                "NotNull.java.lang.Integer",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "pageQuery.pageSize",
                        "pageSize"
                    ],
                    "arguments": null,
                    "defaultMessage": "pageSize",
                    "code": "pageSize"
                }
            ],
            "defaultMessage": "页大小不能为空",
            "objectName": "pageQuery",
            "field": "pageSize",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        }
    ],
    "message": "Validation failed for object='pageQuery'. Error count: 3",
    "path": "/localLoadBalance/approval/getApproval"
}

可以看到,返回的结果中包含了我们之前预设的校验提示和内容。不过到这里我们的任务还没有结束,实际项目中,我们不允许接口返回这样的类型。大多数情况下,我们希望接口的返回结果有通用的模板格式。而上面那样的返回方式需要前端做大量的解析,而且也不符合后端接口的规范。因此我们希望能有一个全局的处理器,来解析@validated抛出的异常。

使用全局异常处理类来进行统一的异常处理

在Spring boot项目中,我们可以使用@ControllerAdvice注解来进行全局的异常处理,当然@ControllerAdvice的用处不止是异常处理,还可以实现统一的参数绑定和数据的预处理。详情可以参考# SpringMVC 中 @ControllerAdvice 注解的三种使用场景!

首先我们新增一个handler,当然你也可以指定一个包来扫描包下的所有controller。如@ControllerAdvice(basePackages="com.test.controller"),然后我们使用@ExceptionHandler来进行异常的处理。

此处需要说明的是,我们是针对@validated进行的异常的处理,因此我们希望异常校验类只拦截@validated注解抛出的异常。所以在本方法中,我只让@ExceptionHandler拦截了BindException。其次,针对参数校验出现多个异常的情况,我们把多个错误信息通过逗号分隔开来。

@ControllerAdvice  
public class GlobalHandler {  
 private final Logger logger = LoggerFactory.getLogger(GlobalHandler.class);  
  
    /**  
 * 全局处理所有使用了@validation校验参数的controller  
 * @param e 捕获到validation抛出异常  
 * @return 返回参数中所有的校验错误,以,分隔不用的错误信息  
 */  
 @ResponseBody  
 @ExceptionHandler(BindException.class)  
 public Result<Void> exceptionHandler(BindException e) {  
 String errors=e.getBindingResult().getAllErrors().stream()  
 .map(ObjectError::getDefaultMessage)  
 .collect(Collectors.joining(","));  
        logger.error("Request params error,caught by global exception handler,{}",errors);  
        return Result.<Void>toBuilder()  
 .code(0)  
 .msg(errors)  
 .builder();  
    }  
}

再次准备一个含有错误参数的请求,这次我们不传currentPage,pageSize的值为-1。我们看看会返回什么。

{
 "code": 0,
 "msg": "当前页不能为空,页大小不能小于1",
 "data": null
}

可以看到,返回的结果符合我们的预期。

在获取错误信息的地方我们看到有针对BindException的异常信息解析,涉及了多个.操作。有经验的老鸟可能觉得这里容易出现空指针异常。不过此处你大可放心,BindException中的BindingResult是绝对不会为null的。我们看下源码,可以看到内部是用断言来保证结果不为空的。

	/**
	 * Create a new BindException instance for a BindingResult.
	 * @param bindingResult the BindingResult instance to wrap
	 */
	public BindException(BindingResult bindingResult) {
		Assert.notNull(bindingResult, "BindingResult must not be null");
		this.bindingResult = bindingResult;
	}

参考文章

SpringMVC 中 @ControllerAdvice 注解的三种使用场景! - 江南一点雨 - 博客园 (cnblogs.com)

javax.validation 参数验证 - 不朽丶 - 博客园 (cnblogs.com)

@Validated详解 - yuxinkuan - 博客园 (cnblogs.com)