Spring Boot 使用 JSR303 實現引數驗證

程式設計師果果發表於2020-06-17

簡介

JSR-303 是 JAVA EE 6 中的一項子規範,叫做 Bean Validation。

在任何時候,當你要處理一個應用程式的業務邏輯,資料校驗是你必須要考慮和麵對的事情。應用程式必須通過某種手段來確保輸入進來的資料從語義上來講是正確的。在通常的情況下,應用程式是分層的,不同的層由不同的開發人員來完成。很多時候同樣的資料驗證邏輯會出現在不同的層,這樣就會導致程式碼冗餘和一些管理的問題,比如說語義的一致性等。為了避免這樣的情況發生,最好是將驗證邏輯與相應的域模型進行繫結。

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

Bean Validation 規範內嵌的約束註解

例項

基本應用

引入依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

給引數物件新增校驗註解

@Data
public class User {
    
    private Integer id;
    @NotBlank(message = "使用者名稱不能為空")
    private String username;
    @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{8,16}$", message = "密碼必須為8~16個字母和數字組合")
    private String password;
    @Email
    private String email;
    private Integer gender;

}

Controller 中需要校驗的引數Bean前新增 @Valid 開啟校驗功能,緊跟在校驗的Bean後新增一個BindingResult,BindingResult封裝了前面Bean的校驗結果。

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

    @PostMapping("")
    public Result save (@Valid User user , BindingResult bindingResult)  {
        if (bindingResult.hasErrors()) {
            Map<String , String> map = new HashMap<>();
            bindingResult.getFieldErrors().forEach( (item) -> {
                String message = item.getDefaultMessage();
                String field = item.getField();
                map.put( field , message );
            } );
            return Result.build( 400 , "非法引數 !" , map);
        }
        return Result.ok();
    }

}

測試如下:

異常的統一處理

引數校驗不通過時,會丟擲 BingBindException 異常,可以在統一異常處理中,做統一處理,這樣就不用在每個需要引數校驗的地方都用 BindingResult 獲取校驗結果了。

@Slf4j
@RestControllerAdvice(basePackages = "com.itwolfed.controller")
public class GlobalExceptionControllerAdvice {

    @ExceptionHandler(value= {MethodArgumentNotValidException.class , BindException.class})
    public Result handleVaildException(Exception e){
        BindingResult bindingResult = null;
        if (e instanceof MethodArgumentNotValidException) {
            bindingResult = ((MethodArgumentNotValidException)e).getBindingResult();
        } else if (e instanceof BindException) {
            bindingResult = ((BindException)e).getBindingResult();
        }
        Map<String,String> errorMap = new HashMap<>(16);
        bindingResult.getFieldErrors().forEach((fieldError)->
                errorMap.put(fieldError.getField(),fieldError.getDefaultMessage())
        );
        return Result.build(400 , "非法引數 !" , errorMap);
    }

}

分組解決校驗

新增和修改對於實體的校驗規則是不同的,例如id是自增的時,新增時id要為空,修改則必須不為空;新增和修改,若用的恰好又是同一種實體,那就需要用到分組校驗。

校驗註解都有一個groups屬性,可以將校驗註解分組,我們看下@NotNull的原始碼:

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {

	String message() default "{javax.validation.constraints.NotNull.message}";

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

	Class<? extends Payload>[] payload() default { };

	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
	@Retention(RUNTIME)
	@Documented
	@interface List {

		NotNull[] value();
	}
}

從原始碼可以看出 groups 是一個Class<?>型別的陣列,那麼就可以建立一個Groups.

public class Groups {
    public interface Add{}
    public interface  Update{}
}

給引數物件的校驗註解新增分組

@Data
public class User {

    @Null(message = "新增不需要指定id" , groups = Groups.Add.class)
    @NotNull(message = "修改需要指定id" , groups = Groups.Update.class)
    private Integer id;
    @NotBlank(message = "使用者名稱不能為空")
    @NotNull
    private String username;
    @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{8,16}$", message = "密碼必須為8~16個字母和數字組合")
    private String password;
    @Email
    private String email;
    private Integer gender;

}

Controller 中原先的@Valid不能指定分組 ,需要替換成@Validated

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

    @PostMapping("")
    public Result save (@Validated(Groups.Add.class) User user)  {
        return Result.ok();
    }

}

測試如下:

自定義校驗註解

雖然JSR303和springboot-validator 已經提供了很多校驗註解,但是當面對複雜引數校驗時,還是不能滿足我們的要求,這時候我們就需要 自定義校驗註解。

