全文共 2188 个字

一切从元编程开始

一个健壮的系统都要对外部提交的数据进行完整性、合法性的校验。即使开发一个不面对最终用户的工具包,也需要对传入的数据进行缜密的校验来防止引发底层难以追踪的问题。各路大神当然也会注意到这个问题,所以在“元编程”(见JSR250与资源控制)提出之后相续提交了JSR-303、JSR-349以及JSR-380来完善使用注解进行数据校验的机制,这三个JSR也被称为Bean Validation 1.0、Bean Validation 1.1和Bean Validation 2.0,后文统称为Bean Validation。

先看一个不使用Bean Validation校验数据的代码:

public class StandardValidation {

	public static void main(String[] args) {
		System.out.println(validationWithoutAnnotation(" ", -1));
	}

	public static String validationWithoutAnnotation(String inputString, Integer inputInt) {
		String error = null;
		if (null == inputString) {
			error = "inputString不能为null";
		} else if (null == inputInt) {
			error = "inputInt不能为null";
		} else if (1 > inputInt.compareTo(0)) {
			error = "inputInt必须大于0";
		} else if (inputString.isEmpty() || inputString.trim().isEmpty()) {
			error = "inputString不能为空字符串";
		} else {
			// DO
		}
		return error;
	}
}

相信很多码友多少都写过类似的代码。使用IF—ELSE是否优雅这种高端问题暂且不谈,但是大量的IF—ELSE会导致业务内容越来越多的嵌套在代码中。针对这些问题Bean Validation为数据校验提供了更加规范化、通用化、复用程度更高的校验方法。

数据校验的原理并不复杂,主要是用注解(Annotation)在域或setter方法上声明JavaBean中数据的准则。Java的数据校验代码主要在javax.validation包中,包括注解、校验器以及校验器工厂,接下来通过例子说明。(例子可执行代码在本人的gitee库,本文代码在chkui.springcore.example.javabase.validation包)

标准数据校验

JSR提交的Javax.validation定义中已经为数据校验定义了很多方法和注解,但是需要清晰的是JSR仅仅制定了一个规范,具体的功能是由各种框架实现的。本文的例子引入了Hibernate Validator 6.0.12.Final包,他与Spring Validator一样,都是根据JSR规范实现校验功能。

数据校验是围绕一个实体类展开的,下面的代码声明了一个实体类,通过注解标注每个域上的赋值规则:

package chkui.springcore.example.javabase.validation.entity;
public class Game {
	@NotNull //非空
	@Length(min=0, max=5) //字符串长度小于5,这个是一个Hibernate Validator增加的注解
	private String name;
	
	@NotNull
	private String description;
	
	@NotNull
	@Min(0) //最小值>=0
	@Max(10) //最大值<=10
	private int currentVersion; 
    //getter and setter…………
}

使用校验器对其进行校验:

public StandardValidation {
	public void validate() {
		//引入校验工具
		ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        //获取校验器
		Validator validator = factory.getValidator();
		Game wow = new Game();
        //执行校验
		Set<ConstraintViolation<Game>> violationSet = validator.validate(wow);
		violationSet.forEach(violat -> {
			violat.getPropertyPath();//校验错误的域
            violat.getMessage());//校验错误的信息
		});
        //设置值之后再次进行校验
		wow.setName("World Of Warcraft");
		wow.setDescription("由著名游戏公司暴雪娱乐所制作的第一款网络游戏,属于大型多人在线角色扮演游戏。");
		wow.setCurrentVersion(8);
		violationSet = validator.validate(wow);
		violationSet.forEach(violat -> {});
	}
}

执行完毕之后violationSet中就是校验的结果。如果校验通过那么返回的Set长度为0。

Bean Validation已经为常规的校验功能预设了很多注解,详见关于所有注解的介绍

自定义校验规则

虽然在javax.validation.constraints已经定义了很多用于校验的注解,但是肯定无法满足复杂多样的业务需求。所以Bean Validation也支持自定义校验规则。在JSR的文档中对数据域的一个校验被称为Constraint(约束),一个Constraint由一个Annotation(注解)绑定1~n个Validator(校验器)组成。 因此可以通过新增AnnotationValidator来定义新的校验方式(或者说是定义新的Constraint)。

组合注解校验

可以通过组合已有的注解来实现新的数据校验规则。例如下面的例子。

定义新的校验注解:

package chkui.springcore.example.javabase.validation.annotation;
@Min(1)//最小值>=1
@Max(300)//最大值<=300
@Constraint(validatedBy = {}) //不制定校验器
@Documented
@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface Price {
	String message() default "定价必须在$1~$200之间";
	Class<?>[] groups() default { };
	Class<? extends Payload>[] payload() default { };
}

在@Price注解中我们标记了@Min(1)和@Max(300),之后直接在域上标记@Price就会校验对应的值是否满足这个条件:

package chkui.springcore.example.javabase.validation.entity;
public class Game {
    @Price
	private float price;
    //Other field
    //setter and getter
}

自定义校验器

除了组合javax.validation.constraints中的注解,还可以自定义校验器(Validator)进行数据校验。

声明一个用于自定义校验的注解:

