标签归档:Spring AOP

基于Redis的分布式锁的简易实现

本文讨论的分布式锁适用于集群节点程序的互斥执行,注意此处的“分布式”是指该锁的适用场景是分布式系统,而非锁本身是分布式的,关于锁本身的分布式设计可以参考Redis官方介绍:『Distributed locks with Redis』。

之前在『如何用Spring实现集群环境下的定时任务』一文中提到利用Redis的"GET/SET+TTL"机制实现集群场景下定时任务的互斥执行功能,这实际上就是本文实现的原型。该文描述的方法的问题在于GET和SET方法的组合并非原子操作,在多进程并行执行场景下可能有多个客户端获得锁,从而破坏了锁的安全性。

本文的改进在两个方面:

1. 要解决分布式锁的安全性问题,需要使用Redis提供的锁原语:SETNX(since 1.0.0)或者SET NX EX(since 2.6.12)。这类命令的语义是:如果Key已存在,则返回SET成功,否则返回失败。

2. 使用注解方式加锁和解锁,避免代码重复和耦合。

以下是Java实现(Spring AOP)的原型:

1. 定义注解类,接收expire参数,表示此锁的过期时间。如果在定时任务中使用,一般要大于节点间的时间差,小于定时任务的时间间隔。

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

    public String expire() default "30";
}

2. 定义切面类,完成锁的申请和释放逻辑。

@Aspect
@Component
public class DLockAdvice {
    private static String DLOCK_PREFIX = "DLOCK_PREFIX_";

    @Autowired
    private RedisService redisService = null;

    @Around("@annotation(lock)")
    public Object lock(ProceedingJoinPoint pjp, DLock lock) throws Throwable {
        Long expire = Long.valueOf(lock.expire());;
        Signature sig = pjp.getSignature();
        String lockKey = DLOCK_PREFIX + sig.getDeclaringTypeName() + sig.getName() + expire;
        if (redisService.setValueIfAbsent(lockKey, true, expire)) {
            return pjp.proceed();
        } 
        return null;
    }
}

考虑到注解的通用性,锁名称的区分度越大越好,此处采用的“前缀+包名+类名+方法名+过期时间”,因此假如有个方法通过参数个数或者类型不同进行重载,则该锁会对这个重载的每个方法都生效,除非把参数个数或者类型信息加到锁名称里。

另外考虑到Redis服务本身或者网络的不稳定性,需要在RedisService的setValueIfAbsent()方法中对异常进行处理,假如:

1. 能够容忍多个客户端同时获得锁。那么当执行Redis命令异常时返回true。
2. 无法容忍多个客户端同时获得锁,宁愿没有客户端可以获得锁。那么当执行Redis命令异常时返回false。

--EOF--

Spring @Async

Spring @Async注解可以将方法异步化处理。与『如何用Spring实现集群环境下的定时任务』提到的@Scheduled注解一样,@Async注解也需要task命名空间驱动,需要配置一个线程池。

一个被@Async注解的异步方法,它的返回值只有两种,要么void,要么返回一个Future类型,Future类型用于追踪异步方法的执行结果。Spring提供了一个Future类型的实现类AsyncResult。

比如以下场景:一个创建实例的HTTP请求,该实例由多个相同的资源组成,这些资源的创建非常耗时,但是相互间没有依赖性,资源创建完成后还要进行一些数据库操作记录实例元信息。这个场景非常适合用@Async注解+Future返回值实现。用@Async注解标注创建实例的Service方法,使得创建实例的过程放在了一个独立线程中执行,此时HTTP请求可以直接响应用户(202 Accepted),并且实例的Location(URI)也可以同时返回。为了进一步提升用户体验,减少实例创建的等待时间,可以将资源的创建分配到多个线程中完成。资源创建的结果(true or false)放进AsyncResult中返回,此处如果不用Future返回值对异步创建资源的过程进行追踪,创建实例的主线程就没法确定资源是否创建成功,则资源的创建只能在主线程中依次进行。

这个过程的大致代码如下:
1. Controller类,接收用户请求。

