用ES6 Generator实现异步

本文根据 https://davidwalsh.name/async-generators 翻译

现在你已经了解了ES6 genderators和关于他们更深入的一些内容,是时候真正利用他们来改善我们的代码了。

generators的主要能力在于提供了一个单线程,看起来同步的代码风格,而隐藏了异步实现的细节。这让我们可以用非常自然的方式来实现程序步骤/描述,能够同时避开异步语法和陷阱。

换句话说,通过分离值的使用(generator的逻辑)和异步填充值的实现细节(generator迭代器的next(..)),我们达成了一个很好的能力/关注分离。

结果是?我们能够得到所有的异步代码的能力,同时还具备简单的易维护的同步样式代码。

那么我们该如何完成这一壮举呢?

简单的异步

最简单的,对于不具备异步能力的程序,generators不需要任何额外的处理。

比如,想象下有下面这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function makeAjaxCall(url,cb) {
// do some ajax fun
// call `cb(result)` when complete
}

makeAjaxCall( "http://some.url.1", function(result1){
var data = JSON.parse( result1 );

makeAjaxCall( "http://some.url.2/?id=" + data.id, function(result2){
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
});
} );

使用generator(不需要额外的修饰)来表达相同的程序,你可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function request(url) {
// this is where we're hiding the asynchronicity,
// away from the main code of our generator
// `it.next(..)` is the generator's iterator-resume
// call
makeAjaxCall( url, function(response){
it.next( response );
} );
// Note: nothing returned here!
}

function *main() {
var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );

var result2 = yield request( "http://some.url.2?id=" + data.id );
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
}

var it = main();
it.next(); // get it all started

让我们来检查下他是怎么工作的

request(..)辅助方法主要是包裹正常的makeAjaxCall(..)单元,用来确保它的回调会调用generator迭代器的next(..)方法。

随着request(..)的调用,你会注意到它没有返回值(换句话说,他的返回值是undefined)。这没有什么大不了的,但是对于对比我们如何验证文章后面所说的事情:我们有效的在这里yield了一个undfined,这有点重要。

然后我们调用yield ..(使用undefined的值),他基本上不做什么只是在这里暂停了generator。它将等待直到it.next(..)调用然后恢复执行,它排队等待Ajax调用结束后发生(作为回调函数)。

但是对于yield ..表达式的结果具体发生了什么?我们把它赋值给了变量result1. 它是怎么得到第一个Ajax调用的结果的呢?

因为当it.next(..)被作为ajax的回调调用的时候,它传递了ajax的返回,意味着这个值被回传回给当时暂停的那个generator,在中间的result1 = yield ..语句这。

这真的很酷而且很强大。本质上,result1 = yield request(..)请求数据,但他的实现细节对我们透明(完全的!)—— 至少在这里我们不需要去关心 —— 尽管在这个实现的覆盖下这个步骤是异步的。他完成了通过yield隐式暂停的能力实现异步,且分离出generator的恢复功能给另外一个函数,使得我们的主要代码只看起来只是创建了一个同步风格的值请求。

同样的,第二个result2 = yield result(..)语句:他透明化了暂停&恢复,并且给了我们需要的值,在这里我们不需要关心任何的异步的细节。

当然yield是存在的,稍微提示了有些神奇的东西(指异步)可能发生在这里。但是yield是一个很小的语法信号/开销,相比嵌套回调噩梦(甚至是promise链!)。

当然也要注意我说的“可能发生”,这本身是一个非常强大的东西。上面的程序总是产生一个异步的ajax请求,但如果它没有呢?如果后面改变我们程序的是一个之前ajax响应的缓存(或者预取)呢?或者某些复杂的应用程序URL路由能够以正确的方式来填充ajax请求,而不需要实际到服务器去获取呢?

我们可以像这样来改变request(..)的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var cache = {};

function request(url) {
if (cache[url]) {
// "defer" cached response long enough for current
// execution thread to complete
setTimeout( function(){
it.next( cache[url] );
}, 0 );
}
else {
makeAjaxCall( url, function(resp){
cache[url] = resp;
it.next( resp );
} );
}
}

注意:一个很微妙的细节,在这里需要用setTimeout(..0)延迟到已经有结果被缓存。如果我们只是直接调用it.next(..), 将会触发一个错误,因为(且这是一个比较麻烦的部分)generator技术上还没有处于暂停状态。函数调用request(..)最先被执行,然后才是yield暂停。所以我们当前不能立即调用request(..)中的it.next(。。), 因为此刻generator依然在执行(yield还没被处理)。但我们可以调用it.next(..)“之后”,在当前线程执行完成后立即调用,这就是我们的setTimeout(..0)所做的事情。接下来会有一个更好的答案。

