深入koa2源码

深入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的返回结果。
env node的执行环境, 默认是development
context 中间件第一个实参ctx的原型, 具体在讲context.js时会说到。
request ctx.request的原型,定义在request.js中。
response ctx.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
origin protocol://host
href 请求的完整url
method 原生req对象的method
path 请求urlpathname
query 请求urlquery,对象形式
queryString 请求urlquery,字符串形式
search ?queryString
hostname hostname
URL 完整的URL对象
fresh 判断缓存是否新鲜,只针对HEADGET方法,其余请求方法均返回false
stale fresh取反
idempotent 检查请求是否幂等,符合幂等性的请求有GET, HEAD, PUT, DELETE, OPTIONS, TRACE6个方法
socket 原生req对象的套接字
charset 请求字符集
type 获取请求头的Content-Type 不含参数 charset
length 请求的 Content-Length
secure 判断是不是https请求
ips X-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
lastModified Last-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);
}
复制代码
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/101218.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号