koa@2.5.0源代码解读

1,105 阅读13分钟

koa简介

koa是由Express原班人马开发的一个nodejs服务器框架。koa使用了ES2017的新标准:async function来实现了真正意义上的中间件(middleware)。koa的源代码极其简单,但是借由其强大的中间件扩展能力,使得koa成为了一个极其强大的服务器框架。借助中间件,你可以做任何nodejs能做到的事儿。

一些繁琐的交代

本文并不会像其他的代码分析那样贴段代码加注释,因此需要你自己打开koa@2.5.0的源代码一起阅读。

koa目前已经更新到了2.x版本,1.x以前的版本相对于koa@2.x已经不再兼容。本文针对的是koa@2.5.0进行的代码分析。

此外,koa的源代码里面涉及部分http协议的内容,这部分内容本文不会过分强调,默认读者已经掌握了基本的知识。

另外,用于koa2是用了ES2017新特性编写的,因此你需要了解一些ES2017的新语法才行。

为了使得这篇文章简单,我有意地忽略了错误处理,参数判断之类。

本文你还可以在这里找到。

$1.查看package.json

对于nodejs甚至是JavaScript项目,第一件事儿就是看看它的package.jsonpackage.json里面可以找到不少有用的信息。

我们打开koa@2.5.0的目录,发现它依赖了不少的库。其实这些库大多都十分简单,koa的编写原则其实就是把功能分割到其他的库中去。我们暂且先不管这些依赖。

我们找到main字段,这里就是‘通往新世界的大门’了。顺着main打开lib/application.js

$2.分析application.js

好家伙,一上来就是一大串的引入,这可不是什么好东西,咱么先不看这些东西。先看下面的代码。

首先是定义了一个application的类,接着在构造函数中定义了一些变量。我们主要关注以下几个变量,因为他们的用处最大:

    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);

Object.create是用来克隆对象的,这里克隆了三个对象,也是koa最重要的三个对象requestresponsecontext。这三个对象几乎就是koa的全部内容了。待会儿会逐一分析。

我们接着往下看,listen函数大家都很熟悉了,就是用来监听端口的。koalisten函数也很简单。

    const server = http.createServer(this.callback());
    return server.listen(...args);

短短两行,对于nodejs不熟的同学,建议在这里就打住了。其中this.callback()是个什么玩意儿呢?它返回一个函数,这个函数接收两个参数requestresponse,也就是createServer的回调函数,在中间件原理章节会更详细介绍。

接着就是toJSSON这个方法。JSON.stringify调用的方法,目的是当你JSON化一个application实例时,返回指定的属性,而并非所有。这个用处不大,几乎用不到。

inspect也就是调用了toJSON这个方法而已。

接着就是use函数了。use函数本身不是很复杂,但是use函数作为中间件的接口,背后的中间件却有点儿复杂。为此,本文在后面专门解读了中间件相关的源代码,这里暂时跳过。

callbackhandleRequestrespond这几个方法涉及中间件的,因此放到中间件的章节讲。

createContext这个方法是用来封装context的。这个context就是你在使用koause方法,你传递的回调函数的第一个ctx参数。createContext执行的最重要的操作就是把context.request设置成了Request,把context.response设置成了Response。以及把Response.resh和Request.req分别设置成了原生的responserequest

为什么这样说,这个就得追到context.jsrequest.js以及response.js的代码里面了,先等等。

值得强调的是,这里的RequestResponse并不是nodejs里面的,而是koa封装过后的。为了区分原生的和koa封装好的,我把RequestResponse称为封装过后的,requestresponse称为原生的。你需要记住的是context.res是指原生的response,而context.response则是封装后的ResponseRequest以此类推。

封装的东西看起来并没有什么高大上,无非是把常用的一些方法给简化了。就像jquery简化了jsdom的操作一样。

$3.分析context.js

打开context.js,代码不多,但是含金量挺高的。首先是把proto赋值成一个对象,这个对象也是模块的导出值。

inspecttoJSON功能和application.js里面一样,不做过多介绍了。

接着看到个assert,这个和nodejs里面的assert其实是差不多,它其实是提供了一些断言的操作。比如equalnotEqualstrictEqual之类的。比较有意思的是,assert提供了一个深度比较的方法deepEqual,这个可是个好东西。js里面的深度比较一直是个比较麻烦的问题,有经验的程序员会使用JSON来比较,这里提供了一种性能更好的方法。代码其实不复杂,就是引用了deep-eqaul这个库而已,有兴趣的可以去看看哦。

跳过两个关于错误处理的函数(本文不讲解错误处理),来到了context.js最精华的地方了。 这里使用了delegate这个库。这是个啥?delegate其实很简单的,你甚至不需要去查看delegate的源代码,看我解释就行了。

delegate提供了一种类似Proxy的手段,也就是代理。代理什么?具体来说delegate(proto, 'response')这段代码的意思就是把proto上的一些属性代理到proto.response上面去。具体是哪些代理呢?就是接下来排列工整的代码做的了。delegate区分了methodgetteraccess等类型。前面两个还好理解,就是方法和只读属性,第三个呢?其实就是可读可写属性罢了,相当于同时代理了gettersetter。所以其实你访问ctx.redirect实际上访问的是ctx.request.redirect,以此类推。需要注意的是,这里的requestresponse不是nodejs原生的,是koa封装过后的。

context.js就这么简单。

$4.request.js & response.js

request.jsresponse.js分别是对createServer回调函数接收的的requestresponse进行封装。

先看request.js。还记得createContext吗?我们说过,他把Request.req设置成了原生的request。所以你可以看到,很多方法其实本质就是在操作this.req,这一点和response.js类似,后面就不重复说了。

首先是一些个常用的属性,header分别设置了gettersetter,都是对this.req.headers操作。headersheader一模一样,并不是用来区分单复数的(这有点儿坑,初学以为headers是设置多个的)。接下来还有很多常用的属性,就不一一介绍了,什么urlmethod之类的,稍微熟悉点儿nodejs的同学都能够实现出来。

值得注意的是queryquerystring,一个返回的是对象,一个是字符串哦。

你或许会问searchquerystring有啥区别。区别,emmmmn。。。可能是为了完整吧,毕竟express都有个search,koa也要提供。。。

另外需要说一下的是,这里的很多属性的操作涉及到了http协议的内容了,比如freshhttp是个很大的内容,不做讲解。如果遇到看不懂的代码,不妨去查看相关的http协议哦。

另外在idempotent你可以看到!!~,这是个啥玩意儿???第一次看见都是一脸懵逼。这个其实就是位操作而已。我们一般把!!看做一组,它的作用是把任意数据变成boolean值。这个操作其实很简单,就是判断是不是-1,如果是-1,那么就是false;如果不是-1,那么都是true。这个操作很巧妙。稍微解释一下吧。

我们假设数字是8位表示的,那么-1的原码就是1000 0001,反码就是1111 1110,补码就是1111 1111。而~操作符是取反的意思,所以取反以后就成了0000 0000。计算机存储负数是用的补码(相关知识可以取google搜索一下),所以最后就是判断是不是-1的。

有几个accept打头的函数可以忽略,这几个函数是判断是否符合指定类型、语言、编码的,它内部调用了一个accepts的库。这个功能其实用得很少,但涉及编码之类较为复杂的知识了。

在最后的代码里面,request.js提供了get方法,其实就是获取header

让我们转到response.js里面去。劈头盖脸一看,和request.js差不多,知识封装的方法和属性不一样而已。

首先是socket,这个是套接字,http模块的底层,不讲解。

header调用的是getHeaders(),获取已经设置好的所有的header,同headersstatus设置状态码,比如200,404之类的。值得一提的是,通常情况下使用nodejs的statusCode还需要你设置一个statusMessage来提示用户发生了什么错误,koa会智能的为你设置好。比如你设置好了status为404,会自动把statusMessage设置成404 not found。这是因为koa使用了statuses这个库,这个库会根据你传入的状态码返回指定的状态信息。

接下来是Response最重要的一个属性,也就是body。对body的操作反应在内部的_body上面。body的setter做了各种处理。比如判断传给body的值是不是空,如果是空就进行一些操作。比较有意思的是,body的setter会在你没有设置Content-Type时,判断一下传递给body的数据是个什么类型。

  1. 当传递的是字符串时,它使用了一个正则:/^\s*</来判断是html还是text。很明显,这个正则很简陋,在很多情况下并不能正确判断,比如<----就会被判断成html。所以body的类型还是要手动的设置type才行。

  2. 当传递的是buffer的时候,把类型设置称为bin(记住,type是koa封装过后的属性,它会根据你设置的type自动匹配最佳的Content-Type。比如你把type设置成'json',实际上最后的Content-Type会是application/json。后面会说实现方法的)。

  3. 当传递的是个stream(通过判断它是否拥有pipe这个函数),先绑定回调函数,当res发送完毕的时候,销毁这个stream,避免内存浪费。接着错误处理。接着判断以下现在这个stream和原来body的值是否相同,如果不是的话,那就移除Content-Length,交给nodejs自己处理。(实际上nodejs也并不会处理,为啥呢?header必须在正文发送之前发送,但是Stream的字节数要在发送完才知道,so,你懂得)。最后把type设置成bin,因为stream是二进制的数据流。

  4. 不满足以上三种,那么就只能是json了呗(别问我为什么不判断boolean,symbol这些,谁会没事儿干发送这些玩意儿?)。移除Content-Type(你可能想问,为啥呢?因为你传递的实际上是个Object对象,需要stringify之后才能知道它的字节数,这个其实会在后面处理的)。设置typejson

至此,bodysetter分析得差不多了。

接着到了length,这个其实就是封装了设置Content-Length的方法。反倒是它的getter有点儿复杂来着。我们不妨细看一下。

