使用 SpringAOP + hibernate-validator 完美實現自動參數校驗

在前面的文章《Spring 參數校驗最佳實踐》 中,我們介紹過 SpringMVC 如何做自動參數校驗並通過統一異常處理機制在校驗不通過時返回統一的異常。然而這並不完美,如果我們用的是 RequestBody 來接收的參數,一旦校驗失敗,我們在統一異常處理中並不能獲取到完整的參數列表。另外,有些時候我們用的框架可能沒有包含參數校驗的功能,例如一些 RPC 框架。

這種情況下,我們可以通過 SpringAOP + hibernate-validator 來自己實現參數校驗的功能。

加入 maven 依賴

<!-- 參數校驗相關依賴 -->
<dependency>
  <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.17.Final</version>
</dependency>
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>javax.el</artifactId>
    <version>3.0.1-b08</version>
</dependency>
<!-- SpringAOP 相關依賴 -->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>1.9.1</version>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.9.1</version>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aop</artifactId>
    <version>5.1.8.RELEASE</version>
</dependency>

編譯插件

在 pom.xml 文件中添加編譯插件,開啓 <parameters>true</parameters>,這樣在校驗不通過時,可以從結果中獲取到不通過參數名稱,具體參考:《深度分析如何獲取方法參數名》

<build>
	<plugins>
	     <plugin>
	         <groupId>org.apache.maven.plugins</groupId>
	         <artifactId>maven-compiler-plugin</artifactId>
	         <version>3.8.0</version>
	         <configuration>
	             <source>1.8</source>
	             <target>1.8</target>
	             <parameters>true</parameters>
	             <encoding>UTF-8</encoding>
	         </configuration>
	     </plugin>
	</plugins>
</build>

開啓 AOP 功能

直接在帶有 Configuration 註解的類上添加 EnableAspectJAutoProxy 註解

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {
}

實現校驗功能

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.hibernate.validator.HibernateValidator;
import org.hibernate.validator.internal.engine.DefaultParameterNameProvider;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

import javax.annotation.PostConstruct;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.executable.ExecutableValidator;
import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 使用AOP做統一參數校驗
 *
 * @author huangxuyang
 * @since 2019-09-28
 */
@Aspect
@Component
public class ValidationAspect {
    private static Set<Class<?>> SIMPLE_TYPES = new HashSet<>(Arrays.asList(
            Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class, Character.class,
            Boolean.class, String.class, Enum.class, Date.class, Void.class, LocalDateTime.class, LocalDate.class));
    private ExecutableValidator executableValidator;
    private Validator validator;

    @PostConstruct
    public void init() {
        // 這裏初始化校驗器,可以對校驗器進行一些定製
        validator = Validation.byProvider(HibernateValidator.class)
                .configure()
                .failFast(true)
                .ignoreXmlConfiguration()
                .parameterNameProvider(new DefaultParameterNameProvider())
                .buildValidatorFactory()
                .getValidator();
        // 這個 validator 可以直接校驗參數,
        executableValidator = validator.forExecutables();
    }

    /**
     * 定義需要攔截的切面 —— 方法或類上包含 {@link Validated} 註解
     */
    @Pointcut("@target(org.springframework.validation.annotation.Validated)" +
            " ||  @within(org.springframework.validation.annotation.Validated)")
    public void pointcut() {
    }

