大家发现我们前面举的例子,发送数据时都是直接 send,接收数据时都是直接 recv,前面的例子只适合作为 demo 来学习,而这种写法在实际项目开发中是不切合实际的。在实际项目开发中,编写基于 TCP 协议通信的应用程序,最容易犯的错误就是粘包分包问题,本节课我们详细分析粘包分包的原因以及如何解决粘包分包。
经过前面我们对 TCP 协议的详细分析,我们知道调用套接字的 send 发送数据时,是把数据发送到本机操作系统维护的 TCP 协议栈发送缓冲区中,而发送缓冲区"28LLLLLLLL"什么时候把数据发送到网络上,则是由操作系统规定的。而调用 recv 接收数据时,是从本机操作系统的 TCP 协议栈接收缓冲区内取数据,我们可以自己设置参数一次性最大取多少。应用层的 send 和 recv 无法一一对应,这是导致粘包分包的根本原因所在。
分包原因
我们目前的互联网结构是基于以太网的,一个以太网包只能传输 1500 字节长度的数据,而这其中,IP 头和 TCP 头各占去了 20 个字节,因此,有效载荷为 1460。
如果你要发的一段数据的长度超过了 1460,假设为 3000,那么必然被分成多个以太网包发送过来,对于接收方来说,如果每次接受 1024 个字节,则需要多次 recv 才能把整段数据接收。当然,也有可能你发送的数据不足 1460,由于网络问题,协议栈发送缓冲区也有可能给把数据分成多次发送到客户端。
举个栗子:发送方发送字符串“老鸟python”,接收方却接收到了三个字符串“老”和“鸟pyth”和“on”。
''' 此为服务器程序 ''' import socket sv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建套接字 sv_ipport = ("0.0.0.0", 8889) # 监听的 ip 和端口 sv_socket.bind(sv_ipport) # 绑定服务地址 sv_socket.listen(5) # 协议栈缓冲区最大套接字存放个数 print('启动服务器,等待客户端连接......') server_socket, addr = sv_socket.accept() while True: client_data = server_socket.recv(1024).decode("utf-8") # 接收信息 if (client_data == "exit") or (not client_data): # 判断客户端是否申请结束会话或客户端是否退出 break print("收到客户端信息:%s" % client_data) server_socket.close() # 关闭监听的服务器套接字
''' 此为客户端程序 ''' import socket import time client_socket = socket.socket() # 创建套接字 ip_port = ("127.0.0.1", 8889) # 要连接的服务器的 ip 和端口 client_socket.connect(ip_port) # 连接服务器 ''' 假如我们的代码是:client_socket.send("老鸟python".encode("utf-8")) 我们本意是把"老鸟python"看成一个整体发送给客户端。 结果协议栈由于某种原因给我们分为三次发送。 下面的代码我们模拟协议栈的发送行为。 ''' # 模拟协议栈第 1 次发送 client_socket.send("老".encode("utf-8")) # 发送信息 time.sleep(1) # 模拟协议栈第 2 次发送 client_socket.send("鸟pyth".encode("utf-8")) # 发送信息 time.sleep(1) # 模拟协议栈第 3 次发送 client_socket.send("on".encode("utf-8")) # 发送信息 client_socket.close() # 关闭套接字
运行结果:
启动服务器,等待客户端连接...... 收到客户端信息:老 收到客户端信息:鸟pyth 收到客户端信息:on
粘包原因
粘包则和分包相反,你要发送的数据长度很短,比如只有 20 个字节左右,如果你以非常快的速度发送,那么有可能一个以太网包里包含了好几段数据,他们是被一起发送过来的,这时接收方 recv 得到的数据是好几段数据连在一起,无法分开。
举个栗子:发送方发送两个字符串“老鸟”和“python”,接收方却一次性接收到了“老鸟python”。
''' 此为服务器程序 ''' import socket sv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建套接字 sv_ipport = ("0.0.0.0", 8889) # 监听的 ip 和端口 sv_socket.bind(sv_ipport) # 绑定服务地址 sv_socket.listen(5) # 协议栈缓冲区最大套接字存放个数 print('启动服务器,等待客户端连接......') server_socket, addr = sv_socket.accept() while True: client_data = server_socket.recv(1024).decode("utf-8") # 接收信息 if (client_data == "exit") or (not client_data): # 判断客户端是否申请结束会话或客户端是否退出 break print("收到客户端信息:%s" % client_data) server_socket.close() # 关闭监听的服务器套接字
''' 此为客户端程序 ''' import socket client_socket = socket.socket() # 创建套接字 ip_port = ("127.0.0.1", 8889) # 要连接的服务器的 ip 和端口 client_socket.connect(ip_port) # 连接服务器 ''' 我们的本意是发送两个消息包,send 先把这两个消息包发送到协议栈缓冲区。 但是我们的协议栈缓冲区并没有消息包的概念,所有的信息到了协议栈缓冲区都变成了数据流。 协议栈缓冲区把我们应用层认为的信息包作为数据流一次性发送给客户端了。 ''' client_socket.send("老鸟".encode("utf-8")) # 发送信息 client_socket.send("python".encode("utf-8")) # 发送信息 client_socket.close() # 关闭套接字
运行结果:
启动服务器,等待客户端连接...... 收到客户端信息:老鸟python
关于粘包分包的处理方案,在面试的时候经常被问到,往往很少有面试者回答的令人满意,下面我们来正确的分析解决粘包分包的算法,并编码解决粘包分包问题。
算法分析
一种方法就是约定好数据的长度,这样一来,接收方就可以根据提前约定好的数据长度来解析数据了。但这样会产生许多不必要的麻烦了,比如实际发送数据小于约定长度时需要填充,这样也造成了传输上的浪费。面试的时候,这种解决方案基本上是 0 分。
另一种方法是,对于用户每次用 send 发送的内容,我们都人为的添加一个特殊标识,我们自己来定义它是一个完整的消息包,这样一来,接收方可以根据约定的这个特殊字符来分辨一次完整的消息,但是,如果发送的消息内容里面有这个特殊字符(因为用户发送的内容你无法预测),接收方就会解析错误。这种方案面试也是 0 分。
正确的方法是,对要传输的数据进行封装,在发送的数据前面加上一个消息头部,消息头部里面记录发送数据的长度,这样一来,接收方先解析消息头部获得数据的长度,然后根据数据的长度来获取实际数据。
对于给发送的数据增加一个消息头部的解决方案,有以下几点需要注意。
代码实现
做粘包分包指的是对接收来的数据进行粘包分包处理,无论是在客户端还是服务器端,只要是基于 TCP 协议发送的数据,都需要做粘包分包处理。
首先我们在服务器端和客户端要有个约定,这个约定就是发送的数据包的包头是什么格式,在此,我们采用固定长度包头,包头占有 10 个字符,这 10 个字符来记录包内容的长度。在此,为了简化,我们在服务器端只接收数据,客户端只发送数据。
''' 此为服务器端程序 ''' import socket import json import os ptl_headerlen = 12 # 包头用 12 个字符表示,有 2 个是序列化的产生的引号 alldata = "" sv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sv_socket.bind(('0.0.0.0', 9998)) sv_socket.listen(5) print("服务器在监听......") sock, addr = sv_socket.accept() print("新客户端连接到来:", addr) while True: while True: if len(alldata) < ptl_headerlen: # 一直到包头字节流全部接收完成 alldata += sock.recv(12).decode("utf-8") if not alldata: # 收到服务器的套接字的 close 消息 sock.close() os._exit(0) # 通信完毕,退出进程 else: header = json.loads(alldata[:ptl_headerlen]) # 反序列化包头 print("包头:", header) index = header.find("L") # 取出包头结束符 L 之前的内容 bodylen = int(header[0: index]) print("包内容长度:", bodylen) break while True: if len(alldata) < ptl_headerlen + bodylen: # 一直到包内容的字节流全部接收完成 alldata += sock.recv(1024).decode("utf-8") continue else: bodydata = json.loads(alldata[ptl_headerlen: (ptl_headerlen + bodylen)]) print("包内容:", bodydata) alldata = alldata[ptl_headerlen + bodylen:] print("多余部分:", alldata) break
客户端代码如下:
''' 此为客户端程序 ''' import socket import json import time ''' 总纲:协议包(package)由包头(header)和包内容(body)组成 一:协议包包头(header)说明 1.说明: 协议包包头(header)共占用 10 个字符,协议包头记录包内容的长度,内容长度以 L 为补充识别结束 注意对包头 json 序列化后,会多出两个引号,所以包头的总长度为 12(是个固定值) 2.模型: header = "LLLLLLLLLL" 其中 L 代表包内容(body)实际长度值,但要保证包头的值要以 L 结束,这样方便解析 3.样例: header = "28LLLLLLLL"。我们自己组成包头(header)共 10 个字符,包内容(body)长度为"28"。 4.注意事项 在网络上我们都是发送序列化后的字节流,所以对 header 序列化后为:'"28LLLLLLLL"',共 12 个字符 ''' ptl_header = "LLLLLLLLLL" # 定义包头 def ptl_dealheader(body): header = str(len(body)) + ptl_header[len(str(len(body))):] return json.dumps(header) def ptl_dealbody(body): return json.dumps(body) def getpackage(body): body = ptl_dealbody(body) header = ptl_dealheader(body) return header + body clt_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) clt_socket.connect(('127.0.0.1 ', 9998)) dataone = [3, "python", 4] # 第 1 个包内容 pkgone = getpackage(dataone) # 封装成 pkgone 包(包头和包内容) datatwo = "老鸟python" # 第 2 个包内容 pkgtwo = getpackage(datatwo) # 封装成 pkgtwo 包(包头和包内容) # 粘包测试 clt_socket.send((pkgone + pkgtwo).encode("utf-8")) # 一次性发送 pkgone 包和 pkgtwo 包 time.sleep(2) # 分包测试 clt_socket.send(pkgone[:4].encode("utf-8")) # 发送第 pkgone 包的前四个字节流 time.sleep(2) clt_socket.send(pkgone[4:].encode("utf-8")) # 发送第 pkgone 包剩余的部分 time.sleep(2) # 粘包和分包同时测试 clt_socket.send(pkgone[:4].encode("utf-8")) # 发送 pkgone 包的前四个字节流 time.sleep(2) clt_socket.send((pkgone[4:] + pkgtwo).encode("utf-8")) # 发送 pkgone 包剩余的部分和 pkgtwo 包 clt_socket.close()
运行结果如下:
服务器在监听...... 新客户端连接到来: ('127.0.0.1', 6279) 包头: 16LLLLLLLL 包内容长度: 16 包内容: [3, 'python', 4] 多余部分: "20LLLLLLLL""\u8001\u9e1fpython" 包头: 20LLLLLLLL 包内容长度: 20 包内容: 老鸟python 多余部分: 包头: 16LLLLLLLL 包内容长度: 16 包内容: [3, 'python', 4] 多余部分: 包头: 16LLLLLLLL 包内容长度: 16 包内容: [3, 'python', 4] 多余部分: "20LLLLLLLL""\u8001\u9e1fpython" 包头: 20LLLLLLLL 包内容长度: 20 包内容: 老鸟python 多余部分:
上面的案例,我们在包头中用字符记录包内容的长度,如果能用整数记录就会更好了,python 中提供了 struct 模块,可以表示字节。
import struct import json body = json.dumps(dict(hello="world")) # 对包内容序列化 print(body) # '{"hello": "world"}' # 序列成了字符串 header = body.__len__() # 18 # 包头记录包内容的长度 headPack = struct.pack("I", header) # I 代表无符号整数,占 4 个字节 print(headPack) # b'\x12\x00\x00\x00' # 18 的字节流 header = struct.unpack("I", headPack) # 还原数据 print(header) # (18,) # 注意:结果是个元组
深度了解 TCP 协议的流概念。
弄明白粘包分包的原因。
独立完成粘包和分包处理。
包头不但有包内容长度,包头增加版本号和保留字段内容,独立完成粘包分包处理程序。可以用变长包头或者固定长度包头,包头使用字节数据结构。
嗯嗯,所以应用层要定义好协议
说不存在粘包问题,真的是醉了,就怀疑他们没写过tcp编程
tcp是流模式,没有消息边界。
我不是很明白TCP作为一个成熟的广泛使用的协议,,为什么会发生粘包现象?协议在设计的时候,既然是要把应用层的多个数据包合并一起发送,合并的时候就应该做好标记的呀。而且我记得tcp协议字段里有本包长度值的字段,可以标志包的长度是多少。
TCP 只是做了他应该做的事情,TCP 只有流的概念,他没有包的概念,所以 TCP 不会解决你应用层自己定义的包,这个就需要你应用层自己去解决了
面试的时候被问到了,幸好提前一周看了博主的教程,面试已经通过
所以不是要解决tcp的粘包,而是应用层要去分包?这样说就可以了?