1
2
3
4
5
6
7
8
9
@Controller
public class InstanceController {
    @RequestMapping(value = "instances", method = RequestMethod.POST)
    public String createInstance(HttpServletResponse response, ModelMap model) {
        //doSth().
        instanceTaskService.createInstance(instance);
        return doResponse(201);
    }
}

2. Instance Service类。异步处理实例创建过程。

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
@Service
public class InstanceTaskService {
    @Async
    public void createInstance(String instanceId) {
        //doSth().
        List<Future<Boolean>> futureList = new ArrayList<Future<Boolean>>();
        for (Resource res : resourceList) {
            // 将每个资源的异步执行结果放入列表。
            futureList.add(resourceTaskService.createResource(res.getId()));
        }
 
        for (Future<Boolean> future : futureList) {
            try {
                //超时判断,避免无限期等待。
                Boolean result = future.get(10, TimeUnit.MINUTES);
                if (!result) {
                    // success.
                } else {
                    throw new Exception("create instance error.");
                }
            } catch (Exception e) {
                rollback();
            }
        }
    }
}

3. Resouce Service类,异步处理资源创建过程。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class ResouceTaskService {
    @Async
    public Future<Boolean> createRescource(String resId) {
        //doSth().
        if(success()){
            return new AsyncResult<Boolean>(true);
        else{
            return new AsyncResult<Boolean>(false);
        }
    }
}

关键步骤在于createInstance()方法中对每个资源调用future.get()方法,Future类的get()方法是阻塞的:当异步方法已经返回时,调用get()方法立即返回;当异步方法还在执行中时,调用get()方法会被阻塞,直到异步方法执行结束,get(long timeout, TimeUnit unit)方法是get()方法的重载版本,可以指定阻塞的最长时间。示例程序中,创建资源可以并发进行,createInstance()方法中等待所有资源创建成功的时间就是单个资源创建成功的最长时间。

另外有一个需要注意的地方,示例程序中把createResource()方法和createInstance()方法放在了不同的类中,这么做不仅仅是因为代码风格和可读性,而是因为,一个@Async注解的方法,它只能被非本类方法调用时Spring才将其转变为异步方法执行,如果它被本类方法调用,那么无法转变为异步执行。原因在于:@Async注解的实现基于Spring AOP,『Spring AOP实践小结』一文中已有提及,Spring AOP的实现原理是Java动态代理,也就是说Spring会为带@Async注解的方法所在类生成一个代理类,当该方法被调用时,Spring将代理类放到线程池中运行。而发生在本类中的方法调用,则属于目标类的内部调用,代理类无法再参与进去进行异步处理了。

--EOF--

Spring AOP实践小结

近几天重构了一块代码,这块代码本来的逻辑是这样的:一个创建队列的操作,操作成功后,会调用监控和计费系统接口进行注册;反之,删除队列后会调用相应接口在监控和计费系统中注销。由于监控项和计费项较为特殊,导致最后写出来的createQueue()/deleteQueue()方法中监控/计费代码占30%比例。用Spring AOP进行代码重构后,createQueue()/deleteQueue()方法中只包含队列相关的业务逻辑,监控和计费代码放在独立的切面类中,实现解耦。

以下是这次的学习加实践小结。

一、 Spring AOP介绍
Spring AOP是Spring提供的面向切面编程(Aspect Oriented Programming)框架。但凡是AOP,就少不了以下几个关键概念:

1. 切面(Aspect):
切面是一个独立于常规业务逻辑的模块类,用来描述横向的关注点,它定义了这个横向关注点“何时”“何处”完成“什么样的功能”。

2. 通知(Advice):
通知定义了切面“何时”完成“什么样的功能”。目前,Spring AOP支持以下5种通知类型:
1). Before: 在被拦截方法调用之前执行自定义代码。
2). After: 在被拦截方法调用之后执行自定义代码。
3). After-returning: 在被拦截方法成功返回之后执行自定义代码。
4). After-throwing: 在被拦截方法抛出异常之后执行自定义代码。
5). Around: 包裹被拦截方法,可以在该方法之前或之后执行自定义代码。

3. 切点(Pointcut):
切点定义了切面中的自定义代码在“何处”执行。

