Node.js软肋之回调大坑

Node.js需要按顺序执行异步逻辑时一般采用后续传递风格,也就是将后续逻辑封装在回调函数中作为起始函数的参数,逐层嵌套。这种风格虽然可以提高CPU利用率,降低等待时间,但当后续逻辑步骤较多时会影响代码的可读性,结果代码的修改维护变得很困难。根据这种代码的样子,一般称其为\"callback hell\"或\"pyramid of doom\",本文称之为回调大坑,嵌套越多,大坑越深。

\

坑的起源

\

后续传递风格

\

为什么会有坑?这要从后续传递风格(continuation-passing style--CPS)说起。这种编程风格最开始是由Gerald Jay Sussman和Guy L. Steele, Jr. 在AI Memo 349上提出来的,那一年是1975年,Schema语言的第一次亮相。既然JavaScript的函数式编程设计原则主要源自Schema,这种风格自然也被带到了Javascript中。

\

这种风格的函数要有额外的参数:“后续逻辑体”,比如带一个参数的函数。CPS函数计算出结果值后并不是直接返回,而是调用那个后续逻辑函数,并把这个结果作为它的参数。从而实现计算结果在逻辑步骤之间的传递,以及逻辑的延续。也就是说如果要调用CPS函数,调用方函数要提供一个后续逻辑函数来接收CPS函数的“返回”值。

\

回调

\

在JavaScript中,这个“后续逻辑体”就是我们常说的回调(callback)。这种作为参数的函数之所以被称为回调,是因为它一般在主程序中定义,由主程序交给库函数,并由它在需要时回来调用。而将回调函数作为参数的,一般是一个会占用较长时间的异步函数,要交给另一个线程执行,以便不影响主程序的后续操作。如下图所示:

\

Node.js软肋之回调大坑_第1张图片

\

在JavaScript代码中,后续传递风格就是在CPS函数的逻辑末端调用传入的回调函数,并把计算结果传给它。但在不需要执行处理时间较长的异步函数时,一般并不需要用这种风格。我们先来看个简单的例子,编程求解一个简单的5元方程:

\
\x+y+z+u+v=16\x+y+z+u-v=10\x+y+z-u=11\x+y-z=8\x-y=2\
\

对于x+y=a;x-y=b这种简单的二元方程我们都知道如何求解,这个5元方程的运算规律和这种二元方程也没什么区别,都是两式相加除以2求出前一部分,两式相减除以2求出后一部分。5元方程的前一部分就是4元方程的和值,依次类推。我们的程序写出来就是:

\
\

代码清单1. 普通解法-calnorm.js

\
\
\var res = new Int16Array([16,10,11,8,2]),l= res.length;\var variables = [];\for(var i = 0;i \u0026lt; l;i++) {\    if(i === l-1) {\        variables[i] = res[i];\    }else {\      variables[i] = calculateTail(res[i],res[i+1]);\      res[i+1] = calculateHead(res[i],res[i+1]);\    }\}\function calculateTail(x,y) {\    return (x-y)/2;\}\function calculateHead(x,y) {\    return (x+y)/2;\}\
\

方程式的结果放在了一个整型数组中,我们在循环中依次遍历数组中的头两个值res[i]和res[i+1],用calculateTail计算最后一个单值,比如第一和第二个等式中的v;用calculateHead计算等式的\"前半部分\",比如第一和第二个等式中的x+y+z+u部分。并用该结果覆盖原来的差值等式,即用x+y+z+u的结果覆盖原来x+y+z+u-v的结果,以便计算下一个tail,直到最终求出所有未知数。

\

如果calculateTail和calculateHead是CPU密集型的计算,我们通常会把它放到子线程中执行,并在计算完成后用回调函数把结果传回来,以免阻塞主进程。关于CPU密集型计算的相关概念,可参考本系列的上一篇Node.js软肋之CPU密集型任务。比如我们可以把代码改成下面这样:

\
\

代码清单2. 回调解法-calcb.js

