简谈前端开发中的AOP(一) -- 前端AOP的实现思路

895 阅读8分钟

前言

本意不想用太长的篇幅,来阐述这个话题。但是有些概念和设计初衷有必要讲清楚,以便于搞清楚其深层次的内在逻辑。这是我一直遵从的“知其然,知其所以然”的原则。首先,本文将简单的阐述一下概念;进而,举例一起探讨一下前端AOP实现方式,以及随着前端语言ES5、ES6、Typescript的发展,实现方式的演变;最后,一起了解一下实际项目中AOP的应用场景、相关框架以及开发模式。

什么是AOP?

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程。主要意图是将日志记录,性能统计,安全控制,事务处理,异常处理等代码从业务逻辑代码中划分出来,将它们独立到非指导业务逻辑的方法中,进而改变这些行为的时候不影响业务逻辑的代码。

简而言之,就是“优雅”的把“辅助功能逻辑”从“业务逻辑”中分离解耦。

简单说一下AOP中的一些概念

确切说是其实是AspectJ中的相关概念。AspectJ是一个扩展了Java语言的面向切面的框架。其优秀的设计思想值得我们参考学习。

AspectJ的主要相关概念有下面这些:

  • 连接点(Joinpoint):表示在程序中明确定义的点,典型的包括方法调用,对类成员的访问以及异常处理程序块的执行等等

  • 切入点(Pointcut):表示一组Joinpoint,这些Joinpoint或是通过逻辑关系组合起来,或是通过通配、正则表达式等方式集中起来,它定义了将要放置增强Advice的地方。比如"所有类的submit和reset方法调用执行”

  • 切面(Aspect):由切点和增强组成,既包含了增强逻辑的具体实现和连接点的定义

  • 通知/增强(Advice):具体的增强逻辑实现。通常依据放置的地方不同,可分为前置通知(Before)、后置返回通知(AfterReturning)、后置异常通知(AfterThrowing)、后置通知(After)与围绕通知(Around)5种

  • 织入(Weaving):将切面应用到目标对象从而创建一个新的代理对象的过程

切片

为了便于理解,我们可以把一个业务流程比作一个面包,那么面包切片就是一个业务模块或者功能。类似于“某个切面的前面”,“某个切片的后面”这种描述叫做连接点(Joinpoint);把花生酱、面包、生菜打包成一个独立模块进行逻辑处理,就是一个切面(Aspect);切面可以定义通知(Advice)、切入点(Pointcut)也即放入通知的连接点;把切面跟面包结合,从而制成三明治这个过程叫做织入(Weaving)。

切点

早期前端AOP的简单实现

多数的前端的编程思想、设计模式、框架以及工具,都是参考服务端已经应用成熟的设计思路实现的,AOP也不例外。我们前面已经讲过一些AspectJ的相关概念。很明显,下面我们打算参照其用法,逐步实现前端AOP编程。

通过前面对AspectJ的概念的分析,可以得出一个简略的总结:AOP编程就是在不破坏原有代码的基础上,在原执行动作之前或者之后,加入一段自定义的动作。这里的“原有动作”是函数,“增强的动作”也是函数,把他们打包成一个动作也是函数。显然,基于javascript语言的特性,函数式编程是实现前端AOP编程的最佳方式。

看下面的简单实现:

/**
 * @param {Function} oldFn 原函数
 * @param {Function} before 前置函数 
 * @param {Function} after 后置函数 
 * @return {Function} 新函数
 */
var adviceAction = function(oldFn, before, after){
    return function(){
        var args = [].slice.call(arguments)
        try{
            // 增强
            before.apply(null, args)
        }catch(e){}

        // 原函数
        var rst = oldFn.apply(this, args)

       try{
            // 增强
            after.apply(null, args)
        }catch(e){}

        return rst
    }
}

调用执行:

function run(){
    console.log('run')
}

function before(jp){
    console.log('before action')
}

function after(jp){
    console.log('after action')
}

var newFn = adviceAction(run, before, after)

newFn()

执行结果

结果

