原文地址 https://reflectoring.io/bean-validation-with-spring-boot/
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
)或其包裝類型(如 Integer
或 String
)。
我們沒有像上面那樣去標記類字段,而是直接向 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
。
7 在Spring Boot中使用自定義校驗器
如果官方提供可用的校驗注解不能滿足我們的需要,我們自己可以定義一個自定義校驗器。
在上面的 Inpu
t 類中,我們使用正則表達式來驗證字符串是否是有效的 IP 地址。 然而,正則表達式並不完善:它允許值大於 255 的數字(即“111.111.111.333”將被視為有效)。
讓我們通自定義校驗器來解決這個問題,該驗證器使用 Java 代碼而不是使用正則表達式來實現此校驗邏輯(是的,我知道我們可以使用更復雜的正則表達式來達到相同的效果,但我們喜歡在 Java 中實現驗證)。
首先,我們創建自定義校驗注解 @IpAddress
:
@Target({ FIELD })
@Retention(RUNTIME)
@Constraint(validatedBy = IpAddressValidator.class)
@Documented
public @interface IpAddress {
String message() default "{IpAddress.invalid}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
自定義校驗注解需要包含以下要素:
- 參數
message
, 指定 ValidationMessages.properties 文件中的屬性鍵,用於在校驗失敗時解析提示消息, - 參數
groups
, 允許定義在何種情況下觸發此校驗(稍后我們將討分組校驗) - 參數
payload
, 允許定義要通過此校驗傳遞的Payload(因為這是一個很少使用的功能,我們不會在本教程中介紹它) - 一個
@Constraint
注解, 指定實現 ConstraintValidator 接口的校驗邏輯類。
校驗器的實現如下所示:
class IpAddressValidator implements ConstraintValidator<IpAddress, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
Pattern pattern =
Pattern.compile("^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})$");
Matcher matcher = pattern.matcher(value);
try {
if (!matcher.matches()) {
return false;
} else {
for (int i = 1; i <= 4; i++) {
int octet = Integer.valueOf(matcher.group(i));
if (octet > 255) {
return false;
}
}
return true;
}
} catch (Exception e) {
return false;
}
}
}
我們現在可以像使用任何其他校驗注解一樣使用 @IpAddress
注解:
class InputWithCustomValidator {
@IpAddress
private String ipAddress;
// ...
}
8 以編程方式校驗
在某些情況下,我們希望以編程方式調用校驗而不是依賴 Spring 的內置 Bean校驗。 在這種情況下,我們可以直接使用 Bean Validation API。
我們手動創建一個 Validator 並調用它來觸發校驗:
class ProgrammaticallyValidatingService {
void validateInput(Input input) {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<Input>> violations = validator.validate(input);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
這里不需要任何的 Spring 支持。
但是,Spring Boot 為我們提供了一個預配置的 Validator
實例。 我們可以不用手動去創建它,我們可以將這個實例注入到我們的Service中,並使用這個實例:
@Service
class ProgrammaticallyValidatingService {
private Validator validator;
ProgrammaticallyValidatingService(Validator validator) {
this.validator = validator;
}
void validateInputWithInjectedValidator(Input input) {
Set<ConstraintViolation<Input>> violations = validator.validate(input);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
當這個Service被 Spring 實例化時,它會自動將一個 Validator 實例注入到構造函數中。
以面的單元測試表明上述兩種方法都按預期正常運行:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ProgrammaticallyValidatingServiceTest {
@Autowired
private ProgrammaticallyValidatingService service;
@Test
void whenInputIsInvalid_thenThrowsException(){
Input input = invalidInput();
assertThrows(ConstraintViolationException.class, () -> {
service.validateInput(input);
});
}
@Test
void givenInjectedValidator_whenInputIsInvalid_thenThrowsException(){
Input input = invalidInput();
assertThrows(ConstraintViolationException.class, () -> {
service.validateInputWithInjectedValidator(input);
});
}
}
9 使用分組校驗針對不同情況以不同方式校驗對象
通常會在不同的情況之間共用同一個類模型。
讓我們以典型的 CRUD 操作為例:“創建”和“更新”很可能都采用相同的類模型作為入參。 但是,可能存在不同場景下觸發不同的校驗邏輯:
-
僅在“創建”時執行的校驗邏輯
-
僅在“更新”時執行的校驗邏輯
-
或者兩者都要執行的校驗邏輯
允許我們實現這樣的校驗規則的 Bean 校驗功能稱為“分組校驗”。
我們已經看到所有校驗注解都必須有一個group
字段。 這可使用任何類類型,每個類都定義了應該觸發的某個校驗組。
對於我們的 CRUD 示例,我們簡單地定義了兩個標記型接口 OnCreate
和 OnUpdate
:
interface OnCreate {}
interface OnUpdate {}
然后我們可以將這些標記型接口與任何校驗注解一起使用,如下所示:
class InputWithGroups {
@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
private Long id;
// ...
}
這將確保 ID 在創建”時一定為空,並且在“更新”時一定不為空。
Spring 支持 @Validated 注解中的校驗組:
@Service
@Validated
class ValidatingServiceWithGroups {
@Validated(OnCreate.class)
void validateForCreate(@Valid InputWithGroups input){
// do something
}
@Validated(OnUpdate.class)
void validateForUpdate(@Valid InputWithGroups input){
// do something
}
}
請注意,@Validated
注解必須再次放在類上。而要定義哪個校驗組當前是有效可用的,@Validated
還必須放在方法上並指定校驗組。
為了確保上述功能按預期運行,我們可以寫一個單元測試:
@ExtendWith(SpringExtension.class)
@SpringBootTest
class ValidatingServiceWithGroupsTest {
@Autowired
private ValidatingServiceWithGroups service;
@Test
void whenInputIsInvalidForCreate_thenThrowsException() {
InputWithGroups input = validInput();
input.setId(42L);
assertThrows(ConstraintViolationException.class, () -> {
service.validateForCreate(input);
});
}
@Test
void whenInputIsInvalidForUpdate_thenThrowsException() {
InputWithGroups input = validInput();
input.setId(null);
assertThrows(ConstraintViolationException.class, () -> {
service.validateForUpdate(input);
});
}
}
請注意校驗組
使用校驗組很容易成為一種反模式,因為它混淆了關注點。 對於校驗組,需要校驗的實體必須知道它所使用的所有場景的校驗規則。在我關於 Bean Validation反模式的文章中,有關此主題的更多信息。
10 處理校驗錯誤
當校驗失敗時,我們通常希望向客戶端返回一條有意義的錯誤消息。 為了使客戶端能夠顯示有用的錯誤消息,我們應該返回一個統一的數據結構,其中包含每個校驗失敗的錯誤消息。
首先,我們需要定義該數據結構。 我們將其命名為 ValidationErrorResponse
,它包含一個 Violation
對象列表:
public class ValidationErrorResponse {
private List<Violation> violations = new ArrayList<>();
// ...
}
public class Violation {
private final String fieldName;
private final String message;
// ...
}
然后,我們創建一個全局參數校驗異常處理器 ControllerAdvice 來冒泡處理所有到Controller層的 ConstraintViolationExceptions
異常。 為了同樣捕獲請求實體(request body)的校驗錯誤,我們還將處理 MethodArgumentNotValidExceptions
異常:
@ControllerAdvice
class ErrorHandlingControllerAdvice {
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
ValidationErrorResponse onConstraintValidationException(
ConstraintViolationException e) {
ValidationErrorResponse error = new ValidationErrorResponse();
for (ConstraintViolation violation : e.getConstraintViolations()) {
error.getViolations().add(
new Violation(violation.getPropertyPath().toString(), violation.getMessage()));
}
return error;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
ValidationErrorResponse onMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
ValidationErrorResponse error = new ValidationErrorResponse();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
error.getViolations().add(
new Violation(fieldError.getField(), fieldError.getDefaultMessage()));
}
return error;
}
}
我們在這里所做的只是從異常中讀取有關校驗失敗信息並將它們轉換到我們的 ValidationErrorResponse
數據結構中。
請注意@ControllerAdvice
注解,它使得上述類型異常的異常處理機制對所有Controller全局可用。
11 總結
在本教程中,我們已經介紹了使用 Spring Boot 構建程序時可能需要的所有主要的校驗功能。
如果您想深入了解示例代碼,請查看 github 倉庫。