Hibernate Validator校驗引數全攻略

碼農小胖哥發表於2020-07-31

1. 前言

資料欄位一般都要遵循業務要求和資料庫設計,所以後端的引數校驗是必須的,應用程式必須通過某種手段來確保輸入進來的資料從語義上來講是正確的。

2. 資料校驗的痛點

為了保證資料語義的正確,我們需要進行大量的判斷來處理驗證邏輯。而且專案的分層也會造成一些重複的校驗,產生大量與業務無關的程式碼。不利於程式碼的維護,增加了開發人員的工作量。

3. JSR 303校驗規範及其實現

為了解決上面的痛點,將驗證邏輯與相應的領域模型進行繫結是十分有必要的。為此產生了JSR 303 – Bean Validation 規範Hibernate ValidatorJSR-303的參考實現,它提供了JSR 303規範中所有的約束(constraint)的實現,同時也增加了一些擴充套件。

Hibernate Validator 提供的常用的約束註解

約束註解 詳細資訊
@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(value) 被註釋的元素必須符合指定的正規表示式
@Email 被註釋的元素必須是電子郵箱地址
@Length 被註釋的字串的大小必須在指定的範圍內
@NotEmpty 被註釋的字串的必須非空
@Range 被註釋的元素必須在合適的範圍內

4. 驗證註解的使用

Spring Boot開發中使用Hibernate Validator是非常容易的,引入下面的starter就可以了:

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

一種可以實現介面來定製Validator,一種是使用約束註解。胖哥覺得註解可以滿足絕大部分的需求,所以建議使用註解來進行資料校驗。而且註解更加靈活,控制的粒度也更加細。接下來我們來學習如何使用註解進行資料校驗。

4.1 約束註解的基本使用

我們對需要校驗的方法入參進行註解約束標記,例子如下:

@Data
public class Student {

    @NotBlank(message = "姓名必須填")
    private String name;
    @NotNull(message = "年齡必須填寫")
    @Range(min = 1,max =50, message = "年齡取值範圍1-50")
    private Integer age;
    @NotEmpty(message = "成績必填")
    private List<Double> scores;
}

POST請求

然後定義一個POST請求的Spring MVC介面:

@RestController
@RequestMapping("/student")
public class StudentController {

    
    @PostMapping("/add")
    public Rest<?> addStudent(@Valid @RequestBody Student student) {
        return RestBody.okData(student);
    }
}   

通過對addStudent方法入參新增@Valid來啟用引數校驗。當使用下面資料進行請求將會丟擲MethodArgumentNotValidException異常,提示age範圍超出1-50

POST /student/add HTTP/1.1
Host: localhost:8888
Content-Type: application/json

{
    "name": "felord.cn",
    "age": 77,
    "scores": [
        55
    ]
}

GET請求

如法炮製,我們定義一個GET請求的介面:

@GetMapping("/get")
public Rest<?> getStudent(@Valid Student student) {
    return RestBody.okData(student);
}

使用下面的請求可以正確對學生分數scores進行了校驗,但是丟擲的並不是MethodArgumentNotValidException異常,而是BindException異常。這和使用@RequestBody註解有關係,這對我們後面的統一處理非常十分重要。

GET /student/get?name=felord.cn&age=12 HTTP/1.1
Host: localhost:8888

自定義註解

可能有些同學注意到上面的年齡我進行了這樣的標記:

@NotNull(message = "年齡必須填寫")
@Range(min = 1,max =50, message = "年齡取值範圍1-50")
private Integer age;

這是因為@Range不會去校驗為空的情況,它只處理非空的時候是否符合範圍約束。所以要用多個註解來約束。如果我們某些場景需要重複的捆綁多個註解來使用時,可以使用自定義註解將它們封裝起來組合使用,下面這個註解就是將@NotNull@Range進行了組合,你可以仿一個出來用用看。

import org.hibernate.validator.constraints.Range;

import javax.validation.Constraint;
import javax.validation.Payload;
import javax.validation.ReportAsSingleViolation;
import javax.validation.constraints.NotNull;
import javax.validation.constraintvalidation.SupportedValidationTarget;
import javax.validation.constraintvalidation.ValidationTarget;
import java.lang.annotation.*;

/**
 * @author a
 * @since 17:31
 **/
@Constraint(
        validatedBy = {}
)
@SupportedValidationTarget({ValidationTarget.ANNOTATED_ELEMENT})
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, 
        ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, 
        ElementType.PARAMETER, ElementType.TYPE_USE})
@NotNull
@Range(min = 1, max = 50)
@Documented
@ReportAsSingleViolation
public @interface Age {
    // message 必須有
    String message() default "年齡必須填寫,且範圍為 1-50 ";

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

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

還有一種情況,我們在後臺定義了列舉值來進行狀態的流轉,也是需要校驗的,比如我們定義了顏色列舉:

public enum Colors {

