《Node.js設計模式》基於回撥的非同步控制流

counterxing發表於2019-02-16

本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版連結

歡迎關注我的專欄,之後的博文將在專欄同步:

Asynchronous Control Flow Patterns with Callbacks

Node.js這類語言習慣於同步的程式設計風格,其CPS風格和非同步特性的API是其標準,對於新手來說可能難以理解。編寫非同步程式碼可能是一種不同的體驗,尤其是對非同步控制流而言。非同步程式碼可能讓我們難以預測在Node.js中執行語句的順序。例如讀取一組檔案,執行一串任務,或者等待一組操作完成,都需要開發人員採用新的方法和技術,以避免最終編寫出效率低下和不可維護的程式碼。一個常見的錯誤是回撥地獄,程式碼量急劇上升又不可讀,使得簡單的程式也難以閱讀和維護。在本章中,我們將看到如何通過使用一些規則和一些模式來避免回撥,並編寫乾淨、可管理的非同步程式碼。我們將看到控制流庫,如async,可以極大地簡化我們的問題,提升我們的程式碼可讀性,更易於維護。

非同步程式設計的困難

JavaScript中非同步程式碼的順序錯亂無疑是很容易的。閉包和對匿名函式的定義可以使開發人員有更好的程式設計體驗,而並不需要開發人員手動對非同步操作進行管理和跳轉。這是符合KISS原則的。簡單且能保持非同步程式碼控制流,讓它在更短的時間內工作。但不幸的是,回撥巢狀是以犧牲諸如模組性、可重用性和可維護性,增大整個函式的大小,導致糟糕的程式碼結構為代價的。大多數情況下,建立閉包在功能上是不需要的,但這更多是一種約束,而不是與非同步程式設計相關的問題。認識到回撥巢狀會使得我們的程式碼變得笨拙,然後根據最適合的解決方案採取相應的方法解決回撥地獄,這是新手與專家的區別。

建立一個簡單的Web爬蟲

為了解釋上述問題,我們建立了一個簡單的Web爬蟲,一個命令列應用,其接受一個URL為輸入,然後可以把其內容下載到一個檔案中。在下列程式碼中,我們會依賴以下兩個npm庫。

此外,我們還將引用一個叫做./utilities的本地模組。

我們的應用程式的核心功能包含在一個名為spider.js的模組中。如下所示,首先載入我們所需要的依賴包:

const request = require('request');
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const utilities = require('./utilities');複製程式碼

接下來,我們將建立一個名為spider()的新函式,該函式接受URL為引數,並在下載過程完成時呼叫一個回撥函式。

function spider(url, callback) {
  const filename = utilities.urlToFilename(url);
  fs.exists(filename, exists => {
    if (!exists) {
      console.log(`Downloading ${url}`);
      request(url, (err, response, body) => {
        if (err) {
          callback(err);
        } else {
          mkdirp(path.dirname(filename), err => {
            if (err) {
              callback(err);
            } else {
              fs.writeFile(filename, body, err => {
                if (err) {
                  callback(err);
                } else {
                  callback(null, filename, true);
                }
              });
            }
          });
        }
      });
    } else {
      callback(null, filename, false);
    }
  });
}複製程式碼

上述函式執行以下任務:

  • 檢查該URL的檔案是否已經下載過,即驗證相應檔案是否已經被建立:

fs.exists(filename, exists => ...

  • 如果檔案還沒有被下載,則執行下列程式碼進行下載操作:

request(url, (err, response, body) => ...

  • 然後,我們需要確定目錄下是否已經包含了該檔案:

mkdirp(path.dirname(filename), err => ...

  • 最後,我們把HTTP請求返回的報文主體寫入檔案系統:

mkdirp(path.dirname(filename), err => ...

要完成我們的Web爬蟲應用程式,只需提供一個URL作為輸入(在我們的例子中,我們從命令列引數中讀取它),我們只需呼叫spider()函式即可。

spider(process.argv[2], (err, filename, downloaded) => {
  if (err) {
    console.log(err);
  } else if (downloaded) {
    console.log(`Completed the download of "${filename}"`);
  } else {
    console.log(`"${filename}" was already downloaded`);
  }
});複製程式碼

現在,我們開始嘗試執行Web爬蟲應用程式,但是首先,確保已有utilities.js模組和package.json中的所有依賴包已經安裝到你的專案中:

npm install複製程式碼

之後,我們執行我們這個爬蟲模組來下載一個網頁,使用以下命令:

node spider http://www.example.com複製程式碼

我們的Web爬蟲應用程式要求在我們提供的URL中總是包含協議型別(例如,http://)。另外,不要期望HTML連結被重新編寫,也不要期望下載像圖片這樣的資源,因為這只是一個簡單的例子來演示非同步程式設計是如何工作的。

回撥地獄

看看我們的spider()函式,我們可以發現,儘管我們實現的演算法非常簡單,但是生成的程式碼有幾個級別的縮排,而且很難讀懂。使用阻塞式的同步API實現類似的功能是很簡單的,而且很少有機會讓它看起來如此錯誤。然而,使用非同步CPS是另一回事,使用閉包可能會導致出現難以閱讀的程式碼。

大量閉包和回撥將程式碼轉換成不可讀的、難以管理的情況稱為回撥地獄。它是Node.js中最受認可和最嚴重的反模式之一。一般來說,對於JavaScript而言。受此問題影響的程式碼的典型結構如下:

asyncFoo(err => {
  asyncBar(err => {
    asyncFooBar(err => {
      //...
    });
  });
});複製程式碼

我們可以看到,用這種方式編寫的程式碼是如何形成金字塔形狀的,由於深嵌的原因導致的難以閱讀,稱為“末日金字塔”。

像前面的程式碼片段這樣的程式碼最明顯的問題是可讀性差。由於巢狀太深,幾乎不可能跟蹤回撥函式的結束位置和另一個回撥函式開始的位置。

另一個問題是由每個作用域中使用的變數名的重疊引起的。通常,我們必須使用類似甚至相同的名稱來描述變數的內容。最好的例子是每個回撥接收到的錯誤引數。有些人經常嘗試使用相同名稱的變體來區分每個範圍內的物件,例如,errorerrerr1err2等等。另一些人則傾向於隱藏在範圍中定義的變數,總是使用相同的名稱。例如,err。這兩種選擇都遠非完美,而且會造成混淆,並增加導致bug的可能性。

此外,我們必須記住,雖然閉包在效能和記憶體消耗方面的代價很小。此外,它們還可以建立不易識別的記憶體洩漏,因為我們不應該忘記,由閉包引用的任何上下文變數都不會被垃圾收集所保留。

關於對於V8的閉包工作原理,可以參考Vyacheslav Egorov的部落格文章

如果我們看一下我們的spider()函式,我們會清楚地注意到它便是一個典型的回撥地獄的場景,並且在這個函式中有我們剛才描述的所有問題。這正是我們將在本章中學習的模式和技巧所要解決的問題。

使用簡單的JavaScript

既然我們已經遇到了第一個回撥地獄的例子,我們知道我們應該避免什麼。然而,在編寫非同步程式碼時,這並不是惟一的關注點。事實上,有幾種情況下,控制一組非同步任務的流需要使用特定的模式和技術,特別是如果我們只使用普通的JavaScript而沒有任何外部庫的幫助的情況下。例如,通過按順序應用非同步操作來遍歷集合並不像在陣列中呼叫forEach()那樣簡單,但實際上它需要一種類似於遞迴的技術。

在本節中,我們將學習如何避免回撥地獄,以及如何使用簡單的JavaScript實現一些最常見的控制流模式。

回撥函式的準則

在編寫非同步程式碼時,要記住的第一個規則是在定義回撥時不要濫用閉包。濫用閉包一時很爽,因為它不需要對諸如模組化和可重用性這樣的問題進行額外的思考。但是,我們已經看到,這種做法弊大於利。大多數情況下,修復回撥地獄問題並不需要任何庫、花哨的技術或正規化的改變,只是一些常識。

以下是一些基本原則,可以幫助我們更少的巢狀,並改進我們的程式碼的組織:

  • 儘可能退出外層函式。根據上下文,使用returncontinuebreak,以便立即退出當前程式碼塊,而不是使用if...else程式碼塊。其他語句。這將有助於優化我們的程式碼結構。
  • 為回撥建立命名函式,避免使用閉包,並將中間結果作為引數傳遞。命名函式也會使它們在堆疊跟蹤中更優雅。
  • 程式碼儘可能模組化。並儘可能將程式碼分成更小的、可重用的函式。

回撥呼叫的準則

為了展示上述原則,我們通過重構Web爬蟲應用程式來說明。

對於第一步,我們可以通過刪除else語句來重構我們的錯誤檢查方式。這是在我們收到錯誤後立即從函式中返回。因此,看以下程式碼:

if (err) {
  callback(err);
} else {
  // 如果沒有錯誤,執行該程式碼塊
}複製程式碼

我們可以通過編寫下面的程式碼來改進我們的程式碼結構:

if (err) {
  return callback(err);
}
// 如果沒有錯誤,執行該程式碼塊複製程式碼

有了這個簡單的技巧,我們立即減少了函式的巢狀級別,它很簡單,不需要任何複雜的重構。

在執行我們剛才描述的優化時,一個常見的錯誤是在呼叫回撥函式之後忘記終止函式,即return。對於錯誤處理場景,以下程式碼是bug的典型來源:

if (err) {
  callback(err);
}
// 如果沒有錯誤,執行該程式碼塊複製程式碼

在這個例子中,即使在呼叫回撥之後,函式的執行也會繼續。那麼避免這種情況的出現,return語句是十分必要的。還要注意,函式返回的輸出是什麼並不重要,實際結果(或錯誤)是非同步生成的,並傳遞給回撥。非同步函式的返回值通常被忽略。該屬性允許我們編寫如下的程式碼:

return callback(...);複製程式碼

否則我們必須拆成兩條語句來寫:

callback(...);
return;複製程式碼

接下來我們繼續重構我們的spider()函式,我們可以嘗試識別可複用的程式碼片段。例如,將給定字串寫入檔案的功能可以很容易地分解為一個單獨的函式:

function saveFile(filename, contents, callback) {
  mkdirp(path.dirname(filename), err => {
    if (err) {
      return callback(err);
    }
    fs.writeFile(filename, contents, callback);
  });
}複製程式碼

遵循同樣的原則,我們可以建立一個名為download()的通用函式,它將URL檔名作為輸入,並將URL的內容下載到給定的檔案中。在內部,我們可以使用前面建立的saveFile()函式。

function download(url, filename, callback) {
  console.log(`Downloading ${url}`);
  request(url, (err, response, body) => {
    if (err) {
      return callback(err);
    }
    saveFile(filename, body, err => {
      if (err) {
        return callback(err);
      }
      console.log(`Downloaded and saved: ${url}`);
      callback(null, body);
    });
  });
}複製程式碼

最後,修改我們的spider()函式:

function spider(url, callback) {
  const filename = utilities.urlToFilename(url);
  fs.exists(filename, exists => {
    if (exists) {
      return callback(null, filename, false);
    }
    download(url, filename, err => {
      if (err) {
        return callback(err);
      }
      callback(null, filename, true);
    })
  });
}複製程式碼

spider()函式的功能和介面仍然是完全相同的,改變的僅僅是程式碼的組織方式。通過應用上述基本原則,我們能夠極大地減少程式碼的巢狀,同時增加了它的可重用性和可測試性。實際上,我們可以考慮匯出saveFile()download(),這樣我們就可以在其他模組中重用它們。這也使我們能夠更容易地測試他們的功能。

我們在這一節中進行的重構清楚地表明,大多數時候,我們所需要的只是一些規則,並確保我們不濫用閉包和匿名函式。它的工作非常出色,只需最少的工作量,並且只使用原始的JavaScript

順序執行

現在開始探尋非同步控制流的執行順序,我們會通過開始分析一串非同步程式碼來探尋其控制流。

按順序執行一組任務意味著一次一個接一個地執行它們。執行順序很重要,必須保證其正確性,因為列表中一個任務的結果可能會影響下一個任務的執行。下圖說明了這個概念:

上述非同步控制流有一些不同的變化:

  • 按順序執行一組已知任務,無需連結或傳遞執行結果
  • 使用任務的輸出作為下一個輸入(也稱為chainpipeline,或者waterfall
  • 在每個元素上執行非同步任務時迭代一個集合,一個元素接一個元素

對於順序執行而言,儘管在使用直接樣式阻塞API實現很簡單,但通常情況下使用非同步CPS時會導致回撥地獄問題。

按順序執行一組已知的任務

在上一節中實現spider()函式時,我們已經遇到了順序執行的問題。通過研究如下方式,我們可以更好地控制非同步程式碼。以該程式碼為準則,我們可以用以下模式來解決上述問題:

function task1(callback) {
  asyncOperation(() => {
    task2(callback);
  });
}

function task2(callback) {
  asyncOperation(result() => {
    task3(callback);
  });
}

function task3(callback) {
  asyncOperation(() => {
    callback(); //finally executes the callback
  });
}

task1(() => {
  //executed when task1, task2 and task3 are completed
  console.log('tasks 1, 2 and 3 executed');
});複製程式碼

上述模式顯示了在完成一個非同步操作後,再呼叫下一個非同步操作。該模式強調任務的模組化,並且避免在處理非同步程式碼使用閉包。

順序迭代

我們前面描述的模式如果我們預先知道要執行什麼和有多少個任務,這些模式是完美的。這使我們能夠對序列中下一個任務的呼叫進行硬編碼,但是如果要對集合中的每個專案執行非同步操作,會發生什麼?在這種情況下,我們不能對任務序列進行硬編碼。相反的是,我們必須動態構建它。

Web爬蟲版本2

為了顯示順序迭代的例子,讓我們為Web爬蟲應用程式引入一個新功能。我們現在想要遞迴地下載網頁中的所有連結。要做到這一點,我們將從頁面中提取所有連結,然後按順序逐個地觸發我們的Web爬蟲應用程式。

第一步是修改我們的spider()函式,以便通過呼叫一個名為spiderLinks()的函式觸發頁面所有連結的遞迴下載。

此外,我們現在嘗試讀取檔案,而不是檢查檔案是否已經存在,並開始爬取其連結。這樣,我們就可以恢復中斷的下載。最後還有一個變化是,我們確保我們傳遞的引數是最新的,還要限制遞迴深度。結果程式碼如下:

function spider(url, nesting, callback) {
  const filename = utilities.urlToFilename(url);
  fs.readFile(filename, 'utf8', (err, body) => {
    if (err) {
      if (err.code! == 'ENOENT') {
        return callback(err);
      }
      return download(url, filename, (err, body) => {
        if (err) {
          return callback(err);
        }
        spiderLinks(url, body, nesting, callback);
      });
    }
    spiderLinks(url, body, nesting, callback);
  });
}複製程式碼
爬取連結

現在我們可以建立這個新版本的Web爬蟲應用程式的核心,即spiderLinks()函式,它使用順序非同步迭代演算法下載HTML頁面的所有連結。注意我們在下面的程式碼塊中定義的方式:

function spiderLinks(currentUrl, body, nesting, callback) {
  if(nesting === 0) {
    return process.nextTick(callback);
  }

  let links = utilities.getPageLinks(currentUrl, body); //[1]
  function iterate(index) { //[2]
    if(index === links.length) {
      return callback();
    }

    spider(links[index], nesting - 1, function(err) { //[3]
      if(err) {
        return callback(err);
      }
      iterate(index + 1);
    });
  }
  iterate(0); //[4]
}複製程式碼

從這個新功能中的重要步驟如下:

  1. 我們使用utilities.getPageLinks()函式獲取頁面中包含的所有連結的列表。此函式僅返回指向相同主機名的連結。
  2. 我們使用一個稱為iterate()的本地函式來遍歷連結,該函式需要下一個連結的索引進行分析。在這個函式中,我們首先要檢查索引是否等於連結陣列的長度,如果等於則是迭代完成,在這種情況下我們立即呼叫callback()函式,因為這意味著我們處理了所有的專案。
  3. 這時,處理連結已準備就緒。我們通過遞迴呼叫spider()函式。
  4. 作為spiderLinks()函式的最後一步也是最重要的一步,我們通過呼叫iterate(0)來開始迭代。

我們剛剛提出的演算法允許我們通過順序執行非同步操作來迭代陣列,在我們的例子中是spider()函式。

我們現在可以嘗試這個新版本的Web爬蟲應用程式,並觀看它一個接一個地遞迴地下載網頁的所有連結。要中斷這個過程,如果有很多連結可能需要一段時間,請記住我們可以隨時使用Ctrl + C。如果我們決定恢復它,我們可以通過啟動Web爬蟲應用程式並提供與上次結束時相同的URL來恢復執行。

現在我們的網路Web爬蟲應用程式可能會觸發整個網站的下載,請仔細考慮使用它。例如,不要設定高巢狀級別或離開爬蟲執行超過幾秒鐘。用數千個請求過載伺服器是不道德的。在某些情況下,這也被認為是非法的。需要考慮後果!

迭代模式

我們之前展示的spiderLinks()函式的程式碼是一個清楚的例子,說明了如何在應用非同步操作時迭代集合。我們還可以注意到,這是一種可以適應任何其他情況的模式,我們需要在集合的元素或通常的任務列表上按順序非同步迭代。該模式可以推廣如下:

function iterate(index) {
  if (index === tasks.length) {
    return finish();
  }
  const task = tasks[index];
  task(function() {
    iterate(index + 1);
  });
}

function finish() {
  // 迭代完成的操作
}

iterate(0);複製程式碼

注意到,如果task()是同步操作,這些型別的演算法變得真正遞迴。在這種情況下,可能造成呼叫棧的溢位。

我們剛剛提出的模式是非常強大的,因為它可以適應幾種情況。例如,我們可以對映陣列的值,或者我們可以將迭代的結果傳遞給迭代中的下一個,以實現一個reduce演算法,如果滿足特定的條件,我們可以提前退出迴圈,或者甚至可以迭代無限數量的元素。

我們還可以選擇將解決方案進一步推廣:

iterateSeries(collection, iteratorCallback, finalCallback);複製程式碼

通過建立一個名為iterator的函式來執行任務列表,該函式呼叫集合中的下一個可執行的任務,並確保在當前任務完成時呼叫迭代器結束的回撥函式。

並行

在某些情況下,一組非同步任務的執行順序並不重要,我們只需要在所有這些執行的任務完成時通知我們。使用並行執行流更好地處理這種情況,如下圖所示:

如果我們認為Node.js是單執行緒的話,這可能聽起來很奇怪,但是如果我們記住我們在第一章中討論過的內容,我們意識到即使我們只有一個執行緒,我們仍然可以實現併發,由於Node.js的非阻塞性質。實際上,在這種情況下,並行字不正確地使用,因為這並不意味著任務同時執行,而是它們的執行由底層的非阻塞API執行,並由事件迴圈進行交織。

我們知道,當一個任務允許事件迴圈執行另一個任務時,或者是說一個任務允許控制回到事件迴圈。這種工作流的名稱為併發,但為了簡單起見,我們仍然會使用並行。

下圖顯示了兩個非同步任務可以在Node.js程式中並行執行:

通過上圖,我們有一個Main函式執行兩個非同步任務:

  1. Main函式觸發Task 1Task 2的執行。由於這些觸發非同步操作,這兩個函式會立即返回,並將控制權返還給主函式,之後等到事件迴圈完成再通知主執行緒。
  2. Task 1的非同步操作完成時,事件迴圈給與其執行緒控制權。當Task 1同步操作完成時,它通知Main函式。
  3. Task 2的非同步操作完成時,事件迴圈給與其執行緒控制權。當Task 2同步操作完成時,它再次通知Main函式。在這一點上,Main函式知曉Task 1Task 2都已經執行完畢,所以它可以繼續執行其後操作或將操作的結果返回給另一個回撥函式。

簡而言之,這意味著在Node.js中,我們只能執行並行非同步操作,因為它們的併發性由非阻塞API在內部處理。在Node.js中,同步阻塞操作不能同時執行,除非它們的執行與非同步操作交錯,或者通過setTimeout()setImmediate()延遲。我們將在第九章中更詳細地看到這一點。

Web爬蟲版本3

上邊的Web爬蟲在並行非同步操作上似乎也算表現得很完美。到目前為止,應用程式正在遞迴地執行連結頁面的下載。但效能不是最佳的,想要提升這個應用的效能很容易。

要做到這一點,我們只需要修改spiderLinks()函式,確保spider()任務只執行一次,當所有任務都執行完畢後,呼叫最後的回撥,所以我們對spiderLinks()做如下修改:

function spiderLinks(currentUrl, body, nesting, callback) {
  if (nesting === 0) {
    return process.nextTick(callback);
  }
  const links = utilities.getPageLinks(currentUrl, body);
  if (links.length === 0) {
    return process.nextTick(callback);
  }
  let completed = 0,
    hasErrors = false;

  function done(err) {
    if (err) {
      hasErrors = true;
      return callback(err);
    }
    if (++completed === links.length && !hasErrors) {
      return callback();
    }
  }
  links.forEach(link => {
    spider(link, nesting - 1, done);
  });
}複製程式碼

上述程式碼有何變化?,現在spider()函式的任務全部同步啟動。可以通過簡單地遍歷連結陣列和啟動每個任務,我們不必等待前一個任務完成再進行下一個任務:

links.forEach(link => {
  spider(link, nesting - 1, done);
});複製程式碼

然後,使我們的應用程式知曉所有任務完成的方法是為spider()函式提供一個特殊的回撥函式,我們稱之為done()。當爬蟲任務完成時,done()函式設定一個計數器。當完成的下載次數達到連結陣列的大小時,呼叫最終回撥:

function done(err) {
  if (err) {
    hasErrors = true;
    return callback(err);
  }
  if (++completed === links.length && !hasErrors) {
    callback();
  }
}複製程式碼

通過上述變化,如果我們現在試圖對網頁執行我們的爬蟲,我們將注意到整個過程的速度有很大的改進,因為每次下載都是並行執行的,而不必等待之前的連結被處理。

模式

此外,對於並行執行流程,我們可以提取我們方案,以便適應於不同的情況提高程式碼的可複用性。我們可以使用以下程式碼來表示模式的通用版本:

const tasks = [ /* ... */ ];
let completed = 0;
tasks.forEach(task => {
  task(() => {
    if (++completed === tasks.length) {
      finish();
    }
  });
});

function finish() {
  // 所有任務執行完成後呼叫
}複製程式碼

通過小的修改,我們可以調整模式,將每個任務的結果累積到一個list中,以便過濾或對映陣列的元素,或者一旦完成了一個或一定數量的任務即可呼叫finish()回撥。

注意:如果是沒有限制的情況下,並行執行的一組非同步任務,然後等待所有非同步任務完成後執行回撥這種方式,其方法是計算它們的執行完成的數目。

用併發任務修復競爭條件

當使用阻塞I/O與多執行緒組合的方式時,並行執行一組任務可能會導致一些問題。但是,我們剛剛看到,在Node.js中卻不一樣,並行執行多個非同步任務實際上在資源方面消耗較低。這是Node.js最重要的優點之一,因此在Node.js中並行化成為一種常見的做法,而且這並是多麼複雜的技術。

Node.js的併發模型的另一個重要特徵是我們處理任務同步和競爭條件的方式。在多執行緒程式設計中,這通常使用諸如鎖,互斥條件,訊號量和觀察器之類的構造來實現,這些是多執行緒語言並行化的最複雜的方面之一,對效能也有很大的影響。在Node.js中,我們通常不需要一個花哨的同步機制,因為所有執行在單個執行緒上!但是,這並不意味著我們沒有競爭條件。相反,他們可以相當普遍。問題的根源在於非同步操作的呼叫與其結果通知之間的延遲。舉一個具體的例子,我們可以再次參考我們的Web爬蟲應用程式,特別是我們建立的最後一個版本,其實際上包含一個競爭條件。

問題在於在開始下載相應的URL的文件之前,檢查檔案是否已經存在的spider()函式:

function spider(url, nesting, callback) {
  if(spidering.has(url)) {
    return process.nextTick(callback);
  }
  spidering.set(url, true);

  const filename = utilities.urlToFilename(url);
  fs.readFile(filename, 'utf8', function(err, body) {
    if(err) {
      if(err.code !== 'ENOENT') {
        return callback(err);
      }

      return download(url, filename, function(err, body) {
        if(err) {
          return callback(err);
        }
        spiderLinks(url, body, nesting, callback);
      });
    }

    spiderLinks(url, body, nesting, callback);
  });
}複製程式碼

現在的問題是,在同一個URL上操作的兩個爬蟲任務可能會在兩個任務之一完成下載並建立一個檔案,導致第二個任務開始下載之前,在同一個檔案上呼叫fs.readFile()的結果不對,致使下載兩次。這種情況如下圖所示:

上圖顯示了Task 1Task 2如何在Node.js的單個執行緒中交錯執行,以及非同步操作如何實際引入競爭條件。在我們的情況下,兩個爬蟲任務最終會下載相同的檔案。
我們如何解決這個問題?答案比我們想象的要簡單得多。實際上,我們所需要的只是一個變數(互斥變數),可以相互排除執行在同一個URL上的多個spider()任務。這可以通過以下程式碼來實現:

const spidering = new Map();

function spider(url, nesting, callback) {
  if (spidering.has(url)) {
    return process.nextTick(callback);
  }
  spidering.set(url, true);
  // ...
}複製程式碼

並行執行頻率限制

通常,如果不控制並行任務頻率,並行任務就會導致過載。想象一下,有數千個檔案要讀取,訪問的URL或資料庫查詢並行執行。在這種情況下,常見的問題是系統資源不足,例如,當嘗試一次開啟太多檔案時,利用可用於應用程式的所有檔案描述符。在Web應用程式中,它還可能會建立一個利用拒絕服務(DoS)攻擊的漏洞。在所有這種情況下,最好限制同時執行的任務數量。這樣,我們可以為伺服器的負載增加一些可預測性,並確保我們的應用程式不會耗盡資源。下圖描述了一個情況,我們將五個任務並行執行併發限制為兩段:

從上圖可以清楚我們的演算法如何工作:

  1. 我們可以執行儘可能多的任務,而不超過併發限制。
  2. 每當任務完成時,我們再執行一個或多個任務,同時確保任務數量達不到限制。

併發限制

我們現在提出一種模式,以有限的併發性並行執行一組給定的任務:

const tasks = ...
let concurrency = 2, running = 0, completed = 0, index = 0;

function next() {
  while (running < concurrency && index < tasks.length) {
    task = tasks[index++];
    task(() => {
      if (completed === tasks.length) {
        return finish();
      }
      completed++, running--;
      next();
    });
    running++;
  }
}
next();

function finish() {
  // 所有任務執行完成
}複製程式碼

該演算法可以被認為是順序執行和並行執行之間的混合。事實上,我們可能會注意到我們之前介紹的兩種模式的相似之處:

  1. 我們有一個迭代器函式,我們稱之為next(),有一個內部迴圈,並行執行儘可能多的任務,同時保持併發限制。
  2. 我們傳遞給每個任務的回撥檢查是否完成了列表中的所有任務。如果還有任務要執行,它會呼叫next()來執行下一個任務。

全域性併發限制

我們的Web爬蟲應用程式非常適合應用我們所學到的限制一組任務的併發性。事實上,為了避免同時爬上數千個連結的情況,我們可以通過在併發下載數量上增加一些措施來限制併發量。

0.11之前的Node.js版本已經將每個主機的併發HTTP連線數限制為5.然而,這可以改變以適應我們的需要。請檢視官方文件nodejs.org/docs/v0.10.… axsockets中的更多內容。從Node.js 0.11開始,併發連線數沒有預設限制。

我們可以將我們剛剛學到的模式應用到我們的spiderLinks()函式,但是我們將獲得的只是限制一個頁面中的一組連結的併發性。如果我們選擇了併發量為2,我們最多可以為每個頁面並行下載兩個連結。然而,由於我們可以一次下載多個連結,因此每個頁面都會產生另外兩個下載,這樣遞迴下去,其實也沒有完全做到併發量的限制。

使用佇列

我們真正想要的是限制我們可以並行執行的全域性下載運算元量。我們可以略微修改之前展示的模式,但是我們寧願把它作為一個練習,因為我們想借此機會引入另一個機制,它利用佇列來限制多個任務的併發性。讓我們看看這是如何工作的。

我們現在要實現一個名為TaskQueue類,它將佇列與我們之前提到的演算法相結合。我們建立一個名為taskQueue.js的新模組:

class TaskQueue {
  constructor(concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }
  pushTask(task) {
    this.queue.push(task);
    this.next();
  }
  next() {
    while (this.running < this.concurrency && this.queue.length) {
      const task = this.queue.shift();
      task(() => {
        this.running--;
        this.next();
      });
      this.running++;
    }
  }
};複製程式碼

上述類的建構函式只作為輸入的併發限制,但除此之外,它初始化執行和佇列的變數。前一個變數是用於跟蹤所有正在執行的任務的計數器,而後者是將用作佇列以儲存待處理任務的陣列。

pushTask()方法簡單地將新任務新增到佇列中,然後通過呼叫this.next()來引導任務的執行。

next()方法從佇列中生成一組任務,確保它不超過併發限制。

我們可能會注意到,這種方法與限制我們前面提到的併發性的模式有一些相似之處。它基本上從佇列開始儘可能多的任務,而不超過併發限制。當每個任務完成時,它會更新執行任務的計數,然後再次呼叫next()來啟動另一輪任務。 TaskQueue類的有趣屬性是它允許我們動態地將新的專案新增到佇列中。另一個優點是,現在我們有一箇中央實體負責限制我們任務的併發性,這可以在函式執行的所有例項中共享。在我們的例子中,它是spider()函式,我們將在稍後看到。

Web爬蟲版本4

現在我們有一個通用的佇列來執行有限的並行流程中的任務,我們可以在我們的Web爬蟲應用程式中直接使用它。我們首先載入新的依賴關係並通過將併發限制設定為2來建立TaskQueue類的新例項:

const TaskQueue = require('./taskQueue');
const downloadQueue = new TaskQueue(2);複製程式碼

接下來,我們使用新建立的downloadQueue更新spiderLinks()函式:

function spiderLinks(currentUrl, body, nesting, callback) {
  if (nesting === 0) {
    return process.nextTick(callback);
  }
  const links = utilities.getPageLinks(currentUrl, body);
  if (links.length === 0) {
    return process.nextTick(callback);
  }
  let completed = 0,
    hasErrors = false;
  links.forEach(link => {
    downloadQueue.pushTask(done => {
      spider(link, nesting - 1, err => {
        if (err) {
          hasErrors = true;
          return callback(err);
        }
        if (++completed === links.length && !hasErrors) {
          callback();
        }
        done();
      });
    });
  });
}複製程式碼

這個函式的這種新的實現是非常容易的,它與這本章前面提到的無限並行執行的演算法非常相似。這是因為我們將併發控制委託給TaskQueue物件,我們唯一要做的就是檢查所有任務是否完成。看上述程式碼中如何定義我們的任務:

  • 我們通過提供自定義回撥來執行spider()函式。
  • 在回撥中,我們檢查與spiderLinks()函式執行相關的所有任務是否完成。當這個條件為真時,我們呼叫spiderLinks()函式的最後回撥。
  • 在我們的任務結束時,我們呼叫了done()回撥,以便佇列可以繼續執行。

在我們進行這些小的變化之後,我們現在可以嘗試再次執行Web爬蟲應用程式。這一次,我們應該注意到,同時不會有兩個以上的下載。

async庫

如果我們到目前為止我們分析的每一個控制流程模式看一下,我們可以看到它們可以用作構建可重用和更通用的解決方案的基礎。例如,我們可以將無限制的並行執行演算法包裝到一個接受任務列表的函式中,並行執行它們,並且當它們都完成時呼叫給定的回撥函式。將控制流演算法轉化為可重用功能的這種方式可以導致更具宣告性和表達性的方式來定義非同步控制流,這正是async所做的。async庫是一個非常流行的解決方案,在Node.jsJavaScript中來說,用於處理非同步程式碼。它提供了一組功能,可以大大簡化不同配置中一組任務的執行,併為非同步處理集合提供了有用的幫助。即使有其他幾個具有相似目標的庫,由於它的受歡迎程度,因此asyncNode.js中的一個事實上的標準。

順序執行

async庫可以在實現複雜的非同步控制流程時大大幫助我們,但是一個難題就是選擇正確的庫來解決問題。例如,對於順序執行,有大約20個不同的函式可供選擇,包括eachSeries(), mapSeries(), filterSeries(), rejectSeries(), reduce(), reduceRight(), detectSeries(), concatSeries(), series(), whilst(), doWhilst(), until(), doUntil(), forever(), waterfall(), compose(), seq(), applyEachSeries(), iterator(), 和timesSeries()

選擇正確的函式是編寫更穩固和可讀的程式碼的重要一步,但這也需要一些經驗和實踐。在我們的例子中,我們將僅介紹其中的一些情況,但它們仍將為理解和有效地使用庫的其餘部分提供堅實的基礎。

下面,通過例子說明async庫如何工作,我們將用於我們的Web爬蟲應用程式。我們直接從版本2開始,按順序遞迴地下載所有的連結。

但是,首先我們確保將async庫安裝到我們當前的專案中:

npm install async複製程式碼

然後我們需要從spider.js模組載入新的依賴項:

const async = require('async');複製程式碼

已知一組任務的順序執行

我們先修改download()函式。如下所示,它依次做了以下三件事:

  1. 下載URL的內容。
  2. 建立一個新目錄(如果尚不存在)。
  3. URL的內容儲存到檔案中。

async.series()可以實現順序執行一組任務:

async.series(tasks, [callback])複製程式碼

async.series()接受一個任務列表和一個在所有任務完成後呼叫的回撥函式作為引數。每個任務只是一個接受回撥函式的函式,當任務完成執行時,這個回撥函式被呼叫:

function task(callback) {}複製程式碼

async的優勢是它使用與Node.js相同的回撥約定,它會自動處理錯誤傳播。所以,如果任何一個任務呼叫它的回撥並且產生了一個錯誤,async將跳過列表中剩餘的任務,直接跳轉到最後的回撥。

考慮到這一點,讓我們看看如何通過使用async來修改上述的download()函式:

function download(url, filename, callback) {
  console.log(`Downloading ${url}`);
  let body;
  async.series([
    callback => {
      request(url, (err, response, resBody) => {
        if (err) {
          return callback(err);
        }
        body = resBody;
        callback();
      });
    },
    mkdirp.bind(null, path.dirname(filename)),
    callback => {
      fs.writeFile(filename, body, callback);
    }
  ], err => {
    if (err) {
      return callback(err);
    }
    console.log(`Downloaded and saved: ${url}`);
    callback(null, body);
  });
}複製程式碼

對比起這段程式碼的回撥地獄版本,使用async方式使我們能夠更好地組織我們的非同步任務。並且不會巢狀回撥,因為我們只需要提供一個的任務列表,通常對於用於每個非同步操作,然後非同步任務將依次執行:

  1. 首先是下載URL的內容。我們將響應體儲存到一個閉包變數(body)中,以便它可以與其他任務共享。
  2. 建立並儲存下載的頁面的目錄。我們通過執行mkdirp()函式實現,並和建立的目錄路徑繫結。這樣,我們可以節省幾行程式碼並增加其可讀性。
  3. 最後,我們將下載的URL的內容寫入檔案。在這種情況下,我們無法執行部分應用程式(就像我們在第二個任務中所做的那樣),因為變數body只在系列中的下載任務完成後才可用。但是,通過將任務的回撥直接傳遞到fs.writeFile()函式,我們仍然可以通過利用非同步的自動錯誤管理來儲存一些程式碼行。
    4.完成所有任務後,將呼叫async.series()的最後回撥。在我們的例子中,我們只是做一些錯誤管理,然後返回body變數來回撥download()函式。

對於上述情況,async.series()的一個可替代的方法是async.waterfall(),它仍然按順序執行任務,但另外還提供每個任務的輸出作為下一個輸入。在我們的情況下,我們可以使用這個特徵來傳播body變數直到序列結束。

順序迭代

在前面講了如何按順序執行一組任務。上面的例子async.series()來做到這一點。可以使用相同的功能來實現Web爬蟲版本2spiderLinks()函式。然而,async為特定的情況提供了一個更合適的API,遍歷一個集合,這個APIasync.eachSeries()。我們來使用它來重新實現我們的spiderLinks()函式(版本2,序列下載),如下所示:

function spiderLinks(currentUrl, body, nesting, callback) {
  if (nesting === 0) {
    return process.nextTick(callback);
  }
  const links = utilities.getPageLinks(currentUrl, body);
  if (links.length === 0) {
    return process.nextTick(callback);
  }
  async.eachSeries(links, (link, callback) => {
    spider(link, nesting - 1, callback);
  }, callback);
}複製程式碼

如果我們將使用async的上述程式碼與使用純JavaScript模式實現的相同功能的程式碼進行比較,我們將注意到async在程式碼組織和可讀性方面給我們帶來的巨大優勢。

並行執行

async不具有處理並行流的功能,其中可以找到each()map()filter()reject()detect()some()every()concat()parallel()applyEach()times()。它們遵循與我們已經看到的用於順序執行的功能相同的邏輯,區別在於所提供的任務是並行執行的。

為了證明這一點,我們可以嘗試應用上述功能之一來實現我們的Web爬蟲應用程式的第三版,即使用無限制的並行流程來執行下載。

如果我們記住我們之前使用的程式碼來實現spiderLinks()函式的順序版本,那麼調整它使其並行工作就比較簡單:

function spiderLinks(currentUrl, body, nesting, callback) {
  // ...
  async.each(links, (link, callback) => {
    spider(link, nesting - 1, callback);
  }, callback);
}複製程式碼

這個函式與我們用於順序下載的功能完全相同,但是使用的是async.each()而非async.eachSeries()。這清楚地表明瞭使用庫(例如async)抽象非同步流的功能。程式碼不再繫結到特定的執行流程了,沒有專門為此寫的程式碼。大多數只是應用邏輯。

限制並行執行

如果你想知道async還可以用來限制並行任務的併發性,答案是肯定的。我們有一些我們可以使用的函式,即eachLimit()mapLimit()parallelLimit()queue()cargo()

我們試圖利用其中的一個來實現Web爬蟲應用程式的第4版,以有限的併發性並行執行連結的下載。幸運的是,asyncasync.queue(),它的工作方式與本章前面建立的TaskQueue類似。 async.queue()函式建立一個新的佇列,它使用一個worker()函式來執行一組具有指定併發限制的任務:

const q = async.queue(worker, concurrency);複製程式碼

worker()函式作為輸入接收要執行的任務和一個回撥函式作為引數,當任務完成時執行回撥:

function worker(task, callback);複製程式碼

我們應該注意到在這個例子中 task 可以是任何型別,而不僅僅只能是函式。實際上, worker有責任以最適當的方式處理任務。新建任務,可以通過q.push(task, callback)將任務新增到佇列中。一個任務處理完後,關聯一個任務的回撥函式必須被worker呼叫。

現在,我們再次修改我們的程式碼實現一個全面並行的有併發限制的執行流,利用async.queue(),首先,我們需要建立一個佇列:

const downloadQueue = async.queue((taskData, callback) => {
  spider(taskData.link, taskData.nesting - 1, callback);
}, 2);複製程式碼

程式碼很簡單。我們正在建立一個併發限制為2的新佇列,讓一個工作人員只需使用與任務關聯的資料呼叫我們的spider()函式。接下來,我們實現spiderLinks()函式:

function spiderLinks(currentUrl, body, nesting, callback) {
  if (nesting === 0) {
    return process.nextTick(callback);
  }
  const links = utilities.getPageLinks(currentUrl, body);
  if (links.length === 0) {
    return process.nextTick(callback);
  }
  const completed = 0,
    hasErrors = false;
  links.forEach(function(link) {
    const taskData = {
      link: link,
      nesting: nesting
    };
    downloadQueue.push(taskData, err => {
      if (err) {
        hasErrors = true;
        return callback(err);
      }
      if (++completed === links.length && !hasErrors) {
        callback();
      }
    });
  });
}複製程式碼

前面的程式碼應該看起來非常熟悉,因為它幾乎和使用TaskQueue物件來實現相同流程的程式碼相同。此外,在這種情況下,要分析的重要部分是將新任務推入佇列的位置。在這一點上,我們確保我們傳遞一個回撥,使我們能夠檢查當前頁面的所有下載任務是否完成,並最終呼叫最終回撥。

辛虧有async.queue(),我們可以輕鬆地複製我們的TaskQueue物件的功能,再次證明了通過async,我們可以避免從頭開始編寫非同步控制流模式,減少我們的工作量,程式碼量更加簡潔。

總結

在本章開始的時候,我們說Node.js的程式設計可能很難因為它的非同步性,特別是對於以前在其他平臺上開發的人而言。然而,在本章中,我們展示了非同步API如何可以從簡單原生JavaScript開始,從而為我們分析更復雜的技術奠定了基礎。然後我們看到,除了為每一種口味提供程式設計風格,我們所掌握的工具確實是多樣化的,併為我們大部分的問題提供了很好的解決方案。例如,我們可以選擇async庫來簡化最常見的流程。

還有更為先進的技術,如PromiseGenerator函式,這將是下一章的重點。當了解所有這些技術時,能夠根據需求選擇最佳解決方案,或者在同一個專案中使用多種技術。

相關文章