Python 29 网络编程 UDP协议和粘包

Python 29 网络编程 UDP协议和粘包

旧博客Python系列

UDP协议的服务端与客户端

由于UDP不像TCP一样需要握手,所以只需要绑定端口以后直接开始通信即可,不像TCP一样需要listen.
# UDPserver
from socket import *

udp_server = socket(AF_INET, SOCK_DGRAM)
udp_server.bind(ip_port)

while True:
    data = udp_server.recvfrom(buffer_size)
    print(data)
# UDPclient
from socket import *

ip_port = ('127.0.0.1', 8080)
udp_client = socket(AF_INET, SOCK_DGRAM)

while True:
    udp_client.sendto('hello'.encode('utf-8'), ip_port)

唯一需要注意的是,udp的sendto需要把ip和端口号在每次发送的时候都写上

在可以建立了简单的服务端和客户端之后,由于双方往来发送的介质就是字节流的字符串消息,所以可以针对这个消息,在客户端和服务端加入各种逻辑,就可以实现简单的功能.比如可以做一个通过远程执行命令的服务端和客户端.
这里需要用到subprocess模块

# server
from socket import *
import subprocess

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

while True:
    print('server is ready.')
    conn, addr = tcp_server.accept()

    while True:
        try:
            data = conn.recv(buffer_size)
            if not data:
                conn.close()
                break
            res = subprocess.Popen(data.decode('utf-8'),
                                   shell=True, stdout=subprocess.PIPE,
                                   stdin=subprocess.PIPE,
                                   stderr=subprocess.PIPE) # 这句是执行结果,将结果分别放入标准输出,标准输入和标准错误里
            err = res.stderr.read()  # 读取标准错误,如果标准错误不是空,则返回错误信息
            if err:
                res_data = err
            else:
                res_data = res.stdout.read()

            if not res_data:         # 即使标准错误为空,但是很可能执行成功后不返回结果,此时再加一层判断,如果还是为空,说明命令成功执行,也返回一段东西,不会导致客户端卡住
                conn.send('Command sucessfully executed'.encode('gbk'))  
            else:
                conn.send(res_data)  # 注意,系统执行的结果虽然是字节,但是以系统默认的gbk编码,所以在接收端需要改成编码为gbk
                print('向客户端传送的消息是: ', res_data.decode('gbk'))  
        except Exception:
            conn.close()
            break

客户端基本没变化,主要是在显示的时候,需要采用gbk进行解码.

from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_client = socket(AF_INET, SOCK_STREAM)

tcp_client.connect(ip_port)

while True:
    msg = input('>>>: ').strip()
    if not msg:
        continue
    if msg=='quit':
        break
    tcp_client.send(msg.encode('utf-8'))
    print('向服务端发送了:',msg)
    data = tcp_client.recv(buffer_size)
    print('客户端收到的是: ',data.decode('gbk'),sep='\n')

tcp_client.close()

粘包

