jvm-sandbox-repeater http回放的“陷阱”與源碼研讀

使用任何一個(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í)候,一直是失敗的。

image.png

被回放的服務(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è)策略:

image.png

這塊,基于異常堆棧,我回放時(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)桿致敬!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容