最近在工作中遇到写一些API,这些API的请求参数非常多,嵌套也非常复杂,如果参数的校验代码全部都手动去实现,写起来真的非常痛苦。正好 Spring
轮子里面有一个 Validation
,这里记录一下怎么使用,以及怎么自定义它的返回结果。
Bean Validation 是Java中的一项标准,它通过一些 注解 表达了对实体的限制规则。通过提出了一些API和扩展性的规范,这个规范是没有提供具体实现的,希望能够 Constrain once, validate everywhere
。现在它已经发展到了2.0,兼容Java8。
hibernate validation
实现了Bean Validation标准,里面还增加了一些注解,在程序中引入它我们就可以直接使用。
Spring MVC
也支持 Bean Validation
,它对 hibernate validation
进行了二次封装,添加了自动校验,并将校验信息封装进了特定的 BindingResult
类中,在SpringBoot中我们可以添加 implementation('org.springframework.boot:spring-boot-starter-validation')
引入这个库,实现对bean的校验功能。
gradle dependencies
如下:
dependencies { implementation('org.springframework.boot:spring-boot-starter-validation') implementation('org.springframework.boot:spring-boot-starter-web') }
定义一个示例的Bean,例如下面的 User.java
。
public class User{ @NotBlank @Size(max=10) private String name; private String password; public String getName(){ return name; } public void setName(String name){ this.name = name; } public String getPassword(){ return password; } public void setPassword(String password){ this.password = password; } }
在 name
属性上,添加 @NotBlank
和 @Size(max=10)
的注解,表示 User
对象的 name
属性不能为字符串且长度不能超过10个字符。
然后我们暂时不添加任何多余的代码,直接写一个 UserController
对外提供一个RESTful的 GET
接口,注意接口的参数用到了 @Validated注解
。
// UserController.java,省略其他代码 @RestController public class UserController{ @RequestMapping(value = "/validation/get", method = RequestMethod.GET) public ServiceResponse validateGet(@Validated User user){ ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(200); serviceResponse.setMessage("test"); return serviceResponse; } } // ServiceResponse.java,简单包含了code、message字段返回结果。 public class ServiceResponse{ private int code; private String message; ... 省略getter、setter ... }
启动SpringBoot程序,发一个测试请求看一下:
http://127.0.0.1:8080/validation/get?name=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&password=1
返回的结果是,注意此时的 HTTP STATUS CODE = 400
:
此时已经可以实现参数的校验了,但是返回的结果不太友好,下面看一下怎么定制返回的消息。在定制返回结果前,先看下一下内置的校验注解有哪些,在这里我不一个个去贴了,写代码的时候根据需要进入到源码里面去看即可。
在第二节,我定义了一个 ServiceResponse
,其实作为一个开放的API,我们希望不论用户传入任何参数,返回的结果都应该是预先定义好的,并且可以在接口文档写明给到调用方的,所以即使发生了校验失败,我们也希望返回一个包含 code
和 message
字段
{ "code": 51000, "message": "Param 'name' must be less than 10 characters." }
的结果,而 HTTP STATUS CODE
一直都是 200
。
为了实现这个目的,我们加一个全局异常处理方法。
@RestControllerAdvice public class ServiceExceptionHandler{ @ExceptionHandler(BindException.class) public ServiceResponse handleBindException(BindException ex){ FieldError fieldError = ex.getFieldError(); String message = "Param %s error"; if (fieldError != null && fieldError.getDefaultMessage() != null) { message = String.format(fieldError.getDefaultMessage(), fieldError.getField()); } // 生成返回结果 ServiceResponse errorResult = new ServiceResponse(); errorResult.setCode(400); errorResult.setMessage(message); return errorResult; } }
在上面的方法中,我们处理了 BindException
,并获取到了Bean对象出现错误的属性,然后取出它的 defaultMessage
,并包装成统一的 ServiceResponse
返回。由于默认的消息内容是有注解默认的 DefaultMessage
决定的,为了按照自定义的描述返回,在Bean对象的注解上需要手动赋值为我们希望返回的消息内容。
... @NotBlank(message ="Param 'name' can't be blank.") @Size(max=10,message ="Param 'name' must be less than 10 characters.") private String name; ...
这样当 name
参数长度超过10时,就会返回
{ "code": 51000, "message": "Param 'name' must be less than 10 characters." }
这里的 FieldError fieldError = ex.getFieldError();
只会随机返回一个出错的属性,如果Bean对象的多个属性都出错了,可以调用 ex.getFieldErrors()
来获得,由此可以看出Spring Validation在参数校验时不会在第一次碰到参数错误时就返回,而是会校验完成所有的参数。
显然除了自带的 NotNull
、 NotBlank
、 Size
等注解,实际业务上还会需要特定的校验规则。
假设我们有一个参数 address
,必须以 Beijing
开头,那我们可以定义一个注解和一个自定义的 Validator
。
// StartWithValidator.java public class StartWithValidatorimplements ConstraintValidator<StartWithValidation,String>{ private String start; @Override public void initialize(StartWithValidation constraintAnnotation){ start = constraintAnnotation.start(); } @Override public boolean isValid(String value, ConstraintValidatorContext context){ if (!StringUtils.isEmpty(value)) { return value.startsWith(start); } return true; } } // StartWithValidation.java @Documented @Constraint(validatedBy = StartWithValidator.class) @Target({METHOD, FIELD}) @Retention(RUNTIME) public @interface StartWithValidation { Stringmessage()default "不是正确的性别取值范围"; Stringstart()default "_"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER}) @Retention(RUNTIME) @Documented @interface List { GenderValidation[] value(); } }
然后在 User.java
中增加一个 address
属性,并给它加上上面这个自定义的注解,这里我们定义了一个可以传入 start
参数的注解,表示应该以什么开头。
... @StartWithValidation(message = "Param 'address' must be start with 'Beijing'.", start = "Beijing") private String address; ...
有的时候,我们会有两个不同的接口,但是会使用到同一个 Bean
来作为 VO
,而在不同的接口上,对Bean的校验需求可能不一样,比如接口2需要校验 studentId
,而接口1不需要。那么此时就可以用到校验注解的分组 groups
。
// User.java public class User{ ... 省略其他属性 // 指明在groups={Student.class}时才需要校验studentId @NotNull(groups = {Student.class}, message = "Param 'studentId' must not be null.") private Long studentId; // 增加Student interface public interface Student{ } } // UserController.java,增加了一个/getStudent接口 @RestController public class UserController{ @RequestMapping(value = "/validation/get", method = RequestMethod.GET) public ServiceResponse validateGet(@Validated User user){ ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(200); serviceResponse.setMessage("test"); return serviceResponse; } @RequestMapping(value = "/validation/getStudent", method = RequestMethod.GET) public ServiceResponse validateGetStudent(@Validated({User.Student.class})User user){ ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(200); serviceResponse.setMessage("test"); return serviceResponse; } }
到这里,也可以带一下 Valid
和 Validated
注解的区别,其实看代码注释就知道后者就是对前者的一个扩展,支持了 group
分组的功能。
其实还有一种比较典型的自定义返回,就是错误码( code
)和消息( message
)是一一对应的,比如:
这种情况可以结合上面3.1和3.2的方法来实现处理,具体思路是:
code
、 message
属性。 Validator
:在 isValid
方法中实现校验,并抛出一个自定义的异常 ValiationException
,这个异常包含了注解默认的 code
和 message
。 ValiationException
时,取出 code
和 message
,以 ServiceResponse
返回。 如此,只要封装一个注解和Validator的库,那么不同业务Service错误码就可以统一使用了,这种方式比较适合一个团队中对外有一份统一的API文档,包含了统一的错误码,但是却有不同的成员在开发不同的API。
其实在实际的工作中,肯定还有更复杂的校验逻辑,但是不一定非要都用注解去实现,注解的实现应该是一个比较简单通用的校验,能够达到复用,减少重复的劳动。而更加复杂的逻辑校验,一定是存在具体业务当中的,最好是在业务代码里面实现校验。