这里面通过传参匹配到的"oldFn的调用执行"对应切入点(PointCut);before和after对应通知/增强(Advice);创造newFn的过程可以对应织入(Weaving)动作。另外,还可以参考AspectJ的用法,简单模拟一下每个增强对应的连接点(JoinPoint)信息。

var adviceAction = function(oldFn, before, after){
    return function(){
        var args = [].slice.call(arguments)

        var joinPoint = {
            target:null,
            key: oldFn.name,
            method: oldFn.bind(this),
            context: this,
            args: args
        }

        try{
            // 增强
            before.apply(null, [joinPoint])
        }catch(e){}

        // 原函数
        var rst = oldFn.apply(this, args)

        try{
            // 增强
            after.apply(null, [joinPoint])
        }catch(e){}

        return rst
    }
}

测试调用:

// ......省略

function before(jp){
    console.log('before action:' +jp.key)
}

function after(jp){
    console.log('after action:' + jp.key)
}

// .......省略

执行结果

结果

至此,已经可以初见前端AOP编程的雏形。前面例子已经简单实现过前置通知(Before)和后置终于通知(After)。还有后置返回通知(AfterReturning)、后置异常通知(AfterThrowing)、与围绕通知(Around)的具体实现没有讨论。接下来,我们将简单的模拟实现一个简单完整的AspectJS。

  • Before标识的方法为前置方法,在目标方法的执行之前执行,即在连接点之前进行执行。

  • Around环绕通知方法可以包含上面四种通知方法,环绕通知的功能最全面。且环绕通知必须有返回值, 返回值即为目标方法的返回值。对应JoinPoint需要附带可执行原执行动作的方法(invoke)

比如我们需要计算原函数的执行时间

function around(joinPoint){
    var startTime = Date.now()
    var rst = joinPoint.invoke()
    var howLong = Dte.now - startTime

    return rst
}
  • AfterThrowing异常通知方法只在连接点方法出现异常后才会执行,否则不执行。因此对应JoinPoint需要附带异常参数(exception)
try{
    var rst = oldFn.apply(this, args)
    // returning
    return rst
}catch(err){
    joinPoint.exception = err
    throwing.apply(null, [joinPoint])
}finally{
   //after
}
  • AfterReturning当连接点方法成功执行后,返回通知方法才会执行,如果连接点方法出现异常,则返回通知方法不执行。返回通知方法在目标方法执行成功后才会执行,返回通知方法可以拿到目标方法(连接点方法)执行后的结果。因此对应的JoinPoint需要附带执行结果(result)
try{
    var rst = oldFn.apply(this, args)
    joinPoint.result = rst
    returning.apply(null,[joinPoint])
    return rst
}catch(err){
    // throwing
}finally{
   //after
}
  • After后置通知方法在连接点方法完成之后执行,无论连接点方法执行成功还是出现异常,都将执行后置方法
// brefore
try{
    // around or oldFn
    // returning
}catch(err){
    // throwing
}finally{
   after.apply(null, [joinPoint])
}

下面完整的实现一下这个简略版的AOP工具

/**
 * @param {Function} target 对象或者类
 * @param {String} methodKey 方法名称
 * @param {Object} advices 增强 {before?:Function, afeter?: Function, throwing?: Fuction, around?: Function, returning?: Function}
 */
