Repository: semlinker/node-deep Branch: master Commit: 62a00e721abe Files: 10 Total size: 118.9 KB Directory structure: gitextract_ajd81ox4/ ├── README.md ├── buffer/ │ ├── Node.js Buffer 实战.md │ └── 深入学习Node.js Buffer.md ├── event/ │ └── 深入学习 Node.js EventEmitter.md ├── http/ │ ├── 深入学习 Node.js Http 基础篇.md │ └── 深入学习 Node.js Http.md ├── module/ │ ├── 深入学习 Node.js Module 进阶篇.md │ └── 深入学习 Node.js Module.md ├── net/ │ └── 深入学习 Node.js Net.md └── stream/ └── 深入学习 Node.js Stream 基础篇.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: README.md ================================================ ## 深入学习 Node.js 2017 年一直有个想法,就是好好地学一下 Node.js,后面由于工作需要踏上了 Angular 的踩坑之路,学习期间在 Segmentfault 开了个专栏 [Angular 4.x 修仙之路](https://segmentfault.com/a/1190000008754631),在写专栏的过程中,亲身体验到了写作的 “艰辛”,同时也感受到了写一篇好文章(通俗易懂、干货十足),需要的投入很多时间和精力,真的特别烧脑。 2018 年已经过了 1/3,为了不想在 2019 年留下遗憾,决定静下心来好好地学一下 Node.js 和相关的框架。多年一直保持做学习笔记的习惯,因此在学 Node.js 的过程,我会用心的整理好学习笔记,有写得不好的地方,欢迎各位小伙伴指出,同时也希望我的笔记对同在 Node.js 探索之路的小伙伴们会有些帮助。 此外,在学习的过程中,也参考了很多大神的文章,感谢他们对知识的无私奉献。 ![logo](logo.png) ### 目录 #### 理论篇 * [深入学习 Node.js EventEmitter](https://github.com/semlinker/node-deep/blob/master/event/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20EventEmitter.md) * [深入学习 Node.js Buffer](https://github.com/semlinker/node-deep/blob/master/buffer/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0Node.js%20Buffer.md) * [深入学习 Node.js Module](https://github.com/semlinker/node-deep/blob/master/module/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Module.md) * [深入学习 Node.js Module 进阶篇](https://github.com/semlinker/node-deep/blob/master/module/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Module%20%E8%BF%9B%E9%98%B6%E7%AF%87.md) * [深入学习 Node.js Http 基础篇](https://github.com/semlinker/node-deep/blob/master/http/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Http%20%E5%9F%BA%E7%A1%80%E7%AF%87.md) * [深入学习 Node.js Http](https://github.com/semlinker/node-deep/blob/master/http/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Http.md) * [深入学习 Node.js Net](https://github.com/semlinker/node-deep/blob/master/net/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Net.md) * [深入学习 Node.js Stream 基础篇](https://github.com/semlinker/node-deep/blob/master/stream/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Stream%20%E5%9F%BA%E7%A1%80%E7%AF%87.md) #### 实战篇 * [Node.js Buffer 实战](https://github.com/semlinker/node-deep/blob/master/buffer/Node.js%20Buffer%20%E5%AE%9E%E6%88%98.md) ### 资源 #### 流 * [github — stream-handbook](https://github.com/substack/stream-handbook) * [github — streamify-your-node-program](https://github.com/zoubin/streamify-your-node-program/blob/master/README.md) * [medium — node-js-streams-everything-you-need-to-know](https://medium.freecodecamp.org/node-js-streams-everything-you-need-to-know-c9141306be93) #### 事件循环 * [不要混淆nodejs和浏览器中的event loop](https://cnodejs.org/topic/5a9108d78d6e16e56bb80882) #### 系列教程 - [深入理解Node.js:核心思想与源码分析](https://yjhjstz.gitbooks.io/deep-into-node/) - [Davidc.ai - Node.js 源码分析系列](https://davidc.ai/archives/) - [Xiedacon - Node.js 源码分析系列](http://www.xiedacon.com/archives/) - [NodeJS源码分析-由浅入深解析架构以及运行原理](https://github.com/fzxa/NodeJS-Nucleus-Plus-Internals) - [Node.js 挖掘系列](https://cnodejs.org/user/LanceHBZhang) - [Fz-node](https://215566435.github.io/Fz-node/#/home) ================================================ FILE: buffer/Node.js Buffer 实战.md ================================================ ## Node.js Buffer 实战 - [Node.js Buffer 实战](#nodejs-buffer-实战) - [预备知识](#预备知识) - [Base64](#base64) - [Data URLs](#data-urls) - [语法](#语法) - [示例](#示例) - [常见问题](#常见问题) - [Node.js Buffer 实战](#nodejs-buffer-实战-1) - [实战一 Buffer 转换为其他格式](#实战一--buffer-转换为其他格式) - [实战二 使用 Buffers 来修改字符串编码](#实战二--使用-buffers-来修改字符串编码) - [实战三 处理 data URLs](#实战三--处理-data-urls) - [实战四 创建自己的网络协议](#实战四--创建自己的网络协议) - [讨论](#讨论) - [总结](#总结) - [参考资源](#参考资源) ### 预备知识 #### Base64 > **Base64**是一种基于64个可打印字符来表示[二进制数据](https://zh.wikipedia.org/wiki/%E4%BA%8C%E8%BF%9B%E5%88%B6)的表示方法。由于![{\displaystyle 2^{6}=64}](https://wikimedia.org/api/rest_v1/media/math/render/svg/c4becc8d811901597b9807eccff60f0897e3701a),所以每6个[比特](https://zh.wikipedia.org/wiki/%E4%BD%8D%E5%85%83)为一个单元,对应某个可打印字符。3个[字节](https://zh.wikipedia.org/wiki/%E5%AD%97%E8%8A%82)有24个比特,对应于4个Base64单元,即3个字节可表示4个可打印字符。它可用来作为[电子邮件](https://zh.wikipedia.org/wiki/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6)的传输[编码](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6%E7%BC%96%E7%A0%81)。在Base64中的可打印字符包括[字母](https://zh.wikipedia.org/wiki/%E6%8B%89%E4%B8%81%E5%AD%97%E6%AF%8D)`A-Z`、`a-z`、[数字](https://zh.wikipedia.org/wiki/%E6%95%B0%E5%AD%97)`0-9`,这样共有62个字符,此外两个可打印符号在不同的系统中而不同。一些如[uuencode](https://zh.wikipedia.org/wiki/Uuencode)的其他编码方法,和之后[BinHex](https://zh.wikipedia.org/w/index.php?title=BinHex&action=edit&redlink=1)的版本使用不同的64字符集来代表6个二进制数字,但是不被称为 Base64。 > > Base64常用于在通常处理文本[数据](https://zh.wikipedia.org/wiki/%E6%95%B0%E6%8D%AE)的场合,表示、传输、存储一些二进制数据,包括[MIME](https://zh.wikipedia.org/wiki/MIME)的[电子邮件](https://zh.wikipedia.org/wiki/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6)及[XML](https://zh.wikipedia.org/wiki/XML)的一些复杂数据。 —— [维基百科](https://zh.wikipedia.org/wiki/Base64) #### Data URLs Data URLs,即为前缀为 `data:scheme ` 的 URL,其允许内容创建者向文档中嵌入小文件。 ##### 语法 ``` data:[][;base64], ``` `mediatype ` 是个 MIME 类型的字符串,例如 "`image/jpeg`" 表示 JPEG 图像文件。如果被省略,则默认值为 `text/plain;charset=US-ASCII`。 如果数据是文本类型,你可以直接将文本嵌入 (根据文档类型,使用合适的实体字符或转义字符)。如果是二进制数据,你可以将数据进行 base64 编码之后再进行嵌入。 ##### 示例 ``` data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D ``` ##### 常见问题 * 长度限制:虽然 Firefox 支持无限长度的 `data` URLs,但是标准中并没有规定浏览器必须支持任意长度的 `data` URIs。比如,Opera 11浏览器限制 URLs 最长为 65535 个字符,这意外着 data URLs 最长为 65529 个字符(如果你使用纯文本 data:,而不是指定一个 MIME 类型的话,那么 65529 字符长度是编码后的长度,而不是源文件)。 * 缺乏错误处理:MIME 类型错误或者 base64 编码错误,都会造成 `data` URIs 无法被正常解析, 但不会有任何相关错误提示。 ### Node.js Buffer 实战 在[深入学习 Node.js Buffer](https://github.com/semlinker/node-deep/blob/master/buffer/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0Node.js%20Buffer.md)中我们介绍了 `Buffer.from()` 方法,然后通过简单示例分析了 `buffer.js` 文件内的 `fromString()` 函数,最后介绍了 Array#slice() 与 Buffer#slice() 方法之间的区别。 这篇文章我们将参考 "Node.js硬实战:115个核心技巧" 这本书中的示例,来实战一下 Buffer 对象的相关 API。 在 Node.js 中如果没提供编码格式,那么文件操作以及很多网络操作就会将数据作为 Buffer 类型返回,以 fs.readFile 为例: ```javascript const fs = require("fs"); fs.readFile("./names.txt", (err, buf) => { console.log(`It's buffer object? ${Buffer.isBuffer(buf) ? "Yes" : "No"}`); }); ``` #### 实战一 Buffer 转换为其他格式 默认情况下,没有指定编码格式,Node 的一些核心 API 都会返回 Buffer 对象。比如你需要把一个 Buffer 转换为文本类型,此时你可以利用 Buffer API 提供的 toString() 方法: ```javascript const fs = require("fs"); fs.readFile("./names.txt", (err, buf) => { console.log(buf.toString()); }); ``` toString() 方法默认使用 utf8 的编码格式进行解码。但是,如果我们知道这个数据仅仅包含了ascii 字符时,我们也可以把编码改为 ascii 来提升性能。为了实现这个,我们需要提供编码类型作为 toString() 方法的第一个参数: ```javascript const fs = require("fs"); fs.readFile("./names.txt", (err, buf) => { console.log(buf.toString('ascii')); }); ``` #### 实战二 使用 Buffers 来修改字符串编码 除了把 Buffer 转换为字符串,你也可以利用 Buffer 来进行字符串的编码格式转换。有时候,创建一个字符串数据后修改它的编码格式很有用的。例如,当你需要从一个使用基础验证的服务器请求数据时,你需要发送 Base64 编码的用户名和密码: ``` Authorization: Basic c2VtbGlua2VyOmxvdmVfbm9kZQ== ``` 在进行 Base64 编码前,基础验证需要把用户名和密码拼接到一起,用 `:` 分隔开来,例如,我们使用 semlinker 作为用户名,而 love_node 作为密码: ```javascript let authstring = 'semlinker:love_node'; ``` 现在我们需要把这个字符串转为 Buffer 对象,然后修改它的编码格式: ```javascript let encoded = Buffer.from(authstring).toString('base64'); // c2VtbGlua2VyOmxvdmVfbm9kZQ== ``` 在浏览器环境中,我们可以通过 `window.btoa("semlinker:love_node")` 来实现 Base64 编码。 #### 实战三 处理 data URLs Data URLs 是说明 Buffer API 非常有用的例子: ```javascript const fs = require("fs"); const mime = "image/png"; const encoding = "base64"; const data = fs.readFileSync("./logo.png").toString(encoding); const url = "data:" + mime + ";" + encoding + "," + data; ``` 上面示例中,我们利用 `fs.readFileSync()` 方法读取本地的 `logo.png` 文件,然后使用 `Buffer` 对象的 `toString()` 方法把内容转换为 Base64 编码的字符串,最后使用 Data URLs 的语法,生成 Data URLs。 作为前端在日常开发过程中,我们有时候会使用 FileReader 对象的 `readAsDataURL()` 方法,把文件转换为 Data URLs 的形式,比如: ```javascript const reader = new FileReader(); reader.onload = function (e) { let dataURL = fileReader.result; }; reader.readAsDataURL(file); ``` 在获取到 Data URLs 数据后,我们就可以把该数据提交到后台服务器,当服务器接收到请求参数后,就可以对数据进行解析,然后把内容保存为相应 MIME 类型的文件,这里就不详细展开。 接下来,我们来看一个简单的解析 base64 字符串并输出文件的示例: ```javascript const fs = require('fs'); const uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...'; const data = uri.split(',')[1]; const buf = Buffer(data, 'base64'); fs.writeFileSync('./new-logo.png', buf); ``` #### 实战四 创建自己的网络协议 有些时候,你需要一种在进程或者网络之间进行高效的数据通信。针对这种场景,你就可以利用 Node.js 的 Buffer API 可以来创建自定义二进制协议。 ##### 讨论 在这个例子中,我们要开发一个简洁的数据库协议,主要内容有: - 使用掩码来确定数据存放于哪个数据库。 - 数据保存一个在 0~255 范围之内的无符号正数(单字节)的键值来标识。 - 存储通过 zlib 压缩的任意长度的数据。 | Byte | 内容 | | | ---- | ---------- | ----------------------- | | 0 | 1 byte | 决定数据要写入到哪个数据库 | | 1 | 1 byte | 一个字节的无符号整数用做数据库键存储 | | 2-n | o -n bytes | 存储的数据,任意通过 zlib 进行压缩的字节 | - 使用比特来表示选择哪个数据库 在我们定义的协议中,第一个字节用于表示选择哪个数据库来存储传输的数据。在数据接收端,主数据库就像一个简单的多维数组,支配着 8 个数据分库(恰好一个字节等于 8 比特)。在 JavaScript 中可以简单地使用数组字面量来表示: ```javascript var database = [[],[],[],[],[],[],[],[]]; ``` 比特对应位置的数据库会将存储接收到的数据。例如,数字 8 对应的二进制 `00001000`。我们会把数据信息存储在第 4 个数据库中,因为第 4 个比特位是 1(比特按照从右往左的顺序)。 通常,数值的二进制不仅仅只有一位为 1。例如,20 的二进制表示是 `00010100`,在应用中意味着我们要把数据存储在第 3 个和第 5 个数据库。所以得想想办法,把给定的任意一个数值转换为只有一位为 1 的二进制。这里需要用到位掩码。 位掩码可以达到我们想要的效果。例如,我们想要确定是否要把数据存放在第 5 个数据库,可以创建一个第 5 位为 1 的位掩码。用二进制的格式表示为 00010000,即是数值 32(或者十六进制的 0x20)。之后我们就可以使用这个掩码去测试某个数值是否满足要求,在 JavaScript 中我们可以利用 `&` 位与来实现该功能。比如: ``` 00010100 &00010000 ---------- 00010000 ``` 我们可以看到,如果一个值和对应的位掩码相同,或者本身为 1 时,通过运算后的值依旧还是对应的位掩码。利用 `&` 位与运算符的特性,我们就可以设置一个简单的条件来判断: ```javascript if((value & bitmask) === bitmask) { // ... } ``` 为了测试接收到的二进制协议中的第一个字节,我们需要建立一个掩码列表,用来对应数据库的索引。如果匹配到对应的掩码,那么可以确定是哪个数据库需要写入数据。用一个数组来记录每一个二进制不同位为 1 的值,对应的掩码表如下: * 1 - 0000 0001 * 2 - 0000 0010 * 4 - 0000 0100 * 8 - 0000 1000 * 16 - 0001 0000 * 32 - 0010 0000 * 64 - 0100 0000 * 128 - 1000 0000 此时我们可以设置一个简单的循环来测试每一个掩码对应的第一个字节的值: ```javascript var database = [[],[],[],[],[],[],[],[]]; var bitmasks = [1, 2, 4, 8, 16, 32, 64, 128]; function store(buf){ var db = buf[0]; // 决定数据要写入到哪个数据库 bitmasks.forEach(function(bitmask, index) { if((db & bitmask) === bitmask) { // 执行匹配后的操作 } }); } ``` 接下来我们来看一下如何找出数据存储的键值。 * 找出数据存储的键值 在数据库协议中,数据字节位为 1 的值是一个无符号整数(0~255),用于表示数据库中存储数据的键值。我们特意把数据库设置为一个多维数据,第一维用于表示数据分库,第二维用于存储键值和数据,由于键值为数字,使用数组便可以了。 举个例子,数据 `foo` 存放在第一个和第三个数据库中的 0 键值,数据结构看来是这样的: ```javascript [ ['foo'], [], ['foo'], [], [], [], [], [] ] ``` 从字节位 1 中获取键值,可以使用 `readUInt8()` 方法: ```javascript var key = buf.readUInt8(1); // 也可以使用buf[1] ``` 我们把上述的代码加入到之前的代码中: ```javascript var database = [[],[],[],[],[],[],[],[]]; var bitmasks = [1, 2, 4, 8, 16, 32, 64, 128]; function store(buf){ var db = buf[0]; // 决定数据要写入到哪个数据库 var key = buf.readUInit8(1); // 一个字节的无符号整数用做数据库键存储 bitmasks.forEach(function(bitmask, index) { if((db & bitmask) === bitmask) { database[index][key] = 'foo'; } }); } ``` 现在我们已经完成数据库协议中两个环节,接下来完成最后一个环节的内容,即使用 zlib 实现解压缩。 * 使用 zlib 解压缩 在传输字符串 ASCII/UTF-8 数据时进行压缩绝对是一个好主意,这可以大大减少传输使用的带宽。在简介的数据库协议中,我们假定接收到的数据是已经压缩过的。在 Node.js 中内置的 `zlib` 模块提供了 deflate(压缩)、inflate(解压缩)的方法,它也包括了 gzip 的压缩方法。为了防止获取到错误的信息,需要检查接收到的数据是否经过了正确的压缩,如果不是,则不进行解压。通常,zlib 压缩的数据结果第一个字节为 0x78,我们根据以下条件来判断: ```javascript if(buf[2] === 0x78) { //...} ``` 需要注意的是,我们从第 2 个字节位开始,因为前面已经处理过数据库索引和键值。 确定处理的是压缩过的数据后,可以使用 zlib.inflate 方法来解压缩。我们还需要使用 buf.slice() 来截取数据那一个部分,更新后的代码如下: ```javascript var zlib = require('zlib'); var database = [[],[],[],[],[],[],[],[]]; var bitmasks = [1, 2, 4, 8, 16, 32, 64, 128]; function store(buf){ var db = buf[0]; // 决定数据要写入到哪个数据库 var key = buf.readUInt8(1); // 一个字节的无符号整数用做数据库键存储 if(buf[2] === 0x78){ // 一般来说,zlib压缩的数据结果第一个字节为0x78 zlib.inflate(buf.slice(2), function(er, inflatedBuf){ if(er) return console.error(er); var data = inflatedBuf.toString(); bitmasks.forEach(function(bitmask, index) { if((db & bitmask) === bitmask) { database[index][key] = data; } }) }); } } ``` 以上代码中,因为 zlib.inflate 方法返回的是一个 Buffer 对象,我们需要将其解码为字符串来进行存储。 现在我们的代码可以用来存储数据了。万事俱备只欠东风,接下来我们来生成测试数据,具体代码如下: ```javascript var header = Buffer.alloc(2); header[0] = 8; // 存放在第4个数据库(00001000) header[1] = 0; // 存放在0键值 zlib.deflate('my message', function(er, deflateBuf) { // 压缩'my message' if(er) return console.error(er); // 把头部信息和数据打包在一起 var message = Buffer.concat([header, deflateBuf]); store(message); // 存储信息 }); ``` 至此,简洁的数据库协议已基本开发完成。以下是本人基于书中的示例做了些简单的调整,有兴趣的小伙伴可以参考一下: ```javascript const zlib = require("zlib"); const database = [[], [], [], [], [], [], [], []]; const bitmasks = [1, 2, 4, 8, 16, 32, 64, 128]; function store(buf) { return new Promise((resolve, reject) => { let db = buf[0]; // 决定数据要写入到哪个数据库 let key = buf.readUInt8(1); // 一个字节的无符号整数用做数据库键存储 let matched = false; if (buf[2] === 0x78) { // 一般来说,zlib压缩的数据结果第一个字节为0x78 zlib.inflate(buf.slice(2), function(err, inflatedBuf) { if (err) { console.error(err); reject(err); } else { let data = inflatedBuf.toString(); bitmasks.forEach(function(bitmask, index) { if ((db & bitmask) === bitmask) { database[index][key] = data; matched = true; resolve({ code: 0, status: "success" }); } }); if (!matched) reject(new Error("Match failed")); } }); } }); } /***************数据库协议测试***************/ const header = Buffer.alloc(2); header[0] = 8; // 存放在第4个数据库(00001000) header[1] = 0; // 存放在0键值 zlib.deflate("my message", async function(err, deflateBuf) { // 压缩'my message' if (err) return console.error(err); // 把头部信息和数据打包在一起 let message = Buffer.concat([header, deflateBuf]); try { const result = await store(message); if (result && result.status === "success") { console.dir(database); } } catch (error) { console.log("Save failed!"); } }); ``` ### 总结 本文基于 "Node.js硬实战:115个核心技巧" 这本书中的部分示例,介绍了 Buffer 对象中常用的 API,最后还介绍了如何利用 Buffer API 实现简洁的数据库协议。个人感觉 "Node.js硬实战:115个核心技巧" 这本书挺不错的,推荐感兴趣的小伙伴可以阅读一下。 ### 参考资源 * [MDN - Data URIs](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/data_URIs) * Node.js硬实战:115个核心技巧 ================================================ FILE: buffer/深入学习Node.js Buffer.md ================================================ ## 深入学习 Node.js Buffer > 友情提示:本文篇幅较长,可根据实际需要,进行选择性阅读。另外,对源码感兴趣的小伙伴,建议采用阅读和调试相结合的方式,进行源码学习。详细的调试方式,请参考 [Debugging Node.js Apps](https://nodejs.org/en/docs/inspector/) 文章。 - [深入学习 Node.js Buffer](#深入学习-nodejs-buffer) - [预备知识](#预备知识) - [ArrayBuffer](#arraybuffer) - [语法](#语法) - [示例](#示例) - [Unit8Array](#unit8array) - [语法](#语法-1) - [示例](#示例-1) - [ArrayBuffer 和 TypedArray](#arraybuffer-和-typedarray) - [Node.js Buffer](#nodejs-buffer) - [Buffer 基本使用](#buffer-基本使用) - [Buffer.from(), Buffer.alloc(), and Buffer.allocUnsafe()](#bufferfrom-bufferalloc-and-bufferallocunsafe) - [为什么 Buffer.allocUnsafe() 和 Buffer.allocUnsafeSlow() 不安全](#为什么-bufferallocunsafe-和-bufferallocunsafeslow-不安全) - [Buffer 与字符编码](#buffer-与字符编码) - [示例](#示例-2) - [Buffer 与 TypedArray](#buffer-与-typedarray) - [Buffer 内存管理](#buffer-内存管理) - [8K 内存池](#8k-内存池) - [ascii、unicode 和 utf8](#asciiunicode-和-utf8) - [ascii 编码](#ascii-编码) - [小结](#小结) - [unicode 编码](#unicode-编码) - [小结](#小结-1) - [utf8 编码](#utf8-编码) - [小结](#小结-2) - [Buffer 中文处理](#buffer-中文处理) - [Buffer slice() vs Array slice()](#buffer-slice-vs-array-slice) - [Array slice()](#array-slice) - [示例](#示例-3) - [Buffer slice()](#buffer-slice) - [示例](#示例-4) - [字节对齐](#字节对齐) - [总结](#总结) - [参考资源](#参考资源) ### 预备知识 #### ArrayBuffer ArrayBuffer 对象用来表示**通用的、固定长度的**原始二进制数据缓冲区。**ArrayBuffer 不能直接操作,而是要通过[类型数组对象](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) 或 [`DataView`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/DataView) 对象来操作**,它们会将缓冲区中的数据表示为特定的格式,并通过这些格式来读写缓冲区的内容。 > ArrayBuffer 简单说是一片内存,但是你不能(也不方便)直接用它。这就好比你在 C 里面,malloc 一片内存出来,你也会把它转换成 unsigned_int32 或者 int16 这些你需要的实际类型的数组/指针来用。 > > 这就是JS里的 TypedArray 的作用,那些 Uint32Array 也好,Int16Array 也好,都是给 ArrayBuffer 提供了一个 “View”,MDN上的原话叫做 “Multiple views on the same data”,对它们进行下标读写,最终都会反应到它所建立在的 ArrayBuffer 之上。 > > 来源 https://www.zhihu.com/question/30401979 ##### 语法 > new ArrayBuffer(length) * 参数:length 表示要创建的 ArrayBuffer 的大小,单位为字节。 * 返回值:一个指定大小的 ArrayBuffer 对象,其内容被初始化为 0。 * 异常:如果 length 大于 [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER)(>= 2 ** 53)或为负数,则抛出一个 [`RangeError`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RangeError) 异常。 ##### 示例 下面的例子创建了一个 8 字节的缓冲区,并使用一个 [`Int32Array`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Int32Array) 来引用它: ```javascript var buffer = new ArrayBuffer(8); var view = new Int32Array(buffer); ``` > 从 ECMAScript 2015 开始,`ArrayBuffer` 对象需要用 [`new`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/new) 运算符创建。如果调用构造函数时没有使用 `new`,将会抛出 [`TypeError`](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypeError) 异常。 #### Unit8Array Uint8Array 数组类型表示一个 8 位无符号整型数组,创建时内容被初始化为 0。创建完后,可以以**对象的方式或使用数组下标索引的方式**引用数组中的元素。 ##### 语法 > Uint8Array(length);//创建初始化为0的,包含length个元素的无符号整型数组 > Uint8Array(typedArray); > Uint8Array(object); > Uint8Array(buffer [, byteOffset [, length]]); ##### 示例 ```javascript // 来自长度 var uint8 = new Uint8Array(2); uint8[0] = 42; console.log(uint8[0]); // 42 console.log(uint8.length); // 2 console.log(uint8.BYTES_PER_ELEMENT); // 1 // 来自数组 var arr = new Uint8Array([21,31]); console.log(arr[1]); // 31 // 来自另一个 TypedArray var x = new Uint8Array([21, 31]); var y = new Uint8Array(x); console.log(y[0]); // 21 // 来自 ArrayBuffer var buffer = new ArrayBuffer(8); var z = new Uint8Array(buffer, 1, 4); ``` #### ArrayBuffer 和 TypedArray ArrayBuffer 本身只是一个 0 和 1 存放在一行里面的一个集合,ArrayBuffer 不知道第一个和第二个元素在数组中该如何分配。 ![array-buffer](array-buffer.png) (图片来源 —— [A cartoon intro to ArrayBuffers and SharedArrayBuffers](https://hacks.mozilla.org/2017/06/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/)) 为了能提供上下文,我们需要将其封装在一个叫做 View 的东西里面。这些在数据上的 View 可以被添加进确定类型的数组,而且我们有很多种确定类型的数据可以使用。 例如,你可以使用一个 Int8 的确定类型数组来分离存放 8 位二进制字节。 ![int8-array](int8-array.png) (图片来源 —— [A cartoon intro to ArrayBuffers and SharedArrayBuffers](https://hacks.mozilla.org/2017/06/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/)) 或者你可以使用一个无符号的 Int16 数组来分离存放 16 位二进制字节,这样如果是一个无符号的整数也能处理。 ![uint16-array](uint16-array.png) (图片来源 —— [A cartoon intro to ArrayBuffers and SharedArrayBuffers](https://hacks.mozilla.org/2017/06/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/)) 你甚至可以在相同基础的 Buffer 上使用不同的 View,同样的操作不同的 View 会给你不同的结果。 比如,如果我们在这个 ArrayBuffer 中从 Int8 View 里获取了元素 0 和 1,在 Uint16 View 中元素 0 会返回给我们不同的值,尽管它们包含的是完全相同的二进制字节。 ![int8-and-uint16](int8-and-uint16.png) (图片来源 —— [A cartoon intro to ArrayBuffers and SharedArrayBuffers](https://hacks.mozilla.org/2017/06/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/)) 在这种方式中,ArrayBuffer 基本上扮演了一个原生内存的角色,它模拟了像 C 语言才有的那种直接访问内存的方式。**你可能想知道为什么我们不让程序直接访问内存,而是添加了这种抽象层,因为直接访问内存将导致一些安全漏洞**。 ### Node.js Buffer 在 ECMAScript 2015 (ES6) 引入 [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) 之前,JavaScript 语言没有读取或操作二进制数据流的机制。Buffer 类被引入作为 Node.js API 的一部分,使其可以在 TCP 流或文件系统操作等场景中处理二进制数据流。 [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) 现已被添加进 ES6 中,Buffer 类以一种更优化、更适合 Node.js 用例的方式实现了 [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) API。 **Buffer 类的实例类似于整数数组,但 Buffer 的大小是固定的、且在 V8 堆外分配物理内存。 Buffer 的大小在被创建时确定,且无法调整。** #### Buffer 基本使用 ```javascript // 创建一个长度为 10、且用 0 填充的 Buffer。 const buf1 = Buffer.alloc(10); // 创建一个长度为 10、且用 0x1 填充的 Buffer。 const buf2 = Buffer.alloc(10, 1); // 创建一个长度为 10、且未初始化的 Buffer。 // 这个方法比调用 Buffer.alloc() 更快, // 但返回的 Buffer 实例可能包含旧数据, // 因此需要使用 fill() 或 write() 重写。 const buf3 = Buffer.allocUnsafe(10); // 创建一个包含 [0x1, 0x2, 0x3] 的 Buffer。 const buf4 = Buffer.from([1, 2, 3]); // 创建一个包含 UTF-8 字节 [0x74, 0xc3, 0xa9, 0x73, 0x74] 的 Buffer。 const buf5 = Buffer.from('tést'); // 创建一个包含 Latin-1 字节 [0x74, 0xe9, 0x73, 0x74] 的 Buffer。 const buf6 = Buffer.from('tést', 'latin1'); ``` #### Buffer.from(), Buffer.alloc(), and Buffer.allocUnsafe() 在 Node.js v6 之前的版本中,Buffer 实例是通过 Buffer 构造函数创建的,它根据提供的参数返回不同的 Buffer: - 传一个数值作为第一个参数给 `Buffer()`(如 `new Buffer(10)`),则分配一个指定大小的新建的 `Buffer` 对象。 在 Node.js 8.0.0 之前,分配给这种 `Buffer` 实例的内存是**没有**初始化的,且**可能包含敏感数据**。 这种 `Buffer` 实例随后必须被初始化,可以使用 [`buf.fill(0)`](http://nodejs.cn/api/buffer.html#buffer_buf_fill_value_offset_end_encoding) 或写满这个 `Buffer`。 虽然这种行为是为了提高性能而**有意为之的**,但开发经验表明,创建一个快速但未初始化的 `Buffer` 与创建一个慢点但更安全的 `Buffer` 之间需要有更明确的区分。从 Node.js 8.0.0 开始, `Buffer(num)` 和 `new Buffer(num)` 将返回一个初始化内存之后的 `Buffer`。 - 传一个字符串、数组、或 `Buffer` 作为第一个参数,则将所传对象的数据拷贝到 `Buffer` 中。 - 传入一个 [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer),则返回一个与给定的 [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) 共享所分配内存的 `Buffer`。 为了使 `Buffer` 实例的创建更可靠、更不容易出错,各种 `new Buffer()` 构造函数已被**废弃**,并由 `Buffer.from()`、[`Buffer.alloc()`](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_alloc_size_fill_encoding)、和 [`Buffer.allocUnsafe()`](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_allocunsafe_size) 方法替代。 #### 为什么 Buffer.allocUnsafe() 和 Buffer.allocUnsafeSlow() 不安全 当调用 [`Buffer.allocUnsafe()`](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_allocunsafe_size) 和 [`Buffer.allocUnsafeSlow()`](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_allocunsafeslow_size) 时,被分配的内存段是**未初始化的**(没有用 0 填充)。 虽然这样的设计使得内存的分配非常快,但已分配的内存段可能包含潜在的敏感旧数据。 使用通过 [`Buffer.allocUnsafe()`](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_allocunsafe_size) 创建的**没有被完全重写**内存的 `Buffer` ,在 `Buffer`内存可读的情况下,可能泄露它的旧数据。 虽然使用 [`Buffer.allocUnsafe()`](http://nodejs.cn/api/buffer.html#buffer_class_method_buffer_allocunsafe_size) 有明显的性能优势,但必须额外小心,以避免给应用程序引入安全漏洞。 #### Buffer 与字符编码 `Buffer` 实例一般用于表示编码字符的序列,比如 UTF-8 、 UCS2 、 Base64 、或十六进制编码的数据。 通过使用显式的字符编码,就可以在 `Buffer` 实例与普通的 JavaScript 字符串之间进行相互转换。 ##### 示例 ```javascript const buf = Buffer.from('hello world', 'ascii'); // 输出 68656c6c6f20776f726c64 console.log(buf.toString('hex')); // 输出 aGVsbG8gd29ybGQ= console.log(buf.toString('base64')); ``` Node.js 目前支持的字符编码包括: - `'ascii'` - 仅支持 7 位 ASCII 数据。如果设置去掉高位的话,这种编码是非常快的。 - `'utf8'` - 多字节编码的 Unicode 字符。许多网页和其他文档格式都使用 UTF-8 。 - `'utf16le'` - 2 或 4 个字节,小字节序编码的 Unicode 字符。支持代理对(U+10000 至 U+10FFFF)。 - `'ucs2'` - `'utf16le'` 的别名。 - `'base64'` - Base64 编码。当从字符串创建 `Buffer` 时,按照 [RFC4648 第 5 章](https://tools.ietf.org/html/rfc4648#section-5)的规定,这种编码也将正确地接受 “URL 与文件名安全字母表”。 - `'latin1'` - 一种把 `Buffer` 编码成一字节编码的字符串的方式(由 IANA 定义在 [RFC1345](https://tools.ietf.org/html/rfc1345) 第 63 页,用作 Latin-1 补充块与 C0/C1 控制码)。 - `'binary'` - `'latin1'` 的别名。 - `'hex'` - 将每个字节编码为两个十六进制字符。 #### Buffer 与 TypedArray `Buffer` 实例也是 [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) 实例。 但是与 ECMAScript 2015 中的 TypedArray 规范还是有些微妙的不同。 例如,当 [`ArrayBuffer#slice()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/slice) 创建一个切片的副本时,[`Buffer#slice()`](http://nodejs.cn/api/buffer.html#buffer_buf_slice_start_end) 的实现是在现有的 `Buffer` 上不经过拷贝直接进行创建,这也使得 [`Buffer#slice()`](http://nodejs.cn/api/buffer.html#buffer_buf_slice_start_end) 更高效。 遵循以下注意事项,也可以从一个 `Buffer` 创建一个新的 [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) 实例: 1. `Buffer` 对象的内存是拷贝到 [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) 的,而不是共享的。 2. `Buffer` 对象的内存是被解析为一个明确元素的数组,而不是一个目标类型的字节数组。 也就是说,`new Uint32Array(Buffer.from([1, 2, 3, 4]))` 会创建一个包含 `[1, 2, 3, 4]` 四个元素的 [`Uint32Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint32Array),而不是一个只包含一个元素 `[0x1020304]` 或 `[0x4030201]` 的 [`Uint32Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint32Array)。 也可以通过 TypeArray 对象的 `.buffer` 属性创建一个新建的且与 [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray) 实例共享同一分配内存的 `Buffer` 。 ### Buffer 内存管理 在介绍 Buffer 内存管理之前,我们要先来介绍一下 Buffer 内部的 8K 内存池。 #### 8K 内存池 在 Node.js 应用程序启动时,为了方便地、高效地使用 Buffer,会创建一个大小为 8K 的内存池。 ```javascript Buffer.poolSize = 8 * 1024; // 8K var poolSize, poolOffset, allocPool; // 创建内存池 function createPool() { poolSize = Buffer.poolSize; allocPool = createUnsafeArrayBuffer(poolSize); poolOffset = 0; } createPool(); ``` 在 createPool() 函数中,通过调用 createUnsafeArrayBuffer() 函数来创建 poolSize(即8K)的 ArrayBuffer 对象。createUnsafeArrayBuffer() 函数的实现如下: ```javascript function createUnsafeArrayBuffer(size) { zeroFill[0] = 0; try { return new ArrayBuffer(size); // 创建指定size大小的ArrayBuffer对象,其内容被初始化为0。 } finally { zeroFill[0] = 1; } } ``` 这里你只需知道 Node.js 应用程序启动时,内部有个 8K 的内存池即可。那接下来我们要介绍哪个对象呢?在前面的预备知识部分,我们简单介绍了 ArrayBuffer 和 Unit8Array 相关的基础知识,而 ArrayBuffer 的应用在 8K 的内存池部分的已经介绍过了。那接下来当然要轮到 Unit8Array 了,我们再来回顾一下它的语法: ```javascript Uint8Array(length); Uint8Array(typedArray); Uint8Array(object); Uint8Array(buffer [, byteOffset [, length]]); ``` 其实除了 Buffer 类外,还有一个 FastBuffer 类,该类的声明如下: ```javascript class FastBuffer extends Uint8Array { constructor(arg1, arg2, arg3) { super(arg1, arg2, arg3); } } ``` 是不是知道 Uint8Array 用在哪里了,在 FastBuffer 类的构造函数中,通过调用 `Uint8Array(buffer [, byteOffset [, length]])` 来创建 Uint8Array 对象。 那么现在问题来了,FastBuffer 有什么用?它和 Buffer 类有什么关系?带着这两个问题,我们先来一起分析下面的简单示例: ```javascript const buf = Buffer.from('semlinker'); console.log(buf); ``` 以上代码运行后输出的结果如下: ``` ``` 什么鬼,竟然输出了一串数字,是谁偷走了我的字母?经过好心人引荐,我找到私家侦探**毛利小五郎**,打算重金请他帮我调查**字母丢失案**,期间在侦探社遇到了一个名叫柯南的小帅哥,他告诉我 “真相只有一个,请从源码找答案”。听完这句话,我茅塞顿开,从此踏上了漫漫的源码求解之路。 ```javascript /** * Functionally equivalent to Buffer(arg, encoding) but throws a TypeError * if value is a number. * Buffer.from(str[, encoding]) * Buffer.from(array) * Buffer.from(buffer) * Buffer.from(arrayBuffer[, byteOffset[, length]]) **/ Buffer.from = function from(value, encodingOrOffset, length) { if (typeof value === "string") return fromString(value, encodingOrOffset); // 处理其它数据类型,省略异常处理等其它代码 if (isAnyArrayBuffer(value)) return fromArrayBuffer(value, encodingOrOffset, length); var b = fromObject(value); }; ``` 可以看出 `Buffer.from()` 工厂函数,支持基于多种数据类型(string、array、buffer 等)创建 Buffer 对象。对于字符串类型的数据,内部调用 `fromString(value, encodingOrOffset)` 方法来创建 Buffer 对象。 是时候来会一会 `fromString()` 方法了,它内部实现如下: ```javascript function fromString(string, encoding) { var length; if (typeof encoding !== "string" || encoding.length === 0) { if (string.length === 0) return new FastBuffer(); // 若未设置编码,则默认使用utf8编码。 encoding = "utf8"; // 使用 buffer binding 提供的方法计算string的长度 length = byteLengthUtf8(string); } else { // 基于指定的 encoding 计算string的长度 length = byteLength(string, encoding, true); if (length === -1) throw new errors.TypeError("ERR_UNKNOWN_ENCODING", encoding); if (string.length === 0) return new FastBuffer(); } // 当字符串所需字节数大于4KB,则直接进行内存分配 if (length >= Buffer.poolSize >>> 1) // 使用 buffer binding 提供的方法,创建buffer对象 return createFromString(string, encoding); // 当剩余的空间小于所需的字节长度,则先重新申请8K内存 if (length > poolSize - poolOffset) // allocPool = createUnsafeArrayBuffer(8K); poolOffset = 0; createPool(); // 创建 FastBuffer 对象,并写入数据。 var b = new FastBuffer(allocPool, poolOffset, length); const actual = b.write(string, encoding); if (actual !== length) { // byteLength() may overestimate. That's a rare case, though. b = new FastBuffer(allocPool, poolOffset, actual); } // 更新pool的偏移,并执行字节对齐 poolOffset += actual; alignPool(); return b; } ``` 现在我们来梳理一下几个注意项: * 当未设置编码的时候,默认使用 utf8 编码; * 当字符串所需字节数大于4KB,则直接进行内存分配; * 当字符串所需字节数小于4KB,但超过预分配的 8K 内存池的剩余空间,则重新申请 8K 的内存池; * 调用 `new FastBuffer(allocPool, poolOffset, length)` 创建 FastBuffer 对象,进行数据存储,数据成功保存后,会进行长度校验、更新 poolOffset 偏移量和字节对齐等操作。 相信很多小伙伴跟我一样,第一次听到字节对齐这个概念,这里我们先不展开,后面再来简单介绍它。这时,字母丢失案渐渐有了一点眉目,原来我们字符串中的字符,使用默认的 utf8 编码后才保存到内存中。现在是时候该介绍一下 ascii、unicode 和 utf8 编码了。 ### ascii、unicode 和 utf8 #### ascii 编码 > ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于[拉丁字母](https://baike.baidu.com/item/%E6%8B%89%E4%B8%81%E5%AD%97%E6%AF%8D)的一套电脑编码系统,主要用于显示现代[英语](https://baike.baidu.com/item/%E8%8B%B1%E8%AF%AD/109997)和其他[西欧](https://baike.baidu.com/item/%E8%A5%BF%E6%AC%A7)语言。它是现今最通用的单[字节](https://baike.baidu.com/item/%E5%AD%97%E8%8A%82)[编码](https://baike.baidu.com/item/%E7%BC%96%E7%A0%81)系统,并等同于[国际](https://baike.baidu.com/item/%E5%9B%BD%E9%99%85)标准ISO/IEC 646。—— [百度百科](https://baike.baidu.com/item/ASCII/309296) ASCII 码使用指定的 7 位或 8 位[二进制数](https://baike.baidu.com/item/%E4%BA%8C%E8%BF%9B%E5%88%B6%E6%95%B0)组合来表示 128 或 256 种可能的[字符](https://baike.baidu.com/item/%E5%AD%97%E7%AC%A6)。标准 ASCII 码也叫基础ASCII 码,使用7 位[二进制数](https://baike.baidu.com/item/%E4%BA%8C%E8%BF%9B%E5%88%B6%E6%95%B0)(剩下的1位二进制为0)来表示所有的大写和小写字母,数字 0 到 9、标点符号, 以及在美式英语中使用的特殊[控制字符](https://baike.baidu.com/item/%E6%8E%A7%E5%88%B6%E5%AD%97%E7%AC%A6)。 * **0~31及127(共33个)是控制字符或通信专用字符(其余为可显示字符),**如控制符:LF(换行)、CR(回车)、FF(换页)、DEL(删除)等。 * 32~126 (共95个) 是字符 (32是空格),其中 48~57 为 0 到 9 十个阿拉伯数字。 * 65~90 为 26 个大写英文字母,97~122 号为 26 个小写英文字母,其余为一些标点符号、运算符号等。 后 128 个称为[扩展ASCII](https://baike.baidu.com/item/%E6%89%A9%E5%B1%95ASCII)码。许多基于[x86](https://baike.baidu.com/item/x86)的系统都支持使用扩展 ASCII。扩展 ASCII 码允许将每个字符的第 8 位用于确定附加的 128 个特殊符号字符、外来语字母和图形符号。 ##### 小结 在计算机内部,字节是最小的单位,一字节为 8 位,每一位可能的值为 0 或 1。标准 ASCII 码使用指定的 7 位二进制数来表示 128 种可能的字符。后 128 个称为扩展 ASCII 码,它允许将每个字符的第 8 位用于确定附加的 128 个特殊符号字符、外来语字母和图形符号。 #### unicode 编码 全世界那么多语言文字,仅使用 ascii 编码肯定远远不够。这时,我们就得来介绍一下 unicode 编码。 > Unicode([统一码](https://baike.baidu.com/item/%E7%BB%9F%E4%B8%80%E7%A0%81)、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的[二进制](https://baike.baidu.com/item/%E4%BA%8C%E8%BF%9B%E5%88%B6)编码,以满足跨语言、跨平台进行文本转换、处理的要求。—— [百度百科](https://baike.baidu.com/item/Unicode) Unicode 也是一种字符编码方法,不过它是由国际组织设计,可以容纳全世界所有语言文字的编码方案。Unicode的全称是 **"Universal Multiple-Octet Coded Character Set"**,简称为 UCS。UCS 可以看作是 "Unicode Character Set" 的缩写。 **不过 UCS 只是规定如何编码,并没有规定如何传输、保存这个编码。**例如汉字 “超” 字的 UCS 编码是 `8d85`,我们可以用 4 个 ascii 码来传输、保存这个编码;也可以用 utf8 编码:3 个连续的字节 E8 B6 85 来表示它。关键在于通信双方都要认可。 ##### 小结 Unicode 是由国际组织设计,可以容纳全世界所有语言文字的编码方案。Unicode 的学名是 Universal Multiple-Octet Coded Character Set,简称为 UCS。UCS 只是规定如何编码,并没有规定如何传输、保存这个编码。 #### utf8 编码 前面已经介绍过了汉字 “超” 字的 UCS 编码是 `8d85`,而对应的 utf8 编码为 `E8 B6 85`。接下来我们来了解一下 utf8 编码。 > UTF-8(8-bit Unicode Transformation Format)是一种针对 Unicode 的可变长度字符编码,又称万国码。由Ken Thompson于1992年创建。现在已经标准化为RFC 3629。UTF-8用1到6个字节编码Unicode字符。用在网页上可以统一页面显示中文简体繁体及其它语言(如英文,日文,韩文)。 —— [百度百科](https://baike.baidu.com/item/UTF-8) 通过百度百科的定义,我们知道 UTF 的全称为 **"Unicode Transformation Format"**。UTF-8 是一种针对 **Unicode 的可变长度字符编码**。UTF-8 就是以 8 位为单元对 UCS 进行编码,而 UTF-8 不使用大尾序和小尾序的形式,每个使用 UTF-8 存储的字符,除了第一个字节外,其余字节的头两个比特都是以 "10" 开始,使文字处理器能够较快地找出每个字符的开始位置。 **Unicode 和 UTF-8 之间的转换关系表 ( x 字符表示码点占据的位 )** | 码点的位数 | 码点起值 | 码点终值 | 字节序列 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 | | ----- | --------- | ---------- | ---- | ---------- | ---------- | ---------- | ---------- | ---------- | ---------- | | 7 | U+0000 | U+007F | 1 | `0xxxxxxx` | | | | | | | 11 | U+0080 | U+07FF | 2 | `110xxxxx` | `10xxxxxx` | | | | | | 16 | U+0800 | U+FFFF | 3 | `1110xxxx` | `10xxxxxx` | `10xxxxxx` | | | | | 21 | U+10000 | U+1FFFFF | 4 | `11110xxx` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` | | | | 26 | U+200000 | U+3FFFFFF | 5 | `111110xx` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` | | | 31 | U+4000000 | U+7FFFFFFF | 6 | `1111110x` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` | `10xxxxxx` | - 在 ASCII 码的范围,用一个字节表示,超出 ASCII 码的范围就用多个字节表示,这就形成了我们上面看到的 UTF-8的表示方法,**这样的好处是当 UNICODE 文件中只有 ASCII 码时,存储的文件都为一个字节**,所以就是普通的ASCII 文件无异,读取的时候也是如此,所以能与以前的 ASCII 文件兼容。 - 大于 ASCII 码的,就会由上面的第一字节的前几位表示该 unicode 字符的长度,即在多字节串中,第一个字节的开头 "1" 的数目就是整个串中字节的数目。比如在(U+0080 - U+07FF)码点范围的第一字节为 `110xxxxx` ,该字节高位有连续两个 1,因此表示在(U+0080 - U+07FF)范围内的 unicode 码值,使用 utf8 编码后,占用两个字节。 ##### 小结 UTF 的全称为 "Unicode Transformation Format",UTF-8 就是以 8 位为单元对 UCS 进行编码,它是一种针对 Unicode 的可变长度字符编码。对应的 UCS 码值,如果在 ASCII 码的范围,用一个字节表示,超出 ASCII 码的范围就用多个字节表示。这样的好处是为了节省存储空间,提高网络传输的效率。 了解完 ascii、unicode 和 utf8 相关的知识,各位小伙伴是不是对**字母丢失案**已经有了大概的结论。 接下来我们再来回顾一下**字母丢失案**: ```javascript const buf = Buffer.from('semlinker'); console.log(buf); // console.log(buf.length); // 9 ``` 由于调用 `from()` 方法时,我们没有设定编码,所以默认使用 utf8 编码。在 ascii/unicode 编码中,65~90 为 26 个大写英文字母,97~122 号为 26 个小写英文字母。它们的码点在 (U+0000 - U+007F)范围内,因此根据 "Unicode 和 UTF-8 之间的转换关系表" 我们可以知道对于大小写英文字母来说,它们的 ascii/utf8 编码值是一样的,此时**字母丢失案**已经告破了。难道这样就结束了,其实我想说这只是告一段落。 ### Buffer 中文处理 在**字母丢失案**中我们已经知道可以通过 `Buffer.from('semlinker')` 来创建 Buffer 对象,然后利用 `length` 属性来获取 Buffer 的长度,但如果运行以下代码: ```javascript const buf = Buffer.from('超'); console.log(buf); console.log(buf.length); ``` 它的输出结果是什么?估计仔细看过前面 "ascii、unicode 和 utf8" 章节的小伙伴,已经知道输出结果为 `` 和 `3` 了。前面已经介绍过 "Unicode 和 UTF-8 之间的转换关系表",接下来我们利用该关系表,来手动进行 utf8 编码。 汉字 “超” 字的 UCS 编码是 `8d85`,处于的码点范围为 (U+0800 - U+FFFF),所以使用以下模板: ```javascript 1110xxxx 10xxxxxx 10xxxxxx ``` 接下来列出 `8d85` 每一位对应的二进制值,具体值如下: ``` 8 —— 1000 d —— 1101 8 —— 1000 5 —— 0101 ``` 然后从后向前按照 5 - 8 - d - 8 的顺序依次进行位填充,多出的位补 0,最终填充后的结果如下: ``` 11101000 10110110 10000101 ``` 以上二进制格式对应的十六进制表示为 `e8 b6 85`。相信到这里,你已经对 Buffer 中文处理有了一个大致的了解。那么现在问题又来了,我们应该如何读取保存到 Buffer 对象中的数据,其实我们可以通过下标来访问 Buffer 中保存的数据,具体方式如下: ```javascript const buf = Buffer.from('semlinker'); console.log(buf[0]); // 十进制:115 十六进制:0x73 console.log(buf[1]); // 十进制:101 十六进制:0x65 ``` 虽然我们已经可以访问到每个字节的数据,但如果我们想获取原始的 "semlinker" 字符串呢?Buffer 类也为我们考虑到了这个需求,为了提供了 `toString()` 方法,该方法的签名如下: ```javascript Buffer.prototype.toString = function toString(encoding, start, end) { } ``` 所以当我们需要获取原始的 "semlinker" 字符串时,我们可以使用 `buf.toString('utf8')` 来实现解码操作。需要注意的是目前 Node.js 支持的字符编码包括:ascii、utf8、utf16le (别名 ucs2)、base64、latin1 (别名 binary) 和 hex。 不知道小伙伴们有没有发现,Buffer 对象与 Array 对象有很多相同之处,比如它们都有 length 属性、from() 方法、toString() 方法和 slice() 方法等。但 Buffer 对象的 slice() 方法与 Array 对象的 slice() 方法还是有区别的。 ### Buffer slice() vs Array slice() #### Array slice() slice() 方法返回一个从开始到结束(不包括结束)选择的数组的一部分**浅拷贝**到一个新数组对象,**且原始数组不会被修改**。 ##### 示例 ```javascript var animals = ['ant', 'bison', 'camel', 'duck', 'elephant']; console.log(animals.slice(2)); // ["camel", "duck", "elephant"] console.log(animals); //  ["ant", "bison", "camel", "duck", "elephant"] ``` #### Buffer slice() slice() 返回一个指向相同原始内存的新建的 `Buffer`,但做了偏移且通过 `start` 和 `end` 索引进行裁剪。 **注意,修改这个新建的 Buffer 切片,也会同时修改原始的 Buffer 的内存,因为这两个对象所分配的内存是重叠的。** ##### 示例 ```javascript const buf = Buffer.from('semlinker'); const buf1 = buf.slice(0, 3); buf1[0] = 97; console.log(buf); // console.log(buf1); // console.log(buf.toString('utf8')); // aemlinker ``` 通过观察 Array slice() 示例和 Buffer slice() 示例的输出结果,我们更加直观地了解它们之间的差异。 Buffer 对象的 slice() 方法具体实现如下: ```javascript Buffer.prototype.slice = function slice(start, end) { const srcLength = this.length; start = adjustOffset(start, srcLength); end = end !== undefined ? adjustOffset(end, srcLength) : srcLength; const newLength = end > start ? end - start : 0; // 与原始的Buffer对象,共用内存。 return new FastBuffer(this.buffer, this.byteOffset + start, newLength); }; ``` 最后我们再来简单介绍一下字节对齐的概念。 ### 字节对齐 **所谓的字节对齐,就是各种类型的数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这个就是对齐**。我们经常听说的对齐在 N 上,它的含义就是数据的存放起始地址 %N== 0。首先还是让我们来看一下,为什么要进行字节对齐吧。 各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些架构的 CPU,诸如 SPARC 在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构上必须编程必须保证字节对齐,**而有些平台对于没有进行对齐的数据进行存取时会产生效率的下降**。 让我们来以 x86 为例看一下如果在不进行对齐的情况下,会带来什么样子的效率低下问题,看下面的数据结构声明: ```c++ struct A { char c; // 字符占一个字节 int i; // 整型占四个字节 }; struct A a; ``` 假设变量 a 存放在内存中的起始地址为 0x00,那么其成员变量 c 的起始地址为 0x00,成员变量 i 的起始地址为0x01,变量 a 一共占用了 5 个字节。当 CPU 要对成员变量 c 进行访问时,只需要一个读周期即可。 然而如果要对成员变量 i 进行访问,那么情况就变得有点复杂了,首先 CPU 用了一个读周期,从 0x00 处读取了 4 个字节(注意由于是 32 位架构),然后将 0x01-0x03 的 3 个字节暂存,接着又花费了一个读周期读取了从 0x04 - 0x07 的 4 字节数据,将 0x04 这个字节与刚刚暂存的 3 个字节进行拼接从而读取到成员变量 i 的值。 为了读取这个成员变量 i,CPU 花费了整整 2 个读周期。试想一下,如果数据成员 i 的起始地址被放在了 0x04 处,那么读取其所花费的周期就变成了 1,显然引入字节对齐可以避免读取效率的下降,但这同时也浪费了 3 个字节的空间 (0x01-0x03)。 了解完字节对齐的概念和使用字节对齐的原因,最后我们来看一下 Buffer.js 文件中的实现字节对齐的 `alignPool()` 函数: ```javascript /** * 如果不按照平台要求对数据存放进行对齐,会带来存取效率上的损失。比如32位的 * Intel处理器通过总线访问内存数据。每个总线周期从偶地址开始访问32位内存数 * 据,内存数据以字节为单位存放。如果一个32位的数据没有存放在4字节整除的内 * 存地址处,那么处理器就需要2个总线周期对其进行访问,显然访问效率下降很多。 */ function alignPool() { // Ensure aligned slices // 后四位:0001|0010|0011|0100|0101|0110|0111 if (poolOffset & 0x7) { poolOffset |= 0x7; poolOffset++; } } ``` ### 总结 为了深入学习 Node.js 中的 Buffer 对象,本文介绍了 ArrayBuffer、Uint8Array、常用编码和内存对齐等相关知识。然后通过简单的示例,介绍了 `Buffer.from()` 工厂函数,接着我们以字符串 `'semlinker'` 为输入参数,详细分析了 buffer.js 文件中 `fromString()` 函数。最后,我们使用简单示例介绍了 Array 对象 slice() 方法与 Buffer 对象 slice() 方法的区别。 ### 参考资源 * [内存管理速成教程](http://zhaozhiming.github.io/blog/2017/06/20/a-crash-course-in-memory-management-zh/) * [通俗漫画介绍 ArrayBuffers 和 SharedArrayBuffers](http://zhaozhiming.github.io/blog/2017/06/20/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers-zh/) * [a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers](https://hacks.mozilla.org/2017/06/a-cartoon-intro-to-arraybuffers-and-sharedarraybuffers/) * [Node.js中文文档 - Buffer API](http://nodejs.cn/api/buffer.html) * [百度百科 - ASCII](https://baike.baidu.com/item/ASCII/309296) & [百度百科 - Unicode](https://baike.baidu.com/item/Unicode) & [百度百科 - UTF-8](https://baike.baidu.com/item/UTF-8) * [维基百科 - UTF-8](https://zh.wikipedia.org/wiki/UTF-8) * [阮一峰老师文章的常识性错误之 Unicode 与 UTF-8](https://foofish.net/unicode_utf-8.html) ================================================ FILE: event/深入学习 Node.js EventEmitter.md ================================================ ## 深入学习 Node.js EventEmitter - [深入学习 Node.js EventEmitter](#深入学习-nodejs-eventemitter) - [预备知识](#预备知识) - [观察者模式](#观察者模式) - [发布/订阅模式](#发布订阅模式) - [观察者模式 vs 发布/订阅模式](#观察者模式-vs-发布订阅模式) - [Node.js EventEmitter](#nodejs-eventemitter) - [EventEmitter 基本使用](#eventemitter-基本使用) - [EventEmitter 构造函数](#eventemitter-构造函数) - [EventEmitter on() 方法](#eventemitter-on-方法) - [EventEmitter removeListener() 方法](#eventemitter-removelistener-方法) - [EventEmitter once() 方法](#eventemitter-once-方法) - [总结](#总结) - [参考资源](#参考资源) ### 预备知识 #### 观察者模式 > **观察者模式**是[软件设计模式](https://zh.wikipedia.org/wiki/%E8%BB%9F%E4%BB%B6%E8%A8%AD%E8%A8%88%E6%A8%A1%E5%BC%8F)的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常透过呼叫各观察者所提供的方法来实现。此种模式通常被用来实时事件处理系统。 —— 维基百科 观察者模式,它定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。 我们可以使用日常生活中,期刊订阅的例子来形象地解释一下上面的概念。期刊订阅包含两个主要的角色:期刊出版方和订阅者,它们之间的关系如下: - 期刊出版方 - 负责期刊的出版和发行工作。 - 订阅者 - 只需执行订阅操作,新版的期刊发布后,就会主动收到通知,如果取消订阅,以后就不会再收到通知。 在观察者模式中也有两个主要角色:主题和观察者,分别对应期刊订阅例子中的期刊出版方和订阅者,它们之间的关系图如下: ![observer-pattern](observer-pattern.png) 观察者模式的优缺点、应用和实现,这里就不详细展开,有兴趣的小伙伴可以阅读本人之前整理的文章[Observable详解 - Observer Pattern](https://segmentfault.com/a/1190000008809168#articleHeader0)。 #### 发布/订阅模式 > 在[软件架构](https://zh.wikipedia.org/wiki/%E8%BD%AF%E4%BB%B6%E6%9E%B6%E6%9E%84)中,**发布-订阅**是一种[消息](https://zh.wikipedia.org/wiki/%E6%B6%88%E6%81%AF)[范式](https://zh.wikipedia.org/wiki/%E8%8C%83%E5%BC%8F),消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。—— [维基百科](https://zh.wikipedia.org/wiki/%E5%8F%91%E5%B8%83/%E8%AE%A2%E9%98%85) 发布/订阅模式与观察者模式非常类似,它们最大的区别是:发布者和订阅者不知道对方的存在。它们之间需要一个第三方组件,叫做信息中介,它将订阅者和发布者串联起来,它过滤和分配所有输入的消息。换句话说,发布/订阅模式用来处理不同系统组件的信息交流,即使这些组件不知道对方的存在。 那么信息中介是如何过滤消息呢?在发布/订阅模型中,订阅者通常接收所有发布的消息的一个子集。选择接受和处理的消息的过程被称作过滤。有两种常用的过滤形式:基于主题的和基于内容的。 * 在**基于主题**的系统中,消息被发布到主题或命名通道上。订阅者将收到其订阅的主题上的所有消息,并且所有订阅同一主题的订阅者将接收到同样的消息。发布者负责定义订阅者所订阅的消息类别。 * 在**基于内容**的系统中,订阅者定义其感兴趣的消息的条件,只有当消息的属性或内容满足订阅者定义的条件时,消息才会被投递到该订阅者。订阅者需要负责对消息进行分类。 一些系统支持两者的混合:发布者发布消息到主题上,而订阅者将基于内容的订阅注册到一个或多个主题上。基于主题的通信基础结构图如下: ![pub-sub](pubsub-pattern.png) 最后我们再来总结一下观察者模式与发布/订阅模式之间的区别。 #### 观察者模式 vs 发布/订阅模式 ![observer-vs-pubsub](observer-vs-pubsub.jpg) (图片来源 - [developers-club](http://developers-club.com/posts/270339/)) 观察者模式与发布/订阅模式之间的区别: * 在观察者模式中,观察者知道 Subject 的存在,Subject 一直保持对观察者进行记录。然而,在发布/订阅模式中,发布者和订阅者不知道对方的存在,它们只有通过信息中介进行通信。 * 在发布订阅模式中,组件是松散耦合的,正好和观察者模式相反。 * 观察者模式大多数时候是同步的,比如当事件触发,Subject 就会去调用观察者的方法。而发布/订阅模式大多数时候是异步的(使用消息队列)。 ### Node.js EventEmitter 大多数 Node.js 核心 API 都采用惯用的异步事件驱动架构,其中某些类型的对象(触发器)会周期性地触发命名事件来调用函数对象(监听器)。 例如,[`net.Server`](http://nodejs.cn/api/net.html#net_class_net_server) 对象会在每次有新连接时触发事件;[`fs.ReadStream`](http://nodejs.cn/api/fs.html#fs_class_fs_readstream) 会在文件被打开时触发事件;[流对象](http://nodejs.cn/api/stream.html) 会在数据可读时触发事件。 所有能触发事件的对象都是 `EventEmitter` 类的实例。 这些对象开放了一个 `eventEmitter.on()` 函数,允许将一个或多个函数绑定到会被对象触发的命名事件上。 事件名称通常是驼峰式的字符串,但也可以使用任何有效的 JavaScript 属性名。 **当 `EventEmitter` 对象触发一个事件时,所有绑定在该事件上的函数都被同步地调用**。 监听器的返回值会被丢弃。 #### EventEmitter 基本使用 ```javascript const EventEmitter = require('events'); class MyEmitter extends EventEmitter {} const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('触发了一个事件!'); }); myEmitter.emit('event'); ``` 以上示例,我们自定义 MyEmitter 类,该类继承于 EventEmitter 类,接着我们通过使用 `new` 关键字创建了 `myEmitter` 实例,然后使用 `on()` 方法监听 event 事件,最后利用 `emit()` 方法触发 event 事件。 小伙伴们,是不是觉得示例很简单。觉得简单就对了,我们就从简单的入手,慢慢深入学习 EventEmitter 类。 #### EventEmitter 构造函数 ```javascript function EventEmitter() { EventEmitter.init.call(this); } EventEmitter.usingDomains = false; EventEmitter.prototype._events = undefined; EventEmitter.prototype._eventsCount = 0; // 事件数 EventEmitter.prototype._maxListeners = undefined; // 最大的监听器数 ``` 在 EventEmitter 构造函数内部,会调用 `EventEmitter.init` 方法执行初始化操作,`EventEmitter.init` 的具体实现如下: ```javascript EventEmitter.init = function() { if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { this._events = Object.create(null); this._eventsCount = 0; } this._maxListeners = this._maxListeners || undefined; }; ``` 在 EventEmitter.init 内部,会根据条件执行初始化操作,比较重要的这行代码 `this._events = Object.create(null)`,实现过简单发布/订阅模式的小伙伴,估计已经猜到 `_events` 属性的作用了,这里我们就先不继续讨论,我们先来看一下 `on()` 方法。 #### EventEmitter on() 方法 ```javascript EventEmitter.prototype.on = EventEmitter.prototype.addListener; EventEmitter.prototype.addListener = function addListener(type, listener) { return _addListener(this, type, listener, false); }; ``` 通过代码我们可以发现 EventEmitter 实例上 `addListener` 和 `on` 的实现是一样的,执行时都是调用 `events.js` 文件内的 `_addListener()` 函数,它的具体实现如下(代码片段): ```javascript /** * 添加事件监听器 * target:EventEmitter 实例 * type:事件类型 * listener:事件监听器 * prepend:是否添加在前面 */ function _addListener(target, type, listener, prepend) { var m; var events; var existing; // 若监听器不是函数对象,则抛出异常 if (typeof listener !== 'function') { const errors = lazyErrors(); throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'listener', 'Function'); } events = target._events; // 若target._events对象未定义,则使用Object.create创建一个新的对象 if (events === undefined) { events = target._events = Object.create(null); target._eventsCount = 0; } else { // To avoid recursion in the case that type === "newListener"! Before // adding it to the listeners, first emit "newListener". if (events.newListener !== undefined) { target.emit('newListener', type, listener.listener ? listener.listener : listener); // Re-assign `events` because a newListener handler could have caused the // this._events to be assigned to a new object events = target._events; } existing = events[type]; // 获取type类型保存的对象 } if (existing === undefined) { // Optimize the case of one listener. Don't need the extra array object. // 优化单个监听器的场景,不需使用额外的数组对象。 existing = events[type] = listener; ++target._eventsCount; } else { if (typeof existing === 'function') { // 添加type前已有绑定监听器 // Adding the second element, need to change to array. existing = events[type] = prepend ? [listener, existing] : [existing, listener]; // If we've already got an array, just append. } else if (prepend) { // 添加到前面 existing.unshift(listener); } else { // 添加到后面 existing.push(listener); } } return target; } ``` 现在我们来简单总结一下 _addListener() 方法内部的主要流程: - 验证监听器是否为函数对象。 - 避免类型为 newListener 的事件类型,造成递归调用。 - 优化单个监听器的场景,不需使用额外的数组对象。 - 基于 prepend 参数的值,控制监听器的添加顺序。 这时,相信你已经知道 EventEmitter 实例中 `_events` 属性的作用了,即用来以 Key-Value 的形式来保存指定的事件类型与对应的监听器。具体可以参考下图(myEmitter.on('event', ()=>{} 内部执行情况): ![emitter-on](emitter-on.png) 绑定完事件,如果要派发事件,就可以调用 EventEmitter 实例的 emit() 方法,该方法的实现如下(代码片段): ```javascript EventEmitter.prototype.emit = function emit(type, ...args) { let doError = (type === 'error'); const events = this._events; const handler = events[type]; // 获取type类型对应的处理器 if (handler === undefined) return false; // 若事件处理器为函数对象,则使用Reflect.apply进行调用 if (typeof handler === 'function') { Reflect.apply(handler, this, args); } else { const len = handler.length; const listeners = arrayClone(handler, len); for (var i = 0; i < len; ++i) Reflect.apply(listeners[i], this, args); } return true; }; // 数组浅拷贝 function arrayClone(arr, n) { var copy = new Array(n); for (var i = 0; i < n; ++i) copy[i] = arr[i]; return copy; } ``` emit() 方法内部实现还是挺简单的,先根据事件类型获取对应的处理器,然后根据事件处理器的类型,进行进一步处理。需要注意的是,调用处理器是通过 Reflect 对象提供的 `apply()` 方法来实现。 Reflect.apply() 方法的签名如下: > Reflect.apply(target, thisArgument, argumentsList) - target —— 目标函数。 - thisArgument —— target 函数调用时绑定的 this 对象。 - argumentsList —— target 函数调用时传入的实参列表,该参数应该是一个类数组的对象。 如果对 Reflect 对象感兴趣的小伙伴,可以参考[MDN - Reflect 对象](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect)。 到这里前面的简单的示例,我们已经分析完了。我们已经知道通过 EventEmitter 实例的 `on()` 方法可以用来添加事件监听,但有些时候,我们也需要在某些情况下移除对应的监听。针对这种需求,我们就需要利用 EventEmitter 实例的 `removeListener()` 方法了。 #### EventEmitter removeListener() 方法 `removeListener()` 方法最多只会从监听器数组里移除一个监听器实例。 如果任何单一的监听器被多次添加到指定 `type` 的监听器数组中,则必须多次调用 `removeListener()` 方法才能移除每个实例。为了方便一次性移除 `type` 对应的监听器,EventEmitter 为我们提供了 `removeAllListeners()` 方法。 下面我们来看一下 removeListener() 方法的具体实现(代码片段): ```javascript // Emits a 'removeListener' event if and only if the listener was removed. EventEmitter.prototype.removeListener = function removeListener(type, listener) { var list, events, position, i, originalListener; // 若监听器不是函数对象,则抛出异常 if (typeof listener !== 'function') { const errors = lazyErrors(); throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'listener', 'Function'); } events = this._events; if (events === undefined) return this; list = events[type]; // 获取type对应的绑定对象 if (list === undefined) return this; if (list === listener || list.listener === listener) { if (--this._eventsCount === 0) // 只绑定一个监听器 this._events = Object.create(null); else { delete events[type]; // 若已设置removeListener监听器,则触发removeListener事件 if (events.removeListener) this.emit('removeListener', type, list.listener || listener); } } else if (typeof list !== 'function') { // 包含多个监听器 position = -1; for (i = list.length - 1; i >= 0; i--) { // 获取需移除listener对应的索引值 if (list[i] === listener || list[i].listener === listener) { originalListener = list[i].listener; position = i; break; } } if (position < 0) return this; if (position === 0) list.shift(); else { if (spliceOne === undefined) spliceOne = require('internal/util').spliceOne; // 调用内置的spliceOne移除position对应的值 spliceOne(list, position); } if (list.length === 1) events[type] = list[0]; if (events.removeListener !== undefined) this.emit('removeListener', type, originalListener || listener); } return this; }; ``` 通过代码我们发现在调用 `removeListener()` 方法时,若 type 事件类型上绑定多个事件处理器,那么内部处理程序会先根据 `listener` 事件处理器,查找该事件处理器对应的索引值,若该索引值大于 0,则会调用 Node.js 内部工具库提供的 spliceOne() 方法,移除对应的事件处理器。为什么不直接利用 Array#splice() 方法呢?官方的回答是 spliceOne() 方法的执行速度比 Array#splice() 快大约 1.5 倍。 spliceOne() 方法具体实现如下: ```javascript // About 1.5x faster than the two-arg version of Array#splice(). function spliceOne(list, index) { for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) list[i] = list[k]; list.pop(); // 把最后面的空位移除 } ``` 感兴趣的小伙伴,可以实际对比一下 Array#splice() 与 spliceOne() 的性能哈。最后我们来介绍一下 EventEmitter 另一个常用的方法 once()。 #### EventEmitter once() 方法 有些时候,对于一些特殊的事件类型,我们只需执行一次事件处理器,这时我们就可以使用 once() 方法: ```javascript const myEmitter = new MyEmitter(); let m = 0; myEmitter.once('event', () => { console.log(++m); }); myEmitter.emit('event'); // 打印: 1 myEmitter.emit('event'); // 无输出 ``` 以上代码很简单,废话不多说,我们直接看一下 once 函数的具体实现: ```javascript EventEmitter.prototype.once = function once(type, listener) { if (typeof listener !== 'function') { const errors = lazyErrors(); throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'listener', 'Function'); } this.on(type, _onceWrap(this, type, listener)); return this; }; ``` 通过源码可以发现,once() 函数内部也是通过调用 `on()` 方法来绑定事件监听器。特别之处是,内部使用 `_onceWrap` 函数对 listener 函数进行进一步封装。那我们只能继续发掘 `_onceWrap` 函数,该函数的实现如下: ```javascript function _onceWrap(target, type, listener) { var state = { fired: false, wrapFn: undefined, target, type, listener }; var wrapped = onceWrapper.bind(state); // 绑定this上下文 wrapped.listener = listener; state.wrapFn = wrapped; return wrapped; } ``` 在 _onceWrap 函数内部,我们创建了一个 state 对象,该对象有一个 `fired` 属性,用来标识是否已触发,其默认值是 false。一开始还以为内部实现都包含在 _onceWrap 函数内,没想到竟然又来了个 onceWrapper 函数对象。为了能够揭开 once() 的神秘面纱,只能继续前进了。onceWrapper 函数的实现如下: ```javascript function onceWrapper(...args) { if (!this.fired) { this.target.removeListener(this.type, this.wrapFn); this.fired = true; Reflect.apply(this.listener, this.target, args); } } ``` 守得云开见月明,终于见到 onceWrapper 函数的庐山真面目。在函数体中,若发现事件处理器未被调用,则先移除事件监听器并设置 fired 字段值为 true,然后利用之前介绍的 Reflect.apply() 方法调用 type 事件类型,对应的事件处理器。至此,EventEmitter 的探索之旅,就落下的帷幕,想继续了解 EventEmitter 的小伙伴,可以查阅官方文档或 EventEmitter 对应的源码。 ### 总结 为了能够更好地理解 EventEmitter 的设计思想,首先我们介绍了观察者模式与发布/订阅模式,然后对比了它们之间的区别。接着我们以一个简单的示例为切入点,介绍了 EventEmitter 的 on()、emit()、removeListener() 和 once() 方法的使用及内部实现。 如果小伙伴们也对 EventEmitter 源码感兴趣,建议采用阅读和调试相结合的方式,进行源码学习。详细的调试方式,请参考 [Debugging Node.js Apps](https://nodejs.org/en/docs/inspector/) 文章。 ### 参考资源 * [observer-vs-pub-sub-pattern](https://hackernoon.com/observer-vs-pub-sub-pattern-50d3b27f838c) * [Node.js 中文文档 - events](http://nodejs.cn/api/events.html) ================================================ FILE: http/深入学习 Node.js Http 基础篇.md ================================================ ## 深入学习 Node.js Http 基础篇 - [深入学习 Node.js Http 基础篇](#深入学习-nodejs-http-基础篇) - [B/S 结构定义](#bs-结构定义) - [URI (统一资源标志符)](#uri-统一资源标志符) - [URI 文法](#uri-文法) - [MIME](#mime) - [文件格式](#文件格式) - [HTTP 协议](#http-协议) - [HTTP 协议主要特点](#http-协议主要特点) - [HTTP 请求报文](#http-请求报文) - [请求报文示例](#请求报文示例) - [请求行](#请求行) - [请求头](#请求头) - [空行(请求)](#空行请求) - [请求体](#请求体) - [HTTP 响应报文](#http-响应报文) - [响应报文示例](#响应报文示例) - [状态行](#状态行) - [响应头](#响应头) - [空行(响应)](#空行响应) - [响应体](#响应体) - [HTTP 请求方法](#http-请求方法) - [HTTP 状态码](#http-状态码) - [参考资源](#参考资源) ### B/S 结构定义 > **浏览器-服务器(Browser/Server)结构**,简称[B/S结构](https://zh.wikipedia.org/wiki/B/S%E7%BB%93%E6%9E%84),与[C/S结构](https://zh.wikipedia.org/wiki/C/S%E7%BB%93%E6%9E%84)不同,其客户端不需要安装专门的[软件](https://zh.wikipedia.org/wiki/%E8%BD%AF%E4%BB%B6),只需要[浏览器](https://zh.wikipedia.org/wiki/%E6%B5%8F%E8%A7%88%E5%99%A8)即可,浏览器通过[Web](https://zh.wikipedia.org/wiki/Web)[服务器](https://zh.wikipedia.org/wiki/%E6%9C%8D%E5%8A%A1%E5%99%A8)与[数据库](https://zh.wikipedia.org/wiki/%E6%95%B0%E6%8D%AE%E5%BA%93)进行交互,可以方便的在不同平台下工作;服务器端可采用高性能[计算机](https://zh.wikipedia.org/wiki/%E8%AE%A1%E7%AE%97%E6%9C%BA),并安装[Oracle](https://zh.wikipedia.org/wiki/Oracle)、[Sybase](https://zh.wikipedia.org/wiki/Sybase)、[Informix](https://zh.wikipedia.org/w/index.php?title=Informix&action=edit&redlink=1)等大型数据库。B/S结构简化了客户端的工作,它是随着[Internet](https://zh.wikipedia.org/wiki/Internet)技术兴起而产生的,对C/S技术的改进,但该结构下服务器端的工作较重,对服务器的性能要求更高。—— [维基百科](https://zh.wikipedia.org/wiki/%E6%B5%8F%E8%A7%88%E5%99%A8-%E6%9C%8D%E5%8A%A1%E5%99%A8) ![B/S结构](./images/http-resource-1.png) (图片资源来源于网络) ### URI (统一资源标志符) > 在[电脑](https://zh.wikipedia.org/wiki/%E9%9B%BB%E8%85%A6)术语中,**统一资源标识符**(英语:Uniform Resource Identifier,或**URI**)是一个用于[标识](https://zh.wikipedia.org/wiki/%E6%A0%87%E8%AF%86)某一[互联网](https://zh.wikipedia.org/wiki/%E4%BA%92%E8%81%94%E7%BD%91)[资源](https://zh.wikipedia.org/wiki/%E8%B5%84%E6%BA%90)名称的[字符串](https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6%E4%B8%B2)。 该种标识允许用户对网络中(一般指[万维网](https://zh.wikipedia.org/wiki/%E4%B8%87%E7%BB%B4%E7%BD%91))的资源通过特定的[协议](https://zh.wikipedia.org/wiki/%E5%8D%8F%E8%AE%AE)进行交互操作。URI的最常见的形式是[统一资源定位符](https://zh.wikipedia.org/wiki/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E5%AE%9A%E4%BD%8D%E7%AC%A6)(URL),经常指定为非正式的网址。更罕见的用法是[统一资源名称](https://zh.wikipedia.org/wiki/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E5%90%8D%E7%A7%B0)(URN),其目的是通过提供一种途径。用于在特定的[命名空间](https://zh.wikipedia.org/wiki/%E5%91%BD%E5%90%8D%E7%A9%BA%E9%97%B4)资源的标识,以补充网址。—— 维基百科 #### URI 文法 > URI文法由[URI协议](https://zh.wikipedia.org/w/index.php?title=URI%E5%8D%8F%E8%AE%AE&action=edit&redlink=1)名(例如“[`http`](https://zh.wikipedia.org/wiki/%E8%B6%85%E6%96%87%E6%9C%AC%E4%BC%A0%E8%BE%93%E5%8D%8F%E8%AE%AE)”,“[`ftp`](https://zh.wikipedia.org/wiki/%E6%96%87%E4%BB%B6%E4%BC%A0%E8%BE%93%E5%8D%8F%E8%AE%AE)”,“[`mailto`](https://zh.wikipedia.org/wiki/%E7%94%B5%E5%AD%90%E9%82%AE%E4%BB%B6)”或“`file`”),一个[冒号](https://zh.wikipedia.org/wiki/%E5%86%92%E5%8F%B7),和协议对应的内容所构成。特定的协议定义了协议内容的语法和[语义](https://zh.wikipedia.org/wiki/%E8%AF%AD%E4%B9%89),而所有的协议都必须遵循一定的URI文法通用规则,亦即为某些专门目的保留部分特殊字符。—— [维基百科](https://zh.wikipedia.org/wiki/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E6%A0%87%E5%BF%97%E7%AC%A6) 下面展示了 URI 例子及它们的组成部分: ```shell 权限 路径 ┌───────────────┴───────────────┐┌───┴────┐ abc://username:password@example.com:123/path/data?key=value&key2=value2#fragid1 └┬┘ └───────┬───────┘ └────┬────┘ └┬┘ └─────────┬─────────┘ └──┬──┘ 协议 用户信息 主机名 端口 查询参数 片段 ``` ### MIME > MIME (Multipurpose Internet Mail Extensions) 多用途互联网邮件扩展类型。是设定某种[扩展名](http://baike.baidu.com/item/%E6%89%A9%E5%B1%95%E5%90%8D)的[文件](http://baike.baidu.com/item/%E6%96%87%E4%BB%B6)用一种[应用程序](http://baike.baidu.com/item/%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F)来打开的方式类型,当该扩展名文件被访问的时候,[浏览器](http://baike.baidu.com/item/%E6%B5%8F%E8%A7%88%E5%99%A8)会自动使用指定应用程序来打开。多用于指定一些[客户端](http://baike.baidu.com/item/%E5%AE%A2%E6%88%B7%E7%AB%AF)[自定义](http://baike.baidu.com/item/%E8%87%AA%E5%AE%9A%E4%B9%89)的[文件名](http://baike.baidu.com/item/%E6%96%87%E4%BB%B6%E5%90%8D),以及一些媒体文件打开方式。 —— [百度百科](https://baike.baidu.com/item/MIME/2900607) #### 文件格式 每个 MIME 类型由两部分组成,前面是数据的大类别,例如声音 audio、图象 image 等,后面定义具体的种类。 常见的 MIME 类型有: | 资源名称 | 后缀 | 类型 | | --------- | ----- | --------------- | | 超文本标记语言文本 | .html | text/html | | xml文档 | .xml | text/xml | | 普通文本 | .txt | text/plain | | PNG图像 | .png | image/png | | PDF文档 | .pdf | application/pdf | 了解更多的 MIME 类型 - [互联网媒体类型](https://zh.wikipedia.org/wiki/%E4%BA%92%E8%81%94%E7%BD%91%E5%AA%92%E4%BD%93%E7%B1%BB%E5%9E%8B)。 ### HTTP 协议 > **超文本传输协议**([英文](https://zh.wikipedia.org/wiki/%E8%8B%B1%E6%96%87):**HyperText Transfer Protocol**,[缩写](https://zh.wikipedia.org/wiki/%E7%B8%AE%E5%AF%AB):**HTTP**)是[互联网](https://zh.wikipedia.org/wiki/%E7%B6%B2%E9%9A%9B%E7%B6%B2%E8%B7%AF)上应用最为广泛的一种[网络协议](https://zh.wikipedia.org/wiki/%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE)。设计HTTP最初的目的是为了提供一种发布和接收[HTML](https://zh.wikipedia.org/wiki/HTML)页面的方法。通过HTTP或者HTTPS协议请求的资源由[统一资源标识符](https://zh.wikipedia.org/wiki/%E7%B5%B1%E4%B8%80%E8%B3%87%E6%BA%90%E6%A8%99%E8%AD%98%E7%AC%A6)(Uniform Resource Identifiers,URI)来标识。—— 维基百科 HTTP 协议是基于请求与相应,具体如下图所示: ![http-protocol](./images/http-resource-2.png) (图片资源来源于网络) ### HTTP 协议主要特点 - 简单快速:当客户端向服务器端发送请求时,只是简单的填写请求路径和请求方法即可,然后就可以通过浏览器或其他方式将该请求发送就行了。 - 灵活:HTTP 协议允许客户端和服务器端传输任意类型任意格式的数据对象。 - 无连接:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接,采用这种方式可以节省传输时间。(当今多数服务器支持 Keep-Alive 功能,使用服务器支持长连接,解决无连接的问题)。 - 无状态:无状态是指协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。即客户端发送 HTTP请求后,服务器根据请求,会给我们发送数据,发送完后,不会记录信息。(使用 cookie 机制可以保持 session,解决无状态的问题)。 ### HTTP 请求报文 HTTP 请求报文由**请求行**、**请求头**、**空行** 和 **请求体(请求数据)** 4 个部分组成,如下图所示: ![request-message](./images/http-resource-3.png) (图片资源来源于网络) #### 请求报文示例 ``` GET / HTTP/1.1 Host: www.baidu.com Connection: keep-alive Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.110 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Accept-Encoding: gzip, deflate, sdch, br Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,id;q=0.4 Cookie: PSTM=1490844191; BIDUPSID=2145FF54639208435F60E1E165379255; BAIDUID=CFA344942EE2E0EE081D8B13B5C847F9:FG=1; ``` #### 请求行 请求行由请求方法、URL 和 HTTP 协议版本组成,它们之间用空格分开。 ```shell GET / HTTP/1.1 ``` #### 请求头 请求头由 `key-value` 对组成,每行一对,key (键) 和 value (值)用英文冒号 `:` 分隔。请求头通知服务器有关于客户端请求的信息,典型的请求头有: - User-Agent:用户代理信息 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 ... - Accept:客户端可识别的内容类型列表 - text/html,application/xhtml+xml,application/xml - Accept-Language:客户端可接受的自然语言 - zh-CN,zh;q=0.8,en;q=0.6,id;q=0.4 - Accept-Encoding:客户端可接受的编码压缩格式 - gzip, deflate, sdch, br - Host:请求的主机名,允许多个域名同处一个IP地址,即虚拟主机 - `www.baidu.com` - connection:连接方式 - close:告诉WEB服务器或代理服务器,在完成本次请求的响应后,断开连接 - keep-alive:告诉WEB服务器或代理服务器。在完成本次请求的响应后,保持连接,以等待后续请求 - Cookie:存储于客户端扩展字段,向同一域名的服务端发送属于该域的cookie - PSTM=1490844191; BIDUPSID=2145FF54639208435F60E1E165379255; #### 空行(请求) 最后一个请求头之后是一个空行,发送回车符和换行符,通知服务器以下不再有请求头。 #### 请求体 请求数据不在 GET 方法中使用,而是在 POST 方法中使用。与请求数据相关的最常使用的请求头是 Content-Type和 Content-Length。 ### HTTP 响应报文 HTTP响应报文由**状态行、响应头、空行和响应体** 4 个部分组成,如下图所示: ![response-message](./images/http-resource-4.png) (图片资源来源于网络) #### 响应报文示例 ``` HTTP/1.1 200 OK Server: bfe/1.0.8.18 Date: Thu, 30 Mar 2017 12:28:00 GMT Content-Type: text/html; charset=utf-8 Connection: keep-alive Cache-Control: private Expires: Thu, 30 Mar 2017 12:27:43 GMT Set-Cookie: BDSVRTM=0; path=/ ``` #### 状态行 状态行格式: HTTP-Version Status-Code Reason-Phrase CRLF - HTTP-Version:HTTP 协议版本。 - Status-Code:状态码。 - Reason-Phrase:状态码描述。 - CRLF:回车/换行符。 #### 响应头 响应头由 `key-value` 对组成,每行一对,key (键) 和 value (值)用英文冒号 `:` 分隔。响应头域允许服务器传递不能放在状态行的附加信息,这些域主要描述服务器的信息和Request-URI进一步的信息,典型的响应头有: - Server:包含处理请求的原始服务器的软件信息。 - Date:服务器日期。 - Content-Type:返回的资源类型 (MIME)。 - Connection:连接方式 - close:连接已经关闭。 - keep-alive:连接已保持,在等待本次连接的后续请求。 - Cache-Control:缓存控制。 - Expires:设置过期时间。 - Set-Cookie:设置 Cookie 信息。 #### 空行(响应) 最后一个响应头之后是一个空行,发送回车符和换行符,通知浏览器以下不再有响应头。 #### 响应体 服务器返回给浏览器的响应信息,下面是百度首页的响应体片段: ```html 百度一下,你就知道 ... ``` ### HTTP 请求方法 HTTP 协议的请求方法有:GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT、PATCH、HEAD。 HTTP 常用的请求方法: - GET:获取资源,使用 URL 方式传递参数,大小为 2KB - `http://www.example.com/users` - 获取所有用户 - POST:传输资源,HTTP Body,大小默认 8M - `http://www.example.com/users/a-unique-id ` - 新增用户 - PUT:资源更新 - `http://www.example.com/users/a-unique-id` - 更新用户 - DELETE:删除资源 - `http://www.example.com/users/a-unique-id` - 删除用户 ### HTTP 状态码 状态代码由三位数字组成,第一个数字定义了响应的类别,且有五种可能取值: - 1xx:指示信息 – 表示请求已接收,继续处理。 - 2xx:成功 – 表示请求已被成功接收。 - 3xx:重定向 – 要完成请求必须进行更进一步的操作。 - 4xx:客户端错误 – 请求有语法错误或请求无法实现。 - 5xx:服务器错误 – 服务器未能实现合法的请求。 常见状态代码、状态描述的说明如下: - 200 OK:客户端请求成功。 - 204 No Content:没有新文档,浏览器应该继续显示原来的文档。 - 206 Partial Content:客户发送了一个带有 Range 头的 GET 请求,服务器完成了它。 - 301 Moved Permanently:所请求的页面已经转移至新的 url。 - 302 Found:所请求的页面已经临时转移至新的 url。 - 304 Not Modified:客户端有缓冲的文档并发出了一个条件性的请求,服务器告诉客户,原来缓冲的文档还可以继续使用。 - 400 Bad Request:客户端请求有语法错误,不能被服务器所理解。 - 401 Unauthorized:请求未经授权,这个状态代码必须和 WWW-Authenticate 报头域一起使用。 - 403 Forbidden:对被请求页面的访问被禁止。 - 404 Not Found:请求资源不存在。 - 500 Internal Server Error:服务器发生不可预期的错误。 - 503 Server Unavailable:请求未完成,服务器临时过载或当机,一段时间后可能恢复正常。 ### 参考资源 - [HTTP请求报文和HTTP响应报文以及工作原理](http://www.kannng.com/http/2016/10/26/http-packets.html) - [百度百科 - HTTP](http://baike.baidu.com/link?url=Z2p_PetqY7-ujWiMn15wvG6z4K7ORksVt8Dy47sWI2KF_p4VEZKZrN_7vfwuF1HLBfzLs_vJJNyyF-xAQ4AyuK#1) ================================================ FILE: http/深入学习 Node.js Http.md ================================================ ## 深入学习 Node.js Http - [深入学习 Node.js Http](#深入学习-nodejs-http) - [预备知识](#预备知识) - [HTTP](#http) - [请求报文示例](#请求报文示例) - [响应报文示例](#响应报文示例) - [Expect 请求头](#expect-请求头) - [FreeList](#freelist) - [IncomingMessage](#incomingmessage) - [ServerResponse](#serverresponse) - [Node.js Http](#nodejs-http) - [Http 基本使用](#http-基本使用) - [Http 服务器](#http-服务器) - [总结](#总结) - [参考资源](#参考资源) ### 预备知识 #### HTTP 阅读本篇前,如果对 HTTP 协议还不了解的同学,建议先阅读[深入学习 Node.js Http 基础篇](https://github.com/semlinker/node-deep/blob/master/http/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20Http%20%E5%9F%BA%E7%A1%80%E7%AF%87.md)这篇文章。 ##### 请求报文示例 ``` GET / HTTP/1.1 Host: www.baidu.com Connection: keep-alive Cache-Control: max-age=0 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.110 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 ``` ##### 响应报文示例 ``` HTTP/1.1 200 OK Server: bfe/1.0.8.18 Date: Thu, 30 Mar 2017 12:28:00 GMT Content-Type: text/html; charset=utf-8 Connection: keep-alive Cache-Control: private Expires: Thu, 30 Mar 2017 12:27:43 GMT ``` ##### Expect 请求头 Expect 是一个请求消息头,包含一个期望条件,表示服务器只有在满足此期望条件的情况下才能妥善地处理请求。规范中只规定了一个期望条件,即 `Expect: 100-continue`,对此服务器可以做出如下回应: - [`100`](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/100):表示消息头中的期望条件可以得到满足,请求可以顺利进行。 - [`417`](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/417) (Expectation Failed) 表示服务器不能满足期望的条件,也可以是其他任意表示客户端错误的状态码(4xx)。 常见的浏览器不会发送 `Expect` 消息头,但是其他类型的客户端如 cURL 默认会这么做。目前规范中只规定了 `Expect: 100-continue` 这一个期望条件。100-continue 握手的目的是允许客户端在发送包含请求体的消息前,判断源服务器是否愿意在客户端发送请求体前接收请求。 在实际开发过程中,需谨慎使用 Expect: 100-continue,因为如果遇到不支持 HTTP/1.1协议的服务器或代理服务器可能会引起问题。 #### FreeList 在 Node.js 中为了避免频繁创建和销毁对象,实现了一个通用的 FreeList 机制。在 http 模块中,就利用到了 FreeList 机制,即用来动态管理 HTTPParser 对象: ```javascript var parsers = new FreeList('parsers', 1000, function() { var parser = new HTTPParser(HTTPParser.REQUEST); //... } ``` 是不是感觉很高大尚,其实 FreeList 的内部实现很简单,具体如下: ```javascript class FreeList { constructor(name, max, ctor) { this.name = name; // 管理的对象名称 this.ctor = ctor; // 管理对象的构造函数 this.max = max; // 存储对象的最大值 this.list = []; // 存储对象的数组 } alloc() { return this.list.length ? this.list.pop() : this.ctor.apply(this, arguments); } free(obj) { if (this.list.length < this.max) { this.list.push(obj); return true; } return false; } } ``` 在处理 HTTP 请求的场景下,当新的请求到来时,我们通过调用 `parsers.alloc()` 方法来获取 HTTPParser 对象,从而解析 HTTP 请求。当完成 HTTP 解析任务后,我们可以通过调用 `parsers.free()` 方法来归还 HTTPParser 对象。 #### IncomingMessage 在 Node.js 服务器接收到请求时,会利用 HTTPParser 对象来解析请求报文,为了便于开发者使用,Node.js 会基于解析后的请求报文创建 IncomingMessage 对象,IncomingMessage 构造函数(代码片段)如下: ```javascript function IncomingMessage(socket) { Stream.Readable.call(this); this.socket = socket; this.connection = socket; this.httpVersion = null; this.complete = false; this.headers = {}; // 解析后的请求头 this.rawHeaders = []; // 原始的头部信息 // request (server) only this.url = ''; // 请求url地址 this.method = null; // 请求地址 } util.inherits(IncomingMessage, Stream.Readable); ``` Http 协议是基于请求和响应,请求对象我们已经介绍了,那么接下来就是响应对象。在 Node.js 中,响应对象是 ServerResponse 类的实例。 #### ServerResponse ```javascript function ServerResponse(req) { OutgoingMessage.call(this); if (req.method === 'HEAD') this._hasBody = false; this.sendDate = true; this._sent100 = false; this._expect_continue = false; if (req.httpVersionMajor < 1 || req.httpVersionMinor < 1) { this.useChunkedEncodingByDefault = chunkExpression.test(req.headers.te); this.shouldKeepAlive = false; } } util.inherits(ServerResponse, OutgoingMessage); ``` 通过以上代码,我们可以发现 ServerResponse 继承于 OutgoingMessage。在 OutgoingMessage 对象中会包含用于生成响应报文的相关信息,这里就不详细展开,有兴趣的小伙伴可以查看 `_http_outgoing.js` 文件。 ### Node.js Http #### Http 基本使用 simple_server.js ```javascript const http = require("http"); const server = http.createServer((req, res) => { res.end("Hello Semlinker!"); }); server.listen(3000, () => { console.log("server listen on 3000"); }); ``` 当运行完 `node simple_server.js ` 命令后,你可以通过 `http://localhost:3000/` 这个 url 地址来访问我们本地的服务器。不出意外的话,你将在打开的页面中看到 "Hello Semlinker!"。 虽然以上的示例很简单,但对于之前没有服务端经验或者刚接触 Node.js 的小伙伴来说,可能会觉得这是一个很神奇的事情。接下来我们来通过以上简单的示例,分析一下 Node.js 的 Http 模块。 #### Http 服务器 显而易见,`http.createServer()` 方法用来创建服务器,该方法的实现如下: ```javascript function createServer(requestListener) { return new Server(requestListener); } ``` 在 `createServer` 函数内部,我们通过调用 Server 构造函数来创建服务器。因此,接下来的重点就是分析 Server 构造函数了,该函数的内部实现如下: ```javascript function Server(options, requestListener) { if (!(this instanceof Server)) return new Server(options, requestListener); if (typeof options === 'function') { requestListener = options; options = {}; } else if (options == null || typeof options === 'object') { options = util._extend({}, options); } net.Server.call(this, { allowHalfOpen: true }); if (requestListener) { this.on('request', requestListener); } this.on('connection', connectionListener); this.timeout = 2 * 60 * 1000; // 设置超时时间 } util.inherits(Server, net.Server); ``` 看到 `this.on('request',requestListener)` 和 `this.on('connection',connectionListener)` 这两行,不知道小伙伴们有没有想起我们的 EventEmitter。如果对它还不了解的小伙伴,可以参考之前的文章 —— [深入学习 Node.js EventEmitter](https://github.com/semlinker/node-deep/blob/master/event/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0%20Node.js%20EventEmitter.md)。 通过以上源码,目前我们得出了一个结论,在触发 `request` 事件后,就会调用我们设置的 requestListener 函数,即执行以下代码: ```javascript (req, res) => { res.end("Hello Semlinker!"); } ``` 那么什么时候会触发 request 事件呢?而 connection 事件和 connectionListener 又是什么?带着这些问题,我们来继续学习 Http 模块。 connection 事件,顾名思义用来跟踪网络连接。这里,我们重点来看一下 connectionListener 函数: ```javascript function connectionListener(socket) { defaultTriggerAsyncIdScope( getOrSetAsyncId(socket), connectionListenerInternal, this, socket ); } ``` 该函数内竟然还有一个 connectionListenerInternal,那只能继续往下分析了,connectionListenerInternal 函数(代码片段)的内部实现如下: ```javascript function connectionListenerInternal(server, socket) { httpSocketSetup(socket); if (socket.server === null) socket.server = server; if (server.timeout && typeof socket.setTimeout === 'function') socket.setTimeout(server.timeout); socket.on('timeout', socketOnTimeout); // 处理超时情况 var parser = parsers.alloc(); // 获取parser对象 parser.reinitialize(HTTPParser.REQUEST); parser.socket = socket; socket.parser = parser; parser.incoming = null; var state = { outgoing: [], incoming: [], //... }; parser.onIncoming = parserOnIncoming.bind(undefined, server, socket, state); } ``` 在 connectionListenerInternal 函数内部,我们终于见到了 "预备知识" 章节中介绍的 parsers 对象(FreeList 实例)。现在是时候来目睹一下 HTTPParser 对象的芳容了: ```javascript var parsers = new FreeList('parsers', 1000, function() { var parser = new HTTPParser(HTTPParser.REQUEST); parser._headers = []; parser._url = ''; parser._consumed = false; parser.socket = null; parser.incoming = null; parser.outgoing = null; parser[kOnHeaders] = parserOnHeaders; parser[kOnHeadersComplete] = parserOnHeadersComplete; parser[kOnBody] = parserOnBody; return parser; }); ``` 以 parser 开头的这些对象,都是定义在 `_http_common.js` 文件中的函数对象。这里我就不罗列出相关的代码了,只对它们的作用做一些简单的总结: * parserOnHeaders:当请求头跨多个 TCP 数据包或者过大无法再一个运行周期内处理完才会调用该方法。 * kOnHeadersComplete:请求头解析完成后,会调用该方法。方法内部会创建 IncomingMessage 对象,填充相关的属性,比如 url、httpVersion、method 和 headers 等。 * parserOnBody:不断解析已接收的请求体数据。 这里需要注意的是,请求报文的解析工作是由 C++ 来完成,内部通过 binding 来实现,具体参考 `deps/http_parser` 目录。 ```javascript const { methods, HTTPParser } = process.binding('http_parser'); ``` 介绍完 HTTPParser 对象,我们继续回到 connectionListenerInternal 函数中,在最后一行我们设置 parser 对象的 onIncoming 属性为绑定后的 parserOnIncoming 函数,该函数的实现如下(代码片段): ```javascript function parserOnIncoming(server, socket, state, req, keepAlive) { state.incoming.push(req); // 缓冲IncomingMessage实例 var res = new server[kServerResponse](req); if (socket._httpMessage) { state.outgoing.push(res); // 缓冲ServerResponse实例 } else { res.assignSocket(socket); } // 判断请求头是否包含expect字段且http协议的版本为1.1 if (req.headers.expect !== undefined && (req.httpVersionMajor === 1 && req.httpVersionMinor === 1)) { // continueExpression: /(?:^|\W)100-continue(?:$|\W)/i // Expect: 100-continue if (continueExpression.test(req.headers.expect)) { res._expect_continue = true; if (server.listenerCount('checkContinue') > 0) { server.emit('checkContinue', req, res); } else { res.writeContinue(); server.emit('request', req, res); } } else if (server.listenerCount('checkExpectation') > 0) { server.emit('checkExpectation', req, res); } else { // HTTP协议中的417Expectation Failed 状态码表示客户端错误,意味着服务器无法满足 // Expect请求消息头中的期望条件。 res.writeHead(417); res.end(); } } else { server.emit('request', req, res); } return 0; } ``` 通过观察上面的代码,我们终于发现了 `request` 事件的踪迹。在 parserOnIncoming 函数内,我们会基于 req 请求对象创建 ServerResponse 响应对象,在创建响应对象后,会判断请求头是否包含 expect 字段,然后针对不同的条件做出不同的处理。对于之前最早的示例来说,程序会直接走 `else` 分支,即触发 `request` 事件,并传递当前的请求对象和响应对象。 最后我们来回顾一下整个流程: * 调用 `http.createServer()` 方法创建 server 对象,该对象创建完后,我们调用 `listen()` 方法执行监听操作。 * 当 server 接收到客户端的连接请求,在成功创建 socket 对象后,会触发 `connection` 事件。 * 当 `connection` 事件触发后,会执行对应的 `connectionListener` 回调函数。在函数内部会利用 HTTPParser 对象,对请求报文进行解析。 * 在完成请求头的解析后,会创建 IncomingMessage 对象,并填充相关的属性,比如 url、httpVersion、method 和 headers 等。 * 在配置完 IncomingMessage 对象后,会调用 parserOnIncoming 函数,在该函数内会构建 ServerResponse 响应对象,如果请求头不包含 expect 字段,则 server 就会触发 `request` 事件,并传递当前的请求对象和响应对象。 * `request` 事件触发后,就会执行我们设定的 `requestListener` 函数。 ### 总结 本文基于一个简单的服务器示例,一步一步分析了 Node.js Http 模块中请求对象、响应对象内部的创建过程,此外还介绍了 Server 内部两个重要的事件:`connection` 与 `request`。 在文中我们只分析 `request` 事件的触发时机,并未介绍 `connection` 事件的触发时机。另外,我们也没有继续深入分析 server 对象 `listen()` 方法内部执行流程。这是为什么呢?其实这是为下一篇 "深入学习 Node.js Net" 文章留个小伏笔。 ### 参考资源 * [深入理解Node.js:核心思想与源码分析](https://yjhjstz.gitbooks.io/deep-into-node/) * [MDN - Expect 请求头](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Expect) ================================================ FILE: module/深入学习 Node.js Module 进阶篇.md ================================================ ## 深入学习 Node.js Module 进阶篇 - [深入学习 Node.js Module 进阶篇](#深入学习-nodejs-module-进阶篇) - [模块导入](#模块导入) - [JavaScript 模块导入](#javascript-模块导入) - [C、C++ 模块导入](#cc-模块导入) - [JSON 文件导入](#json-文件导入) - [核心模块](#核心模块) - [JavaScript 核心模块的编译过程](#javascript-核心模块的编译过程) - [C、C++ 核心模块的编译过程](#cc-核心模块的编译过程) - [总结](#总结) - [参考资源](#参考资源) ### 模块导入 在 Node 中,模块分为两类: - Node 提供的模块,称为核心模块:在 Node 源代码的编译过程中,编译进了二进制执行文件。在 Node 进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。 - 用户编写的模块,称为文件模块:在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。 而导入模块时,需要经历如下 3 个步骤: - 路径分析:分析 . 或 .. 开始的相对路径文件模块、以 / 开始的绝对路径文件模块、非路径形式的文件模块,如自定义的 connect 模块 - 文件定位:文件扩展名的分析、目录和包的处理。 - 扩展名分析:Node 会按 .js、.json、.node 的次序补足扩展名,依次尝试。 - 目录分析:require() 通过分析文件扩展名之后,可能没有查找到对应文件,但却得到一个目录,这在引入自定义模块和逐个模块路径进行查找时经常会出现,此时 Node 会将目录当做一个包来处理。 - 包处理:Node 对 CommonJS 包规范进行了一定程度的支持。首先,Node 在当前目录下查找 package.json (CommonJS 包规范定义的包描述文件),通过 JSON.parse() 解析出包描述对象,从中取出 main 属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤。 而如果 main 属性指定的文件名错误,或者压根没有 package.json 文件,Node 会将 index 当做默认文件名,依次查找 index.js、index.json、index.node。 - 编译执行 > 不论是核心模块还是文件模块,require() 方法对相同模块的二次加载都一律采用缓存优先的方式,这是第一优先级。不同之处在于核心模块的缓存检查优先于文件模块的缓存检查。 #### JavaScript 模块导入 回到 CommonJS 模块规范,我们知道每个模块文件存在着 require、exports、module 这三个变量,但是它们在模块文件中并没有定义,那么从何而来呢?甚至在 Node 的 API 文档中,我们知道每个模块中还有 `__filename`、`__dirname` 这两个变量的存在,它们又是从何而来的呢? 事实上,在编译的过程中,Node 对获取的 JavaScript 文件内容进行了头尾包装。包装之后的代码会通过 vm 原生模块的 `runInThisContext()` 方法执行(类似 eval,只是具有明确上下文,不污染全局),返回一个具体的 function 对象。最后,将当前模块对象的 exports 属性、require 方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个 function 执行。 ```javascript // Native extension for .js Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); module._compile(internalModule.stripBOM(content), filename); }; ``` #### C、C++ 模块导入 Node 调用 process.dlopen() 方法进行加载和执行。在 Node 的架构下,dlopen() 方法在 Windows 和 *nix 平台下分别有不同的实现,通过 libuv 兼容层进行了封装。 实际上,.node 的模块文件并不需要编译,因为它是编写 C/C++ 模块之后编译生成的,所以这里只有加载和执行的过程。在执行的过程中,模块的 exports 对象与 .node 模块产生联系,然后返回给调用者。 ```javascript //Native extension for .node Module._extensions['.node'] = function(module, filename) { return process.dlopen(module, path.toNamespacedPath(filename)); }; ``` #### JSON 文件导入 Node 利用 fs 模块同步读取 JSON 文件的内容之后,调用 JSON.parse() 方法得到对象,然后将它赋值给模块对象的 exports,以供外部调用。 ```json // Native extension for .json Module._extensions['.json'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); try { module.exports = JSON.parse(internalModule.stripBOM(content)); } catch (err) { err.message = filename + ': ' + err.message; throw err; } }; ``` ### 核心模块 前面提到,Node 的核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块其实分为 C/C++ 编写的和 JavaScript 编写的两部分,其中 C/C++ 文件存放在 Node 项目的 src 目录下,JavaScript 文件存放在 lib 目录下。 #### JavaScript 核心模块的编译过程 在编译所有 C/C++ 文件之前,编译程序需要将所有的 JavaScript 模块文件编译为 C/C++ 代码,此时是否直接将其编译为可执行代码了呢?其实在 Node 在编译的时候,会利用 V8 附带的 js2c.py 工具,将所有内置的 JavaScript 文件(`lib/*.js 和 deps/*.js`)转换成 C++ 里的数组,生成 node_javascript.cc 文件。 - JavaScript 核心模块转存为 C++ 文件 ```c++ #include "node.h" #include "node_javascript.h" #include "v8.h" #include "env.h" #include "env-inl.h" namespace node { static const uint8_t raw_internal_bootstrap_node_key[] = { 105,110,116,101,114,110,97,108,47,98,111,111,116,115,116,114,97,112,95,110, 111,100,101 }; // internal/bootstrap_node static struct : public v8::String::ExternalOneByteStringResource { const char* data() const override { return reinterpret_cast(raw_internal_bootstrap_node_key); } size_t length() const override { return arraysize(raw_internal_bootstrap_node_key); } void Dispose() override { /* Default calls `delete this`. */ } v8::Local ToStringChecked(v8::Isolate* isolate) { return v8::String::NewExternalOneByte(isolate, this).ToLocalChecked(); } } internal_bootstrap_node_key; // lib/internal/bootstrap_node.js源码 static const uint8_t raw_internal_bootstrap_node_value[] = [47,47,32,72,101,108,...] ``` 在这个过程中,JavaScript 代码以字节数组的形式存储在 node 命名空间中,是不可直接执行的。在启动 Node 进程时,JavaScript 代码直接加载进内存中。在加载的过程中,JavaScript 核心模块经历标识符分析后直接定位到内存中,比普通的文件模块从磁盘中一处一处查找要快很多。 - 编译 JavaScript 核心模块 lib 目录下的所有模块文件也没有定义 require、module、exports 这些变量。在引入 JavaScript 核心模块的过程中,也经历了头尾包装的过程,然后才执行和导出了 exports 对象。 ```javascript NativeModule.wrap = function(script) { return NativeModule.wrapper[0] + script + NativeModule.wrapper[1]; }; NativeModule.wrapper = [ '(function (exports, require, module, internalBinding, process) {', '\n});' ]; ``` 此外核心模块与文件模块有区别的地方在于,获取源代码的方式(核心模块是从内存中加载的)以及缓存执行结果的位置。 JavaScript 核心模块的定义如下面代码所示,源文件通过 `process.binding('natives')` 取出,编译成功的模块缓存到 `NativeModule._cache` 对象上,文件模块则缓存到 `Module._cache` 对象上: ```javascript function NativeModule(id) { this.filename = `${id}.js`; this.id = id; this.exports = {}; this.loaded = false; this.loading = false; } NativeModule._source = process.binding('natives'); NativeModule._cache = {}; ``` #### C、C++ 核心模块的编译过程 在核心模块中,有些模块全部由 C/C++ 编写,有些模块则由 C/C++ 完成核心部分,其他部分则由 JavaScript 实现包装或者向外导出,以满足性能需求。后面这种 C++ 模块主内完成核心,JavaScript 主外实现封装的模式是 Node 能够提高性能的常见方式。通常,脚本语言的开发速度优于静态语言,但是其性能则弱于静态语言。而 Node 的这种复合模式可以在开发速度和性能之间找到平衡点。 这里我们将那些由纯 C/C++ 编写的部分统一称为内建模块,因为它们通常不被用户直接调用。Node 的 buffer、crypto、evals、fs、os 等模块都是部分通过 C/C++ 编写的。 - 内建模块的组织形式 在 Node 中,内建模块的内部结构定义如下: ```c struct node_module { int nm_version; // NM_F_BUILTIN = 1 << 0, NM_F_LINKED = 1 << 1, NM_F_INTERNAL = 1 << 2 unsigned int nm_flags; void* nm_dso_handle; const char* nm_filename; node::addon_register_func nm_register_func; node::addon_context_register_func nm_context_register_func; const char* nm_modname; void* nm_priv; struct node_module* nm_link; }; ``` 每一个内建模块在定义之后,会通过 `NODE_BUILTIN_MODULE_CONTEXT_AWARE` 宏将模块定义到 node 命名空间中,模块的初始化方法挂载到 node_module 结构体的 `nm_context_register_func` 成员上。 接下来我们以 `node_buffer.cc` 为例,来简单分析一下这个过程: ```c++ void Initialize(Local target, Local unused, Local context) { Environment* env = Environment::GetCurrent(context); env->SetMethod(target, "setupBufferJS", SetupBufferJS); env->SetMethod(target, "createFromString", CreateFromString); env->SetMethod(target, "byteLengthUtf8", ByteLengthUtf8); env->SetMethod(target, "copy", Copy); // ... env->SetMethod(target, "encodeUtf8String", EncodeUtf8String); target->Set(env->context(), FIXED_ONE_BYTE_STRING(env->isolate(), "kMaxLength"), Integer::NewFromUnsigned(env->isolate(), kMaxLength)).FromJust(); target->Set(env->context(), FIXED_ONE_BYTE_STRING(env->isolate(), "kStringMaxLength"), Integer::New(env->isolate(), String::kMaxLength)).FromJust(); } NODE_BUILTIN_MODULE_CONTEXT_AWARE(buffer, node::Buffer::Initialize) ``` 通过观察以上源码,我可以知道 `node::Buffer::Initialize` 方法,用于导出 `node_buffer.cc` 内部的 C++ 方法,以供 JavaScript 端调用。而上面的 NODE_BUILTIN_MODULE_CONTEXT_AWARE 宏定义如下: ```c++ #define NODE_BUILTIN_MODULE_CONTEXT_AWARE(modname, regfunc) \ NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, nullptr, NM_F_BUILTIN) #define NODE_MODULE_CONTEXT_AWARE_CPP(modname, regfunc, priv, flags) \ static node::node_module _module = { \ NODE_MODULE_VERSION, \ flags, \ nullptr, \ __FILE__, \ nullptr, \ (node::addon_context_register_func) (regfunc), \ NODE_STRINGIFY(modname), \ priv, \ nullptr \ }; \ void _register_ ## modname() { \ node_module_register(&_module); \ } ``` 定义完内建模块后,Node 会通过 `NODE_BUILTIN_MODULES` 宏,进行内建模块的注册, `NODE_BUILTIN_MODULES` 宏的定义如下: ```c++ #define NODE_BUILTIN_MODULES(V) \ NODE_BUILTIN_STANDARD_MODULES(V) \ NODE_BUILTIN_OPENSSL_MODULES(V) \ NODE_BUILTIN_ICU_MODULES(V) ``` 其中 NODE_BUILTIN_STANDARD_MODULES 中注册的主要模块有: - async_wrap - fs - http_parser - module_wrap - stream_wrap - timer_wrap 通过查看源码,我们发现在 `node.cc` 中会调用 NODE_BUILTIN_MODULES 宏: ```c++ // This is used to load built-in modules. Instead of using // __attribute__((constructor)), we call the _register_ // function for each built-in modules explicitly in // node::RegisterBuiltinModules(). This is only forward declaration. // The definitions are in each module's implementation when calling // the NODE_BUILTIN_MODULE_CONTEXT_AWARE. #define V(modname) void _register_##modname(); NODE_BUILTIN_MODULES(V) #undef V ``` 是不是感觉有点晕了,我们赶紧来简单梳理一下 buffer 模块的注册流程: ``` NODE_BUILTIN_MODULES(V) NODE_BUILTIN_STANDARD_MODULES V(buffer) // #define V(modname) void _register_##modname(); node_module_register(&_module); ``` 接着我们继续看一下 node_module_register() 方法: ```c++ extern "C" void node_module_register(void* m) { struct node_module* mp = reinterpret_cast(m); if (mp->nm_flags & NM_F_BUILTIN) { mp->nm_link = modlist_builtin; modlist_builtin = mp; } else if (mp->nm_flags & NM_F_INTERNAL) { mp->nm_link = modlist_internal; modlist_internal = mp; } else if (!node_is_initialized) { // "Linked" modules are included as part of the node project. // Like builtins they are registered *before* node::Init runs. mp->nm_flags = NM_F_LINKED; mp->nm_link = modlist_linked; modlist_linked = mp; } else { modpending = mp; } } ``` 那么如何获取已注册的内建模块呢?这里我们可以利用 `node.cc` 文件中的 get_builtin_module 方法来获取指定的模块,get_builtin_module 方法的具体实现如下: ```C++ node_module* get_builtin_module(const char* name) { return FindModule(modlist_builtin, name, NM_F_BUILTIN); } ``` 这时我们来简单总结一下内建模块的优势:首先,它们本身由 C/C++ 编写,性能上优于脚本语言;其次,在进行文件编译时,它们被编译进二进制文件。一旦 Node 开始执行,它们被直接加载进内存中,无需再次做标识符定位、文件定位、编译等过程,直接就可执行。 - 内建模块的导出 在 Node 的所有模块类型中,存在着如下图所示的一种依赖层级关系,即文件模块可能会依赖核心模块,核心模块可能会依赖内建模块。 ![module-deps](module-deps.png) **通常,不推荐文件模块直接调用内建模块。如需调用,直接调用核心模块即可,因为核心模块中基本都封装了内建模块**。那么内建模块是如何将内部的变量或方法导出,以供外部 JavaScript 核心模块调用的呢? Node 在启动时,会生成一个全局变量 process,并提供 `Binding()` 方法来加载内建模块。`Binding()` 的实现代码在 `src/node.cc` 中,具体如下所示: ```c++ static void Binding(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); CHECK(args[0]->IsString()); Local module = args[0].As(); node::Utf8Value module_v(env->isolate(), module); // 获取内建模块,即C++实现的原生模块 node_module* mod = get_builtin_module(*module_v); Local exports; if (mod != nullptr) { // 若存在则进行模块初始化 exports = InitModule(env, mod, module); } else if (!strcmp(*module_v, "constants")) { // 获取常量信息 exports = Object::New(env->isolate()); CHECK(exports->SetPrototype(env->context(), Null(env->isolate())).FromJust()); DefineConstants(env->isolate(), exports); } else if (!strcmp(*module_v, "natives")) { exports = Object::New(env->isolate()); DefineJavaScript(env, exports); // 实现把C++方法导出到JavaScript上下文中 } else { return ThrowIfNoSuchModule(env, *module_v); } args.GetReturnValue().Set(exports); } // 初始化内建模块,调用nm_context_register_func()方法执行模块注册操作 static Local InitModule(Environment* env, node_module* mod, Local module) { // 新建exports对象 Local exports = Object::New(env->isolate()); // Internal bindings don't have a "module" object, only exports. CHECK_EQ(mod->nm_register_func, nullptr); CHECK_NE(mod->nm_context_register_func, nullptr); Local unused = Undefined(env->isolate()); // 调用模块的初始化方法:如node::Buffer::Initialize mod->nm_context_register_func(exports, unused, env->context(), mod->nm_priv); return exports; } ``` 在加载内建模块时,我们先创建一个 exports 空对象,然后调用 get_builtin_module() 方法取出内建模块对象,通过执行 `nm_context_register_func()` 填充 exports 对象,最后将 exports 对象按模块名缓存,并返回给调用方完成导出。 **这个方法不仅可以导出内建方法,还能导出一些别的内容。前面提到的 JavaScript 核心文件被转换为 C/C++ 数组存储后,便是通过 `process.binding('natives')` 取出放置在 NativeModule._source 中**: ```c++ NativeModule._source = process.binding('natives'); ``` 该方法将通过 js2c.py 工具转换出的字节数组取出,然后重新转换为普通字符串,以对 JavaScript 核心模块进行编译和执行,最后我们来看一下核心模块的引入流程。 - 核心模块的引入流程 从下图所示的 fs 原生模块的引入流程可以看到,为了符合 CommonJS 模块规范,从 JavaScript 到 C/C++ 的过程相当复杂的,它要经历 C/C++ 层面的内建模块定义、JavaScript 核心模块的定义和引入以及(JavaScript)文件模块层面的引入。但是对于用户而言,require() 十分简洁友好。 ![require-fs-module](require-fs-module.png) NativeModule.require ```javascript NativeModule.require = function(id) { const cached = NativeModule.getCached(id); if (cached && (cached.loaded || cached.loading)) { return cached.exports; } moduleLoadList.push(`NativeModule ${id}`); const nativeModule = new NativeModule(id); nativeModule.cache(); nativeModule.compile(); return nativeModule.exports; }; ``` NativeModule.prototype.compile ```javascript NativeModule.prototype.compile = function() { /** * NativeModule._source = process.binding('natives'); * NativeModule.getSource = function(id) { * return NativeModule._source[id]; * }; **/ let source = NativeModule.getSource(this.id); source = NativeModule.wrap(source); this.loading = true; try { const fn = runInThisContext(source, { filename: this.filename, lineOffset: 0, displayErrors: true }); const requireFn = this.id.startsWith('internal/deps/') ? NativeModule.requireForDeps : NativeModule.require; fn(this.exports, requireFn, this, internalBinding, process); this.loaded = true; } finally { this.loading = false; } }; ``` node_javascript.cc -> fs ```c++ // NativeModule.getSource('fs') -> 返回uint8_t raw_fs_value[]的值; static const uint8_t raw_fs_key[] = { 102,115 }; // fs static struct : public v8::String::ExternalOneByteStringResource { const char* data() const override { return reinterpret_cast(raw_fs_key); } size_t length() const override { return arraysize(raw_fs_key); } void Dispose() override { /* Default calls `delete this`. */ } v8::Local ToStringChecked(v8::Isolate* isolate) { return v8::String::NewExternalOneByte(isolate, this).ToLocalChecked(); } } fs_key; static const uint8_t raw_fs_value[] = { // lib/fs.js 文件内容 47,47,32,67,111,112,121,114,105,103,104,116,32,74,111,121,101,110,116,44,... } ``` lib/fs.js ```javascript const binding = process.binding('fs'); const fs = exports; fs.access = function(path, mode, callback) { if (typeof mode === 'function') { callback = mode; mode = fs.F_OK; } path = getPathFromURL(path); validatePath(path); mode = mode | 0; var req = new FSReqWrap(); req.oncomplete = makeCallback(callback); binding.access(pathModule.toNamespacedPath(path), mode, req); }; ``` process.binding ```c++ static void Binding(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); CHECK(args[0]->IsString()); Local module = args[0].As(); node::Utf8Value module_v(env->isolate(), module); node_module* mod = get_builtin_module(*module_v); Local exports; if (mod != nullptr) { exports = InitModule(env, mod, module); } // ... args.GetReturnValue().Set(exports); } ``` node_file.cc ```c++ void InitFs(Local target, Local unused, Local context, void* priv) { Environment* env = Environment::GetCurrent(context); env->SetMethod(target, "access", Access); env->SetMethod(target, "close", Close); env->SetMethod(target, "open", Open); // ... } NODE_BUILTIN_MODULE_CONTEXT_AWARE(fs, node::fs::InitFs) ``` ### 总结 这篇文章我们首先介绍了 Node.js 模块导入的相关知识,之后我们进一步介绍了 JavaScript 模块与 C/C++ 模块的编译过程,最后我们以导入 fs 模块为例,简单介绍了核心模块的引入流程。文中涉及较多的 C/C++ 代码,小伙伴们可以选择性阅读。另外,由于本人也不是很熟悉 C/C++,如有表述不当的地方请大家多见谅。 ### 参考资源 * 深入浅出 Node.js ================================================ FILE: module/深入学习 Node.js Module.md ================================================ ## 深入学习 Node.js Module - [深入学习 Node.js Module](#深入学习-nodejs-module) - [预备知识](#预备知识) - [CommonJS 规范](#commonjs-规范) - [示例](#示例) - [Node.js 模块分类](#nodejs-模块分类) - [module 对象](#module-对象) - [Node.js vm](#nodejs-vm) - [vm.runInThisContext(code[, options])](#vmruninthiscontextcode-options) - [示例](#示例-1) - [Node.js Module](#nodejs-module) - [Module 基本使用](#module-基本使用) - [模块中的 module、exports、`__dirname`、`__filename` 和 require 来自何方?](#模块中的-moduleexports__dirname__filename-和-require-来自何方) - [module.exports 与 exports 有什么区别?](#moduleexports-与-exports-有什么区别) - [模块出现循环依赖了,会出现死循环么?](#模块出现循环依赖了会出现死循环么) - [require 函数支持导入哪几类文件?](#require-函数支持导入哪几类文件) - [require 函数执行的主要流程是什么?](#require-函数执行的主要流程是什么) - [从 `node_modules` 目录加载](#从-node_modules-目录加载) - [总结](#总结) - [参考资源](#参考资源) ### 预备知识 #### CommonJS 规范 Node.js 遵循 [CommonJS规范](http://wiki.commonjs.org/wiki/CommonJS),该规范的核心思想是允许模块通过 `require` 方法来**同步加载所要依赖的其他模块**,然后通过 `exports` 或 `module.exports` 来导出需要暴露的接口。CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。 ##### 示例 add.js ```javascript module.exports = (a, b) => a + b; ``` calculate.js ```javascript const add = require("./add"); console.log("Result: ", add(2, 3)); ``` CommonJS 也有浏览器端的实现,其原理是现将所有模块都定义好并通过 `id` 索引,这样就可以方便的在浏览器环境中解析了,可以参考 [require1k](https://github.com/Stuk/require1k) 和 [tiny-browser-require](https://github.com/ruanyf/tiny-browser-require) 的源码来理解其解析(resolve)的过程。 #### Node.js 模块分类 在 Node.js 中包含以下几类模块: - builtin module: Node.js 中以 C++ 形式提供的模块,如 tcp_wrap、contextify 等 - constants module: Node.js 中定义常量的模块,用来导出如 signal,openssl 库、文件访问权限等常量的定义。如文件访问权限中的 O_RDONLY,O_CREAT、signal 中的 SIGHUP,SIGINT 等。 - native module: Node.js 中以 JavaScript 形式提供的模块,如 http、https、fs 等。有些 native module 需要借助于 builtin module 实现背后的功能。**如对于 native 模块 buffer , 还是需要借助 builtin node_buffer.cc 中提供的功能来实现大容量内存申请和管理,目的是能够脱离 V8 内存大小使用限制**。 - 3rd-party module: 以上模块可以统称 Node.js 内建模块,除此之外为第三方模块,典型的如 express 模块。 #### module 对象 每个模块内部,都有一个 `module` 对象,代表当前模块。它有以下属性。 - `module.id` 模块的识别符,通常是带有绝对路径的模块文件名。 - `module.filename` 模块的文件名,带有绝对路径。 - `module.loaded` 返回一个布尔值,表示模块是否已经完成加载。 - `module.parent` 返回一个对象,表示调用该模块的模块。 - `module.children` 返回一个数组,表示该模块要用到的其他模块。 - `module.exports` 表示模块对外输出的值。 #### Node.js vm `vm` 模块提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。JavaScript 代码可以被编译并立即运行,或编译、保存然后再运行。 ##### vm.runInThisContext(code[, options]) `vm.runInThisContext() ` 在当前的`global` 对象的上下文中编译并执行 `code`,最后返回结果。运行中的代码无法获取本地作用域,但可以获取当前的 `global` 对象。 ##### 示例 ```javascript const vm = require('vm'); let localVar = 'initial value'; const vmResult = vm.runInThisContext('localVar = "vm";'); console.log('vmResult:', vmResult); console.log('localVar:', localVar); const evalResult = eval('localVar = "eval";'); console.log('evalResult:', evalResult); console.log('localVar:', localVar); // vmResult: 'vm', localVar: 'initial value' // evalResult: 'eval', localVar: 'eval' ``` 正因 `vm.runInThisContext()` 无法获取本地作用域,故 `localVar` 的值不变。相反,[`eval()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval) 确实能获取本地作用域,所以`localVar`的值被改变了。 ### Node.js Module Node.js 有一个简单的模块加载系统。 在 Node.js 中,文件和模块是一一对应的(每个文件被视为一个独立的模块)。废话不多说,小伙伴们让我们一起开启 Node.js Module 的探索之旅吧,这次旅程我们会带着以下问题: * 模块中的 module、exports、`__dirname`、`__filename` 和 require 来自何方? * module.exports 与 exports 有什么区别? * 模块出现循环依赖了,会出现死循环么? * require 函数支持导入哪几类文件? * require 函数执行的主要流程是什么? 在这次旅程结束后,希望小伙伴对上述的问题,能够有一个较为清楚的认识。 #### Module 基本使用 foo.js 模块 ```javascript const circle = require('./circle.js'); console.log(`半径为 4 的圆的面积是 ${circle.area(4)}`); ``` circle.js 模块 ```javascript const { PI } = Math; exports.area = (r) => PI * r ** 2; exports.circumference = (r) => 2 * PI * r; ``` `circle.js` 模块导出了 `area()` 和 `circumference()` 两个函数。 通过在特殊的 `exports` 对象上指定额外的属性,函数和对象可以被添加到模块的根部。 在 `circle.js` 文件中,我们使用了特殊的 `exports` 对象。其实除了 `exports` 之外,在模块中我们还可以 module、`__dirname`、`__filename` 和 require 这些对象,那它们是从哪里来的呢?好的,我们来解答第一个问题。 #### 模块中的 module、exports、`__dirname`、`__filename` 和 require 来自何方? 当然首先我要先知道它们是什么,这里我们新建一个模块 `module-var.js`,输入以下内容: ```javascript console.log(module); console.log(exports); console.log(__dirname); console.log(__filename); console.log(require); ``` 执行完以上代码,控制台的输出如下(忽略输出对象中的大部分属性): ```shell Module { -------------------------------------------------> module id: '.', exports: {}, paths: [] // 模块查找路径 } {} -------------------------------------------------> exports /Users/fer/VSCProjects/learn-node/module -----------------------------> __dirname /Users/fer/VSCProjects/learn-node/module/module-var.js -----------------> __filename { [Function: require] -------------------------------------------------> require resolve: [Function: resolve], main: Module { } // Module对象 } ``` 通过控制台的输出值,我们可以清楚地看出每个变量的值。这里先不细究它们,我们先来调查一下它们的来源。 > CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。 那么 CommonJS 规范是如何解决 JavaScript 的作用域问题,并让每个模块在自身的命名空间中执行呢?不知道小伙伴们是否还记得,在前端的模块方案出来之前,为了避免污染变量污染,我们通过以下方式来创建独立的运行空间: ```javascript (function(global){ // some code })(window) ``` 那么 Node.js 是不是也是通过这种方式来解决作用域问题和代码封装呢? 俗话说眼见为实,我们输入 `node --inspect-brk module-var.js` 命令,调试一下前面创建的 `module-var.js` 文件:![module-var](module-var.png) 通过上图我们可以发现,`module-var.js` 文件中定义的内容,以 `(function(){})` 这种形式被包装了。这里,我们就清楚了,模块中的 module、exports、`__dirname`、`__filename` 和 require 这些对象都是函数的输入参数,在调用包装后的函数时传入。这时第一个问题先告一段落,我们继续探究第二个问题。 #### module.exports 与 exports 有什么区别? 先不急着解释它们之间的区别,我们先来看一行代码: ```javascript console.log(module.exports === exports); ``` 运行完上面的代码,控制台会输出 `true`。那好,我们继续往下看: ```javascript exports.id = 1; // 方式一:可以正常导出 exports = { id: 1 }; // 方式二:无法正常导出 module.exports = { id: 1 }; // 方式三:可以正常导出 ``` 为什么方式二无法正常导出呢?让我们回顾一下运行 `module-var.js` 文件时, `module` 和 `exports` 的输出结果: ``` Module { -------------------------------------------------> module id: '.', exports: {}, paths: [] // 模块查找路径 } {} -------------------------------------------------> exports ``` 如果 `module.exports === exports` 执行的结果为 true,那么表示模块中的 exports 变量与 module.exports 属性是指向同一个对象。**当使用方式二 `exports = { id: 1 }` 的方式会改变 exports 变量的指向,这时与module.exports 属性指向不同的变量,而当我们导入某个模块时,是导入 module.exports 属性指向的对象**,具体原因后面会细说。 希望通过上面的分析,小伙伴们能够清晰地了解 module.exports 与 exports 之间的区别和联系。接下来,我们继续第三个问题。 #### 模块出现循环依赖了,会出现死循环么? 首先我们先简单解释一下循环依赖,当模块 a 执行时需要依赖模块 b 中定义的属性或方法,而在导入模块 b 中,发现模块 b 同时也依赖模块 a 中的属性或方法,即两个模块之间互相依赖,这种现象我们称之为循环依赖。 介绍完循环依赖的概念,那出现这种情况会出现死循环么?我们马上来验证一下: module1.js ```javascript exports.a = 1; exports.b = 2; require("./module2"); exports.c = 3; ``` module2.js ```javascript const Module1 = require('./module1'); console.log('Module1 is partially loaded here', Module1); ``` 当我们在命令行中输入 `node lib/module1.js` 命令,你会发现程序正常运行,并且在控制台输出了以下内容: ``` Module1 is partially loaded here { a: 1, b: 2 } ``` 通过实际验证,我们发现出现循环依赖的时候,程序并不会出现死循环,但只会输出相应模块已加载的部分数据。 解释完模块循环依赖的问题,我们继续下一个问题。 #### require 函数支持导入哪几类文件? 模块内的 require 函数,支持的文件类型主要有 `.js` 、`.json` 和 `.node`。其中 `.js` 和 `.json` 文件,相信大家都很熟悉了,`.node` 后缀的文件是 Node.js 的二进制文件。然而为什么 require 函数,只支持这三种文件格式呢?其实答案在模块内输出的 `require` 函数对象中: ```javascript { [Function: require] resolve: [Function: resolve], main: Module {}, extensions: { '.js': [Function], '.json': [Function], '.node': [Function] } } ``` 在 `require` 函数对象中,有一个 `extensions` 属性,顾名思义表示它支持的扩展名。细心的小伙伴,可能已经看到了,每种扩展名对应的值都是函数对象。既然发现了它们的踪迹,我们就来看一下它们的真面目。其实模块内的 require 函数对象是通过 `lib/internal/module.js` 文件中的 `makeRequireFunction` 函数创建的,那我们就来看一下该函数(代码片段): ```javascript function makeRequireFunction(mod) { const Module = mod.constructor; function require(path) { try { exports.requireDepth += 1; return mod.require(path); } finally { exports.requireDepth -= 1; } } // Enable support to add extra extension types. require.extensions = Module._extensions; require.cache = Module._cache; return require; } ``` 通过以上我们发现,模块内的 require 函数对象,在导入模块时,最终还是通过调用 Module 对象的 require() 方法来实现模块导入。此时,我们的重点在 `require.extensions = Module._extensions;` 这行代码上,哈哈,终于定位到了源头。 继续打开 `lib/module.js` 文件,我们发现了以下的定义: ```javascript // Native extension for .js Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); module._compile(internalModule.stripBOM(content), filename); }; // Native extension for .json Module._extensions['.json'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); try { module.exports = JSON.parse(internalModule.stripBOM(content)); } catch (err) { err.message = filename + ': ' + err.message; throw err; } }; //Native extension for .node Module._extensions['.node'] = function(module, filename) { return process.dlopen(module, path.toNamespacedPath(filename)); }; ``` `.json` 的文件的处理逻辑很简单,我们就不进一步说明了。而 `.node` 文件的处理方式,因为涉及到 bindings 这个后面会有专门的文章介绍这块内容。这里我们就来重点介绍 `.js` 文件的处理方式。 ```javascript // Native extension for .js Module._extensions['.js'] = function(module, filename) { var content = fs.readFileSync(filename, 'utf8'); // (1) module._compile(internalModule.stripBOM(content), filename); // (2) }; ``` 函数体中的第一行,我们以同步的方式读取对应的文件内容。而第二行中,我们会对文件的内容进行编译,然而在编译前我们会对内容进行处理,比如移除 BOM (Byte Order Mark),stripBOM 的具体实现如下: ```javascript function stripBOM(content) { if (content.charCodeAt(0) === 0xFEFF) { content = content.slice(1); } return content; } ``` > **字节顺序标记**(英语:byte-order mark,**BOM**)是位于码点`U+FEFF`的[统一码](https://zh.wikipedia.org/wiki/%E7%B5%B1%E4%B8%80%E7%A2%BC)字符的名称。当以[UTF-16](https://zh.wikipedia.org/wiki/UTF-16)或[UTF-32](https://zh.wikipedia.org/wiki/UTF-32)来将[UCS](https://zh.wikipedia.org/wiki/UCS)/统一码字符所组成的字符串编码时,这个字符被用来标示其[字节序](https://zh.wikipedia.org/wiki/%E5%AD%97%E8%8A%82%E5%BA%8F)。它常被用来当做标示文件是以[UTF-8](https://zh.wikipedia.org/wiki/UTF-8)、[UTF-16](https://zh.wikipedia.org/wiki/UTF-16)或[UTF-32](https://zh.wikipedia.org/wiki/UTF-32)编码的记号。 —— [维基百科](https://zh.wikipedia.org/wiki/%E4%BD%8D%E5%85%83%E7%B5%84%E9%A0%86%E5%BA%8F%E8%A8%98%E8%99%9F) 接下来我们就来重点看一下 `_compile()` 方法(代码片段): ```javascript Module.prototype._compile = function(content, filename) { // 在计算机科学中,Shebang(也称为 Hashbang )是一个由井号和叹号构成的字符序列#! content = internalModule.stripShebang(content); // create wrapper function var wrapper = Module.wrap(content); // (1) var compiledWrapper = vm.runInThisContext(wrapper, { // (2) filename: filename, lineOffset: 0, displayErrors: true }); var dirname = path.dirname(filename); var require = internalModule.makeRequireFunction(this); var depth = internalModule.requireDepth; if (depth === 0) stat.cache = new Map(); var result; if (inspectorWrapper) { result = inspectorWrapper(compiledWrapper, this.exports, this.exports, require, this, filename, dirname); } else { result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname); } if (depth === 0) stat.cache = null; return result; }; ``` 这里我们先来看 (1) 这一行,`var wrapper = Module.wrap(content);` ,即调用 Module 内部的封装函数对模块的原始内容进行封装。Module.wrap 函数实现很简单,具体如下: ```javascript Module.wrap = function(script) { return Module.wrapper[0] + script + Module.wrapper[1]; }; Module.wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ]; ``` 看到这里你是不是已经恍然大悟,原来模块中的原始内容是在这个阶段进行包装的。包装后的格式为: ```javascript (function (exports, require, module, __filename, __dirname) { // 模块原始内容 }); ``` 经过 Module.wrap 函数包装后返回的字符串,会作为 vm.runInThisContext() 方法的输入参数,并调用该方法。 然后我们把方法的返回值保存在 `compiledWrapper` 变量上,接着我们会准备 compiledWrapper 对应函数对象的调用参数,最后通过 `call()` 方法调用该函数。 OK,我们继续下一个问题 —— require 函数执行的主要流程是什么? #### require 函数执行的主要流程是什么? 在加载对应模块前,我们首先需要定位文件的路径,文件的定位是通过 Module 内部的 `_resolveFilename()` 方法来实现,相关的伪代码描述如下: ``` 从 Y 路径的模块 require(X) 1. 如果 X 是一个核心模块, a. 返回核心模块 b. 结束 2. 如果 X 是以 '/' 开头 a. 设 Y 为文件系统根目录 3. 如果 X 是以 './' 或 '/' 或 '../' 开头 a. 加载文件(Y + X) b. 加载目录(Y + X) 4. 加载Node模块(X, dirname(Y)) 5. 抛出 "未找到" 加载文件(X) 1. 如果 X 是一个文件,加载 X 作为 JavaScript 文本。结束 2. 如果 X.js 是一个文件,加载 X.js 作为 JavaScript 文本。结束 3. 如果 X.json 是一个文件,解析 X.json 成一个 JavaScript 对象。结束 4. 如果 X.node 是一个文件,加载 X.node 作为二进制插件。结束 加载索引(X) 1. 如果 X/index.js 是一个文件,加载 X/index.js 作为 JavaScript 文本。结束 3. 如果 X/index.json 是一个文件,解析 X/index.json 成一个 JavaScript 对象。结束 4. 如果 X/index.node 是一个文件,加载 X/index.node 作为二进制插件。结束 加载目录(X) 1. 如果 X/package.json 是一个文件, a. 解析 X/package.json,查找 "main" 字段 b. let M = X + (json main 字段) c. 加载文件(M) d. 加载索引(M) 2. 加载索引(X) 加载Node模块(X, START) 1. let DIRS=NODE_MODULES_PATHS(START) 2. for each DIR in DIRS: a. 加载文件(DIR/X) b. 加载目录(DIR/X) NODE_MODULES_PATHS(START) 1. let PARTS = path split(START) 2. let I = count of PARTS - 1 3. let DIRS = [] 4. while I >= 0, a. if PARTS[I] = "node_modules" CONTINUE b. DIR = path join(PARTS[0 .. I] + "node_modules") c. DIRS = DIRS + DIR d. let I = I - 1 5. return DIRS ``` `_resolveFilename()` 方法内部的判断逻辑比较复杂,感兴趣的小伙伴,可以断点跟踪一下整个执行过程。这里我们需要注意的是加载文件、加载索引和加载目录的主要执行过程。 接下来我们来看一下内部的 Module 对象的 require() 方法: ```javascript // Loads a module at the given file path. Returns that module's // `exports` property. Module.prototype.require = function(id) { if (typeof id !== 'string') { throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'id', 'string', id); } if (id === '') { throw new errors.Error('ERR_INVALID_ARG_VALUE', 'id', id, 'must be a non-empty string'); } return Module._load(id, this, /* isMain */ false); }; ``` 通过源码上的注释,我们清楚地知道了 require 函数的作用,即用来加载给定文件路径的模块,并返回相应模块对象的 exports 属性。趁热打铁,我们继续来看一下 `Module._load()` 方法(代码片段): ```javascript // Check the cache for the requested file. // 1. If a module already exists in the cache: return its exports object. // 2. If the module is native: call `NativeModule.require()` with the // filename and return the result. // 3. Otherwise, create a new module for the file and save it to the cache. // Then have it load the file contents before returning its exports // object. Module._load = function(request, parent, isMain) { // 解析文件的具体路径 var filename = Module._resolveFilename(request, parent, isMain); // 优先从缓存中获取 var cachedModule = Module._cache[filename]; if (cachedModule) { updateChildren(parent, cachedModule, true); // 导出模块的exports属性 return cachedModule.exports; } // 判断是否为native module,如fs、http等 if (NativeModule.nonInternalExists(filename)) { debug('load native module %s', request); return NativeModule.require(filename); } // Don't call updateChildren(), Module constructor already does. // 创建新的模块对象 var module = new Module(filename, parent); if (isMain) { process.mainModule = module; module.id = '.'; } // 缓存新建的模块 Module._cache[filename] = module; // 尝试进行模块加载 tryModuleLoad(module, filename); return module.exports; }; ``` 通过源码我们可以发现,模块首次被加载后,会被缓存在 Module._cache 属性中,以提高模块的导入效率。但有些时候,我们修改了已被缓存的模块,希望其它模块导入时,获取到更新后的内容,那应该怎么办呢?针对这种情况,我们可以使用以下方法清除指定缓存的模块,或清理所有已缓存的模块: ```javascript //删除指定模块的缓存 delete require.cache[require.resolve('/*被缓存的模块名称*/')] // 删除所有模块的缓存 Object.keys(require.cache).forEach(function(key) { delete require.cache[key]; }); ``` 最后我们再来简单介绍一下从 `node_modules` 目录加载,即通过 `require('koa')` 导入 Koa 模块的加载过程。 #### 从 `node_modules` 目录加载 如果传递给 `require()` 的模块标识符不是一个[核心模块](http://nodejs.cn/api/modules.html#modules_core_modules),也没有以 `'/'` 、 `'../'` 或 `'./'` 开头,则 Node.js 会从当前模块的父目录开始,尝试从它的 `/node_modules` 目录里加载模块。 Node.js 不会附加 `node_modules` 到一个已经以 `node_modules` 结尾的路径上。 如果还是没有找到,则移动到再上一层父目录,直到文件系统的根目录。 比如在 `'/home/ry/projects/foo.js'` 文件里调用了 `require('bar.js')`,则 Node.js 会按以下顺序查找: - `/home/ry/projects/node_modules/bar.js` - `/home/ry/node_modules/bar.js` - `/home/node_modules/bar.js` - `/node_modules/bar.js` 这使得程序本地化它们的依赖,避免它们产生冲突。通过在模块名后包含一个路径后缀,可以请求特定的文件或分布式的子模块。 例如,`require('example-module/path/to/file')` 会把 `path/to/file` 解析成相对于 `example-module` 的位置。 后缀路径同样遵循模块的解析语法。 ### 总结 为了能够更好地理解 Node.js Module 模块,我们介绍了 CommonJS、Node 模块分类、Module 对象等相关的基础知识。然后以一系列问题为切入点,循序渐进介绍了 module.exports 与 exports 对象的区别、模块循环依赖、require 支持导入的文件类型及 require 函数执行的主要流程等相关的知识。最后我们还介绍了如何清除已缓存的模块,从而实现模块更新和从 node_modules 目录加载的相关内容。 希望本篇文章,能够帮你更好地理解并掌握 Node.js 模块的相关知识,如果有写得不好的地方,请各位小伙伴多多见谅。 ### 参考资源 * [Webpack 中文指南 - CommonJS 规范](http://zhaoda.net/webpack-handbook/index.html) * [Node.js 中文文档 - module](http://nodejs.cn/api/modules.html) * [Node.js 中文文档 - vm](http://nodejs.cn/api/vm.html#vm_vm_executing_javascript) ================================================ FILE: net/深入学习 Node.js Net.md ================================================ ## 深入学习 Node.js Net - [深入学习 Node.js Net](#深入学习-nodejs-net) - [预备知识](#预备知识) - [Socket](#socket) - [Node.js 网络模块架构](#nodejs-网络模块架构) - [Nagle 算法](#nagle-算法) - [nc 命令](#nc-命令) - [Node.js net](#nodejs-net) - [net 基本使用](#net-基本使用) - [TCP Server](#tcp-server) - [UNIX Domain Socket](#unix-domain-socket) - [总结](#总结) - [参考资源](#参考资源) ### 预备知识 #### Socket 网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个 socket(套接字),因此建立网络通信连接至少要一对端口号。**socket 本质是对 TCP/IP 协议栈的封装,它提供了一个针对 TCP 或者 UDP 编程的接口,并不是另一种协议**。通过 socket,你可以使用 TCP/IP 协议。 > Socket的英文原义是“孔”或“插座”。作为BSD UNIX的[进程通信](https://baike.baidu.com/item/%E8%BF%9B%E7%A8%8B%E9%80%9A%E4%BF%A1)机制,取后一种意思。通常也称作"[套接字](https://baike.baidu.com/item/%E5%A5%97%E6%8E%A5%E5%AD%97)",用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。在Internet上的[主机](https://baike.baidu.com/item/%E4%B8%BB%E6%9C%BA)一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。Socket正如其英文原义那样,像一个多孔插座。一台主机犹如布满各种插座的房间,每个插座有一个编号,有的插座提供220伏交流电, 有的提供110伏交流电,有的则提供有线电视节目。 客户软件将插头插到不同编号的插座,就可以得到不同的服务。—— [百度百科](https://baike.baidu.com/item/socket/281150) 关于 Socket,可以总结以下几点: * 它可以实现底层通信,几乎所有的应用层都是通过 socket 进行通信的。 * 对 TCP/IP 协议进行封装,便于应用层协议调用,属于二者之间的中间抽象层。 * TCP/IP 协议族中,传输层存在两种通用协议: TCP、UDP,两种协议不同,因为不同参数的 socket 实现过程也不一样。 ![socket](socket.jpg) (图片来源 —— [初步研究node中的网络通信模块](http://zhenhua-lee.github.io/node/socket.html)) #### Node.js 网络模块架构 在 Node.js 的模块里面,与网络相关的模块有:**Net**、**DNS**、**HTTP**、**TLS/SSL**、**HTTPS**、**UDP/Datagram**,除此之外,还有 v8 底层相关的网络模块有 `tcp_wrap.cc`、`udp_wrap.cc`、`pipe_wrap.cc`、`stream_wrap.cc` 等等,在 JavaScript 层以及 C++ 层之间通过 `process.binding ` 进行桥接相互通信。 ![network-arch](network-arch.png) (图片来源 —— [Node.js之网络通讯模块浅析](https://segmentfault.com/a/1190000008908077)) #### Nagle 算法 Nagle 算法描述是当一个连接有未确认的数据,小片段应该保留。当足够的数据已被收件人确认,这些小片段将被分批成能够被传输的更大的片段。 在很多小的数据包传输的网络,理想的情况将小的包集合起来一起发送以减少拥堵。但有时等待时间比其他都重要,所以传送小包是非常重要的。 这对互动应用尤其重要,像 ssh 或者 X Window 系统。在这些应用中,体积小的消息应毫不延迟地输送,以给人实时反馈的感觉。针对这种需求场景,我们可以通过 `setNoDelay(true)` 方法,来关闭 Nagle 算法。 #### nc 命令 nc(netcat)可以用于涉及 TCP 或 UDP 的相关内容,比如通过它我们可以打开 TCP 连接,发送 UDP 数据包,监听任意的 TCP 和 UDP 端口,执行端口扫描和处理 IPv4 和 IPv6 等。 利用 `nc` 命令,我们可以方便地连接一个 UNIX 域套接字(socket)服务器,如: ```shell $ nc -U /tmp/echo.sock # -U — Use UNIX domain socket ``` socket API 原本是为网络通讯设计的,但后来在 socket 的框架上发展出一种 IPC (Inter-Process Communication)机制,就是 UNIX Domain Socket 也称为本地域。虽然网络 socket 也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是 UNIX Domain Socket 用于 IPC 更有效率:**不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程**。这是因为,IPC 机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket 也提供面向流和面向数据包两种 API 接口,类似于 TCP 和 UDP,但是面向消息的 UNIX Domain Socket 也是可靠的,消息既不会丢失也不会顺序错乱。 在 Windows 上,本地域通过命名管道实现。路径必须是以 `\\?\pipe\` 或 `\\.\pipe\` 为入口。路径允许任何字符,但后面的字符可能会对管道名称进行一些处理,例如解析 `..` 序列。尽管如此,管道空间是平面的。管道不会持续,当最后一次引用关闭时,管道就会被删除。 ### Node.js net `net` 模块提供了创建基于流的 TCP 或 [IPC](http://nodejs.cn/api/net.html#net_ipc_support) 服务器 ([`net.createServer()`](http://nodejs.cn/api/net.html#net_net_createserver_options_connectionlistener)) 和客户端 ([`net.createConnection()`](http://nodejs.cn/api/net.html#net_net_createconnection)) 的异步网络 API。 #### net 基本使用 server.js ```javascript // 创建socket服务器 const net = require("net"); let clients = 0; const server = net.createServer(client => { clients++; let clientId = clients; console.log("Client connect:", clientId); client.on("end", () => { console.log("Client disconnected:", clientId); }); client.write("Welcome client: " + clientId + " \r\n"); client.pipe(client); // 把客户端的数据返回给客户端 }); server.listen(8000, () => { console.log("Server started on port 8000"); }); ``` client.js ```javascript // 创建socket客户端 const net = require("net"); const client = net.connect(8000); client.on("data", data => { console.log(data.toString()); }); client.on("end", () => { console.log("Client disconnected"); }); ``` 新开两个终端,按顺序执行 `node server.js` 和 `node client.js` 命令后,就可以在控制台看到输出的数据了。 接下来我们来分别分析一下 server.js 和 client.js 文件。 #### TCP Server ```javascript // 创建socket服务器 const net = require("net"); let clients = 0; const server = net.createServer(client => { // 参考net基本使用相关代码 }); server.listen(8000, () => { console.log("Server started on port 8000"); }); ``` 以上代码通过调用 net.createServer() 方法来创建一个新的 TCP 服务器,该方法的实现如下: ```javascript function createServer(options, connectionListener) { return new Server(options, connectionListener); } ``` 可以看到 net.createServer() 方法内部是通过调用 `Server` 的构造函数创建 TCP 服务器,Server 构造函数(代码片段)如下: ```javascript function Server(options, connectionListener) { if (!(this instanceof Server)) // 确保以new形式调用构造函数 return new Server(options, connectionListener); EventEmitter.call(this); if (typeof options === 'function') { connectionListener = options; options = {}; this.on('connection', connectionListener); } else if (options == null || typeof options === 'object') { options = options || {}; if (typeof connectionListener === 'function') { this.on('connection', connectionListener); } } this._connections = 0; this._handle = null; } util.inherits(Server, EventEmitter); ``` 通过观察以上代码,我们发现 Server 类继承了 EventEmitter 类,Server 实例内部会监听 `connection` 事件,该事件触发后,会执行用户设置的 `connectionListener` 回调函数。那么何时会触发 `connection` 事件,通过源码我们发现在 onconnection 函数内部会触发 `connection` 事件,具体如下(代码片段): ```javascript function onconnection(err, clientHandle) { var handle = this; var self = handle.owner; // 判断是否超过最大连接数 if (self.maxConnections && self._connections >= self.maxConnections) { clientHandle.close(); return; } // util.inherits(Socket, stream.Duplex); var socket = new Socket({ handle: clientHandle, allowHalfOpen: self.allowHalfOpen, pauseOnCreate: self.pauseOnConnect }); socket.readable = socket.writable = true; self._connections++; self.emit('connection', socket); } ``` 在 onconnection 函数内部,我们通过调用 Socket 构造函数来创建 socket 对象,因为 Socket 类继承于 stream.Duplex 类,所以 socket 对象也是一个可读可写流,可以使用 stream.Duplex 中定义的方法。 那么接下来的问题就是何时调用 `onconnection` 函数,我们继续在源码中找答案。最终我们发现在 setupListenHandle 函数内部会通过执行 `this._handle.onconnection = onconnection;` 语句设置 `onconnection` 函数。 顾名思义,setupListenHandle 函数的作用是用于设置监听处理器,该函数对象会被绑定到 Server 原型对象的 `_listen2` 属性上: ```javascript Server.prototype._listen2 = setupListenHandle; ``` 不知道小伙伴们,还记得以下这段代码: ```javascript server.listen(8000, () => { console.log("Server started on port 8000"); }); ``` 以上代码我们通过 `listen()` 方法来设置 TCP 服务器的监听端口,这里的 `listen()` 方法与 `_listen2()` 方法是不是会有联系?嗯,没错,它们之间有紧密的联系,谁让它们长得像。 接下来我们先来看一下创建 TCP 服务器 `listen()` 方法的签名: > `server.listen([port\][, host][, backlog][, callback])]` * 支持 port、host、backlog 和 callback 参数。 * 返回相应的 server 对象。 而创建 IPC 服务器 `listen()` 方法的签名为: > `server.listen(path[, backlog][, callback])` * 支持 path(服务器需要监听的路径,详情可以查看 [Identifying paths for IPC connections](http://nodejs.cn/api/net.html#net_identifying_paths_for_ipc_connections)。)backlog 和 callback 参数。 * 返回相应的 server 对象。 这里我们先来分析创建 TCP 服务器的情形: ```javascript Server.prototype.listen = function(...args) { var normalized = normalizeArgs(args); var options = normalized[0]; var cb = normalized[1]; var hasCallback = (cb !== null); if (hasCallback) { this.once('listening', cb); } options = options._handle || options.handle || options; var backlog; // options.port:8000 if (typeof options.port === 'number' || typeof options.port === 'string') { // start TCP server listening on host:port if (options.host) { lookupAndListen(this, options.port | 0, options.host, backlog, options.exclusive); } else { // Undefined host, listens on unspecified address // Default addressType 4 will be used to search for master server listenInCluster(this, null, options.port | 0, 4, backlog, undefined, options.exclusive); } return this; } }; ``` 而 `listenInCluster` 函数的内部实现如下(代码片段): ```javascript function listenInCluster(server, address, port, addressType, backlog, fd, exclusive) { // 如果exclusive是false(默认),则集群的所有进程将使用相同的底层句柄,允许共享连接处理任务。 // 如果exclusive是true,则句柄不会被共享,如果尝试端口共享将导致错误。 exclusive = !!exclusive; // 引入cluster(集群)模块 // Node.js在单个线程中运行单个实例。 用户(开发者)为了使用现在的多核系统,有时候, // 用户(开发者)会用一串Node.js进程去处理负载任务。 if (cluster === null) cluster = require('cluster'); // 当该进程是主进程时,返回true。 if (cluster.isMaster || exclusive) { // Will create a new handle // _listen2 sets up the listened handle, it is still named like this // to avoid breaking code that wraps this method server._listen2(address, port, addressType, backlog, fd); return; } } ``` 我们继续来看一下 `_listen2()` 方法(代码片段): ```javascript // Server.prototype._listen2 = setupListenHandle; function setupListenHandle(address, port, addressType, backlog, fd) { if (this._handle) { debug('setupListenHandle: have a handle already'); } else { debug('setupListenHandle: create a handle'); var rval = null; if (rval === null) rval = createServerHandle(address, port, addressType, fd); this._handle = rval; } // 此处绑定onconnection函数 this._handle.onconnection = onconnection; } ``` 以上代码的核心在于 `createServerHandle` 函数,所以我们继续来分析一下该函数(代码片段): ```javascript function createServerHandle(address, port, addressType, fd) { var handle; var isTCP = false; // server.listen(handle[, backlog][, callback]) // 启动一个服务器,监听已经绑定到端口、UNIX域套接字或Windows命名管道的给定句柄上的连接。 // 句柄对象可以是服务器、套接字(任何具有底层_handle成员的东西),也可以是含有fd成员的对象, // 该成员是一个有效的文件描述符。 if (typeof fd === 'number' && fd >= 0) { try { handle = createHandle(fd, true); } catch (e) { // Not a fd we can listen on. This will trigger an error. debug('listen invalid fd=%d:', fd, e.message); return UV_EINVAL; } handle.open(fd); handle.readable = true; handle.writable = true; } else if (port === -1 && addressType === -1) { // 处理UNIX domain socket或Windows pipe handle = new Pipe(PipeConstants.SERVER); } else { handle = new TCP(TCPConstants.SERVER); // 创建TCP服务 isTCP = true; } return handle; } function createHandle(fd, is_server) { // 基于文件描述符确认handle的类型,TTY(文本终端) const type = TTYWrap.guessHandleType(fd); if (type === 'PIPE') { return new Pipe( is_server ? PipeConstants.SERVER : PipeConstants.SOCKET ); } if (type === 'TCP') { return new TCP( is_server ? TCPConstants.SERVER : TCPConstants.SOCKET ); } } ``` 需要注意的是 createHandle 函数中的 Pipe 和 TCP 类内部是由 C++ 实现: ```javascript const { TCP, constants: TCPConstants } = process.binding('tcp_wrap'); const { Pipe, constants: PipeConstants } = process.binding('pipe_wrap'); ``` 在 `createServerHandle` 函数内部,如果是创建 TCP 服务器,只需调用 `new TCP(TCPConstants.SERVER)` 即可。现在我们来简单总结一下示例中创建 TCP 服务器的过程: * 调用 `net.createServer()` 方法创建 server 对象,该对象创建完后,我们调用 `listen()` 方法执行监听操作。 * 在 `listen()` 方法内,将解析相关参数,然后调用 `listenInCluster()` 方法。 * 由于当该进程是主进程,所以 `listenInCluster()` 方法内会直接调用 `_listen2()` 方法。 * 因为 `_listen2` 是指向 `setupListenHandle` 函数,所以最终调用的是 `setupListenHandle` 函数。该函数的主要作用是调用 `createServerHandle` 函数创建对应的 handle 对象(本示例为 TCP 对象),并为该对象设定 `onconnection` 处理器,然后在把返回的对象赋值给 server 对象的 _handle 属性。 * 最后当服务器接收到连接请求时,就会调用 `onconnection` 处理器,随后创建 Socket 对象,并触发 `connection` 事件,然后就会执行我们设置的 connectionListener 监听函数。 #### UNIX Domain Socket 在预备知识章节,我们了解到 UNIX Domain Socket 用于 IPC (Inter-Process Communication)更有效率: > **不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程**。 接下来我们就来介绍一下,如何创建简单的 UNIX 域套接字服务器。 在 `createServerHandle` 函数中,不知道小伙伴们有没有注意到 `port === -1 && addressType === -1` 这一行,竟然有 port 和 addressType(一般为 4 或 6 即表示 IPv4 或 IPv6)为 -1 的情况。其实这就是创建 UNIX domain socket 或 Windows pipe 服务器的场景。 最后我们来创建一个 UNIX 域套接字服务器(实现 echo 功能),具体的示例如下: ```javascript const net = require("net"); const server = net.createServer(c => { c.on("end", () => { console.log("client disconnected"); }); c.write("hello\r\n"); c.pipe(c); }); server.on("error", err => { throw err; }); // server.listen(path[, backlog][, callback]) for IPC servers server.listen("/tmp/echo.sock", () => { console.log("server bound"); }); ``` 成功运行服务器后,我们就可以用前面介绍的 `nc` 命令来连接 UNIX 域套接字服务器: ```shell $ nc -U /tmp/echo.sock ``` 命令执行后,控制台首先会输出 `hello`,当我们输入任何消息后,UNIX 域套接字服务器也会返回同样的消息: ```shell ➜ ~ nc -U /tmp/echo.sock hello semlinker semlinker i love node i love node ``` 有兴趣的小伙伴可以亲自动手试一试,体验一下上面 `echo` 服务器。 ### 总结 本文通过两个简单的示例,分别介绍了如何创建简单 TCP 和用于 IPC 的 UNIX Domain Socket 服务器,同时也介绍了 Socket、Nagle 算法、nc 命令等相关的知识。其实 Node.js 的 Net 模块还有挺多知识点的,比如核心的 Socket 类,这里就不做进一步介绍了。如果想更全面和深入了解 Net 模块的小伙伴,建议阅读相关的文章或源码。 ### 参考资源 * [初步研究node中的网络通信模块](http://zhenhua-lee.github.io/node/socket.html) * [Node.js之网络通讯模块浅析](https://segmentfault.com/a/1190000008908077) * [百度百科 - socket](https://baike.baidu.com/item/socket/281150) * [UNIX Domain Socket IPC](https://akaedu.github.io/book/ch37s04.html) * [Node.js 中文文档 - net](http://nodejs.cn/api/net.html) ================================================ FILE: stream/深入学习 Node.js Stream 基础篇.md ================================================ ## 深入学习 Node.js Stream 基础篇 - [深入学习 Node.js Stream 基础篇](#深入学习-nodejs-stream-基础篇) - [什么是流](#什么是流) - [为什么要使用流](#为什么要使用流) - [流实战](#流实战) - [创建大文件](#创建大文件) - [启动服务器后内存占用](#启动服务器后内存占用) - [接收请求后内存占用](#接收请求后内存占用) - [重启服务器后内存占用](#重启服务器后内存占用) - [pipe优化后内存占用](#pipe优化后内存占用) - [流的分类](#流的分类) - [参考资源](#参考资源) ### 什么是流 流是数据的集合 —— 就像数组或字符串一样。流与它们的不同之处在于,流可能无法立马可用,并且它们不需要全部载入内存中。这种特性使得流能够处理大量数据,或者在一个时刻处理来自外部数据源的数据。 Node.js 中的许多 build-in 模块都实现了流的接口: ![readable-and-writeable-stream](readable-and-writeable-stream.png) (图片来源 ——  Advanced Node.js) 上面列表中有一个特殊的对象,这些对象是可读可写的流,比如 TCP 套接字、zlib 和 crypto 流。 ### 为什么要使用流 在 Node.js 中,I/O 都是异步的,所以在和硬盘以及网络的交互过程中会涉及到传递回调函数的过程。 你之前可能会写出这样的代码: ```javascript var http = require('http'); var fs = require('fs'); var server = http.createServer(function (req, res) { fs.readFile(__dirname + '/data.txt', function (err, data) { res.end(data); }); }); server.listen(8000); ``` 上面的这段代码并没有什么问题,但是在每次请求时,我们都会把整个 `data.txt` 文件读入到内存中,然后再把结果返回给客户端。想想看,如果 `data.txt` 文件非常大,在响应大量用户的并发请求时,程序可能会消耗大量的内存,这样很可能会造成用户连接缓慢的问题。 其次,上面的代码可能会造成很不好的用户体验,因为用户在接收到任何的内容之前首先需要等待程序将文件内容完全读入到内存中。 幸运的是,req 请求对象和 res 响应对象都是流对象,这意味着我们可以使用一种更好的方法来实现上面的需求: ```javascript var http = require('http'); var fs = require('fs'); var server = http.createServer(function (req, res) { var stream = fs.createReadStream(__dirname + '/data.txt'); stream.pipe(res); }); server.listen(8000); ``` 在这里,`.pipe()` 方法会自动帮助我们监听 `data` 和 `end` 事件。上面的这段代码不仅简洁,而且 `data.txt` 文件中每一小段数据都将源源不断的发送到客户端。 ![without-vs-with-stream](without-vs-with-stream.gif) (图片来源 —— [2016 - the year of web streams](https://jakearchibald.com/2016/streams-ftw/)) 除此之外,使用 `.pipe()` 方法还有别的好处,比如说它可以自动控制背压,以便在客户端连接缓慢的时候 Node.js 可以将尽可能少的缓存放到内存中。 使用流真的能提高程序的运行效率么?实践是检验真理的唯一标准!我们马上来实践一下。 ### 流实战 #### 创建大文件 generate-big-file.js ```javascript const fs = require('fs'); const file = fs.createWriteStream(__dirname + '/big.file'); for(let i=0; i<= 1e6; i++) { file.write('My name is semlinker, i love node!\n'); } file.end(); ``` 以上代码,我们通过调用 `fs.createWriteStream()` 方法来创建一个可写流,然后通过循环来写入一百万行的数据。成功运行以上代码,将会生成一个文件名为 `big.file` (大约35M)的大文件。 接下来我们来创建一个简单的 Node.js Web 服务器,用于响应该大文件的请求: serve-big-file.js ```javascript const fs = require("fs"); const server = require("http").createServer((req, res) => { fs.readFile("./big.file", (err, data) => { if (err) throw err; res.end(data); }); }); server.listen(8000); ``` 当服务器接收到请求时,它将使用异步的 fs.readFile() 方法把 `big.file` 大文件读入内存中,然后返回大文件中的数据。 那么,让我们来看一下当我们启动服务器后,接收到请求后 Node.js 进程内存变化情况。 #### 启动服务器后内存占用 ![normal-memory-used](normal-memory-used.png) #### 接收请求后内存占用 ![serve-big-file](serve-big-file.png) 从以上两张图中,我们发现在服务器接收到请求后,Node.js 进程的内存占用会有较大幅度的增加。那么使用 `pipe()` 能降低内存占用么?我们先来更新一下以上 Node.js Web 服务器的相关代码: ```javascript const fs = require("fs"); const server = require("http").createServer((req, res) => { var stream = fs.createReadStream(__dirname + '/big.file'); stream.pipe(res); }); server.listen(8000); ``` #### 重启服务器后内存占用 ![use-pipe-memory-used](use-pipe-memory-used.png) #### pipe优化后内存占用 ![serve-big-file-with-pipe](serve-big-file-with-pipe.png) 通过对比可以发现使用 pipe 优化过的程序,内存占用会减少大约 11 M。如果文件更大的话,比如几百兆,那么优化的效果会更明显,有兴趣的小伙伴可以亲自动手试试,最后我们来介绍一下 Node.js 中流的分类。 ### 流的分类 在 Node.js 中有四种类型的流:Readable、Writable、Duplex 和 Transform 流: - Readable 流表示数据能够被消费,例如可以通过 `fs.createReadStream()` 方法创建可读流。 - Writable 流表示数据能被写,例如可以通过 `fs.createWriteStream()` 方法创建可写流。 - Duplex 流即表示既是 Readable 流也是 Writable 流,如 TCP Socket。 - Transform stream 也是 Duplex 流,能够用来修改或转换数据。例如 `zlib.createGzip` 方法用来使用 gzip 压缩数据。你可以认为 transform 流是一个函数,它的输入是 Writable 流,输出是 Readable 流。 | 使用情景 | 类 | 需要重写的方法 | | -------------- | --------- | ------------------ | | 只读 | Readable | _read | | 只写 | Writable | _write | | 双工 | Duplex | _read, _write | | 操作被写入数据,然后读出结果 | Transform | _transform, _flush | 此外所有的流都是 `EventEmitter` 的实例,它们能够监听或触发事件,用于控制读取和写入数据。Readable 与 Writable 流支持的常见的事件和方法如下图所示: ![stream-events-and-method](stream-events-and-method.png) (图片来源 ——  Advanced Node.js) ### 参考资源 * [stream-handbook](https://github.com/substack/stream-handbook) * [node-js-streams-everything-you-need-to-know](https://medium.freecodecamp.org/node-js-streams-everything-you-need-to-know-c9141306be93)