使用SpringBoot進行優雅的資料驗證

程式設計師自由之路發表於2020-11-24

JSR-303 規範

在程式進行資料處理之前,對資料進行準確性校驗是我們必須要考慮的事情。儘早發現資料錯誤,不僅可以防止錯誤向核心業務邏輯蔓延,而且這種錯誤非常明顯,容易發現解決。

JSR303 規範(Bean Validation 規範)為 JavaBean 驗證定義了相應的後設資料模型和 API。在應用程式中,通過使用 Bean Validation 或是你自己定義的 constraint,例如 @NotNull, @Max, @ZipCode , 就可以確保資料模型(JavaBean)的正確性。constraint 可以附加到欄位,getter 方法,類或者介面上面。對於一些特定的需求,使用者可以很容易的開發定製化的 constraint。Bean Validation 是一個執行時的資料驗證框架,在驗證之後驗證的錯誤資訊會被馬上返回。

關於 JSR 303 – Bean Validation 規範,可以參考官網

對於 JSR 303 規範,Hibernate Validator 對其進行了參考實現 . Hibernate Validator 提供了 JSR 303 規範中所有內建 constraint 的實現,除此之外還有一些附加的 constraint。如果想了解更多有關 Hibernate Validator 的資訊,請檢視官網

validation-api 內建的 constraint 清單

Constraint 詳細資訊
@AssertFalse 被註釋的元素必須為 false
@AssertTrue 同@AssertFalse
@DecimalMax 被註釋的元素必須是一個數字,其值必須小於等於指定的最大值
@DecimalMin 同DecimalMax
@Digits 帶批註的元素必須是一個在可接受範圍內的數字
@Email 顧名思義
@Future 將來的日期
@FutureOrPresent 現在或將來
@Max 被註釋的元素必須是一個數字,其值必須小於等於指定的最大值
@Min 被註釋的元素必須是一個數字,其值必須大於等於指定的最小值
@Negative 帶註釋的元素必須是一個嚴格的負數(0為無效值)
@NegativeOrZero 帶註釋的元素必須是一個嚴格的負數(包含0)
@NotBlank 同StringUtils.isNotBlank
@NotEmpty 同StringUtils.isNotEmpty
@NotNull 不能是Null
@Null 元素是Null
@Past 被註釋的元素必須是一個過去的日期
@PastOrPresent 過去和現在
@Pattern 被註釋的元素必須符合指定的正規表示式
@Positive 被註釋的元素必須嚴格的正數(0為無效值)
@PositiveOrZero 被註釋的元素必須嚴格的正數(包含0)
@Szie 帶註釋的元素大小必須介於指定邊界(包括)之間

Hibernate Validator 附加的 constraint

Constraint 詳細資訊
@Email 被註釋的元素必須是電子郵箱地址
@Length 被註釋的字串的大小必須在指定的範圍內
@NotEmpty 被註釋的字串的必須非空
@Range 被註釋的元素必須在合適的範圍內
CreditCardNumber 被註釋的元素必須符合信用卡格式

Hibernate Validator 不同版本附加的 Constraint 可能不太一樣,具體還需要你自己檢視你使用版本。Hibernate 提供的 Constraintorg.hibernate.validator.constraints這個包下面。

一個 constraint 通常由 annotation 和相應的 constraint validator 組成,它們是一對多的關係。也就是說可以有多個 constraint validator 對應一個 annotation。在執行時,Bean Validation 框架本身會根據被註釋元素的型別來選擇合適的 constraint validator 對資料進行驗證。

有些時候,在使用者的應用中需要一些更復雜的 constraint。Bean Validation 提供擴充套件 constraint 的機制。可以通過兩種方法去實現,一種是組合現有的 constraint 來生成一個更復雜的 constraint,另外一種是開發一個全新的 constraint。

使用Spring Boot進行資料校驗

Spring Validation 對 hibernate validation 進行了二次封裝,可以讓我們更加方便地使用資料校驗功能。這邊我們通過 Spring Boot 來引用校驗功能。

如果你用的 Spring Boot 版本小於 2.3.x,spring-boot-starter-web 會自動引入 hibernate-validator 的依賴。如果 Spring Boot 版本大於 2.3.x,則需要手動引入依賴:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.1.Final</version>
</dependency>

直接引數校驗

有時候介面的引數比較少,只有一個活著兩個引數,這時候就沒必要定義一個DTO來接收引數,可以直接接收引數。

@Validated
@RestController
@RequestMapping("/user")
public class UserController {

    private static Logger logger = LoggerFactory.getLogger(UserController.class);

    @GetMapping("/getUser")
    @ResponseBody
    // 注意:如果想在引數中使用 @NotNull 這種註解校驗,就必須在類上新增 @Validated;
    public UserDTO getUser(@NotNull(message = "userId不能為空") Integer userId){
        logger.info("userId:[{}]",userId);
        UserDTO res = new UserDTO();
        res.setUserId(userId);
        res.setName("程式設計師自由之路");
        res.setAge(8);
        return res;
    }
}

下面是統一異常處理類

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(value = ConstraintViolationException.class)
    public Response handle1(ConstraintViolationException ex){
            StringBuilder msg = new StringBuilder();
        Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
        for (ConstraintViolation<?> constraintViolation : constraintViolations) {
            PathImpl pathImpl = (PathImpl) constraintViolation.getPropertyPath();
            String paramName = pathImpl.getLeafNode().getName();
            String message = constraintViolation.getMessage();
            msg.append("[").append(message).append("]");
        }
        logger.error(msg.toString(),ex);
        // 注意:Response類必須有get和set方法,不然會報錯
        return new Response(RCode.PARAM_INVALID.getCode(),msg.toString());
    }

    @ExceptionHandler(value = Exception.class)
    public Response handle1(Exception ex){
        logger.error(ex.getMessage(),ex);
        return new Response(RCode.ERROR.getCode(),RCode.ERROR.getMsg());
    }
}

呼叫結果

# 這裡沒有傳userId
GET http://127.0.0.1:9999/user/getUser

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 14 Nov 2020 07:35:44 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "rtnCode": "1000",
  "rtnMsg": "[userId不能為空]"
}

實體類DTO校驗

定義一個DTO

import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotEmpty;

public class UserDTO {

    private Integer userId;

    @NotEmpty(message = "姓名不能為空")
    private String name;
    @Range(min = 18,max = 50,message = "年齡必須在18和50之間")
    private Integer age;
    //省略get和set方法
}

接收引數時使用@Validated進行校驗

@PostMapping("/saveUser")
@ResponseBody
//注意:如果方法中的引數是物件型別,則必須要在引數物件前面新增 @Validated
public Response<UserDTO> getUser(@Validated @RequestBody UserDTO userDTO){
    userDTO.setUserId(100);
    Response response = Response.success();
    response.setData(userDTO);
    return response;
}

統一異常處理

@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Response handle2(MethodArgumentNotValidException ex){
    BindingResult bindingResult = ex.getBindingResult();
    if(bindingResult!=null){
        if(bindingResult.hasErrors()){
            FieldError fieldError = bindingResult.getFieldError();
            String field = fieldError.getField();
            String defaultMessage = fieldError.getDefaultMessage();
            logger.error(ex.getMessage(),ex);
            return new Response(RCode.PARAM_INVALID.getCode(),field+":"+defaultMessage);
        }else {
            logger.error(ex.getMessage(),ex);
            return new Response(RCode.ERROR.getCode(),RCode.ERROR.getMsg());
        }
    }else {
        logger.error(ex.getMessage(),ex);
        return new Response(RCode.ERROR.getCode(),RCode.ERROR.getMsg());
    }
}

呼叫結果

### 建立使用者
POST http://127.0.0.1:9999/user/saveUser
Content-Type: application/json

{
  "name1": "程式設計師自由之路",
  "age": "18"
}

# 下面是返回結果
{
  "rtnCode": "1000",
  "rtnMsg": "姓名不能為空"
}

對Service層方法引數校驗

個人不太喜歡這種校驗方式,一半情況下呼叫service層方法的引數都需要在controller層校驗好,不需要再校驗一次。這邊列舉這個功能,只是想說 Spring 也支援這個。

@Validated
@Service
public class ValidatorService {

	private static final Logger logger = LoggerFactory.getLogger(ValidatorService.class);

	public String show(@NotNull(message = "不能為空") @Min(value = 18, message = "最小18") String age) {
		logger.info("age = {}", age);
		return age;
	}

}

分組校驗

有時候對於不同的介面,需要對DTO進行不同的校驗規則。還是以上面的UserDTO為列,另外一個介面可能不需要將age限制在18~50之間,只需要大於18就可以了。

