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<String> 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<A extends Annotation, T> {
    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使用。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349