Python网络编程——UDP

Python网络编程——UDP

UDP

IP 协议只负责尝试将每个数据包传输到正确的机器。如果2个独立的应用程序要维护一个会话的话,还需要两个额外的特性。这两个特性是由 IP 层以上的协议来提供的。

  • 需要为两台主机之间传输的大量数据包打上标签,这样就可以将网页的数据包和用于电子邮件的数据包区分开来,而这两种数据包也可以与该机器正在进行的其他网络会话使用的数据包分隔开,这一过程叫做多路复用(multiplexing)
  • 对两台主机间独立传输的数据包流发生任何错误,都需要进行修复,而丢失的数据包也需要进行重新传输,直至成功发送到目的地。另外,如果数据包到达时顺序错乱,则要将这些包重组回正常的顺序。最后要丢弃重复的数据包,以保证数据流中的信息没有冗余,提供这些保证的特性叫做可靠传输(reliable transport)

第一个是用户数据报协议(UDP),UDP协议只解决上述的第一个问题,UDP 协议提供了一个端口号,用于对目标为同一机器上的不同服务的多个数据包进行适当的多路分解。虽然支持多路复用和分解,但使用 UDP 协议的网络程序仍需要自己处理丢包重包和包的乱序问题。

第二个是传输控制协议(TCP),TCP 解决了上述2个问题,它跟 UDP 一样,使用了端口号来支持多路复用和
分解。除此之外,TCP 还保证数据流的顺序以及可靠传输,这样一来,尽管连续的数据流在传输时被分为多个数据包吗,而后在接收端再进行重组,但是这些细节都对应用层隐藏了。

端口号

在计算机网络和电磁信号理论中,对共享同一通信的多个信号进行区分是个常见的问题。多路复用(multiplexing) 就是允许多个会话共享同一介质或机制的一种解决方案。UDP 的设计者从数字领域分析,为每个 UDP 数据包分配了一对无符号16位端口号(port number),从0到65536。源端口(source port)标识了源机器上发送数据包的特定程序或者进程,而目标端口(destination port)则标识了目标 IP 地址上进行该会话的特定应用程序。

Source(IP: port number) -> Destination(IP: port number)

UDP 就仅仅使用 IP 地址和端口号进行标识,将数据包发送至目标地址。客户端想要获悉这些需要连接的端口号,会采用下面的方法:

  1. 惯例:互联网号码分配机构(IANA, Internet Assigned Number Authority),为许多专用服务分配了官方端口,如 DNS 默认为53号 UDP 端口。一般0-1023都被分配给了最重要最常用的服务。而1024-49151这些注册端口,在操作系统层没有任何特别之处,你可以占用,但一般会有一些应用申请或者默认端口选择在这里。剩余的就是可以随意使用的端口,当客户端无需指定特殊的端口时,现代操作系统会维护一个端口池来随机选取端口提供给该应用。

  2. 自动配置,如通过 DHCP 这样的协议获取一些重要服务的 IP,和知名端口号结合就可以访问这些基础服务。

  3. 手动配置,即手动配置 IP 地址或相应的服务域名。

例子

我们使用 socket 来实现一个简单的 udp 客户端和服务端

udp_local.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 使用自环接口的 udp服务器和客户端.py
import argparse
import socket
from datetime import datetime

MAX_BYTES = 65536