這樣上面的校驗規則就不適用了。分組校驗就是來解決這個問題的,同一個DTO,不同的分組採用不同的校驗策略。

public class UserDTO {

    public interface Default {
    }

    public interface Group1 {
    }

    private Integer userId;
    //注意:@Validated 註解中加上groups屬性後,DTO中沒有加group屬性的校驗規則將失效
    @NotEmpty(message = "姓名不能為空",groups = Default.class)
    private String name;

    //注意:加了groups屬性之後,必須在@Validated 註解中也加上groups屬性後,校驗規則才能生效,不然下面的校驗限制就失效了
    @Range(min = 18, max = 50, message = "年齡必須在18和50之間",groups = Default.class)
    @Range(min = 17, message = "年齡必須大於17", groups = Group1.class)
    private Integer age;
}

使用方式

@PostMapping("/saveUserGroup")
@ResponseBody
//注意:如果方法中的引數是物件型別,則必須要在引數物件前面新增 @Validated
//進行分組校驗,年齡滿足大於17
public Response<UserDTO> saveUserGroup(@Validated(value = {UserDTO.Group1.class}) @RequestBody UserDTO userDTO){
    userDTO.setUserId(100);
    Response response = Response.success();
    response.setData(userDTO);
    return response;
}

使用Group1分組進行校驗,因為DTO中,Group1分組對name屬性沒有校驗,所以這個校驗將不會生效。

分組校驗的好處是可以對同一個DTO設定不同的校驗規則,缺點就是對於每一個新的校驗分組,都需要重新設定下這個分組下面每個屬性的校驗規則。

分組校驗還有一個按順序校驗功能。

考慮一種場景:一個bean有1個屬性(假如說是attrA),這個屬性上新增了3個約束(假如說是@NotNull、@NotEmpty、@NotBlank)。預設情況下,validation-api對這3個約束的校驗順序是隨機的。也就是說,可能先校驗@NotNull,再校驗@NotEmpty,最後校驗@NotBlank,也有可能先校驗@NotBlank,再校驗@NotEmpty,最後校驗@NotNull。

那麼,如果我們的需求是先校驗@NotNull,再校驗@NotBlank,最後校驗@NotEmpty。@GroupSequence註解可以實現這個功能。

public class GroupSequenceDemoForm {

    @NotBlank(message = "至少包含一個非空字元", groups = {First.class})
    @Size(min = 11, max = 11, message = "長度必須是11", groups = {Second.class})
    private String demoAttr;

    public interface First {

    }

    public interface Second {

    }

    @GroupSequence(value = {First.class, Second.class})
    public interface GroupOrderedOne {
        // 先計算屬於 First 組的約束,再計算屬於 Second 組的約束
    }


    @GroupSequence(value = {Second.class, First.class})
    public interface GroupOrderedTwo {
        // 先計算屬於 Second 組的約束,再計算屬於 First 組的約束
    }

}

使用方式

// 先計算屬於 First 組的約束,再計算屬於 Second 組的約束
@Validated(value = {GroupOrderedOne.class}) @RequestBody GroupSequenceDemoForm form

巢狀校驗

前面的示例中,DTO類裡面的欄位都是基本資料型別和String等型別。

但是實際場景中,有可能某個欄位也是一個物件,如果我們需要對這個物件裡面的資料也進行校驗,可以使用巢狀校驗。

假如UserDTO中還用一個Job物件,比如下面的結構。需要注意的是,在job類的校驗上面一定要加上@Valid註解。

public class UserDTO1 {

    private Integer userId;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @Valid
    @NotNull
    private Job job;

    public Integer getUserId() {
        return userId;
    }

    public void setUserId(Integer userId) {
        this.userId = userId;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public Job getJob() {
        return job;
    }

    public void setJob(Job job) {
        this.job = job;
    }

    /**
     * 這邊必須設定成靜態內部類
     */
    static class Job {
        @NotEmpty
        private String jobType;
        @DecimalMax(value = "1000.99")
        private Double salary;

        public String getJobType() {
            return jobType;
        }

        public void setJobType(String jobType) {
            this.jobType = jobType;
        }

        public Double getSalary() {
            return salary;
        }

        public void setSalary(Double salary) {
            this.salary = salary;
        }
    }

}

使用方式

@PostMapping("/saveUserWithJob")
@ResponseBody
public Response<UserDTO1> saveUserWithJob(@Validated @RequestBody UserDTO1 userDTO){
    userDTO.setUserId(100);
    Response response = Response.success();
    response.setData(userDTO);
    return response;
}

測試結果

POST http://127.0.0.1:9999/user/saveUserWithJob
Content-Type: application/json

{
  "name": "程式設計師自由之路",
  "age": "16",
  "job": {
    "jobType": "1",
    "salary": "9999.99"
  }
}

{
  "rtnCode": "1000",
  "rtnMsg": "job.salary:必須小於或等於1000.99"
}

巢狀校驗可以結合分組校驗一起使用。還有就是巢狀集合校驗會對集合裡面的每一項都進行校驗,例如List欄位會對這個list裡面的每一個Job物件都進行校驗。這個點
在下面的@Valid和@Validated的區別章節有詳細講到。

集合校驗

如果請求體直接傳遞了json陣列給後臺,並希望對陣列中的每一項都進行引數校驗。此時,如果我們直接使用java.util.Collection下的list或者set來接收資料,引數校驗並不會生效!我們可以使用自定義list集合來接收引數:

包裝List型別,並宣告@Valid註解

public class ValidationList<T> implements List<T> {

    // @Delegate是lombok註解
    // 本來實現List介面需要實現一系列方法,使用這個註解可以委託給ArrayList實現
    // @Delegate
    @Valid
    public List list = new ArrayList<>();


    @Override
    public int size() {
        return list.size();
    }

    @Override
    public boolean isEmpty() {
        return list.isEmpty();
    }

    @Override
    public boolean contains(Object o) {
        return list.contains(o);
    }
    //.... 下面省略一系列List介面方法,其實都是呼叫了ArrayList的方法
}

呼叫方法

@PostMapping("/batchSaveUser")
@ResponseBody
public Response batchSaveUser(@Validated(value = UserDTO.Default.class) @RequestBody ValidationList<UserDTO> userDTOs){
   return Response.success();
}

呼叫結果

Caused by: org.springframework.beans.NotReadablePropertyException: Invalid property 'list[1]' of bean class [com.csx.demo.spring.boot.dto.ValidationList]: Bean property 'list[1]' is not readable or has an invalid getter method: Does the return type of the getter match the parameter type of the setter?
	at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:622) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
	at org.springframework.beans.AbstractNestablePropertyAccessor.getNestedPropertyAccessor(AbstractNestablePropertyAccessor.java:839) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
	at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyAccessorForPropertyPath(AbstractNestablePropertyAccessor.java:816) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
	at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:610) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]

會丟擲NotReadablePropertyException異常,需要對這個異常做統一處理。這邊程式碼就不貼了。

自定義校驗器

在Spring中自定義校驗器非常簡單,分兩步走。

自定義約束註解

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EncryptIdValidator.class})
public @interface EncryptId {

    // 預設錯誤訊息
    String message() default "加密id格式錯誤";

    // 分組
    Class[] groups() default {};

    // 負載
    Class[] payload() default {};
}

實現ConstraintValidator介面編寫約束校驗器

public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {

    private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 不為null才進行校驗
        if (value != null) {
            Matcher matcher = PATTERN.matcher(value);
            return matcher.find();
        }
        return true;
    }
}

程式設計式校驗

上面的示例都是基於註解來實現自動校驗的,在某些情況下,我們可能希望以程式設計方式呼叫驗證。這個時候可以注入
javax.validation.Validator物件,然後再呼叫其api。

@Autowired
private javax.validation.Validator globalValidator;

// 程式設計式校驗
@PostMapping("/saveWithCodingValidate")
public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {
    Set<constraintviolation> validate = globalValidator.validate(userDTO, UserDTO.Save.class);
    // 如果校驗通過,validate為空;否則,validate包含未校驗通過項
    if (validate.isEmpty()) {
        // 校驗通過,才會執行業務邏輯處理

    } else {
        for (ConstraintViolation userDTOConstraintViolation : validate) {
            // 校驗失敗,做其它邏輯
            System.out.println(userDTOConstraintViolation);
        }
    }
    return Result.ok();
}

快速失敗(Fail Fast)配置