例如User中的gender,用 1代表男 2代表女,我們自定義一個校驗註解@ListValue,指定取值只能1和2。

建立約束規則

@Documented
@Constraint(validatedBy = { ListValueConstraintValidator.class })
@Target({ METHOD, FIELD, ANNOTATION_TYPE })
@Retention(RUNTIME)
public @interface ListValue {
    String message() default "";

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

    Class<? extends Payload>[] payload() default { };

    int[] vals() default { };
}

一個標註(annotation) 是通過@interface關鍵字來定義的. 這個標註中的屬性是宣告成類似方法
的樣式的. 根據Bean Validation API 規範的要求:

  • message屬性, 這個屬性被用來定義預設得訊息模版, 當這個約束條件被驗證失敗的時候,通過
    此屬性來輸出錯誤資訊。
  • groups 屬性, 用於指定這個約束條件屬於哪(些)個校驗組. 這個的預設值必須是Class<?>型別陣列。
  • payload 屬性, Bean Validation API 的使用者可以通過此屬性來給約束條件指定嚴重級別. 這個屬性並不被API自身所使用。

除了這三個強制性要求的屬性(message, groups 和 payload) 之外, 我們還添
加了一個屬性用來指定所要求的值. 此屬性的名稱vals在annotation的定義中比較特
殊, 如果只有這個屬性被賦值了的話, 那麼, 在使用此annotation到時候可以忽略此屬性名稱.

另外, 我們還給這個annotation標註了一些元標註( meta
annotatioins):

  • @Target({ METHOD, FIELD, ANNOTATION_TYPE }): 表示此註解可以被用在方法, 欄位或者
    annotation宣告上。
  • @Retention(RUNTIME): 表示這個標註資訊是在執行期通過反射被讀取的.
  • @Constraint(validatedBy = ListValueConstraintValidator.class): 指明使用哪個校驗器(類) 去校驗使用了此標註的元素.
  • @Documented: 表示在對使用了該註解的類進行javadoc操作到時候, 這個標註會被新增到
    javadoc當中.

建立約束校驗器

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.HashSet;
import java.util.Set;

public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> {

    private Set<Integer> set = new HashSet<>();
    /**
     * 初始化方法
     */
    @Override
    public void initialize(ListValue constraintAnnotation) {

        int[] vals = constraintAnnotation.vals();
        for (int val : vals) {
            set.add(val);
        }

    }

    /**
     * 判斷是否校驗成功
     *
     * @param value 需要校驗的值
     * @param context
     * @return
     */
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {

        return set.contains(value);
    }
}

ListValueConstraintValidator定義了兩個泛型引數, 第一個是這個校驗器所服務到標註型別(在我們的例子中即ListValue), 第二個這個校驗器所支援到被校驗元素的型別 (即Integer)。

如果一個約束標註支援多種型別的被校驗元素的話, 那麼需要為每個所支援的型別定義一個ConstraintValidator,並且註冊到約束標註中。

這個驗證器的實現就很平常了, initialize() 方法傳進來一個所要驗證的標註型別的例項, 在本
例中, 我們通過此例項來獲取其vals屬性的值,並將其儲存為Set集合中供下一步使
用。

isValid()是實現真正的校驗邏輯的地方, 判斷一個給定的int對於@ListValue這個約束條件來說
是否是合法的。

在引數物件中使用@ListValue註解。

@Data
public class User {

    @Null(message = "新增不需要指定id" , groups = Groups.Add.class)
    @NotNull(message = "修改需要指定id" , groups = Groups.Update.class)
    private Integer id;
    @NotBlank(message = "使用者名稱不能為空")
    @NotNull
    private String username;
    @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{8,16}$", message = "密碼必須為8~16個字母和數字組合")
    private String password;
    @Email
    private String email;
    @ListValue( message = "性別應指定相應的值" , vals = {1,2} , groups = {Groups.Add.class , Groups.Update.class})
    private Integer gender;

}

測試如下:

原始碼地址

https://github.com/gf-huanchupk/SpringBootLearning

參考

https://www.ibm.com/developerworks/cn/java/j-lo-jsr303/index.html
https://docs.jboss.org/hibernate/validator/4.3/reference/zh-CN/pdf/hibernate_validator_reference.pdf




歡迎掃碼或微信搜尋公眾號《程式設計師果果》關注我,關注有驚喜~

相關文章