1

前言

koa作为广泛运用的node框架,其源代码非常精简,看完之后愈发佩服TJ大神,能够用这么少的代码实现了如此强大易用的框架。下面结合源码具体分析一下其中的核心原理。

整体结构

首先看一下 koa 框架的组成结构,koa 的源码由4个部分组成,分别是 application.js(入口文件),context.js(上下文,即koa的ctx),request.js(请求对象,基于req封装),response.js(响应对象,基于res封装),其中核心代码主要都位于 application.js 中。下面会从4个文件展开具体的分析:

application

application.js 是 koa 的入口文件,也是核心所在。在该文件中引入了其他3个文件,并在构造函数中定义了一些核心属性,主要有

  • middleware:这是注册的中间件的集合
  • context:上下文模块,继承于context.js创建的对象
  • request:请求模块,继承于request.js创建的对象
  • response:响应模块,继承于response.js创建的对象
const context = require('./context');
const request = require('./request');
const response = require('./response');
....

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

接下来看koa的使用方法 listen ,listen 方法就是对 http.createServer 对了一个简单的封装,抽离出来单独的回调函数,返回 http 服务对传入端口的监听。node 原生的 http.createServer方法中需要传入处理的回调函数,但是在实际的复杂业务逻辑中,代码不可避免不好管理,因此 koa 这里对回调函数作了单独处理。

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

下面继续看单独抽离的 callback 方法,该方法中主要做了3件事情:

  1. 对注册的中间件进行了统一整合处理
  2. 监听框架运行错误,并设置了错误处理函数
  3. 返回请求处理函数

下面具体来看这 3 个步骤:

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

compose

该方法中的第一步就调用了 compose 方法对中间件进行了整合处理, compose方法是单独写的 koa-compose 模块,是koa中间件处理的核心所在,具体看一下处理逻辑,十分有意思:

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!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  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))); // 返回的方法中的第二个参数递归调用下一个中间件方法,这就是为什么中间件中执行next()时会调用下一个中间件函数
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

该方法中首先对传入的中间件集合入参做了判断,只能是数组,并且数组中的每个元素都必须是函数,这就要求注册的中间件必须是函数。 接下来就是核心所在,该方法返回一个函数,第一个参数 context 是请求上下文,第二个参数 next 是所有中间件执行完之后最终执行的回调函数。我们重点来看一下该方法中核心的dispatch函数的逻辑:

dispatch

dispatch函数会遍历 middleware 中间件集合,依次取出中间件进行执行,直到所有中间件都执行完成。fn(context, dispatch.bind(null, i + 1)) 这条语句是最关键的一条语句,执行当前中间件函数,将上下文context作为第一个参数传入,下一个要执行的中间件方法作为第二个参数传入。这就是为什么我们在 koa 中间件中执行next()方法(对应这里的第二个参数)时,会执行下一个中间件函数的原因,如果不调用next(),那么后面的中间件函数都会无法执行。

监听error

第二步,通过 this.on('error', this.onerror) 对框架中的 error 事件进行监听,对应的 onerror 处理函数分情况进行了相应的错误处理

 onerror(err) {
    if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', 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();
  }

返回handleRequest

createContext

先执行了 const ctx = this.createContext(req, res),创建了当前请求的上下文对象,createContext 方法做的事情就是创建了一个 context 对象,并且将当前的this、req、res都挂载到了该对象上,这也是为什么我们在使用 koa 时能在请求的 ctx 上拿到关于 app、req 和 res 上的各种请求相关属性的原因。

createContext(req, res) {
    const context = Object.create(this.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;
  }
handleRequest

紧接着 return this.handleRequest(ctx, fn) 其中 ctx 就是上一步创建的请求上下文对象,fn 是 compose 返回的闭包函数。handleRequest方法最终 return fnMiddleware(ctx).then(handleResponse).catch(onerror); 即当所有中间件执行之后执行响应处理函数。

 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); // 所有中间件执行之后执行响应处理函数,若抛出异常,执行默认错误回调函数
  }
respond

接下来重点看一下上一步相应处理函数中用到的 respond 函数,该函数是 koa 中的又一个核心函数。主要就是针对不同的响应主体和状态,进行不同的处理,主要分为以下几种case:

  1. 没有响应主体时的处理
  if (statuses.empty[code]) { // 返回的状态码表示没有相应主体时
    // strip headers
    ctx.body = null;
    return res.end();
  }
  1. HEAD请求方法,响应头已经发送,但是没有内容长度时的处理
  if ('HEAD' === ctx.method) { // HEAD请求方法
    if (!res.headersSent && !ctx.response.has('Content-Length')) { // 响应头已经发送,但是没有内容长度,进行设置
      const { length } = ctx.response;
      if (Number.isInteger(length)) ctx.length = length;
    }
    return res.end();
  }
  1. 有相应主体,但是为空时的处理
 if (null == body) { // 有相应主体,但是为空
    if (ctx.response._explicitNullBody) {
      ctx.response.remove('Content-Type');
      ctx.response.remove('Transfer-Encoding');
      return res.end();
    }
    ...
  1. 有相应主体,不同格式的处理
 // 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);

至此整个 koa 入口文件的主流程使用方法已经分析完成,除了这个主流程之外,还有个中间件的使用方法 use() 需要单独看一下。

use

use 方法是 koa 中注册中间件的方法,原理其实很简单,当调用 use 方法注册中间件时,实质上就是讲中间件函数 push 到 this.middleware 这个框架中间件集合中,所以中间件的执行是先进先出。并且函数最后返回了 this, 这么做是保证了中间件的注册可以实现链式调用。具体的代码和注释如下:

 use(fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // koa1.x版本使用Generator Function的方式写中间件,而Koa2改用ES6 async/await。所以在use()函数中会判断是否为旧风格的中间件写法,并对旧风格写法得中间件进行转换
    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; // 返回this,所以可以链式调用
  }

context

接下来看 koa 的 context.js 文件,context 的核心在于:

  1. 封装了 koa 请求的上下文,代理了 request 和 response 这两个对象的属性和方法,用到的 delegate 方法是一个第三方库,用来代理对象的属性和方法。
delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  
  ...
  
delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set') 
  
  ...

2.定义了 onerror 错误处理函数,在之前 application.js 里面的 handleRequest 就有用到。该函数主要也是根据不同的情况做不同的处理:

onerror(err) {
    if (null == err) return;
    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
    
    ...
    
    if ('ENOENT' == err.code) err.status = 404;

    // default to 500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    // respond
    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    res.end(msg);
    
}

request && response

最后的请求和响应模块,没什么特别需要分析的,就是对请求和响应的相关属性和方法作了封装,用 set 和 get 函数的形式进行属性的读写操作。大致的封装代码如下:

get search() {
    if (!this.querystring) return '';
    return `?${this.querystring}`;
  },

set search(str) {
    this.querystring = str;
  },
  ...

洋葱圈模型

最后着重看下 koa 中间件请求的洋葱圈模型,这是 koa 区别于 express 的一个重大特点。 用一张网上经典的图片和一个简单的小例子说明一下: image

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

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

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

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

app.listen(3000, () => {~~~~
    console.log('listenning on 3000');
});

//依次输出 1、2、3、4、5、6

为什么能实现这个效果呢,其实在前面介绍 koa-compose 源码中已经可以找到答案了。koa 中间件机制 return Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 每次都返回一个promise,并在中间件方法中调用 next()时,对应执行下一个中间件。因此当 await next() 时会等待下一个中间件执行完成后,再回到当前中间件中继续执行后续代码。这也就是洋葱圈模型实现的原理。


款冬
1.5k 声望42 粉丝

前端小小弄潮儿~