我悟了!Koa源码全流程解读

1,302 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情

前言

哎呀,眼瞧着6月更文计划快结束了,数数自己才更了6天。这不行,怎么着也得整个小风扇回来,不然感觉亏了一个亿。我的文章一般都比较长,主要我想成体系的去讲某个东西,人家读者朋友好容易赏脸来一趟,还不得让人家痛快了?今天咱们一起聊聊Koa,聊得不好,多多包涵。

回顾一下Koa用法

先简单回顾一下Koa的用法。Koa是一个Web服务器,基于Node中的Http模块做了很多封装,使用上分三步:1. 实例化app 2. 注册中间件 3. 启动服务并监听端口。

const Koa = require('koa');

const app = new Koa();

// middleware01
app.use(async (ctx, next) => {
  console.log('before middleware 01');
  await next();
  console.log('after middleware 01');
});

// middleware02
app.use(async (ctx, next) => {
  console.log('before middleware 02');
  await next();
  console.log('after middleware 02');
});

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

app.listen(3000, () => {
  console.log('listening at 3000');
});

执行结果如下:

listening at 3000
before middleware 01
before middleware 02
after middleware 02
after middleware 01
// 页面显示:Hello Koa

Koa主要在两个方面做了封装:

  1. 新建context,request,response三个对象对原生Http模块中的req,res对象进行封装,便于读写HTTP数据。
  2. 搭建基于洋葱模型的中间件注册和执行体系,优化原生Http模块中通过createServer(callback)注册回调的使用体验。

在上述案例中,一次请求的处理流程如下:

image.png

当收到Request请求时,我们通过已注册的中间件函数实现了对请求的按步处理,每个中间件通过next调用下一个中间件,next之前的逻辑按顺序执行,next之后的逻辑按逆序执行,每个函数都被访问了两次。最后,当所有中间件都执行完成了,Koa返回Response,完成一次请求处理。

另外,我们还发现代码中使用了ctx.body设置响应结果而不是原生的res.end()。不过,这没什么大惊小怪的,真正让我们感兴趣的还是这一套基于洋葱模型的中间件注册和执行体系,为了搞明白这些中间件是如何组织和执行的,我们需要问出以下三个问题:

  1. app.use()如何实现了中间件的注册?什么叫注册?
  2. 这些中间件函数如何实现洋葱模型的执行顺序?是怎么驱动的?
  3. 中间件函数中的next究竟是什么?为什么可以执行下一个中间件函数?

源码分析:中间件注册和执行体系

说句题外话,对于Koa这种极度简洁的框架,我真的是毫无抵抗力,简洁精巧才是好的架构该有的样子。相比于Express,Koa剥离了所有内置中间件,只留下最核心的中间件注册执行体系,既保证代码的稳定,又使得项目具有无限扩展性。当然,这么做最大的好处是:源码能看懂

Koa的源码只有4个模块,而核心模块只有application.js这一个。其余三个,context.js,request.js,response.js都是对原生Http模块中req,res的封装代码,我们放到后面再说,先通过研究application.js回答上述三个问题。

.
├── application.js
├── context.js
├── request.js
└── response.js

考虑到后面聊源码的时候大家可能比较晕,这里我按照前文的处理流程画了一张原理图,标注了后面要讲到的核心方法,方便大家理清思路。

image.png

首先我们看看application.js里面导出的是什么?

module.exports = class Application extends Emitter

哦,原来导出了一个Application类,还继承了EventEmitter,这就解释了我们为什么要new Koa(),同时也解释了为什么可以app.on('error',...)

app.use()如何实现了中间件的注册

接下来我们回答第一个问题:app.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(fn) {
    this.middleware.push(fn);
    return this;
  }

再看看this.middleware是什么?

  constructor(options) {
    this.middleware = [];
  }

哦,原来所谓注册就是创建一个数组然后往里丢数据啊,app.use()就是把依次把中间件函数push到数组里面。这就可以解释为什么中间件函数执行是有顺序的,因为是数组嘛。

看源码的一个好处是可以看清事物的本质,以后别人再说什么注册,你就知道:哦,无非就是搞个容器往里面丢数据。小菜一碟,往下往下。

中间件如何实现洋葱模型?

接下来回答第二个问题:这些中间件函数如何实现洋葱模型的执行顺序?是怎么驱动的?

洋葱模型就是指:以next()函数为分隔点,先从外向内执行Request处理逻辑,再从内向外执行Response处理逻辑的过程。

image.png

其实更精确的说是:先顺序执行next前面的逻辑,在逆序执行next后面的逻辑。不过通常我们把next前面的逻辑用来处理Request,next后面的逻辑用来处理Response。

这种后进先出的执行顺序其实跟函数调用栈很像,一个函数调用另一个函数,等另一个函数返回再执行剩下的逻辑。究竟洋葱模型的本质是不是函数调用栈呢?我们继续研究。

首先我们找到app.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);
  }

简化之后:

  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => respond(ctx);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

这里我们能看到两个点:

  1. 请求的主要处理逻辑放在了fnMiddleware(ctx)
  2. 请求处理完毕后,会通过.then(handleResponse)返回响应数据,到这儿我们就能理解整个请求处理流程接收请求、执行中间件函数、返回响应是如何实现的。

接下来再看看fnMiddleware(ctx)是什么?我们刚才注册了那么多中间件,这儿怎么就一个?

我们刚才说app.handleRequest()是请求处理的入口函数,这个过程是在app.callback()中指定的,最终通过执行http.createServer(this.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;
  }

简化之后:

  callback() {
    const fn = compose(this.middleware); // 这个fn就是fnMiddleware,this.middleware就是中间件注册的那个数组
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn); // 这是入口函数
    };
    return handleRequest;
  }

这一块可能比较绕,其实就是多封装了几层: this.callback() -> handleRequest() -> this.handleRequest(),我们只要知道最终的入口函数是this.handleRequest()就行了。

如同代码中所示,const fn = compose(this.middleware)就是我们要找的fnMiddleware(ctx),这个compose是什么?

const compose = require('koa-compose');

哦,原来这里引用了另一个包koa-compose,这个包才是中间件洋葱模型驱动的核心,它把所有注册的中间件打包在一起返回了一个新的中间件函数fnMiddleware。一旦我们调用该函数,他就会在内部按照洋葱模型顺序执行所有的中间件。

这里插一句,既然koa-compose最终打包返回的是一个新的中间件,那我们就可以主动用它给中间件进行合并分组,使得主体代码更加简洁。

接下来我们到koa-compose包中看看这个compose函数。

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)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

简化之后:

function compose (middleware) {
  return function (context, next) {
    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)
      }
    }
  }
}

为了进一步简化,我们把其中不关键的部分先讲一下:

  1. 在一个中间件函数中最多只能调用一次next()
function compose (middleware) {
  return function (context, next) {
    let index = -1
    // ...
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      // ...
    }
  }
}

这部分的作用是防止在一个中间件函数中多次调用next(),比如这么调用会报错:

// middleware01
app.use(async (ctx, next) => {
  console.log('before middleware 01');
  await next();
  await next();
  console.log('after middleware 01');
});
  1. 忽略最后一个中间件函数调用的next()
function compose (middleware) {
  return function (context, next) {
    // ...
    function dispatch (i) {
      // ...
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      // ...
    }
  }
}

最后一个函数如果调用了next(),那么下一个fn肯定是undefined,于是就设为compose中传入的next,完成这一组中间件的调用,如果next不为空则进入下一组中间件,如果next为空就直接完成。这里的完成仅仅只所有next之前的逻辑完成,next之后的逻辑还未开始。

讲明白这两点之后,我们进一步简化代码:

