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 循环。

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

这有点像我上面说的异步家务。当我需要全神贯注地准备食物时,我就只做这件事。但是,当我可以让炉子帮我做饭,洗碗机洗我的碗,洗衣机和烘干机处理我的衣物时,我的注意力就可以解放出来去做其他的事情。当我收到其中一个长时间运行的任务已经完成并可以再次处理的通知时,如果我空闲,我就可以拿起该任务的结果并完成接下来需要做的任何事情。

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()

我们做了什么?

我们将 HelloWorld 视图从 views.py 文件导入到 __init__.py 文件的顶部。然后,我们将路由-视图对列表作为实例化 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 并生成一个对象,该对象的唯一目的是为我们的视图提供与数据库的连接。 然后,我们通过使用 session_factory 关键字参数将新创建的 factory 传递给 Application 对象,将其绑定到我们的应用程序中。

最后,初始化和管理数据库将与 Flask 和 Pyramid 的方式相同(即,单独的数据库管理脚本,与 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
                })

这里的主要部分是 @coroutine 装饰器,它是从 tornado.gen 导入的。 任何与正常调用堆栈流程不同步的 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 则简单得多,当你进入视图方法时,会话已经打开,并且在响应发送回客户端之前进行提交。

虽然 read the docs 代码片段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:(正如我们将看到的)适用于可能变得更大的大型事物; 大量的附加组件和修改; 在其配置和管理方面非常固执己见,以使所有不同的部分保持一致

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

向 Python BDFL 致敬

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

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

10 条评论

接受使用条款。

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

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

serve_app 不能与这些配置一起使用 - 未找到命令

你是否使用“pipenv install -e .”安装了该项目? 除非你安装该项目,否则你将不会有该命令。

回复 by Denis G.

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

回复 by nhuntwalker

如果这不清楚,我深感抱歉。 我在本系列的前一篇帖子(这是第 3 篇帖子)中已经介绍了这一点,所以我选择这次跳过该细节。

回复 by Denis G.

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

感谢你的指南。
我只想更正 sqlalchemy-tornado 模块的指南说明。
上下文管理器不会在结束时将更改提交到数据库。 我在测试我的应用程序时发现了它。
如果要应用更改,你无论如何都应该自己调用 session.commit()。(我认为它也应该用 as_future() 包装)
因此,我更喜欢使用作为请求自我对象成员的会话。
(所以,不要忘记你应该在请求处理结束时手动关闭它(处理程序类的 on_finish() 方法可以帮助你))

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