最近在工作中遇到写一些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。
其实在实际的工作中,肯定还有更复杂的校验逻辑,但是不一定非要都用注解去实现,注解的实现应该是一个比较简单通用的校验,能够达到复用,减少重复的劳动。而更加复杂的逻辑校验,一定是存在具体业务当中的,最好是在业务代码里面实现校验。