这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战
我是小十七_,今天和大家一起阅读 koa 中间件源码,通俗易懂,包教包会~
Koa 的中间件不同于 Express,Koa 使用洋葱模型原理。它的源码只包含四个文件,对于初读源码的同学非常友好,今天我们只看主文件 - application.js
,它已经包含了中间件是如何工作的核心逻辑。
前置准备
首先 clone koa 源码
git clone [email protected]:koajs/koa.git
npm install
复制代码
然后我们在项目的根目录添加一个 index.js 用于测试
// index.js
// 包括 koa 的入口文件
const Koa = require('./lib/application.js');
const app = new Koa();
const debug = require('debug')('koa');
app.use(async (ctx, next) => {
console.log(1);
await next();
console.log(6);
const rt = ctx.response.get('X-Response-Time');
console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});
// 时间日志记录
app.use(async (ctx, next) => {
console.log(2);
const start = Date.now();
await next();
console.log(5);
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
app.use(async (ctx, next) => {
console.log(3);
ctx.body = 'Hello World';
await next();
console.log(4);
});
app.listen(3000);
复制代码
运行下面的命令来启动服务器:
node index.js
复制代码
接着访问 http://localhost:3000
,你会看到 1, 2, 3, 4, 5, 6
输出。这称为洋葱模型(中间件)
洋葱模型的工作原理
让我们一起阅读 koa 的核心代码,看看中间件是如何工作的。在 index.js 中,我们这样使用中间件:
const app = new Koa();
app.use(// middleware);
app.use(// middleware);
app.listen(3000);
复制代码
让我们来看看 application.js
,它在源代码的 lib
目录下,这里是与中间件相关的代码,我把代码进行了简化,保留了中间件的核心逻辑,并在代码中添加了一些注释。
module.exports = class Application extends Emitter {
constructor() {
super();
this.proxy = false;
// Step 0:初始化 middleware 中间件数组
this.middleware = [];
}
use(fn) {
// Step 1: 向数组里 push 中间件
this.middleware.push(fn);
return this;
}
listen(...args) {
debug('listen');
// Step 2: 调用 this.callback() 去组合所有的中间件
const server = http.createServer(this.callback());
return server.listen(...args);
}
callback() {
// Step 3: 最重要的部分 - compose 函数, 它把所有
// 中间件组合成一个大的函数,函数返回一个 promise,我们后面会继续讨论这个函数
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// Step 4: Resolve 这个 promise
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
}
复制代码
我们把代码简化为只有关于 compose 函数的部分的伪代码:
listen(...args) {
const server = http.createServer(this.callback());
}
callback() {
// compose 函数
const fn = compose(this.middleware);
return this.handleRequest(ctx, fn);
}
handleRequest(ctx, fnMiddleware) {
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
复制代码
从上面的代码可以猜测到:compose 函数,执行后返回了一个函数(这里叫 fn),fn 函数执行后,返回的是一个 promise。
关于 compose 函数
更多关于 compose
函数的信息,我们可以看一下 koa-compose
包的源码
module.exports = compose
function compose (middleware) {
// 这里跳过类型检测的代码
return function (context, next) {
// last called middleware #
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)
}
}
}
}
复制代码
和上面猜测的一样,我们简称 compose 返回的函数叫 fn,所有中间件都被 compose 传递到 了 fn 函数中,它返回了 dispatch(0)
,也就是立即执行了 dispatch
函数并返回了一个 promise。在了解 dispatch
函数的内容之前,我们先要了解 promise
的语法。
关于 Promise
通常我们会像这样使用 promise:
const promise = new Promise(function(resolve, reject) {
if (success){
resolve(value);
} else {
reject(error);
}
});
复制代码
在 Koa 中,它是这样使用的:
let testPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('test success');
}, 1000);
});
Promise.resolve(testPromise).then(function (value) {
console.log(value); // "test success"
});
复制代码
因此,我们知道在 compose
函数中,它返回一个 promise
。
回到 Koa - compose 中间件
module.exports = compose
function compose (middleware) {
// 这里跳过类型检测的代码
return function (context, next) {
// last called middleware #
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)
}
}
}
}
复制代码
dispatch 是一个递归函数,它将循环所有中间件。其中我们简化一下递归的部分:
let fn = middleware[i]
fn(context, dispatch.bind(null, i + 1))
复制代码
这里 fn 是当前的中间件函数,执行了 fn,参数分别是 context
和 dispatch.bind(null, i + 1)
(也就是我们传给中间价的 next),中间件执行这个函数,也就递归执行了 dispatch
函数,具体看下面的分析:
在我们的测试文件 index.js 中,我们有 3 个中间件,所有 3 个中间件都会在 await next()
之前执行这些代码;
app.use(async (ctx, next) => {
console.log(2);
const start = Date.now();
await next(); // <- 停在这儿并且等待下一个中间件执行
console.log(5);
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
复制代码
我们可以看一下 index.js
中这三个中间件的执行顺序:
- 执行
dispatch(0)
时,会执行 Promise.resolve(fn(context, dispatch.bind(null, 0 + 1)))
- 第一个中间件内容将运行直到
await next()
next()
=dispatch.bind(null, 0 + 1)
,这是第二个中间件- 第二个中间件将运行直到
await next()
next()
=dispatch.bind(null, 1 + 1)
,这是第三个中间件- 第三个中间件将一直运行到
await next()
next() = dispatch.bind(null, 2 + 1)
,没有第四个中间件,会立即通过if (!fn) return Promise.resolve()
返回,第三个中间件中的await next()
被解析,剩余执行第三个中间件中的代码。- 第二个中间件中的
await next()
被解析,第二个中间件中的剩余代码被执行。 - 第一个中间件中的
await next()
被解析,第一个中间件中的剩余代码被执行。
为什么使用洋葱模型?
如果我们在中间件中有 async/await,编码会更简单。当我们想为 api 请求编写一个时间记录器时,通过添加这个中间件可以非常容易:
app.use(async (ctx, next) => {
const start = Date.now();
await next(); // 你的 API 逻辑
const ms = Date.now() - start;
console.log('API response time:' + ms);
});
复制代码