聊聊基於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的認識又深入了一點。