指令式Callback,函数式Promise:对node.js的一声叹息

原文:Callbacks are imperative, promises are functional: Node’s biggest missed opportunitynode

promises 天生就不会受不断变化的状况影响。
-- Frank Underwood, ‘House of Cards’数据库

人们常说Javascript是'函数式'编程语言。而这仅仅由于函数是它的一等值,可函数式编程的不少其余特性,包括不可变数据,递归比循环更招人待见,代数类型系统,规避反作用等,它都不俱备。尽管把函数做为一等公民确实管用,也让码农能够根据本身的须要决定是否采用函数式的风格编程,但宣称JS是函数式的每每会让JS码农们忽略函数式编程的一个核心理念:用值编程。express

'函数式编程'是一个使用不当的词,由于它会让人们觉得这是'用函数编程'的意思,把它跟用对象编程相对比。但若是面向对象编程是把一切都看成对象,那函数式编程是把一切都看成值,不只函数是值,而是一切都是值。这其中固然包括显而易见的数值、字符串、列表和其它数据,还包括咱们这些OOP狗通常不会当作值的其它东西:IO操做和其它反作用,GUI事件流,null检查,甚至是函数调用序列的概念。若是你曾据说过'可编程的分号'1这个短语,你应该就能明白我在说什么了。npm

1单子。 In functional programming, a monad is a structure that represents computations. A type with a monad structure defines what it means to chain operations of that type together. This allows the programmer to build pipelines that process data in steps, in which each action is decorated with additional processing rules provided by the monad. As such, monads have been described as "programmable semicolons"; a semicolon is the operator used to chain together individual statements in many imperative programming languages, thus the expression implies that extra code will be executed between the statements in the pipeline. Monads have been also explained with a physical metaphor as assembly lines, where a conveyor belt transports data between functional units that transform it one step at a time. http://en.wikipedia.org/wiki/Monad_(functional_programming)编程

最好的函数式编程是声明式的。在指令式编程中,咱们编写指令序列来告诉机器如何作咱们想作的事情。在函数式编程中,咱们描述值之间的关系,告诉机器咱们想计算什么,而后由机器本身产生指令序列完成计算。api

用过excel的人都作过函数式编程:在其中经过建模把一个问题描绘成一个值图(如何从一个值推导出另外一个)。当插入新值时,Excel负责找出它对图会产生什么影响,并帮你完成全部的更新,而无需你编写指令序列指导它完成这项工做。数组

有了这个定义作依据,我要指出node.js一个最大的设计失误,最起码我是这样认为的:在最初设计node.js时,在肯定提供哪一种方式的API式,它选择了基于callback,而不是基于promise。promise

全部人都在用 [callbacks]。若是你发布了一个返回promise的模块,没人会注意到它。人们甚至不会去用那样一个模块。 服务器

若是我要本身写个小库,用来跟Redis交互,而且这是它所作的最后一件事,我能够把传给个人callback转给Redis。并且当咱们真地遇到callback hell之类的问题时,我会告诉你一个秘密:这里还有协同hell和单子hell,而且对于你所建立的任何抽象工具,只要你用得足够多,总会遇到某个hell。网络

在90%的状况下咱们都有这种超级简单的接口,因此当咱们须要作某件事的时候,只要小小的缩进一下,就能够搞定了。而在遇到复杂的状况时,你能够像npm里的其它827个模块同样,装上async。

--Mikeal Rogers, LXJS 2012

Node宣称它的设计目标是让码农中的屌丝也能轻松写出反应迅速的并发网络程序,但我认为这个美好的愿望撞墙了。用Promise可让运行时肯定控制流程,而不是让码农绞尽脑汁地明确写出来,因此更容易构建出正确的、并发程度最高的程序。

编写正确的并发程序归根结底是要让尽量多的操做同步进行,但各操做的执行顺序仍能正确无误。尽管Javascript是单线程的,但因为异步,咱们仍然会遇到竞态条件:全部涉及到I/O操做的操做在等待callback时都要把CPU时间让给其余操做。多个并发操做都能访问内存中的相同数据,对数据库或DOM执行重叠的命令序列。借助promise,咱们能够像excel那样用值之间的相互关系来描述问题,从而让工具帮你找出最优的解决方案,而不是你亲自去肯定控制流。

