Tornado Python Web 应用框架简介

在本系列比较 Python 框架的第三部分中,了解专为处理异步进程而构建的 Tornado。
284 位读者喜欢这篇文章。
tornado photo

Internet Archive Book Images。由 Opensource.com 修改。CC BY-SA 4.0

在本系列比较不同 Python Web 框架的前两篇文章中,我们介绍了 PyramidFlask Web 框架。 我们两次构建了相同的应用程序,并看到了一个完整的 DIY 框架和一个包含更多功能的框架之间的异同。

现在让我们看看一个有点不同的选择:Tornado 框架。 在大多数情况下,Tornado 与 Flask 一样是赤裸裸的,但有一个主要区别:Tornado 专门用于处理异步进程。 这种特殊的技巧在我们正在构建的这个系列应用程序中并不是非常有用,但我们将看到我们可以在哪里使用它以及它在更一般的情况下如何工作。

让我们继续我们在前两篇文章中设定的模式,从处理设置和配置开始。

Tornado 启动和配置

如果您一直在关注这个系列,那么我们首先要做的事情不应该让您感到惊讶。

$ mkdir tornado_todo
$ cd tornado_todo
$ pipenv install --python 3.6
$ pipenv shell
(tornado-someHash) $ pipenv install tornado

创建一个 setup.py 来安装我们的应用程序

(tornado-someHash) $ touch setup.py
# setup.py
from setuptools import setup, find_packages

requires = [
    'tornado',
    'tornado-sqlalchemy',
    'psycopg2',
]

setup(
    name='tornado_todo',
    version='0.0',
    description='A To-Do List built with Tornado',
    author='<Your name>',
    author_email='<Your email>',
    keywords='web tornado',
    packages=find_packages(),
    install_requires=requires,
    entry_points={
        'console_scripts': [
            'serve_app = todo:main',
        ],
    },
)

因为 Tornado 不需要任何外部配置,所以我们可以直接编写将运行我们应用程序的 Python 代码。 让我们创建我们的内部 todo 目录,并用我们需要的前几个文件填充它。

todo/
    __init__.py
    models.py
    views.py

与 Flask 和 Pyramid 一样,Tornado 有一些中心配置将进入 __init__.py。 从 tornado.web,我们将导入 Application 对象。 这将处理路由和视图的连接,包括我们的数据库(当我们到达那里时)和运行我们的 Tornado 应用程序所需的任何额外设置。

# __init__.py
from tornado.web import Application

def main():
    """Construct and serve the tornado application."""
    app = Application()

与 Flask 类似,Tornado 也是一个主要的 DIY 框架。 在构建我们的应用程序时,我们必须设置应用程序实例。 因为 Tornado 使用自己的 HTTP 服务器为应用程序提供服务,所以我们还必须设置应用程序的服务方式。 首先,我们使用 tornado.options.define 定义一个要监听的端口。 然后我们实例化 Tornado 的 HTTPServer,并将 Application 对象的实例作为其参数传递。

# __init__.py
from tornado.httpserver import HTTPServer
from tornado.options import define, options
from tornado.web import Application

define('port', default=8888, help='port to listen on')

def main():
    """Construct and serve the tornado application."""
    app = Application()
    http_server = HTTPServer(app)
    http_server.listen(options.port)

当我们使用 define 函数时,我们最终会在 options 对象上创建属性。 第一个参数位置中的任何内容都将是属性的名称,分配给 default 关键字参数的内容将是该属性的值。

例如,如果我们命名属性 potato 而不是 port,我们可以通过 options.potato 访问它的值。

HTTPServer 上调用 listen 还没有启动服务器。 我们必须再做一步才能拥有一个可以监听请求并返回响应的工作应用程序。 我们需要一个输入输出循环。 值得庆幸的是,Tornado 以 tornado.ioloop.IOLoop 的形式开箱即用。

# __init__.py
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.options import define, options
from tornado.web import Application

define('port', default=8888, help='port to listen on')

def main():
    """Construct and serve the tornado application."""
    app = Application()
    http_server = HTTPServer(app)
    http_server.listen(options.port)
    print('Listening on http://localhost:%i' % options.port)
    IOLoop.current().start()

