深入浅出Node.js 朴灵
深入浅出Node.js
朴灵
1.3 Node给JavaScript带来的意义
除了HTML、WebKit和显卡这些UI相关技术没有支持外,Node的结构与Chrome十分相似。它们都是基于事件驱动的异步架构,浏览器通过事件驱动来服务界面上的交互,Node通过事件驱动来服务I/O
在Node中,JavaScript可以随心所欲地访问本地文件,可以搭建WebSocket服务器端,可以连接数据库,可以如Web Workers一样玩转多进程。如今,JavaScript可以运行在不同的地方,不再继续限制在浏览器中与CSS样式表、DOM树打交道。如果HTTP协议栈是水平面,Node就是浏览器在协议栈另一边的倒影。Node不处理UI,但用与浏览器相同的机制和原理运行。Node打破了过去JavaScript只能在浏览器中运行的局面。前后端编程环境统一,可以大大降低前后端转换所需要的上下文交换代价。
随着Node的出现,关于JavaScript的想象总是无限的。目前,社区已经出现node-webkit这样的项目,这个项目在2012年的沪JS会议上首次介绍给了公众。如同上文提及的关于浏览器的优势和限制,在node-webkit项目中,它将Node中的事件循环和WebKit的事件循环融合在一起,既可以通过它享受HTML、CSS带来的UI构建,也能通过它访问本地资源,将两者的优势整合到一起。桌面应用程序的开发可以完全通过HTML、CSS、JavaScript完成。
1.4.1 异步I/O
下面的代码用于发起一个Ajax请求:
|
熟悉异步的用户必然知道,“收到响应”是在“发送Ajax结束”之后输出的。在调用$.post()后,后续代码是被立即执行的,而“收到响应”的执行时间是不被预期的。我们只知道它将在这个异步请求结束后执行,但并不知道具体的时间点。异步调用中对于结果值的捕获是符合“Don’t call me, I will call you”的原则的,这也是注重结果,不关心过程的一种表现
在Node中,异步I/O也很常见。以读取文件为例,我们可以看到它与前端Ajax调用的方式是极其类似的:
|
这里的“发起读取文件”是在“读取文件完成”之前输出的。同样,“读取文件完成”的执行也取决于读取文件的异步调用何时结束。图1-3是一个经典的异步调用。
在Node中,绝大多数的操作都以异步的方式进行调用。RyanDahl排除万难,在底层构建了很多异步I/O的API,从文件读取到网络请求等,均是如此。这样的意义在于,在Node中,我们可以从语言层面很自然地进行并行I/O操作。每个调用之间无须等待之前的I/O调用结束。在编程模型上可以极大提升效率。
下面的两个文件读取任务的耗时取决于最慢的那个文件读取的耗时:
|
而对于同步I/O而言,它们的耗时是两个任务的耗时之和
5.1.1 Node与V8
关于V8,它的来历与背景亦是大有来头。作为虚拟机,V8的性能表现优异,这与它的领导者有莫大的渊源,Chrome的成功也离不开它背后的天才——Lars Bak
在Lars的工作履历里,绝大部分都是与虚拟机相关的工作。在开发V8之前,他曾经在Sun公司工作,担任HotSpot团队的技术领导,主要致力于开发高性能的Java虚拟机。在这之前,他也曾为Self、Smalltalk语言开发过高性能虚拟机。这些无与伦比的经历让V8一出世就超越了当时所有的JavaScript虚拟机。
Node在JavaScript的执行上直接受益于V8,可以随着V8的升级就能享受到更好的性能或新的语言特性(如ES5和ES6)等,同时也受到V8的一些限制,尤其是本章要重点讨论的内存限制。
5.1.2 V8的内存限制
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7GB)。在这样的限制下,将会导致Node无法直接操作大内存对象,比如无法将一个2 GB的文件读入内存中进行字符串分析处理,即使物理内存有32 GB。这样在单个Node进程的情况下,计算机的内存资源无法得到充足的使用。
造成这个问题的主要原因在于Node基于V8构建,所以在Node中使用的JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。V8的这套内存管理机制在浏览器的应用场景下使用起来绰绰有余,足以胜任前端页面中的所有需求。但在Node中,这却限制了开发者随心所欲使用大内存的想法。
5.1.3 V8的对象分配
在V8中,所有的JavaScript对象都是通过堆来进行分配的
当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止
至于V8为何要限制堆的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。对于网页来说,V8的限制值已经绰绰有余。深层原因是V8的垃圾回收机制的限制。按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。
当然,这个限制也不是不能打开,V8依然提供了选项让我们使用更多的内存。Node在启动时可以传递--max-old-space-size
或--max-new-space-size
来调整内存限制的大小,示例如下:
|
上述参数在V8初始化时生效,一旦生效就不能再动态改变。如果遇到Node无法分配足够内存给JavaScript对象的情况,可以用这个办法来放宽V8默认的内存限制,避免在执行过程中稍微多用了一些内存就轻易崩溃。
5.1.4 V8的垃圾回收机制
V8的垃圾回收策略主要基于分代式垃圾回收机制。在自动垃圾回收的演变过程中,人们发现没有一种垃圾回收算法能够胜任所有的场景。因为在实际的应用中,对象的生存周期长短不一,不同的算法只能针对特定情况具有最好的效果。
在V8中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间
前面我们提及的–max-old-space-size命令行参数可以用于设置老生代内存空间的最大值,–max-new-space-size命令行参数则用于设置新生代内存空间的大小的。比较遗憾的是,这两个最大值需要在启动时就指定。这意味着V8使用的内存没有办法根据使用情况自动扩充,当内存分配过程中超过极限值时,就会引起进程出错。
对于新生代内存,它由两个reserved_semispace_size_
所构成,后面将描述其原因。按机器位数不同,reserved_semispacesize在64位系统和32位系统上分别为16 MB和8 MB。所以新生代内存的最大值在64位系统和32位系统上分别为32 MB和16 MB。
Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制。
Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。
由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。
当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升
对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。这两个问题导致应对生命周期较长的对象时Scavenge会显得捉襟见肘。为此,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。与Scavenge相比,Mark-Sweep并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与Scavenge复制活着的对象不同,Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因
Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出来。Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
,在Mark-Sweep和Mark-Compact之间,由于Mark-Compact需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。
为了避免出现JavaScript应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full 垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。
图5-8 增量标记示意图
V8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
从V8的自动垃圾回收机制的设计角度可以看到,V8对内存使用进行限制的缘由。新生代设计为一个较小的内存空间是合理的,而老生代空间过大对于垃圾回收并无特别意义。V8对内存限制的设置对于Chrome浏览器这种每个选项卡页面使用一个V8实例而言,内存的使用是绰绰有余了。对于Node编写的服务器端来说,内存限制也并不影响正常场景下的使用。但是对于V8的垃圾回收特点和JavaScript在单线程上的执行情况,垃圾回收是影响性能的因素之一。想要高性能的执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。
以Web服务器中的会话实现为例,一般通过内存来存储,但在访问量大的时候会导致老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出
5.1.5 查看垃圾回收日志
查看垃圾回收日志的方式主要是在启动时添加–trace_gc参数。在进行垃圾回收时,将会从标准输出中打印垃圾回收的日志信息。下面是一段示例,执行结束后,将会在gc.log文件中得到所有垃圾回收信息:
|
5.2 高效使用内存
高效使用内存
在V8面前,开发者所要具备的责任是如何让垃圾回收机制更高效地工作。
5.2.1 作用域
提到如何触发垃圾回收,第一个要介绍的是作用域(scope)。在JavaScript中能形成作用域的有函数调用、with以及全局作用
foo()函数在每次被调用时会创建对应的作用域,函数执行结束后,该作用域将会销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短
将会分配在新生代中的From空间中。在作用域释放后,局部变量local失效,其引用的对象将会在下次垃圾回收时被释放。
标识符查找
与作用域相关的即是标识符查找。所谓标识符,可以理解为变量名。在下面的代码中,执行bar()函数时,将会遇到local变量:
|
JavaScript在执行时会去查找该变量定义在哪里。它最先查找的是当前作用域,如果在当前作用域中无法找到该变量的声明,将会向上级的作用域里查找,直到查到为止。
2. 作用域链
local变量在baz()函数形成的作用域里查找不到,继而将在bar()的作用域里寻找。如果去掉上述代码bar()中的local声明,将会继续向上查找,一直到全局作用域。这样的查找方式使得作用域像一个链条。由于标识符的查找方向是向上的,所以变量只能向外访问,而不能向内访问。
当我们在baz()函数中访问local变量时,由于作用域中的变量列表中没有local,所以会向上一个作用域中查找,接着会在bar()函数执行得到的变量列表中找到了一个local变量的定义,于是使用它。尽管在再上一层的作用域中也存在local的定义,但是不会继续查找了。如果查找一个不存在的变量,将会一直沿着作用域链查找到全局作用域,最后抛出未定义错误。
变量的主动释放
如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。如果需要释放常驻内存的对象,可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清除和整理的过程中,会被回收释放。下面为示例代码:
|
同样,如果在非全局作用域中,想主动释放变量引用的对象,也可以通过这样的方式。虽然delete操作和重新赋值具有相同的效果,但是在V8中通过delete删除对象的属性有可能干扰V8的优化,所以通过赋值方式解除引用更好。
5.2.2 闭包
我们知道作用域链上的对象访问只能向上,这样外部无法向内部访问。
在JavaScript中,实现外部作用域访问内部作用域中变量的方法叫做闭包(closure)。这得益于高阶函数的特性:函数可以作为参数或者返回值。
一般而言,在bar()函数执行完成后,局部变量local将会随着作用域的销毁而被回收。但是注意这里的特点在于返回值是一个匿名函数,且这个函数中具备了访问local的条件。虽然在后续的执行中,在外部作用域中还是无法直接访问local,但是若要访问它,只要通过这个中间函数稍作周转即可
闭包是JavaScript的高级特性,利用它可以产生很多巧妙的效果。它的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。除非不再有引用,才会逐步释放。
5.2.3 小结
在正常的JavaScript执行中,无法立即回收的内存有闭包和全局变量引用这两种情况。由于V8的内存限制,要十分小心此类变量是否无限制地增加,因为它会导致老生代中的对象增多
5.3 内存指标
一般而言,应用中存在一些全局性的对象是正常的,而且在正常的使用中,变量都会自动释放回收。但是也会存在一些我们认为会回收但是却没有被回收的对象,这会导致内存占用无限增长。一旦增长达到V8的内存限制,将会得到内存溢出错误,进而导致进程退出。
5.3.2 堆外内存
我们看到15次循环都完整执行,并且三个内存占用值与前一个示例完全不同。在改造后的输出结果中,heapTotal与heapUsed的变化极小,唯一变化的是rss的值,并且该值已经远远超过V8的限制值。这其中的原因是Buffer对象不同于其他对象,它不经过V8的内存分配机制,所以也不会有堆内存的大小限制。
这意味着利用堆外内存可以突破内存限制的问题。
为何Buffer对象并非通过V8分配?这在于Node并不同于浏览器的应用场景。在浏览器中,JavaScript直接处理字符串即可满足绝大多数的业务需求,而Node则需要处理网络流和文件I/O流,操作字符串远远不能满足传输的性能需求。
5.3.3 小结
Node的内存构成主要由通过V8进行分配的部分和Node自行分配的部分。受V8的垃圾回收限制的主要是V8的堆内存。
5.4 内存泄漏
Node对内存泄漏十分敏感,一旦线上应用有成千上万的流量,那怕是一个字节的内存泄漏也会造成堆积,垃圾回收过程中将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩
在V8的垃圾回收机制下,在通常的代码编写中,很少会出现内存泄漏的情况。但是内存泄漏通常产生于无意间,较难排查。尽管内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。
通常,造成内存泄漏的原因有如下几个。
缓存。队列消费不及时。
作用域未释放。
5.4.1 慎将内存当做缓存
慎将内存当做缓存
缓存在应用中的作用举足轻重,可以十分有效地节省资源。因为它的访问效率要比I/O的效率高,一旦命中缓存,就可以节省一次I/O的时间。
但是在Node中,缓存并非物美价廉。一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。
另一个问题在于,JavaScript开发者通常喜欢用对象的键值对来缓存东西,但这与严格意义上的缓存又有着区别,严格意义的缓存有着完善的过期策略,而普通对象的键值对并没有。
十分容易理解,如果需要,只要限定缓存对象的大小,加上完善的过期策略以防止内存无限制增长,还是可以一用的。
这里给出一个可能无意识造成内存泄漏的场景:memoize。
它的原理是以参数作为键进行缓存,以内存空间换CPU执行时间。这里潜藏的陷阱即是每个被执行的结果都会按参数缓存在memo对象上,不会被清除。这在前端网页这种短时应用场景中不存在大问题,但是执行量大和参数多样性的情况下,会造成内存占用不释放。
所以在Node中,任何试图拿内存当缓存的行为都应当被限制。当然,这种限制并不是不允许使用的意思,而是要小心为之。
为了解决缓存中的对象永远无法释放的问题,需要加入一种策略来限制缓存的无限增长。
为此我曾写过一个模块limitablemap,它可以实现对键值数量的限制。
|
实现过程还是非常简单的。记录键在数组中,一旦超过数量,就以先进先出的方式进行淘汰。
这种淘汰策略并不是十分高效,只能应付小型应用场景。如果需要更高效的缓存,可以参见Isaac Z.Schlueter采用LRU算法的缓存,地址为https://github.com/isaacs/node-lru-cache。结合有限制的缓存,memoize还是可用的。
另一个案例在于模块机制。在第2章的模块介绍中,为了加速模块的引入,所有模块都会通过编译执行,然后被缓存起来。由于通过exports导出的函数,可以访问文件模块中的私有变量,这样每个文件模块在编译执行后形成的作用域因为模块缓存的原因,不会被释放。示例代码如下所示:
|
由于模块的缓存机制,模块是常驻老生代的。在设计模块时,要十分小心内存泄漏的出现。在下面的代码,每次调用leak()方法时,都导致局部变量leakArray不停增加内存的占用,且不被释放:
|
如果模块不可避免地需要这么设计,那么请添加清空队列的相应接口,以供调用者释放内存。
缓存的解决方案
直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。如果在进程内使用缓存,这些缓存不可避免地有重复,对物理内存的使用是一种浪费。如何使用大量缓存,目前比较好的解决方案是采用进程外的缓存,进程自身不存储状态。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能。它的好处多多,在Node中主要可以解决以下两个问题。
(1)将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。
(2)进程之间可以共享缓存。
目前,市面上较好的缓存有Redis和Memcached。Node模块的生态系统十分完善,这两个产品的客户端都有,通过以下地址可以查看具体使用详情。
|
5.4.2 关注队列状态
关注队列状态
在解决了缓存带来的内存泄漏问题后,另一个不经意产生的内存泄漏则是队列。在第4章中可以看到,在JavaScript中可以通过队列(数组对象)来完成许多特殊的需求,比如Bagpipe。队列在消费者-生产者模型中经常充当中间产物。这是一个容易忽略的情况,因为在大多数应用场景下,消费的速度远远大于生产的速度,内存泄漏不易产生。但是一旦消费速度低于生产速度,将会形成堆积。
举个实际的例子,有的应用会收集日志。如果欠缺考虑,也许会采用数据库来记录日志。日志通常会是海量的,数据库构建在文件系统之上,写入效率远远低于文件直接写入,于是会形成数据库写入操作的堆积,而JavaScript中相关的作用域也不会得到释放,内存占用不会回落,从而出现内存泄漏。
遇到这种场景,表层的解决方案是换用消费速度更高的技术。在日志收集的案例中,换用文件写入日志的方式会更高效。需要注意的是,如果生产速度因为某些原因突然激增,或者消费速度因为突然的系统故障降低,内存泄漏还是可能出现的。深度的解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。
对于Bagpipe而言,它提供了超时模式和拒绝模式。启用超时模式时,调用加入到队列中就开始计时,超时就直接响应一个超时错误。启用拒绝模式时,当队列拥塞时,新到来的调用会直接响应拥塞错误。这两种模式都能够有效地防止队列拥塞导致的内存泄漏问题。
5.5 内存泄漏排查
在Node中,由于V8的堆内存大小的限制,它对内存泄漏非常敏感。当在线服务的请求量变大时,哪怕是一个字节的泄漏都会导致内存占用过高。
v8-profiler
。由Danny Coates提供,它可以用于对V8堆内存抓取快照和对CPU进行分析,但该项目已经有3年没有维护了。
node-heapdump
。这是Node核心贡献者之一Ben Noordhuis编写的模块,它允许对V8堆内存抓取快照,用于事后分析。
node-mtrace
。由Jimb Esser提供,它使用了GCC的mtrace工具来分析堆的使用。
dtrace
。在Joyent的SmartOS系统上,有完善的dtrace工具用来分析内存泄漏。
node-memwatch
。来自Mozilla的Lloyd Hilaiel贡献的模块,采用WTFPL许可发布。
5.5.2 node-memwatch
最终得到的leak事件的信息只能告知我们应用中存在内存泄漏,具体问题产生在何处还需要从V8的堆内存上定位。node-memwatch提供了抓取快照和比较快照的功能,它能够比较堆上对象的名称和分配数量,从而找出导致内存泄漏的元凶。
5.6 大内存应用
在Node中,不可避免地还是会存在操作大文件的场景。由于Node的内存限制,操作大文件也需要小心,好在Node提供了stream模块用于处理大文件。
stream模块是Node的原生模块,直接引用即可。stream继承自EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分可读和可写两种。Node中的大多数模块都有stream的应用,比如fs的createReadStream()和createWriteStream()方法可以分别用于创建文件的可读流和可写流,process模块中的stdin和stdout则分别是可读流和可写流的示例。由于V8的内存限制,我们无法通过fs.readFile()和fs.writeFile()直接进行大文件的操作,而改用fs.createReadStream()和fs.createWriteStream()方法通过流的方式实现对大文件的操作
如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。但是这种大片使用内存的情况依然要小心,即使V8不限制堆内存的大小,物理内存依然有限制。
第06章 理解Buffer
文件和网络I/O对于前端开发者而言都是不曾有的应用场景,因为前端只需做一些简单的字符串操作或DOM操作基本就能满足业务需求,在ECMAScript规范中,也没有对这些方面做任何的定义,只有CommonJS中有部分二进制的定义。由于应用场景不同,在Node中,应用需要处理网络协议、操作数据库、处理图片、接收上传文件等,在网络流和文件的操作中,还要处理大量二进制数据,JavaScript自有的字符串远远不能满足这些需求,于是Buffer对象应运而生。
6.1 Buffer结构
Buffer是一个像Array的对象,但它主要用于操作字节
6.1.1 模块结构
Buffer是一个典型的JavaScript与C++结合的模块,它将性能相关部分用C++实现,将非性能相关的部分用JavaScript实现,如图6-1所示。
图6-1 Buffer的分工
Buffer所占用的内存不是通过V8分配的,属于堆外内存。由于V8垃圾回收性能的影响,将常用的操作对象用更高效和专有的内存分配回收策略来管理是个不错的思路。
由于Buffer太过常见,Node在进程启动时就已经加载了它,并将其放在全局对象(global)上。所以在使用Buffer时,无须通过require()即可直接使用。
6.1.2 Buffer对象
Buffer受Array类型的影响很大,可以访问length属性得到长度,也可以通过下标访问元素,在构造对象时也十分相似,代码如下:
|
上述代码分配了一个长100字节的Buffer对象。可以通过下标访问刚初始化的Buffer的元素,代码如下:console.log(buf[10]);
这里会得到一个比较奇怪的结果,它的元素值是一个0到255的随机值。
值得注意的是,如果给元素赋值不是0到255的整数而是小数时会怎样呢?示例代码如下所示:
|
给元素的赋值如果小于0,就将该值逐次加256,直到得到一个0到255之间的整数。如果得到的数值大于255,就逐次减256,直到得到0~255区间内的数值。如果是小数,舍弃小数部分,只保留整数部分。
6.1.3 Buffer内存分配
Buffer内存分配
Buffer对象的内存分配不是在V8的堆内存中,而是在Node的C++层面实现内存的申请的。因为处理大量的字节数据不能采用需要一点内存就向操作系统申请一点内存的方式,这可能造成大量的内存申请的系统调用,对操作系统有一定压力。为此Node在内存的使用上应用的是在C++层面申请内存、在JavaScript中分配内存的策略。为了高效地使用申请来的内存,Node采用了slab分配机制。slab是一种动态内存管理机制,最早诞生于SunOS操作系统(Solaris)中,目前在一些*nix操作系统中有广泛的应用,如FreeBSD和Linux。
简单而言,slab就是一块申请好的固定大小的内存区域。slab具有如下3种状态。
full:完全分配状态。
partial:部分分配状态。
empty:没有被分配状态。
当我们需要一个Buffer对象,可以通过以下方式分配指定大小的Buffer对象:new Buffer(size);
Node以8 KB为界限来区分Buffer是大对象还是小对象:Buffer.poolSize = 8 * 1024;
这个8 KB的值也就是每个slab的大小值,在JavaScript层面,以它作为单位单元进行内存的分配。
分配小Buffer对象
如果指定Buffer的大小少于8 KB,Node会按照小对象的方式进行分配。Buffer的分配过程中主要使用一个局部变量pool作为中间处理对象,处于分配状态的slab单元都指向它。以下是分配一个全新的slab单元的操作,它会将新申请的SlowBuffer对象指向它:
|
再次创建一个Buffer对象时,构造过程中将会判断这个slab的剩余空间是否足够。如果足够,使用剩余空间,并更新slab的分配状态。下面的代码创建了一个新的Buffer对象,它会引起一次slab分配:
如果slab剩余的空间不够,将会构造新的slab,原slab中剩余的空间会造成浪费。例如,第一次构造1字节的Buffer对象,第二次构造8192字节的Buffer对象,由于第二次分配时slab中的空间不够,所以创建并使用新的slab,第一个slab的8 KB将会被第一个1字节的Buffer对象独占。下面的代码一共使用了两个slab单元:
|
这里要注意的事项是,由于同一个slab可能分配给多个Buffer对象使用,只有这些小Buffer对象在作用域释放并都可以回收时,slab的8 KB空间才会被回收。尽管创建了1个字节的Buffer对象,但是如果不释放它,实际可能是8 KB的内存没有释放。
分配大Buffer对象
如果需要超过8 KB的Buffer对象,将会直接分配一个SlowBuffer对象作为slab单元,这个slab单元将会被这个大Buffer对象独占。
|
这里的SlowBuffer类是在C++中定义的,虽然引用buffer模块可以访问到它,但是不推荐直接操作它,而是用Buffer替代。
上面提到的Buffer对象都是JavaScript层面的,能够被V8的垃圾回收标记回收。但是其内部的parent属性指向的SlowBuffer对象却来自于Node自身C++中的定义,是C++层面上的Buffer对象,所用内存不在V8的堆中。
简单而言,真正的内存是在Node的C++层面提供的,JavaScript层面只是使用它。当进行小而频繁的Buffer操作时,采用slab的机制进行预先申请和事后分配,使得JavaScript到操作系统之间不必有过多的内存申请方面的系统调用。对于大块的Buffer而言,则直接使用C++层面提供的内存,而无需细腻的分配操作。
6.2 Buffer的转换
Buffer对象可以与字符串之间相互转换。
6.2.1 字符串转Buffer
字符串转Buffer
字符串转Buffer对象主要是通过构造函数完成的:
|
通过构造函数转换的Buffer对象,存储的只能是一种编码类型。encoding参数不传递时,默认按UTF-8编码进行转码和存储。
一个Buffer对象可以存储不同编码类型的字符串转码的值,调用write()方法可以实现该目的
由于可以不断写入内容到Buffer对象中,并且每次写入可以指定编码,所以Buffer对象中可以存在多种编码转化后的内容。需要小心的是,每种编码所用的字节长度不同,将Buffer反转回字符串时需要谨慎处理。
6.2.2 Buffer转字符串
实现Buffer向字符串的转换也十分简单,Buffer对象的toString()可以将Buffer对象转换为字符串
比较精巧的是,可以设置encoding(默认为UTF-8)、start、end这3个参数实现整体或局部的转换。如果Buffer对象由多种编码写入,就需要在局部指定不同的编码,才能转换回正常的编码。
6.2.3 Buffer不支持的编码类型
目前比较遗憾的是,Node的Buffer对象支持的编码类型有限,只有少数的几种编码类型可以在字符串和Buffer之间转换。为此,Buffer提供了一个isEncoding()函数来判断编码是否支持转换:
Buffer.isEncoding(encoding)将编码类型作为参数传入上面的函数,如果支持转换返回值为true,否则为false。很遗憾的是,在中国常用的GBK、GB2312和BIG-5编码都不在支持的行列中。
对于不支持的编码类型,可以借助Node生态圈中的模块完成转换。iconv和iconv-lite两个模块可以支持更多的编码类型转换,包括Windows 125系列、ISO-8859系列、IBM/DOS代码页系列、Macintosh系列、KOI8系列,以及Latin1、US-ASCII,也支持宽字节编码GBK和GB2312。
iconv-lite采用纯JavaScript实现,iconv则通过C++调用libiconv库完成。前者比后者更轻量,无须编译和处理环境依赖直接使用。在性能方面,由于转码都是耗用CPU,在V8的高性能下,少了C++到JavaScript的层次转换,纯JavaScript的性能比C++实现得更好。
另外,iconv和iconv-lite对无法转换的内容进行降级处理时的方案不尽相同。iconv-lite无法转换的内容如果是多字节,会输出<图>;如果是单字节,则输出?。iconv则有三级降级策略,会尝试翻译无法转换的内容,或者忽略这些内容。如果不设置忽略,iconv对于无法转换的内容将会得到EILSEQ异常
6.3 Buffer的拼接
Buffer的拼接
Buffer在使用场景中,通常是以一段一段的方式传输。
以下是常见的从输入流中读取内容的示例代码:
|
上面这段代码常见于国外,用于流读取的示范,data事件中获取的chunk对象即是Buffer对象。对于初学者而言,容易将Buffer当做字符串来理解,所以在接受上面的示例时不会觉得有任何异常。
一旦输入流中有宽字节编码时,问题就会暴露出来。如果你在通过Node开发的网站上看到<图>乱码符号,那么该问题的起源多半来自于这里。
这里潜藏的问题在于如下这句代码:
|
这句代码里隐藏了toString()操作,它等价于如下的代码:
|
值得注意的是,外国人的语境通常是指英文环境,在他们的场景下,这个toString()不会造成任何问题。但对于宽字节的中文,却会形成问题。
6.3.1 乱码是如何产生的
上面的诗歌中,“月”、“是”、“望”、“低”4个字没有被正常输出,取而代之的是3个<图>。产生这个输出结果的原因在于文件可读流在读取时会逐个读取Buffer。这首诗的原始Buffer应存储为:
|
由于我们限定了Buffer对象的长度为11,因此只读流需要读取7次才能完成完整的读取,结果是以下几个Buffer对象依次输出:
|
上文提到的buf.toString()
方法默认以UTF-8为编码,中文字在UTF-8下占3个字节。所以第一个Buffer对象在输出时,只能显示3个字符,Buffer中剩下的2个字节(e6 9c)将会以乱码的形式显示。第二个Buffer对象的第一个字节也不能形成文字,只能显示乱码。于是形成一些文字无法正常显示的问题。
在这个示例中我们构造了11这个限制,但是对于任意长度的Buffer而言,宽字节字符串都有可能存在被截断的情况,只不过Buffer的长度越大出现的概率越低而已,但该问题依然不可忽视。
6.3.2 setEncoding()与string_decoder()
setEncoding()与string_decoder()
在看过上述的示例后,也许我们忘记了可读流还有一个设置编码的方法setEncoding(),示例如下:
|
该方法的作用是让data事件中传递的不再是一个Buffer对象,而是编码后的字符串。为此,我们继续改进前面诗歌的程序,添加setEncoding()的步骤如下:
|
要知道,无论如何设置编码,触发data事件的次数依旧相同,这意味着设置编码并未改变按段读取的基本方式。
事实上,在调用setEncoding()时,可读流对象在内部设置了一个decoder对象。每次data事件都通过该decoder对象进行Buffer到字符串的解码,然后传递给调用者。是故设置编码后,data不再收到原始的Buffer对象。但是这依旧无法解释为何设置编码后乱码问题被解决掉了,因为在前述分析中,无论如何转码,总是存在宽字节字符串被截断的问题。最终乱码问题得以解决,还是在于decoder的神奇之处。decoder对象来自于string_decoder模块StringDecoder的实例对象。它神奇的原理是什么,下面我们以代码来说明:
|
我将前文提到的前两个Buffer对象写入decoder中。奇怪的地方在于“月”的转码并没有如平常一样在两个部分分开输出。StringDecoder在得到编码后,知道宽字节字符串在UTF-8编码下是以3个字节的方式存储的,所以第一次write()时,只输出前9个字节转码形成的字符,“月”字的前两个字节被保留在StringDecoder实例内部。第二次write()时,会将这2个剩余字节和后续11个字节组合在一起,再次用3的整数倍字节进行转码。于是乱码问题通过这种中间形式被解决了。
奇妙,但是它也并非万能药,它目前只能处理UTF-8、Base64和UCS-2/UTF-16LE这3种编码。所以,通过setEncoding()的方式不可否认能解决大部分的乱码问题,但并不能从根本上解决该问题。
6.3.3 正确拼接Buffer
正确拼接Buffer
淘汰掉setEncoding()方法后,剩下的解决方案只有将多个小Buffer对象拼接为一个Buffer对象,然后通过iconv-lite一类的模块来转码这种方式。+=的方式显然不行,那么正确的Buffer拼接方法应该如下面展示的形式:
|
正确的拼接方式是用一个数组来存储接收到的所有Buffer片段并记录下所有片段的总长度,然后调用Buffer.concat()方法生成一个合并的Buffer对象。Buffer.concat()方法封装了从小Buffer对象向大Buffer对象的复制过程,实现十分细腻,值得围观学习:
|
6.4 Buffer与性能
Buffer与性能
Buffer在文件I/O和网络I/O中运用广泛,尤其在网络传输中,它的性能举足轻重。在应用中,我们通常会操作字符串,但一旦在网络中传输,都需要转换为Buffer,以进行二进制数据传输。在Web应用中,字符串转换到Buffer是时时刻刻发生的,提高字符串到Buffer的转换效率,可以很大程度地提高网络吞吐率。
在展开Buffer与网络传输的关系之前,我们可以先来进行一次性能测试。下面的例子中构造了一个10 KB大小的字符串。我们首先通过纯字符串的方式向客户端发送,代码如下:
|
我们通过ab进行一次性能测试,发起200个并发客户端:
|
得到的测试结果如下所示:
|
测试的QPS(每秒查询次数)是2527.64,传输率为每秒25 370.16 KB。接下来我们注释掉
|
使向客户端输出的是一个Buffer对象,无须在每次响应时进行转换。再次进行性能测试的结果如下所示:
|
QPS的提升到4843.28,传输率为每秒48 612.56KB,性能提高近一倍。
通过预先转换静态内容为Buffer对象,可以有效地减少CPU的重复使用,节省服务器资源。在Node构建的Web应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为Buffer的方式,使性能得到提升。由于文件自身是二进制数据,所以在不需要改变内容的场景下,尽量只读取Buffer,然后直接传输,不做额外的转换,避免损耗。
文件读取
Buffer的使用除了与字符串的转换有性能损耗外,在文件的读取时,有一个highWaterMark
设置对性能的影响至关重要。在fs.createReadStream(path, opts)
时,我们可以传入一些参数,
fs.createReadStream()
的工作方式是在内存中准备一段Buffer,然后在fs.read()读取时逐步从磁盘中将字节复制到Buffer中。完成一次读取时,则从这个Buffer中通过slice()方法取出部分数据作为一个小Buffer对象,再通过data事件传递给调用方。如果Buffer用完,则重新分配一个;如果还有剩余,则继续使用。下面为分配一个新的Buffer对象的操作:
|
在理想的状况下,每次读取的长度就是用户指定的highWaterMark。但是有可能读到了文件结尾,或者文件本身就没有指定的highWaterMark那么大,这个预先指定的Buffer对象将会有部分剩余,不过好在这里的内存可以分配给下次读取时使用。pool是常驻内存的,只有当pool单元剩余数量小于128(kMinPoolSpace)字节时,才会重新分配一个新的Buffer对
Node源代码中分配新的Buffer对象的判断条件如下所示:
|
这里与Buffer的内存分配比较类似,highWaterMark的大小对性能有两个影响的点。
highWaterMark
设置对Buffer内存的分配和使用有一定影响。highWaterMark
设置过小,可能导致系统调用次数过多。
文件流读取基于Buffer分配,Buffer则基于SlowBuffer分配,这可以理解为两个维度的分配策略。如果文件较小(小于8 KB),有可能造成slab未能完全使用。
由于fs.createReadStream()内部采用fs.read()实现,将会引起对磁盘的系统调用,对于大文件而言,highWaterMark的大小决定会触发系统调用和data事件的次数
读取一个相同的大文件时,highWaterMark值的大小与读取速度的关系:该值越大,读取速度越快。
6.5 总结
体验过JavaScript友好的字符串操作后,有些开发者可能会形成思维定势,将Buffer当做字符串来理解。但字符串与Buffer之间有实质上的差异,即Buffer是二进制数据,字符串与Buffer之间存在编码关系。因此,理解Buffer的诸多细节十分必要,对于如何高效处理二进制数据十分有用。
第07章 网络编程
Node是一个面向网络而生的平台,它具有事件驱动、无阻塞、单线程等特性,具备良好的可伸缩性,使得它十分轻量,适合在分布式网络中扮演各种各样的角色。同时Node提供的API十分贴合网络,适合用它基础的API构建灵活的网络服务。
在Web领域,大多数的编程语言需要专门的Web服务器作为容器,如ASP、ASP.NET需要IIS作为服务器,PHP需要搭载Apache或Nginx环境等,JSP需要Tomcat服务器等。但对于Node而言,只需要几行代码即可构建服务器,无需额外的容器。
Node提供了net、dgram、http、https这4个模块,分别用于处理TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。
7.1.1 TCP
TCP全名为传输控制协议,在OSI模型(由七层组成,分别为物理层、数据链结层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。许多应用层协议基于TCP构建,典型的是HTTP、SMTP、IMAP等协议。
只有会话形成之后,服务器端和客户端之间才能互相发送数据。在创建会话的过程中,服务器端和客户端分别提供一个套接字,这两个套接字共同形成一个连接。服务器端与客户端则通过套接字实现两者之间连接的操作。
注 | 计算机基础(^_^) |
---|---|
7.1.2 创建TCP服务器端
我们可以开始创建一个TCP服务器端来接受网络请求,代码如下:
|
我们通过net.createServer(listener)即可创建一个TCP服务器,listener是连接事件connection的侦听器,也可以采用如下的方式进行侦听:
|
我们可以利用Telnet工具作为客户端对刚才创建的简单服务器进行会话交流,相关代码如下所示:
|
除了端口外,同样我们也可以对Domain Socket进行监听,代码如下:
|
通过nc工具进行会话,测试上面构建的TCP服务的代码如下所示:
|
通过net模块自行构造客户端进行会话,测试上面构建的TCP服务的代码如下所示:
|
将以上客户端代码存为client.js并执行,如下所示:
|
client connected
欢迎光临《深入浅出Node.js》示例:
你好
client disconnected
其结果与使用Telnet和nc的会话结果并无差别。如果是Domain Socket,在填写选项时,填写path即可,代码如下:
|
7.1.3 TCP服务的事件
对于通过net.createServer()创建的服务器而言,它是一个EventEmitter实例,它的自定义事件有如下几种。
- listening:在调用server.listen()绑定端口或者Domain Socket后触发,简洁写法为server.listen(port,listeningListener),通过listen()方法的第二个参数传入。
- connection:每个客户端套接字连接到服务器端时触发,简洁写法为通过net.create-Server(),最后一个参数传递。
- close:当服务器关闭时触发,在调用server.close()后,服务器将停止接受新的套接字连接,但保持当前存在的连接,等待所有连接都断开后,会触发该事件。
- error:当服务器发生异常时,将会触发该事件。比如侦听一个使用中的端口,将会触发一个异常,如果不侦听error事件,服务器将会抛出异常。
连接事件
服务器可以同时与多个客户端保持连接,对于每个连接而言是典型的可写可读Stream对象。Stream对象可以用于服务器端和客户端之间的通信,既可以通过data事件从一端读取另一端发来的数据,也可以通过write()方法从一端向另一端发送数据。
- data:当一端调用write()发送数据时,另一端会触发data事件,事件传递的数据即是write()发送的数据。
- end:当连接中的任意一端发送了FIN数据时,将会触发该事件。
- connect:该事件用于客户端,当套接字与服务器端连接成功时会被触发。
- drain:当任意一端调用write()发送数据时,当前这端会触发该事件。
- error:当异常发生时,触发该事件。
- close:当套接字完全关闭时,触发该事件
- timeout:当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前该连接已经被闲置了。
由于TCP套接字是可写可读的Stream对象,可以利用pipe()方法巧妙地实现管道操作
值得注意的是,TCP针对网络中的小数据包有一定的优化策略:Nagle算法。如果每次只发送一个字节的内容而不优化,网络中将充满只有极少数有效数据的数据包,将十分浪费网络资源。Nagle算法针对这种情况,要求缓冲区的数据达到一定数量或者一定时间后才将其发出,所以小数据包将会被Nagle算法合并,以此来优化网络。这种优化虽然使网络带宽被有效地使用,但是数据有可能被延迟发送。
在Node中,由于TCP默认启用了Nagle算法,可以调用socket.setNoDelay(true)去掉Nagle算法,使得write()可以立即发送数据到网络中。
另一个需要注意的是,尽管在网络的一端调用write()会触发另一端的data事件,但是并不意味着每次write()都会触发一次data事件,在关闭掉Nagle算法后,另一端可能会将接收到的多个小数据包合并,然后只触发一次data事件。
7.2 构建UDP服务
但在UDP中,一个套接字可以与多个UDP服务通信,它虽然提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是由于它无须连接,资源消耗低,处理快速且灵活,所以常常应用在那种偶尔丢一两个数据包也不会产生重大影响的场景,比如音频、视频等。UDP目前应用很广泛,DNS服务即是基于它实现的。
7.2.1 创建UDP套接字
7.2.1 创建UDP套接字
创建UDP套接字十分简单,UDP套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器端接收数据。下面的代码创建了一个UDP套接字:var dgram = require(‘dgram’);
var socket = dgram.createSocket(“udp4”);
7.2.2 创建UDP服务器端
若想让UDP套接字接收网络消息,只要调用dgram.bind(port,[address])方法对网卡和端口进行绑定即可。
|
该套接字将接收所有网卡上41234端口上的消息。在绑定完成后,将触发listening事件。
7.2.4 UDP套接字事件
UDP套接字相对TCP套接字使用起来更简单,它只是一个EventEmitter的实例,而非Stream的实例。
- message:当UDP套接字侦听网卡端口后,接收到消息时触发该事件,触发携带的数据为消息Buffer对象和一个远程地址信息。
- listening:当UDP套接字开始侦听时触发该事件
close:调用close()方法时触发该事件,并不再触发message事件。如需再次触发message事件,重新绑定即可。
error:当异常发生时触发该事件,如果不侦听,异常将直接抛出,使进程退出。
7.3 构建HTTP服务
TCP与UDP都属于网络传输层协议,如果要构造高效的网络应用,就应该从传输层进行着手
但是对于经典的应用场景,则无须从传输层协议入手构造自己的应用,比如HTTP或SMTP等,这些经典的应用层协议对于普通应用而言绰绰有余。Node提供了基本的http和https模块用于HTTP和HTTPS的封装,对于其他应用层协议的封装,也能从社区中轻松找到其实现。
在Node中构建HTTP服务极其容易,Node官网上的经典例子就展示了如何用寥寥几行代码实现一个HTTP服务器,代码如下:
|
尽管这个HTTP服务器简单到只能回复Hello World,但是它能维持的并发量和QPS都是不容小觑的,其背后的原因在第3章中有叙述,此处我们不再探讨
7.3.1 HTTP
从上述的报文信息中可以看出HTTP的特点,它是基于请求响应式的,以一问一答的方式实现服务,虽然基于TCP会话,但是本身却并无会话的特点。
从协议的角度来说,现在的应用,如浏览器,其实是一个HTTP的代理,用户的行为将会通过它转化为HTTP请求报文发送给服务器端,服务器端在处理请求后,发送响应报文给代理,代理在解析报文后,将用户需要的内容呈现在界面上
以浏览器打开一张图片地址为例:
- 首先,浏览器构造HTTP报文发向图片服务器端;
- 然后,服务器端判断报文中的要请求的地址,将磁盘中的图片文件以报文的形式发送给浏览器;
- 浏览器接收完图片后,调用渲染引擎将其显示给用户。
简而言之,HTTP服务只做两件事情:处理HTTP请求和发送HTTP响应。
7.3.2 http模块
Node的http模块包含对HTTP处理的封装。在Node中,HTTP服务继承自TCP服务器(net模块),它能够与多个客户端保持连接,由于其采用事件驱动的形式,并不为每一个连接创建额外的线程或进程,保持很低的内存占用,所以能实现高并发
HTTP服务与TCP服务模型有区别的地方在于,在开启keepalive后,一个TCP会话可以用于多次请求和响应。TCP服务以connection为单位进行服务,HTTP服务以request为单位进行服务。http模块即是将connection到request的过程进行了封装
http模块将连接所用套接字的读写抽象为ServerRequest和ServerResponse对象,它们分别对应请求和响应操作。
对于TCP连接的读操作,http模块将其封装为ServerRequest对象。让我们再次查看前面的请求报文,报文头部将会通过http_parser进行解析。请求报文的代码如下所示:
|
报文头第一行GET / HTTP/1.1被解析之后分解为如下属性。
req.method
属性 : 值为GET,是为请求方法,常见的请求方法有GET、POST、DELETE、PUT、CONNECT等几种。req.url属性:值为/。req.httpVersion
属性:值为1.1。其余报头是很规律的Key: Value格式,被解析后放置在req.headers属性上传递给业务逻辑以供调用,如下所示:
|
报文体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则要在这个数据流结束后才能进行操作,如下所示:
|
HTTP请求对象和HTTP响应对象是相对较底层的封装,现行的Web框架如Connect和Express都是在这两个对象的基础上进行高层封装完成的。
HTTP响应
再来看看HTTP响应对象。HTTP响应相对简单一些,它封装了对底层连接的写操作,可以将其看成一个可写的流对象。它影响响应报文头部信息的API为res.setHeader()和res.writeHead()。
|
其分为setHeader()和writeHead()两个步骤。它在http模块的封装下,实际生成如下报文:
|
报文体部分则是调用res.write()和res.end()方法实现,后者与前者的差别在于res.end()会先调用write()发送数据,然后发送信号告知服务器这次响应结束,响应结果如下所示:
|
响应结束后,HTTP服务器可能会将当前的连接用于下一个请求,或者关闭连接。值得注意的是,报头是在报文体发送前发送的,一旦开始了数据的发送,writeHead()和setHeader()将不再生效。这由协议的特性决定。
另外,无论服务器端在处理业务逻辑时是否发生异常,务必在结束时调用res.end()结束请求,否则客户端将一直处于等待的状态。当然,也可以通过延迟res.end()的方式实现客户端与服务器端之间的长连接,但结束时务必关闭连接。
HTTP服务的事件
如同TCP服务一样,HTTP服务器也抽象了一些事件,以供应用层使用,同样典型的是,服务器也是一个EventEmitter实例。
- connection事件:在开始HTTP请求和响应前,客户端与服务器端需要建立底层的TCP连接,这个连接可能因为开启了keep-alive,可以在多次请求响应之间使用;当这个连接建立时,服务器触发一次connection事件。
- request事件:建立TCP连接后,http模块底层将在数据流中抽象出HTTP请求和HTTP响应,当请求数据发送到服务器端,在解析出HTTP请求头后,将会触发该事件;在res.end()后,TCP连接可能将用于下一次请求响应。
- close事件:与TCP服务器的行为一致,调用server.close()方法停止接受新的连接,当已有的连接都断开时,触发该事件;可以给server.close()传递一个回调函数来快速注册该事件
- checkContinue事件:某些客户端在发送较大的数据时,并不会将数据直接发送,而是先发送一个头部带Expect: 100-continue的请求到服务器,服务器将会触发checkContinue事件;如果没有为服务器监听这个事件,服务器将会自动响应客户端100 Continue的状态码,表示接受数据上传;如果不接受数据的较多时,响应客户端400Bad Request拒绝客户端继续发送数据即可。需要注意的是,当该事件发生时不会触发request事件,两个事件之间互斥。当客户端收到100 Continue后重新发起请求时,才会触发request事件
- connect事件:当客户端发起CONNECT请求时触发,而发起CONNECT请求通常在HTTP代理时出现;如果不监听该事件,发起该请求的连接将会关闭。
- upgrade事件:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上Upgrade字段,服务器端会在接收到这样的请求时触发该事件。这在后文的WebSocket部分有详细流程的介绍。如果不监听该事件,发起该请求的连接将会关闭。
- clientError事件:连接的客户端触发error事件时,这个错误会传递到服务器端,此时触发该事件。
7.3.3 HTTP客户端
在对服务器端的实现进行了描述后,HTTP客户端的原理几乎不用再描述,因为它就是服务器端服务模型的另一部分,处于HTTP的另一端,在整个报文的参与中,报文头和报文体由它产生。同时http模块提供了一个底层API:http.request(options, connect),用于构造HTTP客户端。
|
host:服务器的域名或IP地址,默认为localhost。
hostname:服务器名称。
port:服务器端口,默认为80。
localAddress:建立网络连接的本地网卡。
socketPath:Domain套接字路径。
method:HTTP请求方法,默认为GET。
path:请求路径,默认为/。
headers:请求头对象。
auth:Basic认证,这个值将被计算成请求头中的Authorization部分。
报文体的内容由请求对象的write()和end()方法实现:通过write()方法向连接中写入数据,通过end()方法告知报文结束。它与浏览器中的Ajax调用几近相同,Ajax的实质就是一个异步的网络HTTP请求。
HTTP客户端的响应对象与服务器端较为类似,在ClientRequest对象中,它的事件叫做response。ClientRequest在解析响应报文时,一解析完响应头就触发response事件,同时传递一个响应对象以供操作ClientResponse。后续响应报文体以只读流的方式提供,如下所示:
|
由于从响应读取数据与服务器端ServerRequest读取数据的行为较为类似,此处不再赘述。
如同服务器端的实现一般,http提供的ClientRequest对象也是基于TCP层实现的,在keepalive的情况下,一个底层会话连接可以多次用于请求。为了重用TCP连接,http模块包含一个默认的客户端代理对象http.globalAgent。它对每个服务器端(host + port)创建的连接进行了管理,默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多可以创建5个连接。它的实质是一个连接池,
调用HTTP客户端同时对一个服务器发起10次HTTP请求时,其实质只有5个请求处于并发状态,后续的请求需要等待某个请求完成服务后才真正发出。这与浏览器对同一个域名有下载连接数的限制是相同的行为。
如果你在服务器端通过ClientRequest调用网络中的其他HTTP服务,记得关注代理对象对网络请求的限制。一旦请求量过大,连接限制将会限制服务性能。如需要改变,可以在options中传递agent选项。默认情况下,请求会采用全局的代理对象,默认连接数限制的为5
我们既可以自行构造代理对象,代码如下:
|
也可以设置agent选项为false值,以脱离连接池的管理,使得请求不受并发的限制。
Agent对象的sockets和requests属性分别表示当前连接池中使用中的连接数和处于等待状态的请求数,在业务中监视这两个值有助于发现业务状态的繁忙程度。
与服务器端对应的,HTTP客户端也有相应的事件。
- response:与服务器端的request事件对应的客户端在请求发出后得到服务器端响应时,会触发该事件。
- socket:当底层连接池中建立的连接分配给当前请求对象时,触发该事件
- connect:当客户端向服务器端发起CONNECT请求时,如果服务器端响应了200状态码,客户端将会触发该事件。
- upgrade:客户端向服务器端发起Upgrade请求时,如果服务器端响应了101 Switching Protocols状态,客户端将会触发该事件。
- continue:客户端向服务器端发起Expect:100-continue头信息,以试图发送较大数据量,如果服务器端响应100 Continue状态,客户端将触发该事件。
7.4 构建WebSocket服务
提到Node,不能错过的是WebSocket协议。它与Node之间的配合堪称完美,其理由有两条。
WebSocket客户端基于事件的编程模型与Node中自定义事件相差无几。WebSocket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大量的客户端保持高并发连接。
客户端与服务器端只建立一个TCP连接,可以使用更少的连接。
WebSocket服务器端可以推送数据到客户端,这远比HTTP请求响应模式更灵活、更高效。有更轻量级的协议头,减少数据传送量。
WebSocket最早是作为HTML5重要特性而出现的,最终在W3C和IETF的推动下,形成RFC
6455规范。现代浏览器大多都支持WebSocket协议,接下来我们用一段代码来展现WebSocket在客户端的应用示例:
|
浏览器与服务器端创建WebSocket协议请求,在请求完成后连接打开,每50毫秒向服务器端发送一次数据,同时可以通过onmessage()方法接收服务器端传来的数据。这行为与TCP客户端十分相似,相较于HTTP,它能够双向通信。浏览器一旦能够使用WebSocket,可以想象应用的使用空间极大。
在WebSocket之前,网页客户端与服务器端进行通信最高效的是Comet技术。实现Comet技术的细节是采用长轮询(long-polling)或iframe流。长轮询的原理是客户端向服务器端发起请求,服务器端只在超时或有数据响应时断开连接(res.end());客户端在收到数据或者超时后重新发起请求。这个请求行为拖着长长的尾巴,是故用Comet(彗星)来命名它。
使用WebSocket的话,网页客户端只需一个TCP连接即可完成双向通信,在服务器端与客户端频繁通信时,无须频繁断开连接和重发请求。连接可以得到高效应用,编程模型也十分简洁。
相比HTTP,WebSocket更接近于传输层协议,它并没有在HTTP的基础上模拟服务器端的推送,而是在TCP上定义独立的协议。让人迷惑的部分在于WebSocket的握手部分是由HTTP完成的,使人觉得它可能是基于HTTP实现的。
WebSocket协议主要分为两个部分:握手和数据传输
7.4.1 WebSocket握手
WebSocket握手
客户端建立连接时,通过HTTP发起请求报文,如下所示:
|
与普通的HTTP请求协议略有区别的部分在于如下这些协议头:
- Upgrade: websocket
- Connection: Upgrade上述两个字段表示请求服务器端升级协议为WebSocket。
其中Sec-WebSocket-Key用于安全校验: - Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Key的值是随机生成的Base64编码的字符串。
服务器端接收到之后将其与字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11相连,形成字符串dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后通过sha1安全散列算法计算出结果后,再进行Base64编码,最后返回给客户端。这个算法如下所示:
|
另外,下面两个字段指定子协议和版本号:
|
服务器端在处理完请求后,响应如下报文:
|
上面的报文告之客户端正在更换协议,更新应用层协议为WebSocket协议,并在当前的套接字连接上应用新协议。剩余的字段分别表示服务器端基于Sec-WebSocket-Key
生成的字符串和选中的子协议。客户端将会校验Sec-WebSocket-Accept
的值,如果成功,将开始接下来的数据传输。
一旦WebSocket握手成功,服务器端与客户端将会呈现对等的效果,都能接收和发送消息。
7.4.2 WebSocket数据传输
WebSocket数据传输
在握手顺利完成后,当前连接将不再进行HTTP的交互,而是开始WebSocket的数据帧协议,实现客户端与服务器端的数据交换
握手完成后,客户端的onopen()将会被触发执行,代码如下:
|
服务器端则没有onopen()方法可言。为了完成TCP套接字事件到WebSocket事件的封装,需要在接收数据时进行处理,WebSocket的数据帧协议即是在底层data事件上封装完成的,代码如下:
|
同样的数据发送时,也需要做封装操作,代码如下:
|
当客户端调用send()发送数据时,服务器端触发onmessage();当服务器端调用send()发送数据时,客户端的onmessage()触发。当我们调用send()发送一条数据时,协议可能将这个数据封装为一帧或多帧数据,然后逐帧发送。
为了安全考虑,客户端需要对发送的数据帧进行掩码处理,服务器一旦收到无掩码帧(比如中间拦截破坏),连接将关闭。而服务器发送到客户端的数据帧则无须做掩码处理,同样,如果客户端收到带掩码的数据帧,连接也将关闭。
图7-7中为WebSocket数据帧的定义,每8位为一列,也即1个字节。其中每一位都有它的意义。
图7-7 WebSocket数据帧的定义
fin
: 如果这个数据帧是最后一帧,这个fin位为1,其余情况为0。当一个数据没有被分为多帧时,它既是第一帧也是最后一帧。rsv1、rsv2、rsv3
: 各为1位长,3个标识用于扩展,当有已协商的扩展时,这些值可能为1,其余情况为0。opcode
: 长为4位的操作码,可以用来表示0到15的值,用于解释当前数据帧。0表示附加数据帧,1表示文本数据帧,2表示二进制数据帧,8表示发送一个连接关闭的数据帧,9表示ping数据帧,10表示pong数据帧,其余值暂时没有定义。ping数据帧和pong数据帧用于心跳检测,当一端发送ping数据帧时,另一端必须发送pong数据帧作为响应,告知对方这一端仍然处于响应状态。masked
:表示是否进行掩码处理,长度为1。客户端发送给服务器端时为1,服务器端发送给客户端时为0。payloadlength
:一个7、7+16或7+64位长的数据位,标识数据的长度,如果值在0~125之间,那么该值就是数据的真实长度;如果值是126,则后面16位的值是数据的真实长度;如果值是127,则后面64位的值是数据的真实长度。masking key
:当masked为1时存在,是一个32位长的数据位,用于解密数据。payload data:我们的目标数据,位数为8的倍数。
客户端发送消息时,需要构造一个或多个数据帧协议报文。由于hello world!较短,不存在分割为多个数据帧的情况,又由于hello world!会以文本的方式发送,它的payload length长度为96(12字节×8位/字节),二进制表示为1100000。
7.4.3 小结
在所有的WebSocket服务器端实现中,没有比Node更贴近WebSocket的使用方式了。它们的共性有以下内容。
基于事件的编程接口。基于JavaScript,以封装良好的WebSocket实现,API与客户端可以高度相似。
另外,Node基于事件驱动的方式使得它应对WebSocket这类长连接的应用场景可以轻松地处理大量并发请求。尽管Node没有内置WebSocket的库,但是社区的ws模块封装了WebSocket的底层实现。socket.io即是在它的基础上构建实现的。
7.5 网络服务与安全
网络服务与安全
在网络中,数据在服务器端和客户端之间传递,由于是明文传递的内容,一旦在网络被人监控,数据就可能一览无余地展现在中间的窃听者面前。为此我们需要将数据加密后再进行网络传输,这样即使数据被截获和窃听,窃听者也无法知道数据的真实内容是什么。但是对于我们的应用层协议而言,如HTTP、FTP等,我们仍然希望能够透明地处理数据,而无须操心网络传输过程中的安全问题。在网景公司的NetScape浏览器推出之初就提出了 SSL(Secure Sockets Layer,安全套接层)
。SSL作为一种安全协议,它在传输层提供对网络连接加密的功能。对于应用层而言,它是透明的,数据在传递到应用层之前就已经完成了加密和解密的过程。最初的SSL应用在Web上,被服务器端和浏览器端同时支持,随后IETF将其标准化,称为 TLS(Transport Layer Security,安全传输层协议)
。
Node在网络安全上提供了3个模块,分别为crypto
、tls
、https
。其中crypto
主要用于加密解密,SHA1、MD5等加密算法都在其中有体现,在这里我们不用再提。真正用于网络的是另外两个模块,tls模块提供了与net模块类似的功能,区别在于它建立在TLS/SSL加密的TCP连接上。对于https而言,它完全与http模块接口一致,区别也仅在于它建立于安全的连接之上。
7.5.1 TLS/SSL
密钥
TLS/SSL是一个公钥/私钥的结构,它是一个非对称的结构,每个服务器端和客户端都有自己的公私钥。
公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,通过公钥加密的数据,只有通过私钥才能解密,所以在建立安全传输之前,客户端和服务器端之间需要互换公钥。客户端发送数据时要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密,如此才能完成加密解密的过程,如图7-8所示。图7-8 客户端和服务器端交换密钥Node在底层采用的是openssl实现TLS/SSL的,为此要生成公钥和私钥可以通过openssl完成。我们分别为服务器端和客户端生成私钥,如下所示:
|
上述命令生成了两个1024位长的RSA私钥文件,我们可以通过它继续生成公钥,如下所示:
|
公私钥的非对称加密虽好,但是网络中依然可能存在窃听的情况,典型的例子是中间人攻击。客户端和服务器端在交换公钥的过程中,中间人对客户端扮演服务器端的角色,对服务器端扮演客户端的角色,因此客户端和服务器端几乎感受不到中间人的存在。为了解决这种问题,数据传输过程中还需要对得到的公钥进行认证,以确认得到的公钥是出自目标服务器。如果不能保证这种认证,中间人可能会将伪造的站点响应给用户,从而造成经济损失。图7-9是中间人攻击的示意图。图7-9 中间人攻击示意图
为了解决这个问题,TLS/SSL引入了数字证书来进行认证。与直接用公钥不同,数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系。
数字证书为了确保我们的数据安全,现在我们引入了一个第三方:CA(CertificateAuthority,数字证书认证中心)
。
CA的作用是为站点颁发证书,且这个证书中具有CA通过自己的公钥和私钥实现的签名。为了得到签名证书,服务器端需要通过自己的私钥生成CSR(Certificate Signing Request,证书签名请求)文件。CA机构将通过这个文件颁发属于该服务器端的签名证书,只要通过CA机构就能验证证书是否合法。通过CA机构颁发证书通常是一个烦琐的过程,需要付出一定的精力和费用。对于中小型企业而言,多半是采用自签名证书来构建安全网络的。所谓自签名证书,就是自己扮演CA机构,给自己的服务器端颁发签名证书。以下为生成私钥、生成CSR文件、通过私钥自签名生成证书的过程:
|
图7-11 客户端通过CA验证服务器端证书的真伪过程示意图CA机构将证书颁发给服务器端后,证书在请求的过程中会被发送给客户端,客户端需要通过CA的证书验证真伪。如果是知名的CA机构,它们的证书一般预装在浏览器中。如果是自己扮演CA机构,颁发自有签名证书则不能享受这个福利,客户端需要获取到CA的证书才能进行验证。上述的过程中可以看出,签名证书是一环一环地颁发的,但是在CA那里的证书是不需要上级证书参与签名的,这个证书我们通常称为根证书。
7.5.2 TLS服务
创建服务器端将构建服务所需要的证书都备齐之后,我们通过Node的tls模块来创建一个安全的TCP服务,这个服务是一个简单的echo服务,代码如下:
|
启动上述服务后,通过下面的命令可以测试证书是否正常:
|
7.5.3 HTTPS服务
创建HTTPS服务只比HTTP服务多一个选项配置,其余地方几乎相同,
|
启动之后通过curl进行测试
由于是自签名的证书,curl工具无法验证服务器端证书是否正确,所以出现了上述的抛错,要解决上面的问题有两种方式。一种是加-k选项,让curl工具忽略掉证书的验证,这样的结果是数据依然会通过公钥加密传输,但是无法保证对方是可靠的,会存在中间人攻击的潜在风险。
另一种解决的方式是给curl设置–cacert选项,告知CA证书使之完成对服务器证书的验证,
|
7.6 总结
Node基于事件驱动和非阻塞设计,在分布式环境中尤其能发挥出它的特长,基于事件驱动可以实现与大量的客户端进行连接,非阻塞设计则让它可以更好地提升网络的响应吞吐。Node提供了相对底层的网络调用,以及基于事件的编程接口,使得开发者在这些模块上十分轻松地构建网络应用。下一章我们将在本章的基础上探讨具体的Web应用。
第08章 构建Web应用
如今看来,Web应用俨然是互联网的主角,伴随Web 1.0、Web 2.0一路走来,HTTP占据了网络中的大多数流量。随着移动互联网时代的到来,Web又开始在移动浏览器上发挥光和热。在Web标准化的努力过后,Web又开始朝向应用化发展,JavaScript在前端变得炙手可热
8.1 基础功能
非阻塞
事件机制
对于一个Web应用而言,仅仅只是上面这样的响应远远达不到业务的需求。在具体的业务中,我们可能有如下这些需求。
- 请求方法的判断。URL的路径解析。
- URL中查询字符串解析。Cookie的解析。
- Basic认证。表单数据的解析。
- 任意格式文件的上传处理。
除此之外,可能还有Session(会话)
的需求。尽管Node提供的底层API相对来说比较简单,但要完成业务需求,还需要大量的工作,仅仅一个request事件似乎无法满足这些需求。但是要实现这些需求并非难事,一切的一切,都从如下这个函数展开:
|
在第4章中,我们曾对高阶函数有过简单的介绍:我们的应用可能无限地复杂,但是只要最终结果返回一个上面的函数作为参数,传递给createServer()方法作为request事件的侦听器就可以了。
8.1.1 请求方法
在Web应用中,最常见的请求方法是GET和POST,除此之外,还有HEAD、DELETE、PUT、CONNECT等方法。请求方法存在于报文的第一行的第一个单词,通常是大写
如下为一个报文头的示例:
|
HTTP_Parser
在解析请求报文的时候,将报文头抽取出来,设置为req.method。通常,我们只需要处理GET和POST两类请求方法,但是在RESTful类Web服务中请求方法十分重要,因为它会决定资源的操作行为。PUT代表新建一个资源,POST表示要更新一个资源,GET表示查看一个资源,而DELETE表示删除一个资源。
我们可以通过请求方法来决定响应行为,如下所示:
|
8.1.2 路径解析
路径解析
除了根据请求方法来进行分发外,最常见的请求判断莫过于路径的判断了。路径部分存在于报文的第一行的第二部分,如下所示:
|
HTTP_Parser
将其解析为req.url。一般而言,完整的URL地址是如下这样的:
|
客户端代理(浏览器)会将这个地址解析成报文,将路径和查询部分放在报文第一行。需要注意的是,hash部分会被丢弃,不会存在于报文的任何地方。
8.1.3 查询字符串
查询字符串
查询字符串位于路径之后,在地址栏中路径后的?foo=bar&baz=val字符串就是查询字符串。这个字符串会跟随在路径后,形成请求报文首行的第二部分。这部分内容经常需要为业务逻辑所用,Node提供了querystring模块用于处理这部分数据,如下所示:
|
更简洁的方法是给url.parse()传递第二个参数,如下所示:
|
它会将foo=bar&baz=val解析为一个JSON对象,如下所示:
|
8.1.4 Cookie
初识Cookie
在Web应用中,请求路径和查询字符串对业务至关重要,通过它们已经可以进行很多业务操作了,但是HTTP是一个无状态的协议,现实中的业务却是需要一定的状态的,否则无法区分用户之间的身份。如何标识和认证一个用户,最早的方案就是Cookie(曲奇饼)了
Cookie的处理分为如下几步。
- 服务器向客户端发送Cookie。浏览器将Cookie保存。
之后每次浏览器都会将Cookie发向服务器端。客户端发送的Cookie在请求报文的Cookie字段中,我们可以通过curl工具构造这个字段,如下所示:curl -v -H "Cookie: foo=bar; baz=val"
“http://127.0.0.1:1337/path?foo=bar&foo=baz“
HTTP_Parser会将所有的报文字段解析到req.headers上,那么Cookie就是req.headers.cookie。根据规范中的定义,Cookie值的格式是key=value; key2=value2形式的,如果我们需要Cookie,解析它也十分容易,如下所示:
|
在业务逻辑代码执行之前,我们将其挂载在req对象上,让业务代码可以直接访问,如下所示:
|
任何请求报文中,如果Cookie值没有isVisit,都会收到“欢迎第一次来到动物园”这样的响应。这里提出一个问题,如果识别到用户没有访问过我们的站点,那么我们的站点是否应该告诉客户端已经访问过的标识呢?告知客户端的方式是通过响应报文实现的,响应的Cookie值在Set-Cookie字段中。它的格式与请求中的格式不太相同,规范中对它的定义如下所示:
|
其中name=value是必须包含的部分,其余部分皆是可选参数。这些可选参数将会影响浏览器在后续将Cookie发送给服务器端的行为
path表示这个Cookie影响到的路径,当前访问的路径不满足该匹配时,浏览器则不发送这个Cookie。
Expires和Max-Age是用来告知浏览器这个Cookie何时过期的,如果不设置该选项,在关闭浏览器时会丢失掉这个Cookie。如果设置了过期时间,浏览器将会把Cookie内容写入到磁盘中并保存,下次打开浏览器依旧有效。Expires的值是一个UTC格式的时间字符串,告知浏览器此Cookie何时将过期,Max-Age则告知浏览器此Cookie多久后过期。前者一般而言不存在问题,但是如果服务器端的时间和客户端的时间不能匹配,这种时间设置就会存在偏差。为此,Max-Age告知浏览器这条Cookie多久之后过期,而不是一个具体的时间点。
HttpOnly告知浏览器不允许通过脚本document.cookie去更改这个Cookie值,事实上,设置HttpOnly之后,这个值在document.cookie中不可见。但是在HTTP请求的过程中,依然会发送这个Cookie到服务器端。
Secure。当Secure值为true时,在HTTP中是无效的,在HTTPS中才有效,表示创建的Cookie只能在HTTPS连接中被浏览器传递到服务器端进行会话验证,如果是HTTP连接则不会传递该信息,所以很难被窃听到。
略改前文的访问逻辑,我们就能轻松地判断用户的状态了,如下所示:
|
值得注意的是,Set-Cookie是较少的,在报头中可能存在多个字段。为此res.setHeader的第二个参数可以是一个数组,如下所示:
|
这会在报文头部中形成两条Set-Cookie字段:
|
Cookie的性能影响
由于Cookie的实现机制,一旦服务器端向客户端发送了设置Cookie的意图,除非Cookie过期,否则客户端每次请求都会发送这些Cookie到服务器端,一旦设置的Cookie过多,将会导致报头较大。大多数的Cookie并不需要每次都用上,因为这会造成带宽的部分浪费。
在YSlow的性能优化规则中有这么一条:减小Cookie的大小
更严重的情况是,如果在域名的根节点设置Cookie,几乎所有子路径下的请求都会带上这些Cookie,这些Cookie在某些情况下是有用的,但是在有些情况下是完全无用的。其中以静态文件最为典型,静态文件的业务定位几乎不关心状态,Cookie对它而言几乎是无用的,但是一旦有Cookie设置到相同域下,它的请求中就会带上Cookie。
好在Cookie在设计时限定了它的域,只有域名相同时才会发送。
所以YSlow中有另外一条规则用来避免Cookie带来的性能影响。为静态组件使用不同的域名
简而言之就是,为不需要Cookie的组件换个域名可以实现减少无效Cookie的传输。所以很多网站的静态文件会有特别的域名,使得业务相关的Cookie不再影响静态资源。当然换用额外的域名带来的好处不只这点,还可以突破浏览器下载线程数量的限制,因为域名不同,可以将下载线程数翻倍。但是换用额外域名还是有一定的缺点的,那就是将域名转换为IP需要进行DNS查询,多一个域名就多一次DNS查询。
YSlow中有这样一条规则:减少DNS查询
看起来减少DNS查询和使用不同的域名是冲突的两条规则,但是好在现今的浏览器都会进行DNS缓存,以削弱这个副作用的影响。
Cookie除了可以通过后端添加协议头的字段设置外,在前端浏览器中也可以通过JavaScript进行修改,浏览器将Cookie通过document.cookie暴露给了JavaScript。前端在修改Cookie之后,后续的网络请求中就会携带上修改过后的值
目前,广告和在线统计领域是最为依赖Cookie的,通过嵌入第三方的广告或者统计脚本,将Cookie和当前页面绑定,这样就可以标识用户,得到用户的浏览行为,广告商就可以定向投放广告了。尽管这样的行为看起来很可怕,但是从Cookie的原理来说,它只能做到标识,而不能做任何具有破坏性的事情。如果依然担心自己站点的用户被记录下行为,那就不要挂任何第三方的脚本
8.1.5 Session
通过Cookie,浏览器和服务器可以实现状态的记录。但是Cookie并非是完美的,前文提及的体积过大就是一个显著的问题,最为严重的问题是Cookie可以在前后端进行修改,因此数据就极容易被篹改和伪造。如果服务器端有部分逻辑是根据Cookie中的isVIP字段进行判断,那么一个普通用户通过修改Cookie就可以轻松享受到VIP服务了。综上所述,Cookie对于敏感数据的保护是无效的
为了解决Cookie敏感数据的问题,Session应运而生。Session的数据只保留在服务器端,客户端无法修改,这样数据的安全性得到一定的保障,数据也无须在协议中每次都被传递。
虽然在服务器端存储数据十分方便,但是如何将每个客户和服务器中的数据一一对应起来,这里有常见的两种实现方式。
- 第一种:基于Cookie来实现用户和数据的映射
虽然将所有数据都放在Cookie中不可取,但是将口令放在Cookie中还是可以的。因为口令一旦被篹改,就丢失了映射关系,也无法修改服务器端存在的数据了。并且Session的有效期通常较短,普遍的设置是20分钟,如果在20分钟内客户端和服务器端没有交互产生,服务器端就将数据删除。由于数据过期时间较短,且在服务器端存储数据,因此安全性相对较高。那么口令是如何产生的呢?一旦服务器端启用了Session,它将约定一个键值作为Session的口令,这个值可以随意约定,比如Connect默认采用connect_uid,Tomcat会采用jsessionid等。一旦服务器检查到用户请求Cookie中没有携带该值,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间。
以下为生成session的代码:
|
每个请求到来时,检查Cookie中的口令与服务器端的数据,如果过期,就重新生成,如下所示:
|
当然仅仅重新生成Session还不足以完成整个流程,还需要在响应给客户端时设置新的值,以便下次请求时能够对应服务器端的数据。这里我们hack响应对象的writeHead()方法,在它的内部注入设置Cookie的逻辑,
如下所示:
|
session在前后端进行对应的过程就完成了。这样的业务逻辑可以判断和设置session,以此来维护用户与服务器端的关系
|
这样在session中保存的数据比直接在Cookie中保存数据要安全得多。这种实现方案依赖Cookie实现,而且也是目前大多数Web应用的方案。如果客户端禁止使用Cookie,这个世界上大多数的网站将无法实现登录等操作。
第二种:通过查询字符串来实现浏览器端和服务器端数据的对应
它的原理是检查请求的查询字符串,如果没有值,会先生成新的带值的URL,如下所示:
|
然后形成跳转,让客户端重新发起请求,如下所示:
|
用户访问http://localhost/pathname时,如果服务器端发现查询字符串中不带`session_id` 参数,就会将用户跳转到http://localhost/pathname?session_id=12344567
这样一个类似的地址。如果浏览器收到302状态码和Location报头,就会重新发起新的请求,如下所示:
|
这样,新的请求到来时就能通过Session的检查,除非内存中的数据过期。
有的服务器在客户端禁用Cookie时,会采用这种方案实现退化。通过这种方案,无须在响应时设置Cookie。但是这种方案带来的风险远大于基于Cookie实现的风险,因为只要将地址栏中的地址发给另外一个人,那么他就拥有跟你相同的身份。Cookie的方案在换了浏览器或者换了电脑之后无法生效,相对较为安全。
还有一种比较有趣的处理Session的方式是利用HTTP请求头中的ETag,同样对于更换浏览器和电脑后也是无效的
Session与内存
在上面的示例代码中,我们都将Session数据直接存在变量sessions中,它位于内存中。然而在第5章的内存控制部分,我们分析了为什么Node会存在内存限制,这里将数据存放在内存中将会带来极大的隐患,如果用户增多,我们很可能就接触到了内存限制的上限,并且内存中的数据量加大,必然会引起垃圾回收的频繁扫描,引起性能问题。另一个问题则是我们可能为了利用多核CPU而启动多个进程,这个细节在第9章中有详细描述。用户请求的连接将可能随意分配到各个进程中,Node的进程与进程之间是不能直接共享内存的,用户的Session可能会引起错乱。
为了解决性能问题和Session数据无法跨进程共享的问题,常用的方案是将Session集中化,将原本可能分散在多个进程里的数据,统一转移到集中的数据存储中。目前常用的工具是Redis、Memcached等,通过这些高效的缓存,Node进程无须在内部维护数据对象,垃圾回收问题和内存限制问题都可以迎刃而解,并且这些高速缓存设计的缓存过期策略更合理更高效,比在Node中自行设计缓存策略更好。
采用第三方缓存来存储Session引起的一个问题是会引起网络访问。理论上来说访问网络中的数据要比访问本地磁盘中的数据速度要慢,因为涉及到握手、传输以及网络终端自身的磁盘I/O等,尽管如此但依然会采用这些高速缓存的理由有以下几条:
- Node与缓存服务保持长连接,而非频繁的短连接,握手导致的延迟只影响初始化。
- 高速缓存直接在内存中进行数据存储和访问。
- 缓存服务通常与Node进程运行在相同的机器上或者相同的机房里,网络速度受到的影响较小。
尽管采用专门的缓存服务会比直接在内存中访问慢,但其影响小之又小,带来的好处却远远大于直接在Node中保存数据。为此,一旦Session需要异步的方式获取,代码就需要略作调整,变成异步的方式,如下所示:
|
在响应时,将新的session保存回缓存中,如下所示:
|
Session与安全
从前文可以知道,尽管我们的数据都放置在后端了,使得它能保障安全,但是无论通过Cookie,还是查询字符串的实现方式,Session的口令依然保存在客户端,这里会存在口令被盗用的情况。如果Web应用的用户十分多,自行设计的随机算法的一些口令值就有理论机会命中有效的口令值。一旦口令被伪造,服务器端的数据也可能间接被利用。这里提到的Session的安全,就主要指如何让这个口令更加安全。有一种做法是将这个口令通过私钥加密进行签名,使得伪造的成本较高。客户端尽管可以伪造口令值,但是由于不知道私钥值,签名信息很难伪造。如此,我们只要在响应时将口令和签名进行对比,如果签名非法,我们将服务器端的数据立即过期即可,如下所示:
|
在响应时,设置session值到Cookie中或者跳转URL中,如下所示:
|
接收请求时,检查签名,如下所示:
|
这样一来,即使攻击者知道口令中.号前的值是服务器端Session的ID值,只要不知道secret私钥的值,就无法伪造签名信息,以此实现对Session的保护。该方法被Connect中间件框架所使用,保护好私钥,就是在保障自己Web应用的安全。
当然,将口令进行签名是一个很好的解决方案,但是如果攻击者通过某种方式获取了一个真实的口令和签名,他就能实现身份的伪装。一种方案是将客户端的某些独有信息与口令作为原值,然后签名,这样攻击者一旦不在原始的客户端上进行访问,就会导致签名失败。这些独有信息包括用户IP和用户代理(User Agent)。但是原始用户与攻击者之间也存在上述信息相同的可能性,如局域网出口IP相同,相同的客户端信息等,不过纳入这些考虑能够提高安全性。通常而言,将口令存在Cookie中不容易被他人获取,但是一些别的漏洞可能导致这个口令被泄漏,典型的有XSS漏洞,下面简单介绍一下如何通过XSS拿到用户的口令,实现伪造。
XSS
的全称是跨站脚本攻击(CrossSite Scripting,通常简称为XSS)
通常都是由网站开发者决定哪些脚本可以执行在浏览器端,不过XSS漏洞会让别的脚本执行。它的主要形成原因多数是用户的输入没有被转义,而被直接执行。
这段代码将该用户的Cookie提交给了c.com站点,这个站点就是攻击者的服务器,他也就能拿到该用户的Session口令。然后他在客户端中用这个口令伪造Cookie,从而实现了伪装用户的身份。如果该用户是网站管理员,就可能造成极大的危害。
XSS造成的危害远远不止这些,这里不再过多介绍。在这个案例中,如果口令中有用户的客户端信息的签名,即使口令被泄漏,除非攻击者与用户客户端完全相同,否则不能实现伪造。
8.1.6 缓存
我们知道软件的架构经历过一次C/S
模式到B/S
模式的演变,在HTTP之上构建的应用,其客户端除了比普通桌面应用具备更轻量的升级和部署等特性外,在跨平台、跨浏览器、跨设备上也具备独特优势。传统客户端在安装后的应用过程中仅仅需要传输数据,Web应用还需要传输构成界面的组件(HTML、JavaScript、CSS文件等)。这部分内容在大多数场景下并不经常变更,却需要在每次的应用中向客户端传递,如果不进行处理,那么它将造成不必要的带宽浪费。如果网络速度较差,就需要花费更多时间来打开页面,对于用户的体验将会造成一定影响。因此节省不必要的传输,对用户和对服务提供者来说都有好处。
为了提高性能,YSlow中也提到几条关于缓存的规则。添加Expires或Cache-Control到报文头中。
配置ETags。让Ajax可缓存。
这里我们将展开这几条规则的来源。如何让浏览器缓存我们的静态资源,这也是一个需要由服务器与浏览器共同协作完成的事情。
RFC 2616规范对此有一定的描述,只有遵循约定,整个缓存机制才能有效建立。通常来说,POST、DELETE、PUT这类带行为性的请求操作一般不做任何缓存,大多数缓存只应用在GET请求中。使用缓存的流程如图8-1所示。
简单来讲,本地没有文件时,浏览器必然会请求服务器端的内容,并将这部分内容放置在本地的某个缓存目录中。在第二次请求时,它将对本地文件进行检查,如果不能确定这份本地文件是否可以直接使用,它将会发起一次条件请求。所谓条件请求,就是在普通的GET请求报文中,附带If-Modified-Since字段,如下所示:
If-Modified-Since: Sun, 03 Feb 2013 06:01:12 GMT它将询问服务器端是否有更新的版本,本地文件的最后修改时间。如果服务器端没有新的版本,只需响应一个304状态码,客户端就使用本地版本。如果服务器端有新的版本,就将新的内容发送给客户端,客户端放弃本地版本。代码如下所示:
|
这里的条件请求采用时间戳的方式实现,但是时间戳有一些缺陷存在。
文件的时间戳改动但内容并不一定改动。时间戳只能精确到秒级别,更新频繁的内容将无法生效。
为此HTTP1.1中引入了ETag来解决这个问题。ETag的全称是Entity Tag,由服务器端生成,服务器端可以决定它的生成规则。如果根据文件内容生成散列值,那么条件请求将不会受到时间戳改动造成的带宽浪费。下面是根据内容生成散列值的方法:
|
与If-Modified-Since/Last-Modified不同的是,ETag的请求和响应是If-None-Match/ETag,如下所示:
|
浏览器在收到
ETag: “83-1359871272000”
这样的请求后,在下次的请求中,会将其放置在请求头中:
If-None-Match:”83-1359871272000”。
尽管条件请求可以在文件内容没有修改的情况下节省带宽,但是它依然会发起一个HTTP请求,使得客户端依然会花一定时间来等待响应。可见最好的方案就是连条件请求都不用发起。那么如何让浏览器知晓是否能直接使用本地版本呢?答案就是服务器端在响应内容时,让浏览器明确地将内容缓存起来。如同YSlow规则里提到的,在响应里设置Expires或Cache-Control头,浏览器将根据该值进行缓存。那么这两个值有何区别呢?
|
Expires是一个GMT格式的时间字符串。浏览器在接到这个过期值后,只要本地还存在这个缓存文件,在到期时间之前它都不会再发起请求。YUI3的CDN实践是缓存文件在10年后过期。但是Expires的缺陷在于浏览器与服务器之间的时间可能不一致,这可能会带来一些问题,比如文件提前过期,或者到期后并没有被删除。
Cache-Control以更丰富的形式,实现相同的功能,如下所示:
|
上面的代码为Cache-Control设置了max-age值,它比Expires优秀的地方在于,Cache-Control能够避免浏览器端与服务器端时间不同步带来的不一致性问题,只要进行类似倒计时的方式计算过期时间即可。除此之外,Cache-Control的值还能设置public、private、no-cache、no-store等能够更精细地控制缓存的选项。
由于在HTTP1.0时还不支持max-age,如今的服务器端在模块的支持下多半同时对Expires和Cache-Control进行支持。在浏览器中如果两个值同时存在,且被同时支持时,max-age会覆盖Expires。
清除缓存
虽然我们知晓了如何设置缓存,以达到节省网络带宽的目的,但是缓存一旦设定,当服务器端意外更新内容时,却无法通知客户端更新。这使得我们在使用缓存时也要为其设定版本号,所幸浏览器是根据URL进行缓存,那么一旦内容有所更新时,我们就让浏览器发起新的URL请求,使得新内容能够被客户端更新。一般的更新机制有如下两种。每次发布,路径中跟随Web应用的版本号:http://url.com/?v=20130501
.
每次发布,路径中跟随该文件内容的hash值:http://url.com/?hash=afadfadwe
。大体来说,根据文件内容的hash值进行缓存淘汰会更加高效,因为文件内容不一定随着Web应用的版本而更新,而内容没有更新时,版本号的改动导致的更新毫无意义,因此以文件内容形成的hash值更精准。
8.1.7 Basic认证
Basic认证
Basic认证是当客户端与服务器端进行请求时,允许通过用户名和密码实现的一种身份认证方式。这里简要介绍它的原理和它在服务器端通过Node处理的流程。
如果一个页面需要Basic认证,它会检查请求报文头中的Authorization字段的内容,该字段的值由认证方式和加密值构成,如下所示:
|
在Basic认证中,它会将用户和密码部分组合: username\+ ":" + password
。然后进行Base64编码,如下所示:
|
如果用户首次访问该网页,URL地址中也没携带认证内容,那么浏览器会响应一个401未授权的状态码,如下所示:
|
在上面的代码中,响应头中的WWW-Authenticate
字段告知浏览器采用什么样的认证和加密方式。一般而言,未认证的情况下,浏览器会弹出对话框进行交互式提交认证信息,如图8-2所示。
图8-2 浏览器弹出的交互式提交认证信息的对话框当认证通过,服务器端响应200状态码之后,浏览器会保存用户名和密码口令,在后续的请求中都携带上Authorization信息。
Basic认证有太多的缺点,它虽然经过Base64加密后在网络中传送,但是这近乎于明文,十分危险,一般只有在HTTPS的情况下才会使用。不过Basic认证的支持范围十分广泛,几乎所有的浏览器都支持它。为了改进Basic认证,RFC 2069规范提出了摘要访问认证,它加入了服务器端随机数来保护认证过程,在此不做深入的解释。
8.2 数据上传
数据上传
上述的内容基本都集中在HTTP请求报文头中,适用于GET请求和大多数其他请求。头部报文中的内容已经能够让服务器端进行大多数业务逻辑操作了,但是单纯的头部报文无法携带大量的数据,在业务中,我们往往需要接收一些数据,比如表单提交、文件提交、JSON上传、XML上传等。
Node的http模块只对HTTP报文的头部进行了解析,然后触发request事件。如果请求中还带有内容部分(如POST请求,它具有报头和内容),内容部分需要用户自行接收和解析。通过报头的Transfer-Encoding或Content-Length即可判断请求中是否带有内容,如下所示:
|
在HTTP_Parser解析报头结束后,报文内容部分会通过data事件触发,我们只需以流的方式处理即可,如下所示:
|
将接收到的Buffer列表转化为一个Buffer对象后,再转换为没有乱码的字符串,暂时挂置在req.rawBody处。
8.2.1 表单数据
表单数据
最为常见的数据提交就是通过网页表单提交数据到服务器端,如下所示:
|
默认的表单提交,请求头中的Content-Type
字段值为application/x-www-form-urlencoded
,
如下所示:
|
由于它的报文体内容跟查询字符串相同:
|
因此解析它十分容易:
|
后续业务中直接访问req.body
就可以得到表单中提交的数据。
8.2.2 其他格式
除了表单数据外,常见的提交还有JSON和XML文件等,判断和解析他们的原理都比较相似,都是依据Content-Type中的值决定,其中JSON类型的值为application/json,XML的值为application/xml。
需要注意的是,在Content-Type中可能还附带如下所示的编码信息:Content-Type: application/json; charset=utf-8
|
解析XML文件稍微复杂一点,但是社区有支持XML文件到JSON对象转换的库,这里以xml2js模块为例,如下所示:
|
8.2.3 附件上传
附件上传
除了常见的表单和特殊格式的内容提交外,还有一种比较独特的表单。通常的表单,其内容可以通过urlencoded的方式编码内容形成报文体,再发送给服务器端,但是业务场景往往需要用户直接提交文件。在前端HTML代码中,特殊表单与普通表单的差异在于该表单中可以含有file类型的控件,以及需要指定表单属性enctype为multipart/form-data,如下所示:
浏览器在遇到multipart/form-data表单提交时,构造的请求报文与普通表单完全不同。首先它的报头中最为特殊的如下所示:
|
它代表本次提交的内容是由多部分构成的,其中boundary=AaB03x
指定的是每部分内容的分界符,AaB03x是随机生成的一段字符串,报文体的内容将通过在它前面添加–进行分割,报文结束时在它前后都加上–表示结束。另外,Content-Length的值必须确保是报文体的长度。假设上面的表单选择了一个名为diveintonode.js的文件,并进行提交上传,那么生成的报文如下所示:
|
普通的表单控件的报文体如下所示:
|
文件控件形成的报文如下所示:
|
一旦我们知晓报文是如何构成的,那么解析它就变得十分容易。值得注意的一点是,由于是文件上传,那么像普通表单、JSON或XML那样先接收内容再解析的方式将变得不可接受。接收大小未知的数据量时,我们需要十分谨慎,如下所示:
|
这里我们将req这个流对象直接交给对应的解析方法,由解析方法自行处理上传的内容,或接收内容并保存在内存中,或流式处理掉
这里要介绍到的模块是formidable。它基于流式处理解析报文,将接收到的文件写入到系统的临时文件夹中,并返回对应的路径,
|
因此在业务逻辑中只要检查req.body和req.files中的内容即可。
8.2.4 数据上传与安全
数据上传与安全
Node提供了相对底层的API,通过它构建各种各样的Web应用都是相对容易的,但是在Web应用中,不得不重视与数据上传相关的安全问题。由于Node与前端JavaScript的近缘性,前端JavaScript甚至可以上传到服务器直接执行,但在这里我们并不讨论这样危险的动作,而是介绍内存和CSRF相关的安全问题。
- 内存限制
在解析表单、JSON和XML部分,我们采取的策略是先保存用户提交的所有数据,然后再解析处理,最后才传递给业务逻辑。这种策略存在潜在的问题是,它仅仅适合数据量小的提交请求,一旦数据量过大,将发生内存被占光的情况。攻击者通过客户端能够十分容易地模拟伪造大量数据,如果攻击者每次提交1 MB的内容,那么只要并发请求数量一大,内存就会很快地被吃光。要解决这个问题主要有两个方案。
限制上传内容的大小,一旦超过限制,停止接收数据,并响应400状态码。通过流式解析,将数据流导向到磁盘中,Node只保留文件路径等小数据。
流式处理在上文的文件上传中已经有所体现,这里介绍一下Connect中采用的上传数据量的限制方式,如下所示:
|
上面的代码中我们可以看到,数据是由包含Content-Length的请求报文判断是否长度超过限制的,超过则直接响应413状态码。对于没有Content-Length的请求报文,略微简略一点,在每个data事件中判定即可。一旦超过限制值,服务器停止接收新的数据片段。如果是JSON文件或XML文件,极有可能无法完成解析。对于上线的Web应用,添加一个上传大小限制十分有利于保护服务器,在遭遇攻击时,能镇定从容应对。
CSRF
CSRF的全称是Cross-Site Request Forgery
,中文意思为跨站请求伪造
。前文提及了服务器端与客户端通过Cookie来标识和认证用户,通常而言,用户通过浏览器访问服务器端的Session ID是无法被第三方知道的,但是CSRF的攻击者并不需要知道Session ID就能让用户中招。为了详细解释CSRF攻击是怎样一个过程,这里以一个留言的例子来说明。假设某个网站有这样一个留言程序,提交留言的接口如下所示:
用户通过POST提交content字段就能成功留言。
服务器端会自动从Session数据中判断是谁提交的数据,补足username和updatedAt两个字段后向数据库中写入数据,如下所示:
|
正常的情况下,谁提交的留言,就会在列表中显示谁的信息。如果某个攻击者发现了这里的接口存在CSRF漏洞,那么他就可以在另一个网站(http://domain_b.com/attack)上构造了一个表单提交,如下所示:
|
这种情况下,攻击者只要引诱某个domain_a
的登录用户访问这个domain_b
的网站,就会自动提交一个留言。由于在提交到domain_a
的过程中,浏览器会将domain_a
的Cookie发送到服务器,尽管这个请求是来自domain_b的,但是服务器并不知情,用户也不知情。以上过程就是一个CSRF攻击的过程。这里的示例仅仅是一个留言的漏洞,如果出现漏洞的是转账的接口,那么其危害程度可想而知。
尽管通过Node接收数据提交十分容易,但是安全问题还是不容忽视。好在CSRF并非不可防御,解决CSRF攻击的方案有添加随机值的方式,如下所示:
|
也就是说,为每个请求的用户,在Session中赋予一个随机值,如下所示:
|
在做页面渲染的过程中,将这个_csrf值告之前端,如下所示:
由于该值是一个随机值,攻击者构造出相同的随机值的难度相当大,所以我们只需要在接收端做一次校验就能轻易地识别出该请求是否为伪造的,如下所示:
|
_csrf字段也可以存在于查询字符串或者请求头中。
8.3.1 文件路径型
在MVC模式流行起来之前,根据文件路径执行动态脚本也是基本的路由方式,它的处理原理是Web服务器根据URL路径找到对应的文件,如/index.asp或/index.php。Web服务器根据文件名后缀去寻找脚本的解析器,并传入HTTP请求的上下文。
8.3.2 MVC
在MVC流行之前,主流的处理方式都是通过文件路径进行处理的,甚至以为是常态。直到有一天开发者发现用户请求的URL路径原来可以跟具体脚本所在的路径没有任何关系。
MVC模型的主要思想是将业务逻辑按职责分离,主要分为以下几种。
- 控制器(Controller),一组行为的集合。
- 模型(Model),数据相关的操作和封装。
- 视图(View),视图的渲染。
它的工作模式如下说明。
路由解析,根据URL寻找到对应的控制器和行为。
行为调用相关的模型,进行数据操作。
数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端。
如何根据URL做路由映射,这里有两个分支实现。一种方式是通过手工关联映射,一种是自然关联映射。前者会有一个对应的路由文件来将URL映射到对应的控制器,后者没有这样的文件。
图8-3 分层模式
手工映射
手工映射除了需要手工配置路由外较为原始外,它对URL的要求十分灵活,几乎没有格式上的限制。
手工映射十分方便,由于它对URL十分灵活,所以我们可以将两个路径都映射到相同的业务逻辑,如下所示:
|
正则匹配对于简单的路径,采用上述的硬匹配方式即可,但是如下的路径请求就完全无法满足需求了:
|
这些请求需要根据不同的用户显示不同的内容,这里只有两个用户,假如系统中存在成千上万个用户,我们就不太可能去手工维护所有用户的路由请求,因此正则匹配应运而生,我们期望通过以下的方式就可以匹配到任意用户:
|
于是我们改进我们的匹配方式,在通过use注册路由时需要将路径转换为一个正则表达式,然后通过它来进行匹配,如下所示:
|
上述正则表达式十分复杂,总体而言,它能实现如下的匹配:
|
现在我们重新改进注册部分:
|
以及匹配部分:
|
7.5.3 HTTPS服务
创建HTTPS服务只比HTTP服务多一个选项配置,其余地方几乎相同,
|
启动之后通过curl进行测试
由于是自签名的证书,curl工具无法验证服务器端证书是否正确,所以出现了上述的抛错,要解决上面的问题有两种方式。一种是加-k选项,让curl工具忽略掉证书的验证,这样的结果是数据依然会通过公钥加密传输,但是无法保证对方是可靠的,会存在中间人攻击的潜在风险。
另一种解决的方式是给curl设置–cacert选项,告知CA证书使之完成对服务器证书的验证,
|
7.6 总结
Node基于事件驱动和非阻塞设计,在分布式环境中尤其能发挥出它的特长,基于事件驱动可以实现与大量的客户端进行连接,非阻塞设计则让它可以更好地提升网络的响应吞吐。Node提供了相对底层的网络调用,以及基于事件的编程接口,使得开发者在这些模块上十分轻松地构建网络应用。下一章我们将在本章的基础上探讨具体的Web应用。
第08章 构建Web应用
如今看来,Web应用俨然是互联网的主角,伴随Web 1.0、Web 2.0一路走来,HTTP占据了网络中的大多数流量。随着移动互联网时代的到来,Web又开始在移动浏览器上发挥光和热。在Web标准化的努力过后,Web又开始朝向应用化发展,JavaScript在前端变得炙手可热
8.1 基础功能
非阻塞
事件机制
对于一个Web应用而言,仅仅只是上面这样的响应远远达不到业务的需求。在具体的业务中,我们可能有如下这些需求。
- 请求方法的判断。URL的路径解析。
- URL中查询字符串解析。Cookie的解析。
- Basic认证。表单数据的解析。
- 任意格式文件的上传处理。
除此之外,可能还有Session(会话)
的需求。尽管Node提供的底层API相对来说比较简单,但要完成业务需求,还需要大量的工作,仅仅一个request事件似乎无法满足这些需求。但是要实现这些需求并非难事,一切的一切,都从如下这个函数展开:
|
在第4章中,我们曾对高阶函数有过简单的介绍:我们的应用可能无限地复杂,但是只要最终结果返回一个上面的函数作为参数,传递给createServer()方法作为request事件的侦听器就可以了。
8.1.1 请求方法
在Web应用中,最常见的请求方法是GET和POST,除此之外,还有HEAD、DELETE、PUT、CONNECT等方法。请求方法存在于报文的第一行的第一个单词,通常是大写
如下为一个报文头的示例:
|
HTTP_Parser
在解析请求报文的时候,将报文头抽取出来,设置为req.method。通常,我们只需要处理GET和POST两类请求方法,但是在RESTful类Web服务中请求方法十分重要,因为它会决定资源的操作行为。PUT代表新建一个资源,POST表示要更新一个资源,GET表示查看一个资源,而DELETE表示删除一个资源。
我们可以通过请求方法来决定响应行为,如下所示:
|
8.1.2 路径解析
路径解析
除了根据请求方法来进行分发外,最常见的请求判断莫过于路径的判断了。路径部分存在于报文的第一行的第二部分,如下所示:
|
HTTP_Parser
将其解析为req.url。一般而言,完整的URL地址是如下这样的:
|
客户端代理(浏览器)会将这个地址解析成报文,将路径和查询部分放在报文第一行。需要注意的是,hash部分会被丢弃,不会存在于报文的任何地方。
8.1.3 查询字符串
查询字符串
查询字符串位于路径之后,在地址栏中路径后的?foo=bar&baz=val字符串就是查询字符串。这个字符串会跟随在路径后,形成请求报文首行的第二部分。这部分内容经常需要为业务逻辑所用,Node提供了querystring模块用于处理这部分数据,如下所示:
|
更简洁的方法是给url.parse()传递第二个参数,如下所示:
|
它会将foo=bar&baz=val解析为一个JSON对象,如下所示:
|
8.1.4 Cookie
初识Cookie
在Web应用中,请求路径和查询字符串对业务至关重要,通过它们已经可以进行很多业务操作了,但是HTTP是一个无状态的协议,现实中的业务却是需要一定的状态的,否则无法区分用户之间的身份。如何标识和认证一个用户,最早的方案就是Cookie(曲奇饼)了
Cookie的处理分为如下几步。
- 服务器向客户端发送Cookie。浏览器将Cookie保存。
之后每次浏览器都会将Cookie发向服务器端。客户端发送的Cookie在请求报文的Cookie字段中,我们可以通过curl工具构造这个字段,如下所示:curl -v -H "Cookie: foo=bar; baz=val"
“http://127.0.0.1:1337/path?foo=bar&foo=baz“
HTTP_Parser会将所有的报文字段解析到req.headers上,那么Cookie就是req.headers.cookie。根据规范中的定义,Cookie值的格式是key=value; key2=value2形式的,如果我们需要Cookie,解析它也十分容易,如下所示:
|
在业务逻辑代码执行之前,我们将其挂载在req对象上,让业务代码可以直接访问,如下所示:
|
任何请求报文中,如果Cookie值没有isVisit,都会收到“欢迎第一次来到动物园”这样的响应。这里提出一个问题,如果识别到用户没有访问过我们的站点,那么我们的站点是否应该告诉客户端已经访问过的标识呢?告知客户端的方式是通过响应报文实现的,响应的Cookie值在Set-Cookie字段中。它的格式与请求中的格式不太相同,规范中对它的定义如下所示:
|
其中name=value是必须包含的部分,其余部分皆是可选参数。这些可选参数将会影响浏览器在后续将Cookie发送给服务器端的行为
path表示这个Cookie影响到的路径,当前访问的路径不满足该匹配时,浏览器则不发送这个Cookie。
Expires和Max-Age是用来告知浏览器这个Cookie何时过期的,如果不设置该选项,在关闭浏览器时会丢失掉这个Cookie。如果设置了过期时间,浏览器将会把Cookie内容写入到磁盘中并保存,下次打开浏览器依旧有效。Expires的值是一个UTC格式的时间字符串,告知浏览器此Cookie何时将过期,Max-Age则告知浏览器此Cookie多久后过期。前者一般而言不存在问题,但是如果服务器端的时间和客户端的时间不能匹配,这种时间设置就会存在偏差。为此,Max-Age告知浏览器这条Cookie多久之后过期,而不是一个具体的时间点。
HttpOnly告知浏览器不允许通过脚本document.cookie去更改这个Cookie值,事实上,设置HttpOnly之后,这个值在document.cookie中不可见。但是在HTTP请求的过程中,依然会发送这个Cookie到服务器端。
Secure。当Secure值为true时,在HTTP中是无效的,在HTTPS中才有效,表示创建的Cookie只能在HTTPS连接中被浏览器传递到服务器端进行会话验证,如果是HTTP连接则不会传递该信息,所以很难被窃听到。
略改前文的访问逻辑,我们就能轻松地判断用户的状态了,如下所示:
|
值得注意的是,Set-Cookie是较少的,在报头中可能存在多个字段。为此res.setHeader的第二个参数可以是一个数组,如下所示:
|
这会在报文头部中形成两条Set-Cookie字段:
|
Cookie的性能影响
由于Cookie的实现机制,一旦服务器端向客户端发送了设置Cookie的意图,除非Cookie过期,否则客户端每次请求都会发送这些Cookie到服务器端,一旦设置的Cookie过多,将会导致报头较大。大多数的Cookie并不需要每次都用上,因为这会造成带宽的部分浪费。
在YSlow的性能优化规则中有这么一条:减小Cookie的大小
更严重的情况是,如果在域名的根节点设置Cookie,几乎所有子路径下的请求都会带上这些Cookie,这些Cookie在某些情况下是有用的,但是在有些情况下是完全无用的。其中以静态文件最为典型,静态文件的业务定位几乎不关心状态,Cookie对它而言几乎是无用的,但是一旦有Cookie设置到相同域下,它的请求中就会带上Cookie。
好在Cookie在设计时限定了它的域,只有域名相同时才会发送。
所以YSlow中有另外一条规则用来避免Cookie带来的性能影响。为静态组件使用不同的域名
简而言之就是,为不需要Cookie的组件换个域名可以实现减少无效Cookie的传输。所以很多网站的静态文件会有特别的域名,使得业务相关的Cookie不再影响静态资源。当然换用额外的域名带来的好处不只这点,还可以突破浏览器下载线程数量的限制,因为域名不同,可以将下载线程数翻倍。但是换用额外域名还是有一定的缺点的,那就是将域名转换为IP需要进行DNS查询,多一个域名就多一次DNS查询。
YSlow中有这样一条规则:减少DNS查询
看起来减少DNS查询和使用不同的域名是冲突的两条规则,但是好在现今的浏览器都会进行DNS缓存,以削弱这个副作用的影响。
Cookie除了可以通过后端添加协议头的字段设置外,在前端浏览器中也可以通过JavaScript进行修改,浏览器将Cookie通过document.cookie暴露给了JavaScript。前端在修改Cookie之后,后续的网络请求中就会携带上修改过后的值
目前,广告和在线统计领域是最为依赖Cookie的,通过嵌入第三方的广告或者统计脚本,将Cookie和当前页面绑定,这样就可以标识用户,得到用户的浏览行为,广告商就可以定向投放广告了。尽管这样的行为看起来很可怕,但是从Cookie的原理来说,它只能做到标识,而不能做任何具有破坏性的事情。如果依然担心自己站点的用户被记录下行为,那就不要挂任何第三方的脚本
8.1.5 Session
通过Cookie,浏览器和服务器可以实现状态的记录。但是Cookie并非是完美的,前文提及的体积过大就是一个显著的问题,最为严重的问题是Cookie可以在前后端进行修改,因此数据就极容易被篹改和伪造。如果服务器端有部分逻辑是根据Cookie中的isVIP字段进行判断,那么一个普通用户通过修改Cookie就可以轻松享受到VIP服务了。综上所述,Cookie对于敏感数据的保护是无效的
为了解决Cookie敏感数据的问题,Session应运而生。Session的数据只保留在服务器端,客户端无法修改,这样数据的安全性得到一定的保障,数据也无须在协议中每次都被传递。
虽然在服务器端存储数据十分方便,但是如何将每个客户和服务器中的数据一一对应起来,这里有常见的两种实现方式。
- 第一种:基于Cookie来实现用户和数据的映射
虽然将所有数据都放在Cookie中不可取,但是将口令放在Cookie中还是可以的。因为口令一旦被篹改,就丢失了映射关系,也无法修改服务器端存在的数据了。并且Session的有效期通常较短,普遍的设置是20分钟,如果在20分钟内客户端和服务器端没有交互产生,服务器端就将数据删除。由于数据过期时间较短,且在服务器端存储数据,因此安全性相对较高。那么口令是如何产生的呢?一旦服务器端启用了Session,它将约定一个键值作为Session的口令,这个值可以随意约定,比如Connect默认采用connect_uid,Tomcat会采用jsessionid等。一旦服务器检查到用户请求Cookie中没有携带该值,它就会为之生成一个值,这个值是唯一且不重复的值,并设定超时时间。
以下为生成session的代码:
|
每个请求到来时,检查Cookie中的口令与服务器端的数据,如果过期,就重新生成,如下所示:
|
当然仅仅重新生成Session还不足以完成整个流程,还需要在响应给客户端时设置新的值,以便下次请求时能够对应服务器端的数据。这里我们hack响应对象的writeHead()方法,在它的内部注入设置Cookie的逻辑,
如下所示:
|
session在前后端进行对应的过程就完成了。这样的业务逻辑可以判断和设置session,以此来维护用户与服务器端的关系
|
这样在session中保存的数据比直接在Cookie中保存数据要安全得多。这种实现方案依赖Cookie实现,而且也是目前大多数Web应用的方案。如果客户端禁止使用Cookie,这个世界上大多数的网站将无法实现登录等操作。
第二种:通过查询字符串来实现浏览器端和服务器端数据的对应
它的原理是检查请求的查询字符串,如果没有值,会先生成新的带值的URL,如下所示:
|
然后形成跳转,让客户端重新发起请求,如下所示:
|
用户访问http://localhost/pathname时,如果服务器端发现查询字符串中不带`session_id` 参数,就会将用户跳转到http://localhost/pathname?session_id=12344567
这样一个类似的地址。如果浏览器收到302状态码和Location报头,就会重新发起新的请求,如下所示:
|
这样,新的请求到来时就能通过Session的检查,除非内存中的数据过期。
有的服务器在客户端禁用Cookie时,会采用这种方案实现退化。通过这种方案,无须在响应时设置Cookie。但是这种方案带来的风险远大于基于Cookie实现的风险,因为只要将地址栏中的地址发给另外一个人,那么他就拥有跟你相同的身份。Cookie的方案在换了浏览器或者换了电脑之后无法生效,相对较为安全。
还有一种比较有趣的处理Session的方式是利用HTTP请求头中的ETag,同样对于更换浏览器和电脑后也是无效的
Session与内存
在上面的示例代码中,我们都将Session数据直接存在变量sessions中,它位于内存中。然而在第5章的内存控制部分,我们分析了为什么Node会存在内存限制,这里将数据存放在内存中将会带来极大的隐患,如果用户增多,我们很可能就接触到了内存限制的上限,并且内存中的数据量加大,必然会引起垃圾回收的频繁扫描,引起性能问题。另一个问题则是我们可能为了利用多核CPU而启动多个进程,这个细节在第9章中有详细描述。用户请求的连接将可能随意分配到各个进程中,Node的进程与进程之间是不能直接共享内存的,用户的Session可能会引起错乱。
为了解决性能问题和Session数据无法跨进程共享的问题,常用的方案是将Session集中化,将原本可能分散在多个进程里的数据,统一转移到集中的数据存储中。目前常用的工具是Redis、Memcached等,通过这些高效的缓存,Node进程无须在内部维护数据对象,垃圾回收问题和内存限制问题都可以迎刃而解,并且这些高速缓存设计的缓存过期策略更合理更高效,比在Node中自行设计缓存策略更好。
采用第三方缓存来存储Session引起的一个问题是会引起网络访问。理论上来说访问网络中的数据要比访问本地磁盘中的数据速度要慢,因为涉及到握手、传输以及网络终端自身的磁盘I/O等,尽管如此但依然会采用这些高速缓存的理由有以下几条:
- Node与缓存服务保持长连接,而非频繁的短连接,握手导致的延迟只影响初始化。
- 高速缓存直接在内存中进行数据存储和访问。
- 缓存服务通常与Node进程运行在相同的机器上或者相同的机房里,网络速度受到的影响较小。
尽管采用专门的缓存服务会比直接在内存中访问慢,但其影响小之又小,带来的好处却远远大于直接在Node中保存数据。为此,一旦Session需要异步的方式获取,代码就需要略作调整,变成异步的方式,如下所示:
|
在响应时,将新的session保存回缓存中,如下所示:
|
Session与安全
从前文可以知道,尽管我们的数据都放置在后端了,使得它能保障安全,但是无论通过Cookie,还是查询字符串的实现方式,Session的口令依然保存在客户端,这里会存在口令被盗用的情况。如果Web应用的用户十分多,自行设计的随机算法的一些口令值就有理论机会命中有效的口令值。一旦口令被伪造,服务器端的数据也可能间接被利用。这里提到的Session的安全,就主要指如何让这个口令更加安全。有一种做法是将这个口令通过私钥加密进行签名,使得伪造的成本较高。客户端尽管可以伪造口令值,但是由于不知道私钥值,签名信息很难伪造。如此,我们只要在响应时将口令和签名进行对比,如果签名非法,我们将服务器端的数据立即过期即可,如下所示:
|
在响应时,设置session值到Cookie中或者跳转URL中,如下所示:
|
接收请求时,检查签名,如下所示:
|
这样一来,即使攻击者知道口令中.号前的值是服务器端Session的ID值,只要不知道secret私钥的值,就无法伪造签名信息,以此实现对Session的保护。该方法被Connect中间件框架所使用,保护好私钥,就是在保障自己Web应用的安全。
当然,将口令进行签名是一个很好的解决方案,但是如果攻击者通过某种方式获取了一个真实的口令和签名,他就能实现身份的伪装。一种方案是将客户端的某些独有信息与口令作为原值,然后签名,这样攻击者一旦不在原始的客户端上进行访问,就会导致签名失败。这些独有信息包括用户IP和用户代理(User Agent)。但是原始用户与攻击者之间也存在上述信息相同的可能性,如局域网出口IP相同,相同的客户端信息等,不过纳入这些考虑能够提高安全性。通常而言,将口令存在Cookie中不容易被他人获取,但是一些别的漏洞可能导致这个口令被泄漏,典型的有XSS漏洞,下面简单介绍一下如何通过XSS拿到用户的口令,实现伪造。
XSS
的全称是跨站脚本攻击(CrossSite Scripting,通常简称为XSS)
通常都是由网站开发者决定哪些脚本可以执行在浏览器端,不过XSS漏洞会让别的脚本执行。它的主要形成原因多数是用户的输入没有被转义,而被直接执行。
这段代码将该用户的Cookie提交给了c.com站点,这个站点就是攻击者的服务器,他也就能拿到该用户的Session口令。然后他在客户端中用这个口令伪造Cookie,从而实现了伪装用户的身份。如果该用户是网站管理员,就可能造成极大的危害。
XSS造成的危害远远不止这些,这里不再过多介绍。在这个案例中,如果口令中有用户的客户端信息的签名,即使口令被泄漏,除非攻击者与用户客户端完全相同,否则不能实现伪造。
8.1.6 缓存
我们知道软件的架构经历过一次C/S
模式到B/S
模式的演变,在HTTP之上构建的应用,其客户端除了比普通桌面应用具备更轻量的升级和部署等特性外,在跨平台、跨浏览器、跨设备上也具备独特优势。传统客户端在安装后的应用过程中仅仅需要传输数据,Web应用还需要传输构成界面的组件(HTML、JavaScript、CSS文件等)。这部分内容在大多数场景下并不经常变更,却需要在每次的应用中向客户端传递,如果不进行处理,那么它将造成不必要的带宽浪费。如果网络速度较差,就需要花费更多时间来打开页面,对于用户的体验将会造成一定影响。因此节省不必要的传输,对用户和对服务提供者来说都有好处。
为了提高性能,YSlow中也提到几条关于缓存的规则。添加Expires或Cache-Control到报文头中。
配置ETags。让Ajax可缓存。
这里我们将展开这几条规则的来源。如何让浏览器缓存我们的静态资源,这也是一个需要由服务器与浏览器共同协作完成的事情。
RFC 2616规范对此有一定的描述,只有遵循约定,整个缓存机制才能有效建立。通常来说,POST、DELETE、PUT这类带行为性的请求操作一般不做任何缓存,大多数缓存只应用在GET请求中。使用缓存的流程如图8-1所示。
简单来讲,本地没有文件时,浏览器必然会请求服务器端的内容,并将这部分内容放置在本地的某个缓存目录中。在第二次请求时,它将对本地文件进行检查,如果不能确定这份本地文件是否可以直接使用,它将会发起一次条件请求。所谓条件请求,就是在普通的GET请求报文中,附带If-Modified-Since字段,如下所示:
If-Modified-Since: Sun, 03 Feb 2013 06:01:12 GMT它将询问服务器端是否有更新的版本,本地文件的最后修改时间。如果服务器端没有新的版本,只需响应一个304状态码,客户端就使用本地版本。如果服务器端有新的版本,就将新的内容发送给客户端,客户端放弃本地版本。代码如下所示:
|
这里的条件请求采用时间戳的方式实现,但是时间戳有一些缺陷存在。
文件的时间戳改动但内容并不一定改动。时间戳只能精确到秒级别,更新频繁的内容将无法生效。
为此HTTP1.1中引入了ETag来解决这个问题。ETag的全称是Entity Tag,由服务器端生成,服务器端可以决定它的生成规则。如果根据文件内容生成散列值,那么条件请求将不会受到时间戳改动造成的带宽浪费。下面是根据内容生成散列值的方法:
|
与If-Modified-Since/Last-Modified不同的是,ETag的请求和响应是If-None-Match/ETag,如下所示:
|
浏览器在收到
ETag: “83-1359871272000”
这样的请求后,在下次的请求中,会将其放置在请求头中:
If-None-Match:”83-1359871272000”。
尽管条件请求可以在文件内容没有修改的情况下节省带宽,但是它依然会发起一个HTTP请求,使得客户端依然会花一定时间来等待响应。可见最好的方案就是连条件请求都不用发起。那么如何让浏览器知晓是否能直接使用本地版本呢?答案就是服务器端在响应内容时,让浏览器明确地将内容缓存起来。如同YSlow规则里提到的,在响应里设置Expires或Cache-Control头,浏览器将根据该值进行缓存。那么这两个值有何区别呢?
|
Expires是一个GMT格式的时间字符串。浏览器在接到这个过期值后,只要本地还存在这个缓存文件,在到期时间之前它都不会再发起请求。YUI3的CDN实践是缓存文件在10年后过期。但是Expires的缺陷在于浏览器与服务器之间的时间可能不一致,这可能会带来一些问题,比如文件提前过期,或者到期后并没有被删除。
Cache-Control以更丰富的形式,实现相同的功能,如下所示:
|
上面的代码为Cache-Control设置了max-age值,它比Expires优秀的地方在于,Cache-Control能够避免浏览器端与服务器端时间不同步带来的不一致性问题,只要进行类似倒计时的方式计算过期时间即可。除此之外,Cache-Control的值还能设置public、private、no-cache、no-store等能够更精细地控制缓存的选项。
由于在HTTP1.0时还不支持max-age,如今的服务器端在模块的支持下多半同时对Expires和Cache-Control进行支持。在浏览器中如果两个值同时存在,且被同时支持时,max-age会覆盖Expires。
清除缓存
虽然我们知晓了如何设置缓存,以达到节省网络带宽的目的,但是缓存一旦设定,当服务器端意外更新内容时,却无法通知客户端更新。这使得我们在使用缓存时也要为其设定版本号,所幸浏览器是根据URL进行缓存,那么一旦内容有所更新时,我们就让浏览器发起新的URL请求,使得新内容能够被客户端更新。一般的更新机制有如下两种。每次发布,路径中跟随Web应用的版本号:http://url.com/?v=20130501
.
每次发布,路径中跟随该文件内容的hash值:http://url.com/?hash=afadfadwe
。大体来说,根据文件内容的hash值进行缓存淘汰会更加高效,因为文件内容不一定随着Web应用的版本而更新,而内容没有更新时,版本号的改动导致的更新毫无意义,因此以文件内容形成的hash值更精准。
8.1.7 Basic认证
Basic认证
Basic认证是当客户端与服务器端进行请求时,允许通过用户名和密码实现的一种身份认证方式。这里简要介绍它的原理和它在服务器端通过Node处理的流程。
如果一个页面需要Basic认证,它会检查请求报文头中的Authorization字段的内容,该字段的值由认证方式和加密值构成,如下所示:
|
在Basic认证中,它会将用户和密码部分组合: username\+ ":" + password
。然后进行Base64编码,如下所示:
|
如果用户首次访问该网页,URL地址中也没携带认证内容,那么浏览器会响应一个401未授权的状态码,如下所示:
|
在上面的代码中,响应头中的WWW-Authenticate
字段告知浏览器采用什么样的认证和加密方式。一般而言,未认证的情况下,浏览器会弹出对话框进行交互式提交认证信息,如图8-2所示。
图8-2 浏览器弹出的交互式提交认证信息的对话框当认证通过,服务器端响应200状态码之后,浏览器会保存用户名和密码口令,在后续的请求中都携带上Authorization信息。
Basic认证有太多的缺点,它虽然经过Base64加密后在网络中传送,但是这近乎于明文,十分危险,一般只有在HTTPS的情况下才会使用。不过Basic认证的支持范围十分广泛,几乎所有的浏览器都支持它。为了改进Basic认证,RFC 2069规范提出了摘要访问认证,它加入了服务器端随机数来保护认证过程,在此不做深入的解释。
8.2 数据上传
数据上传
上述的内容基本都集中在HTTP请求报文头中,适用于GET请求和大多数其他请求。头部报文中的内容已经能够让服务器端进行大多数业务逻辑操作了,但是单纯的头部报文无法携带大量的数据,在业务中,我们往往需要接收一些数据,比如表单提交、文件提交、JSON上传、XML上传等。
Node的http模块只对HTTP报文的头部进行了解析,然后触发request事件。如果请求中还带有内容部分(如POST请求,它具有报头和内容),内容部分需要用户自行接收和解析。通过报头的Transfer-Encoding或Content-Length即可判断请求中是否带有内容,如下所示:
|
在HTTP_Parser解析报头结束后,报文内容部分会通过data事件触发,我们只需以流的方式处理即可,如下所示:
|
将接收到的Buffer列表转化为一个Buffer对象后,再转换为没有乱码的字符串,暂时挂置在req.rawBody处。
8.2.1 表单数据
表单数据
最为常见的数据提交就是通过网页表单提交数据到服务器端,如下所示:
|
默认的表单提交,请求头中的Content-Type
字段值为application/x-www-form-urlencoded
,
如下所示:
|
由于它的报文体内容跟查询字符串相同:
|
因此解析它十分容易:
|
后续业务中直接访问req.body
就可以得到表单中提交的数据。
8.2.2 其他格式
除了表单数据外,常见的提交还有JSON和XML文件等,判断和解析他们的原理都比较相似,都是依据Content-Type中的值决定,其中JSON类型的值为application/json,XML的值为application/xml。
需要注意的是,在Content-Type中可能还附带如下所示的编码信息:Content-Type: application/json; charset=utf-8
|
解析XML文件稍微复杂一点,但是社区有支持XML文件到JSON对象转换的库,这里以xml2js模块为例,如下所示:
|
8.2.3 附件上传
附件上传
除了常见的表单和特殊格式的内容提交外,还有一种比较独特的表单。通常的表单,其内容可以通过urlencoded的方式编码内容形成报文体,再发送给服务器端,但是业务场景往往需要用户直接提交文件。在前端HTML代码中,特殊表单与普通表单的差异在于该表单中可以含有file类型的控件,以及需要指定表单属性enctype为multipart/form-data,如下所示:
浏览器在遇到multipart/form-data表单提交时,构造的请求报文与普通表单完全不同。首先它的报头中最为特殊的如下所示:
|
它代表本次提交的内容是由多部分构成的,其中boundary=AaB03x
指定的是每部分内容的分界符,AaB03x是随机生成的一段字符串,报文体的内容将通过在它前面添加–进行分割,报文结束时在它前后都加上–表示结束。另外,Content-Length的值必须确保是报文体的长度。假设上面的表单选择了一个名为diveintonode.js的文件,并进行提交上传,那么生成的报文如下所示:
|
普通的表单控件的报文体如下所示:
|
文件控件形成的报文如下所示:
|
一旦我们知晓报文是如何构成的,那么解析它就变得十分容易。值得注意的一点是,由于是文件上传,那么像普通表单、JSON或XML那样先接收内容再解析的方式将变得不可接受。接收大小未知的数据量时,我们需要十分谨慎,如下所示:
|
这里我们将req这个流对象直接交给对应的解析方法,由解析方法自行处理上传的内容,或接收内容并保存在内存中,或流式处理掉
这里要介绍到的模块是formidable。它基于流式处理解析报文,将接收到的文件写入到系统的临时文件夹中,并返回对应的路径,
|
因此在业务逻辑中只要检查req.body和req.files中的内容即可。
8.2.4 数据上传与安全
数据上传与安全
Node提供了相对底层的API,通过它构建各种各样的Web应用都是相对容易的,但是在Web应用中,不得不重视与数据上传相关的安全问题。由于Node与前端JavaScript的近缘性,前端JavaScript甚至可以上传到服务器直接执行,但在这里我们并不讨论这样危险的动作,而是介绍内存和CSRF相关的安全问题。
- 内存限制
在解析表单、JSON和XML部分,我们采取的策略是先保存用户提交的所有数据,然后再解析处理,最后才传递给业务逻辑。这种策略存在潜在的问题是,它仅仅适合数据量小的提交请求,一旦数据量过大,将发生内存被占光的情况。攻击者通过客户端能够十分容易地模拟伪造大量数据,如果攻击者每次提交1 MB的内容,那么只要并发请求数量一大,内存就会很快地被吃光。要解决这个问题主要有两个方案。
限制上传内容的大小,一旦超过限制,停止接收数据,并响应400状态码。通过流式解析,将数据流导向到磁盘中,Node只保留文件路径等小数据。
流式处理在上文的文件上传中已经有所体现,这里介绍一下Connect中采用的上传数据量的限制方式,如下所示:
|
上面的代码中我们可以看到,数据是由包含Content-Length的请求报文判断是否长度超过限制的,超过则直接响应413状态码。对于没有Content-Length的请求报文,略微简略一点,在每个data事件中判定即可。一旦超过限制值,服务器停止接收新的数据片段。如果是JSON文件或XML文件,极有可能无法完成解析。对于上线的Web应用,添加一个上传大小限制十分有利于保护服务器,在遭遇攻击时,能镇定从容应对。
CSRF
CSRF的全称是Cross-Site Request Forgery
,中文意思为跨站请求伪造
。前文提及了服务器端与客户端通过Cookie来标识和认证用户,通常而言,用户通过浏览器访问服务器端的Session ID是无法被第三方知道的,但是CSRF的攻击者并不需要知道Session ID就能让用户中招。为了详细解释CSRF攻击是怎样一个过程,这里以一个留言的例子来说明。假设某个网站有这样一个留言程序,提交留言的接口如下所示:
用户通过POST提交content字段就能成功留言。
服务器端会自动从Session数据中判断是谁提交的数据,补足username和updatedAt两个字段后向数据库中写入数据,如下所示:
|
正常的情况下,谁提交的留言,就会在列表中显示谁的信息。如果某个攻击者发现了这里的接口存在CSRF漏洞,那么他就可以在另一个网站(http://domain_b.com/attack)上构造了一个表单提交,如下所示:
|
这种情况下,攻击者只要引诱某个domain_a
的登录用户访问这个domain_b
的网站,就会自动提交一个留言。由于在提交到domain_a
的过程中,浏览器会将domain_a
的Cookie发送到服务器,尽管这个请求是来自domain_b的,但是服务器并不知情,用户也不知情。以上过程就是一个CSRF攻击的过程。这里的示例仅仅是一个留言的漏洞,如果出现漏洞的是转账的接口,那么其危害程度可想而知。
尽管通过Node接收数据提交十分容易,但是安全问题还是不容忽视。好在CSRF并非不可防御,解决CSRF攻击的方案有添加随机值的方式,如下所示:
|
也就是说,为每个请求的用户,在Session中赋予一个随机值,如下所示:
|
在做页面渲染的过程中,将这个_csrf值告之前端,如下所示:
由于该值是一个随机值,攻击者构造出相同的随机值的难度相当大,所以我们只需要在接收端做一次校验就能轻易地识别出该请求是否为伪造的,如下所示:
|
_csrf字段也可以存在于查询字符串或者请求头中。
8.3.1 文件路径型
在MVC模式流行起来之前,根据文件路径执行动态脚本也是基本的路由方式,它的处理原理是Web服务器根据URL路径找到对应的文件,如/index.asp或/index.php。Web服务器根据文件名后缀去寻找脚本的解析器,并传入HTTP请求的上下文。
8.3.2 MVC
在MVC流行之前,主流的处理方式都是通过文件路径进行处理的,甚至以为是常态。直到有一天开发者发现用户请求的URL路径原来可以跟具体脚本所在的路径没有任何关系。
MVC模型的主要思想是将业务逻辑按职责分离,主要分为以下几种。
- 控制器(Controller),一组行为的集合。
- 模型(Model),数据相关的操作和封装。
- 视图(View),视图的渲染。
它的工作模式如下说明。
路由解析,根据URL寻找到对应的控制器和行为。
行为调用相关的模型,进行数据操作。
数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端。
如何根据URL做路由映射,这里有两个分支实现。一种方式是通过手工关联映射,一种是自然关联映射。前者会有一个对应的路由文件来将URL映射到对应的控制器,后者没有这样的文件。
图8-3 分层模式
手工映射
手工映射除了需要手工配置路由外较为原始外,它对URL的要求十分灵活,几乎没有格式上的限制。
手工映射十分方便,由于它对URL十分灵活,所以我们可以将两个路径都映射到相同的业务逻辑,如下所示:
|
正则匹配对于简单的路径,采用上述的硬匹配方式即可,但是如下的路径请求就完全无法满足需求了:
|
这些请求需要根据不同的用户显示不同的内容,这里只有两个用户,假如系统中存在成千上万个用户,我们就不太可能去手工维护所有用户的路由请求,因此正则匹配应运而生,我们期望通过以下的方式就可以匹配到任意用户:
|
于是我们改进我们的匹配方式,在通过use注册路由时需要将路径转换为一个正则表达式,然后通过它来进行匹配,如下所示:
|
上述正则表达式十分复杂,总体而言,它能实现如下的匹配:
|
现在我们重新改进注册部分:
|
以及匹配部分:
|
参数解析
尽管完成了正则匹配,可以实现相似URL的匹配,但是:username到底匹配了啥,还没有解决。为此我们还需要进一步将匹配到的内容抽取出来,希望在业务中能如下这样调用:
|
这里的目标是将抽取的内容设置到req.params处。那么第一步就是将键值抽取出来,如下所示:
|
我们将根据抽取的键值和实际的URL得到键值匹配到的实际值,并设置到req.params处,如下所示:
|
至此,我们除了从查询字符串(req.query)或提交数据(req.body)中取到值外,还能从路径的映射里取到值。
自然映射手工映射的优点在于路径可以很灵活,但是如果项目较大,路由映射的数量也会很多。从前端路径到具体的控制器文件,需要进行查阅才能定位到实际代码的位置,为此有人提出,尽是路由不如无路由。实际上并非没有路由,而是路由按一种约定的方式自然而然地实现了路由,而无须去维护路由映射。
上文的路径解析部分对这种自然映射的实现有稍许介绍,简单而言,它将如下路径进行了划分处理:
/controller/action/param1/param2/param3以/user/setting/12/1987为例,它会按约定去找controllers目录下的user文件,将其require出来后,调用这个文件模块的setting()方法,而其余的值作为参数直接传递给这个方法。
|
由于这种自然映射的方式没有指明参数的名称,所以无法采用req.params的方式提取,但是直接通过参数获取更简洁,如下所示:
|
事实上手工映射也能将值作为参数进行传递,而不是通过req.params。但是这个观点见仁见智,这里不做比较和讨论。自然映射这种路由方式在PHP的MVC框架CodeIgniter中应用十分广泛,设计十分简洁,在Node中实现它也十分容易。与手工映射相比,如果URL变动,它的文件也需要发生变动,手工映射只需要改动路由映射即可。
8.3.3 RESTful
RESTful
MVC模式大行其道了很多年,直到RESTful的流行,大家才意识到URL也可以设计得很规范,请求方法也能作为逻辑分发的单元。
REST
的全称是Representational State Transfer
,中文含义为表现层状态转化
。符合REST规范的设计,我们称为RESTful设计。它的设计哲学主要将服务器端提供的内容实体看作一个资源,并表现在URL上。
比如一个用户的地址如下所示:
|
这个地址代表了一个资源,对这个资源的操作,主要体现在HTTP请求方法上,不是体现在URL上。过去我们对用户的增删改查或许是如下这样设计URL的:
|
操作行为主要体现在行为上,主要使用的请求方法是POST和GET。在RESTful设计中,它是如下这样的:
|
它将DELETE和PUT请求方法引入设计中,参与资源的操作和更改资源的状态。对于这个资源的具体表现形态,也不再如过去一样表现在URL的文件后缀上。过去设计资源的格式与后缀有很大的关联,例如:
|
在RESTful设计中,资源的具体格式由请求报头中的Accept字段和服务器端的支持情况来决定。如果客户端同时接受JSON和XML格式的响应,那么它的Accept字段值是如下这样的:
|
靠谱的服务器端应该要顾及这个字段,然后根据自己能响应的格式做出响应。在响应报文中,通过Content-Type字段告知客户端是什么格式,如下所示:Content-Type: application/json具体格式,我们称之为具体的表现。所以REST的设计就是,通过URL设计资源、请求方法定义资源的操作,通过Accept决定资源的表现形式。
RESTful与MVC设计并不冲突,而且是更好的改进。相比MVC,RESTful只是将HTTP请求方法也加入了路由的过程,以及在URL路径上体现得更资源化。
请求方法
为了让Node能够支持RESTful需求,我们改进了我们的设计。如果use是对所有请求方法的处理,那么在RESTful的场景下,我们需要区分请求方法设计。示例如下所示:
|
上面的代码添加了get()、put()、delete()、post()4个方法后,我们希望通过如下的方式完成路由映射:
|
这样的路由能够识别请求方法,并将业务进行分发。为了让分发部分更简洁,我们先将匹配的部分抽取为match()方法,如下所示:
|
然后改进我们的分发部分,如下所示:
|
如此,我们完成了实现RESTful支持的必要条件。这里的实现过程采用了手工映射的方法完成,事实上通过自然映射也能完成RESTful的支持,但是根据Controller/Action
的约定必须要转化为Resource/Method
的约定,此处已经引出实现思路,不再详述。目前RESTful应用已经开始广泛起来,随着业务逻辑前端化、客户端的多样化,RESTful模式以其轻量的设计,得到广大开发者的青睐。对于多数的应用而言,只需要构建一套RESTful服务接口,就能适应移动端、PC端的各种客户端应用。
8.4 中间件
对于Web应用而言,我们希望不用接触到这么多细节性的处理,为此我们引入中间件(middleware)来简化和隔离这些基础设施与业务逻辑之间的细节,让开发者能够关注在业务的开发上,以达到提升开发效率的目的。
在最早的中间件的定义中,它是一种在操作系统上为应用软件提供服务的计算机软件。
它既不是操作系统的一部分,也不是应用软件的一部分,它处于操作系统与应用软件之间,让应用软件更好、更方便地使用底层服务。如今中间件的含义借指了这种封装底层细节,为上层提供更方便服务的意义,并非限定在操作系统层面。这里要提到的中间件,就是为我们封装上文提及的所有HTTP请求细节处理的中间件,开发者可以脱离这部分细节,专注在业务上。
中间件的行为比较类似Java中过滤器(filter)的工作原理,就是在进入具体的业务处理之前,先让过滤器处理。它的工作模型如图8-4所示。如同图8-4所示,从HTTP请求到具体业务逻辑之间,其实有很多的细节要处理。Node的http模块提供了应用层协议网络的封装,对具体业务并没有支持,在业务逻辑之下,必须有开发框架对业务提供支持。这里我们通过中间件的形式搭建开发框架,这个开发框架用来组织各个中间件。对于Web应用的各种基础功能,我们通过中间件来完成,每个中间件处理掉相对简单的逻辑,最终汇成强大的基础框架
由于中间件就是前述的那些基本功能,所以它的上下文也就是请求对象和响应对象:req和res。有一点区别的是,由于Node异步的原因,我们需要提供一种机制,在当前中间件处理完成后,通知下一个中间件执行。在第4章中其实已经对中间件做了介绍,这里我们还是采用Connect的设计,通过尾触发的方式实现。
|
改进后的use()方法将中间件都存进了stack数组中保存,等待匹配后触发执行。由于结构发生改变,那么我们的匹配部分也需要进行修改,如下所示:
|
一旦匹配成功,中间件具体如何调动都交给了handle()方法处理,该方法封装后,递归性地执行数组中的中间件,每个中间件执行完成后,按照约定调用传入next()方法以触发下一个中间件执行(或者直接响应),直到最后的业务逻
|
这里带来的疑问是,像querystring、cookie、session这样基础的功能中间件是否需要为每个路由都进行设置呢?如果都设置将会演变成如下的路由配置:
|
更多路由为每个路由都配置中间件并不是一个好的设计,既然中间件和业务逻辑是等价的,那么我们是否可以将路由和中间件进行结合?设计是否可以更人性?既能照顾普适的需求,又能照顾特殊的需求?答案是Yes,如下所示:
|
为了满足更灵活的设计,这里持续改进我们的use()方法以适应参数的变化,如下所示:
|
除了改进use()方法外,还要持续改进我们的匹配过程,与前面一旦一次匹配后就不再执行后续匹配不同,还会继续后续逻辑,这里我们将所有匹配到中间件的都暂时保存起来,如下所示:
|
改进完use()方法后,还要持续改进分发的过程:
|
综上所述,通过中间件和路由的协作,我们不知不觉之间已经将复杂的事情简化下来,Web应用开发者可以只关注业务开发就能胜任整个开发工作。
8.4.1 异常处理
异常处理
但是等等,如果某个中间件出现错误该怎么办?我们需要为自己构建的Web应用的稳定性和健壮性负责。于是我们为next()方法添加err参数,并捕获中间件直接抛出的同步异常,如下所示:
|
Next()方法接到异常对象后,会将其交给handle500()进行处理。为了将中间件的思想延续下去,我们认为进行异常处理的中间件也是能进行数组式处理的。由于要同时传递异常,所以用于处理异常的中间件的设计与普通中间件略有差别,它的参数有4个,如下所示:
|
我们通过use()可以将所有异常处理的中间件注册起来,如下所示:
|
为了区分普通中间件和异常处理中间件,handle500()方法将会对中间件按参数进行进行选取,然后递归执行。
|
8.4.2 中间件与性能
中间件与性能
前文我们添加了强大的中间件组织能力,如果注意到一个现象的话,那就是我们的业务逻辑往往是在最后才执行。为了让业务逻辑提早执行,尽早响应给终端用户,中间件的编写和使用是需要一番考究的。下面是两个主要的能提升的点。编写高效的中间件。
合理利用路由,避免不必要的中间件执行。
编写高效的中间件
编写高效的中间件其实就是提升单个处理单元的处理速度,以尽早调用next()执行后续逻辑。需要知道的事情是,一旦中间件被匹配,那么每个请求都会使该中间件执行一次,哪怕它只浪费1毫秒的执行时间,都会让我们的QPS显著下降。常见的优化方法有几种。
使用高效的方法。必要时通过jsperf.com
测试基准性能。
缓存需要重复计算的结果(需要控制缓存用量,原因在第5章阐述过)。避免不必要的计算。比如HTTP报文体的解析,对于GET方法完全不需要。
合理使用路由
在拥有一堆高效的中间件后,并不意味着每个中间件我们都使用,合理的路由使得不必要的中间件不参与请求处理的过程。这里以一个示例来说明该问题。假设我们这里有一个静态文件的中间件,它会对请求进行判断,如果磁盘上存在对应文件,就响应对应的静态文件,否则就交由下游中间件处理,如下所示:
|
如果我们以如下的方式注册路由:
那么意味着对/路径下的所有URL请求都会进行判断。又由于它中间涉及到了磁盘I/O,如果成功匹配,它的效率还行,但是如果不成功匹配,每次的磁盘I/O都是对性能的浪费,使QPS直线下降。
对于这种情况,我们需要做的是提升匹配成功率,那么就不能使用默认的/路径来进行匹配了,因为它的误伤率太高。给它添加一个更好的路由路径是个不错的选择,如下所示:
|
这样只有/public路径会匹配上,其余路径根本不会涉及该中间件。
8.4.3 小结
中间件使得前文的基础功能,从凌乱的发散状态收敛成很规整的组织方式。对于单个中间件而言,它足够简单,职责单一。与像面条一样杂糅在一起的逻辑判断相比,它具备更好的可测试性。中间件机制使得Web应用具备良好的可扩展性和可组合性,可以轻易地进行数据增删。从某种角度来讲它就是Unix哲学的一个实现,专注简单,小而美,然后通过组合使用,发挥出强大的能
中间件是Connect的经典模式,通过本节的叙述,我们已经可以看到整个Connect是如何搭建轮廓的。本节试图解释Web开发过程的前置思路,省略了许多细节,尽管与实际的Connect代码不尽相同,希望借着这些思路,每位开发者都能独立写出适应自己业务需求的框架。
8.5 页面渲染
页面渲染
通过中间件机制组织基础功能完成我们的请求预处理后,不管是通过MVC还是通过RESTful路由,开发者或者是调用了数据库,或者是进行了文件操作,或者是处理了内存,这时我们终于来到了响应客户端的部分了。这里的“页面渲染”是个狭义的标题,我们其实响应的可能是一个HTML网页,也可能是CSS、JS文件,或者是其他多媒体文件。这里我们要承接上文谈论的HTTP响应实现的技术细节,主要包含内容响应和页面渲染两个部分。
对于过去流行的ASP、PHP、JSP等动态网页技术,页面渲染是一种内置的功能。但对于Node来说,它并没有这样的内置功能,在本节的介绍中,你会看到正是因为标准功能的缺失,我们可以更贴近底层,发展出更多更好的渲染技术,社区的创造力使得Node在HTTP响应上呈现出更加丰富多彩的状态。
8.5.1 内容响应
内容响应
在第7章我们介绍了http模块封装了对请求报文和响应报文的操作,在这里我们则展开说明应用层该如何使用响应的封装。服务器端响应的报文,最终都要被终端处理。这个终端可能是命令行终端,也可能是代码终端,也可能是浏览器。服务器端的响应从一定程度上决定或指示了客户端该如何处理响应的内容。内容响应的过程中,响应报头中的Content-*字段十分重要。在下面的示例响应报文中,服务端告知客户端内容是以gzip编码的,其内容长度为21 170个字节,内容类型为JavaScript,字符集为UTF-8:
|
客户端在接收到这个报文后,正确的处理过程是通过gzip来解码报文体中的内容,用长度校验报文体内容是否正确,然后再以字符集UTF-8将解码后的脚本插入到文档节点中。
MIME
如果想要客户端用正确的方式来处理响应内容,了解MIME必不可少。可以先猜想一下下面两段代码在客户端会有什么样的差异:
|
在网页中,前者显示的是
|
而后者只能看到Hello World,如图8-5所示。
没错,引起上述差异的原因就在于它们的Content-Type
字段的值是不同的。浏览器对内容采用了不同的处理方式,前者为纯文本,后者为HTML,并渲染了DOM树。浏览器正是通过不同的Content-Type
的值来决定采用不同的渲染方式,这个值我们简称为MIME值。
MIME的全称是Multipurpose Internet Mail Extensions
,从名字可以看出,它最早用于电子邮件,后来也应用到浏览器中。不同的文件类型具有不同的MIME值,如JSON文件的值为application/json
、XML文件的值为application/xml
、PDF文件的值为application/pdf
。
为了方便获知文件的MIME值,社区有专有的mime模块可以用判段文件类型。它的调用十分简
|
除了MIME值外,Content-Type的值中还可以包含一些参数,如字符集。示例如下:
|
8.5.2 视图渲染
视图渲染
Web应用的内容响应形式十分丰富,可以是静态文件内容,也可以是其他附件文件,也可以是跳转等。这里我们回到主流的普通的HTML内容的响应上,总称视图渲染。Web应用最终呈现在界面上的内容,都是通过一系列的视图渲染呈现出来的。在动态页面技术中,最终的视图是由模板和数据共同生成出来的。模板是带有特殊标签的HTML片段,通过与数据的渲染,将数据填充到这些特殊标签中,最后生成普通的带数据的HTML片段。
8.5.3 模板
模板
最早的服务器端动态页面开发,是在CGI程序或servlet中输出HTML片段,通过网络流输出到客户端,客户端将其渲染到用户界面上。
这种逻辑代码与HTML输出的代码混杂在一起的开发方式,导致一个小小的UI改动都要大动干戈,甚至需要重新编译。为了改良这种情况,使HTML与逻辑代码分离开来,催生出一些服务器端动态网页技术,如ASP、PHP、JSP。
它们将动态语言部分通过特殊的标签(ASP和JSP以<% %>
作为标志,PHP则以<? ?>
作为标志)包含起来,通过HTML和模板标签混排,将开发者从输出HTML的工作中解脱出来。这样的方法虽然一定程度上减轻了开发维护的难度,但是页面里还是充斥着大量的逻辑代码。这催生了MVC在动态网页技术中的发展,MVC将逻辑、显示、数据分离开来的方式,大大提高了项目的可维护性。其中模板技术就在这样的发展中逐渐成熟起来的.
尽管模板技术看起来在MVC时期才广泛使用,但不可否认的是如ASP、PHP、JSP,它们其实就是最早的模板技术。模板技术虽然多种多样,但它的实质就是将模板文件和数据通过模板引擎生成最终的HTML代码。形成模板技术的也就如下4个要素:
- 模板语言。
- 包含模板语言的模板文件。
- 拥有动态数据的数据对象。
- 模板引擎。
对于ASP、PHP、JSP而言,模板属于服务器端动态页面的内置功能,模板语言就是它们的宿主语言(VBScript、JScript、PHP、Java),模板文件就是以.php
、.asp
、.jsp
为后缀的文件,模板引擎就是Web容器。
这个时期的模板极度依赖上下文,甚至要处理整个HTTP的请求对象。随后模板语言的发展使得模板可以脱离上下文环境,只有数据对象就可以执行。如PHP中的PHPLIB Template
和FastTemplate
、Java
的XSTL
,以及Velocity
、JDynamiTe
、Tapestry
等模板。
这类模板的缺点在于它的实现与宿主语言有很大的关联性,由于各种语言采用的模板语言不同,包含各种特殊标记,导致移植性较差。早期的企业一旦选定编程语言就不会轻易地转换环境,所以较少有开发者去开发新的模板语言和模板引擎来适应不同的编程语言。如今异构系统越来越多,模板能够应用到多门编程语言中的这种需求也开始呈现出来.
破局者是Mustache,它宣称自己是弱逻辑的模板
(logic-less templates
),定义了以
|
为标志的一套模板语言,并给出了十多门编程语言的模板引擎实现,使得采用它作为模板具备很好的可移植性。但随着Node在社区的发展,思路很快被打开,模板语言可以随意创造,模板引擎也可以随意实现。Node社区目前与模板引擎相关模块的列表差不多要滚3个屏幕才能看完。并且由于Node与前端都采用相同的执行语言JavaScript,所以一套模板语言也无须为它编写两套不同的模板引擎就能轻松地跨前后端共用。
模板和数据与最终结果相比,这里有一个静态、动态的划分过程,相同的模板和不同的数据可以得到不同的结果,不同的模板与相同的数据也能得到不同的结果。模板技术使得网页中的动态内容和静态内容变得不互相依赖,数据开发者与模板开发者只要约定好数据结构
但模板技术并不是什么神秘的技术,它干的实际上是拼接字符串这样很底层的活,只是各种模板有着各自的优缺点和技巧。说模板是拼接字符串并不为过,我们要的就是模板加数据,通过模板引擎的执行就能得到最终的HTML字符串这样结果。
模板引擎
为了演示模板引擎的技术,我们将通过render()方法实现一个简单的模板引擎。这个模板引擎会将Hello <%= username%>转换为”Hello “ + obj.username。该过程进行以下几个步骤。语法分解。提取出普通字符串和表达式,这个过程通常用正则表达式匹配出来,<%=%>
的正则表达式为/<%=([\s\S]+?)%>/g
。
处理表达式。将标签表达式转换成普通的语言表达式。
生成待执行的语句。
与数据一起执行,生成最终字符串。
知晓了流程,模板函数就可以轻松愉快地开工了,如下所示:
|
模板编译
上述代码的实现过程中,可以看到有部分内容前文没有提及,它的内容如下:
|
为了能够最终与数据一起执行生成字符串,我们需要将原始的模板字符串转换成一个函数对象。比如Hello <%=username%>这句模板字符串,最终会生成如下的代码:
|
这个过程称为模板编译,生成的中间函数只与模板字符串相关,与具体的数据无关。如果每次都生成这个中间函数,就会浪费CPU。为了提升模板渲染的性能速度,我们通常会采用模板预编译的方式。是故,上面的代码可以拆解为两个方法,如下所示:
|
通过预编译缓存模板编译后的结果,实际应用中就可以实现一次编译,多次执行,而原始的方式每次执行过程中都要进行一次编译和执行。
with的应用
上面实现的模板引擎非常弱,只能替换变量,<%=”Jackson Tian”%>就无法支持了。为了让它更灵活,我们需要改进它的实现,使字符串能继续表达为字符串,变量能够自动寻找属于它的对象。于是with关键字引入到我们的实现中。with关键字是JavaScript中饱受Douglas Crockford指责的设计,细节在本书附录C中有详细描述。但在这里,with关键字可以得到很方便的应用。
|
普通字符串就直接输出,变量code的值则是obj[code]。关于new Function(),这里通过它创建了一个函数对象,它的语法如下:
|
构造函数接受多个参数,最后一个参数作为函数体的内容,其余参数都会用来作为新生成的函数的参数列表。
模板安全
前文提到过XSS漏洞,它的产生大多跟模板相关,如果上文中的username的值为
|
那么模板渲染输出的字符串将会是:
|
这会在页面上执行这个脚本,如果恰好这里的username是在URL的查询字符上输入的,这就构成了XSS漏洞。为了提高安全性,大多数模板都提供了转义的功能。转义就是将能形成HTML标签的字符转换成安全的字符,这些字符主要有&、<、>、"、'
。转义函数如下:
|
不确定要输出HTML标签的字符最好都转义,为了让转义和非转义表现得更方便,<%=%>和<%-%>分别表示为转义和非转义的情况,如下所示:
|
模板引擎通过正则分别匹配-和=并区别对待,最后不要忘记传入escape()函数。最终上面的危险代码会转换为安全的输出,如下所示:Hello <script>alert("I am XSS.")</script>.
因此,在模板技术的使用中,时刻不要忘记转义,尤其是与输入有关的变量一定要转义。
模板逻辑尽管模板技术已经将业务逻辑与视图部分分离开来,但是视图上还是会存在一些逻辑来控制页面的最终渲染。为了让上述模板变得强大一点,我们为它添加逻辑代码,使得模板可以像ASP、PHP那样控制页面渲染。
譬如下面的代码,结果HTML与输入数据相关:
它要编译成的函数应该是如下这样的:
|
模板引擎拼接字符串的原理还是通过正则表达式进行匹配替换,如下所示:
|
完成上面的实现后,试试成果,如下所示:
|
得到的输出内容如下所示:
|
接下来在不传递user时试试,如下所示:
|
结果是遗憾地得到异常信息,如下所示:
|
为了程序的健壮性,需要将模板写得健壮一点,对于不确定是否存在的属性,应该为它加上引用,如下所示:
|
EJS中,它的变量不是obj,而是locals,这里的值与模板引擎中的with语句有关。重新执行上面的示例,得到的结果为:
|
此外,实现了执行表达式的模板引擎还能进行循环,如下所示:
|
得到的输出如下所示:
如此,我们实现的模板引擎已经能够处理输出和逻辑了,视图的渲染逻辑不成问题。
集成文件系统
前文我们实现的complie()和render()函数已经能够实现将输入的模板字符串进行编译和替换的功能。如果与前文的HTTP响应对象组合起来处理的话,我们响应一个客户端的请求大致如下:
|
这样的响应体验并不友好,其缺点有如下几点。每次请求需要反复读磁盘上的模板文件。
每次请求需要编译。调用烦琐。
如果你记性不差的话,应该知道大多数的MVC框架在做渲染时都只有一个简单的render()方法,所以我们也需要一个更简洁、性能更好的render()函数,如下所示:
|
这个res.render()实现中,虽然有同步读取文件的情况,但是由于采用了缓存,只会在第一次读取的时候造成整个进程的阻塞,一旦缓存生效,将不会反复读取模板文件。其次,缓存之前已经进行了编译,也不会每次读取都编译。
封装完渲染函数之后,我们的调用就很轻松了,如下所示:
|
与文件系统集成之后,再引入缓存,可以很好地解决性能问题,接口也大大得到简化。由于模板文件内容都不太大,也不属于动态改动的,所以使用进程的内存来缓存编译结果,并不会引起太大的垃圾回收问题。
子模板
有时候模板文件太大,太过复杂,会增加维护上的难度,而且有些模板是可以重用的,这催生了子模板(Partial View)的产生。子模板可以嵌套在别的模板中,多个模板可以嵌入同一个子模板中。维护多个子模板比维护完整而复杂的大模板的成本要低很多,很多复杂问题可以降解为多个小而简单的问题
布局视图
子模板主要是为了重用模板和降低模板的复杂度。子模板的另一种使用方式就是布局视图(layout),布局视图又称母版页,它与子模板的原理相同,但是场景稍有区别。一般而言模板指定了子模板,那它的子模板就无法进行替换了,子模板被嵌入到多个父模板中属于正常需求,但是如果在多个父模板中只是嵌入的子视图不同,模板内容却完全一样,也会出现重复。比如下面两个简单的父模板:
|
这些重复的内容主要用来布局,为了能将这些布局模板重用起来,模板技术必须支持布局视图。支持布局视图之后,布局模板就只有一份,渲染视图时,指定好布局视图就可以了,如下所示:
|
对于布局模板文件,我们设计为将<%- body %>部分替换为我们的子模板,如下所示:
|
替换代码如下:
|
最终集成进res.render()函数,如下所示:
|
如此,我们可以轻松地实现重用布局文件,如下所示:
|
模板引擎的优化步骤,主要有如下几种。
- 缓存模板文件。
- 缓存模板文件编译后的函数。
完成上述两个步骤之后,渲染的性能与生成的函数直接相关,这个函数与模板字符串的复杂度有直接关系。如果在模板中编写了执行表达式,执行表达式的性能将直接影响模板的性能。优化执行表达式就是对模板性能的优化,所以加入一条优化步骤:优化模板中的执行表达式
除了这几个常见的方案外,模板引擎的实现也与性能相关。本节的实现中采用了new Function(),事实上还可以使用eval();对于字符串处理,本节中用的是字符串直接相加,有的模板引擎采用数组存储的方式,最后将所有字符串相连。对于变量的查找,本节采用的是with形成作用域的方式实现了查找,有的模板引擎采用了本节第一种方式,即指定变量名的方式(obj.username)查找,指定变量而不用with可以减少切换上下文。这些细节都是影响模板速度的因素。由于现有模板引擎数量巨多,此处不再做比较。
模板技术的出现,将业务开发与HTML输出的工作分离开来,它的设计原理就是单一职责原理。这与MVC中的数据、逻辑、视图分离如出一辙,更与前端HTML、CSS、JavaScript分离的设计理念一致,让视觉、结构、逻辑分离开来。随着Node的出现,模板能够在前后端共用实在是太寻常不过的事情,甚至都不用去重复实现引擎。本节介绍了模板的基本原理,如今各种各样的模板具备不同的特性和性能。最知名的有EJS、Jade等,它们在模板语言的设计上各不相同,EJS是ASP、PHP、JSP风格的模板标签,Jade则类似Python、Ruby的风格。
8.5.4 Bigpipe
Bagpipe的翻译为风笛,是用于调用限流的
Bigpipe是产生于Facebook公司的前端加载技术,它的提出主要是为了解决重数据页面的加载速度问题,在2010年的Velocity会议上,当时来自Facebook的蒋长浩先生分享了该议题,随后引起了国内业界巨大的反响。
这里以一个简单的例子说明下前文提到的MVC和模板技术潜在的问题:
|
这个例子中,我们渲染profile页面需要获取users和articles数据,然后通过布局文件layout和模板文件user,最终发出页面到浏览器端。排除掉模板文件和布局文件可能同步的影响,将无依赖的数据获取通过EventProxy解开,如下所示:
|
至此还存在的问题是什么?
问题在于我们的页面,最终的HTML要在所有的数据获取完成后才输出到浏览器端。Node通过异步已经将多个数据源的获取并行起来了,最终的页面输出速度取决于两个数据请求中响应时间慢的那个。在数据响应之前,用户看到的是空白页面,这是十分不友好的用户体验
Bigpipe的解决思路则是将页面分割成多个部分(pagelet),先向用户输出没有数据的布局(框架),将每个部分逐步输出到前端,再最终渲染填充框架,完成整个网页的渲染。这个过程中需要前端JavaScript的参与,它负责将后续输出的数据渲染到页面上。
Bigpipe是一个需要前后端配合实现的优化技术,这个技术有几个重要的点。页面布局框架(无数据的)。
后端持续性的数据输出。前端渲染。
Bigpipe的渲染流程示意图如图8-8所示。
第09章 玩转进程
Node在选型时决定在V8引擎之上构建,也就意味着它的模型与浏览器类似。我们的JavaScript将会运行在单个进程的单个线程上。它带来的好处是:程序状态是单一的,在没有多线程的情况下没有锁、线程同步问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的使用率。
但是单进程单线程并非完美的结构,如今CPU基本均是多核的,真正的服务器(非VPS)往往还有多个CPU。一个Node进程只能利用一个核,这将抛出Node实际应用的第一个问题:如何充分利用多核CPU服务器?另外,由于Node执行在单线程上,一旦单线程上抛出的异常没有被捕获,将会引起整个进程的崩溃。这给Node的实际应用抛出了第二个问题:如何保证进程的健壮性和稳定性?
在这两个问题中,前者只是利用率不足的问题,后者对于实际产品化带来一定的顾虑。本章关于进程的介绍和讨论将会解决掉这两个问题。
从严格的意义上而言,Node并非真正的单线程架构,在第3章中我们有叙述过Node自身还有一定的I/O线程存在,这些I/O线程由底层libuv处理,这部分线程对于JavaScript开发者而言是透明的,只在C++扩展开发时才会关注到。JavaScript代码永远运行在V8上,是单线程的。
9.1 服务模型的变迁
服务模型的变迁
从“古”到今,Web服务器的架构已经历了几次变迁。服务器处理客户端请求的并发量,就是每个里程碑的见证。
9.1.1 石器时代:同步
最早的服务器,其执行模型是同步的,它的服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。这意味除了当前的请求被处理外,其余请求都处于耽误的状态。它的处理能力相当低下,假设每次响应服务耗用的时间稳定为N秒,这类服务的QPS为1/N。
这类架构如今已基本被淘汰,只在一些无并发要求的应用中存在。
9.1.2 青铜时代:复制进程
为了解决同步架构的并发问题,一个简单的改进是通过进程的复制同时服务更多的请求和用户。这样每个连接都需要一个进程来服务,即100个连接需要启动100个进程来进行服务,这是非常昂贵的代价。在进程复制的过程中,需要复制进程内部的状态,对于每个连接都进行这样的复制的话,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据,启动是较为缓慢的。为了解决启动缓慢的问题,预复制(prefork)被引入服务模型中,即预先复制一定数量的进程。同时将进程复用,避免进程创建、销毁带来的开销。但是这个模型并不具备伸缩性,一旦并发请求过高,内存使用随着进程数的增长将会被耗尽。假设通过进行复制和预复制的方式搭建的服务器有资源的限制,且进程数上限为M,那这类服务的QPS为M/N。
9.1.3 白银时代:多线程
为了解决进程复制中的浪费问题,多线程被引入服务模型,让一个线程服务一个请求。线程相对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线程池可以减少创建和销毁线程的开销。但是多线程所面临的并发问题只能说比多进程略好,因为每个线程都拥有自己独立的堆栈,这个堆栈都需要占用一定的内存空间。另外,由于一个CPU核心在一个时刻只能做一件事情,操作系统只能通过将CPU切分为时间片的方法,让线程可以较为均匀地使用CPU资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时,时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。如果忽略掉多线程上下文切换的开销,假设线程所占用的资源为进程的1/L,受资源上限的影响,它的QPS则为M * L/N。
9.1.4 黄金时代:事件驱动
多线程的服务模型服役了很长一段时间,Apache就是采用多线程/多进程模型实现的,当并发增长到上万时,内存耗用的问题将会暴露出来,这即是著名的C10k问题。为了解决高并发问题,基于事件驱动的服务模型出现了,像Node与Nginx均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。
基于事件的服务模型存在的问题即是本章起始时提及的两个问题:CPU的利用率和进程的健壮性。单线程的架构并不少见,其中尤以PHP最为知名——在PHP中没有线程的支持。它的健壮性是由它给每个请求都建立独立的上下文来实现的。但是对于Node来说,所有请求的上下文都是统一的,它的稳定性是亟需解决的问题。由于所有处理都在单线程上进行,影响事件驱动服务模型性能的点在于CPU的计算能力,它的上限决定这类服务模型的性能上限,但它不受多进程或多线程模式中资源上限的影响,可伸缩性远比前两者高。如果解决掉多核CPU的利用问题,带来的性能上提升是可观的。
9.2 多进程架构
多进程架构
面对单进程单线程对多核使用不足的问题,前人的经验是启动多进程即可。理想状态下每个进程各自利用一个CPU,以此实现多核CPU的利用。所幸,Node提供了child_process
模块,并且也提供了child_process.fork()
函数供我们实现进程的复制。我们再一次将经典的示例代码存为worker.js文件,如下所示:
|
通过node worker.js启动它,将会侦听1000到2000之间的一个随机端口。
将以下代码存为master.js,并通过node master.js启动它:
|
这段代码将会根据当前机器上的CPU数量复制出对应Node进程数。在*nix系统下可以通过
ps aux | grep worker.js
查看到进程的数量,如下所示:
|
|
图9-1就是著名的Master-Worker模式,又称主从模式。图9-1中的进程分为两种:主进程和工作进程。
这是典型的分布式架构中用于并行处理业务的模式,具备较好的可伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。工作进程负责具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以工作进程的稳定性值得开发者关注。
图9-1 Master-Worker模式通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例。它需要至少30毫秒的启动时间和至少10 MB的内存。尽管Node提供了fork()供我们复制进程使每个CPU内核都使用上,但是依然要切记fork()进程是昂贵的。好在Node通过事件驱动的方式在单线程上解决了大并发的问题,这里启动多个进程只是为了充分将CPU资源利用起来,而不是为了解决并发问题。
9.2.1 创建子进程
创建子进程child_process
模块给予Node可以随意创建子进程(child_process
)的能力。它提供了4个方法用于创建子进程。
- spawn():启动一个子进程来执行命令。
- exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。
- execFile():启动一个子进程来执行可执行文件。
- fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间,一旦创建的进程运行超过设定的时间将会被杀死。
exec()与execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。这里我们以一个寻常命令为例,node worker.js
分别用上述4种方法实现,如下所示:
|
以上4个方法在创建子进程之后均会返回子进程对象。它们的差别可以通过表9-1查看。
这里的可执行文件是指可以直接执行的文件,如果是JavaScript文件通过execFile()
运行,它的首行内容必须添加如下代码:
尽管4种创建子进程的方式有些差别,但事实上后面3种方法都是spawn()的延伸应用。
9.2.2 进程间通信
进程间通信
在Master-Worker模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间的通信。对于child_process模块,创建好了子进程,然后与父子进程间通信是十分容易的。
在前端浏览器中,JavaScript主线程与UI渲染共用同一个线程。执行JavaScript的时候UI渲染是停滞的,渲染UI时,JavaScript是停滞的,两者互相阻塞。长时间执行JavaScript将会造成UI停顿不响应。为了解决这个问题,HTML5提出了WebWorker API。WebWorker允许创建工作线程并在后台运行,使得一些阻塞较为严重的计算不影响主线程上的UI渲染。它的API如下所示:
|
其中,worker.js如下所示:
|
主线程与工作线程之间通过onmessage()和postMessage()进行通信,子进程对象则由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据,与API在一定程度上相似。通过消息传递内容,而不是共享或直接操作相关资源,这是较为轻量和无依赖的做法。
Node中对应示例如下所示:
|
通过fork()或者其他API,创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才能通过message和send()传递消息。
进程间通信原理
IPC的全称是Inter-Process Communication,即进程间通信
进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。
实现进程间通信的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket 等。
Node中实现IPC通道的是管道(pipe)技术。但此管道非彼管道,在Node中管道是个抽象层面的称呼,具体细节实现由libuv提供,在Windows下由命名管道(named pipe)实现,*nix系统则采用Unix Domain Socket实现。表现在应用层上的进程间通信只有简单的message事件和send()方法,接口十分简洁和消息化。