簡介
- 本篇介紹一個
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
分組。