前言
記得前段時間我們生產上的一個閘道器出現了故障。
這個閘道器邏輯非常簡單,就是接收客戶端的請求然後解析報文最後傳送簡訊。
但這個請求並不是常見的 HTTP ,而是利用 Netty 自定義的協議。
有個前提是:閘道器是需要讀取一段完整的報文才能進行後面的邏輯。
問題是有天突然發現閘道器解析報文出錯,檢視了客戶端的傳送日誌也沒發現問題,最後通過日誌發現收到了許多不完整的報文,有些還多了。
於是想會不會是 TCP 拆、粘包帶來的問題,最後利用 Netty 自帶的拆包工具解決了該問題。
這便有了此文。
TCP 協議
問題雖然解決了,但還是得想想原因,為啥會這樣?打破砂鍋問到底才是一個靠譜的程式設計師。
這就得從 TCP 這個協議說起了。
TCP 是一個面向位元組流的協議,它是性質是流式的,所以它並沒有分段。就像水流一樣,你沒法知道什麼時候開始,什麼時候結束。
所以他會根據當前的套接字緩衝區的情況進行拆包或是粘包。
下圖展示了一個 TCP 協議傳輸的過程:
傳送端的位元組流都會先傳入緩衝區,再通過網路傳入到接收端的緩衝區中,最終由接收端獲取。
當我們傳送兩個完整包到接收端的時候:
正常情況會接收到兩個完整的報文。
但也有以下的情況:
接收到的是一個報文,它是由傳送的兩個報文組成的,這樣對於應用程式來說就很難處理了(這樣稱為粘包)。
還有可能出現上面這樣的雖然收到了兩個包,但是裡面的內容卻是互相包含,對於應用來說依然無法解析(拆包)。
對於這樣的問題只能通過上層的應用來解決,常見的方式有:
- 在報文末尾增加換行符表明一條完整的訊息,這樣在接收端可以根據這個換行符來判斷訊息是否完整。
- 將訊息分為訊息頭、訊息體。可以在訊息頭中宣告訊息的長度,根據這個長度來獲取報文(比如 808 協議)。
- 規定好報文長度,不足的空位補齊,取的時候按照長度擷取即可。
以上的這些方式我們在 Netty 的 pipline 中里加入對應的解碼器都可以手動實現。
但其實 Netty 已經幫我們做好了,完全可以開箱即用。
比如:
-
LineBasedFrameDecoder
可以基於換行符解決。 -
DelimiterBasedFrameDecoder
可基於分隔符解決。 -
FixedLengthFrameDecoder
可指定長度解決。
字串拆、粘包
下面來模擬一下最簡單的字串傳輸。
還是在之前的
https://github.com/crossoverJie/netty-action
進行演示。
在 Netty 客戶端中加了一個入口可以迴圈傳送 100 條字串報文到接收端:
/**
* 向服務端發訊息 字串
* @param stringReqVO
* @return
*/
@ApiOperation("客戶端傳送訊息,字串")
@RequestMapping(value = "sendStringMsg", method = RequestMethod.POST)
@ResponseBody
public BaseResponse<NULLBody> sendStringMsg(@RequestBody StringReqVO stringReqVO){
BaseResponse<NULLBody> res = new BaseResponse();
for (int i = 0; i < 100; i++) {
heartbeatClient.sendStringMsg(stringReqVO.getMsg()) ;
}
// 利用 actuator 來自增
counterService.increment(Constants.COUNTER_CLIENT_PUSH_COUNT);
SendMsgResVO sendMsgResVO = new SendMsgResVO() ;
sendMsgResVO.setMsg("OK") ;
res.setCode(StatusEnum.SUCCESS.getCode()) ;
res.setMessage(StatusEnum.SUCCESS.getMessage()) ;
return res ;
}
/**
* 傳送訊息字串
*
* @param msg
*/
public void sendStringMsg(String msg) {
ByteBuf message = Unpooled.buffer(msg.getBytes().length) ;
message.writeBytes(msg.getBytes()) ;
ChannelFuture future = channel.writeAndFlush(message);
future.addListener((ChannelFutureListener) channelFuture ->
LOGGER.info("客戶端手動發訊息成功={}", msg));
}
服務端直接列印即可:
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
LOGGER.info("收到msg={}", msg);
}
順便提一下,這裡加的有一個字串的解碼器:.addLast(new StringDecoder())
其實就是把訊息解析為字串。
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
out.add(msg.toString(charset));
}
在 Swagger 中呼叫了客戶端的介面用於給服務端傳送了 100 次訊息:
正常情況下接收端應該列印 100 次 hello
才對,但是檢視日誌會發現:
收到的內容有完整的、多的、少的、拼接的;這也就對應了上面提到的拆包、粘包。
該怎麼解決呢?這便可採用之前提到的 LineBasedFrameDecoder
利用換行符解決。
利用 LineBasedFrameDecoder 解決問題
LineBasedFrameDecoder
解碼器使用非常簡單,只需要在 pipline 鏈條上新增即可。
//字串解析,換行防拆包
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new StringDecoder())
建構函式中傳入了 1024 是指報的長度最大不超過這個值,具體可以看下文的原始碼分析。
然後我們再進行一次測試看看結果:
注意,由於 LineBasedFrameDecoder 解碼器是通過換行符來判斷的,所以在傳送時,一條完整的訊息需要加上
。
最終的結果:
仔細觀察日誌,發現確實沒有一條被拆、粘包。
LineBasedFrameDecoder 的原理
目的達到了,來看看它的實現原理:
- 第一步主要就是
findEndOfLine
方法去找到當前報文中是否存在分隔符,存在就會返回分隔符所在的位置。 - 判斷是否需要丟棄,預設為 false ,第一次走這個邏輯(下文會判斷是否需要改為 true)。
- 如果報文中存在換行符,就會將資料擷取到那個位置。
- 如果不存在換行符(有可能是拆包、粘包),就看當前報文的長度是否大於預設的長度。大於則需要快取這個報文長度,並將 discarding 設為 true。
- 如果是需要丟棄時,判斷是否找到了換行符,存在則需要丟棄掉之前記錄的長度然後擷取資料。
- 如果沒有找到換行符,則將之前快取的報文長度進行累加,用於下次拋棄。
從這個邏輯中可以看出就是尋找報文中是否包含換行符,並進行相應的擷取。
由於是通過緩衝區讀取的,所以即使這次沒有換行符的資料,只要下一次的報文存在換行符,上一輪的資料也不會丟。
高效的編碼方式 Google Protocol
上面提到的其實就是在解碼中進行操作,我們也可以自定義自己的拆、粘包工具。
編解碼的主要目的就是為了可以編碼成位元組流用於在網路中傳輸、持久化儲存。
Java 中也可以實現 Serializable 介面來實現序列化,但由於它效能等原因在一些 RPC 呼叫中用的很少。
而 Google Protocol
則是一個高效的序列化框架,下面來演示在 Netty 中如何使用。
安裝
首先第一步自然是安裝:
在官網下載對應的包。
本地配置環境變數:
當執行 protoc --version
出現以下結果表明安裝成功:
定義自己的協議格式
接著是需要按照官方要求的語法定義自己的協議格式。
比如我這裡需要定義一個輸入輸出的報文格式:
BaseRequestProto.proto:
syntax = "proto2";
package protocol;
option java_package = "com.crossoverjie.netty.action.protocol";
option java_outer_classname = "BaseRequestProto";
message RequestProtocol {
required int32 requestId = 2;
required string reqMsg = 1;
}
BaseResponseProto.proto:
syntax = "proto2";
package protocol;
option java_package = "com.crossoverjie.netty.action.protocol";
option java_outer_classname = "BaseResponseProto";
message ResponseProtocol {
required int32 responseId = 2;
required string resMsg = 1;
}
再通過
protoc --java_out=/dev BaseRequestProto.proto BaseResponseProto.proto
protoc 命令將剛才定義的協議格式轉換為 Java 程式碼,並生成在 /dev
目錄。
只需要將生成的程式碼拷貝到我們的專案中,同時引入依賴:
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.4.0</version>
</dependency>
利用 Protocol 的編解碼也非常簡單:
public class ProtocolUtil {
public static void main(String[] args) throws InvalidProtocolBufferException {
BaseRequestProto.RequestProtocol protocol = BaseRequestProto.RequestProtocol.newBuilder()
.setRequestId(123)
.setReqMsg("你好啊")
.build();
byte[] encode = encode(protocol);
BaseRequestProto.RequestProtocol parseFrom = decode(encode);
System.out.println(protocol.toString());
System.out.println(protocol.toString().equals(parseFrom.toString()));
}
/**
* 編碼
* @param protocol
* @return
*/
public static byte[] encode(BaseRequestProto.RequestProtocol protocol){
return protocol.toByteArray() ;
}
/**
* 解碼
* @param bytes
* @return
* @throws InvalidProtocolBufferException
*/
public static BaseRequestProto.RequestProtocol decode(byte[] bytes) throws InvalidProtocolBufferException {
return BaseRequestProto.RequestProtocol.parseFrom(bytes);
}
}
利用 BaseRequestProto
來做一個演示,先編碼再解碼最後比較最終的結果是否相同。答案肯定是一致的。
利用 protoc 命令生成的 Java 檔案裡已經幫我們把編解碼全部都封裝好了,只需要簡單呼叫就行了。
可以看出 Protocol 建立物件使用的是構建者模式,對使用者來說清晰易讀,更多關於構建器的內容可以參考這裡。
更多關於 Google Protocol
內容請檢視官方開發文件。
結合 Netty
Netty 已經自帶了對 Google protobuf 的編解碼器,也是隻需要在 pipline 中新增即可。
server 端:
// google Protobuf 編解碼
.addLast(new ProtobufDecoder(BaseRequestProto.RequestProtocol.getDefaultInstance()))
.addLast(new ProtobufEncoder())
客戶端:
// google Protobuf 編解碼
.addLast(new ProtobufDecoder(BaseResponseProto.ResponseProtocol.getDefaultInstance()))
.addLast(new ProtobufEncoder())
稍微注意的是,在構建 ProtobufDecoder 時需要顯式指定解碼器需要解碼成什麼型別。
我這裡服務端接收的是 BaseRequestProto,客戶端收到的是服務端響應的 BaseResponseProto 所以就設定了對應的例項。
同樣的提供了一個介面向服務端傳送訊息,當服務端收到了一個特殊指令時也會向客戶端返回內容:
@Override
protected void channelRead0(ChannelHandlerContext ctx, BaseRequestProto.RequestProtocol msg) throws Exception {
LOGGER.info("收到msg={}", msg.getReqMsg());
if (999 == msg.getRequestId()){
BaseResponseProto.ResponseProtocol responseProtocol = BaseResponseProto.ResponseProtocol.newBuilder()
.setResponseId(1000)
.setResMsg("服務端響應")
.build();
ctx.writeAndFlush(responseProtocol) ;
}
}
在 swagger 中呼叫相關介面:
在日誌可以看到服務端收到了訊息,同時客戶端也收到了返回:
雖說 Netty 封裝了 Google Protobuf 相關的編解碼工具,其實檢視它的編碼工具就會發現也是利用上文提到的 api 實現的。
Protocol 拆、粘包
Google Protocol 的使用確實非常簡單,但還是有值的注意的地方,比如它依然會有拆、粘包問題。
不妨模擬一下:
連續傳送 100 次訊息看服務端收到的怎麼樣:
會發現服務端在解碼的時候報錯,其實就是被拆、粘包了。
這點 Netty 自然也考慮到了,所以已經提供了相關的工具。
//拆包解碼
.addLast(new ProtobufVarint32FrameDecoder())
.addLast(new ProtobufVarint32LengthFieldPrepender())
只需要在服務端和客戶端加上這兩個編解碼工具即可,再來傳送一百次試試。
檢視日誌發現沒有出現一次異常,100 條資訊全部都接收到了。
這個編解碼工具可以簡單理解為是在訊息體中加了一個 32 位長度的整形欄位,用於表明當前訊息長度。
總結
網路這塊同樣是計算機的基礎,由於近期在做相關的工作所以接觸的比較多,也算是給大學補課了。
後面會接著更新 Netty 相關的內容,最後會產出一個高效能的 HTTP 以及 RPC 框架,敬請期待。
上文相關的程式碼:
https://github.com/crossoverJie/netty-action
號外
最近在總結一些 Java 相關的知識點,感興趣的朋友可以一起維護。
歡迎關注公眾號一起交流: