一、Qomolangma中的AOP
AOP(面向切面编程)有没有必要在JavaScript中实现,一直以来是个问题。滥用AOP的特性,将导致系统效率下降、性能不稳定等后果。因此在展开下面的讨论之前,我需要先提醒Qomoer:尽管我们拥有了强大的AOP框架,但如果你不足够了解它,那么还是慎用之。
前面在讲述Interface的时候提到,Qomo是鉴于AOP的需要,而为之提供了强大的Interface机制。但这并不是说用户需要定义很多接口,才能使用AOP。——Interface是在Qomo实现AOP中的“定制切面”时使用到的关键技术,而不是用户使用AOP时所必须的技术。
Qomo的AOP框架依赖于Qomo中提供的如下特性:
- 接口机制:Interface.js
- JSEnhance中的事件多投:MuEvent()
- Qomo的OOP框架:Object.js
TODO: beta1 中,Qomo并未完成实现在Qomo框架内部的各个IJoPoints。但这完全不影响用户使用AOP机制本身。因为AOP机制在beta1中已经是完整的了。
二、AOP基础
如果你需要一本专业的书籍来指导你学习AOP,那么我比较推荐《面向方面软件开发(AOSD)》这本书。
Aspect被译作“方面”、“切面”和“剖面”都是有的,请不要追究这个用词。
AOSD中介绍到AOP中的几个关键术语:
- 联接点(join point):程序的结构或者执行流中定义好的“位置”。Qomo中简写为JoPoint。
- 通知(advice):在联接点上会发生的一种行为,这种行为能力是AOP框架来提供的。
- 编织(weaving): 将核心功能与方面组合在一起,以“产生一个(基于AOP的)工作系统的过程”。
- 周围、之前与之后(around, before and after):联接点上(常见的)三种"通知(advice)"能力。
Qomo中用到的几个名词/术语:
- 观察者与被观察者(observer/observable):一个切面中,观察者是切面(aspect),被观察者是切面(当前)拦截到的对象。
- 切点(pointcut):与“联接点(join point)”对应,切点是对这个“联接点位置”的一个描述。AspectJ中使用“切点原语(一种表达式)”来描述pointcut,而Qomo使用一个表示名字的字符串。
- 元数据(metadata):在处理切面或执行切面代码时所需要的一些数据。这可以是用户在建立切面时初始的任何数据,甚至是用于获取数据的函数回调。
- 引导(Introduction):Qomo中的一种切面事件,发生在before通知之前,可以决定切面的行为是否需要发生——是否需要拦截并发出通知。
另一个关键的名词是"切面(Ascpect)",它首先是基于OOP体系的一个概念,切面描述的是对“一组”对象实例的共同行为能力的“一个关注”。也就是说:如果你希望了解一些对象(无论它们是否是同一父类/基类)的一些相类同的行为,那么你可以将这些行为发生的“位置”理解成一个“切面”。而AOP就是一套针对这个“切面”进行编程的框架。
一个经常被提到的“切面”是“(记录一些对象行为的)日志系统”。而在Qomo中,AOP被用来作为实现JavaScript Profiler的基础技术。
最后一个比较学术的名词是“不知觉性(obliviousness)”,这是AOP的特性之一。它要求加入一段AOP的代码对原有系统不会产生可察觉的影响。——需要强调的是:around通知可能改变原有系统的行为,这可能使得“不知觉性”被破坏或者产生歧义。
三、一些其它JS框架中的AOP
在高级语言中被经常提及的AOP系统包括AspectJ和Spring。与之相比较,目前可见的一些其它JS框架中提供的AOP能力就非常弱了。
影响最广的一个JS AOP实现框架(概念化的模型)是"AOP Fun with JavaScript",你可以在这里读到这篇文档的全文:
此后就有更多声称支持AOP的JS框架出现,例如Dojo。在Dojo中实现了对函数/方法的五种通知类型:
- before
- before-around
- around
- after
- after-around
Dojo中采用的语法是这样的:
observable = {
method : function () { ... }
}
aspect = {
func : function() { ... }
}
dojo.event.connect('before', // 通知类型
observable, 'method', // 被观察者及被观察的方法
aspect, 'func'); // 切面
另外一篇描述AOP实现的文档是:
它提供Introduction事件,Before、After和Around三种通知。但这个实现方案中切面的声明,以及与被观察者之间的关系都处理得较为复杂。而且,事实上它破坏了AOP系统所要求的“不知觉性”。
四、Qomo中AOP语法
Qomo中,Aspect是一个标准的Qomo对象。也就是说,Qomo中存在TAspect类及其子类。这包括:
TAspect
- TFunctionAspect
- TClassAspect
- TObjectAspect
- TCustomAspect
其中TAspect是一个抽象基类,因此你不应当创建它的实例。
1. 创建切面
可以用标准的Qomo OOP语法,或者标准JavaScript语法来创建切面,例如:
var a_Aspect = new ObjectAspect();
Qomo的切面对象具有如下接口
IAspect = function() {
this.supported = Abstract;
this.assign = Abstract;
this.unassign = Abstract;
this.merge = Abstract;
this.unmerge = Abstract;
this.combine = Abstract;
this.uncombine = Abstract;
this.OnIntroduction = Abstract;
this.OnBefore = Abstract;
this.OnAfter = Abstract;
this.OnAround = Abstract;
}
2. 切点
在使用一个已经创建的切面对象之前,你应该先了解该切面能否支持(supported)某些切点(pointcut)。Qomo对此的约定如下:
- support pointcut:
for TFunctionAspect : 'Function'
for TClassAspect : 'Method'
for TObjectAspect : 'Method', 'Event', 'AttrGetter', 'AttrSettter'
for TCustomAspect : <可以通过用户代码为被观察者定制切点>
下面的代码用于检测一个切面是否能切入某种切点:
var a_Aspect = new ObjectAspect();
alert(a_Aspect.supported('AttrGetter');
alert(a_Aspect.supported('Function');
3. 关联(assign)被观察对象
切面要被关联到一个或一些具体的被观察者(observable)才会有意义。这通过assign()方法来实现:
// assign()的语法:
// function assign(host, name, pointcut) { ... }
var a_Aspect = new ObjectAspect();
a_Aspect.assign(aObject, '<method_name>', 'Method');
对于不同的被观察对象,host、name和pointcut的含义不尽相同。详情如下:
observable <host> <name> <pointcut>
对象 object instance 方法/事件/特性名 'Method', 'Event', ...
函数 a function 函数名 'Function'
类 Qomo's class 该类实例(原型)的方法名 'Method'(only)
支持定制切面的函数 a function 用户设定的一个任意标签 <host>函数内实现的JoPoint
切面可以在创建时即关联到目标。例如:
// assign()的语法:
// function assign(host, name, pointcut) { ... }
var a_Aspect = new ObjectAspect(aObject, '<method_name>', 'Method');
它的参数表与assign()是一致的。
4. 多投事件MuEvent()的“中断投送”特性
在介绍AOP的进一步特性之前,先公开Qomo中多投事件的一个未公开特性,即“中断投送”。该特性在以前的发布代码中已经提供,而并非为AOP单独实现的。假定如下代码:
var obj = new Object();
obj.OnRun = new MuEvent();
obj.run = function() { return obj.OnRun() }
obj.OnRun.add(func_01);
obj.OnRun.add(func_02);
obj.OnRun.add(func_03);
缺省行为下,obj.run()调用将导致func_01
等三个函数先后被调用,这个过程不会被打断。而且由于run()行为需要一个返回值,因此OnRun()调用期间,三个函数中最后一个"非undefined"的返回值将会被传出。——例如func_02
返回了a_string
,而func_03
返回的是undefined,则run()将返回a_string
。
上述的是MuEvent()内部的缺省机制。但是,如果我们在func_02
中不希望继续投送事件,也就是说func_03
得不到执行呢?下面的代码解释这一点:
function func_02 {
// do somethings..
if ( if_you_want ) {
return new BreakEventCast('a_string');
}
}
也就是说,事件响应代码只需要返回一个BreakEventCast()的实例,即可中断MuEvent()的继续投送。func_02
同样也可以返回有效值,例如a_string
;或者不传入参数,则此前的事件响应代码中“最后一个‘非undefined’”的值将被返回。——上例中即是func_01
的返回值。
5. 切面上的行为:通知的事件及其响应
创建切面的目的,是观察“对象(或目标)”在切面上的行为。AOP中通常用“通知”机制来使得用户代码可以“响应”这些行为。在Qomo中,使用多投事件(MuEvent对象)来完成这件事。这意味着用户可以为一个切面定制任意多个响应:
function MyObject() {
this.run = function() { };
}
var obj = new MyObject();
// 1. 创建切面并关联, 添加
var asp = new ObjectAspect(obj, 'run', 'Method');
// 2. 定制切面上的行为
asp.OnIntroduction.add(func_01);
asp.OnIntroduction.add(func_02);
asp.OnAfter.add(func_03);
// 3. (测试)调用对象方法
obj.run();
这个切面上OnIntroduction的事件有func_01
和func_02
两个响应函数。而OnAfter事件有func_03
。
切面上这样的行为一共有四个(Introduction, Before, After, Around),通知事件分别为:
- OnIntroduction : 导引。切面其它行为发生之前检测行为是否需要发生;
- OnBefore = 切面行为前。
- OnAfter = 切面行为后。
- OnAround = 切面行为周围。切面关注对象(observable)在调用之前,检测是否需要调用。
下面的形式化的逻辑代码,用于说明这些通知之间的关系:
var intro = OnIntroduction();
if (intro) OnBefore();
var cancel = intro ? OnAround() : false;
if (!cancel) value = call_observable_method_or_more();
if (intro) OnAfter();
上面这些切面上的事件响应函数可以得到的入口参数约定是:
TOnAspectBehavior = function(observable, aspectname, pointcut, args) {};
TOnIntroduction = function(observable, aspectname, pointcut, args) {};
我通常会把这四个参数缩写为o, n, p, a。例如:
asp.OnIntroduction.add(function(o, n, p, a) {
alert(n);
});
而按照MuEvent()的约定,在事件响应代码中使用的this对象,将会是切面本身,也就是这里的asp(即Aspect)。——切面是观察者(observer),assign到的对象是被观察者(observable)。
举例来说,如果我们要构造一个切面,使其:
- 关注于类MyObject()的所有实例中value>5的对象,并
- 使value>10的对象的方法run()不被执行
那么可以通过如下的AOP代码来实现:
var value = 0;
function MyObject() {
this.run = function() {
alert(this.value)
}
this.Create = function() {
this.value = value++;
}
}
TMyObject = Class(TObject, 'MyObject');
// 切面上的行为
var asp = new ClassAspect(TMyObject, 'run', 'Method');
asp.OnIntroduction.add(function(o, n, p, a) {
if (o.value <= 5) return false;
});
asp.OnAround.add(function(o, n, p, a) {
if (o.value > 10) return false;
});
// 测试
for (var i=0; i<20; i++) {
var obj = new MyObject();
obj.run();
}
6. 定制连结点
如果试图观察目标的内部行为,而不是外部的方法/事件,则传统的JS AOP机制将无能为力。——例如我们想观察对象在“构造过程中”发生的行为,而不是对构造结束后的所产生的实例进行观察。
例如,如果我们有一个MyFunc()的实现:
MyFunc = function() {
// 1. 一些MyFunc()中的逻辑代码
var func_01 = function() {
alert('hi, func_01');
}
var func_02 = function() {
alert('hi, func_01');
}
// 2. 实现MyFunc()
function _MyFunc() {
func_01();
func_02();
}
// 3. 返回MyFunc()
return _MyFunc;
}();
我们需要对这个例子中的func_01()
和func_02()
进行观察。但很显然,在MyFunc()的外部是无论如何也看不到这两个方法的。
相较于其它AOP系统,Qomo在这方面提供了更强大的特性。Qomo允许开发人员在“当前函数中”为外部系统定制连结点。这看起来与AOP系统的“不知觉性”有些背离,但也可能是实现这种机制的唯一方法。——除非JavaScript解释器内部提供等同的功能,或者单独编写外部的paser。
Qomo中这个“定制连结点”的机制要求“observable有能力告知外部系统自己可提供的连接点(Join Point)”,但是当这些连接点被AOP系统接入(或切入)时,observable却是“不知觉”的。这种能为被Qomo分解成两个部分:
- Qomo提供一组工具,来使observable可以产生连结点,并在连结点上产生通知
- observable应当将这些连结点通过一个IJoPoints接口向外抛出
因此Qomo要求MyFunc()在实现中添加一些代码来暴露它的连结点。这用到了三种技术:
- 连接点(Join Points):产生可供外部使用的连结点。对外部代码它表现为pointcut。
- 编织(weaving):使连结点与目标的内部的“位置(或位置上的方法)”发生关系。
- 聚合(Aggregate):Qomo使用“(内部的)聚合”来暴露一个实体内部的接口。
下面的代码演示如何在上面的MyFunc()中定制连结点:
MyFunc = function() {
// CustomAOP_1: 创建连接点
var _joinpoints_ = new JoPoints();
_joinpoints_.add('step1'); // 'step1'切点(pointcut)
_joinpoints_.add('step2'); // 'step2'切点(pointcut)
// CustomAOP_2: 编织(或织入)
// 1. 对MyFunc()中的逻辑代码
var func_01 = _joinpoints_.weaving('step1', function() {
alert('hi, func_01');
});
var func_02 = _joinpoints_.weaving('step2', function() {
alert('hi, func_02');
});
// 2. 实现MyFunc()
function _MyFunc() {
func_01();
func_02();
}
// CustomAOP_3: 聚合IJoPoints接口
var _Intfs = Aggregate(_MyFunc, IJoPoints);
var intf = _Intfs.GetInterface(IJoPoints);
intf.getLength = function() { return _joinpoints_.length }
intf.items = function(i) { return _joinpoints_.items(i) }
intf.names = function(i) { if (!isNaN(i)) return _joinpoints_[i] }
// 3. 返回MyFunc()
return _MyFunc;
}();
我们看到,在这个例子中,对MyFunc()的程序原有结构并没有太大的变化。最关键的地方,是_MyFunc()
、func_01()
和func_02()
内部实现代码并没有变化。
接下来,我们来创建切面,并书写有关切面上的行为的代码。亦即是测试MyFunc():
var asp = new CustomAspect(MyFunc, 'test_aspect', 'step1');
asp.OnAfter.add(function() {
alert('do OnAfter');
});
// 测试
MyFunc();
测试的结果,我们发现显示如下信息:
hi, func_01
do OnAfter
hi, func_02
这表现切面asp已经成功地切入func_01
,并在它执行完之后、funct_02()
执行之前调用到了asp.OnAfter();
8. 切面的合并(merge)和联合(combine)
Qomo中的切面有四种被关注者对象:类、对象、函数和定制连接点的函数。但是AOP的本意是不区分这些被关注者的类型的。
那么,如果使得一个切面能够处理更复杂的observable呢?Qomo提出了切面的合并和联合这两个概念。
合并,是指切面A将切面B的行为加到自身,使A拥有B的关注能力。但不改变B的能力。联合,是指切面A和其它切面(B,C,D, ...)的行为联合在一起,作为A~D(或者更多)共有的关注能力。
下图说明这两种技术的不同:
如果我们要记录一批目标的执行(例如做log系统),那么下面的Aspect()代码可能是一个不错的示例:
function MyObjectEx() { }
function MyObject () {
this.getValue = function () {
return 100;
}
this.run = function() {
alert(this.get('Value'));
}
}
TMyObject = Class(TObject, 'MyObject');
var obj = new MyObject();
var A1 = new ObjectAspect(obj, 'Value', 'AttrGetter');
var A2 = new ClassAspect(TMyObject, 'run', 'Method');
var A3 = new CustomAspect(Class, 'a_custom_aspect', 'Initializtion');
var A4 = new FunctionAspect($import, '$import', 'Function');
A1.OnBefore.add(function(o, n, p, a) {
document.writeln('Before: ', n, '<br>');
});
A2.OnAfter.add(function(o, n, p, a) {
document.writeln('After: ', n, '<br>');
});
// 测试
A1.combine(A2, A3, A4);
TMyObjectEx = Class(TMyObject, 'MyObjectEx');
obj.run();
$import('2.js');
五、Qomo中AOP的实现技术
AOP尽管复杂、强大,但是核心技术却非常简单。前面讲到过AOP的通知和响应逻辑:
var intro = OnIntroduction();
if (intro) OnBefore();
var cancel = intro ? OnAround() : false;
if (!cancel) value = call_observable_method_or_more();
if (intro) OnAfter();
这样的核心逻辑被实现在JSenhancd.js的JoPoints()和Aspect.js里的$Aspect()函数中:
function $Aspect(pointcut, foo) {
var _aspect = this;
var point = pointcut;
var name = _aspect.get('AspectName');
var f = foo;
// AOP的核心逻辑
return function($A) {
if ($A===GetHandle) return f;
// (略)
return _value;
}
}
$Aspect()中暂存了_aspect
, point, name等引用,供核心逻辑部分安全地调用。另外也暂存了foo()的引用,亦即是Aspect()对象所关注的方法。这可以用于核心逻辑部分调用,也用于在unassign()的时候还原被关注者。
GetHandle在上面代码中有特殊的作用,它是在Aspect()对象中声明的局部变量,当调用切面的unassign()方法时,事实上会调用:
instance[n](GetHandle);
这样的代码instance[n]即是被AOP替换的方法,这样的调用就会回到核心逻辑,从而执行到下面的代码:
// AOP的核心逻辑
return function($A) {
if ($A===GetHandle) return f;
// ...
}
这样就返回了最初暂存的foo()的引用。由于unassign()只需要执行:
instance[n] = instance[n](GetHandle);
即可完成操作。
由于GetHandle被稳藏在Aspect()内部,因此在外部不可能通过该对象来套取任何信息,或者试图跳过unassign()来破坏切面的逻辑。
类似的技巧还被用于解决在“实现篇(四)”中讲述过的“多投事件”的“强壮就不快,快就不强壮”的矛盾。在beta1中采用了上述的技巧来实现search(),达到O(1)的性能:
MuEvent = function () {
var GetHandle = {};
var all = {
length : 0,
search : function(ME) {
var i = ME(GetHandle), me = all[i]; // 1. 取handle, 并取值
if (me && me['event']===ME) return me; // 2. 复核
}
}
// ...
var ME = function($E) {
if ($E===GetHandle) return handle;
// ...
}();
六、其它
1. SmartAspect
我曾试图实现出一个“智能的切面”,它可以理解入口参数的host是对象、类、函数或者是用户定制的。但是我在后来由于无法妥善处理AttrGetter与AttrSetter,因此放弃了这一想法。这直接使得最终确定下来的assign()入口参数有了如今的设计。
另外一个方面的原因,是因为assign()的三个入口参数(以及其后的meta data参数),都是AOP中确定的概念。因此将它们替换或者去除掉,未见得是合理的设计。
2. Qomo中提供的连接点
Qomo中内置为以下函数提供了连接点(下表可能在今后被动态维护):
函数 连接点/切点 含义 其它
Class() 'Initializtion' “类初始化过程”开始
'Initialized' “类初始化过程”结束
'RegisterToSpc' 将类注册到活动命名空间 beta1未提供
cls.Create() 'Initializtion' 类(cls)开始构造一个新对象实例 (同上)
'Initialized' 类(cls)完成构造一个新对象实例 (同上)
obj.Create() 'Initializtion' “对象(obj)初始化过程”开始 (同上)
'Initialized' “对象(obj)初始化过程”结束 (同上)
$import() 'Decode' 对responseBody解码 (同上)
'HttpGet' 载入获取url上的内容并解码 (同上)
'TransitionUrl' 转换Url地址 (同上)
MuEvent() 'NewInstance' 创建新的多投事件对象 (同上)
'Close' 关闭多投特性 (同上)
3. 其它之其它
根本上来说,Aspect的基类理解两种目标的切面行为:方法(含事件)与特性。对于Custom类型的切面,只能是方法。
不能在切面例程中,调用受影响的被切方法/特性。 例如在一个'Name'的'attrGetter'切面中,调用observable.get('Name')。或者在'run'的'Method'切面中,调用observable.run()。——很显然,这将导致一个锁死的递归。
AOP系统的其它两个示例参见:
- /Framework/DOCUMENTs/AdvObjectDemo4.html : Qomo中AOP的基本示例
- /Framework/DOCUMENTs/AdvObjectDemo5.html : Qomo中AOP的合并与联合的示例
Qomo的AOP系统可用于Qomo的OOP系统之外的其它对象与函数。尽管Qomo的AOP依赖OOP和Interface,但对第三方系统来说,仍然不难从中分离出一个非Qomo的OOP实现的继承关系的AOP。——当然,我想要实现CustomAspect,仍然是需要完整的Interface特性的。