了解AOP(第一部分)
用面向方面的编程方式分离软件关注点
摘要
多数软件系统都包含几个跨越多个模块的关注点。用面向对象技术实现这些关注点会使系统难以实现,难以理解,并且不利于软件的演进。新的AOP(面向角度的编程方法)利用模块化来分离软件中横切多模块的关注点。使用AOP,你可以建立容易设计,易于理解和维护的系统。此外,AOP可以带来更高的产出,更好的质量,更好的扩展性,这篇文章是这个系列里三篇文章中的第一章,介绍AOP的概念和它所解决的问题。
作者:Ramnivas Laddad
一个关注点就是一个特定的目的、一块我们感兴趣的的区域。从技术的角度来说,一个典型的软件系统包含一些核心的关注点和系统级的关注点。举个例子来说,一个信用卡处理系统的核心关注点是借贷/存入处理,而系统级的关注点则是日志,事务完整性,授权,安全性及性能问题等,许多关注点——我们叫它横切关注点——会在多个模块中出现,使用现有的编程方法,横切关注点会横越多个模块,结果是使系统难以设计、理解、实现和演进。
AOP(面向角度的编程方式)能够比上述方法更好的分离系统关注点,从而提供模块化的横切关注点。
在这篇文章里——关于AOP的三篇文章的第一章,我首先会 解释横切关注点在一些即使是中等复杂度的软件系统中也会引起的问题,接着我会介绍AOP的核心概念并演示AOP是怎样解决横切关注点问题的。
软件编程方法的演进
在计算机科学的早期阶段,开发人员使用直接的机器级代码来编程,不幸的是,程序员得花费更多时间来考虑一种特定机器的指令集而不是手中需要解决的问题本身。慢慢的我们转而使用允许对底层机器做某种抽象的高级语言。然后是结构化语言,我们可以把问题分解成一些必要的过程来完成任务。但是,随着复杂程度的增加,我们又需要更适合的技术。面向对象的编程方式(OOP)使我们可以把系统看作是一批相互合作的对象。类允许我们把实现细节隐藏在接口下。多态性为相关概念提供公共的行为和接口,并允许特定的组件在无需访问基础实现的前提下改变特定行为。
编程方法和语言决定了我们和计算机交流的方式。每一种新的方法学都提出一种新的分解问题的方法:机器码、伪代码、过程和类等。每种新的方法学都使得从系统需求到编程概念的映射更加自然。编程方法学的发展让我们可以建立更加复杂的系统,这句话反过来说也对,我们能够建立更加复杂的系统是因为这些技术允许我们处理这种复杂度。
现在,大多数软件项目都选择OOP的编程方式。确实,OOP已经表明了它处理一般行为的能力,但是,我们一会儿会看到(或许你已经感觉到了),OOP不能很好的处理横越多个——经常是不相关的——模块的行为,相比之下,AOP填补了这个空白,它很可能会是编程方法学发展的下一个里程碑。
把系统看作一批关注点
我们可以把一个复杂的系统看作是由多个关注点来组合实现的,一个典型的系统可能会包括几个方面的关注点,如业务逻辑,性能,数据存储,日志和调试信息,授权,安全,线程,错误检查等,还有开发过程中的关注点,如易懂,易维护,易追查,易扩展等,图一演示了由不同模块实现的一批关注点组成了一个系统。
图 1. 把模块作为一批关注点来实现
图二把需求比作一束穿过三棱镜的光,我们让需求之光通过关注点鉴别三棱镜,就会区别出每个关注点,同样的方法也适用于开发阶段的关注点。
图 2. 关注点分解: 三棱镜法则
系统中的横切关注点
开发人员建立一个系统以满足多个需求,我们可以大致的把这些需求分类为核心模块级需求和系统级需求。很多系统级需求一般来说是相互独立的,但它们一般都会横切许多核心模块。举个例子来说,一个典型的企业应用包含许多横切关注点,如验证,日志,资源池,系统管理,性能及存储管理等,每一个关注点都牵涉到几个子系统,如存储管理关注点会影响到所有的有状态业务对象。
让我们来看一个简单,但是具体的例子,考虑一个封装了业务逻辑的类的实现框架:
public class SomeBusinessClass extends OtherBusinessClass {
// 核心数据成员
// 其它数据成员:日志流,保证数据完整性的标志位等
// 重载基类的方法
public void performSomeOperation(OperationInformation info) {
// 安全性验证
// 检查传入数据是否满足协议
// 锁定对象以保证当其他线程访问时的数据完整性
// 检查缓存中是否为最新信息
// 纪录操作开始执行时间
// 执行核心操作
// 纪录操作完成时间
// 给对象解锁
}
// 一些类似操作
public void save(PersitanceStorage ps) {
}
public void load(PersitanceStorage ps) {
}
}
在上面的代码中,我们注意到三个问题,首先,其它数据成员不是这个类的核心关注点,第二,performSomeOperation()的实现做了许多核心操作之外的事,它要处理日志,验证,线程安全,协议验证和缓存管理等一些外围操作,而且这些外围操作同样也会应用于其他类,第三,save()和load()执行的持久化操作是否构成这个类的核心清楚的。
横切关注点的问题。
虽然横切关注点会跨越多个模块,但当前的技术倾向于使用一维的方法学来处理这种需求,把对应需求的实现强行限制在一维的空间里。这个一维空间就是核心模块级实现,其他需求的实现被嵌入在这个占统治地位的空间,换句话说,需求空间是一个n维空间,而实现空间是一维空间,这种不匹配导致了糟糕的需求到实现的映射
表现
用当前方法学实现横切关注点是不好的,它会带来一些问题,我们可以大致把这些问题分为两类
代码混乱:软件系统中的模块可能要同时兼顾几个方面的需要。举例来说,开发者经常要同时考虑业务逻辑,性能,同步,日志和安全等问题,兼顾各方面的需要导致相应关注点的实现元素同时出现,引起代码混乱。
代码分散:由于横切关注点,本来就涉及到多个模块,相关实现也就得遍布在这些模块里,如在一个使用了数据库的系统里,性能问题就会影响所有访问数据库的模块。这导致代码分散在各处
结果
混乱和分散的代码会在多个方面影响系统的设计和开发:
可读性差:同时实现几个关注点模糊了不同关注点的实现,使得关注点与其实现之间的对应关系不明显。
低产出:同时实现几个关注点把开发人员的注意力从主要的转移到外围关注点,导致产能降低。
低代码重用率:由于这种情况下,一个模块实现多个关注点,其他需要类似功能的系统不能马上使用该模块,进一步降低了产能。
代码质量差:混乱的代码掩盖了代码中隐藏的问题。而且,由于同时要处理多个关注点,应该特别注意的关注点得不到应有的关注
难以扩展:狭窄的视角和有限的资源总是产生仅注意当前关注点的设计。新的需求导致从新实现。由于实现不是模块化的,就是说实现牵涉到多个模块,为了新需求修改子系统可能会带来数据的不一致,而且还需相当规模测试来保证这些修改不会带来bug。
当前解决方法
由于多数系统中都包含横切关注点,自然的已经形成了一些技术来模块化横切关注点的实现,这些技术包括:混入类,设计模式和面向特定问题域的解决方式
使用混入类,你可以推迟关注点的最终实现。基本类包含一个混入类的实例,允许系统的其他部分设置这个实例,举个例子来说,实现业务逻辑的类包含一个混入的logger,系统的其他部分可以设置这个logger已得到合适的日志类型,比如logger可能被设置为使用文件系统或是消息中间件.在这种方式下,虽然日志的具体实现被推迟啦,基本类还是得包含在所有的写日志的点调用日志操作和控制日志信息的代码。
行为型设计模式,如Visitor和Template模式,也允许你推迟具体实现。但是也就像混入类一样,操作的控制——调用visitor或template的逻辑——仍然留给了基本类
面向特定问题域的解决方式,如框架和应用服务器,允许开发者用更模块化的方式处理某些横切关注点。比如EJB(Enterprise JavaBean,企业级javabean)架构,可以处理安全,系统管理,性能和容器管理的持久化(container-managed persistence)等横切关注点。Bean的开发者仅需关心业务逻辑,而部署者仅需关心部署问题,如bean与数据库的映射。但是大多数情况下,开发者还是要了解存储结构。这种方式下,你用基于XML的映射关系描述器来实现于数据持久化相关的横切关注点。
面向特定问题域的解决方式提供了解决特定问题的专门机制,它的缺点是对于每一种这样的解决方式开发人员都必须重新学习,另外,由于这种方式是特定问题域相关的,属于特定问题域之外的横切关注点需要特殊的对待
设计师的两难局面
好的系统设计师不仅会考虑当前需求,还会考虑到可能会有的需求以避免到处打补丁。这样就存在一个问题,预知将来是很困难的,如果你漏过了将来可能会有的横切关注点的需求,你将会需要修改或甚至是重新实现系统的许多部分;从另一个角度来说,太过于关注不一定需要的需求会导致过分设计(overdesigned)的,难以理解的,臃肿的系统。所以系统设计师处在这么一个两难局面中:怎么设计算是过分设计?应该宁可设计不足还是宁可过分设计?
举个例子来说,设计师是否应该在系统中包含现在并不需要的日志机制?如果是的话,哪里是应该写日志的点?日志应该记录那些信息?相似的例子还有关于性能的优化问题,我们很少能预先知道瓶颈的所在。常用的方法是建立系统,profile它,然后翻新系统以提高性能,这种方式可能会依照profiling修改系统的很多部分,此外,随着时间的流逝,由于使用方式的变化,可能还会产生新的瓶颈,类库设计师的任务更困难,因为他很难设想出所有对类库的使用方式。
总而言之,设计师很难顾及到系统可能需要处理的所有关注点。即使是在已经知道了需求的前提下,某些建立系统时需要的细节也可能不能全部得到。整体设计就面临着设计不足/过分设计的两难局面。
AOP基础
到目前为止的讨论说明模块化横切关注点是有好处的。研究人员已经尝试了多种方法来实现这个任务,这些方法有一个共同的主题:分离关注点。AOP是这些方法中的一种,它的目的是清晰的分离关注点来解决以上提到的问题。
AOP,从其本质上讲,使你可以用一种松散耦合的方式来实现独立的关注点,然后,组合这些实现来建立最终系统。用它所建立的系统是使用松散耦合的,模块化实现的横切关注点来搭建的。与之对照,用OOP建立的系统则是用松散耦合的模块化实现的一般关注点来实现的。在AOP终,这些模块化单元叫方面(aspect),而在OOP中,这些一般关注点的实现单元叫做类。
AOP包括三个清晰的开发步骤:
方面分解:分解需求提取出横切关注点和一般关注点。在这一步里,你把核心模块级关注点和系统级的横切关注点分离开来。就前面所提到的信用卡例子来说,你可以分解出三个关注点:核心的信用卡处理,日志和验证。
关注点实现:各自独立的实现这些关注点,还用上面信用卡的例子,你要实现信用卡处理单元,日志单元和验证单元。
方面的重新组合:在这一步里,方面集成器通过创建一个模块单元——方面来指定重组的规则。重组过程——也叫织入或结合——则使用这些信息来构建最终系统,还拿信用卡的那个例子,你可以指定(用某种AOP的实现所提供的语言)每个操作的开始和结束需要纪录,并且每个操作在涉及到业务逻辑之前必须通过验证。
图 3. AOP 开发的步骤
AOP与OOP的不同关键在于它处理横切关注点的方式,在AOP中,每个关注点的实现都不知道其它关注点是否会‘关注’它,如信用卡处理模块并不知道其它的关注点实现正在为它做日志和验证操作。它展示了一个从OOP转化来的强大的开发范型。
注意:一个AOP实现可以借助其它编程范型作为它的基础,从而原封不动的保留其基础范型的优点。例如,AOP可以选择OOP作为它的基础范型,从而把OOP善于处理一般关注点的好处直接带过来。用这样一种实现,独立的一般关注点可以使用OOP技术。这就像过程型语言是许多OOP语言的基础一样。
织入举例
织入器——一个处理器——组装一个个关注点(这个过程叫做织入)。就是说,它依照提供给它的规则把不同的执行逻辑段混编起来。
为了说明代码织入,让我们回到信用卡处理的例子,为了简单起见,我们只考虑两个操作:存入和取出,并且我们假设已经有了一个合适的logger.
来看一下下面的信用卡模块:
public class CreditCardProcessor {
public void debit(CreditCard card, Currency amount)
throws InvalidCardException, NotEnoughAmountException,
CardExpiredException {
// 取出逻辑
}
public void credit(CreditCard card, Currency amount)
throws InvalidCardException {
// 存入逻辑
}
}
下面是日志接口
public interface Logger {
public void log(String message);
}
所需组合需要如下织入规则,这里用自然语言来表达(本文的后面会提供这些织入规则的程序版本):
1. 纪录每个公共操作的开始
2. 纪录每个公共操作的结束
3. 纪录所有公共方法抛出的异常
织入器就会使用这些织入规则和关注点实现来产生与如下代码有相同效果的代码:
public class CreditCardProcessorWithLogging {
Logger _logger;
public void debit(CreditCard card, Money amount)
throws InvalidCardException, NotEnoughAmountException,
CardExpiredException {
_logger.log("Starting CreditCardProcessor.credit(CreditCard,
Money) "
+ "Card: " + card + " Amount: " + amount);
// 取出逻辑
_logger.log("Completing CreditCardProcessor.credit(CreditCard,
Money) "
+ "Card: " + card + " Amount: " + amount);
}
public void credit(CreditCard card, Money amount)
throws InvalidCardException {
System.out.println("Debiting");
_logger.log("Starting CreditCardProcessor.debit(CreditCard,
Money) "
+ "Card: " + card + " Amount: " + amount);
// 存入逻辑
_logger.log("Completing CreditCardProcessor.credit(CreditCard,
Money) "
+ "Card: " + card + " Amount: " + amount);
}
}
AOP语言剖析
就像其他编程范型的实现一样,AOP的实现有两部分组成:语言规范和实现。语言规范描述了语言的基础单元和语法。语言实现则按照语言规范来验证代码的正确性并把代码转成目标机器的可执行形式。这一节,我来解释一下AOP组成部分。
AOP语言规范
从抽象的角度看来,一种AOP语言要说明下面两个方面:
关注点的实现:把每个需求映射为代码,然后,编译器把它翻译成可执行代码,由于关注点的实现以指定过程的形式出现,你可以使用传统语言如C,C++,Java等。
织入规则规范:怎样把独立实现的关注点组合起来形成最终系统呢?为了这个目的,需要建立一种语言来指定组合不同的实现单元以形成最终系统的规则,这种指定织入规则的语言可以是实现语言的扩展,也可以是一种完全不同的语言。
AOP语言的实现
AOP的编译器执行两步操作:
1. 组装关注点。
2. 把组装结果转成可执行代码
AOP实现可以用多种方式实现织入,包括源码到源码的转换。它预处理每个方面的源码产生织入过的源码,然后把织入过的源码交给基础语言的编译器产生最终可执行代码。比如,使用这种方式,一个基于Java的AOP实现可以先把不同的方面转化成Java源代码,然后让Java编译器把它转化成字节码。也可以直接在字节码级别执行织入;毕竟,字节码本身也是一种源码。此外,下面的执行系统——Java虚拟机——也可以是方面认知的,基于Java的AOP实现如果使用这种方式的话,虚拟机可以先装入织入规则,然后对后来装入的类都应用这种规则,也就是说,它可以执行just-in-time的方面织入。
AOP的好处
AOP可帮助我们解决上面提到的代码混乱和代码分散所带来的问题,它还有一些别的好处:
模块化横切关注点:AOP用最小的耦合处理每个关注点,使得即使是横切关注点也是模块化的。这样的实现产生的系统,其代码的冗余小。模块化的实现还使得系统容易理解和维护
系统容易扩展:由于方面模块根本不知道横切关注点,所以很容易通过建立新的方面加入新的功能,另外,当你往系统中加入新的模块时,已有的方面自动的横切进来,使系统的易于扩展
设计决定的迟绑定:还记得设计师的两难局面吗?使用AOP,设计师可以推迟为将来的需求作决定,因为它可以把这种需求作为独立的方面很容易的实现。
更好的代码重用性:由于AOP把每个方面实现为独立的模块,模块之间是松散耦合的,举例来说,你可以用另外一个独立的日志写入器方面(替换当前的)把日志写入数据库,以满足不同的日志写入要求。
总的来说,松散耦合的实现意味着更好的代码重用性, AOP在使系统实现松散耦合这一点上比OOP做得更好。
AspectJ:一个Java的AOP实现
AspectJ是一个可免费获得的由施乐公司帕洛阿尔托研究中心(Xerox PARC)开发Java的AOP实现,它是一个多功能的面向方面的Java扩展。它使用Java作为单个关注点的实现语言,并扩展Java以指定织入规则。这些规则是用切入点(pointcuts)、联结点(join points),通知(advice)和方面(aspect)来说明的。联结点是定义在程序执行过程之间的点,切入点由用来指定联结点的语言构造,通知定义了要在切入点上执行的代码片,而方面则是这些基础元素的组合。
另外,AspectJ允许以多种方式用方面和类建立新的方面,你可以引入新的数据成员和方法,或是声明一个新的类来继承和实现另外的类或接口。
AspectJ的织入器——AspectJ的编译器——负责把不同的方面组合在一起,由于由AspectJ编译器建立的最终系统是纯Java字节码,它可以运行在任何符合Java标准的虚拟机上。而且,AspectJ还提供了一些工具如调试器和Java IDE集成等,我将会在本系列的第二、三部分详细讲解这些。
下面是我在上面用自然语言描述的日志方面的织入规则的AspectJ实现,由于我将会在第二部分详细介绍AspectJ,所以如果你不能透彻的看懂它的话也不必担心。关键是你应该注意到信用卡处理过程本身一点都不知道日志的事。
public aspect LogCreditCardProcessorOperations {
Logger logger = new StdoutLogger();
pointcut publicOperation():
execution(public * CreditCardProcessor.*(..));
pointcut publicOperationCardAmountArgs(CreditCard card,
Money amount):
publicOperation() && args(card, amount);
before(CreditCard card, Money amount):
publicOperationCardAmountArgs(card, amount) {
logOperation("Starting",
thisjoin point.getSignature().toString(), card, amount);
}
after(CreditCard card, Money amount) returning:
publicOperationCardAmountArgs(card, amount) {
logOperation("Completing",
thisjoin point.getSignature().toString(), card, amount);
}
after (CreditCard card, Money amount) throwing (Exception e):
publicOperationCardAmountArgs(card, amount) {
logOperation("Exception " + e,
thisjoin point.getSignature().toString(), card, amount);
}
private void logOperation(String status, String operation,
CreditCard card, Money amount) {
logger.log(status + " " + operation +
" Card: " + card + " Amount: " + amount);
}
}
我需要AOP吗?
AOP仅仅是解决设计上的缺点吗?在AOP里,每个关注点的实现的并不知道是否有其它关注点关注它,这是AOP和OOP的主要区别,在AOP里,组合的流向是从横切关注点到主关注点,而OOP则相反,但是,OOP可以和AOP很好的共存。比如,你可以使用一个混入类来做组合,既可以用AOP实现,也可以用OOP实现,这取决你对AOP的接受程度。在这两种情况下,实现横切关注点的混入类实现都无需知道它自己是被用在类中还是被用在方面中。举个例子来说,你可以把一个日志写入器接口用作某些类的混入类或是用作一个日志方面。因而,从OOP到AOP是渐进的。
了解AOP
在这篇文章里,你看到了横切关系带来的问题,这些问题的当前解决方法,以及这些方法的缺点。你也看到了AOP是怎样克服这些缺点的。AOP的编程方式试图模块化横切关注点的实现,提供了一个更好更快的软件开发方式。
如果你的系统中涉及到多个横切关注点,你可以考虑进一步了解AOP,它的实现,它的好处。AOP很可能会是编程方式的下一个里程碑。请继续关注本系列的第二、第三部分。