Python Socket 编程指南

470 阅读31分钟

翻译自: realpython.com/python-sock…

源码见: realpython/python-sockets-tutorial

文中大量机翻,仅供自己学习查阅,不当之处,敬请谅解

套接字 (Socket) 和套接字 API 用于在网络上发送消息。它们提供了一种进程间通信(IPC)形式。这个网络可以是计算机的逻辑本地网络,也可以是物理上连接到外部网络的网络,其自身连接到其他网络。最明显的例子就是互联网,你可以通过 ISP 连接到它。

在本教程中,你将创建:

  • 一个简单的 socket 服务器和客户端
  • 同时处理多个连接的改进版本;
  • 一个服务器-客户端应用程序,其功能类似于成熟的 socket应用程序,具有自己的自定义头和内容。

在本教程结束时,你将了解如何使用 Python 的 socket 模块中的主要函数和方法来编写自己的 client-server 应用程序。你将知道如何使用自定义类在客户端服务端点之间发送消息和数据,你可以在此基础上构建并将其用于你自己的应用程序。

本教程中的示例需要 Python 3.6 或更高版本,并且已经使用 Python 3.10 进行了测试。为了充分利用本教程,最好下载源代码,以便在阅读时随时参考。

网络和套接字是很大的主题。关于它们的著作已经大量成册。如果你刚接触套接字或网络,如果你对所有的术语和部件感到不知所措,这是完全正常的。

但不要气馁。本教程是为你准备的!与任何 Python 相关的东西一样,你可以一次学习一点点。收藏这篇文章,准备好阅读下一节时再回来。

背景

套接字有着悠久的历史。它们的使用起源于 1971 年的 ARPANET,后来成为发布于 1983 年的 Berkeley Software Distribution (BSD)操作系统中的 API,称为 Berkeley Socket

当互联网在 20 世纪 90 年代随着万维网(World Wide Web)而兴起时,网络编程也兴起了。Web 服务器和浏览器并不是唯一利用新连接的网络和使用套接字的应用程序。各种类型和大小的客户机-服务器应用程序也开始广泛使用。

今天,尽管套接字 API 所使用的底层协议经过了多年的发展,并且还开发了新的协议,但底层 API 仍然保持不变。

最常见的套接字应用程序类型是客户端-服务器应用程序,其中一方充当服务器并等待来自客户端的连接。这就是你将在本教程中创建的应用程序类型。更具体地说,你将关注用于 Internet socket 的套接字 API,有时称为 Berkeley 或 BSD 套接字。还有 Unix域套接字,只能用于同一主机上的进程之间的通信。

Socket API 概述

Python 的 socket 模块提供了 Berkeley sockets API 接口。这是你将在本教程中使用的模块。

这个模块中主要的 Socket API 函数和方法是:

  • socket()
  • .bind()
  • .listen()
  • .accept()
  • .connect()
  • .connect_ex()
  • .send()
  • .recv()
  • .close()

Python 提供了一个方便且一致的 API,可以直接映射到系统调用。在下一节中,我们将学习如何将它们结合使用。

作为标准库的一部分,Python 还提供了一些类,让我们使用这些低级套接字函数更加容易。虽然在本教程中没有涉及,但你可以查看 socketserver模块,这是一个用于网络服务器的框架。还有许多模块可以实现更高级别的 Internet 协议,如 HTTP 和 SMTP。有关概述,请参见 Internet协议和支持

TCP Socket

你将使用 socket.socket() ​创建一个套接字对象,并指定套接字类型为 socket.SOCK_STREAM​。这样做时,使用的默认协议是 Transmission Control Protocol (TCP)。这是一个很好的默认值,可能也是你想要的。

为什么要使用 TCP?传输控制协议(TCP):

  • 可靠性:在网络中丢弃的报文会被检测到并被发送方重传。
  • 有序性:应用程序按照发送方写入数据的顺序读取数据。

相反,使用 socket.SOCK_DGRAM ​创建的 User Datagram Protocol (UDP)套接字是不可靠的,接收方读取的数据可能与发送方写入的数据顺序不一致。

为什么这很重要?网络是一个 best-effort 传输系统。它并不能保证你的数据将到达目的地,也不能保证你能收到发送给你的数据。

网络设备(如路由器和交换机)可用带宽有限,并有其固有的系统限制。它们有 CPU、内存、总线和接口包缓冲区,就像你的客户机和服务器一样。TCP 使你不必担心数据包丢失、无序数据到达以及在网络上通信时经常发生的其他缺陷。

为了更好地理解这一点,请查看 TCP 的套接字 API 调用和数据流的顺序: image.png

左边一列表示服务端,右边是客户端。从左上一列开始,注意服务端为设置监听套接字而进行的 API 调用:

  • socket()
  • .bind()
  • .listen()
  • .accpet()

监听套接字就像它的名字所暗示的那样,它监听来自客户端的连接。当客户端连接时,服务器调用 .accept() ​来接受或完成连接。

客户端调用 .connect() ​来建立到服务器的连接并发起三次握手。握手步骤很重要,因为它确保网络中连接的每一方都可以到达,换句话说,客户端可以到达服务器,反之亦然。可能只有一台主机、客户机或服务器可以连接到另一台。

中间是往返部分,调用 .send() ​和 .recv() ​在客户端和服务器之间交换数据。

最后,客户端和服务器关闭各自的套接字。

Echo 客户端和服务器

现在你已经了解了套接字 API 以及客户端和服务器之间如何通信,接下来就可以创建第一个客户机和服务器了。你将从一个简单的实现开始。服务器将简单地将它接收到的任何信息回传给客户端。

Echo 服务端

这是服务端:

# echo-server.py

import socket

HOST = "127.0.0.1"  # Standard loopback interface address (localhost)
PORT = 65432  # Port to listen on (non-privileged ports are > 1023)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

注意:现在不要担心理解上面的所有内容。在这几行代码中发生了很多事情。这只是一个起点,这样你就可以看到一个基础服务端的运行情况。

在本教程的末尾有一个参考部分,其中有更多信息和其他资源的链接。你还可以在整个教程中找到这些和其他有用的链接。

好吧,那么在 API 调用中究竟发生了什么?

socket.socket() ​创建了一个支持上下文管理器类型的套接字对象,因此可以在 with语句中使用它。这样就不用调用 s.close()​:

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    pass  # Use the socket object without calling s.close().

传递给 socket()的参数是用于指定地址族和套接字类型的常量AF_INET ​是 IPv4 的 Internet 地址族。SOCK STREAM ​是 TCP 的套接字类型,用于在网络中传输消息的协议。

.bind() ​方法用于将套接字与特定的网络接口和端口号关联起来

# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    # ...

传递给 .bind() ​的值取决于套接字的地址族。在本例中,你将使用 socket.AF_INET​ (IPv4)。因此它需要一个二元组:(host, port)​。

host ​可以是主机名、IP地址或空字符串。如果使用 IP 地址,host ​应该是 IPv4 格式的地址字符串。IP 地址 127.0.0.1 ​是环回接口的标准 IPv4 地址,因此只有主机上的进程能够连接到服务器。如果传递空字符串,服务器将接受所有可用 IPv4 接口上的连接。

port ​表示接收来自客户端连接的 TCP端口号。它应该是 1 ​到 65535 ​之间的整数,因为 0 ​是保留的。如果端口号小于 1024​,某些系统可能需要超级用户权限。

这里有一个注意使用主机名与 .bind()​:

如果你在 IPv4/v6 套接字地址的 host 部分中使用了一个主机名,此程序可能会表现不确定行为,因为 Python 使用 DNS 解析返回的第一个地址。套接字地址在实际的 IPv4/v6 中以不同方式解析,根据 DNS 解析和/或 host 配置。为了确定行为,在 host 部分中使用数字的地址。(来源)

稍后,你将在使用主机名中了解更多。现在,只要理解在使用主机名时,根据名称解析过程返回的内容,你可能会看到不同的结果。这些结果可能是任何东西。第一次运行应用程序时,可能会得到地址 10.1.2.3​。下一次,你将得到一个不同的地址,192.168.0.1​。第三次,可以得到 172.16.7.8​,依此类推。

在这个服务端示例中,.listen() ​使服务器能够接受连接。它使服务器成为一个“监听”套接字:

# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    # ...

.listen() ​方法有一个 backlog ​参数。它指定系统在拒绝新连接之前允许的未接受连接的数量。从 Python 3.5 开始,它是可选的。如果未指定,则选择默认的 backlog ​值。

如果服务器同时接收大量连接请求,通过增加 backlog ​值来设置挂起连接队列的最大长度可能会有所帮助。最大值与系统有关。例如,在 Linux 操作系统中,请参见/proc/sys/net/core/somaxconn

.accept() ​方法阻塞执行并等待传入连接。当客户端连接时,它返回一个表示连接的新套接字对象和一个保存客户端地址的元组。元组将包含 (host, port) ​用于 IPv4 连接或 (host, port, flowinfo, scopeid) ​用于 IPv6 连接。有关元组值的详细信息,请参阅参考部分中的套接字地址族

必须理解的一件事是,现在从 .accept() ​获得了一个新的套接字对象。这很重要,因为你将使用这个套接字与客户机通信。它不同于服务器用来接受新连接的监听套接字:

# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

.accept() ​提供客户端套接字对象 conn ​之后,将使用无限 while ​循环来阻塞调用 conn.recv()​。它将读取客户端发送的任何数据,并使用 conn.sendall() ​进行回显。

如果 conn.recv()返回一个空的字节对象 b''​,这表明客户端关闭了连接,循环结束。with语句conn ​一起使用,在块的末尾自动关闭套接字。

Echo 客户端

现在,让我们看看客户端:

# echo-client.py

import socket

HOST = "127.0.0.1"  # The server's hostname or IP address
PORT = 65432  # The port used by the server

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b"Hello, world")
    data = s.recv(1024)

print(f"Received {data!r}")

与服务器相比,客户端非常简单。它创建了一个套接字对象,使用 .connect() ​连接到服务器,并调用 s.sendall() ​发送它的消息。最后,它调用 s.recv() ​来读取服务器的回复,然后打印它。

运行 Echo 客户机和服务器

在本节中,你将运行客户机和服务器,以查看它们的行为并检查正在发生的事情。

注意:如果你在从命令行运行示例或自己的代码时遇到困难,请阅读如何使用Python创建自己的命令行命令?怎样运行Python脚本。如果你使用的是 Windows,请查看 Python Windows FAQ