\
\var res = new Int16Array([16,10,11,8,2]),l= res.length;\var variables = [];\(function calculate(i) {\    if(i === l-1) {\        variables[i] = res[i];\        console.log(i + \":\" + variables[i]); \        process.exit();\    }else {\        calculateTail(res[i],res[i+1],function(tail) {\            variables[i] = tail;\            calculateHead(res[i],res[i+1],function(head) {\                res[i+1] = head;\                console.log('-----------------'+i+'-----------------')\                calculate(i+1);\            });\        });\    }\})(0);\function calculateTail(x,y,cb) {\   setTimeout(function(){\        var tail = (x-y)/2;\        cb(tail);\    },300);\}\function calculateHead(x,y,cb) {\    setTimeout(function(){\        var head = (x+y)/2;\        cb(head);\    },400);\}\
\
\

跟上一段代码相比,这段代码主要有两个变化。第一是calculateTail和calculateHead里增加了setTimeout,把它们伪装成CPU密集型任务;第二是弃用for循环,改用函数递归。因为calculateHead的计算结果会影响下一轮的calculateTail计算,所以calculateHead计算要阻塞后续计算。而for循环是无法阻塞的,会产生错误的结果。此外就是calculateTail和calculateHead都变成后续传递风格的函数了,通过回调返回最终计算结果。

\

这个例子比较简单,既不能充分体现回调在处理异步非阻塞操作时在性能上的优越性,坑的深度也不够恐怖。不过也可以说明“用后续传递风格实现几个异步函数的顺序执行是产生回调大坑的根本原因”。下面有一个更抽象的回调样例,看起来更有代表性:

\
\module.exports = function (param, cb) {\  asyncFun1(param, function (er, data) {\    if (er) return cb(er);\    asyncFun2(data,function (er,data) {\      if (er) return cb(er);\      asyncFun3(data, function (er, data) {\        if (er) return cb(er);\        cb(data);\      })\    })\  })\}\
\

像function(er,data)这种回调函数签名很常见,几乎所有的Node.js核心库及第三方库中的CPS函数都接收这样的函数参数,它的第一个参数是错误,其余参数是CPS函数要传递的结果。比如Node.js中负责文件处理的fs模块,我们再看一个实际工作中可能会遇到的例子。要找出一个目录中最大的文件,处理步骤应该是:

\
  1. 用fs.readdir获取目录中的文件列表;\
  2. 循环遍历文件,获取文件的stat;\
  3. 找出最大文件;\
  4. 以最大文件的文件名为参数调用回调。\

这些都是异步操作,但需要顺序执行,后续传递风格的代码应该是下面这样的:

\
\

代码清单3. 寻找给定目录中最大的文件

\
\var fs = require('fs')\var path = require('path')\module.exports = function (dir, cb) {\  fs.readdir(dir, function (er, files) { // [1]\    if (er) return cb(er)\    var counter = files.length\    var errored = false\    var stats = []\    files.forEach(function (file, index) {\      fs.stat(path.join(dir,file), function (er, stat) { // [2]\        if (errored) return\        if (er) {\          errored = true\          return cb(er)\        }\        stats[index] = stat // [3]\        if (--counter == 0) { // [4]\          var largest = stats\            .filter(function (stat) { return stat.isFile() }) // [5]\            .reduce(function (prev, next) { // [6]\              if (prev.size \u0026gt; next.size) return prev\              return next\            })\          cb(null, files[stats.indexOf(largest)]) // [7]\        }\      })\    })\  })\}\
\
\

对这个模块的用户来说,只需要提供一个回调函数function(er,filename),用两个参数分别接收错误或文件名:

\
\var findLargest = require('./findLargest')\findLargest('./path/to/dir', function (er, filename) {\  if (er) return console.error(er)\  console.log('largest file was:', filename)\})\
\

介绍完CPS和回调,我们接下来看看如何平坑。

\

解套平坑

\

编写正确的并发程序归根结底是要让尽可能多的操作同步进行,但各操作的先后顺序仍能正确无误。服务端的代码一般逻辑比较复杂,步骤多,此时用嵌套实现异步函数的顺序执行会比较痛苦,所以应该尽量避免嵌套,或者降低嵌套代码的复杂性,少用匿名函数。这一般有几种途径:

\
  1. 最简单的是把匿名函数拿出来定义成单独的函数,然后或者像原来一样用嵌套方式调用,或者借助流程控制模块放在数组里逐一调用;\
  2. 用Promis;\
  3. 如果你的Node版本\u0026gt;=0.11.2,可以用generator。\

我们先介绍最容易理解的流程控制模块。

\

流程控制模块

\

Nimble是一个轻量、可移植的函数式流程控制模块。经过最小化和压缩后只有837字节,可以运行在Node.js中,也可以用在各种浏览器中。它整合了underscore和async一些最实用的功能,并且API更简单。

\

nimble有两个流程控制函数,_.parallel和_.series。顾名思义,我们要用的是第二个,可以让一组函数串行执行的_.series。下面这个命令是用来安装Nimble的:

\
\npm install nimble\
\

如果用.series调度执行上面那个解方程的函数,代码应该是这样的:

\
\...\var flow = require('nimble');\(function calculate(i) {\    if(i === l-1) {\        variables[i] = res[i];\        process.exit();\    }else {\        flow.series([\            function (callback) {\                calculateTail(res[i],res[i+1],function(tail) {\                    variables[i] = tail;\                    callback();\                });\            },\            function (callback) {\                calculateHead(res[i],res[i+1],function(head) {\                    res[i+1] = head;\                    callback();\                });\            },\            function(callback){\                calculate(i+1);\            }]);\    }\})(0);\...\
\

.series数组参数中的函数会挨个执行,只是我们的calculateTail和calculateHead都被包在了另一个函数中。尽管这个用流程控制实现的版本代码更多,但通常可读性和可维护性要强一些。接下来我们介绍Promise。

\

Promise

\

什么是Promise呢?在纸牌屋的第一季第一集中,当琳达告诉安德伍德不能让他做国务卿后,他说:“所谓Promise,就是说它不会受不断变化的情况影响。”

\

Promise不仅去掉了嵌套,它连回调都去掉了。因为按照Promise的观点,回调一点也不符合函数式编程的精神。回调函数什么都不返回,没有返回值的函数,执行它仅仅是因为它的副作用。所以用回调函数编程天生就是指令式的,是以副作用为主的过程的执行顺序,而不是像函数那样把输入映射到输出,可以组装到一起。

\

最好的函数式编程是声明式的。在指令式编程中,我们编写指令序列来告诉机器如何做我们想做的事情。在函数式编程中,我们描述值之间的关系,告诉机器我们想计算什么,然后由机器(底层框架)自己产生指令序列完成计算。Promise把函数的结果变成了一个与时间无关的值,就像算式中的未知数一样,可以用它轻松描述值之间的逻辑计算关系。虽然要得出一个函数最终的结果需要先计算出其中的所有未知数,但我们写的程序只需要描述出各未知数以及未知数和已知数之间的逻辑关系。而CPS是手工编排控制流,不是通过定义值之间的关系来解决问题,因此用回调函数编写正确的并发程序很困难。比如在代码清单2中,caculateHead被放在caculateTail的回调中执行,但实际上在计算同一组值时,两者之间并没有依赖关系,只是进入下一轮计算前需要两者都给出结果,但如果不用回调嵌套,实现这种顺序控制比较麻烦。

\
\

当然,这和我们的处理方式(共用数组)有关,就这个问题本身而言,caculateHead完全不依赖于任何caculateTail。

\
\

这里用的Promis框架是著名的Q,可以用npm install q安装。虽然可用的Promis框架有很多,但在它们用法上都大同小异。我们在这里会用到其中的三个方法。

\

第一个负责将Node.js的CPS函数变成Promise。Node.js核心库和第三方库中有非常多的CPS函数,我们的程序肯定要用到这些函数,要解决回调大坑,就要从这些函数开始。这些函数的回调函数参数大多遵循一个相同的模式,即函数签名为function(err, result)。对于这种函数,可以用简单直接的Q.nfcall和Q.nfapply调用这种Node.js风格的函数返回一个Promise:

\
\return Q.nfcall(FS.readFile, \"foo.txt\

你可能感兴趣的:(Node.js软肋之回调大坑)