一、PathVariable 校驗
在定義 Restful 風格的接口時,通常會采用 PathVariable 指定關鍵業務參數,如下:
@GetMapping("/path/{group:[a-zA-Z0-9_]+}/{userid}") @ResponseBody public String path(@PathVariable("group") String group, @PathVariable("userid") Integer userid) { return group + ":" + userid; }
{group:[a-zA-Z0-9_]+} 這樣的表達式指定了 group 必須是以大小寫字母、數字或下划線組成的字符串。
我們試着訪問一個錯誤的路徑:
GET /path/testIllegal.get/10000
此時會得到 404的響應,因此對於PathVariable 僅由正則表達式可達到校驗的目的
二、方法參數校驗
類似前面的例子,大多數情況下,我們都會直接將HTTP請求參數映射到方法參數上。
@GetMapping("/param") @ResponseBody public String param(@RequestParam("group")@Email String group, @RequestParam("userid") Integer userid) { return group + ":" + userid; }
上面的代碼中,@RequestParam 聲明了映射,此外我們還為 group 定義了一個規則(復合Email格式)
這段代碼是否能直接使用呢?答案是否定的,為了啟用方法參數的校驗能力,還需要完成以下步驟:
- 聲明 MethodValidationPostProcessor
@Bean public MethodValidationPostProcessor methodValidationPostProcessor() { return new MethodValidationPostProcessor(); }
- Controller指定@Validated注解
@Controller @RequestMapping("/validate") @Validated public class ValidateController {
如此之后,方法上的@Email規則才能生效。
校驗異常
如果此時我們嘗試通過非法參數進行訪問時,比如提供非Email格式的 group
會得到以下錯誤:
GET /validate/param?group=simple&userid=10000 ====> { "timestamp": 1530955093583, "status": 500, "error": "Internal Server Error", "exception": "javax.validation.ConstraintViolationException", "message": "No message available", "path": "/validate/param" }
而如果參數類型錯誤,比如提供非整數的 userid,會得到:
GET /validate/param?group=simple&userid=1f ====> { "timestamp": 1530954430720, "status": 400, "error": "Bad Request", "exception": "org.springframework.web.method.annotation.MethodArgumentTypeMismatchException", "message": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; nested exception is java.lang.NumberFormatException: For input string: \"1f\"", "path": "/validate/param" }
當存在參數缺失時,由於定義的@RequestParam注解中,屬性 required=true,也將會導致失敗:
GET /validate/param?userid=10000 ====> { "timestamp": 1530954345877, "status": 400, "error": "Bad Request", "exception": "org.springframework.web.bind.MissingServletRequestParameterException", "message": "Required String parameter 'group' is not present", "path": "/validate/param" }
三、表單對象校驗
頁面的表單通常比較復雜,此時可以將請求參數封裝到表單對象中,
並指定一系列對應的規則,參考JSR-303
public static class FormRequest { @NotEmpty @Email private String email; @Pattern(regexp = "[a-zA-Z0-9_]{6,30}") private String name; @Min(5) @Max(199) private int age;
上面定義的屬性中:
- email必須非空、符合Email格式規則;
- name必須為大小寫字母、數字及下划線組成,長度在6-30個;
- age必須在5-199范圍內
Controller方法中的定義:
@PostMapping("/form") @ResponseBody public FormRequest form(@Validated FormRequest form) { return form; }
@Validated指定了參數對象需要執行一系列校驗。
校驗異常
此時我們嘗試構造一些違反規則的輸入,會得到以下的結果:
{
"timestamp": 1530955713166, "status": 400, "error": "Bad Request", "exception": "org.springframework.validation.BindException", "errors": [ { "codes": [ "Email.formRequest.email", "Email.email", "Email.java.lang.String", "Email" ], "arguments": [ { "codes": [ "formRequest.email", "email" ], "arguments": null, "defaultMessage": "email", "code": "email" }, [], { "arguments": null, "codes": [ ".*" ], "defaultMessage": ".*" } ], "defaultMessage": "不是一個合法的電子郵件地址", "objectName": "formRequest", "field": "email", "rejectedValue": "tecom", "bindingFailure": false, "code": "Email" }, { "codes": [ "Pattern.formRequest.name", "Pattern.name", "Pattern.java.lang.String", "Pattern" ], "arguments": [ { "codes": [ "formRequest.name", "name" ], "arguments": null, "defaultMessage": "name", "code": "name" }, [], { "arguments": null, "codes": [ "[a-zA-Z0-9_]{6,30}" ], "defaultMessage": "[a-zA-Z0-9_]{6,30}" } ], "defaultMessage": "需要匹配正則表達式\"[a-zA-Z0-9_]{6,30}\"", "objectName": "formRequest", "field": "name", "rejectedValue": "fefe", "bindingFailure": false, "code": "Pattern" }, { "codes": [ "Min.formRequest.age", "Min.age", "Min.int", "Min" ], "arguments": [ { "codes": [ "formRequest.age", "age" ], "arguments": null, "defaultMessage": "age", "code": "age" }, 5 ], "defaultMessage": "最小不能小於5", "objectName": "formRequest", "field": "age", "rejectedValue": 2, "bindingFailure": false, "code": "Min" } ], "message": "Validation failed for object='formRequest'. Error count: 3", "path": "/validate/form" }
如果是參數類型不匹配,會得到:
{
"timestamp": 1530955359265, "status": 400, "error": "Bad Request", "exception": "org.springframework.validation.BindException", "errors": [ { "codes": [ "typeMismatch.formRequest.age", "typeMismatch.age", "typeMismatch.int", "typeMismatch" ], "arguments": [ { "codes": [ "formRequest.age", "age" ], "arguments": null, "defaultMessage": "age", "code": "age" } ], "defaultMessage": "Failed to convert property value of type 'java.lang.String' to required type 'int' for property 'age'; nested exception is java.lang.NumberFormatException: For input string: \"\"", "objectName": "formRequest", "field": "age", "rejectedValue": "", "bindingFailure": true, "code": "typeMismatch" } ], "message": "Validation failed for object='formRequest'. Error count: 1", "path": "/validate/form" }
Form表單參數上,使用@Valid注解可達到同樣目的,而關於兩者的區別則是:
@Valid 基於JSR303,即 Bean Validation 1.0,由Hibernate Validator實現;
@Validated 基於JSR349,是Bean Validation 1.1,由Spring框架擴展實現;
后者做了一些增強擴展,如支持分組校驗,有興趣可參考這里。
四、RequestBody 校驗
對於直接Json消息體輸入,同樣可以定義校驗規則:
@PostMapping("/json") @ResponseBody public JsonRequest json(@Validated @RequestBody JsonRequest request) { return request; } ... public static class JsonRequest { @NotEmpty @Email private String email; @Pattern(regexp = "[a-zA-Z0-9_]{6,30}") private String name; @Min(5) @Max(199) private int age;
校驗異常
構造一個違反規則的Json請求體進行輸入,會得到:
{
"timestamp": 1530956161314, "status": 400, "error": "Bad Request", "exception": "org.springframework.web.bind.MethodArgumentNotValidException", "errors": [ { "codes": [ "Min.jsonRequest.age", "Min.age", "Min.int", "Min" ], "arguments": [ { "codes": [ "jsonRequest.age", "age" ], "arguments": null, "defaultMessage": "age", "code": "age" }, 5 ], "defaultMessage": "最小不能小於5", "objectName": "jsonRequest", "field": "age", "rejectedValue": 1, "bindingFailure": false, "code": "Min" } ], "message": "Validation failed for object='jsonRequest'. Error count: 1", "path": "/validate/json" }
此時與FormBinding的情況不同,我們得到了一個MethodArgumentNotValidException異常。
而如果發生參數類型不匹配,比如輸入age=1f,會產生以下結果:
{
"timestamp": 1530956206264, "status": 400, "error": "Bad Request", "exception": "org.springframework.http.converter.HttpMessageNotReadableException", "message": "Could not read document: Can not deserialize value of type int from String \"ff\": not a valid Integer value\n at [Source: java.io.PushbackInputStream@68dc9800; line: 2, column: 8] (through reference chain: org.zales.dmo.boot.controllers.ValidateController$JsonRequest[\"age\"]); nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Can not deserialize value of type int from String \"ff\": not a valid Integer value\n at [Source: java.io.PushbackInputStream@68dc9800; line: 2, column: 8] (through reference chain: org.zales.dmo.boot.controllers.ValidateController$JsonRequest[\"age\"])", "path": "/validate/json" }
這表明在JSON轉換過程中已經失敗!
五、自定義校驗規則
框架內預置的校驗規則可以滿足大多數場景使用,
但某些特殊情況下,你需要制作自己的校驗規則,這需要用到ContraintValidator接口。
我們以一個密碼校驗的場景作為示例,比如一個注冊表單上,
我們需要檢查 密碼輸入 與 密碼確認 是一致的。
**首先定義 PasswordEquals 注解
@Documented @Constraint(validatedBy = { PasswordEqualsValidator.class }) @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface PasswordEquals { String message() default "Password is not the same"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
在表單上聲明@PasswordEquals 注解
@PasswordEquals public class RegisterForm { @NotEmpty @Length(min=5,max=30) private String username; @NotEmpty private String password; @NotEmpty private String passwordConfirm;
針對@PasswordEquals實現校驗邏輯
public class PasswordEqualsValidator implements ConstraintValidator<PasswordEquals, RegisterForm> { @Override public void initialize(PasswordEquals anno) { } @Override public boolean isValid(RegisterForm form, ConstraintValidatorContext context) { String passwordConfirm = form.getPasswordConfirm(); String password = form.getPassword(); boolean match = passwordConfirm != null ? passwordConfirm.equals(password) : false; if (match) { return true; } String messageTemplate = context.getDefaultConstraintMessageTemplate(); // disable default violation rule [ 禁用默認違規規則] context.disableDefaultConstraintViolation(); // assign error on password Confirm field context.buildConstraintViolationWithTemplate(messageTemplate).addPropertyNode("passwordConfirm") .addConstraintViolation(); return false; } }
如此,我們已經完成了自定義的校驗工作。
六、異常攔截器
SpringBoot 框架中可通過 @ControllerAdvice 實現Controller方法的攔截操作。
可以利用攔截能力實現一些公共的功能,比如權限檢查、頁面數據填充,以及全局的異常處理等等。
在前面的篇幅中,我們提及了各種校驗失敗所產生的異常,整理如下表:
異常類型 | 描述 |
---|---|
ConstraintViolationException | 違反約束,javax擴展定義 |
BindException | 綁定失敗,如表單對象參數違反約束 |
MethodArgumentNotValidException | 參數無效,如JSON請求參數違反約束 |
MissingServletRequestParameterException | 參數缺失 |
TypeMismatchException | 參數類型不匹配 |
如果希望對這些異常實現統一的捕獲,並返回自定義的消息,
可以參考以下的代碼片段:
@ControllerAdvice public static class CustomExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(value = { ConstraintViolationException.class }) public ResponseEntity<String> handle(ConstraintViolationException e) { Set<ConstraintViolation<?>> violations = e.getConstraintViolations(); StringBuilder strBuilder = new StringBuilder(); for (ConstraintViolation<?> violation : violations) { strBuilder.append(violation.getInvalidValue() + " " + violation.getMessage() + "\n"); } String result = strBuilder.toString(); return new ResponseEntity<String>("ConstraintViolation:" + result, HttpStatus.BAD_REQUEST); } @Override protected ResponseEntity<Object> handleBindException(BindException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { return new ResponseEntity<Object>("BindException:" + buildMessages(ex.getBindingResult()), HttpStatus.BAD_REQUEST); } @Override protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { return new ResponseEntity<Object>("MethodArgumentNotValid:" + buildMessages(ex.getBindingResult()), HttpStatus.BAD_REQUEST); } @Override public ResponseEntity<Object> handleMissingServletRequestParameter(MissingServletRequestParameterException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { return new ResponseEntity<Object>("ParamMissing:" + ex.getMessage(), HttpStatus.BAD_REQUEST); } @Override protected ResponseEntity<Object> handleTypeMismatch(TypeMismatchException ex, HttpHeaders headers, HttpStatus status, WebRequest request) { return new ResponseEntity<Object>("TypeMissMatch:" + ex.getMessage(), HttpStatus.BAD_REQUEST); } private String buildMessages(BindingResult result) { StringBuilder resultBuilder = new StringBuilder(); List<ObjectError> errors = result.getAllErrors(); if (errors != null && errors.size() > 0) { for (ObjectError error : errors) { if (error instanceof FieldError) { FieldError fieldError = (FieldError) error; String fieldName = fieldError.getField(); String fieldErrMsg = fieldError.getDefaultMessage(); resultBuilder.append(fieldName).append(" ").append(fieldErrMsg).append(";"); } } } return resultBuilder.toString(); } }
默認情況下,對於非法的參數輸入,框架會產生 HTTP_BAD_REQUEST(status=400) 錯誤碼,
並輸出友好的提示消息,這對於一般情況來說已經足夠。
更多的輸入校驗及提示功能應該通過客戶端去完成(服務端僅做同步檢查),
客戶端校驗的用戶體驗更好,而這也符合富客戶端(rich client)的發展趨勢。
參考文檔
springmvc-validation樣例
使用validation api進行操作
hibernate-validation官方文檔
Bean-Validation規范