Bean Validation 简化表单验证逻辑

一、背景

在 Java mvc 分层架构的实际应用中,从视图层到数据访问层,每一层都会对表单参数信息进行校验,如下图所示:


原始校验方式.jpg

校验方式普遍采用“抽象工具类”+“逻辑if...else判断”的形式。其中,抽象工具类主要封装了业务常用的校验方法,该方法使用正则表达式对参数进行校验,并返回 boolean 类型的返回值;

// 验证输入用户名:只包含数字和小写字母,且不允许为空
public static boolean isUserName(String name) {
    if(StringUtils.isBlank(name)){
        // 不允许为空
        return false;
    }
    String regEx = "[a-z0-9\\-]+";
    Matcher m = Pattern.compile(regEx).matcher(name);
    return m.matches();
}

在业务中进行逻辑判断返回值的真伪,从而控制业务流程走向。

// 新增用户(Controller层)
@PostMapping("/add/user")
public MethodResult addUser(User user) {
    // 表单参数校验
    if(user == null){
        return MethodResult.errorResult("用户信息不能为空");
    }
    // 调用抽象工具类中的校验
    if(!Utils.isUserName(user.getName())){
        return MethodResult.errorResult("用户名只能包含数字和小写字母且不能为空");
    }
    //.... 省略剩余校验逻辑 ....
}

这种验证方式有如下弊端:

  • 虽然封装了抽象的校验工具类,但是在业务代码中仍然需要采用大量的if..else..编码。
  • 若业务校验规则变更,这些散落在业务代码中的校验语句均需要修改,耗时且容易诱发错误。

二、Bean Validation 规范定义

Bean Validation 规范(JSR303规范)提供了对 Java EE 和 Java SE 中的 Java Bean 进行验证的方式。该规范主要使用注解的方式来实现对 Java Bean 的验证功能,从而使验证逻辑从业务代码中分离出来,如下图所示:


简化校验.jpg

Bean Validation 规范倾向于将验证规则直接放到 Java Bean 本身,使用注解的方式进行规则校验,Bean Validation API为我们提供了可拓展的四大接口(Bootstrapping、Validator、ConstraintViolation、MessageInterpolator)以及约束注解。
Hibernate Validator 对 Bean Validation 规范进行了实现,后续示例代码均基于该实现进行,以下是简要版本清单:

spring-boot-starter-parent-2.1.0.RELEASE;
hibernate-validator-6.0.13.Final;
validation-api-2.1.0.Final;

三、Bean Validation 规范应用

Bean Validation 规范对约束的定义包括两部分:一是约束注解;二是约束验证器。每一个约束注解都存在对应的约束验证器,约束验证器用来验证具体的 Java Bean 是否满足该约束注解声明的条件。

1.约束注解

Bean Validation 规范默认提供了几种约束注解的定义,如下:

约束注解名称 约束注解说明
@Null 验证元素是否为空
@NotNull 验证元素是否非空
@NotBlank 验证元素是否非空,且必须包含至少一个非空白字符
@NotEmpty 验证元素是否非空,支持Array, Collection, Map, Charsequence
@AssertTrue 验证 Boolean 对象是否为 true
@AssertFalse 验证 Boolean 对象是否为 false
@Min 验证 Number 和 String 对象是否大等于指定的值
@Max 验证 Number 和 String 对象是否小等于指定的值
@DecimalMin 验证 Number 和 String 对象是否大等于指定的值,小数存在精度
@DecimalMax 验证 Number 和 String 对象是否小等于指定的值,小数存在精度
@Size 验证元素(Array,Collection,Map,String)长度是否在给定的范围之内
@Digits 验证 Number 和 String 的构成是否合法
@Past 验证 Date 和 Calendar 对象是否在当前时间之前
@Future 验证 Date 和 Calendar 对象是否在当前时间之后
@Email 验证元素是否是格式正确的电子邮件地址
@Negative 验证元素是否是一个严格的负数(0被视为无效值)
@NegativeOrZero 验证元素是否是一个负数或0
@Pattern 验证元素是否满足指定的正则表达式
@Positive 验证元素是否是严格的正数(即0被视为无效值)
@PositiveOrZero 验证元素是否是正数或0

