Koa, co and coruntine
发布在Harmony2014年1月20日view:15696
在文章任何区域双击击即可给文章添加【评注】!浮到评注点上可以查看详情。

最近想研究下在看 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

Coruntine 协程

哈哈,估计很多人看到这个 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
评论
发表评论
4年前

@仙菜小红丸 如果你對協程有更準確的認識和看法,歡迎說出來交流交流,而不是直接人身攻擊。

4年前

”在其他语言的协程应用中,协程扮演的角色与其名字一样,是辅助的子程序,用於协助主线程/进程进行一些工作,减轻主线程的运算负担”

呵呵,作者连协程的原理都不清楚就乱说一气误导别人,真是七窍通了六窍

4年前

@小问 体验如何?跟express相比

4年前

@琢大爷 現在已經完全可以使用了,早就已經將 koa 帶入生產環境中

4年前

文章发表于2014年1月,那个时候“ Koa 的社区和生產力还是完全比不上 connect,中间件数量也少的可怜”,“把 Harmony 带到生產环境上去还是件不太可能的事情”。

那么经过了一年半时间的发展,现在情况如何?

4年前
添加了一枚【评注】:这种形式和Async比,有何优劣?
4年前
添加了一枚【评注】:单词拼错了,应该是 Coroutine
5年前
添加了一枚【评注】:nealll
5年前
赞了此文章!
5年前
添加了一枚【评注】:能不能去了这玩意儿,加个开关也行
5年前

co或者koa核心就两点,一是避免callback hell,二是统一的错误处理。参考这篇https://github.com/koajs/koa/blob/master/docs/koa-vs-express.md 关于callback和coroutines可以看看TJ的blog https://medium.com/code-adventures/174f1fe66127 我觉得可以尝试把Koa用到生产环境中了

5年前

@chenllos Thanks

5年前

koa官网的地址错了“‘是koa

5年前

支持问神!

WRITTEN BY
小问
跨界工程师,文化人。
TA的新浪微博
PUBLISHED IN
Harmony

ECMA-262 6th Edition Harmony

我的收藏