- Python爬蟲(chóng)開(kāi)發(fā)與項(xiàng)目實(shí)戰(zhàn)
- 范傳輝
- 11185字
- 2019-01-05 01:17:28
第1章 回顧Python編程
本書(shū)所要講解的爬蟲(chóng)技術(shù)是基于Python語(yǔ)言進(jìn)行開(kāi)發(fā)的,擁有Python編程能力對(duì)于本書(shū)的學(xué)習(xí)是至關(guān)重要的,因此本章的目標(biāo)是幫助之前接觸過(guò)Python語(yǔ)言的讀者回顧一下Python編程中的內(nèi)容,尤其是與爬蟲(chóng)技術(shù)相關(guān)的內(nèi)容。
1.1 安裝Python
Python是跨平臺(tái)語(yǔ)言,它可以運(yùn)行在Windows、Mac和各種Linux/Unix系統(tǒng)上。在Windows上編寫(xiě)的程序,可以在Mac和Linux上正常運(yùn)行。Python是一種面向?qū)ο蟆⒔忉屝陀?jì)算機(jī)程序設(shè)計(jì)語(yǔ)言,需要Python解釋器進(jìn)行解釋運(yùn)行。目前,Python有兩個(gè)版本,一個(gè)是2.x版,一個(gè)是3.x版,這兩個(gè)版本是不兼容的。現(xiàn)在Python的整體方向是朝著3.x發(fā)展的,但是在發(fā)展過(guò)程中,大量針對(duì)2.x版本的代碼都需要修改才能運(yùn)行,導(dǎo)致現(xiàn)在許多第三方庫(kù)無(wú)法在3.x版本上直接使用,因此現(xiàn)在大部分的云服務(wù)器默認(rèn)的Python版本依然是2.x版。考慮到上述原因,本書(shū)采用的Python版本為2.x,確切地說(shuō)是2.7版本。
1.1.1 Windows上安裝Python
首先,從Python的官方網(wǎng)站www.python.org下載最新的2.7.12版本,地址是https://www.python.org/ftp/python/2.7.12/python-2.7.12.msi。然后,運(yùn)行下載的MSI安裝包,在選擇安裝組件時(shí),勾選上所有的組件,如圖1-1所示。

圖1-1 Python安裝界面
特別要注意勾選pip和Add python.exe to Path,然后一路點(diǎn)擊Next即可完成安裝。
pip是Python安裝擴(kuò)展模塊的工具,通常會(huì)用pip下載擴(kuò)展模塊的源代碼并編譯安裝。
Add python.exe to Path是將Python添加到Windows環(huán)境中。
安裝完成后,打開(kāi)命令提示窗口,輸入python后出現(xiàn)如圖1-2情況,說(shuō)明Python安裝成功。

圖1-2 Python命令行窗口
當(dāng)看到提示符“>>>”就表示我們已經(jīng)在Python交互式環(huán)境中了,可以輸入任何Python代碼,回車(chē)后會(huì)立刻得到執(zhí)行結(jié)果。現(xiàn)在,輸入exit()并回車(chē),就可以退出Python交互式環(huán)境。
1.1.2 Ubuntu上的Python
本書(shū)采用Ubuntu 16.04版本,系統(tǒng)自帶了Python 2.7.11的環(huán)境,如圖1-3所示,所以不需要額外進(jìn)行安裝。

圖1-3 Python環(huán)境
擁有了Python環(huán)境,但為了以后方便安裝擴(kuò)展模塊,還需要安裝python-pip和python-dev,在shell中執(zhí)行:sudo apt-get install python-pip python-dev即可安裝,如圖1-4所示。

圖1-4 安裝pip和python-dev
1.2 搭建開(kāi)發(fā)環(huán)境
俗話(huà)說(shuō):“工欲善其事必先利其器”,在做Python爬蟲(chóng)開(kāi)發(fā)之前,一個(gè)好的IDE將會(huì)使編程效率得到大幅度提高。下面主要介紹兩種IDE:Eclipse和PyCharm,并以在Windows 7上安裝為例進(jìn)行介紹。
1.2.1 Eclipse+PyDev
Eclipse是一個(gè)強(qiáng)大的編輯器,并通過(guò)插件的方式不斷拓展功能。Eclipse比較常見(jiàn)的功能是編寫(xiě)Java程序,但是通過(guò)擴(kuò)展PyDev插件,Eclipse就具有了編寫(xiě)Python程序的功能。所以本書(shū)搭建的開(kāi)發(fā)環(huán)境是Eclipset+PyDev。
Eclipse是運(yùn)行在Java虛擬機(jī)上的,所以要先安裝Java環(huán)境。
第一步,安裝Java環(huán)境。Java JDK的下載地址為:http://www.oracle.com/technetwork/java/javase/downloads/index.html。下載頁(yè)面如圖1-5所示。

圖1-5 JDK下載界面
下載好JDK之后,雙擊進(jìn)行安裝,一直點(diǎn)擊“下一步”即可完成安裝,安裝界面如圖1-6所示。

圖1-6 JDK安裝界面
安裝完JDK,需要配置Java環(huán)境變量。
1)首先右鍵“我的電腦”,選擇“屬性”,如圖1-7所示。

圖1-7 電腦屬性
2)接著在出現(xiàn)的對(duì)話(huà)框中選擇“高級(jí)系統(tǒng)設(shè)置”,如圖1-8所示。

圖1-8 高級(jí)系統(tǒng)設(shè)置
3)在出現(xiàn)的對(duì)話(huà)框中選擇“環(huán)境變量”,如圖1-9所示。

圖1-9 環(huán)境變量
4)新建名為classpath的變量名,變量的值可以設(shè)置為:.; %JAVA_HOME\lib; %JAVA_HOME\lib\tools.jar,如圖1-10所示。

圖1-10 classpath環(huán)境變量
5)新建名為JAVA_HOME的變量名,變量的值為之前安裝的JDK路徑位置,默認(rèn)是C:\Program Files\Java\jdk1.8.0_101\,如圖1-11所示。

圖1-11 JAVA_HOME環(huán)境變量
6)在已有的系統(tǒng)變量path的變量值中加上:; %JAVA_HOME%\bin; %JAVA_HOME%\jre\bin,如圖1-12所示,自此配置完成。

圖1-12 path環(huán)境變量
下面檢驗(yàn)是否配置成功,運(yùn)行cmd命令,在出現(xiàn)的對(duì)話(huà)框中輸入“java-version”命令,如果出現(xiàn)圖1-13的結(jié)果,則表明配置成功。

圖1-13 java-version
第二步,下載Eclipse,下載地址為:http://www.eclipse.org/downloads/eclipse-packages/,下載完后,解壓就可以直接使用,Eclipse不需要安裝。下載界面如圖1-14所示。

圖1-14 下載界面
第三步,在Eclipse中安裝pydev插件。啟動(dòng)Eclipse,點(diǎn)擊Help->Install New Software...,如圖1-15所示。

圖1-15 安裝新軟件
在彈出的對(duì)話(huà)框中,點(diǎn)擊Add按鈕。在Name中填:Pydev,在Location中填http://pydev.org/updates,然后一步一步安裝下去。過(guò)程如圖1-16和圖1-17所示。

圖1-16 安裝過(guò)程1

圖1-17 安裝過(guò)程2
第四步,安裝完pydev插件后,需要配置pydev解釋器。在Eclipse菜單欄中,點(diǎn)擊Windows→Preferences。在對(duì)話(huà)框中,點(diǎn)擊PyDev→Interpreter-Python。點(diǎn)擊New按鈕,選擇python.exe的路徑,打開(kāi)后顯示出一個(gè)包含很多復(fù)選框的窗口,點(diǎn)擊OK即可,如圖1-18所示。

圖1-18 配置PyDev
經(jīng)過(guò)上述四個(gè)步驟,Eclipse就可以進(jìn)行Python開(kāi)發(fā)了。如需創(chuàng)建一個(gè)新的項(xiàng)目,選擇File→New→Projects...,再選擇PyDev→PyDevProject并輸入項(xiàng)目名稱(chēng),點(diǎn)擊Finish即可完成項(xiàng)目的創(chuàng)建,如圖1-19所示。

圖1-19 新建Python工程
然后新建PyDev Package,就可以寫(xiě)代碼了,如圖1-20所示。

圖1-20 新建Python包
1.2.2 PyCharm
PyCharm是本人用過(guò)的Python編輯器中,比較順手,而且可以跨平臺(tái),在MacOS、Linux和Windows下都可以用。PyCharm主要分為專(zhuān)業(yè)版和社區(qū)版,兩者的區(qū)別在于專(zhuān)業(yè)版一開(kāi)始有30天的試用期,之后就要收費(fèi);社區(qū)版一直免費(fèi),當(dāng)然專(zhuān)業(yè)版的功能更加強(qiáng)大。我們進(jìn)行Python爬蟲(chóng)開(kāi)發(fā),社區(qū)版基本上可以滿(mǎn)足需要,所以接下來(lái)就以社區(qū)版為例。大家可以根據(jù)自己的系統(tǒng)版本,進(jìn)行下載安裝,下載地址為:http://www.jetbrains.com/pycharm/download/#。下載界面如圖1-21所示。

圖1-21 下載界面
以Windows為例,下載后雙擊進(jìn)行安裝,一步一步點(diǎn)擊Next,即可完成安裝。安裝界面如圖1-22所示。

圖1-22 安裝界面
安裝完成后,運(yùn)行PyCharm,創(chuàng)建Python項(xiàng)目就可以進(jìn)行Python開(kāi)發(fā)了,如圖1-23所示。

