Socket传输文件时进行校验(简单解决TCP粘包问题)
本小菜最近频繁使用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)等这样的方法都是阻塞的,也就是说,如果没有数据,线程会一直等待,程序会在这暂停,直到有消息到来。
如果是单纯传输文件,则不必考虑粘包问题,因为即使粘了,也无所谓,反正都是写入,只不过粘包后每次写入的数据长度可能不相等而已。