数据校验

简介:

作为一个Java开发者我们或多或少在Spring MVC使用场景中接触过数据校验(Bean Validation)。Bean Validation技术隶属于Java EE规范,期间有多个JSR(Java Specification Requests)支持,目前共有三次相关JSR标准发布:

  • JSR303 最早(2009)
  • JSR349
  • JSR380

JSR303

JSR303提出很早(2009年),它为 基于注解的 JavaBean验证定义元数据模型和API。JSR-303主要是对JavaBean进行验证,如方法级别(方法参数/返回值)、依赖注入等的验证是没有指定的。

作为开山之作,它规定了Java数据校验的模型和API,这就是Java Bean Validation 1.0版本。

<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>1.0.0.GA</version>
</dependency>

该版本提供了13个现在常见的校验注解:

注解

支持类型

含义

null值是否校验

@AssertFalse

bool

元素必须是false


@AssertTrue

bool

元素必须是true


@DecimalMax

Number的子类型(浮点数除外)以及String

元素必须是一个数字,且值必须<=最大值


@DecimalMin

同上

元素必须是一个数字,且值必须>=最小值


@Max

同上

同上


@Min

同上

同上


@Digits

同上

元素构成是否合法(整数部分和小数部分)


@Future

时间类型(包括JSR310)

元素必须为一个将来(不包含相等)的日期(比较精确到毫秒)


@Past

同上

元素必须为一个过去(不包含相等)的日期(比较精确到毫秒)


@NotNull

any

元素不能为null


@Null

any

元素必须为null


@Pattern

String

元素需符合指定的正则表达式


@Size

String/Collection/Map/Array

元素大小需在指定范围中


它的官方参考实现如下:

shenyu dubbo 参数为Map_嵌套

JSR349

该规范2013年完成伴随java EE 7一起发布,就是我们比较熟悉的Bean Validation1.1。

<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>1.1.0.Final</version>
</dependency>

相较于1.0版本,它主要的改进/优化有如下几点:

  1. 标准化了Java平台的约束定义、描述、和验证
  2. 支持方法级验证(入参或返回值的验证)
  3. Bean验证组件的依赖注入
  4. 与上下文和DI依赖注入集成
  5. 使用EL表达式的错误消息插值,让错误消息动态化起来(强依赖于ElManager)
  6. 跨参数验证。比如密码和验证密码必须相同

注解个数上,相较于1.0版本并没新增~

它的官方参考实现如下:

shenyu dubbo 参数为Map_Java_02

注:当你导入了hibernate-validator后,无需再显示导入javax.validation

JSR380

当下主流版本,也就是Java Bean Validation 2.0,它完成于2017年8月,在2019年8月发布,属于Java EE 8的一部分。它的官方参考实现只有唯一的Hibernate validator了:

shenyu dubbo 参数为Map_shenyu dubbo 参数为Map_03


此版本具有很重要的现实意义,主要有以下变化:

  1. 支持通过注解泛型类型来验证容器内的元素,如:List<@Positive Integer> positiveNumbers,即容器内元素须为正数
    1. 更灵活的集合类型级联验证;例如,现在可以验证映射的键和值,如:Map<@Valid CustomerType, @Valid Customer> customersByType 2. 支持java.util.Optional类型,并且支持通过插入额外的值提取器来支持自定义容器类型
  2. 让@Past/@Future注解支持注解在JSR310时间上
  3. 新增内建的注解类型(共9个):@Email, @NotEmpty, @NotBlank, @Positive, @PositiveOrZero, @Negative, @NegativeOrZero, @PastOrPresent和@FutureOrPresent
  4. 所有内置的约束现在都支持重复标记
  5. JDK最低版本要求:JDK 8

新增注解

注解

支持类型

含义

null值是否校验

@Email

String

元素必须是电子邮箱地址


@NotEmpty

容器类型

集合的Size必须大于0


@NotBlank

String

字符串必须包含至少一个非空白的字符


@Positive

Positive

元素必须必须为正数(不包括0)


@PositiveOrZero

同上

同上(包括0)


@Negative

同上

元素必须必须为负数(不包括0)


@NegativeOrZero

同上

同上(包括0)


@PastOrPresent

时间类型

在@Past基础上包括相等


@FutureOrPresent

时间类型

在@Futrue基础上包括相等


从1.1版本起就需要El管理器支持用于错误消息动态插值,因此需要自己额外导入EL的实现。EL也属于Java EE标准技术,可认为是一种表达式语言工具,它并不仅仅是只能用于Web,可以用于任意地方(类比Spring的SpEL)

