异步思维——把请求与解析分开_当前页

在昨天的文章《Callback ——从同步思维切换到异步思维》,我们举的例子似乎还不能很好地说明 Callback 的优势。今天我们再来看另外一个场景。

例如有下面这个代码片段:

async def parse(html):
    selector = fromstring(html)
    
    
    print('...解析 HTML 的数据...')
    
    next_page = selector.xpath('//a[@class="next_page"]/@href')
    if next_page:
        next_page_url = next_page[0]
        next_page_html = await request(next_page_url)
        await parse(next_page_html)

这种场景常常发生在需要翻页的时候,不同页面的处理逻辑是完全一样的,但是每次获取到了当前页面才能获取下一页。于是不少人使用递归的办法来解决问题。

如果页数非常多,那么你就会面临一个问题:超出最大递归深度,导致报错。

并且,在定义这个parse函数的时候,我使用了async def把它定义为一个异步函数。但实际上,解析 HTML 是一个 CPU 密集型的工作,它没有 IO 等待,根本就没有必要异步!但为了在里面调用await request(next_page_url),却必须要使用async def

为了解决这个问题,我们可以把递归改成循环。于是可能有人会这样写代码:


...无关代码...

url_list = ['初始 URL']
while url_list:
    url = url_list.pop()
    html = await request(url)
    data = parse(html)
    next_page_url = data['next_page_url']
    if next_page_url:
        url_list.append(next_page_url)

通过这种写法,parse函数可以直接定义成普通函数,并且每次只会调用1层,不会递归调用。这就同时解决了两个问题。

看到这里,大家可能发现了,实际上我们只有在涉及到 IO 请求的地方,才需要使用async/await。在解析网页的地方,只需要使用普通函数就可以了。

而对于aiohttp请求网页来说,它的逻辑非常简单,你告诉它urlheadersmethodbody。它返回源代码给你。它不需要关心你传入的这一批URL 是不是对应同一个类型的页面,甚至不需要关心你请求的是不是同一个网站!

在这种情况下,如果我们使用 Callback,那么优势就凸现出来了。我们来看下面这个例子:

def parse_1(html):
    print('处理页面1的源代码')

def parse_2(html):
    print('处理页面2的源代码')
    
def parse_3(html):
    print('处理另外一个网站的源代码')


class RequestObj:
    def __init__(self, url, headers=None, method='get', body=None, callback):
        self.url = url
        self.headers = headers
        self.method = method
        self.body = body
        self.callback = callback


async def request(req_obj):
    async with aiohttp.ClientSession() as session:
        if req_obj.method == 'get':
            resp = await session.get(req_obj.url, headers=req_obj.headers)
        else:
            resp = await session.post(req_obj.url, headers=req_obj.headers, json=req_obj.body)
        html = await resp.text(encoding='utf-8')
    req_obj.callback(html)


async main():
    req_obj_list = [
        RequestObj('页面1的 url', headers1, callback=parse_1),
        RequestObj('页面2的 url', headers2, method='post', body={'xx': 1}, callback=parse_2),
        RequstObj('另一个网站的 url', headers3, callback=parse_3)
    ]
    tasks = [request(x) for x in req_obj_list]
    await asyncio.gather(*tasks)

在这个代码片段中,不同的 parse 函数处理不同的 url。我们在创建 RequestObj 对象的时候,把不同的 parse 函数通过 callback 参数与 url 关联起来。那么下载器在请求完成 url 以后,要做的仅仅是调用这个 callback 函数。

这样一来,假设有一个网站,我们先访问列表页,然后从列表页中拿到每一个详情页的 URL 去访问详情页。列表页可以翻页,详情页也可以翻页。通过维护一个全局的队列,我们可以实现,列表页要翻页的时候,把RequestObj 对象放到队列中,详情页要翻页的时候,把 RequestObj 对象也放到队列中。而负责请求网站的代码,不关心它自己请求的是哪个页面,它只管请求,然后调用 callback 传入 html 即可。这样就是实现了,列表页和详情页同时请求。速度大大提升。

下一篇文章,我们来实现这个全局的队列。

异步思维——把请求与解析分开_数据_02