美文网首页node
nodejs深入学(5)异步编程

nodejs深入学(5)异步编程

作者: 白昔月 | 来源:发表于2017-12-14 14:49 被阅读7425次

    前言

    上一章讲解了node如何通过事件循环实现异步,包括与各种IO多路复用搭配实现的异步IO已经与IO无关的异步API。

    以前,之所以异步IO在应用层面不太流行,是因为异步编程在流程控制中,业务表达并不太适合程序员开发。

    函数式编程

    函数式编程是js异步编程的基础。

    高阶函数

    在js中,函数的参数可以为基本数据类型、对象引用,甚至是一个函数(函数也是一种对象)。同理,函数的返回值也可以是基本数据类型、对象引用,甚至是一个函数。

    function foo(x) {
    return function () {
    return x;
    };
    }
    

    那么高阶函数就是把函数作为参数或是返回值的一类函数的称谓。这就形成了一种后续传递风格(Continuation Passing Style)的结果接收方式,这种风格将编程重点从关注返回值,转移到了回调函数中。

    function foo(x, bar) {
    return bar(x);
    }
    
    //例如sort()
    
    var points = [40, 100, 1, 5, 25, 10];
    points.sort(function(a, b) {
    return a - b;
    });
    // [ 1, 5, 10, 25, 40, 100 ]
    

    通过改动sort()方法的参数,可以决定不同的排序方式,从这里就可以看出高阶函数的灵活性了。

    结合node提供的最基本的事件模块可以看出,事件的处理方式正是基于高阶函数的特性来完成的,在自定义事件实例中,通过为相同事件注册不同的回调函数,可以很灵活的处理业务逻辑。

    var emitter = new events.EventEmitter();
    emitter.on('event_foo', function () {
    // TODO
    });
    

    这本书里时常提到事件可以十分方便的进行复杂业务逻辑的解耦,它其实受益于高阶函数。(ES5中提供的一些数组方法都是高阶函数,forEach()map()、 reduce()、 reduceRight()、 filter()、 every()、 some())

    偏函数用法

    书中对于偏函数的描述,十分的拗口.....“偏函数用法是指创建一个调用另外一个部分(参数或变量已经预置的函数)的函数的用法。”.....这简直就是翻译界的灾难呀,我们来看看例子:

    var toString = Object.prototype.toString;
    var isString = function (obj) {
        return toString.call(obj) == '[object String]';
    };
    var isFunction = function (obj) {
        return toString.call(obj) == '[object Function]';
    };
    

    上述代码的业务逻辑很简单,但是却需要我们重复定义相同的部分,如果有跟多的isXXX(),就会出现更多的冗余代码,为了解决重复定义的问题,我们引入新的函数,这个函数可以如工厂一样批量创建一些类似的函数。我们看一下改造:

    var isType = function (type) {
        return function (obj) {
            return toString.call(obj) == '[object ' + type + ']';
        };
    };
    var isString = isType('String');
    var isFunction = isType('Function');
    

    引入isType()函数后,创建方法就简单了,这个根本就是js的工厂模式嘛,这种通过指定部分参数来产生一个新的定制函数的形式就是偏函数。

    偏函数在异步编程中十分

    异步编程的优势与难点

    单线程容易阻塞服务器,多线程因为存在锁、线程间状态同步以及多线程之间的上下文切换,用起来也是需要一定经验和积累的。当然,如果你是c/c++技术大牛,你可以通过c/c++调用操作系统底层接口,自己手工完成异步IO,这样性能可以提升很多,但是调试开发门槛则十分高了,不是新入行的菜鸟小白们能玩耍的了的。(不熟不生巧。)

    优势

    node的优势在于基于事件驱动的非阻塞IO模型,这个模型使得非阻塞IO可以使cpu计算与IO相互解耦,让资源得到更好的利用。我们来看一个图进一步说明node的异步IO模型:

    node的异步IO模型

    下边是同步IO模型,在这里再次进行对比,虽然上一章已经反复讲解了,但是在这里作者还是给大家重新描述,我想这应该是作者希望每一章都可以独立阅读的缘故。

    同步IO模型

    在第三章中,node的异步IO利用了事件循环方式,js线程像分配任务和处理结果的大管家,IO线程池里的各个IO都是小二,负责兢兢业业的完成分配的任务,小二与管家之间没有依赖,可以保持整体的高效率。(前端编程、ios开发等都是这样的)

    书中在此处补充了第一章说的如何分解任务的方法来应对cpu密集型的程序:

    由于事件循环模型需要应对海量请求,海量请求同时作用在单线程上,就需要防止任何一个计算耗费过多的cpu时间片。至于是计算密集型,还是IO密集型,只要计算不影响异步IO的调度,那就不构成问题。建议对cpu的耗用不要超过10ms,或者将大量的计算分解为诸多的小量计算,通过setImmediate()进行调度。只要合理利用node的异步模型与V8的高性能,就可以充分发挥cpu和IO资源的优势。

    难点

    异步编程跟传统的同步编程还是有很大差异的,此处哦说的难点主要是针对同步编程来说的,也就是同步编程可以很好解决的问题,但是在异步编程中变成了难点。

    难点 描述 解决
    异常处理 无法利用try/catch/final的方式捕获异常,也就是说对于回调抛出的异常,使用传统的同步异常抓取办法是抓不到的。 将回调函数的第一个实参作为err回传,如果为null则没有异常,如果有err对象,则发生了异常。这也就要求我们在写异步程序时,第一,要有回调函数,第二,要正确设置回调函数的参数,并且将第一个参数设置为err,第三,要确保在回调函数内部发生错误时正确的传递了这个错误。
    函数嵌套过深 callback hell 最新的解决方案是使用async/await来将异步变同步
    阻塞代码 因为node是单线程程序,因此没有sleep()来阻塞程序 使用setTimeout()来阻塞程序,但是这个方案也未必就好,我们已经从第三章了解了这个异步API的一些知识,因此,阻塞代码的做法,不要在node中出现,尽量还是利用异步事件编程,来实现业务。
    多线程 node的js执行方式是单线程的 node没有web workers,同时,web workers虽然解决了利用cpu和减少阻塞ui渲染的问题,但是还是不能解决ui渲染效率的问题。因此,在node层面,使用了child_process作为基础的解决API方案,同时还提供了cluster模块作为更深层次的应用解决方案。node借助了这个web worker的模式,通过多进程的方式,调用了操作系统层面的多线程。
    异步转同步 还是那个回调问题 最新的解决方案是使用async/await来将异步变同步

    异常处理的正确参数传递

    异常处理的正确参数传递的示例代码。

    var async = function (callback) {
    process.nextTick(function() {
    var results = something;
    if (error) {
    return callback(error);
    }
    callback(null, results);
    });
    };
    

    此处说一个题外话,在以前还有一种基于node核心模块domain进行异常处理的方式,代码如下:

    var d = require('domain').create();
    d.on('error', function (err) {
        logger.fatal('Domain master', { message: err.message, stack: err.stack });
        process.exit(0);
    });
    d.run(function () {
    ...
    ...
    

    但是这个方法经常会造成整个程序奔溃,因此,已经不建议使用了。

    我们来看看前端的web workers

    浏览器提高了web workers来将js执行和ui渲染分离,并通过web workers的消息传递来调度多核cpu进行运算。

    web workers的工作示意图

    异步编程解决方案

    因为,这本书写作时还没有推出node v7.6和es2015规范,因此,书中介绍的解决方案并不完美,目前通过async/await的方式,已经可以完美解决这个问题了。(目前看来是完美的)

    我们来看看书中介绍的解决方案,分别是:事件发布/订阅模式、Promise/Deferred模式、流程控制库

    事件发布/订阅模式

    事件发布/订阅模式,其实就是回调函数的事件化。这个功能基于的是node自身提供的events模块,换句话说这个应该就是很多node事件回调语法的中间层实现的这部分了。所谓事件发布订阅,也可以理解为是浏览器中发布一个按钮,然后为浏览器注册这个按钮监听的相关事件,然后,点击这个按钮。这个events模块提供了addListener/on()、once()、removeListener()、removeAllListeners()、emit()等基本的事件监听模式的方法实现。示例代码如下:

    // 订阅
    emitter.on("event1", function (message) {
    console.log(message);
    });
    // 发布
    emitter.emit('event1', "I am message!");
    

    可以看到订阅事件就是一个高阶函数的应用,事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又被称为事件监听器。(这个类似于浏览器中使用onclick="doSomething();doSomethingElse();"这样的事件关联方式)通过emit()发布事件后,消息会立即传递给当前事件的所有侦听器执行。侦听器可以很灵活的添加和删除,使得事件和具体处理逻辑之间可以很轻松的关联和解耦。

    事件发布/订阅模式自身并无同步和异步调用的问题,但在node中,emit()调用多半是伴随事件循环而异步触发的,所以,事件发布/订阅广泛应用于异步编程。

    事件发布/订阅模式常常用来解耦业务逻辑,事件发布者无需关注订阅的侦听器如何实现业务逻辑,甚至不用关注有多少个侦听器存在数据通过消息的方式可以很灵活的传递。这个就非常像面向对象设计中的接口设计了,接口设计者只需要规定都要哪些接口,实现哪些功能,而具体的接口实现都在子类中由开发程序员们来实现。

    因此,在node中,事件的设计其实就是组件的接口设计。

    从另外一个角度来看,事件侦听器模式也是一种钩子hook机制,利用钩子导出内部数据或状态给外部的调用者。Node中的很多对象大多具有黑盒的特点,功能点较少,如果不通过事件钩子的形式,我们就无法获取对象在运行期间的中间值或内部状态。这种通过事件钩子的方式,可以是编程人员不用关注组件是如何启动和执行的,只需要关注在需要的事件点上即可。因此,我们不需要了解内部运行的机制,只需要关注关键数据就行了。例如http请求,就是这样一个场景:

    var options = {
        host: 'www.google.com',
        port: 80,
        path: '/upload',
        method: 'POST'
    };
    var req = http.request(options, function (res) {
        console.log('STATUS: ' + res.statusCode);
        console.log('HEADERS: ' + JSON.stringify(res.headers));
        res.setEncoding('utf8');
        res.on('data', function (chunk) {
            console.log('BODY: ' + chunk);
        });
        res.on('end', function () {
            // TODO
        });
    });
    req.on('error', function (e) {
        console.log('problem with request: ' + e.message);
    });
    // write data to request body
    req.write('data\n');
    req.write('data\n');
    req.end();
    

    在这段http请求中,程序员只需要将视线放在error、data、end上即可,至于内部流程如何,我们不需要过度关注。

    注意:
    1.如果对一个事件添加了超过10个侦听器,将会得到一条警告,因为,有可能造成内存泄漏。调用emitter.setMaxListeners(0);可以去掉这个限制。但是,设置太多的侦听器,也可能造成占用cpu过多的情况发生。
    2.为了处理异常,EventEmitter对象对error事件进行了特殊对待,如果运行期间的错误触发了error事件,EventEmitter会检查是否有对error事件添加过侦听器,如果添加了,这个错误将会交由该侦听器处理,否则这个错误将会作为异常抛出。如果外部没有捕获这个异常,将会引起线程退出。因此,一定要对error事件进行处理。

    事件的实现

    1.继承events模块:
    实现一个继承EventEmitter的类是十分简单的,我们来看一下这个代码:

    var events = require('events');
    function Stream() {
        events.EventEmitter.call(this);
    }
    util.inherits(Stream, events.EventEmitter);
    

    node在util模块中封装了继承的方法,所以此次可以很方便的调用。开发者可以通过这样的方式轻松继承EventEmitter类。在node核心模块中,几乎有近一半的模块都继承自EventEmitter。这个也是node事件驱动的一个实现的基础。

    雪崩问题和解决方案

    雪崩问题,是因为高访问量和大并发的情况下,造成缓存失效,大量的请求同时涌入数据库中,使得数据库无法同时承受如此大的查询需求,从而影响整个应用的性能的一种情况。

    我们可以利用事件队列解决雪崩问题。我们利用once()方法,使得通过它添加的侦听器只能执行一次,在执行之后就会将他与事件的关联移除。这个特性可以帮助我们过滤一些重复的事件响应。

    例如如下代码:

    var select = function (callback) {
        db.select("SQL", function (results) {
            callback(results);
        });
    };
    

    如果应用刚刚启动,这时缓存中没有数据,而访问量巨大,同一句SQL会被发到数据库中反复查询,影响整体性能。我们可以增加一个状态锁:

    var status = "ready";
    var select = function (callback) {
        if (status === "ready") {
            status = "pending";
            db.select("SQL", function (results) {
                status = "ready";
                callback(results);
            });
        }
    };
    

    这样虽然解决了访问量大的问题,但是,除了第一访问存在数据,后续的访问都获取不到数据了,前端的请求就得不到正确的结果了,因此,这时就需要用once()来解决了:

    var proxy = new events.EventEmitter();
    var status = "ready";
    var select = function (callback) {
        proxy.once("selected", callback);
        if (status === "ready") {
            status = "pending";
            db.select("SQL", function (results) {
                proxy.emit("selected", results);
                status = "ready";
            });
        }
    };
    

    这里我们利用了once()方法,将所有请求的回调都压入事件队列中,利用其执行一次就将监视移除的特点,保证每一个回调只会被执行一次。对于相同的SQL语句,保证在同一个查询开始到结束的过程中永远只有一次。sql在进行查询时,新到来的相同调用只需要在队列中等待数据就绪即可,这样就可以保证同一个查询从开始到结束的过程中,只有一次。新来的相同调用,只需要在队列中等待数据就绪即可,一旦查询结束,得到的结果可以被这些调用共同使用。这种方式能节省重复的数据库调用产生的开销,由于node单线程执行的原因,此处无需担心状态同步的问题,这种方式其实也可以应用到其他远程调用的场景中,即使外部没有缓存策略,也可以有效的节省重复开销。

    此处可能会存在注册事件过多引发的警告,需要调用setMaxListeners(0),移除警告,或者设置更大的警告阈值。

    多异步之间的协作方案

    产生这个的原因是,一般情况下,事件监听器和回调函数是一对多的关系,也就是有一个同类型的监听器,可以监听多个相同类型事件的响应。但是,也会存在多个事件监听器去响应一个回调函数的情况,这就是callback hell产生的原因,因为确实有这种需求,因此,在这里讲解一下多异步之间的协作方案。

    这里,作者想要通过node的原生代码,来解决callback hell的问题,我们来看一下下边这个代码,这里以渲染页面需要的模板读取、数据读取和本地化资源读取为例,简单介绍

    var count = 0;
    var results = {};
    var done = function (key, value) {
        results[key] = value;
        count++;
        if (count === 3) {
            // 渲染页面
            render(results);
        }
    };
    fs.readFile(template_path, "utf8", function (err, template) {
        done("template", template);
    });
    db.query(sql, function (err, data) {
        done("data", data);
    });
    l10n.get(function (err, resources) {
        done("resources", resources);
    });
    

    由于多个异步场景中,回调函数的执行并不能保证顺序,且回调函数间不会存在交集,因此,需要借助第三方函数和第三方变量来协助处理结果,这个变量就是用于检测执行次数的,它一般被称为哨兵变量。此处需要用到偏函数的相关知识。

    var after = function (times, callback) {
        var count = 0, results = {};
        return function (key, value) {
            results[key] = value;
            count++;
            if (count === times) {
                callback(results);
            }
        };
    };
    
    var done = after(times, render);
    done(1,"张")
    
    //例子
    after(1, results => {
        console.log('hello world ' + results[1]);
    })(1, "zhangz");
    
    //如果写成这样
    var after = function (times, callback) {
        var count = 0, results = {};
       
            callback(results);
            
    };
    
    //也是可以触发回调函数的
    after(1,results=>{
        console.log('hello world '+results[1]);
    });
    
    //但是就没有地方传递参数了,因此,最好写成返回函数的形式。
    
    

    上述方案实现了多对一的目的,如果业务继续增长,我们依然可以继续利用发布订阅方式,来完成多对多的方案:

    var emitter = new events.Emitter();
    var done = after(times, render);
    emitter.on("done", done);
    emitter.on("done", other);
    fs.readFile(template_path, "utf8", function (err, template) {
        emitter.emit("done", "template", template);
    });
    db.query(sql, function (err, data) {
        emitter.emit("done", "data", data);
    });
    l10n.get(function (err, resources) {
        emitter.emit("done", "resources", resources);
    });
    

    这种方案结合了前者简单的偏函数完成多对一的收敛和事件订阅/发布模式中一对多的发散。

    另外,朴灵自己写了一个叫做EventProxy的模块,也是来处理这种问题的,它对应事件发布订阅模式是一种补充。我们来感受一下:

    var proxy = new EventProxy();
    proxy.all("template", "data", "resources", function (template, data, resources) {
        // TODO
    });
    fs.readFile(template_path, "utf8", function (err, template) {
        proxy.emit("template", template);
    });
    db.query(sql, function (err, data) {
        proxy.emit("data", data);
    });
    l10n.get(function (err, resources) {
        proxy.emit("resources", resources);
    });
    

    EventProxy提供了一个all()方法来订阅多个事件,当每个事件被触发后,监听器才会被执行。使用tail()方法,让每个事件都顺序执行。另外,还有after,可以命令事件在多少次访问后,执行。

    var proxy = new EventProxy();
    proxy.after("data", 10, function (datas) {
    // TODO
    });
    

    EventProxy的原理

    EventProxy来自于Backbone的事件模型,我们来看一下相关代码

    // Trigger an event, firing all bound callbacks. Callbacks are passed the
    // same arguments as `trigger` is, apart from the event name.
    // Listening for `"all"` passes the true event name as the first argument
    trigger: function(eventName) {
        var list, calls, ev, callback, args;
        var both = 2;
        if (!(calls = this._callbacks)) return this;
        while (both--) {
            ev = both ? eventName : 'all';
            if (list = calls[ev]) {
                for (var i = 0, l = list.length; i < l; i++) {
                    if (!(callback = list[i])) {
                        list.splice(i, 1); i--; l--;
                    } else {
                        args = both ? Array.prototype.slice.call(arguments, 1) : arguments;
                        callback[0].apply(callback[1] || this, args);
                    }
                }
            }
        }
        return this;
    }
    

    EventProxy则是将all当做一个事件流的拦截层,在其中注入一些业务来处理单一事件无法解决的异步处理问题。类似的扩展方法还有all、tail、after、not、any

    EventProxy的异常处理

    根据commonjs的规范,异常处理都被封装在了回调函数的第一个err中。

    exports.getContent = function (callback) {
        var ep = new EventProxy();
        ep.all('tpl', 'data', function (tpl, data) {
            // 成功回调
            callback(null, {
                template: tpl,
                data: data
            });
        });
        // 监听error事件
        ep.bind('error', function (err) {
            //卸载掉所有处理函数
            ep.unbind();
            // 异常回调
            callback(err);
        });
        fs.readFile('template.tpl', 'utf-8', function (err, content) {
            if (err) {
                // 一旦发生异常,一律交给error事件的处理函数处理
                return ep.emit('error', err);
            }
            ep.emit('tpl', content);
        });
        db.get('some sql', function (err, result) {
            if (err) {
                // 一旦发生异常,一律交给error事件的处理函数处理
                return ep.emit('error', err);
            }
            ep.emit('data', result);
        });
    };
    exports.getContent = function (callback) {
        var ep = new EventProxy();
        ep.all('tpl', 'data', function (tpl, data) {
            // 成功回调
            callback(null, {
                template: tpl,
                data: data
            });
        });
        //绑定错误处理函数
        ep.fail(callback);
        fs.readFile('template.tpl', 'utf-8', ep.done('tpl'));
        db.get('some sql', ep.done('data'));
    };
    

    另外,此处还有一些编程技巧:例如

    ep.fail(callback);
    //等价于
    ep.fail(function (err) {
    callback(err);
    });
    //等价于
    ep.bind('error', function (err) {
    // 卸载掉所有处理函数
    ep.unbind();
    // 异常回调
    callback(err);
    });
    

    done()也可以变换实现

    ep.done('tpl');
    //等价于
    function (err, content) {
    if (err) {
    // 一旦发生异常,一律交给error事件的处理函数处理
    return ep.emit('error', err);
    }
    ep.emit('tpl', content);
    }
    

    又或者让done接受一个函数作为参数

    ep.done(function (content) {
    // TODO
    ep.emit('tpl', content);
    });
    
    //等价于
    
    function (err, content) {
    if (err) {
    
    return ep.emit('error', err);
    }
    (function (content) {
    // TODO
    ep.emit('tpl', content);
    }(content));
    }
    ep.done('tpl', function (content) {
    // content.replace('s', 'S');
    // TODO
    return content;
    });
    

    大家也可以不看朴灵关于eventproxy的这个介绍,把之前的原理弄明白即可。

    Promise/Deferred模式

    使用事件的方式时,执行流程需要被预先设定,即便是分支,也需要预先设定,这是由发布/订阅模式的运行机制所决定的,例如ajax:

    $.get('/api', {
    success: onSuccess,
    error: onError,
    complete: onComplete
    });
    

    我们还可以利用Promise/Deferred模式来先执行异步调用,延迟传递处理内容。jquery的作者们通过这个模式几乎重写了jquery 1.5 ,我们便可以这样调用ajax了:

    $.get('/api')
    .success(onSuccess)
    .error(onError)
    .complete(onComplete);
    

    在原始api中,一个事件只能处理一个回调,而通过Derferred对象,可以对事件加入任意的业务逻辑,如:

    $.get('/api')
    .success(onSuccess1)
    .success(onSuccess2);
    

    Promise/Deferred模式在CommonJS下抽象出了Promises/A、Promises/B、 Promises/D等模式,接下来我们就重点介绍一下Promises/A。

    Promises/A

    Promise/Deferred模式肯定包含Promise模式和Deferred模式两部分,Promises/A的行为就印证了这一点:

    1.Promises只会存在三种状态,未完成态、完成态、失败态
    2.状态只会从未完成态向完成态,或者从未完成态向失败态转化,过程不可逆,完成态和失败态也不会相互转化。
    3.状态一旦转化,将不能被更改。


    Promises状态转化

    Promises/A的实现非常简单,一个Promises对象只需要具备then()方法即可,这个then()有如下特点:

    1.接受完成态、错误态的回调方法,在操作完成或者出现错误时,将会调用对应方法。
    2.可选的支持progress事件回调作为第三方法
    3.then()方法只接受function对象,其余对象将被忽略。
    4.then()方法继续返回promise对象,以实现链式调用。

    then()方法的定义

    then(fulfilledHandler, errorHandler, progressHandler)
    

    我们使用events模块来实现then()

    var Promise = function () {
        EventEmitter.call(this);
    };
    util.inherits(Promise, EventEmitter);
    Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) {
        if (typeof fulfilledHandler === 'function') {
            this.once('success', fulfilledHandler);
        }
        if (typeof errorHandler === 'function') {
            this.once('error', errorHandler);
        }
        if (typeof progressHandler === 'function') {
            this.on('progress', progressHandler);
        }
        return this;
    }; 
    

    在这里我们看到,实现then()方法所做的事情,是将回调函数存放起来,为了完成整个流程,还需要触发执行这些回调函数的地方,实现这些功能的对象通常被称为Deferred,即延迟对象,示例代码如下:

    var Deferred = function () {
        this.state = 'unfulfilled';
        this.promise = new Promise();
    };
    Deferred.prototype.resolve = function (obj) {
        this.state = 'fulfilled';
        this.promise.emit('success', obj);
    };
    Deferred.prototype.reject = function (err) {
        this.state = 'failed';
        this.promise.emit('error', err);
    };
    Deferred.prototype.progress = function (data) {
        this.promise.emit('progress', data);
    };
    
    状态和方法之间的对应关系

    利用promise/a提议的模式,我们可以对一个典型的响应对象进行封装,代码如下:

    res.setEncoding('utf8');
    res.on('data', function (chunk) {
        console.log('BODY: ' + chunk);
    });
    res.on('end', function () {
        // Done
    });
    res.on('error', function (err) {
        // Error
    });
    
    //封装为
    
    res.then(function () {
        // Done
    }, function (err) {
        // Error
    }, function (chunk) {
        console.log('BODY: ' + chunk);
    });
    

    因此,实现promise只需要简单改造即可:

    var promisify = function (res) {
        var deferred = new Deferred();
        var result = '';
        res.on('data', function (chunk) {
            result += chunk;
            deferred.progress(chunk);
        });
        res.on('end', function () {
            deferred.resolve(result);
        });
        res.on('error', function (err) {
            deferred.reject(err);
        });
        return deferred.promise;
    };
    
    //执行代码
    
    promisify(res).then(function () {
    // Done
    }, function (err) {
    // Error
    }, function (chunk) {
    // progress
    console.log('BODY: ' + chunk);
    });
    
    

    注意:这里返回deferred.promise的目的是为了不让外部程序调用resolve()和reject()方法,更改内部状态的行为全部交由定义者处理。

    promise和deferred的关系

    deferred主要用于内部,用于维护异步模型状态,promise则作用于外部,通过then()方法,暴露给外部已添加自定义逻辑。

    因此综上所示,promise/Deferred模式与事件发布订阅模式相比,在api的接口和抽象上都进行了简化,它将业务中不可变的部分封装在了deferred中,可变部分交给了promise。从这里可以看出设计者的思路,事件发布订阅时直接使用了events,属于低级接口,用户可以自己实现任何业务逻辑,但是,都需要经过一些相对复杂的过程和对于业务的抽象以及接口的封装,promise/deferred从本质上看是对于events模块的抽象封装的一种,是对于经典场景的高度抽象,简洁好用,能够覆盖大部分业务场景的需要。

    第三方包:q

    q是promise/a的一个实现,通过npm install q。我们来看一下例子:

    /**
    * Creates a Node-style callback that will resolve or reject the deferred
    * promise.
    * @returns a nodeback
    */
    defer.prototype.makeNodeResolver = function () {
        var self = this;
        return function (error, value) {
            if (error) {
                self.reject(error);
            } else if (arguments.length > 2) {
                self.resolve(array_slice(arguments, 1));
            } else {
                self.resolve(value);
            }
        };
    };
    
    //如果基于q则变为:
    
    var readFile = function (file, encoding) {
    var deferred = Q.defer();
    fs.readFile(file, encoding, deferred.makeNodeResolver());
    return deferred.promise;
    };
    readFile("foo.txt", "utf-8").then(function (data) {
    // Success case
    }, function (err) {
    // Failed case
    });
    

    promise中的多异步协作

    因为,promise主要是用来解决单个异步操作而设计的,那么多个异步调用应该如何处理呢?我们给出一个简单的逻辑实现

    Deferred.prototype.all = function (promises) {
        var count = promises.length;
        var that = this;
        var results = [];
        promises.forEach(function (promise, i) {
            promise.then(function (data) {
                count--;
                results[i] = data;
                if (count === 0) {
                    that.resolve(results);
                }
            }, function (err) {
                that.reject(err);
            });
        });
        return this.promise;
    }
    
    var promise1 = readFile("foo.txt", "utf-8");
    var promise2 = readFile("bar.txt", "utf-8");
    var deferred = new Deferred();
    deferred.all([promise1, promise2]).then(function (results) {
    // TODO
    }, function (err) {
    // TODO
    });
    

    promise进阶知识

    我们先来看一个问题

    obj.api1(function (value1) {
        obj.api2(value1, function (value2) {
            obj.api3(value2, function (value3) {
                obj.api4(value3, function (value4) {
                    callback(value4);
                });
            });
        });
    });
    
    //然后利用普通函数,将代码并行地展开
    
    var handler1 = function (value1) {
        obj.api2(value1, handler2);
    };
    var handler2 = function (value2) {
        obj.api3(value2, handler3);
    };
    var handler3 = function (value3) {
        obj.api4(value3, hander4);
    };
    var handler4 = function (value4) {
        callback(value4);
    });
    obj.api1(handler1);
    
    //并行展开后,所以代码几乎是同时执行的,回调先后无法控制,因此,引入事件来控制
    var emitter = new event.Emitter();
    emitter.on("step1", function () {
        obj.api1(function (value1) {
            emitter.emit("step2", value1);
        });
    });
    emitter.on("step2", function (value1) {
        obj.api2(value1, function (value2) {
            emitter.emit("step3", value2);
        });
    });
    emitter.on("step3", function (value2) {
        obj.api3(value2, function (value3) {
            emitter.emit("step4", value3);
        });
    });
    emitter.on("step4", function (value3) {
        obj.api4(value3, function (value4) {
            callback(value4);
        });
    });
    emitter.emit("step1");
    //其实事件控制后,反而代码量更多了,其实这个跟promise的想法已经差不多了,但是抽象不够。
    //因此,理想的编程体验,应该是前一个调用的结果作为下一个调用的开始,也就是链式调用或者说是队列调用:
    //这就好像是promise的then一样
    
    promise()
    .then(obj.api1)
    .then(obj.api2)
    .then(obj.api3)
    .then(obj.api4)
    .then(function (value4) {
    // Do something with value4
    }, function (error) {
    // Handle any error from step1 through step4
    })
    .done();
    //于是对代码进行一些改造
    var Deferred = function () {
        this.promise = new Promise();
    };
    // 完成态
    Deferred.prototype.resolve = function (obj) {
        var promise = this.promise;
        var handler;
        while ((handler = promise.queue.shift())) {
            if (handler && handler.fulfilled) {
                var ret = handler.fulfilled(obj);
                if (ret && ret.isPromise) {
                    ret.queue = promise.queue;
                    this.promise = ret;
                    return;
                }
            }
        }
    };
    // 失败态
    Deferred.prototype.reject = function (err) {
        var promise = this.promise;
        var handler;
        while ((handler = promise.queue.shift())) {
            if (handler && handler.error) {
                var ret = handler.error(err);
                if (ret && ret.isPromise) {
                    ret.queue = promise.queue;
                    this.promise = ret;
                    return;
                }
            }
        }
    };
    // 生成回调函数
    Deferred.prototype.callback = function () {
        var that = this;
        return function (err, file) {
            if (err) {
                return that.reject(err);
            }
            that.resolve(file);
        };
    };
    var Promise = function () {
        // 队列用于存储待执行的回调函数
        this.queue = [];
        this.isPromise = true;
    };
    Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) {
        var handler = {};
        if (typeof fulfilledHandler === 'function') {
            handler.fulfilled = fulfilledHandler;
        }
        if (typeof errorHandler === 'function') {
            handler.error = errorHandler;
        }
        this.queue.push(handler);
        return this;
    };
    
    

    经过这个改造,我们可以开始对原来的多个异步调用进行操作了:

    var readFile1 = function (file, encoding) {
        var deferred = new Deferred();
        fs.readFile(file, encoding, deferred.callback());
        return deferred.promise;
    };
    var readFile2 = function (file, encoding) {
        var deferred = new Deferred();
        fs.readFile(file, encoding, deferred.callback());
        return deferred.promise;
    };
    readFile1('file1.txt', 'utf8').then(function (file1) {
        return readFile2(file1.trim(), 'utf8');
    }).then(function (file2) {
        console.log(file2);
    })
    

    因此,如果让,promise支持链式执行,需要做两件事

    1.将回调都存入队列
    2.promise完成时,逐个执行回调,一旦检测到返回了新的promise对象,就停止执行,然后将当前deferred对象的promise引用改变为新的promise对象,并将队列中余下的回调转交给它。当然,这只是自己研究promise原理,如果真的使用promise还是请用when和q这样的成熟promise库来解决问题吧。

    将API promise化

    // smooth(fs.readFile);
    var smooth = function (method) {
        return function () {
            var deferred = new Deferred();
            var args = Array.prototype.slice.call(arguments, 1);
            args.push(deferred.callback());
            method.apply(null, args);
            return deferred.promise;
        };
    };
    
    //于是,前面两次文件读取就可以简化了
    var readFile1 = function (file, encoding) {
    var deferred = new Deferred();
    fs.readFile(file, encoding, deferred.callback());
    return deferred.promise;
    };
    var readFile2 = function (file, encoding) {
    var deferred = new Deferred();
    fs.readFile(file, encoding, deferred.callback());
    return deferred.promise;
    };
    
    //简化为
    var readFile = smooth(fs.readFile);
    
    //这样代码量会急剧减少
    var readFile = smooth(fs.readFile);
    readFile('file1.txt', 'utf8').then(function (file1) {
        return readFile(file1.trim(), 'utf8');
    }).then(function (file2) {
        // file2 => I am file2
        console.log(file2);
    });
    
    

    流程库控制

    事件发布订阅和promise规范,都是模式和相关的commonjs规范,这些其实都是实现其他库的底层方法和响应规范。接下来介绍一下实际的方法,来解决异步编程的实际问题。

    尾触发与next

    在用connect中间件时,会有一个next对象,我们来看一下:

    var app = connect();
    // Middleware
    app.use(connect.staticCache());
    app.use(connect.static(__dirname + '/public'));
    app.use(connect.cookieParser());
    app.use(connect.session());
    app.use(connect.query());
    app.use(connect.bodyParser());
    app.use(connect.csrf());
    app.listen(3001);
    

    用use注册好中间件后,就在监听上存在了请求,然后就可以利用尾触发机制了:

    function (req, res, next) {
    // 中间件
    }
    

    这样就人为的设置了一个业务队列

    中间件通过人为队列形成的一个处理流

    其实,过滤呀,验证呀,日志呀都可以用这个机制,我们来看一下:

    exports.authorize_session = function (req, res, next) {
    
        if (req.session.user) {
            if (req.session.clt_id) {
    
                return next();
            }
            else {
                if (req.xhr) {
    
                    res.json(
                        {
                            errorcode: "b0001",
                            errorinfo: "(用户未认证,请先进行账户认证)"
                        }
                    );
                    res.end();
                }
                else {
                    var loginTip;
                    switch (req.session.statusname) {
                        case "未认证": loginTip = "<div class='status0'>该账号尚未认证,请先进行:<a href='/au'>身份认证</a></div>"; break;
                        case "待认证": loginTip = "<div class='status1'>该账号已提交认证申请,请等待系统审核。</div>"; break;
                        case "驳回": loginTip = "<div class='status3'>该账号认证被驳回,请重新提交:<a href='/au'>身份认证</a></div>"; break;
                        case "已认证": loginTip = false;
                        default: break;
                    }
                    res.render('./login/logined', {
                        layout: 'admin',
                        title: "登录成功",
                        username: req.session.user,
                        accMsg: req.session.accMsg,
                        status: req.session.statusname,//0未,1待,2已
                        clientid: req.session.clt_id,
                        login_tip: loginTip
                    });
                }
            }
        }
        else {
    
            if (req.xhr) {
                res.json(
                    {
                        errorcode: "b0001",
                        errorinfo: "(用户未登录,请先登录)"
                    }
                );
                res.end();
            }
            else {
                res.render('./login/login', {
                    title: "请先登录",
                    usernametemp: req.body.username,
                    error_reason: "请先登录",
                    layout: "default"
                });
            }
        }
    };
    

    接下来,我们看一下connect的核心实现

    function createServer() {
        function app(req, res) { app.handle(req, res); }
        utils.merge(app, proto);
        utils.merge(app, EventEmitter.prototype);
        app.route = '/';
        app.stack = [];
        for (var i = 0; i < arguments.length; ++i) {
            app.use(arguments[i]);
        }
        return app;
    };
    

    这段代码通过function app(req, res){ app.handle(req, res); }就可以创建http服务器的request事件处理函数了。其中,真正的核心代码是app.stack = [];

    stack属性是这个服务器内部维护的中间件队列,通过调用use(),我们可以将中间件放入队列

    app.use = function (route, fn) {
        // some code
        this.stack.push({ route: route, handle: fn });
        return this;
    };
    

    此时,就建好出了模型了,接下来结合node原生http模块,实现监听即可。

    app.listen = function(){
    var server = http.createServer(this);
    return server.listen.apply(server, arguments);
    };
    

    最终,app.handle()将会把监听到的网络请求,都从这里进程处理。

    app.handle = function(req, res, out) {
    // some code
    next();
    };
    

    每一个next()将取出队列中的中间件,并执行,同时将传入当前方法以实现递归调用,最终达到持续触发的目的:

    function next(err) {
        // some code
        // next callback
        layer = stack[index++];
        layer.handle(req, res, next);
    }
    

    async库

    第一次我使用这个库是因为lisk的区块链程序,这个应该是当时最为知名的流程控制模块了。这个库的基本使用可以参考我写的一篇文章:ebookcoin中出现的异步编程浅析

    朴灵在书中描述了使用async库,如何实现异步的串行执行、并行执行、异步调用的依赖处理、自动依赖处理等功能,官方细节可以参考:https://github.com/caolan/async。在此为了加快笔记的书写速度,我将不再写朴灵书上关于async库的介绍了

    setp库

    setp比async更加轻量,通过npm install step安装即可使用。step只有一个接口:Step(task1, task2, task3);
    它可以接受任意数量的任务,所以任务都会串行依次执行:

    Step(
        function readFile1() {
            fs.readFile('file1.txt', 'utf-8', this);
        },
        function readFile2(err, content) {
            fs.readFile('file2.txt', 'utf-8', this);
        },
        function done(err, content) {
            console.log(content);
        }
    )
    

    step的this关键字,是他内部的next()方法,将异步调用的结果传递给下一个任务做为参数。

    因为,这个库也不是本次学习的重点,因此step的并行任务执行、结果分组等都不再写笔记,大家可以自己上网查看。

    wind

    这个库,大家可以自行查看https://github.com/JeffreyZhao/wind,在此也不做过多的介绍。

    流程控制小结

    流程控制是为了解决callback hell的,这个也是异步编程的重点。我们比较一下几种方案:

    流程控制方法 说明
    事件发布/订阅 node的事件底层实现,是其他库的实现基础,比较原始和底层,理解后对于其他库的原理可以有更深刻的理解
    promise/deferred 它是一种解决异步编程的规范,并做了代码抽象和封装,现在已经广泛应用于各种异步库中。
    eventproxy 朴灵自己写的一个对于events模块的扩展,可以理解其原理,深刻体会流程控制的精妙之处
    async 一个流程控制库,可以解决异步串行、并行、自动执行等多种任务
    step 跟async差不多
    wind 也是一个库,没怎么看,以后补充
    streamline 书中一笔带过,以后补充原理

    我们现在处于node7.6以后的时代,更多的流程控制可以通过async/await来自行设计和解决,这些内容都做完程序底层原理学习即可。

    异步并发控制

    因为,node可以随便的调用异步发起并行调用程序,因此,如果使用的太过于随意很可能出现资源被吃光的情况,并报类似于打开文件过多的错:Error: EMFILE, too many open files

    虽然,这样确实榨干了服务器资源,但是也应该做一下过载保护,防止问题出现。

    bagpipe的解决方案

    bagpipe可以实现通过队列控制并发量的功能,同时还启用了拒绝模式,防止大量的异步调用。另外,对于过长时间的异步调用,也提供了超时控制。因此,这个不是本次笔记的学习重点,因此,请大家先自行查询网络,自己学习,我会后续补全笔记。

    async的解决方案

    async提供了 parallelLimit()来做异步并发控制,还是一样,请大家看我的另外一篇笔记。ebookcoin中出现的异步编程浅析

    总结

    异步编程我们可以通过事件注册和监听、Promise/Deferred模式、流程控制库、Generator/yeild、async/await等方式进行解决。

    其中事件发布/订阅模式相对算是一种较为原始的方式,Promise/Deferred模式贡献了一个非常不错的异步任务模型的抽象。流程控制库则对异步进行了封装,让用户只关注回调函数,而不是异步程序。

    另外,书中还介绍了一个streamline,如果有兴趣,可以去看看。

    当然,我们知道,在现在这种情况下,async/await则是对于异步编程,非常完美的解决方案了。

    async/await搭配Promise/Deferred模式,从原生核心node代码解决方案出发,几乎是完美的解决了异步编程的问题。

    注意:这本书主要还是基于node6和es5语法的,现在在node7.6和es2015等规范下,已经可以通过async/await这种形式,完美的解决异步编程callback hell的问题了。

    相关文章

      网友评论

        本文标题:nodejs深入学(5)异步编程

        本文链接:https://www.haomeiwen.com/subject/wxgwixtx.html