node设计模式 - 异步控制流模式之回调函数

异步控制流模式之回调函数

以下内容是对《Node.js设计模式》第三章的理解,例子都是采用的书上的内容。未将书中整章内容摘取出来,只提取出部分内容。全部内容可查看 (《Node.js设计模式》基于回调的异步控制流)

异步编程的困难

JavaScript中,我们可以通过闭包和匿名函数的定义为我们提供平滑的异步变成体验,但牺牲 的是质量,如模块化、可重用性和可维护性,很有可能会出现嵌套失控、函数体积的增长并会导致糟糕的代码结构。

首先从一个例子开始理解异步编程的困难:

我们首先创建一个简单的Web爬虫,其中

  • request:一个简化HTTP调用的库,
  • mkdirp:一个用户递归创建目录的小实用程序
  • utilities:本地的一个模块,它其中包含了一些我们要使用的应用程序

我们会将后续涉及到的函数进行解释,所以无需担心不懂其作用。

const request = require('request');
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const utilities = require('./utilities');

我们继续创建一个名为spider的新函数,它接受一个要下载的URL和一个在下载完成时调用的回调函数:在该函数中,我们首先将传入的url转换为一个唯一的文件名,,然后去查找该文件名对应的文件是否存在,如果不存在,则去下载该url对应的文件,否则代表该文件已被下载,直接执行传入的回调函数。如果文件需要下载,当文件下载完毕后确定包含该文件的目录是否存在,如果不存在则递归创建。此时包含该文件的目录已经存在,便可以将内容写入到对应文件中,当写入成功后调用回调函数。

