[译]Python 中的 Socket 编程(指南)

博客原文: https://keelii.com/2018/09/24/socket-programming-in-python/html

说明

本书翻译自 realpython 网站上的文章教程 Socket Programming in Python (Guide),因为原文比较长,因此整理成了 Gitbook 方便阅读前端

原做者

Nathan Jennings 是 Real Python 教程团队的一员,他在很早以前就使用 C 语言开始了本身的编程生涯,可是最终发现了 Python,从 Web 应用和网络数据收集到网络安全,他喜欢任何 Pythonic 的东西
—— realpython

译者注

译者 是一名前端工程师,日常会写不少的 JavaScript。可是当我使用 JavaScript 很长一段时间后,会对一些 语言无关 的编程概念感兴趣,好比:网络/socket 编程、异步/并发、线/进程通讯等。然而刚好这些内容在 JavasScript 领域不多见python

由于一直从事 Web 开发,因此我认为理解了网络通讯及其 socket 编程就理解了 Web 开发的某些本质。过程当中我发现 Python 社区有不少我喜欢的内容,而且不少都是高质量的公开发布且开源的。git

最近我发现了这篇文章,系统地从底层网络通讯讲到了应用层协议及其 C/S 架构的应用程序,由浅入深。虽然代码、API 使用了 Python,可是底层缘由都是相通的。很是值得一读,推荐给你们github

另外,因为本人水平所限,翻译的内容不免出现误差,若是你在阅读的过程当中发现问题,请绝不犹豫的提醒我或者开新 PR。或者有什么不理解的地方也能够开 issue 讨论shell

受权

本文(翻译版)经过了 realpython 官方受权,原文版权归其全部,任何转载请联系他们数据库

开始

网络中的 Socket 和 Socket API 是用来跨网络的消息传送的,它提供了 进程间通讯(IPC) 的一种形式。网络能够是逻辑的、本地的电脑网络,或者是能够物理链接到外网的网络,而且能够链接到其它网络。英特网就是一个明显的例子,就是那个你经过 ISP 链接到的网络编程

本篇教程有三个不一样的迭代阶段,来展现如何使用 Python 构建一个 Socket 服务器和客户端json

  1. 咱们将以一个简单的 Socket 服务器和客户端程序来开始本教程
  2. 当你看完 API 了解例子是怎么运行起来之后,咱们将会看到一个具备同时处理多个链接能力的例子的改进版
  3. 最后,咱们将会开发出一个更加完善且具备完整的自定义头信息和内容的 Socket 应用

教程结束后,你将学会如何使用 Python 中的 socket 模块 来写一个本身的客户端/服务器应用。以及向你展现如何在你的应用中使用自定义类在不一样的端之间发送消息和数据windows

全部的例子程序都使用 Python 3.6 编写,你能够在 Github 上找到 源代码

网络和 Socket 是个很大的话题。网上已经有了关于它们的字面解释,若是你还不是很了解 Socket 和网络。当你你读到那些解释的时候会感到不知所措,这是很是正常的。由于我也是这样过来的

尽管如此也不要气馁。 我已经为你写了这个教程。 就像学习 Python 同样,咱们能够一次学习一点。用你的浏览器保存本页面到书签,以便你学习下一部分时能找到

让咱们开始吧!

背景

Socket 有一段很长的历史,最初是在 1971 年被用于 ARPANET,随后就成了 1983 年发布的 Berkeley Software Distribution (BSD) 操做系统的 API,而且被命名为 Berkeleysocket

当互联网在 20 世纪 90 年代随万维网兴起时,网络编程也火了起来。Web 服务和浏览器并非惟一使用新的链接网络和 Socket 的应用程序。各类类型不一样规模的客户端/服务器应用都普遍地使用着它们

时至今日,尽管 Socket API 使用的底层协议已经进化了不少年,也出现了许多新的协议,可是底层的 API 仍然保持不变

Socket 应用最多见的类型就是 客户端/服务器 应用,服务器用来等待客户端的连接。咱们教程中涉及到的就是这类应用。更明确地说,咱们将看到用于 InternetSocket 的 Socket API,有时称为 Berkeley 或 BSD Socket。固然也有 Unix domain sockets —— 一种用于 同一主机 进程间的通讯

Socket API 概览

Python 的 socket 模块提供了使用 Berkeley sockets API 的接口。这将会在咱们这个教程里使用和讨论到

主要的用到的 Socket API 函数和方法有下面这些:

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

Python 提供了和 C 语言一致且方便的 API。咱们将在下面一节中用到它们

做为标准库的一部分,Python 也有一些类可让咱们方便的调用这些底层 Socket 函数。尽管这个教程中并无涉及这部份内容,你也能够经过socketserver 模块 中找到文档。固然还有不少实现了高层网络协议(好比:HTTP, SMTP)的的模块,能够在下面的连接中查到 Internet Protocols and Support

TCP Sockets

就如你立刻要看到的,咱们将使用 socket.socket() 建立一个类型为 socket.SOCK_STREAM 的 socket 对象,默认将使用 Transmission Control Protocol(TCP) 协议,这基本上就是你想使用的默认值

为何应该使用 TCP 协议?

  • 可靠的:网络传输中丢失的数据包会被检测到并从新发送
  • 有序传送:数据按发送者写入的顺序被读取

相反,使用 socket.SOCK_DGRAM 建立的 用户数据报协议(UDP) Socket 是 不可靠 的,并且数据的读取写发送能够是 无序的

为何这个很重要?网络老是会尽最大的努力去传输完整数据(每每不尽人意)。无法保证你的数据必定被送到目的地或者必定能接收到别人发送给你的数据

网络设备(好比:路由器、交换机)都有带宽限制,或者系统自己的极限。它们也有 CPU、内存、总线和接口包缓冲区,就像咱们的客户端和服务器。TCP 消除了你对于丢包、乱序以及其它网络通讯中一般出现的问题的顾虑

下面的示意图中,咱们将看到 Socket API 的调用顺序和 TCP 的数据流:

TCP Socket 流

左边表示服务器,右边则是客户端

左上方开始,注意服务器建立「监听」Socket 的 API 调用:

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

「监听」Socket 作的事情就像它的名字同样。它会监听客户端的链接,当一个客户端链接进来的时候,服务器将调用 accept() 来「接受」或者「完成」此链接

客户端调用 connect() 方法来创建与服务器的连接,并开始三次握手。握手很重要是由于它保证了网络的通讯的双方能够到达,也就是说客户端能够正常链接到服务器,反之亦然

上图中间部分往返部分表示客户端和服务器的数据交换过程,调用了 send()recv()方法

下面部分,客户端和服务器调用 close() 方法来关闭各自的 socket

打印客户端和服务端

你如今已经了解了基本的 socket API 以及客户端和服务器是如何通讯的,让咱们来建立一个客户端和服务器。咱们将会以一个简单的实现开始。服务器将打印客户端发送回来的内容

打印程序服务端

下面就是服务器代码,echo-server.py

#!/usr/bin/env python3

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('Connected by', addr)
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)
注意:上面的代码你可能还无法彻底理解,可是不用担忧。这几行代码作了不少事情,这
只是一个起点,帮你看见这个简单的服务器是如何运行的
教程后面有引用部分,里面有不少额外的引用资源连接,这个教程中我将把连接放在那儿

让咱们一块儿来看一下 API 调用以及发生了什么

socket.socket() 建立了一个 socket 对象,而且支持 context manager type,你可使用 with 语句,这样你就不用再手动调用 s.close() 来关闭 socket 了

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

调用 socket() 时传入的 socket 地址族参数 socket.AF_INET 表示因特网 IPv4 地址族SOCK_STREAM 表示使用 TCP 的 socket 类型,协议将被用来在网络中传输消息

bind() 用来关联 socket 到指定的网络接口(IP 地址)和端口号:

HOST = '127.0.0.1'
PORT = 65432

# ...

s.bind((HOST, PORT))

bind() 方法的入参取决于 socket 的地址族,在这个例子中咱们使用了 socket.AF_INET (IPv4),它将返回两个元素的元组:(host, port)

host 能够是主机名称、IP 地址、空字符串,若是使用 IP 地址,host 就应该是 IPv4 格式的字符串,127.0.0.1 是标准的 IPv4 回环地址,只有主机上的进程能够链接到服务器,若是你传了空字符串,服务器将接受本机全部可用的 IPv4 地址

端口号应该是 1-65535 之间的整数(0是保留的),这个整数就是用来接受客户端连接的 TCP 端口号,若是端口号小于 1024,有的操做系统会要求管理员权限

使用 bind() 传参为主机名称的时候须要注意:

若是你在 host 部分 主机名称 做为 IPv4/v6 socket 的地址,程序可能会产生非确
定性的行为,由于 Python 会使用 DNS 解析后的 第一个 地址,根据 DNS 解析的结
果或者 host 配置 socket 地址将会以不一样方式解析为实际的 IPv4/v6 地址。若是想得
到肯定的结果传入的 host 参数建议使用数字格式的地址 引用

我稍后将在 使用主机名 部分讨论这个问题,可是如今也值得一提。目前来讲你只须要知道当使用主机名时,你将会由于 DNS 解析的缘由获得不一样的结果

多是任何地址。好比第一次运行程序时是 10.1.2.3,第二次是 192.168.0.1,第三次是 172.16.7.8 等等

继续看上面的服务器代码示例,listen() 方法调用使服务器能够接受链接请求,这使它成为一个「监听中」的 socket

s.listen()
conn, addr = s.accept()

listen() 方法有一个 backlog 参数。它指定在拒绝新的链接以前系统将容许使用的 未接受的链接 数量。从 Python 3.5 开始,这是可选参数。若是不指定,Python 将取一个默认值

若是你的服务器须要同时接收不少链接请求,增长 backlog 参数的值能够加大等待连接请求队列的长度,最大长度取决于操做系统。好比在 Linux 下,参考 /proc/sys/net/core/somaxconn

accept() 方法阻塞并等待传入链接。当一个客户端链接时,它将返回一个新的 socket 对象,对象中有表示当前链接的 conn 和一个由主机、端口号组成的 IPv4/v6 链接的元组,更多关于元组值的内容能够查看 socket 地址族 一节中的详情

这里必需要明白咱们经过调用 accept() 方法拥有了一个新的 socket 对象。这很是重要,由于你将用这个 socket 对象和客户端进行通讯。和监听一个 socket 不一样的是后者只用来授受新的链接请求

conn, addr = s.accept()
with conn:
    print('Connected by', addr)
    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.sendall(data)

accept() 获取客户端 socket 链接对象 conn 后,使用一个无限 while 循环来阻塞调用 conn.recv(),不管客户端传过来什么数据都会使用 conn.sendall() 打印出来

若是 conn.recv() 方法返回一个空 byte 对象(b''),而后客户端关闭链接,循环结束,with 语句和 conn 一块儿使用时,通讯结束的时候会自动关闭 socket 连接

打印程序客户端

如今咱们来看下客户端的程序, echo-client.py

#!/usr/bin/env python3

import socket

HOST = '127.0.0.1'  # 服务器的主机名或者 IP 地址
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(1024)

print('Received', repr(data))

与服务器程序相比,客户端程序简单不少。它建立了一个 socket 对象,链接到服务器而且调用 s.sendall() 方法发送消息,而后再调用 s.recv() 方法读取服务器返回的内容并打印出来

运行打印程序的客户端和服务端

让咱们运行打印程序的客户端和服务端,观察他们的表现,看看发生了什么事情

若是你在运行示例代码时遇到了问题,能够阅读 如何使用 Python 开发命令行命令,若是
你使用的是 windows 操做系统,请查看 Python Windows FAQ

打开命令行程序,进入你的代码所在的目录,运行打印程序的服务端:

$ ./echo-server.py

你的命令行将被挂起,由于程序有一个阻塞调用

conn, addr = s.accept()

它将等待客户端的链接,如今再打开一个命令行窗口运行打印程序的客户端:

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

在服务端的窗口你将看见:

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

上面的输出中,服务端打印出了 s.accept() 返回的 addr 元组,这就是客户端的 IP 地址和 TCP 端口号。示例中的端口号是 64623 这极可能是和你机器上运行的结果不一样

查看 socket 状态

想查找你主机上 socket 的当前状态,可使用 netstat 命令。这个命令在 macOS, Window, Linux 系统上默承认用

下面这个就是启动服务后 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 设置成空字符串 '' 的话,netstat 命令将显示以下:

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

本地地址是 *.65432,这表示全部主机支持的 IP 地址族均可以接受传入链接,在咱们的例子里面调用 socket() 时传入的参数 socket.AF_INET 表示使用了 IPv4 的 TCP socket,你能够在输出结果中的 Proto 列中看到(tcp4)

上面的输出是我截取的只显示了我们的打印程序服务端进程,你可能会看到更多输出,具体取决于你运行的系统。须要注意的是 Proto, Local Address 和 state 列。分别表示 TCP socket 类型、本地地址端口、当前状态

另一个查看这些信息的方法是使用 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)

isof 命令使用 -i 参数能够查看打开的 socket 链接的 COMMAND, PID(process id) 和 USER(user id),上面的输出就是打印程序服务端

netstatisof 命令有许多可用的参数,这取决于你使用的操做系统。可使用 man page 来查看他们的使用文档,这些文档绝对值得花一点时间去了解,你将受益不浅,macOS 和 Linux 中使用命令 man netstat 或者 man lsof 命令,windows 下使用 netstat /? 来查看帮助文档

一个一般会犯的错误是在没有监听 socket 端口的状况下尝试链接:

$ ./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

也多是端口号出错、服务端没启动或者有防火墙阻止了链接,这些缘由可能很难记住,或许你也会碰到 Connection timed out 的错误,记得给你的防火墙添加容许咱们使用的端口规则

引用部分有一些常见的 错误

通讯的流程分解

让咱们再仔细的观察下客户端是如何与服务端进行通讯的:

host

当使用回环地址时,数据将不会接触到外部网络,上图中,回环地址包含在了 host 里面。这就是回环地址的本质,链接数据传输是从本地到主机,这就是为何你会听到有回环地址或者 127.0.0.1::1 的 IP 地址和表示本地主机

应用程序使用回环地址来与主机上的其它进程通讯,这使得它与外部网络安全隔离。因为它是内部的,只能从主机内访问,因此它不会被暴露出去

若是你的应用程序服务器使用本身的专用数据库(非公用的),则能够配置服务器仅监听回环地址,这样的话网络上的其它主机就没法链接到你的数据库

若是你的应用程序中使用的 IP 地址不是 127.0.0.1 或者 ::1,那就可能会绑定到链接到外部网络的以太网上。这就是你通往 localhost 王国以外的其余主机的大门

external network

这里须要当心,而且可能让你感到难受甚至怀疑全世界。在你探索 localhost 的安全限制以前,确认读过 使用主机名 一节。 一个安全注意事项是 **不要使用主机名,要使用
IP 地址**

处理多个链接

打印程序的服务端确定有它本身的一些局限。这个程序只能服务于一个客户端而后结束。打印程序的客户端也有它本身的局限,可是还有一个问题,若是客户端调用了下面的方法s.recv() 方法将返回 b'Hello, world' 中的一个字节 b'H'

data = s.recv(1024)

1024 是缓冲区数据大小限制最大值参数 bufsize,并非说 recv() 方法只返回 1024个字节的内容

send() 方法也是这个原理,它返回发送内容的字节数,结果可能小于传入的发送内容,你得处理这处状况,按需屡次调用 send() 方法来发送完整的数据

应用程序负责检查是否已发送全部数据;若是仅传输了一些数据,则应用程序须要尝试传
递剩余数据 引用

咱们可使用 sendall() 方法来回避这个过程

和 send() 方法不同的是, sendall() 方法会一直发送字节,只到全部的数据传输完成
或者中途出现错误。成功的话会返回 None 引用

到目前为止,咱们有两个问题:

  • 如何同时处理多个链接请求
  • 咱们须要一直调用 send() 或者 recv() 直到全部数据传输完成

应该怎么作呢,有不少方式能够实现并发。最近,有一个很是流程的库叫作 Asynchronous I/O 能够实现,asyncio 库在 Python 3.4 后默认添加到了标准库里面。传统的方法是使用线程

并发的问题是很难作到正确,有许多细微之处须要考虑和防范。可能其中一个细节的问题都会致使整个程序崩溃

我说这些并非想吓跑你或者让你远离学习和使用并发编程。若是你想让程序支持大规模使用,使用多处理器、多核是颇有必要的。然而在这个教程中咱们将使用比线程更传统的方法使得逻辑更容易推理。咱们将使用一个很是古老的系统调用:select()

select() 容许你检查多个 socket 的 I/O 完成状况,因此你可使用它来检测哪一个 socket I/O 是就绪状态从而执行读取或写入操做,可是这是 Python,总会有更多其它的选择,咱们将使用标准库中的selectors 模块,因此咱们使用了最有效的实现,不用在乎你使用的操做系统:

这个模块提供了高层且高效的 I/O 多路复用,基于原始的 select 模块构建,推荐用
户使用这个模块,除非他们须要精确到操做系统层面的使用控制 [引用
]( https://docs.python.org/3/lib...

尽管如此,使用 select() 也没法并发执行。这取决于您的工做负载,这种实现仍然会很快。这也取决于你的应用程序对链接所作的具体事情或者它须要支持的客户端数量

asyncio 使用单线程来处理多任务,使用事件循环来管理任务。经过使用 select(),咱们能够建立本身的事件循环,更简单且同步化。当使用多线程时,即便要处理并发的状况,咱们也不得不面临使用 CPython 或者 PyPy 中的「全局解析器锁 GIL」,这有效地限制了咱们能够并行完成的工做量

说这些是为了解析为何使用 select() 多是个更好的选择,不要以为你必须使用 asyncio、线程或最新的异步库。一般,在网络应用程序中,你的应用程序就是 I/O 绑定:它能够在本地网络上,网络另外一端的端,磁盘上等待

若是你从客户端收到启动 CPU 绑定工做的请求,查看 concurrent.futures模块,它包含一个 ProcessPoolExecutor 类,用来异步执行进程池中的调用

若是你使用多进程,你的 Python 代码将被操做系统并行地在不一样处理器或者核心上调度运行,而且没有全局解析器锁。你能够经过
Python 大会上的演讲 John Reese - Thinking Outside the GIL with AsyncIO and Multiprocessing - PyCon 2018 来了解更多的想法

在下一节中,咱们将介绍解决这些问题的服务器和客户端的示例。他们使用 select() 来同时处理多链接请求,按需屡次调用 send()recv()

多链接的客户端和服务端

下面两节中,咱们将使用 selectors 模块中的 selector 对象来建立一个能够同时处理多个请求的客户端和服务端

多链接的服务端

首页,咱们来看眼多链接服务端程序的代码,multiconn-server.py。这是开始创建监听 socket 部分

import selectors
sel = selectors.DefaultSelector()
# ...
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print('listening on', (host, port))
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)

这个程序和以前打印程序服务端最大的不一样是使用了 lsock.setblocking(False) 配置 socket 为非阻塞模式,这个 socket 的调用将不在是阻塞的。当它和 sel.select() 一块儿使用的时候(下面会提到),咱们就能够等待 socket 就绪事件,而后执行读写操做

sel.register() 使用 sel.select() 为你感兴趣的事件注册 socket 监控,对于监听 socket,咱们但愿使用 selectors.EVENT_READ 读取到事件

data 用来存储任何你 socket 中想存的数据,当 select() 返回的时候它也会返回。咱们将使用 data 来跟踪 socket 上发送或者接收的东西

下面就是事件循环:

import selectors
sel = selectors.DefaultSelector()

# ...

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)

sel.select(timeout=None) 调用会阻塞直到 socket I/O 就绪。它返回一个(key, events) 元组,每一个 socket 一个。key 就是一个包含 fileobj 属性的具名元组。key.fileobj 是一个 socket 对象,mask 表示一个操做就绪的事件掩码

若是 key.data 为空,咱们就能够知道它来自于监听 socket,咱们须要调用 accept() 方法来授受链接请求。咱们将使用一个 accept() 包装函数来获取新的 socket 对象并注册到 selector 上,咱们立刻就会看到

若是 key.data 不为空,咱们就能够知道它是一个被接受的客户端 socket,咱们须要为它服务,接着 service_connection() 会传入 keymask 参数并调用,这包含了全部咱们须要在 socket 上操做的东西

让咱们一块儿来看看 accept_wrapper() 方法作了什么:

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print('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) 来让 socket 进入非阻塞模式

请记住,这是这个版本服务器程序的主要目标,由于咱们不但愿它被阻塞。若是被阻塞,那么整个服务器在返回前都处于挂起状态。这意味着其它 socket 处于等待状态,这是一种 很是严重的 谁都不想见到的服务被挂起的状态

接着咱们使用了 types.SimpleNamespace 类建立了一个对象用来保存咱们想要的 socket 和数据,因为咱们得知道客户端链接何时能够写入或者读取,下面两个事件都会被用到:

events = selectors.EVENT_READ | selectors.EVENT_WRITE

事件掩码、socket 和数据对象都会被传入 sel.register()

如今让咱们来看下,当客户端 socket 就绪的时候链接请求是如何使用 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
        else:
            print('closing connection to', data.addr)
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print('echoing', repr(data.outb), 'to', data.addr)
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

这就是多链接服务端的核心部分,key 就是从调用 select() 方法返回的一个具名元组,它包含了 socket 对象「fileobj」和数据对象。mask 包含了就绪的事件

若是 socket 就绪并且能够被读取, mask & selectors.EVENT_READ 就为真,sock.recv() 会被调用。全部读取到的数据都会被追加到 data.outb 里面。随后被发送出去

注意 else: 语句,若是没有收到任何数据:

if recv_data:
    data.outb += recv_data
else:
    print('closing connection to', data.addr)
    sel.unregister(sock)
    sock.close()

这表示客户端关闭了它的 socket 链接,这时服务端也应该关闭本身的链接。不过别忘了先调用 sel.unregister() 来撤销 select() 的监控

当 socket 就绪并且能够被读取的时候,对于正常的 socket 应该一直是这种状态,任何接收并被 data.outb 存储的数据都将使用 sock.send() 方法打印出来。发送出去的字节随后就会被从缓冲中删除

data.outb = data.outb[sent:]

多链接的客户端

如今让咱们一块儿来看看多链接的客户端程序,multiconn-client.py,它和服务端很类似,不同的是它没有监听链接请求,它以调用 start_connections() 开始初始化链接:

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('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=list(messages),
                                     outb=b'')
        sel.register(sock, events, data=data)

num_conns 参数是从命令行读取的,表示为服务器创建多少个连接。就像服务端程序同样,每一个 socket 都设置成了非阻塞模式

因为 connect() 方法会当即触发一个 BlockingIOError 异常,因此咱们使用 connect_ex() 方法取代它。connect_ex() 会返回一个错误指示 errno.EINPROGRESS,不像 connect() 方法直接在进程中返回异常。一旦链接结束,socket 就能够进行读写而且经过 select() 方法返回

socket 创建完成后,咱们将使用 types.SimpleNamespace 类建立想会传送的数据。因为每一个链接请求都会调用 socket.send(),发送到服务端的消息得使用 list(messages) 方法转换成列表结构。全部你想了解的东西,包括客户端将要发送的、已发送的、已接收的消息以及消息的总字节数都存储在 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:
            print('received', repr(recv_data), 'from connection', data.connid)
            data.recv_total += len(recv_data)
        if not recv_data or data.recv_total == data.msg_total:
            print('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('sending', repr(data.outb), 'to connection', data.connid)
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

有一个不一样的地方,客户端会跟踪从服务器接收的字节数,根据结果来决定是否关闭 socket 链接,服务端检测到客户端关闭则会一样的关闭服务端的链接

运行多链接的客户端和服务端

如今让咱们把 multiconn-server.pymulticonn-client.py 两个程序跑起来。他们都使用了命令行参数,若是不指定参数能够看到参数调用的方法:

服务端程序,传入主机和端口号

$ ./multiconn-server.py
usage: ./multiconn-server.py <host> <port>

客户端程序,传入启动服务端程序时一样的主机和端口号以及链接数量

$ ./multiconn-client.py
usage: ./multiconn-client.py <host> <port> <num_connections>

下面就是服务端程序运行起来在 65432 端口上监听回环地址的输出:

$ ./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)

下面是客户端,它建立了两个链接请求到上面的服务端:

$ ./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

应用程序客户端和服务端

多链接的客户端和服务端程序版本与最先的原始版本相比确定有了很大的改善,可是让咱们再进一步地解决上面「多链接」版本中的不足,而后完成最终版的实现:客户端/服务器应用程序

咱们但愿有个客户端和服务端在不影响其它链接的状况下作好错误处理,显然,若是没有发生异常,咱们的客户端和服务端不能崩溃的一团糟。这也是到如今为止咱们还没讨论的东西,我故意没有引入错误处理机制由于这样可使以前的程序容易理解

如今你对基本的 API,非阻塞 socket、select() 等概念已经有所了解了。咱们能够继续添加一些错误处理同时讨论下「房间里面的大象」的问题,我把一些东西隐藏在了幕后。你应该还记得,我在介绍中讨论到的自定义类

首先,让咱们先解决错误:

全部的错误都会触发异常,像无效参数类型和内存不足的常见异常能够被抛出;从 Python
3.3 开始,与 socket 或地址语义相关的错误会引起 OSError 或其子类之一的异常 引用

咱们须要捕获 OSError 异常。另一个我没说起的的问题是延迟,你将在文档的不少地方看见关于延迟的讨论,延迟会发生并且属于「正常」错误。主机或者路由器重启、交换机端口出错、电缆出问题或者被拔出,你应该在你的代码中处理好各类各样的错误

刚才说的「房间里面的大象」问题是怎么回事呢。就像 socket.SOCK_STREAM 这个参数的字面意思同样,当使用 TCP 链接时,你会从一个连续的字节流读取的数据,比如从磁盘上读取数据,不一样的是你是从网络读取字节流

然而,和使用 f.seek() 读文件不一样,换句话说,无法定位 socket 的数据流的位置,若是能够像文件同样定位数据流的位置(使用下标),那你就能够随意的读取你想要的数据

当字节流入你的 socket 时,会须要有不一样的网络缓冲区,若是想读取他们就必须先保存到其它地方,使用 recv() 方法持续的从 socket 上读取可用的字节流

至关于你从 socket 中读取的是一块一块的数据,你必须使用 recv() 方法不断的从缓冲区中读取数据,直到你的应用肯定读取到了足够的数据

何时算「足够」这取决于你的定义,就 TCP socket 而言,它只经过网络发送或接收原始字节。它并不了解这些原始字节的含义

这可让咱们定义一个应用层协议,什么是应用层协议?简单来讲,你的应用会发送或者接收消息,这些消息其实就是你的应用程序的协议

换句话说,这些消息的长度、格式能够定义应用程序的语义和行为,这和咱们以前说的从socket 中读取字节部份内容相关,当你使用 recv() 来读取字节的时候,你须要知道读的字节数,而且决定何时算读取完成

这些都是怎么完成的呢?一个方法是只读取固定长度的消息,若是它们的长度老是同样的话,这样作很容易。当你收到固定长度字节消息的时候,就能肯定它是个完整的消息

然而,若是你使用定长模式来发送比较短的消息会比较低效,由于你还得处理填充剩余的部分,此外,你还得处理数据不适合放在一个定长消息里面的状况

在这个教程里面,咱们将使用一个通用的方案,不少协议都会用到它,包括 HTTP。咱们将在每条消息前面追加一个头信息,头信息中包括消息的长度和其它咱们须要的字段。这样作的话咱们只须要追踪头信息,当咱们读到头信息时,就能够查到消息的长度而且读出全部字节而后消费它

咱们将经过使用一个自定义类来实现接收文本/二进制数据。你能够在此基础上作出改进或者经过继承这个类来扩展你的应用程序。重要的是你将看到一个例子实现它的过程

我将会提到一些关于 socket 和字节相关的东西,就像以前讨论过的。当你经过 socket 来发送或者接收数据时,其实你发送或者接收到的是原始字节

若是你收到数据而且想让它在一个多字节解释的上下文中使用,好比说 4-byte 的整形,你须要考虑它多是一种不是你机器 CPU 本机的格式。客户端或者服务器的另一头多是另一种使用了不一样的字节序列的 CPU,这样的话,你就得把它们转换成你主机的本地字节序列来使用

上面所说的字节顺序就是 CPU 的 字节序,在引用部分的字节序 一节能够查看更多。咱们将会利用 Unicode 字符集的优势来规避这个问题,并使用UTF-8 的方式编码,因为 UTF-8 使用了 8字节 编码方式,因此就不会有字节序列的问题

你能够查看 Python 关于编码与 Unicode 的 文档,注意咱们只会编码消息的头部。咱们将使用严格的类型,发送的消息编码格式会在头信息中定义。这将让咱们能够传输咱们以为有用的任意类型/格式数据

你能够经过调用 sys.byteorder 来决定你的机器的字节序列,好比在个人英特尔笔记本上,运行下面的代码就能够:

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

若是我把这段代码跑在能够模拟大字节序 CPU「PowerPC」的虚拟机上的话,应该是下面的结果:

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

在咱们的例子程序中,应用层的协议定义了使用 UTF-8 方式编码的 Unicode 字符。对于真正传输消息来讲,若是须要的话你仍是得手动交换字节序列

这取决于你的应用,是否须要它来处理不一样终端间的多字节二进制数据,你能够经过添加额外的头信息来让你的客户端或者服务端支持二进制,像 HTTP 同样,把头信息作为参数传进去

不用担忧本身还没搞懂上面的东西,下面一节咱们看到是若是实现的

应用的协议头

让咱们来定义一个完整的协议头:

  • 可变长度的文本
  • 基于 UTF-8 编码的 Unicode 字符集
  • 使用 JSON 序列化的一个 Python 字典

其中必须具备的头应该有如下几个:

名称 描述
byteorder 机器的字节序列(uses sys.byteorder),应用程序可能用不上
content-length 内容的字节长度
content-type 内容的类型,好比 text/json 或者 binary/my-binary-type
content-encoding 内容的编码类型,好比 utf-8 编码的 Unicode 文本,二进制数据

这些头信息告诉接收者消息数据,这样的话你就能够经过提供给接收者足够的信息让他接收到数据的时候正确的解码的方式向它发送任何数据,因为头信息是字典格式,你能够随意向头信息中添加键值对

发送应用程序消息

不过还有一个问题,因为咱们使用了变长的头信息,虽然方便扩展可是当你使用 recv() 方法读取消息的时候怎么知道头信息的长度呢

咱们前面讲到过使用 recv() 接收数据和如何肯定是否接收完成,我说过定长的头可能会很低效,的确如此。可是咱们将使用一个比较小的 2 字节定长的头信息前缀来表示头信息的长度

你能够认为这是一种混合的发送消息的实现方法,咱们经过发送头信息长度来引导接收者,方便他们解析消息体

为了给你更好地解释消息格式,让咱们来看看消息的全貌:

message

消息以 2字节的固定长度的头开始,这两个字节是整型的网络字节序列,表示下面的变长 JSON 头信息的长度,当咱们从 recv() 方法读取到 2 个字节时就知道它表示的是头信息长度的整形数字,而后在解码 JSON 头以前读取这么多的字节

JSON 头包含了头信息的字典。其中一个就是 content-length,这表示消息内容的数量(不是JSON头),当咱们使用 recv() 方法读取到了 content-length 个字节的数据时,就表示接收完成而且读取到了完整的消息

应用程序类

最后让咱们来看下成果,咱们使用了一个消息类。来看看它是如何在 socket 发生读写事件时与 select() 配合使用的

对于这个示例应用程序而言,我必须想出客户端和服务器将使用什么类型的消息,从这一点来说这远远超过了最先时候咱们写的那个玩具同样的打印程序

为了保证程序简单并且仍然可以演示出它是如何在一个真正的程序中工做的,我建立了一个应用程序协议用来实现基本的搜索功能。客户端发送一个搜索请求,服务器作一次匹配的查找,若是客户端的请求无法被识别成搜索请求,服务器就会假定这个是二进制请求,对应的返回二进制响应

跟着下面一节,运行示例、用代码作实验后你将会知道他是如何工做的,而后你就能够以这个消息类为起点把他修改为适合本身使用的

就像咱们以前讨论的,你将在下面看到,处理 socket 时须要保存状态。经过使用类,咱们能够将全部的状态、数据和代码打包到一个地方。当链接开始或者接受的时候消息类就会为每一个 socket 建立一个实例

类中的不少包装方法、工具方法在客户端和服务端上都是差很少的。它们如下划线开头,就像 Message._json_encode() 同样,这些方法经过类使用起来很简单。这使得它们在其它方法中调用时更短,并且符合 DRY 原则

消息类的服务端程序本质上和客户端同样。不一样的是客户端初始化链接并发送请求消息,随后要处理服务端返回的内容。而服务端则是等待链接请求,处理客户端的请求消息,随后发送响应消息

看起来就像这样:

步骤 动做/消息内容
1 客户端 发送带有请求内容的消息
2 服务端 接收并处理请求消息
3 服务端 发送有响应内容的消息
4 客户端 接收并处理响应消息

下面是代码的结构:

应用程序 文件 代码
服务端 app-server.py 服务端主程序
服务端 libserver.py 服务端消息类
客户端 app-client.py 客户端主程序
客户端 libclient.py 客户端消息类

消息入口点

我想经过首先提到它的设计方面来讨论 Message 类的工做方式,不过这对我来讲并非立马就能解释清楚的,只有在重构它至少五次以后我才能达到它目前的状态。为何呢?由于要管理状态

当消息对象建立的时候,它就被一个使用 selector.register() 事件监控起来的 socket 关联起来了

message = libserver.Message(sel, conn, addr)
sel.register(conn, selectors.EVENT_READ, data=message)
注意,这一节中的一些代码来自服务端主程序与消息类,可是这部份内容的讨论在客户端
也是同样的,我将在他们之间存在不一样点的时候来解释客户端的版本

当 socket 上的事件就绪的时候,它就会被 selector.select() 方法返回。对过 key 对象的 data 属性获取到 message 的引用,而后在消息用调用一个方法:

while True:
    events = sel.select(timeout=None)
    for key, mask in events:
        # ...
        message = key.data
        message.process_events(mask)

观察上面的事件循环,能够看见 sel.select() 位于「司机位置」,它是阻塞的,在循环的上面等待。当 socket 上的读写事件就绪时,它就会为其服务。这表示间接的它也要负责调用 process_events() 方法。这就是我说 process_events() 方法是入口的缘由

让咱们来看下 process_events() 方法作了什么

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() 方法中调用,这将使处理socket 事件的逻辑尽可能的简单

可能提及来很简单,可是经历了前面几回类的迭代:混合了一些方法,检查当前状态、依赖于其它值、在 read() 或者 write() 方法外面调用处理数据的方法,最后这证实了这样管理起来很复杂

固然,你确定须要把类按你本身的需求修改使它可以符合你的预期,可是我建议你尽量把状态检查、依赖状态的调用的逻辑放在 read()write() 方法里面

让咱们来看看 read() 方法,这是服务端版本,可是客户端也是同样的。不一样之处在于方法名称,一个(客户端)是 process_response() 另外一个(服务端)是 process_request()

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 读取数据并存入到接收缓冲区

记住,当调用 socket.recv() 方法时,组成消息的全部数据并无一次性所有到达。socket.recv() 方法可能须要调用不少次,这就是为何在调用相关方法处理数据前每次都要检查状态

当一个方法开始处理消息时,首页要检查的就是接受缓冲区保存了足够的多读取的数据,若是肯定,它们将继续处理各自的数据,而后把数据存到其它流程可能会用到的变量上,而且清空本身的缓冲区。因为一个消息有三个组件,因此会有三个状态检查和处理方法的调用:

Message Component Method Output
Fixed-length header process_protoheader() self._jsonheader_len
JSON header process_jsonheader() self.jsonheader
Content process_request() self.request

接下来,让咱们一块儿看看 write() 方法,这是服务端的版本:

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

    self._write()

write() 方法会首先检测是否有请求,若是有并且响应还没被建立的话 create_response() 方法就会被调用,它会设置状态变量 response_created,而后为发送缓冲区写入响应

若是发送缓冲区有数据,write() 方法会调用 socket.send() 方法

记住,当 socket.send() 被调用时,全部发送缓冲区的数据可能还没进入到发送队列,socket 的网络缓冲区可能满了,socket.send() 可能须要从新调用,这就是为何须要检查状态的缘由,create_response() 应该只被调用一次,可是 _write() 方法须要调用屡次

客户端的 write() 版大致与服务端一致:

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() 方法建立一个请求写入到发送缓冲区中,同时也会使用变量 _request_queued 记录状态值防止屡次调用

就像服务端同样,若是发送缓冲区有数据 _write() 方法会调用 socket.send() 方法

须要注意客户端版本的 write() 方法与服务端不一样之处在于最后的请求是否加入到队列中的检查,这个咱们将在客户端主程序中详细解释,缘由是要告诉 selector.select()中止监控 socket 的写入事件并且咱们只对读取事件感兴趣,没有办法通知套接字是可写的

我将在这一节中留下一个悬念,这一节的主要目的是解释 selector.select() 方法是如何经过 process_events() 方法调用消息类以及它是如何工做的

这一点很重要,由于 process_events() 方法在链接的生命周期中将被调用不少次,所以,要确保那些只能被调用一次的方法正常工做,这些方法中要么须要检查本身的状态变量,要么须要检查调用者的方法中的状态变量

服务端主程序

在服务端主程序 app-server.py 中,主机、端口参数是经过命令行传递给程序的:

$ ./app-server.py
usage: ./app-server.py <host> <port>

例如需求监听本地回环地址上面的 65432 端口,须要执行:

$ ./app-server.py 127.0.0.1 65432
listening on ('127.0.0.1', 65432)

<host> 参数为空的话就能够监听主机上的全部 IP 地址

建立完 socket 后,一个传入参数 socket.SO_REUSEADDR 的方法 `to
socket.setsockopt()` 将被调用

# Avoid bind() exception: OSError: [Errno 48] Address already in use
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

设置这个参数是为了不 端口被占用 的错误发生,若是当前程序使用的端口和以前的程序使用的同样,你就会发现链接处于 TIME_WAIT 状态

好比说,若是服务器主动关闭链接,服务器会保持为大概两分钟的 TIME_WAIT 状态,具体时长取决于你的操做系统。若是你想在两分钟内再开启一个服务,你将获得一个OSError 表示 端口被打败,这样作是为了确保一些在途的数据包正确的被处理

事件循环会捕捉全部错误,以保证服务器正常运行:

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('main: error: exception for',
                      f'{message.addr}:\n{traceback.format_exc()}')
                message.close()

当服务器接受到一个客户端链接时,消息对象就会被建立:

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

消息对象会经过 sel.register() 方法关联到 socket 上,并且它初始化就被设置成了只监控读事件。当请求被读取时,咱们将经过监听到的写事件修改它

在服务器端采用这种方法的一个优势是,大多数状况下,当 socket 正常而且没有网络问题时,它始终是可写的

若是咱们告诉 sel.register() 方法监控 EVENT_WRITE 写入事件,事件循环将会当即唤醒并通知咱们这种状况,然而此时 socket 并不用唤醒调用 send() 方法。因为请求还没被处理,因此不须要发回响应。这将消耗并浪费宝贵的 CPU 周期

服务端消息类

在消息切入点一节中,当经过 process_events() 知道 socket 事件就绪时咱们能够看到消息对象是如何发出动做的。如今让咱们来看看当数据在 socket 上被读取是会发生些什么,以及为服务器就绪的消息的组件片断发生了什么

libserver.py 文件中的服务端消息类,能够在 Github 上找到 源代码

这些方法按照消息处理顺序出如今类中

当服务器读取到至少两个字节时,定长头的逻辑就能够开始了

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:]

网络字节序列中的定长整型两字节包含了 JSON 头的长度,struct.unpack() 方法用来读取并解码,而后保存在 self._jsonheader_len 中,当这部分消息被处理完成后,就要调用 process_protoheader() 方法来删除接收缓冲区中处理过的消息

就像上面的定长头的逻辑同样,当接收缓冲区有足够的 JSON 头数据时,它也须要被处理:

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 格式的,因此解码方法调用时咱们写死了这个参数,结果将被存放在 self.jsonheader 中,process_jsonheader 方法作完他应该作的事情后,一样须要删除接收缓冲区中处理过的消息

接下来就是真正的消息内容,当接收缓冲区有 JSON 头中定义的 content-length 值的数量个字节时,请求就应该被处理了:

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('received request', repr(self.request), 'from', self.addr)
    else:
        # Binary or unknown content-type
        self.request = data
        print(f'received {self.jsonheader["content-type"]} request from',
              self.addr)
    # Set selector to listen for write events, we're done reading.
    self._set_selector_events_mask('w')

把消息保存到 data 变量中后,process_request() 又会删除接收缓冲区中处理过的数据。接着,若是 content type 是 JSON 的话,它将解码并反序列化数据。不然(在咱们的例子中)数据将被视 作二进制数据并打印出来

最后 process_request() 方法会修改 selector 为只监控写入事件。在服务端的程序 app-server.py 中,socket 初始化被设置成仅监控读事件。如今请求已经被所有处理完了,咱们对读取事件就不感兴趣了

如今就能够建立一个响应写入到 socket 中。当 socket 可写时 create_response() 将被从 write() 方法中调用:

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

响应会根据不一样的 content type 的不一样而调用不一样的方法建立。在这个例子中,当 action == 'search' 的时候会执行一个简单的字典查找。你能够在这个地方添加你本身的处理方法并调用

一个很差处理的问题是响应写入完成时如何关闭链接,我会在 _write() 方法中调用 close()

def _write(self):
    if self._send_buffer:
        print('sending', repr(self._send_buffer), '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()

虽然close() 方法的调用有点隐蔽,可是我认为这是一种权衡。由于消息类一个链接只处理一条消息。写入响应后,服务器无需执行任何操做。它的任务就完成了

客户端主程序

客户端主程序 app-client.py 中,参数从命令行中读取,用来建立请求并链接到服务端

$ ./app-client.py
usage: ./app-client.py <host> <port> <action> <value>

来个示例演示一下:

$ ./app-client.py 127.0.0.1 65432 search needle

当从命令行参数建立完一个字典来表示请求后,主机、端口、请求字典一块儿被传给 start_connection()

def start_connection(host, port, request):
    addr = (host, port)
    print('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)

对服务器的 socket 链接被建立,消息对象被传入请求字典并建立

和服务端同样,消息对象在 sel.register() 方法中被关联到 socket 上。然而,客户端不一样的是,socket 初始化的时候会监控读写事件,一旦请求被写入,咱们将会修改成只监控读取事件

这种实现和服务端同样有好处:不浪费 CPU 生命周期。请求发送完成后,咱们就不关注写入事件了,因此不用保持状态等待处理

客户端消息类

消息入口点 一节中,咱们看到过,当 socket 使用准备就绪时,消息对象是如何调用具体动做的。如今让咱们来看看 socket 上的数据是如何被读写的,以及消息准备好被加工的时候发生了什么

客户端消息类在 libclient.py 文件中,能够在 Github 上找到 源代码

这些方法按照消息处理顺序出如今类中

客户端的第一个任务就是让请求入队列:

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 中传入的命令行参数,当消息对象建立的时候,请求字典被当作参数传入

请求消息被建立并追加到发送缓冲区中,消息将被 _write() 方法发送,状态参数 self._request_queued 被设置,这使 queue_request() 方法不会被重复调用

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

客户端读取和处理消息的方法和服务端一致,因为响应数据是从 socket 上读取的,因此处理 header 的方法会被调用:process_protoheader()process_jsonheader()

最终处理方法名字的不一样在于处理一个响应,而不是建立:process_response(),_process_response_json_content()_process_response_binary_content()

最后,但确定不是最不重要的 —— 最终的 process_response() 调用:

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

消息类的包装

我将经过说起一些方法的重要注意点来结束消息类的讨论

主程序中任意的类触发异常都由 except 字句来处理:

try:
    message.process_events(mask)
except Exception:
    print('main: error: exception for',
          f'{message.addr}:\n{traceback.format_exc()}')
    message.close()

注意最后一行的方法 message.close()

这一行很重要的缘由有不少,不只仅是保证 socket 被关闭,并且经过调用 message.close() 方法删除使用 select() 监控的 socket,这是类中的一段很是简洁的代码,它能减少复杂度。若是一个异常发生或者咱们本身主动抛出,咱们很清楚 close() 方法将处理善后

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

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 行:except BlockingIOError

_write() 方法也有,这几行很重要是由于它们捕获临时错误并经过使用 pass 跳过。临时错误是 socket 阻塞的时候发生的,好比等待网络响应或者链接的其它端

经过使用 pass 跳过异常,select() 方法将再次调用,咱们将有机会从新读写数据

运行应用程序的客户端和服务端

通过全部这些艰苦的工做后,让咱们把程序运行起来并找到一些乐趣!

在这个救命中,咱们将传一个空的字符串作为 host 参数的值,用来监听服务器端的全部IP 地址。这样的话我就能够从其它网络上的虚拟机运行客户端程序,我将模拟一个 PowerPC 的机器

首页,把服务端程序运行进来:

$ ./app-server.py '' 65432
listening on ('', 65432)

如今让咱们运行客户端,传入搜索内容,看看是否能看他(墨菲斯-黑客帝国中的角色):

$ ./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)

个人命令行 shell 使用了 utf-8 编码,因此上面的输出能够是 emojis

再试试看能不能搜索到小狗:

$ ./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)

注意请求发送行的 byte string,很容易看出来你发送的小狗 emoji 表情被打印成了十六进制的字符串 \xf0\x9f\x90\xb6,我可使用 emoji 表情来搜索是由于个人命令行支持utf-8 格式的编码

这个示例中咱们发送给网络原始的 bytes,这些 bytes 须要被接受者正确的解释。这就是为何以前须要给消息附加头信息而且包含编码类型字段的缘由

下面这个是服务器对应上面两个客户端链接的输出:

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)

注意发送行中写到客户端的 bytes,这就是服务端的响应消息

若是 action 参数不是搜索,你也能够试试给服务器发送二进制请求

$ ./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 个 bytes 给客户端

$ ./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 模块文档,确保你读过文档中每一个咱们使用到的方法、函数。一样的能够从引用一节中找到一些办法,尤为是错误一节中的内容

有的时候问题并非由你的源代码引发的,源代码多是正确的。有多是不一样的主机、客户端和服务器。也多是网络缘由,好比路由器、防火墙或者是其它网络设备扮演了中间人的角色

对于这些类型的问题,额外的一些工具是必要的。下面这些工具或者集可能会帮到你或者至少提供一些线索

pin

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 发送的请求可能会被阻止。防火墙管理员定义了一些规则强制阻止一些请求,主要的缘由就是他们不想本身的主机是能够被发现的。若是你的机器也出现这种状况的话,请确保在规则中添加了容许 ICMP 包的发送

ICMP 是 ping 命令使用的协议,但它也是 TCP 和其余底层用于传递错误消息的协议,若是你遇到奇怪的行为或缓慢的链接,可能就是这个缘由

ICMP 消息经过类型和代号来定义。下面有一些重要的信息能够参考:

ICMP 类型 ICMP 代码 说明
8 0 打印请求
0 0 打印回复
3 0 目标网络不可达
3 1 目标主机不可达
3 2 目标协议不可达
3 3 目标端口不可达
3 4 须要分片,可是 DF(Don't fragmentation) 标识已被设置
11 0 网络存在环路

查看 Path MTU Discovery 更多关于分片和 ICMP 消息的内容,里面遇到的问题就是我前面说起的一些奇怪行为

netstat

查看 socket 状态 一节中咱们已经知道如何使用 netstat 来查看 socket 及其状态的信息。这个命令在 macOS, Linux, Windows 上均可以使用

在以前的示例中我并无说起 Recv-QSend-Q 列。这些列表示发送或者接收队列中网络缓冲区数据的字节数,可是因为某些缘由这些字节还没被远程或者本地应用读写

换句话说,这些网络中的字节还在操做系统的队列中。一个缘由多是应用程序受 CPU 限制或者没法调用 socket.recv()socket.send() 方法处理,或者由于其它一些网络缘由致使的,好比说网络的拥堵、失败、硬件及电缆的问题

为了复现这个问题,看看到底在错误发生前我应该发送多少数据。我写了一个测试客户端能够链接到测试服务器,而且重复的调用 socket.send() 方法。测试服务端永远不调用 socket.recv() 或者 socket.send() 方法来处理客户端发送的数据,它只接受链接请求。这会致使服务器上的网络缓冲区被填满,最终会在客户端上报错

首先运行服务端:

$ ./app-server-test.py 127.0.0.1 65432 listening on ('127.0.0.1', 65432)

而后运行客户端,看看发生了什么:

$ ./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

第一行就表示服务端(本地端口是 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

第二行表示客户端(远程端口是 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,它是 windows 下的一个可视化的 netstat 工具。除了地址、端口号和 socket 状态以外,它还会显示发送和接收的数据包以及字节数。就像 Unix 工具集 lsof 命令同样,你也能够看见进程名和 ID,能够在菜单中查看更多选项

TCPView

Wireshark

有时候你可能想查看网络底层发生了什么,忽略应用程序的输出或者外部库调用,想看看网络层面到底收发了什么内容,就像调试器同样,当你须要看清这些的时候,没有别的办法

Wireshark 是一款能够运行在 macOS, Linux, Windows 以及其它系统上的网络协议分析、流量捕获工具,GUI 版本的程序叫作
wireshark,命令 行的程序叫作 tshark

流量捕获是一个很是好用的方法,它可让你看到网络上应用程序的行为,收集到关于收发消息多少、频率等信息,你也能够看到客户端或者服务端如何关闭/取消链接,或者中止响应,当你须要排除故障的时候这些信息很是的有用

网上还有不少关于 wiresharkTShark 的基础使用教程

这有一个使用 wireshark 捕获本地网络数据的例子:

wireshark

还有一个和上面同样的使用 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

引用

这一节主要用来引用一些额外的信息和外部资源连接

Python 文档

错误信息

下面这段话来自 python 的 socket 模块文档:

全部的错误都会触发异常,像无效参数类型和内存不足的常见异常能够被抛出;从
Python 3.3 开始,与 socket 或地址语义相关的错误会引起 OSError 或其子类之一的异
引用

异常 | errno 常量 | 说明
BlockingIOError | EWOULDBLOCK | 资源暂不可用,好比在非阻塞模式下调用 send() 方法,对方太繁忙面没有读取,发送队列满了,或者网络有问题
OSError | EADDRINUSE | 端口被战用,确保没有其它的进程与当前的程序运行在同一地址/端口上,你的服务器设置了 SO_REUSEADDR 参数
ConnectionResetError | ECONNRESET | 链接被重置,远端的进程崩溃,或者 socket 意外关闭,或是有防火墙或链路上的设配有问题
TimeoutError | ETIMEDOUT | 操做超时,对方没有响应
ConnectionRefusedError | ECONNREFUSED | 链接被拒绝,没有程序监听指定的端口

socket 地址族

socket.AF_INETsocket.AF_INET6socket.socket() 方法调用的第一个参数
,表示地址协议族,API 使用了一个指望传入指定格式参数的地址,这取决因而
AF_INET 仍是 AF_INET6

地址族 协议 地址元组 说明
socket.AF_INET IPv4 (host, port) host 参数是个如 www.example.com 的主机名称,或者如 10.1.2.3 的 IPv4 地址
socket.AF_INET6 IPv6 (host, port, flowinfo, scopeid) 主机名同上,IPv6 地址 如:fe80::6203:7ab:fe88:9c23,flowinfo 和 scopeid 分别表示 C 语言结构体 sockaddr_in6 中的 sin6_flowinfosin6_scope_id 成员

注意下面这段 python socket 模块中关于 host 值和地址元组文档

对于 IPv4 地址,使用主机地址的方式有两种: '' 空字符串表示 INADDR_ANY,字符
'<broadcast>' 表示 INADDR_BROADCAST,这个行为和 IPv6 不兼容,所以若是你的
程序中使用的是 IPv6 就应该避免这种作法。[源文档
]( https://docs.python.org/3/lib...

我在本教程中使用了 IPv4 地址,可是若是你的机器支持,也能够试试 IPv6 地址。socket.getaddrinfo() 方法会返回五个元组的序列,这包括全部建立 socket 链接的必要参数,socket.getaddrinfo() 方法理解并处理传入的 IPv6 地址和主机名

下面的例子中程序将返回一个经过 TCP 链接到 example.org 80 端口上的地址信息:

>>> 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 模块文档中的 [示例
](https://docs.python.org/3/lib... 一节中有客户端和服务端
程序

使用主机名

这一节主要适用于使用 bind()connect()connect_ex() 方法时如何使用主机名,然而当你使用回环地址作为主机名时,它老是会解析到你指望的地址。这恰好与客户端使用主机名的场景相反,它须要 DNS 解析的过程,好比 www.example.com

下面一段来自 python socket 模块文档

若是你主机名称作为 IPv4/v6 socket 地址的 host 部分,程序可能会出现非预期的结果
,因为 python 使用了 DNS 查找过程当中的第一个结果,socket 地址会被解析成与真正的
IPv4/v6 地址不一样的其它地址,这取决于 DNS 解析和你的 host 文件配置。若是想获得
肯定的结果,请使用数字格式的地址作为 host 参数的值 [源文档
]( https://docs.python.org/3/lib...

一般回环地址 localhost 会被解析到 127.0.0.1::1 上,你的系统可能就是这么设置的,也可能不是。这取决于你系统配置,与全部 IT 相关的事情同样,总会有例外的状况,没办法彻底保证 localhost 被解析到了回环地址上

好比在 Linux 上,查看 man nsswitch.conf 的结果,域名切换配置文件,还有另一个 macOS 和 Linux 通用的配置文件地址是:/etc/hosts,在 windows 上则是C:\Windows\System32\drivers\etc\hosts,hosts 文件包含了一个文本格式的静态域名地址映射表,总之 DNS 也是一个难题

有趣的是,在撰写这篇文章的时候(2018 年 6 月),有一个关于 让 localhost 成为真正的 localhost的 RFC 草案,讨论就是围绕着 localhost 使用的状况开展的

最重要的一点是你要理解当你在应用程序中使用主机名时,返回的地址多是任何东西,若是你有一个安全性敏感的应用程序,不要使用主机名。取决于你的应用程序和环境,这可能会困扰到你

注意: 安全方面的考虑和最佳实践老是好的,即便你的程序不是安全敏感型的应用。若是你的应用程序访问了网络,那它就应该是安全的稳定的。这表示至少要作到如下几点:

  • 常常会有系统软件升级和安全补丁,包括 python,你是否使用了第三方的库?若是是的话,确保他们能正常工做而且更新到了新版本
  • 尽可能使用专用防火墙或基于主机的防火墙来限制与受信任系统的链接
  • DNS 服务是如何配置的?你是否信任配置内容及其配置者
  • 在调用处理其余代码以前,请确保尽量地对请求数据进行了清理和验证,还要为此添加测试用例,而且常常运行

不管是否使用主机名称,你的应用程序都须要支持安全链接(加密受权),你可能会用到 TLS,这是一个超越了本教程的范围的话题。能够从 python 的 SSL 模块文档了解如何开始使用它,这个协议和你的浏览器使用的安全协议是同样的

考虑到接口、IP 地址、域名解析这些「变量」,你应该怎么应对?若是你尚未网络应用程序审查流程,可使用如下建议:

应用程序 使用 建议
服务端 回环地址 使用 IP 地址 127.0.0.1 或 ::1
服务端 以太网地址 使用 IP 地址,好比:10.1.2.3,使用空字符串表示本机全部 IP 地址
客户端 回环地址 使用 IP 地址 127.0.0.1 或 ::1
客户端 以太网地址 使用统一的不依赖域名解析的 IP 地址,特殊状况下才会使用主机地址,查看上面的安全提示

对于客户端或者服务端来讲,若是你须要受权链接到主机,请查看如何使用 TLS

阻塞调用

若是一个 socket 函数或者方法使你的程序挂起,那么这个就是个阻塞调用,好比 accept(), connect(), send(), 和 recv() 都是 阻塞 的,它们不会当即返回,阻塞调用在返回前必须等待系统调用 (I/O) 完成。因此调用者 —— 你,会被阻止直到系统调用结束或者超过延迟时间或者有错误发生

阻塞的 socket 调用能够设置成非阻塞的模式,这样他们就能够当即返回。若是你想作到这一点,就得重构并从新设计你的应用程序

因为调用直接返回了,可是数据确没就绪,被调用者处于等待网络响应的状态,无法完成它的工做,这种状况下,当前 socket 的状态码 errno 应该是 socket.EWOULDBLOCKsetblocking() 方法是支持非阻塞模式的

默认状况下,socket 会以阻塞模式建立,查看 socket 延迟的注意事项 中三种模式的解释

关闭链接

有趣的是 TCP 链接一端打开,另外一端关闭的状态是彻底合法的,这被称作 TCP「半链接」,是否须要这种保持状态是由应用程序决定的,一般来讲不须要。这种状态下,关闭方将不能发送任何数据,它只能接收数据

我不是在提倡你采用这种方法,可是做为一个例子,HTTP 使用了一个名为「Connection」的头来标准化规定应用程序是否关闭或者保持链接状态,更多内容请查看 RFC 7230 中 6.3 节, HTTP 协议 (HTTP/1.1): 消息语法与路由

当你在设计应用程序及其应用层协议的时候,最好先了解一下如何关闭链接,有时这很简单并且很明显,或者采起一些能够实现的原型,这取决于你的应用程序以及消息循环如何被处理成指望的数据,只要确保 socket 在完成工做后老是能正确关闭

字节序

查看维基百科 字节序 中关于不一样的 CPU 是如何在内存中存储字节序列的,处理单个字节时没有任何问题,可是当把多个字节处理成单个值(四字节整型)时,若是和你通讯的另外一端使用了不一样的字节序时字节顺序须要被反转

字节顺序对于字符文原本说也很重要,字符文本经过表示为多字节的序列,就像 Unicode 同样。除非你只使用 true 和 ASCII 字符来控制客户端和服务端的实现,不然使用 utf-8 格式或者支持字节序标识(BOM) 的 Unicode 字符集会比较合适

在应用层协议中明确的规定使用编码格式是很重要的,你能够规定全部的文本都使用 utf-8 或者用「content-encoding」头指定编码格式,这将使你的程序不须要检测编码方式,固然也应该尽可能避免这么作

当数据被调用存储到了文件或者数据库中并且又没有数据的元信息的时候,问题就很麻烦了,当数据被传到其它端,它将试着检测数据的编码方式。有关讨论,请参阅 Wikipedia 的 Unicode 文章,它引用了 RFC 3629:UTF-8, a transformation format of ISO 10646

然而 UTF-8 的标准 RFC 3629 中推荐禁止在 UTF-8 协议中使用标记字节序 (BOM),可是
讨 论了没法实现的状况,最大的问题在于如何使用一种模式在不依赖 BOM 的状况下区分
UTF-8 和其它编码方式

避开这些问题的方法就是老是存储数据使用的编码方式,换句话说,若是不仅用 utf-8 格式的编码或者其它的带有 BOM 的编码就要尝试以某种方式将编码方式存储为元数据,而后你就能够在数据上附加编码的头信息,告诉接收者编码方式

TCP/IP 使用的字节顺序是 big-endian,被称作网络序。网络序被用来表示底层协议栈中的整型数字,比如 IP 地址和端口号,python 的 socket 模块有几个函数能够把这种整型数字从网络字节序转换成主机字节序

函数 说明
socket.ntohl(x) 把 32 位的正整型数字从网络字节序转换成主机字节序,在网络字节序和主机字节序相同的机器上这是个空操做,不然将是一个 4 字节的交换操做
socket.ntohs(x) 把 16 位的正整型数字从网络字节序转换成主机字节序,在网络字节序和主机字节序相同的机器上这是个空操做,不然将是一个 2 字节的交换操做
socket.htonl(x) 把 32 位的正整型数字从主机字节序转换成网络字节序,在网络字节序和主机字节序相同的机器上这是个空操做,不然将是一个 4 字节的交换操做
socket.htons(x) 把 16 位的正整型数字从主机字节序转换成网络字节序,在网络字节序和主机字节序相同的机器上这是个空操做,不然将是一个 2 字节的交换操做

你也可使用 struct 模块打包或者解包二进制数据(使用格式化字符串):

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

结论

咱们在本教程中介绍了不少内容,网络和 socket 是很大的一个主题,若是你对它们都比较陌生,不要被这些规则和大写字母术语吓到

为了理解全部的东西如何工做的,有不少部分须要了解。可是,就像 python 同样,当你花时间去了解每一个独立的部分时它才开始变得有意义

咱们看过了 python socket 模块中底层的一些 API,并了解了如何使用它们建立客户端服务器应用程序。咱们也建立了一个自定义类来作为应用层的协议,并用它在不一样的端点之间交换数据,你可使用这个类并在些基础上快速且简单地构建出一个你本身的 socket 应用程序

你能够在 Github 上找到 源代码

恭喜你坚持到最后!你如今就能够在程序中很好地使用 socket 了

我但愿这个教程能为你开始 socket 编程旅途中提供一些信息、示例、或者灵感

相关文章
相关标签/搜索