Content Table

AspectJ with Annotation - 自定义注解

AspectJ with XmlAspectJ with Annotation 中介绍了 2 中实现 AoP 的方式:

  • AspectJ with Xml 中介绍使用纯 XML 的方式配置切面 (Java 类) 和切入点 (类的方法)
  • AspectJ with Annotation 中介绍使用注解配置切面,方法限定表达式配置切入点

这里我们介绍实现 AoP 的第三种方法: 使用注解配置切面和切入点,主要有以下几个部分:

  • Gradle 依赖
  • 自定义注解
  • 自定义 AOP 使用注解配置切入点
  • 使用自定义注解
  • Xml 文件中配置自动扫描包路径
  • 测试
  • 三种实现切面方式的比较
  • 使用 SpEL 增强注解

下面就以实现一个分布式锁的注解来进行介绍。

一、Gradle 依赖

项目里通常会使用 Lombok 简化编码,JUnit + Spring Test 进行测试,Logback 作为日志框架,这几个依赖都不是必须的,可以视具体情况而定是否使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ext.versions = [
spring : '5.0.2.RELEASE',
aspectj: '1.9.4',
lombok : '1.16.20',
logback: '1.3.0-alpha4',
slf4j : '1.7.25',
junit : '4.12'
]

dependencies {
compile(
"org.springframework:spring-context-support:${versions.spring}",
"org.springframework:spring-aop:${versions.spring}",
"org.aspectj:aspectjweaver:${versions.aspectj}",
)

// Log dependencies
compile(
"ch.qos.logback:logback-classic:${versions.logback}", // slf4j-logback 绑定
"org.slf4j:jcl-over-slf4j:${versions.slf4j}", // redirect apache commons logging
"org.slf4j:jul-to-slf4j:${versions.slf4j}", // redirect jdk util logging
"org.slf4j:log4j-over-slf4j:${versions.slf4j}", // redirect log4j
)

testCompile "junit:junit:${versions.junit}"
testCompile "org.springframework:spring-test:${versions.spring}"
compileOnly "org.projectlombok:lombok:${versions.lombok}"
annotationProcessor "org.projectlombok:lombok:${versions.lombok}"
}

二、自定义注解

定义了注解 Lock,某个方法上使用 Lock 进行注解时,这个方法在执行时就会使用分布式锁进行保护。

1
2
3
4
5
6
7
8
9
10
11
12
package com.xtuer.annotation;

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

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Lock {
String value() default "";
}

注解的实例对象是用来存储数据的 (静态数据,编译期确定),Spring 会在方法调用的时候自动创建这个注解的实例对象,我们可以在 AoP 的 Advice 方法中获取到这些数据。

注解实例对象的数据可以是写代码时写死的 SpEL 表达式的静态字符串,运行时根据方法的参数动态计算它要表达的值。

三、自定义 AoP 使用注解配置切入点

Spring AoP 使用自定义注解配置切入点很简单, 设置 @Pointcut 的值为我们自定义的注解的全路径名即可,在 Advice 方法 (@Around 注解的方法) 处直接引用 pointcut 和注解 Lock 的对象 (Spring 自动注入):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.xtuer.aspect;

import com.xtuer.annotation.Lock;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Slf4j
@Component
public class LockAspect {
/**
* 通过 @annotation 来配置切点,表示我们定义的切面 LockAspect 会切入到所有用注解 @Lock 修饰的方法上,
* 也就是那些方法执行时会使用下面定义的 @Around
*/
@Pointcut("@annotation(com.xtuer.annotation.Lock)")
public void pointcut() {}

/**
* 使用分布式锁保护函数的调用
*/
@Around("pointcut() && @annotation(lock)")
public Object doAround(ProceedingJoinPoint pjp, Lock lock) throws Throwable {
try {
// 1. 获取分布式锁
log.info("Lock: {}", lock.value());

// 2. 执行函数
return pjp.proceed();
} finally {
// 3. 释放分布式锁
log.info("Unlock: {}", lock.value());
}
}
}

提示:

  • 分布式锁的实现部分不是这里讨论的重点,所以使用了伪代码,具体实现可参考 ZooKeeper 实现分布式锁
  • AOP 代理对象的创建是 Spring 初始化 Bean 完成后在 BeanPostProcessor.postProcessAfterInitialization() 的调用中进行的。

四、使用自定义注解

Service.foo() 上使注解 Lock,期待 Service.foo() 执行时使用上面定义的 LockAspect.doAround()Service.bar() 上不使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.xtuer.service;

import com.xtuer.annotation.Lock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class XService {
@Lock("lock-foo")
public void foo() {
log.info("XService::foo()");
}

public void bar() {
log.info("XService::bar()");
}
}

五、Xml 文件中配置自动扫描包路径

Service 和 Aspect 对象通过 Spring 的包扫描自动生成,就不再使用 <bean> 的方式一个一个的手动创建了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd">

<aop:aspectj-autoproxy/> <!--启用 Annotation 的 AOP-->
<context:component-scan base-package="com.xtuer.service, com.xtuer.aspect"/>
</beans>

在 Spring 容器中创建 LockAspect 的实例后我们自定义的注解才会被 Spring AoP 执行。

六、测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import com.xtuer.service.XService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@ContextConfiguration({"classpath:bean.xml"})
public class XServiceTest {
@Autowired
private XService service;

@Test
public void testFoo() {
service.foo();
}

@Test
public void testBar() {
service.bar();
}
}

输出:

1
2
3
4
5
[2019-07-31 22:30:28] [INFO ] [LockAspect.java-doAround:28] - Lock: lock-foo
[2019-07-31 22:30:28] [INFO ] [XService.java-foo:12] - XService::foo()
[2019-07-31 22:30:28] [INFO ] [LockAspect.java-doAround:34] - Unlock: lock-foo

[2019-07-31 22:30:28] [INFO ] [XService.java-bar:16] - XService::bar()

从输出的日志中可以看到 foo() 上的注解 Lock 生效了,bar() 不受影响,符合我们的期望。

七、三种实现切面方式的比较

到目前我们了解了三种实现切面的方式,它们各有优缺点,适用于不同的场景:

  • AspectJ with Xml: 开启、关闭、修改切入点都只需要修改 xml 配置文件,不需要修改和编译代码,重启程序后就能生效,但是 AoP 配置和逻辑代码分开了,不够直观,不过胜在灵活。

  • AspectJ with Annotation: AoP 配置写死在代码里,修改时需要修改和编译代码,然后发布以及启动程序,并且不利于提供给第三方使用,因为不知道第三方是怎么命名类和方法的,优点是 AoP 配置和逻辑代码放在一起,只是自己使用的时候比较方便一点,但 AoP 和使用它的地方是分离开的。

  • AspectJ with Annotation - 自定义注解: AoP 里只和注解名关联,不限制类和方法名等,非常适合作为库提供给第三方使用,第三方只需要在自己的代码里使用我们提供的注解即可,当然第三方要使用或者删除这个注解就需要修改他们自己的代码了。

    当然也有优雅的方式,通过注解来开启是否使 @Lock 生效: 去掉上面 LockAspect 的 @Component 注解,并创建注解 EnableLock。使用 EnableLock 注解时再 @Import({ LockAspect.class }) 创建 LockAspect 的对象。更多详情请参考 SpringBoot 中的 @Import 注解

每种方式都有优缺点,但是根据观察,现在使用自定义注解实现 AoP 的方式更受欢迎,方便以库的方式提供给第三方使用,例如阿里的 JetCache (简单使用可参考 Spring MVC 中使用 JetCache)。

八、使用 SpEL 增强注解

上面在代码里写死了锁的名字: @Lock("lock-foo"),也即是说同一个功能在分布式系统里同时只能在一个服务器中执行。例如我们有一个视频转换服务,有 10 台执行视频转换的服务器,使用目前这个分布式锁的话,同时就只能有一台服务器执行转换操作,其他几台都在围观,我们期望的是同名文件的视频同时只能在一个服务器上转换 (避免重复转换),不同的服务器可以同时转换不同名的视频,也就是说锁的名字与文件名有关,而不是写死的,可以借助 SpEL 来实现这个功能。

先看使用 SpEL 计算一个表达式的值的简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class Test {
public static void main(String[] args) {
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = new StandardEvaluationContext();
Expression expression = parser.parseExpression("'hello-' + #id"); // 字符串的 spel 表达式

context.setVariable("id", 123); // 设置变量的值
String result = expression.getValue(context, String.class); // 结果: hello-123

System.out.println(result);
}
}

通过上面这个例子,我们可以把 SpEL 表达式作为 Lock 的 value (如 @Lock("#filename")),使用反射从方法的参数中获得文件名,使用 SpEL 动态的计算出文件名相关的锁的名字,这样就能够实现转换不同的文件时使用不同的分布式锁,多个服务器就能并发的进行转换了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.xtuer.aspect;

import com.xtuer.annotation.Lock;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Aspect
@Slf4j
@Component
public class LockAspect {
private ExpressionParser parser = new SpelExpressionParser();
private LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();

/**
* 使用自定义注解配置切入点
*/
@Pointcut("@annotation(com.xtuer.annotation.Lock)")
public void pointcut(){}

/**
* 使用分布式锁保护函数的调用
*/
@Around("pointcut() && @annotation(lock)")
public Object doAround(ProceedingJoinPoint pjp, Lock lock) throws Throwable {
String lockName = buildLockName(pjp, lock);

try {
// 1. 获取分布式锁
log.info("Lock: {}", lockName);

// 2. 执行函数
return pjp.proceed();
} finally {
// 3. 释放分布式锁
log.info("Unlock: {}", lockName);
}
}

// 使用 SpEL 表达式创建锁的名字
private String buildLockName(ProceedingJoinPoint pjp, Lock lock) {
// 1. 如果 lock.value() 中不包含 #,则说明只是一个普通的字符串,直接返回作为锁的名字
// 2. 获取被调用的方法以及它的参数名和参数值
// 3. 把参数名和对应的参数值设置到 context 中
// 4. 创建执行 SpEL 表达式, 并返回它的结果作为锁的名字

String spel = lock.value();

// [1] 如果 lock.value() 中不包含 #,则说明只是一个普通的字符串,直接返回作为锁的名字
if (!spel.contains("#")) {
return spel;
}

// [2] 获取被调用的方法以及它的参数名和参数值
Method method = ((MethodSignature) pjp.getSignature()).getMethod(); // 方法
String[] params = discoverer.getParameterNames(method); // 参数名
Object[] args = pjp.getArgs(); // 参数值
EvaluationContext context = new StandardEvaluationContext();

// [3] 把参数名和对应的参数值设置到 context 中
if (params != null) {
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], args[len]);
}
}

// [4] 创建执行 SpEL 表达式, 并返回它的结果作为锁的名字
Expression expression = parser.parseExpression(spel);
return expression.getValue(context, String.class);
}
}

注解 + SpEL 的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.xtuer.service;

import com.xtuer.annotation.Lock;
import com.xtuer.bean.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class XService {
// 1. SpEL 中没有参数: noom 作为锁名
@Lock("noom")
public void noom() {
log.info("XService::noom()");
}

// 2. SpEL 用简单参数: 参数 id 作为锁名
@Lock("#id")
public void foo(int id) {
log.info("XService::foo()");
}

// 3. SpEL 用级联参数: user.username 作为锁名
@Lock("#user.username")
public void bar(User user) {
log.info("XService::bar()");
}

// 4. SpEL 字符串拼接: lock- 后跟上参数 id 作为锁名
@Lock("'lock-' + #id")
public void goo(int id) {
log.info("XService::goo()");
}
}

附上测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import com.xtuer.bean.User;
import com.xtuer.service.XService;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@ContextConfiguration({"classpath:bean.xml"})
public class XServiceTest {
@Autowired
private XService service;

@Test
public void testFoo() {
service.foo(10010);
}

@Test
public void testBar() {
service.bar(new User("Bob"));
}

@Test
public void testNoom() {
service.noom();
}

@Test
public void testGoo() {
service.goo(10010);
}
}

不同的 SpEL 和参数,计算得到的锁名不同,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2019-08-02 13:35:48.896 INFO  [42-LockAspect.java-doAround()] - Lock: 10010
2019-08-02 13:35:48.896 INFO [20-XService.java-foo() ] - XService::foo()
2019-08-02 13:35:48.896 INFO [48-LockAspect.java-doAround()] - Unlock: 10010

2019-08-02 13:35:48.877 INFO [42-LockAspect.java-doAround()] - Lock: Bob
2019-08-02 13:35:48.893 INFO [26-XService.java-bar() ] - XService::bar()
2019-08-02 13:35:48.893 INFO [48-LockAspect.java-doAround()] - Unlock: Bob

2019-08-02 13:35:48.900 INFO [42-LockAspect.java-doAround()] - Lock: noom
2019-08-02 13:35:48.900 INFO [14-XService.java-noom() ] - XService::noom()
2019-08-02 13:35:48.901 INFO [48-LockAspect.java-doAround()] - Unlock: noom

2019-08-02 13:35:48.898 INFO [42-LockAspect.java-doAround()] - Lock: lock-10010
2019-08-02 13:35:48.899 INFO [32-XService.java-goo() ] - XService::goo()
2019-08-02 13:35:48.899 INFO [48-LockAspect.java-doAround()] - Unlock: lock-10010

参考资料