记一次分布式锁注解化

noname
基于SpringBoot-2.1.3.RELEASE

背景

需求:
JedisDistributedLock.Lock lock = jedisDistributedLock.acquire(key, value, expire);
jedisDistributedLock.release(lock);

以上是当前已有的分布式锁工具类,现在要把它注解化,减小代码入侵。要满足以下需求:

  • 可以从参数里提取出锁的key,实现数据级别的锁。
  • 可以从参数里提取出锁的value
  • keyvalue可以不配置,默认为方法级别的锁。
  • 兼容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上生成一个代理类,假如叫BProxyA注入的是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内,fun1fun2时,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) 你该知晓的一切
image
例如上面例子的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)。

image

引入步骤也很简单:

  • 开启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!

    1. 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>
  1. 启动命令加上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-instrumentjar包是放在他们自己的仓库( https://invesdwin.de/artifact... ) 里,到时候要上线,还得手动导入到公司的仓库里,所以就没有继续试下去。

占坑

到目前为止试了的三种方式,都有各自的缺陷,去掉第二个的编译期织入,第一个和第三个分成两个分支。等以后有时间了,再试下javassist

阅读 296

为何常含泪水,因为菜的绝望。

148 声望
12 粉丝
0 条评论

为何常含泪水,因为菜的绝望。

148 声望
12 粉丝
宣传栏