ES6 Generators的异步应用

  ES6 Generators系列:

  1. ES6 Generators基本概念
  2. 深入研究ES6 Generators
  3. ES6 Generators的异步应用
  4. ES6 Generators并发

  通过前面两篇文章,我们已经对ES6 generators有了一些初步的了解,是时候来看看如何在实际应用中发挥它的作用了。

  Generators最主要的特点就是单线程执行,同步风格的代码编写,同时又允许你将代码的异步特性隐藏在程序的实现细节中。这使得我们可以用非常自然的方式来表达程序或代码的流程,而不用同时还要兼顾如何编写异步代码。

  也就是说,通过generator函数,我们将程序具体的实现细节从异步代码中抽离出来(通过next(..)来遍历generator函数),从而很好地实现了功能和关注点的分离。

  其结果就是代码易于阅读和维护,在编写上具有同步风格,但却支持异步特性。那如何才能做到这一点呢?

 

最简单的异步

  一个最简单的例子,generator函数内部不需要任何异步执行代码即可完成整个异步过程的调用。

  假设你有下面这段代码:

function makeAjaxCall(url,cb) {
    // ajax请求
    // 完成时调用cb(result)
}

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函数来实现上面代码的逻辑:

function request(url) {
    // 这里的异步调用被隐藏起来了,
    // 通过it.next(..)方法对generator函数进行迭代,
    // 从而实现了异步调用与main方法之间的分离
    makeAjaxCall( url, function(response){
        it.next( response );
    } );
    // 注意:这里没有return语句!
}

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(); // 开始

  解释一下上面的代码是如何运行的。

  方法request(..)是对makeAjaxCall(..)的封装,确保回调能够调用generator函数的next(..)方法。请注意request(..)方法中没有return语句(或者说返回了一个undefined值),后面我们会讲到为什么要这么做。

  Main函数的第一行,由于request(..)方法没有任何返回值,所以这里的yield request(..)表达式不会接收任何值进行计算,仅仅暂停了main函数的运行,直到makeAjaxCall(..)在ajax的回调中执行it.next(..)方法,然后恢复main函数的运行。那这里yield表达式的结果到底是什么呢?我们将什么赋值给了变量result1?在Ajax的回调中,it.next(..)方法将Ajax请求的返回值传入,这个值会被yield表达式返回给变量result1

  是不是很酷!这里,result1 = yield request(..)事实上就是为了得到ajax的返回结果,只不过这种写法将回调隐藏起来了,我们完全不用担心,因为其中具体的执行步骤就是异步调用。通过yield表达式的暂停功能,我们将程序的异步调用隐藏起来,然后在另一个函数(ajax的回调)中恢复对generator函数的运行,整个过程使得我们的main函数的代码看起来就像是在同步执行一样

  语句result2 = yield result(..)的执行过程与上面一样。代码执行过程中,有关generator函数的暂停和恢复完全是透明的,程序最终将我们想要的结果返回回来,而所有的这些都不需要我们将注意力放在异步代码的编写上。

  当然,代码中少不了yield关键字,这里暗示着可能会有一个异步调用。不过这和地狱般的嵌套回调(或者promise链)比起来,代码看起来要清晰很多。

  注意上面我说的yield关键字的地方是“可能”会出现一个异步调用,而不是一定会出现。在上面的例子中,程序每次都会去调用一个Ajax的异步请求,但如果我们修改了程序,将之前Ajax响应的结果缓存起来,情况会怎样呢?又或者我们在程序的URL请求路由中加入某些逻辑判断,使其立即就返回Ajax请求的结果,而不是真正地去请求服务器,情况又会怎样呢?

  我们将上面的代码改成下面这个版本:

var cache = {};

function request(url) {
    if (cache[url]) {
        // 延迟返回缓存中的数据,以保证当前执行线程运行完成
        setTimeout( function(){
            it.next( cache[url] );
        }, 0 );
    }
    else {
        makeAjaxCall( url, function(resp){
            cache[url] = resp;
            it.next( resp );
        } );
    }
}

  注意上面代码中的setTimeout(..)语句,它会延迟返回缓存中的数据。如果我们直接调用it.next(..)程序会报错,这是因为generator函数目前还不是处于暂停状态。主函数在调用完request(..)之后,generator函数才会处于暂停状态。所以,我们不能在request(..)函数内部立即执行it.next(..),因为此时的generator函数仍然处于运行中(即yield表达式还没有被处理)。不过我们可以稍后再调用it.next(..)setTimeout(..)语句将会在当前执行线程完成后立即执行,也就是在request(..)方法执行完后再执行,这正是我们想要的。下面我们会有更好的解决方案。

  现在,我们的main函数的代码依然是这样:

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

  瞧!我们的程序从不带缓存的版本改成了带缓存的版本,但是main函数却不用做任何修改。*main()函数依然只是请求一个值,然后暂停运行,直到请求返回一个结果,然后再继续运行。当前程序中,暂停的时间可能会比较长(实际Ajax请求大概会在300-800ms之间),但也可能是0(使用setTimeout(..0)延迟的情况)。无论是哪种情况,我们的主流程是不变的。

  这就是将异步过程抽象为实现细节的真正力量!

 

改进的异步

  以上方法仅适用于一些简单异步处理的generator函数,很快你就会发现在大多数实际应用中根本不够用,所以我们需要一个更强大的异步处理机制来匹配generator函数,使其能够发挥更大的作用。这个处理机制是什么呢?答案就是promises. 如果你对ES6 Promises还不了解,可以看看这里的一篇文章: http://blog.getify.com/promises-part-1/

  在前面的Ajax示例代码中,无一例外都会遇到嵌套回调的问题(我们称之为回调地狱)。到目前为止我们还有一些东西没有考虑到:

  1. 有关错误处理。在前一篇文章中我们已经介绍过如何在generator函数中处理错误,我们可以在Ajax的回调中判断是否出错,并通过it.throw(..)方法将错误传递给generator函数,然后在generator函数中使用try..catch语句来处理它。但这无疑会带来许多工作量,而且如果程序中有很多generator函数的话,代码也不容易重用。
  2. 如果makeAjaxCall(..)函数不在我们的控制范围内,并且它会多次调用回调,或者同时返回success和error等等,那么我们的generator函数将会陷于混乱(未处理的异常,返回意外的值等)。要解决这些问题,你可能需要做很多额外的工作,这显然很不方便。
  3. 通常我们需要“并行”来处理多个任务(例如同时发起两个Ajax请求),由于generator函数的yield只允许单个暂停,因此两个或多个yield不能同时运行,它们必须按顺序一个一个地运行。所以,在不编写大量额外代码的前提下,很难在generator函数的单个yield中同时处理多个任务。

  上面的这些问题都是可以解决的,但是谁都不想每次都面对这些问题然后从头到尾地解决一遍。我们需要一个功能强大的设计模式,能够作为一个可靠的并且可以重用的解决方案,应用到我们的generator函数的异步编程中。这种模式要能够返回一个promises,并且在完成之后恢复generator函数的运行。

  回想一下上面代码中的yield request(..)表达式,函数request(..)没有任何返回值,但实际上这里我们是不是可以理解为yield返回了一个undefined呢?

  我们将request(..)函数改成基于promises的,这样它会返回一个promise,所以yield表达式的计算结果也是一个promise而不是undefined

function request(url) {
    // 注意:现在返回的是一个promise!
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } );
}

  现在,request(..)函数会构造一个Promise对象,并在Ajax调用完成之后进行解析,然后返回一个promise给yield表达式。然后呢?我们需要一个函数来控制generator函数的迭代,这个函数会接收所有的这些yield promises然后恢复generator函数的运行(通过next(..)方法)。我们假设这个函数叫runGenerator(..)

// 异步调用一个generator函数直到完成
// 注意:这是最简单的情况,不包含任何错误处理
function runGenerator(g) {
    var it = g(), ret;

    // 异步迭代给定的generator函数
    (function iterate(val){
        ret = it.next( val );

        if (!ret.done) {
            // 简单测试返回值是否是一个promise
            if ("then" in ret.value) {
                // 等待promise返回
                ret.value.then( iterate );
            }
            // 立即执行
            else {
                // 避免同步递归调用
                setTimeout( function(){
                    iterate( ret.value );
                }, 0 );
            }
        }
    })();
}

  几个关键的点:

  1. 程序会自动初始化generator函数(创建迭代器it),然后异步运行直到完成(done:true)。
  2. 查看yield是否返回一个promise(通过it.next(..)返回值中的value属性来查看),如果是,则等待promise中的then(..)方法执行完。
  3. 任何立即执行的代码(非promise类型)将会直接返回结果给generator函数,然后继续运行。

  现在我们来看看如何使用它。

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

  等等!这不是和本文一开始的那个generator函数一样吗?是的。不过在这个版本中,我们创建了promises并返回给yield,等promise完成之后恢复generator函数继续运行。所有这些操作都“隐藏”在实现细节中!不过不是真正的隐藏,我们只是将它从消费代码(这里指的是我们的generator函数中的流程控制)中分离出去而已。

  Yield接受一个promise,然后等待它完成之后返回最终的结果给it.next(..)。通过这种方式,语句result1 = yield request(..)能够得到和之前一样的结果。

  现在我们使用promises来管理generator函数中异步调用部分的代码,从而解决了在回调中所遇到的各种问题:

  1. 拥有内置的错误处理机制。虽然我们并没有在runGenerator(..)函数中显示它,但是从promise监听错误并非难事,一旦监听到错误,我们可以通过it.throw(..)将错误抛出,然后通过try..catch语句捕获和处理这些错误。
  2. 我们通过promises来控制所有的流程。这一点毋庸置疑。
  3. 在自动处理各种复杂的“并行”任务方面,promises拥有十分强大的抽象能力。例如,yield Promise.all([..])接收一个“并行”任务的promises数组,然后yield一个单个的promise(返回给generator函数处理),这个单个的promise会等待数组中所有的promises全部处理完之后才会开始,但这些promises的执行顺序无法保证。当所有的promises执行完后,yield表达式会接收到另外一个数组,数组中的值是每个promise返回的结果,按照promise被请求的顺序依次排列。

  首先我们来看一下错误处理:

// 假设:`makeAjaxCall(..)` 是“error-first”风格的回调(为了简洁,省略了部分代码)
// 假设:`runGenerator(..)` 也具备错误处理的功能(为了简洁,省略了部分代码)

function request(url) {
    return new Promise( function(resolve,reject){
        // 传入一个error-first风格的回调函数
        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 );
} );

  在request(..)函数中,makeAjaxCall(..)如果出错,会返回一个promise的rejection,并最终映射到generator函数的error(在runGenerator(..)函数中通过it.throw(..)方法抛出错误,这部分细节对于消费端来说是透明的),然后在消费端我们通过try..catch语句最终捕获错误。

  下面我们来看一下复杂点的使用promises异步调用的情况:

