1. 程式人生 > 實用技巧 >Spring Boot demo系列(四):Spring Web+Validation

Spring Boot demo系列(四):Spring Web+Validation

1 概述

本文主要講述瞭如何使用Hibernate Validator以及@Valid/@Validate註解。

2 校驗

對於一個普通的Spring Boot應用,經常可以在業務層看到以下類似的操作:

if(id == null)
{...}
if(username == null)
{...}
if(password == null)
{...}

這是很正常的,但是會顯得程式碼很繁瑣,一個更好的做法就是使用Hibernate Validator

3 Hibernate Validator

JSRJava Specification Requests的縮寫,意思是Java規範提案JSR-303

Java EE 6的一項子規範,叫作Bean ValidationHibernate ValidatorBean Validator的參考實現。其中JSR-303內建constraint如下:

  • @Null:被註解元素必須為null
  • @NotNull:必須不為null
  • @AssertTrue/@AssertFalse:必須為true/false
  • @Min(value)/@Max(value):指定最小值/最大值(可以相等)
  • @DecimalMin(value)/DecimalMax(value):指定最小值/最大值(不能相等)
  • @Size(min,max):大小在給定範圍
  • @Digits(integer,fraction)
    :將字串轉為浮點數,並且規定整數位數最大integer位,小數位數最大fraction
  • @Past:必須是一個過去日期
  • @Future:必須是將來日期
  • @Pattern:必須符合正則表示式

其中Hibernate Validator新增的constraint如下:

  • @Email:必須符合郵箱格式
  • @Length(min,max):字串長度範圍
  • @Range:數字在指定範圍

而在Spring中,對Hibernate Validator進行了二次封裝,添加了自動校驗並且可以把校驗資訊封裝進特定的BindingResult中。

4 基本使用

註解直接在實體類的對應欄位加上即可:

@Setter
@Getter
public class User {
    @NotBlack(message = "郵箱不能為空")
    @Email(message = "郵箱非法")
    private String email;
    @NotBlack(message = "電話不能為空")
    private String phone;
}

控制層:

@CrossOrigin(value = "http://localhost:3000")
@RestController
public class TestController {
    @PostMapping("/test")
    public boolean test(@RequestBody @Valid User user)
    {
        return true;
    }
}

測試:

可以看到把phone欄位留空或者使用非法郵箱格式時直接丟擲異常。

5 異常處理

前面說過校驗出錯會把異常放進BindingResult中,具體的處理方法就是加上對應引數即可,控制層修改如下:

@PostMapping("/test")
public boolean test(@RequestBody @Valid User user, BindingResult result)
{
    if(result.hasErrors())
        result.getAllErrors().forEach(System.out::println);
    return true;
}

可以通過getAllErrors獲取所有的錯誤,這樣就可以對具體錯誤進行處理了。

6 快速失敗模式

Hibernate Validator有兩種校驗模式:

  • 普通模式:預設,檢驗所有屬性,然後返回所有驗證失敗資訊
  • 快速失敗模式:只要有一個驗證失敗便返回

使用快速失敗模式需要通過HiberateValidateConfiguration以及ValidateFactory建立Validator,並且使用Validator.validate手動校驗,首先可以新增一個生成Validator的類:

import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Configuration;

import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;

@Configuration
public class FailFastValidator {
    private final Validator validator;
    public FailFastValidator()
    {
        validator = Validation
        .byProvider(HibernateValidator.class)
        .configure()
        .failFast(true)
        .buildValidatorFactory()
        .getValidator();
    }

    public Set<ConstraintViolation<User>> validate(User user)
    {
        return validator.validate(user);
    }
}

接著修改控制層,去掉User上的@Valid,同時注入validator進行手動校驗:

import com.example.demo.entity.User;
import com.example.demo.failfast.FailFastValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.ConstraintViolation;
import java.util.Set;

@CrossOrigin(value = "http://localhost:3000")
@RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
    private final FailFastValidator validator;
    @PostMapping("/test")
    public boolean test(@RequestBody User user)
    {
        Set<ConstraintViolation<User>> message = validator.validate(user);
        message.forEach(System.out::println);
        return true;
    }
}

這樣一旦校驗失敗便會返回,而不是校驗完所有的欄位記錄所有錯誤資訊再返回。

7 @Valid@Validated

@Valid位於javax.validation下,而@Validated位於org.springframework.validation.annotation下,是@Valid的一次封裝,在@Valid的基礎上,增加了分組以及組序列的功能,下面分別進行介紹。

7.1 分組

當不同的情況下需要不同的校驗方式時,可以使用分組功能,比如在某種情況下需要註冊時不需要校驗郵箱,而修改資訊的時候需要校驗郵箱,則實體類可以如下設計:

@Setter
@Getter
public class User {
    @NotBlank(message = "郵箱不能為空",groups = GroupB.class)
    @Email(message = "郵箱非法",groups = GroupB.class)
    private String email;
    @NotBlank(message = "電話不能為空",groups = {GroupA.class,GroupB.class})
    private String phone;

    public interface GroupA{}
    public interface GroupB{}
}

接著修改控制層,並使用@Validate代替原來的@Valid

public class TestController {
    @PostMapping("/test")
    public boolean test(@RequestBody @Validated(User.GroupA.class) User user)
    {
        return true;
    }
}

GroupA的情況下,只校驗電話,測試如下:

而如果修改為GroupB

public boolean test(@RequestBody @Validated(User.GroupB.class) User user)

這樣就郵箱與電話都校驗:

7.2 組序列

預設情況下,校驗是無序的,也就是說,對於下面的實體類:

public class User {
    @NotBlank(message = "郵箱不能為空")
    @Email(message = "郵箱非法")
    private String email;
    @NotBlank(message = "電話不能為空")
    private String phone;
}