function spider(url, callback) {
  const filename = utilities.urlToFilename(url);//获取到url对应的文件名
  fs.exists(filename, exists => {
    if (!exists) {
      console.log(`Downloading ${url}`);
      request(url, (err, response, body) => { //请求下载url对应的文件
        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);
    }
  });
}

执行命令后:
在这里插入图片描述

回调地狱

从上述我们展示的例子中可以看出,实现的算法时非常简单的,但它的一个比较明显的缺点是嵌套层次多,难以阅读。使用直接风格地阻塞API实现类似地功能则更为直截了当,也就很少出现这种深层次嵌套且难以阅读地糟糕情况,但当使用异步CPS时,错误地使用闭包可能会导致及其糟糕的代码。
大量的闭包和就地定义的回调函数使代码变得不可读并难以控制的情况称为回调地狱,它是Node.jsJavaScript中被公认的反面模式之一,其结构为:

asyncFoo(err => {
  asyncBar(err => {
    asyncFooBar(err => {
      //...
    });
  });
});

这种模式最为明显的问题是可读性差。由于嵌套太深,几乎不可能跟踪某个功能在哪里结束,或某个功能从哪里开始。另一个问题是由每个作用域使用的变量名重叠引起的。通常,我们必须使用相似甚至相同的名称来描述变量的内容。例如,对于每个回调种接收到的错误参数,有些人会使用不同的名称对其进行命名,有些人则选择相同的名称来隐藏作用域中定义的变量。在不同的情况下,这两种方案都不完美,容易造成混乱,增加引发缺陷的可能性。最后,虽然闭包在性能和内存消耗方面,代价是很小的,但是它们可能造成不容易识别的内存泄漏:由闭包引用的任何上下文变量都会在垃圾回收时保留。

我们即将接触的内容便是为了解决上述spider()函数种的回调地狱的情况。我们会通过JavaScriptasync库两种方式来解决回调地狱**的问题。

使用纯JavaScript

我们在前面介绍回调地狱的例子时,知道在写异步代码时要去避免该问题,但实际上不止要去注意这一点。在一些情况下,控制一组异步任务的流程需要使用特定的模式和技术。顺序应用异步操作来迭代集合实际上需要一种类似于递归的技术。我们即将要学习如何使用简单的纯javaScript来实现一些最常见的控制流模式

回调规则

下面列出一些编写异步代码时所需要记住的规则,这样便可在很大程度上修复回调地狱问题。

  • 在定义回调时不要滥用闭包
  • 必须尽快退出。根据上下文,使用returncontinuebreak,立即退出当前语句,而不是编写(和嵌套)完成if...else语句。这将有助于保持更浅的代码层级
  • 需要为回调创建命名函数,将它们保持在闭包之外,并将中间结果作为参数传递。命名函数也会使它们在堆栈跟踪种更优雅。
  • 代码尽可能模块化。只要有可能,将代码拆分为更小的、可重用的函数

应用回调规则

我们用上述回调规则来修复Web爬虫应用中回调地狱的问题

//将创建目录以及写入文件部分提取出来
function saveFile(filename, contents, callback) {
  mkdirp(path.dirname(filename), err => {
    if(err) {
      return callback(err);
    }
    fs.writeFile(filename, contents, callback);
  });
}

//将下载文件部分提取出来
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部分,首先将原始的if(exists) {...} else {...} 部分变换成if(exists) { return ... } ...,这样在收到错误后立即从函数种返回,这样能够减少函数的嵌套级别。其次将可重用的代码段提取出来:将下载文件的部分提取出来以及将内容写入到文件的部分提取出来,这样不止能够将不同函数公用,也能够减少其嵌套级别,增加了可重用性和可测试性。

上述内容只是使用了基本的回调规则而改善了原始回调地狱的情况,下面我们要开始探索异步控制流模式。

顺序执行

从分析顺序执行流开始
按顺序执行一组任务意味着一次运行一个任务,一个接一个地运行。执行顺序很重要,必须保证其执行顺序的正确性,因为列表中任务运行的结果可能影响下一个任务的执行。我们以一张图说明一下该概念:
在这里插入图片描述

从上图可以看出:

  • 按顺序执行一组已知任务,而没有链接或传播结果
  • 一个任务的输出作为下一个任务的输入
  • 在每个元素上运行异步任务时迭代一个集合,一个元素接一个元素

对于顺序执行而言,尽管在使用直接风格的阻塞API时没有多大问题,但它在异步CPS种是引起回调地狱问题的主因。

我们可以通过顺序执行流来找到一个多个异步事件顺序执行的解决方案(已知异步事件的个数):

function asyncOperation(callback) {
  process.nextTick(callback);
}

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

function task2(callback) {
  asyncOperation(() => {
    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

我们在版本1的基础上增加一个新功能:递归下载网页中包含的所有链接。既然要下载全部链接,则必须要从页面中提取出来所有链接,再按照顺序逐个执行Web爬虫应用程序。

我们首先提取出来一个名为spiderLinks的函数去触发一个页面的所有链接的递归下载。它使用顺序异步迭代算法下载HTML页面的所有链接。在该函数中,我们通过传入一个nesting参数来限制递归的深度,当nesting为0时,调用process.nextTick方法,从而延迟任务的执行,直到下一次事件循环运行时执行。我们从调用iterate(0)开始,当将该索引对应的url作为参数传入spider函数内部时,nesting减1,代表此时进入了更深层次的部分,当0索引对应的url内部的所有资源下载完毕后,会执行回调函数,此时才将继续执行iterate(1),以此类推,直到该页面上所有链接下载完毕。(该函数实际上是一个典型的递归程序,当最深层次的内容执行完毕后会一层层地向外,直到到最外层,当最外层的内容执行完毕后则调用最外层的回调函数,此时整个页面的链接下载完毕)

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

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

    spider(links[index], nesting - 1, function(err) {
      if(err) {
        return callback(err);
      }
      iterate(index + 1);
    });
  }
  iterate(0);
}

然后尝试读取文件,并开始搜索其中的链接(之前我们是先检查文件是否已经存在,如果不存在再去启动下载,但版本2中,我们通过回调函数中的参数err来进行判断该文件是否存在,如果存在则直接调用spiderLinks函数,去触发该页面的所有链接的递归下载。如果不存在,则err.codeENOENT,此时我们去下载该url对应的资源,在下载成功后的回调函数中调用spiderLinks函数,从而去触发该页面的所有链接的递归下载),而不是检查文件是否已经存在,这样便可以恢复中断的下载(当我们中断文件的下载时,如果A链接对应的页面中的某些资源未被下载时,此时实际上已经存在A链接对应的文件,但是却未将其中的资源下载完毕。当我们通过Ctrl+c退出,然后再重新执行node index.js A时,会对A链接中所有链接进行spider操作,此时便会找到中断前未被下载的资源链接并继续下载。如果只是检查A链接对应的文件是否存在的话,便不会检测到其中未被下载的资源链接)。

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

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

function spider(url, nesting, callback) {
  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);
  });
}

模式

在Web爬虫应用程序版本2spiderLinks函数的代码实例中清楚地说明了如何在应用异步操作时迭代集合。它实际上是一种可以适应任何其他情况的模式,我们需要按顺序异步迭代集合的所有元素或通常的任务列表。我们可以将该模式概括如下:

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

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

iterate(0);

该模式可以适应好几种情况:它可以映射数组的值,可以将操作的结果传递给迭代中的下一个,以实现reduce算法,如果满足特定条件,可以提前退出循环,甚至可以迭代无限数量的元素。

并行执行

我们在之前讲解了顺序执行异步任务,但有时候一组异步任务的执行顺序并不是很重要,我们想要的只是在所有运行的任务完成后得到通知,从而执行最终的回调函数。我们可以使用并行执行流来处理这种情况,如下图所示:
node设计模式 - 异步控制流模式之回调函数_第1张图片
由于Node.js的非阻塞特性,仍然可以实现并发功能。实际上,在这种情况下,并行的说法是不准确的,因为任务并不是同时运行,而是由底层的非阻塞API执行,并由事件循环进行交叉运行的。当一个任务请求新的异步操作时,会把控制权交给事件循环让它去处理其他任务,而不是让其等待。描述这种工作流更适当的词语是并发,但为了简单起见,我们仍使用并行一词。

下图显示了两个异步任务如何在Node.js程序中并行运行:
node设计模式 - 异步控制流模式之回调函数_第2张图片

在上图中,我们一个Main函数来执行两个异步任务:

  • 事件循环首先将控制权交给Main函数,然后Main函数将控制权交给异步任务Task1,当任务触发异步操作时,它立即将控制权返回给Main函数,Main函数又将控制权交给异步任务Task2,当任务触发异步操作时,它立即将控制权返回给Main函数,Main函数又将控制权交还给事件循环。
  • 事件循环继续去执行其他操作。
  • Task1异步任务执行完毕时,事件循环会触发其回调,此时事件循环会将控制权交给Task1任务,让其执行与其绑定的回调函数中的同步处理。当Task1完成回调函数中的所有操作后,通知Main函数并将控制权交还给Main函数,Main函数又将其交还给事件循环
  • Task2异步任务执行完毕时,事件循环触发其回调,并将控制权交给Task2。当Task2回调内部操作执行完毕后再次通知Main函数。此时Main函数已经知道Task1Task2已经完成,所以它可以继续执行或将操作的结果返回给另一个回调(Main函数之所以要等待Task1Task2,是因为其中的某一个操作需要在Task1Task2已经完成后才能执行)

Node.js中,我们只能以并行异步操作执行,因为它们的并发性由非阻塞API在内部处理。我们不能让同步操作进行并发处理,除非它们与异步操作交错执行,或者通过setTimeout()或者setImmediate()延迟。

Web爬虫应用版本3

在版本2中,我们可以以顺序方式执行链接页面的递归下载。但在并行执行中,我们无需考虑不同任务之间的顺序关系,所以可以通过并行下载所有链接页面,从而能够提高此过程的性能。

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

  const links = utilities.getPageLinks(currentUrl, body);  //[1]
  if(links.length === 0) {
    return process.nextTick(callback);
  }

  let completed = 0, hasErrors = false;
//done
  function done(err) {
    if(err) {
      hasErrors = true;
      return callback(err);
    }
    if(++completed === links.length && !hasErrors) {
      return callback();
    }
  }

  links.forEach(function(link) {
  //并发
    spider(link, nesting - 1, done);
  });
}
function saveFile(filename, contents, callback) {
  mkdirp(path.dirname(filename), err => {
    if(err) {
      return callback(err);
    }
    fs.writeFile(filename, contents, callback);
  });
}

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

let spidering = new Map();
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);
  });
}

