【源码解析】解析koa中间件原理,理解无处不在的compose

149 阅读3分钟

无处不在的next函数

Node.js中的connect库、生成器自动执行库co、redux、Koa中都有各自的一个流程控制函数:next,这里我们通过koa的中间件源码来聊一下next函数是如何进行流程控制的。

Koa的核心源码实现极其简洁,很适合用于学习,他的主目录lib下只有四个模块:

image.png

koa使用方式

koa的使用也很简单:

// 这里导入的koa其实就是application.js导出的一个class
const Koa = require('koa');
// 实例化Koa application对象
const app = new Koa();
// 使用中间件
app.use(async ctx => {
 ctx.body = 'Hello World';
});
// 监听
app.listen(3000);

koa实现原理

首先,在new Koa时,会执行构造函数,初始化一个数组存储中间件:

constructor(options) {
 super();
 ...
 this.middleware = [];
 ...
}

同时,use方法也很简单,就是将传入的中间件函数push到middleware中,并且返回自身以便实现链式调用:

use(fn) {
 // 判断中间件是否为function
 if (typeof fn !== 'function'throw new TypeError('middleware must be a function!');
 ...
 this.middleware.push(fn);
 return this;
}

最后调用listen方法监听对应端口的请求:

listen(...args) {
 const server = http.createServer(this.callback());
 return server.listen(...args);
}

这里其实就是调用了node.js提供的net内置库创建一个httpServer:

// http.js
function createServer(opts, requestListener) {
 return new Server(opts, requestListener);
}

Server函数中对opts做了类型判断,如果传入的第一个参数时function类型,则会被视作为回调:

function Server(options, requestListener) {
 ...
 if (typeof options === 'function') {
   requestListener = options;
   ...
}
 ...
}

而createServer中传入的是this.callback(),它返回一个函数。所以当服务监听到请求时,就会调用callback方法执行返回的这个函数,接下来就重点看看该方法的实现:

callback() {
 const fn = compose(this.middleware);
 ...
 const handleRequest = (req, res) => {
   const ctx = this.createContext(req, res);
   return this.handleRequest(ctx, fn);
};
 return handleRequest;
}

其中做了两点重要的工作,一个是调用compose方法对中间件数组进行处理返回一个方法fn,另一个就是对类成员函数handleRequest函数的包装,传入了上下文ctx,ctx的作用很明显,内部封装了请求中用到的各种上下文变量,便于开发者获取想要的参数,而fn函数则是在handleRequest进行了调用:

handleRequest(ctx, fnMiddleware) {
 const res = ctx.res;
 res.statusCode = 404;
 const onerror = err => ctx.onerror(err);
 const handleResponse = () => respond(ctx);
 onFinished(res, onerror);
 return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

虽然不知道compose做了什么,但是从handleRequest的实现来看,我们可以大致看出:这个方法执行了中间件函数对请求做处理,并且处理完成之后返回了一个Promise,且调用handleResponse方法,并最终调用respond对请求做了处理,而respond方法中对请求做了最后的返回:

function respond(ctx) {
  ...
  res.end(body);
}

看到这里,就可以发现,koa中中间件的关键实现逻辑其实就在compose函数中,理解了compose函数,就理解了koa中间件洋葱模型的实现逻辑。

理解compose

compose方法是一个单独的库,其中只有一个文件,且只导出了一个compose方法:

function compose (middleware) {
  // 参数类型校验:middleware必须为数组
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  // 参数类型校验:middleware数组内容必须为函数
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  return function (context, next) {
    // 定义分发函数
    function dispatch (i) {
      // 避免重复调用
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      // 获取第i项中间件函数
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      // fn为空则直接返回一个空Promise
      if (!fn) return Promise.resolve()
      try {
        // 最核心的一行代码:返回一个Promise.resolve,resolve中传入fn函数(也就是当前循环取到的中间件函	数)的调用,同时调用fn时的第二个参数为dispatch函数,会调用下一个中间件函数,依次递归
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        // 通过try catch返回Promise
        return Promise.reject(err)
      }
    }
    
    let index = -1
    // 启动dispatch,i传入0,也就是会调用middleware的中第一个函数
    return dispatch(0)
  }
}

相关的释义均详细地在代码中进行了注释。

其中最关键的代码就是

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))

根据注释

还有一点就是,在使用中间件时,一般需要在处理完自己的逻辑后调用一下next函数,我们常说koa实现了洋葱模型:

// 洋葱模型示例,
app.use(async (ctx, next) => {
  console.log('before next middleware invoke')
  await next()
  console.log('after next middleware invoke')
})

这个next是什么呢?打个断点很容易看出,其实就是bound dispatch,也就是dispatch.bind(null, i + 1)

image.png

由此可见,通过next,koa完成了中间件依次执行,并且通过支持async/await,实现了一个类似洋葱的模型。

compose的意义

在很多业务场景中,我们都可以使用compose来优化代码

比如,假设有一个业务流程,有多个步骤需要用户进行操作,而不同类型用户可能需要完成的步骤不一样,那可以维护一个不同类型用户需要执行的函数对象:

{
	"normal": [fn1, fn2, fn3, fn4],
	"vip": [fn1, fn2, fn4]
}

这样的写法可以避免大量的if/else逻辑,提供了很好的可读性和扩展性。