一文讲透koa-源码剖析

阅读 1274
收藏 13
2017-10-20
原文链接:tecknight.xyz

前言

本文从头开始由浅入深剖析现在十分流行的koa框架的核心源码,适合已经熟练掌握koa框架使用的开发人员阅读

核心机制

现在,让我们从头开始看看koa的内部究竟做了些什么?

// usage
const app = new Koa()
// source code
constructor() {
    super();

    this.proxy = false;
    this.middleware = [];
    this.subdomainOffset = 2;
    this.env = process.env.NODE_ENV || 'development';
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
}

这就是一切的起源,Koa 类的实例从此诞生,继承自 Events,可得知其子类拥有处理异步事件的能力,然而Koa如何处理,现在还不得而知,先打个问号。但创建实例的过程中,可得知有三个对象作为实例的属性被初始化,分别为 context request response,还有我们非常熟悉的存放所有全局中间件的数组middleware

koa class 图示

koa.png
// usage 
app.use(async (ctx, next) => {
    // 中间件函数
});

当调用use方法时,在确认它是async函数的情况下,通过push操作,这个函数会被追加到middleware数组中

use(fn) {
    // 类型检查...
    this.middleware.push(fn);
    return this;
}

此时我们已经有了处理的操作,但是koa还并没有真正的跑起来

app.listen(3000);

可以说当开启http服务器的时候,koa才真正能够开始处理我们的http请求,那么这样一个简洁的调用背后具体究竟做了什么呢?

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

koa 使用了 node 的原生 http包来创建http服务,所有的秘密都藏匿在 callback() 这个方法中

// source code
const fn = compose(this.middleware);
...
return fn(ctx).then(handleResponse).catch(onerror);

koa 自身还依赖于 koa-compose 模块,从 koa对于 fn 的使用情况来看,middleware 应该是被封装成了一个叫做 fn 的对象,通过传入 context 对象来返回一个 Promise

现在,深入 koa-compose 模块来看看它又对我们的中间件数组做了什么。

// source code
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, function next () {
        return dispatch(i + 1)    // 递归调用dispatch
      }))
    } catch (err) {
      return Promise.reject(err)
    }
  }
}

里面的闭包看起来很眼熟是吗?再对比一下

// usage 
app.use(async (ctx, next) => {
    // 中间件函数
});

这里的 Promise.resolve(fn(..)) 帮助我们异步执行的中间件函数,这里的next函数就解释了为什么Koa的中间件调用是递归执行的,它递归调用了 dispatch 函数来遍历数组中的,同时,所有的中间件函数享有同一个 ctx,再回顾一次外部

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

return handleRequest;

context 使用node原生的 http 的监听回调函数中的 req res 来进行进一步的封装,意味着对于每一个 http 请求,koa都会创建一个 context 并共享给所有的全局中间件使用,当所有的中间件执行完过后,会将最后要返回的所有数据统一再交还给 res 进行返回,所以我们在每一个中间件中才能够从 ctx 中取得自己所需要的 req 中的数据进行处理,最后 ctx 再把要返回的 body 给原生的 res 进行返回

每一个请求都有唯一一个 context 对象,所有的关于请求和返回的东西都统一放在里面

createContext 方法将 req res 进一步封装

// source code
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的原则,context 必须是作为一个临时对象而存在,所有的东西都必须封进一个对象中,因此 app req res 三个属性就此诞生,但不知道大家是否有一个疑问,为什么 app req res 也同时被封在了 requestresponse 里 ?

使他们同时共享同一个 app req resctx,是为了将处理职责进行转移,客户从外部访问,他们只需要一个 ctx 即可获得所有 koa 提供的数据和方法,而 koa 会继续将这些职责进行进一步的划分,比如 request 是用来进一步封装 req 的,response 是用来进一步封装 res的,这样职责得到了分散,降低了耦合,同时共享所有资源使得整个 context 具有了高内聚的性质,内部元素互相都能够访问得到

// source code
context.state = {};

看得出来,其中的 state 就是专门负责保存单个请求状态的空对象,用户可以根据自己的需要来管理里面的内容

// source code
const ctx = this.createContext(req, res);
const onerror = err => ctx.onerror(err);

这个巨大的 ctx 被创建出来的时候在一开始就立马挂载了错误监听器,但在 createContext 中并没有发现这个onerror方法,应该属于其中一个模块的原型方法,经过一番搜索,发现它位于 context.js 这个文件下,这个文件定义了所有的默认 context 对象具有的原型方法

// application.js
// handleRequest()
const onerror = err => ctx.onerror(err);
onFinished(res, onerror);
return fn(ctx).then(handleResponse).catch(onerror);

异步函数运行过程中如果有异常抛出且内部没有自行捕获异常的话, koa 会统一使用 Promisecatch 语句来进行错误处理,用一个图来表示的话那就是这样

koa 请求处理流程图

koa-request-process.png

到这里我们应该也不难解开本文开头时我们心中的一个疑问,那就是为什么要继承 Events,其实就是为了使用 node 自带的事件监听器来监听一些事件,例如目前所知的 error ,来解耦错误处理这一块的功能

全局的错误处理函数

// source code
// application.js

onerror(err) {
  assert(err instanceof Error, `non-error thrown: ${err}`);

  if (404 == err.status || err.expose) return;
  if (this.silent) return;

  const msg = err.stack || err.toString();
  console.error();
  console.error(msg.replace(/^/gm, '  '));
  console.error();
}

Koa 的内部核心处理流程已经梳理完毕,现在我们再进入内部模块一探究竟

request 模块

大量的 getter setter 充分提取出 node http 所提供的有用的请求相关的属性,还有一些必要的 helper 函数这里不再赘述,API文档中几乎所有的 ctx.req 下的属性都在其下提供,值得一提的是其中的 this ,头疼的 this , 这一块的代码用了大量的 this.req 的方式来访问node原生提供的 http 请求信息(因为我们把 node 的req赋给了context),当在 koa 外部进行使用时我们是通过 ctx.req.propertyName 的形式 … 没错,我想你也发现了,我们需要绑定我们的 this 使之指向的是 ctx.req,所以在 context.js 模块中使用了代理来将 this 进行正确的绑定,前文提到的使这些模块能够相互访问的赋值代码其实也一定程度减少了this的麻烦事

// 详见 delegates 包
// https://github.com/tj/node-delegates

// Koa context.js
delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .access('query')
  .access('path')
  .access('url')
  .getter('origin')

// delegates 的内部核心实现,通过apply来重新绑定this
proto[name] = function(){
  return this[target][name].apply(this[target], arguments);
};

response 模块

response.js 的内部也同 request.js 中类似,值得一提的是 writable 这个getter,如果请求已经得到相应,那么返回 true,其中读取的值是 this.res.finished,许多的报错中我们常常会碰到关于尝试重复写入响应头 header 的错误,这个错误的来源即使是 on-finished 这个包,当结束响应的时候此包能够执行一个回调来保存此请求的状态于 res

response helper 函数

koa有一个统一的响应函数位于 application.js 的末尾,专门负责处理 ctx.body ctx.status 来进行请求响应

// 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);

值得注意的是返回的body支持 Buffer string 流,以及最常见的 json

全文完

商业转载请联系作者获得授权,非商业转载请注明出处,谢谢合作!

联系方式:tecker_yuknigh@163.com

评论