Python-网络编程基础知识-一-

122 阅读1小时+

Python 网络编程基础知识(一)

原文:Foundations of Python Network Programming

协议:CC BY-NC-SA 4.0

零、简介

对于 Python 社区来说,这是一个激动人心的时刻。经过二十年的精心创新,这种语言在保持语法和概念简单的同时,获得了上下文管理器、生成器和理解等功能,Python 终于起飞了。

Python 不再被视为只能由谷歌和美国国家航空航天局(n as a)等顶级编程公司冒险开发的精品语言,而是正在被迅速采用,既包括传统的编程角色,如 web 应用设计,也包括“不情愿的程序员”的广阔世界,如科学家、数据专家和工程师——这些人学习编程不是为了编程本身,而是因为如果他们要在自己的领域取得进展,就必须编写程序。我认为,简单的编程语言为偶尔的或非专业的程序员提供的好处不能被夸大。

蟒蛇 3

在 2008 年首次亮相后,Python 3 经历了几年的修改和精简,才准备好扮演其前身的角色。但是随着它现在进入第二个五年,它已经成为 Python 社区中首选的创新平台。

无论是着眼于根本性的改进,如真正的 Unicode 文本现在是 Python 3 中的默认字符串类型,还是着眼于个别的改进,如正确支持 SSL,一个用于异步编程的内置asyncio框架,以及对大大小小的标准库模块的调整,Python 3 为网络程序员提供的平台几乎在各个方面都得到了改进。这是一项重大成就。Python 2 已经是让程序员在现代互联网上快速有效地工作的最佳语言之一。

这本书并不是从 Python 2 切换到 Python 3 的全面指南。它不会告诉您如何在旧的print语句中添加括号,将标准库模块导入重命名为新名称,或者调试有严重缺陷的网络代码,这些代码依赖于 Python 2 在字节字符串和 Unicode 字符串之间危险的自动转换——这些转换总是基于粗略的猜测。已经有很好的资源可以帮助您完成这种转变,甚至可以帮助您足够仔细地编写库,以便它们的代码可以在 Python 2 和 Python 3 下工作,以防您需要支持这两种受众。

相反,这本书侧重于网络编程,在 Python 提示符下,每个示例脚本和代码片段都使用 Python 3。这些例子旨在构建一幅全面的图像,说明如何利用该语言提供的工具最好地构建网络客户机、网络服务器和网络工具。读者可以通过将本书第二版每章中使用的脚本与第三版中的清单进行比较,来研究从 Python 2 到 Python 3 的过渡——这两个版本都可以在https://github.com/brandon-rhodes/fopnp/tree/m/获得,这要归功于在线提供源代码的优秀策略。接下来每一章的目标只是向您展示 Python 3 如何最好地用于解决现代网络编程问题。

通过直接关注如何用 Python 3 以正确的方式完成事情,这本书希望让准备从头开始编写新应用的程序员和准备将旧代码基础转换到新约定的程序员都做好准备。两位程序员都应该知道 Python 3 中正确的网络代码是什么样的,因此也应该知道他们的目标应该是什么样的代码。

此版本中的改进

除了将 Python 3 作为其目标语言,以及在过去五年中对标准库和第三方 Python 模块进行了许多更新之外,本书还尝试对之前的版本进行了一些改进。

  • 每个 Python 程序清单现在都被写成一个模块。也就是说,每个模块执行其导入并定义其函数或类,然后小心地保护一个if语句中的任何导入时动作,该语句仅在模块__name__具有特殊字符串值'__main__'时触发,该字符串值表示该模块作为主程序运行。这是 Python 的最佳实践,在本书的前一版本中几乎完全被忽略了,它的缺失使得示例清单更难被引入真正的代码库并用于解决读者的问题。通过将可执行逻辑放在左边,而不是放在一个if语句中,旧的程序清单可能节省了一两行代码,但是它们给 Python 程序员新手提供的如何编排真正代码的练习要少得多。
  • 本书中的大多数脚本现在使用标准库argparse模块来解释选项和参数,而不是专门使用原始的sys.argv字符串列表来解释命令行。这不仅阐明并记录了每个脚本在调用期间所期望的语义,还让每个脚本的用户在从 Windows 或 Unix 命令行启动脚本时使用–h--help查询选项来获得交互式帮助。
  • 程序清单现在通过在一个控制with语句中打开文件来努力执行适当的资源控制,当它完成时将自动关闭文件。在以前的版本中,大多数清单依赖于这样一个事实,即 Python 主网站上的 C Python 运行时通常会确保文件立即关闭,这要归功于它积极的引用计数。
  • 这些清单在很大程度上已经过渡到了执行字符串插值的现代format()方法,并远离了旧的模运算符 hack string % tuple,这在 20 世纪 90 年代是有意义的,当时大多数程序员都知道 C 语言,但对于今天进入该领域的新程序员来说可读性较差——功能也较弱,因为单个 Python 类不能像使用新类型那样覆盖百分比格式。
  • 关于 HTTP 和万维网的三章(第九章到第十一章)已经被从头开始重写,重点是更好地解释协议和介绍 Python 为程序员提供的最现代的工具。HTTP 协议的解释现在使用请求库作为执行客户端操作的 API,第十一章在 Flask 和 Django 中都有例子。
  • 关于 SSL/TLS 的材料(第六章)已经完全重写,以匹配 Python 3 为安全应用提供的支持方面的巨大改进。虽然 Python 2 中的ssl模块是一个弱的折中方案,它甚至不能验证服务器的证书是否与 Python 所连接的主机名相匹配,但是 Python 3 中的相同模块提供了一个设计更加精心、更加广泛的 API,它提供了对其特性的大量控制。

因此,就如何构造清单和示例而言,即使不考虑 Python 3 对该语言以前版本的改进,本书的这个版本也是学习程序员的更好资源。

网络游乐场

本书中程序列表的源代码可以在网上获得,因此本书的当前所有者和潜在读者都可以研究它们。这本书的每一章都有目录。你可以在这里找到章节目录:


https://github.com/brandon-rhodes/fopnp/tree/m/py3

但是程序清单对于支持好奇的网络编程学生来说只能到此为止。网络编程有许多特性很难在单台主机上探索。因此,本书的源代码库提供了一个由 12 台机器组成的示例网络,每台机器都实现为一个 Docker 容器。提供了一个安装脚本来构建映像、启动映像并将其联网。您可以在下面的源代码库中找到脚本和图像:


https://github.com/brandon-rhodes/fopnp/tree/m/playground

在图 i-1 中可以看到 12 台机器以及它们的相互连接。这个网络被设计成一个微型的互联网。

9781430258544_FM-01.jpg

图 1 网络游乐场的拓扑结构

  • 代表客户在家中或咖啡店中的典型情况的是modemAmodemB后面的客户机,它们不仅不向互联网提供服务,而且实际上在更广泛的互联网上根本看不到。它们只拥有本地 IP 地址,这些地址只有在它们与同一家或咖啡店中的任何其它主机共享的子网上才有意义。当它们与外界建立连接时,这些连接看起来似乎来自调制解调器本身的 IP 地址。

  • 直接连接允许调制解调器连接到更广阔的互联网上的一个isp网关,它由一个单独的backbone路由器代表,在它所连接的网络之间转发数据包。

  • example.com及其相关的机器代表一个简单的面向服务的机房的配置。这里,没有网络转换或伪装发生。example.com后面的三台服务器的服务端口完全暴露给来自互联网的客户端流量。

  • 每台服务机器ftpmailwww都已经正确地配置好守护进程并运行,因此本书中的 Python 脚本可以在操场上的其他机器上运行,以成功连接到每个服务的代表性示例。

  • 所有的服务机器都已经正确安装了 TLS 证书(参见第六章),并且客户端机器都已经安装了example.com签名证书作为可信证书。这意味着要求真正 TLS 认证的 Python 脚本将能够实现它

随着 Python 和 Docker 的不断发展,网络游乐场将继续得到维护。存储库中将保存如何在自己的机器上下载和运行网络的说明,并将根据用户报告进行调整,以确保提供操场的虚拟机可以由 Linux、Mac OS X 和 Windows 机器上的读者运行。

有了在任何一台游戏机器上连接和运行命令的能力,您将能够在网络上任何您希望看到客户机和服务器之间流量通过的地方设置数据包跟踪。文档中展示的示例代码,结合本书中的示例和说明,应该有助于您对网络如何帮助客户端和服务器通信有一个坚实而生动的理解。

一、客户端-服务器网络简介

这本书探索了 Python 语言的网络编程。它涵盖了使用最流行的 Internet 通信协议与远程机器通信时可能用到的基本概念、模块和第三方库。

如果你以前从未见过 Python 语言,或者你甚至从未写过计算机程序,那么这本书缺乏足够的篇幅来教你如何用 Python 编程;它假设您已经从许多优秀的教程和书籍中学到了一些关于 Python 编程的知识。我希望书中的 Python 例子能让你了解如何构建和编写自己的代码。但是我将使用各种各样的高级 Python 特性,而不做任何解释或道歉——不过,偶尔,当我认为某项技术或构造特别有趣或聪明时,我可能会指出我是如何使用它的。

另一方面,这本书并没有从假设你了解任何网络开始。只要你曾经使用过网络浏览器或发送过电子邮件,你就应该知道足够多的知识来开始阅读这本书,并在此过程中了解计算机网络。我将从一个应用程序员的角度来研究网络,这个程序员要么正在实现一个与网络相连的服务——比如一个网站、一个电子邮件服务器或一个联网的计算机游戏——要么正在编写一个旨在使用这种服务的客户端程序。

但是,请注意,您不会从本书中学到如何设置或配置网络。网络设计、服务器机房管理和自动化供应等学科本身就是完整的主题,不会与本书中涉及的计算机编程学科重叠。虽然由于 OpenStack、SaltStack 和 Ansible 等项目,Python 确实正在成为供应领域的一个重要组成部分,但如果您想了解更多信息,您将需要搜索专门关于供应及其许多技术的书籍和文档。

积木:书库和图书馆

当您开始探索 Python 网络编程时,有两个概念会反复出现。

  • 一个协议栈 的想法,其中简单的网络服务被用作构建更复杂服务的基础。
  • 事实上,您将经常使用以前编写的代码的 Python ——无论是 Python 附带的内置标准库中的模块,还是您下载并安装的第三方发行版中的包——它们已经知道如何使用您想要使用的网络协议。

在许多情况下,网络编程只是选择和使用已经支持您需要执行的网络操作的库。本书的主要目的是向您介绍 Python 可用的几个关键网络库,同时也向您介绍构建这些库所基于的底层网络服务。了解底层材料是有用的,这样既可以理解库是如何工作的,也可以理解当底层出错时会发生什么。

让我们从一个简单的例子开始。以下是邮寄地址:

207 N. Defiance St
Archbold, OH

我想知道这个物理地址的纬度和经度。恰好 Google 提供了一个地理编码 API,可以执行这样的转换。为了利用 Python 的这一网络服务,您需要做些什么?

当查看您想要使用的新网络服务时,总是有必要从了解是否有人已经实现了该协议开始,在这种情况下,就是您的程序需要使用的 Google 地理编码协议。首先浏览 Python 标准库文档,查找与地理编码有关的内容。

http://docs.python.org/3/library/

你看到任何关于地理编码的东西了吗?不,我也不知道。但是对于一个 Python 程序员来说,经常浏览标准库的目录是很重要的,即使你通常找不到你要找的东西,因为每次通读都会让你更熟悉 Python 中包含的服务。Doug Hellmann 的“本周 Python 模块”博客是另一个很好的参考,从中您可以了解 Python 由于其标准库而带来的功能。

因为在这种情况下,标准库没有可用的包,所以您可以求助于 Python 包索引,这是一个很好的资源,可以找到由世界各地的其他程序员和组织提供的各种通用 Python 包。当然,您也可以查看您将使用其服务的供应商的网站,看看它是否提供了访问它的 Python 库。或者,你可以在谷歌上搜索 Python,加上你想使用的任何网络服务的名字,看看前几个结果中是否有链接到你想尝试的包。

在本例中,我搜索了 Python 包索引,它位于以下 URL:

https://pypi.python.org/

在那里,我输入了地理编码,我立即找到了一个名为pygeocoder的包,它提供了一个清晰的谷歌地理编码特性的接口(不过,你会从它的描述中注意到,它是而不是供应商提供的,而是由谷歌以外的人编写的)。

http://pypi.python.org/pypi/pygeocoder/

这是一个很常见的情况——找到一个 Python 包,听起来它可能已经做了您想要做的事情,并且您想在您的系统上尝试它——我应该暂停一下,向您介绍快速尝试新库的最佳 Python 技术:virtualenv

在过去,安装 Python 包是一件可怕且不可逆转的事情,需要对您的机器拥有管理权限,并且会使您的系统 Python 安装被永久更改。经过几个月繁重的 Python 开发,您的系统 Python 安装可能会变成几十个包的废墟,全部都是手工安装的,您甚至会发现您试图安装的新包会崩溃,因为它们与硬盘上的旧包不兼容,而旧包来自一个几个月前结束的项目。

细心的 Python 程序员再也不会遭受这种情况了。我们中的许多人在系统范围内只安装一个 Python 包——这就是virtualenv!。一旦安装了virtualenv,您就有能力创建任意数量的小型、自包含的“虚拟 Python 环境”,在这里可以安装和卸载包,并且您可以进行试验,所有这些都不会污染您的系统范围的 Python。当一个特定的项目或实验结束时,您只需删除它的虚拟环境目录,您的系统就干净了。

在这种情况下,您想要创建一个虚拟环境来测试pygeocoder包。如果您以前从未在系统上安装过virtualenv,请访问以下 URL 下载并安装它:

http://pypi.python.org/pypi/virtualenv

一旦安装了virtualenv,就可以使用下面的命令创建一个新环境。(在 Windows 上,虚拟环境中包含 Python 二进制文件的目录将被命名为Scripts,而不是bin。)

$ virtualenv –p python3 geo_env
$ cd geo_env
$ ls
bin/  include/  lib/
$ . bin/activate
$ python -c 'import pygeocoder'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ImportError: No module named 'pygeocoder'

如你所见,pygeocoder包还没有发布。要安装它,请使用您的虚拟环境中的pip命令,由于您已经运行了activate命令,该命令现在位于您的路径上。

$ pip install pygeocoder
Downloading/unpacking pygeocoder
  Downloading pygeocoder-1.2.1.1.tar.gz
  Running setup.py egg_info for package pygeocoder

Downloading/unpacking requests>=1.0 (from pygeocoder)
  Downloading requests-2.0.1.tar.gz (412kB): 412kB downloaded
  Running setup.py egg_info for package requests

Installing collected packages: pygeocoder, requests
  Running setup.py install for pygeocoder

  Running setup.py install for requests

Successfully installed pygeocoder requests
Cleaning up...

virtualenv中的python二进制文件现在可以使用pygeocoder包了。

$ python -c 'import pygeocoder'

现在您已经安装了pygeocoder包,您应该能够运行名为search1.py的简单程序,如清单 1-1 所示。

清单 1-1 。获取经纬度

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search1.py

from pygeocoder import Geocoder

if __name__ == '__main__':
    address = '207 N. Defiance St, Archbold, OH'
    print(Geocoder.geocode(address)[0].coordinates)

通过在命令行运行它,您应该会看到如下结果:

$ python3 search1.py
(41.521954, -84.306691)

在那里,就在你的电脑屏幕上是我们关于地址的纬度和经度问题的答案!答案直接来自谷歌的网络服务。第一个范例程序取得了令人振奋的成功。

打开一本关于 Python 网络编程的书,却发现自己立即被引导下载并安装了一个第三方包,该包将一个有趣的网络问题变成了一个令人厌烦的三行 Python 脚本,您对此感到恼火吗?安息吧!90%的情况下,您会发现这正是解决编程挑战的方法——通过在 Python 社区中找到已经解决了您所面临的问题的其他程序员,然后在他们的解决方案的基础上聪明而简单地构建。

然而,您还没有探索完这个例子。您已经看到,一个复杂的网络服务通常可以很容易地访问。但是漂亮的pygeocoder界面背后是什么呢?这项服务实际上是如何工作的?现在,您将详细探索这种复杂的服务实际上是如何成为至少包含六个不同级别的网络堆栈的顶层。

应用层

第一个程序清单使用了从 Python 包索引下载的第三方 Python 库来解决问题。它对谷歌地理编码 API 及其使用规则了如指掌。但是如果那个图书馆不存在呢?如果你必须自己为谷歌地图 API 构建一个客户端会怎么样?

答案请看search2.py,如清单 1-2 所示。它没有使用支持地理编码的第三方库,而是下降一级,使用位于pygeocoding之后的流行的requests库,正如您在前面的pip install命令中看到的,它也已经安装在您的虚拟环境中。

清单 1-2 。从 Google 地理编码 API 获取 JSON 文档

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search2.py

import requests

def geocode(address):
    parameters = {'address': address, 'sensor': 'false'}
    base = 'http://maps.googleapis.com/maps/api/geocode/json'
    response = requests.get(base, params=parameters)
    answer = response.json()
    print(answer['results'][0]['geometry']['location'])

if __name__ == '__main__':
    geocode('207 N. Defiance St, Archbold, OH')

运行这个 Python 程序会返回一个与第一个脚本非常相似的答案。

$ python3 search2.py
{'lat': 41.521954, 'lng': -84.306691}

输出并不完全相同——例如,您可以看到 JSON 数据将结果编码为一个“对象”,requests将它作为 Python 字典交给您。但是很明显,这个脚本完成了与第一个脚本相同的事情。

关于这段代码,您会注意到的第一件事是,高层模块pygeocoder提供的语义不存在了。除非您仔细查看这段代码,否则您可能根本看不出它在询问邮寄地址!鉴于search1.py直接要求将地址转换成纬度和经度,第二个清单费力地构建了一个基本 URL 和一组查询参数,除非您已经阅读了 Google 文档,否则您可能不清楚它们的用途。如果你想阅读文档,顺便说一下,你可以找到这里描述的 API:

http://code.google.com/apis/maps/documentation/geocoding/

如果您仔细查看search2.py中查询参数的字典,您会看到address参数提供了您所询问的特定邮寄地址。另一个参数通知 Google,由于来自移动设备位置传感器的实时数据,您不会发出这个位置查询。

当您在查找这个 URL 的结果中收到一个文档时,您可以手动调用response.json()方法将其解释为 JSON,然后深入到多层的结果数据结构中,找到包含纬度和经度的正确元素。

然后,search2.py脚本做与search1.py相同的事情——但它不是用地址和纬度的语言来做,而是谈论构建 URL、获取响应并将其解析为 JSON 的具体细节。当您从网络堆栈的一层向下一层时,这是一个常见的区别:高级代码谈论请求意味着什么,而低级代码只能看到请求如何构造的细节

讲协议

因此,第二个示例脚本创建了一个 URL 并获取与之对应的文档。这个操作听起来很简单,当然,你的网络浏览器努力让它看起来很简单。当然,URL 可以用来获取文档的真正原因是,URL 是一种描述在哪里找到以及如何获取 Web 上给定文档的方法。URL 由协议名、文档所在的机器名组成,并以命名该机器上特定文档的路径结束。那么,search2.py Python 程序能够解析 URL 并获取文档的原因是,URL 提供了指令,告诉低层协议如何找到文档。

事实上,URL 使用的底层协议是著名的超文本传输协议(HTTP),它是几乎所有现代网络通信的基础。你会在本书的第九章、 10 章和 11 章中了解到更多。HTTP 提供了请求库从 Google 获取结果的机制。如果你去掉这层魔法,你认为会是什么样子——如果你想使用 HTTP 直接获取结果呢?结果是search3.py,如清单 1-3 所示。

清单 1-3 。建立到谷歌地图的原始 HTTP 连接

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search3.py

import http.client
import json
from urllib.parse import quote_plus

base = '/maps/api/geocode/json'

def geocode(address):
    path = '{}?address={}&sensor=false'.format(base, quote_plus(address))
    connection = http.client.HTTPConnection('maps.google.com')
    connection.request('GET', path)
    rawreply = connection.getresponse().read()
    reply = json.loads(rawreply.decode('utf-8'))
    print(reply['results'][0]['geometry']['location'])

if __name__ == '__main__':
    geocode('207 N. Defiance St, Archbold, OH')

在这个清单中,您直接操作 HTTP 协议:要求它连接到一台特定的机器,发出一个带有您手工构建的路径的GET请求,最后直接从 HTTP 连接读取回复。您不能方便地将查询参数作为字典中单独的键和值来提供,而是必须将它们直接手动嵌入到您所请求的路径中,方法是首先写一个问号(?),然后是由&字符分隔的name=value格式的参数。

然而,运行该程序的结果与前面显示的程序非常相似。

$ python3 search3.py
{'lat': 41.521954, 'lng': -84.306691}

正如您将在本书中看到的,HTTP 只是 Python 标准库提供内置实现的众多协议之一。在search3.py中,不必担心 HTTP 如何工作的所有细节,您的代码可以简单地请求发送一个请求,然后查看结果响应。当然,脚本必须处理的协议细节比那些search2.py更原始,因为您已经在协议栈中降低了另一个级别,但至少您仍然能够依靠标准库来处理实际的网络数据,并确保您得到正确的数据。

原始网络对话

当然,HTTP 不能简单地使用稀薄的空气在两台机器之间发送数据。相反,HTTP 协议必须通过使用一些更简单的抽象来运行。事实上,它利用现代操作系统的能力,通过使用 TCP 协议来支持 IP 网络上两个不同程序之间的纯文本网络对话。换句话说,HTTP 协议的运作方式是精确地指定在两台能说 TCP 的主机之间来回传递的消息的文本是什么样子。

当您移动到 HTTP 下面查看它下面发生的事情时,您正在下降到网络堆栈的最底层,您仍然可以从 Python 轻松地访问它。仔细看看search4.py,如清单 1-4 所示。它向谷歌地图发出与前三个程序完全相同的联网请求,但它是通过在互联网上发送一条原始文本消息并接收一组文本作为回报来做到这一点的。

清单 1-4 。通过一个裸露的套接字与谷歌地图对话

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/search4.py

import socket
from urllib.parse import quote_plus

request_text = """\
GET /maps/api/geocode/json?address={}&sensor=false HTTP/1.1\r\n\
Host: maps.google.com:80\r\n\
User-Agent: search4.py (Foundations of Python Network Programming)\r\n\
Connection: close\r\n\
\r\n\
"""

def geocode(address):
    sock = socket.socket()
    sock.connect(('maps.google.com', 80))
    request = request_text.format(quote_plus(address))
    sock.sendall(request.encode('ascii'))
    raw_reply = b''
    while True:
        more = sock.recv(4096)
        if not more:
            break
        raw_reply += more
    print(raw_reply.decode('utf-8'))

if __name__ == '__main__':
    geocode('207 N. Defiance St, Archbold, OH')

search3.pysearch4.py,你已经跨过了一个重要的门槛。在之前的每个程序清单中,您使用的是 Python 库——用 Python 本身编写——它知道如何代表您讲述复杂的网络协议。但是这里您已经到达了底部:您正在调用由主机操作系统提供的原始的socket()函数来支持 IP 网络上的基本网络通信。换句话说,您使用的机制与低级系统程序员在 C 语言中编写相同的网络操作时使用的机制相同。

在接下来的几章中,你会学到更多关于套接字的知识。现在,您可以在search4.py中注意到,原始网络通信就是发送和接收字节串。您发送的请求是一个字节的字符串,而回复是另一个大字节的字符串,在本例中,您只需将它打印到屏幕上,这样您就可以体验它所有的低级荣耀。(请参阅本章后面的“编码和解码”一节,了解为什么要在打印之前解码字符串的详细信息。)HTTP 请求的文本可以在sendall()函数中看到,它由单词GET——您想要执行的操作的名称——后跟您想要获取的文档的路径和您支持的 HTTP 版本组成。

GET /maps/api/geocode/json?address=207+N.+Defiance+St%2C+Archbold%2C+OH&sensor=false HTTP/1.1

然后是一系列的头,每个头由一个名称、一个冒号和一个值组成,最后是一个结束请求的回车/换行符对。

如果运行search4.py,回复将作为脚本的输出打印出来,如清单 1-5 中的所示。在本例中,我选择简单地将回复打印到屏幕上,而不是编写复杂的文本操作代码来解释响应。我这样做是因为我认为,简单地阅读屏幕上的 HTTP 回复会让你对它的样子有更好的了解,而不是你必须破译用来解释它的代码。

清单 1-5 。运行search4.py的输出

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
Date: Sat, 23 Nov 2013 18:34:30 GMT
Expires: Sun, 24 Nov 2013 18:34:30 GMT
Cache-Control: public, max-age=86400
Vary: Accept-Language
Access-Control-Allow-Origin: *
Server: mafe
X-XSS-Protection: 1; mode=block
X-Frame-Options: SAMEORIGIN
Alternate-Protocol: 80:quic
Connection: close

{
   "results" : [
      {
         ...
         "formatted_address""207 North Defiance Street, Archbold, OH 43502, USA",
         "geometry" : {
            "location" : {
               "lat"41.521954,
               "lng" : -84.306691
            },
            ...
         },
         "types" : ["street_address"]
      }
   ],
   "status""OK"
}

您可以看到 HTTP 回复在结构上与 HTTP 请求非常相似。它以一个状态行开始,后面是一些标题。在一个空行之后,显示响应内容本身:一个 JavaScript 数据结构,格式简单,称为 JSON,它通过描述 Google Geocoding API 搜索返回的地理位置来回答您的查询。

当然,所有这些状态行和标题正是 Python 的httplib在早期清单中处理的那种低级细节。在这里,你可以看到,如果去掉这层软件,通信会是什么样子。

海龟一路向下

我希望您喜欢这些 Python 网络编程的初始示例。退一步讲,我可以用这一系列的例子来说明关于 Python 中网络编程的几点。

首先,您现在也许可以更清楚地看到术语协议栈的含义:它意味着在更简单、更基本的对话之上构建一个高级的、语义复杂的对话(“我想要这个邮件地址的地理位置”),这些对话最终只是使用网络硬件在两台计算机之间来回发送的文本字符串。

您刚刚探索的特定协议栈有四个协议高。

  • 最上面是 Google Geocoding API,它告诉您如何将您的地理查询表达为获取包含坐标的 JSON 数据的 URL。
  • URL 命名可以使用 HTTP 检索的文档。
  • HTTP 支持面向文档的命令,比如使用原始 TCP/IP 套接字的 GET。
  • TCP/IP 套接字只知道如何发送和接收字节串。

您可以看到,堆栈的每一层都使用其下一层提供的工具,并依次为下一个更高层提供功能。

通过这些例子清楚地表明的第二点是,Python 对你刚才操作的每一个网络级别的支持是多么完整。只有当使用特定于供应商的协议,并且需要格式化请求以便谷歌能够理解时,才有必要求助于第三方库;我选择第二个清单中的requests,不是因为标准库缺少urllib.request模块,而是因为它的 API 过于笨重。您遇到的每一个其他协议级别都已经在 Python 标准库中得到了强有力的支持。无论您想要在特定的 URL 获取文档,还是在原始网络套接字上发送和接收字符串,Python 都准备好了可以用来完成工作的函数和类。

第三,注意我的程序质量下降了很多,因为我强迫自己使用越来越低级的协议。例如,search2.pysearch3.py清单开始以一种不灵活的方式对表单结构和主机名进行硬编码,这种方式以后可能很难维护。search4.py中的代码更糟糕:它包括一个手写的、未参数化的 HTTP 请求,其结构对 Python 来说完全不透明。当然,它不包含解析和解释 HTTP 响应以及理解可能发生的任何网络错误情况所需的任何实际逻辑。

这说明了一个你应该在本书后面的每一章都记住的教训:正确实现网络协议是困难的,你应该尽可能使用标准库或第三方库。尤其是当你在编写网络客户端的时候,你总会被诱惑去过度简化你的代码;您往往会忽略许多可能出现的错误情况,只准备最有可能的响应,避免正确地转义参数,因为您天真地认为您的查询字符串将只包含简单的字母字符,并且通常会编写非常脆弱的代码,这些代码尽可能地不了解它正在与之对话的服务。相反,通过使用已经开发了一个协议的完整实现的第三方库,它必须支持许多不同的 Python 开发人员,这些开发人员使用该库来完成各种任务,您将从库实现者已经发现并学会如何正确处理的所有边缘情况和棘手问题中受益。

第四,需要强调的是,更高级别的网络协议——比如用于解析街道地址的谷歌地理编码 API 通常通过隐藏它们下面的网络层来工作。如果您只使用过pygeocoder库,您可能甚至不知道 URL 和 HTTP 是用于构造和回答查询的底层机制!

一个有趣的问题是,这个库是否正确地隐藏了那些较低级别的错误,这个问题的答案因 Python 库编写的细致程度而异。一个网络错误会使你的位置暂时无法访问谷歌,在试图查找街道地址坐标的代码中引发一个原始的低级网络异常吗?还是会把所有的错误都改成针对地理编码的更高级别的异常?在阅读本书的过程中,请特别注意捕捉网络错误的主题,尤其是第一部分中强调底层网络的章节。

最后,我们已经到达了本书第一部分的主题:在search4.py中使用的socket()接口是而不是,事实上,当你向 Google 发出这个请求时,它是最低的协议级别!正如这个例子中的网络协议运行在原始套接字之上一样,在套接字抽象之下也有协议,Python 看不到这些协议,因为它们是由操作系统管理的。

socket() API 下运行的层如下:

  • 传输控制协议(TCP) 通过发送(或者重新发送)、接收和重新排序称为数据包的小型网络消息,支持由字节流组成的双向对话。
  • 互联网协议(IP)知道如何在不同的计算机之间发送数据包。
  • 最底层的“链路层”由网络硬件设备组成,如以太网端口和无线网卡,它们可以在直接链接的计算机之间发送物理消息。

在本章的其余部分以及接下来的两章中,您将探索这些最低的协议级别。在本章中,您将首先研究 IP 层,然后在接下来的章节中了解两种完全不同的协议(UDP 和 TCP)如何支持两种基本类型的会话,这两种会话可能在一对连接到 Internet 的主机上的应用之间进行。

但是首先,说几句关于字节和字符的话。

编码和解码

Python 3 语言对字符串和低级字节序列进行了严格的区分。字节是计算机在网络通信中来回传输的实际二进制数,每个二进制数由八个二进制数字组成,范围从二进制值 0000000 到 1111111,因此从十进制整数 0 到 255。Python 中由字符组成的字符串可以包含 Unicode 符号,如a(unicode 标准称之为“拉丁小写字母 a”)或}(右花括号)或∅(空集)。虽然每个 Unicode 字符确实都有一个与之相关联的数字标识符,称为其码位,但您可以将此视为内部实现细节——Python 3 小心翼翼地使字符始终表现得像字符一样,只有当您要求时,Python 才会将字符与实际的外部可见字节进行相互转换。

这两种操作都有正式名称。

解码是指当字节通过进入你的应用时,你需要弄清楚它们的意思。当您的应用从文件或通过网络接收字节时,可以把它想象成一个典型的冷战间谍,其任务是解密通过通信信道传输的原始字节。

编码是这样一个过程:当数字计算机需要使用作为其唯一真实货币的字节来传输或存储符号时,使用许多编码中的一种,将您准备呈现给外界的字符串转换为字节。把你的间谍想象成必须把他们的信息转换成数字来传输,把符号转换成可以通过网络发送的代码。

这两个操作在 Python 3 中非常简单和明显地公开为一个decode()方法,可以在读入字节串后应用于它们,以及一个encode()方法,可以在准备写回字符串时调用它们。这些技术在清单 1-6 中有说明。

清单 1-6 。解码输入字节和编码输出字符

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/stringcodes.py

if __name__ == '__main__':
    # Translating from the outside world of bytes to Unicode characters.
    input_bytes = b'\xff\xfe4\x001\x003\x00 \x00i\x00s\x00 \x00i\x00n\x00.\x00'
    input_characters = input_bytes.decode('utf-16')
    print(repr(input_characters))

    # Translating characters back into bytes before sending them.
    output_characters = 'We copy you down, Eagle.\n'
    output_bytes = output_characters.encode('utf-8')
    with open('eagle.txt', 'wb') as f:
        f.write(output_bytes)

本书中的例子试图仔细区分字节和字符。请注意,当您显示它们的repr()时,这两者有不同的外观:字节字符串以字母 b 开头,看起来像b'Hello',而真正完整的字符串没有初始字符,只是看起来像'world'。为了避免字节字符串和字符串之间的混淆,Python 3 只在字符串类型上提供了大多数字符串方法。

互联网协议

两种联网,当你用一条物理链路连接几台计算机以便它们能够通信时发生,以及互联,它链接相邻的物理网络形成一个像互联网一样的更大的系统,本质上只是允许资源共享的精心设计的方案。

当然,计算机中的各种东西都需要共享:磁盘驱动器、内存和 CPU 都由操作系统小心翼翼地保护着,这样运行在计算机上的各个程序就可以访问这些资源,而不会互相影响。网络是操作系统需要保护的另一种资源,以便程序可以相互通信,而不会干扰同一网络上发生的其他会话。

您的计算机用于通信的物理网络设备(如以太网卡、无线发射器和 USB 端口)本身都设计有一个精心设计的功能,可以在许多想要通信的不同设备之间共享一个物理介质。一打以太网卡可能被插入同一个集线器;30 个无线卡可能共享同一个无线信道;DSL 调制解调器使用频域多路复用,这是电气工程中的一个基本概念,可以防止自己的数字信号干扰电话通话时线路上传输的模拟信号。

网络设备之间共享的基本单位——货币,如果你愿意的话,他们交易的货币——是数据包。数据包是一个字节串,其长度可能从几个字节到几千个字节不等,它作为一个单元在网络设备之间传输。虽然确实存在专用网络,特别是在电信等领域,传输线路上的每个字节都可能被分别路由到不同的目的地,但用于为现代计算机构建数字网络的更通用的技术都是基于更大的数据包单元。

一个包在物理层通常只有两个属性:它携带的字节串数据和它要被传送到的地址。物理数据包的地址通常是一个唯一的标识符,用来命名与传输数据包的计算机连接到同一以太网网段或无线信道的其它网卡。网卡的工作就是发送和接收这样的数据包,而不需要计算机的操作系统关心网络如何使用电线、电压和信号来运行的细节。

那么,什么是互联网协议呢?

互联网协议是一种在全世界所有连接互联网的计算机上实施统一地址系统的方案,使数据包能够从互联网的一端传输到另一端。理想情况下,像 web 浏览器这样的应用应该能够连接到任何地方的主机,而不必知道每个数据包在它的旅程中穿过哪个网络设备迷宫。

Python 程序很少能在如此低的层次上运行,以至于看到互联网协议本身在起作用,但至少了解它是如何工作的是有帮助的。

IP 地址

互联网协议的最初版本为连接到全球网络的每台计算机分配一个 4 字节的地址。这种地址通常由四个十进制数组成,用句点分隔,每个句点代表地址的一个字节。因此,每个数字的范围可以从 0 到 255。因此,传统的四字节 IP 地址如下所示:

130.207.244.244

因为纯数字地址对人类来说很难记住,使用互联网的人通常会看到主机名而不是 IP 地址。用户可以简单地键入google.com,而忘记在幕后这将解析为一个类似74.125.67.103的地址,他们的计算机实际上可以将数据包发送到这个地址,以便在互联网上传输。

getname.py脚本中,如清单 1-7 所示,您可以看到一个简单的 Python 程序,它要求操作系统——Linux、Mac OS、Windows 或该程序运行的任何系统——解析主机名www.python.org。被称为域名系统的特定网络服务非常复杂,我将在第四章中更详细地讨论它。

清单 1-7 。将主机名转换为 IP 地址

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter01/getname.py

import socket

if __name__ == '__main__':
    hostname = 'www.python.org'
    addr = socket.gethostbyname(hostname)
    print('The IP address of {} is {}'.format(hostname, addr))

现在,你只需要记住两件事。

  • 首先,不管一个互联网应用看起来多么奇特,实际的互联网协议总是使用数字 IP 地址将数据包导向目的地。
  • 其次,如何将主机名解析为 IP 地址的复杂细节通常由操作系统来处理。

像互联网协议的大多数操作细节一样,您的操作系统喜欢自己处理它们,对您和您的 Python 代码隐藏这些细节。

实际上,现在的寻址情况可能比刚才描述的简单的 4 字节方案要复杂一些。因为世界上的 4 字节 IP 地址即将耗尽,一种被称为 IPv6 的扩展地址方案正在部署,它允许绝对庞大的 16 字节地址在未来很长一段时间内满足人类的需求。它们的写法不同于 4 字节的 IP 地址,看起来像这样:

fe80::fcfd:4aff:fecf:ea4e

但是,只要您的代码接受来自用户的 IP 地址或主机名,并将它们直接传递给网络库进行处理,您就可能永远不需要担心 IPv4 和 IPv6 之间的区别。运行 Python 代码的操作系统将知道它使用的 IP 版本,并相应地解释地址。

通常,传统的 IP 地址可以从左到右读取:前一个或两个字节指定一个组织,然后下一个字节通常指定目标机器所在的特定子网。最后一个字节将地址缩小到特定的机器或服务。还有一些特殊的 IP 地址范围具有特殊的含义。

  • 127。.*.** :以字节127开头的 IP 地址位于一个特殊的保留范围内,该范围是运行应用的机器的本地地址。当您的 web 浏览器、FTP 客户端或 Python 程序连接到此范围内的某个地址时,它会要求与同一台机器上运行的其他服务或程序进行对话。大多数机器在整个范围内只使用一个地址:IP 地址127.0.0.1通常被用来表示“运行这个程序的机器本身”,通常可以通过主机名localhost来访问。
  • 10。.., 172.16–31.., 192.168..* :这些 IP 范围是为所谓的私有子网保留的。管理互联网的当局已经做出了绝对的承诺:他们永远不会把这三个范围中的任何一个 IP 地址提供给真正建立服务器或服务的公司。因此,在互联网上,这些地址肯定是没有意义的;它们没有指定您想要连接的主机。因此,您可以在您组织的任何内部网络上免费使用这些地址,您可以在内部自由分配 IP 地址,而无需选择从 Internet 上的其他位置访问这些主机。

您甚至可能在自己的家中看到这些私有地址中的一些:您的无线路由器或 DSL 调制解调器通常会将这些私有范围中的一个 IP 地址分配给您的家庭计算机和笔记本电脑,并将您的所有互联网流量隐藏在您的互联网服务提供商分配给您使用的单个“真实”IP 地址之后。

路由

一旦应用要求操作系统向特定的 IP 地址发送数据,操作系统就必须决定如何使用机器所连接的物理网络之一来传输数据。这一决定(即,根据 IP 地址选择将每个互联网协议数据包发送到哪里)被称为路由

您在职业生涯中编写的大部分(也许是全部)Python 代码将运行在互联网边缘的主机上,通过一个网络接口将它们与世界其他地方连接起来。对于这样的机器,路由成为一个非常简单的决定。

  • 如果 IP 地址看起来像127.*.*.*,那么操作系统就知道这个包的目的地是运行在同一台机器上的另一个应用。它甚至不会提交给物理网络设备进行传输,而是由操作系统通过内部数据副本直接交给另一个应用。
  • 如果 IP 地址与机器本身在同一个子网中,那么只需检查本地以太网网段、无线信道或本地网络,然后将数据包发送到本地连接的机器,就可以找到目的主机。
  • 否则,你的机器会将数据包转发给一台网关机器,它将你的本地子网连接到互联网的其余部分。之后,将由网关机器决定将数据包发送到哪里。

当然,在因特网的边缘,路由只是这么简单,在那里,唯一的决定是把信息包留在本地网上,还是把它送到因特网的其他地方。您可以想象,对于构成互联网主干的专用网络设备来说,路由决策要复杂得多!在那里,在连接整个大陆的交换机上,必须构建、查阅并不断更新复杂的路由表,以便知道去往谷歌的数据包是一个方向,去往亚马逊 IP 地址的数据包是另一个方向,而去往你的机器的数据包又是另一个方向。但是 Python 应用很少能在互联网主干路由器上运行,所以刚刚概述的更简单的路由情况几乎总是您将看到的实际情况。

在前面的段落中,我对你的计算机如何决定一个 IP 地址是属于一个本地子网,还是应该通过一个网关转发到互联网的其余部分有点含糊。为了说明子网的概念,其所有主机共享相同的 IP 地址前缀,我一直在编写前缀,后跟星号来表示地址中可能变化的部分。当然,运行操作系统网络堆栈的二进制逻辑实际上不会将小的 ASCII 星号插入其路由表!相反,子网是通过将 IP 地址与一个掩码组合来指定的,该掩码指示主机必须匹配多少个最高有效位才能属于该子网。如果您记住 IP 地址中的每个字节代表八位二进制数据,那么您将能够很容易地读取子网号。它们看起来像这样:

  • 127.0.0.0/8 :这个模式描述了前面讨论过的 IP 地址范围,并且是为本地主机保留的,它指定前 8 位(1 个字节)必须与数字 127 匹配,而剩余的 24 位(3 个字节)可以是它们想要的任何值。
  • 192.168.0.0/16 :此模式将匹配属于私有 192.168 范围内的任何 IP 地址,因为前 16 位必须完全匹配。32 位地址的最后 16 位可以是他们想要的任何值。
  • 192.168.5.0/24 :这里有一个特定子网的规范。这可能是整个互联网上最常见的子网掩码。地址的前三个字节是完全指定的,它们必须匹配才能使 IP 地址落入此范围。在此范围内,只有最后一个字节(最后八位)允许在机器之间有所不同。这样就剩下 256 个唯一的地址。通常,.0地址被用作子网的名称,.255地址被用作“广播包”的目的地,该“广播包”寻址子网中的所有主机(您将在下一章中看到),这样就有 254 个地址可以分配给计算机。地址.1通常用于连接子网和互联网的网关,但是一些公司和学校选择使用另一个号码作为网关。

几乎在所有情况下,您的 Python 代码将简单地依赖其主机操作系统来正确地做出数据包路由选择,就像它首先依赖操作系统来将主机名解析为 IP 地址一样。

数据包碎片

最后一个值得一提的互联网协议概念是数据包分段。虽然它被认为是一个模糊的细节,通过你的操作系统的网络堆栈的聪明成功地隐藏在你的程序之外,但它在互联网的历史上已经引起了足够多的问题,至少值得在这里简单地提一下。

分段是必要的,因为 Internet 协议支持非常大的数据包(最大长度可达 64KB ),但构建 IP 网络的实际网络设备通常支持小得多的数据包。例如,以太网只支持 1500 字节的数据包。因此,互联网数据包包括一个“不分段”(DF)标志,如果数据包太大,无法通过位于源计算机和目的地之间的物理网络,发送方可以使用该标志选择他们希望发生的情况:

  • 如果 DF 标志未置位,则允许分段,当数据包到达其无法容纳的网络阈值时,网关可以将其拆分成更小的数据包,并标记它们以便在另一端重新组装。
  • 如果设置了 DF 标志,则禁止分段,如果该数据包不适合,则它将被丢弃,并且错误消息将被发送回发送该数据包的机器(在一个称为互联网控制消息协议(ICMP) 数据包的特殊信令数据包中),以便该机器可以尝试将消息分成更小的片段并重新发送。

您的 Python 程序通常无法控制 DF 标志;而是由操作系统设置。粗略地说,系统通常使用的逻辑是这样的:如果你正在进行一个 UDP 会话(参见第二章),该会话由单独的数据报组成,这些数据报在互联网上传播,那么操作系统将保留 DF 不变,这样每个数据报以所需的任意数量到达目的地;但是如果你正在进行一个 TCP 对话(参见第三章),其长数据流可能有数百或数千个数据包长,那么操作系统将设置 DF 标志,这样它就可以准确地选择正确的数据包大小,让对话顺利进行,而不会使数据包在途中不断被分割*,这将使对话的效率稍有下降。*

互联网子网可以接受的最大数据包被称为其最大传输单元(MTU) ,MTU 处理曾经有一个大问题,给很多互联网用户带来了问题。20 世纪 90 年代,互联网服务提供商(最著名的是提供 DSL 链接的电话公司)开始使用 PPPoE,这是一种将 IP 数据包放在一个胶囊中的协议,该胶囊仅留给它们 1492 字节的空间,而不是通常允许通过以太网的 1500 字节。许多互联网站点对此毫无准备,因为它们默认使用 1500 字节的数据包,并作为一种错误的安全措施阻止了所有 ICMP 数据包。结果,他们的服务器永远不会收到 ICMP 错误,告诉他们,他们的 1500 字节的“不分段”大数据包正在到达客户的 DSL 链路,无法通过它们。

这种情况令人抓狂的症状是,小文件或网页可以毫无问题地查看,而 Telnet 和 SSH 之类的交互协议可以工作,因为这两种活动都倾向于发送长度小于 1,492 字节的小数据包。但是,一旦客户试图下载一个大文件,或者一旦 Telnet 或 SSH 命令一次输出几个屏幕,连接就会冻结,变得没有响应。

今天,这个问题很少遇到,但它说明了一个低级的 IP 功能是如何产生用户可见的症状的,因此,在编写和调试网络程序时记住 IP 的所有功能是有好处的。

了解更多关于知识产权的信息

在接下来的章节中,您将逐步了解 IP 之上的协议层,并了解您的 Python 程序如何通过使用建立在 Internet 协议之上的不同服务来进行不同类型的网络对话。但是,如果你对前面关于 IP 如何工作的概述感兴趣,并想了解更多,该怎么办呢?

描述 Internet 协议的官方资源是 IETF 发布的注释请求(RFC ),它准确描述了协议的工作原理。它们写得很仔细,再加上一杯浓咖啡和几个小时的自由阅读时间,会让你了解互联网协议如何运作的每一个细节。例如,这里是定义互联网协议本身的 RFC:

http://tools.ietf.org/html/rfc791

您还可以在维基百科等一般资源上找到参考 RFC,RFC 通常会引用其他 RFC 来描述协议或寻址方案的更多细节。

如果你想了解互联网协议和运行在其上的其他协议的一切,你可能会有兴趣获得 Kevin R. Fall 和 w . Richard Stevens(Addison-Wesley Professional,2011)编写的经典文本 TCP/IP Illustrated,Volume 1:The Protocols(2nd Edition)。它非常详细地涵盖了所有的协议操作,这本书只有篇幅来描述这些操作。还有其他一些关于网络的好书,如果你在工作中或者仅仅是在家里设置 IP 网络和路由来让你的电脑上网,这些书可能会对网络配置有所帮助。

摘要

除了最基本的服务之外,所有的网络服务都是在其他一些更基本的网络功能之上实现的。

你已经在本章的开始部分探索了这样一个“堆栈”。TCP/IP 协议(将在第三章中介绍)支持在客户端和服务器之间传输字节串。HTTP 协议(见第九章)描述了这样一个连接如何被客户端用来请求一个特定的文档,以及服务器如何通过提供它来响应。万维网(第十一章)将检索 HTTP 托管文档的指令编码到一个称为 URL 的特殊地址中,当服务器返回的文档需要向客户机呈现结构化数据时,标准的 JSON 数据格式很受欢迎。在这整个大厦之上,谷歌提供地理编码服务,让程序员建立一个 URL,谷歌回复一个描述地理位置的 JSON 文档。

每当文本信息要在网络上传输时——或者就此而言,要保存到面向字节的持久存储器(如磁盘)中——就需要将字符编码成字节。有几种广泛使用的将字符表示为字节的方案。现代互联网上最常见的是简单而有限的 ASCII 编码和强大而通用的 Unicode 系统,尤其是其被称为 UTF-8 的特殊编码。Python 字节串可以使用它们的decode()方法转换成真实的字符,普通的字符串可以通过它们的encode()方法改回来。Python 3 从不尝试自动将字节转换成字符串——这项操作只需要猜测您想要的编码——因此 Python 3 代码通常会比 Python 2 下的实践更多地调用decode()encode()

为了让 IP 网络代表应用传输数据包,网络管理员、设备供应商和操作系统程序员必须共同合作,为各个机器分配 IP 地址,在机器和路由器级别建立路由表,并配置域名系统(第四章)以将 IP 地址与用户可见的名称相关联。Python 程序员应该知道,每个 IP 包都以自己的方式穿过网络到达目的地,如果包太大而无法通过路径上路由器之间的一个“跳”,那么它可能会被分割。

在大多数应用中,使用 IP 有两种基本方式。它们要么将每个数据包作为独立的消息使用,要么请求自动拆分成数据包的数据流。这些协议被命名为 UDP 和 TCP,它们是本书第二章和第三章的主题。

二、UDP

前一章描述了支持短消息传输的现代网络硬件,这些短消息被称为数据包,通常不超过几千字节。如何将这些微小的单个消息组合起来,形成 web 浏览器和服务器之间或电子邮件客户端和 ISP 邮件服务器之间的对话?

IP 协议只负责尝试将每个数据包传送到正确的机器。如果单独的应用要维护会话,通常需要两个额外的功能,而提供这些功能是建立在 IP 之上的协议的工作。

  • 在两台主机之间传输的许多数据包需要被标记,以便可以将 web 数据包与电子邮件数据包区分开,并且可以将两者与机器参与的任何其他网络会话区分开。这叫做复用??。
  • 从一台主机单独传输到另一台主机的数据包流可能发生的所有损坏都需要修复。丢失的数据包需要重新传输,直到它们到达。无序到达的数据包需要重组为正确的顺序。最后,需要丢弃重复的数据包,以便数据流中没有重复的信息。这就是所谓的提供可靠的运输

这本书专门为 IP 上使用的两个主要协议各写了一章。

第一个是用户数据报协议 (UDP),在本章中有记录。它只解决了前面概述的两个问题中的第一个。它提供端口号,如下一节所述,以便在一台机器上发往不同服务的数据包可以被正确地解复用。然而,使用 UDP 的网络程序仍然必须在数据包丢失、复制和排序方面保护自己。

第二个是传输控制协议 (TCP),解决了这两个问题。它既使用与 UDP 相同的规则合并了端口号,又提供了有序可靠的数据流,对应用隐藏了这样一个事实,即连续的数据流实际上已经被分割成数据包,然后在另一端重新组装。你将在第三章中学习使用 TCP。

请注意,一些罕见的专用应用,如局域网上所有主机共享的多媒体,既不选择任何协议,而是选择创建一个全新的基于 IP 的协议,与 TCP 和 UDP 并列,作为在 IP 网络上进行对话的新方式。这不仅不寻常,而且作为一个底层操作,不太可能用 Python 编写,所以在本书中你不会探究协议工程。本书中在 IP 上构建原始数据包的最接近的方法是在第一章末尾的“构建和检查数据包”部分,它构建原始 ICMP 数据包并接收 ICMP 回复。

我应该首先承认,您不太可能在自己的任何应用中使用 UDP。如果您认为 UDP 非常适合您的应用,那么您可能希望研究一下消息队列(参见第八章)。尽管如此,在你准备好在第三章中学习 TCP 之前,UDP 给你的原始包多路复用的体验是重要的一步。

端口号

在计算机网络和电磁信号理论中,区分共享同一信道的许多信号是一个普遍的问题。允许几个对话共享一种媒介或机制的解决方案被称为多路复用?? 方案。众所周知,人们发现无线电信号可以通过使用不同的频率相互分离。在数据包的数字领域,UDP 的设计者选择使用一种粗略的技术来区分不同的对话,这种技术用一对无符号的 16 位端口号来标记每个 UDP 数据包,端口号范围为 0 到 65,536。源端口 标识从源机器发送数据包的特定进程或程序,而目的地端口 指定通信应该传送到的目的地 IP 地址的应用。

在 IP 网络层,所有可见的都是流向特定主机的数据包。

Source IP ® Destination IP

但是两台通信机器的网络堆栈——毕竟必须控制和争论这么多可能正在对话的独立应用——认为对话更具体地是每台机器上的 IP 地址和端口号对之间的对话。

Source (IP : port number) ® Destination (IP : port number)

属于特定会话的传入数据包将始终具有相同的四个坐标值,而以另一种方式发送的回复只是在它们的源和目的地字段中交换了两个 IP 号码和两个端口号。

为了使这个想法具体化,假设您在一台 IP 地址为 192.168.1.9 的机器上设置了一个 DNS 服务器(第四章)。为了允许其他计算机找到该服务,服务器将向操作系统请求许可,以接收到达具有标准 DNS 端口号(端口 53)的 UDP 端口的数据包。假设还没有运行一个进程来声明这个端口号,DNS 服务器将被授予这个端口。

接下来,假设一台 IP 地址为 192.168.1.30 的客户机想要向服务器发出一个查询。它将在内存中创建一个请求,然后要求操作系统将该数据块作为 UDP 数据包发送。因为当数据包返回时需要某种方法来识别客户端,并且客户端没有明确请求端口号,所以操作系统会为其分配一个随机的端口号,比如端口 44137。

因此,该数据包将向端口 53 飞去,其地址如下所示:

Source (192.168.1.30:44137) ® Destination (192.168.1.9:53)

一旦它制定了一个响应,DNS 服务器将要求操作系统发送一个 UDP 数据包作为响应,将这两个地址反过来,以便将回复直接返回给发送方。

Source (192.168.1.9:53) ® Destination (192.168.1.30:44137)

因此,UDP 方案实际上非常简单;只需要一个 IP 地址和端口就可以将数据包发送到目的地。

但是客户端程序如何知道它应该连接的端口号呢?有三种通用方法。

  • 惯例 :互联网号码分配机构(IANA) 指定了许多端口号作为特定服务的官方、知名端口。这就是为什么在前面的例子中,DNS 应该在 UDP 端口 53。
  • 自动配置 :通常,当计算机首次连接到网络时,会使用 DHCP 之类的协议获知 DNS 之类的关键服务的 IP 地址。通过将这些 IP 地址与众所周知的端口号相结合,程序可以访问这些基本服务。
  • 手动配置 :对于前两种情况未涵盖的所有情况,管理员或用户的手动干预将必须提供服务的 IP 地址或相应的主机名。这种意义上的手动配置正在发生,例如,每当您在 web 浏览器中键入 web 服务器名称时。

当决定定义端口号时,例如 DNS 的 53,IANA 认为它们分为三个范围——这适用于 UDP 和 TCP 端口号。

  • 著名港口(0–1023)是最重要和最广泛使用的服务。在许多类似 Unix 的操作系统上,普通用户程序不能监听这些端口。在过去,这可以防止麻烦的大学生在多用户的大学机器上运行伪装成重要系统服务的程序。如今,当托管公司分发命令行 Linux 账户时,同样的谨慎也适用。
  • 注册的端口(1024–49151)通常不会被操作系统视为特殊端口——例如,任何用户都可以编写一个程序来占用端口 5432 并伪装成 PostgreSQL 数据库——但是它们可以被 IANA 注册用于特定的服务,IANA 建议您避免将它们用于除了指定服务之外的任何事情。
  • 剩余的端口号(49152–65535)可以自由使用。正如您将看到的,它们是现代操作系统用来生成任意端口号的池,当客户端不关心它的输出连接分配了什么端口时。

当您编写接受来自用户输入(如命令行或配置文件)的端口号的程序时,不仅允许数字端口号,而且允许众所周知的端口的可读名称,这是很友好的。这些名字是标准的,它们可以通过 Python 的标准socket模块中的getservbyname()函数获得。如果你想询问域名服务的端口,你可以这样找到:

>>> import socket
>>> socket.getservbyname('domain')
53

正如你将在第四章中看到的,端口名也可以由更复杂的getaddrinfo()函数解码,该函数也由socket模块提供。

众所周知的服务名和端口号的数据库通常保存在 Linux 和 Mac OS X 机器上的文件/etc/services中,您可以在闲暇时仔细阅读。特别是文件的前几页,散落着古老的协议,尽管多年来世界上任何地方都没有真正的数据包发给它们,但这些协议仍然保留着号码。在www.iana.org/assignments/port-numbers,IANA 还在线维护了一份最新的(通常更广泛的)副本。

套接字

Python 没有试图发明自己的网络编程 API,而是做出了一个有趣的决定。本质上,Python 的标准库只是提供了一个基于对象的接口 给所有普通的、粗糙的、低级操作系统调用,这些调用通常用于在 POSIX 兼容的操作系统上完成网络任务。这些调用甚至与它们包装的底层操作同名。Python 愿意公开传统的系统调用,而在它出现之前,每个人都已经理解了这些调用,这也是 Python 在 20 世纪 90 年代早期给我们这些努力学习低级语言的人带来一股新鲜空气的原因之一。最终,一种更高级的语言出现了,它允许我们在需要时进行低级操作系统调用,而不是坚持使用笨拙、功能不足但表面上“更漂亮”的特定于语言的 API。记住一组在 C 和 Python 中都有效的调用要容易得多。

在 Windows 和 POSIX 系统(如 Linux 和 Mac OS X)上,底层系统要求联网,其中心思想是一个被称为套接字的通信端点。操作系统使用整数来标识套接字,但是 Python 会向您的 Python 代码返回一个更方便的socket.socket对象。它在内部记住这个整数(你可以调用它的fileno()方法来查看它),并在每次你调用它的一个方法来请求在套接字上运行一个系统调用时自动使用它。

Image 注意在 POSIX 系统上,标识套接字的fileno()整数也是从表示打开文件的整数池中提取的文件描述符。假设在 POSIX 环境中,您可能会遇到这样的代码,它获取这个整数,然后使用它对文件描述符执行非网络调用,如os.read()os.write(),对实际上是网络通信端点的对象执行类似文件的操作。但是,因为本书中的代码也是为在 Windows 上工作而设计的,所以您将只对您的套接字执行真正的套接字操作。

插座在运行中是什么样子的?看看清单 2-1 中的,它显示了一个简单的 UDP 服务器和客户端。您可以看到,它只对函数socket.socket()进行了一次 Python 标准库调用,所有其他调用都是对它返回的 socket 对象的方法进行的。

清单 2-1 。UDP 服务器和客户端在环回接口上

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/udp_local.py
# UDP client and server on localhost

import argparse, socket
from datetime import datetime

MAX_BYTES = 65535

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

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

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive UDP locally')
    parser.add_argument('role', choices=choices, help='which role to play')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.p)

即使您当前不在网络范围内,您也应该能够在自己的计算机上运行该脚本,因为服务器和客户端都只使用本地主机 IP 地址,无论您是否连接到真实的网络,该地址都应该可用。请先尝试启动服务器。

$ python udp_local.py server
Listening at ('127.0.0.1', 1060)

打印完这行输出后,服务器等待传入的消息。

在源代码中,您可以看到服务器启动和运行需要三个步骤。

它首先用socket() 创建了一个普通的套接字调用。这个新的套接字尚未绑定到 IP 地址或端口号,尚未连接到任何东西,如果您试图使用它进行通信,将会引发异常。然而,这个套接字至少被标记为一种特殊的类型:它的族是AF_INET,互联网协议族,它是SOCK_DGRAM数据报类型,这意味着它将在 IP 网络上使用 UDP。请注意,术语数据报(而不是数据包)是应用级传输数据块的官方术语,因为操作系统网络堆栈不保证网络上的单个数据包实际上代表单个数据报。(参见下一节,我坚持数据报和包之间的一一对应,以便您可以测量最大传输单位[MTU]。)

接下来,这个简单的服务器使用bind()命令 请求一个 UDP 网络地址,您可以看到这是一个简单的 Python 元组,由一个str IP 地址(稍后您将看到,主机名也是可以接受的)和一个int UDP 端口号组成。如果另一个程序已经在使用该 UDP 端口,而服务器脚本无法获得该端口,则此步骤可能会失败并出现异常。尝试运行该服务器的另一个副本,您将看到它如下所示:

$ python udp_local.py server
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

当然,在第一次运行服务器时,有很小的可能会收到这个异常,因为 UDP 端口 1060 已经在您的机器上使用了。碰巧的是,在为第一个例子选择端口号时,我发现自己有点困惑。当然,它必须大于 1023,否则如果不是系统管理员,您就无法运行该脚本——尽管我确实喜欢我的小示例脚本,但我真的不想鼓励任何人以系统管理员的身份运行它们!我可以让操作系统选择端口号(正如我对客户机所做的那样,稍后您将会看到),让服务器将它打印出来,然后让您将它作为命令行参数之一键入客户机。然而,那样我就不能亲自向您展示请求特定端口号的语法了。最后,我考虑使用前面描述的编号较大的“短暂”端口,但是这些端口可能已经被您机器上的其他应用使用,比如您的 web 浏览器或 SSH 客户端 。

因此,我唯一的选择似乎是从 1023 以上的预留但不知名的范围中选择一个端口。我浏览了一下列表,打赌你,亲爱的读者,不会在运行我的 Python 脚本的笔记本电脑、台式机或服务器上运行 SAP BusinessObjects Polestar。如果是,那么尝试给服务器一个–p选项来选择不同的端口号。

注意,Python 程序总是可以使用套接字的getsockname()方法 来检索包含套接字绑定的当前 IP 地址和端口的元组。

一旦套接字绑定成功,服务器就可以开始接收请求了!它进入一个循环并重复运行recvfrom() ,告诉例程它将愉快地接收最大长度为 65,535 字节的消息——这个值恰好是 UDP 数据报可能具有的最大长度,因此您将始终看到每个数据报的完整内容。直到你与客户发送消息,你的recvfrom()呼叫将永远等待。

一旦数据报到达,recvfrom()将返回给你发送数据报的客户端的地址以及数据报的内容(以字节为单位)。使用 Python 将字节直接转换为字符串的能力,您可以将消息打印到控制台,然后将回复数据报返回给客户机。

因此,让我们启动我们的客户端并检查结果。客户端代码也显示在清单 2-1 中。

(顺便说一句,我希望这个例子不要混淆——像书中的其他例子一样——将服务器和客户机代码组合成一个清单,通过命令行参数选择。我通常更喜欢这种风格,因为它使服务器和客户端逻辑在页面上彼此靠近,并且更容易看出哪些服务器代码片段与哪些客户端代码片段相匹配。)

当服务器仍在运行时,在系统上打开另一个命令窗口,并尝试连续运行客户机两次,如下所示:

$ python udp_local.py client
The OS assigned me the address ('0.0.0.0', 46056)
The server ('127.0.0.1', 1060) replied 'Your data was 46 bytes long'
$ python udp_local.py client
The OS assigned me the address ('0.0.0.0', 39288)
The server ('127.0.0.1', 1060) replied 'Your data was 46 bytes long'

在服务器的命令窗口中,您应该看到它报告它所服务的每个连接。

The client at ('127.0.0.1', 46056) says 'The time is 2014-06-05 10:34:53.448338'
The client at ('127.0.0.1', 39288) says 'The time is 2014-06-05 10:34:54.065836'

尽管客户机代码比服务器代码稍微简单一些——只有三行网络代码——但它引入了两个新概念。

客户端对sendto() 的调用提供了消息和目的地址。这个简单的调用是向服务器发送数据报所必需的!但是,当然,如果要进行通信,您需要客户端的 IP 地址和端口号。因此,操作系统会自动分配一个,从调用getsockname()的输出中可以看到。正如承诺的那样,每个客户端端口号都来自 IANA 的“短暂”端口号范围。(至少它们在这里,在我的笔记本电脑上,在 Linux 下;在不同的操作系统下,你可能会得到不同的结果。)

当您使用完服务器后,您可以在运行它的终端中按 Ctrl+C 来终止它。

滥交的客户和不受欢迎的回复

清单 2-1 中的客户端程序实际上是危险的!如果您查看它的源代码,您会发现虽然recvfrom()返回了传入数据报的地址,但是代码从不检查它接收到的数据报的源地址,以验证它实际上是来自服务器的回复。

您可以通过延迟服务器的回复来发现这个问题,并查看其他人是否可以发送这个天真的客户端可以信任的响应。在像 Windows 这样功能较弱的操作系统上,您可能需要在服务器的接收和发送之间添加一个很长的time.sleep()调用,以模拟一个需要很长时间来响应的服务器。然而,在 Mac OS X 和 Linux 上,一旦服务器建立了它的套接字来模拟一个需要很长时间来响应的服务器,您可以更简单地用 Ctrl+Z 暂停服务器。

因此,启动一个新的服务器,然后使用 Ctrl+Z 暂停它。

$ python udp_local.py server
Listening at ('127.0.0.1', 1060)
^Z
[1]  + 9370 suspended  python udp_local.py server
$

如果您现在运行客户端,它将发送其数据报,然后挂起,等待接收回复。

$ python udp_local.py client
The OS assigned me the address ('0.0.0.0', 39692)

假设您现在是一个攻击者,想要通过在服务器有机会发送自己的回复之前跳入并发送您的数据报来伪造来自服务器的响应。由于客户端已经告诉操作系统它愿意接收任何数据报,并且没有对结果进行健全性检查,所以它应该相信您的假回复实际上来自服务器。您可以在 Python 提示符下使用快速会话发送这样的包。

$ python3
Python 3.4.0 (default, Apr 11 2014, 13:05:18)
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> sock.sendto('FAKE'.encode('ascii'), ('127.0.0.1', 39692))
4

客户端将立即退出,并愉快地将这个第三方回复解释为它正在等待的响应。

The server ('127.0.0.1', 37821) replied 'FAKE'

您现在可以通过键入fg取消服务器的冻结,让它继续运行(它现在会看到已经排队等待的客户机数据包,并将它的回复发送到现在关闭的客户机套接字)。照常按 Ctrl+C 杀死它。

请注意,客户端容易受到任何能够将 UDP 数据包发送到它的人的攻击。这不是一个中间人攻击者控制网络并能从虚假地址伪造数据包的例子,这种情况只能通过使用加密来防止(见第六章)。相反,无特权的发送者完全在规则内操作并发送具有合法返回地址的分组,然而其数据被接受。

一个侦听网络客户端将接受或记录它看到的每一个数据包,而不考虑该数据包是否被正确寻址,这在技术上被称为混杂客户端。有时我们故意写这些,例如当我们进行网络监控并希望看到到达接口的所有数据包时。然而,在这种情况下,滥交是一个问题。

只有好的、写得好的加密才能让你的代码确信它已经与正确的服务器进行了对话。除此之外,你还可以做两个快速检查。首先,设计或使用在请求中包含唯一标识符或请求 ID 的协议,该标识符或请求 ID 会在应答中重复出现。如果回复包含您正在寻找的 ID,那么——只要 ID 的范围足够大,某人不可能简单地用包含每个可能的 ID 的数千或数百万个包来迅速淹没您——看到您的请求的某人至少必须已经编写了它。第二,要么对照您发送的地址检查回复包的地址(记住 Python 中的元组可以简单地与==进行比较),要么使用connect()禁止其他地址向您发送包。更多详细信息,请参见以下章节“连接 UDP 套接字” 和“请求 id”。

不可靠性、回退、阻塞和超时

因为前面几节中的客户机和服务器都运行在同一台机器上,并通过它的环回接口进行通信——环回接口不是一个可能会出现信号故障的物理网卡——所以数据包不可能真正丢失,所以您实际上看不到清单 2-1 中的 UDP 的任何不便之处。当数据包真的可能丢失时,代码是如何变得更加复杂的?

看一下清单 2-2 。该服务器并不总是响应客户端请求,而是随机选择只响应来自客户端的一半请求,这将让您了解如何在客户端代码中建立可靠性,而无需等待可能需要几个小时才会在网络上出现真正的数据包丢失!

清单 2-2 。UDP 服务器和客户端在不同的机器上

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/udp_remote.py
# UDP client and server for talking over the network

import argparse, random, socket, sys

MAX_BYTES = 65535

def server(interface, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((interface, port))
    print('Listening at', sock.getsockname())
    while True:
        data, address = sock.recvfrom(MAX_BYTES)
        if random.random() < 0.5:
            print('Pretending to drop packet from {}'.format(address))
            continue
        text = data.decode('ascii')
        print('The client at {} says {!r}'.format(address, text))
        message = 'Your data was {} bytes long'.format(len(data))
        sock.sendto(message.encode('ascii'), address)

def client(hostname, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    hostname = sys.argv[2]
    sock.connect((hostname, port))
    print('Client socket name is {}'.format(sock.getsockname()))

    delay = 0.1  # seconds
    text = 'This is another message'
    data = text.encode('ascii')
    while True:
        sock.send(data)
        print('Waiting up to {} seconds for a reply'.format(delay))
        sock.settimeout(delay)
        try:
            data = sock.recv(MAX_BYTES)
        except socket.timeout:
            delay *= 2  # wait even longer for the next request
            if delay > 2.0:
                raise RuntimeError('I think the server is down')
        else:
            break   # we are done, and can stop looping

    print('The server says {!r}'.format(data.decode('ascii')))

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive UDP,'
                                     ' pretending packets are often dropped')
    parser.add_argument('role', choices=choices, help='which role to take')
    parser.add_argument('host', help='interface the server listens at;'
                        'host the client sends to')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p)

虽然前面示例中的服务器告诉操作系统它只需要数据包,这些数据包是通过专用的 127.0.0.1 接口从同一台机器上的其他进程到达的,但是您可以通过将服务器 IP 地址指定为空字符串来使该服务器更加慷慨。这意味着“任何本地接口”,我的 Linux 笔记本电脑意味着向操作系统请求 IP 地址 0.0.0.0。

$ python udp_remote.py server ""
Listening at ('0.0.0.0', 1060)

每次收到一个请求,服务器将通过random()抛硬币来决定这个请求是否会被响应,这样你就不必整天运行客户机来等待一个真正被丢弃的包。无论它做出什么决定,它都会在屏幕上显示一条信息,这样您就可以跟上它的活动。

我们如何编写一个“真正的”UDP 客户端,一个必须处理数据包可能丢失的事实的客户端?

首先,UDP 的不可靠性 意味着客户端必须在一个循环中执行它的请求。它要么准备好永远等待回复,要么武断地决定何时等待回复“太久”,需要发送另一个回复。这种困难的选择是必要的,因为客户通常没有办法区分这三种完全不同的事件:

  • 回复需要很长时间才能回来,但很快就会到了。
  • 回复永远不会到达,因为它或请求已经丢失。
  • 服务器关闭了,它没有回复任何人。

因此,UDP 客户端必须选择一个时间表,如果它等待一段合理的时间而没有得到响应,它将根据该时间表发送重复的请求。当然,这样做可能会浪费服务器的时间,因为第一个回复可能即将到达,而请求的第二个副本可能会导致服务器执行不必要的重复工作。然而,在某些时候,客户端必须决定重新发送请求,否则就要冒永远等待的风险。

