1. 程式人生 > 程式設計 >AOP + Aviator 實現引數校驗

AOP + Aviator 實現引數校驗

在開發過程中,始終避免不了的是校驗引數,引數的校驗和業務程式碼耦合在一起,程式碼變得越來越臃腫,影響後期的維護,程式碼也不夠優美。

Aviator 是谷歌的表示式求值引擎。使用Aviator主要是來校驗引數。它支援大部分運算操作符,包括算術操作符、關係運算子、邏輯操作符、正則匹配操作符(=~)、三元表示式?:,並且支援操作符的優先順序和括號強制優先順序。

由於在之前的專案中有用過Aviator,並且我習慣用Assert斷言來進行引數校驗。因為Assert斷言丟擲的異常是IllegalArgumentException,可能會丟擲對使用者不友好的異常。所以才想開發一個引數校驗的東西。

依賴

<?
xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId
>
org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.9.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.ler</groupId> <artifactId>jcheck</artifactId
>
<version>1.0.0-SNAPSHOT</version> <name>jcheck</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!--AOP依賴--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!--Aviator依賴--> <dependency> <groupId>com.googlecode.aviator</groupId> <artifactId>aviator</artifactId> <version>3.3.0</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.56</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.8.1</version> </dependency> <!--swagger--> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger-ui</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.9.2</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> 複製程式碼

首先想到的是註解,可是因為一般會有多個引數校驗,所以需要在同一個方法上使用多個註解。 但是在Java8之前,同一個註解是不能在同一個位置上重複使用的。

雖然可以重複使用註解,其實這也是一個語法糖,多個註解在編譯後其實還是要用一個容器包裹起來。

下面是註解:

package com.ler.jcheck.annation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author lww
 */