4. 连接点(Joinpoint):
连接点是应用在正常执行过程中能够插入切面的一个点。比如调用某个方法时,修改某个字段时。

5. 织入(Weaving):
织入是指将切面类应用到被拦截的目标对象,并生成新的代理类的过程。

Spring AOP借鉴了Aspectj的切点解析与匹配的实现方式,支持@Aspectj注解驱动的切面,与Aspectj AOP框架相比,它有以下特征:

1. Spring AOP由标准Java编写,简单易学。Aspectj则是通过扩展的Java语法和语义实现,有额外的学习成本。
2. Spring AOP只支持运行期的切面织入。Aspectj则同时支持编译期、类加载期和运行期的切面织入。
3. Spring AOP只支持方法调用时的连接点。Aspectj则还支持成员变量修改和构造器调用时的连接点。

在实现原理方面,Spring AOP利用了Java的动态代理技术。Java动态代理有两种实现方式:

1. JDK动态代理:这种生成代理对象的方式需要传入目标类的接口定义,因此,基于JDK动态代理的AOP只能拦截那些实现了接口的目标类。

2. CGLib动态代理:CGLib动态代理是通过为目标类动态生成一个子类,同时这个子类也是目标类的代理类。因此,目标类中使用了final限定词的方法不能被拦截,因为它无法被子类重写。此外,CGLib生成代理子类的过程中目标类的构造方法会被调用两次(目标类实例化一次,代理子类实例化时一次),需留意。

另外,两种动态代理方式在性能上也有些不同表现。网上有一组数据,据说CGLib动态代理创建代理对象的速度比JDK动态代理慢8倍左右,但是其创建出来的代理对象运行速度要比JDK动态代理创建出来的代理对象快10倍。

两种动态代理方式各有优缺点,默认情况下,Spring AOP在为目标对象生成代理对象时会有个判断,如果目标对象是某(多)个接口的实现类,Spring会选择JDK动态代理实现方式,否则,选择CGLib动态代理实现方式。代码片段如下:

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
public AopProxy createAopProxy(AdvisedSupport config) 
                                    throws AopConfigException {
    if (config.isOptimize() || config.isProxyTargetClass() 
        || hasNoUserSuppliedProxyInterfaces(config)) {
        Class targetClass = config.getTargetClass();
        if (targetClass == null) {
            throw new AopConfigException(
              "TargetSource cannot determine target class: " +
              "Either an interface or a target is required for proxy creation.");
        }
        if (targetClass.isInterface()) {
            return new JdkDynamicAopProxy(config); //目标类是接口类型,JDK动态代理实现
        }
        if (!cglibAvailable) {
            throw new AopConfigException(
              "Cannot proxy target class because CGLIB2 is not available. " +
              "Add CGLIB to the class path or specify proxy interfaces.");
        }
        return CglibProxyFactory.createCglibProxy(config); //CGLib动态代理实现
    }
    else {
        return new JdkDynamicAopProxy(config); //JDK动态代理实现
    }
}
 
/**
 * Determine whether the supplied {@link AdvisedSupport} has only the
 * {@link org.springframework.aop.SpringProxy} interface specified
 * (or no proxy interfaces specified at all).
 */
private boolean hasNoUserSuppliedProxyInterfaces(AdvisedSupport config) {
    Class[] interfaces = config.getProxiedInterfaces();
    return (interfaces.length == 0 || (interfaces.length == 1 
        && SpringProxy.class.equals(interfaces[0])));
}

其中hasNoUserSuppliedProxyInterfaces()方法就是用于判断目标类是否是接口的实现类。如果不想使用这种默认的动态代理策略,Spring提供了一些选项可以进行配置。

那么,Spring AOP是什么时候拦截目标类的呢?前文提过Spring AOP支持运行时的切面织入,实际上,所谓的“拦截”发生在调用ApplicationContext容器getBean()方法获取目标对象的时候,getBean()方法返回的不是目标类实例,而是目标类的代理类实例。因此程序中所有对目标类方法的调用,实际上都是对相应代理类的方法调用。

二、 Spring AOP Demo

这个Demo以Around通知为例,切点为IQueueService的createQueue()方法。

