Koa2第三篇:koa-compose

337 阅读2分钟
原文链接: zhuanlan.zhihu.com

上一篇是在应用层上介绍了Koa2的中间件用法,下面我们深入源码理解Koa2的中间件机制。

xyzcoding:Koa2第二篇:中间件

Koa2中koa-compose的使用

  /**
   * 添加中间件的方法
   * Use the given middleware `fn`.
   *
   * Old-style middleware will be converted.
   *
   * @param {Function} fn
   * @return {Application} self
   * @api public
   */

  use(fn) {
    // fn必须是函数
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // 兼容生成器函数的中间件写法
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    // 添加中间件, 所有中间件是通过数组保存的
    this.middleware.push(fn);
    // 支持链式调用
    return this;
  }

  /**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */

  callback() {
    // 传入的是一个数组
    const fn = compose(this.middleware);
    // http://nodejs.cn/api/events.html#events_emitter_listenercount_eventname
    // 添加默认的错误异常处理
    if (!this.listenerCount('error')) this.on('error', this.onerror);

    const handleRequest = (req, res) => {
      // 创建每一次请求的下文文对象
      const ctx = this.createContext(req, res);
      // this.handleRequest处理每一次的请求流程
      // 熟悉的朋友可能就想到我们添加一个中间件的写法 app.use(async(ctx, next){ ... })
      return this.handleRequest(ctx, fn);
    };
    // 返回http.createServer()的参数requestListener
    return handleRequest;
  }

  /**
   * Handle request in callback.
   * 处理HTTP的请求回调
   * @api private
   */

  handleRequest(ctx, fnMiddleware) {
   // 每个请求会经过这个方法,后面调试可以看到
    const res = ctx.res;
    // http://nodejs.cn/api/http.html#http_response_statuscode
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

koa-compose

接下来我们通过断点调试的方式来熟悉中间件的来龙去脉。

const Koa = require('koa')
const app = new Koa()
const convert = require('koa-convert');

// 日志中间件
app.use(async(ctx, next) => {
  console.log('middleware before await');
  const start = new Date()
  await next();
  console.log('middleware after await');
  const ms = new Date() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})

app.use(async(ctx, next) => {
  console.log('response');
  ctx.body = ""
})

module.exports = app

通过debug模式启动

// bin/www
var server = http.createServer(app.callback());

进入koa-compose源码断点的位置是从

 // application.js  
 callback() {
    // 进入koa-compose 
    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;
  }

执行完一遍koa-compose后,会返回一个包装函数。

然后在浏览器端访问http://localhost:3000/1212

此时一个请求进来会经过application.js中的handleRequest方法。

  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    // fnMiddleware(ctx)执行后,koa-compose中闭包正式开始了。
    // 于是第一个中间件开始执行
    // 此处传入的ctx其实会挂载到后续中间件的context对象。
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

从调试进入fnMiddleware(ctx)方法,你就会发现进入koa-compose的返回函数中,i=0,从第一个中间件函数开始执行。

可以看的从第一个中间件开始执行,这里需要注意dispatch中的注释地方。

    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
      // 执行返回的都是Promise
      if (!fn) return Promise.resolve()
      try {
        // 这里需要特别注意
        // fn(context, dispatch.bind(null, i + 1))
        // 其实就是对应我们async中间件 async(ctx, next) context就是上下文对象, dispatch.bind(null, i + 1)就是下一个中间件函数
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }

第一个中间件执行返回Promise化的fn(context, dispatch.bind(null, i + 1))。熟悉的朋友就马上意识到我们async函数中间件写法。

// context 就是 ctx
// dispatch.bind(null, i + 1) 就是next,下一个中间件
app.use(async (ctx, next) => {
  ...
})

于是开始执行我们第一个中间件。

当接着执行到await next()时候,断点调试发现。

你会发现index变为1了。继续单步调试,执行return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

执行第二个中间件。执行完后回溯(后续看看async_hook.js源码)流程。

于是正常的流程我们这里就走完了。

但是如果我们修改下开始的demo

// 日志中间件
app.use(async (ctx, next) => {
  console.log('middleware before await');
  const start = new Date()
  await next();
  console.log('middleware after await');
  const ms = new Date() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})

app.use(async (ctx, next) => {
  console.log('response');
  await next() // 也就是说这里一个调用next()。其实它并不存在
  ctx.body = "koa2"
})

i等于2,next其实并不存在。因此会走下面代码流程

 if (i === middleware.length) fn = next
 // 执行返回的都是Promise
 if (!fn) return Promise.resolve()

相当于什么没有做。

于是接下来流程跟最开始一样了。

上述文章内容大致可以说明一个HTTP请求在Koa2的流转过程。

总结

简单来说的话, koa-compose的功能就是把 compose([f1, f2, f3, ..., fn])转化为f1(ctx, (ctx, (...fn))))。

Koa2的中间件可以对请求作出处理,也可以对响应作出处理。当一个请求进来,会依次被各个中间件处理,中间件跳转是通过async next(),当请求到达一个中间件并且被处理后不执行next(),那么此时程序会逆序执行前面的中间件剩下的业务逻辑,这个就是回溯,也就是我们常说的洋葱模型。