手撕KOA2源码

在SF平台潜水很久了,这也是我的第一篇文章,之前一直以学习为主。希望日后能通过写技术文章和回答问题的方式来做一些输出^ ^ 之前没有这方面的习惯,所以语言组织方面可能会有点混乱.

最近面试两次被问到KOA的洋葱圈模型(因为之前学校的几个项目我都是用KOA2来写的),但是自己没有深挖过洋葱圈的原理,感觉答得不是很满意。所以这次特地翻出KOA的源码看了一下。

目录结构

├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── package.json

目前我们下载的node_modules/koa包中的源文件结构就是如此。而koa处理请求的核心也就是以上四个文件,其中

  • application.js是整个KOA2的入口。最重要的中间件逻辑也是在此进行处理。本次学习的也就是这个文件。
  • context.js负责处理应用上下文
  • request.js处理http请求
  • response.js处理http响应

简单看看KOA类中封装了哪些方法,顺藤摸瓜看看

构造函数

constructor(options) {

super();
options = options || {};
this.proxy = options.proxy || false;
this.subdomainOffset = options.subdomainOffset || 2;
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
this.maxIpsCount = options.maxIpsCount || 0;
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;
}

}

listen

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

KOA的监听函数是对原生的createServer做的简单封装,传入的参数也会直接被传给原生的server.listen。只不过这里通过KOA类中的callback方法生成了一个配置对象传入server中,从这里来看,KOA实际的执行逻辑其实是通过callback函数来暴露的。

callback

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;
}

首先查看第一句

const fn = compose(this.middleware);

this.midlleware显然是实例中的中间件队列。

众所周知,compose是组成的意思,以前我们学过的一个短语就是be composed of -> 由…所组成。在代码中的表现形式则大概为组合函数

g() + h() => g(h())

这里的compose变量来自koa-compose这个包,他的作用是将所有的koa中间件进行合并执行。可以理解为之前的middleware数组只是一些零散的洋葱圈层级,是通过koa-compose处理过后才成为了一个完整的洋葱(文章末尾会附上koa-compose的原理)

回到上面的方法,获得了中间件合体后的组合函数fn后,声明了一个最终用于输出的handleRequest函数,在函数中先通过this.createContext初始化报文,可以理解为生成了一个完整的请求报文和响应报文,而初始化报文的逻辑就写在了lib/request.jslib/response.js之中。最后将他们通过this.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);
}

这里就是将处理后的报文传给我们之前生成的组合函数fn(也就是洋葱圈),在完成了整个洋葱圈的处理逻辑之后去做一些后续处理(返回客户端/错误处理)。

众所周知,洋葱圈模型的每一层都是一个Promise对象,只有当上游(官方文档的说法)的洋葱圈进入resolved状态后,线程的使用权才会向下游传递。(请注意,此时上一层洋葱圈并没有执行完毕)之后当内层的Promise成为resolved状态后,JS线程的使用权才会继续向上冒泡,去处理外层洋葱圈resolve之后的逻辑

洋葱圈函数接收的两个参数 ctxnext中的next方法就是一个异步方法,代表将当前这层洋葱圈强制 resolve,控制权指向下游,当前的函数将被阻塞,直到下游所有逻辑处理完之后,才能继续执行。

手撕KOA2源码_第1张图片

看到这里我们已经可以对目前看到的部分做一个梳理。

  1. KOA实例收集了一些需要的中间件(use方法)
  2. this.callback函数中通过koa-compose将中间件进行组合生成了一个洋葱圈处理器fn(处理谁呢,当然是处理报文对象啦)
  3. 引用报文格式化方法
  4. 将报文格式化方法和洋葱圈处理器封装成一个工厂函数handleRequest。这个工厂函数只负责接受报文,返回结果。

上面说到的use方法其实也是洋葱圈模型的核心,也就是字面上的注册中间件方法。

use

use(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;
}

use接受的必须是一个函数(接受两个参数,一个是ctx上下文,一个是next函数),如果这个函数是生成器(*generator),(KOA1中的中间件只接收迭代器方法,KOA2中则全部用async await来实现),那么就需要做一个优雅降级处理,通过koa-convert函数去进行转换。(这一块儿我也还没有看= =)

最后就很简单了,将这个方法push到KOA实例的middleware队列中

看到这里,application文件的内容就结束了,这样看来KOA的源码还是比较少的,但是因为中间件的存在,可扩展性变得非常强,这应该也是为什么Koa目前这么火的原因。

PS: koa-compose

koa-compose的代码量非常少,只有50行不到(49行)。但是设计非常精妙。

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) {
    // 最后一次被调用的中间件下标
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      // i <= index 说明有中间件被重复调用了,抛出错误
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      // fn取对应层的中间件,如果传入了next函数,那么next函数的优先级更高
      if (i === middleware.length) fn = next
      // 此处fn指向的就是第i层的中间件函数
      // 如果没有了,那么直接resolve
      if (!fn) return Promise.resolve()
      try {
        // 实际的执行步骤在这,执行fn方法,同时将下一层中间件的执行函数也传递过去
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

笼统的概括这个语法就是每个中间件执行到await next()语句的时候,都会调用下一层的中间件。你也可以将代码理解为

// 前半部分处理逻辑
await next() 
// 后半部分处理逻辑


/* ================= 等价于 ================= */


// 前半部分处理逻辑
await new Promise([下一层中间件的逻辑])
// 后半部分处理逻辑

而这样的插入逻辑在下一层中间件的逻辑中又在递归的发生着,洋葱圈的执行逻辑其实存储在数组koa.middleware中,只有执行到洋葱圈的第n层的时候,才会通过下标n+1去取下一层的处理逻辑,并且生成Promise插入到上层洋葱圈的函数体中,形成了一个不断重叠的函数作用域。

END

你可能感兴趣的:(koa.js,javascript,node.js)