参数校验神器hibernate-validator配合统

传统的参数校验

我相信大家在开发过程中都很头疼对前端传过来的参数进行校验,因为有时候接口需要的参数很多,在远古时代我们的校验方式应该是这样的:

    @PostMapping("/order/submit")
    public void submit(@RequestBody OrderRequest order){
        if(order.getParam1() == null){
            throw new XXXException("xxx不能为空");
        } else if(order.getParam2() == null){
            throw new XXXException("xxx不能为空");
        }
        ...
    }
复制代码

好家伙,如果一个接口几十个参数我觉得能把人写奔溃啊……如果说不对请求参数进行校验的话,那问题更严重,在和前端联调的时候可能出现各种参数不匹配、不合法问题,这样的话我觉得前后端都会崩溃。。。。也许你会说,不是有 swagger 这种文档吗?带 "*" 就表示必传啊。但是你要知道,这个只是告诉前端这个是必传的,并不能实际限制接口,如果前端就是忘了传呢?所以后台做参数校验是必要的

尝试优化解决

聪明的小伙伴可能已经想到了,根本没必要每个接口都写这些判断嘛,我自己定义一个注解,在拦截器里面拦截这些请求,判断请求对象的字段上有没有加我定义的注解就行了,假设我们自己定义一个注解

/**
 * 自定义注解
 * */
@Documented
@Target( {ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotNull {
}
复制代码

在请求参数实体类中使用我们定义的注解

@Data
public class OrderRequest {
    //自己定义的注解
    @NotNull
    private String merchantCode;
    
    @NotNull
    private String memberCode;
}
复制代码

拦截器中做统一校验

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HandlerMethod handlerMethod = (HandlerMethod)handler;
        //先获取参数列表
        Parameter[] parameters = handlerMethod.getMethod().getParameters();
        for (Parameter parameter : parameters) {
            //获取参数字段
            Field[] declaredFields = parameter.getType().getDeclaredFields();
            for (Field declaredField : declaredFields) {
                NotNull annotation = declaredField.getAnnotation(NotNull.class);
                //如果该字段有NotNull注解
                if(annotation != null){
                    declaredField.setAccessible(true);
                    String s = declaredField.get(getTargetObject(parameter,request)).toString();//实际值
                    if(s == null){
                        //这里应该组装信息返回给前端,为了简单就直接 throw
                        throw new RuntimeException(declaredField.getName()+":不能为NULL");
                    }
                }
            }
        }
    }
复制代码

这样一个简单地 NotNull 校验就完成了,看似简单,不过 getTargetObject 方法我并没有去实现,这里要拿到目标对象(也就是上面 controller 方法的 order 对象)还是比较麻烦的,因为我们在这里只能通过反射拿到类型、字段等信息。如果要拿到目标对象,还得从 HttpServletRequest 这里拿到字节流去做转换,也就是 SpringMVC 的参数解析和数据绑定那里做的工作。

强大的 hibernate-validator

你可能已经发现了,我们刚刚实现的自定义校验非常简陋,只做了一个不为空的校验,而且我们实现的是在拦截器里面,走到拦截器时,数据绑定这些其实已经结束了。对此,有一个框架 hibernate-validator 帮我们解决了这个问题。

在谈它之前我们需要了解一下 JSR(Java Specification Requests)规范,JSR 303 是 Java 官方提出的数据校验规范 叫做 Bean Validation,现在已经到了 2.0 版本,对应 JSR 380 。就像 Servlet 一样,只提了一个规范,具体的由我们实现。 hibernate-validator 就遵循这个规范做出了一套实现。

本篇文章以 SpringBoot 为例,在 SpringBoot 中使用非常方便,直接引入 starter 即可, SpringBoot 会帮我们自动配置

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
复制代码

引入之后就可以使用它提供的一系列校验注解,现在我们将自定义注解换成框架提供的注解,删掉之前的拦截器

@Data
public class OrderRequest {
    //hibernate-validator 提供的注解
    @javax.validation.constraints.NotNull
    private String merchantCode;

    @javax.validation.constraints.NotNull
    private String memberCode;
}
复制代码

Controller 代码

    @PostMapping("/order/submit")
    public void submit(@RequestBody @Validated OrderRequest request){}
复制代码

使用 Postman 发送 Post 请求,并且故意传空的 JSON 串测试,可以看到 Postman 会接受到下面的返回结果:

{
    "timestamp": "2021-06-09T11:25:49.136+00:00",
    "status": 400,
    "error": "Bad Request",
    "path": "/order/submit"
}
复制代码

这说明注解起作用了,但是这并不是我们希望的结果,我们肯定希望的是能够告诉前端,到底哪个或者哪些字段没有通过校验,并且要知道是什么样的校验没有通过,比如是必传的参数我没传,还是传的数字大于最大限制了等等。

所以下面我们需要对报错信息做处理,在此之前我们需要先知道,这个参数校验的动作其实是 SpringMVC 发出的,具体的校验规则是 hibernate-validator 提供的,可以翻阅 SpringMVC 源码:

image.png

图中我打断点的地方, SpringMVC 判断是否需要校验,这里面最终是去调用 hibernate-validator 的实现规则,然后下面根据返回的结果,抛出了一个 MethodArgumentNotValidException 类型的异常。这样一来,我们只需要去捕捉这个异常,然后开发者自己做出响应就可以了。捕捉这个异常,我相信大家应该都知道 SpringMVC 统一异常处理

SpringMVC 统一异常处理

统一异常处理比较简单,只需要写个类用 @RestControllerAdvice 标注,使用 @ExceptionHandler 标注方法即可,不过 SpringMVC 给我们提供了一个类 ResponseEntityExceptionHandler ,继承它重写 handleExceptionInternal 方法即可

@RestControllerAdvice
public class GlobalExceptionController extends ResponseEntityExceptionHandler {
    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        if(ex instanceof MethodArgumentNotValidException){
            String message = ((MethodArgumentNotValidException) ex).getFieldErrors().stream().map(v -> v.getField()+":"+v.getDefaultMessage()).collect(Collectors.joining(";"));
            JSONObject obj = new JSONObject();
            obj.put("status",status.value());
            obj.put("error",status.getReasonPhrase());
            obj.put("message",message);
            obj.put("path",((NativeWebRequest) request).getNativeRequest(HttpServletRequest.class).getRequestURI());
            body = obj;
        }
        return super.handleExceptionInternal(ex, body, headers, status, request);
    }
}
复制代码

由于我们上面看到了,对于不合法的参数校验会抛出 MethodArgumentNotValidException 异常,所以上面我们针对 ex 的类型判断,如果是该类型,把我们需要的信息放进 body 返回给前端,再次使用 Postman 测试,得到以下结果

{
    "path": "/order/submit",
    "error": "Bad Request",
    "message": "memberCode:不能为null;merchantCode:不能为null",
    "status": 400
}
复制代码

这样的结果就是我们通常需要的。那为什么我们重写这个 handleExceptionInternal 方法就能做到拦截异常呢?可以看下我们继承的 ResponseEntityExceptionHandler 类的源码,它自己定义了一个方法用 @ExceptionHandler 标注,并且里面拦截了几乎所有可能发生的异常

image.png

这些 return 对不同的异常类型做不同处理,但是最终内部调用的都是我们重写的 handleExceptionInternal 方法。

那你可能已经发现了,它只能处理 @ExceptionHandler 中定义好的这些异常类型,开发中我们肯定会自己定义一些异常类,那如何把我们自定义的异常类也捕捉进来呢?

SpringMVC 处理自定义异常

仿照 ResponseEntityExceptionHandler 它的写法,我们也写个方法用 @ExceptionHandler 标注即可,首先自定义一个异常类 ClientException

public class ClientException extends RuntimeException {
    public ClientException(String msg) {
        super(msg);
    }

    public ClientException(String msg, Object... objs) {
        super(MessageFormatter.arrayFormat(msg, objs).getMessage());//实现日志占位符,类似 Slf4j 的log
    }
}
复制代码

然后在我们统一处理类中,自定义一个异常处理方法 handleClientException 处理 ClientException 类型,加上 @ExceptionHandler 即可

@RestControllerAdvice
@Slf4j
public class GlobalExceptionController extends ResponseEntityExceptionHandler {
    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        if(ex instanceof MethodArgumentNotValidException){
            String message = ((MethodArgumentNotValidException) ex).getFieldErrors().stream().map(v -> v.getField()+":"+v.getDefaultMessage()).collect(Collectors.joining(";"));
            JSONObject obj = new JSONObject();
            obj.put("status",status.value());
            obj.put("error",status.getReasonPhrase());
            obj.put("message",message);
            obj.put("path",((NativeWebRequest) request).getNativeRequest(HttpServletRequest.class).getRequestURI());

            body = obj;
        } else if (ex instanceof ClientException){  //捕捉自定义异常
            body = "1111";
        }
        return super.handleExceptionInternal(ex, body, headers, status, request);
    }

    /**
     * 处理自定义异常
     */
    @ExceptionHandler
    public ResponseEntity<Object> handleClientException(ClientException ex, NativeWebRequest request) {
        HttpHeaders headers = new HttpHeaders();
        HttpStatus status = HttpStatus.METHOD_NOT_ALLOWED;
        return handleExceptionInternal(ex, null, headers, status, request);
    }
}
复制代码

之后我们在代码中测试,抛出一个 ClientException ,例如

if (user == null) {
    throw new ClientException("手机号不存在");
}
----------------------
if (count > 0) {
    throw new ClientException("名称为:{} 的权限已经存在", permission.getName());
}
复制代码

这样异常就会被我们自定义的处理方法拦截,给前端返回响应的信息。

级联校验

有这样一种场景,就是请求对象可能是嵌套的,例如创建订单接口通常会传商品信息

@Data
public class OrderRequest {
    @NotNull
    private String merchantCode;
    @NotNull
    private String memberCode;
    
    @Valid
    @NotNull
    private List<OrderProduct> orderProduct;

    @Data
    public class OrderProduct {
        @NotNull
        private String skuName;

        @Positive
        private BigDecimal price;
    }
}
复制代码

此时我们需要对 OrderProduct 类进行字段校验就需要使用 @Valid 注解支持级联校验,传入负数金额测试结果

{
    "path": "/order/submit",
    "error": "Bad Request",
    "message": "orderProduct[0].price:必须是正数",
    "status": 400
}
复制代码

这看起来已经很完美,但似乎有些美中不足,因为这样一来我们将来会在业务层写非常多的 if 然后抛异常,这在业务方法是不太优雅的。

使用 Assert 实现优雅的客户端提示

其实稍微动动脑筋我们就能想到用 Assert 来实现。是不是很熟悉?写单元测试用的 Assert !它不是正好实现这些 isNullboolean expression 的校验么~~不过 SpringAssert 抛出的是 IllegalArgumentException 异常,不符合要求。所以自定义一个 Assert 类就好啦~

public abstract class Assert {

    public static void isNull(@Nullable Object object, String message, Object... args) {
        if (object == null) {
            throw new ClientException(message, args);
        }
    }

    @Nullable
    private static <T> T nullSafeGet(@Nullable Supplier<T> messageSupplier) {
        return (messageSupplier != null ? messageSupplier.get() : null);
    }

    public static <T> void isTrue(boolean expression, String message, Supplier<T> supplier) {
        if (expression) {
            throw new ClientException(message, nullSafeGet(supplier));
        }
    }
    //...继续扩展
}
复制代码

这样在业务层的判断就可以这样用,是不是像 18 岁少女一样优雅?

Assert.isNull(user, "手机号不存在");
Assert.isTrue(count > 0, "名称为:{} 的权限已经存在", permission::getName);
复制代码

客户端收到的响应

{
    "path": "/user/login",
    "error": "Bad Request",
    "message": "手机号不存在",
    "status": 400
}
-----------------
{
    "path": "/admin/permission",
    "error": "Bad Request",
    "message": "名称为:admin 的权限已经存在",
    "status": 400
}
复制代码

hibernate-validator 注解功能查询

如果使用比较少的话,你可能不太熟悉都有哪些注解校验,分别是校验什么功能,所以作为暖男的我在这里把所有校验注解都列出来~~

注解 含义 注解 含义 注解 含义
@NotNull 验证字段不为 null @NotEmpty 验证字段不为空,常用于校验集合元素不为空 @NotBlank 验证字段不为空,常用于验证字符串不是空串
@Max 验证字段的最大值 @Min 验证字段的最小值 @Digits (integer=整数位数, fraction=小数位数)验证字段整数位数和小数位数上限
@DecimalMax 与 @Max 类似,不同的是它限定值可以带小数,一般用于 double 和 Bigdecimal 类型 @DecimalMin 与 @Min 类似,...... @Range 验证数字类型字段值在最小值和最大值之间
@Size 验证字段值的在 min 和 max (包含)指定区间之内,如字符长度、集合大小 @Length 验证字符串值长度在 min 和 max 区间内
@AssertFalse 验证布尔类型值是 false @AssertTrue 验证布尔类型值是 true @Future 验证日期类型字段值比当前时间晚
@Email 验证字段值是个邮箱 @Pattern (regex=正则表达式) 验证注解的元素值不指定的正则表达式匹配 @Past 验证日期类型字段值比当前时间早
@Negative 校验必须是负数 @Positive 校验必须是正数 @PastOrPresent 验证日期类型字段值比当前时间早或者是当前日期
@NegativeOrZero 校验必须是负数或 0 @PositiveOrZero 校验必须是正数或 0 @FutureOrPresent 验证日期类型字段值比当前时间晚或者是当前日期

校验分组

实际开发中,我们写一个接受请求参数的类,可能用于多种业务校验。比如我一个类,同时用于保存和更新,那保存一般是不用传 id 的,但是更新时必传 id 字段的。如果我们想用一个类同时满足两种校验怎么办呢? hibernate-validator 给我们提供了一个分组的方案,我们可以把保存分为一个组,标识哪些字段是保存动作组的,哪些字段是更新动作组的,这样就可以同时满足两种业务的校验需求了。

实际上 hibernate-validator 提供的所有注解都有 groups 属性

@Data
public class ReqPremiumLevelRights {

    @NotNull(groups = {UpdateGroup.class})//只用于更新
    private Long id;
    
    @Length(max = 8, groups = {SaveGroup.class, UpdateGroup.class})
    @NotNull(groups = {SaveGroup.class, UpdateGroup.class})
    private String name;
    
    @NotNull(groups = {SaveGroup.class})
    @Size(min = 1, message = "必须至少选择一个会员权益", groups = {SaveGroup.class, UpdateGroup.class})
    private List<Long> rightsIds;

    public interface SaveGroup {}
    public interface UpdateGroup {}
}
复制代码

然后我们在 Controller 里面,使用 @Validated 的时候指定分组就好了

    @PostMapping
    @Operation(summary = "新增会员级别(关联权益)")
    public void save(@RequestBody @Validated(ReqPremiumLevelRights.SaveGroup.class) ReqPremiumLevelRights levelRights) {
        premiumMemberLevelService.save(levelRights);
    }

    @PutMapping
    @Operation(summary = "编辑会员级别")
    public void edit(@RequestBody @Validated(ReqPremiumLevelRights.UpdateGroup.class) ReqPremiumLevelRights levelRights) {
        premiumMemberLevelService.update(levelRights);
    }
复制代码

这样一来,就解决了一个类同时用于多种业务校验的问题。

不依赖 SpringMVC 触发校验动作

有这样一种场景,我们需要根据某个字段值来确定要使用哪个分组的校验,上面我们是使用不同的接口分开,这样可以自己选择使用哪个分组的规则来校验,如果我们现在要只用一个接口来实现针对不同情况下对不同的分组校验怎么办呢?

举个例子,比如我们现在有个开票需求,针对个人开票有一套校验,针对企业开票有一套校验规则,我们一般不会用两个接口来实现这个开票功能。那么这就要我们在代码中,根据用户类型来自己去决定要使用哪一套校验规则。

    @Autowired
    private Validator validator;
    
    @PostMapping
    public void apply(@RequestBody @Validated AppInvoiceApplyRequest request) {
        Set<ConstraintViolation<FundApplyVerifyRequest>> constraintViolations = null;
        if (isCorporation) { //企业开票
            constraintViolations = validator.validate(request,AppInvoiceApplyRequest.CompanyInvoiceGroup.class);
        } else { //个人开票
            constraintViolations = validator.validate(request,AppInvoiceApplyRequest.PersonalCompanyInvoiceGroup.class);
        }
        if (CollectionUtils.isNotEmpty(constraintViolations)) { //如果没有通过校验,抛出异常
            throw new ConstraintViolationException(constraintViolations);
        }
        appInvoiceService.save(request);
    }
复制代码

其实 SpringMVC 源码去调用校验器 validator 的时候也是这么做的,现在我们在参数中,不指定 @Validated 的校验分组,在代码中判断前端传过来的用户类型,根据用户类型使用校验器 validator 去对不同业务对应的校验分组来校验就可以了

扩展 hibernate-validator 提供的注解

细心的你可能已经发现了, hibernate-validator 只提供了 20 个左右的校验注解,虽然已经满足了大部分场景的使用,但是由于开发过程中的业务多样性,我们可能会遇到它没有提供的校验需求,比如我们现在要校验一个字段必须是合法的身份证号码,那我们就没有办法了,hibernate-validator 允许我们自己定义注解

首先定义一个校验注解

/**
 * 身份证校验
 */
@Target({ ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IdentityConstraintValidator.class)//用哪个校验器校验
public @interface IdentityNo {
    String message() default "身份证号码不符合规则";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
}
复制代码

校验逻辑,我们只需要去实现 ConstraintValidator 这个接口重写 isValid 方法即可

/**
 * 身份证约束逻辑判断
 */
public class IdentityConstraintValidator implements ConstraintValidator<IdentityNo, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return check(value);//返回身份证号码是否符合规则(自己实现校验逻辑)
    }
}
复制代码

这样一来,我们自己的注解就定义好了,把它用在需要验证的字段上就可以了。

@Data
public class OrderRequest {
    //自己定义的注解
    @IdentityNo
    private String merchantCode;
    private String memberCode;
}
复制代码

Postman 测试结果

{
    "path": "/order/submit",
    "error": "Bad Request",
    "message": "merchantCode:身份证号码不符合规则",
    "status": 400
}
复制代码

结语

hibernate-validator 配合统一异常处理简直是完美呀,这样绝大多数问题在前端传参的时候就能暴露出来了~~

如果这篇文章对你有帮助,记得点赞加关注。你的支持就是我继续创作的动力