首先判断Content-Length设置没有,有就直接返回,有的话那就分情况读body的字节数。当body是stream的时候,啥都不返回。

这里有个奇淫巧技,~~这个玩意儿可以用来把字符串转换成数字。为什么呢?!我就知道你要问!其实这个东西要对js有比较高的理解才行的,js里面存在隐式类型转换,当遇到一些特殊的操作符,例如位操作符,会把字符串转换成数字来进行计算。其实+这个符号也可以进行字符串转数字(str+str这个不算哈,这个不会进行隐式类型转换),那么为什么要用~~而不是+呢?我思索再三,认为是作者可能不了解。但实际上,~~要比'+'安全,+在遇到不能转换的式子时,会返回NaN,而~~是基于位操作的,返回安全的0。

跳到type,这个和length类似,是对Content-Type实现的封装。这里引用了一个mime-types的库,这个库功能很强大,可以根据传入的参数返回指定的mime类型。比如我们设置type为json,会去调用mime-typescontentType函数,然后返回json类型的mime,也就是application/json

request.js一样,response.js同样封装了setget两个方法,用于设置和读取header

inspecttoJSON又来了。。。

response.js很多的属性和方法并没有提及,这是因为这些属性和方法就是做了简单的封装而已,方便调用,很容易理解。

好了,至此response.js也分析完了。

$5.koa中间件原理分析

koa的中间件原理很强大,实现起来其实并不是特别复杂。记得怎么使用koa中间件吗?只需要use一个函数就行了!这个函数接受两个参数,一个是context,我们已经分析过了。另一个是next,这个就是中间件的核心了。

让我们回到开头,看看use怎么实现的。不看错误处理的那些内容,这里先对fn进行了一次判断。判断什么呢?判断fn是不是generator function。koa官方建议是不要继续使用Generator function了,换成了async function。如果你使用的是Generator function,那么内部会调用co模块来处理。由于处理内容比较晦涩,且与正文关系不大,故不作讲解。我们假设所有的中间件都是async function。

application维护了一个middleware的队列,use方法把中间件推送进这个队列,除此之外什么都没做。

还记得listen方法吗?它调用了callback这个方法。最终的答案都在这里了!

看到callback方法。首先,它对middleware队列调用了compose方法。我们打开compose对应的模块,短短几十行代码。

不看错误处理,那么compose只有一个return语句,返回一个函数。这个函数有两个参数contextnext,熟悉吗?这不就是中间件函数吗!别慌,接着往下看。

首先声明一个index游标,接着定义一个dispatch函数,然后默认返回dispatch(0)

dispatch函数用来分发中间件(和分发事件很像)。它接收一个数字,这个数字是中间件队列中某个中间件的下标。首先先判断一下有没有越界,也就是index和传入的i进行比较,没有越界把游标移动到当前分发的中间件。接着判断i是否已经遍历完了中间件队列,i === middleware.length判断。如果完了,就把fn设置成传入的next。接着使用Promise.resolve,并调用当前中间件,注意

return Promise.resolve(fn(context, function next () {
    return dispatch(i + 1)
}))

这里传入中间件的第二个参数,也就是next,是一个函数,这个函数正是用来分发下一个事件的!!!中间件最重要的原理就在这里,为什么可以用next转移控制权,逻辑就在这里!

compose函数分析完毕了,记住compose的返回值,是一个类似中间件的函数。

回到applicationcallback方法中。定义了一个handleRequest函数并且直接返回,handleRequest其实就是http.createServer的回调函数。这个回调函数首先封装一下createContext,上面已经讲过了。接着调用了application上的handleRequest方法(别搞混了,这个是下面那个handleRequest方法)。

我们看看handleRequest方法,它接受两个参数,第一个是context,第二个是什么呢?其实就是compose处理后的middleware中间件队列。抛开一些’多余‘的代码不看,把它精简成这样:

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

记得fnMiddleware的返回值是什么吗?是dispatch(0)。那记得dispatch的返回值是什么吗?是一个Promise。我们再来看看这个Promise

return Promise.resolve(fn(context, function next () {
    return dispatch(i + 1)
}))

好好想一想,fn现在是第一个中间件,它先被调用了。在这个中间件里面调用了next函数,也就是相当于调用了dispatch(i + 1),如此下去。这不就相当于依次调用了dispatch函数吗?

最后一点,中间件是async function,你明白为什么要使用Promise了吗?对了,就是为了await。

最后的最后,就是respond这个方法了,这个方法实际上就是对statusCodeheader以及body进行处理,最后调用nodejs提供了发送数据的方法,向客户端发送数据。最后调用ctx.end(body),结束本次http请求

那么至此,koa中间件也就完了。

结语

koa的源码并不是十分复杂,有兴趣的同学可以自己再看看。希望这篇文章能给你帮助。

推广一下自己的GitHub,我的开源项目doxjs,有兴趣的可以看看,给个star之类的。