使用 Spring-Validation 进行数据校验

在开发 web 项目的过程中,我们往往需要对从前端接收到的数据进行校验,如果我们使用大量的 if else 来进行校验,那么代码会变得非常臃肿,而且不易维护。这时候我们可以使用 Spring-Validation 来进行数据 校验。

使用 JSR 303 注解

Spring-Validation 提供了对于 JSR 303 中的注解的支持,我们可以使用 JSR 303 中的注解来对数据进行校验。

简单来讲,对于一个 DTO 对象,我们可以在其属性上添加 JSR 303 中的注解,然后在 Controller 中对 参数使用 @Valid 或者 @Validated 注解来对数据进行校验。

例如,在进行用户注册的时候,我们从前端接收用户的用户名、密码和邮箱,我们可以创建如下的 DTO 类:

@Data
public class UserDTO {
    @Size(min = 1, max = 20, message = "Username length must be between 1 and 20 characters")
    private String username;

    @Email(message = "Email format is incorrect")
    @NotBlank(message = "Email cannot be blank")
    private String email;

    @Size(min = 6, max = 20, message = "Password length must be between 6 and 20 characters")
    private String userPassword;
}

上面的注解的意思是不言而喻的。接着我们只需要在需要校验的地方使用 @Valid 或者 @Validated 注解即可:

@Controller
@RequestMapping("/user")
public class UserController {
    @Autowired private UserService userService;

    @PostMapping
    public ResponseEntity<Void> createUser(@Valid @RequestBody UserDTO user) {}
}

通过上面的操作后,如果前端传入的数据不符合要求,那么 Spring 会抛出一个 MethodArgumentNotValidException 异常,这个异常的默认处理方式是返回一个 400 的状态码和一个 JSON 格式的错误信息。我们可以自己定义异常处理器。

自定义异常处理器

异常处理器的定义有两种模式,一种是全局异常处理器,一种是局部异常处理器。局部异常处理器只会处理当前 Controller 中的异常,而全局异常处理器会处理所有的异常。局部异常处理器具有更高的优先级。

全局异常处理器

要定义一个全局异常处理器,我们可以创建一个类并且添加 @RestControllerAdvice@ControllerAdvice 注解:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Map<String, String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach((error) -> {
            String fieldName = error.getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        return errors;
    }
}

在上面的例子中,我们只将异常中的字段名和错误信息提取出来,然后返回一个 Map 对象。这样我们就可以 很清楚的从错误信息中看到哪个字段出现了错误。例如在用户名为空的时候,我们可以在响应体中看到:

{
  "username": "Username length must be between 1 and 20 characters"
}

局部异常处理器

局部异常处理器的定义只需要将异常处理器定义在某个 Controller 中即可。

分组校验

现在我们为上面的 DTO 对象添加一个 id 字段,在创建用户的时候,不需要指定 id 字段,但是在更新 用户的时候,id 字段是必须的。这时候我们可以使用分组校验来解决这个问题。

首先我们需要在 DTO 对象中定义一个分组:(这里使用内部类进行演示)

@Data
public class UserDTO {
    @NotNull(groups = Update.class)
    @Null(groups = Create.class)
    private Long id;

    @Size(
        groups = {Update.class, Create.class},
        min = 1, max = 20,
        message = "Username length must be between 1 and 20 characters")
    private String username;

    @Email(groups = {Update.class, Create.class}, message = "Email format is incorrect")
    @NotBlank(groups = {Update.class, Create.class}, message = "Email cannot be blank")
    private String email;

    @Size(
        groups = {Update.class, Create.class},
        min = 6, max = 20,
        message = "Password length must be between 6 and 20 characters")
    private String userPassword;
    
    public interface Update {}

    public interface Create {}
}

然后我们在 Controller 中使用 @Validated (此时不能使用 @Valid 注解) 注解来指定分组:

@Controller
@RequestMapping("/user")
public class UserController {
    @Autowired private UserService userService;

    @PostMapping
    public ResponseEntity<Void> createUser(@Validated(UserDTO.Create.class) @RequestBody UserDTO user) {
        return null;
    }

    @PatchMapping
    public ResponseEntity<Void> updateUser(@Validated(UserDTO.Update.class) @RequestBody UserDTO user) {
        return null;
    }
}

常用注解

JSR 303 中定义了很多注解,这里列出一些常用的注解:

注解 说明
@Null 验证对象是否为 null
@NotNull 验证对象是否不为 null,无法查检长度为 0的字符串
@NotBlank 验证 String 对象是否不为 null、长度是否大于 0、是否包含至少一个非空白字符
@AssertFalse 验证 Boolean 对象是否为 false
@AssertTrue 验证 Boolean 对象是否为 true
@Min 验证 Number 对象是否大于等于指定的值
@Max 验证 Number 对象是否小于等于指定的值
@DecimalMin 验证 NumberString 对象是否大于等于指定的值
@DecimalMax 验证 NumberString 对象是否小于等于指定的值
@Digits 验证 NumberString 对象是否是指定范围内的数字
@Past 验证 Date 对象是否是在当前时间之前
@Future 验证 Date 对象是否是在当前时间之后
@Size 验证对象(ArrayCollectionMapString)长度是否在给定的范围之内
@Pattern 验证 String 对象是否符合正则表达式的规则

下面的注解是 Hibernate Validator 中定义的注解:

注解 说明
@Email 验证 String 对象是否是一个合法的电子邮件地址
@Length 验证 String 对象是否是在给定的范围之内
@NotEmpty 验证对象是否不为 null 或者 ""
@Range 验证 Number 对象是否在给定的范围之内

MessageSource 管理错误信息

在上面的例子中,我们直接在注解中写入了错误信息,这样的做法并不利于开发。例如,我们如果在编写测试的时候 需要检验错误信息是否一直,我们不得不再次写入错误信息。而修改的时候,我们则需要对多处进行修改。

为了解决这个问题,我们可以使用 MessageSource 来管理错误信息。

首先我们在 application.yml ( 或者 application.properties ) 中添加如下配置:

spring:
  messages:
    basename: message/message
    encoding: UTF-8

标识我们的错误信息文件在 resources 目录下的 message 目录中,文件名为 message.properties ( 只能 使用 properties 文件 )。

接下来,我们在 resources 目录下创建一个 message 目录,并且在其中创建一个 message.properties 文件, 在文件中添加如下内容:

# UserDTO validation messages
UserDTO.username.Size=Username must be between {min} and {max} characters
UserDTO.email.NotBlank=Email cannot be blank
UserDTO.email.Email=Email must be a valid email address
UserDTO.userPassword.Size=Password must be between {min} and {max} characters

然后我们可以直接在注解中的 message 部分使用 MessageSourcekey 来引用错误信息:

@Data
public class UserDTO {
    @Size(
            groups = {UpdateGroup.class, CreateGroup.class},
            min = ConstantProperty.MIN_USERNAME_LENGTH,
            max = ConstantProperty.MAX_USERNAME_LENGTH,
            message = "{UserDTO.username.Size}")
    private String username;

    @Email(
            groups = {UpdateGroup.class, CreateGroup.class},
            message = "{UserDTO.email.Email}")
    @NotBlank(
            groups = {UpdateGroup.class, CreateGroup.class},
            message = "{UserDTO.email.NotBlank}")
    private String email;

    @Size(
            groups = {UpdateGroup.class, CreateGroup.class},
            min = ConstantProperty.MIN_PASSWORD_LENGTH,
            max = ConstantProperty.MAX_PASSWORD_LENGTH,
            message = "{UserDTO.userPassword.Size}")
    private String userPassword;
}

上面通过 message = "{xxx.xxx.xxx}" 的方式获取变量是 Hibernate 提供的支持。而我们编写的字符串中 的 {min}{max} 会被替换成注解中的 minmax 的值。

但是我们在使用 MessageSource 的时候并不支持这种自动替换的方式,因此我们需要自己书写一个工具类来 替换这些变量:

@Component
public class MessageSourceUtil {
    private static MessageSource messageSource;

    // 通过构造器注入 MessageSource
    MessageSourceUtil(MessageSource messageSource) {
        MessageSourceUtil.messageSource = messageSource;
    }

    public static String getMessage(String code, Object[] args) {
        String message = messageSource.getMessage(code, null, null);
        // 获取到的 message 中包含了 {min} 和 {max} 这样的变量,我们需要将其替换成实际的值
        Pattern pattern = Pattern.compile("\\{.*?\\}");
        Matcher matcher = pattern.matcher(message);
        int i = 0;
        while (matcher.find()) {
            message = message.replace(matcher.group(), args[i].toString());
            i++;
        }
        return message;
    }
}

这样我们可以在 Controller 中使用 MessageSourceUtil 来获取错误信息:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class UserControllerTest {
    @Autowired private MockMvc mvc;

    @Test
    public void testCreateUserInvalid() throws Exception {
        String user =
                """
                {
                    "name": "test",
                    "email": "invalid email address",
                    "userPassword": ""
                }
                """;
        mvc.perform(
                        MockMvcRequestBuilders.post("/user")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(user))
                .andExpectAll(
                        status().isBadRequest(),
                        jsonPath(
                                "$.email",
                                equalTo(MessageSourceUtil.getMessage("UserDTO.email.Email"))),
                        jsonPath(
                                "$.userPassword",
                                equalTo(
                                        MessageSourceUtil.getMessage(
                                                "UserDTO.userPassword.Size",
                                                ConstantProperty.MIN_PASSWORD_LENGTH,
                                                ConstantProperty.MAX_PASSWORD_LENGTH
                                                ))),
                        content().contentType(MediaType.APPLICATION_JSON));
    }

在上面的代码中,我们同样将密码长度等常量信息放在了 ConstantProperty 类中,这样我们可以在测试中直接 引用这些常量。

国际化

实际上,MessageSource 是用来支持国际化的。在上面的例子中,我们只是使用了一个 properties 文件, 实际上我们可以使用多个 properties 文件来支持多种语言。

我们只需要在 resources/message (取决于我们指定的 basename ) 目录下创建多个 properties 文件, 例如 message.propertiesmessage_en.propertiesmessage_zh.properties 等等。这样在发送请求的 时候,我们可以在请求头中添加 Accept-Language 来指定语言,Spring 会根据请求头中的语言来选择对应的 properties 文件中的信息。




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • IEEE Xtreme 18.0 题解
  • 马拉松 4 小时挑战记录
  • ssh 端口转发简介
  • 服务器上创建 git 远程仓库
  • Spring Boot Test 自定义测试类顺序