package chkui.springcore.example.javabase.validation.annotation;
@Constraint(validatedBy = { TypeValidator.class }) //指定校验器
@Documented
@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface Type {
	String message() default "游戏类型错误,可选类型为RPG、ACT、SLG、ARPG";
	Class<?>[] groups() default {};
	Class<? extends Payload>[] payload() default {};
}

注意@Constraint(validatedBy = { TypeValidator.class })这一行代码,他的作用就是将这个注解和校验器进行绑定,当我们执行Validator::validator方法时对应的校验器会被调用。

TypeValidator类:

package chkui.springcore.example.javabase.validation.validator;
public class TypeValidator implements ConstraintValidator<Type, String> {
	private final List<String> TYPE = Arrays.asList(new String[]{"RPG", "ACT", "SLG", "ARPG"});
	@Override
	public boolean isValid(String value, ConstraintValidatorContext context) {
		return TYPE.contains(value);
	}
}

TypeValidator必须实现ConstraintValidator这个接口,并在范型中声明对应的校验注解和数据类型(ConstraintValidator<T, E>,T是绑定的注解类型、E是数据类型)。TypeValidator中判断数值是不是"RPG", "ACT", "SLG", "ARPG"当中的一个,若不是则TypeValidator::isValid返回false表示校验没通过。

在实体类的域上使用自定义的@Type注解:

public class Game {
	@NotNull
	@Type
	private String type;
    //Other field ......
    //getter and setter ......
}

分组校验

对于业务来说数据录入的规则并不是一成不变的,往往需要根据某些状态来对单个或一组数据进行校验。这个时候我们可以用到分组功能——根据状态启用一组约束。

观察自定义注解或javax.validation.constraints包中预定以的注解,都有一个groups参数:

public @interface Max {
	String message() default "{javax.validation.constraints.Max.message}";
	Class<?>[] groups() default { }; //用于分组的参数
	Class<? extends Payload>[] payload() default { };
	long value();
}

如果未指定该参数,那么校验都属于javax.validation.groups.Default分组。

先定义一个分组,用一个没有任何功能的类或者接口即可:

package chkui.springcore.example.javabase.validation.groups;
public interface BetaGroup {}

然后在校验的注解上通过groups指定分组:

public class Game {
	
	@NotNull
	@Min(0) //最小值>=0
	@Max(10) //最大值<=10
	@Max(value=0, message="未发行的游戏版本为0!", groups = BetaGroup.class)//分组校验
	private int currentVersion; 
	
	@AssertTrue(groups = BetaGroup.class)//分组校验
	//表示是否为内侧版
	private boolean beta;
    //Other field ......
    //getter and setter ......
}

然后执行分组校验:

public enum StandardValidation {
	public void validate() {
		//引入校验工具
		ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
		Validator validator = factory.getValidator();

		Game wow = new Game();
		wow.setName("World Of Warcraft");
		wow.setDescription("由著名游戏公司暴雪娱乐所制作的第一款网络游戏,属于大型多人在线角色扮演游戏。");
		wow.setCurrentVersion(8);
		wow.setType("RPG");
		wow.setPrice(401.01F);

        //使用默认分组校验
		violationSet = validator.validate(wow);
		
		//指定分组校验
		violationSet = validator.validate(wow, BetaGroup.class);
	}
}

Validator::validator方法未指定分组时,相当于使用javax.validation.groups.Default分组。而在violationSet=validator.validate(wow, BetaGroup.class);这一行代码指定分组之后,只会执行groups = BetaGroup.class注解的校验。

可以一次指定多个分组的校验,这样有利于处理复杂的状态:

validator.validate(wow, Default.class, BetaGroup.class, OtherGroup.class);

校验错误级别

校验的注解中还有一个参数——payload,他表示“校验问题”的级别。这个参数就像使用Log4j输出日志会指定DEBUG、INFO、WARN等级别一样,在校验数据时会有对“校验问题”进行分类的需求,比如某些页面会对用户录入的数据进行“错误”或“警告”的提示。

在使用payload时需要先声明PalyLoad接口类以标定“问题级别”:

package chkui.springcore.example.javabase.validation;
public class PayLoadLevel {
    //警告级别
	static public interface WARN extends Payload {}
    //错误级别
	static public interface Error extends Payload {}
}

然后在JavaBean上指定“校验问题”的级别:

public class Game {
    //默认分组校验错误时,错误级别为Error
	@NotNull(payload=PayLoadLevel.Error.class)
	@Min(value=0, payload=PayLoadLevel.Error.class) 
	@Max(value=10, payload=PayLoadLevel.Error.class) 
    //BetaGroup分组错误级别为WARN
	@Max(value=0, message="未发行的游戏版本为0!", groups = BetaGroup.class, payload=PayLoadLevel.WARN.class)
	private int currentVersion; 
	
	@AssertTrue(groups = BetaGroup.class, payload=PayLoadLevel.WARN.class)
	private boolean beta;
    //Other field ......
    //getter and setter ......	
}

然后在执行校验的时候使用ConstraintViolation::getConstraintDescriptor::getPayload方法获取每一个校验问题的错误级别:

violationSet = validator.validate(wow, BetaGroup.class);
violationSet.forEach(violat -> {
	violat.getPropertyPath();//错误域的名称
    violat.getMessage();//错误消息
	violat.getConstraintDescriptor().getPayload();//错误级别
});