记一次分布式锁注解化

码途拓荒使
• 阅读 2799
基于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) 你该知晓的一切
记一次分布式锁注解化
例如上面例子的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!

    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

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
4年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
4年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Easter79 Easter79
4年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
4年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
4年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
4年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Wesley13 Wesley13
4年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Python进阶者 Python进阶者
2年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这
码途拓荒使
码途拓荒使
Lv1
塞花飘客泪,边柳挂乡愁。
文章
4
粉丝
0
获赞
0