Node.js异步动作机
发布于 10 年前 作者 zealoth 4823 次浏览 最后一次编辑是 8 年前

原理

Node.js的异步编程模型,有许多各种各样的实现。看来看去还没有发现和我的这个一样的。所以就写出来,让大家讨论讨论。

这个东西,我以前称之为状态机,来源于程序的状态图。但后来发现其实我要描述的,不是状态的变化,而是动作的连续。所以称之为动作机比较好。

简而言之,我是把动作串连起来,从拓朴结构上说就包含许多动作的有向图。

一个动作(Action)完成一个特定功能,可以类比一个方法。但是动作和方法有一个差异。

常见的编程语言中的方法,或是函数,都是一个入口,一个出口,即被调用者调用,然后返回调用者,返回时带上一个或多个数据作为返回值。也就是说出口一定是被调用者。

我所定义的动作不一样之处,就是它可以有任意多个出口。每个出口有其特定的含义。

比如说,打开文件,一般的方法来说,它就是返回成功了返回文件号,否则是负数表示出错,然后在外层代码来作后续处理。而对于动作来说。它就会有两个出口,一个是失败的出口,一个成功的出口。然后,在不同的出口上接上后续的动作。

为了表示动作的变化流程,我定义了动作的迁移图。有一个入口动作,然后将上一个动作的出口和下一个动作的入口相连,这个图就出来了,并表达了整个流程。

当然,动作及迁移图是不会自己运行起来的,它们的逻辑结构和现有的基于栈的函数调用模型格格不入。所以我就需要一个调度器。能使它们能运行起来。称之为动作调度器。

所以总结一下,我在动作机中,定义了三个东西:

  • 动作,准确的说,应该是一堆动作,我把它们放一个表里,称之为动作表,用名称索引。
  • 迁移图
  • 调度器

动作机的运行,基本是这样的流程:

  1. 当前动作被设置为入口动作;
  2. 调度器,在动作表中,找下个要执行的动作;
  3. 找到动作后执行之。动作执行后,返回调度器;
  4. 调度器检查动作的出口是哪个;
  5. 调度器查迁移图,找这个出口对应的下个动作名称;
  6. 回到第1步。

细节

下面举一个读文件例子:

迁移图

移图是通过JSON定义的

{
    states: [
        ["filter", 1, -2],      // 数据组下标0
        [“stat", 2],           // 1
        ["stated", 3, -3],        // 2
        ["read", 4],            // 3
        ["readed", -1, -4]      // 4
    ]
}

states是一个数组,之所以用states这个名称是历史原因。这个数组共五个元素,每个代表了一个动作。每个动作本身又是一个数组。

比如第1个[“filter”, 1, -2],名叫filter,数组下标0,也就是默认的入口动作。filter后面带的两个数字是表示它有的两个出口。

filter的功能是将传入的文件名进行过滤,看这个文件是不是认识的,可以打开。出口1表示这个文件可以后续处理读取,出口2表示这个读这个文件非法, 不允许读。

现在,出口1 定义了数字1, 表示这个出口,连到了数组下标1的动作上,即第二个动作[“stat”, 2]。

stat即是调用了node的fs.stat这个异步的api。而它只一个出口,值是2,即连到了下标为2的[“stated”, 3, -3]。

而stated动作是查看stat的返回值,两个出口分别代表,成功和失败,即文件不存在或无法读取。

回过来再来看filter的第二个出口,它的值是-2是什么意思呢?这个整个迁移表从外部来看,它整个可以被视为一个动作。这个动作可以纳入其它迁移图。这即是迁移图的嵌套。这个动作就是读文件,它有四个出口,出口1代表这个文件读取成功了,数据也已放置到了约定好的变量中。出口2代表这个文件因为路径及文件名的原因,根本不允许读。出口3代表,这个文件不存在或无法访问。出口4代表,读了这个文件但是读失败了。所以,我想你也许猜到了,这个-2表示从这个迁移图的出口2出去。这样在上层的迁移图中,你就可以找到它对应的下个动作了。

看到这里能看明白的。表示你的专注力惊人,因为我觉得已经把自己说晕了。

动作

下面介绍一下具体的动作的写法。 在Node.js的动作机实现中,一个动作即一个对象的方法,比如下标为2的动作“stated"

filehandler.prototype.stated = function(err, stats)
{
    if (err)
    {
        this.result(2)
        this.reason = "Stat error, " + err
        this.go()
        return
    }
   
    this.stats = stats
    this.result(1)
    this.go()
}

请看其中的两句,

  • this.result(2), 这个表示从出口2出去
  • this.result(1), 这个表示从出口1出去 而this.go(),则是让动作机将下一个动作的方法,挂到Node.js的事件循环中去,并在下个循环中立即执行。 说白了,就是调用了process.nextTick(callback_function),这个callback_function即是通过查迁移表得到的。

以上所述是针对基本同步的动作的调用,另外一种是针对异步的,比如下标为1的stat的动作

filehandler.prototype.check = function()
{
    fs.stat(this.filename, this.result(1));          //fs.stat 是Node.js的api,一个异步function.
}

fs.stat 是一个异步function. 就直接将this.result(1)作为回调塞给它。不需要this.go()了。

归纳的话,就是 决定动作出口的方法是调用this.result(出口编号),注意出口编号是从1开始的。 然后如是立即的顺序执行,就调用this.go(), 如果下出口由异步方法触发,则将this.result直接挂在异步的回调上。

调度器

那filehandler对象的定义

var cb = require('./callback')

function filehandler(name)
{
    cb.initStateMachine(this)
    this.filename = name
}

cb.applyStateMachine(filehandler)

我写了一个callback.js的模块,定义了statemachine这个对象,其实就我说的动作机,调度器。 这儿你可以看到filehandler采用javascript中继承的写法,继承了statemachine这个对象,所以它内部就有了this.result, this.go, this.callback等动作机要用的方法。

如果你来写的话,就要只直接写filehandler的函数,每个函数只要用this.result定义了出口,它就是一个可以当成动作来用了。

数据

那么动作间的数据如何传递? 基本上说,是通过filehandler实例的成员数据来操作的,filehandler中本身是一个动作机,它的成员方法即是动作,它本身也是一个数据容器,也可称之为上下文,各成员方法约定好操作的成员数据就好了。

好处

下面谈这个动作机的好处。 这个真正做了功能和业务逻辑分离。编码被分为两个部分,

  1. 功能部分,即实现独立的各个动作。每个动作与其它动作解耦。
  2. 业务逻辑通过迁移表来定义,JSON格式,方便增减动作。

另外,这动作机可以执行表达分层的逻辑,迁移图可以嵌套(调用),或递归。

我写的相友象棋,web后端就是用node.js写的,全程采用这种动作机模型。代码可以参考 github.com/zhentao-huang/cchess, 在工程的web/node目录下就有callback.js。

8 回复

你确定没有重复造轮子?

不造轮子,你永远不知道轮子到底能有多大用处

我的确在找看是否有人和我有相同的实现。 但一直没找到。 最近,看到的有层次状态机,但做法与我的还是不一样。

收藏了 以后看

这和jbpm的原理不是一样的么?transition的概念

刚看了一下,有相似的部分。 jbpm定义了一些语法来解决分支的问题,而我的不需要语法如: when, then等支持。

看完“原理”部分,第一个想到的是promise,我觉得你这个是在promise上面再包装一层的思路,你的底层可以考虑用promise来实现看看

回到顶部