Twisted-专家级编程-二-

138 阅读53分钟

Twisted 专家级编程(二)

原文:Expert Twisted

协议:CC BY-NC-SA 4.0

三、使用treq和 Klein 的应用

前面几章深入解释了 Twisted 的基本原理。熟悉这些核心概念是必要的,但不足以编写真正的应用。在这一章中,我们将通过使用两个强大的 Twisted web 库:treq和 Klein 构建一个提要聚合器来探索现代的高级 API 和整个程序设计。

treq ( https://treq.readthedocs.io )用受流行的同步 HTTP 库requests启发的 API 包装twisted.web.client.Agent。其方便安全的默认值使得发送异步 HTTP 请求变得很容易,而treq.testing提供的 fakes 简化并标准化了编写测试。

Klein ( https://klein.readthedocs.io )是一个用户友好的包装器,围绕着 Twisted 的古老的twisted.web.server web 框架。它允许使用从 Werkzeug ( https://werkzeug.readthedocs.io/ )借来的熟悉的路由范例来开发动态、异步的 web 应用。

为什么是图书馆?

Twisted 本身提供了 Klein 和treq的核心功能。那为什么不直接用 Twisted 的那些部分呢?这两个库的界面都与 Twisted 的有很大不同;例如,twisted.web使用对象遍历而不是路由将 URL 路径与 Python 代码关联起来。一个twisted.web.server.Site不匹配一个请求的路径和查询字符串对一个字符串模板,如“/some/”;相反,它将路径段匹配到嵌套的Resource对象。在设计twisted.web的时候,这是 Python web 应用框架中流行的范例。Klein 的作者没有给 Twisted 本身增加一个新的路由抽象,而是选择在一个单独的代码库中试验一种不同的方法。他们的结果是成功的,Klein 的独立存在允许它在不破坏依赖于twisted.web.server的应用的情况下成长和适应。

同样,treq在高级 API 中封装了常见的twisted.web.client.Agent使用模式;例如,Agent要求将所有请求体表示为IBodyProducer对象,包括短到可以用字节串表示的有效载荷,而treq的请求方法直接接受字节串体。使用treq并不妨碍你使用Agent,它的全部力量在 Twisted 中仍然存在。

用于安装第三方 Python 包的工具pip目前运行得非常好,额外的要求不会给开发者带来不必要的负担。我们还将在后面的章节中看到如何使用 Docker 来开发和部署使用第三方库的 Twisted 应用,使之健壮并可重复。最后,Klein 和treq都属于 Twisted GitHub 组织,由 Twisted 的核心贡献者开发和使用。它们是图书馆中风险最低的。

饲料聚集

追溯到互联网历史上一个不同的、更加开放的时代。在其全盛时期,网站通过 HTTP 提供 feed 文件,这些文件以结构化的方式组织其内容,因此其他网站可以出于各种目的使用它们。像 RSS (真正简单的联合或丰富文档格式站点摘要)和 Atom 这样的开放标准描述了这些结构,并允许任何人编写这些提要的消费者。将许多网站的信息聚合在一个地方的服务成为用户获取最新新闻和博客的流行方式。这些格式的扩展,比如 RSS 的附件,允许 feeds 引用外部媒体,使得播客之类的东西得以兴起。

2013 年谷歌阅读器的消亡恰逢订阅源的受欢迎程度下降。网站删除了他们的提要,一些消费软件失去了消费它们的能力。尽管有所下降,但基于 feed 的网络聚合还没有单一的替代品,它仍然是组织来自许多不同在线来源的内容的有效方式。

许多标准定义了 RSS 的变体。如果需要直接使用提要格式,我们将只支持由哈佛大学伯克曼中心( http://cyber.harvard.edu/rss/rss.html )定义的 RSS 2.0 的以下子集:

  1. A <channel>是 RSS 2.0 提要文件的根元素,由它的<title><link>元素描述;

  2. 一个<channel>中的网页由<item>描述,每个网页都有自己的<title><link>元素。

我们将使用测试驱动开发用 Klein 和treq编写一个提要聚合器。然而,在此之前,我们将通过编写探索性程序来了解它们以及定义提要聚合的问题空间。然后,我们将使用我们学到的知识来设计、实现和迭代地改进我们的应用。因为不先下载提要就无法显示它们,所以我们将从探索如何用treq发送 HTTP 请求开始。

介绍treq

提要聚合器必须先下载提要,然后才能显示它们,所以我们将从探索treq开始。请注意,下面的例子应该适用于 Python 2 和 3。

使用您喜欢的工具创建一个新的虚拟环境,并将 PyPI 中的treq安装到其中。有许多工具可以实现这一点;出于通用性的考虑,我们建议像这样使用virtualenv ( https://virtualenv.pypa.io/en/stable/ )和pip ( https://pip.pypa.io/en/stable/ ):

$ virtualenv treq-experiment-env
...
$ ./treq-experiment-env/bin/pip install treq
...
$ ./treq-experiment-env/bin/python experiment.py

其中experiment.py包含以下代码:

from argparse import ArgumentParser
from twisted.internet import defer, task
from twisted.web import http
import treq

@defer.inlineCallbacks
def download(reactor):
    parser = ArgumentParser()
    parser.add_argument("url")
    arguments = parser.parse_args()
    response = yield treq.get(
        arguments.url, timeout=30.0, reactor=reactor)
    if response.code != http.OK:
        reason = http.RESPONSES[response.code]
    raise RuntimeError("Failed:{}{}".format(response.code,
                                            reason))
    content = yield response.content()
    print(content)

task.react(download)

download函数用标准库的argparse模块提取一个 URL 命令行参数,然后使用treq.getGET它。treq的客户端 API 接受bytesunicodeURL,根据定义文本 URL 的复杂规则对后者进行编码。这使得我们的程序更容易编写,因为ArgumentParser.parse_args返回代表 Python 2 和 3 上命令行参数的str对象;在 Python 2 中,它们是字节字符串,而在 Python 3 中,它们是 unicode 字符串。我们不必担心将 URL str编码或解码成适合特定 Python 版本的类型,因为treq会为我们正确地完成这项工作。

treq的客户端 API 接受一个timeout参数,该参数终止未能在指定超时内开始的请求。reactor参数指定哪个反应器对象用于网络和内部簿记。这是一个依赖注入 : treq 依赖于反应器,但是treq可以提供这个依赖而不是导入twisted.internet.reactor本身。我们将在后面看到依赖注入如何使测试和分解代码变得更容易。

treq.get返回一个解析为treq.response._Response对象的Deferred(其名称中的下划线暗示我们不应该自己构造实例,而不是说我们不应该与它交互)。这实现了twisted.web.iweb.IRequest接口,所以它在code属性中包含了响应的状态代码。我们的示例程序检查这个值,以确保服务器的响应表明我们的请求是成功的;如果不是,它将使用响应的状态代码及其对应的状态短语引发一个RuntimeError,这是由twisted.web.http.RESPONSES字典提供的,它将二者相互映射。

Deferred也可以解析为Failure。例如,如果在Response对象可以被构造之前,经过了由timeout参数指定的时间量,Deferred将失败,出现CancelledError

treq的回复也有额外的方法,使得与他们的互动更加方便。其中之一是content,它返回一个Deferred,将请求的整个主体解析为一个单独的bytes对象。treq在幕后为我们处理收集响应的所有细节。

最后,我们的例子从不直接调用reactor.runreactor.stop。相反,它使用了一个我们从未见过的 Twisted 的库函数:twisted.internet.task.reactreact为我们处理反应堆的启动和停止。它接受一个 callable 作为它唯一需要的参数,这个 callable 是它在运行的反应器中调用的;可调用函数本身必须返回一个Deferred,当它解析到一个值或Failure时,该函数会导致反应器停止。由于其twisted.internet.defer.inlineCallback s 装饰器的缘故,download函数返回了这样一个Deferred。因为react本身接受一个 callable 作为它的第一个参数,所以它也可以用作装饰器。我们可以这样写我们的例子:

..
from twisted.internet import defer, task
...

@task.react
@defer.inlineCallbacks
def main(reactor):
    ...

这实际上是用 Twisted 编写简短脚本的一种流行方式。今后,当我们使用react时,我们将把它用作装饰器。

针对 web 提要的 URL 运行这个treq示例程序可以检索提要的内容。我们可以修改我们的程序,使用 Python feedparser库来打印提要的摘要。首先,用pipfeedparser安装到您的虚拟环境中:

$ ./treq-experiment-env/bin/pip install feedparser

然后,将下面的程序保存到feedparser_experiment.py并根据 RSS URL 运行它:

$ ./treq-experiment-env/bin/python feedparser_experiment.py http://planet.twistedmatrix.com

from __future__ import print_function
from argparse import ArgumentParser
import feedparser
from twisted.internet import defer, task
from twisted.web import http
import treq

@task.react
@defer.inlineCallbacks
def download(reactor):
    parser = ArgumentParser()
    parser.add_argument("url")
    arguments = parser.parse_args()
    response = yield treq.get(arguments.url, reactor=reactor)
    if response.code != http.OK:
        reason = http.RESPONSES[response.code]
        raise RuntimeError("Failed:{}{}".format(response.code,
                                                reason))
    content = yield response.content()
    parsed = feedparser.parse(content)
    print(parsed['feed']['title'])
    print(parsed['feed']['description'])
    print("*** ENTRIES ***")
    for entry in parsed['entries']:
        print(entry['title'])

运行此命令应该会产生如下输出:

Planet Twisted
Planet Twisted - http://planet.twistedmatrix.com/
*** ENTRIES ***
Moshe Zadka: Exploration Driven Development
Hynek Schlawack: Python Application Deployment with Native Packages
Hynek Schlawack: Python Hashes and Equality
...

介绍克莱因

既然我们已经知道了如何使用treq来检索和解析提要,我们需要学习足够多的关于 Klein 的知识来在网站中呈现它们。

为了保持我们的实验有条理,为 Klein 创建一个新的虚拟环境,并用pip install Klein安装它。然后,运行以下示例:

import klein

application = klein.Klein()

@application.route('/')
def hello(request):
    return b'Hello!'

application.run("localhost",8080)

现在,在您最喜欢的网络浏览器中访问http://localhost:8080/。(如果已经有一个程序绑定到了另一个端口,您可能需要将8080改为另一个端口。)你会看到从我们程序的hello路由处理器返回的字符串Hello!

Klein 应用从一个Klein类的实例开始。通过使用Klein.route方法作为装饰器,可调用程序与路由相关联。route的第一个参数是 Werkzeug 风格的 URL 模式;可能的格式指令与 Werkzeug 的路由文档中的相匹配,可以在这里找到: http://werkzeug.readthedocs.io/en/latest/routing/ 。让我们修改我们的程序,使用这样一个指令从路径中提取一个整数:

import klein

application = klein.Klein()

@application.route('/<int:amount>')
def increment(request, amount):
    newAmount = amount + 1
    message = 'Hello! Your new amount is:{} '.format(newAmount)
    return message.encode('ascii')

application.run("localhost",8080)

运行这个程序并访问http://localhost:8080/1会得到一个类似图 3-1 的网页。

img/455189_1_En_3_Fig1_HTML.jpg

图 3-1

增加.png

URL 模式指定一个路径组件,Klein 提取该路径组件,将其转换为指定的 Python 类型,并作为位置参数传递给处理函数。金额参数是第一个路径元素,必须是整数;否则,请求将失败,并显示 404。Werkzeug 文档中提供了一份转换器的列表。

还要注意,处理程序不能返回 unicode 字符串;在 Python 3 上;这意味着本地字符串必须在从 Klein 路由的处理程序返回之前被编码成字节字符串。因此,在执行了字符串格式化之后,我们将变量message编码为ascii。在 Python 3.5 和更高版本中,我们可以使用字节字符串格式,但是在撰写本文时,Python 3.4 仍然被广泛使用。同样,这段代码在 Python 2 上隐式地将 message解码为ascii。当使用除了ascii编码之外的任何编码时,这种不幸的行为会导致一个奇怪的错误消息,但是在处理只包含 ASCII 并且必须在 Python 2 和 3 上都工作的原生字符串的 Twisted 代码中,这是一种常见的模式。

克莱因和德弗雷德

Klein 是一个 Twisted 的项目,自然对Deferreds有特殊的支持。返回Deferreds的处理函数会产生一个响应,等待延迟解析为一个值或Failure。我们可以通过修改我们的程序来模拟一个缓慢的网络操作来看到这一点,方法是返回一个在接收到请求后至少一秒钟触发的Deferred:

from twisted.internet import task
from twisted.internet import reactor
import klein

application = klein.Klein()

@application.route('/<int:amount>')
def slowIncrement(request, amount):
    newAmount = amount + 1
    message = 'Hello! Your new amount is:{} '.format(newAmount)
    return task.deferLater(reactor,1.0, str.encode, message, 'ascii')

application.run("localhost",8080)

正如所料,这个程序只在一秒钟后才响应http://localhost:8080/1。它通过使用接受一个twisted.internet.interfaces.IReactorTime提供者、一个延迟、一个函数以及延迟过后将应用于该函数的参数的twisted.internet.task.deferLater来实现这一点。注意,我们对函数和参数的选择利用了实例方法存储在它们的类中的事实,并且它们的第一个参数必须是它们所绑定的实例;因此,str.encode(message, 'ascii' ),其中message是一个str,相当于message.encode(' ascii' )。这是 Twisted 代码中出现的另一种模式。

最后一个例子演示了使用 decorators 作为注册路由的方式所固有的局限性:被修饰函数的参数必须完全由路由框架提供。这使得编写引用某个状态或依赖于某个现有对象的处理函数变得困难。在我们的例子中,我们的代码依赖于反应器来满足deferLater的 API,但是我们不能将反应器传递给我们的处理程序,因为只有 Klein 可以调用它。在解决这个问题的许多方法中,Klein 特别支持一种方法:特定于实例的 Klein 应用。我们将再次重写我们的slowIncrement例子来利用这个特性。

from twisted.internet import task
from twisted.internet import reactor
import klein

class SlowIncrementWebService(object):
    application = klein.Klein()
    def init (self, reactor):
        self._reactor = reactor
    @application.route('/<int:amount>')
    def slowIncrement(self, request, amount):
        newAmount = amount + 1
        message = 'Hello! Your new amount is:{} '.format(newAmount)
        return task.deferLater(self._reactor,1.0, str.encode, message, 'ascii')

webService = SlowIncrementWebService(reactor) webService.application.run("localhost",8080)

SlowIncrementWebService类有一个分配给它的application类级变量的Klein应用。我们可以用那个变量的route方法来修饰这个类的方法,就像我们用模块级Klein对象的route方法来修饰模块级slowIncrement函数一样。因为我们现在正在修饰实例方法,所以我们可以访问实例变量,比如reactor。这允许我们在不依赖模块级对象的情况下参数化我们的 web 应用。

对象本身通过实现描述符协议来定位它们的内部状态。webService.application返回一个特定于请求的Klein实例,该实例包含我们向SlowIncrementWebServiceapplication注册的所有路由及其处理程序。因此,Klein 保持了健壮的封装,并最小化了共享的可变状态。

电镀克莱因模板

在准备构建提要聚合器的简单版本之前,我们需要的最后一件事是一个 web 页面模板系统。我们可以使用 Jinja2,或者樱井真子,或者任何其他用于生成网页的 Python 模板系统,但是 Klein 自带了自己的模板工具,叫做 *Plating。*让我们修改SlowIncrementWebService示例,使用klein.Plating来生成可读性更好的响应:

from twisted.internet import task, reactor
from twisted.web.template import tags, slot
from klein import Klein, Plating

class SlowIncrementWebService(object):
    application = Klein()
    commonPage = Plating(
        tags=tags.html( tags.head(
            tags.title(slot("title")),
            tags.style("#amount { font-weight: bold; }"
                       "#message { font-style: italic; }")),
            tags.body(
                tags.div(slot(Plating.CONTENT)))))
    def __init__ (self, reactor):
        self._reactor = reactor

    @commonPage.routed(
        application.route('/<int:amount>'),
        tags.div(
            tags.span("Hello! Your new amount is: ", id="message"),
            tags.span(slot("newAmount"), id="amount")),
    )
    def slowIncrement(self, request, amount):
        slots = {
            "title":"Slow Increment",
            "newAmount": amount + 1,
    }
    return task.deferLater(self._reactor,1.0, lambda: slots)

webService=SlowIncrementWebService(reactor) webService.application.run("localhost",8080)

新的commonPage Plating对象代表了对我们的SlowIncrementWebService的根本改变。因为Plating是建立在 Twisted 自己古老的twisted.web.template系统之上的,所以我们必须在继续之前了解它的基本原理。

twisted.templatestwisted.web.template.Tagtwisted.web.template.slot实例构成。Tags表示 html、bodydiv等 HTML 标签。它们是通过访问它们的名称作为标签工厂实例上的方法来创建的,标签工厂实例可作为twisted.web.template.tags获得。例如,这叫:

tags.div()

表示一个div标签,将被呈现如下:

<div></div>

这些实例方法的位置参数代表了它们标签的子标签,所以我们可以通过嵌套它们的方法调用向我们的div添加一个span:

tags.div(tags.span("A span."))

这个简单的标记树将呈现如下:

<div><span>A span.</span></div>

请注意,标记的文本内容也表示为子标记。

这些方法的关键字参数表示它们的属性,因此我们可以在 div 树中包含一个图像:

tags.div(tags.img(src="picture.png"), tags.span("A span."))

渲染时,这棵树看起来像这样:

<div><img src="picture.png"><span>A span.</span></div>

twisted.web.template保留一个关键字参数供内部使用:render。这是一个命名特殊的呈现方法的字符串,该方法将用于将标签呈现为 HTML。一会儿我们将看到一个特定于 Klein 的渲染方法的例子。

有时将标签的属性写在其子标签之前更容易阅读,但是关键字参数必须总是在位置参数之前。为了在不违反 Python 语法的情况下提高可读性,tags可以和它们的子节点一起被称为。我们可以重写我们的标记树,这样就可以添加它的子树了:

tags.div()(tags.img(src="picture.png"), tags.span("A span."))

是占位符,在模板渲染过程中可以用名字来填充,我们将在后面看到。它们允许我们参数化标签内容和属性。给定这个标记树,然后:

tags.div(tags.img(src=slot('imageURL')), tags.span(slot("spanText")))

我们可以提供“anotherimage.png”作为 imageURL 槽的值,并提供“Different text”对于spanText槽,导致如下结果:

<div><img src="anotherimage.png"><span>Different text.</span></div>

当用包含 HTML 文字的字符串填充槽时,twisted.webtemplate转义它们以避免将用户数据误解为模板指令。这反过来减少了常见的 web 应用错误,如跨站点脚本(XSS)攻击。然而,槽可以用其他tags填充,从而实现复杂的模板重用模式。这些规则意味着这棵树:

tags.div(slot("child")).fillSlots(child="<div>")

渲染到:

<div>&lt;div&gt;</div>

而这棵树:

tags.div(slot("child")).fillSlots(child=tags.div())

渲染到:

<div><div></div></div>

饲料聚集的初稿

既然我们已经熟悉了twisted.web.template的基本原理,我们可以回到我们的示例应用的klein.Plating对象:

commonPage = Plating(
    tags=tags.html(
        tags.head(
            tags.title(slot("title")),
            tags.style("#amount { font-weight: bold; }"
                       "#message { font-style: italic; }")),
            tags.body(
                tags.div(slot(Plating.CONTENT)))))

作为tags参数传递的标记树描述了这个Plating实例将呈现的所有 HTML 页面的结构。它包括两个插槽:titlePlating.CONTENTtitle插槽和其他插槽一样;每当我们想要呈现一个属于这个标签树的页面时,我们都必须为这个槽提供一个值。然而,Plating.CONTENT槽代表标签树中的位置,在这个位置上Plating将插入特定于页面的内容。我们的示例应用只呈现了一个来自commonPage的页面:

@commonPage.routed(
    application.route('/<int:amount>'),
    tags.div(
        tags.span("Hello!    Your new amount is: ", id="message"),
        tags.span(slot("newAmount"), id="amount")),
)
def slowIncrement(self, request, amount):
    slots={
        "title":"Slow Increment",
        "newAmount": amount+1,
    }
    return task.deferLater(self._reactor,1.0, lambda: slots)

我们通过用基本页面的routed装饰器包装 Klein route来表示派生页面。routed装饰器的第二个位置参数表示将填充基页的Klein.CONTENT槽的标记树。这个slowIncrement页面包装了我们之前定义的相同路由,并指定一个标签树作为其内容,该标签树包括一个用于递增量的槽。

在 Klein 中,槽是通过返回一个字典来填充的,该字典将它们的名称映射到来自页面处理程序的值,或者返回一个解析为 1 的Deferred。这个处理程序通过使用deferLater来延迟返回插槽字典,直到一秒钟过去。

结果是一个更有个性的网页,如图 3-2 所示。

Klein 的电镀提供了一个独特的特性:您可以通过指定json查询参数来请求将 slots 字典作为序列化的 JSON 返回。在图 3-3 中,我们可以看到当提供这个参数时,我们的“慢增量”页面是什么样子。

这使得Plating用户可以编写处理程序来呈现 HTML 和 JSON,作为简单的页面,或者为复杂的单页面应用(SPA)或本地移动应用提供后端。我们的提要聚合器的 HTML 前端不会成为一个 SPA,因为这是一本关于 Twisted 而不是 JavaScript 的书,但是我们将在开发应用时继续支持和探索 JSON 序列化。

我们现在可以编写一个简单的提要聚合器来探索它的设计。我们将编写一个SimpleFeedAggregation类,它接受提要 URL,并在用户访问根 URL 时使用treq来检索它们。我们将把每个提要呈现为一个表格,表格的标题链接到提要,表格的行链接到每个提要条目。

首先将 feedparser 和 treq 安装到 Klein 虚拟环境中,就像在 treq 虚拟环境中一样。

import feedparser

img/455189_1_En_3_Fig3_HTML.jpg

图 3-3

作为 JSON 递增

img/455189_1_En_3_Fig2_HTML.jpg

图 3-2

风格的增加

from twisted.internet import defer, reactor
from twisted.web.template import tags, slot
from twisted.web import http
from klein import Klein, Plating
import treq

class SimpleFeedAggregation(object):
    application = Klein()
    commonPage = Plating(
        tags=tags.html(
            tags.head(
                tags.title("Feed Aggregator 1.0")),
            tags.body(
                tags.div(slot(Plating.CONTENT)))))

    def __init__ (self, reactor, feedURLs):
        self._reactor = reactor
        self._feedURLs = feedURLs

    @defer.inlineCallbacks

    def retrieveFeed(self, url):
        response = yield treq.get(url, timeout=30.0, reactor=self._reactor)
        if response.code != http.OK:
            reason = http.RESPONSES[response.code]
            raise RuntimeError("Failed:{}{}".format(response.code,
                                                    reason))
        content = yield response.content()
        defer.returnValue(feedparser.parse(content))

@commonPage.routed(
    application.route('/'),
    tags.div(render="feeds:list")(slot("item")))
def feeds(self, request):

    def renderFeed(feed):
        feedTitle = feed[u"feed"][u"title"]
        feedLink = feed[u"feed"][u"link"]
        return tags.table(
            tags.tr(tags.th(tags.a(feedTitle, href=feedLink)))
        )([
            tags.tr(tags.td(tags.a(entry[u'title'], href=entry[u'link'])))
            for entry in feed[u'entries']
        ])

    return {
            u"feeds": [
                self.retrieveFeed(url).addCallback(renderFeed)
                for url in self._feedURLs

            ]
        }

webService = SimpleFeedAggregation(reactor,
                              ["http://feeds.bbci.co.uk/news/technology/rss.xml",
                               "http://planet.twistedmatrix.com/rss20.xml"])
webService.application.run("localhost",8080)

retrieveFeed方法类似于我们第一个treq程序的下载函数,而feeds方法以一个电镀装饰器开始,类似于我们的 slowIncrement Klein 应用。然而,在提要的情况下,特定于路由的模板由一个带有特殊的呈现方法div标签组成。Klein 将feeds:list解释为为列表中的每个项目复制div标签并将其放入项目槽的方向。例如,如果我们的feeds方法返回下面的字典:

{"feeds": ["first","second","third"]}

Klein 将为feeds路线呈现以下 HTML:

<div>first</div><div>second</div>third</div>

我们的feeds方法不仅返回一个槽字典,它的feeds键返回一个列表,而且还返回一个包含*延迟的列表。*这利用了twisted.web.template's的独特能力来呈现Deferred的结果:当遇到一个结果时,呈现暂停,直到它解析为一个值,然后被呈现,或者失败发生。

我们的feeds列表中的每一个Deferred都源于一个retrieveURL调用,这个调用通过treqfeedparser为一个 URL 创建一个解析的提要。renderFeed回调将经过解析的提要转换成标签树,标签树将提要呈现为一个链接表。这利用了twisted.web.template在插槽中嵌入tag元素的能力。

在浏览器中访问该页面时,首先呈现的是 BBC 提要,然后是更大更慢的 Twisted 矩阵提要,如图 3-4 和 3-5 所示。

我们的SimpleFeedAggregation类成功地检索并呈现了提要。它的基本设计反映了服务中的数据流:给定一个可迭代的提要 URL,通过对每个请求应用treq.get来同时检索它们。数据流通常会影响 Twisted 程序的设计。

然而,我们的执行不力:

  1. 它有虫子。用户实际上不能请求 JSON,因为代表每个提要的标记树不是 JSON 可序列化的。

img/455189_1_En_3_Fig5_HTML.jpg

图 3-5

一个包含 BBC 和 Twisted Matrix 的完整页面

img/455189_1_En_3_Fig4_HTML.jpg

图 3-4

只有英国广播公司信息的不完整页面

  1. 它的错误报告很差。虽然由SimpleFeedAggregation.retrieveFeed引发的RuntimeError是信息性的,但它是以不可操作的方式呈现给用户的,尤其是那些请求了 JSON 的用户。

在我们解决这些和其他问题之前,我们需要一个测试套件。我们将通过使用测试驱动的开发来指导我们,确保我们的提要聚合器的下一个实现符合我们的期望。

用 Klein 和treq进行测试驱动开发

编写测试需要时间和精力。测试驱动开发通过将测试作为开发过程的一部分来简化这一点。我们从某个代码单元应该实现的接口开始。接下来,我们编写一个空的实现,比如一个具有空方法体的类,然后进行测试,在给定已知输入的情况下验证该实现的期望输出。运行这些测试一开始应该会失败,开发变成了填充实现的过程,这样测试才能通过。结果,我们在早期发现实现的一部分是否与其他部分冲突,并且最终我们有一个完整的测试套件。

编写测试需要时间,所以从最有价值的接口开始很重要。对于一个 web 应用,这是客户机将使用的 HTTP 接口,所以我们的第一次测试将涉及对我们的FeedAggregation Klein 应用使用一个内存中的 HTTP 客户机。

在可安装项目上运行测试

测试驱动开发需要重复运行项目的测试,因此在我们开始编写任何测试之前,我们需要做好准备,以便 Twisted 的测试运行器trial能够找到它们。

trial命令接受包含或表示可运行测试用例的完全限定路径名作为它唯一的强制参数。trial的设计遵循与 Python 的unittest相同的受 xUnit 影响的模式,所以它的测试用例是twisted.trial.unittest.TestCasetwisted.trial.unittest.SynchronousTestCase的子类。这些名称本身是完全限定的路径名,或 FQPNs。从最顶层的包开始,它们指定了向下到特定函数、类或方法的属性访问路径。例如,下面的命令行运行位于 Twisted 自己的异步消息协议(AMP)测试套件中的ParsingTests测试用例的test_sillyEmptyThing方法:

trial twisted.test.test_amp.ParsingTests.test_sillyEmptyThing

给定一个更短因而更通用的 FQPN,trial递归进入模块和包树寻找测试,就像python -m unittest discover一样。例如,你可以用trial twisted运行 Twisted 自己的所有测试。

因为测试是用 FQPNs 指定的,所以它们必须是可导入的。trial超越了这一点,要求它们也驻留在 Python 运行时的模块搜索路径下。这符合 Twisted 的惯例,即在特殊的test子包下的库代码中包含测试。

Python 允许程序员以几种方式影响它的搜索路径。设置PYTHONPATH环境变量或者直接操作sys.path都允许它从特定于项目的位置导入代码。然而,告诉 Python 可以找到代码的新位置是不可靠的,因为它依赖于定制的配置和特定的运行时入口点。更好的方法是依靠虚拟环境将 Python 的搜索路径定位到特定于项目的目录树,然后将项目及其依赖项安装到其中。通过利用相同的工具和模式,以管理其依赖关系的相同方式管理我们自己的应用给了我们更大的一致性。

对虚拟环境和 Python 打包的全面讨论超出了本书的范围。相反,我们将概述一个最小的项目布局和配置,展示如何将我们的项目链接到一个虚拟环境中,然后为一个空的测试套件提供一个示例trial调用。

该项目的目录结构如下:

img/455189_1_En_3_Fig6_HTML.jpg

图 3-6

提要聚合项目目录结构

也就是说,在作为当前工作目录的某个目录下,存在一个setup.pysrc/目录。src/目录又包含顶层feed_aggregation包和一个_service子模块。feed_aggregation.test.test_service将存放_service中代码的测试用例。

将包含一个 Twisted 的应用插件,这将使运行我们的 Klein 应用更容易。

我们将把我们的FeedAggregation类放在feed_aggregation._service中:

class FeedAggregation(object):
    pass

这是一个私有模块,所以我们将通过在feed_aggregation/__init__.py中导出它来公开访问我们的类:

from feed_aggregation._service import FeedAggregation
__all__ =["FeedAggregation"]

将实现放在私有子模块中,然后在顶层包的 __ init__ .py中公开它,这是 Twisted 代码中的常见模式。它确保文档工具、linters 和 ide 将公共 API 的来源视为公共包,从而限制了私有实现细节的暴露。

我们将让feedaggregation/test/ __init__ .py为空,但将SynchronousTestCase的一个小子类放入feed_aggregation/test/test_service.py中,这样trial在我们完成设置后就可以运行了:

from twisted.trial.unittest import SynchronousTestCase

class FeedAggregationTests(SynchronousTestCase):
    def test_nothing(self):
        pass

twisted/plugins/feed_aggregation_plugin.py也为空,我们准备考虑setup.py:

from setuptools import setup, find_packages

setup(
    name="feed_aggregation",
    install_requires=["feedparser", "Klein", "Twisted", "treq"],
    package_dir={"": "src"},
    packages=find_packages("src") + ["twisted.plugins"],
)

这将我们的项目名称声明为feed_aggregation,其依赖项为feedparser(用于解析提要)、Klein(用于我们的 web 应用)、Twisted(用于trial)和treq(用于检索提要)。它还指示 setuptools 在src下查找包,并在twisted/plugins下包含feed_aggregation_plugin.py

假设我们为我们的项目激活了一个新的虚拟环境,并且我们在项目根中,我们现在可以运行这个:

pip install -e .

-e标志指示pip install执行我们项目的可编辑安装,这将把一个指针从虚拟环境放回我们项目根目录。因此,一旦我们保存编辑内容,它们就会出现在虚拟环境中。

最后,trial feed_aggregation应该显示以下内容:

feed_aggregation.test.test_service
  FeedAggregationTests
    test_nothing ...[OK]

---------------------------------------------------------------------------
Ran 1 tests in 0.001s

PASSED (successes=1)

证明我们实际上已经通过我们的虚拟环境对我们的项目进行了测试。

用 StubTreq 测试 Klein

现在我们可以运行测试,我们可以用测试某些东西的方法来代替FeedAggregationTests.test_nothing。如上所述,这应该是我们的 Klein 应用将呈现给客户端的 HTTP 接口。

测试 HTTP 服务的一种方法是运行一个 web 服务器,就像运行一个实时服务一样,可能绑定到一个可预测的端口上的localhost,并使用一个 HTTP 客户端库来连接它。这可能会很慢,更糟糕的是,端口是一种操作系统资源,它的稀缺会导致获取它们的测试不稳定。

幸运的是,Twisted 的传输和协议允许我们在测试中运行内存中的 HTTP 客户端和服务器对。特别是,treqtreq.testing.StubTreq中提供了一个强大的测试工具。StubTreq的实例暴露了与treq模块相同的接口,因此通过依赖注入获得treq的代码可以在测试中使用这个存根实现。由treq项目来验证StubTreq是否符合与treq模块相同的 API 我们不需要在测试中这样做。

StubTreq将一个twisted.web.resource.Resource作为它的第一个参数,它的响应决定了各种 treq 调用的结果。因为 Klein 实例公开了一个生成twisted.web.resource.Resourceresource()方法,所以我们可以将一个StubTreq绑定到我们的 web 应用,以获得一个适合我们测试的内存中 HTTP 客户端。

让我们用一个使用StubTreq请求我们服务的根 URL 的方法来替换test_nothing:

# src/feed_aggregation/tests/test_service.py

from twisted.trial.unittest import SynchronousTestCase
from twisted.internet import defer
from treq.testing import StubTreq
from .. import FeedAggregation

class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        self.client = StubTreq(FeedAggregation().resource())
    @defer.inlineCallbacks
    def test_requestRoot(self):
        response = yield self.client.get(u'http://test.invalid/')
        self.assertEqual(response.code,200)

setUp方法为我们的FeedAggregation的 Klein 应用创建一个绑定到twisted.web.resource.ResourceStubTreq实例。test_requestRoot使用这个客户端向 Klein 资源发出一个GET请求,验证它收到了一个成功的响应。

注意,只有传递给self.client.get的 URL 的路径部分对我们的测试有影响。treq 和 StubTreq 只能对一个完整的 web URL 发出带有 scheme 和 netloc 的请求,所以我们使用一个.invalid域来满足这个需求。那个。invalid顶级域名被定义为永远不会解析到实际的互联网地址,这是我们测试的最佳选择。

trial feed_aggregation运行这个新版本的FeedAggregationTests会因为一个AttributeError而失败,因为我们的FeedAggregation类的实例没有一个resource方法。然而,添加正确的实现不会使测试通过;我们还需要构建一个 Klein 应用来响应对/的请求。我们将修改_service模块来满足这两个需求。

# src/feed_aggregation/_service.py

from klein import Klein

class FeedAggregation(object):
    _app=Klein()
    def resource(self):
        return self._app.resource()
    @_app.route("/")
    def root(self, request):
        return b""

新的resource实例方法将其调用委托给与该类相关联的 Klein 应用。这是 Demeter 的法则的一个例子,这是软件开发中的一个原则,反对在实例属性上调用方法;相反,像FeedAggregation.resource这样的委托方法包装了这些属性的方法,所以使用FeedAggregation的代码仍然不知道它的内部实现。我们将我们的 Klein 应用命名为_app,以表明它是FeedAggregation内部私有 API 的一部分。

*根方法充当根 URL path /的普通处理程序,并与FeedAggregation.resource一起使FeedAggregation.test_requestRoot通过。

我们现在已经完成了一个测试驱动的开发周期。我们从编写一个最小的失败测试开始,然后用最少的应用代码让它通过。

让我们跳过这一步,用一个更完整的测试套件来替换FeedAggregationTests,这个测试套件可以测试 HTML 和 JSON feed 渲染。

# src/feed_aggregation/test/test_service.py

import json
from lxml import html
from twisted.internet import defer
from twisted.trial.unittest import SynchronousTestCase
from treq.testing import StubTreq
from .. import FeedAggregation

class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        self.client = StubTreq(FeedAggregation().resource())
    @defer.inlineCallbacks
    def get(self, url):
        response = yield self.client.get(url)
        self.assertEqual(response.code,200)
        content = yield response.content()
        defer.returnValue(content)
    def test_renderHTML(self):
        content = self.successResultOf(self.get(u"http://test.invalid/"))
        parsed = html.fromstring(content)
        self.assertEqual(parsed.xpath(u'/html/body/div/table/tr/th/a/text()'),
                        [u"First feed",u"Second feed"])
        self.assertEqual(parsed.xpath('/html/body/div/table/tr/th/a/@href'),
                        [u"http://feed-1/",u"http://feed-2/"])
        self.assertEqual(parsed.xpath('/html/body/div/table/tr/td/a/text()'),
                        [u"First item",u"Second item"])
        self.assertEqual(parsed.xpath('/html/body/div/table/tr/td/a/@href'),
                        [u"#first",u"#second"])
    def test_renderJSON(self):
        content = self.successResultOf(self.get(u"http://test.invalid/?json=true"))
        parsed = json.loads(content)
        self.assertEqual(
            parsed,
            {u"feeds": [{u"title": u"First feed", u"link": u"http://feed-1/",
             u"items": [{u"title": u"First item",u"link": u"#first"}]},
            {u"title": u"Second feed", u"link": u"http://feed-2/",
             u"items": [{u"title": u"Second item", u"link": u"#second"}]}]})

在这个测试案例中有很多事情要做。有两个测试,test_renderHTMLtest_renderJSON,它们验证我们期望我们的FeedAggregation web 服务返回的 HTML 和 JSON 的结构和内容。test_requestRoot已经被一个get方法所取代,这个方法可以被test_renderHTMLtest_renderJSON用来为我们的 Klein 应用检索一个特定的 URL。test_renderHTMLtest_renderJSON都使用SynchronousTestCase.successResultOf来断言get返回的Deferred已经触发并提取了值。

test_renderHTML使用lxml库( https://lxml.de/ )来解析和检查我们的 Klein 应用返回的 HTML。因此,我们必须在我们的setup.py中将lxml添加到install_requires列表中。请注意,您可以通过再次运行pip install -e .将虚拟环境与项目的依赖项同步。

XPaths 定位并提取 DOM 中特定元素的内容和属性。隐含的表结构与我们在原型中开发的相匹配:提要驻留在table中,其标题链接到提要的主页,其行链接到每个提要的项目。

test_renderJSON请求呈现为 JSON 的提要,将其解析成一个字典,然后断言它等于预期的输出。

这些新测试自然会失败,因为现有的FeedAggregation仅仅返回一个空的响应体。让我们通过用最少的必要实现替换FeedAggregation来让它们通过。

# src/feed_aggregation/_service.py

from klein import Klein, Plating
from twisted.web.template import tags as t, slot

class FeedAggregation(object):
    _app = Klein()
    _plating = Plating(
        tags=t.html(
            t.head(t.title("Feed Aggregator 2.0")),
            t.body(slot(Plating.CONTENT))))
    def resource(self):
        return self._app.resource()
    @_plating.routed(
        _app.route("/"),
        t.div(render="feeds:list")(slot("item")),
    )
    def root(self, request):
        return {u"feeds": [
    t.table(t.tr(t.th(t.a(href=u"http://feed-1/")(u"First feed"))),
            t.tr(t.td(t.a(href=u"#first")(u"First item")))),
    t.table(t.tr(t.th(t.a(href=u"http://feed-2/")(u"Second feed"))),
            t.tr(t.td(t.a(href=u"#second")(u"Second item"))))

]}

因为我们还没有编写提要检索的测试,所以这个实现还不能检索 RSS 提要。相反,它通过返回与我们的断言相匹配的硬编码数据来满足我们的测试。除此之外,它类似于我们的原型:一个root方法处理根 URL 路径,该路径使用 Klein 的:list渲染器将一系列twisted.web.template.tag转换成 HTML。

这个版本的FeedAggregation通过了test_renderHTML但是在test_renderJSON上失败:

(feed_aggregation) $ trial feed_aggregation
feed_aggregation.test.test_service
  FeedAggregationTests
    test_renderHTML ...                                           [OK]
    test_renderJSON ...                                        [ERROR]
                                                               [ERROR]

======================================================================= [ERROR]
Traceback (most recent call last):
...
exceptions.TypeError: Tag('table', ...) not JSON serializable

feed_aggregation.test.test_service.FeedAggregationTests.test_renderJSON
======================================================================= [ERROR]
Traceback (most recent call last):
...
twisted.trial.unittest.FailTest: 500 != 200

feed_aggregation.test.test_service.FeedAggregationTests.test_renderJSON
-----------------------------------------------------------------------
Ran 2 tests in 0.029s

FAILED (failures=1, errors=1, successes=1)

第二个错误对应于FeedAggregationTests.get中的self.assertEqual(response.code, 200),而第一个错误指出了真正的问题:Klein 无法序列化FeedAggregation.root返回给 JSON 的tag

最简单的解决方案是检测请求何时应该序列化为 JSON,并返回一个可序列化的字典。当前的设计需要复制必要的数据来满足测试,所以在我们解决 bug 的同时,我们还要添加存储提要数据的容器类,以及存储提要来源并控制其表示的顶级类。这些将允许我们定义一次数据,但同时呈现给 HTML 和 JSON。事实上,我们可以安排FeedAggregation在其初始化器中接受顶级 feed 容器类的实例,这样测试就可以使用它们自己的 fixture 数据。让我们按照这种方法重写_service.py。我们将使用 Hynek Schlawack 的attrs ( https://attrs.readthedocs.io )库来保持代码简洁明了;一定要把它加到你的setup.pyinstall_requires里。

# src/feed_aggregation/_service.py

import attr
from klein import Klein, Plating
from twisted.web.template import tags as t, slot

@attr.s(frozen=True)
class Channel(object):
    title = attr.ib()
    link = attr.ib()
    items = attr.ib()

@attr.s(frozen=True)
class Item(object):
    title = attr.ib()
    link = attr.ib()

@attr.s(frozen=True)

class Feed(object):
    _source = attr.ib()
    _channel = attr.ib()

    def asJSON(self):
        return attr.asdict(self._channel)

    def asHTML(self):
        header = t.th(t.a(href=self._channel.link)
                    (self._channel.title))
        return t.table(t.tr(header))(
                [t.tr(t.td(t.a(href=item.link)(item.title)))
                 for item in self._channel.items])

@attr.s
class FeedAggregation(object):
    _feeds = attr.ib()
    _app = Klein()
    _plating = Plating(
        tags=t.html(
        t.head(t.title("Feed Aggregator 2.0")),
        t.body(slot(Plating.CONTENT))))
def resource(self):
    return self._app.resource()
@_plating.routed(
    _app.route("/"),t.div(render="feeds:list")(slot("item")),
)
def root(self, request):
    jsonRequested = request.args.get(b"json")
    def convert(feed):
        return feed.asJSON() if jsonRequested else feed.asHTML()
    return {"feeds": [convert(feed) for feed in self._feeds]}

使用attrs可以很容易地定义像ChannelItem这样的容器类。在其最基本的操作中,attr.s类装饰器生成一个init方法,该方法设置对应于类的attr.ib变量的实例变量。

attrs也使得通过 decorator 的frozen参数定义实例为不可变的类变得容易。不变性很适合我们的容器类,因为它们表示外部数据;在我们收到它之后改变它肯定会是一个错误。attrslxml,必须添加到setup.py里面的install_requires列表中。

Feed类包装了提要的源 URL 和表示其内容的Channel实例,并公开了两种表示方法。asJSON使用attrs.asdict递归地将 channel 实例转换成 JSON 可序列化的字典,而asHTML返回一个twisted.web.template.tags树,由 Klein 的电镀系统渲染。

FeedAggregation.root现在检查请求的json查询参数,可以在args字典中找到,以确定响应是否应该呈现为 JSON 或 HTML,并适当地调用asJSONasHTML

最后,FeedAggregation现在本身是一个attrs修饰类,它的初始化器接受一个要呈现的Feed对象的 iterable。

因此,FeedAggregationTests.setUp必须被重构,以将Feed对象的 iterable 传递给它的FeedAggregation实例:

# src/feed_aggregation/test/test_service.py

...
from .._service import Feed, Channel, Item

FEEDS = (
    Feed("http://feed-1.invalid/rss.xml",
         Channel(title="First feed", link="http://feed-1/",
                 items=(Item(title="First item", link="#first"),))),
    Feed("http://feed-2.invald/rss.xml",
         Channel(title="Second feed", link="http://feed-2/",
                 items=(Item(title="Second item", link="#second"),))),
)

class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        self.client = StubTreq(FeedAggregation(FEEDS).resource())

...

这个最新版本有它的好处:最明显的是,test_renderJSON现在通过了,但是另外 fixture 的数据现在和测试驻留在同一个地方,这样就更容易和它们的断言保持同步。

它也有不利的一面。如果没有检索 RSS 提要的能力,FeedAggregation不仅作为提要聚合服务毫无用处,而且测试现在导入并依赖于我们的容器类。像这样依赖于内部实现细节的测试是脆弱的,难以重构。

我们将通过编写提要检索逻辑来解决这两个缺点。

用 Klein 测试 treq

在前一节中,我们使用了StubTreq来测试我们的 Klein 应用。颠倒关系允许我们简洁地测试treq代码。

同样,我们将从编写测试开始。我们将把它们添加到test_service模块中,新的导入显示在顶部,我们的新测试用例显示在底部。

# src/feed_aggregation/test/test_service.py

import attr
...
from hyperlink import URL
from klein import Klein
from lxml.builder import E
from lxml.etree import tostring
...
from .. import FeedRetrieval

@attr.s
class StubFeed(object):
    _feeds = attr.ib()
    _app = Klein()
    def resource(self):
        return self._app.resource()

    @_app.route("/rss.xml")
    def returnXML(self, request):
        host = request.getHeader(b    'host')
        try:
            return self._feeds[host]
        except KeyError:
            request.setResponseCode(404)
            return b'Unknown host: ' +host
def makeXML(feed):
    channel = feed._channel
    return tostring(
    E.rss(E.channel(E.title(channel.title), E.link(channel.link),
                    *[E.item(E.title(item.title), E.link(item.link))
                      for item in channel.items],
          version = u"2.0")))

class FeedRetrievalTests(SynchronousTestCase):
    def setUp(self):
        service = StubFeed(
            {URL.from_text(feed._source).host.encode('ascii'): makeXML(feed)
             for feed in FEEDS})
        treq = StubTreq(service.resource())
        self.retriever = FeedRetrieval(treq=treq)
    def test_retrieve(self):
        for feed in FEEDS:
            parsed = self.successResultOf(
                self.retriever.retrieve(feed._source))
            self.assertEqual(parsed, feed)

与之前的FeedAggregationTests一样,FeedRetrievalTests类依赖于一些新概念。StubFeed是一个 Klein 应用,其/rss.xml route 返回一个特定于请求主机的 XML 文档。这允许它为 http://feed-1.invalidhttp://feed-2.invalid 返回不同的响应。作为预防措施,对未知主机的请求会导致信息性的 404“未找到”响应。

makeXML函数将一个Feed及其关联的Item转换成一个符合 RSS 2.0 的 XML 文档。我们使用lxml.builderE标签工厂,其 API 类似于twisted.web.template.tags,作为 XML 模板系统,并用lxml.etree.tostring将其标签树序列化为字节(尽管其名称如此,它确实在 Python 3 上返回字节)。

FeedRetrievalTests.setUp fixture 方法创建一个Feeds列表,并将它们传递给一个StubFeed实例,然后将它们与一个StubTreq实例相关联。这又被传递给一个FeedRetrieval实例,该实例将包含我们的提要检索代码。在treq实现上参数化这个类是一个依赖注入的例子,它简化了编写测试的过程。

注意,我们通过使用hyperlink.URL从其link元素中的 URL 派生出每个提要的主机。超链接( https://hyperlink.readthedocs.io ) URL是不可变的对象,表示解析后的 URL。超链接库是从 Twisted 自己的twisted.python.url模块中抽象出来的,提供了原始 API 的超集。因此,Twisted 现在依赖于它,所以它可以隐式地用于任何依赖 Twisted 的项目。然而,任何依赖的最佳实践是使其显式,所以我们必须将hyperlink包添加到我们的setup.pyinstall_requires列表中。我们的setup.py现在应该是这样的:

# setup.py

from setuptools import setup, find_packages

setup(
    name="feed_aggregation",
    install_requires=["attrs","feedparser","hyperlink","Klein",
                      "lxml","Twisted","treq"],
    package_dir={"":"src"},
    packages=find_packages("src")+["twisted.plugins"],
)

(记得我们我们在上面加了attrs和 lxml。)

我们的FeedAggregationTests测试用例中的一个测试test_retrieve断言FeedRetrieval.retrieve将从其_source URL 检索到的提要解析为与其 XML 表示匹配的Feed对象。

现在我们已经有了一个提要检索器的测试,我们可以实现一个。首先,我们将把FeedRetrieval添加到src/feed_aggregation/__init__.py中,这样就可以在不与私有 API 交互的情况下导入它:

# src/feed_aggregation/ init .py

from ._service import FeedAggregation, FeedRetrieval

__all__ = ["FeedAggregation","FeedRetrieval"]

现在,我们可以实现通过测试所需的最少代码:

# src/feed_aggregation/_service.py

...
import treq
import feedparser

@attr.s
class FeedRetrieval(object):
    _treq = attr.ib()
    def retrieve(self, url):
        feedDeferred = self._treq.get(url)
        feedDeferred.addCallback(treq.content)
        feedDeferred.addCallback(feedparser.parse)
    def toFeed(parsed):
        feed = parsed[u'feed']
        entries = parsed[u'entries']
        channel = Channel(feed[u'title'], feed[u'link'],
                          tuple(Item(e[u'title'], e[u'link'])
                              for e in entries))
        return Feed(url, channel)

        feedDeferred.addCallback(toFeed)
        return feedDeferred

正如所料,FeedRetrieval通过attr.s类装饰器和一个_treq attr.ib接受一个treq实现作为它的唯一参数。它的retrieve方法遵循与我们的探索性程序相同的模式:首先,它使用treq检索提供的 URL 并收集其主体,然后使用feedparser将收集的 XML 解析到 Python 字典中。

接下来,toFeed提取提要的标题、链接及其条目的标题和链接,然后将它们组装成一个ChannelItem和一个Feed

这个版本的FeedRetrieval通过了我们的测试,但是它缺少错误处理。如果一个提要已经被删除或者返回的 XML 无效怎么办?照目前的情况来看,FeedRetrieval.retrieve返回的Deferred将会异常失败,这将是FeedAggregation的问题。

网站和 JSON 服务都不应该显示回溯。同时,应该记录任何回溯以帮助调试。幸运的是,Twisted 有一个复杂的日志系统,我们可以用它来跟踪应用的行为。

twisted.logger记录

Twisted 为许多版本提供了自己的日志系统。从 Twisted 15.2.0 开始,twisted.logger已经成为 Twisted 程序中记录事件的首选方法。

像标准库的logging模块一样,应用通过调用一个twisted.logger.Logger实例上的适当方法,在不同的级别发出日志消息。下面的代码在info级别发出一条消息。

from twisted.logger import Logger
Logger().info("A message with{key}", key="value")

logging一样,Logger.info这样的发射方法接受一个格式字符串和值进行插值;与logging不同,这是一个新样式的格式化字符串,它是在底层日志事件中发送的。也不同于 Python 的标准logging系统,twisted.logger.Logger没有等级,而是通过观察者来路由它们的消息。格式字符串被保留的事实启用了twisted.logger最强大的特性之一:它可以以传统的格式发出日志消息供人们使用,并且可以将它们作为 JSON 序列化的对象发出。后者允许在像 Kibana 这样的系统中进行复杂的过滤和收集。当我们为提要聚合应用编写 Twisted 应用插件时,我们将看到如何在这些格式之间切换。

我们还使用描述符协议来捕获相关类的信息,所以我们将为我们的FeedRetrieval类创建一个Logger。然后,我们将安排在请求提要之前以及在成功解析或者因异常而失败时发出消息。然而,在我们这样做之前,我们必须决定当异常发生时FeedRetrieval.retrieveDeferred应该解决什么问题。它不能是一个Feed实例,因为没有任何 XML 可以解析到一个Channel实例中;但是FeedAggregation期望一个提供asJSONasHTML方法的对象,它们的唯一实现存在于Feed上。

我们可以用多态来解决这个问题。我们可以定义一个新的类FailedFeed,它表示FeedRetrieval检索提要失败。它将通过实现自己的asJSONasHTML方法来满足与Feed相同的接口,以适当的格式呈现错误。

像往常一样,我们将从编写测试开始。FeedRetrieval.retrieve可能遇到的异常情况可以分为两类:状态代码不是 200 的响应,以及任何其他异常。我们将用一个定制的异常类型ResponseNotOK对第一个进行建模,retrieve 将在内部引发并处理这个异常,我们可以通过从一个StubFeed不知道的主机请求一个提要来请求这个异常。后者可以通过向StubFeed提供一个返回空字符串的主机来请求,feedparser将无法解析空字符串。让我们给我们的FeedRetrievalTests类添加一些测试。

# src/feed_aggregation/test/test_service.py

from .. import FeedRetrieval
from .._service import Feed, Channel, Item, ResponseNotOK
from xml.sax import SAXParseException

...

class FeedRetrievalTests(SynchronousTestCase):
    ...
    def assertTag(self, tag, name, attributes, text):
        self.assertEqual(tag.tagName, name)
        self.assertEqual(tag.attributes, attributes)
        self.assertEqual(tag.children, [text])
    def test_responseNotOK(self):
        noFeed = StubFeed({})
        retriever = FeedRetrieval(StubTreq(noFeed.resource()))
        failedFeed = self.successResultOf(
            retriever.retrieve("http://missing.invalid/rss.xml"))
        self.assertEqual(
            failedFeed.asJSON(),
            {"error":"Failed to load http://missing.invalid/rss.xml: 404"}
        )
        self.assertTag(failedFeed.asHTML(),
            "a", {"href":"http://missing.invalid/rss.xml"},
            "Failed to load feed: 404")
    def test_unexpectedFailure(self):
        empty = StubFeed({b"empty.invalid": b""})
        retriever = FeedRetrieval(StubTreq(empty.resource()))
        failedFeed = self.successResultOf(
             retriever.retrieve("http://empty.invalid/rss.xml"))
        msg = "SAXParseException('no element found',)"
        self.assertEqual(
            failedFeed.asJSON(),
            {"error":"Failed to load http://empty.invalid/rss.xml: " + msg}
        )
        self.assertTag(failedFeed.asHTML(),
           "a", {"href": "http://empty.invalid/rss.xml"},
           "Failed to load feed: " + msg)
        self.assertTrue(self.flushLoggedErrors(SAXParseException))

assertTag方法确保深度为 1 的twisted.web.template标记树具有给定的名称、属性和子元素,简化了test_responseNotOKtest_unexpectedFailure方法。

test_responseNotOK方法创建了一个空的StubFeed应用,它将使用 404 来响应测试发出的任何请求。然后,它断言检索一个 URL 会导致一个被触发的Deferred,并将结果FailedFeed呈现给 JSON 和一个标记树。JSON 应该包含 URL 和 HTTP 状态代码,而 HTML 应该链接到失败的提要并包含状态代码。

test_unexpectedFailure方法创建一个StubFeed,用一个空字符串响应对empty.invalid的请求。结果FailedFeed实例的 HTML 和 JSON 呈现检查源 URL 以及导致失败的异常的repr。我们选择repr是因为许多异常的消息,像KeyError一样,没有它们的类名是无法理解的。

test_unexpectedFailure最后一行值得特别关注。与 Python 的unittest不同的是,trial不能通过任何测试,因为它不能恢复由它调用的代码记录的异常。请注意,这不包括测试本身引起的错误。

synchronoustestcase . flushloggederrors 返回到该时间点为止已记录的 twisted . python . failure . failure 列表;如果异常类型作为参数传递,则只返回与这些类型匹配的FailureflushLoggedErrors中的“flush”意味着这是一个破坏性调用,因此给定的Failure不会出现在两个连续调用返回的列表中。当测试完成时,如果记录的错误列表为非空,则测试失败。我们的测试断言至少有一个SAXParseException是由feedparser引发的,这有清除记录的错误列表的副作用,这应该允许测试通过。

让我们编写通过这些新测试所需的代码。我们将完整地展示新版本的FeedRetrieval,这样就可以在上下文中看到它的错误处理。

# src/feed_aggregation/_service.py

...
import treq import feedparser
from twisted.logger import Logger
from functools import partial
...

@attr.s(frozen=True)
class FailedFeed(object):
    _source = attr.ib()
    _reason = attr.ib()

    def asJSON(self):
        return {"error":"Failed to load{}:{}".format(
            self._source,self._reason)}

    def asHTML(self):
        return t.a(href=self._source)(
            "Failed to load feed:{}.".format(self._reason))

class ResponseNotOK(Exception):
    """A response returned a non-200 status code."""

@attr.s
class FeedRetrieval(object):
    _treq = attr.ib()
    _logger = Logger()
    def retrieve(self, url):
        self._logger.info("Downloading feed{url}", url=url)
        feedDeferred = self._treq.get(url)

        def checkCode(response):
            if response.code != 200:
                raise ResponseNotOK(response.code)
            return response

        feedDeferred.addCallback(checkCode)
        feedDeferred.addCallback(treq.content)
        feedDeferred.addCallback(feedparser.parse)

        def toFeed(parsed):
            if parsed[u'bozo']:
                raise parsed[u'bozo_exception']
            feed=parsed[u'feed']
            entries = parsed[u'entries']

            channel = Channel(feed[u'title'], feed[u'link'],
                            tuple(Item(e[u'title'], e[u'link'])
                                  for e in entries))
            return Feed(url, channel)

        feedDeferred.addCallback(toFeed)

        def failedFeedWhenNotOK(reason):
            reason.trap(ResponseNotOK)
            self._logger.error("Could not download feed{url}:{code}",
                               url=url, code=str(reason.value))
            return FailedFeed(url, str(reason.value))

        def failedFeedOnUnknown(failure):
            self._logger.failure("Unexpected failure downloading{url}",
                                 failure=failure, url=url)
            return FailedFeed(url, repr(failure.value))

        feedDeferred.addErrback(failedFeedWhenNotOK)
        feedDeferred.addErrback(failedFeedOnUnknown)
        return feedDeferred

FailedFeed类根据Feed的接口实现asJSONasHTML。因为初始化器是私有的,所以我们可以定义一个新的reason参数来解释提要下载失败的原因。

ResponseNotOK异常表示由非 200 状态代码引起的错误类别。这也是对retrieve本身的第一个更改:当treq.get返回的响应的状态代码指示失败时,checkCode 回调会引发ResponseNotOK,将代码传递给异常。

toFeed也做了改变,以适应feedparser笨拙的错误报告 API。feedparser's宽松解析的方法意味着feedparser.parse从不直接引发异常;相反,它将返回的字典中的bozo键设置为True,将bozo_exception键设置为实际的异常。

第二次加薪属于第二类意外错误。当然,还有许多可能的意外错误,确保我们的代码也能处理这些错误是很重要的。

failedFeedWhenNotOK errback 通过捕获ResponseNotOK并记录一条带有提要的 URL 和失败响应代码的error消息来处理第一类,而failedFeedOnUnknown errback 通过记录一条critical消息来处理第二类,该消息通过Logger.failure helper 方法包含失败的回溯。两者都返回了一个FailedFeed实例,该实例根据我们添加的测试的预期来呈现它们各自的失败。

当我们将错误添加到feedDeferred时以及添加的顺序都很重要。回想一下,当回调引发异常时,下一个注册的 errback 会处理它。通过在所有回调之后添加 errbacks,我们可以清楚地看到它们处理任何引发的异常。此外,由于一个 errback 会有效地引发自己的异常,并将其传递给下一个注册的 errback,因此我们在“??”和“??”之前添加了更具体的“??”和“??”。这些错误的净效果相当于以下同步代码:

try:
...
except ResponseNotOK:
    self._logger.error(...)
    return FailedFeed(...)
except:
    self._logger.failure(...)
    return FailedFeed(...)

使用twist运行 Twisted 应用

我们将项目分成两个独立的功能部分:FeedAggregation,它处理传入的 web 请求;和FeedRetrieval,它检索和解析 RSS 提要。FeedFailedFeed通过一个公共接口将两者绑定在一起,但是如果没有最后的修改,将应用组合成一个工作整体是不可能的。

就像我们的探索性SimpleFeedAggregation程序一样,当一个传入的 HTTP 请求到达时,FeedAggregation应该驱动FeedRetrieval。这个控制流意味着一个FeedAggregation实例应该包装一个FeedRetrieval实例,这可以通过依赖注入来实现;我们可以传递一个FeedRetrieval实例的retrieve方法和一个 feed URLs 列表来请求,而不是传递一个Feed条目列表给FeedAggregation。让我们修改FeedAggregationTests to那样做:

# src/feed_aggregation/test/test_service.py

...
class FeedAggregationTests(SynchronousTestCase):
    def setUp(self):
        service = StubFeed(
            {URL.from_text(feed._source).host.encode('ascii'): makeXML(feed)
             for feed in FEEDS})
        treq = StubTreq(service.resource())
        urls = [feed._source for feed in FEEDS]
        retriever = FeedRetrieval(treq)
        self.client = StubTreq(
            FeedAggregation(retriever.retrieve, urls).resource())
        ...

现在我们可以让FeedAggregation遵循这个新的 API:

# src/feed_aggregation/_service.py

@attr.s
class FeedAggregation(object):
    _retrieve = attr.ib()
    _urls = attr.ib()
    _app = Klein()
    _plating = Plating(
        tags=t.html(
            t.head(t.title("Feed Aggregator 2.0")),
            t.body(slot(Plating.CONTENT))))
    def resource(self):
        return self._app.resource()
    @_plating.routed(
        _app.route("/"),
        t.div(render="feeds:list")(slot("item")),
    )
    def root(self, request):
        def convert(feed):
            return feed.asJSON() if request.args.get(b"json") else feed.asHTML()
        return {"feeds": [self._retrieve(url).addCallback(convert)
                          for url in self._urls]}

FeedAggregation初始化器接受两个新参数:一个接受 URL 并返回解析为FeedFailedFeed实例的Deferredretrieve callable,和一个表示要检索的 RSS 提要 URL 的urls iterable。root处理程序通过将_retrieve callable 应用到每个提供的_urls来组合这两者,然后安排通过convert回调来呈现结果。

既然我们可以将应用的服务部分与检索部分组合在一起,那么我们可以在文件src/twisted/plugins/feed_aggregation_plugin.py中编写一个 Twisted application 插件来加载和运行我们的提要聚合服务。

Twisted 的twist命令行程序允许用户运行各种开箱即用的 Twisted 服务,就像带有twist web --path=/path/to/serve的静态 web 服务器一样。它还可以通过 Twisted 的插件机制进行扩展。让我们编写一个运行提要聚合 web 服务的插件。

# src/twisted/plugins/feed_aggregation_plugin.py

from twisted import plugin
from twisted.application import service, strports
from twisted.python.usage import Options
from twisted.web.server import Site
import treq
from feed_aggregation import FeedAggregation, FeedRetrieval
from zope.interface import implementer

class FeedAggregationOptions(Options):
    optParameters = [["listen", "l", "tcp:8080", "How to listen for requests"]]

@implementer(plugin.IPlugin, service.IServiceMaker)
class FeedAggregationServiceMaker(service.Service):
    tapname = "feed"
    description = "Aggregate RSS feeds."
    options = FeedAggregationOptions
    def makeService(self, config):
        urls = ["http://feeds.bbci.co.uk/news/technology/rss.xml",
                "http://planet.twistedmatrix.com/rss20.xml"]
        aggregator = FeedAggregation(FeedRetrieval(treq).retrieve, urls)
        factory = Site(aggregator.resource())
        return strports.service(config['listen'], factory)

makeFeedService = FeedAggregationServiceMaker()

A twisted.application.service.IService是由twist运行的代码单元,而 a twisted.application.service.IServiceMaker允许 twist 发现IService提供者,a twisted.plugin.IPlugin允许twisted.plugin发现插件。FeedAggregationServiceMaker类实现了这两个接口,所以它在twisted/plugins中的实例被twist选中。

tapname属性表示twist子命令的名称,我们的服务将在该子命令下可用,而description属性是twist将呈现给命令用户的文档。options属性包含一个twisted.python.usage.Options实例,它将命令行选项解析成一个传递给makeService方法的字典。我们的FeedAggregationOptions子类包含一个命令行选项--listen-l,它代表一个默认为tcp:8080端点字符串描述。我们稍后将解释这些是什么以及它们是如何工作的。

FeedAggregationServiceMaker.makeService接受我们的 Options 类返回的解析配置,并返回一个运行我们的FeedAggregation web 服务的IService提供者。我们在这里以与测试中相同的方式构造了一个FeedAggregation实例,除了这一次,我们向FeedRetrieval提供了实际的treq实现。

twisted.web.server.Site类实际上是一个知道如何响应 HTTP 请求的工厂。它接受一个twisted.web.resource.Resource作为它的第一个参数,这个参数将响应传入的请求,就像StubTreq在我们的测试中所做的一样,所以我们再次使用FeedAggregation.resource从底层的 Klein 应用中创建一个。

strports.service函数将端点字符串描述解析成管理指定端口的IService提供者。端点字符串描述为 Twisted 应用提供了极大的灵活性,使它们可以利用协议和传输来监听客户端。

默认的tcp:8080使 Twisted 在所有可用的接口上绑定 TCP 端口 8080,并将 TCP 传输与由Site工厂创建的协议实例相关联。然而,它可以被切换到ssl:port=8443;privateKey=server.pem,后者在端口 8443 上设置一个 TLS 监听器,使用server.pem证书建立连接。然后,由站点工厂创建的协议将被绑定到 TLS 包装的传输,该传输自动加密和解密与客户端的连接。strports解析器也可以通过第三方插件扩展;例如,txtorcon ( https://txtorcon.readthedocs.io/en/latest/ )允许通过onion:端点字符串描述启动 TOR 服务器。

现在,我们可以在虚拟环境中使用twist程序调用提要聚合服务:

$ twist feed
2018-02-01T12:12:12-0800 [-] Site starting on 8080
2018-02-01T12:12:12-0800 [twisted.web.server.Site#info] Starting factory <twisted.web.serve
2018-02-01T12:12:12-0800 [twisted.application.runner._runner.Runner#info] Starting reactor.
2018-02-01T12:13:13-0800 [feed_aggregation._service.FeedRetrieval#info] Downloading feed
2018-02-01T12:13:13-0800 [feed_aggregation._service.FeedRetrieval#info] Downloading feed
...

twist设置twisted.logger将日志信息格式化并打印到标准输出。FeedRetrieval消息对应于在FeedRetrieval.retrieve中发出的info消息,并暗示客户端访问了我们的应用。

twist也可以用--log-format=json将日志消息作为 JSON 对象发出

command line option:

$ twist --log-format=json feed
...
{"log_namespace": "...FeedRetrieval", "url": "http://feeds.bbci.co.uk/news/technology/rss.x
{"log_namespace": "...FeedRetrieval", "url": "http://planet.twistedmatrix.com/rss20.xml", .
...

为了使输出更具可读性,我们省略了许多细节。但是,请注意,FeedRetrieval._retrieveinfo调用的url参数是返回的 JSON 对象的一个属性。这允许日志聚合服务从日志消息中提取数据,而不需要像正则表达式那样的试探。像strports一样,这种行为上的改变根本不需要我们修改应用代码。

摘要

本章介绍了克莱因和treq。这两个库围绕 Twisted 的 web APIs 提供了高级包装器,简化了常见的开发模式。

我们使用古老的feedparser库编写了一个 RSS 2.0 feed 聚合服务,从一个简单的原型开始,然后使用测试驱动开发来构建一个可以用twist命令行程序运行的全功能 Twisted 应用。我们使用treq.testing.StubTreq在没有任何实际网络请求的情况下测试我们的 web 服务,使用SynchronousTestCase验证我们的并发操作在给定各种输入的情况下确定性地完成。在这个过程中,我们看到了 Klein 的 Plating 特性如何使我们能够构建可以用 JSON 和 HTML 响应的 web 服务,以及我们如何用twisted.logger记录结构化数据。

对并发性没有任何假设的第三方库的使用,如feedparserlxmlattrs,展示了 Twisted 程序如何与现代 Python 生态系统集成。同时,我们的程序使用了经典的 Twisted 概念,如Deferreds;我们的提要聚合服务展示了将 Python 庞大的库与 Twisted 自己的概念和代码相结合的力量。*

四、Docker 和 Twisted

Docker 常用于微服务架构。这些都是基于通过网络通信的不同组件。Twisted 本身支持多种网络范例,通常非常适合基于 Docker 的架构。

Docker 工人和一般的集装箱都是新的。工具和关于如何使用工具的共识都在快速发展。我们在这里给出了如何使用 Docker 的基础,因此我们可以在此基础上建立如何使用 Twisted in Docker 的理解。

注意 Docker 是一种基于 Linux 的技术。虽然其他操作系统也有类似的功能,但是 Docker 是建立在利用特定的 Linux 内核功能的基础上的。Docker for Windows 确实能够运行“Windows 容器”,但这超出了本章的范围。

Docker for Mac 和 Docker for Windows 使用运行 Linux 的虚拟机,并与主机操作系统(分别是 OS X 和 Windows)进行了足够的集成,以实现无缝交互。然而,重要的是要记住 Docker 容器总是在 Linux 内核上运行,即使是在 Mac 或 Windows 笔记本电脑上运行。

intro to docker

因为 Docker 既新又受欢迎,所以有几个不同的东西被称为“Docker”准确理解 Docker 是什么本身并不简单。我们试图将这里的“Docker”分成不同的概念。注意,这些中的每一个通常被称为“Docker”,以及包括它们的整体。

容器

容器是在比传统 UNIX 进程更隔离的情况下运行的进程。

在容器中,唯一可见的进程是那些由容器的根进程启动的进程,在容器中显示为进程 ID 1。注意,这是可选的:容器可以共享主机的进程 id。使用 Docker 命令行,这是通过参数--pid host完成的。

同样,容器也有自己的网络地址。这意味着容器内部的进程可以监听给定的端口,而无需与主机或其他运行的容器协调。同样,可以使用特殊的参数--net host运行容器,以便共享主机网络名称空间。

最后,每个容器都有自己的文件系统。例如,这意味着我们可以在不同的容器中安装不同的 Python,而不用担心甚至是冲突的 Python 包。直接共享主机文件系统是很棘手的。

但是,我们可以使用 Docker 的“volume mount”选项。卷装载选项要求在容器内使主机上的目录可访问(“装载”)。该选项的语法是用冒号将目录与主机(左侧)和容器(右侧)中要“装入”的目录分开。

因此,用--volume /:/from-host运行 Docker 将使主机的所有文件都可以访问。请注意,它们在容器内部是可访问的,不是在它们通常的位置,而是在/from-host目录中。

容器被精确地隔离到它们期望被隔离的程度。这类似于克隆系统调用的标志,指示父进程和子进程之间共享什么:例如,CLONE_FILES标志指示共享文件描述符表。

容器图像

容器是运行的、孤立的进程集时,容器映像允许我们实例化一个容器——它们相当于可执行映像。

在内部,容器映像由组成,每一层代表一个文件系统。容器将看到的最终文件系统(通常称为联合文件系统)是所有层的组合,较高的层覆盖较低的层。上层可以修改、添加甚至“删除”前一层中的文件。虽然下层不会受到影响,但容器内部可见的最终文件系统将受到影响。

这很重要,因为这意味着删除上层文件并不能节省空间。例如,如果第一层有一个 tarball,然后将其展开,则 tarball 通常是多余的。上层通常会有rm/path/to/file.tar.gz或类似的命令。这在文件名不可见的情况下是好的——但是,在整个容器映像的最终大小中——例如,需要下载多少字节来运行它 tarball 仍将被包括在内。

集装箱图像以其最终位置命名为(或者更准确地说是标签)。通常的命名方案是[optional host/] [ optional user/]name[:optional tag]。虽然也有例外,但那些永远不会离开构建它们的主机的映像通常会省略hostuser部分。

如果标签关闭,默认为:latest。如果主机关闭,默认为docker.io

注意,同一个容器图像可以有多个标签。

容器映像在注册中心主机之间移动:它们可以被“推”到注册中心,也可以被“拉”到主机。

Runc 和 Containerd

为了从一个映像运行一个容器,使用了一个叫做runc(“运行容器”)的特殊程序。这个程序负责设置适当的隔离机制:它使用 Linux 内核设施,比如 cgroups 和 namespaces,以便适当地隔离文件系统、进程名称空间和网络地址。

通常,容器用户不会直接与runc交互。然而,它被 Docker 堆栈和几乎所有的替代容器堆栈(如 Rocket)在幕后使用。

为了管理正在运行的容器,有必要知道哪些容器正在运行,以及它们的状态如何。出于这个原因,一个名为containerd的“守护程序”通过调用runc从图像中生成所有容器。

注意,在 Docker 的早期版本中,runc 被嵌入到containerd中——所以很多资料仍然将“Docker 守护进程”称为运行容器。

客户

与预期相反,命令行docker run不运行容器。相反,它与containerd守护进程通信,并要求它运行带有runc的容器。

默认情况下,它使用 UNIX 域套接字与服务器通信。UNIX 域套接字是基于 UNIX 的操作系统上一种特殊的进程间通信工具。它们的 API 类似于 TCP 套接字,但是它们只用于同一台机器内部的通信,允许内核做一些快捷方式。UNIX 域套接字使用文件路径作为它们的地址,而不是 IP 地址和端口。这允许应用通常的 UNIX 文件权限模型。

默认情况下,docker连接的 UNIX 域套接字是/var/run/docker.sock。根据 Docker 安装的具体细节,它可能由docker组或root组访问。Docker 客户端还可以使用 TLS over TCP 连接到服务器,使用 TLS 证书进行相互身份验证。

对于docker的所有其他子命令也是如此,比如buildimages等。(注意docker login是个例外,但是远程注册中心登录如何工作的解释超出了我们目前的范围。)

因为命令行docker主要用于向守护进程发送远程过程调用,所以我们称之为“客户端”

登记处

Docker 将图像保存在一个(通常是远程的)注册表中。注册表将每个图像存储为一些元数据,加上一组。元数据记录了层的顺序,以及容器图像的一些细节。

请注意,由于这种存储方法,同一层将只存储一次。几个图像共享层的常见情况是具有共同的祖先,这意味着从一个公共基础图像构建的多个图像不需要该图像的自己的副本。

还要注意,默认注册表docker.io内置于软件中——如果没有指定注册表,则采用默认注册表——通常称为“DockerHub”

这是单词“Docker”的一种稍微不同的用法,应该再次注意到这是一种可能会引起混淆的术语。

建设

构建映像的通常方式是使用docker build命令行。这使用了一个被称为Dockerfile的配置文件。Dockerfile以一条FROM线开始。FROM标识祖先形象。如果需要一个空图像,FROM scratch将使用没有图层的scratch图像。然而,这是罕见的。

通常,构建将从一个通用的 Linux 发行版开始,这些发行版都可以从默认的 Docker 注册表 DockerHub 中获得。比如 Debian,Ubuntu,CentOS 都有。

Dockerfile中的每一行都是一个“构建阶段”每个构建阶段都会创建一个层,并且层会被缓存。这意味着当修改一个Dockerfile时,只有被改变的行(以及它们后面的行)会被执行。

下面的例子就是这样一个Dockerfile,它将运行 Twisted web 演示服务器。

FROM debian:latest
RUN python3 -m pip install --user Twisted
ENTRYPOINT ["python3", "-m", "twisted", "web"]

这并没有展示最佳实践,我们将在查看更复杂的特性时介绍最佳实践,但是它展示了几乎总是出现在Dockerfile中的三个重要部分:

  • FROM线。在这里,我们要求的是debianlatest版本。请注意,因为我们没有使用带斜线的名称,所以这来自 DockerHub 上的“库”——一组半官方的基本图像。

  • RUN行在正在构建的容器中运行一个命令,通常的效果是以某种方式改变它。在这种情况下,我们将 Twisted 安装成一个user安装。

  • ENTRYPOINT行设置容器启动时将运行的程序。

多阶段构建

上面的解释缺少了一个重要的新功能,是在docker build中于 2017 年年中添加的。这些是多阶段构建。当Dockerfile中有一个以上的FROM行时,就会发生多阶段构建。

当这种情况发生时,构建过程开始构建新的映像,在构建结束时,所有非最终映像都将被丢弃。然而,当构建运行时,其他映像可以通过一个Dockerfile命令—COPY访问。

当使用COPY --from=<image>时,它不会从上下文中复制文件,而是从以前的图像中复制。虽然在理论上,多阶段构建可以有任意数量的阶段,但需要两个以上的阶段是非常罕见的。图像的排序使用从 0 开始的编号。大多数“多阶段”构建实际上是“两阶段”构建。第一阶段将构建所有的工件,使用一个充满编译器和构建工具的“厚”映像。第二阶段从第一阶段提取所有的工件,并生成最终的图像进行分发。正因为如此,级间指令通常采用的形式是COPY --from=0

当需要一个复杂的构建环境来生成一些将要部署的产品时,这很有用——最好不要在最终的运行时容器中提供复杂的构建环境:这样可以减少规模、层数和潜在的安全风险。

下面是一个多阶段构建的示例。请注意,在这种情况下,最终输出并不打算直接使用,而是在其他构建中构建。这是一种常见的模式:构建具有通用元素的标准库有几个优点——例如,这可以节省注册表和运行服务器中的空间(如果多个不同的映像在一台服务器上运行,这是常有的事)。另一个好处是当 bug 被修复时,有一个地方可以升级基础包。

FROM python:3
RUN mkdir /wheels
RUN pip wheel --wheel-dir /wheels pyrsistent

FROM python:3-slim
COPY --from=0 /wheels /wheels
RUN pip install --no-index --find-links /wheel pyrsistent

同样,我们将逐行解释这里发生了什么:

FROM python:3

python:3库是标准“DockerHub 库”库的另一个例子。它包括 Python 3,但也包括足够的工具来构建本机代码轮——至少是简单的,没有进一步的依赖性。

RUN mkdir /wheels

我们创建目录来存储轮子。请注意,因为这个阶段不会出现在最终输出中,所以我们对创建额外的层并不敏感。事实上,额外的层是好的——它们创造了更多的缓存点。在这种情况下,这没什么意思,但是构建基础通常包括安装更多的构建依赖项。

RUN pip wheel --wheel-dir /wheels pyrsistent

pip wheel子命令在多阶段构建中很有用。它为指定的需求及其所有依赖项构建了一个轮子。如果平台兼容,它将使用 PyPI 为manylinux构建的二进制轮——但如果需要,可以用pip wheel --no-binary :all关闭这种行为。

FROM python:3-slim

python:3-slim基础类似于 python:3,但是不包括复杂的构建时依赖集。请注意,Python 发行版中的 many :code: setup.py会自动检测编译器或依赖项的缺失,并自动关闭本机代码模块的构建。例如,pyrsistent有一个 C 优化的持久矢量实现,这是我们想要的。因此,我们不想在这个阶段安装pyrsistent

COPY --from=0 /wheels /wheels

我们复制刚刚构建的pyrsistent轮,以及从第一阶段(阶段 0)到当前阶段的任何依赖关系。第二条FROM线表示这是一个多阶段构建——但是这条COPY线使多阶段构建变得有用。

RUN pip install --no-index --find-links /wheel pyrsistent

最后,我们将库安装到本地 Python 环境中。我们小心地为pip指定了--no-index--find-links选项,这样它将使用第一阶段的轮子,而不是从 PyPI 获得新的发行版。

Docker 上的 Python

像在任何 UNIX 平台上一样,在 Docker 上部署 Python 应用有很多种方法。它们并不完全相同,有些比其他的更好。我们将调查那些效果较好的选项。

部署选项

全环境

“全环境”部署意味着有一个专门为应用安装的定制 Python 解释器。这个 Python 可以作为 Docker 构建过程的一部分或之前从源代码定制构建,也可以来自元发行版,如condanix

安装一个定制的 Python 解释器通常是有用的:我们可以在其上定制构建选项,固定解释器版本,甚至在特别极端的情况下,应用定制补丁。然而,这意味着我们承担了使解释器保持最新的任务。

无论我们如何安装这个解释器,它将完全用于我们的应用。我们使用pip install在其中安装包——或者,如果它来自元发行版(比如condanix),我们也可以从元发行版安装包。这对于 conda 尤其有用,因为有许多与数据科学相关的 Python 包可供安装。

这里有一个例子Dockerfile,它构建了一个定制的 Python 解释器,装载了必要的包。

FROM buildpack-deps:stretch

ENV PYTHON_VERSION 3.6.4
ENV PREFIX https://www.python.org/ftp/python

ENV LANG C.UTF-8

ENV GPG_KEY 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D

RUN apt-get update
RUN apt-get install -y --no-install-recommends \
        tcl \
        tk \
        dpkg-dev \
        tcl-dev \
        tk-dev

RUN wget -O python.tar.xz \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"
RUN wget -O python.tar.xz.asc \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"
RUN export GNUPGHOME="$(mktemp -d)" && \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$GPG_KEY" && \
    gpg --batch --verify python.tar.xz.asc python.tar.xz
RUN mkdir -p /usr/src/python
RUN tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz

WORKDIR /usr/src/python

RUN gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"
RUN ./configure \
    --build="$gnuArch" \
    --enable-loadable-sqlite-extensions \
    --enable-shared \
        --prefix=/opt/custom-python/
RUN make -j
RUN make install
RUN ldconfig /opt/custom-python/lib
RUN /opt/custom-python/bin/python3 –m pip install twisted

FROM debian:stretch

COPY --from=0 /opt/custom-python /opt/custom-python
RUN apt-get update && \
    apt-get install libffi6 libssl1.1 && \
    ldconfig /opt/custom-python/lib
ENTRYPOINT ["/opt/custom-python/bin/python3", "-m", "twisted", "web"]

构建定制的 Python 解释器虽然有用,但并不简单。我们一行一行地检查这个文件:

FROM buildpack-deps:stretch

对于建筑来说,buildpack-deps是一个有用的基础图像。由于我们将使用 Debian“stretch”作为我们的部署版本,在撰写本文时它是最新的稳定 Debian 版本,所以我们得到了 stretch 兼容的 buildpack。

ENV PYTHON_VERSION 3.6.4
ENV PREFIX https://www.python.org/ftp/python

设置这些可以让我们轻松地修改我们使用的 Python 版本——这对从上游获得新的安全修复和错误修复是必不可少的。我们越容易升级 Python,我们的情况就越好。

ENV LANG C.UTF-8

将语言明确设置为 UTF-8 是必要的,以避免在 Python 构建过程中出现不明显的错误。虽然在教学上没有启发性,但作为一个放置这些变通办法的地方,这是有用的。将这些细节放在 docker 文件中是确保构建成功的一个方便的地方——无论是在持续集成系统上还是在本地。

ENV GPG_KEY 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D

这是 GnuPG 公钥,对应于签署 Python tarball 上传的私钥。Gnu Privacy Guard 是一个使用密码术来实现安全保证的工具。在这种情况下,密钥允许我们知道源代码没有被篡改。这是一个好主意,增加纵深防御,并使用多种方法来验证我们的来源是真实的。这个Dockerfile,或者类似的,经常被用在持续集成环境中,在那里它们被重复地和自动地运行。只需一次破坏就能严重危及基础设施。如果源代码没有保证,确保构建失败可以消除代价高昂的生产违规。

将密钥指纹保存在 docker 文件中(可能会签入到源代码控制中),是在签入的代码中建立信任的一种方式。

RUN apt-get update
RUN apt-get install -y --no-install-recommends \
        tcl \
        tk \
        dpkg-dev \
        tcl-dev \
        tk-dev

除了 buildpack 之外,我们还需要一些额外的库。我们把它们安装在这里。

RUN wget -O python.tar.xz \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"

接下来,我们下载 Python 源代码 tarball。定义上面的变量可以让我们保持这一行简短明了。此外,即使对于稳定版本来说不是必需的,这个命令行也可以用于像3.6.1rc2这样的版本——如果我们想使用这个 docker 文件,只做很小的修改,来测试与候选发布版本的兼容性,那么这个命令行是必需的。

RUN wget -O python.tar.xz.asc \
    "$PREFIX/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"

我们下载分离的公钥签名。尽管我们从支持 TLS 的网站下载这两个版本,一个是以https为前缀,而不是http,检查签名是一个很好的深度防御措施。

RUN export GNUPGHOME="$(mktemp -d)" && \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$GPG_KEY" && \
    gpg --batch --verify python.tar.xz.asc python.tar.xz

此命令行验证公钥。注意,这是一个命令的例子,它不改变本地状态。然而,由于任何失败的命令都将停止docker build进程,一个关键验证错误将导致构建暂停。

RUN mkdir -p /usr/src/python

我们为解压后的源代码创建一个目录。注意,由于这是一个多阶段的构建,我们不关心这个目录的最终清理——整个容器都将被清理!

RUN tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz

我们将 Python tarball 解压到新创建的目录中。

WORKDIR /usr/src/python

我们将当前工作目录设置为源代码目录。这使得需要从内部运行的后续构建指令更短、更容易理解。

RUN gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)" && \
  ./configure \
    --build="$gnuArch" \
    --enable-loadable-sqlite-extensions \
    --enable-shared \
        --prefix=/opt/custom-python/

我们运行。/configure 脚本,带有自定义前缀。自定义前缀/opt/custom-python确保我们将处于一个原始的目录中。我们还提供了一些选项来确保我们的 Python 构建是正确的:

  • 使用dpkg-architecture计算架构,并显式传递给配置脚本。这比让配置脚本自动检测它更可靠。

  • 我们启用sqlite模块。由于它是内置的,许多第三方模块将依赖于它而不声明依赖关系,所以确保它是安装的一部分是很重要的。

  • 我们启用共享库。在我们的例子中,这不是绝对必要的,但是它允许嵌入 Python 的情况。

RUN make -j

计算 CPU 的确切数量并不简单。在这个例子中,我们只是以最大并行度运行 make。这就是-j标志的作用。注意,一般情况下,建议通过给-j一个数字参数,例如-j 4,将并行度设置到一个合理的水平。

RUN make install

此阶段会将具有正确权限的文件复制到安装目录中。

RUN ldconfig /opt/custom-python/lib

我们将目录添加到我们的库搜索路径中——否则 Python(动态链接的)无法运行。

RUN /opt/custom-python/bin/python3 -m pip install twisted

我们安装 Twisted 的。在 Twisted 的许多其他好处中,它包含一个方便的默认 web 服务器,这对演示很有用。

FROM debian:stretch

对于产品构建,我们从一个合适的最小 Debian 发行版开始——保持它是构建包的匹配版本。

COPY --from=0 /opt/custom-python /opt/custom-python

我们复制整个环境——包括安装的第三方库:在本例中,是 Twisted 及其依赖项。

RUN apt-get update && \
    apt-get install libffi6 libssl1.1 && \
    ldconfig /opt/custom-python/lib

我们安装必要的库并在生产映像中运行ldconfig

ENTRYPOINT ["/opt/custom-python/bin/python3", "-m", "twisted", "web"]

我们设置入口点来运行 Twisted 内置的演示 web 服务器。如果我们构建并运行这个 docker 映像,web 服务器将会运行——如果我们导出端口,我们甚至可以用浏览器检查它。

Virtualenv(虚拟环境)

完整环境的替代方案是“轻量级”环境,也就是人们所说的虚拟环境。当使用 Python 2.7 时,我们使用virtualenv包创建一个虚拟环境。使用pip安装virtualenv是可能的,但是这有问题:毕竟,如果创建虚拟环境的原因是为了避免改变真实环境,这就失去了好处。一种方法是用我们获得 Python 的方式获得virtualenv。另一种方法是使用

pip install --user virtualenv

这将把它放在用户目录下(在 Docker 上,通常在/root下)。这通常意味着virtualenv不在默认的 shell 路径上——但是因为它在 Python 路径上,

python -m virtualenv <directory>

仍然可以工作并创建一个虚拟环境。

当使用 Python 3.x 时,这些问题是没有实际意义的:python -m venv是为 Python 3.x 创建虚拟环境的最佳方式。请注意,一些文档尚未更新,virtualenv 在 Python 3.x 上运行——这使得确保所有这些都是最新的变得更加困难。然而,venv内置模块的存在极大地简化了虚拟环境的引导。

在虚拟环境中安装代码的好处之一是,我们知道虚拟环境的目录只包含运行它所必需的内容——除了解释器。当我们构建 Docker 映像时,这个特性会派上用场。

把所有这些想法放在一起,我们可能会得出这样一个Dockerfile:

FROM python:3

因为我们要构建一个虚拟环境,所以我们需要已经安装了一个完整的环境。最简单的方法之一是从python容器开始。

RUN python -m venv /opt/venv-python

我们在/opt/venv-python中创建一个虚拟环境。

RUN /opt/venv-python/bin/pip install Twisted

我们在里面安装了扭结。注意,安装 Twisted 意味着安装几个带有 C 扩展的包——这个阶段需要一个 C 编译器。容器映像拥有构建 C 扩展所需的所有工具。

FROM python:3-slim

python:3-slim容器映像没有构建工具。由于这是我们将发布的映像,这意味着我们不会将 C 编译器发布到产品中。

COPY --from=0 /opt/venv-python /opt/venv-python

我们复制虚拟环境。请注意,虚拟环境中有几个硬编码的路径。这就是为什么我们要确保使用与部署路径相同的路径来创建它。

ENTRYPOINT ["/opt/venv-python/bin/python", "-m", "twisted", "web"]

入口点与之前的入口点几乎相同。唯一的区别是路径——这次指向的是虚拟环境,而不是完整的环境。

最大运动量

最独立的选项是 Pex——Twitter 首创的 Python 可执行格式。Pex 结合了 UNIX、Python 和 Zip 归档的功能,具有包含所有应用代码和第三方依赖项的单一文件格式。

Pex 文件应该在文件系统级别被标记为可执行的,例如使用chmod +x,并使用调用 Python 解释器的 shebang 行(!#)生成。由于 Zip 存档具有独特的属性,即它们是通过它们的最后字节而不是第一个字节来检测和解析的,因此文件的其余部分是一个 Zip 文件。

当 Python 解释器接受一个 Zip 文件,或者一个带有任意内容的 Zip 文件时,它会将其视为 sys.path 附加文件,并且会额外执行存档中的__main __.py文件。Pex 文件生成一个定制的__main__ .py,它调用入口点或执行 Python 模块,这取决于传递给 Pex builder 的参数。

Pex 可以通过使用pex命令行(随pip install pex一起安装)、使用pex作为 Python 库并使用其创建 API 来构建,也可以通过大多数现代元构建器来构建——Pants、Bazel 和 Buck 都能够生成 Pex 输出。

FROM python:3
RUN python -m venv /opt/venv-python

我们创造了一个虚拟环境。虽然我们不打算发布这个环境,但它将帮助我们构建 Pex 文件。

RUN /opt/venv-python/bin/pip install pex

我们安装 pex 实用程序。

RUN mkdir /opt/wheels /opt/pex

我们创建两个目录来包含不同种类的产品。

RUN /opt/venv-python/bin/pip wheel --wheel-dir /opt/wheels Twisted

我们用pip来制造轮子。这意味着我们将使用pip依赖解析算法。虽然客观上并不比pex算法更好,但它是其他任何地方都使用的算法。这意味着如果软件包在pip依赖关系解析过程中遇到问题,它们将添加正确安装所需的任何提示。不常用的pex就没有这种保证。

RUN /opt/venv-python/bin/pex --find-links /opt/wheels --no-index \
                             Twisted -m twisted -o /opt/pex/twisted.pex

我们构建 Pex 文件。注意,我们告诉pex忽略 PyPI 索引,只从一个特定的目录中收集包——在这个目录中pip放置了它构建的所有轮子。我们配置 Pex 文件的行为就像我们用-m twisted运行 Python 一样,我们把输出放在/opt/pex中。虽然后缀不是绝对必要的,但在检查 Docker 容器图像时,它非常有用,有助于理解事物是如何运行的。

FROM python:3-slim

同样,我们避免使用第二阶段的slim映像将构建工具交付给生产。

COPY --from=0 /opt/pex /opt/pex

我们复制目录——这一次,它只有一个文件。还要注意,这一次,文件是可重定位的:可以(尽管我们在这里不这样做)复制到不同的路径。

ENTRYPOINT ["/opt/pex/twisted.pex", "web"]

在前面的例子中驻留在ENTRYPOINT(我们想要运行python -m twisted)中的一些逻辑现在被构建到 Pex 文件中。我们的ENTRYPOINT现在更短了。

构建选项

不管 Python 是以什么方式运行的,Docker 容器是以什么方式构建的也有很多选择。

一个大包

一种方法是完全避免多阶段构建,使用构建环境所需的任何工具构建一个容器。这通常意味着容器很大,有很多层。

虽然这种方法简单、直接且易于调试,但它也有缺点。容器尺寸很容易成为生产中的一个问题。类似地,层数减慢了容器的部署。最后,将大量的包放在一个暴露于潜在的恶意用户输入的容器中会导致更多的攻击媒介。

阶段之间的复制轮

另一种方法是在构建阶段构建所有轮子,包括任何二进制轮子,然后将它们复制到生产阶段。在这种情况下,生产阶段仍然需要足够的工具来创建一个虚拟环境并在其中安装这些轮子——尽管由于venv是 Python 3 中的一个 Python 内置模块,这通常不再是一个难题。

还有另外两个问题:轮子在安装后仍然存在,因为在切换层后不可能真正删除一个文件;并且它经常创建额外的层(尽管通过巧妙的重新排序和反斜杠继续的行,这有时是可以避免的)。

在阶段之间复制环境

另一个部署选项是将环境(可以是完整的或虚拟的)从构建阶段复制到生产阶段。这种方法的优点是快速、简单,缺点是没有兼容性检查、依赖性检查或位置检查。尽管如此,如果对生成的容器进行了适当的测试,通常会发现基本的不兼容问题。

在阶段之间复制 Pex 可执行文件

最后,如果 Pex 可执行文件是在构建阶段生成的,那么复制它是很简单的。当然,Pex 文件会在运行时寻找依赖关系。然而,它会做一个可靠的检查,所以即使启动容器也足以测试它。

它也是可重定位的,所以它从哪里拷贝或者拷贝到哪里都没有关系。Pex 和 Docker 通常是很好的组合。然而,Pex 的固有限制(例如,预构建的二进制轮子支持差或 PyPy 支持差)有时使它无法成功。

Dockerpy 自动化

一个名为dockerpy的包允许用 Python 实现 Docker 步骤的自动化。虽然通常在生产中运行容器,我们将使用编排框架,这通常对构建和测试容器有用。dockerpy库允许我们仔细微调发送给 Docker 守护进程的上下文——使用tarfile Python 模块,可以精确地制作所需的上下文。

在 Docker 上扭转

入口点和 PID 1

Docker 的ENTRYPOINT Dockerfile 指令中的进程在容器内部将具有进程 ID 1。进程 ID 1 在 Linux 上有特殊的责任。当一个进程的父进程在它死亡之前死亡时,PID 1“采用”它——成为它的父进程。这意味着当子进程终止时,PID 1 需要“收获它”——等待它的退出状态,以便从进程表中清除进程条目。

这个责任有点诡异,很多节目都不是为它设置的。当运行一个不接收领养儿童的程序时,进程表将会填满。在最好的情况下,这将使容器崩溃。在最坏的情况下,当没有仔细设置进程限制时,这可能会使运行容器的整个机器(虚拟的或物理的)崩溃。

幸运的是,任何Twisted 的程序都设置为 PID 1。这是因为 Twisted 的流程基础设施会自动收获预期的和意外的孩子。

这意味着当构建一个容器时,如果我们用它来运行 WSGI 应用,或者 Klein 应用,或者 Buildbot master,让它作为入口点是很好的。

事实上,由于这个原因,如果有任何自定义的启动代码要做,可以考虑将其实现为 tap 插件。这样,Twisted 还是可以作为切入点的。

自定义插件

当编写一个在 Docker 中运行的 Twisted 应用时,我们几乎总是希望将它作为一个定制的tap插件来交付。这使得ENTRYPOINT简单

["/path/to/python", "-m", "twisted", "custom_plugin"]

这意味着插件可以获得传递给docker run命令的任何参数——因为这些参数被直接添加到ENTRYPOINT参数中。这也意味着插件可以直接读取通过--env传递给docker run的任何环境变量。

在插件中,makeService函数是返回正在运行的服务的函数。请注意,插件可以在该函数中进行任何它想要的初始化——事件循环此时还没有运行。

殖民地

有时,有必要在 Docker 容器中运行多个进程。也许是一些辅助进程来清理文件,或者是一个多进程设置来使用多个 CPU。在这种情况下,一个进程管理者是有用的——运行几个进程,监视它们,并在必要时重新启动它们。

NColony 是一个基于 Twisted 的流程主管。它是围绕twisted.runner.procmon的一个小垫片,允许几个灵活的配置选项。NColony 将配置作为描述进程的 JSON 格式文件的目录。

当然,也可以通过打开一个文件并将 JSON 写入其中来直接创建这些文件。然而,NColony 还附带了一个命令行实用程序—python -m ncolony ctl—来创建这样的文件,以及一个 Python 库—ncolony.ctllib

目录模型的一个优点是,这意味着它可以与 Docker 容器的层模型很好地交互。一个本地基础容器可以有一个["python", "-m", "twisted", "ncolony", ...]ENTRYPOINT,甚至在配置目录中有几个基础进程——通常是/var/run/ncolony/config/。然后,特定的 containerd 可以在这个目录中转储它们自己的文件,这些文件是在容器的构建阶段使用例如python -m ncolony ctl创建的。最终的容器将同时运行副进程和主进程。

这里有一个例子,它将本章讨论的大部分内容具体化了:

FROM python:3
RUN python3 -m venv /application/env
RUN /application/env/bin/pip install ncolony
RUN mkdir /application/config /application/messages
RUN /application/env/bin/python -m ncolony \
    --config /application/config \
    --messages /application/messages \
    ctl \
    --cmd /application/env/bin/python \
    --arg=-m \
    --arg=twisted \
    --arg=web

FROM python:3-slim
COPY --from=0 /application/ /application/
ENTRYPOINT ["/application/env/bin/python", \
            "-m", \
            "twisted", \
            "ncolony", \
            "--config", "/application/config", \
            "--messages", "/application/messages"]

我们一行一行地检查,这里有很多东西。

FROM python:3

获得我们的 Python 环境的一种方法是使用官方的 Docker(“库”)映像。这是基于 Debian 发行版的,其中有 Python——以及构建 Python 和 Python 扩展模块所需的所有工具。

RUN python3 -m venv /application/env

我们在/application/env中创建一个虚拟环境。如前所述,Python 3 使虚拟环境成为一个内置的概念,我们充分利用了它。

RUN /application/env/bin/pip install ncolony

为了更好的可重复构建,最好是复制一个需求文件——最好是一个也有散列的文件——pip install。然而,当我们直接使用一个包名时,更容易看到发生了什么。

RUN mkdir /application/config /application/messages

NColony 需要两个目录才能正常工作:一个用于配置,一个用于消息。我们在/application下创建它们。配置是需要运行的一组进程及其参数。消息是瞬时请求——通常是重启一个或多个进程的请求。

RUN /application/env/bin/python -m ncolony \

我们从安装在/application/env虚拟环境中的 NColony 运行子命令。

--config /application/config \
--messages /application/messages \

我们传递 NColony 的参数。虽然在这种情况下没有使用 messages 目录,但是最好将它们都传递给所有命令。

ctl \

Control ( ctl)是控制配置的 NColony 子命令。

--cmd /application/env/bin/python \

我们运行与我们运行的 Python 相同的 Python。注意,一般来说,这对于 NColony 来说是不必要的。然而,为不同的用途编写使用完全不同的解释器的代码会令人困惑。

--arg=-m \
--arg=twisted \

NColony 正在监控的进程不一定是一个 Twisted 的进程,但在我们的例子中,它将是一个 Twisted 的进程——事实上,是另一个tap插件。

--arg=web

当没有给定参数时,web tap插件显示一个演示 web 应用。这对于快速演示和检查来说非常有用——就像这个例子一样。

FROM python:3-slim

第二行FROM开始生产 Docker 图像。注意——当构建完成时,到目前为止构建的所有东西都将被丢弃。早期步骤存在的唯一原因是从那个短暂的阶段复制。这个源映像是一个最小的 Debian,加上一个已安装的 Python 3。

COPY --from=0 /application/ /application/

我们复制整个应用目录。因为这个目录既有虚拟环境又有 NColony 配置,所以我们不需要其他东西。这一行的简单性解释了我们为设置这个目录所做的所有细致工作的价值。

ENTRYPOINT ["/application/env/bin/python", \ "-m", \
            "twisted", \ "ncolony", \
            "--config", "/application/config", \
            "--messages", "/application/messages"]

最后,我们配置入口点。由于 NColony 本身是一个 tap 插件,我们再次运行的命令是python -m twisted <plugin>

在这个例子中,我们可以直接运行 web 服务器作为入口点。然而,一个实际上需要几个进程的更现实的例子会掩盖让 NColony 在 Docker 中运行的基本机制。

摘要

Docker、Python 和 Twisted 是互补的技术。具有多阶段构建和注册的 Docker 为 Python 提供了一种标准化的方式来指定构建过程和打包。Twisted 及其进程管理原语为 Docker 提供了一个有用的 PID 1,它可以独立完成有用的工作——例如 web 服务器——或者是一个强大的基础层 NColony 非常适合 Docker 层模型。

Docker 是构建、打包和运行 Twisted 应用的实用方法,而 Twisted 是在 Docker 内部运行的有用工具。