Spring Validation預設會校驗完所有欄位,然後才丟擲異常。可以通過一些簡單的配置,開啟Fali Fast模式,一旦校驗失敗就立即返回。

@Bean
public Validator validator() {
    ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
            .configure()
            // 快速失敗模式
            .failFast(true)
            .buildValidatorFactory();
    return validatorFactory.getValidator();
}

校驗資訊的國際化

Spring 的校驗功能可以返回很友好的校驗資訊提示,而且這個資訊支援國際化。

這塊功能暫時暫時不常用,具體可以參考這篇文章

@Validated和@Valid的區別聯絡

首先,@Validated和@Valid都能實現基本的驗證功能,也就是如果你是想驗證一個引數是否為空,長度是否滿足要求這些簡單功能,使用哪個註解都可以。

但是這兩個註解在分組、註解作用的地方、巢狀驗證等功能上兩個有所不同。下面列下這兩個註解主要的不同點。

  • @Valid註解是JSR303規範的註解,@Validated註解是Spring框架自帶的註解;
  • @Valid不具有分組校驗功能,@Validate具有分組校驗功能;
  • @Valid可以用在方法、建構函式、方法引數和成員屬性(欄位)上,@Validated可以用在型別、方法和方法引數上。但是不能用在成員屬性(欄位)上,兩者是否能用於成員屬性(欄位)上直接影響能否提供巢狀驗證的功能;
  • @Valid加在成員屬性上可以對成員屬性進行巢狀驗證,而@Validate不能加在成員屬性上,所以不具備這個功能。

這邊說明下,什麼叫巢狀驗證。

我們現在有個實體叫做Item:

public class Item {

    @NotNull(message = "id不能為空")
    @Min(value = 1, message = "id必須為正整數")
    private Long id;

    @NotNull(message = "props不能為空")
    @Size(min = 1, message = "至少要有一個屬性")
    private List<Prop> props;
}

Item帶有很多屬性,屬性裡面有:pid、vid、pidName和vidName,如下所示:

public class Prop {

    @NotNull(message = "pid不能為空")
    @Min(value = 1, message = "pid必須為正整數")
    private Long pid;

    @NotNull(message = "vid不能為空")
    @Min(value = 1, message = "vid必須為正整數")
    private Long vid;

    @NotBlank(message = "pidName不能為空")
    private String pidName;

    @NotBlank(message = "vidName不能為空")
    private String vidName;
}

屬性這個實體也有自己的驗證機制,比如pid和vid不能為空,pidName和vidName不能為空等。
現在我們有個ItemController接受一個Item的入參,想要對Item進行驗證,如下所示:

@RestController
public class ItemController {

    @RequestMapping("/item/add")
    public void addItem(@Validated Item item, BindingResult bindingResult) {
        doSomething();
    }
}

在上圖中,如果Item實體的props屬性不額外加註釋,只有@NotNull和@Size,無論入參採用@Validated還是@Valid驗證,Spring Validation框架只會對Item的id和props做非空和數量驗證,不會對props欄位裡的Prop實體進行欄位驗證,也就是@Validated和@Valid加在方法引數前,都不會自動對引數進行巢狀驗證。也就是說如果傳的List中有Prop的pid為空或者是負數,入參驗證不會檢測出來。

為了能夠進行巢狀驗證,必須手動在Item實體的props欄位上明確指出這個欄位裡面的實體也要進行驗證。由於@Validated不能用在成員屬性(欄位)上,但是@Valid能加在成員屬性(欄位)上,而且@Valid類註解上也說明了它支援巢狀驗證功能,那麼我們能夠推斷出:@Valid加在方法引數時並不能夠自動進行巢狀驗證,而是用在需要巢狀驗證類的相應欄位上,來配合方法引數上@Validated或@Valid來進行巢狀驗證。

我們修改Item類如下所示:

public class Item {

    @NotNull(message = "id不能為空")
    @Min(value = 1, message = "id必須為正整數")
    private Long id;

    @Valid // 巢狀驗證必須用@Valid
    @NotNull(message = "props不能為空")
    @Size(min = 1, message = "props至少要有一個自定義屬性")
    private List<Prop> props;
}

然後我們在ItemController的addItem函式上再使用@Validated或者@Valid,就能對Item的入參進行巢狀驗證。此時Item裡面的props如果含有Prop的相應欄位為空的情況,Spring Validation框架就會檢測出來,bindingResult就會記錄相應的錯誤。

