學習總結,轉自:http://suwish.com/html/java-tcp-socket-stream-packet-split.html
關鍵字:java socket tcp 分包 粘包
前不久寫的socket程序,服務器是java的,客戶端是flex。一開始就想過所謂的拆分數據包的問題,因為數據包結構是自己定義的,也簡單的寫了幾行數據包的驗證。關鍵是測試中完全沒有發生什么情況,但是發布到外網之后卻出現一些非常奇怪的問題,典型的就是通信過一定時間之后,數據包驗證那塊就會出錯然后就拋棄了數據包,這就是所謂的“丟包”吧,但是我的是TCP的socket,所謂因為網絡問題導致的數據包沒有發送與接收成功這種問題應該是不可能出現的。
於是看了幾篇文正,發現這種現象被稱作“粘包”,我覺得還是挺貼切的。經過一定時間的思考、和測試,大概了解了其中的原理,按照現在的此時情況來看,應該是沒什么問題。於是在此總結一下,如果哪天我發現一些新問題或更好的方法,還是會來繼續補充這篇文章的。當然各位路過的前輩覺得其中存在錯誤什么的也請指出。
首先在將程序之前,還是先說一下TCP的通信。TCP和UDP的最大區別就是TCP維護了連接狀態,而這個狀態我們可以理解為一個暢通的流通道,即stream,當然流的傳輸內容歸根結底還是byte。於是將流的通信進行假設,假設存在一條引水管道,從遠方輸水過來,我們在這邊等待水的到來,並使用容器接收流出來的水。
此時存在一下幾種情況:
- 假設這個輸水管道在操作過程中不會斷掉。
- 先進先出,先流進管道的水一定是先到達。
- 某一狀態,(在輸水管沒有斷掉是)流量無法保證,甚至某段時間沒有水。
- 我們的容器(緩沖區)大小固定,即每次接收的水量存在最大值,超多將無法接收。
在以上情況作為前提,再回歸編碼。TCP的socket可以通信的前提是連接沒有斷開,連接斷開事件可以從兩種情況進行判斷。流斷開,這read()為-1,SocketException或者IoException。分包的前提是socket可以正常通信,不論網絡延時多么嚴重,這些TCP協議會去處理。我們僅僅關心通信既可。現在最優情況,即實驗室環境,或者是內網,服務器與客戶端延時不會大於一毫秒,此時只要我們接收輸水的容器夠大,基本就可以完成正常通信。
但是互聯網情況就非常復雜,數據包要經過無數網絡軟硬件設備,延時不可避免,但是TCP協議會像輸水管道那樣,保障數據包的順序和保證不會丟失。所以這時我們可以控制的只有接水的容器。查看一些簡單的TCP通信的知識,網絡數據在傳輸的時候存在緩存現象,簡單的說,就是連續發送N個數據包。他們可能被緩存起來一起被發送。這種情況就是粘包,當然對於接收端來說,我們不能保證 每次都能正好的完整的接收數據包,更多時候是x.5個數據包。
再次回到輸水的模型,我們的容器等待水的到來,現在存在超時時間即每次等待水來有一個最大時間,超過這個時間,即使沒有接到一滴水我們也要處理這個水桶。所以我們得到水桶的水理論上是大於等於0,小於等於水桶的容量。我想這樣說應該可以很清楚的表達清楚了吧。現在開始從代碼角度來說。
現在我們有一個byte[] buffer = new byte[MAX_LEN],即數據包讀取緩沖區,int len = connection.read(buffer)。read方法使用buffer讀數據流,len為實際讀出的數據長度,此長度大於等於0,小於等於MAX_LEN。等於0自然不去處理,等於-1認為連接斷開,當然read方法會拋出異常,即當讀取數據過程中,連接出錯。
現在我們獲得一個buffer,即緩沖區。里面存在len長度的可用數據。我們要做的就是根據自己的協議結構將這個buffer轉化為遵循我們自己的協議的packet。進而交由后面的業務邏輯代碼處理。
此時我們定義自己的通信協議一個byte的包頭,用於數據吧合法性驗證,兩byte數據包長(一般用4byte,即一個int),剩下內容為可變長度的數據包體。現在我們拿到buffer,這時候就有分包(粘包),和組包(數據包沒有接收完整)兩種情況。感覺似乎比較頭疼,但是實際上獲得packet我們緊緊需要知道的是數據包的真實長度,即2byte的內容,轉為short后假設為PACKET_LEN。然后我們只要拆分和等待PACKET_LEN個長度的byte即可,那才是我們班真正需要的東西。當然,這個過程我曾經陷入過誤區,然后經人指點后才發現我關注了很多沒用的東西,結果增加了代碼的復雜度。之后就上代碼了,現在我的結構是服務器使用nio,然后nio框架將buffer封裝為java.nio.ByteBuffer。其底層實現還是固定長度的byte[],它做的僅僅是封裝了一些byte操作的快捷方法而已。既然它封裝了,我們就要利用一下。
- ByteBuffer.remaining(),此方法最給力,返回剩余的可用長度,此長度為實際讀取的數據長度,最大自然是底層數組的長度。於是這樣看來這個ByteBuffer更像是一個可標記的流。
- ByteBuffer.get(byte[]),從ByteBuffer中讀取byte[]。
首先呢把ByteBuffer當做流來處理,即read(ByteBuffer)之后ByteBuffer.flip()。此時重置到流的前端。這個java代碼是按照最原始的思路寫的,寫的比較難看,但是比較清晰。有時間再優化下算法,應該可以寫的再漂亮一點。
public List<byte[]> getPacket(ByteBuffer buffer) throws Exception{ pLink.clear(); try{ while(buffer.remaining() > 0){ if(packetLen == 0){ //此時存在兩種情況及在數據包包長沒有獲得的情況下可能已經獲得過一次數據包 if(buffer.remaining() + _packet.length < 3){ byte[] temp = new byte[buffer.remaining()]; buffer.get(temp); _packet = PacketUtil.joinBytes(_packet , temp); break; //保存包頭 }else{if(_packet.length == 0){ buffer.get(); packetLen = PacketUtil.parserBuffer2ToInt(buffer); }else if(_packet.length == 1){ packetLen = PacketUtil.parserBuffer2ToInt(buffer); } else if(_packet.length == 2){ byte[] lenByte = new byte[2]; lenByte[0] = _packet[1]; lenByte[1] = buffer.get(); packetLen = PacketUtil.parserBytes2ToInt(lenByte); } else{ packetLen = PacketUtil.parserBytes2ToInt(_packet , 1); } } } if(_packet.length <= 3){ //此時_packet 沒有有用數據,所需數據都在緩沖區中 if(buffer.remaining() < packetLen){ _packet = new byte[buffer.remaining()]; buffer.get(_packet); }else{ byte[] p = new byte[packetLen]; buffer.get(p); pLink.add(p); packetLen = 0; _packet = new byte[0]; } }else { if(buffer.remaining() + _packet.length - 3 < packetLen){ //剩余數據包不足一個完整包,保存后等待寫一個 byte[] temp = new byte[buffer.remaining()]; buffer.get(temp); _packet = PacketUtil.joinBytes(_packet , temp);break; }else{ //數據包完整或者多出 byte[] temp = new byte[packetLen - ( _packet.length - 3) ]; buffer.get(temp); pLink.add(PacketUtil.subPacket(PacketUtil.joinBytes(_packet , temp))); _packet = new byte[0]; packetLen = 0; } } } }catch(Exception e){ System.out.println("..GETPACKET packetLen = " + packetLen + " _packet.length = " + _packet.length); throw e; } return pLink; }
如果覺得不好看,可以先看下面的Flex首先方法,思路是一樣的,但是看起來非常簡單。
//接收到消息 private function socketDataHandler(event:ProgressEvent):void{ try{ while(true){ if(packet_len == 0){ if(socket.bytesAvailable < 3) return ; var temp : ByteArray = new ByteArray(); socket.readBytes(temp , 0 , 3); packet_len = PacketUtil.parserBytesToInt2(temp , 1); } if(socket.bytesAvailable < packet_len) return; var buffer : ByteArray = new ByteArray(); socket.readBytes(buffer , 0 , packet_len); packet_len = 0; buffer.position = 0; packetArrive(buffer); } }catch(e : Error){ trace(e.message); } }
果然貼代碼太占用篇幅了。首先拿Flex說,Flex庫和Flash實際是一樣的。flex中的socket中有自己的緩沖區,所以自己只管按時讀數據即可。所以我們就等packet的長度,等待長度之后等這個長度的字節,簡明扼要。但是java就不同,java的底層緩沖區我們沒辦法控制,於是就需要自己寫一個東西緩沖沒有接收完整的數據。就是代碼中的_packet,他是一個初始化長度為0的byte[]。思想就是等我們需要的東西,等到就讀出來,剩下不完整的就存起來和下一次合並再判斷。當然這種東西都是有規律的,我覺得還沒有發現這個規律,如果發現的話,代碼長度應該會像Flex那么簡明吧。
規律這種東西真的很美妙,我們總結出規律之后就完全跳出了復雜和容易出錯的步驟,進而去關注更重要的事情。就像我獲得packet之后,剛開始算數組索引,由於是可變長度,里面的內容也是定義的可變數據,所以算數據索引算的非常痛苦。之后我后來發現了所以規律,簡單的說就是index += packet[index] + n。然后就完全從數據結構里面擺脫出來。
嗯,差不多就是這個樣子了。