我但愿澄清你们对promise的误解,它的做用不只是给基于callback的异步实现找一个语法更清晰的写法。promise以一种全新的方式对问题建模;它要比语法层面的变化更深刻,其实是在语义层上改变了解决问题的方式。

我在两年前曾写过一篇文章,promises是异步编程的单子。那篇文章的核心理念是单子是组建函数的工具,好比构建一个以上一个函数的输出做为下一个函数输入的管道。这是经过使用值之间的结构化关系来达成的,它的值和彼此之间的关系在这里仍要发挥重要做用。

我仍将借助Haskell的类型声明来阐明问题。在Haskell中,声明foo::bar表示“foo是类型为bar的值”。声明foo :: Bar -> Qux 的意思是"foo是一个函数,以类型Bar的值为参数,返回一个类型为Qux的值"。若是输入/输出的确切类型可有可无,能够用单个的小写字母表示,foo :: a -> b。若是foo的参数不止一个,能够加上更多的箭头,好比foo :: a -> b -> c表示foo有两个类型分别为a和b的参数,返回类型为c的值。

咱们来看一个Node函数,就以fs.readFile()为例吧。这个函数的参数是一个String类型的路径名和一个callback函数,它没有任何返回值。callback函数有两个参数,Error(可能为null)和包含文件内容的Buffer,也是没有任何返回值。咱们能够把readFile的类型表示为:

readFile :: String -> Callback -> ()

() 在 Haskell 中表示 null 类型。callback 自己是另外一个函数,它的类型签名是:

Callback :: Error -> Buffer -> ()

把这些都放到一块儿,则能够说readFile以一个String和一个带着Buffer调用的函数为参数:

readFile :: String -> (Error -> Buffer -> ()) -> ()

好,如今请想象一下Node使用promises是什么状况。对于readFile而言,就是简单地接受一个String类型的值,并返回一个Buffer的promise值。

readFile :: String -> Promise Buffer

说得更归纳一点,就是基于callback的函数接受一些输入和一个callback,而后用它的输出调用这个callback函数,而基于promise的函数接受输入,返回输出的promise值:

callback :: a -> (Error -> b -> ()) -> ()
promise :: a -> Promise b

基于callback的函数返回的那些null值就是基于callback编程之因此艰难的源头:基于callback的函数什么都不返回,因此难以把它们组装到一块儿。没有返回值的函数,执行它仅仅是由于它的反作用 -- 没有返回值或反作用的函数就是个黑洞。因此用callback编程天生就是指令式的,是编写以反作用为主的过程的执行顺序,而不是像函数应用那样把输入映射到输出。是手工编排控制流,而不是经过定义值之间的关系来解决问题。所以使编写正确的并发程序变得艰难。

而基于promise的函数与之相反,你总能把函数的结果看成一个与时间无关的值。在调用基于callback的函数时,在你调用这个函数和它的callback被调用之间要通过一段时间,而在这段时间里,程序中的任何地方都找不到表示结果的值。

fs.readFile('file1.txt',
  // 时光流逝...
  function(error, buffer) {
    // 如今,结果忽然跌落在凡间
  }
);

从基于callback或事件的函数中获得结果基本上就意味着你“要在正确的时间正确的地点”出现。若是你是在事件已经被触发以后才把事件监听器绑定上去,或者把callback放错了位置,那上帝也罩不了你,你只能看着结果从眼前溜走。这对于用Node写HTTP服务器的人来讲就像瘟疫同样。若是你搞错了控制流,那你的程序就只能崩溃。

而Promises与之相反,它不关心时间或者顺序。不管你在promise被resolve以前仍是以后附上监听器,都不要紧,你总能从中获得结果值。所以,返回promises的函数立刻就能给你一个表示结果的值,你能够把它看成一等数据来用,也能够把它传给其它函数。不用等着callback,也不会错过任何事件。只要你手中握有promise,你就能从中获得结果值。

var p1 = new Promise();
p1.then(console.log);
p1.resolve(42);

var p2 = new Promise();
p2.resolve(2013);
p2.then(console.log);

// prints:
// 42
// 2013

