返回 登录
0

spring引介增强定时器实例:无侵入式动态增强类功能

阅读4588

引介增强实例需求

在前面我们已经提到了前置、后置、环绕、最终、异常等增强形式,它们的增强对象都是针对方法级别的,而引介增强,则是对类级别的增强,我们可以通过引介增强为目标类添加新的属性和方法,更为诱人的是,这些新属性或方法是可以根据我们业务逻辑需求动态变化的。怎么来理解这一点?我们先展示一个用引介增强解决的现实需求问题,现在先来看看我们的一个需求:

我们要设计一个定时任务,在每天特定流量高峰时间里,判断人们某个网络请求在服务端程序业务逻辑处理上的耗时,一般地,我们web后端处理请求处理按如下时序图执行:

Created with Raphaël 2.1.0模拟服务端接受web请求并返回客户端客户端controllercontrollerserviceserviceDAO/数据库DAO/数据库发送请求进行业务逻辑处理调用DAO层API访问数据库进行数据处理返回数据封装返回相应业务逻辑处理结果发送响应。

现在,即需要我们统计从接收请求后到发出相应前这个过程的耗时。

最直接的解决方法就是,在Controller层的每一个方法起始到结束,分别记录一次时间,用结束减起始来获得结果,但这存在两个问题:
1. 根据springMVC一个方法对应一种请求,如果我们的controller中有很多方法,而我们需要为每一个方法都进行统计,这意味者我们需要在controller中嵌入大量重复冗杂的代码,并且这些代码和我们controller层逻辑没有任何关系,如果日后我们根据需求要添加其他任务,就又要修改我们的controller,这不符合开闭原则和职责分明原则。
2. 因为我们的需求是动态的,我们还可能需要在controller中每次调用都去判断当前时刻是否处于需要统计耗时的时刻。

基于接口实现和代理工厂

下面看看我们如何用引介增强来解决同样的问题:

1. 定义UserController

