SpringBoot中BeanValidation資料校驗與優雅處理詳解

天喬巴夏丶發表於2020-11-16

本篇要點

JDK1.8、SpringBoot2.3.4release

後端引數校驗的必要性

在開發中,從表現層到持久化層,資料校驗都是一項邏輯差不多,但容易出錯的任務,

前端框架往往會採取一些檢查引數的手段,比如校驗並提示資訊,那麼,既然前端已經存在校驗手段,後端的校驗是否還有必要,是否多餘了呢?

並不是,正常情況下,引數確實會經過前端校驗傳向後端,但如果後端不做校驗,一旦通過特殊手段越過前端的檢測,系統就會出現安全漏洞。

不使用Validator的引數處理邏輯

既然是引數校驗,很簡單呀,用幾個if/else直接搞定:

    @PostMapping("/form")
    public String form(@RequestBody Person person) {
        if (person.getName() == null) {
            return "姓名不能為null";
        }
        if (person.getName().length() < 6 || person.getName().length() > 12) {
            return "姓名長度必須在6 - 12之間";
        }
        if (person.getAge() == null) {
            return "年齡不能為null";
        }
        if (person.getAge() < 20) {
            return "年齡最小需要20";
        }
        // service ..
        return "註冊成功!";
    }

寫法乾脆,但if/else太多,過於臃腫,更何況這只是區區一個介面的兩個引數而已,要是需要更多引數校驗,甚至更多方法都需要這要的校驗,這程式碼量可想而知。於是,這種做法顯然是不可取的,我們可以利用下面這種更加優雅的引數處理方式。

Validator框架提供的便利

Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone.

如果依照下圖的架構,對每個層級都進行類似的校驗,未免過於冗雜。

Jakarta Bean Validation 2.0 - defines a metadata model and API for entity and method validation. The default metadata source are annotations, with the ability to override and extend the meta-data through the use of XML.

The API is not tied to a specific application tier nor programming model. It is specifically not tied to either web or persistence tier, and is available for both server-side application programming, as well as rich client Swing application developers.

Jakarta Bean Validation2.0定義了一個後設資料模型,為實體和方法提供了資料驗證的API,預設將註解作為源,可以通過XML擴充套件源。

SpringBoot自動配置ValidationAutoConfiguration

Hibernate Validator Jakarta Bean Validation的參考實現。

在SpringBoot中,只要類路徑上存在JSR-303的實現,如Hibernate Validator,就會自動開啟Bean Validation驗證功能,這裡我們只要引入spring-boot-starter-validation的依賴,就能完成所需。

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

目的其實是為了引入如下依賴:

    <!-- Unified EL 獲取動態表示式-->
	<dependency>
      <groupId>org.glassfish</groupId>
      <artifactId>jakarta.el</artifactId>
      <version>3.0.3</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.hibernate.validator</groupId>
      <artifactId>hibernate-validator</artifactId>
      <version>6.1.5.Final</version>
      <scope>compile</scope>
    </dependency>

SpringBoot對BeanValidation的支援的自動裝配定義在org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration類中,提供了預設的LocalValidatorFactoryBean和支援方法級別的攔截器MethodValidationPostProcessor

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ExecutableValidator.class)
@ConditionalOnResource(resources = "classpath:META-INF/services/javax.validation.spi.ValidationProvider")
@Import(PrimaryDefaultValidatorPostProcessor.class)
public class ValidationAutoConfiguration {

	@Bean
	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
	@ConditionalOnMissingBean(Validator.class)
	public static LocalValidatorFactoryBean defaultValidator() {
        //ValidatorFactory
		LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
		MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
		factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
		return factoryBean;
	}

    // 支援Aop,MethodValidationInterceptor方法級別的攔截器
	@Bean
	@ConditionalOnMissingBean
	public static MethodValidationPostProcessor methodValidationPostProcessor(Environment environment,
			@Lazy Validator validator) {
		MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
		boolean proxyTargetClass = environment.getProperty("spring.aop.proxy-target-class", Boolean.class, true);
		processor.setProxyTargetClass(proxyTargetClass);
        // factory.getValidator(); 通過factoryBean獲取了Validator例項,並設定
		processor.setValidator(validator);
		return processor;
	}

}

Validator+BindingResult優雅處理

預設已經引入相關依賴。

為實體類定義約束註解

/**
 * 實體類欄位加上javax.validation.constraints定義的註解
 * @author Summerday
 */

@Data
@ToString
public class Person {
    private Integer id;
    
    @NotNull
    @Size(min = 6,max = 12)
    private String name;

    @NotNull
    @Min(20)
    private Integer age;
}

使用@Valid或@Validated註解

@Valid和@Validated在Controller層做方法引數校驗時功能相近,具體區別可以往後面看。

@RestController
public class ValidateController {

    @PostMapping("/person")
    public Map<String, Object> validatePerson(@Validated @RequestBody Person person, BindingResult result) {
        Map<String, Object> map = new HashMap<>();
        // 如果有引數校驗失敗,會將錯誤資訊封裝成物件組裝在BindingResult裡
        if (result.hasErrors()) {
            List<String> res = new ArrayList<>();
            result.getFieldErrors().forEach(error -> {
                String field = error.getField();
                Object value = error.getRejectedValue();
                String msg = error.getDefaultMessage();
                res.add(String.format("錯誤欄位 -> %s 錯誤值 -> %s 原因 -> %s", field, value, msg));
            });
            map.put("msg", res);
            return map;
        }
        map.put("msg", "success");
        System.out.println(person);
        return map;
    }
}

傳送Post請求,偽造不合法資料

這裡使用IDEA提供的HTTP Client工具傳送請求。

POST http://localhost:8081/person
Content-Type: application/json

{
  "name": "hyh",
  "age": 10
}

響應資訊如下:

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 14 Nov 2020 15:58:17 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "msg": [
    "錯誤欄位 -> name 錯誤值 -> hyh 原因 -> 個數必須在6和12之間",
    "錯誤欄位 -> age 錯誤值 -> 10 原因 -> 最小不能小於20"
  ]
}

Response code: 200; Time: 393ms; Content length: 92 bytes

Validator + 全域性異常處理

在介面方法中利用BindingResult處理校驗資料過程中的資訊是一個可行方案,但在介面眾多的情況下,就顯得有些冗餘,我們可以利用全域性異常處理,捕捉丟擲的MethodArgumentNotValidException異常,並進行相應的處理。

定義全域性異常處理

@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * If the bean validation is failed, it will trigger a MethodArgumentNotValidException.
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpStatus status) {
        BindingResult result = ex.getBindingResult();
        Map<String, Object> map = new HashMap<>();
        List<String> list = new LinkedList<>();
        result.getFieldErrors().forEach(error -> {
            String field = error.getField();
            Object value = error.getRejectedValue();
            String msg = error.getDefaultMessage();
            list.add(String.format("錯誤欄位 -> %s 錯誤值 -> %s 原因 -> %s", field, value, msg));
        });
        map.put("msg", list);
        return new ResponseEntity<>(map, status);
    }
}

定義介面

@RestController
public class ValidateController {

    @PostMapping("/person")
    public Map<String, Object> validatePerson(@Valid @RequestBody Person person) {
        Map<String, Object> map = new HashMap<>();
        map.put("msg", "success");
        System.out.println(person);
        return map;
    }
}

@Validated精確校驗到引數欄位

有時候,我們只想校驗某個引數欄位,並不想校驗整個pojo物件,我們可以利用@Validated精確校驗到某個欄位。

定義介面

@RestController
@Validated
public class OnlyParamsController {

    @GetMapping("/{id}/{name}")
    public String test(@PathVariable("id") @Min(1) Long id,
                       @PathVariable("name") @Size(min = 5, max = 10) String name) {
        return "success";
    }
}

傳送GET請求,偽造不合法資訊

GET http://localhost:8081/0/hyh
Content-Type: application/json

未作任何處理,響應結果如下:

{
  "timestamp": "2020-11-15T15:23:29.734+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "trace": "javax.validation.ConstraintViolationException: test.id: 最小不能小於1, test.name: 個數必須在5和10之間...省略",
  "message": "test.id: 最小不能小於1, test.name: 個數必須在5和10之間",
  "path": "/0/hyh"
}

