《深入浅出Nodejs》小结

node三大特性: 异步I/O 、 事件驱动、单线程
模块机制:CommonJs

Commonjs

CommonJs规范

模块引用、模块定义、模块标识
模块引用: require
模块定义: exports
模块标识: require()的参数,即模块名或模块路径

CommonJs模块的实现

CommonJs引入模块有三个步骤:
  1. 路径分析(分析模块标识符)
  1. 文件定位
    1. 文件扩展名分析(.js、.json、.node依次尝试)
    2. 目录分析(如果定位到的是一个目录,则当做是一个包来处理,解析package.json获取main属性指定的文件名进行定位,如果没有就将index当做默认文件名)
  1. 编译执行
但是值得注意的是,nodejs的模块有两种,一种是nodejs内置的核心模块,一种是用户编写的文件模块。
核心模块在node启动时,就被直接加载到了内存中,所以对于核心模块来说没有路径分析文件定位。也正是因为这个原因,核心模块的加载是最快的。
缓存: 不论是核心模块还是文件模块,require()方法对相同加载的二次加载都采用缓存优先的方式,这是第一优先级。核心模块的缓存检查先于文件模块的缓存检查。
因此
优先级: 缓存 > 核心模块 > 文件模块
加载速度: 核心模块 > 缓存 > 路径形式的文件模块 > 自定义模块
路径分析:即分析模块标识符
模块标识有以下几种:
  • 核心模块,如fs
  • .或..的相对路径
  • /开始的绝对路径
  • 自定义模块
文件定位(除了定位文件外,还可能会进行文件扩展名分析和目录分析)
对于核心模块:
直接从内存中加载代码,没有路径分析和文件定位步骤。
对于路径形式的文件模块(包括相对路径和绝对路径)
  1. 将路径转换成真实路径
  1. 根据真实路径查找到文件
  1. 执行后缓存
对于自定义模块(根据查找策略进行查找)
  1. 当前目录下的node_modules
  1. 父目录下的node_modules
  1. 父目录的父目录下的node_modules
  1. ...
  1. 根目录下的node_modules(还没找到就会抛出异常)
因为这种层层查找很耗时间,因此自定义模块的加载速度是最慢的。
模块编译
每个模块都是一个对象,对于不同文件类型,加载方式也不同
  • .js 通过fs模块同步读取文件后编译执行
  • .node 这是c/c++编写的扩展文件
  • .json 通过fs模块同步读取后,用JOSN.parse()方法解析返回结果
其余扩展名 当做js文件载入
模块的全局变量
CommonJs模块中有一些不用我们自己声明的变量: require、exports、module、__dirname、__filename
其实,在编译的时候,node会对js文件的内容进行头尾包装: 在头部添加(function (exports, require,module, __filename, __dirname){,在尾部添加了\n});。
这样就利用了函数作用域实现了避免变量污染的问题。
关于exports和module.exports
exports是对module.exports的引用,如果直接赋值exports,就会改变exports对module.exports的指向,而真正导出的对象是module.exports。
从函数的解释是:exports对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但并不能改变作用域外的值。
 

异步I/O、事件驱动和单线程

单线程: node的单线程指的是JavaScript执行是单线程,并非是指node是单线程,node本身有一个线程池。
node实现异步I/O出于以下考虑:
  • 用户体验、提高响应速度,避免长时间等待。
  • 资源分配,由于node是单线程,利用异步I/O可以提高效率,利用异步I/O,让单线程远离阻塞,以更好地使用CPU。
操作系统内核对于I/O只有两种方式:阻塞与非阻塞。
操作系统的阻塞与非阻塞I/O
非阻塞I/O是调用后立马返回,通过轮询机制,每隔一段时间就去查看任务是否已完成,若已完成就返回结果,没有则继续轮询。
notion image
notion image
理想的非阻塞I/O应该是调用非阻塞任务后,无需轮询,直接进行下一个任务,当事件完成后通知应用程序。
现实中的非阻塞I/O通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O(尽管它是模拟的)
notion image
notion image
这种方式就是利用线程池来实现异步I/O,现实中的操作系统就是基于这种方式实现异步I/O的,另外这里的I/O不仅仅只限于磁盘文件的读写,磁盘文件、硬件、套接字等几乎所有计算机资源都被抽象为了文件。

node的异步I/O

node的异步I/O是基于事件循环、观察者、请求对象和I/O线程池实现的。
  • 事件循环: 在进程启动时,Node便会创建一个类似于while(true)的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。
  • 观察者: 类似于浏览器的事件监听器,监听事件是否被触发,若触发则将事件加入事件队列中。
  • 请求对象: JavaScript发起调用到内核执行完操作的过渡过程的中间产物。
  • I/O线程池: 执行阻塞和非阻塞I/O操作,不论是阻塞还是非阻塞I/O,当node主线程将I/O操作放到线程池后,对于node而言就都是非阻塞的。
notion image
事实上,在Node中,除了JavaScript是单线程外,Node自身其实是多线程的,只是I/O线程使用的CPU较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的I/O(磁盘I/O和网络I/O等)则是可以并行起来的。

