《深入浅出Node.js》学习笔记

文章目录

第1章 Node简介

gjJCh6.jpg

1.1 node特点

  • 异步I/O

  • 事件与回调函数

  • 单线程
    与其余线程是无法共享任何状态的
    弱点:

    1. 无法利用多核Cpu。
    2. 错误会引起整个应用退出,应用的健壮性值得考验。
    3. 大量计算占用cpu导致无法继续调用异步I/O。

    child_process解决单线程大计算量的问题。

  • 跨平台

1.2 Node的应用场景

  • I/O密集型

  • CPU密集型业务

  • 与遗留系统和平共处

    取长补短

  • 分布式应用

第2章 模块机制

gjJAje.jpg

2.1 CommonJS模块规范

const math = require('math');
exports.add = function() {...}
module.export = xxx

2.2 Node模块的实现

在Node中引入模块经历3个步骤

  1. 路径分析
  2. 文件定位
  3. 编译执行

模块分为两类,一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。

核心模块被直接加载进内存中,文件模块加载速度比核心模块加载速度慢。

优先从缓存加载

浏览器仅缓存文件,而Node缓存的是编译和执行之后的对象

核心模块的缓存检查先于文件模块的缓存检查。

路径分析和文件定位
  1. 模块标识符分析

    • 核心模块,如http、fs、path等。

      核心模块的优先级仅次于缓存加载。

    • .或…开始的相对路径文件模块。

      require()方法会将路径转为真实路径,并以真实路径作为索引。

    • 以/开始的绝对路径文件模块。

    • 非路径形式的文件模块。

      查找最慢最费时。

  2. 文件定位

    从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。

    • 文件拓展名分析

      在不包含文件扩展名的情况下,Node会按.js、.json、.node的次序补足拓展名,依次尝试。

      在尝试的过程中,需要调用fs模块同步阻塞地判断文件是否存在。小诀窍是:1.如果是.node和.json文件,在传递给require()的标识符中带上拓展名,会加快一点速度。2.同步配合缓存,可以大幅度缓解Node单线程中阻塞式调用的缺陷。

    • 目录分析和包

      如果引入的路径得到一个目录,首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少拓展名,将会进入拓展名分析的步骤。

      而如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js,index.json,index.node。

模块编译

对于不同的文件拓展名,其载入方法也有所不同

  • .js文件。通过fs模块同步读取文件后编译执行。
  • .node文件。这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。
  • .json文件。通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
  • 其余扩展名文件。他们都被当做.js文件载入。

编译方式

  1. JavaScript模块的编译

    在编译过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function (exports, require, module, __filename, __dirname){\n,在尾部添加了\n});

    在执行之后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性都可以被外部调用到,模块中的其余变量或属性则不可直接被调用。

  2. C/C++模块的编译

    Node调用process.dlopen()方法进行加载和执行。

  3. JSON文件的编译

    Node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将它赋给模块对象的exports,以供外部调用。

2.3 核心模块

核心模块分为C/C++编写和JavaScript编写,C/C++文件存放在src目录下,JavaScript文件存放在lib目录下。

JavaScript核心模块的编译过程
  1. 转存为C/C++代码

    在启动Node进程时,JavaScript代码直接加载进内存中。

  2. 编译JavaScript核心模块

    在引入JavaScript核心模块的过程中,也经历了头尾包装的过程,然后才执行和导出了exports对象。

    与文件模块的区别在于获取源代码的方式(核心模块从内存中加载)以及缓存执行结果的位置。

C/C++模块的编译过程

由纯C/C++编写的部分称为内建模块,他们通常不被用户直接调用。Node的buffer、crypto、evals、fs、os等模块都是部分通过C/C++编写的。

  1. 内建模块的组织形式

    node_extensions.h文件将散列的内建模块统一放进node_module_list数组中。

    内建模块的优势:它们本身由C/C++编写,性能上优于脚本语言,在进行文件编译时,它们被编译进二进制文件。一旦Node开始执行,它们被直接加载进内存中,无须再次做标识符定位、文件定位、编译等过程,直接就可执行。

  2. 内建模块的导出

    文件模块=》核心模块=》内建模块,文件模块依赖核心模块依赖内建模块。

    Node在启动时,会生成一个全局变量process,并提供Binding()方法来协助加载内建模块。

核心模块的引入流程

gjJVnH.jpg

编写核心模块

编写内建模块分为两步,编写头文件和编写C/C++文件。

2.4 C/C++拓展模块

C/C++拓展模块属于文件模块的一类。

C/C++模块通过预先编译为.node文件,然后调用process.dlopen()方法加载执行。

一个平台下的.node文件在另一个平台下是无法加载运行的,必须重新用各自平台的编译器编译为正确的.node文件。

前提条件
  • GYP项目生成工具。

    node-gyp,为Node提供的一个专门的扩展构建工具。

  • V8引擎C++库。

  • libuv库。

    这个库是跨平台的一层封装。libuv封装的功能包括事件循环、文件操作等。

  • Node内部库。

  • 其他库。

C/C++扩展模块的编写

普通的扩展模块与内建模块的区别在于无须将源代码编译进Node,而是通过dlopen()方法动态加载。

C/C++扩展模块的编译

通过输入node-gyp build进行编译。

C/C++扩展模块的加载

require(‘./xx.node’)执行,.node扩展名的文件会调用process.dlopen()方法去加载文件。

加载.node文件实际上经历了两个步骤,第一个步骤是调用uv_dlopen()文件去打开动态链接库,第二个步骤是调用uv_dlsym()方法找到动态链接库中通过NODE_MODULE宏定义的方法低值。

C/C++扩展模块与JavaScript模块的区别在于加载之后不需要编译。

2.5 模块调用栈

2Ckxun.jpg

C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用。

如果不是非常了解C/C++内建模块,尽量避免通过process.binding()调用。

JavaScript核心模块作为C/C++内建模块的封装层和桥接层,供文件模块调用,也是纯粹的功能模块,它不需要跟底层打交道,但又十分重要。

2.6 包与NPM

gjJeHA.jpg

CommonJS的包规范的定义其实也十分简单,它由包结构和包描述文件两个部分组成,前者用于组织包中的各种文件,后者则用于描述包的相关信息,以供外部读取分析。

包结构

包实际上是一个存档文件,即一个目录直接打包为.zip或tag.gz格式的文件。

完全符合CommonJS规范的包目录包含如下文件:

  • package.json:包描述文件。
  • bin:用于存放可执行二进制文件的目录。
  • lib:用于存放JavaScript代码的目录。
  • doc:用于存放文档的目录。
  • test:用于存放单元测试用例的代码。
包描述文件与NPM

CommonJS为package.json文件定义了如下一些必需的字段。

  • name。包名。
  • description。包简介。
  • version。版本号。通常为major.minor.revision格式。
  • keywords。关键词数组,NPM中主要用来做分类搜索。
  • maintainers。包维护者列表。
  • contributors。贡献值列表。
  • bugs。一个可以反馈bug的网页地址或邮件地址。
  • licenses。当前包所使用的许可证列表,表示这个包可以在哪些许可证下使用。
  • repositories。托管源代码的位置列表,表明可以通过哪些方式和地址访问包的源码。
  • dependencies。使用当前包所需要依赖的包列表。

可选字段:

  • homepage。当前包的网站地址。
  • os。操作系统支持列表。
  • cpu。CPU架构的支持列表。
  • engines。支持的JavaScript引擎列表。
  • builtin。标志当前包是否是内建在底层系统的标准组件。
  • directories。包目录说明。
  • implements。实现规范的列表。标志当前包实现了CommonJS的哪些规范
  • scripts。脚本说明对象。

在包描述文件的规范中,NPM实际需要的字段主要有name、version、description、keywords、repositories、author、bin、main、scripts、engines、dependencies、devDependencies。

  • author。包作者。
  • bin。一些包作者希望包可以作为命令行工具使用。
  • main。模块引入方法require()在引入包时,会优先检查这个字段,并将其作为包中其余模块的入口。
  • devDependencies。一些模块只在开发时需要依赖。