function compose (middleware) {
  return function (context, next) {
    return dispatch(0)
    function dispatch (i) {
      let fn = middleware[i]
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

我宣布,这里就是整个Koa的精华,也是中间件洋葱模型驱动的核心逻辑。

我们回顾一下之前提的三个问题:

  1. app.use()如何实现了中间件的注册?什么叫注册?
  2. 这些中间件函数如何实现洋葱模型的执行顺序?是怎么驱动的?
  3. 中间件函数中的next究竟是什么?为什么可以执行下一个中间件函数?

第1个问题已经解决了,接下来解决剩下两个。

首先看见function dispatch (i)dispatch.bind(null, i + 1),我们就明白,这是传说中的递归,边界条件就是if (!fn) return Promise.resolve()

哦,原来中间件函数是通过递归调用驱动执行的。递归函数dispatch每次都从middleware数组中取得下一个中间件函数来执行,并把dispatch.bind(null, i + 1)作为next参数传入其中。一旦当前中间件调用了next(),就相当于主动调用了下一个中间件函数,如果不调用那后面的中间件永远得不到执行。

其次,我们发现compose中的返回值全部都是Promise.resolve()或Promise.reject(),为什么这么设计?

如果我们假设所有中间件执行的都是同步操作,那么只要基于函数调用栈就可以实现洋葱模型的执行顺序。如此,我们便可以去掉compose中的Promise.resolve或Promise.reject()

function compose (middleware) {
  return function (context, next) {
    return dispatch(0)
    function dispatch (i) {
      let fn = middleware[i]
      if (!fn) return;
      try {
        return fn(context, dispatch.bind(null, i + 1))
      } catch (err) {
        throw err
      }
    }
  }
}

调用情况如下:

function middleware01(ctx, next) {
  console.log('before middleware 01');
  next();
  console.log('after middleware 01');
}

function middleware02(ctx, next) {
  console.log('before middleware 02');
  next();
  console.log('after middleware 02');
}

function middleware03(ctx, next) {
  console.log('before middleware 03');
  next();
  console.log('after middleware 03');
}

const fn = compose([middleware01, middleware02, middleware03]);
fn();

/*
before middleware 01
before middleware 02
before middleware 03
after middleware 03
after middleware 02
after middleware 01
*/

不过,中间件函数中大概率会存在异步操作,如果想要在存在异步操作的情况下保持执行顺序,那就无法基于函数调用栈了,而要用Promise.then的链式调用方案:

function middleware01(ctx, next) {
  console.log('before middleware 01');
  return next().then(() => console.log('after middleware 01'));
}

function middleware02(ctx, next) {
  console.log('before middleware 02');
  return next().then(() => console.log('after middleware 02'));
}

function middleware03(ctx, next) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('before middleware 03');
      resolve();
    }, 1000);
  })
    .then(next)
    .then(() => console.log('after middleware 03'));
}

const fn = compose([middleware01, middleware02, middleware03]);
fn();

/*
before middleware 01
before middleware 02
before middleware 03
after middleware 03
after middleware 02
after middleware 01
*/

显然,Promise.then的方案可以通过async/await进行优化,代码如下:


async function middleware01(ctx, next) {
  console.log('before middleware 01');
  await next();
  console.log('after middleware 01');
}

async function middleware02(ctx, next) {
  console.log('before middleware 02');
  await next();
  console.log('after middleware 02');
}

async function middleware03(ctx, next) {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('before middleware 03');
      resolve();
    }, 1000);
  });
  await next();
  console.log('after middleware 03');
}

const fn = compose([middleware01, middleware02, middleware03]);
fn();

/*
before middleware 01
before middleware 02
before middleware 03
after middleware 03
after middleware 02
after middleware 01
*/

async/await方案正是Koa推荐我们进行使用的,目的就是为了保持异步操作间的有序执行。

所以,中间件函数的洋葱模型本质就是Promise.then().then()...的链式调用。首先通过主动调用next实现前置逻辑的顺序执行,在通过Promise.then的逐级回溯实现后置逻辑的逆序执行。compose中之所以返回Promise.resolve()或Promise.reject(),目的就是保证所有中间件函数的返回值都是Promise,方便统一处理。

到这里,剩下的两个问题也有了答案:

  1. 这些中间件函数如何实现洋葱模型的执行顺序?是怎么驱动的?

    中间件函数通过Promise.then的链式调用实现洋葱模型,通过dispatch回调的方式驱动执行。

  2. 中间件函数中的next究竟是什么?为什么可以执行下一个中间件函数?

    中间件函数中的next入参可以理解为就是下一个中间件函数,我们调用next()就相当于直接调用了下一个中间件函数

