欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《编程机制探析》第二十一章 AOP

程序员文章站 2022-05-17 13:44:18
...
《编程机制探析》第二十一章 AOP

第二十一章 AOP

程序设计的一个重要目标就是提高重用性,避免重复代码。
到目前为止,我们已经接触到了诸多重用手段——过程式编程,面向对象编程,函数式编程,泛型编程,设计模式,等等。
本章介绍一种新的重用手段——面向方面编程(Aspect Oriented Programming),简称AOP。
什么叫做方面(Aspect)?这个词很难从字面上解释。我们还是通过具体的应用场景来看理解AOP的概念。
在程序设计中,我们虽然有各种手段来消除重复代码,但是,总有那么一些地方,遍布着重复代码,而那些地方,是我们现有的重用手段无法涉及的领域。我给那些地方起个名字,叫做设计死角。
那么,有哪些地方是设计死角呢?让我们来看几个具体的例子。
第一个例子,程序运行日志(Log)。
为了方便程序员(或者管理员)追踪应用程序的运行状态,程序员通常在程序代码中加入日志记录功能。
在原始的年代里,程序员大量使用print语句来输出程序运行的当前状态。虽然现在还是有很多程序员这么做。但是,这种做法已经是被摒弃的。正确的做法是使用日志(Log)编程接口。
通过日志(Log)编程库,我们可以定义日志开关,决定是否记录日志;我们可以定义日志存放位置,屏幕、文件、或者数据库;我们可以定义日志格式,简单文本、XML或者HTML;我们可以定义日志内容,决定哪些内容记录,哪些内容不记录;对于多个模块,我们可以定义多种日志记录方式;总之,日志编程库的好处不胜枚举。
现代程序员基本上都用日志编程库进行日志记录。但是,他们使用日志编程库的手段还是那么原始,他们照样把日志记录语句写得到处都是,遍布程序中各个角落。
那些日志记录语句就是重复代码,那些日志记录语句所在之地,就是设计死角。因为,日志记录遍及所有的对象类型中,我们无法用现有的设计手段进行统一设计。
第二个例子,数据库事务(Transaction)。
数据库是应用开发中最常用到的存储结构。数据库事务是操作数据库中最常遇到的问题。
这里简单地介绍一下数据库事务的概念。
数据库事务的和“原子操作”的概念有些相似。
在数据库中,我们执行一件任务,涉及到改动多处数据,我们希望这些改动,要么一次都成功,要么一切都保持原状。这样的一种“多个步骤合成一个原子步骤”的操作,就叫做数据库事务。
数据库事务分为两种——局部事务(Local Transaction)和全局事务(Global Transaction)。
局部事务中,涉及的数据库只有一个,涉及的数据也只在一个数据库中,这是我们在应用程序开发中最常遇到的事务。
局部事务的处理也很简单,就是把数据库的自动提交(Auto Commit)功能关掉,自己在所有数据操作都成功之后,在手动进行提交(Commit)处理;否则就进行回滚(Roll back)操作,一切恢复原状。
全局事务的情况就要复杂一些,涉及到多个数据库中的数据操作。这时候,就要保证所有数据库中的操作全都要同时成功提交,或者同时回滚、恢复原状。全局事务多见于一些拥有多个数据服务器的大型机构中。
全局事务的处理相当复杂,卷入全局事务的所有数据库,相互之间要进行复杂的通信和确认,才能保证全局事务的“原子性”。
当然,数据库事务的繁简与否,不是我们关心的问题。我们关心的是,这些事务代码分布在哪里。我们需要把事务代码分布到几乎所有的数据库操作代码中。
我们应该如何设计,才能消除这些重复的事务代码?要知道,进行操作数据库的对象各种各样,没有统一的接口定义。
这些事务代码遍及之处,就是设计死角。
看过了这两个例子,你应该对方面(Aspect)这个词有了感性体会。是的,Aspect就是设计死角。Aspect就是遍及在程序中的日志代码。Aspect就是遍及在程序中的事务代码。在AOP的概念中,这是两个不同的Aspect,分别叫做Log Aspect和Transaction Aspect。
AOP的目的就是把这些重复代码从程序中抽离出来,在程序编译或者运行的时候,再把这些重复代码自动“回填”程序中的对应位置,从而免除了程序员的重复劳动。
从AOP的目的,可以看出AOP模型中的两个主要组成部分——重复代码和回填位置。
重复代码就是日志代码、事务代码等。回填位置就是重复代码原来所在的位置,即设计死角,或者叫做Aspect。
AOP处理器(编译器或者解释器)的工作很简单,就是在所有的回填位置中,加入程序员定义的重复代码。
AOP用户的工作是什么?就是定义重复代码和回填位置。
代码部分不用说了,我们来看回填位置应该如何定义。
首先,我们需要明确,回填位置都可以包括哪些位置。
重复代码是否可以填入到程序中的任何一条语句的前面或者后面?可以说,这种想法只存在理论上的可行性。
实现上的效率且不说,就从用法上来说,也没有什么现实意义。
想象一下,我们需要给AOP编译器(或者解释器)定义这样一个任务:请把重复代码加入到方法f中的if(x > 0)语句之前。
为了描述“方法f中的if(x > 0)语句之前”这个位置,我们写的东西可能比重复代码还要长,还不如直接就把重复代码加到“方法f中的if(x > 0)语句之前”呢。
因此,这里有一个原则,回填位置的定义必须是简洁的。
既然语句级别不行,那么我们再上一级,到方法(过程、函数)的级别,这已经是代码块容身的*别了。如果这个级别也不行。那么,AOP也就别实现了。
幸运的是,这个级别是可行的。因为方法名简单易写,方法存身的类名、包名、模块名也很容易写,而且,还可以用通配符(*)来定义一批相似的回填范围。
AOP处理器(编译器或者解释器)根据回填位置定义,就可以把重复代码回填到方法(过程、函数)定义的前后。这个 “回填”的工作实际上相当于“篡改”了方法的原有行为,很像是黑客或者计算机病毒的行为。不过,AOP的“篡改”工作不是为了破坏,而是为了帮助。另外,“回填”这个动作,除了“篡改”这个别名之外,还有一个用得更广的别名,叫做“织入”(weave,编织),意思就是,把回填代码“编织”进原来的代码中。
AOP的“回填”动作的实现,基本上都是在Proxy Pattern(代理模式)的基础上实现的,即用一个Proxy对象包装原有对象的方法。例如:
LogProxy {
  innerObject; // 真正的对象
  f1() {
// Log here

innerObject.f1(); // 调用原来对象的对应方法

// Log here too
  }
}
再例如:
TransactionProxy {
  innerObject; // 真正的对象
  f1() {
// start Transaction

innerObject.f1(); // 调用原来对象的对应方法

// finish Transacion
  }
}
这些Proxy是可以叠加在一起的。叠加顺序由程序员自己指定。
当然,一个对象中的方法可能不止一个。这时候,我们就需要包装对象中所有需要AOP处理的方法。比如:
LogProxy {
  innerObject; // 真正的对象
  f1() {
// Log here

innerObject.f1(); // 调用真正的对象的对应方法

// Log here too
  }

  f2() {
// Log Hear

innerObject.f2(); // 调用真正的对象的对应方法

// Log here too
  }
}
这里面还是存在重复代码。Bad Smell。我们可以利用Reflection机制,写一个通用的方法截获器。截获器的英文叫做Interceptor。这个词汇有拦截的意思。
还有种叫法,叫做劫持(Hijack)。我们经常听说,某种浏览器被某某木马插件劫持了。就是这个意思。为了避免这种不良联想,我们还是用Interceptor这个更通用的词汇。
MethodInterceptor{

  around( method ){
    before(method)
 
    method.invoke(…); // 调用真正的对象方法
  
    after(method, result)
  }
}
Proxy可以继承这个MehtodInterceptor,实现before()和after()两个方法,把重复代码填进去。
函数式语言中,每个函数对象只有一个方法,用代理模式来包装,更加容易。从回填位置的声明上来说,回填位置定义中的通配符,等同于函数式编程中的模式匹配(Pattern Match)。从这两个方面看,函数式语言更有利于实现AOP。
Proxy Pattern + Method Reflection Interceptor + 代码自动生成(即回填)
这样一个三元组合,就是AOP的基本实现原理。AOP的实现多种多样,但是,其实现原理都脱不了这个范式。
AOP就像一个大染缸,本来干干净净的对象,经AOP一处理,就染上了(织入了)本来不属于它的特性。
AOP实现之间的区别,主要就在于代码自动生成(织入,填入)的方案。一般有两种方案。一种是在源代码上做文章,在编译器中加入插件,在编译源代码的过程中,把重复代码填进去(织进去),最后一起编译成目标代码。
一种是在目标代码上做文章。这种AOP直接处理目标代码,在目标代码中间填入(织入)重复代码。
这两种方案都是代码生成技术,我都不喜欢。实质上,重复代码还是分布在各处了。写在一处,复制到各处。
我喜欢第三种方案——在解释器或者虚拟机里做文章。当解释器或者虚拟机在执行代码的过程中,遇到回填位置,就会执行相应的重复代码。
这种实现方案就摆脱了代码生成技术,重复代码只有一处。但是,这种方案有一种致命的缺陷,那就是效率。
如果采取这种方案,那么,解释器或者虚拟机每次调用方法的时候,都需要查找“回填位置表”,看看这个方法是否在回填范围中。当然,我们可以做有限的优化。在第一次查找某方法之后,就在这个方法上贴上一个标签,比如,“无Interceptor”、“有LogInterceptor”、“有LogInterceptor、TransactionInterceptor”等。下一次,再遇到这个方法的时候,就可以不用查表了。但是,一个应用程序中的方法如此之多,这种优化的作用是很有限的。所以,这种方案是不现实的。我没有看到过这种方案,也没有想出解决思路。所以,目前的AOP还是代码生成技术的天下。
AOP的实现原理,讲到这里就结束了。我们这里稍微涉及一下具体的应用。由于AOP目前还是一种代码生成技术,不符合我的审美观。而且,AOP的入门例子到处都是,也不值得占用本书的篇幅。我们这里说明一下常见的AOP声明语法。
首先,我先给出,我理想中的AOP声明语法是什么样的,然后我们再来看现有AOP实现框架中的语法惯例。
我理想中的AOP声明语法是这样的。形式上类似于Haskell函数。
weave   拦截器列表   回填位置列表
用英文表示就是
weave  InterceptorList  LocationList
具体例子就是:
weave  [LogInterceptor,  TransationInterceptor]  [ com.*,  net.db.* ]
这段声明的含义就是,把[LogInterceptor,  TransationInterceptor]这个列表里面的所有拦截器,都织入到[ com.*,  net.db.* ]这些位置的所有方法里面。
这样就够了吗?还不够。我们还需要一个过滤器,叫做Filter或者Exclude,用来过滤掉或者排除掉一些不需要拦截的特殊方法。我们可以直接在LocationList里面支持这种过滤和排除功能。比如:
weave  [LogInterceptor,  TransationInterceptor]  [ com.*,  net.db.*,  - net.db.test.* ]
net.db.test.*前面加了一个负号(-)。这就表示,把net.db.test.*里面的所有方法都排除出去。
我们看到,这种声明形式很像是合法的程序调用代码。事实上,AOP确实会提供类似于这样的程序编程接口。只不过,他们的用词惯例不同。他们把LocationList叫做PointCut(切点,切面),把Interceptor叫做Advice。换成他们的词,AOP声明形式就是这样:
weave  AdviceList  PointCut
这就是一个weave函数,接受两个参数,AdviceList和PointCut。
在解释语言中,我们可以直接用这种函数调用代码的形式来声明AOP。因为解释语言的程序本身就可以当做配置文件来用,随时修改,随时运行,不需要重新编译。
但是,编译语言就不行了。为了保持AOP定义的灵活,我们必须把AOP声明从代码中抽出来,放在一个配置文件中,通常是XML文件中。
我们可以看到解释语言(通常是动态类型语言)的优越性。这也是我为什么倾向于动态类型语言的又一个原因。
另外,关于用XML表达程序结构,我是不太赞成的。在我看来,XML就是用来存放树形数据的。用XML来表达程序结构,简直就是滥用。但是,由于XML很容易解析,因此,这种滥用现象越来越严重。毕竟,解析一门解释语言,需要一个颇为复杂的词法、语法分析器,而XML需要的解析器则简单许多。
由于个人的好恶,我这里坚决不给出XML声明AOP的例子。反正只不过是把好端端的代码转换成蹩脚的XML格式而已。
另外,需要说明的是,在AOP的惯例声明语法中,Advice并不是直接对应Interceptor,而是对应Interceptor里面的before和after两个方法。我们可以把上面的MethondInterceptor进一步细化,变成:
BeforeAdvice{
  before(method)
}

