Tornado:基于协程实现的老牌网络框架

楔子

随着 asyncio 的出现,各种基于协程的网络框架层出不穷,比如 FastAPI、Sanic,并且这两个框架我都有介绍。但是这两个框架在工作中使用的还不是很广泛,说到协程框架,大部分企业用的还是 Tornado,所以我觉得学习 Tornado 还是有必要的,并且 Tornado 也是我早期比较喜欢的一个框架。

其实最主要的原因是,我本人正在研究 Twisted,而 Tornado 在设计上面和它有着相似之处,只不过 Twisted 更加偏向底层,比如 Twisted 也可以编写 Web 服务,但是我们一般不会拿 Twisted 去写。所以准备先介绍一下 Tornado,之后再介绍 Twisted 会轻松一些,毕竟 Twisted 的难度还是有的。

那么什么是 Tornado 呢?它是基于 Python 语言编写的一个全栈式(full-stack)Web 框架和异步网络库,通过使用非阻塞 IO,Tronado 可以同时处理数以万计的连接,是开发长轮询、WebSocket等应用的理想选择。

Tronado 的特点如下:

  • 开源的轻量级全栈式 Web 框架,提供了一整套完善的异步编码方案;

  • 基于协程,并提供 IOLoop 事件循环来处理 socket 读写,IOLoop 封装了 Linux 的 epoll,具有出色的抗负载能力。这里需要注意的是,在 Python 提供原生协程之前 Tornado 就已经存在了,当时 Tornado 是基于生成器实现的协程,并自己提供了一套事件循环调度机制。但随着原生协程的出现,以及 asyncio 的流行,Tornado 也开始支持原生协程,并且 IOLoop 底层也直接使用了 asyncio;

  • WSGI 全栈替代产品,Tornado 将应用(Application)和服务器(Server)结合了起来,既是 WSGI 应用也是 WSGI 服务。通俗来讲,Tornado 既是 Web 服务器也是 Web 框架,甚至可以通过 Tornado 来运行 Django、Flask 服务。

下面我们来安装 Tornado,直接 pip install tornado==6.0.3 即可。我这里的 Python 版本是 3.8,Tornado 版本是 6.0.3。

Tornado 服务的基本结构

先来使用 Tornado 编写一个简单的服务,看看是什么样子的。

# tornado 提供的 Web 框架模块
# 该模块实现了路由映射、视图类等功能
from tornado import web
# tornado 提供的事件循环模块
# 它封装了 Linux 的 epoll 和 BSD 的 kqueue
from tornado import ioloop
# tornado 提供的单线程、非阻塞的 http 服务器模块
from tornado import httpserver


class IndexHandler(web.RequestHandler):
    """
    处理请求的视图类,在 tornado 里面就叫做 handler
    我们在定义视图类的时候,必须继承 RequestHandler
    然后写请求对应的方法,比如:
        def get(self) -> 处理 get 请求
        def post(self) -> 处理 post 请求
        def put(self) -> 处理 put 请求
        def patch(self) -> 处理 patch 请求
        def delete(self) -> 处理 delete 请求
    """
    def get(self):
        # self.write 会将数据写到缓冲区
        # 当方法执行完毕之后,再将缓冲区里面的数据返回给客户端
        self.write("hi, boy")


# 实例化一个 APP 对象,将 URL 和 Handler 绑定在一起
app = web.Application(
    [
        (r"/", IndexHandler)
    ]
)
# 传入 app,创建一个 HTTP 服务器
server = httpserver.HTTPServer(app)
# 基于端口和 IP 创建 socket,虽然方法叫 listen
# 但内部只是创建了 socket,并没有开启监听
server.listen(9000, "0.0.0.0")
"""
由于我们应用程序很少会和 HTTPServer 进行交互,我们不需要显式地创建它
所以上面的两步等价于 app.listen(9000, "0.0.0.0")
而在 app.listen 里面也是先实例化一个 HTTPServer 实例,然后调用 listen 方法
"""
# 返回当前线程的 IOLoop 实例
loop = ioloop.IOLoop.current()
# 启动内部事件循环,同时开启监听
loop.start()

我们启动之后,在浏览器输入 localhost:9000 即可看到输出。然后我们代码中创建的 server 相当于是服务端,那么浏览器就是客户端。

然后我们再来说说 IOLoop,在调用 IOLoop.current 的时候会返回一个 IOLoop 实例,也就是事件循环。当调用 start 时,这个循环就启动了,会不停地监听 socket 读写事件。当然啦,IOLoop 实际上封装了 Linux 的 epoll,具体的监听都是 epoll 来做的,epoll 相当于是 IOLoop 实例的一个管家。

事件循环启动之后会创建一个用于监听的 socket,注意这个 socket 只用于监听。但有客户端到来时,这个 socket 负责与其建立连接。当连接建立完毕,那么会创建一个新的 socket,假设叫 socket1,然后将连接交给 socket1,后续 socket1 来负责和客户端进行消息的收发。

假设这时又来了一个客户端,那么监听的 socket 继续负责建立连接,连接建立之后又会创建新的 socket2,然后 socket2 和客户端进行通信。

也就是说,监听的 socket 只负责连接的建立,至于消息的收发会交给别的 socket 去做。假设同时来了 10 个客户端,那么会有 11 个 socket,其中 1 个 socket 还是负责监听,剩余的 10 个 socket 则负责和到来的 10 个客户端进行通信。

然而无论有多少个 socket,它们都是由 epoll 负责管理,IOLoop 所做的事情就是不断地询问 epoll,有没有已经就绪、需要处理的事件发生。如果没有,就啥也不做;有的话,就去处理。比如某个客户端发消息了,那么 epoll 会通知 IOLoop 实例有事件发生,那么事件循环就会获取客户端发送的消息,并且解析出请求的 URL 和参数。然后进行路由映射,找到对应的 handler,执行相关的请求,然后返回响应给客户端,实现服务端和客户端之间的 HTTP 通信。

以上就是 Tornado 服务的基本结构,并且整个过程只有一个进程、一个线程,那么问题来了, Tornado 是怎么实现的高性能?其实核心就在于 IOLoop 实例、也就是事件循环,它封装了 Linux 的 epoll。通过 IO 多路复用,单线程也可以监听大量的文件描述符(参考 Redis),只要有消息,就去处理。而且事件循环在处理数据的时候,如果发生异步阻塞,还可以将执行权交给事件循环,然后事件循环去处理别的事件,从而不浪费 CPU。

另外,我们说在 Python 还没有提供 asyncio 的时候,Tornado 是自己实现了一套事件循环机制,但是在高版本的 Tornado 中,底层则是直接使用了 asyncio 的事件循环。

从源码中我们可以清晰的看到,确实是使用了 asyncio 的事件循环,当然我们还可以用另一种方式印证这一点。

from tornado import ioloop

async def foo():
    print(123)

loop = ioloop.IOLoop.current()
loop.run_sync(foo)
"""
123
"""

里面的 loop 是使用 Tornado 的 IOLoop 创建的,它可以执行 Python 的协程。

import asyncio
from tornado import web
from tornado import httpserver

class IndexHandler(web.RequestHandler):
    def get(self):
        self.write("hi, boy")

app = web.Application(
    [
        (r"/", IndexHandler)
    ]
)

server = httpserver.HTTPServer(app)
server.listen(9000, "0.0.0.0")
# 基于 asyncio 创建事件循环
loop = asyncio.get_event_loop()
# 启动事件循环
loop.run_forever()

此时启动之后访问 localhost:9000 也能得到返回,说明 asyncio 的事件循环启动 Tronado 服务。因此这两个栗子充分说明了,高版本的 Tornado 使用的就是 asyncio 的事件循环。

所以还是很好理解的,但不管采用哪一种事件循环,自己早期实现的也好,asyncio 提供的也罢。本质上都是对 epoll 的封装,借助于 epoll 实现单线程管理大量文件描述符,从而实现高并发。

然后再来说一说多进程的问题,虽然通过事件循环实现了单线程管理多个文件描述符、以及异步非阻塞 IO 实现任务的切换,但终归只有一个核。如果我们想利用多核的话,该怎么做呢?

app = web.Application(
    [
        (r"/", IndexHandler)
    ]
)

# app.listen 相当于先创建一个 HTTPServer,然后再调用 server.listen 方法
# 但其实 server.listen 也可以使用 server.bind 替代,并且支持的参数更多
server = httpserver.HTTPServer(app)

# 基于端口和 IP 创建 socket,并加入到 Server 中,使得它能够在指定的端口上接收连接
# 但它支持的参数更多,比如还可以指定 backlog 和 reuse_port
# 我们知道 TCP 在完成三次握手之后,会将连接从半连接队列移动到全连接队列中
# backlog 指定的就是全连接队列的大小,显然该参数会被传递到 socket.listen 中
# reuse_port 则表示是否支持多个线程绑定在同一个端口上,Windows 上不支持
server.bind(9999, "0.0.0.0", backlog=128, reuse_port=False)
# 所以 server.bind,名字起得更加合适,因为这个过程本来只是创建了一个 socket
# 而 server.listen 会给人一种已经开启监听的错觉,但实际上这一步根本没有开启监听
# 当然啦,无论是 bind 还是 listen 都可以,这里额外补充一下

# 然后开启多个进程,可以通过 server.start 实现
# 如果大于等于 1,那么会创建指定数量的进程
# 如果小于等于 0 或者为 None,那么会获取可用的 CPU 核心数、创建该数量的进程
server.start(2)

所以只需要通过 server.start 即可开启指定数量的进程,而在创建子进程的时候会调用 os.fork,但 Windows 上 os 模块里面没有这个函数。因此在 Windows 平台上,我们无法通过这种方式来启动多进程,当我们调用完 app.listen、或者 server.listen、或者 server.bind 之后,直接启动事件循环即可。

而 Linux 平台则是支持的,但很明显,即便支持,我们也不会通过这种方式来开启多进程。原因如下:

  • 所有进程都是由一个命令启动的,无法在不停止服务的情况下修改代码
  • 所有进程共享一个端口,想要分别监控非常困难

至于 Tornado 如何启动多进程服务,我们后面再聊。总之以上就是 Tornado 服务的基本结构,然后我们再来介绍 Tornado 的其它功能。

从命令行和配置文件中解析参数

目前的端口等信息都是写死的,而 Tornado 提供了一个模块 options,可以支持我们从命令行中传递参数。作用类似于 optparser、argparser 等模块,我们来看一下。

from tornado import options

# options.define 常用参数如下
"""
name: 选项的变量名,必须保证唯一性
default: 默认值,如果不指定,则使用默认值
type: 类型,从命令行获取到的都是字符串,如果指定了 type,那么会进行转换
      如果没有指定 type,那么会按照 default 的类型进行转换
multiple: 设置选项变量是否支持多个值,默认 False
"""

options.define("host")
# 在命令行中如果不指定选项变量 port,则会使用默认值 9000,这是一个整型
# 但要是显式地指定了,比如指定了 8000,那么首先会得到字符串 "8000"
# 然后会再看是否指定了 type,如果指定了,则进行转化
# 比如 type=list,会得到 ['8', '0', '0', '0']
# 如果没有指定 type,那么会按照 default 的类型进行转化,所以这里会得到整型 8000
# 如果也没有指定 default,那么得到的就是字符串了
options.define("port", default=9000)
# multiple 默认是 False,也就是一个选项变量只能接收一个值
# 指定为 True,则表示可以接收多个值,此时会得到一个列表
options.define("args", default=[], multiple=True)

# 定义的选项变量都会成为 options.options 的一个属性
# 然后从命令行解析参数
options.parse_command_line()
print(options.options.host)
print(options.options.port)
print(options.options.args)

我们来演示一下:

# 通过 ` --选项=值 ` 的方式指定即可
# 没有指定 port,那么使用默认值 9000
# 如果是接收多个值,需要使用逗号分割,并且多个值之间不能有空格
python main.py --host=0.0.0.0 --args=a,b,c,d
0.0.0.0
9000
['a', 'b', 'c', 'd']

# 包含了空格,那么就会出问题
python main.py --host=0.0.0.0 --args=a, b, c, d
0.0.0.0
9000
['a', '']

# 但可以通过引号来解决这一问题
python main.py --host=0.0.0.0 --args="a, b, c, d"
0.0.0.0
9000
['a', ' b', ' c', ' d']

# host 和 args 指定的值一样,但定义 host 时 multiple 参数为 False
# 并且没有 type 和 default,所以它就是一个字符串
# 但定义 args 时 multiple 参数为 True,所以它是一个列表
python main.py --host=0,0,0,0 --args=0,0,0,0
0,0,0,0
9000
['0', '0', '0', '0']

# 即便 args 只有一个值,也会得到一个列表
python main.py --host=0.0.0.0 --port=8888 --args=0.0.0.0
0.0.0.0
8888
['0.0.0.0']

还是比较简单的,可以自己测试一下,写一写。其实在命令行解析上面,argparser 模块比 tornado 提供的 options 模块要更强大一些。但 options 模块还有一个特点,就是它除了支持从命令行解析参数之外,还支持从配置文件解析。我们定义一个配置文件,就叫 config.py 吧。

host = "0.0.0.0"
port = 9000
args = ["satori", "koishi", "marisa"]

以上是配置文件 config.py,我们将参数信息写到了里面,然后看看 Tornado 如何加载。

from tornado import options

options.define("host")
options.define("port", default=9000)
options.define("args", default=[], multiple=True)

# 从配置文件解析参数
options.parse_config_file("config.py")

print(options.options.host)  # 0.0.0.0
print(options.options.port)  # 9000
print(options.options.args)  # ['satori', 'koishi', 'marisa']

parse_config_file 函数接收的是文件路径,会将文件里面的内容读取出来,通过内置函数 exec 进行执行。所以即使配置文件不以 .py 结尾也可以,但是文件里面的内容必须符合 Python 代码规范。

以上就是 Tornado 解析参数的两种方式,但说实话都不是很常用,尤其是第二种。我既然都已经定义在配置文件里面了,那么直接将配置文件作为模块 import 进来不可以吗?

tornado.web.Application

这里再多提一下 Application 这个类,它负责保存 URL 和 handler 之间的映射关系,但它还支持一些其它参数。

from tornado import web
from tornado import ioloop
from tornado import httpserver


class IndexHandler(web.RequestHandler):

    def get(self):
        self.write("hi, boy")


# Application 里面还支持一些其它参数
settings = {
    # 文件修改时,是否自动重新加载
    "autoreload": True,
    # 是否缓存模板文件,这里主要针对模板渲染的
    # 如果设置为 True,会发现 html 模板修改之后,没有生效
    # 原因就是显示的还是之前的模板文件
    "compiled_template_cache": False,
    # 是否缓存静态文件
    # 如果为 True,会发现 css 样式修改之后在一段时间内不生效
    # 但说实话,现在基本都是前后端分离
    # 页面展示由专门的前端负责,后端只需要返回数据即可
    # 因此 compiled_template_cache 和 static_hash_cache 基本不用了
    "static_hash_cache": False,
    # 是否提供追踪信息,当 handler 出现异常时
    # 会生成一个包含追踪信息的页面
    "serve_traceback": True
}

# 我们以关键字参数的形式传递即可
app = web.Application(
    [(r"/", IndexHandler)],
    **settings
)
# 注意:如果传递了 debug=True,效果和上面的四个参数是等价的
# 因为在 Tornado 的源码中有这样一段逻辑
"""
    if self.settings.get("debug"):
        self.settings.setdefault("autoreload", True)
        self.settings.setdefault("compiled_template_cache", False)
        self.settings.setdefault("static_hash_cache", False)
        self.settings.setdefault("serve_traceback", True)
"""

server = httpserver.HTTPServer(app)
server.listen(9000, "0.0.0.0")
ioloop.IOLoop.current().start()

还是比较简单的,然后再来说一下路由的问题,我们在调用 Application 的时候传递了一个列表,列表里面的元素都是元组。元组的第一个元素是 URL、第二个元素是 handler,所以一个元组可以暂时理解为一个 URL 和一个 handler 之间的映射(也就是所谓的路由)。但列表里面的元素除了可以是元组之外,还可以是 web.url 对象。

app = web.Application(
    [
        # (r"/", IndexHandler) 也可以写成 web.url(r"/", IndexHandler)
        # 并且指定路由时,我们还可以给路由起一个名字
        web.url(r"/", IndexHandler, name="哼哼"),
        # 当然啦,不管哪种方式,其实都是调用了 routing.Rule
        # 所以我们直接使用 routing.Rule 也是可以的
        # 但是使用 routing.Rule 时,必须用 routing.PathMatches 将 URL 包装一下
        # 然后会返回 re.compile(URL),因为 URL 里面可能会涉及到正则
        # 不过在工作中,我们很少会使用 routing.Rule,因为 URL 还需要额外包装一下,比较麻烦
        # 基本上都用 web.url,或者元组,然后基于它们构建 routing.Rule 时,会自动帮我们处理好一切
        routing.Rule(routing.PathMatches(r"/index"), IndexHandler, name="哈哈")
    ],
    # 设置 debug=True,等于之前的 **settings
    debug=True
)

此时我们访问 / 和访问 /index 都可以,页面显示的内容是相同的。使用方法我们知道了,然后再来深入一下,看看 URL 和视图类(handler)是如何绑定在一起的。

首先我们传给 Application 的第一个参数(参数名为 handlers),在它的 __init__ 方法内部,又传给了 _ApplicationRouter 这个类,并进行了调用。

所以两者的绑定实际上发生在 _ApplicationRouter 里面,但这个类继承了一个父类,并且它的父类又继承了新的父类,一层接一层。如果一层层的找下去的话,最终我们会找到 routing.RuleRouter 这个类,在里面会看到如下逻辑:

整个过程还是比较清晰的,可以自己用 PyCharm 看看每一步都做了哪些事情,总之 URL 和 handler 之间的映射关系被称之为路由(route),在 Tornado 里面路由是基于 routing.Rule 这个类实现的。并且 Tornado 支持通过多种方式创建路由,可以是元组、列表,还可以是 web.url 对象。

当路由创建完毕后,就被 append 到 self.rules 里面了,而 self.rules 便是路由表,里面可以包含大量的路由。当来一个请求时,根据 URL 匹配指定的路由,这个过程就是路由匹配。找到指定的路由之后,再获取相应的 handler,并执行。

获取请求数据

当客户端发起请求时,我们如何获取相应的请求数据呢?我们来看一下:

from tornado import web
from tornado import ioloop
from tornado import httpserver

class IndexHandler(web.RequestHandler):

    def get(self):
        # 客户端发起的请求数据会被封装成一个 HTTPServerRequest 对象
        # 该类位于 tornado.httputil 模块下,这里直接通过 self.request 即可获取
        # 下面来解释一下都可以获取哪些属性
        print("协议:", self.request.protocol)
        print("请求方法:", self.request.method)
        print("uri 地址:", self.request.uri)
        print("完整 url 地址:", self.request.full_url())
        print("HTTP 协议版本:", self.request.version)
        # print("客户端请求头:", self.request.headers)  # 内容太多不打印了
        print("客户端请求体(原始数据):", self.request.body.decode('utf-8'))
        print("客户端请求的地址和端口, 即服务端监听的地址和端口:", self.request.host)
        print("客户端上传的文件信息:", self.request.files)
        # print("客户端的 cookie 信息:", self.request.cookies)  # 内容太多不打印了
        print("客户端的 IP 地址:", self.request.remote_ip)
        print("请求处理时间:", self.request.request_time())
        self.write("hi, boy")

app = web.Application(
    [web.url(r"/", IndexHandler, name="哼哼")],
)

server = httpserver.HTTPServer(app)
server.listen(9000, "0.0.0.0")
ioloop.IOLoop.current().start()

我们发起一个请求,看看会输出什么?

显示的信息还是比较全面的,咦,我们貌似没有看到查询参数、表单数据、json 数据的获取方式啊。别急,马上介绍。

class IndexHandler(web.RequestHandler):

    def get(self):
        # 查询参数,通过 self.get_query_argument 获取,但只能获取一个
        # 如果想获取多个的话,则通过 self.get_query_arguments
        
        # 获取查询参数 name,如果没有指定,会返回默认值
        name = self.get_query_argument("name", default="无名氏")
        # 没有指定的话,会返回空列表
        hobby = self.get_query_arguments("hobby")
        self.write(f"name: {name}, hobby: {hobby}")

以上是查询参数,再来看看表单数据如何获取:

class IndexHandler(web.RequestHandler):

    def post(self):
        # 用法和获取查询参数类似
        name = self.get_body_argument("name", default="无名氏")
        hobby = self.get_body_arguments("hobby")
        self.write(f"name: {name}, hobby: {hobby}")

获取表单数据也比较简单,不过这里补充一下,还有一个 self.get_argument 和 self.get_arguments,它们既可以获取查询参数,也可以获取表单数据。但用的比较少,因为 post 请求里面如果同时传递了表单数据和查询参数,那么这两个方法就容易造成误解。因此我们一般还是用 self.get_query_argument[s] 获取查询参数,self.get_body_argument[s] 获取表单数据。

除此之外,获取查询参数还可以通过 self.request.query_arguments,此时会拿到所有的查询参数(一个字典);获取表单数据还可以通过 self.request.body_arguments,此时会拿到所有的表单数据(一个字典)。

然后是 json 数据的获取,json 数据的话,Tornado 没有提供相应的方案,但我们可以换一种方式。

class IndexHandler(web.RequestHandler):

    def post(self):
        # 拿到原始数据(字节流),然后再进行解析即可
        # 所以所谓的表单数据、json 数据,都是基于原始数据解析得到的
        payload = self.request.body
        # 如果我们通过表单的方式传递
        # 比如 requests.post(url, data={"name": "satori", "age": 16})
        # 那么这里的 payload 就是 b'name=satori&age=16'
        
        # 如果通过 json 传递,比如 requests.post(url, json={"name": "satori", "age": 16})
        # 那么这里的 payload 就是 b'{"name": "satori", "age": 16}'
        
        # 然而不管是表单传递、还是 json 传递,拿到的都是一串字节流,然后对字节流解析即可
        # 注意:这里应该做一次异常检测,因为用户可能传递的不是 json
        data = orjson.loads(payload)
        self.write(f"数据: {data}, 类型: {data.__class__}")

获取请求头

我们知道请求头可以通过 self.request.headers 获取,上面已经说过了,我们这里来看一看它的用法。

class IndexHandler(web.RequestHandler):

    def get(self):
        # 拿到的是一个 tornado.httputil.HTTPHeaders 对象
        headers = self.request.headers
        # 我们可以像字典一样使用它
        # 比如获取某个字段对应的值
        print(headers["Host"])  # localhost:9000
        # 如果有多个相同的字段,可以获取多个,会得到一个列表
        print(headers.get_list("Host"))  # ['localhost:9000']
        # 获取所有的字段,得到的是一个生成器
        # 这里转成列表,当然也可以转成字典
        pprint(list(headers.get_all()))
        """
        [('Host', 'localhost:9000'),
         ('User-Agent', 'python-requests/2.24.0'),
         ('Accept-Encoding', 'gzip, deflate'),
         ('Accept', '*/*'),
         ('Connection', 'keep-alive')]
        """

cookie 可以通过 self.request.cookie 获取,它返回的是一个 http.cookies.SimpleCookie 对象。

class IndexHandler(web.RequestHandler):

    def post(self):
        # 拿到的是一个 http.cookies.SimpleCookie 对象
        # SimpleCookie 继承 dict,所以使用方式和字典别无二致
        headers = self.request.cookies
        pprint(dict(headers))

获取文件

然后是获取文件,显然它可以通过 self.request.files 来获取。但问题是它返回的是一个什么样的对象呢?我们又要如何将文件保存起来呢?下面实际操作一波。

class IndexHandler(web.RequestHandler):

    def post(self):
        # 对于 Tornado 而言,文件是一个 tornado.httputil.HTTPFile 对象
        # 它有三个属性,分别是 filename、body、content_type
        # 这三个属性的含义应该不需要解释了
        files = self.request.files
        # self.request.files 是一个字典,结构如下:
        """
            {
                "...": [<HTTPFile>, <HTTPFile>],
                "...": [<HTTPFile>],
            }
        """
        self.write(f"表单数据: {self.request.body_arguments}\n")
        self.write("文件信息如下:\n")
        for field, file_list in files.items():
            self.write(f"-----{field}-----\n")
            for file in file_list:
                self.write(f"文件名: {file.filename}\n")
                # file.body 是文件的内容,并且以字节流的形式存在
                self.write(f"文件内容(长度): {len(file.body)}\n")
                self.write(f"文件类型: {file.content_type}\n")

我们在上传文件的同时,还可以传递表单数据,它们都会以字节流的形式存储在 self.request.body 里面。我们可以拿到这些字节流,然后进行解析。但是这么做比较麻烦,因为 Tornado 已经帮我们解析好了,并将解析好的表单数据放在 self.request.body_arguments 中、解析好的文件数据放在 self.request.files 中,我们直接获取即可。

打印的输出显然是符合预期的。

获取路径参数

再来看看如何获取路径参数,假设我有一个 images 目录,我希望用户能够通过 /images/<xxx> 访问 images 目录中文件名为 xxx 的文件,要该怎么做呢?

class IndexHandler(web.RequestHandler):

    def get(self, filename):
        self.write(f"你访问了 `{filename}` 文件")
        
app = web.Application(
    # Tornado 的动态路径参数,需要使用小括号括起来
    # 周围没有小括号,那么它就是一个写死的路径参数
    # 周围有小括号,那么它就是一个动态路径参数,并且借用了正则
    # 所以里面的 \d+\.png 表示该路径参数必须是 "数字.png" 的方式
    [web.url(r"/images/(\d+\.png)", IndexHandler, name="哼哼")],
)

当然,里面也可以有多个动态路径参数。

class IndexHandler(web.RequestHandler):

    def get(self, arg1, arg2, arg3):
        self.write(f"arg1={arg1}、arg2={arg2}、arg3={arg3}")

app = web.Application(
    # 因为 URL 里面有三个组,所以 get 方法里面也要有三个参数
    # 匹配出来之后,会以位置参数的方式,依次传递到方法中
    [web.url(r"/(\d+)/xx/(\w+)/(\d+)", IndexHandler, name="哼哼")],
)

我们在设置动态路径的时候,还可以起一个名字,然后调用方法时会以关键字参数的方式传递。

class IndexHandler(web.RequestHandler):

    def get(self, arg3, arg2, arg1):
        self.write(f"arg1={arg1}、arg2={arg2}、arg3={arg3}")

app = web.Application(
    # () 表示一个组,通过 ?P<> 可以给组起一个名字,也就是此路径参数的名字
    # 之后调用 get 方法的时候,会以关键字参数的方式传递
    [web.url(r"/(?P<arg1>\d+)/(?P<arg2>\d+)/(?P<arg3>\d+)", IndexHandler, name="哼哼")],
)

虽然 get 方法里面的参数顺序是 arg3、arg2、arg1,但是不影响,因为是通过关键字参数的方式传递的。如果设置 URL 的时候没有起名字,那么会以位置参数的方式传递,于是 arg3 就变成了 123、arg1 变成了 789。

给客户端响应数据

请求相关的内容我们先告一段落,然后看看如何给客户端响应数据。

到目前位置,响应数据都是通过 self.write 实现的,并且这个方法只是将数据写入到缓冲区。所以方法里面可以有多个 self.write,当整个方法执行完毕后,再将缓冲区里面的数据一次性返回给客户端。

可以查看 self.write 的源码,我们看到它只是将内容 append 到了 self._write_buffer 中,而当方法执行完毕,self._write_buffer 里面的内容就会返回给客户端。那么问题来了,为什么方法执行完毕之后,缓冲区的内容会返回给客户端呢?肯定是执行了其它的某个方法,没错,这个方法就是 self.finish。

self.finish 负责刷新缓冲区、将缓冲区的内容返回给客户端,然后关闭连接通道。所以它只能调用一次,并且调用完 self.finish 之后,就不能再调用 self.write。

class IndexHandler(web.RequestHandler):

    def get(self):
        self.write(f"hello, ")
        self.finish(f"satori")

访问之后,会返回字符串 "hello, satori"。所以 self.finish 也可以往缓冲区里面写入数据,只不过调用完 self.finish 之后,连接就关闭了,再尝试往缓冲区里面写数据就会报错。

这里可以看出 Tornado 和其它框架的一个不同之处,比如:Flask、Django,它们都是在方法 return 了之后,才会给客户端返回数据。也就是当客户端拿到数据之后,方法已经执行完毕了。但 Tornado 不同,我们举个栗子:

class IndexHandler(web.RequestHandler):

    def get(self):
        self.finish(f"hello, satori")
        time.sleep(30)
        print(123)

当客户端发起请求时,会立刻收到数据,因为 self.finish 将数据写入缓冲区之后,会刷新缓冲区,并将数据返回给客户端。所以对于客户端而言,请求就算是结束了,但是这个 get 方法并没有执行完毕,在 30 秒过后,终端会打印整数 123。

所以 self.write 和 self.finish 之间的区别就很清晰了,两者都可以往缓冲区里面写数据。区别就是 self.write 它可以调用多次,因为它只负责写数据;而 self.finish 在写完数据还会刷新缓冲区,并关闭连接通道,所以它只能调用一次,并且调用完 self.finish 之后,客户端会立即收到数据。

由于写到缓冲区的数据肯定是要返回给客户端的,所以在方法里面如果没有 self.finish,那么 Tornado 会在方法执行完毕之后,帮我们执行 self.finish。

设置响应头

我们目前返回的都是字符串,也就是返回内容的 Content-Type 为 text/plain,如果我们想返回 JSON、HTML 要怎么做呢?显然是通过设置响应头来实现。那么如何设置呢?下面来介绍一下。

class IndexHandler(web.RequestHandler):

    def get(self):
        data = orjson.dumps({"name": "古明地觉", "where": "地灵殿"})
        # 返回的数据为 JSON 格式
        self.set_header("Content-Type", "application/json")
        self.set_header("komeiji", "satori")
        self.finish(data)
        
app = web.Application(
    [web.url(r"/", IndexHandler)]
)        

响应头设置成功,并且字段的首字母自动变成了大写。

并且当想要指定返回数据的格式时,只需要设置对应的 Content-Type 即可,常见的 Content-Type 如下:

  • 返回 JSON,就设置为 application/json
  • 返回 HTML,就设置为 text/html
  • 返回 纯文本,就设置为 text/plain
  • 返回 二进制流,就设置为 application/octet-stream

Content-Type 具体种类都有哪些,可以参考菜鸟教程

如果我们不想返回某个响应字段也是可以的,Tornado 也提供了相应的方法,直接调用 self.clear_header 即可删掉响应头中的某个字段。

设置状态码

除了响应头,我们也可以设置状态码。

class IndexHandler(web.RequestHandler):

    def get(self):
        self.set_status(404, "just 404")
        self.finish("请求正常执行,但就是 404")

app = web.Application(
    [web.url(r"/", IndexHandler)]
)

设置 cookie 也很简单,并且 Tornado 还支持设置加密 cookie:

class IndexHandler(web.RequestHandler):

    def get(self):
        # 里面还支持很多其它参数,比如 domain、过期时间、path 等等
        self.set_cookie("Token", "123456")
        # 还可以对 cookie 进行加密,但是使用加密 cookie 有一个前提条件
        # 我们必须在 Application 中设置一个 cookie_secret 参数
        # cookie_secret 应该是一个很长的随机字符窜,用于 HMAC 加密
        self.set_secure_cookie("Secure_Token", "123456")
        self.finish("hello satori")

app = web.Application(
    [web.url(r"/", IndexHandler)],
    cookie_secret="不想设置的太长"
)

我们看到 cookie 确实被加密了,然后是获取 cookie:

class IndexHandler(web.RequestHandler):

    def get(self):
        # 之前我们说可以通过 self.request.cookies 拿到所有的 cookie
        # 但其实我们还有其它的方法,比如通过 self.get_cookie 获取指定的 cookie
        print(self.get_cookie("Token"))
        # 然后获取加密 cookie,拿到的结果是加密之后的字符串
        # 那么问题来了,我们怎么知道结果是不是正确的呢?
        # 所以获取的时候,需要使用 self.get_secure_cookie(name, value)
        # 会对 value 进行进行加密,这个 value 就是加密前的 cookie
        # 如果加密后的结果和获取到结果一样,那么证明用户传递的加密 cookie 是合法的
        # 如果合法,就返回用户传递过来的 cookie,也就是加密 cookie;不合法,则返回 None
        # 注意:两次加密使用的 cookie_secret 应该是一样的
        print(self.get_secure_cookie("Secure_Token", "123456"))
        self.finish("hello satori")

和响应头类似,如果我们不想返回某个 cookie,也可以将其删掉。

class IndexHandler(web.RequestHandler):

    def get(self):
        # 删除某个 cookie,可以指定 path 和 domain
        self.clear_cookie("Token", path="/", domain=None)
        # 还可以删除全部 cookie,同样可以指定 path 和 domain
        self.clear_all_cookies(path="/", domain=None)
        self.finish("hello satori")

设置错误信息

Tornado 提供了两个方法 send_error 和 write_error,我们来看看它是干什么的?

class IndexHandler(web.RequestHandler):

    def get(self):
        if self.get_query_argument("komeiji", None) != "satori":
            self.send_error(status_code=404)
        else:
            self.finish("hello world")

如果访问时没有设置查询参数 komeiji,或者设置了,但是不等于 satori,那么调用 send_error。

调用 send_error 的时候,会自动给客户端返回错误信息。只是这个错误信息是 Tornado 自动设置的,那么我们能不能自定义呢?答案是可以的,只需要重写 write_error 方法即可。

class IndexHandler(web.RequestHandler):

    def get(self):
        if self.get_query_argument("komeiji", None) != "satori":
            # 会自动调用 write_error,里面的参数也会传递过去
            self.send_error(status_code=404, name="古明地觉", where="地灵殿")
        else:
            self.finish("hello world")

    def write_error(self, status_code: int, **kwargs):
        self.write(f"状态码: {status_code}\n")
        self.write(f"少女的身份: {kwargs['name']}\n")
        self.write(f"少女身在何方: {kwargs['where']}\n")

self.send_error 会触发 self.finish 的调用,因为要将错误信息返回回去,所以当执行完 self.send_error 之后,就不可以再往缓冲区里面写数据了。

但是说实话,生产上很少会用这种方式。一般来说,无论请求是否成功,我们都会直接返回一个 JSON,里面有一个专门的字段来记录请求是否成功,比如 status、is_success 等等。然后还会有一个 data 字段和 error_message 字段,如果请求成功,data 字段就保存返回的数据,error_message 为空;请求失败,error_message 字段则保存错误信息,data 字段为空。

通过返回 JSON,整个过程会更加的可控,特别是当请求失败的时候。比如前端某个参数传的不对,后端可以在返回的 JSON 的 error_message 字段中写上错误信息,这样前端在拿到之后就知道问题出在哪了。虽然在 write_error 里面也可以返回 JSON,但我们完全可以直接返回,没必要非得定义一个 write_error,然后在 write_error 里面返回。

另外我这里只是举个栗子,返回的 JSON 具体要包含哪些字段,取决于你当前的业务,以及你和前端所定下的 API 契约。

设置重定向

重定向分为两种:永久性重定向(状态码为 301)和暂时性重定向(状态码为 302)。当用户访问了一个废弃的域名,应该将它重定向到新的域名,此时是永久性重定向;如果用户想要发评论、但是没有登录,那么应该重定向到登录页面,此时是暂时性重定向。

当然,不管是哪一种重定向,都是让浏览器跳转到我们指定的新的页面。那么在 Tornado 里面如何实现重定向呢?

class IndexHandler(web.RequestHandler):

    def get(self):
        # 该方法接收三个参数
        # 参数一:重定向后的地址
        # 参数二:是否为永久性重定向,如果为 True,会将状态码设置为 301,否则 302,默认为 False
        # 参数三:状态码,如果指定了会忽略参数二
        self.redirect("https://www.baidu.com")

app = web.Application(
    [web.url(r"/", IndexHandler)],
)

访问 localhost:9000 之后会跳转到百度页面,另外说到跳转,我们也可以手动实现,我们测试一下。

class IndexHandler(web.RequestHandler):

    def get(self):
        # 浏览器会先判断状态码
        # 如果是 3xx,那么就知道自己要跳转了
        self.set_header("Status Code", 302)
        # 但是要跳转到哪一个 URL 呢?
        # 会根据求头中的 Location 字段来判断
        self.set_header("Location", "https://www.baidu.com")
        # 然后刷新缓冲区,将数据返回给客户端,关闭请求通道
        # 虽然 Tornado 会在方法执行完毕之后自动调用 self.finish()
        # 但最好养成一个习惯,当返回数据时,手动 finish
        self.finish()

以上我们就手动实现了重定向。

然后我们再来说一下路由的名字,我们之前定义路由的时候说过,可以给路由指定一个名字。但是却没解释这个名字是用来做什么的,现在它派上用场了。

class IndexHandler(web.RequestHandler):

    def get(self):
        pass

class ServiceHandler(web.RequestHandler):

    def get(self):
        # 假设用户访问 /service,我希望它重定向到 IndexHandler 对应的 URL
        # 首先 self.redirect("/") 肯定可以的
        self.redirect("/")
        # 但是这样就出现了硬编码,要是哪一天 URL 改了就尴尬了
        # 那应该怎么做呢?
        """
        # 我们给路由起了个名字叫 index
        # 通过 self.reverse_url("index") 即可拿到对应的 URL
        # 然后再重定向即可
        url = self.reverse_url("index")
        self.redirect(url)
        """

app = web.Application(
    [web.url(r"/", IndexHandler, name="index"),
     web.url(r"/service", ServiceHandler)],
)

所以路由的名字是专门用来重定向的。

返回文件

纯文本、HTML、JSON 我们已经知道如何返回了,那如果是文件呢?我们来看一下。

class IndexHandler(web.RequestHandler):

    def get(self):
        # 假设我有一张图片,文件名是 1.png,我想将它返回该怎么做呢?
        # 首先设置请求头,表示响应的数据类型为 image/png 格式
        self.set_header("Content-Type", "image/png")
        # 读取图片,返回数据
        with open(r"1.png", "rb") as f:
            data = f.read()
        self.finish(data)

此时访问 localhost:9000,你会发现图片直接显示在了页面上。原因是浏览器在获取响应之后,通过响应头的 Content-Type 字段知道了返回的数据是图片格式,而浏览器(比如谷歌)也支持显示图片,所以就将字节流以图片的格式渲染在了页面上,于是我们就看到了图片内容。当然不仅是图片,音频、视频也是可以的,只要是浏览器支持的格式,都可以渲染在页面上。

注意:虽然浏览器也可以在页面上渲染音频和视频,比如 mp3 格式的音频,只需要将 Content-type 指定为 audio/mp3 即可。但问题是你会发现进度条无法拖动,视频也是同理,如果想支持进度条拖动的话,那么还需要设置一个响应头 Accept-Ranges,将它指定为 bytes 即可,这样就支持拖动了。

再补充一下,渲染的前提是,我们必须告诉浏览器返回数据的格式,如果我们不指定 Content-Type,那么浏览器会默认以文本的方式显示。比如我们将代码中的响应头设置给删掉,然后访问看看会出现什么结果。

我们看到了一堆乱码,原因就是在没有指定 Content-Type 的时候,浏览器会自动将图片的二进制流按照文本的方式显示。所以在返回数据时,一定要指定好 Content-Type。

那么问题来了,如果我们不希望数据显示在页面上,而是希望浏览器自动下载下来该怎么做呢?

class IndexHandler(web.RequestHandler):

    def get(self):
        # 浏览器默认会将数据显示在页面上,至于如何显示,则取决于 Content-Type
        # 但如果设置了 Content-Disposition,那么浏览器就不会显示了,而是会直接将其下载下来
        self.set_header("Content-Disposition", "attachment")
        # 如果是下载的话,最好也指定一下 Content-Type,格式为 application/octet-stream
        # 表示将内容以二进制的方式进行下载
        self.set_header("Content-Type", "application/octet-stream")
        # 此时我们访问的时候,会下载成文件,文件里面的内容为 xx
        self.finish("xx")

不过这里还有一个问题,就是当下载的时候,文件名不是我们想要的。我这里的文件名默认叫 "下载.htm",那么能否在浏览器下载的时候,指定文件名呢?显然是可以的。

class IndexHandler(web.RequestHandler):

    def get(self):
        # 此时下载的文件名就叫 xxx.txt
        self.set_header("Content-Disposition", "attachment;filename=xxx.txt")
        self.set_header("Content-Type", "application/octet-stream")
        self.finish("xx")

以上就是文件下载相关的内容,感觉更多的还是 HTTP 相关的内容。

说到返回文件,Tornado 也支持返回静态文件:

app = web.Application(
    [(r"/images/(.*)", web.StaticFileHandler, {"path": r"C:\Users\satori\Desktop\bg"})]
)

此时便可通过 /images/xxx 访问指定目录下的 xxx 文件。

handler 的一些钩子方法

在视图类中,我们除了可以编写 HTTP请求名称对应的方法之外,还可以编写一些钩子方法,它们会在请求到来的时候自动调用。而常见的钩子方法有如下几种,我们分别介绍。

initialize

当请求过来的时候,会先执行 initialize 方法,在里面可以做一些初始化工作。这个方法会自动调用,并且发生在 HTTP 请求对应的方法之前。

class IndexHandler(web.RequestHandler):
    
    def initialize(self, **kwargs):
        for k, v in kwargs.items():
            object.__setattr__(self, k, v)
            
    def get(self):
        self.write(f"name={self.name}, age = {self.age}")
        self.finish()
        # 也可以直接 self.finish(f"name={self.name}, age = {self.age}")

app = web.Application(
    # 元组的第一个元素和 web.url 的第一个参数表示 URL
    # 元组的第二个元素和 web.url 的第二个参数表示 handler
    # 元组的第三个元素和 web.url 的第三个参数是一个字典,会以关键字参数的方式传递到 initialize 方法中
    # 元组的第四个元素和 web.url 的第四个参数表示路由的名称
    [web.url(r"/", IndexHandler, {"name": "satori", "age": 16})],
)

注意:web.url 里面的第三个参数字典里面的 key 和 initialize 里面的参数要匹配。因此上面的 StaticFileHandler 里面一定实现了 initialize 方法,并且里面至少有一个 path 参数。

prepare 方法

prepare 方法可以用来做一些预处理工作,不接收额外参数,并且也是在 HTTP请求对应的方法之前执行。

class IndexHandler(web.RequestHandler):

    def prepare(self):
        if self.request.headers.get("Content-Type", "application/json"):
            self.is_json = True
            self.json_data = orjson.loads(self.request.body)
        else:
            self.is_json = False
            self.json_data = None

    def get(self):
        if self.is_json:
            # 执行相关逻辑
            name = self.json_data["name"]
            
app = web.Application(
    # 如果没有定义 initialize 方法,那么无需指定第三个参数
    [web.url(r"/", IndexHandler)],
)            

显然 prepare 里面的逻辑也可以拿到 initialize 里面去执行。

set_default_headers

再来看一个 set_default_headers,从名字上来看它显然是设置响应头,那么问题来了,它存在的意义是什么呢?不是已经有 set_headers 了吗?假设我们定义了 get、post、delete 方法,每个方法在返回的时候都需要设置指定的多个响应头,那么按照之前的做法,需要在每个方法内都写一遍。而有了 set_default_headers 之后,我们只需要写一遍即可,举个栗子。

class IndexHandler(web.RequestHandler):

    def set_default_headers(self):
        self.set_header("Token1", "xxx")
        self.set_header("Token2", "yyy")
        self.set_header("Token3", "zzz")
        # 如果有下面这三行,即可实现跨域
        # self.set_header("Access-Control-Allow-Origin", "*")  
        # self.set_header("Access-Control-Allow-Headers", "*")
        # self.set_header('Access-Control-Allow-Methods', "*")

    def get(self):
        self.finish("...")

    def post(self):
        self.finish("...")

    def delete(self):
        self.finish("...")

app = web.Application(
    [web.url(r"/", IndexHandler)],
)

initialize、prepare、set_default_headers 这三个方法都发生在 HTTP请求对应的方法之前,那么问题来了,它们三个之间的顺序如何呢?我们来测试一下:

class IndexHandler(web.RequestHandler):
    
    def initialize(self):
        print("initialize")
    
    def prepare(self):
        print("prepare")
        
    def set_default_headers(self):
        print("set_default_headers")

    def get(self):
        print("get 请求")

我们访问 localhost:9000,看看终端会打印什么?

根据打印的结果,执行顺序一目了然。当然,这三个方法谁先谁后其实不是特别重要,总之都在 HTTP 对应的方法之前。

on_finish

on_finish 方法会在 HTTP 对应的请求结束之后再执行,主要用于资源的清理,日志的记录等等。

class IndexHandler(web.RequestHandler):

    def prepare(self):
        self.ip = self.request.remote_ip
        self.method = self.request.method
        self.uri = self.request.uri
        print(f"`{self.ip}` 向 `{self.uri}` 发起了 `{self.method}` 请求")

    def get(self):
        print("执行中")
    
    def on_finish(self):
        print(f"`{self.ip}` 向 `{self.uri}` 发起的 `{self.method}` 请求执行完毕")

这样是不是就将整个请求过程都记录了下来呢?当然你也可以加入更多的信息,都是可以的。

以上就是 handler 的一些钩子方法,在工作中可以使用起来。

缓冲区的刷新

在视图类中调用 self.write 可以将数据写入缓冲区,而缓冲区在 Tornado 里面就是一个列表:self._write_buffer。由于 self.write 只是写入数据,并没有输出,所以它可以多次调用。然后在没有其它操作干预的情况下,当视图方法处理结束后,会刷新缓冲区,将里面的所有数据都返回给客户端。

而刷新缓冲区可以通过 self.finish 实现,但除了 self.finish 之外,我们还可以使用 self.flush,那么这两者有什么区别呢?

  • self.flush: 刷新缓冲区,将数据返回给客户端之后,不关闭连接通道;
  • self.finish: 刷新缓冲区,将数据返回给客户端之后,关闭连接通道;

所以调用完 self.flush 之后我们还可以继续写,相信你也已经明白它存在的意义了。就是当返回的数据流非常的大时,为了避免缓冲区过度膨胀,可以将数据分批返回。我们举个栗子:

class IndexHandler(web.RequestHandler):

    def get(self):
        self.write("xxx")
        self.flush()
        time.sleep(6)
        self.write("yyy")
        self.flush()
        time.sleep(6)
        self.write("zzz")
        self.finish()

当客户端发起请求时,会先收到 xxx,因为服务端执行了 self.flush,于是会强行将数据返回给客户端,并刷新缓冲区;但是注意:此时并没有关闭连接通道,所以客户端的请求还没有结束。然后 sleep 6s 之后,会收到 yyy;再 sleep 6s 之后会收到 zzz。

而收到 zzz 之后,服务端调用了 self.finish,那么连接通道就被关闭了,此时客户端就知道请求结束了。对于服务端而言,当连接通道被关闭之后,也就不能再往缓冲区里面写数据了,因为执行完 self.finish 之后,和客户端的连接就已经断开了。

浏览器收到 xxx 之后并没有断开连接,因为通道还没有关闭,但是我们看到数据已经返回了;然后 6s 之后,收到 yyy。

此时连接依旧没有断开,再过 6s 会收到 zzz。

所以这就是 Tornado 的神奇之处,可以非常简单地将数据分批返回,因此我们再来总结一下:

  • self.write:负责将数据写入缓冲区 self._write_buffer 中
  • self.flush:负责刷新缓冲区,将数据返回给客户端,但是连接并没有断开。因此它存在的意义就是当返回的数据量过大时,避免缓冲区膨胀导致内存占用过大。而做法就是先将当前已写入的数据返回,然后刷新缓冲区,也就是将缓冲区清空,然后继续写,从而实现分批返回
  • self.finish:负责刷新缓冲区,将数据返回给客户端,但是会断开连接,或者说会关闭和客户端的连接通道。使用 self.finish 就说明数据已经全部写入缓冲区了,刷新之后就可以断开连接了。当然啦,self.finish 也可以充当 self.write,因为它也支持向缓冲区写入数据,所以 self.write("xx") 和 self.finish() 组合起来等价于 self.finish("xx")。但很明显,它只能写入一次,因为调用完之后连接就关闭了

不知道各位有没有遇到网络比较卡的时候,加载的图片需要等一段时间才能完全显示出来;或者从上往下一点一点显示,直到完全显示。如果是前者,说明服务端是将字节流一次性返回的;如果是后者,说明服务端是将字节流分批返回的。我们演示一下:

class IndexHandler(web.RequestHandler):

    def get(self):
        self.set_header("Content-Type", "image/png")
        with open(r"doge.png", "rb") as f:
            # 一行一行返回,每返回一行 sleep 0.1秒
            for line in f:
                # self.write 里面可以接收字符串、字节序列、字典
                self.write(line)
                # 写完了别忘了刷新,否则客户端在我们写完之前啥也看不到
                # 如果刷新的话,那么写一点客户端就能看到一点
                self.flush()
                # sleep 0.3s
                time.sleep(0.1)
        # 关闭通道
        self.finish()

我们看到图片确实是一点一点返回的,灰色区域就是还没有加载的部分。

另外,既然数据是写到缓冲区里面的,那如果直接将数据写到缓冲区是不是也可以呢?我们试一下:

class IndexHandler(web.RequestHandler):

    def get(self):
        # 虽然调用 self.write 的时候,可以传递字符串
        # 但是写入缓冲区时,必须是一个 bytes 对象
        # 只不过 self.write 会自动帮我们处理,而此时则需要我们手动处理
        self._write_buffer.append("你好呀".encode("utf-8"))
        self.finish()

访问页面的话,仍然是可以看到内容的,说明上面这种做法是没有问题的,但显然它不是一个值得提倡的做法。

用户认证

当用户没有登录,但是却访问了一个需要登录才能够访问的页面,这时候我们应该将它重定向到登录页面。而 Tornado 提供了装饰器 web.authenticated 和视图内置方法 get_current_user,可以允许我们轻松地实现定制性的用户认证功能。

class HomeHandler(web.RequestHandler):
    @web.authenticated
    def get(self):
        self.write("欢迎来到我的公众号 '古明地觉的 Python小屋'\n")
        self.write(f"self.current_user = {self.current_user}")
        self.finish()

    def get_current_user(self):
        """
        get 方法加了一个装饰器,一会我们来分析这个装饰器的内部实现
        总之在请求 get 方法时,在内部会先调用这里 get_current_user 方法
        如果此方法的返回值的布尔值为真,那么认证通过,否则重定向到 login_url 中
        而这个 login_url 则需要我们配置在 Application 中
        :return:
        """
        # 这里为了简单,我们用查询参数来模拟用户认证
        # 但显然生产中,应该让用户传递一个 Token,然后去数据库或 Redis 中查询 Token 是否合法
        # 或者要求用户传递一个 SessionID,然后检验这个 SessionID 有没有过期
        name = self.get_query_argument("name", "")
        password = self.get_query_argument("password", "")
        # 一旦认证成功(返回值的布尔值为真),那么会执行上面的 get 方法
        if name == "komeiji" and password == "satori":
            return "komeiji satori"
        else:
            # 否则重定向到 login_url 中
            return False

class LoginHandler(web.RequestHandler):

    def get(self):
        self.finish("去登陆~~~")

app = web.Application(
    [web.url(r"/login", LoginHandler),
     web.url(r"/home", HomeHandler)],
    # 配置 login_url,当认证不通过时,会跳转到此 URL
    login_url="/login"
)

由于 /home 对应的 get 方法加上了 @authenticated,那么在内部会先执行 self.get_current_user(),当返回值为假时,根本不会执行 get 方法,直接重定向到 Application 配置的 login_url 中。如果返回值为真,那么才执行 get 方法。

然后我们来看看这个装饰器做了什么事情吧?

def authenticated(
    method: Callable[..., Optional[Awaitable[None]]]
) -> Callable[..., Optional[Awaitable[None]]]:

    @functools.wraps(method)
    def wrapper( 
        self: RequestHandler, *args, **kwargs
    ) -> Optional[Awaitable[None]]:
        # self.current_user 是一个被 property 装饰的方法
        # 在里面会 return self.get_current_user()
        if not self.current_user:
            # 如果 self.current_user 为假,那么执行 if 语句
            # 首先判断请求方法,如果不是 GET 或者 HEAD,则抛出 403 错误
            # 说明这个装饰器只能针对 get 和 head 请求
            if self.request.method in ("GET", "HEAD"):
                # 获取 login_url
                # 该方法会返回 self.application.settings["login_url"]
                # 所以我们要将 login_url 配置在 Application 里面
                url = self.get_login_url()
                # url 是跳转之后的链接,显然就是 /login
                # 但是我们一般还会在后面加上一个查询参数 ?next=...
                # 表示是从别的链接跳转过来的,而这里 ?next= 后面的 ... 显然就是 /home
                if "?" not in url:
                    # 如果能获取到 scheme,表示当前连接是一个绝对链接
                    if urllib.parse.urlsplit(url).scheme:
                        # 直接获取绝对链接
                        next_url = self.request.full_url()
                    else:
                        # 但明显我们这里是相对链接,所以 next_url 为 /home
                        assert self.request.uri is not None
                        next_url = self.request.uri
                    # 因此这里的 url 最终就是 /login?next=/home
                    url += "?" + urlencode(dict(next=next_url))
                # 如果 login_url 里面包含了 ?
                # 那么 Tornado 会认为我们自定义了 next,那么此时会直接重定向
                self.redirect(url)
                return None
            raise HTTPError(403)
        # 如果 self.current_user 为真
        # 那么 if 条件不通过,直接执行请求对应的视图方法
        return method(self, *args, **kwargs)

    return wrapper

以上就是该装饰器的内部逻辑,并不是很难。

自定义 404

再来说说如何自定义 404,当用户访问一个不存在的 URL 时,返回我们自定义的错误。

class NotFoundHandler(web.RequestHandler):

    def prepare(self):
        # 还可以 raise 一个 tornado.web.HTTPError,但是个人还是喜欢用 self.finish
        self.finish("您要找的页面去火星了")

# 我们以关键字参数的形式传递即可
app = web.Application(
    [],
    default_handler_class=NotFoundHandler
)

在 Application 里面指定 default_handler_class 即可,这是最优雅的做法。

Tornado 的模板引擎和数据库

咦,模板引擎和数据库是应该两个部分吧,为啥要放在一起介绍呢?答案是这两部分都打算介绍。先来说说模板引擎,Tornado 的模板引擎借鉴了 jinja2,jinja2 借鉴了 Django 的 DTL。但它们的本质是一样的,都是读取 HTML 模板,然后进行解析,将数据嵌入到模板中。只是现在前后端分离已经是主流了,几乎不需要后端人员去渲染 HTML 了。

现在后端人员只需要提供一个接口,然后将数据返回即可,前端拿到数据之后再由前端去生成页面。如果前后端不分离,那么这些 HTML、CSS、JS 文件是由前端人员编写、还是后端人员编写呢?如果是后端人员编写,那么就要求后端人员懂前端;如果前端人员编写,那么就要求前端人员懂后端语言的模板渲染语法,但不管是哪一种,都无非是加重了沟通成本。

所以 Tornado 的模板引擎,这里就不说了,而且也比较简单。

然后是数据库,任何一个 web 框架,它的瓶颈都是在数据库上。而 Tornado 并没有提供相应的 ORM,或者说它里面没有和数据库相关的代码,所以需要我们基于现有的驱动或 ORM 自己去适配。虽然在 Tornado3.0 版本之前提供了 tornado.database 模块操作 MySQL 数据库,但从 3.0 版本开始此模块就独立出来了,作为 torndb 模块单独存在。而且 torndb 也只是对 MySQLdb 进行了简单封装,不支持 Python3。

之前所在公司就是使用的 torndb,通过自定义类的方式,每个类对应一张表(没有继承所谓的 Model)。并且每遇到一个查询需求,就定义一个类方法,然后类方法里面写原生 SQL。通过这种方法,来模拟 ORM,但事实上都是基于的原生 SQL。

因此数据库相关的部分,这里就也略过了,可以基于自己的项目进行选择。但需要注意的是,Tornado 是一个协程框架,后续我们会介绍它的异步操作,以及相关原理。因此我们在连接的时候,需要使用基于协程实现的驱动,比如 asyncmy、aiomysql、asyncpg 等等,ORM 的话可以尝试 tortoise。

扯了这么多,就是想为自己的偷懒找个借口。

什么是协程?为什么要有协程的出现?

接下来就开始 Tornado 的重头戏了,也就是协程。不过在介绍 Tornado 的协程之前,我们先来了解一下什么是协程。

如何实现高并发

我们知道一台主机的资源有限,一颗 CPU、一块磁盘、一张网卡,如何同时服务上百个请求呢?多进程模式是最初的解决方案,一个进程对应一个请求。内核把 CPU 的执行时间切分成许多时间片(timeslice),比如 1 秒钟可以切分为 100 个 10 毫秒的时间片,每个时间片再分发给不同的进程(而通常每个进程需要多个时间片才能完成一个请求)。这样,虽然微观上,比如说 10 毫秒时间 CPU 只能执行一个进程;但宏观上 1 秒钟执行了 100 个时间片,每个时间片所属进程中的请求都得到了执行,这就实现了请求的并发执行。

但是每个进程的内存空间都是独立的,因此使用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便。于是,多线程模式就出现了,多线程模式通过共享内存地址空间,解决了这两个问题。

然而,共享地址空间虽然可以方便地共享对象,但这也导致一个问题,那就是任何一个线程出错,都会导致进程中的所有线程跟着一起崩溃。这也是如 Nginx 等强调稳定性的服务坚持使用多进程模式的原因。

但事实上无论基于多进程还是多线程,都难以实现高并发,主要有以下两个原因:

  • 首先,单个线程消耗的内存过多,比如 64 位的 Linux 为每个线程的栈分配了 8MB 的内存;此外为了提升后续内存分配的性能,还为每个线程预分配了 64MB 的内存作为堆内存池(Thread Area)。所以,我们没有足够的内存去开启几万个线程实现并发。
  • 其次,切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,当调用阻塞方法时,内核为了让 CPU 充分工作,也会切换到其他线程执行。而一次上下文切换的成本在几十纳秒到几微秒间,当线程繁忙且数量众多时,这些切换会消耗绝大部分的 CPU 运算能力。

下图以磁盘 IO 为例,描述了多线程中使用阻塞方法读磁盘,2 个线程间的切换方式。

那么怎样才能实现高并发呢?答案是「把上图中本来由内核实现的请求切换工作,交由用户态的代码来完成就可以了」。异步化编程通过应用层代码实现了请求切换,降低了切换成本和内存占用空间。异步化依赖于 IO 多路复用机制,比如 Linux 的 epoll 或者 Windows 上的 iocp;同时,必须把阻塞方法更改为非阻塞方法,才能避免内核切换带来的巨大消耗。Nginx、Redis 等高性能服务都依赖异步化实现了百万量级的并发。

下图描述了异步 IO 的非阻塞读和异步框架结合后,是如何切换请求的。

这就是「非阻塞+回调」的方式,它依赖操作系统提供的 IO 多路复用,比如 Linux 的 epoll,BSD 的 kqueue。此时的读写操作都相当于一个事件,并为每一个事件都注册相应的回调函数,然后线程不会阻塞(读写操作是非阻塞的),而是可以做其它事情,由 epoll 来对这些事件进行统一管理。一旦事件发生(满足可读、可写时),那么 epoll 就会告知线程,然后线程执行为该事件注册的回调函数。

但是很明显,像这种异步化代码在编写时非常容易出错,因为所有阻塞函数,都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能,但调用方式却不同:第一个函数由我们显式调用,第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则,函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时,异步化的改造工作会非常困难。

那有没有办法既享受到异步化带来的高并发,又可以使用阻塞函数写同步化代码呢?答案是使用「协程」。它在异步化之上包了一层外衣,兼顾了开发效率与运行效率。

协程是如何实现高并发的?

为了达到高并发,上面的做法是选择一个异步框架,用非阻塞 API 把业务逻辑打乱到多个回调函数中,通过多路复用实现高并发(Python 的 Twisted 就是这么干的)。但此时就要求业务代码过度关注并发细节,需要维护很多中间状态,一旦代码逻辑出现错误就会陷入回调地狱。因此这么做不但 Bug 率会很高,项目的开发速度也上不去,产品及时上线存在风险。如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码,这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。

协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,如果遇到阻塞(比如准备数据),会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的某个协程中去执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程中继续执行。如下图所示:

看起来非常棒,这正是我们想要的。

所以我们再来对比一下这两种切换方式,第一种是通过「非阻塞 + 回调函数」来完成请求切换的,给每个读写事件注册一个回调,然后 epoll 来统一监听这些 socket,一旦事件发生,就通知线程去执行相应的回调。Redis、Nginx 都是基于这种方式实现的高并发,但是这种编程模型很容易出错,因为一个函数实现的功能非要拆成两个函数来实现,并且编写业务逻辑的同时还要注意并发方面的细节。而协程不同,协程不需要什么「回调函数」,它允许用户调用「阻塞的」协程方法,用同步编程方式写业务逻辑。

于是神奇的地方就出现了,协程在遇到阻塞的时候会自动切换到其它的协程去执行,那么这是怎么做到的呢?

实际上,用户态的代码切换协程,与内核切换线程的原理是一样的。内核通过管理 CPU 的寄存器来切换线程,我们以最重要的栈寄存器和指令寄存器为例,看看协程切换时如何切换程序指令与内存。

每个线程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU 中的栈寄存器 SP 指向了当前线程的栈,而指令寄存器 IP 保存着下一条要执行的指令地址。因此,从线程 1 切换到线程 2 时,首先要把 SP、IP 寄存器的值为线程 1 保存下来,再从内存中找出线程 2 上一次切换前保存好的寄存器值,写入 CPU 的寄存器,这样就完成了线程切换(其他寄存器也需要管理、替换,原理与此相同,不再赘述)。

协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态:

从协程 1 切换到协程 2 后的状态如下图所示:

创建协程时,会从进程的堆中分配一段内存作为协程的栈。线程的栈有 8MB,而协程栈的大小通常只有几十 KB。而且,C 库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着 10 万个协程。当然栈缩小后,就尽量不要使用递归函数,也不能在栈中申请过多的内存,这是实现高并发必须付出的代价。

由此可见,协程就是用户态的线程。然而,为了保证所有切换都在用户态进行,协程必须重新封装所有阻塞系统的调用,否则一旦协程触发了线程切换,会导致这个线程进入休眠状态,进而其上的所有协程都得不到执行。比如,普通的 sleep 函数会让当前线程休眠,由内核来唤醒线程,而协程化改造后,sleep 只会让当前协程休眠,由协程框架在指定时间后唤醒协程,所以在 Python 的协程里面我们不能写 time.sleep,而是写 asyncio.sleep。再比如,线程间的互斥锁是使用信号量实现的,而信号量也会导致线程休眠,协程化改造互斥锁后,同样由框架来协调、同步各协程的执行。

所以使用协程,不能有任何阻塞的系统调用,也就是不能进入内核态。因此它在使用起来,局限性比回调的方式要大很多,因为像读取文件、发送网络请求这些涉及内核的系统调用,我们都要在用户态重新实现一遍。因为协程的背后还是一个单线程,它既然能在协程内部出现阻塞调用,并且还能切换,就意味着这个阻塞调用一定不能是涉及内核的阻塞调用,而是我们在用户态实现的阻塞调用(在 Python 里面本质上也是个协程)。这样当出现阻塞时,会主动将执行的控制权交给事件循环(也依赖 IO 多路复用),然后事件循环再去找其它已经就绪的协程执行。如果协程内部的阻塞是涉及内核的阻塞,那么整个线程就阻塞了,线程阻塞了,所有的协程就都别想执行了。

而回调的方式不同,它是为每个读写操作注册了一个回调函数,由 epoll 统一管理,而此时读写是涉及到内核的。当可读、可写时,epoll 会通知线程执行相应的回调,相当于将阻塞的系统调用和具体的处理逻辑进行了分离。而协程则相当于选择了不分离,既然不分离,那你内部就不能有系统调用了,或者说要把系统调用在用户态重新实现一遍。

这里我们还必须要指出,使用「协程」带来的并发量并不优于「非阻塞 + 回调」的方式,而我们之所以选择协程是因为它的编程模型要更简单,类似于同步,也就是可以让我们使用同步的方式编写异步的代码。「非阻塞 + 回调」这种方式非常考验编程技巧,一旦出现错误,不好定位问题,容易陷入回调地狱、栈撕裂等困境。

所以协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。我经常看见有人在 async def 里面写 requests.get 发请求,这是不对的,requests.get 底层调用的是同步阻塞的 socket,所以把它放在 async def 里面没有任何意义,正确的做法是使用 aiohttp 或 httpx。因此如果想使用协程,那么需要重新封装底层的系统调用,如果实在没办法就扔到线程池中运行。

再比如 MySQL 官方提供的客户端 SDK,它也使用了同步阻塞的 socket 做网络访问,在请求数据时会导致线程休眠;所以必须将先 SDK 异步化,之后在协程中使用才有意义。

实际上,面对多核系统,也需要协程与线程配合工作。因为协程的载体是线程,而一个线程同一时间只能使用一颗 CPU,所以通过开启更多的线程,将所有协程分布在这些线程中,就能充分使用 CPU 资源。有过 Go 语言使用经验的话,应该很清楚这一点。除此之外,为了让协程获得更多的 CPU 时间,还可以设置所在线程的优先级,比如 Linux 下把线程的优先级设置到 -20,就可以每次获得更长的时间片。另外 CPU 缓存对程序性能也是有影响的,为了减少 CPU 缓存失效的比例,还可以把线程绑定到某个 CPU 上,增加协程执行时命中 CPU 缓存的机率。

虽然这里一直说协程框架在调度协程,然而你会发现,很多协程库(asyncio)只提供了创建、挂起、恢复执行等基本方法,并没有协程框架的存在,需要业务代码自行调度协程。这是因为,这些通用的协程库并不是专为服务器设计的,服务器中可以由客户端网络连接的建立,驱动着创建出协程,同时伴随着请求的结束而终止。在协程的运行条件不满足时,多路复用框架会将它挂起,并根据优先级策略选择另一个协程执行。因此,使用协程实现服务器端的高并发服务时,并不只是选择协程库,还要从其生态中找到结合 IO 多路复用的协程框架(Tornado、FastAPI、Sanic 等等),这样可以加快开发速度。

我们有时会将 Tornado、FastAPI 之类的框架也叫作异步框架,这个说法不是太严谨,准确的说它们应该是协程框架。当然了,协程是在异步化之上批了一层外衣,所以叫它们为异步框架也可以,我们理解就好。

归纳一下

以上我们就从高并发的应用场景入手,分析了协程出现的背景和实现原理,以及它的应用范围。你会发现,协程融合了多线程与异步化编程的优点,既保证了开发效率,也提升了运行效率。有限的硬件资源下,线程通过微观上时间片的切换,实现了同时服务上百个用户的能力。多线程的开发成本虽然低,但内存消耗大,切换次数过多,无法实现高并发。

异步编程方式通过非阻塞系统调用和多路复用,把原本属于内核的请求切换能力,放在用户态的代码中执行。这样,不仅减少了每个请求的内存消耗,也降低了切换请求的成本,最终实现了高并发。然而,异步编程违反了代码的内聚性,还需要业务代码关注并发细节,开发成本很高。

协程参考内核通过 CPU 寄存器切换线程的方法,在用户态代码中实现了协程的切换,既降低了切换请求的成本,也使得协程中的业务代码不用关注自己何时被挂起,何时被执行。相比异步编程中要维护一堆数据结构表示中间状态,协程直接用代码表示状态,大大提升了开发效率。但是在协程中调用的所有 API,都需要做非阻塞的协程化改造。优秀的协程生态下,常用服务都有对应的协程 SDK,方便业务代码使用。开发高并发服务时,与 IO 多路复用结合的协程框架可以与这些 SDK 配合,自动挂起、切换协程,进一步提升开发效率。

最后,协程并不是完全与线程无关。因为线程可以帮助协程充分使用多核 CPU 的计算力,而且遇到无法协程化、会导致内核切换的阻塞函数,或者计算太密集从而长时间占用 CPU 的任务,还是要放在独立的线程中执行,以防止它影响所有协程的执行。

Tornado 发送网络请求

本来下面应该介绍 Tornado 的协程,但是 Tornado 内部有几个模块也比较重要,我们来先把这些模块说了,了解它们有助于我们更好的理解协程。

Tornado 内部有一个 httpserver,它是用来创建 HTTP 服务器的模块,通过该模块下的类 HTTPServer,我们可以创建出一个单线程、非阻塞的服务器。同理,除了 httpserver,Tornado 内部还有一个 httpclient 模块,用于创建 HTTP 客户端,并发送请求。

from tornado import httpclient

# 创建一个客户端
client = httpclient.HTTPClient()
# 获取 url 对应的页面
response = client.fetch("http://www.baidu.com")
print("百度一下".encode("utf-8") in response.body)  # True

用起来还蛮方便的,但是 fetch 里面我们只传递了一个 url,如果还想传递请求头、请求方法、请求体等参数,该怎么做呢?答案是传递一个 httpclient.HTTPRequest。

from tornado import httpclient

client = httpclient.HTTPClient()
# 创建一个 HTTPRequest 实例
# 里面的参数很多,可以点进源码中查看,有很详细的注释,这里只罗列了一些常用的
request = httpclient.HTTPRequest(
    url="http://www.baidu.com",  # 请求路径
    method="GET",  # 请求方法
    headers=None,  # 请求头,可以是一个字典,或者是 httputil.HTTPHeaders
    body=None,  # 请求体
    auth_username=None,  # 用户名,用于 HTTP 认证
    auth_password=None,  # 密码,用于 HTTP 认证
    connect_timeout=None,  # 初始连接的超时时间,默认 20 秒
    request_timeout=None,  # 请求的超时时间,默认 20 秒

)
# 传递到 fetch 方法中,如果只传递 url,那么 Tornado 也会自动包装成 HTTPRequest 实例
response = client.fetch(request)
print("百度一下".encode("utf-8") in response.body)  # True

既然传递的是 httpclient.HTTPRequest,那么返回的我们猜应该是 httpclient.HTTPResponse,没错,就是如此。

from tornado import httpclient

client = httpclient.HTTPClient()
response = client.fetch("http://www.baidu.com")
print(
    "请求时的 HTTPRequest 对象:", response.request
)  # 请求时的 HTTPRequest 对象: <tornado.httpclient.HTTPRequest object at 0x00000276149E56A0>

print("状态码:", response.code)  # 状态码: 200
print("状态码对应的描述:", response.reason)  # 状态码对应的描述: OK
print("响应头:", response.headers)
# 这里没有跳转,就是本身
print("跳转之后最终的 url:", response.effective_url)  # 跳转之后最终的 url: http://www.baidu.com
print("响应体(原始的字节流):", response.body)
# 响应体其实是放在 response.buffer 里面,这是一个 BytesIO 缓冲区
# response.buffer.getvalue() 拿到的就是 response.body
print(response.buffer.getvalue() == response.body)  # True
# 如果报错了,显式异常
print(response.error)  # None
print(
    "请求开始到结束总共花了多长时间:", response.request_time
)  # 请求开始到结束总共花了多长时间: 0.09774661064147949
print(
    "请求执行时的开始时间(时间戳):", response.start_time
)  # 请求执行时的开始时间(时间戳): 1645768801.43936

上面发送请求的方式是同步发送的,Tornado 还支持异步发送请求。

from tornado import httpclient

# 使用 AsyncHTTPClient 创建客户端
client = httpclient.AsyncHTTPClient()
# 里面传递 HTTPRequest,这里我们就用 url 了
# 在 6.0 之前里面还有一个 callback 参数,可以指定一个回调函数
# 因为是异步的,所以请求发完之后不会阻塞,但当数据返回了要如何通知呢?
# 没错就是通过回调的方式,但我们说这种做法不太行,因为它将逻辑分割成了多个函数
future = client.fetch("http://www.baidu.com")

因为改成了协程,所以现在的  Tornado 不需要我们指定回调了,它返回的是一个 Future 对象。那么问题来了,什么是 Future 对象呢?下面来聊一聊。

解密 Future 对象

关于 Future 对象,我之前介绍过两个标准库,分别是concurrent.futuresasyncio,可以看一下,里面介绍了 Future 的用法。当然不看也无所谓,下面也会解释。

高版本的 Tornado,底层直接基于 asyncio,当然里面的 Future 也是直接用的 asyncio.Future。

import asyncio
# 早期 Tornado 实现了自己的 Future 类,但现在改成直接使用 asyncio.Future
# 因此 concurrent 模块主要包含了 Tornado 为 Future 实现的一些工具函数
# 虽然这个模块在内部非常重要,但我们开发应用程序的时候很少会直接使用此模块
from tornado.concurrent import Future

print(Future is asyncio.Future)  # True

future 被称为未来对象,它里面了包含了任务的执行状态。一旦协程 return 了,就会执行 set_result 方法,设置返回值,然后再将任务的状态标记为 "已完成",后续通过 future.result() 即可拿到值。

import asyncio
from tornado.concurrent import Future

# 每一个协程在添加到事件循环中都会被包装成一个 Task,而 Task 是 Future 的子类
# 所以如果是基于协程创建,那么得到的既是 Task 对象、也是 Future 对象
# 因此可以把 Future 理解为 Task 的容器,负责监视 Task 的执行状态
print(issubclass(asyncio.Task, Future))  # True
# 而 Task 有三种状态:"PENDING"、"CANCELLED"、"FINISHED"
# 分别表示:正在执行中、已取消、已完成
# 一旦内部协程 return 了,那么执行 set_result 方法,参数就是 "返回值"
# 而在 set_result 方法里面,会将返回值设置给属性 _result,并将任务状态(属性 _state)设置为 "FINISHED"
# 所以任务是否执行完成,取决于是否执行了 set_result

async def coro1():
    return "嘿嘿"

# 创建一个 Future 对象,当然啦,内部实际上创建的是 Task 对象
# 但 Task 是 Future 的子类,所以它也是 Future 对象
future = asyncio.ensure_future(coro1())
# 等待执行,当协程执行完毕之后,会返回 "嘿嘿"
# 然后通过 set_result 将返回值设置给内部的 _result 属性,并将任务标记为已完成
loop = asyncio.get_event_loop()
loop.run_until_complete(future)
# 执行完毕之后,来查看一下
print(future._result)  # 嘿嘿
# 但是不推荐这种方式,因为 Future 给我们提供了相关接口获取返回值
print(future.result())  # 嘿嘿

相信此时你对 Future 对象有一定认识了,它就有点类似我们请求一个异步的接口一样,这个接口会立即给你返回结果。至于 Future 对象里面的任务到底有没有执行完,我们并不知道,后续还需要查询 Future 提供的接口,来判断这个任务到底有没有执行完毕。

但是 Future 对象的内容,还不止这些,我们还要再聊一聊。因为理解了 Future 对象,你就知道 Tornado 的内部是怎么实现的了。

import asyncio
from tornado.concurrent import Future

# 基于协程创建的对象其实是 Task 对象,但 Task 对象也是 Future 对象
# 而我们也可以直接创建一个 Future 对象,显然此时它不是 Task 对象
future = Future()
print(isinstance(future, asyncio.Task))  # False
# 虽然当前 future 里面没有 task,但我们就假装它有
# 调用 future.done() 可以判断任务是否完成
print(future.done())  # False

# 这里我们直接手动调用 set_result,就代表任务执行完毕了
# 因为任务是否执行完毕,取决于 future 的 _state 属性是否是 "FINISHED"
# 而在调用 set_result 的时候,会执行 self._state = _FINISHED
future.set_result("执行完了")
print(future.result())  # 执行完了
print(future.done())  # True

如果当任务执行的时候出现异常了,会怎么样呢?如果出现了异常,那么会调用 set_exception 方法将异常设置进去,通过调用 exception 方法将异常获取出来。

from tornado.concurrent import Future

future = Future()
future.set_exception(ValueError("我设置了异常!!!"))
print(future.exception())  # 我设置了异常!!!
print(future.exception().__class__)  # <class 'ValueError'>

# 调用 result 是获取返回值,但如果出现了异常,再调用 result 就会将异常抛出来
try:
    future.result()
except ValueError as e:
    print(e)  # 我设置了异常!!!

# 无论是 set_result 还是 set_exception,或者说无论是 return 了还是出异常了
# 一旦执行,就代表任务已经执行完毕了

问题来了,我们能不能同时设置 result 和 exception 呢?显然用鼻子想也知道不可以。无论是 set_result 还是 set_exception 都会将任务状态从 "PENDING" 修改为 "FINISHED",并且当状态不为 "PENDING" 时调用它们会报错,所以它们只能调用一次。我们看一下源代码:

# asyncio/future.py

    def set_result(self, result):
        # 如果状态不为 _PENDING 会报错
        if self._state != _PENDING:
            raise exceptions.InvalidStateError(f'{self._state}: {self!r}')
        self._result = result    # 将返回值设置给属性 _result
        self._state = _FINISHED  # 设置状态为 "FINISHED"
        self.__schedule_callbacks()
        
    def set_exception(self, exception):
        # 如果状态不为 _PENDING 也会报错
        if self._state != _PENDING:
            raise exceptions.InvalidStateError(f'{self._state}: {self!r}')
        # 我们抛出异常时,比如 ValueError,标准写法应该是 ValueError(),然后里面还可以传参
        # 但如果你不需要传参,那么不加小括号在 Python 里面也是合法的,会自动帮你加,比如这里
        if isinstance(exception, type):
            exception = exception()
        # 但是我们不可以将 StopIteration 引到 Future 里面来,其它异常可以
        if type(exception) is StopIteration:
            raise TypeError("StopIteration interacts badly with generators "
                            "and cannot be raised into a Future")
        self._exception = exception  # 将返回值设置给属性 _exception
        self._state = _FINISHED      # 设置状态为 "FINISHED"
        self.__schedule_callbacks()
        self.__log_traceback = True        

所以 set_result 和 set_exception 都会将任务状态标记为已完成,下面实际演示一遍:

import sys
from tornado.concurrent import Future

future = Future()
# 执行完之后会将 _state 修改为 "FINISHED"
future.set_result("xxx")

try:
    # 会发现 _state 不为 "PENDING",因此报错
    future.set_exception(ValueError)
except Exception as e:
    exc_type, exc_value, _ = sys.exc_info()
    print(exc_type)  # <class 'asyncio.exceptions.InvalidStateError'>
    print(exc_value)  # invalid state

同理我们调用 result 和 exception 方法时,必须要等到任务状态为已完成、也就是 "FINISHED" 之后才可以,或者说必须等到调用完了 set_result 或 set_exception 之后才可以。先看一下源代码:

# asyncio/future.py

    def result(self):
        # 任务状态为已取消,抛出 CancelledError
        if self._state == _CANCELLED:
            raise exceptions.CancelledError
        # 任务状态不为已完成,抛出 InvalidStateError
        # 意思就是要先执行 set_result 或 set_exception 将状态标记为已完成,才可以调用我
        if self._state != _FINISHED:
            raise exceptions.InvalidStateError('Result is not ready.')
        self.__log_traceback = False
        # 如果 _exception 不为 None,证明出现异常了
        # 状态变成 _FINISHED 是在 set_exception 里面发生的
        # 既然出现异常了,那么就把它抛出来
        if self._exception is not None:
            raise self._exception
        return self._result

    def exception(self):
        # 任务状态为已取消,抛出 CancelledError
        if self._state == _CANCELLED:
            raise exceptions.CancelledError
        # 任务状态不为已完成,抛出 InvalidStateError
        # 意思就是要先执行 set_result 或 set_exception 将状态标记为已完成,才可以调用我
        if self._state != _FINISHED:
            raise exceptions.InvalidStateError('Exception is not set.')
        self.__log_traceback = False
        # 返回异常,但如果 _state 变成 _FINISHED 是在 set_result 里面发生的
        # 那么这里返回的就是 None
        return self._exception

还是比较好理解的,我们演示一下:

import sys
from tornado.concurrent import Future

future = Future()
try:
    future.result()
except Exception:
    exc_type, exc_value, _ = sys.exc_info()
    print(exc_type)  # <class 'asyncio.exceptions.InvalidStateError'>
    print(exc_value)  # Result is not set.

try:
    future.exception()
except Exception:
    exc_type, exc_value, _ = sys.exc_info()
    print(exc_type)  # <class 'asyncio.exceptions.InvalidStateError'>
    print(exc_value)  # Exception is not set.

因为状态不为 "FINISHED",所以调用就会报错,那么我们能不能手动修改状态呢?答案是不能的,因为 Future 这个类底层逻辑是用 C 实现的,而尝试修改 _state 属性时会报错。

from tornado.concurrent import Future

future1 = Future()
# 调用 set_result,任务状态为已完成
# 但没有异常,所以 future1.exception() 为 None
future1.set_result("xxx")
print(future1.exception())  # None


future2 = Future()
# 执行 set_exception,任务状态为已完成
# 但由于出现了异常,执行 future2.result() 会将异常抛出来
future2.set_exception(IndexError)
future2.result()
"""
Traceback (most recent call last):
  File ".../main.py", line 18, in <module>
    future2.result()
IndexError
"""

相信到此,Future 对象的面纱就被我们揭开了,只不过这里是手动创建的 Future 对象,而在实际工作中,Tornado 会根据我们定义的协程自动创建 Future 对象。当然这两者本质都是一样的,当协程返回时会调用 set_result,当协程执行出现异常时会调用 set_exception,然后标记任务状态为 "FINISHED"。整个过程和我们这里演示的没有区别,只不过我们当前是直接创建的 Future 对象,压根不存在所谓的任务,所以我们需要手动调用这两个方法来模拟任务完成;而基于协程创建的 future,在协程返回或者出现异常时会自动调用这两个方法。

所以 Future 虽然是 Tornado 内部的一个非常重要的对象,但我们编写的应用程序很少会直接和它交互。

import asyncio

async def foo():
     1 / 0

# 基于协程创建 Future 对象,其实这一步也不需要我们来做
# 在框架内部会自动做,只不过我们需要观察到现象
# 但是说到底,我们还是没有直接用 Future 这个类去创建
future = asyncio.ensure_future(foo())
# 获取事件循环
loop = asyncio.get_event_loop()
# 等待任务执行完毕,由于这里明显出现了异常,那么会自动调用 set_exception 将异常设置进去
# 如果没有出现异常,那么协程返回时,会自动调用 set_result 设置返回值
try:
    loop.run_until_complete(future)
except ZeroDivisionError:
    pass

print(future.exception())  # division by zero
print(future.exception().__class__)  # <class 'ZeroDivisionError'>

可以看到表现和我们之前直接使用 Future 这个类是一致的,但是可能会有人好奇,为什么要出现一个异常捕获呢?答案是 loop.run_until_complete 这个方法是有返回值的,返回值就是被包装成 Future 对象的协程的返回值,那么显然,最终在该方法内部会调用 future.result()。而我们上面在介绍 Future 的时候演示过,当出现异常时,再调用 result 就会将异常抛出来。

import asyncio

async def foo():
    return "bar"

future = asyncio.ensure_future(foo())
loop = asyncio.get_event_loop()
print(loop.run_until_complete(future))  # bar

print(future.result())  # bar
print(future.exception())  # None

如果正常返回是没有问题的,并且 loop.run_until_complete 也会返回 future.result()。

补充一下:虽然我们的主角是 Tornado,但是到目前为止,有很大的笔墨用在了 asyncio 上面。因为我们说过,Tornado 在早期是基于生成器自己实现了协程,只需要打上装饰器 @gen.coroutine 即可,并且也实现了一套事件循环调度方案。

但是随着 Python 原生协程的出现以及协程库 asyncio 的流行,Tornado 也开始支持原生协程,并且摒弃了之前自己实现的事件循环,直接基于 asyncio。所以理解 Tornado 内部机制的前提,首先要了解 Python 的协程以及 asyncio,因为 Tornado 就是基于协程和协程库 asyncio 实现的协程框架。

你以为 Future 对象就到此结束啦?还没有,Future 对象还支持添加回调。咦,为啥还要有回调呢?我们知道协程在被包装成 Future 对象之后会立即返回,当内部出现了阻塞(用户态)时,会将控制权交给事件循环(会驱动其它任务执行),当阻塞结束时,事件循环再找一个合适的时机切换回来。这一切都是由 asyncio 提供的功能,但我们说 asyncio 只是一个协程库,它只提供了协程的创建、挂起、恢复执行等基本方法,却没有实现 web 框架的功能。

我们希望的是,当和客户端建立连接时,协程能自动创建;读写就绪时自动调用绑定的 handler;同时请求结束时自动终止、并断开连接。而这些功能是协程库所不具备的,需要在协程库之上引入 web 框架的功能(即协程框架,比如 Tornado),而想实现这一点就依赖于 Future 可以添加回调这一特性了。我们先来看看怎么给 Future 对象添加回调:

import asyncio

async def coro():
    return "古明地觉"

future = asyncio.ensure_future(coro())
# 给 future 绑定一个回调
# 该回调只接收一个参数,显然就是 future
future.add_done_callback(lambda f: print(f"执行完了, 返回值: {future.result()}"))

# 创建事件循环并运行
loop = asyncio.get_event_loop()
loop.run_until_complete(coro())
# 以下是执行回调时打印的输出
"""
执行完了, 返回值: 古明地觉
"""

可能有人好奇了,这里的回调和之前说的基于 epoll 的回调有什么区别呢?答案是之前的回调是注册给 epoll 的,当读写就绪时再由 epoll 通知线程来执行回调。而这里的回调是注册给 Future 对象的,它由事件循环负责调用。当协程 return 或者出现异常时,那么将执行的控制权交给事件循环,再由事件循环调用对应的 Future 对象的 set_result 或 set_exception 方法,表示任务执行完毕了。

当任务执行完毕时,事件循环会检测 Future 对象有没有绑定回调,没有的话则驱动其它的任务执行,有的话则执行绑定的回调。回调只能接收一个参数,如果需要多个参数,那么可以考虑使用偏函数。

因此,虽然都是回调,但两者有着本质的不同,给 Future 对象注册的回调不需要经过内核态。

然后还有一个重点,回调是由事件循环负责调用的,我们将上面的代码修改一下:

from tornado.concurrent import Future

future = Future()
future.add_done_callback(lambda f: print(f"执行完了, 返回值: {future.result()}"))
future.set_result("古明地觉")
print(future.result())  # 古明地觉

虽然返回值设置进去了,也能打印出来,但是回调并没有得到执行。原因是我们没有创建事件循环,而回调是由事件循环驱动执行的。

还是之前提到的,Future 对象虽然很重要,但我们很少会基于 Future 这个类直接创建,而是创建协程,由框架基于协程来自动创建 Future 对象。只不过我们这里为了更直观的理解,手动创建了 Future 对象。

那么回到开始的问题,Future 添加回调和 Tornado 的实现到底有什么关系呢?下面单独解释。

Tornado 的事件循环

Tornado 的事件循环基于 asyncio.get_event_loop() 创建,它只负责调度协程,那么如何能够在请求到来时,调用视图类呢?没错,就是 Future 的回调特性。

当客户端请求到来时,会创建协程进行连接,没错,连接 socket 也是基于协程实现的,因为肯定不能用同步阻塞的 socket。然后根据协程创建 Future 对象,丢到 Event Loop、或者说 IO Loop 中。而在文章的最开始我们就说过,事件循环封装了 epoll,可以同时管理大量的描述符(和文件一样,每个 socket 也有一个描述符)。

然后当客户端发消息时,代表有连接已经可以读了,这时候事件循环会读取消息,根据消息中的 URL 去 Application 对象中查询,找到与之绑定的视图类 handler,注册到 Future 的回调中。可能有人觉得这不是多此一举吗?我都找到视图类了,直接调用不行吗?为啥要注册到 Future 的回调里面,再进行调用呢?其实很简单,因为这个 handler 可以调用多次,一旦绑定,那么后续就不用再进行路由匹配了,所以说 Tornado 很适合长轮询。

这里我们回到 Tornado 发送异步网络请求上面来:

from tornado import ioloop, web, httpclient

client = httpclient.AsyncHTTPClient()
future = client.fetch("http://www.baidu.com")
# 得到的是 _asyncio.Future
print(future.__class__)  # <class '_asyncio.Future'>
# 直接打印的话处于 pending 状态
# 因为任务、或者说协程必须靠事件循环才能运行
print(future)  # <Future pending>

# 我们给添加到事件循环中
loop = ioloop.IOLoop.current()
# add_future 方法接收一个 future、和回调,会自动调度 future,执行任务
# 一旦任务完成,就会将 future 作为参数触发回调
# 回调函数中的 f 负责接收 future,而 f.result() 显然就是 HTTPResponse 对象
loop.add_future(future,
                lambda f: print("百度一下".encode("utf-8") in f.result().body))
# 然后启动事件循环,此时会驱动任务执行
loop.start()
"""
True
"""

此时请求才算成功执行。那么如果是放在 handler 里面呢?

from tornado import web, ioloop, httpclient

class IndexHandler(web.RequestHandler):

    async def get(self):
        """
        使用 async def 即可定义一个协程,Tornado 支持原生协程
        :return:
        """
        url = self.get_query_argument("url")
        client = httpclient.AsyncHTTPClient()
        # client.fetch 返回 future,本来要用回调才能执行的
        # 但很明显,一个函数就能执行的事情,为啥要拆成两个函数来执行呢?
        # 因此通过 await 会自动将 client.fetch(url) 这个 future 加入到事件循环中
        # 事件循环驱动 client.fetch(url) 执行,并且执行完之后会自动将结果(调用 result)返回给 response
        response = await client.fetch(url)
        # 所以这就是协程的好处,我们无需用两个函数实现,可以用同步的方式编写异步的代码
        # 并且当阻塞了,还可以进行切换,调度其它的协程运行
        # 但前提是 await 后面要是一个协程、或者 Future 对象
        # 而 requests.get 是不能放在 await 后面的,如果直接通过 response = requests.get(url)
        # 那么就会发生阻塞,必须等待 requests.get 返回之后才可以继续执行
        # 此时就实现不了高并发的效果了,因此对于一个 IO 操作,必须在用户态重新实现,不能进入内核态
        await self.finish(response.body)

app = web.Application(
    [web.url(r"/", IndexHandler)]
)
app.listen(9000)
# 启动事件循环
loop = ioloop.IOLoop.current()
loop.start()

测试一下:

所以要想执行 Future 里面的任务、或者说协程,必须借助事件循环,执行完之后会自动通过 set_result 设置返回值。尽管我们也可以手动 set_result,但这没有什么意义,举个栗子:

from tornado import httpclient

client = httpclient.AsyncHTTPClient()
future = client.fetch("http://www.baidu.com")
future.set_result("xxx")
print(future.result())  # xxx

此时拿到的结果是我们手动设置,至于获取页面这个操作压根就没有执行。如果希望任务得到执行,那么必须由事件循环进行驱动,执行完之后内部会通过 set_result 设置返回值,此时再调用 result 才是我们想要的结果。而拿到结果也有两种方式,一种是绑定回调,任务完成后会自动将 future 作为参数传递到回调函数中;另一种是使用 await 的方式,通过 await 一个 future、或者协程(协程也会被包装成 future),那么也会驱动执行,并且会自动调用 result 将结果返回。但是 await 必须出现在协程函数中,所以使用 await 这种方式,必须再定义一个协程。

import asyncio
from tornado.concurrent import Future

future = Future()
# 调用了 set_result,假设任务执行完毕了
future.set_result("执行好了")
# 我们可以通过绑定回调,将其扔到事件循环中运行
# 但也可以使用 await,只不过需要额外定义一个协程
async def foo():
    # 因为 foo 是一个协程函数,那么 foo() 必须在事件循环中运行
    # 而在 foo 里面 await future 也相当于将 future 加入到了事件循环中
    # 当任务执行结束, await future 会自动返回 future.result()
    res = await future
    print(f"res: {res}")

asyncio.run(foo())
"""
res: 执行好了
"""

而我们在编写 Tornado 服务的时候,就像平常使用协程一样,直接 async def 即可。对于那些阻塞操作使用 await,将控制权交出去就行。所以 Tornado 使用编写异步服务的方式和同步服务并没有什么差别,只需要将 def 换成 async def,将阻塞操作前面加上 await。

比如 self.finish,它前面也可以加上 await,因为刷新缓冲区也属于 IO,不耗费 CPU。那么在刷新的这段时间,完全可以让 CPU 去做别的事情,当然啦,还有 self.flush。至于 self.write 则不行,它负责往缓冲区写数据,因此它不是一个阻塞操作,所以前面不可以加 await。

还是回到我们上面的那个服务,假设我们有两个请求到来,分别是:

  • http://localhost:9000/?url=http://www.google.com
  • http://localhost:9000/?url=http://www.baidu.com

由于我当前无法访问谷歌,所以第一个请求过来时会阻塞,但这是协程阻塞了,协程背后的线程没有阻塞。此时会将控制权交给事件循环,事件循环驱动其它任务执行,所以当第二个请求过来时, 是可以提供服务的,因此第二个请求不受影响。但如果将获取的方式从 await client.fetch 改成 requests.get,那么两个请求都会卡住。因为 requests.get 底层是同步的 socket,会触发操作系统的内核调用,那么此时整个线程就阻塞了。而线程阻塞,那么后续请求就都没办法服务了。

到此 Tornado 的事件循环我们算是理清楚了,但它还提供了一些 API,我们来看一下。

run_sync

运行某个函数:

from tornado import ioloop

loop = ioloop.IOLoop.current()
loop.run_sync(lambda: print("哼哼"))
"""
哼哼
"""

它和 asyncio.run 类似,在内部会启动一个事件循环,并通过 add_future 加入到事件循环中进行驱动。而绑定的回调就是 self.stop,这个 self 就是事件循环本身。因此在执行结束后,事件循环会关闭。

另外这个函数不需要参数,并且返回值要么是 None、要么是一个协程、或者 Future 对象,更准确的说是一个 awaitable 对象。然后会继续驱动这个 awaitable 对象,直到它执行结束。

from tornado import ioloop
from tornado.concurrent import Future

class A:

    def __await__(self):
        print("我被 await 了")
        f = Future()
        f.set_result("result")
        return f

loop = ioloop.IOLoop.current()
# 如果自定义类实现了 __await__ 魔法方法
# 那么实例对象就是 awaitable 对象
# 然后执行 __await__,但是又返回了 future
# 那么会驱动返回的 future 继续执行
print(loop.run_sync(lambda: A()))
"""
我被 await 了
result
"""

await obj 本质上是调用了 obj 的 __await__ 方法,包括 future 也是如此。

import asyncio

future = asyncio.Future()
future.set_result("返回值")

try:
    # 还需要一步 __next__()
    future.__await__().__next__()
except StopIteration as e:
    print(e.value)  # 返回值

Python3 的协程比想象中的难理解很多,如果不熟悉的话可能看的会有点懵。当然,在编写服务的时候,也用不到这些。

add_callback

往事件循环里面加一个回调函数,等到下一次循环迭代的时候,会进行调用。

from tornado import ioloop

loop = ioloop.IOLoop.current()
loop.add_callback(lambda a, b, c: print(a + b + c), 1, 2, 3)
loop.add_callback(lambda a, b, c: print(a * b * c), 2, 3, 4)
loop.start()
"""
6
24
"""

此外还有几个方法,分别是 call_at、call_later、add_timeout,作用和 add_callback 类似,但是会延迟调用。

run_in_executor

将一个无法异步化的函数放到线程池中运行,比如我们连接 Hive,现在还找不到一款合适的连接 Hive 的协程驱动。只能使用同步的驱动去连接,但是当获取数据的时候,整个线程就阻塞了。所以为了避免影响其它任务,我们会将它丢到线程池中运行。

from tornado import ioloop
import time

def foo():
    time.sleep(5)
    print("我执行完了")

loop = ioloop.IOLoop.current()
# 线程池可以通过 concurrent.futures.ThreadPoolExecutor 创建
# 如果传递一个 None,内部会自动创建
# 会返回一个 future
future = loop.run_in_executor(None, foo)
print("会立即执行")
"""
会立即执行
我执行完了
"""

Tornado 的事件循环我们就介绍到这里,还是那句话,理解 Tornado 的前提条件是要理解协程和 asyncio。但是我们在编写应用程序的时候,不需要想太多,因为在使用层面上还是很简单的,直接 async + await 即可,基本不会错。实在无法异步的情况下,就扔到线程池里面去运行。

tornado.queues

Tornado 也提供了基于协程的异步队列,这些队列和 asyncio 提供的队列具有非常高的相似性,但是 Tornado 没有直接使用 asyncio 提供的队列。

import asyncio
from tornado import ioloop
from tornado.queues import Queue

queue = Queue()

async def producer():
    for i in range(1, 10):
        print(f"put task{i}")
        await queue.put(f"task{i}")
        await asyncio.sleep(0.1)
    await queue.put(None)

async def consumer():
    async for item in queue:
        if item is None:
            break
        print(item)
        queue.task_done()

async def main():
    await asyncio.gather(producer(), consumer())

ioloop.IOLoop.current().run_sync(main)
print("Done")
"""
put task1
task1
put task2
task2
put task3
task3
put task4
task4
put task5
task5
put task6
task6
put task7
task7
put task8
task8
put task9
task9
Done
"""

比较简单,可以对着源码看一遍。

tornado.gen

在早期 Python 还不支持原生协程的时候,tornado 基于生成器自己实现了协程,而相关逻辑就位于 gen 模块中。而现在这个模块依然存在,如果你当前是使用 Tornado 开发新项目,那么 gen 这个模块可以忽略掉,直接使用原生协程就好。但如果是老项目,那么这个模块还是有必要了解一下的。

class IndexHandler(web.RequestHandler):
    @gen.coroutine
    def get(self):
        url = self.get_query_argument("url")
        client = httpclient.AsyncHTTPClient()
        res = yield client.fetch(url)
        self.finish(res.buffer.getvalue())

app = web.Application(
    [web.url("/", IndexHandler)]
)

通过 @gen.coroutine 装饰成一个协程,里面的 yield(也可以是 yield from)就类似于 await。除了上面的写法外,还有一种写法:

class IndexHandler(web.RequestHandler):
    @gen.coroutine
    def get(self):
        res = yield self.get_data()
        self.finish(res.buffer.getvalue())

    @gen.coroutine
    def get_data(self):
        url = self.get_query_argument("url")
        client = httpclient.AsyncHTTPClient()
        res = yield client.fetch(url)
        raise gen.Return(res)

两种做法均可,但还是那句话,新项目的话,直接 async def + await 即可。

Tornado 的 WebSocket

WebSocket 是一个真正的全双工通信协议,与 TCP 一样,客户端和服务器都可以随时向对方发送数据,而不用像 HTTP 那么客套。于是,服务器就可以变得更加主动了,一旦后台有新的数据,就可以立即推送给客户端,不需要客户端轮询,实时通信的效率也就提高了。

WebSocket 采用了二进制帧结构,语法、语义与 HTTP 完全不兼容,但因为它的主要运行环境是浏览器,为了便于推广和应用,就不得不搭便车,在使用习惯上尽量向 HTTP 靠拢,这就是它名字里 Web 的含义。服务发现方面,WebSocket 没有使用 TCP 的 IP 地址 + 端口号,而是延用了 HTTP 的 URI 格式,但开头的协议名不是 http,引入的是两个新的名字:ws 和 wss,分别表示明文和加密的 WebSocket 协议。

from tornado import ioloop, web, websocket

class WebSocketHandler(websocket.WebSocketHandler):

    async def open(self):
        """
        当客户端连接时会执行
        :return:
        """
        print(f"`{self.request.remote_ip}` 已登录")
        await self.write_message(f"欢迎用户 `{self.request.remote_ip}` 登陆")

    async def write_message(self, message, binary=False):
        """
        执行此方法可给客户端发消息
        :param message: 消息,默认传递字符串即可,会自动以 UTF-8 格式编码
        :param binary: 是否以二进制发送,如果为 True,那么 message 需要手动编码成 bytes 对象
        :return:
        """
        return await super(WebSocketHandler, self).write_message(message, binary)

    async def on_message(self, message):
        """
        当用户发消息时,会执行此方法
        :param message:
        :return:
        """
        await self.write_message(f"收到消息: {message}")

    def on_close(self):
        """
        当用户断开连接时,会执行此方法
        :return:
        """
        print(f"`{self.request.remote_ip}` 断开了")
        # 关闭 WebSocket 连接
        self.close()

    def check_origin(self, origin):
        """
        该方法会最先执行,用于对 origin 进行检测,只有合法的 origin 才会放行
        这里直接返回 True,就不对 origin 检测了
        注意:必须有这个方法,否则会抛出 403 错误
        :param origin: eg. http://localhost:63342
        :return:
        """
        return True


app = web.Application(
    [web.url("/ws", WebSocketHandler)]
)

app.listen(9000, "0.0.0.0")
ioloop.IOLoop.current().start()

然后我们通过浏览器去连接一下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <script>
        ws = new WebSocket("ws://localhost:9999/ws");

        //如果连接成功, 会打印下面这句话, 否则不会打印
        ws.onopen = function () {
            console.log('连接成功')
        };

        //接收数据, 服务端有数据过来, 会执行
        ws.onmessage = function (event) {
            console.log(event)
        };

        //服务端主动断开连接, 会执行.
        //客户端主动断开的话, 不执行
        ws.onclose = function () {  }

    </script>
</body>
</html>

以上就是客户端的输出,再来看看服务端:

显然第一行输出是客户端建立连接、执行 open 方法时打印的,第二行输出是客户端断开连接、执行 on_close 方法时打印的。

小结

Tornado 相关的部分就介绍到这里,其实单从使用层面上来讲还是比较简单的,但如果深挖里面的细节的话,还是有难度的。当然这些难度并不来源于 Tornado,而是来自于 Python 的协程、以及 asyncio 的使用。总之还是那句话,理解 Tornado 的前提是理解协程和 asyncio。

另外我们使用 Tornado 还可以编写 TCP 服务、发送 TCP 请求,位于 tcpserver 和 tcpclient 模块中,有兴趣可以看一下。

posted @ 2022-02-27 17:29  古明地盆  阅读(764)  评论(0编辑  收藏  举报