NPM常用功能
  1. 查看帮助

    npm help <common>

  2. 安装依赖包

    执行npm install xxx,NPM会在当前目录下创建node_modules目录,然后在该目录下创建xxx目录,接着将包解压到这个目录下。

    • 全局模式安装

      npm install -g xxx会将包安装的node目录/lib/node_modules目录下。

    • 从本地安装

      本地安装只需为NPM指明package.json文件所在的位置即可,它可以是一个包含package.json的存档文件,也可以是一个URL地址,也可以是一个目录下有package.json文件的目录位置。

    • 从非官方源安装

  3. NPM钩子命令

    scripts字段让包在安装或者卸载等过程中提供钩子机制。

  4. 发布包

    • 编写模块

    • 初始化包描述文件

    • 注册包仓库账号

      npm adduser

    • 上传包

      npm publish <folder>

    • 安装包

    • 管理包权限

      npm ower 管理包的所有者

    • 分析包

      npm ls分析包

局部NPM

搭建自己的NPM仓库,参考附录D

NPM潜在问题

包质量良莠不齐。安全问题。

符合Kwalitee的模块要满足的条件:

  • 具备良好的测试。
  • 具备良好的文档(README、API)
  • 具备良好的测试覆盖率。
  • 具备良好的编码规范。
  • 更多条件。

2.7 前后端共用模块

前后端的环境略有差别。

模块的侧重点

浏览器端JavaScript:需要经历从同一个服务器端分发到多个客户端执行。瓶颈在于带宽。需要通过网络加载代码。

服务器端JavaScript:相同的代码需要多次执行。瓶颈在于CPU和内存等资源。从磁盘中加载,两者的加载速度不在一个数量级上。

AMD规范

AMD规范是CommonJS模块规范的一个延伸。它是异步加载模块。

CMD规范

由国内的玉伯提出。与AMD规范的区别在于定义模块和依赖引入的部分。

兼容多种模块规范

类库开发者需要将类库代码包装在一个闭包内。

第3章 异步I/O

Node是首个将异步作为主要编程方式和设计理念。

Node既可以作为服务器端去处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。

3.1 为什么要异步I/O

用户体验

采用异步请求,在下载资源期间,JavaScript和UI的执行都不会处于等待状态,可以继续响应用户的交互行为,给用户一个鲜活的页面。

只有后端能够快速响应资源,才能让前端的体验变好。

资源分配

多线程编程经常面临锁、状态同步等问题,但是多线程在多核CPU上能够有效提升CPU的利用率。

单线程同步编程模型会因阻塞I/O导致硬件资源得不到更优的使用。

Node的优点是利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程上将资源分配得更高效。

Node提供了类似前端浏览器中Web Workers的子进程,该子进程可以通过工作进程高效地利用CPU和I/O。

3.2 异步I/O实现现状

异步I/O与非阻塞I/O

操作系统内核对于I/O只有两种方式:阻塞与非阻塞。

阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。

非阻塞I/O跟阻塞I/O地差别为调用之后会立即返回。

重复调用判断操作是否完成的技术叫做轮询。

现存的轮询技术有以下:

  • read。它是最原始、性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的读取。
  • select。通过对文件描述符上的事件状态来进行判断,最多可以同时检查1024个文件描述符。
  • poll。采用链表的方式避免数组长度的限制,能避免不需要的检查,但当文件描述符较多的时候,它的性能还是十分低下的。
  • epoll。在Linux下效率最高的I/O事件通知机制,利用事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率高。
  • kqueue。实现方式与epoll类似,不过它仅在FreeBSD系统下存在。

轮询技术满足了非阻塞I/O确保获取完整数据的需求。

理想的非阻塞异步I/O

完美的异步I/O是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,直接处理下一个任务,等I/O完成后通过信号或回调将数据传递给应用程序即可。

现实的异步I/O

单线程仅仅只是JavaScript执行的单线程中罢了,在Node中,无论是*nix还是Windows平台,内部完成I/O任务的另有线程池。

gjJK4P.jpg

3.3 Node的异步I/O

完成整个异步I/O环节的有事件循环、观察者和请求对象等。

事件循环

在进程启动时,Node便会创建一个类似于while(true)的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行他们,然后进入下个循环,如果不再有事件处理,就退出进程。

观察者

每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件。

在Windows下,这个循环基于IOCP创建,而在*nix下则基于多线程创建。

请求对象

从JavaScript发起调用到内核执行完I/O操作的过渡过程中,存在一种中间产物,它叫做请求对象。

从JavaScript调用Node的核心模块,核心模块调用C++内建模块,内建模块通过libuv进行系统调用。

执行回调

组装好请求对象、送入I/O线程池等待执行,完成了异步I/O的第一部分,回调通知是第二部分。

gjJQ9f.jpg

小结

异步I/O的几个关键词:单线程、事件循环、观察者和I/O线程池。

除了用户代码无法并行执行外,所有的I/O则是可以并行起来的。

3.4 非I/O的异步API

setTimeout()、setInterval()、setImmediate()和process.nextTick()。

定时器

setTimeout()和setInterval()的实现原理与异步I/O比较类似,只是不需要I/O线程池的参与。

创建的定时器会被插入到定时器观察者内部的一个红黑树中。

process.nextTick()

立即异步执行一个任务,setTimeout(fn,0)的方式较为浪费性能。

定时器中采用红黑树的操作时间复杂度为O(lg(n)),nextTick()的事件复杂度为O(1)。

setImmediate()

setImmediate()方法和proce.nextTick()十分类似,process.nextTIck()中的回调函数执行优先级高于setImmediate()。

原因是proces.nextTick()属于idle观察者,setImmediate()属于check观察者。在每一轮轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。

process.nextTick()的回调函数保存在一个数组中,setImmediate()的结果则是保存在链表中。

process.nextTick()在每轮循环中会将数组中的回调函数全部执行完,setImmediate()的每轮循环中执行链表中的一个回调函数。

3.5 事件驱动与高性能服务器

gjJl38.jpg

集中经典的服务器模型:

  • 同步式。对于同步式的服务,一次只能处理一个请求,并且其余请求都处于等待状态。
  • 每进程/每请求。为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多。
  • 每线程/每请求。为每个请求启动一个线程来处理。当大并发请求到来时,内存将会很快用光,导致服务器缓慢。

Node采用事件驱动的方式处理请求,无须为每个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。即使在大量连接的情况下,也不受线程上下文切换开销的影响 ,这是Node高性能的一个原因。

第4章 异步编程

4.1 函数式编程

高阶函数

高阶函数可以把函数当做参数,或是将函数作为返回值的函数。

偏函数用法

偏函数用法是指创建一个调用另外一个部分——参数或变量已经预置的函数——的函数的用法。

const toString = Object.prototype.toString;
const isType = function(type) {
  return function(obj) {
    return toString.call(obj) === `[object ${type}]`;
  }
}

const isString = isType('String');
const isFunction = isType('Function');

4.2 异步编程的优势与难点

优势

Node特性是基于事件驱动的非阻塞I/O模型,是灵魂所在。

建议对CPU的耗用不要超过10ms,或者将大量的计算分解为诸多的小量计算,通过setImmediate()进行调度。

难点
  1. 难点1:异常处理

    自行编写的异步方法需要遵循这些原则:必须执行调用者传入的回调函数;正确传递回异常供调用者判断。

  2. 难点2:函数嵌套过深

  3. 难点3:阻塞代码

    没有像sleep()这样的线程沉睡功能。

    调用setTimeout()效果会更好

  4. 难点4:多线程编程

    JavaScript中,Web Workers能解决利用CPU和减少阻塞UI渲染,但是不能解决UI渲染的效率问题。

    Node借鉴了这个模式,child_process是基础API,cluster模块是更深层次的应用。

  5. 难点5:异步转同步

4.3 异步编程解决方案

  • 事件发布/订阅模式。
  • Promise/Deferred模式。
  • 流程控制库。
事件发布/订阅模式

Node自身提供的events模块是发布/订阅模式的一个简单实现。

emitter.on('e1',function(mes) {
  console.log(mes)
})

emitter.emit('e1','nihao')

事件侦听器模式也是一种钩子(hook)机制,利用钩子导出内部数据或状态给外部的调用者。

Node对事件发布/订阅的机制做了一些额外的处理

  • 如果对一个事件添加超过10个侦听器,就会得到一条警告,侦听器太多可能导致内存泄漏;调用emiiter.setMaxListeners(0),可以将限制去掉;如果事件的侦听器过多,可能存在过多占用CPU的情景。
  • 为了处理异常,EventEmitter对象对error事件进行了特殊对待。如果运行期间的错误触发了error事件,EventEmitter会检查是否有对error事件添加过侦听器,如果添加则将这个错误交给侦听器处理,否则会作为异常抛出;如果外部没捕获异常会引起线程退出。
  1. 继承events模块

  2. 利用事件队列解决雪崩问题

    once()方法,侦听器只能执行一次。

    雪崩问题,就是在高访问量、大并发量的情况下缓存失效的情景。

  3. 多异步之间的协作方案

    一般而言,事件与侦听器的关系是一对多。

  4. EventProxy的原理

  5. EventProxy的异常处理

Promise/Deferred模式
  1. Promises/A

    • 3种状态,未完成态、完成态和失败态。
    • 从未完成态向完成态或失败态转化,不能逆反。
    • 一旦转化,不能被更改。
  2. Promise中的多异步协作

  3. Promise的进阶知识

    • 支持序列执行的Promise

      要Promise支持链式执行,需要以下两个步骤

      (1)将所有的回调都存到队列中。

      (2)Promise完成时,逐个执行回调,一旦检查到返回了新的Promise对象,停止执行,然后将当前Deferred对象的promise引用改变为新的Pormise对象,并将队列中余下的回调转交给它。

    • 将API Promise化

流程控制库
  1. 尾触发与Next,Connect库

    需要手工调用才能持续执行后续调用。

  2. async库

    • 异步的串行执行
    • 异步的并行执行
    • 异步调用的依赖处理
    • 自动依赖处理
  3. Step库

  4. wind库

4.4 异步并发控制

通过队列来控制并发量

第5章 内存控制

基于无阻塞、事件驱动建立的Node服务,具有内存消耗低的优点,非常适合处理海量的网络请求。

5.1 V8的垃圾回收机制与内存限制

Node与V8

Node在JavaScript的执行上直接受益于V8,可以随着V8的升级就能享受到更好地性能或新的语言特性(如ES5和ES6)等。

V8的内存控制

Node中通过JavaScript使用内存时只能使用部分内存,这会导致Node无法直接操作大内存对象,计算机内存资源无法得到充足的使用。

V8的对象分配

在V8中,所有的JavaScript对象都是通过来进行分配的。

process.memoryUsage()查看内存使用信息。

当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。

考虑到垃圾回收机制所需要的时间会短暂造成JavaScript线程暂停执行,直接限制堆内存是一个好的选择。

这个限制也可以打开,在Node启动时传递--max-old-space-size(设置老生代内存空间的大小)或--max-new-space-size(设置新生代内存空间的大小)来调整内存限制的大小,如node --max-old-space-size=1700 test.js // 单位为MB

V8的垃圾回收机制

V8用到的各种垃圾回收算法

  • V8的内存分代

    V8的垃圾回收策略主要基于分代式垃圾回收机制

    现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

    将内存分为新生代和老生代,新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象

    当内存分配过程超过极限值就会导致进程出错。默认内存配置是64位1.4GB和32位0.7GB。

  • Scavenge算法

    新生代中的对象主要通过Scavenge算法进行垃圾回收,Scavenge算法实现主要采用了Cheney算法。

    Cheney算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,每一部分空间称为semispace,在这两个semispace空间中,一个处于使用状态(From空间),一个处于闲置状态(To空间)分配对象是在From空间进行分配

    当开始进行垃圾回收时,会检查From空间中的存活对象,将存活对象复制到To空间,而非存活对象占用的空间将会被释放,垃圾回收的过程就是通过将存活对象在两个semispace空间之间进行复制。

    Scavenge的缺点是只能使用堆内存中的一半,这是因为划分空间和复制机制所决定的,采用了牺牲空间换取时间的算法。

    Scavenge非常适合应用在新生代中,因为新生代中对象的声明周期较短。

    实际使用的堆内存是新生代中的两个semispace空间大小和老生代所用内存大小之和。

    当一个对象经过多次复制依然存活,它将会被认为是生命周期较长的对象,这种较长生命周期的对象随后被移动到老生代中,采用新的算法进行管理,这种过程称为晋升

    在单纯的Scavenge过程中,From空间中的存活对象会被复制到To空间中去,然后对From空间和To空间进行角色对换(又称翻转)。

    对象晋升的条件一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制(To空间占用比超过25%)。

    在默认情况下,V8的对象分配主要集中在From空间中。对象从From空间中复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收,如果已经经历过,会将该对象从From空间复制到老生代空间中,如果没有,则复制到To空间中。

  • Mark-Sweep & Mark-compact

    Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象

    Mark-Sweep最大的问题是进行一次标记清除回收后,内存空间会出现不连续的状态,这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

    Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。他们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

    V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact

  • Incremental Marking

    垃圾回收的3中基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”。

    为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记,也就是拆分为许多小“步进”,每做完一“步进”,就让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。

    想要高性能的执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。

查看垃圾回收日志

在启动时添加--trace_gc参数可以查看垃圾回收日志。

在启动时添加--prof参数可以得到V8执行时的性能分析数据。

5.2 高效使用内存

作用域

作用域会触发垃圾回收。函数在每次被调用时会创建对应的作用域,函数执行结束后,该作用域将会销毁,作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁,只被局部变量引用的对象存活周期较短。

标识符查找。通过作用域链进行查找。如果变量是全局变量,由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(老生代中),可以通过delete或者将变量重新复制来释放常驻内存的对象。

闭包

实现外部作用域访问内部作用域中变量的方法叫做闭包(closure)。

一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放,除非不再有引用,才会逐步释放。

小结

在正常的JavaScript执行中,无法立即回收的内存有闭包和全局变量引用,由于V8的内存限制,要十分小心此类变量是否无限制的增加,因为它会导致老生代中的对象增多。

5.3 内存指标

查看内存使用情况

os模块中的totalmem()和freemem()方法可以查看内存的使用情况。

  1. 查看进程的内存占用

    Process.meoryUsage()可以查看Node进程的内存占用情况。

    rss表示进程的常驻内存部分,heapUsed表示目前堆中使用中的内存量,heapTotal表示堆中总共申请的内存量。

  2. 查看系统的内存占用

    os模块的totalmem()返回系统的总内存,freemem()返回闲置内存。

堆外内存

不是通过V8分配得内存称为堆外内存。

Buffer对象不同于其他对象,它不经过V8的内存分配机制,不会有堆内存的大小限制。

5.4 内存泄漏

造成内存泄漏的原因有:缓存。队列消费不及时。作用域未释放。

慎将内存当做缓存

缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。

严格意义的缓存有着完善的过期策略,而普通对象的键值对没有。

在node中,任何试图拿内存当缓存的行为要小心为之。

  1. 缓存限制策略

    可以通过限制数量来限制缓存的无限增长。

    由于模块的缓存机制,模块是常驻老生代的,在设计模块时,要十分小心内存泄漏,可以添加清空队列的接口供调用者释放内存。

  2. 缓存的解决方案

    目前较好的解决方案是采用进程外的缓存,进程自身不存储状态。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能,它可以解决Node中的两个问题(1)将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。(2)进程之间可以共享缓存。

    目前市面上较好的缓存有Redis和Memcached。

关注队列状态

解决方案是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。

另一个方案是任意异步调用都应该包含超时机制,如果在限定时间内未完成响应,通过回调函数传递超时异常。

5.5 内存泄漏排查

定位Node应用的内存泄漏工具:

  • v8-profiler。
  • node-heapdump。
  • node-mtrace。
  • dtrace。
  • node-memwatch。

5.6 大内存应用

Node提供的stream模块解决操作大文件的读写问题。

const reader = fs.createReadStream('in.txt');
const writer = fs.createWriteStream('out.txt');

第6章 理解Buffer

Buffer对象用于处理二进制数据。

6.1 Buffer结构

模块结构

Buffer的性能相关部分用C++实现,非性能部分用JavaScript实现。

Buffer所占用的内存不是通过V8分配得,属于堆外内存。

Buffer对象

Buffer对象类似于数组,它的元素为16进制的两位数。

可以通过length属性得到长度,通过下标访问元素。

Buffer内存分配

Node在内存的使用上应用的是在C++层面申请内存、在JavaScript中分配内存的策略。

Node中采用了slab分配机制,slab是一种动态内存管理机制。

slab有如下3中状态

  • full:完全分配状态。
  • partial:部分分配状态。
  • empty:没有被分配状态。

Node以8KB为界限来区分Buffer是大对象还是小对象。这个8KB的值也就是每个slab的大小值,在JavaScript层面,以它作为单位单元进行内存的分配。

1.分配小Buffer对象

Buffer的分配过程中主要使用一个局部变量pool作为中间处理对象,处于分配状态的slab单元都指向它。

new Buffer(1024);构造将会去检查pool对象,如果pool没有则会创建一个新的slab单元指向它,同时当前Buffer对象的parent属性指向该slab,并记录下是从这个slab的哪个位置(offset)开始使用的,slab对象自身也记录被使用了多少字节。

当再创建一个Buffer对象时,如果这个slab的剩余空间足够则使用这个slab的剩余空间并更新分配状态,如果不够则创建一个新的slab并使用,第一个slab剩余的空间将造成浪费。

由于同一个slab可能分配给多个Buffer对象使用,只有这些小Buffer对象在作用域释放并都可以回收时,slab的8KB空间才会被回收。

2.分配大Buffer对象

超过8KB的Buffer对象将会直接分配一个SlowBuffer对象作为slab单元,这个slab单元将会被这个大Buffer对象独占。

上面提到的Buffer对象都是JavaScript层面的,能够被V8的垃圾回收标记回收,但是其内部的parent属性指向的SlowBuffer对象却来自于Node自身C++中的定义,是C++层面上的Buffer独享,所用内存不在V8的堆中。

6.2 Buffer的转换

Buffer对象可以与字符串之间互相转换,目前支持的字符串编码类型有:

  • ASCII
  • UTF-8
  • UTF-16LE/UCS-2
  • Base64
  • Binary
  • Hex
字符串转Buffer

new Buffer(str, [encoding]);

通过构造函数转换的Buffer对象,存储的只能是一种编码类型。encoding参数不传递时,默认按UTF-8编码进行转码和存储。

Buffer对象中可以存在多种编码转化后的内容。不过每种编码所用的字节长度不同,将Buffer反转回字符串时需要谨慎处理。

Buffer转字符串

Buffer对象调用toString()方法可以转为字符串。

buf.toString([encoding],[start],[end]);

Buffer不支持的编码类型

isEncoding()函数判断编码是否支持转换。

iconv和iconv-lite两个模块支持更多的编码类型转换。

6.3 Buffer的拼接

乱码是如何产生的

对于任意长度的Buffer而言,宽字节字符串都有可能存在被截断的情况,只不过Buffer的长度越大出现的概率越低而已。

setEncoding()与string_decoder()

setEncoding()设置编码格式

string_decoder()可以自动进行整数倍字节进行转码,目前可以处理UTF-8、Base64和UCS-2/UTF-16LE。

正确的拼接Buffer
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
  chunks.push(chunk);
  size += chunk.length
})
res.on('end', () => {
  const buf = Buffer.concat(chunks, size);
  const str = iconv.decode(buf, 'utf8');
  console.log(str);
})
Buffer与性能

提高字符串转Buffer的转换效率,可以很大程度提高网络吞吐率。

通过预先转换静态内容为Buffer对象,可以有效地减少CPU的重复使用,节省服务器资源。

在Node构建的Web应用中,可以选择将页面中的动态内容和静态内容分离,静态内容部分可以通过预先转换为Buffer的方式,使性能得到提升。文件自身是二进制数据,在不需要改变内容的场景下,尽量只读取Buffer,然后直接传输,不做额外的转换,避免损耗。

fs.createReadStream()的工作方式是在内存中准备一段Buffer,然后在fs.read()读取时逐步从磁盘中将字节复制到Buffer中。

highWaterMark的大小对性能有两个影响的点:

  • highWaterMark设置对Buffer内存的分配和使用有一定影响。
  • highWaterMark设置过小,可能导致系统调用次数过多。

highWaterMark的大小决定会触发系统调用和data事件的次数。

第7章 网络编程

Node中只需要几行代码即可构建服务器,无需额外的容器。

Node提供net、dgram、http、https这4个模块,分别用于处理TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。

7.1 构建TCP服务

TCP

TCP全名为传输控制协议,在OSI模型(物理层、数据链路层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。

创建TCP服务器端

net模块net.createServer()

TCP服务的事件

1.服务器事件

net.createServer()是一个EventEmitter实例,它的自定义事件有如下几种:

  • listening:在调用server.listen()或者Domain Socket触发。
  • connection:每个客户端套接字连接到服务器端时触发。
  • close:当服务器关闭时触发,在调用server.close()后,服务器将停止接受新的套接字连接,但保持当前存在的连接,等待所有连接都断开后,会触发该事件。
  • error:当服务器发生异常时,会触发该事件。

2.连接事件

服务器可以同时与多个客户端保持连接,每个连接是可读可写Stream对象。Stream对象用于服务器端和客户端之间的通信。

  • data:当一端调用write()发送数据时,另一端会触发data事件,事件传递的数据即是write()发送的数据。
  • end:当连接中的任意一段发送了FIN数据时,将会触发该事件。
  • connect:该事件用于客户端,当套接字与服务器连接成功时会被触发。
  • drain:当任意一端调用write()发送数据时,当前这端会触发该事件。
  • error:当异常发生时,触发该事件。
  • close:当套接字完全关闭时,触发该事件。
  • timeout:当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前连接已经被闲置了。

Node中TCP默认采用了Nagle算法来对小数据包进行一定的优化,Nagle算法要求缓冲区的数据达到一定数量或者一定时间后才将其发出。

调用socket.setNoDelay(true)去掉Nagle算法。

7.2 构建UDP服务

在UDP中,一个套接字可以与多个UDP服务通信。

创建UDP套接字

UDP套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器端接收数据。

const dgram = require('dgram');
const socket = dgram.createSocket('udp4')
创建UDP服务器端

调用dgram.bind(port, [address])方法对网卡和端口进行绑定即可。

创建UDP客户端

调用socket.send(buf,offset,length,port,address,[callback])发送数据。

参数分别是要发送的Buffer、Buffer的偏移、Buffer的长度、目标端口,目标地址、发送完成后的回调。

UDP套接字事件

UDP套接字是一个EventEmitter的事例。

  • message:当UDP套接字侦听网卡端口后,接收到消息时触发该事件,触发携带的数据为消息Buffer对象和一个远程地址信息。
  • listening:当UDP套接字开始侦听时触发该事件。
  • close:调用close()方法时触发该事件,并不再触发message事件。如需再次触发message事件,重新绑定即可。
  • error:当异常发生时触发该事件,如果不侦听,直接抛出异常,使进程退出。

7.3 构建HTTP服务

const http = require('http')
http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'})
  res.end('Hello World\n')
}).listen(1337, '127.0.0.1')
HTTP

HTTP构建在TCP之上,属于应用层协议。B/S模式

浏览器,其实是一个HTTP的代理。

http模块

在Node中,HTTP服务继承自TCP服务器(net模块)。

在开启keep-alive后,一个TCP会话可以用于多次请求和响应。

TCP服务以connection为单位进行服务,HTTP服务以request为单位进行服务。http模块即是将connection到request的过程进行了封装。

在请求产生的过程中,http模块拿到连接中传来的数据,调用二进制模块http_parser进行解析,在解析完请求报文的报头后,触发request事件,调用用户的业务逻辑。

  1. HTTP请求

    请求头放在在req.headers属性上。

    报文体需要数据流结束后才能进行操作。

  2. HTTP响应

    res.setHeader()和res.writeHead()响应报文头部信息。

    可以调用setHeader进行多次设置,当调用writeHead,报文头才会写入到连接中。

    res.write()和res.end()实现报文体部分,res.end()会先调用write()发送数据,然后发送信号告知服务器这次响应结束。

    报头是在报文体前发送,一旦开始了数据的发送,writeHead()和setHeader()将不再生效。

    务必在结束时调用res.end()结束请求,否则客户端将一直处于等待的状态。

  3. HTTP服务的事件

    • 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的状态码,表示接受数据上传;如果不接受数据的较多时,响应客户端400 Bad Request拒绝客户端继续发送数据即可。当该事件发生时不会触发request事件,两个事件之间互斥。当客户端收到100 continue后重新发送请求时,才会触发request事件。
    • connect事件:当客户端发起connect请求时触发,而发起connect请求重新在HTTP代理时出现;如果不监听该事件,发起该请求的连接将会关闭。
    • upgrade事件:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上Upgrade字段,服务器端会在接收到这样的请求时触发该事件。
    • clientError事件:连接的客户端触发error事件时,这个错误会传递到服务器端,此时触发该事件。
HTTP客户端

HTTP模块提供了一个底层API,http.request(options, connect),用于构造HTTP客户端。

options参数决定了这个HTTP请求头的内容,它的选项有如下:

  • host:服务器的域名或ip地址,默认为localhost。
  • hostname:服务器名称。
  • port:服务器端口,默认为80。
  • localAddress:建立网络连接的本地网卡。
  • scoketPath:Domain套接字路径。
  • method:HTTP请求方法,默认为get。
  • path:请求路径,默认为/。
  • headers:请求头对象。
  • auth:Basic认证,这个值将被计算成请求头中的Authorization部分。
  1. HTTP响应

    ClientRequest在解析响应报文时,一解析完响应头就触发response事件。

  2. HTTP代理

    为了重用TCP连接,HTTP模块包含了一个默认的客户端代理对象http.globalAgent。它对每个服务器端(host+port)创建的连接进行了管理,默认情况下,通过ClientRequest对象对同一个服务器端发起的HTTP请求最多可以创建五个连接。它的实质是一个连接池。

    一旦请求量过大,连接限制将会限制服务性能。可以通过在options中传递agent选项。可以设置agent为false脱离连接池的管理,使得请求不受并发的限制。

    Agent对象的sockets和requests属性分别表示当前连接池中使用中的连接数和处于等待状态的请求数。

  3. HTTP客户端事件

    • response:与服务器端的request事件对应的客户端在请求发出后得到服务器端响应时。会触发该事件。
    • socket:当底层连接池中建立的连接分配给当前请求对象时,触发该事件。
    • connect:当客户端向服务器端发起connect请求时,如果服务器端响应了200状态码,客户端将会触发该事件。
    • Upgrade:客户端向服务器端发起Upgrade请求时,如果服务器端响应了101 Switching Protocols状态,客户端将会触发该事件。
    • continus:客户端向服务器端发起Expect: 100-continus头信息,以试图发送较大数据量,如果服务器端响应100 不。continue状态,客户端将会触发该事件。

7.4 构建WebSocket服务

WebScoket客户端基于事件的编程模型与Node中自定义事件相差无几;WebScoket实现了客户端与服务器端之间的长连接,而Node事件驱动的方式十分擅长与大量的客户端保持高并发连接。

WebSocket好处:

  • 客户端与服务器端只建立一个TCP连接,可以使用更少的连接。
  • WebScoket服务器端可以推送数据到客户端,这远比HTTP请求响应模式更灵活、更高效。
  • 有轻量级的协议头,减少数据传送量。

长轮询的原理是客户端向服务器端发起请求,服务器端只在超时或有数据响应时断开连接(res.end())。

使用WebSocket的话,网页客户端只需要一个TCP连接即可完成双向通信,在客户端与服务器端频繁通信时,无需频繁断开连接和重发请求。

WebScoket握手

一旦Websocket握手成功,服务器端与客户端将会呈现对等的效果,都能接收和发送消息。

WebSocket数据传输

gjJ1gS.jpg

ws模块封装了WebScoket的底层实现。

7.5 网络服务与安全

SSL作为一种安全协议,它在传输层提供对网络连接加密的功能。对于应用层而言,它是透明的,数据在传递到应用层之前就已经完成了加密和解密的过程。

Node在网络安全上提供了3个模块,分别是crypto、tls、https。

crypto主要用于加密解密,SHA1、MD5等加密算法都在其中。tls模块提供了与net模块类似的功能,区别在于它建立在TLS/SSL加密的TCP连接上。https与http模块接口一致,区别是它建立在安全的连接之上。

TLS/SSL
  1. 密钥

    TLS/SSL是一个公钥/私钥的结构,它是一个非对称的结构,每个服务器端和客户端都有自己的公私钥。

    公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,通过公钥加密的数据,只有通过私钥才能解密。

    在建立安全传输之前,客户端和服务器端之间需要互换公钥。客户端发送数据时要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密。

    gjJ3jg.jpg

    Node底层采用的是openssl实现TLS/SSL的,生成公钥私钥可以用个openssl完成。

    公私钥可能存在的漏洞是中间人攻击,通过数字证书认证可以解决这个问题。

  2. 数字证书

    数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系。

    CA(Cretificate Authority,数字证书认证中心)。CA的作用是为站点颁发证书,这个证书通过CA通过自己的公钥和私钥实现的签名。

    为了得到签名证书,服务器端需要通过自己的私钥生成CSR(证书签名请求)文件。CA机构将通过这个文件颁发属于该服务器端的签名证书,只要通过CA机构就能验证证书是否合法。

    对于中小型企业,多半是采用自签名证书来构建安全网络的。所谓自签名证书就是自己扮演CA机构。给自己的服务器端颁发签名证书。

TLS服务
  1. 创建服务器端

    const tls = require('tls')
    const server = tls.createServer(options, fn)
    server.listen(8080)
    
  2. TLS客户端

    const stream = tls.connect(8080, options, fn)
    stream.on('data', (data) => {})
    

    与普通的TCP服务器端和客户端相比。TLS的服务器端和客户端仅仅只在证书的配置上有差别,其余部分基本相同。

HTTPS服务

HTTPS服务就是工作在TLS/SSL上的HTTP。

  1. 准备证书

    HTTPS服务需要用到私钥和签名证书。

  2. 创建HTTPS服务

    const https = require('https')
    https.createServer(options, (req, res) => {
      res.writeHead(200)
      res.end("heel world\n")
    }).listen(8000)
    
  3. HTTPS客户端

    const options = {
      hostname: 'localhost',
      port: 8000,
      path: '/',
      method: 'GET',
      key: fs.readFileSync('client.key'),
      cert: fs.readFileSync('client.crt'),
      ca: [fs.readFileSync('ca.crt')]
    }
    
    options.agent = new https.Agent(options)
    
    const req = https.request(options, (res) => {
      res.setEncoding('utf-8')
      res.on('data',(data) => { console.log(data) })
    })
    req.end()
    

第8章 构建Web应用

8.1 基础功能

请求方法

GET、POST、HEAD、DELETE、PUT、CONNECT

HTTP_Parser提供req.method方法获取请求方法

路径分析

HTTP_Parser提供req.url方法获取路径解析

查询字符串

?foo=bar&baz=val,Note提供了querystring模块用于处理这部分数据。

Cookie
1.初识Cookie

Cookie的处理分为如下几步。

  • 服务器向客户端发送cookie。
  • 浏览器将cookie保存。
  • 之后每次浏览器都会将cookie发向服务器端。

HTTP_Parser会将所有的报文字段解析到req.headers上,cookie是req.headers.cookie。Cookie的值格式是key=value;key2=value2。

浏览器端通过document.cookie获取cookie信息。

服务器端第一次发送cookie通过Set-Cooket字段。Set-Cookie: name=value; Paht=/; Expires=Sun, 23-Apr-23 09:01:35 GMT; Domain=.domain.com;

  • path表示这个cookie影响到的路径,当前访问的路径不满足该匹配时,浏览器则不发送这个cookie。
  • Expires和Max-Age是用来告知浏览器这个cookie何时过期的,如果不设置该选项,在关闭浏览器时会丢失掉这个cookie。
  • Secure。当Secure为true时,在HTTP中是无效的,在HTTPS中才有效。
2.Cookie的性能影响

一旦设置的cookie过多将会导致报头较大。

  • 减少cookie的大小。
  • 为静态组件使用不同的域名。
  • 减少DNS查询。
Session

Session的数据值保留在服务器端,客户端无法修改,这样的数据的安全性得到一定的保障,数据也无须在协议中每次都被传递。

  • 基于Cookie来实现用户和数据的映射

  • 通过查询字符串来实现浏览器端和服务器端数据的对应

    它的原理是检查请求的查询字符串,如果没有值,会先生成新的带值的URL。

  1. Session与内存

    Session数据直接存在变量sessions中,它位于内存中。用户数量增多会引起性能问题。

    解决性能问题和Session数据无法跨进程共享的问题,常用的方案是将Session集中化,将原本可能分散在多个进程里的数据,统一转移到集中的数据存储中。目前常用的工具是Redis、Memcached等,通过这些高效的缓存,Node进程无须在内部维护数据对象,垃圾回收问题和内存限制问题都可以迎刃而解。

  2. Session与安全

    Seesion口令会被伪造,可能会造成服务器端数据被利用。

    解决方法:将这个口令通过私钥加密进行签名,使得伪造的成本较高。在响应时将口令和签名进行对比,如果签名非法,我们将服务器中的数据立即过期即可。

缓存

缓存方法:

  • 添加Expires或Cache-Control到报文头中。
  • 配置ETag。
  • 让Ajax可缓存。

浏览器端如果在第二次请求如果不能确定这份本地文件是否可以直接使用,它将会发送一次条件请求,在普通的GET请求报文中,附带If-Modified-Since字段。

如果服务器端没有新的版本,只需响应一个304状态码,客户端就使用本地版本,如果服务器端有新的版本,就将新的内容发送给客户端,客户端放弃本地版本。

这里的条件请求采用时间戳的方式实现,但是时间戳有一些缺陷存在。

  • 文件的时间戳改动但内容并不一定改动。
  • 时间戳只能精确到秒级别,更新频繁的内容将无法生效。

为此HTTP1.1中引入了ETag的来解决这个问题,ETag由服务器端生成,服务器端可以决定它生成规则。

const gethash = (str) => {
  const shasum = crtpto.createHash('sha1')
  return shasum.update(str).digest('base64')
}

const handle = (req, res) => {
  fs.readFile(filename, (err, file) => {
    const hash = getHash(file)
    const noneMatch = req.headers['if-none-match']
    if (hash === noneMatch) {
      res.writeHead(304, 'Not Modified')
      res.end()
    } else {
      res.setHead('ETag', hash)
      res.writeHead(200, 'OK')
      res.end(file)
    }
  })
} 

ETag的请求和响应是If-None-Match/ETag。

在响应里设置Expires或Cache-Control头,浏览器将根据该值进行缓存。

Expires是一个GMT格式的时间字符串。浏览器在接收到这个过期值后,只有本地还存在这个缓存文件,在到期时间之前它都不会再发起请求。Expires的缺陷在于浏览器与服务器之间的时间可能不一致,这可能会带来一些问题,比如文件提前过期或者到期后并没有被删除。Cache-Control以更丰富的形式,实现相同的功能。

Cache-Control设置了max-age值,可以避免服务器端与浏览器端时间不同步带来的不一致性问题。Cache-Control还可以设置public、private、no-cache、no-store等。

在浏览器中如果两个值同时存在且被同时支持时,max-age会覆盖Expires。

清除缓存

浏览器是根据URL进行缓存,一旦内容有所更新时,我们就让浏览器发起新的URL请求。一般的更新机制有如下两种。

  • 路径中跟随Web应用的版本号:http://url.com/?v=20210401
  • 路径中跟随该文件内容的hash值:http://url.com/?hash=afadfadwe

hash值进行缓存淘汰会更加高效。

Basic认证

Basic认证是当客户端与服务器端进行请求时,允许通过用户名和密码实现的一种身份认证方式。

Basic认证会去检查请求报头中的Authorization字段。Basic认证会将用户名和密码部分组合然后进行Base64编码。

响应头中的WWW-Authenticate字段会告知浏览器采用什么样的认证和加密方式。

Basic认证一般在只有HTTPS的情况下才会使用。

8.2 数据上传

通过判断报头的Transfer-EncodingContent-Length来判断请求中是否带有内容。

8.2.1 表单数据

表单提交的请求头Content-Type字段值为application/x-www-form-urlencoded

8.2.2 其他格式

JSON类型的值为application/json,XML类型的值为application/xml

解析JSON文件通过JSON.parse(),解析XML文件通过xml2js模块处理。

8.2.3 附件上传

特殊表单的值为multipart/form-data

8.2.4 数据上传于安全
1.内存限制

解决内存被占光的问题:

  • 限制上传内容的大小,一旦超过限制,停止接收数据,并响应400状态码。
  • 通过流式解析,将数据流导向到磁盘中,Node只保留文件路径等小数据。
// 限制
const bytes = 1024
function (req, res) {
  const len = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : null
  if (len && len > bytes) {
    res.writeHead(413)
    res.end()
    return
  }
}
2.CSRF

CSRF的全称是Cross-Site Request Forgery,中文意思是跨站请求伪造。

解决CSRF攻击的方案有添加随机值的方式。

const generateRandom = (len) => {
  return crypto.randomBytes(Math.ceil(len * 3 / 4)).toString('base64').slice(0, len)
}

8.3 路由解析

8.3.1 文件路径型
  1. 静态文件

    将请求路径对应的文件发送给客户端即可。

  2. 动态文件(少见)

    Web服务器根据URL路径找到对应的文件。

8.3.2 MVC

MVC模型的主要思想是将业务逻辑按职责分离,主要分为以下几种。

  • 控制器(Controller),一组行为的集合。
  • 模型(Model),数据相关的操作和封装。
  • 视图(View),视图的渲染。

工作模式如下

  • 路由解析,根据URL寻找到对应的控制器和行为。
  • 行为调用相关的模型,进行数据操作。
  • 数据操作结束后,调用视图和相关数据进行页面渲染,输出到客户端。

路由映射分为手工映射和自然关联映射。

8.3.3 RESTful

REST的全称是Representational State Transfer,中文含义为表现层状态转化。

通过URL设计资源、请求方法定义资源的操作,通过Accept决定资源的表现形式。

8.4 中间件

中间件可以用来简化和隔离基础设施与业务逻辑之间的细节,让开发者能够关注在业务的开发上,以达到提升开发效率的目的。

8.4.1 异常处理

添加err参数,捕获中间件直接抛出的同步异常。

8.4.2 中间件与性能
  1. 编写高效的中间件

    • 使用高效的方法。
    • 缓存需要重复计算的结果。
    • 避免不必要的计算。
  2. 合理使用路由

    提升匹配成功率,避免QPS直线下降。

8.5 页面渲染

