Twisted 专家级编程(三)
五、使用 Twisted 作为 WSGI 服务器
WSGI 简介
WSGI——Web 标准网关接口——是 Python 标准。它松散地基于 CGI——通用网关接口——标准,web 服务器用来与脚本交互。随着负载的增加,需要在 web 服务器中有一个持久的 Python 进程。最初,每个服务器都有自己独特的运行 Python 应用的方式。这意味着每个应用都必须在一个 web 服务器上做出决定,并且不能移动。WSGI 是作为用 Python 编写的 web 应用的底层标准而设计的,用于与可以在内部运行 Python 的 web 服务器进行交互(要么用 Python 编写,要么嵌入 Python 解释器)。
WSGI 标准定义了两者之间的接口:WSGI web 应用和 WSGI web 服务器。
Twisted 有一个 web 服务器,它在实现自己独特的基于 web 的 API 的同时,还实现了 WSGI 标准。因为它实现了 WSGI 标准,所以它可以运行任何支持 WSGI 的 Python web 应用。
通常,Python web 应用不会直接与 WSGI 交互。相反,web 框架——如 Django、Flask 或 Pyramid——负责作为应用与 WSGI 接口,并向 web 应用提供更高级别的接口。这些接口是特定于 web 框架的——我们并不期望一个应用可以很容易地从 Django 移植到 Pyramid。
打个比方,把 web 框架的选择想象成类似于编程语言的选择,把 web 服务器的选择想象成类似于操作系统的选择。我们期望在操作系统之间移动将允许我们保持大部分代码的完整性(可移植性),但是我们不期望在转换编程语言时也是如此。
从 web 服务器的角度来看,支持 WSGI 意味着它们不知道所使用的 web 框架——运行金字塔应用与运行 Flask 是一样的。从 web 框架的角度来看,支持 WSGI 意味着它们与所使用的 web 服务器无关——在 Apache 上运行与在 uwsgi 上运行是一样的。
WSGI 不是在真空中诞生的。在设计它的时候,已经有许多服务器和许多 Python web 框架。正因为如此,WSGI 被设计成易于实现——无论是在服务器端还是在 web 框架端。事实上,它与 CGI 的相似性就是结果。许多这些框架已经支持 CGI,添加 WSGI 支持几乎不需要做什么工作。
WSGI 是在 2003 年设计的。它提到的许多框架的名字——例如,Quixote 和 Webware——现在是 web 框架早期实验的遗迹。虽然它没有明确提到它的名字,但当时唯一重要的服务器是 Apache——自那以后,它的受欢迎程度急剧下降。
然而,尽管流行的框架和流行的服务器都是较新的,WSGI 标准却经受住了考验。
WSGI API 的定义很微妙。它试图抽象的标准 HTTP 是复杂的。现代 web 应用需要访问这种复杂性。这个定义跨越了两个文档,有时会显得令人不知所措。
这一节将分解 WSGI 并解释组成它的各个部分。
精力
Python 的所有主要增强都要经过 PEP (Python 增强提案)过程。WSGI 作为一大特色,最初是在人教版 0333 中描述的。PEP 0333 最初创建于 2003 年 12 月,于 2004 年 8 月完成。
虽然这个 PEP 对于 Python 2.x 仍然是正确的,但 PEP 3333 描述了如何为 Python 2.x 和 Python 3.x 实现 WSGI。PEP 3333 创建于 2010 年 9 月,并于 2010 年 10 月完成。
这是对 PEP 0333 的一个相当小的改动,处理了在 Python 2.x 和 Python 3.x 之间 WSGI 的正确实现。为了理解为什么它是必要的,理解 Python 2.x 和 Python 3.x 之间的变化是很重要的。
Python 2.x 和 Python 3.x 之间的主要变化之一是对 unicode 的处理,特别是字节、字符串和 unicode 类型发生了重大变化。WSGI 作为一个处理(最终)通过 TCP 连接传输字节的标准,需要被细化以澄清哪些类型属于 Python 3.x 中的什么地方。
虽然对这些变化的详细解释超出了我们目前的范围,但一些解释对于澄清这些问题是很重要的。Python 2.7+和 Python 3.x 都有一个 bytes 类型(字节序列)和一个 unicode 类型(unicode 码位序列)。然而,字符串类型相当于 Python 2.7+中的字节类型,相当于 Python 3.x 中的 unicode。
编码是字节和 unicode 之间的(可能是部分)映射。ASCII 就是这样一种编码——将 128 以下的字节映射为相同值的 unicode 点,并将所有其他字节声明为无效。Latin-1(或 ISO-8859-1)是一种将所有字节映射到相同值的 unicode 点的编码,如果不存在该值的 unicode 点,则声明该字节无效。
在管理 web 的协议 HTTP 中,它被分成头部,后面是主体;如果正文是文本的,那么头会指出它是用什么编码的。
对报头本身进行编码的问题很微妙:PEP 3333 将它们视为 Latin-1(也称为 ISO-8859-1),而 Twisted 将它们编码为 UTF-8。最安全的做法是确保所有报头都符合 UTF-8 和拉丁-1 的公共子集:ASCII。这确保了无论我们的头经过什么编码/解码,它们都将保持完整。
在 PEP 3333 中,头应该是本地字符串类型 Python 2.x 的字节和 Python 3.x 的 unicode 而内容应该总是字节。
PEP 3333 和 PEP 0333 也描述了 WSGI 中间件的概念——对于应用来说它看起来像服务器,对于服务器来说它又像应用。虽然存在一些 WSGI 中间件,但是请注意一些流行的框架——特别是 Django 和 Pyramid——有它们自己的中间件概念。然而,Flask 依赖于 WSGI 中间件。
原始示例
最简单的 WSGI 应用确实很简单:
def application(
environment,
start_response):
start_response('200 OK', [('Content-Type', 'text/html')])
return [b'hello world']
我们将逐行解释每个 WSGI 应用应该具有的三个主要部分:
def application(
在 Python 中,函数定义完成两件事:
-
创建一个函数对象。
-
为其指定一个名称。
具体来说,这个函数定义创建了一个函数对象,并将其指定给名称application。
这意味着application现在指向一个可调用的对象。这就是 WSGI 应用,按照 PEP 3333:可调用对象。
environment,
第一个参数是所谓的“环境”。这个名字可以追溯到 WSGI 的起源,它是对 CGI 标准的快速改编。
CGI 标准处理 web 服务器如何执行脚本。该标准的一部分定义了这些脚本可以访问的环境变量。事实上,大多数关于 web 请求的数据都可以从 CGI 下的环境变量中获得。WSGI 标准采用了相同的变量名和环境的概念,并将其称为 WSGI 应用的第一个参数。
environment参数是一个 Python 字典,将指定的名称映射到关于 web 请求的数据。在上面的示例应用中,这个参数被忽略了,因为我们总是使用一个常量值。如果这就是我们所需要做的,我们只需要一个静态的 HTML 页面——大多数真正的应用在某种程度上依赖于用户输入。
start_response):
第二个参数,通常称为start_response,是一个微妙的——也是经常被误解的——参数。它是可调用的,接受两个参数:HTTP 响应代码和 HTTP 头。
start_response('200 OK', [('Content-Type', 'text/html')])
我们做的第一件事是调用start_response callable。第一个参数是 200 OK,表示正常的成功 HTTP 响应。第二个参数是头列表。在这种情况下,我们发送的唯一报头是Content-Type报头。这表明我们的响应应该被浏览器解释为 HTML 文本。
return [b'hello world']
下一行返回字节字符串列表。由于我们没有在Content-Type,中包含显式编码,浏览器将使用默认编码。在这种情况下,这是相当安全的——现代浏览器的编码检测将始终正确处理 ASCII 范围内的字节。
一般来说,依靠浏览器变得聪明并不是一个好主意:最好的方法是通常使用 UTF-8,并在Content-Type中明确指出。
这很重要,因为 HTML 总是用 unicode 来定义的。浏览器会将其翻译成 unicode 字符串u'hello world',向用户显示问候消息。
在本章的其余部分,我们将假设这段代码在一个名为
wsgi_hello.py。
参考实现
尽管 PEP 333(和 3333)建议没有必要在核心 Python 中实现 WSGI,但经验证明并非如此。模块wsgiref实现了一个简单的 web 服务器,它可以支持 WSGI 应用。
下面的命令行将在任何 bash 类 shell 中工作,其中引号允许换行。这样做是为了提高可读性——用分号替换前两个换行符,并删除其余的换行符,会产生一个完全可移植的命令——但是,逐行阅读和解释会更困难。
python -c '
from wsgiref import simple_server
import wsgi_hello
simple_server.make_server(
"127.0.0.1",
8000,
wsgi_hello.application
).serve_forever()
'
我们将一行一行地检查:
python -c '
Python 有一个选项-c,它将下一个参数视为 Python 代码并执行它。这是一种执行短程序的便捷方式,无需将代码放在单独的文件中。
from wsgiref import simple_server
导入wsgiref.simple_server模块。这个模块实现了一个单线程单进程同步 web 服务器。虽然该服务器还不能用于生产,但有时对于简单的演示来说还是很方便的。
import wsgi_hello
假设上面的代码在一个名为wsgi_hello.py的文件中是很重要的。同样重要的是:
-
该文件位于当前工作目录中。
-
使用
-c.时,当前工作目录在 Python 模块路径上
这将在后面关于寻找 WSGI 应用代码的微妙之处的讨论中变得很重要。
simple_server.make_server(
这是simple_server模块中的主要功能——创建一个简单的服务器。
"127.0.0.1",
很多例子(包括官方文档中的例子)在这里都会用到""。这将导致 WSGI 服务器绑定到0.0.0.0,即所谓的“any”接口。注意wsgiref不是生产服务器——但即使它是,我们在这里也用它来运行测试和示例代码。将它绑定到 any 接口意味着,根据防火墙的设置,外部人员可能会连接到代码。
相反,在这个例子中,我们绑定到本地接口"127.0.0.1,"。现在只有在同一台机器上运行的程序才能连接。这很有用——我们可以很容易地用浏览器测试正在运行的服务器,但是只能在与服务器相同的机器上运行一个浏览器。
8000,
按照 IANA 标准的定义,标准网络端口是80。但是,在 UNIX 系统上,1024 以下的端口是为管理员(root)用户帐户保留的。这可以防止非特权用户“劫持”系统端口。虽然导致这种需求的特定线程模型的重要性正在下降,现在许多无特权的用户直接登录到运行 web 服务器的系统并不常见,但它仍然是威胁缓解的一个组件,最重要的是,它仍然在现代的类 UNIX 系统(如 Linux)上得到实施。
绑定到“看起来相似”的端口,如80、8888,或8080,已经成为开发中的一个传统。
wsgi_hello.application
这是实际的 WSGI 应用。正如我们提到的,WSGI 应用是一个可调用的 Python 对象。
).serve_forever()
创建了服务器之后,我们无限循环地运行它。
这是一种快速运行 WSGI 应用进行测试的简单方法,除了 Python 的标准库之外,没有任何依赖。
WebOb 示例
WebOb包是一个低级 web 框架的例子。通常不直接使用它,尽管这样做肯定是可能的。
import webob
def application(environment, start_response):
request = webob.Request(environment)
response = webob.Response(
text='Hello world!')
return response(environment, start_response)
以下是逐行解释:
import webob
图书馆足够小,所以我们需要的一切都是顶级的。
def application(environment, start_response):
在这种情况下,WSGI 应用本身只是一个普通的函数——就好像我们没有使用任何框架一样。
request = webob.Request(environment)
请求对象是从 WSGI 环境字典构建的。虽然这个应用不检查请求对象,但是它有许多参数的解析视图:URL 和查询参数,以及 cookies 等等。
response = webob.Response(
我们创建响应对象。创建响应对象将我们从处理一些底层细节中解放出来。
text='Hello world!')
例如,这里我们设置了 text 属性,而不必关心将它转换成一个字节字符串列表。
return response(environment, start_response)
响应对象知道如何调用start_response并写出它的主体。
金字塔示例
金字塔是一个框架,旨在施加最小的开销,但可以很好地扩展到大型项目。
from pyramid import config, response
def hello_world(request):
return response.Response('Hello World!')
with config.Configurator() as conf:
conf.add_route('hello', '/')
conf.add_view(hello_world, route_name="hello")
application = conf.make_wsgi_app()
我们一行一行地检查申请。
from pyramid import config, response
金字塔有相当多的活动部件。对于这个例子,我们只需要这两个模块。
def hello_world(request):
注意hello_world是一个普通的 Python 函数。它没有任何包装。这使得重用它更容易:例如,我们可以为它编写测试,或者在不同的功能中使用它。
return response.Response('Hello World!')
我们创建一个响应对象,类似于使用WebOb或werkzeug。
with config.Configurator() as conf:
使用配置器作为上下文管理器意味着在程序块的末尾,假设没有出现异常,它将自动提交配置并结束它。
conf.add_route('hello', '/')
金字塔路由是一个两步过程。将 URL 映射到“逻辑名称”是第一种方法。
conf.add_view(hello_world, route_name="hello")
第二步是将逻辑名映射到一个view。
application = conf.make_wsgi_app()
最后,我们要求配置将自己表示为一个 WSGI 应用。
入门指南
虽然通过 Twisted 运行 WSGI 应用的文档都是正确的,但它是通过一些文档分发的。这里我们将展示一个完整的运行 WSGI 应用的工作示例,一次构建一个块。
WSGI 服务器
Twisted WSGI 服务器是web tap 插件上的一个选项。在这里的演示中,我们将使用独特的调用插件的方式python -m twisted。虽然它有点啰嗦,但最终是一个在生产中有用的东西。
虽然它没有使用 WSGI,但是了解如何运行 web 插件是很有用的——许多选项最终都与操作 WSGI 服务器相关,并且能够单独操作“监听端”进行故障排除也是很有用的。
假设环境中安装了 Twisted,则可以运行:
$ python -m twisted web --port tcp:8000
并获得一个运行所谓“演示”的网络服务器演示 web 应用只是用一条 hello 消息来问候——在本例中,是在端口 8000 上。
运行一个 WSGI 应用很容易——我们上面有六个!
$ python -m twisted web --port tcp:8000 --wsgi wsgi_hello.application
$ python -m twisted web --port tcp:8000 --wsgi werkzeug_hello.application
$ python -m twisted web --port tcp:8000 --wsgi flask_hello.application
$ python -m twisted web --port tcp:8000 --wsgi webob_hello.application
$ python -m twisted web --port tcp:8000 --wsgi pyramid_hello.application
$ python -m twisted web --port tcp:8000 --wsgi django_hello.application
值得注意的是,这实际上比使用参考实现更容易*。对于参考实现,我们必须编写一个小的 shell 脚本,其中包含一个 4 语句 Python blob 作为-c参数。虽然 Python 命令行和 UNIX shell 合作提供这些有用的工具很好,但是没有它们也很好。*
*选项实际上比看起来更强大。
$ python -m twisted web --port tcp:8000:interface=127.0.0.1 \
--wsgi wsgi_hello.application
这将只在本地主机接口上运行 web 服务器,并使其无法从外部访问。在使用咖啡店的网络开发下一代 web 应用时,这可能是件好事!
端点的全部功能在--port命令行选项中可用,包括插件。一些端点插件非常重要,值得稍后特别提及。
注意,与其他全功能的 WSGI 服务器不同,Twisted 没有配置文件。命令行上有一些小的调整选项,但是很多事情都采用默认值——例如,WSGI 线程池的大小。
自定义这些是通过一个自定义插件来完成的。
# put in twisted/plugins/twisted_book_wsgi.py
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server
from twisted.internet import reactor
import wsgi_hello
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_wsgi"
description = "WSGI for book"
class options(usage.Options): pass
def makeService(self, options):
pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
root = wsgi.WSGIResource(reactor, pool, wsgi_hello.application)
site = server.Site(root)
return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()
我们逐一检查非进口产品:
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
这就是如何编写一个 Twisted 的 tap 插件。它将一个类标记为
-
作为插件的东西;
-
知道如何将命令行转换成服务的东西(
service.IServiceMaker)。
它通过使用zope.interface框架来做到这一点,该框架允许显式标记接口及其实现——以及对该信息的编程访问。这个编程接口允许 Twisted 插件系统工作。
class ServiceMaker(object):
类的名字其实并不重要。唯一重要的是实例的名字是serviceMaker。
tapname = "twisted_book_wsgi"
这是插件的名称,用作python -m twisted的第一个参数。
description = "WSGI for book"
通常描述应该更具信息性,因为在没有参数的情况下运行python -m twisted时,它会出现在帮助文本中。
class options(usage.Options): pass
由于这是一个最小的插件,我们“硬编码”一切。这并不是真正的硬编码——在某些时候,必须决定哪个端口和哪个应用。在插件编写时创建它通常是有意义的,特别是如果使用类似 12 因素和从环境变量中查询所有配置的话。
但是,至少从命令行提供端口选项通常是有用的。
def makeService(self, options):
该函数在解析命令行后接受 options 实例。
pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
这是而不是一个好配置的例子。事实上,这几乎肯定是一个糟糕的线程池配置。但是,通常对线程数量进行一些微调是有意义的。这显然取决于应用、机器和使用特性。
reactor.callWhenRunning(pool.start)
反应堆启动时启动水池。
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
反应堆完成后关闭水池。
root = wsgi.WSGIResource(reactor, pool, wsgi_hello.application)
构建根资源。在这里,我们将特定的线程池与特定的 WSGI 应用结合起来。
site = server.Site(root)
从资源对象构建实际理解 HTTP 的Site对象。
return strports.service('tcp:8000', site)
构建一个端点并监听 HTTP 协议。
serviceMaker = ServiceMaker()
如上所述,实际的插件依赖于一个实例,而不是一个类。我们创建我们定义的类的一个实例。
这允许我们用一个更好(或者,在这种情况下,更差)的线程池运行相同的 hello world 应用。也有可能因为许多其他原因构建一个插件——其中一些我们将在本章的剩余部分讨论。
查找代码
Twisted WSGI 服务器需要做的最重要的事情是找到它需要运行的 WSGI 应用。然而,这在传统上是一件棘手的事情。
默认路径
当使用-c或-m启动 Python 时,当前目录.位于导入路径上。上面,在使用参考实现时,我们使用了-c,在使用 Twisted WSGI 服务器时,我们使用了-m。
然而,当直接用脚本运行 Python 时,script's目录,而不是当前目录,被添加到路径中。因为这就是控制台脚本入口点的工作方式,如果我们使用twist,而不是python -m twisted,当前目录将不再位于导入路径上。
依赖于路径中的当前目录是可行的——直到它不可行时,原因看起来很简单。虽然对于演示目的来说这很好,但是对于生产用途来说,我们需要更强大的东西。
皮顿路径
一种方法是将环境变量PYTHONPATH设置为一个值。第一个问题是哪一个值:一些值是 ??,而另一些值是 ??。第一种选择的优势是它可以跟随外壳——但这种优势同样是它的弱点,因为像cd这样简单的东西就可以打破它。
下一个有具体的优点——但是同样有远程操作的问题,在稍后运行 Python 时会突然导入一个旧的 WSGI 应用。这对于在 Python 路径上寻找东西的项目来说尤其是个问题——比如 Twisted 的插件实现。出现一个额外的插件可能会非常令人惊讶。
setup.py
最好的解决方案是写一个setup.py文件,把代码变成一个合适的包。必须选择一个名字,没错,但是通常最顶层模块的名字就足够了。必须选择一个版本,但是如果没有发布它的意图,那么0.0.0dev1是一个简单、安全的选择。
出于开发目的,通常最简单的方法是用pip install -e .将其安装到虚拟环境中。这将跟踪对源文件所做的更改,从而在与虚拟环境系统或任何其他类似 virtualenv 的系统(如 Nix 或 Conda)集成时最大限度地减少麻烦。
为什么 Twisted
Twisted 当然不是运行 WSGI 应用的唯一选择。Gunicorn、uwsgi 和 Apache 的 mod_wsgi 都可以做到这一点。然而,Twisted 有一些具体的好处。
生产与开发
大多数 web 框架自带内置服务器,通常基于wsgiref实现。毫无疑问,这些 web 服务器上会出现警告,比如“不要在生产环境中使用此服务器”。它没有经过安全审计或性能测试。”(这是从 Django 的文档中引用的。)在最糟糕的情况下,这些警告没有得到重视——出于无知或权宜之计——网站在开发服务器上运行。
在最好的情况下,这些警告得到了解决,开发人员使用开发服务器,而生产人员使用生产级服务器。这导致了环境漂移——例如,WSGI 实现中的一些细微差异意味着生产中的一些行为不会在开发中重现。最重要的是,开发人员不熟悉生产级 web 服务器的常规操作。日志、错误消息和故障模式都是独一无二的——通常会导致开发人员和操作人员之间的脱节。
最后,但同样重要的是,当使用两个 web 服务器时,需要一些逻辑来决定何时在何处运行。工具经常会混淆,并意外地在生产中运行开发服务器。由于开发服务器没有完全崩溃,这通常不会导致立即崩溃,而是一种奇怪的问题模式——可能是一些模糊的性能问题。
相反,Twisted 既可用于开发,也可用于生产。可以从命令行直接使用 Twisted,就像我们上面做的那样,只传递应用的名称。如果后来证明编写一个定制插件是有用的,那么这个插件通常也可以用于开发。这可以消除很多潜在的生产/开发偏差。
一些更高级的开发服务器支持一个有用的特性——代码的自动重载。然而,通过一点点的配置,这在 Twisted 上也是可能的。第一步是用pip install -e安装我们的代码,这样仅仅重启服务器就足够了。然后,我们不直接运行服务器,而是运行
$ watchmedo shell-command \
--patterns="*.py" \
--recursive \
--command='python -m twisted web --wsgi=wsgi_hello.application' \
.
每当文件改变时,这将自动重新启动服务器。它利用了watchdog PyPI 包。
坦克激光瞄准镜(Tank Laser-Sight 的缩写)
TLS(传输层安全性)是过去被称为 SSL(安全套接字层)的最新版本。TLS 是在 TCP 之上工作的加密和密钥交换协议。
TLS 做两件事:
-
加密:使用 TLS 的通信可以抵抗窃听。
-
端点身份验证:使用 TLS 时,可以验证我们正在与预期的端点进行对话。
虽然第一种解释在解释 TLS 的重要性时很流行,但第二种解释甚至更重要。一些 WSGI 应用可能没有什么敏感数据:但是,由于它们将 HTML、JavaScript 和 CSS 发送到潜在易受攻击的浏览器,因此确保没有恶意软件通过网络传递是非常重要的。
TLS 验证端点的方式是检查由证书颁发机构签名的证书。一般来说,让证书颁发机构签署证书的两种方法是说服它您是合法的端点,或者创建您自己的证书颁发机构。虽然创建一个真正的证书颁发机构几乎是不可能的,但这往往是数据中心内的首选解决方案,在数据中心内,同一个人或团体负责连接的两端。
假设密钥在key.pem中,证书在cert.pem中,
$ python -m twisted web \
--port ssl:port=8443:privateKey=key.pem:certKey=cert.pem \
--wsgi wsgi_hello.application
将与应用一起运行 TLS 服务器。注意,在这种情况下,environment字典会将wsgi.url_scheme设置为"https.",WSGI 应用可以检查这一点,看看它们是否在 TLS 之后。
这是在 WSGI 服务器中直接实现 TLS 的一个优点。否则,需要查询模糊和不标准的 HTTP 头来了解请求是否安全。
服务器名称指示
WSGI 应用可以访问头文件,包括Host头文件。这意味着 WSGI 应用可以使用客户端访问它的主机作为它的参数之一——比如说,在example.com和m.example.com上提供不同的内容,作为支持移动浏览器的一种方式。
假设我们希望应用仍然有 TLS 来验证主机名,这意味着我们需要有针对m.example.com和example.com的证书,并知道服务哪一个。TLS 支持一个名为“服务器名称指示”的扩展,它允许客户端指示服务器应该证明它拥有哪个名称。
为了在 WSGI 中支持 SNI,我们需要做几件事:
-
获取相关证书和密钥。
-
对于每个主机名,将证书和密钥连接到一个文件中(通常使用 UNIX 命令
cat)。这个文件应该被命名为<host>.pem,例如m.example.com.pem。 -
把所有这些文件放在一个目录中,比如说
/var/lib/keys。 -
从 PyPI 安装
txsni包。 -
快跑。
$ python -m twisted web \
--port txsni:/var/lib/keys:tcp:8443 \
--wsgi wsgi_hello.application
这个例子适用于我们希望从两个不同的域名(例如,example.com和www.example.com)提供相同内容(安全地)的情况。
如果我们想为不同的子域提供不同的内容,例如,app.example.com用于动态应用,static.example.com用于静态文件,我们可以对创建twisted.web.vhost.NameVirtualHost资源的自定义插件使用相同的port参数。
下面是一个插件例子:
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server, static, vhost
from twisted.internet import reactor
import wsgi_hello
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_vhost"
description = "Virtual hosting for book"
class options(usage.Options):
optParameters = [["port", "p", None,
"strports description of the port to "
"start the server on."]]
def makeService(self, options):
application = wsgi_hello.application
pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
dynamic = wsgi.WSGIResource(reactor, pool, application)
files = static.File('static')
root = vhost.NameVirtualHost()
root.addHost(b'app.example.org', dynamic)
root.addHost(b'static.example.org', files)
site = server.Site(root)
return strports.service(options['port'], site)
serviceMaker = ServiceMaker()
有趣的线条是
root = vhost.NameVirtualHost()
root.addHost(b'app.example.org', dynamic)
root.addHost(b'static.example.org', files)
这创建了一个根资源,它将对app.example.org的所有请求重定向到动态资源,并将对static.example.org的所有请求重定向到静态资源。注意,因为我们选择了example.org,所以出于测试目的,将这些名称指向您的 hosts 文件中的127.0.0.1是安全的。
请注意,在这种情况下,我们没有选择默认值。通过不同的名称(如localhost)访问某个站点会导致 404 错误。可以在一个NameVirtualHost上设置default属性,为所有其他名字设置一个默认根。
静态文件
使用 Twisted 作为 WSGI 服务器可以让我们从同一个 web 服务器上服务静态资产和动态应用。这包括图像、JavaScript 文件、CSS 文件以及任何其他文件。
Twisted is 最初是为高性能网络应用而构建的,而 Twisted web 服务器在提供静态文件时,可以满足除了最繁重的需求之外的所有需求。然而,当满足这些需求时,大多数应用将在内容分发网络(CDN)的背后提供服务。
CDN 意味着静态文件服务速度的任何差异都是无关紧要的。然而,在这些情况下,能够从 Python 代码设置缓存控制头是很方便的。用 Python 编写 WSGI 应用的团队通常精通 Python,并且更喜欢用它来学习另一种高度特定的领域语言,比如大多数服务器的内置配置语言。
然而,要理解如何做到这一点,重要的是要更深入地研究 Twisted web 服务器 API 是如何——并且,作为一种副作用,要多理解一些早先几乎没有解释的东西。
资源模型
大多数现代 web 应用服务器,如果它们有路由模型的话,都有一个模式匹配路由模型。Flask、Django 和 Pyramid,正如我们前面看到的,都以某种方式将 URL 模式映射到代码。
Twisted 的网早于所有这些。在 URL 模式匹配变得流行之前,将 web 资源视为一个tree也是一种选择——这就是 Twisted web 采用的选择。因此,它有一个包含子资源的资源模型。
只要我们只使用了 WSGI,这并不太重要:WSGI 资源用isLeaf = True标记自己。这意味着它没有子节点,当到达时树遍历停止。这允许 WSGI 资源将路径传递给 web 应用框架,用于自己的路由。因为我们使用 WSGI 资源作为根资源——直接传递给Site构造函数的资源——这意味着资源树模型只是理论上的。
然而,当将不同的资源组合在一起时,这个模型的细节是至关重要的。
纯静态
为了理解如何在 Twisted web 上提供静态文件服务,有必要先编写一个插件来实现这一点——不使用动态资源。
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import static, server
from twisted.internet import reactor
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_static"
description = "Static for book"
class options(usage.Options):
pass
def makeService(self, options):
root = static.File('static')
site = server.Site(root)
return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()
这里唯一新的一行是
root = static.File('static')
这定义了一个File资源。File资源也是一个叶资源,它将 URL 的其余部分映射到磁盘上的一个路径。这使用了一个相对路径,static,指向当前工作目录。这对于演示来说非常有用,但是生产应用通常会使用完整的路径。
获得完整路径的一种方法是直接用 Python 代码打包文件。打包它以及在运行时找到它需要一些黑客技术。
下面是一个例子setup.py,以及使用它的插件:
import setuptools
setuptools.setup(
name='static_server',
license='MIT',
description="Server: Static",
long_description="Static, the web server",
version="0.0.1",
author="Moshe Zadka",
author_email="zadka.moshe@gmail.com",
packages=setuptools.find_packages(where='src') + ['twisted/plugins'],
package_dir={"": "src"},
include_package_data=True,
install_requires=['twisted', 'setuptools'],
)
最有趣的台词是include_package_data=True。为了获得一些有趣的数据,我们需要一个清单:在MANIFEST.in中,我们将
include src/static_server/a_file.html
为该文件提供服务的插件(在本例中是 on /)如下所示:
import pkg_resources
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import static, server, resource
from twisted.internet import reactor
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_pkg_resources"
description = "Static for book"
class options(usage.Options):
pass
def makeService(self, options):
root = resource.Resource()
fname = pkg_resources.resource_filename("static_server",
"a_file.html")
static_resource = static.File(fname)
root.putChild(“, static_resource)
site = server.Site(root)
return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()
这里有趣的新行是:
fname = pkg_resources.resource_filename("static_server",
"a_file.html")
static_resource = static.File(fname)
这使用了pkg_resources包,它是setuptools的一部分,在运行时查找文件名。
请注意,即使我们的包使用类似于pex(或内置的zipapp)的工具直接部署为 zip 文件,这也是可行的:pkg_resources足够聪明,可以在给出文件名之前透明地解压文件。
当使用像Jinja2或Chameleon这样的系统时,这种技术对于包含模板文件也很有用。
将静态文件与 WSGI 结合起来
我们还可以通过 Twisted 自己的 web 服务器为 WSGI 应用提供静态资源。
import os
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server, static, resource
from twisted.internet import reactor
import wsgi_hello
class DelegatingResource(resource.Resource):
def __init__ (self, wsgi_resource):
resource.Resource. __init__ (self)
self._wsgi_resource = wsgi_resource
def getChild(self, name, request):
request.prepath = []
request.postpath.insert(0, name)
return self._wsgi_resource
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_combined"
description = "twisted_book_combined"
class options(usage.Options): pass
def makeService(self, options):
application = wsgi_hello.application
pool = threadpool.ThreadPool()
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
wsgi_resource = wsgi.WSGIResource(reactor, pool, application)
static_resource = static.File('.')
root = DelegatingResource(wsgi_resource)
root.putChild('static', static_resource)
site = server.Site(root)
return strports.service('tcp:8000', site)
serviceMaker = ServiceMaker()
我们一行一行地研究新代码:
class DelegatingResource(resource.Resource):
我们定义了一个名为DelegatingResource的类。这将是我们的根。它继承自resource.Resource。注意,它是而不是一个叶资源——所以站点将遍历它。
def __init__ (self, wsgi_resource):
我们用 WSGI 资源初始化委托者。
resource.Resource. __init__ (self)
在适当的时候,我们调用超类构造函数。这一点至关重要——Resource没有它的构造函数就不能正常运行。
self.wsgi_resource = wsgi_resource
我们将 WSGI 资源保存在一个属性中。
def getChild(self, name, request):
getChild的名字有点混乱。语义是获得一个动态子节点。一个静态的子对象,也就是已经被手动添加到一个Resource中的子对象,将会阻止这个方法被调用。根永远不会被调用到render:即使像/这样的 URL 也会导致一个带有空字符串的子遍历name。
request.prepath = []
request.postpath.insert(0, name)
我们将名称从prepath移动到postpath,从而欺骗作为根的委托资源。请注意,只有当这个资源是根资源时,这个技巧才有效。
return self.wsgi_resource
在欺骗路径假装少完成了一次遍历之后,我们返回 WSGI 资源。
static_resource = static.File('.')
我们创建静态资源。这与纯静态资源的情况没有什么不同。
root = DelegatingResource(wsgi_resource)
我们创建委托资源作为我们的根资源。
root.putChild('static', static_resource)
如前所述,手动引入的子元素将覆盖getChild方法。因此,对于任何以/static/开头的路径,都会提供一个静态资源。
内置计划任务
对于下面的例子,我们想要一个依赖于我们可以改变的参数的 WSGI 应用。
class _Application(object):
def __init__ (self, greeting='hello world'):
self.greeting = greeting
def __call__ (self, environment, start_response):
start_response('200 OK', [('Content-Type',
'text/html; charset=utf-8')])
return [self.greeting.encode('utf-8')]
application = _Application()
我们将逐行检查代码:
class _Application(object):
如前所述,关于 WSGI 应用的唯一假设是它们是可调用的对象。在这种情况下,我们通过用__call__方法定义一个类来创建一个可调用对象。
def __init__ (self, greeting='hello world'):
我们用一个问候进行初始化,使用标准的默认值。
self.greeting = greeting
在构造函数中,我们没有做任何比设置属性更有趣的事情。
def __call__ (self, environment, start_response):
因为这是一个 WSGI 应用,所以用标准参数调用它。
start_response('200 OK', [('Content-Type',
'text/html; charset=utf-8')])
这与之前的start_response调用相同,只是增加了一个显式字符集。由于创建者可以传递任意的 unicode 字符串,并且我们将它们编码为utf-8,我们需要让浏览器知道这是我们所做的。
return [self.greeting.encode('utf-8')]
我们希望能够将问候语设置为字符串。因此,这必须将它们编码成字节。
application = _Application()
我们不关心类,我们想要的是它作为应用的一个实例。
import time
from zope import interface
from twisted.python import usage, reflect, threadpool, filepath
from twisted import plugin
from twisted.application import service, strports, internet
from twisted.web import wsgi, server, static
from twisted.internet import reactor
import wsgi_param
def update(application, reactor):
stamp = time.ctime(reactor.seconds())
application.greeting = "hello world, it's {}".format(stamp)
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_scheduled"
description = "Changing application"
class options(usage.Options): pass
def makeService(self, options):
s = service.MultiService()
pool = threadpool.ThreadPool()
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
root = wsgi.WSGIResource(reactor, pool, wsgi_param.application)
site = server.Site(root)
strports.service('tcp:8000', site).setServiceParent(s)
ts = internet.TimerService(1, update, wsgi_param.application, reactor)
ts.setServiceParent(s)
return s
serviceMaker = ServiceMaker()
def update(application, reactor):
这个函数将被定期调用来更新应用。
stamp = time.ctime(reactor.seconds())
我们在这里用reactor.seconds(),而不是time.time()。如果这段代码变得更大,这将有助于测试性。
application.greeting = "hello world, it's {}".format(stamp)
这将设置应用问候语属性。因为它是公共的,所以被认为是类的 API 的一部分。
注意:这是利用了可变的全局状态,这通常是一种危险的模式——在线程的情况下更是如此。虽然 Twisted 的主循环没有线程,但 WSGI 的工作都在 Twisted 的线程池中运行。
但是,在这种特定情况下,更改是安全的——线程将看到旧的问候语或新的问候语。这是因为 Python 的全局解释器锁,它确保 Python 线程看到一致的状态——并且因为这只是用一个字符串替换另一个字符串。
s = service.MultiService()
这将创建一个启动多个服务的服务。它允许我们从同一个服务中进行 web 服务和更新。
strports.service('tcp:8000', site).setServiceParent(s)
这一次,我们没有返回strports.service结果,而是将其父对象设置为MultiService。这会把它附在MultiService小时候。
ts = internet.TimerService(1, update, wsgi_param.application, reactor)
这里我们创建了一个每 1 秒触发一次的定时器,并用参数wsgi_param.application和reactor调用函数update。
ts.setServiceParent(s)
将计时器附加到返回值。
return s
并返回MultiService。
虽然这肯定不是显示时钟的最佳方式,但在很多情况下,将检索值和显示值分开是有意义的。设想一个股票行情应用:最好每秒检索一次股票价格,并在 web 请求发生时从内存中显示一个值,而不是让每个 web 请求等待一个(可能很慢的)后端服务。
这显示了在进程中运行计划服务的好处。当然,即使没有有要处理的事情也可以用这种方式来安排——例如,日志清理。这允许将应用配置保存在一个地方,而不是像cron那样添加对服务的依赖。
控制通道
通常,在运行时修改 web 应用的配置是很有用的,无需重新启动或重建。这方面的一些例子有:
-
解决问题时修改调试级别。
-
修改 A/B 测试中的质控/测试百分比。
-
如果客户报告问题,则关闭“功能标志”。
这意味着,除了应用最终用户与应用交互的“应用通道”,我们还需要一个辅助通道,即“控制通道”,它将修改行为。通过不同的端口(可能是不同的协议)使用该通道要安全得多——未授权用户访问控制通道的攻击媒介可以通过传统的防火墙和网络配置来缓解,而不仅仅是通过应用级访问控制。
因为 Twisted 本质上是一个网络事件框架,所以它非常适合向 WSGI 应用添加控制通道。由于这种控制通道本质上是跨越线程边界的,所以有必要注意和考虑线程安全。
然而,它确实允许将有趣的行为添加到 WSGI 应用中。
下面的插件展示了一种使用网络控制问候的方法。
from zope import interface
from twisted.python import usage, reflect, threadpool, filepath
from twisted import plugin
from twisted.application import service, strports, internet
from twisted.web import wsgi, server, static
from twisted.internet import reactor, protocol
from twisted.protocols import basic
import wsgi_param
class UpdateMessage(basic.LineReceiver):
def lineReceived(self, line):
self.factory.application.greeting = line.decode('utf-8')
self.transport.writeSequence([b"greeting is now: ", line, b"\r\n"])
self.transport.loseConnection()
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_control"
description = "Changing application"
class options(usage.Options): pass
def makeService(self, options):
s = service.MultiService()
pool = threadpool.ThreadPool()
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
root = wsgi.WSGIResource(reactor, pool, wsgi_param.application)
site = server.Site(root)
strports.service('tcp:8000', site).setServiceParent(s)
factory = protocol.Factory.forProtocol(UpdateMessage)
factory.application = wsgi_param.application
strports.service('tcp:8001',factory).setServiceParent(s)
return s
serviceMaker = ServiceMaker()
我们一行一行地检查新代码:
class UpdateMessage(basic.LineReceiver):
这定义了协议basic.LineReceiver的一个子类。它将消息分成行,使我们能够轻松地划分消息。
def lineReceived(self, line):
这将在接收到一行时调用——注意,该行将而不是包含终止字符(默认情况下,回车后面跟一个换行符,\r\n)。
self.factory.application.greeting = line
我们将问候语设置到呼入线路。
factory = protocol.Factory.forProtocol(UpdateMessage)
我们创建了一个工厂,它将在客户端连接时产生UpdateMessage的实例。
factory.application = wsgi_param.application
我们将工厂中的应用设置为 WSGI 应用。这允许协议对象访问应用,以便改变问候语。
strports.service('tcp:8001',factory).setServiceParent(s)
我们将此协议绑定到一个更高的端口。
使用多核的策略
WSGI 服务器的一个限制是它只能运行一个进程。由于 Python 拥有全局解释器锁,这意味着在多核机器上,只有一个内核用于 WSGI。通常,这不是问题:在一些环境中,较低层将向应用呈现单核“机器”。例如,使用虚拟化平台或容器编排框架时就是这种情况。
然而,由于许多原因,有时需要在应用层解决正确的多进程解决方案。在这里,我们展示了其中的一些方法。
负载平衡
最简单的方法是启动多个 Twisted WSGI 进程,并在它们前面放置一个负载平衡器。一个流行的负载平衡器是 HAProxy。拥有一个完整的 HAProxy 教程超出了我们的范围,但是下面是一个 HAProxy 配置的例子。为了简化配置,配置是针对纯文本 HTTP 的——尽管 HAProxy 经常用于终止 SSL。
defaults
log global
mode http
frontend localnodes
bind *:8080
mode http
default_backend nodes
backend nodes
mode http
balance roundrobin
option forwardfor
http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc }
option httpchk HEAD / HTTP/1.1\r\nHost:localhost
server web01 127.0.0.1:9000 check
server web02 127.0.0.1:9001 check
server web03 127.0.0.1:9002 check
最后三行是最重要的:它们转发到三个不同的本地 web 服务器。
现在,我们需要一些东西来运行所有四个进程——ha proxy 和三个 web 服务器。在这个例子中,我们将使用ncolony。
$ alias add="python -m ncolony --messages /var/run/messages \
--config /var/run config add"
$ add --cmd haproxy --arg=-f --arg=/my/haproxy.cfg haproxy
$ add --cmd python --arg=-m --arg=twisted \
--arg=web --arg=--wsgi \
--arg=wsgi_hello.application \
--arg=--port --arg=tcp:9001 web1
$ add --cmd python --arg=-m --arg=twisted \
--arg=web --arg=--wsgi \
--arg=wsgi_hello.application \
--arg=--port --arg=tcp:9002 web2
$ add --cmd python --arg=-m --arg=twisted \
--arg=web --arg=--wsgi \
--arg=wsgi_hello.application \
--arg=--port --arg=tcp:9003 web3
$ python -m twisted ncolony --messages /var/run/messages \
--config /var/run config add
以共享模式打开套接字
Linux 内核最近的一个特性是SO_REUSEPORT套接字选项。这允许几个服务器监听同一个端口。然而,由于这个特性是最近才出现的,Twisted 不支持开箱即用。
为了利用它,我们将需要插入 Twisted 的更低的层。
import socket
import attr
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, internet as tainternet
from twisted.web import wsgi, server
from twisted.internet import reactor, tcp, interfaces as tiinterfaces, defer
import wsgi_hello
@interface.implementer(tiinterfaces.IStreamServerEndpoint)
@attr.s
class ListenerWithReuseEndPoint(object):
port = attr.ib()
reactor = attr.ib(default=None)
backlog = attr.ib(default=50)
interface = attr.ib(default=“)
def listen(self, protocolFactory):
p = tcp.Port(self.port, protocolFactory, self.backlog, self.interface,
self.reactor)
self._sock = sock = p.createInternetSocket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
sock.bind((self.interface, self.port))
sock.listen(self.backlog)
return defer.succeed(reactor.adoptStreamPort(sock.fileno(),
p.addressFamily,
protocolFactory))
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_reuseport"
description = "Reuse port"
class options(usage.Options): pass
def makeService(self, options):
application = wsgi_hello.application
pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
root = wsgi.WSGIResource(reactor, pool, application)
site = server.Site(root)
endpoint = ListenerWithReuseEndPoint(8000)
service = tainternet.StreamServerEndpointService(endpoint, site)
return service
serviceMaker = ServiceMaker()
这无疑是我们迄今为止编写的最复杂的插件。在生产代码中,这对一个插件来说太大了——当然大部分逻辑应该被分离出来。
然而,为了便于说明,将所有代码放在一起显示可以使其更加清晰。
@interface.implementer(tiinterfaces.IStreamServerEndpoint)
模块名似乎很奇怪。Twisted 的深度模块层次结构意味着一些名称在层次结构中的不同点重复出现。一个有用的惯例是在导入模块时仍然保留层次结构中的一些字母,以使目的更加清晰。在这种情况下,tiinterfaces代表twisted.internet.interfaces。
我们实现了IStreamServerEndpoint接口,因为我们需要实现一种新的端点——以REUSEPORT模式打开套接字的端点。
@attr.s
由于这个类有很多数据成员,我们使用attrs包来简化代码。
class ListenerWithReuseEndPoint(object):
port = attr.ib()
reactor = attr.ib(default=None)
backlog = attr.ib(default=50)
interface = attr.ib(default=")
我们接受与reactor.listenTCP调用完全相同的参数。这是故意的。
def listen(self, protocolFactory):
这是IStreamServerEndpoint接口中唯一的方法。
p = tcp.Port(self.port, protocolFactory, self.backlog, self.interface,
self.reactor)
self._sock = sock = p.createInternetSocket()
Twisted 的底层 TCP 设施,在tcp.Port中,确保非阻塞的正确选项会设置在套接字上。我们保留了对 socket 对象的引用,以防止它被收集。这很重要,因为我们将从同一个文件描述符创建一个新的 Python 级别的套接字对象。
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
这是所有这些繁琐的事情的真正原因——设置SO_REUSEPORT选项。
sock.bind((self.interface, self.port))
我们绑定到接口。
sock.listen(self.backlog)
我们开始倾听。
return defer.succeed(reactor.adoptStreamPort(sock.fileno(),
p.addressFamily,
protocolFactory))
我们从 socket 对象中获取文件描述符,并允许 Twisted“采用”它。这将返回一个IListeningPort。由于listen的合同是返回延期的,我们将其包装在defer.succeed中。
为了将这个投入生产,我们可以再次使用ncolony。
$ alias add="python -m ncolony --messages /var/run/messages \
--config /var/run config add"
$ add --cmd python --arg=-m --arg=twisteded \
--arg=twisted_book_reuseport web1
$ add --cmd python --arg=-m --arg=twisteded \
--arg=twisted_book_reuseport web2
$ add --cmd python --arg=-m --arg=twisteded \
--arg=twisted_book_reuseport web3
$ python -m twist ncolony --messages /var/run/messages \
--config /var/run config add
和上一个例子一样,我们运行三个 web workers。请注意,这一次,所有三者的命令行都是相同的——不再需要负载平衡器。
其他选项
一般来说,Twisted 中还有一些其他的多处理选项。可以创建一个套接字,然后产生监听它的进程。这意味着以有些笨拙的方式捆绑流程管理和监听代码。例如,不再可能使用ncolony来监控流程,也不再可能使用twisted.runner.procmon来监控流程。如果“父”进程死亡,我们将面临一个两难的选择:是重启它,杀死所有现有的子进程,还是等待所有子进程先死亡。
另一种选择是在一个进程中监听,然后通过 UNIX 域套接字传递文件描述符。这对于移植来说并不简单,并且需要深入研究套接字系统调用的奥秘。
一般来说,端口重用或负载平衡的选项更好。请注意,与任何性能改进一样,特定选择的效果(例如端口重用与负载平衡)应该在尽可能接近生产环境的环境中进行测量。
动态配置
如前所述,使用 Twisted 作为 WSGI 服务器允许向应用添加控制通道,允许在运行时重新配置。这里我们展示了这种控制的完整示例,使用异步消息协议(AMP)作为我们的控制协议。该示例包括应用和控件应用。
A/B 可测试金字塔应用
A/B 测试意味着向一些用户展示 web 应用的一个版本,而向其他用户展示不同的版本——并检查各种指标的效果。例如,一个电子商务应用可能会尝试放置“Checkout”按钮,并测试它对多少客户结帐的影响。
Python web 框架有许多全功能的 A/B 测试选项。这里我们没有足够的篇幅来编写一个全功能的替代方案,但是我们将展示其中的一个基本部分:改变输出。一般来说,向给定用户显示时,输出应该是恒定的,但是这需要一致的会话构造,这也超出了我们的范围。
我们的“测试”将只是每个请求,决定显示哪个版本。我们将在随机选择的基础上这样做。然而,我们将采用 A/B 测试框架的一个重要特征——偏向选择。如果我们认为测试可能会对用户产生不利影响,我们通常会在很小的比例上运行它。
我们的默认设置是对 0%的用户运行测试。我们将依靠外部机制来提高这些百分比。
import random
from pyramid import config, response
FEATURES = dict(capitalize=0.0, exclaim=0.0)
def hello_world(request):
if random.random() < FEATURES['capitalize']:
message = 'Hello world'
else:
message = 'hello world'
if random.random() < FEATURES['exclaim']:
message += '!'
return response.Response(message)
with config.Configurator() as conf:
conf.add_route('hello', '/')
conf.add_view(hello_world, route_name="hello")
application = conf.make_wsgi_app()
我们逐行检查新代码:
FEATURES = dict(capitalize=0.0, exclaim=0.0)
我们允许两个“特征”——capitalize,是否大写我们的问候;还有exclaim,是否加感叹号。注意,在这个例子中,这些特性是独立的:用户可以看到四种不同的问候。
在小范围内,这是对进行 A/B 测试的实际环境的一个很好的模拟——从理论上讲,当运行n实验时,用户可以经常接触到任何2**n可能的选项。
if random.random() < FEATURES['capitalize']:
这就是 Python 中一个所谓的“有偏向的抛硬币”的基本逻辑。会导致True平均约FEATURES['capitalize']。
message = 'Hello world'
大写消息。
else:
message = 'hello world'
小写消息。
if random.random() < FEATURES['exclaim']:
message += '!'
如果感叹号打开,请添加感叹号。
带 AMP 的自定义插件
为了能够调整百分比,我们使用 AMP 协议。有许多可供选择的选项,但是这个平衡了灵活性和可论证性。一个好处是对 AMP 的支持内置在 Twisted 中,所以不需要第三方包。
from zope import interface
from twisted.python import usage, threadpool
from twisted import plugin
from twisted.application import service, strports
from twisted.web import wsgi, server
from twisted.internet import reactor, protocol
from twisted.protocols import amp
import pyramid_dynamic
class GetCapitalize(amp.Command):
arguments = []
response = [(b'value', amp.Float())]
class GetExclaim(amp.Command):
arguments = []
response = [(b'value', amp.Float())]
class SetCapitalize(amp.Command):
arguments = [(b'value', amp.Float())]
response = []
class SetExclaim(amp.Command):
arguments = [(b'value', amp.Float())]
response = []
class AppConfiguration(amp.CommandLocator):
@GetCapitalize.responder
def get_capitalize(self):
return {'value': pyramid_dynamic.FEATURES['capitalize']}
@GetExclaim.responder
def get_exclaim(self):
return {'value': pyramid_dynamic.FEATURES['exclaim']}
@SetCapitalize.responder
def set_capitalize(self, value):
pyramid_dynamic.FEATURES['capitalize'] = value
return {}
@SetExclaim.responder
def set_exclaim(self, value):
pyramid_dynamic.FEATURES['exclaim'] = value
return {}
@interface.implementer(service.IServiceMaker, plugin.IPlugin)
class ServiceMaker(object):
tapname = "twisted_book_configure"
description = "WSGI for book"
class options(usage.Options):
pass
def makeService(self, options):
application = pyramid_dynamic.application
pool = threadpool.ThreadPool(minthreads=1, maxthreads=100)
reactor.callWhenRunning(pool.start)
reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
root = wsgi.WSGIResource(reactor, pool, application)
site = server.Site(root)
control = protocol.Factory()
control.protocol = lambda: amp.AMP(locator=AppConfiguration())
ret = service.MultiService()
strports.service('tcp:8000', site).setServiceParent(ret)
strports.service('tcp:8001', control).setServiceParent(ret)
return ret
serviceMaker = ServiceMaker()
我们将复习新代码:
class GetCapitalize(amp.Command):
arguments = []
response = [(b'value', amp.Float())]
class GetExclaim(amp.Command):
arguments = []
response = [(b'value', amp.Float())]
class SetCapitalize(amp.Command):
arguments = [(b'value', amp.Float())]
response = []
class SetExclaim(amp.Command):
arguments = [(b'value', amp.Float())]
response = []
这些定义了 AMP 命令。命令是 AMP 中的基本信息。虽然理论上,命令可以双向发送,但在大多数情况下,它们将从客户机发送到服务器。
我们有意让 get/set 命令一次只允许一个字段,以便清楚地表明不保证原子性。事实上,由于在没有更多机制的情况下很难保证字典访问的原子性,所以在 API 中指出不可能同时将大写设置为 1 和保证为 0 是有用的。
我们可以制作一个声明原子性的 API:例如,同时设置两个属性。我们甚至可以用原子的方式来实现它:例如,大规模替换FEATURES字典,这样就可以访问旧字典或新字典,并且没有中间步骤。然而,线程切换可能发生在线之间
if random.random() < FEATURES['capitalize']:
那条线呢
if random.random() < FEATURES['exclaim']:
这将使原子性的借口成为谎言。相反,我们选择明确表示更新不是原子性的,
class AppConfiguration(amp.CommandLocator):
@GetCapitalize.responder
def get_capitalize(self):
return {'value': pyramid_dynamic.FEATURES['capitalize']}
@GetExclaim.responder
def get_exclaim(self):
return {'value': pyramid_dynamic.FEATURES['exclaim']}
@SetCapitalize.responder
def set_capitalize(self, value):
pyramid_dynamic.FEATURES['capitalize'] = value
return {}
@SetExclaim.responder
def set_exclaim(self, value):
pyramid_dynamic.FEATURES['exclaim'] = value
return {}
我们编写一个简单的类,将命令连接到pyramid_dynamic.FEATURES字典,适当地设置和获取字段。
control = protocol.Factory()
control.protocol = lambda: amp.AMP(locator=AppConfiguration())
控制工厂将协议设置为使用自定义定位器创建新的amp.AMP的函数。还有其他方法将 AMP 协议绑定到特定的定位器,但这将尽可能多的权力交给了集成者——编写插件的程序员,而不是编写命令处理本身的程序员。
控制程序
也许在其他地方,控制代码本身会使用同步风格并阻塞网络调用。然而,在本书中,这是一个展示如何使用 Twisted 编写客户端的机会。我们选择以一种与 Python 2 和 Python 3 都兼容的方式编写这段代码。
from twisted.internet import task, defer, endpoints, protocol
from twisted.protocols import amp
from twisted.plugins import twisted_book_configure
@task.react
@defer.inlineCallbacks
def main(reactor):
endpoint = endpoints.TCP4ClientEndpoint(reactor, "127.0.0.1", 8001)
prot = yield endpoint.connect(protocol.Factory.forProtocol(amp.AMP))
res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
print(res1['value'], res2['value'])
yield prot.callRemote(twisted_book_configure.SetCapitalize, value=0.5)
yield prot.callRemote(twisted_book_configure.SetExclaim, value=0.5)
res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
print(res1['value'], res2['value'])
@task.react
装饰器将立即运行主函数,并带有一个反应器参数。
@defer.inlineCallbacks
我们使用一个inlineCallbacks装饰器来允许代码更好地流动。
def main(reactor):
注意,这里我们接受reactor作为参数,而不是导入它。
endpoint = endpoints.TCP4ClientEndpoint(reactor, "127.0.0.1", 8001)
创建客户端端点。
prot = yield endpoint.connect(protocol.Factory.forProtocol(amp.AMP))
创建客户端工厂,并连接。
res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
检索值。请注意,我们使用的是之前定义的命令类。
print(res1['value'], res2['value'])
显示更改前的值
yield prot.callRemote(twisted_book_configure.SetCapitalize, value=0.5)
yield prot.callRemote(twisted_book_configure.SetExclaim, value=0.5)
设置值
res1 = yield prot.callRemote(twisted_book_configure.GetCapitalize)
res2 = yield prot.callRemote(twisted_book_configure.GetExclaim)
print(res1['value'], res2['value'])
再去拿。这证明它们已经改变。
这三个部分——应用、插件和控制程序——为我们提供了一个 web 服务器,我们可以动态配置其内部参数。
摘要
Twisted WSGI 服务器在开发中易于安装和使用——事实上,甚至比参考实现更容易。尽管使用方便,但它完全适合在生产中使用。为了避免开发环境和生产环境之间的差异——这种差异常常使生产问题难以重现,这种方法非常方便。
由于它基于 Twisted Web 服务器,因此继承了生产级 TLS 实现等功能,这些功能支持 SNI 和 Let's Encrypt 等功能,以及 HTTP/2 协议支持。它还可以配置为静态文件 web 服务器,允许它从与动态应用相同的进程中提供静态资产,如图像、JavaScript 和 CSS 文件,从而避免静态资产与应用接受的内容不匹配。
它没有定义任何配置文件格式。相反,对于任何比设置监听端口或命名 WSGI 应用更深层次的配置,都可以编写一个 Twisted 插件——它允许用一种语言进行最终配置,不管是什么 web 框架,所有从事应用工作的工程师都知道并使用这种语言。
Twisted 作为 WSGI 容器的最大缺点是利用了多核机器。为此,可以通过几种不同的配置来建立多个 WSGI 流程。一般来说,将“如何监听套接字”与“如何管理多个进程”分开,可以为每个进程找到好的解决方案,而不是将进程管理和套接字代码绑定在一起。*
六、Tahoe-LAFS:最低权限文件系统
是一个分布式存储系统,于 2006 年开始作为一家名为 AllMyData(早已倒闭)的个人备份公司的强大后端。在关闭之前,该公司开源了代码,现在一个黑客社区改进和维护这个项目。
该系统允许您将数据从您的计算机上传到称为“网格”的服务器网络中,然后再从网格中检索您的数据。除了提供备份(例如,万一您的笔记本电脑硬盘出现故障),它还提供了与同一网格上的其他用户共享特定文件或目录的灵活方式。这样,它的行为有点像“网络驱动器”(SMB 或 NFS),或者文件传输协议(FTP 或 HTTP)。
Tahoe 的特色是“独立于提供商的安全性”所有文件在离开您的电脑之前都经过加密和加密哈希处理。存储服务器永远看不到明文(因为加密),也无法进行未被发现的更改(因为散列)。此外,密文被擦除编码成冗余部分,并上传到多个独立的服务器。这意味着您的数据可以在失去几台服务器的情况下继续存在,从而提高耐用性和可用性。
因此,您可以纯粹根据性能、成本和正常运行时间来选择存储服务器,而不需要依赖它们的安全性。大多数其他网络驱动器完全容易受到服务器的攻击:危害主机提供商的攻击者可以看到或修改您的数据,或者完全删除它。Tahoe 的机密性和完整性完全独立于存储提供商,可用性也得到提高。
太浩湖-LAFS 如何运作
一个 Tahoe“网格”由一个或多个介绍器、一些服务器和一些客户机组成。
-
客户端知道如何上传和下载数据。
-
服务器持有加密的共享。
-
介绍器帮助客户机和服务器找到并相互连接。
这三种节点类型使用名为“Foolscap”的特殊协议进行通信,该协议源于 Twisted 的“透视代理”,但增加了安全性和灵活性。
Tahoe 使用“能力字符串”来识别和访问所有文件和目录。这些看起来随机的 base32 数据块包含加密密钥、完整性保护散列和共享位置信息。当它们指代文件时,我们将其缩写为“filecaps ”,对于目录,我们将其缩写为“dircaps”。
图 6-1
太浩湖-LAFS 网格图
(为了可读性,本章中的例子被缩短了,但是 filecaps 通常大约有 100 个字符长。)
它们有时有多种形式:“writecap”赋予知道它的人修改文件的能力,而“readcap”只让他们读取内容。甚至还有一个“验证上限”,它允许持有者验证加密的服务器端共享(如果一些已经丢失,还可以生成新的),但不能读取或修改明文。当您自己的计算机脱机时,您可以放心地将这些文件交给授权的维修代理来维护您的文件。
Tahoe 最简单的 API 调用是一个命令行PUT,它接受明文数据,将其上传到一个全新的不可变文件中,并返回生成的 filecap:
$ tahoe put kittens.jpg
200 OK
URI:CHK:bz3lwnno6stuspjq5a:mwmb5vaecnd3jz3qc:2:3:3545
这个 filecap 是世界上检索文件的唯一方法。您可以将它写下来,或者存储在另一个文件中,或者存储在一个 Tahoe 目录中,但是这个字符串对于恢复文件来说既必要又充分。下载如下所示(tahoe get命令将下载的数据写入 stdout,因此我们使用"> " shell 语法将其重定向到一个文件中):
$ tahoe get URI:CHK:bz3lwnno6stuspjq5a:mwmb5vaecnd3jz3qc:2:3:3545 >downloaded.jpg
我们经常(也许是错误地)在许多地方将 filecaps 称为 URIs,包括 filecaps 字符串本身。“CHK”代表“内容散列键”,它描述了我们使用的不可变文件编码的具体种类:其他种类的 cap 有不同的标识符。不可变的文件上限总是 readcaps:一旦文件上传,世界上没有人可以修改它,即使是最初的上传者。
Tahoe 还提供了可变的文件,这意味着我们可以在以后更改内容。这有三个 API 调用:create生成一个可变槽,publish向槽中写入新数据(覆盖以前的内容),然后retrieve返回槽的当前内容。
可变槽有写上限和读上限。给你 writecap,但是任何知道 writecap 的人都可以把它“简化”成 readcap。这允许您与其他人共享 readcap,但为您自己保留 write 权限。
在 Tahoe 中,目录只是包含特殊编码的表的文件,该表将孩子的名字映射到孩子的 filecap 或 dircap。可以把这些目录想象成有向图中的中间节点。
图 6-2
Rootcap、目录和文件的图表
我们可以用mkdir命令创建一个。这默认创建一个可变目录(但是如果我们愿意,我们也可以创建完全填充的不可变目录)。Tahoe 有cp和ls命令来复制文件和列出目录,这些命令知道如何像往常一样处理斜杠分隔的文件路径。
CLI 工具还提供了“别名”,它只是在本地文件(~/.tahoe/private/aliases)中存储一个“rootcap”目录,允许其他命令使用看起来很像网络驱动器指示符的前缀来缩写 dircap(例如,Windows E:驱动器)。这减少了输入,使命令更容易使用:
$ tahoe mkdir
URI:DIR2:ro76sdlt25ywixu25:lgxvueurtm3
$ tahoe add-alias mydrive URI:DIR2:ro76sdlt25ywixu25:lgxvueurtm3
Alias 'mydrive' added
$ tahoe cp kittens.jpg dogs.jpg mydrive:
Success: files copied
$ tahoe ls URI:DIR2:ro76sdlt25ywixu25:lgxvueurtm3
kittens.jpg
dogs.jpg
$ tahoe mkdir mydrive:music
$ tahoe cp piano.mp3 mydrive:music
$ tahoe ls mydrive:
kittens.jpg
music
dogs.jpg
$ tahoe ls mydrive:music
piano.mp3
$ tahoe cp mydrive:dogs.jpg /tmp/newdogs.jpg
$ ls /tmp
newdogs.jpg
命令行工具构建在 HTTP API 之上,我们将在后面探讨。
系统结构
客户机节点是一个长期存在的网关守护进程,它接受来自“前端”协议的上传和下载请求。最基本的前端是一个 HTTP 服务器,它监听环回接口(127.0.0.1)。
HTTP GET用于检索数据,这涉及多个步骤:
-
解析 filecap 以提取解密密钥和存储索引;
-
确定我们需要每个共享的哪些部分来满足客户端请求,包括共享数据和中间散列树节点;
-
使用存储索引来确定哪些服务器可以共享此文件;
-
向这些服务器发送下载请求;
-
跟踪我们已发送的请求和已完成的请求,以避免重复请求,除非必要;
-
跟踪服务器响应时间,选择速度更快的服务器;
-
核实股份,拒绝腐败的股份;
-
当可用或失去连接时,切换到更快的服务器;
-
将份额重组为密文;
-
解密密文,将明文传递给前端客户端。
这由一个事件循环来管理,该事件循环随时准备接受来自前端管理器的新的read()请求,或者来自服务器的响应,或者指示是时候放弃一个服务器并尝试另一个服务器的定时器到期。这个循环将同时处理几十个甚至几百个连接和计时器,其中任何一个上的活动都会导致其他的事情发生。Twisted 的事件循环非常适合这种设计。
在另一个方向,HTTP PUT和POST动作导致数据被上传,这执行许多相同的步骤,但是向后:
-
客户端节点接受来自前端协议的数据,并将其缓存在临时文件中;
-
对文件进行哈希处理以构建“聚合加密密钥”,该密钥也用于对文件进行重复数据消除;
-
加密密钥被散列以形成存储索引;
-
存储索引标识我们应该尝试使用哪些服务器(服务器列表对于每个存储索引以不同的方式排序,并且该列表提供了优先级排序);
-
向这些服务器发送上传请求;
-
如果文件上传得更早,服务器会告诉我们他们已经有一个共享,在这种情况下,我们不需要再次存储那个;
-
如果一个服务器拒绝我们的请求(没有足够的磁盘空间),或者回答速度不够快,请尝试另一个服务器;
-
收集响应,直到每个共享映射到一个服务器;
-
对每一段明文进行加密编码,会占用大量的 CPU(至少相对于网络活动来说是这样的),所以我们把它推给一个单独的线程来利用多核的优势;
-
编码完成后,将共享上传到之前映射的服务器;
-
当所有服务器确认收到时,构建最终的散列树;
-
从哈希树的根和加密密钥构建 filecap
-
在 HTTP 响应体中返回 filecap。
客户端还实现其他(非 HTTP)前端协议:
-
FTP:通过提供用户名、密码和 rootcaps 的配置文件,Tahoe 客户机节点可以假装成一个 FTP 服务器,为每个用户提供一个单独的虚拟目录;
-
SFTP:像 FTP 一样,但是分层在 SSH 之上;
-
Magic-Folder:一个类似 Dropbox 的双向目录同步工具。
客户对介绍人说废话,以了解服务器。他们也对服务器本身说废话。
太浩-LAFS 存储服务器可以将共享存储在本地磁盘上,也可以将它们发送到远程商品存储服务,如 S3 或 Azure。例如,服务器在前端使用 Foolscap,在后端使用基于 HTTP 的 S3 命令。
在存储服务器上,节点必须接受来自任意数量客户端的连接,每个客户端都将发送重叠的共享上传/下载请求。对于像 S3 这样的远程后端,每个客户端请求都可能引发多个 S3 端 API 调用,每个调用都可能失败或超时(并且需要重试)。
所有节点类型还运行 HTTP 服务来进行状态和管理。这目前使用 Nevow 渲染,但是我们打算切换到 Twisted 的内置 HTTP 模板工具(twisted.web.template)。
它如何使用 Twisted 的
塔霍-LAFS 大量使用了 Twisted:我们很难想象我们可以用其他方式来写它。
该应用是围绕一个 Twisted 的MultiService层次结构构建的,它控制上传者、下载者、引入者客户端等的启动和关闭。这让我们可以在单元测试期间启动单独的服务,而不需要每次都启动整个节点。
最大的服务是Node,它代表整个客户机、服务器或介绍者。这是其他所有东西的父级MultiService。关闭服务(并等待所有网络活动停止)就像调用stopService()并等待延迟启动一样简单。默认情况下,节点监听临时分配的端口,并向引入者宣布它们的位置。所有状态仅限于节点的“基本目录”这使得在单个过程中启动多个客户机/服务器变得很容易,以便一次测试整个网格。这与早期的体系结构形成对比,在早期的体系结构中,每个存储服务器都需要一个单独的 MySQL 数据库,并使用固定的 TCP 端口。在该系统中,如果没有至少 5 台不同的计算机,就不可能进行真实的测试。在 Tahoe 中,集成测试套件将启动一个包含 10 台服务器的网格,所有这些都在一个进程中,执行一些功能,然后在几秒钟内再次关闭所有的功能。每当您运行tox来运行测试套件时,这种情况都会发生几十次。
Twisted 强大的集成协议实现套件支持各种前端接口。我们不需要编写 HTTP 客户端,或者服务器,或者 FTP 服务器,或者 SSH/SFTP 服务器:这些都是 Twisted 附带的“电池”。
我们遇到的问题
我们对 Twisted 的使用相当顺利。如果我们今天重新开始,我们还是会从 Twisted 开始。我们的遗憾是微不足道的:
-
依赖负载:一些用户(通常是打包者)觉得 Tahoe 依赖于太多的库。多年来,我们试图避免添加依赖项,因为 Python 的打包工具不成熟,但现在
pip让这变得容易多了; -
打包/分发:很难从 Python 应用中构建一个单文件的可执行文件,所以目前用户必须了解 Python 特有的工具,如
pip和virtualenv,以便在他们的家庭计算机上安装 Tahoe -
Python 3: Twisted 现在对 Python 3 有很好的支持,但这需要很多年的努力。在此期间,我们变得自满,代码自由地将机器可读的字节与人类可读的字符串混杂在一起。既然 py3 是首选实现(py2 的 2020 年寿终正寝的最后期限即将到来),我们正在努力更新我们的代码以在 py3 下工作。
虚拟化工具
Twisted 提供了一个名为twistd的便利工具,它允许将长时间运行的应用编写为插件,使 Twisted 负责特定于平台的后台化细节(例如从控制 tty 中分离,记录到文件而不是 stdout,以及在打开特权侦听 TCP 端口后可能切换到非 root 用户)。当 Tahoe 开始的时候,“pip”和“virtualenv”都还不存在,所以我们建造了类似的东西。为了将虚拟化与这个定制的依赖安装程序/管理器结合起来,Tahoe 命令行工具包括了tahoe start和tahoe stop子命令。
如今,我们可能会省略这些子命令,让用户运行twistd或twist(非守护进程形式)。我们还会寻找根本不需要守护进程的方法。
开始的时候,twistd没有那么容易管理,所以 Tahoe 用了”。点击“文件”来控制它。这是我在 Buildbot 中使用的模式的延续,遗憾的是第一个版本使用了”。点击“文件”来记录状态(一种应用的“冻干”副本,下次你想启动它时可以再次解冻)。Tahoe 从未将动态状态放入其中,但是tahoe create-node进程会创建一个带有正确初始化代码的.tap文件来实例化和启动新节点。然后tahoe start是围绕twistd -y node.tap的一个简单包装。
不同种类的。tap文件用于启动不同类型的节点(客户端、服务器、引入器等)。).这是一个错误的决定。那个。tap 文件只包含几行:一个 import 语句和实例化应用对象的代码。两者最终都限制了我们重新安排代码库或改变其行为的能力:简单地重命名Client类会破坏所有现有的部署。我们无意中创建了一个公共 API(包含所有的兼容性问题),其中的“公共”是早期 Tahoe 安装使用的所有旧的.tap文件。
我们通过让tahoe start忽略.tap文件的内容,只关注它的文件名来解决这个问题。节点的大部分配置已经存储在一个名为tahoe.cfg的单独的 INI 风格文件中,所以转换非常容易。当tahoe start看到client.tap时,它创建一个客户机实例(相对于介绍者/etc。),用配置文件初始化它,并设置守护进程运行。
内部文件节点接口
在内部,Tahoe 定义了FileNode对象,可以从现有文件的 filecap 字符串创建,也可以通过第一次上传一些数据从头开始创建。这些提供了一些简单的方法,隐藏了加密、擦除编码、服务器选择和完整性检查的所有细节。下载方法在名为IReadable的接口中定义:
class IReadable(Interface):
def get_size():
"""Return the length (in bytes) of this readable object."""
def read(consumer, offset=0, size=None):
"""Download a portion (possibly all) of the file's contents, making them available to the given IConsumer. Return a Deferred that fires (with the consumer) when the consumer is unregistered (either because the last byte has been given to it, or because the consumer threw an exception during write(), possibly because it no longer wants to receive data). The portion downloaded will start at 'offset' and contain 'size' bytes (or the remainder of the file if size==None). """
Twisted 将zope.interface用于支持接口定义的类(即Interface实际上是zope.interface.Interface)。我们使用这些作为类型检查的一种形式:前端可以断言被读取的对象是IReadable的提供者。FileNode有多种,但都实现了IReadable接口,前端代码只使用那个接口上定义的方法。
read()接口不直接返回数据:相反,它接受一个“消费者”,当数据到达时,它可以将数据提供给这个消费者。它使用 Twisted 的生产者/消费者系统(在第一章中描述)来传输数据,而没有不必要的缓冲。这使得 Tahoe 能够在不使用千兆字节内存的情况下传送数千兆字节的文件。
类似地,也可以创建对象。这些节点也有方法(在IDirectoryNode中定义)来列出它们的子节点,或者跟随子节点链接(通过名称)到其他节点。可变目录包括通过名称添加或替换子目录的方法。
class IDirectoryNode(IFilesystemNode):
"""I represent a filesystem node that is a container, with a name-to-child mapping, holding the tahoe equivalent of a directory. All child names are unicode strings, and all children are some sort of IFilesystemNode (a file, subdirectory, or unknown node).
"""
def list():
"""I return a Deferred that fires with a dictionary mapping child name (a unicode string) to (node, metadata_dict) tuples, in which 'node' is an IFilesystemNode and 'metadata_dict' is a dictionary of metadata."""
def get(name):
"""I return a Deferred that fires with a specific named child node, which is an IFilesystemNode. The child name must be a unicode string. I raise NoSuchChildError if I do not have a child by that name."""
请注意,这些方法返回延迟。目录存储在文件中,文件存储在共享中,共享存储在服务器上。我们不知道这些服务器何时会响应我们的下载请求,所以我们使用一个延迟来“等待”数据可用。
每个前端协议都使用这个节点对象图。
前端协议集成
为了探索 Tahoe 如何利用 Twisted 的多种协议支持,我们将研究几个“前端协议”这些提供了外部程序和内部IFileNode / IDirectoryNode / IReadable接口之间的桥梁。
所有的协议处理器都使用一个名为Client的内部对象,其最重要的方法是create_node_from_uri。这需要一个 filecap 或 directorycap(作为一个字符串),并返回相应的FileNode或DirectoryNode对象。从这里,调用者可以使用它的方法来读取或修改底层的分布式文件。
Web 前端
Tahoe-LAFS 客户端守护程序提供了一个本地 HTTP 服务来控制其大部分操作。这包括一个以人为本的浏览文件和文件夹的网络应用(“WUI”:网络用户界面)和一个以机器为本的控制界面(“WAPI”:网络应用编程界面),我们亲切地称之为“哇”和“哇”
两者都是通过 Twisted 内置的twisted.web服务器实现的。“资源”对象的层次结构将请求路由到某个叶子,该叶子实现类似于render_GET的方法来处理请求细节并提供响应。默认情况下,它监听端口 3456,但是这可以在tahoe.cfg文件中通过提供不同的端点描述符来配置。
Tahoe 实际上使用了“Nevow”项目,该项目在 raw twisted.web之上提供了一个层,但这些天 Twisted 的内置功能本身就足够强大,所以我们正在慢慢地从代码库中删除 Nevow。
最简单的 WAPI 调用是获取文件。HTTP 客户端提交一个 filecap,Tahoe 将其转换成一个FileNode,下载内容,并在 HTTP 响应中返回数据。该请求看起来像是:
curl -X GET http://127.0.0.1:3456/uri/URI:CHK:bz3lwnno6stus:mwmb5vae...
这会产生一个带有“path”数组的twisted.web.http.Request,该数组有两个元素:文字字符串“uri,”和 filecap。Twisted 的 web 服务器从一个根资源开始,在这个资源上可以附加不同名称的处理程序。我们的Root资源用上面描述的Client对象实例化,并配置了一个用于uri名称的处理程序:
from twisted.web.resource import Resource
class Root(Resource):
def __init__(self, client):
...
self.putChild("uri", URIHandler(client))
所有以uri/开始的请求都将被路由到这个URIHandler资源。当这些请求有额外的路径组件(例如,我们的 filecap)时,它们将导致调用getChild方法,该方法负责找到正确的资源来处理请求。我们将从给定的 filecap/dircap 创建一个 FileNode 或 DirectoryNode,然后将它包装在一个特定于 web 的 handler 对象中,该对象知道如何处理 HTTP 请求:
class URIHandler(Resource):
def __init__ (self, client):
self.client = client
def getChild(self, path, request):
# 'path' is expected to be a filecap or dircap
try:
node = self.client.create_node_from_uri(path)
return directory.make_handler_for(node,self.client)
except (TypeError,AssertionError):
raise WebError("'%s' is not a valid file- or directory- cap" %name)
node是包装来自 GET 请求的 filecap 的FileNode对象。该处理程序来自一个 helper 函数,它检查节点的可用接口并决定创建哪种包装器:
def make_handler_for(node, client, parentnode=None, name=None):
if parentnode:
assert IDirectoryNode.providedBy(parentnode)
if IFileNode.providedBy(node):
return FileNodeHandler(client, node, parentnode, name)
if IDirectoryNode.providedBy(node):
return DirectoryNodeHandler(client, node, parentnode, name)
return UnknownNodeHandler(client, node, parentnode, name)
对于我们的例子,这将返回FileNodeHandler。这个处理程序有很多选项,并且web/filenode.py中的实际代码看起来非常不同,但是一个简化的形式应该是这样的:
class FileNodeHandler(Resource):
def __init__ (self, client, node, parentnode=None, name=None):
self.node = node
...
@inlineCallbacks
def render_GET(self, request):
version = yield self.node.get_best_readable_version()
filesize = version.get_size()
first, size, contentsize = 0, None, filesize
... # these will be modified by a Range header, if present
request.setHeader("content-length", b"%d" % contentsize)
yield version.read(request, first, size)
Twisted 的原生 web 服务器不允许Resource对象返回 Deferreds,但是 Nevow 的允许,这很方便。基本上是这样的:
-
首先,我们向 FileNode 询问它的最佳可读版本。不可变文件不需要这样做(反正只有一个版本),但是可变文件在网格上可能有多个版本。“最佳”是指最新的。我们得到一个提供了
IReadable接口的“版本”对象。 -
接下来,我们计算文件的大小。对于不可变文件,大小嵌入在 filecap 中,所以
get_size()方法让我们可以立即计算出来。对于可变文件,大小是在我们检索版本对象时确定的。 -
我们使用文件的大小和范围头(如果提供的话)来计算要读取多少数据,以及从什么偏移量开始。
-
我们设置 Content-Length 头来告诉 HTTP 客户机预期有多少数据。
-
调用
IReadable的read()方法开始下载。请求对象也是一个 IConsumer,下载代码构建一个 IProducer 来附加到它。这将返回一个延迟,当文件的最后一个字节已传递给使用者时,将触发该延迟。 -
当最后一个延迟触发时,服务器知道它可以关闭 TCP 连接,或者为下一个请求重置它。
我们省略了许多细节,在下面展开。
文件类型,内容类型,/name/
Tahoe 的存储模型将文件上限映射到字节串,没有名称、日期或其他元数据。目录包含名字和日期,在指向它们孩子的表条目中,但是一个基本的 filecap 只给你一堆字节。
然而,HTTP 协议为每次下载包含一个Content-Type,它允许浏览器决定如何呈现页面(HTML、JPG 或 PNG),或者在将页面保存到磁盘时记录什么操作系统元数据。此外,大多数浏览器假定 URL 路径的最后一部分是文件名,并且“保存到磁盘”功能将使用它作为默认文件名。
为了处理这种不匹配,Tahoe 的 WAPI 有一个特性,让你下载一个在路径的最后一个元素有任意名称的 filecap。WUI 目录浏览器将这些特殊的 URL 放在目录页面的 HTML 中,因此“将链接另存为..”工作正常。完整的 URL 如下所示:
http://127.0.0.1:3456/named/URI:CHK:bz3lwnno6stus:mwmb5vae../kittens.jpg
这看起来很像一个目录和里面的一个孩子。为了避免视觉上的混乱,我们通常会在这样的 URL 中插入一个看起来特别有趣的字符串:
http://127.0.0.1:3456/named/URI:CHK:bz3lwn../@@named=/kittens.jpg
这是用一个创建了一个FileNodeHandler的Named资源实现的,但是它还会记住self.filename中 URL 路径的最后一个组件(忽略任何中间组件,比如@@ named=字符串)。然后,当我们运行render_GET时,我们将这个文件名传递给一个 Twisted 实用程序,该实用程序使用相当于/etc/mime.types的代码将文件名后缀映射到一个类型字符串。由此,我们可以设置Content-Type和Content-Encoding标题。
# from twisted.web import static
ctype, encoding = static.getTypeAndEncoding(
self.filename,
static.File.contentTypes,
static.File.contentEncodings,
defaultType="text/plain")
request.setHeader("content-type", ctype)
if encoding:
request.setHeader("content-encoding", encoding)
保存到磁盘
当你点击一个链接时,浏览器将试图呈现返回的文档:HTML 经过布局,图像被绘制在窗口中,音频文件被播放,等等。如果它不能识别文件类型,它会将文件保存到磁盘。Tahoe 的“WUI”HTML 前端提供了一种强制执行这种保存到磁盘行为的方式:对于任何指向文件的 URL,只需在 URL 后面附加一个?save=True查询参数。web 服务器通过添加一个Content-Disposition头来处理这个问题,这个头指示浏览器总是保存响应,而不是试图呈现它:
if boolean_of_arg(get_arg(request,"save","False")):
request.setHeader("content-disposition",
'attachment; filename="%s"' % self.filename)
范围标题
web 前端允许 HTTP 客户端通过提供一个范围头来请求文件的一个子集。当“搓擦”控制用于在影片或音频文件中跳转时,流媒体播放器(如 VLC 或 iTunes)经常使用这种方法。通过使用 Merkle 散列树,Tahoe 的编码方案被特别设计来有效地支持这种随机存取。
Merkle 哈希树首先将数据分割成段,然后对每个段应用加密哈希函数(SHA256)。然后,我们将每对段散列放入第二层(长度是第一层的一半)。重复这个减少过程,直到我们在中间散列节点的二进制树的顶部有单个“根散列”,而段在底部。根散列存储在 filecap 中,我们将其他所有东西(数据段和中间散列)发送到服务器。在检索期间,通过要求服务器提供从该段到根的路径的伴随散列节点,可以对照存储的根来验证任何单个段,而无需下载所有其他段。这使得能够以最少的数据传输快速验证任意数据段。
web 前端通过解析请求的 Range 头、设置响应的 Content-Range 和 Content-Length 头,以及修改我们传递给read()方法的first和size值来处理这个问题。
解析范围头并不简单,因为它可以包含一个(可能重叠的)范围列表,其中可能包含文件的开头或结尾,并且可以用不同的单位(不仅仅是字节)来表示。幸运的是,允许服务器忽略不可解析的范围规范:这样效率不高,但是它们可以返回整个文件,就好像范围头不存在一样。然后,客户端有义务忽略他们不想要的数据部分。
first, size, contentsize = 0,None, filesize
request.setHeader("accept-ranges","bytes")
rangeheader = request.getHeader('range')
if rangeheader:
ranges = self.parse_range_header(rangeheader)
# ranges = None means the header didn't parse, so ignore
# the header as if it didn't exist. If is more than one
# range, then just return the first for now, until we can
# generate multipart/byteranges.
if ranges is not None:
first, last = ranges[0]
if first >= filesize:
raise WebError('First beyond end of file',
http.REQUESTED_RANGE_NOT_SATISFIABLE)
else:
first = max(0, first)
last = min(filesize-1, last)
request.setResponseCode(http.PARTIAL_CONTENT)
request.setHeader('content-range',"bytes %s-%s/%s" %
(str(first), str(last),
str(filesize)))
contentsize = last – first + 1
size = contentsize
request.setHeader("content-length", b"%d" % contentsize)
返回端的错误转换
当出现问题时,Tahoe 的内部 API 会抛出各种异常。例如,如果太多的服务器出现故障,文件可能无法恢复(至少在一些服务器重新联机之前无法恢复)。我们试图用一个运行在 HTTP 处理链末端的异常处理程序将这些异常映射成合理的 HTTP 错误代码。这个处理程序的核心被命名为humanize_failure(),并查看twisted.python.failure.Failure对象,该对象封装了在延迟处理期间引发的所有异常:
def humanize_failure(f):
# return text, responsecode
if f.check(EmptyPathnameComponentError):
return ("The webapi does not allow empty pathname components, "
"i.e. a double slash" , http.BAD_REQUEST)
if f.check(ExistingChildError):
return ("There was already a child by that name, and you asked me "
"to not replace it." , http.CONFLICT)
if f.check(NoSuchChildError):
quoted_name = quote_output(f.value.args[0], encoding="utf-8")
return ("No such child: %s" % quoted_name, http.NOT_FOUND)
if f.check(NotEnoughSharesError):
t = ("NotEnoughSharesError: This indicates that some "
"servers were unavailable, or that shares have been "
"lost to server departure, hard drive failure, or disk "
"corruption. You should perform a filecheck on "
"this object to learn more.\n\nThe full error message is:\n"
"%s" ) % str(f.value)
return (t, http.GONE)
...
返回值的前半部分是要放入 HTTP 响应正文的字符串;第二个是 HTTP 错误码本身。
呈现 UI 元素:Nevow 模板
Tahoe 的 WUI 提供了一个文件浏览器界面:目录面板、文件列表、上传/下载选择器、删除按钮等。这些由 HTML 组成,在服务器端由 Nevow 模板呈现。
web/目录包含每个页面的 XHTML 文件,占位符由DirectoryNodeHandler类的变量填充。每个占位符都是一个命名空间 XML 元素,用来命名一个“槽”目录列表模板如下所示:
<table class="tahoe-directory"n:render="sequence"n:data="children" >
<tr n:pattern="header">
<th>Type</th>
<th>Filename</th>
<th>Size</th>
</tr>
<tr n:pattern="item"n:render="row" >
<td><n:slot name="type"/></td>
<td><n:slot name="filename"/></td>
<td align="right"><n:slot name="size"/></td>
</tr>
在directory.py中,填充该表单的代码循环遍历正在呈现的目录的所有子目录,检查其类型,并使用ctx“context”对象按名称填充每个槽。对于文件,T.a Nevow 标签产生一个超链接,其中href=属性指向一个使用前面描述的/named/前缀的下载 URL:
...
elif IImmutableFileNode.providedBy(target):
dlurl = "%s/named/%s/@@named=/%s"%(root, quoted_uri, nameurl)
ctx.fillSlots("filename", T.a(href=dlurl, rel="noreferrer")[name])
ctx.fillSlots("type","FILE")
ctx.fillSlots("size", target.get_size())
Nevow 还提供了构建 HTML 输入表单的工具。这些用于构建上传文件选择器表单和“制作目录”名称输入元素。
FTP 前端
前端协议允许其他应用以某种与其现有数据模型相匹配的形式访问这个内部文件图。例如,FTP 前端将每个“帐户”(用户名/密码对)分配给一个根目录。当 FTP 客户端连接到该帐户时,他们会看到一个文件系统,该文件系统从该目录节点开始,并且只向下扩展(到子文件和子目录中)。在一个普通的 FTP 服务器中,所有的帐户都看到相同的文件系统,但是有不同的权限(Alice 不能读取 Bob 的文件),以及不同的开始目录(Alice 在/home/alice开始,Bob 在/home/bob开始)。在 Tahoe FTP 服务器中,Alice 和 Bob 将拥有完全不同的文件系统视图,这些视图可能根本不会重叠(除非他们已经安排共享他们空间的某个部分)。
Tahoe 的 FTP 前端建立在 Twisted 的 FTP 服务器上(twisted.protocols.ftp)。FTP 服务器使用 Twisted 的“Cred”框架进行帐户管理(包括“门户”、“领域”和“头像”)。因此,服务器由几个组件组成:
-
端点:这定义了服务器将监听哪个 TCP 端口,以及使用哪些网络接口之类的选项(例如,服务器可以被限制为只监听 127.0.0.1,即环回接口)。
-
FTPFactory (twisted.protocols.ftp.FTPFactory):这提供了整个 FTP 服务器。它是一个“协议工厂”,所以每次新客户端连接时都会调用它,它负责构建管理特定连接的Protocol实例。当您告诉端点开始监听时,您给了它一个工厂对象。 -
Checker:这是一个实现
ICredentialsChecker并处理认证的对象,通过检查一些凭证并(如果成功)返回一个“化身 ID”在 FTP 协议中,凭证是用户提供的用户名和密码。在 SFTP,它们包括 SSH 公钥。“头像 ID”只是一个用户名。Tahoe FTP 前端可以配置为使用一个AccountFileChecker(在 auth.py 中),它将用户名/密码/rootcap 映射存储在一个本地文件中。它还可以使用一个AccountURLChecker,查询一个 HTTP 服务器(它发布用户名和密码,并在响应中获取 rootcap)。AccountURLChecker用于 AllMyData 的集中账户管理。 -
Avatar:这是处理特定用户体验的服务器端对象。它还特定于一个服务类型,因此它必须实现一些特定的
Interface,在本例中是一个名为IFTPShell的 Twisted 接口(它有像makeDirectory、stat、list和openForReading这样的方法)。 -
Realm:这是任何实现 Twisted 的
IRealm接口的对象,负责把一个头像 ID 变成头像。Realm API 还处理多个接口:需要特定类型访问的客户端可以请求特定的Interface,Realm 可能会根据他们的请求返回不同的虚拟角色。在 Tahoe FTP 前端,realm 是一个名为Dispatcher的类,它知道如何从帐户信息创建一个根目录节点,并将其包装在一个处理程序中。 -
Portal(twisted.cred.portal.Portal):这是一个管理跳棋和领域的 Twisted 对象。在构建时,FTPFactory配置有一个Portal实例,所有涉及授权的事情都委托给门户。 -
Handler(allmydata.frontends.ftpd.Handler):这是一个 Tahoe 对象,实现了 Twisted 的IFTPShell,并将 FTP 概念翻译成 Tahoe 概念。
Tahoe FTP 服务器代码执行以下操作:
-
创建一个挂在顶层节点 multiservice 上的
MultiService; -
挂一个
strports.service下来,监听 FTP 服务器端口; -
用
FTPFactory;配置监听器 -
用
Portal;配置工厂 -
创建一个
Dispatcher作为门户的“领域”; -
向门户添加一个
AccountFileChecker和/或一个 AccountURLChecker。
当 FTP 客户端连接时,用户名和密码被提交给AccountFileChecker,它之前已经将帐户文件解析到内存中。帐户查找非常简单:
class FTPAvatarID:
def __init__ (self, username, rootcap):
self.username = username
self.rootcap = rootcap
@implementer(checkers.ICredentialsChecker)
class AccountFileChecker(object):
def requestAvatarId(self, creds):
if credentials.IUsernamePassword.providedBy(creds):
return self._checkPassword(creds)
...
def _checkPassword(self, creds):
try:
correct = self.passwords[creds.username]
except KeyError:
return defer.fail(error.UnauthorizedLogin())
d = defer.maybeDeferred(creds.checkPassword, correct)
d.addCallback(self._cbPasswordMatch, str(creds.username))
return d
def _cbPasswordMatch(self, matched, username):
if matched:
return self._avatarId(username)
raise error.UnauthorizedLogin
def _avatarId(self, username):
return FTPAvatarID(username,self.rootcaps[username])
如果用户名不在列表中,或者如果密码不匹配,requestAvatarId将返回一个延迟的 errbacks 和UnauthorizedLogin,FTPFactory 将返回适当的 FTP 错误代码。如果两者都是好的,那么它返回一个FTPAvatarID对象,该对象封装了用户名和账户的 rootcap URI(只是一个字符串)。
当这成功时,门户要求其领域(即,我们的 Dispatcher 对象)将化身 ID 转换成处理程序。我们的领域也很简单:
@implementer(portal.IRealm)
class Dispatcher(object):
def __init__ (self, client):
self.client = client
def requestAvatar(self, avatarID, mind, interface):
assert interface == ftp.IFTPShell
rootnode = self.client.create_node_from_uri(avatarID.rootcap)
convergence = self.client.convergence
s = Handler(self.client, rootnode, avatarID.username, convergence)
def logout(): pass
return (interface, s,None)
首先,我们断言我们被请求的是一个IFTPShell,而不是一些其他的接口(我们不知道如何处理)。然后,我们使用 Tahoe 文件图 API 将 rootcap URI 转换成一个目录节点。“融合秘密”不在本章讨论范围内,但它的存在是为了提供安全的重复数据删除,提供给处理程序是为了让我们扩展接口,为每个帐户使用不同的融合秘密。
然后,我们围绕客户机(提供创建全新 filenodes 的方法)和 rootnode(提供对用户“主目录”及其下所有内容的访问)构建一个处理程序,并将其返回给门户。这足够连接 FTP 服务器了。
稍后,当客户端执行一个“ls”命令时,我们的处理程序的list()方法将被调用。我们的实现负责将列出目录的 FTP 概念(它获得相对于根目录的路径名组件的列表)转换为 Tahoe 的概念(它从根目录节点到其他目录节点进行逐步遍历)。
def list(self, path, keys=()):
d = self._get_node_and_metadata_for_path(path)
def _list((node, metadata)):
if IDirectoryNode.providedBy(node):
return node.list()
return { path[-1]: (node, metadata) }
d.addCallback(_list)
def _render(children):
results = []
for (name, childnode) in children.iteritems():
results.append( (name.encode("utf-8"),
self._populate_row(keys, childnode) ) )
return results
d.addCallback(_render)
d.addErrback(self._convert_error)
return d
我们从一个常见的“从根开始跟踪路径”助手方法开始,该方法返回一个延迟的,最终用路径命名的文件或目录的节点和元数据触发(如果路径是foo/bar,那么我们将向我们的根目录节点请求它的foo子节点,期望那个子节点是一个目录,然后向那个子目录请求它的bar子节点)。如果路径指向一个目录,我们使用 Tahoe IDirectoryNode 的node.list()方法来获取它的子节点:这将返回一个字典,该字典将子名称映射到(子节点,元数据)元组。如果路径指向一个文件,我们假设它指向一个只有一个文件的目录。
然后我们需要把这个孩子的字典变成 FTP 服务器可以接受的东西。在 FTP 协议中,LIST命令可以要求不同的属性:有时客户端需要所有者/组名,有时需要权限,有时它只关心子名称列表。Twisted 的IFTPShell接口通过给list()方法一系列“键”(字符串)来表示它想要的值。我们的_populate_row()方法将一个子元素+元数据对转换成正确的值列表。
def _populate_row(self, keys, (childnode, metadata)):
values = []
isdir = bool(IDirectoryNode.providedBy(childnode))
for key in keys:
if key == "size":
if isdir:
value = 0
else:
value = childnode.get_size() or 0
elif key == "directory":
value = isdir
elif key == "permissions":
value = IntishPermissions(0600)
elif key == "hardlinks":
value = 1
elif key == "modified":
if "linkmotime" in metadata.get("tahoe", {}):
value = metadata["tahoe"]["linkmotime"]
else:
value = metadata.get("mtime",0)
elif key == "owner":
value = self.username
elif key == "group":
value = self.username
else:
value = "??"
values.append(value)
return values
对于 Twisted 想要的每一个键,我们都将其转换成可以从 Tahoe 的IFileNode或IDirectoryNode接口获得的东西。其中大多数是在元数据中的简单查找,或者通过调用节点对象上的方法来获得。一个不寻常的案例是permissions:详见下文。
最后一步是附加_convert_error作为 errback 处理程序。这将一些特定于 Tahoe 的错误转换为最接近的 FTP 等价错误,这比客户端在没有转换的情况下会得到的“内部服务器错误”更有用。
def _convert_error(self, f):
if f.check(NoSuchChildError):
childname = f.value.args[0].encode("utf-8")
msg = "'%s' doesn't exist" % childname
raise ftp.FileNotFoundError(msg)
if f.check(ExistingChildError):
msg = f.value.args[0].encode("utf-8")
raise ftp.FileExistsError(msg)
return f
SFTP 前端
SFTP 是建立在 SSH 安全外壳加密层上的文件传输协议。它向远程客户端公开了一个非常类似 POSIX 的 API:打开、查找、读取和写入,都在同一个 filehandle 上进行。另一方面,FTP 只提供单个文件的全有或全无传输。FTP 更适合 Tahoe 的文件模型,但 SFTP 在与远程服务器通信时更安全。
使用Cred的好处是相同的认证机制可以被其他协议重用。尽管 FTP 和 SFTP 有所不同,但它们使用相同的基本访问模型:客户端通过一些凭证来标识,这提供了对特定主目录的访问。在 Tahoe,FTP 和 SFTP 都使用上面相同的FTPAvatarID和AccountFileChecker类。AccountFileChecker定义了credentialInterfaces,以涵盖所有可能出现的认证类型:IUsernamePassword、IUsernameHashedPassword和ISSHPrivateKey(这是特定于 SFTP 的,允许用户通过他们的 SSH 公钥而不是密码来识别)。
它们只是在领域(我们的Dispatcher类)上有所不同,后者为两种协议返回了不同种类的处理程序。
向后不兼容的 Twisted API
Tahoe 没有访问控制列表(ACL)、用户名或读/写/执行权限位的概念:它遵循“如果可以引用一个对象,就可以使用它”的对象能力原则。filecap 是不可访问的,因此引用文件的唯一方法是知道 file cap,它只能来自最初上传文件的人,或者来自从上传者那里了解到它的其他人。
大多数文件存储在目录中,因此访问控制是通过目录遍历来管理的,这是安全的,因为 Tahoe 目录没有“父”链接。你可以通过给别人一个链接来和别人分享你自己的一个目录:他们不能用这个来达到你给他们的那个目录之上的任何东西。
因此,FTP 服务器总是为“权限”字段返回“0600”,这意味着“仅由当前用户读写”这个值主要是装饰性的:FTP 客户端只使用它来填充长格式(ls -l)目录列表的“mode”列。我们在这里可以更准确,为不可变对象返回“0400 ”,但是我们并没有真的关心到做出改变。
然而,当 Twisted 的一个 API 发生意外变化时,即使是静态值也会引起问题。在早期,Twisted 使用整数来表示文件模式/权限(就像 Unix 内核和大多数 C 程序一样)。最终人们意识到这是非常以 unix 为中心的,所以在 Twisted-11.1.0 中,创建了一个漂亮、干净的filepath.Permissions类来保存这类信息作为布尔集合。
但是 FTP 服务器直到很久以后才更新使用它。在 Twisted-14.0.2 之前,list()的“权限”值应该返回一个整数。从 Twisted-15.0.0 开始,它应该返回一个Permissions实例。此外,它只接受了一个Permissions实例:返回一个整数会导致异常。
实际上,IFTPShell接口在 14.0.2 和 15.0.0 之间突然发生了变化,这是当我们开始收到关于 FTP ls命令失败的错误报告时发现的(我们没有对这个前端进行端到端的测试,我们的个人手动测试仍然使用 Twisted-14.0.2,所以我们自己没有注意到这个问题)。
Twisted 通常会在做出不兼容的更改之前对几个版本的 API 进行出色的改进,但这次却没有成功,这可能是因为最常见的IFTPShell实现是 Twisted 的内置FTPShell类,该类同时被更新。所以,另一种描述问题的方式是IFTPShell被修改了,没有折旧期,好像是私有的内部 API,但实际上是公共的。
解决这个问题最简单的方法是让 Tahoe 的setup.py需要Twisted >= 15.0.0,并修改代码返回一个Permissions对象。但是对于那些在 Linux 发行版上构建 Tahoe 的人来说,这将会使生活变得更加困难,因为 Linux 发行版包含了一个已经过时了几年的 Twisted 版本。(Debian 8.0“Jessie”是 2015 年和 Twisted-14.0.2 一起发布的,直到 2017 年才被取代。)当时,Tahoe 试图兼容各种 Twisted 的版本。让用户升级他们的系统只是为了满足 Tahoe 对现代时尚的热情,这让我们感觉很糟糕。
因此,为了让 Tahoe 既能处理新旧 Twisteds,我们需要在必要时返回类似整数的行为,但也可以类似于Permissions的行为。当我们检查 Twisted-14.0.2 使用该值的方式时,我们发现它总是在格式化过程中对该值进行按位 AND 运算:
# twisted-14.0.2: twisted/protocols/ftp.py line 428
def formatMode(mode):
return ''.join([mode&(256>>n) and 'rwx'[n % 3] or '-' for n in range(9)])
这让我们可以构建一个 helper 类,它从 Permissions 继承而来,但是如果旧的 Twisted 使用了二进制文件和方法,那么它会重写二进制文件和方法以返回一个整数:
# filepath.Permissions was added in Twisted-11.1.0, which we require.
# Twisted <15.0.0 expected an int, and only does '&' on it. Twisted
# >=15.0.0 expects a filepath.Permissions. This satisfies both.
class IntishPermissions(filepath.Permissions):
def __init__ (self, statModeInt):
self._tahoe_statModeInt = statModeInt
filepath.Permissions.__init__(self, statModeInt)
def __and__ (self, other):
return self._tahoe_statModeInt&other
如今,情况有所不同。我们不再建议用户将 Tahoe(或任何 Python 应用)安装到像/usr/local/bin这样的系统级位置,也不建议针对系统提供的 Python 库运行 Tahoe。相反,从源代码构建的用户应该将 Tahoe 安装到一个新的 virtualenv 中,在这里可以很容易地安装所有依赖项的最新版本,并且它们可以安全地与系统 python 隔离。
pipsi工具使这变得非常容易:pipsi install tahoe-lafs将创建一个 Tahoe 特定的 virtualenv,将 Tahoe 及其所有依赖项安装到其中,然后将tahoe可执行文件符号链接到~/.local/bin/tahoe中,它可能在您的 PATH 中。pipsi是现在推荐的从源码树安装 Tahoe 的方法。
系统范围的安装应该通过操作系统软件包管理器来完成。例如,apt install tahoe-lafs将在现代 Debian 和 Ubuntu 版本上获得一个工作的/usr/bin/tahoe,他们将使用来自/usr/lib/python2.7/dist-packages的全系统依赖(如 Twisted)。Debian 开发人员(和其他打包人员)负责确保系统范围的库与所有打包的应用兼容:Tahoe、Magic-Wormhole、Buildbot、Mercurial、Trac 等。当 Tahoe 摆脱对 Twisted 的依赖时,包装商必须解决这个问题。如果系统升级像 Twisted 这样的库,并且它包含意外的不兼容性,那么升级可以被恢复,直到 Tahoe 可以被修补来解决这个问题。
摘要
太浩湖-LAFS 是一个大型项目,始于 2006 年,当时 Twisted 还不是很老。它包含不再存在的错误的解决方法,以及已经被新的 Twisted 特性取代的技术。有时,代码似乎更好地反映了开发人员的历史恐惧和个人特质,而不是作为一个很好的教学示例。
但它也嵌入了多年来“愤怒地”(不是随便地)与 Twisted 的代码库一起工作的经验。尽管 Tahoe-LAFS 可能不是一个家喻户晓的名字,但它的核心思想已经影响并融入了许多其他分散存储系统(用 Go、Node.js、Rust 等编写)。
Twisted 的中央事件循环和大量现成的协议实现对我们的特性集至关重要。如果您真的不喜欢事件驱动的系统,您可以尝试用线程和锁来实现类似的东西(在客户端,您需要一个单独的线程来写入每个服务器,第二个线程用于从每个服务器接收,第三个线程用于每个前端请求,所有这些都必须小心地使用锁来防止并发访问)。这种方法安全工作的可能性很低。
Python 标准库包括一些很好的协议实现,但它们几乎都是以模块化的方式编写的,将它们限制为一次只能做一件事的程序。希望随着 Python 3 和asyncio的发展势头,这种情况会有所改变。同时,Twisted 是这样一个项目的最佳工具。
参考
太浩-LAFS 首页: https://tahoe-lafs.org