1. 程式人生 > 其它 >在 Spring Boot 使用Bean Validation 完全指南

在 Spring Boot 使用Bean Validation 完全指南

1 前言

Bean Validation是 Java 生態圏中實現Bean校驗規範的事實上的標準。 它與 Spring 和 Spring Boot 能很好地整合在一起。

但是,也存在一些問題。 本教程詳細介紹了所有主要的校驗用例和每個用例的程式碼示例。

程式碼示例
他的文章附有 GitHub 上的工作程式碼示例。

2 使用 Spring Boot Validation Starter

Spring Boot 的 Bean Validation 支援起步依賴starter,我們可以將其包含到我們的專案中(在Gradle專案構建工具中):

implementation('org.springframework.boot:spring-boot-starter-validation')

這沒有必要新增版本號,因為 Spring Dependency Management Gradle 外掛會為我們加上父依賴中統一管理的版本號。 如果您不使用該外掛,可以在此處找到最新版本。

但是,如果我們的專案中已經包含了 web starter,那麼validation starter 會自動包含在其中,而不需要再額外引入validation starter

implementation('org.springframework.boot:spring-boot-starter-web')

請注意,validation starter只是向相容版本的Hibernate Validator

新增依賴(這是最被廣泛使用的Bean Validation 規範實現庫)

3 Bean Validation 基礎

大體上,Bean Validation 的工作原理是通過使用某些註解標記類的欄位來定義對類欄位的約束。

1) 常用的Validation註解

一些最常見的驗證註解如下:

  • @NotNull: 標記欄位不能為 null
  • @NotEmpty: 標記集合欄位不為空(至少要有一個元素)
  • @NotBlank: 標記欄位串欄位不能是空字串(即它必須至少有一個字元)
  • @Min / @Max: 標記數字型別欄位必須大於/小於指定的值
  • @Pattern: 標記字串欄位必須匹配指定的正則表示式
  • @Email
    :
    標記字串欄位必須是有效的電子郵件地址

請看如下一個類欄位約束的示例

class Customer {

  @Email
  private String email;

  @NotBlank
  private String name;
  
  // ...
}

2) 校驗器Validator

為了驗證一個物件是否有效,我們可以將它傳遞給一個 Validator 物件來檢查是否滿足約束:

Set<ConstraintViolation<Input>> violations = validator.validate(customer);
if (!violations.isEmpty()) {
  throw new ConstraintViolationException(violations);
}

更多以程式設計方式使用Validator請開啟連結https://reflectoring.io/bean-validation-with-spring-boot/#validating-programmatically。

3) @Validated@Valid

在許多情況下,Spring 會自動為我們提供校驗能力,我們幾乎不需要自己建立驗證器物件。並且,我們可以讓 Spring 知道我們想要校驗某個物件。 這主要是通過使用@Validated@Valid 註解來實現的。

@Validated 註解是一個類級別的註解,我們可以使用它來告訴 Spring 某方法的引數需要校驗。 接下我們將在Controller層中的路徑變數和簡單請求引數使用它,並瞭解其更多的用法

我們可以在方法引數和欄位上加上@Valid 註解來告訴 Spring 我們想要對一個方法引數或欄位被驗證。 我們將在Controlle層中的請求實體中使用它,並瞭解其更多的用法。

4 在Spring MVC Controller中校驗入參

假設我們已經實現了一個 Spring REST Controller並且想要驗證客戶端傳入的入參。 對於任何傳入的 HTTP 請求,我們可以校驗三種引數:

  • 請求實體(request body)
  • 路徑變數 (如 /foos/{id} 中的id引數)
  • 查詢引數 (query parameters)

接下讓我們更詳細地瞭解如何校驗這三種引數

1) 校驗請求實體(request body)

在 POST 和 PUT 請求中,通常在請求實體中傳遞 JSON 資料。 Spring 自動將傳入的 JSON 對映到 Java 物件引數上。 現在,我們要校驗傳入的 Java 物件是否滿足我們預先定義的約束條件。

這是我們將要傳入的Http請求實體類:

class Input {