而,我们主要的generator代码是这样的:

1
2
3
var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
..

看见了吧!?generator逻辑(指我们的流控制)相比上面的没有缓存的版本,不需要做任何改变。

*main()代码中依然只是请求值,并且暂停直到获取到值才继续。在当前的方案中,“暂停”可能比较长(创建一个实际的服务端请求,可能在300-800ms左右)或者几乎立即(setTimeout(..0)延迟hack)。但我们的控制流不需要关心。

这是把异步实现的细节抽象出来的真正力量。

更好的异步

上面描述对于说明一个简单的异步generator工作是不错的。但是它很快将成为限制,我们需要一个更强大的异步机制与generators来配对,用来处理更多繁琐的事情。这个机制是?Promises。

如果你仍然对ES6 Promise有点迷惑,我写了一个连续的5篇系列博客来介绍它。先去看看,我会等你回来的。(  ̄ ▽ ̄)o╭╯ 一个老掉牙的笑话。

早期的ajax代码的离职在这里遭受了相同的控制反转问题(指“回调金字塔”)以我们初始嵌套回调的例子。目前位置对于我们来说,一些观察是缺乏的:
//Some observations of where things are lacking for us so far:

  1. 错误处理没有明确的路径。正如我们在之前的文章中了解到的,我们可以监测ajax调用(未知)的错误,通过it.throw(..)把他传递给generator, 然后在generator逻辑中使用try..catch处理它。但是这更多的是指人工操作,在“后端”(指代码处理我们的generator迭代器逻辑),它可能不是我们能重用的代码,如果我们在程序中使用了大量的generators.
  2. 如果makeAjaxCall(..)本来就不在我们的控制下,并且它会多次调用回调函数,或者同时有成功或者失败的信号,等等,那么我们的generator将会失控(导致错误,非预期的值,等等)。处理和防止这样的问题是大量重复的手工工作,也可能是不可复用的。
  3. 我们经常需要多个任务“并行”(就像两个同步的ajax调用)。由于generator yield语句一次只做一个暂停,无法两个或者更多同时执行 —— 他们必须按顺序一次一个地执行。因此,并不是很清楚如何在不需要大量人工代码的覆盖下,在一个单一的generator yield点上触发多个任务。

如你所见,所有的这些问题都是可以解决的,但谁真的想每次都需要改造这些解决方案。我们需要一个更强大的模式,专门设计的值得信赖的可重用的基于generator的异步编码解决方案。

这个模式?yielding出promises, 然后当promise被fulfill的时候让他们回复generator.

重新回顾上面我们做过的yield request(..), request(..)并没有任何返回值,因此它的效果只是yield了一个undefined?

让我们稍微调整一下。把我们的request(..)改造成基于promise的,让它返回一个promise, 那么我们返回的实际上就是一个promise了(不是undefined).

1
2
3
4
5
6
function request(url) {
// Note: returning a promise now!
return new Promise( function(resolve,reject){
makeAjaxCall( url, resolve );
} );
}

request(..)现在构造了一个promise,它将在ajax调用结束的时候被resolve,并且我们返回了这个promise,所以它能够被yield出来。那么接下来呢?

我们需要一个程序能够控制我们的generator的迭代器,它将接收yield出来的promises并且引导他们恢复generator(通过next(..)). 现在我将调用runGenerator(..).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// run (async) a generator to completion
// Note: simplified approach: no error handling here
function runGenerator(g) {
var it = g(), ret;

// asynchronously iterate over generator
(function iterate(val){
ret = it.next( val );

if (!ret.done) {
// poor man's "is it a promise?" test
if ("then" in ret.value) {
// wait on the promise
ret.value.then( iterate );
}
// immediate value: just send right back in
else {
// avoid synchronous recursion
setTimeout( function(){
iterate( ret.value );
}, 0 );
}
}
})();
}

需要注意的关键点:

  1. 我们自动初始化了generator(创建了他的it迭代器),并且我们异步地将它执行到结束(done:true).
  2. 我们确认promise被yield出来(指每一个it.next(..)调用的返回值)。如果是,我们通过在promise上注册then(..)等待它完成。
  3. 如果有任何的立即值被返回出来(指非promise), 我们简单的把值送回generator因此他立即继续。

现在,我们怎么使用?

1
2
3
4
5
6
7
8
runGenerator( function *main(){
var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );

var result2 = yield request( "http://some.url.2?id=" + data.id );
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
} );

Bam!等下… 这确实跟之前的generator代码一样么? 是的。再次的,generator的能力被显示出来了。事实上,我们现在创建了promise,yield出了他们,并且在他们完成的时候恢复了generator —— 所有这些都隐藏了实现细节! 这不是真的隐藏,只是从消费代码中分离了出来(我们的控制流在我们的generator中)。

通过在yield出来的promise上进行等待,然后发送他的完成值到it.next(..)result1 = yield request(..)同之前一样得到了他需要的准确值。

现在我们使用了promises来管理generator代码中的异步部分,我们解决了所有callback-only代码实现中的反转/信任问题。我们通过使用generator+promise解决了所有的上面提到的所有问题。

现在我们有了一些容易跟踪的内建的错误处理。我们没有在上面提到的runGenerator(..)中体现它,但从promise中监听错误,并且把他们导向it.throw(..)一点不难 —— 之后我们可以在generator代码中使用try..catch来捕获和处理这些错误。

我们收到了所有promise提供的控制/可信赖。没有担心,没有大惊小怪。Promises有很多强大的抽象能够自动处理复杂和多样化的“并行”任务,等等。

例如,yield Promise.all([..])可以处理一个promises数组的并行任务,并且yield出一个单一的promise(为了generator的处理),它等待所有的子promise完成(无关顺序).你从yield表啊时中得到的(当promise完成时)是一个所有子promise响应的数组,依照他们被请求的顺序。(所以他是确定的,无关完成顺序)

首先,让我们探索下错误处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// assume: `makeAjaxCall(..)` now expects an "error-first style" callback (omitted for brevity)
// assume: `runGenerator(..)` now also handles error handling (omitted for brevity)

function request(url) {
return new Promise( function(resolve,reject){
// pass an error-first style callback
makeAjaxCall( url, function(err,text){
if (err) reject( err );
else resolve( text );
} );
} );
}

runGenerator( function *main(){
try {
var result1 = yield request( "http://some.url.1" );
}
catch (err) {
console.log( "Error: " + err );
return;
}
var data = JSON.parse( result1 );

try {
var result2 = yield request( "http://some.url.2?id=" + data.id );
} catch (err) {
console.log( "Error: " + err );
return;
}
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
} );

如果一个promise在url获取发生的时候产生了拒绝(因为某些错误/非预期),promise拒绝将被映射到generator错误(使用那个——没有显示出来的——runGenerator(..)中的it.throw(..)),它将被try..catch语句捕获。

现在,让我们看看更多复杂的例子,他们使用了promise来管理更多复杂的异步调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function request(url) {
return new Promise( function(resolve,reject){
makeAjaxCall( url, resolve );
} )
// do some post-processing on the returned text
.then( function(text){
// did we just get a (redirect) URL back?
if (/^https?:\/\/.+/.test( text )) {
// make another sub-request to the new URL
return request( text );
}
// otherwise, assume text is what we expected to get back
else {
return text;
}
} );
}

runGenerator( function *main(){
var search_terms = yield Promise.all( [
request( "http://some.url.1" ),
request( "http://some.url.2" ),
request( "http://some.url.3" )
] );

var search_results = yield request(
"http://some.url.4?search=" + search_terms.join( "+" )
);
var resp = JSON.parse( search_results );

console.log( "Search results: " + resp.value );
} );

Promise.all([ .. ])构建了一个promise它等待三个子promises,并且它是那个主要的promise被yield出来给 runGenerator(..)单元用来监听generator的恢复。 子promises可以接受一个看起来像是从另外一个URL重定向过来的响应,并且链式返回另外一个到新地址的子请求的promise,跟多关于promis chain的信息,请阅读这篇文章。

任何一种能力/复杂性,promises可以处理异步,你可以通过使用generator yield出一个promise(的promise的promise的…)来利用同步代码的好处。这是这两个世界最好的东西。

runGenerator(..): 代码库实用工具

我们必须定义我们自己的runGenerator(..)实用工具来开启并且无缝连接generator+promise的魅力。我们甚至(为了简洁起见)忽略这样一个工具的全部实现,为了有更多的跟处理错误相关的细微差别。

但是,你并不想自己来写runGenerator(..)吧?

是的。

大量的promise/async库提供了这样的工具。这里我不能介绍全部的,但你可以看看Q.spawn(..), co(..),等等

然而我将主要介绍下我自己的库的工具:asynquencerunner(..)插件,我认为它提供了一些比其他出众的独特的功能。我写了一个深入的分两部分关于asynquence的系列文章,如果你有兴趣可以学到比这里更多的东西。

首先,asynquence提供了自动处理“错误有限风格”回调的工具:

1
2
3
4
5
6
function request(url) {
return ASQ( function(done){
// pass an error-first style callback
makeAjaxCall( url, done.errfcb );
} );
}

这很不错,不是么?

其次,asynquence的runner(..)插件正确消耗一个异步队列中的generator(步骤的异步序列),所以你可以从前面的步骤中得到消息,而且你的generator可以传出消息给下一步,并且所有的错误都自动按预期的传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// first call `getSomeValues()` which produces a sequence/promise,
// then chain off that sequence for more async steps
getSomeValues()

// now use a generator to process the retrieved values
.runner( function*(token){
// token.messages will be prefilled with any messages
// from the previous step
var value1 = token.messages[0];
var value2 = token.messages[1];
var value3 = token.messages[2];

// make all 3 Ajax requests in parallel, wait for
// all of them to finish (in whatever order)
// Note: `ASQ().all(..)` is like `Promise.all(..)`
var msgs = yield ASQ().all(
request( "http://some.url.1?v=" + value1 ),
request( "http://some.url.2?v=" + value2 ),
request( "http://some.url.3?v=" + value3 )
);

// send this message onto the next step
yield (msgs[0] + msgs[1] + msgs[2]);
} )

// now, send the final result of previous generator
// off to another request
.seq( function(msg){
return request( "http://some.url.4?msg=" + msg );
} )

// now we're finally all done!
.val( function(result){
console.log( result ); // success, all done!
} )

// or, we had some error!
.or( function(err) {
console.log( "Error: " + err );
} );

asynqunece的runner(…)工具接受(可选的)信息来开始generator,来自队列的前一个步骤,而且可以and are accessible in the generator in the token.messages array.

然后,类似我们上面展示的runGenerator(..)工具的效果,runner(..)监听每一个yield出的promise或者yield出的异步队列(在这个例子中,是“并行”步骤的ASQ().all(..)), 并且等待它完成后重启generator.

当generator完成,最后的值被yield出传递给队列的下一个步骤。

此外,发生在这个队列中任何地方的错误,甚至generator内部的错误,都将被冒泡到单独的or(..)的错误处理方法中。

asynquence试图使混合和匹配promises和generators变得尽可能简单。你可以自由的引导任何generator流沿着基于promise的序列流动,以你认为合适的方式。

ES7 async

ES7的时间表上有一个提议,看起来相当有可能被接纳,创建另外一种函数:async函数,就像一个genderator被自动包装在像runGenerator(..)(或者是asynqunece的runner(..))这样的实用工具里面。这种方式下,你可以传出一个promise并且异步方法会自动引导他们自己在完成的时候恢复(甚至不需要处理迭代器!)。

它看起来像这样:

1
2
3
4
5
6
7
8
9
10
async function main() {
var result1 = await request( "http://some.url.1" );
var data = JSON.parse( result1 );

var result2 = await request( "http://some.url.2?id=" + data.id );
var resp = JSON.parse( result2 );
console.log( "The value you asked for: " + resp.value );
}

main();

如你所见,一个异步函数能够直接被调用(就像main()), 不需要包装成像runGenerator(..)或者ASQ().runner(..)这样的工具。在函数内部,你将使用await(另外一个新的关键字)取代yield,告诉async函数处理前等待promise完成。

基本上,我们将有大部分的包装生成器的库的能力,但是是直接通过内建的语法直接支持的。

很酷,不是么!?

同时,像asynquence这样的库给我们提供了这些runner工具来使得创建异步generators变得相当简单。

总结

简言之:generator+yielded promises结合了两个世界中最好的东西获得了真正强大且优雅的同步风格的异步控制流表达能力。使用简单包装工具集(很多库已经提供),我们可以自动运行我们的generator直到完成,同时包含明确和同步的错误处理!

而且在ES7+中,我们将可能会看到async函数,它甚至能让我们摆脱这些库工具来实现这些工作(至少例子中的这些)!

async在Javascript的前途光明,而且只会越来越好!亮瞎我的眼。

但是还没完,我们还有最后一项要去挖掘:

你能否把2个或者更多的generators绑在一起,让他们独立运行但是“并行的”,并且让他们发回他们处理的消息?这将是一些更强大的能力,不是么!?!这种模式叫做“CSP”(通信顺序进程)。我们将在下一篇文章中解锁CSP的能力。保持关注!