重磅开源|AOP for Flutter开发利器——AspectD
問題背景
隨著Flutter這一框架的快速發展,有越來越多的業務開始使用Flutter來重構或新建其產品。但在我們的實踐過程中發現,一方面Flutter開發效率高,性能優異,跨平臺表現好,另一方面Flutter也面臨著插件,基礎能力,底層框架缺失或者不完善等問題。
舉個栗子,我們在實現一個自動化錄制回放的過程中發現,需要去修改Flutter框架(Dart層面)的代碼才能夠滿足要求,這就會有了對框架的侵入性。要解決這種侵入性的問題,更好地減少迭代過程中的維護成本,我們考慮的首要方案即面向切面編程。
那么如何解決AOP for Flutter這個問題呢?本文將重點介紹一個閑魚技術團隊開發的針對Dart的AOP編程框架AspectD。
AspectD:面向Dart的AOP框架
AOP能力究竟是運行時還是編譯時支持依賴于語言本身的特點。舉例來說在iOS中,Objective C本身提供了強大的運行時和動態性使得運行期AOP簡單易用。在Android下,Java語言的特點不僅可以實現類似AspectJ這樣的基于字節碼修改的編譯期靜態代理,也可以實現Spring AOP這樣的基于運行時增強的運行期動態代理。
那么Dart呢?一來Dart的反射支持很弱,只支持了檢查(Introspection),不支持修改(Modification);其次Flutter為了包大小,健壯性等的原因禁止了反射。
因此,我們設計實現了基于編譯期修改的AOP方案AspectD。
設計詳圖
典型的AOP場景
下列AspectD代碼說明了一個典型的AOP使用場景:
aop.dartimport 'package:example/main.dart' as app; import 'aop_impl.dart';void main()=> app.main(); aop_impl.dartimport 'package:aspectd/aspectd.dart';@Aspect() @pragma("vm:entry-point") class ExecuteDemo {@pragma("vm:entry-point")ExecuteDemo();@Execute("package:example/main.dart", "_MyHomePageState", "-_incrementCounter")@pragma("vm:entry-point")void _incrementCounter(PointCut pointcut) {pointcut.proceed();print('KWLM called!');} }面向開發者的API設計
PointCut的設計
@Call("package:app/calculator.dart","Calculator","-getCurTime")PointCut需要完備表征以怎么樣的方式(Call/Execute等),向哪個Library,哪個類(Library Method的時候此項為空),哪個方法來添加AOP邏輯。
PointCut的數據結構:
其中包含了源代碼信息(如庫名,文件名,行號等),方法調用對象,函數名,參數信息等。
請注意這里的@pragma('vm:entry-point')注解,其核心邏輯在于Tree-Shaking。在AOT(ahead of time)編譯下,如果不能被應用主入口(main)最終可能調到,那么將被視為無用代碼而丟棄。AOP代碼因為其注入邏輯的無侵入性,顯然是不會被main調到的,因此需要此注解告訴編譯器不要丟棄這段邏輯。
此處的proceed方法,類似AspectJ中的ProceedingJoinPoint.proceed()方法,調用pointcut.proceed()方法即可實現對原始邏輯的調用。原始定義中的proceed方法體只是個空殼,其內容將會被在運行時動態生成。
Advice的設計
@pragma("vm:entry-point") Future<String> getCurTime(PointCut pointcut) async{...return result; }此處的@pragma("vm:entry-point")效果同a中所述,pointCut對象作為參數傳入AOP方法,使開發者可以獲得源代碼調用信息的相關信息,實現自身邏輯或者是通過pointcut.proceed()調用原始邏輯。
Aspect的設計
@Aspect() @pragma("vm:entry-point") class ExecuteDemo {@pragma("vm:entry-point")ExecuteDemo();...}Aspect的注解可以使得ExecuteDemo這樣的AOP實現類被方便地識別和提取,也可以起到開關的作用,即如果希望禁掉此段AOP邏輯,移除@Aspect注解即可。
AOP代碼的編譯
包含原始工程中的main入口
從上文可以看到,aop.dart引入import 'package:example/main.dart' as app;,這使得編譯aop.dart時可包含整個example工程的所有代碼。
Debug模式下的編譯
在aop.dart中引入import 'aop_impl.dart';這使得aop_impl.dart中內容即便不被aop.dart顯式依賴,也可以在Debug模式下被編譯進去。
Release模式下的編譯
在AOT編譯(Release模式下),Tree-Shaking邏輯使得當aop_impl.dart中的內容沒有被aop中main調用時,其內容將不會編譯到dill中。通過添加@pragma("vm:entry-point")可以避免其影響。
當我們用AspectD寫出AOP代碼,透過編譯aop.dart生成中間產物,使得dill中既包含了原始項目代碼,也包含了AOP代碼后,則需要考慮如何對其修改。在AspectJ中,修改是通過對Class文件進行操作實現的,在AspectD中,我們則對dill文件進行操作。
Dill操作
dill文件,又稱為Dart Intermediate Language,是Dart語言編譯中的一個概念,無論是Script Snapshot還是AOT編譯,都需要dill作為中間產物。
Dill的結構
我們可以通過dart sdk中的vm package提供的dump_kernel.dart打印出dill的內部結構。
dart bin/dump_kernel.dart /Users/kylewong/Codes/AOP/aspectd/example/aop/build/app.dill /Users/kylewong/Codes/AOP/aspectd/example/aop/build/app.dill.txtDill變換
dart提供了一種Kernel to Kernel Transform的方式,可以通過對dill文件的遞歸式AST遍歷,實現對dill的變換。
基于開發者編寫的AspectD注解,AspectD的變換部分可以提取出是哪些庫/類/方法需要添加怎樣的AOP代碼,再在AST遞歸的過程中通過對目標類的操作,實現Call/Execute這樣的功能。
一個典型的Transform部分邏輯如下所示:
@overrideMethodInvocation visitMethodInvocation(MethodInvocation methodInvocation) {methodInvocation.transformChildren(this);Node node = methodInvocation.interfaceTargetReference?.node;String uniqueKeyForMethod = null;if (node is Procedure) {Procedure procedure = node;Class cls = procedure.parent as Class;String procedureImportUri = cls.reference.canonicalName.parent.name;uniqueKeyForMethod = AspectdItemInfo.uniqueKeyForMethod(procedureImportUri, cls.name, methodInvocation.name.name, false, null);}else if(node == null) {String importUri = methodInvocation?.interfaceTargetReference?.canonicalName?.reference?.canonicalName?.nonRootTop?.name;String clsName = methodInvocation?.interfaceTargetReference?.canonicalName?.parent?.parent?.name;String methodName = methodInvocation?.interfaceTargetReference?.canonicalName?.name;uniqueKeyForMethod = AspectdItemInfo.uniqueKeyForMethod(importUri, clsName, methodName, false, null);}if(uniqueKeyForMethod != null) {AspectdItemInfo aspectdItemInfo = _aspectdInfoMap[uniqueKeyForMethod];if (aspectdItemInfo?.mode == AspectdMode.Call &&!_transformedInvocationSet.contains(methodInvocation) && AspectdUtils.checkIfSkipAOP(aspectdItemInfo, _curLibrary) == false) {return transformInstanceMethodInvocation(methodInvocation, aspectdItemInfo);}}return methodInvocation;}通過對于dill中AST對象的遍歷(此處的visitMethodInvocation函數),結合開發者書寫的AspectD注解(此處的_aspectdInfoMap_和aspectdItemInfo),可以對原始的AST對象(此處methodInvocation)進行變換,從而改變原始的代碼邏輯,即Transform過程。
AspectD支持的語法
不同于AspectJ中提供的BeforeAroundAfter三種預發,在AspectD中,只有一種統一的抽象即Around。
從是否修改原始方法內部而言,有Call和Execute兩種,前者的PointCut是調用點,后者的PointCut則是執行點。
Call
import 'package:aspectd/aspectd.dart';@Aspect() @pragma("vm:entry-point") class CallDemo{@Call("package:app/calculator.dart","Calculator","-getCurTime")@pragma("vm:entry-point")Future<String> getCurTime(PointCut pointcut) async{print('Aspectd:KWLM02');print('${pointcut.sourceInfos.toString()}');Future<String> result = pointcut.proceed();String test = await result;print('Aspectd:KWLM03');print('${test}');return result;} }Execute
import 'package:aspectd/aspectd.dart';@Aspect() @pragma("vm:entry-point") class ExecuteDemo{@Execute("package:app/calculator.dart","Calculator","-getCurTime")@pragma("vm:entry-point")Future<String> getCurTime(PointCut pointcut) async{print('Aspectd:KWLM12');print('${pointcut.sourceInfos.toString()}');Future<String> result = pointcut.proceed();String test = await result;print('Aspectd:KWLM13');print('${test}');return result;}Inject
僅支持Call和Execute,對于Flutter(Dart)而言顯然很是單薄。一方面Flutter禁止了反射,退一步講,即便Flutter開啟了反射支持,依然很弱,并不能滿足需求。
舉個典型的場景,如果需要注入的dart代碼里,x.dart文件的類y定義了一個私有方法m或者成員變量p,那么在aop_impl.dart中是沒有辦法對其訪問的,更不用說多個連續的私有變量屬性獲得。另一方面,僅僅對方法整體進行操作可能是不夠的,我們可能需要在方法的中間插入處理邏輯。
為了解決這一問題,AspectD設計了一種語法Inject,參見下面的例子:
flutter庫中包含了一下這段手勢相關代碼:
如果我們想要在onTapCancel之后添加一段對于instance和context的處理邏輯,Call和Execute是不可行的,而使用Inject后,只需要簡單的幾句即可解決:
import 'package:aspectd/aspectd.dart';@Aspect() @pragma("vm:entry-point") class InjectDemo{@Inject("package:flutter/src/widgets/gesture_detector.dart","GestureDetector","-build", lineNum:452)@pragma("vm:entry-point")static void onTapBuild() {Object instance; //Aspectd IgnoreObject context; //Aspectd Ignoreprint(instance);print(context);print('Aspectd:KWLM25');} }通過上述的處理邏輯,經過編譯構建后的dill中的GestureDetector.build方法如下所示:
此外,Inject的輸入參數相對于Call/Execute而言,多了一個lineNum的命名參數,可用于指定插入邏輯的具體行號。
構建流程支持
雖然我們可以通過編譯aop.dart達到同時編譯原始工程代碼和AspectD代碼到dill文件,再通過Transform實現dill層次的變換實現AOP,但標準的flutter構建(即flutter_tools)并不支持這個過程,所以還是需要對構建過程做細微修改。
在AspectJ中,這一過程是由非標準Java編譯器的Ajc來實現的。在AspectD中,通過對flutter_tools打上應用Patch,可以實現對于AspectD的支持。
實戰與思考
基于AspectD,我們在實踐中成功地移除了所有對于Flutter框架的侵入性代碼,實現了同有侵入性代碼同樣的功能,支撐上百個腳本的錄制回放與自動化回歸穩定可靠運行。
從AspectD的角度看,Call/Execute可以幫助我們便捷實現諸如性能埋點(關鍵方法的調用時長),日志增強(獲取某個方法具體是在什么地方被調用到的詳細信息),Doom錄制回放(如隨機數序列的生成記錄與回放)等功能。Inject語法則更為強大,可以通過類似源代碼諸如的方式,實現邏輯的自由注入,可以支持諸如App錄制與自動化回歸(如用戶觸摸事件的錄制與回放)等復雜場景。
進一步來說,AspectD的原理基于Dill變換,有了Dill操作這一利器,開發者可以自由地對Dart編譯產物進行操作,而且這種變換面向的是近乎源代碼級別的AST對象,不僅強大而且可靠。無論是做一些邏輯替換,還是是Json<-->模型轉換等,都提供了一種新的視角與可能。
寫在最后
AspectD作為閑魚技術團隊新開發的面向Flutter的AOP框架,已經可以支持主流的AOP場景并在Github開源,歡迎使用。
原文鏈接
本文為云棲社區原創內容,未經允許不得轉載。
總結
以上是生活随笔為你收集整理的重磅开源|AOP for Flutter开发利器——AspectD的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 解密双十一小程序云背后毫秒级伸缩的Ser
- 下一篇: MongoDB 定位 oplog 必须全