深入理解Promise

刚使用nodejs编程是痛苦的,这痛苦并非源自对新的知识点学习的过程,而是来自于新的思维模式与惯有的思维模式之间的冲突,具体来说,就是我们个体同步的思考方式和异步的编程方式之间的碰撞。我们只有一个大脑,因此当我们处理问题时,无论是什么样的问题,我们都会将其拆分成1、2、3步(某些大牛除外),一步一步进行处理,这就造成了在我们内心中,希望我们自己写代码时也能遵循这样的方式,代码从逻辑上来说尽量同步;但另一方面,这个世界上,并不只有个体存在,更多时候,我们需要处理的是群体间的事务,当遇到个体无法处理的事务,我们希望能将其分发给不同的对象,通过消息通知/回调的方式,来追踪事务的进展 。

例如,当我们需要采购一批货物,我们需要将采购的流程拆分成经理注册、财务审批、采购员采购等小规模的业务分发给经理、财务、采购员,当经理注册完成后通知财务,财务才能审批;财务审批完之后又要通知采购员去进行采购。是不是很眼熟?这就是现实生活中的回调模型。这样的模型,实际上是一个同步模型,而我们在node中又不得不采用回调的形式来实现它,这就是问题的结症所在。什么意思呢?如果采用同步的表达方式,上面的例子可以表示成:

managerRegister();  
/* wait for a while */      
financeCheck();  
/* wait for a while */  
purchase();  

每一步都需要等待上一步执行完毕之后才能执行,然后,到了node中,由于js引擎单线程的特性,因此一般来说,我们不允许某一步的存在阻塞了线程,于是乎,上面的模型便成了这个样子:

/* Do some thing */  
managerRegister(function(result) {  
financeCheck(function(result) {  
        purchase();  
    });  
});  
/* Do some thing else */  

回调本身并不是异步的,你甚至完全可以用它来编写所有的同步代码。它存在的意义是为异步的操作增加了同步的约束,所以本质上,它表达的是一种同步的思想。这就是痛苦的根源: 在node中,我们需要连篇累牍的使用这种形式去表达同步的过程。
因此我们需要有另一种形式,将我们从这种对立的思考和实现之间解脱出来。最理想的方案肯定是显示同步,隐式异步,即用同步的代码编写异步的逻辑,然后到目前为止,还没有很好的实现可供选择。退而求其次,我们选择promise。

###1. 同步(synchronize),异步(asynchronize),并发(concurrency), 并行(parallelism)

要理解promise,首先我们要搞清楚同步、异步、并发、并行这四个概念(说实话在此之前我也没搞清楚)。同步指的是程序是顺序执行,每一步都需要等待前一步执行完毕才能往下执行。异步指的是程序并非顺序执行,当某一事件在执行时,程序可以继续做其他事,而不必等待该事件执行完毕。异步是我们希望得到的结果,然而如何实现程序的异步执行?这就要用到我们的并发和并行了。当我们的程序允许多线程执行时,我们可以采用并行的方式来实现异步操作:遇到需要异步执行的程序片段,新开一个线程来执行该程序片段,主线程继续往下执行。如果我们的程序运行环境不允许多线程呢(比如node)?那我们可以选择并发的方式来实现,下面用一个例子来说明并发这个概念:
假设我们的程序的执行过程中遇到两个任务A,B,其中A可分为A1,A2,A3,B可以分为B1,B2,A2和A3需要等待A1执行完毕,然而B的执行并不依赖于A,采用并发的方式,我们可能就会得到这样一个结果:

A1
B1(不必等待A执行完毕)
A2(A1执行完毕)
A3
B2

虽然同一时间只能有一个程序片段在执行,但它实现了在不同的任务之间的轮转,使得我们不必等待某一任务执行完毕,这就是所谓的并发。大量的采用并发也是node得以发扬光大的基石。


###2. ‘恶魔金字塔’并非问题的本质?

接触node编程不久,我们就会遇到‘回调金字塔’的问题。在各种各样关于node的参考资料中,我们都可以看到不同作者对“邪恶”的回调金字塔的大肆渲染,仿佛这就是问题的本质。诚然,“邪恶金字塔”使代码的可读性严重下降,使得后续的维护难上加难,但一段逻辑严谨的回调嵌套的运行效率还高于其对应的promise实现,如果问题仅限于此,那么仅仅是缩进的“问题”为何值得那么多人大书特书?实际上,真正的问题远比这深的多。
首先我们先看一个简单的代码片段:

callBeforeAyncCalls(
    function() { 
     someAyncCalls(function() { 
        callAfterAyncCalls(
             //More nested calls....
            )
        }
    }
)

可以看到,在上面的回调嵌套中,程序自然而然被分成了三个部分:someAyncCalls之前的部分,someAyncCalls和它之后的部分。下面我们思考一个问题:

你是否拥有someAsyncCalls的控制权?

遗憾的是,大部分时候答案都是否。如今,开发一个程序需要用到太多的第三方控件、库,因此不可避免的会调用很多并非由我们开发的函数,当我们将我们的代码作为回调函数传给这些函数时,我们不禁要问:这些回调函数能被正常的调用吗?我们能够信任这些第三方的函数吗?
你可能会问,不就是调用一个回调函数吗?还需要信任哪些内容?不管你是否注意到,你已经隐式的信任了这些内容:[1]

1. 不要过早调用我的回调函数
2. 不要太晚调用我的回调
3. 不要过少的调用回调
4. 不要过多的调用回调
5. 将必要的参数正确的传给我的回调函数
6. 如果回调失败,请保证能够让我知道    

这一大堆信任问题正是结症所在。下面举一个简单的例子:
你开发了一个购物网站,当用户购物完成之后,你需要调用一个第三方的支付网站验证用户的支付信息(checkUserInfo),验证完成之后你再通过回调调用支付接口(chargeCreditCard),大部分时候,perfect!但是你对checkUserInfo这个函数的实现并不了解,某次一个粗心的程序员将其中一段callUserCallBack函数的调用次数改为了三次,这意味着什么呢?意味你的用户在一次支付流程中支付了三倍的费用!幸运的是,你发现了这个问题,你赶紧将多余的钱退回给客户,然后找到这个api的开发者,告诉他们,你们的函数出错了!他们赶紧改掉BUG,并告诉你,现在你可以将api升级到2.0版,放心的使用我们的api了。现在,你还能放心吗?
当然,现实生活中我们很少会遇到这样极端的情况,但这种我们永远无法保证第三方的函数都是完美的,因此我们需要竭尽全力去避免这种无法掌控的情况。
怎么做呢?
答案就是promise。


###3. Promise如何解决上面的问题?
在回答这个问题之前,我们首先看一个现实生活中的场景:
你去一家快餐店吃饭,走进店内,首先来到吧台点了一分大娘水饺(好吧这家快餐店叫面点王),付款后,收银员并不会立马给你端一碗水饺(水饺肯定不新鲜),也不会让你在吧台等着厨师把煮熟的水饺端给你(这是同步的做法)。正确的做法是,收银员会给你一张小票(你的promise),上面有你的订单号123,你拿着小票随便找一个位置坐下就等着服务员叫号(回调promise),过了十分钟,当你听到服务员终于叫到123号时,便拿着你的小票去getADaNiangShuiJiao()(调用你的回调函数)。实际上,你的小票就是现实生活中的promise,水饺做好后,服务员并不会直接把水饺端给你,他只会回调到你的小票,然后等着你用你的小票去领取你的水饺(resolve水饺)。下面用代码的形式来进一步说明:

function makeOrderPromise(order) {  
    var tip = new tip();  
    makeOrder(order, function(err, result) 
    {  
        if(err) tip.reject(result);  
        else tip.resolve(result);    
    });  
}  
makeOrderPromise('ShuiJiao').then(  
    function() {  
        getShuiJiao();  
    },  
    function() {  
        rejectedByWaiters();  
    }  
)   

可以看到,promise在“我”和“服务员”之间插入了一个中间层(小票),有了它之后,我不再直接向服务员注册回调函数,而是告诉我的小票:我需要一碗水饺,当我的小票被服务员回调之后,我再通过解析我的小票来获得我的水饺。
现在我们再回过头来看我们上面提的那6个问题,promise是如何解决的:

1. 不要过早调用我的回调函数: 只有当原函数被正确执行时,才会调用回调。因此不存在“过早”和“过晚”这样的情况。
2. 不要太晚调用我的回调:
3. 不要过少的调用回调:Promise被resolve的次数<=1次,因此过少就是说Promise没有调用回调。事实上,Promise并不保证总是被resolve,但我们可以通过手动添加一个timeout函数来保证超时的promise可以返回error。
4. 不要过多的调用回调:根据Promise/A+规范,promise只会被resolve一次。
5. 将必要的参数正确的传给我的回调函数:Promise允许状态和参数的传递。
6. 如果回调失败,请保证能够让我知道:完善的promise chain可以保证错误可以被catch到。

###4. Promise的具体实现/使用
使用Promise之前,我们需要将我们的函数“Promise化”。然而,既然已经到了这里,事实上“Promisify”已经是一件水到渠成的事了,我们要做的仅仅是在我们的函数和它的回调之间插入一张“小票”,就是我们的Promise。换句话说,函数回调时并不会直接回调给我们的回调函数:它首先通知Promise,再由Promise来调用我们的回调函数。下面用一个简单的例子说明一下:

//对正常的getSomeStuff函数的调用
getSomeStuff(params, function(err, value) {
    if(err) console.log(err);
    else return value;
});
//Promisify后的getSomeStuff函数
getSomeStuffAsync(params) {
    return new Promise(function(resolve,reject) {
    getSomeStuff(params, function(err, value) {
            if(err) return reject(err);
            else return resolve(value);
        });
    });
}

说了这么多,实际上都是我自己对下面参考资料一些粗浅的理解和总结,权当是抛砖引玉。欢迎大家猛烈的鞭挞。


参考:
【1】Nodejs Promise的本质
【2】关于Promise你理解了多少
【3】Promise/A+规范
【4】NodeJS最新技术站之Promise篇
【5】How can I convert a existing API to promises