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
JSR
是Java Specification Requests
的縮寫,意思是Java規範提案
,JSR-303
Java EE 6
的一項子規範,叫作Bean Validation
,Hibernate Validator
是Bean 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
版: