博客 / 詳情

返回

自動化迴歸測試平台 AREX 的 Mock 實現原理

AREX 是一款開源的基於真實請求與數據的自動化迴歸測試平台,利用 Java Agent 字節碼注入技術,通過在生產環境錄製和存儲請求、應答數據,並在測試環境回放請求和注入 Mock 數據,存儲新的應答,實現了自動錄製、自動回放、自動比對,為接口迴歸測試提供便利。

AREX Mock 功能十分強大,不僅支持各種主流技術框架的自動數據採集和 Mock,還支持了本地時間、緩存數據以及各種內存數據的採集和 Mock,可以做到在回放時精準還原生產執行時的數據環境,且不會產生髒數據。

這篇文檔將從代碼實現的角度簡單介紹下 AREX 是如何實現在流量回放時自動 Mock 數據的。

示例

讓我們先以一個簡單的函數為例,理解⼀下其實現原理。假定我們有下面⼀個函數,用於將給定的 IP 字符串轉換成整型,代碼如下:

public Integer parseIp(String ip) {
    int result = 0;
    if (checkFormat(ip)) { // 檢查IP串是否合法
        String[] ipArray = ip.split("\\.");
        for (int i = 0; i < ipArray.length; i++) {
            result = result << 8;
            result += Integer.parseInt(ipArray[i]);
        }
    }
    return result;
}

我們將從兩個方面説明如何實現該函數的流量回放功能:

  • ecord(流量採集)

當這個函數被調用時,我們把對應的請求參數和返回結果保存下來,供後面流量回放使用,代碼如下:

if (needRecord()) {
    // 數據採集,將參數和執⾏結果保存進DB
    DataService.save("parseIp", ip, result);
}
  • Replay(流量回放)

在進行流量回放時,就可以用之前採集的數據來自動實現這個函數的 Mock,代碼如下:

if (needReplay()) {
    return DataService.query("parseIp", ip);
}

通過查看完整的代碼,我們可以更好地理解其實現邏輯:

public Integer parseIp(String ip) {
    if (needReplay()) {
        // 回放的場景,使⽤採集的數據做為返回結果,也就是 Mock
        return DataService.query("parseIp", ip);
    }
 
    int result = 0;
    if (checkFormat(ip)) {
        String[] ipArray = ip.split("\\.");
        for (int i = 0; i < ipArray.length; i++) {
            result = result << 8;
            result += Integer.parseInt(ipArray[i]);
        }
    }
 
    if (needRecord()) {
        // 錄製的場景,將參數和執⾏結果保存進到數據庫
        DataService.save("pareseIp", ip, result);
    }
    return result;
}

AREX 中的具體實現

AREX 實現的原理類似,不過會更復雜⼀些,不需要開發人員手動在業務代碼中添加錄製和回放的代碼。arex-agent 會在應用啓動時,在需要的代碼塊中自動添加相應的代碼來實現這個功能。這裏以 MyBatis3 的 Query 為例,看看 AREX 中的具體實現。

閲讀過 MyBatis 源碼的應該都瞭解,Query 的操作都會收束在 org.apache.ibatis.executor.BaseExecutor 類的 query 方法上(Batch 操作除外),這個方法的簽名如下:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException

這⾥包含了執行的 SQL 和參數,函數的結果包含了從數據庫中查到的數據,顯然在這裏執行數據採集是合適的,在回放的時候也可以用採集的數據作為結果返回,從而避免實際的數據庫操作。看看 AREX 中的代碼,為了便於理解,這裏做了⼀定的簡化,如下:

public class ExecutorInstrumentation extends TypeInstrumentation {
    @Override
    protected ElementMatcher<TypeDescription> typeMatcher() {
        // 需要進行代碼注入的類全名
        return named("org.apache.ibatis.executor.BaseExecutor");
    }
 
    @Override
    public List<MethodInstrumentation> methodAdvices() {
        // 需要進行代碼注入的方法名,因為query方法存在多個重載,所以帶上了參數驗證
        return Collections.singletonList(new MethodInstrumentation(
                        named("query").and(isPublic())
                                .and(takesArguments(6))
                                .and(takesArgument(0, named("org.apache.ibatis.mapping.MappedStatement")))
                                .and(takesArgument(1, Object.class))
                                .and(takesArgument(5, named("org.apache.ibatis.mapping.BoundSql"))),
                        QueryAdvice.class.getName())
        );
    }
 
    // 注入的代碼
    public static class QueryAdvice {
 
        @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class, suppress = Throwable.class)
        public static boolean onMethodEnter(@Advice.Argument(0) MappedStatement var1,
                                            @Advice.Argument(1) Object var2,
                                            @Advice.Argument(5) BoundSql boundSql,
                                            @Advice.Local("mockResult") MockResult mockResult) {
            RepeatedCollectManager.enter(); // 防止嵌套調用導致的數據重複採集
            if (ContextManager.needReplay()) {
                mockResult = InternalExecutor.replay(var1, var2, boundSql, "query");
            }
            return mockResult != null;
        }
 