打开终端或命令提示符,导航到包含脚本的目录,确保安装了 Python 3.6 或更高版本,然后运行服务器脚本:

$ python echo-server.py

你的终端将显示挂起。这是因为服务器在 .accept() ​上被阻塞或挂起:

# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

它正在等待客户端连接。现在,打开另一个终端窗口或命令提示符并运行客户端:

$ python echo-client.py 
Received b'Hello, world'

在服务器窗口中,你应该注意到这样的东西:

$ python echo-server.py 
Connected by ('127.0.0.1', 64623)

在上面的输出中,服务器打印了 s.f accept()返回的 addr 元组。这是客户端的 IP 地址和 TCP 端口号。端口号 64623 在你的机器上运行时很可能不同。

查看 Socket 状态

要查看主机上套接字的当前状态,请使用 netstat​。它在 macOS、Linux 和 Windows 上默认可用。

下面是启动服务器后 macOS 的 netstat ​输出:

$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  127.0.0.1.65432        *.*                    LISTEN

注意,本地地址 ​是 127.0.0.1.65432​。如果 echo-server.py ​使用 HOST = "" ​而不是 HOST = "127.0.0.1"​, netstat ​将显示如下:

$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  *.65432                *.*                    LISTEN

本地地址 ​为 *.65432​,这意味着所有支持地址族的可用主机接口都将用于接受传入连接。本例中调用 socket() ​时使用了 socket.AF_INET​(IPv4)。你可以在 Proto ​列中看到 :tcp4​。

上面的输出经过调整,只显示 echo 服务器。你可能会看到更多的输出,这取决于你所运行的系统。需要注意的是 Proto​、Local Address ​和 (state) ​列。在上面的最后一个例子中,netstat 显示 echo 服务器在 65432 ​端口上的所有接口(*.65432​)的使用 IPv4 TCP 套接字(tcp4),并且处于侦听状态(LISTEN​)。

访问上述内容以及其他有用信息的另一种方法是使用 lsof​(列出打开的文件)。它在 macOS 上默认可用,在 Linux 上如果还没有安装,可以使用包管理器安装:

$ lsof -i -n
COMMAND     PID   USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
Python    67982 nathan    3u  IPv4 0xecf272      0t0  TCP *:65432 (LISTEN)

当使用 -i ​选项时,lsof ​会提供打开 Internet 套接字的 COMMAND​、PID​(进程 ID)和 USER​(用户 ID)。上面是 echo server 进程。

netstat ​和 lsof ​有很多可用的选项,根据运行它们的操作系统而有所不同。检查两者的手册页或文档。他们绝对值得花点时间去了解。你会得到回报的。在 macOS 和 Linux 操作系统下,请使用 man netstat ​和 man lsof​。Windows 操作系统使用 netstat /?

下面是一个常见的错误,当你试图连接一个没有监听套接字的端口时,你会遇到:

$ python echo-client.py 
Traceback (most recent call last):
  File "./echo-client.py", line 9, in <module>
    s.connect((HOST, PORT))
ConnectionRefusedError: [Errno 61] Connection refused

要么指定的端口号错误,要么服务器没有运行。或者可能路径上有防火墙阻碍了连接,这很容易被忘记。你还可能看到错误连接超时。添加一个防火墙规则,允许客户端连接到 TCP 端口!

参考资料部分有一个常见错误列表

通信故障

现在我们来仔细看看客户端和服务器是如何通信的:

image.png

使用环回接口(IPv4 地址 127.0.0.1​ ​或 IPv6 地址 ::1​​)时,数据不会离开主机,也不会接触到外部网络。在上图中,环回接口包含在主机内部。这代表了环回接口的内部性质,并表明传输它的连接和数据是本地的。这就是为什么你会听到环回接口和 IP 地址 127.0.0.1​ ​或 ::1​ ​被称为“本地主机”。

应用程序使用环回接口与运行在主机上的其他进程通信,并用于安全的与外部网络隔离。因为它是内部的,只能从主机内部访问,所以它不会暴露。

如果你有一个使用自己私有数据库的应用程序服务器,则可以看到这一点的实际效果。如果它不是一个供其他服务器使用的数据库,它可能被配置为只监听环回接口上的连接。如果是这种情况,网络上的其他主机就无法连接到它。

当你在应用程序中使用 127.0.0.1 ​或 ::1 ​以外的 IP 地址时,它可能与连接到外部网络的以太网接口绑定。这是你访问“localhost”王国之外其他主机的网关:

image.png

在外面要小心。这是一个肮脏、残酷的世界。在离开“localhost”的安全范围之前,请务必阅读本节的“使用主机名”。即使你不使用主机名而只使用 IP 地址有一个安全注意事项适用于你。

处理多个连接

echo 服务端肯定有它的局限性。最大的问题是它只服务一个客户端,然后退出。echo 客户端也有这个限制,但还有一个问题。当客户端使用 s.recv() ​时,它可能只会从 b'hello, world' ​返回一个字节 b'H'​:

# echo-client.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b"Hello, world")
    data = s.recv(1024)

print(f"Received {data!r}")

上面使用的 bufsize ​参数 1024 ​是一次性接收的最大数据量。这并不意味着。.recv() ​将返回 1024 ​字节。

.send() ​方法也是如此。它返回发送的字节数,可能小于传入数据的大小。你负责检查这一点,并在需要发送所有数据时多次调用 .send()​:

“应用程序负责检查所有数据是否已发送;如果只传输了部分数据,应用程序需要尝试传输剩余的数据。”(来源)

在上面的例子中,通过使用 .sendall() ​可以避免这样做:

send() ​不同,这个方法会继续从字节中发送数据,直到所有数据都发送完或发生错误。成功返回 None​。”(来源)

现在有两个问题:

  • 如何同时处理多个连接?
  • 你需要调用 .send() ​和 .recv()​,直到所有数据发送或接收完毕。

你能做什么?有很多并发的方法。一种流行的方法是使用异步I/Oasyncio ​是在 Python 3.4 中引入的标准库。传统的选择是使用线程

并发的问题是很难正确处理。有许多微妙之处需要考虑和防范。只要其中一个出现,你的应用程序就可能会以不那么微妙的方式突然失败。

这并不是要吓退你学习和使用并发编程。如果你的应用需要扩展,如果你想使用多个处理器或一个内核,这是必要的。然而,在本教程中,你将使用比线程更传统且更容易理解的东西。这里要用到系统调用的鼻祖:.select()​。

使用 .select() ​方法可以检查多个套接字的 I/O 完成情况。因此,你可以调用。select() ​来查看哪些套接字已经准备好读取或写入 I/O。但这是 Python,所以还有更多。我们将使用标准库中的 selectors 模块,这样才能使用最有效的实现,无论运行的是什么操作系统:

“此模块允许高层级且高效率的 I/O 复用,它建立在 select`` ​模块原型的基础之上。 推荐用户改用此模块,除非他们希望对所使用的 OS 层级原型进行精确控制。”(来源)

不过,使用 .select() ​仍然不能并发运行。也就是说,根据你的工作负载,这种方法可能仍然非常快。这取决于你的应用程序在处理请求时需要做什么,以及它需要支持的客户端数量。

asyncio``​ 使用单线程协作多任务和事件循环来管理任务。使用 .select()​,就可以编写自己的事件循环了,不过更简单,也更同步。当使用多线程时,即使你有并发,你目前也必须对 CPython 和 PyPy 使用 GIL(全局解释器锁)。这有效地限制了你可以并行执行的工作量。

也就是说,使用 .select() ​可能是一个完美的选择。不要觉得你必须使用 asyncio​、线程或最新的异步库。通常,在网络应用程序中,你的应用程序无论如何都是 I/O 密集型的:它可能在本地网络上等待,等待网络另一端的端点,等待磁盘写入,等等。

如果你从客户端收到的请求发起了 CPU 密集型工作,请查看 concurrent.futures 模块。它包含 ProcessPoolExecutor 类,该类使用一个进程池来异步执行调用。

如果你使用多个进程,操作系统能够调度你的 Python 代码在多个处理器或核心上并行运行,而不需要 GIL。有关想法和灵感,请参阅 PyCon talk John Reese - Thinking Outside the GIL with AsyncIO and Multiprocessing - PyCon 2018

在下一节中,你将看到解决这些问题的服务端和客户端的例子。它们使用 .select() ​来同时处理多个连接,并根据需要多次调用 .send() ​和 .recv()​。

多连接客户端和服务器

在接下来的两节中,我们将使用从 selectors 模块中创建的 selector​ ​对象创建一个服务器和客户端,处理多个连接。

多连接服务器

首先,将注意力转向多连接服务器。第一部分设置监听套接字:

# multiconn-server.py

import sys
import socket
import selectors
import types

sel = selectors.DefaultSelector()

# ...

host, port = sys.argv[1], int(sys.argv[2])
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print(f"Listening on {(host, port)}")
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)

这个服务器和上一节回显服务器最大的区别在于调用了 lsock.setblocking(False)​ 将套接字配置为非阻塞模式。对这个套接字的调用将不再阻塞。当它与 sel.select()​ 一起使用时,你将在下面看到,你可以等待一个或多个套接字上的事件,然后在准备好时读写数据。

sel.register() ​用 sel.select() ​注册要监控的套接字,让它包含你感兴趣的事件。对于监听的套接字,需要读取事件:selectors.EVENT_READ​。

要在套接字中存储你想要的任何数据,就要使用 data​。它在 .select() ​返回时返回。你将使用 data ​来记录 socket 发送和接收的内容。

接下来是事件循环:

# multiconn-server.py

# ...

try:
    while True:
        events = sel.select(timeout=None)
        for key, mask in events:
            if key.data is None:
                accept_wrapper(key.fileobj)
            else:
                service_connection(key, mask)
except KeyboardInterrupt:
    print("Caught keyboard interrupt, exiting")
finally:
    sel.close()

sel.select(timeout=None)阻塞调用,直到用于 I/O 的套接字准备就绪。它返回一个元组列表,每个套接字对应一个元组。每个元组包含一个 key ​和一个 mask​。key ​是一个 SelectorKey namedtuple``​,包含一个 fileobj​ 属性。key.fileobj​ 是 socket 对象,mask​ 是就绪操作的事件掩码

如果 key.data​ 是 None​,那么你知道它来自监听的套接字,并且你需要接受连接。调用自己的 accept_wrapper() ​函数获取新的套接字对象,并将其注册到选择器中。你马上就会看到。