这是EL技术规范的API:

<dependency>
    <groupId>javax.el</groupId>
    <artifactId>javax.el-api</artifactId>
    <version>3.0.0</version>
</dependency>

Expression Language 3.0表达式语言规范于2013-4-29发布,Tomcat 8、Jetty 9、GlasshFish 4都已经支持实现了EL 3.0,如果你是web环境,就不用自己手动导入了

简单来说以上JSR提供了一套Bean校验规范的API,维护在包javax.validation.constraints下。该规范使用属性或者方法参数或者类上的一套简洁易用的注解来做参数校验。开发者在开发过程中,仅需在需要校验的地方加上形如@NotNull, @NotEmpty , @Email的注解,就可以将参数校验的重任委托给一些第三方校验框架来处理。

Spring MVC中,只需要使用@Valid注解标注在方法参数商,Spring MVC即可对参数对象进行校验,校验结果会放在BindingResult对象中。除了@Valid 还有 @Validated注解支持参数的分组校验。它们的区别:

  • @Valid:没有分组的功能。
  • @Valid:可以用在方法、构造函数、方法参数和成员属性(字段)上
  • @Validated:提供了一个分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制
  • @Validated:可以用在类型、方法和方法参数上。但是不能用在成员属性(字段)上

两者是否能用于成员属性(字段)上直接影响能否提供嵌套验证的功能

嵌套验证

看下图:
比如我们现在有个实体叫做Item:

public class Item {

    @NotNull(message = "id不能为空")
    @Min(value = 1, message = "id必须为正整数")
    private Long id;

    @NotNull(message = "props不能为空")
    @Size(min = 1, message = "至少要有一个属性")
    private List<Prop> props;
}

Item带有很多属性,属性里面有:pid、vid、pidName和vidName,如下所示:

public class Prop {

    @NotNull(message = "pid不能为空")
    @Min(value = 1, message = "pid必须为正整数")
    private Long pid;

    @NotNull(message = "vid不能为空")
    @Min(value = 1, message = "vid必须为正整数")
    private Long vid;

    @NotBlank(message = "pidName不能为空")
    private String pidName;

    @NotBlank(message = "vidName不能为空")
    private String vidName;
}

属性这个实体也有自己的验证机制,比如pid和vid不能为空,pidName和vidName不能为空等。
正常情况,Spring Validation框架只会对Item的id和props做非空和数量验证,不会对props字段里的Prop实体进行字段验证。
如何进行嵌套校验?
为了能够进行嵌套验证,必须手动在Item实体的props字段上明确指出这个字段里面的实体也要进行验证。由于@Validated不能用在成员属性(字段)上,但是@Valid能加在成员属性(字段)上,而且@Valid类注解上也说明了它支持嵌套验证功能,那么我们能够推断出:@Valid加在方法参数时并不能够自动进行嵌套验证,而是用在需要嵌套验证类的相应字段上,来配合方法参数上@Validated@Valid来进行嵌套验证。

修改Item类如下所示:

public class Item {

    @NotNull(message = "id不能为空")
    @Min(value = 1, message = "id必须为正整数")
    private Long id;

    @Valid // 嵌套验证必须用@Valid
    @NotNull(message = "props不能为空")
    @Size(min = 1, message = "props至少要有一个自定义属性")
    private List<Prop> props;
}

除了上面常见的@NotNull@Min@NotBlank@Size等校验注解我们还可以自定义校验注解~

类级别验证(多字段联合验证)

约束也可以放在类级别上(也就说注解标注在类上)。在这种情况下,验证的主体不是单个属性,而是整个对象。如果验证依赖于对象的几个属性之间的相关性,那么类级别约束就能搞定。
这个需求场景在平时开发中也非常常见,比如此处我举个简单场景案例:修改用户名密码,需要输入两遍新密码:newPassnewPassAgain,要求newPass.equals(newPassAgain)。如果用事务脚本来实现这个验证规则,那么你的代码里肯定穿插着类似这样的代码:

if (!this.newPass.equals(this.newPassAgain)){
    throw new RuntimeException("...");
}

虽然这么做也能达到校验的效果,但很明显这不够优雅。
但是基于Hibernate-Validator内置的@ScriptAssert,可以很容易的处理这种case:

@ScriptAssert(lang = "javascript", alias = "_", script = "_.newPass.equals(_.newPassAgain)",message = "两个密码不相等")
public class SecContent implements Serializable {

    @NotNull(message = "age 不能为空",groups = {TestGroup.class})
    private Integer age;

    @NotBlank
    private String newPass;
    @NotBlank
    private String newPassAgain;
    ...
}

