常規的網絡通信程序引用的是TCP/IP協議的傳輸層接口,對於面向連接的TCP協議,數據的傳輸形式是流(Stream),其特點是傳輸的數據無消息邊界。套接字就像管道的兩端,數據從一端流入,從另一端流出,數據的流動是連續的。
TCP 協議提供了發送和接收數據的接口,發送操作每次發送一串字節,接收操作每次接收一串字節,TCP協議保證發送數據的順序和接收數據的順序相同,比如發送數據爲0123456789(10個字節),只要套接字不出現錯誤,另一端接收到的數據一定是0123456789(10個字節)。但接收數據的操作卻不一定要和發送數據的操作一一對應。這主要是因爲 TCP 協議是字節流形式的、無消息邊界的協議,受網絡傳輸中的不確定因素的影響,不能保證每次send和recv操作能夠一一對應。上述情況可以形象表示爲:
情況1: | 發送: | 012 | 3456789 | 接收: | 012 | 3456789 | |||
情況2: | 發送: | 012 | 3456789 | 接收: | 0123456789 | ||||
情況3: | 發送: | 012 | 3456789 | 接收: | 01 | 234 | 56789 | ||
...... | |||||||||
情況N: | 發送: | 012 | 3456789 | 接收: | ...... |
有些時候傳輸的數據具有一定的協議語義,爲了保證接收方不出現解析錯誤, 編程時必須要考慮消息邊界問題, 否則就可能會出現命令格式解析錯誤、丟失命令等後果。實際應用中,解決 TCP 協議消息邊界問題的方式有三種:
第一種方式是發送固定長度的消息。該方法類似於CPU的RISC架構,每條指令的長度相同,這種處理方式最簡單,但需要仔細設計指令體系。
第二種方式是各種指令具有不同的頭標識,具體到每種指令的長度則固定,收到指令後根據頭部標識判斷指令的總長度。該方法類似於CPU的CISC架構,各種指令的長度不同,但每種指令的長度是已知固定的。
第三種方式是將消息長度在頭部進行標識,相當於對第二種方式的優化,將整條指令劃分爲頭部和數據部分,頭部長度固定,包含指令類型和數據部分長度,解析後一次讀取數據部分長度。這種方式降低了指令解析的複雜度。
以第三種方式爲例,分別使用java.nio.ByteBuffer和io.netty.buffer.ByteBuf演示指令解析的過程。
使用java.nio.ByteBuffer演示網絡流的讀取、解析過程:
// 讀取數據. int cnt = channel.read(src); if(cnt < 0) { // ... } elseif (cnt == 0) { // ... } // 解析指令循環,一次讀取可能會讀到多條指令 ByteBuffer buf = src.duplicate(); buf.flip(); while (buf.remaining() >= PDU_HEAD_LEN) { int pduLen = buf.getInt(buf.position()); if (buf.remaining() >= pduLen) { // 循環處理指令的各個字段 // ... } // buf中剩下的數據爲下一個指令的數據,下一次循環將繼續解析,直到數據不足一條指令 } // buf中剩下的數據不足一條指令,等待下次讀取數據後再次處理 // 將數據轉移到src的頭部,供讀取添加,每次都需執行 bufTmp.clear(); bufTmp.put(buf); bufTmp.flip(); src.clear(); src.put(bufTmp);
使用io.netty.buffer.ByteBuf演示網絡流的讀取、解析過程:
// 讀取數據. src.clear(); int cnt = channel.read(src); if(cnt < 0) { // ... } elseif (cnt == 0) { // ... } src.flip(); buf.writeBytes(src); // 解析指令循環,一次讀取可能會讀到多條指令 while (buf.readableBytes() >= PDU_HEAD_LEN) { int pduLen = buf.getInt(buf.readerIndex()); if (buf.readableBytes() >= pduLen) { // 循環處理指令的各個字段 // ... } // buf中剩下的數據爲下一個指令的數據,下一次循環將繼續解析,直到數據不足一條指令 } // buf中剩下的數據不足一條指令,等待下次讀取數據後再次處理 // 根據必要進行buf的壓縮,根據系統內存進行權衡,可以每500次執行一次壓縮 if (buf.writerIndex() > ALLOW_CAP) { buf.writeBytes(buf); }
結論:從上述代碼看,使用io.netty.buffer.ByteBuf進行網絡流的讀取、解析,不僅代碼簡潔、易懂而且高效。最好的代碼不是使用了豐富的註釋,而是不需過多註釋就通俗易懂。這其中的一個重要原因在於io.netty.buffer.ByteBuf將readerIndex和writerIndex分開標記,並使用了準確的接口命名帶來的。而java.nio.ByteBuffer的接口設計中,get和put則使用了同一個位置標識position,用limit標識有效數據的末尾,如果是交替讀寫,其代碼反覆調用duplicate和flip將讓人感到莫名其妙,或者使用getXXX(index)或者put(index, value)就更讓人摸不着頭腦。另外put和get的命名也讓人奇怪,爲什麼不使用write和read更直接的單詞命名呢?同樣是開源工程,顯然netty這一小步,給網絡編程的進化帶來了一大步。Netty帶來的優勢遠不至於此,後續將花時間逐步介紹。在此祝願會有更多更好的像netty這樣的開源工程涌現出來!