我们知道,面向切面编程是一个非常成熟的代码解决方案。我们可以通过不改变代码结构的情况下增强特定代码段的功能,比如最经典的加注解完成方法运行时间计算。切面和切点就成为了代码增强的要点。而Java中主要使用强大的反射机制完成这一解析。
前段时间有一个需要用到Dubbo的明文参数传递Token鉴权,而一个应用里面有很多前端控制器接口都需要转写成dubbo的接口去给其他应用调用,一个个写鉴权逻辑又导致代码冗余过高,用静态方法解析似乎也不够简洁。于是我想到了注解和切面的鉴权方式。
本文只讨论获取方法指定参数的技巧,不讨论鉴权逻辑。
我们需要一个SpringBoot工程,版本2.x,并需要引入如下依赖
<!-- aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.7</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
至此,第一阶段的准备工作就做好了
SpringAOP不是所有场景都能用的,只有在被切方法是代理调用的时候,切面才能生效,也就是说,如果被切方法是被直接调用的,那么这个方法是不能被切的。
说人话就是,只有注入到Spring容器里面的对象,通过Spring代理的方式调用,这个方法才能被切面捕获并调用切面方法。
举两个例子,如下方式是不能被切的:
public void funcA() {
funcB();
}
@AnnoTest
public void funcB() {
}
所以要想在同一个类里面互相调用还能被切,那么可以将类本身注入到Spring容器,再进行调用,这样就可以被切到对应的方法了
public class Demo {
@Autowired
Demo demo;
public void funcA() {
demo.funcB();
}
@AnnoTest
public void funcB() {
}
}
讲完了注意事项,我们来看如何获取到方法参数,首先我们定义一个注解,采用注解的形式对该方法进行切面获取
@Target(ElementType.METHOD) //表示在方法上可用
@Retention(RetentionPolicy.RUNTIME) //运行时生效
@Documented
public @interface TokenCheck {
}
我们定义一个需要被切的方法
public class AopPointCutDemo() {
@TokenCheck
public String aopDemo(String parm1, String param2, String tokenId) {
//自己的方法逻辑,已省略,不重要。
}
}
现在在这个类里面定义了一个方法,这个方法里面有三个参数,其中第三个tokenId
是我们需要获取的鉴权对象。并在方法上加上该注解
我们创建一个类:TokenCheckHandler
,并完成如下代码
@Slf4j //日志工具,可引入lombok依赖使用
@Aspect //切面
@Component //注入Spring容器
public class TokenCheckHandler {
//定义切点,切点为带TokenCheck注解的方法
@Pointcut("@annotation(TokenCheck)")
private void pointCut() {
}
@Around("pointCut()")
public Object annotationAround(ProceedingJoinPoint pjp) throws Throwable {
//切点获取逻辑
}
private void isTokenValid(String tokenId) {
//具体的tokenId鉴权逻辑
}
}
至此,一个切面的大概代码就完成了,接下来就是重头戏,获取token完成解析。
annotationAround
中的ProceedingJoinPoint pjp
参数,获取到切点的类名和方法名//声明tokenId
String tokenId = null;
//获取切面的类名和方法名
String classType = pjp.getTarget().getClass().getName();
String methodName = pjp.getSignature().getName();
private static HashMap<String, Class> map = new HashMap<String, Class>() {
{
put("java.lang.Integer", Integer.class);
put("java.lang.Double", Double.class);
put("java.lang.Float", Float.class);
put("java.lang.Long", Long.class);
put("java.lang.Short", Short.class);
put("java.lang.Boolean", Boolean.class);
put("java.lang.Char", Character.class);
}
};
然后写for循环获取到每一个参数的类型
//获取参数值
Object[] args = pjp.getArgs();
//解析参数的类名
Class<?>[] classes = new Class[args.length];
for (int k = 0; k < args.length; k++) {
if (!args[k].getClass().isPrimitive()) {
// 获取的是封装类型而不是基础类型
String result = args[k].getClass().getName();
Class s = map.get(result);
classes[k] = s == null ? args[k].getClass() : s;
}
}
方法签名=类型+方法名+方法参数类型
Method method = Class.forName(classType).getMethod(methodName, classes);
获取方法参数。
接下来这里需要引入一个知识点,是关于Spring核心的一个方法参数名的解析功能,众所周知,Java在运行时是不记录方法参数名的,例如你定义的时候是String id,到执行的时候就变成了Sting param1…等,所以Spring在这里使用了一个ASM工具,可以在运行时获取字节码的方法参数变量,从而得到参数名去解析对应的MVC参数,这个东西咱了解就好,有兴趣的可以去翻看Spring的这段源码。
定义一个获取参数名称的工具ParameterNameDiscoverer
//获取参数名称的工具
ParameterNameDiscoverer pnd = new DefaultParameterNameDiscoverer();
//通过上述步骤获取的方法对象得到方法的参数名称
String[] parameterNames = pnd.getParameterNames(method);
for (int i = 0; i < Objects.requireNonNull(parameterNames).length; i++) {
//比对方法名称,找到是tokenId并且是字符串类型则赋值给tokenId,跳出循环
if ("tokenId".equals(parameterNames[i]) && "java.lang.String".equals(classes[i].getName())) {
tokenId = (String) args[i];
break;
}
}
tokenId
进行鉴权,鉴权完成后放行方法继续运行被切方法逻辑(省略)isTokenValid(tokenId);
log.info("tokenId已解析,完成鉴权!");
return pjp.proceed();
按照正常逻辑来说,以上代码是没有什么问题的,能够正常在spring环境或者dubbo环境中使用。但是如果此时调用方传入的参数中有一个为null
,那么就会报空指针异常
通过定位,找到问题出在切点逻辑的第2点,for循环获取参数类型上,如果传入的参数为空,那么args[k].getClass().isPrimitive()
这个方法就不能执行,导致出现空指针异常。
那么有聪明的小伙伴就想到了,那我在这个逻辑之上再加一个判空不就行了?有道理,将逻辑改成这样
Object[] args = pjp.getArgs();
Class<?>[] classes = new Class[args.length];
for (int k = 0; k < args.length; k++) {
//避免空指针
if (args[k] == null) {
//遇到空的赋值为Object类型
classes[k] = Object.class;
//进入下一次循环
continue;
}
if (!args[k].getClass().isPrimitive()) {
// 获取的是封装类型而不是基础类型
String result = args[k].getClass().getName();
Class s = map.get(result);
classes[k] = s == null ? args[k].getClass() : s;
}
}
但是很快你就会发现这段代码是不报错了,但是下面这行代码却报了NoSuchMethodException
的异常
Method method = Class.forName(classType).getMethod(methodName, classes);
还记得上面说过的方法签名获取方法对象吗?我们给未知的null
参数的Class数组赋值了Object.class
那么自然也就找不到对应的方法了,看样子这个方法是不能传null
了,但是传null
的场景还是很多的,不能因噎废食。普通反射的方式看来是行不通了。
但是,灵性的但是,我们的切点,不是能获取方法名吗?那凭啥不能获取到方法对象呢?
但很可惜,ProceedingJoinPoint
里面的getSignature()
方法获取到的Signature
接口对象,并没有getMethod()
方法。
于是我开始找源码,往这个接口的实现类里面去寻找,发现实现它的下一级接口MethodSignature
拥有getMethod()
这个方法,于是,我开始Debug,发现其实这个接口所承载的对象,就是MethodSignature
的实现类MethodSignatureImpl
!
于是我使用了强转的方式,将参数类型由MethodSignature
接收,从而成功获取到当前切点的方法
//获取切面的类名和方法名
//String classType = pjp.getTarget().getClass().getName();
//String methodName = pjp.getSignature().getName();
//获取被切方法
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method invokeMethod = signature.getMethod();
//中间省略开始...
//中间省略结束...
ParameterNameDiscoverer pnd = new DefaultParameterNameDiscoverer();
//Method method = Class.forName(classType).getMethod(methodName, classes);
// 参数名
String[] parameterNames = pnd.getParameterNames(invokeMethod);
这样就能够正常获取到被切方法,而且方法的参数名也能被获取出来了,原来的那种方式,其实有点舍近求远。明明被切的切点就可以拿到方法对象,还使用方法签名来获取它,多此一举。
其实翻看很多博客和教程,大部分都是按照我上面的方法进行参数的处理,没有考虑参数可以为null的情况,导致空指针异常!
这样,我们就能成功处理接收方法的参数为null
的情况了。
至此,我们就实现了一个能够获取注解方法参数的功能。
因篇幅问题不能全部显示,请点此查看更多更全内容