JSR-303注解中的分組校驗groups屬性


簡介

  • 本篇介紹一個JSR303中校驗注解的groups屬性。

背景

  • 關於groups的了解之路,來源於一個朋友的解決思路。
  • 項目實際需要根據國家來區分用戶,進而對用戶資料進行驗證。對來自不同國家的用戶來說,其擁有不一樣的驗證字段,比如中國要求用戶必須提供身份證信息,而日本可能不需要提供身份證信息。
  • 在這個要求的基礎上,無論如何進行驗證操作,首先需要判斷的都是該用戶來自於哪個國家,進而才能根據所屬的國家信息,指定相關的驗證操作。
  • 即通俗地講,除非你在一個單獨的Controller中使用@ModelAttribute注解的方法判斷用戶來自於哪個國家,並為每個國家編寫特定的實體類,才能在Controller的驗證方法入參中使用@Valid@Validated注解進行一步到位的驗證操作。
  • 稍作考慮,則目前的解決方案可能有以下兩種:
    1. 為來自於不同國家的用戶建立不同的用於記錄該國家用戶信息的Bean對象,在這些Bean對象中使用驗證注解注釋字段;
    2. 將用戶信息集中在同一個Bean對象中,使用驗證類提供的groups屬性進行驗證分組。
  • 關於第一種方式,大部分的人應該都知道該如何編寫,因此本篇將考慮第二種方式。
  • 在使用這種方式前,需要首先了解一下什么是標記接口Java Marker Interface

標記接口

  • 什么是Java Marker Interface標記接口呢?類似於Serializable,它就是JDK中的一個標記接口。
  • 對於標記接口來說,需要明確這不是Java語言所特有的,而是計算機科學中一種通用的設計理念。
  • 程序往往可以通過判斷對象是否實現了某些標記接口,而判定該對象是否具有某些特征,使程序編寫更為靈活。
  • 標記接口不具有任何的字段或方法,是一個空接口。

groups屬性

  • 在介紹JSR303時,細心的人一定會發現一個細節,所有的驗證注解中都存在兩個屬性:groupspayload
  • 以下為@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
  • 標記接口StudentTeacher如下:
package cn.dylanphang.mark;

public interface Student {
}
package cn.dylanphang.mark;

public interface Teacher {
}
  • 實體類Person如下:
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());
}
  • 單獨運行testNoGroups()查看結果:

  • 測試通過。
  • 當存在必要的其他分組信息時,需要在分組屬性groups中添加Default.class以確保在validate()不提供任何標記接口的情況下,也會對當前傳入對象的字段進行合法性校驗。此時對象字段所屬的校驗組別為Default.class

案例編寫

  • 對標記接口異或groups用法都了解后,可以開始針對開篇的案例進行代碼的編寫。
  • 對於第一種解決方案不再多做贅述。本篇主要為了展示驗證類注解中groups屬性用法,因此案例編寫將針對第二種解決方案。
  • 假設此時有中國China、日本Japan和美國USA三個國家的用戶的信息需要進行校驗,那么首先應該具備三個標記接口,這三個標記接口均繼承了名為Country的標記接口。(提示:接口是無法實現接口的喲。)
  • Country標記接口:
package cn.dylanphang.mark.supers;

public interface Country {
}
  • China標記接口:
package cn.dylanphang.mark.supers;

public interface China extends Country {
}
  • Japan標記接口:
package cn.dylanphang.mark.supers;

public interface Japan extends Country {
}
  • USA標記接口:
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位的用戶名name18位的身份證號id0~99之間的年齡age,無需提供愛好;
    • Japan.class:用戶需提供1~16位的用戶名name0~99之間的年齡age、不為空的愛好,無需提供身份證號;
    • USA.class:用戶需提供1~16位的用戶名name12位的身份證號id0~99之間的年齡age,無需提供愛好;
  • 以上代碼中,Default.classChina.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分組。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM