如何在SpringBoot中做参数校验

前言

参数校验是开发中非常重要,也是非常繁琐的一件事情,不得不做,但是确无聊至极,而且和业务代码混杂在一起,维护起来十分麻烦。
JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation,用于对 Java Bean 中的字段的值进行验证。Hibernate Validator 则是Hibdernate提供的一种对该规范的实现。
使用spring-boot-starter-web的话,已经包含了hibernate-validator包的依赖,给开发人员提供了极大的便利。

pom.xml

<dependencies>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-webartifactId>
    dependency>
dependencies>

校验规则

javax.validation.constraints规范中定义了很多校验规则:

标签 说明
@AssertFalse 只能为false
@AssertTrue 只能为true
@DecimalMax 必须小于或等于{value}
@DecimalMin 必须大于或等于{value}
@Digits 数字的值超出了允许范围(只允许在{integer}位整数和{fraction}位小数范围内)
@Email 一个合法的电子邮件地址
@Future 需要是一个将来的时间
@FutureOrPresent 需要是一个将来或现在的时间
@Max 最大不能超过{value}
@Min 最小不能小于{value}
@Negative 必须是负数
@NegativeOrZero 必须是负数或零
@NotBlank 不能为空字符
@NotEmpty 不能为空元素
@NotNull 不能为null
@Null 必须为null
@Past 需要是一个过去的时间
@PastOrPresent 需要是一个过去或现在的时间
@Pattern 需要匹配正则表达式"{regexp}"
@Positive 必须是正数
@PositiveOrZero 必须是正数或零
@Size 元素个数必须在{min}和{max}之间

除了官方定义的规则,hibernate-validator自身也特有一套校验规则:

标签 说明
@CreditCardNumber 信用卡号码
@Currency 货币格式
@EAN 条形码
@Length 字符长度需要在{min}和{max}之间
@ParametersScriptAssert 执行脚本表达式"{script}"返回期望结果
@Range 需要在{min}和{max}之间
@SafeHtml 安全的HTML内容
@ScriptAssert 可以用一些脚本来进行校验
@URL 合法的URL
@DurationMax 指定最大时间长度
@DurationMin 指定最小时间长度

那么,规则是有了,怎么用呢?下面开始进入使用方法的讲解。

Controller层参数校验

应该大部分的参数校验都在对web请求过来的参数进行判断,在进入业务之前,这里是参数校验的重中之重。

VO类入参校验

如果是接口开发的话,一般来说大多数是以json格式接收的,然后用一个VO对象进行包装映射。
还有一种常见的是表单提交,同样也用一个VO对象进行参数包装。
它们的写法区别很简单,前者需要用@RequestBody修饰,后者不需要,而VO类的写法是没有任何区别的。

DemoVO.java

import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class DemoVO {
     
    @NotBlank(message="ID不能为空")
    private String id;
    
    @NotNull(message="Count不能为空")
    private Integer count;
}

DemoController.java

import javax.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("demo")
public class DemoController {
     

    @PostMapping("test")
    public void test(@RequestBody @Valid DemoVO demo) {
     
    }
}

/demo/test POST 一个空的JSON
则返回以下错误提示:

{
     
    "timestamp": "2021-03-02T08:56:06.301+0000",
    "status": 400,
    "error": "Bad Request",
    "errors": [
        {
     
            "codes": [
                "NotNull.demoVO.count",
                "NotNull.count",
                "NotNull.java.lang.Integer",
                "NotNull"
            ],
            "arguments": [
                {
     
                    "codes": [
                        "demoVO.count",
                        "count"
                    ],
                    "arguments": null,
                    "defaultMessage": "count",
                    "code": "count"
                }
            ],
            "defaultMessage": "Count不能为空",
            "objectName": "demoVO",
            "field": "count",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        },
        {
     
            "codes": [
                "NotBlank.demoVO.id",
                "NotBlank.id",
                "NotBlank.java.lang.String",
                "NotBlank"
            ],
            "arguments": [
                {
     
                    "codes": [
                        "demoVO.id",
                        "id"
                    ],
                    "arguments": null,
                    "defaultMessage": "id",
                    "code": "id"
                }
            ],
            "defaultMessage": "ID不能为空",
            "objectName": "demoVO",
            "field": "id",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotBlank"
        }
    ],
    "message": "Validation failed for object='demoVO'. Error count: 2",
    "path": "/demo/test"
}

错误处理

针对以上不友好的错误提示,有两种方法处理:

内部处理

在方法中传入BindingResult 对象,在方法内部处理校验错误:

    @PostMapping("test")
    public void test(@RequestBody @Valid DemoVO demo, BindingResult result) {
     
        if (result.hasErrors()) {
     
            for (ObjectError error : result.getAllErrors()) {
     
                System.out.println(error.getDefaultMessage());
            }
        }
    }

全局处理

参数的校验失败会引发MethodArgumentNotValidException异常,所以可以使用@RestControllerAdvice来全局捕获这个异常,然后返回统一的格式。

Violation.java (定义错误描述类)

@Data
@AllArgsConstructor
public class Violation {
     
    private String fieldName;
    private String message;
}

ParamValidateHandlingControllerAdvice.java (定义通用异常拦截)

@RestControllerAdvice
public class ParamValidateHandlingControllerAdvice {
     
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    List<Violation> onMethodArgumentNotValidException(MethodArgumentNotValidException e) {
     
        List<Violation> violations = new ArrayList<>();
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
     
            violations.add(new Violation(fieldError.getField(), fieldError.getDefaultMessage()));
        }
        return violations;
    }
}

当再次请求一个空的json对象:{}时,将得到以下结果(http状态码为400)

[
    {
     
        "fieldName": "count",
        "message": "Count不能为空"
    },
    {
     
        "fieldName": "id",
        "message": "ID不能为空"
    }
]

参数校验分组

在实际开发中,不同的接口如果用相同的VO接收参数,但是校验的逻辑不同的时候,就可以用参数分组来实现:
GroupDemoVO.java

@Data
public class GroupDemoVO {
     
    interface Update {
     }
	
    @NotBlank(message = "ID不能为空", groups = {
     Update.class})
    private String id;
    
    @NotNull(message = "Count不能为空")
    private Integer count;
}

GroupDemoController.java

import org.springframework.validation.annotation.Validated;
import javax.validation.groups.Default;

@RestController
@RequestMapping("demo")
public class GroupDemoController {
     
	
    @PostMapping("create")
    public void create(@RequestBody @Validated GroupDemoVO demo) {
     
    }
    @PostMapping("update")
    public void update(@RequestBody @Validated({
     Default.class, GroupDemoVO.Update.class} GroupDemoVO demo) {
     
    }
}

上面的代码表示调用create接口时,只使用未分组的校验规则:

[
    {
     
        "fieldName": "count",
        "message": "Count不能为空"
    }
]

而调用update时,同时使用未分组 + Update分组的所有校验规则:

[
    {
     
        "fieldName": "count",
        "message": "Count不能为空"
    },
    {
     
        "fieldName": "id",
        "message": "ID不能为空"
    }
]

需要注意的是,方法参数列表中的@Valid要换成@Validated, 前者为javax校验框架提供,后者为Spring提供,它是前者的扩展,支持 group分组校验的写法,所以为了校验统一,尽量使用 @Validated

嵌套校验

上面的例子中,需校验的字段全部封装在一个类中,层级都是1层,那如果类中有需要验证的其他类呢,这就用到了嵌套校验,需要在嵌套的那个对象声明上加上@Valid标签:

DemoNestSubVO.java

@Data
public static class DemoNestSubVO {
     
    @NotBlank
    private String id;

    @NotNull
    private Integer count;
}

DemoNestParentVO.java

@Data
public class DemoNestParentVO {
     
    @Valid
    private DemoNestSubVO sub;
}

GroupDemoController.java

@RestController
@RequestMapping("demo")
public class GroupDemoController {
     
	
    @PostMapping("nestParent")
    public void nestParent(@RequestBody @Validated DemoNestParentVO parentVO) {
     
    }
}

/demo/nestParent 发送一个JSON请求:{"sub": {}},将得到以下结果:

[
    {
     
        "fieldName": "sub.count",
        "message": "不能为null"
    },
    {
     
        "fieldName": "sub.id",
        "message": "不能为空"
    }
]

嵌套校验,也支持List等集合类型的包装:
DemoNestParentVO.java

@Data
public class DemoNestListVO {
     
    @Valid
    private List<DemoNestSubVO> subList;
}

GroupDemoController.java


@RestController
@RequestMapping("demo")
public class GroupDemoController {
     
	
    @PostMapping("nestList")
    public void nestList(@RequestBody @Validated DemoNestListVO listVO) {
     
    }
}

/demo/nestList 发送一个JSON请求:{“subList”: [{“count”:1}, {“id”:“1”}]},subList有两个对象,一个只有id,一个只有count,将得到以下结果:

[
    {
     
        "fieldName": "subList[1].count",
        "message": "不能为null"
    },
    {
     
        "fieldName": "subList[0].id",
        "message": "不能为空"
    }
]

@Valid 与 @Validated

上面的例子中,@Valid@Validated有时候可以通用,有时候必须用其中一个,可能会绝对困惑,这里做一下总结:

项目 @Valid @Validated
javax.validation org.springframework.validation.annotation
作用范围 参数、方法、构造器、字段 参数、方法、类
支持分组 是(作用于参数)
支持嵌套 是(作用于字段)

校验模式

hibernate的校验模式有两种,默认是普通模式,会校验所有的字段,如果只要有一个校验失败就返回结果,则需使用快速失败返回模式:
ValidatorConfiguration.java

@Configuration
public class ValidatorConfiguration {
     
    @Bean
    public Validator validator() {
     
        ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                .configure()
                .failFast(true)
                .buildValidatorFactory();
        Validator validator = validatorFactory.getValidator();
        return validator;
    }
}

再次运行,则返回一条校验错误消息:

[
    {
     
        "fieldName": "count",
        "message": "Count不能为空"
    }
]

RequestParam参数校验

当表单提交的参数很少,常常会省略掉VO类的创建,直接用方法参数来接收,这时候的参数校验,需要在类定义上加上@Validated标签。

@RestController
@RequestMapping("demo")
@Validated
public class DemoController {
     

    @GetMapping("getOne")
    public void getOne(@RequestParam @NotBlank(message = "ID不能为空") String id) {
     
    }
}

当请求/demo/getOne的时候,由于@RequestParam默认是要求必须传入的,所以如果不传id, 会触发MissingServletRequestParameterException, 然后返回以下内容:

{
     
    "timestamp": "2021-03-03T02:10:16.342+0000",
    "status": 400,
    "error": "Bad Request",
    "message": "Required String parameter 'id' is not present",
    "path": "/demo/getOne"
}

而当请求/demo/getOne?id的时候,就会触发参数验证了,由于id的值未传入,这里会触发ConstraintViolationException异常,并返回以下内容:

{
     
    "timestamp": "2021-03-03T02:14:49.552+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "getOne.id: ID不能为空",
    "path": "/demo/getOne"
}

需要注意的是这里是500异常,而不是400,参照之前的异常处理,这里也可以统一返回格式:

@RestControllerAdvice
public class ParamValidateHandlingControllerAdvice {
     

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    List<Violation> onConstraintViolationException(ConstraintViolationException e) {
     
        List<Violation> violations = new ArrayList<>();
        for (ConstraintViolation fieldError : e.getConstraintViolations()) {
     
            violations.add(new Violation(fieldError.getPropertyPath().toString(), fieldError.getMessage()));
        }
        return violations;
    }
}

这时候再请求/demo/getOne?id, 结果返回:

[
    {
     
        "fieldName": "getOne.id",
        "message": "ID不能为空"
    }
]

那如何处理@RequestParam时候的异常呢,有3种方法:

1.修改@RequestParam的必须传入属性:@RequestParam(required = false)

2.直接把@RequestParam去掉

3.自定义统一异常处理

@RestControllerAdvice
public static class ParamValidateHandlingControllerAdvice {
     

    @ExceptionHandler(MissingServletRequestParameterException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    List<Violation> onConstraintViolationException(MissingServletRequestParameterException e) {
     
        List<Violation> violations = new ArrayList<>();
        violations.add(new Violation(e.getParameterName(), e.getMessage()));
        return violations;
    }
}
[
    {
     
        "fieldName": "id",
        "message": "Required String parameter 'id' is not present"
    }
]

Service层参数校验

在上面的介绍种,已经将hibernate-validator的基本用法都讲完了,实际上不光在Controller层,只要是spring管理的bean中,都可以直接使用这种自动化的校验,需要做的仅仅是在类上加上@Validated注解,其他用法和上面的Controller一模一样。

@Service
@Validated
public class DemoService {
     
    public void getOne(@NotBlank String id) {
     
    }

    public void create(@Valid DemoVO demo) {
     
    }
}

程序触发校验逻辑

有些场景下可能需要程序触发校验逻辑,这时候可以创建一个Validator对象来进行手动验证:

@Service
public class DemoService {
     
    public void test() {
     
        DemoVO demo = new DemoVO();
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<DemoVO>> violations = validator.validate(demo);
        if (!violations.isEmpty()) {
     
            throw new ConstraintViolationException(violations);
        }
    }
}

在Spring环境中,Validator对象已经存在于容器中,所以可以通过自动注入的方式获取到Validator对象,上面的代码可以写成:

@Service
public class DemoService {
     
    @Autowired
    Validator validator;
    
    public void test() {
     
        DemoVO demo = new DemoVO();
        Set<ConstraintViolation<DemoVO>> violations = validator.validate(demo);
        if (!violations.isEmpty()) {
     
            throw new ConstraintViolationException(violations);
        }
    }
}

自定义消息

上面这些校验规则的标签默认的错误消息是来自hibernate-validatator包下面的资源里:
如何在SpringBoot中做参数校验_第1张图片
如果要自定义这些消息,除了在使用的时候,指定message属性外,还可以在项目的/resources目录下创建ValidationMessages.properties自定义消息文件来实现, 比如:

javax.validation.constraints.NotBlank.message = 请输入参数

则提示消息变为:

[
    {
     
        "fieldName": "getOne.id",
        "message": "请输入参数"
    }
]

自定义校验规则

虽然官方已经提供了很多常用的校验规则,但是千变万化的业务场景中,肯定有很多其他的检查逻辑,比如自定义一个IP地址的校验规则:
IpAddress.java

@Target({
      FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = IpAddressValidator.class)
@Documented
public @interface IpAddress {
     

  String message() default "{IpAddress.invalid}";

  Class<?>[] groups() default {
      };

  Class<? extends Payload>[] payload() default {
      };

}

IpAddressValidator.java

class IpAddressValidator implements ConstraintValidator<IpAddress, String> {
     

  @Override
  public boolean isValid(String value, ConstraintValidatorContext context) {
     
    Pattern pattern = 
      Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$");
    Matcher matcher = pattern.matcher(value);
    try {
     
      if (!matcher.matches()) {
     
        return false;
      } else {
     
        for (int i = 1; i <= 4; i++) {
     
          int octet = Integer.valueOf(matcher.group(i));
          if (octet > 255) {
     
            return false;
          }
        }
        return true;
      }
    } catch (Exception e) {
     
      return false;
    }
  }
}

某些场景下规则的标签和规则的校验逻辑不是在一个包下面的,这个时候可以通过配置Validator的方式来实现自定义映射,首先将validatedBy的值去除:
IpAddress.java

@Target({
      FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = {
     })
@Documented
public @interface IpAddress {
     

  String message() default "{IpAddress.invalid}";

  Class<?>[] groups() default {
      };

  Class<? extends Payload>[] payload() default {
      };

}

ValidatorConfiguration.java

@Configuration
public static class ValidatorConfiguration {
     
    @Bean
    public Validator validator() {
     
        HibernateValidatorConfiguration configure = Validation.byProvider(HibernateValidator.class).configure();
        ConstraintMapping constraintMapping = configure.createConstraintMapping();
        constraintMapping.constraintDefinition(IpAddress.class).validatedBy(IpAddressValidator.class);
        
        ValidatorFactory validatorFactory = configure
                .failFast(true)
                .addMapping(constraintMapping)
                .buildValidatorFactory();
        Validator validator = validatorFactory.getValidator();
        return validator;
    }
}

还有一种方式是通过XML的形式来配置,在项目的/resources/META-INF/目录中分别建两个XML:

validation.xml


<validation-config
        xmlns="http://xmlns.jcp.org/xml/ns/validation/configuration"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/validation/configuration
            http://xmlns.jcp.org/xml/ns/validation/configuration/validation-configuration-2.0.xsd"
        version="2.0">
        
    <constraint-mapping>META-INF/constraint-mappings.xmlconstraint-mapping>
    <property name="hibernate.validator.fail_fast">trueproperty>
validation-config>

constraint-mappings.xml


<constraint-mappings
        xmlns="http://xmlns.jcp.org/xml/ns/validation/mapping"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/validation/mapping
            http://xmlns.jcp.org/xml/ns/validation/mapping/validation-mapping-2.0.xsd"
        version="2.0">

    <constraint-definition annotation="mypackage.IpAddress">
        <validated-by include-existing-validators="false">
            <value>mypackage.IpAddressValidatorvalue>
        validated-by>
    constraint-definition>
constraint-mappings>

注意:XML配置麻烦的地方就在于启动的时候会检验XML的正确性,这与hibernate-validator的版本有很大关系,上面是6.x版本中的DTD定义,如果是7.x版本的话,需要改成下面的定义:

validation.xml


<validation-config
        xmlns="https://jakarta.ee/xml/ns/validation/configuration"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="https://jakarta.ee/xml/ns/validation/configuration https://jakarta.ee/xml/ns/validation/validation-configuration-3.0.xsd"
        version="3.0">
        
    <constraint-mapping>META-INF/constraint-mappings.xmlconstraint-mapping>
    <property name="hibernate.validator.fail_fast">trueproperty>
validation-config>

constraint-mappings.xml


<constraint-mappings
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/validation/mapping https://jakarta.ee/xml/ns/validation/validation-mapping-3.0.xsd"
    xmlns="https://jakarta.ee/xml/ns/validation/mapping"
    version="3.0">

    <constraint-definition annotation="mypackage.IpAddress">
        <validated-by include-existing-validators="false">
            <value>mypackage.IpAddressValidatorvalue>
        validated-by>
    constraint-definition>
constraint-mappings>

至于每个版本的详细文档,可以到 https://docs.jboss.org/hibernate/validator 查找对应的目录。
如何在SpringBoot中做参数校验_第2张图片

总结

1.用类包装参数时,需要在对象前面加@Valid@Validated 修饰,后者支持分组校验,所以推荐统一使用@Validated;而使用参数列表校验时,需要在类定义上加@Validated修饰;如果一个字段需要嵌套校验,则这个字段加上@Valid注解。

2.不仅Controller层可以参数校验,其他Spring容器中的组件也都可以,只需在类上加@Validated修饰

3.可以通过统一异常处理类,来接收不同参数异常的统一返回:

  • @RequestParam修饰的参数未传入,抛MissingServletRequestParameterException异常,返回400错误
  • Spring MVC Controller层的方法经过类包装的参数验证未通过,抛MethodArgumentNotValidException异常,返回400错误
  • 自动校验(类上有@Validated修饰)校验未通过, 抛ConstraintViolationException异常, 返回500错误
  • 程序触发的校验,本身不会发生异常,但是可以和上面一样主动抛出ConstraintViolationException异常, 返回500错误

4.可以通过自定义校验规则来扩展修饰标签,可以通过程序或XML的形式对标签和规则处理类进行绑定

5.通过设置failFast(false)hibernate.validator.fail_fast=true的方式,使出现第一个校验失败的时候,就返回错误消息

参考

  • https://reflectoring.io/bean-validation-with-spring-boot
  • https://www.cnblogs.com/mooba/p/11276062.html
  • https://www.cnblogs.com/mr-yang-localhost/p/7812038.html

你可能感兴趣的:(JAVA,Spring)