2009年5月8日星期五

写个 JavaScript 异步调用框架 (Part 4 - 链式调用)

我们已经实现了一个简单的异步调用框架,然而还有一些美中不足,那就是顺序执行的异步函数需要用嵌套的方式来声明。

现实开发中,要按顺序执行一系列的同步异步操作又是很常见的。还是用百度Hi网页版中的例子,我们先要异步获取联系人列表,然后再异步获取每一个联系人的具体信息,而且后者是分页获取的,每次请求发送10个联系人的名称然后取回对应的具体信息。这就是多个需要顺序执行的异步请求。

为此,我们需要设计一种新的操作方式来优化代码可读性,让顺序异步操作代码看起来和传统的顺序同步操作代码一样优雅。

传统做法

大多数程序员都能够很好的理解顺序执行的代码,例如这样子的:

var firstResult = firstOperation(initialArgument);
var secondResult = secondOperation(firstResult);
var finalResult = thirdOperation(secondResult);
alert(finalResult);


其中先执行的函数为后执行的函数提供所需的数据。然而使用我们的异步调用框架后,同样的逻辑必须变成这样子:

firstAsyncOperation(initialArgument)
  .addCallback(function(firstResult) {
    secondAsyncOperation(firstResult)
      .addCallback(function(secondResult) {
        thirdAsyncOperation(secondResult)
          .addCallback(function(finalResult) {
            alert(finalResult);
        });
    });
});

链式写法

我认为上面的代码实在是太不美观了,并且希望能够改造为jQuery风格的链式写法。为此,我们先构造一个用例:

Async.go(initialArgument)
  .next(firstAsyncOperation)
  .next(secondAsyncOperation)
  .next(thirdAsyncOperation)
  .next(function(finalResult) { alert(finalResult); })


在这个用例当中,我们在go传入初始化数据,然后每一个next后面传入一个数据处理函数,这些处理函数按顺序对数据进行处理。

同步并存

上面的用例调用到的全部都是异步函数,不过我们最好能够兼容同步函数,让使用者无需关心函数的具体实现,也能使用这项功能。为此我们再写一个这样的用例:

Async.go(0)
  .next(function(i) { alert(i); return i + 1; })
  .next(function(i) {
    alert(i);
    var operation = new Async.Operation();
    setTimeout(function() { operation.yield(i + 1); }, 1000);
    return operation;
  })
  .next(function(i) { alert(i); return i + 1; })
  .next(function(i) { alert(i); return i; });


在上述用例中,我们期待能够看到0, 1, 2, 3的提示信息序列,并且1和2之间间隔为1000毫秒。

异步本质

一个链式调用,本质上也是一个异步调用,所以它返回的也是一个Operation实例。这个实例自然也有result、state和completed这几个字段,并且当整个链式调用完成时,result等于最后一个调用返回的结果,而completed自然是等于true。

我们可以扩展一下上一个用例,得到如下用例代码:

var chainOperation = Async.go(0)
  .next(function(i) { alert(i); return i + 1; })
  .next(function(i) {
    alert(i);
    var operation = new Async.Operation();
    setTimeout(function() { operation.yield(i + 1); }, 1000);
    return operation;
  })
  .next(function(i) { alert(i); return i + 1; })
  .next(function(i) { alert(i); return i; });

setTiemout(function() { alert(chainOperation.result; }, 2000);


把链式调用的返回保存下来,在链式调用完成时,它的result应该与最后一个操作的返回一致。在上述用例中,也就是3。

调用时机

尽管我们提供了一种链式调用方式,但是用户不一定会按照这种固定的方式来调用,所以我们仍然要考虑兼容用户的各种可能用法,例如说异步地用next往调用链添加操作:

var chainOperation = Async.go(0);
chainOperation.next(function(i) { alert(i); return i + 1; });
setTimeout(function() {
  chainOperation.next(function(i) {
    alert(i);
    var operation = new Async.Operation();
    setTimeout(function() { operation.yield(i + 1); }, 2000);
    return operation;
  })
}, 1000);
setTimeout(function() {
  chainOperation.next(function(i) { alert(i); return i + 1; });
}, 2000);


在这个用例当中,用户每隔1000毫秒添加一个操作,而其中第二个操作耗时2000毫秒。也就是说,添加第三个操作时第二个操作还没返回。作为一个健壮的框架,必须要能兼容这样的使用方式。

此外我们还要考虑,用户可能想要先构造调用链,然后再执行调用链。这时候用户就会先使用next方法添加操作,再使用go方法执行。

var chainOperation = Async
  .chain(function(i) { alert(i); return i + 1; })
  .next(function(i) {
    alert(i);
    var operation = new Async.Operation();
    setTimeout(function() { operation.yield(i + 1); }, 2000);
    return operation;
  })
  .go(0)
setTimeout(function() {
  chainOperation.next(function(i) { alert(i); return i + 1; })
}, 1000);


在上述用例中,用户通过chain和next添加了头同步操作和异步操作各一个,然后用go执行调用链,在调用链执行完毕之前又用next异步追加了一个操作。一个健壮的框架,在这样的用例当中应该能够如同用户所期望的那样提示0, 1, 2。

小结

针对链式调用的需求,我们设计了如此多的用例,包括各种奇怪的异步调用方式。最终如何实现这样的功能呢?如果你想知道的话,欢迎订阅我的博客:

2009年5月6日星期三

写个 JavaScript 异步调用框架 (Part 3 - 代码实现)

在上一篇文章里,我们说到了要实现一个Async.Operation类,通过addCallback方法传递回调函数,并且通过yield方法返回回调结果。现在我们就来实现这个类吧。

类结构

首先我们来搭一个架子,把需要用到的似有变量都列出来。我们需要一个数组,来保存回调函数列表;需要一个标志位,来表示异步操作是否已完成;还可以学IAsyncResult,加一个state,允许异步操作的实现者对外暴露自定义的执行状态;最后加一个变量保存异步操作结果。

var Cat = {};

Async = {
  Operation: {
    var callbackQueue = [];
    this.result = undefined;
    this.state = "waiting";
    this.completed = false;
  }
}

addCallback方法

接下来,我们要实现addCallback方法,它的工作职责很简单,就是把回调函数放到callbackQueue中。此外,如果此时completed为true,说明异步操作已经yield过了,则立即调用此回调。

this.yield = function(callback) {
  callbackQueue.push(callback);
  if (this.completed) {
    this.yield(this.result);
  }
  return this;
}


我们假设yield方法会把callbackQueue中的回调函数逐个取出来然后调用,因此如果compeleted为true,则使用已有的result再调用一次yield就可以了,这样yield自然会调用这次添加到callbackQueue的回调函数。

至于最后的return this;,只是为了方便jQuery风格的链式写法,可以通过点号分隔连续添加多个回调函数:

asyncOperation(argument)
  .addCallback(firstCallback)
  .addCallback(secondCallback);

yield方法

最后,我们要实现yield方法。它需要将callbackQueue中的回调函数逐个取出来,然后都调用一遍,并且保证这个操作是异步吧。

this.yield = function(result) {
  var self = this;
  setTimeout(function() {
    self.result = result;
    self.state = "completed";
    self.completed = true;
    while (callbackQueue.length > 0) {
      var callback = callbackQueue.shift();
      callback(self.result);
    }
  }, 1);
  return this;
}


通过使用setTimeout,我们确保了yield的实际操作是异步进行的。然后我们把用户传入yield的结果及相关状态更新到对象属性之上,最后遍历callbackQueue调用所有的回调函数。

小结

这样我们就做好了一个简单的JavaScript异步调用框架,完整的代码可以看这里:异步调用框架Async.Operation

这个框架能够很好的解决调用栈中出现同步异步操作并存的情况,假设所有函数都返回Async.Operation,框架的使用者可以使用一种统一的模式来编写代码,处理函数返回,而无需关心这个函数实际上是同步返回了还是异步返回了。

对于串行调用多个异步函数的情况,我们现在可以用嵌套addCallback的方式来书写,但随着嵌套层数的增多,代码会变得越来越不美观:

firstAsyncOperation().addCallback(function() {
  secondAsyncOperation().addCallback(function() {
    thirdAsyncOperation().addCallback(function() {
      finalSyncOperation();
    });
  });
});


我们能否把嵌套形式改为jQuery风格的链式写法呢?这是我们接下来要思考的问题,如果你不希望错过相关讨论的话,欢迎订阅我的博客:

写个 JavaScript 异步调用框架 (Part 2 - 用例设计)

在上一篇文章里说到,我们要设计一个异步调用框架,最好能够统一同步异步调用的接口,同时具体调用顺序与实现方式无关。那么我们现在就来设计这样一个框架的用例。

传递回调

我们首先要考虑的一个问题是,如何传递回调入口。在最传统的XHR调用当中,回调函数会被作为最后一个参数传递给异步函数:

function asyncOperation(argument, callback)

在参数相当多的时候,我们可以把参数放到一个JSON里面,这样参数就如同具名参数一样,可以通过参数名选择性的传递参数,不传递的参数相当于使用默认值。这是从Prototype开始就流行起来的做法:

function asyncOperation(argument, options)

然而这两种做法都有一个坏处,就是把同步函数改为异步函数(或同步异步混合函数)时,必须显式地修改函数签名,在最后增加一个(或多个)参数。

由于在调用栈的底层引入异步函数对我们来说太常见了,为此可能要更改一大堆上层调用函数签名的成本实在是太高了,所以我们还是想一个不用修改函数签名的做法吧。

在这里我参考了.NET Framework的IAsyncResult设计,把异步操作有关的一切信息集中到一个对象上来,从而避免了对函数签名的修改。在此,我们假设一个异步函数的调用原型是这样子的:

function asyncOperation(argument) {
  operation = new Async.Operation();
  setTimeout(function() { operation.yield("hello world"); }, 1000);
  return operation;
}


在这段代码里,我们返回了一个Operation对象,用于将来传递回调函数。同时,我们通过setTimeout模拟了异步返回结果,而具体的返回方式就是yield方法。

接着,我们还要设计传递回调函数的方法。由于我们不能好像C#那样重载+=运算符,所以只能用函数传递回调函数:

var operation = asyncOperation(argument);
operation.addCallback(function(result) { alert(result); });


在C#里面做这样的设计是不安全的,因为在异步操作可能在添加回调之前就完成了。但在JavaScript里面这样写是安全的,因为JavaScript是单线程的,紧接着asyncOperation的同步addCallback必然先执行,asyncOperation中的异步yield必然后执行。

调用顺序

可能有人要问,如果用户使用同步的方式来调用yield,这时候执行顺序不一样依赖于yield的实现吗?没错,不过yeild是在框架中一次性实现的,我们只要把它做成异步的就可以了,这样即使对它进行同步调用,也不影响执行顺序:

function psudoAsyncOperation(argument) {
  operation = new Async.Operation();
  operation.yield("hello world");
  return operation;
}
var operation = asyncOperation(argument);
operation.addCallback(function(result) { alert(result); });


就算把代码写成这个样子,我们也能确保addCallback先于yield的实际逻辑执行。

事后回调

有时候,框架的使用者可能真的写出了先yield后addCallback的代码。这时候,我认为必须保证addCallback中添加的回调函数会被立即触发。因为用户添加这个回调函数,意味着他期望当异步操作有结果时通知这个回调函数,而这与添加回调函数时异步操作是否完成无关。为此,我们再添加一个用例:

function psudoAsyncOperation(argument) {
  operation = new Async.Operation();
  operation.yield("hello world");
  return operation;
}
var operation = asyncOperation(argument);
setTimeout(function() {
  operation.addCallback(function(result) { alert(result); });
}, 1000);

小结

到这里,我们就设计好了一个名为Async.Operation的异步操作对象,具体如何实现关键的yield方法和addCallback方法将在下一篇文章讲述如果。你不希望错过的话,欢迎订阅我的博客:

2009年5月5日星期二

写个 JavaScript 异步调用框架 (Part 1 - 问题 & 场景)

问题

在Ajax应用中,调用XMLHttpRequest是很常见的情况。特别是以客户端为中心的Ajax应用,各种需要从服务器端获取数据的操作都通过XHR异步调用完成。然而在单线程的JavaScript编程中,XHR异步调用的代码风格实在是与一般的JavaScript代码格格不入。
额外参数
考虑一个除法函数,如果它是纯客户端的同步函数,那么签名会是这样的:

function divide(operand1, operand2)

然而假设我们对客户端除法的精度不满意,于是把除法转移到服务器端来执行,那么它是个需要调用XHR的异步函数,签名也就可能会是以下几种之一:

function divide(operand1, operand2, callback)
function divide(operand1, operand2, successCallback, failureCallback)
function divide(operand1, operand2, options)


我们必须在签名中引入新的参数来传递回调函数,不能选择让函数变成阻塞式的同步调用。
可传递性
不仅仅直接操作XHR的函数需要引入新的参数,这种复杂性还会顺着调用栈向外传递。例如说,我们对加减乘除四则运算作了封装,只向外暴露一个运算接口:

function calculate(operand1, operand2, operator)

这个calculate函数根据operator参数来调用内部的plus, subtract, multiply, divide函数。然而,因为divide函数变成了异步函数,所以整个calculate函数不得不也转变为异步函数:

function calculate(operand1, operand2, operator, callback)

同时,在调用栈之上凡是需要调用到calculate的函数,都必须变成异步的,除非它并不需要向上一级调用函数返回结果。
同步并存
尽管calculate函数变成了一个异步函数,它所调用的plus, subtract, multiply函数还是同步函数,只有调用divide时是异步的,这时候calculate就是一个异步同步并存函数。

这会带来什么问题?calculate的调用者看到函数签名自然会认为calculate是个异步函数,因为它需要传递回调函数,然而calculate的执行方式却是不确定的。考虑如下调用:

calculate(operand1, operand2, operator, callback);
next();


这里涉及到callback和next两个函数,它们哪个先执行哪个后执行是不确定的,或者说是依赖于calculate具体实现的。

如果calculate的实现是,当不需要进行异步操作时,直接调用callback。那么,在执行加减乘法时callback会在next之前被调用;在执行除法时next会在callback之前调用。

如果我们不喜欢这种不确定性,我们可以改变一下calculate的实现,把同步调用也都改为setTimeout形式的,这样在任何情况下next都一定会在callback之前被调用。

然而后面一种做法依赖于成本较高的实现方式,开发者一个不小心(或者摆明偷懒)就会漏掉setTimeout,导致函数调用顺序变得不确定,所以我们会希望这是框架帮助实现的功能,在使用框架时无法把这绕过。

场景

在这里,我举一个关于上述问题的具体应用场景。(为简化问题,描述已略作修改,与实际应用并不一致。)

百度Hi网页版里面,我们会在客户端保存一个用户对象列表,在打开和这个用户的聊天窗口时,我们需要从中读取这个用户的信息。这个操作就涉及很多可能同步又可能异步的分支:
  • 用户对象未缓存
    • 异步读取用户信息
  • 用户对象已缓存
    • 用户是好友(信息更新会由服务器端推送)
      • 同步读取用户信息
    • 用户不是好友(信息更新需要由客户端拉取)
      • 可以接受缓存信息
        • 同步读取用户信息
      • 必须获取最新信息
        • 异步读取用户信息
可以看到,分支的结果最终既有同步的,也有异步的。并且这些分支还不是在一个函数里完成,而是在几个函数里实现。也就是说,按照传统的模式,这些函数一部分是同步的,一部分是异步的,由于异步的传递性,最终调用栈顶层的函数都是异步的。

为了解决这个问题,我们需要写一个异步调用框架,用一种统一的方式来进行调用,把同步和异步调用都合并为一种返回方式。

具体的解决方案会在下一篇文章中给出,如果你不希望错过的话,欢迎订阅我的博客: