动手实现一个JavaScript的AOP(一)

文章首发于我的个人博客huangmb.github.io,欢迎关注。

前言

AOP即面向切面编程,简单来说就是可以通过编译期或者运行时在不修改源代码的情况下给程序动态增加功能的一种技术。

AOP应用场景

AOP比较典型的应用有:日志记录、性能监控、埋点上报、异常处理等等。对于业务无关的附加功能,直接写到业务代码中也可以实现,但这显然不是一个有"洁癖"程序员的作风;而且这些功能往往需求多变,或者会污染业务代码的实现,掺杂在一起难以维护。无侵入的AOP才是"附加功能"的最佳选择。

Java的AOP实现

在Java领域,最负盛名的AOP框架莫过于AspectJ,不论是客户端的Swing项目(编译期织入),还是Web平台的Spring项目(运行时动态代理),我们都可以见到它的身影。

JavaScript版本的AOP实现?

那么在JavaScript上有没有AspectJ这样的框架呢?

笔者目前在开发一个React Native项目,测试妹子要求给她打印页面的一些诸如请求起止时间数据解析起止时间视图渲染起止时间之类的性能指标。

面对这样的要求,首先想到的就是通过AOP实现。
毕竟这不是产品经理的需求,写到业务也不合适,甚至可能会影响到正式版本的性能;
本地单独写个版本,不合入主仓库,这样的话测试妹子明天又来要个版本,又得在新版本上再写一遍(想想好像也不是不可以)。

回到这个"要求",Google了一番"JavaScript" + "AOP"关键字,并没有找到一个合适的框架┐(゚~゚)┌。

或许并不需要这样一个"框架"呢。庆幸的是,js作为一个语法高度自由的弱类型语言,允许动态增删方法,这不就是各种AOP框架实现的基础么。

于是就有了这篇文章,自己撸一个js版本的AOP实现。

AOP的理论基础

和尚念经时间。

AOP一般有以下几个概念:

  • 连接点(JointPoint):
    能够被拦截的地方,一般是成员方法或者属性,它们都可以称之为连接点。
  • 切点(PointCut):
    具体定位的连接点,既然每个方法(或属性)都可以作为连接点,我们不可能对所有方法都进行增强,那么被我们匹配用来增强的方法就是切点。
  • 增强/通知(Advice):
    就是我们用来添加到特定切点上的逻辑代码,用于"增强"原有的功能。
  • 切面(Aspect):
    切面由切点增强组成,就是定义你要在"什么地方"以"何种方式"做"什么事"。

增强(Advice)一般有以下五种类型:

  • 前置(before):
    也就是在连接点执行前实施增强。
  • 异常(after throw)
    在连接点抛出异常后实施增强,一般允许拿到连接点抛出的异常。
  • 返回(after return)
    在连接点正常执行后实施增强,一般允许拿到连接点的返回值。
  • 后置(after (final)):
    在连接点执行后实施增强,不论连接点是正常返回还是抛出异常,一般拿不到返回值,因为不知道是异常还是返回。
  • 环绕(around)
    在连接点执行前后实施增强,甚至可以让连接点可选的执行。

动手实现

撸起袖子开始干。

实现切点和切面

我们知道,JavaScript的对象都有个prototype原型对象,即使是es6的class上定义的属性和方法,其实也是在声明在prototype上。

我们可以通过SomeClass.prototype.methodName找到SomeClass类的MethodName方法,这样,一个最简单的方法名匹配切点就实现了。

我们可以通过修改prototype,重新定义方法,比如:

let target = SomeClass;
let pointCut = 'methodName';
// 切点
let old = target.prototype[pointCut]
// 切面
target.prototype[pointCut] = function () {
    // 前置增强
    console.log(`method ${pointCut} will be invoke`);
    old();
}

这里为SomeClass类重新定义了methodName方法,在原方法之前加入了一条log语句,这条语句其实就是before类型的增强代码。这段代码就是最简单的前置增强的切面例子。

实现增强/通知

在实现具体的增强前,先定义一个匹配切点的方法,目前最简单的版本就是根据方法名直接匹配。

let findPointCut = (target, pointCut) => {
    if (typeof pointCut === 'string') {
        let func = target.prototype[pointCut];
        // 暂不支持属性的aop
        if (typeof func === 'function') {
            return func;
        }
    }
    // 暂不支持模糊匹配切点
    return null;
};

最终,我们将以下面的结构来提供我们的AOP工具,其中target即为要增强的类,pointCut为要增强的方法名,cb为回调即我们要注入的增强代码。

let aop = {
    before(target, pointCut, cb) {
    },
    after(target, pointCut, cb) {
    },
    afterReturn(target, pointCut, cb) {
    },
    afterThrow(target, pointCut, cb) {
    },
    around(target, pointCut, cb) {
    }

};
export default aop;

以前置增强为例,我们要给增强代码传递的连接点信息只要最基础的目标类、目标方法、原始参数,便于增强代码识别切面信息。

在连接点信息中还加入了self即当前对象的引用,之所以加入这个信息,是因为当增强代码是一个箭头函数时,后面的applycall方法无法修改增强代码的this引用,可以通过这个self来访问目标对象的属性;
使用function定义的回调可以直接使用this访问目标对象

before(target, pointCut, cb = emptyFunc) {

        let old = findPointCut(target, pointCut);
        if (old) {
            target.prototype[pointCut] = function () {
                let self = this;
                let joinPoint = {
                    target,
                    method: old,
                    args: arguments,
                    self
                };
                cb.apply(self, joinPoint);
                return old.apply(self, arguments);
            };
        }
    }

