如何使用Nodejs進行批量下載

0x0.前言

作為Geek,我們通常會寫一些爬蟲,來從網(wǎng)頁上抓取我們喜歡的一些資源,比如說妹紙的圖片。這些爬蟲一般是由Python來寫的,因為人生苦短,我用Python。盡管我們都很喜歡這個語言,用來寫爬蟲再好不過,但是不得不承認,Python還是有一些缺陷的,比如它鶸的http庫和線程庫。

Python自帶的http庫urllib2發(fā)起的http請求是阻塞式的,這意味著如果采用單線程模型,那么整個進程的大部分時間都阻塞在等待服務(wù)端把數(shù)據(jù)傳輸過來的過程中。如果只是請求一個很簡短的數(shù)據(jù)包,或者下載一個網(wǎng)頁,那么這不是什么問題,但是如果用來下載爬蟲抓到的大批量的圖片鏈接,一個圖片少則幾百kb,多則上兆,加上如果連接速度比較低,那就不能忍了。

這時候很容易想到用多線程并發(fā)下載。于是你就不得不面對Python那糟糕的線程庫了。很多人抱怨Python的線程庫Api不友好,功能也太弱,我覺得這些都不是最主要的,最要命的是,Python的所有線程全部跑在一個核上。。。你們感受一下。

0x1 Nodejs登場

Nodejs是一款基于谷人希的V8引擎開發(fā)javascript運行環(huán)境。在高性能的V8引擎以及事件驅(qū)動的單線程異步非阻塞運行模型的支持下,Nodejs實現(xiàn)的web服務(wù)可以在沒有Nginx的http服務(wù)器做反向代理的情況下實現(xiàn)很高的業(yè)務(wù)并發(fā)量(當(dāng)然了配合Nginx食用風(fēng)味更佳)。

好了,牛逼吹半天這玩意也不是我寫的,我只是想說明用Nodejs來做下載大量圖片鏈接這種高io并發(fā)的事情簡直在好不過。

Talk is cheap, show me code.

0x2 準(zhǔn)備工作

現(xiàn)在我們假設(shè)你的爬蟲已經(jīng)幫你爬到了一堆圖片的鏈接,然后你的nodejs腳本以某種方式(接收post http請求,進程間通信,讀寫文件或數(shù)據(jù)庫等等。。。)獲得了這些鏈接,這里我用某款大型角色扮演網(wǎng)絡(luò)游戲的官網(wǎng)上提供的壁紙鏈接為例子(這里似乎并沒有為一款運營10年經(jīng)久不衰的游戲打廣告的意思,僅僅只是情懷溢出。。。):

(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執(zhí)行一個遍歷來依次下載這些圖片,確切的說是依次啟動下載這些鏈接的任務(wù)。

(function() {
  //略...

  var startDownloadTask = function(imgSrc, dirName, index) {
    //TODO: startDownloadTask
  }

  urlList.forEach(function(item, index, array) {
    startDownloadTask(item, './', index);
  })
})();

startDownloadTask這個函數(shù)就是用來下載這些圖片的。其中imgSrc是圖片的鏈接,dirName是我們存放下載后的圖片的路徑,index是圖片鏈接在列表中的序號。我們在這個函數(shù)中,會調(diào)用Nodejs的系統(tǒng)Apihttp.request來完成下載工作,由于該Api和大多數(shù)Nodejs的Api一樣是異步非阻塞模式,所以startDownloadTask函數(shù)調(diào)用該Api后不會等待下載完成,就會立即返回。在下載的過程中,以及完成之后,或者發(fā)生異常時,系統(tǒng)會調(diào)用http.request的回掉函數(shù)來做相應(yīng)的處理。我們接下來會看到該Api的詳細聲明和用法,在了解了該Api的使用方法之后,就可以用它來實現(xiàn)startDownloadTask函數(shù)。

0x3 http.request的聲明和使用方法

我們在Nodejs的官方文檔上可以找到http.request的完整聲明和各個參數(shù)的說明。它的聲明如下:

http.request(options[, callback])

其中options可以是帶有請求的目的地址的一條字符串,亦可以是一系用于發(fā)起請求的列詳細參數(shù),用于對請求進行更精確的控制。我們現(xiàn)在暫時不需要這些精確的參數(shù)控制,直接傳入圖片的鏈接就可以。

至于callback參數(shù)就是剛才說到的回調(diào)函數(shù),這是個非常重要的函數(shù),圖片下載下來后能否存入我們指定的位置可全靠它。這個回調(diào)函數(shù)會接受一個入?yún)?,文檔中對這個入?yún)]有詳細說明,通過后面的例子我們發(fā)現(xiàn),這個叫res的入?yún)⒈O(jiān)聽了兩個事件,分別是dataend事件,并且還有一個setEncoding方法,并且還有statusCodeheaders兩個成員屬性。熟悉Nodejs Api的同學(xué)不難猜出,這個res其實是一個stream.Readable類型的子類的變量,那兩個事件監(jiān)聽和setEncoding方法就是繼承自這個類型,而那兩個成員屬性是子類擴展的。這并沒有什么意外的,在其他語言的類庫中,http請求Api返回一個可讀數(shù)據(jù)流是很常見的做法。仔細閱讀文檔的其他部分后可以發(fā)現(xiàn),這個res的真實類型是http.IncomingMessage。這里不得不對這種不寫明每個參數(shù)的類型的文檔提出批評,像javascript這種動態(tài)弱類型腳本語言,開發(fā)者要想知道一個Api各個參數(shù)和返回值有可能是什么類型,拿過來怎么處理可全靠文檔啊。

