Restful接口参数校验实践

984 阅读9分钟

1、背景

在日常开发写restful接口时,接口参数校验这一部分是必须的,如果全部用代码中去进行参数校验,显得十分麻烦,spring validation框架提供了这部分功能

2、spring validation基本用法

2.1 声明bean约束

Bean Validation 中的约束通过 Java 注解表示,有四种类型的 bean 约束:字段约束、属性约束、容器元素约束、类约束。

2.1.1 字段约束

约束可以通过对类的字段进行注解来表达,约束可以应用于任何访问类型(公共、私有等)的字段。但是,不支持对静态字段的约束。检测时检测引擎直接反射访问待验证字段的值。
字段级约束
public class ImageRegistryConfigParam {
 
    @NotBlank(message = "registry is blank")
   @ApiModelProperty(value = "仓库地址", required = true)
    private String registry;
 
    @NotBlank(message = "repository is blank")
    @ApiModelProperty(value = "仓库名称", required = true)
    private String repository;
 
    @NotBlank(message = "remark is blank")
    @ApiModelProperty(value = "备注", required = true)
    private String remark;
}

2.1.2 属性约束

属性约束必须在属性的 getter 方法方法上加上注解,建议在一个类中使用字段_或_属性注解。不建议对字段_和_随附的 getter 方法进行注释_,_因为这会导致该字段被验证两次。由于我们在开发中使用了@Getter注解,所以我们使用字段约束即可。检测时检测引擎调用属性get方法访问待验证字段的值。

2.1.3 容器元素约束

validation支持对java容器元素进行验证,支持java.util.Iterable、java.util.Map(支持键和值)、java.util.Optional、支持自定义容器类型的容器元素约束,此处以对List类型参数应用约束为例
public class User {
    private List<@NotBlank(message = "course 不能为空") String> courses;
}
当传入的courses参数中某个元素为"",则会返回“course 不能为空“给调用方
参数
{
    "courses":["", "q"]
}
返回结果
{
    "code": 500101,
    "msg": "参数校验异常:course 不能为空",
    "data": null
}

2.1.4 类约束

约束也可以应用在类级别上,来校验整个对象的状态。下面的例子访问完整的Car对象,允许比较座位和乘客的数量。当乘客的数量少于车上的座位时合法。此处应用到自定义验证注解,参见2.4自定义校验
自定义类约束注解
@ValidPassengerCount
public class Car {
 
    private int seatCount;
 
    private List passengers;
 
    //...
}
 
@Target({ TYPE, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = { ValidPassengerCountValidator.class })
@Documented
public @interface ValidPassengerCount {
 
    String message() default "{org.hibernate.validator.referenceguide.chapter06.classlevel." +
            "ValidPassengerCount.message}";
 
    Class<?>[] groups() default { };
 
    Class<? extends Payload>[] payload() default { };
}
package org.hibernate.validator.referenceguide.chapter06.classlevel;
 
public class ValidPassengerCountValidator
        implements ConstraintValidator<ValidPassengerCount, Car> {
 
    @Override
    public void initialize(ValidPassengerCount constraintAnnotation) {
    }
 
    @Override
    public boolean isValid(Car car, ConstraintValidatorContext context) {
        if ( car == null ) {
            return true;
       }
              return car.getPassengers().size() <= car.getSeatCount();
    }
}
 

2.2 声明方法约束

2.3 validation支持的部分验证约束注解

验证注解验证的数据类型说明
@AssertFalseBoolean,boolean验证注解的元素值是false
@AssertTrueBoolean,boolean验证注解的元素值是true
@NotNull任意类型验证注解的元素值不是null
@Null任意类型验证注解的元素值是null
@Min(value=值)BigDecimal,BigInteger, byte,short, int, long,等任何Number或CharSequence(存储的是数字)子类型验证注解的元素值大于等于@Min指定的value值
@Max(value=值)和@Min要求一样验证注解的元素值小于等于@Max指定的value值
@DecimalMin(value=值)和@Min要求一样验证注解的元素值大于等于@ DecimalMin指定的value值
@DecimalMax(value=值)和@Min要求一样验证注解的元素值小于等于@ DecimalMax指定的value值
@Digits(integer=整数位数, fraction=小数位数)和@Min要求一样验证注解的元素值的整数位数和小数位数上限
@Size(min=下限, max=上限)字符串、Collection、Map、数组等验证注解的元素值的在min和max(包含)指定区间之内,如字符长度、集合大小
@Pastjava.util.Date,java.util.Calendar;Joda Time类库的日期类型验证注解的元素值(日期类型)比当前时间早
@Future与@Past要求一样验证注解的元素值(日期类型)比当前时间晚
@NotBlankCharSequence子类型验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的首位空格
@Length(min=下限, max=上限)CharSequence子类型验证注解的元素值长度在min和max区间内
@NotEmptyCharSequence子类型、Collection、Map、数组验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)
@Range(min=最小值, max=最大值)BigDecimal,BigInteger,CharSequence, byte, short, int, long等原子类型和包装类型验证注解的元素值在最小值和最大值之间
@Email(regexp=正则表达式,flag=标志的模式)CharSequence子类型(如String)验证注解的元素值是Email,也可以通过regexp和flag指定自定义的email格式
@Pattern(regexp=正则表达式,flag=标志的模式)String,任何CharSequence的子类型验证注解的元素值与指定的正则表达式匹配
@Valid任何非原子类型指定递归验证关联的对象如用户对象中有个地址对象属性,如果想在验证用户对象时一起验证地址对象的话,在地址对象上加@Valid注解即可

