ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
[TOC] > 大部分内容摘取自《深入浅出 node.js》- 朴灵 > 有些地方真的比较 “深入” 鉴于个人水平只记录一些要点 # 第 1 章 node.js 简介 ## Node 的特点 1.异步 I/O:Node 在底层构建了很多异步 I/O 的 API,从文件读取到网络请求,极大地提升了程序性能。 2.事件与回调函数:配合异步 I/O,将事件点暴露给业务逻辑。这种事件的编程方式具有轻量级、松耦合、只关注事务点等优势。 3.单线程:单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。但是它也有以下弱点(child process 模块出现后都得到了解决或缓解): - 无法利用多核 CPU - 错误会引起整个应用退出,应用的健壮性值得考验 - 大量计算占用 CPU 导致无法继续调用异步 I/O 在浏览器端,Web Workers 能够创建工作线程来进行计算,以解决 JavaScript 大量计算阻塞 UI 渲染的问题,工作线程为了不阻塞主线程,通过消息传递的方式来传递运行结果,这也使得工作线程不能访问到主线程的 UI。 Node 采用了与 Web Worker 相同的思路来解决单线程中大计算量的问题:child process。子进程的出现,意味着 Node 可以从容地应对单线程健壮性和无法利用多核 CPU 方面的问题。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果。 4.跨平台:借助 libuv 实现跨平台运行。 ## Node 的应用场景 探讨的比较多的主要有 I/O 密级型和 CPU 密级型。 Node 面向网络并且擅长并行 I/O,能够有效地组织起更多的硬件资源,在 I/O 密集型场景表现还是不错的。 CPU 密集型应用给 Node 带来的挑战主要是:由于 JavaScript 单线程的原因,如果有长时间运行的计算(比如大循环),将会导致 CPU 时间片不能释放,使得后续 I/O 无法发起。Node 虽然没有提供多线程用于计算支持,但是有以下两个方式来充分利用 CPU: - 编写 C/C++ 扩展,将一些 V8 不能做到性能极致的地方通过 C/C++ 来实现 - 通过子进程的方式,将一部分 Node 进程当作常驻服务进程用于计算,然后利用进程间的消息来传递结果,将计算与 I/O 分离 ## Node 使用者的倚重点 - 前后端语言环境统一 - 高性能 I/O 用于实时应用(应用在长连接中,通过 socket.io 实现实时通知的功能) - 并行 I/O 使得使用者可以更高效地利用分布式环境 - 云计算平台提供 Node 支持 ## 经典的服务器模型的比较 - 同步式:一次只处理一个请求,并且其余请求都处于等待状态 - 每进程 / 每请求:为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多 - 每线程 / 每请求:为每个请求启动一个线程来处理。尽管线程比进程要轻量,但是由于每个线程要占用一定的内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢。 Node 通过事件驱动的方式处理请求,无须为每个请求创建额外的线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低,这便是 Node 高性能的一个原因。 # 第 2 章 Node 的模块实现 ## CommonJS 的模块规范 CommonJS 对模块的定义主要分为模块引用、模块定义和模块标识 3 个部分 1.模块引用 模块引用的示例代码如下: ```js const math = require('math') ``` require() 方法接受模块表示,以此引入一个模块的 API 到当前上下文中 2.模块定义 在模块中,存在一个 module 对象,它代表模块自身,而 exports 是 module 的属性,用于导出当前模块的方法或变量。在 Node 中,**一个文件就是一个模块**。 3.模块标识 模块标识就是传递给 require() 方法的参数,它必须是符合小驼峰命名的字符串,或者以 .、.. 开头的相对路径,或者绝对路径,可以没有文件后缀名 .js。 定义模块的意义在于:将类聚的方法和变量限定在私有的作用域中,每个模块具有独立的空间,它们互不干扰,在引用时也显得干净利落。这套导出和引入机制使得用户完全不必考虑变量污染。 ![](https://box.kancloud.cn/36f6c12b9bd42233b813129b410a143f_611x235.png =400x) ## Node 的模块实现 规范中 require 和 exports 使用起来十分方便,但是 Node 在实现它们的过程中经历了什么呢? 在 Node 引入模块,需要经历如下 3 个步骤: 1. 路径分析 2. 文件定位 3. 编译执行 在 Node 中,模块分为两类:一类是由 Node 提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。 - 核心模块部分在 Node 源代码的编译过程中,编译进了二进制执行文件,在 Node 进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。 - 文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。 ## 模块加载过程(浅析) ### 优先从缓存加载 与前端浏览器会缓存静态脚本文件以提高性能一样,Node 对引入过的模块都会进行缓存,以减少二次引入时的开销。不同之处在于,浏览器仅仅缓存文件,而 Node 缓存的是编译和执行之后的对象。 不论是核心模块还是文件模块,require() 方法对相同模块的二次加载都一律采用缓存优先的方式,不同之处在于核心模块的检查先于文件模块的缓存检查。 ### 路径分析和文件定位 1、模块标识符分析 require() 方法接受一个标识符作为参数,Node 正是基于这样一个标识符进行模块查找的。模块标识符在 Node 中主要分为以下几类: - 核心模块,如 http、fs、path 等 - `.`或`..`开始的相对路径文件模块 - 以`/`开始的绝对路径文件模块 - 非路径形式的文件模块,如自定义的 connect 模块 **核心模块** 核心模块的优先级仅次于缓存加载,它在 Node 的源代码编译过程中已经编译为二进制代码,其加载过程最快。如果视图加载一个与核心模块标识符相同的自定义模块,那是不会成功的。如果自己编写了一个 http 用户模块,想要加载成功,必须选择一个不同的标识符或者换用路径的方式。 **路径形式的文件模块** 以`.`、`..`、`/`开头的标识符,这里都被当作文件模块来处理。在分析路径模块时,require() 方法会将路径转为真实路径,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。 **自定义模块** 自定义模块指的是非核心模块,也不是路径形式的标识符(比如 npm install 的库)。这类模块的查找是最费时的,也是所有方式中最慢的一种。 在介绍自定义模块的查找方式之前,首先需要了解 <span style="font-family: 楷体">模块路径</span> 这个概念。 模块路径是 Node 在定位文件模块的具体文件时定制的查找策略,具体表现为一个路径组成的数组,关于这个路径的生成规则,我们可以手动尝试一番: (1)创建 module_path.js 文件,其内容为 console.log(module.paths) (2)将其放到任意一个目录中然后执行 node module_path.js 在 Windows 下,可以得到这样的一个输出: ```txt [ 'D:\\Proj\\JS代码片段\\node_modules', 'D:\\Proj\\node_modules', 'D:\\node_modules' ] ``` 可以看出,模块路径的生成规则如下所示: - 当前文件目录下的 node_modules 目录 - 父目录下的 node_modules 目录 - 父目录的父目录下的 node_modules 目录 - 沿路径向上逐级递归,直到根目录下的 node_modules 目录 它的查找方式与 JavaScript 的原型链或作用域链的查找方式十分类似:在加载的过程中,Node 会逐个尝试模块中的路径,直到找到目标文件为止。可以看出,当前文件的路径越深。模块查找耗时会越多,这便是自定义模块的加载速度最慢的原因。 2、文件定位 从缓存加载的优化策使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。 在文件定位的过程中,有一些细节需要注意,这主要包括文件扩展名的分析、目录和包的处理。 ✪ 文件扩展名的分析 require() 在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况。CommonJS 模块规范也允许在标识符中不包含文件扩展名,这种情况下,Node 会按 .js、.node、.json 的次序补足扩展名,依次尝试。 在尝试的过程中,需要调用 fs 模块同步阻塞式地判断文件是否存在。因为 Node 是单线程的,所以这里是一个会引起性能问题的地方。小诀窍是:如果是 .node 和 .json 文件,在传递给 require() 的标识符中带上扩展名,会加快一点速度。另一个诀窍是:同步配合缓存,可以大幅缓解 Node 单线程中阻塞式调用的缺陷(怎么做?) ✪ 目录分析和包 在分析标识符的过程中,require() 通过分析文件扩展名之后,可能没有查找到对应文件,但却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,此时 Node 会将目录当做一个包来处理。 在这个过程中,Node 对 CommonJS 包规范进行了一定程度的支持。首先,Node 在当前目录下查找 package.json(CommonJS 包规范定义的包描述文件),通过 JSON.parse() 解析出包描述对象,从中取出 main 属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。而如果 main 属性指定的文件名错误,或者压根没有 package.json 文件,Node 会将 index 当作默认文件名,然后依次查找 index.js、index.node、index.json 如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找,如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。 ### 模块编译 在 Node 中,每个文件模块都是一个对象,它的定义如下: ```js function Module (id, parent) { this.id = id this.exports = {} this.parent = parent if (parent && parent.children) { parent.children.push(this) } this.filename = null this.loaded = false this.children = [] } ``` 编译和执行是引入文件模块的最后一个阶段。定位到具体的文件后,Node 会新建一个模块对象,然后根据路径载入并编译。对于不同的文件扩展名,其载入方法也有所不同,具体如下: - .js 文件。通过 fs 模块同步读取文件后编译执行 - .node 文件。这是用 C/C++ 编写的扩展文件,通过 dlopen() 方法加载最后编译生成的文件 - .json 文件。通过 fs 模块同步读取文件后,用 JSON.parse() 解析返回结果 - 其余扩展名文件,它们都被当做 .js 文件载入 每一个编译成功的模块都会将其文件路径作为索引缓存在 Module._cache 对象上,以提高二次引入的性能。 编译的过程较为复杂,有兴趣的请自行阅读。这里只记录下 JavaScript 模块的编译过程。 <br /> **JavaScript 模块的编译** 我们知道每个模块文件中都存在着 require、exports、module 这 3 个变量,但是它们在模块文件中并没有定义,那么它们从何而来呢?在 Node 的 API 文档中,我们知道每个模块还有`__filename`、`__dirname`这两个变量的存在,它们又是从何而来的呢? 事实上,在编译的过程中,Node 对获取的 JavaScript 文件内容进行了头尾包装。一个正常的 JavaScript 文件会被包装成如下的样子: ```js (function (exports, require, module, __filename, __dirname) { var math = require('math') exports.data = function (radius) { return Math.PI * radius * radius } }) ``` 这样每个模块文件之间都进行了作用域隔离。包装之后的代码会通过 vm 原生模块的 runInThisConText() 方法执行(类似 eval,只是具有明确上下文,不会污染全局),返回一个 function 对象。最后,将当前模块对象的 exports 属性、require() 方法、module(模块对象自身),以及在文件定位中的得到的完整文件路径和文件目录作为参数传递给这个 function() 执行。这就是这些变量并没有定义在每个模块文件中却存在的原因。 # 第 3 章 异步 I/O Node 实现异步 I/O 的过程可以提取为几个关键词:单线程、事件循环、观察者、I/O 线程池。 这里的单线程和 I/O 线程池之间看起来有些悖论的样子,事实上,在 Node 中,除了 JavaScript 是单线程外,Node 自身其实是多线程的,只是 I/O 线程使用的 CPU 较少。另一个需要重视的观点则是,除了用户代码无法并行执行外,所有的 I/O (磁盘 I/O 和网络 I/O 等)都是可以并行执行的。 ## 事件循环 这里将 Node 的事件循环抽象一下便于理解: ![](https://box.kancloud.cn/91f7d718ecd8e56935da8b4359d86516_548x687.png =400x) 在进程启动时,Node 便会创建一个类似于 white(true) 的循环,每执行一次循环体的过程我们称为 Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下个循环,如果不再有事件处理,就退出流程。 ## 观察者 在每个 Tick 的过程中,如何判断是否有事件需要处理呢?这里必须要引入的概念是观察者。每个事件循环中有一个或者多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有需要处理的事件。 在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件对应的观察者有文件 I/O 观察者、网络 I/O 观察者等。 事件循环是一个典型的 *生产者/消费者模型*。异步 I/O、网络请求等是事件的生产者,源源不断地为 Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。 在 Windows 下,这个循环基于 IOCP 创建,而在 *nix 下则基于多线程创建。 ## 请求对象 对于 Node 中的异步 I/O 调用而言,回调函数不由开发者来调用,那么我们发出调用后,到回调函数被执行,中间发生了什么呢?事实上,从 JavaScript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,它叫做**请求对象**。 下面我们以 fs.open() 方法作为例子,来探索 Node 与底层之间是如何执行异步 I/O 调用以及回调函数究竟是如何被调用执行的: ```js fs.open = function (path, flags, mode, callback) { // ... ImageBitmapRenderingContext.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback) } ``` fs.open() 的作用是根据指定路径和参数取打开一个文件,从而得到一个文件描述符,这是后续所有 I/O 操作的初始操作。JavaScript 层面的代码通过调用 C++ 核心模块进行下层的操作,如图所示: ![](https://box.kancloud.cn/f9f17e45b29a54545dc8c21a2c2de453_600x717.png =400x) 从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里最经典的调用方式。 在 uv_fs_open() 的调用过程中,会创建一个 FSReqWrap 请求对象,从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中,回调函数则被设置在这个对象的 oncomplete_sym 属性上: `req_wrap->object_Set(oncomplete_sym, callback)` 对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem() 方法将这个 FSReqWrap 对象推入线程池中等待执行,该方法的代码如下: `QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)` 第一个参数是要执行的方法的引用,第二个参数是这个方法运行时所需要的参数,第三个参数是执行的标志。当线程池中有可用线程时,就会调用 us_fs_thread_proc() 方法,该方法会根据传入参数的类型调用相应的底层函数。 至此,JavaScript 调用立即返回,JavaScript 线程可以继续执行当前任务的后续操作。当前的 I/O 操作在线程池中等待执行,不管它是否阻塞 I/O,都不会影响到 JavaScript 线程的后续执行,如此就达到了异步的目的。 总结:请求对象是异步 I/O 过程中的重要产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及 I/O 操作完毕后的回调处理。 ## 执行回调 线程池中的 I/O 操作调用完毕后,会将获取的结果存储在 req->result 属性上,然后通知 IOCP(Windows),告诉当前对象操作已经完成,并将线程归还线程池。 事件循环中的 I/O 观察者,在每次 Tick 的执行中,会检查线程池中是否有执行完的请求,如果存在,会把请求对象加入到 I/O 观察者的队列中,然后将其当做事件处理。 I/O 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,然后调用执行。 至此,整个异步 I/O 的流程完全结束,如图所示: ![](https://box.kancloud.cn/89f26d3994d2a7842fc45d2a0941ecf5_719x655.png) # 第 5 章 内存控制 ## V8 的垃圾回收机制与内存限制 在一般的后端开发语言中,基本的内存使用上没有什么限制,然而在 Node 中通过 JavaScript 使用内存时会发现只能使用部分内存(64 位系统下约为 1.4 GB,32 位系统下约为 0.7 GB)。在这样的限制下,将会导致 Node 无法直接操作大内存对象,比如无法将一个 2GB 的文件读入内存中进行字符串分析处理。(stream 模块解决了这个问题) 造成这个问题的主要原因在于 Node 基于 V8 构建,V8 的内存管理机制在浏览器的应用场景下绰绰有余,但在 Node 中却限制了开发者。所以我们有必要知晓 V8 的内存管理策略。 ## V8 的对象分配 在 V8 中,所有的 JavaScript 对象(object)都是通过堆来进行分配的,Node 提供了 V8 中内存使用量的查看方式,如下: ```js process.memoryUsage() { rss: 21434368, heapTotal: 7159808, heapUsed: 4455120, external: 8224 } ``` 其中,heapTotal 和 heapUsed 是 V8 的堆内存使用情况,前者是已申请到的堆内存,后者是当前使用的量。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过 V8 的限制为止。 至于 V8 为何要限制堆的大小,主要是内存过大会导致垃圾回收引起 JavaScript 线程暂停执行的时间增长,应用的性能和响应会直线下降,这样的情况不仅仅是后端服务无法接受,前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。 不过 V8 也提供了选项让我们打开这个限制,Node 在启动时可以传递如下的选项: ```js node --max-old-space-size=1700 test.js // 单位为 MB 设置老生代的内存空间 node --max-new-space-size=1024 test.js // 单位为 KB 设置新生代的内存空间 ``` 上述参数在 V8 初始化时生效,一旦生效就不能再改变。 ## V8 的垃圾回收机制 V8 的垃圾回收策略主要基于分代式垃圾回收机制,在实际应用中,人们发现没有一种垃圾回收算法能够胜任所有的场景,因为对象的生存周期长短不一,不同的算法只能针对特定情况具有最好的效果。因此,现代的垃圾回收算法按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。 在 V8 中,主要将内存分为新生代和老生代。新生代的对象为存活时间较短的对象,老生代的对象为存活时间较长或常驻内存的对象。 ![](https://box.kancloud.cn/40ea50ead570213f5f04b2b993786c3b_563x121.png) *Scavenge 算法* 在分代的基础上,新生代的对象主要通过 Scavenge 算法进行垃圾回收,在 Scavenge 的具体实现中,主要采用了 Cheney 算法。 Cheney 算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。 ![](https://box.kancloud.cn/502e51312fb7dda5a4dd51d83b06e76b_558x153.png) 当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将被释放。 完成复制后,From 空间和 To 空间的角色发生对换。 - Scavenge 的缺点是只能使用堆内存中的一半 - Scavenge 是典型的牺牲空间换取时间的算法,适合应用于新生代中,因为新生代中对象的生命周期较短 - 当一个对象经过多次复制仍然存活时,它将会被认为是生命周期较长的对象,其随后会被移动到老生代中,这一过程称为**晋升** *Mark-Sweep & Mark-Compact* 老生代中的对象生命周期较长,存活对象占较大比重,V8 在老生代主要采用 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收 Mark-Sweep:标记清除,其分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记活着的对象,在清除阶段只清除没有被标记的对象。Mark-Sweep 最大的问题在于进行一次标记清除回收后,内存空间会出现不连续的状态,内存碎片会对后续的内存分配造成问题,比如碎片空间不足以分配一个大对象导致提前触发垃圾回收。 于是就有了 Mark-Compact:标记整理,简单来说就是标记完成后加一个整理阶段,存活对象往一端移动(合并),整理完成后直接清理掉边界外的内存。 ![](https://box.kancloud.cn/0868a9bce8dfec64cf974161956d8957_677x333.png) *Incremental Marking* 为了避免出现 JavaScript 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的 3 种基本算法需要将应用逻辑暂停下来,待执行玩垃圾回收后再恢复执行应用逻辑,这种行为被称为全停顿(stop-the-world)。 对于新生代来说,全停顿的影响不大,但是对于老生代就需要改善。 为了降低全堆垃圾回收带来的停顿时间,V8 采用了增量标记(incremental marking)的技术,大概是将原本一口气停顿完成的动作拆分为许多小“步进”,每做完一“步进”就让 JavaScript 应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。 ![](https://box.kancloud.cn/b0770128dd32e3ffadb08f96395fcf5c_734x218.png) V8 后续还引入了延迟清理(lazy sweeping)、增量式整理(incremental compaction)、[并发标记](https://www.oschina.net/translate/v8-javascript-engine) 等技术,感兴趣的可以自行了解。 ## 查看垃圾回收日志 启动时添加 --trace_gc 参数,这样在进行垃圾回收时,将会从标准输出中打印垃圾回收的日志信息。 下面是一段示例,执行结束后,将会在 gc.log 文件中得到所有垃圾回收信息: ```shell node --trace_gc -e "var a = []; for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log ``` 通过在 Node 启动时使用 --prof 参数,可以得到 V8 执行时的性能分析数据: ```shell node --prof test.js ``` # 第 6 章 理解 Buffer 在网络流和文件的操作中需要处理大量二进制数据,JavaScript 自有的字符串远远不能满足这些需求,于是 Buffer 对象应运而生。 Buffer 是一个像 Array 的对象,但它主要用于操作字节,其是一个典型的 JavaScript 与 C++ 结合的模块,它将性能相关部分用 C++ 实现,将非性能相关部分应用 JavaScript 实现。 Node 在进程启动时就已经加载了 Buffer 模块,并将其放在全局对象 global 上,使用时无须 require。 ## Buffer 对象 Buffer 对象类似于数组,它的元素为 16 进制的两位数,即 0 到 255 的数值 ```js let str = '深入浅出node.js' let buf = new Buffer(str, 'utf-8') console.log(buf) // => <Buffer e6 b7 b1 e5 85 a5 e6 b5 85 e5 87 ba 6e 6f 64 65 2e 6a 73> ``` 可以看到,不同编码的字符串占用的元素个数不同,中文字在 UTF-8 编码下占用 3 个元素,字母和半角标点符号占用 1 个元素。 Buffer 与 Array 类型很相似,可以访问 length 属性得到长度,也可以通过下标访问元素,构造对象时也十分相似,代码如下: ```js let buf = new Buffer(100) // 分配一个长 100 字节的 Buffer 对象 console.log(buf.length) // 100 console.log(buf[10]) // 初始化为 0? buf[10] = 100 // 可以通过下标进行赋值 console.log(buf[10]) // => 100 // 给元素的赋值如果小于 0,就将该值逐次加 256,直到得到一个 0 ~ 255 之间的整数; // 如果得到的数值大于 255,就逐次减 256,直到得到 0 ~ 255 区间内的整数; // 如果是小数,舍弃小数部分,只保留整数部分 buf[20] = -100 console.log(buf[20]) // 156 buf[21] = 300 console.log(buf[21]) // 44 buf[22] = 3.1415 console.log(buf[22]) // 3 ``` ## Buffer 内存分配 Buffer 对象的内存分配不是在 V8 的堆内存中,而是在 Node 的 C++ 层面实现内存的申请的。因为处理大量的字节数据不能采用需要一点内存就向操作系统申请一点内存的方式,这可能造成大量的内存申请的系统调用,对操作系统有一定压力。为此 Node 在内存的使用上应用的是在 C++ 层面申请内存,在 JavaScript 中分配内存的策略。 为了高效地使用申请来的内存,Node 采用了 slab 分配机制,这里不做记录了。 ## Buffer 的转换 Buffer 对象可以与字符串之间相互转换,其支持的字符串编码类型包括以下几种: - ASCII - UTF-8 - UTF-16LE/UCS-2 - Base64 - Binary - Hex 字符串转 Buffer:通过构造函数完成,存储的只能是一种编码类型。 ```js new Buffer(str, [encoding]) // encoding 参数不传递时,默认按 UTF-8 编码进行转码和存储 ``` Buffer 转字符串:Buffer 对象的 toString() 方法可以将 Buffer 对象转换为字符串: ```js buf.toString([encoding], [start], [end]) // encoding 默认为 UTF-8 // start、end 可实现局部的转换 ``` ## Buffer 的拼接 ...... ## Buffer 与性能 ...... # 第 7 章 网络编程 ## 构建 TCP 服务 ```js // 构造 TCP 服务器 const net = require('net') const server = net.createServer(function (socket) { // 新的连接 socket.on('data', function (data) { socket.write('你好') }) socket.on('end', function () { console.log('连接断开') }) socket.write('TCP 连接示例 \n') }) server.listen(8124, function () { console.log('server bound') }) ``` ```js // 构造 TCP 客户端 const net = require('net') const client = net.connect({ port: 8124 }, function () { // 'connect' listener console.log('client connected') client.write('world!\r\n') }) client.on('data', function (data) { console.log(data.toString()) client.end() }) client.on('end', function () { console.log('client disconnected') }) ``` TCP 服务的事件:在上面的示例中,代码分为服务器事件和连接事件。 1.服务器事件,通过 net.createServer() 创建的服务器而言,它具有如下事件 - listening: 调用 server.listen() 后触发 - connection:每个客户端套接字连接到服务器端时触发 - close:服务器关闭时触发 - error:服务器发生异常时触发 2.连接事件:服务器可以同时与多个客户端保持连接,每个连接是典型的**可读可写 Stream 对象**,既可以通过 data 事件从一端读取另一端发来的数据,也可以通过 write() 方法从一端向另一端发送数据,它具有如下事件: - data:当一端调用 write() 发送数据时,另一端会触发 data 事件,事件传送的数据即是 write() 发送的数据 - end:当连接中的任意一端发送了 FIN 数据时,将会触发该事件 - connect:与服务器端连接成功时触发 - drain:当任意一端调用 write() 发送数据时,当前这端会触发该事件 - error:异常发生时触发 - close:套接字完全关闭时触发 - timeout:当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前该连接已经被闲置了 ## 构建 UDP 服务 TCP 中所有的会话基于连接完成,UDP 中一个套接字可以与多个 UDP 服务通信,其提供面向事务的简单不可靠信息传输服务,在网络差的情况下存在丢包严重的问题,但是由于它无须连接,资源消耗低,处理快速且灵活,所有常用于那种即使丢失一两个数据包也不会产生重大影响的场景,比如音频、视频等。DNS 服务也是基于 UDP 实现的。 ```js // 构建 UDP 服务器 // UDP 套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器端接收数据 const dgram = require('dgram') const server = dgram.createSocket('udp4') server.on('message', function (msg, rinfo) { console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`) }) server.on('listening', function () { const address = server.address() console.log(`server is listening at ${address.address}: ${address.port}`) }) server.bind(41234) // 该套接字将接收所有网卡上 41234 端口上的消息,绑定完成后将触发 listening 事件 ``` ```js // 构建 UDP 客户端 const dgram = require('dgram') const message = new Buffer('深入浅出Node.js') const client = dgram.createSocket('udp4') client.send(message, 0, message.length, 41234, 'localhost', function (err, bytes) { client.close() }) // socket.send(buf, offset, length, port, address, [callback]) /* buf: 要发送的 Buffer offset: Buffer 的偏移 length: Buffer 的长度 port: 目标端口 address: 目标地址 callback: 发送后的回调 */ ``` UDP 套接字事件: - message:当 UDP 套接字侦听网卡端口后,接收到消息触发该事件 - listening:UDP 套接字开始侦听时触发该事件 - close:调用 close() 方法后触发 - error:异常发生时触发 ## HTTP ### 1.HTTP 请求 报文头第一行如 GET / HTTP/1.1 被解析之后分解为如下属性: - req.method:请求方法,值为 GET、POST、DELETE、PUT、CONNECT 等 - req.url:这里值为 / - req.httpVersion:值为 1.1 其余报头是很规律的 Key:Value 形式,被解析后放置在 req.headers 属性上,例如 ```js headers: { 'user-agent': ..., host: '127.0.0.1:1337', accept: '*/*' } ``` 报文体部分则抽象为一个只读流对象,如果业务逻辑需要读取报文体中的数据,则要在这个数据流结束后才能进行操作: ```js function (req, res) { let buffers = [] req.on('data', function (trunk) { buffers.push(trunk) }).on('end', function () { let buffer = Buffer.concat(buffers) // TODO res.end('hello world') }) } ``` 这里的 request 对象和 response 对象都是相对较为底层的封装,express 等框架在这两个对象的基础上进行了高层封装。 ### 2.HTTP 响应 HTTP 响应对象封装了对底层连接的写操作,可以将其看成一个可写的流对象。它影响响应报文头部信息的 API 为 res.setHeader() 和 res.writeHead()。 我们可以调用 setHeader 进行多次设置,但只有调用 writeHead 后,报头才会写入到连接中。 报文体部分则是调用 res.write() 和 res.end() 方法实现的,其差别在于 res.end() 会先调用 write() 发送数据,然后发送信号告知服务器这次响应结束。 需要注意以下几点: - 报头是在报文体发送前发送的,一旦开始了数据的发送,writeHead() 和 setHeader() 将不再生效,这是由协议的特性决定的 - 务必调用 res.end() 以结束请求,否则客户端将一直处于等待的状态。当然,也可以通过延迟 res.end() 的方式实现客户端与服务器之间的长连接 ### 3.HTTP 服务的事件 - connection:开启 HTTP 请求和响应前,客户端与服务器需要建立底层的 TCP 连接,该连接建立时触发一次 connection 事件 - request:服务器解析出 HTTP 请求头后触发 - close:调用 server.close() 后触发 - checkContinue:某些客户端再发送较大的数据时,并不会将数据直接发送,而是先发送一个头部带 Expect:100-continue 的请求到服务器,服务器将会触发 checkContinue 事件;如果没有为服务器监听这个事件,服务器将会自动响应客户端 100 Continue 的状态码,表示接收数据上传;如果不接受较大的数据,响应客户端 400 Bad Request 拒绝客户端继续发送即可。需要注意的是,当该事件发生时不会触发 request 事件,两个事件之间互斥。当客户端收到 100 Continue 后重新发送请求时,才会触发 request 事件。 - connect:当客户端发起 CONNECT 请求时触发,发起 CONNECT 请求通常在 HTTP 代理时出现;如果不监听该事件,发起该请求的连接将会关闭。 - upgrade:当客户端要求升级连接的协议时,需要和服务器协商,客户端会在请求中带上 Upgrade 字段,服务器会在接收到这样的请求时触发该事件,如果不监听该事件,发起该请求的连接将会关闭。(对 WebSocket 很重要) - clientError:连接的客户端发 error 事件时,这个错误会传送到服务器端,触发该事件。 ### 4.HTTP 客户端 ```js const options = { hostname: '127.0.0.1', port: 1334, path: '/', method: 'GET' } /* options 参数决定了 HTTP 请求头中的内容 host: 服务器的域名或 IP 地址,默认 localhsot hostname: 服务器名称 port: 服务器端口,默认 80 localAddress: 建立网络连接的本地网卡 socketPath: Domain 套接字路径 method: HTTP 请求方法,默认 GET path: 请求路径,默认 / headers: 请求头对象 auth: Basic 认证,这个值将被计算成请求头中的 Authorization 部分 */ const req = http.request(options, function (res) { console.log(`STATUS: ${res.statusCode}`) console.log(`HEADERS: ${JSON.stringify(res.headers)}`) res.setEncoding('utf-8') res.on('data', function (chunk) { console.log(chunk) }) }) ``` HTTP 客户端事件: - response - socket - connect - upgrade - continue ## 构建 WebSocket 服务 - WebSocket 客户端基于事件的编程模型与 Node 中自定义事件相差无几 - WebSocket 实现了客户端与服务器端之间的长连接,而 Node 事件驱动的方式十分擅长与大量的客户端保持高并发连接 WebSocket 与传统 HTTP 相比有如下好处: - 客户端与服务器端只建立一个 TCP 连接,可以使用更少的连接 - WebSocket 服务器端可以推送数据到客户端,这远比 HTTP 请求响应模式更灵活高效 - 有更轻量级的协议头,减少数据传送量 WebScoket 协议主要分为两个部分:握手和数据传输 客户端建立连接时,通过 HTTP 发起请求报文,如下所示: ```shell GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13 ``` 与普通的 HTTP 协议略有区别的部分在于如下这些请求头: ```js Upgrade: websocket Connection: Upgrade // 以上表示请求服务器端升级协议为 WebSocket Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== // 用于安全校验 Sec-WebSocket-Protocol: chat, superchat // 子协议 Sec-WebSocket-Version: 13 // 版本号 ``` 服务端在处理完请求后,响应如下报文:告知客户端正在更换协议,更新应用层协议为 WebSocket 协议,并在当前的套接字上应用新协议 ```shell HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocal: caht ``` 一旦 WebSocket 握手成功,服务器端与客户端将会呈现对等的效果,都能接收和发送消息。 ***** 在握手顺利完成后,当前连接将不再进行 HTTP 的交互,而是开始 WebSocket 的数据帧协议。 ![](https://box.kancloud.cn/41566e04af305f5a09112665a4d92f96_780x541.png =450x) ```js // 实例化一个 WebSocket 对象,并传入要连接的 URL var socket = new WebSocket('url') // url 中要使用 ws:// 来代替 http:// ; 使用 wss 来代替 https:// // 成功建立连接时会触发 open 事件 socket.onopen = function () { console.log('established') } // 发生错误时会触发 error 事件 socket.onerror = function () { console.log('error') } // 当连接关闭时会触发 close 事件 socket.onclose = function () { console.log('closed!') } // 使用 send() 方法发送数据,只能接受字符串,json 对象要先序列化成 json 字符串 socket.send(str) // 当服务器端向客户端发来消息,WebSocket 对象就会触发 message 事件 socket.onmessage = function (event) { console.log(event.data) } // 调用 close() 方法,会关闭 WebScoket 连接 socket.close() ``` 使用的话还是建议直接用社区的 socket.io 比较好。 ## HTTPS Node 在网络安全上提供了 3 个模块,分别是 crypto、tls、https。其中 crypto 主要用于加密解密,SHA1、MD5 等加密算法都在其中有体现;tls 模块用于建立 TLS/SSL 加密的 TCP 连接;https 模块与 http 模块接口一致,区别仅在于它建立于安全的连接上。 > TLS(Transport Layer Security,安全传输层协议)可以认为就是 SSL(Secure Socket Layer,安全套阶层)的标准化后的名称,在传输层提供对网络连接加密的功能 中间人攻击:客户端与服务器端在交换公钥的过程中,中间人对客户端扮演服务器的角色,对服务器端扮演客户端的角色,因此客户端和服务器端几乎感受不到中间人的存在。 ![](https://box.kancloud.cn/b7d47966c0f28b3a4183dd2264a79f22_752x355.png =450x) 因此,需要引入数字证书来对公钥进行验证。 数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而产生信任关系。 CA(Certificate Authority,数字证书认证中心)的作用是为站点颁发证书,且这个证书中具有 CA 通过自己的公钥和私钥实现的签名。 为了得到签名证书,服务器端需要通过自己的私钥生成 CSR(Certificate Signing Request,证书签名请求)文件。CA 机构将通过这个文件颁发属于该服务器端的签名证书,只要通过 CA 机构就能验证证书是否合法。 通过 CA 机构颁发证书是一个烦琐的过程,需要付出一定的经理和费用,对于中小型企业,多半是采用自签名证书来构建安全网络的。所谓自签名证书,就是自己扮演 CA 机构,给自己的服务器端颁发签名证书。 ......