超级详细的koa源码解析(看完不会我打你)

·  阅读 4031

如果你想提升node水平,那么我极力推荐你看看koa源码

koa作者是神一般的男人TJ Holowaychuk,源码设计巧妙而又短小精悍,既能领略koa的设计思想,而又避免了源码过冗长而带来的疲劳感。

这篇文章,我们从源头出发,一起领略kao的精髓,同时围绕下面三点介绍:

1.老生常谈的 洋葱模型 2. context的 委托模式 3. koa的 错误处理


目录结构

话不多说,让我们开始吧~首先请跟我一起打开命令行 输入

touch koa_learn.js && npm init && npm i koa
复制代码

然后用任何一个ide打开koa_learn.js,我们看看node_modules下的koa目录结构。

在这里插入图片描述
除了readme、历史信息、开源许可证,真正的源码部分只有四个文件。application、context、request、response。 打开package.json文件,我们可以看到koa的入口。

  "main": "lib/application.js",
复制代码

application.js

好的,让我们开始写一个简单的Demo。

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

app.use(async (ctx, next) => {
  ctx.body = "你好,YouHe";
});

app.listen(3000);
复制代码

然后在终端上

curl http://localhost:3000
//终端上打印出 *你好,YouHe*
复制代码

我们看到,app.js里引入了koa,然后用new来实例化一个app,之后我们使用了app.use传入一个async函数,也就是kao中间件,最后调用app.listen方法,至此一个kao应用就跑起来了。

打开application.js文件,首先看到这里暴露了一个Application类,继承于Emitter(错误处理讲到)。

constructor

module.exports = class Application extends Emitter {

  constructor(options) {
    super();
    options = options || {};  //配置
    this.proxy = options.proxy || false;   //是否proxy模式
    this.subdomainOffset = options.subdomainOffset || 2;  //domain要忽略的偏移量
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'; //proxy自定义头部
    this.maxIpsCount = options.maxIpsCount || 0;  //代理服务器数量
    this.env = options.env || process.env.NODE_ENV || 'development';  //环境变量
    if (options.keys) this.keys = options.keys;   // 自定义cookie 密钥
    this.middleware = [];  //中间件数组
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    
    if (util.inspect.custom) {   //自定义检查,这里的作用是get app时,去执行this.inspect 。感兴趣可见http://nodejs.cn/api/util.html#util_util_inspect_custom
      this[util.inspect.custom] = this.inspect;
    }
  }
...
复制代码

这里是constructor里做了一些配置,这里主要是

    this.middleware = [];  //中间件数组
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
复制代码

首先是一个中间件数组,我们用use使用的中间件都会放进这个数组中,然后分别用Object.create拷贝的context、request、response,分别对应koa目录的三个文件。这里用Object.create是因为我们在同一个应用中可能会有多个new Koa的app,为了防止这些app相互污染,用拷贝的方法让其引用不指向同一个地址。

app.use

在这里插入图片描述

这里首先判断use传进来的参数是不是一个函数,然后判断函数是否为generator函数,并将其转化为generator,这里用到了convert函数,本质上其实是用到co去把generator转成了一个返回promise的function(说着很绕口,可以理解转成类似async函数)

  const converted = function (ctx, next) {
    return co.call(ctx, mw.call(ctx, createGenerator(next)))
  }
复制代码

其实除了这个个人觉得很鸡肋的DEBUG,(感兴趣试试命令行输入 DEBUG=koa* node app.js),重点在于这个 this.middleware.push(fn);,它做的只是把中间件放进了middleware数组。

app.listen

之后我们就用到listen方法

  listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    //从http.createServer我们猜测,this.callback()返回的是一个 (req,res)=> something 的函数
    return server.listen(...args);
  }
复制代码

这个方法是封装了http模块提供的http.createServer和listen方法,将this.callback()传入,我们看一下这个callback

callback() {
    const fn = compose(this.middleware);
	//上面的compose就是koa中间件洋葱模型的核心了
    if (!this.listenerCount('error')) this.on('error', this.onerror);//koa错误处理,判断app上错误监听的数量,也就是判断是否我们的代码里有自己写监听,如果没有那么走koa的 this.onerror方法
    const handleRequest = (req, res) => { //koa的委托模式会在这个函数里体现
      const ctx = this.createContext(req, res);  //将req, res包装成一个ctx返回
      return this.handleRequest(ctx, fn); //怎么又返回了自己? 别急,注意这个this,它代表的是app上的handleRequest方法,而不是它自己
    };

    return handleRequest;  
  }
复制代码

现在我们知道koa的大概的运转了,那么现在我们就从这几个方法入手。

