koa源码学习

2,078 阅读8分钟

曾经看过很多源码,但是却没有本着刨根问底的精神,遇到不懂的问题总是轻易的放过。我知道掘金是个大神云集的地方,希望把自己的学习过程记录下来,一方面督促自己,一方面也是为了能和大家一起学习,分享自己学习的心得。

koa文件结构

├── application.js

├── context.js

├── request.js

└── response.js

koa一共只有四个文件,所以学习起来并不困难,稍微用一点时间就可以看完。从名称上就可以看出各个文件的功能。分别是请求,响应,上下文,应用四个文件。

request.js

reuest.js是请求的封装,包含发请求相关的一系列操作。

~的运用

判断请求是否幂等。

get idempotent() {
    const methods = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'];
    return !!~methods.indexOf(this.method);
  }

首先解释下幂等概念,幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。在http中,是指无论调用这个url多少次,都不会有不同的结果的HTTP方法。这一部分如果有不理解的地方,可以看看这篇文章HTTP请求方法及幂等性探究
比较好玩的地方是!!~methods.indexOf()。
!!的作用是转换为布尔值,~的作用是按位取反。举个例子js中-1的原码为10..001(62个0),所以补码为111..11(64)个1,按位取反后得到0。有这样一个规律,整数按位取反的结果等于-(x+1)。比较有意思的是 ~NaN === -1, ~Infinity === -1。

~~的运用

获取content-length的长度,return Number。

get length() {
    const len = this.get('Content-Length');
    if (len == '') return;
    return ~~len;
  }

由y=-(x+1),可以推出y==~~x。所以整数情况下,结果并不会发生改变。~~的参数不为整数时,会向下取整。当参数为NaN,或Infinity,以及非number类型时,都会返回0。这样可以保证,返回的输出的安全性。

X-Forwarded-For字段

相关代码如下:

get ips() {
   const proxy = this.app.proxy;
   const val = this.get('X-Forwarded-For');
   return proxy && val
     ? val.split(/\s*,\s*/)
     : [];
 }

这一部分的作用是获得真实的用户ip。X-Forwarded-For:简称XFF头,它代表客户端,也就是HTTP的请求端真实的IP。如果一个 HTTP 请求到达服务器之前,经过了三个代理 Proxy1、Proxy2、Proxy3,IP 分别为 IP1、IP2、IP3,用户真实 IP 为 IP0,那么按照 XFF 标准,服务端最终会收到以下信息: X-Forwarded-For: IP0,IP1,IP2。IP3不在这个列表中,因为IP3会通过Remote Address 字段获得。

response.js

response.js是对原生req进行的封装。

Content-Disposition属性

attachment(filename) {
    if (filename) this.type = extname(filename);
    this.set('Content-Disposition', contentDisposition(filename));
  },

其中extname是node的原生方法,获得文件的扩展名。主要需要搞清楚的是Content-Disposition字段。

    在常规的HTTP应答中,Content-Disposition 消息头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。 此时的第一个参数与可选值有inline,或者attachment。inline时,文件会以页面的一部分或者整体展现,而attachment则会弹出下载提示。

    在multipart/form-data类型的应答消息体中, Content-Disposition消息头可以被用在multipart消息体的子部分中,用来给出其对应字段的相关信息。第一个参数固定为form-data。详细文档可以参考MDN

etag字段