介紹完了入?yún)?,再來看?code>http.request會返回什么。文檔中說它會返回一個http.ClientRequest類型的變量,這個變量可以接受error事件,來對請求異常的情況進行處理。

剛才說過,這個Api是一個異步接口,調(diào)用這個Api之后會立即返回一個http.ClientRequest類型變量,假設(shè)變量名為req。但這時候不會馬上發(fā)起請求。我們這時候可以設(shè)置reqerror事件的監(jiān)聽回調(diào),如果是POST請求的話,還可以調(diào)用req.write方法來設(shè)置請求消息體,然后調(diào)用req.end方法來結(jié)束此次請求的發(fā)送過程。當(dāng)收到響應(yīng)時(嚴(yán)格的說是確認接收完響應(yīng)頭時),就會調(diào)用callback回調(diào)函數(shù),在這個回調(diào)函數(shù)中,可以通過讀取res.statusCoderes.headers獲取響應(yīng)的返回狀態(tài)碼和頭部信息,其中頭部信息包含了重要的字段content-length,表示響應(yīng)消息體的總長度。由于響應(yīng)消息體可能很長,服務(wù)端需要把消息體拆分成多個tcp封包來發(fā)送,客戶端在接收到tcp封包后還要進行消息體的重組,所以這里采用一個數(shù)據(jù)流對象來對返回的消息體做讀取操作,需要注冊dataend事件監(jiān)聽,分別處理鏈路層緩沖區(qū)接收了若干字節(jié)的消息體封包并且拼接完成回調(diào)上層協(xié)議處理和tcp連接拆線時的事務(wù)。

Api聲明后面附帶了一個例子,比較簡單不難看懂,這里就不詳細說了。

0x4 實現(xiàn)startDownloadTask

了解了http.request的基本使用方法,以及看過例子之后,我們很快就能寫出一個簡單的下載過程了:

(function() {
  "use strict";
  const http = require("http");

  //略...

  function getHttpReqCallback(imgSrc, dirName, index) {
    var callback = function(res) {
      // TODO: callback回調(diào)函數(shù)實現(xiàn)
    };

    return callback;
  }

  var startDownloadTask = function(imgSrc, dirName, index) {
    var req = http.request(imgSrc, getHttpReqCallback(imgSrc, dirName, index));
    req.on('error', function(e){});
    req.end();
  }

  //略
})();

我暫且先忽略了請求的錯誤處理。這里需要講解的是函數(shù)getHttpReqCallback,這個函數(shù)本身不是回調(diào)函數(shù),在調(diào)用http.request時會先調(diào)用它,它返回了一個閉包callback,作為http.request的回調(diào)函數(shù)。我很快會解釋為什么需要這樣寫。

接下來我們來實現(xiàn)這個回調(diào)函數(shù):