  @Min(1)
  @Max(10)
  private int numberBetweenOneAndTen;

  @Pattern(regexp = "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$")
  private String ipAddress;
  
  // ...
}

我們有一個 int型別 欄位,它的值必須介於 1 和 10 之間,如@Min@Max 註解所定義的那樣。 我們還有一個 String 型別欄位,它必須是一個 IP 地址,正如@Pattern 註解中的正則表示式所定義的那樣(正則表示式實際上仍然允許八位位元組大於 255 的無效 IP 地址,但在我們建立自定義驗證器時,我們將在本教程的後面修復這個BUG)。

為了校驗傳入 HTTP 請求的請求實體,我們在 REST 控制器中使用 @Valid 註解對請求實體進行標記:

@RestController
class ValidateRequestBodyController {

  @PostMapping("/validateBody")
  ResponseEntity<String> validateBody(@Valid @RequestBody Input input) {
    return ResponseEntity.ok("valid");
  }

}

我們只是在 入參中添加了 @Valid 註解,該引數也用 @RequestBody註解標記過,使其從HTTP請求體(requet body)中對映各個欄位。 這樣,我們就告訴了 Spring 框架在執行任何其他操作之前先將此入參物件傳遞給 Validator校驗器。

在複合型別上使用 @Valid
如果 入參類還包含待校驗的另一種複雜型別的欄位,則該欄位也需要使用 @Valid註解進行標記。

如果校證失敗,則會觸發 MethodArgumentNotValidException異常。 預設情況下,Spring 會將此異常轉換為 HTTP 狀態 碼400(Bad Request)。

我們可以通過整合測試框架來驗證結果:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateRequestBodyController.class)
class ValidateRequestBodyControllerTest {

  @Autowired
  private MockMvc mvc;

  @Autowired
  private ObjectMapper objectMapper;

  @Test
  void whenInputIsInvalid_thenReturnsStatus400() throws Exception {
    Input input = invalidInput();
    String body = objectMapper.writeValueAsString(input);

    mvc.perform(post("/validateBody")
            .contentType("application/json")
            .content(body))
            .andExpect(status().isBadRequest());
  }
}

可以在 @WebMvcTest 註解文章中找到有關測試 Spring MVC 控制器的更多詳細資訊

2) 校驗路徑變數和查詢引數

校驗路徑變數、查詢引數方式與查驗請求實體略有不同。

在這種情況下,我們不會驗證複雜的 Java 物件,因為路徑變數和請求引數是原始型別(如 int)或其包裝型別(如 IntegerString)。

我們沒有像上面那樣去標記類欄位,而是直接向 Spring 控制器中的方法引數新增校驗註解(在本例中使用 @Min):

@RestController
@Validated
class ValidateParametersController {

  @GetMapping("/validatePathVariable/{id}")
  ResponseEntity<String> validatePathVariable(
      @PathVariable("id") @Min(5) int id) {
    return ResponseEntity.ok("valid");
  }
  
  @GetMapping("/validateRequestParameter")
  ResponseEntity<String> validateRequestParameter(
      @RequestParam("param") @Min(5) int param) { 
    return ResponseEntity.ok("valid");
  }
}

請注意,我們必須在類級別將 Spring 的 @Validated 註解新增到Conroller類上,以此通知 Spring 要注意方法引數上的校驗註解。

在這種情況下,@Validated 註解僅在類級別上進行校驗處理,即使它允許用於方法級別(稍後討論分組校驗時,我們將瞭解為什麼允許在方法級別上使用它)。

與請求體驗證相反,失敗的驗證將觸發 ConstraintViolationException 異常而不是 MethodArgumentNotValidException異常。 Spring 不會為此異常註冊預設異常處理器,因此在預設情況下它會導致 HTTP 狀態碼為 500(Internal Server Error)的響應。

如果我們想返回一個 HTTP 狀態碼為 400(這樣處理是有道理的,因為客戶端輸入了一個無效的引數,並使它成為了一個bad request),我們可以向我們的控制器新增一個自定義異常處理器:

@RestController
@Validated
class ValidateParametersController {

  // request mapping method omitted
  
  @ExceptionHandler(ConstraintViolationException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  ResponseEntity<String> handleConstraintViolationException(ConstraintViolationException e) {
    return new ResponseEntity<>("not valid due to validation error: " + e.getMessage(), HttpStatus.BAD_REQUEST);
  }

}

在本教程的後面,我們將研究如何返回統一的錯誤響應結構,其中包含所有驗證失敗的詳細資訊,供客戶端排查錯誤原因。

我們可以通過整合測試框架來驗證結果:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = ValidateParametersController.class)
class ValidateParametersControllerTest {

  @Autowired
  private MockMvc mvc;

  @Test
  void whenPathVariableIsInvalid_thenReturnsStatus400() throws Exception {
    mvc.perform(get("/validatePathVariable/3"))
            .andExpect(status().isBadRequest());
  }

  @Test
  void whenRequestParameterIsInvalid_thenReturnsStatus400() throws Exception {
    mvc.perform(get("/validateRequestParameter")
            .param("param", "3"))
            .andExpect(status().isBadRequest());
  }

}

5 校驗Spring Service 方法入參

除了在Controller層校驗入參之外,我們還可以校驗任何 Spring Bean 的入參。 為此,我們可結合使用 @Validated@Valid 註解:

@Service
@Validated
class ValidatingService{

    void validateInput(@Valid Input input){
      // do something
    }

}

同樣,@Validated 註解僅在放在類級別上,因此在此用例中不要將其放在方法上。

接下來,看這個校驗示例:

@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidatingServiceTest {

  @Autowired
  private ValidatingService service;

  @Test
  void whenInputIsInvalid_thenThrowsException(){
    Input input = invalidInput();

    assertThrows(ConstraintViolationException.class, () -> {
      service.validateInput(input);
    });
  }

}

6 校驗JPA實體類

通常我們校驗的最後一道防線是持久層。 預設情況下,Spring JPA在底層使用 Hibernate,它支援開箱即用的 Bean 校驗。

在持久層中校驗的合適嗎?
我們通常不希望在持久層中進行校驗,因為這意味著上層的業務程式碼已經引入了可能導致無法預料的錯誤或潛在無效引數。 在我關於反 Bean Validation 模式的文章中詳細介紹了這個主題。

假設要將 Input 類的物件儲存到資料庫中。 首先,我們新增必要的 JPA 註解 @Entity 並新增一個 ID 欄位:

@Entity
public class Input {

  @Id
  @GeneratedValue
  private Long id;

  @Min(1)
  @Max(10)
  private int numberBetweenOneAndTen;

  @Pattern(regexp = "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$")
  private String ipAddress;
  
  // ...
  
}

然後,我們建立一個 Spring Data Repository,它為我們提供了持久化和查詢 Input 物件的方法:

public interface ValidatingRepository extends CrudRepository<Input, Long> {
    
}

預設情況下,每當我們使用違反校驗註解的 Input 物件時,這裡都會產生一個 ConstraintViolationException 異常,正如這個測試所演示的那樣:

@ExtendWith(SpringExtension.class)
@DataJpaTest
class ValidatingRepositoryTest {

  @Autowired
  private ValidatingRepository repository;

  @Autowired
  private EntityManager entityManager;

  @Test
  void whenInputIsInvalid_thenThrowsException() {
    Input input = invalidInput();

    assertThrows(ConstraintViolationException.class, () -> {
      repository.save(input);
      entityManager.flush();
    });
  }

}

您可以在 @DataJpaTest 註解文章中找到有關測試 Spring Data Repository的更多詳細資訊

請注意,Bean 校驗僅在 EntityManager 重新整理後由 Hibernate 觸發。 在某些情況下,Hibernate 會自動重新整理 EntityManager,但在我們使用整合測試框架時,我們必須手動執行此操作。

如果出於某些原因我們想在我們的 Spring Data Repository中禁用 Bean Validation,我們可以將 Spring Boot 屬性 spring.jpa.properties.javax.persistence.validation.mode 設定為 none