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

144 阅读54分钟

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

原文:Foundations of Python Network Programming

协议:CC BY-NC-SA 4.0

十六、Telnet 和 SSH

如果你从未读过这本书,那么你应该泡一杯你最喜欢的咖啡,坐下来,听听尼尔·斯蒂芬森的文章《开始》。。。曾是命令行》(威廉·莫罗平装本,1999)。您也可以在www.cryptonomicon.com/beginning.html从他的网站上下载一份原始文本文件(足够合适)。

命令行是本章的主题。它涵盖了如何通过网络访问它,以及关于它的典型行为的足够多的讨论,以帮助您解决在尝试使用它时可能遇到的任何令人沮丧的问题。

令人高兴的是,对于许多读者来说,向另一台计算机发送简单的文本命令这一过时的想法将是本书最相关的主题之一。讨论的主要网络协议——安全外壳(SSH)——似乎在任何地方都被用来配置和维护各种机器。

当你在一家虚拟主机公司获得一个新帐户,并且已经使用它的控制面板设置好你的域名和网络应用列表后,命令行就成了你安装和运行网站代码的主要手段。

Rackspace 和 Linode 等公司的虚拟服务器或物理服务器几乎总是通过 SSH 连接来管理。

如果您使用基于 API 的虚拟主机服务(如 Amazon AWS)构建一个动态分配的服务器云,您会发现 Amazon 允许您通过向您询问 SSH 密钥并安装它来访问您的新主机,以便您可以立即登录到您的新实例,而无需密码。

就好像一旦早期的计算机能够接收文本命令并返回文本输出作为响应,它们就达到了一种有用的顶峰,但仍有待改进。语言是人类用来表达意思的最强大的手段,当你打字时,即使是在 Unix 外壳的狭窄和精确的语言中,再多的指向、点击或拖动鼠标也无法表达哪怕是一小部分的内容。

命令行自动化

在详细了解命令行的工作原理以及如何通过网络访问远程命令行之前,请注意,如果您的特定目标是执行远程系统管理,那么您可能需要检查更多特定的工具。为了增加复杂性,Python 社区已经在三个方向上采用了远程自动化:

  1. Fabric
  2. Ansible 是一个圆滑而强大的系统,它让你声明几十或几百台远程机器应该如何配置。它用 SSH 连接到它们中的每一个,并执行任何必要的检查或更新。它的速度和设计不仅引起了 Python 社区的注意,也引起了整个系统管理学科的注意(见http://docs.ansible.com/index.html)。
  3. SaltStack 让您在每台客户机上安装自己的代理,而不是简单地安装在 SSH 之上。这允许主服务器将新信息推送到其他机器,比通过成百上千个同步 SSH 连接要快得多。反过来,它的速度快得惊人,即使对于大型设备和大型集群也是如此(见www.saltstack.com/)。

最后,我应该提一下 pexpect 。虽然从技术上来说,它并不是一个知道如何使用网络的程序,但当 Python 程序员想要自动化与某种远程提示的交互时,它经常被用来控制系统sshtelnet命令。这通常发生在设备没有可用的 API,每次命令行提示符出现时只需键入命令的情况下。配置简单的网络硬件通常需要这种笨拙的逐步交互。你可以在http://pypi.python.org/pypi/pexpect了解更多关于 pexpect 的信息。

当然,可能没有像这样的自动化解决方案能够满足您的项目,您实际上必须卷起袖子,自己学习如何操作远程外壳协议。那样的话,你来对地方了。继续读!

命令行扩展和报价

如果您曾经在 Unix 命令提示符下键入过命令,您会意识到并不是您键入的每个字符都会被逐字解释。例如,考虑以下命令。(注意,在这个例子和本章后面的所有例子中,我将使用美元符号$,作为 shell 的提示符,它告诉你“该你打字了。”)

$ echo *
sftp.py shell.py ssh_commands.py ssh_simple.py ssh_simple.txt ssh_threads.py telnet_codes.py
telnet_login.py

该命令中的星号(* ) 并不表示“将实际的星号字符打印到屏幕上”相反,shell 认为我试图编写一个模式来匹配当前目录中的所有文件名。要打印一个真正的星号,我必须使用另一个特殊字符,一个转义字符,因为它让我从 shell 的正常含义中“转义”出来,告诉它我只是在字面上表示星号。

$ echo Here is a lone asterisk: \*
Here is a lone asterisk: *

$ echo And here are '*' two "*" more asterisks
And here are * two * more asterisks

Shells 可以运行子进程,然后在另一个命令的文本中使用子进程的输出——现在它们甚至可以做数学运算。为了计算尼尔·斯蒂芬森的《在开始》的纯文本版本中每行有多少单词。。。如果是命令行”短文,您可以要求无处不在的 Bourne-again shell——目前大多数 Linux 系统上的标准 shell——将短文中的字数除以行数并产生一个结果。

$ echo $(($(wc -w < command.txt) / $(wc -l < command.txt))) words per line
44 words per line

从这个例子可以明显看出,现代 shells 解释命令行中特殊字符的规则已经变得相当复杂。 bash shell 的手册页目前总共运行了 5375 行,或者说在一个标准的 80×24 终端窗口中有 223 个充满文本的屏幕!很明显,如果我只探索 shell 破坏您输入的命令的一小部分可能方式,这将会把本章引入歧途。

相反,为了帮助您有效地使用命令行,在接下来的部分中,您将只关注两个要点:

  • 特殊字符被你正在使用的 shell 解释为特殊,比如bash。它们对操作系统本身没有任何特殊意义。
  • 当在本地或通过网络向 shell 传递命令时,您需要对所使用的特殊字符进行转义,这样它们就不会在远程系统上扩展为非预期的值。

现在,我将在各自的章节中逐一解决这些问题。请记住,我说的是通用的服务器操作系统,如 Linux 和 OS X,而不是更原始的操作系统,如 Windows,我将在单独的章节中讨论。

Unix 命令参数可以包括(几乎)任何字符

纯粹的低级 Unix 命令行没有特殊字符或保留字符。这是你要把握的一个重要事实。如果您使用类似于bash的 shell 已经有一段时间了,您可能会认为您的系统命令行就像是一个雷区。一方面,所有的特殊字符使得命名当前目录中的所有文件作为命令的参数变得容易。然而,从另一方面来说,很难将消息回显到屏幕上,这样做就像将单引号和双引号混合起来一样简单,而且很难知道哪些字符是安全的,哪些字符是 shell 认为特殊的。

本节的简单教训是,关于 shell 特殊字符的整套约定与您的操作系统无关。它们完全是bash shell 的行为,或者是您正在使用的其他流行(或神秘)shell 的行为。不管这些规则看起来有多熟悉,或者想象一下没有它们使用一个类似 Unix 的系统有多困难。如果你把外壳拿走,那么特殊字符的现象就消失了。

您可以通过自己启动一个进程并尝试在一个熟悉的命令中加入一些特殊字符来观察到这一点。

>>> import subprocess
>>> args = ['echo', 'Sometimes', '*', 'is just an asterisk']
>>> subprocess.call(args)
Sometimes * is just an asterisk

在这里,您选择启动一个带有参数的新进程,而不要求 shell 介入。这个过程——在本例中是echo命令——获取的正是这些字符,而不是先将*转换成文件名列表。

虽然星号通配符经常使用,但是 shell 中最常见的特殊字符是您一直在使用的:空格字符。每个空格都被解释为分隔参数的分隔符。当人们在 Unix 文件名中包含空格,然后试图将文件移动到其他地方时,这会导致无休止的娱乐。

$ mv Smith Contract.txt ~/Documents
mv: cannot stat `Smith': No such file or directory
mv: cannot stat `Contract.txt': No such file or directory

为了让 shell 理解您正在讨论的是一个名称中带有空格的文件,而不是两个文件,您必须设计类似以下可能的命令行之一:

$ mv Smith\ Contract.txt ~/Documents
$ mv "Smith Contract.txt" ~/Documents
$ mv Smith*Contract.txt ~/Documents

最后一种可能性显然意味着与前两种不同的东西,因为它将匹配任何碰巧以Smith开始并以Contract.txt结束的文件名,而不管它们之间的文本是简单的空格字符还是更长的文本序列。我经常看到用户在学习 shell 约定并且不记得如何键入空格字符时,沮丧地求助于使用通配符。

如果你想让自己相信bash shell 教你要小心的字符没有什么特别的,清单 16-1 展示了一个用 Python 写的简单 shell,它只将空格字符视为特殊字符,但将其他所有内容直接传递给命令。

清单 16-1 。外壳支持空格分隔的参数

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/shell.py
# A simple shell, so you can try running commands at a prompt where no
# characters are special (except that whitespace separates arguments).

import subprocess

def main():
    while True:
        args = input('] ').strip().split()
        if not args:
            pass
        elif args == ['exit']:
            break
        elif args[0] == 'show':
            print("Arguments:", args[1:])
        else:
            try:
                subprocess.call(args)
            except Exception as e:
                print(e)

if __name__ == '__main__':
    main()

当然,这个简单的 shell 没有提供特殊的引用字符,这意味着您不能用它来讨论名称中有空格的文件,因为它总是毫无例外地认为空格意味着一个参数的结束和下一个参数的开始。

通过运行这个 shell 并尝试各种您害怕使用的特殊字符,您会发现如果直接传递到您使用的常用命令中,它们毫无意义。(清单 16-2 中的shell 使用了一个]提示符,以便于与您自己的 shell 区分开来。)

$ python shell.py
] echo Hi there!
Hi there!
] echo An asterisk * is not special.
An asterisk * is not special.
] echo The string $HOST is not special, nor are "double quotes".
The string $HOST is not special, nor are "double quotes".
] echo What? No *<>!$ special characters?
What? No *<>!$ special characters?
] show "The 'show' built-in lists its arguments."
Arguments: ['"The', "'show'", 'built-in', 'lists', 'its', 'arguments."']
] exit

您可以在这里看到绝对的证据,Unix 命令——在本例中是您反复调用的/bin/echo命令——不注意它们参数中的特殊字符。echo命令很乐意接受参数列表中的双引号、美元符号和星号等字符,并将它们都视为文字字符。正如前面的show命令所示,Python 只是将您的参数简化为一个字符串列表,供操作系统在创建新进程时使用。

如果您未能将命令拆分成单独的参数,并将命令名和参数作为单个字符串传递给操作系统,那该怎么办?

>>> import subprocess
>>> subprocess.call(['echo hello'])
Traceback (most recent call last):
  ...
FileNotFoundError: [Errno 2] No such file or directory: 'echo hello'

你看到发生了什么吗?操作系统不知道空格应该是特殊的。因此,系统认为它被要求运行一个名为echo[space]hello的命令,除非您已经在当前目录中创建了这样一个文件,否则它无法找到它并引发一个异常。

事实上,有一个字符是系统特有的:空字符(Unicode 和 ASCII 码为零的字符)。在类似 Unix 的系统中,null 字符用于标记内存中每个命令行参数的结束。因此,如果您尝试在参数中使用空字符,Unix 将认为参数已经结束,并忽略其文本的其余部分。为了防止您犯这种错误,如果您在命令行参数中包含一个空字符,Python 会让您停下来。

>>> subprocess.call(['echo', 'Sentences can end\0 abruptly.'])
Traceback (most recent call last):
  ...
TypeError: embedded NUL character

令人高兴的是,由于系统上的每个命令都被设计为符合这一限制,您通常会发现无论如何都没有理由在命令行参数中使用空字符。(具体来说,它们不能出现在文件名中的原因与它们不能出现在参数列表中的原因完全相同:操作系统提供了以空终止字符串表示的文件名。)

引用保护字符

在上一节中,您使用 Python 的subprocess模块中的例程直接调用命令。这很棒,它允许您传递对于普通的交互式 shell 来说很特殊的字符。如果您有一个很大的文件名列表,其中包含空格和其他特殊字符,那么简单地将它们传递到一个子进程调用中,并让接收端的命令完全理解您的意思可能会非常好。

然而,当您在网络上使用远程 shell 协议时,您通常会与类似于bash的 shell 对话,而不是像通过subprocess模块那样直接调用命令。这意味着远程 shell 协议将感觉更像来自os模块的system()例程,它调用一个 shell 来解释您的命令,因此涉及到 Unix 命令行的所有复杂性。

>>> import os
>>> os.system('echo *')
sftp.py shell.py ssh_commands.py ssh_simple.py ssh_simple.txt ssh_threads.py telnet_codes.py
telnet_login.py

您的网络程序可能连接的各种系统和嵌入式 shells 提供了各种各样的引用和通配符约定。在某些情况下,它们可能相当神秘。然而,如果网络连接的另一端是一个标准的 Unix shell 家族,如bashzsh,那么你很幸运:通常用于构建复杂 shell 命令行的相当晦涩的 Python pipes模块包含一个非常适合转义参数的 helper 函数。它被称为quote,可以简单地传递一个字符串。

>>> from pipes import quote
>>> print(quote("filename"))
filename
>>> print(quote("file with spaces"))
'file with spaces'
>>> print(quote("file 'single quoted' inside!"))
 'file '"'"'single quoted'"'"' inside!'
>>> print(quote("danger!; rm -r *"))
'danger!; rm -r *'

因此,为远程执行准备命令行可以简单到对每个参数运行quote(),然后将结果与空格粘贴在一起。

请注意,使用 Python 向远程 shell 发送命令通常不会让您陷入两级 shell 引用的恐惧中,如果您曾经尝试构建一个本身使用花哨引用的远程 SSH 命令行,您可能会遇到这种情况。试图编写将参数传递给远程 shell 的 shell 命令往往会产生一系列类似这样的实验:

$ echo $HOST
guinness
$ ssh asaph echo $HOST
guinness
$ ssh asaph echo \$HOST
asaph
$ ssh asaph echo \\$HOST
guinness
$ ssh asaph echo \\\$HOST
$HOST
$ ssh asaph echo \\\\$HOST
\guinness

这些回答中的每一个都是合理的,你可以向自己证明。首先使用echo来查看每个命令在被本地 shell 引用时的样子,然后将该文本粘贴到远程 SSH 命令行中,以查看处理后的文本在那里是如何处理的。然而,这些命令可能很难编写,即使是经验丰富的 Unix shell 脚本编写人员在试图预测上述一系列命令的输出时也可能会猜错!

可怕的 Windows 命令行

您喜欢阅读前面关于 Unix shell 以及参数最终是如何传递给进程的部分吗?好吧,如果你打算使用远程 shell 协议连接到一台 Windows 机器,那么你可以忘记你刚刚读到的所有内容。Windows 惊人的原始。它不是将命令行参数作为单独的字符串传递给一个新进程,而是简单地将整个命令行的文本传递给正在启动的新进程,并让该进程尝试自己找出用户可能是如何引用包含空格的文件名的!

当然,仅仅是为了生存,Windows 世界的人们已经或多或少地采用了关于命令如何解释他们的参数的一致传统。例如,您可以用双引号将一个多字文件名括起来,并期望几乎所有的程序都能识别出您是在命名一个文件,而不是几个文件。大多数命令还试图理解文件名中的星号是通配符。但这总是由您正在运行的程序做出的选择,而不是由命令提示符做出的选择。

正如您将看到的,存在一种原始的网络协议——古代的 Telnet 协议——它也像 Windows 一样简单地以文本形式发送命令行,因此如果您的程序发送包含空格或特殊字符的参数,它将不得不进行某种形式的转义。但是,如果您使用的是现代远程协议,如 SSH,它允许您以字符串列表而不是单个字符串的形式发送参数,那么请注意,在 Windows 系统上,SSH 所能做的只是将您精心构建的命令行重新粘贴在一起,并希望 Windows 命令能够识别它。

当向 Windows 发送命令时,您可能想要利用 Python subprocess模块提供的list2cmdline()例程。它接受一个与 Unix 命令类似的参数列表,并尝试将它们粘贴在一起(必要时使用双引号和反斜杠),以便传统的 Windows 程序可以将命令行解析回完全相同的参数。

>>> from subprocess import list2cmdline
>>> args = ['rename', 'salary "Smith".xls', 'salary-smith.xls']
>>> print(list2cmdline(args))
rename "salary \"Smith\".xls" salary-smith.xls

对您选择的网络库和远程 shell 协议进行一些快速实验,应该有助于您了解 Windows 在您的情况下需要什么。对于本章的其余部分,我将做一个简化的假设,即您正在连接到使用现代的类似 Unix 的操作系统的服务器,该操作系统可以在不使用额外引号的情况下将离散的命令行参数分开。

在一个终端里事情是不同的

通过 Python 支持的远程连接,您可能会与更多的程序对话,而不仅仅是一个 shell。您可能经常希望观察传入的数据流,查看您正在运行的命令输出的数据和错误。有时,您还想发回数据,或者向远程程序提供输入,或者对程序提出的问题和提示做出响应。

当执行诸如此类的任务时,有时您可能会沮丧地发现程序无限期地挂起,甚至没有发送您所等待的输出。或者,您发送的数据可能看起来无法通过。为了帮助您解决这种情况,需要对 Unix 终端进行简单的讨论。

一个终端?? 是一个用户输入文本的设备,计算机的响应可以显示在它的屏幕上。如果 Unix 机器有物理串行端口,可以容纳一个物理终端,那么设备目录将包含像/dev/ttyS1这样的条目,程序可以用这些条目向该设备发送和接收字符串。然而,现在大多数终端实际上是其他程序:xterm 终端、Gnome 或 KDE 终端程序、Mac OS X iTerm 或终端,甚至是 Windows 机器上的 PuTTY 客户机,它们通过本章讨论的远程 shell 协议进行连接。

在计算机终端内运行的程序通常会试图自动检测它们是否在与人对话,只有当它们连接到终端设备时,它们才会认为它们的输出应该是为人类格式化的。因此,Unix 操作系统提供了一组名为/dev/tty42的“伪终端”设备(这些设备可能被命名为“虚拟”终端,以免混淆),如果您想让进程确信它们正在与一个真实的人进行通信,可以将进程连接到这些设备。当有人使用 xterm 或通过 SSH 连接时,xterm 或 SSH 守护进程会获取一个新的伪终端,对其进行配置,并运行附加到其上的用户 shell。shell 检查它的标准输入,发现它是一个终端,并给出一个提示,因为它认为它正在与一个人对话。

Image 因为嘈杂的电传打字机是计算机终端的最早例子,所以 Unix 常用 TTY 作为终端设备的缩写。这就是为什么测试你的输入是否是终端的调用被命名为isatty()

这是一个需要理解的关键区别:shell 给出一个提示是因为,而且仅仅是因为,它认为自己连接到了一个终端。如果您启动一个 shell 并给它一个不是终端的标准输入——比如说,来自另一个命令的管道——那么将不会打印任何提示,但是它仍然会响应命令。

$ cat | bash
echo Here we are inside of bash, with no prompt
Here we are inside of bash, with no prompt
python3
print('Python has not printed a prompt, either.')
import sys
print('Is this a terminal?', sys.stdin.isatty())

不仅bash没有打印提示,Python 也没有。事实上,Python 异常安静。虽然bash至少用一行文本响应了我们的echo命令,但是此时你已经在 Python 中输入了三行内容,却没有看到任何响应。这是怎么回事?

答案是,由于它的输入不是一个终端, Python 认为它应该从标准输入中盲目地读取整个 Python 脚本。毕竟它的输入是一个文件,文件里面有完整的脚本。为了完成 Python 正在执行的这种潜在的无休止的读取直到文件结束的操作,您必须按 Ctrl-D 向cat发送一个“文件结束”,然后它将关闭自己的输出,让示例结束。

一旦您关闭了它的输入,Python 将解释并运行您提供的三行脚本(在刚刚显示的会话中,除了单词python之外的所有内容),您将在终端上看到结果,随后是您启动的 shell 的提示符。

Python has not printed a prompt, either.
Is this a terminal? False

一些程序根据它们是否与终端对话来自动调整它们的输出格式。如果交互使用,ps命令会将每个输出行截断到您的终端宽度,但是如果它的输出是管道或文件,则会产生任意宽度的输出。此外,ls命令的基于列的输出被替换为每行一个文件名(您必须承认,这是另一个程序更容易读取的格式)。

$ ls
sftp.py   ssh_commands.py  ssh_simple.txt  telnet_codes.py
shell.py  ssh_simple.py    ssh_threads.py  telnet_login.py
$ ls | cat
sftp.py
shell.py
ssh_commands.py
ssh_simple.py
ssh_simple.txt
ssh_threads.py
telnet_codes.py
telnet_login.py

那么,所有这些和网络编程有什么关系呢?你所看到的两种行为——如果连接到终端,程序倾向于显示提示,但如果从文件或另一个命令的输出中读取,则忽略它们并静默运行——也发生在你在本章中考虑的 shell 协议的远端。

例如,一个运行在 Telnet 后面的程序总是认为它在和一个终端对话。因此,每次 shell 准备好输入时,您的脚本或程序都必须期望看到提示,以此类推。然而,当您通过更复杂的 SSH 协议建立连接时,您实际上可以选择程序是否认为它的输入是一个终端或只是一个普通的管道或文件。如果有另一台可以连接的计算机,您可以从命令行轻松测试这一点。

$ ssh -t asaph
asaph$ echo "Here we are, at a prompt."
Here we are, at a prompt.
asaph$ exit
$ ssh -T asaph
echo "The shell here on asaph sees no terminal; so, no prompt."
The shell here on asaph sees no terminal; so, no prompt.
exit
$

因此,当您通过 SSH 之类的现代协议生成一个命令时,您需要考虑是否希望远程端的程序认为您是一个通过终端输入的人,或者是否最好认为它正在与通过文件或管道输入的原始数据进行对话。

当与终端对话时,程序实际上不需要有任何不同的行为。只是为了我们的方便,他们改变了他们的行为。他们通过调用相当于 Python 的isatty()调用(“这是电传吗?”),然后根据该调用返回的内容改变它们的行为。以下是他们不同行为的一些常见方式:

  • 经常交互使用的程序在与终端对话时会给出一个可读的提示。然而,当他们认为输入来自一个文件时,他们会避免打印提示符,因为否则当您运行一个长的 shell 脚本或 Python 程序时,您的屏幕会被数百个连续的提示符弄得乱七八糟!
  • 如今,当复杂的交互程序的输入是 TTY 时,它们通常会打开命令行编辑。这使得许多控制字符很特别,因为它们习惯于访问命令行历史和执行编辑命令。当它们不受终端控制时,这些程序关闭命令行编辑,并把控制字符作为输入流的正常部分。
  • 许多程序在监听终端时一次只读取一行输入,因为人类喜欢对他们输入的每一个命令都得到立即的响应。然而,当从管道或文件中读取时,这些相同的程序会等到数千个字符到达后,才试图解释它们的第一批输入。正如您刚才看到的,bash保持在一次一行的模式,即使它的输入是一个文件,但是 Python 决定在试图执行它的第一行之前从它的输入中读取整个 Python 脚本。
  • 更常见的是,程序根据是否与终端对话来调整输出。如果用户可能正在观看,他们希望输出的每一行,甚至每一个字符立即出现。但是,如果他们只是在与一个文件或管道对话,他们会等待并批量处理大块的输出,更有效地一次发送整个块。

最后两个问题都涉及缓冲,当您采用通常手动完成的过程并试图使其自动化时,会导致各种各样的问题——因为在这样做时,您经常从终端输入转移到通过文件或管道提供的输入,并且突然发现程序的行为完全不同。它们甚至可能会挂起,因为“print”语句不会立即产生输出,而是保存它们的结果,以便在它们的输出缓冲区已满时一次全部推出。

前面的问题就是为什么许多精心编写的程序,无论是 Python 还是其他语言,都频繁地在它们的输出上调用flush() ,以确保缓冲区中等待的任何东西都继续前进并被发送出去,而不管输出看起来是否像一个终端。

因此,这些是终端和缓冲的基本问题:当与终端对话时,程序通常以特殊的方式改变它们的行为,如果它们认为它们正在写入文件或管道,而不是让您立即看到它们的输出,它们通常会开始大量缓冲它们的输出。

端子做缓冲

除了刚刚描述的特定于程序的行为之外,终端设备还会带来另一类问题。当您希望程序一次读取一个字符的输入,但是 Unix 终端设备本身正在缓冲您的击键,以便将它们作为一整行来传送时,会发生什么情况呢?这种常见的问题之所以会发生,是因为 Unix 终端默认采用“规范的”输入处理,即让用户输入一整行——甚至通过退格和重新键入来编辑它——然后最后按 enter 键,让程序看到他们键入的内容。

如果你想关闭规范处理,这样程序就可以看到输入的每个字符,你可以使用stty“设置当前 TTY 的设置”命令来禁用它。

$ stty -icanon

另一个问题是,Unix 终端传统上支持两次击键,这两次击键最初是为了让用户可以暂停输出,并在屏幕滚动并被更多文本取代之前阅读满屏的文本。通常,这些字符 Ctrl+S 表示“停止”,Ctrl+Q 表示“继续”,如果二进制数据进入自动 Telnet 连接,这是一个非常令人烦恼的事情,因为第一个 Ctrl+S 会暂停终端并可能破坏会话。

同样,可以使用stty关闭该设置。

$ stty -ixon -ixoff

这是你在终端缓冲时会遇到的两个最大的问题,但是还有很多不太出名的设置也会让你伤心。因为有太多的方式——并且因为它们在不同的 Unix 实现之间有所不同——stty命令实际上支持两种模式。模式是cookedraw,它们一起打开和关闭几十种设置,如icanonixon

$ stty raw
$ stty cooked

如果在经过一些实验后,您的终端设置变得一塌糊涂,大多数 Unix 系统都提供了一个命令,用于将终端重置为合理的设置。(请注意,如果您玩stty玩得太认真,您可能需要按 Ctrl+J 来提交重置命令,因为您的回车键(相当于 Ctrl+M)实际上只用于提交命令,因为终端设置称为icrnl。)

$ reset

如果您不是试图让终端在 Telnet 或 SSH 会话中运行,而是碰巧从自己的 Python 脚本中与终端对话,请查看标准库附带的termios模块。通过研究它的示例代码并记住布尔逐位数学是如何工作的,您应该能够控制刚才通过stty命令访问的所有相同的设置。

本书篇幅有限,无法详细介绍终端(因为一两章示例可以很容易地插入到这里,以涵盖更有趣的技术和案例),但是有很多很好的资源可以学习更多关于它们的知识——经典的是 W. Richard Stevens 的《UNIX 环境中的高级编程》第十九章“伪终端”( Addison-Wesley Professional,1992)。

远程登录

这一小段就是你在这本书中所能找到的关于古代远程登录协议的全部内容。为什么?这是不安全的:任何人看到你的 Telnet 包飞过都会看到你的用户名、密码和你在远程系统上做的一切。它很笨重,对于大多数系统管理来说,它已经被完全抛弃了。

ImageTELNET 协议

用途:远程外壳访问

标准:RFC 854 (1989)

运行于:TCP/IP

默认端口:23 库:telnetlib

异常:socket.error、socket.gaierror、EOFError、select.error

我唯一一次发现自己需要 Telnet 是在与小型嵌入式系统通信时,比如 Linksys 路由器或 DSL 调制解调器或防火墙严密的公司网络内部的网络交换机。如果您必须编写一个 Python 程序来与这些设备之一进行 Telnet 对话,这里有一些关于使用 Python telnetlib的提示。

首先,你必须意识到 Telnet 所做的只是建立一个通道——事实上,是一个相当简单的 TCP 套接字(见第三章)——然后通过这个通道双向复制信息。你输入的所有内容都通过电线发送出去,Telnet 把它收到的所有内容打印到屏幕上。这意味着 Telnet 不知道您可能希望远程外壳协议知道的所有事情。

例如,当您远程登录到 Unix 机器时,通常会出现一个login:提示,让您输入用户名,然后出现一个password:提示,让您输入密码。如今仍在使用 Telnet 的小型嵌入式设备可能遵循稍微简单一些的脚本,但是它们经常要求某种密码或认证。不管怎样,Telnet 本身对这种交换模式一无所知!对于您的 Telnet 客户机来说,password:只是九个随机字符,它们通过 TCP 连接飞过来,必须打印到您的屏幕上。它不知道你正在被提示,你正在响应,或者,过一会儿,远程系统会知道你是谁。

Telnet 不知道认证的事实有一个重要的后果:您不能给 Telnet 命令本身任何参数来对远程系统进行预认证,也不能避免首次连接时弹出的登录和密码提示。如果您打算使用普通的 Telnet,不知何故,您必须观察这两个提示(或者远程系统提供的任何提示)的输入文本,然后通过键入正确的回复进行响应。

显然,如果系统呈现的用户名和密码提示不同,那么当您的密码失败时,您很难期望打印的错误消息或响应是标准化的。这就是为什么 Telnet 很难用 Python 这样的语言编写脚本和编程。除非您知道远程系统在响应您的登录和密码时可能会输出的每一条错误消息(可能不仅仅是“错误密码”消息,还可能是“无法生成 shell:内存不足”、“未安装主目录”和“超出配额:将您限制在受限制的 shell 中”之类的消息),否则您的脚本有时会遇到等待查看命令提示符或特定错误消息的情况,相反,它将永远等待,而看不到它所识别的入站字符流中的任何内容。

因此,如果您使用 Telnet,您就是在玩一个纯文本的游戏。您等待文本到达,然后尝试用远程系统可以理解的内容进行回复。为了帮助您做到这一点,Python telnetlib 不仅提供了发送和接收数据的基本方法,还提供了一些例程来监视和等待来自远程系统的特定字符串。在这方面,telnetlib有点像我在本章前面提到的第三方 Python pexpect库,因此它有点像古老的 Unix expect命令。事实上,这些telnetlib套路之一,为了纪念它的前辈,被命名为expect()

清单 16-2 连接到一个主机,自动完成整个来回的登录对话,然后运行一个简单的命令,这样你就可以看到它的输出。这是自动化远程登录对话的最低限度。

清单 16-2 。使用 Telnet 登录远程主机

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/telnet_login.py
# Connect to localhost, watch for a login prompt, and try logging in

import argparse, getpass, telnetlib

def main(hostname, username, password):
    t = telnetlib.Telnet(hostname)
    # t.set_debuglevel(1)        # uncomment to get debug messages
    t.read_until(b'login:')
    t.write(username.encode('utf-8'))
    t.write(b'\r')
    t.read_until(b'assword:')    # first letter might be 'p' or 'P'
    t.write(password.encode('utf-8'))
    t.write(b'\r')
    n, match, previous_text = t.expect([br'Login incorrect', br'\$'], 10)
    if n == 0:
        print('Username and password failed - giving up')
    else:
        t.write(b'exec uptime\r')
        print(t.read_all().decode('utf-8'))  # read until socket closes

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Use Telnet to log in')
    parser.add_argument('hostname', help='Remote host to telnet to')
    parser.add_argument('username', help='Remote username')
    args = parser.parse_args()
    password = getpass.getpass('Password: ')
    main(args.hostname, args.username, password)

如果脚本成功,它会向您展示简单的uptime命令在远程系统上打印的内容。

$ python telnet_login.py example.com brandon
Password: *abc123*
10:24:43 up 5 days, 12:13, 14 users, load average: 1.44, 0.91, 0.73

清单向您展示了由telnetlib驱动的会话的一般结构。首先,建立一个连接,在 Python 中由一个Telnet类的实例表示。这里只指定了主机名,但是您也可以提供一个端口号来连接到标准 Telnet 之外的其他服务端口。

如果想让 Telnet 对象打印出它在会话期间发送和接收的所有字符串,可以调用set_debuglevel(1) 。事实证明,这对于编写清单中所示的非常简单的脚本非常重要,因为在两种不同的情况下,脚本挂起,我必须在打开调试消息的情况下重新运行它,以便我可以看到实际的输出并修复脚本。(有一次我无法匹配返回的确切文本,另一次我忘记了 uptime 命令末尾的'\r'。)我通常只在程序运行良好时关闭调试,然后在我想对脚本做更多工作时再打开它。

请注意,Telnet并没有掩盖它的服务由 TCP 套接字支持的事实,它会将引发的任何socket.errorsocket.gaierror异常传递给你的程序。

一旦建立了 Telnet 会话,交互通常就变成了接收-发送模式,在这种模式下,您等待来自远端的提示或响应,然后发送下一条信息。该清单说明了等待文本到达的两种方法:

  • 简单的read_until()方法等待一个文字字符串到达,然后它返回一个字符串,该字符串提供了从它开始列出到最终看到您等待的字符串的所有文本。
  • 更强大、更复杂的expect()方法采用 Python 正则表达式列表。一旦来自远端的文本最终与某个正则表达式相匹配,expect()返回三项:匹配模式列表中的索引、正则表达式SRE_Match对象本身,以及接收到的导致匹配文本的文本。关于如何使用SRE_Match的更多信息,包括查找模式中任何子表达式的值,请阅读re模块的标准库文档。

正则表达式一如既往,必须认真编写。当我第一次编写这个脚本时,我使用'$'作为expect()模式来等待 shell 提示符出现——唉,这是正则表达式中的一个特殊字符!因此,清单中显示的修改后的脚本对$进行了转义,这样expect()实际上会一直等待,直到看到来自远端的美元符号。

如果脚本因为密码不正确而看到一条错误消息,并且没有永远等待登录或密码提示,而这些提示从未到达或者看起来与预期不同,那么它将退出。

$ python telnet_login.py example.com brandon
Password: *wrongpass*
Username and password failed - giving up

如果您最终编写了一个必须使用 Telnet 的 Python 脚本,它将只是这里显示的相同简单模式的一个更大或更复杂的版本。

read_until()expect()都有一个可选的第二个参数,名为timeout,这个参数限制了调用在放弃并将控制权返回给 Python 脚本之前,以秒为单位观察文本模式的最长时间。如果它们因为超时而退出并放弃,它们不会引发错误;相反(非常尴尬),他们只是返回到目前为止看到的文本,并让您来判断该文本是否包含模式!

在 Telnet 对象中有一些零碎的东西,我不需要在这里介绍。你可以在telnetlib标准库文档中找到它们,包括一个interact()方法,它允许用户使用终端直接通过你的 Telnet 连接“交谈”!这种调用在过去很流行,那时您希望自动登录,但是自己控制并发出普通命令。

Telnet 协议确实有嵌入控制信息的约定,并且telnetlib小心地遵循这些协议规则,以将您的数据与出现的任何控制代码分开。因此,您可以使用一个Telnet对象来发送和接收您想要的所有二进制数据,并且忽略控制代码也可能到达的事实。但是,如果您正在做一个复杂的基于 Telnet 的项目,那么您可能需要处理选项。

通常,每次 Telnet 服务器发送选项请求时,telnetlib都会断然拒绝发送或接收该选项。但是,您可以为 Telnet 对象提供自己的回调函数来处理选项。一个适度的例子显示在清单 16-3 中。对于大多数选项,它只是重新实现默认的telnetlib行为,并拒绝处理任何选项。(永远记住以这样或那样的方式回应每个选项;不这样做通常会挂起 Telnet 会话,因为服务器会永远等待您的回复。)如果服务器表示对“终端类型”选项感兴趣,那么该客户端发送一个回复mypython,它在登录后运行的 shell 命令将该回复视为其$TERM环境变量。

清单 16-3 。如何处理 Telnet 选项代码

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/telnet_codes.py
# How your code might look if you intercept Telnet options yourself

import argparse, getpass
from telnetlib import Telnet, IAC, DO, DONT, WILL, WONT, SB, SE, TTYPE

def process_option(tsocket, command, option):
    if command == DO and option == TTYPE:
        tsocket.sendall(IAC + WILL + TTYPE)
        print('Sending terminal type "mypython"')
        tsocket.sendall(IAC + SB + TTYPE + b'\0'b'mypython' + IAC + SE)
    elif command in (DO, DONT):
        print('Will not', ord(option))
        tsocket.sendall(IAC + WONT + option)
    elif command in (WILL, WONT):
        print('Do not', ord(option))
        tsocket.sendall(IAC + DONT + option)

def main(hostname, username, password):
    t = Telnet(hostname)
    # t.set_debuglevel(1)        # uncomment to get debug messages
    t.set_option_negotiation_callback(process_option)
    t.read_until(b'login:', 10)
    t.write(username.encode('utf-8') + b'\r')
    t.read_until(b'password:', 10)    # first letter might be 'p' or 'P'
    t.write(password.encode('utf-8') + b'\r')
    n, match, previous_text = t.expect([br'Login incorrect', br'\$'], 10)
    if n == 0:
        print("Username and password failed - giving up")
    else:
        t.write(b'exec echo My terminal type is $TERM\n')
        print(t.read_all().decode('ascii'))

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Use Telnet to log in')
    parser.add_argument('hostname', help='Remote host to telnet to')
    parser.add_argument('username', help='Remote username')
    args = parser.parse_args()
    password = getpass.getpass('Password: ')
    main(args.hostname, args.username, password)

有关 Telnet 选项如何工作的更多详细信息,您也可以参考相关的 RFC。在下一节中,我将抛开古老的不安全的 Telnet 协议,开始讨论一种运行远程命令的现代而安全的方法。

SSH:安全外壳

SSH 协议是安全加密协议最著名的例子之一(HTTPS 可能是最著名的)。

ImageSSH 协议

用途:安全远程外壳、文件传输、端口转发

标准:RFC 4250–4256(2006)

运行于:TCP/IP

默认端口:22

库:paramiko

异常:socket.error、socket.gaierror、paramiko。SSHException

SSH 起源于一个早期的协议,该协议支持名为rloginrshrcp的“远程登录”、“远程 shell”和“远程文件复制”命令,在当时,这些命令在支持它们的站点上比 Telnet 更受欢迎。你无法想象rcp是一个怎样的启示,特别是,除非你花了几个小时试图在只有 Telnet 和一个试图为你输入密码的脚本的计算机之间传输一个二进制文件,却发现你的文件包含一个看起来像 Telnet 或远程终端的控制字符的字节,导致整个事情挂起,直到你添加一个转义层(或者想出如何禁用 Telnet escape 键和所有发生在远程终端上的解释)。

然而,rlogin 家族成员的最大特点是,他们不只是在不知道正在发生的事情的意义的情况下重复用户名和密码提示。相反,他们在整个认证过程中都参与其中,你甚至可以在你的主目录下创建一个文件,告诉他们“当一个叫brandon的人试图从asaph机器连接时,不需要密码就让他们进来。”突然之间,系统管理员和 Unix 用户每个月都有了原本用于输入密码的时间。此外,突然间你可以rcp将十个文件从一台机器复制到另一台机器上,就像你将它们复制到本地文件夹中一样容易。

SSH 保留了早期远程 shell 协议的所有这些优秀特性,同时带来了安全性和硬加密,在管理关键服务器方面得到了全世界的信任。本章将关注第三方的paramiko Python 包,它可以使用 SSH 协议,并且做得如此成功,以至于它实际上也已经被移植到 Java 上,因为 Java 世界的人们希望能够像我们使用 Python 时一样容易地使用 SSH。

SSH 概述

这本书的第一部分谈了很多关于多路复用的内容——关于 UDP ( 第二章)和 TCP ( 第三章)如何采用底层 IP 协议,该协议没有考虑到实际上可能有几个用户或应用在一台计算机上需要通信,并添加了 UDP 和 TCP 端口号的概念,以便一对 IP 地址之间可以同时进行几个不同的对话。

一旦复用的基本层次建立起来,我们或多或少就把这个话题抛在了脑后。到目前为止,我们已经学习了十几章的协议,这些协议采用 UDP 或 TCP 连接,然后愉快地将它用于一件事——下载网页或发送电子邮件——从不试图通过一个套接字同时做几件事。

现在我们来到 SSH,我们发现一个非常复杂的协议,它实际上实现了自己的多路复用。几个信息“通道”可以共享同一个 SSH 套接字。SSH 通过其套接字发送的每个信息块都标有一个“通道”标识符,以便几个会话可以共享该套接字。

子通道有意义至少有两个原因。首先,尽管通道 ID 为传输的每个信息块占用了一点带宽,但是与 SSH 为了协商和维护加密而必须传输的额外信息相比,额外的数据是很少的。其次,通道是有意义的,因为 SSH 连接的真正开销是设置它。主机密钥协商和身份验证总共需要几秒钟的时间,一旦建立了连接,您就希望能够使用它进行尽可能多的操作。由于通道的 SSH 概念,您可以在关闭连接之前通过执行许多操作来分摊连接的高成本。

连接后,您可以创建几种通道:

  • 交互式 shell 会话,如 Telnet 支持的会话
  • 单个命令的单独执行
  • 让您浏览远程文件系统的文件传输会话
  • 截取 TCP 连接的端口转发

在接下来的部分中,您将了解所有这些类型的渠道。

SSH 主机密钥

当一个 SSH 客户机第一次连接到一个远程主机时,两者交换临时公钥,这样它们就可以加密剩下的对话,而不会向任何正在监视的第三方透露任何信息。然后,在客户机愿意透露任何进一步的信息之前,它要求证明远程服务器的身份。这是很有意义的第一步:如果你真的在和一个暂时设法获取远程服务器 IP 的黑客软件交谈,你不希望 SSH 泄露你的用户名——更不用说你的密码了!

正如你在第六章中看到的,互联网上机器身份问题的一个答案是建立一个公钥基础设施。首先,您指定一组名为认证机构 的组织来发布证书。然后,在所有的 web 浏览器和其他现有的 SSL 客户机中安装它们的公钥列表。然后这些组织向你收费,以验证你真的是google.com(或者你是谁)并且你应该得到你的google.com SSL 证书的签名。最后,您可以在 web 服务器上安装证书,每个人都会信任您的身份。

从 SSH 的角度来看,这个系统有很多问题。虽然您确实可以在组织内部构建一个公钥基础设施,将您自己的签名机构的证书分发到您的 web 浏览器或其他应用,然后可以在不支付第三方费用的情况下签署您自己的服务器证书,但是对于 SSH 之类的东西来说,公钥基础设施仍然是一个非常麻烦的过程。服务器管理员希望一直设置、使用和拆除服务器,而不必先与中央机构联系。

因此,SSH 的想法是,每台服务器在安装时都会创建自己的随机公钥-私钥对,这个密钥对没有经过任何人的签名。相反,两种方法中的一种被用于密钥分发。

  • 系统管理员编写一个脚本,收集组织中的所有主机公钥,创建一个列出所有公钥的ssh_known_hosts,并将该文件放在组织中每个系统的/etc/sshd目录中。他们还可能使它对任何桌面客户端都可用,比如 Windows 下的 PuTTY 命令。现在,每个 SSH 客户机甚至在第一次连接之前就知道每个 SSH 主机密钥。
  • 或者,管理员可以简单地放弃提前知道主机密钥的想法,而是让每个 SSH 客户端在第一次连接时记住它们。SSH 命令行的用户对此会很熟悉:客户机说它不识别您正在连接的主机,您本能地回答“是”,它的密钥存储在您的~/.ssh/known_hosts文件中。实际上,在第一次见面时,你无法保证你真的在和你认为的主人交谈。尽管如此,至少你可以保证,你对那台机器的每一次后续连接都是到正确的地方,而不是到其他服务器,而这些服务器是有人在同一个 IP 地址交换的(当然,除非有人偷了那台主机的密钥)。

当 SSH 命令行看到不熟悉的主机时,熟悉的提示符如下所示:

$ ssh asaph.rhodesmill.org
The authenticity of host 'asaph.rhodesmill.org (74.207.234.78)' can't be established.
RSA key fingerprint is 85:8f:32:4e:ac:1f:e9:bc:35:58:c1:d4:25:e3:c7:8c.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added 'asaph.rhodesmill.org,74.207.234.78' (RSA) to the list of known hosts.

深埋在倒数第二个完整行中的答案是我输入的答案,它让宋承宪可以进行连接并记住下次使用的密钥。如果 SSH 曾经连接到一个主机并看到一个不同的密钥,它的反应是非常严重的。

$ ssh asaph.rhodesmill.org
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!

任何曾经不得不从头开始重建服务器并忘记保存旧的 SSH 密钥的人都会熟悉这条消息。如果没有它们,新重建的主机现在将使用重新安装生成的新密钥。走到所有 SSH 客户端并删除有问题的旧密钥,以便它们在重新连接时悄悄地学习新密钥,这可能是很痛苦的。

paramiko库完全支持围绕主机密钥的所有常规 SSH 策略。然而,它的默认行为是相当宽容的。默认情况下,它不加载任何主机密钥文件,因此它必须为您连接的第一台主机引发一个异常,因为它将无法验证其密钥。

>>> import paramiko
>>> client = paramiko.SSHClient()
>>> client.connect('example.com', username='test')
Traceback (most recent call last):
  ...
paramiko.ssh_exception.SSHException: Server 'example.com' not found in known_hosts

要像普通的 SSH 命令一样工作,在建立连接之前加载系统和当前用户的已知主机密钥。

>>> client.load_system_host_keys()
>>> client.load_host_keys('/home/brandon/.ssh/known_hosts')
>>> client.connect('example.com', username='test')

paramiko 库还允许您选择如何处理未知主机。一旦创建了一个客户机对象,就可以为它提供一个决策类,当主机键未被识别时,会询问该如何处理。您可以通过继承MissingHostKeyPolicy类来自己构建这些类。

>>> class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
...    def missing_host_key(self, client, hostname, key):
...        return
...
>>> client.set_missing_host_key_policy(AllowAnythingPolicy())
>>> client.connect('example.com', username='test')

请注意,通过对missing_host_key()方法 的争论,您可以获得几条信息来作为决策的基础。例如,您可以允许在没有主机密钥的情况下连接到您自己的服务器子网上的机器,但不允许所有其他机器。

在 paramiko 中,也有几个决策类已经实现了几个基本的主机键选项。

  • paramiko.AutoAddPolicy:当第一次遇到主机密钥时,主机密钥会自动添加到您的用户主机密钥库(Unix 系统上的文件~/.ssh/known_hosts)中,但是从那时起,主机密钥的任何更改都会引发致命的异常。
  • 使用未知密钥连接到主机只会引发一个异常。
  • paramiko.WarningPolicy:未知主机导致记录警告,但允许连接继续。

当编写一个将执行 SSH 的脚本时,我总是从使用普通的ssh命令行工具“手动”连接到远程主机开始,这样我就可以对它的提示回答“是”,并在我的主机密钥文件中获得远程主机的密钥。这样,我的程序就永远不必担心处理丢失键的情况,如果遇到错误,也不会出错。

然而,如果你不像我一样喜欢手工操作,那么AutoAddPolicy可能是你最好的选择。它从来不需要人类的互动,但它至少会在随后的遭遇中向你保证,你仍然和以前一样在和同一台机器说话。因此,即使这台机器是一个特洛伊木马,它正在记录你与它的所有交互并秘密记录你的密码(如果你正在使用的话),它至少必须向你证明它在你每次连接时都持有相同的密钥。

SSH 认证

SSH 验证的整个主题是大量优秀文档、文章和博客文章的主题,所有这些都可以在 Web 上找到。关于配置常见的 SSH 客户端、在 Unix 或 Windows 主机上设置 SSH 服务器以及使用公共密钥来验证自己的身份(这样您就不必一直输入密码)的信息非常丰富。因为这一章主要是关于如何在 Python 中“说 SSH ”,所以我将简单概述一下认证是如何工作的。

通常有三种方法向您通过 SSH 联系的远程服务器证明您的身份。

  • 您可以提供用户名和密码。
  • 您可以提供一个用户名,然后让您的客户端成功地执行一个公钥挑战响应。这个巧妙的操作设法证明您拥有一个秘密的“身份”密钥,而不会将其内容暴露给远程系统。
  • 您可以执行 Kerberos 身份验证。如果远程系统被设置为允许 Kerberos(这在目前看来非常罕见),并且如果您已经运行了kinit命令行工具来向 SSH 服务器的认证域中的一个主 Kerberos 服务器证明您的身份,那么您应该可以在没有密码的情况下进入。

由于第三种选择很少,我们将集中讨论前两种。

在 paramiko 中使用用户名和密码很简单——您只需在对connect()方法的调用中提供它们。

>>> client.connect('example.com', username='brandon', password=mypass)

公钥认证使用ssh-keygen创建一个“身份”密钥对(通常存储在~/.ssh目录中),无需密码就可以用来认证您的身份,这使得 Python 代码更加简单!

>>> client.connect('my.example.com')

如果您的身份密钥文件不是存储在普通的~/.ssh/id_rsa文件中,那么您可以手动向connect()方法提供它的文件名——或者一个完整的 Python 文件名列表。

>>> client.connect('my.example.com', key_filename='/home/brandon/.ssh/id_sysadmin')

当然,根据 SSH 的一般规则,只有在将id_sysadmin.pub文件中的公钥附加到远程端的“authorized hosts”文件之后,提供这样的公钥身份才有效,通常命名为:

/home/brandon/.ssh/authorized_keys

如果您在让公钥认证工作时遇到问题,请始终检查远程.ssh目录和其中文件的文件权限。如果 SSH 服务器的某些版本看到这些文件是组可读或组可写的,它们会感到不安。对.ssh目录使用模式 0700,对里面的文件使用模式 0600,往往会让 SSH 最开心。在最近的版本中,将 SSH 密钥复制到其他帐户的任务实际上已经通过一个小命令实现了自动化,该命令将确保为您正确设置文件权限。

ssh-copy-id -i ~/.ssh/id_rsa.pub myaccount@example.com

一旦connect()方法成功,您现在就可以开始执行远程操作了,所有这些操作都将通过同一个物理套接字转发,而不需要重新协商主机密钥、您的身份或保护 SSH 套接字本身的加密。

Shell 会话和单个命令

一旦您有了一个连接的 SSH 客户机,SSH 操作的整个世界就向您敞开了。只需询问,您就可以访问远程 shell 会话,运行单独的命令,开始文件传输会话,并设置端口转发。您将依次查看这些操作。

首先,SSH 可以为您建立一个原始的 shell 会话,运行在虚拟终端内的远程终端上,这样程序在终端上与用户交互时就像平时一样。这种连接的行为非常像 Telnet 连接。看一下清单 16-4 中的示例,它在远程 shell 中推送一个简单的echo命令,然后要求它退出。

清单 16-4 。在 SSH 下运行交互式 Shell】

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/ssh_simple.py
# Using SSH like Telnet: connecting and running two commands

import argparse, paramiko, sys

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    channel = client.invoke_shell()
    stdin = channel.makefile('wb')
    stdout = channel.makefile('rb')

    stdin.write(b'echo Hello, world\rexit\r')
    output = stdout.read()
    client.close()

    sys.stdout.buffer.write(output)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Connect over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    args = parser.parse_args()
    main(args.hostname, args.username)

您可以看到这个脚本带有在终端上运行的程序的痕迹。它不能整齐地封装它发出的两个命令中的每一个并分离它们的参数,而是必须使用空格和回车,并信任远程 shell 来正确地划分内容。请注意,这个脚本是在假设您有一个身份文件和一个远程授权密钥文件的情况下编写的,因此不需要键入密码。如果是这样,那么您可以使用注释掉的 password 参数来编辑脚本以提供一个。为了避免在 Python 文件中输入密码,您可以让它调用getpass() ,就像您在 Telnet 示例中所做的那样。

此外,如果您运行这个命令,您将看到您键入的命令实际上被回显了两次,并且没有明显的方法将这些命令回显与实际的命令输出分开。

Welcome to Ubuntu 13.10 (GNU/Linux 3.11.0-19-generic x86_64)
Last login: Wed Apr 23 15:06:03 2014 from localhost

echo Hello, world
exit
test@guinness:~$ echo Hello, world
Hello, world
test@guinness:~$ exit
logout

你能猜到发生了什么事吗?

因为在发出echoexit命令(这需要一个循环重复进行read()调用)之前,您没有暂停并耐心等待 shell 提示,所以命令文本在远程主机发出欢迎消息的过程中就被发送到了远程主机。因为 Unix 终端在默认情况下处于“熟”状态,它会响应用户的击键,所以命令会打印出来,就在“Last login”行的下面。

然后实际的bash shell 启动,将终端设置为raw模式,因为它喜欢提供自己的命令行编辑界面,然后开始逐字符读取命令。因为它假设您希望看到您正在键入的内容(即使您实际上已经完成了键入,并且它只是从几毫秒前的缓冲区中读取字符),所以它会将每个命令第二次回显到屏幕上。

当然,如果没有很好的解析和智能,您将很难编写一个 Python 例程,从您通过 SSH 连接接收的输出中挑选出实际的命令输出(单词Hello, world)。

由于所有这些古怪的、依赖于终端的行为,你通常应该避免使用invoke_shell() ,除非你实际上是在编写一个交互式终端程序,让一个真实的用户输入命令。

运行远程命令的一个更好的选择是使用exec_command(),而不是启动整个 shell 会话,只运行一个命令。它让您可以控制该命令的标准输入、输出和错误流,就像您使用标准库中的 subprocess模块在本地运行该命令一样。清单 16-5 显示了一个演示其使用的脚本。exec_command() 和本地子进程的区别(当然,除了命令在远程机器上运行的事实之外!)的缺点是您没有机会将命令行参数作为单独的字符串传递给远程服务器。相反,您必须传递一个完整的命令行,以便由远程端的 shell 进行解释。

清单 16-5 。运行单个 SSH 命令

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/ssh_commands.py
# Running three separate commands, and reading three separate outputs

import argparse, paramiko

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    for command in 'echo "Hello, world!"', 'uname', 'uptime':
        stdin, stdout, stderr = client.exec_command(command)
        stdin.close()
        print(repr(stdout.read()))
        stdout.close()
        stderr.close()

    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Connect over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    args = parser.parse_args()
    main(args.hostname, args.username)

与我们之前的所有 Telnet 和 SSH 对话不同,这个脚本将这三个命令的输出作为完全独立的数据流接收。不会将其中一个命令的输出与任何其他命令的输出混淆。

$ python3 ssh_commands.py localhost brandon
'Hello, world!\n'
'Linux\n'
'15:29:17 up 5 days, 22:55,  5 users,  load average: 0.78, 0.83, 0.71\n'

除了安全性之外,这是 SSH 提供的巨大进步:能够在远程机器上执行语义上独立的任务,而不必单独连接到远程机器。

正如在前面的“Telnet”一节中提到的,如果您需要引用命令行参数,以便包含文件名和特殊字符的空格能够被远程 shell 正确解释,那么在为exec_command()函数构建命令行时,您可能会发现 Python pipes 模块中的quotes()非常有用。

每次使用invoke_shell()启动一个新的 SSH shell 会话,以及每次使用exec_command()启动一个命令,都会在后台创建一个新的 SSH“通道”来提供类似文件的 Python 对象,让您可以与远程命令的标准输入、输出和错误流进行对话。这些通道并行运行,SSH 会在您的单个 SSH 连接上巧妙地交错它们的数据,以便所有的对话同时发生而不会混淆。

看一下清单 16-6 中的一个简单例子。这里有两个命令行是远程启动的,每个都是一个简单的 shell 脚本,其中有一些夹杂着sleep停顿的echo命令。如果您愿意,您可以假装这些是真正的文件系统命令,它们在遍历文件系统时返回数据,或者它们是 CPU 密集型操作,只缓慢地生成和返回结果。对于宋承宪来说,这种差异根本无关紧要。重要的是,这些通道每次都处于空闲状态几秒钟,然后随着更多的数据变得可用而再次活跃起来。

清单 16-6 。SSH 通道并行运行

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/ssh_threads.py
# Running two remote commands simultaneously in different channels

import argparse, paramiko, threading

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    def read_until_EOF(fileobj):
        s = fileobj.readline()
        while s:
            print(s.strip())
            s = fileobj.readline()

    ioe1 = client.exec_command('echo One;sleep 2;echo Two;sleep 1;echo Three')
    ioe2 = client.exec_command('echo A;sleep 1;echo B;sleep 2;echo C')
    thread1 = threading.Thread(target=read_until_EOF, args=(ioe1[1],))
    thread2 = threading.Thread(target=read_until_EOF, args=(ioe2[1],))
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()

    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Connect over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    args = parser.parse_args()
    main(args.hostname, args.username)

为了能够同时处理这两个数据流,您启动了两个线程,并为它们提供了一个读取通道。两者都在新信息到达时打印每一行,并在readline()命令通过返回一个空字符串指示文件结束时退出。运行时,该脚本应该返回如下内容:

$ python3 ssh_threads.py localhost brandon
One
A
B
Two
Three
C

正如您所看到的,同一 TCP 连接上的 SSH 通道是完全独立的,每个通道都可以按照自己的速度接收(和发送)数据,并且可以在它们正在对话的特定命令最终终止时独立关闭。您即将看到的功能也是如此——文件传输和端口转发。

SFTP:通过 SSH 进行文件传输

SSH 协议的第 2 版包括一个名为 SSH 文件传输协议(SFTP) 的子协议,它允许您遍历远程目录树,创建和删除目录和文件,以及在本地和远程机器之间来回复制文件。事实上,SFTP 的功能是如此复杂和完整,它们不仅支持简单的文件复制操作,而且它们还可以支持图形化的文件浏览器,甚至可以让远程文件系统安装在本地!(谷歌一下sshfs系统了解详情。)

对于我们这些曾经不得不使用脆弱的脚本复制文件的人来说,SFTP 协议是一个不可思议的福音,脆弱的脚本试图通过小心地转义二进制数据来通过 Telnet 发送数据。每次您想要移动文件时,SSH 不会让您启动它自己的sftp命令行,而是遵循 RSH 的传统,提供一个scp命令行工具,其行为就像传统的cp命令一样,但是它允许您在任何文件名前面加上hostname:来表示它存在于远程机器上。这意味着远程复制命令保留在您的命令行历史中,就像您的其他 shell 命令一样,而不是丢失到单独命令提示符的单独历史缓冲区中,您必须调用然后退出(这是传统 FTP 客户端的一大烦恼)。

此外,SFTP 和 sftpscp命令的最大成就在于,它们不仅支持密码认证,还允许你使用相同的公钥机制复制文件,这让你在使用ssh命令运行远程命令时避免反复输入密码。

如果你在旧的 FTP 系统上简单浏览一下第十七章,你会对 SFTP 支持的操作种类有一个很好的了解。事实上,大多数 SFTP 命令与您已经运行的用于操作 Unix shell 帐户上的文件的本地命令同名,如chmodmkdir,或者与您可能已经通过 Python os模块熟悉的 Unix 系统调用同名,如lstatunlink。因为这些操作是如此的熟悉,所以在编写 SFTP 命令时,除了在www.lag.net/paramiko/docs/paramiko . SFTPClient-class为 Python SFTP 客户端提供的 paramiko 文档之外,我从不需要任何其他支持。

以下是在 SFTP 时要记住的主要事情:

  • SFTP 协议是有状态的,就像 FTP 和普通的 shell 帐户一样。因此,您可以将所有文件名和目录名作为从文件系统根目录开始的绝对路径传递,或者使用getcwd()chdir()在文件系统中移动,然后使用相对于您到达的目录的路径。
  • 您可以使用file()open()方法打开一个文件(就像 Python 有一个内置的 callable,它存在于两个名称下),然后您得到一个类似 file 的对象,它连接到一个独立于您的 SFTP 通道运行的 SSH 通道。也就是说,您可以继续发出 SFTP 命令,然后在文件系统中移动,复制或打开更多的文件,原始通道仍将连接到其文件,并准备好进行读写。
  • 因为每个打开的远程文件都有一个独立的通道,所以文件传输可以异步进行。您可以一次打开许多远程文件,并让它们全部传输到您的磁盘驱动器,或者打开新文件并以另一种方式发送数据。注意你要认识到这一点,否则你可能会同时打开如此多的通道,以至于每一个都慢得像爬行一样。
  • 最后,请记住,您在 SFTP 传递的任何文件名都不会进行 shell 扩展。如果您尝试使用类似于*的文件名或包含空格或特殊字符的文件名,它们会被简单地解释为文件名的一部分。使用 SFTP 时不涉及 shell。由于 SSH 服务器本身的支持,您可以直接与远程文件系统对话。这意味着,您想要向用户提供的任何模式匹配支持都必须通过自己获取目录内容,然后使用 Python 标准库中的fnmatch中提供的例程,对照每个内容检查它们的模式。

清单 16-7 显示了一个 SFTP 会话的例子。它做一些系统管理员可能经常需要的简单事情(但是,当然,他们也可以用一个scp命令轻松完成):它连接到远程系统并从/var/log目录中复制消息日志文件,也许是为了在本地机器上进行扫描或分析。

清单 16-7 。列出一个目录并用 SFTP 抓取文件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter16/sftp_get.py
# Fetching files with SFTP

import argparse, functools, paramiko

class AllowAnythingPolicy(paramiko.MissingHostKeyPolicy):
    def missing_host_key(self, client, hostname, key):
        return

def main(hostname, username, filenames):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(AllowAnythingPolicy())
    client.connect(hostname, username=username)  # password='')

    def print_status(filename, bytes_so_far, bytes_total):
        percent = 100\. * bytes_so_far / bytes_total
        print('Transfer of %r is at %d/%d bytes (%.1f%%)' % (
            filename, bytes_so_far, bytes_total, percent))

    sftp = client.open_sftp()
    for filename in filenames:
        if filename.endswith('.copy'):
            continue
        callback = functools.partial(print_status, filename)
        sftp.get(filename, filename + '.copy', callback=callback)
    client.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Copy files over SSH')
    parser.add_argument('hostname', help='Remote machine name')
    parser.add_argument('username', help='Username on the remote machine')
    parser.add_argument('filename', nargs='+', help='Filenames to fetch')
    args = parser.parse_args()
    main(args.hostname, args.username, args.filename)

注意,虽然我花了很大篇幅谈论你用 SFTP 打开的每个文件是如何使用它自己的独立通道的,但是 paramiko 提供的简单的get()put()便利函数,是一个open()的轻量级包装器,后面是一个读写循环,并没有尝试任何异步;相反,它们只是阻塞并等待,直到每个完整的文件到达。这意味着前面的脚本一次平静地传输一个文件,产生类似下面的输出:

$ python sftp.py guinness brandon W-2.pdf miles.png
Transfer of 'W-2.pdf' is at 32768/115065 bytes (28.5%)
Transfer of 'W-2.pdf' is at 65536/115065 bytes (57.0%)
Transfer of 'W-2.pdf' is at 98304/115065 bytes (85.4%)
Transfer of 'W-2.pdf' is at 115065/115065 bytes (100.0%)
Transfer of 'W-2.pdf' is at 115065/115065 bytes (100.0%)
Transfer of 'miles.png' is at 15577/15577 bytes (100.0%)
Transfer of 'miles.png' is at 15577/15577 bytes (100.0%)

再次参考刚才提到的 URL 上的优秀的 paramiko 文档,查看 SFTP 支持的简单而完整的文件操作集。

其他特征

在过去的几节中,我已经介绍了基本SSHClient对象上的方法所支持的所有 SSH 操作。您可能比较熟悉的模糊特性,比如远程 X11 会话和端口转发,要求您在 paramiko 接口中更深入一层,直接与客户机的“transport”对象对话。

传输层是实际上知道底层操作的类,这些底层操作组合在一起为 SSH 连接提供动力。你可以很容易地向客户要求运输。

>>> transport = client.get_transport()

虽然我没有足够的篇幅在这里介绍其他 SSH 特性,但是您在本章中获得的对 SSH 的理解应该有助于您理解它们,因为 paramiko 文档结合了示例代码——无论是从 paramiko 项目本身的demos目录,还是从博客、Stack Overflow 或您可能在网上找到的关于 paramiko 的其他资料。

我应该明确提到的一个特性是端口转发,其中 SSH 在本地或远程主机上打开一个端口——至少使该端口可用于来自本地主机的连接,也可能接受来自互联网上其他机器的连接——并通过 SSH 通道“转发”这些连接,在 SSH 通道上连接到远程端的其他主机和端口,来回传递数据。

端口转发很有用。例如,我有时发现自己在开发一个 web 应用,但我无法在笔记本电脑上轻松运行,因为它需要访问数据库和其他资源,而这些资源只能在服务器群中获得。但是我可能不想要在公共端口上运行应用的麻烦,在那里我可能不得不调整防火墙规则来打开它,然后让 HTTPS 运行,这样第三方就看不到我正在进行的工作。

一个简单的解决方案是在远程开发机器上运行正在开发的 web 应用,就像我在本地一样——监听 localhost:8080,这样就不能从另一台计算机上联系到它——然后告诉 SSH,我希望在我的笔记本电脑上建立的到我的本地端口 8080 的连接被转发出去,这样它们就可以真正连接到本地机器上的端口 8080。

$ ssh -L 8080:localhost:8080 devel.example.com

如果在用 paramiko 运行 SSH 连接时需要创建端口转发,那么我有一个坏消息和一个好消息。坏消息是顶级 SSHClient 不提供创建转发的简单方法,因为它支持更常见的操作,比如 shell 会话。相反,您必须通过直接与“transport”对象对话来创建转发,然后自己编写在转发上双向复制数据的循环。

但是好消息是 paramiko 附带了示例脚本,展示了如何编写端口转发循环。paramiko 主干中的这两个脚本应该可以帮助您入门:

http://github.com/paramiko/paramiko/blob/master/demos/forward.py
http://github.com/paramiko/paramiko/blob/master/demos/rforward.py

当然,由于端口转发数据在 SSH 连接内部的通道之间来回传递,所以您不必担心它们是原始的、未受保护的 HTTP 或其他通常对第三方可见的流量;因为它们现在嵌入在 SSH 中,所以它们受到自身加密的保护,不会被拦截。

摘要

远程 shell 协议允许您连接到远程机器,运行 shell 命令,并查看它们的输出,就像命令在本地终端窗口中运行一样。有时您使用这些协议连接到实际的 Unix shell,有时连接到路由器或其他需要配置的网络硬件中的小型嵌入式 shell。

和往常一样,在使用 Unix 命令时,您需要注意输出缓冲、特殊 shell 字符和终端输入缓冲等问题,这些问题会通过篡改数据甚至挂起 shell 连接而使您的生活变得困难。

Python 标准库通过其telnetlib模块支持 Telnet 协议。尽管 Telnet 是古老的、不安全的、难以编写脚本,但它可能是您想要连接的简单设备所支持的唯一协议。

安全 Shell 协议是当前的技术水平,不仅用于连接到远程主机的命令行,还用于复制文件和转发 TCP/IP 端口。得益于第三方 paramiko 包,Python 拥有相当出色的 SSH 支持。当建立 SSH 连接时,您需要记住三件事。

  • Paramiko 将需要验证(或者被明确告知忽略)远程机器的身份,该身份被定义为建立连接时它所提供的主机密钥。
  • 认证通常通过一个密码或通过使用一个公钥-私钥对来完成,公钥-私钥对的公钥部分已经放在远程服务器上的authorized_keys文件中。
  • 一旦通过身份验证,您就可以启动所有种类的 SSH 服务——远程 shells、单个命令和文件传输会话——它们都可以立即运行,而无需您打开新的 SSH 连接,这是因为它们都将在主 SSH 连接中获得自己的“通道”。

下一章将考察一个更老、功能更弱的文件传输协议,它可以追溯到互联网的早期:SFTP 所基于的文件传输协议。

十七、FTP

文件传输协议(FTP)曾经是互联网上使用最广泛的协议之一,每当用户想要在联网的计算机之间传输文件时都会调用。唉,《议定书》曾经风光一时,如今,它的每一个主要角色都有了更好的替代方案。

FTP 曾经支持四种主要活动。FTP 的第一个也是最主要的用途是文件下载。允许公众访问的“匿名”FTP 服务器列表被分发,用户连接以检索文档、新程序的源代码以及图像或电影等媒体。(你用用户名“anonymous”或“ftp”登录他们,然后——出于礼貌,让他们知道谁在使用他们的带宽——你输入你的电子邮件地址作为密码。)当文件需要在计算机帐户之间移动时,FTP 总是首选的协议,因为试图用 Telnet 客户端传输大文件通常是一个冒险的提议。

第二,FTP 经常被人为操纵以提供匿名上传。许多组织希望外部人员能够提交文档或文件,他们的解决方案是建立 FTP 服务器,允许将文件写入一个目录,该目录的内容不能再次列出。这样,用户就看不到(希望也猜不到!)其他用户刚刚提交的文件的名称,并在站点管理员之前得到它们。

第三,该协议通常用于支持计算机帐户之间整个文件树的同步。通过使用提供递归 FTP 操作的客户端,用户可以将整个目录树从他们的一个帐户推到另一个帐户,并且服务器管理员可以克隆或安装新服务,而不必在新机器上从头开始重建它们。当像这样使用 FTP 时,用户通常不知道实际的协议是如何工作的,也不知道传输这么多不同的文件需要许多单独的命令:相反,他们单击一个按钮,一个大的批处理操作就会运行,然后完成这个过程。

第四,也是最后一点,FTP 被用于它最初的目的:交互式的、成熟的文件管理。早期的 FTP 客户端提供了一个命令行提示,感觉有点像 Unix shell 帐户本身,并且——正如我将解释的——该协议从 shell 帐户借用了“当前工作目录”和从一个目录移动到另一个目录的cd命令的概念。后来的客户模仿了类似 Mac 界面的想法,在电脑屏幕上绘制文件夹和文件。但无论是哪种情况,在文件系统浏览活动中,FTP 的全部功能最终都发挥了作用:它不仅支持列出目录、上传和下载文件的操作,还支持创建和删除目录、调整文件权限和重命名文件的操作。

用什么代替 FTP

今天,有比 FTP 协议更好的选择,几乎可以做任何你想做的事情。偶尔,你仍会看到以ftp://开头的网址,但它们变得相当罕见了。如果你有一个遗留系统,并且你需要从你的 Python 程序中使用 FTP,或者因为你想学习更多关于文件传输协议的知识,这一章将会很有用,FTP 是一个很好的历史起点。

该协议最大的问题是缺乏安全性:不仅仅是文件,用户名和密码都是完全明文发送的,任何观察网络流量的人都可以看到。

第二个问题是,FTP 用户倾向于建立一个连接,选择一个工作目录,并在同一个网络连接上进行多项操作。拥有数百万用户的现代互联网服务更喜欢像 HTTP(见第九章第一节)这样的协议,它由简短的、完全独立的请求组成,而不是需要服务器记住当前工作目录等信息的长时间运行的 FTP 连接。

最后一个大问题是文件系统安全性。早期的 FTP 服务器倾向于简单地暴露整个文件系统,让用户cd/四处窥探系统是如何配置的,而不是向用户显示所有者想要暴露的主机文件系统的一小部分。的确,你可以在一个单独的ftp用户下运行服务器,并试图拒绝该用户访问尽可能多的文件;但是 Unix 文件系统的许多区域需要完全公开可读,以便普通用户可以使用那里的程序。

那么有哪些选择呢?

  • 对于文件下载,HTTP(见第九章)是当今互联网的标准协议,必要时为了安全用 SSL 保护。HTTP 不像 FTP 那样公开特定于系统的文件名约定,而是支持独立于系统的 URL。
  • 匿名上传有点不太标准,但是一般趋势是在网页上使用一个表单,指示浏览器使用 HTTP POST 操作来传输用户选择的文件。
  • 自从递归 FTP 文件复制成为将文件传输到另一台计算机的唯一常见方式以来,文件同步已经有了不可估量的改进。像rsyncrdist这样的现代命令可以有效地比较连接两端的文件,只复制新的或已更改的文件,而不是浪费地复制每个文件。(本书不涉及这些命令;试试谷歌一下。)非程序员最有可能使用 Python 驱动的 Dropbox 服务或任何与之竞争的“云驱动”服务,这些服务现在由大型提供商提供。
  • 完全文件系统访问实际上是 FTP 在今天的互联网上仍然常见的一个领域:尽管缺乏安全性,数以千计的低价 ISP 继续支持 FTP,作为用户可以将他们的媒体和(通常)PHP 源代码复制到他们的 web 帐户的手段。如今,一个更好的选择是服务提供商转而支持 SFTP(见第十六章)。

Image 注意FTP 标准是 RFC 959,可在www.faqs.org/rfcs/rfc959.html获得。

通信渠道

FTP 不常见,因为默认情况下,它实际上在操作期间使用了两个 TCP 连接。一个连接是控制信道,它传送命令和结果确认或错误代码。第二个连接是数据通道,它仅用于传输文件数据或其他信息块,如目录列表。从技术上讲,数据通道是全双工的,这意味着它允许文件同时双向传输。然而,在实际操作中,很少使用这种能力。

在传统操作中,从 FTP 服务器下载文件的过程是这样的:

  1. 首先,FTP 客户端通过连接到服务器上的 FTP 端口来建立命令连接。
  2. 客户端通常使用用户名和密码进行身份验证。
  3. 客户端将服务器上的目录更改为它希望存放或检索文件的位置。
  4. 客户端开始监听数据连接的新端口,然后通知服务器该端口。
  5. 服务器连接到客户端打开的端口。
  6. 文件被传输。
  7. 数据连接已关闭。

这种服务器应该连接回客户端的想法在互联网的早期工作得很好;当时,几乎每台可以运行 FTP 客户端的机器都有一个公共 IP 地址,防火墙也相对少见。然而,今天的情况更加复杂。阻止台式机和笔记本电脑连接的防火墙现在很常见,而且许多无线、DSL 和内部商业网络无论如何都不为客户机提供真正的公共 IP 地址。

为了适应这种情况,FTP 还支持所谓的被动模式。在这个场景中,数据连接是反向的:服务器打开一个额外的端口,它告诉客户机进行第二次连接。除此之外,一切都是一样的。

今天,被动模式是大多数 FTP 客户端的默认模式,包括 Python 的ftplib模块,我将在本章解释。

在 Python 中使用 FTP

Python 模块ftplib是 Python 程序员 FTP 的主要接口。它为您处理建立各种连接的细节,并为自动化常见命令提供了方便的方法。

Image 提示如果你只对下载文件感兴趣,那么第一章中介绍的urllib2模块支持 FTP,对于简单的下载任务可能更容易使用;只需用一个ftp:// URL 运行它。在这一章中,我描述了ftplib,因为它提供了urllib2所没有的 FTP 特有的特性。

清单 17-1 展示了一个非常基本的ftplib例子。该程序连接到远程服务器,显示欢迎消息,并打印当前工作目录。

清单 17-1 。建立简单的 FTP 连接

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

from ftplib import FTP

def main():
    ftp = FTP('ftp.ibiblio.org')
    print("Welcome:", ftp.getwelcome())
    ftp.login()
    print("Current working directory:", ftp.pwd())
    ftp.quit()

if __name__ == '__main__':
    main()

欢迎消息通常没有程序可以有效解析的信息,但是如果用户以交互方式调用您的客户端,您可能希望显示它。login()函数可以接受几个参数,包括用户名、密码和第三个很少使用的认证令牌,FTP 称之为“帐户”这里调用它时没有参数,让用户以“匿名”身份登录,并输入一个通用的密码值。

回想一下,FTP 会话可以访问不同的目录,就像 shell 提示符可以使用cd在不同的位置之间移动一样。这里,pwd()函数返回连接的远程站点上的当前工作目录。最后,quit()功能注销并关闭连接。

下面是程序运行时的输出:

$ ./connect.py
Welcome: 220 ProFTPD Server (Bring it on...)
Current working directory: /

ASCII 和二进制文件

在进行 FTP 传输时,您必须决定是希望将文件视为一整块二进制数据,还是希望将其解析为文本文件,以便您的本地计算机可以使用您的平台固有的任何行尾字符将其行粘贴回一起。

正如您所料,当您要求 Python 3 在文本模式下运行时,Python 3 忠实地期望并返回普通字符串,但是如果您正在处理二进制文件数据,它需要字节字符串。

一个以所谓的 ASCII 模式传输的文件一次传送一行,非常尴尬的是,传送到你的程序时没有行尾,所以你必须自己把这些行粘在一起。看一下清单 17-2 中的程序,它下载一个众所周知的文本文件并保存在你的本地目录中。

清单 17-2 。下载 ASCII 文件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter17/asciidl.py
# Downloads README from remote and writes it to disk.

import os
from ftplib import FTP

def main():
    if os.path.exists('README'):
        raise IOError('refusing to overwrite your README file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel')

    with open('README', 'w') as f:
        def writeline(data):
            f.write(data)
            f.write(os.linesep)

        ftp.retrlines('RETR README', writeline)

    ftp.quit()

if __name__ == '__main__':
    main()

在清单中,cwd()函数在远程系统上选择一个新的工作目录。然后retrlines()功能开始传输。它的第一个参数指定在远程系统上运行的命令,通常是RETR,后面跟着一个文件名。它的第二个参数是一个函数,当文本文件的每一行被检索时,这个函数被反复调用;如果省略,数据将简单地打印到标准输出。传递行时去掉了行尾字符,所以自制的writeline()函数只是在写出每一行时将系统的标准行尾附加到每一行上。

尝试运行此程序;程序完成后,您当前的目录中应该有一个名为README的文件。

基本二进制文件传输的工作方式与文本文件传输非常相似。清单 17-3 展示了一个这样的例子。

清单 17-3 。下载二进制文件

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

import os
from ftplib import FTP

def main():
    if os.path.exists('patch8.gz'):
        raise IOError('refusing to overwrite your patch8.gz file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel/v1.0')

    with open('patch8.gz', 'wb') as f:
        ftp.retrbinary('RETR patch8.gz', f.write)

    ftp.quit()

if __name__ == '__main__':
    main()

运行时,这个程序会将一个名为patch8.gz的文件存放到您当前的工作目录中。retrbinary()函数只是将数据块传递给指定的函数。这很方便,因为文件对象的write()函数需要数据,所以在这种情况下,不需要定制函数。

高级二进制下载

ftplib模块提供了第二个可以用于二进制下载的函数:ntransfercmd() 。该命令提供了一个低级界面,但是如果您想了解更多关于下载过程中发生的事情,它会很有用。特别是,这个更高级的命令允许您跟踪传输的字节数,并且您可以使用该信息为用户显示状态更新。清单 17-4 显示了一个使用ntransfercmd()的示例程序。

清单 17-4 。带状态更新的二进制下载

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

import os, sys
from ftplib import FTP

def main():
    if os.path.exists('linux-1.0.tar.gz'):
        raise IOError('refusing to overwrite your linux-1.0.tar.gz file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel/v1.0')
    ftp.voidcmd("TYPE I")

    socket, size = ftp.ntransfercmd("RETR linux-1.0.tar.gz")
    nbytes = 0

    f = open('linux-1.0.tar.gz', 'wb')

    while True:
        data = socket.recv(2048)
        if not data:
            break
        f.write(data)
        nbytes += len(data)
        print("\rReceived", nbytes, end=' ')
        if size:
            print("of %d total bytes (%.1f%%)"
                  % (size, 100 * nbytes / float(size)), end=' ')
        else:
            print("bytes", end=' ')
        sys.stdout.flush()

    print()
    f.close()
    socket.close()
    ftp.voidresp()
    ftp.quit()

if __name__ == '__main__':
    main()

这里有一些新的东西需要注意。首先是对voidcmd()的调用。这会将一个 FTP 命令直接传递给服务器并检查错误,但不返回任何内容。在这种情况下,原始命令是TYPE I。这会将传输模式设置为“图像”,这是 FTP 在内部引用二进制文件的方式。在前面的例子中,retrbinary()自动在后台运行这个命令,但是低级别的ntransfercmd()没有。

接下来,注意ntransfercmd()返回一个由数据套接字和估计大小组成的元组。始终记住,尺寸仅仅是一个估计,它不应该被认为是权威的;文件可能会很快结束,也可能会比该值长得多。同样,如果来自 FTP 服务器的大小估计根本不可用,那么返回的估计大小将是None

对象datasock实际上是一个普通的 TCP 套接字,它具有本书第一部分描述的所有行为(具体见第三章)。在这个例子中,一个简单的循环调用recv(),直到它从套接字中读取了所有数据,沿途将数据写到磁盘,并将状态更新打印到屏幕上。

Image 提示注意清单 17-4 中打印到屏幕上的关于状态更新的两件事。首先,不是打印一个消失在终端顶部的滚动列表,而是每一行都以回车符'\r'开始,它将光标移回到终端的左边缘,这样每一个状态行都会覆盖前一行,并创建一个增加的动画百分比的假象。第二,因为您告诉每个print语句以空格而不是新行结束一行,所以您实际上从未让它完成一行输出,所以您必须flush()标准输出以确保状态更新立即到达屏幕。

收到数据后,关闭数据套接字并调用voidresp()是很重要的,它从服务器读取命令响应代码,如果在传输过程中有任何错误,就会引发异常。即使您不关心检测错误,未能调用voidresp()也会使将来的命令失败,因为服务器的输出套接字将被阻塞,等待您读取结果。

以下是运行该程序的输出示例:

$ ./advbinarydl.py
Received 1259161 of 1259161 bytes (100.0%)

上传数据

文件数据也可以通过 FTP 上传。和下载一样,上传也有两个基本功能:storbinary()storlines() 。两者都需要运行一个命令和传输一个类似文件的对象。storbinary()函数将对该对象反复调用read()方法,直到其内容用尽,而storlines()则相反,调用readline()方法。

与相应的下载函数不同,这些方法不要求您提供自己的可调用函数。(当然,您可以传递一个您自己制作的类似文件的对象,它的read()readline()方法在传输过程中计算输出数据!)

清单 17-5 展示了如何以二进制模式上传文件。

清单 17-5 。二进制上传

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

from ftplib import FTP
import sys, getpass, os.path

def main():
    if len(sys.argv) != 5:
        print("usage:", sys.argv[0],
              "<host> <username> <localfile> <remotedir>")
        exit(2)

    host, username, localfile, remotedir = sys.argv[1:]
    prompt = "Enter password for {} on {}: ".format(username, host)
    password = getpass.getpass(prompt)

    ftp = FTP(host)
    ftp.login(username, password)
    ftp.cwd(remotedir)
    with open(localfile, 'rb') as f:
        ftp.storbinary('STOR %s' % os.path.basename(localfile), f)
    ftp.quit()

if __name__ == '__main__':
    main()

这个程序看起来与早期的工作非常相似。因为大多数匿名 FTP 站点不允许文件上传,所以你必须找一个服务器来测试它;我只是在我的笔记本电脑上安装了旧的、古老的ftpd几分钟,然后像这样运行测试:

$ python binaryul.py localhost brandon test.txt /tmp

我在提示符下输入了密码(brandon是我在这台机器上的用户名)。当程序完成时,我检查了一下,果然,test.txt文件的副本现在在/tmp中。记住不要通过网络在另一台机器上尝试,因为 FTP 不会加密或保护你的密码!

只需将storbinary()改为storlines(),就可以修改这个程序,以 ASCII 模式上传文件。

高级二进制上传

正如下载过程有一个复杂的原始版本一样,也可以使用ntransfercmd()手动上传文件,如清单 17-6 所示。

清单 17-6 。一次上传一个块的文件

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

import os, sys
from ftplib import FTP

def main():
    if os.path.exists('linux-1.0.tar.gz'):
        raise IOError('refusing to overwrite your linux-1.0.tar.gz file')

    ftp = FTP('ftp.kernel.org')
    ftp.login()
    ftp.cwd('/pub/linux/kernel/v1.0')
    ftp.voidcmd("TYPE I")

    socket, size = ftp.ntransfercmd("RETR linux-1.0.tar.gz")
    nbytes = 0

    f = open('linux-1.0.tar.gz', 'wb')

    while True:
        data = socket.recv(2048)
        if not data:
            break
        f.write(data)
        nbytes += len(data)
        print("\rReceived", nbytes, end=' ')
        if size:
            print("of %d total bytes (%.1f%%)"
                  % (size, 100 * nbytes / float(size)), end=' ')
        else:
            print("bytes", end=' ')
        sys.stdout.flush()

    print()
    f.close()
    socket.close()
    ftp.voidresp()
    ftp.quit()

if __name__ == '__main__':
    main()

请注意,完成传输后,您要做的第一件事是调用datasock.close()。上传数据时,关闭套接字就是给服务器上传完成的信号!如果您在上传完所有数据后未能关闭数据套接字,服务器将继续等待其余数据的到达。

现在,您可以执行上传,上传过程中会持续显示其状态:

$ python binaryul.py localhost brandon patch8.gz /tmp
Enter password for brandon on localhost:
Sent 6408 of 6408 bytes (100.0%)

处理错误

和大多数 Python 模块一样,ftplib会在错误发生时抛出异常。它定义了自己的几个异常,还可以引发socket.errorIOError。为了方便起见,它提供了一个名为ftplib.all_errors的元组,其中列出了所有可能由ftplib引发的异常。这通常是编写try...except子句的有用捷径。

基本的retrbinary()函数的一个问题是,为了方便使用,您通常会在远程端开始传输之前在本地端打开文件。如果您针对远程端的命令反驳说该文件不存在,或者如果RETR命令失败,那么您将不得不关闭并删除您刚刚创建的本地文件(或者以零长度文件结束文件系统)。

相比之下,使用ntransfercmd()方法,您可以在打开本地文件之前检查问题。清单 17-6 已经遵循了这些准则:如果ntransfercmd()失败,异常将导致程序在本地文件打开之前终止。

扫描目录

FTP 提供了两种发现服务器文件和目录信息的方法。这些在ftplib中被实现为nlst()dir()方法。

nlst()方法返回给定目录中的条目列表——所有文件和目录都在里面。但是,返回的都是简单的名称。没有关于哪些特定条目是文件还是目录、存在的文件的大小或任何其他信息。

更强大的dir()函数从遥控器返回一个目录列表。该列表采用系统定义的格式,但通常包含文件名、大小、修改日期和文件类型。在 Unix 服务器上,它通常是以下两个 shell 命令之一的输出:

$ ls -l
$ ls -la

Windows 服务器可能会使用dir的输出。虽然输出可能对最终用户有用,但由于输出格式的变化,程序很难使用。一些需要这些数据的客户机实现了许多不同格式的解析器,这些格式是由lsdir跨机器和操作系统版本产生的;其他人只能解析在特定情况下使用的一种格式。

清单 17-7 展示了一个使用nlst()获取目录信息的例子。

清单 17-7 。获取一个空的目录列表

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

from ftplib import FTP

def main():
    ftp = FTP('ftp.ibiblio.org')
    ftp.login()
    ftp.cwd('/pub/academic/astronomy/')
    entries = ftp.nlst()
    ftp.quit()

    print(len(entries), "entries:")
    for entry in sorted(entries):
        print(entry)

if __name__ == '__main__':
    main()

当您运行这个程序时,您将看到如下输出:

$ python nlst.py
13 entries:
INDEX
README
ephem_4.28.tar.Z
hawaii_scope
incoming
jupitor-moons.shar.Z
lunar.c.Z
lunisolar.shar.Z
moon.shar.Z
planetary
sat-track.tar.Z
stars.tar.Z
xephem.tar.Z

如果您使用 FTP 客户端手动登录到服务器,您会看到相同的文件列表。当您尝试另一个文件列表命令时,结果会有所不同,如列表 17-8 所示。

清单 17-8 。得到一个奇特的目录列表

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

from ftplib import FTP

def main():
    ftp = FTP('ftp.ibiblio.org')
    ftp.login()
    ftp.cwd('/pub/academic/astronomy/')
    entries = []
    ftp.dir(entries.append)
    ftp.quit()

    print(len(entries), "entries:")
    for entry in entries:
        print(entry)

if __name__ == '__main__':
    main()

请注意,文件名采用了便于自动化处理的格式—一个简单的文件名列表—但是没有额外的信息。将您之前看到的文件名列表与清单 17-8 中的输出进行对比,清单 17-8 中使用了dir():

$ python dir.py
13 entries:
-rw-r--r--   1 (?) »    (?) »   »     750 Feb 14  1994 INDEX
-rw-r--r--   1 root »   bin »   »     135 Feb 11  1999 README
-rw-r--r--   1 (?) »    (?) »      341303 Oct  2  1992 ephem_4.28.tar.Z
drwxr-xr-x   2 (?) »    (?) »   »    4096 Feb 11  1999 hawaii_scope
drwxr-xr-x   2 (?) »    (?) »   »    4096 Feb 11  1999 incoming
-rw-r--r--   1 (?) »    (?) »   »    5983 Oct  2  1992 jupitor-moons.shar.Z
-rw-r--r--   1 (?) »    (?) »   »    1751 Oct  2  1992 lunar.c.Z
-rw-r--r--   1 (?) »    (?) »   »    8078 Oct  2  1992 lunisolar.shar.Z
-rw-r--r--   1 (?) »    (?) »   »   64209 Oct  2  1992 moon.shar.Z
drwxr-xr-x   2 (?) »    (?) »   »    4096 Jan  6  1993 planetary
-rw-r--r--   1 (?) »    (?) »      129969 Oct  2  1992 sat-track.tar.Z
-rw-r--r--   1 (?) »    (?) »   »   16504 Oct  2  1992 stars.tar.Z
-rw-r--r--   1 (?) »    (?) »      410650 Oct  2  1992 xephem.tar.Z

dir()方法为每一行调用一个函数,像retrlines()传递特定文件的内容一样传递目录列表。在这里,您只需简单地提供普通旧 Python entries列表的append()方法。

检测目录和递归下载

如果您不能保证 FTP 服务器可能选择从其dir()命令返回什么信息,您将如何区分目录和普通文件——从服务器下载整个文件树时的一个重要步骤?

唯一确定的答案,如清单 17-9 所示,就是简单地尝试在nlst()返回的每个名称中添加一个cwd() ,如果成功,则断定该实体是一个目录!这个示例程序不做任何实际的下载;相反,为了简单起见(并且不要让样本数据淹没您的磁盘),它打印出它访问屏幕的目录。

清单 17-9 。尝试递归进入目录

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

from ftplib import FTP, error_perm

def walk_dir(ftp, dirpath):
    original_dir = ftp.pwd()
    try:
        ftp.cwd(dirpath)
    except error_perm:
        return  # ignore non-directores and ones we cannot enter
    print(dirpath)
    names = sorted(ftp.nlst())
    for name in names:
        walk_dir(ftp, dirpath + '/' + name)
    ftp.cwd(original_dir)  # return to cwd of our caller

def main():
    ftp = FTP('ftp.kernel.org')
    ftp.login()
    walk_dir(ftp, '/pub/linux/kernel/Historic/old-versions')
    ftp.quit()

if __name__ == '__main__':
    main()

这个示例程序运行起来有点慢——事实证明,在 Linux 内核归档的旧版本目录中有相当多的文件——但是在几十秒钟内,您应该会看到屏幕上显示的结果目录树:

$ python recursedl.py
/pub/linux/kernel/Historic/old-versions
/pub/linux/kernel/Historic/old-versions/impure
/pub/linux/kernel/Historic/old-versions/old
/pub/linux/kernel/Historic/old-versions/old/corrupt
/pub/linux/kernel/Historic/old-versions/tytso

通过添加一些print语句,您可以通过显示递归过程正在(缓慢地)发现的每个文件来补充这个目录列表。此外,通过添加另外几行代码,您可以将文件本身下载到您在本地创建的相应目录中。然而,递归下载的唯一真正必要的逻辑已经在清单 17-9 中的代码中运行了:但是要知道一个条目是否是一个你被允许进入的目录,唯一简单的方法是试着对它运行cwd()

创建目录,删除东西

最后,FTP 支持文件删除,它支持目录的创建和删除。这些更模糊的调用都在ftplib文档中有描述:

  • delete(filename)将从服务器上删除一个文件。
  • 尝试创建新目录。
  • rmd(dirname)将删除一个目录;请注意,大多数系统首先要求目录为空。
  • rename(oldname, newname)的工作方式基本上与 Unix 命令mv类似:如果两个名称在同一个目录中,文件基本上被重命名;但是如果目的地指定了不同目录中的名称,那么文件实际上被移动了。

请注意,与所有其他 FTP 操作一样,这些命令的执行或多或少就像您使用登录 FTP 时使用的用户名真正登录到远程服务器命令行一样。正是由于这最后几个命令,FTP 可以用于支持文件浏览器应用,使用户可以在本地系统和远程主机之间无缝地拖放文件和目录。

安全地执行 FTP

虽然我在本章开始时指出,对于几乎任何可以使用 FTP 完成的事情,都有比 FTP 好得多的协议可以采用,特别是对 SSH 的健壮和安全的 SFTP 扩展(参见第十六章),但我应该公平地指出,一些 FTP 服务器支持 TLS 加密(参见第六章),如果你想利用它,Python 的ftplib确实提供了这种保护。

要使用 TLS,用FTP_TLS类而不是普通的FTP类创建 FTP 连接。简单地这样做,你的用户名和密码,事实上,整个 FTP 命令通道将受到保护,免受窥探。如果您随后额外运行该类的prot_p()方法(它没有参数),那么 FTP 数据连接也将受到保护。如果出于某种原因,您想在会话期间使用未加密的数据连接,有一个prot_c()方法可以将数据流恢复正常。同样,只要您使用FTP_TLS类,您的命令将继续受到保护。

如果您最终需要 FTP: http://docs.python.org/3/library/ftplib.html的这个扩展,请查看 Python 标准库文档以了解更多细节(其中包括一个小代码示例)。

摘要

FTP 允许您在计算机上运行的客户端和远程 FTP 服务器之间传输文件。尽管该协议不安全,而且与 SFTP 等更好的选择相比已经过时,但您可能仍然会发现需要您使用它的服务和机器。在 Python 中,ftplib库用于与 FTP 服务器对话。

FTP 支持二进制和 ASCII 传输。ASCII 传输通常用于文本文件,它们允许在文件传输时调整行尾。二进制传输用于其他一切。retrlines()函数用于以 ASCII 模式下载文件,而retrbinary()以二进制模式下载文件。

您也可以将文件上传到远程服务器。storlines()函数以 ASCII 模式上传文件,storbinary()以二进制模式上传文件。

ntransfercmd()函数可用于二进制文件的上传和下载。它让您对传输过程有更多的控制,它通常用于支持用户的进度条。

模块ftplib在出错时引发异常。特殊的元组ftplib.all_errors可以用来捕捉它可能引发的任何错误。

您可以使用cwd()切换到远端的特定目录。nlst()命令返回给定目录中所有条目(文件或目录)的简单列表。dir()命令返回一个更详细的列表,但是采用特定于服务器的格式。即使只有nlst(),您通常也可以通过尝试使用cwd()改变条目并注意是否出现错误来检测条目是文件还是目录。

在下一章中,我们将从简单的文件传输操作转向更一般的操作,即调用另一台服务器上的远程过程,并获取类型化数据,而不是空字符串作为响应。

十八、RPC

远程过程调用(RPC )系统允许您使用与调用本地 API 或库中的例程相同的语法调用另一个进程或远程服务器上的函数。这在两种情况下很有用:

  • 您的程序有许多工作要做,并且您希望通过在网络上进行调用来将这些工作分散到多台机器上,但是不需要更改进行调用的代码,现在调用是远程的。
  • 您需要仅在另一个硬盘或网络上可用的数据或信息,RPC 接口让您可以轻松地向另一个系统发送查询以获得答案。

第一个远程过程系统倾向于为 C 之类的低级语言编写。它们将字节放在网络上,看起来非常像每次一个 C 函数调用另一个 C 函数时已经写入处理器堆栈的字节。正如一个 C 程序不能安全地调用一个没有头文件的库函数,头文件告诉它如何在内存中精确地布置函数的参数(任何错误经常导致崩溃),RPC 调用不能在没有提前知道数据将如何序列化的情况下进行。事实上,每个 RPC 负载看起来就像一个二进制数据块,由第五章中讨论的 Python struct模块格式化。

然而,今天我们的机器和网络已经足够快了,所以我们经常用一些内存和速度来交换协议,这些协议更健壮,并且在对话的两段代码之间需要更少的协调。旧的 RPC 协议会发送如下所示的字节流:

0, 0, 0, 1, 64, 36, 0, 0, 0, 0, 0, 0

接收器应该知道函数的参数是一个 32 位整数和一个 64 位浮点数,然后将 12 个字节解码为一对值“整数 1”和“浮点 10.0”然而,更现代的 RPC 协议使用像 XML 这样的自文档格式,这种格式的编写方式使得几乎不可能将参数解释为除整数和浮点数之外的任何东西:

<params>
  <param><value><i4>41</i4></value></param>
  <param><value><double>10.</double></value></param>
</params>

早期的程序员可能会对 12 字节的实际二进制数据膨胀成 108 字节的协议感到震惊,这些协议必须由发送方生成,然后在接收方进行解析,消耗数百个 CPU(中央处理器)周期。尽管如此,消除协议中的模糊性通常被认为是值得的。当然,上面的一对值也可以用比 XML 更现代的有效负载格式来表达,比如 JSON JavaScript 对象符号:

[1, 10.0]

然而,在这两种情况下,您可以看到明确的文本表示已经成为当今的主流,它已经取代了发送原始二进制数据的旧做法,而原始二进制数据的含义必须事先知道。

当然,此时您可能会问到底是什么让 RPC 协议如此特别。毕竟,我在这里所说的选择——选择数据格式、发送请求和接收响应——并不特定于过程调用;但是它们对于任何有意义的网络协议都是通用的!举两个前几章的例子,HTTP 和 SMTP 都必须序列化数据和定义消息格式。所以,你可能会想:是什么让 RPC 如此特别?有三个特征将协议标记为 RPC 的一个例子。

首先,RPC 协议的特点是它缺乏对每个调用含义的强语义。HTTP 用于检索文档,SMTP 支持消息的传递,而 RPC 协议除了支持整数、浮点、字符串和列表之类的基本数据类型之外,不对传递的数据赋予任何意义。相反,这取决于您使用 RPC 协议来定义其调用的含义的每个特定 API。

第二,RPC 机制是一种调用方法的方式,但是它们不定义方法。当你阅读像 HTTP 或 SMTP 这样更单一用途的协议的规范时,你会注意到它们定义了有限数量的基本操作,像 HTTP 的GETPUT,或者当你使用 SMTP 时的EHLOMAIL。但是 RPC 机制让您来定义您的服务器将支持的动词或函数调用;他们不会提前指定它们。

第三,当您使用 RPC 时,您的客户端和服务器代码看起来不应该与任何其他使用函数调用的代码有很大不同。除非您知道一个对象代表一个远程服务器,否则您可能在代码中注意到的唯一模式是对被传递的对象的某种谨慎——大量的数字、字符串和列表,但通常不是像打开的文件这样的 live 对象。然而,虽然传递的参数种类可能有限,但函数调用将“看起来很正常”,不需要修饰或精心制作就可以通过网络传递。

RPC 的特性

除了让您进行看似本地的函数或方法调用(实际上是通过网络传递到不同的服务器)这一基本目的之外,RPC 协议还有几个关键特性和一些差异,在选择和部署 RPC 客户端或服务器时,您应该记住这些特性和差异。

首先,每种 RPC 机制对可以传递的数据类型都有限制。事实上,最通用的 RPC 机制往往是最具限制性的,因为它们被设计为与许多不同的编程语言一起工作,因此只能支持几乎所有这些语言中出现的最小公分母特性。

因此,最流行的协议只支持几种数字和字符串;一种序列或列表数据类型;然后是类似结构或关联数组的东西。许多 Python 程序员在得知通常只支持位置参数时感到失望,因为目前很少有其他语言支持关键字参数。

当 RPC 机制被绑定到特定的编程语言时,它可以自由地支持更大范围的参数。在某些情况下,如果协议能够找到某种方法在远程端重建活动对象,甚至可以传递活动对象。在这种情况下,只有由实时操作系统资源支持的对象,如打开的文件、实时套接字或共享内存区域,才不可能通过网络传递。

第二个常见特性是服务器在运行远程功能时发出异常信号的能力。在这种情况下,客户端 RPC 库通常会自己引发一个异常,告诉调用者发生了错误。当然,Python 提供给异常处理程序的那种活堆栈帧通常不能被传回;毕竟,每个堆栈框架都可能引用甚至不存在于客户端程序中的模块。但是,当服务器上的调用失败时,至少必须在 RPC 会话的客户端引发某种代理异常,给出正确的错误消息。

第三,许多 RPC 机制提供了自省,这是一种让客户端列出特定 RPC 服务所支持的调用的方式,并且可能发现它们采用了哪些参数。一些重量级的 RPC 协议实际上要求客户机和服务器交换描述它们支持的库或 API 的大型文档;其他的只是允许客户端获取函数名和参数类型的列表;而其他 RPC 实现根本不支持内省。Python 在支持自省方面有点弱,因为 Python 不像静态类型语言,不知道编写每个函数的程序员想要哪些参数类型。

第四,每个 RPC 机制都需要支持某种寻址方案,这样您就可以接触并连接到特定的远程 API。有些这样的机制相当复杂,它们甚至可以自动将您连接到网络上的正确服务器上,以执行特定的任务,而无需您事先知道它的名称。其他机制非常简单,只要求您提供想要访问的服务的 IP 地址、端口号或 URL。这些机制公开了底层网络寻址方案,而不是创建自己的方案。

最后,一些 RPC 机制支持身份验证、访问控制,甚至当 RPC 调用是由几个使用不同凭证的不同客户端程序发出时,支持特定用户帐户的完全模拟。但是像这样的功能并不总是可用的;事实上,简单和流行的 RPC 机制通常完全没有它们。简单的 RPC 方案使用像 HTTP 这样的底层协议来提供自己的身份验证,如果您希望您的 RPC 服务不被任意访问,那么您可以自行配置保护底层协议所需的密码、公钥或防火墙规则。

XML-RPC

让我们通过查看 Python 中内置的用于表达 XML-RPC 的工具来开始这个简短的 RPC 机制之旅。对于第一个例子来说,这似乎不是一个好的选择。毕竟,XML 是出了名的笨重和冗长,XML-RPC 在新服务中的受欢迎程度多年来一直在下降。

但是 XML-RPC 在 Python 的标准库中有本地支持,正是因为它是互联网时代的第一批 RPC 协议之一,通过 HTTP 本地操作,而不是坚持自己的在线协议。这意味着这里给出的例子甚至不需要第三方模块。尽管这使得 RPC 服务器的功能比使用第三方库时要差一些,但这也将使初次尝试 RPC 的例子变得简单。

XML-RPC 协议

目的:远程过程调用

标准:www.xmlrpc.com/spec

运行在顶端:HTTP

数据类型:intfloatunicodelistdictunicode键;带非标准扩展,datetimeNone

库:xmlrpclibSimpleXMLRPCServerDocXMLRPCServer

如果您曾经使用过原始 XML,那么您会对它缺乏任何数据类型语义这一事实很熟悉。例如,它不能表示数字,而只能表示包含其他元素、文本字符串和文本字符串属性的元素。因此,XML-RPC 规范必须在纯 XML 文档格式的基础上构建额外的语义,以便指定像数字这样的东西在转换成带标记的文本时应该是什么样子。

Python 标准库使得编写 XML-RPC 客户机或服务器变得容易。清单 18-1 显示了一个基本服务器,它在端口 7001 上启动一个 web 服务器,然后监听进入的 Internet 连接。

清单 18-1 。一个 XML-RPC 服务器

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

import operator, math
from xmlrpc.server import SimpleXMLRPCServer
from functools import reduce

def main():
    server = SimpleXMLRPCServer(('127.0.0.1', 7001))
    server.register_introspection_functions()
    server.register_multicall_functions()
    server.register_function(addtogether)
    server.register_function(quadratic)
    server.register_function(remote_repr)
    print("Server ready")
    server.serve_forever()

def addtogether(*things):
    """Add together everything in the list `things`."""
    return reduce(operator.add, things)

def quadratic(a, b, c):
    """Determine `x` values satisfying: `a` * x*x + `b` * x + c == 0"""
    b24ac = math.sqrt(b*b - 4.0*a*c)
    return list(set([(-b-b24ac) / 2.0*a,
                      (-b+b24ac) / 2.0*a]))

def remote_repr(arg):
    """Return the `repr()` rendering of the supplied `arg`."""
    return arg

if __name__ == '__main__':
    main()

XML-RPC 服务存在于网站的单个 URL 中,所以实际上不必像这样将整个端口专用于 RPC 服务。相反,您可以将它与一个普通的 web 应用集成,该应用在其他 URL 上提供各种其他页面,甚至是单独的 RPC 服务。但是,如果您确实有整个端口空闲,那么 Python XML-RPC 服务器提供了一种简单的方法来建立一个除了 XML-RPC 之外什么也不做的 web 服务器。

您可以看到服务器通过 XML-RPC 提供的三个示例函数(通过register_function()调用添加到 RPC 服务的那些)是非常典型的 Python 函数。这也是 XML-RPC 的全部意义——它让您可以通过网络调用例程,而不必像编写程序中提供的普通函数一样编写它们。

Python 标准库提供的SimpleXMLRPCServer,顾名思义,相当简单;它不能提供其他 web 页面,它不理解任何类型的 HTTP 认证,如果不自己创建子类并添加更多代码,您就不能要求它提供 TLS 安全性。尽管如此,它将很好地服务于这里的目的,向您展示 RPC 的一些基本特性和限制,同时还让您只用几行代码就可以开始运行。

注意,除了注册函数的三个调用之外,还进行了两个额外的配置调用。它们中的每一个都开启了一个额外的服务,这个服务是可选的,但是通常是由 XML-RPC 服务器提供的:一个自检例程,客户端可以用它来询问给定的服务器支持哪些 RPC 调用,以及支持一个 multicall function 的能力,这个函数允许将几个单独的函数调用捆绑到一个网络往返中。

在您尝试下面三个程序列表之前,需要运行此服务器,因此请打开一个命令窗口并启动它:

$ python xmlrpc_server.py
Server ready

服务器现在正在本地主机端口 7001 上等待连接。所有正常的寻址规则都适用于您在第二章和第三章中学到的这个 TCP 服务器,所以除非您调整代码绑定到一个非本地主机的接口,否则您将不得不从同一系统上的另一个命令提示符连接到它。首先打开另一个命令窗口,并准备尝试下面三个清单。

首先,让我们试试您在这个特定的服务器上打开的自省功能。请注意,这种能力是可选的,您在线使用或自己部署的许多其他 XML-RPC 服务可能不具备这种能力。清单 18-2 从客户的角度展示了自省是如何发生的。

清单 18-2 。询问 XML-RPC 服务器它支持什么功能

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_introspect.py
# XML-RPC client

import xmlrpc.client

def main():
    proxy = xmlrpc.client.ServerProxy('http://127.0.0.1:7001')

    print('Here are the functions supported by this server:')
    for method_name in proxy.system.listMethods():

        if method_name.startswith('system.'):
            continue

        signatures = proxy.system.methodSignature(method_name)
        if isinstance(signatures, list) and signatures:
            for signature in signatures:
                print('%s(%s)' % (method_name, signature))
        else:
            print('%s(...)' % (method_name,))

        method_help = proxy.system.methodHelp(method_name)
        if method_help:
            print('  ', method_help)

if __name__ == '__main__':
    main()

自省机制不仅仅是一个可选的扩展,它实际上并没有在 XML-RPC 规范本身中定义!它让客户端调用一系列以字符串system开头的特殊方法,以区别于普通方法。这些特殊的方法给出了关于其他可用调用的信息。让我们从称呼listMethods() 开始。如果支持内省,那么您将收到一个其他方法名称的列表。对于这个清单示例,让我们忽略系统方法,只打印出其他方法的信息。对于每个方法,您将尝试检索它的签名,以了解它接受什么参数和数据类型。因为服务器是用 Python(一种没有类型声明的语言)编写的,所以它实际上不知道函数期望什么数据类型:

$ python xmlrpc_introspect.py
Here are the functions supported by this server:
concatenate(...)
   Add together everything in the list `things`.
quadratic(...)
   Determine `x` values satisfying: `a` * x*x + `b` * x + c == 0
remote_repr(...)
   Return the `repr()` rendering of the supplied `arg`.

但是,您可以看到,虽然在这种情况下没有给出参数类型,但是确实提供了文档字符串。事实上,SimpleXMLRPCServer已经获取了函数的文档字符串并返回了它们。在真实的客户机中,您可能会发现自省有两种用途。首先,如果您正在编写一个使用特定 XML-RPC 服务的程序,那么它的在线文档可能会提供人类可读的帮助。第二,如果您正在编写一个客户机,该客户机访问一系列类似的 XML-RPC 服务,这些服务提供的方法各不相同,那么一个listMethods()调用可能会帮助您确定哪些服务器提供哪些命令。

您可能还记得,RPC 服务的全部目的是让目标语言中的函数调用看起来尽可能自然。此外,正如你在清单 18-3 中看到的,标准库的xmlrpclib给了你一个代理对象,用于对服务器进行函数调用。这些调用看起来完全像本地方法调用。

清单 18-3 。进行 XML-RPC 调用

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_client.py
# XML-RPC client

import xmlrpc.client

def main():
    proxy = xmlrpc.client.ServerProxy('http://127.0.0.1:7001')
    print(proxy.addtogether('x', 'ÿ', 'z'))
    print(proxy.addtogether(20, 30, 4, 1))
    print(proxy.quadratic(2, -4, 0))
    print(proxy.quadratic(1, 2, 1))
    print(proxy.remote_repr((1, 2.0, 'three')))
    print(proxy.remote_repr([1, 2.0, 'three']))
    print(proxy.remote_repr({'name': 'Arthur',
                             'data': {'age': 42, 'sex': 'M'}}))
    print(proxy.quadratic(1, 0, 1))

if __name__ == '__main__':
    main()

在示例服务器上运行前面的代码会产生输出,从中您可以了解一些关于 XML-RPC 和 RPC 机制的一般情况。请注意几乎所有的调用都工作顺利,以及清单 18-1 中的函数本身看起来完全像普通的 Python 它们没有任何特定于网络的特性:

$ python xmlrpc_client.py
xÿz
55
[0.0, 8.0]
[-1.0]
[1, 2.0, 'three']
[1, 2.0, 'three']
{'data': {'age': [42], 'sex': 'M'}, 'name': 'Arthur'}
Traceback (most recent call last):
  ...
xmlrpclib.Fault: <Fault 1: "<type 'exceptions.ValueError'>:math domain error">

但是有几个细节你需要注意。首先,注意 XML-RPC 没有对您提供的参数类型施加任何限制。您可以用字符串或数字调用addtogether(),并且可以提供任意数量的参数。协议本身并不关心;对于一个函数应该有多少个参数或者应该是什么类型,它没有先入为主的概念。当然,如果您正在调用一种关心的语言——甚至是一个不支持可变长度参数列表的 Python 函数——那么远程语言可能会引发一个异常。但是这是语言的抱怨,而不是 XML-RPC 协议本身。

第二,注意 XML-RPC 函数调用,就像 Python 和其他语言的函数调用一样,可以接受几个参数,但是它们只能返回一个结果值。该值可能是复杂的数据结构,但它将作为单个结果返回。并且该协议不关心该结果是否具有一致的形状或大小;由quadratic()返回的列表(是的,我厌倦了 XML-RPC 示例中使用的所有简单的add()subtract()数学函数!)在没有来自网络逻辑的任何抱怨的情况下返回的元素数量变化。

第三,注意丰富多样的 Python 数据类型必须减少到 XML-RPC 本身恰好支持的较小集合。特别是,XML-RPC 只支持单一的序列类型:列表。因此,当您向remote_repr()提供一个包含三个条目的元组时,它实际上是服务器接收到的三个条目的列表。这是所有 RPC 机制与特定语言结合时的一个共同特征。它们不直接支持的类型要么必须映射到不同的数据结构(因为元组在这里变成了列表),要么必须引发异常,抱怨特定的参数类型无法传输。

第四,XML-RPC 中的复杂数据结构可以是递归的。您不会受限于内部只有一个复杂数据类型级别的参数。正如您所看到的,传递一个字典并把另一个字典作为它的一个值就可以了。

最后,注意,如前所述,服务器上函数中的一个异常成功地通过网络返回,并在客户机上由一个xmlrpclib.Fault实例表示。这个实例提供了远程异常名和与之相关的错误消息。无论使用什么语言来实现服务器例程,您总是可以预期 XML-RPC 异常具有这种结构。追溯信息并不丰富;虽然它告诉您代码中的哪个调用触发了异常,但是堆栈的最内层只是xmlrpclib本身的代码。

到目前为止,我已经介绍了 XML-RPC 的一般特性和限制。如果您查阅 Python 标准库中客户端或服务器模块的文档,您可以了解更多的特性。特别是,您可以通过向ServerProxy类提供更多参数来学习如何使用 TLS 和认证。但是有一个特性很重要,足以在这里讨论:当服务器支持时,在网络往返中进行多次调用的能力(这是那些可选扩展中的另一个),如清单 18-4 所示。

清单 18-4 。使用 XML-RPC 多调用

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/xmlrpc_multicall.py
# XML-RPC client performing a multicall

import xmlrpc.client

def main():
    proxy = xmlrpc.client.ServerProxy('http://127.0.0.1:7001')
    multicall = xmlrpc.client.MultiCall(proxy)
    multicall.addtogether('a', 'b', 'c')
    multicall.quadratic(2, -4, 0)
    multicall.remote_repr([1, 2.0, 'three'])
    for answer in multicall():
        print(answer)

if __name__ == '__main__':
    main()

当您运行这个脚本时,您可以观察服务器的命令窗口,以确认只发出了一个 HTTP 请求来响应所有三个函数调用:

localhost - - [04/Oct/2010 00:16:19] "POST /RPC2 HTTP/1.0" 200 -

顺便说一下,可以关闭像前面那样记录消息的功能;此类记录由SimpleXMLRPCServer中的一个选项控制。注意,服务器和客户机使用的默认 URL 是路径/RPC2,除非您查阅文档并对客户机和服务器进行不同的配置。

在我继续研究另一个 RPC 机制之前,最后三点值得一提:

  • 有两种额外的数据类型有时被证明是不可或缺的,所以许多 XML-RPC 机制都支持它们:日期和值,Python 称之为None(其他语言称之为nullnil)。Python 的客户机和服务器都支持允许传输和接收这些非标准值的选项。
  • 唉,XML-RPC 不支持关键字参数,因为很少有语言成熟到可以包含它们。一些服务通过允许将字典作为函数的最终参数来传递,或者通过完全处理位置参数并对每个函数使用单个字典参数(通过名称指定其所有参数)来解决这个问题。
  • 最后,请记住,只有当字典的所有键都是字符串时,才能传递字典,不管是普通的还是 Unicode 的。有关如何考虑这一限制的更多信息,请参阅本章后面的“自我记录数据”一节。

尽管像 XML-RPC 这样的 RPC 协议的全部目的是让您忘记网络传输的细节,专注于正常的编程,但是您应该至少看到一次您的调用在网络上是什么样子的!下面是示例客户端程序对quadratic ()的第一次调用:

<?xml version='1.0'?>
<methodCall>
<methodName>quadratic</methodName>
<params>
<param>
<value><int>2</int></value>
</param>
<param>
<value><int>-4</int></value>
</param>
<param>
<value><int>0</int></value>
</param>
</params>
</methodCall>

对前面调用的响应如下所示:

<?xml version='1.0'?>
<methodResponse>
<params>
<param>
<value><array><data>
<value><double>0.0</double></value>
<value><double>8.0</double></value>
</data></array></value>
</param>
</params>
</methodResponse>

如果这个响应对于它所传输的数据量来说看起来有点冗长,那么您将很乐意了解我接下来要处理的 RPC 机制,JSON-RPC。

JSON-RPC

JSON 背后的好主意是使用 JavaScript 编程语言的语法将数据结构序列化为字符串。这意味着,理论上,JSON 字符串可以简单地通过使用eval()函数在 web 浏览器中变回数据。(然而,对于不受信任的数据这样做通常是不明智的,所以大多数程序员使用正式的 JSON 解析器,而不是利用它与 JavaScript 的兼容性。)通过使用专门为数据设计的语法,而不是采用像 XML 这样冗长的文档标记语言,这种远程过程调用机制可以使数据更加紧凑,同时简化解析器和库代码。

JSON-RPC 协议

目的:远程过程调用

标准:http://json-rpc.org/wiki/specification

运行在顶端:HTTP

数据类型:intfloatunicodelistdictunicode键;None

库:许多第三方,包括jsonrpclib

Python 标准库中不支持 JSON-RPC,因此您必须从几个可用的第三方发行版中选择一个。您可以在 Python 包索引中找到这些发行版。第一个正式支持 Python 3 的是jsonrpclib-pelix。如果你把它安装在一个虚拟环境中(见第一章,那么你可以分别试用清单 18-5 和清单 18-6 中的服务器和客户端。

清单 18-5 。JSON-RPC 服务器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/jsonrpc_server.py
# JSON-RPC server needing "pip install jsonrpclib-pelix"

from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer

def lengths(*args):
    """Measure the length of each input argument.

    Given N arguments, this function returns a list of N smaller
    lists of the form [len(arg), arg] that each state the length of
    an input argument and also echo back the argument itself.

    """
    results = []
    for arg in args:
        try:
            arglen = len(arg)
        except TypeError:
            arglen = None
        results.append((arglen, arg))
    return results

def main():
    server = SimpleJSONRPCServer(('localhost', 7002))
    server.register_function(lengths)
    print("Starting server")
    server.serve_forever()

if __name__ == '__main__':
    main()

服务器代码非常简单,就像 RPC 机制一样。和 XML-RPC 一样,你只需要命名你想在网络上提供的函数,它们就可以被查询了。(您也可以传递一个对象,它的方法将一次注册到服务器。)

清单 18-6 。JSON-RPC 客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# github.com/brandon-rhodes/fopnp/blob/m/py3/chapter18/jsonrpc_client.py
# JSON-RPC client needing "pip install jsonrpclib-pelix"

from jsonrpclib import Server

def main():
    proxy = Server('http://localhost:7002')
    print(proxy.lengths((1,2,3), 27, {'Sirius': -1.46, 'Rigel': 0.12}))

if __name__ == '__main__':
    main()

编写客户端代码也很简单。发送几个需要测量长度的对象,并让这些数据结构立即被服务器回显,这样就可以看到这个特定协议的几个细节。

首先,请注意,该协议允许您发送任意多的参数;它不会因为不能从函数中自省静态方法签名而烦恼。这类似于 XML-RPC,但它与为传统的静态类型语言构建的 XML-RPC 机制有很大不同。

其次,注意服务器回复中的None值会不受阻碍地传回。这是因为该值本身就受协议支持,无需激活任何非标准扩展:

$ python jsonrpc_server.py
Starting server

[In another command window:]
$ python jsonrpc_client.py
[[3, [1, 2, 3]], [None, 27], [2, {'Rigel': 0.12, 'Sirius': -1.46}]]

第三,注意 JSON-RPC 只支持一种序列,这意味着客户机发送的元组必须被强制转换成一个列表才能通过。

当然,JSON-RPC 和 XML-RPC 之间的最大区别——在这种情况下,数据负载是一个小而简洁的 JSON 消息,它本身知道如何表示每种数据类型——在这里甚至看不到。这是因为这两种机制都很好地将网络隐藏在代码之外。当在我的本地主机接口上运行 Wireshark,同时运行这个示例客户端和服务器时,我可以看到实际传递的消息如下:

{"version": "1.1",
 "params": [[1, 2, 3], 27, {"Rigel": 0.12, "Sirius": -1.46}],
 "method": "lengths"}
{"result": [[3, [1, 2, 3]], [null, 27],
            [2, {"Rigel": 0.12, "Sirius": -1.46}]]}

请注意,JSON-RPC version 1 的流行已经导致了几个用附加特性来扩展和补充该协议的竞争尝试。如果你想了解标准的现状以及围绕标准的讨论,你可以在网上做研究。对于大多数基本任务,您可以简单地使用一个好的第三方 Python 实现,而不用担心关于标准扩展的争论。

离开这个话题而不提一个重要的事实是我的失职。尽管前面的例子是同步的——客户机发送一个请求,然后耐心等待只接收一个响应,并且在此期间不做任何有用的事情——JSON-RPC 协议确实支持为每个请求附加id值。这意味着在收到带有相同的id的匹配响应之前,您可以有几个正在进行的请求。我不会在这里进一步探讨这个想法,因为严格地说,异步超越了 RPC 机制的传统角色。毕竟,传统过程语言中的函数调用是严格同步的事件。但是,如果您觉得这个想法很有趣,那么您应该阅读该标准,然后探究哪些 Python JSON-RPC 库可能支持您对异步的需求。

自我记录数据

您已经看到,XML-RPC 和 JSON-RPC 似乎都支持非常像 Python 字典的数据结构,但是有一个令人讨厌的限制。在 XML-RPC 中,数据结构被称为结构,而 JSON 称之为对象。然而,对于 Python 程序员来说,它看起来像一个字典,您的第一反应可能是它的键不能是整数、浮点数或元组。

我们来看一个具体的例子。假设您有一个由原子序数索引的物理元素符号字典:

{1: 'H', 2: 'He', 3: 'Li', 4: 'Be', 5: 'B', 6: 'C', 7: 'N', 8: 'O'}

如果您需要通过 RPC 机制传输这个字典,您的第一反应可能是将数字改为字符串,以便字典可以作为结构或对象传递。事实证明,在大多数情况下,这种本能是错误的。

简单地说,struct 和 object RPC 数据结构并不是为了将任意大小的容器中的键与值配对而设计的。相反,它们被设计成将一小组预定义的属性名与它们恰好为某个特定对象携带的属性值相关联。如果您试图使用 struct 来将随机的键和值配对,您可能会无意中使您的服务对于那些不幸使用静态类型编程语言的人来说非常难以使用。

相反,您应该把跨 RPC 发送的字典想象成 Python 对象,通常每个对象都有一个代码熟知的属性名的小集合。同样,通过 RPC 发送的字典应该将少量预定义的键与其相关值相关联。

所有这些都意味着,如果通用 RPC 机制要使用前面给出的字典,那么它实际上应该序列化为一个显式标记值的列表:

[{'number': 1, 'symbol': 'H'},
 {'number': 2, 'symbol': 'He'},
 {'number': 3, 'symbol': 'Li'},
 {'number': 4, 'symbol': 'Be'},
 {'number': 5, 'symbol': 'B'},
 {'number': 6, 'symbol': 'C'},
 {'number': 7, 'symbol': 'N'},
 {'number': 8, 'symbol': 'O'}]

请注意,前面的示例显示了 Python 字典,因为您将它传递到 RPC 调用中,而不是它在网络上的表示方式。

这种方法的关键区别(除了这本字典长得惊人之外)是,早期的数据结构毫无意义,除非您事先知道键和值的含义。它依靠惯例来赋予数据意义。但是在这里,您在数据中包含了名称,这使得它可以自我描述:在网络上或程序中查看这些数据的人有更大的机会猜测它们代表什么。

这就是 XML-RPC 和 JSON-RPC 希望您使用它们的键值类型的方式,这也是名称 structobject 的来源。它们分别是保存命名属性的实体的 C 语言和 JavaScript 术语。同样,这使得它们更像 Python 对象,而不是 Python 字典。

如果您有一个类似这里讨论的 Python 字典,您可以将它转换成一个适合 RPC 的数据结构,然后用如下代码将它改回来:

>>>elements = {1: 'H', 2: 'He'}
>>>t = [{'number': key, 'symbol': value} for key, value in elements.items()]
>>>t
[{'symbol': 'H', 'number': 1}, {'symbol': 'He', 'number': 2}]
>>> {obj['number']: obj['symbol']) for obj in t}
{1: 'H', 2: 'He'}

如果您发现自己创建和销毁了太多的字典,使得这种转换没有吸引力,那么使用命名元组(因为它们存在于 Python 的最新版本中)可能是在发送这些值之前整理它们的更好的方法。

谈论对象:Pyro 和 RPyC

如果 RPC 的想法是让远程函数调用看起来像本地函数调用,那么前面讨论的两个基本 RPC 机制实际上非常失败。如果您调用的函数碰巧在它们的参数和返回值中只使用了基本数据类型,那么 XML-RPC 和 JSON-RPC 就能很好地工作。但是,想想所有使用更复杂的参数和返回值的情况吧!当你需要传递活的物体时会发生什么?这通常是一个很难解决的问题,原因有二。

首先,对象在不同的编程语言中有不同的行为和语义。因此,支持对象的机制往往要么局限于一种特定的语言,要么从它想要支持的语言的最小公分母中挑选出对“对象”如何行为的描述。

第二,通常不清楚一个对象需要多少状态才能在另一台计算机上有用。的确,RPC 机制可以开始递归地向下进入对象的属性,并准备好这些值以便在网络上传输。然而,在中等复杂程度的系统上,您可以通过对属性值进行简单的递归来遍历内存中的大多数对象。收集了数兆字节的数据进行传输后,远程终端实际需要所有这些数据的可能性有多大?

除了发送作为参数传递或作为值返回的每个对象的全部内容之外,另一种方法是只发送一个对象名,如果需要的话,远程端可以使用它来询问有关对象属性的问题。这意味着高度连接的对象图中只有一个项目可以被快速传输,并且只有远程站点实际需要的那些部分最终会被传输。然而,这两种方案通常导致昂贵和缓慢的服务,并且它们使得很难跟踪一个对象如何被允许影响网络另一端的另一个服务所提供的答案。

事实上,XML-RPC 和 JSON-RPC 强加给你的任务(即,分解你想问远程服务的问题,以便简单的数据类型可以容易地传输)通常最终只是软件架构的任务。对参数和返回值数据类型的限制使您仔细考虑您的服务,直到您确切地看到远程服务需要什么以及为什么需要。因此,我建议不要仅仅为了避免设计您的远程服务和弄清楚他们需要哪些数据来完成他们的工作而跳到一个更加基于对象的 RPC 服务。

有几个著名的 RPC 机制,如 SOAP 和 CORBA,它们在不同程度上试图解决这样一个大问题,即如何支持可能存在于一个服务器上的对象,同时代表客户端程序从第三个服务器发送 RPC 消息,将这些对象传递到另一个服务器。一般来说,Python 程序员似乎像躲避瘟疫一样避免这些 RPC 机制,除非合同或任务特别要求他们将这些协议与另一个现有的系统交流。它们超出了本书的范围;而且,如果你需要使用它们,你应该准备好购买至少一整本关于每种技术的书,因为它们可能是如此复杂!

然而,当您只有需要相互通信的 Python 程序时,至少有一个极好的理由来寻找了解 Python 对象及其方式的 RPC 服务。Python 有许多非常强大的数据类型,所以试图“贬低”XML-RPC 和 JSON-RPC 等有限数据格式的方言是不合理的。当 Python 字典、集合和datetime对象能够准确表达您想要表达的内容时,尤其如此。

有两个 Python 原生的 RPC 系统我应该提一下: PyroRPyC 。Pyro 项目可以在http://pythonhosted.org/Pyro4/找到。这个完善的 RPC 库建立在 Python pickle模块之上,它可以发送任何类型的参数和响应值,这是固有的可处理的。基本上,这意味着如果一个对象及其属性可以被简化为它的基本类型,那么它就可以被传输。然而,如果你想发送或接收的值是那些被pickle模块阻塞的值,那么 Pyro 就不适合你的情况。(尽管您也可以查看 Python 标准库中的pickle文档。这个库包含了当 Python 不能自己解决如何 pickle 类的问题时,如何使类可 pickle 的说明。)

RPyC 的一个例子

RPyC 项目可以在http://rpyc.readthedocs.org/en/latest/找到。这个项目对对象采取了一种更加复杂的方法。事实上,它更像 CORBA 中可用的方法,在 CORBA 中,实际上通过网络传递的是对一个对象的引用,如果接收者需要,该对象可以用于回调和调用它的更多方法。最新的版本似乎在安全性方面投入了更多的心思,如果您让其他组织使用您的 RPC 机制,这一点很重要。毕竟,如果你让别人给你一些数据,你基本上是让他们在你的电脑上运行任意代码!

您可以分别在清单 18-7 和清单 18-8 中看到一个示例客户端和服务器。如果你想要一个像 RPyC 这样的系统所能实现的不可思议的事情的例子,你应该仔细研究这些列表。

清单 18-7 。RPyC 的客户

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

import rpyc

def main():
    config = {'allow_public_attrs': True}
    proxy = rpyc.connect('localhost', 18861, config=config)
    fileobj = open('testfile.txt')
    linecount = proxy.root.line_counter(fileobj, noisy)
    print('The number of lines in the file was', linecount)

def noisy(string):
    print('Noisy:', repr(string))

if __name__ == '__main__':
    main()

清单 18-8 。RPyC 服务器

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

import rpyc

def main():
    from rpyc.utils.server import ThreadedServer
    t = ThreadedServer(MyService, port = 18861)
    t.start()

class MyService(rpyc.Service):
    def exposed_line_counter(self, fileobj, function):
        print('Client has invoked exposed_line_counter()')
        for linenum, line in enumerate(fileobj.readlines()):
            function(line)
        return linenum + 1

if __name__ == '__main__':
    main()

起初,客户端可能看起来像一个使用 RPC 服务的相当标准的程序。毕竟,它用一个网络地址调用一个一般命名为connect()的函数,然后访问返回的代理对象的方法,就好像调用是在本地执行的一样。然而,如果你仔细观察,你会发现一些惊人的差异!RPC 函数的第一个参数实际上是一个动态文件对象,它不一定存在于服务器上。另一个参数是一个函数;另一个活动对象,而不是 RPC 机制通常支持的那种惰性数据结构。

服务器公开一个方法,该方法接受提供的文件对象和可调用函数。它完全像在单个进程中的普通 Python 程序中一样使用这些。它调用文件对象的readlines(),并且它期望返回值是一个迭代器,一个for循环可以在其上重复。最后,服务器调用传入的函数对象,而不考虑函数实际位于何处(即在客户机中)。注意,RPyC 的新安全模型规定,如果没有任何特殊许可,它将只允许客户端调用以特殊前缀exposed_开头的方法。

假设当前目录中确实存在一个小的testfile.txt,并且其中包含一些有智慧的话,那么查看运行客户端生成的输出特别有启发性:

$ python rpyc_client.py
Noisy: 'Simple\n'
Noisy: 'is\n'
Noisy: 'better\n'
Noisy: 'than\n'
Noisy: 'complex.\n'
The number of lines in the file was 5

同样令人吃惊的是两个事实。首先,服务器能够迭代来自readlines()的多个结果,尽管这需要反复调用客户机上的文件对象逻辑。第二,服务器没有以某种方式复制noisy()函数的代码对象,以便它可以直接运行该函数;相反,它在连接的客户端反复调用函数,每次都使用正确的参数!

这是怎么回事?很简单,RPyC 采用了与前面研究的其他 RPC 机制完全相反的方法。尽管所有其他技术都试图序列化并通过网络发送尽可能多的信息,然后让远程代码要么成功要么失败,而没有进一步的信息,但 RPyC 方案只序列化完全不可变的项目,如 Python 整数、浮点、字符串和元组。对于其他所有事情,它通过一个远程对象标识符传递,该标识符允许远程端返回到客户端以访问这些活动对象的属性和调用方法。

这种方法会产生大量的网络流量。如果在操作完成之前,大量的对象操作必须在客户机和服务器之间来回传递,也会导致明显的延迟。建立适当的安全性也是一个问题。为了允许服务器在客户端自己的对象上调用类似于readlines()的东西,我选择用allow_public_attrs的一揽子断言来建立客户端连接。但是,如果您不愿意让您的服务器代码拥有这种完全的控制权,那么您可能需要花一些时间来获得正确的权限,以使您的操作能够正常工作,而不会暴露太多潜在的危险功能。

因此,这项技术可能很昂贵,而且如果客户端和服务器彼此不信任,安全性也很棘手。但是当你需要它的时候,没有什么比 RPyC 更能让网络边界两端的 Python 对象相互协作了。你甚至可以让两个以上的进程玩游戏;查看 RPyC 文档了解更多细节!

事实上,RPyC 可以成功地处理普通的 Python 函数和对象,而不需要它们继承或混合任何特殊的网络功能,这是 Python 赋予我们拦截对对象执行的操作并以我们自己的方式处理这些事件的能力的不可思议的证明——甚至通过跨网络询问一个问题!

RPC、Web 框架和消息队列

愿意为您的 RPC 服务工作探索替代的传输机制。例如,Python 标准库中为 XML-RPC 提供的类甚至没有被许多需要使用该协议的 Python 程序员使用。毕竟,人们经常将 RPC 服务部署为大型网站的一部分,并且必须在单独的端口上为这种特定类型的 web 请求运行单独的服务器,这是非常烦人的。

有三种有用的方法可以帮助您超越过于简单的示例代码,这些代码使您看起来好像必须为您希望从特定站点提供的每个 RPC 服务创建一个新的 web 服务器。

首先,看看是否可以利用 WSGI 的可插拔性来安装一个 RPC 服务,这个服务已经合并到一个正在部署的大型 web 项目中。在检查传入 URL 的过滤器下,将普通的 web 应用和 RPC 服务实现为 WSGI 服务器,使您能够允许这两种服务使用相同的主机名和端口号。它还允许您利用这样一个事实,即您的 WSGI web 服务器可能已经提供了 RPC 服务本身没有提供的线程和可伸缩性。

如果 RPC 服务本身缺少身份验证,那么将 RPC 服务放在更大的 WSGI 堆栈的底部也可以为您提供一种添加身份验证的方法。参见第十一章了解更多关于 WSGI 的信息。

其次,您可能会发现,您选择的 web 框架已经知道如何托管 XML-RPC、JSON-RPC 或其他类型的 RPC 调用,而不是使用专用的 RPC 库。这意味着您可以像 web 框架允许您定义视图或 RESTful 资源一样轻松地声明 RPC 端点。查阅您的 web 框架文档,并在 web 上搜索 RPC 友好的第三方插件。

第三,您可能希望尝试通过替代传输发送 RPC 消息,该传输比协议的本地传输更好地将调用路由到准备好处理它们的服务器。在第八章中讨论过的消息队列,通常是 RPC 调用的一个很好的载体,当你想要整个服务器机架忙于分担传入请求的负载时。

从网络错误中恢复

当然,网络上有一个 RPC 服务无法轻易隐藏的现实:当您试图发起一个调用时,网络可能会关闭,或者它甚至可能在一个特定的 RPC 调用过程中关闭。

您会发现,如果调用被中断并且没有完成,大多数 RPC 机制只会引发一个异常。请注意,不幸的是,一个错误并不能保证远端没有处理该请求——也许它确实完成了处理,但是在发送最后一个应答包时,网络就中断了。在这种情况下,从技术上讲,您的调用应该已经发生,并且数据应该已经成功地添加到数据库或写入文件或 RPC 调用所做的任何事情。但是,您会认为调用失败了,并想再试一次—可能会将相同的数据存储两次。幸运的是,在编写通过网络委托一些函数调用的代码时,您可以使用一些技巧。

首先,尝试编写提供可以安全重试的幂等操作的服务。虽然像“从我的银行帐户中删除 10 美元”这样的操作本质上是不安全的,因为重试它可能会从您的帐户中再删除 10 美元,但是像“执行交易 583812,从我的帐户中删除 10 美元”这样的操作是完全安全的,因为服务器可以通过存储交易号来确定您的请求实际上是重复的,并且可以报告成功,而不必实际重复扣款。

第二,采纳第五章中提供的建议:不要在 RPC 调用的任何地方都用try...except乱丢你的代码,尝试使用tryexcept来包装更大的代码片段,这些代码片段具有可靠的语义,可以更干净地重试或恢复。如果您用异常处理程序来保护每一个调用,您将会失去 RPC 的大部分好处:您的代码应该是方便编写的,而不是让您经常注意到函数调用实际上是通过网络转发的事实!如果您决定您的程序应该重试一次失败的调用,您可能想要尝试使用类似于在第三章中讨论的 UDP 指数后退算法。这种方法可以让您避免打击过载的服务,避免让情况变得更糟。

最后,要小心处理网络中异常细节的丢失。除非您使用的是支持 Python 的 RPC 机制,否则您可能会发现,通常在远程端熟悉友好的KeyErrorValueError变成了某种特定于 RPC 的错误,您必须检查其文本或数字错误代码,以便有机会知道发生了什么。

摘要

RPC 让您编写看似普通的 Python 函数调用,实际上是通过网络调用另一台服务器上的函数。它们通过序列化参数来做到这一点,这样它们就可以被传输;然后,它们对发回的返回值进行同样的处理。

所有 RPC 机制的工作方式几乎都是一样的:您建立一个网络连接,然后调用给您的代理对象,以便调用远程端的代码。Python 标准库中原生支持旧的 XML-RPC 协议,而对于更时尚、更现代的 JSON-RPC,则有很好的第三方库。

这两种机制都只允许少量的数据类型在客户机和服务器之间传递。如果您想要更完整的 Python 数据类型,那么您应该看看 Pyro 系统,它可以通过网络链接 Python 程序,并广泛支持本地 Python 类型。RPyC 系统甚至更广泛,它允许实际的对象在系统之间传递,这样对这些对象的方法调用被转发到对象实际所在的系统。

回顾这本书的内容,你会禁不住开始把每一章都看作是关于 RPC 的;也就是说,关于客户端程序和服务器之间的信息交换,通过一个关于请求将包含什么以及响应看起来如何的协议作为中介。既然您已经学习了 RPC,那么您已经看到了这种交换的最大特点,它不是为了支持任何特定的动作,而是为了支持任意的通信。当实现新服务时——尤其是当您想使用 RPC 时——总是要考虑您的问题是否真的需要 RPC 的灵活性,或者您的客户机和服务器之间的事务是否可以简化为本书前面提到的一种更简单、用途有限的协议。如果你为你面临的每一个问题选择正确的协议,不会招致不必要的复杂性,你将会得到简单、可靠和易于维护的网络系统的良好回报。