深入koa2源码

koa是当下非常流行的node框架,相比笨重的expresskoa只专注于中间件模型的建立,以及请求和响应控制权的转移。本文将以koa2为例,深入源码分析框架的实现细节。 koa2的源码位于lib目录,结构非常简单和清晰,只有四个文件,如下:

根据package.json中的main字段,可以知道入口文件是lib/application.js,application.js定义了koa的构造函数以及实例拥有的方法,如下图:

构造函数

首先看一下构造函数的代码

 constructor() {
    super();
    this.proxy = false;
    this.middleware = [];
    this.subdomainOffset = 2;
    this.env = process.env.NODE_ENV || 'development';
    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;
    }
  }
复制代码

这里定义了实例的8个属性,各自的含义如下:

属性含义
proxy表示是否开启代理,默认为false,如果开启代理,对于获取request请求中的hostprotocolip分别优先从Header字段中的X-Forwarded-HostX-Forwarded-ProtoX-Forwarded-For获取。
middleware最重要的一个属性,存放所有的中间件,存放和执行的过程后文细说。
subdomainOffset子域名的偏移量,默认值为2,这个参数决定了request.subdomains的返回结果。
envnode的执行环境, 默认是development
context中间件第一个实参ctx的原型, 具体在讲context.js时会说到。
requestctx.request的原型,定义在request.js中。
responsectx.response的原型,定义在response.js中。
[util.inspect.custom]util.inspect这个方法用于将对象转换为字符串, 在node v6.6.0及以上版本中util.inspect.custom是一个Symbol类型的值,通过定义对象的[util.inspect.custom]属性为一个函数,可以覆盖util.inspect的默认行为。
use()

use方法很简单,接受一个函数作为参数,并加入middleware数组。由于koa最开始支持使用generator函数作为中间件使用,但将在3.x的版本中放弃这项支持,因此koa2中对于使用generator函数作为中间件的行为给与未来将被废弃的警告,但会将generator函数转化为async函数。返回this便于链式调用。

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

下面是listen方法,可以看到内部是通过原生的http模块创建服务器并监听的,请求的回调函数是callback函数的返回值。

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

下面是callback的代码,compose函数将中间件数组转换成执行链函数fncompose的实现是重点,下文会分析。koa继承自Emitter,因此可以通过listenerCount属性判断监听了多少个error事件, 如果外部没有进行监听,框架将自动监听一个error事件。callback函数返回一个handleRequest函数,因此真正的请求处理回调函数是handleRequest。在handleRequest函数内部,通过createContext创建了上下文ctx,并交给koa实例的handleRequest方法去处理回调逻辑。

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

上面是createContext的代码, 从这里我们可以知道,通过ctx.reqctx.res可以访问到node原生的请求对象和响应对象, 通过修改ctx.state可以让中间件共享状态。可以用一张图描述这个函数中定义的关系,如下:

接下来我们分析细节,this.contextthis.requestthis.response分别通过contextrequestresponse三个对象的原型创建, 我们先看一下request的定义,它位于request.js文件中。

request.js

request.js定义了ctx.request的原型对象的原型对象,因此该对象的任意属性都可以通过ctx.request获取。这个对象一共有20多个属性和若干方法。其中属性多数都定义了getset方法,截取一小部分代码如下:

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

复制代码

上面代码中定义了header属性,根据前面的关系图可知,this.req指向的是原生的req,因此ctx.request.header等于原生reqheaders属性,修改ctx.request.header就是修改reqheadersrequest对象中所有的属性和方法列举如下:

属性/方法含义
header原生req对象的headers
headers原生req对象的headers, 同上
url原生req对象的url
originprotocol://host
href请求的完整url
method原生req对象的method
path请求urlpathname
query请求urlquery,对象形式
queryString请求urlquery,字符串形式
search?queryString
hostnamehostname
URL完整的URL对象
fresh判断缓存是否新鲜,只针对HEADGET方法,其余请求方法均返回false
stalefresh取反
idempotent检查请求是否幂等,符合幂等性的请求有GET, HEAD, PUT, DELETE, OPTIONS, TRACE6个方法
socket原生req对象的套接字
charset请求字符集
type获取请求头的Content-Type 不含参数 charset
length请求的 Content-Length
secure判断是不是https请求
ipsX-Forwarded-For 存在并且 app.proxy 被启用时,这些 ips的数组被返回,从上游到下游排序。 禁用时返回一个空数组。
ip请求远程地址。 当 app.proxytrue 时支持 X-Forwarded-Proto
protocol返回请求协议,httpshttp。当 app.proxytrue 时支持 X-Forwarded-Proto
host获取当前主机(hostname:port)。当 app.proxytrue 时支持 X-Forwarded-Host,否则使用Host
subdomains根据app.subdomainOffset设置的偏移量,将子域返回为数组
get(...args)获取请求头字段
accepts(...args)检查给定的 type(s) 是否可以接受,如果 true,返回最佳匹配,否则为 false
acceptsEncodings(...args)检查 encodings 是否可以接受,返回最佳匹配为 true,否则为 false
acceptsCharsets(...args)检查 charsets 是否可以接受,在 true 时返回最佳匹配,否则为 false
acceptsLanguages(...args)检查 langs 是否可以接受,如果为 true,返回最佳匹配,否则为 false
[util.inspect.custom]自定义的util.inspect
response.js

response.js定义了ctx.response的原型对象的原型对象,因此该对象的任意属性都可以通过ctx.response获取。和request类似,response的属性多数也定义了getset方法。response的属性和方法如下:

属性/方法含义
header原生res对象的headers
headers原生res对象的headers, 同上
status响应状态码, 原生res对象的statusCode
message响应的状态消息. 默认情况下, response.messageresponse.status 关联
socket套接字,原生res对象的socket
type获取响应头的 Content-Type 不含参数 charset
body响应体,支持stringbufferstreamjson
lastModifiedLast-Modified 标头返回为 Date, 如果存在
etag响应头的ETag
length数字返回响应的 Content-Length,使用Buffer.byteLengthbody进行计算
headerSent检查是否已经发送了一个响应头, 用于查看客户端是否可能会收到错误通知
vary(field)field 上变化。
redirect(url, alt)执行重定向
attachment(filename, options)Content-Disposition 设置为 “附件” 以指示客户端提示下载。(可选)指定下载的 filename
get(field)返回指定的响应头部
set(field, val)设置响应头部
is(type)响应类型是否是所提供的类型之一
append(field, val)设置规范之外的响应头
remove(field)删除指定的响应头
flushHeaders()刷新所有响应头
writable()判断响应是否可写,原生res对象的finishedtrue,则返回false, 否则判断原生res对象是否建立套接字socket, 如果没有返回false, 有则返回socket.writable

requestresponse中每个属性getset的定义以及方法的实现多数比较简单直观,如果对每个进行单独分析会导致篇幅过长,而且这些不是理解koa运行机制的核心所在,因此本文只罗列属性和方法的用途,这些大部分也可以在koa的官方文档中找到。关心细节的朋友可以直接阅读request.jsresponse.js这两个文件,如果你熟悉http协议,相信这些代码对你并没有障碍。接下来我们的重点是context.js

context.js

context.js定义了ctx的原型对象的原型对象, 因此这个对象中所有属性都可以通过ctx访问到。context.js中除了定义[util.inspect.custom]这个不是很重要的属性外,只直接定义了一个属性cookies,也定义了几个方法,这里分别进行介绍:

cookies
  get cookies() {
    if (!this[COOKIES]) {
      this[COOKIES] = new Cookies(this.req, this.res, {
        keys: this.app.keys,
        secure: this.request.secure
      });
    }
    return this[COOKIES];
  },

  set cookies(_cookies) {
    this[COOKIES] = _cookies;
  }
复制代码

上面的代码中定义了cookies属性的setget方法。set方法很简单,COOKIES是一个Symbol类型的私有变量。需要注意的是我们一般不通过ctx.cookies来直接设置cookies,官方文档推荐使用ctx.cookies.set(name, value, options)来设置,可是这里并没有cookies.set呀,其实这里稍微一看就明白,cookies的值是this[COOKIES],它是Cookies的一个实例,在Cookie这个npm包中是定义了实例的getset方法的。

throw()
 throw(...args) {
    throw createError(...args);
  },
复制代码

当我们调用ctx.throw抛出一个错误时,内部是抛出了一个有状态码和信息的错误,createError的实现在http-errors这个npm包中。

onerror()

下面是onerror方法的代码,发生错误时首先会触发koa实例上的error事件来打印一个错误日志, headerSent变量表示响应头是否发送,如果响应头已经发送,或者响应处于不可写状态,将无法在响应中添加错误信息,直接退出该函数,否则需要将之前写入的响应头部信息清空。

onerror(err) {
    // 没有错误时什么也不做
    if (null == err) return;
    // err不是Error实例时,使用err创建一个Error实例
    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));

    let headerSent = false;
    // 如果res不可写或者请求头已发出
    if (this.headerSent || !this.writable) {
      headerSent = err.headerSent = true;
    }

    // 触发koa实例app的error事件
    this.app.emit('error', err, this);

    if (headerSent) {
      return;
    }

    const { res } = this;

    // 移除所有设置过的响应头
    if (typeof res.getHeaderNames === 'function') {
      res.getHeaderNames().forEach(name => res.removeHeader(name));
    } else {
      res._headers = {}; // Node < 7.7
    }

    // 设置错误头部
    this.set(err.headers);

    // 设置错误时的Content-Type
    this.type = 'text';

    // 找不到文件错误码设为404
    if ('ENOENT' == err.code) err.status = 404;

    // 不能被识别的错误将错误码设为500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    // 设置错误码
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    // 结束响应
    res.end(msg);
  },
复制代码

从上面代码中会有疑问, this.setthis.type等是哪里来的?context并没有定义这些属性。我们知道, ctx中其实是代理了很多responseresquest的属性和方法的,this.setthis.type其实就是response.setresponse.type。那么koa中对象属性和方法的代理是如何实现的呢,答案是delegate,context中代码的最后就是使用delegate来代理一些本来只存在于requestresponse上的属性。接下来我们看一下delegete是如何实现代理的,delegete的实现代码在delegetes这个npm包中。

delegate

delegate方法本质上是一个构造函数,接受两个参数,第一个参数是代理对象,第二个参数是被代理的对象,下面是它的定义, Delegator就是delegate。可以看到,不管是否使用new关键字,该函数总是会返回一个实例。

function Delegator(proto, target) {
  if (!(this instanceof Delegator)) return new Delegator(proto, target);
  this.proto = proto;
  this.target = target;
  this.methods = [];
  this.getters = [];
  this.setters = [];
  this.fluents = [];
}
复制代码

此外,在Delegator构造函数的原型上,定义了几个方法,koa中用到了Delegator.prototype.methodDelegator.prototype.accsess以及Delegator.prototype.getter,这些都是代理方法, 分别代理setget方法。下面是代码,其中getset方法的代理主要使用了对象的__defineGetter__以及__defineSetter__方法。

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;
};
Delegator.prototype.access = function(name){
  return this.getter(name).setter(name);
};
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;
};
Delegator.prototype.setter = function(name){
  var proto = this.proto;
  var target = this.target;
  this.setters.push(name);

  proto.__defineSetter__(name, function(val){
    return this[target][name] = val;
  });

  return this;
};
复制代码

到这里,关于requestresponsecontext就聊的差不多了,接下来回到callback继续我们的重点,前面说到的compose才是koa的精华和核心所在,他的代码在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!')
  }

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

函数接收一个middleware数组为参数,返回一个函数,给函数传入ctx时第一个中间件将自动执行,以后的中间件只有在手动调用next,即dispatch时才会执行。另外从代码中可以看出,中间件的执行是异步的,并且中间件执行完毕后返回的是一个Promise,每个dispatch的返回值也是一个Promise,因此我们的中间件中可以方便地使用async函数进行定义,内部使用await next()调用“下游”,然后控制流回“上游”,这是更准确也更友好的中间件模型。从下面的代码可以看到,中间件顺利执行完毕后将执行respond函数,失败后将执行ctxonerror函数。onFinished(res, onerror)这段代码是对响应处理过程中的错误监听,即handleResponse发生的错误或自定义的响应处理中发生的错误。

  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

respondkoa内置的响应自动处理函数,代码如下,它主要功能是判断ctx.body的类型,然后自动完成最后的响应。另外,如果在koa中需要自行处理响应,可以设置ctx.respond = false,这样内置的respond就会被忽略。

function respond(ctx) {
  // allow bypassing koa
  if (false === ctx.respond) return;

  const res = ctx.res;
  if (!ctx.writable) return;

  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 && isJSON(body)) {
      ctx.length = Buffer.byteLength(JSON.stringify(body));
    }
    return res.end();
  }

  // status body
  if (null == body) {
    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);
}
复制代码
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值