8.5.1 内容响应
  1. MIME

    浏览器通过不同的Content-Type的值来决定采用不同的渲染方式,这个值简称MIME值。

    MIME的全称是Multipurpose Internet Mail Extensions。

  2. 附件下载

    Content-Disposition字段可以让客户端根据它的值判断应该讲报文数据当做即时浏览的内容,还是可下载的附件。

    即时查看值为inline,存为附件值为attachement。Content-Disposition: attachment; filename="filename.ext"

  3. 响应JSON

    res.json = (json) => {
      res.setHeader('Content-Type', 'application/json')
      res.writeHead(200)
      res.end(JSON.stringify(json))
    }
    
  4. 响应跳转

    res.redirect = (url) => {
      res.setHeader('Location', url)
      res.writeHead(302)
      res.end('Redirect to ' + url)
    }
    
8.5.2 视图渲染

通过后端返回html片段生成视图。

8.5.3 模板

实质是将模板文件和数据通过模板引擎生成最终的HTML代码。模板技术使得网页中的动态内容和静态内容变得不互相依赖。

  1. 模板引擎

    • 语法分解。提取出普通字符串和表达式。
    • 处理表达式。将标签表达式转换成普通的语言表达式。
    • 生成待执行的语句。
    • 与数据一起执行,生成最终字符串。

    将原始的模板字符串转成一个函数对象,这个过程称为模板编译。

  2. with的应用

    tpl = 'var tpl = "";\nwith (obj) {' + tpl + '}\nreturn tpl;';

    模板技术需要注意时刻转义。

  3. 模板逻辑

  4. 集成文件系统

  5. 子模板

  6. 布局视图

  7. 模板性能

模板引擎模块EJS

8.5.4 Bigpipe

在数据响应之前,用户看到的是空白页面,这对用户体验十分不友好。Bigpipe的解决思路是将页面分割成多个部分(pagelet),先向用户输出没有数据的布局(框架),将每个部分逐步输出到前端,最终渲染填充框架,完成整个页面的渲染。需要前后端的配合。

第9章 玩转进程

JavaScript运行在单个进程的单个线程上。

9.1 服务模型的变迁

9.1.1 石器时代:同步

服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。QPS为1/N。

9.1.2 青铜时代:复制进程

通过进程的复制同时服务更多的请求和用户。QPS为M/N。

9.1.3 白银时代:多线程

一个线程服务一个请求。由于一个CPU核心在一个时刻只能做一件事,操作系统只能通过将CPU切分为时间片的方法,让线程较为均匀地使用CPU资源。

当线程数量过多时,时间将会被耗用在上下文切换中。QPS为M*L/N。

9.1.4 黄金时代:事件驱动

采用单线程避免了不必要的内存开销和上下文切换开销。

对于Node来说,所有请求的上下文都是统一的。

9.2 多进程架构

Node提供了child_process模块实现多核CPU利用。

gjJJBj.jpg

Master-Worker,主从模式,主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。工作进程负责具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以工作进程的稳定性值得开发者关注。

通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例。它需要至少30毫秒的启动时间和至少10MB的内存。fork()进程是昂贵的。

9.2.1 创建子进程

child_process模块提供创建子进程的能力,4个方法创建子进程。

  • spawn():启动一个子进程来执行命令。
  • exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它由一个回调函数获知子进程的状况。
  • execFile():启动一个子进程来执行可执行文件。
  • fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
9.2.2 进程间通信

主线程与工作线程之间通过onmessage()postMessage()进行通信,子进程对象则由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据。

父进程与子进程之间会创建IPC通道,通过IPC通道,父子进程之间才能通过message()和send()传递消息。

进程间通信原理

IPC全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。

进程间通信的技术有命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等。Node的通信技术具体实现有libuv提供,在Window下由命名管道(named pipe)实现,*nix系统由Unix Domain Socket实现。

父进程在实际创建子进程之前,会创建IPC通信并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。

由于IPC通道是用命名管道或Domain Socket创建的,属于双向通行。IPC通道被抽象为Stream对象,通过send()发送数据,message事件触发给应用层。

9.2.3 句柄传递

gjJYHs.jpg

send()方法第二个可选参数可以发送句柄。句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。

gjJUNq.jpg

1.句柄发送与还原

子进程对象send()方法可以发送的句柄类型如下几种:

  • net.Socket。TCP套接字。
  • net.Server。TCP服务器,任意建议在TCP服务器上的应用层服务都可以享受到它带来的好处。
  • net.Native。C++层面的TCP套接字或IPC管道。
  • dgram.Socket。UDP套接字。
  • dgram.Native。C++层面的UDP套接字。

发送到IPC管道中的实际上是我们要发送的句柄文件描述符,文件描述符是一个整数值。写入到IPC管道会通过JSON.Stringify()进行序列化,子进程读取时通过JSON.parse()解析还原。

2.端口共同监听

send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引起异常。

多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。

9.3 集群稳定之路

9.3.1 进程事件

除了message事件,还有如下事件:

  • error:当子进程无法被复制创建、无法被杀死、无法发送消息时触发该事件。
  • exit:子进程退出时触发该事件,子进程如果是正常退出,这个事件的第一个参数为退出码,否则为null。如果进程是通过kill()被杀死,会得到第二个参数表示杀死进程时的信号。
  • close:在子进程的标准输入输出流中止时触发该事件,参数与exit相同。
  • disconnect:在父进程或子进程调用disconnect()方法时触发该事件,在调用该方法时将关闭监听IPC管道。

kill()方法给子进程发送消息,它并不能真正的杀死子进程,子进程需要监听信号列表,之后做出回应。

9.3.2 自动重启
const fork = require("child_process").fork;
const cpus = require("os").cpus();

const server = require("net").createServer();
server.listen(1337);

const workers = {};
const createWorker = () => {
  const worker = fork("./worker.js");
  worker.on("exit", () => {
    console.log(`Worker ${worker.pid} exited.`);
    delete workers[worker.pid];
    createWorker();
  });
  worker.send("server", server);
  workers[worker.pid] = worker;
  console.log(`Create worker. pid: ${worker.pid}`);
};

for (let i = 0; i < cpus.length; i++) {
  createWorker();
}

process.on("exit", () => {
  for (let pid in workers) {
    workers[pid].kill();
  }
});

  1. 自杀信号

    工作进程在得知要退出时,向主进程发送一个自杀信号,然后才停止新的连接,当所有连接断开后才退出。

    主进程在接受到自杀任务后,立即创建新的工作进程服务。

  2. 限量重启

    在单位时间内规定只能重启多少次,超过限制就触发giveup事件。

9.3.3 负载均衡

保证多个处理单元工作量公平的策略叫负载均衡。

Node默认提供的机制是采用操作系统的抢占式策略。谁抢到谁服务。

**轮叫调度:**由主进程接受连接,将其依次分发给工作进程。分发的策略是在N个工作进程中,每次选择第i=(i+1)mod n个进程来发送连接。

9.3.4 状态共享

实现数据在多个进程之间的共享:

  1. 第三方数据存储

    将数据存放到数据库、磁盘文件、缓存服务(如Redis)。

    通过定时轮询实现状态同步。轮询时间不能过密。

  2. 主动通知

    当数据发生更新时,主动通知子进程。

    为了不混合业务逻辑,可以添加一个通知进程来进行轮询和通知的工作,不处理任何业务逻辑。

9.4 Cluster模块

cluster模块解决多核CPU的利用率问题,提供了较完善的API,用以处理进程的健壮性问题。

9.4.1 Cluster工作原理

cluster模块就是child_process和net模块的组合应用。

在cluster模块应用中,一个主进程只能管理一组工作进程。

9.4.2 Cluster事件
  • fork:复制一个工作进程后触发该事件。
  • online。
  • listening。
  • disconnect。
  • exit。
  • setup。

第10章 测试

测试的意义在于,在用户消费产出的代码之前,开发者首先消费它,给予其重要的质量保证。为自己代码写测试用例是一种行之有效的方法,它能够让开发者明确掌握到代码的行为和性能等。

测试包含单元测试、性能测试、安全测试和功能测试等几个方面。

10.1 单元测试

10.1.1 单元测试的意义

