Spring MVC 通過切面,實現超靈活的註解式數據校驗 原 薦

這篇文在主要是介紹,如何在 Controller 的方法裏面,讓校驗註解 ( @NotNull @Email @Size...等),對基本類型的數據生效(基本類型 Integer,String,Long等)。

Spring MVC 有什麼校驗方式?

大家都知道,Spring MVC 默認依賴了 hibernate-validator 校驗框架。使用這個,我們可以在可以在model的字段上,加相應的校驗註解來輕鬆的實現數據校驗。 例如:

// 實體類
public class User {
  @NotNull
  private String username;
  
  @NotBlank
  @Length(min = 6, max = 32)
  private String password;

}

// Controller 請求
@RequestMapping("save-user")
 // 使用 @Valid 註解,告訴 Spring MVC 要校驗 user 對象的數據
public User save(@Valid User user){
.....
}

相信大家都有接觸過,使用這種方法來實現整體對象的校驗,而且還可以根據不同場景,加上不同的 @Group 註解,來實現不同請求對數據的校驗規則。

我們想實現什麼?

但是有些時候,我們的請求參數並不多,可能只是一些零碎的基本類型的參數 例如 String Integer Long 等等。就像下面這個請求:

@RequestMapping("update-user-status")
public User update(String userId, Integer status){
....
}

這種情況相信大家經常遇到,大部分情況下,我們都需要對接收過來的數據做校驗。 如果接收過來的是基本類型,我們一般都是包裝一些工具類,然後通過編碼的方式來實現校驗。如果這個時候,我們想用 @NotNull, @Email,@Size 等校驗註解,直接加在參數上,是做不到的。
例如這樣,spring mvc 是不支持的。

@RequestMapping("update-user-status")
public User update( 
              @NotNull String userId, 
              @NotNull @Range(min = 0, max = 5) Integer status){
....
}

那麼這篇文章主要就是講解,如何讓加在基本類型上的校驗註解生效,最終實現上代碼所呈現的效果,在基本類型參數上校驗註解,執行校驗邏輯。

Hibernate-Validator 方法參數校驗說明

因爲Spring MVC 默認使用的是 Hibernate-Validator 來進行數據校驗,那麼我首先瞄準的目標也就是看看 Hibernate-Validator 有沒有什麼方法可以直接對方法的參數進行校驗。
最終我在官方文檔裏找到,ExecutableValidator這個接口裏有一個 validateParameters,實現我們想要的功能,該方法聲明如下:

interface ExecutableValidator{
//...
 <T> Set<ConstraintViolation<T>> validateParameters(
                              T object, // 需要校驗的方法所屬對象
                              Method method,  // 需要校驗的方法
                              Object[] parameterValues, // 需要交驗的方法對應的參數
                              Class<?>... groups);  // 校驗組(這裏我們暫時用不到)
//...
}

如何使用呢? 我寫了一個測試類來測試這個方法具體返回的內容。

package diamond.cms.server.mvc.valid;

import java.lang.reflect.Method;
import java.util.Set;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.ValidatorFactory;
import javax.validation.constraints.NotNull;
import javax.validation.executable.ExecutableValidator;

import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.Range;
import org.junit.Test;

public class ExecutableValidatorTest {

    @Test
    public void hibernateVaildTest() throws NoSuchMethodException, SecurityException {
        // 需要校驗的方法實例
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        ExecutableValidator validator = factory.getValidator().forExecutables();

        Method method = this.getClass().getMethod("vaildMethod",  Integer.class, String.class, String.class);
        // 校驗參數,應該是有兩個非法的參數
        Object [] params = new Object[]{100, "", "test"};

        // 獲得校驗結果 Set 集合,有多少個字段校驗錯誤 Set 的大小就是多少
        Set<ConstraintViolation<ExecutableValidatorTest>> constraintViolationSet =
                validator.validateParameters(this, method, params);

        System.out.println("非法參數校驗結果條數: " + constraintViolationSet.size());
        constraintViolationSet.forEach(cons -> {
            System.out.println("非法消息: " + cons.getMessage());
        });

        params = new Object[]{10, "build-test", "test"};
        constraintViolationSet =
                validator.validateParameters(this, method, params);

        System.out.println("合法參數校驗結果條數: " + constraintViolationSet.size());
    }

    // 校驗示範方法
    public void vaildMethod(@NotNull @Range(min = 0, max = 18)Integer age,@NotBlank String build, String test){}
}

上面的方法最終輸出:

非法參數校驗結果條數: 2
非法消息: 需要在0和18之間
非法消息: 不能爲空
合法參數校驗結果條數: 0

獲得校驗所需參數,統一處理進行數據校驗

如何去使用我們上面提到的數據校驗方法呢?首先我們要想,如何去獲得我們需要的參數。我們需要以下參數:

  1. 請求執行的目標對象
  2. 請求執行的方法
  3. 請求的參數

有兩種方式來獲得:

1. 通過實現 HandlerInterceptor 攔截器來實現

因爲通過攔截器實現,有很多坑要填,這裏不推薦使用。主要講第二個方法,通過AOP來實現校驗數據獲取。

2. 通過 AOP(切面)來實現校驗數據獲取

首先說,推薦使用這種方式,上面的那個方式在這篇文章裏只是說說而已。我們來講一講,如何實現這樣一個切面,來獲取我們校驗數據所需的參數。

首先定義一個切面,切入點是所有 controllers 包下所有類的所有方法。 最後我們定義一個方法,在切入點方法之前執行。

當執行到Controller這一層的時候,所有的數據已經被Spring MVC處理好了,包括數據類型的轉換,自定義的WebDataBinder等。所以我們可以直接通過切面獲得所需的校驗參數,做最終校驗。

@Component
@Aspect
public class RequestParamValidAspect{

    Logger log = LoggerFactory.getLogger(getClass());

    @Pointcut("execution(* diamond.cms.server.mvc.controllers.*.*(..))")
    public void controllerBefore(){};

    ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();

    @Before("controllerBefore()")
    public void before(JoinPoint point) throws NoSuchMethodException, SecurityException, ParamValidException{
        //  獲得切入目標對象
        Object target = point.getThis();
        // 獲得切入方法參數
        Object [] args = point.getArgs(); 
        // 獲得切入的方法
        Method method = ((MethodSignature)point.getSignature()).getMethod(); 
          
        // 執行校驗,獲得校驗結果
        Set<ConstraintViolation<Object>> validResult = validMethodParams(target, method, args);

        if (!validResult.isEmpty()) {
            String [] parameterNames = parameterNameDiscoverer.getParameterNames(method); // 獲得方法的參數名稱
            List<FieldError> errors = validResult.stream().map(constraintViolation -> {
                PathImpl pathImpl = (PathImpl) constraintViolation.getPropertyPath();  // 獲得校驗的參數路徑信息
                int paramIndex = pathImpl.getLeafNode().getParameterIndex(); // 獲得校驗的參數位置
                String paramName = parameterNames[paramIndex];  // 獲得校驗的參數名稱
                FieldError error = new FieldError();  // 將需要的信息包裝成簡單的對象,方便後面處理
                error.setName(paramName);  // 參數名稱(校驗錯誤的參數名稱)
                error.setMessage(constraintViolation.getMessage()); // 校驗的錯誤信息
                return error;
            }).collect(Collectors.toList());
            throw new ParamValidException(errors);  // 我個人的處理方式,拋出異常,交給上層處理
        }
    }

    private final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    private final ExecutableValidator validator = factory.getValidator().forExecutables();

    private <T> Set<ConstraintViolation<T>> validMethodParams(T obj, Method method, Object [] params){
        return validator.validateParameters(obj, method, params);
    }
}

FieldError.java

class FieldError implements Serializable{
    private String name;
    private String message;
    // getter / setter...
}

ParamValidException.java

public class ParamValidException extends Exception{
    private List<FieldError>;
    public ParamValidException(List<FieldError> errors) {
        this.fieldErrors = errors;
    }
}

通過這樣的方式,我們請求這個方法:

