网络编程——黏包

一、黏包现象

  比如基于tcp制作一个远程命令的程序(命令ls -l ,ifconfig,pwd)。同时执行多条命令之后,得到的结果可能只有一部分,在执行其他命令的时候又接收到之前执行的另一部分结果,这种现象就是黏包。(注意:编码是以当前所在的系统为准的,如果是windows,那么res.stdout.read()读出的就是GBK编码的,在接收端需要用GBK解码)

  1、基于tcp协议实现的黏包

     server端

from socket import *
import subprocess

ip_port = ('127.0.0.1',8080)
BUFSIZE = 1024

tcp_server_socket = socket(AF_INET,SOCK_STREAM)
tcp_server_socket .setsockopt(SOL_SOCKET,SO_REUSEADDR,1)
tcp_server_socket .bind(ip_port)
tcp_server_socket .listen(5)

while 1:
    conn,addr = tcp_server_socket.accept()
    print('客户端',addr)

    while 1:
        cmd = conn.recv(BUFSIZE)
        if len(cmd) == 0:
            break
        res = subprocess.Popen(cmd.decode('gbk'),shell = True,
                               stdout=subprocess.PIPE,
                               stdin=subprocess.PIPE,
                               stderr=subprocess.PIPE
                               )
        stderr = res.stderr.read()
        stdout = res.stdout.read()
        conn.send(stderr)
        conn.send(stdout)

    client端

import socket

BUFSIZE = 1024
ip_port = ('127.0.0.1',8080)

sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res = sk.connect_ex(ip_port)

while 1:
    msg = input(">>>>>>:").strip()
    if len(msg) == 0:
        continue
    if msg == "q":
        break

    sk.send(msg.encode('gbk'))
    act_res = sk.recv(BUFSIZE)

    print(act_res.decode('gbk'),end="")

 

  2、基于udp协议实现的黏包

    server端

from socket import *
import subprocess

ip_port = ('127.0.0.1', 8080)
bufsize = 1024

udp_server = socket(AF_INET, SOCK_DGRAM)
udp_server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
udp_server.bind(ip_port)

while 1:
    # 收消息
    cmd, addr = udp_server.recvfrom(bufsize)
    print('用户命令:', cmd)

    # 逻辑处理
    res = subprocess.Popen(cmd.decode('gbk'), shell=True, stderr=subprocess.PIPE, stdin=subprocess.PIPE,
                           stdout=subprocess.PIPE)
    stderr = res.stderr.read()
    stdout = res.stdout.read()

    # 发消息
    udp_server.sendto(stderr, addr)
    udp_server.sendto(stdout, addr)
udp_server.close()

    client端

from socket import *

ip_port = ('127.0.0.1',8080)
bufsize = 1024

udp_clietn = socket(AF_INET,SOCK_DGRAM)

while 1:
    msg = input(">>>>>>>:").strip()
    udp_clietn.sendto(msg.encode('gbk'),ip_port)
    err,addr = udp_clietn.recvfrom(bufsize)
    out,addr = udp_clietn.recvfrom(bufsize)

    if err:
         print("error: %s" %err.decode('gbk'),end="")
    if out:
        print(out.decode('gbk'),end="")

 @只有TCP有黏包的现象,UDP永远不会黏包。

 

二、黏包的成因

  1、TCP协议中的数据传递

    (1)、tcp协议的拆包机制

      当发送端缓冲区的长度大于网卡的MTU时,tcp会将这次发送的数据拆成几个数据包发送出去。
      MTU是Maximum Transmission Unit的缩写。意思是网络上传送的最大数据包。MTU的单位是字节。 大部分网络设备的MTU都是1500。如果本机的MTU比网关的MTU大,大的数据包就会被拆开来传送,这样会产生很多数据包碎片,增加丢包率,降低网络速度。

    (2)、面向流的通信特点和Nagle算法

      TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。