圖1-23 創(chuàng)建項(xiàng)目開(kāi)發(fā)
1.3 IO編程
IO在計(jì)算機(jī)中指的是Input/Output,也就是輸入輸出。凡是用到數(shù)據(jù)交換的地方,都會(huì)涉及IO編程,例如磁盤(pán)、網(wǎng)絡(luò)的數(shù)據(jù)傳輸。在IO編程中,Stream(流)是一種重要的概念,分為輸入流(Input Stream)和輸出流(Output Stream)。我們可以把流理解為一個(gè)水管,數(shù)據(jù)相當(dāng)于水管中的水,但是只能單向流動(dòng),所以數(shù)據(jù)傳輸過(guò)程中需要架設(shè)兩個(gè)水管,一個(gè)負(fù)責(zé)輸入,一個(gè)負(fù)責(zé)輸出,這樣讀寫(xiě)就可以實(shí)現(xiàn)同步。本節(jié)主要講解磁盤(pán)IO操作,網(wǎng)絡(luò)IO操作放到之后的1.5節(jié)進(jìn)行討論。
1.3.1 文件讀寫(xiě)
1.打開(kāi)文件
讀寫(xiě)文件是最常見(jiàn)的IO操作。Python內(nèi)置了讀寫(xiě)文件的函數(shù),方便了文件的IO操作。
文件讀寫(xiě)之前需要打開(kāi)文件,確定文件的讀寫(xiě)模式。open函數(shù)用來(lái)打開(kāi)文件,語(yǔ)法如下:
open(name[.mode[.buffering]])
open函數(shù)使用一個(gè)文件名作為唯一的強(qiáng)制參數(shù),然后返回一個(gè)文件對(duì)象。模式(mode)和緩沖區(qū)(buffering)參數(shù)都是可選的,默認(rèn)模式是讀模式,默認(rèn)緩沖區(qū)是無(wú)。
假設(shè)有個(gè)名為qiye.txt的文本文件,其存儲(chǔ)路徑是c:\text(或者是在Linux下的~/text),那么可以像下面這樣打開(kāi)文件。在交互式環(huán)境的提示符“>>>”下,輸入如下內(nèi)容:
>>> f = open(r'c:\text\qiye.txt')
如果文件不存在,將會(huì)看到一個(gè)類(lèi)似下面的異常回溯:
Traceback (most recent call last): File "<stdin>", line 1, in <module> IOError: [Errno 2] No such file or directory: 'C:\\qiye.txt'
2.文件模式
下面主要說(shuō)一下open函數(shù)中的mode參數(shù)(如表1-1所示),通過(guò)改變mode參數(shù)可以實(shí)現(xiàn)對(duì)文件的不同操作。
表1-1 open函數(shù)中的mode參數(shù)

這里主要是提醒一下’b’參數(shù)的使用,一般處理文本文件時(shí),是用不到’b’參數(shù)的,但處理一些其他類(lèi)型的文件(二進(jìn)制文件),比如mp3音樂(lè)或者圖像,那么應(yīng)該在模式參數(shù)中增加’b',這在爬蟲(chóng)中處理媒體文件很常用。參數(shù)’rb’可以用來(lái)讀取一個(gè)二進(jìn)制文件。
3.文件緩沖區(qū)
open函數(shù)中第三個(gè)可選參數(shù)buffering控制著文件的緩沖。如果參數(shù)是0, I/O操作就是無(wú)緩沖的,直接將數(shù)據(jù)寫(xiě)到硬盤(pán)上;如果參數(shù)是1, I/O操作就是有緩沖的,數(shù)據(jù)先寫(xiě)到內(nèi)存里,只有使用flush函數(shù)或者close函數(shù)才會(huì)將數(shù)據(jù)更新到硬盤(pán);如果參數(shù)為大于1的數(shù)字則代表緩沖區(qū)的大小(單位是字節(jié)), -1(或者是任何負(fù)數(shù))代表使用默認(rèn)緩沖區(qū)的大小。
4.文件讀取
文件讀取主要是分為按字節(jié)讀取和按行進(jìn)行讀取,經(jīng)常用到的方法有read()、readlines()、close()。
在“>>>”輸入f = open(r'c:\text\qiye.txt')后,如果成功打開(kāi)文本文件,接下來(lái)調(diào)用read()方法則可以一次性將文件內(nèi)容全部讀到內(nèi)存中,最后返回的是str類(lèi)型的對(duì)象:
>>> f.read() "qiye"
最后一步調(diào)用close(),可以關(guān)閉對(duì)文件的引用。文件使用完畢后必須關(guān)閉,因?yàn)槲募?duì)象會(huì)占用操作系統(tǒng)資源,影響系統(tǒng)的IO操作。
>>> f.close()
由于文件操作可能會(huì)出現(xiàn)IO異常,一旦出現(xiàn)IO異常,后面的close()方法就不會(huì)調(diào)用。所以為了保證程序的健壯性,我們需要使用try ... finally來(lái)實(shí)現(xiàn)。
try: f = open(r'c:\text\qiye.txt', 'r') print f.read() finally: if f: f.close()
上面的代碼略長(zhǎng),Python提供了一種簡(jiǎn)單的寫(xiě)法,使用with語(yǔ)句來(lái)替代try ... finally代碼塊和close()方法,如下所示:
with open(r'c:\text\qiye.txt', 'r') as fileReader: print fileReader.read()
調(diào)用read()一次將文件內(nèi)容讀到內(nèi)存,但是如果文件過(guò)大,將會(huì)出現(xiàn)內(nèi)存不足的問(wèn)題。一般對(duì)于大文件,可以反復(fù)調(diào)用read(size)方法,一次最多讀取size個(gè)字節(jié)。如果文件是文本文件,Python提供了更加合理的做法,調(diào)用readline()可以每次讀取一行內(nèi)容,調(diào)用readlines()一次讀取所有內(nèi)容并按行返回列表。大家可以根據(jù)自己的具體需求采取不同的讀取方式,例如小文件可以直接采取read()方法讀到內(nèi)存,大文件更加安全的方式是連續(xù)調(diào)用read(size),而對(duì)于配置文件等文本文件,使用readline()方法更加合理。
將上面的代碼進(jìn)行修改,采用readline()的方式實(shí)現(xiàn)如下所示:
with open(r'c:\text\qiye.txt', 'r') as fileReader: for line in fileReader.readlines(): print line.strip()
5.文件寫(xiě)入
寫(xiě)文件和讀文件是一樣的,唯一的區(qū)別是在調(diào)用open方法時(shí),傳入標(biāo)識(shí)符’w’或者’wb'表示寫(xiě)入文本文件或者寫(xiě)入二進(jìn)制文件,示例如下:
f = open(r'c:\text\qiye.txt', 'w') f.write('qiye') f.close()
我們可以反復(fù)調(diào)用write()方法寫(xiě)入文件,最后必須使用close()方法來(lái)關(guān)閉文件。使用write()方法的時(shí)候,操作系統(tǒng)不是立即將數(shù)據(jù)寫(xiě)入文件中的,而是先寫(xiě)入內(nèi)存中緩存起來(lái),等到空閑時(shí)候再寫(xiě)入文件中,最后使用close()方法就將數(shù)據(jù)完整地寫(xiě)入文件中了。當(dāng)然也可以使用f.flush()方法,不斷將數(shù)據(jù)立即寫(xiě)入文件中,最后使用close()方法來(lái)關(guān)閉文件。和讀文件同樣道理,文件操作中可能會(huì)出現(xiàn)IO異常,所以還是推薦使用with語(yǔ)句:
with open(r'c:\text\qiye.txt', 'w') as fileWriter: fileWriter.write('qiye')
1.3.2 操作文件和目錄
在Python中對(duì)文件和目錄的操作經(jīng)常用到os模塊和shutil模塊。接下來(lái)主要介紹一些操作文件和目錄的常用方法:
□ 獲得當(dāng)前Python腳本工作的目錄路徑:os.getcwd()。
□ 返回指定目錄下的所有文件和目錄名:os.listdir()。例如返回C盤(pán)下的文件:os.listdir("C:\\")
□ 刪除一個(gè)文件:os.remove(filepath)。
□ 刪除多個(gè)空目錄:os.removedirs(r"d:\python")。
□ 檢驗(yàn)給出的路徑是否是一個(gè)文件:os.path.isfile(filepath)。
□ 檢驗(yàn)給出的路徑是否是一個(gè)目錄:os.path.isdir(filepath)。
□ 判斷是否是絕對(duì)路徑:os.path.isabs()。
□ 檢驗(yàn)路徑是否真的存在:os.path.exists()。例如檢測(cè)D盤(pán)下是否有Python文件夾:os.path.exists(r"d:\python")
□ 分離一個(gè)路徑的目錄名和文件名:os.path.split()。例如:os.path.split(r"/home/qiye/qiye.txt"),返回結(jié)果是一個(gè)元組:('/home/qiye', 'qiye.txt')。
□ 分離擴(kuò)展名:os.path.splitext()。例如os.path.splitext(r"/home/qiye/qiye.txt"),返回結(jié)果是一個(gè)元組:('/home/qiye/qiye', '.txt')。
□ 獲取路徑名:os.path.dirname(filetpah)。
□ 獲取文件名:os.path.basename(filepath)。
□ 讀取和設(shè)置環(huán)境變量:os.getenv()與os.putenv()。
□ 給出當(dāng)前平臺(tái)使用的行終止符:os.linesep。Windows使用’\r\n', Linux使用’\n’而Mac使用’\r'。
□ 指示你正在使用的平臺(tái):os.name。對(duì)于Windows,它是’nt',而對(duì)于Linux/Unix用戶(hù),它是’posix'。
□ 重命名文件或者目錄:os.rename(old, new)。
□ 創(chuàng)建多級(jí)目錄:os.makedirs(r"c:\python\test")。
□ 創(chuàng)建單個(gè)目錄:os.mkdir("test")。
□ 獲取文件屬性:os.stat(file)。
□ 修改文件權(quán)限與時(shí)間戳:os.chmod(file)。
□ 獲取文件大小:os.path.getsize(filename)。
□ 復(fù)制文件夾:shutil.copytree("olddir", "newdir")。olddir和newdir都只能是目錄,且newdir必須不存在。
□ 復(fù)制文件:shutil.copyfile("oldfile", "newfile"), oldfile和newfile都只能是文件;shutil. copy("oldfile", "newfile"), oldfile只能是文件,newfile可以是文件,也可以是目標(biāo)目錄。
□ 移動(dòng)文件(目錄):shutil.move("oldpos", "newpos")。
□ 刪除目錄:os.rmdir("dir"),只能刪除空目錄;shutil.rmtree("dir"),空目錄、有內(nèi)容的目錄都可以刪。
1.3.3 序列化操作
對(duì)象的序列化在很多高級(jí)編程語(yǔ)言中都有相應(yīng)的實(shí)現(xiàn),Python也不例外。程序運(yùn)行時(shí),所有的變量都是在內(nèi)存中的,例如在程序中聲明一個(gè)dict對(duì)象,里面存儲(chǔ)著爬取的頁(yè)面的鏈接、頁(yè)面的標(biāo)題、頁(yè)面的摘要等信息:
d = dict(url='index.html', title=’首頁(yè)’, content=’首頁(yè)’)
在程序運(yùn)行的過(guò)程中爬取的頁(yè)面的鏈接會(huì)不斷變化,比如把url改成了second.html,但是程序一結(jié)束或意外中斷,程序中的內(nèi)存變量都會(huì)被操作系統(tǒng)進(jìn)行回收。如果沒(méi)有把修改過(guò)的url存儲(chǔ)起來(lái),下次運(yùn)行程序的時(shí)候,url被初始化為index.html,又是從首頁(yè)開(kāi)始,這是我們不愿意看到的。所以把內(nèi)存中的變量變成可存儲(chǔ)或可傳輸?shù)倪^(guò)程,就是序列化。
將內(nèi)存中的變量序列化之后,可以把序列化后的內(nèi)容寫(xiě)入磁盤(pán),或者通過(guò)網(wǎng)絡(luò)傳輸?shù)絼e的機(jī)器上,實(shí)現(xiàn)程序狀態(tài)的保存和共享。反過(guò)來(lái),把變量?jī)?nèi)容從序列化的對(duì)象重新讀取到內(nèi)存,稱(chēng)為反序列化。
在Python中提供了兩個(gè)模塊:cPickle和pickle來(lái)實(shí)現(xiàn)序列化,前者是由C語(yǔ)言編寫(xiě)的,效率比后者高很多,但是兩個(gè)模塊的功能是一樣的。一般編寫(xiě)程序的時(shí)候,采取的方案是先導(dǎo)入cPickle模塊,如果此模塊不存在,再導(dǎo)入pickle模塊。示例如下:
try: import cPickle as pickle except ImportError: import pickle
pickle實(shí)現(xiàn)序列化主要使用的是dumps方法或dump方法。dumps方法可以將任意對(duì)象序列化成一個(gè)str,然后可以將這個(gè)str寫(xiě)入文件進(jìn)行保存。在Python Shell中示例如下:
>>> import cPickle as pickle >>> d = dict(url='index.html', title=’首頁(yè)’, content=’首頁(yè)’) >>> pickle.dumps(d) "(dp1\nS'content'\np2\nS'\\xca\\xd7\\xd2\\xb3'\np3\nsS'url'\np4\nS'index.html'\n p5\nsS'title'\np6\ng3\ns."
如果使用dump方法,可以將序列化后的對(duì)象直接寫(xiě)入文件中:
>>> f=open(r'D:\dump.txt', 'wb') >>> pickle.dump(d, f) >>> f.close()
pickle實(shí)現(xiàn)反序列化使用的是loads方法或load方法。把序列化后的文件從磁盤(pán)上讀取為一個(gè)str,然后使用loads方法將這個(gè)str反序列化為對(duì)象,或者直接使用load方法將文件直接反序列化為對(duì)象,如下所示:
>>> f=open(r'D:\dump.txt', 'rb') >>> d=pickle.load(f) >>> f.close() >>> d {'content': '\xca\xd7\xd2\xb3', 'url': 'index.html', 'title': '\xca\xd7\xd2\xb3'}
通過(guò)反序列化,存儲(chǔ)為文件的dict對(duì)象,又重新恢復(fù)出來(lái),但是這個(gè)變量和原變量沒(méi)有什么關(guān)系,只是內(nèi)容一樣。以上就是序列化操作的整個(gè)過(guò)程。
假如我們想在不同的編程語(yǔ)言之間傳遞對(duì)象,把對(duì)象序列化為標(biāo)準(zhǔn)格式是關(guān)鍵,例如XML,但是現(xiàn)在更加流行的是序列化為JSON格式,既可以被所有的編程語(yǔ)言讀取解析,也可以方便地存儲(chǔ)到磁盤(pán)或者通過(guò)網(wǎng)絡(luò)傳輸。對(duì)于JSON的操作,將在第5章進(jìn)行講解。
1.4 進(jìn)程和線程
在爬蟲(chóng)開(kāi)發(fā)中,進(jìn)程和線程的概念是非常重要的。提高爬蟲(chóng)的工作效率,打造分布式爬蟲(chóng),都離不開(kāi)進(jìn)程和線程的身影。本節(jié)將從多進(jìn)程、多線程、協(xié)程和分布式進(jìn)程等四個(gè)方面,幫助大家回顧Python語(yǔ)言中進(jìn)程和線程中的常用操作,以便在接下來(lái)的爬蟲(chóng)開(kāi)發(fā)中靈活運(yùn)用進(jìn)程和線程。
1.4.1 多進(jìn)程
Python實(shí)現(xiàn)多進(jìn)程的方式主要有兩種,一種方法是使用os模塊中的fork方法,另一種方法是使用multiprocessing模塊。這兩種方法的區(qū)別在于前者僅適用于Unix/Linux操作系統(tǒng),對(duì)Windows不支持,后者則是跨平臺(tái)的實(shí)現(xiàn)方式。由于現(xiàn)在很多爬蟲(chóng)程序都是運(yùn)行在Unix/Linux操作系統(tǒng)上,所以本節(jié)對(duì)兩種方式都進(jìn)行講解。
1.使用os模塊中的fork方式實(shí)現(xiàn)多進(jìn)程
Python的os模塊封裝了常見(jiàn)的系統(tǒng)調(diào)用,其中就有fork方法。fork方法來(lái)自于Unix/Linux操作系統(tǒng)中提供的一個(gè)fork系統(tǒng)調(diào)用,這個(gè)方法非常特殊。普通的方法都是調(diào)用一次,返回一次,而fork方法是調(diào)用一次,返回兩次,原因在于操作系統(tǒng)將當(dāng)前進(jìn)程(父進(jìn)程)復(fù)制出一份進(jìn)程(子進(jìn)程),這兩個(gè)進(jìn)程幾乎完全相同,于是fork方法分別在父進(jìn)程和子進(jìn)程中返回。子進(jìn)程中永遠(yuǎn)返回0,父進(jìn)程中返回的是子進(jìn)程的ID。下面舉個(gè)例子,對(duì)Python使用fork方法創(chuàng)建進(jìn)程進(jìn)行講解。其中os模塊中的getpid方法用于獲取當(dāng)前進(jìn)程的ID, getppid方法用于獲取父進(jìn)程的ID。代碼如下:
import os if __name__ == '__main__': print 'current Process (%s) start ...'%(os.getpid()) pid = os.fork() if pid < 0: print 'error in fork' elif pid == 0: print 'I am child process(%s) and my parent process is (%s)', (os.getpid(), os.getppid()) else: print 'I(%s) created a chlid process (%s).', (os.getpid(), pid)
運(yùn)行結(jié)果如下:
current Process (3052) start ... I(3052) created a chlid process (3053). I am child process(3053) and my parent process is (3052)
2.使用multiprocessing模塊創(chuàng)建多進(jìn)程
multiprocessing模塊提供了一個(gè)Process類(lèi)來(lái)描述一個(gè)進(jìn)程對(duì)象。創(chuàng)建子進(jìn)程時(shí),只需要傳入一個(gè)執(zhí)行函數(shù)和函數(shù)的參數(shù),即可完成一個(gè)Process實(shí)例的創(chuàng)建,用start()方法啟動(dòng)進(jìn)程,用join()方法實(shí)現(xiàn)進(jìn)程間的同步。下面通過(guò)一個(gè)例子來(lái)演示創(chuàng)建多進(jìn)程的流程,代碼如下:
import os from multiprocessing import Process # 子進(jìn)程要執(zhí)行的代碼 def run_proc(name): print 'Child process %s (%s) Running...' % (name, os.getpid()) if __name__ == '__main__': print 'Parent process %s.' % os.getpid() for i in range(5): p = Process(target=run_proc, args=(str(i), )) print 'Process will start.' p.start() p.join() print 'Process end.'
運(yùn)行結(jié)果如下:
Parent process 2392. Process will start. Process will start. Process will start. Process will start. Process will start. Child process 2 (10748) Running... Child process 0 (5324) Running... Child process 1 (3196) Running... Child process 3 (4680) Running... Child process 4 (10696) Running... Process end.
以上介紹了創(chuàng)建進(jìn)程的兩種方法,但是要啟動(dòng)大量的子進(jìn)程,使用進(jìn)程池批量創(chuàng)建子進(jìn)程的方式更加常見(jiàn),因?yàn)楫?dāng)被操作對(duì)象數(shù)目不大時(shí),可以直接利用multiprocessing中的Process動(dòng)態(tài)生成多個(gè)進(jìn)程,如果是上百個(gè)、上千個(gè)目標(biāo),手動(dòng)去限制進(jìn)程數(shù)量卻又太過(guò)繁瑣,這時(shí)候進(jìn)程池Pool發(fā)揮作用的時(shí)候就到了。
3.multiprocessing模塊提供了一個(gè)Pool類(lèi)來(lái)代表進(jìn)程池對(duì)象
Pool可以提供指定數(shù)量的進(jìn)程供用戶(hù)調(diào)用,默認(rèn)大小是CPU的核數(shù)。當(dāng)有新的請(qǐng)求提交到Pool中時(shí),如果池還沒(méi)有滿(mǎn),那么就會(huì)創(chuàng)建一個(gè)新的進(jìn)程用來(lái)執(zhí)行該請(qǐng)求;但如果池中的進(jìn)程數(shù)已經(jīng)達(dá)到規(guī)定最大值,那么該請(qǐng)求就會(huì)等待,直到池中有進(jìn)程結(jié)束,才會(huì)創(chuàng)建新的進(jìn)程來(lái)處理它。下面通過(guò)一個(gè)例子來(lái)演示進(jìn)程池的工作流程,代碼如下:
from multiprocessing import Pool import os, time, random def run_task(name): print 'Task %s (pid = %s) is running...' % (name, os.getpid()) time.sleep(random.random() * 3) print 'Task %s end.' % name if __name__=='__main__': print 'Current process %s.' % os.getpid() p = Pool(processes=3) for i in range(5): p.apply_async(run_task, args=(i, )) print 'Waiting for all subprocesses done...' p.close() p.join() print 'All subprocesses done.'
運(yùn)行結(jié)果如下:
Current process 9176. Waiting for all subprocesses done... Task 0 (pid = 11012) is running... Task 1 (pid = 12464) is running... Task 2 (pid = 11260) is running... Task 2 end. Task 3 (pid = 11260) is running... Task 0 end. Task 4 (pid = 11012) is running... Task 1 end. Task 3 end. Task 4 end. All subprocesses done.
上述程序先創(chuàng)建了容量為3的進(jìn)程池,依次向進(jìn)程池中添加了5個(gè)任務(wù)。從運(yùn)行結(jié)果中可以看到雖然添加了5個(gè)任務(wù),但是一開(kāi)始只運(yùn)行了3個(gè),而且每次最多運(yùn)行3個(gè)進(jìn)程。當(dāng)一個(gè)任務(wù)結(jié)束了,新的任務(wù)依次添加進(jìn)來(lái),任務(wù)執(zhí)行使用的進(jìn)程依然是原來(lái)的進(jìn)程,這一點(diǎn)通過(guò)進(jìn)程的pid就可以看出來(lái)。
注意
Pool對(duì)象調(diào)用join()方法會(huì)等待所有子進(jìn)程執(zhí)行完畢,調(diào)用join()之前必須先調(diào)用close(),調(diào)用close()之后就不能繼續(xù)添加新的Process了。
4.進(jìn)程間通信
假如創(chuàng)建了大量的進(jìn)程,那進(jìn)程間通信是必不可少的。Python提供了多種進(jìn)程間通信的方式,例如Queue、Pipe、Value+Array等。本節(jié)主要講解Queue和Pipe這兩種方式。Queue和Pipe的區(qū)別在于Pipe常用來(lái)在兩個(gè)進(jìn)程間通信,Queue用來(lái)在多個(gè)進(jìn)程間實(shí)現(xiàn)通信。
首先講解一下Queue通信方式。Queue是多進(jìn)程安全的隊(duì)列,可以使用Queue實(shí)現(xiàn)多進(jìn)程之間的數(shù)據(jù)傳遞。有兩個(gè)方法:Put和Get可以進(jìn)行Queue操作:
□ Put方法用以插入數(shù)據(jù)到隊(duì)列中,它還有兩個(gè)可選參數(shù):blocked和timeout。如果blocked為T(mén)rue(默認(rèn)值),并且timeout為正值,該方法會(huì)阻塞timeout指定的時(shí)間,直到該隊(duì)列有剩余的空間。如果超時(shí),會(huì)拋出Queue.Full異常。如果blocked為False,但該Queue已滿(mǎn),會(huì)立即拋出Queue.Full異常。
□ Get方法可以從隊(duì)列讀取并且刪除一個(gè)元素。同樣,Get方法有兩個(gè)可選參數(shù):blocked和timeout。如果blocked為T(mén)rue(默認(rèn)值),并且timeout為正值,那么在等待時(shí)間內(nèi)沒(méi)有取到任何元素,會(huì)拋出Queue.Empty異常。如果blocked為False,分兩種情況:如果Queue有一個(gè)值可用,則立即返回該值;否則,如果隊(duì)列為空,則立即拋出Queue.Empty異常。
下面通過(guò)一個(gè)例子進(jìn)行說(shuō)明:在父進(jìn)程中創(chuàng)建三個(gè)子進(jìn)程,兩個(gè)子進(jìn)程往Queue中寫(xiě)入數(shù)據(jù),一個(gè)子進(jìn)程從Queue中讀取數(shù)據(jù)。程序示例如下:
from multiprocessing import Process, Queue import os, time, random # 寫(xiě)數(shù)據(jù)進(jìn)程執(zhí)行的代碼: def proc_write(q, urls): print('Process(%s) is writing...' % os.getpid()) for url in urls: q.put(url) print('Put %s to queue...' % url) time.sleep(random.random()) # 讀數(shù)據(jù)進(jìn)程執(zhí)行的代碼: def proc_read(q): print('Process(%s) is reading...' % os.getpid()) while True: url = q.get(True) print('Get %s from queue.' % url) if __name__=='__main__': # 父進(jìn)程創(chuàng)建Queue,并傳給各個(gè)子進(jìn)程: q = Queue() proc_writer1 = Process(target=proc_write, args=(q, ['url_1', 'url_2', 'url_3'])) proc_writer2 = Process(target=proc_write, args=(q, ['url_4', 'url_5', 'url_6'])) proc_reader = Process(target=proc_read, args=(q, )) # 啟動(dòng)子進(jìn)程proc_writer,寫(xiě)入: proc_writer1.start() proc_writer2.start() # 啟動(dòng)子進(jìn)程proc_reader,讀取: proc_reader.start() # 等待proc_writer結(jié)束: proc_writer1.join() proc_writer2.join() # proc_reader進(jìn)程里是死循環(huán),無(wú)法等待其結(jié)束,只能強(qiáng)行終止: proc_reader.terminate()
運(yùn)行結(jié)果如下:
Process(9968) is writing... Process(9512) is writing... Put url_1 to queue... Put url_4 to queue... Process(1124) is reading... Get url_1 from queue. Get url_4 from queue. Put url_5 to queue... Get url_5 from queue. Put url_2 to queue... Get url_2 from queue. Put url_6 to queue... Get url_6 from queue. Put url_3 to queue... Get url_3 from queue.
最后介紹一下Pipe的通信機(jī)制,Pipe常用來(lái)在兩個(gè)進(jìn)程間進(jìn)行通信,兩個(gè)進(jìn)程分別位于管道的兩端。
Pipe方法返回(conn1, conn2)代表一個(gè)管道的兩個(gè)端。Pipe方法有duplex參數(shù),如果duplex參數(shù)為T(mén)rue(默認(rèn)值),那么這個(gè)管道是全雙工模式,也就是說(shuō)conn1和conn2均可收發(fā)。若duplex為False, conn1只負(fù)責(zé)接收消息,conn2只負(fù)責(zé)發(fā)送消息。send和recv方法分別是發(fā)送和接收消息的方法。例如,在全雙工模式下,可以調(diào)用conn1.send發(fā)送消息,conn1.recv接收消息。如果沒(méi)有消息可接收,recv方法會(huì)一直阻塞。如果管道已經(jīng)被關(guān)閉,那么recv方法會(huì)拋出EOFError。
下面通過(guò)一個(gè)例子進(jìn)行說(shuō)明:創(chuàng)建兩個(gè)進(jìn)程,一個(gè)子進(jìn)程通過(guò)Pipe發(fā)送數(shù)據(jù),一個(gè)子進(jìn)程通過(guò)Pipe接收數(shù)據(jù)。程序示例如下:
import multiprocessing import random import time, os def proc_send(pipe, urls): for url in urls: print "Process(%s) send: %s" %(os.getpid(), url) pipe.send(url) time.sleep(random.random()) def proc_recv(pipe): while True: print "Process(%s) rev:%s" %(os.getpid(), pipe.recv()) time.sleep(random.random()) if __name__ == "__main__": pipe = multiprocessing.Pipe() p1 = multiprocessing.Process(target=proc_send, args=(pipe[0], ['url_'+str(i) for i in range(10) ])) p2 = multiprocessing.Process(target=proc_recv, args=(pipe[1], )) p1.start() p2.start() p1.join() p2.join()
運(yùn)行結(jié)果如下:
Process(10448) send: url_0 Process(5832) rev:url_0 Process(10448) send: url_1 Process(5832) rev:url_1 Process(10448) send: url_2 Process(5832) rev:url_2 Process(10448) send: url_3 Process(10448) send: url_4 Process(5832) rev:url_3 Process(10448) send: url_5 Process(10448) send: url_6 Process(5832) rev:url_4 Process(5832) rev:url_5 Process(10448) send: url_7 Process(10448) send: url_8 Process(5832) rev:url_6 Process(5832) rev:url_7 Process(10448) send: url_9 Process(5832) rev:url_8 Process(5832) rev:url_9
注意
以上多進(jìn)程程序運(yùn)行結(jié)果的打印順序在不同的系統(tǒng)和硬件條件下略有不同。
1.4.2 多線程
多線程類(lèi)似于同時(shí)執(zhí)行多個(gè)不同程序,多線程運(yùn)行有如下優(yōu)點(diǎn):
□ 可以把運(yùn)行時(shí)間長(zhǎng)的任務(wù)放到后臺(tái)去處理。
□ 用戶(hù)界面可以更加吸引人,比如用戶(hù)點(diǎn)擊了一個(gè)按鈕去觸發(fā)某些事件的處理,可以彈出一個(gè)進(jìn)度條來(lái)顯示處理的進(jìn)度。
□ 程序的運(yùn)行速度可能加快。
□ 在一些需要等待的任務(wù)實(shí)現(xiàn)上,如用戶(hù)輸入、文件讀寫(xiě)和網(wǎng)絡(luò)收發(fā)數(shù)據(jù)等,線程就比較有用了。在這種情況下我們可以釋放一些珍貴的資源,如內(nèi)存占用等。
Python的標(biāo)準(zhǔn)庫(kù)提供了兩個(gè)模塊:thread和threading, thread是低級(jí)模塊,threading是高級(jí)模塊,對(duì)thread進(jìn)行了封裝。絕大多數(shù)情況下,我們只需要使用threading這個(gè)高級(jí)模塊。
1.用threading模塊創(chuàng)建多線程
threading模塊一般通過(guò)兩種方式創(chuàng)建多線程:第一種方式是把一個(gè)函數(shù)傳入并創(chuàng)建Thread實(shí)例,然后調(diào)用start方法開(kāi)始執(zhí)行;第二種方式是直接從threading.Thread繼承并創(chuàng)建線程類(lèi),然后重寫(xiě)__init__方法和run方法。
首先介紹第一種方法,通過(guò)一個(gè)簡(jiǎn)單例子演示創(chuàng)建多線程的流程,程序如下:
import random import time, threading # 新線程執(zhí)行的代碼: def thread_run(urls): print 'Current %s is running...' % threading.current_thread().name for url in urls: print '%s ---->>> %s' % (threading.current_thread().name, url) time.sleep(random.random()) print '%s ended.' % threading.current_thread().name print '%s is running...' % threading.current_thread().name t1 = threading.Thread(target=thread_run, name='Thread_1', args=(['url_1', 'url_2', ' url_3'], )) t2 = threading.Thread(target=thread_run, name='Thread_2', args=(['url_4', 'url_5', ' url_6'], )) t1.start() t2.start() t1.join() t2.join() print '%s ended.' % threading.current_thread().name
運(yùn)行結(jié)果如下:
MainThread is running... Current Thread_1 is running... Thread_1---->>> url_1 Current Thread_2 is running... Thread_2---->>> url_4 Thread_1---->>> url_2 Thread_2---->>> url_5 Thread_2---->>> url_6 Thread_1---->>> url_3 Thread_1 ended. Thread_2 ended. MainThread ended.
第二種方式從threading.Thread繼承創(chuàng)建線程類(lèi),下面將方法一的程序進(jìn)行重寫(xiě),程序如下:
import random import threading import time class myThread(threading.Thread): def __init__(self, name, urls): threading.Thread.__init__(self, name=name) self.urls = urls def run(self): print 'Current %s is running...' % threading.current_thread().name for url in self.urls: print '%s ---->>> %s' % (threading.current_thread().name, url) time.sleep(random.random()) print '%s ended.' % threading.current_thread().name print '%s is running...' % threading.current_thread().name t1 = myThread(name='Thread_1', urls=['url_1', 'url_2', 'url_3']) t2 = myThread(name='Thread_2', urls=['url_4', 'url_5', 'url_6']) t1.start() t2.start() t1.join() t2.join() print '%s ended.' % threading.current_thread().name
運(yùn)行結(jié)果如下:
MainThread is running... Current Thread_1 is running... Thread_1---->>> url_1 Current Thread_2 is running... Thread_2---->>> url_4 Thread_2---->>> url_5 Thread_1---->>> url_2 Thread_1---->>> url_3 Thread_2---->>> url_6 Thread_2 ended. Thread_1 ended.
2.線程同步
如果多個(gè)線程共同對(duì)某個(gè)數(shù)據(jù)修改,則可能出現(xiàn)不可預(yù)料的結(jié)果,為了保證數(shù)據(jù)的正確性,需要對(duì)多個(gè)線程進(jìn)行同步。使用Thread對(duì)象的Lock和RLock可以實(shí)現(xiàn)簡(jiǎn)單的線程同步,這兩個(gè)對(duì)象都有acquire方法和release方法,對(duì)于那些每次只允許一個(gè)線程操作的數(shù)據(jù),可以將其操作放到acquire和release方法之間。
對(duì)于Lock對(duì)象而言,如果一個(gè)線程連續(xù)兩次進(jìn)行acquire操作,那么由于第一次acquire之后沒(méi)有release,第二次acquire將掛起線程。這會(huì)導(dǎo)致Lock對(duì)象永遠(yuǎn)不會(huì)release,使得線程死鎖。RLock對(duì)象允許一個(gè)線程多次對(duì)其進(jìn)行acquire操作,因?yàn)樵谄鋬?nèi)部通過(guò)一個(gè)counter變量維護(hù)著線程acquire的次數(shù)。而且每一次的acquire操作必須有一個(gè)release操作與之對(duì)應(yīng),在所有的release操作完成之后,別的線程才能申請(qǐng)?jiān)揜Lock對(duì)象。下面通過(guò)一個(gè)簡(jiǎn)單的例子演示線程同步的過(guò)程:
import threading mylock = threading.RLock() num=0 class myThread(threading.Thread): def __init__(self, name): threading.Thread.__init__(self, name=name) def run(self): global num while True: mylock.acquire() print '%s locked, Number: %d'%(threading.current_thread().name, num) if num>=4: mylock.release() print '%s released, Number: %d'%(threading.current_thread().name, num) break num+=1 print '%s released, Number: %d'%(threading.current_thread().name, num) mylock.release() if __name__== '__main__': thread1 = myThread('Thread_1') thread2 = myThread('Thread_2') thread1.start() thread2.start()
運(yùn)行結(jié)果如下:
Thread_1 locked, Number: 0 Thread_1 released, Number: 1 Thread_1 locked, Number: 1 Thread_1 released, Number: 2 Thread_2 locked, Number: 2 Thread_2 released, Number: 3 Thread_1 locked, Number: 3 Thread_1 released, Number: 4 Thread_2 locked, Number: 4 Thread_2 released, Number: 4 Thread_1 locked, Number: 4 Thread_1 released, Number: 4
3.全局解釋器鎖(GIL)
在Python的原始解釋器CPython中存在著GIL(Global Interpreter Lock,全局解釋器鎖),因此在解釋執(zhí)行Python代碼時(shí),會(huì)產(chǎn)生互斥鎖來(lái)限制線程對(duì)共享資源的訪問(wèn),直到解釋器遇到I/O操作或者操作次數(shù)達(dá)到一定數(shù)目時(shí)才會(huì)釋放GIL。由于全局解釋器鎖的存在,在進(jìn)行多線程操作的時(shí)候,不能調(diào)用多個(gè)CPU內(nèi)核,只能利用一個(gè)內(nèi)核,所以在進(jìn)行CPU密集型操作的時(shí)候,不推薦使用多線程,更加傾向于多進(jìn)程。那么多線程適合什么樣的應(yīng)用場(chǎng)景呢?對(duì)于IO密集型操作,多線程可以明顯提高效率,例如Python爬蟲(chóng)的開(kāi)發(fā),絕大多數(shù)時(shí)間爬蟲(chóng)是在等待socket返回?cái)?shù)據(jù),網(wǎng)絡(luò)IO的操作延時(shí)比CPU大得多。
1.4.3 協(xié)程
協(xié)程(coroutine),又稱(chēng)微線程,纖程,是一種用戶(hù)級(jí)的輕量級(jí)線程。協(xié)程擁有自己的寄存器上下文和棧。協(xié)程調(diào)度切換時(shí),將寄存器上下文和棧保存到其他地方,在切回來(lái)的時(shí)候,恢復(fù)先前保存的寄存器上下文和棧。因此協(xié)程能保留上一次調(diào)用時(shí)的狀態(tài),每次過(guò)程重入時(shí),就相當(dāng)于進(jìn)入上一次調(diào)用的狀態(tài)。在并發(fā)編程中,協(xié)程與線程類(lèi)似,每個(gè)協(xié)程表示一個(gè)執(zhí)行單元,有自己的本地?cái)?shù)據(jù),與其他協(xié)程共享全局?jǐn)?shù)據(jù)和其他資源。
協(xié)程需要用戶(hù)自己來(lái)編寫(xiě)調(diào)度邏輯,對(duì)于CPU來(lái)說(shuō),協(xié)程其實(shí)是單線程,所以CPU不用去考慮怎么調(diào)度、切換上下文,這就省去了CPU的切換開(kāi)銷(xiāo),所以協(xié)程在一定程度上又好于多線程。那么在Python中是如何實(shí)現(xiàn)協(xié)程的呢?
Python通過(guò)yield提供了對(duì)協(xié)程的基本支持,但是不完全,而使用第三方gevent庫(kù)是更好的選擇,gevent提供了比較完善的協(xié)程支持。gevent是一個(gè)基于協(xié)程的Python網(wǎng)絡(luò)函數(shù)庫(kù),使用greenlet在libev事件循環(huán)頂部提供了一個(gè)有高級(jí)別并發(fā)性的API。主要特性有以下幾點(diǎn):
□ 基于libev的快速事件循環(huán),Linux上是epoll機(jī)制。
□ 基于greenlet的輕量級(jí)執(zhí)行單元。
□ API復(fù)用了Python標(biāo)準(zhǔn)庫(kù)里的內(nèi)容。
□ 支持SSL的協(xié)作式sockets。
□ 可通過(guò)線程池或c-ares實(shí)現(xiàn)DNS查詢(xún)。
□ 通過(guò)monkey patching功能使得第三方模塊變成協(xié)作式。
gevent對(duì)協(xié)程的支持,本質(zhì)上是greenlet在實(shí)現(xiàn)切換工作。greenlet工作流程如下:假如進(jìn)行訪問(wèn)網(wǎng)絡(luò)的IO操作時(shí),出現(xiàn)阻塞,greenlet就顯式切換到另一段沒(méi)有被阻塞的代碼段執(zhí)行,直到原先的阻塞狀況消失以后,再自動(dòng)切換回原來(lái)的代碼段繼續(xù)處理。因此,greenlet是一種合理安排的串行方式。
由于IO操作非常耗時(shí),經(jīng)常使程序處于等待狀態(tài),有了gevent為我們自動(dòng)切換協(xié)程,就保證總有g(shù)reenlet在運(yùn)行,而不是等待IO,這就是協(xié)程一般比多線程效率高的原因。由于切換是在IO操作時(shí)自動(dòng)完成,所以gevent需要修改Python自帶的一些標(biāo)準(zhǔn)庫(kù),將一些常見(jiàn)的阻塞,如socket、select等地方實(shí)現(xiàn)協(xié)程跳轉(zhuǎn),這一過(guò)程在啟動(dòng)時(shí)通過(guò)monkey patch完成。下面通過(guò)一個(gè)的例子來(lái)演示gevent的使用流程,代碼如下:
from gevent import monkey; monkey.patch_all() import gevent import urllib2 def run_task(url): print 'Visit --> %s' % url try: response = urllib2.urlopen(url) data = response.read() print '%d bytes received from %s.' % (len(data), url) except Exception, e: print e if __name__=='__main__': urls = ['https://github.com/', 'https://www.python.org/', 'http://www.cnblogs.com/'] greenlets = [gevent.spawn(run_task, url) for url in urls ] gevent.joinall(greenlets)
運(yùn)行結(jié)果如下:
Visit --> https://github.com/ Visit --> https://www.python.org/ Visit --> http://www.cnblogs.com/ 45740 bytes received from http://www.cnblogs.com/. 25482 bytes received from https://github.com/. 47445 bytes received from https://www.python.org/.
以上程序主要用了gevent中的spawn方法和joinall方法。spawn方法可以看做是用來(lái)形成協(xié)程,joinall方法就是添加這些協(xié)程任務(wù),并且啟動(dòng)運(yùn)行。從運(yùn)行結(jié)果來(lái)看,3個(gè)網(wǎng)絡(luò)操作是并發(fā)執(zhí)行的,而且結(jié)束順序不同,但其實(shí)只有一個(gè)線程。
gevent中還提供了對(duì)池的支持。當(dāng)擁有動(dòng)態(tài)數(shù)量的greenlet需要進(jìn)行并發(fā)管理(限制并發(fā)數(shù))時(shí),就可以使用池,這在處理大量的網(wǎng)絡(luò)和IO操作時(shí)是非常需要的。接下來(lái)使用gevent中pool對(duì)象,對(duì)上面的例子進(jìn)行改寫(xiě),程序如下:
from gevent import monkey monkey.patch_all() import urllib2 from gevent.pool import Pool def run_task(url): print 'Visit --> %s' % url try: response = urllib2.urlopen(url) data = response.read() print '%d bytes received from %s.' % (len(data), url) except Exception, e: print e return 'url:%s --->finish'% url if __name__=='__main__': pool = Pool(2) urls = ['https://github.com/', 'https://www.python.org/', 'http://www.cnblogs.com/'] results = pool.map(run_task, urls) print results
運(yùn)行結(jié)果如下:
Visit --> https://github.com/ Visit --> https://www.python.org/ 25482 bytes received from https://github.com/. Visit --> http://www.cnblogs.com/ 47445 bytes received from https://www.python.org/. 45687 bytes received from http://www.cnblogs.com/. ['url:https://github.com/ --->finish', 'url:https://www.python.org/ --->finish', 'url:http://www.cnblogs.com/ --->finish']
通過(guò)運(yùn)行結(jié)果可以看出,Pool對(duì)象確實(shí)對(duì)協(xié)程的并發(fā)數(shù)量進(jìn)行了管理,先訪問(wèn)了前兩個(gè)網(wǎng)址,當(dāng)其中一個(gè)任務(wù)完成時(shí),才會(huì)執(zhí)行第三個(gè)。
1.4.4 分布式進(jìn)程
分布式進(jìn)程指的是將Process進(jìn)程分布到多臺(tái)機(jī)器上,充分利用多臺(tái)機(jī)器的性能完成復(fù)雜的任務(wù)。我們可以將這一點(diǎn)應(yīng)用到分布式爬蟲(chóng)的開(kāi)發(fā)中。
分布式進(jìn)程在Python中依然要用到multiprocessing模塊。multiprocessing模塊不但支持多進(jìn)程,其中managers子模塊還支持把多進(jìn)程分布到多臺(tái)機(jī)器上。可以寫(xiě)一個(gè)服務(wù)進(jìn)程作為調(diào)度者,將任務(wù)分布到其他多個(gè)進(jìn)程中,依靠網(wǎng)絡(luò)通信進(jìn)行管理。舉個(gè)例子:在做爬蟲(chóng)程序時(shí),常常會(huì)遇到這樣的場(chǎng)景,我們想抓取某個(gè)網(wǎng)站的所有圖片,如果使用多進(jìn)程的話(huà),一般是一個(gè)進(jìn)程負(fù)責(zé)抓取圖片的鏈接地址,將鏈接地址存放到Queue中,另外的進(jìn)程負(fù)責(zé)從Queue中讀取鏈接地址進(jìn)行下載和存儲(chǔ)到本地。現(xiàn)在把這個(gè)過(guò)程做成分布式,一臺(tái)機(jī)器上的進(jìn)程負(fù)責(zé)抓取鏈接,其他機(jī)器上的進(jìn)程負(fù)責(zé)下載存儲(chǔ)。那么遇到的主要問(wèn)題是將Queue暴露到網(wǎng)絡(luò)中,讓其他機(jī)器進(jìn)程都可以訪問(wèn),分布式進(jìn)程就是將這一個(gè)過(guò)程進(jìn)行了封裝,我們可以將這個(gè)過(guò)程稱(chēng)為本地隊(duì)列的網(wǎng)絡(luò)化。整體過(guò)程如圖1-24所示。

圖1-24 分布式進(jìn)程
要實(shí)現(xiàn)上面例子的功能,創(chuàng)建分布式進(jìn)程需要分為六個(gè)步驟:
1)建立隊(duì)列Queue,用來(lái)進(jìn)行進(jìn)程間的通信。服務(wù)進(jìn)程創(chuàng)建任務(wù)隊(duì)列task_queue,用來(lái)作為傳遞任務(wù)給任務(wù)進(jìn)程的通道;服務(wù)進(jìn)程創(chuàng)建結(jié)果隊(duì)列result_queue,作為任務(wù)進(jìn)程完成任務(wù)后回復(fù)服務(wù)進(jìn)程的通道。在分布式多進(jìn)程環(huán)境下,必須通過(guò)由Queuemanager獲得的Queue接口來(lái)添加任務(wù)。
2)把第一步中建立的隊(duì)列在網(wǎng)絡(luò)上注冊(cè),暴露給其他進(jìn)程(主機(jī)),注冊(cè)后獲得網(wǎng)絡(luò)隊(duì)列,相當(dāng)于本地隊(duì)列的映像。
3)建立一個(gè)對(duì)象(Queuemanager(BaseManager))實(shí)例manager,綁定端口和驗(yàn)證口令。
4)啟動(dòng)第三步中建立的實(shí)例,即啟動(dòng)管理manager,監(jiān)管信息通道。
5)通過(guò)管理實(shí)例的方法獲得通過(guò)網(wǎng)絡(luò)訪問(wèn)的Queue對(duì)象,即再把網(wǎng)絡(luò)隊(duì)列實(shí)體化成可以使用的本地隊(duì)列。
6)創(chuàng)建任務(wù)到“本地”隊(duì)列中,自動(dòng)上傳任務(wù)到網(wǎng)絡(luò)隊(duì)列中,分配給任務(wù)進(jìn)程進(jìn)行處理。
接下來(lái)通過(guò)程序?qū)崿F(xiàn)上面的例子(Linux版),首先編寫(xiě)的是服務(wù)進(jìn)程(taskManager.py),代碼如下:
import random, time, Queue from multiprocessing.managers import BaseManager # 第一步:建立task_queue和result_queue,用來(lái)存放任務(wù)和結(jié)果 task_queue=Queue.Queue() result_queue=Queue.Queue() class Queuemanager(BaseManager): pass # 第二步:把創(chuàng)建的兩個(gè)隊(duì)列注冊(cè)在網(wǎng)絡(luò)上,利用register方法,callable參數(shù)關(guān)聯(lián)了Queue對(duì)象, # 將Queue對(duì)象在網(wǎng)絡(luò)中暴露 Queuemanager.register('get_task_queue', callable=lambda:task_queue) Queuemanager.register('get_result_queue', callable=lambda:result_queue) # 第三步:綁定端口8001,設(shè)置驗(yàn)證口令‘qiye'。這個(gè)相當(dāng)于對(duì)象的初始化 manager=Queuemanager(address=('',8001), authkey='qiye') # 第四步:?jiǎn)?dòng)管理,監(jiān)聽(tīng)信息通道 manager.start() # 第五步:通過(guò)管理實(shí)例的方法獲得通過(guò)網(wǎng)絡(luò)訪問(wèn)的Queue對(duì)象 task=manager.get_task_queue() result=manager.get_result_queue() # 第六步:添加任務(wù) for url in ["ImageUrl_"+i for i in range(10)]: print 'put task %s ...' %url task.put(url) # 獲取返回結(jié)果 print 'try get result...' for i in range(10): print 'result is %s' %result.get(timeout=10) # 關(guān)閉管理 manager.shutdown()
任務(wù)進(jìn)程已經(jīng)編寫(xiě)完成,接下來(lái)編寫(xiě)任務(wù)進(jìn)程(taskWorker.py),創(chuàng)建任務(wù)進(jìn)程的步驟相對(duì)較少,需要四個(gè)步驟:
1)使用QueueManager注冊(cè)用于獲取Queue的方法名稱(chēng),任務(wù)進(jìn)程只能通過(guò)名稱(chēng)來(lái)在網(wǎng)絡(luò)上獲取Queue。
2)連接服務(wù)器,端口和驗(yàn)證口令注意保持與服務(wù)進(jìn)程中完全一致。
3)從網(wǎng)絡(luò)上獲取Queue,進(jìn)行本地化。
4)從task隊(duì)列獲取任務(wù),并把結(jié)果寫(xiě)入result隊(duì)列。
程序taskWorker.py代碼(win/linux版)如下:
# coding:utf-8 import time from multiprocessing.managers import BaseManager # 創(chuàng)建類(lèi)似的QueueManager: class QueueManager(BaseManager): pass # 第一步:使用QueueManager注冊(cè)用于獲取Queue的方法名稱(chēng) QueueManager.register('get_task_queue') QueueManager.register('get_result_queue') # 第二步:連接到服務(wù)器: server_addr = '127.0.0.1' print('Connect to server %s...' % server_addr) # 端口和驗(yàn)證口令注意保持與服務(wù)進(jìn)程完全一致: m = QueueManager(address=(server_addr, 8001), authkey='qiye') # 從網(wǎng)絡(luò)連接: m.connect() # 第三步:獲取Queue的對(duì)象: task = m.get_task_queue() result = m.get_result_queue() # 第四步:從task隊(duì)列獲取任務(wù),并把結(jié)果寫(xiě)入result隊(duì)列: while(not task.empty()): image_url = task.get(True, timeout=5) print('run task download %s...' % image_url) time.sleep(1) result.put('%s--->success'%image_url) # 處理結(jié)束: print('worker exit.')
最后開(kāi)始運(yùn)行程序,先啟動(dòng)服務(wù)進(jìn)程taskManager.py,運(yùn)行結(jié)果如下:
put task ImageUrl_0 ... put task ImageUrl_1 ... put task ImageUrl_2 ... put task ImageUrl_3 ... put task ImageUrl_4 ... put task ImageUrl_5 ... put task ImageUrl_6 ... put task ImageUrl_7 ... put task ImageUrl_8 ... put task ImageUrl_9 ... try get result...
接著再啟動(dòng)任務(wù)進(jìn)程taskWorker.py,運(yùn)行結(jié)果如下:
Connect to server 127.0.0.1... run task download ImageUrl_0... run task download ImageUrl_1... run task download ImageUrl_2... run task download ImageUrl_3... run task download ImageUrl_4... run task download ImageUrl_5... run task download ImageUrl_6... run task download ImageUrl_7... run task download ImageUrl_8... run task download ImageUrl_9... worker exit.
當(dāng)任務(wù)進(jìn)程運(yùn)行結(jié)束后,服務(wù)進(jìn)程運(yùn)行結(jié)果如下:
result is ImageUrl_0--->success result is ImageUrl_1--->success result is ImageUrl_2--->success result is ImageUrl_3--->success result is ImageUrl_4--->success result is ImageUrl_5--->success result is ImageUrl_6--->success result is ImageUrl_7--->success result is ImageUrl_8--->success result is ImageUrl_9--->success
其實(shí)這就是一個(gè)簡(jiǎn)單但真正的分布式計(jì)算,把代碼稍加改造,啟動(dòng)多個(gè)worker,就可以把任務(wù)分布到幾臺(tái)甚至幾十臺(tái)機(jī)器上,實(shí)現(xiàn)大規(guī)模的分布式爬蟲(chóng)。
注意
由于平臺(tái)的特性,創(chuàng)建服務(wù)進(jìn)程的代碼在Linux和Windows上有一些不同,創(chuàng)建工作進(jìn)程的代碼是一致的。
taskManager.py程序在Windows版下的代碼如下:
# coding:utf-8 # taskManager.py for windows import Queue from multiprocessing.managers import BaseManager from multiprocessing import freeze_support # 任務(wù)個(gè)數(shù) task_number = 10 # 定義收發(fā)隊(duì)列 task_queue = Queue.Queue(task_number); result_queue = Queue.Queue(task_number); def get_task(): return task_queue def get_result(): return result_queue # 創(chuàng)建類(lèi)似的QueueManager: class QueueManager(BaseManager): pass def win_run(): # Windows下綁定調(diào)用接口不能使用lambda,所以只能先定義函數(shù)再綁定 QueueManager.register('get_task_queue', callable = get_task) QueueManager.register('get_result_queue', callable = get_result) # 綁定端口并設(shè)置驗(yàn)證口令,Windows下需要填寫(xiě)IP地址,Linux下不填默認(rèn)為本地 manager = QueueManager(address = ('127.0.0.1',8001), authkey = 'qiye') # 啟動(dòng) manager.start() try: # 通過(guò)網(wǎng)絡(luò)獲取任務(wù)隊(duì)列和結(jié)果隊(duì)列 task = manager.get_task_queue() result = manager.get_result_queue() # 添加任務(wù) for url in ["ImageUrl_"+str(i) for i in range(10)]: print 'put task %s ...' %url task.put(url) print 'try get result...' for i in range(10): print 'result is %s' %result.get(timeout=10) except: print('Manager error') finally: # 一定要關(guān)閉,否則會(huì)報(bào)管道未關(guān)閉的錯(cuò)誤 manager.shutdown() if __name__ == '__main__': # Windows下多進(jìn)程可能會(huì)有問(wèn)題,添加這句可以緩解 freeze_support() win_run()
1.5 網(wǎng)絡(luò)編程
既然是做爬蟲(chóng)開(kāi)發(fā),必然需要了解Python網(wǎng)絡(luò)編程方面的知識(shí)。計(jì)算機(jī)網(wǎng)絡(luò)是把各個(gè)計(jì)算機(jī)連接到一起,讓網(wǎng)絡(luò)中的計(jì)算機(jī)可以互相通信。網(wǎng)絡(luò)編程就是如何在程序中實(shí)現(xiàn)兩臺(tái)計(jì)算機(jī)的通信。例如當(dāng)你使用瀏覽器訪問(wèn)谷歌網(wǎng)站時(shí),你的計(jì)算機(jī)就和谷歌的某臺(tái)服務(wù)器通過(guò)互聯(lián)網(wǎng)建立起了連接,然后谷歌服務(wù)器會(huì)把把網(wǎng)頁(yè)內(nèi)容作為數(shù)據(jù)通過(guò)互聯(lián)網(wǎng)傳輸?shù)侥愕碾娔X上。
網(wǎng)絡(luò)編程對(duì)所有開(kāi)發(fā)語(yǔ)言都是一樣的,Python也不例外。使用Python進(jìn)行網(wǎng)絡(luò)編程時(shí),實(shí)際上是在Python程序本身這個(gè)進(jìn)程內(nèi),連接到指定服務(wù)器進(jìn)程的通信端口進(jìn)行通信,所以網(wǎng)絡(luò)通信也可以看做兩個(gè)進(jìn)程間的通信。
提到網(wǎng)絡(luò)編程,必須提到的一個(gè)概念是Socket。Socket(套接字)是網(wǎng)絡(luò)編程的一個(gè)抽象概念,通常我們用一個(gè)Socket表示“打開(kāi)了一個(gè)網(wǎng)絡(luò)鏈接”,而打開(kāi)一個(gè)Socket需要知道目標(biāo)計(jì)算機(jī)的IP地址和端口號(hào),再指定協(xié)議類(lèi)型即可。Python提供了兩個(gè)基本的Socket模塊:
□ Socket,提供了標(biāo)準(zhǔn)的BSD Sockets API。
□ SocketServer,提供了服務(wù)器中心類(lèi),可以簡(jiǎn)化網(wǎng)絡(luò)服務(wù)器的開(kāi)發(fā)。
下面講一下Socket模塊功能。
1.Socket類(lèi)型
套接字格式為:socket(family, type[, protocal]),使用給定的地址族、套接字類(lèi)型(如表1-2所示)、協(xié)議編號(hào)(默認(rèn)為0)來(lái)創(chuàng)建套接字。
表1-2 Socket類(lèi)型及說(shuō)明

2.Socket函數(shù)
表1-3列舉了Python網(wǎng)絡(luò)編程常用的函數(shù),其中包括了TCP和UDP。
表1-3 Socket函數(shù)及說(shuō)明

本節(jié)接下來(lái)主要介紹Python中TCP和UDP兩種網(wǎng)絡(luò)類(lèi)型的編程流程。
1.5.1 TCP編程
網(wǎng)絡(luò)編程一般包括兩部分:服務(wù)端和客戶(hù)端。TCP是一種面向連接的通信方式,主動(dòng)發(fā)起連接的叫客戶(hù)端,被動(dòng)響應(yīng)連接的叫服務(wù)端。首先說(shuō)一下服務(wù)端,創(chuàng)建和運(yùn)行TCP服務(wù)端一般需要五個(gè)步驟:
1)創(chuàng)建Socket,綁定Socket到本地IP與端口。
2)開(kāi)始監(jiān)聽(tīng)連接。
3)進(jìn)入循環(huán),不斷接收客戶(hù)端的連接請(qǐng)求。
4)接收傳來(lái)的數(shù)據(jù),并發(fā)送給對(duì)方數(shù)據(jù)。
5)傳輸完畢后,關(guān)閉Socket。
下面通過(guò)一個(gè)例子演示創(chuàng)建TCP服務(wù)端的過(guò)程,程序如下:
# coding:utf-8 import socket import threading import time def dealClient(sock, addr): # 第四步:接收傳來(lái)的數(shù)據(jù),并發(fā)送給對(duì)方數(shù)據(jù) print('Accept new connection from %s:%s...' % addr) sock.send(b'Hello, I am server! ') while True: data = sock.recv(1024) time.sleep(1) if not data or data.decode('utf-8') == 'exit': break print '-->>%s! ' % data.decode('utf-8') sock.send(('Loop_Msg: %s! ' % data.decode('utf-8')).encode('utf-8')) # 第五步:關(guān)閉Socket sock.close() print('Connection from %s:%s closed.' % addr) if __name__=="__main__": # 第一步:創(chuàng)建一個(gè)基于IPv4和TCP協(xié)議的Socket # Socket綁定的IP(127.0.0.1為本機(jī)IP)與端口 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(('127.0.0.1', 9999)) # 第二步:監(jiān)聽(tīng)連接 s.listen(5) print('Waiting for connection...') while True: # 第三步:接收一個(gè)新連接: sock, addr = s.accept() # 創(chuàng)建新線程來(lái)處理TCP連接: t = threading.Thread(target=dealClient, args=(sock, addr)) t.start()
接著編寫(xiě)客戶(hù)端,與服務(wù)端進(jìn)行交互,TCP客戶(hù)端的創(chuàng)建和運(yùn)行需要三個(gè)步驟:
1)創(chuàng)建Socket,連接遠(yuǎn)端地址。
2)連接后發(fā)送數(shù)據(jù)和接收數(shù)據(jù)。
3)傳輸完畢后,關(guān)閉Socket。
程序如下:
# coding:utf-8 import socket # 初始化Socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 連接目標(biāo)的IP和端口 s.connect(('127.0.0.1', 9999)) # 接收消息 print('-->>'+s.recv(1024).decode('utf-8')) # 發(fā)送消息 s.send(b'Hello, I am a client') print('-->>'+s.recv(1024).decode('utf-8')) s.send(b'exit') # 關(guān)閉Socket s.close()
最后看一下運(yùn)行結(jié)果,先啟動(dòng)服務(wù)端,再啟動(dòng)客戶(hù)端。服務(wù)端打印的信息如下:
Waiting for connection... Accept new connection from 127.0.0.1:20164... -->>Hello, I am a client! Connection from 127.0.0.1:20164 closed.
客戶(hù)端輸出信息如下:
-->>Hello, I am server! -->>Loop_Msg: Hello, I am a client!
以上完成了TCP客戶(hù)端與服務(wù)端的交互流程,用TCP協(xié)議進(jìn)行Socket編程在Python中十分簡(jiǎn)單。對(duì)于客戶(hù)端,要主動(dòng)連接服務(wù)器的IP和指定端口;對(duì)于服務(wù)器,要首先監(jiān)聽(tīng)指定端口,然后,對(duì)每一個(gè)新的連接,創(chuàng)建一個(gè)線程或進(jìn)程來(lái)處理。通常,服務(wù)器程序會(huì)無(wú)限運(yùn)行下去。
1.5.2 UDP編程
TCP通信需要一個(gè)建立可靠連接的過(guò)程,而且通信雙方以流的形式發(fā)送數(shù)據(jù)。相對(duì)于TCP, UDP則是面向無(wú)連接的協(xié)議。使用UDP協(xié)議時(shí),不需要建立連接,只需要知道對(duì)方的IP地址和端口號(hào),就可以直接發(fā)數(shù)據(jù)包,但是不關(guān)心是否能到達(dá)目的端。雖然用UDP傳輸數(shù)據(jù)不可靠,但是由于它沒(méi)有建立連接的過(guò)程,速度比TCP快得多,對(duì)于不要求可靠到達(dá)的數(shù)據(jù),就可以使用UDP協(xié)議。
使用UDP協(xié)議,和TCP一樣,也有服務(wù)端和客戶(hù)端之分。UDP編程相對(duì)于TCP編程比較簡(jiǎn)單,服務(wù)端創(chuàng)建和運(yùn)行只需要三個(gè)步驟:
1)創(chuàng)建Socket,綁定指定的IP和端口。
2)直接發(fā)送數(shù)據(jù)和接收數(shù)據(jù)。
3)關(guān)閉Socket。
示例程序如下:
# coding:utf-8 import socket # 創(chuàng)建Socket,綁定指定的IP和端口 # SOCK_DGRAM指定了這個(gè)Socket的類(lèi)型是UDP,綁定端口和TCP示例一樣。 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind(('127.0.0.1', 9999)) print('Bind UDP on 9999...') while True: # 直接發(fā)送數(shù)據(jù)和接收數(shù)據(jù) data, addr = s.recvfrom(1024) print('Received from %s:%s.' % addr) s.sendto(b'Hello, %s! ' % data, addr)
客戶(hù)端的創(chuàng)建和運(yùn)行更加簡(jiǎn)單,創(chuàng)建Socket,直接可以與服務(wù)端進(jìn)行數(shù)據(jù)交換,示例如下:
# coding:utf-8 import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for data in [b'Hello', b'World']: # 發(fā)送數(shù)據(jù): s.sendto(data, ('127.0.0.1', 9999)) # 接收數(shù)據(jù): print(s.recv(1024).decode('utf-8')) s.close()
以上就是UDP服務(wù)端和客戶(hù)端數(shù)據(jù)交互的流程,UDP的使用與TCP類(lèi)似,但是不需要建立連接。此外,服務(wù)器綁定UDP端口和TCP端口互不沖突,即UDP的9999端口與TCP的9999端口可以各自綁定。
1.6 小結(jié)
本章主要講解了Python的編程基礎(chǔ),包括IO編程、進(jìn)程和線程、網(wǎng)絡(luò)編程等三個(gè)方面。這三個(gè)方面在Python爬蟲(chóng)開(kāi)發(fā)中經(jīng)常用到,熟悉這些知識(shí)點(diǎn),對(duì)于之后的開(kāi)發(fā)將起到事半功倍的效果。如果對(duì)于Python編程基礎(chǔ)不是很熟練,希望能將本章講的三個(gè)知識(shí)點(diǎn)著重復(fù)習(xí),將書(shū)中的例子靈活運(yùn)用并加以改進(jìn)。
- Mobile Web Performance Optimization
- CMDB分步構(gòu)建指南
- Delphi程序設(shè)計(jì)基礎(chǔ):教程、實(shí)驗(yàn)、習(xí)題
- Windows系統(tǒng)管理與服務(wù)配置
- Scala Design Patterns
- AngularJS深度剖析與最佳實(shí)踐
- C#程序設(shè)計(jì)
- iOS編程基礎(chǔ):Swift、Xcode和Cocoa入門(mén)指南
- Python算法從菜鳥(niǎo)到達(dá)人
- Windows Embedded CE 6.0程序設(shè)計(jì)實(shí)戰(zhàn)
- C#面向?qū)ο蟪绦蛟O(shè)計(jì)(第2版)
- PowerDesigner 16 從入門(mén)到精通
- SQL Server 2008實(shí)用教程(第3版)
- Arduino Electronics Blueprints
- 測(cè)試基地實(shí)訓(xùn)指導(dǎo)