源码分析:context是什么?

明白了中间件注册和执行体系的实现原理之后,我们再简单聊一下中间件函数中传入的context是什么?

我们知道,Node原生的http.createServer((req, res) => {})中分别将请求和响应封装成了req和res对象,我们通过调用对象上的方法获取请求信息和返回响应数据。context就是把这两个对象上的属性和方法封装到一起便于我们使用的这么一个对象,本质上就是一个代理。

Koa首先将req和res分别封装为request和response对象,然后再用context代理访问request和response,这部分的源码不涉及复杂逻辑,所以我们简单的看两眼就行。

request.jsresponse.js中基本上就是各种getter和setter,用来封装reqres对象

// request.js
module.exports = {
  get header() {
    return this.req.headers;
  },
  set header(val) {
    this.req.headers = val;
  },
// ...

// response.js
module.exports = {

  get socket() {
    return this.res.socket;
  },

  get header() {
    const { res } = this;
    return typeof res.getHeaders === 'function'
      ? res.getHeaders()
      : res._headers || {}; // Node < 7.7
  },

  get headers() {
    return this.header;
  },

  get status() {
    return this.res.statusCode;
  },

  set status(code) {
    if (this.headerSent) return;

    assert(Number.isInteger(code), 'status code must be a number');
    assert(code >= 100 && code <= 999, `invalid status code: ${code}`);
    this._explicitStatus = true;
    this.res.statusCode = code;
    if (this.req.httpVersionMajor < 2) this.res.statusMessage = statuses[code];
    if (this.body && statuses.empty[code]) this.body = null;
  },
  // ...

context.js中主要就是将request.jsresponse.js的属性和方法挂载到自己身上。

const delegate = require('delegates');

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .access('status')
  .access('message')
  .getter('headerSent')
  .getter('writable');
 
delegate(proto, 'request')
  .method('accepts')
  .method('get')
  .access('method')
  .access('query')
  .getter('origin')
  .getter('href')

// ... 代码太长了删掉了部分

这里使用delegate实现了委托模式,这个函数的作用是:将对象表层的属性访问委托给深层属性来访问。上述代码中的proto就是context,委托之后访问context.attachment()就等于访问context.response.attachment(),访问context.method就等于访问context.request.method。至于.method,.access,.getter这些方法是用来控制访问权限的, method表示只能调用,getter表示只读,access表示可读可写。

你可能要问,为什么context上可以访问到response和request,实际上是因为application.js源码中存在一个createContext()方法,把这些对象相互挂载了一遍。

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

好了,到这里我们就明白了context是什么了,最后我们回到Koa执行流程的最后一步,看看handleResponse做了什么事情。

源码分析:handleResponse如何返回响应?

之前我们分析handleRequest时说过,中间件函数执行完成后会执行handleResponse返回响应数据。

  handleRequest(ctx, fnMiddleware) {
    // ...
    const handleResponse = () => respond(ctx);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

handleResponse主要调用的是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.response._explicitNullBody) {
      ctx.response.remove('Content-Type');
      ctx.response.remove('Transfer-Encoding');
      return res.end();
    }
    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);
}

简化之后:

function respond(ctx) {

  const res = ctx.res;
  let body = ctx.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);
  res.end(body);
}

我们可以发现,respond中主要就是判断ctx.body的数据类型,然后调用原生的res.end或者res.write方法返回响应数据。其中body.pipe(res)是通过stream.pipe的方式调用res.write方法。

尾声

到此为止,Koa整个执行流程的主要源码实现就分析完毕了,我们大体上可以得出这么几个结论:

  1. Koa框架是对原生Node.http模块的封装
  2. 中间件注册流程就是将函数依次推入数组保存,而执行流程本质上是通过回调依序执行数组中函数,通过Promise.then链式调用实现洋葱模型的执行顺序。
  3. 所谓框架就是要实现一套主流程并暴露相应的接口,比如Koa中的app.use()注册中间件,比如ctx.body=xxx实现数据返回。
  4. 框架设计的关键:一是架构是否清晰可靠,二是接口是否简单易用。

最后,感谢大家看到这里,欢迎多多交流!