约束注解的使用示例:

// 示例代码 : 视图层领域模型
public class FaceInfoVO extends FaceInfoBaseVO {
    @NotEmpty(message = "缩略图不能为空")
    @Size(max = 128, message = "缩略图存储路径最大长度不能超过128")
    private String thumbnailUrl;
    // .... 省略其他字段以及getter setter ....
}

// 示例代码 : Controller层使用, 这里@Validated是Spring提供的注解, Controller接收参数时即可进行校验。
@PostMapping("/add/faceinfo")
public MethodResult addFaceInfo(@Validated FaceInfoVO faceInfoVO {
    // ....  ....
}

2.分组验证

很多时候,一个视图层领域模型可以用于不同场景,每一个场景对模型中的属性校验各不相同。于是Bean Validation 规范中引入了一个重要的概念,就是 “组”。
对于一个给定的Java Bean,有了组的概念,则无需对该 Java Bean 中所有的约束进行验证,只需要对该组定义的一个子集进行验证即可。完成组别验证需要在约束声明时进行组别的声明,否则使用默认的组 Default.class。

// 定义组
public interface GroupUpdateInfo {
}
public interface GroupInsertInfo {
}

// 示例代码 : 视图层领域模型
public class FaceInfoVO extends FaceInfoBaseVO {
    @NotEmpty(message = "缩略图不能为空", groups = {GroupUpdateInfo.class})
    private String thumbnailUrl;

    @NotEmpty(message = "名称不能为空", groups = {GroupInsertInfo.class})
    private String name;
    // .... 省略其他字段以及getter setter ....
}

// 示例代码: Controller层, 此处校验满足GroupUpdateInfo组的属性
public MethodResult updateInfo(@Validated(GroupUpdateInfo.class) FaceInfoVO vo) {
    // .... 省略 ....
}

3.组序验证

一个组可以定义为其他组的序列,示例代码如下:

// 定义组序列
@GroupSequence({GroupA.class, GroupB.class})
public intreface Group {
}

在使用组序列验证的时候,如果序列前边的组验证失败,则后面的组将不再给予验证。不过遗憾的是,由于底层代码使用的HashSet的缘故,在同一个 Java Bean 中当多个字段属性对应同一个组时,这些同组的字段是没有先后顺序之分的。

public class FaceInfoVO extends FaceInfoBaseVO {
    @NotEmpty(message = "缩略图不能为空", groups = {GroupB.class})
    private String thumbnailUrl;

    @NotEmpty(message = "名称不能为空", groups = {GroupB.class})
    private String name;
    @NotEmpty(message = "ID不能为空", groups = {GroupA.class})
    private String id;
    // .... 省略其他字段以及getter setter ....
}

在上段示例中,当Controller层使用@Validated(Group.class)进行校验,那么人员 id 肯定会优先于 name 和 thumbnailUrl 进行校验。 当 id 校验合法时,对GroupA的两个元素校验是无序的,可能是 name 也可能是 thumbnailUrl。

4.级联验证

在实际业务中,领域模型可能存在很多“对象套对象”或者“对象套集合”的情况,对于这些场景可以使用级联验证进行解决,示例代码如下:

// 对象套数组, 集合中又套了一层对象
public class FaceComparAlarmResultDTO extends FaceAlarmResultDTO {
    @NotEmpty(message = "结果信息不能为空")
    private List<@NotNull @Valid FaceComparAlarmResultBO> alarmResult;
    // .... 省略 ....
}

// 对象套对象
public class FacesBO {
    @Valid
    @NotNull(message = "年龄不能为空")
    private FaceAgeBO age;
    // .... 省略 ....
}

这里值得注意的是,当使用“对象套集合,集合又套对象”这种形式的时候,若想对List集合内部的每个对象以及对象中的每个属性进行校验,务必进行@NotNull非空验证,且将该验证写在泛型之中。

5.自定义约束注解以及验证器

我们也可以实现符合自身业务需求的约束注解,约束注解和普通的注解一样,约束注解的定义至少包括如下内容:

// 约束注解应用的目标元素类型
@Target({...})
// 约束注解应用的时机
@Retention(...)
// 多值约束
@Repeatable(List.class)
// 与约束注解关联的验证器
@Constraint(validatedBy = {...})
public @interface NotEmpty {
    // 约束注解验证匹配时输出的消息
    String message() default "";
    // 约束注解在验证分组
    Class[] groups() default { };

    @Target({...})
    @Retention(...)
    @Documented
    public @interface List {
        NotEmpty[] value();
    }
}
  • 目标元素类型:@Target表示注解可以用于什么地方,可以使用ElementType进行指定。

    ElementType的类型包括:

    1. ElementType.CONSTRUCTOR: 用于描述构造器
    2. ElementType.FIELD: 成员变量、对象、属性(包括enum实例)
    3. ElementType.LOCAL_VARIABLE: 用于描述局部变量
    4. ElementType.METHOD: 用于描述方法
    5. ElementType.PACKAGE: 用于描述包
    6. ElementType.PARAMETER: 用于描述参数
    7. ElementType.TYPE: 用于描述类、接口(包括注解类型) 或enum声明
  • 注解生效时机:@Retention表示该自定义注解的生命周期,可以使用RetentionPolicy指定。

    RetentionPolicy的类型包括:

    1. RetentionPolicy.SOURCE : 在编译阶段丢弃。标志着编译结束之后该注解就不再有任何意义,不会被写入字节码,例如:@Override、@SuppressWarnings等。
    2. RetentionPolicy.CLASS : 在类加载的时候丢弃。在字节码文件的处理中有用,lombok提供的注解,例如:@Data、@Getter、@Setter;mapstruct提供的注解,例如:@Mapper、@Mapping等。
    3. RetentionPolicy.RUNTIME : 始终不会丢弃,运行期也保留该注解,可以使用反射机制读取该注解的信息。
  • 关联注解验证器:@Constraint的值用于指定该注解的注解验证器,可以指定一个或多个。

  • 多值约束:@Repeatable在需要在同一元素上进行多值约束时可以进行标注,并指定List

自定义约束注解的完整示例,如下:

@Target(FIELD)
@Retention(RUNTIME)
@Repeatable(List.class)
@Constraint(validatedBy = TextValidator.class)
public @interface ValidateText {
    String message() default "文本只支持中文,且长度小于8";
    Class[] group() default ();

    @Target(FIELD)
    @Retention(RUNTIME)
    @interface List {
        ValidateText[] value();
    }
}

约束注解定义完成后,需要同时实现与该约束注解关联的验证器。约束验证器的实现需要实现 JSR303 规范提供的接口 javax.validation.ConstraintValidator。

public interface ConstraintValidator {
    default void initialize(A constraintAnnotation) {}

    boolean isValid(T value, ConstraintValidatorContext context);
}

该接口有两个方法,方法 initialize 对验证器进行实例化,必须在验证器的实例使用之前被调用,并保证正确初始化验证器,它的参数是约束注解;方法 isValid 是进行约束验证的主体方法,其中 value 参数代表需要验证的实例,context 参数代表约束执行的上下文环境,这里定义@ValidateText对应的验证器TextValidator:

public class isValid(String value, ConstraintValidatorContext context) {
    if(StringUtils.isBlank(value)) {
        return false;
    }
    String regex = "[\u4e00-\u9fa5]+";
    return value.matches(regex) && value.length() <= 8;
}

四、总结

使用 Bean Validation 规范进行表单验证,可以减少业务代码中的if…else的使用,将验证统一到了 Java Bean 中方便了业务变更后的校验规则修改。当然,该验证方式仍然不能完全适用于所有业务场景,遇到复杂的业务验证场景可以自定义处理器或配合if…else使用。

你可能感兴趣的:(Bean Validation 简化表单验证逻辑)