如果 key.data​ 不是 None​,那么你就知道它是一个已经被接受的客户端 socket,你需要为它提供服务。然后调用 service_connection()​,将 key ​和 mask ​作为参数,这就是对 socket 进行操作所需的一切。

下面是你的 accept_wrapper() ​函数的作用:

# multiconn-server.py

# ...

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"")
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    sel.register(conn, events, data=data)

# ...

因为监听 socket 被注册为 selectors.EVENT_READ​,它应该可以读取了。调用 sock.accept()​,然后调用 conn.setblocking(False)​,将套接字设置为 非阻塞 模式。

记住,这是这个版本服务器的主要目标,因为你不希望它阻塞。如果它阻塞了,那么整个服务器将处于停顿状态,直到它返回。这意味着即使服务器没有积极工作,其他套接字也会等待。我们不希望服务器处于的可怕的“挂起”状态。

接下来,使用 SimpleNamespace 创建一个对象来保存你想要包含在套接字中的数据。因为你想知道客户端连接何时准备好读取和写入,所以这两个事件都使用按位或操作符设置:

# multiconn-server.py

# ...

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"")
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    sel.register(conn, events, data=data)

# ...

events ​掩码、socket 和数据对象随后传递给 sel.register()​。

现在看一下 service_connection()​,看看客户端连接就绪时是如何处理的:

# multiconn-server.py

# ...

def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            data.outb += recv_data
        else:
            print(f"Closing connection to {data.addr}")
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

# ...

这是简单的多连接服务器的核心。key ​是 .select() ​返回的 namedtuple​,它包含套接字对象(fileobj​)和数据对象。mask ​包含已经准备好的事件。

如果 socket 准备读取,那么 mask & selectors.EVENT_READ ​将被计算为 True​,因此 sock.recv() ​被调用。读取的任何数据都会被添加到 data.outb ​中以便稍后可以发送。

注意 else: ​块用于检查是否没有接收到数据:

# multiconn-server.py

# ...

def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            data.outb += recv_data
        else:
            print(f"Closing connection to {data.addr}")
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

# ...

如果没有收到任何数据,这意味着客户端已经关闭了套接字,所以服务器也应该关闭。但是不要忘记在关闭之前调用 sel.unregister()​,这样 .select()​就不会监视它了。

当 socket 准备写入时(正常的 socket 应该总是这样),接收到的任何数据都存储在 data.outb​中,使用 sock.send()​将 data.outb​回显给客户端。发送的字节会从发送缓冲区中移除(13 行):

# multiconn-server.py

# ...

def service_connection(key, mask):

    # ...

    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]  # <==

# ...

.send()​方法返回发送的字节数。然后可以在 .outb​缓冲区上使用这个数字和切片符号来丢弃已发送的字节。

多连接客户端

现在来看看多连接客户端 multiconn-client.py​。它和服务器非常相似,但不是监听连接,而是通过 start_connections()​初始化连接:

# multiconn-client.py

import sys
import socket
import selectors
import types

sel = selectors.DefaultSelector()
messages = [b"Message 1 from client.", b"Message 2 from client."]

def start_connections(host, port, num_conns):
    server_addr = (host, port)
    for i in range(0, num_conns):
        connid = i + 1
        print(f"Starting connection {connid} to {server_addr}")
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setblocking(False)
        sock.connect_ex(server_addr)
        events = selectors.EVENT_READ | selectors.EVENT_WRITE
        data = types.SimpleNamespace(
            connid=connid,
            msg_total=sum(len(m) for m in messages),
            recv_total=0,
            messages=messages.copy(),
            outb=b"",
        )
        sel.register(sock, events, data=data)

# ...

num_conns​ 从命令行读取要创建到服务器的连接数。和服务器一样,每个 socket 都被设置为非阻塞模式。

这里使用 .connect_ex()``​而不是 .connect()​,因为 .connect()​会立即引发 BlockingIOError​异常。.connect_ex()​方法最初返回一个错误指示符 errno.EINPROGRESS​,而不是抛出异常,干扰正在进行的连接。连接完成后,就可以读写由 .select()​返回的 socket。

在 socket 设置好之后,使用 SimpleNamespace​ 创建要存储在套接字中的数据。客户端发送到服务器的消息会使用 messages.copy()​复制,因为每个连接都会调用 socket.send()​并修改这个列表。记录客户端需要发送、已发送和已接收的内容(包括消息的总字节数),都存储在对象数据中。

看看服务器的 service_connection()​对客户端版本所做的更改:

def service_connection(key, mask):
     sock = key.fileobj
     data = key.data
     if mask & selectors.EVENT_READ:
         recv_data = sock.recv(1024)  # Should be ready to read
         if recv_data:
-            data.outb += recv_data
+            print(f"Received {recv_data!r} from connection {data.connid}")
+            data.recv_total += len(recv_data)
-        else:
-            print(f"Closing connection {data.connid}")
+        if not recv_data or data.recv_total == data.msg_total:
+            print(f"Closing connection {data.connid}")
             sel.unregister(sock)
             sock.close()
     if mask & selectors.EVENT_WRITE:
+        if not data.outb and data.messages:
+            data.outb = data.messages.pop(0)
         if data.outb:
-            print(f"Echoing {data.outb!r} to {data.addr}")
+            print(f"Sending {data.outb!r} to connection {data.connid}")
             sent = sock.send(data.outb)  # Should be ready to write
             data.outb = data.outb[sent:]

它们在根本上是一样的,但有一个重要的区别。客户端记录从服务器接收到的字节数,以便关闭自己这一边的连接。当服务器检测到这一点时,它也会关闭自己的连接。

注意,这样做的前提是客户端行为良好:服务器希望客户端在发送完消息后关闭自己的连接。如果客户端不关闭,服务器将保持连接打开。在实际的应用程序中,你可能希望通过实现一个超时来防止服务器中出现这种情况,如果客户端在一段时间后没有发送请求,则可以防止连接累积。

运行多连接客户端和服务器

现在该运行 multiconn-server.py​ 和 multiconn-client.py​ 了。它们都使用命令行参数。你可以不带参数运行它们以查看选项。

对于服务器,传入 host​和 port​:

$ python multiconn-server.py
Usage: multiconn-server.py <host> <port>

对于客户端,还要将要创建的连接数传给服务器和 num_connections​:

$ python multiconn-client.py
Usage: multiconn-client.py <host> <port> <num_connections>

下面是服务器在 65432​ 端口上监听环回接口时的输出:

$ python multiconn-server.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)
Accepted connection from ('127.0.0.1', 61354)
Accepted connection from ('127.0.0.1', 61355)
Echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61354)
Echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61355)
Closing connection to ('127.0.0.1', 61354)
Closing connection to ('127.0.0.1', 61355)

下面是客户端创建两个到服务器的连接时的输出:

$ python multiconn-client.py 127.0.0.1 65432 2
Starting connection 1 to ('127.0.0.1', 65432)
Starting connection 2 to ('127.0.0.1', 65432)
Sending b'Message 1 from client.' to connection 1
Sending b'Message 2 from client.' to connection 1
Sending b'Message 1 from client.' to connection 2
Sending b'Message 2 from client.' to connection 2
Received b'Message 1 from client.Message 2 from client.' from connection 1
Closing connection 1
Received b'Message 1 from client.Message 2 from client.' from connection 2
Closing connection 2

太棒了!现在已经运行了多连接客户端和服务器。在下一节中,你将进一步学习这个例子。

客户端和服务器应用程序

与刚开始的例子相比,多连接客户端和服务器的例子绝对是一个改进。不过,现在你可以再迈出一步,在最终的实现中解决前面 multiconn​示例的缺点:客户端和服务器应用程序。

你希望客户端和服务器能够正确地处理错误,这样其他连接就不会受到影响。显然,如果没有捕获异常,你的客户端或服务器不应该突然崩溃。到目前为止,你还不必担心这个问题,因为为了简洁和清晰,示例中有意省略了错误处理。

现在你已经熟悉了基本的 API、非阻塞套接字和 .select()​,可以添加一些错误处理,解决“房间里的大象”问题(明明存在但人们不愿提及的棘手问题)了,例子把这个问题藏在了那边的大帘子后面。还记得前面介绍中提到的自定义类吗?这就是你接下来要探索的。

首先,处理错误:

所有的错误都会引发异常。对于无效参数类型和内存不足条件的正常异常可以被抛出;从 Python 3.3 开始,与套接字或地址语义相关的错误将引发 OSError​ 或其子类之一。(来源)

你需要做的一件事是捕获 OSError​。与错误有关的另一个重要考虑因素是超时。你会在文档的很多地方看到对它们的讨论。发生超时,这是所谓的正常错误。主机和路由器重新启动,交换机端口坏了,线缆坏了,线缆拔掉了,你能想到的都有。你应该为这些错误和其他错误做好准备,并在代码中处理它们。

那房间里的大象呢?正如 socket 类型所暗示的那样。SOCK_STREAM,当使用 TCP 时,你从一个连续的字节流中读取。这就像从磁盘上读取文件一样,只不过是从网络中读取字节。然而,与读取文件不同的是,这里没有 f.s ecseek()。

那房间里的大象呢?正如 socket 类型 socket.SOCK_STREAM​所暗示的那样,当使用 TCP 时,你从一个连续的字节流中读取。这就像从磁盘上读取文件一样,只不过是从网络中读取字节。然而,与读取文件不同,这里没有 f.seek()``​.。

换句话说,你不能重新定位套接字指针(如果有的话),也不能移动数据。

当字节到达 socket 时,就会涉及到网络缓冲区。一旦你读了它们,它们就需要保存在某个地方,否则你会丢弃他们。再次调用 .recv()​从套接字中读取下一个可用的字节流。

你会从 socket 中分块读取数据。因此,需要调用 .recv()​并将数据保存在缓冲区中,直到读取到足够的字节,得到对应用程序有意义的完整消息。

由你来定义和跟踪消息边界的位置。就 TCP 套接字而言,它只是在网络上收发原始字节。它不知道这些原始字节是什么意思。

这就是为什么你需要定义一个应用层协议。什么是应用层协议?简而言之,你的应用程序将发送和接收消息。这些消息的格式是应用程序的协议。

换句话说,你为这些消息选择的长度和格式定义了应用程序的语义和行为。这与我们在前一段中学到的从套接字读取字节直接相关。使用 .recv()​读取字节时,需要了解读取了多少字节,并确定消息的边界。

那要怎么做呢?一种方法是始终发送固定长度的消息。如果它们总是一样的大小,那就很简单了。当你将这些字节读入缓冲区后,就得到了一条完整的消息。

但是,对于需要使用填充物来填充的固定长度小消息,使用定长消息是低效的。此外,你仍然面临如何处理无法放入一条消息的数据的问题。

在本教程中,你将学习一种通用的方法,它被许多协议使用,包括 HTTP。你将以消息头作为前缀,其中包括内容长度以及你需要的任何其他字段。这样做的话,你只需要先读取到消息头即可。一旦你阅读了消息头,你就可以处理它来确定消息内容的长度。有了内容长度,你就可以读取该字节数来使用它。

你将通过创建一个自定义类来实现这一点,该类可以发送和接收包含文本或二进制数据的消息。你可以为自己的应用程序改进和扩展这个类。最重要的是,你将能够看到一个示例,说明如何做到这一点。

在开始之前,你需要了解一些关于 socket 和 bytes 的信息。如前所述,通过 socket 发送和接收数据时,发送和接收的是原始字节。

如果你接收到的数据需要被解释为多个字节,比如一个 4 字节的整数,那么你需要考虑数据的格式可能不是机器 CPU 原生的。另一端的客户端或服务器的 CPU 可能与你的 CPU 使用不同的字节顺序。如果是这种情况,你需要在使用它之前将其转换为主机的本机字节序。

这种字节顺序称为 CPU 的字节序。详情请参见参考资料部分中的字节序。你可以利用 Unicode 作为消息头并使用编码 UTF-8 来避免这个问题。因为 UTF-8 使用 8 位编码,所以没有字节顺序问题。

你可以在 Python 的编码和 Unicode 文档中找到解释。注意,这只适用于文本标题。你将使用在头中定义的显式类型和编码来发送的内容,即消息有效载荷。这将允许你以任何格式传输任何数据(文本或二进制)。

使用 sys.byteorder​ 可以很容易地确定计算机的字节顺序。例如,你可能会看到这样的代码:

$ python -c 'import sys; print(repr(sys.byteorder))'
'little'

如果你在模拟大端 CPU (PowerPC)的虚拟机中运行此程序,则会发生以下情况:

$ python -c 'import sys; print(repr(sys.byteorder))'
'big'

在这个示例应用程序中,你的应用层协议将标头定义为 UTF-8 编码的 Unicode 文本。对于消息中的实际内容,即消息有效载荷,如果需要,你仍然需要手动交换字节顺序。

这取决于你的应用程序,以及它是否需要处理来自不同端序机器的多字节二进制数据。你可以通过添加额外的首部来帮助客户端或服务器实现二进制支持,并使用它们来传递参数,这与 HTTP 类似。

如果你还不明白,也不用担心。在下一节中,你将看到所有这些是如何工作并相互配合的。

应用协议头

现在你将完整的定义协议头。协议首部是:

  • 变长文本
  • 使用 UTF-8 编码的 Unicode
  • 使用 JSON 序列化的 Python 字典

协议首部字典中所需的首部(或子首部)如下所示:

名称描述
byteorder机器的字节顺序(使用 sys.byteorder​)。这对于你的应用程序可能不是必需的。
content-length内容的长度,以字节为单位。
content-type负载中的内容类型,例如 text/json​ 或 binary/my-binary-type​。
content-encoding内容使用的编码,例如,utf-8​ 用于 Unicode 文本,binary​ 用于二进制数据。

这些标头告诉接收者消息有效载荷的内容。这允许你发送任意数据,同时提供足够的信息,以便接收方可以正确地解码和解释内容。因为首部保存在字典中,所以很容易根据需要插入键值对来添加其他首部。

发送应用消息

还有一点问题。我们有一个可变长度的头文件,这很好也很灵活,但使用 .recv()​读取头文件时,如何知道头文件的长度呢?

在前面学习 .recv()​和消息边界时,我们还了解到定长首部的效率可能很低。这是对的,但你将使用一个小的、2 字节的、固定长度的消息头为包含其长度的 JSON 头加上前缀。

你可以将其视为发送消息的混合方法。实际上,你通过先发送消息头的长度来引导消息接收过程。这使得你的接收者很容易解构信息。

为了让你更好地了解消息格式,请查看完整的消息:

image.png

消息以两个字节的定长首部开始,这是一个按网络字节顺序排列的整数。这是下一个头文件的长度,即变长 JSON 头文件。一旦使用 .recv()​读取了两个字节,就可以将这两个字节作为整数处理,然后在解码 UTF-8 JSON 首部之前读取该字节数。

JSON 首部包含一个包含其他首部的字典。其中之一是 content-length​,它是消息内容的字节数(不包括 JSON 首部)。调用 .recv()​并读取 content-length​ 字节后,就到达了消息边界,也就是说已经读取了整条消息。

应用消息类

最后,回报的时候到了!在本节中,你将学习 Message​类,并了解在套接字上发生读写事件时如何与 .select()​一起使用 Message​ 类。

这个示例应用程序反映了客户端和服务器可以合理使用哪些类型的消息。在这一点上,你远远超出了玩具 echo 客户端和服务器!

为了保持简单,并仍然演示如何在实际应用程序中工作,本示例使用了实现基本搜索特性的应用程序协议。客户端发送一个搜索请求,服务器对匹配进行查找。如果客户端发送的请求没有被识别为搜索,服务器就认为这是一个二进制请求,并返回一个二进制响应。

在阅读以下部分、运行示例并试验代码之后,你将了解事情的工作原理。然后,你可以使用 Message​类作为起点,并修改它以供自己使用。

该应用程序与多连接客户端和服务器示例相差无几。事件循环代码在 app-client.py​ 和 app-server.py​中保持不变。你要做的是将消息代码移动到一个名为 Message​的类中,并添加方法来支持读、写和处理标题和内容。这是一个使用的很好的例子。

正如你之前学到的以及下面将看到的,使用套接字涉及保持状态。通过使用类,你可以将所有状态、数据和代码捆绑在一个有组织的单元中。当连接启动或接受时,将为客户端和服务器中的每个 socket 创建类的实例。

对于包装器和实用方法,客户端和服务器中的类基本上是相同的。它们以下划线开头,如 Message._json_encode()​。这些方法简化了类的工作。它们可以帮助其他方法保持更短的时间,并支持 DRY 原则。

服务器的 Message​类基本上与客户端的 Message​类的工作方式相同,反之亦然。不同之处在于客户端发起连接并发送请求消息,然后处理服务器的响应消息。相反,服务器等待连接,处理客户机的请求消息,然后发送响应消息。

它看起来像这样:

步骤端点动作/ 消息内容
1Client发送一个包含请求内容的 Message
2Server接收并处理客户端请求的 Message
3Server发送一个包含响应内容的 Message
4Client接收并处理服务器响应的 Message

下面是文件和代码布局:

应用文件代码
Serverapp-server.py服务端主脚本
Serverlibserver.py服务端 Message​ 类
Clientapp-client.py客户端主脚本
Clientlibclient.py客户端 Message​ 类

消息入口点

理解 Message​ 类的工作原理可能是一个挑战,因为它的设计中有一个方面可能不是很明显。为什么?管理状态。

创建 Message​ 对象后,将它关联到一个 socket 上,并使用 selector.register()​监控事件(9-10 行):

# app-server.py

# ...

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    message = libserver.Message(sel, conn, addr)
    sel.register(conn, selectors.EVENT_READ, data=message)

# ...

注意:本节中的一些代码示例来自服务器的主脚本和消息类,但本节和讨论同样适用于客户端。当客户端版本发生变化时,你将收到通知。

在 socket 上准备好事件时,事件会通过 selector.select()​返回。然后,你可以使用 key​ 对象的 data​ 属性并调用 Message​ 中的方法来获取 message 对象的引用:

# app-server.py

# ...

try:
    while True:
        events = sel.select(timeout=None)
        for key, mask in events:
            if key.data is None:
                accept_wrapper(key.fileobj)
            else:
                message = key.data
                try:
                    message.process_events(mask)
                # ...

# ...

看看上面的事件循环,你会发现 sel.select()​占据了主导地位。它是阻塞的,在循环的顶部等待事件。它负责在套接字上准备处理读写事件时被唤醒。这间接意味着,它还负责调用 .process_events()​方法。这就是为什么 .process_events()​是入口点。

下面是 .process_events()​方法的实现:

# libserver.py

# ...

class Message:
    def __init__(self, selector, sock, addr):
        # ...

    # ...

    def process_events(self, mask):
        if mask & selectors.EVENT_READ:
            self.read()
        if mask & selectors.EVENT_WRITE:
            self.write()

    # ...

这很好:.process_events()​很简单。它只能做两件事:调用 .read()​和 .write()​。

这就是管理状态的意义所在。如果另一个方法依赖于具有特定值的状态变量,那么只能从 .read()​和 .write()​中调用它们。这使得逻辑尽可能简单,因为事件是在套接字上进行处理的。

你可能想混合使用一些方法来检查当前状态变量,并根据它们的值在 .read()​或 .write()​之外调用其他方法来处理数据。最终,这可能会被证明过于复杂,难以管理和跟上。

当然,你应该根据自己的需要修改这个类,使其最适合你,但如果尽可能将状态检查和依赖于该状态的方法调用保留给 .read()​和 .write()​方法,可能会得到最好的效果。

现在看看 .read()​。这是服务器的版本,但客户端的版本是一样的。它只是使用了不同的方法名,即 .process_response()​而不是 .process_request()​:

# libserver.py

# ...

class Message:

    # ...

    def read(self):
        self._read()

        if self._jsonheader_len is None:
            self.process_protoheader()

        if self._jsonheader_len is not None:
            if self.jsonheader is None:
                self.process_jsonheader()

        if self.jsonheader:
            if self.request is None:
                self.process_request()

    # ...

首先调用 ._read()​ ​方法。它调用 socket.recv()​ ​从套接字中读取数据并将其存储在接收缓冲区中。

记住,当调用 socket.recv() ​时,组成完整消息的所有数据可能还没有到达。socket.recv() ​可能需要再次调用。这就是为什么在调用处理消息的适当方法之前,要对消息的每个部分进行状态检查。

在一个方法处理它的那部分消息之前,它首先检查是否有足够的字节被读入接收缓冲区。如果有,则处理各自的字节,从缓冲区中删除它们,并将其输出写入一个变量,供下一个处理阶段使用。因为一个消息有三个组件,所以有三个状态检查和 process​ 方法调用:

Message ComponentMethodOutput
Fixed-length headerprocess_protoheader()self._jsonheader_len
JSON headerprocess_jsonheader()self.jsonheader
Contentprocess_request()self.request

接下来,看看 .write()​。这是服务器的版本:

# libserver.py

# ...

class Message:

    # ...

    def write(self):
        if self.request:
            if not self.response_created:
                self.create_response()

        self._write()

    # ...

.write() ​方法首先检查是否有 request​。如果存在响应并且还没有创建响应,则调用 .create_response()​。.create_response() ​方法设置状态变量 response_created ​并将响应写入发送缓冲区。

如果发送缓冲区中有数据,._write() ​方法会调用 socket.send()​。

记住,当调用 socket.send() ​时,发送缓冲区中的所有数据可能还没有排队等待传输。socket 的网络缓冲区可能已满,因此可能需要再次调用 socket.send()​。这就是为什么要进行状态检查。.create_response() ​方法应该只被调用一次,但是 ._write() ​将需要被调用多次。

客户端版本的 .write() ​是类似的:

# libclient.py

# ...

class Message:
    def __init__(self, selector, sock, addr, request):
        # ...

    def write(self):
        if not self._request_queued:
            self.queue_request()

        self._write()

        if self._request_queued:
            if not self._send_buffer:
                # Set selector to listen for read events, we're done writing.
                self._set_selector_events_mask("r")

    # ...

因为客户端首先发起到服务器的连接并发送请求,所以会检查状态变量 _request_queued​。如果一个请求没有排队,它会调用 .queue_request()​。queue_request() ​方法创建请求并将其写入发送缓冲区。它还设置了状态变量 _request_queued​,这样它就只被调用一次。

就像服务器一样,如果发送缓冲区中有数据,._write() ​会调用 socket.send()​。

客户端版本的 .write() ​的显著区别是最后检查请求是否已经排队。这将在客户端主脚本节中解释更多,但这样做的原因是告诉 selector.select() ​停止监视套接字的写入事件。如果请求已经排队,并且发送缓冲区是空的,那么你就完成了写操作,只对读事件感兴趣。没有理由通知我们 socket 是可写的。

总结一下这一节,考虑一下这个想法:这一节的主要目的是解释通过 .process_events()​ 方法在 Message​ 类中调用 selector.select()​,并描述如何管理状态。

这一点很重要,因为 .process_events() ​将在连接的生命周期中被调用多次。因此,要确保只调用一次的方法要么检查状态变量本身,要么检查该方法设置的状态变量。

服务端主脚本

在服务器的主脚本 app-server.py ​中,参数从指定要监听的接口和端口的命令行中读取:

$ python app-server.py
Usage: app-server.py <host> <port>

例如,要监听端口 65432 ​上的环回接口,输入:

$ python app-server.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)

<host> ​使用空字符串可以监听所有接口。

创建套接字后,调用 socket.setsockopt()​,参数为 socket.SO_REUSEADDR​:

# app-server.py

# ...

host, port = sys.argv[1], int(sys.argv[2])
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Avoid bind() exception: OSError: [Errno 48] Address already in use
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind((host, port))
lsock.listen()
print(f"Listening on {(host, port)}")
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)

# ...

设置此选项可以避免 Address already in use ​错误。在连接状态为 TIME_WAIT 的端口上启动服务器时,你将看到这一点。

例如,如果服务器主动关闭了一个连接,它将保持在 TIME_WAIT ​状态两分钟或更长时间,这取决于操作系统。如果你试图在 TIME_WAIT ​状态过期之前再次启动服务器,那么你会得到 Address already in use ​的 OSError ​异常。这是一种保护措施,以确保网络中任何延迟的数据包都不会传递给错误的应用程序。

在事件循环中捕获所有错误,让服务器可以继续运行:

# app-server.py

# ...

try:
    while True:
        events = sel.select(timeout=None)
        for key, mask in events:
            if key.data is None:
                accept_wrapper(key.fileobj)
            else:
                message = key.data
                try:
                    message.process_events(mask)
                except Exception:
                    print(
                        f"Main: Error: Exception for {message.addr}:\n"
                        f"{traceback.format_exc()}"
                    )
                    message.close()
except KeyboardInterrupt:
    print("Caught keyboard interrupt, exiting")
finally:
    sel.close()

当客户端连接被接受时,会创建一个 Message​ 对象(9 行):

# app-server.py

# ...

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    message = libserver.Message(sel, conn, addr)
    sel.register(conn, selectors.EVENT_READ, data=message)

# ...

Message ​对象在调用 sel.register() ​时与套接字关联,并最初被设置为仅监控读取事件。读取请求后,我们将修改它,使其只监听写事件。

在服务器端采用这种方式的好处是,在大多数情况下,当一个 socket 是健康且没有网络问题时,它始终是可写的。

如果你告诉 sel.register() ​也监控 EVENT_WRITE​,那么事件循环将立即被唤醒并通知你是这种情况。然而,在这一点上,没有理由在套接字上唤醒并调用 .send()​。没有响应要发送,因为还没有处理请求。这将消耗和浪费宝贵的 CPU 周期。

服务端 Message 类

消息入口点一节中,你了解了当 socket 事件通过 .process_events() ​准备就绪时,如何调用 Message ​对象。现在,你将了解在套接字上读取数据,并且服务器准备处理消息的某个组件或部分时会发生什么。

服务器的 message 类在 libserver.py ​中,它是你之前下载的源代码的一部分。你也可以通过点击下面的链接下载代码:

**获取源代码:**​点击这里获取本教程中示例的源代码

这些方法按照处理消息的顺序出现在类中。

当服务器至少读取了两个字节时,就可以处理定长首部了:

# libserver.py

# ...

class Message:
    def __init__(self, selector, sock, addr):
        # ...

    # ...

    def process_protoheader(self):
        hdrlen = 2
        if len(self._recv_buffer) >= hdrlen:
            self._jsonheader_len = struct.unpack(
                ">H", self._recv_buffer[:hdrlen]
            )[0]
            self._recv_buffer = self._recv_buffer[hdrlen:]

    # ...

定长首部是一个 2 字节的网络整数,或大端字节序。它包含 JSON 头的长度。你将使用 struct.unpack()来读取值,解码它,并将其存储在 self._jsonheader_len ​中。处理完它负责的消息片段后,.process_protoheader() ​将其从接收缓冲区中移除。

就像固定长度的头文件一样,当接收缓冲区中有足够的数据来包含 JSON 头文件时,它也可以被处理:

# libserver.py

# ...

class Message:

    # ...

    def process_jsonheader(self):
        hdrlen = self._jsonheader_len
        if len(self._recv_buffer) >= hdrlen:
            self.jsonheader = self._json_decode(
                self._recv_buffer[:hdrlen], "utf-8"
            )
            self._recv_buffer = self._recv_buffer[hdrlen:]
            for reqhdr in (
                "byteorder",
                "content-length",
                "content-type",
                "content-encoding",
            ):
                if reqhdr not in self.jsonheader:
                    raise ValueError(f"Missing required header '{reqhdr}'.")

    # ...

调用 self._json_decode() ​方法来解码并反序列化 JSON 头文件到字典中。因为 JSON 头文件被定义为 UTF-8 编码的 Unicode, UTF-8 ​在调用中被硬编码。结果被保存到 self.jsonheader ​中。处理完它负责的消息片段后,process_jsonheader() ​将其从接收缓冲区中移除。

接下来是消息的实际内容或有效载荷。它由 self.jsonheader ​中的 JSON 头文件描述。当接收缓冲区中有 content-length ​字节时,请求可以被处理:

# libserver.py

# ...

class Message:

    # ...

    def process_request(self):
        content_len = self.jsonheader["content-length"]
        if not len(self._recv_buffer) >= content_len:
            return
        data = self._recv_buffer[:content_len]
        self._recv_buffer = self._recv_buffer[content_len:]
        if self.jsonheader["content-type"] == "text/json":
            encoding = self.jsonheader["content-encoding"]
            self.request = self._json_decode(data, encoding)
            print(f"Received request {self.request!r} from {self.addr}")
        else:
            # Binary or unknown content-type
            self.request = data
            print(
                f"Received {self.jsonheader['content-type']} "
                f"request from {self.addr}"
            )
        # Set selector to listen for write events, we're done reading.
        self._set_selector_events_mask("w")

    # ...

在将消息内容保存到 data ​变量之后,.process_request() ​将其从接收缓冲区中移除。然后,如果内容类型是 JSON, .process_request() ​将其解码并反序列化。如果不是,这个示例应用程序假定它是一个二进制请求,并简单地打印内容类型。

.process_request() ​做的最后一件事是修改选择器,使其只监控写事件。在服务器的主脚本 app-server.py ​中,套接字最初被设置为只监控读取事件。现在请求已经被完全处理,你不再对读取感兴趣了。

现在可以创建响应并将其写入套接字。当套接字可写时,会从 .write() ​中调用 .create_response()​:

# libserver.py

# ...

class Message:

    # ...

    def create_response(self):
        if self.jsonheader["content-type"] == "text/json":
            response = self._create_response_json_content()
        else:
            # Binary or unknown content-type
            response = self._create_response_binary_content()
        message = self._create_message(**response)
        self.response_created = True
        self._send_buffer += message

响应是通过调用其他方法创建的,具体取决于内容类型。在这个示例应用程序中,当 action == 'search' ​时,对 JSON 请求进行简单的字典查找。对于你自己的应用程序,你可以定义在此处调用的其他方法。

在创建响应消息之后,状态变量 self.response_created​ 被设置为 True​,因此 .write() ​不会再次调用 .create response()​。最后,将响应添加到发送缓冲区。这可以通过 ._write() ​看到并发送。

需要解决的一个棘手问题是如何在写入响应后关闭连接。你可以在方法 ._write() ​中调用 .close()​:

# libserver.py

# ...

class Message:

    # ...

    def _write(self):
        if self._send_buffer:
            print(f"Sending {self._send_buffer!r} to {self.addr}")
            try:
                # Should be ready to write
                sent = self.sock.send(self._send_buffer)
            except BlockingIOError:
                # Resource temporarily unavailable (errno EWOULDBLOCK)
                pass
            else:
                self._send_buffer = self._send_buffer[sent:]
                # Close when the buffer is drained. The response has been sent.
                if sent and not self._send_buffer:
                    self.close()

    # ...

尽管它有点隐藏,但考虑到 Message ​类每个连接只处理一条消息,这是一个可以接受的权衡。在写入响应之后,服务器就什么都不需要做了。它完成了它的工作。

客户端主脚本

在客户端的主脚本 app-client.py ​中,参数从命令行中读取,并用于创建请求和启动到服务器的连接:

