Spring Boot 參數校驗 Validation 入門

轉載自 芋道 Spring Boot 參數校驗 Validation 入門

本文在提供完整代碼示例,可見 https://github.com/YunaiV/SpringBoot-Labs 的 lab-22 目錄。

原創不易,給點個 Star 嘿,一起衝鴨!

1. 概述

在想標題的時候,到底應該叫數據校驗,還是參數校驗時,我糾結了,而且非常。

最後,考慮參數校驗更貼近我們的理解,就選擇了它。實際更合適的叫法,還是數據校驗

文頭艿艿瞎嗶嗶了一些碎碎念,嫌棄的胖友,可以跳往 「3. 快速入門」 。

當我們想提供可靠的 API 接口,對參數的校驗,以保證最終數據入庫的正確性,是必不可少的活。例如說,用戶註冊時,會校驗手機格式的正確性,密碼非弱密碼。

可惜的是,在翻開自己的項目的時候,會發現大量的 API 接口,我們並沒有添加相應的參數校驗,而是把這個活交給調用方(例如說前端)來完成。😈 甚至在艿艿接觸過的後端開發中,認爲這是前端的活,簡直了!

世界比我們想象中的不安全,可能有“黑客”會繞過瀏覽器,直接使用 HTTP 工具,模擬請求向後端 API 接口傳入違法的參數,以達到它們“不可告人”的目的。

又或者前端開發小哥,不小心漏做了一些 API 接口調用時的參數校驗,結果導致用戶提交了大量不正確的數據到後端 API 接口,並且這些數據成功入庫了。這個時候,你是會甩鍋給前端小哥,還是怒噴測試小姐姐驗收不到位呢?

我相信,很多時候並不是我們不想添加,而是沒有統一方便的方式,讓我們快速的添加實現參數校驗的功能。畢竟,比起枯燥的 CRUD 來說,它更枯燥。例如說,還是拿用戶註冊的接口,校驗手機和密碼這兩個參數,可能就要消耗掉小 10 行的代碼。更不要說,管理後臺創建商品這種參數賊多的接口。

😈 世界上大多數碰到的困難,大多已經有了解決方案,特別是軟件開發。實際上,Java 早在 2009 年就提出了 Bean Validation 規範,並且已經歷經 JSR303、JSR349、JSR380 三次標準的置頂,發展到了 2.0 。

FROM https://beanvalidation.org/specification/

Bean Validation 1.0 :Bean Validation 1.0 (JSR 303) was the first version of Java's standard for object validation. It was released in 2009 and is part of Java EE 6. You can learn more about Bean Validation 1.0 here (specification text, API docs etc).

Bean Validation 1.1 :Bean Validation 1.1 (JSR 349) was finished in 2013 and is part of Java EE 7. Its main contributions are method-level validation, integration with CDI, group conversion and some more. You can learn more about Bean Validation 1.1 here (specification text, full change log, API docs etc).

Bean Validation 2.0 :Bean Validation 2.0 (JSR 380) was finished in August 2017.

It's part of Java EE 8 (but can of course be used with plain Java SE as the previous releases).

You can learn more about Bean Validation 2.0 here (specification text, full change log, API docs etc).

Bean Validation 和我們很久以前學習過的 JPA 一樣,只提供規範,不提供具體的實現。

艿艿:對 JPA 不了的胖友,可以看看 《芋道 Spring Boot JPA 入門》 一文。

  • 在 Bean Validation API 中,定義了 Bean Validation 相關的接口,並沒有具體實現。

  • 在 javax.validation.constraints 包下,定義了一系列的校驗註解。例如說,@NotNull@NotEmpty 。

實現 Bean Validation 規範的數據校驗框架,主要有:

  • Hibernate Validator

    不要以爲 Hibernate 僅僅是一個 ORM 框架,這只是它的 Hibernate ORM 所提供的。

    Hibernate 可是打着“Everything data”口號的,它還提供了 Hibernate Search、Hibernate OGM 等等解決方案的。😈

    所以,女朋友也是 data ,我們來 new 一個就好,不需要找。

  • 🐔 咳咳咳,突然想不起來還有個叫啥了,以後補充吧。啪啪打臉的疼~ Apache BVal

絕大多數情況下,也就 99.99% 吧,我們採用 Hibernate Validator 。

但是,我們在使用 Spring 的項目中,因爲 Spring Validation 提供了對 Bean Validation 的內置封裝支持,可以使用 @Validated 註解,實現聲明式校驗,而無需直接調用 Bean Validation 提供的 API 方法。而在實現原理上,也是基於 Spring AOP 攔截,實現校驗相關的操作。

友情提示:這一點,類似 Spring Transaction 事務,通過 @Transactional 註解,實現聲明式事務。

而在 Spring Validation 內部,最終還是調用不同的 Bean Validation 的實現框架。例如說,Hibernate Validator 。