2.4 使用时需要引入的依赖


    org.springframework.boot
    spring-boot-starter-validation

2.5 分组校验

有些场景我们需要校验某些字段,有些场景下某些字段不需要校验,举个例子,当我们向数据库插入新数据时,id这个字段不需要我们自己指定,可以为空,但是当我们进行更新操作时id这个字段不能为空,我们只有一个DTO,这个时候就要用到分组校验了。
声明分组
import javax.validation.groups.Default;
 
public interface Update extends Default {
}
在DTO上添加分组
import com.qingteng.validation.validator.IsMobile;
import com.qingteng.validation.validator.Update;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
 
import javax.validation.constraints.NotBlank;
 
@Data
public class User {
    @NotBlank(message = "id为空", groups = Update.class)
    private String id;
 
    @NotBlank(message = "姓名为空")
    private String name;
 
    @Range(max = 150, min = 1, message = "年龄范围应该在1-150内")
    private Integer age;
 
    @NotBlank(message = "手机号码为空")
    @IsMobile
    private String mobile;
 
    @Override
    public String toString() {
        return "User [name=" + name + ",age=" + age + ", mobile=" + mobile + "]";
    }
}
在controller的接口上加上@Validated注解,参数就加上你需要根据那种规则来校验,更新时才会校验id所以在update方法中的@Validated注解上添加Update.class,在调用更新方法时就会校验id是否为空,而在调用create方法时并不会进行校验。
        @PostMapping("/create")
        public Result create(@Validated @RequestBody User user) {
         log.info(user.toString());
        // create
        return Result.success("success");
    }
 
    @PostMapping("/update")
    public Result update(@Validated(Update.class) @RequestBody User user) {
        log.info(user.toString());
        // update
        return Result.success("success");
    }

2.6 自定义校验

虽然Spring Validation 提供的注解基本上够用,但是面对复杂的定义,我们还是需要自己定义相关注解来实现自动校验。Spring 框架就提供了这种扩展。下面以校验手机号码为例:
定义一个自定义校验注解
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
 
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class })
public @interface  IsMobile {
        
        boolean required() default true;
        
        String message() default "手机号码格式错误";
 
        Class<?>[] groups() default { };
 
        Class<? extends Payload>[] payload() default { };
}
 
创建手机号码的逻辑校验类:
import com.qingteng.validation.util.ValidatorUtil;
import org.apache.commons.lang3.StringUtils;
 
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
 
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {
 
        private boolean required = false;
        
        @Override
        public void initialize(IsMobile constraintAnnotation) {
               required = constraintAnnotation.required();
        }
 
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
               if(required) {
                       return ValidatorUtil.isMobile(value);
               }else {
                       if(StringUtils.isEmpty(value)) {
                               return true;
                       }else {
                               return ValidatorUtil.isMobile(value);
                       }
               }
        }
 
}
ValidatorUtil
import org.apache.commons.lang3.StringUtils;
 
import java.util.regex.Matcher;
import java.util.regex.Pattern;
 
public class ValidatorUtil {
        
        private static final Pattern mobile_pattern = Pattern.compile("1\d{10}");
        
        public static boolean isMobile(String src) {
               if(StringUtils.isEmpty(src)) {
                       return false;
               }
               Matcher m = mobile_pattern.matcher(src);
               return m.matches();
        }
 
}

2.7 递归校验

有时候我们的实体可能包含了另一个实体类,比如常见的一对一或者一对多关系,遇到这种情况,我们不但要校验本类自己的属性,而且包含的另一个实体类也需要校验,就会用到递归校验。我们只需要在包含的另一个类的上边加上注解 @Valid 即可实现
import com.qingteng.validation.validator.IsMobile;
import com.qingteng.validation.validator.Update;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
 
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
 
@Data
public class User {
    @NotBlank(message = "id为空", groups = Update.class)
    private String id;
 
    @NotBlank(message = "姓名为空")
    private String name;
 