因此,这个客户端首先在套接字上执行一个settimeout(),而不是让操作系统在recv()调用中永远暂停。这通知系统,客户端不愿意在套接字操作中等待超过delay秒,并且一旦调用等待了那么长时间,它希望调用以socket.timeout异常中断。

等待网络操作完成的呼叫被称为阻塞呼叫者。术语阻塞 用于描述类似recv()的调用,它使客户端等待直到新数据到达。当你读到第七章讨论服务器架构的时候,阻塞和非阻塞网络调用之间的区别将变得非常明显!

这个特定的客户端开始时只等待了十分之一秒。对于我的家庭网络,ping 时间通常是几十毫秒,这很少会导致客户端仅仅因为回复延迟而发送重复的请求。

这个客户端程序的一个重要特性是如果超时 到达会发生什么。它不会而不是简单地以固定的时间间隔一遍又一遍地发送重复请求!由于数据包丢失的主要原因是拥塞——正如任何人都知道在上传照片或视频的同时试图通过 DSL 调制解调器向上游发送正常数据——您最不想做的事情就是通过发送更多的数据包来应对可能丢失的数据包。

因此,该客户端使用一种称为指数补偿 的技术,在这种情况下,它的尝试变得越来越不频繁。这有助于在几个丢弃的请求或回复中幸存下来,同时使拥塞的网络能够慢慢恢复,因为所有活动客户端都放弃了它们的需求,并逐渐发送更少的数据包。虽然存在更好的指数回退算法,例如,该算法的以太网版本增加了一些随机性,以便两个竞争的网卡不太可能按照完全相同的时间表回退,但基本效果可以通过在每次未收到回复时将延迟加倍来实现。

请注意,如果请求是向 200 毫秒之外的服务器发出的,那么这个简单的算法每次都会发送每个请求的至少两个副本,因为它永远不会知道对这个服务器的请求总是需要 0.1 秒以上的时间。如果您正在编写一个生存时间很长的 UDP 客户端,请考虑让它记住最后几个请求需要多长时间才能完成,这样它就可以延迟第一次重试,直到服务器有足够的时间进行回复。

当您运行清单 2-2 客户机时,给它运行服务器脚本的另一台机器的主机名,如前所示。有时候,这个客户会很幸运,得到一个即时回复。

$ python udp_remote.py client guinness
Client socket name is ('127.0.0.1', 45420)
Waiting up to 0.1 seconds for a reply
The server says 'Your data was 23 bytes long'

然而,它经常会发现它的一个或多个请求从未得到答复,它将不得不重试。如果您仔细观察它的重复尝试,您甚至可以看到实时发生的指数后退,因为随着延迟计时器加速,回显到屏幕上的打印语句越来越慢。

$ python udp_remote.py client guinness
Client socket name is ('127.0.0.1', 58414)
Waiting up to 0.1 seconds for a reply
Waiting up to 0.2 seconds for a reply
Waiting up to 0.4 seconds for a reply
Waiting up to 0.8 seconds for a reply
The server says 'Your data was 23 bytes long'

您可以在运行服务器的终端上看到请求是否真的发出了,或者您是否碰巧在网络上遇到了真正的丢包。当我运行前面的测试时,我可以查看服务器的控制台,看到所有的数据包都成功了。

Pretending to drop packet from ('192.168.5.10', 53322)
Pretending to drop packet from ('192.168.5.10', 53322)
Pretending to drop packet from ('192.168.5.10', 53322)
Pretending to drop packet from ('192.168.5.10', 53322)
The client at ('192.168.5.10', 53322) says, 'This is another message'

如果服务器完全瘫痪了怎么办?不幸的是,UDP 让我们无法区分服务器故障和网络状况不佳,丢弃所有数据包或回复。当然,我认为我们不应该把这个问题归咎于 UDP。毕竟,世界本身无法让我们区分无法探测的事物和不存在的事物!因此,客户端能做的最好的事情就是在尝试了足够多之后放弃。终止服务器进程,然后再次尝试运行客户端。

$ python udp_remote.py client guinness
Client socket name is ('127.0.0.1', 58414)
Waiting up to 0.1 seconds for a reply
Waiting up to 0.2 seconds for a reply
Waiting up to 0.4 seconds for a reply
Waiting up to 0.8 seconds for a reply
Waiting up to 1.6 seconds for a reply
Traceback (most recent call last):
  ...
socket.timeout: timed out

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
RuntimeError: I think the server is down

当然,只有当你的程序试图执行一些简单的任务,需要产生输出或者返回某种结果给用户时,放弃才有意义。如果你正在编写一个全天运行的守护程序——比如说,在屏幕的角落有一个天气图标,显示从远程 UDP 服务获取的温度和天气预报——那么让代码“永远”重试是没问题的毕竟,台式机或笔记本电脑可能会长时间脱离网络,您的代码可能需要耐心等待数小时或数天,直到可以再次联系到预测服务器。

如果您正在编写整天重试的守护程序代码,那么不要坚持严格的指数后退,否则您很快就会将延迟增加到两个小时,然后您可能会错过整个半小时的时间,在此期间,笔记本电脑所有者会坐在咖啡店里,而您实际上可以访问网络。相反,选择某个最大延迟,比如说五分钟,一旦指数回退达到该时间,就保持该时间,这样,一旦用户在长时间断开连接后在网络上停留了五分钟,您就可以始终保证尝试更新。

如果您的操作系统允许您的进程收到类似网络恢复这样的事件的信号,那么您将能够做得比玩计时器和猜测网络何时恢复好得多。但遗憾的是,这种特定于系统的机制已经超出了本书的范围,所以现在让我们回到 UDP 以及它引发的一些问题。

连接 UDP 套接字

清单 2-2 ,您在上一节中研究过的,引入了另一个需要解释的新概念。我已经讨论过绑定——服务器用来获取它想要使用的地址的显式bind()调用,以及当客户端第一次尝试使用套接字并被操作系统分配了一个随机的临时端口号时发生的隐式绑定。

但是清单 2-2 中的远程 UDP 客户端也使用了一个我之前没有讨论过的新调用:套接字操作。你可以很容易地看到它做了什么。与每次您想向服务器发送东西时必须使用带有显式地址元组的sendto()不同,connect()调用让操作系统提前知道您想向其发送数据包的远程地址,因此您可以简单地向send()调用提供数据,而不必再次重复服务器地址。

但是connect()做了其他更重要的事情,这一点从阅读清单 2-2 中根本看不出来:它解决了客户混杂的问题!如果您在这个客户端上执行您在“混乱”部分中执行的测试,您将发现清单 2-2 中的客户端不容易接收来自其他服务器的数据包。这是因为使用connect()配置 UDP 套接字的首选目的地的第二个不太明显的影响:一旦您运行了connect(),操作系统将丢弃任何传入到您的端口的数据包,这些数据包的返回地址与您连接的地址不匹配。

那么,有两种方法来编写 UDP 客户端,它们要小心到达的数据包的返回地址。

  • 您可以使用sendto()将每个传出的数据包定向到一个特定的目的地,然后使用recvfrom()接收回复,并根据您发出未完成请求的服务器列表仔细检查每个返回地址。
  • 你可以在创建完套接字后立即connect()你的套接字,并与send()recv()通信。操作系统会为你过滤掉不需要的数据包。这仅适用于一次与一个服务器对话,因为在同一个套接字上再次运行connect()不会添加第二个目的地址。相反,它会彻底清除第一个地址,这样就不会再有来自更早地址的回复发送到您的程序。

使用connect()连接了 UDP 套接字后,可以使用套接字的getpeername()方法来记住它连接到的地址。在尚未连接的套接字上调用此函数时要小心。该调用不会返回 0.0.0.0 或其他通配符响应,而是会引发socket.error

关于connect()电话会议,应该提出最后两点。

首先,在 UDP 套接字上做connect()不会不会通过网络发送任何信息,或者做任何事情来警告服务器数据包可能到来。它只是将地址写入操作系统的内存中,供您稍后调用send()recv()时使用。

第二,请记住,使用回邮地址自己做一个connect()—或者甚至过滤掉不想要的数据包——并不是一种安全形式!如果网络上有人真的怀有恶意,他们的计算机通常很容易伪造带有服务器返回地址的数据包,这样他们伪造的回复就能顺利通过您的地址过滤器。

用另一台计算机的返回地址发送数据包被称为欺骗 ,这是协议设计者在设计被认为是安全抗干扰的协议时首先要考虑的事情之一。更多信息见第六章。

请求 id:好主意

在清单 2–1 和清单 2–2 中发送的消息都是简单的 ASCII 文本。但是,如果您曾经为 UDP 请求和响应设计过自己的方案,您应该认真考虑为每个请求添加一个序列号,并确保您接受的回复使用相同的序列号。在服务器端,只需将每个请求的编号复制到相应的回复中。这至少有两大好处。

首先,它保护您不被重复的请求所迷惑,这些请求被执行指数回退循环的客户端重复了几次。

你可以很容易地看到复制是如何发生的。你发送请求 a。你在等待答复时感到厌烦,所以你重复请求 a。然后你终于得到了答复,回复 a。你认为第一个副本丢失了,所以你愉快地继续赶路。

但是,如果两个请求都到达了服务器,而回复返回的速度有点慢,该怎么办呢?您收到了两个回复中的一个,但是另一个是否即将到达?如果你现在向服务器发送请求 B 并开始监听,你几乎会立即收到重复的回复 A,也许会认为这是你在请求 B 中所提问题的答案,你会变得困惑。从那时起,你可能会完全步调不一,把每一个回复理解为对应于一个不同的请求,而不是你认为的那个请求!

请求 id 可以保护您不受此影响。如果您为请求 A 的每个副本指定了请求 ID #42496,为请求 B 指定了 ID #16916,那么等待 B 的回答的程序循环可以简单地丢弃 ID 不等于#16916 的回答,直到它最终接收到一个匹配的回答。这防止了重复应答,重复应答不仅出现在您重复问题的情况下,而且出现在网络结构中的冗余意外地在服务器和客户端之间的某个地方生成数据包的两个副本的罕见情况下。

正如在“混乱”一节中提到的,请求 id 的另一个作用是提供对欺骗的威慑,至少在攻击者看不到您的数据包的情况下是如此。当然,如果他们可以,那么你就完全迷路了:他们会看到你发送的每个数据包的 IP、端口号和请求 ID,并且可以尝试发送假的回复,当然,希望他们的回复在服务器的回复之前到达,任何他们喜欢的请求!但是,如果攻击者无法观察到您的流量,不得不盲目地向您的服务器发送 UDP 数据包,一个大小合适的请求 ID 号会使您的客户端不太可能接受他们的回答。

您会注意到,我在刚刚讲述的故事中使用的示例请求 id 既不是连续的,也不容易猜测。这些特征意味着攻击者不知道什么是可能的序列号。如果你从 0 或 1 开始向上计数,攻击者的工作就容易多了。相反,尝试使用random模块来生成大整数。如果您的 ID 号是 0 到 N 之间的一个随机数,那么攻击者用一个有效的数据包攻击您的机会——即使假设攻击者知道服务器的地址和端口——最多是 1/N,如果他或她不得不疯狂地尝试攻击您机器上所有可能的端口号,这个机会可能会小得多。

但是,当然,这些都不是真正的安全——它只是防止无法观察您的网络流量的人发起幼稚的欺骗攻击。真正的安全保护你,即使攻击者既可以观察你的流量,也可以随时插入他们自己的消息。在第六章中,你将看到真正的安全是如何工作的。

绑定到接口

到目前为止,您已经看到了服务器发出的bind()调用中使用的 IP 地址的两种可能性。您可以使用'127.0.0.1'来表示您希望来自其他程序的数据包只在同一台机器上运行,或者您可以使用空字符串''作为通配符来表示您愿意接收通过任何网络接口到达服务器的数据包。

还有第三种选择。您可以提供机器的一个外部 IP 接口的 IP 地址,如以太网连接或无线网卡,,服务器将只监听发往这些 IP 的数据包。您可能已经注意到清单 2-2 实际上允许您为bind()调用提供一个服务器字符串,这将允许您做一些实验。

如果只绑定到外部接口会怎样?像这样运行服务器,使用你的操作系统告诉你的系统的外部 IP 地址:

$ python udp_remote.py server 192.168.5.130
Listening at ('192.168.5.130', 1060)

从另一台机器连接到这个 IP 地址应该仍然可以正常工作。

$ python udp_remote.py client guinness
Client socket name is ('192.168.5.10', 35084)
Waiting up to 0.1 seconds for a reply
The server says 'Your data was 23 bytes'

但是,如果您在同一台机器上运行客户机脚本,尝试通过环回接口连接到服务,数据包将永远不会被传送。

$ python udp_remote.py client 127.0.0.1
Client socket name is ('127.0.0.1', 60251)
Waiting up to 0.1 seconds for a reply
Traceback (most recent call last):
  ...
socket.error: [Errno 111] Connection refused

实际上,至少在我的操作系统上,结果甚至比数据包从未被传送要好。因为操作系统可以在不通过网络发送数据包的情况下查看自己的某个端口是否打开,所以它会立即回复到该端口的连接是不可能的!但是请注意,UDP 返回“连接被拒绝”的这种能力是环回的一种强大功能,在真实的网络中是永远不会看到的。在那里,必须简单地发送分组,而不指示是否有接收它的目的地端口。

尝试在同一台机器上再次运行客户端,但这次使用机器的外部 IP 地址。

$ python udp_remote.py client 192.168.5.130
Client socket name is ('192.168.5.130', 34919)
Waiting up to 0.1 seconds for a reply
The server says 'Your data was 23 bytes'

你看到发生了什么吗?允许本地运行的程序发送来自它们想要的任何机器 IP 地址的请求——即使它们只是使用那个 IP 地址与同一机器上的另一个服务进行对话!

因此,绑定到 IP 接口可能会限制哪些外部主机可以与您对话。但它肯定不会限制与同一台机器上的其他客户机的对话,只要它们知道应该连接的 IP 地址。

如果你试图同时运行两个服务器会发生什么?停止所有正在运行的脚本,并尝试在同一台机器上运行两台服务器。您将把其中一个连接到环回接口。

$ python udp_remote.py server 127.0.0.1
Listening at ('127.0.0.1', 1060)

现在这个地址被占用了,您不能在这个地址运行第二个服务器,因为这样操作系统就不知道哪个进程应该得到到达这个地址的任何给定的数据包。

$ python udp_remote.py server 127.0.0.1
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

但是更令人惊讶的是,你也不能在通配符 IP 地址上运行服务器。

$ python udp_remote.py server
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

这将失败,因为通配符地址包括 127.0.0.1,因此它与第一个服务器进程已经获取的地址冲突。但是,如果不是尝试在所有 IP 接口上运行第二台服务器,而是在外部 IP 接口上运行第二台服务器—服务器的第一个副本不监听该接口,那会怎么样呢?让我们试试。

$ python udp_remote.py server 192.168.5.130
Listening at ('192.168.5.130', 1060)

成功了。现在,这台机器上运行着两台具有相同 UDP 端口号的服务器,其中一台绑定到向内查看的环回接口,另一台向外查看到达我的无线网卡所连接的网络的数据包。如果您碰巧在一个有几个远程接口的机器上,您可以启动更多的服务器,每个远程接口一个服务器。

一旦你运行了这些服务器,试着用你的 UDP 客户端给它们发送一些包。您会发现只有一个服务器接收每个请求,并且在每种情况下,它将是保存您将 UDP 请求数据包定向到的特定 IP 地址的服务器。

所有这一切的教训是,IP 网络堆栈从不认为 UDP 端口是在任何给定时刻完全可用或正在使用的单独实体。相反,它认为 UDP“套接字名称”总是一对链接 IP 接口(即使它是通配符接口)和 UDP 端口号的名称。正是这些套接字名称在任何给定时刻都不能在监听服务器之间发生冲突,而不是正在使用的裸 UDP 端口。

最后一个警告是适当的。由于前面的讨论表明将您的服务器绑定到接口 127.0.0.1 可以保护您免受外部网络上可能生成的恶意数据包的攻击,您可能会认为绑定到一个外部接口可以保护您免受其他外部网络上的不满意者生成的恶意数据包的攻击。例如,在一个有多个网卡的大型服务器上,您可能想绑定到一个面向其他服务器的私有子网,并因此认为可以避免欺骗数据包到达您面向 Internet 的公共 IP 地址。

可悲的是,生活并非如此简单。实际上,这取决于您选择的操作系统及其配置方式,即是否允许发往一个接口的入站数据包到达另一个接口。如果数据包出现在您的公共互联网连接上,您的系统可能会很乐意接受声称来自您网络上其他服务器的数据包!请查阅您的操作系统文档,或咨询您的系统管理员,以了解有关您的具体情况的更多信息。在您的机器上配置和运行防火墙也可以提供保护,如果您的操作系统不这样做的话。

UDP 碎片

到目前为止,我在本章中一直在说,UDP 让您作为用户发送原始数据报,这些数据报只是简单地打包成 IP 数据包,只带有一点点附加信息——发送方和接收方的端口。但是您可能已经开始怀疑了,因为前面的程序清单表明 UDP 数据包的大小可以达到 64kB,而您可能已经知道您的以太网或无线网卡只能处理大约 1500 字节的数据包。

实际情况是,虽然 UDP 确实将小数据报作为单个 IP 包发送,但它必须将较大的 UDP 数据报分割成几个小的 IP 包,以便它们可以穿过网络(正如在第一章中简要讨论的那样)。这意味着大数据包更有可能被丢弃,因为如果它们中的任何一个片段未能到达目的地,那么整个数据包就永远无法被重组并传送到侦听操作系统。

除了较高的失败几率之外,将大型 UDP 数据包分段以适合网络传输的过程应该对您的应用不可见。然而,它在三个方面可能是相关的。

  • 如果您考虑效率,您可能希望将您的协议限制为小数据包,以减少重新传输的可能性,并限制远程 IP 堆栈重组您的 UDP 数据包并将其交给等待的应用所需的时间。
  • 如果 ICMP 数据包被防火墙错误地阻止,防火墙通常会允许您的主机自动检测您和远程主机之间的 MTU(这是 20 世纪 90 年代末的常见情况),那么您的较大 UDP 数据包可能会在您不知不觉中消失。MTU 是两台主机之间所有网络设备支持的“最大传输单位”或“最大数据包大小”。

如果您的协议可以自行选择如何在不同的数据报之间分割数据,并且您希望能够根据两台主机之间的实际 MTU 自动调整该大小,则某些操作系统允许您关闭分段,并在 UDP 数据包太大时收到错误消息。然后,您可以小心地设计最小单位下的数据报。

Linux 是支持最后一种选择的操作系统。看一下清单 2-3 ,它发送了一个很大的数据报。

清单 2-3 。发送大型 UDP 数据包

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/big_sender.py
# Send a big UDP datagram to learn the MTU of the network path.

import IN, argparse, socket

if not hasattr(IN, 'IP_MTU'):
    raise RuntimeError('cannot perform MTU discovery on this combination'
                       ' of operating system and Python distribution')

def send_big_datagram(host, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.IPPROTO_IP, IN.IP_MTU_DISCOVER, IN.IP_PMTUDISC_DO)
    sock.connect((host, port))
    try:
        sock.send(b'#'65000)
    except socket.error:
        print('Alas, the datagram did not make it')
        max_mtu = sock.getsockopt(socket.IPPROTO_IP, IN.IP_MTU)
        print('Actual MTU: {}'.format(max_mtu))
    else:
        print('The big datagram was sent!')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Send UDP packet to get MTU')
    parser.add_argument('host', help='the host to which to target the packet')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='UDP port (default 1060)')
    args = parser.parse_args()
    send_big_datagram(args.host, args.p)

如果我在家庭网络中的其他服务器上运行这个程序,我会发现我的无线网络允许物理数据包不超过 1500 字节,而以太网通常支持 1500 字节。

$ python big_sender.py guinness
Alas, the datagram did not make it
Actual MTU: 1500

更令人惊讶的是,我的笔记本电脑上的环回接口,大概可以支持和我的 RAM 一样大的数据包,也强加了一个 MTU。

$ python big_sender.py 127.0.0.1
Alas, the datagram did not make it
Actual MTU: 65535

但是检查 MTU 的能力并不是到处都有的;有关详细信息,请查看您的操作系统文档。

插座选项

POSIX 套接字接口 支持控制网络套接字特定行为的各种套接字选项。你在清单 2-3 的中看到的IP_MTU_DISCOVER选项只是冰山一角。通过 Python 套接字方法getsockopt()setsockopt()访问选项,使用操作系统文档中为这两个系统调用列出的选项。在 Linux 上,尝试查看手册页 socket (7)、 udp (7),以及—当您进入下一章时— tcp (7)。

当设置套接字选项时,你首先必须命名它们所在的选项组,然后,作为后续参数,命名你想要设置的实际选项。有关这些组的名称,请查阅您的操作系统手册。就像 Python 调用getattr()setattr() 一样,set 调用只是比 get 多了一个参数。

value = s.getsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, value)

许多选项是特定于特定操作系统的,他们可能对选项的呈现方式很挑剔。以下是一些比较常见的选项:

  • SO_BROADCAST :这允许发送和接收广播 UDP 包,我将在下一节中介绍。
  • SO_DONTROUTE :只愿意发送寻址到该计算机直接连接的子网上的主机的数据包。例如,如果设置了这个套接字选项,我的笔记本电脑此时会愿意将数据包发送到网络 127.0.0.0/8 和 192.168.5.0/24,但不会愿意将它们发送到任何其他地方,因为数据包必须通过网关进行路由。
  • SO_TYPE :当传递给getsockopt()时,它返回给你一个套接字是属于SOCK_DGRAM类型并可用于 UDP 还是属于SOCK_STREAM类型并支持 TCP 的语义(参见第三章)。

下一章将进一步介绍一些专门适用于 TCP 套接字的套接字选项。

广播

如果说 UDP 有什么超能力,那就是它支持广播的能力。您可以将数据报寻址到您的计算机所连接的整个子网,并让物理网卡广播数据报,这样所有连接的主机都可以看到数据报,而不必将数据单独复制到每个主机,而不必将数据报发送到其他特定主机。

应该立即提到的是,广播现在被认为是过时的,因为一种称为多播的更复杂的技术已经被开发出来,它让现代操作系统能够更好地利用许多网络和网络接口设备中内置的智能。此外,多播可以与不在本地子网上的主机一起工作。但是,如果您想要一种简单的方法来保持本地局域网上的游戏客户端或自动记分牌等内容保持最新,并且每个客户端都可以经受住偶尔丢失的数据包,那么 UDP 广播是一种简单的选择。

清单 2-4 展示了一个可以接收广播包的服务器和一个可以发送广播包的客户端的例子。如果仔细观察,您会发现这个清单和以前的清单中使用的技术几乎只有一处不同。在使用这个 socket 对象之前,您调用它的setsockopt()方法来打开广播。除此之外,服务器和客户机都很正常地使用套接字。

清单 2-4 。UDP 广播

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter02/udp_broadcast.py
# UDP client and server for broadcast messages on a local LAN

import argparse, socket

BUFSIZE = 65535

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

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

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

当尝试这个服务器和客户机时,您应该注意的第一件事是,如果您只是使用客户机发送寻址到特定服务器的 IP 地址的数据包,它们的行为就像普通的客户机和服务器一样。打开 UDP 套接字的广播不会禁用或更改它发送和接收特定地址数据包的正常能力。

当您查看本地网络的设置并使用其 IP“广播地址”作为客户端的目的地时,神奇的事情就发生了。首先,使用如下命令在网络上启动一台或两台服务器:

$ python udp_broadcast.py server ""
Listening for broadcasts at ('0.0.0.0', 1060)

然后,当这些服务器运行时,首先使用客户机向每个服务器发送消息。您将看到只有一台服务器接收每条消息。

$ python udp_broadcast.py client 192.168.5.10

但是当您使用本地网络的广播地址时,您会突然发现所有的广播服务器同时收到数据包!(但是没有普通的服务器会看到它——运行普通udp_remote.py服务器的几个副本,同时进行广播以使人信服。)目前在我的本地网络上,ifconfig命令告诉我广播地址是这样的:

$ python udp_broadcast.py client 192.168.5.255

果然,两台服务器都立即报告它们看到了该消息。如果您的操作系统很难确定广播地址,并且您不介意从主机的每个网络端口进行广播,Python 允许您在使用 UDP 套接字发送时使用特殊的主机名'<broadcast>'。在将这个名字传递给客户机时,要注意引用它,因为对于任何普通的 POSIX shell 来说,<>字符都是非常特殊的。

$ python udp_broadcast.py client "<broadcast>"

如果有任何独立于平台的方法来了解每个连接的子网及其广播地址,我会告诉你。不幸的是,如果您想做比使用这个特殊的'<broadcast>'字符串更具体的事情,您必须查阅您自己的操作系统文档。

何时使用 UDP

您可能认为 UDP 对于发送小消息会很有效。实际上,只有当主机一次只发送一条消息,然后等待响应时,UDP 才是有效的。如果您的应用可能会在一个脉冲串中发送几条消息,那么使用像 MQ 这样的智能消息队列实际上会更有效,因为它会设置一个短的计时器,让它将几条小消息捆绑到一个单独的传输中,可能是在 TCP 连接上,这比您更好地将有效负载分成片段!

然而,有一些使用 UDP 的好理由。

  • 因为您正在实现一个已经存在的协议,并且它使用 UDP。
  • 因为您正在设计一个时间关键的媒体流,它的冗余允许偶尔的数据包丢失,并且您永远不希望这一秒钟的数据被挂起,等待几秒钟前尚未传送的旧数据(TCP 就发生了这种情况)。
  • 因为不可靠的局域网子网多播对您的应用来说是一个很好的模式,UDP 完全支持它。

除了这三种情况之外,你可能应该看看本书后面的章节,寻找如何为你的应用构建通信的灵感。有句老话说,当你的应用有了 UDP 协议时,你可能已经彻底改造了 TCP——很糟糕。

摘要

用户数据报协议让用户级程序通过 IP 网络发送单个数据包。通常,客户端程序向服务器发送数据包,然后服务器使用每个 UDP 数据包中内置的返回地址进行回复。

POSIX 网络堆栈通过“套接字”的概念为您提供对 UDP 的访问,套接字是一个通信端点,可以位于一个 IP 地址和 UDP 端口号上——这两个东西合起来称为套接字的名称地址——并发送和接收数据报。Python 通过内置的socket模块提供这些原始的网络操作。

服务器需要bind()到一个地址和端口,然后才能接收输入的数据包。客户端 UDP 程序可以直接开始发送,操作系统会自动为它们选择一个端口号。

因为 UDP 是建立在网络数据包的实际行为之上的,所以它是不可靠的。数据包可能会因为网络传输介质上的故障或网段太忙而被丢弃。客户端必须对此进行补偿,愿意重新发送请求,直到收到回复。为了防止使繁忙的网络变得更糟,当客户端遇到重复的故障时,它们应该使用指数回退,并且如果它们发现到服务器的往返所花费的时间比它们最初愿意等待的时间长,它们也应该延长它们的初始等待时间。

请求 id 对于解决回复重复问题至关重要,在这种情况下,您认为丢失的回复最终会到达,并可能被误认为是您当前问题的回复。如果随机选择,请求 id 也有助于防止幼稚的欺骗攻击。

当使用套接字时,重要的是区分绑定的行为——通过这种行为,您获取一个特定的 UDP 端口供自己使用——和客户端通过连接执行的行为,这种行为限制了所有接收到的回复,因此它们只能来自您想要与之对话的特定服务器。

在 UDP 套接字可用的套接字选项中,最强大的是广播,它允许您向子网中的每台主机发送数据包,而不必分别发送给每台主机。这有助于编写本地局域网游戏或其他协作计算程序,也是您选择 UDP 作为新应用的少数原因之一。

三、TCP

传输控制协议(正式名称为 TCP/IP,但在本书的其余部分都称为 TCP)是互联网的主力。它于 1974 年首次定义,建立在互联网协议(IP,在第一章中描述)的数据包传输技术之上,让应用使用连续的数据流进行通信。除非连接因为网络问题而终止或冻结,否则 TCP 保证数据流将完好无损地到达,不会丢失、复制或打乱任何信息。

携带文档和文件的协议几乎总是建立在 TCP 之上。这包括将网页发送到您的浏览器、文件传输以及所有主要的电子邮件传输机制。TCP 也是人们或计算机之间进行长时间对话的协议选择的基础,例如 SSH 终端会话和许多流行的聊天协议。

当互联网还比较年轻的时候,通过在 UDP 上构建一个应用(参见第二章)并自己仔细选择每个单独数据报的大小和时间,有时很容易试图从网络中挤出更多的性能。但是现代 TCP 实现往往是复杂的,受益于 30 多年的改进、创新和研究。除了协议设计专家,很少有人能改进现代 TCP 栈的性能。如今,甚至像消息队列这样的性能关键型应用(第八章)通常也选择 TCP 作为它们的媒介。

TCP 如何工作

正如你在第一章和第二章中了解到的,网络是变化无常的生物。它们有时会丢弃您试图通过它们传输的数据包。他们偶尔会创建数据包的额外副本。此外,它们经常不按顺序发送数据包。对于 UDP 这样的简单数据报工具,您自己的应用代码必须考虑每个数据报是否到达,并在没有到达时制定恢复计划。但是使用 TCP,数据包本身隐藏在协议之下,您的应用可以简单地将数据流向其目的地,确信丢失的信息将被重新传输,直到它最终成功到达。

TCP/IP 的经典定义是 1981 年的 RFC 793,尽管许多后续的 RFC 都有详细的扩展和改进。

TCP 如何提供可靠的连接?以下是它的基本原则:

  • 每个 TCP 数据包都有一个序列号,这样接收端的系统就可以按正确的顺序将它们放回一起,还可以注意到序列中丢失的数据包,并要求重新传输它们。
  • 代替使用连续的整数(1,2,3...)为了对数据包进行排序,TCP 使用一个计数器来计算传输的字节数。例如,序列号为 7,200 的 1,024 字节的数据包后面会跟一个序列号为 8,224 的数据包。这意味着繁忙的网络堆栈不需要记住它是如何将数据流分解成数据包的。如果请求重传,它可以通过其他方式将数据流分成新的数据包(如果现在有更多的字节等待传输,这可能会让数据包容纳更多的数据),而接收器仍然可以将数据包重新组合在一起。
  • 在好的 TCP 实现中,初始序列号是随机选择的,这样恶棍就不能假定每个连接都从字节 0 开始。不幸的是,可预测的序列号使得伪造数据包变得更容易,这些数据包看起来像是数据的合法部分,可能会中断对话。
  • TCP 不是在发送下一个数据包之前需要对每个数据包进行确认,从而在锁定步骤中运行得非常慢,而是在等待响应之前一次发送整个数据包突发。在任何给定时刻,发送者愿意在网络上传输的数据量被称为 TCP 窗口的大小。
  • 接收端的 TCP 实现可以调整发送端的窗口大小,从而减慢或暂停连接。这被称为流量控制。这使得接收器在其输入缓冲区已满的情况下禁止传输额外的数据包,即使数据到达,它也必须丢弃更多的数据。
  • 最后,如果 TCP 认为数据包正在被丢弃,它会认为网络正在变得拥塞,并减少每秒发送的数据量。这对于无线网络和其他介质来说可能是一场灾难,在这些介质中,数据包仅仅因为噪声而丢失。它还会破坏正常运行的连接,直到路由器重启,端点无法通话,比如说 20 秒。当网络恢复时,两个 TCP 对等体将会认为网络的流量已经超负荷了,在重新建立联系时,它们将首先拒绝以除涓涓细流之外的任何方式向对方发送数据。

TCP 的设计除了刚才描述的行为之外,还涉及许多其他细微差别和细节,但理想情况下,这种描述会让您对它的工作方式有一个良好的感觉——尽管您会记得,您的应用看到的只是一个数据流,实际的数据包和序列号被您的操作系统网络堆栈巧妙地隐藏了起来。

何时使用 TCP

如果你的网络程序和我的完全一样,那么你从 Python 执行的大部分网络通信将使用 TCP。事实上,您可能在整个职业生涯中都没有刻意从您的代码中生成 UDP 包。(不过,正如你将在第五章中看到的,每当你的程序需要查找 DNS 主机名时,UDP 可能会出现在后台。)

尽管当两个互联网程序需要通信时,TCP 几乎已经成为通用的默认协议,但我将介绍一些它的行为不是最佳的实例,以防您正在编写的应用属于这些类别之一。

首先,TCP 是一种笨拙的协议,在这种协议中,客户端希望向服务器发送单个的、小的请求,然后它们就完成了,不会再与服务器进一步对话。两台主机建立 TCP 连接需要三个数据包,即著名的 SYN、SYN-ACK 和 ACK 序列。

  • SYN :“我想说话;这是我将开始使用的数据包序列号。”
  • SYN-ACK :“好的,这是我将在我的方向上使用的初始序列号。”
  • ACK :“好的!”

当连接完成时,需要另外三个或四个数据包来关闭连接——要么是快速 FIN、FIN-ACK 和 ACK,要么是每个方向上一对稍长的单独 FIN 和 ACK 数据包。总之,至少需要六个数据包来传递一个请求!在这种情况下,协议设计者很快转向 UDP。

但是,有一个问题要问,客户机是否想打开一个 TCP 连接,然后用它在几分钟或几小时内向同一台服务器发出许多单独的请求。一旦连接开始并且支付了握手的成本,每个实际的请求和响应在每个方向上只需要一个包,这将受益于 TCP 关于重传、指数补偿和流量控制的所有智能。

UDP 真正的优势在于客户机和服务器之间不存在长期的关系,特别是在客户机太多的情况下,如果必须为每个活动客户机提供单独的数据流,典型的 TCP 实现就会耗尽内存。

TCP 不适用的第二种情况是,当数据包丢失时,应用可以做一些比简单地重新传输数据更聪明的事情。例如,想象一个音频聊天对话。如果一秒钟的数据因为丢包而丢失,那么简单地一遍又一遍地重新发送同样一秒钟的音频,直到它最终到达,也没有什么好处。相反,客户端应该用它可以从确实到达的数据包中拼凑的任何音频来填充这尴尬的一秒钟(一个聪明的音频协议将使用来自前后时刻的一点高度压缩的音频来开始和结束每个数据包,以准确地覆盖这种情况),然后在中断后继续进行,就像它没有发生一样。这对于 TCP 来说是不可能的,它会顽固地重新传输丢失的信息,即使这些信息已经太旧而没有任何用处。UDP 数据报通常是互联网上实时流媒体的基础。

TCP 套接字是什么意思

正如 UDP 在第二章中的情况一样,TCP 使用端口号来区分在同一 IP 地址上运行的不同应用,并且它遵循关于众所周知的短暂端口号的完全相同的约定。如果您想查看详细信息,请重读该章中的“端口号”一节。

正如您在上一章中看到的,只需要一个套接字就可以发出 UDP:一个服务器可以打开一个 UDP 端口,然后从成千上万个不同的客户端接收数据报。虽然当然有可能将数据报套接字connect()到一个特定的对等体,使得套接字总是只send()到该对等体和从该对等体发回的recv()数据包,但是连接的概念只是为了方便。connect()的效果与您的应用简单地自行决定只向一个地址发送sendto()呼叫,然后忽略来自同一地址以外的任何地址的响应是完全一样的。

但是对于像 TCP 这样的有状态流协议,connect()调用成为所有进一步网络通信的开端。这是操作系统的网络堆栈启动上一节描述的握手协议的时刻,如果成功,TCP 流的两端都可以使用。

这意味着 TCP connect()与 UDP 套接字上的相同调用不同,可能会失败。远程主机可能不应答,或者拒绝连接。或者可能发生更模糊的协议错误,如立即接收到 RST(“复位”)分组。因为流连接涉及在两台主机之间建立持久连接,所以另一台主机需要监听并准备好接受您的连接。

在“服务器端”——根据定义,会话伙伴不执行connect()调用,而是接收 connect 调用发起的 SYN 包——传入的连接为 Python 应用生成了一个更重要的事件:创建一个新的套接字!这是因为 TCP 的标准 POSIX 接口实际上涉及两种完全不同的套接字:“被动”监听套接字和主动“连接”套接字。

  • 被动 套接字监听 套接字 维护服务器准备接收连接的“套接字名称”——地址和端口号。这种套接字不能接收或发送任何数据。它不代表任何实际的网络对话。相反,它是服务器如何首先提醒操作系统它愿意在给定的 TCP 端口号上接收传入的连接。
  • 一个活动的、连接的 套接字 被绑定到一个具有特定 IP 地址和端口号的特定远程会话伙伴。它只能用于与那个伙伴来回通话,而且它可以被读取和写入,而不用担心产生的数据如何被分割成包。流看起来非常像管道或文件,在 Unix 系统上,一个连接的 TCP 套接字可以传递给另一个希望从普通文件中读取的程序,而该程序甚至永远不会知道它正在通过网络进行对话。

请注意,虽然被动套接字通过它正在侦听的接口地址和端口号变得唯一(不允许任何其他人获取相同的地址和端口),但是可以有许多主动套接字共享相同的本地套接字名称。例如,一个繁忙的 web 服务器,有一千个客户端都与它建立了 HTTP 连接,它将有一千个活动套接字都绑定到 TCP 端口 80 的公共 IP 地址。活动套接字的独特之处在于四部分坐标,如下所示:

(local_ip, local_port, remote_ip, remote_port)

正是这个四元组,操作系统通过它来命名每个活动的 TCP 连接,并且检查传入的 TCP 包以查看它们的源地址和目的地址是否将它们与系统上任何当前活动的套接字相关联。

一个简单的 TCP 客户端和服务器

看一下清单 3-1 。正如我在前一章所做的,我在这里将两个独立的程序合并成一个清单——因为它们共享一些公共代码,这样客户端和服务器代码可以更容易地一起阅读。

清单 3-1 。简单的 TCP 服务器和客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter03/tcp_sixteen.py
# Simple TCP client and server that send and receive 16 octets

import argparse, socket

def recvall(sock, length):
    data = b''
    while len(data) < length:
        more = sock.recv(length - len(data))
        if not more:
            raise EOFError('was expecting %d bytes but only received'
                           ' %d bytes before the socket closed'
                           % (length, len(data)))
        data += more
    return data

def server(interface, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((interface, port))
    sock.listen(1)
    print('Listening at', sock.getsockname())
    while True:
        sc, sockname = sock.accept()
        print('We have accepted a connection from', sockname)
        print('  Socket name:', sc.getsockname())
        print('  Socket peer:', sc.getpeername())
        message = recvall(sc, 16)
        print('  Incoming sixteen-octet message:', repr(message))
        sc.sendall(b'Farewell, client')
        sc.close()
        print('  Reply sent, socket closed')

def client(host, port):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, port))
    print('Client has been assigned socket name', sock.getsockname())
    sock.sendall(b'Hi there, server')
    reply = recvall(sock, 16)
    print('The server said', repr(reply))
    sock.close()

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Send and receive over TCP')
    parser.add_argument('role', choices=choices, help='which role to play')
    parser.add_argument('host', help='interface the server listens at;'
                        ' host the client sends to')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p)

在第二章的中,我非常仔细地讨论了bind() 的主题,因为你作为参数提供的地址做出了一个重要的选择:它决定了远程主机是否可以尝试连接到我们的服务器,或者你的服务器是否受到保护,不能与外部连接,只能被运行在同一台机器上的其他程序联系。相应地,第二章从仅将自己绑定到环回接口的安全程序列表开始,然后发展到接受来自网络上其他主机的连接的更危险的程序列表。

但是在这里,我将两种可能性合并到一个清单中。使用您从命令行提供的host参数,您可以更安全地选择绑定到 127.0.0.1,或者您可以选择绑定到您机器的一个外部 IP 地址——或者您可以提供一个空字符串来表示您将接受您机器的任何 IP 地址的连接。再次,如果你想记住所有的规则,回顾一下第二章,这些规则同样适用于 TCP 和 UDP 连接和套接字。

您选择的端口号也与您在第二章中为 UDP 选择端口号时具有相同的权重,同样,TCP 和 UDP 在端口号主题上的对称性非常相似,您可以简单地应用您在那里使用的推理来理解为什么在本章中使用了相同的选择。

那么,UDP 的早期成果与构建在 TCP 之上的新客户端和服务器之间有什么区别呢?

客户实际上看起来差不多。它创建一个套接字,用它想要与之通信的服务器的地址运行connect(),然后就可以自由地发送和接收数据了。但除此之外,还有几个不同之处。

首先,TCP connect() 调用——正如我刚才所讨论的——不是本地套接字配置的无关紧要的部分,它在 UDP 的情况下仅仅设置一个默认的远程地址,用于任何后续的send()recv()调用。这里,connect()是一个真实的实时网络操作,它启动客户机和服务器之间的三次握手,以便它们准备好进行通信。这意味着connect()可能会失败,因为您可以在服务器不运行时通过执行客户端来轻松验证。

$ python tcp_deadlock.py client localhost
Sending 16 bytes of data, in chunks of 16 bytes
Traceback (most recent call last):
  ...
ConnectionRefusedError: [Errno 111] Connection refused

第二,您将看到这个 TCP 客户端在某种程度上比 UDP 客户端简单得多,因为它不需要为丢弃的数据包做任何准备。由于 TCP 提供的保证,它可以send()数据,甚至不用停下来检查远程端是否接收到它,并且运行recv()而不必考虑重新传输其请求的可能性。客户端可以放心,网络堆栈将执行任何必要的重新传输,以使其数据通过。

第三,这个程序实际上比等效的 UDP 代码更复杂——这可能会让你感到惊讶,因为尽管有它的保证,对程序员来说,TCP 流似乎比 UDP 数据报更简单。但是正因为 TCP 认为你的输出和输入数据只是没有开始和结束的数据流,所以它可以随心所欲地把它们分成包。因此send()recv() 的含义与之前有所不同。在 UDP 的情况下,它们仅仅意味着“发送这个数据报”或“接收一个数据报”,每个数据报都是原子的:它要么到达,要么不是一个自包含的数据单元。应用永远不会看到只发送了一半或接收了一半的 UDP 数据报。只有完整的数据报才会被传送到 UDP 应用。

但是 TCP 可能会在传输过程中将其数据流拆分成几个不同大小的数据包,然后在接收端逐渐重新组合它们。尽管对于清单 3-1 中的 16 个八位字节的小消息来说,这几乎是不可能的,但是您的代码仍然需要为这种可能性做好准备。对于send()recv()呼叫,TCP 流的结果是什么?

从考虑send()开始。当你执行一个 TCP send() 时,你的操作系统的网络堆栈将面临三种情况之一。

  • 数据可以立即被本地系统的网络堆栈接受,要么是因为网卡可以立即自由传输,要么是因为系统有空间将数据复制到一个临时的传出缓冲区,以便您的程序可以继续运行。在这些情况下,send()立即返回,并且它将返回数据字符串的长度作为其返回值,因为整个字符串正在被传输。
  • 另一种可能是,网卡正忙,此套接字的传出数据缓冲区已满,系统无法(或不愿)分配更多空间。在这种情况下,send()的默认行为只是阻塞,暂停你的程序,直到数据可以被接受传输。
  • 还有最后一种中间可能性:输出缓冲区几乎满了,但还没有满,所以你试图发送的数据的部分可以立即排队。但是数据块的其余部分将不得不等待。在这种情况下,send()会立即完成,并返回从数据字符串开始处接受的字节数,但不会处理其余的数据。

由于最后一种可能性,您不能简单地在流套接字上调用send()而不检查返回值。您必须将一个send()调用放入一个循环中,在部分传输的情况下,这个循环将继续尝试发送剩余的数据,直到整个字节串都被发送完。有时,您会看到在网络代码中使用如下所示的循环来表达这一点:

bytes_sent = 0
while bytes_sent < len(message):
    message_remaining = message[bytes_sent:]
    bytes_sent += s.send(message_remaining)

幸运的是,Python 不会在每次有数据块要发送的时候强迫您自己跳这种舞。作为一个特殊的便利,标准库socket实现提供了一个友好的sendall()方法() ,清单 3-1 使用了这个方法。sendall()不仅比自己做要快,因为它是用 C 实现的,而且(对于那些知道这意味着什么的读者来说)它在循环期间释放了全局解释器锁,这样其他 Python 线程可以无争用地运行,直到所有数据都被传输完。

不幸的是,没有为recv()调用提供等效的标准库包装器,尽管它也有不完全传输的可能。在内部,recv() 的操作系统实现使用的逻辑非常接近发送时使用的逻辑。

  • 如果没有数据可用,那么recv()阻塞,你的程序暂停,直到数据到达。
  • 如果传入缓冲区中已经有足够的数据可用,那么您将获得与您给予recv()的许可一样多的字节。
  • 如果缓冲区只包含一些等待数据,但没有你允许recv()返回的那么多,那么你会立即返回那里发生的事情,即使它没有你请求的那么多。

这就是为什么recv()调用必须在一个循环中。操作系统无法知道这个简单的客户机和服务器正在使用固定宽度的 16 位字节消息。因为它不能猜测输入的数据何时最终会达到你的程序所认为的一个完整的消息,所以它会尽可能快地给你任何数据。

为什么 Python 标准库包含了sendall()却没有recv()方法的等价物?这可能是因为如今固定长度的消息非常少见。大多数协议对于如何分隔传入流的一部分有着复杂得多的规则,而不是简单的“消息总是 16 字节长”的决定在大多数现实世界的程序中,运行recv()的循环要比清单 3-1 中的更复杂,因为一个程序经常要读取或处理部分消息,然后才能猜测还会有多少消息。例如,一个 HTTP 响应由头、一个空行组成,然后在Content-Length头中指定了更多字节的数据。您不知道要运行多少次recv(),直到您至少收到了头,然后解析它们以找出内容长度,这种细节最好留给您的应用,而不是标准库。

每个对话一个套接字

转到清单 3-1 中的服务器代码,你会看到一个与你之前看到的非常不同的模式,这种差异取决于 TCP 流套接字的真正含义。回想一下我们之前的讨论,有两种不同类型的流套接字:监听套接字,服务器使用它们为传入的连接提供一个端口,以及连接套接字,它们代表服务器与特定客户端的对话。

在清单 3-1 中,你可以看到这种区别是如何在实际的服务器代码中实现的。这个链接可能会让您觉得奇怪,因为侦听套接字实际上会返回一个新的、连接的套接字,作为您通过调用accept()获得的值!让我们按照程序清单中的步骤来看看套接字操作发生的顺序。

首先,服务器运行bind() 来声明一个特定的端口。请注意,这还不能决定程序是客户端还是服务器,也就是说,它是主动建立连接还是被动等待接收传入的连接。它只是声明一个特定的端口,或者在一个特定的接口上,或者在所有的接口上,供这个程序使用。如果出于某种原因,客户端希望从其机器上的特定端口访问服务器,而不是简单地使用分配给它们的临时端口号,那么它们也可以使用这个调用。

真正的决策时刻伴随着下一个方法调用,当服务器宣布它想要使用套接字到listen() 。在 TCP 套接字上运行它完全改变了它的特性。在调用了listen()之后,套接字被不可撤销地改变了,并且从这一点开始,再也不能被用来发送或接收数据。这个特定的套接字对象现在永远不会连接到任何特定的客户端。相反,套接字现在只能用于通过其accept()方法接收传入的连接——这种方法你在本书中还没有见过,因为它的目的只是支持监听 TCP 套接字——这些调用中的每一个都等待新的客户端连接,然后返回一个全新的新的套接字,该套接字控制刚刚与它们开始的新对话。

从代码中可以看出,getsockname()对监听和连接的套接字都很有效,在这两种情况下,它都可以让您找出套接字使用的本地 TCP 端口。要了解一个已连接的套接字所链接到的客户机的地址,您可以在任何时候运行getpeername() 方法,或者您可以存储作为第二个返回值从accept()返回的套接字名称。当您运行此服务器时,您会看到两个值为您提供了相同的地址。

$ python tcp_sixteen.py server ""
Listening at ('0.0.0.0', 1060)
Waiting to accept a new connection
We have accepted a connection from ('127.0.0.1', 57971)
  Socket name: ('127.0.0.1', 1060)
  Socket peer: ('127.0.0.1', 57971)
  Incoming sixteen-octet message: b'Hi there, server'
  Reply sent, socket closed
Waiting to accept a new connection

让客户端与服务器建立一个连接,就像这样,产生了前面的输出:

$ python3 tcp_sixteen.py client 127.0.0.1
Client has been assigned socket name ('127.0.0.1', 57971)
The server said b'Farewell, client'

您可以从其余的服务器代码中看到,一旦连接的套接字被accept()返回,它就像客户端套接字一样工作,在它们的通信模式中没有进一步的不对称性。当数据变得可用时,recv()调用返回数据,当您想确保所有数据都被传输时,sendall()是发送整个数据块的最佳方式。

您会注意到,当在服务器套接字上调用listen()时,向它提供了一个整数参数。这个数字表明,在操作系统开始忽略新的连接并推迟任何进一步的三次握手之前,应该允许多少个等待的连接(这些连接还没有被accept()调用创建套接字)进行堆叠。我在示例中使用非常小的值1,因为我一次只支持一个示例客户端连接,但是当我在第七章中谈到网络服务器设计时,我会考虑更大的值。

一旦客户机和服务器完成了它们需要的一切,它们就close()告诉操作系统传输仍然留在输出缓冲区中的任何剩余数据,然后通过前面提到的 FIN-packet 关闭过程结束 TCP 会话。

地址已被使用

在清单 3-1 中还有最后一个你可能会好奇的细节。为什么服务器在尝试绑定到端口之前会小心翼翼地设置套接字选项SO_ REUSEADDR

如果您注释掉该行,然后尝试运行服务器,就可以看到未能设置该选项的后果。起初,你可能认为这没有什么后果。如果您所做的只是停止和启动服务器,那么您将看不到任何效果(这里我启动服务器,然后在终端的提示下用一个简单的 Ctrl+C 终止它):

$ python tcp_sixteen.py server ""
Listening at ('127.0.0.1', 1060)
Waiting to accept a new connection
^C
Traceback (most recent call last):
  ...
KeyboardInterrupt
$ python tcp_sixteen.py server ""
Listening at ('127.0.0.1', 1060)
Waiting to accept a new connection

但是,如果您启动服务器,对其运行客户机,然后尝试终止并重新运行服务器,您会看到很大的不同。当服务器开始备份时,您将收到一条错误消息:

$ python tcp_sixteen.py server
Traceback (most recent call last):
  ...
OSError: [Errno 98] Address already in use

多么神秘!为什么一个可以一遍又一遍重复的bind()会仅仅因为一个客户端已经连接而突然变得不可能?如果您继续尝试在没有SO_REUSEADDR选项的情况下运行服务器,您会发现该地址直到您最后一次连接客户端几分钟后才再次可用。

这种限制的原因是操作系统的网络堆栈非常小心。仅仅在监听的服务器套接字可以立即关闭并被遗忘。但是,一个连接的 TCP 套接字(实际上是在与客户机对话)不会立即消失,即使客户机和服务器可能都关闭了它们的连接并在每个方向上发送 FIN 数据包。为什么呢?因为即使在网络堆栈发送了关闭套接字的最后一个数据包之后,它也无法确定是否收到了该数据包。如果它碰巧被网络丢弃,那么远程终端可能在任何时候都想知道是什么用了这么长时间才发送最后一个包,并重新发送它的 FIN 包,希望最终收到一个应答。

像 TCP 这样可靠的协议显然必须有这样一个停止说话的点;从逻辑上来说,一些最后的包必须被挂起而不被确认,否则系统将不得不承诺无休止的交换“好吧,我们都同意我们都完成了,对吗?”消息,直到机器最终被关闭。然而,即使是最后一个数据包也可能会丢失,需要重新传输几次,另一端才能最终收到它。解决办法是什么?

答案是,从应用的角度来看,一旦一个已连接的 TCP 连接最终关闭,操作系统的网络堆栈实际上会在等待状态下保存一份长达四分钟的记录。RFC 将这些状态命名为关闭等待和时间等待。当关闭的套接字仍然处于这些状态中的任何一个时,任何最终的 FIN 分组都可以被正确地应答。如果 TCP 实现只是忘记了连接,那么它就不能用正确的 ACK 回复 FIN。

因此,一个服务器试图声明一个在过去几分钟内正在运行活动连接的端口,实际上是试图声明一个在某种意义上仍在使用的端口。这就是为什么如果你尝试一个bind()到那个地址,你会返回一个错误。通过指定套接字选项SO_REUSEADDR,您表明您的应用可以拥有一个端口,该端口的旧连接可能仍然在网络上的某个客户端上关闭。实际上,我在编写服务器代码时总是使用SO_REUSEADDR,从来没有遇到过任何问题。

绑定到接口

正如我在第二章讨论 UDP 时所解释的,当你执行bind()操作时,你与一个端口号配对的 IP 地址告诉操作系统你希望从哪个网络接口接收连接。清单 3-1 中的示例调用使用了本地 IP 地址 127.0.0.1,这可以保护您的代码免受来自其他机器的连接。

您可以通过在服务器模式下运行清单 3-1 来验证这一点,如前所示,并尝试从另一台机器连接客户机。

$ python tcp_sixteen.py client 192.168.5.130
Traceback (most recent call last):
  ...
ConnectionRefusedError: [Errno 111] Connection refused

您可以看到,如果您让服务器运行,它甚至没有反应。操作系统甚至不会通知它到其端口的传入连接被拒绝。(请注意,如果您的计算机上运行了防火墙,客户端在尝试连接时可能会挂起,而不是得到友好的“连接被拒绝”异常来告诉它正在发生什么!)

但是,如果您使用空字符串作为主机名来运行服务器,这将告诉 Python bind()例程您愿意接受通过您机器的任何活动网络接口的连接,那么客户端可以从另一台主机成功连接(空字符串是通过在命令行末尾给 shell 加上这两个双引号来提供的)。

$ python tcp_sixteen.py server ""
Listening at ('0.0.0.0', 1060)
Waiting to accept a new connection
We have accepted a connection from ('127.0.0.1', 60359)
  Socket name: ('127.0.0.1', 1060)
  Socket peer: ('127.0.0.1', 60359)
  Incoming sixteen-octet message: b'Hi there, server'
  Reply sent, socket closed
Waiting to accept a new connection

如前所述,我的操作系统使用特殊的 IP 地址 0.0.0.0 来表示“接受任何接口上的连接”,但这种约定在您的操作系统上可能有所不同,Python 通过让您使用空字符串来隐藏这种差异。

僵局

术语死锁用于计算机科学中的各种情况,在这些情况下,共享有限资源的两个程序可能因为糟糕的计划而永远等待对方。事实证明,在使用 TCP 时,这种情况很容易发生。

我前面提到过,典型的 TCP 栈使用缓冲区,这样它们就有地方放置传入的数据包数据,直到应用准备好读取它,并且它们可以收集传出的数据,直到网络硬件准备好传输传出的数据包。这些缓冲区的大小通常非常有限,系统通常不愿意让程序用未发送的网络数据填满所有的 RAM。毕竟,如果远程端还没有准备好处理数据,那么花费系统资源来生成更多的数据是没有意义的。

如果你遵循清单 3-1 中所示的客户端-服务器模式 ,这种限制通常不会给你带来麻烦,在这种模式中,每一端总是在转身向另一个方向发送数据之前读取其伙伴的完整消息。但是,如果您设计的客户机和服务器让太多的数据等待,而没有及时读取这些数据的安排,那么您很快就会遇到麻烦。

看看清单 3-2 中的一个例子,一个服务器和客户端不考虑后果就试图变得有点聪明。在这里,服务器作者做了一些实际上相当聪明的事情。服务器的工作是将任意数量的文本转换成大写字母。认识到客户端请求可以任意大,并且在尝试处理输入流之前尝试读取整个输入流可能会耗尽内存,服务器一次读取和处理 1,024 字节的小数据块。

清单 3-2 。可能死锁的 TCP 服务器和客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter03/tcp_deadlock.py
# TCP client and server that leave too much data waiting

import argparse, socket, sys

def server(host, port, bytecount):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen(1)
    print('Listening at', sock.getsockname())
    while True:
        sc, sockname = sock.accept()
        print('Processing up to 1024 bytes at a time from', sockname)
        n = 0
        while True:
            data = sc.recv(1024)
            if not data:
                break
            output = data.decode('ascii').upper().encode('ascii')
            sc.sendall(output)  # send it back uppercase
            n += len(data)
            print('\r  %d bytes processed so far' % (n,), end=' ')
            sys.stdout.flush()
        print()
        sc.close()
        print('  Socket closed')

def client(host, port, bytecount):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    bytecount = (bytecount + 15) // 1616  # round up to a multiple of 16
    message = b'capitalize this!'  # 16-byte message to repeat over and over

    print('Sending', bytecount, 'bytes of data, in chunks of 16 bytes')
    sock.connect((host, port))

    sent = 0
    while sent < bytecount:
        sock.sendall(message)
        sent += len(message)
        print('\r  %d bytes sent' % (sent,), end=' ')
        sys.stdout.flush()

    print()
    sock.shutdown(socket.SHUT_WR)

    print('Receiving all the data the server sends back')

    received = 0
    while True:
        data = sock.recv(42)
        if not received:
            print('  The first data received says', repr(data))
        if not data:
            break
        received += len(data)
        print('\r  %d bytes received' % (received,), end=' ')

    print()
    sock.close()

if __name__ == '__main__':
    choices = {'client': client, 'server': server}
    parser = argparse.ArgumentParser(description='Get deadlocked over TCP')
    parser.add_argument('role', choices=choices, help='which role to play')
    parser.add_argument('host', help='interface the server listens at;'
                        ' host the client sends to')
    parser.add_argument('bytecount', type=int, nargs='?', default=16,
                        help='number of bytes for client to send (default 16)')
    parser.add_argument('-p', metavar='PORT', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    function = choices[args.role]
    function(args.host, args.p, args.bytecount)

它可以很容易地分割工作——不需要做框架或分析——因为它只是试图在普通 ASCII 字符上运行upper()字符串方法。这是一个可以在每个输入块上单独执行的操作,而不用担心之前或之后的块。如果服务器试图运行像title()这样更复杂的字符串操作,事情就不会这么简单了,如果单词碰巧跨越了块边界而没有正确地重新组合,那么这个字符串操作就会在单词中间大写字母。例如,如果一个特定的数据流被分割成 16 字节的块,那么错误就会像这样出现:

>>> message = 'the tragedy of macbeth'
>>> blocks = message[:16], message[16:]
>>> ''.join(b.upper() forin blocks)   # works fine
'THE TRAGEDY OF MACBETH'
>>> ''.join(b.title() forin blocks)   # whoops
'The Tragedy Of MAcbeth'

在固定长度的块 上分割的同时处理文本也不适用于 UTF-8 编码的 Unicode 数据,因为多字节字符可能会在两个二进制块之间的边界上分割。在这种情况下,服务器必须比这个例子更加小心,并且在一个数据块和下一个数据块之间传递某种状态。

在任何情况下,像这样一次处理一个块的输入对于服务器来说都是非常智能的,即使这里用于说明的 1,024 字节的块大小对于今天的服务器和网络来说实际上是一个非常小的值。通过分段处理数据并立即发送响应,服务器限制了每次必须保存在内存中的数据量。这样设计的服务器可以同时处理数百台客户机,每台客户机发送的数据流总计数千兆字节,而不会增加内存或其他硬件资源的负担。

对于小数据流,清单 3-2 中的客户端和服务器看起来工作得很好。如果您启动服务器,然后使用指定适度字节数的命令行参数运行客户端,比如说,要求它发送 32 字节的数据,那么它将获得全部大写的文本。为简单起见,它会将您提供的任何值四舍五入到 16 字节的倍数。

$ python tcp_deadlock.py client 127.0.0.1 32
Sending 32 bytes of data, in chunks of 16 bytes
  32 bytes sent
Receiving all the data the server sends back
  The first data received says b'CAPITALIZE THIS!CAPITALIZE THIS!'
  32 bytes received

服务器将报告它确实代表其最近的客户端处理了 32 个字节。顺便说一下,服务器需要运行在同一台机器上,这个脚本使用本地主机 IP 地址来使示例尽可能简单。

Processing up to 1024 bytes at a time from ('127.0.0.1', 60461)
  32 bytes processed so far
  Socket closed

因此,当用少量数据测试时,这段代码看起来工作得很好。事实上,它也可能适用于更大的数量。尝试用数百或数千字节运行客户端,看看它是否继续工作。

顺便说一下,第一个数据交换示例向您展示了我之前描述的recv()的行为。即使服务器要求接收 1,024 个字节,如果这是可用的数据量,并且还没有来自客户端的数据,那么recv(1024)也很乐意只返回 16 个字节。

但是这个客户端和服务器可能会被推到可怕的境地。如果你尝试一个足够大的值,那么灾难就来了!试着使用客户端发送一个大的数据流,比如说,一个总计 1gb 的数据流。

$ python tcp_deadlock.py client 127.0.0.1 1073741824

您将看到客户端和服务器都在紧张地更新它们的终端窗口,它们气喘吁吁地向您更新它们发送和接收的数据量。这些数字会不断攀升,直到突然之间,两个连接都冻结了。实际上,如果您仔细观察,您会看到服务器首先停止,然后客户端很快停止。在我写这一章的 Ubuntu 笔记本电脑上,在它们停止之前处理的数据量各不相同,但是在我刚刚在我的笔记本电脑上完成的测试运行中,Python 脚本停止了,服务器说:

$ python tcp_deadlock.py server ""
Listening at ('0.0.0.0', 1060)
Processing up to 1024 bytes at a time from ('127.0.0.1', 60482)
  4452624 bytes processed so far

并且客户端在写入其输出数据流时被冻结了大约 350,000 字节。

$ python tcp_deadlock.py client "" 16000000
Sending 16000000 bytes of data, in chunks of 16 bytes
  8020912 bytes sent

为什么客户端和服务器都停止了?

答案是服务器的输出缓冲区和客户机的输入缓冲区最终都已填满,TCP 使用其窗口调整协议来通知这一事实,并阻止套接字发送额外的数据,这些数据将被丢弃并在以后重新发送。

为什么这会导致僵局?考虑每个数据块传输时会发生什么。客户端用sendall()发送。然后服务器通过recv()接受它,处理它,并通过另一个sendall()调用将它的大写版本传输回来。然后呢。嗯,没什么!客户端从不运行任何recv()调用——当它仍有数据要发送时——因此越来越多的数据备份,直到操作系统缓冲区不再愿意接受更多数据。

在前面显示的运行过程中,在网络堆栈确定客户端的传入队列已满之前,操作系统在其中缓冲了大约 4MB。在这一点上,服务器阻塞了它的sendall()调用,它的进程被操作系统暂停,直到阻塞被清除,它可以发送更多的数据。随着服务器不再处理数据或运行更多的recv()调用,现在轮到客户机开始备份数据了。操作系统似乎已经将它愿意在该方向排队的数据量限制在 3.5MB 左右,因为客户端在最终停止之前已经大致产生了数据。

在你自己的系统上,你可能会发现达到了不同的极限;上述数字是任意的,基于我的笔记本电脑此刻的心情。它们根本不是 TCP 工作方式所固有的。

这个例子的目的是教你两件事——当然,除此之外,显示如果立即可用的字节数更少,那么recv(1024)确实返回少于 1,024 的字节数!

首先,这个例子应该使网络连接两端的 TCP 栈中有缓冲区 的想法更加具体。这些缓冲区可以临时保存数据,这样,如果数据包到达时,它们的读取器恰好不在recv()调用内,就不会被丢弃并最终被重新发送。但是缓冲区不是无限的。最终,试图写入从未被接收或处理的数据的 TCP 例程将发现自己不再能够写入,直到一些数据最终被读取并且缓冲区开始变空。

第二,这个例子清楚地表明了协议 中包含的危险,这些协议没有交替锁定步骤,客户端请求有限数量的数据,然后等待服务器应答或确认。如果一个协议没有严格要求服务器读取一个完整的请求,直到客户端完成发送,然后在另一个方向发送一个完整的响应,那么像这里创建的情况可能会导致两者都冻结,除了手动终止程序,然后重写它以改进它的设计。

但是,网络客户机和服务器应该如何处理大量数据 而不进入死锁呢?事实上,有两种可能的答案。首先,他们可以使用套接字选项来关闭阻塞,这样像send()recv()这样的调用如果发现它们还不能发送任何数据,就会立即返回。你将在第七章的中了解到更多关于这个选项的信息,在那里你将认真地寻找构建网络服务器程序的可能方法。

或者,程序可以使用几种技术中的一种来一次处理来自几个输入的数据,或者通过分成单独的线程或进程(一个任务是将数据发送到套接字,另一个任务是将数据读取回来),或者通过运行操作系统调用,如select()poll(),让它们同时等待繁忙的传出和传入套接字,并对准备好的套接字做出响应。这些也将在第七章中探讨。

最后,请注意,当您使用 UDP 时,上述情况永远不会发生。这是因为 UDP 不实现流量控制。如果到达的数据报多于可以处理的数据报,那么 UDP 可以简单地丢弃其中一些数据报,让应用去发现它们丢失了。

封闭连接、半开连接

从前面的例子中可以看出,在另一个不同的问题上,还有两点需要说明。

首先,清单 3-2 向您展示了当到达文件结尾时 Python 套接字对象的行为。就像 Python 文件对象在没有剩余数据时返回一个空字符串一样,套接字在关闭时只返回一个空字符串。

在清单 3-1 中,我从不担心这个问题,因为在那种情况下,我对协议施加了足够严格的结构——交换一对正好 16 字节的消息——当通信完成时,我不需要关闭套接字来发送信号。客户机和服务器可以发送消息,同时让套接字保持打开状态,稍后再关闭它们的套接字,而不用担心有人在等待它们关闭。

但是在清单 3-2 中,客户端发送——因此服务器也处理并发回——任意数量的数据,其长度仅由用户在命令行输入的数字决定。所以你可以在代码中看到两次相同的模式:一个while循环,一直运行到最后看到一个从recv()返回的空字符串。注意,一旦到达第七章的并探索非阻塞套接字,这种正常的 Pythonic 模式将不再工作,其中recv()可能仅仅因为此刻没有可用的数据而引发异常。在这种情况下,使用其他技术来确定套接字是否已经关闭。

其次,您将看到客户端在发送完传输后,在套接字上发出一个 shutdown()调用。这解决了一个重要问题。如果服务器将一直读取,直到看到文件结束,那么客户端将如何避免在套接字上执行完整的close()操作,从而禁止自己运行许多recv()调用来接收服务器的响应呢?解决方案是“半关闭”套接字——也就是说,在不破坏套接字本身的情况下永久关闭一个方向的通信。在这种状态下,服务器不能再读取任何数据,但是它仍然可以在另一个方向上发送任何剩余的回复,该方向仍然是开放的。

如清单 3-2 中的所示,shutdown()调用可以用来结束双向套接字中的任何一个方向的通信。它的参数可以是三个符号之一。

  • 这是最常用的值,因为在大多数情况下,程序知道自己的输出何时完成,但不一定知道它的对话伙伴何时结束。这个值表示调用者将不再向套接字写入数据,从另一端读取的数据应该响应没有更多数据并指示文件结束。
  • SHUT_RD:这用于关闭传入的套接字流,这样,如果您的对等方试图在套接字上向您发送更多数据,就会遇到文件结束错误。
  • SHUT_RDWR:关闭套接字上的双向通信。起初,这可能看起来没什么用,因为您也可以只在套接字上执行一个close(),并且通信在两个方向上都是类似地结束的。关闭一个套接字和双向关闭套接字之间的区别是相当高级的。如果你的操作系统上的几个程序被允许共享一个套接字,那么close()仅仅是结束你的进程与套接字的关系,但是只要另一个进程还在使用它,它就保持打开。另一方面,shutdown()方法总是会立即禁用每个使用它的人的套接字。

由于不允许通过标准的socket()调用创建单向套接字,许多只需要在一个套接字上单向发送信息的程序员会先创建它,然后——一旦连接上了——立即运行shutdown(),向他们不需要的方向发送。这意味着,如果与之通信的对等体意外地试图以不应该的方向发送数据,操作系统缓冲区不会被不必要地填满。

在应该是单向的套接字上立即运行shutdown(),还会为混淆并试图发送数据的对等体提供更明显的错误消息。否则,意外数据要么会被忽略,要么甚至会填满缓冲区并导致死锁,因为它永远不会被读取。

像文件一样使用 TCP 流

由于 TCP 支持数据流,它们可能已经让您想起了普通文件,普通文件也支持读写顺序数据作为基本操作。Python 很好地将这些概念分开。文件对象可以read()write(),而套接字只能send()recv()。没有一种物体能同时做到这两点。(与底层 POSIX 接口相比,这实际上是一个更干净、更可移植的概念划分,它允许 C 程序员不加区别地调用套接字上的read()write(),就像它是一个普通的文件描述符一样。)

但是有时候你会想把一个套接字当作一个普通的 Python 文件对象——通常是因为你想把它传递给这样的代码,像很多 Python 模块,比如picklejsonzlib,可以直接从文件中读写数据。为此,Python 在每个返回 Python 文件对象的套接字上提供了一个makefile()方法,该对象真正在幕后调用recv()send()

>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> hasattr(sock, 'read')
False
>>> f = sock.makefile()
>>> hasattr(f, 'read')
True

像 Ubuntu 和 Mac OS X 这样的 Unix 衍生系统上的套接字,像普通的 Python 文件一样,也有一个fileno()方法,让您发现它们的文件描述符编号,以防您需要将它提供给低级调用。当你在第七章的中探索select()poll()时,你会发现这很有帮助。

摘要

TCP 驱动的“流”套接字做任何必要的事情,包括重新传输丢失的数据包,重新排序无序到达的数据包,以及将大量数据流拆分为适合您的网络的最佳大小的数据包,以支持两个套接字之间的网络数据流的传输和接收。

与 UDP 一样,TCP 使用端口号来区分可能存在于一台机器上的许多流端点。想要接受传入的 TCP 连接的程序需要bind()到一个端口,在套接字上运行listen(),然后进入一个循环,该循环反复运行accept()来为每个传入的连接接收一个新的套接字,通过该套接字它可以与每个连接的特定客户端对话。想要连接到现有服务器端口的程序只需要创建一个套接字和一个地址的connect()

服务器通常希望在它们的套接字上设置选项,以免服务器最后一次运行时在同一个端口上关闭的旧连接阻止操作系统允许绑定。

使用send()recv()实际发送和接收数据。一些运行在 TCP 之上的协议会标记它们的数据,以便客户端和服务器自动知道通信何时完成。其他协议将 TCP 套接字视为真正的流,并发送和接收,直到到达文件结尾。shutdown()套接字方法可用于在一个套接字上产生一个方向的文件结束(所有套接字本质上都是双向的),同时保持另一个方向开放。

如果两个对等体被写入,使得套接字填充越来越多的永远不会被读取的数据,则会发生死锁。最终,一个方向将不再能够send()并可能永远等待积压清除。

如果你想把一个套接字传递给一个知道如何读写普通文件对象的 Python 例程,makefile() socket 方法会给你一个 Python 对象,当调用者需要读写时,这个对象会在后台调用recv()send()

四、套接字名称和 DNS

在前两章中,我们已经学习了 UDP 和 TCP 的基础知识,这是 IP 网络上两种主要的数据传输方式,现在是时候后退一步,讨论两个需要解决的更大的问题了,不管您使用哪种数据传输方式。在这一章中,我将讨论网络地址的主题,并且我将描述允许名字被解析为原始 IP 地址的分布式服务。

主机名和套接字

我们很少在浏览器或电子邮件客户端输入原始 IP 地址。相反,我们输入域名。一些域名标识整个组织,如python.orgbbc.co.uk,而另一些域名则指定特定的主机或服务,如www.google.comasaph.rhodesmill.org。一些网站让你通过简单地输入asaph来缩写一个主机名,他们会自动为你填写剩下的名字,假设你指的是同一个网站的asaph机器。然而,不管任何本地定制,指定一个包含所有部分(包括顶级域名)的完全限定域名总是正确的。

一个*顶级域名(TLD)*的想法曾经很简单:要么是。com,。net,。org,。gov,。mil,或者国际公认的两个字母的国家代码。uk。但是今天,许多其他更无聊的顶级域名,如.beer正在增加,这将使区分完全合格和部分合格域名变得更加困难(除非你试图记住顶级域名的整个列表!).

通常,每个 TLD 都有自己的一组服务器,并由负责授予 TLD 下的域所有权的组织运行。当你注册一个域名时,他们会在他们的服务器上添加一个条目。然后,当在世界任何地方运行的客户端想要解析您的域内的名称时,顶级服务器可以将该客户端转到您自己的域服务器,以便您的组织可以返回它想要的您创建的各种主机名的地址。使用顶级域名和推荐系统来回答域名请求的全球服务器集合一起提供了域名服务(DNS)

前两章已经向您介绍了这样一个事实,即套接字不能像数字或字符串那样用一个简单的 Python 值来命名。相反,TCP 和 UDP 都使用整数端口号在可能运行的许多不同的应用之间共享单个机器的 IP 地址,因此地址和端口号必须组合起来才能产生一个套接字名称,如下所示:

('18.9.22.69', 80)

虽然您可能已经能够从前几章中获得一些关于套接字名称的零散事实——比如第一项可以是主机名或带点的 IP 地址——但是现在是时候更深入地研究整个主题了。

您还记得,在创建和使用套接字的过程中,套接字名称在几个方面非常重要。作为参考,这里列出了所有主要的套接字方法,它们都需要某种套接字名称作为参数:

  • mysocket.accept() :每次在有传入连接准备移交给应用的侦听 TCP 流套接字上调用此函数时,它都会返回一个元组,其第二项是已连接的远程地址(元组中的第一项是连接到该远程地址的新套接字)。
  • mysocket.bind (address):这将给定的本地地址分配给套接字,以便传出的数据包有一个始发地址,并且来自其他机器的任何传入连接都有一个它们可以连接的名称。
  • mysocket.connect (address):这建立了通过这个套接字发送的数据将被定向到给定的远程地址。对于 UDP 套接字,如果调用者使用send()而不是sendto()recv()而不是recvfrom(),但不立即执行任何网络通信,那么这只是设置所使用的默认地址。但是,对于 TCP 套接字,这实际上是使用三次握手与另一台机器协商新的流,如果协商失败,将引发 Python 异常。
  • mysocket.getpeername() :返回该套接字连接的远程地址。
  • mysocket.getsockname() :返回这个套接字自己的本地端点的地址。
  • mysocket.recvfrom(...) :对于 UDP 套接字,它返回一个元组,该元组将一串返回的数据与接收它的地址配对。
  • mysocket.sendto (data, address):一个未连接的 UDP 端口使用这种方法在一个特定的远程地址发送数据包。

你有它!这些都是与套接字地址有关的主要套接字操作,都在一个地方,因此您对后面的注释有一些了解。一般来说,上述任何方法都可以接收或返回后面的任何类型的地址,这意味着无论您使用的是 IPv4、IPv6 还是我在本书中不涉及的不太常见的地址族,它们都可以工作。

五插座坐标

在研究第二章和第三章中的示例程序时,您特别注意了它们的套接字使用的主机名和 IP 地址。但是这些只是在每个 socket 对象的构造和部署过程中做出的五个主要决策的最后两个坐标。回想一下,步骤是这样的:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('localhost', 1060))