AfterAdvice{
  after(method)
}


MethodInterceptor{
  BeforeAdvice beforeAdvice
  AfterAdvice  afterAdvice

  around( method ){
    beforeAdvice.before(method)
 
    result = method.invoke(…); // 调用真正的对象方法
  
    afterAdvice.after(method, result)
  }
}
按理来说,AOP的声明也就这些,到这里就完了。但是,还没有完。有些AOP玩出了花活儿,发明出了新式的AOP声明方式——运用一些语言中提供的标注(Annotation)特性进行AOP声明。
这是怎么做的呢?这种声明方式要求程序员用某种Annotation标注每一个需要织入AOP重复代码的方法。然后,AOP实现框架就在代码中去寻找这些Annotaion,进行相应的织入重复代码的处理。
这种写法用散布在各处的Annotation替换了集中在一处的AOP声明。你可以说这是一种创新,你也可以说,这是开历史的倒车。
俗话说,分久必合,合久必分。分有分的道理,合有合的道理,分分合合,就是这个道理。
AOP的作用本来是把散落在各个方法中的重复代码合在一处,进行集中处理。现在,又用Annotiaon把它分散开了。这个游戏倒是有趣。
当然了,喜欢这种声明方式的程序员可以举出若干好处。比如,Annotion比重复代码更加简洁方便,比集中式声明更加一目了然,云云。但这劝服不了我。我仍然固执地认为,这就是一种没事找事型的做法。天下本无事,庸人自扰之。还是那句老话,这是我的个人偏见,不必当真。