        @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
        public static void onMethodExit(@Advice.Argument(0) MappedStatement var1,
                                  @Advice.Argument(1) Object var2,
                                  @Advice.Argument(5) BoundSql boundSql,
                                  @Advice.Thrown(readOnly = false) Throwable throwable,
                                  @Advice.Return(readOnly = false) List<?> result,
                                  @Advice.Local("mockResult") MockResult mockResult) {
            if (!RepeatedCollectManager.exitAndValidate()) {
                return;
            }
 
            if (mockResult != null) {
                if (mockResult.getThrowable() != null) {
                    throwable = mockResult.getThrowable();
                } else {
                    result = (List<?>) mockResult.getResult();
                }
                return;
            }           
 
            if (ContextManager.needRecord()) {
                InternalExecutor.record(var1, var2, boundSql, result, throwable, "query");
            }
        }
    }
}

其中 QueryAdvice 是需要在 query 方法中注入的代碼。通過 onMethodEnter 注入的代碼會在方法最開始地位置執行,而 onMethodExit 注入的代碼則會在函數返回結果之前執行。

單純地看這個可能比較難於理解,我們把注入代碼後的 BaseExecutor 的 query 方法的代碼 dump下來進行分析,如下:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
        MockResult mockResult = null;
        boolean skipOk;
        try {
            RepeatedCollectManager.enter();
            if (ContextManager.needReplay()) {
                mockResult = InternalExecutor.replay(ms, parameter, boundSql, "query");
            }
 
            skipOk = mockResult != null;
        } catch (Throwable var28) {
            var28.printStackTrace();
            skipOk = false;
        }
 
        List result;
        Throwable throwable;
        if (skipOk) {
            // 重放的場景,不再執行原來的 query 方法體
            result = null;
        } else {
            try {
                // BaseExecutor query 方法的原代碼,此處省略,唯一會被調整的就是原方法裏 return 的代碼,會被修改為將結果賦值給 result
                result = list;
            } catch (Throwable var27) {
                throwable = var27;
                result = null;
            }
        }
 
        try {
            if (mockResult != null) {
                if (mockResult.getThrowable() != null) {
                    throwable = mockResult.getThrowable();
                } else {
                    result = (List)mockResult.getResult();
                }
            } else if (RepeatedCollectManager.exitAndValidate() && ContextManager.needRecord()) {
                InternalExecutor.record(ms, parameter, boundSql, result, throwable, "query");
            }
        } catch (Throwable var26) {
            var26.printStackTrace();
        }
 
        if (throwable != null) {
            throw throwable;
        } else {
            return result;
        }
    }

可以看到 onMethodEnter 和 onMethodExit 裏的代碼被插⼊到了開頭和結尾,再來理解下這段代碼:

  • 錄製的場景

AREX 會判斷這次訪問數據是否需要錄製(服務收到請求時,AREX 會根據配置的錄製頻率決定是否對這個請求進行錄製,如果判斷為需要錄製,則這個請求執行過程中所有的外部依賴都會被錄製,具體實現細節這裏不做介紹了)。錄製過程中,AREX 會調用 InternalExecutor.record(ms, parameter, boundSql, result, throwable, "query") 方法,將本次數據庫訪問的結果、核心參數等信息存入AREX的數據庫中,完成對該數據庫訪問的錄製。

  • 回放的場景

從上面的代碼可以看到,當把前面錄製的請求再次發送給對應服務時,AREX 會將其視為回放,此時不會再執行原函數的代碼了,而是直接返回之前錄製下來的結果(包括當時異常的還原),通過調用 InternalExecutor.replay(ms, parameter, boundSql, "query”) 可以獲取之前保存的錄製數據。

內存數據的 Record\&Replay(動態類)

當然,前面示例的函數是冪等的,對於冪等函數而言,由於每次調用時,其返回結果始終相同,不會受到外部因素的影響,因此在錄製和回放過程中並不需要進行數據的採集和 Mock。

相反,對於非冪等的函數,每次調用的結果可能會受到外部環境的影響,並且執行結果會影響服務輸出(例如各種本地緩存,不同的環境數據可能不同,從而影響輸出結果)。在這種情況下,AREX 也提供配置動態類這種機制來實現這部分數據的 Record 和 Mock 功能,具體可以在 Setting 子菜單的 Record 配置項中配置:

mock原理.png

在這裏依次配置類名、方法名(非必需,不配置的話將會應用於所有有參數和返回值的公共方法)、參數類型(非必需)。配置完成後,arex-agent 將會自動在對應的方法中注入類似上面的 Record\&Replay 代碼,從而實現數據的採集和回放時的 Mock 功能。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.