文章目錄
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 |
被註釋的元素必須是電子郵箱地址,可選參數 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也是直接使用數據就行了,校驗器已經幫我們校驗好數據了