spring boot中使用Bean Validation做優雅的參數校驗

Bean Validation簡介

Bean Validation是Java定義的一套基於註解的數據校驗規範,目前已經從JSR 303的1.0版本升級到JSR 349的1.1版本,再到JSR 380的2.0版本(2.0完成於2017.08),目前最新穩定版2.0.2(201909)
大家可能會發現它的pom引用有幾個

<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
</dependency>
<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>2.0.1</version>
</dependency>
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.5.Final</version>
</dependency>

它們的關係是,Hibernate Validator 是 Bean Validation 的實現,除了JSR規範中的,還加入了它自己的一些constraint實現,所以點開pom發現Hibernate Validator依賴了validation-api。jakarta.validation是javax.validation改名而來,因爲18年Java EE改名Jakarta EE了。
對於spring boot應用,直接引用它提供的starter

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

spring boot有它的版本號配置,繼承了spring boot的pom,所以不需要自己指定版本號了。這個starter它內部也依賴了Hibernate Validator

版本

Bean Validation Hibernate Validation JDK Spring Boot
1.1 5.4 + 6+ 1.5.x
2.0 6.0 + 8+ 2.0.x

Bean Validation作用

在平常寫接口代碼時,相信對下面代碼非常眼熟,對接口請求參數進行校驗的邏輯,比較常用的做法就是寫大量if else來做各種判斷

@RestController
public class LoginController {

	@PostMapping("/user")
	public ResultObject addUserInfo(@RequestBody User params) {
		// 參數校驗
		if(params.getStatus() == null) {
		      ...
		} else if(params.getUserName == null || "".equals(params.getUserName())) {
		      ...
		} else {
		      ...
		}
		// 業務邏輯處理
		...
	}
}

這樣顯得非常繁瑣,代碼也顯得很臃腫,不便於維護。
而這個bean validation框架能夠簡化這一步,就最簡單的判空來說,直接在傳入對象裏的屬性上加上@NotNull、@NotEmpty、@NotBlank(這三種判空的區別後面討論)就可以了,對於複雜的場景,比如判斷a依賴於b的值,也可以通過自定義校驗器得到很好的解決。

基本使用

官方參考文檔:
Hibernate Validator: https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single
Jakarta Bean Validation: https://beanvalidation.org/2.0/spec/
Hibernate Validator demo:https://github.com/hibernate/hibernate-validator/tree/master/documentation/src/test

這裏簡單介紹基於註解的校驗方式

常用註解

常用註解如下:

Constraint 說明 支持的數據類型
@AssertFalse 被註釋的元素必須爲 false Boolean
@AssertTrue 被註釋的元素必須爲 true Boolean
@DecimalMax 被註釋的元素必須是一個數字,其值必須小於等於指定的最大值 BigDecimal, BigInteger, CharSequence, byte, short, int, long
@DecimalMin 被註釋的元素必須是一個數字,其值必須大於等於指定的最小值 BigDecimal, BigInteger, CharSequence, byte, short, int, long
@Max 被註釋的元素必須是一個數字,其值必須小於等於指定的最大值 BigDecimal, BigInteger, byte, short, int, long
@Min 被註釋的元素必須是一個數字,其值必須大於等於指定的最小值 BigDecimal, BigInteger, byte, short, int, long
@Digits(integer=, fraction=) 檢查註釋的值是否爲最多爲整數位(integer)和小數位(fraction)的數字 BigDecimal, BigInteger, CharSequence, byte, short, int, long
@Email 被註釋的元素必須是電子郵箱地址,可選參數 regexp和flag允許指定必須匹配的附加正則表達式(包括正則表達式標誌)。 CharSequence
@Future 被註釋的元素必須是一個將來的日期 Date,Calendar,Instant,LocalDate等
@FutureOrPresent 被註釋的元素必須是一個將來的日期或現在的日期 Date,Calendar,Instant,LocalDate等
@Past 被註釋的元素必須是一個過去的日期 Date,Calendar,Instant,LocalDate等
@PastOrPresent 被註釋的元素必須是一個過去的日期或現在的日期 Date,Calendar,Instant,LocalDate等
@NotBlank 被註釋的元素不爲null,並且去除兩邊空白字符後長度大於0 CharSequence
@NotEmpty 被註釋的元素不爲null,並且集合不爲空 CharSequence, Collection, Map, arrays
@NotNull 被註釋的元素不爲null Any type
@Null 被註釋的元素爲null Any type
@Pattern(regex=, flags=) 被註釋的元素必須與正則表達式 regex 匹配 CharSequence
@Size(min=, max=) 被註釋的元素大小必須介於最小和最大(閉區間)之間 CharSequence, Collection, Map,arrays

