欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

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

程序员文章站 2022-03-22 10:06:13
如果你想提升node水平,那么我极力推荐你看看koa源码。koa作者是神一般的男人TJ Holowaychuk,源码设计巧妙而又短小精悍,既能领略koa的设计思想,而又避免了源码过冗长而带来的疲劳感。这篇文章,我们从源头出发,一起领略kao的精髓,同时围绕下面三点介绍:1.老生常谈的 洋葱模型2. context的 委托模式3. koa的 错误处理目录结构话不多说,让我们开始吧~首先请跟我一起打开命令行 输入touch koa_learn.js && npm init &am...

如果你想提升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目录结构。
荐
                                                        超级详细的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

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

这里首先判断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源码到这里。欢迎学习交流~