在《深入浅出 Node.js》的第 4 章里,笔者深入地介绍了当前盛行在 Node 和前端 JavaScript 中的几种异步编程的解决方案,唯独对 Generator 的解决方案没有介绍。但随着 Node 版本的升级和 ECMAScript harmony 的特性不断得到支持,在 0.11 版本中,我们可以通过启用--harmory
参数让 V8 支持 Generator。最近 Connect/Express 背后的开发团队也将精力转移到新的库和框架上,这个核心库和框架就是co
和koa
,它们最主要的特点主要就是基于 ECMAScript harmony 中的 Generator 特性,这使得它在异步编程方面有较优雅的实现。
本文将深度介绍下 Generator 是如何实现将异步编程从原始的嵌套式代码转换成扁平的顺序式代码。
异步编程问题回顾
简单地回顾下,异步编程的问题主要有两个:一个是必须通过回调函数进行返回值的处理,另一个是复杂情况下会造成嵌套过深。下面简单的给出两种典型的异步场景。
异步串行读取文件:
fs.readFile('file1.txt', 'utf8', function (err, txt) { if (err) { throw err; } fs.readFile(txt, 'utf8', function (err, content) { if (err) { throw err; } console.log(content); }); });
异步并行读取文件:
fs.readFile('file1.txt', 'utf8', function (err, txt) { if (err) { throw err; } console.log(txt); }); fs.readFile('file2.txt', 'utf8', function (err, content) { if (err) { throw err; } console.log(content); });
上述两种场景下,可以看到串行时由于代码嵌套,调用越多,会造成代码越糟糕。对于异步并行读取文件的代码,难点是无法获知并行异步调用完全完成的时间点,要解决这个问题需要借助各种异步流程库。
目前解决异步流程控制问题的主流方案有以下三种:
- 自定义事件式方案
- Promise/Deferred
- 高阶函数篡改回调函数
由于在《异步编程》章节中已经充分介绍了上述三种方式的实现形式,这里不再详细展开三种方式的细节。但是为了行文承前启后,这里简单回顾下第三种方案的实现。
高阶函数在异步编程中的使用
高阶函数在异步编程中的使用,最广泛、最知名的莫过于async
和step
这两个库,它将用户正常传递进来的回调函数替换成自己包装了特殊逻辑的函数,然后再传递给异步调用。当异步调用结束后,先执行的是特殊逻辑,然后才是用户传入的回调函数。以一个简单的场景为例,假设需要等待所有异步回调执行完成后,才能执行某个逻辑,这时通过高阶函数篡改回调函数的方式就大为受用,也相当简单。以下为简单实现:
var pending = (function () { var count = 0; return function (callback) { count++; return function () { count--; if (count === 0) { callback(); } }; }; }()); var done = pending(function () { console.log('all is over'); }); fs.readFile('file1.txt', 'utf8', done()); fs.readFile('file2.txt', 'utf8', done());
上述代码中,done
执行了两次,每次执行的过程中,将计数器count
加一,然后返回一个函数。当fs.readFile
这个异步调用结束后,done
执行后的回调函数会得到执行,计数器减一。当计数器回到 0 的时候,意味着多个异步调用的回调函数都已经执行,此时执行传入的回调函数。因为非阻塞的原因,done()
生成的函数不会立即执行,使得计数器可以正常地增加值,结束后才慢慢减少值。
抛开异步调用不谈,高阶函数的试用上,要让用户传入的函数能得到执行。需要如下这种方式的调用:
var done = pending(function () { console.log('all is over'); }); done()();
这里生成的函数被立即调用了,count
这个计数器加一后,立即减一,然后触发等于零的条件,于是回调函数被执行了。
在通过 Generator 解决异步编程问题的方案中,高阶函数与 Generator 之间会产生相当微妙的化学反应。
Generator 扫盲
Generator 的中文翻译是生成器,它是 ECMAScript6(代号 harmory)中提供的新特性。在过去,封装一段运算逻辑的单元是函数。函数只存在“没有被调用”或者“被调用”的情况,不存在一个函数被执行之后还能暂停的情况,而 Generator 的出现让这种情况成为可能。
Generator 的定义
Generator 的定义十分简单,与普通的函数相比,它只多出一个*
号。以下为简单例子:
var compute = function* (a, b) { var sum = a + b; console.log(sum); var c = a - b; console.log(c); var d = a * b; console.log(d); var e = a / b; console.log(e); };
这个星号只要出现在关键字function
和函数名之间即可。如果是匿名函数,出现在function
和参数列表的起始括号之间即可。
定义的 Generator 实际上可以理解为定义了一种特殊的数据结构,要得到 Generator 实例,还需要执行它一次:
var generator = compute(4, 2);
这样我们能得到一个 Generator 对象。Generator 对象具有一个next
方法。要使得定义中封装的代码逻辑得到执行,还得需要调用一次next
方法才行。
generator.next();
调用之后的输出结果如下:
$ node --harmony-generators examples/compute.js 6 2 8 2
yield 关键字
单独地介绍 Generator 没有太大价值,因为它除了更复杂外,功能与普通函数没有太大差别。真正让 Generator 具有价值的是yield
关键字,这个yield
关键字让 Generator 内部的逻辑能够切割成多个部分。下面是简单的示例:
var compute = function* (a, b) { var sum = a + b; yield console.log(sum); var c = a - b; yield console.log(c); var d = a * b; yield console.log(d); var e = a / b; console.log(e); };
加入yield
关键字后,我们继续将其实例化,然后调用.next()
方法:
var generator = compute(4, 2); generator.next();
可以看到输出如下:
$ node --harmony-generators examples/compute.js 6
上面的输出意味着代码执行到第一个yield
关键字的时候就停止了。要让业务逻辑继续执行完,需要反复调用.next()
。
var generator = compute(4, 2); generator.next(); // 6 generator.next(); // 2 generator.next(); // 8 generator.next(); // 2
可以简单地理解为yield
关键字将程序逻辑划分成几部分,每次.next()
执行时执行一部分。这使得程序的执行单元再也不是函数,复杂的逻辑可以通过yield
来暂停。从朴素的角度来看,这类似于将一个函数的逻辑分拆为四个函数,但它们共享上下文。
yield
除了切割逻辑外,它与.next()
的行为息息相关。每次.next()
调用时,返回一个对象,这个对象具备两个属性,其中一个属性是布尔型的done
。它表示这个 Generator 对象的逻辑块是否执行完成。另一个属性是value
,它来自于yield
语句后的表达式的结果。我们将代码简单修改,可以看到效果:
var compute = function* (a, b) { var sum = a + b; yield sum; var c = a - b; yield c; var d = a * b; yield d; var e = a / b; return e; }; var generator = compute(4, 2); console.log(generator.next()); // { value: 6, done: false } console.log(generator.next()); // { value: 2, done: false } console.log(generator.next()); // { value: 8, done: false } console.log(generator.next()); // { value: 2, done: true }
上述是yield
对.next()
行为的影响,反之,.next()
也能影响到yield
。可以简单地猜测下如下代码中会打印出什么结果:
var compute = function* (a, b) { var foo = yield a + b; console.log(foo); };
也许读者会以为是a + b
的值,但是这里不是:默认情况下,这个foo
打印出来是undefined
。那么.next()
如何影响yield
的呢?答案在于可以通过.next()
传递参数,赋值给yield
关键字前面的变量声明。见下面的简单示例:
var compute = function* (a, b) { var foo = yield a + b; console.log(foo); }; var generator = compute(4, 2); generator.next(); generator.next("Hello world!"); // Hello world!
所以,对于 Generator 而言,它不仅可以将逻辑的单元切分得更细外,还能在暂停和继续执行的间隔中,动态传入数据,使得代码逻辑可以更灵活。相比普通函数,Generator 的特性相当令人期待。
Generator 与异步编程
初见之下,Generator 似乎与异步编程之间还八竿子打不着。但前文的高阶函数和 Generator 的介绍已经为我们准备好了基础。我们继续揭开 Generator 是如何与异步编程摩擦出闪亮的火花的。
在拥有了yield
关键字后,我们可以很巧妙地处理异步调用的回调函数。以顺序读取两个文件的场景为例:
fs.readFile('file1.txt', 'utf8', function (err, txt) { if (err) { throw err; } fs.readFile(txt, 'utf8', function (err, content) { if (err) { throw err; } console.log(content); }); });
如果我们要完成这两个操作,而且不以嵌套的方式进行,我们可以很自然想到以yield
来分割两个操作。
var flow = function* () { var txt = yield fs.readFile('file1.txt', 'utf8'); var content = yield fs.readFile(txt, 'utf8'); console.log(content); };
这里虽然没有写入回调函数,但我们可以想象,如果回调函数执行的时候触发这个 Generator 执行一次.next()
,然后将返回结果通过.next(result)
这样的形式传入,这样尽管是异步调用,但代码编写形式已经近乎顺序式了。
想象是美好的,现实能否实现是另一回事。要完成上面的目的,需要做的事情有两步:
- 需要在回调函数中置入逻辑,用于收集回调函数传递的数据。
- 通过
.next()
传入异步执行的结果,传递给yield
,让业务流程继续进行。
改造异步方法
为了完成收集异步调用的结果数据,我们必须得借助高阶函数。如下是一个修改回调函数逻辑的函数:
var helper = function (fn) { return function () { var args = [].slice.call(arguments); var pass; args.push(function () { // 在回调函数中植入收集逻辑 if (pass) { pass.apply(null, arguments); } }); fn.apply(null, args); return function (fn) { // 传入一个收集函数 pass = fn; }; }; };
这个函数的参数是一个异步调用函数,调用之后,得到一个新的函数,这个函数在重新整理了参数列表后,添加了一个实际被调用到的回调函数。这个新的函数执行后,会调用真正的异步函数,然后再次返回一个函数,最后返回的函数的作用是为了随时注入新的逻辑(pass
)。在参数列表后添加的回调函数中,它会将结果传递给最终给到的函数。
用语言来解释这段代码有点吃力,我们以fs.readFile
调用为实际例子,重点参见下文的注释:
var readFile = helper(fs.readFile); // => // function () { // var args = [].slice.call(arguments); // var pass; // args.push(function () { // 在回调函数中植入收集逻辑 // if (pass) { // pass.apply(null, arguments); // } // }); // fn.apply(null, args); // // return function (fn) { // 传入一个收集函数 // pass = fn; // }; // } var flow = function* () { var txt = yield readFile('file1.txt', 'utf8'); console.log(txt); }; var generator = flow(); var ret = generator.next(); // 执行 readFile('file1.txt', 'utf8'); // ret.value => // function (fn) { // pass = fn; // }
可以看到上面的代码,我们已经成功的将flow
这个 Generator 的第一部分代码执行起来,我们可以通过ret.value
来尝试植入一段特殊的逻辑,同时在异步调用结束后,将数据取出,同时执行 Generator 的下一部分逻辑。完整代码如下:
var generator = flow(); var ret = generator.next(); // 执行 readFile('file1.txt', 'utf8'); ret.value(function (err, data) { if (err) { throw err; } generator.next(data); });
通过这样置入特殊逻辑后,使得flow
中的代码能够按期望顺利执行,通过yield
巧妙地将回调函数得到的值转换为类似返回值。
为了让所有的 Generator 能适应这种情况,我们设计一个流程控制函数,用来专门控制此类操作。
设计流程控制函数
为了向 TJ 大神致敬,这个函数我们暂时命名为co
。它要进行的操作是让 Generator 启动,在 Generator 暂停的时候,植入逻辑。简单实现如下:
var co = function (flow) { var generator = flow(); var next = function (data) { var result = generator.next(data); if (!result.done) { result.value(function (err, data) { if (err) { throw err; } next(data); }); } }; next(); };
代码中通过递归调用来完成 Generator 中流程的执行,调用示例如下:
co(function* () { var txt = yield readFile('file1.txt', 'utf8'); console.log(txt); var txt2 = yield readFile('file2.txt', 'utf8'); console.log(txt2); });
执行结果如下:
$ node --harmony-generators flow.js I am file1. I am file2.
如此我们就完成从嵌套函数的写法转换为顺序式的编写,深度嵌套带来的“地狱之门”将成为历史。
并行执行
前面的例子中,已经成功地将嵌套代码转换为顺序的扁平的代码,但是异步调用之间仍然是串行执行,这使得我们无法享受到 Node 中并行 I/O 的好处。为此我们还需要改进co
函数,使得异步调用能并行执行,同时依旧保证代码的顺序。
为此,我们约定当yield
后的表达式结果是一个数组时,表示里面为多个异步调用。简单的修改如下:
var co = function (flow) { var generator = flow(); var next = function (data) { var ret = generator.next(data); if (!ret.done) { if (Array.isArray(ret.value)) { var count = 0; var results = []; ret.value.forEach(function (item, index) { count++; item(function (err, data) { count--; if (err) { throw err; } results[index] = data; if (count === 0) { next(results); } }); }); } else { ret.value(function (err, data) { if (err) { throw err; } next(data); }); } } }; next(); };
调用示例如下,当为数组时,应当并行执行:
co(function* () { var results = yield [readFile('file1.txt', 'utf8'), readFile('file2.txt', 'utf8')]; console.log(results[0]); console.log(results[1]); var file3 = yield readFile('file3.txt', 'utf8'); console.log(file3); });
执行结果如下:
$ node --harmony-generators parallel.js I am file1. I am file2. I am file3.
为了验证异步调用是并行进行的,我们换用定时器来测试:
var _sleep = function (ms, fn) { setTimeout(fn, ms); }; var sleep = helper(_sleep); co(function* () { console.time('sleep1'); yield sleep(1000); yield sleep(1000); console.timeEnd('sleep1'); console.time('sleep2'); yield [sleep(1000), sleep(1000)]; console.timeEnd('sleep2'); });
执行结果如下:
$ node --harmony-generators sleep.js sleep1: 2004ms sleep2: 1005ms
从sleep2
的输出来看,yield
关键字后的数组中的所有异步调用得到并行执行。
小结
至此,我们小结一下通过 Generator 进行流程控制的几个要点。首先,每个异步方法都需要标准化为yield
关键字能接受的方法,使我们有机会注入特殊逻辑,这个过程被 TJ 称为thunkify
。其次,需要巧妙地将异步调用执行完成得到的结果通过.next()
传递给下一段流程。最后,需要递归地将业务逻辑执行完成。
需要注意的是yield
只能暂停 Generator 内部的逻辑,它并不是真正暂停整个线程,Generator 外的业务逻辑依然会继续执行下去。
向下兼容
我们知道 Generator 和yield
关键字的特性来自于 ECMAScript6,这意味着目前 Node 主流的 0.10 版本上无法享受到这些令人激动的特性;另外,Generator 的语法与现行的语法不兼容,导致无法进行 shim 式的兼容。但是事情也并不绝对,这些难题并不能难倒 geek 们丰富的想象力。来自 Facebook 的工程师发布了一个 regenerator 的工具,它将 ECMAScript6 中的 Generator 和yield
语法重新编译,使得编译出的代码能够在 ECMAScript5 下执行,同时达到相同的效果。
下面的代码,我们尝试通过regenerator
进行编译:
var flow = function* () { console.time('sleep1'); yield sleep(1000); yield sleep(1000); console.timeEnd('sleep1'); console.time('sleep2'); yield [sleep(1000), sleep(1000)]; console.timeEnd('sleep2'); };
结果如下:
var flow = wrapGenerator.mark(function() { return wrapGenerator(function($ctx0) { while (1) switch ($ctx0.prev = $ctx0.next) { case 0: console.time('sleep1'); $ctx0.next = 3; return sleep(1000); case 3: $ctx0.next = 5; return sleep(1000); case 5: console.timeEnd('sleep1'); console.time('sleep2'); $ctx0.next = 9; return [sleep(1000), sleep(1000)]; case 9: console.timeEnd('sleep2'); case 10: case "end": return $ctx0.stop(); } }, this); });
这段代码中的wrapGenerator.mark
和wrapGenerator
来自于regenerator
生成的依赖中,由于代码太多,暂不给出。如需查看,可以尝试以下命令:
$ regenerator -r generator.js
如果编写的模块想兼容 ECMAScript5 和 ECMAScript6,可以尝试通过regenerator
生成两套代码,在 ECMAScript5 下,引入编译后的代码,在 ECMAScript6 下,引入 Generator 和yield
语法的代码。示例如下:
module.exports = supportES6 ? require('./lib/index.js') : require('./es5/index.js');
如果要直接体验 Generator 的特性,可以尝试安装gnode
这个模块。它的原理在 0.11.x 的版本上是启用--harmory-generators
,在 0.10.x 下则是调用regenerator
将代码编译为 ECMAScript5 支持的代码,然后再执行。
总结
Generator 的出现,使得流程控制可以更细腻,通过co
和suspend
库,我们几乎已经完全实现了流程控制的线性处理,同时还能享受到并行异步的性能提升,在不损失性能的情况下,大大提升了编程体验。简单而言,即使只为这个特性,ECMAScript6 也值得期待。
参考
感谢杨赛对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论