最近想研究下在看 TJ (PS: 不是新上任掌門人的那個 TJ) 的 co 和 Koa,這兩個項目是 TJ 根據 EMCAScript Harmony 標準中, 關於生成器標準所做的項目,前者是一個 shim-library 而後者爲 connect 的進化版, 它可以幫助開發者快速上手 Harmony 中的新特性——生成器(Generators),並用其嘗試着解決一直困擾着我們的異步嵌套問題(Nested Callbacks) 。

Koa......pre

Koa 是由 TJ 等人組成的 express 幕後貢獻組所開發的一個 Web 開發框架。其副標題中,突出了 "generation" 一詞,
這也就是其使用了 Harmony 中的生成器特性的一個體現。

我覺得與其現在開始講 Koa,還是先給大家講講 co 吧,不然如果直接講 Koa 的實現會讓大家感覺一頭霧水。

co

co 是什麼?co 是一個異步流程簡化工具,它利用了 Harmony 中的生產器特性,把平時看到的一層層的異步調用,
變成我們最熟悉的同步寫法。
下面我們來從生成器特性開始講起。

Harmony Generator

生成器特性是 ECMA-262 在第六个版本,即我们说的 Harmony 中所提出的新特性。 目前在 Python, Lua, Scheme, Smalltalk 等流行語言中都能它的身影。

First-class coroutines, represented as objects encapsulating suspended execution contexts (i.e., function activations).
來自 harmony:generators

這是 ECMAScript 制定組給在目前的日誌版本中對生成器特性的描述:

  1. 和 Function 一樣,是 ECMAScript Harmony 標準中的第一公民
  2. 生成器是協程的一種表達方式
  3. 它表現爲一種包裝了被yield語句切割和暫停的執行內容的對象。

相信有不少同學已經查閱過網上各種關於生成器的 DEMO,也自己動手實踐過了, 不過我這裏還是再給大家一個很小的例子,幫助大家理解生成器特性。

如果想要嘗試 Harmony 的話,在 Node.js v0.11.* 的版本中,使用--harmony選項來開啟 Node.js 對 Harmony 的支持; 在 Chrome 30及以後的版本中可以在 about:flags 中開啟 Enable Experimental JavaScript (實驗性 JavaScript)。

var echo = function *() {
  yield 'Hello';
  yield 'World';

  return '!';
};

var generator = echo();

var res = [];
var curr = null;

res.push((curr = generator.next()).value);
res.push((curr = generator.next()).value);
curr = generator.next(); // Make it done
if (curr.done) {
  console.log(res.join(' ') + curr.value); //=> Hello World!
}

在上面的代碼中,echo是一個生成器函數(在函數名之前有一個*符號),它內部有三個操作:兩次yield,一次return。 上面說到關於yield把執行內容給切割開,而這裏爲了節省筆墨我就不詳細扯 JavaScript 編譯原理之類的內容了。

程序執行echo()時,返回的不是我們從前return的內容,而是返回一個生成器對象,且不會馬上執行函數體內容。

interface Generator {
  getter Object next(Any value);
  setter void throw(Error err);
}

在程序執行generator.next()時,就會從函數體始端或上一次yield斷點開始執行,直到下一個yieldreturn時停止。

簡單地說,JavaScript 引擎在運行到yield 'Hello';的時候,會先把後面的'Hello'執行,然後引擎找到yield, 此時引擎就會把當前上下文的執行時切入協程,並向主線程拋出yield後面所得到的對象。
此時,主線程的generator.next()會返回一個對象,內容爲以下:

{
  value: ...,
  done: true/false
}

value爲剛纔在yield後面得到的對象,done爲生成器當前的完成狀態,若生成器執行到return或函數結束,done便爲true。 這裏需要提醒的是,return的對象由在所有yield完成之後的最後一次generator.next().value返回。

總而言之,生成器就是一種可以把執行內容切割成一段一段並拋入協程中,並交由主線程的生成器對象進行控制的工具。

Generator + Async = ?

在見識過生成器之後,你可能會在想,co 究竟是如何把這項技術運用到異步流程上的呢? 我們先來看看 TJ 在 co 的 README 中給出的一個示例。

function read(path, encoding) {
  return function(cb){
    fs.readFile(path, encoding, cb);
  }
}

co(function *() {
  var buffer = yield read(__dirname + '/foo.txt', 'utf8');
  // Just like using fs.readFileSync!

  console.log(buffer.toString());

  return buffer;
})(function(err, buffer) {
  // ...
});

這裏我們可以通過 co 把fs.readFile當作fs.readFileSync一樣用,只需要把我們需要變形的異步函數用兩層函數包裝起來即可, 這樣就形成了一個高級函數。 最外面的函數是我們在真正運行時需要執行的函數,而裏面的函數是 co 內部的實現所需,待會我們會詳細講解其中的奧妙。

如果你覺得自己包裝太麻煩,你還可以通過用 TJ 的node-thunkify來簡化操作。

var fs = require('fs');
var thunkify = require('thunkify');

fs.readFile = thunkify(fs.readFile);

co(function *() {
  var buffer = yield fs.readFile(__dirname + '/foo.txt');
  // ...

  return buffer;
})(function(err, buffer) {
  // ...
});

回歸正題,通過 co 我們可以把以前一大串的異步回調嵌套變成我們最熟悉的同步寫法。

就像這樣。

// Before co
var redisClient = ...;
var mongoCollection = ...;
var pgClient = ...;

redisClient.spop('users', function(err, id) {
  if (err)
    return console.error(err);

  mongoCollection.findOne({ user_id: id }, function(err, user) {
    if (err)
      return console.error(err);

    pgClient.query("SELECT * FROM items WHERE weibo=$1;", [ user.weibo ], function(err, result) {
      if (err)
        return console.error(err);

      var items = result.rows;

      console.log(items);
    });
  })
});

// co
var co = require('co');
var thunkfiy = require('thunkfiy');
var redisClient = ...;
var mongoCollection = ...;
var pgClient = ...;

redisClient.spop = thunkfiy(redisClient.spop);
mongoCollection.findOne = thunkfiy(mongoCollection.findOne);
pgClient.query = thunkfiy(pgClient.query);

co(function *() {
  var id = yield redisClient.spop('users');
  var user = yield mongoCollection.findOne({ user_id: id });
  var items = (yield pgClient.query("SELECT * FROM items WHERE weibo=$1;", [ user.weibo ])).rows;

  return items;
})(function(err, items) {
  // ...
});

異步回調耗掉我們多少光陰啊。。(致我們終將逝去的青春

自從 co 出現以後,Node.js 工程師終於可以以最熟悉的方式去完成這最煩人的任務了。
那麼 co 究竟是如何實現這樣的體驗的呢?co 的核心代碼不足百行,其中的奧妙其實很簡單。

我們再回頭看看生成器的 API,生成器對象有一個next方法,它可以控制協程的運行。 而在之前的日誌版本 Harmony Generator 規範中,生成器對象還有一個send方法,它的作用爲把傳入的第一個參數返回到協程中, 作爲yield語句的返回值。但是在目前的更新版本中,該方法被廢棄,其功能則被整合到next方法中, 也就是可以這樣對協程進行操作:

function *foo() {
  var bar = yield 'foo';

  console.log(bar);
}

var gen = foo();
var curr = gen.next();

gen.next(curr.value + 'bar');
//=> print 'foobar'

另外之前說到的 co 需要開發者把所要變形的異步方法套兩層函數,而裏面的那一層就會在 co 的內部進行調用。 將其核心執行原理簡化便是如下:

function echo(content) {
  return function(callback) {
    callback(null, content);
  };
}

function *exec() {
  var foo = yield echo('bar');

  console.log(foo);
}
var gen = exec();
var curr = gen.next(); // now curr.value is the Inner Function

curr.value(function(err, value) {
  if (err)
    return console.log(err);

  gen.next(value);
  //=> print 'bar'
});

echo函數是我們需要進行變形的異步方法,在exec這個生成器函數中,我們調用其並傳入一個字符串。 此時 JavaScript 引擎因爲執行到了yield而將當前執行內容切入協程並回到主線程,主線程的生成器對象則獲得了echo函數 返回的內層函數,主線程將其執行並通過異步回調獲得返回值;然後我們再通過生成器對象將返回值送回協程中去。
這就是 co 內部的一次yield流程中,最重要的一步。 co 另外做得最多的事情就是toThunk,把 Array, Object, Generator, GeneratorFunction, Promise 等東西轉化爲 co 能使用的thunk

co 就這樣一步步地執行主線程和協程之間的切換,協程負責業務流程,主線程負責真正的異步操作,這樣就實現“同步”的體驗了。

如果你覺得讀 co 的源碼有點困難,可以看看我寫的一個“簡化版”的 co。Gist - A simple version of co

Coroutine 協程

哈哈,估計很多人看到這個 Subhead 馬上就軟了,JavaScript 明明就是一爲表層業務而使用的語言,爲啥還要接觸這些這麼高級的東西呢? 不過爲了讓大家能更好地理解 co 和 Koa 的內部原理和機制,簡單地介紹以下協程這個概念是有必要的。

Coroutines are computer program components that generalize subroutines to allow multiple entry points for suspending and resuming execution at certain locations.
--From Wikipedia

協程與進程、線程爲同等級別的一種計算機程序部件,它是一種允許設置多個入口和出口(即我們上面所說的執行內容切割點)的子程序。 我們可以把協程形象地看作是老闆的小秘,可以“隨時”呼喚並給予其任務,直到完成任務。

協程這個概念的根源來自於 Melvin Conway 博士 在1963年發表的論文。 目前協程在 Go, Java, Python, Lua, Ruby, C/C++, C# 等主流語言中都有相應的實現。 PS:詳細可以參見 Wikipedia

協程的應用主要用於實現狀態機(C#, etc.),角色模型生成器(JavaScrtipt, etc.)和 進程之間的通訊模型(Go, etc.)等高等程序模型。

在其他語言的協程應用中,協程扮演的角色與其名字一樣,是輔助的子程序,用於協助主線程/進程進行一些工作,減輕主線程的運算負擔。 這裏我們以一個 Go 語言中使用協程進行輔助計算的 DEMO 爲例:

package main

import "fmt"

func Add(x, y int, ch chan int) {
  z := x + y

  ch <- z
}

func main() {
  chs := make([]chan int, 10)
  for i := 0; i < 10; i++ {
    chs[i] = make(chan int)
    go Add(i, i, chs[i])
  }

  for _, ch := range(chs) {
    value := <- ch
    fmt.Println(value)
  }
}

主線程生成十個協程,並分別給予相應任務,各協程通過 Go 中的 Channel 特性向主線程返回數據,最後主線程得到所有數據。

然而如果使用 co 完成這個需求,會是這樣的:

var co = require('co');

function add(x, y) {
  return function(callback) {
    callback(null, x + y);
  };
}

co(function *() {
  for (var i = 0; i < 10; i++) {
    var value = yield add(i, i);

    console.log(value);
  }
})();

需要注意的是,JavaScript 中的協程暫時來說必須由生成器函數來表達,也就是說生成器函數的函數體纔是協程的運行體。 而按照 co 的使用慣例,我們需要把主業務流程放到協程中,而常規的異步運算則留在主線程,出現了與其他語言不同的地方, 就是在 co 中,協程和主線程所扮演的角色出現的顛倒,我們的主業務流程由協程完成,而主線程則負責本應由協程完成的運算。

這種做法暫時來說還是比較新奇的,然而究竟是好是壞,得看實際生產中的所得數據了。

Koa

說完 co 和協程,相信大家已經對這個看似很高級的概念有所瞭解了。那麼我就就來說說 Koa 吧。 上面說到了 Koa 是一個 Web 開發框架,實際上在我的觀點中,Koa 和 connect 一樣,只是一個中間件部件, 如果你也是 connect 粉的話,我相信你也會這樣覺得。

var koa = require('koa');

var app = koa();
app.use(function *() {
  this.body = 'Hello World!';
});

app.listen(8888);

Koa 和 connect 相比,採用了 co 作爲中間件運行器,並引入了 Context 的概念,而原本的requestresponse則被弱化了。

因爲 Koa 採用了 co 作爲中間件運行器,所以 Koa 的中間件也與 connect 的中間件有所不同(當然也可以很簡單地做一個 shim)。

我們來舉個例子:

// Connect
var connect = require('connect');
var redisClient = ...;

var app = connect();

app.use(connect.cookieParser());
app.use(function(req, res, next) {
  redisClient.hgetall('user:' + req.cookies.user_id, function(err, user) {
    if (err) {
      return next(err);
    }

    req.user = user;

    next();
  });
});
app.use('/', function(req, res) {
  res.end(req.user.name);
});

app.listen(8888);

// Koa
var koa = require('koa');
var thunkify = require('thunkify');
var redisClient = ...;

redisClient.hgetall = thunkify(redisClient.hgetall);

var app = koa();
app.use(function *(next) {
  var user = yield redisClient.hgetall('user:' + req.cookies.get('user_id'));

  this.user = user;

  yield next();
});
app.use(function *() {
  this.body = this.user.name;
});

app.listen(8888);

connect 的中間件是普通的函數,而 Koa 的中間件則必須是生成器函數,如果把 connect 的中間件傳入 Koa 中,
則會拋出app.use() requires a generator function錯誤。

Koa 好的地方就在於它把requestresponse給弱化了,取而代之的是使用context進行操作。

// Example 1: Static Index Page
var koa    = require('koa');
var router = require('koa-router');
var fs     = require('fs');

var app = koa();

app.use(router(app));

app.get('/', function *() {
  this.body = fs.createReadStream(__dirname + '/assets/index.html');
  // set the context body with a readable stream
});

app.listen(8080);

// Example 2: Database Querying and Template Render
var koa        = require('koa');
var router     = require('koa-router');
var thunkify   = require('thunkify');
var Handlebars = require('handlebars');
var redis      = require('redis');
var mongo      = require('mongoskin');
var fs         = require('fs');

var app = koa();

var redisClient = redis.createClient();
var items = mongo.db('localhost/myapp').collection('items');

redisClient.hgetall = thunkify(redisClient.hgetall);
items.find = thunkify(items.find);

var template = Handlebars.compile(fs.readFileSync(__dirname + '/index.hbs'));

app.use(router(app));

app.get('/', function *() {
  // Parallel Querying
  var data = yield {
    user: redisClient.hgetall('user:' + this.cookies.get('user_id')),
    items: items.find()
  };

  this.body = template(data);
});

app.listen(8080);

Koa 因爲 co 和生成器特性的使用,可以讓我們之前要使用 when.js, async, EventProxy 之類流控制工具來做的事情,
換成我們最喜歡和熟悉的順序編程的風格。

但是目前 Koa 的社區和生產力還是完全比不上 connect,中間件數量也少的可憐,不過相信接下來的一段時間內,Koa 會火起來的。 不過也得等到 Harmony 標準化和 Node.js 對 Harmony 的支持穩定下來吧。

詳細關於 Koa 和 co 的使用方法和其他信息,請到它們的 Github Repo 去看看吧。 : )

小結

我這裏簡要地介紹了 Koa, co 和協程的一些內容:

  1. ECMAScript Harmony 標準中關於生成器 (Gerenator) 的使用
  2. 利用 co 和生成器特性簡化 JavaScript 中異步請求的嵌套回調
  3. 介紹了 co 內部的核心機制和原理
  4. 介紹了協程這種概念,對比了 JavaScript 和 Go 中協程,還有 co 和 Go 在協程應用上的差別
  5. 介紹了 Koa 這個新力軍的簡單使用

最後,Harmony 目前來說還只是一個非完成的標準,各種東西都會由變動的可能,而且各種瀏覽器和不同版本的 Node.js 對其的支持也參差不齊。 總而來說,把 Harmony 帶到生產環境上去還是件不太可能的事情,就讓我們繼續觀察吧。

Being Lucky.

References

  1. Koa
  2. visionmedia/co
  3. Callback Hell

扫描二维码,分享此文章

Will Wen Gunn's Picture
Will Wen Gunn

Senior JavaScript Engineer, Web Architect, Culture Worker @ Qiniu Tech.

Canton, China http://lifemap.in/resume