对于 WEB 项目中的 参数校验 个人经验中一直也没一套完整的解决方案,每次遇到都是接口上硬编码调用,大概就是“最朴实”的做法,传入校验的值、名字并异常做返回,下面是一段伪代码:
// xxxController
public ResultContext<XxxVo> xxx(XxxVo xxxVo) {
ParamCheckUtils.checkObjectNotNull(xxxVo, "xxx 不能为空");
ParamCheckUtils.checkStringParam("id 过长", xxxVo.getUrid(), 32, true);
ParamCheckUtils.checkIntegerParam("version 必须为整型", xxxVo.getVersion(), 10, true);
// do something
}
于是每个 Controller 方法开头那 n 行都是这些,重复代码充斥了大部分的代码排面。当接口数量不多时,倒也这样子过去了,别说还有种挺灵活的感觉。但真的面对几十个接口,几百个字段时,我是真的笑不出来。
到后来偶然了解到了 Spring Validation,于是展开了一番学习,像是打开了新世界,不但解决了重复代码问题,甚至比以前更灵活。下面分享一些自己的经验:
一些概念+入门
在 Java 平台,其实前辈们早就定义了一套标准 JSR-303(JSR-349 是它的升级版本),专门用于规范对 Java Bean 的校验,但不提供具体的实现。类似 java.sql.Driver 组织提供标准,各个数据库厂商提供实现。常见的实现是 hibernate-validator。
假设当前有 POJO
public class PersonForm {
private String name;
private int age;
}
JSR-303 允许使用者在字段属性上添加规定的注解,来约束这些属性的值
public class PersonForm {
@NotNull
@Size(max=64)
private String name;
@Min(0)
private int age;
}
当调用 JSR-303 标准的校验器校验此类实例,会按照在类字段上定义的约束执行。
public static void main(String[] args) {
ParamC param = new ParamC();
// 创建一个默认的校验器
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
Set<ConstraintViolation<ParamC>> violations = validator.validate(param);
for (ConstraintViolation<ParamC> constraintViolation : violations) {
// 字段路径
Path propertyPath = constraintViolation.getPropertyPath();
// 失败信息
String message = constraintViolation.getMessage();
}
}
关于 validator 实例,这里又涉及到 SPI 的知识点,就不展开。简单来说,默认查找类路径下面:META-INF/services/javax.validation.spi.ValidationProvider 文件中提供的实现类完整路径。如果你引入了 hibernate-validator 的依赖,此文件内容为:
org.hibernate.validator.HibernateValidator
JSR-303 标准提供了如下注解:
| 注解 | 描述 |
|---|---|
| @Null | 被注释的元素必须为 null |
| @NotNull | 被注释的元素必须不为 null |
| @AssertTrue | 被注释的元素必须为 true |
| @AssertFalse | 被注释的元素必须为 false |
| @Min(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
| @Max(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
| @DecimalMin(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
| @DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
| @Size(max, min) | 被注释的元素的大小必须在指定的范围内 |
| @Digits(integer, fraction) | 被注释的元素必须是一个数字,其值必须在可接受的范围内 |
| @Past | 被注释的元素必须是一个过去的日期 |
| @Future | 被注释的元素必须是一个将来的日期 |
| @Pattern(value) | 被注释的元素必须符合指定的正则表达式 |
当然还可以进行扩展,hibernate-validator 在此基础上添加了一些
| 注解 | 描述 |
|---|---|
| 被注释的元素必须是电子邮箱地址 | |
| @Length | 被注释的字符串的大小必须在指定的范围内 |
| @NotEmpty | 被注释的字符串的必须非空 |
| @Range | 被注释的元素必须在合适的范围内 |
除此之外,还可以自定义约束和校验器(后续讲)。接着,讲讲如何与 Spring 结合进行使用。
Spring 提供了对 Bean 验证 API 的全面支持,支持由使用者自定义 validator,也可以使用默认的校验器,如下
<bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean"/>
默认去类路径下找实现了 JSR-303 标准的校验器(上文提到的 SPI 方式)。可以理解为 LocalValidatorFactoryBean 这个类是 Spring 和 hibernate-validator 之间的桥梁。
如果你用的是 SpringBoot 项目,直接跳过 pom、和 xml 修改两步,直接加上注解就可以使用。假如你是传统的 SpringMVC 项目,需要先把对应的校验器依赖进来,如下:
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.6.Final</version>
</dependency>
<!-- 无需再单独依赖 Bean Validation API 包,该包中已依赖-->
如果你的项目没有打开注解驱动,那也是不生效的,所以请保证此配置已被打开,如下:
<mvc:annotation-driven/>
做完这两步,接下来再加一些注解就搞定了,如下:
// 需要校验的实体类
// import org.hibernate.validator.constraints.NotBlank;
// import org.hibernate.validator.constraints.Length;
@Data
public class Param {
// 该属性不能为空,且长度为 2-10 个字符之间
@NotBlank
@Length(min=2, max=10)
String name;
}
// 某 Controller 方法
// import org.springframework.validation.BindingResult;
// import org.springframework.validation.annotation.Validated;
@RequestMapping(value = "/test", method = RequestMethod.POST)
public String test(@Validated Param param, BindingResult bindingResult) {
System.out.println(bindingResult.getAllErrors());
return "success";
}
假设现在前端上传的 name 字段为空或者是长度不规范,此时 SpringMVC 会识别出 @Validated 注解,并结合
hibernate.validator 框架或者是 JRS 中定义的在字段上面添加的注解,对上传的数据进行校验,最后将结果封装在 BindingResult 对象实例中。此时使用者就能拿到详细的错误信息,来进行一些后续的操作。
这里请注意,一次请求可能存在多个字段同时错误。hibernate.validator 默认采用 完整校验,会把所有的错误字段都校验出来
List<FieldError> errors = bindingResult.getFieldErrors();
如果你只想 随意(对顺序无所谓) 获取一个错误,并不用将每个错误都展示出去,那么可以这么获取:
FieldError error = bindingResult.getFieldError();
// 错误的字段名
String fieldName = error.getField();
// 在注解上输入的自定义错误文字
String errMsg = error.getDefaultMessage();
System.out.println(String.format("字段名:%s,错误信息:%s", fieldName, errMsg));
当然,假如你想一遇到错误就立即返回,hibernate.validator 还提供了 Fail fast mode 也就是快速失败模式。
Validator failFastValidator = Validation
.byProvider(HibernateValidator.class)
.configure().failFast(true)
.buildValidatorFactory().getValidator()
注意一点,快速失败模式 并不保证约束被校验的顺序,假如对字段顺序有一定的要求,请跳到后面对 按序校验 的章节,以下是 hibernate-validator 官网的引用:链接
There is no guarantee in which order the constraints are evaluated
这时候,虽说已不用挨个手动调用进行校验,但是返回的校验结果还是要每个方法写一遍,依旧很费力。好在 SpringMVC 还给我们提供了另外一种错误结果交互方式 异常。
结合 SpringMVC 全局异常处理
只要将 Controller 方法中的 BindingResult 参数去掉,SpringMVC 就会以默认抛出 org.springframework.validation.BindException 异常来通知使用者校验失败。
有了异常,很自然地联想到 SpringMVC 中对统一异常的处理,下面是一段示例代码:
@Slf4j
@ControllerAdvice
public class ExceptionAdvice {
/**
* 与 @Validated 注解搭配使用,对 Controller 接口参数的校验异常
* 进行统一处理返回
*/
@ResponseBody
@ExceptionHandler(BindException.class)
public String handleBindException(BindException ex) {
BindingResult result = ex.getBindingResult();
if (result.hasErrors()) {
FieldError fieldError = result.getFieldError();
if (fieldError == null) {
log.error("fieldError 实例为 null");
return OpenApiBaseResponse.gatewayFail(UserTips.PARAM_BINDING_ERROR);
}
String fieldName = fieldError.getField();
String defaultErrMsg = fieldError.getDefaultMessage();
if (fieldError.isBindingFailure()) {
return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " " + UserTips.PARAM_BINDING_ERROR);
}
if (NotBlank.class.getSimpleName().equals(fieldError.getCode())) {
return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " 必填");
}
if (NotNull.class.getSimpleName().equals(fieldError.getCode())) {
return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " 必填");
}
if (Length.class.getSimpleName().equals(fieldError.getCode())) {
return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " " + defaultErrMsg);
}
return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, defaultErrMsg);
}
// 其他绑定异常
return OpenApiBaseResponse.gatewayFail(UserTips.PARAM_BINDING_ERROR);
}
}
注:OpenApiBaseResponse 该类是自己定义的统一返回信息载体,实战请选择自定义返回
此时,只需要在接受参数的 POJO 类的属性加几个注解,做到了 全自动参数校验。除此之外,利用了全局的异常处理,对参数校验失败结果响应做了统一的处理。回顾,与之前相比已是一个质变。
还没完,之前的方案虽说是最笨的,却有个好处,非常的灵活。假设现在有这样的情况,你要检验的对象并不是从 HTTP 方式中进入的,而是 service 间的方法调用,那新的方式就不能适用。又或许你在 web 层方法中需要先用 Map、String 来接受,后续再进行转 Bean 操作,新的方式也不适用。
所以下面要讲的是,添加 手动校验工具类,并与之前的 SpringMVC 统一异常处理相结合,来解决这些场景中的问题。
手动校验
SpringMVC 之所以能校验 bean 实例,底层也是调用了 JSR 标准提供的 API,而真正的逻辑在 hibernate-validator 中。
结合上面默认注入的 LocalValidatorFactoryBean,我们可以获取 bean name 为 validator 的 SpringBean 来拿到真正的校验器。或者是自己手动创建一个,如下:
注: 如果是手动创建,建议与注入 Spring 中的校验器的配置保持一致,当系统存在两个不同行为的校验器,会导致相同的实例在自动、手动校验后,结果却不一致的情况
import org.hibernate.validator.HibernateValidator;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
// 手动校验器
public class ValidationUtil {
private static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
public static <T> void validate(T obj) throws BindException {
Set<ConstraintViolation<T>> constraintViolations = validator.validate(obj);
if (constraintViolations.size() > 0) {
BeanPropertyBindingResult result = new BeanPropertyBindingResult(obj, "");
Iterator<ConstraintViolation<T>> iterator = constraintViolations.iterator();
while (iterator.hasNext()) {
ConstraintViolation<T> cv = iterator.next();
String field = cv.getPropertyPath().toString();
String defaultMessage = cv.getMessage();
String annotationName = cv.getConstraintDescriptor().
getAnnotation().annotationType().getSimpleName();
result.addError(new FieldError("", field, null, false, new String[]{annotationName}, null, defaultMessage));
}
throw new BindException(result);
}
}
public static <T> void throwBindException(T obj, String field, boolean bindingFailure, String message) throws BindException {
BeanPropertyBindingResult result = new BeanPropertyBindingResult(obj, "");
result.addError(new FieldError("", field, null, bindingFailure, new String[]{}, null, message));
throw new BindException(result);
}
}
为了能被统一异常处理捕捉,ValidationUtil#validate 方法模仿 Spring 行为,自己构建了 BindException 异常。,而 ValidationUtil#throwBindException 方法则是为了,那些没有调用 validate 方法而又是参数校验失败情况而创建。
再来看之前的场景。web 入口方法并不是一个 Bean 类型,假如放在 body 域中且有多种类型。如此也无妨,现在只需在解析成 bean 实例之后,再手动调用一次 validate 方法,也能得到与自动校验相同的结果
public OpenApiBaseResponse<?> xxx(HttpServletRequest request) throws BindException {
String ori = super.readAsString(request);
OpenApiBaseRequest<R> reqInstance = null;
try {
// 忽略 clazz 的获取逻辑
reqInstance = JSON.parseObject(ori, new TypeReference<OpenApiBaseRequest<R>>(clazz){});
}
catch (Exception e) {
log.error("解析 JSON 请求异常", e);
return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR);
}
//
ValidationUtil.validate(reqInstance);
//
}
嵌套校验
假如请求报文存在嵌套(json 对象中套 json 对象),那后台的 pojo 类也存在嵌套。
若想内外对象一起校验,形成递归式,那必须在内层对象属性上加 javax.validation.Valid 注解,否则默认不校验 T 类型对象。
如下:
public class OpenApiBaseRequest<T extends OpenApiBizContent> {
@Valid
private T bizContent;
}
分组校验
类似管理后台,新增和更新接口两者的 pojo 对象往往很相似,两接口若是公用 pojo,分组校验 就派上用场了。首先定义两个 标识接口 Add、Update,并在各个约束注解上加 groups 属性,指定对应校验组别:
public class TestVo {
interface Add {}
interface Update {}
@NotBlank(groups = Update.class, message = "更新时 id 必填")
private String id;
@NotBlank(groups = Add.class, message = "新增时 code 必填")
private String code;
}
controller 入口处,则根据不同入口,传入不同的分组校验标识接口
@RequestMapping(value = "/add", method = RequestMethod.POST)
public String add(@Validated(value=TestVo.Add.class) TestVo param) {
return "success";
}
@RequestMapping(value = "/update", method = RequestMethod.POST)
public String update(@Validated(value=TestVo.Update.class) TestVo param) {
return "success";
}
自定义校验器:特殊字符、可枚举值校验器
JSR 标准留的自定义校验器接口,提供了巨大的扩展性,基于此我们可以实现很多系统内部自定义的约束,分享在工作中常用的两个例子:
特殊字符
仿照已存在的注解(如:javax.validation.constraints.NotNull),自定义注解,同时使用 @Constraint 关联对应的校验器
@Target( {FIELD})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {SpecialCharacterValidator.class})
public @interface SpecialCharacter {
String regexp() default "";
String message() default "值中含有回车符、换行符或制表符";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
创建自定义检验器,实现 JSR 标准定义的 javax.validation.ConstraintValidator 接口
public class SpecialCharacterValidator implements ConstraintValidator<SpecialCharacter, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value != null && !value.equals("")) {
// 回车符、制表符、换行符
if (value.contains("\r") || value.contains("\n") || value.contains("\t") || value.contains("\r\n")) {
return false;
}
}
return true;
}
@Override
public void initialize(SpecialCharacter constraintAnnotation) {
}
}
可枚举值校验器
前端上传的某个字段是否处于后端要求的范围内,以前你会怎么实现呢?是不是想想就很复杂?现在有了自定义约束,轻松拿下。
相比于前者,这个稍微难些。注解内多了一个 target 属性,用于接受枚举值的类类型,只有拿到类型,才能调用其中方法。
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {EnumCheckValidator.class})
public @interface EnumCheck {
Class<?> target();
String regexp() default "";
String message() default "值必须在枚举值内中选填";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
同样地需要创建对应的校验器,这里复写了 initialize 初始化方法,给内部 targetClass 赋了值
public class EnumCheckValidator implements ConstraintValidator<EnumCheck, String> {
Class<?> targetClass;
@Override
public void initialize(EnumCheck constraintAnnotation) {
targetClass = constraintAnnotation.target();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (!targetClass.isEnum()) {
return false;
}
// 枚举类的成员
Object[] enumInstances = targetClass.getEnumConstants();
// 枚举类的所有 value 值集合
Set<String> values = new HashSet<>();
try {
for (Object enumInstance : enumInstances) {
Method getValue = targetClass.getMethod("getValue");
Object valueObj = getValue.invoke(enumInstance, null);
values.add(valueObj.toString());
}
}
catch (Exception e) {
return false;
}
return values.contains(value);
}
}
几个点需要注意:
EnumCheckValidator与字段进行一对一绑定,每个字段都有自己的校验器,不存在并发问题- 定义枚举时,需要有 getValue 方法,建议用实现接口方式进行约定,如下
// 约定所有枚举都要实现该接口
public interface LabelAndValue<T> extends Serializable {
T getValue();
String getLabel();
}
public enum YesOrNoEnum implements LabelAndValue<String> {
YES("1", "是"),
NO("0", "否"),;
private String value;
private String label;
YesOrNoEnum(String value, String label) {
this.value = value;
this.label = label;
}
@Override
public String getValue() {
return value;
}
@Override
public String getLabel() {
return label;
}
}
- 效率问题
可以考虑在初始化时,做一层缓存,并不是每次都需要进行反射调用
按顺校验
说实话自己在实践中并没有遇到过,字段校验对顺序有严格要求的场景。不过我们测试人员确实发现某个接口同时存在多个字段错误时,重启前后 会出现提示不一致情况。
JSR 标准当然也想到了,做法与分组校验类似,多加一个顺序标识接口。定义标识接口,加 @GroupSequence 注解,参数内指定校验的顺序,其中 Default.class 是不加 groups 的属性,如下面的 c 属性
@Data
public class TestVo2 {
interface A {}
interface B {}
@GroupSequence({ Default.class, A.class, B.class })
interface CheckOrder1 { }
@NotBlank(groups = TestVo2.A.class, message = "a 错误")
private String a;
@NotBlank(groups = TestVo2.B.class, message = "b 错误")
private String b;
@NotBlank(message = "c 错误")
private String c;
}
调用方传入 CheckOrder1 标识接口进行分组校验
@RequestMapping(value = "/test3", method = RequestMethod.POST)
public String test3(@Validated(value=TestVo2.CheckOrder1.class) TestVo2 param) {
return "success";
}
到这里文章已经结束了。但还有一点是没有讲到的,字段的联合校验,不知大家有没有什么好的方法分享?