我喜欢某种 print 语句来告诉我我什么时候在服务我的应用程序,但那是我。 如果您愿意,您可以不用 print 行。

我们使用 IOLoop.current().start() 开始我们的 I/O 循环。 让我们更多地谈谈输入、输出和异步性。

Python 中的异步基础知识和 I/O 循环

请允许我先说明,我绝对、肯定、确定且安全地不是异步编程方面的专家。 与我写的所有东西一样,以下内容源于我对这个概念的理解的局限性。 因为我是人,所以它可能存在深刻的缺陷。

异步程序的主要关注点是

  • 数据是如何进入的?
  • 数据是如何出去的?
  • 什么时候可以留下一些程序运行而不消耗我的全部注意力?

由于 全局解释器锁 (GIL),Python 在设计上是一种 单线程 语言。 对于 Python 程序必须执行的每个任务,其执行线程的全部注意力都集中在该任务上,直到该任务完成。 我们的 HTTP 服务器是用 Python 编写的。 因此,当接收到数据(例如,HTTP 请求)时,服务器的唯一焦点就是传入的数据。 这意味着,在大多数情况下,处理和处理该数据需要运行的任何程序都会完全消耗服务器的执行线程,从而阻止接收其他潜在的数据,直到服务器完成它需要做的任何事情。

在许多情况下,这并不是太大的问题; 一个典型的 Web 请求-响应周期只会花费几分之一秒。 除此之外,HTTP 服务器构建的套接字可以维护要处理的传入请求的积压。 因此,如果一个请求在套接字正在处理其他事情时进入,则它很可能会先排队等待一段时间才能被处理。 对于低到中等流量的站点,几分之一秒并不算什么大问题,您可以使用多个已部署的实例以及像 NGINX 这样的负载均衡器来分配更大的请求负载的流量。

但是,如果您的平均响应时间超过几分之一秒怎么办? 如果您使用来自传入请求的数据来启动一些长时间运行的进程,例如机器学习算法或一些大型数据库查询怎么办? 现在,您的单线程 Web 服务器开始累积无法处理的请求积压,其中一些请求会因为超时而被丢弃。 这不是一个选择,特别是如果您希望您的服务被视为定期可靠。

异步 Python 程序就派上用场了。 重要的是要记住,因为它是在 Python 中编写的,所以该程序仍然是一个单线程进程。 任何会在同步程序中阻止执行的内容,除非特别标记,否则仍然会在异步程序中阻止执行。

但是,当结构正确时,您的异步 Python 程序可以随时“搁置”长时间运行的任务,只要您指定某个函数应具有这样做的能力。 然后,当搁置的任务完成并准备好恢复时,您的异步控制器可以得到警报,仅在需要时管理它们的执行,而不会完全阻止处理新的输入。

这有点行话,所以让我们用一个人类的例子来演示。

带回家

我经常发现自己试图在家里完成多项家务,但时间很少。 在某一天,家务的积压可能看起来像

  • 做饭(20 分钟准备,40 分钟烹饪)
  • 洗碗(60 分钟)
  • 洗涤和烘干衣物(30 分钟洗涤,每负载 90 分钟烘干)
  • 吸尘地板(30 分钟)

如果我表现得像传统的同步程序,我会自己手动完成每项任务。 在我可以考虑处理任何其他事情之前,每项任务都需要我的全部注意力才能完成,因为没有我的积极关注,任何事情都不会完成。 所以我的执行顺序可能看起来像

  1. 完全专注于准备和烹饪食物,包括等待食物...烹饪(60 分钟)。
  2. 将脏盘子转移到水槽(65 分钟已过)。
  3. 清洗所有盘子(125 分钟已过)。
  4. 完全专注于启动洗衣,包括等待洗衣机完成,然后将衣物转移到烘干机,并等待烘干机完成(250 分钟已过)。
  5. 吸尘地板(280 分钟已过)。

从头到尾完成我的家务需要 4 小时 40 分钟。

我不应该努力工作,而应该像异步程序一样聪明地工作。 我的家里到处都是可以为我工作而无需我不断努力的机器。 与此同时,我可以将我的注意力转移到 现在 可能需要它的东西上。

我的执行顺序可能看起来像

  1. 将衣服装入洗衣机并启动洗衣机(5 分钟)。
  2. 在洗衣机运转时,准备食物(25 分钟已过)。
  3. 准备好食物后,开始烹饪食物(30 分钟已过)。
  4. 在食物烹饪时,将衣服从洗衣机移动到烘干机并启动烘干机(35 分钟已过)。
  5. 在烘干机运转且食物仍在烹饪时,吸尘地板(65 分钟已过)。
  6. 吸尘地板后,将食物从炉子上取下并装入洗碗机(70 分钟已过)。
  7. 运行洗碗机(完成后 130 分钟)。

现在我下降到 2 小时 10 分钟。 即使我允许更多的时间在作业之间切换(总共 10-20 分钟),我仍然下降到如果我等待按顺序执行每项任务所花费时间的一半左右。 这就是将您的程序构建为异步的强大之处。

那么 I/O 循环在哪里起作用呢?

异步 Python 程序的工作方式是从某个外部源(输入)获取数据,如果该过程需要,则将该数据卸载到某个外部工作器(输出)进行处理。 当外部进程完成时,主 Python 程序会收到警报。 然后,该程序会拾取外部处理的结果(输入)并继续其快乐的旅程。

如果数据没有被主 Python 程序主动使用,该主程序就可以自由地处理几乎任何其他事情。这包括等待全新的输入(例如,HTTP 请求)和处理长时间运行的进程的结果(例如,机器学习算法的结果、长时间运行的数据库查询)。主程序虽然仍然是单线程的,但变成了事件驱动型的,被程序处理的特定事件触发执行。监听这些事件并决定如何处理它们的主要工作程序就是 I/O 循环。

我知道,为了得到这个精辟的解释,我们走过了漫长的道路,但我希望传达的是,这并非魔法,也不是某种复杂的并行处理或多线程工作。全局解释器锁 (GIL) 仍然存在;主程序中的任何长时间运行的进程仍然会阻止其他任何事情的发生。该程序仍然是单线程的;但是,通过将繁琐的工作外部化,我们节省了该线程的注意力,只关注它需要关注的事情。

这有点像我上面提到的异步家务。当我的注意力完全需要用于准备食物时,我就只做这件事。但是,当我可以让炉子为我做饭,洗碗机洗我的碗,洗衣机和烘干机处理我的衣物时,我的注意力就可以解放出来去处理其他事情。当我收到警报,得知我的某个长时间运行的任务已经完成并准备好再次处理时,如果我的注意力是空闲的,我可以获取该任务的结果并执行接下来需要完成的任何操作。

Tornado 路由和视图

尽管经历了讲述 Python 中的 async 的所有麻烦,我们将暂停使用它一段时间,先编写一个基本的 Tornado 视图。

与我们在 Flask 和 Pyramid 实现中看到的*基于函数*的视图不同,Tornado 的视图都是*基于类*的。这意味着我们将不再使用单独的、独立的函数来决定如何处理请求。相反,传入的 HTTP 请求将被捕获并分配为我们定义的类的属性。然后,它的方法将处理相应的请求类型。

让我们从一个将“Hello, World”打印到屏幕上的基本视图开始。我们为 Tornado 应用程序构建的每个基于类的视图*必须*继承自 tornado.web 中找到的 RequestHandler 对象。这将设置我们需要(但不想编写)的所有底层逻辑,以接收请求并构建格式正确的 HTTP 响应。

from tornado.web import RequestHandler

class HelloWorld(RequestHandler):
    """Print 'Hello, world!' as the response body."""

    def get(self):
        """Handle a GET request for saying Hello World!."""
        self.write("Hello, world!")

因为我们希望处理 GET 请求,所以我们声明(实际上是重写)get 方法。我们不返回任何内容,而是提供文本或 JSON 可序列化对象,以便使用 self.write 写入响应正文。之后,我们让 RequestHandler 完成在发送响应之前必须完成的其余工作。

