使用 SpringBoot 進行優雅的數據驗證

来源:https://www.cnblogs.com/zerozayu/archive/2023/08/10/17620589.html
-Advertisement-
Play Games

## JSR-303 規範 在程式進行數據處理之前,對數據進行準確性校驗是我們必須要考慮的事情。儘早發現數據錯誤,不僅可以防止錯誤向核心業務邏輯蔓延,而且這種錯誤非常明顯,容易發現解決。 JSR303 規範(Bean Validation 規範)為 JavaBean 驗證定義了相應的元數據模型和 A ...


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;

    @DecimalMin(value = "0.00", message = "費率格式不正確",groups = UpdateFeeRate.class)
    @DecimalMax(value = "100.00", message = "費率格式不正確",groups = UpdateFeeRate.class)
    private BigDecimal gongzi;
    //省略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 等錯誤的統一處理

參考博客

參考

Spring Validation 實現原理及如何運用

SpringBoot 參數校驗和國際化使用

@Valid 和@Validated 區別
Spring Validation 最佳實踐及其實現原理,參數校驗沒那麼簡單!

出處:https://www.cnblogs.com/54chensongxia/p/14016179.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 由於垃圾收集演算法的實現涉及大量的程式細節,而且各個平臺的虛擬機操作記憶體的方法又各不相同,因此本節不打算過多地討論演算法的實現,只是介紹幾種演算法的思想及其發展過程。 垃圾收集演算法概要 1、 標記-清除演算法 標記-清除演算法最基礎的收集演算法是“標記-清除”(Mark-Sweep)演算法,演算法分為“標記”和“清 ...
  • **** # 1.內容 | | 解釋 | | | | | // 內容 | 單行註釋 | | /* 內容 */ | 多行註釋 | | /*** 內容 */ | 文檔註釋 | # 2.多行註釋 與 文檔註釋的區別 多行註釋: ![img](https://img2023.cnblogs.com/blog ...
  • **原文鏈接:** [如何實現計數器限流?](https://mp.weixin.qq.com/s/CTemkZ2aKPCPTuQiDJri0Q) 上一篇文章 [go-zero 是如何做路由管理的?](https://mp.weixin.qq.com/s/uTJ1En-BXiLvH45xx0eFsA ...
  • ## 教程簡介 Mahout 是 Apache Software Foundation(ASF) 旗下的一個開源項目,提供一些可擴展的機器學習領域經典演算法的實現,旨在幫助開發人員更加方便快捷地創建智能應用程式。Mahout包含許多實現,包括聚類、分類、推薦過濾、頻繁子項挖掘。此外,通過使用 Apac ...
  • 哈嘍大家好,我是鹹魚 幾天前有媒體報道稱,經過多次辯論,Python 指導委員會打算批准通過 PEP 703 提案,**讓 GIL(全局解釋器)鎖在 CPython 中成為一個可選項** PEP 703 提案主要目標是使 GIL 變成可選項,即允許 Python 解釋器在特定情況下不使用GIL ![ ...
  • 概述 亂碼問題是大家在日常開發過程中經常會遇到的問題,由於各自環境的不同,解決起來也費時費力,本文主要介紹一般性亂碼問題的解決方法與步驟,開發工具採用Eclipse+Tomcat,統一設置項目編碼UTF-8為例,供大家參考。 解決方法與步驟 步驟一:首先,檢查JSP頁面聲明的編碼是否正確,正確示例( ...
  • 在軟體版本快速迭代的過程中,經常會遇到一些介面變化問題。而如果需要相容舊版本的話,就需要使用到版本判斷的方法。判斷清楚版本號屬於哪一個區間,再對不同的版本號區間採取不同的演算法或者執行策略。Python中預先內置的LooseVersion就是一個很好的版本號比對工具,不僅僅可以對相同位數或者相同類型的... ...
  • 原文在[這裡](https://go.dev/blog/go1.21)。 > 由Eli Bendersky, on behalf of the Go team 發佈於 8 August 2023 Go團隊今天非常高興地發佈了Go 1.21版本,你可以通過訪問[下載頁面](https://go.dev ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...