教程 · 2021年11月28日 0

JSR 380 Java Bean 验证标准草案介绍

内容目录

前言

使用 Java 编写接口时,我们经常需要验证参数的合法性,但是在标准出现之前,我们有各种各样的方法用来验证,比如编写断言工具类等。后来 Java 发展出了一套非常方便的验证框架,最开始作为 JSR 303 规范,后来进行了拓展,叫做 JSR 380,其说明可在 https://jcp.org/en/jsr/detail?id=380 查看。虽然叫做提案,但是已经非常完善了。

下载安装

要使用 Java Bean Validation API,需要选择一个具体的实现模块,目前用的最多的是 Hibernate 提供的实现,需要在类路径中包含如下 jar:

  • jakarta.validation-api.jar
  • hibernate-validator.jar
  • jakarta.el-api.jar(只在没有 Web 环境的情况下需要)

如果使用 Manven,可以使用如下配置代码:

<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>7.0.1.Final</version>
</dependency>
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.el</artifactId>
    <version>4.0.2</version>
</dependency>

功能

验证 Java Bean

既然叫做 Bean Validation,最主要的功能当然是校验 Java Bean 了,事实上第一版的规范确实只有校验 Java Bean 的功能,下面的功能都是后来加的。

先来看看我这个 Java Bean:

package org.example;

import java.time.LocalDate;

public class User {
    private String name;

    private int age;

    private LocalDate birthDay;

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public LocalDate getBirthDay() {
        return birthDay;
    }

    public void setBirthDay(LocalDate birthDay) {
        this.birthDay = birthDay;
    }
}

就是一个普普通通的类,现在假设一个场景,要求这个对象的所有属性不能为空,名字必须在 2 到 4 个字之间,年龄必须在 18 到 35 之间,生日必须是过去的日期并且生日年份加上年龄必须是今年年份(不考虑现实逻辑)。

这个很好实现,不用框架也能实现,但是用判断语句写非常麻烦,而且写完之后如果不看代码实现别人完全不知道有这个限制,不适合团队开发。这时候 Bean Valitation 就派上用场了,我们稍微把代码改一下:

package org.example;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import java.time.LocalDate;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.Range;

public class User {
    @NotBlank
    @Length(min = 2, max = 4, message = "必须在2个字到4个字之间")
    private String name;

    @Range(min = 18, max = 35)
    private int age;

    @Past
    @NotNull
    private LocalDate birthDay;

    // 省略 Getters and Setters
}

就这么放上几个注解,你是不是一下子就能明白这些属性的约束条件?什么?少了判断生日与年龄之间关系的,稍等,后面讲。

在使用这个对象时,只需要调用验证器验证即可:

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
User user = new User();
user.setAge(16);
user.setName("我是刘华强");
user.setBirthDay(LocalDate.of(2005, 1, 1));
Set<ConstraintViolation<User>> violations = validator.validate(user);
for (ConstraintViolation<User> violation : violations) {
    System.err.println(violation.getPropertyPath().toString() + violation.getMessage());
}

输出如下:

age需要在18和35之间
name必须在2个字到4个字之间

另外 Validator 对象是线程安全的,应该被共享而不是每次使用时重新初始化。

验证方法参数

上面演示了验证 Java Bean 的功能,那么如果我的方法只接收一两个参数呢?没必要大动干戈再写个 Java Bean 吧。确实,在后来的版本中也支持了直接验证方法参数的接口。比如定义如下方法:

public void happy(@Positive double money) {
    System.out.printf("I got ¥%.2f, I am happy\n", money);
}

如果给了我 0 元甚至负数,我也不会 happy,所以校验方法如下:

ExecutableValidator executableValidator = validator.forExecutables();
Method happy = User.class.getMethod("happy", double.class);
double money = 0.0D;
Set<ConstraintViolation<Object>> methodViolations = executableValidator.validateParameters(user, happy, new Object[] {money});
for (ConstraintViolation<Object> violation : methodViolations) {
    System.err.println(violation.getPropertyPath().toString() + violation.getMessage());
}

输出如下:

happy.arg0必须是正数

如果觉得 arg0 有点奇怪的话不妨加上 -parameters 编译参数试试,在 maven pom.xml 的 build.plugins 节点中添加:

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>-parameters</arg>
        </compilerArgs>
    </configuration>
</plugin>

再次运行就能正常输出参数名了。

看到这你可能觉得有些不对劲:什么鬼,怎么还要调用方去校验参数,其实正常情况下方法参数校验都是通过动态代理或者方法拦截器实现的,这样的话就用着比较方便了。

验证集合内容

