基於tcp的應用層訊息邊界如何定義

踩刀詩人發表於2021-02-23

聊聊基於tcp的應用層訊息邊界如何定義

 

背景

2018年筆者有幸接觸一個專案要用到長連線實現雲端到裝置端訊息推送,所以藉機瞭解過相關的內容,最終是通過rabbitmq+mqtt實現了相關功能,同時在心裡也打了一個問號“如果自己實現長連線框架,該怎麼定義訊息的邊界呢?”,之後斷斷續續整理了一些,一直不成體系,最近放假了整理出來跟大家交流一番。

 

為什麼需要訊息邊界

訊息邊界並非長連線場景才需要,即使是短連線也可能需要,拿我們比較常用的http1.0協議(http1.1稍微複雜一些,後面會單獨說)來說,它基於tcp這個傳輸協議來傳遞訊息,而tcp協議又是一個面向流的協議,怎麼能識別出已經到了流的末尾呢?我們需要一種規則來定義訊息的邊界,告訴對方讀取已經到了末尾,可以結束了。

 

舉一個生活中的例子來幫助理解,2020年由於疫情的原因,平日裡都是線上下會議室開會,特殊時期演變成了線上會議。不知道大家有沒有遇到過這種情況,線下開會時通過觀察別人的動作、神情很容易知道他說完了,這時候下一個人就可以接著發言了,但是線上開會時這樣就行不通了,你如果想發言是不是得先確認下別人有沒有說完,如果直接發言可能會打斷別人,這樣很不禮貌,為什麼會出現這種情況呢?因為你不知道他到底有沒有結束髮言,更專業一點說你不知道是否到達了訊息的邊界。那怎麼改進呢,如果每個人發言完畢都顯示的告訴別人“我說完了”,是不是會好一些呢,“我說完了”這四個字就是一種訊息的邊界,給接收方傳達一種訊息結束的訊息。

 

TCP層面的分析

本節來源於https://netty.io/wiki/user-guide-for-4.x.html#wiki-h3-10

在基於流的傳輸(例如TCP / IP)中,將接收到的資料儲存到套接字接收緩衝區中。不幸的是,基於流的傳輸的緩衝區不是資料包佇列而是位元組佇列。這意味著,即使您將兩個訊息作為兩個獨立的資料包傳送,作業系統也不會將它們視為兩個訊息,而只是一堆位元組。因此,不能保證讀取的內容與遠端寫的完全一樣。例如,假設作業系統的TCP / IP棧已收到三個資料包:

 

 

 由於是基於流的協議,因此很有可能在應用程式中讀到以下四個分段:

 

 

 因此,無論是伺服器端還是客戶端,接收方都應將接收到的資料整理到一個或多個有意義的幀中,以使應用程式邏輯易於理解。在上面的示例中,正確的資料應採用以下格式:

 

 

 

訊息邊界的種類

前面介紹了訊息邊界的定義以及作用,這一節我們來看看大概會有哪幾種訊息邊界。

    1.特殊字元:比如上面提到的“我說完了”這就是一種特殊字元作為訊息邊界的例子,以特殊字元為邊界的典型產品有我們熟知的redis,客戶端和伺服器傳送的命令或資料一律以 \r\n (CRLF)結尾,還有Netty中的DelimiterBasedFrameDecoder。

    2.基於訊息長度:比如約定了訊息長度為4k位元組,接收方每次讀取4k位元組以後就認為已到達訊息邊界,結束本次讀取。當然現實中訊息長度一般是變長的,這樣就需要設計一個約定好的訊息頭部,將訊息長度作為頭部的一部分傳輸過去,以長度為邊界的例子有Dubbo、http

、websocket,Netty中的FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder等。

                  附上一張dubbo協議頭,供大家體會

 

 

 

redis如何解析完整訊息

上面說過,redis是通過\r\n來作為訊息邊界的,下面我將從原始碼角度分析下redis具體是如何處理的。
1.這裡通過telnet來傳送內聯格式命令請求redis,之所以沒有選用redis-cli是想模擬一條指令redis-server分多次收到的情況,在telnet模式下,每輸入一個字元,就會傳送給redis-server端,而redis-cli不是,它是按下回車時才會傳送整體輸入的命令,redis-server端是分多次還是一次收到完整的命令,這個取決於底層,如果想模擬分多次收到,這個過程較為複雜。

