前言

主要议题

  • 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项目。

spring boot 签名验签服务 springboot验证器_java

(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错误返回。

spring boot 签名验签服务 springboot验证器_java_02

Postman测试localhost:8080/user/save2接口,不符合的数据传入直接500。

spring boot 签名验签服务 springboot验证器_User_03

如果是name不符合条件,控制台会打出“名称不能为空!”信息。而采用JVM断言的id不符合条件,返回400。

spring boot 签名验签服务 springboot验证器_springboot验证_04

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可以找到各种验证。

spring boot 签名验签服务 springboot验证器_java_05

(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接口。

先故意来个错的

spring boot 签名验签服务 springboot验证器_java_06

这里发现控制台出现了这个错误,意思大概是不包含一个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了,但是控制台却没有返回我刚刚设置的消息,这个还有待于研究:

spring boot 签名验签服务 springboot验证器_User_07

然后来个对的,Ok,正常,通过验证!

spring boot 签名验签服务 springboot验证器_spring boot 签名验签服务_08

那么补充一点,信息提示的国际化该如何实现。

(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的格式(视图)。