作用於成員變量(Field-level constraints)

@RestController
@RequestMapping("/test")
public class TestController {
    @PostMapping("/t1")
    public void test1(@RequestBody @Valid Person person, BindingResult bindingResult) {
    	// 當校驗失敗時,使用
        if(bindingResult.hasErrors()) {
            List<ObjectError> errors = bindingResult.getAllErrors();
            errors.forEach(e -> System.out.println(e.getDefaultMessage()));
            System.out.println("校驗失敗");
        } else {
            System.out.println("校驗成功");
        }
    }
}

一個簡單的接口,傳入一個Person對象,加上@Valid啓用校驗,bindingResult裏面就包含了參數校驗的結果

@Data
public class Person {
    @NotBlank(message = "姓名不能爲空")
    private String name;
    @NotBlank(message = "性別不能爲空")
    private String sex;
    @NotNull(message = "年齡不能爲空")
    @Max(value = 100, message = "年齡不能超過100")
    private Integer age;
    @Email(message = "電子郵箱格式錯誤")
    private String email;
    @Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$")
    private String phone;
    @NotEmpty(message = "興趣不能爲空")
    private List<String> hobby;
}

這裏做了判空和基本格式校驗
其中關於@NotEmpty、@NotNull、@NotBlank的區別:
簡單來說,在Integer或者自定義對象中使用@NotNull,在String上使用@NotBlank,在集合上使用NotEmpty

運行結果:
輸入一個空對象,發現根據我們自定義的message錯誤消息返回到了bindingResult,這裏將錯誤信息sout到了控制檯

姓名不能爲空
性別不能爲空
興趣不能爲空
年齡不能爲空
校驗失敗

嵌套對象校驗

這種需求也是非常常見的,需要在校驗的對象裏嵌套一個對象並且也校驗

@Data
public class Person {
    @NotBlank(message = "姓名不能爲空")
    private String name;
    @NotBlank(message = "性別不能爲空")
    private String sex;
    @NotNull(message = "年齡不能爲空")
    @Max(value = 100, message = "年齡不能超過100")
    private Integer age;
    @Email(message = "電子郵箱格式錯誤")
    private String email;
    @Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$")
    private String phone;
    @NotEmpty(message = "興趣不能爲空")
    private List<String> hobby;
    @NotNull(message = "必須有臺電腦")
    @Valid
    private Computer computer;
}

還是上面的類,加了一個Computer類,上面加上@Valid就可以進行嵌套校驗了

@Data
public class Computer {
    @NotBlank(message = "電腦名稱不能爲空")
    private String name;
    @NotBlank(message = "cpu不能沒有")
    private String cpu;
    @NotBlank(message = "內存不能沒有")
    private String mem;
    @PastOrPresent(message = "生產日期不能大於當前時間")
    @JsonFormat(pattern = "yyyyMMdd", timezone = "GMT+8")
    private Date productionDate;
}

運行結果:
請求:

{
	"name": "asd",
	"computer": {
		"cpu": "i7",
		"mem": "256g",
		"productionDate": "20990909"
	}
}

輸出:

興趣不能爲空
性別不能爲空
年齡不能爲空
校驗失敗

繼承對象校驗

如果被校驗的對象有繼承關係,並且父類有約束條件,那麼這些約束條件會被校驗

@Data
public class Human {
    @NotBlank(message = "---這個不能爲空---")
    private String common;
}

聲明一個父類

@Data
public class Person extends Human {
...
}

還是剛剛的類,增加繼承關係

運行結果

年齡不能爲空
興趣不能爲空
---這個不能爲空---
性別不能爲空
校驗失敗

發現父類的也會被校驗

作用於類上,自定義校驗(Class-level constraints)

這個就是自定義參數校驗的方式,當遇到一些特殊的需求,比如根據類中屬性A的值,採取不同策略校驗其他值

@Data
@PersonValidator
public class Person {
    private String name;
    private String sex;
    private Integer age;
    private String email;
    private String phone;
    private List<String> hobby;
}

還是這個Person類,我們可以發現加了一個@PersonValidator註解,這是自定義的註解