@RequestMapping(value = "token")
public Result token(@NotBlank String username, @NotBlank String password){
    String token = userService.login(username, PwdUtils.pwd(password));
    Result result = new Result(token);
    return result;
}
  1. 模擬請求不傳參數 http://localhsot/token
{
  "success": false,
  "msg": "invalid params: [`password` 不能爲空, `username` 不能爲空]",
  "code": 10012,
  "data": [
    {
      "name": "password",
      "message": "不能爲空"
    },
    {
      "name": "username",
      "message": "不能爲空"
    }
  ]
}
  1. 模擬請求,只傳username參數 http://localhost/token?username=testusername
{
  "success": false,
  "msg": "invalid params: [`password` 不能爲空]",
  "code": 10012,
  "data": [
    {
      "name": "password",
      "message": "不能爲空"
    }
  ]
}
  1. 模擬請求,傳正確參數 http://localhost/token?username=testusername&password=testpassword
{
  "success": true,
  "code": 0,
  "data": "token-data"
}

以上請求結果,都是獲取基本錯誤信息封裝得來的,根據實際情況可能不同,主要是爲了講解在方法上添加 校驗註解 的效果。

FAQ (常見問題解答)

爲什麼我拋出異常後捕獲不到?

切面內拋出的異常都會被 UndeclaredThrowableException包裝,需要先捕獲這個異常,獲得這個異常後,調用這個他的 getUndeclaredThrowable() 方法,就可以獲得實際的異常了, 例如:

 @ExceptionHandler(UndeclaredThrowableException.class)
 public Result undeclaredThrowableException(UndeclaredThrowableException ex, HttpServletResponse response){
        Throwable throwable = ex.getUndeclaredThrowable(); // 獲得實際異常
        if (throwable instanceof ParamValidException) { // 如果是我們自定義異常就調用自定義異常的處理方法
            return paramValidExceptionHandler((ParamValidException)throwable, response);
        }
        return exception(ex, response);
    }

爲什麼不實現 HandlerInterceptor 攔截器來處理?

實現攔截器後,攔截器提供 preHandle 方法,在請求處理之前執行。

preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)

這個方法傳過來的最後一個參數 Object handler 實際上是一個 HandlerMethod 對象。
可以通過強制轉換獲得 HandlerMethod methodHandler = (HandlerMethod) handler;

通過這個對象,我們可以獲取到處理本次請求的處理對象 HandlerMethod.getBean(),本次請求的處理方法 MethodHandler.getMethod()。 至此,我們校驗需要的前兩個參數都有了。
問題就在這最後一個參數上,最後一個參數我們需要獲得前端傳過來的數據,在這裏,我們只能從 HttpServletRequest request 裏面獲取。 從 request 獲取的參數,都只是原始的 String[] 沒有經過處理和轉換。
如果要實際使用,還需要轉換成 方法 對應的數據類型,並考慮自定義的 WebDataBinder 或其複雜類型的數據轉換。 相當於要把 Spring MVC 處理參數的邏輯重新實現一遍。雖然也是可以完成的,但是太過於複雜,所以不推薦使用這種方式。

代碼中的 LocalVariableTableParameterNameDiscoverer 是個什麼東西

我們需要知道被校驗的參數的名稱,以便告訴前端,具體是哪個參數有問題。 通過 LocalVariableTableParameterNameDiscoverer.getParameterNames(Method method) 方法,獲取到一個字符串數組,裏面就是包含的方法的參數名稱。例如如下這個方法。

@RequestMapping(value = "token")
public Result token(@NotBlank String username, @NotBlank String password){
    String token = userService.login(username, PwdUtils.pwd(password));
    Result result = new Result(token);
    return result;
}

// 演示代碼
LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
Method method = demo.getMethod("token", String.class, String.class);
String [] paramNames = discoverer.getParameterNames(method);
// 最終獲得  
paramNames: ["username", "password"]

項目源碼

博客系統後臺源碼: Github-cms-admin-end
文章內容的代碼片段: Github-cms-admin-end-valid
個人博客: https://diamondfsd.com

文章到此就結束了,希望對大家有所幫助,有什麼問題也可以在下方進行討論。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章