[Spring Validation]SpringBoot请求参数校验:分组校验

73 阅读3分钟

问题引入

在使用 SpringBoot 实现请求体参数校验的时候,有些字段可能是共用的,比如在创建和更新的时候共用请求体 UserDTO,但是差别就在于 userId 字段。创建时不需要 userId 字段,更新则需要。

如果直接在 userId 字段标注 @NotNull 注解,那么创建操作就会失败,所以需要使用分组校验来区分。

搭建工程

创建一个 SpringBoot 工程,导入以下依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.12.RELEASE</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>annotationProcessor</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <scope>annotationProcessor</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

然后简单编写一下 application.yml 配置:

server:
  port: 8080
spring:
  application:
    name: spring-boot-first
  jackson:
    default-property-inclusion: non_null
  mvc:
    locale: en_US # 内置的错误详情信息返回显示为英文
    locale-resolver: fixed

DTO类

案例用到的 DTO 类如下:

UserDTO

@Builder
@Data
public class UserDTO {

    @NotNull
    @Range(max = Integer.MAX_VALUE)
    private Integer id;
    
    @Range(max = 200)
    private Integer age;
    
    @Length(max = 256)
    private String username;
    
    @Length(max = 256)
    private String address;
    
    @Valid
    private JobInfo jobInfo;
}

JobInfo

@Data
@AllArgsConstructor
@NoArgsConstructor
public class JobInfo {

    @NotNull
    @Length(max = 64)
    private String company;

    @NotNull
    @Length(max = 64)
    private String position;

}

响应结果类 R

@Data
public class R<T> {

    private Integer code = NORMAL_CODE;

    private String msg = "";

    private T data;
    
    private List<ParamError> errors;

    public static final String SUCCESS = "操作成功";

    public static final String FAILURE = "操作失败";

    public static final Integer ERROR_CODE = 500;

    public static final Integer NORMAL_CODE = 200;

    public R() {

    }

    public R(T data) {
        this.data = data;
    }

    public R(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public static <T> R<T> ok() {
        return ok(HttpStatus.OK.value(), SUCCESS, null);
    }

    public static <T> R<T> ok(Integer code, String msg, T data) {
        R<T> r = new R<>();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }

    public static <T> R<T> ok(T data) {
        return ok(ERROR_CODE, SUCCESS, data);
    }

    public static <T> R<T> fail(Integer code, String msg) {
        return ok(code, msg, null);
    }

    public static <T> R<T> fail(Integer code) {
        return ok(code, FAILURE, null);
    }

    public static <T> R<T> fail(String msg) {
        return ok(ERROR_CODE, msg, null);
    }

    public static <T> R<T> fail() {
        return ok(ERROR_CODE, FAILURE, null);
    }
}

参数详情类 ParamError

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ParamError {

    private String param;
    
    private String reason;
}

全局异常捕获器

在 SpringBoot 工程中需要定义一个全局异常捕获器才能把详细的错误信息返回给客户端。在此不多做赘述,不清楚地可以翻看 SpringBoot 的资料:

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 用于校验请求体字段 RequestBody 错误的方法
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public R<Void> handleValidationExceptions(MethodArgumentNotValidException ex) {
        R<Void> resp = R.fail(HttpStatus.BAD_REQUEST.value());
        List<ParamError> errors = ex.getBindingResult().getAllErrors().stream().map(error -> {
            String paramName = ((FieldError) error).getField();
            String reason = error.getDefaultMessage();
            return new ParamError(paramName, reason);
        }).collect(Collectors.toList());
        resp.setErrors(errors);
        return resp;
    }

    // 用于校验请求参数 RequestParam/PathVariable 错误的方法
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConstraintViolationException.class)
    public R<Void> handleConstraintViolation(ConstraintViolationException ex) {
        R<Void> resp = R.fail(HttpStatus.BAD_REQUEST.value());
        List<ParamError> errors = ex.getConstraintViolations().stream().map(cv -> {
            String paramName = cv.getPropertyPath().toString().split("\\.")[1];
            String reason = cv.getMessage();
            return new ParamError(paramName, reason);
        }).collect(Collectors.toList());
        resp.setErrors(errors);
        return resp;
    }
}

开发一个UserController

开发一个简单的接口:

@Validated
@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/{id}")
    public R<UserDTO> getById(@Range(max = Integer.MAX_VALUE) @PathVariable("id") Integer id) {
        UserDTO user = UserDTO.builder()
                        .id(id)
                        .age(200)
                        .username("奥巴马")
                        .address("LA")
                        .jobInfo(new JobInfo("Government", "President"))
                        .build();
        return R.ok(user);
    }

    @PostMapping("/create")
    public String create(@Validated @RequestBody UserDTO user) {
        System.out.println(user);
        return "creating user succeeded";
    }

    @PutMapping("/update")
    public String update(@Validated @RequestBody UserDTO user) {
        System.out.println(user);
        return "updating user succeeded";
    }

    @DeleteMapping("/del")
    public String delById(@Length(max = 64) @RequestParam("id") String id) {
        System.out.println(id);
        return "deleting user succeeded";
    }
}

使用ApiPost测试

现在使用 ApiPost 测试创建用户和更新用户的接口:

image.png

响应体显示 id 不可以为 null,因为这边创建用户和更新用户是共用的 UserDTO 类的字段,创建时不需要,更新时候则需要。如果根据不同操作类型定义不同的 DTO 对象,比如:UserCreateDTOUserUpdateDTO 那么就显得太冗余了。这时候就需要使用到分组校验的特性了。

分别定义Create和Update接口

定义如下接口并继承自 javax.validation.groups.Default 接口:

package org.codeart.validation;

import javax.validation.groups.Default;

public interface Create extends Default {

}

public interface Update extends Default {

}

然后 UserController 的方法参数内部的 @Validated 注解的参数添加上分组的接口类型:

@PostMapping("/create")
public String create(@Validated(Create.class) @RequestBody UserDTO user) {
    System.out.println(user);
    return "creating user succeeded";
}

@PutMapping("/update")
public String update(@Validated(Update.class) @RequestBody UserDTO user) {
    System.out.println(user);
    return "updating user succeeded";
}

UserDTO 类的字段上添加校验的注解:

@Null(groups = Create.class)
@NotNull(groups = Update.class)
@Range(max = Integer.MAX_VALUE)
private Integer id;

现在就可以实现分组校验的功能了,再使用 ApiPost 来测试一下:

image.png

image.png

在创建用户时传了 id 报错,修改用户时没有传 id 也报错,最终实现了我们想要的效果。