使用任何一個(gè)新技術(shù),必定要經(jīng)過采坑的過程。一千個(gè)團(tuán)隊(duì)面臨一萬個(gè)場景,在不同的場景下審視同一個(gè)開源技術(shù),一定會有不同的看法。我們基于開源,回饋開源,這才是開源的魅力。
repeater地址:https://github.com/alibaba/jvm-sandbox-repeater
本文所有源碼分析基于commit id:0a1b47b2aae295a5c4627e533e7da94b9ed2b14d
我的場景
官方文檔里介紹的slogan的例子,我在console里玩起來也是666,給組里測試的美眉也演示了一下,她跟我要了一個(gè)時(shí)間點(diǎn):什么時(shí)候能用上?
于是基于sandbox做了一個(gè)docker基礎(chǔ)鏡像,美滋滋把一個(gè)業(yè)務(wù)服務(wù)部署起來,美滋滋錄制、回放。但是在回放的時(shí)候,一直是失敗的。

被回放的服務(wù),報(bào)錯(cuò)日志如下:
Caused by: com.alibaba.jvm.sandbox.repeater.plugin.exception.RepeatException: no matching invocation found
at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractMockStrategy.execute(AbstractMockStrategy.java:69)
at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractInvocationProcessor.doMock(AbstractInvocationProcessor.java:75)
at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultEventListener.doBefore(DefaultEventListener.java:156)
at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultEventListener.onEvent(DefaultEventListener.java:118)
at com.alibaba.jvm.sandbox.core.enhance.weaver.EventListenerHandler.handleEvent(EventListenerHandler.java:117)
at com.alibaba.jvm.sandbox.core.enhance.weaver.EventListenerHandler.handleOnBefore(EventListenerHandler.java:353)
at java.com.alibaba.jvm.sandbox.spy.Spy.spyMethodOnBefore(Spy.java:164)
at redis.clients.jedis.BinaryJedis.hgetAll(BinaryJedis.java)
at org.springframework.data.redis.connection.jedis.JedisConnection.hGetAll(JedisConnection.java:2999)
從日志上看,是因?yàn)閞epeater的字節(jié)碼增強(qiáng)導(dǎo)致了業(yè)務(wù)服務(wù)回放的時(shí)候報(bào)了500的錯(cuò)誤。
問題探查
這個(gè)是為什么呢?repeater真的這么不健壯嗎?去看一下報(bào)錯(cuò)的地方的代碼:
if (select.isMatch() && invocation != null) {
response = MockResponse.builder()
.action(invocation.getThrowable() == null ? Action.RETURN_IMMEDIATELY : Action.THROWS_IMMEDIATELY)
.throwable(invocation.getThrowable())
.invocation(invocation)
.build();
mi.setSuccess(true);
mi.setOriginUri(invocation.getIdentity().getUri());
mi.setOriginArgs(invocation.getRequest());
} else {
//就是這一行報(bào)的錯(cuò)
response = MockResponse.builder()
.action(Action.THROWS_IMMEDIATELY)
.throwable(new RepeatException("no matching invocation found")).build();
}
這個(gè)地方的邏輯,解釋起來可能會有點(diǎn)迷糊,因?yàn)?code>不識廬山真面目,只緣身在此山中,各位先硬著頭皮做一下局部理解,不需要發(fā)散。
上面這段代碼隸屬于AbstractMockStrategy, 這是一個(gè)通用的mock的策略實(shí)現(xiàn)。
這段代碼圍繞invocation來進(jìn)行判斷,invocation代表著回放時(shí)獲取到的一個(gè)調(diào)用記錄,可以是入口調(diào)用也可以是子調(diào)用,當(dāng)然mock肯定是針對子調(diào)用進(jìn)行的。
所以在這段代碼執(zhí)行之前,其實(shí)repeater已經(jīng)根據(jù)回放id找到了之前錄制的時(shí)候錄制好的很多個(gè)子調(diào)用的記錄。
舉個(gè)例子,比如之前錄制的時(shí)候,我調(diào)用業(yè)務(wù)的接口A,然后這個(gè)A的調(diào)用觸發(fā)了很多次redis的操作,那么每一次redis的調(diào)用都是一次子調(diào)用,錄制的時(shí)候會把每次與redis的交互的入?yún)⒑头祷亟涌诙加涗浵聛恚?br>
回放的時(shí)候呢,根據(jù)調(diào)用id可以找到很多的子調(diào)用記錄,repeater會根據(jù)一個(gè)策略選擇某一個(gè)字段用記錄與當(dāng)前回放階段發(fā)生的子調(diào)用進(jìn)行匹配:
//按照一定的策略匹配一次自調(diào)用記錄
SelectResult select = select(request);
Invocation invocation = select.getInvocation();
//...
if (select.isMatch() && invocation != null) {
//找到了匹配的子調(diào)用,直接在回放的時(shí)候mock掉本次子調(diào)用
//...
} else {
//找不到對應(yīng)的子調(diào)用,直接拋出異常
//...
}
基于上面的代碼,repeater的邏輯其實(shí)很簡單,回放的時(shí)候發(fā)生的子調(diào)用一定會存在于錄制時(shí),如果不存在,那一定是發(fā)生了意外情況,所以拋出異常。
疑點(diǎn)
按照常理來說,我的使用姿勢沒有特殊的,被錄制的系統(tǒng)代碼在回放的時(shí)候也沒有發(fā)生變化,怎么會出現(xiàn)這種情況呢?難道是repeater有bug?不自覺的心中一喜。
(想要zheng解ming開ta這you個(gè)bug疑點(diǎn),我?guī)缀醢裷epeater的核心源碼都擼了一遍,然后才豁然開朗。)
問題原因解釋
此處按照正向邏輯解釋上面的疑點(diǎn),但問題排查其實(shí)是一個(gè)逆向的過程,因?yàn)閷υ创a不熟,還是花了很長時(shí)間的。
流量回時(shí)序圖
基于源碼研讀,整理整個(gè)回放的時(shí)序圖如下:

流程解讀
整體回放的時(shí)序圖如上圖,console這邊通過頁面觸發(fā)回放之后,會向業(yè)務(wù)服務(wù)發(fā)起http調(diào)用,通過repeater的標(biāo)準(zhǔn)接口發(fā)起回放,注意此處請求并不是直接調(diào)用業(yè)務(wù)接口,而是調(diào)用repeater的此處貼一下脫敏后的回放抓包:
//request
POST /sandbox/default/module/http/repeater/repeat HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 444
Host: *.*.*.*:12580
Connection: Keep-Alive
Accept-Encoding: gzip
User-Agent: okhttp/3.9.0
_data=xxxxxxxxxx
//RESPONSE:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Server: Jetty(8.y.z-SNAPSHOT)
submit success
核心流程一
這個(gè)請求的處理者是repeater,入口代碼如下:
@Command("repeat")
public void repeat(final Map<String, String> req, final PrintWriter writer) {
//...
}
上面這個(gè)函數(shù)類似spring mvc里的controller里的函數(shù),它接到這個(gè)請求之后做了簡單的處理之后就publish一個(gè)RepeatEvent,event處理的邏輯鏈路較長,此處貼一下整個(gè)調(diào)用鏈路:
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.spi.RepeatSubscDefaultFlowDispatcherribeSupporter#onSubscribe
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultFlowDispatcher#dispatch
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractRepeater#repeat
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractRepeater#sendRepeat
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractBroadcaster#sendRepeat
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultBroadcaster#broadcastRepeat
com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultBroadcaster#broadcast
com.alibaba.jvm.sandbox.repeater.plugin.core.util.HttpUtil#invokePostBody(java.lang.String, java.util.Map<java.lang.String,java.lang.String>, java.lang.String)
com.alibaba.jvm.sandbox.repeater.plugin.core.util.HttpUtil#invokePostBody(java.lang.String, java.util.Map<java.lang.String,java.lang.String>, java.util.Map<java.lang.String,java.lang.String[]>, java.lang.String)
com.alibaba.jvm.sandbox.repeater.plugin.core.util.HttpUtil#executeRequest(okhttp3.Request)
com.alibaba.jvm.sandbox.repeater.plugin.core.util.HttpUtil#executeRequest(okhttp3.Request, int)
感興趣的,可以跟著源碼看一下,上面這一坨的代碼邏輯,其實(shí)是上面那張時(shí)序圖里標(biāo)注的核心流程一的內(nèi)容。
核心流程一,要做的主要事情就是基于錄制的記錄,拼裝回放請求,并向127.0.0.1發(fā)起回放的http請求, 此處其實(shí)發(fā)起的就是真正的業(yè)務(wù)請求了,如果console里沒有開啟mock開關(guān),且業(yè)務(wù)系統(tǒng)接口不是冪等的,就可能會造成臟數(shù)據(jù)了,所以需要謹(jǐn)慎。
核心流程二
回放流量到了業(yè)務(wù)服務(wù)之后,業(yè)務(wù)代碼和處理過程和標(biāo)準(zhǔn)業(yè)務(wù)處理是沒什么兩樣的,關(guān)鍵就在于在執(zhí)行某些子調(diào)用的時(shí)候,業(yè)務(wù)代碼會被欺騙,被mock。
比如我的業(yè)務(wù)代碼,原來計(jì)劃是查詢r(jià)edis的,但是因?yàn)閞epeater識別到是回放流量,所以直接幫我把訪問redis的操作給mock了,并根據(jù)錄制的返回值把之前的返回結(jié)果再重新返回一遍。
這里的關(guān)鍵入口代碼是:
//com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultEventListener
protected void doBefore(BeforeEvent event) throws ProcessControlException {
// 回放流量;如果是入口則放棄;子調(diào)用則進(jìn)行mock
if (RepeatCache.isRepeatFlow(Tracer.getTraceId())) {
processor.doMock(event, entrance, invokeType);
return;
}
//...
}
上面這段代碼是一個(gè)事件監(jiān)聽器,這里的事件其實(shí)是sandbox一層發(fā)生的,repeater對事處理流程做了更高級一層的封裝。對于before事件,如果識別到是流量回放過程,則直接進(jìn)行mock。mock的邏輯如下代碼:
//com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractInvocationProcessor
@Override
public void doMock(BeforeEvent event, Boolean entrance, InvokeType type) throws ProcessControlException {
/*
* 獲取回放上下文
*/
RepeatContext context = RepeatCache.getRepeatContext(Tracer.getTraceId());
/*
* mock執(zhí)行條件
*/
if (!skipMock(event, entrance, context) && context != null && context.getMeta().isMock()) {
try {
/*
* 構(gòu)建mock請求
*/
final MockRequest request = MockRequest.builder()
.argumentArray(this.assembleRequest(event))
.event(event)
.identity(this.assembleIdentity(event))
.meta(context.getMeta())
.recordModel(context.getRecordModel())
.traceId(context.getTraceId())
.type(type)
.repeatId(context.getMeta().getRepeatId())
.index(SequenceGenerator.generate(context.getTraceId()))
.build();
/*
* 執(zhí)行mock動(dòng)作
*/
final MockResponse mr = StrategyProvider.instance().provide(context.getMeta().getStrategyType()).execute(request);
/*
* 處理策略推薦結(jié)果
*/
switch (mr.action) {
case SKIP_IMMEDIATELY:
break;
case THROWS_IMMEDIATELY:
ProcessControlException.throwThrowsImmediately(mr.throwable);
break;
case RETURN_IMMEDIATELY:
ProcessControlException.throwReturnImmediately(assembleMockResponse(event, mr.invocation));
break;
default:
ProcessControlException.throwThrowsImmediately(new RepeatException("invalid action"));
break;
}
} catch (ProcessControlException pce) {
throw pce;
} catch (Throwable throwable) {
ProcessControlException.throwThrowsImmediately(new RepeatException("unexpected code snippet here.", throwable));
}
}
}
上面這段代碼邏輯簡單講:
先提取錄制的記錄,然后封裝成MockRequest, 然后基于特定的策略,匹配到具體的子調(diào)用并獲取返回結(jié)果。然后基于返回結(jié)果對mock請求進(jìn)行響應(yīng)。
- 提取錄制記錄:RepeatCache.getRepeatContext(Tracer.getTraceId());
- 基于特定策略匹配特定自調(diào)用:StrategyProvider.instance().provide(context.getMeta().getStrategyType()).execute(request);
- 進(jìn)行返回值處理: switch (...) {...}
這里提取錄制記錄的地方,要想一下,這個(gè)緩存是什么時(shí)候放進(jìn)去的呢?其實(shí)是在核心流程一里放置的。這塊也是引發(fā)疑點(diǎn)的一個(gè)原因之一,后面解釋。
這塊的源碼有一個(gè)地方需要解釋一下,就是為什么要基于特定策略尋找匹配的子調(diào)用。
大家可以想一下,repeater下層是sandbox,sandbox簡單來說,用來做代碼增強(qiáng)的。再具體點(diǎn)講,只能是在目標(biāo)方法的前、后、拋異常等關(guān)鍵時(shí)刻插入一些額外的代碼,并干擾原始方法的返回結(jié)果。
那么在錄制的時(shí)候,其實(shí)repeater對子調(diào)用能記錄的事情,其實(shí)是有限的,頂多是方法簽名、入?yún)⒌取R淮握{(diào)用,會包含多個(gè)子調(diào)用,一般是一個(gè)一對多的關(guān)系。然而在回放的時(shí)候呢,具體到某一個(gè)函數(shù)的執(zhí)行,如果判斷下來是在做回放,那么repeater就需要從多個(gè)子調(diào)用記錄中找到和當(dāng)前執(zhí)行的子調(diào)用所匹配的一個(gè),并根據(jù)錄制的時(shí)候所錄制的返回結(jié)果進(jìn)行相同的結(jié)果返回。那么這個(gè)找到匹配的子調(diào)用的過程就是基于特定的策略的,目前開源的repeater里內(nèi)置了幾個(gè)策略:

這塊,基于異常堆棧,我回放時(shí)報(bào)錯(cuò)的策略是
ParameterMatchMockStrategy, 這是一個(gè)基于入?yún)磉M(jìn)行子調(diào)用匹配的策略,簡單講就是如果當(dāng)前子調(diào)用是一個(gè)redis的hgetall的請求,入?yún)⑹莂,那么如果在錄制的子調(diào)用里能找到同樣的redis的hgetall并且入?yún)⑹莂,則認(rèn)為就是當(dāng)前所匹配的子調(diào)用。源碼如下:
@Override
protected SelectResult select(MockRequest request) {
String parameter = Arrays.stream(request.getArgumentArray()).map(t -> new String((byte[]) t)).reduce((t1, t2) -> String.
Stopwatch stopwatch = Stopwatch.createStarted();
List<Invocation> subInvocations = request.getRecordModel().getSubInvocations();
List<Invocation> target = Lists.newArrayList();
// 第一步:根據(jù)方法簽名過濾掉一批簽名不同的方法
// 第二步:根據(jù)方法入?yún)ⅲ鱿嗨贫绕ヅ?,如果有匹配直接返回,并切從recordModel里刪掉已匹配過的記錄
// 第三步: 如果沒有找到,返回相似度最高的一條
}
上面就是核心流程二的簡要說明。
謎團(tuán)解密
上面核心流程一和二的解讀,可能會比較晦澀,源碼調(diào)用鏈路比較長,這里只能對大的鏈路進(jìn)行描述,相當(dāng)于是一個(gè)路標(biāo),具體的每一塊的代碼的閱讀還是不能省略的。下面對謎團(tuán)進(jìn)行解釋:
在問題排查的過程中,我進(jìn)行了抓包,在我的回放過程中核心流程一總共發(fā)起了三次回放http請求而不是一次,并且這三次返回結(jié)果是不一樣的,只有第一次是正常的,后面兩次才是因?yàn)閚o matching invocation found的500錯(cuò)誤。
所以有兩個(gè)問題:
問題一:為什么發(fā)起多次請求?
這塊的邏輯在核心流程一里:
//com.alibaba.jvm.sandbox.repeater.plugin.core.util.HttpUtil
public static Resp executeRequest(Request request) {
return executeRequest(request, 3);
}
這塊會進(jìn)行三次的失敗重試,但是我的請求并沒有失敗啊,為什么要重試?邏輯在這塊:
//okhttp3.Response#isSuccessful
public boolean isSuccessful() {
return this.code >= 200 && this.code < 300;
}
不是200,就認(rèn)為是失敗需要重試。
我錄制回放的服務(wù)是一個(gè)鑒權(quán)服務(wù),錄制的case是鑒權(quán)失敗的case,會返回401。當(dāng)然我認(rèn)為這種情況,不應(yīng)該重試,bad case也是case,錄制的時(shí)候401,回放也是401就證明case驗(yàn)證通過了。
問題二:為什么后面兩次請求是no matching invocation found的500錯(cuò)誤,而第一次不是?
還記得上面源碼分析的地方提到過,根據(jù)ParameterMatchMockStrategy的策略,匹配過的子調(diào)用,會被刪除,而基于問題一的解釋,我的回放會被重試,所以...
調(diào)用記錄只提取了一次,當(dāng)?shù)诙位胤诺臅r(shí)候,在提取的調(diào)用記錄已經(jīng)是第一次回放后修改過的記錄信息了,即和當(dāng)前回放匹配的子調(diào)用已經(jīng)被刪除了。
提取子調(diào)用的地方在com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractInvocationProcessor#doMock,上面的篇幅已經(jīng)貼過源碼了。
總結(jié)
我個(gè)人認(rèn)為repeater這塊設(shè)計(jì)的不太好:
- 對于http請求不能非200就認(rèn)為一定是失敗的,業(yè)務(wù)我錄制的就是非200的場景呢?
- 關(guān)于這個(gè)context,屬于典型的通過共享內(nèi)存做數(shù)據(jù)通信的場景,對于這種
全局變量, 聲明周期管理尤其需要謹(jǐn)慎、清晰、簡單;個(gè)人認(rèn)為內(nèi)存的設(shè)置和取用橫跨兩個(gè)流程,間隔太遠(yuǎn)了,雖然性能上做了一些權(quán)衡,但在清晰度上,我不認(rèn)為這是一個(gè)好的設(shè)計(jì)。
我猜想阿里開源的repeater版本估計(jì)要滯后于他們內(nèi)部的版本吧?總的來說,如果想把repeater應(yīng)用到自己團(tuán)隊(duì)來使用,還有很多工作要做,最關(guān)鍵的一條,先把源碼吃透,畢竟這是一個(gè)小眾并且年輕的項(xiàng)目,經(jīng)過驗(yàn)證的場景有限。如果不吃透其領(lǐng)域的概念,大概率只能玩一玩demo。
另外console目前看功能設(shè)計(jì)思路上還欠設(shè)計(jì)感,我僅說功能設(shè)計(jì)層面,比較單薄,當(dāng)然官方文檔貌似說后續(xù)會推出新的一版本的console。
如果我們團(tuán)隊(duì)使用repeater,我認(rèn)為還有如下一些事情要做:
- 配合基礎(chǔ)鏡像,開發(fā)sandbox中心化的管理平臺,可以針對sandbox、sandbox module做版本管理,類似OTA升級
- 不僅支持repeater的場景,還可以下發(fā)自定義module,這塊想想空間比較大,比如快速修復(fù)安全漏洞,這塊sandbox官方培訓(xùn)視頻里鸞伽大佬也有提到過
- 吃透源碼
- 可能需要開發(fā)一些repeater插件
- 重新設(shè)計(jì)一下console的功能,最好能和公司內(nèi)部的devops平臺打通
sandbox是一個(gè)非常棒的生態(tài),如果有余力,一定會在團(tuán)隊(duì)內(nèi)部推動(dòng)使用,相信一定能大大提升研發(fā)效率。
發(fā)散
其實(shí)復(fù)雜問題的解決方案,為了解決問題,可能會針對這個(gè)領(lǐng)域創(chuàng)造一些概念,比如購物領(lǐng)域,有商品、訂單、支付、物流等概念,因?yàn)檫@些領(lǐng)域大眾都比較熟悉,司空見慣了,所以不以為然了。
repeater本身是解決一個(gè)小眾領(lǐng)域的解決方案,他自然要有一些他自己的核心概念,所以在理解這些概念之前去嘗試分析問題,難免不識廬山真面目。
最后
向sandbox團(tuán)隊(duì)致敬!向業(yè)界標(biāo)桿致敬!