信任问题

在顺序的大脑规划和JS代码中回调驱动的异步处理间的不匹配只是关于回调的问题的一部分。还有一些更深刻的问题值得担忧。

让我们再一次重温这个概念——回调函数是我们程序的延续(也就是程序的第二部分):

  1. // A
  2. ajax( "..", function(..){
  3. // C
  4. } );
  5. // B

// A// B现在 发生,在JS主程序的直接控制之下。但是// C被推迟到 稍后 再发生,并且在另一部分的控制之下——这里是ajax(..)函数。在基本的感觉上,这样的控制交接一般不会让程序产生很多问题。

但是不要被这种控制切换不是什么大事的罕见情况欺骗了。事实上,它是回调驱动的设计的最可怕的(也是最微妙的)问题。这个问题围绕着一个想法展开:有时ajax(..)(或者说你向之提交回调的部分)不是你写的函数,或者不是你可以直接控制的函数。很多时候它是一个由第三方提供的工具。

当你把你程序的一部分拿出来并把它执行的控制权移交给另一个第三方时,我们称这种情况为“控制倒转”。在你的代码和第三方工具之间有一个没有明言的“契约”——一组你期望被维护的东西。

五个回调的故事

为什么这件事情很重要可能不是那么明显。让我们来构建一个夸张的场景来生动地描绘一下信任危机。

想象你是一个开发者,正在建造一个贩卖昂贵电视的网站的结算系统。你已经将结算系统的各种页面顺利地制造完成。在最后一个页面,当用户点解“确定”购买电视时,你需要调用一个第三方函数(假如由一个跟踪分析公司提供),以便使这笔交易能够被追踪。

你注意到它们提供的是某种异步追踪工具,也许是为了最佳的性能,这意味着你需要传递一个回调函数。在你传入的这个程序的延续中,有你最后的代码——划客人的信用卡并显示一个感谢页面。

这段代码可能看起来像这样:

  1. analytics.trackPurchase( purchaseData, function(){
  2. chargeCreditCard();
  3. displayThankyouPage();
  4. } );

足够简单,对吧?你写好代码,测试它,一切正常,然后你把它部署到生产环境。大家都很开心!

6个月过去了,没有任何问题。你几乎已经忘了你曾写过的代码。一天早上,工作之前你先在咖啡店坐坐,悠闲地享用着你的拿铁,直到你接到老板慌张的电话要求你立即扔掉咖啡并冲进办公室。

当你到达时,你发现一位高端客户为了买同一台电视信用卡被划了5次,而且可以理解,他不高兴。客服已经道了歉并开始办理退款。但你的老板要求知道这是怎么发生的。“我们没有测试过这样的情况吗!?”

你甚至不记得你写过的代码了。但你还是往回挖掘试着找出是什么出错了。

在分析过一些日志之后,你得出的结论是,唯一的解释是分析工具不知怎么的,由于某些原因,将你的回调函数调用了5次而非一次。他们的文档中没有任何东西提到此事。

十分令人沮丧,你联系了客户支持,当然他们和你一样惊讶。他们同意将此事向上提交至开发者,并许诺给你回复。第二天,你收到一封很长的邮件解释他们发现了什么,然后你将它转发给了你的老板。

看起来,分析公司的开发者曾经制作了一些实验性的代码,在一定条件下,将会每秒重试一次收到的回调,在超时之前共计5秒。他们从没想要把这部分推到生产环境,但不知怎地他们这样做了,而且他们感到十分难堪而且抱歉。然后是许多他们如何定位错误的细节,和他们将要如何做以保证此事不再发生。等等,等等。

后来呢?

你找你的老板谈了此事,但是他对事情的状态不是感觉特别舒服。他坚持,而且你也勉强地同意,你不能再相信 他们 了(咬到你的东西),而你将需要指出如何保护放出的代码,使它们不再受这样的漏洞威胁。

修修补补之后,你实现了一些如下的特殊逻辑代码,团队中的每个人看起来都挺喜欢:

  1. var tracked = false;
  2. analytics.trackPurchase( purchaseData, function(){
  3. if (!tracked) {
  4. tracked = true;
  5. chargeCreditCard();
  6. displayThankyouPage();
  7. }
  8. } );

注意: 对读过第一章的你来说这应当很熟悉,因为我们实质上创建了一个门闩来处理我们的回调被并发调用多次的情况。

但一个QA的工程师问,“如果他们没调你的回调怎么办?” 噢。谁也没想过。

你开始布下天罗地网,考虑在他们调用你的回调时所有出错的可能性。这里是你得到的分析工具可能不正常运行的方式的大致列表:

  • 调用回调过早(在它开始追踪之前)
  • 调用回调过晚 (或不调)
  • 调用回调太少或太多次(就像你遇到的问题!)
  • 没能向你的回调传递必要的环境/参数
  • 吞掉了可能发生的错误/异常

这感觉像是一个麻烦清单,因为它就是。你可能慢慢开始理解,你将要不得不为 每一个传递到你不能信任的工具中的回调 都创造一大堆的特殊逻辑。

现在你更全面地理解了“回调地狱”有多地狱。

不仅是其他人的代码

现在有些人可能会怀疑事情到底是不是如我所宣扬的这么大条。也许你根本就不和真正的第三方工具互动。也许你用的是进行了版本控制的API,或者自己保管的库,因此它的行为不会在你不知晓的情况下改变。

那么,好好思考这个问题:你能 真正 信任你理论上控制(在你的代码库中)的工具吗?

这样考虑:我们大多数人都同意,至少在某个区间内我们应当带着一些防御性的输入参数检查制造我们自己的内部函数,来减少/防止以外的问题。

过于相信输入:

  1. function addNumbers(x,y) {
  2. // + 操作符使用强制转换重载为字符串连接
  3. // 所以根据传入参数的不同,这个操作不是严格的安全。
  4. return x + y;
  5. }
  6. addNumbers( 21, 21 ); // 42
  7. addNumbers( 21, "21" ); // "2121"

防御不信任的输入:

  1. function addNumbers(x,y) {
  2. // 保证数字输入
  3. if (typeof x != "number" || typeof y != "number") {
  4. throw Error( "Bad parameters" );
  5. }
  6. // 如果我们到达这里,+ 就可以安全地做数字加法
  7. return x + y;
  8. }
  9. addNumbers( 21, 21 ); // 42
  10. addNumbers( 21, "21" ); // Error: "Bad parameters"

或者也许依然安全但更友好:

  1. function addNumbers(x,y) {
  2. // 保证数字输入
  3. x = Number( x );
  4. y = Number( y );
  5. // + 将会安全地执行数字加法
  6. return x + y;
  7. }
  8. addNumbers( 21, 21 ); // 42
  9. addNumbers( 21, "21" ); // 42

不管你怎么做,这类函数参数的检查/规范化是相当常见的,即便是我们理论上完全信任的代码。用一个粗俗的说法,编程好像是地缘政治学的“信任但验证”原则的等价物。

那么,这不是要推论出我们应当对异步函数回调的编写做相同的事,而且不仅是针对真正的外部代码,甚至要对一般认为是“在我们控制之下”的代码?我们当然应该。

但是回调没有给我们提供任何协助。我们不得不自己构建所有的装置,而且这通常最终成为许多我们要在每个异步回调中重复的模板/负担。

有关于回调的最麻烦的问题就是 控制反转 导致所有这些信任完全崩溃。

如果你有代码用到回调,特别是但不特指第三方工具,而且你还没有为所有这些 控制反转 的信任问题实施某些缓和逻辑,那么你的代码现在就 bug,虽然它们还没咬到你。将来的bug依然是bug。

确实是地狱。