前言
主要议题
- Bean Validation(JSR-303):介绍Java Bean验证、核心API、实现框架Hibernate Validator
- Apache commons-validator:介绍最传统Apache通用验证器框架,如:长度、邮件等方式。
- Spring Validator:介绍Spring内置验证器API、以及自定义实现。
主体内容
一、Bean Validation
JSR-303
1.Maven依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2.命名规则(Since Spring Boot 1.4)
SpringBoot大多数情况采用starter(启动器,包含一些自动装配的Spring组件),官方的命名规则:spring-boot-starter-{name},业界或者民间:{name}-spring-boot-starter
3.举例
(1)老样子,我们去https://start.spring.io/构建一个validation的springboot项目。
(2)然后Idea导入该项目,创建domain下的User.java模型(不难理解,@Max注解设置最大值,@NotNull不为空)。
import javax.validation.constraints.Max;
import javax.validation.constraints.NotNull;
/**
* @ClassName User
* @Describe 用户模型
* @Author 66477
* @Date 2020/5/1321:50
* @Version 1.0
*/
public class User {
@Max(value=10000)
private long id;
@NotNull
private String name;
private String cardNumber;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCardNumber() {
return cardNumber;
}
public void setCardNumber(String cardNumber) {
this.cardNumber = cardNumber;
}
}
(3)创建controller下的UserController.java,解释已经在注释里了,不做过多赘述。
import com.gupao.springbootbeanvalidation.domain.User;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* @ClassName
* @Describe 控制层直接进行验证,这里只是为了举例
* @Author 66477
* @Date 2020/5/1321:59
* @Version 1.0
*/
@RestController
public class UserController {
/**
* 经过Postman测试,json格式 POST请求方式时,如果加上@Valid,User模型设置了@Max(value=10000),@NotNull等,如果参数不符合条件,直接会返回400错误
* @param user
* @return
*/
@PostMapping("/user/save")
public User save(@Valid @RequestBody User user){
return user;
}
@PostMapping("/user/save2")
public User save2(@Valid @RequestBody User user){
//API调用的方式
Assert.hasText(user.getName(),"名称不能为空!");
//JVM断言
assert user.getId()<=10000;
return user;
}
}
(4)Postman测试localhost:8080/user/save接口,post请求方式,json格式,传入不符合条件的数据直接就是400错误返回。
Postman测试localhost:8080/user/save2接口,不符合的数据传入直接500。
如果是name不符合条件,控制台会打出“名称不能为空!”信息。而采用JVM断言的id不符合条件,返回400。
java.lang.IllegalArgumentException: 名称不能为空!
at org.springframework.util.Assert.hasText(Assert.java:284) ~[spring-core-5.2.6.RELEASE.jar:5.2.6.RELEASE]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
...
Spring Assert API &&JVM/java assert断言这两种方式有缺点就是:耦合了业务逻辑。网上关于耦合和解耦有个比较形象的解释:
耦合
有一对热恋中的男女,水深火热的,谁离开谁都不行了,离开就得死,要是对方有一点风吹草动,这一方就得地动山摇。可以按照琼瑶阿姨的路子继续想象,想成什么样都不过分,他们之间的这种状态就应该叫做“耦合”。
解耦
他们这么下去,有人看不惯了,有一些掌握话语权的权利机构觉得有必要出面阻止了,这样下去不是个事吖,你得先爱祖国,爱社会,爱人民,爱这大好河山才行啊,于是棒打鸳鸯,让他们之间对对方的需要,抽象成一种生理需要,这就好办了,把她抽象成女人,他抽象成男人,当他需要女人时,就把她当做女人送来,反之亦然,看上去他们仍在一起,没什么变化,实质上呢,他们已经被成功的拆散了,当有一天他需要女人时,来了另外一个女人,嘿嘿 他不会反对的。对方怎么变他也不会关心了。这就是“解耦”。
虽然可以通过实现HandlerInterceptor做拦截或者Filter做拦截,但是也是较为恶心的。
还可以通过AOP的方式,也可以提升代码的可读性。
以上方式方法都有一个问题,那就是不是统一的标准。
4.自定义Bean Validation
我们以一个需求例子来演示自定义Bean Validation。
需求:通过员工的卡号来校验,需要通过工号的前缀和后缀来判断。前缀必须以“GUPAO-”开头,后缀必须是数字。需要通过Bean Validator来校验。
这里介绍一下Apahce的验证。可以在http://commons.apache.org/proper/commons-validator/apidocs/org/apache/commons/validator/package-summary.html#package_description可以找到各种验证。
(1)首先我们仿造@Max内部实现来写一个Annotation:ValidCardNumber(为了保持统一,看看@Max导包package,我们也仿造它一波,即创建package#validation.constraints)
import com.gupao.springbootbeanvalidation.validation.ValidCardNumberConstraintValidator;
import javax.validation.Constraint;
import java.lang.annotation.*;
/**
* @ClassName
* @Describe 合法 卡号校验
* @Need 需求:通过员工的卡号来校验,需要通过工号的前缀和后缀来判断。前缀必须以“GUPAO-”开头,后缀必须是数字。需要通过Bean Validator来校验。
* @Author 66477
* @Date 2020/5/1420:47
* @Version 1.0
*/
@Target({ ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
validatedBy = {ValidCardNumberConstraintValidator.class}
)
public @interface ValidCardNumber {
}
(2)然后呢,我们发现@Max注解定义上还有个@Constraint注解,点进去看发现它是继承了ConstraintValidator,那么再点进ConstraintValidator看一下,发现了一个叫做“ConstraintValidator<A extends Annotation, T>”的即可。那么接下来,我们编写一个自定义类(这里我取名叫做ValidCardNumberConstraintValidator)来实现ConstraintValidator<A extends Annotation, T>。
在此之前需要用到一个依赖,我们需要用到里面判断是否为数字的方法StringUtils.isNumeric()。
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.6</version>
</dependency>
附上代码:
import com.gupao.springbootbeanvalidation.validation.constraints.ValidCardNumber;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Objects;
/**
* @ClassName
* @Describe 自定义一个类实现ConstraintValidator<A extends Annotation, T>
* @Author 66477
* @Date 2020/5/1420:55
* @Version 1.0
*/
public class ValidCardNumberConstraintValidator implements ConstraintValidator<ValidCardNumber,String> {
@Override
public void initialize(ValidCardNumber constraintAnnotation) {
}
/**
* @Need 需求:通过员工的卡号来校验,需要通过工号的前缀和后缀来判断。前缀必须以“GUPAO-”开头,后缀必须是数字。需要通过Bean Validator来校验。
* @param value
* @param constraintValidatorContext
* @return
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
//前半部分和后半部分
//String[] parts = StringUtils.delimitedListToStringArray(value,"-");
String[] parts = StringUtils.split(value,"-");
//为什么一般不用String#split方法?原因在于该方法使用了正则表达式 这里因为StringUtils用了两处,只能选择一种
//其次是NPE保护不够
//如果在依赖中没没有StringUtils.delimitedListToStringArray API的话呢,可以使用
//Apache commons-lang StringUtils
// jdk里的StringTokenizer(不足之处在于它类似于Enumeration API)
/* if(parts.length!=2){
return false;
}*/
if(ArrayUtils.getLength(parts)!=2){
return false;
}
String prefix = parts[0];
String suffix = parts[1];
//boolean isValidPrefix = "GUPAO".equals(prefix);
boolean isValidPrefix = Objects.equals(prefix,"GUPAO");
boolean isValidInteger = StringUtils.isNumeric(suffix);
return isValidPrefix&&isValidInteger;
}
}
(3)好了,万事俱备,只欠注解。我们去User模型加上刚定义好还热乎的注解。首先别忘了还是要给cardNumber字段加上@NotNull,判断前提它不为空嘛,然后加上自己的@ValidCardNumbe。如下:
@NotNull
@ValidCardNumber
private String cardNumber;
(4)重启项目,掏出Postman,还是访问之前写的http://localhost:8080/user/save接口。
先故意来个错的
这里发现控制台出现了这个错误,意思大概是不包含一个message参数:
javax.validation.ConstraintDefinitionException: HV000074: com.gupao.springbootbeanvalidation.validation.constraints.ValidCardNumber contains Constraint annotation, but does not contain a message parameter.
at org.hibernate.validator.internal.metadata.core.ConstraintHelper.assertMessageParameterExists(ConstraintHelper.java:1054) ~[hibernate-validator-6.1.4.Final.jar:6.1.4.Final]
那么回去看看代码,发现人家@Max好像是有个message的东东。
public @interface Max {
String message() default "{javax.validation.constraints.Max.message}";//就是它
Class<?>[] groups() default {};
我们把它加到ValidCardNumber自定义注解中,default就不要了,我们另外在模型User中定义它的属性即可。剩余几个属性也一起复制过来,因为它必然还会发生这种少参数的错误。
public @interface ValidCardNumber {
String message() ;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
User模型的cardNumber这么搞就对了。
@NotNull
@ValidCardNumber(message = "卡号必须以\"GUPAO\" 开头,以数字结尾")
private String cardNumber;
再次测试一波无法通过验证的数据,结果终于是400了,但是控制台却没有返回我刚刚设置的消息,这个还有待于研究:
然后来个对的,Ok,正常,通过验证!
那么补充一点,信息提示的国际化该如何实现。
(1)我们先在ValidCardNumber重新定义message默认值
String message() default "{com.gupao.bean.validation.invalid.card.number.message}";
(2)然后在resource下分别创建两个文件,文件名就用这个不要更改。
a.ValidationMessages.properties
com.gupao.bean.validation.invalid.card.number.message=The card number must start with "GUPAO",and its suffix must be a number!
b.ValidationMessages_zh_CN.properties
com.gupao.bean.validation.invalid.card.number.message=卡号必须以"GUPAO" 开头,以数字结尾
(3)去掉User模型上cardNumber注解的message定义。
@NotNull
@ValidCardNumber
private String cardNumber;
注意注意注意,这里pom文件中一定要切换成SpringMVC,WebFlux可能导致无结果返回,他两的实现方式有差异,至于什么导致WebFlux控制台不输出错误信息,过于深入,这里暂时不做研究了。
切换成SpringMVC结果:
2020-05-14 22:47:09.743 WARN 177284 --- [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.gupao.springbootbeanvalidation.domain.User com.gupao.springbootbeanvalidation.web.controller.UserController.save(com.gupao.springbootbeanvalidation.domain.User): [Field error in object 'user' on field 'cardNumber': rejected value [GUPAO]; codes [ValidCardNumber.user.cardNumber,ValidCardNumber.cardNumber,ValidCardNumber.java.lang.String,ValidCardNumber]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.cardNumber,cardNumber]; arguments []; default message [cardNumber]]; default message [¿¨ºÅ±ØÐëÒÔ"GUPAO" ¿ªÍ·£¬ÒÔÊý×Ö½áβ]] ]
貌似有乱码,我们就处理一下吧。
首先,打开cmd,cd到jdk的bin目录下,执行以下语句,用jdk工具相当于给这个文件重新编码一波。(笨方法,要么直接百度在线工具也行)
C:\Program Files\Java\jdk1.6.0_45\bin>native2ascii.exe E:\Workplaces\IDEAWorkplace\wk-microservice\spring-boot-bean-validation\src\main\resources\ValidationMessages_zh_CN.properties E:\Workplaces\ValidationMessages_zh_CN.properties
生成文件到E:\Workplaces\ValidationMessages_zh_CN.properties,替换了就ok。这就编码后的文件内容,白嫖这个也行:
com.gupao.bean.validation.invalid.card.number.message=\u5361\u53f7\u5fc5\u987b\u4ee5"GUPAO" \u5f00\u5934\uff0c\u4ee5\u6570\u5b57\u7ed3\u5c3e
再次测试,ok,控制台返回值正常了。
Validation failed for argument [0] in public com.gupao.springbootbeanvalidation.domain.User com.gupao.springbootbeanvalidation.web.controller.UserController.save(com.gupao.springbootbeanvalidation.domain.User): [Field error in object 'user' on field 'cardNumber': rejected value [GUPAO]; codes [ValidCardNumber.user.cardNumber,ValidCardNumber.cardNumber,ValidCardNumber.java.lang.String,ValidCardNumber]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.cardNumber,cardNumber]; arguments []; default message [cardNumber]]; default message [卡号必须以"GUPAO" 开头,以数字结尾]] ]
二、问题总结
1.Json校验如何搞?
解答:尝试让它变成Bean的方式。
2.实际中很多参数都要校验,那时候怎么写这样写会增加很多类?
解答:确实会增加部分工作量,大多数场景,不需要自定义,除非很特殊的情况。Bean Validation的主要缺点就是单元测试不方便。
3.如何将400错误变成200?(这个有问题,先不要看,等后面研究后补充上来)
(1)编写一个拦截器UserControllerInterceptor(或者使用过滤器Filter也可以)
import org.springframework.http.HttpStatus;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @ClassName
* @Describe TODO
* @Author 66477
* @Date 2020/5/1322:50
* @Version 1.0
*/
public class UserControllerInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//把校验逻辑存放在这里
return true;
}
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
Integer status = response.getStatus();
if(status == HttpStatus.BAD_REQUEST.value()){
response.setStatus(HttpStatus.OK.value());
}
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}
}
(2)启动类add这个拦截器。
@SpringBootApplication
public class SpringBootBeanValidationApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(SpringBootBeanValidationApplication.class, args);
}
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserControllerInterceptor);
}
}
4.如果前端固定表单的话,这种校验方式很好,但是灵活性不够,如果表单是动态的话,如何校验呢?
解答:表单字段与Form对象绑定即可,再走Bean Validation逻辑。
<form action="" method="POST" command="form">
<input value = "${form.name}"/>
...
<input value = "${form.age}"/>
</form>
或者就是采用普通的一个接着一个验证,责任链模式(Pipeline):
filed1->filed2->filed3->compute->result
5.如何自定义返回格式?如何最佳实现?
解答:可以通过REST来实现,比如XML或者JSON的格式(视图)。