多维度分析 Express、Koa 之间的区别 - 图1
Express 历史悠久相比 Koa 学习资料多一些,其自带 Router、路由规则、View 等功能,更接近于 Web FrameWork 的概念。Koa 则相对轻量级,更像是对 HTTP 的封装,自由度更多一些,官方 koajs/koa/wiki 提供了一些 Koa 的中间件,可以自行组合。

本文重点从 Handler 处理方式中间件执行机制响应机制多个维度来看待 Express、Koa 的区别。

Handler 处理方式

这个是 Express、Koa(koa1、koa2)的重点区别:

Express

Express 使用普通的回调函数,一种线性的逻辑,在同一个线程上完成所有的 HTTP 请求,Express 中一种不能容忍的是 Callback,特别是对错捕获处理起来很不友好,每一个回调都拥有一个新的调用栈,因此你没法对一个 callback 做 try catch 捕获,你需要在 Callback 里做错误捕获,然后一层一层向外传递。

Koa1

目前我们使用的是 Koa2,Koa1 是一个过度版,因此也有必要了解下,它是利用 generator 函数生成器 + co 来实现的 “协程响应”

先说下 Generator 和协程,协程是处于线程的环境下,同一时刻一个线程只能执行一个协程,相比线程它更加轻量级,没有了线程的创建、销毁,上下文切换等消耗,它不受操作系统管理,由具体的应用程序所控制,Generator 也是在 ES6 中所实现,它由函数的调用者给予授权执行,因此也称为 “半协程/像协程”,完全的协程是所有的函数都可控制。

在说下 co,Generator 加上 co 这个必杀器,完全干掉了回调函数这种写法,co 是什么呢?它是一种基于 Promise 对象的 Generator 函数流程自动管理,可以像写同步代码一样来管理我们的异步代码。

Koa2(现在 Koa 默认的)

Koa2 这个现在是 Koa 的默认版本,与 Koa1 最大的区别是使用 ES7 的 Async/Await 替换了原来的 Generator + co 的模式,也无需引入第三方库,底层原生支持,Async/Await 现在也称为 JS 异步的终极解决方案

Koa 使用的是一个洋葱模型,它的一个特点是级联,通过 await next() 控制调用 “下游” 中间件,直到 “下游” 没有中间件且堆栈执行完毕,最终在流回 “上游” 中间件。这种方式有个优点特别是对于日志记录(请求->响应耗时统计)、错误处理支持都很完美。

因为其背靠 Promise,Async/Await 只是一个语法糖,因为 Promise 是一种链式调用,当多个 then 链式调用中你无法提前中断,要么继续像下传递,要么 catch 抛出一个错误。对应到 Koa 这个框架也是你只能通过 await next() 来控制是否像下流转,或者抛出一个错误,无法提前终止。

上面说到无法提前终止,后来有看过 Teambition 严清老师自己实现的一个框架 Toa,基于 Koa 进行开发,它的其中一个特点是可以通过 context.end() 提前终止,感兴趣的可以去看看 toajs/toa

中间件实现机制

Koa 中间件机制

Koa (>=v7.6)默认支持 Async/Await,在 Koa 中多个异步中间件进行组合,其中一个最核心的实现是 koa-compse 这个组件,下面一步一步的进行实现。

从三个函数开始做为例子开始封装一个类似于 koa-compse 的组合函数:

  1. async function f1(ctx, next) {
  2. console.log('f1 start ->');
  3. await next();
  4. console.log('f1 end <-');
  5. }
  6. async function f2(ctx, next) {
  7. console.log('f2 start ->');
  8. await next();
  9. console.log('f2 end <-');
  10. }
  11. async function f3(ctx) {
  12. console.log('f3 service...');
  13. }

如果是按照 Koa 的执行顺序,就是先让 f1 先执行、f1 的 next 参数是 f2、f2 的 next 参数是 f3,可以看到 f3 是最后一个函数,处理完逻辑就结束,模拟实现:

  • 行 {1} 定义一个中间件的集合
  • 行 {2} 定义 use 方法,像中间件集合里 push 中间件,可以看成类似于 app.use()
  • 行 {3} 依次挂载我们需要的执行的函数 f1、f2、f3
  • 行 {5} 执行 next1(),也即先从 f1 函数开始执行
  • 行 {4.3} 定义 next1 执行函数,middlewares[0] 即 f1 函数,其函数内部调用 f2,我们在行 {4.2} 定义 next2 执行函数
  • 行 {4.2} 定义 next2 执行函数,middlewares[1] 即 f2 函数,其函数内部要调用 f3,我们再次定义 next3 执行函数
  • 行 {4.1} 定义 next1 执行函数,middlewares[2] 即 f3 函数,因为其是最后一步,到这里也就结束了
  1. const ctx = {}
  2. const middlewares = []; // {1} 定义一个中间件的集合
  3. const use = fn => middlewares.push(fn); // {2} 定义 use 方法
  4. // {3}
  5. use(f1);
  6. use(f2);
  7. use(f3);
  8. // {4}
  9. const next3 = () => middlewares[2](ctx); // {4.1}
  10. const next2 = () => middlewares[1](ctx, next3); // {4.2}
  11. const next1 = () => middlewares[0](ctx, next2); // {4.3}
  12. // {5}
  13. next1()
  14. // 输出结果
  15. // f1 start ->
  16. // f2 start ->
  17. // f3 service...
  18. // f2 end <-
  19. // f1 end <-

上面输出结果是我们所期望的,但是如果我们在新增一个 f4 呢,是不是还得定义呢?显然这样不是很合理,我们需要一个更通用的方法来组合我们这些函数,通过上面例子,可以看出是由规律性的,可以通过递归遍历来实现,实现如下:

  • 行 {1} {2} 为边界处理,首先 middlewares 是一个数组,其次数组中的每个元素必须为函数
  • 行 {4} 定义 dispatch 函数这里是我们实现的关键
  • 行 {5} i 为当前执行到中间件集合 middlewares 的哪个位置了,如果等于 middlewares 的长度,也就执行完毕直接返回;
  • 行 {6} 取出当前遍历到的函数定义为 fn
  • 行 {7} 执行函数 fn,传入 dispatch 函数且 i+1,但是注意一定要 bind 下,因为 bind 会返回一个函数,并不会立即执行,什么时候执行呢?也就是当前 fn 函数里的 await next() 执行时,此时这个 next 也就是现在 fn 函数传入的 dispatch.bind(null, (i + 1))
  • 行 {8} 中间的任一个中间件出现错误,就直接返回
  1. /**
  2. * 中间件组合函数,可以参考 https://github.com/koajs/compose/blob/master/index.js
  3. * @param { Array } middlewares
  4. */
  5. function compose(ctx, middlewares) {
  6. // {1}
  7. if (!Array.isArray(middlewares)) throw new TypeError('Middlewares stack must be an array!')
  8. // {2}
  9. for (const fn of middlewares) {
  10. if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  11. }
  12. return function() {
  13. const len = middlewares.length; // {3} 获取数组长度
  14. const dispatch = function(i) { // {4} 这里是我们实现的关键
  15. if (len === i) { // {5} 中间件执行完毕
  16. return Promise.resolve();
  17. } else {
  18. const fn = middlewares[i]; // {6}
  19. try {
  20. // {7} 这里一定要 bind 下,不要立即执行
  21. return Promise.resolve(fn(ctx, dispatch.bind(null, (i + 1))));
  22. } catch (err) {
  23. // {8} 返回错误
  24. return Promise.reject(err);
  25. }
  26. }
  27. }
  28. return dispatch(0);
  29. }
  30. }
  31. const fn = compose(ctx, middlewares);
  32. fn();

进行测试,是我们期望的结果,它的执流程为 f1 -> f2 -> f3 -> f2 -> f1,刚开始从 f1 往下游执行,直到 f3 最后一个中间件执行完毕,在流回到 f1,这种模式另外一个名字就是最著名的 “洋葱模型”;

  1. f1 start ->
  2. f2 start ->
  3. f3 service...
  4. f2 end <-
  5. f1 end <-

以上就是 Koa 中间件 Compose 的核心实现,关于 Koa 的更多内容可参见 Github 源码。

Express 中间件机制

笔者这里看到是 Express 4.x 版本,其中一个重大改变是移除了内置中间件 Connect,详情参考 迁移到 Express 4.x

我们通常说 Express 是线性的,那么请看下面代码:

  1. const Express = require('express')
  2. const app = new Express();
  3. const sleep = () => new Promise(resolve => setTimeout(function(){resolve(1)}, 2000))
  4. const port = 3000
  5. function f1(req, res, next) {
  6. console.log('f1 start ->');
  7. next();
  8. console.log('f1 end <-');
  9. }
  10. function f2(req, res, next) {
  11. console.log('f2 start ->');
  12. next();
  13. console.log('f2 end <-');
  14. }
  15. async function f3(req, res) {
  16. //await sleep();
  17. console.log('f3 service...');
  18. res.send('Hello World!')
  19. }
  20. app.use(f1);
  21. app.use(f2);
  22. app.use(f3);
  23. app.get('/', f3)
  24. app.listen(port, () => console.log(`Example app listening on port ${port}!`))

控制台执行 curl localhost:3000 输出如下,有点迷惑了,不是线性吗?为什么和我们上面讲 Koa 的输出顺序是一致呢?不也是洋葱模型吗?

  1. f1 start ->
  2. f2 start ->
  3. f3 service...
  4. f2 end <-
  5. f1 end <-

少年,先莫及,再看一段代码。
上面我们的 f3 函数其中注释了一条代码 await sleep() 延迟执行,现在让我们打开这个注释。

  1. async function f3(req, res) {
  2. await sleep(); // 改变之处
  3. console.log('f3 service...');
  4. res.send('Hello World!')
  5. }

控制台再次执行 curl localhost:3000,发现顺序发生了改变,上游中间件并没有等待 f3 函数执行完毕,就直接执行了。

  1. f1 start ->
  2. f2 start ->
  3. f2 end <-
  4. f1 end <-
  5. f3 service...

下面试图复现其执行过程,可以看到 f1、f2 为同步代码,而 f3 为异步,说了这么多,答案终于出来了。
Express 中间件实现是基于 Callback 回调函数同步的,它不会去等待异步(Promise)完成,这也解释了为什么上面的 Demo 我加上异步操作,顺序就被改变了。
在 Koa 的中间件机制中使用 Async/Await(背后全是 Promise)以同步的方式来管理异步代码,它则可以等待异步操作。

  1. f1 (req, res) {
  2. console.log('f1 start ->');
  3. f2 (req, res) { // 第一个 next() 地方
  4. console.log('f2 start ->');
  5. async f3 (req, res) { // 第二个 next() 地方
  6. await sleep(); // 改变之处
  7. console.log('f3 service...');
  8. res.send('Hello World!')
  9. }
  10. console.log('f2 end <-');
  11. }
  12. console.log('f1 end <-');
  13. }

Express 中间件源码解析

看过 Express 的源码,再去看 Koa 的源码,你会发现 Koa 是真的简洁精炼,Express 的源码看起来还是有点绕,需要时间去梳理,下面贴两个重点实现的地方,详情可参考 Express 4.x 源码,感兴趣的可以看下。

  1. 中间件挂载

初始化时主要通过 proto.use 方法将中间件挂载到自身的 stack 数组中

  1. // https://github.com/expressjs/express/blob/4.x/lib/router/index.js#L428
  2. proto.use = function use(fn) {
  3. var offset = 0;
  4. var path = '/';
  5. ...
  6. var callbacks = flatten(slice.call(arguments, offset));
  7. if (callbacks.length === 0) {
  8. throw new TypeError('Router.use() requires a middleware function')
  9. }
  10. for (var i = 0; i < callbacks.length; i++) {
  11. var fn = callbacks[i];
  12. if (typeof fn !== 'function') {
  13. throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
  14. }
  15. // add the middleware
  16. debug('use %o %s', path, fn.name || '<anonymous>')
  17. var layer = new Layer(path, {
  18. sensitive: this.caseSensitive,
  19. strict: false,
  20. end: false
  21. }, fn);
  22. layer.route = undefined;
  23. this.stack.push(layer); // 中间件 route 的 layer 对象的 route 为 undefined,区别于路由的 router 对象
  24. }
  25. return this;
  26. };
  1. 中间件的执行

Express 中间件的执行其中一个核心的方法为 proto.handle 下面省略了很多代码。详情参见源码 Express 4.x,如何进行多个中间件的调用呢?proto.handle 方法的核心实现定义了 next 函数递归调用取出需要执行的中间件。

  1. // https://github.com/expressjs/express/blob/dc538f6e810bd462c98ee7e6aae24c64d4b1da93/lib/router/index.js#L136
  2. proto.handle = function handle(req, res, out) {
  3. var self = this;
  4. ...
  5. next();
  6. function next(err) {
  7. ...
  8. // find next matching layer
  9. var layer;
  10. var match;
  11. var route;
  12. while (match !== true && idx < stack.length) {
  13. layer = stack[idx++]; // 取出中间件函数
  14. match = matchLayer(layer, path);
  15. route = layer.route;
  16. if (typeof match !== 'boolean') {
  17. // hold on to layerError
  18. layerError = layerError || match;
  19. }
  20. if (match !== true) {
  21. continue;
  22. }
  23. if (!route) {
  24. // process non-route handlers normally
  25. continue;
  26. }
  27. ...
  28. }
  29. ...
  30. // this should be done for the layer
  31. self.process_params(layer, paramcalled, req, res, function (err) {
  32. if (err) {
  33. return next(layerError || err);
  34. }
  35. if (route) {
  36. return layer.handle_request(req, res, next);
  37. }
  38. trim_prefix(layer, layerError, layerPath, path);
  39. });
  40. }
  41. function trim_prefix(layer, layerError, layerPath, path) {
  42. ...
  43. if (layerError) {
  44. layer.handle_error(layerError, req, res, next);
  45. } else {
  46. // 这里进行函数调用,且递归
  47. layer.handle_request(req, res, next);
  48. }
  49. }
  50. };

响应机制

Koa 响应机制

在 Koa 中数据的响应是通过 ctx.body 进行设置,注意这里仅是设置并没有立即响应,而是在所有的中间件结束之后做了响应,源码中是如下方式写的:

  1. const handleResponse = () => respond(ctx);
  2. fnMiddleware(ctx).then(handleResponse)
  3. function respond(ctx) {
  4. ...
  5. res.end(body);
  6. }

这样做一个好处是我们在响应之前是有一些预留操作空间的,例如:

  1. async function f1(ctx, next) {
  2. console.log('f1 start ->');
  3. await next();
  4. ctx.body += 'f1';
  5. console.log('f1 end <-');
  6. }
  7. async function f2(ctx, next) {
  8. console.log('f2 start ->');
  9. await next();
  10. ctx.body += 'f2 ';
  11. console.log('f2 end <-');
  12. }
  13. async function f3(ctx) {
  14. ctx.body = 'f3 '
  15. console.log('f3 service...');
  16. }
  17. fn().then(() => {
  18. console.log(ctx); // { body: 'f3 f2 f1' }
  19. });

Express 响应机制

在 Express 中我们直接操作的是 res 对象,在 Koa 中是 ctx,直接 res.send() 之后就立即响应了,这样如果还想在上层中间件做一些操作是有点难的。

  1. function f2(req, res, next) {
  2. console.log('f2 start ->');
  3. next();
  4. res.send('f2 Hello World!') // 第二次执行
  5. console.log('f2 end <-');
  6. }
  7. async function f3(req, res) {
  8. console.log('f3 service...');
  9. res.send('f3 Hello World!') // 第一次执行
  10. }
  11. app.use(f2);
  12. app.use(f3);
  13. app.get('/', f3)

注意:向上面这样如果执行多次 send 是会报 ERR_HTTP_HEADERS_SENT 错误的。

总结

本文从 Handler 处理方式、中间件执行机制的实现、响应机制三个维度来对 Express、Koa 做了比较,通常都会说 Koa 是洋葱模型,这重点在于中间件的设计。但是按照上面的分析,会发现 Express 也是类似的,不同的是Express 中间件机制使用了 Callback 实现,这样如果出现异步则可能会使你在执行顺序上感到困惑,因此如果我们想做接口耗时统计、错误处理 Koa 的这种中间件模式处理起来更方便些。最后一点响应机制也很重要,Koa 不是立即响应,是整个中间件处理完成在最外层进行了响应,而 Express 则是立即响应。