(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函數(shù)的邏輯目前為止還不是很復(fù)雜,resdata事件的回調(diào)函數(shù)中,chunk參數(shù)是從可讀數(shù)據(jù)流中讀出的數(shù)據(jù),將其轉(zhuǎn)換為Buffer對象后插入fillBuff數(shù)組以待后用。

resend事件意味著鏈路層鏈接拆除,數(shù)據(jù)接收完畢,在該事件的回調(diào)中,我們通過Buffer.concat函數(shù),將fileBuff中的所有Buffer對象依次重組為一個新的Buffer對象totalBuff,該對象既是接收到的完整的數(shù)據(jù)。之后通過fs.appendFile函數(shù)將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,甚至連打印日志都沒有了,下載過程中一旦出現(xiàn)任何意外情況,那就自求多福吧。

但即使這樣一個漏洞百出的代碼,也還是有幾點需要特殊說明。

為什么要采用閉包?

因為實際上作為http.request的回調(diào)函數(shù)callback,它的聲明原型決定的它只可以接受唯一一個參數(shù)res,但是在callback函數(shù)中我們需要明確知道下載下來的數(shù)據(jù)在硬盤上存放的路徑,這個路徑取決于startDownloadTask的入?yún)?code>dirName和index。所以函數(shù)getHttpReqCallback就是用于創(chuàng)建一個閉包,將dirNameindex的值寫入這個閉包中。
其實我們原本并不需要getHttpReqCallback這個函數(shù)來顯示的返回一個閉包,而是可以直接使用內(nèi)聯(lián)匿名函數(shù)的方法實現(xiàn)http.requestcallback,代碼大概會寫成這樣:

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.requestcallback直接訪問外層作用域的變量,即函數(shù)startDownloadTask的入?yún)?code>dirName和index,這也是一個閉包。這樣寫的問題在于,一段異步代碼強行插入原本連貫的同步代碼中,也許現(xiàn)在你覺得這也沒什么,這是因為目前callback里還沒有處理任何的異常情況,所以邏輯比較簡單,這樣看起來也不算很混亂,但是我需要說的是,一旦后面加入了異常處理的代碼,這一塊看起來就會非常糟糕了。

為什么在data事件中要使用一個列表緩存接收到的所有數(shù)據(jù),然后在end中一次性寫入硬盤?

首先要說的是,這里并不是出于通過減少寫磁盤次數(shù)達到提高性能或者延長磁盤壽命的目的,雖然可能確實有這樣的效果。根本原因在于,如果不采用一次性寫入,在nodejs的異步非阻塞運行機制下,這樣存入磁盤的數(shù)據(jù)會混亂,導(dǎo)致不堪入目的后果,比較直觀的情況見附錄。

在同步阻塞運行模型的語言中(java, c, python),確實存在將遠程連接傳輸過來的數(shù)據(jù)先緩存在內(nèi)存里,待接收完整或或緩存了一定長度的數(shù)據(jù)之后再一次性寫入硬盤的做法,以達到減少寫磁盤操作次數(shù)的目的。但是如果在每一次從遠程連接接中讀取到數(shù)據(jù)之后立即將數(shù)據(jù)寫入硬盤,也不會有什么問題(tcp協(xié)議已經(jīng)幫我們將數(shù)據(jù)包排好序),這是因為在同步阻塞運行模型中,讀tcp連接和寫磁盤這兩個動作必然不可能同時執(zhí)行,而是讀tcp -> 寫磁盤 -> 讀tcp -> 寫磁盤...這樣的串行執(zhí)行,在上一個操作完成之后,下一個操作才會開始。這樣的執(zhí)行方式也許效率會比較低,但是寫入的磁盤的數(shù)據(jù)并不會混亂。

現(xiàn)在回到我們的異步非阻塞世界中來,在這個世界中,遠程讀取的操作是通過事件回調(diào)的方式發(fā)生的,resdata事件任何一個時間片內(nèi)都可能觸發(fā),你無法預(yù)知,無法控制,甚至觸發(fā)頻率都和你無關(guān),那取決于本次連接的帶寬。而我們的寫磁盤操作fs.appendFile和Nodejs的大部分Api一樣是一個異步非阻塞的調(diào)用,它會非??斓姆祷兀撬鶊?zhí)行的寫文件操作,則會慢的多,而進程不會阻塞在那里等待這個操作完成。在常識里,遠程連接的下載速度比本地硬盤的寫入速度要慢,但這并不是絕對的,隨著網(wǎng)速的提高,現(xiàn)在一塊高速網(wǎng)卡,在高速的網(wǎng)絡(luò)中帶來的下載速度超過一塊老舊的機械硬盤的寫入速度并非不可能發(fā)生。除此之外,即使在較長的一段時間內(nèi),網(wǎng)絡(luò)的平均連接速度并沒有快的那么夸張,但是我們知道在tcp/ip協(xié)議棧中,鏈路層下層的網(wǎng)絡(luò)層中前后兩個ip報文的到達時間間隔也是完全無法確定的,有可能它們會在很短的時間間隔內(nèi)到達,被tcp協(xié)議重組之后上拋給應(yīng)用層協(xié)議,在我們的運行環(huán)境中以很短的間隔兩次觸發(fā)data事件,而這個間隔并不足夠磁盤將前一段數(shù)據(jù)寫入。