1. 修改application-context.xml文件,添加aop命名空间,添加<aop:aspectj-autoproxy />声明。

1
2
3
4
5
6
7
8
9
10
11
12
13
<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-3.0.xsd">
 
    <aop:aspectj-autoproxy />
</beans>

2. 切面类实现。

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
@Aspect
@Component
public class MonitorAdvice implements Ordered {
    @Autowired
    private IMonitorService monitorService = null;
 
    @Pointcut("execution(* com.xs.service.IQueueService.createQueue(..))")
    public void createQueue() {
    }
 
    @Around("createQueue()")
    public Object createQueue(ProceedingJoinPoint pjp) throws Throwable {
        Boolean result = (Boolean) pjp.proceed();
        if (result) {
            String[] args = (String[]) pjp.getArgs();
            monitorService.register(args);
        }
        return result;
    }
 
    @Override
    public int getOrder() {
        return 20;
    }
}

其中,@Aspect注解声明了一个切面类,@Component注解是为了将该类也纳入Spring的依赖管理,通常切面类也依赖到其他的Spring bean,例如示例中的monitorService。@Pointcut注解用于声明切点,它支持多种切点指示器,此处采用最常用的execution,表示在createQueue()方法调用前后将此切面织入。Spring AOP支持的切点指示器没有Aspectj丰富,它是Aspectj的一个子集,关于切点指示器的使用,可以查看手册:『Declaring a pointcut』。@Around注解用于声明一个Around类型的通知,此处也可以将切点表达式直接写到Around注解里,之所以将切点表达式单独提取出来用@Pointcut声明,目的是为了重用。ProceedingJoinPoint是连接点实例,从中可以获取连接点的信息,比如目标类信息,目标方法参数等等。

假如某个连接点要织入多个切面,而且比较关心各个切面的织入顺序时,可以有两种方式指定顺序:一种是如示例所示的实现Ordered接口的getOrder()方法;另外一种是在切面类上加@Order(int)的注解。数值越低,表示优先级越高,即越先织入。

三、注意事项
1. @Aspectj注解的切面类无法被继承。所以,企图将所有切点声明(@Pointcut)放入公有父类实现切点声明重用的想法可以打消了。有一个变通的方法是将所有切点声明放到一个单独类中,并且所有切点方法声明为public,然后在切面类用“类名.方法名”形式访问。如下所示:

1
2
3
4
5
6
7
8
9
10
@Aspect
public class Pointcuts {
    @Pointcut("execution(* com.xs.service.IQueueService.createQueue(..))")
    public void createQueue() {
    }
 
    @Pointcut("execution(* com.xs.service.IQueueService.deleteQueue(..))")
    public void deleteQueue() {
    }
}
1
2
3
4
5
6
7
8
9
@Around("Pointcuts.createQueue()")
public Object createQueue(ProceedingJoinPoint pjp) throws Throwable {
    Boolean result = (Boolean) pjp.proceed();
    if (result) {
        String[] args = (String[]) pjp.getArgs();
        monitorService.register(args);
    }
    return result;
}

2. 被本类中其他方法调用的方法是无法作为切点被织入通知的,例如,ClassA中有方法MethodA1()和MethodA2(),ClassB中有方法MethodB(),其中,ClassB.MethodB()调用了ClassA.MethodA1(),ClassA.MethodA1()又调用了本类中的MethodA2()。此时,切点切在MethodA2()上的话,MethodA2()不会被拦截,不会被织入通知。探其原因,因为Spring无论选择哪种动态代理方式,本质都是为目标类创建一个代理类,再由代理类调用目标类的方法。因为ClassB.MethodB()调用了ClassA代理类的MethodA1'()方法,再由MethodA1'()方法调用MethodA1()方法,当程序已经执行到ClassA.MethodA1()时,就无法再执行代理类代码了。因此,在Spring AOP框架下,可以认为连接点只存在于不同实例间的方法调用。

3. execution切点指示器,切点表达式匹配某个子类的所有方法,如果该子类继承了父类的一个方法,并且没有对其进行重写,那么当这个方法被调用时,它无法被织入通知,也就是说该切点无效。

