python学习_Python中的socket编程


楔子

网络编程是python应用的一块大领域,一直以来各种web应用框架(django, flask等)帮我们处理了底层的各种复杂的网络通信和数据处理,降低了开发人员的开发难度,但是另一方面也使得底层的东西被包裹在重重的框架下面无法看清和学习,因此本文从socket编程开始去一探网络编程的冰山一角。

前言

socket,翻译为插座,插口,编程上翻译为套接字,初看不太懂为什么叫这个名字,且将socket通信的双方(B/S或者C/S)理解为插座和插头的关系吧。

socket和socket API 被用于通过网络发送消息。它们提供了一种进程间通信 (IPC)形式。网络可以是计算机的逻辑的本地的网络(localhost),也可以是物理连接到外部网络的网络。一个明显的例子是您通过 ISP(Internet Service Provider,互联网服务提供商,就是电信、移动运营商等) 连接到的 Internet(目前的互联网)。

本次学习共有如下内容:

  • 建立一个简单的,可以互相通信的socket服务器和客户端
  • 在上面的基础上建立可以同时处理多个连接的改进版本
  • 建立一个服务器-客户端应用程序,其功能类似于一个成熟的socket应用程序,具有自己的自定义标头和内容

网络和套接字编程是一个大的主题,python官网上已经有很详细的说明,但如果你对这块是个新手,那么估计和我一样阅读起来难度很大,本文就是为了解决这个问题,力求通过简单的示例和详细的说明去揭开这个大主题的一层面纱,希望你能有所收获~

背景

socket已经有很长的历史了。它的使用起源于 1971 年的 ARPANET,后来在 1983 年,发布成为伯克利软件 (BSD) 操作系统中的一个 API,称为 Berkeley sockets

当互联网在20世纪90年代随着万维网(World Wide Web)起飞时,对应的网络编程也随之风生水起。Web 服务器和浏览器(BS架构)并不是唯一利用新生网络和socket编程的应用程序,他们在各种类型和规模的客户端-服务器应用程序(CS架构)也得到了广泛使用。

一般来说,套接字应用程序用在客户端-服务器架构上(CS架构)的比较多,其中一侧充当服务器并等待来自客户端的连接。本文将专注于Internet 套接字的 API ,有时称为 Berkeley 或 BSD 套接字。除此之外还有Unix 域套接字,它只能用于在同一主机上的进程之间进行通信。

Socket API总览

Python 的socket 模块Berkeley sockets API提供了一个接口。这也是本文中将学习使用的模块。

此模块中主要的API函数和方法如下:

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

Python 提供了能够直接映射到操作系统调用的 API——socket(内置进标准库了),使用方便且接口调用保持一致。

除了socket库,Python 还有更高级的一些的类,他们封装了底层的socket函数。尽管本文没有介绍它,但您可以查看socketserver 模块,这是一个网络服务器框架。还有许多模块可以实现更高级别的 Internet 协议,例如 HTTP 和 SMTP。有关概述,请参阅Internet 协议和支持

TCP Sockets

您将使用socket.socket()来创建一个套接字对象,并将套接字类型指定为socket.SOCK_STREAM。当您这样做时,使用的默认协议是传输控制协议 (TCP)。这是一个很好的默认值,可能是正是您想要的。

为什么要使用 TCP(Transmission Control Protocol)?传输控制协议 (TCP)的优点是什么呢?

  • 协议是可靠的:丢弃在网络中的数据包将会被发送方检测并重传。
  • 保证有序的数据传递:应用程序按照发送者写入的顺序读取数据。

相比之下,使用socket.SOCK_DGRAM创建的用户数据报协议 (UDP)套接字是不可靠的,并且接收方读取的数据可能与发送方的写入顺序不一致。

为什么这两点很重要呢?网络是一个最大努力传输(best-effort)的系统。一般情况下,有很多突发情况是无法保证您的数据准确的到达目的地,或者给您的数据是准确完整有序的。

网络设备(例如路由器和交换机)的可用带宽有限,并且会受自身固有的系统限制——它们具有 CPU、内存、总线和接口数据包缓冲区,就像您的客户端和服务器一样。TCP 协议使您不必担心数据包丢失、无序数据到达以及在您通过网络进行通信时总是会发生的其他陷阱。

为了更好地理解这一点,请查看下图——TCP的套接字 API 调用过程和数据流的顺序:

img

左侧一列代表服务器。右侧一列代表客户端。

从左上角开始,注意服务器为设置监听状态而进行的 API 调用:

  • socket()
  • bind()
  • listen()
  • accept()

服务端首先要建立自己的socket对象,并对外监听。它侦听来自客户端的连接。当客户端连接时,服务端调用.accept()以接受或完成连接。

客户端调用.connect()建立与服务端的连接并启动三次握手。握手步骤很重要,因为它确保连接的每一端都可以在网络中访问,换句话说,客户端可以访问服务器,反之亦然。

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

在流程的底部,客户端和服务器关闭各自的套接字连接。

Client and Server的第一次连接

现在,相信您已经了解了socket API的总体概况,并且对客户端和服务器如何通信有了初步印象。基于此我们可以创建第一个可以同学客户端和服务器了。我们将从简单的实现开始:1)建立客户端和服务端。2)服务端将返回任何从客户端收到的内容。

Server部分

# echo-server.py

import socket

HOST = "127.0.0.1"  # 标准的环回接口地址(localhost),用于同台主机同时作为客户端和服务端
PORT = 65432  # 端口监听 非特权端口需要端口号 > 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"客户端 {addr} 进行了访问")
        while True:
            data = conn.recv(bufsize=1024)
            if not data:
                break
            conn.sendall(data)

现在不用担心对上述的代码有疑问,接下来我们一起逐句的分析每行代码的作用~

所以,上述的代码究竟做了什么事情呢?

首先,socket.socket()方法创建了一个支持上下文管理器类型的套接字对象(socket object),因此可以用with语句使用它——这样就无需显示的调用s.close()方法了,因为已经在上下文里面执行了。

其次,socket对象接受两个参数:1)地址家族;2)socket的类型。socket.AF_INET表示是IPv4的网络地址。socket.SOCK_STREAM表示是TCP类型的socket(TCP是用于网络中传输消息的协议)。

而后,.bind()方法用于将socket对象与指定的网络接口(IP地址)和端口号相关联。

bind方法接受的参数

传递给.bind()的参数取决于已实例化的socket的地址家族是什么。在此示例中,我们使用的是socket.af_inet(ipv4)。因此,bind接受一个双元素元组:(主机端口)。

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

端口代表TCP端口号,它可以接受客户端的连接。它的取值范围是1到65535的整数(0是被保留的)。如果端口号小于1024,则某些系统可能需要使用root账户。

下面是一段关于在.bind()中使用主机参数的说明:

“如果您在IPv4/V6套接字地址的主机部分中使用主机名,则该程序可能会执行非确定性行为,因为Python使用的是从DNS解析器返回的第一个地址。根据DNS解析器以及主机的配置,套接字地址将以不同的方式分配到实际的IPv4/V6地址中(即同一主机名可能对应不同的IP地址)。为了确定性行为,请在主机部分使用IP地址而不是主机名”

所以,在使用主机名时,第一次运行应用程序时,您可能会获得地址10.1.2.3。下次,您将获得不同的地址,192.168.0.1。第三次,您可以获得172.16.7.8,依此类推。

listen方法

listen()使服务端可以接受来自外部的连接。它使服务端成为一个时刻监听的服务。

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

如果您的服务器同时接收到大量连接请求,通过设置待处理连接的队列的最大长度来增加该backlog的值可能会有所帮助。最大值取决于系统。例如,在 Linux 上,请参阅/proc/sys/net/core/somaxconn.

accept方法

.accept()方法会阻塞执行并等待客户端传入连接。当客户端连接时,它返回一个表示连接的新套接字对象和一个保存客户端地址的元组。对于IPv4地址元组将包含(host, port) ,对于IPv6地址元组则包含(host, port, flowinfo, scopeid)

必须记住的是,现在有一个来自.accept()方法的新的套接字对象(conn)。这很重要,因为它是将用于与客户端通信的套接字。它与服务端用来接受连接的监听套接字对象(s)不同。

accept()提供客户端套接字对象conn后,将使用while死循环来循环对conn.recv()阻塞调用。这会读取客户端发送的任何数据并使用conn.sendall()发送回去。

如果conn.recv()返回一个空字节对象b'',则表示客户端关闭连接并且循环终止。用于connwith语句将在代码的末尾自动关闭套接字。

Client部分

# echo-client.py

import socket

HOST = "127.0.0.1"  # 服务端使用的主机地址或主机名
PORT = 65432  # 服务端使用的端口

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

print(f"接收数据: {data!r}")

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

启动Server和Client

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

[root@bain pyscript]# python echo-server.py 

此时此终端将出现挂起。那是因为服务器因为.accept()方法而被阻塞或挂起。它正在等待客户端连接。现在,打开另一个终端窗口或命令提示符并运行客户端:

[root@bain pyscript]# python echo-client.py 
接收数据: b'Hello, world'

在服务器窗口中,会看到如下内容:

[root@bain pyscript]# python echo-server.py 
客户端 ('127.0.0.1', 41282) 进行了访问

在上面的输出中,服务器打印了从s.accept()方法返回的addr元组. 这是客户端的 IP 地址和 TCP 端口号。端口号64623在不同的计算机上运行它时,可能会有所不同。

查看套接字(Socket)状态

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

这是启动服务器进行监听时 linux 的 netstat 输出:

[root@bain pyscript]# netstat -an
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:65432         0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN     
tcp        0      0 192.168.10.101:22       192.168.10.100:10167    ESTABLISHED
tcp        0     36 192.168.10.101:22       192.168.10.100:6420     ESTABLISHED
tcp6       0      0 :::22                   :::*                    LISTEN     
tcp6       0      0 ::1:25                  :::*                    LISTEN     
udp        0      0 127.0.0.1:323           0.0.0.0:*                          
udp6       0      0 ::1:323                 :::*                               
raw6       0      0 :::58                   :::*                    7          

注意Local Address127.0.0.1.65432。如果echo-server.py使用了HOST = ""而不是HOST = "127.0.0.1",netstat 会显示:

[root@bain pyscript]# netstat -an
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:65432           0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN     
tcp        0      0 192.168.10.101:22       192.168.10.100:10167    ESTABLISHED
tcp        0     36 192.168.10.101:22       192.168.10.100:6420     ESTABLISHED
tcp6       0      0 :::22                   :::*                    LISTEN     
tcp6       0      0 ::1:25                  :::*                    LISTEN     
udp        0      0 127.0.0.1:323           0.0.0.0:*                          
udp6       0      0 ::1:323                 :::*                               
raw6       0      0 :::58                   :::*                    7          

Local Address0.0.0.0.65432,这意味着支持IPv4地址族的所有可用主机接口都将用于接受传入连接。在此示例中,socket.AF_INET在对socket()的调用中使用了 (IPv4) 。您可以在Proto列中看到这一点:tcp

需要注意的是ProtoLocal Address(state)列。在上面的输出里,netstat 命令显示服务器正在使用 IPv4 TCP 套接字 ( tcp),且接受所有接口连接本机端口65432 (0.0.0.0:65432),且目前正处于监听状态 ( LISTEN)。

访问此文件以及其他有用信息的另一种方法是使用lsof(用于查看你进程打开的文件,打开文件的进程,进程打开的端口(TCP、UDP))。它在 macOS 上默认可用,并且可以使用包管理器安装在 Linux 上(如果尚未安装):

[root@qianfeng01 pyscript]# lsof -i -n
COMMAND  PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
...
python  1556   root    3u  IPv4  23805      0t0  TCP *:65432 (LISTEN)
...

lsof输出各列信息的意义如下:

  1. ​ COMMAND:进程的名称
  2. ​ PID:进程标识符
  3. ​ PPID:父进程标识符(需要指定-R参数)
  4. ​ USER:进程所有者
  5. ​ PGID:进程所属组
  6. ​ FD:文件描述符,应用程序通过文件描述符识别该文件。

当尝试连接到没有监听套接字的端口时,您会遇到以下常见错误(未启动服务端,仅启动客户端):

[root@bain pyscript]# python echo-client.py 
Traceback (most recent call last):
  File "echo-client.py", line 9, in <module>
    s.connect((HOST, PORT))
ConnectionRefusedError: [Errno 111] Connection refused

指定的端口号错误或服务器未运行会出现上面的错误。或者,连接路径中可能存在阻止连接的防火墙,这很容易忘记。还有可能还会看到错误Connection timed out。遇到这种情况需要服务端添加防火墙规则允许客户端连接到服务端的TCP 端口。

CS通信过程

现在我们来仔细研究下客户端和服务端到底是如何互相通信的。

image-20220606165635163

使用环回接口(IPv4 地址127.0.0.1或 IPv6 地址::1)时,数据是永远不会离开主机或接触外部网络的。在上图中,环回接口是包含在主机内部的。这代表了环回接口的内部传输属性,并表明它的连接和数据传输对于主机来说都是在本地进行的。这就是为什么有时还会听到环回接口和 IP 地址127.0.0.1::1被称为“localhost”的原因(指向的通信都是主机自身)。

为了保证安全性以及保证与外部网络的隔离,应用程序会使用环回接口(下图的lo0)与主机上运行的其他进程进行通信。因为它是内部的并且只能从主机内部访问,所以它不会暴露。

对于应用程序服务器使用的数据库,如果它不是其他服务器使用的数据库,它可能被配置为仅监听环回接口。如果是这种情况,网络上的其他主机将无法连接到它。

当您在应用程序中使用除127.0.0.1::1之外的IP地址时,它可能绑定到连接到外部网络的以太网接口(下图的eth0)——这是与本机(localhost)之外其他主机进行通信的网关:

image-20220606171028486

当与外部主机进行通信时,请小心。外部的网络世界是残酷和危险的,在通信之前请一定要阅读相关安全说明(即使主机为IP地址而不是域名也同样要小心)

如何处理多个连接

上面的服务端有许多局限性,最大的一个是它只会服务一个客户端然后就退出了,客户端也是如此。客户端除此之外还有一个额外的问题,就是当客户端使用s.recv()时,它可能只返回一个字节,b'H'来自b'Hello, world'

# echo-client.py

...
    data = s.recv(bufsize=1024)
...

上面使用的bufsize参数表示每次接收的来自服务端的最大数据量(示例为1024字节)。

send()方法同样也以这种方式运行。不同的是它的返回值是发送的字节数,并且发送的字节数可能小于数据传入的字节数。我们有必要对此进行检查并且在需要的情况下尽可能多次的调用send方法,以便将数据全部发出。

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

在上面的示例中,我们通过使用.sendall()方法来避免检查操作:

“与 send() 不同,此方法持续从字节发送数据,直到所有数据都已发送或发生错误。全部发送成功后返回None。” (引用)

我们目前的问题如下:

  • 如何同时处理多个连接?
  • 需要多次调用.send().recv()直到发送或接收所有数据。(而不是发一次就关闭连接了)

针对上述的问题,有许多并发方法可以解决。一种流行的方法是使用异步 I/Oasyncio在 Python 3.4 中被引入标准库。一般而言传统的选择是使用线程。

并发方案很难做到正确的传输数据。实现的时候有许多微妙和细节的地方需要考虑和防范。因为只要有其中一个地方没考虑到,应用程序可能会以你想不到的方式崩溃。

这并不是要吓大家别去学习和使用并发编程的意思。如果我们的应用程序需要扩展,如果我们需要使用多个处理器或多个内核,并发就是必要的。在本文,我们将使用比线程更传统且更易于理解的东西。我们将使用系统调用的鼻祖方法:.select().

.select()方法允许我们检查多个套接字上的 I/O 完成情况。因此,可以调用.select()查看哪些套接字已准备好读取/或写入 I/O。在Python里,我们将使用标准库中的选择器(selectors)模块,以便使用最有效的方式实现此功能,并且此模块的使用无需关注代码运行在何种操作系统上:

“selectors模块建立在select原语模块的基础上,并且允许高级和高效的 I/O 多路复用。我们鼓励用户使用这个模块,除非他们想要精确控制所使用的操作系统级原语。” (引用)

不过,如果使用了.select()方法,我们就不能并行的运行程序了。也就是说,确认是否使用并发编程前,我们需要考虑:1)应用程序在为请求提供服务时需要做什么;2)服务端需要支持的客户端数量。

asyncio使用单线程协作多任务(即协程)和事件循环来管理任务。使用.select(),我们将编写自己的事件循环版本,简单且同步性强。当使用多线程做并发时,我们需要考虑GIL(全局解释器锁)的限制。

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

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

如果使用多进程,则操作系统能够安排 Python 代码在多个处理器或内核上并行运行,无需受限于 GIL。

支持多个连接的Client和Server

在接下来的两节里,我们将使用从选择器模块创建的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跟踪套接字上发送和接收的内容。

接下来是事件循环:

# 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和 一个 maskkey是一个SelectorKey 。的命名元组(namedtuple),它包括了一个fileobj属性:key.fileobj——这是一个套接字对象;还包括了一个准备好的操作的事件掩码

events = [((fileobj1, data1), mask1), (key2, mask2), ...]

如果key.dataNone,那么说明它来自监听套接字,我们需要接受连接。此时会调用accept_wrapper()函数来获取新的套接字对象并将其注册到选择器。函数内容在后面会展开。

如果key.data不为 None,那么说明它是一个已被接受的客户端套接字,此时需要为它提供服务。service_connection()会接收keyandmask作为参数调用。

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)

# ...

因为监听套接字是为事件注册的selectors.EVENT_READ,所以它应该可以读取了。先调用sock.accept()然后调用conn.setblocking(False)将套接字置于非阻塞模式。

请记住,这是此阶段服务端的主要目标,因为我们不希望它阻塞。如果它阻塞,则整个服务器将停止,直到它返回。这意味着即使服务器没有处于活动状态,其他套接字也会等待。我们不希望服务器处于可怕的“挂起”状态。

接下来,我们用SimpleNamespace创建一个对象,用于包含所需的数据以及套接字. 因为我们想知道客户端连接何时准备好进行读写,所以这两个事件都使用按位 OR运算符设置。

然后将events掩码、套接字和数据对象传递给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:]

# ...

这是简单多连接服务端的核心。keynamedtuple.select()包含套接字对象(fileobj)和数据对象的返回值。mask包含准备好的事件。

如果套接字已准备好读取,则mask & selectors.EVENT_READ计算结果为True,因此sock.recv()被调用。读取的任何数据都会附加到data.outb后面,以便以后发送。

注意else检查是否没有收到数据的块,如果没有收到数据,这意味着客户端已经关闭了他们的套接字,所以服务器也应该这样做。但是不要忘记在关闭之前调用sel.unregister(),如此套接字才能不再被.select()监控。

当套接字准备好写入时(对于一个健康的套接字来说应该总是如此),任何接收到的数据都会存储在data.outb中并使用sock.send(). 发送到客户端。然后从发送缓冲区中删除发送的字节。

.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从命令行读取,是要创建到服务端的连接数。同服务端一样,每个套接字都设置为非阻塞模式。

这里使用.connect_ex()而不是.connect()的原因是.connect()会立即引发BlockingIOError异常。而.connect_ex()方法会返回一个错误指示符, errno.EINPROGRESS,而不是引发会干扰正在进行的连接的异常。一旦连接完成,套接字就可以读写了,并由.select().返回

设置套接字后,要使用套接字存储的数据是使用SimpleNamespace. 因为每个连接都会调用socket.send()和修改列表,所以客户端将发送到服务器的消息被messages.copy()复制使用。跟踪客户端需要发送、已发送和已接收的所有内容,包括消息中的总字节数,都存储在 object 中data

查看服务器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:]

客户端跟踪它从服务器接收到的字节数,以便它可以关闭它的连接端。当服务器检测到这一点时,它也会关闭它的连接端。

请注意,通过这样做,服务器依赖于表现良好的客户端:服务器希望客户端在完成发送消息后关闭其连接端。如果客户端没有关闭,服务器将保持连接打开。在实际应用程序中,您可能希望通过实施超时来防止客户端连接在一定时间后不发送请求时累积,从而在服务器中防止这种情况。

posted @ 2023-12-15 18:07  故君子慎为善  阅读(241)  评论(0)    收藏  举报