@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {PersonValidatorProcess.class})
public @interface PersonValidator {
    /**
     * 校驗的失敗的時候返回的信息,由於這個註解被用於class,我們想返回具體的校驗信息
     * 所以後面會通過buildConstraintViolationWithTemplate重寫返回失敗時具體哪些參數校驗未通過
     */
    String message() default "";

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

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

這個註解重要的地方就在@Constraint(validatedBy = {PersonValidatorProcess.class}),指定了校驗的處理器

public class PersonValidatorProcess implements ConstraintValidator<PersonValidator, Person> {

    @Override
    public boolean isValid(Person value, ConstraintValidatorContext context) {
        // 關閉默認消息
        context.disableDefaultConstraintViolation();
        if(value.getName() == null || "".equals(value.getName())) {
            context.buildConstraintViolationWithTemplate("名稱不能爲空").addConstraintViolation();
            return false;
        }
        if(value.getSex() == null || "".equals(value.getSex())) {
            context.buildConstraintViolationWithTemplate("性別不能爲空").addConstraintViolation();
            return false;
        }
        return true;
    }
}

這個處理器實現ConstraintValidator接口就行了,裏面有個isValid方法,就做我們自定義處理的邏輯,注意到context.buildConstraintViolationWithTemplate,這個信息會傳遞到之前的bindingResult的error裏面,這樣就可以返回具體的校驗錯誤信息了

使用全局異常處理

在實際項目實踐中,發現用全局異常處理去處理bindingResult的error信息是不錯的選擇

@Slf4j
@RestControllerAdvice
public class GlobalAdvice {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultObject<List<String>> parameterExceptionHandler(MethodArgumentNotValidException e,
                                                          HttpServletRequest request) {
        // 獲取異常信息
        BindingResult exceptions = e.getBindingResult();
        // 這裏列出了全部錯誤參數,這裏用List傳回
        List<String> fieldErrorMsg = new ArrayList<>();
        // 判斷異常中是否有錯誤信息,如果存在就使用異常中的消息,否則使用默認消息
        if (exceptions.hasErrors()) {
            List<ObjectError> errors = exceptions.getAllErrors();
            if (!errors.isEmpty()) {
                errors.forEach(msg -> fieldErrorMsg.add(msg.getDefaultMessage()));
                return ResultObject.createByErrorMessage("請求參數校驗錯誤", fieldErrorMsg);
            }
        }
        fieldErrorMsg.add("未知異常");
        return ResultObject.createByErrorMessage("請求參數校驗錯誤", fieldErrorMsg);
    }
}

捕獲MethodArgumentNotValidException異常,加到全局異常處理即可,這樣就不需要在controller中處理bindingResult了

下面是我這用的一個全局異常處理模板,包含了更多的異常處理,僅供參考

@Slf4j
@RestControllerAdvice
public class GlobalAdvice {

    @Autowired
    private ObjectMapper objectMapper;

    // ---------- 參數校驗 ----------

    /**
     * 忽略參數異常處理器
     * @param e 忽略參數異常
     * @return ResultObject
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResultObject<String> parameterMissingExceptionHandler(MissingServletRequestParameterException e,
                                                                 HttpServletRequest request) {
        printLog(e, request);
        return ResultObject.createByErrorMessage("請求參數 " + e.getParameterName() + " 不能爲空");
    }

    /**
     * 媒體類型不支持異常處理器
     * @param e 類型不匹配異常
     * @return resultObject
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public ResultObject<String> HttpMediaTypeNotSupportedExceptionHandler(HttpMediaTypeNotSupportedException e,
                                                                 HttpServletRequest request) {
        printLog(e, request);
        return ResultObject.createByErrorMessage("請求類型錯誤,請檢查conten-type是否正確");
    }

    /**
     * 缺少請求體異常處理器
     * @param e 缺少請求體異常
     * @return ResultObject
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResultObject<String> parameterBodyMissingExceptionHandler(HttpMessageNotReadableException e,
                                                                     HttpServletRequest request) {
        printLog(e, request);
        return ResultObject.createByErrorMessage("參數體校驗錯誤");
    }

    /**
     * Bean Validation參數校驗異常處理器
     * @param e 參數驗證異常
     * @return ResultObject
     */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultObject<List<String>> parameterExceptionHandler(MethodArgumentNotValidException e,
                                                          HttpServletRequest request) {
        printLog(e, request);
        // 獲取異常信息
        BindingResult exceptions = e.getBindingResult();
        // 這裏列出了全部錯誤參數,這裏用List傳回
        List<String> fieldErrorMsg = new ArrayList<>();
        // 判斷異常中是否有錯誤信息,如果存在就使用異常中的消息,否則使用默認消息
        if (exceptions.hasErrors()) {
            List<ObjectError> errors = exceptions.getAllErrors();
            if (!errors.isEmpty()) {
                errors.forEach(msg -> fieldErrorMsg.add(msg.getDefaultMessage()));
                return ResultObject.createByErrorMessage("請求參數校驗錯誤", fieldErrorMsg);
            }
        }
        fieldErrorMsg.add("未知異常");
        return ResultObject.createByErrorMessage("請求參數校驗錯誤", fieldErrorMsg);
    }

