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 源码,感兴趣的可以看下。
- 中间件挂载
初始化时主要通过 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 则是立即响应。