翻译:Tornado官方文档用户指南
介绍
Tornado是一个Python web框架,同时也是一个异步网络库,最初开发于FriendFeed。通过非阻塞网络I/O,Tornado的规模可以达到万级开发式连接,使其成为那些要求与用户建立长期连接的长轮询,WebSockets,和其他应用程序的理想web框架。
Tornado可以大致分为4大主要部分:
- 一个Web框架(包括创建Web应用程序的子类RequestHandler和各种支持类)
- HTTP的客户端和服务端实现(HTTPServer和AsyncHTTPClient)
- 一个异步网络库,包含IOLoop和IOStream类,作为HTTP组件的构建块,也可以用于实现其他协议。
- 一个协程库(tornado.gen),它使异步代码可以使用比链式回调更直截了当的方式编写。其类似于Python3.5(async def)中引入的本机协程特性。如果本机协程可用,则建议代替tornado.gen模块。
Tornado web框架和HTTP服务器一起为WSGI提供了全栈替代方案。虽然可以将Tornado HTTP服务器作为其他WSGI框架(WSGIContainer)的容器,但这种组合具有局限性,要充分发挥Tornado的优势,你需要结合HTTP服务器一起使用Tornado
异步和非阻塞I/O
实时的web功能要求每个用户都保持一个长期空闲的连接。在传统的同步web服务中,该要求意味着要为每一个用户都投入一个线程,这对于服务器资源来说,无疑是非常昂贵的。
为了最小化同步连接的成本,Tornado使用了一个单线程事件循环。这意味着所有的应用代码的编写都应该以异步和非阻塞为目标,因为一次只能由一个操作处于活动状态。
异步和非阻塞这两个术语是密切关联的,并且通常可以互换使用,但他们实质并不完全相同。
阻塞
一个函数在返回前等待某件事发送时阻塞。函数可能会有很多阻塞的原因:网络I/O,磁盘I/O,互斥锁,等待。事实上,当一个函数正在运行或使用CPU时都或多或少的阻塞(举一个极端的例子来说明为什么CPU阻塞需要得到和其他类型阻塞相同程度的重视,请考虑密码散列函数比如bcrypt,其特意被设计用来占用数百毫秒的CPU时间,这远远超过了典型的网络或磁盘访问)
函数可以在某些方面时阻塞的,在其他方面也可以是非阻塞的。谈到Tornado,我们通常在网络I/O的背景下谈论阻塞,虽然所有类型的的阻塞都应当最小化。
异步
异步函数在其完成之前返回,并使一些工作在后台执行然后触发应用程序中未来的某些操作(与正常的同步函数不同,同步函数会在返回前做完所有事)。这有许多种类型的异步接口:
不管使用哪种类型的接口,异步函数根据其定义与调用方的交互都是不同的;没有自由的方法可以使一个同步函数以对其调用者透明的方式异步(像gevent系统使用轻量级的线程提供和异步系统相当的性能,但它们事实上并没有真的让事情变得异步)
Tornado异步操作通常返回占位符对象(Futures),除了一些比如IOLoop这样使用回调的低级组件。Futures通常用await或yield关键字转换成它们的结果。
示例
这是一个简单的同步函数:
from tornado.httpclient import HTTPClient
def synchronous_fetch(url):
http_client = HTTPClient()
response = http_client.fetch(url)
return response.body
接下来是一个相同的函数,异步重写为本机协程:
from tornado.httpclient import AsyncHTTPClient
async def asynchronous_fetch(url):
http_client = AsyncHTTPClient()
response = await http_client.fetch(url)
return response.body
或者为了和旧版Python兼容,使用tornado.gen模块:
from tornado.httpclient import AsyncHTTPClient
from tornado import gen
@gen.coroutine
def async_fetch_gen(url):
http_client = AsyncHTTPClient()
response = yield http_client.fetch(url)
raise gen.Return(response.body)
Coroutines协程有些神奇,但它们在内部的作用就像是这样:
from tornado.concurrent import Future
def async_fetch_manual(url):
http_client = AsyncHTTPClient()
my_future = Future()
fetch_future = http_client.fetch(url)
def on_fetch(f):
my_future.set_result(f.result().body)
fetch_future.add_done_callback(on_fetch)
return my_future
注意:协程在fetch做完之前返回它的Futures,这就是使协程异步的原因。
你可以用协程做的任何事,同样也可以通过传递回调对象的方法做到,但协程提供一个重要的简化作用,它允许您以与同步时相同的方式组织代码。这对错误处理尤其重要,因为try/except块的工作方式与协程中预期的一样。而使用回调很难实现这一点。
关于协程的讨论,将在下一章节进行深入讨论。
协调程序
Tornado建议使用协程来编写异步代码。协程使用了Python的await或yield关键词来挂起或恢复执行,以此来代替一系列的回调(像gevent这样的框架中所用到的协作轻量级线程有时也被称为协程,但在Tornado中,所有的协程都使用显式上下文切换,并被称之为异步函数)
协程几乎和异步代码一样简单,但不需要花费线程。它们还通过减少可能发生上下文切换的位置的数量来使得并发更容易理解。
示例:
async def fetch_coroutine(url):
http_client = AsyncHTTPClient()
response = await http_client.fetch(url)
return response.body
原始VS装饰后的协程
Python3.5引入了async和await关键词(使用这些关键词的函数也被叫做“原始协程”)。为了和老版本的Python兼容,你可以使用调用了tornado.gen.coroutine装饰器的”被装饰“协程或”基于yield“协程
尽可能的推荐使用原始协程。只在需要与老版Python兼容的时候使用装饰好的协程。Tornado文档中的示例通常使用原始协程。
两种形式之间的转化通常是很简明的:
# Decorated: # Native:
# Normal function declaration
# with decorator # "async def" keywords
@gen.coroutine
def a(): async def a():
# "yield" all async funcs # "await" all async funcs
b = yield c() b = await c()
# "return" and "yield"
# cannot be mixed in
# Python 2, so raise a
# special exception. # Return normally
raise gen.Return(b) return b
下面概述了这两种协程之间的其他差异。
- 原始协程:
- 通常更快;
- 可以使用async for和async with语句,这样可以使一些模板更简单;
- 不要运行,除非你或它们。装饰过的协程以被调用就可以在后台运行。注意:对于这两种协程来说,使用await或yield是非常重要的,这样以来,任何异常都会有地方可去。
- 装饰后的协程:
- 与concurrent.futures包有额外的集成,允许executor.submit的结果可以被直接yielded。而远程协程则是使用IOLoop.run_in_executor。
- 通过生成一个列表或字典来支持一些简写来等待躲过对象。原始协程中使用tornado.gen.multi来实现这一点。
- 支持与其他包的集成,包括通过一个转换函数的注册来集成Twisted。要在原始协程中访问这些功能,需要使用tornado.gen.convert_yielded。
- 总是返回一个Future对象。原始协程返回一个可等待的对象而不是一个Future。Tornado中,这两者大部分都是可以互换的。
它是怎么实现的
本节将介绍装饰协程的操作。原始协程在概览上类似,但有一点复杂,因为需要与Python的运行时间进行额外的集成。
包含yield的函数被称为生成器。所有的生成器都是异步的;调用它们会返回一个生成器对象而不是直接运行到底。@gen.coroutine装饰器通过yield表达式与生成器通信,通过返回Future与协程的调研者通信。
这是一个协程装饰器内循环的简化版本:
# Simplified inner loop of tornado.gen.Runner
def run(self):
# send(x) makes the current yield return x.
# It returns when the next yield is reached
future = self.gen.send(self.next)
def callback(f):
self.next = f.result()
self.run()
future.add_done_callback(callback)
装饰器从生成器中接收到一个Future,等待(不阻塞)Future的完成,然后剥开Future并将结果发回给生成器作为yield表达式的结果。大部分异步代码从不直接接触Future类,除了将Future类直接传递给yield表达式的异步函数。
怎样调用一个协程
协程不会以正常的方式引发异常:它们引发的任何异常都将在可等待对象中被捕获,直到它们被yield。这意味着用正确的方式调用协程很重要,非则你可能会遇到一些难被注意到的错误:
async def divide(x, y):
return x / y
def bad_call():
# This should raise a ZeroDivisionError, but it won't because
# the coroutine is called incorrectly.
divide(1, 0)
几乎在所有情况下,调用协程的任何函数都必须是协程本身,并且在调用中使用await或yield关键词。当你重写定义在超类中的方法时,请参考文档来查看是否允许协程(文档应该会指明这个方法"可能是一个协程"或返回一个’Future’):
async def good_call():
# await will unwrap the object returned by divide() and raise
# the exception.
await divide(1, 0)
有时,你可能想”fire并forget“一个协程,而不等到它的结果。这类情况建议使用IOLoop.spawn_callback,这使得IOLoop负责调用。如果它报错了,IOLoop会记录堆栈跟踪:
# The IOLoop will catch the exception and print a stack trace in
# the logs. Note that this doesn't look like a normal call, since
# we pass the function object to be called by the IOLoop.
IOLoop.current().spawn_callback(divide, 1, 0)
对于使用IOLoop.spawn_callback的建议让函数使用@gen.coroutine,对于使用[async def]的函数必须使用@gen.coroutine]()(否则协程运行器将不会启动)
最后,在程序的顶层,如果IOLoop还没有运行,你可以启动IOLoop,运行协程,然后使用IOLoop.run_sync方法停止IOLoop。这种方式通常用来启动面向批处理的主程序。
# run_sync() doesn't take arguments, so we must wrap the
# call in a lambda.
IOLoop.current().run_sync(lambda: divide(1, 0))
协程模型
调用阻塞函数
从协程调用阻塞函数最简单的方法是使用IOLoop.run_in_executor,它所返回的Futures与协程兼容:
async def call_blocking():
await IOLoop.current().run_in_executor(None, blocking_func, args)
平行性
multi函数接受值为Futures的字典或列表,同时也等待所有的Futures
from tornado.gen import multi
async def parallel_fetch(url1, url2):
resp1, resp2 = await multi([http_client.fetch(url1),
http_client.fetch(url2)])
async def parallel_fetch_many(urls):
responses = await multi ([http_client.fetch(url) for url in urls])
# responses is a list of HTTPResponses in the same order
async def parallel_fetch_dict(urls):
responses = await multi({url: http_client.fetch(url)
for url in urls})
# responses is a dict {url: HTTPResponse}
在装饰后的协程中,可以直接yield字典或列表:
@gen.coroutine
def parallel_fetch_decorated(url1, url2):
resp1, resp2 = yield [http_client.fetch(url1),
http_client.fetch(url2)]
交叉
有时保存一个Future,优于直接yield Future,因此你可以在等待前进行其他操作。
from tornado.gen import convert_yielded
async def get(self):
# convert_yielded() starts the native coroutine in the background.
# This is equivalent to asyncio.ensure_future() (both work in Tornado).
fetch_future = convert_yielded(self.fetch_next_chunk())
while True:
chunk = yield fetch_future
if chunk is None: break
self.write(chunk)
fetch_future = convert_yielded(self.fetch_next_chunk())
yield self.flush()
这对于装饰后的协程来说非常简单,因为当他们被调用时就立即启动了:
@gen.coroutine
def get(self):
fetch_future = self.fetch_next_chunk()
while True:
chunk = yield fetch_future
if chunk is None: break
self.write(chunk)
fetch_future = self.fetch_next_chunk()
yield self.flush()
循环
在原始协程中,可以使用async for。对于老版Python,循环对协程来说非常棘手,因为没办法在每一次for循环或while循环中进行yield并捕获yield的结果,相反,你需要将循环条件从访问结果中分离开,下面是Motor里的示例:
import motor
db = motor.MotorClient().test
@gen.coroutine
def loop_example(collection):
cursor = db.collection.find()
while (yield cursor.fetch_next):
doc = cursor.next_object()
后台运行
PeriodicCallback通常不和协程一起使用。相反,一个协程可以包含一个while True:循环并使用tornado.gen.sleep
async def minute_loop():
while True:
await do_something()
await gen.sleep(60)
# Coroutines that loop forever are generally started with
# spawn_callback().
IOLoop.current().spawn_callback(minute_loop)
有时可能需要更复杂的循环。比如,先前的循环没60+N秒运行一次,其中N时do_something()的运行时间。要精确每60秒运行一次,请使用上面的交错模式:
async def minute_loop2():
while True:
nxt = gen.sleep(60) # Start the clock.
await do_something() # Run while the clock is ticking.
await nxt # Wait for the timer to run out.
队列示例-并发web爬虫
Tornado的tornado.queues模块为协程实现了一个异步生产者/消费者模式,其线程的实现模式类似于Python标准库中的queue模块。
yield Queue.get的协程会禁止到队列里有一个item。如果队列设置了最大值,则其会一直禁止直到队列有容纳其他item的空间。
一个Queue从零开始计数,包含了一定数目未完成的任务。put会增加数目,task_done减少数目。
在这个网络爬虫示例中,队列一开始只包含base_url。当一个工人函数获取一个页面后解析链接并put一个新的任务到队列中,这时调用task_done来减少一个队列中的任务数目。最终,工人函数获取到的页面是之前都看过的,并且队列中也没有剩余任务。因此,工人函数调用task_done将数目减少至零。等待join的主协程是非禁止且已结束的。
#!/usr/bin/env python3
import time
from datetime import timedelta
from html.parser import HTMLParser
from urllib.parse import urljoin, urldefrag
from tornado import gen, httpclient, ioloop, queues
base_url = "http://www.tornadoweb.org/en/stable/"
concurrency = 10
async def get_links_from_url(url):
"""Download the page at `url` and parse it for links.
Returned links have had the fragment after `#` removed, and have been made
absolute so, e.g. the URL 'gen.html#tornado.gen.coroutine' becomes
'http://www.tornadoweb.org/en/stable/gen.html'.
"""
response = await httpclient.AsyncHTTPClient().fetch(url)
print("fetched %s" % url)
html = response.body.decode(errors="ignore")
return [urljoin(url, remove_fragment(new_url)) for new_url in get_links(html)]
def remove_fragment(url):
pure_url, frag = urldefrag(url)
return pure_url
def get_links(html):
class URLSeeker(HTMLParser):
def __init__(self):
HTMLParser.__init__(self)
self.urls = []
def handle_starttag(self, tag, attrs):
href = dict(attrs).get("href")
if href and tag == "a":
self.urls.append(href)
url_seeker = URLSeeker()
url_seeker.feed(html)
return url_seeker.urls
async def main():
q = queues.Queue()
start = time.time()
fetching, fetched = set(), set()
async def fetch_url(current_url):
if current_url in fetching:
return
print("fetching %s" % current_url)
fetching.add(current_url)
urls = await get_links_from_url(current_url)
fetched.add(current_url)
for new_url in urls:
# Only follow links beneath the base URL
if new_url.startswith(base_url):
await q.put(new_url)
async def worker():
async for url in q:
if url is None:
return
try:
await fetch_url(url)
except Exception as e:
print("Exception: %s %s" % (e, url))
finally:
q.task_done()
await q.put(base_url)
# Start workers, then wait for the work queue to be empty.
workers = gen.multi([worker() for _ in range(concurrency)])
await q.join(timeout=timedelta(seconds=300))
assert fetching == fetched
print("Done in %d seconds, fetched %s URLs." % (time.time() - start, len(fetched)))
# Signal all the workers to exit.
for _ in range(concurrency):
await q.put(None)
await workers
if __name__ == "__main__":
io_loop = ioloop.IOLoop.current()
io_loop.run_sync(main)
Tornado Web应用程序的结构
一个Tornado web应用服务,一般都包含一个或多个RequestHandler子类,一个application对象负责将将传入的请求路由到处理器,以及一个main()函数,来启动服务。
最小的’hello world’示例,如下所示:
import tornado.ioloop
import tornado.web
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("Hello, world")
def make_app():
return tornado.web.Application([
(r"/", MainHandler),
])
if __name__ == "__main__":
app = make_app()
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
Application对象
Application对象负责全局配置,包括将请求直向处理器的路由表。
路由表是一个URLSpec列表对象或元组,每个对象都至少包含一个正则表达式和一个处理器类。顺序很重要;使用第一个匹配规则。如果正则表达式包含捕获组,这些组是路径参数并会传递给处理器的HTTP方法。如果将字典作为URLSpec的第三个元素传递,它将提供将传递给RequestHandler.initialize的初始化参数。最后,URLSpec需要有一个名字,允许它与RequestHandler.reverse_url一起使用。
比如,在这个片段中,根URL **/**被映射给MainHandler,/story/后跟数字的表单URLs被映射给StoryHandler。这个数字作为字符传递给StoryHandler.get
class MainHandler(RequestHandler):
def get(self):
self.write('<a href="%s">link to story 1</a>' %
self.reverse_url("story", "1"))
class StoryHandler(RequestHandler):
def initialize(self, db):
self.db = db
def get(self, story_id):
self.write("this is story %s" % story_id)
app = Application([
url(r"/", MainHandler),
url(r"/story/([0-9]+)", StoryHandler, dict(db=db), name="story")
])
Application的构造器用了许多关键词参数,这些参数可以被用来自定义应用程序的行为并启用可选功能;查看Application.settings完整的列表。
子类RequestHandler
Tornado web应用程序的大部分工作都在RequestHandler子类中完成。处理器子类的主要入口点是以正在被处理的HTTP方法命名的方法:get(),post,等待。每个处理器可能定义一个或多个这样的方法来处理不同的HTTP操作。如上所述,这些方法将和相当于匹配路由规则的捕获组的参数一起调用。
在处理器中,调用RequestHandler.render或RequestHandler.write这样的方法来产生响应。render()通过名字加载一个模板并用给定的参数来呈现它。write()被用于非基于模板的输出;它接受strings,bytes,和多个字典(dicts将被编码成json格式)。
许多RequestHandler中的方法,被设计为在子类中重写,并在整个应用程序中使用。通常定义一个重写了write_error和get_current_user这些方法的BaseHandler类,然后你可以在你自定的处理器中用自己的BaseHandler来代替RequestHandler。
处理请求输入
请求处理器可以用self.request来访问表示当前请求的对象。
有关属性的完整列表,请参阅为HTTPServerRequest定义的类。
HTML表单所使用的请求数据的格式将会为你解析,并在get_query_argument和get_body_argument方法中可用。
class MyFormHandler(tornado.web.RequestHandler):
def get(self):
self.write('<html><body><form action="/myform" method="POST">'
'<input type="text" name="message">'
'<input type="submit" value="Submit">'
'</form></body></html>')
def post(self):
self.set_header("Content-Type", "text/plain")
self.write("You wrote " + self.get_body_argument("message"))
由于HTML表单对于参数是一个单一值还是包含一个元素的列表的概念是摸棱两可的,RequestHandler有一个直接的方法让应用程序表名是不是需要一个列表。对于多个列表,使用get_query_arguments和get_body_arguments来代替它们的单数对应项。
self.request.files支持通过表单来上传文件,它将名称(HTML <input type=“file”> 元素的名字)映射到文件列表中。每一个文件是一个表单字典{“filename”:…, “content_type”:…, “body”:…}。只有使用表单包装(即一个multipart/form-data Content-Type)来上传的文件才能生成文件对象。如果未使用该方式,则可以用self.request.body来上传原始数据。默认情况下,上传的文件在内存中是完全缓冲的;如果你需要处理大文件以便其可以舒适的待在内存中,可以查阅stream_request_body类装饰器。
在demos的目录中,file_receiver.py展示了接受文件上传的两种方式。
因为HTML表单编码(如:单数和复数的模糊性)的巧合,Tornado不尝试将表单参数与其他类型的输入类型统一起来。尤其是,我们不解析json请求体。希望用JSON来代替表单编码的应用程序可以重写prepare来解析它们的请求:
def prepare(self):
if self.request.headers.get("Content-Type", "").startswith("application/json"):
self.json_args = json.loads(self.request.body)
else:
self.json_args = None