簡介
- 本篇介紹一個
JSR303中校驗注解的groups屬性。
背景
- 關於
groups的了解之路,來源於一個朋友的解決思路。
- 項目實際需要根據國家來區分用戶,進而對用戶資料進行驗證。對來自不同國家的用戶來說,其擁有不一樣的驗證字段,比如中國要求用戶必須提供身份證信息,而日本可能不需要提供身份證信息。
- 在這個要求的基礎上,無論如何進行驗證操作,首先需要判斷的都是該用戶來自於哪個國家,進而才能根據所屬的國家信息,指定相關的驗證操作。
- 即通俗地講,除非你在一個單獨的
Controller中使用@ModelAttribute注解的方法判斷用戶來自於哪個國家,並為每個國家編寫特定的實體類,才能在Controller的驗證方法入參中使用@Valid或@Validated注解進行一步到位的驗證操作。
- 稍作考慮,則目前的解決方案可能有以下兩種:
- 為來自於不同國家的用戶建立不同的用於記錄該國家用戶信息的
Bean對象,在這些Bean對象中使用驗證注解注釋字段;
- 將用戶信息集中在同一個
Bean對象中,使用驗證類提供的groups屬性進行驗證分組。
- 關於第一種方式,大部分的人應該都知道該如何編寫,因此本篇將考慮第二種方式。
- 在使用這種方式前,需要首先了解一下什么是標記接口
Java Marker Interface。
標記接口
- 什么是
Java Marker Interface標記接口呢?類似於Serializable,它就是JDK中的一個標記接口。
- 對於標記接口來說,需要明確這不是
Java語言所特有的,而是計算機科學中一種通用的設計理念。
- 程序往往可以通過判斷對象是否實現了某些標記接口,而判定該對象是否具有某些特征,使程序編寫更為靈活。
- 標記接口不具有任何的字段或方法,是一個空接口。
groups屬性
- 在介紹
JSR303時,細心的人一定會發現一個細節,所有的驗證注解中都存在兩個屬性:groups和payload。
- 以下為
@NotNull注解的源碼:
- 由於源碼貼到此處的
markdown中會出現錯誤的代碼顏色,這里只貼截圖不貼源碼了。