    RED, YELLOW, BLUE

}

我們希望入參不能超出Colors的範圍["RED", "YELLOW", "BLUE"],這就需要實現ConstraintValidator<A extends Annotation, T>介面來定義一個顏色約束了,其中泛型A為自定義的約束註解,泛型T為入參的型別,這裡使用字串,然後我們的實現如下:

/**
 * @author felord.cn
 * @since 17:57
 **/
public class ColorConstraintValidator implements ConstraintValidator<Color, String> {
    private static final Set<String> COLOR_CONSTRAINTS = new HashSet<>();

    @Override
    public void initialize(Color constraintAnnotation) {
        Colors[] value = constraintAnnotation.value();
        List<String> list = Arrays.stream(value)
                .map(Enum::name)
                .collect(Collectors.toList());
        COLOR_CONSTRAINTS.addAll(list);

    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return COLOR_CONSTRAINTS.contains(value);
    }
}

然後宣告對應的約束註解Color,需要在元註解@Constraint中指明使用上面定義好的處理類ColorConstraintValidator進行校驗。

/**
 * @author felord.cn
 * @since 17:55
 **/
@Constraint(validatedBy = ColorConstraintValidator.class)
@Documented
@Target({ElementType.METHOD, ElementType.FIELD,
        ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR,
        ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Color {
    // 錯誤提示資訊
    String message() default "顏色不符合規格";

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

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

    // 約束的型別
    Colors[] value();
}

然後我們來試一下,先對引數進行約束:

@Data
public class Param {
    @Color({Colors.BLUE,Colors.YELLOW})
   private String color;
}

介面跟上面幾個一樣,呼叫下面的介面將丟擲BindException異常:

GET /student/color?color=CAY HTTP/1.1
Host: localhost:8888

當我們把引數color賦值為BLUE或者YELLOW後,能夠成功得到響應。

4.2 常見問題

在實際使用起來我們會遇到一些問題,這裡總結了一些常見的問題和處理方式。

檢驗基礎型別不生效的問題

上面為了校驗顏色我們宣告瞭一個Param物件來包裝唯一的字串引數color,為什麼直接使用下面的方式定義呢?

@GetMapping("/color")
public Rest<?> color(@Valid @Color({Colors.BLUE,Colors.YELLOW}) String color) {
    return RestBody.okData(color);
}

或者使用路徑變數:

@GetMapping("/rest/{color}")
public Rest<?> rest(@Valid @Color({Colors.BLUE, Colors.YELLOW}) @PathVariable String color) {
    return RestBody.okData(color);
}

上面兩種方式是不會生效的。不信你可以試一試,起碼在Spring Boot 2.3.1.RELEASE是不會直接生效的。

使以上兩種生效的方法是在類上新增@Validated註解。注意一定要新增到方法所在的類上才行。這時候會丟擲ConstraintViolationException異常。

集合型別引數中的元素不生效的問題

就像下面的寫法,方法的引數為集合時,如何檢驗元素的約束呢?

/**
 * 集合型別引數元素.
 *
 * @param student the student
 * @return the rest
 */
@PostMapping("/batchadd")
public Rest<?> batchAddStudent(@Valid @RequestBody List<Student> student) {
    return RestBody.okData(student);
}

同樣是在類上新增@Validated註解。注意一定要新增到方法所在的類上才行。這時候會丟擲ConstraintViolationException異常。

巢狀校驗不生效

巢狀的結構如何校驗呢?打個比方,如果我們在學生類Student中新增了其所屬的學校資訊School並希望對School的屬性進行校驗。

@Data
public class Student {

    @NotBlank(message = "姓名必須填")
    private String name;
    @Age
    private Integer age;
    @NotEmpty(message = "成績必填")
    private List<Double> scores;
    @NotNull(message = "學校不能為空")
    private School school;
}


@Data
public class School {
    @NotBlank(message = "學校名稱不能為空")
    private String name;
    @Min(value = 0,message ="校齡大於0" )
    private Integer age;
}

GET請求時正常校驗了School的屬性,但是POST請求卻無法對School的屬性進行校驗。這時我們只需要在該屬性上加上@Valid註解即可。

@Data
public class Student {

    @NotBlank(message = "姓名必須填")
    private String name;
    @Age
    private Integer age;
    @NotEmpty(message = "成績必填")
    private List<Double> scores;
    @Valid
    @NotNull(message = "學校不能為空")
    private School school;
}

每加一層巢狀都需要加一層@Valid註解。通常在校驗物件屬性時,@NotNull@NotEmpty@Valid配合才能起到校驗效果。

如果你有其它問題可以通過felord.cn聯絡到我探討。

5. 總結

通過校驗框架我們可以專心於業務開發,本文對Hibernate Validator的使用和一些常見問題進行了梳理。我們可以通過Spring Boot統一異常處理來解決引數校驗的異常資訊的提示問題。具體可以通過關注:碼農小胖哥 回覆 valid獲取相關DEMO

關注公眾號:Felordcn 獲取更多資訊

個人部落格:https://felord.cn

相關文章