compose、this.createContext、this.createContext


洋葱模型

找到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) {//过一遍middleware,判断每个成员是否为函数
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  return function (context, next) {  //返回一个函数
    let index = -1  //index计数
    return dispatch(0)//调用dispatch,传入0
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times')) //i小于index,证明在中间件内调用了不止一次的next(),抛出错误
      index = i //更新index的值
      let fn = middleware[i] //middleware中的函数,从第1个开始
      if (i === middleware.length) fn = next//如果i走到最后一个的后面,就让fn为next,此时fn为undefined
      if (!fn) return Promise.resolve() //那么这时候就直接resolve
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));//将其包装成一个Promise resolve态,主要作用是区分reject
      } catch (err) {
        return Promise.reject(err)//catch错误,并reject
      }
    }
  }
}

复制代码

不知道上面的注释看明白没有,那么这里重新过一遍流程。 首先直接返回一个函数,函数内部返回一个dispatch函数,dispatch调用并传入0,现在我们fn取到了middleware的第一个中间件,然后返回被Promise包裹的、fn的执行结果。fn在调用时传入两个参数,

  • context 也就是中间件函数里的ctx
  • dispatch.bind(null, i + 1) 下一个中间件函数,用bind把this指向null,也就是中间件函数里的next

所以,调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身,这样代码形成回形针式的级联。这也就是老生常谈的洋葱模型。 在中间件自身,我们使用async函数,这样可以让异步转同步更方便,但是,async并不是koa洋葱模型的必要条件,面试的时候注意哦~

但是,如果开发者在一个中间件多次调用next。koa怎么处理呢。首先,声明一个计数器为-1,在每个中间件内部判断这个index是否小于等于现在中间件的i(也就是在middleware的index),然后将更新这个index为i。 这时如果多次调用next,i就会大于现在的index,抛出错误。

上面的this.callback里的fn便是koa-compose返回的这个函数。介绍完了,我们来看下this.createContext吧。

this.createContext

    const handleRequest = (req, res) => { //验证了上面的(req,res)=>sth
      const ctx = this.createContext(req, res);  //将req, res包装成一个ctx返回
      return this.handleRequest(ctx, fn); 
    };
复制代码

这里传入了http模块提供的两个参数req、res,然后声明ctx为this.createContext(req, res)的返回值。看下这个this.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。

刚才在上面的constructor里我们声明了

  this.context = Object.create(context);
  this.request = Object.create(request);
  this.response = Object.create(response);
复制代码

在这里,我们用Object.create又包装了一层

  const context = Object.create(this.context); //全局唯一
  const request = context.request = Object.create(this.request);
  const response = context.response = Object.create(this.response);
复制代码

目的就是让每次http请求都生成一个context,并且单次生成的context是全局唯一的,相互之间隔离。同样的,Object.create(this.request|response)也是同理。我们将Object.create(this.request|response)赋值给context.request|response,这样我们可以在context上访问到request和response。 这里我们其实就是做了让response、this.request、context,可以共享app、res、req这些属性,并且可以互相访问。为什么要这么做呢?

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

除此之外,我个人认为,这点的好处其中之一就是可以让一个完全不懂http req、res的人直接上手一个简单的web应用。 看完了createContext,回到this.handleRequest,接下来的是 return this.handleRequest(ctx, fn);

这就是callback流程的最后一步,http.createServer传入的函数被调用时,handleRequest被调用,然后内部调用this.handleRequest,传入包装好的ctx和中间件函数。

  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);
  }
复制代码

这里简单判断了下statusCode是否404,onFinished处理res为stream时情况。 最后调用fnMiddleware函数,传入ctx,resove则进入respond,reject则进入ctx.onerror,最终返回结果。

  • respond这个方法里内容稍微有点长,它做的是ctx返回不同情况的处理,如method为head时加上content-length字段、body为空时去除content-length等字段,返回相应状态码、body为Stream时使用pipe等,这里不再赘述。

  • ctx.onerror我们会在下面context.js里提到。

这样一个大概流程便走完了,我们只看了application,还有一下几个点没有看 context.js 、 request.js 、 response.js.

直接用到这三个包的是在constructor里,

    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
复制代码

现在我们去看看它们都做了什么。

上半部分,方法有toJson、inspect、throw、onerror、get cookies()、set cookies方法,下面依次介绍它们的作用

  1. toJson 同context.js一样(上面没有提到,但是context.js里也有这个方法) , 调用only这个包,返回
  return {
      request: this.request.toJSON(),
      response: this.response.toJSON(),
      app: this.app.toJSON(),
      originalUrl: this.originalUrl,
      req: '<original node req>',
      res: '<original node res>',
      socket: '<original node socket>'
    };
复制代码
  1. inspect 调用toJson()
  2. throw 抛出错误,将穿进的参数付给createError()包返回的函数并调用。
  3. onerror ctx的错误处理
  4. get cookies() 获取cookie,如果有直接返回,如果无立刻生成
    if (!this[COOKIES]) {
      this[COOKIES] = new Cookies(this.req, this.res, {
        keys: this.app.keys,
        secure: this.request.secure
      });
    }
    return this[COOKIES];
复制代码
  1. set cookies 设置cookie

错误处理

我们重点看下onerror

onerror(err) {
    if (null == err) return;

    const isNativeError =  //判断是否为原生错误
      Object.prototype.toString.call(err) === '[object Error]' ||
      err instanceof Error;
    if (!isNativeError) err = new Error(util.format('non-error thrown: %j', err));

    let headerSent = false; 
    if (this.headerSent || !this.writable) {  //检查是否已经发送了一个响应头
      headerSent = err.headerSent = true;
    }

    this.app.emit('error', err, this);  //emit 这个错误,而刚刚我们看到application上有监听器。app.on('error',onerror) ,转交给application的onerror处理。这里可以做到emit、on来发布订阅错误就是因为application继承了Emitter模块。这里不熟悉的可以看下我的另一篇文章,见文章末尾

    if (headerSent) {//已经发送了一个响应头,return掉
      return;
    }

    const { res } = this;

    if (typeof res.getHeaderNames === 'function') {//HeaderNames为function,删除所有Header
      res.getHeaderNames().forEach(name => res.removeHeader(name));
    } else {
      res._headers = {}; // Node < 7.7
    }
  //下面的就是对这个错误的ctx进行修改。如header设置成err.headers、statusCode设置成err.status ,msg设置为如err.message等等
    this.set(err.headers);

    this.type = 'text';

    let statusCode = err.status || err.statusCode;

    // ENOENT support
    if ('ENOENT' === err.code) statusCode = 404;

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

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

这里主要是处理了发生error时ctx的情况,和把err emit给application进行处理。

看下application是怎么处理这个错误的。

  onerror(err) {
    // When dealing with cross-globals a normal `instanceof` check doesn't work properly.
    // See https://github.com/koajs/koa/issues/1466
    // We can probably remove it once jest fixes https://github.com/facebook/jest/issues/2549.
    const isNativeError =
      Object.prototype.toString.call(err) === '[object Error]' ||
      err instanceof Error;
    if (!isNativeError) 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(`\n${msg.replace(/^/gm, '  ')}\n`);
  }
};
复制代码

其实这里可以说只是把error打印了出来。但是对于中间件内的异步错误,koa是无法捕捉的(除非转同步)。我们的应用如果需要记录这个错误可以用node的process监听

process.on("unhandledRejection", (err) => {
  console.log(err);
});
复制代码

委托模式

我们来看context的下半部分。

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');
复制代码

可以看到这样的代码,用了一个delegate函数。我们来看看它的源码。我们主要用到了access、getter、method

Delegator.prototype.method = function(name){
  var proto = this.proto;
  var target = this.target;
  this.methods.push(name);
  proto[name] = function(){
    return this[target][name].apply(this[target], arguments);
  };
  return this;
};
复制代码

作用很明显,target.name包装一层函数赋值给proto.name,也就是将target上的函数也能让proto去调用。

Delegator.prototype.getter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.getters.push(name);
  proto.__defineGetter__(name, function(){
    return this[target][name];
  });
  return this;
};
复制代码

通过__defineGetter__劫持proto的 get,转而去访问 target。(目前官方建议使用Object.defineProroty或Proxy进行劫持)

Delegator.prototype.access = function(name){
  return this.getter(name).setter(name);
};
复制代码

劫持get和set,set与上面同理,不再赘述。

我们在使用诸如ctx.body时,这里的context将它们委托给request和respense。

我们看下request.js & respense.js,(代码较长,不放在文章上,请自行看源码) 这里所做的,其实就是将原生req、res做了一层封装。我们在需要时调用request.js而间接调用了req。(res同理) 打个比方,我们在访问ctx.header时,ctx会将其委托给reques.headert,而request又会将其委托给req.headers,最终我们拿到了header值。

Koa使用委托模式,把外层暴露的自身对象将请求委托给内部的node原生对象进行处理。 委托模式使得我们可以用 聚合 来替代 继承。


末尾

至此,本文就介绍koa源码到这里。欢迎学习交流~😁

发布订阅模式

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改