var adviceAction = function (target, methodKey, advices) {
    var context = target
    target = typeof target === 'function' ? target.prototype : target
    var oldFn = target[methodKey]

    Object.defineProperty(target, methodKey, {
        value: _decorator,
        writeable:true
    })

    function _decorator() {
        var args = [].slice.call(arguments)
        var that = this,
            rst

        var joinPoint = {
            target: target,
            key: methodKey,
            method: oldFn.bind(this),
            context: this,
            args: args
        }

        if (advices) {
            var before = advices.before,
                after = advices.after,
                throwing = advices.throwing,
                around = advices.around,
                returning = advices.returning
        }

        if (typeof before === 'function') {
            try {
                // 前置增强
                before.apply(null, [joinPoint])
            } catch (e) { }
        }

        try {
            if (typeof around === 'function') {
                var invoke = function () {
                    var _args = [].slice.call(arguments)
                    return oldFn.apply(that, args.concat(_args))
                }

                rst = around.apply(null, [Object.assign({}, joinPoint, {
                    invoke: invoke
                })])
            } else {
                rst = oldFn.apply(that, args)
            }

            if (typeof returning === 'function') {
                joinPoint.result = rst
                // 后置增强
                returning.apply(null, [joinPoint])
            }

            return rst
        } catch (err) {
            if (typeof throwing === 'function') {
                joinPoint.exception = err
                throwing.apply(null, [joinPoint])
            }
        } finally {
            if (typeof after === 'function') {
                after.apply(null, [joinPoint])
            }
        }
    }
}

下面通过一个简单的实例简单说明。同样的,我们打算在Person类的run方法的执行前后织入增强。按照前面的实现思路:

function Person(name, age){
    this.name = name
    this.age = age
}

Person.prototype.run = function(){
    console.log(this.name + ': runing!')
}

// 织入增强的方法
adviceAction(Person, 'run', {before:before, after:after})

var p = new Persion('老刀', 28)
p.run()

结果

这是针对单独类方法AOP的实现。显然这里面存在以下几个问题:

1、现实中不总是针对一个应用类的一个方法做AOP处理 2、应该如何如何定义切面Aspect,也就是上面定advices 3、一个应用类不总是对应一个切面Aspect 4、一个切面Aspect也不总是对应一个应用类

综上所述,更合理的应用类织入切面Aspect应该是这样的:

var waveing = function(TargetClass, [Aspect1, Aspect2,...]){
    // ....
}

这里引发出另外一个问题,切面类Aspect应该是什么样子?应该如何定义?

前面讲过,切面包括切点(joinPoint)和通知(advice),方法名称对应切点joinPoint,相应的增强函数对应通知advice。那么一个简单的Aspect应该是这样子:

var Aspect = {
    joinPoint1:{
        before?:function{...},
        after?:function{...},
        around?:function{...},
        throwing?:function{...},
        returning?:function{...}
    },
    joinPoint2:{
        before?:function{...},
        after?:function{...},
        around?:function{...},
        throwing?:function{...},
        returning?:function{...}
    }
    ...
}

相应的我们创建一个织入waveing函数

var waveing = function(TargetClass, Aspects){
    Aspects.forEach(function(aspect){
        for(var key in aspect){
            if (aspect.hasOwnProperty(key)){
                var advices = aspect[key]
                adviceAction(TargetClass, key, advices)
            } 
        }
    })
    // ....
}

看一下完整的实例:

function Person(name, age) {
    this.name = name
    this.age = age
}

Person.prototype.run = function () {
    console.log(this.name + ': runing!')
    return 'running'
}

Person.prototype.work = function () {
    console.log(this.name + ': working!')
    return 'working'
}

function before(jp) {
    console.log('before action:' + jp.key)
}

function after(jp) {
    console.log('after action:' + jp.key)
}

function throwing(jp) {
    console.log('throw action:' + jp.exception)
}

function around(jp) {
    console.log('around action:' + jp.key)
    return jp.invoke()
}

function returning(jp) {
    console.log('returning action:' + jp.result)
}

var Aspect = {
    run: {
        before: before,
        after: after
    },
    work: {
        throwing: throwing,
        around: around,
        returning: returning
    }
}

waveing(Person, [Aspect])

var p = new Person('老刀', 28)

p.run()

p.work()

结果

至此,一个简略版的前端AOP工具已经完成了。但是这里依然存在一些问题。

比如:

1,上面讨论过的应用类和Aspect多对多关系 2,Aspect类的实现 3,ES6和Typescript的出现与发展对前端AOP开发带来哪些影响? 4,AOP的应用以及遇到的问题及其解决办法

由于篇幅所限我们将在下一篇文章继续讨论,继续完善我们上面实现的AOP工具。

这仅仅是一个开始,我们需要讨论的还有很多......