我们前面那个简单的接受连接的例子,让我们无从下手去连接。虽然有很多命令行工具可以读写服务器的数据,但一个存在已久且广受欢迎的应用就是 Telnet。
Telnet 最早于1969年开发,是“电传打字机网络”的缩写。它会建立一个到指定服务器和主机的 TCP 连接。一旦建立连接,就会开启一个终端,我们可以自由地发送和接收字节,所有内容都会在终端上显示出来。
在 Mac OS 上,你可以用 Homebrew 安装 telnet,命令是 brew install telnet(参见 https://brew.sh/(https://brew.sh/) 安装 Homebrew)。在 Linux 发行版上,你需要使用系统包管理器安装(如 apt-get install telnet)。在 Windows 上,建议使用 PuTTY,可以从 https://putty.org(https://putty.org) 下载。
注意 使用 PuTTY 时,为了使本书中的代码示例正常工作,你需要开启本地行编辑功能。在 PuTTY 配置窗口左侧的“终端”部分,将“本地行编辑”设置为“强制开启”。
要连接我们在列表 3.1 中构建的服务器,我们可以在命令行中使用 telnet 命令,并指定连接到本地主机的 8000 端口:
执行后,你会在终端看到一些输出,告诉你已成功连接。然后,Telnet 会显示一个光标,让你输入内容,并按 [Enter] 发送数据到服务器。
telnet localhost 8000Trying 127.0.0.1...Connected to localhost.Escape character is '^]'.
在你的服务器应用的控制台输出中,你应该能看到类似下面的信息,表明我们已经成功与 Telnet 客户端建立连接:
I got a connection from ('127.0.0.1', 56526)!
你还会看到一条 Connection closed by foreign host 消息,这是服务器代码退出时发出的,表示服务器已关闭与客户端的连接。现在,我们终于有了连接服务器并读写字节的方法,但目前的服务器还不能自己读或发送任何数据。我们可以通过客户端套接字的 sendall 和 recv 方法来实现这一点。
现在,我们已经创建了一个能接受连接的服务器,接下来看看如何从连接中读取数据。socket 类有一个名为 recv 的方法,我们可以用它从特定套接字获取数据。这个方法接收一个整数,表示我们希望一次读取多少字节。这一点很重要,因为我们不能一次性读取套接字中的所有数据;我们需要缓冲,直到读到输入的末尾。
在这个例子中,我们将输入结束标记为回车加换行符,即 '\r\n'。这正是用户在 Telnet 中按 [Enter] 时附加的内容。为了演示缓冲区在小消息上的工作方式,我们故意设置了较小的缓冲区大小。在实际应用中,我们会使用更大的缓冲区,比如 1024 字节。通常我们希望使用较大的缓冲区,因为这能利用操作系统层面的缓冲,效率远高于在应用层手动实现。
import socketserver_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server_address = ('127.0.0.1', 8000)server_socket.bind(server_address)server_socket.listen()try: connection, client_address = server_socket.accept() print(f'I got a connection from {client_address}!') buffer = b'' while buffer[-2:] != b'\r\n': data = connection.recv(2) if not data: break else: print(f'I got data: {data}!') buffer = buffer + data print(f"All the data is: {buffer}")finally: server_socket.close()
在上面的代码中,我们像之前一样用 server_socket.accept 等待连接。一旦获得连接,我们就尝试接收两个字节并存入缓冲区。然后进入一个循环,每次迭代都检查缓冲区是否以回车加换行符结尾。如果没结束,我们就再获取两个字节,打印收到的字节,并将其追加到缓冲区。如果收到了 '\r\n',我们就跳出循环,打印出从客户端收到的完整消息。我们还在 finally 块中关闭了服务器套接字。这确保即使在读取数据时发生异常,连接也会被关闭。如果你用 telnet 连接这个应用并发送消息 'testing123',你会看到如下输出:
I got a connection from ('127.0.0.1', 49721)!I got data: b'te'!I got data: b'st'!I got data: b'in'!I got data: b'g1'!I got data: b'23'!I got data: b'\r\n'!All the data is: b'testing123\r\n'
现在,我们能从套接字读取数据了,那怎么把数据发回给客户端呢?套接字有一个名为 sendall 的方法,它会接收一个消息并将其写回给客户端。我们可以修改列表 3.2 的代码,通过在缓冲区填满后调用 connection.sendall(buffer) 来回显客户端发送的消息:
while buffer[-2:] != b'\r\n': data = connection.recv(2) if not data: break else: print(f'I got data: {data}!') buffer = buffer + data print(f"All the data is: {buffer}") connection.sendall(buffer)
现在,当你连接这个应用并通过 Telnet 发送消息时,应该能在 Telnet 终端上看到该消息被打印回来。我们已经用套接字搭建了一个非常基本的回显服务器!
目前这个应用一次只能处理一个客户端,但多个客户端可以连接到同一个服务器套接字。让我们来调整这个例子,让它能同时允许多个客户端连接。通过这样做,我们将展示为什么使用阻塞套接字无法正确支持多个客户端。
一个处于监听模式的套接字可以同时允许多个客户端连接。这意味着我们可以反复调用 socket.accept,每当一个客户端连接时,我们就能得到一个新的连接套接字,用于读写与该客户端的数据。掌握了这一点,我们就可以很直接地修改之前的例子来处理多个客户端。我们无限循环,不断调用 socket.accept 来监听新连接。每次拿到连接后,我们就把它加入到已有的连接列表中。然后,我们遍历每个连接,接收数据并立即写回客户端连接。
import socketserver_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server_address = ('127.0.0.1', 8000)server_socket.bind(server_address)server_socket.listen()connections = []try: while True: connection, client_address = server_socket.accept() print(f'I got a connection from {client_address}!') connections.append(connection) for connection in connections: buffer = b'' while buffer[-2:] != b'\r\n': data = connection.recv(2) if not data: break else: print(f'I got data: {data}!') buffer = buffer + data print(f"All the data is: {buffer}") connection.send(buffer)finally: server_socket.close()
我们可以尝试这样操作:先用 telnet 建立一个连接并输入一条消息。然后,再用第二个 telnet 客户端连接并发送另一条消息。然而,如果你这么做了,会立刻发现问题。第一个客户端工作正常,能像预期那样回显消息,但第二个客户端却收不到任何回显。这是因为套接字的默认阻塞行为造成的。accept 和 recv 方法会一直阻塞,直到收到数据。这意味着,一旦第一个客户端连接,我们就会阻塞等待它发送第一条回显消息。这就导致其他客户端被卡住,必须等到第一个客户端发送数据后,循环才会继续(见图 3.2)。
图 3.2 使用阻塞套接字时,客户端1连接后,客户端2会被阻塞,直到客户端1发送数据。
这显然不是令人满意的用户体验;我们创造的东西无法在有多于一个用户时良好扩展。我们可以通过将套接字设置为非阻塞模式来解决这个问题。当我们把套接字标记为非阻塞时,其方法在没有数据可接收时不会阻塞,而是立即返回,继续执行下一行代码。