使用 spring validation 完成數據後端校驗

前言

數據的校驗是交互式網站一個不可或缺的功能,前端的 js 校驗可以涵蓋大部分的校驗職責,如用戶名唯一性,生日格式,郵箱格式校驗等等常用的校驗。但是爲了避免用戶繞過瀏覽器,使用 http 工具直接向後端請求一些違法數據,服務端的數據校驗也是必要的,可以防止髒數據落到數據庫中,如果數據庫中出現一個非法的郵箱格式,也會讓運維人員頭疼不已。我在之前保險產品研發過程中,系統對數據校驗要求比較嚴格且追求可變性及效率,曾使用 drools 作爲規則引擎,兼任了校驗的功能。而在一般的應用,可以使用本文將要介紹的 validation 來對數據進行校驗。

簡述 JSR303/JSR-349,hibernate validation,spring validation 之間的關係。JSR303 是一項標準,JSR-349 是其的升級版本,添加了一些新特性,他們規定一些校驗規範即校驗註解,如 @Null,@NotNull,@Pattern,他們位於 javax.validation.constraints 包下,只提供規範不提供實現。而 hibernate validation 是對這個規範的實踐(不要將 hibernate 和數據庫 orm 框架聯繫在一起),他提供了相應的實現,並增加了一些其他校驗註解,如 @Email,@Length,@Range 等等,他們位於 org.hibernate.validator.constraints 包下。而萬能的 spring 爲了給開發者提供便捷,對 hibernate validation 進行了二次封裝,顯示校驗 validated bean 時,你可以使用 spring validation 或者 hibernate validation,而 spring validation 另一個特性,便是其在 springmvc 模塊中添加了自動校驗,並將校驗信息封裝進了特定的類中。這無疑便捷了我們的 web 開發。本文主要介紹在 springmvc 中自動校驗的機制。

引入依賴

我們使用 maven 構建 springboot 應用來進行 demo 演示。

1
2
3
4
5
6
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

我們只需要引入 spring-boot-starter-web 依賴即可,如果查看其子依賴,可以發現如下的依賴:

1
2
3
4
5
6
7
8
<dependency>
	<groupId>org.hibernate</groupId>
	<artifactId>hibernate-validator</artifactId>
</dependency>
<dependency>
	<groupId>com.fasterxml.jackson.core</groupId>
	<artifactId>jackson-databind</artifactId>
</dependency>

驗證了我之前的描述,web 模塊使用了 hibernate-validation,並且 databind 模塊也提供了相應的數據綁定功能。

構建啓動類

無需添加其他註解,一個典型的啓動類

1
2
3
4
5
6
7
@SpringBootApplication
public class ValidateApp {

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

 

創建需要被校驗的實體類

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Foo {    
    @NotBlank
    private String name;

    @Min(18)
    private Integer age;

    @Pattern(regexp = "^1(3|4|5|7|8)\\d{9}$",message = "手機號碼格式錯誤")
    @NotBlank(message = "手機號碼不能爲空")
    private String phone;

    @Email(message = "郵箱格式錯誤")
    private String email;
    
    //... getter setter

}

使用一些比較常用的校驗註解,還是比較淺顯易懂的,字段上的註解名稱即可推斷出校驗內容,每一個註解都包含了 message 字段,用於校驗失敗時作爲提示信息,特殊的校驗註解,如 Pattern(正則校驗),還可以自己添加正則表達式。

在 @Controller 中校驗數據

springmvc 爲我們提供了自動封裝表單參數的功能,一個添加了參數校驗的典型 controller 如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class FooController {

    @RequestMapping("/foo")
    public String foo(@Validated Foo foo <1>, BindingResult bindingResult <2>) {
        if(bindingResult.hasErrors()){
            for (FieldError fieldError : bindingResult.getFieldErrors()) {
                //...
            }
            return "fail";
        }
        return "success";
    }

}

值得注意的地方:

<1> 參數 Foo 前需要加上 @Validated 註解,表明需要 spring 對其進行校驗,而校驗的信息會存放到其後的 BindingResult 中。注意,必須相鄰,如果有多個參數需要校驗,形式可以如下。foo(@Validated Foo foo, BindingResult fooBindingResult ,@Validated Bar bar, BindingResult barBindingResult); 即一個校驗類對應一個校驗結果。

<2> 校驗結果會被自動填充,在 controller 中可以根據業務邏輯來決定具體的操作,如跳轉到錯誤頁面。

一個最基本的校驗就完成了,總結下框架已經提供了哪些校驗:
JSR 提供的校驗註解 :

1
2
3
4
5
6
7
8
9
10
11
12
13
@Null   被註釋的元素必須爲 null    
@NotNull    被註釋的元素必須不爲 null    
@AssertTrue     被註釋的元素必須爲 true    
@AssertFalse    被註釋的元素必須爲 false    
@Min(value)     被註釋的元素必須是一個數字,其值必須大於等於指定的最小值    
@Max(value)     被註釋的元素必須是一個數字,其值必須小於等於指定的最大值    
@DecimalMin(value)  被註釋的元素必須是一個數字,其值必須大於等於指定的最小值    
@DecimalMax(value)  被註釋的元素必須是一個數字,其值必須小於等於指定的最大值    
@Size(max=, min=)   被註釋的元素的大小必須在指定的範圍內    
@Digits (integer, fraction)     被註釋的元素必須是一個數字,其值必須在可接受的範圍內    
@Past   被註釋的元素必須是一個過去的日期    
@Future     被註釋的元素必須是一個將來的日期    
@Pattern(regex=,flag=)  被註釋的元素必須符合指定的正則表達式

 

Hibernate Validator 提供的校驗註解 

1
2
3
4
5
@NotBlank(message =)   驗證字符串非 null,且長度必須大於 0    
@Email  被註釋的元素必須是電子郵箱地址    
@Length(min=,max=)  被註釋的字符串的大小必須在指定的範圍內    
@NotEmpty   被註釋的字符串的必須非空    
@Range(min=,max=,message=)  被註釋的元素必須在合適的範圍內

校驗實驗

我們對上面實現的校驗入口進行一次測試請求:
訪問 http://localhost:8080/foo?name=xujingfeng&email=000&age=19 可以得到如下的 debug 信息:

這裏寫圖片描述這裏寫圖片描述

實驗告訴我們,校驗結果起了作用。並且,可以發現當發生多個錯誤,spring validation 不會在第一個錯誤發生後立即停止,而是繼續試錯,告訴我們所有的錯誤。debug 可以查看到更多豐富的錯誤信息,這些都是 spring validation 爲我們提供的便捷特性,基本適用於大多數場景。

你可能不滿足於簡單的校驗特性,下面進行一些補充。

分組校驗

如果同一個類,在不同的使用場景下有不同的校驗規則,那麼可以使用分組校驗。未成年人是不能喝酒的,而在其他場景下我們不做特殊的限制,這個需求如何體現同一個實體,不同的校驗規則呢?

改寫註解,添加分組:

1
2
3
4
5
6
7
8
Class Foo{
	@Min(value = 18,groups = {Adult.class})
	private Integer age;
	
	public interface Adult{}
	
	public interface Minor{}
}

這樣表明,只有在 Adult 分組下,18 歲的限制纔會起作用。

Controller 層改寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RequestMapping("/drink")
public String drink(@Validated({Foo.Adult.class}) Foo foo, BindingResult bindingResult) {
    if(bindingResult.hasErrors()){
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            //...
        }
        return "fail";
    }
    return "success";
}

@RequestMapping("/live")
public String live(@Validated Foo foo, BindingResult bindingResult) {
    if(bindingResult.hasErrors()){
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            //...
        }
        return "fail";
    }
    return "success";
}

drink 方法限定需要進行 Adult 校驗,而 live 方法則不做限制。

自定義校驗

業務需求總是比框架提供的這些簡單校驗要複雜的多,我們可以自定義校驗來滿足我們的需求。自定義 spring validation 非常簡單,主要分爲兩步。

1 自定義校驗註解
我們嘗試添加一個“字符串不能包含空格”的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {CannotHaveBlankValidator.class})<1>
public @interface CannotHaveBlank {

    // 默認錯誤消息
    String message() default "不能包含空格";

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

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

    // 指定多個時使用
    @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        CannotHaveBlank[] value();
    }

}

我們不需要關注太多東西,使用 spring validation 的原則便是便捷我們的開發,例如 payload,List ,groups,都可以忽略。

<1> 自定義註解中指定了這個註解真正的驗證者類。

2 編寫真正的校驗者類

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CannotHaveBlankValidator implements <1> ConstraintValidator<CannotHaveBlank, String> {

	@Override
    public void initialize(CannotHaveBlank constraintAnnotation) {
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context <2>) {
        //null 時不進行校驗
        if (value != null && value.contains(" ")) {
	        <3>
            // 獲取默認提示信息
            String defaultConstraintMessageTemplate = context.getDefaultConstraintMessageTemplate();
            System.out.println("default message :" + defaultConstraintMessageTemplate);
            // 禁用默認提示信息
            context.disableDefaultConstraintViolation();
            // 設置提示語
            context.buildConstraintViolationWithTemplate("can not contains blank").addConstraintViolation();
            return false;
        }
        return true;
    }
}

<1> 所有的驗證者都需要實現 ConstraintValidator 接口,它的接口也很形象,包含一個初始化事件方法,和一個判斷是否合法的方法。

1
2
3
4
public interface ConstraintValidator<A extends Annotation, T> {
	void initialize(A constraintAnnotation);
		boolean isValid(T value, ConstraintValidatorContext context);
}

<2> ConstraintValidatorContext 這個上下文包含了認證中所有的信息,我們可以利用這個上下文實現獲取默認錯誤提示信息,禁用錯誤提示信息,改寫錯誤提示信息等操作。

<3> 一些典型校驗操作,或許可以對你產生啓示作用。

值得注意的一點是,自定義註解可以用在 METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER 之上,ConstraintValidator 的第二個泛型參數 T,是需要被校驗的類型。

手動校驗

可能在某些場景下需要我們手動校驗,即使用校驗器對需要被校驗的實體發起 validate,同步獲得校驗結果。理論上我們既可以使用 Hibernate Validation 提供 Validator,也可以使用 Spring 對其的封裝。在 spring 構建的項目中,提倡使用經過 spring 封裝過後的方法,這裏兩種方法都介紹下:

Hibernate Validation

1
2
3
4
5
6
7
8
9
Foo foo = new Foo();
foo.setAge(22);
foo.setEmail("000");
ValidatorFactory vf = Validation.buildDefaultValidatorFactory();
Validator validator = vf.getValidator();
Set<ConstraintViolation<Foo>> set = validator.validate(foo);
for (ConstraintViolation<Foo> constraintViolation : set) {
    System.out.println(constraintViolation.getMessage());
}

由於依賴了 Hibernate Validation 框架,我們需要調用 Hibernate 相關的工廠方法來獲取 validator 實例,從而校驗。

在 spring framework 文檔的 Validation 相關章節,可以看到如下的描述:

Spring provides full support for the Bean Validation API. This includes convenient support for bootstrapping a JSR-303/JSR-349 Bean Validation provider as a Spring bean. This allows for a javax.validation.ValidatorFactory or javax.validation.Validator to be injected wherever validation is needed in your application. Use the LocalValidatorFactoryBean to configure a default Validator as a Spring bean:

bean id=”validator” class=”org.springframework.validation.beanvalidation.LocalValidatorFactoryBean”

The basic configuration above will trigger Bean Validation to initialize using its default bootstrap mechanism. A JSR-303/JSR-349 provider, such as Hibernate Validator, is expected to be present in the classpath and will be detected automatically.

上面這段話主要描述了 spring 對 validation 全面支持 JSR-303、JSR-349 的標準,並且封裝了 LocalValidatorFactoryBean 作爲 validator 的實現。值得一提的是,這個類的責任其實是非常重大的,他兼容了 spring 的 validation 體系和 hibernate 的 validation 體系,也可以被開發者直接調用,代替上述的從工廠方法中獲取的 hibernate validator。由於我們使用了 springboot,會觸發 web 模塊的自動配置,LocalValidatorFactoryBean 已經成爲了 Validator 的默認實現,使用時只需要自動注入即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
Validator globalValidator; <1>

@RequestMapping("/validate")
public String validate() {
    Foo foo = new Foo();
    foo.setAge(22);
    foo.setEmail("000");

    Set<ConstraintViolation<Foo>> set = globalValidator.validate(foo);<2>
    for (ConstraintViolation<Foo> constraintViolation : set) {
        System.out.println(constraintViolation.getMessage());
    }

    return "success";
}

<1> 真正使用過 Validator 接口的讀者會發現有兩個接口,一個是位於 javax.validation 包下,另一個位於 org.springframework.validation 包下,注意我們這裏使用的是前者 javax.validation,後者是 spring 自己內置的校驗接口,LocalValidatorFactoryBean 同時實現了這兩個接口。

<2> 此處校驗接口最終的實現類便是 LocalValidatorFactoryBean。

基於方法校驗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@Validated <1>
public class BarController {

    @RequestMapping("/bar")
    public @NotBlank <2> String bar(@Min(18) Integer age <3>) {
        System.out.println("age :" + age);
        return "";
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public Map handleConstraintViolationException(ConstraintViolationException cve){
        Set<ConstraintViolation<?>> cves = cve.getConstraintViolations();<4>
        for (ConstraintViolation<?> constraintViolation : cves) {
            System.out.println(constraintViolation.getMessage());
        }
        Map map = new HashMap();
        map.put("errorCode",500);
        return map;
    }

}

<1> 爲類添加 @Validated 註解

<2> <3> 校驗方法的返回值和入參

<4> 添加一個異常處理器,可以獲得沒有通過校驗的屬性相關信息

基於方法的校驗,個人不推薦使用,感覺和項目結合的不是很好。

使用校驗框架的一些想法

理論上 spring validation 可以實現很多複雜的校驗,你甚至可以使你的 Validator 獲取 ApplicationContext,獲取 spring 容器中所有的資源,進行諸如數據庫校驗,注入其他校驗工具,完成組合校驗(如前後密碼一致)等等操作,但是尋求一個易用性和封裝複雜性之間的平衡點是我們作爲工具使用者應該考慮的,我推崇的方式,是僅僅使用自帶的註解和自定義註解,完成一些簡單的,可複用的校驗。而對於複雜的校驗,則包含在業務代碼之中,畢竟如用戶名是否存在這樣的校驗,僅僅依靠數據庫查詢還不夠,爲了避免併發問題,還是得加上唯一索引之類的額外工作,不是嗎?

 

更多免費資料可點贊+關注後私信“學習”免費獲取

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