$ python app-client.py
Usage: app-client.py <host> <port> <action> <value>

下面是一个例子:

$ python app-client.py 127.0.0.1 65432 search needle

在通过命令行参数创建了一个表示请求的字典之后,主机、端口和请求字典被传递给 .start_connection()​:

# app-client.py

# ...

def start_connection(host, port, request):
    addr = (host, port)
    print(f"Starting connection to {addr}")
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setblocking(False)
    sock.connect_ex(addr)
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    message = libclient.Message(sel, sock, addr, request)
    sel.register(sock, events, data=message)

# ...

为服务器连接创建一个套接字,以及使用 request ​字典创建一个 Message ​对象。

与服务器一样,Message ​对象在调用 sel.register() ​时关联到套接字。然而,对于客户端来说,套接字最初被设置为同时监视读事件和写事件。一旦写好请求,就可以修改它,让它只监听读事件。

这种方法具有与服务器相同的优点:不浪费 CPU 周期。请求发送后,你对写事件不再感兴趣,因此没有理由唤醒并处理它们。

客户端 Message 类

消息入口点一节中,你了解了当 socket 事件通过 .process_events() ​准备就绪时,如何调用 Message ​对象。现在,你将了解在套接字上读写数据、消息准备以便让客户端处理之后会发生什么。

客户端的 message 类在 libclient.py ​文件中,它是你之前下载的源代码的一部分。你也可以通过点击下面的链接下载代码:

**获取源代码:**​点击这里获取本教程中示例的源代码

这些方法按照处理消息的顺序出现在类中。

客户端的第一个任务是将请求排队:

# libclient.py

# ...

class Message:

    # ...

    def queue_request(self):
        content = self.request["content"]
        content_type = self.request["type"]
        content_encoding = self.request["encoding"]
        if content_type == "text/json":
            req = {
                "content_bytes": self._json_encode(content, content_encoding),
                "content_type": content_type,
                "content_encoding": content_encoding,
            }
        else:
            req = {
                "content_bytes": content,
                "content_type": content_type,
                "content_encoding": content_encoding,
            }
        message = self._create_message(**req)
        self._send_buffer += message
        self._request_queued = True

    # ...

根据命令行中传递的内容,用于创建请求的字典位于客户端的主脚本 app-client.py ​中。当创建 Message ​对象时,请求字典作为参数传递给类。

请求消息被创建并添加到发送缓冲区,然后通过 ._write() ​查看并发送。状态变量 self._request_queued ​设置为 True​,这样就不会再次调用 .queue_request()​。

发送请求后,客户端等待服务器的响应。

在客户端读取和处理消息的方法与在服务器端相同。当响应数据从 socket 中读取时,process ​头方法被调用:.process_protoheader() ​和 .process_jsonheader()​。

区别在于最终的 process ​方法的命名,以及它们正在处理响应,而不是创建响应:.process_response()​、._process_response_json_content() ​和 ._process_response_binary_content()​。

最后,但同样重要的是,最后调用 .process_response()​:

# libclient.py

# ...

class Message:

    # ...

    def process_response(self):

        # ...

        # Close when response has been processed
        self.close()

    # ...

Message 类封装

为了总结你对 Message ​类的学习,值得一提的是一些支持方法需要注意的重要事情。

类引发的任何异常都会被主脚本在事件循环中的 except ​子句中捕获:

# app-client.py

# ...

try:
    while True:
        events = sel.select(timeout=1)
        for key, mask in events:
            message = key.data
            try:
                message.process_events(mask)
            except Exception:
                print(
                    f"Main: Error: Exception for {message.addr}:\n"
                    f"{traceback.format_exc()}"
                )
                message.close()
        # Check for a socket being monitored to continue.
        if not sel.get_map():
            break
except KeyboardInterrupt:
    print("Caught keyboard interrupt, exiting")
finally:
    sel.close()

注意这一行: message.close()​.

这行代码非常重要,原因不止一个!它不仅确保套接字是关闭的,而且 message.close() ​还将套接字从 .select() ​的监视中移除。这大大简化了类中的代码,降低了复杂性。如果有异常或者你自己显式抛出异常,你知道 .close() ​会负责清理。

Message._read() ​和 Message._write() ​方法也包含一些有趣的东西:

# libclient.py

# ...

class Message:

    # ...

    def _read(self):
        try:
            # Should be ready to read
            data = self.sock.recv(4096)
        except BlockingIOError:
            # Resource temporarily unavailable (errno EWOULDBLOCK)
            pass
        else:
            if data:
                self._recv_buffer += data
            else:
                raise RuntimeError("Peer closed.")

    # ...

注意 except BlockingIOError: ​这行代码。

._write() ​方法也有一个。这些行很重要,因为它们捕获临时错误并使用 pass ​跳过它。临时错误是当套接字将阻塞时,例如,如果它正在网络或连接的另一端(也称为它的对等体)上等待。

通过使用 pass ​捕获并跳过异常,.select() ​最终将触发新的调用,你将有另一个机会读取或写入数据。

运行客户端和服务器应用

在所有这些艰苦的工作之后,是时候享受一些乐趣并运行一些搜索了!

在这些例子中,你将运行服务器,通过传递空字符串作为 host ​参数监听所有接口。这将允许你运行客户端并从另一个网络上的虚拟机连接。它模拟了 big-endian PowerPC 机器。

首先,启动服务器:

$ python app-server.py '' 65432
Listening on ('', 65432)

现在运行客户端并输入搜索。看看你能不能找到它:

$ python app-client.py 10.0.1.1 65432 search morpheus
Starting connection to ('10.0.1.1', 65432)
Sending b'\x00d{"byteorder": "big", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 41}{"action": "search", "value": "morpheus"}' to ('10.0.1.1', 65432)
Received response {'result': 'Follow the white rabbit. 🐰'} from ('10.0.1.1', 65432)
Got result: Follow the white rabbit. 🐰
Closing connection to ('10.0.1.1', 65432)

你可能注意到了,终端正在运行一个使用 Unicode (UTF-8)文本编码的 shell,因此上面的输出可以很好地打印出表情符号。

现在看看你能否找到小狗:

$ python app-client.py 10.0.1.1 65432 search 🐶
Starting connection to ('10.0.1.1', 65432)
Sending b'\x00d{"byteorder": "big", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 37}{"action": "search", "value": "\xf0\x9f\x90\xb6"}' to ('10.0.1.1', 65432)
Received response {'result': '🐾 Playing ball! 🏐'} from ('10.0.1.1', 65432)
Got result: 🐾 Playing ball! 🏐
Closing connection to ('10.0.1.1', 65432)

注意网络中发送的请求的字节字符串在 sending ​行。如果你查找表示小狗表情符号的十六进制字节:\xf0\x9f\x90\xb6​,就更容易看清楚。如果你的终端使用编码为 UTF-8 的 Unicode,你就可以输入表情符号进行搜索。

这表明你正在通过网络发送原始字节,它们需要被接收方解码才能正确解释。这就是为什么要花这么大力气创建一个包含内容类型和编码的消息头。

以下是上面两个客户端连接的服务器输出:

Accepted connection from ('10.0.2.2', 55340)
Received request {'action': 'search', 'value': 'morpheus'} from ('10.0.2.2', 55340)
Sending b'\x00g{"byteorder": "little", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 43}{"result": "Follow the white rabbit. \xf0\x9f\x90\xb0"}' to ('10.0.2.2', 55340)
Closing connection to ('10.0.2.2', 55340)

Accepted connection from ('10.0.2.2', 55338)
Received request {'action': 'search', 'value': '🐶'} from ('10.0.2.2', 55338)
Sending b'\x00g{"byteorder": "little", "content-type": "text/json", "content-encoding": "utf-8", "content-length": 37}{"result": "\xf0\x9f\x90\xbe Playing ball! \xf0\x9f\x8f\x90"}' to ('10.0.2.2', 55338)
Closing connection to ('10.0.2.2', 55338)

查看 sending ​这一行,看看有多少字节被写入了客户端的 socket。这是服务器的响应消息。

如果 action ​参数不是 search​,你也可以测试向服务器发送二进制请求:

$ python app-client.py 10.0.1.1 65432 binary 😃
Starting connection to ('10.0.1.1', 65432)
Sending b'\x00|{"byteorder": "big", "content-type": "binary/custom-client-binary-type", "content-encoding": "binary", "content-length": 10}binary\xf0\x9f\x98\x83' to ('10.0.1.1', 65432)
Received binary/custom-server-binary-type response from ('10.0.1.1', 65432)
Got response: b'First 10 bytes of request: binary\xf0\x9f\x98\x83'
Closing connection to ('10.0.1.1', 65432)

因为请求的 content-type ​不是 text/json​,服务器将其视为自定义二进制类型,不会执行 json 解码。它简单地打印 content-type ​并返回前 10 个字节给客户端:

$ python app-server.py '' 65432
Listening on ('', 65432)
Accepted connection from ('10.0.2.2', 55320)
Received binary/custom-client-binary-type request from ('10.0.2.2', 55320)
Sending b'\x00\x7f{"byteorder": "little", "content-type": "binary/custom-server-binary-type", "content-encoding": "binary", "content-length": 37}First 10 bytes of request: binary\xf0\x9f\x98\x83' to ('10.0.2.2', 55320)
Closing connection to ('10.0.2.2', 55320)

疑难解答

不可避免地,有些事情会失败,你会不知道该怎么做。别担心,每个人都会遇到这种事。希望在本教程、调试器和最喜欢的搜索引擎的帮助下,你能够重新开始源代码部分。

如果没有,你的第一站应该是 Python 的 socket module 文档。请确保阅读了所调用的每个函数或方法的所有文档。另外,请阅读下面的参考部分以获取想法。特别是,检查 Errors 部分。

有时,这并不完全与源代码有关。源代码可能是正确的,它只是另一个主机、客户端或服务器。也可能是网络。也许是路由器、防火墙或其他网络设备在扮演中间人的角色。

对于这些类型的问题,额外的工具是必不可少的。下面是一些工具和实用程序,它们可能会有所帮助或至少提供一些线索。

ping

ping ​将通过发送一个 ICMP 回声请求来检查主机是否活着并连接到网络。它直接与操作系统的 TCP/IP 协议栈通信,因此它独立于主机上运行的任何应用程序。

下面是在 macOS 上运行 ping 的例子:

$ ping -c 3 127.0.0.1
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.058 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.165 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.164 ms

--- 127.0.0.1 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.058/0.129/0.165/0.050 ms

注意输出结果末尾的统计信息。当你试图发现间歇性连接问题时,这可能很有用。例如,是否存在丢包?有多少延迟?你可以查看往返时间。

如果你和另一台主机之间有防火墙,ping 的 echo 请求可能不被允许。一些防火墙管理员实施了相应的策略。这个想法是,它们不希望宿主被发现。如果是这种情况,并且你添加了允许主机通信的防火墙规则,那么请确保这些规则也允许 ICMP 在它们之间传递。

ICMP 是 ping ​使用的协议,但它也是 TCP 和其他底层协议用来通信错误消息的协议。如果你正在经历奇怪的行为或缓慢的连接,这可能就是原因。

ICMP 消息由类型和代码标识。为了让你了解它们所携带的重要信息,下面列出了一些:

ICMP TypeICMP CodeDescription
80Echo request
00Echo reply
30Destination network unreachable
31Destination host unreachable
32Destination protocol unreachable
33Destination port unreachable
34Fragmentation required, and DF flag set
110TTL expired in transit

有关碎片和 ICMP 消息的信息,请参阅文章 Path MTU Discovery。这是一个可以导致奇怪行为的例子。

netstat

查看套接字状态一节中,你了解了如何使用 netstat ​来显示有关套接字及其当前状态的信息。这个实用程序可在 macOS、Linux 和 Windows 上使用。

该部分在示例输出中没有提到 Recv-Q ​和 Send-Q ​这两列。这些列将显示排队等待传输或接收的网络缓冲区中保存的字节数,但由于某些原因,远程或本地应用程序没有读写这些字节。

换句话说,这些字节在操作系统队列中的网络缓冲区中等待。一个原因可能是应用程序受 CPU 限制,或者无法调用 socket.recv() ​或 socket.send() ​并处理字节。或者可能存在影响通信的网络问题,如拥塞或网络硬件或布线故障。

为了演示这一点,并在发现错误之前看看你可以发送多少数据,你可以尝试一个连接到测试服务器的测试客户端,并反复调用 socket.send()​。测试服务器从不调用 socket.recv()​。它只是接受连接。这会导致服务器上的网络缓冲区被填满,最终在客户端上引发错误。

首先,启动服务器:

$ python app-server-test.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)

然后运行客户端看看有什么错误:

$ python app-client-test.py 127.0.0.1 65432 binary test
Error: socket.send() blocking io exception for ('127.0.0.1', 65432):
BlockingIOError(35, 'Resource temporarily unavailable')

以下是当客户端和服务器仍在运行时的 netstat ​输出,客户端多次打印出上述错误消息:

$ netstat -an | grep 65432
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4  408300      0  127.0.0.1.65432        127.0.0.1.53225        ESTABLISHED
tcp4       0 269868  127.0.0.1.53225        127.0.0.1.65432        ESTABLISHED
tcp4       0      0  127.0.0.1.65432        *.*                    LISTEN

第一个条目是服务器(Local Address ​的端口为 65432):

Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4  408300      0  127.0.0.1.65432        127.0.0.1.53225        ESTABLISHED

注意 Recv-Q​: 408300​.

第二个条目是客户端(Foreign Address ​的端口为 65432):

Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0 269868  127.0.0.1.53225        127.0.0.1.65432        ESTABLISHED

注意 Send-Q​: 269868​.

客户端确实试图写入字节,但服务器没有读取它们。这导致服务器的网络缓冲区队列在接收端填充,客户机的网络缓冲区队列在发送端填充。

Windows

如果你使用 Windows,如果你还没有,一定要看看一套实用程序:Windows Sysinternals

其中一个是 TCPView.exe​。TCPView 是 Windows 的图形化 netstat​。除了地址、端口号和套接字状态,它还会显示发送和接收的数据包数量和字节数。与 Unix 工具 lsof ​一样,你也可以获得进程名和 ID。检查其他显示选项的菜单。

TCPView screenshotfiles.realpython.com/media/tcpvi…

Wireshark

有时候你需要看看线路上发生了什么。忘记应用程序日志说了什么,或者库调用返回的值是什么。你希望查看网络上实际发送或接收的内容。就像调试器一样,当你需要查看它时,没有替代品。

Wireshark 是一个网络协议分析器和流量捕获应用程序,可以在 macOS、Linux 和 Windows 等平台上运行。有一个 GUI 版本名为 wireshark​,还有一个基于终端的文本版本名为 tshark​。

运行流量捕获是观察应用程序在网络上的行为,并收集有关它发送和接收内容、发送频率和接收量的证据的好方法。你还可以看到客户端或服务器何时关闭、中止连接或停止响应。当你进行故障排除时,这些信息可能非常有用。

网上有很多很好的教程和其他资源会带你完成使用 Wireshark 和 TShark 的基础知识。

下面是一个使用 Wireshark 捕获 loopback 接口流量的例子:

Wireshark screenshotfiles.realpython.com/media/wires…

下面是使用 tshark ​的相同示例:

$ tshark -i lo0 'tcp port 65432'
Capturing on 'Loopback'
    1   0.000000    127.0.0.1 → 127.0.0.1    TCP 68 53942 → 65432 [SYN] Seq=0 Win=65535 Len=0 MSS=16344 WS=32 TSval=940533635 TSecr=0 SACK_PERM=1
    2   0.000057    127.0.0.1 → 127.0.0.1    TCP 68 65432 → 53942 [SYN, ACK] Seq=0 Ack=1 Win=65535 Len=0 MSS=16344 WS=32 TSval=940533635 TSecr=940533635 SACK_PERM=1
    3   0.000068    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [ACK] Seq=1 Ack=1 Win=408288 Len=0 TSval=940533635 TSecr=940533635
    4   0.000075    127.0.0.1 → 127.0.0.1    TCP 56 [TCP Window Update] 65432 → 53942 [ACK] Seq=1 Ack=1 Win=408288 Len=0 TSval=940533635 TSecr=940533635
    5   0.000216    127.0.0.1 → 127.0.0.1    TCP 202 53942 → 65432 [PSH, ACK] Seq=1 Ack=1 Win=408288 Len=146 TSval=940533635 TSecr=940533635
    6   0.000234    127.0.0.1 → 127.0.0.1    TCP 56 65432 → 53942 [ACK] Seq=1 Ack=147 Win=408128 Len=0 TSval=940533635 TSecr=940533635
    7   0.000627    127.0.0.1 → 127.0.0.1    TCP 204 65432 → 53942 [PSH, ACK] Seq=1 Ack=147 Win=408128 Len=148 TSval=940533635 TSecr=940533635
    8   0.000649    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [ACK] Seq=147 Ack=149 Win=408128 Len=0 TSval=940533635 TSecr=940533635
    9   0.000668    127.0.0.1 → 127.0.0.1    TCP 56 65432 → 53942 [FIN, ACK] Seq=149 Ack=147 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   10   0.000682    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [ACK] Seq=147 Ack=150 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   11   0.000687    127.0.0.1 → 127.0.0.1    TCP 56 [TCP Dup ACK 6#1] 65432 → 53942 [ACK] Seq=150 Ack=147 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   12   0.000848    127.0.0.1 → 127.0.0.1    TCP 56 53942 → 65432 [FIN, ACK] Seq=147 Ack=150 Win=408128 Len=0 TSval=940533635 TSecr=940533635
   13   0.001004    127.0.0.1 → 127.0.0.1    TCP 56 65432 → 53942 [ACK] Seq=150 Ack=148 Win=408128 Len=0 TSval=940533635 TSecr=940533635
^C13 packets captured

接下来,你会得到更多的参考资料来支持你的 socket 编程之旅!

参考资料

你可以将本节作为一个一般参考,其中包含额外的信息和到外部资源的链接。

Python 文档

Errors

以下内容摘自 Python 的 socket ​模块文档:

“所有错误都会引发异常。对于无效参数类型和内存不足条件的正常异常可以被抛出;从 Python 3.3 开始,与套接字或地址语义相关的错误会引发 OSError ​或其子类之一。” (Source)

下面是使用套接字时可能遇到的一些常见错误。

Exceptionerrno​​ ConstantDescription
BlockingIOErrorEWOULDBLOCKResource temporarily unavailable. For example, in non-blocking mode, when calling .send()​​ and the peer is busy and not reading, the send queue (network buffer) is full. Or there are issues with the network. Hopefully this is a temporary condition.
OSErrorEADDRINUSEAddress already in use. Make sure that there’s not another process running that’s using the same port number and that your server is setting the socket option SO_REUSEADDR​​: socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)​​.
ConnectionResetErrorECONNRESETConnection reset by peer. The remote process crashed or did not close its socket properly, also known as an unclean shutdown. Or there’s a firewall or other device in the network path that’s missing rules or misbehaving.
TimeoutErrorETIMEDOUTOperation timed out. No response from peer.
ConnectionRefusedErrorECONNREFUSEDConnection refused. No application listening on specified port.

Socket 地址族

socket.AF_INET​​ and socket.AF_INET6​​ represent the address and protocol families used for the first argument to socket.socket()​​. APIs that use an address expect it to be in a certain format, depending on whether the socket was created with socket.AF_INET​​ or socket.AF_INET6​​.

socket.AF_INET​ 和 socket.AF_INET6 ​为 socket.socket() ​第一个参数,表示所使用的地址和协议族。使用地址的 API 希望它是某种格式,这取决于套接字是否是用 socket.AF_INET​ 和 socket.AF_INET6 ​创建的。

地址族协议地址元组描述
socket.AF_INETIPv4(host, port)host​ is a string with a hostname like 'www.example.com'​ or an IPv4 address like '10.1.2.3'​. port​ is an integer.
socket.AF_INET6IPv6(host, port, flowinfo, scopeid)host​ is a string with a hostname like 'www.example.com'​ or an IPv6 address like 'fe80::6203:7ab:fe88:9c23'​. port​ is an integer. flowinfo​ and scopeid​ represent the sin6_flowinfo​ and sin6_scope_id​ members in the C struct sockaddr_in6​.

请注意下面摘自 Python 的套接字模块文档,其中提到了 address 元组的 host ​值:

“For IPv4 addresses, two special forms are accepted instead of a host address: the empty string represents INADDR_ANY​, and the string '<broadcast>'​ represents INADDR_BROADCAST​. This behavior is not compatible with IPv6, therefore, you may want to avoid these if you intend to support IPv6 with your Python programs.” (Source)

更多信息请参考 Python 的 Socket families documentation