在版本三中,首先将spiderLinks函数中的对所有链接执行siper函数的部分修改为并行处理,即使用forEach方法,此时每一个任务都无需等待前一个任务完成。等到一个链接页面中所有子任务完成时,触发done回调函数,即该链接页面的回调函数,在该回调函数中同时增加一个completed计数,当完成的下载数量达到links数组的长度时,将调用最终该链接所在的父链接页面对应的回调函数。通过这样的修改后,整个过程的速度有了巨大的提升。

模式

我们通过版本三说明了并行执行流的内容。我们可以从中提取出一个模式,它可以适应不同的情况:

无限制并行执行模式:如果是没有限制的情况下,并行执行的一组异步任务,然后等待所有异步任务完成后执行回调,计算执行回调的次数,待其数量与异步任务属性相同时即代表异步任务执行完毕,此时执行最终回调。

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则不需要一个花哨的同步机制(为了让所有任务能够同步执行的机制),因为一切都在单个线程上运行。但这并不意味着不会有竞争态。问题的根源其实在于异步操作的调用与其结果通知之前的延迟(两个相同的异步任务,其中之一异步任务已经完成但未及时通知到另一个异步任务,导致另一个异步任务与其做相同的操作)。

function spider(url, nesting, callback) {
  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文档前检查文件是否已经存在的spider函数。问题是:对于同一个URL,操作的两个spider任务可能会在两个任务之一完成下载并创建文件之前,对同一个文件调用fs.readFile,导致两个任务开始下载同一个文件。目前的情况如下图:
node设计模式 - 异步控制流模式之回调函数_第3张图片

上图显示了Task1Task2如何在Node.js的单线程中交错,以及异步操作如何实际地引入竞争条件。在我们的例子中,最终导致的问题是:两个spider任务最终下载相同的问题。
那么如何解决这个问题呢?我们只需要一个变量来排除多个spider任务在同一个url上运行。我们可以用一个Map实例来存储当前url是否已经对应一个spider任务,如果当前url已经对应一个spider任务,则退出该函数,否则为其绑定一个spider任务:

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

在我们的情况中,无需释放锁,因为我们不想下载一个url两次,即使spider任务在两个完全不同的时间点执行。
即使在单线程环境中,竞争条件也会导致很多问题,在某些情况下,可能会导致数据损坏,并且由于它们的短暂特性通常很难调试,故在并行运行任务时仔细检查这种情况是必要的。

并行执行频率限制

通常在没有控制的情况下产生并行任务可能会导致过度负载,在过度负载情况下,很容易导致资源耗尽的问题。在web应用程序中,还可能创建一个利用拒绝服务(DOS)攻击的漏洞。在这些情况下,最好限制可以同时运行的任务数,这样可以为我们的服务器增加一些可预测性,并确保我们的应用程序不会耗尽资源。下图中,在限制并发个数为2的情况下并行运行5个任务。
node设计模式 - 异步控制流模式之回调函数_第4张图片

并发限制

我们提出一种模式,其是以有限的并发个数来执行一组给定的任务:

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() {
  // 所有任务执行完成
}

这种模式可以认为是顺序执行和并行执行的混合。在当当前执行的任务个数小于并发个数时,为并行执行,当超过时,则部分任务会变为顺序执行。

全局限制并发

对于我们前面实现的spiderLinks函数,我们如果只限制每个页面的并发性为2,当我们依次下载多个链接时,对于每一页都将有两个并行下载的链接,此时的下载操作的总数增长仍为指数级的。所以我们需要在全局限制并发。

使用队列

我们需要在全局限制并发,则可以利用队列来限制多个任务的并发性。我们可以实现一个名为TaskQueue的简单类,它将队列与我们前面实现的并发限制算法组合。

//TaskQueue.js
module.exports = 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++;
    }
  }
};

TaskQueue构造函数接收一个全局并发数concurrency值,所有的任务都将被pushTaskQueue实例的queue中,running值为当前并发的运行的任务数,当当前运行的任务书不大于全局并发数时,所有任务并行执行,否则下一个任务需要等待前面的某个任务执行完毕后才能执行。

TaskQueue类允许我们动态地将新任务添加到任务队列中,且有一个中央实体负责限制任务地并发性,它可以在所有执行的spider函数中共享。

Web爬虫版本4(只列出了修改的部分)

