知識庫 / Spring / Spring Boot RSS 訂閱

使用 Spring Boot 構建狀態化自定義 Bean 驗證

Spring Boot
HongKong
6
10:39 AM · Dec 06 ,2025

1. 概述

Java 標準的 Bean Validation 通過 Spring Boot 中的 Hibernate Validator 參考實現一應俱全。它允許我們向請求對象類的字段添加標準註解,如 @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,請求無效。

3. 示例使用場景

接下來,讓我們構建一個可以通過API處理採購訂單的SaaS產品,該API接收PurchaseOrderItem

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 不為 null 且匹配特定模式。我們還期望 clientUuid 是有效的 UUID。我們使用了 jakartahibernate 驗證。但我們的要求需要額外的規則,這需要自定義代碼和驗證器。

首先,我們希望確保 productId 匹配自定義校驗和算法。

接下來,不允許訂單包含套裝和單個商品,因此只能設置 numberOfIndividualsnumberOfPacks

最後,所選倉庫必須能夠運送至目的地國家,並且 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,我們可以在決定標註是否針對字段或整個類型時進行決策。在這種情況下,我們有一個字段級別的標註,通過FIELD 元素組成的類型數組在@Target 標註中體現。根據驗證的性質,我們甚至可以包含函數參數驗證。

@Constraint 標註聲明瞭處理此驗證的類(或類)。這允許驗證器運行我們的自定義驗證代碼。

最後,默認錯誤消息位於message 屬性中。

4.2. 創建自定義驗證器類

接下來,我們需要創建自定義驗證器類並覆蓋 isValid() 方法:

public class ProductCheckDigitValidator implements ConstraintValidator<ProductCheckDigit, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
       // implementation here
    }
}

我們的校驗位邏輯只需要返回 false,才能使驗證器標記我們的字段為無效。

具體來説,此驗證器必須實現 ConstraintValidator 接口。我們還聲明此驗證器適用的類型。第一個類型聲明是我們的註解,第二個類型是需要驗證的類型。在本例中,我們的驗證器應用於 Strings 類型,這些類型帶有 ProductCheckDigit 註解。為了在多個字段類型上使用我們的驗證註解,我們將為每個特定類型編寫自定義驗證器,並在 @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,這樣我們就可以專注於每個測試中各個字段的效果。我們還直接調用驗證器來查看發現的違規情況。

需要注意的是,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");

此外,如果將該字段設置為 ,則會產生多個錯誤,因為我們的自定義驗證器和 驗證器均失敗:

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 {};
}

此標註的目標是<em>TYPE</em>,因為它 intended for use with the<em>PurchaseOrderItem</em> 類型。

我們需要對整個<em>PurchaseOrderItem</em> 進行驗證,因為僅逐字段驗證只會檢查特定字段,而不會考慮周圍的上下文。<strong>跨字段驗證在類型級別實現</strong>。

5.2. 創建驗證器

驗證器需要根據數量是否設置的不同情況,創建不同的約束違規。它需要避免驗證框架在<em>isValid()</em>返回false時可能產生的默認錯誤。

我們首先創建該類,該類能夠使用<em>ChoosePacksOrIndividual</em>註解驗證一個<em>PurchaseOrderItem</em>。

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 屬性進行狀態化驗證

我們之前已經將靜態代碼綁定到註解,並使用編譯時已知的信息進行算法驗證。 也許我們需要使用配置屬性來確定什麼是有效的。

6.1. 有效通道的配置

假設我們有一些運行時配置屬性:

com.baeldung.tenant.channels[0]=retail
com.baeldung.tenant.channels[1]=wholesale

這段數據將存在於我們的 應用程序屬性文件, 並加載到 配置屬性類 中:

@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 以實現基於運行時配置或數據的驗證。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.