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.json。package.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最重要的三个对象request,response和context。这三个对象几乎就是koa的全部内容了。待会儿会逐一分析。
我们接着往下看,listen函数大家都很熟悉了,就是用来监听端口的。koa的listen函数也很简单。
const server = http.createServer(this.callback());
return server.listen(...args);
短短两行,对于nodejs不熟的同学,建议在这里就打住了。其中this.callback()是个什么玩意儿呢?它返回一个函数,这个函数接收两个参数request和response,也就是createServer的回调函数,在中间件原理章节会更详细介绍。
接着就是toJSSON这个方法。JSON.stringify调用的方法,目的是当你JSON化一个application实例时,返回指定的属性,而并非所有。这个用处不大,几乎用不到。
inspect也就是调用了toJSON这个方法而已。
接着就是use函数了。use函数本身不是很复杂,但是use函数作为中间件的接口,背后的中间件却有点儿复杂。为此,本文在后面专门解读了中间件相关的源代码,这里暂时跳过。
callback,handleRequest,respond这几个方法涉及中间件的,因此放到中间件的章节讲。
createContext这个方法是用来封装context的。这个context就是你在使用koa的use方法,你传递的回调函数的第一个ctx参数。createContext执行的最重要的操作就是把context.request设置成了Request,把context.response设置成了Response。以及把Response.resh和Request.req分别设置成了原生的response和request。
为什么这样说,这个就得追到context.js和request.js以及response.js的代码里面了,先等等。
值得强调的是,这里的Request和Response并不是nodejs里面的,而是koa封装过后的。为了区分原生的和koa封装好的,我把Request和Response称为封装过后的,request和response称为原生的。你需要记住的是context.res是指原生的response,而context.response则是封装后的Response。Request以此类推。
封装的东西看起来并没有什么高大上,无非是把常用的一些方法给简化了。就像jquery简化了js对dom的操作一样。
$3.分析context.js
打开context.js,代码不多,但是含金量挺高的。首先是把proto赋值成一个对象,这个对象也是模块的导出值。
inspect和toJSON功能和application.js里面一样,不做过多介绍了。
接着看到个assert,这个和nodejs里面的assert其实是差不多,它其实是提供了一些断言的操作。比如equal,notEqual,strictEqual之类的。比较有意思的是,assert提供了一个深度比较的方法deepEqual,这个可是个好东西。js里面的深度比较一直是个比较麻烦的问题,有经验的程序员会使用JSON来比较,这里提供了一种性能更好的方法。代码其实不复杂,就是引用了deep-eqaul这个库而已,有兴趣的可以去看看哦。
跳过两个关于错误处理的函数(本文不讲解错误处理),来到了context.js最精华的地方了。
这里使用了delegate这个库。这是个啥?delegate其实很简单的,你甚至不需要去查看delegate的源代码,看我解释就行了。
delegate提供了一种类似Proxy的手段,也就是代理。代理什么?具体来说delegate(proto, 'response')这段代码的意思就是把proto上的一些属性代理到proto.response上面去。具体是哪些代理呢?就是接下来排列工整的代码做的了。delegate区分了method,getter,access等类型。前面两个还好理解,就是方法和只读属性,第三个呢?其实就是可读可写属性罢了,相当于同时代理了getter和setter。所以其实你访问ctx.redirect实际上访问的是ctx.request.redirect,以此类推。需要注意的是,这里的request和response不是nodejs原生的,是koa封装过后的。
context.js就这么简单。
$4.request.js & response.js
request.js和response.js分别是对createServer回调函数接收的的request和response进行封装。
先看request.js。还记得createContext吗?我们说过,他把Request.req设置成了原生的request。所以你可以看到,很多方法其实本质就是在操作this.req,这一点和response.js类似,后面就不重复说了。
首先是一些个常用的属性,header分别设置了getter和setter,都是对this.req.headers操作。headers和header一模一样,并不是用来区分单复数的(这有点儿坑,初学以为headers是设置多个的)。接下来还有很多常用的属性,就不一一介绍了,什么url,method之类的,稍微熟悉点儿nodejs的同学都能够实现出来。
值得注意的是query和querystring,一个返回的是对象,一个是字符串哦。
你或许会问search和querystring有啥区别。区别,emmmmn。。。可能是为了完整吧,毕竟express都有个search,koa也要提供。。。
另外需要说一下的是,这里的很多属性的操作涉及到了http协议的内容了,比如fresh。http是个很大的内容,不做讲解。如果遇到看不懂的代码,不妨去查看相关的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,同headers。status设置状态码,比如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的数据是个什么类型。
-
当传递的是字符串时,它使用了一个正则:
/^\s*</来判断是html还是text。很明显,这个正则很简陋,在很多情况下并不能正确判断,比如<----就会被判断成html。所以body的类型还是要手动的设置type才行。 -
当传递的是buffer的时候,把类型设置称为
bin(记住,type是koa封装过后的属性,它会根据你设置的type自动匹配最佳的Content-Type。比如你把type设置成'json',实际上最后的Content-Type会是application/json。后面会说实现方法的)。 -
当传递的是个stream(通过判断它是否拥有pipe这个函数),先绑定回调函数,当res发送完毕的时候,销毁这个stream,避免内存浪费。接着错误处理。接着判断以下现在这个stream和原来
body的值是否相同,如果不是的话,那就移除Content-Length,交给nodejs自己处理。(实际上nodejs也并不会处理,为啥呢?header必须在正文发送之前发送,但是Stream的字节数要在发送完才知道,so,你懂得)。最后把type设置成bin,因为stream是二进制的数据流。 -
不满足以上三种,那么就只能是json了呗(别问我为什么不判断boolean,symbol这些,谁会没事儿干发送这些玩意儿?)。移除
Content-Type(你可能想问,为啥呢?因为你传递的实际上是个Object对象,需要stringify之后才能知道它的字节数,这个其实会在后面处理的)。设置type成json。
至此,body的setter分析得差不多了。
接着到了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-types的contentType函数,然后返回json类型的mime,也就是application/json。
同request.js一样,response.js同样封装了set和get两个方法,用于设置和读取header。
inspect和toJSON又来了。。。
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语句,返回一个函数。这个函数有两个参数context和next,熟悉吗?这不就是中间件函数吗!别慌,接着往下看。
首先声明一个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的返回值,是一个类似中间件的函数。
回到application的callback方法中。定义了一个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这个方法了,这个方法实际上就是对statusCode,header以及body进行处理,最后调用nodejs提供了发送数据的方法,向客户端发送数据。最后调用ctx.end(body),结束本次http请求。
那么至此,koa中间件也就完了。
结语
koa的源码并不是十分复杂,有兴趣的同学可以自己再看看。希望这篇文章能给你帮助。