Flutter切面的應用與擴充套件

語言: CN / TW / HK

背景

作為一款國民級二手交易App,每天有大量提測任務到質量團隊,如何準確地衡量影響範圍以及確保提測程式碼不存在漏測變得尤為重要。因此閒魚質量嘗試研發客戶端的精準化測試與用例推薦,遇到的第一個問題——如何實現程式碼染色和用例關聯。對於Native開發,無論是iOS和Android都有比較成熟的技術和方案,但是對於Flutter來說,這一塊方案是實質缺失的,通過調研,我們發現Aspectd能夠初步達到我們想要的效果。

Aspectd是一個閒魚技術團隊開發的針對Dart的AOP程式設計框架AspectD,其原理介紹見 重磅開源|AOP for Flutter開發利器——AspectD

問題

我們嘗試使用Aspectd切面上報測試時App執行的程式碼資訊(模組、類、函式等),發現該方案雖然可行,但是依舊存在以下問題:

  1. Inject切面無法取得切入點資訊。

  2. Inject切面不支援正則匹配插樁,即無法批量插入程式碼片段。

  3. Aspectd無法支援if、while等語句級別的切面。

  4. 需要提前學習被hook物件的程式碼,對不熟悉工程的測試同學而言,比較複雜。

基於以上問題,我們在Aspectd的基礎上進行了一定的擴充套件以支援我們的程式碼染色和精準化測試。

技術方案

如何快速獲取切入點資訊

在程式碼染色中,我們除了需要獲取某個功能呼叫了哪些函式,還需要了解程式碼具體走到了哪一個分支上。Inject操作能夠在不改變呼叫邏輯的基礎上,在原有函式體中注入程式碼片段。 Aspectd支援注入程式碼片段中使用原函式體中的變數資訊,但是需要開發人員知道原函式變數命名。另外在使用Inject操作時,我們無法拿到具體切入點資訊,例如當前執行函式的Library、class等資訊。 為了解決這個問題,編譯時通過修改AST生成了預置變數,這樣開發者在使用Inject操作時可以直接根據固定變數獲取對應切入點的相關資訊,預置變數生成虛擬碼示例如下:

Procedure procedure = methodNode;
Class methodClass = procedure.parent;
/**
* 預置變數生成:函式相關資訊
*/
final List<MapLiteralEntry> entries = <MapLiteralEntry>[];
entries.add(MapLiteralEntry(StringLiteral("library"), StringLiteral(library.importUri.toString()))); //新增模組
entries.add(MapLiteralEntry(StringLiteral("class"), StringLiteral(methodClass.name))); // 新增類
entries.add(MapLiteralEntry(StringLiteral("method"), StringLiteral(procedure.name.text))); // 新增方法名
entries.add(MapLiteralEntry(StringLiteral("args"), StringLiteral(procedure.function.positionalParameters.toString()))); // 引數列表
final MapLiteral methodLiteral = MapLiteral(entries);
final VariableDeclaration methodInfo = VariableDeclaration("methodInfo", initializer: methodLiteral);
tmpStatements.add(methodInfo);




/**
* 預置變數生成:引數相關資訊
*/
Library core = _libraryMap['dart:core'];
final List<MapLiteralEntry> paramsEntries = <MapLiteralEntry>[];
final MapLiteral paramsLiteral = MapLiteral(paramsEntries);
final VariableDeclaration paramsInfo = VariableDeclaration("params", initializer: paramsLiteral);
tmpStatements.add(paramsInfo);


final List<DartType> positionalParameters = <DartType>[];
positionalParameters.add(DynamicType());
positionalParameters.add(DynamicType());
FunctionType functionType = FunctionType(positionalParameters, VoidType(), Nullability.legacy);
for (VariableDeclaration variable in procedure.function.namedParameters) {
final List<Expression> positional = <Expression>[];
positional.add(StringLiteral(variable.name));
positional.add(VariableGet(variable));
InstanceInvocation instanceInvocation = InstanceInvocation.byReference(InstanceAccessKind.Instance, VariableGet(paramsInfo), Name("[]="),Arguments(positional), interfaceTargetReference: core.classes[95].procedures[14].reference, functionType: functionType);
final VariableDeclaration p = VariableDeclaration(variable.name, initializer: VariableGet(variable));
ExpressionStatement newExpressionStatement = ExpressionStatement(instanceInvocation);
instanceInvocation.parent = newExpressionStatement;
instanceInvocation.parent = statement.parent;
tmpStatements.add(newExpressionStatement);
}

最終實現的效果如圖1所示,通過斷點可以看到,執行時,原始方法中多了兩個變數——methodInfo和params,分別儲存了函式模組資訊和執行期間的引數資訊。

圖1

為了更直觀地看到編譯期間做的工作,使用 dart /pkg/vm/bin/dump_kernel.dart 對編譯後的app.dill進行輸出,如圖2所示,在原有程式碼邏輯基礎上,增加了methodInfo、params的定義和賦值邏輯。

圖2