因此尽管then()这个方法的名字让人以为它跟某种顺序化的操做有关,而且那确实是它所承担的职责的副产品,但你真的能够把它看成unwrap来看待。promise是一个存放未知值的容器,而then的任务就是把这个值从promise中提取出来,把它交给另外一个函数:从单子的角度来看就是bind函数。在上面的代码中,咱们彻底看不出来该值什么时候可用,或代码执行的顺序是什么,它只表达了某种依赖关系:要想在日志中输出某个值,那你必须先知道这个值是什么。程序执行的顺序是从这些依赖信息中推导出来的。二者的区别其实至关微妙,但随着咱们讨论的不断深刻,到文章末尾的lazy promises时,这个区别就会变得越发明显。

到目前为止,你看到的都是些无足轻重的东西;一些彼此之间几乎没什么互动的小函数。为了让你了解promises为何比callback更强大,咱们来搞点更须要技巧性的把戏。假设咱们要写段代码,用fs.stat()取得一堆文件的mtimes属性。若是这是异步的,咱们只须要调用paths.map(fs.stat),但既然跟异步函数映射难度较大,因此咱们把async模块挖出来用一下。

var async = require('async'),
    fs    = require('fs');

var paths = ['file1.txt', 'file2.txt', 'file3.txt'];

async.map(paths, fs.stat, function(error, results) {
  // use the results
});

(哦,我知道fs的函数都有sync版本,但不少其它I/O操做都没有这种待遇。因此,请淡定地坐下来看我把戏法变完。)

一切都很美好,可是,新需求来了,咱们还须要获得file1的size。只要再stat就能够了:

var paths = ['file1.txt', 'file2.txt', 'file3.txt'];

async.map(paths, fs.stat, function(error, results) {
  // use the results
});

fs.stat(paths[0], function(error, stat) {
  // use stat.size
});

需求知足了,但这个跟size有关的任务要等着前面整个列表中的文件都处理完才会开始。若是前面那个文件列表中的任何一项出错了,很不幸,咱们根本就不可能获得第一个文件的size。这可就大大地坏了,因此,咱们要试试别的办法:把第一个文件从文件列表中拿出来单独处理。

var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
    file1 = paths.shift();

fs.stat(file1, function(error, stat) {
  // use stat.size
  async.map(paths, fs.stat, function(error, results) {
    results.unshift(stat);
    // use the results
  });
});

这样也行,但如今咱们已经不能把这个程序称为并行化的了:它要用更长的时间,由于在处理完第一个文件以前,文件列表的请求处理得一直等着。以前它们还都是并发运行的。另外咱们还不得不处理下数组,以即可以把第一个文件提出来作特别的处理。

Okay,最后的成功一击。咱们知道须要获得全部文件的stats,每次命中一个文件,若是成功,则在第一个文件上作些工做,而后若是整个文件列表都成功了,则要在那个列表上作些工做。带着对问题中这些依赖关系的认识,用async把它表示出来。

var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
    file1 = paths.shift();

async.parallel([
  function(callback) {
    fs.stat(file1, function(error, stat) {
      // use stat.size
      callback(error, stat);
    });
  },
  function(callback) {
    async.map(paths, fs.stat, callback);
  }
], function(error, results) {
  var stats = [results[0]].concat(results[1]);
  // use the stats
});

这就对了:每次一个文件,全部工做都是并行的,第一个文件的结果跟其余的不要紧,而相关任务能够尽早执行。Mission accomplished!

好吧,实际上并不尽然。这个太丑了,而且当问题变得更加复杂后,这个显然不易于扩展。为了正确解决问题,要考虑不少东西,并且这个设计意图也不显眼,后期维护时极可能会把它破坏掉,后续任务跟如何完成所需工做的策略混杂在一块儿,并且咱们不得不动用一些比较复杂的数组分割操做来应对这个特殊情况。啊哦!

这些问题的根源都在于咱们用控制流做为解决办法的主体,若是用数据间的依赖关系,就不会这样了。咱们的思路不是“要运行这个任务,我须要这个数据”,没有把找出最优路径的工做交给运行时,而是明确地向运行时指出哪些应该并行,哪些应该顺行,因此咱们获得了一个特别脆弱的解决方案。

那promises怎么帮你脱离困境?嗯,首先要有能返回promises的文件系统函数,用callback作参数的那套东西不行。但在这里咱们不要手工打造一套文件系统函数,经过元编程做个能转换一切函数的东西就行。好比,它应该接受类型为:

String -> (Error -> Stat -> ()) -> ()

的函数,并返回:

String -> Promise Stat

下面就是这样一个函数:

// promisify :: (a -> (Error -> b -> ()) -> ()) -> (a -> Promise b)
var promisify = function(fn, receiver) {
  return function() {
    var slice   = Array.prototype.slice,
        args    = slice.call(arguments, 0, fn.length - 1),
        promise = new Promise();

    args.push(function() {
      var results = slice.call(arguments),
          error   = results.shift();

      if (error) promise.reject(error);
      else promise.resolve.apply(promise, results);
    });

    fn.apply(receiver, args);
    return promise;
  };
};

(这不是特别通用,但对咱们来讲够了.)

如今咱们能够对问题从新建模。咱们须要作的所有工做基本就是将一个路径列表映射到一个stats的promises列表上:

var fs_stat = promisify(fs.stat);

var paths = ['file1.txt', 'file2.txt', 'file3.txt'];

// [String] -> [Promise Stat]
var statsPromises = paths.map(fs_stat);

这已是付利息了:在用 async.map()时,在整个列表处理完以前你拿不到任何数据,而用上promises的列表以后,你能够径直挑出第一个文件的stat作些处理:

statsPromises[0].then(function(stat) { /* use stat.size */ });

因此在用上promise值后,咱们已经解决了大部分问题:全部文件的stat都是并发进行的,而且访问全部文件的stat都和其余的无关,能够从数组中直接挑咱们想要的任何一个,不止是第一个了。在前面那个方案中,咱们必须在代码里明确写明要处理第一个文件,想换文件时改起来不是那么容易,但用promises列表就容易多了。

谜底尚未彻底揭晓,在获得全部的stat结果以后,咱们该作什么?在以前的程序中,咱们最终获得的是一个Stat对象的列表,而如今咱们获得的是一个Promise Stat 对象的列表。咱们想等着全部这些promises都被兑现(resolve),而后生出一个包含全部stats的列表。换句话说,咱们想把一个promises列表变成一个列表的promise。

闲言少叙,咱们如今就给这个列表加上promise方法,那这个包含promises的列表就会变成一个promise,当它所包含的全部元素都兑现后,它也就兑现了。

// list :: [Promise a] -> Promise [a]
var list = function(promises) {
  var listPromise = new Promise();
  for (var k in listPromise) promises[k] = listPromise[k];

  var results = [], done = 0;

  promises.forEach(function(promise, i) {
    promise.then(function(result) {
      results[i] = result;
      done += 1;
      if (done === promises.length) promises.resolve(results);
    }, function(error) {
      promises.reject(error);
    });
  });

  if (promises.length === 0) promises.resolve(results);
  return promises;
};

(这个函数跟 jQuery.when() 相似, 以一个promises列表为参数,返回一个新的promise,当参数中的全部promises都兑现后,这个新的promise就兑现了.)

只需把数组打包在promise里,咱们就能够等着全部结果出来了:

list(statsPromises).then(function(stats) { /* use the stats */ });

咱们最终的解决方案就被削减成了下面这样:

var fs_stat = promisify(fs.stat);

var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
    statsPromises = list(paths.map(fs_stat));

statsPromises[0].then(function(stat) {
  // use stat.size
});

statsPromises.then(function(stats) {
  // use the stats
});

该方案的这种表示方式看起来要清楚得多了。借助一点通用的粘合剂(咱们的promise辅助函数),以及已有的数组方法,咱们就能用正确、有效、修改起来很是容易的办法解决这个问题。不须要async模块特制的集合方法,只是让promises和数组二者的思想各自保持独立,而后以很是强大的方式把它们整合到一块儿。

特别要注意这个程序是如何避免了跟并行或顺序相关的字眼出现。它只是说咱们想作什么,而后说明任务之间的依赖关系是什么样的,其余的事情就交给promise类库去作了。

