Qomolangma实现篇(八):Qomo中的AOP框架

一、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_01func_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特性的。