多维度分析 Express、Koa 之间的区别

2,469 阅读11分钟

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 的组合函数:

async function f1(ctx, next) {
  console.log('f1 start ->');
  await next();
  console.log('f1 end <-');
}

async function f2(ctx, next) {
  console.log('f2 start ->');
  await next();
  console.log('f2 end <-');
}

async function f3(ctx) {
  console.log('f3 service...');
}

如果是按照 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 函数,因为其是最后一步,到这里也就结束了
const ctx = {}
const middlewares = []; // {1} 定义一个中间件的集合
const use = fn => middlewares.push(fn); // {2} 定义 use 方法

// {3}
use(f1);
use(f2);
use(f3);

// {4}
const next3 = () => middlewares[2](ctx); // {4.1}
const next2 = () => middlewares[1](ctx, next3); // {4.2}
const next1 = () => middlewares[0](ctx, next2); // {4.3}

// {5}
next1()

// 输出结果
// f1 start ->
// f2 start ->
// f3 service...
// f2 end <-
// 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} 中间的任一个中间件出现错误,就直接返回
/**
 * 中间件组合函数,可以参考 https://github.com/koajs/compose/blob/master/index.js
 * @param { Array } middlewares 
 */
function compose(ctx, middlewares) {
  // {1}
  if (!Array.isArray(middlewares)) throw new TypeError('Middlewares stack must be an array!')
  
  // {2}
  for (const fn of middlewares) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }
  
  return function() {
    const len = middlewares.length; // {3} 获取数组长度
    const dispatch = function(i) { // {4} 这里是我们实现的关键
      if (len === i) { // {5} 中间件执行完毕
        return Promise.resolve();
      } else {
        const fn = middlewares[i]; // {6}
        
        try {
          // {7} 这里一定要 bind 下,不要立即执行
          return Promise.resolve(fn(ctx, dispatch.bind(null, (i + 1))));
        } catch (err) {
          // {8} 返回错误
          return Promise.reject(err);
        }
      }
    }

    return dispatch(0);
  }
}

const fn = compose(ctx, middlewares);

fn();

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

f1 start ->
f2 start ->
f3 service...
f2 end <-
f1 end <-

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

Express 中间件机制

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

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

const Express = require('express')
const app = new Express();
const sleep = () => new Promise(resolve => setTimeout(function(){resolve(1)}, 2000))
const port = 3000

function f1(req, res, next) {
  console.log('f1 start ->');
  next();
  console.log('f1 end <-');
}

function f2(req, res, next) {
  console.log('f2 start ->');
  next();
  console.log('f2 end <-');
}

async function f3(req, res) {
  //await sleep();
  console.log('f3 service...');
  res.send('Hello World!')
}

app.use(f1);
app.use(f2);
app.use(f3);
app.get('/', f3)
app.listen(port, () => console.log(`Example app listening on port ${port}!`))

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

f1 start ->
f2 start ->
f3 service...
f2 end <-
f1 end <-

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

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

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

f1 start ->
f2 start ->
f2 end <-
f1 end <-
f3 service...

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

f1 (req, res) {
  console.log('f1 start ->');
  f2 (req, res) { // 第一个 next() 地方
    console.log('f2 start ->');
    async f3 (req, res) { // 第二个 next() 地方
      await sleep(); // 改变之处
      console.log('f3 service...');
      res.send('Hello World!')
    }
    console.log('f2 end <-');
  }
  console.log('f1 end <-');
}

Express 中间件源码解析

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

  1. 中间件挂载

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

// https://github.com/expressjs/express/blob/4.x/lib/router/index.js#L428
proto.use = function use(fn) {
  var offset = 0;
  var path = '/';

  ...

  var callbacks = flatten(slice.call(arguments, offset));

  if (callbacks.length === 0) {
    throw new TypeError('Router.use() requires a middleware function')
  }

  for (var i = 0; i < callbacks.length; i++) {
    var fn = callbacks[i];

    if (typeof fn !== 'function') {
      throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
    }

    // add the middleware
    debug('use %o %s', path, fn.name || '<anonymous>')

    var layer = new Layer(path, {
      sensitive: this.caseSensitive,
      strict: false,
      end: false
    }, fn);

    layer.route = undefined;

    this.stack.push(layer); // 中间件 route 的 layer 对象的 route 为 undefined,区别于路由的 router 对象
  }

  return this;
};
  1. 中间件的执行

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

// https://github.com/expressjs/express/blob/dc538f6e810bd462c98ee7e6aae24c64d4b1da93/lib/router/index.js#L136
proto.handle = function handle(req, res, out) {
  var self = this;
  ...
  next();

  function next(err) {
    ...
    // find next matching layer
    var layer;
    var match;
    var route;

    while (match !== true && idx < stack.length) {
      layer = stack[idx++]; // 取出中间件函数
      match = matchLayer(layer, path);
      route = layer.route;

      if (typeof match !== 'boolean') {
        // hold on to layerError
        layerError = layerError || match;
      }

      if (match !== true) {
        continue;
      }

      if (!route) {
        // process non-route handlers normally
        continue;
      }

      ...
    }
    
    ...
    // this should be done for the layer
    self.process_params(layer, paramcalled, req, res, function (err) {
      if (err) {
        return next(layerError || err);
      }

      if (route) {
        return layer.handle_request(req, res, next);
      }
      
      trim_prefix(layer, layerError, layerPath, path);
    });
  }
  
  function trim_prefix(layer, layerError, layerPath, path) {
    ...
    if (layerError) {
      layer.handle_error(layerError, req, res, next);
    } else {
      // 这里进行函数调用,且递归
      layer.handle_request(req, res, next);
    }
  }
};

响应机制

Koa 响应机制

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

const handleResponse = () => respond(ctx);
fnMiddleware(ctx).then(handleResponse)

function respond(ctx) {
  ...
  res.end(body);
}

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

async function f1(ctx, next) {
  console.log('f1 start ->');
  await next();
  ctx.body += 'f1';
  console.log('f1 end <-');
}
async function f2(ctx, next) {
  console.log('f2 start ->');
  await next();
  ctx.body += 'f2 ';
  console.log('f2 end <-');
}
async function f3(ctx) {
  ctx.body = 'f3 '
  console.log('f3 service...');
}
fn().then(() => {
  console.log(ctx); // { body: 'f3 f2 f1' }
});

Express 响应机制

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

function f2(req, res, next) {
  console.log('f2 start ->');
  next();
  res.send('f2 Hello World!') // 第二次执行
  console.log('f2 end <-');
}

async function f3(req, res) {
  console.log('f3 service...');
  res.send('f3 Hello World!') // 第一次执行
}

app.use(f2);
app.use(f3);
app.get('/', f3)

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

总结

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

敬请关注「Nodejs技术栈」微信公众号,获取优质文章