    /**
     * 參數校驗過程中發生的異常
     * @param e 參數校驗異常
     * @return resultObject
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(ValidationException.class)
    public ResultObject<String> validationExceptionHandler(ValidationException e,
                                                                     HttpServletRequest request) {
        printLog(e, request);
        String message = e.getCause().getMessage();
        if(message != null) {
            return ResultObject.createByErrorMessage(message);
        }
        return ResultObject.createByErrorMessage("請求參數校驗錯誤");
    }

    // --------- 業務邏輯異常 ----------

    /**
     * 自定義異常,捕獲程序邏輯中的錯誤,業務中出現異常情況直接拋出異常即可
     * @param e 自定義異常
     * @return ResultObject
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler({GlobalException.class})
    public ResultObject<String> paramExceptionHandler(GlobalException e,
                                                      HttpServletRequest request) {
        printLog(e, request);
        // 判斷異常中是否有錯誤信息,如果存在就使用異常中的消息,否則使用默認消息
        if (!StringUtils.isEmpty(e.getMessage())) {
            return ResultObject.createByErrorMessage(e.getMessage());
        }
        return ResultObject.createByErrorMessage("程序出錯,捕獲到一個未知異常");
    }

    // ---------- 全局通用異常 ----------

    /**
     * 通用異常處理
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(value = Throwable.class)
    public ResultObject<String> exceptionHandler(Throwable e,
                                         HttpServletRequest request,
                                         HttpServletResponse response) {
        printLog(e, request);
        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        return ResultObject.createByErrorMessage("服務器異常");
    }

    /**
     * 打印日誌
     * @param e Throwable
     * @param request HttpServletRequest
     */
    private void printLog(Throwable e, HttpServletRequest request) {
        log.error("【method】: {}【uri】: {}【errMsg】: {}【params】:{}",
                request.getMethod(), request.getRequestURI(), e.getMessage(), buildParamsStr(request), e);
    }

    /**
     * 請求的參數拼接str
     * @param request HttpServletRequest
     * @return 請求參數
     */
    private String buildParamsStr(HttpServletRequest request) {
        try {
            return objectMapper.writeValueAsString(request.getParameterMap());
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return null;
    }
}

實戰自定義參數校驗

業務背景: 傳入一個指標對象,裏面有個status字段,根據這個字段數據表示不同類型的指標(大概有4種),然後根據不同類型有不同校驗規則(規則相差比較大)去校驗,然後落庫。
如果按照最簡單的方式,在service裏先用if else判斷不同status,然後調用不同的方法去用if else校驗每種類型指標,再執行後面的業務邏輯,顯得比較繁瑣,耦合性也比較強。

考慮到不同校驗方法相差比較大,這不就是不同的校驗策略嘛,就想到了策略模式,由於新增和編輯都是不同的策略,可能會導致策略類膨脹,以後可以考慮用混合模式,比如模板方法模式+策略模式,減少重複代碼。

這裏使用Bean Validation+策略模式解決這繁瑣的業務

@Data
@NoArgsConstructor
@AllArgsConstructor
@KpiCreateValidator
public class IndexDeployEditionVO {
    @NotBlank(message = "指標域不能爲空")
    private String indexArea;
    @NotBlank(message = "指標組不能爲空")
    private String indexGroup;
    @NotBlank(message = "指標編碼不能爲空")
    private String indexId;
    @NotBlank(message = "指標名稱不能爲空")
    private String indexDesc;
    @NotBlank(message = "指標週期不能爲空")
    private String indexCycle;
    private Integer startCondition;
    @NotNull(message = "狀態不能爲空")
    private Integer status;
    private String endPerson;
    private String operDate;
    ......
}

首先看需要校驗的參數對象,加了一個@KpiCreateValidator表示自定義註解,注意到它可以與成員變量上的校驗混用

再就是寫校驗邏輯了,我這裏的目錄結構
在這裏插入圖片描述

@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {KpiCreateValidatorProcess.class})
public @interface KpiCreateValidator {
    String message() default "";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

還是老套路,自定義註解上寫一個自定義校驗器

public class KpiCreateValidatorProcess implements ConstraintValidator<KpiCreateValidator, IndexDeployEditionVO> {

    private KpiDao kpiDao = ApplicationContextProvider.getBean(KpiDao.class);
	// 此爲策略模式中的context
    private KpiCreateContext kpiCreateContext;

    @Override
    public void initialize(KpiCreateValidator constraintAnnotation) {
    }

    @Override
    public boolean isValid(IndexDeployEditionVO value, ConstraintValidatorContext context) {
    	 // 如果是a類型指標
         if (value.getStatus().equals(IndexStatusEnum.BASIC_KPI.getCode())) {
             kpiCreateContext = new KpiCreateContext(ApplicationContextProvider.getBean(KpiCreateBasicStrategy.class));
             return kpiCreateContext.doValid(value, context);
         }
         // 如果是b類型指標
         if (value.getStatus().equals(IndexStatusEnum.CONVERT_KPI.getCode())) {
             kpiCreateContext = new KpiCreateContext(ApplicationContextProvider.getBean(KpiCreateConvertStrategy.class));
             return kpiCreateContext.doValid(value, context);
         }
         // 如果是c類型指標
         if (value.getStatus().equals(IndexStatusEnum.COMPUTE_KPI.getCode())) {
             kpiCreateContext = new KpiCreateContext(ApplicationContextProvider.getBean(KpiCreateComputeStrategy.class));
             return kpiCreateContext.doValid(value, context);
         }
      
        return false;
    }
}

可以發現,根據不同類型的指標採取了不同的校驗策略,如果要修改某一策略也非常方便容易。其他的,ApplicationContextProvider是實現ApplicationContextAware接口的一個類,主要用於獲取ApplicationContext,從而從Spring容器中獲取想要的bean,因爲這個類沒有加@Component註解,所以採用的這樣的方式獲取bean

下面就是一個典型的策略模式實現了
在這裏插入圖片描述

/**
 * context類,用於接納不同的校驗策略
 * @author Created by 0x on 2020/6/5
 **/
public class KpiCreateContext {
    private KpiCreateStrategy strategy;

    public KpiCreateContext(KpiCreateStrategy strategy) {
        this.strategy = strategy;
    }

    public boolean doValid(IndexDeployEditionVO params, ConstraintValidatorContext context) {
        return strategy.doValid(params, context);
    }
}

策略模式的context類

/**
 * 指標創建校驗器的策略類
 * @author Created by 0x on 2020/6/5
 **/
public interface KpiCreateStrategy {

    /**
     * 新增指標校驗的方法
     * @param params 需要校驗的對象
     * @param context 校驗器context
     * @return bool
     */
    boolean doValid(IndexDeployEditionVO params, ConstraintValidatorContext context);
}

各個校驗策略需要實現的接口

@Component
public class KpiCreateBasicStrategy implements KpiCreateStrategy {

    private final KpiDao kpiDao;

    public KpiCreateBasicStrategy(KpiDao kpiDao) {
        this.kpiDao = kpiDao;
    }

    @Override
    public boolean doValid(IndexDeployEditionVO params, ConstraintValidatorContext context) {
        // 關閉默認消息
        context.disableDefaultConstraintViolation();

        boolean ret = true;
        if (params.getIndexDelay() == null) {
            ret = false;
            context.buildConstraintViolationWithTemplate("延遲天數不能爲空").addConstraintViolation();
        }
        if (params.getIndexDependentModel() == null) {
            ret = false;
            context.buildConstraintViolationWithTemplate("指標依賴方式不能爲空").addConstraintViolation();
        }
        ......
        return ret;
    }
}

這裏列舉其中一個策略,其他的策略類根據業務需求來實現就行,全局異常處理也還是用上面的方式,然後就完成這個需求了

    /**
     * 新增單指標
     *
     * @param params 指標所有信息
     */
    @PostMapping("addSingleKpi")
    @SuccessWrapper
    public void addSingleKpi(@RequestBody @Valid IndexDeployEditionVO params) {
        kpiCreateService.addSingleKpiToEdition(params, "1");
    }

最後一看controller,是不是很乾淨,service也是直接使用數據就行了,校驗器已經幫我們校驗好數據了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章