Continuation.js——解决JavaScript的异步难题

Continuation.js是一个JavaScript的CPS(Continuation-Passing Style)变换工具,用于简化异步JavaScript编程。Continuation.js是一个把「同步」JavaScript编译为标准JavaScript的编译器,引入了虚拟的函数cont,以便于更容易地写异步的代码。cont不是真正的函数,是一个与函数语法相同的变换标记。使用Continuation.js你可以像顺序的代码一样写出异步的代码,然后由编译器编译为异步调用的风格(延续传递风格,Continuation-Passing Style,CPS)。

Continuation.js演示文稿

Continuation.js

概览

通常用JavaScript写异步的代码十分困难,因为一不小心就会写出这样的代码:

function textProcessing(callback) {
  fs.readFile('somefile.txt', 'utf-8', function (err, contents) {
    if (err) return callback(err);
    //process contents
    contents = contents.toUpperCase();
    fs.readFile('somefile2.txt', 'utf-8', function (err, contents2) {
      if (err) return callback(err);
      contents += contents2;
      fs.writeFile('somefile_concat_uppercase.txt', contents, function (err) {
        if (err) return callback(err);
        callback(null, contents);
      });
    });
  });
}
textProcessing(function (err, contents) {
  if (err)
    console.error(err);
});

这种风格的代码被成为「回调陷阱」或「回调金字塔」。使用Continuation.js,可以直接写成:

function textProcessing(ret) {
  fs.readFile('somefile.txt', 'utf-8', cont(err, contents));
  if (err) return ret(err);
  contents = contents.toUpperCase();
  fs.readFile('somefile2.txt', 'utf-8', cont(err, contents2));
  if (err) return ret(err);
  contents += contents2;
  fs.writeFile('somefile_concat_uppercase.txt', contents, cont(err));
  if (err) return ret(err);
  ret(null, contents);
}
textProcessing(cont(err, contents));
if (err)
  console.error(err);

上面这段代码通过一个虚拟的函数「cont」展平了回调金字塔。当控制流执行到异步的调用时,会等待fs.readFile「返回」,cont参数表里面的变量会被赋值为结果。返回在这里有一些歧义,确切地说是回调函数被调用,不同于「函数返回」的字面意思。因为异步的函数通常会立刻返回,而回调函数可能等到操作执行完成以后才被调用。可以简单理解成cont后面,直到函数结束的部分都是当前异步调用的回调函数。这样的代码令人感觉是顺序执行的,实际上执行中仍然是异步。

更简单地,还可以配合obtain使用JavaScript的try..catch语法。obtain(a)相当于cont(err, a),如果err不是undefined,会被throw抛出。

function textProcessing(ret) {
  try {
    fs.readFile('somefile.txt', 'utf-8', obtain(contents));
    contents = contents.toUpperCase();
    fs.readFile('somefile2.txt', 'utf-8', obtain(contents2));
    contents += contents2;
    fs.writeFile('somefile_concat_uppercase.txt', contents, obtain());
    ret(null, contents);
  } catch(err) {
    ret(err);
  }
}
try {
  textProcessing(obtain(contents));
} catch(err) {
  console.error(err);
}

特性

  • JIT(Just-in-time)和AOT(Ahead-of-time)编译器
  • 没有任何运行时的依赖
  • 没有额外语法,只有cont,obtain,parallel三个保留字
  • 自由的编码风格,编译后的代码依然可读
  • 兼容CoffeeScript和LiveScript(以及其他编译到js的语言)
  • 支持Node.js和浏览器端JavaScript
  • 支持并行化和轻量级线程

文档

cont

cont是一个异步调用的标记,用于接受异步返回的结果,必须用在函数调用的参数表中,代替回调函数的位置。cont的参数表中的变量会被设置为异步的回调函数的参数,作为返回值。如果cont参数表中有变量(而非表达式),变量会被自动定义。

例子:

setTimeout(cont(), 1000);

fs.lstat('/path/file', cont(err, stats));

var obj;
fs.readdir('/path', cont(err, obj.files));

编译后的代码:

var err, stats, obj;
setTimeout(function () {
  fs.lstat('/path', function () {
    err = arguments[0];
    stats = arguments[1];
    fs.readdir('/path', function () {
      err = arguments[0];
      obj.files = arguments[1];
    });
  });
}, 1000);

obtain

obtain是一个cont和throw的语法糖。可以使用try..catch来捕获obtain抛出的异常。使用obtain的一个假设是回调函数的第一个参数是错误对象,如果没有错误则被设置为undefined或者null,(这是Node.js API的一个约定)。

例子:

function f1() {
  fs.readdir('/path', obtain(files));
}

function f2() {
  fs.readdir('/path', cont(err, files));
  if (err)
    throw err;
}

编译后的代码:

function f1() {
  var _$err, files;
  fs.readdir('/path', function () {
    _$err = arguments[0];
    files = arguments[1];
    if (_$err)
      throw _$err;
  });
}
function f2() {
  var err, files;
  fs.readdir('/path', function () {
    err = arguments[0];
    files = arguments[1];
    if (err) {
      throw err;
    }
  });
}

parallel

parallel可以让异步的函数「并行」地执行。parallel也是一个虚拟的函数调用,它的参数必须都是一个带cont或obtain的函数调用。参数中所有函数调用会并行地执行,直到所有的并行函数都执行结束后(确切地说是回调函数被调用),控制流才继续向后执行。

注意无论是Node.js还是浏览器JavaScript,代码都是单线程执行的,所以parallel并不是一个系统级别的多线程的实现。由于函数调用很像一个线程的入口,所以可以称为「轻量级线程」。只有I/O和计算之间可以并行执行,计算和计算仍然是顺序的。可以理解为当一个线程被I/O阻塞时,另一个就可以运行了,这些「线程」可以自动调度利用计算和I/O的空隙,以实现并行。所有如果要用parallel,请确保「线程」中以I/O为主,而不是大量的计算。

例子:

var contents = {};
parallel(
  fs.readFile('/path1', obtain(contents.file1)),
  fs.readFile('/path2', obtain(contents.file2)),
  fs.readFile('/path3', obtain(contents.file3))
);
console.log(contents);

显式声明模式

Continuation.js可以自动递归编译使用require调用的模块,如果在需要编译的文件加上'use continuation',同时使用continuation script.js --explicit运行模块,只有标注了'use continuation'的模块才会被编译。使用这个选项可以加快模块载入,但要确保每个需要编译的文件上加上'use continuation'。

编译缓存

使用contination script.js --cache [cacheDir]运行时,所有编译过的模块会被缓存到cacheDir,下一次再运行时就可以直接取用。如果源文件的时间戳新于缓存,那么就会被重新编译。这个选项依赖于系统时间戳,使用它也可以大幅度减少载入时间。推荐--explicit和--cache同时使用。

默认cacheDir是/tmp/continuation

使用CoffeeScript(以及其他编译到js的语言)

Continuation.js于绝大部分编译到js的语言兼容,因为没有引入任何新的语法,仅有的三个关键字cont,obtain和parallel都与函数调用的语法相同,因此可以直接使用对应语言的函数调用语法。

例子(CoffeeScript):

dns = require('dns')
domains = ['www.google.com', 'nodejs.org', 'www.byvoid.com']
for domain in domains
  dns.resolve domain, obtain(addresses)
  console.log addresses

目前Continuation.js内建支持CoffeeScriptLiveScript

在程序中使用Continuation.js

Continuation.js支持作为模块调用,提供一个compile(code)接口,code是表示代码的字符串。

例子:

var continuation = require('continuation');

function fibonacci() {
  var a = 0, current = 1;
  while (true) {
    var b = a;
    a = current;
    current = a + b;
    setTimeout(cont(), 1000);
    console.log(current);
  }
};

var code = fibonacci.toString();
var compiledCode = continuation.compile(code);
console.log(compiledCode);
eval(compiledCode);
fibonacci();

以上这段代码可以直接用node命令运行,无需安装Continuation.js到全局环境。这段代码把一个函数转换为字符串,然后调用Continuation.js编译,最后再通过eval运行。

安装

通过npm安装Continuation.js:

npm install -g continuation

使用

Usage: continuation [options] <file.js/file.coffee/file.ls> [arguments]

Options:

  -h, --help               output usage information
  -V, --version            output the version number
  -p, --print              compile script file and print it
  -o, --output <filename>  compile script file and save as <filename>
  -e, --explicit           compile only if "use continuation" is explicitly declared
  -c, --cache [directory]  run and cache compiled sources to [directory], by default [directory] is /tmp/continuation
  -v, --verbose            print verbosal information to stderr

直接使用Continuation.js运行代码(例如script.js):

contination script.js

将编译后的代码输出到终端:

contination script.js -p

将编译后的代码保存到另一个文件:

contination script.js -o compiled.js

使用显式标记模式运行代码:(只编译带有'use continuation'的代码):

contination script.js -e

开启编译缓存:

contination script.js -c

例子

循环和延时

计算Fibonacci数列,每秒钟输出一个:

var fib = function () {
  var a = 0, current = 1;
  while (true) {
    var b = a;
    a = current;
    current = a + b;
    setTimeout(cont(), 1000);
    console.log(current);
  }
};
fib();

顺序执行异步代码

顺序读5个文件

var fs = require('fs');

for (var i = 0; i < 4; i++) {
  fs.readFile('text' + i + '.js', 'utf-8', obtain(text));
  console.log(text);
}

console.log('Done');

并行执行异步函数

var fs = require('fs');
var dns = require('dns');
var http = require('http');

var complexWork = function (next) {
  setTimeout(cont(), 500);
  http.get('https://www.byvoid.com', cont(res));
  next(null, res.headers);
};

parallel(
  fs.readdir('/', obtain(files)),
  dns.resolve('npmjs.org', obtain(addresses)),
  complexWork(obtain(result))
);

console.log(files, addresses, result);

递归

计算目录所占磁盘空间:

var fs = require('fs');

function calcDirSize(path, callback) {
  var dirSize = 0, dirBlockSize = 0;
  fs.readdir(path, obtain(files));
  for (var i = 0; i < files.length; i++) {
    var filename = path + '/' + files[i];
    fs.lstat(filename, obtain(stats));
    if (stats.isDirectory()) {
      calcDirSize(filename, obtain(subDirSize, subDirBlockSize));
      dirSize += subDirSize;
      dirBlockSize += subDirBlockSize;
    } else {
      dirSize += stats.size;
      dirBlockSize += 512 * stats.blocks;
    }
  }
  callback(null, dirSize, dirBlockSize);
}

var path = process.argv[2];
if (!path) path = '.';

calcDirSize(path, obtain(totalSize, totalBlockSize));

console.log('Size:', Math.round(totalSize / 1024), 'KB');
console.log('Actual Size on Disk:', Math.round(totalBlockSize / 1024), 'KB');

更多的例子可以在代码'examples'和'test'目录中找到。

相关的项目

Continuation.js不是惟一的CPS变换工具,有一些其他的解决方案。下面是一个简单的比较:

项目 Continuation.js streamline.js TameJS Wind.js jwacs NarrativeJS StratifiedJS
Node.js支持
浏览器支持
运行时依赖
额外语法
兼容编译到js的语言 是 (CoffeeScript) 是 (CoffeeScript) 是 (手动)
并行支持 未知
生成的代码可读性 困难 几乎可读 未知 未知
异步结果传递方式 参数表 返回值 参数表 返回值 返回值 返回值 返回值
文档 不详
实现 JavaScript JavaScript JavaScript JavaScript Lisp Java JavaScript