文章首發於我的個人部落格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即當前物件的引用,之所以加入這個資訊,是因為當增強程式碼是一個箭頭函式時,後面的
apply
和call
方法無法修改增強程式碼的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。