koa中间件导致接口404的问题

最近在使用koa时遇见了一个很奇怪的问题:控制器中包含异步操作的接口,返回均为404;只包含同步操作的接口,接口返回正常。

经过排查,发现是前面使用了一个同步形式的中间件,并未等待后续异步中间件结束导致的问题。这里记录一下相关问题,以及分析koa中间件的实现和注意事项。

<!--more-->

参考

1. 问题还原

下面是相关代码

// 路由
// 使用了一个日志中间件,用于在控制台打印请求
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的中间件机制。

2. 中间件

中间件这个概念在很多地方都有用到,可以把中间件可以看做是一个装饰者模式,在实现具体的逻辑前后,进行相关的操作,根据执行的顺序,又可以分为前置中间件和后置中间件。

从网上找了一张描述中间件的洋葱模型图

在了解koa的中间件之前,我们先尝试手动实现一个超级简单的中间件系统。

2.1. 实现一个中间件

中间件最主要其思路就是维护一个回调函数的队列,在注册中间件时将中间件添加到该队列上,然后依次调用即可

中间件的形式

中间件是一个回调函数,包含reqresnext三个参数,其中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中中间件的实现。

2.2. 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(),你必须等待它完成!

3. 解决办法

根据中间件的原理和Koa的实现,现在回过头来看文章开头的问题:控制器里面的ctx.body设置无效,koa响应返回404。

可以看见,我们使用的日志中间件是同步函数,其作用是在请求进来的时候打印控制器和方法。

由于在日志中间件中,我们没有等待next()的完成,导致koa直接执行了handleResponse,此时控制器中的异步代码还并未执行,导致返回了默认的404状态码。

知道了原因,那么解决方法了很简单了:修改日志中间件,将其修改为async函数,然后等待下一个中间件完成

router.use(async (ctx, next) => {
    console.log(`${ctx.request.method} ${ctx.url}`)
    await next()
})

这样,重新访问接口,就可以得到正常的数据返回了。

4. 小结

这里整理了koa源码里面中间件的原理,并手动实现了一个简易的中间件系统。此次bug仅仅是漏掉了一个await next(),涉及到的却是Koa的核心原理,虽然使用了一段时间的koa了,对于其中的原理却没有过多深入,应当引起重视。

中间件在很多框架中都有应用,其原理应该是大同小异,接下来打算看看axios中拦截器的实现了~