Spring Validation原理簡析

現在我們來簡單分析下Spring校驗功能的原理。

方法級別的引數校驗實現原理

所謂的方法級別的校驗就是指將@NotNull和@NotEmpty這些約束直接加在方法的引數上的。

比如

@GetMapping("/getUser")
@ResponseBody
public R getUser(@NotNull(message = "userId不能為空") Integer userId){
   //
}

或者

@Validated
@Service
public class ValidatorService {

	private static final Logger logger = LoggerFactory.getLogger(ValidatorService.class);

	public String show(@NotNull(message = "不能為空") @Min(value = 18, message = "最小18") String age) {
		logger.info("age = {}", age);
		return age;
	}

}

都屬於方法級別的校驗。這種方式可用於任何Spring Bean的方法上,比如Controller/Service等。

其底層實現原理就是AOP,具體來說是通過MethodValidationPostProcessor動態註冊AOP切面,然後使用MethodValidationInterceptor對切點方法織入增強。

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessorimplements InitializingBean {
    @Override
    public void afterPropertiesSet() {
        //為所有`@Validated`標註的Bean建立切面
        Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
        //建立Advisor進行增強
        this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }

    //建立Advice,本質就是一個方法攔截器
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
        return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
    }
}

接著看一下MethodValidationInterceptor:

public class MethodValidationInterceptor implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        //無需增強的方法,直接跳過
        if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
            return invocation.proceed();
        }
        //獲取分組資訊
        Class[] groups = determineValidationGroups(invocation);
        ExecutableValidator execVal = this.validator.forExecutables();
        Method methodToValidate = invocation.getMethod();
        Set<constraintviolation> result;
        try {
            //方法入參校驗,最終還是委託給Hibernate Validator來校驗
            result = execVal.validateParameters(
                invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
        }
        catch (IllegalArgumentException ex) {
            ...
        }
        //有異常直接丟擲
        if (!result.isEmpty()) {
            throw new ConstraintViolationException(result);
        }
        //真正的方法呼叫
        Object returnValue = invocation.proceed();
        //對返回值做校驗,最終還是委託給Hibernate Validator來校驗
        result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
        //有異常直接丟擲
        if (!result.isEmpty()) {
            throw new ConstraintViolationException(result);
        }
        return returnValue;
    }
}

DTO級別的校驗

@PostMapping("/saveUser")
@ResponseBody
//注意:如果方法中的引數是物件型別,則必須要在引數物件前面新增 @Validated
public R saveUser(@Validated @RequestBody UserDTO userDTO){
    userDTO.setUserId(100);
    return R.SUCCESS.setData(userDTO);
}

這種屬於DTO級別的校驗。在spring-mvc中,RequestResponseBodyMethodProcessor是用於解析@RequestBody標註的引數以及處理@ResponseBody標註方法的返回值的。顯然,執行引數校驗的邏輯肯定就在解析引數的方法resolveArgument()中。

public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
    @Override
    public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

        parameter = parameter.nestedIfOptional();
        //將請求資料封裝到DTO物件中
        Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
        String name = Conventions.getVariableNameForParameter(parameter);

        if (binderFactory != null) {
            WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
            if (arg != null) {
                // 執行資料校驗
                validateIfApplicable(binder, parameter);
                if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                    throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                }
            }
            if (mavContainer != null) {
                mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
            }
        }
        return adaptArgumentIfNecessary(arg, parameter);
    }
}

可以看到,resolveArgument()呼叫了validateIfApplicable()進行引數校驗。

protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
    // 獲取引數註解,比如@RequestBody、@Valid、@Validated
    Annotation[] annotations = parameter.getParameterAnnotations();
    for (Annotation ann : annotations) {
        // 先嚐試獲取@Validated註解
        Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
        //如果直接標註了@Validated,那麼直接開啟校驗。
        //如果沒有,那麼判斷引數前是否有Valid起頭的註解。
        if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
            Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
            Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
            //執行校驗
            binder.validate(validationHints);
            break;
        }
    }
}

看到這裡,大家應該能明白為什麼這種場景下@Validated、@Valid兩個註解可以混用。我們接下來繼續看WebDataBinder.validate()實現。

最終發現底層最終還是呼叫了Hibernate Validator進行真正的校驗處理。

404等錯誤的統一處理

參考部落格

參考

相關文章