这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。
      对于空消息:tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住。而udp时基于数据报的,即便是你输入的是空内容(直接回车),也可以被发送,udp协议会帮助你封装上消息头发送过去。

      可靠黏包的tcp协议:tcp的协议数据不会丢包,没有收完包,下次接收,会继续上次继续接受,己端总是在收到ack时才会清空缓冲区内容。数据是可靠的,但是会黏包。    

     (3)、基于tcp协议特点的黏包现象成因

      发送端可以是一k一k的发送数据,而接收端的应用程序可以两两k的走数据,当然也有可能一次提走3k或6k数据,或者一次提走几个字节的数据。也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应应用程序时不可看见的,因此TCP协议时面向流的协议,这也就是容易出现黏包问题的原因。而UDP时面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。

      怎样定义消息呢?可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条消息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。

  2、UDP不会发生黏包

    UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。不会使用块的合并优化算法,由于udp支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的udp包,在每个udp包中就有了消息头(消息来源头,端口等信息),这样对于接收端来说就容易进行分区处理了。即面向消息的通信时有消息保护边界的。

    不可靠不黏包的udp协议:udp的recvfrom是阻塞的,一个recvfrom(x)必须对唯一一个sendinto(y),收完了x个字节的数据就算完成,若是y,x数据就丢失,这就意味着udp根本不会黏包,但是会丢失数据,不可靠。

    用udp协议发送时,用sendto函数最大能发送数据长度为:65535 - IP头(20) - UDP头(8)=65507字节。用sendto函数发送数据时,如果发送数据长度大于该值,则函数会返回错误。(丢弃这个包,不进行发送)

    用TCP协议发送时,由于TCP是数据流协议,因此不存在包大小的限制(暂时不考虑缓冲区的大小),这是指在用send函数时,数据长度参数不受限制。而实际上,所指定的这段数据并不一定一次性发送出去,如果这段数据比较长时,会被分段送,如果比较短,可能会等待和下一次数据一起发送。

  3、会发生黏包的两种清况

    (1)、发送方的缓存机制

      发送端需要等待缓冲区满才发送出去,造成黏包(发送数据时间间隔很短,数据很小,会合到一起,产生黏包)

    server端

from socket import *

ip_port = ('127.0.0.1',8080)

tcp_socket_server = socket(AF_INET,SOCK_STREAM)
tcp_socket_server.bind(ip_port)
tcp_socket_server.listen(5)

conn,addr = tcp_socket_server.accept()

data1 = conn.recv(10)
data2 = conn.recv(10)

print(">>>>>>>>>>>",data1.decode('utf-8'))
print(">>>>>>>>>>>>",data2.decode("utf-8"))

conn.close()

    client端

import socket

ip_port = ('127.0.0.1',8080)
bufsize = 1024

sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res = sk.connect_ex(ip_port)

sk.send("s_hello".encode('utf-8'))
sk.send("egg".encode('utf-8'))

     (2)、接收方的缓存机制

      接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生黏包)

  server端

from socket import *

ip_port = ('127.0.0.1',8080)

tcp_socket_server = socket(AF_INET,SOCK_STREAM)
tcp_socket_server.bind(ip_port)
tcp_socket_server.listen(5)

conn,addr = tcp_socket_server.accept()

data1 = conn.recv(2) #第一次没有收完
data2 = conn.recv(10)  #下次收的时候,会先取旧的数据,然后取新的

print('---->',data1.decode('utf-8'))
print('---->',data2.decode('utf-8'))

conn.close()

  client端

import socket

bufsize = 1024
ip_port = ('127.0.0.1',8080)

sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res = sk.connect_ex(ip_port)

sk.send('s_hello'.encode('utf-8'))

@总结:

  黏包的现象只发生在tcp协议中:

    1、从表面上看,黏包问题主要是因为发送方和接收方的缓存机制,tcp协议面向流通信的特点。

    2、实际上,主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据造成的。

 

三、黏包的解决方案

  1、解决方案一、

    问题的根源在于接收端不知道发送端将要传送多少字节流的长度,所以解决黏包的方法就是围绕如何让发送端在发送数据前把自己要发送的字节流总大小让接收端知晓,然后接收端来一个死循环接收完所有的数据。

  server端

import socket,subprocess

ip_port = ('127.0.0.1',8080)
bufsize = 1024
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)

sk.bind(ip_port)
sk.listen(5)

while 1:
    conn,addr = sk.accept()
    print('客户端',addr)
    while 1:
        msg = conn.recv(bufsize)
        if not msg:
            break
        res = subprocess.Popen(msg.decode('GBK'),shell=True,\
                               stdin=subprocess.PIPE,\
                               stderr=subprocess.PIPE,\
                               stdout=subprocess.PIPE)
        err = res.stderr.read()
        if err:
            ret = err
        else:
            ret = res.stdout.read()
        data_length = len(ret)
        conn.send(str(data_length).encode('GBK'))
        data = conn.recv(bufsize).decode('GBK')
        if data == 'recv_ready':
            conn.sendall(ret)
    conn.close()

  clietn端

import socket,time

ip_port = ('127.0.0.1',8080)
bufsize = 1024
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res = sk.connect_ex(ip_port)

while 1:
    msg = input('>>>>>>:').strip()
    if len(msg) == 0:
        continue
    if msg == 'q':
        break

    sk.send(msg.encode('utf-8'))
    length = int(sk.recv(bufsize).decode('GBK'))
    sk.send('recv_ready'.encode('GBK'))
    send_size = 0
    recv_size = 0
    data = b''
    while recv_size < length:
        data += sk.recv(bufsize)
        recv_size += len(data)
    print(data.decode('GBK'))