可以看到,校驗已經生效,但狀態和響應錯誤資訊不太正確,我們可以通過捕獲ConstraintViolationException修改狀態。

捕獲異常,處理結果

@ControllerAdvice
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(CustomGlobalExceptionHandler.class);


    /**
     * If the @Validated is failed, it will trigger a ConstraintViolationException
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public void constraintViolationException(ConstraintViolationException ex, HttpServletResponse response) throws IOException {
        ex.getConstraintViolations().forEach(x -> {
            String message = x.getMessage();
            Path propertyPath = x.getPropertyPath();
            Object invalidValue = x.getInvalidValue();
            log.error("錯誤欄位 -> {} 錯誤值 -> {} 原因 -> {}", propertyPath, invalidValue, message);
        });
        response.sendError(HttpStatus.BAD_REQUEST.value());
    }
}

@Validated和@Valid的不同

參考:@Validated和@Valid的區別?教你使用它完成Controller引數校驗(含級聯屬性校驗)以及原理分析【享學Spring】

  • @Valid是標準JSR-303規範的標記型註解,用來標記驗證屬性和方法返回值,進行級聯和遞迴校驗。
  • @Validated:是Spring提供的註解,是標準JSR-303的一個變種(補充),提供了一個分組功能,可以在入參驗證時,根據不同的分組採用不同的驗證機制。
  • Controller中校驗方法引數時,使用@Valid和@Validated並無特殊差異(若不需要分組校驗的話)。
  • @Validated註解可以用於類級別,用於支援Spring進行方法級別的引數校驗。@Valid可以用在屬性級別約束,用來表示級聯校驗
  • @Validated只能用在類、方法和引數上,而@Valid可用於方法、欄位、構造器和引數上。

如何自定義註解

Jakarta Bean Validation API定義了一套標準約束註解,如@NotNull,@Size等,但是這些內建的約束註解難免會不能滿足我們的需求,這時我們就可以自定義註解,建立自定義註解需要三步:

  1. 建立一個constraint annotation。
  2. 實現一個validator。
  3. 定義一個default error message。

建立一個constraint annotation

/**
 * 自定義註解
 * @author Summerday
 */

@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = CheckCaseValidator.class) //需要定義CheckCaseValidator
@Documented
@Repeatable(CheckCase.List.class)
public @interface CheckCase {
    String message() default "{CheckCase.message}";

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

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

    CaseMode value();

    @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        CheckCase[] value();
    }
}

實現一個validator

/**
 * 實現ConstraintValidator
 *
 * @author Summerday
 */
public class CheckCaseValidator implements ConstraintValidator<CheckCase, String> {

    private CaseMode caseMode;

    /**
     * 初始化獲取註解中的值
     */
    @Override
    public void initialize(CheckCase constraintAnnotation) {
        this.caseMode = constraintAnnotation.value();
    }

    /**
     * 校驗
     */
    @Override
    public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
        if (object == null) {
            return true;
        }

        boolean isValid;
        if (caseMode == CaseMode.UPPER) {
            isValid = object.equals(object.toUpperCase());
        } else {
            isValid = object.equals(object.toLowerCase());
        }

        if (!isValid) {
            // 如果定義了message值,就用定義的,沒有則去
            // ValidationMessages.properties中找CheckCase.message的值
            if(constraintContext.getDefaultConstraintMessageTemplate().isEmpty()){
                constraintContext.disableDefaultConstraintViolation();
                constraintContext.buildConstraintViolationWithTemplate(
                        "{CheckCase.message}"
                ).addConstraintViolation();
            }
        }
        return isValid;
    }
}

定義一個default error message

ValidationMessages.properties檔案中定義:

CheckCase.message=Case mode must be {value}.

這樣,自定義的註解就完成了,如果感興趣可以自行測試一下,在某個欄位上加上註解:@CheckCase(value = CaseMode.UPPER)

原始碼下載

本文內容均為對優秀部落格及官方文件總結而得,原文地址均已在文中參考閱讀處標註。最後,文中的程式碼樣例已經全部上傳至Gitee:https://gitee.com/tqbx/springboot-samples-learn,另有其他SpringBoot的整合哦。

參考閱讀

相關文章