set etag(val) {
    if (!/^(W\/)?"/.test(val)) val = `"${val}"`;
    this.set('ETag', val);
  }

    etag是资源的指纹,用来标识资源是否更改。和etag相比较的是If-Match,和If-None-Match响应首部。有关响应首部有不理解的朋友可以看看http条件请求
    当首部是If-Match时,在请求方法为 GET 和 HEAD 的情况下,服务器仅在请求的资源满足此首部列出的 ETag 之一时才会返回资源。而对于 PUT 或其他非安全方法来说,只有在满足条件的情况下才可以将资源上传。
    当响应首部是If-None-Match时,对于GET 和 HEAD 请求方法来说,当且仅当服务器上没有任何资源的 ETag 属性值与这个首部中列出的相匹配的时候,服务器端会才返回所请求的资源,响应码为 200。对于get和head不对服务器状态发生改变的方法,如果相匹配返回304,其他的返回则返回 412。

Vary字段

vary(field) {
    vary(this.res, field);
  }

这里主要讲一下vary的作用。

http中有一个内容协商机制,为同一个URL指向的资源提供不同的展现形式。比如文档的自然语言,编码形式,以及压缩算法等等。这种协商机制可以分为两种形式展现:

  • 客户端设置特定的 HTTP 首部 (又称为服务端驱动型内容协商机制或者主动协商机制);这是进行内容协商的标准方式;
  • 服务器返回 300 (Multiple Choices) 或者 406 (Not Acceptable) HTTP 状态码 (又称为代理驱动型协商机制或者响应式协商机制);这种方式一般用作备选方案。

vary字段就是标志服务器在服务端驱动型内容协商阶段所使用的首部清单,他可以通知缓存服务器决策的依据。常见的首部清单有Accept,Accept-Language,Accept-Charset,Accept-Encoding,User-Agent等。

content-length计算

get length() {
    const len = this.header['content-length'];
    const body = this.body;

    if (null == len) {
      if (!body) return;
      if ('string' == typeof body) return Buffer.byteLength(body);
      if (Buffer.isBuffer(body)) return body.length;
      if (isJSON(body)) return Buffer.byteLength(JSON.stringify(body));
      return;
    }

    return ~~len;
  }

Buffer.byteLength方法返回字符串实际占据的字节长度,默认编码方式为utf8。即使对于string类型,也没有使用String.length来直接获取,因为String.length获取到的是字符的长度,而不是字节长度。比如汉字,utf8编码一个字符就要占三个字节。

context.js

ctx是我们日常开发中最常用到的属性,比如ctx.req,ctx.res,ctx.response,ctx.request。以及开发中间件时的各种操作,都是在ctx上完成的。

context原型上有inspect,toJson,assert,throw,和onerror五个方法。剩下的就是response和request的代理。这里用了一个比较有意思的库delegates。写起来就像这样

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

这里使用链式操作,看起来非常简单明了。 delegates中的getter和setter使用的是Object.prototype.defineGetter()和Object.prototype.defineSetter()方法。以setter举例:

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;
};

当我们为proto的某一属性赋值时,其实还是调用target的set访问器,这里仅仅是一个代理。

application.js

Application继承于Emmiter类,包含request,response,context,subdomainOffset,proxy,middleware,subdomainOffset,env等属性。
listen方法实际调用了http.createServer(app.callback()).listen()。所以koa中最重要的就是callback函数的实现。

callback() {
    const fn = compose(this.middleware);
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };
    return handleRequest;
  }

首先将middleware转化为function,并构建ctx对象,随后调用this.handleRequest传入ctx,fn,处理请求。

this.handleRequest函数主干如下所示:

handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => respond(ctx);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

fnMiddleware由compose函数得来,compose函数实现为下:

function compose (middleware) {
    return function (context, next) {
        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, function next () {
              return dispatch(i + 1)
            }))
          } catch (err) {
            return Promise.reject(err)
          }
        }
    }
  }

调用compose,返回一个(context,next)=>{}的函数,也就是this.handleRequest中的fnMiddleware。当执行fnMiddleware时,返回dispatch(0)。执行dispatch时,返回一个Promise,当Promise完成时,调用dispatch(1),以此类推,直到i === middleware.length时,fn = next,因为在this.handleRequest调用时,next并没有传,所以,此时fn === undefined, return Promise.resolve();到这里compose的逻辑算是理清了。我们在来看一下中间件是怎么书写的,举一个简单的例子:

const one = (ctx, next) => {
  console.log('>> one');
  next();
  console.log('<< one');
}

const two = (ctx, next) => {
  console.log('>> two');
  next(); 
  console.log('<< two');
}

app.use(one);
app.use(two);

执行dispatch(0)时,返回

return Promise.resolve(one(context, function next () {
  return dispatch(1)
}))

当执行到one函数的next函数时,此时return到dispatch(1)。此时dispatch(1)执行,当执行到two的next时,返回dispatch(2)。因为2 === middleware.length,又因为fn == undefined,固此时return Promise.resolve()。当two的next方法执行完毕,继续执行console.log('<< two')。当two的函数全部执行完毕后,程序回到one的next()结束部分,继续执行console.log('<< one')。async await异步函数执行同理。

这一块的逻辑确实难于理解,可以打断点调试下看看结果。

总结一下:学习不光要多看,还要多写,还要多实践,这样才能真正理解,并有所收获!