    @Before("pointcut()")
    public void before(JoinPoint point) {
        if (point.getArgs() == null || point.getArgs().length <= 0) {
            return;
        }
        Method method = ((MethodSignature) point.getSignature()).getMethod();
        // 執行普通參數校驗,獲得校驗結果
        Set<ConstraintViolation<Object>> validResult = executableValidator.validateParameters(point.getTarget(), method, point.getArgs());
        if (validResult == null || validResult.isEmpty()) {
            // 找出所有帶 @Validated 註解的參數
            Annotation[][] parameterAnnotations = method.getParameterAnnotations();
            for (int i = 0; i < parameterAnnotations.length; i++) {
                Annotation[] annotation = parameterAnnotations[i];
                for (Annotation ann : annotation) {
                    if (ann.annotationType().equals(Validated.class)) {
                        validResult = validateBean(point.getArgs()[i]);
                    }
                }
            }
        }
        // 如果有校驗不通過的,直接拋出參數校驗不通過的異常
        if (validResult != null && !validResult.isEmpty()) {
            String msg = validResult.stream()
                    .map(cv -> {
                        // 如果是普通類型帶上具體校驗不通過的值
                        if (isSimpleType(cv.getInvalidValue())) {
                            return cv.getPropertyPath() + " " + cv.getMessage() + ": " + cv.getInvalidValue();
                        } else {
                            return cv.getPropertyPath() + " " + cv.getMessage();
                        }
                    }).collect(Collectors.joining(", "));
            // 若校驗不通過,則直接拋出異常,若有必要,可以通過 point.getArgs() 獲取並記錄完整的參數列表
            throw new IllegalArgumentException(msg);
        }
    }

    /**
     * 校驗參數,支持數據和集合
     */
    private Set<ConstraintViolation<Object>> validateBean(Object arg) {
        Class<?> clz = arg.getClass();
        if (clz.isArray()) {
            // 遍歷數組
            int length = Array.getLength(clz);
            for (int i = 0; i < length; i++) {
                Object obj = Array.get(arg, i);
                Set<ConstraintViolation<Object>> tmp = validator.validate(obj);
                if (tmp != null && !tmp.isEmpty()) {
                    return tmp;
                }
            }
        } else if (arg instanceof Collection) {
            // 遍歷集合
            Collection collection = (Collection) arg;
            for (Object item : collection) {
                Set<ConstraintViolation<Object>> tmp = validator.validate(item);
                if (tmp != null && !tmp.isEmpty()) {
                    return tmp;
                }
            }
        } else {
            return validator.validate(arg);
        }
        return Collections.emptySet();
    }

    /**
     * 是否爲普通類型,即原始數據類型及其包裝類、字符串、日期、枚舉等
     */
    private static boolean isSimpleType(Object obj) {
        if (obj == null) {
            return true;
        }
        Class<?> clazz = obj.getClass();
        if (clazz.isPrimitive()) {
            return true;
        }
        return SIMPLE_TYPES.contains(clazz);
    }
}

這個實現幾乎考慮到了所有的校驗場景,支持普通校驗也支持自定義類型的參數、數組和集合類型。在需要校驗參數的類或方法上添加 @Validated 註解,就可以通過切面進行校驗。如果是複雜對象參數,則需要在參數上也添加 @Validated 註解。

舉個例子

@Data
public class SystemUser {
	  @NotBlank
	  private String username;
	  @Min(0)
	  @Max(150)
	  private int age;

接口

public interface ISystemUserService {
	/**
	 * 校驗普通參數,直接添加校驗註解即可
	 */
    SystemUser getUserById(@Positive long id) throws Exception;
    
    /**
     * 複雜參數,可以當成普通參數進行非空校驗,然後添加 @Validated 註解聲明要進行字段校驗
     */
    long insertUser(@NotNull @Validated SystemUser user) throws Exception;

	/**
	 * 對集合也可以做基本校驗,然後添加 @Validated 註解聲明對集合中的每個對象進行字段校驗
	 */
    List<Long> insertUsers(@NotNull @Size(min = 1, max = 200) @Validated List<SystemUser> users) throws Exception;
}

實現類,要添加 @Validated 註解讓校驗切面對這個類中的所有方法都生效。接口和實現方法上的參數註解必須一致,否則可能會出錯或校驗不生效。

@Validated
public class SystemUserServiceImpl implements ISystemUserService {
    private final SystemUserMapper systemUserMapper;

    @Override
    public SystemUser getUserById(@Positive long id) {
        // 這裏實現查詢邏輯
    }

    @Override
    public long insertUser(@NotNull @Validated SystemUser user) {
        // 這裏實現插入邏輯
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public List<Long> insertUsers(@NotNull @Size(min = 1, max = 200) @Validated List<SystemUser> users) {
        // 這裏實現插入邏輯
    }
}

寫個測試用例看下:
測試用例
可以看到,切面生效了,我們對方法進行了統一的參數校驗!

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