@ScriptAssert支持写脚本来完成验证逻辑,这里使用的是javascript(缺省情况下的唯一选择,也是默认选择)

@ScriptAssert是内置就提供的,因此使用起来非常的方便和通用。但缺点也是因为过于通用,因此语义上不够明显,需要阅读脚本才知。推荐少量(非重复使用)、逻辑较为简单时使用,更为轻巧

自定义校验

举例说明自定义注解的实现:需要一个自定义注解来校验入参name不能和已存在name重名

1.自定义注解

@Target({ElementType.FIELD,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueConstraintValidator.class)
public @interface UniqueConstraint {

    //下面三个属性是必须有的属性

    String message();

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

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

2.新建一个UniqueConstraintValidator类来验证注解

//自定义校验注解 的 校验逻辑
//不需要加注解@Component,因为实现了ConstraintValidator接口自动会注册为spring bean
public class UniqueConstraintValidator implements ConstraintValidator<UniqueConstraint,Object> {

    @Autowired
    private UserService userService;

    @Override
    public void initialize(UniqueConstraint uniqueConstraint) {
        System.out.println("my validator init");
    }

    //Object为校验的字段类型
    //返回true则校验成功
    //o为校验字段的值,constraintValidatorContext为校验注解里的属性值
    @Override
    public boolean isValid(Object o, ConstraintValidatorContext constraintValidatorContext) {
        String username = (String) o;
        TbUser user = userService.findByUsername(username);
        return user==null?true:false;
    }
}
  • UniqueConstraintValidator类必须实现ConstraintValidator接口initialize方法以及验证方法isValid
  • 具体的校验逻辑在isValid方法中做校验

使用的时候在需要的字段上标记该注解即可:

shenyu dubbo 参数为Map_Java_04


Dubbo RPC参数校验

那么Dubbo作为国产优秀的开源RPC框架,支持注解方式校验参数么?答案当然是支持的~

ValidationFilter

shenyu dubbo 参数为Map_dubbo_05


ValidationFilter通过在实际方法调用之前,根据调用者url配置的validation属性值找到正确的{Validator}实例来调用验证。

关于ValidationFilter是如何被调用的是dubbo spi的内容这里就不提了,但是要想其生效需要在consumer或者provider端配置一下:

consumer:

@DubboReference(validation = "true")
    private DemoService demoService;


provider:

@DubboService(validation = "true")
public class DemoServiceImpl implements DemoService {

注:如果在消费端开启参数校验,不通过就不会向服务端发起rpc调用,但是要自己处理校验异常ConstraintViolationException

Validator校验器

默认ValidationFilter使用的校验器看下图

shenyu dubbo 参数为Map_字段_06


JValidation实现代码还是比较少的

/**
 * Creates a new instance of {@link Validator} using input argument url.
 * @see AbstractValidation
 * @see Validator
 */
public class JValidation extends AbstractValidation {

    /**
     * Return new instance of {@link JValidator}
     * @param url Valid URL instance
     * @return Instance of JValidator
     */
    @Override
    protected Validator createValidator(URL url) {
        return new JValidator(url);
    }

}

可以看到实际干活的是org.apache.dubbo.validation.support.jvalidation.JValidator

在其validate方法中可以看到有一个@MethodValidated注解

shenyu dubbo 参数为Map_shenyu dubbo 参数为Map_07


点开查看它的注释

shenyu dubbo 参数为Map_字段_08


大意上能明白这注解是标记在方法上支持分组校验的!

简单示例:

消费方代码:

@Component("demoServiceComponent")
public class DemoServiceComponent implements DemoService {
    
    @DubboReference(validation = "true")
    private DemoService demoService;

    @Override
    public String sayHello(String name) {
        return demoService.sayHello(name);
    }

    @Override
    public String sayGoodBye(Content content) {
        return demoService.sayGoodBye(content);
    }

    @Override
    public CompletableFuture<String> sayHelloAsync(String name) {
        return null;
    }
}

dubbo client interface:

public interface DemoService {

    String sayHello(String name);

    @MethodValidated({TestGroup.class})
    String sayGoodBye(Content content);

    default CompletableFuture<String> sayHelloAsync(String name) {
        return CompletableFuture.completedFuture(sayHello(name));
    }

}

方法入参Content:

public class Content implements Serializable {

    @NotNull(message = "name不能为空",groups = {TestGroup.class})
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

注:没有设置groups的校验注解也会进行校验,作为默认分组。最后捕获下抛出的ConstraintViolationException以结构化的json格式返回给调用方"校验错误信息"