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