#存在的问题:

  程序的运行速度远快于网络传输的速度,所以在发送一段字节前先用send去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗。

 

  2、解决方案的进阶(struct模块)

    借助一个模块,这个模块可以把要发送的数据长度转换成固定长度的字节。这样客户端每次收消息之前只要先接收这个固定长度的内容看一看接下来要收的信息大小,那么最终接收的数据只要达到这个值就停止,就能刚好不多不少的接收完整的数据。

    struct模块:该模块可以把一个类型,如数字,转成古典给长度bytes。

import struct
ret = struct.pack('i',1111111111)
print(ret)

结果:b'\xc75:B'

@范围:
struct.error: 'i' format requires -2147483648 <= number <= 2147483647 #这个是范围

import json,struct

#假设通过客户端上传IT:1073741824000的文件a.txt

#为了避免黏包,必须自定制报头
header = {'file_size':1073741824000,'file_name':'/a/b/c/d/e/a.txt','md5':'8f6fbf8347faa4924a76856701edb0f3'}#1T数据,文件路径和md5值

#为了让报头能传送,需要序列化并转为bytes
header_bytes = bytes(json.dumps(header),encoding='utf-8') #序列化并转成bytes,用于传输

#为了让客户端知道报头长度,用struct将报头长度这个数字转成固定长度:4个字节
head_len_bytes = struct.pack('i',len(header_bytes))  #这4个字节里只包含了一个数字,该数字是报头得长度

#客户端开始发送
conn.send(head_len_bytes)  #先发报头的长度,4个bytes
conn.send(header_bytes)  #再发报头的字节格式
conn.sendall(文件内容)  #然后发真实内容的字节格式

#服务端开始接收
head_len_bytes = s.recv(4) #先收报头4个bytes,得到报头长度的字节格式
x = struct.unpack('i',head_len_bytes[0])  #提取报头长度

header_bytes = s.recv(x)  #按照报头长度x,收取报头的bytes格式
header = json.loads(json.dumps(header))  #提取报头

#最后根据报头的内容提取真实的数据,比如
real_data_len = s.recv(header['file_size'])
s.recv(real_data_len)

   

  @关于struct的详细用法

import struct
import binascii
import ctypes

values1 = (1,'abc'.encode('utf-8'),2.7)
values2 = ('defg'.encode('utf-8'),101)
s1 = struct.Struct('I3sf')
s2 = struct.Struct('4dI')

print(s1.size,s2.size)
prebuffer = ctypes.create_string_buffer(s1.size+s2.size)
print('Before:',binascii.hexlify(prebuffer))

s1.pack_into(prebuffer,0,*values1)
s2.pack_into(prebuffer,s1.size,*values1)

print('After pack',binascii.hexlify(prebuffer))
print(s1.unpack_from(prebuffer,0))
print(s2.unpack_from(prebuffer,s1.size))

s3 = struct.Struct('ii')
s3.pack_into(prebuffer,0,123,123)
print('After pack',binascii.hexlify(prebuffer))
print(s3.unpack_from(prebuffer,0))

   @使用struct解决黏包

    借助struct模块,我们知道长度数字可以被转成一个标准大小的4字节数字,因此可以利用这个特点来预先发送数据长度。

发送时接收时
先发送struct转换好的数据长度4字节先接收4个字节使用struct转换成数字来获取要接收的数据长度
再发送数据再按照长度接收数据

   server端(自定制报头)

import socket,struct,json
import subprocess

phone = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)

phone.bind(('127.0.0.1',8080))
phone.listen(5)

while 1:
    conn,addr = phone.accept()
    while 1:
        cmd = conn.recv(1024)
        if not cmd:
            break
        print('cmd:%s'%cmd)

        res = subprocess.Popen(cmd.decode('gbk'),
                               shell=True,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE
                               )
        err = res.stderr.read()
        print(err)

        if err:
            back_msg = err
        else:
            back_msg = res.stdout.read()

        conn.send(struct.pack('i',len(back_msg)))  #先发back_msg的长度
        conn.sendall(back_msg)  #在发真实的内容
    conn.close()

  client端(自制报头)

import socket,time,struct

s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
res = s.connect_ex(('127.0.0.1',8080))

while 1:
    msg = input(">>>>>>>:").strip()
    if len(msg) == 0:
        continue
    if msg == "q" :
        break
    s.send(msg.encode('gbk'))

    l = s.recv(4)
    x = struct.unpack('i',l)[0]

    print(type(x),x)

    r_s = 0
    data = b''
    while r_s < x:
        r_d = s.recv(1024)
        data += r_d
        r_s += len(r_d)

    print(data.decode('gbk'))  #windows默认是gbk

 

