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 中的异步,但我们将暂时不使用它,而是先编写一个基本的 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 并生成一个对象,该对象的唯一目的是为我们的视图提供与数据库的连接。然后,我们通过将新创建的 factory 通过 session_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"

我们向 self.write 的参数添加了 json.dumps(some_object),因为它使构建传出响应体的内容变得容易。

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

# __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 应用程序的魔力和奇迹,那么 To-Do List 并不是一个好的选择。

但是,这就是我们正在构建的,所以让我们看看我们的方法如何利用 @coroutine 装饰器。被混合到 BaseView 声明中的 SessionMixin 向我们的视图类添加了两个方便的、数据库感知的属性:sessionmake_session。它们的名称相似,并且完成的目标非常相似。

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

self.make_session 是一个上下文管理器和生成器,它会动态地构建并返回一个全新的会话对象。最初的 self.session 对象仍然存在;make_session 无论如何都会创建一个新的会话。make_session 生成器还在内部包含了提交和关闭它所创建的会话的逻辑,一旦它的上下文(即,缩进级别)结束。

如果你检查源代码,会发现赋值给 self.session 的对象类型和 self.make_session 生成的对象类型没有区别。区别在于它们的管理方式。

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

self.session 则简单得多,当你到达你的视图方法时,会话已经打开,并在将响应发送回客户端之前提交。

虽然 readthedocs 上的代码片段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: command not found

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

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

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

回复 的评论,作者是 Denis G.

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

回复 的评论,作者是 nhuntwalker

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

回复 的评论,作者是 Denis G.

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

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

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