def server(port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", port))
print("Listening at {}".format(sock.getsockname()))
while True:
data, address = sock.recvfrom(MAX_BYTES)
text = data.decode("ascii")
print("The client at {} says {!r}".format(address, text))
text = "Your data was {} bytes long".format(len(data))
data = text.encode("ascii")
sock.sendto(data, address)


def client(port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
text = "The time is {} seconds".format(datetime.now())
data = text.encode("ascii")
sock.sendto(data, ("127.0.0.1", port))
print("The OS assigned me the address {}".format(sock.getsockname()))
data, address = sock.recvfrom(MAX_BYTES) # !Danger
text = data.decode("ascii")
print("The server {} replied {!r}".format(address, text))


if __name__ == "__main__":
choices = {"client": client, "server": server} # 函数列表
# argparse 是一个用来解析命令行参数的 Python 库
# 创建解析器对象并且加上描述
parser = argparse.ArgumentParser(description="Send and receive UDP locally")
parser.add_argument("role", choices=choices.keys(), help="which role to play")
parser.add_argument("-p", metavar="PORT", type=int, default=1060, help="UDP port (default 1060)")
# action参数的'store_true'指的是:触发 action时为真,不触发则为假。即储存了一个bool变量,默认为 false,触发不用赋值即变为true
# type 指定参数类别 默认是str 传入数字要定义
# help 是一些提示信息
# default 是默认值
# metavar 在 usage 说明中的参数名称,对于必选参数默认就是参数名称,对于可选参数默认是全大写的参数名称
# https://docs.python.org/zh-cn/3/library/argparse.html
args = parser.parse_args()
func = choices[args.role]
func(args.p)

首先使用 socket() 创建了一个空套接字,标记了所属的特定的类别:协议族 AF_INET 以及数据类型 SOCK_DGRAM,后者表示在 IP 网络上使用 UDP 协议。需要注意的是,数据报(datagram)是用来表示应用层数据块传输的官方术语。操作系统的网络栈并不保证传输线路上的单个数据包实际表示的就是单个数据报。

服务程序是一个循环,不断运行 recvfrom(), recvfrom(MAX_BYTES)表示可以最大接受 65535 字节的数据,在没有数据之前,recvfrom() 将永远保持等待。一旦接受到数据报,recvfrom() 返回两个值,第一个是发送数据的客户端地址,第二个是字节表示的数据报内容。与之相对的是客户端函数 sendto(),指定发送信息和目标地址。

1
2
3
4
5
6
(venv) D:\my_py36\Python-Web\udp>python udp_local.py server
Listening at ('127.0.0.1', 1060)
# 执行一次客户端
The client at ('127.0.0.1', 51384) says 'The time is 2020-09-28 16:16:34.259636 seconds'
# 执行第二次客户端
The client at ('127.0.0.1', 51385) says 'The time is 2020-09-28 16:16:44.724149 seconds'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(venv) D:\my_py36\Python-Web\udp>python udp_local.py server
Traceback (most recent call last):
File "udp_local.py", line 48, in <module>
func(args.p)
File "udp_local.py", line 11, in server
sock.bind(("127.0.0.1", port))
OSError: [WinError 10048] 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。
# 执行第一次客户端程序
(venv) D:\my_py36\Python-Web\udp>python udp_local.py client
The OS assigned me the address ('0.0.0.0', 51384)
The server ('127.0.0.1', 1060) replied 'Your data was 46 bytes long'
# 执行第二次客户端程序
(venv) D:\my_py36\Python-Web\udp>python udp_local.py client
The OS assigned me the address ('0.0.0.0', 51385)
The server ('127.0.0.1', 1060) replied 'Your data was 46 bytes long'

混杂客户端与垃圾回复

上面的例子看起来很美好,但实际上有很大的问题,客户端并不会去校验这条消息有没有发给服务端,假如我们暂停服务器一会,开启一个新的程序起一个新的socket,向客户端地址发消息,那么客户端就会认为是服务器发的。这样他人就可以轻松的伪造成服务端。

需要注意,客户端面对任何可以向其发送 UDP 数据包的终端都是脆弱的,这与中间人攻击(http面临的问题)不同,中间人攻击是取得了网络的控制权后从非法的地址发送伪造的数据包,这种情况只能通过加密来保护。

像这样不考虑地址是否正确,接受并且处理所有收到的数据包的网络监听器客户端在技术上叫做混杂模式(Promiscuous)客户端,有时我们也会需要这样的客户端,如进行网络监控的时候,需要监控到达某一个接口的所有数据包。

不可靠性,退避,阻塞和超时

我们的例子运行在同一个机器上,客户端和服务端通过自环接口来进行通信,而没有使用可能会产生信号故障的网卡,因此数据包不可能丢失,我们模拟一下丢包的情况,重新写这个客户端和服务端程序:

udp_remote.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import argparse
import socket
import random
import sys
from datetime import datetime

MAX_BYTES = 65536


def server(interface, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((interface, port))
print("Listening at {}".format(sock.getsockname()))
while True:
data, address = sock.recvfrom(MAX_BYTES)
if random.random() < 0.5:
print("Pretending to drop packet from {}".format(address))
continue

text = data.decode("ascii")
print("The client at {} says {!r}".format(address, text))
text = "Your data was {} bytes long".format(len(data))
data = text.encode("ascii")
sock.sendto(data, address)


def client(hostname, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
hostname = sys.argv[2]
sock.connect((hostname, port))
print("client socket name is {}".format(sock.getsockname()))
text = "The time is {} seconds".format(datetime.now())
data = text.encode("ascii")
delay = 0.1
while True:
sock.send(data)
print("Waiting up to {} seconds for a reply".format(delay))
sock.settimeout(delay)
try:
data = sock.recv(MAX_BYTES)
except socket.timeout:
delay = delay * 2 # 等待更久
if delay > 2.0:
raise RuntimeError("May be the server is down")
else:
break
print("The server says {!r}".format(data.decode("ascii")))


if __name__ == "__main__":
choices = {"client": client, "server": server}
parser = argparse.ArgumentParser(description="Send and receive UDP, pretending packet dropped in transmission")
parser.add_argument("role", choices=choices.keys(), help="which role to take")
parser.add_argument("host", help="interface the server listens at / host the client sends to")
parser.add_argument("-p", metavar="PORT", type=int, default=1060, help="UDP port (default 1060)")
args = parser.parse_args()
func = choices[args.role]
func(args.host, args.p)

UDP 客户端要处理丢包现象,由于不确定没有收到包是因为响应时间过长还是丢失响应,首先要选择一个等待时间,超过等待时间间隔还没有响应就重新发送请求,我们例子中设定 delay = 0.5s,设置 timeout 时间,当超时时捕获抛出的异常。当然,我们不希望不断发一个会丢失的数据包,所以尝试重发数据包的频率应该越来越低,这就是指数退避(exponential backoff)技术,我们例子中是一种简单的方法,每次延长2倍时间等待,当延长时间到达一定数量时可以认为服务器宕机了(当然 UDP 是无法判断是否真的宕机了,只能推测)。

当然,编写需要不断重发数据包的守护程序代码时就不要遵循指数退避策略,这样很容易导致延时被拉的很大,最好的办法就是设置一个最大值,超过后每次都使用最大值作为等待时间,这样保证计算机长时间离线恢复之后也可以较快的尝试重发。

请求 ID

重发可以解决丢包问题,但带来的是冗余问题,假设我们发送了请求A,很久之后也没得到相应,于是就重发,然后得到了响应A,这时如果你发一个请求B,拿到了一个响应,假如第一次发的A包真的丢失了,没有任何问题;但如果没有丢失只不过是响应很慢,这样有可能你第二次请求拿到了第一次响应的结果,而这次请求B有可能回来的结果是A请求的结果,这样就彻底乱套了。

使用请求 ID 就是为了解决这种问题,我们给A,B请求不同的 ID,接受响应只收对应 ID 的,这样就可以解决重复问题,当然我们设定一个 ID 的范围,在一定程度上也可以防止一些伪造响应或者请求。

UDP 分组

程序中 UDP 数据包最大可以达到 64KB,而以太网和无线网卡只能处理 1500B 左右的数据包。事实上,UDP 必须把较大的 UDP 数据报分为多个较小的数据报,这样就能够以单独 IP 数据包的形式在网络中发送这些数据。这意味着,较大的数据包在传输过程中更容易发生丢包现象,因为只要它分割出任一小数据包没有传至目标地址,便无法重组出原始的大数据包。

如果运行了防火墙,那么本机主机通常可以自动检测出与远程主机的 MTU(最大传输单元,最大数据包容量),如果不注意的话,较大的 UDP 数据包可能会被遗忘。

UDP 广播

UDP 支持广播,通过广播可以将数据的目标地址设置为本机连接的整个子网,然后使用物理网卡将数据报广播,这样就无需再复制该数据包并单独发给所有连接至该子网的主机了。由于有了多播(multicast)的技术,广播被认为是过时了,经过多播,现代操作系统能够更好的利用网络以及网络接口设备提供的许多只能信息,另外多播支持费本地子网上的主机。不过想用一种简单的方法在本地 LAN 上完成一些偶尔允许丢包的功能(如游戏客户端货值自动实时记分牌)的话,UDP 广播是个简单易行的选择。

udp_broadcast.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import argparse
import socket

BUFSIZE = 65536


def server(interface, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((interface, port))
print("Listening for datagram at {}".format(sock.getsockname()))
while True:
data, address = sock.recvfrom(BUFSIZE)
text = data.decode("ascii")
print("The client at {} says: {!r}".format(address, text))


def client(network, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
text = "Broadcast datagram!"
sock.sendto(text.encode("ascii"), (network, port))



if __name__ == "__main__":
choices = {"client": client, "server": server}
parser = argparse.ArgumentParser(description="Send, receive UDP broadcast")
parser.add_argument("role", choices=choices.keys(), help="which role to take")
parser.add_argument("host", help="interface the server listens at / host the client sends to")
parser.add_argument("-p", metavar="PORT", type=int, default=1060, help="UDP port (default 1060)")
args = parser.parse_args()
func = choices[args.role]
func(args.host, args.p)

连着网络然后查查本机在内网的 IP 地址,运行客户端,将ip地址前一部分不变,最后改为 .255 (广播段),然后用 wireshark 之类的抓包工具就可以看到你发的这个 UDP 广播,当然你也可以邀请你的小伙伴跑一下server(在 0.0.0.0)来看看有没收到这个包。

Author

Ctwo

Posted on

2020-09-27

Updated on

2020-10-25

Licensed under

Comments