前言
在 NodeJS 中 express 和 koa 可以说是两个比较著名的服务端框架,express 成型较早于 koa,两者比较大的差异在于 express 主要基于异步回调的处理方式,而且集成了丰富的功能模块(静态文件,路由支持),相比之下 koa 则轻便了很多,koa 使用了 ES8 的新语法 async/await 代替了回调函数,使得 逻辑更加简单明了,同时 Koa 并没有捆绑任何中间件。
express 和 koa 中间件解析
用过或者了解过 koa 的人都知道,koa 的中间件采用了一种被称为 “洋葱模型” 的中间件形式,那么什么是洋葱模型,其与 express 中的中间件的模式区别在哪里?有什么样的优缺点?接下来我们来逐个分析(下面的例子只对全局中间件展开讨论)。
什么是中间件
中间件定义-维基百科
中间件(英语:Middleware),又译中间件、中介层,是提供系统软件和应用软件之间连接的软件,以便于软件各部件之间的沟通,特别是应用软件对于系统软件的集中的逻辑,在现代信息技术应用框架如Web服务、面向服务的体系结构等中应用比较广泛。
中间件在操作系统、网络和数据库之上,应用软件的下层,总的作用是为处于自己上层的应用软件提供运行和开发的环境,帮助用户灵活、高效地开发和集成复杂的应用软件,中间件是一类软件,中间件不仅要实现互联,还要实现应用之间的互操作;中间件是基于分布式处理的软件,最突出的特点是其网络通信功能。
在 Node 服务程序中,中间件是一个个解耦的服务处理层单元,其对服务起着验证,过滤,监控,日志维护等功能,中间件主要能执行以下的几个操作:
- 执行任何代码。
- 对请求和响应对象进行更改。
- 结束请求/响应循环。
- 调用堆栈中的下一个中间件。
从一个例子来窥探 express 和 koa 中间件处理流程
express 代码
const express = require('express')
const app = new express()
// 中间件1
app.use((req, res, next) => {
console.log('中间件1 start')
next()
console.log('中间件1 end')
res.json({
result: '中间件1'
})
})
// 中间件2
app.use((req, res, next) => {
console.log('中间件2 start')
next()
res.json({
result: '中间件2'
})
console.log('中间件2 end')
})
// 中间件3
app.use((req, res, next) => {
console.log('中间件3 start')
res.json({
result: '中间件3'
})
console.log('中间件3 end')
})
app.listen(9001)
koa 代码
const koa = require('koa')
const app = new koa()
// 中间件1
app.use(async (ctx, next) => {
console.log('中间件1 start')
await next()
console.log('中间件1 end')
ctx.body = {
result: '中间件1'
}
})
// 中间件2
app.use(async (ctx, next) => {
console.log('中间件2 start')
await next()
console.log('中间件2 end')
ctx.body = {
result: '中间件2'
}
})
// 中间件3
app.use(async (ctx, next) => {
console.log('中间件3 start')
console.log('中间件3 end')
ctx.body = {
result: '中间件3'
}
})
app.listen(9001)
提问:当分别用浏览器访问 127.0.0.1:9001 的时候,服务日志输出的是什么,浏览器接收到结果是什么?
(如果知道结果,可以选择跳过这部分内容)
其结果是:服务控制台输入的日志都是一样的,而浏览器接收到的结果略有不同. 其服务输出日志为:
中间件1 start
中间件2 start
中间件3 start
中间件3 end
中间件2 end
中间件1 end
koa 服务浏览器接收到响应
{"result":"中间件1"}
express 服务浏览器接收到的响应
{"result":"中间件3"}
同时 express 服务还会抛出一个异常
Cannot set headers after they are sent to the client.......
那么,因为会出现两个不同的结果?要弄清这个问题原因,我们就需要知道 express 和 koa 中间件的处理模式。
koa 洋葱模型
koa 中间件模型入下图所示:
如上图所以,在 koa 的中间件模型中,当一个请求到来的时候,请求流会按照中间件注册的顺序,首先进入第一个注册的中间件,当第一个中间件处理完请求流的时候,其有两个选择,1 是直接响应结束请求,2 是调用 next 方法, 该函数暂停并将控制传递给定义的下一个中间件。当在下游没有更多的中间件执行后,堆栈将展开并且每个中间件恢复执行其上游行为。
即是说,koa 中间件在响应的时候,会从最后的一个中间件开始返回,到最开始的的中间件结束响应,并返回给客户端,其中返回的内容取决于洋葱模型中最靠近外层的响应结果。
所以在上述的例子中,虽然每个中间件都响应了数据,但由于响应会从第三个中间件往外发出,到第一个中间件的时候,响应 res.body 被改成了中间件 1 的数据。所以浏览器端接受到的数据为:
{"result":"中间件1"}
从洋葱模型途中,我们可以知道洋葱模型有以下几个特点:
- 外部请求会根据中间件注册的顺序依次流入中间件(前提的上一个中间件调用了 next)
- 当前中间件的 ctx 参数,是经过上一个中间件(如果有的话),处理过的参数。
- 请求的响应会从请求到达最内层中间件依次向外层中间件流过
express 管道模型
express 中间件管道模型如下图所示
从上图可以看出,express 中间件的模型有点类似于水流管道,某个中间件在调用 next 的时候,会将执行权交给下一个中间件(如果有的话),和 koa 不同的是:
- express 的中间件主要基于回调的方式
- express 响应流方式不一样,同一请求只能有一个响应出口,也就是只能有一个中间件对请求做出响应,所以响应的结果却决于第一个对请求做出响应的结果(这也是例子中为何会输出):
{"result":"中间件3"}
同时报错的原因
从源码的处深入分析中间件流程之 - koa
下面,我们的通过上述的列子,结合 koa 的源码深入的了解其中间件的处理流程: 这里我们会贴一些关键代码
app.use 发生了啥(这里去掉了防错检查和日志提示代码)
module.exports = class Application extends Emitter {
constructor(options) {
// n 行代码
super();
this.middleware = [];
// n 行代码
}
// n 行代码
use(fn) {
if (isGeneratorFunction(fn)) {
fn = convert(fn);
}
this.middleware.push(fn);
return this;
}
// n 行代码
}
这里做了两件事
- 检查中间件函数,如果是 generator 就转化为基于 Promise 的中间件函数
- 将中间件函数 push 到中间件数组中
接下来执行 app.listen:
// class Application
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
listen 也很简单明了,直接创建一个 http 服务,然后将 callback 函数的返回值作为处理请求的回调函数:
// this.callback
callback() {
const fn = compose(this.middleware);
// 省略部分代码
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
这里 callback 返回了一个回调函数,并且将中间件进行了合并 compose, 当有请求到来的时候就执行 this.handleRequest(ctx, fn), 并传入合并处理后的中间件函数, 如下是合并中间件的操作:
function compose (middleware) {
// n 行代码
return function (context, next) {
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
从上面的代码可以看出,当请求到来的时候,会执行第一个中间件的逻辑,然后第一个中间件执行的完成依赖于后面中间件的执行,其过程类似于如下代码:
const fn1 = async () => {
console.log('中间件1 start')
await fn2()
console.log('中间件1 end')
}
const fn2 = async () => {
console.log('中间件2 start')
await fn3()
console.log('中间件2 end')
}
const fn3 = async () => {
console.log('中间件3 start')
console.log('中间件3 end')
}
Promise.resolve(fn1())
只不过在 compose, 在执行第一个中间件的过程中,会将下一个中间件的包装函数座位 next 参数的值传入,最后用一个 Promise 包裹,形成 Promise 栈式调用。同时也利用了闭包的特性,所有的中间件都引用子同一个 context 对象,这也是为何 koa 当前中间件函数中 ctx 的值是来自上一个中间件函数处理的结果。所以当最后一个中间件执行完毕时,函数的调用栈会依次返回,从而达到响应流的洋葱模型流出形式。
从源码的处深入分析中间件流程之 - express
同样,我们根据例子来深入到 express 中间件的源码中,分析其中间件的执行流程,首先我们来看看 app.use 做了啥?
//express/lib/application.js
app.use = function use(fn) {
var offset = 0;
var path = '/';
// 扁平化为数组
var fns = flatten(slice.call(arguments, offset));
this.lazyrouter(); // _router 不存在就初始化一个 Router 对象
var router = this._router;
fns.forEach(function (fn) {
if (!fn || !fn.handle || !fn.set) {
return router.use(path, fn);
}
}, this);
return this;
};
app.use 主要以下几件事:
- 将中间件函数转化为数组对象
- 生成一个 Router 实例 _router并初始化(如果不存在的话)
- 遍历中间件数组对象, 调用 _router.use(path, fn) 所以 app.use 实际上是调用了 _router.use()
顺藤摸瓜,继续来看看 _router.use 的方法
// express/lib/router/index.js
proto.use = function use(fn) {
var offset = 0;
var path = '/';
// 省略部分代码-参数处理逻辑
callbacks = flatten(slice.call(arguments, offset));
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);
layer.route = undefined;
this.stack.push(layer);
}
return this;
};
可以看到这个为每个中间件函数创建了一个 Layer (层)实例,并将所有的层 push 到 _router 的 task 数组中,而这个 Layer 就是实际调用中间件函数处理请求的地方。
// express/router/layer.js
function Layer(path, options, fn) {
// 省略部分代码
this.handle = fn;
this.name = fn.name || '<anonymous>';
// 省略部分代码
}
接下来我们来看看,当有实际请求到来的时候,express 是如何处理请求的,先来看看引入 的 express 是如何初始化:
//express/lib/express
exports = module.exports = createApplication;
function createApplication() {
// 省略部分代码
var app = function(req, res, next) {
app.handle(req, res, next);
};
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);
// 省略部分代码
return app;
}
可以看出,当执行 const _app = new express() 实际上返回的是 app 函数, 其中 该函数中混入了 proto 和 EventEmitter.prototype 对象的属性, 然后返回了 app 这个函数。
然后 listen 的时候传入了这个 app 函数:
// express/lib/application.js
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
所以当有请求到来的时候,就会执行 app.handle 方法
// express/lib/application.js
app.handle = function handle(req, res, callback) {
var router = this._router;
// 省略部分代码
var done = callback || finalhandler(req, res, {
env: this.get('env'),
onerror: logerror.bind(this)
});
// 省略部分代码
router.handle(req, res, done);
};
这里又调用了 router.handle, 同时传入默认的响应请求函数 done。
// express/lib/router/index.js
proto.handle = function handle(req, res, out) {
var self = this;
var idx = 0;
var protohost = getProtohost(req.url) || ''
var removed = '';
var stack = self.stack;
var parentParams = req.params;
var parentUrl = req.baseUrl || '';
var done = restore(out, req, 'baseUrl', 'next', 'params');
req.next = next;
req.baseUrl = parentUrl;
req.originalUrl = req.originalUrl || req.url;
next();
function next(err) {
// 省略 n 行代码
// 没有更多中间件
if (idx >= stack.length) {
setImmediate(done, layerError);
return;
}
// find next matching layer
var layer;
var match;
var route;
// 遍历 layer, 找出合适的处理层
while (match !== true && idx < stack.length) {
layer = stack[idx++];
match = matchLayer(layer, path);
route = layer.route;
// 省略部分代码
}
// this should be done for the layer
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
return next(layerError || err);
}
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);
}
}
};
由上述的代码可以看出,express 中间件处理的过程是:当有请求到来时,会根据路径来遍历中间件列表,依次找出合适的中间件进行处理,同时再执行中间件处理逻辑时,传入触发执行下一个中间件的 next 函数,只有调用 next 方法时,才会继续取出下一个匹配的中间件执行。
到这里我们知道,koa 和 express 的 next 有着本质上的区别,在 koa 中,中间件的 next 代表的是下一个中间件方法,而在 express 中,next 则更像是一个启动匹配下一个合适中间件的‘开关’。
总结
综上所述,对于 koa 和 express 中间件,其主要以下的不同点:
-
1.中间件串联调用方式不同 koa 基于函数栈调用方式,express 基于循环匹配查找并执行调方法
-
2.响应规则不同 koa 可以在每个中间件执行响应数据写入,而只有最后一个写入的才会最终返回给客户端,express 只能有一个中间件进行响应,多个中间件响应后会报错(所以中间件响应后,最好调用 return 确保结束处理请求)
ps: 文章涉及的知识仅为个人结论,如有错误,敬请斧正,不胜感激。
更多文章可戳这里