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

简介: Express 历史悠久相比 Koa 学习资料多一些,其自带 Router、路由规则、View 等功能,更接近于 Web FrameWork 的概念。Koa 则相对轻量级,更像是对 HTTP 的封装,自由度更多一些,官方 koajs/koa/wiki 提供了一些 Koa 的中间件,可以自行组合。本文重点从 Handler 处理方式、中间件执行机制、响应机制多个维度来看待 Express、Koa 的区别。Handler 处理方式这个是 Express、Koa(koa1、koa2)的重点区别:ExpressExpress 使用普通的回调函数,一种线性的逻辑,在同一个线程上完成所有的

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} 中间的任一个中间件出现错误,就直接返回
/**

*/
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 源码,感兴趣的可以看下。

中间件挂载
初始化时主要通过 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;
};
复制代码
中间件的执行
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 则是立即响应。

目录
相关文章
|
开发框架 JavaScript 前端开发
koa和express有哪些不同?
koa和express有哪些不同?
101 1
|
6月前
|
开发框架 JavaScript 前端开发
比较两个突出的node.js框架:koa和express
接上文讲述了 koa框架,这边文章比较一下这两个突出的node.js框架:koa和express
|
开发框架 JavaScript 前端开发
Node.js Express VS Koa
我自己实际调研过,做过分享技术选型
111 0
|
前端开发 JavaScript 中间件
express VS koa
express 出来的时候,js 还是处于混沌期间,es6的标准没有出来。而node的事件处理的方式都是基于 cb(callback) 的这种形式,在当时来看,这种方式是比较好的一种方式。所以express 里面的中间件处理的方式基本上都是回调。es6 的出现,带来了许多新的标准。使得express 不得不考虑需要兼容es6中的语法, 而 es6中处理异步的方式是promise,还有后面陆续的 async 和 await 等语法糖。(express 不是说不能使用这些语法糖,他使用第三方的库是可以解决的,只是没有koa那么优雅)
express VS koa
|
SQL 存储 JavaScript
重学Node.js及其框架(Express, Koa, egg.js) 之 MySQL及Nodejs中使用MySQL(下)
重学Node.js及其框架(Express, Koa, egg.js) 之 MySQL及Nodejs中使用MySQL
|
算法 JavaScript Shell
重学Node.js及其框架(Express, Koa, egg.js) 之 token的使用
重学Node.js及其框架(Express, Koa, egg.js) 之 token的使用
|
存储 JavaScript 中间件
重学Node.js及其框架(Express, Koa, egg.js) 之 Koa框架(下)
重学Node.js及其框架(Express, Koa, egg.js) 之 Koa框架
|
JSON JavaScript 中间件
重学Node.js及其框架(Express, Koa, egg.js) 之 Koa框架(上)
重学Node.js及其框架(Express, Koa, egg.js) 之 Koa框架
|
JSON JavaScript 安全
重学Node.js及其框架(Express, Koa, egg.js) 之 Express框架
重学Node.js及其框架(Express, Koa, egg.js) 之 Express框架
|
缓存 移动开发 JavaScript
重学Node.js及其框架(Express, Koa, egg.js) 之 Nodejs基础(下)
重学Node.js及其框架(Express, Koa, egg.js) 之 Nodejs基础