const TaskQueue = require('./taskQueue');
const downloadQueue = new TaskQueue(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);
  }
  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();
      });
    });
  });
}

在版本4中,我们将TaskQueue类运用到其中,并为其传入了全局并发数,我们将并发控制权委托给TaskQueue对象,我们唯一要做的就是检查所有任务是否完成。

async

我们前面使用纯JavaScript来解决了回调地狱的问题。实际上,async库提供了一组函数,大大简化了在不同架构中的任务列表的执行,它还对异步处理集合提供了很有用的帮助。接下来我们来介绍一下如何用async库控制任务列表的执行。

顺序执行

async 库可以在实现复杂的异步控制流程时大大帮助我们,但是一个难题就是选择正确的库来解决问题。例如,对于顺序执行,有大约20个不同的函数可供选择,包括 eachSeries() , mapSeries() , filterSeries() , rejectSeries() , reduce() , reduceRight() , detectSeries() , concatSeries() , series() , whilst() , doWhilst() , until() , doUntil() , forever() , waterfall() , compose() , seq() , applyEachSeries() , iterator() , 和 timesSeries() 。我们需要选择适合我们的函数。

安装并引入async

安装:npm install async
引入:const async = require('async');

顺序执行一组已知数量的任务

我们修改了原download函数中的内容。我们首先来回顾一下download函数做了什么操作:

  • 下载URL对应的内容
  • 如果目录不存在,创建新的目录
  • 将下载的内容保存在上一步创建的目录下的某个文件下
function download(url, filename, callback) {
  console.log(`Downloading ${url}`);
  let body;

  async.series([
    callback => {           //[1]
      request(url, (err, response, resBody) => {
        if(err) {
          return callback(err);
        }
        body = resBody;
        callback();
      });
    },

    mkdirp.bind(null, path.dirname(filename)),    //[2]

    callback => {           //[3]
      fs.writeFile(filename, body, callback);
    }
  ], err => {         //[4]
    if(err) {
      return callback(err);
    }
    console.log(`Downloaded and saved: ${url}`);
    callback(null, body);
  });
}

在上面实现中,我们将所有任务作为一个tasks列表传入async.series方法中,同时传入一个所有任务完成后调用的callback函数。每个任务都将一个回调函数作为参数,在该任务完成时需要调用该回调函数。

对于这种特定情况,async.series() 的一个可替代的方法是 async.waterfall() ,它仍然按顺序执行任务,但另外还提供每个任务的输出作为下一个输入。在我们的情况下,我们可以使用这个特征来传播 body 变量直到序列结束。

顺序迭代

我们已经知道通过async.series()来顺序执行一组已知的任务,我们也可以使用类似的函数来实现web爬虫版本2的spiderLinks函数。我们将用async.eachSeries()来遍历集合,从而能够实现动态构建顺序任务列表。

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

不得不说,对比我们使用纯javaScript的方式,代码组织以及可读性方面提升了许多。

并行执行

async库不具有处理并行流的能力,其中可以找到 each()map()filter()reject()detect()some()every()concat()parallel()applyEach()times() 。它们遵循与我们已经看到的用于顺序执行的功能相同的逻辑,区别在于所提供的任务是并行执行的。

我们使用这些函数之一来实现web爬虫版本3的功能:使用无限制并行流执行下载

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

限制并行执行

我们可以通过eachLimit()mapLimit()parallelLimit()queue()cargo()这些函数来限制并行任务的数量。我们使用其中之一来实现web爬虫版本4的功能:有限的并发性并行执行链接的下载。值得一提的是,async中有async.queue函数,其工作方式类似于TaskQueue类,async.queue函数创建一个新队列,它使用一个worker函数来执行一组具有指定并发限制数量的任务列表。

const q = async.queue(worker, concurrency);

worker函数接受两个参数:要运行的task和该任务完成时要调用的callback函数。其中task可以是任何东西,而不只是一个函数。与传入任务相关联的回调必须在任务处理结束后由worker调用

//创建限制并发数为2的一个队列
const downloadQueue = async.queue((taskData, callback) => {
  spider(taskData.link, taskData.nesting - 1, callback);
}, 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);
  }
  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();
      }
    });
  });
}

你可能感兴趣的:(javascript,node)