設置依賴
spring boot的bean validation 由validation start支持,maven依賴如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
這里可以找到最新的版本。但是如果引用了spring-boot-starter-web,就不要需要引用validation-starter。
基礎
本質上來說,validation的工作原理是通過特定的注解修飾對類的字段定義約束。
然后,把類傳遞給驗證器對象,校驗字段約束是否滿足。
我們將會看到更多的細節通過下面這些例子。
驗證 Spring MVC Controller的輸入
假設已經實現一個Spring REST 服務,并且想要驗證客戶端傳入的參數,我們可以驗證任意HTTP請求的3個部分:
- request body
- path variable (e.g. /user/{id})
- query parameters
具體看下每個部分的詳細
驗證request body
在post和get請求中,通用的做法是在request body里面傳入一個json串。spring自動把json串映射為一個java對象?,F在,我們想要檢查這個java對象是否滿足需求。
輸入的Java對象:
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;
// ...
}
對象擁有一個取值范圍在1-10之間的int類型字段,除此之外,還有一個包含ip地址的字符串類型字段。
從request body中接受參數對象并且驗證:
@RestController
class ValidateRequestBodyController {
@PostMapping("/validateBody")
ResponseEntity<String> validateBody(@Valid @RequestBody Input input) {
return ResponseEntity.ok("valid");
}
}
簡單的加個@Valid注解修飾輸入的參數,同時用@RequestBody標記應該從request body中解析參數。通過這個注解,我們告訴spring在做其他任何操作之前先把參數對象傳遞給Validator。
注意:如果待校驗對象的某個字段也是需要校驗的復雜類型(組合語法),這個字段也需要用@Valid修飾:
@Valid
private ContactInfo contactInfo;
如果校驗失敗,會觸發MethodArgumentNotValidException異常,Spring默認會把這個一場轉為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());
}
}
校驗path變量和query參數
驗證path變量和query參數有一些細微差別。因為路徑變量和請求參數是基本類型例如int 或者他們的包裝類型Integer或者String。
直接在Controller方法參數上加注解約束:
@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");
}
}
注意,同時必須在類級別機上@Validated注解告訴Spring需要校驗方法參數上的約束。
在這種場景里@Validated注解只能修飾類級別,但是,它也允許被用在方法上(允許用在方法級別為了解決validation group。)
校驗失敗會觸發ConstraintViolationException 異常,但是spring沒有提供默認處理這個一場的handler,所以會報500(Internal Server Error)。
如果我們想要返回400 替代500,可以在controller中增加以自定義的異常處理。
@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);
}
}
校驗Spring service層的參數
前面都是校驗controller級別,同時也支持校驗任何層級的參數。只需要組合使用@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);
});
}
}
實現自定義的校驗器
如果提供的注解約束沒有滿足使用場景,也可以自己實現一個。
在上面Input類中,我們使用正則表達式來檢驗字符串字段是否為有效的IP地址,但是這個正則表達式不夠完整,他允許每一段超過255.
實現一個IP校驗器替代正則表達式。
首先:新建一個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文件中一個參數key。
- groups允許在不同校驗器情況下有不同的校驗約束
- payload 允許一些額外參數傳遞給校驗器
- @Constraint注解指向實現了ConstraintValidator 接口的Validator。
Validator的實現像這樣:
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;
// ...
}
通過編程的方式校驗
有一些場景,我想通過程序來調用校驗器而不是依賴Spring的支持。
在這種情況下我們可以手動創建一個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提供了與配置的驗證其實例,我們可以直接注入到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);
}
}
}
測試:
@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);
});
}
}
使用不同的驗證組驗證不同用例下的同一個對象
經常會有兩個相同的Service使用同一個領域對象。
比如在實現CRUD操作時,創建操作和更新操作很可能使用用一個對象作為參數,但是在兩種情況下可能會觸發不同的驗證:
- 創建情況
- 更新情況
- 兩者同時存在
Validation Groups允許使用不同的規則來驗證。
剛剛已經看到一個約束注解必須有一個groups字段。他可以被傳遞到任何一個定義了驗證組的Validator里面。
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類再次被用到了類級別,這是因為在告訴Spring需要啟動方法上的約束注解(@Min),同時為了激活驗證組group,必須把它作用在方法上。
返回結構化的響應
當校驗失敗時需要返回有意義的錯誤信息給客戶端。為了能讓客戶端展示錯誤信息,我們需要返回一個數據結構,其中包含每個錯誤驗證信息。
首先,定義一個返回體:
public class ValidationErrorResponse {
private List<Violation> violations = new ArrayList<>();
// ...
}
public class Violation {
private final String fieldName;
private final String message;
// ...
}
然后,定義一個全局的切面處理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;
}
}
通過捕獲異常并且轉換為結構的錯誤信息返回。
總結
我們已經完成了使用Spring Boot構建應用過程中可能需要所有的校驗特性。
當然,復雜的業務規則,建議大家使用Spring或者Guava里面Assert類來判斷,比如這種復雜的業務規則判斷:
- column A value is > 10.
- column B value > 10
- column A +column B > 30.
所以,Bean Validation只使用與參數的校驗,不要讓它參與業務邏輯。
文章翻譯自這里。建議看完原文后,看看下面的討論,你有疑惑的,美國的工程師也有疑惑。所以文章下面會有很多看文章時不太明白的解答。
自己整理的記憶思路(約定默認的注解稱之為約束注解:@Null,@Min,@NotNull...): - Controller類中,校驗Dto: @Valid + Dto 約束注解
- Controller類中,校驗路徑變量或者參數變量:Class @Validated + Method 約束注解
- Service層,校驗Dto:Class @Validated + Method @Valid + Dto 約束注解
- Service層,校驗普通變量: TODO
//TODO記憶思路一定不能依賴,最好的記憶辦法是去研究源碼,理解透徹再回來填這個坑。