因为后面几种增强跟这个差不太多,可能会出现很多重复代码。现在将所有的增强进行了一个封装,所有类型的增强都融合在advice方法里。整个aop完整代码如下:

let emptyFunc = () => {
};

let findPointCut = (target, pointCut) => {
    if (typeof pointCut === 'string') {
        let func = target.prototype[pointCut];
        // 暂不支持属性的aop
        if (typeof func === 'function') {
            return func;
        }
    }
    // 暂不支持模糊匹配切点
    return null;
};
let advice = (target, pointCut, advice = {}) => {
    let old = findPointCut(target, pointCut);
    if (old) {
        target.prototype[pointCut] = function () {
            let self = this;
            let args = arguments;
            let joinPoint = {
                target,
                method: old,
                args,
                self
            };
            let {before, round, after, afterReturn, afterThrow} = advice;
            // 前置增强
            before && before.apply(self, joinPoint);
            // 环绕增强
            let roundJoinPoint = joinPoint;
            if (round) {
                roundJoinPoint = Object.assign(joinPoint, {
                    handle: () => {
                        return old.apply(self, arguments || args);
                    }
                });
            } else {
                // 没有声明round增强,直接执行原方法
                round = () => {
                    old.apply(self, args);
                };
            }


            if (after || afterReturn || afterThrow) {
                let result = null;
                let error = null;
                try {
                    result = round.apply(self, roundJoinPoint);
                    // 返回增强
                    return afterReturn && afterReturn.call(self, joinPoint, result) || result;
                } catch (e) {
                    error = e;
                    // 异常增强
                    let shouldIntercept = afterThrow && afterThrow.call(self, joinPoint, e);
                    if (!shouldIntercept) {
                        throw e;
                    }
                } finally {
                    // 后置增强
                    after && after.call(self, joinPoint, result, error);
                }
            } else {
                // 未定义任何后置增强,直接执行原方法
                return round.call(self, roundJoinPoint);
            }
        };
    }
};

let aop = {
    before(target, pointCut, before = emptyFunc) {
        advice(target, pointCut, {before});
    },
    after(target, pointCut, after = emptyFunc) {
        advice(target, pointCut, {after});
    },
    afterReturn(target, pointCut, afterReturn = emptyFunc) {
        advice(target, pointCut, {afterReturn});
    },
    afterThrow(target, pointCut, afterThrow = emptyFunc) {
        advice(target, pointCut, {afterThrow});
    },
    round(target, pointCut, round = emptyFunc) {
        advice(target, pointCut, {round});
    }
};

export default aop;

现在我们的before可以简化成:

 before(target, pointCut, before = emptyFunc) {
    advice(target, pointCut, {before});
 }

使用方法

前置before

前置增强不干扰原方法的执行,只有一个参数为连接点信息,可以访问到切点所在的类和方法以及当前的参数和this引用。

import Test from './test';
aop.before(Test, 'test', (joinPoint) => {
    let {target, method, args, self} = joinPoint;
    console.log('test方法将被执行');
});

后置after

后置增强在原方法执行完毕后执行,参数除了连接点信息外还有返回结果和异常。因为原方法可能是正常返回也可能抛出异常,所以result和error有一个为空(AspectJ无此设计)。

import Test from './test';
aop.after(Test, 'test', (joinPoint, result, error) => {
    let {target, method, args, self} = joinPoint;
    console.log('test方法执行完毕');
});

返回afterReturn

返回增强可以拿到原方法的返回值,即回调的第二个参数。
如果需要修改返回值,可以在增强里面return,否则使用原返回值。

import Test from './test';
aop.afterReturn(Test, 'test', (joinPoint, result) => {
    let {target, method, args, self} = joinPoint;
    console.log('test方法正常执行完毕');
    // 可以修改返回值
    return newResult;
});

异常afterThrow

异常增强在原方法发生异常时执行,回调的第二个参数为异常。

并且回调可以方法布尔值,表示是否截断异常,当return true时异常不会继续上抛(AspectJ无此功能)。

import Test from './test';
aop.afterThrow(Test, 'test', (joinPoint, error) => {
    let {target, method, args, self} = joinPoint;
    console.log('test方法抛出异常');
});

环绕around

环绕增强是最灵活的方法,将原方法的执行权交给增强代码来调用,在连接点中多了一个handle方法,增强代码中手动调用handle方法,因此可以根据调用时机实现前面四种增强类型,并且可以定制原方法的参数和返回值。
arround增强需要return结果给原方法的调用方

import Test from './test';
aop.around(Test, 'test', (joinPoint, error) => {
    let {target, method, args, self, handle} = joinPoint;
    console.log('test方法即将执行');
    let result = handle(); // 无参调用即使用原始参数调用原方法
    // let result = handle(args) // 使用指定的参数调用原方法
    // 可以对result进行处理
    console.log('test方法执行完毕');
    // 必须返回一个结果
    return result;
});

结尾

得益于JavaScript语言的动态性,实现一个基础版功能过得去的AOP还是非常容易的,基本可以满足一般NodeJs、React Native等项目使用。

当然还有很多不足的地方,比如更灵活的切面等,如果大家用过AspectJ,可能会知道Aspect可以通过全程类名、特定注解、继承关系、模糊匹配等多种方式声明切点,无疑能使aop的使用更加灵活。另外也可以针对React的Component组件类aop做改进,这部分可以参考react-proxy实现。

后面可能会视应用场景逐渐优化和改进aop。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容