function request(url) {
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } )
    // 在ajax调用完之后获取返回值,然后进行下一步操作
    .then( function(text){
        // 查看返回值中是否包含URL
        if (/^https?:\/\/.+/.test( text )) {
            // 如果有则继续调用这个新的URL
            return request( text );
        }
        // 否则直接返回调用的结果
        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,当所有的子promises都完成之后,将返回的结果通过yield表达式传递给runGenerator(..)函数并恢复运行。在request(..)函数中,每个子promise通过链式操作对response的值进行解析,如果其中包含另一个URL则继续请求这个URL,如果没有则直接返回response的值。有关promise的链式操作可以查看这篇文章: http://blog.getify.com/promises-part-5/#the-chains-that-bind-us

  任何复杂的异步处理,你都可以通过在generator函数中使用yield promise来完成(或者promise的promise链式操作),这样代码具有同步风格,看起来更加简洁。这是目前最佳的处理方式。

 

runGenerator(..)工具库

  我们需要定义我们自己的runGenerator(..)工具来实现上面介绍的generator+promises模式。为了简单,我们甚至可以不用实现所有的功能,因为这其中有很多的细节需要处理,例如错误处理的部分。

  但是你肯定不想亲自来写runGenerator(..)函数吧?反正我是不想。

  其实有很多的开源库提供了promise/async工具,你可以免费使用。这里我就不去一一介绍了,推荐看看Q.spawn(..)co(..)等。

  这里我想介绍一下我自己写的一个工具库:asynquence的插件runner。因为我认为和其它工具库比起来,这个插件提供了一些独特的功能。我写过一个系列文章,是有关asynquence的,如果你有兴趣的话可以去读一读。

  首先,asynquence提供了一系列的工具来自动处理“error-first”风格的回调函数。看下面的代码:

function request(url) {
    return ASQ( function(done){
        // 这里传入了一个error-first风格的回调函数 - done.errfcb
        makeAjaxCall( url, done.errfcb );
    } );
}

  看起来是不是会好很多?

  接下来,asynquence的runner(..)插件消费了asynquence序列(异步调用序列)中的generator函数,因此你可以从序列的从上一步中传入消息,然后generator函数可以将这个消息返回,继续传到下一步,并且这其中的任何错误都将自动向上抛出,你不用自己去管理。来看看具体的代码:

// 首先调用`getSomeValues()`创建一个sequence/promise,
// 然后将sequence中的async链起来
getSomeValues()

// 使用generator函数来处理获取到的values
.runner( function*(token){
    // token.messages数组将会在前一步中赋值
    var value1 = token.messages[0];
    var value2 = token.messages[1];
    var value3 = token.messages[2];

    // 并行调用3个Ajax请求,并等待它们全部执行完(以任何顺序)
    // 注意:`ASQ().all(..)`类似于`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 )
    );

    // 将message发送到下一步
    yield (msgs[0] + msgs[1] + msgs[2]);
} )

// 现在,将前一个generator函数的最终结果发送给下一个请求
.seq( function(msg){
    return request( "http://some.url.4?msg=" + msg );
} )

// 所有的全部执行完毕!
.val( function(result){
    console.log( result ); // 成功,全部完成!
} )

// 或者,有错误发生!
.or( function(err) {
    console.log( "Error: " + err );
} );

  Asynquence runner(..)从sequence的上一步中接收一个messages(可选)来启动generator,这样在generator中可以访问token.messages数组中的元素。然后,与我们上面演示的runGenerator(..)函数一样,runner(..)负责监听yield promise或者yield asynquence(一个ASQ().all(..)包含了所有并行的步骤),等待完成之后再恢复generator函数的运行。当generator函数运行完之后,最终的结果将会传递给sequence中的下一步。此外,如果这其中有错误发生,包括在generator函数体内产生的错误,都将会向上抛出或者被错误处理程序捕捉到。

  Asynquence试图将promises和generator融合到一起,使代码编写变得非常简单。只要你愿意,你可以随意地将任何generator函数与基于promise的sequence联系到一起。

ES7 async

  在ES7的计划中,有一个提案非常不错,它创建了另外一种function:async function。有点像generator函数,它会自动包装到一个类似于我们的runGenerator(..)函数(或者asynquence的runner(..)函数)的utility中。这样,就可以自动地发送promisesasync function并在它们执行完后恢复运行(甚至都不需要generator函数遍历器了!)。

  代码看起来就像这样:

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

  Async function可以被直接调用(上面代码中的main()语句),而不用像我们之前那样需要将它包装到runGenerator(..)或者ASQ.runner(..)函数中。在函数内部,我们不需要yield,取而代之的是await(另一个新加入的关键字),它会告诉async function等待promise完成之后才会继续运行。将来我们会有更多的generator函数库都支持本地语法。

  是不是很酷?

  同时,像asynquence runner这样的库一样,它们会给我们在异步generator函数编程方面带来极大的便利。

 

总结

  一句话,generator + yield promise(s)模式功能是如此强大,它们一起使得对同步和异步的流程控制变得行运自如。伴随着使用一些包装库(很多现有的库都已经免费提供了),我们可以自动执行我们的generator函数直到所有的任务全部完成,并且包含了错误处理!

  在ES7中,我们很可能将会看到async function这种类型的函数,它使得我们在没有第三方库支持的情况下也可以做到上面说的这些(至少对于一些简单情况来说是可以的)。

  JavaScript的异步在未来是光明的,而且只会越来越好!我坚信这一点。

  不过还没完,我们还有最后一个东西需要探索:

  如果有两个或多个generators函数,如何让它们独立地并行运行,并且各自发送自己的消息呢?这或许需要一些更强大的功能,没错!我们管这种模式叫“CSP”(communicating sequential processes)。我们将在下一篇文章中探讨和揭秘CSP的强大功能。敬请关注!

posted @ 2017-03-03 00:38  Jaxu  阅读(2133)  评论(0编辑  收藏  举报