Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

koa源码解析 #41

Open
kekobin opened this issue Sep 21, 2019 · 0 comments
Open

koa源码解析 #41

kekobin opened this issue Sep 21, 2019 · 0 comments

Comments

@kekobin
Copy link
Owner

kekobin commented Sep 21, 2019

简介

与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源码解析

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant