kekobin / blog

blog

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

koa源码解析

kekobin opened this issue · comments

简介

与express既当爹又当妈相比,koa不要太简洁。因为它只实现了基础核心,需要其他功能时额外引入即可。
它有多简介呢?查看下它的源码就知道了:

image

整个的实现就4个文件,对比下express:

image

简直不能太友好呀!

从一个简单的例子开始

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

app.use(async (ctx, next) => {  
  console.log(1)
  next();
  console.log(2)
})

app.use(async (ctx, next) => {  
  console.log(3)
  next();
  console.log(4)
})

app.listen(3000)

大家觉得会输出什么呢?
是 1234? 或者 1324 ?
都不是,答案是 1 3 4 2。
很多新手都会觉得没法理解,那么接下来通过这个例子来解析koa的源码,顺便解答为什么会这样输出。

application.js

从koa源码package.json的main入口可以看到,它指向的是lib/application.js。即整个应用的入口。

构造函数

const app = new koa()时,会处理构造函数的逻辑:

constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.env = options.env || process.env.NODE_ENV || 'development';
    if (options.keys) this.keys = options.keys;
    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }

构造函数主要的功能是初始化了中间件的容器(可看出koa中间件就是用数组处理的),从context,request,response创建koa proto的相同功能属性。

中间件添加

app.use(xxx) 对应的逻辑如下:

use(fn) {
  ...
  this.middleware.push(fn);
  return this;
}

很简单,就是添加到this.middleware数组里。

创建服务器并监听

app.listen(3000) 对应的逻辑如下:

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

可以看到,里面使用的是http.createServer来创建。重点是里面的callbak逻辑。

callback() {
    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;
}

很明显,http.createServer的回调代理给了这里的handleRequest。同时可以看到里面处理了中间件逻辑、每次请求的上下文、请求的最终处理等,那么问题来了:

  • 问题一:都说koa中间件是洋葱模型,那么这里是如何实现的呢?
  • 问题二:每次请求的上下文是如何处理的?
  • 问题三:每次请求的最终回调处理是怎样的?

问题一:都说koa中间件是洋葱模型,那么这里是如何实现的呢?

对应上面的逻辑代码:

const fn = compose(this.middleware);

其中的compose是koa-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!')
  }

  // 返回一个函数闭包, 保持对 middleware 的引用
  return function (context, next) {
    // 这里的 context 参数是作为一个全局的设置, 所有中间件的第一个参数就是传入的 context, 这样可以
    // 在 context 中对某个值或者某些值做"洋葱处理"

    // 解释一下传入的 next, 这个传入的 next 函数是在所有中间件执行后的"最后"一个函数, 这里的"最后"并不是真正的最后,
    // 而是像上面那个图中的圆心, 执行完圆心之后, 会返回去执行上一个中间件函数(middleware[length - 1])剩下的逻辑

    // index 是用来记录中间件函数运行到了哪一个函数
    let index = -1
    // 执行第一个中间件函数
    return dispatch(0)

    function dispatch (i) {
      // i 是洋葱模型的记录已经运行的函数中间件的下标, 如果一个中间件里面运行两次 next, 那么 i 是会比 index 小的.
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) {
        // 这里的 next 就是一开始 compose 传入的 next, 意味着当中间件函数数列执行完后, 执行这个 next 函数, 即圆心
        fn = next
      }
      // 如果没有函数, 直接返回空值的 Promise
      if (!fn) return Promise.resolve()
      try {
        // next 函数是固定的, 可以执行下一个函数
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

整个中间件的处理注释里面写的很清楚了,总结几点:

1.洋葱模型(即先入后出)是基于中间件中使用 next()实现的,如果中间件没有使用next(),或者某些中间件没有使用,则它及它后面的中间件就会被截断掉,执行不到了。
2.洋葱模型实现的关键点在于下面代码:

function next () {
  return dispatch(i + 1)
}

即当前中间件中执行nex(),便会递归处理后面的中间件,等待后面的中间件执行完,才会再回到当前中间件,实现洋葱的效果。
3.洋葱模型并不是绝对的,可以在中间件的nex()前后执行需要的逻辑,实现AOP的效果。比如接口的权限验证,必须是在next()之前进行验证,只有验证通过了才会去执行后面的中间件。

问题二:每次请求的上下文是如何处理的?

对应上面的 const ctx = this.createContext(req, res)。从这句代码中,可以看出来,每次请求都会根据req和res创建一个全新的上下文ctx,那么是如何实现的呢?这里的ctx中包含哪些东西呢?

createContext(req, res) {
    const context = Object.create(this.context);// 创建一个对象,使之拥有context的原型方法,后面以此类推
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
}

从上面可以看到,app、req、res等等全部赋给了context一个对象上面。所以我们才能够访问ctx.req.url、ctx.res.body这些属性。那为什么app、req、res、ctx也存放在了request、和response对象中呢?
使它们同时共享一个app、req、res、ctx,是为了将处理职责进行转移,当用户访问时,只需要ctx就可以获取koa提供的所有数据和方法,而koa会继续将这些职责进行划分,比如request是进一步封装req的,response是进一步封装res的,这样职责得到了分散,降低了耦合度,同时共享所有资源使context具有高内聚的性质,内部元素互相能访问到。

问题三:每次请求的最终回调处理是怎样的?

对应上面的this.handleRequest(ctx, fn),源码如下:

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    // application.js也有onerror函数,但这里使用了context的onerror,
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);

    // 这里是中间件如果执行出错的话,都能执行到onerror的关键!!!
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
 }

这里可以看出,会先执行所有的中间件,如果出错去执行onerror,如果成功回去执行handleResponse。而handleResponse的respond的逻辑如下:

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

  const res = ctx.res;
  // writable 是原生的 response 对象的 writeable 属性, 检查是否是可写流
  if (!ctx.writable) return;

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

  // ignore body
  // 如果响应的 statusCode 是属于 body 为空的类型, 例如 204, 205, 304, 将 body 置为 null
  if (statuses.empty[code]) {
    // strip headers
    ctx.body = null;
    return res.end();
  }

  // 如果是 HEAD 方法
  if ('HEAD' == ctx.method) {
    // headersSent 属性 Node 原生的 response 对象上的, 用于检查 http 响应头部是否已经被发送
    // 如果头部未被发送, 那么添加 length 头部
    if (!res.headersSent && isJSON(body)) {
      ctx.length = Buffer.byteLength(JSON.stringify(body));
    }
    return res.end();
  }

  // status body
  // 如果 body 值为空
  if (null == body) {
    // body 值为 context 中的 message 属性或 code
    body = ctx.message || String(code);
    // 修改头部的 type 与 length 属性
    if (!res.headersSent) {
      ctx.type = 'text';
      ctx.length = Buffer.byteLength(body);
    }
    return res.end(body);
  }

  // responses
  // 对 body 为 buffer 类型的进行处理
  if (Buffer.isBuffer(body)) return res.end(body);
  // 对 body 为字符串类型的进行处理
  if ('string' == typeof body) return res.end(body);
  // 对 body 为流形式的进行处理
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  // 对 body 为 json 格式的数据进行处理, 1: 将 body 转化为 json 字符串, 2: 添加 length 头部信息
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  res.end(body);
}

其核心就是根据不同类型的数据对 http 的响应头部与响应体 body 做对应的处理.运用 node http 模块中的响应对象中的 end 方法与 koa context 对象中代理的属性进行最终响应对象的设置.

至此,整个appllication.js的核心实现基本分析完了。

context.js

这个js主要实现的是koa的上下文。它主要实现两个核心功能:

  • 异步函数的统一错误处理机制
    上面分析application代码时,有这么一段:
const onerror = err => ctx.onerror(err);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);

这里处理的是所有中间件,如果出错则用onerror去处理,里面的实现逻辑使用的ctx.onerror。而ctx.onerror的源码如下:

onerror(err) {
    ...
    // delegate
    this.app.emit('error', err, this);
    ...
}

可见,最终会将err还是代理回app上,所以可以通过如下的方式监听整个的错误进行处理:

app.on('error', err => {
  log.error('server error', err)
});

context中还有如下两端代码,使用的是依靠delegates库通过委托模式,将node内部的request和response委托到了context上:

delegate(proto, 'response')
  ...
  .method('redirect')
  .method('remove')
  ...
  .access('status')
  .access('message')
  .access('body')
  ...
  .access('lastModified')
  .access('etag')
  ...

/**
 * Request delegation.
 */

delegate(proto, 'request')
 ...
  .method('accepts')
  .method('get')
  .method('is')
  ...
  .access('search')
  .access('method')
  .access('query')
  .access('path')
  .access('url')
  ...
  .getter('ip');

所以,我们可以通过如下访问:

ctx.header    
ctx.method    
ctx.query

request.js和response.js

比较简单,参考图
request
response

参考

koa源码解析