我畫個草圖來解釋到底發(fā)生什么事情:

|data1
|       |data2
|-----------------------------|  //<- write data1
|       |-----------------------------|  //<- write data2
|       |
|----------------------------------------------------------> time

此時要想寫入的數(shù)據(jù)保持有序不混亂,只能寄希望于機械硬盤的一面只有一個磁頭來從物理層面保證原子操作了。但是很可惜我們知道現(xiàn)代機械硬盤每一面至少都有兩個磁頭。

有著很多java或者c++編程經(jīng)驗的你也許會想在這里加一個同步鎖,不過Nodejs作為一個表面宣稱的單線程環(huán)境(底層的V8引擎肯定還是有多線程甚至多進程調(diào)度機制實現(xiàn)的),在語法和Api層面并沒有鎖這個概念。

所以為了保證最終寫入磁盤的數(shù)據(jù)不混亂,在data事件的回調(diào)中不可以再用異步的方式處理數(shù)據(jù)了,于是有了現(xiàn)在這種先寫入緩存列表中,在數(shù)據(jù)接收完整后再一次性寫文件的做法。由于new Buffer(chunk)fileBuff.push(buffer)都是同步操作,并且執(zhí)行的速度非???;即使下一個data事件到來的比這兩個操作還要快,由于單線程運行模型的限制,也必須等待這兩個操作完成后才會開始第二次回調(diào)。所以能保證數(shù)據(jù)有序的緩存到內(nèi)存中,再有序的寫入硬盤。

0x5 異常處理

剛才說到,目前為止我們的腳本雖然能夠正常工作,但是沒有異常處理,程序非常脆弱。由于異常處理是一個程序非常重要的部分,所以在這里我有義務(wù)要完成這部分代碼。

首先我們從最簡單的做起,打印一些日志來幫助調(diào)試程序。

(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);
    //略。。。
  }
})

接下來我們在reqerror事件中,進行重新下載嘗試的操作:

  var startDownloadTask = function(imgSrc, dirName, index) {
    //略。。
    req.on('error', function(e){
      console.log("request " + imgSrc + " error, try again");
      startDownloadTask(imgSrc, dirName, index);
    });
  }

這樣一旦在請求階段出現(xiàn)異常,會自動重新發(fā)起請求。你也可以在這里自行添加重試次數(shù)上限。

下面的代碼給請求設(shè)置了一個一分鐘的超時時間:

  var startDownloadTask = function(imgSrc, dirName, index) {
    //略。。
    req.setTimeout(60 * 1000, function() {
      console.log("reqeust " + imgSrc " timeout, abort this reqeust");
      req.abort();
    })
  }

一旦在一分鐘之內(nèi)下載還沒有完成,則會強制終止此次請求,這會立即觸發(fā)resend事件。

req的異常處理大致就是這些,接下來是對res的異常處理。

我們首先需要獲取包體的總長度,該值在響應(yīng)頭的content-length字段中:

function getHttpReqCallback(imgSrc, dirName, index) {
  var callback = function(res) {
    var contentLength = parseInt(res.headers['content-length']);
    //略。。
  }
}

end事件的回調(diào)中,用接收到的數(shù)據(jù)總長度和響應(yīng)頭中的包體長度進行比較,驗證響應(yīng)信息是否接收完全:

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){});
}

如果收到的響應(yīng)數(shù)據(jù)的長度比content-length中標(biāo)記的短,通常是由于請求超時造成的,在這里我重新發(fā)起了一次請求,你也可以根據(jù)你的實際情況采取其他的做法。

好了,異常處理部分的代碼就是這么多。

0x6 結(jié)束

完整的代碼見這里https://github.com/knightingal/SimpleDownloader

本人在Nodejs方面也是完全的新手,沒有太深入的研究Nodejs內(nèi)部的運行機制,只是網(wǎng)上讀過幾篇文章,用Nodejs寫過一些簡短的腳本,在這個過程中掉過一些坑,本文就是一次印象深刻的爬坑過程的整理和總結(jié)??偟膩碚f,Nodejs是一個非常強大且有趣的工具,但是由于其獨特的運行模型,以及javascript自身也有不少的歷史遺留問題需要解決,所以對于長期以來習(xí)慣了java, c/c++, python一類思維方式的猿們剛剛接觸它的時候產(chǎn)生不少疑惑,希望本文能幫助大家理解Nodejs中的一些不同于其他語言的和運行環(huán)境的地方。


附:錯誤的姿勢會導(dǎo)致什么后果

假如我們的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;
  }

它會把你下下來的圖片搞成這個樣子:

badimg.png

什么鬼。。。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容