下面,讓我們開始遨遊,在 Spring Boot 中,如何實現參數校驗。

2. 註解

在開始入門之前,我們先了解下本文可能會涉及到的註解。

2.1 Bean Validation 定義的約束註解

javax.validation.constraints 包下,定義了一系列的約束( constraint )註解。如下:

參考 《JSR 303 - Bean Validation 介紹及最佳實踐》 博客。

一共 22 個註解,快速略過即可。

  • 空和非空檢查

    • @NotBlank :只能用於字符串不爲 null ,並且字符串 #trim() 以後 length 要大於 0 。

    • @NotEmpty :集合對象的元素不爲 0 ,即集合不爲空,也可以用於字符串不爲 null 。

    • @NotNull :不能爲 null 。

    • @Null :必須爲 null 。

  • 數值檢查

    • @DecimalMax(value) :被註釋的元素必須是一個數字,其值必須小於等於指定的最大值。

    • @DecimalMin(value) :被註釋的元素必須是一個數字,其值必須大於等於指定的最小值。

    • @Digits(integer, fraction) :被註釋的元素必須是一個數字,其值必須在可接受的範圍內。

    • @Positive :判斷正數。

    • @PositiveOrZero :判斷正數或 0 。

    • @Max(value) :該字段的值只能小於或等於該值。

    • @Min(value) :該字段的值只能大於或等於該值。

    • @Negative :判斷負數。

    • @NegativeOrZero :判斷負數或 0 。

  • Boolean 值檢查

    • @AssertFalse :被註釋的元素必須爲 true 。

    • @AssertTrue :被註釋的元素必須爲 false 。

  • 長度檢查

    • @Size(max, min) :檢查該字段的 size 是否在 min 和 max 之間,可以是字符串、數組、集合、Map 等。

  • 日期檢查

    • @Future :被註釋的元素必須是一個將來的日期。

    • @FutureOrPresent :判斷日期是否是將來或現在日期。

    • @Past :檢查該字段的日期是在過去。

    • @PastOrPresent :判斷日期是否是過去或現在日期。

  • 其它檢查

    • @Email :被註釋的元素必須是電子郵箱地址。

    • @Pattern(value) :被註釋的元素必須符合指定的正則表達式。

2.2 Hibernate Validator 附加的約束註解

org.hibernate.validator.constraints 包下,定義了一系列的約束( constraint )註解。如下:

  • @Range(min=, max=) :被註釋的元素必須在合適的範圍內。

  • @Length(min=, max=) :被註釋的字符串的大小必須在指定的範圍內。

  • @URL(protocol=,host=,port=,regexp=,flags=) :被註釋的字符串必須是一個有效的 URL 。

  • @SafeHtml :判斷提交的 HTML 是否安全。例如說,不能包含 javascript 腳本等等。

  • ... 等等,就不一一列舉了。

2.3 @Valid 和 @Validated

@Valid 註解,是 Bean Validation 所定義,可以添加在普通方法、構造方法、方法參數、方法返回、成員變量上,表示它們需要進行約束校驗。

@Validated 註解,是 Spring Validation 鎖定義,可以添加在類、方法參數、普通方法上,表示它們需要進行約束校驗。同時,@Validated 有 value 屬性,支持分組校驗。屬性如下:

// Validated.java

Class<?>[] value() default {};

對於初學的胖友來說,很容易搞混 @Valid 和 @Validated 註解。

① 聲明式校驗

Spring Validation 對 @Validated 註解,實現聲明式校驗。

② 分組校驗

Bean Validation 提供的 @Valid 註解,因爲沒有分組校驗的屬性,所以無法提供分組校驗。此時,我們只能使用 ``@Validated` 註解。

③ 嵌套校驗

相比來說,@Valid 註解的地方,多了【成員變量】。這就導致,如果有嵌套對象的時候,只能使用 @Valid 註解。例如說:

// User.java
public class User {
    
    private String id;

    @Valid
    private UserProfile profile;

}

// UserProfile.java
public class UserProfile {

    @NotBlank
    private String nickname;

}
  • 如果不在 User.profile 屬性上,添加 @Valid 註解,就會導致 UserProfile.nickname 屬性,不會進行校驗。

當然,@Valid 註解的地方,也多了【構造方法】和【方法返回】,所以在有這方面的訴求的時候,也只能使用 @Valid 註解。

🔥 總結

總的來說,絕大多數場景下,我們使用 @Validated 註解即可。

而在有嵌套校驗的場景,我們使用 @Valid 註解添加到成員屬性上。

3. 快速入門

示例代碼對應倉庫:lab-22-validation-01 。

本小節,我們會實現在 Spring Boot 中,對 SpringMVC 的 Controller 的 API 接口參數,實現參數校驗。

同時,因爲我們在 Service 也會有參數校驗的訴求,所以我們也會提供示例。

3.1 引入依賴

在 pom.xml 文件中,引入相關依賴。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>lab-22-validation-01</artifactId>

    <dependencies>
        <!-- 實現對 Spring MVC 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 保證 Spring AOP 相關的依賴包 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
        </dependency>

        <!-- 方便等會寫單元測試 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>
  • 具體每個依賴的作用,胖友自己認真看下艿艿添加的所有註釋噢。

  • spring-boot-starter-web 依賴裏,已經默認引入 hibernate-validator 依賴,所以本示例使用的是 Hibernate Validator 作爲 Bean Validation 的實現框架。

在 Spring Boot 體系中,也提供了 spring-boot-starter-validation 依賴。在這裏,我們並沒有引入。爲什麼呢?該依賴的目的,重點也是引入 hibernate-validator 依賴,這在 spring-boot-starter-web 已經引入,所以無需重複引入。

3.2 Application

創建 Application.java 類,配置 @SpringBootApplication 註解即可。代碼如下:

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true) // http://www.voidcn.com/article/p-zddcuyii-bpt.html
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}
  • 添加 @EnableAspectJAutoProxy 註解,重點是配置 exposeProxy = true ,因爲我們希望 Spring AOP 能將當前代理對象設置到 AopContext 中。具體用途,我們會在下文看到。想要提前看的胖友,可以看看 《Spring AOP 通過獲取代理對象實現事務切換》 文章。

先暫時不啓動項目。等我們添加好 Controller 。

3.3 UserAddDTO

在 cn.iocoder.springboot.lab22.validation.dto 包路徑下,創建 UserAddDTO 類,爲用戶添加 DTO 類。代碼如下:

// UserAddDTO.java

public class UserAddDTO {

    /**
     * 賬號
     */
    @NotEmpty(message = "登陸賬號不能爲空")
    @Length(min = 5, max = 16, message = "賬號長度爲 5-16 位")
    @Pattern(regexp = "^[A-Za-z0-9]+$", message = "賬號格式爲數字以及字母")
    private String username;
    /**
     * 密碼
     */
    @NotEmpty(message = "密碼不能爲空")
    @Length(min = 4, max = 16, message = "密碼長度爲 4-16 位")
    private String password;
    
    // ... 省略 setting/getting 方法
}

每個字段上的約束註解,胖友仔細瞅瞅。

3.4 UserController

在 cn.iocoder.springboot.lab22.validation.controller 包路徑下,創建 UserController 類,提供用戶 API 接口。代碼如下:

// UserController.java

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

    private Logger logger = LoggerFactory.getLogger(getClass());

    @GetMapping("/get")
    public void get(@RequestParam("id") @Min(value = 1L, message = "編號必須大於 0") Integer id) {
        logger.info("[get][id: {}]", id);
    }

    @PostMapping("/add")
    public void add(@Valid UserAddDTO addDTO) {
        logger.info("[add][addDTO: {}]", addDTO);
    }

}
  • 在類上,添加 @Validated 註解,表示 UserController 是所有接口都需要進行參數校驗。

  • 對於 #get(id) 方法,我們在 id 參數上,添加了 @Min 註解,校驗 id 必須大於 0 。校驗不通過示例如下圖:

  • 對於 #add(addDTO) 方法,我們在 addDTO 參數上,添加了 @Valid 註解,實現對該參數的校驗。校驗不通過示例如下圖:

    • errors 字段,參數錯誤明細數組。每一個數組元素,對應一個參數錯誤明細。這裏,username 違背了長度不滿足 [5, 16] 。

示例我們是已經成功跑通了,但是呢,這裏有幾點差異性,我們要來理解下。

艿艿:解釋起來,信息量有點大,胖友保持耐心。

也可以不理解,就按照這麼使用即可。

第一點#get(id) 方法上,我們並沒有給 id 添加 @Valid 註解,而 #add(addDTO) 方法上,我們給 addDTO 添加 @Valid 註解。這個差異,是爲什麼呢?

因爲 UserController 使用了 @Validated 註解,那麼 Spring Validation 就會使用 AOP 進行切面,進行參數校驗。而該切面的攔截器,使用的是 MethodValidationInterceptor 。

  • 對於 #get(id) 方法,需要校驗的參數 id ,是平鋪開的,所以無需添加 @Valid 註解。

  • 對於 #add(addDTO) 方法,需要校驗的參數 addDTO ,實際相當於嵌套校驗,要校驗的參數的都在 addDTO 裏面,所以需要添加 @Valid 註解。

第二點#get(id) 方法的返回的結果是 status = 500 ,而 #add(addDTO) 方法的返回的結果是 status = 400 。

  • 對於 #get(id) 方法,在 MethodValidationInterceptor 攔截器中,校驗到參數不正確,會拋出 ConstraintViolationException 異常。

  • 對於 #add(addDTO) 方法,因爲 addDTO 是個 POJO 對象,所以會走 SpringMVC 的 DataBinder 機制,它會調用 DataBinder#validate(Object... validationHints) 方法,進行校驗。在校驗不通過時,會拋出 BindException 。

在 SpringMVC 中,默認使用 DefaultHandlerExceptionResolver 處理異常。

  • 對於 BindException 異常,處理成 400 的狀態碼。

  • 對於 ConstraintViolationException 異常,沒有特殊處理,所以處理成 500 的狀態碼。

這裏,我們在拋個問題,如果 #add(addDTO 方法,如果參數正確,在走完 DataBinder 中的參數校驗後,會不會在走一遍 MethodValidationInterceptor 的攔截器呢?思考 100 毫秒...

答案是會。這樣,就會導致浪費。所以 Controller 類裏,如果只有類似的 #add(addDTO) 方法的嵌套校驗,那麼我可以不在 Controller 類上添加 @Validated 註解。從而實現,僅使用 DataBinder 中來做參數校驗。

第三點,無論是 #get(id) 方法,還是 #add(addDTO) 方法,它們的返回提示都非常不友好,那麼該怎麼辦呢?

參考 《芋道 Spring Boot SpringMVC 入門》 的 「5. 全局異常處理」 ,使用 @ExceptionHandler 註解,實現自定義的異常處理。這個,我們在本文的 4. 處理校驗異常 小節中,來提供具體示例。

3.5 UserService

相比在 Controller 添加參數校驗來說,在 Service 進行參數校驗,會更加安全可靠。艿艿個人建議的話,Controller 的參數校驗可以不做,Service 的參數校驗一定要做

在 cn.iocoder.springboot.lab22.validation.service 包路徑下,創建 UserService 類,提供用戶 Service 邏輯。代碼如下:

// UserService.java

@Service
@Validated
public class UserService {

    private Logger logger = LoggerFactory.getLogger(getClass());

    public void get(@Min(value = 1L, message = "編號必須大於 0") Integer id) {
        logger.info("[get][id: {}]", id);
    }

    public void add(@Valid UserAddDTO addDTO) {
        logger.info("[add][addDTO: {}]", addDTO);
    }

    public void add01(UserAddDTO addDTO) {
        this.add(addDTO);
    }

    public void add02(UserAddDTO addDTO) {
        self().add(addDTO);
    }

    private UserService self() {
        return (UserService) AopContext.currentProxy();
    }

}
  • 和 UserController 的方法是一致的,包括註解。

  • 額外添加了 #add01(addDTO) 和 #add02(addDTO) 方法,用於演示方法內部調用。

創建 UserServiceTest 測試類,我們來測試一下簡單的 UserService 的每個操作。代碼如下:

// UserService.java

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class UserServiceTest {

    @Autowired
    private UserService userService;

    @Test
    public void testGet() {
        userService.get(-1);
    }

    @Test
    public void testAdd() {
        UserAddDTO addDTO = new UserAddDTO();
        userService.add(addDTO);
    }

    @Test
    public void testAdd01() {
        UserAddDTO addDTO = new UserAddDTO();
        userService.add01(addDTO);
    }

    @Test
    public void testAdd02() {
        UserAddDTO addDTO = new UserAddDTO();
        userService.add02(addDTO);
    }

}

① #testGet() 測試方法

執行,拋出 ConstraintViolationException 異常。日誌如下:

javax.validation.ConstraintViolationException: get.id: 編號必須大於 0

 at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:116)
  • 符合期望。

② #testAdd() 測試方法

執行,拋出 ConstraintViolationException 異常。日誌如下:

javax.validation.ConstraintViolationException: add.addDTO.username: 登陸賬號不能爲空, add.addDTO.password: 密碼不能爲空

 at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:116)
  • 符合期望。不同於我們在調用 UserController#add(addDTO) 方法,這裏被 MethodValidationInterceptor 攔截,進行參數校驗,而不是 DataBinder 當中。

③ #testAdd01() 測試方法

執行,正常結束。因爲進行 this.add(addDTO) 調用時,this 並不是 Spring AOP 代理對象,所以並不會被 MethodValidationInterceptor 所攔截。

④ #testAdd02() 測試方法

執行,拋出 IllegalStateException 異常。日誌如下:

java.lang.IllegalStateException: Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available.

 at org.springframework.aop.framework.AopContext.currentProxy(AopContext.java:69)
  • 理論來說,因爲我們配置了 @EnableAspectJAutoProxy(exposeProxy = true) 註解,在 Spring AOP 攔截時,通過調用 AopContext.currentProxy() 方法,是可以獲取到當前的代理對象。結果,此處拋出 IllegalStateException 異常。

  • 顯然,這裏並沒有將當前的代理對象,設置到 AopContext 中,所以拋出 IllegalStateException 異常。目前猜測,可能是 BUG 。😈 暫時木有心情去調試,嘿嘿。

4. 處理校驗異常

示例代碼對應倉庫:lab-22-validation-01 。

在 「3. 快速入門」 中,我們可以看到,如果直接將校驗的結果返回給前端,提示內容的可閱讀性是比較差的,所以我們需要對校驗拋出的異常進行處理。

在 《芋道 Spring Boot SpringMVC 入門》 的 「5. 全局異常處理」 小節中,使用 @ExceptionHandler 註解,實現自定義的異常處理。所以本小節,我們在 「3. 快速入門」 小節的 lab-22-validation-01 示例,進一步處理校驗異常。

4.1 複製粘貼

我們先把 《芋道 Spring Boot SpringMVC 入門》 的 「5. 全局異常處理」 小節中,需要用到的類,全部複製過來。

  • 在 cn.iocoder.springboot.lab22.validation.constants 包路徑下,複製 ServiceExceptionEnum 類。

  • 在 cn.iocoder.springboot.lab22.validation.core.exception 包路徑下,複製 ServiceException 類。

  • 在 cn.iocoder.springboot.lab22.validation.core.vo 包路徑下,複製 CommonResult 類。

  • 在 cn.iocoder.springboot.lab22.validation.core.web 包路徑下,複製 GlobalExceptionHandler 和 GlobalResponseBodyHandler 類。

4.2 ServiceExceptionEnum

修改 ServiceExceptionEnum 枚舉類,增加校驗參數不通過的錯誤碼枚舉。代碼如下:

// ServiceExceptionEnum.java

INVALID_REQUEST_PARAM_ERROR(2001001002, "請求參數不合法"),

4.3 GlobalExceptionHandler

修改 GlobalExceptionHandler 類,增加 #constraintViolationExceptionHandler(...) 方法,處理 ConstraintViolationException 異常。代碼如下:

// GlobalExceptionHandler.java

@ResponseBody
@ExceptionHandler(value = ConstraintViolationException.class)
public CommonResult constraintViolationExceptionHandler(HttpServletRequest req, ConstraintViolationException ex) {
    logger.debug("[constraintViolationExceptionHandler]", ex);
    // 拼接錯誤
    StringBuilder detailMessage = new StringBuilder();
    for (ConstraintViolation<?> constraintViolation : ex.getConstraintViolations()) {
        // 使用 ; 分隔多個錯誤
        if (detailMessage.length() > 0) {
            detailMessage.append(";");
        }
        // 拼接內容到其中
        detailMessage.append(constraintViolation.getMessage());
    }
    // 包裝 CommonResult 結果
    return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
            ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}
  • 將每個約束的錯誤內容提示,拼接起來,使用 ; 分隔。

  • 重新請求 UserController#get(id) 對應的接口,響應結果如下:

修改 GlobalExceptionHandler 類,增加 #bindExceptionHandler(...) 方法,處理 BindException 異常。代碼如下:

// GlobalExceptionHandler.java

@ResponseBody
@ExceptionHandler(value = BindException.class)
public CommonResult bindExceptionHandler(HttpServletRequest req, BindException ex) {
    logger.debug("[bindExceptionHandler]", ex);
    // 拼接錯誤
    StringBuilder detailMessage = new StringBuilder();
    for (ObjectError objectError : ex.getAllErrors()) {
        // 使用 ; 分隔多個錯誤
        if (detailMessage.length() > 0) {
            detailMessage.append(";");
        }
        // 拼接內容到其中
        detailMessage.append(objectError.getDefaultMessage());
    }
    // 包裝 CommonResult 結果
    return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
            ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}
  • 將每個約束的錯誤內容提示,拼接起來,使用 ; 分隔。

  • 重新請求 UserController#add(addDTO) 對應的接口,響應結果如下:

5. 自定義約束

示例代碼對應倉庫:lab-22-validation-01 。

在大多數項目中,無論是 Bean Validation 定義的約束,還是 Hibernate Validator 附加的約束,都是無法滿足我們複雜的業務場景。所以,我們需要自定義約束。

開發自定義約束一共只要兩步:1)編寫自定義約束的註解;2)編寫自定義的校驗器 ConstraintValidator 。

下面,就讓我們一起來實現一個自定義約束,用於校驗參數必須在枚舉值的範圍內。

5.1 IntArrayValuable

在 cn.iocoder.springboot.lab22.validation.core.validator 包路徑下,創建 IntArrayValuable 接口,用於返回值數組。代碼如下:

// IntArrayValuable.java

public interface IntArrayValuable {

    /**
     * @return int 數組
     */
    int[] array();

}

因爲對於一個枚舉類來說,我們無法獲得它具體有那些值。所以,我們會要求這個枚舉類實現該接口,返回它擁有的所有枚舉值。

5.2 GenderEnum

在 cn.iocoder.springboot.lab22.validation.constants 包路徑下,創建 GenderEnum 枚舉類,枚舉性別。代碼如下:

// GenderEnum.java

public enum GenderEnum implements IntArrayValuable {

    MALE(1, "男"),
    FEMALE(2, "女");

    /**
     * 值數組
     */
    public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(GenderEnum::getValue).toArray();

    /**
     * 性別值
     */
    private final Integer value;
    /**
     * 性別名
     */
    private final String name;

    GenderEnum(Integer value, String name) {
        this.value = value;
        this.name = name;
    }

    public Integer getValue() {
        return value;
    }

    public String getName() {
        return name;
    }

    @Override
    public int[] array() {
        return ARRAYS;
    }

}
  • 實現 IntArrayValuable 接口,返回值數組 ARRAYS 。

5.3 @InEnum

在 cn.iocoder.springboot.lab22.validation.core.validator 包路徑下,創建 @InEnum 自定義約束的註解。代碼如下:

// InEnum.java

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = InEnumValidator.class)
public @interface InEnum {

    /**
     * @return 實現 IntArrayValuable 接口的
     */
    Class<? extends IntArrayValuable> value();

    /**
     * @return 提示內容
     */
    String message() default "必須在指定範圍 {value}";

    /**
     * @return 分組
     */
    Class<?>[] groups() default {};

    /**
     * @return Payload 數組
     */
    Class<? extends Payload>[] payload() default {};

    /**
     *  Defines several {@code @InEnum} constraints on the same element.
     */
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @interface List {

        InEnum[] value();

    }

}
  • 在類上,添加 @@Constraint(validatedBy = InEnumValidator.class) 註解,設置使用的自定義約束的校驗器

  • value() 屬性,設置實現 IntArrayValuable 接口的類。這樣,我們就能獲得參數需要校驗的值數組。

  • message() 屬性,設置提示內容。默認爲 "必須在指定範圍 {value}" 。

  • 其它屬性,複製粘貼即可,都可以忽略不用理解。

5.4 InEnumValidator

在 cn.iocoder.springboot.lab22.validation.core.validator 包路徑下,創建 InEnumValidator 自定義約束的校驗器。代碼如下:

// InEnumValidator.java

public class InEnumValidator implements ConstraintValidator<InEnum, Integer> {

    /**
     * 值數組
     */
    private Set<Integer> values;

    @Override
    public void initialize(InEnum annotation) {
        IntArrayValuable[] values = annotation.value().getEnumConstants();
        if (values.length == 0) {
            this.values = Collections.emptySet();
        } else {
            this.values = Arrays.stream(values[0].array()).boxed().collect(Collectors.toSet());
        }
    }

    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        // <2.1> 校驗通過
        if (values.contains(value)) {
            return true;
        }
        // <2.2.1>校驗不通過,自定義提示語句(因爲,註解上的 value 是枚舉類,無法獲得枚舉類的實際值)
        context.disableDefaultConstraintViolation(); // 禁用默認的 message 的值
        context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()
                .replaceAll("\\{value}", values.toString())).addConstraintViolation(); // 重新添加錯誤提示語句      
        return false; // <2.2.2.>
    }

}
  • 實現 ConstraintValidator 接口。

    • 第一個泛型爲 A extends Annotation ,設置對應的自定義約束的註解。例如說,這裏我們設置了 @InEnum 註解。

    • 第二個泛型爲 T ,設置對應的參數值的類型。例如說,這裏我們設置了 Integer 類型。

  • 實現 #initialize(annotation) 方法,獲得 @InEnum 註解的 values() 屬性,獲得值數組,設置到 values 屬性種。

  • 實現 #isValid(value, context) 方法,實現校驗參數值,是否在 values 範圍內。

    • <2.1> 處,校驗參數值在範圍內,直接返回 true ,校驗通過。

    • <2.2.1> 處,校驗不通過,自定義提示語句。

    • <2.2.2> 處,校驗不通過,所以返回 false 。

至此,我們已經完成了自定義約束的實現。下面,我們來進行下測試。

5.5 UserUpdateGenderDTO

在 cn.iocoder.springboot.lab22.validation.dto 包路徑下,創建 UserUpdateGenderDTO 類,爲用戶更新性別 DTO。代碼如下:

// UserUpdateGenderDTO.java

public class UserUpdateGenderDTO {

    /**
     * 用戶編號
     */
    @NotNull(message = "用戶編號不能爲空")
    private Integer id;

    /**
     * 性別
     */
    @NotNull(message = "性別不能爲空")
    @InEnum(value = GenderEnum.class, message = "性別必須是 {value}")
    private Integer gender;
    
    // ... 省略 set/get 方法
}
  • 在 gender 字段上,添加 @InEnum(value = GenderEnum.class, message = "性別必須是 {value}") 註解,限制傳入的參數值,必須在 GenderEnum 枚舉範圍內。

5.6 UserController

修改 UserController 類,增加修改性別 API 接口。代碼如下:

// UserController.java

@PostMapping("/update_gender")
public void updateGender(@Valid UserUpdateGenderDTO updateGenderDTO) {
    logger.info("[updateGender][updateGenderDTO: {}]", updateGenderDTO);
}

模擬請求該 API 接口,響應結果如下:

因爲我們傳入的請求參數 gender 的值爲 null ,顯然不在 GenderEnum 範圍內,所以校驗不通過,輸出 "性別必須是 [1, 2]" 。

6. 分組校驗

示例代碼對應倉庫:lab-22-validation-01 。

在一些業務場景下,我們需要使用分組校驗,即相同的 Bean 對象,根據校驗分組,使用不同的校驗規則。咳咳咳,貌似我們暫時沒有這方面的訴求。即使有,也是拆分不同的 Bean 類。當然,作爲一篇入門的文章,艿艿還是提供下分組校驗的示例。

6.1 UserUpdateStatusDTO

在 cn.iocoder.springboot.lab22.validation.dto 包路徑下,創建 UserUpdateStatusDTO 類,爲用戶更新狀態 DTO 。代碼如下:

// UserUpdateStatusDTO.java

public class UserUpdateStatusDTO {

    /**
     * 分組 01 ,要求狀態必須爲 true
     */
    public interface Group01 {}

    /**
     * 狀態 02 ,要求狀態必須爲 false
     */
    public interface Group02 {}
    
    /**
     * 狀態
     */
    @AssertTrue(message = "狀態必須爲 true", groups = Group01.class)
    @AssertFalse(message = "狀態必須爲 false", groups = Group02.class)
    private Boolean status;

    // ... 省略 set/get 方法
}
  • 創建了 Group01 和 Group02 接口,作爲兩個校驗分組。不一定要定義在 UserUpdateStatusDTO 類中,這裏僅僅是爲了方便。

  • status 字段,在 Group01 校驗分組時,必須爲 true ;在 Group02 校驗分組時,必須爲 false 。

6.2 UserController

修改 UserController 類,增加兩個修改狀態的 API 接口。代碼如下:

// UserController.java

@PostMapping("/update_status_true")
public void updateStatusTrue(@Validated(UserUpdateStatusDTO.Group01.class) UserUpdateStatusDTO updateStatusDTO) {
    logger.info("[updateStatusTrue][updateStatusDTO: {}]", updateStatusDTO);
}

@PostMapping("/update_status_false")
public void updateStatusFalse(@Validated(UserUpdateStatusDTO.Group02.class) UserUpdateStatusDTO updateStatusDTO) {
    logger.info("[updateStatusFalse][updateStatusDTO: {}]", updateStatusDTO);
}
  • 對於 #updateStatusTrue(updateStatusDTO) 方法,我們在 updateStatusDTO 參數上,添加了 @Validated 註解,並且設置校驗分組爲 Group01 。校驗不通過示例如下圖:

  • 對於 #updateStatusFalse(updateStatusDTO) 方法,我們在 updateStatusDTO 參數上,添加了 @Validated 註解,並且設置校驗分組爲 Group02 。校驗不通過示例如下圖:

所以,使用分組校驗,核心在於添加上 @Validated 註解,並設置對應的校驗分組。

7. 手動校驗

示例代碼對應倉庫:lab-22-validation-01 。

在上面的示例中,我們使用的主要是 Spring Validation 的聲明式註解。然而在少數業務場景下,我們可能需要手動使用 Bean Validation API ,進行參數校驗。

修改 UserServiceTest 測試類,增加手動參數校驗的示例。代碼如下:

// UserServiceTest.java

@Autowired // <1.1>
private Validator validator;

@Test
public void testValidator() {
    // 打印,查看 validator 的類型 // <1.2>
    System.out.println(validator);

    // 創建 UserAddDTO 對象 // <2>
    UserAddDTO addDTO = new UserAddDTO();
    // 校驗 // <3>
    Set<ConstraintViolation<UserAddDTO>> result = validator.validate(addDTO);
    // 打印校驗結果 // <4>
    for (ConstraintViolation<UserAddDTO> constraintViolation : result) {
        // 屬性:消息
        System.out.println(constraintViolation.getPropertyPath() + ":" + constraintViolation.getMessage());
    }
}
  • <1.1> 處,注入 Validator Bean 對象。

  • <1.2> 處,打印 validator 的類型。輸出如下:

    org.springframework.validation.beanvalidation.LocalValidatorFactoryBean@48c3205a
    
    • validator 的類型爲 LocalValidatorFactoryBean 。LocalValidatorFactoryBean 提供 JSR-303、JSR-349 的支持,同時兼容 Hibernate Validator 。

    • 在 Spring Boot 體系中,使用 ValidationAutoConfiguration 自動化配置類,默認創建 LocalValidatorFactoryBean 作爲 Validator Bean 。

  • <2> 處,創建 UserAddDTO 對象,即 「3.3 UserAddDTO」 ,已經添加相應的約束註解。

  • <3> 處,調用 Validator#validate(T object, Class<?>... groups) 方法,進行參數校驗。

  • <4> 處,打印校驗結果。輸出如下:

    username:登陸賬號不能爲空
    password:密碼不能爲空
    
    • 如果校驗通過,則返回的 Set<ConstraintViolation<?>> 集合爲空。

8. 國際化 i18n

示例代碼對應倉庫:lab-22-validation-01 。

在一些項目中,我們會有國際化的需求,特別是我們在做 TOB 的 SASS 化服務的時候。那麼,顯然我們在使用 Bean Validator 做參數校驗的時候,也需要提供國際化的錯誤提示。

給力的是,Hibernate Validator 已經內置了國際化的支持,所以我們只需要簡單的配置,就可以實現國際化的錯誤提示。

8.1 應用配置文件

在 resources 目錄下,創建 application.yaml 配置文件。配置如下:

spring:
  # i18 message 配置,對應 MessageSourceProperties 配置類
  messages:
    basename: i18n/messages # 文件路徑基礎名
    encoding: UTF-8 # 使用 UTF-8 編碼

然後,我們在 resources/i18 目錄下,創建不同語言的 messages 文件。如下:

  • messages.properties :默認的 i18 配置文件。

    UserUpdateDTO.id.NotNull=用戶編號不能爲空
    
  • messages_en.properties :英文的 i18 配置文件。

    UserUpdateDTO.id.NotNull=userId cannot be empty
    
  • messages_ja.properties :日文的 i18 配置文件。

    UserUpdateDTO.id.NotNull=ユーザー番號は空にできません
    

8.2 ValidationConfiguration

在 cn.iocoder.springboot.lab22.validation.config 包路徑下,創建 ValidationConfiguration 配置類,用於創建一個支持 i18 國際化的 Validator Bean 對象。代碼如下:

// ValidationConfiguration.java

@Configuration
public class ValidationConfiguration {

    /**
     * 參考 {@link ValidationAutoConfiguration#defaultValidator()} 方法,構建 Validator Bean
     *
     * @return Validator 對象
     */
    @Bean
    public Validator validator(MessageSource messageSource)  {
        // 創建 LocalValidatorFactoryBean 對象
        LocalValidatorFactoryBean validator = ValidationAutoConfiguration.defaultValidator();
        // 設置 messageSource 屬性,實現 i18 國際化
        validator.setValidationMessageSource(messageSource);
        // 返回
        return validator;
    }

}

8.3 UserUpdateDTO

在 cn.iocoder.springboot.lab22.validation.dto 包路徑下,創建 UserUpdateDTO 類,爲用戶更新 DTO 。代碼如下:

// UserUpdateDTO.java

public class UserUpdateDTO {

    /**
     * 用戶編號
     */
    @NotNull(message = "{UserUpdateDTO.id.NotNull}")
    private Integer id;

    // ... 省略 get/set 方法
    
}
  • 不同於我們上面看到的約束註解的 message 屬性的設置,這裏我們使用了 {} 佔位符。

8.4 UserController

修改 UserController 類,增加用戶更新的 API 接口。代碼如下:

// UserController.java

@PostMapping("/update")
public void update(@Valid UserUpdateDTO updateDTO) {
    logger.info("[update][updateDTO: {}]", updateDTO);
}

下面,我們來進行下 API 接口測試。有一點要注意,SpringMVC 通過 Accept-Language 請求頭,實現 i18n 國際化。

  • Accept-Language = zh 的情況,響應結果如下:

  • Accept-Language = en 的情況,響應結果如下:

  • Accept-Language = ja 的情況,響應結果如下:

至此,我們的 Validator 的 i18n 國際化已經完成了。

不過細心的胖友,會發現 "請求參數不合法" 並沒有國際化處理。是的~實際上,國際化是個大工程,涉及到方方面面。例如說,業務信息表的國際化,商品同時支持中文、英文、韓文等多種語言。😈 最近艿艿手頭有個新項目,需要做國際化,有這方面需求的胖友,可以一起多多交流呀。

666. 彩蛋

希望閱讀完本文,能夠讓胖友更加舒適且優雅的完成各種需要參數校驗的地方。😈 不說了,艿艿趕緊給自己的系統去把參數校驗給補全,嘿嘿。

當然,有一點要注意,Bean Validation 更多做的是,無狀態的參數校驗。怎麼理解呢?

  • 例如說,參數的大小長度等等,是適合通過 Bean Validation 中完成。

  • 例如說,校驗用戶名唯一等等,依賴外部數據源的,是不適合通過 Bean Validation 中完成。

當然,如果胖友有不同意見,歡迎留言討論。

受限於篇幅,艿艿偷懶了下,還有一些內容其實可以補充:

  • 《Intro to Apache BVal》 使用 Apache BVal 實現參數校驗。

  • 《使用 Spring 的 Validator 接口進行校驗》 ,通過實現 Validator 接口,提供對應 Bean 的參數校驗器。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章