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