2.redis-server端每次有輸入時會觸發readQueryFromClient(networking.c)函式,對redis執行流程感興趣的可以參考我之前的文章“redis原始碼學習之工作流程初探”。

3.redis-server將收到的內容暫存到redisClient的querybuf中,如果沒有收到\r\n就等待,直到收到\r\n才將querybuf中的內容解析成指令執行。

測試步驟如下:

  • telnet 中輸入g

 

 

  •  debug檢視redisClient中querybuf的值,目前只有g

 

 

 

  • telnet中輸完get a按回車以後,redisClient中querybuf儲存了所有的輸入get a \r\n

 

 

 

 原始碼分析如下:

readQueryFromClient

void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    redisClient *c = (redisClient*) privdata;
    int nread, readlen;
    size_t qblen;
    REDIS_NOTUSED(el);
    REDIS_NOTUSED(mask);

    server.current_client = c;
    readlen = REDIS_IOBUF_LEN;
    /* If this is a multi bulk request, and we are processing a bulk reply
     * that is large enough, try to maximize the probability that the query
     * buffer contains exactly the SDS string representing the object, even
     * at the risk of requiring more read(2) calls. This way the function
     * processMultiBulkBuffer() can avoid copying buffers to create the
     * Redis Object representing the argument. */
    if (c->reqtype == REDIS_REQ_MULTIBULK && c->multibulklen && c->bulklen != -1
        && c->bulklen >= REDIS_MBULK_BIG_ARG)
    {
        int remaining = (int)((unsigned)(c->bulklen+2)-sdslen(c->querybuf));

        if (remaining < readlen) readlen = remaining;
    }

    qblen = sdslen(c->querybuf);
    if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
    c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);

    //從fd中讀取內容,讀取的內容存到redisClient的querybuf中
    nread = read(fd, c->querybuf+qblen, readlen);
    if (nread == -1) {
        if (errno == EAGAIN) {
            nread = 0;
        } else {
#ifdef _WIN32
            redisLog(REDIS_VERBOSE, "Reading from client: %s",wsa_strerror(errno));
#else
            redisLog(REDIS_VERBOSE, "Reading from client: %s",strerror(errno));
#endif
            freeClient(c);
            return;
        }
    } else if (nread == 0) {
        redisLog(REDIS_VERBOSE, "Client closed connection");
        freeClient(c);
        return;
    }
#ifdef WIN32_IOCP
    aeWinReceiveDone(fd);
#endif
    if (nread) {
        sdsIncrLen(c->querybuf,nread);
        c->lastinteraction = server.unixtime;
        if (c->flags & REDIS_MASTER) c->reploff += nread;
    } else {
        server.current_client = NULL;
        return;
    }
    if (sdslen(c->querybuf) > server.client_max_querybuf_len) {
        sds ci = getClientInfoString(c), bytes = sdsempty();

        bytes = sdscatrepr(bytes,c->querybuf,64);
        redisLog(REDIS_WARNING,"Closing client that reached max query buffer length: %s (qbuf initial bytes: %s)", ci, bytes);
        sdsfree(ci);
        sdsfree(bytes);
        freeClient(c);
        return;
    }

    //正常的讀取,繼續執行processInputBuffer
    processInputBuffer(c);
    server.current_client = NULL;
}

 

processInputBuffer

