Tornado应用笔记05-异步客户端

索引

  • 01-简介
  • 02-Web框架
  • 03-协程与异步示例
  • 04-浅析源码
  • => 05-异步客户端
  • 06-WebSocket与长轮询

本节内容将分析Tornado内置的异步HTTP客户端, 包括源码和Tornado官方的爬虫demo. AsyncHTTPClientSimpleAsyncHTTPClient两个类是异步客户端的应用层代码, 下面就来分析其中的源码, 更底层的代码分析留待日后填坑.

简介与说明

Tornado内置了一个没有外部依赖的基于tornado iostreams的非阻塞 HTTP 1.1 客户端,

默认情况下, 为了限制挂起连接的数量, 单个IOLoop只有一个AsyncHTTPClient实例. 当然也可以通过设置force_instance=True来取消这个限制, 然而并不建议这么做. 默认情况下, 只有第一次调用下面这种方式初始化客户端时, 配置才会生效.

AsyncHTTPClient(max_clients=20, defaults=dict(user_agent="WtfAgent"))

原因在上面也提到了, AsyncHTTPClient 是"隐式复用"的, 除非你设置了force_instance取消"单实例"限制, 所以更推荐下面这种配置方式

tornado.httpclient.AsyncHTTPClient.configure(None,
    defaults=dict(
        user_agent="MyUserAgent"
     ), 
    max_clients=20,
)

max_clients是实例的最大同时请求数, 超过限制的请求将会被放入队列中, 需要注意的是, 在队列中等待的时间也是计入超时计时中的, 比方说设定的超时时间是2秒, 如果排队时间超过2秒 , 即便这个请求实际上并没有发出, 那么也会被认定为超时

源码分析
AsyncHTTPClient

主要看fetch, 其内部的操作如下:

  • 新建一个future
  • 统一请求格式, 如果输入的是url那么需要封装成HTTPRequest
  • 为请求添加回调(具体操作是, 先为future添加回调, future完成后执行自身回调, future的回调再在ioloop注册用户定义的回调)
  • 执行fetch_impl, 这个函数实际上调用的是AsyncHTTPClient的子类SimpleAsyncHTTPClient 的方法, fetch_impl完成后会执行future.set_result的回调
class AsyncHTTPClient(Configurable):

    def fetch(self, request, callback=None, raise_error=True, **kwargs):
        # 实际上并不需要显式close, 除非用多实例, 一般情况不会关异步客户端
        if self._closed:
            raise RuntimeError("fetch() called on closed AsyncHTTPClient")
        # 统一请求格式
        if not isinstance(request, HTTPRequest):
            request = HTTPRequest(url=request, **kwargs)
        request.headers = httputil.HTTPHeaders(request.headers)
        request = _RequestProxy(request, self.defaults)
        future = TracebackFuture()
        # 注册回调
        if callback is not None:
            callback = stack_context.wrap(callback)

            def handle_future(future):
                exc = future.exception()
                if isinstance(exc, HTTPError) and exc.response is not None:
                    response = exc.response
                elif exc is not None:
                    response = HTTPResponse(
                        request, 599, error=exc,
                        request_time=time.time() - request.start_time)
                else:
                    response = future.result()
                # 注册HTTP请求完成的回调
                self.io_loop.add_callback(callback, response)

            # 注册future的回调
            future.add_done_callback(handle_future)

        # fetch_impl完成的回调
        def handle_response(response):
            if raise_error and response.error:
                future.set_exception(response.error)
            else:
                future.set_result(response)

        # "发送"请求, 返回`future`
        self.fetch_impl(request, handle_response)
        return future
SimpleAsyncHTTPClient

客户端是以队列的形式管理HTTP请求的, 单个实例允许同时发起和处理的最大请求数(即max_clients)默认是10, 其内部工作流程如下:

  • 将新的HTTP请求加入队列
  • 将请求放入"等待区", 判断当前"工作区"的请求数, 如果超出可处理的请求数则为其在ioloop注册超时事件(超时事件是将请求移出等待区, 并通过在ioloop注册回调的方式抛出异常)
  • 处理"等待区"的请求, 先注销ioloop中相应的超时事件(如果有的话), 然后将请求放入"工作区", 接着发出异步HTTP请求, 请求包含了释放资源的回调, 回调会将请求移出"工作区", 并重复这一步骤, 继续处理"等待区"的请求, 直到"等待区"被清空.
class SimpleAsyncHTTPClient(AsyncHTTPClient):
    def initialize(self, io_loop, max_clients=10,
                   hostname_mapping=None, max_buffer_size=104857600,
                   resolver=None, defaults=None, max_header_size=None,
                   max_body_size=None):

        super(SimpleAsyncHTTPClient, self).initialize(io_loop,
                                                      defaults=defaults)
        self.max_clients = max_clients
        self.queue = collections.deque()
        self.active = {}
        self.waiting = {}
        self.max_buffer_size = max_buffer_size
        self.max_header_size = max_header_size
        self.max_body_size = max_body_size
        # TCPClient could create a Resolver for us, but we have to do it
        # ourselves to support hostname_mapping.
        if resolver:
            self.resolver = resolver
            self.own_resolver = False
        else:
            self.resolver = Resolver(io_loop=io_loop)
            self.own_resolver = True
        if hostname_mapping is not None:
            self.resolver = OverrideResolver(resolver=self.resolver,
                                             mapping=hostname_mapping)
        self.tcp_client = TCPClient(resolver=self.resolver, io_loop=io_loop)

    def fetch_impl(self, request, callback):

        # 将请求加入`collections.deque()`队列中
        key = object()
        self.queue.append((key, request, callback))

        # 对于超出最大同时请求量的请求, 为其在ioloop注册超时事件,
        if not len(self.active) < self.max_clients:
            timeout_handle = self.io_loop.add_timeout(
                self.io_loop.time() + min(request.connect_timeout,
                                          request.request_timeout),
                functools.partial(self._on_timeout, key))
        else:
            timeout_handle = None

        # 在`waiting`(等待发出的请求)中添加这`整个请求事件`(包括请求本身, 请求回调, 超时处理)
        self.waiting[key] = (request, callback, timeout_handle)

        # 处理请求队列
        self._process_queue()
        if self.queue:
            gen_log.debug("max_clients limit reached, request queued. "
                          "%d active, %d queued requests." % (
                              len(self.active), len(self.queue)))

    def _process_queue(self):
        with stack_context.NullContext():
            # 只能同时处理不超过 `max_clients` 的请求数
            while self.queue and len(self.active) < self.max_clients:
                # 从队列中获取在`等待`的请求,
                key, request, callback = self.queue.popleft()
                if key not in self.waiting:
                    continue
                # 注销超时事件(如果有)并退出`waiting`, 在`active`(正在处理的请求)中添加请求
                self._remove_timeout(key)
                self.active[key] = (request, callback)
                # 构建释放资源的回调函数
                release_callback = functools.partial(self._release_fetch, key)
                # `真正`发送HTTP请求的操作
                self._handle_request(request, release_callback, callback)

    def _handle_request(self, request, release_callback, final_callback):
        self._connection_class()(
            self.io_loop, self, request, release_callback,
            final_callback, self.max_buffer_size, self.tcp_client,
            self.max_header_size, self.max_body_size)

    def _release_fetch(self, key):
        # 完成请求后释放资源, 退出`active`
        del self.active[key]
        self._process_queue()

    def _remove_timeout(self, key):
        # 注销超时事件(如果有)并退出`waiting`
        if key in self.waiting:
            request, callback, timeout_handle = self.waiting[key]
            if timeout_handle is not None:
                self.io_loop.remove_timeout(timeout_handle)
            del self.waiting[key]

    def _on_timeout(self, key):
        # 退出`waiting`并注册超时响应回调
        request, callback, timeout_handle = self.waiting[key]
        self.queue.remove((key, request, callback))
        timeout_response = HTTPResponse(
            request, 599, error=HTTPError(599, "Timeout"),
            request_time=self.io_loop.time() - request.start_time)
        self.io_loop.add_callback(callback, timeout_response)
        del self.waiting[key]
官方爬虫demo(源码地址)

这个爬虫来自Tornado源码附带的demo, 使用其内置的队列, 协程和异步HTTP客户端实现一个"全站"爬虫. 理解这个demo对于理解协程和异步客户端都有一定帮助.

先简单说明这个爬虫的工作原理:

  • 创建一个队列, 用于存放等待爬取的网页url, 并将网站的"根url"放入队列中
  • 开启10个worker(可以理解为10个异步客户端)爬网站, 并为这个爬虫任务设置一个超时时间, 如果超时将抛出异常终止任务
  • 每个worker的工作流程大致为:
    • 从队列获取一个新的url加入到"工作区",
    • 爬取新url对应的页面
    • 获取新页面中的所有url
    • 将本次已爬取的url加入"完成区", 移出"工作区"
    • 按照规定筛选前面获取到的新url, 然后将筛选的结果加入队列
    • 重复第一步, 直到队列清空
#!/usr/bin/env python

import time
from datetime import timedelta

try:
    from HTMLParser import HTMLParser
    from urlparse import urljoin, urldefrag
except ImportError:
    from html.parser import HTMLParser
    from urllib.parse import urljoin, urldefrag

from tornado import httpclient, gen, ioloop, queues

# 设定根url和最大worker数
base_url = 'http://www.tornadoweb.org/en/stable/'
concurrency = 10


@gen.coroutine
def get_links_from_url(url):
    # 爬取网页内容并获取网页中所有去尾的(去除锚点"#"及其后续内容)url
    try:
        response = yield httpclient.AsyncHTTPClient().fetch(url)
        print('fetched %s' % url)

        html = response.body if isinstance(response.body, str) \
            else response.body.decode()
        urls = [urljoin(url, remove_fragment(new_url))
                for new_url in get_links(html)]
    except Exception as e:
        print('Exception: %s %s' % (e, url))
        raise gen.Return([])

    raise gen.Return(urls)


def remove_fragment(url):
    # 去除url中锚点"#"及其后续内容
    pure_url, frag = urldefrag(url)
    return pure_url


def get_links(html):
    # 获取网页中所有标签url
    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


@gen.coroutine
def main():
    # 创建url队列, "工作区" 和 "完成区"
    q = queues.Queue()
    start = time.time()
    fetching, fetched = set(), set()

    @gen.coroutine
    def fetch_url():
        # 从队列中获取新的url
        current_url = yield q.get()
        try:
            # 当前url已经在爬取(即url在"工作区中"), 就退出
            if current_url in fetching:
                return

            print('fetching %s' % current_url)
            
            # 将url加入"工作区", 爬取内容, 完成后加入"完成区"
            fetching.add(current_url)
            urls = yield get_links_from_url(current_url)
            fetched.add(current_url)

            # 筛选新的url并将其加入队列
            for new_url in urls:
                # Only follow links beneath the base URL
                if new_url.startswith(base_url):
                    yield q.put(new_url)

        # 完成后调用`q.task_done`, 队列中的未完成任务计数将减1, 与`q.get`一一对应
        # 为的是配合`q.join`, 当计数为0时, `q.join`会结束"等待"
        finally:
            q.task_done()

    # worker
    @gen.coroutine
    def worker():
        while True:
            yield fetch_url()

    # 为队列添加第一个url("任务")
    q.put(base_url)

    # 启动workers, 然后"等待"队列清空, 时间为300秒, 超时会抛出异常
    # 完成后检查"url队列"和"完成区"是否一致, 最后输出任务耗时
    for _ in range(concurrency):
        worker()

    # 这里使用了`.join`阻塞等待, 直到队列被清空才会恢复, 继续往下走
    yield q.join(timeout=timedelta(seconds=300))
    assert fetching == fetched
    print('Done in %d seconds, fetched %s URLs.' % (
        time.time() - start, len(fetched)))


if __name__ == '__main__':
    import logging
    logging.basicConfig()
    # 启动任务
    io_loop = ioloop.IOLoop.current()
    io_loop.run_sync(main)

本节内容就是这些, 下节内容将通过两种方式实现的聊天室demo, 介绍WebSocket与长轮询在Tornado中的实现方法.

NEXT ===> Tornado应用笔记06-WebSocket与长轮询

你可能感兴趣的:(Tornado应用笔记05-异步客户端)