您可以看到这里指定了四个值:两个用于配置套接字,两个用于处理bind()调用。实际上有第五个可能的坐标,因为socket()接受第三个可选参数,总共有五个选择。我将依次讨论它们,从socket()的三个可能参数开始。

首先,地址族做出最大的决定:它从一台特定机器可能连接的多种网络中指定你想与之对话的网络。

在本书中,我将始终使用值AF_INET来表示地址族,因为我相信撰写关于 IP 网络的文章将最适合绝大多数 Python 程序员,同时给你提供在 Linux、Mac OS 甚至 Windows 上工作的技能。然而,如果你导入socket模块,打印出dir(socket),并寻找以AF_(“地址族”)开头的符号,你会看到其他选择,它们的名字你可能认识,比如 AppleTalk 和蓝牙。在 POSIX 系统上特别流行的是AF_UNIX地址族,它提供了非常类似于互联网套接字的连接,但是它通过“连接”到文件名而不是主机名和端口号,直接在同一台机器上的程序之间运行。

其次,在地址族之后是套接字类型。它选择您想要在所选网络上使用的特定类型的通信技术。您可能会猜测,每个地址族都代表完全不同的套接字类型,您必须去查找每一种类型。毕竟除了AF_INET之外还有什么地址家族是要呈现 UDP 和 TCP 这样的套接字类型的?

幸运的是,这种怀疑是错误的。尽管 UDP 和 TCP 确实非常特定于AF_INET协议族,但是套接字接口设计者决定为基于包的套接字的广泛概念创建更通用的名称。这被称为SOCK_DGRAM,以及可靠的流量控制数据流的广义概念,如你所见,被称为SOCK_STREAM。因为许多地址族支持这两种机制中的一种或两种,所以只需要这两个符号就可以涵盖各种不同地址族下的许多协议。

socket()调用中的第三个字段,即协议,很少使用,因为一旦您指定了地址族和套接字类型,您通常会将可能的协议缩小到只有一个主要选项。因此,程序员通常不指定它,或者他们提供值 0 来强制自动选择它。如果你想要一个 IP 下的流,系统知道选择 TCP。如果你想要数据报,那么它选择 UDP。这就是为什么本书中没有一个socket()调用有第三个参数:它在实践中几乎从不需要。在socket模块中查找以IPPROTO开头的名字,以获得为AF_INET家族定义的协议的一些例子。你会看到这本书列出了两个名字IPPROTO_TCPIPPROTO_UDP

最后,用于建立连接的第四个和第五个值是 IP 地址和端口号,这在前两章中有详细说明。

我们应该立即退后一步,注意到正是由于我们对前三个坐标的特定选择,我们的套接字名称才具有两个组成部分:主机名和端口。如果你选择了 AppleTalk 或 ATM 或 Bluetooth 作为你的地址族,那么可能需要一些其他的数据结构,而不是一个内部包含一个字符串和一个整数的元组。因此,我在本节中提到的五个坐标实际上是创建套接字所需的三个固定坐标,然后是您的特定地址族需要您使用的更多坐标,以便建立网络连接。

IPv6

现在,在解释了所有这些之后,事实证明这本书实际上需要在迄今为止使用的AF_INET之外引入一个额外的地址族:名为AF_INET6的 IPv6 地址族,这是通向未来的道路,在未来世界不会而不是最终耗尽 IP 地址。

一旦旧的阿帕网真正开始起飞,它选择的 32 位地址名称——这在计算机内存以千字节计量的时代是很有意义的——成为一个明显而令人担忧的限制。只有 40 亿个可用的地址为地球上的每个人提供了不到一个 IP 地址,这意味着一旦每个人都有了电脑和智能手机,那就真的麻烦了!

尽管今天互联网上只有一小部分计算机通过其互联网服务提供商使用 IPv6 与全球网络进行通信(其中“今天”是 2014 年 6 月),但使您的 Python 程序与 IPv6 兼容的必要步骤非常简单,因此您应该继续尝试编写代码,为未来做好准备。

在 Python 中,可以通过检查socket模块中的has_ipv6布尔属性来直接测试底层平台是否支持 IPv6。

>>> import socket
>>> socket.has_ipv6
True

请注意,这并不能而不是告诉您实际的 IPv6 接口是否已启动和配置,并且当前是否可以用于向任何地方发送数据包!这纯粹是断言 IPv6 支持是否已经编译到操作系统中,而不是断言它是否正在使用中。

如果一个接一个地列出 IPv6 将为您的 Python 代码带来的不同,听起来可能会令人望而生畏。

  • 如果您被要求在 IPv6 网络上运行,您的套接字必须使用家族AF_INET6创建。
  • 套接字名称不再仅仅由两部分组成——地址和端口号。相反,它们还可以包含提供“流”信息和“范围”标识符的附加坐标。
  • 您可能已经从配置文件或命令行参数中读到的漂亮的 IPv4 八位字节(如18.9.22.69)现在有时会被 IPv6 主机地址代替,而且您可能还没有很好的正则表达式来表示它们。它们有很多冒号,可以包含十六进制数字,通常看起来很难看。

IPv6 过渡的好处不仅在于它将提供数量惊人的地址,而且该协议比 IPv4 的大多数实现更全面地支持诸如链路层安全之类的东西。

但是,如果您习惯于编写笨重的老式代码,通过自己设计的正则表达式扫描或汇集 IP 地址和主机名,那么刚才列出的更改听起来可能会有很多麻烦。换句话说,如果您一直从事以任何形式解释地址的工作,您可能会认为向 IPv6 的过渡会让您编写比以前更复杂的代码。不要担心:我的实际建议是您将地址解释和扫描中解脱出来!下一节将向您展示如何操作。

现代地址解析

为了使您的代码简单、强大,并且不受从 IPv4 到 IPv6 过渡的复杂性的影响,您应该将注意力转向 Python socket 用户武器库中最强大的工具之一:getaddrinfo()

getaddrinfo()函数位于socket模块中,与大多数其他涉及地址的操作一起。除非您正在做一些专门的事情,否则它可能是您需要用来将用户指定的主机名和端口号转换成套接字方法可以使用的地址的唯一例程。

它的方法很简单。使用socket模块中的旧例程时,需要一点一点地解决寻址问题,而不是这样,它让您在一次调用中指定您所知道的关于连接的一切。作为响应,它返回我前面讨论过的所有坐标,这是创建套接字并将其连接到指定目的地所必需的。

它的基本用法很简单,它是这样的(注意,pprint“pretty print”模块与网络无关,但它在显示元组列表方面比普通的print函数做得更好):

>>> from pprint import pprint
>>> infolist = socket.getaddrinfo('gatech.edu', 'www')
>>> pprint(infolist)
[(2, 1, 6, '', ('130.207.244.244', 80)),
 (2, 2, 17, '', ('130.207.244.244', 80))]
>>> info = infolist[0]
>>> info[0:3]
(2, 1, 6)
>>> s = socket.socket(*info[0:3])
>>> info[4]
('130.207.244.244', 80)
>>> s.connect(info[4])

这里名为info的变量包含了创建套接字并使用它建立连接所需的一切。它提供了一个系列、一个类型、一个协议、一个规范名称,最后是一个地址。提供给getaddrinfo()的参数有哪些?我问过连接到主机gatech.edu的 HTTP 服务的可能方法,返回的二元列表告诉你有两种方法:要么创建一个使用IPPROTO_TCP(协议号6)的SOCK_STREAM套接字(套接字类型1),要么使用一个使用IPPROTO_UDP(用整数17表示的协议)的SOCK_ DGRAM (套接字类型2)套接字。

是的,前面的回答表明 HTTP 官方支持 TCP 和 UDP,至少根据发布端口号的官方组织是这样的。当您稍后从脚本中调用getaddrinfo()时,您通常会指定您想要哪种套接字,而不是将答案留给运气。

如果您在代码中使用getaddrinfo(),那么与第二章和第三章中的清单不同,它们使用真实的符号,如AF_INET 只是为了更清楚地说明底层套接字机制是如何工作的,您的生产 Python 代码将不会引用来自socket模块的任何符号,除了那些向getaddrinfo()解释您想要哪种地址的符号。相反,您将使用getaddrinfo()返回值中的前三项作为socket()构造函数的参数,然后使用第五项作为任何地址感知调用的地址,如本章第一节中列出的connect()

从前面的代码片段中可以看出,getaddrinfo()通常不仅允许主机名,而且允许端口名是像'www'这样的符号,而不是整数,如果用户想要提供像wwwsmtp这样的符号端口号,而不是 80 或 25,就不需要旧的 Python 代码进行额外的调用。

在讨论getaddrinfo()支持的所有选项之前,看看它是如何支持三种基本网络操作的会更有用。我将按照您在套接字上执行操作的顺序来处理它们:绑定、连接,然后识别向您发送信息的远程主机。

使用 getaddrinfo()将服务器绑定到端口

如果您希望向bind()提供一个地址,或者因为您正在创建一个服务器套接字,或者因为某种原因您希望您的客户端从一个可预测的地址连接到其他人,那么您将调用getaddrinfo(),使用None作为主机名,但是填充端口号和套接字类型。注意,在这里,就像在下面的getaddrinfo()调用中一样,零在应该包含数字的字段中充当通配符:

>>> from socket import getaddrinfo
>>> getaddrinfo(None, 'smtp', 0, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
[(2, 1, 6, '', ('0.0.0.0', 25)), (10, 1, 6, '', ('::', 25, 0, 0))]
>>> getaddrinfo(None, 53, 0, socket.SOCK_DGRAM, 0, socket.AI_PASSIVE)
[(2, 2, 17, '', ('0.0.0.0', 53)), (10, 2, 17, '', ('::', 53, 0, 0))]

这里我问了两个不同的问题,第一个问题使用了一个字符串端口标识符,第二个问题使用了一个原始的数字端口号。首先,我问如果我想使用 TCP 为 SMTP 流量提供服务,我应该将套接字bind()连接到哪个地址。其次,我问了关于使用 UDP 服务端口 53 (DNS)流量的问题。我得到的答案是适当的通配符地址,它将允许您绑定到本地机器上的每个 IPv4 和每个 IPv6 接口,在每种情况下都具有套接字系列、套接字类型和协议的所有正确值。

如果您想bind()到一个特定的 IP 地址,您知道这个 IP 地址被配置为您正在运行的机器的本地地址,那么省略AI_PASSIVE标志,只指定主机名。例如,这里有两种方法可以尝试绑定到localhost:

>>> getaddrinfo('127.0.0.1', 'smtp', 0, socket.SOCK_STREAM, 0)
[(2, 1, 6, '', ('127.0.0.1', 25))]
>>> getaddrinfo('localhost', 'smtp', 0, socket.SOCK_STREAM, 0)
[(10, 1, 6, '', ('::1', 25, 0, 0)), (2, 1, 6, '', ('127.0.0.1', 25))]

您可以看到,为本地主机提供 IPv4 地址会限制您只能通过 IPv4 接收连接,而使用符号名localhost(至少在我的 Linux 笔记本电脑上有一个配置良好的/etc/hosts文件)可以为机器提供 IPv4 和 IPv6 本地名称。

顺便说一句,此时你可能已经在问的一个问题是,当你声称你想要提供一个基本服务,而getaddrinfo()给了你几个地址让你使用时,你到底应该做什么——你当然不能创建一个套接字并bind()它到多个地址!在第七章中,我将介绍一些你可以使用的技术,如果你正在编写服务器代码,并且想要几个绑定的服务器套接字同时运行的话。

使用 getaddrinfo()连接到服务

除非您绑定到一个本地地址来自己提供服务,否则您将使用getaddrinfo()来了解如何连接到其他服务。在查找服务时,您可以使用空字符串来表示您想要使用环回接口连接回本地主机,或者提供一个给出 IPv4 地址、IPv6 地址或主机名的字符串来命名您的目的地。

当你准备connect()sendto()一项服务时,用AI_ADDRCONFIG标志调用getaddrinfo(),它会过滤掉任何你的计算机无法到达的地址。例如,一个组织可能同时拥有 IPv4 和 IPv6 范围的 IP 地址。如果您的特定主机只支持 IPv4,那么您会希望过滤的结果只包括该系列中的地址。为了应对这样的情况,即本地机器只有一个 IPv6 网络接口,但是您所连接的服务只支持 IPv4,您还需要指定AI_V4MAPPED来返回重新编码为 IPv6 地址的 IPv4 地址,您可以实际使用这些地址。

将这些部分放在一起,在连接之前,您通常会这样使用getaddrinfo():

>>> getaddrinfo('ftp.kernel.org', 'ftp', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
[(2, 1, 6, '', ('204.152.191.37', 21)),
 (2, 1, 6, '', ('149.20.20.133', 21))]

作为回报,您已经得到了您想要的东西:一个列表,列出了通过 TCP 连接到名为ftp.kernel.org的主机的 FTP 端口的所有连接方式。请注意,返回了几个 IP 地址,因为为了分散负载,此服务位于 Internet 上的几个不同地址。当多个地址像这样返回时,通常应该使用返回的第一个地址,只有当连接尝试失败时,才应该尝试剩余的地址。通过遵循远程服务的管理员希望您尝试联系他们的服务器的顺序,您将提供他们想要的工作负载。

下面是另一个查询,询问我如何从我的笔记本电脑连接到 IANA 的 HTTP 接口,该接口首先分配端口号:

>>> getaddrinfo('iana.org', 'www', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
[(2, 1, 6, '', ('192.0.43.8', 80))]

IANA 网站实际上是一个很好的展示AI_ADDRCONFIG标志效用的网站,因为像任何其他好的互联网标准组织一样,它的网站已经支持 IPv6。碰巧我的笔记本电脑只能在它当前连接的无线网络上使用 IPv4,所以前面的调用很小心地只返回了一个 IPv4 地址。但是,如果您去掉第六个参数中精心选择的标志,那么您就可以看到无法使用的 IPv6 地址。

>>> getaddrinfo('iana.org', 'www', 0, socket.SOCK_STREAM, 0)
[(2, 1, 6, '', ('192.0.43.8', 80)),
 (10, 1, 6, '', ('2001:500:88:200::8', 80, 0, 0))]

如果您不打算自己使用这些地址,而是要向其他主机或程序提供某种目录信息,这将非常有用。

向 getaddrinfo()请求规范的主机名

您经常遇到的最后一种情况是,您要么正在建立一个新的连接,要么刚刚在您自己的一个服务器套接字上接受了一个传入的连接,并且您想知道正式属于您的套接字另一端的 IP 地址的主机名。

虽然这种愿望是可以理解的,但请注意,它伴随着一个严重的危险:事实上,当你的机器执行反向查找时,IP 地址的所有者可以让他们的 DNS 服务器返回他们想要的任何东西作为规范名称!他们可以自称为google.compython.org或任何他们想要的人。当你问他们哪个主机名属于他们的一个 IP 地址时,他们可以完全控制鹦鹉学舌般地回答你的字符串。

在信任规范名称查找(也称为反向 DNS 查找,因为它将 IP 地址映射到主机名,而不是主机名映射到 IP 地址)之前,您可能需要查找返回的名称,看看它是否真正解析为原始 IP 地址。如果不是,那么要么主机名是故意误导,要么它是来自一个域的善意回答,该域的正向和反向名称和 IP 地址没有正确配置以使它们匹配。

规范名称查找成本很高。它们需要通过全球 DNS 服务进行额外的往返,因此在进行日志记录时通常会被忽略。停下来反向查找每一个建立连接的 IP 地址的服务往往又慢又笨拙,系统管理员试图让系统更好地响应的一个经典举措是记录裸露的 IP 地址。如果其中一个导致了问题,当您在日志文件中看到它时,您总是可以手动查找它。

但是,如果您很好地使用了主机的规范名称,并且想要尝试查找,那么只需在打开了AI_CANONNAME标志的情况下运行getaddrinfo(),它返回的任何元组的第四项——在前面的示例中是空字符串——将包含规范名称:

>>> getaddrinfo('iana.org', 'www', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED | socket.AI_CANONNAME)
[(2, 1, 6, '43-8.any.icann.org', ('192.0.43.8', 80))]

您还可以向getaddrinfo()提供已经连接到远程对等体的套接字的名称,并获得一个规范的名称作为回报。

>>> mysock = server_sock.accept()
>>> addr, port = mysock.getpeername()
>>> getaddrinfo(addr, port, mysock.family, mysock.type, mysock.proto,
...            socket.AI_CANONNAME)
[(2, 1, 6, 'rr.pmtpa.wikimedia.org', ('208.80.152.2', 80))]

同样,只有当 IP 地址的所有者恰好有一个为其定义的名称时,这才会起作用。互联网上的许多 IP 地址不提供有用的反向名称,因此您无法知道哪个主机真正联系了您,除非您使用加密来验证与您通信的对等方。

其他 getaddrinfo()标志

刚才给出的例子演示了三个最重要的getaddrinfo()标志的操作。可用的标志因操作系统而有所不同,如果您对计算机选择返回的值感到困惑,应该经常查阅自己计算机的文档(更不用说它的配置了)。但是有几个标志倾向于跨平台。以下是一些比较重要的例子:

  • AI_ALL:我已经讨论过了,AI_V4MAPPED选项可以保护您免受以下情况的影响:您在一个纯 IPv6 连接的主机上,但是您想要连接的主机只通告 IPv4 地址。它通过将 IPv4 地址重写为 IPv6 地址来解决这个问题。但是,如果某些 IPv6 地址碰巧可用,那么它们将是唯一显示的地址,并且没有 IPv4 地址将包括在返回值中。这个问题可以通过这个选项来解决:如果您希望看到来自 IPv6 连接的主机的所有地址,即使有一些非常好的 IPv6 地址可用,那么将这个AI_ALL标志与AI_V4MAPPED结合起来,返回给您的列表将包含目标主机的所有已知地址。
  • AI_NUMERICHOST:这关闭了将主机名参数——getaddrinfo()的第一个参数——解释为文本主机名(如cern.ch)的任何尝试,并且它仅尝试将主机名字符串解释为文字 IPv4 或 IPv6 主机名(如74.207.234.78fe80::fcfd:4aff:fecf:ea4e)。这要快得多,因为提供地址的用户或配置文件不会导致您的程序进行 DNS 往返来查找名称(见下一节),并防止可能不可信的用户输入迫使您的系统向其他人控制的名称服务器发出查询。
  • AI_NUMERICSERV:这关闭了像'www'这样的符号端口名,并坚持使用像80这样的端口号来代替。您不需要使用它来保护您的程序免受慢速 DNS 查找的影响,因为端口号数据库通常存储在支持 IP 的机器上,而不是进行远程查找。在 POSIX 系统上,解析一个符号端口名通常只需要快速扫描一下/etc/services文件(但是检查一下/etc/nsswitch.conf文件的服务选项以确保正确)。但是,如果您知道您的端口字符串应该总是一个整数,那么激活这个标志可以是一个有用的健全性检查。

关于标志的最后一点:你不必担心一些操作系统提供的与 IDN 相关的标志,它告诉getaddrinfo()解析那些含有 Unicode 字符的新域名。相反,Python 会检测一个字符串是否需要特殊的编码,并设置任何必要的选项来转换它:

>>> getaddrinfo('παράδειγμα.δοκιμή', 'www', 0, socket.SOCK_STREAM, 0,
...            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED)
[(2, 1, 6, '', ('199.7.85.13', 80))]

如果您对这在幕后是如何工作的感到好奇,请阅读从 RFC 3492 开始的相关国际标准,并注意 Python 现在包括一个可以与国际化域名相互转换的'idna'编解码器。

>>> 'παράδειγμα.δοκιμή'.encode('idna')
b'xn--hxajbheg2az3al.xn--jxalpdlp'

当您输入前面示例中所示的希腊语示例域名时,实际上就是这个生成的纯 ASCII 字符串被发送到域名服务。同样,Python 将为您隐藏这种复杂性。

原始名称服务例程

getaddrinfo()风靡之前,做套接字级编程的程序员通过操作系统支持的一个更简单的名字服务例程集合就可以了。现在应该避免使用它们,因为它们中的大多数都是硬连线的,只能说 IPv4。

您可以在socket模块的标准库页面中找到它们的文档。在这里,我将展示几个简单的例子来说明每个调用。两个调用返回当前机器的主机名。

>>> socket.gethostname()
'asaph'
>>> socket.getfqdn()
'asaph.rhodesmill.org'

另外两个允许您在 IPv4 主机名和 IP 地址之间转换。

>>> socket.gethostbyname('cern.ch')
'137.138.144.169'
>>> socket.gethostbyaddr('137.138.144.169')
('webr8.cern.ch', [], ['137.138.144.169'])

最后,三个例程让您使用操作系统已知的符号名称来查找协议号和端口。

>>> socket.getprotobyname('UDP')
17
>>> socket.getservbyname('www')
80
>>> socket.getservbyport(80)
'www'

如果您想了解运行 Python 程序的机器的主 IP 地址,您可以尝试将其完全限定的主机名传递到一个gethostbyname()调用中,如下所示:

>>> socket.gethostbyname(socket.getfqdn())
'74.207.234.78'

然而,由于任何一个调用都可能失败并返回一个地址错误(参见第五章中关于错误处理的部分),你的代码应该有一个备份计划,以防这对调用未能返回一个有用的 IP 地址。

在您自己的代码中使用 getsockaddr()

为了把所有的东西放在一起,我收集了一个简单的例子,展示了getaddrinfo()在实际代码中的样子。看看清单 4-1 中的。

清单 4-1 。使用getaddrinfo()创建并连接一个插座

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter04/www_ping.py
# Find the WWW service of an arbitrary host using getaddrinfo().

import argparse, socket, sys

def connect_to(hostname_or_ip):
    try:
        infolist = socket.getaddrinfo(
            hostname_or_ip, 'www', 0, socket.SOCK_STREAM, 0,
            socket.AI_ADDRCONFIG | socket.AI_V4MAPPED | socket.AI_CANONNAME,
            )
    except socket.gaierror as e:
        print('Name service failure:', e.args[1])
        sys.exit(1)

    info = infolist[0]  # per standard recommendation, try the first one
    socket_args = info[0:3]
    address = info[4]
    s = socket.socket(*socket_args)
    try:
        s.connect(address)
    except socket.error as e:
        print('Network failure:', e.args[1])
    else:
        print('Success: host', info[3], 'is listening on port 80')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Try connecting to port 80')
    parser.add_argument('hostname', help='hostname that you want to contact')
    connect_to(parser.parse_args().hostname)

这个脚本执行一个简单的“你在吗?”通过尝试使用流套接字快速连接到端口 80,测试您在命令行上指定的任何 web 服务器。使用该脚本看起来会像这样:

$ python www_ping.py mit.edu
Success: host mit.edu is listening on port 80
$ python www_ping.py smtp.google.com
Network failure: Connection timed out
$ python www_ping.py no-such-host.com
Name service failure: Name or service not known

注意这个脚本的三点:

  • 它是完全通用的,既没有提到作为协议的 IP,也没有提到作为传输的 TCP。如果用户碰巧输入了一个主机名,系统认为它是通过 AppleTalk 连接的主机(如果你能想象这个时代的这种事情),那么getaddrinfo()将自由地返回 AppleTalk 套接字系列、类型和协议,这将是你最终创建和连接的套接字类型。
  • getaddrinfo()故障会导致特定的名称服务错误,Python 称之为gaierror,而不是在脚本结束时检测到的普通网络故障所使用的那种普通套接字错误。你将在第五章的中了解更多关于错误处理的知识。
  • 您没有给socket()构造函数一个包含三个独立项目的列表。相反,参数列表由星号引入,这意味着socket_args列表的三个元素作为三个独立的参数传递给构造函数。这与您需要对返回的实际地址所做的事情相反,实际地址是作为一个单元传递给所有需要它的套接字例程的。

DNS 协议

域名系统(DNS) 是数百万互联网主机合作回答什么主机名解析成什么 IP 地址的方案。DNS 是这样一个事实的背后:你可以在你的网络浏览器中键入python.org,而不是总是不得不为那些使用 IPv4 的人记住82.94.164.162,或者如果你已经喜欢 IPv6,记住2001:888:2000:d::a2

DNS 协议

目的:通过返回 IP 地址来解析主机名

标准:RFC 1034 和 RFC 1035(自 1987 年起)

运行于:UDP/IP 和 TCP/IP 之上

端口号:53

库:第三方,包括dnspython3

计算机为执行此解析而发送的消息会穿过服务器的层次结构。如果您的本地计算机和名称服务器无法解析主机名,因为它既不是您组织的本地主机名,也不是最近才被发现的主机名,因此仍在名称服务器的缓存中,那么下一步是查询世界顶级名称服务器之一,以找出哪些计算机负责您需要查询的域。一旦 DNS 服务器 IP 地址被返回,就可以依次查询它们以获得域名本身。

在检查细节之前,我们应该先退后一步,看看这个操作通常是如何开始的。

考虑域名www.python.org。如果你的网络浏览器需要知道这个地址,那么浏览器会运行一个类似getaddrinfo() 的调用来请求操作系统解析这个名称。您的系统本身将知道它正在运行自己的名称服务器,或者它所连接的网络提供名称服务。现在,当您的机器连接到网络时,通常会通过 DHCP 自动配置名称服务器信息,无论是连接到公司办公室或教育机构的局域网、无线网络,还是通过家庭电缆或 DSL 连接。在其他情况下,DNS 服务器 IP 地址将在系统管理员设置您的机器时手动配置。无论哪种方式,DNS 服务器都必须通过它们的原始 IP 地址来指定,因为很明显,在您知道其他到达服务器的方法之前,您不能执行任何 DNS 查询。

有时,人们对他们的 ISP 的 DNS 行为或性能不满意,他们选择配置自己选择的第三方 DNS 服务器,如谷歌在8.8.8.88.8.4.4运行的服务器。在极少数情况下,本地 DNS 域名服务器可以通过计算机使用的其他名称集识别,如 WINS Windows 命名服务。但是,无论如何,必须识别 DNS 服务器,以便进行域名解析。

你的电脑甚至不用咨询域名服务就知道一些主机名。当您发出类似getaddrinfo()的调用时,向 DNS 查询主机名实际上并不是操作系统通常做的第一件事。事实上,因为进行 DNS 查询可能很耗时,所以它通常是最后的选择!根据您的/etc/nsswitch.conf文件中的 hosts 条目(如果您在 POSIX box 上),或者根据您的 Windows 控制面板设置,在转向 DNS 之前,操作系统可能会首先查看一个或几个其他位置。例如,在我的 Ubuntu 笔记本电脑上,每次查找主机名时都会首先检查/etc/hosts文件。然后,如果可能的话,使用称为多播 DNS 的专用协议。只有当失败或不可用时,成熟的 DNS 才会响应主机名查询。

继续我们的例子,假设名称www.python.org没有在您的机器上本地定义,并且最近没有被查询到足以在您运行 web 浏览器的机器上的任何本地缓存中。在这种情况下,计算机将查找本地 DNS 服务器,并且通常通过 UDP 向其发送单个 DNS 请求数据包。

现在问题掌握在真正的 DNS 服务器手中。在接下来的讨论中,我将称它为“您的 DNS 服务器”,意思是“为您执行主机名查找的特定 DNS 服务器”当然,服务器本身可能属于其他人,比如你的雇主、你的 ISP 或谷歌,因此从你拥有它的意义上来说,它实际上并不属于你。

您的 DNS 服务器的第一个动作将是检查其自己的最近查询的域名的缓存,以查看在过去的几分钟或几小时内www.python.org是否已经被 DNS 服务器服务的其他机器检查过。如果条目存在并且尚未过期(每个域名的所有者可以选择其过期超时,因为一些组织喜欢在需要时快速更改 IP 地址,而其他组织则乐于让旧 IP 地址在全球 DNS 缓存中停留几个小时或几天),则可以立即将其返回。但是想象一下,现在是早上,你是今天在办公室或咖啡店里第一个尝试与www.python.org交谈的人,因此 DNS 服务器必须从头开始寻找主机名。

您的 DNS 服务器现在将开始一个递归过程,询问位于全球 DNS 服务器层级顶端的www.python.org,“根级”域名服务器,它们知道所有顶级域名(TLD),如.com.org.net,并且知道负责每个域名的服务器组。域名服务器软件一般都内置了这些顶级服务器的 IP 地址,这样就解决了你在实际连接域名系统之前,如何找到任何域名服务器的自举问题。通过这第一次 UDP 往返,您的 DNS 服务器将了解(如果它不知道已经从另一个最近的查询)哪些服务器保留了.org域的完整索引。

现在将发出第二个 DNS 请求,这次是向其中一个.org服务器发出请求,询问谁在运行python.org域。您可以通过在 POSIX 系统上运行whois命令行程序,或者如果您没有在本地安装该命令,则使用在线的许多“whois”网页之一,来了解这些顶级服务器对某个域了解多少。

$ whois python.org
Domain Name:PYTHON.ORG
Created On:27-Mar-1995 05:00:00 UTC
Last Updated On:07-Sep-2006 20:50:54 UTC
Expiration Date:28-Mar-2016 05:00:00 UTC
...
Registrant Name:Python Software Foundation
...
Name Server:NS2.XS4ALL.NL
Name Server:NS.XS4ALL.NL

这就是我们的答案!无论您在世界的哪个地方,您对python.org内任何主机名的 DNS 请求都必须被传递到该条目中指定的两个 DNS 服务器之一。当然,当您的 DNS 服务器向顶级域名服务器发出这一请求时,它实际上并不会只返回两个名称,就像刚才给出的那样。取而代之的是,他们的 IP 地址也给了它,这样它就可以直接联系他们,而不会招致另一轮昂贵的 DNS 查找。

您的 DNS 服务器现在已经完成了与根级 DNS 服务器和顶级.org DNS 服务器的对话,它可以直接与NS2.XS4ALL.NLNS.XS4ALL.NL通信,询问关于python.org域的信息。事实上,它将尝试其中一个,然后如果第一个不可用,则退回到尝试另一个。这增加了你得到答案的机会,但是,当然,一次失败会增加你坐在那里盯着你的网页浏览器看的时间,直到网页真正显示出来。

根据python.org如何配置其名称服务器,DNS 服务器可能只需要再进行一次查询就能得到答案,或者如果组织是一个拥有许多部门和子部门的大型组织,它可能需要再进行几次查询,这些部门都运行自己的 DNS 服务器,需要将请求委派给这些服务器。在这种情况下,www.python.org查询可以直接由刚刚命名的两个服务器中的任何一个来回答,并且您的 DNS 服务器现在可以向您的浏览器返回一个 UDP 数据包,告诉它哪些 IP 地址属于该主机名。

请注意,此过程需要四次单独的网络往返。您的机器发出一个请求,并从您自己的 DNS 服务器得到一个响应,为了响应这个请求,您的 DNS 服务器必须进行一个递归查询,这个查询包括到其他服务器的三次不同的往返。难怪当你第一次输入域名时,你的浏览器会不停地旋转。

为什么不使用原始 DNS

我希望,前面对典型 DNS 查询的解释已经清楚地表明,当您需要查找主机名时,您的操作系统已经为您做了很多工作。出于这个原因,我将建议您,除非出于非常特殊的原因绝对需要使用 DNS,否则您总是依赖于getaddrinfo()或其他一些系统支持的机制来解析主机名。考虑让您的操作系统为您查找名称的这些好处:

  • DNS 通常不是系统获取名称信息的唯一途径。如果您的应用运行并试图使用 DNS 作为其解析域名的首选,那么用户将会注意到,一些在您系统的任何地方都可以使用的计算机名称——在他们的浏览器中,在文件共享路径中,等等——在他们使用您的应用时突然不起作用了,因为您没有像操作系统本身那样咨询 WINS 或/etc/hosts之类的机制。
  • 本地机器可能有一个最近查询的域名缓存,其中可能已经包含您需要其 IP 地址的主机。如果你试着自己说 DNS 来回答你的问题,你将会重复已经做过的工作。
  • 运行 Python 脚本的系统已经知道本地域名服务器,这要归功于系统管理员的手动配置或 DHCP 等网络设置协议。要在 Python 程序中启动 DNS,您必须学习如何在您的特定操作系统中查询这些信息——这是一个特定于操作系统的操作,我不会在本书中介绍。
  • 如果您不使用本地 DNS 服务器,那么您将无法受益于它自己的缓存,该缓存会阻止您的应用和在同一网络上运行的其他应用重复请求您所在位置经常使用的主机名。
  • 不时地,对世界 DNS 基础设施进行调整,并且操作系统库和守护进程逐渐更新以适应这一点。如果您的程序自己进行原始 DNS 调用,那么您必须自己跟踪这些更改,并确保您的代码与 TLD 服务器 IP 地址的最新更改、涉及国际化的约定以及对 DNS 协议本身的调整保持同步。

最后,请注意 Python 并没有在标准库中内置任何 DNS 工具。如果你打算使用 Python 来讨论 DNS,那么你必须选择并学习一个第三方库。

从 Python 发出 DNS 查询

然而,从 Python 进行 DNS 调用有一个可靠且合法的理由。这是因为您是一个邮件服务器,或者至少是一个试图直接向您的收件人发送邮件而不需要运行本地邮件中继的客户端,并且您想要查找与一个域相关联的 MX 记录,以便您可以在@example.com为您的朋友找到正确的邮件服务器。

因此,在我们结束本章时,让我们来看看 Python 的一个第三方 DNS 库。Python 3 目前最好的版本是 dnspython3,您可以使用标准的 Python 打包工具来安装它。

$ pip install dnspython3

该库使用自己的技巧来找出您的 Windows 或 POSIX 操作系统当前使用的域名服务器,然后它要求这些服务器代表它进行递归查询。因此,本章中没有任何一段代码不需要正确配置的主机,而管理员或网络配置服务已经为该主机配置了有效的名称服务器。

清单 4-2 展示了一个简单而全面的查找。

清单 4-2 。一个简单的 DNS 查询做自己的递归

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter04/dns_basic.py
# Basic DNS query

import argparse, dns.resolver

def lookup(name):
    for qtype in 'A', 'AAAA', 'CNAME', 'MX', 'NS':
        answer = dns.resolver.query(name, qtype, raise_on_no_answer=False)
        if answer.rrset is not None:
            print(answer.rrset)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Resolve a name using DNS')
    parser.add_argument('name', help='name that you want to look up in DNS')
    lookup(parser.parse_args().name)

您可以看到一次只能尝试一种类型的 DNS 查询,因此这个小脚本在一个循环中运行,请求与作为命令行参数给出的单个主机名相关的不同类型的记录。对python.org运行这个命令会立即教会你一些关于 DNS 的事情。

$ python dns_basic.py python.org
python.org. 42945 IN A 140.211.10.69
python.org. 86140 IN MX 50 mail.python.org.
python.org. 86146 IN NS ns4.p11.dynect.net.
python.org. 86146 IN NS ns3.p11.dynect.net.
python.org. 86146 IN NS ns1.p11.dynect.net.
python.org. 86146 IN NS ns2.p11.dynect.net.

从程序中可以看出,回复中返回的每个“答案”都由一系列对象表示。按顺序,打印在每一行上的键如下:

  • 名字查了一下。
  • 在名称过期之前允许您缓存该名称的时间(秒)。
  • IN这样的“类”,它表明你正在返回互联网地址响应。
  • 记录的“类型”。一些常见的是用于 IPv4 地址的A、用于 IPv6 地址的AAAANS用于列出名称服务器的记录,以及MX用于给出应该用于域的邮件服务器的回复。
  • 最后,“数据”提供了连接或联系服务所需的信息。

在刚刚引用的查询中,您了解到关于python.org域的三件事情。首先,A记录告诉您,如果您想要连接到一台实际的python.org机器——建立 HTTP 连接、启动 SSH 会话或做任何其他事情,因为用户已经提供了python.org作为他或她想要连接的机器——那么您应该将您的数据包定向到 IP 地址140.211.10.69。其次,NS 记录告诉您,如果您想要查询python.org下的任何主机的名称,那么您应该让名称服务器ns1.p11.dynect.netns4.p11.dynect.net(最好按照给定的顺序,而不是数字顺序)为您解析这些名称。最后,如果你想给电子邮件域名为@python.org的人发送电子邮件,那么你需要去查找主机名mail.python.org

DNS 查询还可以返回一个记录类型CNAME,这表明您查询的主机名实际上只是另一个主机名的别名——然后您必须单独去查找!因为它经常需要两次往返,所以这种记录类型现在并不流行,但是您仍然可能会遇到它。

解析邮件域

我之前提到过,在大多数 Python 程序中,解析电子邮件域是对原始 DNS 的合法使用。最近在 RFC 5321 中指定了进行这种解析的规则。简而言之,如果MX记录存在,那么您必须尝试联系这些 SMTP 服务器,如果它们都不接受消息,则向用户返回一个错误(或者将消息放在重试队列中)。如果它们的优先级不相等,则按优先级从低到高的顺序尝试它们。如果不存在任何MX记录,但是为该域提供了一个AAAAA记录,那么您可以尝试通过 SMTP 连接到该地址。如果两个记录都不存在,但是指定了一个CNAME,那么应该使用相同的规则在它提供的域名中搜索MXA记录。

清单 4-3 显示了如何实现这个算法。通过进行一系列的 DNS 查询,它在可能的目的地中前进,在前进的过程中打印出它的决定。通过调整像这样的例程来返回地址而不仅仅是打印它们,您可以为需要向远程主机发送电子邮件的 Python 邮件调度程序提供支持。

清单 4-3 。解析电子邮件域名

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter04/dns_mx.py
# Looking up a mail domain - the part of an email address after the `@`

import argparse, dns.resolver

def resolve_hostname(hostname, indent=''):
    "Print an A or AAAA record for `hostname`; follow CNAMEs if necessary."
    indent = indent + '    '
    answer = dns.resolver.query(hostname, 'A')
    if answer.rrset is not None:
        for record in answer:
            print(indent, hostname, 'has A address', record.address)
        return
    answer = dns.resolver.query(hostname, 'AAAA')
    if answer.rrset is not None:
        for record in answer:
            print(indent, hostname, 'has AAAA address', record.address)
        return
    answer = dns.resolver.query(hostname, 'CNAME')
    if answer.rrset is not None:
        record = answer[0]
        cname = record.address
        print(indent, hostname, 'is a CNAME alias for', cname) #?
        resolve_hostname(cname, indent)
        return
    print(indent, 'ERROR: no A, AAAA, or CNAME records for', hostname)

def resolve_email_domain(domain):
    "For an email address `name@domain` find its mail server IP addresses."
    try:
        answer = dns.resolver.query(domain, 'MX', raise_on_no_answer=False)
    except dns.resolver.NXDOMAIN:
        print('Error: No such domain', domain)
        return
    if answer.rrset is not None:
        records = sorted(answer, key=lambda record: record.preference)
        for record in records:
            name = record.exchange.to_text(omit_final_dot=True)
            print('Priority', record.preference)
            resolve_hostname(name)
    else:
        print('This domain has no explicit MX records')
        print('Attempting to resolve it as an A, AAAA, or CNAME')
        resolve_hostname(domain)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Find mailserver IP address')
    parser.add_argument('domain', help='domain that you want to send mail to')
    resolve_email_domain(parser.parse_args().domain)

当然,这里显示的resolve_hostname()的实现相当脆弱,因为它应该根据当前主机是连接到 IPv4 还是 IPv6 网络,在AAAAA记录之间做出动态决定。事实上,很可能我们的朋友getsockaddr()真的应该被推迟到这里,而不是试图解决我们自己的邮件服务器主机名!但是由于清单 4-3 旨在展示 DNS 是如何工作的,我想我也可以使用纯 DNS 来完成这个逻辑,这样你就可以看到查询是如何被解析的。

真正的邮件服务器实现显然不会打印邮件服务器地址,而是尝试向它们发送邮件,并在第一次成功后停止。(如果它在成功后继续检查服务器列表,那么将会生成电子邮件的几个副本,每个副本对应一个成功送达的服务器。)尽管如此,这个简单的脚本让您对这个过程有了一个很好的了解。您可以看到python.org目前只有一个邮件服务器 IP 地址。

$ python dns_mx.py python.org
This domain has 1 MX records
Priority 50
     mail.python.org has A address 82.94.164.166

当然,无论该 IP 属于一台机器还是由一群主机共享,从外部是不容易看到的。其他组织更积极地给收到的电子邮件提供几个着陆点。IANA 目前有不少于六个电子邮件服务器(或者,至少它提供了六个 IP 地址供你连接,不管它实际上运行着多少服务器)。

$ python dns_mx.py iana.org
This domain has 6 MX records
Priority 10
     pechora7.icann.org has A address 192.0.46.73
Priority 10
     pechora5.icann.org has A address 192.0.46.71
Priority 10
     pechora8.icann.org has A address 192.0.46.74
Priority 10
     pechora1.icann.org has A address 192.0.33.71
Priority 10
     pechora4.icann.org has A address 192.0.33.74
Priority 10
     pechora3.icann.org has A address 192.0.33.73

通过在许多不同的域中尝试这个脚本,您将能够看到大型和小型组织是如何将传入的电子邮件路由到 IP 地址的。

摘要

Python 程序经常需要将主机名转换成套接字地址,这样它们就可以真正地建立连接。

大多数主机名查找应该通过socket模块中的getsockaddr()函数进行,因为它的智能通常由您的操作系统提供,它不仅知道如何使用所有可用的机制查找域名,还知道本地 IP 堆栈配置为支持哪种类型的地址(IPv4 或 IPv6)。

传统的 IPv4 地址仍然是互联网上最普遍的地址,但是 IPv6 也变得越来越普遍。通过将所有主机名和端口名的查找推迟到getsockaddr(),您的 Python 程序可以将地址视为不透明的字符串,而不必担心解析或解释它们。

大多数域名解析的背后是 DNS,它是一个分布在世界各地的数据库,将域名查询直接转发给拥有域名的组织的服务器。虽然不常在 Python 中直接使用,但它有助于根据以电子邮件地址中的@符号命名的电子邮件域来确定将电子邮件定向到哪里。

既然你已经了解了如何命名你将要连接套接字的主机,那么第五章将探讨编码和定界你将要传输的数据有效载荷的不同选择。