可以把报头做成字典,字典里包含将要发送的真实数据的详细信息,然后json序列化,然后用struck将序列化后的数据长度打包成4个字节(4个自己足够用了)

发送时接收时
先发送报头长度先接收报头长度,用struck取出来
在编码报头内容然后发送根据取出来的长度接收报头内容,然后解码,反序列化
然后发真实内容从反序列化的结果中取出待取数据的详细信息,然后去取真实的数据内容

   server端:

import socket,struct,json
import subprocess
phone = socket.socket()
phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)  #就是它,加在bind前面

phone.bind(("127.0.0.1",8080))
phone.listen(5)

while 1:
    conn,addr = phone.accept()
    while 1:
        cmd = conn.recv(1024)
        if not cmd:
            break
        print("cmd:%s" %cmd)

        res = subprocess.Popen(cmd.decode("utf-8"),shell=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
        err = res.stderr.read()
        print(err)
        if err:
            back_msg = err
        else:
            back_msg = res.stdout.read()
            
        headers = {'data_size':len(back_msg)}
        head_json = json.dumps(headers)
        head_json_bytes = bytes(head_json,encoding='utf-8')
        
        conn.send(struct.pack('i',len(head_json_bytes)))  #先发报头的长度
        conn.send(head_json_bytes)   #再发报头
        conn.sendall(back_msg)  #再发真实内容
    conn.close()

  client端:

 

 

 

@tcp协议下的socket,简易的文件上传的功能。

  server端:

import socket,struct,json,os
tcp_server = socket.socket()
ip_port = ('127.0.0.1',8080)  #127.0.0.1,本机回环地址,供内部程序之间测试使用
tcp_server.bind(ip_port)
tcp_server.listen()
#客户端上传的文件路径,都放在这个路径下
client_file_path = r"D:\L-W"
conn,addr = tcp_server.accept()
#首先接收到的信息长度转换出来的4个字节的数据
file_info_stru = conn.recv(4)
#解包文件信息的长度
file_info_len = struct.unpack('i',file_info_stru)[0]
#然后接收文件的描述信息
client_file_info = conn.recv(file_info_len).decode("utf-8")
#接收到的json字符串反序列化
abc_file_info = json.loads(client_file_info)
print('abc_file_info>>>>>',abc_file_info)
client_file_size = abc_file_info['file_size']
recv_all_size = 0

#拼接字符串路径
client_file_path = client_file_path + "\\" + abc_file_info["file_name"]
# client_file_path = os.path.join(client_file_path,abc_file_info['file_name'])
with open(client_file_path,'wb') as f:
    while recv_all_size < client_file_size:
        every_recv_data = conn.recv(1024)
        f.write(every_recv_data)
        recv_all_size += len(every_recv_data)
conn.send('小伙子玩的可以呀,上传成功!'.encode("utf-8"))
conn.close()
tcp_server.close()

  client端:

import socket,struct,os
import json
tcp_client = socket.socket()
server_ip = ("127.0.0.1",8080)
tcp_client.connect(server_ip)
read_size = 1024
file_info = {
    "file_path":r"D:\py\Socket\task\aaa.mp4",
    "file_name":"aaa.mp4",
    "file_size":None,
}
#获取文件大小
file_size = os.path.getsize(file_info["file_path"])
#将文件大小添加到文件信息的字典中
file_info["file_size"] = file_size
#要发送的数据是字节类型,那么必须将字典转换为bytes类型,但是字典不能直接转换位bytes,所以想到了用json
#通过json模块将字典类型的文件信息转化为了json类型的字符串
file_info_json = json.dumps(file_info)
#获取了字符串的长度
file_info_len = len(file_info_json)
#打包成4个字节的数据
file_info_stru = struct.pack('i',file_info_len)
#将打包好的4个字节的数据和我们的文件信息数据一起发给服务端
tcp_client.sendall(file_info_stru)
tcp_client.sendall(file_info_json.encode("utf-8"))
#统计文件数据
all_file_data = b''
#统计文件数据长度
all_size_len = 0
with open(file_info['file_path'],'rb') as f:
    while all_size_len < file_size:
        every_read_data = f.read(read_size)
        all_file_data += every_read_data
        all_size_len += len(every_read_data)
        #发送每次读取的数据
        tcp_client.send(every_read_data)
print(tcp_client.recv(1024).decode("utf-8"))
tcp_client.close()

 

转载于:https://www.cnblogs.com/LW-5208/articles/9589274.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值