koa中间件导致接口404的问题
最近在使用koa时遇见了一个很奇怪的问题:控制器中包含异步操作的接口,返回均为404;只包含同步操作的接口,接口返回正常。
经过排查,发现是前面使用了一个同步形式的中间件,并未等待后续异步中间件结束导致的问题。这里记录一下相关问题,以及分析koa中间件的实现和注意事项。
参考
问题还原
下面是相关代码
// 路由
// 使用了一个日志中间件,用于在控制台打印请求
router.use((ctx, next) => {
console.log(`${ctx.request.method} ${ctx.url}`)
next()
})
router.get("/list", controller.index)
// 控制器
let controller = {
async index(ctx, next){
let {id, pageNum} = ctx.query
let data = await model.queryList(id, pageNum)
console.log(data)
ctx.body = {
code: 200,
msg: 'success',
data
}
}
}
看起来是很普通的代码,按照预期:当请求/list
这个url时,首先在控制台打印请求信息
GET /list?id=1&pageNum=10
然后在controller.index
中等待model.queryList
查询数据,获得数据后挂载到ctx.body
上,然后返回给浏览器。
实际上,当请求接口时,在控制台,可以看见打印的请求信息(日志中间件正常运行)和查询的数据(控制器也正常执行);但是浏览器会直接获得404
响应。
404状态码是koa当ctx.body
没有设置任何返回信息时自动设置的。换句话说,在上面的代码中,controller.index
设置的响应并没有生效,修改一下控制器的代码
async index(ctx, next){
let {id, pageNum} = ctx.query
ctx.body = {
code: 200,
msg: 'success',
}
let data = await model.queryList(id, pageNum)
console.log(data)
}
}
现在再次访问接口,可以得到正常的200状态码了。因此我们可以锁定问题:controller.index
进行的异步操作之后的代码,并没有影响这次请求的返回值。
那么,产生这个问题的原因是什么呢?要弄清楚这个问题,我们需要了解koa的中间件机制。
中间件
中间件这个概念在很多地方都有用到,可以把中间件可以看做是一个装饰者模式,在实现具体的逻辑前后,进行相关的操作,根据执行的顺序,又可以分为前置中间件和后置中间件。
从网上找了一张描述中间件的洋葱模型图
在了解koa的中间件之前,我们先尝试手动实现一个超级简单的中间件系统。
实现一个中间件
中间件最主要其思路就是维护一个回调函数的队列,在注册中间件时将中间件添加到该队列上,然后依次调用即可
中间件的形式
中间件是一个回调函数,包含req
、res
、next
三个参数,其中next即队列上的下一个中间件, 需要在中间件处理逻辑中手动调用下一个中间件,或者表示中止操作
let mid1 = function(req, res, next) {
console.log("this is mid 1 before next");
next();
console.log("this is mid 1 after next");
};
通过函数执行栈,可以在next前和后分别处理一些逻辑
注册中间件
注册原理很简单,通过闭包维护一个数组,然后push到队列即可
let stack = [];
use(middleware){
stack.push(middleware);
}
依次调用
通过一个游标,依次调用中间件
handle(req, res) {
var index = 0; // 从0开始遍历队列
function next() {
var handler = stack[index++];
if (handler) {
// 这里就是为什么需要在前一个中间件中手动调用next()的原理
handler(req, res, next);
}
}
next();
}
可见next实际上就是队列中的下一个中间件,并在handle方法中通过参数的形式传递给上一个中间件。
此外,每次调用handle方法都会从0开始,完整执行整个队列里面注册的中间件。
测试
下面是完整的实现和测试代码
let app = (function() {
let stack = [];
return {
// 模拟get方法
get(url, cb) {
// 初始化请求和响应对象
let req = {};
let res = {};
app.use(cb);
// 保证所有注册的中间件都可以被调用
setImmediate(() => {
this.handle(req, res);
});
},
// 注册中间件
use(middleware) {
stack.push(middleware);
},
handle(req, res) {
var index = 0;
function next() {
var handler = stack[index++];
if (handler) {
handler(req, res, next);
}
}
next();
}
};
})();
app.use(function mid1(req, res, next) {
console.log("this is mid 1 before next");
next();
console.log("this is mid 1 after next");
});
app.use(function mid2(req, res, next) {
console.log("this is mid 2 before next");
next();
console.log("this is mid 2 after next");
});
app.get("/test", function(req, res, next) {
console.log('/test action')
next();
});
app.use(function mid3(req, res, next) {
console.log("this is mid 3 before next");
next(); // 实际上暂没有注册下一个中间件
console.log("this is mid 3 after next");
});
运行代码,控制台输出
this is mid 1 before next
this is mid 2 before next
/test action
this is mid 3 before next
this is mid 3 after next
this is mid 2 after next
this is mid 1 after next
看起来整个实现是正常的,那么,如果中间件是异步的,情况会有什么变化呢?
在handle方法中,我们只保证了每个中间件在stack
中的调用顺序,并没有关注他们的执行顺序。因此,在正常情况下,遵循event-loop的顺序,先执行同步代码,再执行异步代码
更改测试代码
// 修改mid2为异步中间件
app.use(async function mid2(req, res, next) {
console.log('sleep for 500ms')
await sleep(500);
console.log("this is async mid 2 before next");
next();
console.log("this is mid 2 after next");
});
结果输出
this is mid 1 before next
sleep for 500ms
this is mid 1 after next
// 这里会延迟500ms,然后输出后面的内容
this is async mid 2 before next
/test action
this is mid 3 before next
this is mid 3 after next
this is mid 2 after next
可见这并不是我们想要的效果,看起来跟我们篇头提到的bug有点相似哦。带着“如何处理中间件的异步任务”这个问题,我们来看看Koa中中间件的实现。
Koa中的中间件
下面是koa源码片段
callback() {
// const compose = require('koa-compose')
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
// 将http模块原始的reqest和response对象处理成ctx上下文对象
const ctx = this.createContext(req, res);
// 执行中间件
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404; // 篇头提到的问题,默认状态码404
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 开始调用中间件,然后执行handleResponse
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
可以看见,其中间件的逻辑主要通过koa-compose实现,compose组合是函数式编程里面的一个概念
compose组合就是结合两个或多个函数,从而产生一个新函数或进行某些计算的过程。
koa-compose本身只是一个高阶函数,其功能是按顺序调用中间件,跟上面实现的的handle
方法类似,接下来看看里面的实现
function compose (middleware) {
// ... middleware类型校验
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0) // 从0开始调用middware队列里面的中间件
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)
}
}
}
}
可见,每个中间件的参数为
- context对象,即本次请求上下文,在上面的
handleRequest
中方法由createContext(req, res)
生成 - next下一个中间件,在源码里面表示为
dispatch.bind(null, i + 1)
此外还可以看见,
- 每个中间件的返回都是一个Promise对象。
- koa对于错误处理也是通过
Promise.reject
进行处理的
通过上面的代码实现和源码阅读,我们知道了中间件的使用方法:在上一个中间件中,手动调用next()
方法来执行下一个中间件,从而保证中间件的调用顺序;又因为每个中间件的返回都是一个Promise对象,如果我们需要保证每个中间件的执行顺序,那么就需要等待中间件的返回值Promise完成
换句话说:如果在中间件中调用 next(),你必须等待它完成!
解决办法
根据中间件的原理和Koa的实现,现在回过头来看文章开头的问题:控制器里面的ctx.body设置无效,koa响应返回404。
可以看见,我们使用的日志中间件是同步函数,其作用是在请求进来的时候打印控制器和方法。
由于在日志中间件中,我们没有等待next()的完成,导致koa直接执行了handleResponse,此时控制器中的异步代码还并未执行,导致返回了默认的404状态码。
知道了原因,那么解决方法了很简单了:修改日志中间件,将其修改为async函数,然后等待下一个中间件完成
router.use(async (ctx, next) => {
console.log(`${ctx.request.method} ${ctx.url}`)
await next()
})
这样,重新访问接口,就可以得到正常的数据返回了。
小结
这里整理了koa源码里面中间件的原理,并手动实现了一个简易的中间件系统。此次bug仅仅是漏掉了一个await next()
,涉及到的却是Koa的核心原理,虽然使用了一段时间的koa了,对于其中的原理却没有过多深入,应当引起重视。
中间件在很多框架中都有应用,其原理应该是大同小异,接下来打算看看axios中拦截器的实现了~
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。