服务端在向外提供接口服务时,不管是对前端提供HTTP接口,还是面向内部其他服务端提供的RPC接口,常常会面对这样一个问题,就是如何优雅的解决各种接口参数校验问题?
早期大家在做面向前端提供的HTTP接口时,对参数的校验可能都会经历这几个阶段:
其中最常见的就是定制检验代码和通用标准的校验逻辑,前者是利用大量的if/else语句,后者指的就是基于JSR303的Java Bean Validation,其中官方指定的具体实现就是 Hibernate Validator,在Web项目中结合Spring可以做到很优雅的去进行参数校验。
大量的 if / else 使代码非常臃肿
/**
* 员工对象
*
* @author sunnyzyq
* @since 2019/12/13
*/
@Data
public class Employee {
/**
姓名
*/
private String name;
/**
年龄
*/
private Integer age;
/**
邮箱地址
*/
private String email;
/**
手机号
*/
private String phone;
}
@Controller
public class TestController {
@RequestMapping("/add")
@ResponseBody
public String add(Employee employee) {
String name = employee.getName0;
if (name == null || name.trim().length == 0){
return"员工名称不能为空"
}
if (name.trim().length0 > 10){
return"员工名称不能超过10个字符"
}
return "新增员工成功";
}
}
以上代码肯定是可以正常的校验员工名称收为空以及长度是否符合的,但是随着检验条件的增多,我们会需要越来越多的代码,比如我们规定年龄也是必填项,且范围在1到100岁,那么此时,我们需要增加对应判定代码如下:
@Controller
public class TestController {
@RequestMapping("/add")
@ResponseBody
public String add(Employee employee) {
String name = employee.getName0;
if (name == null || name.trim().length == 0){
return"员工名称不能为空"
}
if (name.trim().length0 > 10){
return"员工名称不能超过10个字符"
}
// 新增校验条件
Integer age = employee.getAge();
if(age == null){
return "年龄不能为空";
}
if(age < 1 || age > 10){
return "年龄不能大于10岁或者小于1岁";
}
return "新增员工成功";
}
}
定制检验代码现在就会出现一种情况,每校验一个字段就需要增加6行的代码,此时只校验了两个字段,要是有20个字段,岂不是要写 100 多行代码?通常来说,当一个方法中的无效业务代码量过多时,往往代码设计有问题,当然这不是我们所想看到都结果。
其实我真的觉得现在作为一个程序员是幸运的,因为有很多的轮子已经造好了,同时,我觉得现在作为程序员是不幸运的,因为很多轮子已经造好了…
没错,java早就帮我们准备好了更方便的参数校验方式。-- Bean Validation
Bean Validation技术隶属于Java EE规范,期间有多个JSR(Java Specification Requests)支持,目前共有三次相关JSR标准发布:
JSR303提出很早(2009年),它为 基于注解的 JavaBean验证定义元数据模型和API。JSR-303主要是对JavaBean进行验证,如方法级别(方法参数/返回值)、依赖注入等的验证是没有指定的。
作为开山之作,它规定了Java数据校验的模型和API,这就是Java Bean Validation 1.0版本。
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>1.0.0.GA</version>
</dependency>
该版本提供了13个现在常见的校验注解:
注解 | 支持类型 | 含义 | null值是否校验 |
---|---|---|---|
@AssertFalse | bool | 元素必须是false | 否 |
@AssertTrue | bool | 元素必须是true | 否 |
@DecimalMax | Number的子类型(浮点数除外)以及String | 元素必须是一个数字,且值必须<=最大值 | 否 |
@DecimalMin | 同上 | 元素必须是一个数字,且值必须>=最小值 | 否 |
@Max | 同上 | 同上 | 否 |
@Min | 同上 | 同上 | 否 |
@Digits | 同上 | 元素构成是否合法(整数部分和小数部分) | 否 |
@Future | 时间类型(包括JSR310) | 元素必须为一个将来(不包含相等)的日期(比较精确到毫秒) | 否 |
@Past | 同上 | 元素必须为一个过去(不包含相等)的日期(比较精确到毫秒) | 否 |
@NotNull | any | 元素不能为null | 是 |
@Null | any | 元素必须为null | 是 |
@Pattern | String | 元素需符合指定的正则表达式 | 否 |
@Size | String/Collection/Map/Array | 元素大小需在指定范围中 | 否 |
它的官方参考实现如下:
该规范2013年完成伴随java EE 7一起发布,就是我们比较熟悉的Bean Validation1.1。
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>1.1.0.Final</version>
</dependency>
相较于1.0版本,它主要的改进/优化有如下几点:
它的官方参考实现如下:
注:当你导入了hibernate-validator后,无需再显示导入javax.validation,反之亦同
当下主流版本,也就是Java Bean Validation 2.0,它完成于2017年8月,在2019年8月发布,属于Java EE 8的一部分。它的官方参考实现只有唯一的Hibernate validator了:
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
新增注解
注解 | 支持类型 | 含义 | null值是否校验 |
---|---|---|---|
String | 元素必须是电子邮箱地址 | 否 | |
@NotEmpty | 容器类型 | 集合的Size必须大于0 | 是 |
@NotBlank | String | 字符串必须包含至少一个非空白的字符 | 是 |
@Positive | Positive | 元素必须必须为正数(不包括0) | 否 |
@PositiveOrZero | 同上 | 同上(包括0) | 否 |
@Negative | 同上 | 元素必须必须为负数(不包括0) | 否 |
@NegativeOrZero | 同上 | 同上(包括0) | 否 |
@PastOrPresent | 时间类型 | 在@Past基础上包括相等 | 否 |
@FutureOrPresent | 时间类型 | 在@Futrue基础上包括相等 | 否 |
以上就是java中参数校验轮子的发展历程。
Validation 从1.1版本起就需要El管理器支持用于错误消息动态插值,因此需要自己额外导入EL的实现。EL也属于Java EE标准技术,可认为是一种表达式语言工具,它并不仅仅是只能用于Web,可以用于任意地方(类比Spring的SpEL)
<dependency>
<groupId>javax.el</groupId>
<artifactId>javax.el-api</artifactId>
<version>3.0.0</version>
</dependency>
以上是EL技术规范的API,Expression Language 3.0表达式语言规范于2013-4-29发布,Tomcat 8、Jetty 9、GlasshFish 4都已经支持实现了EL 3.0,如果你是web环境,就不用自己手动导入了。
简单来说以上JSR提供了一套Bean校验规范的API,维护在包javax.validation.constraints下。该规范使用属性或者方法参数或者类上的一套简洁易用的注解来做参数校验。开发者在开发过程中,仅需在需要校验的地方加上形如@NotNull, @NotEmpty , @Email的注解,就可以将参数校验的重任委托给一些第三方校验框架来处理。
目前在最常用的springboot 项目中, Spring Boot 2.3.0 之前的 spring-boot-starter-web 依赖中已经自带了,可以直接使用。但是如果是 2.3.0以后的Spring Boot项目则需要手动引入依赖包
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>2.0.2</version>
</dependency>
上面两个jar随便引入哪个都可以,就算是都引入了也没有关系,因为他们的api完全一致。
Hibernate Validator 官网说明:Hibernate Validator
在Spring MVC中,只需要使用@Valid注解标注在方法参数商,Spring MVC即可对参数对象进行校验,校验结果会放在BindingResult对象中。除了@Valid 还有 @Validated注解。@validated是对@Valid 进行了二次封装,在使用上并没有区别,但在分组、注解位置、嵌套验证等功能上有所不同:
不同点 | @Valid | @Validated |
---|---|---|
来源 | 是Hibernate validation 的 校验注解 | 是 Spring Validator 的校验注解,是 Hibernate validation 基础上的增加版 |
注解位置 | 构造函数、方法、方法参数、成员属性 | 类、方法、方法参数 |
嵌套验证 | 用在级联对象的成员属性上 | 不支持 |
分组 | 不支持 | 提供分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制 |
校验结果 | 校验时需要用 BindingResult 来做一个校验结果接收。当校验不通过的时候,如果手动不return ,则并不会阻止程序的执行 | 校验时无需接收校验结果,当校验不通过时,程序会抛出400异常,阻止方法中的代码执行,这时需要再写一个全局校验异常捕获处理类,然后返回校验提示。(配合@RestControllerAdvice非常好用) |
总体来说,在你不需要嵌套验证的情况下,@Validated 使用起来要比 @Valid 方便一些,它可以帮我们节省一定的代码,并且使得方法看上去更加的简洁,同时还有更友好的分组功能。
成员属性上增加注解
package com.zyq.beans;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.Range;
/**
* 员工对象
*
* @author sunnyzyq
* @since 2019/12/13
*/
public class Employee {
/** 姓名 */
@NotBlank(message = "请输入名称")
@Length(message = "名称不能超过个 {max} 字符", max = 10)
public String name;
/** 年龄 */
@NotNull(message = "请输入年龄")
@Range(message = "年龄范围为 {min} 到 {max} 之间", min = 1, max = 100)
public Integer age;
}
然后再 Controller 对应方法上,对这个员工标上 @Valid 注解,表示我们对这个对象属性需要进行验证,同时使用@Valid 注解时就必须手动处理校验结果。做法也很简单,在参数直接添加一个BindingResult,具体如下:
@Controller
public class TestController {
@RequestMapping("/add")
@ResponseBody
public String add(@Valid Employee employee, BindingResult bindingResult){
// 所有字段是否验证通过,true-数据有误,false-数据无误
if (bindingResult.hasErrors()) [
// 验证有误情况,返回第一条错误信息到前端
return bindingResult.getAllErrors().get(0).getDefaultMessage():
}
// TODO 保存到数据库
return"新增员工成功"
}
}
可以看到,相比于手动校验,效果相同,代码却简洁了很多。
在使用 @Valid 进行验证的时候,需要用一个对象去接收校验结果,最后根据校验结果判断,此时如果去掉手动接收参数
@Controller
public class TestController {
@RequestMapping("/add")
@ResponseBody
public String add(@Valid Employee employee, BindingResult bindingResult){
// 所有字段是否验证通过,true-数据有误,false-数据无误
/*
if (bindingResult.hasErrors()) [
// 验证有误情况,返回第一条错误信息到前端
return bindingResult.getAllErrors().get(0).getDefaultMessage():
}
*/
// TODO 保存到数据库
return"新增员工成功"
}
}
也就说@Valid并不会阻挡程序的执行,只是将校验结果进行了一个存储,使用者需要进入校验结果集合中进行手动处理。
相比之下,@Validated更加人性,会自动阻塞程序运行,且不需要手动获取校验结果
@Controller
public class TestController {
@RequestMapping("/add")
@ResponseBody
public String add(@Validated Employee employee){
// TODO 保存到数据库
return"新增员工成功"
}
}
在实际开发的过程中,我们肯定不能讲异常直接展示给用户,而是给能看懂的提示。于是,我们不妨可以通过捕获异常的方式,将该异常进行捕获。
首先我们创建一个校验异常捕获类 ValidExceptionHandler ,然后打上 @RestControllerAdvice 注解,该注解表示他会去抓所有 @Controller 标记类的异常,并在异常处理后返回以 JSON 或字符串的格式响应前端。
package com.zyq.config;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ValidExceptionHandler {
@ExceptionHandler(BindException.class)
public String validExceptionHandler(BindException exception) {
return exception.getAllErrors().get(0).getDefaultMessage();
}
}
那么,我们现在重启程序,然后重新请求,就可以发现界面已经不报400错误了,而是直接提示了我们的错误信息。
比如我们现在有个实体叫做Item,Item带有很多属性,属性里面有:pid、vid、pidName和vidName
public class Item {
@NotNull(message = "id不能为空")
@Min(value = 1, message = "id必须为正整数")
private Long id;
@NotNull(message = "props不能为空")
@Size(min = 1, message = "至少要有一个属性")
private List<Prop> props;
}
public class Prop {
@NotNull(message = "pid不能为空")
@Min(value = 1, message = "pid必须为正整数")
private Long pid;
@NotNull(message = "vid不能为空")
@Min(value = 1, message = "vid必须为正整数")
private Long vid;
@NotBlank(message = "pidName不能为空")
private String pidName;
@NotBlank(message = "vidName不能为空")
private String vidName;
}
正常情况,Spring Validation框架只会对Item的id和props做非空和数量验证,不会对props字段里的Prop实体进行字段验证。
如何进行嵌套校验?
为了能够进行嵌套验证,必须手动在Item实体的props字段上明确指出这个字段里面的实体也要进行验证。由于@Validated不能用在成员属性(字段)上,但是@Valid能加在成员属性(字段)上,而且@Valid类注解上也说明了它支持嵌套验证功能,那么我们能够推断出:@Valid加在方法参数时并不能够自动进行嵌套验证,而是用在需要嵌套验证类的相应字段上,来配合方法参数上@Validated或@Valid来进行嵌套验证。
修改Item类如下所示:
public class Item {
@NotNull(message = "id不能为空")
@Min(value = 1, message = "id必须为正整数")
private Long id;
@Valid // 嵌套验证必须用@Valid
@NotNull(message = "props不能为空")
@Size(min = 1, message = "props至少要有一个自定义属性")
private List<Prop> props;
}
除了上面常见的@NotNull、@Min、@NotBlank和@Size等校验注解我们还可以自定义校验注解~
举例说明自定义注解的实现:需要一个自定义注解来校验入参name不能和已存在name重名
自定义注解
@Target({ElementType.FIELD,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueConstraintValidator.class)
public @interface UniqueConstraint {
//下面三个属性是必须有的属性
String message();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
新建一个UniqueConstraintValidator类来验证注解
//自定义校验注解 的 校验逻辑
//不需要加注解@Component,因为实现了ConstraintValidator接口自动会注册为spring bean
public class UniqueConstraintValidator implements ConstraintValidator<UniqueConstraint,Object> {
@Autowired
private UserService userService;
@Override
public void initialize(UniqueConstraint uniqueConstraint) {
System.out.println("my validator init");
}
//Object为校验的字段类型
//返回true则校验成功
//o为校验字段的值,constraintValidatorContext为校验注解里的属性值
@Override
public boolean isValid(Object o, ConstraintValidatorContext constraintValidatorContext) {
String username = (String) o;
TbUser user = userService.findByUsername(username);
return user==null?true:false;
}
}
约束也可以放在类级别上(也就说注解标注在类上)。在这种情况下,验证的主体不是单个属性,而是整个对象。如果验证依赖于对象的几个属性之间的相关性,那么类级别约束就能搞定。
这个需求场景在平时开发中也非常常见,比如此处我举个简单场景案例:修改用户名密码,需要输入两遍新密码:newPass,newPassAgain,要求newPass.equals(newPassAgain)。如果用事务脚本来实现这个验证规则,那么你的代码里肯定穿插着类似这样的代码:
if (!this.newPass.equals(this.newPassAgain)){
throw new RuntimeException("...");
}
虽然这么做也能达到校验的效果,但很明显这不够优雅。
但是基于Hibernate-Validator内置的@ScriptAssert,可以很容易的处理这种case:
@ScriptAssert(lang = "javascript", alias = "_", script = "_.newPass.equals(_.newPassAgain)",message = "两个密码不相等")
public class SecContent implements Serializable {
@NotNull(message = "age 不能为空",groups = {TestGroup.class})
private Integer age;
@NotBlank
private String newPass;
@NotBlank
private String newPassAgain;
...
}
@ScriptAssert支持写脚本来完成验证逻辑,这里使用的是javascript(缺省情况下的唯一选择,也是默认选择)
@ScriptAssert是内置就提供的,因此使用起来非常的方便和通用。但缺点也是因为过于通用,因此语义上不够明显,需要阅读脚本才知。推荐少量(非重复使用)、逻辑较为简单时使用,更为轻巧
Dubbo作为国产优秀的开源RPC框架,同样支持注解方式校验参数!同时也是基于JSR303去实现的,我们来看下具体是怎么实现的。
ValidationFilter通过在实际方法调用之前,根据调用者url配置的validation属性值找到正确的{Validator}实例来调用验证。
关于ValidationFilter是如何被调用的是dubbo spi的内容这里就不提了,但是要想其生效需要在consumer或者provider端配置一下:
consumer:
@DubboReference(validation = "true")
private DemoService demoService;
或provider:
@DubboService(validation = "true")
public class DemoServiceImpl implements DemoService {
注:如果在消费端开启参数校验,不通过就不会向服务端发起rpc调用,但是要自己处理校验异常ConstraintViolationException
javax.validation.ValidationException: Failed to validate service: com.xxx.demo.UserFacade, method: updateUser, cause: [ConstraintViolationImpl{interpolatedMessage='用户名不能为空', propertyPath=name, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户名不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户手机号不能为空', propertyPath=phone, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户手机号不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户标识不能为空', propertyPath=id, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户标识不能为空'}]
javax.validation.ValidationException: Failed to validate service: com.xxx.demo.UserFacade, method: updateUser, cause: [ConstraintViolationImpl{interpolatedMessage='用户名不能为空', propertyPath=name, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户名不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户手机号不能为空', propertyPath=phone, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户手机号不能为空'}, ConstraintViolationImpl{interpolatedMessage='用户标识不能为空', propertyPath=id, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用户标识不能为空'}]
at org.apache.dubbo.validation.filter.ValidationFilter.invoke(ValidationFilter.java:96)
at org.apache.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:83)
....
at org.apache.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.received(HeaderExchangeHandler.java:175)
at org.apache.dubbo.remoting.transport.DecodeHandler.received(DecodeHandler.java:51)
at org.apache.dubbo.remoting.transport.dispatcher.ChannelEventRunnable.run(ChannelEventRunnable.java:57)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
从异常堆栈内容我们可以看出这个异常信息返回是由ValidationFilter抛出的,从名字我们可以猜到这个是采用Dubbo的Filter扩展机制的一个内置实现,当我们对Dubbo服务接口启用参数校验时(即前文Dubbo服务配置中的validation=“true”),该Filter就会真正起作用,我们来看下其中的关键实现逻辑:
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
if (validation != null && !invocation.getMethodName().startsWith("$")
&& ConfigUtils.isNotEmpty(invoker.getUrl().getMethodParameter(invocation.getMethodName(), VALIDATION_KEY))) {
try {
Validator validator = validation.getValidator(invoker.getUrl());
if (validator != null) {
// 注1
validator.validate(invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments());
}
} catch (RpcException e) {
throw e;
} catch (ValidationException e) {
// 注2
return AsyncRpcResult.newDefaultAsyncResult(new ValidationException(e.getMessage()), invocation);
} catch (Throwable t) {
return AsyncRpcResult.newDefaultAsyncResult(t, invocation);
}
}
return invoker.invoke(invocation);
}
从前文的异常堆栈信息我们可以知道异常信息是由上述代码「注2」处所产生,这边是因为捕获了ValidationException,通过走读代码或者调试可以得知,该异常是由「注1」处valiator.validate方法所产生。
而Validator接口在Dubbo框架中实现只有JValidator,这个通过idea工具显示Validator所有实现的UML类图可以看出(如下图所示),当然调试代码也可以很轻松定位到。
既然定位到JValidator了,我们就继续看下它里面validate方法的具体实现,关键代码如下所示:
@Override
public void validate(String methodName, Class<?>[] parameterTypes, Object[] arguments) throws Exception {
List<Class<?>> groups = new ArrayList<>();
Class<?> methodClass = methodClass(methodName);
if (methodClass != null) {
groups.add(methodClass);
}
Set<ConstraintViolation<?>> violations = new HashSet<>();
Method method = clazz.getMethod(methodName, parameterTypes);
Class<?>[] methodClasses;
if (method.isAnnotationPresent(MethodValidated.class)){
methodClasses = method.getAnnotation(MethodValidated.class).value();
groups.addAll(Arrays.asList(methodClasses));
}
groups.add(0, Default.class);
groups.add(1, clazz);
Class<?>[] classgroups = groups.toArray(new Class[groups.size()]);
Object parameterBean = getMethodParameterBean(clazz, method, arguments);
if (parameterBean != null) {
// 注1
violations.addAll(validator.validate(parameterBean, classgroups ));
}
for (Object arg : arguments) {
// 注2
validate(violations, arg, classgroups);
}
if (!violations.isEmpty()) {
// 注3
logger.error("Failed to validate service: " + clazz.getName() + ", method: " + methodName + ", cause: " + violations);
throw new ConstraintViolationException("Failed to validate service: " + clazz.getName() + ", method: " + methodName + ", cause: " + violations, violations);
}
}
从上述代码中可以看出当「注1」和注「2」两处代码进行参数校验时所得到的「违反约束」的信息都被加入到violations集合中,而在「注3」处检查到「违反约束」不为空时,就会抛出包含「违反约束」信息的ConstraintViolationException,该异常继承自ValidationException,这样也就会被ValidationFilter中方法所捕获,进而向调用方返回相关异常信息。
在JValidator的validate方法中可以看到有一个@MethodValidated注解
服务方代码:
dubbo client interface:
public interface DemoService {
String sayHello(String name);
@MethodValidated({TestGroup.class})
String sayGoodBye(Content content);
default CompletableFuture<String> sayHelloAsync(String name) {
return CompletableFuture.completedFuture(sayHello(name));
}
}
方法入参Content:
public class Content implements Serializable {
@NotNull(message = "name不能为空",groups = {TestGroup.class})
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
消费方代码:
@Component("demoServiceComponent")
public class DemoServiceComponent implements DemoService {
@DubboReference(validation = "true")
private DemoService demoService;
@Override
public String sayHello(String name) {
return demoService.sayHello(name);
}
@Override
public String sayGoodBye(Content content) {
return demoService.sayGoodBye(content);
}
@Override
public CompletableFuture<String> sayHelloAsync(String name) {
return null;
}
}
注:没有设置groups的校验注解也会进行校验,作为默认分组(像kafka一样分配一个默认组)。最后捕获下抛出的ConstraintViolationException以结构化的json格式返回给调用方"校验错误信息"