单元测试是最基本的能保证产品的质量的一种方式,如果没自己测试代码,那必然要面对如下问题:

  1. 测试工程师是否可依赖?

  2. 第三方代码是否可依赖?

  3. 在产品迭代过程中,如何继续保证质量?

    单元测试的意义在于每个测试用例的覆盖都是一种可能的承诺。

缺少单元测试的质量保证,会陷入举步维艰的维护之路,拆东墙补西墙,开发者也渐渐变得只想做新项目,而旧的项目最后变得不可维护或不敢维护。

当无法为一段代码写出单元测试时,这段代码必然有坏味道,这样的代码最需要重构。好代码的单元测试必然是轻量的,重构和写单元测试之间是一个相互促进的步骤。

编写可测试代码有以下几个原则可以遵循:

  • 单一职责。将职责进行解耦分离,变成两个单一职责的方法。
  • 接口抽象
  • 层次分离。逐层测试,逐层保证。
10.1.2 单元测试介绍

单元测试主要包括断言、测试框架、测试用例、测试覆盖率、mock、持续集成等几个方面。

1. 断言

断言就是单元测试中用来保证最小单元是否正常的测试方法。

Node通过assert模块来实现断言。

  • ok():判断结果是否为真。
  • equal():判断实际值与期望值是否相等。
  • notEqualI()。
  • deepEqual():判断实际值与期望值是否深度相等。
  • notDeepEqual()。
  • strictEqual():判断实际值与期望值是否严格相等(===)。
  • notStrictEqual()。
  • throws():判断代码是否抛出异常。
  • doesNotThrow()。
  • ifError():判断实际值是否为一个假值(null、undefined、0、‘’、false),如果实际值为真指,将会抛出异常。
2. 测试框架

测试框架用于为测试服务,它本身并不参与测试,主要用于管理测试用例和生成测试报告,提升测试用例的开发速度。

mochajest测试框架。

现今流行的单元测试风格主要有TDD(测试驱动开发)和BDD(行为驱动开发),它们的差别如下:

  • 关注点不同。TDD关注所有功能是否被正确实现,每一个功能都具备对应的测试用例;BDD关注整体行为是否符合预期,适合自顶向下的设计方式。
  • 表达方式不同。TDD的表述方式偏向于功能说明书的风格;BDD的表述方式更接近于自然语言的习惯。

BDD采用describeit组织,还提供了beforeafterbeforeEachafterEach四个钩子方法。TDD采用steuptest完成,还提供setupteardown钩子函数。

3. 测试代码的文件组织

在package.json的devDependencies添加mocha字段即可。

4. 测试用例

一个测试用例中包含至少一个断言。测试用例最少需要通过正向测试和反向测试来保证测试对功能的覆盖。

  • 异步测试

    通过it回调函数的实参调用注入通知测试框架当前用例执行完成才能继续执行下一个测试用例。

  • 超时设置

    mocha给所有涉及异步的测试用例添加超时限制,防止测试程序卡死。

    可以通过mocha -t <ms>或者在it中调用this.timeout(ms)设置。

5. 测试覆盖率

测试覆盖率是单元测试的一个重要指标,它能够概括性地给出整体的覆盖度,也能明确地给出统计到行的覆盖情况。

6. mock
7. 私有方法的测试

rewire模块提供一种巧妙的方式实现对私有方法的访问。

每一个被rewire引入的模块的方法都有__set____get__方法。

10.1.3 工程化和自动化
1. 工程化

使用Makefile等成熟工具

  • Makefile文件的缩进必须是tab符号,不能用空格。
  • 记得在包描述文件中配置blanket。
2. 持续集成

利用travis-ci实现持续集成。

10.2 性能测试

性能测试包括负载测试、压力测试和基准测试等。

10.2.1 基准测试

基准测试要统计的就是在多少时间内执行了多少次某个方法。

10.2.1 压力测试

做压力测试需要考查的有吞吐率、响应时间和并发数,这些指标反映了服务器的并发处理能力。

最常用的工具有ab、siege、http_load等。

10.2.3 基准测试驱动开发

流程:

  1. 写基准测试。
  2. 写/改代码。
  3. 收集数据。
  4. 找出问题。
  5. 回到第2步。
10.2.4 测试数据与业务数据的转换

每天100万的业务访问量换算为QPS,主要访问量集中在10小时以内,QPS=PV/10h,约等于27.7,即服务器需要每秒处理27.7个请求才能胜任业务量。

第11章 产品化

在实际产品中,需要很多非编码相关的工作比如工程化、架构、容灾备份、部署和运维等来保证项目的进展和产品的正常运行等。

11.1 项目工程化

项目工程化,可以理解为项目的组织能力。在项目工程化过程中,最基本的几部是目录结构、构建工具、编码规范和代码审查等。

11.1.1 目录结构

项目主要分为Web应用和模块应用。普通的模块应用遵循CommonJS的模块和包规范,Web应用只要遵循单一原则即可。

11.1.2 构建工具
1.MakeFile

Makefile文件通常用来管理一些编译相关的工作。

2.Grunt

Grunt用Node写成,能够在window和*nix平台下运行。

Makefile依托强大的bash编程,Grunt则依托它丰富的插件,它自身提供通用接口用于插件的插入。

11.1.3 编码规范

制定良好的编码规范,有助于项目后期的维护。

11.1.4 代码审查

代码审查主要是请求合并的过程中完成,需要审查的点有功能是否正确完成、编码风格是否符合规范、单元测试是否有同步添加等。

如果不符合规范,就需要重新更改代码,然后再提交审查。

11.2 部署流程

11.2.1 部署环境

测试环境(stage)->预发布环境(pre-release)->生产环境(product)。

11.2.2 部署操作

通过脚本实现应用的启动、停止和重启等操作。

11.3 性能

  • 做专一的事。
  • 让擅长的工具做擅长的事情。
  • 将模型简化。
  • 将风险分离。
11.3.1 动静分离

让Node只处理动态请求,静态文件引导到专业的静态文件服务器处理。

11.3.2 启用缓存

避免不必要的计算能带来性能的显著提升。

11.3.3 多进程架构

pm、forever、pm2模块用于进程管理。

11.3.4 读写分离

数据库读写分离,使用主从设计。

11.4 日志

11.4.1 访问日志

通过日志中间件记录日志。

11.4.2 异常日志

console对象:

  • console.log:普通日志。
  • console.info:普通信息。
  • console.warn:警告信息。
  • console.error:错误信息。

异常尽量由最上层的调用者捕获记录,底层调用或中间层调用中出现的异常只要正常传递给上层的调用方即可。

11.4.3 日志与数据库

不要将日志直接写入数据库,将日志分析和日志记录两个步骤分离是较好的选择。

11.4.4 分割日志

当访问量巨大时,将产生的日志按日期分割是一个不错的主意。

11.5 监控报警

监控分为业务逻辑型的监控和硬件型的监控。

11.5.1 监控
1.日志监控

从访问日志中实现PV和UV的监控,同QPS值一样,通过对PV/UV的监控,可以很好地知道应用的使用者们的习惯、预知访问高峰等。

2.响应时间

通过Nginx的反向代理监控,也可以通过应用自行产生的访问日志来监控。

3.进程监控

检查工作进程的数量,如果低于预估值,就应当发出报警声。

4.磁盘监控

监控磁盘的用量。

5.内存监控

监控服务器的内存使用状况,可以检查应用中是否存在内存泄漏的情况。

6.CPU占用监控

CPU使用分为用户态(服务器应用开销)、内核态(服务器进程调度和系统调用)、IOWait(CPU等待磁盘I/O操作)等。

7.CPU load监控

CPU load用来描述操作系统当前的繁忙程度。

8.I/O负载
9.网络监控

网络流量监控的两个主要指标是流入流量和流出流量。

10.应用状态监控
11.DNS监控
11.5.2 报警的实现
  • 邮件报警

    通过nodemailer模块实现。

  • 短信或电话报警

11.6 稳定性

  • 多机器
  • 多机房
  • 容灾备份

11.7 异构共存

协议几乎是解决异构系统最完美的方案。

  • 27
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值