深入理解 Promise 五部曲:2. 控制权转换问题

[转, 原文地址:http://segmentfault.com/a/1190000000591382 ]

在上一篇深入理解Promise五部曲:1.异步问题中,我们揭示了JS的异步事件轮询并发模型并且解释了多任务是如何相互穿插使得它们看起来像是同时运行的。然后我们讨论了为什么我们努力地在我们的代码里表达这些东西以及为什么我们的大脑不善于理解它们。

我们现在要找出一个更好的方式来表达异步流程,然后看看Promises是怎么解决这个问题的。

回调嵌套

JS从一开始就使用事件轮询的并发模型。我们一直以来都在写异步的程序。直到最近,我们仍然在用简单的回调函数来处理异步的问题。

makeAjaxRequest(url,function(respnose){
    alert("Response:" + response) ;
}) ;

当我们只有一个异步任务的时候使用回调函数看起来还不会有什么问题。但是,实际是我们完成一个任务通常需要多个异步操作。例如:

btn.addEventListener("click",function(evt){
    makeAjaxRequest(url,function(response){
        makeAjaxRequest(anotherURL + "?resp=" + response,function(response2){
            alert("Response2:" + response) ;
        })
    }) ;
},false) ;

把一系列异步操作链接在一起最自然的方式就是使用回调嵌套,步骤2嵌套在步骤1中然后步骤3嵌套在步骤2中,等等。

回调地狱

你使用越多的回调,就会有越多的嵌套,不断缩进意大利面条似的代码。很显然,这种代码难以编写,难以理解而且难以维护。如果我们花点时间来理清这些代码往往会让我们事半功倍。这类嵌套/缩进经常被叫做"回调地狱"。有时也被叫做"回调金字塔",专指由于代码不断缩进所形成的金字塔形状,缩进越多金字塔形状越明显。

但是我还是觉得"回调地狱"真的跟嵌套和缩进扯不上太大的关系。如果之前有人跟你说回调地狱就是指嵌套和缩进的话,不要相信他,因为他们并不理解回调真正的问题在哪儿。

可靠性缺失

回调(无论是否有嵌套)的真正问题是远比编辑器中的空白符严重。让我们来分析下下面这个简单的回调发生了什么

//1.everything in my program before now

someAsyncThing(function(){
    //2.everything in my program for later
}) ;

你看清这段代码说了什么吗?你从根本上把你的程序分成了两个部分:

  1. 直到现在为止发生的事情

  2. 以后会发生的事情

换句话说,你把第二部分代码包装在一个回调函数中然后延迟到后面执行。

但是这并不是问题,真正问题是在1和2之间发生了什么。请问在这段时间内是谁在控制这些。
someAsyncThing(..)控制着这些。是你自己拥有并管理someAsyncThing()吗?许多时候不是。更重要的是,你有多信任someAsyncThing(..)?你会问,信任什么?不管你意识到没有,你潜在的相信someAsyncThing(..)会做到下面这些:

  1. 不会太早调用我的回调函数

  2. 不会太迟调用我的回调函数(1,2就是说会在适当的时候调用回调函数)

  3. 不会调用我的回调太少次(不会少于实际应该调用的次数,比如不会漏掉函数调用)

  4. 不会调用我的回调太多次(不会多于实际应该调用的次数,比如重复调用)

  5. 会给我的回调提供必要的参数

  6. 在我的回调失败的时候会提醒我

咳!你也太信任它了!

实际上,这里真正的问题是由于回调引起的控制转移。在你的程序的前半部分,你控制着程序的进程。现在你转移了控制权,someAsyncThing(..)控制了你剩余程序什么时候返回以及是否返回。控制转移表明了你的代码和其他人的代码之间的过度信任关系。

恐吓战术

someAsyncThing(..)是第三方库的一个方法并且你无法控制不能检查的时候会发生什么?只能祝你好运了!

比如你有一个电子商务网站,用户就要完成付款的步骤了,但是在扣费之前有最后一个步骤,它需要通知一个第三方跟踪库。你调用他们API,并且提供一个回调函数。大部分情况下,这不会有什么问题。但是,在这次业务中,有一些你和他们都没有意识到的奇怪的Bug,结果就是第三方库在超时之前五秒的时间内每隔一秒就会调用一次回调函数。猜猜发生了什么?在这个回调里调用了chargeTheCreditCard()

Oops,消费者被扣了五次钱。为什么?因为你相信第三方库只会调用你的回调一次。

所以你不得不被丢鸡蛋并且给消费者道歉归还多扣的四次钱。然后你立刻采取措施确保这种情况不会再发生。你会怎么做呢?

你可能会创建一些状态值来跟踪你的回调,当它被调用一次之后会被标记,然后就可以忽略任何意外的重复调用。无论第三方如何道歉并且承诺他们的bug已经修复了,你再也不会相信他们了,不是吗?

这看起来像一个愚蠢的场景,但是这可能比你想得还普遍。我们的程序变得越复杂,我们就会集成越多的第三方/外部代码,这种愚蠢的场景就越容易发生。

布基胶带

你给你的回调加入了状态跟踪机制,然后睡了一个好觉。但是实际上你只是处理了信任列表许多项目中的一项。当另一个bug造成另一个可靠性丢失的情况时会发生什么?更多的改造,更多丑陋的代码。

更多布基胶带。你必须不断修复回调中的漏洞。无论你是多优秀的开发者,无论你的布基胶带多漂亮,事实就是:在你信任墙上的回调充满了漏洞。

Promise解决方案

一些人喜欢使用布基绷带并且给信任墙上的洞打补丁。但是在某些时候,你也许会问自己,是否有其他模式来表达异步流程控制,不需要忍受所有这些可靠性丢失?

是的!Promises就是一个方法。

在我解释它们是怎么工作之前,让我来解释一些它们背后的概念问题。

快餐业务

你走进你最喜爱的快餐店,走到前台要了一些美味的食物。收银员告诉你一共7.53美元然后你把钱给她。她会给回你什么东西呢?

如果你足够幸运,你要的食物已经准备好了。但是大多数情况下,你会拿到一个写着序列号的小票,是吧?所以你站到一边等待你的食物。

很快,你听到广播响起:“请317号取餐”。正好是你的号码。你走到前台用小票换来你的食物!谢天谢地,你不用忍受太长的等待。

刚才发生的是一个对于Promises很好的比喻。你走到前台开始一个业务,但是这个业务不能马上完成。所以,你得到一个在迟些时候完成业务(你的食物)的promise(小票)。一旦你的食物准备就绪,你会得到通知然后你第一时间用你的promise(小票)换来了你想要的东西:食物。

换句话说,带有序列号的小票就是对于一个未来结果的承诺。

完成事件

想想上面调用someAsyncThing(..)的例子。如果你可以调用它然后订阅一个事件,当这个调用完成的时候你会得到通知而不是传递一个回调给它,这样难道不会更好吗?

例如,想象这样的代码:

var listener = someAsyncThing(..) ;
listener.on("completion",function(data){
    //keep going now !
}) ;

实际上,如果我们还可以监听调用失败的事件那就更好了。

listener.on("failure",function(){
    //Oops,What's plan B?
}) ;

现在,对于我们调用的每个函数,我们能够在函数成功执行或者失败的时候得到通知。换句话说,每个函数调用会是流程控制图上的决策点。

Promise"事件"

Promises就像是一个函数在说“我这有一个事件监听器,当我完成或者失败的时候会被通知到。”我们看看它是怎么工作的:

function someAsyncThing(){
    var p = new Promise(function(resolve,reject){
        //at some later time,call 'resolve()' or 'reject()'
    }) ;
    return p ;
}
var p = someAsyncThing() ;
p.then(
    function(){
        //success happened    
    },
    function(){
        //failure happened
    }
) ;

你只需要监听then事件,然后通过知道哪个回调函数被调用就可以知道是成功还是失败。

逆转

通过promises,我们重新获得了程序的控制权而不是通过给第三方库传递回调来转移控制权。这是javascript中异步控制流程表达上一个很大的进步。

“等等”,你说。“我仍然要传递回调啊。有什么不一样?!”嗯。。。好眼力!

有些人声称Promises通过移除回调来解决“回调地狱”的问题。并不是这样!在一些情况下,你甚至需要比以前更多的回调。同时,根据你如何编写你的代码,你可能仍然需要把promises嵌套在别的promises中!

批判性地看,promises所做的只是改变了你传递回调的地方。

本质上,如果你把你的回调传递给拥有良好保证和可预测性的中立Promises机制,你实质上重新获得了对于后续程序能很稳定并且运行良好的可靠性。标准的promises机制有以下这些保证:

  1. 如果promise被resolve,它要不是success就是failure,不可能同时存在。

  2. 一旦promise被resolve,它就再也不会被resolve(不会出现重复调用)。

  3. 如果promise返回了成功的信息,那么你绑定在成功事件上的回调会得到这个消息。

  4. 如果发生了错误,promise会收到一个带有错误信息的错误通知。

  5. 无论promise最后的结果是什么(success或者failure),他就不会改变了,你总是可以获得这个消息只要你不销毁promise。

如果我们从someAsyncThing(..)得到的promise不是可用的标准的promise会发生什么?如果我们无法判断我们是否可相信它是真的promise会怎么样?

简单!只要你得到的是“类promise”的,也就是拥有then(..)方法可以注册success和failure事件,那么你就可用使用这个“类promise”然后把它包装在一个你信任的promise中。

var notSureWhatItIs = someAsyncThing();

var p = Promise.resolve( notSureWhatItIs );

// now we can trust `p`!!
p.then(
    function(){
        // success happened 
    },
    function(){
        // failure happened 
    }
);

promises的最重要的特点就是它把我们处理任何函数调用的成功或者失败的方式规范成了可预测的形式,特别是如果这个调用实际上的异步的。

在这个规范过程中,它使我们的程序在可控制的位置而不是把控制权交给一个不可相信的第三方。

总结

不要管你所听到的,“回调地狱”不是真的关于函数嵌套和它们在代码编辑器中产生的缩进。它是关于控制转移的,是指我们由于把控制权交给一个我们不能信任的第三方而产生的对我们的程序失去控制的现象。

Promises逆转了这个情况,它使得我们重新获得控制权。相比传递回调给第三方函数,函数返回一个promise对象,我们可以使用它来监听函数的成功或失败。在promise我们仍然使用回调,但是重要的是标准的promise机制使我们可以信任它们行为的正确性。我们不需要想办法来处理这些可靠性问题。

在第三部分:可靠性问题中,我会说道一个promises可靠性机制中很特别的部分:一个promise的状态必须是可靠并且不可变的。


上一篇:vivo 悟空活动中台 - 微组件状态管理(上)


下一篇:领域驱动设计(DDD)实践之路(二):事件驱动与CQRS