基于TCP的簡單Netty自定義協議實現(萬字,全篇例子)
前言
有一陣子沒寫博客了,最近在學習
Netty
寫一個實時聊天軟件,一個高性能異步事件驅動的網絡應用框架
,我們常用的SpringBoot
一般基于Http
協議,而Netty
是沒有十分明確的協議的,不過它內置了一些常用的通信協議,當然你也可以自定義協議。
一、要求
接下來的內容默認你已經有了最基本的
Java
、Netty
、Nio
知識,如果還沒有這方面的知識的話,可以先去小破站找個視頻學習學習。
二、通信協議
*
本文提到的通信協議都是指基于TCP的應用層通信協議
,請勿理解錯誤。
1、協議基本單位
當數據在兩臺計算機上傳輸時,傳輸的數據以
比特(Bit)
為單位,就像01010100010010101...
這種,但是以比特作為傳輸單位太過精細、太過底層,所以封裝一下它,將8
個bit
封裝成一個單位,就成了字節(Byte)
,所以一個協議的基本單位是字節Byte
。同樣的,因為字節是其他大多數高級數據類型的基本組成,所以通信協議的基本單位是字節。例如一串字節流可以被解析為視頻、圖片、字符串等等,它是通用的。
也就是說,我們要自定義一個通信協議,就必須得自己解析字節。在
SpringBoot
框架中,我們在Controller
中能夠直接得到字符串、對象的原因是框架已經幫我們將字節解析好了,我們直接用就行,但是如果我們要自定義協議,就必須自力更生,自己定義格式并解析它。
2、協議格式
協議的格式不是固定的,協議只能是一個約定而不是強制要求。
舉個例子,假如你在晚自習上睡覺,你提前和同桌約定好,老師來了他就敲兩下桌子,班長來了他就敲三下桌子,那么這種約定就可以認定為是一個通信協議,但其并不是固定的,因為明晚、后晚…你可以約定其他方式,例如敲一下變成老師來了,敲兩下變成班長來了,踢你一下表示老師來了,踢你兩下表示班長來了。并不是固定的。
基于這種思想,我們可以定義一個簡單的通信協議,版本號為
V1
:
請求地址 客戶端IP 請求正文
基于這個協議,假如我們有一個請求,它請求服務器的
/test
地址,客戶端IP是192.168.1.2
,請求正文是hello
,那么這個協議看起來就像:
/test192.168.1.2hello
將它轉為字節流就是(沒有空格,空格只是為了方便查看加的):
47 116 101 115 116 49 57 50 46 49 54 56 46 49 46 50 104 101 108 108 111
服務器在解析時,就可以解析
[0,5]
個字符串為請求地址[/test]
,解析[6,11]
個字符串為客戶端IP[192.168.1.2]
,解析剩下的所有字符串為請求正文。
當然,為了形象一點舉了一個不太恰當的簡單例子,解析的不是字符串而是字節。
3、TCP的粘包半包
Ⅰ、問題描述
這個問題可能我一時半會解釋不清楚,導致粘包半包的原因很多,感興趣的可以去找找資料。
你只用知道,基于TCP時,數據并不是一次性達到的,而是分段到達的,例如我們上面舉的例子,那個協議數據:
/test192.168.1.2hello
,服務器在接收這些數據時它就有可能:
第一次收到:/test19
第二次收到:2.168.
第三次收到:1.2hello
...
它可能不會一次收全,可能要好幾次,所以我們上面定義的簡單的協議就有一個問題:
它沒有消息邊界
,就是當客戶端多次發送數據時,服務器無法知道哪些數據是哪次請求的。還是剛才的例子:
第一次收到:/test192.168.1.2he
第二次收到:llo/haha192.168.1.2hi
在這兩次數據中,客戶端分別發送了兩次請求:
/test192.168.1.2hello
和/haha192.168.1.2hi
,但是因為粘包半包的問題,服務器不知道哪條是哪條了,就會導致解析出錯。
Ⅱ、如何解決
解決這個問題有很多種方法,常見的方法有分隔符、標識請求長度等等。兩種方法我都舉個例子,你也可以自己想一個方法來解決,都是靈活的,解決方法不是固定的。
分隔符
的方法也很簡單:我們在每次請求結束時,都添加一個特殊符號,用于標識這個請求結束了,服務器在解析時,遇到這個特殊符號,就知道這個請求結束了,后面的數據是新請求的了。例如我們以$
為分隔符,服務器:
第一次收到:/test192.168.1.2
第二次收到:hello$/haha192.168.1.2hi
服務器在解析到
$
符時,就知道/test
請求已經結束了,后面的數據是屬于/haha
請求的了。但是這么做的話,有一個缺點,就是之后傳輸的正文數據中不能含有$
符,不然解析依舊出錯,你也可以定義復雜一點的符號,例如幾個符號拼接也行:@$&...
。不過我要說的是,其實你還可以用標識請求長度的方式解決。
標識請求長度
就是客戶端在傳輸請求之前,先計算好整個請求有多少個字符(為了不復雜先說成字符吧,其實是字節
),再傳輸數據,服務器在接收到數據后,會去讀取這個字段,查看整個請求有多少個字符,然后再根據這個數字讀取多少個字符。那這就需要一個字段用來專門存儲長度了。
基于這個需求,我們上面定義的協議就得小小的升級一下,變成
V2
:
請求長度 請求地址 客戶端IP 請求正文
以后,服務器會先讀取開頭的長度,再根據長度讀取后面的數據,例如我們還是剛才的
/test
請求,那么它將會變成:
21/test192.168.1.2hello
因為
/test192.168.1.2hello
總共是21
個字符,所以一開始就變為了21
,服務器一讀取到開頭的數字21
,就往下讀取21
個字符,讀完后,就默認這個請求已經結束了,再往下的就是其他請求了。
當然,你也可以將長度字段包含在內,那就是:
23/test192.168.1.2hello
這個長度可以出現在整個請求體的任何地方(除了正文),只要你在服務器/客戶端解析的時候對應解析就行了。
暫時就介紹這個兩個簡單的方法,其他的方法你可以自己想,想出來了可以自己實現,原則是能解決問題就是好辦法。
三、創建協議
1、改正上面的說法
在上面的各個例子中,我為了例子不復雜說的是解析
字符
,其實解析的是字節(Byte)
。
字符是字符,字節是字節,它們不一樣,
你
是一個字符,你好
是一個字符串,而-28(十進制)
它是一個字節,-28
、-67
、-96
它們三個字節組成了一個字符你
。
***
在UTF8
編碼下,常見的中文字符一般由3
個字節組成,不常見的一般是4
個字節組成。
***
在UTF8
編碼下,英文字符一般由1
個字節組成。
***
數字的情況稍微復雜:
1、8
位的數字一般占用1
字節,范圍從-128 到 127
2、16
位的數字一般占2
字節,范圍從-32,768 到 32,767
3、32
位數字一般占4
字節,范圍從-2,147,483,648 到 2,147,483,647
4、64
位數字一般占8
字節,范圍從-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
5、128
位數字一般占16
字節,范圍很大,不寫了。
例如在Rust
中,i32
占4
字節,它對應的Java
數字類型是int
,i64
占8
字節,對應的Java
類型是long
,以此類推。
JavaScript
的number
類型是64
位的,占8
字節,所以js
要想表達64
位以下的就有點麻煩了。
2、SP協議
解釋了上面的錯誤后,可以開始正式自定義協議了,給這個協議取個名字,就叫
SP協議
吧,Simple Protocol
,譯為簡單的協議。
Ⅰ、報文長度
首先,粘包半包的問題用長度字段解決,
4
個字節表示的32
位數字就夠用了,它的范圍是-2,147,483,648 到 2,147,483,647
,負的20億
到正的20億
,用來表示數據的話(不算負數):2147483648 / (1024 * 1024) = 2048 MB
,也就是說32
位數字所表示的數字范圍(正數)用來表示數據大小的話,可以表示2GB
的數據,一個請求根本不可能達到這么大,所以32
位的數字夠用。因為2147483648個字節就是2GB
。
那么協議開頭就是:
長度4字節
Ⅱ、魔數
在協議中添加一個魔數,用來標識這個報文是屬于
SP協議
的,服務器在網絡中讀取字節流時,如果在長度字節后沒有找到這個魔數,就證明該字節流不是SP協議
的,就可以停止讀取接下來的數據了,可以做關閉連接、丟棄數據等操作,就好像,你去坐火車去北京,火車進站時你看第二節車廂上有沒有寫目的地北京,如果寫了,那么就是你要坐的火車,如果沒寫,那就證明不是你要坐的火車,你可以等下一趟。其實就是為整個協議打一個標記。
魔數用幾個字節都行,為了不重復,建議使用
4
字節的32
位數字,那么協議的第二部分應該是:
長度4字節 魔數4字節
Ⅲ、客戶端身份
在多個客戶端連接時,服務器需要為每個客戶端頒發一個標識,用來區分不同的客戶端的請求,用幾個字節都行,為了不重復,建議使用
32
字節的uuid
作為客戶端唯一標識。
那么協議第三部分是:
長度4字節 魔數4字節 客戶端標識32位
Ⅳ、請求路徑
請求路徑這塊比較靈活,你可以使用
1
字節的8
位數字表示,也就是-128 到 127
個數字。例如,你可以規定1
就是登錄,2
就是注冊等等。
我使用的是英文字符串的方式,也就是一個字符一個字節,但是路徑長度不是不變的,它會變化。例如
/test
是5
個字節,但是/hi
是3
個字節,不能像剛才一樣用固定的長度來標識,那么就需要一個固定的路徑長度字段,用來表示后續路徑的長度。
于是協議的第四部分就是:
長度4字節 魔數4字節 客戶端標識32位 路徑長度4字節 路徑N字節
Ⅴ、請求正文
到這步后這個簡單的協議就基本完成了,后續的正文長度是不定的,但是我們有開頭的長度字段表示整個報文的長度,所以這個協議第五部分就是:
長度4字節 魔數4字節 客戶端標識32位 路徑長度4字節 路徑N字節 正文N字節
3、完整協議
協議定義到這后基本完成了,但是這只是一個簡單的例子,實際應用中肯定要復雜許多。
基于該協議,模擬一個請求,它請求
/test
路徑,使用Java字節碼文件同款的魔數0xCAFEBABE
,請求正文是hello
,那么這個協議組裝完成應該是這樣的:
| 54 | 0xCAFEBABE | 32位的UUID | 5 | /test | hello |
解釋一下,首先魔數占了
4
字節,UUID占了32
字節,路徑長度占了4
字節,路徑占了5
字節,正文占了5
字節,報文長度字段不計算在內,所以總長度是:4
+32
+4
+5
+5
=54
字節,這就是開頭54
的由來。
路徑
/test
前的5
就是表示/test
所占的5
字節。
至此,協議定義完成,任何只要遵守了這個協議的請求都能夠被
Netty
服務器識別。
四、服務器代碼實現
協議定義好了,該寫服務器代碼實現這個協議了。
1、Netty服務器啟動流程
首先得先來復習一下
Netty
的啟動流程,我們才知道如何實現這個協議。
快速啟動一個
Netty
服務器代碼:
public static void main(String[] args) {NioEventLoopGroup boss = new NioEventLoopGroup(1);// 處理連接NioEventLoopGroup worker = new NioEventLoopGroup();// 處理業務try {ChannelFuture channelFuture = new ServerBootstrap().group(boss, worker) // 設置線程組.channel(NioServerSocketChannel.class) // 使用NIO通信模式.childHandler(new ChannelInitializer<SocketChannel>() {@Overrideprotected void initChannel(SocketChannel socketChannel) throws Exception {// 在這里添加自定義的處理器}}).bind(8080).sync();// 綁定端口并啟動服務器System.out.println("Netty Server is starting...");channelFuture.channel().closeFuture().sync();// 監聽關閉} catch (InterruptedException e) {throw new RuntimeException(e);}finally {// 優雅的關閉線程組boss.shutdownGracefully();worker.shutdownGracefully();}
}
要想自定義一個協議,我們的重點在
initChannel()
方法上,它可以為Netty
添加處理器,在TCP
收到的數據傳過來的時候,處理原始的字節流數據
2、添加自定義處理器
Ⅰ、解釋ChannelInitializer的作用
為了啟動看起來清爽,我們可以將
childHandler()
所需的參數抽取出來:
public class CustomHandler extends ChannelInitializer<SocketChannel> {@Overrideprotected void initChannel(SocketChannel socketChannel) throws Exception {}
}
在
childHandler()
中傳遞.childHandler(new CustomHandler())
原始的字節流數據在達到
Netty
的時候,Netty
內部會在我們自定義的處理器之前先做一些處理,比如說將字節流數據封裝成ByteBuf
對象等等,就像SprinigBoot
我們添加自定義攔截器一樣,在我們添加的攔截器之前,SpringBoot
就已經添加了許多內部的攔截器先一步處理過數據了。
也就是說,我們自定義處理器接收到的數據,其實是經過
ByteBuf
封裝過的字節流緩沖對象,ByteBuf
對象其實就是對Java.Nio
中ByteBuffer
的進一步封裝升級。
畫個簡陋的圖,自定義處理器處理數據的整個流程看起來像這樣:
我們剛剛自定義的處理器初始化器就是這部分:
它的作用就是往處理器鏈中添加一個個的自定義處理器,在
ChannelInitializer
中添加處理器也很簡單,繼承ChannelInitializer
并實現它的initChannel
方法,再通過initChannel
的形參SocketChannel
獲取到ChannelPipeline
就可以添加了,代碼像這樣:
@Override
protected void initChannel(SocketChannel channel) throws Exception {ChannelPipeline pipeline = channel.pipeline();pipeline.addLast(處理器對象);// 添加一個個的處理器pipeline.addLast(處理器對象);// 添加一個個的處理器...
}
Ⅱ、出站(Outbound)和入站(Inbound)
我沒打錯字,是
出站
和入站
,不是出棧
、入棧
,說白了其實就是數據進入Netty
和數據從Netty
發出,進入Netty
的行為叫入站
,Netty
往外發送數據的行為叫出站
。
所以處理器可以分為三種:
入站處理器
、出站處理器
、入站出站處理器
,入站處理器
專門處理進入Netty
的數據,出站處理器
專門處理從Netty
發送的數據,而入站出站處理器
則兩者都可以。
這些處理器看起來像這樣:
***
注意,出站處理器的順序是與入站相反的,出站是從尾巴上為第1個處理器,頭為最后一個處理器,處理數據時會按照順序一個一個進行。
有一個比喻可以很好理解它們之間的關系:
處理器鏈pipeline
就像兩條相反的流水線,pipeline.addLast();
方法就像在流水線上安排一個工人,調用一次就安排一個工人,只不過一些工人專門處理過來的貨物,一些工人專門處理過去的貨物。
好了,接下來我們開始代碼實現處理器了。
Ⅲ、處理器實現
①、處理長度
報文長度字段是我們自定義協議
SP協議
的第一個字段,所以第一個處理器我們先處理長度。
首先,這個處理器肯定是入站處理器,因為是客戶端發送來的數據,我們要解析。而入站處理器怎么寫呢?
其實
Netty
為我們提供了入站出站處理器的多個模板,我們需要繼承并寫上自己的實現就行了。
最簡單的入站處理器是
SimpleChannelInboundHandler
,源代碼我就不講了,不然又要講半天。我們新建一個類繼承它,這個類就叫CustomLengthHandler
吧:
public class CustomLengthHandler extends SimpleChannelInboundHandler<ByteBuf> {@Overrideprotected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {}
}
為什么
SimpleChannelInboundHandler
的泛型是ByteBuf
?其實這里不一定是固定的(不是第一個處理器的情況),你想是什么都可以,取決于上一個處理器傳遞給當前處理器什么東西,還記得我們上面的那個流程圖嗎?:
一個一個的處理器處理完數據后,可以繼續往下傳遞數據,傳遞的數據就是自定義的。例如我從上一個處理器得到
ByteBuf
對象,我將其解析完后,封裝成一個對象MyObject
,那么我可以往下傳遞這個MyObject
對象,下一個處理器就不用再處理一遍ByteBuf
原始數據了,下一個處理器直接處理MyBoject
封裝好數據的對象就行了。類比一下,就好像上一個處理器給我當前處理器傳遞一個JSON字符串
,我當前處理器處理JSON字符串
,將其序列化為對象,并往下傳遞這個對象,那么下一個處理器就不用再處理原始的JSON字符串
了,就這么個意思。
所以
SimpleChannelInboundHandler
的泛型就是上一個處理器,傳遞給當前處理器的數據的類型,剛才解釋過了,它并不是固定的,上面的CustomLengthHandler
也可以這么寫:
public class CustomLengthHandler extends SimpleChannelInboundHandler<String> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, String str) throws Exception {// 上一個處理器給我傳遞了一個字符串}
}
也可以:
public class CustomLengthHandler extends SimpleChannelInboundHandler<Integer> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, Integer itg) throws Exception {// 上一個處理器給我傳遞了一個數字}
}
并不是固定的。
好了,不說廢話了,開始代碼實現:
因為我們是第一個入站處理器,上面我們也提到過,
Netty
內部會將數據封裝成ByteBuf
,所以我們從上一個處理器接收到的數據其實是一個ByteBuf
對象,所以第一個處理器的泛型必需為ByteBuf
:
public class CustomLengthHandler extends SimpleChannelInboundHandler<ByteBuf> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {}
}
ByteBuf
是一個字節緩沖區,我們可以從它讀取到字節數據,例如:
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {byte b = buf.readByte();// 讀取1個字節int i = buf.readInt();// 讀取4個字節,因為我們之前說了Java的int是4字節組成的buf.readShort();// 依次類推,讀取2字節buf.readLong();String str = buf.readBytes(5).toString(StandardCharsets.UTF_8);// 讀取5個字節并轉為字符串,注意編碼為UTF8
}
還記得嗎,在我們的
SP協議
中,我們定義前四個字節是報文長度,所以一開始我們先讀取4
字節:
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {int msgLength = buf.readInt();// 報文長度
}
在得到這個報文長度字段后,我們需要對
ByteBuf
的長度做一下判斷,如果它的長度小于報文長度,那就說明數據還未全部到達,那我們先不做處理,等完全到達后再做處理,代碼像這樣:
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {int msgLength = buf.readInt();if (buf.readableBytes() < msgLength){ // 緩沖區中的數據不足 msgLength 個,暫不處理return;}// 讀取 msgLength 個字節,也就是整個報文長度的字節,它得到的就是整個報文的完整字節緩沖區ByteBuf bufNew = buf.readBytes(msgLength);// 讀取 msgLength 個字節,不包含 msgLength 占用的4字節// 為了效率也可以寫為:// ByteBuf bufNew = buf.readSlice(msgLength);ctx.fireChannelRead(bufNew);// 傳遞給下一個處理器
}
為什么要這樣寫?還記得一開始我提到的
TCP粘包半包
嗎?因為數據并不是一次完整到達的,所以我們必需處理數據部分達到的情況。ByteBuf
就像一個蓄水池,從管道中一開始流進來一些水,但是這些水沒有達到蓄水池該有的蓄水量,所以不管它,等它滿足了蓄水量,我們再處理。
buf.readBytes(msgLength);
就是一次性從蓄水池(ByteBuf)中獲取msgLength
量的水(字節),并將它放到一個新的水池(ByteBuf bufNew)中,這個新的水池,包含了完整的水量(報文所有字節),接著往下傳遞這個新的水池ctx.fireChannelRead(bufNew);
定義完處理器后,還需要將它添加進處理器鏈中,還記得我們上面一開始定義的
public class CustomHandler extends ChannelInitializer<SocketChannel>
嗎?在其中添加:
public class CustomHandler extends ChannelInitializer<SocketChannel> {@Overrideprotected void initChannel(SocketChannel channel) throws Exception {ChannelPipeline pipeline = channel.pipeline();pipeline.addLast(new CustomLengthHandler());// 我們自定義的第一個長度處理器,它也是入站處理器1}
}
到此為止,這個超級簡單的報文長度處理器就寫完了,當然,這個處理器有很多的問題,它只作為演示,實際使用會有很多Bug,因為實際使用中要處理的情況有點復雜,好在
Netty
給我們提供了一個開箱即用的報文長度處理器,這也是為什么我寫得這么簡單的原因,因為只需了解簡單的原理而不需要深入探索,Netty
有現成的。
這個處理器就是
LengthFieldBasedFrameDecoder
,它的構造函數常用且重要的有5個參數,類型都是int
,我們一個一個來看:
1、第一個參數maxFrameLength
,是整個報文最大長度,說白了就是限制報文大小的,你的報文不可能無限大。
2、第二個參數lengthFieldOffset
,是你的長度字段是從第幾個字節開始的,我們的SP協議
定義了一開始就是長度字段,所以這個參數我們可以填0。
3、第三個參數lengthFieldLength
,是你的長度字段占幾個字節,我們定義的SP協議
指明了長度字段占4
個字節,所以填4就行。
4、第四個參數lengthAdjustment
,有點繞,是指沒有計算進長度,但是在報文中存在的數據的長度。例如你有數據:5ab
,因為長度字段5
占用4
個字節,b
占用1
個字節,但是沒有把a
占用的1
個字節算進來,所以這個例子中,lengthAdjustment
就得填1
,如果是6ab
,那么lengthAdjustment
就得填0
,因為你將a
占用的1
字節算進來了。
5、第五個參數initialBytesToStrip
,是指最終得到的數據要跳過幾個字節,在我們的SP協議
中,如果接下來的數據你不想要長度字段,那就可以跳過長度字段的4字節,initialBytesToStrip
就可以填4
,那么得到的數據中就不包含長度了。
基于我們的
SP協議
,最終得到的處理器應該是:
public class CustomHandler extends ChannelInitializer<SocketChannel> {@Overrideprotected void initChannel(SocketChannel channel) throws Exception {ChannelPipeline pipeline = channel.pipeline();// 長度處理器,它也是入站處理器1pipeline.addLast(new LengthFieldBasedFrameDecoder((1024 * 1024) * 50, // 限制最大報文長度為50MB0, 4, 0, 0));// 長度是從0開始的,長度字段4字節,偏移量為0,不跳過字節}
}
②、魔數校驗
長度處理完了,現在
TCP粘包半包
所帶來的問題我們解決了,接下來就是校驗魔數,新增一個入站處理器:
public class CustomMagicNumberHandler extends SimpleChannelInboundHandler<ByteBuf> {@Overrideprotected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {}
}
從
LengthFieldBasedFrameDecoder
中傳遞過來的數據依舊是ByteBuf
,所以泛型我們依舊寫成ByteBuf
,到達這里的數據,其實還是原始的報文數據,只不過經過前面的處理它一定是完整的。
做一下簡單的魔數校驗:
public class CustomMagicNumberHandler extends SimpleChannelInboundHandler<ByteBuf> {@Overrideprotected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) throws Exception {buf.readInt();// 跳過開頭的4字節長度字段int magicNumber = buf.readInt();if (magicNumber != 0xCAFEBABE){ctx.close();// 魔數不正確,直接關閉連接}ctx.fireChannelRead(buf);}
}
將處理器添加進處理器鏈:
public class CustomHandler extends ChannelInitializer<SocketChannel> {@Overrideprotected void initChannel(SocketChannel channel) throws Exception {ChannelPipeline pipeline = channel.pipeline();// 長度處理器,它也是入站處理器1pipeline.addLast(new LengthFieldBasedFrameDecoder((1024 * 1024) * 50, // 限制最大報文長度為50MB0, 4,0, 0));// 長度是從0開始的,長度字段4字節,偏移量為0,不跳過字節pipeline.addLast(new CustomMagicNumberHandler());// 魔數處理器,入站處理器2}
}
③、為客戶端生成唯一值UUID或校驗客戶端的UUID是否存在
這里我就不寫了,其實就是簡單的頒發身份證明和校驗身份證明而已,生成一個唯一值,然后存儲到服務器上,這里判斷UUID是否存在在報文中,如果不存在為其生成一個UUID并存儲,如果存在,從服務器存儲的UUID中找看能不能找得到。
后面的代碼可以根據協議定義的規則解析。
④、其他規則實現
…
Ⅳ、需要注意的點
①、ByteBuf的讀取
ByteBuf
在讀取的時候是不可回退的,就像迭代器
,迭代到下一個就不能再回去讀上一個了,要想回去重新讀,必需得重置讀取:
buf.resetReaderIndex();
然后又從最開頭開始讀取。
ByteBuf
中數據的基本單位是字節,readInt()
、readLong()
等方法實際上讀取的都是字節,只不過封裝了一下,將多個字節轉為對應Java類型了。
②、字符編碼
注意,解析協議時,客戶端與服務器都要使用相同的字符編碼,否則解析字節會對不上,因為有些字符編碼使用的字節數可能不太一樣。
③、業務邏輯處理
協議解析完后,將數據傳遞到業務邏輯時,可以使用
Netty
服務器啟動時的:
NioEventLoopGroup worker = new NioEventLoopGroup();
worker
來處理業務邏輯,worker
的本質其實是一個線程池。
其他的注意事項我想起來了后續會加,有什么問題可以評論區留言,看到會回復。
五、簡單封裝的框架
根據以上代碼的思路,我封裝了一個簡單的開源框架,主要處理
SP協議
的加強版,它包含了長度處理
、魔數
、客戶端標識
、路徑處理
、數據加密
等操作(暫未做數據驗證)。
源代碼鏈接是:simple-netty-core,丟在
gitee
上了,為什么不是GitHub
?因為我的電腦不科學上網的話,始終訪問不到GitHub
,即使修改了host
文件也訪問不到,所以干脆就將源代碼丟在gitee
上了。
這個框架是我學習
Netty
時寫的,比較簡單,基本能使用,感興趣的可以參考一下,也歡迎貢獻。
寫在最后
最后疊個甲吧:
以上內容是我個人理解,不保證全部正確,如有遺漏、錯誤等后續我會回來更新這篇博客,歡迎評論區指正。