一、前言
在 Spring 6.1 中,有一個非常值得注意的重要改進——編程式驗證器實現。Spring 長期以來一直通過注解支持聲明式驗證,而 Spring 6.1 則通過提供專用的編程式驗證方法引入了這一強大的增強功能。
編程式驗證允許開發人員對驗證過程進行細粒度控制,實現動態和有條件的驗證場景,超越了聲明式方法的能力。在本教程中,我們將深入探討實現編程式驗證并將其與 Spring MVC 控制器無縫集成的細節。
二、聲明式驗證與編程式驗證的區別
對于數據驗證,Spring 框架有兩種主要方法:聲明式驗證和編程式驗證。
"聲明式驗證(Declarative validation)"通過域對象上的元數據或注解指定驗證規則。Spring 利用 JavaBean Validation (JSR 380) 注釋(如 @NotNull、@Size 和 @Pattern)在類定義中直接聲明驗證約束。
Spring 會在數據綁定過程中自動觸發驗證(例如,在 Spring MVC 表單提交過程中)。開發人員無需在代碼中明確調用驗證邏輯。
public class User {@NotNullprivate String username;@Size(min = 6, max = 20)private String password;// ...
}
另一方面,“編程式驗證(Programmatic validation)” 在代碼中編寫自定義驗證邏輯,通常使用 Spring 提供的 Validator 接口。這種方法可以實現更動態、更復雜的驗證場景。
開發人員負責顯式調用驗證邏輯,通常在服務層或控制器中進行。
public class UserValidator implements Validator {@Overridepublic boolean supports(Class<?> clazz) {return User.class.isAssignableFrom(clazz);}@Overridepublic void validate(Object target, Errors errors) {User user = (User) target;// 自定義驗證邏輯, 可以讀取多個字段進行混合校驗,編程的方式靈活性大大增加}
}
三、何時使用程序化驗證
在聲明式驗證和編程式驗證之間做出選擇取決于用例的具體要求。
聲明式驗證通常適用于比較簡單的場景,驗證規則可以通過注釋清晰地表達出來。聲明式驗證很方便,也符合慣例,即重于配置的原則。
程序化驗證提供了更大的靈活性和控制力,適用于超出聲明式表達范圍的復雜驗證場景。當驗證邏輯取決于動態條件或涉及多個字段之間的交互時,程序化驗證尤其有用。
我們可以將這兩種方法結合起來使用。我們可以利用聲明式驗證的簡潔性來處理常見的情況,而在面對更復雜的要求時,則采用編程式驗證。
四、程序化驗證器 API 簡介
Spring 中的編程式驗證器 API 的核心是允許創建自定義驗證器類,并定義僅靠注解可能無法輕松捕獲的驗證規則。
以下是創建自定義驗證器對象的一般步驟。
- 創建一個實現
org.springframework.validation.Validator
接口的類。 - 重載
supports()
方法,以指定該驗證器支持哪些類。 - 實現
validate()
或validateObject()
方法,以定義實際的驗證邏輯。 - 使用
ValidationUtils.rejectIfEmpty()
或ValidationUtils.rejectIfEmptyOrWhitespace()
方法,以給定的錯誤代碼拒絕給定字段。 - 我們可以直接調用
Errors.rejectValue()
方法來添加其他類型的錯誤。
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;@Component
public class UserValidator implements Validator {@Overridepublic boolean supports(Class<?> clazz) {return User.class.isAssignableFrom(clazz);}@Overridepublic void validate(Object target, Errors errors) {User user = (User) target;// 例如: 校驗 username 不能為空ValidationUtils.rejectIfEmptyOrWhitespace(errors, "username", "field.required", "Username must not be empty.");// 添加更多的自定義驗證邏輯}
}
要使用自定義驗證器,我們可以將其注入 @Controller 或 @Service 等 Spring 組件,或者直接將其實例化。然后,我們調用驗證方法,傳遞要驗證的對象和 Errors 對象以收集驗證錯誤。
public class UserService {private Validator userValidator;public UserService(Validator userValidator) {this.userValidator = userValidator;}public void someServiceMethod(User user) {Errors errors = new BeanPropertyBindingResult(user, "user");userValidator.validate(user, errors);if (errors.hasErrors()) {// 處理數據校驗錯誤}}
}
五、初始化安裝
5.1. Maven 配置
要使用編程式驗證器,我們需要 Spring Framework 6.1 或 Spring Boot 3.2,因為這些是最低支持的版本。
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.2.0</version><relativePath/>
</parent>
5.2. 領域對象
本教程的領域對象是雇員(Employee) 和部門(Department)對象。我們不會創建復雜的結構,因此可以專注于核心概念。
Employee.java
package demo.customValidator.model;@Data
@Builder
public class Employee {Long id;String firstName;String lastName;String email;boolean active;Department department;
}
Department.java
package demo.customValidator.model;@Data
@Builder
public class Department {Long id;String name;boolean active;
}
六、 實現程序化驗證器
以下 EmployeeValidator
類實現了 org.springframework.validation.Validator
接口并實現了必要的方法。它將根據需要在 Employee
字段中添加驗證規則。
package demo.customValidator.validator;import demo.customValidator.model.Employee;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;public class EmployeeValidator implements Validator {@Overridepublic boolean supports(Class<?> clazz) {return Employee.class.isAssignableFrom(clazz);}@Overridepublic void validate(Object target, Errors errors) {ValidationUtils.rejectIfEmpty(errors, "id", ValidationErrorCodes.ERROR_CODE_EMPTY);ValidationUtils.rejectIfEmptyOrWhitespace(errors, "firstName", "First name cannot be empty");ValidationUtils.rejectIfEmptyOrWhitespace(errors, "lastName", "Last name cannot be empty");ValidationUtils.rejectIfEmptyOrWhitespace(errors, "email", "Email cannot be empty");Employee employee = (Employee) target;if (employee.getFirstName() != null && employee.getFirstName().length() < 3) {errors.rejectValue("firstName", "First name must be greater than 2 characters");}if (employee.getLastName() != null && employee.getLastName().length() < 3) {errors.rejectValue("lastName", "Last name must be greater than 3 characters");}}
}
同樣,我們為 Department 類定義了驗證器。如有必要,您可以添加更復雜的驗證規則。
package demo.customValidator.model.validation;import demo.customValidator.model.Department;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;public class DepartmentValidator implements Validator {@Overridepublic boolean supports(Class<?> clazz) {return Department.class.equals(clazz);}@Overridepublic void validate(Object target, Errors errors) {ValidationUtils.rejectIfEmpty(errors, "id", ValidationErrorCodes.ERROR_CODE_EMPTY);ValidationUtils.rejectIfEmptyOrWhitespace(errors, "name", "Department name cannot be empty");Department department = (Department) target;if(department.getName() != null && department.getName().length() < 3) {errors.rejectValue("name", "Department name must be greater than 3 characters");}}
}
現在我們可以驗證 Employee
和 Department
對象的實例,如下所示:
Employee employee = Employee.builder().id(2L).build();
//Aurowire if needed
EmployeeValidator employeeValidator = new EmployeeValidator();Errors errors = new BeanPropertyBindingResult(employee, "employee");
employeeValidator.validate(employee, errors);if (!errors.hasErrors()) {System.out.println("Object is valid");
} else {for (FieldError error : errors.getFieldErrors()) {System.out.println(error.getCode());}
}
程序輸出:
First name cannot be empty
Last name cannot be empty
Email cannot be empty
Department
對象也可以進行類似的驗證。
七、鏈式多個驗證器
在上述自定義驗證器中,如果我們驗證了雇員對象,那么 API 將不會驗證部門對象。理想情況下,在驗證特定對象時,應針對所有關聯對象執行驗證。
程序化驗證 API 允許調用其他驗證器,匯總所有錯誤,最后返回結果。使用 ValidationUtils.invokeValidator()
方法可以實現這一功能,如下所示:
public class EmployeeValidator implements Validator {DepartmentValidator departmentValidator;public EmployeeValidator(DepartmentValidator departmentValidator) {if (departmentValidator == null) {throw new IllegalArgumentException("The supplied Validator is null.");}if (!departmentValidator.supports(Department.class)) {throw new IllegalArgumentException("The supplied Validator must support the Department instances.");}this.departmentValidator = departmentValidator;}@Overridepublic void validate(Object target, Errors errors) {//...try {errors.pushNestedPath("department");ValidationUtils.invokeValidator(this.departmentValidator, employee.getDepartment(), errors);} finally {errors.popNestedPath();}}
}
pushNestedPath()
方法允許為子對象設置臨時嵌套路徑。在上例中,當對部門對象進行驗證時,路徑被設置為employee.department
。- 在調用
pushNestedPath()
方法之前,popNestedPath()
方法會將路徑重置為原始路徑。在上例中,它再次將路徑重置為employee
。
現在,當我們驗證 Employee
對象時,也可以看到 Department
對象的驗證錯誤。
Department department = Department.builder().id(1L).build();
Employee employee = Employee.builder().id(2L).department(department).build();EmployeeValidator employeeValidator = new EmployeeValidator(new DepartmentValidator());Errors errors = new BeanPropertyBindingResult(employee, "employee");
employeeValidator.validate(employee, errors);if (!errors.hasErrors()) {System.out.println("Object is valid");
} else {for (FieldError error : errors.getFieldErrors()) {System.out.println(error.getField());System.out.println(error.getCode());}
}
程序輸出:
firstName
First name cannot be emptylastName
Last name cannot be emptyemail
Email cannot be emptydepartment.name
Department name cannot be empty
注意打印出來的字段名稱是 department.name
。由于使用了 pushNestedPath()
方法,所以添加了 department.
前綴。
八、使用帶消息解析功能的 MessageSource
使用硬編碼的消息并不是一個好主意,因此我們可以將消息添加到資源文件(如 messages.properties
)中,然后使用 MessageSource.getMessage()
將消息解析為所需的本地語言,從而進一步改進此代碼。
例如,讓我們在資源文件中添加以下消息:
error.field.empty={0} cannot be empty
error.field.size={0} must be between 3 and 20
為了統一訪問,請在常量文件中添加以下代碼。請注意,這些錯誤代碼是在自定義驗證器實現中添加的。
public class ValidationErrorCodes {public static String ERROR_CODE_EMPTY = "error.field.empty";public static String ERROR_CODE_SIZE = "error.field.size";
}
現在,當我們解析信息時,就會得到屬性文件的信息。
MessageSource messageSource;//...if (!errors.hasErrors()) {System.out.println("Object is valid");
} else {for (FieldError error : errors.getFieldErrors()) {System.out.println(error.getCode());System.out.println(messageSource.getMessage(error.getCode(), new Object[]{error.getField()}, Locale.ENGLISH));}
}
程序輸出:
error.field.empty
firstName cannot be emptyerror.field.empty
lastName cannot be emptyerror.field.empty
email cannot be emptyerror.field.empty
department.name cannot be empty
九、將編程式驗證器與 Spring MVC/WebFlux 控制器集成
將編程式驗證器與 Spring MVC 控制器集成,包括將編程式驗證器注入控制器、在 Spring 上下文中配置它們,以及利用 @Valid 和 BindingResult 等注解簡化驗證。
令人欣慰的是,這種集成還能解決 Ajax 表單提交和控制器單元測試問題。
下面是一個使用我們在前面章節中創建的 EmployeeValidator 對象的 Spring MVC 控制器的簡化示例。
import demo.app.customValidator.model.Employee;
import demo.app.customValidator.model.validation.DepartmentValidator;
import demo.app.customValidator.model.validation.EmployeeValidator;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;@Controller
@RequestMapping("/employees")
public class EmployeeController {@InitBinderprotected void initBinder(WebDataBinder binder) {// 注入 編程式驗證器binder.setValidator(new EmployeeValidator(new DepartmentValidator()));}@GetMapping("/registration")public String showRegistrationForm(Model model) {model.addAttribute("employee", Employee.builder().build());return "employee-registration-form";}@PostMapping("/processRegistration")public String processRegistration(@Validated @ModelAttribute("employee") Employee employee,BindingResult bindingResult) {if (bindingResult.hasErrors()) {return "employee-registration-form";}// 處理成功通過數據校驗后表單的邏輯// 通常涉及數據庫操作、身份驗證等。return "employee-registration-confirmation"; // 重定向至成功頁面}
}
之后,當表單提交時,可以使用 ${#fields.hasErrors('*')}
表達式在視圖中顯示驗證錯誤。
在下面的示例中,我們在兩個地方顯示驗證錯誤,即在表單頂部的列表中顯示所有錯誤,然后顯示單個字段的錯誤。請根據自己的要求定制代碼。
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Employee Registration</title>
</head>
<body><h2>Employee Registration Form</h2><!-- Employee Registration Form -->
<form action="./processRegistration" method="post" th:object="${employee}"><!-- Display validation errors, if any --><div th:if="${#fields.hasErrors('*')}"><div style="color: red;"><p th:each="error : ${#fields.errors('*')}" th:text="${error}"></p></div></div><!-- Employee ID (assuming it's a hidden field for registration) --><input type="hidden" th:field="*{id}" /><!-- Employee First Name --><label for="firstName">First Name:</label><input type="text" id="firstName" th:field="*{firstName}" required /><span th:if="${#fields.hasErrors('firstName')}" th:text="#{error.field.size}"></span><br/><!-- Employee Last Name --><label for="lastName">Last Name:</label><input type="text" id="lastName" th:field="*{lastName}" required /><span th:if="${#fields.hasErrors('lastName')}" th:text="#{error.field.size}"></span><br/><!-- Employee Email --><label for="email">Email:</label><input type="email" id="email" th:field="*{email}" required /><span th:if="${#fields.hasErrors('email')}" th:text="#{error.field.size}"></span><br/><!-- Employee Active Status --><label for="active">Active:</label><input type="checkbox" id="active" th:field="*{active}" /><br/><!-- Department Information --><h3>Department:</h3><label for="department.name">Department Name:</label><input type="text" id="department.name" th:field="*{department.name}" required /><span th:if="${#fields.hasErrors('department.name')}" th:text="#{error.field.size}"></span><br/><!-- Submit Button --><button type="submit">Register</button></form></body>
</html>
當我們運行應用程序并提交無效表單時,會出現如圖所示的錯誤:
十、單元測試編程式驗證器
我們可以將自定義驗證器作為模擬依賴關系或單個測試對象進行測試。下面的 JUnit 測試用例將測試 EmployeeValidator
。
我們編寫了兩個非常簡單的基本測試供快速參考,您也可以根據自己的需求編寫更多測試。
import demo.app.customValidator.model.Department;
import demo.app.customValidator.model.Employee;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.Errors;public class TestEmployeeValidator {static EmployeeValidator employeeValidator;@BeforeAllstatic void setup() {employeeValidator = new EmployeeValidator(new DepartmentValidator());}@Testvoid validate_ValidInput_NoErrors() {// Set up a valid userEmployee employee = Employee.builder().id(1L).firstName("Lokesh").lastName("Gupta").email("admin@howtodoinjava.com").department(Department.builder().id(2L).name("Finance").build()).build();Errors errors = new BeanPropertyBindingResult(employee, "employee");employeeValidator.validate(employee, errors);Assertions.assertFalse(errors.hasErrors());}@Testvoid validate_InvalidInput_HasErrors() {// Set up a valid userEmployee employee = Employee.builder().id(1L).firstName("A").lastName("B").email("C").department(Department.builder().id(2L).name("HR").build()).build();Errors errors = new BeanPropertyBindingResult(employee, "employee");employeeValidator.validate(employee, errors);Assertions.assertTrue(errors.hasErrors());Assertions.assertEquals(3, errors.getErrorCount());}
}
最佳做法是確保測試涵蓋邊緣情況和邊界條件。這包括輸入處于允許的最小值或最大值的情況。
十一、結論
在本教程中,我們通過示例探討了 Spring 6.1 Programmatic Validator API 及其實施指南。程序化驗證允許開發人員對驗證過程進行細粒度控制。
我們討論了如何創建和使用自定義驗證器類,并將其與 Spring MVC 控制器集成。我們學習了如何使用消息解析,隨后還討論了如何測試這些驗證器以實現更強大的編碼實踐。
代碼地址:programmatic-validator