就目前而言,此视图与 Tornado 应用程序本身没有实际连接。我们必须返回到 __init__.py 并稍微更新 main 函数。这是新的热点。

# __init__.py
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.options import define, options
from tornado.web import Application
from todo.views import HelloWorld

define('port', default=8888, help='port to listen on')

def main():
    """Construct and serve the tornado application."""
    app = Application([
        ('/', HelloWorld)
    ])
    http_server = HTTPServer(app)
    http_server.listen(options.port)
    print('Listening on http://localhost:%i' % options.port)
    IOLoop.current().start()

我们做了什么?

我们在脚本顶部的 __init__.py 中从 views.py 文件导入了 HelloWorld 视图。然后,我们将路由-视图对列表作为第一个参数添加到 Application 的实例化中。每当我们想在应用程序中声明一个路由时,它*必须*与一个视图相关联。如果需要,您可以为多个路由使用相同的视图,但每个路由都必须始终有一个视图。

我们可以通过使用我们在 setup.py 中启用的 serve_app 命令运行我们的应用程序来确保这一切正常工作。检查 http://localhost:8888/ 并查看它是否显示“Hello, world!”

当然,我们可以而且将在这个领域做更多的事情,但让我们继续讨论模型。

连接数据库

如果我们想保存数据,我们需要连接一个数据库。与 Flask 一样,我们将使用 SQLAlchemy 的一个框架特定的变体,称为 tornado-sqlalchemy

为什么要使用这个而不是仅仅使用裸 SQLAlchemy?好吧,tornado-sqlalchemy 具有直接 SQLAlchemy 的所有优点,因此我们仍然可以使用通用的 Base 来声明模型,以及使用我们已经习惯的所有列数据类型和关系。除了我们从习惯中已经知道的内容之外,tornado-sqlalchemy 还为其数据库查询功能提供了一种可访问的异步模式,专门用于与 Tornado 现有的 I/O 循环一起工作。

我们通过将 tornado-sqlalchemypsycopg2 添加到 setup.py 的所需软件包列表中并重新安装该软件包来做好准备。在 models.py 中,我们声明我们的模型。此步骤看起来几乎与我们在 Flask 和 Pyramid 中已经看到的完全相同,因此我将跳过完整的类声明,只列出 Task 模型的必需内容。

# this is not the complete models.py, but enough to see the differences
from tornado_sqlalchemy import declarative_base

Base = declarative_base

class Task(Base):
    # and so on, because literally everything's the same...

我们仍然需要将 tornado-sqlalchemy 连接到实际的应用程序。在 __init__.py 中,我们将定义数据库并将其集成到应用程序中。

# __init__.py
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.options import define, options
from tornado.web import Application
from todo.views import HelloWorld

# add these
import os
from tornado_sqlalchemy import make_session_factory

define('port', default=8888, help='port to listen on')
factory = make_session_factory(os.environ.get('DATABASE_URL', ''))

def main():
    """Construct and serve the tornado application."""
    app = Application([
        ('/', HelloWorld)
    ],
        session_factory=factory
    )
    http_server = HTTPServer(app)
    http_server.listen(options.port)
    print('Listening on http://localhost:%i' % options.port)
    IOLoop.current().start()

与我们在 Pyramid 中传递的会话工厂非常相似,我们可以使用 make_session_factory 来接收数据库 URL 并生成一个对象,该对象的唯一目的是为我们的视图提供与数据库的连接。然后,我们通过将新创建的 factorysession_factory 关键字参数一起传递到 Application 对象中,将其与我们的应用程序联系起来。

最后,初始化和管理数据库看起来与 Flask 和 Pyramid 的方式相同(即,单独的 DB 管理脚本,相对于 Base 对象工作等)。它看起来非常相似,以至于我不会在这里重现它。

重新审视视图

对于学习基础知识来说,Hello, World 总是很好的,但我们需要一些真正的、特定于应用程序的视图。

让我们从 info 视图开始。

# views.py
import json
from tornado.web import RequestHandler

