0x0.前言
作為Geek,我們通常會寫一些爬蟲,來從網頁上抓取我們喜歡的一些資源,比如說妹紙的圖片。這些爬蟲一般是由Python來寫的,因為人生苦短,我用Python。盡管我們都很喜歡這個語言,用來寫爬蟲再好不過,但是不得不承認,Python還是有一些缺陷的,比如它鶸的http庫和線程庫。
Python自帶的http庫urllib2發起的http請求是阻塞式的,這意味著如果采用單線程模型,那么整個進程的大部分時間都阻塞在等待服務端把數據傳輸過來的過程中。如果只是請求一個很簡短的數據包,或者下載一個網頁,那么這不是什么問題,但是如果用來下載爬蟲抓到的大批量的圖片鏈接,一個圖片少則幾百kb,多則上兆,加上如果連接速度比較低,那就不能忍了。
這時候很容易想到用多線程并發下載。于是你就不得不面對Python那糟糕的線程庫了。很多人抱怨Python的線程庫Api不友好,功能也太弱,我覺得這些都不是最主要的,最要命的是,Python的所有線程全部跑在一個核上。。。你們感受一下。
0x1 Nodejs登場
Nodejs是一款基于谷人希的V8引擎開發javascript運行環境。在高性能的V8引擎以及事件驅動的單線程異步非阻塞運行模型的支持下,Nodejs實現的web服務可以在沒有Nginx的http服務器做反向代理的情況下實現很高的業務并發量(當然了配合Nginx食用風味更佳)。
好了,牛逼吹半天這玩意也不是我寫的,我只是想說明用Nodejs來做下載大量圖片鏈接這種高io并發的事情簡直在好不過。
Talk is cheap, show me code.
0x2 準備工作
現在我們假設你的爬蟲已經幫你爬到了一堆圖片的鏈接,然后你的nodejs腳本以某種方式(接收post http請求,進程間通信,讀寫文件或數據庫等等。。。)獲得了這些鏈接,這里我用某款大型角色扮演網絡游戲的官網上提供的壁紙鏈接為例子(這里似乎并沒有為一款運營10年經久不衰的游戲打廣告的意思,僅僅只是情懷溢出。。。):
(function() {
"use strict";
const urlList = [
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/fall-of-the-lich-king/fall-of-the-lich-king-1920x1080.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/black-temple/black-temple-1920x1200.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/zandalari/zandalari-1920x1200.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/rage-of-the-firelands/rage-of-the-firelands-1920x1200.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/fury-of-hellfire/fury-of-hellfire-3840x2160.jpg",
];
})();
我們可以對urlList
執行一個遍歷來依次下載這些圖片,確切的說是依次啟動下載這些鏈接的任務。
(function() {
//略...
var startDownloadTask = function(imgSrc, dirName, index) {
//TODO: startDownloadTask
}
urlList.forEach(function(item, index, array) {
startDownloadTask(item, './', index);
})
})();
startDownloadTask
這個函數就是用來下載這些圖片的。其中imgSrc
是圖片的鏈接,dirName
是我們存放下載后的圖片的路徑,index
是圖片鏈接在列表中的序號。我們在這個函數中,會調用Nodejs的系統Apihttp.request
來完成下載工作,由于該Api和大多數Nodejs的Api一樣是異步非阻塞模式,所以startDownloadTask
函數調用該Api后不會等待下載完成,就會立即返回。在下載的過程中,以及完成之后,或者發生異常時,系統會調用http.request
的回掉函數來做相應的處理。我們接下來會看到該Api的詳細聲明和用法,在了解了該Api的使用方法之后,就可以用它來實現startDownloadTask
函數。
0x3 http.request
的聲明和使用方法
我們在Nodejs的官方文檔上可以找到http.request
的完整聲明和各個參數的說明。它的聲明如下:
http.request(options[, callback])
其中options
可以是帶有請求的目的地址的一條字符串,亦可以是一系用于發起請求的列詳細參數,用于對請求進行更精確的控制。我們現在暫時不需要這些精確的參數控制,直接傳入圖片的鏈接就可以。
至于callback
參數就是剛才說到的回調函數,這是個非常重要的函數,圖片下載下來后能否存入我們指定的位置可全靠它。這個回調函數會接受一個入參,文檔中對這個入參沒有詳細說明,通過后面的例子我們發現,這個叫res
的入參監聽了兩個事件,分別是data
和end
事件,并且還有一個setEncoding
方法,并且還有statusCode
和headers
兩個成員屬性。熟悉Nodejs Api的同學不難猜出,這個res
其實是一個stream.Readable
類型的子類的變量,那兩個事件監聽和setEncoding
方法就是繼承自這個類型,而那兩個成員屬性是子類擴展的。這并沒有什么意外的,在其他語言的類庫中,http請求Api返回一個可讀數據流是很常見的做法。仔細閱讀文檔的其他部分后可以發現,這個res
的真實類型是http.IncomingMessage
。這里不得不對這種不寫明每個參數的類型的文檔提出批評,像javascript這種動態弱類型腳本語言,開發者要想知道一個Api各個參數和返回值有可能是什么類型,拿過來怎么處理可全靠文檔啊。
介紹完了入參,再來看看http.request
會返回什么。文檔中說它會返回一個http.ClientRequest
類型的變量,這個變量可以接受error
事件,來對請求異常的情況進行處理。
剛才說過,這個Api是一個異步接口,調用這個Api之后會立即返回一個http.ClientRequest
類型變量,假設變量名為req
。但這時候不會馬上發起請求。我們這時候可以設置req
的error
事件的監聽回調,如果是POST請求的話,還可以調用req.write
方法來設置請求消息體,然后調用req.end
方法來結束此次請求的發送過程。當收到響應時(嚴格的說是確認接收完響應頭時),就會調用callback
回調函數,在這個回調函數中,可以通過讀取res.statusCode
和res.headers
獲取響應的返回狀態碼和頭部信息,其中頭部信息包含了重要的字段content-length
,表示響應消息體的總長度。由于響應消息體可能很長,服務端需要把消息體拆分成多個tcp封包來發送,客戶端在接收到tcp封包后還要進行消息體的重組,所以這里采用一個數據流對象來對返回的消息體做讀取操作,需要注冊data
和end
事件監聽,分別處理鏈路層緩沖區接收了若干字節的消息體封包并且拼接完成回調上層協議處理和tcp連接拆線時的事務。
Api聲明后面附帶了一個例子,比較簡單不難看懂,這里就不詳細說了。
0x4 實現startDownloadTask
了解了http.request
的基本使用方法,以及看過例子之后,我們很快就能寫出一個簡單的下載過程了:
(function() {
"use strict";
const http = require("http");
//略...
function getHttpReqCallback(imgSrc, dirName, index) {
var callback = function(res) {
// TODO: callback回調函數實現
};
return callback;
}
var startDownloadTask = function(imgSrc, dirName, index) {
var req = http.request(imgSrc, getHttpReqCallback(imgSrc, dirName, index));
req.on('error', function(e){});
req.end();
}
//略
})();
我暫且先忽略了請求的錯誤處理。這里需要講解的是函數getHttpReqCallback
,這個函數本身不是回調函數,在調用http.request
時會先調用它,它返回了一個閉包callback
,作為http.request
的回調函數。我很快會解釋為什么需要這樣寫。
接下來我們來實現這個回調函數:
(function() {
"use strict";
const http = require("http");
const fs = require("fs");
const path = require("path");
//略...
function getHttpReqCallback(imgSrc, dirName, index) {
var fileName = index + "-" + path.basename(imgSrc);
var callback = function(res) {
var fileBuff = [];
res.on('data', function (chunk) {
var buffer = new Buffer(chunk);
fileBuff.push(buffer);
});
res.on('end', function() {
var totalBuff = Buffer.concat(fileBuff);
fs.appendFile(dirName + "/" + fileName, totalBuff, function(err){});
});
};
return callback;
}
//略
})();
這里的callback
函數的邏輯目前為止還不是很復雜,res
的data
事件的回調函數中,chunk
參數是從可讀數據流中讀出的數據,將其轉換為Buffer
對象后插入fillBuff
數組以待后用。
res
的end
事件意味著鏈路層鏈接拆除,數據接收完畢,在該事件的回調中,我們通過Buffer.concat
函數,將fileBuff
中的所有Buffer
對象依次重組為一個新的Buffer
對象totalBuff
,該對象既是接收到的完整的數據。之后通過fs.appendFile
函數將totalBuff
存入磁盤,存放路徑為dirName + "/" + fileName
。
于是我們就有了一個完整的勉強可以工作的腳本,完整的腳本代碼如下:
(function() {
"use strict";
const fs = require("fs");
const http = require("http");
const path = require("path");
const urlList = [
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/fall-of-the-lich-king/fall-of-the-lich-king-1920x1080.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/black-temple/black-temple-1920x1200.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/zandalari/zandalari-1920x1200.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/rage-of-the-firelands/rage-of-the-firelands-1920x1200.jpg",
"http://content.battlenet.com.cn/wow/media/wallpapers/patch/fury-of-hellfire/fury-of-hellfire-3840x2160.jpg",
];
function getHttpReqCallback(imgSrc, dirName, index) {
var fileName = index + "-" + path.basename(imgSrc);
var callback = function(res) {
var fileBuff = [];
res.on('data', function (chunk) {
var buffer = new Buffer(chunk);
fileBuff.push(buffer);
});
res.on('end', function() {
var totalBuff = Buffer.concat(fileBuff);
fs.appendFile(dirName + "/" + fileName, totalBuff, function(err){});
});
};
return callback;
}
var startDownloadTask = function(imgSrc, dirName, index) {
var req = http.request(imgSrc, getHttpReqCallback(imgSrc, dirName, index));
req.on('error', function(e){});
req.end();
}
urlList.forEach(function(item, index, array) {
startDownloadTask(item, './', index);
})
})();
之所以說它勉強可工作,是因為它完全沒有做錯誤處理,程序的健壯性幾乎為0,甚至連打印日志都沒有了,下載過程中一旦出現任何意外情況,那就自求多福吧。
但即使這樣一個漏洞百出的代碼,也還是有幾點需要特殊說明。
為什么要采用閉包?
因為實際上作為http.request
的回調函數callback
,它的聲明原型決定的它只可以接受唯一一個參數res
,但是在callback
函數中我們需要明確知道下載下來的數據在硬盤上存放的路徑,這個路徑取決于startDownloadTask
的入參dirName
和index
。所以函數getHttpReqCallback
就是用于創建一個閉包,將dirName
和index
的值寫入這個閉包中。
其實我們原本并不需要getHttpReqCallback
這個函數來顯示的返回一個閉包,而是可以直接使用內聯匿名函數的方法實現http.request
的callback
,代碼大概會寫成這樣:
var startDownloadTask = function(imgSrc, dirName, index) {
var req = http.request(imgSrc, function(res) {
var fileName = index + "-" + path.basename(imgSrc);
var fileBuff = [];
res.on('data', function (chunk) {
var buffer = new Buffer(chunk);
fileBuff.push(buffer);
});
res.on('end', function() {
var totalBuff = Buffer.concat(fileBuff);
fs.appendFile(dirName + "/" + fileName, totalBuff, function(err){});
});
});
req.on('error', function(e){});
req.end();
}
這樣也可以工作,http.request
的callback
直接訪問外層作用域的變量,即函數startDownloadTask
的入參dirName
和index
,這也是一個閉包。這樣寫的問題在于,一段異步代碼強行插入原本連貫的同步代碼中,也許現在你覺得這也沒什么,這是因為目前callback
里還沒有處理任何的異常情況,所以邏輯比較簡單,這樣看起來也不算很混亂,但是我需要說的是,一旦后面加入了異常處理的代碼,這一塊看起來就會非常糟糕了。
為什么在data
事件中要使用一個列表緩存接收到的所有數據,然后在end
中一次性寫入硬盤?
首先要說的是,這里并不是出于通過減少寫磁盤次數達到提高性能或者延長磁盤壽命的目的,雖然可能確實有這樣的效果。根本原因在于,如果不采用一次性寫入,在nodejs的異步非阻塞運行機制下,這樣存入磁盤的數據會混亂,導致不堪入目的后果,比較直觀的情況見附錄。
在同步阻塞運行模型的語言中(java, c, python),確實存在將遠程連接傳輸過來的數據先緩存在內存里,待接收完整或或緩存了一定長度的數據之后再一次性寫入硬盤的做法,以達到減少寫磁盤操作次數的目的。但是如果在每一次從遠程連接接中讀取到數據之后立即將數據寫入硬盤,也不會有什么問題(tcp協議已經幫我們將數據包排好序),這是因為在同步阻塞運行模型中,讀tcp連接和寫磁盤這兩個動作必然不可能同時執行,而是讀tcp -> 寫磁盤 -> 讀tcp -> 寫磁盤...
這樣的串行執行,在上一個操作完成之后,下一個操作才會開始。這樣的執行方式也許效率會比較低,但是寫入的磁盤的數據并不會混亂。
現在回到我們的異步非阻塞世界中來,在這個世界中,遠程讀取的操作是通過事件回調的方式發生的,res
的data
事件任何一個時間片內都可能觸發,你無法預知,無法控制,甚至觸發頻率都和你無關,那取決于本次連接的帶寬。而我們的寫磁盤操作fs.appendFile
和Nodejs的大部分Api一樣是一個異步非阻塞的調用,它會非常快的返回,但是它所執行的寫文件操作,則會慢的多,而進程不會阻塞在那里等待這個操作完成。在常識里,遠程連接的下載速度比本地硬盤的寫入速度要慢,但這并不是絕對的,隨著網速的提高,現在一塊高速網卡,在高速的網絡中帶來的下載速度超過一塊老舊的機械硬盤的寫入速度并非不可能發生。除此之外,即使在較長的一段時間內,網絡的平均連接速度并沒有快的那么夸張,但是我們知道在tcp/ip協議棧中,鏈路層下層的網絡層中前后兩個ip報文的到達時間間隔也是完全無法確定的,有可能它們會在很短的時間間隔內到達,被tcp協議重組之后上拋給應用層協議,在我們的運行環境中以很短的間隔兩次觸發data
事件,而這個間隔并不足夠磁盤將前一段數據寫入。
我畫個草圖來解釋到底發生什么事情:
|data1
| |data2
|-----------------------------| //<- write data1
| |-----------------------------| //<- write data2
| |
|----------------------------------------------------------> time
此時要想寫入的數據保持有序不混亂,只能寄希望于機械硬盤的一面只有一個磁頭來從物理層面保證原子操作了。但是很可惜我們知道現代機械硬盤每一面至少都有兩個磁頭。
有著很多java或者c++編程經驗的你也許會想在這里加一個同步鎖,不過Nodejs作為一個表面宣稱的單線程環境(底層的V8引擎肯定還是有多線程甚至多進程調度機制實現的),在語法和Api層面并沒有鎖這個概念。
所以為了保證最終寫入磁盤的數據不混亂,在data
事件的回調中不可以再用異步的方式處理數據了,于是有了現在這種先寫入緩存列表中,在數據接收完整后再一次性寫文件的做法。由于new Buffer(chunk)
和fileBuff.push(buffer)
都是同步操作,并且執行的速度非常快;即使下一個data
事件到來的比這兩個操作還要快,由于單線程運行模型的限制,也必須等待這兩個操作完成后才會開始第二次回調。所以能保證數據有序的緩存到內存中,再有序的寫入硬盤。
0x5 異常處理
剛才說到,目前為止我們的腳本雖然能夠正常工作,但是沒有異常處理,程序非常脆弱。由于異常處理是一個程序非常重要的部分,所以在這里我有義務要完成這部分代碼。
首先我們從最簡單的做起,打印一些日志來幫助調試程序。
(function() {
//略。。
function getHttpReqCallback(imgSrc, dirName, index) {
var callback = function(res) {
console.log("request: " + imgSrc + " return status: " + res.statusCode);
//略。。
res.on('end', function() {
console.log("end downloading " + imgSrc);
//略。。
});
};
return callback;
}
var startDownloadTask = function(imgSrc, dirName, index) {
console.log("start downloading " + imgSrc);
//略。。。
}
})
接下來我們在req
的error
事件中,進行重新下載嘗試的操作:
var startDownloadTask = function(imgSrc, dirName, index) {
//略。。
req.on('error', function(e){
console.log("request " + imgSrc + " error, try again");
startDownloadTask(imgSrc, dirName, index);
});
}
這樣一旦在請求階段出現異常,會自動重新發起請求。你也可以在這里自行添加重試次數上限。
下面的代碼給請求設置了一個一分鐘的超時時間:
var startDownloadTask = function(imgSrc, dirName, index) {
//略。。
req.setTimeout(60 * 1000, function() {
console.log("reqeust " + imgSrc " timeout, abort this reqeust");
req.abort();
})
}
一旦在一分鐘之內下載還沒有完成,則會強制終止此次請求,這會立即觸發res
的end
事件。
對req
的異常處理大致就是這些,接下來是對res
的異常處理。
我們首先需要獲取包體的總長度,該值在響應頭的content-length
字段中:
function getHttpReqCallback(imgSrc, dirName, index) {
var callback = function(res) {
var contentLength = parseInt(res.headers['content-length']);
//略。。
}
}
在end
事件的回調中,用接收到的數據總長度和響應頭中的包體長度進行比較,驗證響應信息是否接收完全:
res.on('end', function() {
console.log("end downloading " + imgSrc);
if (isNaN(contentLength)) {
console.log(imgSrc + " content length error");
return;
}
var totalBuff = Buffer.concat(fileBuff);
console.log("totalBuff.length = " + totalBuff.length + " " + "contentLength = " + contentLength);
if (totalBuff.length < contentLength) {
console.log(imgSrc + " download error, try again");
startDownloadTask(imgSrc, dirName, index);
return;
}
fs.appendFile(dirName + "/" + fileName, totalBuff, function(err){});
}
如果收到的響應數據的長度比content-length
中標記的短,通常是由于請求超時造成的,在這里我重新發起了一次請求,你也可以根據你的實際情況采取其他的做法。
好了,異常處理部分的代碼就是這么多。
0x6 結束
完整的代碼見這里https://github.com/knightingal/SimpleDownloader。
本人在Nodejs方面也是完全的新手,沒有太深入的研究Nodejs內部的運行機制,只是網上讀過幾篇文章,用Nodejs寫過一些簡短的腳本,在這個過程中掉過一些坑,本文就是一次印象深刻的爬坑過程的整理和總結。總的來說,Nodejs是一個非常強大且有趣的工具,但是由于其獨特的運行模型,以及javascript自身也有不少的歷史遺留問題需要解決,所以對于長期以來習慣了java, c/c++, python一類思維方式的猿們剛剛接觸它的時候產生不少疑惑,希望本文能幫助大家理解Nodejs中的一些不同于其他語言的和運行環境的地方。
附:錯誤的姿勢會導致什么后果
假如我們的callback
寫成下面這樣:
function getHttpReqCallback(imgSrc, dirName, index) {
var fileName = index + "-" + path.basename(imgSrc);
var callback = function(res) {
console.log("request: " + imgSrc + " return status: " + res.statusCode);
var contentLength = parseInt(res.headers['content-length']);
var fileBuff = [];
res.on('data', function (chunk) {
var buffer = new Buffer(chunk);
//fileBuff.push(buffer);
fs.appendFile(dirName + "/" + fileName, buffer, function(err){});
});
res.on('end', function() {
console.log("end downloading " + imgSrc);
// if (isNaN(contentLength)) {
// console.log(imgSrc + " content length error");
// return;
// }
// var totalBuff = Buffer.concat(fileBuff);
// console.log("totalBuff.length = " + totalBuff.length + " " + "contentLength = " + contentLength);
// if (totalBuff.length < contentLength) {
// console.log(imgSrc + " download error, try again");
// startDownloadTask(imgSrc, dirName, index);
// return;
// }
// fs.appendFile(dirName + "/" + fileName, totalBuff, function(err){});
});
};
return callback;
}
它會把你下下來的圖片搞成這個樣子:
什么鬼。。。