使用 Spring Boot 建置有狀態自訂 Bean 驗證
1.概述
Spring Boot 中透過Hibernate Validator參考實現提供了 Java Bean Validation的標準。它允許我們在請求物件類別的欄位上新增標準註解(例如@NotNull
,以便 Spring 驗證其輸入。
我們還可以擴展可用的驗證。此外,我們可能需要使用運行時資料來實現我們的邏輯。例如,一個值只有在資料庫或運行時配置中找到時才有效,這使得我們的驗證演算法具有狀態。
我們可能還需要跨欄位驗證,其中驗證考慮物件中的多個值來確定相關欄位是否有效。
在本教程中,我們將探討如何建立自訂驗證。
2. Bean 驗證基礎知識
驗證 Java Bean 需要使用 JSR-380 框架。該框架在jakarta.validation
套件中提供了通用的驗證註解以及Validator
介面。
2.1. 註釋
為了驗證字段,我們在其聲明中添加註釋:
@Pattern(regexp = "A-\\d{8}-\\d")
private String productId;
在所選的Validator
實例進行驗證時,註解及其元資料(在本例中為 pattern.regex)用於確定驗證規則。驗證器透過在呼叫validate()
時為每個註解找到合適的驗證實作來實現這一點。
在這個例子中,我們對一個欄位進行了驗證,但我們也可以建立類型層級的驗證,我們稍後會看到。
2.2. 驗證請求
在 Spring @ RestController
中,我們可以使用@Valid
註解我們的請求主體,以要求它自動驗證:
@PostMapping("/api/purchasing/")
public ResponseEntity<String> createPurchaseOrder(@Valid @RequestBody PurchaseOrderItem item) {
// ... execute the order
return ResponseEntity.accepted().build();
}
只有當請求有效時,我們的控制器主體才會被呼叫。
我們可以使用MockMvc
測試來測試這一點:
mockMvc.perform(post("/api/purchasing/")
.content("{}")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
在這裡,空的 JSON 無效並導致 HTTP 400,BAD REQUEST。
3.範例用例
接下來,讓我們建立一個 SaaS 產品,它可以透過接收PurchaseOrderItem
的 API 來處理採購訂單:
public class PurchaseOrderItem {
@NotNull
@Pattern(regexp = "A-\\d{8}-\\d")
private String productId;
private String sourceWarehouse;
private String destinationCountry;
private String tenantChannel;
private int numberOfIndividuals;
private int numberOfPacks;
private int itemsPerPack;
@org.hibernate.validator.constraints.UUID
private String clientUuid;
// getters and setters
}
我們已經為這個物件添加了一些內建驗證。首先,我們要求productId
非空白且符合特定模式。我們也要求clientUuid
是一個有效的UUID
。我們使用了jakarta
和hibernate
驗證。但我們的需求需要額外的規則,這需要自訂程式碼和驗證器。
首先,我們要確保productId
與自訂校驗位演算法相符。
接下來,訂單中不能包含包裹和單一物品,因此只能設定numberOfIndividuals
或numberOfPacks
。
最後,所選的倉庫必須能夠運送到目的地國家,並且必須在我們的伺服器上設定tenantChannel
。
我們需要結合演算法和數據驅動的自訂註解來實現這些驗證。我們的規則要求驗證涉及多個欄位。此外,我們將依賴 Spring 屬性和資料庫中的資料進行驗證,從而實現這些有狀態的驗證器。
4. 單一欄位自訂驗證器
我們可以透過提供註解和演算法實作來建立自訂驗證器。讓我們為產品 ID 建立一個校驗位驗證器。
4.1. 定義欄位驗證註解
驗證註解需要一些標準屬性:
@Constraint(validatedBy = ProductCheckDigitValidator.class)
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ProductCheckDigit {
String message() default "must have valid check digit";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
註解必須具有RUNTIME
的Retention
才能供驗證器使用,我們可以決定註解是針對欄位還是整個類型。在本例中,我們有一個字段級註解,由@Target
註解中包含FIELD
的元素類型數組指示。根據驗證的性質,我們甚至可以包含函數參數驗證。
@Constraint
註解聲明了哪個類別(或哪些類別)負責處理此驗證。這樣,驗證器就可以運行我們自訂的驗證程式碼。
最後,我們要注意,預設的錯誤訊息就在這裡的message
屬性中。
4.2. 建立自訂驗證器類
接下來,我們需要建立驗證器類別並重寫isValid()
方法:
public class ProductCheckDigitValidator implements ConstraintValidator<ProductCheckDigit, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// implementation here
}
}
我們的校驗位邏輯只需要傳回false
,驗證器就可以將我們的欄位標記為無效。
具體來說,此驗證器必須實作ConstraintValidator
介面。我們也聲明了此驗證器適用的類型。第一個類型聲明是我們的註解,第二個類型是要驗證的類型。在本例中,我們的驗證器適用於具有ProductCheckDigit
註解的Strings
。為了在多種類型的欄位上使用我們的驗證註解,我們需要為每種特定類型編寫一個自訂驗證器類,並在@Constraint
註解的validatedBy
值中建立一個驗證器陣列。
4.3. 準備一些測試案例
在實現校驗位邏輯之前,讓我們先設定單元測試和請求類別。
首先,我們將新的註解新增到實體類別:
public class PurchaseOrderItem {
@ProductCheckDigit
@NotNull
@Pattern(regexp = "A-\\d{8}-\\d")
private String productId;
// ...
}
然後我們建立一個可以存取驗證器的單元測試:
@SpringBootTest
class PurchaseOrderItemValidationUnitTest {
@Autowired
private Validator validator;
@Test
void givenValidProductId_thenProductIdIsValid() {
PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setProductId("A-12345678-6");
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(violations).isEmpty();
}
}
這裡我們有一個測試工廠方法來創建一個完全有效的PurchaseOrderItem
,這樣我們就可以專注於每個測試中各個欄位的效果。我們也會直接呼叫驗證器來查看發現了哪些違規行為。
我們應該注意, Spring 可以將Validator
物件提供給我們的任何元件,因此我們不僅限於使用 Spring 自動應用的驗證。
4.4. 實現校驗位
我們的產品識別碼包含兩個數字部分-一個八位數字和一個校驗位,校驗位是前八位數字總和的最後一位。讓我們提取這兩個部分並測試校驗位:
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return false;
}
String[] parts = value.split("-");
return parts.length == 3 && checkDigitMatches(parts[1], parts[2]);
}
private static boolean checkDigitMatches(String productCode, String checkDigit) {
int sumOfDigits = IntStream.range(0, productCode.length())
.map(character -> Character.getNumericValue(productCode.charAt(character)))
.sum();
int checkDigitProvided = Character.getNumericValue(checkDigit.charAt(0));
return checkDigitProvided == sumOfDigits % 10;
}
我們透過拆分產品 ID 然後將中間數字字串的各個數字相加來驗證校驗位。
4.5. 檢查失敗時
呼叫驗證器會傳回一組約束違規資訊。為了方便測試,我們將其轉換為欄位路徑和錯誤訊息的清單:
private static List<String> collectViolations(Set<ConstraintViolation<PurchaseOrderItem>> violations) {
return violations.stream()
.map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
.sorted()
.collect(Collectors.toList());
}
現在我們可以檢查校驗位不符時出現的錯誤:
PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setProductId("A-12345678-1");
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(collectViolations(violations))
.containsExactly("productId: must have valid check digit");
此外,如果我們將欄位設為null
,我們會收到多個錯誤,因為我們的自訂驗證器和NotNull
驗證器失敗:
PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setProductId(null);
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(collectViolations(violations))
.containsExactly(
"productId: must have valid check digit",
"productId: must not be null");
5. 多字段驗證器
現在我們已經建立了一個單字段驗證器,讓我們看看必須選擇單個或一包物品的規則。
5.1. 建立驗證註釋
對於多字段驗證,我們需要將驗證應用於父類型:
@Constraint(validatedBy = ChoosePacksOrIndividualsValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ChoosePacksOrIndividuals {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
此註釋具有TYPE
目標,因為它旨在與PurchaseOrderItem
類型一起使用。
我們需要在這裡驗證整個PurchaseOrderItem
,因為逐字段驗證只會關注特定字段,而不會考慮任何相關上下文。跨字段驗證是在類型層級實現的。
5.2. 建立驗證器
驗證器需要在兩個數量都設定或都不設定時建立不同的約束違規。它需要避免驗證框架在isValid()
傳回 false 時可能建立的預設錯誤。
我們首先創建一個類別,它將驗證PurchaseOrderItem
能力與ChoosePacksOrIndividual
註解綁定在一起:
public class ChoosePacksOrIndividualsValidator
implements ConstraintValidator<ChoosePacksOrIndividuals, PurchaseOrderItem> {}
在isValid()
方法中,我們首先停用預設錯誤訊息:
@Override
public boolean isValid(PurchaseOrderItem value, ConstraintValidatorContext context) {
context.disableDefaultConstraintViolation();
...
這允許我們自訂錯誤訊息,而不是使用註釋中的預設message
。
接下來,我們可以實作確定欄位是否有效的邏輯,並為被證明無效的欄位新增約束違反:
boolean isValid = true;
if ((value.getNumberOfPacks() == 0) == (value.getNumberOfIndividuals() == 0)) {
// either both are zero, or both are turned on
isValid = false;
context.disableDefaultConstraintViolation();
if (value.getNumberOfPacks() == 0) {
context.buildConstraintViolationWithTemplate("must choose a quantity when no packs")
.addPropertyNode("numberOfIndividuals")
.addConstraintViolation();
context.buildConstraintViolationWithTemplate("must choose a quantity when no individuals")
.addPropertyNode("numberOfPacks")
.addConstraintViolation();
} else {
context.buildConstraintViolationWithTemplate("cannot be combined with number of packs")
.addPropertyNode("numberOfIndividuals")
.addConstraintViolation();
context.buildConstraintViolationWithTemplate("cannot be combined with number of individuals")
.addPropertyNode("numberOfPacks")
.addConstraintViolation();
}
}
if (value.getNumberOfPacks() > 0 && value.getItemsPerPack() == 0) {
isValid = false;
context.buildConstraintViolationWithTemplate("cannot be 0 when using packs")
.addPropertyNode("itemsPerPack")
.addConstraintViolation();
}
return isValid;
該演算法檢查兩個字段是否都為零,或者兩個字段是否都為非零,這表明我們要么同時指定了它們,要么都未指定。然後,它會為這兩個欄位添加一個自訂約束違規,以解釋究竟是哪個錯誤。
5.3. 測試跨字段驗證
首先,我們需要PurchaseOrderItem
新增新的註解:
@ChoosePacksOrIndividuals
public class PurchaseOrderItem {
}
然後我們可以測試一個無效的組合:
PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setNumberOfIndividuals(10);
item.setNumberOfPacks(20);
item.setItemsPerPack(0);
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(collectViolations(violations))
.containsExactly(
"itemsPerPack: cannot be 0 when using packs",
"numberOfIndividuals: cannot be combined with number of packs",
"numberOfPacks: cannot be combined with number of individuals");
6. 使用 Spring Properties 進行狀態驗證
到目前為止,我們已經將靜態程式碼綁定到註解,並使用了編譯時已知資訊的演算法。我們可能需要使用配置屬性來確定哪些是有效的。
6.1. 有效通道配置
假設我們有一些運行時配置屬性:
com.baeldung.tenant.channels[0]=retail
com.baeldung.tenant.channels[1]=wholesale
這將是我們的application.properties,
我們將其載入到ConfigurationProperties
類別:
@ConfigurationProperties("com.baeldung.tenant")
public class TenantChannels {
private String[] channels;
// getter/setter
}
現在我們希望能夠在驗證器中使用這個通道數組來檢查請求中選擇的通道是否在該租用戶中可用。
6.2. 建立注入 Bean 的驗證器
由於 Spring 提供了驗證器,我們也可以將其他 Spring Bean 注入到我們的驗證器中。因此,我們可以將配置屬性自動組裝到自訂驗證器中:
public class AvailableChannelValidator implements ConstraintValidator<AvailableChannel, String> {
@Autowired
private TenantChannels tenantChannels;
}
在驗證時使用 properties 物件中的陣列來檢查每個通道有點笨拙。讓我們重寫initialize()
方法,將該值轉換為集合:
private Set<String> channels;
@Override
public void initialize(AvailableChannel constraintAnnotation) {
channels = Arrays.stream(tenantChannels.getChannels()).collect(toSet());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return channels.contains(value);
}
現在,我們有一個由伺服器目前 Spring 設定檔的屬性檔案所驅動的驗證註解。我們只需要在PurchaseOrderItem
中註解該欄位即可:
@AvailableChannel
private String tenantChannel;
7.基於數據的驗證
一旦我們可以使用 Spring bean 驗證我們的字段,我們就可以使用相同的技術來利用我們的資料庫或其他 Web 服務:
@Repository
public class WarehouseRouteRepository {
public boolean isWarehouseRouteAvailable(String sourceWarehouse, String destinationCountry) {
// read from database
}
}
此儲存庫可以注入到驗證器中:
public class AvailableWarehouseRouteValidator implements
ConstraintValidator<AvailableWarehouseRoute, PurchaseOrderItem> {
@Autowired
private WarehouseRouteRepository warehouseRouteRepository;
@Override
public boolean isValid(PurchaseOrderItem value, ConstraintValidatorContext context) {
return warehouseRouteRepository.isWarehouseRouteAvailable(value.getSourceWarehouse(),
value.getDestinationCountry());
}
}
最後,由於這驗證了採購訂單的多個字段,我們將在類別層級添加相關註釋:
@ChoosePacksOrIndividuals
@AvailableWarehouseRoute
public class PurchaseOrderItem {
...
}
8. 結論
在本文中,我們研究瞭如何向欄位和類型新增驗證。我們編寫了連結到自訂註解的自訂驗證邏輯,並了解如何使用預設錯誤訊息對單一欄位進行操作,或如何使用自訂錯誤訊息對多個欄位進行操作。
最後,我們利用其他 Spring bean 編寫了有狀態驗證器,以根據執行時間配置或資料進行驗證。
與往常一樣,本文的完整範例程式碼可在 GitHub 上找到。