    @Range(max = 150, min = 1, message = "年龄范围应该在1-150内")
    private Integer age;
 
    @NotBlank(message = "手机号码为空")
    @IsMobile
    private String mobile;
 
    @Valid
    private Address address;
 
    @Override
    public String toString() {
        return "User [name=" + name + ",age=" + age + ", mobile=" + mobile + "]";
    }
}
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
 
public class Address {
    @NotBlank(message = "地址名称为空")
    private String name;
 
    @NotNull(message = "经度为空")
    private Float longitude;
 
    @NotNull(message = "纬度为空")
    private Float latitude;
}

2.8 校验模式

2.8.1 普通模式

Spring Validation默认会校验完所有字段,然后才抛出异常。

2.8.2 快速失败返回模式

可以通过一些简单的配置,开启Fail Fast模式,一旦校验失败就立即返回。
开启快速失败
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
 
@Configuration
public class ValidatorConfig {
    @Bean
    public Validator validator() {
        ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                .configure()
                .failFast(true) // 开启Fail Fast模式
                .buildValidatorFactory();
        return validatorFactory.getValidator();
    }
}
 

3、spring validation使用例子

3.1 PathVariable校验

@RestController
@RequestMapping("/user")
@Validated
public class UserController {
    private static Logger log = LoggerFactory.getLogger(UserController.class);
 
        @GetMapping("/path/{id}")
    public Result testPathVariable(@Validated @Length(min = 2, max = 3, message = "id should between 2 and 3")
                                               @PathVariable String id) {
        log.info(id);
        // testPathVariable
        return Result.success("success");
    }
}
当路径上的id="a"的长度小于2时服务端校验后给前端返回正确的校验结果:

3.2 方法参数校验

@RestController
@RequestMapping("/user")
@Validated
public class UserController {
        private static Logger log = LoggerFactory.getLogger(UserController.class);
 
   @GetMapping("/param")
    public Result testRequestParam(@Validated @NotBlank(message = "name is blank") @RequestParam String name) {
        log.info(name);
        // testPathVariable
        return Result.success("success");
    }
}
当前端传入的参数name为“”时服务端校验后给前端返回正确的校验结果:

3.3 RequestBody校验

创建一个DTO对象:
import lombok.Data;
import org.hibernate.validator.constraints.Range;
 
import javax.validation.constraints.NotBlank;
 
@Data
public class User {
    @NotBlank(message = "姓名为空")
    private String name;
 
    @Range(max = 150, min = 1, message = "年龄范围应该在1-150内")
    private Integer age;
 
    @NotBlank(message = "手机号码为空")
    private String mobile;
 
    @Override
    public String toString() {
        return "User [name=" + name + ",age=" + age + ", mobile=" + mobile + "]";
    }
}
 
在controller层添加@Validated注解
添加注解之后spring就会逐个校验DTO中加了校验注解的字段,完全通过才可以进入业务处理,否则就会抛出MethodArgumentNotValidException异常
import com.qingteng.validation.domain.User;
import com.qingteng.validation.result.Result;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
 
import javax.validation.Valid;
 
@RestController
@RequestMapping("/user")
public class UserController {
    private static Logger log = LoggerFactory.getLogger(UserController.class);
 
    @PostMapping("/do_login")
    public Result doLogin(@Validated @RequestBody User user) {
        log.info(user.toString());
        // 登录
        return Result.success("success");
    }
}

4、处理校验异常

一般项目来说抛出异常都会有约定好的JSON格式返回错误码和错误信息,如果不处理就无法按照约定格式返回。这里我们可以通过声明全局异常处理类来拦截异常并将异常处理成前端能操作的JSON数据。(这里需要关注MethodArgumentNotValidException和ConstraintViolationException异常)
import com.qingteng.validation.result.CodeMsg;
import com.qingteng.validation.result.Result;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
 
import javax.servlet.http.HttpServletRequest;
import java.util.stream.Collectors;
 
@ControllerAdvice
public class MyExceptionHandler {      
        @ExceptionHandler({MethodArgumentNotValidException.class})
    @ResponseBody
    public Result handleMethodArgumentNotValidException(MethodArgumentNotValidException exception, HttpServletRequest request) {
        String msg = exception.getBindingResult().getAllErrors()
                    .stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                    .collect(Collectors.joining(","));
 
        return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
    }
 
   @ExceptionHandler({ConstraintViolationException.class})
    @ResponseBody
    public Result handleConstraintViolationException(ConstraintViolationException exception, HttpServletRequest request) {
        String msg = exception.getConstraintViolations()
                .stream()
                .map(ConstraintViolation::getMessage)
                .collect(Collectors.joining(","));
 
        return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
    }  
}

5、spring实现校验的原理

//TODO



参考:
blog.csdn.net/weixin_4649…
docs.jboss.org/hibernate/s…