【4.0】粘包问题
【一】什么是粘包
- 须知:只有TCP有粘包现象,UDP永远不会粘包
【1】socket收发消息的原理
- 首先需要掌握一个socket收发消息的原理
- 发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据
- 也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的
- 因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。
- 而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。
【2】如何定义消息
-
可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。
-
例如基于tcp的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接收方看了,根本不知道该文件的字节流从何处开始,在何处结束
所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
- 此外
- 发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率
- 发送方往往要收集到足够多的数据后才发送一个TCP段。
- 若连续几次需要send的数据都很少,通常TCP会根据优化算法把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据。
【3】TCP
- TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。
- 收发两端(客户端和服务器端)都要有一一成对的socket
- 因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。
- 这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。
【4】UDP
- UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。
- 不会使用块的合并优化算法,, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息)
- 这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的。
【5】小结
- tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头,实验略
- udp的recvfrom是阻塞的
- 一个recvfrom(x)必须对唯一一个sendinto(y),收完了x个字节的数据就算完成,若是y>x数据就丢失,这意味着udp根本不会粘包,但是会丢数据,不可靠
- tcp的协议数据不会丢,没有收完包,下次接收,会继续上次继续接收,己端总是在收到ack时才会清除缓冲区内容。数据是可靠的,但是会粘包。
- 两种情况下会发生粘包。
- 发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据了很小,会合到一起,产生粘包)
【二】什么是粘包问题
- 客户端发送需要执行的代码
- 服务端接收到客户端传过来的代码
- 服务端调用方法执行代码并拿到执行后的结果
- 服务端将执行后的结果进行返回
- 客户端接收到服务端返回的结果并做打印输出
【1】服务端
# client.connect(('127.0.0.1', 8880))
# 客户端设置的 ip 和 port
from socket import *
# 执行命令模块
import subprocess
# 创建服务对象
server = socket(AF_INET, SOCK_STREAM)
# 建立链接桥梁
server.bind(('127.0.0.1', 8080))
# 指定半连接池大小
server.listen(5)
# 接收数据和发送数据
while True:
# 从半连接池里面取出链接请求,建立双向链接,拿到连接对象
# 拿到 建成的链接对象 和 客户端的 ip port
conn, client_addr = server.accept()
while True:
# 检测可能会抛出的异常 并 对异常做处理
try:
# 基于 取出的链接对象 进行通信
cmd_from_client = conn.recv(1024)
# 不允许传过来的信息为空
if len(cmd_from_client) == 0:
break
# 执行客户端传过来的命令
# 接收执行命令的结果
msg_server = subprocess.Popen(cmd_from_client.decode('utf-8'), # 对命令进行解码
shell=True, # 执行shell命令
stdout=subprocess.PIPE, # 管道一
stderr=subprocess.PIPE, # 管道二
)
# 返回命令的结果 ---- 成功或失败 (Linux系统可以用utf-8解码,Windows系统需要用gbk解码)
true_msg = msg_server.stdout.read() # 读取到执行成功的结果 ---- 二进制数据类型
false_msg = msg_server.stderr.read() # 读取到执行失败的结果 ---- 二进制数据类型
# 反馈信息给 发送信息的客户端
conn.send(true_msg)
conn.send(false_msg)
except Exception as e:
break
conn.close()
【2】客户端
from socket import *
# 创建 socket 对象
client = socket(AF_INET, SOCK_STREAM)
# 创建链接 IP 和 端口
client.connect(('127.0.0.1', 8080))
while True:
msg = input('enter msg :>>>').strip()
# 输入的内容不能为空
if len(msg) == 0: continue
# 传输过程中的数据为二进制数据。对文本数据进行转码
msg = msg.encode('utf-8')
client.send(msg)
# 接收来自服务端返回的结果
msg_from_server = client.recv(1024)
# 对服务端返回的信息进行解码(Mac/Linux解码用utf-8,Windows用GBK)
msg_from_server = msg_from_server.decode('gbk')
print(msg_from_server)
client.close()
【3】问题引入
- 服务端:
- 执行代码,代码为空会报错
- 执行代码,返回的数据可能存在空/报错信息
- 客户端:
- 输入的指令长度,可能会超出范围
- 接受到的服务端反馈的结果可能会特别多
- 如何打印超出数据范围(缓存到系统里)的数据
【4】粘包问题
- 在 TCP 协议中是流式协议,数据是源源不断的传入到客户端中,但是客户端可以接受到的信息的长度是有限的
- 当接收到指定长度的信息后,客户端进行打印输出
- 剩余的其他数据会被缓存到 内存中
- 当再次执行其他命令时
- 新的数据的反馈结果,会叠加到上一次没有完全打印完全的信息的后面,造成数据的错乱
- 当客户端想打印新的命令的数据时,打印的其实是上一次没有打印完的数据
- 对数据造成的错乱
【5】粘包问题解决思路
- 拿到数据的总大小
recv_total_size
recv_size = 0
,循环接收,每接收一次,recv_size += 接收的长度
- 直到
recv_size = recv_total_size
表示接受信息完毕,结束循环
【三】UDP协议不存在粘包问题
- 粘包问题出现的原因
- TCP 协议是流式协议,数据像水流一样粘在一起,没有任何边界之分
- 收数据没有接收干净,有残留,就会和下一次的结果混淆在一起
- 解决粘包问题的核心法门就是
- 每次都收干净
- 不造成数据的混淆
【1】UDP协议不存在粘包问题
(1)服务端
from socket import *
server = socket(AF_INET, SOCK_DGRAM)
server.bind(('127.0.0.1', 8080))
res_from_client = server.recvfrom(1024)
print(res_from_client)
# (b'world', ('127.0.0.1', 56852))
res_from_client_two = server.recvfrom(1024)
print(res_from_client_two)
# (b'hello', ('127.0.0.1', 61798))
res_from_client_three = server.recvfrom(5)
print(res_from_client_three)
# OSError: [WinError 10040] 一个在数据报套接字上发送的消息大于内部消息缓冲区或其他一些网络限制,或该用户用于接收数据报的缓冲区比数据报小。
# 无法接收到 数据以外的数据 报错
(2)客户端
import socket
from socket import *
# 创建client对象
client = socket(AF_INET, SOCK_DGRAM)
client.connect(('127.0.0.1', 8080))
client.send(b'world')
client.send(b'hello')
msg_from_server = client.recvfrom(1024)
print(msg_from_server)
(3)小结
- 当我们启动udp服务端后,由udp客户端向服务端发送两条数据
- 但是在udp服务端只接收到了一条数据
- 这是因为 udp 是报式协议,传送数据过程中会将数据打包直接发走,不会对数据进行拼接操作(没有Nagle算法)
【2】TCP协议存在粘包问题
(1)服务端
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8081))
server.listen(3)
conn, client_addr = server.accept()
msg_from_client = conn.recv(1024)
print(msg_from_client.decode('utf-8'))
# helloworld
conn.send(b'return')
conn.close()
(2)客户端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(('127.0.0.1', 8081))
client.send(b'hello')
client.send(b'world')
msg_from_server = client.recv(1024)
print(msg_from_server)
# b'return'
client.close()
【3】小结
-
从以上我们可以看到
-
TCP协议传输过程中将我们的两次发送的数据拼接成了一个发送到服务端
-
通过比较我们可知,udp协议虽然不存在粘包问题,但是,udp协议的安全性有待考量
【四】TCP协议解决粘包问题基础
【1】解决思路
- 利用
struct
模块将传输过去的数据的总长度 打包 + 到头部进行发送
【2】服务端
# client.connect(('127.0.0.1', 8880))
# 客户端设置的 ip 和 port
from socket import *
# 执行命令模块
import subprocess
# 将数据打包成指定4个长度的数据
import struct
# 创建服务对象
server = socket(AF_INET, SOCK_STREAM)
# 建立链接桥梁
server.bind(('127.0.0.1', 8083))
# 指定半连接池大小
server.listen(5)
# 接收数据和发送数据
while True:
# 从半连接池里面取出链接请求,建立双向链接,拿到连接对象
# 拿到 建成的链接对象 和 客户端的 ip port
conn, client_addr = server.accept()
while True:
# 检测可能会抛出的异常 并 对异常做处理
try:
# 基于 取出的链接对象 进行通信
cmd_from_client = conn.recv(1024)
# 不允许传过来的信息为空
if len(cmd_from_client) == 0:
break
# 执行客户端传过来的命令
# 接收执行命令的结果
msg_server = subprocess.Popen(cmd_from_client.decode('utf-8'), # 对命令进行解码
shell=True, # 执行shell命令
stdout=subprocess.PIPE, # 管道一
stderr=subprocess.PIPE, # 管道二
)
# 返回命令的结果 ---- 成功或失败 (Linux系统可以用utf-8解码,Windows系统需要用gbk解码)
true_msg = msg_server.stdout.read() # 读取到执行成功的结果 ---- 二进制数据类型
false_msg = msg_server.stderr.read() # 读取到执行失败的结果 ---- 二进制数据类型
# (1):先发头部信息(固定长度的bytes二进制数据):对数据信息的描述(包括数据的总长度)
total_size_from_server = len(true_msg) + len(false_msg)
# int类型 -----> 固定长度的 bytes
# 参数 i 表示是整型,具体解释参考文档
total_size_from_server_pack = struct.pack('i', total_size_from_server)
conn.send(total_size_from_server_pack)
# 反馈信息给 发送信息的客户端
conn.send(true_msg)
conn.send(false_msg)
except Exception as e:
break
conn.close()
【3】客户端
from socket import *
# 解指定数据长度
import struct
# 创建 socket 对象
client = socket(AF_INET, SOCK_STREAM)
# 创建链接 IP 和 端口
client.connect(('127.0.0.1', 8083))
while True:
msg = input('enter msg :>>>').strip()
# 输入的内容不能为空
if len(msg) == 0:
continue
# 传输过程中的数据为二进制数据。对文本数据进行转码
msg = msg.encode('utf-8')
client.send(msg)
# 接收来自服务端返回的结果
# 解决粘包问题:解决办法
# (1) 先收到固定长度的头,将头部解析到数据的描述信息,拿到数据的总大小 recv_total_size
# 解析出接收到的总数据的长度
recv_total_size_msg = client.recv(4)
# 解包返回的是元祖。元祖第一个参数就是打包的数字
recv_total_size = struct.unpack('i', recv_total_size_msg)[0]
# (2) recv_size = 0 ,循环接收,每接收一次,recv_size += 接收的长度
# (3) 直到 recv_size = recv_total_size 表示接受信息完毕,结束循环
# 初始化数据长度
recv_size = 0
while recv_size < recv_total_size:
# 本次接收 最多能接收 1024 字节的数据
msg_from_server = client.recv(1024)
# 本次接收到的打印的数据长度
recv_size += len(msg_from_server)
# 对服务端返回的信息进行解码(Mac/Linux解码用utf-8,Windows用GBK)
msg_from_server = msg_from_server.decode('gbk')
print(msg_from_server, end='')
else:
print('命令结束')
client.close()
-
客户端可以完美的接收到查出额定长度以外的数据
-
同时这也是 自定义协议的 简单操作
【五】TCP协议解决粘包问题进阶
【0】解决思路
- 通过json模式 ---- 模版修改参数直接套用
【1】服务端
# client.connect(('127.0.0.1', 8880))
# 客户端设置的 ip 和 port
from socket import *
# 执行命令模块
import subprocess
# 将数据打包成指定4个长度的数据
import struct
# 将头部信息转成json格式(通用信息格式)
import json
# 创建服务对象
server = socket(AF_INET, SOCK_STREAM)
# 建立链接桥梁
server.bind(('127.0.0.1', 8085))
# 指定半连接池大小
server.listen(5)
# 接收数据和发送数据
while True:
# 从半连接池里面取出链接请求,建立双向链接,拿到连接对象
# 拿到 建成的链接对象 和 客户端的 ip port
conn, client_addr = server.accept()
while True:
# 检测可能会抛出的异常 并 对异常做处理
try:
# 基于 取出的链接对象 进行通信
cmd_from_client = conn.recv(1024)
# 不允许传过来的信息为空
if len(cmd_from_client) == 0:
break
# 执行客户端传过来的命令
# 接收执行命令的结果
msg_server = subprocess.Popen(cmd_from_client.decode('utf-8'), # 对命令进行解码
shell=True, # 执行shell命令
stdout=subprocess.PIPE, # 管道一
stderr=subprocess.PIPE, # 管道二
)
# 返回命令的结果 ---- 成功或失败 (Linux系统可以用utf-8解码,Windows系统需要用gbk解码)
true_msg = msg_server.stdout.read() # 读取到执行成功的结果 ---- 二进制数据类型
false_msg = msg_server.stderr.read() # 读取到执行失败的结果 ---- 二进制数据类型
# (1):先发头部信息(固定长度的bytes二进制数据):对数据信息的描述(包括数据的总长度)
total_size_from_server = len(true_msg) + len(false_msg)
# (2)自定义头部信息
headers_dict = {
'file_name': 'a.txt',
'total_size': total_size_from_server,
'md5': 'md5'
}
# 打包头部信息 - 将字典转成 json 格式数据类型
json_data_str = json.dumps(headers_dict)
# 将 json 格式数据转成二进制数据传输
json_data_bytes = json_data_str.encode('utf-8')
# int类型 -----> 将json格式的二进制数据打成固定长度的 bytes
# 参数 i 表示是整型,具体解释参考文档
json_data_size_pack = struct.pack('i', len(json_data_bytes))
conn.send(json_data_size_pack)
# 发送打包好的头信息
conn.send(json_data_bytes)
# 反馈信息给 发送信息的客户端
conn.send(true_msg)
conn.send(false_msg)
except Exception as e:
break
conn.close()
【2】客户端
import json
from socket import *
# 解指定数据长度
import struct
# 创建 socket 对象
client = socket(AF_INET, SOCK_STREAM)
# 创建链接 IP 和 端口
client.connect(('127.0.0.1', 8085))
while True:
msg = input('enter msg :>>>').strip()
# 输入的内容不能为空
if len(msg) == 0:
continue
# 传输过程中的数据为二进制数据。对文本数据进行转码
msg = msg.encode('utf-8')
client.send(msg)
# 接收来自服务端返回的结果
# (1.1) 先收四个字节的数据,从接收到的数据中解析出json格式的二进制数据的长度
json_data_size_unpack = client.recv(4)
# 解包返回的是元祖。元祖第一个参数就是打包的数字
json_data_size = struct.unpack('i', json_data_size_unpack)[0]
# (1.2) 对 服务端 返回的数据中指定长度进行截取 拿到 json 格式的二进制数据
json_data_bytes = client.recv(json_data_size)
# (1.3) 对指定数据进行json格式的解码并取出需要的信息
header_dict_str = json_data_bytes.decode('utf-8')
header_dict = json.loads(header_dict_str)
# (1.4) 取出字典中的信息总长度
recv_total_size = header_dict['total_size']
# (2) 接收真实的数据
# recv_size = 0 ,循环接收,每接收一次,recv_size += 接收的长度
# (3) 直到 recv_size = recv_total_size 表示接受信息完毕,结束循环
# 初始化数据长度
recv_size = 0
while recv_size < recv_total_size:
# 本次接收 最多能接收 1024 字节的数据
msg_from_server = client.recv(1024)
# 本次接收到的打印的数据长度
recv_size += len(msg_from_server)
# 对服务端返回的信息进行解码(Mac/Linux解码用utf-8,Windows用GBK)
msg_from_server = msg_from_server.decode('gbk')
print(msg_from_server, end='')
else:
print('命令结束')
client.close()
【补充】struct模块
struct.pack()
是Python内置模块struct
中的一个函数- 它的作用是将指定的数据按照指定的格式进行打包,并将打包后的结果转换成一个字节序列(byte string)
- 可以用于在网络上传输或者储存于文件中。
struct.pack(fmt, v1, v2, ...)
- 其中,
fmt
为格式字符串,指定了需要打包的数据的格式,后面的v1
,v2
,...则是需要打包的数据。 - 这些数据会按照
fmt
的格式被编码成二进制的字节串,并返回这个字节串。
- 其中,
fmt
的常用格式符如下:x
--- 填充字节c
--- char类型,占1字节b
--- signed char类型,占1字节B
--- unsigned char类型,占1字节h
--- short类型,占2字节H
--- unsigned short类型,占2字节i
--- int类型,占4字节I
--- unsigned int类型,占4字节l
--- long类型,占4字节(32位机器上)或者8字节(64位机器上)L
--- unsigned long类型,占4字节(32位机器上)或者8字节(64位机器上)q
--- long long类型,占8字节Q
--- unsigned long long类型,占8字节f
--- float类型,占4字节d
--- double类型,占8字节s
--- char[]类型,占指定字节个数,需要用数字指定长度p
--- char[]类型,跟s
一样,但通常用来表示字符串?
--- bool类型,占1字节
- 具体的格式化规则可以在Python文档中查看(链接)。
import json
import struct
# 为避免粘包,必须自定制报头
# 1T数据,文件路径和md5值
header = {'file_size': 1073741824000, 'file_name': '/a/b/c/d/e/a.txt',
'md5': '8f6fbf8347faa4924a76856701edb0f3'}
# 为了该报头能传送,需要序列化并且转为bytes
# 序列化并转成bytes,用于传输
head_bytes = bytes(json.dumps(header), encoding='utf-8')
# 为了让客户端知道报头的长度,用struck将报头长度这个数字转成固定长度:4个字节
# 这4个字节里只包含了一个数字,该数字是报头的长度
head_len_bytes = struct.pack('i', len(head_bytes))
print(f"这是原本的数据 :>>>> {header}")
print(f"这是json序列化后的数据 :>>>> {head_bytes}")
print(f"这是压缩后的数据 :>>>> {head_len_bytes}")
# 这是原本的数据 :>>>> {'file_size': 1073741824000, 'file_name': '/a/b/c/d/e/a.txt', 'md5': '8f6fbf8347faa4924a76856701edb0f3'}
# 这是json序列化后的数据 :>>>> b'{"file_size": 1073741824000, "file_name": "/a/b/c/d/e/a.txt", "md5": "8f6fbf8347faa4924a76856701edb0f3"}'
# 这是压缩后的数据 :>>>> b'h\x00\x00\x00'
【作业】FTP文件传输器
【1】服务端
import socket
import struct
import json
import subprocess
import os
class MYTCPServer:
address_family = socket.AF_INET
socket_type = socket.SOCK_STREAM
allow_reuse_address = False
max_packet_size = 8192
coding='utf-8'
request_queue_size = 5
server_dir='file_upload'
def __init__(self, server_address, bind_and_activate=True):
"""Constructor. May be extended, do not override."""
self.server_address=server_address
self.socket = socket.socket(self.address_family,
self.socket_type)
if bind_and_activate:
try:
self.server_bind()
self.server_activate()
except:
self.server_close()
raise
def server_bind(self):
"""Called by constructor to bind the socket.
"""
if self.allow_reuse_address:
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind(self.server_address)
self.server_address = self.socket.getsockname()
def server_activate(self):
"""Called by constructor to activate the server.
"""
self.socket.listen(self.request_queue_size)
def server_close(self):
"""Called to clean-up the server.
"""
self.socket.close()
def get_request(self):
"""Get the request and client address from the socket.
"""
return self.socket.accept()
def close_request(self, request):
"""Called to clean up an individual request."""
request.close()
def run(self):
while True:
self.conn,self.client_addr=self.get_request()
print('from client ',self.client_addr)
while True:
try:
head_struct = self.conn.recv(4)
if not head_struct:break
head_len = struct.unpack('i', head_struct)[0]
head_json = self.conn.recv(head_len).decode(self.coding)
head_dic = json.loads(head_json)
print(head_dic)
#head_dic={'cmd':'put','filename':'a.txt','filesize':123123}
cmd=head_dic['cmd']
if hasattr(self,cmd):
func=getattr(self,cmd)
func(head_dic)
except Exception:
break
def put(self,args):
file_path=os.path.normpath(os.path.join(
self.server_dir,
args['filename']
))
filesize=args['filesize']
recv_size=0
print('----->',file_path)
with open(file_path,'wb') as f:
while recv_size < filesize:
recv_data=self.conn.recv(self.max_packet_size)
f.write(recv_data)
recv_size+=len(recv_data)
print('recvsize:%s filesize:%s' %(recv_size,filesize))
tcpserver1=MYTCPServer(('127.0.0.1',8080))
tcpserver1.run()
【2】客户端
import socket
import struct
import json
import os
class MYTCPClient:
address_family = socket.AF_INET
socket_type = socket.SOCK_STREAM
allow_reuse_address = False
max_packet_size = 8192
coding='utf-8'
request_queue_size = 5
def __init__(self, server_address, connect=True):
self.server_address=server_address
self.socket = socket.socket(self.address_family,
self.socket_type)
if connect:
try:
self.client_connect()
except:
self.client_close()
raise
def client_connect(self):
self.socket.connect(self.server_address)
def client_close(self):
self.socket.close()
def run(self):
while True:
inp=input(">>: ").strip()
if not inp:continue
l=inp.split()
cmd=l[0]
if hasattr(self,cmd):
func=getattr(self,cmd)
func(l)
def put(self,args):
cmd=args[0]
filename=args[1]
if not os.path.isfile(filename):
print('file:%s is not exists' %filename)
return
else:
filesize=os.path.getsize(filename)
head_dic={'cmd':cmd,'filename':os.path.basename(filename),'filesize':filesize}
print(head_dic)
head_json=json.dumps(head_dic)
head_json_bytes=bytes(head_json,encoding=self.coding)
head_struct=struct.pack('i',len(head_json_bytes))
self.socket.send(head_struct)
self.socket.send(head_json_bytes)
send_size=0
with open(filename,'rb') as f:
for line in f:
self.socket.send(line)
send_size+=len(line)
print(send_size)
else:
print('upload successful')
client=MYTCPClient(('127.0.0.1',8080))
client.run()
本文来自博客园,作者:Chimengmeng,转载请注明原文链接:https://www.cnblogs.com/dream-ze/p/17968021