Bean Validation Spring參數校驗

設置依賴

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記憶思路一定不能依賴,最好的記憶辦法是去研究源碼,理解透徹再回來填這個坑。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379