使用者在自己的切面程式碼中,可以直接呼叫這兩個預置變數來獲取想要的資訊。從而解決了inject注入無法獲取切面點相關資訊的問題。

如何對分支語句插樁

前面提到在進行程式碼染色時,除了需要知道呼叫的函式以外,我們還需要知道程式在遇到if/switch/for等分支語句時,具體走到了哪一個分支,以便我們統計程式碼的覆蓋率以及測試的完整性。顯然Aspectd目前並不支援基於語句級別的切面。

圖3

閱讀Aspectd程式碼後,我們發現在inject注入時,insertStatementsToBody方法會根據需要插入的lineNum找到對應的程式碼塊, 然後插入需要注入的程式碼片段。如圖2所示,分支語句也只是Statement的多個子類,所以基於語句級別的切面相對比較簡單,我們只需要遍歷原有函式體的程式碼塊,判斷其是否為分支語句,如果是分支語句,則插入我們想要注入的程式碼即可,其虛擬碼如下:

final List<Statement> statements = body.statements;
final int len = statements.length;
for (int i = 0; i < len; i++) {
final Statement statement = statements[i];
if (statement is IfStatement) {
insertStatementsToIfStatement(aopInsertStatements);
}


if (statement is SwitchStatement) {
insertStatementsToSwitchStatement(aopInsertStatements);
}


if (statement is TryStatement) {
insertStatementsToSwitchStatement(aopInsertStatements);
}
...
}

對於各個分支語句的插入則是單獨處理,例如IfStatement,需要分別在then和otherwise兩個語句子Statements進行注入程式碼片段插入。

如何快速批量插樁

在過去一年,客戶端同學對閒魚工程進行了拆包,每個模組都是一個獨立的庫。測試同學只瞭解自己負責的業務是什麼模組,對於業務程式碼的細節並不清楚。然而在Aspectd中使用Inject插樁需要明確具體的函式資訊,這對於程式碼染色這種需要批量插樁並不適應。

Aspectd在transform階段能夠拿到所有的library,因此可以通過配置檔案顯式定義需要切面的模組名稱以及對應的切面action就能夠直接對指定的模組進行切面,在這個過程中,測試同學只需要實現action函式即可,並不需要知道這個模組有什麼類、什麼方法。

通過配置模組和語句注入兩個擴充套件,就可以實現對指定的模組進行插入程式碼片段,進行更加全面的程式碼染色。

fish_redux:
- type: 'inject'
module: package:fwn_idlefish/CodeExecutionLog.dart
action: CodeExection.Log
lineNum: 0


flutter_boost:
- type: 'call'
module: package:fwn_idlefish/CodeExecutionLog.dart
action: CodeExection.print


fish_test:
- type: 'inject'
module: package:fwn_idlefish/CodeExecutionLog.dart
action: CodeExection.Log
color: true # 程式碼染色,覆蓋fish_test的所有程式碼分支

其配置樣例如上所示,在編寫完具體的切面邏輯在切面配置檔案中指定切面的模組以及action,程式碼編譯階段首先會讀取配置檔案,生成AopItemInfo列表,然後在Transform時通過遍歷Library,對需要切面的模組以及起方法進行切面。虛擬碼如下:

    for (Library library in libraryMapa.values) {
if (library.importUri.toString().contains(itemInfo.module) &&
library.importUri.toString().endsWith('aop.dart') == false &&
library.importUri.toString().contains('CodeExecutionLog') ==
false)
for (Class cls in library.classes) {
for (Constructor constructor in cls.constructors) {
transformConstructor(library,
_uriToSource[library.fileUri], procedure, aopItemInfo)
}
for (Procedure procedure in cls.procedures) {
if (blackList.contains(procedure.name) == false)
transformProcedure(library,
_uriToSource[library.fileUri], procedure, aopItemInfo);
}
}
}

總結

以上是我們在Aspectd實際應用中的一些思考和探索,目前該方案已經應用在閒魚客戶端精準化測試專案中,能夠完整支援上報測試同學在執行用例時具體執行了哪些函式以及相關的環境資訊,以實現業務程式碼和業務測試用例的關聯,從而實現用例推薦,後續我們將完整介紹該方案。

除了精準化測試,該方案也會用於客戶端程式碼實時染色系統,實時收集客戶端的執行資料,計算出對應的程式碼行,確認程式碼執行情況,輔助程式碼走讀,定位問題,完成覆蓋率測試等。

同時後續我們也會繼續嘗試基於Aspectd來實現基於端側的“流量回放”,用於驗證程式碼變動後,邏輯是否受到影響。

參考資料

  1. AspectD ( https://github.com/XianyuTech/aspectd )

  2. Ast Library ( https://pub.dev/documentation/analyzer/latest/dart_ast_ast/dart_ast_ast-library.html )

:tangerine:橙子說

閒魚技術聯合大淘寶技術

新春拜年

“虎虎虎”

紙質紅包大派送

掃碼關注”淘系技術“回覆"紅包“即可獲得領取方式

(2月28日18:00截止)