1. 概述

通常在需要验证用户输入时,Spring MVC 提供了标准的预定义验证器。但当需要验证更特殊的输入类型时,我们完全可以创建自己的自定义验证逻辑

本教程将带你实现两个场景:创建自定义验证器验证表单中的电话号码字段,以及实现跨多字段的自定义验证器。

⚠️ 本文聚焦 Spring MVC,关于 Spring Boot 中的自定义验证可参考《Spring Boot 中的验证》一文。

2. 环境准备

pom.xml 中添加核心依赖:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.10.Final</version>
</dependency>

最新版本可查看 Maven 仓库

✅ 若使用 Spring Boot,添加 spring-boot-starter-web 即可自动引入该依赖。

3. 自定义验证实现

创建自定义验证器需要两个核心步骤:

  1. 定义自定义注解
  2. 实现验证逻辑

下面我们以电话号码验证为例:要求必须是数字,长度在 8-11 位之间。

4. 创建自定义注解

定义注解接口 @ContactNumberConstraint

@Documented
@Constraint(validatedBy = ContactNumberValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ContactNumberConstraint {
    String message() default "Invalid phone number";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

关键点说明:

  • @Constraint 指定验证逻辑实现类
  • message() 定义错误提示信息
  • 其他为 Spring 标准要求的样板代码

5. 实现验证器

创建验证器类 ContactNumberValidator

public class ContactNumberValidator implements 
  ConstraintValidator<ContactNumberConstraint, String> {

    @Override
    public void initialize(ContactNumberConstraint contactNumber) {
    }

    @Override
    public boolean isValid(String contactField,
      ConstraintValidatorContext cxt) {
        return contactField != null && contactField.matches("[0-9]+")
          && (contactField.length() > 8) && (contactField.length() < 14);
    }
}

核心要点:

  • 实现 ConstraintValidator 接口
  • isValid() 方法中编写验证规则
  • 当前规则:非空、纯数字、长度 9-13 位(实际业务中建议更严格的正则)

⚠️ ConstraintValidator 实现需满足:

  • 目标对象必须是非参数化类型
  • 泛型参数必须是无界通配符类型

6. 应用验证注解

在模型类中使用自定义注解:

@ContactNumberConstraint
private String phone;

控制器中处理验证逻辑:

@Controller
public class ValidatedPhoneController {
 
    @GetMapping("/validatePhone")
    public String loadFormPage(Model m) {
        m.addAttribute("validatedPhone", new ValidatedPhone());
        return "phoneHome";
    }
    
    @PostMapping("/addValidatePhone")
    public String submitForm(@Valid ValidatedPhone validatedPhone,
      BindingResult result, Model m) {
        if(result.hasErrors()) {
            return "phoneHome";
        }
        m.addAttribute("message", "Successfully saved phone: "
          + validatedPhone.toString());
        return "phoneHome";
    }   
}

关键点:

  • @Valid 触发验证
  • BindingResult 捕获验证错误
  • 验证失败时返回原表单页面

7. 视图层实现

JSP 表单页面 phoneHome.jsp

<form:form 
  action="/${pageContext.request.contextPath}/addValidatePhone"
  modelAttribute="validatedPhone">
    <label for="phoneInput">Phone: </label>
    <form:input path="phone" id="phoneInput" />
    <form:errors path="phone" cssClass="error" />
    <input type="submit" value="Submit" />
</form:form>

核心元素:

  • form:input 绑定模型字段
  • form:errors 显示验证错误信息

8. 测试验证

8.1 页面访问测试

@Test
public void givenPhonePageUri_whenMockMvc_thenReturnsPhonePage(){
    this.mockMvc.
      perform(get("/validatePhone")).andExpect(view().name("phoneHome"));
}

8.2 验证逻辑测试

@Test
public void 
  givenPhoneURIWithPostAndFormData_whenMockMVC_thenVerifyErrorResponse() {
 
    this.mockMvc.perform(MockMvcRequestBuilders.post("/addValidatePhone").
      accept(MediaType.TEXT_HTML).
      param("phoneInput", "123")).
      andExpect(model().attributeHasFieldErrorCode(
          "validatedPhone","phone","ContactNumberConstraint")).
      andExpect(view().name("phoneHome")).
      andExpect(status().isOk()).
      andDo(print());
}

测试要点:

  • 提交无效数据 "123"(长度不足)
  • 验证返回错误码 ContactNumberConstraint
  • 确保页面正常渲染

9. 类级别自定义验证

当需要验证多个字段的关联关系时(如密码确认),可创建类级别验证器。

9.1 创建注解

定义 @FieldsValueMatch 注解:

@Constraint(validatedBy = FieldsValueMatchValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldsValueMatch {

    String message() default "Fields values don't match!";

    String field();

    String fieldMatch();

    @Target({ ElementType.TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    @interface List {
        FieldsValueMatch[] value();
    }
}

关键设计:

  • 支持指定两个比较字段名
  • 内嵌 List 接口支持多重验证

9.2 实现验证器

public class FieldsValueMatchValidator 
  implements ConstraintValidator<FieldsValueMatch, Object> {

    private String field;
    private String fieldMatch;

    public void initialize(FieldsValueMatch constraintAnnotation) {
        this.field = constraintAnnotation.field();
        this.fieldMatch = constraintAnnotation.fieldMatch();
    }

    public boolean isValid(Object value, 
      ConstraintValidatorContext context) {

        Object fieldValue = new BeanWrapperImpl(value)
          .getPropertyValue(field);
        Object fieldMatchValue = new BeanWrapperImpl(value)
          .getPropertyValue(fieldMatch);
        
        if (fieldValue != null) {
            return fieldValue.equals(fieldMatchValue);
        } else {
            return fieldMatchValue == null;
        }
    }
}

实现技巧:

  • 使用 BeanWrapperImpl 动态获取字段值
  • 处理 null 值的边界情况

9.3 应用注解

在用户注册表单中验证邮箱和密码:

@FieldsValueMatch.List({ 
    @FieldsValueMatch(
      field = "password", 
      fieldMatch = "verifyPassword", 
      message = "Passwords do not match!"
    ), 
    @FieldsValueMatch(
      field = "email", 
      fieldMatch = "verifyEmail", 
      message = "Email addresses do not match!"
    )
})
public class NewUserForm {
    private String email;
    private String verifyEmail;
    private String password;
    private String verifyPassword;

    // 标准构造器、getter/setter
}

控制器实现:

@Controller
public class NewUserController {

    @GetMapping("/user")
    public String loadFormPage(Model model) {
        model.addAttribute("newUserForm", new NewUserForm());
        return "userHome";
    }

    @PostMapping("/user")
    public String submitForm(@Valid NewUserForm newUserForm, 
      BindingResult result, Model model) {
        if (result.hasErrors()) {
            return "userHome";
        }
        model.addAttribute("message", "Valid form");
        return "userHome";
    }
}

9.4 测试类验证

有效数据测试

@Test
public void givenMatchingEmailPassword_whenPostNewUserForm_thenOk() 
  throws Exception {
    this.mockMvc.perform(MockMvcRequestBuilders
      .post("/user")
      .accept(MediaType.TEXT_HTML).
      param("email", "user@example.com")
      .param("verifyEmail", "user@example.com")
      .param("password", "pass")
      .param("verifyPassword", "pass"))
      .andExpect(model().errorCount(0))
      .andExpect(status().isOk());
}

无效数据测试

@Test
public void givenNotMatchingEmailPassword_whenPostNewUserForm_thenOk() 
  throws Exception {
    this.mockMvc.perform(MockMvcRequestBuilders
      .post("/user")
      .accept(MediaType.TEXT_HTML)
      .param("email", "user@example.com")
      .param("verifyEmail", "wrong@example.com")
      .param("password", "pass")
      .param("verifyPassword", "passsss"))
      .andExpect(model().errorCount(2))
      .andExpect(status().isOk());
}

测试要点:

  • 有效数据:邮箱和密码均匹配
  • 无效数据:故意制造不匹配
  • 验证错误数量(预期2个错误)

10. 总结

本文系统介绍了 Spring MVC 中自定义验证的两种实现方式:

  1. 字段级验证:通过自定义注解验证单个字段
  2. 类级验证:通过类注解验证多字段关联关系

核心步骤总结:

  1. 创建注解定义验证规则
  2. 实现 ConstraintValidator 接口编写验证逻辑
  3. 在模型类中应用注解
  4. 控制器中使用 @Valid 触发验证
  5. 通过 BindingResult 处理验证结果

完整代码示例可在 GitHub 获取。


原始标题:Spring MVC Custom Validation | Baeldung

« 上一篇: Java周报,171