用上边的程序执行了一下ipconfig/all 结果发现没有收完,又运行了一下dir,结果发现收到了ipconfig/all的数据,再dir,发现又乱了. 这是因为缓冲区内的数据太多,在第一次取的时候没有取完,下次再用recv的时候又接着取了1024字节的数据.看来需要用一种方法,让客户端来取数据的时候,收到符合要求指定长度的数据. 由于TCP是保证字节流的传输,而这些字节如何分割,是应用程序的事,如果不对消息的边界做处理,则应用程序无法正常通信.这种情况叫做[粘包](https://wiesen.github.io/post/tcp%E6%96%AD%E5%8C%85%E7%B2%98%E5%8C%85%E9%97%AE%E9%A2%98/). 只有TCP协议有粘包现象,UDP协议没有粘包现象.

UDP不基于连接,所以可以任意发,如果缓冲区不够,收的少,剩下的部分就全部都丢失了.通过UDP可以测试到,如果没收完,后边的数据直接就丢弃了,但TCP就会粘在一起.
原因是:
应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。怎样定义消息呢?可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。
做一个简单的测试:

# server
from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

conn,addr = tcp_server.accept()

data = conn.recv(buffer_size)
print('first data: ', data.decode('utf-8'))

data = conn.recv(buffer_size)
print('2nd data: ', data.decode('utf-8'))

data = conn.recv(buffer_size)
print('3rd data: ', data.decode('utf-8'))
# client
from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024

tcp_client = socket(AF_INET, SOCK_STREAM)

tcp_client.connect(ip_port)

tcp_client.send('hello'.encode('utf-8'))
tcp_client.send('hell0'.encode('utf-8'))
tcp_client.send('hellO'.encode('utf-8'))

tcp_client.close()

可以看到虽然客户端发送了三次,但是服务端第一次就已经收到了全部的消息.这是收取的比数据量大发生的粘包.
如果将每次的buffer_size改成5,即每次取5个,则发现可以得到了正确的消息.收取的比数据量小,一样可以发生粘包.只要理解了收发缓冲区的概念,那么就可以来尝试解决粘包的问题:即要想办法确认数据有多长.

因为粘包是发送的时候粘在一起,可以想到,在发送之前,先向另外一端发一个长度,对方收到长度之后返回一个标记,之后再传输数据,然后再用一个循环接受所有数据直到长度到达之前传送的长度.用一个比较简单的版本可以解决粘包问题,那就是每个通信循环对于服务端来说是1收1发,只要额外增加一个发送长度的环节和收到确认的环节,改成2收2发即可,客户端则对应修改成2发2收.

# server
from socket import *
import subprocess

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024
tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
tcp_server.bind(ip_port)
tcp_server.listen(back_log)

while True:
    print('server is ready.')
    conn, addr = tcp_server.accept()
    while True:
        try:
            data = conn.recv(buffer_size) # 第一次接收,接收指令
            if not data:
                conn.close()
                break
            res = subprocess.Popen(data.decode('utf-8'),
                                   shell=True, stdout=subprocess.PIPE,
                                   stdin=subprocess.PIPE,
                                   stderr=subprocess.PIPE)
            err = res.stderr.read()
            if err:
                res_data = err
            else:
                res_data = res.stdout.read()

            if not res_data:
                res_data = 'Command sucessfully executed'.encode('gbk')
            data_length = str(len(res_data))  # 获得要发送的数据包长度
            conn.send(data_length.encode('utf-8'))  # 第一次发送,先把长度发掉
            client_ready = conn.recv(buffer_size)   # 第二次接收,接收客户端返回的准备信息,说明客户端接受到了长度
            if client_ready.decode('utf-8') == ""ready"": # 第二次发送,判断客户端准备好之后,发送信息
                conn.send(res_data)
        except Exception:
            conn.close()
            break
# client
from socket import *

ip_port = ('127.0.0.1', 8080)
back_log = 5
buffer_size = 1024
tcp_client = socket(AF_INET, SOCK_STREAM)
tcp_client.connect(ip_port)

while True:
    msg = input('>>>: ').strip()
    if not msg:
        continue
    if msg == 'quit':
        break
    tcp_client.send(msg.encode('utf-8'))  # 第一次发送,发送指令
    length = int(tcp_client.recv(buffer_size).decode('utf-8'))   # 第一次接收,获得长度
    tcp_client.send(b'ready')  # 第二次发送,发送就绪消息
    recv_size = 0
    recv_msg = b''
    while recv_size < length:  # 第二次接收,用一个循环不断接收消息后拼接消息,直到长度OK,然后一次循环完成,等待下一个通信循环
        cmd_res = tcp_client.recv(buffer_size)
        recv_msg += cmd_res
        recv_size += len(cmd_res)
    print('客户端消息长度:{},收到的是:\n{}'.format(length, recv_msg.decode('gbk')))
tcp_client.close()

再来分析一下看看,可以知道,实际上就是在每次传输数据之前,传输了一部分额外的数据用于确定长度.如果客户端和服务端都是自己写的,那完全可以减少发送的次数,只要约定好,发送数据的前一个固定的部分是表示长度,或者还可以附带其他信息,也就无所谓粘包了,对于另一端来讲,只需要先解析头部数据,再按照长度拿后边的数据就可以了.这其实,就是在写自己的软件内部的通信协议.固定的字节数表示一个范围内的数字,可以用struct来完成.而文件名称和其他信息,可以用pickle来转换成字节,这样就构成了 报头长度--报头本身--数据流 的形式,解析的时候先解析固定长度的报头长度,得到报头本身的长度,按照这个长度解析报头,然后开始传输数据即可.具体代码就不写了,因为通过struct来固定长度不是一个很好的做法,看看以后会有什么做法.这里关键是本质,就是想办法让另外一端了解长度.

LICENSED UNDER CC BY-NC-SA 4.0
Comment