4. 有个循环依赖问题,以Demo为例:如果切点切在IQueueService的任一个方法上,Spring都会为其创建代理类,如果此时切面类本身又依赖到IQueueService,那么就可能出现一个BeanCurrentlyInCreationException异常:

1
2
3
4
5
6
Error creating bean with name 'queueService': Bean with name 'queueService' 
has been injected into other beans [monitorAdvice] in its raw version as part 
of a circular reference, but has eventually been wrapped. This means that 
said other beans do not use the final version of the bean. This is often 
the result of over-eager type matching - consider using 'getBeanNamesOfType' 
with the 'allowEagerInit' flag turned off.

这篇文章提供了这个问题的几种解决方法。我认为,最好的方法还是从源头避免这种情况出现,比如可以为切面类单独封装一个接口,功能同IQueueService,从而使得切面类可以不依赖目标类。

最后,Spring AOP为代码的解耦提供了很好的实践。最理想的情况是业务代码感受不到切面代码的存在,如果因为设计原因,使得业务代码中嵌入了一些能感受到AOP的存在,那不如不用AOP。

--EOF--

系统日志记录技术的分析

Java web系统里,日志系统设计在技术上可以归纳出了三种实现方法:

 一、Spring AOP自动代理(Interceptor, 拦截器)

配置spring配置文件中的BeanNameAutoProxyCreator,通过匹配目标类名来实现AOP,拦截粒度是类。

优势:
1、实现了业务代码和日志代码的分离。
2、当业务代码执行出错时,拦截器可以照常捕获异常(异常码须在业务代码中主动抛出),而不影响日志的记录。
劣势:
1、虽然可以为一组相似功能的类设置拦截器,但是对于不同功能的类或者返回值不同的类方法,必须新建一个拦截器。

 二、注解方式的Spring AOP

注解方式实现的Spring AOP实际上与拦截器的内部实现方式是一样的。它通过声明一个@interface注解类接口,日志记录功能在该注解类接口的实现类(切面类)中实现,最后在所有需要监控的方法前面加上该注解即可。拦截的粒度可以是方法和类,但主要以拦截方法为主。

优势:
1、实现了业务代码和日志代码的分离。
2、即使需记录的方法没有按正常流程执行完,切面类也能记录到错误(异常)返回码。
3、方便。只要对那些需要被监控的方法加注解就可以实现日志记录。相对于第一种拦截器方式,注解方式实现代码风格较好,免去spring配置文件的配置,容易阅读和维护。
劣势:
1、与拦截器一样,一个注解接口声明只能实现一组功能相似的方法的日志记录功能。对于不同功能的或者返回值不同的类方法,必须重新声明一个注解类。

 三、业务代码中直接调用日志方法

该实现方法通过对日志的输出进行封装,在需要进行日志记录的地方直接调用该方法。

优势:
1、不必像拦截器方式和注解方式那样为实现日志功能而新增切面类。
2、代码直观易懂。
劣势:
1、业务代码和日志代码耦合在一起,为系统升级和改造带来麻烦。
2、若要实现记录接口调用失败的日志,还会导致耦合度进一步加剧。

以上分析了三种系统可行的日志记录技术手段的优劣势,至于针对系统中业务日志的记录具体要采用哪种技术手段,可以从以下各方面考虑:

一、如果日志可以只记录接口成功调用时的状态、参数、返回结果等信息,而把异常或错误日志交给另一级别的监控系统处理,那么采用第三种方法比较合理。这种情况下只需提供一个通用的日志工具类,在需要记录日志的地方将接收的参数按固定格式输出至日志文件即可。

二、如果需要同时记录方法调用成功或失败和抛出异常的情形,那么如果采用第三种方法再加异常捕捉语句,实现起来会导致业务代码和日志记录代码高度耦合,而如果采用spring AOP的方法来实现就没有这个问题。

三、对于通过spring aop实现的两种(拦截器和注解)日志记录方式,个人认为第二种通过注解方式进行方法拦截比较合理,注解方式避免了与spring配置文件打交道,放手交给spring容器,使其代理更多的功能,发挥更大的作用,何乐而不为呢。

--EOF--