本教程使用 IPv4 套接字,但如果你的网络支持,请尝试测试并使用 IPv6。一种简单的方法是使用函数 socket.getaddrinfo()。它将 host ​和 port ​参数转换为一系列的五元组,其中包含创建连接到该服务的套接字所需的所有参数。socket.getaddrinfo() ​将理解和解释传入的 IPv6 地址和解析为 IPv6 地址的主机名,而不是 IPv4 地址。

下面的例子返回了 example.org ​在端口 80 ​上的 TCP 连接的地址信息:

>>> socket.getaddrinfo("example.org", 80, proto=socket.IPPROTO_TCP)
[(<AddressFamily.AF_INET6: 10>, <SocketType.SOCK_STREAM: 1>,
 6, '', ('2606:2800:220:1:248:1893:25c8:1946', 80, 0, 0)),
 (<AddressFamily.AF_INET: 2>, <SocketType.SOCK_STREAM: 1>,
 6, '', ('93.184.216.34', 80))]

如果没有启用 IPv6,在你的系统上可能会有不同的结果。上面返回的值可以传递给 socket.socket() ​和 socket.connect() ​来使用。Python 的 socket 模块文档示例部分中有一个客户端和服务器的示例。

使用主机名

作为上下文,本节主要适用于当你打算使用环回接口“localhost”时,使用 .bind() ​和 .connect() ​或 .connect_ex() ​的主机名。然而,它也适用于任何使用主机名的时候,并且期望它解析到某个特定的地址,并对你的应用程序具有特殊的含义,这会影响它的行为或假设。这与客户端使用主机名连接到由 DNS 解析的服务器(如 www.example.com)的典型场景形成了对比。

以下内容摘自 Python 的 socket ​模块文档:

“如果你在 IPv4/v6 套接字地址的主机部分使用主机名,程序可能会表现出不确定的行为,因为 Python 使用 DNS 解析返回的第一个地址。套接字地址将被解析为实际的 IPv4/v6 地址,这取决于 DNS 解析和/或主机配置的结果。对于确定性行为,请在主机部分使用数字地址。(

对于名称“localhost”的标准约定是将其解析为“127.0.0.1​”或“::1​”,即环回接口。在你的系统上很可能是这种情况,但也可能不是。这取决于你的系统如何配置名称解析。和所有的事情一样,总是有例外的,并且不能保证使用“localhost”的名称就会连接到环回接口。

例如,在 Linux 上,请参阅 man nsswitch.conf​,服务交换机的名称配置文件。在 macOS 和 Linux 中,另一个要检查的地方是文件 /etc/hosts​。在 Windows 上,请参阅 C:\Windows\System32\drivers\etc\hosts​。hosts ​文件包含一个简单的文本格式的名称到地址映射的静态表。DNS 是另一块拼图。

有趣的是,截至 2018 年 6 月,有一个 RFC 草案 Let ' localhost ' be localhost,其中讨论了关于使用“localhost”名称的约定、假设和安全性。

重要的是要明白,在应用程序中使用主机名时,返回的地址可能是任何东西。如果你有一个安全敏感的应用程序,请不要对名称进行假设。根据你的应用程序和环境,这可能是你需要考虑的问题,也可能不是。

**注意:**安全预防措施和最佳实践仍然适用,即使你的应用程序不是明确的安全敏感。如果你的应用程序访问网络,则应该对其进行保护和维护。这意味着,至少:

  • 定期更新系统软件和安全补丁,包括 Python。你使用第三方库吗?如果是,确保这些也被检查和更新。
  • 如果可能的话,使用专用的或基于主机的防火墙来限制只连接到可信系统。
  • 配置了什么 DNS 服务器?你信任他们和他们的管理员吗?
  • 在调用其他处理它的代码之前,确保请求数据尽可能地经过了清理和验证。为此可以使用模糊测试,并定期运行它们。

无论您是否使用主机名,如果您的应用程序需要支持通过加密和身份验证的安全连接,那么您可能需要研究使用 TLS。这是一个独立的主题,超出了本教程的范围。请参阅 Python 的 ssl模块文档。这与 web 浏览器用于安全连接网站的协议是相同的。

在考虑接口、IP 地址和名称解析时,有许多变量。你应该怎么做?如果你没有网络应用程序审查过程,这里有一些建议:

ApplicationUsageRecommendation
Serverloopback interfaceUse an IP address, such as 127.0.0.1​​ or ::1​​.
Serverethernet interfaceUse an IP address, such as 10.1.2.3​​. To support more than one interface, use an empty string for all interfaces/addresses. See the security note above.
Clientloopback interfaceUse an IP address, such as 127.0.0.1​​ or ::1​​.
Clientethernet interfaceUse an IP address for consistency and non-reliance on name resolution. For the typical case, use a hostname. See the security note above.

对于客户端或服务器,如果需要对连接的主机进行身份验证,请考虑使用 TLS。

阻塞调用

套接字函数或方法临时挂起应用程序就是阻塞调用。例如,.accept()​、.connect()​、.send() ​和 .recv() ​块,意味着它们不会立即返回。阻塞调用必须等待系统调用(I/O)完成才能返回值。因此,调用者你会被阻塞,直到它们完成或发生超时或其他错误。

阻塞式套接字调用可以设置为非阻塞模式,以便它们立即返回。如果这样做了,那么至少需要重构或重新设计应用程序,以便在准备好时处理套接字操作。

因为调用立即返回,数据可能还没有准备好。来电者正在网络上等待,还没有时间完成工作。如果是这样,那么当前状态是 errno ​值 socket.EWOULDBLOCK​。非阻塞模式支持.setblocking()

默认情况下,套接字总是以阻塞模式创建的。有关三种模式的描述,请参阅套接字超时说明

关闭连接

TCP 的一个有趣之处在于,客户端或服务器完全可以在另一端保持打开的情况下关闭自己的连接。这被称为“半开”连接。这是否可取是由应用程序决定的。一般来说,它不是。在这种状态下,关闭连接的一方将无法再发送数据。他们只能接受它。

我们并不一定推荐使用这种方法,但举个例子,HTTP 使用了一个名为“Connection”的首部,用于规范应用程序应该如何关闭或保持打开的连接。请参见 RFC 7230章节6.3,超文本传输协议(HTTP/1.1):消息语法和路由

在设计和编写应用程序及其应用层协议时,最好先弄清楚如何关闭连接。有时候这是显而易见且简单的,或者这需要一些初始原型和测试。这取决于应用程序以及消息循环处理预期数据的方式。只要确保插座在完成工作后总是及时关闭。

字节序

有关不同 cpu 如何在内存中存储字节顺序的细节,请参阅 Wikipedia关于endian的文章。在解释单个字节时,这不是问题。然而,当你要处理多个字节,并将其作为一个值来读取和处理时,例如一个 4 字节的整数,如果你要与使用不同字节序的机器通信,字节顺序就需要颠倒。

对于以多字节序列表示的文本字符串,如 Unicode,字节顺序也很重要。除非您总是使用 true,严格的 ASCII 并控制客户端和服务器实现,否则您可能最好使用 Unicode 编码,如 UTF-8 或一个支持字节顺序标记(BOM)

明确定义应用层协议中使用的编码是很重要的。你可以通过强制所有文本都是 UTF-8,或者使用“content-encoding”头来指定编码。这可以避免应用程序检测编码,你应该尽可能避免检测。

当涉及到存储在文件或数据库中的数据,并且没有可用的元数据指定其编码时,就会出现问题。当数据传输到另一个端点时,它必须尝试检测编码。有关讨论,请参阅维基百科的Unicode文章,其中引用了 RFC 3629: UTF-8, ISO 10646的转换格式:

然而,UTF-8 标准 RFC 3629 建议在使用 UTF-8 的协议中禁止字节顺序标记,但讨论了可能不允许这样做的情况。此外,UTF-8 对可能的模式有很大的限制(例如,不能有任何单独的高比特位),这意味着在不依赖 BOM 的情况下,应该可以将 UTF-8 与其他字符编码区分开来。()

由此得出的结论是,如果应用程序处理的数据可以变化,请始终存储其使用的编码。换句话说,如果编码不总是 UTF-8 或其他 BOM 编码,则尝试以某种方式将其存储为元数据。然后,您可以将该编码连同数据一起发送到头部,以告诉接收方它是什么。

TCP/IP 中使用的字节顺序是 big-endian,被称为网络顺序。网络顺序用于表示协议栈较低层的整数,如 IP 地址和端口号。Python 的 socket 模块包含了在整数和网络字节序之间进行转换的函数:

FunctionDescription
socket.ntohl(x)​​Convert 32-bit positive integers from network to host byte order. On machines where the host byte order is the same as network byte order, this is a no-op; otherwise, it performs a 4-byte swap operation.
socket.ntohs(x)​​Convert 16-bit positive integers from network to host byte order. On machines where the host byte order is the same as network byte order, this is a no-op; otherwise, it performs a 2-byte swap operation.
socket.htonl(x)​​Convert 32-bit positive integers from host to network byte order. On machines where the host byte order is the same as network byte order, this is a no-op; otherwise, it performs a 4-byte swap operation.
socket.htons(x)​​Convert 16-bit positive integers from host to network byte order. On machines where the host byte order is the same as network byte order, this is a no-op; otherwise, it performs a 2-byte swap operation.

你也可以使用 struct module 来使用格式字符串打包和解包二进制数据:

import struct
network_byteorder_int = struct.pack('>H', 256)
python_int = struct.unpack('>H', network_byteorder_int)[0]

总结

你在本教程中涵盖了很多内容!网络和 socket 是很大的主题。如果你刚接触网络或 sockets,不要被所有的术语和缩略词吓到。

为了理解一切是如何一起工作的,需要熟悉很多部分。然而,就像 Python 一样,随着你对各个部分的了解并花更多的时间来使用它们,它将开始变得更有意义。

在本教程中,你:

  • 查看了 Python socket ​模块中的低级套接字API,并了解了如何使用它创建客户端-服务器应用程序
  • 使用 selectors ​对象构建了一个可以处理多个连接的客户端和服务器
  • 创建你自己的自定义类,并将其用作在端点之间交换消息和数据的应用层协议

从这里开始,你可以使用自定义类并在其基础上学习并帮助更容易、更快地创建自己的套接字应用程序。

要查看这些示例,你可以单击以下链接:

**获取源代码:**​点击这里获取本教程中示例的源代码

恭喜你坚持到了最后!现在你已经可以在自己的应用中使用套接字了。祝你在 sockets 开发之旅中好运。