class InfoView(RequestHandler):
    """Only allow GET requests."""
    SUPPORTED_METHODS = ["GET"]

    def set_default_headers(self):
        """Set the default response header to be JSON."""
        self.set_header("Content-Type", 'application/json; charset="utf-8"')

    def get(self):
        """List of routes for this API."""
        routes = {
            'info': 'GET /api/v1',
            'register': 'POST /api/v1/accounts',
            'single profile detail': 'GET /api/v1/accounts/<username>',
            'edit profile': 'PUT /api/v1/accounts/<username>',
            'delete profile': 'DELETE /api/v1/accounts/<username>',
            'login': 'POST /api/v1/accounts/login',
            'logout': 'GET /api/v1/accounts/logout',
            "user's tasks": 'GET /api/v1/accounts/<username>/tasks',
            "create task": 'POST /api/v1/accounts/<username>/tasks',
            "task detail": 'GET /api/v1/accounts/<username>/tasks/<id>',
            "task update": 'PUT /api/v1/accounts/<username>/tasks/<id>',
            "delete task": 'DELETE /api/v1/accounts/<username>/tasks/<id>'
        }
        self.write(json.dumps(routes))

那么,发生了什么变化?让我们从上到下开始。

添加了 SUPPORTED_METHODS 类属性。这将是一个仅包含此视图接受的请求方法的迭代。任何其他方法都将返回 405 状态代码。当我们创建 HelloWorld 视图时,我们没有指定此属性,主要是因为懒惰。如果没有此类属性,此视图将响应任何尝试访问与该视图关联的路由的请求。

声明了 set_default_headers 方法,该方法设置传出 HTTP 响应的默认标头。我们在此处声明此方法以确保我们发回的任何响应的 "Content-Type" 都为 "application/json"

我们将 json.dumps(some_object) 添加到 self.write 的参数中,因为它使构造传出响应正文的内容变得容易。

现在完成了,我们可以继续将其连接到 __init__.py 中的主页路由。

# __init__.py
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.options import define, options
from tornado.web import Application
from todo.views import InfoView

# add these
import os
from tornado_sqlalchemy import make_session_factory

define('port', default=8888, help='port to listen on')
factory = make_session_factory(os.environ.get('DATABASE_URL', ''))

def main():
    """Construct and serve the tornado application."""
    app = Application([
        ('/', InfoView)
    ],
        session_factory=factory
    )
    http_server = HTTPServer(app)
    http_server.listen(options.port)
    print('Listening on http://localhost:%i' % options.port)
    IOLoop.current().start()

正如我们所知,需要编写更多的视图和路由。每个视图和路由都将根据需要放入 Application 路由列表中。每个视图和路由也都需要一个 set_default_headers 方法。最重要的是,我们将创建我们的 send_response 方法,其工作是将我们的响应与我们想要为给定响应设置的任何自定义状态代码一起打包。由于每个视图都需要这两种方法,我们可以创建一个包含它们的基类,我们的每个视图都可以从该基类继承。这样,我们只需要编写一次。

# views.py
import json
from tornado.web import RequestHandler

class BaseView(RequestHandler):
    """Base view for this application."""

    def set_default_headers(self):
        """Set the default response header to be JSON."""
        self.set_header("Content-Type", 'application/json; charset="utf-8"')

    def send_response(self, data, status=200):
        """Construct and send a JSON response with appropriate status code."""
        self.set_status(status)
        self.write(json.dumps(data))

对于像我们即将编写的 TaskListView 这样的视图,我们还需要连接到数据库。我们将需要 tornado_sqlalchemySessionMixin 在每个视图类中添加一个数据库会话。我们可以将其折叠到 BaseView 中,以便默认情况下,每个继承自它的视图都可以访问数据库会话。

# views.py
import json
from tornado_sqlalchemy import SessionMixin
from tornado.web import RequestHandler

class BaseView(RequestHandler, SessionMixin):
    """Base view for this application."""

    def set_default_headers(self):
        """Set the default response header to be JSON."""
        self.set_header("Content-Type", 'application/json; charset="utf-8"')

    def send_response(self, data, status=200):
        """Construct and send a JSON response with appropriate status code."""
        self.set_status(status)
        self.write(json.dumps(data))

只要我们修改这个 BaseView 对象,我们就应该解决一个在考虑将数据发布到这个 API 时会出现的怪癖。

当 Tornado(从 v.4.5 开始)使用来自客户端的数据并将其组织起来以供应用程序使用时,它会将所有传入数据保存为字节串。但是,此处的所有代码都假定使用 Python 3,因此我们想要使用的唯一字符串是 Unicode 字符串。我们可以向这个 BaseView 类添加另一个方法,其工作是在视图中的任何其他地方使用传入数据之前将其转换为 Unicode。

如果我们想在正确的视图方法中使用这些数据之前转换它们,我们可以重写视图类的本机 prepare 方法。它的工作是在视图方法运行之前运行。如果我们重写 prepare 方法,我们可以设置一些逻辑来运行,以便在收到请求时进行字节串到 Unicode 的转换。

# views.py
import json
from tornado_sqlalchemy import SessionMixin
from tornado.web import RequestHandler

class BaseView(RequestHandler, SessionMixin):
    """Base view for this application."""

    def prepare(self):
        self.form_data = {
            key: [val.decode('utf8') for val in val_list]
            for key, val_list in self.request.arguments.items()
        }

    def set_default_headers(self):
        """Set the default response header to be JSON."""
        self.set_header("Content-Type", 'application/json; charset="utf-8"')

    def send_response(self, data, status=200):
        """Construct and send a JSON response with appropriate status code."""
        self.set_status(status)
        self.write(json.dumps(data))

如果存在任何传入数据,它将在 self.request.arguments 字典中找到。我们可以按键访问该数据并将其内容(始终是一个列表)转换为 Unicode。因为这是一个基于类的视图而不是基于函数的视图,所以我们可以将修改后的数据存储为一个实例属性以供以后使用。我在这里称它为 form_data,但也可以很容易地称为 potato。关键是我们可以在应用程序中存储已提交的数据。

异步视图方法

现在我们已经构建了我们的 BaseView,我们可以构建将从中继承的 TaskListView

正如您可能从本节标题中看出的那样,这就是所有关于异步的讨论的切入点。TaskListView 将处理用于返回任务列表的 GET 请求和用于创建给定一些表单数据的新任务的 POST 请求。让我们首先看看处理 GET 请求的代码。

# all the previous imports
import datetime
from tornado.gen import coroutine
from tornado_sqlalchemy import as_future
from todo.models import Profile, Task

# the BaseView is above here
class TaskListView(BaseView):
    """View for reading and adding new tasks."""
    SUPPORTED_METHODS = ("GET", "POST",)

    @coroutine
    def get(self, username):
        """Get all tasks for an existing user."""
        with self.make_session() as session:
            profile = yield as_future(session.query(Profile).filter(Profile.username == username).first)
            if profile:
                tasks = [task.to_dict() for task in profile.tasks]
                self.send_response({
                    'username': profile.username,
                    'tasks': tasks
                })

这里的第一个主要部分是从 tornado.gen 导入的 @coroutine 装饰器。任何与调用堆栈的正常流程不同步运行的 Python 可调用对象实际上都是“协程”;一种可以与其他例程一起运行的例程。在我的家务示例中,几乎每项家务都是一个协程。有些是阻塞例程(例如,吸尘地板),但该例程只是阻止我开始或处理任何其他事情。它并没有阻止任何已经启动的其他例程继续进行。

Tornado 提供了许多构建利用协程的应用程序的方法,包括允许我们设置对函数调用的锁、同步异步例程的条件以及手动修改控制 I/O 循环的事件的系统。

@coroutine 装饰器在这里的唯一用途是允许 get 方法将 SQL 查询作为后台进程分派出去,并在查询完成后恢复,同时不阻止 Tornado I/O 循环处理来自其他来源的传入数据。这就是此实现中所有“异步”的内容:带外数据库查询。显然,如果我们想展示异步 Web 应用程序的魔力和奇迹,那么待办事项列表并不是一个好方法。

但是,嘿,这就是我们正在构建的东西,所以让我们看看我们的方法如何利用该 @coroutine 装饰器。SessionMixin 被混合到 BaseView 声明中,为我们的视图类添加了两个方便的、与数据库相关的属性:sessionmake_session。它们的名字很相似,并且实现了相当相似的目标。

self.session 属性是一个与数据库关联的会话。在请求-响应周期的结束,也就是在视图将响应发送回客户端之前,所有对数据库的更改都会被提交,并且会话会被关闭。

self.make_session 是一个上下文管理器和生成器,它会动态地构建并返回一个全新的会话对象。最初的 self.session 对象仍然存在; make_session 会创建一个新的对象。make_session 生成器还将提交和关闭其创建的会话的逻辑内置于其中,一旦其上下文(即,缩进级别)结束,就会执行这些逻辑。

如果您检查源代码,分配给 self.session 的对象类型与 self.make_session 生成的对象类型之间没有区别。区别在于它们的管理方式。

使用 make_session 上下文管理器,生成的会话仅属于该上下文,在该上下文中开始和结束。您可以使用 make_session 上下文管理器在同一视图中打开、修改、提交和关闭多个数据库会话。

self.session 更加简单,当您进入您的视图方法时,会话已经打开,并且在响应发送回客户端之前提交。

虽然 官方文档片段PyPI 示例 都指定了上下文管理器的使用,但 self.session 对象或 self.make_session 生成的 session 对象本身都不是异步的。当我们启动查询时,我们才开始考虑内置于 tornado-sqlalchemy 中的异步行为。

tornado-sqlalchemy 包为我们提供了 as_future 函数。as_future 的工作是包装由 tornado-sqlalchemy 会话构建的查询,并产生它的返回值。如果视图方法使用 @coroutine 装饰器装饰,那么使用 yield as_future(query) 模式将使您的包装查询成为异步后台进程。I/O 循环接管,等待查询的返回值和由 as_future 创建的 future 对象的解析。

要访问 as_future(query) 的结果,您必须 yield 它。否则,您只会得到一个未解析的生成器对象,并且无法对查询做任何操作。

此视图方法中的其他一切几乎都是正常的,反映了我们在 Flask 和 Pyramid 中已经看到的。

post 方法看起来会非常相似。为了保持一致性,让我们看看 post 方法是什么样的,以及它如何处理使用 BaseView 构建的 self.form_data

@coroutine
def post(self, username):
    """Create a new task."""
    with self.make_session() as session:
        profile = yield as_future(session.query(Profile).filter(Profile.username == username).first)
        if profile:
            due_date = self.form_data['due_date'][0]
            task = Task(
                name=self.form_data['name'][0],
                note=self.form_data['note'][0],
                creation_date=datetime.now(),
                due_date=datetime.strptime(due_date, '%d/%m/%Y %H:%M:%S') if due_date else None,
                completed=self.form_data['completed'][0],
                profile_id=profile.id,
                profile=profile
            )
            session.add(task)
            self.send_response({'msg': 'posted'}, status=201)

正如我所说,这是我们所期望的

  • 与我们在 get 方法中看到的相同的查询模式
  • 构造一个新的 Task 对象的实例,并使用来自 form_data 的数据填充它
  • 将新的 Task 对象添加到数据库会话(但不提交,因为它由上下文管理器处理!)
  • 将响应发送回客户端

因此,我们有了 Tornado Web 应用程序的基础。其他一切(例如,数据库管理和用于更完整的应用程序的更多视图)实际上与我们在 Flask 和 Pyramid 应用程序中已经看到的相同。

关于使用正确的工具来完成正确的工作的思考

当我们继续研究这些 Web 框架时,我们开始看到它们都可以有效地处理相同的问题。对于像这个 To-Do List 这样的东西,任何框架都可以完成这项工作。然而,根据 “更合适” 对您和您的需求意味着什么,一些 Web 框架比其他框架更适合某些工作。

虽然 Tornado 显然能够处理 Pyramid 或 Flask 可以处理的相同工作,但将其用于像这样的应用程序实际上是一种浪费。这就像使用汽车从家行驶一个街区。是的,它可以完成 “旅行” 的工作,但是短途旅行并不是您选择使用汽车而不是自行车或仅仅使用您的脚的原因。

根据文档,Tornado 被描述为 “一个 Python Web 框架和异步网络库。” 在 Python Web 框架生态系统中,很少有这样的框架。如果您尝试完成的工作需要(或将从)任何形式的异步性中受益匪浅,请使用 Tornado。如果您的应用程序需要处理多个长期连接,同时又不会牺牲太多性能,请选择 Tornado。如果您的应用程序是多个应用程序合一,并且需要线程感知以准确处理数据,请使用 Tornado。这是它工作最好的地方。

使用你的汽车做 “汽车的事情”。使用其他交通方式来做其他一切。

展望未来和一个小小的视角检查

说到使用正确的工具来完成正确的工作,在选择框架时,请记住您的应用程序的范围和规模,无论是现在还是将来。到目前为止,我们只研究了用于中小型 Web 应用程序的框架。本系列的下一个也是最后一个部分将介绍最流行的 Python 框架之一,Django,它是为可能变得更大的大型应用程序设计的。同样,虽然它在技术上可以并且将会处理 To-Do List 问题,但请记住,这实际上不是该框架的用途。我们仍然会尽最大努力展示如何使用它构建应用程序,但我们必须记住框架的意图以及它如何反映在其架构中

  • Flask: 适用于小型、简单的项目;使我们能够轻松地构建视图并将它们快速连接到路由;可以封装在单个文件中而无需太多麻烦
  • Pyramid: 适用于可能增长的项目;包含相当多的配置才能启动和运行;应用程序组件的单独领域可以轻松地划分和构建到任意深度,而不会失去对中心应用程序的关注
  • Tornado: 适用于从精确和谨慎的 I/O 控制中受益的项目;允许协程并轻松公开可以控制如何接收请求/发送响应以及何时发生这些操作的方法
  • Django: (正如我们将看到的)适用于可能变得更大的大型事物;大量的插件和 mods 生态系统;在其配置和管理方面非常有主见,以保持所有不同的部分井然有序

无论您是从本系列的第一篇文章开始阅读还是稍晚加入,感谢您的阅读!请随时留下问题或评论。下次见,我将带上 Django。

向 Python BDFL 致敬

我必须给予应有的荣誉。非常感谢 Guido van Rossum,不仅仅是因为他创造了我最喜欢的编程语言。

PyCascades 2018 期间,我很幸运不仅可以发表本系列文章所基于的演讲,还可以被邀请参加演讲者晚宴。我整晚都坐在 Guido 旁边,并向他提出问题。其中一个问题是 async 在 Python 中是如何工作的,他毫不费力地花时间以我能开始掌握这个概念的方式向我解释它。他后来 在推特上 向我推荐了一个学习 Python 异步的绝佳资源,我随后在三个月内阅读了三遍,然后写了这篇文章。你真是个了不起的家伙,Guido!

10 条评论

接受使用条款。

你好!
我遇到了一个错误
(tornado_todo-Q6AvqvVo) dengolius@aspire ~/tornado_todo $ serve_app
serve_app:找不到命令

我必须做什么才能解决这个问题?

serve_app 不适用于这些配置 - 找不到命令

我运行了你写的所有命令,但你没有将这个命令添加到你的手册中(所以不,我没有使用那些命令安装该项目...

回复 作者 nhuntwalker

如果这不清楚,我深表歉意。我在本系列早些时候的一篇文章中介绍了这一点(这是第 3 篇文章),所以我选择这次跳过这个细节。

回复 作者 Denis G.

很棒的博客,几乎是异步 Web API 后端的资源! 我有兴趣在上面开发一些包装器,以使事情变得更容易! :D

谢谢你的指导。
我只想更正该指南对 sqlalchemy-tornado 模块的解释。
上下文管理器不会在最后提交对数据库的更改。我在测试我的应用程序时发现了它。
如果您希望应用更改,您仍然应该自己调用 session.commit()。(我认为它也应该用 as_future() 包装)
因此,我更喜欢使用作为请求-self 对象成员的会话。
(所以,不要忘记您应该在请求处理完成时手动关闭它(处理程序类的 on_finish() 方法可以帮助您))

Creative Commons License本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.