前言
记得初学Node是自己摸索的状态,后来从这本书学到了很多,下文是初读本书时对部分章节的摘抄和笔记,希望能帮助到你。
第一章:Node简介
2009 年Ryan Dahl 利用了 Google 的 V8 引擎打造了基于事件循环实现的异步I/O框架。
Node关键词:事件驱动,非阻塞IO,单线程
单线程
单线程弱点
无法利用多核CPU
错误会引起整个应用退出,考验应用的健壮性
大量计算占用CPU导致无法继续调用异步IO
child_process
在前端,Web Workers能够创建工作线程进行计算,以解决JS大量计算阻塞UI渲染的问题。工作线程为了阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程中的UI。
Node采用同样的思路解决单线程中大计算量的问题:child_process
跨平台
libuv 跨平台基础组件
Node应用场景
IO密集型
IO密集的优势主要在于Node利用事件循环的处理能力,而不是启动每一个线程为每一个请求服务,资源占用极少。
对于CPU密集的应用场景 ,实际上V8的执行效率是非常高的,CPU密集型应用给Node带来的挑战主要是:JS单线程,长时间运行的计算将导致CPU时间片不能释放,阻塞后续IO。虽然Node没有多线程支持,但还是有两个方式来充分利用CPU。
一是通过编写C/C++扩展的方式高效用CPU。
二是通过子进程的方式,将一部分Node进程当做常驻服务进程用于计算,利用进程间的消息来传递结果,将计算与IO分离。
分布式应用
阿里巴巴的数据平台对Node的分布式应用是一个典型的例子。数据库查询针对单张表进行查询,Node高效利用并行IO,去多台数据库中获取数据并合并,高效实用数据库。避免数据库的复杂计算,进而避免压榨硬件资源的过程。
实时应用
将Node应用在长连接中以提供实施功能。通过socket.io实现实时通知功能。
游戏开发领域
游戏领域对实时和高并发有很高的需求。
第二章:模块机制
CommonJs规范
CommonJS出发点
JS的规范薄弱,还有以下缺陷:
- 没有模块系统
- 标准库较少:ES仅定义了部分核心库,对于文件系统,IO流没有标准的API。
- 没有标准接口
- 缺乏包管理系统:导致JS应用基本没有自动装载和安装依赖的能力。
CommonJS规范主要为了弥补JS没有标准的缺陷,让用CommonJS API写出的应用具备跨宿主环境执行的能力。CommonJS规范涵盖了模块,二进制,Buffer,字符集编码,IO流,进程环境,文件系统,套接字,单元测试,Web服务器网关接口,包管理等。
Node借鉴CommonJS的Modules规范实现了一套非常易用的模块系统,NPM对Packages规范的完好支持使得Node应用在开发过程中事半功倍。
CommonJS的模块规范
模块的意义在于将类聚的方法和变量限定在私有的作用域中,同时支持引入和导出以顺利链接上下游依赖。用户不必考虑变量污染,不必采用命名空间等方案。
模块引用
引入一个模块的API到当前上下文中
var math =require("math")
模块定义
在Node中,一个文件就是一个模块,其中的module对象代表模块自身,exports对象是module的属性,用于导出当前模块的方法或变量,并且它是唯一的导出口。
exports.fun = function(){...}
模块标识
模块标识就是传递给require的参数,必须是小驼峰命名的字符串或"."、".."开头的相对路径,或绝对路径,可以没有后缀.js。
Node的模块实现
Node引入模块步骤:路径分析,文件定位,编译执行
Node模块分为Node提供的核心模块和用户编写的文件模块。
核心模块在Node源代码编译过程中编译进了执行文件中,在Node进程启动时部分模块被直接加载进内存,省略了文件定位和编译执行的步骤,加载速度最快。
文件模块在运行时动态加载,需执行完整的模块引入过程。
优先从缓存加载
Node对引入过的模块进行缓存,缓存的是编译和执行之后的对象。require对于二次加载的模块一律采用缓存优先的方式,缓存加载是第一优先级。
require拿到的是对象的拷贝。对象只有在脚本运行完毕后才生成,故CommonJS在运行时加载,即动态加载。
路径分析和文件定位
加载速度:缓存>核心模块>文件模块>自定义模块
对于自定义模块,在加载时Node会逐个尝试模块路径中的路径,直到找到目标文件为止,路径越深,模块查找耗时越多。
文件扩展名分析:对于不包含扩展名的模块标识,会按.js,.json,.node的次序尝试,调用fs模块同步阻塞式判断文件是否存在,在单线程中会引起性能问题,所以不是js文件最好带上扩展名。
模块编译
在Node中,每个文件模块都是一个对象,其定义如下。在定位到具体文件后,Node会新建一个模块对象,然后根据路径载入并编译。编译成功的模块会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引用性能。
JS模块的编译
模块文件中存在着require,exports,module等变量,但他们在模块文件中并没有定义,那这些变量从何而来?
如果直接把定义模块的过程放在浏览器端,会存在污染全局变量的情况。
在编译过程中,Node对获取的JS文件进行头尾包装。
包装后的代码会通过vm原生模块的runInThisContext方法执行,返回一个具体的funciton对象,之后将当前模块对象的exports,require,module等作为参数传递给这个function执行。
在执行之后,模块的exports属性被返回给调用方,exports上的任何方法和属性都可以被外部调用。
PS:Webpack也是用这种方法实现JS的编译,以便浏览器编译运行。
C/C++模块的编译
JSON文件的编译
Node利用fs模块同步读取JSON文件内容后,调用JSON.parse()得到对象,然后把它赋给模块对象的exorts供外部调用。
如果定义了JSON文件作为项目配置文件,可省略异步读取和解析,直接调用require引入。
核心模块
包与NPM
Node对模块规范的实现,一定程度上解决了变量依赖,依赖关系等代码组织性问题。包的出现则是在模块的基础上进一步组织JS代码。
包结构
前后端共用模块
AMD规范
前端适用
CMD规范
总结
Node通过模块规范组织了自身的原生模块,弥补JS若结构性的问题,形成了稳定的结构并向外提供服务。NPM通过对包规范的支持,有效组织了第三方模块,使得项目开发中的依赖问题得到了很好的解决,并提供了分享和传播的平台,借助开源力量是Node第三方模块的发展速度前所未有。
正是这些底层的规范和事件,使得Node有序的发展着,摆脱过去JS纷乱和被误解的局面,进化成良性的生态系统。
第三章:异步IO
Node利用单线程,远离多线程死锁,状态同步等问题;利用异步IO让单线程远离阻塞,更好的利用CPU。
Node是首个将异步IO应用在应用层上的平台,他力求在单线程上将资源分配的更高效。为了弥补单线程无法利用多核CPU的缺点,Node提供了子进程,子进程可以通过工作进程高效利用CPU和IO。
异步IO的提出是期望IO的调用不再阻塞后续运算,将原有的等待IO完成的这段时间分配给其余需要的业务去执行。
异步IO采用轮询机制获得结果,这在一定程度上浪费了CPU,目前的方案是让部分线程进行阻塞IO或非阻塞IO+轮询技术来完成数据的获取,让另一个线程进行计算处理,通过进程间的通讯将IO得到的2数据进行传递。这种方案也就是线程池。
这里的IO并不仅限于磁盘文件的读写。硬件,套接字等几乎所有的计算机资源都被抽象成了文件。
常说的Node是单线程的,仅仅只是JS执行在单线程中。在Node中,内部完成IO任务的另有线程池。
Node的异步IO
事件循环
事件循环是一个典型的生产者/消费者模型。异步IO,网络请求等则是事件的生产者,这些事件被传递到对应的观察者哪里,事件循环的则从观察者那里取出事件并处理。
从JS发起调用到内核执行完IO操作的过程中,存在着一种重要中间产物,叫做请求对象,所有的状态都保存在这个对象中,包括送入线程池等待执行以及IO操作完毕后的回调处理。
事件循环,观察者,请求对象,IO线程池共同构成了Node异步IO模型的基本要素。
非IO异步的API
定时器
setTimeout()和setInterval()并不是精确的。尽管事件循环很快,但若某一次循环占用的时间较多,那么下一次循环也许会超时。
process.nextTick()
每次调用process.nextTick()方法,会将回调函数放入队列中,在下一轮Tick时取出执行。
setImmediate()
与nexttick方法类似,都是将回调函数延迟执行,但process.nextTick()中的回调函数执行优先级高于setImmediate()
事件驱动与高性能服务器
事件驱动的实质是通过主循环加事件触发的方式来运行程序。
总结
事件循环是异步实现的核心,他与浏览器中的执行模型基本保持一致。Node依靠构建了一套完善的高性能异步IO框架,打破了JS在服务端止步不前的局面。
第四章:异步编程
函数式编程
函数式编程是JS的特点,就是JS可以接受函数作为参数,很多语言是不支持的。
高阶函数
一个函数接受一个函数为参数
偏函数用法
通过执行部分参数来产生应新的定@制函数的形式就是偏函数。
有点像柯里化?
异步编程的优势和难点
难点1:异常处理
异步IO的实现主要包括两个阶段:提交请求和处理结果。异步方法通常在第一个阶段提交请求后立即返回,但异常不一定发生在第一阶段,try/catch不会发生任何功效。
Node在异常处理上形成一种约定,将异常作为回调函数的第一个实参传回,如果为空值,则表示无异常抛出。
编写异步方法需要遵循两个原则:必须执行调用者传入的回调函数,正确传递回异常供调用者判断。
难点2:函数嵌套过深
现在可以用async和await解决了
难点3:阻塞代码
JS没有sleep()这样的线程沉睡功能,只有setInterval和setTImeout两个延时函数,但这两个函数并不能阻塞后续代码的执行。遇到线程沉睡需求时,在统一规划业务逻辑后,调用setTimeout效果会更好。
难点4:多线程编程
Node借鉴了前端Web Workers的模式,child_process是Node的基础API,cluster模块是更深层次的应用。
难点5:异步转同步
异步编程会带来嵌套回调,业务分散等问题。Node提供了绝大部分的异步API和少量的同步API。目前Node中试图同步式编程,但并不能得到原生的支持,需要借助库或者编译等手段实现。
最新的:ES6 promise,ES7 async/await
异步编程解决方案
事件发布/订阅模式
Node自身提供的events模块是发布订阅模式的一个简单实现,Node中的部分模块都继承自它。
事件发布订阅模式本身没有同步和异步调用的问题,但在Node中,emit调用多半伴随着事件循环而异步触发,所以说事件发布订阅常用于异步编程。
Node对事件发布订阅机制做了一些额外的处理,这大多数是基于健壮性而考虑的。
如果对一个事件添加了超过10个侦听器,将会得到警告,设计者认为侦听器太多可能导致内存泄漏,且因为事件发布会引起一系列侦听器执行,如果侦听器过多会存在多占用CPU的情景。调用emitter.setMaxListener(0)可以去除该限制。
EventEmitter对象对error事件进行特殊处理,如果运行期间的错误出发了error事件,EventEmitter会检查是否有对error事件添加过侦听器。如果添加了,该错误会交给该侦听器处理,否则这个错误会作为异常抛出。如果外部没有捕获该错误将引起线程退出。
第五章:内存控制
V8的垃圾回收机制与内存限制
V8的内存限制
在Node中,通过JS只能使用部分内存(64位系统约1.4GB,32位系统约0.7GB)。造成这种情况的主要原因是Node基于V8构建,在Node中使用的JS对象基本上是通过V8自己的方式来进行分配和管理的。
在V8中,所有的JS对象都是通过堆来分配,process.memoryUsage()
可以输出内存信息。V8限制内存大小的表层原因是V8最初为浏览器设计,不太可能遇到大量内存的场景,深层原因是V8垃圾回收机制的限制,V8做小的垃圾回收要50ms,做非增量式垃圾回收甚至要1s以上,这是引起JS线程暂停执行的时间。这种时间花销使应用的性能和相应能力直线下降。所以当时直接限制内存是一个好的选择。
V8主要的垃圾回收算法
V8的内存分代
现代的垃圾回收算法中按对象的存活时间将内存的垃圾进行不同的分代,然后分别对不同分代的内存施以更高效的算法。所以统计学在垃圾回收算法的发展中发挥了很大的作用。
V8主要将内存分为新生代和老生代,新生代是存活时间较短的对象,老生带是存活时间较长或常驻内存的对象。
--max-old-space-size
命令行参数可以用于设置老生代内存空间的最大值。--max-new-space-size
设置新生代内存空间。这两个最大值需要在启动时就指定,无法根据使用情况自动扩充。
Scavenge算法
这是一种通过复制方式实现的垃圾回收算法,该算法将堆内存一分为二,两个空间中From处于使用中,To处于闲置状态。先在From空间中分配对象,当开始回收时,会检查From中的存活对象并复制到To空间中,非存活对象占用的空间将会被释放。完成复制后,两个空间进行角色对换。
简而言之就是通过将存活对象在两个semispace空间之间进行复制实现垃圾回收。
该算法的缺点是只能使用堆内存的一半,但因其只复制存活的对象,且对于生命周期短的场景存活对象只占少部分,所以这个算法虽然无法大规模应用到所有的垃圾回收,但它非常适合应用在新生代中。
对象晋升
当一个对象经过多次复制依然存活时,他会被认为是生命周期较长的对象,这种对象会被移动到老生代,采用新的算法进行管理。对象晋升的主要条件有两个,一个是对象是否经历过Scavenge回收,二是To空间的内存占比超过限制,当要从From复制到To时,如果To空间已经使用了25%,则这个对象直接晋升到老生代空间中。
Mark-Sweep & Mark-Compact(标记清除和标记整理)
老生代中的存货对象占较大比重,V8在老生代中主要采用Mark-Sweep & Mark-Compact两种算法结合的方式进行垃圾回收。
Mark-Sweep是标记清除,它在标记阶段遍历堆中所有的对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。
在一次标记清除后,内存空间会出现不连续的状态,这种内存碎片会影响后续的内存分配。在该算法基础上演化而来的Mark-Compact是标记整理的意思,标记死亡后,在整理的过程中将活着的对象往一边移动,移动后直接清理掉边界外的内存。
Incremental Marking(增量标记)
为了避免JS应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都要将应用逻辑暂停,等垃圾回收后再恢复执行(全停顿)。对于老生代来说垃圾回收造成的停顿过大,需要改善。
V8将原本一次性完成的标记动作改为增量标记,也就是拆分为许多小“步进”,每做完一个"步进"就让JS应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶段完成。经过改进的垃圾回收的最大停顿时间可以减少的原本的1/6左右。
小结
从V8的垃圾回收机制设计角度可以看出V8对内存限制的缘由。新生代设置为较小的内存空间是合理的,而老生代空间过大对于垃圾回收并无特殊意义。V8对内存限制的设置对于Chrome浏览器这种每个选项卡页面使用一个V8实例的情况的应对是绰绰有余的。
对于Node编写的服务器而言,内存限制不影响正常场景下的使用,但对于垃圾回收特点和JS在单线程上的执行情况,垃圾回收是影响性能的因素之一。想要高性能的执行效率,需要注意让垃圾回收尽量少的执行,尤其是全堆垃圾回收。
高效使用内存
作用域
在JS中,能形成作用域的有函数调用,with和全局作用域。
作用域链
自底向上查找变量
变量的主动释放
如果是全局变量,其处于全局作用域中,需要知道进程退出才能释放,导致该变量引用的对象常驻内存(老生代),释放常驻内存的变量可以通过delete操作来删除引用关系,或者将变量重新赋空值,让旧的对象脱离引用关系。这样在接下来的老生代内存清除整理的过程中,会被回收释放。
在局部作用域中可以通过同样的方式主动释放变量。但在V8中通过delete删除对象的属性有可能干扰V8的优化,之所以最好赋空值解除引用。
闭包
闭包实现外部作用域访问内部作用域中变量。
小结
在正常的JS执行中,无法立即回收的内存有闭包和全局变量引用两种情况。由于V8内存的限制,要小心此类变量是否无限制增加,它会导致老生代中的对象增多。
内存指标
查看进程的内存占用
process.memoryUsage(),有3个返回值
- rss(resident set size)是进程的常驻内存部分。进程的内存一部分是rss,其余部分在swap(交换区)或filesystem(文件系统)中。单位字节。
- heapTotal是堆中总共申请的内存量。单位字节。
- heapUsed是堆中目前使用的内存量。单位字节。
查看系统的内存占用
os模块中的totalmem()和freemem()用于查看操作系统的内存使用情况。他们分别返回系统的总内存和限制内存,单位字节。
堆外内存
不是通过V8分配的内存叫堆外内存。
Buffer对象不经过V8的内存分配机制,不会对堆内存的大小限制。这意味着利用堆外内存可以突破内存限制问题。
内存泄露
造成内存泄露的原因:缓存,队列消费不及时,作用域未释放。
慎将内存当缓存
在Node中,任何试图用内存当缓存的行为都应当被限制,这种限制是要小心而为之。
缓存限制策略
限制键值数量
缓存的解决方案
进程间无法共享内存,如果在进程中使用内存难免会重复。对于使用大量缓存,目前较好的解决方案是采用进程外的缓存,进程自身不存储状态。外部缓存有良好的缓存过期淘汰策略和自有的内存管理,不影响Node进程的性能。在Node中主要可以解决一下两个问题。
- 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效
- 进程间可以共享缓存
较好的缓存有Redis和Memcached。
关注队列状态
内存泄露排查
大内存应用
Node提供stream模块用于处理大文件。
第八章:构建WEB应用
页面渲染8
所谓页面渲染响应的可能是HTML网页,CSS,JS或是其他多媒体文件。页面渲染是ASP,PHP,JSP等动态网页技术的内置功能。但Node没有,但正是因为标准缺失让我们可以更贴近底层,发展出更多更好的渲染技术。
内容响应
内容响应的过程中,响应报头的content字段十分重要。
MIME
浏览器通过不同的Content-Type值来决定采用不同的渲染方式,这个值层位MIME(Multipurpose Internet Mail Extensions)值。社区专有的MIME模块可以判断文件类型。除了MIME值外,COntent-Type值中还可以包含一些参数,如字符集。
附件下载
默写场景下,客户端需要直接下载服务端响应的文件,应用到Content-Disposition字段,客户端根据该字段判断报文数据是当做即时浏览内容还是可下载的附件。值为inline表示只需即时查看,attachment表示存为附件。该字段还能通过参数指定保存时该用的文件名。
Content-Disposition: attachment; filename="filename.ext"
响应JSON
//封装响应JSON函数
res.json = function (json) {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(JSON.stringify(json));
};
响应跳转
当前URL不能处理当前请求(比如权限问题),需要用户跳转到其他URL时,可以封装一个快捷方法跳转。
res.redirect = function (url) {
res.setHeader('Location', url);
res.writeHead(302);
res.end('Redirect to ' + url);
};
视图渲染
Web应用最终呈现在界面上的内容都是通过一系列的视图渲染呈现出来的。在动态页面技术中,最终的视图是由模板和数据共同生成出来的。
模板是带有特殊标签的HTML片段,通过与数据的渲染,将数据填充到这些特殊标签中,最终生成带有数据的HTML片段。
模板
模板技术虽然多种多样,但实质就是将模板文件和数据通过模板引擎生成最终的HTML代码。形成模板技术有如下四个要素:模板语言,包含模板语言的模板文件,拥有动态数据的数据对象,模板引擎。
模板性能
模板引擎的优化步骤主要有以下几种:缓存模板文件;缓存模板文件编译后的函数;优化模板中的执行表达式。
小结
模板技术的出现,将业务开发与HTML输出的工作分离开,他的设计原理就是单一职责原理,这与MVC的数据,逻辑,视图分离如出一辙。
Bigpipe
Bigpipe是产生于Facebook的前端加载技术,主要用于解决重数据页面的加载速度问题。最终渲染的HTML页面要在所有数据获取完成后才输出到浏览器端,Node通过异步已经将多个数据源的获取并行起来,最终页面输出速度取决于数据请求中响应最慢的那个。在数据响应之前页面处于空白状态,影响用户体验。
Bigpipe的解决思路则是将页面分割成多个部分(pagelet),先向用户输出没有数据的布局(框架),将每个部分逐步输出到前端,再最终渲染填充框架,完成整个网页的渲染。这个过程中需要前端JavaScript的参与,它负责将后续输出的数据渲染到页面上。Bigpipe是一个需 要前后端配合实现的优化技术,这个技术有几个重要的点。
- 页面布局框架(无数据的)。
- 后端持续性的数据输出。
- 前端渲染。
Bigpipe的渲染流程示意图如图8-8所示。