Skip to content

关于Continuer的What与Why

aimingoo edited this page Jan 4, 2015 · 3 revisions

起初


促使我动手写Continuer的原因是我当时正在做一个RESTApi接口的测试,它需要做一些这样简单的调用:

var srv = 'http://localhost/api/'
var req = require('request');

// case 1
req.get(srv+'select?id=12345', function(err, resp, body){
	console.log(body)
})

// case 2
req.get(srv+'insert?id=12345&info=[1,2,3,4]', function(err, resp, body){
	console.log(body)
})

// case 3
req.get(srv+'delete?id=12345&idx=1', function(err, resp, body){
	console.log(body)
})

...

接下来,以case2为例,我们要在insert操作之后检查结果是否正确,应该怎么做才有效呢?当然应该是在callback里来发起一次新的request。例如:

// case 2
req.get(srv+'insert?id=12345&info=[1,2,3,4]', function(err, resp, body){
	console.log(body)

	// re-check
	req.get(srv+'select?id=12345', function(err, resp, body){
		console.log(body)
	})
})

这是显而易见的、基于callback处理的方案:如果你有一件事情要在action(例如这里的insert)之后做,那么你应该放在它的callback里面。

但是,针对于“测试RESTApi接口”这样的事情来说,几乎需要所有的事情都基于一个序列,而不是同步地去完成它。例如上面的case2与case3,如果按开始的这个写法,case3的delete请求就可能在case2(insert)之前就到达srv,并开始处理。而这时由于Info的1..4并不存在,所以delete?idx=1这样的操作就失效了。更严重的事情是,如果按后面讲到的“在callback里发起re-check”的做法,那么这些recheck事实上可能检查到别的action的结果。所以整件事就变成了需要这么干:

// cases
req.get(srv+'select?id=12345', function(err, resp, body){
	console.log(body)
	req.get(srv+'insert?id=12345&info=[1,2,3,4]', function(err, resp, body){
		console.log(body)
		req.get(srv+'select?id=12345', function(err, resp, body){
			console.log(body)
			req.get(srv+'delete?id=12345&idx=1', function(err, resp, body){
				console.log(body)
				...
					...

好吧,我相信你已经明白我要说的,因为同样的悲剧已经在很多地方上演过了。

有时我们确实需要一个序列的操作,但异步与callback搅乱了一切。

选择


nodejs里面有解决这类问题的通用机制吗?

我的第一反应其实就是“function queue”,这是很显然的。然而在git上查找了很久,并没有合适的:通常都是封装了一堆胡扯的功能,看起来很好,用起来坑多。换个思路,例如“un-block”,又或者是"async"呢?其实上这些概念都过于重量了:他们为了实现某个概念加入了太多的东西。

比如promise。Oh... 它的基本逻辑是:你到callback里去打个桩,触发一次事件调用,然后在事件调用里再来做onXXXX。只不过,他把on('XXXX')换成了then('XXXX')而已,在概念上仍然是基于事件触发的。这有什么不好么?严格来说,在nodejs里,这是很自然、正确的解题思路,因为这个机制还能保证系统整体的异步的(参见阮一峰老师的:Javascript异步编程的4种方法)。但我需要的是什么?“一组能顺序处理的functions”而已啊。我为什么要先定义一堆“xxxx”来做事件,然后再来响应之?

真的是很繁重的。

至于async,再来看看它的接口吧,map/filter/parallel/series ..., more and more! 一部分是基于数组的,另一部分则是针对async自身的。怎么用呢?如果我真的需要“在不同的callback里处理各自的序列”,它基本上就昏掉了。

我需要的只不过是“一个序列的操作”而已啊。

简单的实现


这实在再简单不过了。

var q = []
q.next = function(func, args) { /* 向队列q中塞入一个要执行的函数,以及它的参数 */ }
q.do = function() { /* 执行队列q中的全部函数, 先入先出 */ }

基于上述的接口,我们不停地q.next(),然后q.do()一次,不就可以了?我们只需要确保q[0]结束之后,才会调用到q[1],... 等等如此类推。

然而你知道的,nodejs是用callback来实现异步的。所以……什么呢?所以我们这里在队列中的多数函数可能都是一些callback,它什么时候会“发生”,天知道。

而这才是麻烦之处。做一个function queue,其实并不麻烦,就象上面声明的接口那样做就行了。——而且事实上也没有必要在一个“同步/顺序执行”的环境中再去做一个function queue,顺序调用这些函数就可以了啊。

所以,回到这个接口的设计上来,我们只是需要用一种方法来声明“where is callback”,然后我们在它外面warp一下,让他执行完再去处理function queue的下一步,就好了。

这就是第三个接口函数的由来:

q.isCallback = function(func) { /* 标识参数中的某个函数是callback函数,如果不标识,则默认为参数中的最后一个参数 */ }

最基本的用法


上面这个最简单的实现提供了Continuer的最基本的使用方法。仍以上面的三个case为例。由于request.get()的最后一个参数正好是callback——当然大多数nodejs代码也采用这一约定,所以它们事实上不需要用到isCallback()工具函数。示例如下:

var srv = 'http://localhost/api/'
var req = require('request');
var getter = req.get.bind(req);

// show body
function cb(err, resp, body){
	console.log(resp.statusCode, body)
}

// re-check
function recheck(err, resp, body){
	console.log('recheck: ' + body)
}

require('continuer')
 // case 1
 .next(getter, [srv+'select?id=12345', cb])
 // case 2
 .next(getter, [srv+'insert?id=12345&info=[1,2,3,4]', cb])
 .next(getter, [srv+'select?id=12345', recheck])
 // case 3
 .next(getter, [srv+'delete?id=12345&idx=1', cb])
 .next(getter, [srv+'select?id=12345', recheck])
 .do()

这就是Continuer这个nodejs模块要解决的问题,与它的解法。

封装


接下来是要在github上交付一个“通用一点儿”的Continuer了。而前面“简单的实现”中的设计是粗陋了一点,所以——很自然地——我们需要“封装”一下它。

与promise/async等这些方案不同,我对封装的理解是:越少越好;简单与灵活并存。

做封装很多时候是“变大”的一个基本心理在作祟。所以籍这个理由,很多人做出一个个庞大而又难于使用的东西,做系统如此,做模块也是如此。同样,另一些人把封装理解为“让细节藏起来”,基于这样的系统理念,诸多“封装”让系统/模块变得越来越僵化、越来越难解。而事实上“让细节藏起来”的口号只喊对了一半,另一半是“把功能展示出去”。

不明确用户需求的“封装”无异于做皇帝的新衣呵。

好吧,牢骚发完。说说Continuer的封装吧。提供以下两种装载和初始化的接口是无需多言的(基本上它们是nodejs中的惯例),

// get global singleton
var c = require('continuer')
// a new instance
var c = require('continuer')()

接下来就是上面提到的三个接口:

c.next(func, args, continuator)
c.isCallback(func, args, continuator)
c.do()

然后呢?然后,没了。

保持简单,不要做太多的封装。

相信我,这是对的。

Powerful/Advanced


于是又有人说了:那你那个Continuer的README.md写得那么多,看起来NB得不得了的样子,唬弄码农么?!

还真不是。

对比之前的设计,上面的接口多出一个东西,叫continuator。然而什么是continuator呢?它为什么必须存在呢?

这个事情首先是这样:作为一个function queue,我们塞什么东西进去,它都应该被执行。对吧?然而上面这个Queue其实依赖callback——它让你标识出callback,并在callback完成后调用next step。而这样一来,如果一个函数并不支持callback,就……完蛋了。整个function queue因为缺乏一种“驱动执行next step”的机制而崩溃了。

这在别的function queue中也有解决方案:在外面再跑一个setTimeout,然后再设定当前函数需要的timeout值,然后再确定下一个函数的触发,然后……

其实不需要那么复杂。Continuer提出的方法很简单:使用continuator。这是一个对象,带有一个.do()方法,仅此。

在接口上,我告诉你“需要”执行即可。how do? you know!

所以Continuer中允许你随时加入一个continuator,来确保一个不支持callback的函数能得到执行——有点“不那么明显”的事情是:其实continuer自身也是一个continutor。所以,我们假设在上面的例子中要加入一个普通函数,那么可以是这样:

var c = require('continuer')
c.next(getter, [srv+'select?id=12345', cb])
 .next(getter, [srv+'insert?id=12345&info=[1,2,3,4]', cb])
 .next(getter, [srv+'select?id=12345', recheck])
 // 插入普通函数
 .next(console.log, ["累死个人啊"], c)
 .next(getter, [srv+'delete?id=12345&idx=1', cb])
 // 再来一次
 .next(console.log, ["又累死一个啊"], c)
 .next(getter, [srv+'select?id=12345', recheck])
 .do()

怎么理解呢?c就是continuer,而它自己也是continutor,所以当console.log不支持回调的时候,就把“做下一步(continue)”的职责交给它就可以了。

它做什么?拜托,它不就是“处理队列中的下一个”么?概念完整!

再多讲一点


接下来小议一下Powerful/Advanced功能中的“参数”。

参数是在这类处理中比较“罗嗦”的。我们可以假设args的最后一个参数如果是函数,就是callback(rule-1)。但如果不是呢?例如setTimeout(),它的最后一个参数就是timeout值。好吧,这种情况下我们使用isCallback()来指定(rule-2)。所以,在Continuer中加入一个setTimeout的做法就必须是:

c.next(setTimeout, [c.isCallback(func), 100])

但是你知道的,如果这种情况下最后一个参数又是一个函数呢(rule-3)?God!这与上面的rule-1又冲突了。

好吧,rule-3也是有解的,例如写成:

// f is function, but isnt callback
function func(arg1, cb, f) { ... }

// set continuator to TRUE value, tag of <lastIsntCallback>
c.next(func, [arg1, c.isCallback(cb), f], true)

类似这样的参数处理并不多,因为大多数对“参数”的理解都依赖于调用者。其中之一,就是上面在Request模块中会遇到的:callback时,由host传入的参数如何处理?在Request.get(..., callback)这个接口中,callback的约定是:

function callback(error, response, body)

这是一组稍后(由callback时的host)来交付的参数集。对于这一点,isCallback()留出了一个约定:

c.isCallback(func, args, continuator)

args的取值有两种。其一是Array/Arguments,其二为false value(包括undefined/null等等)。当它为前者时,用来作为参数,在func作为callback时传入;如果它为后者,则表明参数是lzayed,参数的具体值由callback时的host决定。

那么,显而易见的,Request.get调用时,callback是由host决定的。而如果你想给setTimeout的callback传入一点参数,那么在continuer中也就能轻易做到了:

var c = require('continuer')()
var cb = function(msg) { console.log('say:', msg) }
c.next(setTimeout, [c.isCallback(cb, ['Hi!']), 1000])
 .do()

对于更复杂的参数处理,Continuer将这样的机会/权利留给用户,并“稍稍地”在README上做了一点指引(事实上这大多数与continutor有关)。例如,如果一个参数入口可能实际上是上一个函数的返回值呢?

这其实是比较常见的情况,而“根据”对callback的约定,callback是不应该有返回值的,它可以触发下一个action,但它不向任何一个“已知的/已确定的行为”返回数据(因为它应该是确定行为的终结,而非其它确定行为的前设)。但……是……我们现在是在处理一个function queue,它“不完全是”一个callback chain,因而更应该理解为一组函数,这种情况下prev/next step之间存在联系就是自然的了。

OK。怎么做呢?lazyed arguments的概念就来自于这里。我们看看,刚才在处理callback时,callback的参数通常是lazyed,由host来决定如何传入。而这种情况下,我们调用:

c.isCallback(func, args, continutor)

这时应当置args为false/undefined,表明现在不知道参数、延迟到后面调用时来决定。那么这个概念与我们这里讨论的完全一致,因此.next()中也允许这样来处理,接口是:

c.next(func, args, continutor)

当args为undefined/null/false/...时,它也“延迟地(lazyed)”由host来决定。

请问Host是谁?

回到我们最开始的概念设定:谁调用这一组function queue?当然,是continuer!所以,当前的这个c.next(func, ...)中的func是由continuer来调用的。——准确地说,是在上一个step结束时,由某个continutor来调用的。

换而言之,上一个step结束时的continutor,可以设置下一个step的参数。这就是lazyed arguments的完整概念/逻辑。实现起来,也很简单:

var c = require('continuer')();
var continuedArgs = function(continuer) {
  return {
    'do': function() { continuer.do(continuer[0][1] = arguments) }
  }
}(c)

由于continuedArgs是一个有.do()方法的对象,所以它也就是一个continutor。它将自己得到的参数塞给function queue顶部的一个函数的参数表,并随后调用c.do(),就打完收工了。

使用它时,只需要这样:

c.next(a, [], continuedArgs)
 .next(b) // <-- lazyed
 .do()

这表明a()处理的结果,由continuedArgs这个continutor交给了下一个step的b(),而b的参数表为undefined,所以是lazyed。

更加复杂的用法呢?想象一下,由于continutor开放了function queue顶端,这意味着“下一步(next step)”总是由你(以及你的continutor.do)来决定的,那么我还要操什么心呢?

简单,但是强大。

这就足够了。