@Target({ElementType.FIELD,ElementType.METHOD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
//這個註解就是可以讓一個註解同一個方法上標註多次
@Repeatable(CheckContainer.class)
public @interface Check {

	String ex() default "";

	String msg() default "";

}
複製程式碼
  • ex是需要校驗的表示式,可以使用正則表示式。key是形參的名字,JOSN物件的話,key是形參名字.屬性,具體可以看下面例子。
  • msg是提示的錯誤資訊,需要配合全域性異常攔截器使用。
  • 引數校驗的順序,是註解的順序。
package com.ler.jcheck.annation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author lww
 */
@Target({ElementType.FIELD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckContainer {

	Check[] value();
}
複製程式碼

這個是容器註解,當使用多個註解時,在編譯後會使用這個註解把多個相同的註解包裹起來。 所以AOP切面,應該要監視 CheckCheckContainer

核心類AopConfig

package com.ler.jcheck.config;

import com.googlecode.aviator.AviatorEvaluator;
import com.ler.jcheck.annation.Check;
import com.ler.jcheck.annation.CheckContainer;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.springframework.context.annotation.Configuration;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.util.StringUtils;

/**
 * @author lww
 * @date 2019-09-03 20:35
 */
@Aspect
@Configuration
public class AopConfig {

	/**
	 * 切面,監視多個註解,因為一個註解的時候是Check 多個註解編譯後是CheckContainer
	 */
	@Pointcut("@annotation(com.ler.jcheck.annation.CheckContainer) || @annotation(com.ler.jcheck.annation.Check)")
	public void pointcut() {
	}

	@Before("pointcut()")
	public Object before(JoinPoint point) {
		//獲取引數
		Object[] args = point.getArgs();
		//用於獲取引數名字
		Method method = ((MethodSignature) point.getSignature()).getMethod();
		LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
		String[] paramNames = u.getParameterNames(method);

		CheckContainer checkContainer = method.getDeclaredAnnotation(CheckContainer.class);
		List<Check> value = new ArrayList<>();

		if (checkContainer != null) {
			value.addAll(Arrays.asList(checkContainer.value()));
		} else {
			Check check = method.getDeclaredAnnotation(Check.class);
			value.add(check);
		}
		for (int i = 0; i < value.size(); i++) {
			Check check = value.get(i);
			String ex = check.ex();
			//規則引擎中null用nil表示
			ex = ex.replaceAll("null","nil");
			String msg = check.msg();
			if (StringUtils.isEmpty(msg)) {
				msg = "伺服器異常...";
			}

			Map<String,Object> map = new HashMap<>(16);
			for (int j = 0; j < paramNames.length; j++) {
				//防止索引越界
				if (j > args.length) {
					continue;
				}
				map.put(paramNames[j],args[j]);
			}
			Boolean result = (Boolean) AviatorEvaluator.execute(ex,map);
			if (!result) {
				throw new UserFriendlyException(msg);
			}
		}
		return null;
	}
}
複製程式碼

註釋說的很清楚了。下面來看一下具體使用。

在Controller中的使用

普通引數

	@ApiOperation("測試普通引數")
	@ApiImplicitParams({
			@ApiImplicitParam(name = "name",value = "姓名"),@ApiImplicitParam(name = "age",value = "年齡"),@ApiImplicitParam(name = "phone",value = "手機號"),@ApiImplicitParam(name = "idCard",value = "身份證號"),})
	@GetMapping("/simple")
	@Check(ex = "name != null",msg = "姓名不能為空")
	@Check(ex = "age != null",msg = "年齡不能為空")
	@Check(ex = "age > 18",msg = "年齡要大於18歲")
	@Check(ex = "phone != null",msg = "手機號不能為空")
	@Check(ex = "phone =~ /^(1)[0-9]{10}$/",msg = "手機號格式錯誤")
	@Check(ex = "string.startsWith(phone,\"1\")",msg = "手機號要以1開頭")
	@Check(ex = "idCard != null",msg = "身份證號不能為空")
	//不先判空 com.googlecode.aviator.exception.ExpressionRuntimeException
	@Check(ex = "idCard =~ /^[1-9]\\d{5}[1-9]\\d{3}((0[1-9])||(1[0-2]))((0[1-9])||(1\\d)||(2\\d)||(3[0-1]))\\d{3}([0-9]||X)$/",msg = "身份證號格式錯誤")
	//沒有,不會丟擲 NoSuchMethodException 或者 NullPointerException 異常
	@Check(ex = "gender == 1",msg = "性別")
	@Check(ex = "date =~ /^[1-9][0-9]{3}-((0)[1-9]|(1)[0-2])-((0)[1-9]|[1,2][0-9]|(3)[0,1])$/",msg = "日期格式錯誤")
	@Check(ex = "date > '2019-12-20 00:00:00:00'",msg = "日期要大於 2019-12-20")
	public HttpResult simple(String name,Integer age,String phone,String idCard,String date) {
		System.out.println("name = " + name);
		System.out.println("age = " + age);
		System.out.println("phone = " + phone);
		System.out.println("idCard = " + idCard);
		System.out.println("date = " + date);
		return HttpResult.success();
	}
複製程式碼

如果要校驗引數,應該要先進行非空判斷,如果不校驗,普通引數不會報錯,如 age > 18。但是如果是正則表示式,則會丟擲ExpressionRuntimeException

在校驗日期時,如date > '2019-12-20 00:00:00:00,應該首先校驗格式,因為如果引數格式不能與日期比較時,Aviator是不會比較的。因此不會進行校驗。

如果校驗的是沒有的引數,結果是false,會直接丟擲註解中的 msg 的。

@RequestBody引數

/*
		{
			"age": 0,"bornDate": "string","idCard": "string","name": "string","phone": "string"
		}
	*/
	@ApiOperation("測試 @RequestBody")
	@PostMapping("/body")
	@Check(ex = "user.name != null",msg = "姓名不能為空")
	@Check(ex = "user.age != null",msg = "年齡不能為空")
	@Check(ex = "user.age > 18",msg = "年齡要大於18歲")
	@Check(ex = "user.phone =~ /^(1)[0-9]{10}$/",msg = "手機號格式錯誤")
	@Check(ex = "user.name != null && user.age != null",msg = "姓名和年齡不能為空")
	//先要檢查日期格式,bornDate="string" 這種非正常資料,不會比較大小
	@Check(ex = "user.bornDate =~ /^[1-9][0-9]{3}-((0)[1-9]|(1)[0-2])-((0)[1-9]|[1,msg = "日期格式錯誤")
	@Check(ex = "user.bornDate > '2019-12-20'",msg = "日期要大於 2019-12-20")
	//@Check(ex = "user.gender == 1",msg = "性別")
	//Caused by: java.lang.NoSuchMethodException: Unknown property 'gender' on class 'class com.ler.jcheck.domain.User'
	public HttpResult body(@RequestBody User user) {
		String jsonString = JSONObject.toJSONString(user);
		System.out.println(jsonString);
		return HttpResult.success();
	}
複製程式碼

引數是以JSON的形式傳過來的,ex表示式中的key為形參.屬性名。

什麼都不傳是引數錯誤,如果要傳空,是傳一個{},校驗順序是註解的順序。基本和上面的普通引數相同,有一點不一樣的是,如果ex裡是沒有的屬性,會丟擲java.lang.NoSuchMethodException

在Service中使用

引數校驗是使用AOP切面,監視 CheckCheckContainer這兩個註解,所以只要是Spring代理的類都可以使用該註解來完成引數校驗。

程式碼如下:

Controller

	@ApiOperation("新增 在 Service 中校驗")
	@PostMapping("/addUser")
	public HttpResult addUser(@RequestBody User user) {
		userService.addUser(user);
		return HttpResult.success();
	}

	@ApiOperation("刪除 在 Service 中校驗")
	@ApiImplicitParams({
			@ApiImplicitParam(name = "id",value = "id"),})
	@PostMapping("/delete")
	public HttpResult delete(Long id) {
		userService.deleteUser(id);
		return HttpResult.success();
	}
複製程式碼

Service

package com.ler.jcheck.service;

import com.ler.jcheck.domain.User;

/**
 * @author lww
 */
public interface UserService {

	void addUser(User user);

	void deleteUser(Long id);
}
複製程式碼

ServiceImpl

package com.ler.jcheck.service.impl;

import com.alibaba.fastjson.JSONObject;
import com.ler.jcheck.annation.Check;
import com.ler.jcheck.domain.User;
import com.ler.jcheck.service.UserService;
import org.springframework.stereotype.Service;

/**
 * @author lww
 * @date 2019-10-10 15:33
 */
@Service
public class UserServiceImpl implements UserService {

	@Override
	@Check(ex = "user.name != null",msg = "姓名不能為空")
	public void addUser(User user) {
		System.out.println(JSONObject.toJSONString(user));
	}

	@Override
	@Check(ex = "id != null",msg = "id不能為空!")
	public void deleteUser(Long id) {
		System.out.println("id = " + id);
	}
}
複製程式碼

在Service中使用,其實在和Controller使用是一樣的。

專案程式碼 GitHub

還可以再進一步,把這個專案作為一個Starter,在開發時直接引入依賴,就可以使用了。 可以看一下我的部落格 JCheck引數校驗框架之建立自己的SpringBoot-Starter 這裡把該專案封裝成了一個 SpringBoot-Starter,又集成了Swagger配置,執行環境配置,全域性異常攔截器,跨域配置等。部落格最後有專案的Git地址,還有一些測試圖片。