public class UserController {
    public void login(String name){
        System.out.println("I'm "+name+" ,I'm logining");

        try {
            Thread.sleep(100);//模拟登陆过程中进行了数据库查询。各种业务逻辑处理的复杂工作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

2. 配置引介增强需要实现的代理接口

public interface TaskActiver {//通过此接口来监控是是否激活定时器任务
    void startTask();//满足特定条件,开启任务
    void stopTask();//不满足特定条件,停止任务
}

3. 配置我们的增强实现类

    public class MyTaskController extends DelegatingIntroductionInterceptor implements TaskActiver{
    /*
    1. DelegatingIntroductionInterceptor是引介增强接口IntroductionInterceptor的实现类,我们通过扩展此接口自定义引介增强类
    2. 引介增强类要实现我们的代理接口TaskActiver,这是和其他增强不同的地方。
    */
        private ThreadLocal<Boolean> isTime = new ThreadLocal<Boolean>();//这是一个线程安全的变量。

        public MyTaskController(){//定时任务控制器构造函数
            isTime.set(false);//任务默认处于关闭状态
        }
        @Override
        public void startTask() {//通过定时器监控达到特定时间,启动任务。
            isTime.set(true);
        }

        @Override
        public void stopTask() {//在任务完成后关闭任务,等待下次定时器到时启动
            isTime.set(false);
        }

        /**
         * //覆盖父类的invoke方法,当程序运行到特定方法时,我们先拦截下来,
         * 然后启动我们的任务,再调用相应的方法,在方法调用完后,完成并关闭我们的任务

         */
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable{
            Object obj = null;
            if(isTime.get()){//到了特定时刻,即我们的定时器处于任务激活状态。
                MyTask.getTarget().set(invocation.getMethod().getName());
                //通过反射获取运行期当前任务的名称,并初始化我们的任务配置,在这里我们的任务是记录时间
                MyTask.beginRecord();//任务开始,记录开始时间
                obj = super.invoke(invocation);//调用原目标对象方法
                MyTask.endRecord();//结束任务,结合开始时间计算耗时,并将统计结果保存到日志记录文本中
            }else{
                obj = super.invoke(invocation);
            }
            return obj;
        }
    }
    /********************下面是我们的任务操作类***********************/
//这里是记时操作,如果以后我们有其他类似业务需求,可以修改此类,而不是像我们开始提到的那样直接写在controller中
        /*
        MyTask是单例类,为确保每个线程有自己独立的时间记录和方法名,将下面两个静态变量定义为ThreadLocal类型。
        */
        private static ThreadLocal<Long>beginTime = new ThreadLocal<Long>() ;//记录开始时间
        private static ThreadLocal<String> target = new ThreadLocal<String>();//记录调用方法

        public static void beginRecord(){
            System.out.println("记时开始");
            beginTime .set(System.currentTimeMillis());
        }

        public static void endRecord(){
            System.out.println("记时结束");
            System.out.println("调用"+target+"方法耗时:" +( System.currentTimeMillis()  - beginTime.get())+ "毫秒");
        }

    }
  1. 首先谈谈ThreadLocal是什么东东,当我们在多线程中共享一个对象时,对象里的成员属性是有状态的,只要一个线程对成员属性进行了修改,在其他线程也会生效,如果我们想每个线程都共享一个对象,但不共享里面的成员属性,就可以使用ThreadLocal, 它会为我们的每一个线程创建一个当前成员属性的拷贝,即使我们任一线程对其进行了修改,也不会影响到其他线程。这就好像几个朋友(线程)一起住在(共享)一间大房子(对象)但每个人都有自己独立的(一模一样的)房间(成员属性)即使我们把自己的房间拆了,也不会影响到其他人。
  2. 对于以上三个ThreadLocal变量中的beginTime和target我们可能较好理解,但为什么我们要把isTime也设为ThreadLocal型?这是考虑我们可能要利用MyTaskController是单例的,如果我们要调度其他定时任务(而不是这里简单地记录前后耗时),为确保不同引介增强的“定时状态“不互相共享,我们必须确保它是线程安全的。
  3. 那么可能又问:为什么不干脆直接将MyTaskControoler设成prototype?这首先要明确,MyTaskController是一个代理类,由CGLib创建,而CGLib创建代理的性能是很低的,如果每次注入(如getBean())获取代理实例,都返回一个新的实例,将会严重影响性能。

4. 配置我们的IOC容器

<bean id="myTaskController" class="test.aop4.MyTaskController"/>
<bean id="proxyFactory" class="org.springframework.aop.framework.ProxyFactoryBean" >
    <!-- 使用CHLib代理,配合引介增强通过创建子类来生成代理,默认值为false会报错 -->
    <property name="proxyTargetClass" value="true" />
    <property name="interfaces" value="test.aop4.TaskActiver" /><!--注册代理接口,我们的代理增强类必须实现此接口-->
    <property name="targetClass" value="test.aop4.UserController"/><!--这是我们的目标对象,通过指定目标对象,我们可以在注入时,直接将当前代理工厂Bean强制转换为我们的UserController。从而生成我们的代理子类-->
    <property name="interceptorNames" value="myTaskController" /><!--字面意思是拦截器名称,实现效果类似于拦截器,把我们目标类的所有行为(方法)都拦截下来,织入我们的增强(体现在invoke函数的重写中)-->
</bean>

5. 运行测试函数

    public static void main(String args[]) throws InterruptedException{
        ApplicationContext ac = new ClassPathXmlApplicationContext("classpath:test/aop4/aop.xml");
        UserController userController = (UserController) ac.getBean("proxyFactory");//通过代理工厂来生成我们的目标类
        SimpleDateFormat sdf = new SimpleDateFormat("HHmm");
        TaskActiver taskActiver = (TaskActiver) userController;
        userController.login("zenghao");//这里方便测试,没有开启任务,先在正常情况下模拟用户登陆请求
        while(true){//这里方便测试,一般情况,我们应另开一个线程单独来运行我们的定时任务部分
            //定时任务部分开始
            Integer now = Integer.valueOf(sdf.format(new Date()));//以时分格式获取当前时间
            if(now >  0000&& now < 0030 ){
            //在特定时间内开启任务,咱们不妨假设这个时间就是双十一晚的12点到12点半。淘宝要实施监控统计数据
                taskActiver.startTask();
            }else if(now <=  0000 && now >= 0030){//在特定时间外关闭任务
                taskActiver.stopTask();
            }
            //定时任务部分结束
            //下面模拟正常的web请求,假设每隔5秒中,服务器接受一个web请求。
            Thread.sleep(5 * 1000);
            userController.login("zenghao");
        }

    }

6. 测试结果

I’m zenghao ,I’m logining ——————-这是任务没开始前的web请求
记时开始
I’m zenghao ,I’m logining —————-这是满足特定时间开启任务后的web请求
记时结束
调用java.lang.ThreadLocal@17d8986方法耗时:101毫秒
记时开始
记时结束
调用java.lang.ThreadLocal@17d8986方法耗时:0毫秒 ——-代理增强类中使用ThreadLocal变量的时间也被记录下来,
记时开始
I’m zenghao ,I’m logining —————-这是满足特定时间开启任务后的web请求
记时结束
调用java.lang.ThreadLocal@17d8986方法耗时:101毫秒

7. 实例小结

  1. 很多核心概念与实现细节都体现在实例代码注释中,如果有不理解的地方,可以再多看看上面的代码实例及相关的注释。
  2. 观察我们的测试函数,有一个语句为TaskActiver taskActiver = (TaskActiver) userController;即将我们的代理接口引用指向了我们的目标对象,这个时候我们操纵我们的代理接口,实际上由多态特性,我们操纵的是实现了此接口的增强类MyTaskController,于是,核心部分来了:我们的增强类”涵盖“了我们的目标对象(注意到我们的taskActiver是由userController强转而来的),但他又有了许多新的特性,比如我们在MyTaskController中定义的isTime变量,或者简介调用了MyTask中的方法(实际上可以完全不要MyTask类,直接将里面的变量方法定义在MyTaskController中)。总之,我想说的是,我们通过引介增强,可以:
    1. 新的方法、成员属性添进我们的目标对象中,这些新的方法和成员属性通过接口代理操控
    2. 却不用对目标对像做任何修改,而者在现实中是很有意义的,对于大型项目或新接手项目,我们的类可能已经集成了很多的功能,面对新的需求,我们只想添加新的功能,而不是直接对已有的完整的健壮的类进行修改。这也是各类增强的重大实现意义所在。
    3. 不用改变目标对象的被调用方式(比如要登陆,还是调用login方法),即不影响我们的上一层方法调用,比如,我修改了service层的类,不用随之修改调用了service层该类的对应代码段。
    4. 同时最为巧妙的是,我们可以通过我们的业务逻辑来控制这种增强什么时候起作用,什么时候不起作用!比如这里的例子,我要的只是在特定时间内增强。这也是区别其他增强如环绕增强的核心所在。其他的增强一旦配置好了,就必然生效,而不像引介那样是可控的。

除了上述方法外,我们还可以通过@AspectJ和基于Schema的配置来实现引介增强。不过通过测试,通过以上两种方法,由于不能实现重写invoke方法的横切逻辑,增强效果大大减弱,它仅能为目标类动态添加新的实现接口,却不能无侵入式修改原目标类方法的调用效果,比如我再调用login方法,不能通过引介增强来实现耗时统计的功能了,除非使用环绕增强加上一定的业务逻辑处理。下面我们先看用@AspectJ配置来分析说明

使用@AspectJ

使用注解@DeclareParents,相当于IntroductionInterceptor,该注解有两个属性:
- value:定义切点
- defaultImpl:指定默认的接口实现类。

下面是代码示例:

1. 目标对象类

相对之前例子没有变化

public class UserController {
    public void login(String name){
        System.out.println("I'm "+name+" ,I'm logining");

        try {
            Thread.sleep(100);//模拟登陆过程中进行了数据库查询。各种业务逻辑处理的复杂工作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

2. 代理接口和代理接口实现类

相对之前例子,代理接口没有变化,而代理接口实现(增强)类则不再继承DelegatingIntroductionInterceptor类。

/*********************代理接口*********************/
public interface TaskActiver {//通过此接口来监控是是否激活定时器任务
    void startTask();
    void stopTask();
}

/*********************代理接口实现类*********************/
public class MyTaskController  implements TaskActiver{
    private ThreadLocal<Boolean> isTime = new ThreadLocal<Boolean>();

    public MyTaskController(){
        isTime.set(false);
    }
    @Override
    public void startTask() {//通过定时器监控达到特定时间,启动任务。
        System.out.println("任务开启");
        isTime.set(true);
    }

    @Override
    public void stopTask() {//在任务完成后关闭任务,等待下次定时器到时启动
        System.out.println("任务关闭");
        isTime.set(false);
    }
}

3. 配置切面(新增类)

@Aspect
public class MyTaskAspect {
    //value指向我们的目标对象,defaultImpl指向我们的代理接口实现类
    @DeclareParents(value = "test.aop5.UserController",defaultImpl = MyTaskController.class)//注解在我们的代理接口上
    public TaskActiver taskActiver;//声明代理接口
}

4. IOC容器配置

<aop:aspectj-autoproxy proxy-target-class="true"/><!-- 使切面注解@AspectJ生效 -->
<bean id="userController" class="test.aop5.UserController" /><!-- 测试需要注入的Bean-->
<bean class="test.aop5.MyTaskAspect" /><!--注册切面,使IOC容器自动配置AOP-->

5. 运行测试方法

public static void main(String args[]) throws InterruptedException{
    ApplicationContext ac = new ClassPathXmlApplicationContext("classpath:test/aop5/aop.xml");
    UserController userController = (UserController) ac.getBean("userController");
    TaskActiver taskActiver = (TaskActiver) userController;//①强制转换成功,这是java多态中的向上转型。
    System.out.println(taskActiver instanceof UserController);//②
    System.out.println(userController instanceof TaskActiver);//③
    taskActiver.startTask();//④
    userController.login("zenghao");//⑤
}

6. 结果分析

运行测试代码后,控制台打印信息:

true
true
任务开启
I’m zenghao ,I’m logining

接下来我们分析测试方法中注释编号相应的代码行:
1. ①中使用了向上转型,向上转型会使父类(taskTactiver)丢失了子类(userController)的方法,但这里的关键是,taskActiver成为了userController的父类,这是通过引介增强实现的,即我们为UserController添加了新的实现接口taskActiver
2. ②结果为true是显然的,因为我们的taskActiver就是由userController转型而来
3. ③恰恰验证了我们①中的结论:taskActiver成为了userController的父类
4. ④我们通过父类接口调用其增强实现类中的startTask方法,这点可以通过打印信息“任务开启”来说明。在这里,我们也得到一个结论,我们为目标类引介新的代理接口,实际上是让目标类新增了新的代理接口的增强实现类中的功能
5. ⑤模拟我们的web请求,这里不再像我们开始例子那样,能够实现耗时统计,尽管我们在④中开启了任务(事实上,从增强实现类的定义来看,这是显然的结果),也就是说,我们只是为目标对象添加了新的接口,但要使用相应接口的功能还要通过向上转型来完成,而向上转型后父接口也丢失了目标对象的属性方法,因而两者是相对独立的。从这个角度讲,我个人觉得接引增强的这种配置实现实际意义并不大。

基于Schema配置

使用这种方法我们主要需要配置<aop:declare-parents>标签属性。同上例的实现效果,使用基于Schema的配置,可去除切面类MyTaskAspect,同时重新配置xml文件:

<bean id="userController" class="test.aop6.UserController" />
<aop:config proxy-target-class="true">
    <aop:aspect>
        <!-- 
1. types-matching:指向我们的目标对象类,这里可以使用我们的AspctJ统配符如+、\*、和..等通配符
2. implement-interface:指向我们的代理接口
3. default-impl指向我们实现了代理接口的增强类。这里使用全限定名的方式
4. <aop:declare-parents>里的还有另一个属性delegate-ref,它指向我们注册好的另外一个Bean名称
-->
        <aop:declare-parents types-matching="test.aop6.UserController"
            implement-interface="test.aop6.TaskActiver" default-impl="test.aop6.MyTaskController" />
    </aop:aspect>
</aop:config>

运行同样的测试文件,我们会得到相同的结果。

在这里,我们要注意:
1. 一定要将proxy-target-class配置成true,否则引介增强配置失败,会报异常:java.lang.ClassCastException: com.sun.proxy.\$Proxy0 cannot be cast to test.aop6.UserController
2. 因为我们引介增强是类级别的,不用专门配置切面,即也无需在<aop:aspect>中声明ref=”切面Bean名称”

源码下载

本博文提到的示例源码请到我的github仓库https://github.com/jeanhao/spring的aop分支下载。

评论