先校驗哪一個並沒有固定順序,修改控制層如下,返回錯誤資訊:

@PostMapping("/test")
public String test(@RequestBody @Validated User user, BindingResult result)
{
    for (ObjectError allError : result.getAllErrors()) {
        return allError.getDefaultMessage();
    }
    return "true";
}

可以看到兩次測試的結果不同:

因為順序不固定,而如果指定了順序:

public class User {
    @NotBlank(message = "郵箱不能為空",groups = First.class)
    @Email(message = "郵箱非法",groups = First.class)
    private String email;
    @NotBlank(message = "電話不能為空",groups = Third.class)
    private String phone;

    public interface First{}
    public interface Second{}
    public interface Third{}
    @GroupSequence({First.class,Second.class,Third.class})
    public interface Group{}
}

同時控制層指定順序:

public String test(@RequestBody @Validated(User.Group.class) User user, BindingResult result)

這樣就一定會先校驗First,也就是先校驗郵箱是否為空。

8 自定義註解

儘管使用上面的各種註解已經能解決很多情況了,但是對於一些特定的情況,需要一些特別的校驗,而自帶的註解不能滿足,這時就需要自定義註解了,比如上面的電話欄位,國內的是11位的,而且需要符合某些條件(比如預設區號+86等),下面就自定義一個專門用於手機號碼的註解:

@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ElementType.FIELD,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
    String message() default "請使用合法的手機號碼";
    Class<?> [] groups() default {};
    Class<? extends Payload> [] payload() default {};
}

同時定義一個驗證類:

public class PhoneValidator implements ConstraintValidator<Phone,String> {
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        if(s.length() != 11)
            return false;
        return Pattern.matches("^((17[0-9])|(14[0-9])|(13[0-9])|(15[^4,\\D])|(18[0,5-9]))\\d{8}$",s);
    }
}

接著修改實體類,加上註解即可:

@Phone
@NotBlank(message = "電話不能為空")
private String phone;

測試如下,可以看到雖然是11位了,但是格式非法,因此返回相應資訊:

9 來點AOP

預設情況下Hibernate Validator不是快速失敗模式的,但是如果配成快速失敗模式就不能用@Validate了,需要手動例項化一個Validator,這是一種很麻煩的操作,雖然說可以利用組序列“偽裝”成一個快速失敗模式,但是有沒有更好的解決辦法呢?

有!

就是。。。

自己動手使用AOP實現校驗。

9.1 依賴

AOP這種高階的東西當然是用別人的輪子啊:

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

9.2 驗證註解

首先自定義一個驗證註解,這個註解的作用類似@Validate

public @interface UserValidate {}

9.3 欄位驗證

自定義一些類似@NotEmpty等的註解:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface MyEmail {
    String message() default "郵箱不能為空,且需要一個合法的郵箱";
    int order();
}

@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyPhone {
    String message() default "電話不能為空,且需要一個合法的電話";
    int order();
}

9.4 定義驗證器

@Aspect
@Component
public class UserValidator {
    @Pointcut("@annotation(com.example.demo.aop.UserValidate)")
    public void userValidate(){}

    @Before("userValidate()")
    public void validate(JoinPoint point) throws EmailException, PhoneException, IllegalAccessException {
        User user = (User)point.getArgs()[0];
        TreeMap<Integer,Annotation> treeMap = new TreeMap<>();
        HashMap<Integer,Object> allFields = new HashMap<>();
        for (Field field : user.getClass().getDeclaredFields()) {
            field.setAccessible(true);
            for (Annotation annotation : field.getAnnotations()) {
                if(annotation.annotationType() == MyEmail.class)
                {
                    treeMap.put(((MyEmail)annotation).order(),annotation);
                    allFields.put(((MyEmail)annotation).order(),field.get(user));
                }
                else if(annotation.annotationType() == MyPhone.class)
                {
                    treeMap.put(((MyPhone)annotation).order(),annotation);
                    allFields.put(((MyPhone)annotation).order(),field.get(user));
                }
            }
        }
        for (Map.Entry<Integer, Annotation> entry : treeMap.entrySet()) {
            Class<? extends Annotation> type = entry.getValue().annotationType();
            if(type == MyEmail.class)
            {
                validateEmail((String)allFields.get(entry.getKey()));
            }
            else if(type == MyPhone.class)
            {
                validatePhone((String)allFields.get(entry.getKey()));
            }
        }
    }

    private static void validateEmail(String s) throws EmailException
    {
        throw new EmailException();
    }

    private static void validatePhone(String s) throws PhoneException
    {
        throw new PhoneException();
    }
}

這個是實現校驗的核心,首先定義一個切點:

@Pointcut("@annotation(com.example.demo.aop.UserValidate)")
public void userValidate(){}

該切點應用在註解@UserValidate上,接著定義驗證方法validate,首先通過切點獲取其中的引數以及引數中的註解,並且模擬了組序列,先使用TreeMap進行排序,最後針對遍歷該TreeMap,對不同的註解分別呼叫不同的方法校驗。

實體類簡單定義順序即可:

public class User {
    @MyEmail(order = 2)
    private String email;
    @MyPhone(order = 1)
    private String phone;
}

控制類中的註解定義在方法上:

@PostMapping("/test")
@UserValidate
public String test(@RequestBody User user)
{
    return "true";
}

這樣就自定義實現了一個簡單的JSR-303了。

當然該方法還有很多的不足,比如需要配合全域性異常處理,不然的話會直接丟擲異常:

前端也是直接返回異常:

一般情況下還是推薦使用Hibernate Validator,應對常規情況足夠了。

10 參考原始碼

Java版:

Kotlin版: