数据的校验的重要性就不用说了,即使在前端对数据进行校验的情况下,我们还是要对传入后端的数据再进行一遍校验,避免用户绕过浏览器直接通过一些 HTTP 工具直接向后端请求一些违法数据。

最普通的做法就像下面这样。我们通过 语句对请求的每一个参数一一校验。
if/else

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

    @PostMapping
    public ResponseEntity<PersonRequest> save(@RequestBody PersonRequest personRequest) {
        if (personRequest.getClassId() == null
                || personRequest.getName() == null
                || !Pattern.matches("(^Man$|^Woman$|^UGM$)", personRequest.getSex())) {

        }
        return ResponseEntity.ok().body(personRequest);
    }
}

这样的代码,小伙伴们在日常开发中一定不少见,很多开源项目都是这样对请求入参做校验的。

但是,不太建议这样来写,这样的代码明显违背了 单一职责原则。大量的非业务代码混杂在业务代码中,非常难以维护,还会导致业务层代码冗杂!

实际上,我们是可以通过一些简单的手段对上面的代码进行改进的!这也是本文主要要介绍的内容!

废话不多说!下面我会结合自己在项目中的实际使用经验,通过实例程序演示如何在 SpringBoot 程序中优雅地的进行参数验证(普通的 Java 程序同样适用)。

不了解的朋友一定要好好看一下,学完马上就可以实践到项目上去。

并且,本文示例项目使用的是目前最新的 Spring Boot 版本 3.0.2

添加相关依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.13.1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

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

</dependencies>

验证 Controller 的输入

验证请求体

验证请求体即使验证被 注解标记的方法参数。@RequestBody

我们在需要验证的参数上加上了注解,如果验证失败,它将抛出。默认情况下,Spring 会将此异常转换为 HTTP Status 400(错误请求)。
@Valid MethodArgumentNotValidException

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

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

PersonRequest

我们使用校验注解对请求的参数进行校验!

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PersonRequest {

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

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

    @Pattern(regexp = "(^Man$|^Woman$|^UGM$)", message = "sex 值不在可选范围")
    @NotNull(message = "sex 不能为空")
    private String sex;

}

GlobalExceptionHandler全局异常

@ControllerAdvice(assignableTypes = {PersonController.class})
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }
}

通过测试验证

下面我通过 模拟请求 的方式来验证是否生效。当然了,你也可以通过 这种工具来验证。

MockMvc Controller Postman

@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("Man22")
                .classId("82938390").build();
        mockMvc.perform(post("/api/personRequest")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(personRequest)))
                .andExpect(MockMvcResultMatchers.jsonPath("sex").value("sex 值不在可选范围"))
                .andExpect(MockMvcResultMatchers.jsonPath("name").value("name 不能为空"));
    }
}

使用 Postman 验证

验证请求参数

验证请求参数(Path Variables 和 Request Parameters)即是验证被 以及 标记的方法参数。

@PathVariable @RequestParam

一定一定不要忘记在类上加上 Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。

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

    @GetMapping("/{id}")
    public ResponseEntity<Integer> getPersonByID(@Valid @PathVariable("id") @Max(value = 5, message = "超过 id 的范围了") Integer id) {
        return ResponseEntity.ok().body(id);
    }

    @PutMapping
    public ResponseEntity<String> getPersonByName(@Valid @RequestParam("name") @Size(max = 6, message = "超过 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_variable() 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_request_param_value2() throws Exception {
    mockMvc.perform(put("/api/person")
                    .param("name", "snailclimbsnailclimb")
                    .contentType(MediaType.APPLICATION_JSON))
      .andExpect(status().isBadRequest())
      .andExpect(content().string("getPersonByName.name: 超过 name 的范围了"));
}

使用 Postman 验证

Validator 编程方式手动进行参数验证

某些场景下可能会需要我们手动校验并获得校验结果。

我们通过 工厂类获得的 示例。另外,如果是在 Spring Bean 中的话,还可以通过 直接注入的方式。

/**
 * 手动校验对象
 */
@Test
public void check_person_manually() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    PersonRequest personRequest = PersonRequest.builder().sex("Man22")
            .classId("82938390").build();
    Set<ConstraintViolation<PersonRequest>> violations = validator.validate(personRequest);
    violations.forEach(constraintViolation -> System.out.println(constraintViolation.getMessage()));
}