由于 Java 语言的特性,反射是无法获取局部变量上的注解的,因此要想验证集合,那么集合对象必须是成员变量。示例如下:

static class ListContainer {
    public List<@Valid @NotNull User> users = new ArrayList<>(3);
}

注意注解的位置,必须要放在泛型参数前面。@Valid 的作用是嵌套验证集合中的元素。

验证的方式和验证普通的 Java Bean 是一样的。

多字段联合验证

上面用到的注解都是对成员变量或参数验证的,那么有没有验证整个类的注解呢?很遗憾,规范中没有定义这样的接口。但是 Hibernate 的实现中包含一个 @ScriptAssert 实现,可以使用这个方法定义验证脚本,该注解对类型生效(也就是放在类声明前)。

示例如下:

package org.example;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Positive;
import java.time.LocalDate;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

@ScriptAssert(lang = "javascript",
              script = "_this.birthDay != null && _this.age + _this.birthDay.getYear() "
              + "== java.time.LocalDate.now().getYear()",
              message = "生日与年龄不符")
public class User {
    @NotBlank
    @Length(min = 2, max = 4, message = "必须在2个字到4个字之间")
    private String name;

    @Range(min = 18, max = 35)
    private int age;

    @Past
    @NotNull
    private LocalDate birthDay;

    // 省略重复代码
}

在这个示例中我们使用 javascript 脚本判断成员变量之间的关系,可以满足跨字段验证的需求。

使用脚本是需要当前类搜索路径中有 JSR 223 (Java 脚本平台)的实现才行,在 Java 14 之前 JDK 中自带 nashron 引擎,提供对 JavaScript 的支持,Java 14 之后可以考虑其他脚本引擎实现。

自定义验证

jakarta(javax).validation.constraintsorg.hibernate.validator.constraints 包下定义了许多验证注解,基本满足日常使用,但是有的时候我们还是会有特殊需求的,比如我们想验证一个字段是否是格式合法的身份证号,这个使用正则表达式是没法完整判断的,使用工具类也是一种办法,而使用 Java Bean Validation 是一种更合适的解决方法。

首先需要了解的是 @Constraint 注解,这是一个元注解,所有自定义的验证注解都需要添加它,其中的 validatedBy 属性代表了这个注解的处理器是哪一个,因此我们需要定义一个注解,然后定义一个验证注解处理器。

综上,注解的示例代码如下:

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;

/**
 * 验证身份证号码是否合法
 */
@Documented
@Constraint(validatedBy = {IdCardNumberValidator.class})
@Target({ElementType.METHOD,
         ElementType.FIELD,
         ElementType.ANNOTATION_TYPE,
         ElementType.CONSTRUCTOR,
         ElementType.PARAMETER,
         ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface IdCardNumber {
    String message() default "必须是合法的身份证号";

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

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

验证处理器:

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class IdCardNumberValidator implements ConstraintValidator<IdCardNumber, CharSequence> {
    @Override
    public void initialize(IdCardNumber parameters) {
    }
    @Override
    public boolean isValid(CharSequence value, ConstraintValidatorContext constraintValidatorContext) {
        if (value == null) {
            return true;
        }
        try {
            checkIdNumber(value); // 自行实现
            return true;
        } catch (IllegalArgumentException e) {
            // 如果产生异常,可以把异常信息也加入错误列表里。
            constraintValidatorContext.buildConstraintViolationWithTemplate(e.getLocalizedMessage())
                .addConstraintViolation();
            return false;
        }
    }
}

与 Spring 结合

Spring 是一个当下很火热的 Web 框架,将 Spring 与 Bean Validation 结合,用来验证 Form 表单,简直是如虎添翼,不要太方便。

在新建项目时候,选中 spring-boot-starter-validation 依赖,就能直接使用完整功能,如果项目已经创建,只要在 Maven 依赖中添加一个模块:

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

写一个测试接口:

@PostMapping("/valid")
@ResponseBody
public User testUser(@Valid @RequestBody User user) {
    return user;
}

Spring Boot 的参数处理器会自动识别 @Valid 等注解帮我们校验,因此在 Web 应用中可以发挥出 Bean Validation 的巨大优势,再也不用手动写判断语句验证表单参数了。

如果验证失败,Spring 会抛出 org.springframework.validation.BindException,只要捕获这个异常,取出错误消息,就可以自定义错误响应了。

总结

本文通过一个场景示例简单介绍了 Bean Validation 的主要功能基本覆盖了所有的验证需求,希望这篇文章能给你带来收获。

另外写本文时,找到了一篇更加详细的关于 Bean Validation 的文章,大家可以继续延伸一下:

传送门