Java SpringBoot Validation用法案例詳解

提到輸入參數的基本驗證(非空、長度、大小、格式…),在以前我們還是通過手寫代碼,各種if、else、StringUtils.isEmpty、CollectionUtils.isEmpty…,真感覺快要瘋瞭,太繁瑣,Low爆瞭…,其實在Java生態提供瞭一套標準JSR-380(aka. Bean Validation 2.0,part of Jakarta EE and JavaSE),它已成為對象驗證事實上的標準,這套標準可以通過註解的形式(如@NotNull, @Size…)來對bean的屬性進行驗證。而Hibernate Validator對這套標準進行瞭實現,SpringBoot Validation無縫集成瞭Hibernate Validator、自定義驗證器、自動驗證的功能。下文將對SpringBoot集成Validation進行展開。

註: 完整示例代碼可參見GitHub:https://github.com/marqueeluo/spring-boot-validation-demo

constraints分類

JSR-380的支持的constrants註解匯總如下表:

分類 註解 適用對象 null是否驗證通過 說明
非空 @NotNull 所有對象 No 不是null
非空 @NotEmpty CharSequence, Collection, Map, Array No 不是null、不是””、size>0
非空 @NotBlank CharSequence No 不是null、trim後長度大於0
非空 @Null 所有對象 Yes 是null
長度 @Size(min=0, max=Integer.MAX_VALUE) CharSequence, Collection, Map, Array Yes 字符串長度、集合size
大小 @Positive BigDecimal, BigInteger, byte, short, int, long, float, double Yes 數字>0
大小 @PositiveOrZero BigDecimal, BigInteger, byte, short, int, long, float, double Yes 數字>=0
大小 @Negative BigDecimal, BigInteger, byte, short, int, long, float, double Yes 數字<0
大小 @NegativeOrZero BigDecimal, BigInteger, byte, short, int, long, float, double Yes 數字<=0
大小 @Min(value=0L) BigDecimal, BigInteger, byte, short, int, long Yes 數字>=min.value
大小 @Max(value=0L) BigDecimal, BigInteger, byte, short, int, long Yes 數字<=max.value
大小 @Range(min=0L, max=Long.MAX_VALUE) BigDecimal, BigInteger, byte, short, int, long Yes range.min<=數字<=range.max
大小 @DecimalMin(value=””) BigDecimal, BigInteger, CharSequence, byte, short, int, long Yes 數字>=decimalMin.value
大小 @DecimalMax(value=””) BigDecimal, BigInteger, CharSequence, byte, short, int, long Yes 數字<=decimalMax.value
日期 @Past
  • java.util.Date
  • java.util.Calendar
  • java.time.Instant
  • java.time.LocalDate
  • java.time.LocalDateTime
  • java.time.LocalTime
  • java.time.MonthDay
  • java.time.OffsetDateTime
  • java.time.OffsetTime
  • java.time.Year
  • java.time.YearMonth
  • java.time.ZonedDateTime
  • java.time.chrono.HijrahDate
  • java.time.chrono.JapaneseDate
  • java.time.chrono.MinguoDate
  • java.time.chrono.ThaiBuddhistDate
Yes 時間在當前時間之前
日期 @PastOrPresent 同上 Yes 時間在當前時間之前 或者等於此時
日期 @Future 同上 Yes 時間在當前時間之後
日期 @FutureOrPresent 同上 Yes 時間在當前時間之後 或者等於此時
格式 @Pattern(regexp=””, flags={}) CharSequence Yes 匹配正則表達式
格式 @Email
@Email(regexp=”.*”, flags={})
CharSequence Yes 匹配郵箱格式
格式 @Digts(integer=0, fraction=0) BigDecimal, BigInteger, CharSequence, byte, short, int, long Yes 必須是數字類型,且滿足整數位數<=digits.integer, 浮點位數<=digits.fraction
佈爾 @AssertTrue boolean Yes 必須是true
佈爾 @AssertFalse boolean Yes 必須是false

註: 後續還需補充Hibernate Validator中實現的constraints註解,如表中@Range。

對象集成constraints示例

/**
 * 用戶 - DTO
 *
 * @author luohq
 * @date 2021-09-04 13:45
 */
