Spring Bean Validation

1. 前言

平时写接口的时候难免有要对外开放接口调用,如果接口请求体的结构比较简单还好,但是不可避免会遇到一些包含嵌套对象的请求体。

这个时候最烦的就是做参数校验了,一堆的 Objects.isNull 或者 Strings.isNullOrEmpty。冗长的一堆很不雅观而且基本不太可能复用。

最近突然发现 JSR303 (Bean Validation) 的存在,结合 @ControllerAdvice 定制全局异常可以大大减轻参数校验的负担呢。

简单来说,从参数校验的内容来看可以分为两种:

  • 业务无关的参数校验:NotNull、NotEmpty 等等。

  • 业务相关的校验:ID 值的有效性等。

对于业务无关的参数校验,如果校验不通过则会抛出相应的异常。对于与业务有关的参数我们可以在校验不通过后抛出运行时异常。

这些异常可以在 @ControllerAdvice 注解的类中统一处理。

2. 代码实例

2.1 依赖引入

pom.xml 文件中首先引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2.2 简单使用

我们首先定义一个接口:

@GetMapping("jobs")
public Response<String> get(@Valid GetRequest request) {
    return Responses.successResponse(request.getKeyword());
}

然后是请求体格式:

package com.avaloninc.springvalidationexample.request;

import com.avaloninc.webapi.common.request.base.BaseRequest;
import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;

@Data
public class GetRequest extends BaseRequest {
  @NotBlank
  private String keyword;
}

简单来说就是一个只包含一个参数的 GET 请求。请求体的 keyword 字段上面通过 @NotBlank 注解修饰了参数。

Controller 上使用 @Valid 参数触发对参数的校验。

2.3 嵌套结构

通常的使用场景不会有这么简单,请求体往往是嵌套的结构。即请求体的实例成员变量往往是一个集合对象或者另一个类的实例:

package com.avaloninc.springvalidationexample.request;

import com.avaloninc.webapi.common.request.base.BaseRequest;
import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.NotEmpty;

import java.util.List;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;

@Data
public class CreateRequest extends BaseRequest {
  @NotBlank
  private String       name;
  @NotEmpty
  private List<String> alarmReceivers;
  @NotNull
  @Valid
  private Resource     resource;
  @NotEmpty
  @Valid
  private List<File>   files;

  @Data
  public static class Resource {
    @NotEmpty
    private String account;
    @NotEmpty
    private String queue;
  }

  @Data
  public static class File {
    @NotEmpty
    private String fileName;
    @NotEmpty
    private String filePath;
  }
}

这种情况下记得在这些成员变量之上同样加上 @Valid 注解即可!

2.3 全局异常处理

对于参数违反的约束都会以异常的形式抛出来,我们可以在一个类中进行全局的异常处理:

package com.avaloninc.springvalidationexample.advice;

import com.avaloninc.webapi.common.response.Response;
import com.avaloninc.webapi.common.response.Responses;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
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 org.springframework.web.bind.annotation.ResponseStatus;

import java.util.stream.Collectors;

@ControllerAdvice
@Slf4j
public class GlobalControllerAdvice {

  @ExceptionHandler(BindException.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Response handle(final BindException ex) {
    String errorMsg = ex.getFieldErrorCount() > 0
        ? ex.getFieldErrors().stream()
        .map(this::getFieldErrorMessage).collect(Collectors.joining(" "))
        : ex.getMessage();
    return Responses.errorResponse(HttpStatus.BAD_REQUEST.value(), errorMsg);
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Response handle(final MethodArgumentNotValidException ex) {
    String errorMsg = ex.getBindingResult().getFieldErrorCount() > 0
        ? ex.getBindingResult().getFieldErrors().stream()
        .map(this::getFieldErrorMessage).collect(Collectors.joining(" "))
        : ex.getMessage();
    return Responses.errorResponse(HttpStatus.BAD_REQUEST.value(), errorMsg);
  }

  private String getFieldErrorMessage(FieldError err) {
    return err.getField() + " " + err.getDefaultMessage() + "!";
  }

  @ExceptionHandler(IllegalArgumentException.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Response handle(final IllegalArgumentException ex) {
    return Responses.errorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
  }

  @ExceptionHandler(Exception.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  public Response handle(Exception ex) {
    Response response = Responses.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage());
    log.error("Internal Server Error with trace id {}.", response.getMeta().getTraceId(), ex);
    return response;
  }
}

如上面的代码所示,参数校验不通过抛出的异常基本可以总结为:BindExceptionMethodArgumentNotValidException 两种。我们可以从 FieldError 中取出违反约束的参数名以及对应的错误提示!

对于业务相关的参数我们可以用 IllegalArgumentException 来进行异常的抛出和捕获。

最后使用了一个 Exception 来捕获所有没有处理的异常,进行统一的错误日志记录和错误信息的返回。

3. 总结

其实这里只是做了一个最简单的罗列,具体的注解的功能以及使用的类型大家可以从参考资料进一步学习。

借助于 Bean Validation 的确可以大大简化参数的校验,但是还没有想好怎么在工程实践中大规模地使用。

毕竟参数校验可能包含了一定的业务逻辑,全部放在注解中是否合适还有待商榷。

4. 参考资料