SpringBoot 优雅校验参数实践

1,569 阅读6分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

添加相关依赖

Java 程序需要添加以下依赖

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.9.Final</version>
</dependency>
<dependency>
    <groupId>javax.el</groupId>
    <artifactId>javax.el-api</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>org.glassfish.web</groupId>
    <artifactId>javax.el</artifactId>
    <version>2.2.6</version>
</dependency>

Spring Boot 只需要添加 spring-boot-starter-web依赖即可

image-20210426205946094.png

但是 Spring Boot 2.3 1 之后,spring-boot-starter-validation 已经不包括在了 spring-boot-starter-web 中,需要我们手动加上

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

Controller 层的验证

验证请求体

验证被 @RequstBody注解标记的方法参数。

在需要验证的参数前加上 @Valid 注解,验证失败则抛出 MethodArgumentNotValidException 异常,Spring 会将其转换成 HTTP Status 400 「错误请求」。

PersonController

@RestController
@RequestMapping("/api/person")
public class PersonController {

    @PostMapping
    public ResponseEntity<PersonRequest> save(@Valid @RequestBody PersonRequest personRequest) {
        return ResponseEntity.ok().body(personRequest);
    }
}

PersonRequest 使用注解对请求参数校验

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PersonRequest {
    @NotNull(message = "id 不能为空")
    private String id;

    @NotNull(message = "classId 不能为空")
    private String classId;

    @NotNull(message = "name 不能为空")
    private String name;
}

**自定义异常处理器捕获异常 **

增加对于 PersonController 的异常处理方法,捕获验证失败抛出的 MethodArgumentNotValidException ,将校验注解对应字端名和 message 信息封装返回。

@ResponseBody
@ControllerAdvice(assignableTypes = PersonController.class)
public class GlobalExceptionHandler {

    // 处理 MethodArgumentNotValidException 的异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String ,String>> handleVaildationException(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getAllErrors().forEach(error -> {
            String field = ((FieldError) error).getField();
            String defaultMessage = error.getDefaultMessage();
            errors.put(field, defaultMessage);
        });
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }
}

通过测试验证

接下来通过 MockMvc 模拟请求 Controller 的方式来验证是否生效。

@SpringBootTest
@AutoConfigureMockMvc
public class PersonControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    public void should_check_person_value() throws Exception {
        PersonRequest personRequest = PersonRequest.builder().sex("11111").build();
        mockMvc.perform(post("/api/person")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(personRequest)))
                .andExpect(MockMvcResultMatchers.jsonPath("classId").value("classId 不能为空"))
                .andExpect(MockMvcResultMatchers.jsonPath("name").value("name 不能为空"));
    }
}

Postman 验证

image-20210426122206119.png

验证请求参数

常见的请求除了请求体外更多的是对于 @PathVariable@RequestParam 标记的请求参数。

PersonController

注意:必须要在类上加上 @Validated 注解,这样 Spring 才会去校验参数

@Validated
@RestController
@RequestMapping("/api/person")
public class PersonController {
    @GetMapping("/{id}")
    public ResponseEntity<Integer> getPersonByID(@Max(value = 5, message = "超过 id 的范围了") @PathVariable(name = "id") Integer id) {
        return ResponseEntity.ok().body(id);
    }

    @GetMapping("/name")
    public ResponseEntity<String> getPersonByName(@Size(max = 6, message = "超过 name 的范围了") @RequestParam(name = "name") String name) {
        return ResponseEntity.ok().body(name);
    }
}

ExceptionHandler

  @ExceptionHandler(ConstraintViolationException.class)
  ResponseEntity<String> handleConstraintViolationException(ConstraintViolationException e) {
     return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
  }

通过测试验证

@Test
public void should_check_path_value() throws Exception {
    mockMvc.perform(get("/api/person/6")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isBadRequest())
            .andExpect(content().string("getPersonByID.id: 超过 id 的范围了"));
}

@Test
public void should_check_param_value() throws Exception {
    mockMvc.perform(get("/api/person/name")
            .param("name", "1234567")
            .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isBadRequest())
            .andExpect(content().string("getPersonByName.name: 超过 name 的范围了"));
}

使用 Postman 验证

image-20210426162612230.png

image-20210426162640311.png

Service 中方法参数的校验

事实上请求参数的校验不仅仅可以用在 Controller 层,在 Service依旧适用。

PersonService

同样不要忘记在类上加上 @Validated 注解

@Validated
@Service
public class PersonService {

    public void validatePersonRequest(@Valid PersonRequest personRequest) {
        // do something
    }

    public void validateId(@Max(value = 5, message = "超过 id 的范围了") Integer id) {
        // do something
    }
}

通过测试验证

@SpringBootTest
class PersonServiceTest {

    @Autowired
    PersonService personService;

    @Test
    void should_person_check() {
        try {
            PersonRequest personRequest = PersonRequest.builder().sex("22").person(Person.builder().address("aaaa").build()).build();
            personService.validatePersonRequest(personRequest);
        } catch (ConstraintViolationException ex) {
            ex.getConstraintViolations().forEach(constraintViolation -> System.out.println(constraintViolation.getMessage()));
        }
    }

    @Test
    void should_id_check() {
        try {
            personService.validateId(6);
        } catch (ConstraintViolationException ex) {
            ex.getConstraintViolations().forEach(constraintViolation -> System.out.println(constraintViolation.getMessage()));
        }
    }
}

输出结果如下

name 不能为空
classId 不能为空
id 不能为空
超过 id 的范围了

嵌套验证

绝大部分情况请求体中都会出现实体嵌套的现象,比如:PersonRequest 中包含了 Person 类,这种情况可以使用 @Valid 注解标记 Person 属性。

PersonRequest

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PersonRequest {
    @NotNull(message = "classId 不能为空")
    private String classId;

    @NotNull(message = "name 不能为空")
    private String name;

    @Valid
    @NotNull(message = "person 不能为空")
    private Person person;
}

Person

注意:如果没有添加 @NotNull(message = "person 不能为空") 注解,那么请求参数中没有包含 Person 不会去验证 Person 中的参数

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Person {
    @NotNull(message = "id 不能为空")
    private String id;
    
    @NotNull(message = "address 不能为空")
    private String address;
}

通过测试验证

@Test
public void should_check_person_value() throws Exception {
    PersonRequest personRequest = PersonRequest.builder().sex("11111").build();
    mockMvc.perform(post("/api/person")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(personRequest)))
            .andExpect(MockMvcResultMatchers.jsonPath("classId").value("classId 不能为空"))
            .andExpect(MockMvcResultMatchers.jsonPath("name").value("name 不能为空"))
            .andExpect(MockMvcResultMatchers.jsonPath("person").value("person 不能为空"));
}

@Test
public void should_check_person_nested_value() throws Exception{
    PersonRequest personRequest2 = PersonRequest.builder().sex("11111").person(Person.builder().address("add").build()).build();
    mockMvc.perform(post("/api/person")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(personRequest2)))
            .andExpect(MockMvcResultMatchers.jsonPath("classId").value("classId 不能为空"))
            .andExpect(MockMvcResultMatchers.jsonPath("name").value("name 不能为空"))
            .andExpect(MockMvcResultMatchers.jsonPath("$['person.id']").value("id 不能为空"));
}

注意:验证的时候有个小坑 jsonPath 会将 person.id 转换成 {"person":{"id":"id 不能为空"}} 的形式,但是实际后台异常抛出的就是{"person.id":"id 不能为空"} 所以此处要使用$['person.id']来解析 JSON。

image-20210426152506085.png

详细信息可以看看 github 中的介绍 github.com/json-path/J…

Postman 验证

image-20210426152727388.png

Validator 手动验证参数

如果需要手动验证可以直接通过 Validator 工厂类获得的 Validator 示例。

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();

如果在 Spring Bean 中的话,还可以通过 @Autowired 直接注入的方式。

@Autowired
Validator validate

使用:

@Test
void check_person_manually(){
    PersonRequest personRequest = PersonRequest.builder().sex("22").person(Person.builder().address("aaaa").build()).build();

    ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
    Set<ConstraintViolation<PersonRequest>> validate = validatorFactory.getValidator().validate(personRequest);
    validate.forEach(personRequestConstraintViolation -> {
        System.out.println(personRequestConstraintViolation.getMessage());
    });
}

输出结果如下:

name 不能为空
classId 不能为空
id 不能为空

自定义 Validator

自带的注解没有办法满足你的需求,可以通过自定义 Validator 实现

校验字端是否属于指定类型

当前 PersonRequest 中增加了 PhoneType 字端,PhoneType 只能是 IOSANDROID 中的一个。

第一步,创建注解 PhoneType

@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = PhoneTypeValidator.class)
@Documented
public @interface PhoneType {

    String message() default "phoneType 不是规定类型";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

第二步,实现 ConstraintValidator 接口,重写 isValid 方法

public class PhoneTypeValidator implements ConstraintValidator<PhoneType, String>  {

    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        HashSet<String> phoneTypes = new HashSet<>();
        phoneTypes.add("IOS");
        phoneTypes.add("ANDROID");
        return phoneTypes.contains(s);
    }
}

使用注解标记属性

@PhoneType
private String phoneType;

通过测试验证

@Test
void check_phone_type() throws Exception {
    PersonRequest personRequest = PersonRequest.builder().sex("11111").phoneType("window").build();
    mockMvc.perform(post("/api/person")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(personRequest)))
            .andExpect(MockMvcResultMatchers.jsonPath("classId").value("classId 不能为空"))
            .andExpect(MockMvcResultMatchers.jsonPath("name").value("name 不能为空"))
            .andExpect(MockMvcResultMatchers.jsonPath("phoneType").value("phoneType 不是规定类型"));
}

使用 Postman 验证

image-20210426184205409.png

分组验证

对一个参数需要多种验证方式时,也可通过分配组验证达到目的。

  1. 创建两个接口
@GroupSequence({IGroupA.class, IGroupB.class})
public interface IGroup {
}
  1. 实体添加注释
@NotNull(message = "group 不能为空", groups = {AddGroup.class})
@Null(message = "group 必须为空", groups = {UpdateGroup.class})
private String group;
  1. 使用验证组
@Validated(AddGroup.class)
public void validateAddPerson(@Valid Person person) {
  // do something
}

@Validated(UpdateGroup.class)
public void validateUpdatePerson(@Valid Person person) {
  // do something
}

通过测试验证

@Test
void check_add_user() {
    try {
        Person person = Person.builder().address("address").build();
        personService.validateAddPerson(person);
    } catch (ConstraintViolationException ex) {
        ex.getConstraintViolations().forEach(constraintViolation -> System.out.println(constraintViolation.getMessage()));
    }
}
@Test
void check_update_user() {
    try {
        Person person = Person.builder().address("address").group("group").build();
        personService.validateUpdatePerson(person);
    } catch (ConstraintViolationException ex) {
        ex.getConstraintViolations().forEach(constraintViolation -> System.out.println(constraintViolation.getMessage()));
    }
}

注解总结

JSR303 定义了 Bean Validation(校验)的标准 validation-api,并没有提供实现。Hibernate Validation是对这个规范/规范的实现 hibernate-validator,并且增加了 @Email@Length@Range 等注解。Spring Validation 底层依赖的就是Hibernate Validation

JSR 提供的校验注解:

  • @Null 被注释的元素必须为 null
  • @NotNull 被注释的元素必须不为 null
  • @AssertTrue 被注释的元素必须为 true
  • @AssertFalse 被注释的元素必须为 false
  • @Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
  • @Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
  • @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
  • @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
  • @Size(max=, min=) 被注释的元素的大小必须在指定的范围内
  • @Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内
  • @Past 被注释的元素必须是一个过去的日期
  • @Future 被注释的元素必须是一个将来的日期
  • @Pattern(regex=,flag=) 被注释的元素必须符合指定的正则表达式

Hibernate Validator 提供的校验注解

  • @NotBlank(message =) 验证字符串非 null,且长度必须大于 0
  • @Email 被注释的元素必须是电子邮箱地址
  • @Length(min=,max=) 被注释的字符串的大小必须在指定的范围内
  • @NotEmpty 被注释的字符串的必须非空
  • @Range(min=,max=,message=) 被注释的元素必须在合适的范围内