public class UserDto {

    @NotNull(groups = Update.class)
    @Positive
    private Long id;

    @NotBlank
    @Size(max = 32)
    private String name;

    @NotNull
    @Range(min = 1, max = 2)
    private Integer sex;

    @NotBlank
    @Pattern(regexp = "^\\d{8,11}$")
    private String phone;

    @NotNull
    @Email
    private String mail;

    @NotNull
    @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$")
    private String birthDateStr;

    @NotNull
    @PastOrPresent
    private LocalDate birthLocalDate;

    @NotNull
    @Past
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime registerLocalDatetime;

    @Valid
    @NotEmpty
    private List<OrgDto> orgs;

	//省略getter、setter、toString方法	
}

/**
 * 組織 - DTO
 *
 * @author luohq
 * @date 2021-09-04 14:10
 */
public class OrgDto {
    @NotNull
    @Positive
    private Long orgId;

    @NotBlank
    @Size(min = 1, max = 32)
    private String orgName;
    
    //省略getter、setter、toString方法	
}

註:

  • 可通過constraints註解的groups指定分組
    即指定constraints僅在指定group生效,默認均為Default分組,
    後續可通過@Validated({MyGroupInterface.class})形式進行分組的指定
  • 可通過@Valid註解進行級聯驗證(Cascaded Validation,即嵌套對象驗證)
    如上示例中@Valid添加在 List<OrgDto> orgs上,即會對list中的每個OrgDto進行驗證

SpringBoot集成自動驗證

參考:
https://www.baeldung.com/javax-validation-method-constraints#validation

集成maven依賴

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

驗證RequestBody、Form對象參數

在參數前加@Validated

在這裡插入圖片描述

驗證簡單參數

在controller類上加@Validated

在這裡插入圖片描述

驗證指定分組

在這裡插入圖片描述

全局controller驗證異常處理

通過@ControllerAdvice、@ExceptionHandler來對SpringBoot Validation驗證框架拋出的異常進行統一處理,
並將錯誤信息拼接後統一返回,具體處理代碼如下:

import com.luo.demo.validation.domain.result.CommonResult;
import com.luo.demo.validation.enums.RespCodeEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolationException;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * controller增強 - 通用異常處理
 *
 * @author luohq
 * @date 2021-09-04 13:43
 */
@ControllerAdvice
public class ControllerAdviceHandler {

    private static final Logger log = LoggerFactory.getLogger(ControllerAdviceHandler.class);

    /**
     * 是否在響應結果中展示驗證錯誤提示信息
     */
    @Value("${spring.validation.msg.enable:true}")
    private Boolean enableValidationMsg;

    /**
     * 符號常量
     */
    private final String DOT = ".";
    private final String SEPARATOR_COMMA = ", ";
    private final String SEPARATOR_COLON = ": ";

    /**
     * 驗證異常處理 - 在@RequestBody上添加@Validated處觸發
     *
     * @param request
     * @param ex
     * @return
     */
    @ExceptionHandler({MethodArgumentNotValidException.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public CommonResult handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException ex) {
        log.warn("{} - MethodArgumentNotValidException!", request.getServletPath());
        CommonResult commonResult = CommonResult.respWith(RespCodeEnum.PARAM_INVALID.getCode(), this.convertFiledErrors(ex.getBindingResult().getFieldErrors()));
        log.warn("{} - resp with param invalid: {}", request.getServletPath(), commonResult);
        return commonResult;
    }

    /**
     * 驗證異常處理 - form參數(對象參數,沒有加@RequestBody)觸發
     *
     * @param request
     * @param ex
     * @return
     */
    @ExceptionHandler({BindException.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public CommonResult handleBindException(HttpServletRequest request, BindException ex) {
        log.warn("{} - BindException!", request.getServletPath());
        CommonResult commonResult = CommonResult.respWith(RespCodeEnum.PARAM_INVALID.getCode(), this.convertFiledErrors(ex.getFieldErrors()));
        log.warn("{} - resp with param invalid: {}", request.getServletPath(), commonResult);
        return commonResult;
    }


    /**
     * 驗證異常處理 - @Validated加在controller類上,
     * 且在參數列表中直接指定constraints時觸發
     *
     * @param request
     * @param ex
     * @return
     */
    @ExceptionHandler({ConstraintViolationException.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public CommonResult handleConstraintViolationException(HttpServletRequest request, ConstraintViolationException ex) {
        log.warn("{} - ConstraintViolationException - {}", request.getServletPath(), ex.getMessage());
        CommonResult commonResult = CommonResult.respWith(RespCodeEnum.PARAM_INVALID.getCode(), this.convertConstraintViolations(ex));
        log.warn("{} - resp with param invalid: {}", request.getServletPath(), commonResult);
        return commonResult;
    }

    /**
     * 全局默認異常處理
     *
     * @param request
     * @param ex
     * @return
     */
    @ExceptionHandler({Throwable.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public CommonResult handleException(HttpServletRequest request, Throwable ex) {
        log.warn("{} - Exception!", request.getServletPath(), ex);
        CommonResult commonResult = CommonResult.failed();
        log.warn("{} - resp failed: {}", request.getServletPath(), commonResult);
        return commonResult;
    }

    /**
     * 轉換FieldError列表為錯誤提示信息
     *
     * @param fieldErrors
     * @return
     */
    private String convertFiledErrors(List<FieldError> fieldErrors) {
        return Optional.ofNullable(fieldErrors)
                .filter(fieldErrorsInner -> this.enableValidationMsg)
                .map(fieldErrorsInner -> fieldErrorsInner.stream()
                        .flatMap(fieldError -> Stream.of(fieldError.getField(), SEPARATOR_COLON, fieldError.getDefaultMessage(), SEPARATOR_COMMA))
                        .collect(Collectors.joining()))
                .map(msg -> msg.substring(0, msg.length() - SEPARATOR_COMMA.length()))
                .orElse(null);
    }

    /**
     * 轉換ConstraintViolationException異常為錯誤提示信息
     *
     * @param constraintViolationException
     * @return
     */
    private String convertConstraintViolations(ConstraintViolationException constraintViolationException) {
        return Optional.ofNullable(constraintViolationException.getConstraintViolations())
                .filter(constraintViolations -> this.enableValidationMsg)
                .map(constraintViolations -> constraintViolations.stream()
                        .flatMap(constraintViolation -> {
                            String path = constraintViolation.getPropertyPath().toString();
                            path = path.substring(path.lastIndexOf(DOT) + 1);
                            String errMsg = constraintViolation.getMessage();
                            return Stream.of(path, SEPARATOR_COLON, errMsg, SEPARATOR_COMMA);
                        }).collect(Collectors.joining())
                ).map(msg -> msg.substring(0, msg.length() - SEPARATOR_COMMA.length()))
                .orElse(null);

    }
}

參數驗證未通過返回結果示例:

在這裡插入圖片描述

註: 其中CommonResult為統一返回結果,可根據自己業務進行調整

在這裡插入圖片描述

自定義constraints

自定義field constraint註解主要分為以下幾步:
(1)定義constraint annotation註解及其屬性
(2)通過註解的元註解@Constraint(validatedBy = {})關聯的具體的驗證器實現
(3)實現驗證器邏輯

@DateFormat

具體字符串日期格式constraint @DateFormat定義示例如下:

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;

/**
 * The annotated {@code CharSequence} must match date format.
 * The default date format is "yyyy-MM-dd".
 * Can override with property "format".
 * see {@link java.time.format.DateTimeFormatter}.
 * <p>
 * Accepts {@code CharSequence}. {@code null} elements are considered valid.
 *
 * @author luo
 * @date 2021-09-05
 */
@Documented
@Constraint(validatedBy = DateFormatValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ANNOTATION_TYPE,})
@Retention(RetentionPolicy.RUNTIME)
public @interface DateFormat {
    String message() default "日期格式不正確";

    String format() default "yyyy-MM-dd";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}



import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.time.format.DateTimeFormatter;

/**
 * Date Format validator
 *
 * @author luohq
 * @date 2021-09-05
 */
public class DateFormatValidator implements ConstraintValidator<DateFormat, String> {

    private String format;

    @Override
    public void initialize(DateFormat dateFormat) {
        this.format = dateFormat.format();
    }

    @Override
    public boolean isValid(String dateStr, ConstraintValidatorContext cxt) {
        if (!StringUtils.hasText(dateStr)) {
            return true;
        }
        try {
            DateTimeFormatter.ofPattern(this.format).parse(dateStr);
            return true;
        } catch (Throwable ex) {
            return false;
        }
    }
}

@PhoneNo

在查看hbernate-validator中URL、Email約束實現時,發現可以通過元註解的形式去復用constraint實現(如@Pattern),故參考如上方式實現@PhoneNo約束

import javax.validation.Constraint;
import javax.validation.OverridesAttribute;
import javax.validation.Payload;
import javax.validation.ReportAsSingleViolation;
import javax.validation.constraints.Pattern;
import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;


/**
 * The annotated {@code CharSequence} must match phone no format.
 * The regular expression follows the Java regular expression conventions
 * see {@link java.util.regex.Pattern}.
 * <p>
 * Accepts {@code CharSequence}. {@code null} elements are considered valid.
 *
 * @author luo
 * @date 2021-09-05
 */
@Documented
@Constraint(validatedBy = {})
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Repeatable(PhoneNo.List.class)
@ReportAsSingleViolation
@Pattern(regexp = "")
public @interface PhoneNo {
    String message() default "電話號碼格式不正確";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    /**
     * @return an additional regular expression the annotated PhoneNo must match. The default is "^\\d{8,11}$"
     */
    @OverridesAttribute(constraint = Pattern.class, name = "regexp") String regexp() default "^\\d{8,11}$";

    /**
     * @return used in combination with {@link #regexp()} in order to specify a regular expression option
     */
    @OverridesAttribute(constraint = Pattern.class, name = "flags") Pattern.Flag[] flags() default {};

    /**
     * Defines several {@code @URL} annotations on the same element.
     */
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        PhoneNo[] value();
    }
}

註: 同理可以實現@IdNo約束

使用自定義constraint註解

可將之前的對象集成示例中代碼調整為使用自定義驗證註解如下:

/**
 * 用戶 - DTO
 *
 * @author luohq
 * @date 2021-09-04 13:45
 */
public class UserDto {
    ...
    @NotBlank
    //@Pattern(regexp = "^\\d{8,11}$")
    @PhoneNo
    private String phone;
    
    @NotBlank
    @IdNo
    private String idNo;

    @NotNull
    //@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$")
    @DateFormat
    //@DateTimeFormat
    private String birthDateStr;

    ...
}

同時自定義constraints還支持跨多參數、驗證對象裡的多個field、驗證返回對象等用法,待後續再詳細探索。

問題

通過在對象屬性、方法參數上標註註解的形式,需要侵入代碼,之前有的架構師不喜歡這種風格。
在一方開發時,我們有全部源碼且在公司內部,這種方式還是可以的,且集成比較方便,
但是依賴三方Api jar包(參數對象定義在jar包中),我們無法直接去修改參數對象,依舊使用這種侵入代碼的註解方式就不適用瞭,
針對三方包、或者替代註解這種形式,之前公司內部有實現過基於xml配置的形式進行驗證,
這種方式不侵入參數對象,且集成也還算方便,
但是用起來還是沒有直接在代碼裡寫註解來的順手(代碼有補全、有提示、程序員友好),
所以一方開發時,首選推薦SpringBoot Validation這套體系,無法直接編輯參數對象時再考慮其他方式。

參考:

【自定義validator – field、class level】https://www.baeldung.com/spring-mvc-custom-validator

【Spring boot集成validation、全局異常處理】https://www.baeldung.com/spring-boot-bean-validation

【JSR380、非Spring框架集成validation】https://www.baeldung.com/javax-validation

【方法約束 – Single param、Cross param、Return value自定義constraints、編程調用驗證】https://www.baeldung.com/javax-validation-method-constraints

Spring Validation最佳實踐及其實現原理,參數校驗沒那麼簡單!

https://reflectoring.io/bean-validation-with-spring-boot/

到此這篇關於Java SpringBoot Validation用法案例詳解的文章就介紹到這瞭,更多相關Java SpringBoot Validation用法內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: