Introduction
有參數傳遞的地方都少不了參數校驗。在web開發中,前端的參數校驗是為了用戶體驗,后端的參數校驗是為了安全。試想一下,如果在controller層中沒有經過任何校驗的參數通過service層、dao層一路來到了數據庫就可能導致嚴重的后果,最好的結果是查不出數據,嚴重一點就是報錯,如果這些沒有被校驗的參數中包含了惡意代碼,那就可能導致更嚴重的后果。
這里我們主要介紹在springboot中的幾種參數校驗方式。常用的用於參數校驗的注解如下:
- @AssertFalse 所注解的元素必須是Boolean類型,且值為false
- @AssertTrue 所注解的元素必須是Boolean類型,且值為true
- @DecimalMax 所注解的元素必須是數字,且值小於等於給定的值
- @DecimalMin 所注解的元素必須是數字,且值大於等於給定的值
- @Digits 所注解的元素必須是數字,且值必須是指定的位數
- @Future 所注解的元素必須是將來某個日期
- @Max 所注解的元素必須是數字,且值小於等於給定的值
- @Min 所注解的元素必須是數字,且值小於等於給定的值
- @Range 所注解的元素需在指定范圍區間內
- @NotNull 所注解的元素值不能為null
- @NotBlank 所注解的元素值有內容
- @Null 所注解的元素值為null
- @Past 所注解的元素必須是某個過去的日期
- @PastOrPresent 所注解的元素必須是過去某個或現在日期
- @Pattern 所注解的元素必須滿足給定的正則表達式
- @Size 所注解的元素必須是String、集合或數組,且長度大小需保證在給定范圍之內
- @Email 所注解的元素需滿足Email格式
controller層參數校驗
在controller層的參數校驗可以分為兩種場景:
- 單個參數校驗
- 實體類參數校驗
單個參數校驗
@RestController
@Validated
public class PingController {
@GetMapping("/getUser")
public String getUserStr(@NotNull(message = "name 不能為空") String name,
@Max(value = 99, message = "不能大於99歲") Integer age) {
return "name: " + name + " ,age:" + age;
}
}
當處理GET
請求時或只傳入少量參數的時候,我們可能不會建一個bean來接收這些參數,就可以像上面這樣直接在controller
方法的參數中進行校驗。
注意:這里一定要在方法所在的controller類上加入
@Validated
注解,不然沒有任何效果。
這時候在postman
輸入請求:
http://localhost:8080/getUser?name=Allan&age=101
調用方會收到springboot默認的格式報錯:
{
"timestamp": "2019-06-01T04:30:26.882+0000",
"status": 500,
"error": "Internal Server Error",
"message": "getUserStr.age: 不能大於99歲",
"path": "/getUser"
}
后台會打印如下錯誤:
javax.validation.ConstraintViolationException: getUserStr.age: 不能大於99歲
at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:116)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:185)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:688)
at io.shopee.bigdata.penalty.server.controller.PingController$$EnhancerBySpringCGLIB$$232cfd51.getUserStr(<generated>)
...
如果有很多使用這種參數驗證的controller方法,我們希望在一個地方對ConstraintViolationException
異常進行統一處理,可以使用統一異常捕獲,這需要借助@ControllerAdvice
注解來實現,當然在springboot中我們就用@RestControllerAdvice
(內部包含@ControllerAdvice和@ResponseBody的特性)
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.ValidationException;
import java.util.Set;
/**
* @author pengchengbai
* @date 2019-06-01 14:09
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String handle(ValidationException exception) {
if(exception instanceof ConstraintViolationException){
ConstraintViolationException exs = (ConstraintViolationException) exception;
Set<ConstraintViolation<?>> violations = exs.getConstraintViolations();
for (ConstraintViolation<?> item : violations) {
//打印驗證不通過的信息
System.out.println(item.getMessage());
}
}
return "bad request" ;
}
}
當參數校驗異常的時候,該統一異常處理類在控制台打印信息的同時把bad request的字符串和HttpStatus.BAD_REQUEST
所表示的狀態碼400
返回給調用方(用@ResponseBody
注解實現,表示該方法的返回結果直接寫入HTTP response body 中)。其中:
@ControllerAdvice
:控制器增強,使@ExceptionHandler、@InitBinder、@ModelAttribute注解的方法應用到所有的 @RequestMapping注解的方法。@ExceptionHandler
:異常處理器,此注解的作用是當出現其定義的異常時進行處理的方法,此例中處理ValidationException
異常。
實體類參數校驗
當處理post請求或者請求參數較多的時候我們一般會選擇使用一個bean來接收參數,然后在每個需要校驗的屬性上使用參數校驗注解:
@Data
public class UserInfo {
@NotNull(message = "username cannot be null")
private String name;
@NotNull(message = "sex cannot be null")
private String sex;
@Max(value = 99L)
private Integer age;
}
然后在controller方法中用@RequestBody
表示這個參數接收的類:
@RestController
public class PingController {
@Autowired
private Validator validator;
@GetMapping("metrics/ping")
public Response<String> ping() {
return new Response<>(ResponseCode.SUCCESS, null,"pang");
}
@PostMapping("/getUser")
public String getUserStr(@RequestBody @Validated({GroupA.class, Default.class}) UserInfo user, BindingResult bindingResult) {
validData(bindingResult);
return "name: " + user.getName() + ", age:" + user.getAge();
}
private void validData(BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
StringBuffer sb = new StringBuffer();
for (ObjectError error : bindingResult.getAllErrors()) {
sb.append(error.getDefaultMessage());
}
throw new ValidationException(sb.toString());
}
}
}
需要注意的是,如果想讓UserInfo
中的參數注解生效,還必須在Controller參數中使用@Validated
注解。這種參數校驗方式的校驗結果會被放到BindingResult
中,我們這里寫了一個統一的方法來處理這些結果,通過拋出異常的方式得到GlobalExceptionHandler
的統一處理。
校驗模式
在上面的例子中,我們使用BindingResult
驗證不通過的結果集合,但是通常按順序驗證到第一個字段不符合驗證要求時,就可以直接拒絕請求了。這就涉及到兩種校驗模式的配置:
- 普通模式(默認是這個模式): 會校驗完所有的屬性,然后返回所有的驗證失敗信息
- 快速失敗模式: 只要有一個驗證失敗,則返回
如果想要配置第二種模式,需要添加如下配置類:
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
@Configuration
public class ValidatorConf {
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.failFast( true )
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();
return validator;
}
}
參數校驗分組
在實際開發中經常會遇到這種情況:想要用一個實體類去接收多個controller的參數,但是不同controller所需要的參數又有些許不同,而你又不想為這點不同去建個新的類接收參數。比如有一個/setUser
接口不需要id
參數,而/getUser
接口又需要該參數,這種時候就可以使用參數分組來實現。
- 定義表示組別的
interface
;
public interface GroupA {
}
- 在
@Validated
中指定使用哪個組;
@RestController
public class PingController {
@PostMapping("/getUser")
public String getUserStr(@RequestBody @Validated({GroupA.class, Default.class}) UserInfo user, BindingResult bindingResult) {
validData(bindingResult);
return "name: " + user.getName() + ", age:" + user.getAge();
}
@PostMapping("/setUser")
public String setUser(@RequestBody @Validated UserInfo user, BindingResult bindingResult) {
validData(bindingResult);
return "name: " + user.getName() + ", age:" + user.getAge();
}
其中Default
為javax.validation.groups
中的類,表示參數類中其他沒有分組的參數,如果沒有,/getUser
接口的參數校驗就只會有標記了GroupA
的參數校驗生效。
- 在實體類的注解中標記這個哪個組所使用的參數;
@Data
public class UserInfo {
@NotNull( groups = {GroupA.class}, message = "id cannot be null")
private Integer id;
@NotNull(message = "username cannot be null")
private String name;
@NotNull(message = "sex cannot be null")
private String sex;
@Max(value = 99L)
private Integer age;
}
級聯參數校驗
當參數bean中的屬性又是一個復雜數據類型或者是一個集合的時候,如果需要對其進行進一步的校驗需要考慮哪些情況呢?
@Data
public class UserInfo {
@NotNull( groups = {GroupA.class}, message = "id cannot be null")
private Integer id;
@NotNull(message = "username cannot be null")
private String name;
@NotNull(message = "sex cannot be null")
private String sex;
@Max(value = 99L)
private Integer age;
@NotEmpty
private List<Parent> parents;
}
比如對於parents
參數,@NotEmpty
只能保證list不為空,但是list中的元素是否為空、User對象中的屬性是否合格,還需要進一步的校驗。這個時候我們可以這樣寫:
@NotEmpty
private List<@NotNull @Valid UserInfo> parents;
然后再繼續在UserInfo
類中使用注解對每個參數進行校驗。
但是我們再回過頭來看看,在controller中對實體類進行校驗的時候使用的@Validated
,在這里只能使用@Valid
,否則會報錯。關於這兩個注解的具體區別可以參考@Valid 和@Validated的關系,但是在這里我想說的是使用@Valid
就沒辦法對UserInfo
進行分組校驗。這種時候我們就會想,如果能夠定義自己的validator就好了,最好能支持分組,像函數一樣調用對目標參數進行校驗,就像下面的validObject
方法一樣:
import javax.validation.Validator;
@RestController
public class PingController {
@Autowired
private Validator validator;
@PostMapping("/setUser")
public String setUser(@RequestBody @Validated UserInfo user, BindingResult bindingResult) {
validData(bindingResult);
Parent parent = user.getParent();
validObject(parent, validator, GroupB.class, Default.class);
return "name: " + user.getName() + ", age:" + user.getAge();
}
private void validData(BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
StringBuffer sb = new StringBuffer();
for (ObjectError error : bindingResult.getAllErrors()) {
sb.append(error.getDefaultMessage());
}
throw new ValidationException(sb.toString());
}
}
/**
* 實體類參數有效性驗證
* @param bean 驗證的實體對象
* @param groups 驗證組
* @return 驗證成功:返回true;驗證失敗:將錯誤信息添加到message中
*/
public void validObject(Object bean, Validator validator, Class<?> ...groups) {
Set<ConstraintViolation<Object>> constraintViolationSet = validator.validate(bean, groups);
if (!constraintViolationSet.isEmpty()) {
StringBuilder sb = new StringBuilder();
for (ConstraintViolation violation: constraintViolationSet) {
sb.append(violation.getMessage());
}
throw new ValidationException(sb.toString());
}
}
}
@Data
public class Parent {
@NotEmpty(message = "parent name cannot be empty", groups = {GroupB.class})
private String name;
@Email(message = "should be email format")
private String email;
}
自定義參數校驗
雖然JSR303和Hibernate Validtor 已經提供了很多校驗注解,但是當面對復雜參數校驗時,還是不能滿足我們的要求,這時候我們就需要自定義校驗注解。這里我們再回到上面的例子介紹一下自定義參數校驗的步驟。private List<@NotNull @Valid UserInfo> parents
這種在容器中進行參數校驗是Bean Validation2.0
的新特性,假如沒有這個特性,我們來試着自定義一個List數組中不能含有null元素的注解。這個過程大概可以分為兩步:
- 自定義一個用於參數校驗的注解,並為該注解指定校驗規則的實現類
- 實現校驗規則的實現類
自定義注解
定義@ListNotHasNull
注解, 用於校驗 List 集合中是否有null 元素
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
//此處指定了注解的實現類為ListNotHasNullValidatorImpl
@Constraint(validatedBy = ListNotHasNullValidatorImpl.class)
public @interface ListNotHasNull {
/**
* 添加value屬性,可以作為校驗時的條件,若不需要,可去掉此處定義
*/
int value() default 0;
String message() default "List集合中不能含有null元素";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/**
* 定義List,為了讓Bean的一個屬性上可以添加多套規則
*/
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@interface List {
ListNotHasNull[] value();
}
}
注意:message、groups、payload屬性都需要定義在參數校驗注解中不能缺省
注解實現類
該類需要實現ConstraintValidator
import org.springframework.stereotype.Service;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.List;
public class ListNotHasNullValidatorImpl implements ConstraintValidator<ListNotHasNull, List> {
private int value;
@Override
public void initialize(ListNotHasNull constraintAnnotation) {
//傳入value 值,可以在校驗中使用
this.value = constraintAnnotation.value();
}
public boolean isValid(List list, ConstraintValidatorContext constraintValidatorContext) {
for (Object object : list) {
if (object == null) {
//如果List集合中含有Null元素,校驗失敗
return false;
}
}
return true;
}
}
然后我們就能在之前的例子中使用該注解了:
@NotEmpty
@ListNotHasNull
private List<@Valid UserInfo> parents;
其他
Difference Between @NotNull, @NotEmpty, and @NotBlank
@NotNull
不能為null
,但是可以為空字符串""
@NotEmpty
不能為null
,不能為空字符串""
,其本質是CharSequence, Collection, Map, or Array的size或者length不能為0
@NotBlank
a constrained String is valid as long as it’s not null and the trimmed length is greater than zero
@NonNull
@NotNull 是 JSR303(Bean的校驗框架)的注解,用於運行時檢查一個屬性是否為空,如果為空則不合法。
@NonNull 是JSR 305(缺陷檢查框架)的注解,是告訴編譯器這個域不可能為空,當代碼檢查有空值時會給出一個風險警告,目前這個注解只有IDEA支持。