实际上,async集合模块中的不少东西均可以用promises列表上的操做轻松代替。前面已经看到map的例子了:

async.map(inputs, fn, function(error, results) {});

至关于:

list(inputs.map(promisify(fn))).then(
    function(results) {},
    function(error) {}
);

async.each()async.map() 实质上是同样的,只不过each()只是要执行效果,不关心返回值。彻底能够用map()代替。

async.mapSeries() (如前所述,包括 async.eachSeries()) 至关于在promises列表上调用 reduce()。也就是说,你拿到输入列表,并用reduce产生一个promise,每一个操做都依赖于以前的操做是否成功。咱们来看一个例子:基于fs.rmdir()实现 rm -rf 。代码以下:

var dirs = ['a/b/c', 'a/b', 'a'];
async.mapSeries(dirs, fs.rmdir, function(error) {});

至关于:

var dirs     = ['a/b/c', 'a/b', 'a'],
    fs_rmdir = promisify(fs.rmdir);

var rm_rf = dirs.reduce(function(promise, path) {
  return promise.then(function() { return fs_rmdir(path) });
}, unit());

rm_rf.then(
    function() {},
    function(error) {}
);

其中的 unit()只是为了产生一个已解决的promise已启动操做链(若是你知道monads,这就是给promises的return 函数):

// unit :: a -> Promise a
var unit = function(a) {
  var promise = new Promise();
  promise.resolve(a);
  return promise;
};

reduce()只是取出路径列表中的每对目录,用promise.then()根据上一步操做是否成功来执行路径删除操做。这样能够处理非空目录:若是上一个promise因为某种错误被rejecte了,操做链就会终止。用值之间的依赖关系限定执行顺序是函数式语言借助monads处理反作用的核心思想。

最后这个例子的代码比async版本繁琐得多,但不要被它骗了。关键是领会精神,要将彼此不相干的promise值和list操做结合起来组装程序,而不是依赖定制的流程控制库。如您所见,前一种方式写出来的程序更容易理解。

准确地讲,它之因此容易理解,是由于咱们把一部分思考的过程交给机器了。若是用async模块,咱们的思考过程是这样的:

  • A.程序中这些任务间的依赖关系是这样的
  • B.所以各操做的顺序必须是这样
  • C.而后咱们把B所表达的意思写成代码吧

用promises依赖图能够跳过步骤B。代码只要表达任务之间的依赖关系,而后让电脑去设定控制流。换种说法,callback用显式的控制流把不少细小的值粘到一块儿,而promises用显式的值间关系把不少细小的控制流粘到一块儿。Callback是指令式的,promises是函数式的。

若是最终没有一个完整的promises应用,而且是体现函数式编程核心思想 laziness的应用,咱们对这个话题的讨论就不算完整。Haskell是一门懒语言,也就是说它不会把程序当成从头运行到尾的脚本,而是从定义程序输出的表达式开始,向stdio、数据库中写了什么等等,以此向后推导。它寻找最终表达式的输入所依赖的那些表达式,按图反向探索,直到计算出程序产生输出所需的一切。只有程序为完成任务而须要计算的东西才会计算。

解决计算机科学问题的最佳解决方案一般都是找到能够对其建模的准确数据结构。Javascript有一个与之很是类似的问题:模块加载。咱们只想加载程序真正须要的模块,并且想尽量高效地完成这个任务。

在 CommonJS 和 AMD出现以前,咱们确实就已经有依赖的概念了,脚本加载库有一大把。大多数的工做方式都跟前面的例子差很少,明确告诉脚本加载器哪些文件能够并行下载,哪些必须按顺序来。基本上都必须写出下载策略,要想作到正确高效,那是至关困难,跟简单描述脚本间的依赖关系,让加载器本身决定顺序比起来简直太坑人了。

接下来开始介绍LazyPromise的概念。这是一个promise对象,其中会包含一个可能作异步工做的函数。这个函数只在调用promise的then()时才会被调用一次:即只在须要它的结果时才开始计算它。这是经过重写then()实现的,若是工做还没开始,就启动它。

var Promise = require('rsvp').Promise,
    util    = require('util');

var LazyPromise = function(factory) {
  this._factory = factory;
  this._started = false;
};
util.inherits(LazyPromise, Promise);

LazyPromise.prototype.then = function() {
  if (!this._started) {
    this._started = true;
    var self = this;

    this._factory(function(error, result) {
      if (error) self.reject(error);
      else self.resolve(result);
    });
  }
  return Promise.prototype.then.apply(this, arguments);
};

好比下面这个程序,它什么也不作:由于咱们根本没要过promise的结果,因此不用干活:

var delayed = new LazyPromise(function(callback) {
  console.log('Started');
  setTimeout(function() {
    console.log('Done');
    callback(null, 42);
  }, 1000);
});

但若是加上下面这行,程序就会输出Started,过了一秒后,在输出Done和42:

delayed.then(console.log);

但既然这个工做只作一次,调用then()会屡次输出结构,但并不会每次都执行任务:

delayed.then(console.log);
delayed.then(console.log);
delayed.then(console.log);

// prints:
// Started
// -- 1 second delay --
// Done
// 42
// 42
// 42

用这个很是简单的通用抽象,咱们能够随时搭建一个优化模块系统。假定咱们要像下面这样建立一堆模块:每一个模块都有一个名字,一个依赖模块列表,以及一个传入依赖项,返回模块API的工厂函数。跟AMD的工做方式很是像。

var A = new Module('A', [], function() {
  return {
    logBase: function(x, y) {
      return Math.log(x) / Math.log(y);
    }
  };
});

var B = new Module('B', [A], function(a) {
  return {
    doMath: function(x, y) {
      return 'B result is: ' + a.logBase(x, y);
    }
  };
});

var C = new Module('C', [A], function(a) {
  return {
    doMath: function(x, y) {
      return 'C result is: ' + a.logBase(y, x);
    }
  };
});

var D = new Module('D', [B, C], function(b, c) {
  return {
    run: function(x, y) {
      console.log(b.doMath(x, y));
      console.log(c.doMath(x, y));
    }
  };
});

这里出了一个钻石的形状:D依赖于B和C,而它们每一个都依赖于A。也就是说咱们能够加载A,而后并行加载B和C,两个都到位后加载D。可是,咱们但愿工具能本身找出这个顺序,而不是由咱们本身写出来。

这很容易实现,咱们把模块看成LazyPromise的子类型来建模。它的工厂只要用咱们前面那个list promise辅助函数获得依赖项的值,而后再通过一段模拟的加载时间后用那些依赖项构建模块。

var DELAY = 1000;

var Module = function(name, deps, factory) {
  this._factory = function(callback) {
    list(deps).then(function(apis) {
      console.log('-- module LOAD: ' + name);
      setTimeout(function() {
        console.log('-- module done: ' + name);
        var api = factory.apply(this, apis);
        callback(null, api);
      }, DELAY);
    });
  };
};
util.inherits(Module, LazyPromise);

由于 Module 是 LazyPromise, 只是像上面那样定义模块不会加载。咱们只在须要用这些模块的时候加载它们:

D.then(function(d) { d.run(1000, 2) });

// prints:
// 
// -- module LOAD: A
// -- module done: A
// -- module LOAD: B
// -- module LOAD: C
// -- module done: B
// -- module done: C
// -- module LOAD: D
// -- module done: D
// B result is: 9.965784284662087
// C result is: 0.10034333188799373

如上所示,最早加载的是A,完成后同时开始下载B和C,在两个都完成后加载D,跟咱们想的同样。若是调用C.then(function() {}),那就只会加载A和C;不在依赖关系图中的模块不会加载。

因此咱们几乎没怎么写代码就建立了一个正确的优化模块加载器,只要用lazy promises的图就好了。咱们用函数式编程中值间关系的方式代替了显式声明控制流的方式,比咱们本身写控制流容易得多。对于任何一个非循环得依赖关系图,这个库都能用来替你优化控制流。

这就是promises真正强大的地方。它不只能在语法层面上规避缩进金字塔,还能让你在更高层次上对问题建模,而把底层工做交给工具完成。真的,那应该是咱们全部码农对咱们的软件提出的要求。若是Node真的想让并发编程更容易,他们应该再好好看看promises。

相关文章
相关标签/搜索