基于SpringBoot-2.1.3.RELEASE
背景
需求:
JedisDistributedLock.Lock lock = jedisDistributedLock.acquire(key, value, expire);
jedisDistributedLock.release(lock);
以上是当前已有的分布式锁工具类,现在要把它注解化,减小代码入侵。要满足以下需求:
- 可以从参数里提取出锁的
key
,实现数据级别
的锁。 - 可以从参数里提取出锁的
value
。 key
和value
可以不配置,默认为方法级别
的锁。- 兼容Spring的异步方法注解
@Async
。
动态代理(运行期织入)
项目是SpringBoot项目,首选Spring AOP
。
先定义一个注解类:
@Target(value = {ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface DisLock {
/**
* key,如果为空则默认"类名+方法名"
*
* @return java.lang.String
* @author
* @date 2020-03-17 22:49
*/
String key() default "";
/**
* 值,如果为空则默认为当前时间戳
*
* @return java.lang.String
* @author
* @date 2020-03-17 23:04
*/
String value() default "";
/**
* 默认key过期时间
*
* @return long
* @author
* @date 2020-03-17 22:50
*/
int expire() default 3000;
/**
* 获取不到锁是否要抛异常,如果不抛异常,获取锁失败结果会返回null
*
* @return boolean
* @author
* @date 2020-03-17 23:58
*/
boolean throwExceptionIfFailed() default true;
}
这里的重点是怎么让key能支持从方法参数里提取属性。
SpEl
Spring表达式语言(简称SpEl),一种强大的表达式语言,支持在运行时查询和操作对象。
SpEL
支持各种公式运算、对象操作、从Spring配置里获取参数,跟Spring无缝连接,而且可以脱离Spring环境独立使用。
使用起来也简单:
ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
EvaluationContext evaluationContext = new MethodBasedEvaluationContext(null, method, args, parameterNameDiscoverer);
ExpressionParser expressionParser = new SpelExpressionParser();
expressionParser.parseExpression(expression).getValue(evaluationContext);
使用效果如下:
@DisLock(key = "'user:' + #user.id")
public void save(User user) {
}
整个AOP代码如下:
public interface DisLockAop extends PriorityOrdered {
/**
* 默认分隔符
*/
String DEFAULT_KEY_DELIMITER = ":";
@Override
default int getOrder() {
return PriorityOrdered.LOWEST_PRECEDENCE;
}
/**
* 默认key
*
* @param joinPoint
* @return java.lang.String
* @author
* @date 2020-03-17 23:06
*/
default String getDefaultKey(ProceedingJoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
Method method = ((MethodSignature) signature).getMethod();
return method.getDeclaringClass().getName() + DEFAULT_KEY_DELIMITER + method.getName();
}
/**
* 默认值
*
* @param joinPoint
* @return java.lang.String
* @author
* @date 2020-03-17 23:05
*/
default String getDefaultValue(ProceedingJoinPoint joinPoint) {
return String.valueOf(System.currentTimeMillis());
}
}
@Component
@Aspect
@Slf4j
public class DefaultDisLockAop implements DisLockAop {
private ParameterNameDiscoverer parameterNameDiscoverer;
private ExpressionParser expressionParser;
private JedisDistributedLock jedisDistributedLock;
public DefaultDisLockAop(@Autowired JedisDistributedLock jedisDistributedLock) {
this.jedisDistributedLock = jedisDistributedLock;
expressionParser = new SpelExpressionParser();
parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
}
/**
* @param joinPoint
* @return java.lang.Object
* @author minchin
* @date 2020-03-17 23:49
*/
@Around("@annotation(com.xxxxx.DisLock)")
public Object log(ProceedingJoinPoint joinPoint) throws Throwable {
JedisDistributedLock.Lock lock = null;
try {
lock = tryLock(joinPoint);
if (lock.isSuccess()) {
return joinPoint.proceed(joinPoint.getArgs());
}
return null;
} finally {
if (lock != null && lock.isSuccess()) {
jedisDistributedLock.release(lock);
}
}
}
/**
* @param joinPoint
* @return com.xxxxx.JedisDistributedLock.Lock
* @author
* @date 2020-03-17 23:49
*/
protected JedisDistributedLock.Lock tryLock(ProceedingJoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
Method method = ((MethodSignature) signature).getMethod();
DisLock disLock = getDisLock(joinPoint, method);
Object[] args = joinPoint.getArgs();
EvaluationContext evaluationContext = new MethodBasedEvaluationContext(null, method, args, parameterNameDiscoverer);
String key = getKey(joinPoint, method, disLock, args, evaluationContext);
String value = getValue(joinPoint, method, disLock, args, evaluationContext);
JedisDistributedLock.Lock lock = jedisDistributedLock.acquire(key, value, disLock.expire());
if (!lock.isSuccess() && disLock.throwExceptionIfFailed()) {
throw new DisLockFailedException("lock failed!");
}
return lock;
}
/**
* @param joinPoint
* @param method
* @return com.xxxxx.DisLock
* @author
* @date 2020-03-17 23:17
*/
protected DisLock getDisLock(ProceedingJoinPoint joinPoint, Method method) {
return method.getAnnotation(DisLock.class);
}
/**
* @param joinPoint
* @param method
* @param disLock
* @param args
* @param evaluationContext
* @return java.lang.String
* @author
* @date 2020-03-17 23:47
*/
protected String getKey(ProceedingJoinPoint joinPoint, Method method, DisLock disLock, Object[] args, EvaluationContext evaluationContext) {
return Optional.ofNullable(disLock.key())
.filter(StringUtils::isNotBlank)
.map(str -> parseExpression(evaluationContext, str))
.orElse(getDefaultKey(joinPoint));
}
/**
* @param joinPoint
* @param method
* @param disLock
* @param args
* @param evaluationContext
* @return java.lang.String
* @author
* @date 2020-03-17 23:47
*/
protected String getValue(ProceedingJoinPoint joinPoint, Method method, DisLock disLock, Object[] args, EvaluationContext evaluationContext) {
return Optional.ofNullable(disLock.value())
.filter(StringUtils::isNotBlank)
.map(str -> parseExpression(evaluationContext, str))
.orElse(getDefaultValue(joinPoint));
}
/**
* @param evaluationContext
* @param expression
* @return java.lang.String
* @author
* @date 2020-03-17 23:43
*/
protected String parseExpression(EvaluationContext evaluationContext, String expression) {
return expressionParser.parseExpression(expression).getValue(evaluationContext).toString();
}
}
这里有个重点,Order
设为优先级最低PriorityOrdered.LOWEST_PRECEDENCE
,尽量贴近业务逻辑:在其他AOP完成之后,才开始加锁。
回到“兼容Srping的异步方法注解@Async
”这个需求,看下@Async
的优先级是多少?
通过AsyncAnnotationAdvisor
-> AbstractPointcutAdvisor
-> AnnotationAsyncExecutionInterceptor
-> AsyncExecutionInterceptor
源码看到@Async的优先级是最高优先级Ordered.HIGHEST_PRECEDENCE
。会先于DisLock
执行。
喜滋滋!
但是这时候遇到一个需求了:同一个类内部方法之间的调用,希望也能加锁。
这是动态代理一个经典的问题。
比如以下代码:
@Conponent
public class A {
@Autowired
private B b;
public void fun() {
b.fun1();
}
}
@Conponent
public class B {
@DisLock
public void fun1() {
fun2();
}
@DisLock
public void fun2() {
}
}
Spring会在B
上生成一个代理类,假如叫BProxy
,A
注入的是BProxy
实例,调用的也是BProxy
的方法,最终会变成(以下是简化的代码):
@Conponent
public class A {
@Autowired
private BProxy b;
public void fun() {
b.fun1();
}
}
@Conponent
public class BProxy extends B {
private B target;
public void fun1() {
before();
target.fun1();
after();
}
public void fun2() {
before();
target.fun2();
after();
}
}
public class B {
public void fun1() {
fun2();
}
public void fun2() {
}
}
所以最终在B
内,fun1
调fun2
时,AOP
并不会生效。
Spring对这种场景也提供了解决方案:使用expose-proxy
特性,将expose-proxy
设为true
。
xml:
<aop:aspectj-autoproxy expose-proxy=“true”>
注解:
@EnableAspectJAutoProxy(exposeProxy=true)
然后将fun2()
改为((B)AopContext.currentProxy()).fun2()
,开启expose-proxy
后,spring会将当前代理类放入ThreadLocal中AopContext.setCurrentProxy(proxy)
。
但是作为一个有尊严的程序员,肯定希望能找到更优雅的方式。既然在运行期生成动态代理会有这种问题,那就把“修改”
往前提到编译期
。
编译期织入
AspectJ是一个面向切面的框架,它扩展了Java语言。AspectJ定义了AOP语法,它有一个专门的编译器用来生成遵守Java字节编码规范的Class文件。
如果有注意Spring Aop依赖的话,会发现Spring Aop集成了AspectJ,Spring Aop把切点这一套语法、@Aspect这类注解、切点的解析,都直接使用AspectJ的,没有自己另起炉灶。但是默认情况下,核心是没有使用AspectJ的编译期注入
和ltw
的。
ApectJ的编译期织入,是在编译期间使用AspectJ的acj编译器(类似javac)把aspect类编译成class字节码后,在java目标类编译时织入,即先编译aspect类再编译目标类。以下图片来自:《关于 Spring AOP (AspectJ) 你该知晓的一切》
例如上面例子的B
会被编译成(简化的代码):
public class B {
public void fun1() {
切片对象.before();
fun1();
切片对象.after();
}
public void fun2() {
切片对象.before();
fun2();
切片对象.after();
}
}
根据文档,要改成编译期织入
也很简单,只需要在pom.xml
里加上Aspectj编译
需要的配置即可:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>aspectj-maven-plugin</artifactId>
<version>1.11</version>
<configuration>
<complianceLevel>1.8</complianceLevel>
<source>1.8</source>
<target>1.8</target>
<showWeaveInfo>true</showWeaveInfo>
<verbose>true</verbose>
<Xlint>ignore</Xlint>
<encoding>UTF-8</encoding>
</configuration>
<executions>
<execution>
<goals>
<!-- use this goal to weave all your main classes -->
<goal>compile</goal>
<!-- use this goal to weave all your test classes -->
<goal>test-compile</goal>
</goals>
</execution>
</executions>
</plugin>
然而实际过程中,发现有以下几个坑:
- AOP类被实例化两次。
在Aspectj文档《starting-aspectj》上可以看到以下一段话:
Like classes, aspects may be instantiated, but AspectJ controls how that instantiation happens -- so you can't use Java's new form to build new aspect instances. By default, each aspect is a singleton, so one aspect instance is created.
也就是说AspectJ会自己实例化切片对象。那么该如何将这对象跟Spring整合呢?去掉切片类上的@Component
,改为以下方式:
@Bean
public DefaultDisLockAop defaultDisLockAop() {
return Aspects.aspectOf(DefaultDisLockAop.class);
}
要注意AspectJ是使用默认(无参)构造函数
来实例化Aspect的类,所以Bean必须要有一个无参构造函数。
- 切片被执行了两次。
当前切片配置为@Around("@annotation(com.xxxxx.DisLock)")
,运行过程发现被执行了两次,从《分析java 中AspectJ切面执行两次的原因》里看到说这是“ajc的bug”。将表达式改为@Around("* *(..)) && @annotation(com.xxxxx.DisLock)")
即可。 - 原先其他的动态代理都会变成编译期织入。
所有的动态代理(运行期织入)都变成编译期织入了,可能会影响到项目已有的代码。 - 跟其他编译插件冲突,比如lombok
这个查了很多资料,都没看到好的解决方案。目前项目里有用到lombok
,这是个致命的问题。
好吧,既然编译期织入
有问题,那就把修改
往后移到类装载期
。
类装载期织入
这里先介绍三种织入方式,以下说明摘自《SpringBoot中使用LoadTimeWeaving技术实现AOP功能》:
- 运行期织入
这是最常见的,比如在运行期通过为目标类生成动态代理的方式实现AOP就属于运行期织入,这也是Spring AOP中的默认实现,并且提供了两种创建动态代理的方式:JDK自带的针对接口的动态代理和使用CGLib动态创建子类的方式创建动态代理。 - 编译期织入
使用特殊的编译器在编译期将切面织入目标类, 需要特殊的编译器的支持。 - 类加载期织入
通过字节码编辑技术在类加载期将切面织入目标类中,这是本篇介绍的重点。它的核心思想是:在目标类的class文件被JVM加载前,通过自定义类加载器或者类文件转换器将横切逻辑织入到目标类的class文件中,然后将修改后class文件交给JVM加载。这种织入方式可以简称为LTW(LoadTimeWeaving)。
引入步骤也很简单:
开启
LTW
。
在SpringBoot的Application类上增加注解@EnableLoadTimeWeaving(aspectjWeaving = EnableLoadTimeWeaving.AspectJWeaving.AUTODETECT)
。
aspectjWeaving有三个值:- ENABLED:启用LTW
- DISABLED:启用LTW
- AUTODETECT:如果类路径下能读取到META-INF/aop.xml文件,则启动LTW,否则不启动。
AUTODETECT
这个选项,可以让项目同时拥有运行期织入
和加载期织入
两种AOP。在classpath/META-INF下增加aop.xml,指定启用加载期织入
的切片类:
<aspectj>
<aspects>
<aspect name="com.xxxx.DisLockAspect"/>
<weaver options="-verbose -showWeaveInfo">
<!-- <include within="com..*"/>-->
</weaver>
</aspects>
</aspectj>
AOP类被实例化两次
和切片被执行了两次
这两个坑在这里也是存在的,处理方法也是一样。启动需要agent。
心态崩了,虽然编译期织入
无法解决的坑在这里不存在了,但是LTW启动需要指定agent!- pom.xml编译插件指定agent。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
-javaagent:"/jar文件所在目录路径/aspectjweaver-1.9.2.jar"
-javaagent:"/jar文件所在目录路径/spring-instrument-5.1.5.RELEASE.jar"
</argLine>
</configuration>
</plugin>
启动命令加上agent。
java -javaagent:/jar文件所在目录路径/aspectjweaver-1.9.2.jar -javaagent:/jar文件所在目录路径/spring-instrument-5.1.5.RELEASE.jar -jar xxx.jar
这种限制提高了组件引入成本,是否有方式可以在不修改启动脚本的前提下,能让组件生效呢?
Aspectj支持通过java代码加载agent文件:《Aspectj 1.8.7 Readme》
VirtualMachine vm = VirtualMachine.attach(pid);
// 指定agent文件地址
String jarFilePath = System.getProperty("AGENT_PATH");
vm.loadAgent(jarFilePath);
但是这种方式,还是需要在服务器上有agent需要的jar文件。
另外github上有个项目invesdwin-instrument,号称:只需要项目依赖(dependency
)agent的jar文件,并添加以下两行代码即可:
DynamicInstrumentationLoader.waitForInitialized();
DynamicInstrumentationLoader.initLoadTimeWeavingContext();
初步试了下,并没有生效,并且需要依赖的invesdwin-instrument
jar包是放在他们自己的仓库( https://invesdwin.de/artifact... ) 里,到时候要上线,还得手动导入到公司的仓库里,所以就没有继续试下去。
占坑
到目前为止试了的三种方式,都有各自的缺陷,去掉第二个的编译期织入
,第一个和第三个分成两个分支。等以后有时间了,再试下javassist
。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。