node中的异步编程

在node异步编程中有以下难点:
异常处理:尝试对异步方法进行try/catch操作只能捕获当次事件循环内的异常,对callback执行时抛出的异常将无能为力
  1. 回调
node默认将异常当做回调函数中的第一个参数传入,如果未发生异常则为null
  1. 利用ES6新语法:Promise和async/await
node常用的异步解决方法
  • 事件发布/订阅模式利用events核心模块的on()、once()、emit()等事件监听模式 的方法
  • Promise/Deffered模式
  • 流程控制库 aysnc、step等
尾触发与Next: 除了事件和Promise外,还有一类方法是需要手工调用才能持续执行后续调用的,我们将此类方法叫做尾触发,常见的关键词是next。例如koa2的app.use()

内存控制

在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7GB)。在这样的限制下,将会导致Node无法直接操作大内存对象(可以通过stream),在V8中,所有的JavaScript对象都是通过堆来进行分配的。
造成这个问题的主要原因在于Node基于V8构建,所以在Node中使用的JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。至于V8为何要限制堆的大小,最主要原因是因为若堆内容太大,垃圾回收的时间会大大延长。
V8的垃圾回收策略主要基于分代式垃圾回收机制。主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。
notion image
notion image

高效的使用内存

主动释放变量,重新赋值或delete
减少闭包的使用。(因为闭包中返回的函数一旦被引用,则此函数作用域得不到释放,此函数所在的作用域也得不到释放)
Buffer对象不同于其他对象,它不经过V8的内存分配机制,它是通过C/C++内建模块构建的,所以也不会有堆内存的大小限制。
Node的内存构成主要由通过V8进行分配的部分和Node自行分配的部分。受V8的垃圾回收限制的主要是V8的堆内存。

内存泄露

通常,造成内存泄漏的原因有如下几个。
❑ 缓存。
❑ 队列消费不及时。
❑ 作用域未释放。
在Node中,一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。另一个问题在于,JavaScript开发者通常喜欢用对象的键值对来缓存东西,但这与严格意义上的缓存又有着区别,严格意义的缓存有着完善的过期策略,而普通对象的键值对并没有。
解决方案: (1) 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。(2) 进程之间可以共享缓存。比较成熟的解决方案有Redis。
队列在消费者-生产者模型中经常充当中间产物。这是一个容易忽略的情况,因为在大多数应用场景下,消费的速度远远大于生产的速度,内存泄漏不易产生。但是一旦消费速度低于生产速度,将会形成堆积。
深度的解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。
排查内存泄露: 主要通过对堆内存进行分析而找到,通常利用对应工具。
对于大内存应用可以使用以下方式提高效率,避免内存泄露:
  • 使用流stream
  • 对于不需要字符串层面的操作,应当直接使用Buffer操作,这样不会受到V8堆内存的限制

Buffer

Buffer是一个类似数组的对象,主要用于操作字节,它的元素为16进制的两位数,即0到255的数值。。Buffer所占用的内存不是通过V8分配的,属于堆外内存。
Buffer与字符串的转换。
new Buffer(str,[encoding]) buf.toString([encoding],[start],[end])
Node的Buffer对象支持的编码类型有限,为此,Buffer提供了一个isEncoding()函数来判断编码是否支持转换
Buffer的拼接
Buffer是一个类数组,不应该直接使用+拼接,如buf += data,这样会隐式的调用toString().
如果是一个读取文件流(文本是汉字),限定了Buffer对象的长度为11,则会出现乱码的情况,因为汉字在UTF8占3字节,限定了Buffer对象的长度为11会截断第四个字,也会扰乱后面的字节。
正确的方法应该是通过数组的形式来进行拼接:
notion image
在Node操作中,能用Buffer就尽量使用Buffer,这样可以避免转换造成额外的性能损耗,同时Buffer的传输效率比字符串更高。

Node服务器

❑ 请求方法的判断。
❑ URL的路径解析。
❑ URL中查询字符串解析。
❑ Cookie的解析。
❑ Session与内存
❑ JWT授权。
❑ 表单数据的解析。
❑ 任意格式文件的上传处理。
❑ 路由解析

node工程化

  • 测试
    • 单元测试
    • 性能测试
    • 安全测试
  • 性能
    • 启动缓存(Redis)
    • 动静分离(Node处理动态请求,nginx处理静态资源)
    • 多进程(通过多进程架构,不仅可以充分利用多核CPU,更是可以建立机制让Node进程更加健壮,以保障Web应用持续服务)
    • 读写分离(主要针对数据库而言,数据库读取的速度远远高于写入的速度,某些数据库为了保证数据一致性,会进行锁表操作,这同时会影响到读取的速度)
  • 日记
    • 访问日记
    • 异常日记(封装异常信息)
  • 监控报警
    • 日记监控
    • 响应时间
    • 进程监控
    • 磁盘监控
  • 内存监控
  • CPU占用监控
  • CPU load监控
  • I/O负载
  • 网络监控
  • 应用状态监控
  • DNS监控