- 本篇不關注
payload的用法,可以發現@NotNull中的確包含了groups屬性。
groups屬性用作分組校驗,在使用Validator對實體類進行校驗時,可以傳入的參數不僅僅是實體類對象。
- 結合參考
Validator類中提供的validate()方法的源碼:
/**
* Validates all constraints on {@code object}.
*
* @param object object to validate
* @param groups the group or list of groups targeted for validation (defaults to
* {@link Default})
* @param <T> the type of the object to validate
* @return constraint violations or an empty set if none
* @throws IllegalArgumentException if object is {@code null}
* or if {@code null} is passed to the varargs groups
* @throws ValidationException if a non recoverable error happens
* during the validation process
*/
<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);
- 可以從源碼中清晰地看到,
validate可以接收Class對象作為額外的參數,而這個Class對象指的就是groups。
- 程序在對
object進行校驗前,會首先根據groups信息提前篩選出需要進行校驗的字段,然后再對object進行校驗操作。
- 即
groups所要完成的使命,就是標記該驗證類注解所在字段的分組詳情。
簡單案例
- 在對
groups進行初步剖析后,可能仍然不太明白其中的工作原理。
- 考慮問題:假如學校有一個人員信息類
Person,其中記錄着學生Student和老師Teacher的信息,但對於學生來說,需要提供名字、學號和班級的信息,而對於老師來說只需要提供名字信息即可。不考慮現實合理性問題,此時如何編寫Person類?
- 無疑在
Person類中,我們需要添加驗證類注解,以確保字段的合法性,同時通過注解的groups屬性,將驗證字段分為兩組,一組為Student,另一組為Teacher;另一方面,在驗證環節需要告訴驗證器此時驗證的對象是Student還是Teacher。
- 標記接口
Student和Teacher如下:
package cn.dylanphang.mark;
public interface Student {
}
package cn.dylanphang.mark;
public interface Teacher {
}
package cn.dylanphang.pojo;
import cn.dylanphang.mark.Student;
import cn.dylanphang.mark.Teacher;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* @author dylan
*/
@Data
public class Person {
@NotBlank(groups = {Student.class, Teacher.class})
private String name;
@NotBlank(groups = {Student.class})
private String className;
@NotBlank(groups = {Student.class})
private String studentNo;
}
- 實體類
Person的字段上,使用groups進行分組,groups所接收的參數為Class<?>[]。
- 對應地,在進行驗證時,需要提前判定需要驗證的數據的分組情況。
- 在此基礎上,編寫測試類如下:
package cn.dylanphang.controller;
import cn.dylanphang.mark.Student;
import cn.dylanphang.mark.Teacher;
import cn.dylanphang.pojo.Person;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
/**
* @author dylan
*/
public class PersonValidateTest {
private Person person;
private Validator validator;
@Before
public void init() {
this.person = new Person();
final ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
this.validator = validatorFactory.getValidator();
}
@Test
public void testStudent() {
this.person.setName("dylan");
this.person.setClassName("anyClass");
this.person.setStudentNo("11250401128");
final Set<ConstraintViolation<Person>> validate = this.validator.validate(this.person, Student.class);
Assert.assertEquals(0, validate.size());
}
@Test
public void testTeacher() {
this.person.setName("kevin");
final Set<ConstraintViolation<Person>> validate = this.validator.validate(this.person, Teacher.class);
Assert.assertEquals(0, validate.size());
}
@Test
public void testEmpty() {
final Set<ConstraintViolation<Person>> validateA = this.validator.validate(this.person, Student.class);
Assert.assertEquals(3, validateA.size());
final Set<ConstraintViolation<Person>> validateB = this.validator.validate(this.person, Teacher.class);
Assert.assertEquals(1, validateB.size());
}
/**
* 值得注意,此時Person對象不為null,但其中的各項字段均為null。由於沒有指定groups,對this.person來說,等於沒有任何需要驗證的字段。
*/
@Test
public void testNoGroups() {
final Set<ConstraintViolation<Person>> validate = this.validator.validate(this.person);
Assert.assertEquals(0, validate.size());
}
}

- 值得注意的是,當實體類中的所有字段均進行分組操作后,使用
validate方法進行驗證時,如果不傳入相關的分組信息,則表明當前傳入的對象不需要進行驗證。
- 即傳入對象中所有的字段均可以為
null,此時驗證也能通過。那么如何避免這種情況呢?其實在此前的validate()源碼中就已經出現了答案。我們再看一次validate()的源碼:
/**
* Validates all constraints on {@code object}.
*
* @param object object to validate
* @param groups the group or list of groups targeted for validation (defaults to
* {@link Default})
* @param <T> the type of the object to validate
* @return constraint violations or an empty set if none
* @throws IllegalArgumentException if object is {@code null}
* or if {@code null} is passed to the varargs groups
* @throws ValidationException if a non recoverable error happens
* during the validation process
*/
<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);
- 在注釋中,可以發現這樣一行信息:
@param groups the group or list of groups targeted for validation (defaults to{@link Default})
- 即在分組信息屬性
groups沒有值的時候,它具有默認值Default.class。在此前沒有加入分組信息,使用validate()進行校驗時,程序都會自動地添加一個默認的分組Default.class。
- 在知道了原理之后,只需要稍微修改
Person實體類中的groups屬性:
package cn.dylanphang.pojo;
import cn.dylanphang.mark.Student;
import cn.dylanphang.mark.Teacher;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* @author dylan
*/
@Data
public class Person {
@NotBlank(groups = {Student.class, Teacher.class, Default.class})
private String name;
@NotBlank(groups = {Student.class, Default.class})
private String className;
@NotBlank(groups = {Student.class, Default.class})
private String studentNo;
}
@Test
public void testNoGroups() {
final Set<ConstraintViolation<Person>> validate = this.validator.validate(this.person);
Assert.assertEquals(3, validate.size());
}

- 測試通過。
- 當存在必要的其他分組信息時,需要在分組屬性
groups中添加Default.class以確保在validate()不提供任何標記接口的情況下,也會對當前傳入對象的字段進行合法性校驗。此時對象字段所屬的校驗組別為Default.class。
案例編寫
- 對標記接口異或
groups用法都了解后,可以開始針對開篇的案例進行代碼的編寫。
- 對於第一種解決方案不再多做贅述。本篇主要為了展示驗證類注解中
groups屬性用法,因此案例編寫將針對第二種解決方案。
- 假設此時有中國
China、日本Japan和美國USA三個國家的用戶的信息需要進行校驗,那么首先應該具備三個標記接口,這三個標記接口均繼承了名為Country的標記接口。(提示:接口是無法實現接口的喲。)
Country標記接口:
package cn.dylanphang.mark.supers;
public interface Country {
}
package cn.dylanphang.mark.supers;
public interface China extends Country {
}
package cn.dylanphang.mark.supers;
public interface Japan extends Country {
}
package cn.dylanphang.mark.supers;
public interface USA extends Country {
}
- 標記接口編寫完畢后,可以編寫用戶存放用戶信息的
PersonInfo類,同時在驗證注解中列舉分組信息:
package cn.dylanphang.pojo;
import cn.dylanphang.mark.China;
import cn.dylanphang.mark.Japan;
import cn.dylanphang.mark.USA;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
/**
* 測試標記接口分組。
* 即使某一個國家提供了額外的驗證信息,數據庫也不會寫入,只要必要信息提供即可,也只會寫入必要的數據到MySQL中。
*
* @author dylan
*/
@Data
public class PersonInfo {
@NotBlank(message = "名字不能為空。", groups = {China.class, Japan.class, USA.class, Default.class})
@Length(min = 1, max = 16, message = "名字最大長度不可超過16.", groups = {China.class, Japan.class, USA.class, Default.class})
private String name;
@NotBlank(message = "身份編號不能為空。", groups = {China.class, USA.class, Default.class})
@Length(min = 18, max = 18, message = "China身份編號只能是18位的。", groups = {China.class, Default.class})
@Length(min = 12, max = 12, message = "USA身份編號只能是12位的。", groups = {USA.class})
private String id;
@Range(min = 0, max = 99, message = "非法年齡!", groups = {China.class, Japan.class, Default.class})
private Integer age;
@NotBlank(message = "興趣沒有填,快寫!", groups = {Japan.class})
private String hobby;
}
- 從驗證類注解中,可以看出不同國家的用戶,所需要提供的信息是不同的:
China.class:用戶需提供1~16位的用戶名name、18位的身份證號id、0~99之間的年齡age,無需提供愛好;
Japan.class:用戶需提供1~16位的用戶名name、0~99之間的年齡age、不為空的愛好,無需提供身份證號;
USA.class:用戶需提供1~16位的用戶名name、12位的身份證號id、0~99之間的年齡age,無需提供愛好;
- 以上代碼中,
Default.class與China.class均被置於同一個groups中。如果validate()默認不提供校驗分組時,默認需要校驗的是分組則為Default.class的字段,即等同於校驗分組為China.class的字段。
- 除了設定默認校驗情況外,也可以不使用
Default.class進行分組標記。
- 因為程序代碼總是由開發人員進行編寫的,不會說出現
validate()要求groups信息時,編寫代碼卻不提供groups的情況。
- 甚至一般情況下,開發人員會強制使用
validate()時必須攜帶groups信息。此時可以使用工具類包裝validate()方法,提供一個必須攜帶groups信息的驗證方法以供開發人員使用。
- 為了對驗證類是否生效進行測試,此處提供了一個
ValidateInfoController,進一步模擬當需要驗證的參數來源於提交的數據時,應該如何編寫:
package cn.dylanphang.controller;
import cn.dylanphang.mark.supers.Country;
import cn.dylanphang.pojo.PersonInfo;
import cn.dylanphang.util.CountryUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
/**
* @author dylan
*/
@Controller
@RestController
public class ValidateInfoController {
@RequestMapping("/validate/{country}")
public boolean validate(@PathVariable("country") String country, PersonInfo person) {
final Class<? extends Country> countryClass = CountryUtils.getCountry(country);
if (countryClass == null) {
return false;
}
final ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
final Validator validator = validatorFactory.getValidator();
final Set<ConstraintViolation<PersonInfo>> validate = validator.validate(person, countryClass);
return validate.size() == 0;
}
}
- 其中
CountryUtils用於根據country的字符串數據,獲取相應的標記接口的類對象:
package cn.dylanphang.util;
import cn.dylanphang.mark.China;
import cn.dylanphang.mark.Japan;
import cn.dylanphang.mark.USA;
import cn.dylanphang.mark.supers.Country;
import java.util.HashMap;
/**
* @author dylan
*/
public class CountryUtils {
private static final HashMap<String, Class<? extends Country>> HASH_MAP;
static {
HASH_MAP = new HashMap<>();
HASH_MAP.put("CHINA", China.class);
HASH_MAP.put("JAPAN", Japan.class);
HASH_MAP.put("USA", USA.class);
}
public static Class<? extends Country> getCountry(String country) {
return country == null ? null : HASH_MAP.get(country.toUpperCase());
}
}
package cn.dylanphang.controller;
import cn.dylanphang.pojo.PersonInfo;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class ValidateInfoControllerTest {
private ValidateInfoController validateInfoController;
private PersonInfo person;
@Before
public void init() {
// *.測試為了免去啟動SpringBoot,直接使用new關鍵字創建ValidateInfoController對象,目的是調用其validate方法
this.validateInfoController = new ValidateInfoController();
this.person = new PersonInfo();
}
@Test
public void testChina() {
this.person.setName("dylan");
this.person.setId("442648399474623047");
this.person.setAge(18);
boolean result = this.validateInfoController.validate("china", this.person);
Assert.assertTrue(result);
this.person.setId("648204658392");
result = this.validateInfoController.validate("china", this.person);
Assert.assertFalse(result);
}
@Test
public void testJapan() {
this.person.setName("dylan");
this.person.setAge(18);
this.person.setHobby("coding.");
boolean result = this.validateInfoController.validate("japan", this.person);
Assert.assertTrue(result);
this.person.setHobby(null);
result = this.validateInfoController.validate("japan", this.person);
Assert.assertFalse(result);
}
@Test
public void testUSA() {
this.person.setName("dylan");
this.person.setId("648204658392");
boolean result = this.validateInfoController.validate("usa", this.person);
Assert.assertTrue(result);
this.person.setName(null);
result = this.validateInfoController.validate("usa", this.person);
Assert.assertFalse(result);
}
@Test
public void test() {
boolean result = this.validateInfoController.validate("", this.person);
Assert.assertFalse(result);
result = this.validateInfoController.validate(null, this.person);
Assert.assertFalse(result);
this.person.setName("dylan");
this.person.setId("442648399474623047");
this.person.setAge(18);
result = this.validateInfoController.validate("china", this.person);
Assert.assertTrue(result);
}
}

總結
- 校驗注解中提供
groups信息,可以完成分組校驗的操作。
- 啟用分組校驗,需要在相應字段的校驗注解中提供
groups屬性信息,同時在校驗對象時,一並攜帶groups信息。
- 在
validate()中如果未攜帶任何校驗分組groups信息,程序將自動賦予一個默認的Default.class分組。