高质量 - Koa 源码解析

Koa 源码解析

一个简单的 koa 程序

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

// response
app.use(ctx => {
  ctx.body = 'Hello Koa';
});

app.listen(3000);

源码解析- koa 源码目录

  • application.js: koa 的导出文件夹,也就是定义 koa 的地方
  • context.js: 上下文,也就是常见的 ctx。每一个 app 都会有一份 context,通过继承这个 context.js 中定义的 context。同时每一个请求中,都会有一份单独的 ctx, 通过继承 app 中定义的 context。说白了就是复用。
  • request.js: 同前面的 context.js 说明。
  • response.js: 同前面的 context.js 说明。

说明:在 koanodejs 原生对应的是 reqres

源码解析- application.js


包的功能可以参考截图中给出的注释

Application


这个就是 koa 的定义的类

第一步 - new koa()

New 会执行构造函数。

所以在实例化的时候,可以传入这些 options

第二步 - app.use


在这里会检查 middleware 的类型,如果是老的 middleware 会转换一下,最后直接放到 middleware 这个数组中。数组中的中间件,会在每一个请求中去挨个执行一遍。

第三步 - app.listen

listen 的时候,才会去创建 server

对于每一个请求,都会走到 callback 中去,所以 callback 是用于处理实际请求的。一般不要去重写这个 callback

接下来去看看 callback 做了什么:

这里涉及到几个大的点:

  1. createContext 都干了什么
  2. Compose 是如何实现洋葱模型的。
  3. this.handleRequest(ctx, fn) 干了什么

这几个点分成两个大块来讲,2、3 两点放到一起讲。

createContext 干了什么


这里做了三件重要的事情

  1. 每一个 app 都有其对应的 context、request、response 实例,每一个请求,都会基于这些实例去创建自己的实例。在这里就是创建了 context、request、response
  2. node 原生的 res、req 以及 this 挂载到 context、request、response 上面。还有一些其他为了方便访问做得一些挂载,不过前面三个的挂载是必须的。
  3. 将创建的 context 返回,传给所有中间件的第一个 ctx 参数,作为这个请求的上下文

下面着重解释一下第二点中,为什么要把这些属性挂载上去。因为所有的访问都是代理,最终都是访问的 req、res 上面的东西,context 访问的是 request、response 上面的东西,但是他们上面的东西又是访问的是 req、res 上面的。

例如访问 ctx.methodcontext 会去 request 上面早,然后 request 会返回 req.method。后面分析其他文件时会讲到这种代理结构。

compose 如何实现的洋葱模型


在第三步中最后讲到的 callback 中,middleware 全部通过 koa-compose 这个包包装,返回了一个可执行的方法,在请求阶段会去执行这个方法,从而执行每一个中间件。先自己来手撸一个 compose 的🌰

function compose(middleware) {
    return function (ctx, next) {
        function dispatch(i) {
            if (i >= middleware.length) {
                return Promise.resolve()
            }
            let curMiddleware = middleware[i]
            return curMiddleware(ctx, dispatch.bind(null, i + 1))
        }
        return dispatch(0)
    }
}

function mid1(ctx, next) {
    console.log('mid1 before')
    next()
    console.log('mid1 after')
}
function mid2(ctx, next) {
    console.log('mid2 before')
    next()
    console.log('mid2 after')
}
function mid3(ctx, next) {
    console.log('mid3 before')
    next()
    console.log('mid3 after')
}

const fn = compose([mid1, mid2, mid3])
fn({})
--------------------------------------------------------------------打印结果
mid1 before
mid2 before
mid3 before
mid3 after
mid2 after
mid1 after

compose 中会根据 i 去挨个执行中间件,并且有一个回溯的过程。官方代码如下。

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  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 的原理之后,回到第三步中最后的 this.handleRequest(ctx, fn); fn 就是compose 返回的包装过 middleware 的函数。下面进入 handleRequest

可以看到当一个请求来到的时候,最后会去执行包装过的中间件函数,也就是这里的最后一行,并在中间件执行完毕之后,到 handleResponse 中去处理响应。在 handleResponse 中最终执行的是 respond

respond

function respond(ctx) {
  // allow bypassing koa
  if (false === ctx.respond) return;

  if (!ctx.writable) return;

  const res = ctx.res;
  let body = ctx.body;
  const code = ctx.status;

  // ignore body
  if (statuses.empty[code]) {
    // strip headers
    ctx.body = null;
    return res.end();
  }

  if ('HEAD' === ctx.method) {
    if (!res.headersSent && !ctx.response.has('Content-Length')) {
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }

  // status body
  if (null == body) {
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code);
    } else {
      body = ctx.message || String(code);
    }
    if (!res.headersSent) {
      ctx.type = 'text';
      ctx.length = Buffer.byteLength(body);
    }
    return res.end(body);
  }

  // responses
  if (Buffer.isBuffer(body)) return res.end(body);
  if ('string' == typeof body) return res.end(body);
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}

主要是将 ctx 上挂载的 body 通过 res.end 返回响应。

源码解析 - request.js

module.exports = {
   /**
   * Return request header.
   *
   * @return {Object}
   * @api public
   */
  get header() {
    return this.req.headers;
  },

  /**
   * Set request header.
   *
   * @api public
   */

  set header(val) {
    this.req.headers = val;
  },
 ..............
}

可见前面在 createContext 的时候在 request 上面去挂载 req、res 的原因就在这里。

源码解析 - response.js

module.exports = {
    /**
   * Return the request socket.
   *
   * @return {Connection}
   * @api public
   */

  get socket() {
    return this.res.socket;
  },
    /**
   * Get response status code.
   *
   * @return {Number}
   * @api public
   */
  get status() {
    return this.res.statusCode;
  },
.......................
}

源码解析 - context.js

const proto = module.exports = {
.............
  get cookies() {
    if (!this[COOKIES]) {
      this[COOKIES] = new Cookies(this.req, this.res, {
        keys: this.app.keys,
        secure: this.request.secure
      });
    }
    return this[COOKIES];
  },

  set cookies(_cookies) {
    this[COOKIES] = _cookies;
  }
............
}

/**
 * Response delegation.
 */

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');

/**
 * Request delegation.
 */

delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  .access('querystring')
  .access('idempotent')
  .access('socket')
  .access('search')
  .access('method')
  .access('query')
  .access('path')
  .access('url')
  .access('accept')
  .getter('origin')
  .getter('href')
  .getter('subdomains')
  .getter('protocol')
  .getter('host')
  .getter('hostname')
  .getter('URL')
  .getter('header')
  .getter('headers')
  .getter('secure')
  .getter('stale')
  .getter('fresh')
  .getter('ips')
  .getter('ip');

这里的 proto 就是 context,在自身定义了一个常用的方法,可通过 ctx.method 去访问,还有后面使用 delegate ,这个函数会把自 context 上面的 request、response 上面的一些属性定义到 proto 也就是 context 上面去,但是当使用 ctx.xxx 去访问的时候,其实是访问 request、response 上面的属性,这也是为什么需要将 request、response 挂载到 context 上面去。

阅读 614

推荐阅读