最近在工作中遇到寫一些API,這些API的請求參數非常多,嵌套也非常復雜,如果參數的校驗代碼全部都手動去實現,寫起來真的非常痛苦。
正好Spring輪子里面有一個Validation,這里記錄一下怎么使用,以及怎么自定義它的返回結果。
一、Bean Validation基本概念
Bean Validation是Java中的一項標準,它通過一些注解表達了對實體的限制規則。通過提出了一些API和擴展性的規范,這個規范是沒有提供具體實現的,希望能夠Constrain once, validate everywhere。現在它已經發展到了2.0,兼容Java8。
hibernate validation實現了Bean Validation標準,里面還增加了一些注解,在程序中引入它我們就可以直接使用。
Spring MVC也支持Bean Validation,它對hibernate validation進行了二次封裝,添加了自動校驗,并將校驗信息封裝進了特定的BindingResult類中,在SpringBoot中我們可以添加implementation(‘org.springframework.boot:spring-boot-starter-validation')引入這個庫,實現對bean的校驗功能。
二、基本用法
gradle dependencies如下:
dependencies { implementation('org.springframework.boot:spring-boot-starter-validation') implementation('org.springframework.boot:spring-boot-starter-web') }
定義一個示例的Bean,例如下面的User.java。
public class User { @NotBlank @Size(max=10) private String name; private String password; public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } }
在name屬性上,添加@NotBlank和@Size(max=10)的注解,表示User對象的name屬性不能為字符串且長度不能超過10個字符。
然后我們暫時不添加任何多余的代碼,直接寫一個UserController對外提供一個RESTful的GET接口,注意接口的參數用到了@Validated注解。
// UserController.java,省略其他代碼 @RestController public class UserController { @RequestMapping(value = "/validation/get", method = RequestMethod.GET) public ServiceResponse validateGet(@Validated User user) { ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(0); serviceResponse.setMessage("test"); return serviceResponse; } } // ServiceResponse.java,簡單包含了code、message字段返回結果。 public class ServiceResponse { private int code; private String message; ... 省略getter、setter ... }
啟動SpringBoot程序,發一個測試請求看一下:
http://127.0.0.1:8080/validation/get?name=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&password=1
此時已經可以實現參數的校驗了,但是返回的結果不太友好,下面看一下怎么定制返回的消息。在定制返回結果前,先看下一下內置的校驗注解有哪些,在這里我不一個個去貼了,寫代碼的時候根據需要進入到源碼里面去看即可。
早期Spring版本中,都是在Controller的方法中添加Errors/BindingResult參數,由Spring注入Errors/BindingResult對象,再在Controller中手寫校驗邏輯實現校驗。
新版本提供注解的方式(Controller上面bean加一個@Validated注解),將校驗邏輯和Controller分離。
三、自定義校驗
3.1 自定義注解
顯然除了自帶的NotNull、NotBlank、Size等注解,實際業務上還會需要特定的校驗規則。
假設我們有一個參數address,必須以Beijing開頭,那我們可以定義一個注解和一個自定義的Validator。
// StartWithValidator.java public class StartWithValidator implements ConstraintValidator<StartWithValidation, String> { private String start; @Override public void initialize(StartWithValidation constraintAnnotation) { start = constraintAnnotation.start(); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (!StringUtils.isEmpty(value)) { return value.startsWith(start); } return true; } } // StartWithValidation.java @Documented @Constraint(validatedBy = StartWithValidator.class) @Target({METHOD, FIELD}) @Retention(RUNTIME) public @interface StartWithValidation { String message() default "不是正確的性別取值范圍"; String start() default "_"; Class[] groups() default {}; Class[] payload() default {}; @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER}) @Retention(RUNTIME) @Documented @interface List { GenderValidation[] value(); } }
然后在User.java中增加一個address屬性,并給它加上上面這個自定義的注解,這里我們定義了一個可以傳入start參數的注解,表示應該以什么開頭。
@StartWithValidation(message = "Param 'address' must be start with 'Beijing'.", start = "Beijing") private String address;
除了定義可以作用于屬性的注解外,其實還可以定義作用于class的注解(@Target({TYPE})),用于校驗class的實例。
3.2 自定義Validator
第一步,實現一個Validator。(這種方法不需要我們的bean里面有任何注解之類的東西)
package com.example.validation.demo; import org.springframework.stereotype.Component; import org.springframework.validation.Errors; import org.springframework.validation.ValidationUtils; import org.springframework.validation.Validator; @Component public class UserValidator implements Validator { @Override public boolean supports(Class clazz) { return User2.class.equals(clazz); } @Override public void validate(Object target, Errors errors) { ValidationUtils.rejectIfEmpty(errors, "name", "name.empty"); User2 p = (User2) target; if (p.getId() == 0) { errors.rejectValue("id", "can not be zero"); } } }
第二步,修改Controller代碼,注入上面的UserValidator實例,并給Controller的方法參數加上@Validated注解,即可完成和前面自定義注解一樣的校驗功能。
@RestController public class UserController { @Autowired UserValidator validator; @InitBinder public void initBinder(WebDataBinder binder) { binder.setValidator(validator); } @RequestMapping(value = "/user/post", method = RequestMethod.POST) public ServiceResponse handValidatePost(@Validated @RequestBody User user) { ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(0); serviceResponse.setMessage("test"); return serviceResponse; } }
這個方法和自定義注解的區別在于不需要在Bean里面添加注解,并且可以更加靈活的把一個Bean里面所有的Field的校驗代碼都搬到一起,而不是每一個屬性都去加注解,如果校驗的屬性非常多,且默認注解的能力又不夠的話,這種方式也是不錯的,可以避免大量的自定義注解。
3.3 以編程的方式校驗(手動)
這種方式可以算是原始的Hibernate-Validation的方式。直接看代碼,這里有一個比較不同的是,可以使用Hibernate-Validation的Fail fast mode。因為前面的方式,都將所有的參數都驗證完了,再把錯誤返回。有時我們希望遇到一個參數錯誤,就立即返回。
設置fast-fail為true可以達到這個目的。不過貌似不能再用@Validated注解方法參數了,而是要用ValidatorFactory創建Validator。
在實際開發中,不必每次都編寫代碼創建Validator,可以采用@Configuration的方式創建,然后再@Autowired注入到每個需要使用Validator的Controller當中。
@RestController public class UserController { ... @RequestMapping(value = "/validation/postStudent", method = RequestMethod.POST) public ServiceResponse validatePostStudent(@RequestBody User user) { // User參數前面沒有@Validated注解了,User類里面那些注解還是保留著即可。 HibernateValidatorConfiguration configuration = Validation.byProvider(HibernateValidator.class).configure(); ValidatorFactory factory = configuration.failFast(true).buildValidatorFactory(); // fastFail Validator validator = factory.getValidator(); Set<constraintviolation> set = validator.validate(user); // 根據set的size,大于0時,拋異常。由于設置了failFast,這里set最多就一個元素 ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(0); serviceResponse.setMessage("test"); return serviceResponse; } }
3.4 定義分組校驗
有的時候,我們會有兩個不同的接口,但是會使用到同一個Bean來作為VO(意思是兩個接口的URI不同,但參數中都用到了同一個Bean)。
而在不同的接口上,對Bean的校驗需求可能不一樣,比如接口2需要校驗studentId,而接口1不需要。那么此時就可以用到校驗注解的分組groups。
// User.java public class User { ... 省略其他屬性 // 指明在groups={Student.class}時才需要校驗studentId @NotNull(groups = {Student.class}, message = "Param 'studentId' must not be null.") private Long studentId; // 增加Student interface public interface Student { } } // UserController.java,增加了一個/getStudent接口 @RestController public class UserController { @RequestMapping(value = "/validation/get", method = RequestMethod.GET) public ServiceResponse validateGet(@Validated User user) { ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(200); serviceResponse.setMessage("test"); return serviceResponse; } @RequestMapping(value = "/validation/getStudent", method = RequestMethod.GET) public ServiceResponse validateGetStudent(@Validated({User.Student.class}) User user) { ServiceResponse serviceResponse = new ServiceResponse(); serviceResponse.setCode(0); serviceResponse.setMessage("test"); return serviceResponse; } }
到這里,也可以帶一嘴Valid和Validated注解的區別,其代碼注釋寫著后者是對前者的一個擴展,支持了group分組的功能。
3.5 定制返回碼和消息
第二節中定義了一個ServiceResponse,其實作為一個開放的API,不論用戶傳入任何參數,返回的結果都應該是預先定義好的格式,并且可以寫明在接口文檔中,即使發生了校驗失敗,應該返回一個包含錯誤碼code(發生錯誤時一般大于0)和message字段。
{ "code": 51000, "message": "Param 'name' must be less than 10 characters." }
的結果,而HTTP STATUS CODE一直都是200。
為了實現這個目的,我們加一個全局異常處理方法。
// ServiceExceptionHandler.java package com.example.validation.demo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.CollectionUtils; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.List; @RestControllerAdvice public class ServiceExceptionHandler { static final Logger LOG = LoggerFactory.getLogger(ServiceExceptionHandler.class); @ExceptionHandler(value = {Exception.class}) public ServiceResponse handleBindException(Exception ex) { LOG.error("{}", ex); StringBuilder message = new StringBuilder(); if (ex instanceof BindException) { List fieldErrorList = ((BindException) ex).getFieldErrors(); if (!CollectionUtils.isEmpty(fieldErrorList)) { for (FieldError fieldError : fieldErrorList) { if (fieldError != null && fieldError.getDefaultMessage() != null) { message.append(fieldError.getDefaultMessage()).append(" "); } } } } else if (ex instanceof MethodArgumentNotValidException) { List fieldErrorList = ((MethodArgumentNotValidException) ex).getBindingResult().getFieldErrors(); if (!CollectionUtils.isEmpty(fieldErrorList)) { for (FieldError fieldError : fieldErrorList) { if (fieldError != null && fieldError.getDefaultMessage() != null) { message.append(fieldError.getDefaultMessage()).append(" "); } } } } // 生成返回結果 ServiceResponse errorResult = new ServiceResponse(); errorResult.setCode(51000); // ErrorCode.PARAM_ERROR = 51000 errorResult.setMessage(message.toString()); return errorResult; } } // User.java,注解傳入指定Message public class User { @NotBlank(message = "Param 'name' can't be blank.") @Size(max=10, message = "Param 'name' must be less than 10 characters.") private String name; ... }
在上面的方法中,我們處理了BindException(非請求body參數,例如@RequestParam接收的)和MethodArgumentNotValidException(請求body里面的參數,例如@RequestBody接收的),這兩類Exception里面都有一個BindingResult對象,它里面有一個包裝成FieldError的List,保存著Bean對象出現錯誤的Field等信息。
取出它里面defaultMessage,放到統一的ServiceResponse返回即可實現返回碼和消息的定制。由于消息內容是有注解默認的DefaultMessage決定的,為了按照自定義的描述返回,在Bean對象的注解上需要手動賦值為希望返回的消息內容。
@NotBlank(message = "Param 'name' can't be blank.") @Size(max=10,message = "Param 'name' must be less than 10 characters.") private String name;
這樣當name參數長度超過10時,就會返回
{ "code": 51000, "message": "Param 'name' must be less than 10 characters." }
這里的FieldError fieldError = ex.getFieldError();只會隨機返回一個出錯的屬性,如果Bean對象的多個屬性都出錯了,可以調用ex.getFieldErrors()來獲得,這里也可以看到Spring Validation在參數校驗時不會在第一次碰到參數錯誤時就返回,而是會校驗完成所有的參數。
如果不想手動編程去校驗,那么這里可以只讀取一個隨機的FieldError,返回它的錯誤消息即可。
3.6 更加細致的返回碼和消息
其實還有一種比較典型的自定義返回,就是錯誤碼(code)和消息(message)是一一對應的,比如:
- 51001:字符串長度過長
- 51002:參數取值過大
- …
這種情況比較特殊,一般當參數錯誤的時候,會返回一個整體的參數錯誤的錯誤碼,然后攜帶參數的錯誤信息。但有時,業務
上就要不同的參數錯誤,既要錯誤碼不同,錯誤信息也要不同。我想了下,有兩種思路。
- 第一種:通過message同時包含錯誤碼和錯誤信息,在全局異常捕獲方法中,再把它們拆開。
- 第二種:手動校驗,拋出自定義的Exception(里面帶有code、message)。手動校驗這里,如果每一個Controller都去寫一遍,確實比較費勁,可以結合AOP來實現,或者抽出一個基類BaseController的方式。
四、小結
其實在實際的工作中,肯定還有更復雜的校驗邏輯,但是不一定非要都用框架去實現,框架里面的實現(比如注解)應該是一個比較簡單通用的校驗,能夠達到復用,減少重復的勞動。
而更加復雜的邏輯校驗,一定是存在具體業務當中的,最好是在業務代碼里面實現。
還有一點需要注意,Spring Validation的isValid方法,如果返回false,那么Controller不再會被調用,而是直接返回。如果你在Controller上面加了AOP進行接口調用統計的話,可能會漏掉。
這個時候,我們不應該讓Controller不調用,建議這種情況在AOP里面對Controller的參數切面進行校驗后,拋出統一的業務異常。
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持服務器之家。
原文鏈接:https://blog.csdn.net/qq_22343483/article/details/103316856