输出结果如下:

sex 值不在可选范围
name 不能为空

自定义 Validator(实用)

如果自带的校验注解无法满足你的需求的话,你还可以自定义实现注解。

案例一:校验特定字段的值是否在可选范围

比如我们现在多了这样一个需求: 类多了一个 字段, 字段只能是、、这三个中的一个。
China China-Taiwan China-HongKong

第一步,你需要创建一个注解 Region

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

    String message() default "Region 值不在可选范围内";

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

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

第二步,你需要实现 ConstraintValidator接口,并重写isValid 方法。

public class RegionValidator implements ConstraintValidator<Region, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        HashSet<Object> regions = new HashSet<>();
        regions.add("China");
        regions.add("China-Taiwan");
        regions.add("China-HongKong");
        return regions.contains(value);
    }
}

现在你就可以使用这个注解:

@Region
private String region;

使用 Postman 验证

案例二:校验电话号码

校验我们的电话号码是否合法,这个可以通过正则表达式来做,相关的正则表达式都可以在网上搜到,你甚至可以搜索到针对特定运营商电话号码段的正则表达式。

PhoneNumber.java

@Documented
@Constraint(validatedBy = PhoneNumberValidator.class)
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
public @interface PhoneNumber {
    String message() default "Invalid phone number";
    Class[] groups() default {};
    Class[] payload() default {};
}

PhoneNumberValidator.java

public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {

    @Override
    public boolean isValid(String phoneField, ConstraintValidatorContext context) {
        if (phoneField == null) {
            // can be null
            return true;
        }
        //  大陆手机号码11位数,匹配格式:前三位固定格式+后8位任意数
        // ^ 匹配输入字符串开始的位置
        // \d 匹配一个或多个数字,其中 \ 要转义,所以是 \\d
        // $ 匹配输入字符串结尾的位置
        String regExp = "^[1]((3[0-9])|(4[5-9])|(5[0-3,5-9])|([6][5,6])|(7[0-9])|(8[0-9])|(9[1,8,9]))\\d{8}$";
        return phoneField.matches(regExp);
    }
}

搞定,我们现在就可以使用这个注解了。

@PhoneNumber(message = "phoneNumber 格式不正确")
@NotNull(message = "phoneNumber 不能为空")
private String phoneNumber;

使用验证组

验证组我们基本是不会用到的,也不太建议在项目中使用,理解起来比较麻烦,写起来也比较麻烦。简单了解即可!

当我们对对象操作的不同方法有不同的验证规则的时候才会用到验证组。

我写一个简单的例子,你们就能看明白了!

1.先创建两个接口,代表不同的验证组

public interface AddPersonGroup {
}
public interface DeletePersonGroup {
}

2.使用验证组

@Data
public class Person {
// 当验证组为 DeletePersonGroup 的时候 group 字段不能为空
@NotNull(groups = DeletePersonGroup.class)
// 当验证组为 AddPersonGroup 的时候 group 字段需要为空
@Null(groups = AddPersonGroup.class)
private String group;
}

@Service
@Validated
public class PersonService {

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

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

}

通过测试验证:

  @Test(expected = ConstraintViolationException.class)
  public void should_check_person_with_groups() {
      Person person = new Person();
      person.setGroup("group1");
      service.validatePersonGroupForAdd(person);
  }

  @Test(expected = ConstraintViolationException.class)
  public void should_check_person_with_groups2() {
      Person person = new Person();
      service.validatePersonGroupForDelete(person);
  }

验证组使用下来的体验就是有点反模式的感觉,让代码的可维护性变差了!尽量不要使用!

常用校验注解总结:

JSR303定义了 (校验)的标准 ,并没有提供实现。是对这个规范/规范的实现 ,并且增加了 、、 等注解。

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=)被注释的元素必须在合适的范围内

拓展

经常有小伙伴问到:@NotNull@Column(nullable = false) 两者有什么区别?”

我这里简单回答一下:

  • @NotNull是 JSR 303 Bean 验证批注,它与数据库约束本身无关。
  • @Column(nullable = false): 是 JPA 声明列为非空的方法。

总结来说就是即前者用于验证,而后者则用于指示数据库创建表的时候对表的约束。

分类: SpringBoot

0 条评论

发表回复

Avatar placeholder

您的邮箱地址不会被公开。 必填项已用 * 标注