本小菜最近頻繁使用Socket技術,遇到不少問題,有時候會心煩意亂,因為這問題並不是那么容易解決。
就拿Socket傳輸文件來說,Socket無非就是TCP、UDP協議的封裝,用它來傳輸文件,最正常不過了。但就是這么常用的東西,依然有非常多的麻煩事,而且沒有太容易的解決方案。
本小菜嘗試用Socket傳輸圖片,就遇到了如下偉大的粘包問題。
先科普一下什么是粘包(確切的說是TCP傳輸粘包)。簡單的說就是通過TCP協議發送了多條獨立的數據,但接收的時候,有些數據不幸的合並成了一個。比如客戶端向服務器發送兩個命令:”Start”、”Parameter[x.x.x]”,第一個命令的含義是開始,第二個命令的含義是啟動參數。但是服務器接收的時候,很可能不是分兩次接收,而是一次接收到”StartParameter[x.x.x]”,這下全亂了。
造成粘包的原因有很多,大致就是TCP協議本身的缺陷或數據緩沖的問題。我也不是很懂,就不誤導大家了。
小菜利用Socket傳輸圖片時,想先發送一個初始化參數,這個參數大致就是說明圖片名稱、圖片歸屬等信息。傳輸完成之后,服務器再向客戶端發送圖片的MD5值,在客戶端校驗圖片信息是否完整,保證上傳無誤。思路如下圖(一張圖勝過千言萬語):
但就是這么一個簡單的過程,實現起來可真是困難重重,從上面說明可以看出,在傳送圖片之前要先傳送命令,圖片傳完后又要傳送命令,這就引來了偉大的粘包問題!命令和圖片粘在一起!
從網上查到吐血,基本上都是回答自定義包結構,加上包頭、包尾、錯誤重發等等。這些基於字節的操作,沒有深厚的底層基礎,是搞不定的,當然,我也搞不定,項目也沒那么高的需求,果斷放棄這種做法。
經過分析,發現粘包的主要原因是客戶端連續向服務器發了三部分內容,導致數據混亂。既然是這樣,就有了如下設計:
從上圖可以看出,服務器收到初始化參數之后,先返回給客戶端一個確認信息,然后客戶端再傳送圖片,表面上看是麻煩了,但這避免了粘包問題,把命令和圖片分離開,同時又增加了系統可靠性。
還可以發現,客戶端沒有向服務器發送結束命令,也就是說服務器要自己判斷圖片是否上傳完成。怎么判斷呢?小菜的思路是客戶端獲取文件的長度,作為初始化參數傳給服務器,服務器根據接收的數據長度判斷是否上傳完成。
為什么要這樣設計?因為服務器接收圖片用的是一個阻塞循環,如果客戶端不發送結束命令,這個循環將一直阻塞下去,但客戶端一旦發送結束命令,就會和圖片數據粘包。這個矛盾解不開。。。。
看下具體代碼:
服務器核心代碼(C#):
1 try 2 { 3 string removeMsg; 4 SendBack sd = new SendBack(); 5 skClient.ReceiveTimeout = 30; //設置接收超時,超時說明上傳圖片失敗 6 7 //接收初始化數據(利用Receive的阻塞性等待初始化數據) 8 receiveN = skClient.Receive(receiveData); 9 10 //解析客戶端消息 11 removeMsg = Encoding.UTF8.GetString(receiveData, 0, receiveN); 12 13 //獲取文件長度 14 long fileLength = Convert.ToInt64(removeMsg.Split(new char[] { '|' })[1]); 15 16 //回發確認信息 17 sd.SendToClient(skClient, "T"); 18 19 //寫入圖片處理 20 using (Stream pic = File.Create("E:\\" + removeMsg.Split(new char[] { '|' })[0])) 21 { 22 //臨時長度變量 23 long tempLength = 0; 24 25 //接收圖片包(再次阻塞,接收圖片) 26 while ((receiveN = skClient.Receive(receiveData)) > 0)//接收 27 { 28 tempLength += receiveN; 29 30 //寫入圖片 31 pic.Write(receiveData, 0, receiveN); 32 pic.Flush(); 33 34 //判斷文件是否接收完全 35 if (tempLength == fileLength) 36 { 37 //接收完全則退出循環 38 break; 39 } 40 } 41 42 //釋放文件流 43 pic.Close(); 44 pic.Dispose(); 45 } 46 47 //回發圖片MD5校驗碼 48 MD5Helper md5 = new MD5Helper(); 49 sd.SendToClient(skClient, md5.md5_hash("E:\\" + removeMsg.Split(new char[] { '|' })[0])); 50 } 51 catch (SocketException se) 52 { 53 //關閉客戶端連接 54 //超時有兩種可能,一是發送數據包丟失,導致無法跳出循環而超時;二是網絡或客戶端異常。無論哪種情況,我們都有充分的理由斷開連接,標志上傳圖片失敗 55 skClient.Close(); 56 skClient.Dispose(); 57 } 58 catch (Exception ex) 59 { 60 //異常掉線處理:得到掉線客戶端的IP地址傳遞給接口實現類 61 iGetClientData.getClientIP(((IPEndPoint)skClient.RemoteEndPoint).Address + ex.ToString()); 62 }
客戶端核心代碼(Java):
1 try { 2 socket = new Socket(); 3 socket.connect(new InetSocketAddress("192.168.24.177", 5522),10 * 1000); 4 dos = new DataOutputStream(socket.getOutputStream()); 5 6 File file = new File("D:\\1.jpg"); 7 fis = new FileInputStream(file); 8 sendBytes = new byte[1024]; 9 10 /*發送初始化數據*/ 11 String startMessage = "111111.jpg|" + file.length(); 12 byte[] bytStartMessage = startMessage.getBytes("UTF-8"); 13 dos.write(bytStartMessage,0,bytStartMessage.length); 14 15 /*判斷服務器是否收到初始化數據*/ 16 String rtSingle = rsm.read(socket); 17 if("T".equals(rtSingle)){ 18 /*寫入圖片*/ 19 while ((length = fis.read(sendBytes, 0, sendBytes.length)) > 0) { 20 dos.write(sendBytes, 0, length); 21 dos.flush(); 22 } 23 } 24 25 /*發送結束信息*/ 26 /*String endMessage = "End"; 27 byte[] bytEndMessage = endMessage.getBytes("UTF-8"); 28 dos.write(bytEndMessage,0,bytEndMessage.length);*/ 29 30 /*獲取本地圖片的MD5校驗碼,轉成大寫形式*/ 31 String localPicMD5 = MD5Helper.getFileMD5(file).toUpperCase(); 32 /*接收回發的MD5校驗碼,轉成大寫形式*/ 33 String backPicMD5 = rsm.read(socket).toUpperCase(); 34 /*對比校驗碼,判斷照片是否上傳成功*/ 35 if(localPicMD5.equals(backPicMD5)){ 36 System.out.println("succes!"); 37 }else{ 38 System.out.println("fail!"); 39 } 40 } catch (SocketException se) { 41 /*上傳失敗!*/ 42 }catch(Exception e){ 43 e.printStackTrace(); 44 }finally { 45 try{ 46 if (dos != null) 47 dos.close(); 48 if (fis != null) 49 fis.close(); 50 if (socket != null) 51 socket.close(); 52 } catch (Exception e) { 53 e.printStackTrace(); 54 } 55 }
通過代碼相信讀者能明白小菜的意思,服務器通過判斷接收數據的總長度,主動用break跳出while循環,跳出循環后服務器才可以向客戶端發送圖片MD5校驗碼。
稍加思考,會發現這樣設計有一個小問題!假設一旦網絡出現問題,導致數據包丟失,就會造成服務器端接收到的圖片數據小於實際的長度,這樣一來就沒辦法跳出while循環,也就無法向客戶端發送MD5校驗碼,導致客戶端一直阻塞。
考慮到這個問題,小菜在代碼中設置了Receive超時,服務器端一旦超過指定時間沒有收到數據,依然是阻塞狀態,那么就拋出異常,拋出異常后斷開和客戶端的連接,代表傳送圖片失敗。因為在正常傳輸的情況下,不可能很長時間都收不到數據。如果超時,除了傳輸過程中數據包丟失無法跳出while,就是網絡異常,無論是哪種情況,都可以認為本次傳輸失敗。
好啦,就講到這,小菜水平有限,望高手勿噴。
PS:
寫Socket程序一定要時刻清醒:Receive(C#)、read(Java)等這樣的方法都是阻塞的,也就是說,如果沒有數據,線程會一直等待,程序會在這暫停,直到有消息到來。
如果是單純傳輸文件,則不必考慮粘包問題,因為即使粘了,也無所謂,反正都是寫入,只不過粘包后每次寫入的數據長度可能不相等而已。