如何讓你寫的爬蟲速度像坐火箭一樣快【並發請求】

  • 2019 年 10 月 31 日
  • 筆記

首先,我們需要知道什麼是並發,這裡的並髮指的是「並行發送請求」,意思就是一次性發出多個請求,從而達到節省時間的效果!那麼並發和不並發的區別在哪呢?簡單來說就是這樣子的:

把爬蟲比喻成工人,在不並發的情況下,一個工人一次只能做一件事情,所以必須要下載完一個圖片才能繼續下載下一個。

順序執行的情況

而在並發的情況下,就有很多個工人一起在幹活,每個工人都被分配了一件事情做,所以可以同時下載多個圖片,速度自然就快了很多。

並發的情況

當然,上面說的這個例子只是從一個宏觀的角度上來看並發,實際在做的時候要讓你的爬蟲能並發請求的方式是分為多執行緒、多進程、協程三種的,並不是每一種方式在運行時的效果都像上面說的這樣,這裡先不做深入探討,因為這不是本文的重點。我們現在只需要知道,只要能讓爬蟲並發請求,就能同時下載多個圖片,讓速度快得飛起,這樣就夠了。

那麼我們要用上面說的三種方式里的哪一種來實現並發請求呢?這還用問嗎?當然是選擇程式碼最簡單、改動最小,並且最容易看懂的協程啊!在Python3.4之後Python就引入了一個叫做asyncio的庫,原生支援了非同步IO,而在3.5之後Python又支援了asyncawait這兩個語法,使得寫非同步程式碼可以像寫同步程式碼一樣簡單易讀。

剛剛又提到了兩個詞,同步和非同步,這兩個詞的含義其實就跟上面的並發差不多,同步程式碼就是順序執行的,而非同步則不是,這裡同樣不做深入探討,先知道有這麼個東西就行了。

看到這裡肯定會有人開始有疑問了,雖然前面說我們要用協程來實現並發請求,但是後面說的卻是什麼Python支援原生非同步,那麼這個非同步跟協程的關係又是什麼呢?

其實很簡單,協程可以讓你寫非同步程式碼的時候能像寫同步程式碼一樣簡單,在Python3中寫協程程式碼的核心語法就是asyncawait這兩個,舉個簡單的例子吧:

1 def func():  2    print(1)  3    time.sleep(10)  4    print(2)

這是一段普通的函數,它屬於同步程式碼,裡面的time.sleep是普通函數,也屬於同步程式碼。

1 async def func():  # 調用協程函數的那個函數也需要是一個協程函數  2    print(1)  3    await asyncio.sleep(10)  # 調用協程函數的時候要在前面加await  4    print(2)

而這是一個協程函數,它屬於非同步程式碼,裡面的asyncio.sleep是協程函數,也屬於非同步程式碼。

它們的區別顯而易見,用協程來寫非同步程式碼,除了需要換成非同步的庫以外,就只是多了個asyncawait而已,是不是非常簡單?

那麼我們在了解了怎麼寫協程程式碼之後,就能開始優化那段慢成龜速的程式碼了嗎?答案是否定的,那段程式碼中使用了requests庫進行網路請求,而requests是一個同步庫,不能在非同步環境下使用;同樣,文件操作用的openfile.write也是同步的,也不能在非同步環境下使用。

所以在開始之前我們還需要了解兩個庫,分別是aiohttp和aiofiles,aiohttp是一個非同步網路請求庫,而aiofiles是一個非同步文件操作庫。(aiofiles是基於執行緒池實現的,並不是真正的原生非同步,但問題不大,不影響使用)

切記,非同步程式碼不能與同步程式碼混用,否則如果同步程式碼耗時過長,非同步程式碼就會被阻塞,失去非同步的效果。而網路請求和文件操作是整個流程中最耗時的部分,所以我們必須使用非同步的庫來進行操作!否則就白搞了!

好了,先來看看aiohttp的用法吧,官方文檔上的示例大致如下:

1 async with aiohttp.ClientSession() as session:  2    async with session.get(url) as resp:  3        result = await resp.text()

是不是覺得很麻煩,不像requests庫那麼方便?還覺得兩層async with很醜?有沒有辦法讓它像requests庫一樣方便呢?

答案是有的,有一個叫作aiohttp-requests的庫,它能讓上面的這段程式碼變成這樣:

1 resp = await requests.get(url)  2 result = await resp.text()

清爽多了對吧?我們等下就用它了!記得裝這個庫的前提是要先裝aiohttp哦!

然後我們來看看aiofiles的用法,官方文檔上的示例如下:

1 async with aiofiles.open('filename', mode='r') as f:  2    contents = await f.read()  3 print(contents)

嗯,這個用起來就和用同步程式碼操作文件差不多了,沒啥可挑剔的,直接用就完事了。

提示:aiohttp-requests默認是創建並使用了session的,對於一些需要不保留Cookie進行請求的場景需要自己實例化一個Requests類,並指定cookie_jar為aiohttp.DummyCookieJar

了解完了要用的庫之後我們就可以開始對貼子中的程式碼進行魔改了,如果你用的不是Python3.5以上版本的話需要先準備一下環境。除了版本號大於等於3.5的Python以外,你還需要安裝以下幾個庫:

  • aiohttp(非同步網路請求庫)
  • aiohttp-requests(讓aiohttp用起來更方便的庫)
  • aiofiles(非同步文件操作庫)
  • pillow(其實就是PIL庫,程式碼中的圖片操作有用到)

執行一下pip install aiohttp aiohttp-requests aiofiles pillow一次性裝完,如果存在多個不同版本的Python環境記得區分好。

然後我們打開編輯器,開始改程式碼,首先調整一下導包的部分,將裡面的requests替換成aiohttp-requests,像這樣:

然後搜索一下requests,看看哪些地方用到了它。

接著把所有搜到的部分都給改成非同步請求的。

同時不要忘了將所有調用過requests.get的函數都變成協程函數。

然後我們把文件操作的部分也換成非同步的,使用aiofiles.open代替open

最主要的部分都換好了,接著我們將原先在if __name__ == '__main__':下的程式碼移到一個新寫的協程函數run中,並且將調用前面協程函數的部分都加上await

再導入一下asyncio庫,然後在if __name__ == '__main__':下寫出這樣的程式碼:

上面這個是Python3.7之後才能用的寫法,低於Python3.7要這樣寫:

現在我們就可以運行一下看看修改後的程式碼能不能跑通了。

這裡報了個錯,從錯誤堆棧中可以看出問題是出在response = await requests.get(url=url, headers=headers)這裡的,原因是self.session._request方法沒有key為url的參數。這個問題很好解決,只需要將url=url變成url就好了(本來也就沒必要這麼指定參數寫)。將程式碼中所有用到requests.get並且存在url=url這種寫法的都做一下調整:

調整完之後再運行一次就正常了,效果和原先的程式碼相同。

注意!僅僅是這樣並不會讓速度發生很大的變化!我們最後還需要將這一堆程式碼中最耗時且是順序執行、沒有並發請求的部分單獨放到一個協程函數中,並且用asyncio.gather來並發調用(由於原本的邏輯較為混亂,這裡除了並發請求以外還進行了一些其他的微調,主要是計數和文件路徑的部分,無關緊要)。

運行一下看看效果,剛運行起來一瞬間就刷了一排的下載完成,跟修改之前比起來簡直是天差地別。

這就是並發請求的威力!我們僅僅是對他原本的程式碼進行了一些微調,把最耗時的下載圖片部分簡單粗暴地使用asyncio.gather並發執行了一下,速度就從龜爬變成了像坐火箭一樣快!(其實程式碼中還有很多可以優化的點,這裡就不一一拿出來講了)

最後給大家提個醒:

雖然並發請求非常牛逼,可以讓你的爬蟲變得飛快,但它也不是不存在任何問題的!

如果你的並發請求數量過大(又稱並發數過高),你的爬蟲就相當於是在對他人的伺服器進行Dos攻擊(拒絕服務攻擊)了!

舉個例子,你在爬一個小網站的時候為了自己爬的速度更快,對並發請求的數量毫無限制,使得你的爬蟲一次性發出了幾百、上千個請求,但一般的小網站根本扛不住這麼高的並發!幾乎會在一瞬間就被你的爬蟲給打爆掉!試想一下,如果你是站長,看到這樣的情形你會怎麼想?

如果你不能理解這個例子所產生的效果是什麼樣的,可以自己搭建一個Web服務,只放一個簡單的頁面,然後開個幾百並發去請求這個頁面,這樣你就能切身地體會到別人是什麼感受了。

所以記住,一定要合理控制並發請求的數量,不要對對方網站造成過大的壓力!你給別人留活路,別人才會給你留活路!