void processInputBuffer(redisClient *c) {
    /* Keep processing while there is something in the input buffer */
    while(sdslen(c->querybuf)) {
        /* Immediately abort if the client is in the middle of something. */
        if (c->flags & REDIS_BLOCKED) return;

        /* REDIS_CLOSE_AFTER_REPLY closes the connection once the reply is
         * written to the client. Make sure to not let the reply grow after
         * this flag has been set (i.e. don't process more commands). */
        if (c->flags & REDIS_CLOSE_AFTER_REPLY) return;

        /* Determine request type when unknown. */
        //判斷協議型別,如果是*開頭的就是redis的統一請求協議,否則就是內聯協議
        if (!c->reqtype) {
            if (c->querybuf[0] == '*') {
                c->reqtype = REDIS_REQ_MULTIBULK;
            } else {
                c->reqtype = REDIS_REQ_INLINE;
            }
        }

        //走內聯協議的處理函式processInlineBuffer
        if (c->reqtype == REDIS_REQ_INLINE) {
            //如果命令不完整或者解析失敗,不會執行命令
            if (processInlineBuffer(c) != REDIS_OK) break;
        } else if (c->reqtype == REDIS_REQ_MULTIBULK) {
            if (processMultibulkBuffer(c) != REDIS_OK) break;
        } else {
            redisPanic("Unknown request type");
        }

        /* Multibulk processing could see a <= 0 length. */
        if (c->argc == 0) {
            resetClient(c);
        } else {
            /* Only reset the client when the command was executed. */
            //命令解析完成,執行具體的命令對應的函式
            if (processCommand(c) == REDIS_OK)
                resetClient(c);
        }
    }
}

processInlineBuffer

int processInlineBuffer(redisClient *c) {
    char *newline;
    int argc, j;
    sds *argv, aux;
    size_t querylen;

    /* Search for end of line */
    newline = strchr(c->querybuf,'\n');

    /* Nothing to do without a \r\n */
    //最後一個字元不是\n,返回REDIS_ERR,說明命令不完整,繼續等待
    if (newline == NULL) {
        if (sdslen(c->querybuf) > REDIS_INLINE_MAX_SIZE) {
            addReplyError(c,"Protocol error: too big inline request");
            setProtocolError(c,0);
        }
        return REDIS_ERR;
    }

    /* Handle the \r\n case. */
    //繼續判斷是否是以\r\n結尾的,如果是就擷取\r\n前面的內容為引數
    if (newline && newline != c->querybuf && *(newline-1) == '\r')
        newline--;

    /* Split the input buffer up to the \r\n */
    querylen = newline-(c->querybuf);
    aux = sdsnewlen(c->querybuf,querylen);
    argv = sdssplitargs(aux,&argc);
    sdsfree(aux);
    if (argv == NULL) {
        addReplyError(c,"Protocol error: unbalanced quotes in request");
        setProtocolError(c,0);
        return REDIS_ERR;
    }

    /* Newline from slaves can be used to refresh the last ACK time.
     * This is useful for a slave to ping back while loading a big
     * RDB file. */
    if (querylen == 0 && c->flags & REDIS_SLAVE)
        c->repl_ack_time = server.unixtime;

    /* Leave data after the first line of the query in the buffer */
    sdsrange(c->querybuf,querylen+2,-1);

    /* Setup argv array on client structure */
    if (c->argv) zfree(c->argv);
    c->argv = zmalloc(sizeof(robj*)*argc);

    /* Create redis objects for all arguments. */
    for (c->argc = 0, j = 0; j < argc; j++) {
        if (sdslen(argv[j])) {
            c->argv[c->argc] = createObject(REDIS_STRING,argv[j]);
            c->argc++;
        } else {
            sdsfree(argv[j]);
        }
    }
    zfree(argv);
    return REDIS_OK;
  }

Netty FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder如何解析完整訊息

有興趣的小夥伴可以看看FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder原始碼的java doc說明,裡面講的比較詳細,在此不再重複。


總結

網路上其他作者將這類問題稱之為TCP“粘包”和“拆包”,與本文提到的訊息邊界本質上沒有太多區別,之所以沒有繼續叫“拆包”是不想把概念複雜化,回到本質其實就是需要一種機制來定義訊息的邊界,幫助應用層來正確的解析訊息。


通過redis原始碼的簡單分析,大體可以得到解決這類問題的關鍵點有以下兩步:
1.需要一種邊界的定義,基於特殊字元、基於長度等;

2.訊息接收端需要暫存收到的內容,不到邊界時等待,直到符合邊界條件(收到了特殊字元或者收到的位元組數達到約定的長度)。


雖說不是一個高大上的知識點,但是通過查資料和閱讀原始碼也解決了心中的困惑,過程中通過發散式的學習也瞭解到Netty框架針對這類問題的解決方案,算是對Netty的認識又深入了一點。

 

相關文章