# README

### 关于本书

这是一本关于 Node.js 技术的开放源码电子书，受到台湾 nodejs 社区的[教程](https://github.com/nodejs-tw/nodejs-wiki-book)提供启发编写。首先感谢台湾 nodejs 社区的前辈提供这么优秀的教程，不过台湾社区的教程已经停止了维护。为了让大家学习到这么优秀的教程，我决定将其复活，所以才有了这个项目。由于原书写作的时候Node版本是0.6，所以在写作本书的时候，借鉴了原书的目录结构，但是内容上进行了重写。 本书的线上阅读网址，与 GitHub 资料同步更新。

<https://nodebook.whyun.com/> <https://node.whyun.com/>

本书适合 Node.js初学者至进阶开发者，也欢迎您在学习时一起参与本书内容撰写。

### 源码

本书最新的源码网址如下：

<https://github.com/yunnysunny/nodebook>

### 授权

本书原采用创用CC姓名标示-非商业性授权。 **您不必为本书付费。**

**Node.js Wiki Book** book is licensed under the Attribution-NonCommercial 3.0 Unported license. **You should not have paid for this book.**

您可以复制、散布及修改本书内容，但请勿将本书用于商业用途。

您可以在以下网址取得授权条款全文。

<https://creativecommons.org/licenses/by-nc/3.0/legalcode>

### 作者

* yunnysunny <https://github.com/yunnysunny>

### 编译

本书采用 [vuepress](https://vuepress.vuejs.org/) 进行编译，如果你想在本地编译，可以执行如下命令：

```
npm run build
```


# 前言

Node.js 是 JavaScript 后端开发语言。从诞生之初就备受关注，到如今技术已经日臻成熟稳定。正是 Node 的兴起，还带动了前端 JS 的热度，react.js vue.js 这些前端 JS 也火借东风，烧的很旺，甚至连 “全栈” 这个词也一度登上了招聘启事的热搜关键字。本书的写作风格更偏向于底层原理和最佳实践，不会展开评述市面上的所有 Node 编程框架，力求通过一些常用的技术栈来将整个 Node 体系结构串联起来。

**声明**\
nodejs台湾协作电子书（源码参见[github地址](https://github.com/nodejs-tw/nodejs-wiki-book) ），是台湾 nodejs 社区发起的一套nodejs入门教程，本人初学nodejs之时，受其启发颇深。现在台湾社区的兄弟们对其已经停止维护，想想这么优秀的教程就这样白白流逝掉，是一件让人惋惜的事情，于是乎我利用业余时间将其复活。

但是在整理的过程中发现了若干问题，首先繁体中文和简体中文的语言使用上还是有差别，之前耳熟能详的词语到了繁体中文中就变了个叫法，搞的自己差点摸不着头脑；其次，当时书籍写作的时候 Node 的版本还是0.6，如今时过境迁 Node 连10.0都出来了，好多知识点也发生了变化；最后，我也对原书的有些章节安排不是很满意。正是以上三点原因促成了我重写此书的决定。

线上预览地址：<https://nodebook.whyun.com>

本书github地址：<https://github.com/yunnysunny/nodebook>

同时欢迎大家提交pull request来完善文档内容。

**阅读指引**

* 如果你之前有 socket 和多线程的编程经验，可以看第1章，没有上述经验的编程者也可以阅读此章，只不过在阅读过程中可能会遇到概念一时半会儿理解不了。
* 如果之前没有接触过 javascript 这门语言，可以从第2章开始阅读，第2章讲述了一些 javascript 的基础语法。
* 如果之前接触过 javascript ，但是没有接触过 Node.js ，可以从第3章开始阅读，第3章讲述了一些 Node.js 的基础 API。
* 如果之前接触过 Node.js ，但是没有用过数据库操作，可以阅读第5章，第5章讲述了 Node 中操作 redis 、mongodb 的 API 如何使用；如果没有使用 express ，可以阅读第6章和第7章，这两章讲述了如何利用 express 这个 HTTP 编程框架。
* 最后介绍一下一些独立章节，第4章讲述了 npm 命令的使用教程；第8章讲述了如何使用单元测试框架mocha；第9章讲述了一些线上环境的最佳实践，包括配置文件、进程管理、 docker 等内容；第10章讲述如何编写 c++ 扩展。


# 1 Node.js 体系结构

其实我就是想写一下 Node 的底层架构，但是说道底层这个东西，我就想起来了我上学时候的一门课《计算机体系机构》，就是把计算机各个部件的运行原理给串起来来讲，所以我就把这章的名字定为 Node.js 体系结构，但愿讲得够底层。

## 1.1 网络 IO 模型变迁

Node.js 是一门服务器语言，为了体现 Node 的优越性，我们这里不得不扯一下服务器开发的一些历史。

我们最常见的服务器程序一般是基于 HTTP 和 TCP 协议来提供服务的，HTTP 底层又基于 TCP ，所以我们直接来描述 TCP 协议在服务器端实现的逻辑。在操作系统中TCP的通信过程又被称之为网络IO操作，下面描述的就是网络IO操作的简史。

TCP 服务在建立完 socket 监听后，会调用 accept() 函数来监听客户端的连接请求，但是这个过程是堵塞的。也就是说如果函数没有返回，当前线程会一直等待，而且在这个等待的过程中无法做任何事情。伪代码如下：

```
while(true) {
    socket = accept();
}
```

**代码 1.1.1 socket建立连接伪代码**

代码 1.1中我们通过accept函数，服务器和客户端之间建立了一个socket连接，建立完连接之后，就可以开始发送接收数据的操作。但是当程序运行到accept函数的时候，是堵塞的，也就是说这个函数不运行完成，代码是没法继续运行的。 假设我们现在 accept 函数返回了，那么我们就可以读取这个连接发送过来的请求数据了：

```
while(true) {
    socket = accept();
    while(true) {
        data = read(socket);
        //process_data(data);//处理数据
    }
}
```

**代码 1.1.2 读取socket数据伪代码**

不过和 accept 一样，这个 read 函数依然是堵塞的。照这个趋势下去，一个服务器只能给一个连接做服务了，其他的连接就干等着。这可不是我们想要的结果。

我们的前辈们想到的解决方案是fork子[进程](https://zh.wikipedia.org/wiki/%E8%A1%8C%E7%A8%8B)，每次跟客户端建立一个连接，都创建一个新的子进程来维护当前连接，在这个新的子进程中进行发送和接收数据。这种子进程的模型的典型代表就是 Apache 1.x。我们来看一下伪代码：

```
while(true) {//主进程代码
    socket = accept();
    var child = fork(socket);
}

while(true) {//子进程代码
    data = read(socket);
}
```

**代码 1.1.3 子进程读取socket数据伪代码**

看上去是一个好的解决方案，各个socket连接在读取数据的时候都是在单独的一个进程中完成的，不会互相堵塞。不过进程的创建是一个耗时的操作，而且操作系统对于启动的最大进程数也是有限制的，如果服务器创建大量线程，有可能导致系统其他进程无法启动(所以一般服务器都会限制启动子进程的最大数目，这个时候在程序里面会维护一个socket队列，来决定那些连接被丢入子进程进行处理。)。这个时候[线程](https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B)便进入了大家的视野，它作为cpu的最小调度单位，具有比进程更少的资源占用，最好的性能。线程有进程创建，对于一个进程来说，它所创建的线程共享其内存数据，且可以被统一管理。由于使用线程的逻辑和使用进程的逻辑类似，所以这里不给出伪代码。 Apache 从2.x开始增加了对多线程的支持。

即使使用了线程，但是计算机的CPU每次可以处理的线程数是有限的（单核CPU每次处理一个线程，双核可以同时处理两个，Intel使用超线程技术，可以使一个核心处理两个线程，所以说我们常用的i5处理器，虽然是两核但是却可以同时处理四线程），为了让各个线程公平对待，CPU在单位时间内会切换正在处理的线程。但是这个切换动作是比较耗时的，CPU在将处理的线程任务切换走之前要暂存线程的内存，在切换入一个新的要处理的进程之前要读取之前暂存的线程内存，当然还要考虑到CPU内部还要有一套调度算法，来决定什么时候将线程切换到CPU进行处理。所以说使用线程也会遇到性能瓶颈，不会像我们想的那样，线程数起的越多，性能越好。

不过在操作系统中有非堵塞IO（nonblocking IO）的概念，既然它叫这个名，那么我们前面讲的就应该叫堵塞IO（blocking io）了。我们还是通过类比来解释在读取socket数据时两者的区别，同时看看这个传说中的非堵塞IO能否解决我们的问题。

我们把socket通讯过程类比为你在淘宝上买东西的过程，你在淘宝上下单买了件商品（socket连接建立了）。对于堵塞 IO 来说，你需要给快递员打电话，并且你还不能挂断，在快递员没有通知你商品到之前，你啥事也不能干。

![堵塞IO的类比图](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCdeUjBpjieyedW%2Fblock_io.png?generation=1582638398252953\&alt=media)

**图 1.1 堵塞 IO 类比时序图**

我们用一幅图来描述上述过程的话，那他应该如 **图 1.1** 所示，其实在真实场景中，用户就是你的应用程序，而快递员就是你的 Linux 内核。

对于非堵塞 IO 来说，为了了解到包裹是否送达了，你只需要定时给快递员打电话咨询。不过你在打完电话之后还可以忙别的，比如说看会儿书，喝喝茶。然后你想起来，我靠还有一个快递呢，于是赶紧再打一个电话，结果发现人家快递员已经在楼下等了半天。所以说，要想尽早得到快递，你得一直跟快递员打电话（俗称呼死他）。

![非堵塞 IO 类比图](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCf_1eWhdG25ZBH%2Fnone_block_io.png?generation=1582638398288353\&alt=media)

**图 1.2 非堵塞 IO 类比时序图**

> 有人问，为什么不是快递员给你打电话，而是你给快递员打电话，首先声明一下，为了简化描述的工作量，我们现在先按照linux操作系统来讲，在linux系统中只能用户去调用内核函数，没有内核函数主动通知用户程序的功能。我们这里内核函数就是快递员，用户程序就是你自己，所以只能你自己打电话给快递员。同时大家需要注意，对于非堵塞 IO 这个定义，有不同的叫法，有的管我们刚才提到的这种方式叫非堵塞 IO ，但是有的管 IO 多路复用 （下面马上讲）叫非堵塞 IO。

OK，下面要轮到我们的 IO 多路复用闪亮登场了。 一般你在淘宝上买东西，填写邮寄方式的时候，都是直接写你自己的地址，不过淘宝其实提供了菜鸟驿站这个东西，你可以在不方便的情况下把，把包裹的邮寄地址写成菜鸟驿站。这个样子你的所有快递就都可以由菜鸟驿站来代收了，不过你仍然要打电话询问驿站的工作人员，快递来了吗（因为我们用的是linux，在这个操作系统下，内核是不会主动通知用户程序的，这个步骤在linux中称为事件查询）。

![IO 多路复用类比时序图](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-7e4a731cfe797b4414f7d2f3fc5f7f1cc1962757%2Fmulti_io.png?alt=media)

**图 1.3 IO 多路复用类比时序图**

回到上面的栗子，你可能会问如果单纯一件商品的话，自己直接等快递显然比先送到菜鸟驿站再打电话问要快。是的，没错！但是不要忘了，为了收多件快递，每订购一件商品，都要克隆出另外一个你（fork子进程或者创建线程），来等着收快递，一旦你淘宝上下单量很大，管理这些克隆人的成本就会陡增（主要耗费在进程或线程的上下文切换和调度）。所以说在连接数不大的情况下使用堵塞IO反而效率更高。

这个IO 多路复用在linux上几经更新，发展到现在，使用的最新技术就是 epoll ，nginx 底层就是利用了这个技术。其实通过前面的栗子，我们发现 IO 多路复用中依然有堵塞过程（不断打电话给菜鸟驿站的过程），不过实际上 epoll 等 API 提供的事件查询函数默认调用完成后立即返回（即使没有任何事件，也立即返回），基本可以忽略堵塞时间（当然也支持在事件查询的时候设置超时时间，在超时时间内没有事件产生就一直堵塞）。

## 1.2 libuv

Node 的开发者 Ryan dahl，起初想构建一个可以处理大量HTTP连接的web服务，他知道使用 C 语言可以实现这个目标，前面章节讲到 IO 多路复用在大量连接数的时候，性能要优于堵塞 IO。但那时 C 语言开发效率太低了，特别是当你做web开发的时候，当时恰逢08年，谷歌刚推出V8引擎，我们的Ryan dahl 经过各种选型和权衡后，最终选择用C、C++做了一个 IO 处理层，结合V8引擎，组成了 Node。这个 IO 处理层，最终被整合到 [libuv](https://github.com/libuv/libuv) 这个库中。

> 我们前面的内容是基于 linux 描述的，但是类似于 epoll 的操作，在不同的操作系统实现库函数是不同的，在 windows 上有 IOCP，MAC上有 kqueue, SunOS 上有 event ports，这个时候有一个抽象层对外提供统一的 api 是一个好的选择，libuv 就解决了这个问题，但是这不是他所有的功能。

承接上节网络 IO 内容，以下的内容全都基于 Linux 系统编写。Linux 的 epoll 库将准备好的 io 事件首先暂存在内核区域，然后可以在用户代码中通过轮询的方式将准备好的 io 事件拉取到用户进程中。

![epoll\_events.png](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-a1658356236ba41461f347711edca5c5f0865724%2Fepoll_events.png?alt=media)

**图 .1 epoll事件轮询**

Node 的 底层库 libuv 也借助上图的模型来实现网络 IO 的事件轮询。这里只提到了网络 IO，其实是想指出来，文件 IO 是没法直接使用 epoll 来处理的。由于 Node 在语言层面采用了 JavaScript 的 V8 引擎，其单线程的机制决定我们不管是处理计算密集型或者 IO 密集型操作的时候，都不能将当前线程卡住，但是刚才提到了文件 IO 操作又不能使用 epoll 来处理，那么 libuv 是如何解决的呢？

答案是使用线程池。libuv 内部内置了一个线程池，将堵塞住 CPU 行为的任务，包括文件 IO 和一些计算型操作，全都丢到线程池中处理，每项任务处理完成之后再回调主线程。libuv 在处理这些回调的时候还做了一个取巧，虽然 epoll 不能应用在文件 IO 上，但是却可以应用在管道 IO 上。libuv 内部会在异步任务初始化的时候创建管道句柄，并设置当前任务完成之后的回调函数等数据。当此项任务在线程池中处理完成时，就会往之前创建的管道句柄中写入数据，这会触发内核层生成 epoll 的就绪事件，此事件将会在 epoll 轮询的时候被发现，这时候 libuv 会遍历之前创建的所有异步任务，找到已经完成的任务，触发其回调函数。

> 这种一次性唤醒多个任务的做法，可能会对程序带来负面影响，如果当前任务队列长度过长的话，将会耗费大量的 CPU 时间对其进行遍历。libuv 官方也考虑到了此问题，对长队列做了压测，性能还是可以得到保障。考虑到 Node 中使用线程池的情况比较少，包括文件IO、加解密、解压缩等操作，不过用户自己编写的 C++ 扩展也可以调用线程池操作，所以第三方库代码的质量也会间接影响线程池的回调效率。

## 1.3 参考链接

* [Linux IO 概览](https://blog.lucode.net/linux/linux-io-overview.html)
* [Libuv 源码阅读](http://masutangu.com/2016/10/13/libuv-source-code/)
* [The Node.js Event Loop, Timers, and `process.nextTick()`](https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/)


# 2 JavaScript 那些事

### 2 JavaScript那些事

二十世纪 90 年代初，Java 开始投入市场，并取得巨大成功。作为 Java 的维护者的 Sun 公司，也趁热打铁，开发出了 Applet，其实这是一个合成词，可以拆分成 Application 和 little 两个单词，意译的话就是“小程序”。

同时网景（Netscape）公司想开发一门让网页制作者就能学会的脚本语言，于是他们就委派了 Brendan Eich（布兰登·艾克）这位大神。由于时间紧迫，他用了10天便开发出了一个最初版本，开始的时候这个项目的代号还叫 Mocha（好吧，听起来很熟悉吧），并在第一版发布的时候给这门语言起名 LiveScript。不过，很快网景公司和 Sun 公司开展合作，两者达成协议，将这门语言改名为 Javascript。

从此 Sun 公司的 Applet 可以运行在浏览器上，只需要一个浏览器你的应用就可以随处运行了，而 Javascript 也借着 Java 的名声炒作了一把（虽然其语言本身跟 Java 没有多大关系，不过他成功迷惑了好多语言入门者，就好像我小时候搞不清楚雷锋和雷峰塔一样）。不过后来的结果大家大概都猜到了，小程序活不久，而 js 却被大量使用，直到今天依然无可替代。

#### 2.1 数据类型

JavaScript 由于当初设计之初时间紧迫，所以有好多历史遗留问题，所以对于其有些语法知识点，大家可以完全以吐槽的心态来学习，完全不用去深究。

**2.1.1 数字**

一般编程语言都有像整数、浮点数之类的数据类型，但 js 将整数按照浮点数来存储，所以 `1 === 1.0`。不过 js 本身提供了一些浮点数转成整数（保证在显示的时候不带小数点）的函数，比如说 [Math.ceil](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/ceil)（向上取整）、[Math.floor](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math/floor)（向下取整）、[parseInt](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/parseInt)（将字符串转化成整数，如果参数不是字符串，会先转化为字符串）。

**2.1.2 布尔**

布尔，也就我们常说的 `true` `false`，本来布尔就是用来做逻辑判断的，但是我们在做逻辑判断的时候也可以这个样子

```javascript
if (undefined) {
    console.log('undefined is true');
} 
if (null) {
    console.log('null is true');
} 
if ('') {
    console.log('\'\' is true');
} 
if (0) {
    console.log('0 is true');
}
if ('0') {
    console.log('\'0\' is true');
}
if (NaN) {
    console.log('NaN is true');
}
```

**代码 2.1.2.1 逻辑判断语句**

你会发现 只有 `'0' is true` 被打印出来，其他的在做逻辑判断的时候都为假。本来这一节是讲布尔型数据类型的，但是我发现实在没啥可讲的，所以我就讲些逻辑判断相关的知识。其中 `undefined` 在 js 里面表示变量未定义；`null` 代表当前变量是一个对象，但是没有初始化；`''` 代表当前是一个字符串，但是字符串中没有任何字符；`'0'` 表示当前是一个字符串，且字符串就只有一个0字符；NaN 代表当前变量不是数字，这个数据类型在调用 parseInt 的时候会返回，比如说 `parseInt('a')` 就返回 `NaN`。

js 判断相等有两种方式 `==` 和 `===`，两者的区别是前者在做判断前，会将等号两边的数据类型转化成一致的；而后者在做判断的时候，如果检测到等号两边数据类型不一致，直接返回false。例如 `0 == '0'` 为 true，而 `0==='0'` 为 false。最好需要注意，判断一个变量是否是NaN，不要使用等号来判断，而需要使用函数 [isNaN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/isNaN)。

最后讲述一个小技巧，使用下面方式判断一个变量是否是“不正常”的：

```
if (!x) {
    console.log('x is dirty');
}
```

**2.1.3 字符串**

js 中字符串可以使用''或者""来包裹，'a' === "a"，有一些语言（Java或者C）中用 'a' 来表示 `字符` 类型，在 js 中是没有 `字符` 类型的。

js 中提供了N多关于字符串的函数，比如说字符串截取函数 [substring](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String/substring)、替换函数 [replace](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String/replace)、查找函数 [indexOf](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf)。

**2.1.4 数组**

[数组](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array) 是 js 中一项重要的数据结构，我们可以通过如下方式声明一个数组：

var array = \['a','bb','cc'];

js 提供了一系列函数来对数组进行增删改查。

首先是查，你可以通过下标来访问数组元素，下标从0开始， `array[0]` 返回 `'a'` 。同时数组还有一个 `length` 属性，通过这个属性我们可以写一个遍历这个数组的 for 循环：

```javascript
for (var i=0,len=array.length;i<len;i++) {
    console.log(array[i]);
}
```

**代码 2.1.4.1 for循环遍历数组**

我们还可以通过 [indexOf](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) 函数来查找某一个元素是否在当前数组中。

然后是改， 直接举个栗子，设置 0 号元素为 `'11'`，则使用 `array[0] = '11'`，即可。

接着是增加，通过 [shift](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/shift) 函数删除数组最开头的元素， `var first = array.shift()` 调用完之后，变量 `array` 的值为 `['bb','cc']`，同时 `first` 被赋值 `'a'`。

通过 [unshift](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/unshift) 函数可以往数组头部添加元素，例如 `array.unshift('123')`，那么 array 变量的值就变成了 `['123','a','bb','cc']`。

通过 [pop](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/pop) 可以删除数组最末尾的一个元素，例如 `var last = array.pop()` 调用完之后， array 就变成了 `['a','bb']` 同时变量 `last` 被赋值 `'cc'`。

通过 [push](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/push) 函数可以在数组末尾添加元素，例如 `array.push('333')`，则 array 变成 `['a','bb','cc','333']`。

**2.1.5 函数**

作为一门编程语言，我们免不了在使用的时候要把某些功能封装成一个模块，函数作为模块的载体在任何程序语言中都是必不可少的， JavaScript 也不例外。在 js 中定义一个函数很简单：

```javascript
function doAdd(a,b) {
    return a + b;
}
```

**代码 2.1.5.1 函数定义**\
虽然函数体只有一行，但是这个函数却将函数三要素都澄清了：函数名 `doAdd`，参数 `a` 和 `b`，返回值 `a + b`。当然三要素并不是必不可缺的：

```javascript
var doEcho = function() {
    console.log('你好');
}
```

**代码 2.1.5.2 匿名函数**

这里其实是定义了一个匿名函数，只不过我们在定义完之后将它赋值给了变量 `doEcho`，同时这个函数在运行的时候可以不用传任何参数，同时函数内部没有任何 `return` 语句，其实这种情况跟 `return undefined` 是等价的。匿名函数一般作用是作为函数参数使用，例如下面这个栗子：

```javascript
function addLater(a,b,callback) {
    setTimeout(function() {
        var sum = a+b;
        callback(sum);
    },1000);
}

addLater(1,2,function(result) {
    console.log('the result:',result);
});
```

**代码 2.1.5.3 匿名函数使用示例**

上面这个栗子中，addLater函数在调用的时候，第三个参数在使用的时候是一个函数，而且它是匿名的。

#### 2.2 对象

其实对象也是一种数据类型，只不过由于它太特殊，所以这里单独拿出来讲。在 ES6 之前 javascript 还是一门**基于对象**的编程语言，为啥叫基于呢，因为 ES5 和之前版本的 javascript 中原生语法中没有类（class）这个关键词，你只能拿原型（prototype）来模拟一个类的行为。

> ES ( [ECMAScript](https://zh.wikipedia.org/wiki/ECMAScript) )，可以理解为 javascript 的语法标准， 2015年6月发布的 ES6 （又称 ES2015） 版本增加了N多语言特性，其中就包括类和继承的实现。由于 ES6 规避了之前版本中 javascript 中的一些糟粕设计，并且提升了开发效率，所以产生了学习 ES6 的大量前端开发人员，但是现行浏览器对于 ES6 语法的支持能力参差不齐，所以 [babeljs](https://babeljs.io) 应运而生，它提供了 ES6 转 ES5的功能，一时间产生了大量的拥趸。同时国内大神阮一峰也写了一本 [ECMAScript 6 入门](https://es6.ruanyifeng.com/) 开源图书，我想使用 ES6 语法的程序员，没有一位没有浏览过这本书的。另外 node 从4.x开始逐渐引入 ES6 语法，具体各个版本的实现情况可以参见[Node.js ES2015 Support](https://node.green/)。

我们这里先讲一下 ES5 中怎样模拟一个类，答案是使用原型：

```javascript
function PersonES5(p) {
    this.age = p.age;
    this.name = p.name;
    this.sex = p.sex;
}

PersonES5.prototype.showInfo = function() {
    console.log(this);
};

var person = new PersonES5({
    age:18,
    name:'tom',
    sex:'boy'
});

person.showInfo();
```

**代码 2.2.1 person\_es5.js**

而在 ES6 中由于直接有类的概念，所以代码语法上还是有差别的：

```javascript
class PersonES6 {
    constructor(p) {
        this.age = p.age;
        this.name = p.name;
        this.sex = p.sex;
    }
    showInfo() {
        console.log(this);
    }
}


var person = new PersonES6({
    age:18,
    name:'tom',
    sex:'boy'
});

person.showInfo();
```

**代码 2.2.2 person\_es6.js**

由于 javascript 长期函数式编程思想盛行，因为我们一般不会在一个网页中呈现过多的 UI 组件，所以它的代码处理流程一般都是线性的。比如说我们在前端使用 javascript 的流程是这样的：加载网页->请求数据->渲染 UI 组件->触发事件监听，后端的流程是这样的：接收请求->数据库操作->返回处理结果。当然你会说，不对，我们处理的流程可比这复杂多了，当然随着单页应用（SPA,Single Page Application）的兴起，前端 js 的处理逻辑会越来越复杂。比如说有一天，你的经理可能会给你分配一个在线 photoshop 的需求，这时候面向对象就派上用场了，你可能需要一个抽象类来描述组件的基本属性和功能，同时派生出若干继承自这个抽象类的具体组件类，比如说矩形类、三角形类、圆形类。我想面对这么复杂需求的时候，开发者肯定会选择 ES6 来实现，更不用说如今流行 mvvm 框架都是采用 ES6 来开发。

ES6 的类语法和其他很多语言是相似的，和原型链操作比起来可读性也更好，所以现在原型链操作已经很少被使用了。

### 2.3 闭包

很多编程语言都有闭包的特性，有的是天生就有闭包特性（Python Ruby Go 等），有的是随着语言自身的发展吸纳了闭包这个特性（PHP Java 等），JavaScript 属于前者。

JavaScript 中对于函数的返回值，其类型可以是前面提到的基本数据类型、对象，也可以是一个函数。

```javascript
function add2Total() {
	var total = 0;
	return function(num) {
		total += num;
		return total;
	}
}
```

**代码 2.3.1**

下面通过一个例子来讲述闭包的原理，首先看代码：

```javascript
var addFun = add2Total();
console.log(addFun(3));
console.log(addFun(1));
var addFun2 = add2Total()
console.log(addFun(3));
```

**代码 2.3.2**

上述代码中一共有三行打印，打印的结果将会是：

```
3
4
3
```

每次调用 `add2Total` 时将会创建一个闭包，`total` 变量位于闭包内部，它的内存随着闭包创建而被生成， 而我们调用 `add2Total` 得到的 `addFun` 变量就是这个闭包中的内部函数，它保持了对 total 变量的引用。在 `add2Total` 执行结束后，`total` 变量也并不会被销毁，而是被 `addFun` “闭住”了。

所以我们才会看到上述输出结果，对其调用步骤进行输出的话，会是如下流程：

1. 第一次执行 `add2Total()` 时，创建了一个新的作用域，其中定义了变量 `total = 0`。
2. 返回的函数 `function(num)` 是一个闭包，它可以访问 `add2Total` 中的变量 `total`。
3. 当我们执行 `addFun(3)` 时：
   * 访问的是第一个闭包内的 `total`（初始为 0），
   * 计算 `total += 3`，结果为 `3`，返回 `3`。
4. 接下来执行 `addFun(1)`：
   * 仍然在原来的闭包作用域中，`total` 已经是 `3`，
   * 执行 `total += 1`，结果为 `4`，返回 `4`。
5. 然后我们再次执行 `add2Total()`，创建了一个**全新的闭包**，也就是说，这时的 `total` 又从 `0` 开始。
6. `addFun2(3)` 执行时，对应的是新的作用域中的 `total`，所以返回值是 `3`

#### 2.4 异步回调

由于在 javascript 中存在大量的异步操作，函数调用完成之后，不能立马拿到执行结果，必须在回调函数中得到执行结果，如果你在一个函数中要接连做好几次这样的异步处理，是不是画面应该是这样的：

![代码深层次嵌套](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waM4cb9UC0xnHfpvd%2Fcallback_nested.png?generation=1582638397538743\&alt=media)

**图 2.4.1 代码深层次嵌套的即视感**

#### 2.4.1 旧时代的异步

正是由于考虑到这种问题，所以 ES6 在设计的时候增加 Promise 类，不过这东西在批量处理异步回调时候依然让人不爽，大家可以参考 [A quick guide to JavaScript Promises](https://www.twilio.com/blog/2016/10/guide-to-javascript-promises.html)。在 ES5 语法时代，一版使用第三方库来进行异步回调管理，使用最频繁的就是回调流程控制库 [async](https://caolan.github.io/async/docs.html) (这的 `async` 是库的名字)。

我们在处理异步任务的时候，大体上分为两种情况，一种是串行操作，即处理完一个任务之后才能接着处理下一个任务；一种是并行操作，即各个任务都是独立运行的，大家一起运行，没有前后依赖关系。

对于串行运行在 async 中，可以是这样的：

```javascript
var async = require('async');

async.waterfall([
    function(callback) {
        setTimeout(function() {
            callback(null, 2+3);
        },100);
    },
    function(sum,callback) {
        setTimeout(function() {
            callback(null, sum-1);
        },100);
    },
    function(left,callback) {
        setTimeout(function() {
            callback(null, left * 2);
        },100);
    }
],function(err,result) {
    console.log(err,result);
});
```

**代码 2.4.1.1 async waterfall方法示例**

[waterfall](https://caolan.github.io/async/docs.html#waterfall) 函数接受两个参数，第一个参数是 Array 类型，用来指明各个需要异步执行的任务，数组的第一个元素为：

```javascript
function(callback) {
  setTimeout(function() {
    callback(null, 2+3);
  },100);
}
```

注意`callback(null, 2+3);`这一句，调用完这一句，它就参数 2+3 这个值传递到下一个任务中去了，然后数组的第二个元素：

```javascript
function(sum,callback) {
  setTimeout(function() {
    callback(null, sum-1);
  },100);
}
```

其中里面的`sum`正是刚才我们在第一个函数中传递过来的 `3+2`，同理可得我们最终将 `4`作为参数 `left` 传递到了第三方函数中。 `waterfall` 的第二个参数是一个回调函数：

```javascript
function(err,result) {
    console.log(err, result);
}
```

其第一个参数 `err` 代表错误信息，假设我们在处理任何一个异步任务的回调时写了一个 `callback(errorInfo);`，整个 `waterfall` 函数会提前结束，并且将这个 `errorInfo` 传递到第一个参数 `err` 上；第二个参数 `result` 代表最终处理得到的结果，具体到上面那个栗子，最终的结构就应该是 `4*2` 得 `8`。

接着将并行处理，也就是 [parallel](https://caolan.github.io/async/docs.html#parallel) ，我们再举个栗子:

```javascript
var async = require('async');

async.parallel([
    function(callback) {
        setTimeout(function() {
            callback(null, 1);
        }, 200);
    },
    function(callback) {
        setTimeout(function() {
            callback(null, 2);
        }, 100);
    }
],
function(err, results) {
  	console.log(err,results);
    //最终打印结果：null [1,2] 
});
```

**代码 2.4.1.2 async parallel 函数示例**

和 `waterfall` 类似，只要其中有一个任务在 callback 的时候传递了一个 error 对象，就会导致整个 parallel 函数立马结束。

> 最后大家可能留意到我们的第一行使用了 `require('async')` ，这个require 函数用来加载第三方包，我们需要在代码文件所在目录运行 `npm install async --save` 来安装这个第三方包。更多关于 npm 的知识可以参见本书第4章。
>
> 本章部分代码：<https://github.com/yunnysunny/nodebook-sample/tree/master/chapter2>

#### 2.4.2 新时代的异步

2017 年 6 月 **ECMAScript 2017（ES8）** 规范正式发布，正式将 async/await 语法引入了 JavaScript，Node 在 7.6 版本中也正式对其进行支持。由于它简洁的语法，更高的可读性，async/await 迅速成为 JavaScript 异步编程的主流方案，至今仍是处理异步操作（如网络请求、文件 I/O 等）的首选语法。

对于 **代码 2.4.1.1** 可以简化为下述代码：

```javascript
const {setTimeout} = require('timers/promises');

async function main() {
    let num = 0;
    await setTimeout(100);
    num = 2+3;
    await setTimeout(100);
    num -= 1;
    await setTimeout(100);
    num = num * 2;
    console.log(num);
}
main()
```

**代码 2.4.2.1 async/await 串行操作示例**

上述代码中，我们使用 `await` 关键字来等待异步操作完成，`await` 关键字会"暂停"当前函数的执行，直到异步操作完成。 这里的暂停并不是说当前函数被暂停了，而是一个语法糖，它背后会等待 await 后面的 Promise 对象 resolve 之后再继续执行后面的代码。

对于**代码 2.4.1.2**， 可以使用下面代码进行简化：

```javascript
const { setTimeout } = require('timers/promises');

async function doTask(num) {
    await setTimeout(100);
    return num;
}
async function main() {
    const result = await Promise.all([doTask(1), doTask(2)]);
    console.log(result);
}
main();
```

**代码 2.4.2.2 async/await 并行操作示例**

#### 2.5 参考文献

* <https://www.twilio.com/blog/2016/10/guide-to-javascript-promises.html>


# 3 Node 基础

### 3 Node 基础

#### 3.1 安装

打开 [Node官网](https://nodejs.org)，映入眼帘的就是它的下载地址了，windows下提供的是安装程序（下载完之后直接双击安装），linux下提供的是源码包（需要编译安装），详细安装流程这里省略掉，我想这个不会难倒各位好汉。

#### 3.2 旋风开始

在讲 Node 语法之前先直接引入一段 Node 的小例子，我们就从这个例子着手。首先我们在随意目录下创建两个文件 `a.js` `b.js`。

```javascript
exports.doAdd = function(x,y) {
    return x+y;
};
```

**代码 3.2.1 a.js**

```javascript
const a = require('./a');

console.log(a.doAdd(1,2));
```

**代码 3.2.2 b.js**

和普通前端 javascript 不同的是，这里有两个关键字 `exports` 和 `require`。这就牵扯到模块化的概念了，javascript 这门语言设计的初衷是开发一门脚本语言，让美工等从业人员也能快速掌握并做出各种网页特效来，加之当初语言创作者开发这门语言的周期非常之短，所以在 javascript 漫长的发展过程中一直是没有模块这个语言特性的（直到最近 ES6 Module 的出现才打破了这个格局）。

> Node 是最近几年才发展起来的 js 运行时，前端 js 发展的历史要远远长于它，2000 年以后随着 [Ajax](https://zh.wikipedia.org/wiki/AJAX) 技术越来越流行，js的代码开始和后端代码进行交互，逻辑越来越复杂，也越来越需要以工程化的角度去组织它的代码。模块化就是其中一项亟待解决的问题，期间出现了很多模块化的规范，[CommonJS](https://en.wikipedia.org/wiki/CommonJS) 就是其中的一个解决方案。由于其采用同步的模式加载模块，逐渐被前端所抛弃，但是却特别适合服务器端的架构，服务器端只需要在启动前的时候把所有模块加载到内存，启动完成后所有模块就都可以被调用了。
>
> CommonJs 算是一种规范，但是不是 JavaScript 语言固有的语法，后续 JavaScript 语法出现 ES6 Module 的时候算是真正在语法层面有了模块化的实现。但是 CommonJs 已经占据了先机，所以一般在开源的第三方代码中都是用 CommonJs 规范来进行模块化管理，我们本书也是全部采用 CommonJs 的风格进行代码展示。

我们在命令行中进入刚才我们新创建的那个文件夹下，然后运行 `node b.js`，会输出 `3` ，这就意味着你的第一个node程序编写成功了。

在a.js中 exports 对象会被 `导出`，在 b.js 中通过require 就能得到这个被导出的对象，所以我们能访问这个被导出对象的 doAdd 函数。假设我们在 a.js 中还有一个局部变量：

```javascript
var tag = 'in a.js';
exports.doAdd = function(x,y) {
    console.log(tag,x,y);
    return x+y;
};
```

**代码 3.2.3 a.js**

这里定义的 `tag` 变量是没法在 `b.js` 中读取的，其作用区域仅仅被局限在 `a.js` 中。如果在 b.js 中打印 `console.log(a.tag)` 会输出 `undefined`。

需要留意的是上面所有代码中 `exports.xxx` 其实是缩写，最终会被 Node 解析为 `module.exports.xxx`，所以我们也通过 `module.exports = ABC` 这种模式来导出整个对象，例如下面这种模式：

```javascript
module.exports = {
    fieldx: a,
    setFieldX: function(nv) {
        this.fieldx = nv;
    }
};
```

**代码 3.2.4**

### 3.3 做一个Apache

现在我们做个更让人兴奋的例子，做一个 Apache，当然这里的 Apache 不是武装直升机，而是一个服务器，熟悉php的人对他肯定不会陌生。你在本地安装它之后，然后在其默认的网站目录中放一张图片，我们假设它为a.jpg，然后你就可以通过 <http://localhost/a.jpg> 来访问它了。下面的内容就是要模拟这个过程。

要做这个处理，我们首先要搞懂 node 中的 http 包。我们抄一段 node 官网给出的快速搭建 http 服务器的代码吧：

```javascript
const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});
```

**代码 3.3.1 example.js**

直接运行 `node example.js`，然后我们打开 chrome，输入网址 [http://localhost:3000，就会在网页上看到](https://nodebook.whyun.com/http:/localhost:3000，就会在网页上看到) `Hello world`。OK，我们回头看一下代码，关键部分在于 `createServer` 的回调函数上，这里有两个参数 `req` 和 `res`，这两个变量也是 [stream](https://nodejs.org/docs/latest/api/stream.html) 类型，前者是**readable stream(可读流)**，后者是**writable stream(可写流)**，从字面意思上推测出前者是用来读取数据的，而后者是用来写入数据的。接下来就是一个见证奇迹的时刻， stream 类上有一个成员函数叫做 `pipe`，就像它的名字 **管道** 一样，它可以将两个流通过管子连接起来：

![pipe原理示意图](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-163119d93cf5ddceb5f4e363845d30c359c7d36f%2Fstream_pipe.png?alt=media)

**图 3.3.1 pipe原理**

有了pipe这个功能，我们就能将 fs.createReadStream 函数得到的可读流转接到res这个可写流上去了。说干就干，我们简单修改一下**代码 3.3.1**，就可以让其成为一个 Apache：

```javascript
const http = require('http');
const fs = require('fs');
const path = require('path');

const hostname = '127.0.0.1';
const port = 3000;
const imageDir = __dirname + '/images';


const server = http.createServer((req, res) => {
    const url = req.url;
    const _path = path.join(imageDir , url);
    fs.exists(_path,function(exists) {
        if (exists) {
            res.statusCode = 200;
            res.setHeader('Content-Type', `image/${path.extname(url).replace('.','')}`);
            fs.createReadStream(_path).pipe(res);
        } else {
            res.statusCode = 404;
            res.end('Not Found');
        }
    });
    
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});
```

**代码 3.3.2 app.js**

我们仅仅使用了一句`fs.createReadStream(_path).pipe(res);`，就便捷地将文件流输出到HTTP的响应流中了，是不是很强大。OK来看一下效果，运行 `node app.js`，在浏览器中打开 `http://localhost:3000/a.png` 就能看到显示效果。

![最终我们的apache显示效果](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMOyL9BYR5GQ_S5c%2Fsuccess.png?generation=1582638399992462\&alt=media)\
**图 3.3.2 最终我们的apache显示效果**

### 3.4 流进阶

node 的 stream API 是 node 的核心，HTTP 和 TCP 的各种 API ，都是基于 stream 之上的。但是 stream API 本身又过于复杂，让人难以理解。虽然官方文档洋洋洒洒写了一长串的说明，但是好多实现的细节是没有在文档中透露出来的，究其原因还是内部逻辑太繁琐，导致很难用几段话讲清楚。

#### 3.4.1 原理分析

首先 stream 的设计初衷是为了“节流”，说的直白些就是内存中待处理的数据量过大，如果处理的速度过慢，就会导致内存中挤压的数据越来越多，最终导致进程不稳定或者内存溢出进而崩溃，而 stream 的存在，就是构建一个缓冲地带。stream 的类（从功能上分为两种 [`Writable`](https://nodejs.org/docs/latest/api/stream.html#class-streamwritable) 和 [`Readable`](https://nodejs.org/docs/latest/api/stream.html#class-streamreadable) ）在初始化的时候会指定一个 `highWaterMark` 参数，借助此来约定内部使用缓冲区的长度，超过这个参数，就不应该往缓冲区添加数据了。下面对于 `Readable` 和 `Writable` 中的 `highWaterMark` 的使用流程分别进行说明。

`Readable` 通过 [push](https://nodejs.org/docs/latest/api/stream.html#readablepushchunk-encoding) 函数添加数据，数据在其内部存储为一个双向链接的数据结构（具体参见 [BufferList](https://github.com/nodejs/node/blob/master/lib/internal/streams/buffer_list.js) 源码，令人遗憾的是这么方便的数据结构在 Node API 中却没有暴露），如果当前链表中的数据长度达到 `highWaterMark`，`push` 函数就会返回 `false`，不过你依然可以调用 `push` 写入数据。也就是说内部链表的数据长度会大于 `highWaterMark`，Node 内部对于可读流的内存控制完全交给了调用者本身，这个 `highWaterMark` 就是一个警示作用，告诉你现在缓冲区已经满了，你自己看着办吧，如果你不理会，继续往里面写，撑爆了内存是你自己的责任，与我无关。`Readable` 通过 [read](https://nodejs.org/docs/latest/api/stream.html#readablereadsize) 函数读取数据，读取的时候可以指定长度，如果指定了长度就从内部链表的队尾移出指定长度的元素交给调用者；如果没有指定长度，就会把所有元素移出交给用户。

`Writable` 内部维持一个计数器，代表有多少条数据还未写入完成，通过 [write](https://nodejs.org/docs/latest/api/stream.html#writablewritechunk-encoding-callback) 函数添加数据，此时计数器加一（假设我们此时只写一条数据），其内部调用 [`_write`](https://nodejs.org/docs/latest/api/stream.html#writable_writechunk-encoding-callback) 来实现实际的写操作，在 `_write` 实际写完之后在其回调函数中计数器减一。每次调用 `write` 时，如果计数器的值小于 `highWaterMark`，就返回 `true`，这样你可以安心的写；如果为 `false` 就代表当前待写入的数据超标了，如果再写入就有可能会导致内存中的数据越积越多，最终雪崩。这种将主动权放给调用者的行为是和 `Readable` 是如出一辙的。

`highWaterMark` 默认以字节为单位，但是在以下两种情况下，它的单位会发生改变：流对象的构造函数支持传入 `objectMode` 参数，默认为 `false`，如果设置为 `true`，则 `highWaterMark` 的单位变成对象个数；流对象的构造函数支持传入 `defaultEncoding` 参数，对于可读流来说默认为 `null`（此时 `highWaterMark` 的单位为字节），对于可写流来说默认为 `utf-8`（此时如果写入的数据中含有中文等字符，则写入的元素个数算 1 个，而不是 3 个）。 不过如果同时设置 `objectMode` 为 `true` 和 自定义的 `defaultEncoding` 参数时，`defaultEncoding` 参数将会被忽略。

流对象，还支持通过调用 `setDefaultEncoding` 来在使用过程中修改编码方式，这个时候会使读写流的计数方式动态发生更改，也算是一个比较隐蔽的坑。

#### 3.4.2 创建自定义读写流

**3.4.2.1 自定义可读流**

先实现一个可读流，具体代码如下：

```javascript
const { Readable } = require('stream');

class MyReadable extends Readable {
    _read () {
        console.log('调用了 _read 函数');
    }
}

const reader = new MyReadable({
    highWaterMark: 4,
});
console.log('现有流模式', reader.readableFlowing);
console.log('在流有数据前读取', reader.read());
const data = ['a', 'b', 'c', 'd', 'e', 'f'];
const dataLen = data.length;
for (let i = 0; i < dataLen; i++) {
    const pushResult = reader.push(Buffer.from(data[i]));
    if (!pushResult) {
        console.warn('达到highWater值了，最好不要再 push 了', i);
    } else {
        console.log('没有达到highWater值', i);
    }
}
for (let i = 0; i < 3; i++) {
    console.log('read after push', i, reader.read());
}
// 给可读流推送 null，表示数据已经读取完毕
reader.push(null);
console.log('可读取结束后读取', reader.read());
// 因为在流结束后，调用 push 函数，下面这句会触发可读流的 error 事件
reader.push('a');
reader.on('error', (err) => {
    console.error('可读流出错', err);
});
// nodejs 中如果对于对象的 error 事件没有监听器，会导致进程触发 uncaughtException 事件
process.on('uncaughtException', (err) => {
    console.error('uncaughtException', err);
});
```

**代码 3.4.2.1.1 chapter3/stream/read\_simple.js**

我们将 reader 对象的 highWaterMark 值设置为 4，所以在下面的 for 循环中 i 为 4 的时候，就打印 `达到highWater值了，最好不要再 push 了` 的警告。可读流的 push 函数可以接受一个特殊值，就是 null 值，调用 push(null) 后，整个流就处于结束状态，不允许调用者再次调用 push 函数，否则会直接抛出 error 事件。

> Node 中对于对象的 error 事件一定要添加事件监听回调函数，否则从对象中抛出的 error 事件，会外溢到进程级别的 uncaughtException 事件，而一旦触发 uncaughtException 事件，进程默认就会退出。

上述代码中创建了一个简单的可读流。可读流提供了两种读取模式，flow 模式和 no-flow 模式，可读流有一个 `readableFlowing` 属性，默认为 `null`。从上述代码的输出中也可以发现，在没有做任何函数调用的情况下，可读流的 `readableFlowing` 为 `null`。

如果给可读流对象增加 `data` 事件监听、调用函数 `resume` / `pipe` ，将会使用可读流进入 flow 模式，此时 `readableFlowing` 会被置为 true。调用 `pause` / `unpipe` 函数或者添加 `readable` 事件监听会将可读流切换到 no-flow 模式，并且将 `readableFlowing` 置为 false，这个时候必须手动调用函数 `resume` / `pipe` 才能将其切换回 flow 模式，如果在这种情况下添加 `data` 事件是无法切换为 flow 模式的。

注意，如果你同时给可读流添加了 `readable` 和 `data` 的事件，则 `readable` 的优先级高于 `data`，流将会进入 no-flow 模式。当你将 `readable` 事件移除，只保留 `data` 事件时，则会回到 flow 模式。同时需要注意到，添加了 `readable` 事件后，调用 `pause` `resume` 这两个函数是没有意义的。

在可读流的使用过程中，你应该尽量选择一种读取模式，以此降低自己代码的复杂度。Node 中通过调用可读流不同函数来隐式的修改其工作模式的方式，确实是一种比较让人艰涩难懂的设计。比如说下述代码：

```javascript
const { Readable } = require('stream');

class MyReadable extends Readable {
    _read () {
        console.log('_read has been called');
    }
}

const reader = new MyReadable();
const initSize = 6;
for (let i = 0; i < initSize; i++) {
    reader.push(Buffer.from([i & 0xff]));
}
reader.on('readable', function () {
    console.log('get data');
});

//
reader.on('data', (chunk) => {
    console.log('流模式2', reader.readableFlowing);
    console.log('data event', chunk);
});

console.log('流模式', reader.readableFlowing);
```

**代码 3.4.2.1.2 chapter3/stream/readable\_event\_emit\_one.js**

我们同时添加了 readable 和 data 事件，readable 的优先级高，所以程序中 readableFlowing 属性为 false。data 事件中也接收不到任何事件回调。

但是我们一旦将第 15 行稍作改动，改成 `console.log('get data', reader.read());`，你就会发现不但 readable 事件中读取到数据了，`data` 事件中也能读取出数据了。而上述这种诡异的行为，也在官方文档中的犄角旮旯中得到了[官方解释](https://nodejs.org/api/stream.html#event-readable)。

> If both `'readable'` and [`'data'`](https://nodejs.org/api/stream.html#event-data) are used at the same time, `'readable'` takes precedence in controlling the flow, i.e. `'data'` will be emitted only when [`stream.read()`](https://nodejs.org/api/stream.html#readablereadsize) is called.

翻译一下就是两个事件都监听时，在 `readable` 中触发 `read()` 调用时，会级联触发 `data` 事件。我相信没有几个开发者会逐字逐句地阅读官方文档，所以编写流相关的代码，大家还是尽量简洁。

**3.4.2.2 自定义可写流**

```javascript
const { Writable } = require('stream');

class MyWritable extends Writable {
  _write(chunk, encoding, callback) {
    setTimeout(function() {
      callback();
    },100);
  }
}

const writer = new MyWritable({
  highWaterMark: 3
});

for (var i=0;i<6;i++) {
   const result = writer.write(Buffer.from([i & 0xff]));
   console.log('推荐下次继续写',result);
}
writer.on('drain',function() {
  console.log('现在可以放心写了');
});
writer.on('error',function(err) {
  console.error('写错误',err);
});
```

**代码 3.4.2.2.1**

这里为了更快地观察可写流内置缓冲区被写满的现象，这里将 `highWaterMark` 的值设置为 3，这样在 15 行循环到 2 的时候写操作就会返回 false。正常情况下 write 函数返回 false 的时候，就需要停下写入，等待 `drain` 事件触发后再写入，上面的程序明显是一个不规范的写法。

`_write` 函数是供给内部调用使用的，在自己来实现可写流的子类时，这个函数是必须要实现的。`_write` 内部通过 `callback` 函数来标记写入完成。这个回调函数调用之前，认为数据是没有写入成功的。

为了防止可写流写入的速度过快，可写流提供了两个函数 `cork` 和 `uncork`，调用 `cork` 后会把要写入的数据缓存起来，直到调用函数 `uncork` 后才会一股脑将缓存的数据做真正的写入。

**3.4.2.3 让流 “流动” 起来**

我们选择用流，往往想借助其 “流动” 特性来简化我们的代码调用逻辑。这个实现 “流动” 特性的关键技术点，就是使用 `pipe` 函数。我们在 **代码 3.3.2** 17 行中看到了 pipe 函数的简单使用，pipe 函数会将一个可读流的数据传输到可写流中，就像我们在 **图 3.3.1** 中演示的效果一样，看上去数据“流动”起来了。不过你可能之前看过如下类似代码：

```javascript
const fs = require('fs');
const zlib = require('zlib');
const crypto = require('crypto');

// 输入和输出文件路径
const inputFilePath = 'input.txt';
const outputFilePath = 'output.txt.gz';

// 创建读取文件流
const readStream = fs.createReadStream(inputFilePath);

// 创建压缩流
const gzipStream = zlib.createGzip();

// 创建写入文件流
const writeStream = fs.createWriteStream(outputFilePath);

// 多次 `pipe` 操作: 读取 -> 压缩 -> 写入
readStream
    .pipe(gzipStream)    // 第一次 pipe，压缩数据
    .pipe(writeStream)   // 第二次 pipe，将结果写入文件
```

**代码 3.4.2.3.1**

前面讲我们只能将可读流 pipe 到可写流里面，但是这个中间 `gzipStream` 是什么鬼，从 `readStream` 角度看它是可写流，从 `writeStream` 的角度看它又是可读流，解开其神秘面纱的关键就是 `Transform`。它支持以可写流的身份接收从别处写入的数据，经过其加工后，再放置到内置的可写流中。

下面是一个我们自己构建的自定义 Transform 类

```javascript
const { Transform, Readable, Writable } = require('stream');
class InputStream extends Readable {
    _read () {
        //
    }
}
class OutputStream extends Writable {
    constructor (options) {
        super(options);
        this.data = [];// 调试用
    }

    _write (chunk, encoding, callback) {
        this.data.push(chunk);
        callback();
    }
}
class MyTransform extends Transform {
    _transform (chunk, encoding, callback) {
        const filterData = [];
        for (const byte of chunk) {
            if (byte % 2 === 0) {// 挑选出偶数
                filterData.push(byte);
            }
        }
        if (filterData.length > 0) {
            this.push(Buffer.from(filterData));// 将数据写入可读流
        }
        callback();// 不要忘记调用 callback 函数
    }
}
const inputStream = new InputStream();
const outputStream = new OutputStream();
const myTransform = new MyTransform();
inputStream.pipe(myTransform).pipe(outputStream);
const count = 6;
function readData (i) {
    if (i < count) {
        inputStream.push(Buffer.from([i & 0xff]));
        setTimeout(() => {
            readData(i + 1);
        }, 100);
    } else {
        inputStream.push(null);
    }
}
readData(0);

myTransform.on('data', function (data) {
    console.log('transform 得到的转化数据', data);
});
outputStream.on('finish', function () {
    console.log('流写结束了');
    console.log(outputStream.data);
});
```

**代码 3.4.2.3.2 chapter3/stream/transform\_simple.js**

上述代码中我们通过可读流 `inputStream` 来采集数据的数字， `myTransfrom` 转化的函数中将采集到的数字筛选出偶数来，最终可写流 `outputStream` 拿到最终转化的数字。

**3.4.2.4 身兼两职的流**

Transform 流其实内部继承自 [`Duplex`](https://nodejs.org/api/stream.html#class-streamduplex) ，Duplex 内部同时拥有可读流和可写流，但是跟 Transform 不同的是，Duplex 其内部读写的数据是分别存储在两个缓冲区中，两者没有关联关系，相互独立。Node API 中的 [net.Socket](https://nodejs.org/docs/latest/api/net.html#class-netsocket) 类就是继承自 [stream.Duplex](https://nodejs.org/docs/latest/api/stream.html#class-streamduplex) 类，由于 TCP 双向通信的特性，既能收也能发，且在操作系统层面收发使用的是不同的缓冲区，所以使用 `Duplex` 类是特别贴合的。为了演示 `Duplex` 类的使用，我们还是举一个菜鸟驿站的例子，驿站既可以收快递，也可以往外寄快递，和 TCP 的例子类似，他们收上来的快递和要寄出的快递肯定不是同一个（这里不讨论拒收等特殊情况）。那我们可以使用下面的代码来演示：

```javascript
const { Duplex } = require('stream');
class PostHouse extends Duplex {
    constructor (options) {
        super(options);
        this.postingLetters = [];
    }

    _write (_chunk, _encoding, callback) {
        this.postingLetters.push(_chunk);
        callback();
    }

    _read () {
        //
    }

    receiveLetter (letter) {
        this.push(letter);
    }
}

const postHouse = new PostHouse({
    objectMode: true, // 以对象模式工作, 方便传递字符串
});
postHouse.on('data', function (data) {
    console.log('收到信', data);
});
postHouse.receiveLetter('信件1');// 使用字符串格式插入可读流数据
postHouse.write('要寄出的信件x');
console.log(postHouse.postingLetters);
```

**代码 3.4.2.4.1 chapter3/stream/duplex\_post\_house.js**

通过上述代码可以看到我们构建出来的驿站类 `PostHouse`，既可以接收快递，也可以寄出快递，但是两者的数据是不共享的，没有相互干扰。

#### 3.4.3 新时代的流

从 Node 7.6 开始 async/await 语法正式稳定可用，由于其消除了异步回调地狱，渐渐被大家采纳。但是在一些 async/await 风格的代码中使用流时很容易犯一些低级错误。

```javascript
const { DataStream } = require('./data-stream');
const { readDataDelay } = require('./data-source');

exports.getStream = async function () {
    const stream = new DataStream();
    let hasMore = true;
    const readFun = readDataDelay(1000);
    let begin = 0;
    const size = 1;
    while (hasMore) {
        const data = await readFun(begin, size);
        if (data.length === 0) {
            hasMore = false;
        } else {
            stream.push(...data);
            begin += size;
        }
    }
    stream.push(null);
    return stream;
};
```

**代码 3.4.3.1 chapter3/stream-next/blocked-read.js**

上面代码语法是完全正确的，调用时也能拿到数据，但是却有严重的逻辑问题，while 循环中全都需要做异步等待，等待所有数据取完了之后才会返回代码中的 stream 对象，导致调用方拿到 stream 对象时，所有的数据读取过程都做完了。我们使用流肯定是想边读取数据，边处理，上述代码跟直接返回一个最终数组的效果是一样的。

可能你会想到，既然 async/await 这条道路走不通了，那么干脆走回 ES5 callback 那套流程吧，于是写出如下代码：

```javascript
const { readDataDelay } = require('./data-source');
const { DataStream } = require('./data-stream');

function readData (begin, size, callback) {
    readDataDelay(1000)(begin, size).then(data => {
        callback(null, data);
    }).catch(err => {
        callback(err);
    });
}

exports.getStream = function () {
    const stream = new DataStream();
    let begin = 0;
    const size = 1;
    function read () {
        readData(begin, size, (err, data) => {
            if (data.length === 0) {
                stream.push(null);
                return;
            }
            if (err) {
                stream.emit('error', err);
            } else {
                stream.push(...data);
                begin += size;
                read();
            }
        });
    }
    read();
    return stream;
};
```

**代码 3.4.3.2 chapter3/stream-next/callback-read.js**

上述代码抛弃了 async/await 语法，一夜回到解放前，从逻辑上看是没有毛病的，但是我们设计 async/await 这种语法本来是为了消灭回调函数，但是用完高级语法后却不能正常做事了，是不是 node 本身的语法设计有槽点呢？其实不然，如何想从上述代码中消灭回调的方式还是有的，在 async/await 语法出现之前 node 就已经出台了 Generator 函数的语法，下面是对其使用的例子：

```javascript
const { readDataDelay } = require('./data-source');

exports.getData = async function * () {
    let begin = 0;
    const size = 1;
    const readFun = readDataDelay(1000);
    let hasMore = true;
    while (hasMore) {
        const data = await readFun(begin, size);
        if (data.length === 0) {
            hasMore = false;
        } else {
            yield * data;
            begin += size;
        }
    }
};
```

**代码 3.4.3.3 chapter3/stream-next/generator-read.js**

上述的代码，就比前两者简单明了多了。对于 Generator 函数 的调用会在后面统一给出例子。

除了 Generator 函数，从 16.5 版本开始，Node 正式适配了 Web Stream API 标准，从此之后你可以在新写的代码抛弃掉诘屈聱牙的老式 stream 代码，换成新版的 Web Stream 风格，下面给出具体的例子：

```javascript
// 创建自定义ReadableStream
const { readDataDelay } = require('./data-source');
const { ReadableStream } = require('stream/web');
const readFun = readDataDelay(1000);
const customStream = new ReadableStream({
    // 存储当前状态和数据
    start (controller) {
        // 初始化数据
        this.start = 0;
        this.size = 1; //
    },

    // 读取数据的方法
    async pull (controller) {
        // 模拟异步数据生成（如从API、文件等获取数据）
        const data = await readFun(this.start, this.size);

        this.start += this.size;

        // 检查是否还有数据要发送
        if (data.length > 0) {
            // 发送数据
			data.forEach(item => controller.enqueue(item));
        } else {
            // 没有更多数据，结束流
            controller.close();
        }
    },

    // 处理取消的方法（可选）
    cancel (reason) {
        console.log('流被取消:', reason);
    }
});
exports.webStream = customStream;
```

**代码 3.4.3.4 chapter3/stream-next/web-stream-read.js**

看到上述代码，可能你会感觉虽然有差异，但是跟老版本的 stream 代码的编写流程差不太多，哪个 `start` 函数可以类比 `Readable` 中的构造函数，`pull` 函数可以类比 `_read`，`cancel` 可以类比 `_destory` 。我们在学习新鲜事物的时候，确实可以通过类比法，来加快自己的学习进度。但是需要注意的是，`pull` 这个函数跟 `Readable` 中的 `_read` 是大有不同的，前者是支持返回 Promise 对象的，而后者的返回值是 void，这代表着你在后者写一些异步操作的时候要特别留心 `_read` 函数体内同时有多个任务在执行时的状态，我们再给出一个错误示例：

```javascript
const { Readable } = require('stream');
const { readDataDelay } = require('./data-source');
const readFun = readDataDelay(1000);
class LegacyStream extends Readable {
    constructor (options) {
        super({ ...options, objectMode: true });
        this.start = 0;
        this.size = 1;
    }

    /**
     * 这个函数是有 bug 的，如果两个请求同时调用，
     * 通一个 start 会同时被两个请求使用，导致重复数据读取错乱
     */
    _read () {
        const size = this.size;
        readFun(this.start, size).then(data => {
            if (data.length > 0) {
                this.push(...data);
                this.start += size;
            } else {
                this.push(null);
            }
        }).catch(err => {
            this.emit('error', err);
        });
    }
}

exports.LegacyStream = LegacyStream;
```

**代码 3.4.3.5 chapter3/stream-next/legacy-stream-read.js**

假设同时有两个 `_read()` 调用，则调用流程如下：

```
调用1: this.start = 0 → 读取 [0,1)
调用2: this.start = 0 → 也读取 [0,1)（因为此时调用1的 this.start 还未更新）
```

上述调用流程意味着会有重复数据被读出。我们需要将其修正：

```javascript
_read(size) {
	// 关键：用局部变量保存当前读取位置，避免并发修改
	const currentStart = this.start;
	const readSize = this.size;

	readFun(currentStart, readSize)
		.then(data => {
			if (data.length > 0) {
				// 推送数据前，先更新起始位置（确保下次读取正确）
				this.start = currentStart + readSize;
				this.push(...data);
			} else {
				// 数据读取完毕，结束流
				this.push(null);
			}
		})
		.catch(err => {
			this.emit('error', err);
		});
}
```

**代码 3.4.3.6 修正后的 `_read` 函数**

虽然说用传统 stream 也能实现我们的需求，但是代码编写的难度大，稍有不慎就会出现漏洞。相比上述所有实现方式来说，Generator 函数版本是最简单明了的，Web Stream 版本次之。下面是**代码 3.4.3.1** **代码 3.4.3.2** **代码 3.4.3.3** **代码 3.4.3.4** 的测试：

```javascript
const { getStream: getStreamBlocked } = require('./blocked-read');
const { getStream: getStreamCallback } = require('./callback-read');
const { getData } = require('./generator-read');
const { webStream } = require('./web-stream-read');

async function main () {
    console.log(new Date(), 'begin blocked');
    const streamBlocked = await getStreamBlocked();
    for await (const data of streamBlocked) {
        console.log(new Date(), data);
    }
    console.log(new Date(), 'end blocked');

    console.log(new Date(), 'begin callback');
    const streamCallback = await getStreamCallback();
    for await (const data of streamCallback) {
        console.log(new Date(), data);
    }
    console.log(new Date(), 'end callback');

    console.log(new Date(), 'begin generator');
    const streamGenerate = getData();
    for await (const data of streamGenerate) {
        console.log(new Date(), data);
    }
    console.log(new Date(), 'end generator');

    console.log(new Date(), 'begin web stream');
    for await (const data of webStream) {
        console.log(new Date(), data);
    }
    console.log(new Date(), 'end web stream');
}

main().catch(console.error);
```

**代码 3.4.3.7 chapter3/stream-next/do-read.js**

你会发现上述代码所有的数据遍历操作都用的是 for...of 循环，这是由于从 Node 10 开始，传统的 stream 也和 Generator 函数一样实现了 **Async Iteration（异步迭代器）**。运行完成后，输出的内容如下：

```
2025-07-19T08:12:18.759Z begin blocked
2025-07-19T08:12:22.803Z { id: 1, name: 'John Doe', email: 'john.doe@example.com' }
2025-07-19T08:12:22.804Z { id: 2, name: 'Jane Doe', email: 'jane.doe@example.com' }
2025-07-19T08:12:22.804Z { id: 3, name: 'Jim Doe', email: 'jim.doe@example.com' }
2025-07-19T08:12:22.805Z end blocked
2025-07-19T08:12:22.806Z begin callback
2025-07-19T08:12:23.821Z { id: 1, name: 'John Doe', email: 'john.doe@example.com' }
2025-07-19T08:12:24.827Z { id: 2, name: 'Jane Doe', email: 'jane.doe@example.com' }
2025-07-19T08:12:25.838Z { id: 3, name: 'Jim Doe', email: 'jim.doe@example.com' }
2025-07-19T08:12:26.838Z end callback
2025-07-19T08:12:26.839Z begin generator
2025-07-19T08:12:27.852Z { id: 1, name: 'John Doe', email: 'john.doe@example.com' }
2025-07-19T08:12:28.860Z { id: 2, name: 'Jane Doe', email: 'jane.doe@example.com' }
2025-07-19T08:12:29.877Z { id: 3, name: 'Jim Doe', email: 'jim.doe@example.com' }
2025-07-19T08:12:30.891Z end generator
2025-07-19T08:12:30.892Z begin web stream
2025-07-19T08:12:30.898Z { id: 1, name: 'John Doe', email: 'john.doe@example.com' }
2025-07-19T08:12:31.902Z { id: 2, name: 'Jane Doe', email: 'jane.doe@example.com' }
2025-07-19T08:12:32.918Z { id: 3, name: 'Jim Doe', email: 'jim.doe@example.com' }
2025-07-19T08:12:33.944Z end web stream
```

讲了这么多新时代的流，那旧时代的流是不是就应该扔到历史的垃圾堆中呢？目前看是不行的，因为现在绝大多数第三方库依然只提供旧版流的 API，深究一下更底层的原因，Node 库中 http 和 tcp 等模块也是在旧版流的基础上实现的，要想完全将旧版实现抹掉目前看来并不现实。还好我们可以通过转换函数，将新版流转成旧版流。

```javascript
Readable.from(asyncIterator);
```

**代码 3.4.3.8 将新时代流转成旧时代流**

这个 `asyncIterator` 变量就可以传 Web Stream 或者 Async Iteration 函数的返回值。

### 3.5 总结

我们用两个小节讲述了 Node 中如何处理静态资源和动态请求，看完这些之后，如果你是一个初学者，可能会因此打退堂鼓，这也太麻烦了，如果通过这种方式来处理数据，跟 php java 之类的比起来毫无优势可言嘛。大家不要着急，Node 社区已经给大家准备了各种优秀的 Web 开发框架，比如说 [Express](https://expressjs.com/)、[Koa](https://koajs.com/)，绝对让你爱不释手。你可以从本书的第 6 章中学习到 express 基本知识。

本章示例代码可以从这里找到：<https://github.com/yunnysunny/nodebook-sample/tree/master/chapter3> 。


# 4 NPM 包管理工具

在Node出现之前，我们在做前端的时候经常用到一些开发工具使用ruby和python开发的（比如说[sass](https://sass-lang.com/)，一种CSS预编译语言，依赖于ruby；(Pygments)\[<https://pygments.org/>] ，一种代码语法高亮插件，依赖于python），这个时候就会看到[gem](https://rubygems.org/)和[pip](https://pypi.python.org/pypi)的身影。熟悉java的同学，应该也对[maven](https://maven.apache.org/)如数家珍。和这些语言类似，Node 也提供了包管理工具，它就是 npm ，全名为 **N**ode **P**ackage **M**anager，集成于 Node 的安装程序中。

## 4.1 使用NPM

npm 不仅可用于安装新的包，它也支持搜寻、列出已安装模块及更新的功能。

npm 目前拥有数以百万计的包，可以在 <https://www.npmjs.com/> 使用关键字搜寻包。举例来说，在关键字栏位输入“coffee-script”，下方的清单就会自动列出包含 coffee-script 关键字的包。

![image](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waM0oNaFdb_UBYyzZ%2Fnode_npm_registry.png?generation=1582638397449961\&alt=media)

虽然也可以通过`npm search`来在命令行中查询，但是初次查询过程中要在本地建立索引，等待的时间巨漫长，还是不介绍的为好。

找到需要的包后，即可使用以下指令安装：

```
npm install coffee-script
```

> 在 win 10 下的 powershell 中运行命令，如果出现提示 `无法加载文件，因为在此系统上禁止运行脚本` 的提示，那么需要使用超级管理员打开 powershell ，然后运行 `set-ExecutionPolicy RemoteSigned`，接着会提示`是否要更改执行策略`，输入 y。操作完成之后，即可执行 npm 命令。

运行完之后，就会在当前目录下的 `node_modules`目录下安装coffee-script包。

```
  ├─┬ node_modules 
  │ └── coffee-script 
```

**目录结构 4.1.1 将包安装到本地后的目录结构**\
一般情况下，我们在node项目目录下创建package.json，里面包含项目名称、作者、依赖包等配置，我们可以通过 `npm init`快速创建一个package.json文件，我们新建一个目录，然后在命令执行`npm init`，则会要求你填入若干信息：

```
name: (app) test                                           
version: (1.0.0) 0.0.1                                     
description: test app                                      
entry point: (index.js)                                    
test command:                                              
git repository:                                            
keywords:                                                  
author: yunnysunny                                         
license: (ISC) MIT                                         
About to write to I:\node\app\package.json:                

{                                                          
  "name": "test",                                          
  "version": "0.0.1",                                      
  "description": "test app",                               
  "main": "index.js",                                      
  "scripts": {                                             
    "test": "echo \"Error: no test specified\" && exit 1"  
  },                                                       
  "author": "yunnysunny",                                  
  "license": "MIT"                                         
}                                                          


Is this ok? (yes)                                       
```

**命令输入 4.1.1 npm init命令输入示例**\
我们接着在项目中安装express包（在第5章会讲到这个包的使用），不过我们执行命令的时候加个参数：`npm install express --save`。命令执行完成之后，再看package.json，发现多了一个配置属性：

```json
"dependencies": {
    "express": "^4.14.0"
}
```

这个dependencies属性里面描述的就是当前项目依赖的各种包，你可以通过运行`npm install packageName --save`来将其安装到本地的同时在package.json中同时添加依赖声明。当你代码开发完成时，要把项目往服务器上部署，那么这时候package.json中的依赖声明都已经写好了，这时候，你直接在项目目录运行`npm install`，就可以自动将声明中的文件全部下载安装到项目目录的`node_modules`子目录下。\
我们在来稍微留意一下，我们配置的这个express的版本号，咦，`^`是个什么鬼？讲这个东东，还要从[Semantic Versioning](https://semver.org/)这个概念讲起，它将版本号分为三段：

1. 主版本，你可以在这个版本中做不兼容性改动
2. 小版本，你可以在这个版本上增加共嗯那个，不过要向后兼容
3. 补丁版本，在这里可以做一些bug修复，不过依然要保持向后兼容

在这里对于express来说，主版本号是`4`，小版本号是`14`，补丁版本号是`0`。啰嗦了这么多，那么`^`呢，它告诉你使用从`4.14.0`到`5.0.0`（不包括5.0.0）之间的最新版本，也就是说它选择的版本号 x 的取值范围:`4.14.0<=x<5.0.0`。不过，如果版本号是 `0` 打头的一个字符串，比如说 `0.1.2` `0.0.2`，则会被特殊对待，对于前者来说， 0.1.0<=x<0.2.0，对于后者来说 0.0.0<=x<0.1.0。 除了`^`，还有一个版本号标识符`~`也很常用，假设我们将这里express的版本号设置为`~4.14.0`，那么它表示从`4.14.0`到`4.15.0`（不包括4.15.0）之间的最新版本，也就是说它选择的版本号x的取值范围：`4.14.0<=x<4.15.0`。\
另外还有一些版本号的特殊标志符，由于不常用，有需要的可以参考<https://docs.npmjs.com/cli/v6/using-npm/semver> 。 一般情况下，我们通过将依赖安装到项目目录下，但是有时候我们需要做全局安装，这种全局安装的包一般都是些命令行程序，这些命令行程序安装到全局后就可以保证我们通过 cmd.exe（或者bash） 中调用这些程序了。下面我们演示一下如何全局安装[express-generator](https://www.npmjs.com/package/express-generator)：

```
npm install -g express-generator
```

安装完成后会提示安装到了目录 `C:\Users\[用户名]\AppData\Roaming\npm\node_modules`目录下，其实这个安装目录是可以自指定的，老是往系统盘安装会让人抓狂，下面要讲到这个问题。\
安装完 express-generator ，我们在命令行中新建一个目录`mkdir first-express`，然后进入这个目录运行 `express` ，如果发现生成了一堆express项目文件，恭喜你成功了！

## 4.2 NPM用不了怎么办

互联网拉近了整个世界的距离，有时候让你感觉到近到只有一墙之隔。前面讲了很多npm的使用方法，但是我们要想到 [npmjs](https://npmjs.org) 毕竟是一个外国网站，作为一个开发人员，相信你也许经历过很多技术网站，安安静静的躺在那里，但是就是无法访问的问题，但是谁又能保证npmjs不会是下一个中枪者呢？\
幸好，阿里开发出了 [cnpm](https://npmmirror.com/) ，一个完整 npmjs.org 镜像，每隔10分钟和官方库进行一次同步。其安装命令很简单：

```
npm install -g cnpm --registry=https://registry.npmmirror.com
```

**命令 4.2.1**

不过你需要注意，由于最新版本的cnpm不兼容低版本node,如果你当前使用的node版本低于4.x，那么你需要在安装的时候指定版本号：

```
npm install -g cnpm@3.4.1 --registry=https://registry.npmmirror.com
```

**命令 4.2.2**

否则的话，安装完之后运行命令会报错。\
接着你可以使用 cnpm 来代替 npm，比如说`cnpm install`来代替`npm install`，又可以愉快的玩耍了。

但是我们在使用一些第三方工具的时候，其包裹的命令行内部会调用 npm install 命令来安装依赖包，这时候 cnpm 排不上用场，那么将 npm 的安装源手动切换到淘宝源，就是解决这个问题的大法：

```shell
npm config set registry https://registry.npmmirror.com
```

**命令 4.2.3**

接着，我们尝试使用cnpm全局安装lodash，运行假设你的nodejs安装在windows的C盘的话，运行完`cnpm install lodash -g`后，你会惊奇的发现报错了：

```
npm ERR! Error: EPERM, mkdir 'C:\Program Files (x86)\nodejs\node_modules\lodash'
npm ERR!  { [Error: EPERM, mkdir 'C:\Program Files (x86)\nodejs\node_modules\lodash']
npm ERR!   errno: 50,
npm ERR!   code: 'EPERM',
npm ERR!   path: 'C:\\Program Files (x86)\\nodejs\\node_modules\\lodash',
npm ERR!   fstream_type: 'Directory',
npm ERR!   fstream_path: 'C:\\Program Files (x86)\\nodejs\\node_modules\\lodash',
npm ERR!   fstream_class: 'DirWriter',
npm ERR!   fstream_stack:
npm ERR!    [ 'C:\\Users\\sunny\\AppData\\Roaming\\npm\\node_modules\\cnpm\\node_modules\\npm\\node_modules\\fstream\\lib\\dir-writer.js:35:25',
npm ERR!      'C:\\Users\\sunny\\AppData\\Roaming\\npm\\node_modules\\cnpm\\node_modules\\npm\\node_modules\\mkdirp\\index.js:47:53',
npm ERR!      'Object.oncomplete (fs.js:108:15)' ] }
npm ERR!
npm ERR! Please try running this command again as root/Administrator.

npm ERR! Please include the following file with any support request:
```

**输出 4.2.1 cnpm全局安装错误输出**\
为啥呢？首先，cnpm 会在将包默认安装在nodejs安装目录下的`node_modules`子文件夹中，其次我们这里将node安装到了系统盘`C:\Program Files (x86)`目录下，最后写入这个目录需要超级管理员权限。\
本来就讨厌往系统盘写入数据文件，这下子非要改掉它这个默认设置不可了。我们命令

```
npm config set prefix "D:\npm"
```

**命令 4.2.4**

> 设置完成后，记得将 d:\npm 添加到环境变量 PATH 中，否则在终端中无法找到全局安装的命令。

这样你使用 `npm install -g package` 命令安装的包就会被放置到 ${prefix}/node\_modules 下。同时使用命令

```
npm config set cache "D:\npm-cache"
```

**命令 4.2.5**

可以设置npm的缓存路径，否则的话它默认会缓存一部分下载的包到系统目录中。

我们推荐仅仅在全局安装命令行工具类型的包，因为同一个包在不同项目中很有可能使用不同的版本，所以如果将其安装在全局的话，就没办法使用不同版本了。如果你非要将某一个类库安装到全局的话，那就增加一个 `NODE_PATH` 环境变量，指向我们刚才设置的目录 `D:\npm`。

## 4.3 npm 包中的预编译二进制文件无法下载

上一小节我们讨论了如何更改 npm 包安装源，但是这种修改只针对 js 代码有效，如果要下载的包中还包含预编译的二进制文件，则需要进行额外的配置。一般来说，这些第三方包都会提供 npmrc 的配置项，来指定预编译二进制文件的下载地址。 例如，对于 [electron](https://www.npmjs.com/package/electron) 包，你可以在项目根目录创建一个 .npmrc 文件，并添加如下内容：

```
electron_mirror=https://npmmirror.com/mirrors/electron/
```

**代码 4.3.1**

这样在安装包时，npm 会优先从淘宝源下载预编译的二进制文件，而不是从官方源下载。不过为了后续其他项目的安装便捷，我们推荐将这个配置直接写入到系统的 \~/.npmrc 文件中。下面给出常见的配置项：

```
disturl=https://npmmirror.com/mirrors/node
chromedriver-cdnurl=https://npmmirror.com/mirrors/chromedriver
couchbase-binary-host-mirror=https://npmmirror.com/mirrors/couchbase/v{version}
debug-binary-host-mirror=https://npmmirror.com/mirrors/node-inspector
electron-mirror=https://npmmirror.com/mirrors/electron/
flow-bin-binary-host-mirror=https://npmmirror.com/mirrors/flow/v
fse-binary-host-mirror=https://npmmirror.com/mirrors/fsevents
fuse-bindings-binary-host-mirror=https://npmmirror.com/mirrors/fuse-bindings/v{version}
git4win-mirror=https://npmmirror.com/mirrors/git-for-windows
gl-binary-host-mirror=https://npmmirror.com/mirrors/gl/v{version}
grpc-node-binary-host-mirror=https://npmmirror.com/mirrors
hackrf-binary-host-mirror=https://npmmirror.com/mirrors/hackrf/v{version}
leveldown-binary-host-mirror=https://npmmirror.com/mirrors/leveldown/v{version}
leveldown-hyper-binary-host-mirror=https://npmmirror.com/mirrors/leveldown-hyper/v{version}
mknod-binary-host-mirror=https://npmmirror.com/mirrors/mknod/v{version}
node-sqlite3-binary-host-mirror=https://npmmirror.com/mirrors
node-tk5-binary-host-mirror=https://npmmirror.com/mirrors/node-tk5/v{version}
nodegit-binary-host-mirror=https://npmmirror.com/mirrors/nodegit/v{version}/
operadriver-cdnurl=https://npmmirror.com/mirrors/operadriver
phantomjs-cdnurl=https://npmmirror.com/mirrors/phantomjs
profiler-binary-host-mirror=https://npmmirror.com/mirrors/node-inspector/
puppeteer-download-base-url=https://npmmirror.com/mirrors/chrome-for-testing
python-mirror=https://npmmirror.com/mirrors/python
rabin-binary-host-mirror=https://npmmirror.com/mirrors/rabin/v{version}
sass-binary-site=https://npmmirror.com/mirrors/node-sass
sodium-prebuilt-binary-host-mirror=https://npmmirror.com/mirrors/sodium-prebuilt/v{version}
sqlite3-binary-site=https://npmmirror.com/mirrors/sqlite3
utf-8-validate-binary-host-mirror=https://npmmirror.com/mirrors/utf-8-validate/v{version}
utp-native-binary-host-mirror=https://npmmirror.com/mirrors/utp-native/v{version}
zmq-prebuilt-binary-host-mirror=https://npmmirror.com/mirrors/zmq-prebuilt/v{version}
canvas-binary-host-mirror=https://npmmirror.com/mirrors/node-canvas-prebuilt/v{version}
canvas-prebuilt-binary-host-mirror=https://npmmirror.com/mirrors/node-canvas-prebuilt/v{version}
swc_binary_site=https://npmmirror.com/mirrors/node-swc
xprofiler_binary_host_mirror=https://npmmirror.com/mirrors/xprofiler
```

**代码 4.3.2**

如果不过有一些包的预编译二进制文件的下载地址不是在 npmmirror.com 上，这时候就只能自己上传到一个可访问的服务器上，然后再将地址添加到 .npmrc 上。还有一种特殊情况，部分包修改了预编译二进制的自定义下载地址的读取方式，不再支持通过 .npmrc 文件来配置，这时候只能按照官方文档来手动指定下载地址，比如说新版的 [puppeteer](https://www.npmjs.com/package/puppeteer) 包，你需要使用环境变量来指定自定义下载地址。

```
PUPPETEER_CHROME_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing
```

**代码 4.3.3**

> 为了方便在容器中使用，我专门制作了一个[基础镜像](https://github.com/whyun-docker/node)，包含了上述自定义地址的配置。

## 4.4 我只想在某些项目中使用特定 npm 仓库

上面讲了如何修改 Node 的安装仓库的地址，但是这个修改是全局的，一旦你修改了之后，装所有的包都是用同一个仓库。但是我们在实际开发过程中，公司内部一般都会自己搭建一个 npm 仓库，用来托管私有包并提供对于公开包的反向代理功能。这时候就必须将 npm 仓库地址改成公司库才行。但是这样还会带来其他问题，为了防止代码泄露，公司的私有仓库一般都是部署在内网，有时开发一些类似开源项目的时候还得不得不切换到公开仓库地址。那么有没有项目级别的 npm 配置呢？还真有。

我们只需要在项目的根目录中创建 .npmrc 文件，并输入如下内容

```
registry="私有仓库地址"
```

**代码 4.4.1**

这样你在项目中运行 npm install 时使用的就是私有仓库地址，而在其他地方运行时使用的还是默认仓库地址。

## 4.5 其他一些命令

如果你想查看当前全局安装了哪些包，可以使用`npm list -g`命令，运行完成后会打印一个目录树，但是如果安装的包比较多的话，在命令行中会打印不全，所以可以采用重定向的方式将打印结果输出到硬盘，例如`npm list -g > d:\package.txt`。 如果不加`-g`参数就是打印当前目录下 `node_modules` 文件夹下的包结构。

有时候，我们需要将安装好的包删除掉，如果包是安装在项目目录下的话，其实直接可以把 `node_modules` 下对应的文件夹删除即可，如果是全局安装的话，那还是使用命令进行卸载吧，比如卸载我们上面安装的 express-generator ： `npm uninstall express-generator -g`。同样这里的 `-g` 是说卸载全局安装的 express-generator 包。

## 4.6 pnpm

pnpm 作为最近几年的后起之秀，在包管理这块拥有更新的理念。类似于 npm ，在项目中安装包的时候，都需要将包文件实实在在的写入到项目中的 node\_modules 文件夹下，但是 pnpm 采用的是文件链接的形式，真正的文件存储在其公共文件夹中，这个文件夹是所有项目所共享的，这中方式极大加快了包的安装的速度。所以在新项目中，可以使用 pnpm 来作为自己的包管理器。我们可以通过 `npm install pnpm -g` 来全局安装 pnpm。

> 很多其他语言的包管理器也是采用类似链接的方式，比如说 Java 中的 maven。

pnpm 的命令行使用方式和 npm 是类似的，`pnpm install` 等同于 `npm install` 。使用 pnpm 安装完成后，会生成或者更新一个 pnpm-lock.yaml 文件，其格式如下:

```yaml
lockfileVersion: '6.0'

settings:
  autoInstallPeers: true
  excludeLinksFromLockfile: false

dependencies:
  puppeteer:
    specifier: ^22.6.3
    version: 22.6.3

packages:

  /@babel/code-frame@7.24.2:
    resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==}
    engines: {node: '>=6.9.0'}
    dependencies:
      '@babel/highlight': 7.24.2
      picocolors: 1.0.0
    dev: false
```

**代码 4.6.1**

pnpm 没有自己专有的 npm 仓库地址设置，而是借用了 npm 的仓库地址，所以你可以直接复用 **命令 4.2.3** 和 **代码 4.3.1** 两种方式来设置 pnpm 仓库地址。 如果想更改 pnpm 下载文件的存放位置，可以使用如下命令：

```
pnpm config set store-dir d:\.pnpm-store
```

**命令 4.6.1**

同时需要留意的是，pnpm 全局安装命令前必须得设置 `global-bin-dir` 属性，否则会提示 global-dir 不存在，不让安装。具体设置命令如下：

```
pnpm config set global-bin-dir D:\pnpm
```

**命令 4.6.2**

当然设置完这条命令后，并不能保证安装的全局可执行的包可以通过命令行直接调用，你还需要将 `D:\pnpm` 添加到环境变量 `PATH` 中去。

## 4.7 发布自己的包到 npmjs

刚才演示了这么命令都是安装别人的包，现在我们自己开发一个包。首先你要注册一个npmjs的账号（注册地址：<https://www.npmjs.com/signup> ）。注册完成后，通过`npm adduser`命令来将注册的账号绑定到本地机器上，运行完改命令后会让你输入 npmjs 的注册账号和密码。\
要想在 npmjs 上发布自己的包，首先要做的是明确你发布的包名在这个网站上有没有存在，在4.1小节，我们上来就介绍了怎么通过包名搜索npmjs上的包。不过，这里提供一个简单暴力的方法，就是直接在浏览器里输入：npmjs.com/package/packageName ， 将packName替换成你所想创建的包名，然后回车，如果打开的网页中有404映入你的眼帘，恭喜你，这个包名没有被占用。\
我这里演示一下，我开发包slogger的过程，首先在浏览器地址栏里输入：npmjs.com/package/slogger ，很不幸，slogger 这个包名已经被占用了。于是乎我输入 npmjs.com/package/node-slogger ，咦没有被占用（我们应该用发展的眼光的看待问题）。接着新建一个目录 node-slogger ，在命令行中进入这个目录，运行 `npm init`:

```
name: (node-slogger) node-slogger                                           
version: (1.0.0) 0.0.1                                     
description: A wrapper of logger package , which can write same code even if you change you logger api.                                         
entry point: (index.js)                                    
test command:                                              
git repository: git@github.com:yunnysunny/slogger.git                                           
keywords:  logger                                                
author: yunnysunny                                         
license: (ISC) MIT
```

**输出 4.7.1 运行 npm init 后的部分输出**\
注意我们在 `git repository` 位置填写了一个 git 地址，这就意味着当前的代码要托管在github上。接着我们编写代码，然后将代码push到github，接着给预发布的代码打一个tag，最后运行`npm publish`（在此之前需要运行 `npm login` 完成在 npmjs 网站上的授权），打完收工，现在我们看 <https://npmjs.com/package/node-slogger> ，包已经可以访问了！

使用 publish 命令有如下两个注意点：

如果你待发布的代码中含有可执行脚本，且在安装的时候需要运行这个可执行脚本，则你的 publish 命令是不能在 Windows 上运行的，否则你发布的包是没有可执行权限的。

其次如果你之前使用 **命令4.2.3** 手动切换非官方源的话，是没法直接 publish 成功的，这种情况下执行 publish 命令，会将其发布到淘宝源上去，但是我们又没有淘宝源的账号（况且我们也不想发布到淘宝源）。解决的方法是删除 \~/.npmrc 中的这行配置 `registry=https://registry.npmmirror.com/`。当然通过 `npm config set registry https://registry.npmjs.org/` 也能实现相当的效果。

## 4.8 发布自己的包到私有仓库

有的时候，我们写的包是公司内部使用的，不适合公开发布，为了方便大家协作开发，需要一个私有仓库。网上很多教程，都是借用 cnpm 的源代码，自己搭建类似于 cnpm 的系统来做私有仓库，如果嫌这种私有仓库部署麻烦的话，可以借用 npm 自带的 git 链接功能来实现类似的功能。不过如果公司内部已经有私有仓库的话，还是直接用私有仓库，毕竟私有仓库是比较贴合原始 npm 用法的。

npm 中的 dependices ，默认是从镜像仓库中来拉取代码，其实这个地址也可以写成一个 git 地址，比如说类似于 `https://git-domain/project-path.git` 或者 `git@git-domain:project-path.git` 这种格式。不过推荐使用后者，这样可以借助于 git 仓库自带的部署密钥的功能，更好的控制权限。

鉴于目前市面上使用最多的 git 私有托管工具是 gitlab，所以下面的教程内容会直接使用 [gitlab](https://gitlab.com) 做演示。首先在 gitlab 上创建一个项目，<https://gitlab.com/yunnysunny/myecho。提交完代码后，打开菜单> **Repository** -> **Tags**，点击 **New tag** 按钮，创建一个 git tag。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F5d90552f4c4ba15d79ab746e2576539f51b8fc1d.png?generation=1630385961001077\&alt=media)

**图 4.8.1**

这里创建的版本号是 v0.1.0。

接着到了关键的一步，设置部署密钥。输入 key 的内容和标题后，点击 **Add key** 按钮。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2Fc121bb858beb4d7d7586b86c5d1c2d36cfb5e16e.png?generation=1630385962086100\&alt=media)

**图 4.8.2**

添加成功后，就会显示在下面的列表中

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F2a8bb1effb8206c88ba84fbc3d53c2d9cc3007c8.png?generation=1630385959983480\&alt=media)

**图 4.8.3**

这样就算是配置完成了，下次在发布新版本的时候，直接按照 **图4.6.1** 的步骤打个 tag 即可。

最后就是引用这个新创建的包了，使用命令 `npm install git@gitlab.com:yunnysunny/myecho.git#v0.1.0`, 即可完成安装（注意井号后面是 tag 名称）。一般来说，如果是公司的开发人员的话，对于要安装的自定义依赖包是有项目权限的，这时候不用更改任何 ssh 配置，就能完成对其的下载。但是如果要在服务器上运行这个安装命令的话，大家肯定不会把某一位同事的开发用的 git 密钥文件放置在服务器上，这时候就需要在服务器上配置部署密钥了，修改 \~/.ssh/config ，添加类似如下配置即可：

```
# 主机名要改成内网的 gitlab 地址
Host gitlab.com
  Hostname gitlab.com
  User git
  IdentityFile ~/.ssh/test_ssh
```


# 5 Node 中使用数据

我们在使用 node 处理业务逻辑的时候难免要和数据打交道，这时候数据库就派上用场了。在 node 中我们最常用的数据库有两种，redis 和 mongodb。本章也正是围绕这两个数据库展开讲解。

## 5.1 redis

[redis](https://redis.io) 提供 key-value 类型的存储结构，是一种内存数据库，因此数据查询速度特别快，而且它还可以通过配置来实现将数据定期备份到磁盘上的功能，一定程度上解决进程掉线后数据恢复的问题。

node 中推荐使用 [ioredis](https://github.com/luin/ioredis) 这个驱动来对 redis 进行操作。[redis](https://github.com/NodeRedis/node_redis) 这个驱动虽然使用人数更多，但是从 redis 3.x 开始增加了 cluster 模式，但是这个驱动并不支持这种模式，所以不推荐使用。

```javascript
var Redis = require('ioredis');
/**
 * 如果不传参数默认连接127.0.0.1:6379端口
 * */
var redis = new Redis(/*{"port" : 6379,"host" : "127.0.0.1",password: 'auth'}*/);//没有密码不需要传password参数
/*
var clusterRedis = [
    {
        "host":"127.0.0.1",            
        "port":6379
    },
    {
        "host":"127.0.0.1",            
        "port":6380
    }
];
var redis =  new Redis.Cluster(clusterRedis,{redisOptions:{password: 'auth'});//集群连接方式
*/

redis.set('foo', 'bar', function(err,reply) {
  console.log(err, reply);//正常情况打印 null 'OK'
});
redis.get('foo', function (err, result) {
  console.log(err,result);//正常情况打印 null 'bar'
});
```

**代码 5.1.1 redis命令基本演示**

redis 中大多数的命令格式都是这样的 `command key param1 prama2 ...` 对应 ioredis 中的函数就是 `redis.command(key, param1, param2, ...)` 比如说 **代码5.1.1** 中的栗子，我们在 redis-cli 中执行 `set foo bar` 命令就对应我们的 `redis.set('foo', 'bar')` 这行代码。注意到我们这里在接收处理结果的时候都是使用 callback 的方式，ioredis 内部也支持 promise 方式来接收处理结构，你只需要将回调函数去掉，改成 then 函数：

```javascript
redis.set('foo','bar').then(function(reply) {

});
```

**代码 5.1.2 使用 promise 方式接收返回数据**

有时候我们在使用 redis 的时候，在一个处理逻辑中要连续发送多条 redis 命令，这时候你可以考虑用 ioredis 中提供的 pipeline 或者 multi 函数。

使用 pipeline 时 ioredis 内部将一系列指令缓存到内存，最后通过 exec 函数执行后打包发送到 redis 服务器，而且它支持链式的调用方式：

```javascript
redis.pipeline().set('foo', 'bar').get('foo').exec(function (err, results) {
});
```

**代码 5.1.3 pipeline 链式调用**

甚至可以在调用每个命令的时候都加一个回调函数，这里在get位置加一个回调函数：

```javascript
redis.pipeline().set('foo', 'bar').get('foo',function(err,result) {
    console.log('get foo',err,result);
}).exec(function (err, results) {
    console.log('with single callback',err, results);
});
```

**代码 5.1.4 pipeline 链式函数中加回调**

当然这里还有一种更加简洁的调用方式，就是都把参数放到数组里：

```javascript
redis.pipeline([
    ['set','foo','bar'],
    ['get','foo']
]).exec(function(err,results) {
    console.log('array params',err,results);
});
```

**代码 5.1.5 pipeline 数组参数调用方式**

multi 函数跟 pipeline 函数的区别是，multi 提供了事务的功能，提交到 redis 服务器的命令的会被依次执行，pipeline 则是批量执行一批提交一批指令，但是在 redis 内部都是独立执行的，没有先后顺序，只是最终服务器将所有处理结果一起返回给了调用者。不过要想完全保证事务的原子性，我们还需要使用 watch 函数，防止我们在事务中操作一个事务的过程中，当前操作的某一个键值又被其他连接的客户端给修改了：

```javascript
redis.watch('foo');
redis.multi().set('foo', 'bar').get('foo').exec(function (err, results) {
    redis.unwatch();
    console.log('chain',err, results);
});
```

**代码 5.1.6 multi 事务操作代码**

最后一件需要重点指明的事情是，如果你当前使用了 cluster 方式连接 redis，那么最好不要使用 pipeline 和 multi 因为，ioredis 在调用这两个函数的时候，仅仅会往一个节点发送指令，但是你又不能保证你这里面操作的所有键值都在一个节点上，所以说调用这两个函数的时候很有可能会失败。

## 5.2 mongodb

[mongodb](https://www.mongodb.com/)官方提供了 Node.js 的 mongodb 驱动，不过鉴于其提供驱动的功能太过于简单，所以又涌现了许多基于官方驱动上开发的第三方驱动。下面要讲一个使用广泛的第三方驱动， [mongoose](https://mongoosejs.com/) 。

### 5.2.1 mongoose

前面讲了 mongskin ，算是 mongodb 知识点的开胃菜， mongoskin 中的函数绝大部分和 mongodb 命令行是类似的。下面要讲的 mongoose 却稍有不同，因为其有一个 ODM (**O**bject **D**ata **M**odel) 的概念，类似于 [hibernate](https://hibernate.org/) 开发中用到的 [ORM (**O**bject **R**elational **M**apping)](https://zh.wikipedia.org/wiki/%E5%AF%B9%E8%B1%A1%E5%85%B3%E7%B3%BB%E6%98%A0%E5%B0%84) 的概念，它提供了一种将 mongodb 中字段映射为 JavaScript 对象属性的能力。如果我们用 mongoose 来实现一系列的增删改查操作，就必须先定义一个 Schema，不过下面要先讲怎样在 mongoose 中建立连接，否则接下来的例子就没法运行了：

```javascript
var mongoose = require('mongoose');

mongoose.connect('mongodb://localhost/live', {/*user:'username',pass:'password'*/}); // connect to database
```

**代码 5.2.1.1 mongoose 建立连接代码**

在 mongoose 中使用 [connect](https://mongoosejs.com/docs/connections.html) 函数可以初始化 mongodb 连接，第一个参数代表 mongodb 的连接字符串，第二个参数存放连接控制参数，比如说用户名、密码之类的。其实第一个字符串中有更多连接参数控制，可以参考 mongodb 的 [官方文档](https://docs.mongodb.com/manual/reference/connection-string/)，其中就包括用户名和密码信息（格式为 `mongodb://username:password@host:port/database?options...`），但是如果你的密码中有特殊字符的话（比如说`@`），就比较难办了，所以将用户名和密码放到第二个参数中比较保险。

接下来就将mongoose中非常之重要的 Schema，首先直接构造一个我们在 5.2.1 小节中使用过的 article 的schema 声明：

```javascript
var mongoose = require('mongoose');
require('./conn');//代码5.2.1.1对应的代码

var Schema = mongoose.Schema;

var articleSchema = new Schema({
  name:  String,
  content:   String,
  comments: [{ body: String, date: Date }],
  create_at: { type: Date, default: Date.now }
});
var Article = mongoose.model('article', articleSchema);
```

**代码 5.2.1.2 声明 Schema**

通过以上代码可以总结出 shema 干的事情就是把数据库的各个字段的数据类型定义出来，最后我们还通过 model 函数获得了一个 mongoose 中的 Model 类，mongoose 的增删改查都通过这个类来进行。注意第一个参数代表表名。

```javascript
new Article({
    name:'chapter5',
    content:'Express.js 基础',
    comments : [
        {body:'写的不多',date:new Date('2016-10-11')},
        {body:'我顶',date:new Date('2017-01-01')}
    ],
    create_at:new Date('2016/07/03')
}).save(function(err,item) {
    console.log(err,item);
});
```

**代码 5.2.1.3 mongoose 插入操作**

为啥说 model 函数得到的是类呢，通过 **代码 5.2.1.3** 就可以看出，我们通过 new 生成一个对象实例，然后调用其 save 函数将其插入数据库。如果我们将 `create_at` 属性去掉，那么其值就会自动取当前时间。不过等你执行完上述代码后，查看数据库，咦，surprise，数据库里竟然多了一个名字叫 articles 的表，不是说 model 的第一个参数是执行关联的表明吗，明明在 代码 5.2.1.2 中指定的表明是 article 啊？是的，不要惊讶，mongoose 默认就是这么设计的，如果你想绑定到一个自定义的一个表明上，可以在实例化 Schema 的时候，传入一个可选参数：

```javascript
var articleSchema = new Schema({/*此处省略字段定义*/},{collection:'article'});
```

这样将 articleShema 插入model 后得到的 Article 就绑定表 article 上了。

说了插入单条，再说一下批量插入，这时候使用 [insertMany](https://mongoosejs.com/docs/api.html#model_Model.insertMany) 函数即可：

```javascript
Article.insertMany([
    {name:'chapter1',content:'Node.js 简介1',create_at:new Date('2016/07/01')},
    {name:'chapter1',content:'Node.js 简介2',create_at:new Date('2016/07/01')},
    {name:'chapter1',content:'Node.js 简介3',create_at:new Date('2016/07/01')},
    {name:'chapter2',content:'Node.js 基础4',create_at:new Date('2016/07/02')},
    {name:'chapter2',content:'Node.js 基础5',create_at:new Date('2016/07/02')}
],function(err,ret) {
    console.log('插入数组',err,ret);
});
```

**代码 5.2.1.4 mongoose 批量插入操作**

mongoose 的修改操作和官方 API 差不多：

```javascript
Article.update({name:'chapter2'},{
    $set:{content:'Node.js 入门'}
},function(err,ret) {
    console.log('更新单条数据',err,ret);
});
Article.update({name:'chapter2'},{
    $set:{content:'Node.js 入门'}
},{multi:true},function(err,ret) {
    console.log('更新多条数据',err,ret);
});
```

**代码 5.2.1.5 mongoose 修改操作**

不过它的删除稍微有些不同，就是删除的时候仅仅只能指定一个查询参数，如果你想仅仅删除一条的话，那就需要先查询出来，然后再删除。

```javascript
Article.findOne({name:'chapter1'}).remove().exec(function(err,ret) {
    console.log('删除数据',err,ret);
});
Article.remove({name:'chapter1'},function(err,ret) {
    console.log('删除数据',err,ret);
});
```

**代码 5.2.1.6 mongoose 删除操作**

上面总结了一下 mongoose 的一些基本用法，不过前面的描述还不足以体现 mongoose 的强大，下面讲到的一些高级用法，绝对能让你感到惊艳。

首先 mongoose 提供了中间件（ middleware ）的功能，我们可以在执行数据命令前和执行后添加钩子函数，先上代码：

```javascript
var mongoose = require('mongoose');
require('./conn');//代码6.2.2.1对应的代码

var Schema = mongoose.Schema;

var articleSchema = new Schema({
  name:  String,
  content:   String,
  comments: [{ body: String, date: Date }],
  create_at: { type: Date, default: Date.now }
});

articleSchema.pre('save',function(next) {
    this.content = this.name  + '\n' + this.content;
    next();
});

articleSchema.post('save', function(doc) {
    console.log('%s has been saved', doc._id);
});

var Article = mongoose.model('article', articleSchema);

new Article({
    name:'chapter5',
    content:'Node 中使用数据库',
    comments : [
        {body:'写的不多',date:new Date('2016-10-11')},
        {body:'我顶',date:new Date('2017-01-01')}
    ],
    create_at:'2017-02-11'
}).save(function(err,item) {
    console.log(err,item);
});
```

**代码 5.2.1.7 save 的中间件函数演示**

我们创建了一个 article 的 schema 定义，同时定义了两个中间件。通过 `pre('save')` 操作，我们在文章的第一行拼接了文章的标题，然后注意一定要调用 `next` 函数，否则当前数据库操作就不会得到执行。通过 `post('save')` 操作用来在数据库操作完成之后执行一些级联操作，这里我们简单的打印了一下日志。这两个中间件函数会先于 `save` 函数的回调函数前执行。

在调用 save 函数时，mongoose 中还提供了一个 validate 中间件，他会在 pre('save') 之前被触发，用来校验传入 save 函数的各个属性是不是合法：

```javascript
articleSchema.pre('validate',function(next) {
    if (/<script>/.test(this.content)) {
        return next(new Error('文章内容非法'));
    }
    next();
});
new Article({
    name:'chapter5',
    content:'Node 中使用数据库<script>alert(document.cookie)</script>',
}).save(function(err,item) {
    console.log(err,item);
});
```

**代码 5.2.1.8 save 的 validate 中间件函数演示**

上面的代码执行后，会抛出异常，因为我们的 article content 字段中包含 script 标签。令人欣喜的是，mongoose 还提供将 validate 中间件直接加到 schema 定义上的功能：

```javascript
var mongoose = require('mongoose');
require('./conn');//代码6.2.2.1对应的代码

var Schema = mongoose.Schema;

var articleSchema = new Schema({
    name:  {
        type:String,
        required: [true,'必须提供文章标题'],
        maxlength : [50,'文章标题不能多于50个字符']
    },
    isbn : {
        type:String,
        unique:true,
        sparse: true
    },
    content:  {
        type:String,
        validate:{
            validator : function() {
                return !(/<script>/.test(this.content));
            },
            message : '文章内容非法'
        }
    },
    starts : {
        type:Number,
        min:0,
        max:[5,'最多只能给5颗星'],
        default:0
    },
    level : {
        type:String,
        enum:['专家推荐','潜力无限','家有作家初长成','我只是个小学生']
    },
    category : {
        type:String,
        enum:{
            values:['诗歌','散文','杂文','议论文','小说'],
            message:'当前标签不支持'
        }
    },
    cover_url : {
        type:String,
        match:[/^http(s?):\/\//,'封面图格式非法']
    },
    comments: [{ body: String, date: Date }],
    create_at: { type: Date, default: Date.now }
});

articleSchema.pre('save',function(next) {
    this.content = this.name  + '\n' + this.content;
    next();
});

articleSchema.post('save', function(doc) {
    console.log('%s has been saved', doc._id);
});

var Article = mongoose.model('article', articleSchema);

new Article({
    name:'chapter5',
    content:'Node 中使用数据库<script>alert(document.cookie)</script>',
}).save(function(err,item) {
    if (err && err.name === 'ValidationError') {
        for (var field in err.errors) {
            var error = err.errors[field];
            console.error(error.message,error.path,error.value);
        }
    }
});
```

**代码 5.2.1.9 在 schema 中使用校验器**

mongoose 内建了好多校验器（validator），对于所有类型字段来说都可以使用 [required](https://mongoosejs.com/docs/api.html#schematype_SchemaType-required) 校验器，对于 Number 类型字段来说，可以使用 [min](https://mongoosejs.com/docs/api.html#schema_number_SchemaNumber-min) 和 [max](https://mongoosejs.com/docs/api.html#schema_number_SchemaNumber-max) 校验器，对于 String 类型字段来说，可以使用 [enum](https://mongoosejs.com/docs/api.html#schema_string_SchemaString-enum) [match](https://mongoosejs.com/docs/api.html#schema_string_SchemaString-match) [maxlength](https://mongoosejs.com/docs/api.html#schema_string_SchemaString-maxlength) [minlength](https://mongoosejs.com/docs/api.html#schema_string_SchemaString-minlength) 校验器。

所有校验器都可以设置在校验失败后的错误提示信息，如果相对某一个字段设置 required 约束，那么可以写成 `required:true` ，还可以进一步指定校验失败后的提示信息，也就是写成这样 `required:[true,'这个字段必须指定']` 。但是对于 enum 来说，由于本身定义的时候就是一个数组结构（参见上面代码中 `level` 字段的定义），所以 mongoose 内部在定义其 message 属性时使用这样一个 Object 结构：`{values:[/*枚举字段定义*/],message:'出错提示信息'}` 。

还记得在**代码 5.2.1.8**中我们自定义的那个 content 字段的校验中间件不？这个中间件可以直接写到 schema 定义中，在**代码 5.2.1.9**中的 content 字段中的 validate 属性，就能替换掉之前我们写过的校验中间件。

最终你在调用 save 函数之前，这层层的字段定义约束都会被执行，如果校验出错，那么 save 回调函数返回的第一个参数中的 name 属性的值将是 `ValidationError`，然后其 errors 属性中保存着字段的详细信息的一个 key-value数据结构，键名是出错的字段名，值是一个包含错误详情的对象，这个对象中 message 属性就是我们在 schema 中设置的出错信息， path 是出错的字段名，value 是引起出错的具体的设置的值。

最终需要注意，unique 这个约束并不是一个 ValidationError （实际上其 name 属性值为 MongoError），所以你 save 失败后得到的error 对象中没有errors 属性。unique 和 sparse 仅仅是 schema 调用 mongodb 的驱动创建了数据库索引而已。**代码 5.2.1.9** 中关于 isbn 的约束，也可以通过 schema 中的 [index](https://mongoosejs.com/docs/api.html#schema_Schema-index) 函数来实现：

```javascript
articleSchema.index('isbn',{unique:true,sparse:true});
```

**代码 5.2.1.10**

前面讲了许多 mongoose 的插入、修改之类的操作，一直没有提到查询操作，下面就来讲一下查询。

在讲查询之前，需要先将我们在代码5.2.1.9中定义的 articleSchema 进行一下扩充，增加下面这个字段：

```javascript
_author : {type:Schema.Types.ObjectId,ref:'user'},
```

**代码 5.2.1.11**

至于其中的 \_ref 属性是怎么回事，我们先卖个关子，一会儿再说。

mongoose 在查询方面，有好多细节做了优化，比如说在筛选返回字段的时候可以直接通过字符串来指定：

```javascript
Article.findOne({name:nameRand},'name -_id',function(err,item) {
  if (err) {
    return console.error('findOne',err);
  }
  console.log('findOne',item && item.name === nameRand);
});
```

**代码 5.2.1.12 mongoose 查询使用字符串筛选字段**

mongoose 的查询中的各个控制参数都可以链式的调用各个函数来解决，比如说上例中用到的字段筛选可以使用 [select](https://mongoosejs.com/docs/api.html#query_Query-select) 函数来替代，即改成 `Article.findOne({name:nameRand}).select('name -_id').exec(function(err,item) {});` 当中可以添加无数个链式函数来控制查询行为，比如说 [limit](https://mongoosejs.com/docs/api.html#query_Query-limit) [skip](https://mongoosejs.com/docs/api.html#query_Query-skip) [lean](https://mongoosejs.com/docs/api.html#query_Query-lean) 等等，最后以 [exec](https://mongoosejs.com/docs/api.html#query_Query-exec) 函数结尾添加回调函数。mongoose 查询默认返回的是 [MongooseDocuments](https://mongoosejs.com/docs/api.html#document-js) 类型对象，使用lean 函数后可以将其转成普通 javascript 对象：

```javascript
Article.find({name:/^name/}).select('_author').lean().exec(function(err,items) {
  if (err) {
    return console.error('find',err);
  }
  console.log('find',items);
});
```

**代码 5.2.1.13 mongoose 查询返回纯 javascript 对象**

转纯 javascript 对象的使用场景一般比较少见，当我们拿查询的结果作为参数来调用一些第三方库（比如说 [protobufjs](https://github.com/dcodeIO/protobuf.js) ）时，不调用lean的情况下会出错。

最后还要暴一下 mongoose 中的大杀器，就是联合查询，其实 mongdb 本身是没有联合查询功能的，这个功能是在 mongoose 层面延伸的功能：

```javascript
Article
  .findById(articleId)
  .select('name _author')
  .populate('_author','nickname -_id')
  .exec(function(err,item) {
  if (err) {
    return console.error('findById',err);
  }
  console.log('populate',item);
});
```

**代码 5.2.1.14 mongoose 联合查询功能**

还记得我们在**代码 5.2.1.10**中卖的关子不，我们看到其中有一个 \_ref 属性，它的作用就是告诉 mongoose \_author 字段的值对应 users 表中的主键字段，如果在查询的时候使用 populate 函数，则 mongoose 将在底层做两次查询（查询 articles 表 和 users 表），然后把查询结果合并。最终得到的结构演示如下：

```javascript
{ name: 'name0.6169953700982793',
  _author: { nickname: 'nick0.09724390163323227' },
  _id: 5916e9178be9f133b4798002 }
```

## 5.3 代码

本章代码参见这里：<https://github.com/yunnysunny/nodebook-sample/tree/master/chapter5>


# 6 Express 介绍

提到node，那么就不得不提大名鼎鼎的 [express](https://expressjs.com/)，作为一个 web framework，它几乎满足了你所有的愿望 。 本篇的内容主要讲述express的基本使用。

## 6.1 Express 安装

当然作为一个 web framework，必然要牵扯到各种配置。聪明人肯定不是把所有配置代码从头到尾敲出来， 这就要提到 [express-generator](https://github.com/expressjs/generator) 。首先运行

`npm install -g express-generator`

来安装，这里用`-g`参数来将其安装为全局位置，因为这个样子我们就能将其安装后生成的可执行程序添加到环境变量中了。

接着运行`express -e first-app && cd first-app`，其中命令中`-e`参数是说使用 [ejs](https://github.com/tj/ejs) 模板引擎来渲染视图。 `first-app`就是我们生成程序生成的目录，紧接着我们通过 `cd` 命令进入了这个目录。最后我们运行 `npm install` 命令来安装所需依赖。 最终在图形化界面中进入这个目录，会看到如下文件列表：

```
--bin
----www
--public
----images
----javascripts
----stylesheets
--routes
--views
--app.js
--package.json
```

**目录 6.1.1**

## 6.2 Express 基本操作

express的所有配置信息在app.js中：

```javascript
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var users = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use('/', routes);
app.use('/users', users);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

// error handlers

// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
  app.use(function(err, req, res, next) {
    res.status(err.status || 500);
    res.render('error', {
      message: err.message,
      error: err
    });
  });
}

// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
  res.status(err.status || 500);
  res.render('error', {
    message: err.message,
    error: {}
  });
});


module.exports = app;
```

**代码 6.2.1 app.js**\
express处理网络请求，是以一种被称之为 `middlewave(也翻译为中间件)` 机制进行的，即网络请求先经过第一个middlewave，如果处理完成则直接返回，否则调用 `next()` 函数将当前请求丢给下一个middlewave进行处理。我们看到app.js中有很多 `app.use`函数的调用，正是这个函数配置了一个个的middleware。\
其中`app.use(bodyParser.json());`处理请求头为`application/json`的数据，其实这个 middleware 一般用不到，除非是直接通过 post 方式来提交 JSON 字符串。

`app.use(bodyParser.urlencoded({ extended: false }));`这句话是处理form表单数据的，这个用处就大了，我们在 html 中的 method 属性为 post 的 form 表单，提交后的数据就会走到这个 middleware 进行处理，处理完成后就能通过 req.body 来读取表单中的数据了。

`app.use(cookieParser());`是用来处理cookie的，没有这个 middleware 的话，无法读取 http 请求中的cookie数据。

`app.use(express.static(path.join(__dirname, 'public')));`是定义从 `public` 路径读取静态文件。之前讲过当前项目目录中存在`public` 文件夹，假设我们在其下 `javascripts` 目录中放置一个 `jquery.js` 文件，那么我们在html中就可以这么引用这个静态文件：

`<script type="text/javascript" src="/javascripts/jquery.js"></script>`

其他静态文件依次类推。

上面讲了 express-generator 默认添加的若干 middleware, 不过如果是做网站项目的话，还缺少对于session的支持，这个要在后面单独讲。接下来是很重要的路由映射部分，因为项目中的url映射都是 在这里配置的。我们这里只看 `routes/index.js` 文件：

```javascript
var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('index', { title: 'Express' });
});

module.exports = router;
```

**代码 6.2.2 routes/index.js**

> 在express 3.x中，定义路由需要使用到在`app.js`中定义的`app`对象，写成`app.get('/path',function(req,res){})`的样式，不过经过本人测试 在express 4.x中依然可用。如果采用express 3.x的编写方式，那么routes/index.js可以写成这样：

```javascript
module.exports = function (app) {
    app.get('/', function(req, res) {
      res.render('index', { title: 'Express' });
    });
}
```

对应在app.js中，需要将`app.use('/',routes);`替换成`routes(app);`。两种写作手法而已，看个人喜好。

我们看到这里仅仅只有一个根路径的映射，假设我们网站上还有一个 `/about` 的路径。那么就可以在`index.js`中再追加一条：

```javascript
router.get('/about', function(req, res) {
  res.render('index', { title: 'about' });
});
```

**代码 6.2.3**\
app.js中定义的 `app.use('/',routes);`，其实其中的`/`仅仅是定义路由的路径前缀而已，从这种意义上来讲，`routes/index.js` 和 `routes/user.js` 的代码是可以合并的。我们删除`user.js` 文件，然后在index.js中追加一段代码：

```javascript
/* GET users listing. */
router.get('/user', function(req, res) {
  res.send('respond with a resource');
});
```

**代码 6.2.4**\
这样我们就可以删除掉`app.use('/users', users);`了。其实如果看完上述关于路由器的介绍，熟悉express 3的用户会发现，除了语法和3.x不一样以外，功能上没啥不同。不过事实并非如此，`index.js`中的router对象还可以直接用来定义middleware，我们在 `index.js` 开头再添加一段代码：

```javascript
// middleware specific to this router
router.use(function timeLog(req, res, next) {
  console.log('Time: ', Date.now());
  next();
});
```

**代码 6.2.5**\
那么上述代码定义的这个middleware就仅仅对 `index.js` 内部定义的地址起作用，对于这个路由器文件外的代码是不起作用的，这个设计就比较灵活了。之前咱们在 `app.js` 中通过 `app.use` 来定义middleware，那么理论上所有的请求都要经过这种middleware进行处理的，除非在经过这个middleware之前，已经有其他的middleware把HTTP请求处理完成了。\
最后看错误捕获这一块了，`app.js`中对于代码捕获区分了两种情况，如果当前是开发环境就在出错的时候打印堆栈，否则只显示错误名称。我们现在修改一下 `/user` 的路由代码：

```javascript
router.get('/user', function(req, res) {
  console.log(noneExistVar.pp);
  res.send('respond with a resource');
});
```

**代码 6.2.6**\
接着运行项目（关于如何运行项目，将在下面讲到），然后在浏览器中打开<http://localhost:3000/user，浏览器直接显示错误堆栈：>

```
noneExistVar is not defined

ReferenceError: noneExistVar is not defined
    at D:\code\eapp\first-app\routes\index.js:15:14
    at Layer.handle [as handle_request] (D:\code\eapp\first-app\node_modules\express\lib\router\layer.js:95:5)
    at next (D:\code\eapp\first-app\node_modules\express\lib\router\route.js:131:13)
    at Route.dispatch (D:\code\eapp\first-app\node_modules\express\lib\router\route.js:112:3)
    at Layer.handle [as handle_request] (D:\code\eapp\first-app\node_modules\express\lib\router\layer.js:95:5)
    at D:\code\eapp\first-app\node_modules\express\lib\router\index.js:277:22
    at Function.process_params (D:\code\eapp\first-app\node_modules\express\lib\router\index.js:330:12)
    at next (D:\code\eapp\first-app\node_modules\express\lib\router\index.js:271:10)
    at Function.handle (D:\code\eapp\first-app\node_modules\express\lib\router\index.js:176:3)
    at router (D:\code\eapp\first-app\node_modules\express\lib\router\index.js:46:12)
```

**输出 6.2.1**\
这说明，程序默认走到 `app.get('env') === 'development'` 这个条件中去了。`app.get('env')` 其实是读取的环境变量 `NODE_ENV` ,这是一个express专用的环境变量，express官方推荐在生产环境将其设置为 `production`（参考[这里](https://expressjs.com/en/advanced/best-practice-performance.html#env)）后会带来三倍的性能提升。官方推荐使用 `systemd` 或者 `Upstart`来设置环境变量，不过如果你的程序不是开机自启动的话，直接配置 `.bash_profile`文件即可，也就是说直接在该文件中添加 `export NODE_ENV=production`。

## 6.6 模板引擎

在代码6.2.2中遇到了一个`render`函数，这个函数就是express中用于加载模板的函数。通过代码也可以大体看出，第一个参数是模板的名字，它所代表的文件位于视图文件夹`views`目录下的`index.ejs`（`ejs`文件后缀是`ejs`模板引擎的默认后缀）；而第二个参数即为传递给这个模板的参数。 接着看一下在模板中，是怎样使用刚才传递的那个`titile`参数的，打开`views`文件夹下的`index.ejs`：

```html
    <!DOCTYPE html>
    <html>
      <head>
        <title><%= title %></title>
        <link rel='stylesheet' href='/stylesheets/style.css' />
      </head>
      <body>
        <h1><%= title %></h1>
        <p>Welcome to <%= title %></p>
      </body>
    </html>
```

可以看到使用<%=titile%>的方式就可以把之前render函数中传递的title参数读取出来。 扩展一下在ejs中还有两个常见的标签： <%- %>:读取变量中的值且对于变量中的html特殊符号（比如<、>、&、”等）不进行转义，如果使用<%=%>就会把特殊符号转义， <%%>:写在这个标签里的语句会直接当成代码来解析，比如说如下代码：

```
<% if (status == 0) { %>
<input  type="button" value="启用" />
<% } else { %>
<input  type="button" value="禁用" />
<% } %>
```

## 6.7 Express 中的GET和POST

接下来的内容来讲一下express中怎样使用get和post，首先我们在views文件夹下新建目录user,然后在user目录下新建文件sign.ejs(当然你也可以把它当成静态页，放到public中；但是正常环境下，对于html一般都是通过视图的方式来加载)。

```html
    <!DOCTYPE html>
    <html>
    <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Node.js注册演示</title>
    </head>
    <body>
    <h1>注册</h1>
    <form id="signup" method="get" action="/users/do/sign">
    <label>   帐号：</label><input type="text" name="username" />
    <label>   Email：</label><input type="text" name="email" />
    <input type="submit" value="注册" /><br>
    </form>
    </body>
    </html>
```

**代码6.7.1 sign.ejs代码**

这里表单method是get（虽然一般情况下网服务器添加数据都是用post方式，但是这里为了演示方便，现将其写成get）。接下来看一下express中怎样在GET方式下获取表单中的数据。为了演示用户注册这个流程，我们新建`controllers`目录，在里面创建user\_controller.js文件：

```javascript
    exports.showSign = function(req, res) {
        res.render('user/sign');
    }

    exports.doSign = function(req, res) {
        var name = req.query.name;
        var email = req.query.email;
        res.send('恭喜' + name +'注册成功，你的邮箱为:'+email);
    }
```

**代码6.7.2 user\_controller.js文件中处理函数**

> web编程中广泛使用MVC（模型Model、视图View、控制器Controller，这三个单词的缩写）的设计模式，在项目创建`controllers`正是为了符合这一模式，同时你还需要创建一个models文件夹，专门负责处理数据。具体的使用流程是这样的：`controllers`里面放置请求处理的代码，即接收请求参数，对其进行有效性校验，然后调用`models`里面的代码进行数据操作（比如说数据库的增删改查等操作），拿到处理结果后加载视图进行渲染。关于MVC的介绍，可以参见[维基百科](https://zh.wikipedia.org/wiki/MVC)。

然后添加相应的路由如下：

```javascript
    router.get('/users/sign', user.showSign);
    router.get('/users/do/sign', user.doSign);
```

**代码6.7.3 新增路由配置**

运行`npm start`，即可查看效果，打开<http://localhost:3000/users/sign> ，可看到如下界面：

![注册显示界面](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waM4cV8e0WHdi2lQo%2Fshow.png?generation=1582638397767573\&alt=media)

输入数据后，点击注册，显示注册成功提示信息。

这就完成了get操作，但是前面提到了类似于这种注册操作一般都是用post的，将上面的代码改成post是很简单的，只需在代码代码6.7.1 中将表单的 method 改成 post ，代码6.7.2中获取请求数据是这么写的：

```javascript
    var name = req.query.name;
    var email = req.query.email;
```

如果改成post，只需将其改为

```javascript
    var name = req.body.name;
    var email = req.body.email;
```

## 6.8 Express AJAX 应用示例

还是上面的例子，只不过这次换成用ajax来提交数据，我们在views/user文件夹下再新建文件sign2.ejs：

```html
    <!DOCTYPE html>
    <html>
    <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Node.js注册演示</title>
    <script language="javascript" src="/javascripts/jquery-1.10.2.js"></script>
    </head>
    <body>
    <h1>注册</h1>
    <form id="signup" method="post" action="/users/sign2">
    <label>帐号：</label><input type="text" name="name" /><br />
    <label>Email：</label><input type="text" name="email" /><br />
    <input type="submit" value="注册" /><br>
    </form>
    <script language="javascript">
        $(document).ready(function() {
            $('#signup').submit(function() {
                $.post($(this).attr('action'),$(this).serialize(),function(result) {
                    if (result.code == 0) {
                        alert('注册成功');
                    } else {
                        if (result.msg) {
                            alert(result.msg);
                        } else {
                            alert('服务器异常');
                        }
                    }
                },'json')
                return false;
            });
        });
    </script>
    </body>
    </html>
```

**代码6.8.1 sign2.ejs**

为了使用ajax，我们引入了jquery，并将jquery-1.10.2.js放到了public/javascripts文件夹下，为了演示ajax和普通请求处理的区别，这里仅仅给出处理post请求的代码：

```javascript
    exports.doSign2 = function(req, res) {
        var name = req.body.name;
        var result = {};
        if (!name) {
            result.code = 1;
            result.msg = '账号不能为空';
            res.send(result);
            return;
        }
        var email = req.body.email;
        if (!email) {
            result.code = 2;
            result.msg = '邮箱不能为空';
            res.send(result);
            return;
        }
        res.send({code : 0});
    }
```

**代码6.8.2 ajax后台处理代码**

express中res的send函数中传一个json对象，则发送给浏览器的时候会自动序列化成json字符串。\
我们继续添加两个路由：

```javascript
router.get('/users/sign2', user.showSign2);
router.post('/users/do/sign2', user.doSign2);
```

**代码6.8.3 ajax相关路由**\
重启项目后访问地址<http://localhost:3000/users/sign2> 即可进行测试。

## 6.9 代码

本章用的部分代码：<https://github.com/yunnysunny/expressdemo/tree/master/chapter6>


# 7 Express 进阶

第5章讲了Express的入门知识，这一节趁热打铁要讲一下高级技术。

## 7.1 使用 session

对于一个网站来说，一个不可避免的问题就是用户登录，这就牵扯到 session 问题了。为此我们需要在app.js中引入两个middleware，[cookie-parser](https://www.npmjs.com/package/cookie-parser)和[express-session](https://www.npmjs.com/package/express-session),上一章的代码6.2.1已经介绍过cookie-parser，接下来重点介绍一下`express-session`,在app.js中添加如下代码：

```js
var cookieParser = require('cookie-parser');
var session = require('express-session');
var RedisStore = require('connect-redis')(session);
var redis = require('redis');

app.use(cookieParser());//
app.use(session({
    secret: 'GG##@$',
    cookie:{domain:'localhost'},
    key:'express_chapter6',
    resave:false,
    saveUninitialized:false,
    store: new RedisStore({
        client:redis.createClient(6379, '127.0.0.1');,
        ttl:3600*72,
        db:2,
        prefix:'session:chapter6:'
    })

}));
```

**代码 7.1.1 引入session**\
其实express默认的session是存储在内存里的，但是这种做法不适合在生产环境使用，首先node如果使用cluster模式的话，内存无法共享，也就是说只能使用单进程；其次，如果在线人数一直增多的话，会造成内存猛增。所以这里的`store`参数使用了redis，这同样也意味着你需要在你本地（或者远程机器上）启动redis服务，否则程序会报错。 session 函数的 `key` 参数代表生成cookie的名称。`resave`参数设置默认为`true`，代表每次请求结束都会重写`store`中的数据，不管当前的session有没有被修改。`saveUninitialized`参数值默认为`true`，代表将未赋值过的session写入到store，也就是说假设我们的网站需要登录，那么在未登陆之前，也会往`store`写入数据。所以我们将`resave`和`saveUninitialized`都设置为了false。

> 如果你对 session 原理不是很清楚的话，可以参见我的博文 [session的安全性](https://blog.whyun.com/posts/session/)，里面提到了session的基本原理，安全性及攻击防范。

为了减少篇幅，给出的代码都是片段形式：

```html
<form method="post" action="/user/login" id="loginForm">
    <p><input name="username" /><label for="username">用户名</label><p/>
    <p><input name="password" type="password" /><label for="password">密码</label><p/>
    <p><input type="submit" value="登陆" /><p/>
</form>
<script src="//upcdn.b0.upaiyun.com/libs/jquery/jquery-1.10.2.min.js" type="text/javascript"></script>
<script type="text/javascript">
    $(document).ready(function() {
        $('#loginForm').submit(function() {
            var $this = $(this);
            $.ajax({
                method:$this.attr('method'),
                url:$this.attr('action'),
                data:$this.serialize(),
                dataType:'json'
            }).done(function(result) {
                if (result.code == 0) {
                    return location.href = '/user/admin';
                }
                alert(result.msg || '服务器异常');
            }).fail(function() {
                alert('网络异常');
            });
            return false;
        });
    });
</script>
```

**代码7.1.2 登陆前端代码**

```js
exports.login = function(req, res) {
    var _body = req.body;
    var username = _body.username;
    var password = _body.password;
    if (username === 'admin' && password === 'admin') {
        req.session.user = {account:username};
        return res.send({code:0});
    }
    res.send({code:1,msg:'用户名或者密码错误'});
}
```

**代码7.1.3 登陆后端代码**\
在`代码7.1.3`中通过`req.session.user`来给session增加一个user的属性，在`代码7.1.2`中登陆成功后要跳转到`/user/admin`地址上去，我们接下来看这个地址映射的后端代码：

```js
exports.admin = function(req, res) {
    var user = req.session.user;
    res.render('user/admin',{user:user});
}
```

**代码 7.1.4 读取session**\
通过`req.session.user`，就可以方便的将之前存储的`user`属性给读取出来。

## 7.3 改善我们的登陆

在7.2中，我们已经讲述了怎样使用mongodb了，下面就有机会对于我们7.1中提到的登陆进行改进了。虽然登陆在前端页面登陆仅仅是一个表单，但是在后台处理的流程就不是那么简单。这次我们决定读取数据库中的账号信息进行登陆验证，为此我们创建文件夹`models`，在其内新建文件`user_model.js`：

```js
var crypto = require('crypto');
var collection = require('./index');
var users = collection.users;

var passwordValid = exports.passwordValid = function(passwordInput,passwordDb) {
    return crypto.createHash('sha256').update(passwordInput).digest('base64') === passwordDb;
}

exports.loginCheck = function(username,password,callback) {
    users.findOne({account:username},function(err,item) {
        if (err) {
            console.error('查询用户时失败',err);
            return callback('查询用户时失败');
        }
        if (!item) {
            return callback('当前用户不存在');
        }
        if (!passwordValid(password,item.passwd)) {
            return callback('用户名或者密码错误');
        }
        item.passwd = undefined;
        callback(false,item);
    });
};
```

**代码 7.3.1 登陆验证逻辑**

> 其实单纯用md5/sha1/sha256这些算法来说，都存在被破解的可能性，国内有网站<https://cmd5.com> 几乎可以破解一切弱密码，解决方案就是使用更长的密码（可以通过用户名和密码进行拼接来计算哈希值），或者使用hmac算法。这里为了演示，使用了最简单的方式。

那么现在控制器中的代码就可以这么写：

```js
exports.loginWithDb = function(req, res) {
    var _body = req.body;
    var username = _body.username;
    var password = _body.password;
    if (!username) {
        return res.send({code:1,msg:'用户名不能为空'});
    }
    userModel.loginCheck(username,password,function(err,item) {
        if (err) {
            return res.send({code:1,msg:err});
        }
        req.session.user = item;
        res.send({code:0});
    });
};
```

**代码 7.3.2 登陆验证控制器代码**\
我们在路由器中增加一个链接 `/user/login-with-db` 指向代码 7.3.2 中的控制器函数，修改代码7.1.2中的表单提交地址即可。

## 7.4 使用拦截器

之前的章节中介绍过express的middleware，翻译一下就是中间件，下面的内容其实是做一个中间件，但是为啥我给它起名叫拦截器呢，因为我认为对于业务逻辑处理叫拦截器更贴切，因为我理解的middleware仅仅负责解析http数据，不处理业务逻辑。仅仅是个人见解。\
之前我们已经做了登陆操作，但是对于一个网站的若干地址（比如说后台地址），不登录是没法用的，我们需要用户在加载这些地址的时候，如果检测到当前处于未登录状态，就统一跳转到登陆页。在每一个控制器中都做一遍登陆状态判断来决定是否跳转，显然是一个笨拙的方法。但是如果使用了拦截器，一切问题就显得简单了。\
我们新建文件夹`filters`，然后在其内新建文件`auth_filter.js`:

```js
const ERROR_NO_LOGIN = 0xffff0000;
module.exports = function(req, res, next) {
    var path = req.path;
    if (path === '/' || path === '/user/login') {//这些路径不需要做登陆验证
        return next();
    }
    if (req.session && req.session.user) {//已经登陆了
        return next();
    }
    //以下为没有登陆的处理逻辑
    if (req.xhr) {//当前请求为ajax请求
        return res.send({code:ERROR_NO_LOGIN,msg:'尚未登陆'});
    }
    res.redirect('/');//普通请求
};
```

**代码 7.4.1 授权拦截器逻辑**\
同时我们在`app.js`中引入这段代码：

```js
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var session = require('express-session');
var RedisStore = require('connect-redis')(session);
var redis = require('redis');

var routes = require('./routes/index');
var authFilter = require('./filters/auth_filter');

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use(session({
    secret: 'GG##@$',
    cookie:{domain:'localhost'},
    key:'express_chapter6',
    resave:false,
    saveUninitialized:false,
    store: new RedisStore({
        client:redis.createClient(6379, '127.0.0.1'),
        ttl:3600*72,
        db:2,
        prefix:'session:chapter6:'
    })

}));
app.use(authFilter);
app.use('/', routes);
```

**代码 7.4.2 引入授权拦截器**\
将其放到session中间件的后面是由于，我们在这个拦截器中需要读取`req.session`变量，如果放到session中间件前面，则这个变量不存在。\
现在我们在未登录的情况下访问<http://localhost:3000/user/admin> ，则会直接跳转到登陆页。\
本章节代码可以从这里获取：<https://github.com/yunnysunny/expressdemo/tree/master/chapter7> 。


# 8 Node.js 单元测试

## 8.1 单元测试

### 8.1.1 引子

对于一个程序员来说不仅要写代码，还要验证一下代码写得到底对不对，写单元测试就是一个通用且有效的解决方案。单元测试很重要，可以将错误扼杀在摇篮中，如果你认为没有写单元测试也过得很好，也许等我介绍完 [mocha](https://mochajs.org/) 之后，你会改变主意的。\
下面给出一个栗子，假设有一个计算器的项目，不过这个项目周期比较长，后期需要增加更多的人手，所以对于每一个模块都要有相应的测试用例。下面是程序员小明开工后写的第一个函数，一个加法函数：

```javascript
function add(a,b) {
    return a+b;
}
```

**代码 8.1.1.1 add函数**\
如果你之前没有接触过单元测试，可能写出的测试用例是这样的：

```javascript
var s = add(1,2);
if (s == 3) {
    console.log('恭喜你，加法测试成功了');
} else {
    console.error('哎呦，加法测试出错了啊');
}
```

**代码 8.1.1.2 不使用测试框架的测试用例**\
接着又需要写减法函数、乘法函数、除法函数，但是随着模块的增加，就会遇到一个问题，如果按照代码8.1.2的模式的话，一则输出格式比较乱，二则缺失测试结果统计。这时候大名鼎鼎的 `mocha` 就要闪亮登场了。\
有了 `mocha` ,测试结果输出格式化和测试结果统计的需求就可以迎刃而解了。比如说对于上面那个加法测试用例，就可以这么写了：

```javascript
var assert = require('assert');
describe('Calculator', function() {
  describe('#add()', function() {
    it('should get 3 when 1 add 2', function() {
      assert.equal(3, add(1,2));
    });
  });
});
```

**代码 8.1.3 使用mocha改写测试用例**

> mocha 在使用的时候，可以选择全局安装的模式，也可以安装为当前项目的开发依赖包（即在安装的时候使用`--save-dev`参数），不过每个项目中使用的版本可能会略有差异，并且部分 IDE 对于全局安装的支持并不是很好，所以推荐安装为当前项目的开发依赖。

我们将加法运算的代码封装在 `calculator.js` 中，并且提供一个 mocha 的测试文件 `calculator_test.js`:

```javascript
var assert = require('assert');
var calculator = require('./calculator');

describe('Calculator', function() {
  describe('#add()', function() {
    it('should get 3 when 1 add 2', function() {
      assert.equal(3, calculator.add(1,2));
    });
  });
});
```

**代码 8.1.4 文件calculator\_test.js部分代码**\
接着运行`node calculator_test.js`，不出意外会报错：

```
e:\kuaipan\code\node\myapp\chapter7\src\test\mocha>node calculator_test.js 
e:\kuaipan\code\node\myapp\chapter7\src\test\mocha\calculator_test.js:4   
describe('Calculator', function() {     
^                             
ReferenceError: describe is not defined  
    at Object.<anonymous> (e:\kuaipan\code\node\myapp\chapter7\src\test\mocha\calculator_test.js:4:1)  
    at Module._compile (module.js:456:26)           
    at Object.Module._extensions..js (module.js:474:10)     
    at Module.load (module.js:356:32)           
    at Function.Module._load (module.js:312:12)  
    at Function.Module.runMain (module.js:497:10)  
    at startup (node.js:119:16)     
    at node.js:929:3
```

**输出 8.1.1.1 命令 node calculator\_test.js 的输出**\
这是由于函数 `describe` 只有通过 mocha 命令运行时才存在，换成使用 `mocha calculator_test.js` 来运行则能看到正确结构：

```
e:\kuaipan\code\node\myapp\chapter7\src\test\mocha>mocha calculator_test.js                                
  Calculator                 
    #add()                    
      √ should get 3 when 1 add 2  

  1 passing (7ms)
```

**输出 8.1.1.2 命令 mocha calculator\_test.js 的输出**

### 8.1.2 断言

这里之所以使用 node 自带的 assert 包，是由于 mocha 仅仅是一个测试框架，本身并没有提供断言（Assertion）库。很多第三方的断言库都可以和 mocha 完美结合： **1.should.js**\
他使用的格式是 `(something).should` 或者 `should(something)`，更多使用方法还得参阅其 [github文档](https://github.com/Automattic/expect.js) ，例如上面我们使用 assert 进行判断的代码就可以写成:

```javascript
var should = require('should');

(calculator.add(1,2)).should.be.exactly(3).and.be.a.Number();
```

**代码 8.1.2.1 should判断1**\
或者：

```javascript
var should = require('should/as-function');

should(calculator.add(1,2)).be.exactly(3).and.be.a.Number();
```

**代码 8.1.2.2 should判断2**

> 上面仅仅是简单说明使用方法，完整的测试用例大家可以参见第七章源码`test/mocha/calculator_should1.js` 和 `test/mocha/calculator_should2.js`。

**2.expect.js**\
首先给出expect.js的 [github地址](https://github.com/Automattic/expect.js) ，下面是用expect重写的测试用例：

```javascript
var expect = require('expect.js');
expect(calculator.add(1,2)).to.be(3);
```

**代码 8.1.2.3 expect判断**\
**3.chai**\
[chai](https://www.chaijs.com/) 将前面提到的assert should expect融合到了一起，你仅仅需要使用 chai 这一个包就能享用以上三者的功能，所以前面讲到的三种判断在chai中是这么实现的：

```javascript
var chai = require('chai');

var assert = chai.assert;//use assert
assert.equal(3, calculator.add(1,2));

chai.should();//use should
(calculator.add(1,2)).should.be.equal(3).and.be.a('number');

var expect = chai.expect;//use expect
expect(calculator.add(1,2)).to.equal(3);
```

**代码 8.1.2.4 使用chai判断**\
这个 chai 还真是个大杀器呢。不过注意，在 chai 中下面关键字

```
to be been is that which and has have with at of same
```

只能作为属性使用，不能作为函数使用（除非你自己写代码把这些属性覆盖掉），所以 `to.be(3)` 要写作`to.equal(3)`，另外 chai 中也没有 `exactly` 这个函数，所以这里也是用 `equal` 来替代，同时在 chai 中 `a` 只能作为函数使用，其函数声明为 `a(type)` ,所以这里用了 `a('number')`，其他技术细节，请移步官方API [BDD部分](https://www.chaijs.com/api/bdd/)。

### 8.1.3 HTTP 测试

上面演示的是对程序本地调用的测试，不过很多情况下，我门的 API 是通过 HTTP 的形式对外提供访问的，比如说我们将上面提到的计算器的功能改为使用 HTTP 方式对外提供访问，这样的项目依然可以给出测试用例。\
HTTP请求和本地调用从流程是有很多不一样的地方的：当中隔着一个网络层，请求数据以 HTTP 报文的格式在网络上进行传输，服务器端解析 HTTP 报文内容后再调用本地 API 进行处理，然后再将处理结果通过 HTTP 报文返回给调用者。[supertest](https://github.com/visionmedia/supertest) 这个工具可以很好得在 mocha 中处理 HTTP 请求断言。

下面是一个 HTTP 接口：

```javascript
exports.doAdd = function(req, res) {
    var _body = req.body;
    var a = parseInt(_body.a, 10);
    if (isNaN(a)) {
        return res.send({code:1,msg:'a值非法'});
    }
    var b = parseInt(_body.b, 10);
    if (isNaN(b)) {
        return res.send({code:2,msg:'b值非法'});
    }
    res.send({code:0,data:a+b});
};
```

**代码 8.1.3.1 加法运算的 HTTP 接口**

对应的supertest的测试用例代码就是这样的：

```javascript
var request = require('supertest');
var app = require('../../app');

describe('POST /calculator/add', function() {
  it('respond with json', function(done) {
    request(app)
      .post('/calculator/add')
      .send({a: 1,b:2})
      .expect(200, {
          code:0,data:3
      },done);
  });
});
```

**代码 8.1.3.2 加法运算HTTP接口的单元测试代码**

> 此代码放置于第七章代码的目录 /src/test/http目录下，变量app其实是引用的项目根目录的app.js。

对于一个普通的网站来说，它的接口在访问之前，一定是经过授权的，否则就会将整个网站置于危险之中。用户名、密码登陆是我们常见的一种授权模式，为了简化我们的教程，我们姑且将 **代码7.1.3** 作为我们的登陆处理代码，那么登陆链接即为`/user/login`，管理后台首页为`/user/admin`。我们现在来测试登录到后台这个动作。

登录过程要牵扯到我们在第 6 章讲过的session的知识，但是 session 在前端对应的 sessionid 会写入 cookie 中，鉴于我们的测试运行环境是命令行，不是浏览器，这就需要我们自己来维护 cookie 的存储操作。同时我们还需要用到 mocha 中的钩子（Hook）函数，在这里用到的是 mocha 中的 before 函数，它表示在所有测试用例之前执行，当然还有 beforeEach 函数，它表示在每次测试用例执行前都执行一次。显然两者的区别是前者只在所有测试用例前执行一次，而后者要在每个测试用例执行前都要重新执行。先看这个钩子函数：

```javascript
var request = require('supertest');
var app = require('../../app');
var cookie = exports.cookie = '';

before(function(done) {
    request(app)
      .post('/user/login')
      .send({username:'admin', password:'admin'})
      .expect(200,{code:0})
      .end(function(err,res) {
          if (err) {
              return done(err);
          }
          var header = res.header;
          var setCookieArray = header['set-cookie'];

          for (var i=0,len=setCookieArray.length;i<len;i++) {
              var value = setCookieArray[i];
              var result = value.match(/^express_chapter7=([a-zA-Z0-9%\.\-_]+);\s/);
              if (result && result.length > 1) {
                  exports.cookie = cookie = result[1];
                  break;
              }
          }
          if (!cookie) {
              return done(new Error('查找cookie失败'));
          }
          done();
      });
});
```

**代码 8.1.3.3 钩子函数**\
这里我们使用全局变量 cookie 来存储我们提到的 sessionid ，在 supertest 的 end 函数中，我们读取响应体变量res中的 set-cookie 头信息，通过正则把 sessionid 读取出来。注意我们这里将 `cookie` 这个变量还专门做导出了，这样子我们就可以在其他测试文件中引用这个变量了。\
接着就可以使用这个读取到的cookie来进入后台了：

```javascript
var request = require('supertest');
var app = require('../../app');
var before = require('./login_before');

describe('Backend',function() {

    it('first test',function(done) {
        request(app)
        .get('/user/admin')
        .set('Cookie','express_chapter7=' + before.cookie)
        .expect(200,/<title>admin<\/title>/,done);
    });
});
```

**代码 8.1.3.4 请求中使用cookie**\
我们在模板 `user/admin.ejs` 有这么一句：`<title><%=user.account%></title>`，所以我们这里在测试时使用正则 `/<title>admin<\/title>/` 来验证是否真的进入后台了。 还有就是在读取 `cookie` 变量的时候没有直接在 require 完成之后立即读取，因为那个时候，这个变量还没有被赋值，而是在测试用例内部通过 `before.cookie` 读取。

> 注意，由于我们使用了 redis 来存储 session 数据，所以如果你忘记启动 redis 服务器的话，我们的登录操作会失败，而且在 mocha 中给出的报错提示是请求超时，这个问题比较隐蔽，大家一定要注意。

### 8.1.4 仿真测试

上面讲的所有的断言都是对结果进行断言，执行某一个函数，查看返回值是否符合预期，或者请求某一个接口看看响应内容是否符合预期。但是某些情况下，我们想对中间步骤进行测试，比如说在执行 `函数 A` 的时候，正常情况下其内部应该调用了 `函数 B`，我们想验证 `函数 B` 是否真的被调用了。

解决这个需求，就需要用到 [sinon](https://npmjs.com/package/sinon) 这个包。将代码 **代码 8.1.1.1** 稍作改造：

```javascript
function add(a,b) {
    console.log(a, b);
    return a+b;
}
```

**代码 8.1.4.1**

然后我们可以编写测试用例，用来验证 console.log 是否被调用了：

```javascript
var assert = require('assert');
var sinon = require('sinon');
var calculator = require('./calculator');

describe('Calculator sinon', function () {
    describe('#add()', function () {
        it('should call console.log in add function', function () {
            var spy = sinon.spy(console, 'log');
            calculator.add(1, 2);
            assert(spy.calledOnce);
            assert(spy.calledWith(1, 2));
            console.log.restore();
        });
    });
});
```

**代码 8.1.4.2**

上述代码的最后一行也可以写成 `sion.restore()` , 这样的话，会将所有通过 sinon 改造的函数都做还原。

上面的 spy 函数相当于做了一个钩子，可以检测是否被调用，可以获取调用时传的参数。有了这个函数，就可以让我们的代码测试的覆盖面更广。但是有的时候，我们的代码在调用中间件时，肯定要书写中间件不可用时的处理逻辑，但是真实情况是，我们的中间件绝大多数情况下都是可用的，按照正常流程测试，这部分错误处理函数从来都不会被触发，这时候我们就要用到 stub 这个函数。

```javascript
const {expect} = require('chai');
const sinon = require('sinon');
const {UserModel} = require('../../models');
const user = require('../../models/user_model');

describe('user check sinon', function() {
    it('错误处理', function(done) {
        const myFake = sinon.stub(UserModel, 'findOne').callsFake(function(filter, fields, option, callback) {
            callback(new Error('error from sinon'));
        });
        user.loginCheck('','', function(err) {
            expect(err).not.be.null;
            myFake.restore();
            done();
        });
    });
});
```

**代码 8.1.4.3**

这里面对于 `UserModel.findOne` 函数进行改写，强制让其 callback 触发时返回一个 Error 对象，这样我们就可以测试我们代码在数据库查询失败的逻辑处理了。当然在每次测试完成之后，按照惯例需要对于改造进行还原，也就是 `myFake.restroe` 这句代码的调用，如果不写这句话，`UserModel.findOne` 就会一直处在被改写的状态，很有可能影响下面的测试。

stub 这个函数不仅仅可以用来模拟中间件出错的场景，还可以用来模拟调用第三方服务返回数据的场景，模拟这种场景可以让你的测试代码脱离第三方服务的依赖。

## 8.2 测试覆盖率

我们写测试用例是为了及时发现开发中产生的 bug，这就引申出来一个问题，怎样写测试用例才能更好的将潜在的 bug 更好的暴漏出来，这也就是我们现在要讲的话题——测试覆盖率。

> 我们的测试代码是在源码的基础上编写的，那么它属于白盒测试，还有一种基于需求的测试，这种测试不接触源代码，属于黑盒测试。我们这一小节讲覆盖率，只讲白盒测试的覆盖率。

首先我们程序中有各种条件判断分支，理想的情况是，各个分支在测试用例中都有断言检测，但是实际情况中我们很难写出测试代码将所有分支都覆盖。将`覆盖到的条件分支数 / 总的条件分支数`，得到的比例数，是测试覆盖率的一种算法。为什么说一种呢？因为实际情况中，一个程序的执行是几个分支依次运行的，这些程序中执行的分支组合就形成一条条路径，所以说还有一种覆盖率的算法是 `覆盖的测试路径 / 总的路径数`。这种计算方法比较复杂，现在一些测试覆盖率的统计工具都是基于前者的，比如说我们下面要讲到的 [nyc](https://www.npmjs.com/package/nyc)。

通过 npm 安装 nyc 后，修改 `npm test` 对应的命令，在 `mocha` 命令前添加一个 `nyc` 。比如说你之前的 `npm run test` 的命令是 `mocha --recursive src/test/mocha --timeout 99999 --exit` , 那么就可以改为 `nyc mocha --recursive src/test/mocha --timeout 99999 --exit` 。同时新增一个 `coverage` 的 `npm` 命令：

`"coverage": "nyc report --reporter=html"`

之所以加一个 reporter 的参数，是因为默认的输出格式是文本格式的，可读性不强。

先后运行 `npm run test` 和 `npm run coverage`，打开项目更路径下的文件夹 `coverage` 下的 `index.html`，即可看到对于各个文件的测试分支覆盖情况。

![测试覆盖率统计](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMPBXL5OYx6QGm4U%2Ftest_coverage.png?generation=1582638399873953\&alt=media)

**图 8.2.1 测试覆盖率报告**

最后说一下，在实践中的一些测试准测，由于覆盖率检测工具是基于分支的，所以即使覆盖率是 100%，依然无法保证所有的测试路径都会被涵盖到。并且当下的程序开发越来越依赖于团队协作，越来越倾向于往微服务的方向发展，单纯的一个服务 100% 没有问题，并不能保证整个系统是没有问题的。更有甚者，客户端存在的一些问题会倒逼服务器端去强行修改业务逻辑，保证线上业务的顺利进行。所有说，测试人员的集成测试还是至关重要的且无可替代的。

> 本章配套代码：<https://github.com/yunnysunny/expressdemo/tree/master/chapter8>


# 9 Node.js 最佳实践

## 9.1 配置文件

一般代码的运行的环境起码应该包括本地开发环境和线上运行环境，那么问题来了，你开发环境用的配置信息可是跟线上环境不一样的。那么已经存储这个配置信息呢？在代码中写死肯定是最low的方式。更通用的方式是使用配置文件，可是你一旦将这个配置文件就面临一个问题，你这个配置文件一旦提交到了 git 之后，你的同事 pull 代码之后，就有可能就他本地配置文件覆盖掉，而这个配置文件中又包含了本地文件路径有关的配置，但是你和你的同事用的还不是一个操作系统（一个 windows，一个 mac），想想这个场景就恶心。那如果我们不将这个配置文件提交 git 呢？每次新增配置文件选项，都需要口头通知你的同事们，又比如有新同事加入到这个项目来了，你只能把你电脑上存储的配置文件发他一份，让他做相应的修改再使用，想想这个场景就恶心。\
其实解决这个问题的办法也很简单，就是在 git 上放置一个配置文件的示例文件，我们就假设它为 `config.example.json` ，里面写入所有的示例配置项。然后在 `.gitignore` 中将 `config.json` 添加进去，最后你在代码中加载 `config.json` 这个配置文件，这样子的话大家都可以使用自己的配置文件，不会相互干扰，同时在 git 还存留了一份示例文件，配置项的更改都可以呈现到这个示例文件上。貌似是个完美的解决方案，其实这种解决方案不仅适用用 node ，任何语言都适用。\
但，这并不是问题的终点，我们是给出了一个配置示例文件 `config.example.json`，但是如果配置项有修改，你的同事不修改 `config.example.json` 怎么办？那就把错误扼杀在摇篮中吧，在我们的项目中会引入一个 `setting.js` 的文件来负责做配置项校验，在应用加载时，如果检测到某个参数不存在或者非法，就直接退出当前进程，让你启动不起来：

```javascript
var log4js = require('log4js');
var mongoskin = require('mongoskin');
var redis = require('redis');
var slogger = require('node-slogger');

var configObj = require('../config.json');
var settings = require('./lib/settings').init(configObj);
exports.port = settings.loadNecessaryInt('port');

//保证配置文件中的debugfilename属性存在，且其所在目录在当前硬盘中存在
var debugFile = settings.loadNecessaryFile('debuglogfilename', true);
var traceFile = settings.loadNecessaryFile('tracelogfilename', true);
var errorFile = settings.loadNecessaryFile('errorlogfilename', true);

log4js.configure({
    appenders: [
        {type: 'console'},
        {type: 'dateFile', filename: debugFile, 'pattern': 'dd', backups: 10, category: 'debug'}, //
        {type: 'dateFile', filename: traceFile, 'pattern': 'dd', category: 'trace'},
        {type: 'file', filename: errorFile, maxLogSize: 1024000, backups: 10, category: 'error'}
    ],
    replaceConsole: true
});

var debugLogger = exports.debuglogger = log4js.getLogger('debug');
var traceLogger = exports.tracelogger = log4js.getLogger('trace');
var errorLogger = exports.errorlogger = log4js.getLogger('error');
slogger.init({
    debugLogger:debugLogger,
    traceLogger:traceLogger,
    errorLogger:errorLogger
});


var dbConfig = settings.loadNecessaryObject('db');//保证配置文件中的db属性存在
if (dbConfig.url instanceof Array) {
    exports.db = mongoskin.db(dbConfig.url, dbConfig.dbOption, dbConfig.relsetOption);
} else {
    exports.db = mongoskin.db(dbConfig.url, dbConfig.dbOption);
}

var redisConfig = settings.loadNecessaryObject('redis');//保证配置文件中的redis属性存在
exports.redis = redis.createClient(redisConfig.port, redisConfig.host);
```

**代码 9.1 使用配置文件**

## 9.2 自动重启

作为一个健壮的线上环境，肯定不希望自己的应用程序垮掉。然而，现实开发中在代码中总是会时不时出现未捕获的异常导致程序崩溃，真实编程实践中，我们肯定会对代码慎之又慎，但是想要代码100%无bug是不可能的，想想那个整天升级打补丁的微软。\
我们用下面代码监听未捕获异常：

```javascript
process.on('uncaughtException', function(err) {
    try {
        errorlogger.error('出现重大异常，重启当前进程',err);
    } catch(e) {
        console.log('请检查日志文件是否存在',e);
    }

    console.log('kill current process:'+process.pid);
    process.exit();
});
```

**代码 9.2.1 监听未捕获异常**\
在`代码 9.2.1`中最后一行将当前进程强制退出，这是由于如果不这么做的话，很有可能会触发内存泄漏。我们肯定希望进程在意外退出的时候，能够重新再启动。这种需求其实可以使用 Node 的 cluster 来实现，这里我们不讲如何通过代码来达到如上需求，我们介绍一个功能十分之完备的工具——[pm2](https://pm2.keymetrics.io/)。\
首先我们运行 `cnpm install pm2 -g` 对其进行全局安装。为了做对比，我们首先来观察不用pm2的效果。本章用的源码是第6章的基础上完成的，由于在第6章中我们使用了登陆拦截器，为了不破坏这个结构，我们新生成一个路由器，放置在 `routes/test.js`，然后在 `app.js` 中引入这个拦截器：

```javascript
app.use('/test',testRotes);
app.use(authFilter);
app.use('/', routes);
```

**代码 9.2.2 添加测试路由器**\
然后在 `routes/test.js` 中添加让程序崩溃的代码：

```javascript
router.get('/user', function(req, res) {
  setTimeout(function() {
    console.log(noneExistVar.pp);
    res.send('respond with a resource');
  },0);  
});
```

**代码 9.2.3 导致进程崩溃**\
可能你要问，这个地方为啥要加个 `setTimeout` ，因为如果你不把这个错误放到异步代码中，就会像**代码 5.2.6**那样被express本身捕获到，就不会触发未捕获异常了。\
最后启动应用，访问 `/test/user` 路径，不出意外，程序崩溃了。\
然后我们用 pm2 来启动:

`pm2 start src/bin/www`\
运行成功后会有如下输出：

```
[PM2] Spawning PM2 daemon                                                                                                         
[PM2] PM2 Successfully daemonized                                                                                                 
[PM2] Starting src/bin/www in fork_mode (1 instance)                                                                              
[PM2] Done.                                                                                                                       
┌──────────┬────┬──────┬──────┬────────┬─────────┬────────┬─────────────┬──────────┐                                                                  

│ App name │ id │ mode │ pid  │ status │ restart │ uptime │ memory      │ watching │                                              
├──────────┼────┼──────┼──────┼────────┼─────────┼────────┼─────────────┼──────────┤                                                                  

│ www      │ 0  │ fork │ 5804 │ online │ 0       │ 10s    │ 29.328 MB   │ disabled │                                              
└──────────┴────┴──────┴──────┴────────┴─────────┴────────┴─────────────┴──────────┘                                                                  

 Use `pm2 show <id|name>` to get more details about an app
```

**输出 9.2.1**\
pm2 命令还有好多命令行参数，如果单纯手敲的话就太麻烦了，幸好它还提供了通过配置文件的形式来指定各个参数值，它支持使用 json 或者 yaml 格式来书写配置文件，下面给出一个 json 格式的配置文件：

```json
{
  apps : [{
    name        : "chapter7",
    script      : "./src/bin/www",
    instances   : 2,
    watch       : true,
    error_file  : "/temp/log/pm2/chapter7/error.log",
    out_file    : "/temp/log/pm2/chapter7/out.log",
    env: {
      "NODE_ENV": "development",
    },
    env_production : {
      "NODE_ENV": "production"
    }
  }]
}
```

**配置文件 9.2.1 process.json**

> 我为啥要在日志文件的路径配置项上写linux路径呢，因为在 windows 下使用 pm2 ，一旦出现未捕获异常，进程重启的时候，都会弹出命令行窗口来抢占当前的桌面。所以我只能在 linux 下进行测试。并且经过测试，如果使用node 0.10.x版本的话，遇到未捕获异常时，进程无法重启，会僵死，所以推荐使用 4.x+版本。

接着运行如下命令来启动项目：

```
pm2 start process.json
```

**命令 9.2.1**\
如果你想重启当前项目，运行：

```
pm2 restart process.json
```

**命令 9.2.2**

如果想关闭当前进程，运行：

```
pm2 stop process.json
```

**命令 9.2.3**

你还可以使用命令 `pm2 logs chapter7` 来查看当前项目的日志。最后我们来测试一下，访问我们故意为之的错误页面`http://localhost:8100/test/user`,会看到控制台中会打印重启日志：

```
chapter7-0 ReferenceError: noneExistVar is not defined
chapter7-0     at null._onTimeout (/home/gaoyang/code/expressdemo/chapter7/src/routes/test.js:7:17)
chapter7-0     at Timer.listOnTimeout (timers.js:92:15)
chapter7-0 [2016-09-16 23:28:14.016] [ERROR] error - 出现重大异常，重启当前进程 [ReferenceError: noneExistVar is not defined]
chapter7-0 ReferenceError: noneExistVar is not defined
chapter7-0     at null._onTimeout (/home/gaoyang/code/expressdemo/chapter7/src/routes/test.js:7:17)
chapter7-0     at Timer.listOnTimeout (timers.js:92:15)
chapter7-0 [2016-09-16 23:28:14.025] [INFO] console - kill current proccess:6053
chapter7-0 load var [port],value: 8100
chapter7-0 load var [debuglogfilename],value: /tmp/debug.log
chapter7-0 load var [tracelogfilename],value: /tmp/trace.log
chapter7-0 load var [errorlogfilename],value: /tmp/error.log
chapter7-0 [2016-09-16 23:28:14.908] [INFO] console - load var [db],value: { url: 'mongodb://localhost:27017/live',
chapter7-0   dbOption: { safe: true } }
chapter7-0 [2016-09-16 23:28:14.934] [INFO] console - load var [redis],value: { port: 6379, host: '127.0.0.1' }
chapter7-0 [2016-09-16 23:28:15.003] [INFO] console - Listening on port 8100
```

**输出 9.2.1**\
我们看到进程自己重启了，最终实现了我们的目的。

## 9.3 开机自启动

虽然我们在服务上线的时候，可以请高僧来给服务器开光，其实只要不是傻子就看得出来那只不过博眼球的无耻炒作而已。机器不是你想不宕就不宕，所以说给你的服务加一个开机自启动，是绝对有必要的，庆幸的是 pm2 也提供了这种功能。

以下演示命令是在 Ubuntu 16.04 做的，其他服务器差别不大，首先运行 `pm2 startup`，正常情况会有如下输出：

```
[PM2] Writing init configuration in /etc/init.d/pm2-root
[PM2] Making script booting at startup...
>>> Executing chmod +x /etc/init.d/pm2-root
[DONE] 
>>> Executing mkdir -p /var/lock/subsys
[DONE] 
>>> Executing touch /var/lock/subsys/pm2-root
[DONE] 
>>> Executing chkconfig --add pm2-root
[DONE] 
>>> Executing chkconfig pm2-root on
[DONE] 
>>> Executing initctl list
rc stop/waiting
tty (/dev/tty3) start/running, process 2312
tty (/dev/tty2) start/running, process 2310
tty (/dev/tty1) start/running, process 2308
tty (/dev/tty6) start/running, process 2318
tty (/dev/tty5) start/running, process 2316
tty (/dev/tty4) start/running, process 2314
plymouth-shutdown stop/waiting
control-alt-delete stop/waiting
rcS-emergency stop/waiting
kexec-disable stop/waiting
quit-plymouth stop/waiting
rcS stop/waiting
prefdm stop/waiting
init-system-dbus stop/waiting
splash-manager stop/waiting
start-ttys stop/waiting
rcS-sulogin stop/waiting
serial stop/waiting
[DONE] 
+---------------------------------------+
[PM2] Freeze a process list on reboot via:
$ pm2 save

[PM2] Remove init script via:
$ pm2 unstartup systemv
```

按照上面的提示，用 `pm2 save` 产生当前所有已经启动的 pm2 应用列表，这样下次服务器在重启的时候就会加载这个列表，把应用再重新启动起来。

最后，如果不想再使用开机启动功能，运行 `pm2 unstartup systemv` 即可取消。

## 9.4 使用docker

随着智能设备的蓬勃发展，整个互联网的网民总数出现了井喷，对于软件开发者来说，面对的用户群体越来庞大，需求变化原来越快，导致软件开发的规模越来越大，复杂度越来越高。为了应对这些趋势，最近几年一些新的技术渐渐被大家接受，比如说 [devops](https://zh.wikipedia.org/wiki/DevOps)，比如说我们接下来要讲的 [docker](https://zh.wikipedia.org/wiki/Docker_\(%E8%BB%9F%E9%AB%94\)) 容器。

有了docker，大家就可以本地开发代码，然后开发完成之后直接打一个包扔到服务器上运行，这个包就是我们所说的容器，它跟宿主机无关，不管运行在何种宿主机上，它的内部环境都是一致。所以说有了docker，我们再也不用担心在本地跑的好好的，结果一到服务器就出错的问题了。

> 当然如果你们服务器使用了Docker 技术的话，9.3小节的内容就没有必要使用了。因为在 docker 上是不用设置开机服务的。同时我们也不用使用 PM2 技术来进行重启，docker 中会监听 1 号进程的运行情况，一旦这个进程退出整个容器也就处于退出状态，如果你在启动 docker 的时候加了 `--restart=always` 参数，那么容器随后也会被 docker 守护程序再启动起来。

为了能够运行 docker 容器，我们需要现有一个镜像，这个镜像通过 Dockerfile 文件来声明构建过程。一个简单的 Nodejs 的 Dockerfile 的格式可以是这样的：

```dockerfile
FROM node
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app.js"]
```

**代码 9.4.1 chapter9/simple/Dockerfile**

通过 `docker build . -t app:simple` 可以创建 app:simple 这个镜像；然后通过 `docker run app:simple` 即可运行这个镜像，运行后输出 `Example app listening on port 3000`。

> 为了演示简单，这里的 app.js 只是一个 Express 的 helloworld 程序:

```javascript
const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
    res.send('Hello World!');
});

app.listen(port, () => {
    console.log(`Example app listening on port ${port}`);
});
```

**代码 9.4.2 chapter9/simple/app.js**

这么简单使用，看上去是没啥问题的，但是打包出来的镜像个头是比较大的，同时会影响打包和部署的时间。我们可以将 代码 9.4.1 中的 `FROM node` 换成 `FROM node:slim` 。之前我们使用的 FROM node 其实是 FROM node:latest 的缩写，它制作的时候使用的基础镜像包含了额外的一些软件包，但是我们平常用不到，但是 node:slim 这个版本基于一个精简版本的基础镜像制作而成，制作出来的体积会大大减少。我们复制代码 9.4.1，然后重命名为 slim.Dockerfile 文件，将第一行改为 FROM node:slim 。接着运行 `podman build . -f slim.Dockerfile -t app:slim` 即可打出来我们需要的更见轻量的镜像了。

不过平常我们在开发过程中会安装很多开发依赖，这些开发依赖根本不需要被打包出来的程序所使用，所以代码 9.4.1 中可以优化为 npm install --omit=dev ，这样就只会将生产依赖打包到镜像中，如果开发依赖的包有很多的话，可能又会减轻很多体积。

还有一个需要注意的，npm 等包管理工具，在安装过程中同时还会写入磁盘缓存方便加快后续安装，但是我们的镜像打出来后就不会再重复运行安装了，这些缓存是很没有必要的。这时候我们可以利用 dockerfile 的构建阶段来做出来一个“两段式”的结构：

```dockerfile
FROM node AS build
WORKDIR /app
COPY .npmrc .
COPY ./package-lock.json .
COPY ./package.json .
RUN npm install --omit=dev
FROM node:slim AS app
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY . .
CMD ["node", "app.js"]
```

**代码 9.4.3 chapter9/simple/two-stages.Dockerfile**

这里的构建分为 build 和 app 两个阶段，在 build 阶段我们把 .npmrc package-lock.json packge.json 三个文件拷贝到镜像中，用来安装生产依赖包。之所以要单独放一个 .npmrc 文件，是想通过修改 npm 的镜像源来加快 npm 的安装过程，否则使用官方源在国内安装是十分缓慢的。

## 9.5 代码

参见 <https://github.com/yunnysunny/nodebook-sample/tree/master/chapter9> 。


# 10 Node.js 的 c++ 扩展

Node 的优点是处理 IO 密集型操作，对于互联网应用来说，很大一部分内容都是 IO 处理（包括文件 IO和网络IO），但是还是有部分功能属于计算密集型操作。如果遇到这种计算密集型操作，推荐的解决方案是使用其他语言来实现，然后提供一个服务，让 Node 来进行调用。不过我们这章要讲的是 Node 的 C++ 扩展，也就是说，我们可以通过这种方式是 Node 代码直接 “桥接” 到 C++ 上，以此来解决计算密集型操作。但是我在前面为什么推荐使用其他语言来提供计算密集型的服务呢，因为一旦你的这个计算服务稍微一上规模，你的代码开发就要面临横跨两个语种的境况，给程序调试增加了很多不确定性。所以说，如果你要做的计算应用的功能比较单一的话，可以考虑做成 C++ 扩展。

Node 的 C++ 扩展功能是依赖于 V8 来实现的，但是在 Node 每次做大的版本升级的时候，都会有可能对应升级 V8 的版本，相应的扩展 API 的定义也很有可能发生变化，所以下面会首先介绍 [nan](https://www.npmjs.com/package/nan) 这个第三方包的，它提供了一系列的宏定义和包装函数，来对这些不同版本的扩展 API 进行封装。

## 10.1 准备工作

为了能够编译我们的 C++ 扩展，我们需要做一些准备工作，首先需要全局安装 [node-gyp](https://github.com/nodejs/node-gyp) 这个包：`npm install -g node-gyp`。同时需要安装 C++ 编译工具，在 linux 下需要使用 [GCC](https://gcc.gnu.org/)；Mac 下需要使用 [Xcode](https://developer.apple.com/xcode/download/)；Windows 下需要安装 [Visual Studio](https://www.visualstudio.com/products/visual-studio-community-vs) ，大家可以选择安装社区版，因为专业版和旗舰版都是收费的，如果想进一步减小安装后占用磁盘的体积可以安装 [Visual C++ Build Tools](https://visualstudio.microsoft.com/zh-hans/visual-cpp-build-tools/) ，详细的安装说明可以参见 [附 A6](https://nodebook.whyun.com/a6_node_native_addon_config)。

为了演示如何编译一个 C++ 扩展，我们从亘古不变的 hello world 程序入手，这个程序取自 Node [C++扩展的官方文档](https://nodejs.org/dist/latest-v6.x/docs/api/addons.html)。我们的目的是在 C++ 扩展中实现如下代码：

```javascript
exports.hello = () => 'world';
```

**代码 10.2.1**

这看上去有些拿大炮打蚊子的味道，这段代码太简单了，而我们竟然要用 C++ 将其实现一番，是的这一节关注的并不是代码本身，还是如何使用工具进行编译，所以我们选择了最简单的代码。首先我们创建 hello.cc 文件：

```cpp
// hello.cc
#include <node.h>

namespace demo {

using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;

void Method(const FunctionCallbackInfo<Value>& args) {
  Isolate* isolate = args.GetIsolate();
  args.GetReturnValue().Set(String::NewFromUtf8(
      isolate, "world").ToLocalChecked());
}

void init(Local<Object> exports) {
  NODE_SET_METHOD(exports, "hello", Method);
}

NODE_MODULE(addon, init)

}  // namespace demo
```

**代码 10.2.2 hello.cc**

然后创建 `binding.gyp` ，注意这里面的 target\_name 属性要和 NODE\_MODULE 宏定义中的第一个参数保持相同。

```json
{
  "targets": [
    {
      "target_name": "addon",
      "sources": [ "hello.cc" ]
    }
  ]
}
```

**配置文件 10.2.1**

> [gyp](https://gyp.gsrc.io/) （**G**enerate **Y**our **P**roject）是一种跨平台的项目构建工具，是谷歌员工在开发 [chromium](https://www.chromium.org/) 项目时衍生出来的工具。Node.js 扩展说白了也是基于 V8 的 API 基础上的，所以它也采用 gyp 技术。

编写完成之后在 hello.cc 目录下运行命令行 `node-gyp configure` ，成功之后会生成一个 build 文件夹，里面包含当前代码生成的 c++ 编译用文件。接着运行 `node-gyp build` 就能生成扩展包了。

编译成功后，我们就可以在 js 代码中引用这个扩展库了：

```javascript
// hello.js
const addon = require('./build/Release/addon');

console.log(addon.hello());
// Prints: 'world'
```

**代码 10.2.3 hello.js**

之前讲过本章的重点是使用 nan 这个包来实现扩展编写，所以我们就先拿这个 hello world 下手。首先是安装 nan 包：`npm install nan --save`。然后编写 hello.cc：

```cpp
// hello.cc
#include <nan.h>

using namespace v8;

NAN_METHOD(Method) {
  info.GetReturnValue().Set(Nan::New<String>("world").ToLocalChecked());
}

NAN_MODULE_INIT (Init) {
  Nan::Export(target, "hello", Method);
}

NODE_MODULE(hello_nan, Init)
```

**代码 10.2.4 hello.cc 的 nan 版**

可以看到和**代码10.2.2**相比**代码10.2.4**要简洁不少，这里 NAN\_METHOD(Method) 经过宏定义解析为 `void Method(const Nan::FunctionCallbackInfo<v8::Value>& info)`，所以你看到在函数 `Method` 内部会有一个 info 对象，能够在编译的时候被正确识别。同时宏定义 NAN\_MODULE\_INIT(Init) 会被转化为 `void Init(v8::Local<v8::Object> target)` 所以你会在函数内部看到一个 target 对象。同时**代码 10.2.2** 第13行中 `Isolate* isolate = args.GetIsolate();` 这个代码在函数 `NaN::New<String>` 中被封装在其内部，所以在**代码 10.2.4** 中没有看到这段代码。

## 10.3 映射 C++ 类

C++ addon 最精髓的地方，就是将 一个 JavaScript 类映射为一个 C++ 类，这样就会产生一个有趣的效果，你通过 new 构建的 js 对象，它的成员函数都被映射成 C++ 类中的成员函数。

下面举的例子可能可能看上去很傻，因为我们又写 对 `a+b` 求值的函数了，但是这种很天真的代码最好理解不过了。注意，这个例子改写自项目 [node-addon-examples](https://github.com/nodejs/node-addon-examples) 中的 [object\_wrap](https://github.com/nodejs/node-addon-examples/tree/master/6_object_wrap/nan) 小节。首先是 C++ 头文件定义：

```cpp
#ifndef MY_CALC_H
#define MY_CALC_H

#include <nan.h>

class MyCalc : public Nan::ObjectWrap {
public:
    static void Init(v8::Local<v8::Object> module);
private:
    explicit MyCalc(double value=0);
    ~MyCalc();

    static NAN_METHOD(New);
    static NAN_METHOD(PlusOne);
    static NAN_METHOD(GetValue);
    static Nan::Persistent<v8::Function> constructor;
    double _value;
};

#endif
```

**代码 10.3.1 MyCalc.h**

首先注意的一点是，类 `MyCalc` 要继承自 `Nan::ObjectWrap` ，按照惯例这个类中还要有一个 Persistent 类型的句柄用来承载 js 类的构造函数，MyCalc 类中唯一对外公开的函数就是 Init，其参数 `module` 正是对应的是 Node 中的 module 对象。

```cpp
#include "MyCalc.h"

Nan::Persistent<v8::Function> MyCalc::constructor;

MyCalc::MyCalc(double value): _value(value) {

}

MyCalc::~MyCalc() {

}

void MyCalc::Init(v8::Local<v8::Object> module) {
    v8::Local<v8::Context> context = module->CreationContext();
    Nan::HandleScope scope;

    // Prepare constructor template
    v8::Local<v8::FunctionTemplate> tpl = Nan::New<v8::FunctionTemplate>(New);//使用ShmdbNan::New<Object>函数作为构造函数
    tpl->SetClassName(Nan::New<v8::String>("MyCalc").ToLocalChecked());//js中的类名为MyCalc
    tpl->InstanceTemplate()->SetInternalFieldCount(1);//指定js类的成员字段个数

    Nan::SetPrototypeMethod(tpl,"addOne",PlusOne);//js类的成员函数名为addOne,我们将其映射为 C++中的PlusOne函数
    Nan::SetPrototypeMethod(tpl,"getValue",GetValue);//js类的成员函数名为getValue,我们将其映射为 C++中的GetValue函数

    constructor.Reset(tpl->GetFunction(context).ToLocalChecked());
    module->Set(context,
               Nan::New<v8::String>("exports").ToLocalChecked(),
               tpl->GetFunction(context).ToLocalChecked());
}

NAN_METHOD(MyCalc::New) {
    v8::Local<v8::Context> context = info.GetIsolate()->GetCurrentContext();
    if (info.IsConstructCall()) {
        // 通过 `new MyCalc(...)` 方式调用
        double value = info[0]->IsUndefined() ? 0 : info[0]->NumberValue(context).FromJust();
        MyCalc* obj = new MyCalc(value);
        obj->Wrap(info.This());
        info.GetReturnValue().Set(info.This());
    } else {
        // 通过 `MyCalc(...)` 方式调用, 转成使用构造函数方式调用
        const int argc = 1;
        v8::Local<v8::Value> argv[argc] = { info[0] };
        v8::Local<v8::Function> cons = Nan::New<v8::Function>(constructor);
        info.GetReturnValue().Set(cons->NewInstance(context, argc, argv).ToLocalChecked());
    }
}

NAN_METHOD(MyCalc::GetValue) {
    MyCalc* obj = ObjectWrap::Unwrap<MyCalc>(info.Holder());
    info.GetReturnValue().Set(Nan::New(obj->_value));
}

NAN_METHOD(MyCalc::PlusOne) {
    v8::Local<v8::Context> context = info.GetIsolate()->GetCurrentContext();
    MyCalc* obj = ObjectWrap::Unwrap<MyCalc>(info.Holder());
    double wannaAddValue = info[0]->IsUndefined() ? 1 : info[0]->NumberValue(context).FromJust();

    obj->_value += wannaAddValue;
    info.GetReturnValue().Set(Nan::New(obj->_value));
}
```

**代码 10.3.2 MyCalc.cc**

注意到在 `Init` 函数中，定义了一个 js 类的实现，并且将其成员函数和 C++ 类的成员函数做了绑定，其中构造函数绑定为 `New` ，`getValue` 绑定为 `GetValue`，`addOne` 绑定为 `PlusOne`。`Init` 函数中的最后一行类似于我们在 js 中的 `module.exports = MyCalc` 操作。对于 C++ 函数 `New` `GetValue` `PlusOne` 来说，我们在定义的时候都使用了宏 `NAN_METHOD`，这样我们在函数内部就直接拥有了 `info` 这个变量，这个跟 **代码 10.2.4** 中的使用方法是一样的。

同时留意到在函数 `GetValue` 和 `PlusOne` 中，`MyCalc* obj = ObjectWrap::Unwrap<MyCalc>(info.Holder());`，这一句将 js 对象转化为 C++对象，然后操作 C++ 对象的属性。相反在 函数 New 中 `obj->Wrap(info.This());` 是一个相反的过程，将 C++ 对象转化为 js 对象。

## 10.4 使用线程池

前面几小节介绍了 Nan 的基本使用，可是即使使用了 C++ addon 技术，默认情况下，你所写的代码依然运行在 V8 主线程上，所以说在面对高并发的情况下，如果你的 C++ 代码是计算密集型的，它依然会抢占 V8 主线程的 CPU 时间，最严重的后果当然就是事件轮询的 CPU 时间被抢占导致整个 Node 处理效率下降。所以说使用多线程在这种情况下会是一个更优解。

Nan 中提供了 `AsyncWorker` 类，它内部封装了 libuv 中的 `uv_queue_work`，可以在将计算代码直接丢到 libuv 的线程做处理，处理完成之后再通知 V8 主线程。

下面是一个简单的小例子：

```cpp
#include <string>
#include <nan.h>
#include <sstream>

#ifdef WINDOWS_SPECIFIC_DEFINE
#include <windows.h>
typedef DWORD ThreadId;
#else
#include <unistd.h>
#include <pthread.h>
typedef unsigned int ThreadId;
#endif
using v8::Function;
using v8::FunctionTemplate;
using v8::Local;
using v8::Value;
using v8::String;

using Nan::AsyncQueueWorker;
using Nan::AsyncWorker;
using Nan::Callback;
using Nan::HandleScope;
using Nan::New;
using Nan::Null;
using Nan::ThrowError;
using Nan::Set;
using Nan::GetFunction;

NAN_METHOD(doAsyncWork);


static ThreadId __getThreadId() {
    ThreadId nThreadID;
#ifdef WINDOWS_SPECIFIC_DEFINE

    nThreadID = GetCurrentProcessId();
    nThreadID = (nThreadID << 16) + GetCurrentThreadId();
#else
    nThreadID = getpid();
    nThreadID = (nThreadID << 16) + pthread_self();
#endif
    return nThreadID;
}

static void __tsleep(unsigned int millisecond) {
#ifdef WINDOWS_SPECIFIC_DEFINE
    ::Sleep(millisecond);
#else
    usleep(millisecond*1000);
#endif
}


class ThreadWoker : public AsyncWorker {
    private:
        std::string str;
    public:
        ThreadWoker(Callback *callback,std::string str)
            : AsyncWorker(callback), str(str) {}
        ~ThreadWoker() {}
        void Execute() {
            ThreadId tid = __getThreadId();
            printf("[%s]: Thread in uv_worker: %d\n",__FUNCTION__,tid);

            __tsleep(1000);
            printf("sleep 1 seconds in uv_work\n");
            std::stringstream ss;
            ss << " worker function: ";
            ss << __FUNCTION__;
            ss << " worker thread id ";
            ss << tid;
            str += ss.str();
        }
        void HandleOKCallback () {
            HandleScope scope;

            Local<Value> argv[] = {
                Null(),
                Nan::New<String>("the result:"+str).ToLocalChecked()
            };

            callback->Call(2, argv);
        };
};


NAN_METHOD(doAsyncWork) {
    printf("[%s]: Thread id in V8: %d\n",__FUNCTION__,__getThreadId());
    if(info.Length() < 2) { 
        ThrowError("Wrong number of arguments"); 
        return info.GetReturnValue().Set(Nan::Undefined());
    }


    if (!info[0]->IsString() || !info[1]->IsFunction()) {
        ThrowError("Wrong number of arguments");
        return info.GetReturnValue().Set(Nan::Undefined());
    }

    //
    Callback *callback = new Callback(info[1].As<Function>());
    Nan::Utf8String param1(info[0]);
    std::string str = std::string(*param1); 
    AsyncQueueWorker(new ThreadWoker(callback, str));
    info.GetReturnValue().Set(Nan::Undefined());
}

NAN_MODULE_INIT(InitAll) {

  Set(target, New<String>("doAsyncWork").ToLocalChecked(),
    GetFunction(New<FunctionTemplate>(doAsyncWork)).ToLocalChecked());
}

NODE_MODULE(binding, InitAll)
```

**代码 10.4.1 async\_simple.cc**

为了简单起见，将所有代码写到一个 c++ 文件中，注意到在**代码 10.4.1**中使用了自定义宏定义 `WINDOWS_SPECIFIC_DEFINE`，在 `binding.gyp` 中是支持添加自定义宏定义和编译参数的，下面是这个项目中用到的 `binding.gyp` 文件：

```json
{
  'targets': [
    {
      'target_name': 'async-simple',
      'defines': [
        'DEFINE_FOO',
        'DEFINE_A_VALUE=value',
      ],
      "include_dirs" : [
            "<!(node -e \"require('nan')\")"
      ],
      'conditions' : [
            ['OS=="linux"', {
              'defines': [
                'LINUX_DEFINE',
              ],

              'libraries':[
                  '-lpthread'
              ],
              'sources': [ 'async_simple.cc' ]
            }],
            ['OS=="win"', {
              'defines': [
                'WINDOWS_SPECIFIC_DEFINE',
              ],
              'sources': [ 'async_simple.cc' ]
            }]
        ]

    }
  ]
}
```

**代码 10.4.2 binding.gyp**

`binding.gyp` 中的最外层的 `defines` 变量是全局环境变量，`conditions` 中可以放置各种条件判断，`OS=="linux"` 代表当前的操作系统是 linux，其下的 `defines` 下定义的宏定义，只有在 linux 系统下才起作用，所以在 **代码 10.4.1** 中的环境变量 `WINDOWS_SPECIFIC_DEFINE` 只用在 windows 上才起作用，我们使用这个宏定义来做条件编译，以保证能够正确使用线程函数（其实 libuv 中封装了各种跨平台的线程函数，这里不做多讨论）。

继续回到 **代码 10.4.1**，类 `ThreadWorker` 中，函数 `Execute` 用来执行耗时函数，它将在 libuv 中的线程池中执行，函数 `HandleOKCallback` 在函数 `Execute` 执行完成后被调用，用来将处理结果通知 libuv 的事件轮询，它在 V8 主线程中执行。

最终给出测试用的 js 代码，又回到了我们熟悉的回调函数模式：

```javascript
var asyncSimple = require('./build/Release/async-simple');

asyncSimple.doAsyncWork('prefix:',function(err,result) {
    console.log(err, result);
});
```

**代码 10.4.3 addon.js**

## 10.5 N-API

Node 的 C++ addon 直接调用 V8 API 来完成原生代码和 JavaScript 的桥接工作，但是 V8 的 API 每年都几乎有大的版本升级，从而可能导致若干的数据类型或者函数被删除掉，这样就不得不迫使 addon 的开发者去升级他们的代码，以适应新版本的改动。Node 从 12.x 开始使用 7.x 版本的 v8，这个版本对于废弃 API 做了很多的删除，就导致了各个使用 addon 项目的社区一片哀鸿（具体可以参见这个 [issue](https://github.com/nodejs/node/issues/23122)）。

> Node 12 开始，删除了 `V8:Handle`，需要使用 v8::Local 替代；删除了 `Local<Value>::ToString`，需要替换为 `MaybeLocal<String>::ToString(Local<Context>)` ，与此类似的还有 Value 类上的各种转化函数；删除了非 Maybe 版本的 `FunctionTemplate::GetFunction` `Function::New` `Function::Call` 等函数，需要在调用这些函数的时候传递 `Local<Context>` 参数以使用 Maybe 版本的 API。

从 Node 10.x 开始，worker 线程的 API 被引入，这样导致一个问题，就是 c/c++ 中的静态变量，在整个进程中是共享的，而引入多线程机制后，这些静态变量可能会被多个线程读写，从而导致进程崩溃。Node 官方为此为 addon 提供了上下文感知功能。

**代码 10.2.2** 中，为了导出 C++ 函数，使用了 `NODE_MODULE` 这个宏定义，与其对应的上下文感知版本的宏定义是 `NODE_MODULE_INIT`，这个宏定义仅仅是将以下内容缩写成了一行，便于大家少写代码：

```cpp
extern "C" NODE_MODULE_EXPORT void
NODE_MODULE_INITIALIZER(Local<Object> exports,
                        Local<Value> module,
                        Local<Context> context)
```

所以可以看出来使用宏定义 `NODE_MODULE_INIT`，你还需要自己编写函数体，这样才是一个完整的符合语法要求的代码块。那么这个函数体内应该写什么呢，你可以将你代码中牵扯到静态变量的清理的代码全都抽离到这个代码块中。当然你也可以使用原来的 `NODE_MODULE` 宏，在需要添加清理的地方，给自己的代码下桩，不过这个样子会让自己的代码显得比较混乱。下面的代码改编自是官方给出的[例子](/10_node_addon)：

```cpp
// addon.cc
#include <node.h>
#include <assert.h>
#include <stdlib.h>

using node::AddEnvironmentCleanupHook;
using v8::HandleScope;
using v8::Isolate;
using v8::Local;
using v8::Object;

// Note: In a real-world application, do not rely on static/global data.
static char cookie[] = "yum yum";
static int cleanup_cb1_called = 0;
static int cleanup_cb2_called = 0;

static void cleanup_cb1(void* arg) {
  Isolate* isolate = static_cast<Isolate*>(arg);
  HandleScope scope(isolate);
  Local<Object> obj = Object::New(isolate);
  assert(!obj.IsEmpty());  // assert VM is still alive
  assert(obj->IsObject());
  cleanup_cb1_called++;
}

static void cleanup_cb2(void* arg) {
  assert(arg == static_cast<void*>(cookie));
  cleanup_cb2_called++;
}

static void sanity_check(void*) {
  assert(cleanup_cb1_called == 1);
  assert(cleanup_cb2_called == 1);
}

void Method(const v8::FunctionCallbackInfo<v8::Value>& args) {
  Isolate* isolate = args.GetIsolate();
  args.GetReturnValue().Set(v8::String::NewFromUtf8(
      isolate, "world").ToLocalChecked());
}

// Initialize this addon to be context-aware.
NODE_MODULE_INIT(/* exports, module, context */) {
  Isolate* isolate = context->GetIsolate();

  AddEnvironmentCleanupHook(isolate, sanity_check, nullptr);
  AddEnvironmentCleanupHook(isolate, cleanup_cb2, cookie);
  AddEnvironmentCleanupHook(isolate, cleanup_cb1, isolate);
  NODE_SET_METHOD(exports, "hello", Method);
}
```

**代码 10.5.1**

从上述代码上看，使用流程还是略显复杂的。`AddEnvironmentCleanupHook` 函数原型是

```cpp
void AddEnvironmentCleanupHook(v8::Isolate* isolate,
                               void (*fun)(void* arg),
                               void* arg);
```

第 2 个参数是一个回调函数，第 3 个参数是传递给回调函数的参数，也就是被清理对象的指针。这些调用 `AddEnvironmentCleanupHook` 时传递的回调函数，会按照后进先出的顺序，在加载当前扩展的线程退出之前被执行，所以 **代码 10.5.1** 中三个回调函数的执行顺寻为 `cleanup_cb1` `cleanup_cb2` `sanity_check`。由于例子中给的静态变量都是在栈上申请的内存，所以在三个回调函数中没有写其销毁函数，如果你的静态变量的类型是句柄的话，需要调用其清理函数，完成对于句柄指向的对象的清理动作，对于代码 10.3.2 来说，就要在函数 `MyCalc::Init` 中添加如下代码：

```cpp
node::AddEnvironmentCleanupHook(isolate, [](void*) {
    constructor.Reset();
}, nullptr);
```

Node 社区本身不维护 V8，对于 V8 的 API 变更只能采取跟随策略，所以每次 V8 有大的变动，都会伤筋动骨，所以 Node 从 8.x 开始引入了 N-API 的接口。它对于 V8 API 进行一次抽象，在其基础上又封装了一层，保证基于 N-API 开发的代码在 V8 API 发生变动的时候，可以保持不用更改。虽然 N-API 本身也会历经版本变动，但是总体上比 V8 的变动要小。下面是一个 N-API 的 hello world 程序，代码来自官方文档：

```cpp
// hello.cc using Node-API
#include <node_api.h>

namespace demo {

napi_value Method(napi_env env, napi_callback_info args) {
  napi_value greeting;
  napi_status status;

  status = napi_create_string_utf8(env, "world", NAPI_AUTO_LENGTH, &greeting);
  if (status != napi_ok) return nullptr;
  return greeting;
}

napi_value init(napi_env env, napi_value exports) {
  napi_status status;
  napi_value fn;

  status = napi_create_function(env, nullptr, 0, Method, nullptr, &fn);
  if (status != napi_ok) return nullptr;

  status = napi_set_named_property(env, exports, "hello", fn);
  if (status != napi_ok) return nullptr;
  return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, init)

}  // namespace demo
```

**代码 10.5.2**

N-API 的 API 设计符合如下风格：

所有的 N-API 的调用的返回值都是 `napi_status` 类型，如果返回值为 `napi_ok`，则代表调用成功。

你需要使用指针来承载你要返回的数据内容，比如说 **代码 10.5.2** 中第 10 行，通过函数 `napi_create_string_utf8` 来获取一个 JavaScript 字符串时，需要在最后一个参数位传递变量 `greeting` 的指针。

所有的 JavaScript 类型，在 N-API 中都是 `napi_value` 类型。

最后一条，由于 N-API 的调用返回值是 `napi_status` 类型，它是一个枚举类型，如果想获取具体的错误描述信息，可以调用 `napi_get_last_error_info` 函数来完成。不过在 N-API 的调用返回值为非 `napi_ok` 的情况下，还有可能函数内部会抛出异常，可以通过函数 `napi_is_exception_pending` 来判断当前调用是否会产生异常。下面是一个使用示例：

```cpp
// addon.h
#ifndef _ADDON_H_
#define _ADDON_H_
#include <js_native_api.h>
napi_value create_addon(napi_env env);
#endif  // _ADDON_H_
```

**代码 10.5.3**

```cpp
// addon.c
#include "addon.h"

#define NAPI_CALL(env, call)                                      \
  do {                                                            \
    napi_status status = (call);                                  \
    if (status != napi_ok) {                                      \
      const napi_extended_error_info* error_info = NULL;          \
      napi_get_last_error_info((env), &error_info);               \
      const char* err_message = error_info->error_message;        \
      bool is_pending;                                            \
      napi_is_exception_pending((env), &is_pending);              \
      if (!is_pending) {                                          \
        const char* message = (err_message == NULL)               \
            ? "empty error message"                               \
            : err_message;                                        \
        napi_throw_error((env), NULL, message);                   \
        return NULL;                                              \
      }                                                           \
    }                                                             \
  } while(0)

static napi_value
DoSomethingUseful(napi_env env, napi_callback_info info) {
  // Do something useful.
  return NULL;
}

napi_value create_addon(napi_env env) {
  napi_value result;
  NAPI_CALL(env, napi_create_object(env, &result));

  napi_value exported_function;
  NAPI_CALL(env, napi_create_function(env,
                                      "doSomethingUseful",
                                      NAPI_AUTO_LENGTH,
                                      DoSomethingUseful,
                                      NULL,
                                      &exported_function));

  NAPI_CALL(env, napi_set_named_property(env,
                                         result,
                                         "doSomethingUseful",
                                         exported_function));

  return result;
}
```

**代码 10.5.4**

```cpp
// addon_node.c
#include <node_api.h>
#include "addon.h"

NAPI_MODULE_INIT() {
  // This function body is expected to return a `napi_value`.
  // The variables `napi_env env` and `napi_value exports` may be used within
  // the body, as they are provided by the definition of `NAPI_MODULE_INIT()`.
  return create_addon(env);
}
```

**代码 10.5.5**

## 10.6 代码

本章代码位于 <https://github.com/yunnysunny/nodebook-sample/tree/master/chapter10>

## 参考资料

* V8 API Changes <https://docs.google.com/document/d/1g8JFi8T\\_oAE\\_7uAri7Njtig7fKaPDfotU6huOa1alds>


# 11 Node.js 调优

作为一门后端语言，肯定要求运行的效率最优化，以实现对于资源最小损耗，这一章正是围绕这个话题展开。调优是一个有挑战性的活儿，可能经常让人摸不着头脑，下面的内容尽量使用实例来带入一个个调优的场景。

## 11.1 准备工作

首先我们准备一个 http server 端代码，请求后返回一个二维码图片：

```javascript
var http = require('http');
var ccap = require('ccap')();//Instantiated ccap class 

http.createServer(function (request, response) {
    if(request.url == '/favicon.ico')return response.end('');//Intercept request favicon.ico
    var ary = ccap.get();
    var txt = ary[0];
    var buf = ary[1];
    response.end(buf);
    console.log(txt);
}).listen(8124);

console.log('Server running at http://127.0.0.1:8124/');
```

**代码 11.1.1 app.js**

对于 node 版本大于 6.3 的会比较简单，运行

```shell
node --inspect=9229 app.js
```

**命令 11.1.1**

启动的 node 进程会带有调试功能，使用 Chrome DevTools 即可远程查看当前运行的 js 源码，并且能够生成 CPU 和 内存快照，这对于我们分析性能十分有帮助。

运行完**命令11.1.1**之后，我们在控制台上会看到如下输出：

```
> node --inspect=9229 app.js

Debugger listening on port 9229.
Warning: This is an experimental feature and could change at any time.
To start debugging, open the following URL in Chrome:
    chrome-devtools://devtools/remote/serve_file/@60cd6e859b9f557d2312f5bf532f6aec5f284980/inspector.html?experiments=true&v8only=true
&ws=127.0.0.1:9229/1a19bc9d-7175-4df3-b131-2eca35c7c844
Server running at http://127.0.0.1:8124/
Debugger attached
```

**输出 11.1.1**

我们将输出的地址 `chrome-devtools://devtools/remote/serve_file/@60cd6e859b9f557d2312f5bf532f6aec5f284980/inspector.html?experiments=true&v8only=true &ws=127.0.0.1:9229/1a19bc9d-7175-4df3-b131-2eca35c7c844` 贴到 chrome 地址栏中访问，然后我们看到的竟然是一个空白页，好多教程中都说会直接打开一个 chrome 开发面板，然而并没有（我使用的是 Chrome 62 版本）。这时候，你在任何一个网页中手动打开一个开发面板，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCfayTwXAclzww5%2Finspect_dev.png?generation=1582638399382029\&alt=media)

**图 11.1.1**

你会发现多了一个 Node 的小图标，这个图标就是之前我们输入 `chrome-devtools` 地址后生成的，我用红色矩形专门标记了出来，点击这个图标又弹出了一个面板，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMChCT2bRk3sDsed%2Fnew_inspect_dev.png?generation=1582638398988924\&alt=media)

**图 11.1.2**

或者你直接在浏览器地址栏中输入 `chrome://inspect/#devices` ，可以打开一个面板界面，里面的 **Remote Target** 列表中会列出来所有已经可以被调试的 Node 进程，你可以通过点击 **Open dedicated DevTools for Node** 来弹出 **图 11.1.2** 界面。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-1ec6a4ad0572ac41c9a8510498dd0d2f321d6785%2Fremote_devices.png?alt=media)

**图 11.1.3**

点击 `Discover network targets` 后面的 **Configure** 按钮，点击然后输入Node 应用部署的 ip 和 inspect 端口号（即启动node程序的 `inspect` 参数），甚至可以监听任意远程电脑的 Node 应用（如**图 11.1.4**所示），添加成功后，同样可以触发开发面板上出现**图 11.1.1**中的 Node 小图标。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-6aced6b4ecaf4aba7a6d3582c220a7d3eaf6006c%2Fadd_remote_address.png?alt=media)

**图 11.1.4**

继续回到**图 11.1.2**，里面有一个按钮 `Add connection`，其实我们在这里一样可以添加远程 Node 应用的 inspect信息，这里的功能和**图 11.1.4** 中是一样的。我们点开他的 `Source` tab 页，能够找到我们启动的 app.js 的源码：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCnNFCB6bSZX3Wp%2Finspect_source.png?generation=1582638398353575\&alt=media)**图 11.1.5**

> 这项功能对于chrome来说还处于实验状态，所以在操作过程中如果你的浏览器崩溃，不要怀疑，这属于正常现象。

## 11.2 测试工具 JMeter

要想知道性能如何，需要首先借助压力测试工具，这里我们选择开源的 [JMeter](http://jmeter.apache.org/) 。

打开 JMeter 后，首先创建一个线程组，用过 LoadRunner 的同学，可能会比较熟悉`用户数`，这个概念对应到 JMeter 的话，就是线程组中的线程

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCp_Ua-1_FsRBYW%2Fadd_thread_group.png?generation=1582638399700032\&alt=media)

**图 11.2.1 添加线程组**

接着我们添加一个 HTTP 请求的默认参数

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCrTremFDrP-p6e%2Fadd_http_request_config.png?generation=1582638398992120\&alt=media)

**图 11.2.2 HTTP请求默认参数设置**

在这里我们仅仅需要设置 ip 和端口号即可：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCtvmFZi3tSU9TE%2Fhttp_default_request_config.png?generation=1582638398917332\&alt=media)

**图 11.2.3 设置HTTP请求默认参数**

其实这个步骤并不是必须的，下面我们要设置一个 http 请求的 sampler，在那里面设置请求的 ip 和端口号也是可以的，只不过这里提前设置好了，这样如果你想对当前网站配置多个请求 sampler 的时候，可以省去公共部分的配置，特别当你的请求都有公共参数的时候，不过由于我们这里仅仅测试一个 URL，所以优势没有体现出来。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCvz6Fu5tnxoAFS%2Fjmeter_http_req_sampler.png?generation=1582638398562561\&alt=media)

**图 11.2.4 添加 HTTP 请求 sampler**

由于前面在图 11.2.3 中已经设置了请求的 ip 和端口，这里仅仅设置一下请求路径即可：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCxNdri4XSTi8kB%2Fhttp_request_sampler_config.png?generation=1582638398329260\&alt=media)

**图 11.2.5 配置 HTTP 请求 sampler**

最后点击工具栏中的按钮 ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMCzMcbV1GYY44oB%2Fstart_test.png?generation=1582638399327275\&alt=media)即可开始测试。

接着我们回到线程组的配置，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMD00TDFiGRTNt3y%2Fthread_group_config.png?generation=1582638399718731\&alt=media)

里面配置的线程数，可以控制同时发送请求的线程数，我们在做测试的时候逐步增加线程数，然后查看 Node 进程的 CPU 占有率，直到达到 100% （windows下多核CPU的操作系统，显示看是否达到100%/n，其中n为 CPU 核心数，比如说常见的 4 核心的 windows，达到 25%，则代表一颗 CPU 核心被吃满）。

当你的 Node 进程达到满负载之后，回到**图11.1.2**的 Profiler 标签页，选择 Start 按钮开始生成 CPU profile 文件，等待一段时间时候，选择 Stop 则会得到一份 CPU profile，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMD28kMX-9aMIM6j%2Fcpu_profile.png?generation=1582638399524024\&alt=media)

**图 11.2.6 生成的 CPU profile 文件**

通过分析可知 Socket 的write 操作和原生代码操作（即第二行标记为program的调用）比较耗费CPU，由于我们在**代码 11.1.1** 使用了 ccap 这个验证码生成库，而这个库的核心代码是 C/C++ 编写的，所以耗时操作统一算在 program 身上。

回到**代码 11.1.1**，我们将产生的二维码图片数据作为 HTTP 的响应数据，鉴于一个二维码有几十KB的数据，服务端短时间内大批量的写操作，我们在 JMeter 中添加一个聚合报告（如**图 11.2.6**所示），然后在压力测试的过程中就会发现网络吞吐量是非常大的（如**图 11.2.7** 所示）。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMD45B09b8TWsx4E%2Freport_total.png?generation=1582638398888452\&alt=media)

**图 11.2.6**

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMD6-7jrWmlietBF%2Fresult_of_total.png?generation=1582638399159499\&alt=media)

**图 11.2.7**

综上两点，做一个实时的验证码生成程序是不合适的，所以这里推荐的解决方案是提前预生成一批验证码图片，然后放到 CDN 上，并且将关联数据存入数据库（比如说 redis），等浏览器请求过来的时候再随机从数据库中抽取一个，至于实现代码留给各位同学自己实现了。

上面讲了很多 JMeter 的基本使用都是基于 UI 的，正式使用的时候，UI 会占用若干资源，所以官方推荐使用命令行来运行，UI 工具只是用来负责生成 jmx 文件。点击保存按钮即可生成当前 UI 设置的 jmx 文件，然后在命令行中使用如下命令：

```shell
bin/jmeter.sh -n -t the_path_of_jmx -l the_path_of_jtl -e -o the_result_output_directory
```

**命令 11.2.1**

`jmeter.sh` 可以在 JMeter 的 bin 目录下找到 (Windows 下是 jmeter.bat)； `the_path_of_jmx` 即保存后生成 jmx 文件；`the_path_of_jtl` 代表生成的每条测试情况详情，多次运行命令时，要保证每次用的文件名不一致。

如果在设置线程组的时候，指定了循环次数为无线循环，则 jmeter 在命令行运行过程中无法判断什么时候结束，如果手动 CRTL+C 结束命令行时，`the_result_output_directory` 中不会生成测试报表 html 文件。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2Fe06007925ae76097a7b7b4f11ede53bda7d7f391.png?generation=1620715499423710\&alt=media)

**图 11.2.8**

这时候，你需要使用命令行讲 jtl 文件转化成报表文件，具体命令为：

```shell
bin/jmeter.sh -g the_path_of_jtl -e -o the_path_of_result_report_html
```

**命令 11.2.2**

如果在 **图 11.2.8** 中指定了一个循环次数，则运行完 **命令 11.2.1** 后，会在目录 `the_result_output_directory` 直接生成报表文件。

## 11.3 内存分析

Node 的程序中一般不会操作大内存，一般是一个请求过来，处理完数据，变量的生命周期就结束了，会被垃圾回收器回收掉。但是如果有高并发需求时，我们希望从数据库中查询到的数据能够在内存中得到缓存，这样就能减轻对于数据库的压力，提高吞吐率。

我们看下面一段代码：

```javascript
const db = require('./db');
const cacheResult = new Map();
const DEFAULT_CACHE_AGE = 1000;

class CacheItem {
    constructor(data,expire) {
        this.expire = expire || (new Date().getTime() + DEFAULT_CACHE_AGE);
        this.data = data;
    }
}

/*const queryWithCache = */exports.queryWithCache = function(itemName) {
    const item = cacheResult.get(itemName);//console.log(cacheResult.size);

    if (item) {
        if (item.expire > new Date().getTime()) {
            return (item.data);
        }
        cacheResult.delete(item);//console.log('expired cache..........................');
    }

    const value = db[itemName];
    cacheResult.set(itemName,new CacheItem(itemName,value));    
    return (value);
};
```

**代码 11.3.1**

将从数据库中查询到的数据缓存到内存，不过设置了一个过期时间，下次 查询的时候，先判断内存中有没有，如果内存中存在且数据尚未过期，就直接返回。如果内存中存在数据，但是过期了，就将其删除。看上去这个算法比较简单高效，但是却存在严重的内存泄漏问题，缓存的数据只有再下次被重新请求到的时候才有可能被删除，如果每次请求恰好都是新数据，那么之前缓存的数据永远得不到机会删除，导致内存暴涨。

为了方便的复现这个问题，我们对**代码11.1.1**进行改造：

```javascript
const http = require('http');
const ccap = require('ccap')();//Instantiated ccap class 
const cache = require('./lib/cache');
const rand = require('./lib/rand');

http.createServer(function (request, response) {
    const url = request.url;
    if(url === '/favicon.ico')return response.end('');//Intercept request favicon.ico
    if (url === '/cache-test') {
        return response.end(cache.queryWithCache(rand.create(3))||'__');
    }
    const ary = ccap.get();
    const txt = ary[0];
    const buf = ary[1];
    response.end(buf);
    // response.end('ok');
    //console.log(txt);
}).listen(8124);

console.log('Server running at http://127.0.0.1:8124/');
```

**代码 11.3.2**

增加一个地址 `/cache-test`来模拟这个请求，这里使用随机字符串来达到每次请求都使用新数据的目的。回到 Chrome 开发面板，选择 Memory 标签页，然后再选择 `Take heap snapshot`，点击 `Take snapshot` 按钮即可生成堆快照：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMD8n-4rYfVapsmw%2Ftake_snap.png?generation=1582638397856289\&alt=media)

**图 11.3.1**

回到我们的 JMeter，再新建一个 HTTP sampler，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMDAHOw7GjRTVLGb%2Fcache_test_sampler.png?generation=1582638398215567\&alt=media)

**图 11.3.2**

为了单测试内存泄漏，我们在测试之前先把之前的验证码的测试用例禁用掉，然后点击启动按钮。在测试之前，测试过程中，测试结束之后过一段时间之后，分别点击 `Take snapshot` 按钮：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMDC8j0BdFd8cWB3%2Fthree_snaps.png?generation=1582638398125305\&alt=media)

**图 11.3.3**

Snapshot 1 对应开始前，Snapshot 2 为进行中和 Snapshot 3 、4 结束后的堆快照，最终发现即使在测试结束后，堆内存依然没有释放，我们选择 `Snapshot 3` ，然后在 `Class filter` 左边的下拉框中选择 `Comparison` ，它会自动和上一个快照做对比，我们发现 `CacheItem` 类型的对象在这当中新增了 82893 个，然后再对比 `Snapshot 4` 和 `Snapshot 3` 发现，`CacheItem` 类型对象完全没有被回收掉。以此我们可以认定，我们写的代码有内存泄漏了。

> 为了解决这个问题，我单独做了一个闪存处理的包，借鉴了 JVM 或者 V8 中在 GC 中所使用的新生代、老生带的算法， ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMDEv0_HOCwAfQRQ%2Fflash_cache.png?generation=1582638399351116\&alt=media) **图 11.3.4** 假设缓存声明周期为 N，内部会预置一个定时器，每隔 N/2时间后将新生代拷贝到老生带，并且清空新生代，具体代码参考包 [flash-cache](https://www.npmjs.com/package/flash-cache) 。

## 11.4 alinode

> 此平台阿里已经停止运营，如果有相关的性能监控需求，可以参见 [附录A7](/a7_easy_monitor_setup) 来使用 easy-monitor 自建监控平台。

上面讲的性能分析都是基于 chrome devtools 的，国内的阿里公司也大量的使用 Node 来处理自己的业务，为了发现自己程序的性能问题，他们研发了 alinode 和其对应的性能监控平台，提供一系列的工具来定位性能问题。值得称赞的是，他们将此工具免费提供给大家使用👍。

直接浏览 <https://node.console.aliyun.com> ，使用自己的淘宝或者支付宝账号即可登录。登录完成，进入其性能平台管理界面，选择右上角的 `创建新应用` 按钮，在弹出的对话框中，输入应用名称。创建成功后，会弹出对话框，提示应用的 appid 和 secret。

使用 alinode ，要安装阿里开发的专用 Node 版本，它在官方 Node 代码的基础上集成了性能收集功能，以便将性能数据上传到 alinode 平台做分析。为此你需要安装 tnvm，这是一个类似于 nvm 的 Node 版本安装工具，使用它你才能够顺利地安装 alinode 订制版 Node。

```shell
# 安装 tnvm
wget -O- https://raw.githubusercontent.com/aliyun-node/tnvm/master/install.sh | bash
# 安装后更新一下环境变量，否则找不到 tnvm 命令
source ~/.bashrc
```

**代码 11.4.1 安装 tnvm**

接下来需要安装你需要的 tnvm 版本，阿里官方提供了一份 alinode 和原始 Node 的版本对照，详细数据可以参照 [官方文档](https://help.aliyun.com/knowledge_detail/60811.html) 。大体上的规则是 alinode 的 2.x 对应原始 Node 的 6.x 版本，3.x 对应 8.x，4.x 对应 10.x。举一个例子，从官方文档得知，原始 Node v6.14.4 对应的 alinode 为 v2.5.2 ，那么安装这个版本的命令如下：

```shell
tnvm install alinode-v2.5.2 # 安装需要的版本
tnvm use alinode-v2.5.2 # 使用需要的版本
```

**代码 11.4.2 安装对应的 alinode 版本**

为了能将 alinode 运行时产生的数据收集到性能监控平台，你还需要安装 agenthub 这个工具，通过运行 `npm install @alicloud/agenthub -g` 即可完成安装。

默认 alinode 不会收集运行时的数据，首先你需要保证你的 Node 程序在启动前，设置如下环境变量：

```shell
# 设置为 YES，相当于允许产生 Node 运行时的日志
export ENABLE_NODE_LOG=YES
# 默认日志保存目录为 /tmp，你可以通过这个环境变量修改她
export NODE_LOG_DIR=/var/log/alinode
```

**代码 11.4.3 设置 alinode 的环境变量**

可以将上述代码写在 `/etc/profile` 中，然后 `source /etc/profile` 让其生效。同时在你的 Node 程序启动之前，你还需要启动 agenthub，否则数据无法上报。还记得之前我们创建应用时生成的 appid 和 secret 参数不，我们创建一个配置文件写入 `~/alinode.json` 中，将 appid 和 secret 填入：

```json
{
    "appid":"",
    "secret":"",
    "logdir":""
}
```

**代码 11.4.4 配置 agenthub 启动参数**

如果你在 **代码 11.4.3** 中指定了环境变量 `NODE_LOG_DIR` 的话，这个地方就需要设置 `logdir`，agenthub 无法上报数据。

最后运行 `agenthub ~/alinode.json` 启动 agenthub，注意如果你在启动 agenthub 之前就已经通过 pm2 启动了应用，必须通过 pm2 kill 杀死当前进程，然后再重新启动，否则设置不会生效。

最终我们在性能平台中点击之前创建应用的主页，鼠标移动到左侧菜单中的`实例`上，会看到之前配置的那台机器：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMDGVQqVxGlyrQTD%2Falinode_instance.png?generation=1582638398533568\&alt=media)

**图 11.4.1 alinode 实例**

点击这个实例，然后在右侧的进程列表中选择你需要监控的进程：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMDI3y0P4DluBKgU%2Falinode_process_list.png?generation=1582638399316425\&alt=media)

**图 11.4.2 alinode 进程列表**

同时，你可以在右侧面板中找到一系列的性能数据收集工具，包括我们之前用过的堆快照和 CPU Profile。下面着重讲解一下 alinode 平台中，CPU Profile 收集的功能，我们点击一下 `CPU Profile` 按钮，会提示 `操作成功，请访问 文件列表 查看详情`，点击`文件列表` 链接，我们等待 CPU Profile 生成完成，然后选择 `转储` 按钮，它会将生成的文件从 Node 运行所在的服务器上传到 alinode 平台。转储完成之后，点击 `分析` 按钮，你会查看到生成的火焰图；点击 `分析(devtools)` 会将 CPU Profile 呈现在控制台中，跟图 \*\*图 11.2.6 \*\* 中呈现的效果是相同的。

火焰图是一种非常有效的性能分析工具，因为它很直观，即使你并不是代码的编写者，你也一眼能看出哪一块代码运行时间长：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2F-M0waLV491K9oV_xi8uU%2F-M0waMDKK0IAtYh3Lunh%2Falinode_flame.png?generation=1582638399299613\&alt=media)

**图 14.3.3 火焰图**


# 12 Node.js Web 安全

在使用任何后端语言开发 Web 应用的时候，都会遇到安全漏洞问题，比如说 XSS （Cross-site scripting，跨站脚本攻击，在网站中植入恶意代码，用户加载网页就会被触发）和 CSRF（Cross-site request forgery，跨站请求伪造，在任意网站上可以伪造请求发送到被攻击网站上）。对于 XSS 来说，现行的模板引擎（ejs、jade之类）已经做的比较好了，可以防御住常见的恶意代码注入。不过对于 CSRF 来说，就得需要借助于第三方类库了。

## 12.1 CSRF

首先看一个 CSRF 的栗子：

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>美女私聊</title>
  </head>
  <body>
    <iframe name="frameNoSeen" style="display:none;"></iframe>
    <h1>美女私聊</h1>
    <form action="http://localhost:8100/article/add" target="frameNoSeen" method="post" id="js-article-add">
        <label >标题：<input name="title"/></label><br />
        <label >你想对美女说的话：<textarea rows="20" cols="50" name="content"></textarea></label><br />
        <button id="fakeButton">告诉美女</button>
    </form>
    <script src="https://upcdn.b0.upaiyun.com/libs/jquery/jquery-1.10.2.min.js" type="text/javascript"></script>
    <script>
        $(document).ready(function() {
            $('#fakeButton').click(function() {
                alert('美女已经收到你的信息');
            });
        });
    </script>
  </body>
</html>
```

**代码 12.1.1 CSRF 攻击栗子**

将**代码 12.1.1**直接保存为一个 html，用 chrome 打开即可测试。

> 注意在代码中 form 表单的提交地址为 `http://localhost:8100/article/add`，相关代码参见 项目 [nodebook-sample](https://github.com/yunnysunny/nodebook-sample/tree/master/chapter12)，启动项目后，需要首先访问[http://localhost:8100，输入用户名和密码（预制用户名密码为admin](https://nodebook.whyun.com/http:/localhost:8100，输入用户名和密码（预制用户名密码为admin) admin），点击登录。保证用户处于登录状态。

同时留意到代码中有一个 iframe 标签，而且是被隐藏的，由于我们使用表单来提交数据，但是正常情况下表单提交完成后网页会跳转，所以为了不露馅，我们将表单跳转后的网页导向到这个 iframe 中。

点击 `告诉美女` 按钮，然后在开发者工具中查看 iframe 的 html 内容，会发现里面显示 403，这是由于我们使用了 `csurf` 这个包来防御扩展攻击。我们来看看关键代码：

```javascript
var csurf = require('csurf')();

module.exports = function(app) {
    app.use(function (req, res, next) {
        csurf(req, res, next);
    });
    app.use(function (req, res, next) {
        res.locals.csrf = req.csrfToken ? req.csrfToken() : '';
        next();
    });
};
```

**代码 12.1.2 文件 csrf\_filter.js**

文件 `csrf_filter.js` 会在 `app.js` 中被引用。包 `csurf` 默认会自动对 POST PUT DELETE 等数据修改操作做校验，对于 GET HEAD OPTION 等请求会直接跳过。我们在**代码 12.1.2**中，对于每次请求都生成一个 token 字符串，置于 res.locals 变量中，这样我们就能在模板中引用 `locals.csrf`，我们在 文件 `views/article/add_article.ejs` 中的 head 标签中加入这么一句：

```html
<meta content="<%= locals.csrf || '' %>" name="csrf-token">
```

**代码 12.1.3**

同时，我们在 jquery 中做一个 ajax 全局配置：

```javascript
$(document).ready(function () {
    var token = $('meta[name="csrf-token"]').attr('content');
    if (token) {
        $.ajaxSetup({
            headers : {'X-CSRF-Token' : token}
        });
    }
});
```

**代码 12.1.4**

这样每次 ajax 请求的头部中都会带有 X-CSRF-Token 这个头信息，后端代码中包 `csurf` 会读取这个头信息，取出 token 内容做校验。

如果想做一个对比测试的话，将代码 app.js 中的第49行 `csrfFilter(app);` 注释掉，我们点击 `告诉美女` 之后就会在数据库中插入一条记录。


# 13 Node Web Worker

Web Worker 技术，本是前端 API，作为 HTML5 标准 API ，被各大浏览器支持。借助于 V8 引擎的东风，Node 在 10.x 开始引入 Web Worker 的 API。引入了这个API 后，Node 中也可以将应用代码单独运行在某个创建的线程中。

## 13.1 简单开始

下面是一个使用 Web Worker 的简单例子

```javascript
const path = require('path');
const { Worker } = require('worker_threads');

const worker = new Worker(
    path.join(__dirname, './worker.js'),
    { workerData: { a: 1} }
);
worker.postMessage('begin');
worker.on('message', (msg) => {
    console.log('info from child', msg);
});
worker.on('error', (err) => {
    // 子线程崩溃时，会抛出异常，触发 error 事件，这里可以重新触发线程的创建过程，保证线程一直在线
    console.error('worker emmit error', err);
});
worker.on('exit', (code) => {
    //线程退出事件
});
```

**代码 13.1.1 parent.js**

```javascript
const { parentPort, workerData } = require('worker_threads');
console.log('data from parent', workerData);
parentPort.on('message', data => {
    parentPort.postMessage(' worker recive: ' + data);
});
```

**代码 13.1.2 worker.js**

Web Woker 的构造函数比较简单，只有两个参数，第一个参数为 js 代码路径或者 js 代码字符串，第二个参数用来传递给子线程初始化参数，该参数目前仅支持 `workerData` 这一个属性，通过 **代码 13.1.2 worker.js** 可以看出，它被注入到了子线程的上下文中了，可以通过读取 `require('worker_threads').workerData` 来直接获取它的值。

## 13.2 适合什么和不适合什么

Web Woker 的 API 本身并不复杂，但是它为 Node 的编程打开了一扇大门。站在大门口的开发者们，相比于新 API 带来的兴奋感，我们更应该提防“乐极生悲”。

当前 Node 中使用多进程来利用多核 CPU 资源。每个 CPU 核心在使用的过程中，都会被拆分成各个时间片，然后操作系统通过轮转各个时间片给具体的某个线程，来保证各个线程都能获得时间被处理。但是让每个 CPU 核心都有活干，却可能仅仅是一个理想状态。“一个和尚挑水喝，两个和尚抬水喝，三个和尚没水喝”的故事还经常萦绕在耳边，其实根本原因就在于组织调度没有做好。虽然你创建的每个 Web Worker 都运行在独立的线程上，而且这些独立线程不会让 V8 的主线程卡顿，但是我们可以这么认为吗？我只能说上面说法，在满足一定条件下是正确的，这个条件就是你创建的线程总数一定要在一个可控的范围内。前面讲到操作系统通过轮转时间片来让每个线程都得到处理时间。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9922d97b65f3aeb21ce49a1203eb7272a80734a1%2Fcpu_schedule.png?alt=media)

**图 13.2.1 CPU 调度流程**

用户的各个应用中创建的线程，都会在内核中通过调度算法进行分配 CPU 核心。如果线程数少，那么调度算法的损耗比较小，但是一旦用户的应用程序的线程规模失控，整个调度过程就会变得十分低效，会严重拖垮整个系统的性能。我们做这么一个假设，假设去银行办理业务的时候，必须通过大堂经理来决定办业务的顾客是去哪个窗口，如果当前办业务的人数居多，这个大堂经理肯定会忙不过来。

同时我们还应该留意到 CPU 核心在处理完一个线程，然后切换到下一个线程的时候，需要做上下文切换，即需要将上一个线程的数据切换出当前核心，然后把下一个线程的数据切入当前核心。虽然这个过程很快，但是分跟谁比，相比 CPU 的运算指令来说，这就是一个慢操作，也就是说单个核心上频繁的 CPU 线程切换，会让 CPU 的有效利用率下降。

我们再考虑一个极端情况，就是假定现在所有被等待调度的线程，里面的用户代码都是做运算，那么每个线程都可以在一定时间内把单个 CPU 核心吃满，这也就是我们常说的计算密集型的线程。假设当前主机的 CPU 核心数为 N，那么操作系统中最多服务 N 个计算密集型线程，超过 N 个后，线程和线程之间就必须得互相竞争 CPU 的时间片，导致每个线程的处理时延被拉大。然而我们在 Node 中开启 Web Worker ，很多情况下就是为了处理计算密集型任务，这种情况下最好不要将创建的 Web Worker 数大于 CPU 核心数。在实际使用中，一般会采用池化技术来限定线程的使用数目，让 CPU 核心数得到合理的运用。

> 关于 Web Worker 池化的技术，可以参考笔者的项目 [yunnysunny/threads](https://github.com/yunnysunny/threads)。

线程和进程都可以用来控制并发，且线程比进程要更加轻量级，为何社区没有对于 Web Worker 出现大面积使用的情况呢？可能有些人面对 Web Worker 的时候还会发出上述的疑问。

Web Worker 虽是线程级别的技术，但是它是从浏览器领域中引入的 API，跟传统后端语言中线程相比欠缺了很多关键技术细节。在绝大多数语言中，子线程和父线程是可以直接共享变量的，而在 Web Worker 中，父子线程之间必须得通过通信的方式进行数据传递，也就是我们在 **代码 13.1.1** 和 **代码 13.1.2** 中看到的 `postMessage` 函数。V8 内部在实现 `postMessage` 函数的时候，会将传递的数据先在内部做序列化为二进制，然后将序列化后的二进制数据追加到接收线程的内部队列中；同时发送者还会在拷贝还会触发一个信号量，通知对端线程有新数据到来，对端线程在收到这个通知后，会通过反序列化将数据还原。

Web Worker 通过数据拷贝的方式而不是通过共享内存的方式来进行通信，从一定程度使子线程具有一定的安全性，只能操作给定的内存，不会破坏父线程环境，这在前端是具有积极意义的，特别是拿子线程作为一个单独密集型运算的模块来说；但是对于后端来说，本身的代码逻辑跟操作系统系统更加靠近，做这么一个类似沙箱的环境反而会显得多余，还会带来一定的副作用，就比如说上面讲到的数据序列化和拷贝部分。

`postMessage` 函数支持传递 `ShareArrayBuffer` 类型的数据，使用这个类型之后，父子进程之间无需做数据拷贝和序列化，但是实际生产环境中我们操作二进制数据的情况比较少，一般是通过传递 js 对象来进行相互通信，所以这个功能用到的机会很少。同时留意到 `postMessage` 函数的第二个参数支持传递一个数组，里面可以放 `ArrayBuffer` 类型的数据，且存放的变量需要和第一个参数是同一个变量，代表当前数据发送到对端线程后，本端线程将直接销毁掉这个变量，这样可以加快 GC 的过程，但是由于大家二进制用到的情况少，所以这个功能应该也会很少用到。

虽然上面说了很多 Web Worker 的“坏话”，不过也只是说其和传统后端编程语言的线程使用方式上有不足，但是和 Node 中的进程间通信模式比起来，又颇有几分“比上不足比下有余”的感觉。Web Worker 间的通信是有序列化的过程，还有内存拷贝，但是仅仅是发生在同一个进程内部；跨进程通信时需要借助于管道技术，同样需要做序列化，且需要序列化为字符串类型。所以说，不管是从通信效率上，还是序列化效率上，Web Worker 都要高于进程间通信。那么回到之前的疑问，为何 Web Worker 没有取代进程呢？

首先要面临的一个问题是，旧有 API 的改造是个庞大的工程。如果说第一个原因仅仅是托词的话，那么第二个问题就必须得面对了，Node 中的旧有代码都是按照单线程设计的，如果换成多线程操作后，会面临线程不安全的问题。拿最常见的 HTTP 请求处理来说，假定服务器端先后接到了两个请求，对应的请求对象为 `req1` `req2`，对应的处理 HTTP 响应的对象 `res1` 和 `res2`，这两个响应对象底层关联的是同一个 socket 句柄，用户端在书写代码的时候尽管写响应对象的 send 函数即可，Node 底层在处理的时候会先发 res1 的响应，后发 res2 的响应。但是同样的情况换成 Web Worker 之后，这种请求的先后顺序在子线程中无感，也就无从处理这种先后顺序，很容易导致客户端接收到乱序的数据从而解析失败。不过上面的例子多少有些不合时宜，因为目前 Web Worker 在调用 `postMessage` 的时候仅仅只能传简单对象，对于上面这种含有 socket 属性的数据是不能传递的。这里仅仅是举一个让大家更容易理解的例子，大家可以依此展开联想。

上面提到使用线程后，导致的不安全问题，举例子的场景是在 HTTP 请求接收完成之后，将后续的业务逻辑处理交给子线程的情形。如果我们在主进程中接收到 socket 请求后，不是将 socket 交给子进程，而是传递到子进程中去，这样就几乎没有 cluster 模块啥事了。理论上是可行的，不过这样就得 Node 官方将原有的代码彻底做重构，且必须让 Web Worker 支持传递 socket 句柄才行。

最后一个问题，讲一下多进程不能做，但是 Web Worker 可以缓解的功能。现在设想我们在使用内存级别的缓存，并且我们的应用是多进程的，就会导致缓存的各个 key 在单位时间内分配到各个进程上，每个进程上的 key 都不是全集。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-55656f43eb1013fed8c1e21b5d4d115bbc82e274%2Fprocess_cache_miss.png?alt=media)

**图 13.2.2**

如上图所示，进程1不含有 `key2`，用户请求 `key2` 的 请求被分配到进程 1 上时，就会出现缓存击穿的问题，明明在进程2 上含有 `key2` 的数据，但是进程 1 感知不到。

使用 Web Worker 能解决这个问题吗？前面可是讲到了，Web Worker 也是没有共享内存的啊。是的，没有错，但是我们可以使用线程间通信。那如果使用进程，也可以使用进程间通信啊，方案不是类似的吗？也没错，但是线程间的通信要更高效。不过我们依然要面临一个尴尬的问题，Web Worker 还只能支持父子之间通信，兄弟线程之间如果想通信，必须经过父线程做中转。好消息是，从 Node 15 开始，Node API 中增加 [BroadcastChannel](https://nodejs.org/dist/latest-v16.x/docs/api/worker_threads.html#class-broadcastchannel-extends-eventtarget) 类，它支持父子、兄弟线程之间广播数据，估计等上一年半载大家就能用上这个 API。

如果说之前，Node 还是乖乖的实现 V8 API 中的 Web Worker API，那么这次新出来`BroadcastChannel` 的 API，算是对上述行为的“僭越”了。V8 原生的 Web Worker 毕竟设计初衷是为了应对前端计算密集型的场景，Node 官方最终也算是看出来它的不足。我大胆猜测，以后会有更多超出 V8 原生 API 的自定义 API 出现。目前的 Web Worker 在 Node 领域还只是一个初生牛犊，未来还需要茁壮成长。


# 14 日志和监控

软件开发过程中，将一个需求以代码的形式实现，并且部署到线上，并不代表当前需求的开发周期完成了。首先我们无法保证这次交付的代码是没有逻辑缺陷；其次我们无法保证当前的代码能够高效运行，能够抗住高并发的冲击。对于前者，我们需要能在用户反馈软件的使用障碍的时候，能够方便找到他们使用过程中产生的现场数据，方便定位问题；对于后者我们需要将软件运行过程的性能指标数据收集起来，在指标数据超过设定阈值的时候能够立马采取人工干预，做扩容操作。

解决上述这两方面的问题，我们就需要软件运行过程中收集日志和监控指标。日志，确切的说是访问日志，在服务器每次被请求的时候，将请求的参数和响应字段做记录；监控指标，可以收集服务的运行响应时间、请求频率、内存损耗等信息。从上述分析中可以看出，日志和监控指标的内容是有重合的部分的，详细日志中也可以记录响应时间，也可以推算除请求频率。但是监控指标不会讲所有明细数据都采集，而是在固定时间间隔内采集一次，用这些采集到的离散的点来从逻辑上描述一个趋势，以此来预感服务是否正常，不正常则报警。所以说监控指标采集的数据量更小，日志数据收集的更精细，力求涵盖单次请求的所有上下文数据。两者各有所长，在不同的场景下发挥其作用。

## 14.1 收集日志

日志收集代码在 Node 中还是比较容易实现的，拿使用 Express 的情况做一个介绍，在其中添加一个中间件，然后把请求头信息、请求参数、响应码、响应正文等数据收集起来，可以发送到消息队列然后转存到日志分析服务中，或者可以直接发送到数据库中。

以下以笔者的项目 [@yunnysunny/request-logging](https://github.com/yunnysunny/request-log) 为例进行讲解 。

```javascript
const requestLog = require('@yunnysunny/request-logging').default;
const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');
const http = require('http');
const {
    slogger,
    port,
    kafkaSchedule,
} = require('./config');
const { setTimeout } = require('timers/promises');

const app = express();
app.enable('trust proxy');

app.set('port', port);
app.use(requestLog({
    onReqFinished: (data) => {
        kafkaSchedule.addData(data);
    },
}));

app.use(bodyParser.json({ limit: '1mb' }));
app.use(bodyParser.urlencoded({
    extended: false,
    limit: '1mb'
}));

app.use(express.static(path.join(__dirname, 'public')));
const codes = [1, 2, 3];
app.use('/', async (req, res) => {
    const duration = Math.random() * 1000;
    await setTimeout(duration);
    const resCode = duration > 500 ? codes[Math.floor(Math.random() * 3)] : 0;
    res.set('res-code', '' + resCode);
    res.send('hello world');
});

// catch 404 and forward to error handler
app.use(function (req, res, next) {
    const err = new Error('Not Found:' + req.path);
    (err).status = 404;
    next(err);
});

// error handlers
app.use(function (err, req, res, next) {
    const status = err.status;
    if (status === 404) {
        return res.status(404).send(err.message || '未知异常');
    }
    res.status(status || 500);
    slogger.error('发现应用未捕获异常', err);
    res.send({
        msg: err.message || '未知异常',
        code: 0xffff
    });
});

const server = http.createServer(app);

server.listen(port);
server.on('error', (err) => {
    slogger.error('发现应用启动异常', err);
});
function onListening () {
    const addr = server.address();
    const bind = typeof addr === 'string'
        ? 'pipe ' + addr
        : 'port ' + addr.port;
    slogger.info('Listening on ' + bind);
}
server.on('listening', onListening);

setInterval(() => {
    const path = ['/a', '/b', '/c'][Math.floor(Math.random() * 3)];
    const options = {
        port,
        host: '127.0.0.1',
        method: 'GET',
        path,
    };

    const req = http.request(options, (res) => {
        res.on('data', (chunk) => {
            // console.log(`BODY: ${chunk}`);
        });
    });
    req.end();
}, 1000);
```

**代码 14.1.1**

在 17 行中引入了 `@yunnysunny/request-logging` 包，它会将请求数据做收集，并将收集的数据发送到 kafka 中。kafka 在日志采集和大数据领域广泛采用，它支持将数据批量写入，常用的 kafkajs 提供了批量写入的 API，但是不支持定时批量写入功能，我们在上述代码中的对象 kafkaSchedule 变量初始化自 [queue-schedule](https://www.npmjs.com/package/queue-schedule) 库，它提封装了定时批量写入功能。

> kafka 服务可以选择本地搭建，具体参见 **代码 14.1.1.1** 。

为了方便后续的数据分析，这里将收集到的数据字段列到下面的表格中：

**表 14.1.1 请求日志字段说明**

| 名称                   | 类型          | 描述                                                                                        |
| -------------------- | ----------- | ----------------------------------------------------------------------------------------- |
| req\_id              | String      | 日志的唯一 ID                                                                                  |
| domain               | String      | 请求域名                                                                                      |
| original\_url        | String      | 请求原始链接，包含 query string 内容                                                                 |
| path                 | String      | 请求路径，不包含 query string 内容                                                                  |
| router               | String      | 请求对应的 Express 路由                                                                          |
| user\_agent          | String      | 请求者的 UA 信息                                                                                |
| custom\_headers      | Object      | 你希望收集的特定 HTTP 请求头                                                                         |
| custom\_envs         | Object      | 你希望收集的特定服务器环境变量                                                                           |
| method               | String      | HTTP 请求的 method                                                                           |
| ip                   | String      | 请求者的 IP                                                                                   |
| server\_id           | String      | 服务器的 IP 地址                                                                                |
| server\_host         | String      | 服务器的hostname                                                                              |
| duration             | Number      | 请求耗时，单位为毫秒                                                                                |
| pid                  | Number      | 服务器的进程 ID                                                                                 |
| req\_seq             | Number      | 内部请求 ID，是一个自增数字                                                                           |
| content\_length\_req | Number      | 请求头中的 content-length 值                                                                    |
| content\_length      | Number      | 响应头中的 content-length 值                                                                    |
| status\_code         | Number      | HTTP 响应的状态码                                                                               |
| res\_code            | Number      | 请求响应返回的逻辑 code 值，从响应头`res-code`中读取 或者从 `res.locals._res_code` 中读取。                        |
| res\_data            | String\|any | 请求响应正文，包内部默认转成 JSON 字符串，从 `res.locals._res_data` 中读取原始数据。                                 |
| req\_time            | Number      | 请求开始处理的时间戳                                                                                |
| req\_time\_string    | String      | 请求开始处理的时间戳对应的时间字符串，符合 [ISO 8601 Extended Format](https://en.wikipedia.org/wiki/ISO_8601). |
| req\_data            | String\|any | 请求正文，包内部默认转成 JSON 字符串                                                                     |
| referer              | String      | 请求头的 referer 值                                                                            |
| session              | Object      | 请求会话对象，代码编写者要避免其内部单个属性拥有多个类型的情况，否则会导致 ES 写入失败                                             |
| aborted              | Boolean     | 请求是否被取消，在响应体发送前请求者 socket 句柄关闭时，此值为 `true`                                                |

### 14.1.1 使用 ksqldb

将日志采集到 kafka 后，我们可以使用 ksqldb 来快捷查询日志数据。这里给出一个 docker-compose 文件，用来快速启动一个 ksqldb 环境：

```yaml
services:
  kafka:
    image: yunnysunny/kafka
    container_name: kafka
    hostname: kafka
    ports:
      - "9092:9092"
      - "9999:9999"
    networks:
      - my_network
    environment:
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      # JMX_PORT: 9999
      KAFKA_JMX_OPTS: "-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"
  ksqldb-server:
    image: confluentinc/ksqldb-server:0.28.2
    hostname: ksqldb-server
    container_name: ksqldb-server
    user: root
    depends_on:
      - kafka
    ports:
      - "8088:8088"
    networks:
      - my_network
    environment:
      KSQL_BOOTSTRAP_SERVERS: kafka:9092
      KSQL_LISTENERS: http://0.0.0.0:8088/
      # KSQL_KSQL_SERVICE_ID: ksql_service_1_
      KSQL_KSQL_LOGGING_PROCESSING_STREAM_AUTO_CREATE: "true"
      KSQL_KSQL_LOGGING_PROCESSING_TOPIC_AUTO_CREATE: "true"

  kafka-ui:
    container_name: kafka-ui
    image: provectuslabs/kafka-ui:latest
    depends_on:
      - ksqldb-server
      - kafka
    ports:
      - 8090:8080
    networks:
      - my_network
    environment:
      KAFKA_CLUSTERS_0_NAME: local
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
      KAFKA_CLUSTERS_0_KSQLDBSERVER: http://ksqldb-server:8088
      DYNAMIC_CONFIG_ENABLED: true # not necessary for sasl auth, added for tests
#    volumes:
#      - ~/kui/config.yml:/etc/kafkaui/dynamic_config.yaml
networks:
  my_network:
    driver: bridge
```

**代码 14.1.1.1 kafka 本地环境搭建 dockerfile 文件**

> 这里 confluentinc/ksqldb-server 镜像用了 0.28.2 版本，笔者曾经尝试过官方文档中的 0.29.0 版本，发现无法正常启动。 为了让 docker compose 中的 ksqldb-server 正确的连接到 kafka 容器中，kafka 容器在启动的时候设置环境变量 KAFKA\_ADVERTISED\_LISTENERS 的值为 `kafka:9092`，这就导致你在 Node 程序中连接到 kafka broker 后，其会返回集群的连接地址为 `kafka:9092`，为了保证你的电脑能够识别这个地址，你需要在操作系统的 hosts 文件中添加一条记录 `127.0.0.1 kafka`，否则无法正常和 kafka 服务进行通信。

ksqldb 的端口号，改写成了 8090，因为很多人电脑上 8080 端口号被占用了。为了方便数据演示，我们安装了 kafka ui 这个工具，它支持显示 ksql 查询结果。

点击 kafka ui 的 KSQL DB 菜单，然后点击 Execute KSQL Request 按钮，即可发现一个 KSQL 的输入文本框。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-6a10260468feb70866284ac97868a9492d9ea05d%2Fcreate_ksql_stream.png?alt=media)

**图 14.1.1.1 kafkaui 中创建流**

在上图的文本框中输入以下语句

```sql
CREATE STREAM req_log (
  req_id STRING,
  domain STRING,
  original_url STRING,
  path STRING,
  router STRING,
  user_agent STRING,
  custom_headers MAP<STRING, STRING>, 
  custom_envs MAP<STRING, STRING>,
  method STRING,
  ip STRING,
  server_id STRING,
  server_host STRING,
  duration BIGINT,
  pid BIGINT,
  req_seq BIGINT,
  content_length_req BIGINT,
  content_length BIGINT,
  status_code INT,
  res_code INT,
  res_data STRING,
  req_time BIGINT,
  req_time_string STRING,
  req_data STRING,
  referer STRING,
  session MAP<STRING, STRING>,
  aborted BOOLEAN
) WITH (
  kafka_topic='req-log',
  value_format='JSON'
);
```

**代码 14.1.1.2 创建流语句**

执行完成后，清空文本框，重新输入 `select * from req_log;` 并执行，就能查询到 kafka 中的数据了

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9810cea6770dd7f747e058549afe7496c665f775%2Fksql_result_full.png?alt=media)

像我们正常使用 sql 一样，你也可以使用查询条件对于结果进行过滤，比如说你输入，

```sql
 select path, res_code, req_time_string from req_log where res_code <> 0;
```

**代码 14.1.1.3**

就能展示所有 res\_code 不能 0 的请求日志，并只返回 path res\_code req\_time\_string 三个字段。

我们相对 res\_code 进行聚合分析，可以使用如下语句：

```sql
SELECT COUNT(*) AS sum, path, res_code
FROM req_log 
WHERE res_code <> 0 
GROUP BY path, res_code 
EMIT CHANGES;
```

**代码 14.1.1.4**

注意上述代码有一个变化就是结尾加了 `EMIT CHANGES` 关键字，这是由于 ksql 默认使用 pull 的模式来查询数据，可以查询存入 kafka 的所有历史数据，pull 模式查询完成后就退出查询进程。但是如果你想做聚合分析时，其只能支持 push 模式，在 push 模式下你只能收到新增的数据，查询过程中会一直卡住，等待新数据的到来。所以由于 push 机制的限制，你没法对历史数据做聚合分析。

### 14.1.2 使用 ELK

KSQL 虽然能对日志进行查询和分析，但是功能过于简单，业内最著名的日志分析工具，非 **E**(lasticSearch)**L**(ogstash)**K**(ibana) 莫属。

ElasticSearch，简称 ES， 基于 [Lucene](https://lucene.apache.org/) 开发，是一个搜索和数据分析引擎；Kibana 用来对 ES 数据进行可视化查询和报表展示；Logstash 是一个日志数据转换工具，可以支持从 kafka 、本地文件、HTTP、TCP、UDP、ES、sqlite、s3、标准输入等数据源采集数据，并将其转发到 kafka、ES、s3、http、TCP、UDP、zabbix、标准输出、mongodb、syslog 等目标。

我们在上一节中已经将日志收集到了 kafka，下面将讲述如何将 kafka 中的数据转存到 ES 中，并通过 Kibana 进行数据分析。

首先需要用 Logstash 工具，将 kafka 的数据导入到 ES 中。为了演示方便，直接从[官网](https://www.elastic.co/cn/downloads/logstash)下载最新版本到本地。解压缩下载得到的压缩包后，进入解压目录，在 config 文件夹下新建一个 kafka.conf 的文件，贴入如下内容：

```shell
input {
  kafka{
    bootstrap_servers => ["127.0.0.1:9092"]
    group_id => "es-cloud-next"
    auto_offset_reset => "earliest" #从最新的偏移量开始消费
    consumer_threads => 2
    topics => ["req-log"] #数组类型，可配置多个topic
  }
}
filter {
    json {
        source => "message"
        remove_field => "message"
    }
}
output {
  elasticsearch { 
    action => "index"
    cloud_id => "" 
    api_key => "" 
    index => "nodejs-demo-%{+YYYY-MM-dd}"
    codec => "json"
  }
}
```

**代码 14.1.2.1 config/kafka.conf**

整个配置文件分为 `input` `filter` `output` 三个部分：`input` 用来配置 kafka 连接信息，`filter` 用来做一些数据格式转化，output 用来指定输出目标的配置。这里将从 kafka 中读取到的数据序列化成 json，因为 logstash 会将读取到的数据挂载到 message 字段上，所以要指定一下 `source` 参数为 `message`，同时我们在解析完 json 后，就将 message 字段删除掉，读者可以自行选择是否保留这个原始的 `message` 字段，防止出现 json 解析失败后无法得知原始消息的问题。

最下面的 elasticsearch 的配置中的 `cloud_id` `api_key` 字段，我们先留空。鉴于 ES 使用了全文索引等特性，运行起来比较耗费机器资源，使用笔记本来运行的话比较吃力，所以这里我们演示的时候会用到云厂商的 ES 服务。[Elastic Cloud](https://www.elastic.co/cn/cloud) 提供了 14 天的试用时间，且其 ES 版本为最新的 8.x 版本，所以笔者选择了这个官方版本的服务进行讲解。

> 如果想使用永久免费的服务，可以选择 [Bonsai](https://bonsai.io/pricing)提供的服务，它可以允许最多存储 3 万条数据，对于新手练习来说也够用。

使用任意邮箱注册 Elastic Cloud 后，会提示你创建一个部署

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-5db2db4ea41a088107708b73393d60d203fc0d8f%2Fcreate_es_cloud_deployment.png?alt=media)

**图 14.2.1.1 创建 ES 部署**

点击按钮 Create deployment 后，会跳转到 Kibana 界面，在其中选择 Search 标签页，然后选择 Connect to the Elasticsearch API 按钮，在弹出的窗口中，点击 API key 选项卡，然后输入 API key 的名字，点击 Create API key 按钮，会弹出创建 key 的配置界面。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-f860e1f0b07cce2968a145542559a2b066aef559%2Fes_cloud_create_api_key.png?alt=media)

**图 14.2.1.2 生成 API key**

在 key 的配置界面中记得把下拉框选择成 logstash，否则默认生成密钥不能给 logstash 使用：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-ba1dfb8642e9d7953ae33134f2f05a651569b8ef%2Fes_cloud_api_key_format.png?alt=media)

**图 14.2.1.3 选择 Logstash 密钥类型**

`cloud_id` 参数从 **Endpoint** 选项卡中可以查看到：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-67b119587311b6b80a4a5f3db570343a4582e2a2%2Fes_cloud_id.png?alt=media)

**图 14.2.1.4 cloud\_id 参数获取**

至此 cloud\_id 和 api\_key 两个参数就都拥有了，运行 `bin/logstash -f config/kafka.conf` 即可将 kafka 中的数据消费到 ES 中。

运行成功后，我们通过 kibana 左侧菜单中的 Stack Management -> Index Management ，可以找到我们新建的 ES 索引

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-e6018a1c8d3a0a182ce3c8c0422f0d16e4fb1ec1%2Fes_index.png?alt=media)

**图 14.2.1.5 es 索引列表查看**

#### 14.1.2.1 kibana 简单数据查询

为了让 kibana 中能查询到 ES 索引中的数据，我们需要在左侧菜单中一次点开 Stack Management -> Data View ，在打开界面中点击按钮 Create data view，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-f7b2773865ec3297f9453cbe881e97c9a1787fa0%2Fkibana_data_view.png?alt=media)

**图 14.1.2.1.1 创建 kibana data view**

Index pattern 选项中我们使用 \* 通配符进行索引，是由于我们的 ES 所以是按照日期生成的，使用 \* 后可以匹配到所有以 nodejs-demo- 开头的索引。Timestamp field 默认值 @timestamp 是指单条记录写入 ES 的时间，这里可填写的字段只要是符合  [ISO 8601 Extended Format](https://en.wikipedia.org/wiki/ISO_8601) 格式即可，而我们日志中 req\_time\_string 在代码中之所以要格式化成 ISO 8601 格式，就要用在这个地方做时间索引，这样在 kibana 中数据就可以精确的使用请求开始时间进行排序，更利于我们统计 RPS（每秒请求数）。

点击按钮 Save data view to Kibana 之后，通过点击左侧菜单的 Discover ，然后再下图中 ② 号位置下拉选择我们之前创建的 nodej-demo，就可以从 kibana 中看到 ES 的数据了。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-27d31e8ce0ebdd6b811c2dc101681dafc41ac464%2Fkibana_discover.png?alt=media)

**图 14.1.2.1.2 kibana 数据表展示**

在③号位置可以看到，在单位时间内的并发量，柱状图越高代表并发量越大。其上面显示为 Auto interval 的区域可以调整单位时间的颗粒度，展开后会发现里面有毫秒、秒、分钟、小时、天、星期、月、年等单位。⑥号位可以调整当前数据的时间段，kibana 支持按照秒、分钟、小时、天、年等粒度来划分时间段，也支持指定开始、截至的时间点来划分时间段。柱状图下面显示的数据详情，Documents 中 显示数字代表当前时间段内有 883 条数据，数据使用分页展示，④号可以调整分页大小，⑤号位可以切换页码。

如果想检索数据可以在⑦号位中输入 [KQL](https://www.elastic.co/guide/en/kibana/current/kuery-query.html) 表达式，举几个例子，我们前面收集的日志中有 method 这个字段，那么我们搜 method 为 GET 的日志，可以用以下表达式，输入⑦号位后回车即可展现查询结果：

```sql
method: GET
```

**表达式 14.1.2.1.1**

如果想查 method 为 GET，并且 router 为 /a 的日志，可以用 AND 关键字连接两个查询表达式：

```sql
method: GET AND router: /a
```

**表达式 14.1.2.1.2**

如果想查 user\_agent 字段中包含 Windows NT 10.0; Win64; x64 字符串的日志，你需要使用下面的表达式：

```sql
user_agent: "Windows NT 10.0; Win64; x64"
```

**表达式 14.1.2.1.3**

注意我们将值的内容用引号包裹起来了。如果你不加引号写成 user\_agent: Windows NT 10.0; Win64; x64，其会隐式转化为 user\_agent: Windows AND user\_agent: NT AND user\_agent: 10.0; AND user\_agent: Win64; AND user\_agent: x64 ，这样搜索的时候只要包含空格分隔的五个单词就能匹配到日志记录，并不关心当前的五个单词是否是按照我们指定的顺序出现的。

还有一点需要注意，如果你查询的字符串中包含如下字符 `\():<>"*` ，那么你需要使用 \ 字符进行转义，或者使用引号将查询字符串包裹起来。比如说我们查询 referer 字段为 <http://localhost> 的时候，可以写成以下两种方式：

```sql
referer: http\://localhost
referer: "http://localhost"
```

**表达式 14.1.2.1.4**

如果我们查询的逻辑比较负责，可以使用小括号来更改逻辑判断的优先级，比如说我们想查询 method 为 GET ，且 router 为 /a 或者 /b 的日志，可以写成如下表达式：

```sql
method: GET AND (router: /a OR router: /b)
```

**表达式 14.1.2.1.5**

最后，如果想不区分任何字段做全文检索，可以直接输入在⑦号位输入你想查询的字符串即可，例如我们想查询包含 desktop 的字符串，直接在输入框输入 desktop 回车即可。

上面讲了 kql 的一些基本使用教程，如果你觉得记住这些规则太过麻烦，kibana 还提供了图形界面来简化你的操作，比如说对于 **表达式 14.1.2.1.5**，可以在 ⑧ 号位点击加号，在弹出的输入框中依次输入

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-72fa555e11cc739e4e7346e862183cbebbcad04e%2Fkibana_filter_panel.png?alt=media)

**图 14.1.2.1.2 kibana 数据筛选面板**

输入完第一行后，点击选择 AND 按钮，继续添加第二行。注意这里我们筛选的字段后面有一个 .keyword 后缀，拿第一行举例，你在点击开第一列的时候，会弹出 method 和 method.keyword 两个选项。两者的区别是 method 会做部分匹配，也就是说筛选值可以是当前字段值的子字符串；但是使用 method.keyword 就会做严格匹配，你的筛选值必须得和字段值完全相同才行，同时如果你选择了 .keyword 的格式后，kibana 会自动列出来部分可选值，这对于可以枚举的字段值来说筛选起来比较方便。

由于我们一条记录中包含的字段比较多，为了显示更多的记录，kibana 默认只显示前面几个字段，一般我们筛选完数据后更关心跟筛选字段相关联的几个字段，这时候你可以在左侧搜索到你关心的字段名，然后点击 ＋ 号。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-af90236f847df22eeb58c87520efa223ce11267f%2Fkibana_select_field_to_show.png?alt=media)

**图 14.1.2.1.3 kibana 选择展示特定字段**

这里我们选择 router 和 res\_code 两个字段，着重进行展示，可以得到表格视图的展示效果：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-d00d99959c6e540c4612324431fc4d21ac01cb4c%2Fkibana_fields_show.png?alt=media)

**图 14.1.2.1.4 kibana 选择字段后展示表格视图**

鼠标悬浮在每个字段名上面后，会出现 ┇ 的图标，点击这个图标之后，弹出菜单中可以选择删除此字段：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-16ceb43f732cb33fce6abb077e92cb4d3345390a%2Fkibana_field_context_menu.png?alt=media)

**图 14.1.2.1.5 kibana 数据条件弹出菜单**

通过上述弹出菜单还可以看出，我们可以对当前操作的字段进行排序，你可以选择从低到高（Sort Low-High）、从高到低（Sort High-Low）两种排序方向。不过当你选择一种进行排序后，出来的结果依然是乱序的，这是由于 kibana 默认会优先按照首列的 Time 字段从高到低进行排序，你需要在 Time 字段上点击 ┇ 的图标，然后在弹出菜单中再点击一下 Sort High-Low 菜单，就可以取消对于 Time 字段的默认排序，这时候就会选择刚才操作的字段进行排序了。

> ES 写入的字符串类型的字段，默认不支持排序，弹出的菜单中会将排序相关的两个菜单项置灰。如果你想让字符串字段可排序，需要修改 ES 索引的 mapping 设置，不过字符串排序的需求一般是低优先级的，读者可以自行查阅修改方法。

⑧ 号位还可以多次点击添加多个筛选条件，各个筛选条件之间是 AND 的关系，所以也可以将 **图 14.1.2.1.2** 中的筛选条件拆成两条。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-6b9643e548a2231c6f45fea3c129ecb9fcaf62cc%2Fcontext_on_kibana_filter.png?alt=media)

**图 14.1.2.1.6 kibana 数据条件弹出菜单**

拆成多条有一个好处，在任何一个刷线条件上单击，就会弹出菜单来对当前条件进行反向求值（Exclude results）、临时禁用（Temporarily disable）或者删除（Delete）等操作。

点击 ⑨ 号位位置会在弹出的对话框中选择 Copy link 按钮，会将当前地址栏对应的地址生成一个短链接拷贝到剪贴板中。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-573ac36f553cc4a85b7d258ddd854117085607c8%2Fkibana_copy_link.png?alt=media)

**图 14.1.2.1.7 kibana 拷贝链接**

如果你想将当前查询的筛选条件和展示的字段列表保存起来，可以点击 ⑩ 号位位置，在弹出的对话框中输入 Title 字段，然后点击 Save 按钮即可。推荐使用英文填写 Title ，防止出现兼容性问题。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9d9eb655a2b7d603e3229a2874fe60fc17b50607%2Fkibana_save_query.png?alt=media)

**图 14.1.2.1.8 kibana 保存查询**

#### 14.1.2.1 kibana 报表制作

kibana 中报表的制作算是其一大特色功能，你可以通过折线图、柱状图、饼图等图标快速了解日志对应服务的各种状态。

展开左侧菜单，选择 Visualize Library 菜单，在打开的界面中选择 Create new visualization 按钮，在弹出的对话框中，我们选择 Aggregation base 类型，虽然官方推荐选择 Lens 类型，但是 Lens 可选择的图标类型没有 Aggregation base 丰富，所以我们这里使用 Aggregation base 进行讲解。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-6ec5eefb4ae6f1055b9b5e9fa954e126e58844d2%2Fkibana_visualization_type.png?alt=media)

**图 14.1.2.1.1 选择可视化类型**

点击 Aggregation based 类型之后，在弹出的界面中我们可以看到支持的 12 种可视化图标：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-af433dfda4d5808af12d90c013ba13dd0546a7a1%2Fkibana_visualize_graph.png?alt=media)

**图 14.1.2.1.2 kibana 支持的可视化图标**

之前讲过要熟悉 **表 14.1.1** 中各个字段的含义，因为接下来我们制作的图标依赖于表中定义的各个字段。首先我们来展示一下单位时间内各个路由请求总数，我们选择的是 Horizontal bar 图表。点击上图中的 Horizontal bar 后，会让我们选择数据源，这里我们选择之前创建的 nodejs-demo 即可。接下来在打开的界面中，我们设置一下 X 轴的数据：这里我们选择聚合方式为 Terms，使用的聚合字段为 router.keyword；选择好时间区间，这里选择的是最近 30 天的数据；最后点击一下右下角的 Update 按钮即可展示数据：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-eb1cc14a1147c567b63eeb294feb2fc6a805be8a%2Freq_count_among_routers.png?alt=media)

**图 14.1.2.1.3 单位时间内请求次数最多的 5 个路由总数统计**

最后记得要点击 Save 按钮，将刚才做的报表保存起来，否则关闭当前页面后数据没有被保存，下次还得重新制作。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-d74ff9febcd39c2288c71522a0dbdcec962cc7ab%2Fsave_and_goto_dashboard.png?alt=media)

**图 14.1.2.1.4 保存图标并打开 Dashboard**

保存时，弹出的窗口中，我们可以选择将当前图表添加到指定的 dashboard 中，由于我们之前没有创建过 dashboard ，这里选择新建一个。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9ce9f5712e946196ef56217f66664f25d80be5af%2Fdashboard_init.png?alt=media)

**图 14.1.2.1.5 Dashboard 初始化**

记得要在 Dashboard 界面上点击右上角的 Save 按钮，否则添加 Dashboard 上的图标不会被保留，下次你还得重新添加。如果某一个图表之前已经存在了，想将其添加到当前 Dashboard 上，则可以。

> 除了通过 **图 14.1.2.1.4** 中选择指定 Dashboard 的方式， 你也可以通过 **图 14.1.2.1.5** 左上角的 Add panel，来快捷创建图表并将其直接加入当前 Dashboard 中。如果你在 **图 14.1.2.1.4** 中 Add to dashboard 选择了 none 选项，那么你操作的图表将自动进入全局图表库，你通过 **图 14.1.2.1.5** 中的 Add from library 按钮，可以将其添加到当前 Dashborad 中。另外 Dashboard 中不仅仅可以添加图表，我们在 **图 14.1.2.1.2** 中通过 ⑩ 号位保存的查询结果也可以添加 Dashboard 中。

除了关心各个路由的请求数量外，作为一个 http 服务，我们还比较关心每个路由的处理时间，下面我们就用折线图来展现一个不同路由的平均处理时长。在 **图 14.1.2.1.2** 中选择 Line 按钮，在弹出的界面中做如下设置：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-98d4b8f676ad6b6c7e7e908eba54f2628fa67f2c%2Faverage_duration_among_routers.png?alt=media)

**图 14.1.2.1.6 路由的平均响应时间**

可以看到我们将 duration 字段的平均值作为 y 轴，req\_time\_string 作为 x 轴，并且按照 router.keyword 拆分成不同的折线。这样每条折线上就对应了一个路由在不同时间点内的响应时间平均值。同样需要记住点击上图中的 Save 按钮进行保存，这时候在弹出的 **图 14.1.2.1.4** 所示的界面中，我们就可以选择保存到刚才新建的 Dashboard 中去。不过你在 Dashboard 界面上依然要点击一下 Save 按钮，否则 Dashboard 页面关闭后再打开不会再展示当前图表。

接下来讲一下最后一个场景，我们在请求的逻辑处理时，会将返回的错误码信息赋值到日志中的 res\_code 字段上，现在我们想分析一下各个 res\_code 值的比重是多少，将其绘制成饼图，可以选择 **图 14.1.2.1.2** 中的 Pie 按钮。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-716d90cfa82cf1d40e4c63fb682ab5b5e00c2dc5%2Fres_code_pie.png?alt=media)

**图 14.1.2.1.7 逻辑处理错误统计饼图**

如果你还想知道每个错误码下面出错的路由比重是什么，可以再次点击 Add 按钮，在弹出的菜单中选择 Split slices，然后添加一个 router.keyword 的聚合配置

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-bce5fe121a66b832ae687a9a8982791130d994fc%2Fres_code_router_pie.png?alt=media)

**图 14.1.2.1.8 逻辑处理错误在不同路由上的统计饼图**

## 14.2 采集监控指标

对于监控指标的采集，一般采用的是 [Prometheus](https://prometheus.io/) ，它需要定时请求应用程序自己提供 HTTP 接口来拉取监控指标数据。

> 这种行为跟传统的监控服务的 zabbix 正好相反（zabbix 需要依赖应用程序主动将监控指标上报上去），但是这种主动拉取的行为更具有灵活性，这样可以在 Prometheus 侧统一控制数据抓取时间间隔，还可以通过感知应用侧的 HTTP 接口是否可用来触发预警。

所以如果想让 Prometheus 正确的抓取到数据，必须使用它的驱动程序生成符合 Prometheus 读取格式的数据，这个驱动就是 [prom-client](https://github.com/siimon/prom-client) 。要想学习这个驱动的使用方法，必须先了解 Prometheus 中的数据结构。

### 14.2.1 指标数据结构

#### 14.2.1.1 计数器 （Counter）

计数器随着时间的增加，只能增加不能减少，从数学意义上讲其值和时间的关系是一个线性的递增的曲线。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-f589b9e4c6b40ea057a3a16a3c1c810fe880be70%2Fcounter.png?alt=media)

**图 14.2.1.1.1**

#### 14.2.1.2 仪表盘（Guage）

仪表盘可以理解为汽车的速度表，其取值随着时间的推移可以忽高忽低，从数学意义上讲其值和时间的关系是一个不规律的曲线。从理论上也可以出现负值，这主要取决于指标采集者采集的数据的具体内容，比如说采集的数据是摄氏度，就有可能出现负值的情况。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-227d69483142e30e08d273bc7fb87209606a6d50%2Fguage.png?alt=media)

**图 14.2.1.2.1**

#### 14.2.1.3 直方图（Histogram）

直方图是一种统计学上的数据结构，它一般用来将一个固定取值空间内的数据划分为若干区间，这里提到的“区间”，在 Prometheus 中被称之为桶（bucket）。应用程序在生成指标数据的时候，需要在每个桶里填入落入当前桶所关联的取值区间的总数。和前两小节讲到的数据结构不同，计数器和仪表盘在一个时间点上取到的是一个值，而直方图会取到个值，每个桶都会对应一个值。同时注意到各个桶的取值范围都是 `[begin, y]`，其中 `begin` 为取值空间的最小值，`y` 取值空间中的一个具体取值，如果有三个数值满足 `y3 > y2 > y1`，则很明显桶 `[begin, y3]` 包含 `[begin, y2]`，桶 `[begin, y2]` 包含 `[begin, y1]`。

![image-20220430184642173](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-6f3807718c178d3e02fe8bf3a7f139a1b8ca3289%2Fimage-20220430184642173.png?alt=media)

**图 14.2.1.3.1**

借助于第三方可视化工具，我们可以会截取一个时间段，将其绘制成我们更直观看到的直方图，比如说我们我们在上图中截取一个虚线指示的时间段。

![image-20220501164439315](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-eaf855d3c7a8fd02ee15a573e0b62f0f82d4533c%2Fimage-20220501164439315.png?alt=media)

**图 14.2.1.3.2**

就可以转化成一个如下的可视化结构

![image-20220501165935698](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-d5df45b2c138ef0dcb98e366999d590ab8932b4b%2Fimage-20220501165935698.png?alt=media)

**图 14.2.1.3.3**

由于展示的是一个时间段内的取值，所以上图中 n1 n2 n3 代表三个不同的桶在当前时间段内的平均值。由于 y3 y2 y1 三个桶是互相包含的关系，所以一定会保持这个关系 `n3 >= n2 >= n1`。

#### 14.2.1.4 摘要（Summary）

摘要和直方图类似，都是对于数据划分不同的统计区间，只不过摘要的桶是按照百分位数划分的。百分位数是一个统计学术语， 需要通过一个例子来解释其含义。假设一个年级有 100 位学生，我们将学生的分数按照从小到大排列形成一个数组（这里假定没有同分现象），则数组第 5 名对应的学生的分数就叫做 95 分位数，我们记这个分数为 `α`，则代表该年级 95% 的学生的学生分数都低于或者等于 `α` 。

由于我们在采集样本的时候，样本数目肯定不会恰好等于 100 个，所以在计算百分位数的时候，会有一定误差，所以 Prometheus 的各个语言的 API 在初始化摘要的时候，都会设定一个误差允许范围。同时留意到，和直方图不同的是摘要的计算是发生在客户端。但是如果你的程序是分布式的，将多个机器上的计算的同一指标的摘要是没法做加和运算的，但是同一指标的直方图数据却是可以加和的。

同直方图类似，百分位数大的桶是包含百分位小的桶的样本的，但是直方图录入 Prometheus 落入某一个桶的总数，而摘要代表的是落入当前桶里面的所有样本的最大值。假设我们将一个服务的各个接口的处理时长做成摘要录入 Prometheus ，则 95 分位的值代表所有时长正序排序后前 95% 的样本中的最大值。

将摘要的各个桶绘制出来时间曲线的话，跟直方图也是类似的形状：

![image-20220501174024232](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-1509d6e21f9afaa106a4cf7c6900dced1529ad59%2Fimage-20220501174024232.png?alt=media)

**图 14.2.1.4.1**

### 14.2.2 指标采集代码

讲述完 Prometheus 中的数据结构后，就可以编写代码了。首先 `prom-client` 根据 Prometheus [官方推荐](https://prometheus.io/docs/instrumenting/writing_clientlibs/#standard-and-runtime-collectors)已经内置了若干指标，可以通过如下代码进行收集：

```javascript
const client = require('prom-client');
const collectDefaultMetrics = client.collectDefaultMetrics;
collectDefaultMetrics();
```

**代码 14.2.2.1**

上述代码运行时，会收集包含 CPU 时间、堆大小、进程文件句柄数、Event Loop 暂停时间、libuv 句柄数等信息。

和其他语言的驱动不同，prom-client 没有和任何 http 框架集成，你需要手动将其包裹在一个 http 路由中：

```javascript
http.createServer((req, res) => {
    if (req.url === '/metrics') {
        client.register.metrics().then(function (str) {
            res.setHeader('Content-Type', client.register.contentType);
            res.end(str);
        }).catch(function (err) {
            res.end(err);
        });
        return;
    }
    res.end('Hello World');
}).listen(port);
```

**代码 14.2.2.2**

如果想收集自定义指标，使用起来也比较简单。现在拿 http 请求场景举例，请求计数由于其只能增加，所以只能使用计数器数据结构；请求的处理时长，由于是上下波动的，所以可以使用仪表盘来上报，也可以指定若干桶数值将其上报为直方图结构，与其类似，指定百分位数就可上报为摘要结构。两者的示例代码如下：

```javascript
const client = require('prom-client');
const { commonLabels, commonLabelNames } = require('../config');
const counter = new client.Counter({
    name: 'req_count',
    help: 'http request count',
    labelNames: ['path', 'status', ...commonLabelNames],
});
exports.addReqCount = function (path, status) {
    counter.inc({ ...commonLabels, path, status }); // Increment by 1
};
```

**代码 14.2.2.3 对于计数器使用的示例代码**

```javascript
const client = require('prom-client');
const gauge = new client.Gauge({
    name: 'req_duration',
    help: 'request duration'
});
const histogram = new client.Histogram({
    name: 'req_duration_histogram',
    help: 'request duration histogram',
    buckets: [10, 20, 40, 60, 80, 100, 120, 140, 160, 180]
});
const summary = new client.Summary({
    name: 'req_duration_summary',
    help: 'request duration summary',
    percentiles: [0.01, 0.1, 0.5, 0.9, 0.99],
});
exports.collectDuration = function (duration) {
    gauge.set(duration);
    histogram.observe(duration);
    summary.observe(duration);
};
```

**代码 14.2.2.4 对于仪表盘、直方图、摘要的示例代码** ：

## 14.3 指标可视化

指标写入 Prometheus 后，我们还需要使用 grafana 将其做可视化。Prometheus 主动来应用服务中抓取指标数据， grafana 也会定时从 Prometheus 中抓取指标数据来绘制报表。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-5c5cf5878c120b0fa7bab2346c64a9ed4f6a2fa7%2Fdata_flow_prometheus.drawio.png?alt=media) **图 14.3.0 指标采集数据流图**

### 14.3.1 默认指标收集

**代码 14.2.2.1** 是一个简单的指标收集代码，但是它没有考虑到生产环境使用时会部署若干个容器节点，为了更便捷观测某一个服务的运行状态，我们更倾向通过集群名称或者部署服务名称来对节点进行筛选。为了这么做，我们首先改造一下 **代码 14.2.2.1** ，因为我们要引入一个 `lable` 的概念。 为了方便数据做聚类统计，Prometheus 支持对每条采集数据中添加如果标签（`label`）。我们这里正是利用这个特性，对我们的数据添加上服务名称和命名空间（这里模拟 k8s 的命名空间概念）标签：

```javascript
const client = require('prom-client');
const { commonLabels } = require('./config');  

const collectDefaultMetrics = client.collectDefaultMetrics;
collectDefaultMetrics({
    labels: commonLabels,
});
```

**代码14.3.1.1 添加了 lables 属性的采集数据**

为了快速搭建一个 Prometheus 的数据采集环境，这里准备了一份 docker-compose 文件

```yaml
services:
  alertmanager:
    restart: always
    image: prom/alertmanager
    network_mode: host
    volumes:
      - ./alertmanager:/etc/alertmanager
  prometheus:
    restart: always
    user: root
    privileged: true
    image: bitnami/prometheus
    container_name: prometheus-dev
    network_mode: host
    volumes:
      - ./prometheus:/opt/bitnami/prometheus/conf
    depends_on:
      - alertmanager
  grafna:
    restart: always
    image: grafana/grafana
    user: root
    volumes:
      - ./grafana-persistence:/var/lib/grafana
    network_mode: host
    environment:
       GF_SECURITY_ADMIN_PASSWORD: "secret"
    depends_on:
      - prometheus
```

**代码 14.3.1.1 docker-compose.yml**

上面配置文件中，我们设置了一个卷映射 `./prometheus:/opt/bitnami/prometheus/conf` ，这是为了方便我们修改配置文件用，因为镜像 `bitnami/prometheus` 默认将配置文件放置到了 `/opt/bitnami/prometheus/conf` 目录下，我们在本机 `prometheus` 文件夹下放一个 `prometheus.yml` 文件即可被 Prometheus 读取到，这个配置文件的内容如下：

```yaml
# my global config
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).
# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets:
      - alertmanager:9094
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: 'prometheus'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['localhost:9090']
  - job_name: 'nodejs'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.

    static_configs:
    - targets: ['你node进程所在的ip1:3001', '你node进程所在的ip2:端口2']
```

**代码 14.3.1.2 prometheus/prometheus.yml**

> 注意最后一行，你需要正确填写你的 node 进程所在的 IP，在某些环境下，这个 IP 可以用 `host.docker.internal` 替代。

在 docker-compose.yml 所在目录中，通过命令 `docker compose up -d` 可以快速启动一个运行环境。在浏览器中打开地址 <http://localhost:9090/> ，然后在输入框中输入 `nodejs_version_info`，然后回车。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9f43005a37b61d8b8db19fee42cb55df7786a2f2%2Fshow_metric.png?alt=media)

**图 14.3.1.1**

执行输出的结构格式会是这样的：

nodejs\_version\_info{instance="127.0.0.1:3001", job="nodejs", major="20", minor="9", namespace="default", patch="0", serverName="chapter14", version="v20.9.0"} 1

**输出 14.3.1.1**

其中 instance job major 等类键值对的数据，在 Promethues 中称之为 Lable，最后面那个 1 是当前这条 metric 记录的值。

如果输入 node\_version\_info 表达式后回车没有出现任何值，你可以通过打开 <http://localhost:9090/targets> 连接来看一下被收集的 Endpoint 有没有 Error 信息打印出来。\
![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-c1a34af6e7f6f857e1e9ae1e55059b1d41c9f545%2Fprometheus_targets.png?alt=media)

**图 14.3.1.2**

正常情况下，每行 targets 记录的 Error 列应该是空的。

接着打开 <http://localhost:3000/login> ，输入用户名密码 `admin` `secret` 即可进入。然后依次选择左侧菜单 **Connection** -> **Data Source** ，然后点击按钮 **Add Data Source**，接着会提供一系列的数据源供给选择，我们选择 Promethues 即可。最后是 Prometheus 的连接配置，我们在 `Prometheus server URL` 栏填入 `http://localhost:9090` ，然后点击 **Save & test** 按钮，正常情况下会提示 `✔ Successfully queried the Prometheus API.` 。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-1edf0a3140c923a7a71aea194557b6a0ef4b4105%2Fprometheus_data_source.png?alt=media)

**图 14.3.1.3 添加 prometheus 数据源**

然后我们来添加一个面板将指标数据呈现出来，重新回到左侧菜单，选择 Dashboards ，然后点击按钮 Create Dashboard ，显示的操作方式中选择 Import a dashboard： ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-0e9484a00ad3a6d32905122b53771c3f3a941799%2Fimport-dashboard.png?alt=media) **图 14.3.1.4 选择导入面板**

在展示的 **Find and import dashboards for common applications at** [**grafana.com/dashboards**](https://grafana.com/grafana/dashboards/) 输入框中写入 11159，并点击 **Load** 按钮。 ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-af83a957c7d784c16b8be1df57f74199c87d9d15%2Finput_imported_dashoboard_id.png?alt=media) **图 14.3.1.5 输入面板 id**

`1159` 是 grafana.com 上公开的模板 id，具体说明可以参见 [NodeJS Application Dashboard | Grafana Labs](https://grafana.com/grafana/dashboards/11159-nodejs-application-dashboard/)，我们将使用这个模板来对 **代码 14.2.2.1** 采集的数据做图标展示。最后我们需要绑定一下面板关联的数据源，在下拉框 prometheus 输入框中选择我们刚才创建的数据源： ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-b0dbb9acc519a3b5c1675f5e010897a276b4a125%2Fbind_grafana_data_source.png?alt=media) **图 14.3.1.6 绑定数据源**

点击上图的 Import 按钮后，我们就初步完成了报表展示了，会长成这个样子： ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-68c09d4f13f5c0bb6d65b8a93c0f9edcb58cc33c%2Fdashboard_grafana_init.png?alt=media) **图 14.3.1.7 配置初始化完成后展示的面板**

目前我们仅仅演示了一个服务，正常生产环境的服务数可不止一个，有可能有十几个、几十个，甚至更多，而我们在从上图中的 **Instance** 下拉框中进行筛选是一个很困难的事情。还记得我们改造过的 **代码14.3.1.1** 不，现在它能派上用场了。

**代码14.3.1.1** 中引用了来自文件 `config.js` 的 `commonLabels` 常量，这个常量的定义如下：

```javascript
const { name } = require('./package.json');
exports.commonLabels = {
    serverName: name,
    namespace: 'default',
};

exports.commonLabelNames = Object.keys(exports.commonLabels);
```

**代码 14.3.1.3 config.js**

通过上述代码可以看出 `commonLabels` 常量有 `serverName` 和 `namespace` 两个属性，分别代表启用服务的名称和所在命名空间（可以理解为 k8s 系统中的命名空间的概念），另外从**输出 14.3.1.1** 中也能看到这两个 Lable 的具体值。我们的目标就是在 **图 14.3.1.6** 中再增加两个筛选框，分别为 `namespace` 和 `serverName`，保证选中指定 `namespace` 时能够级联筛选出其下的 `serverName`，选中 `serverName` 时能够筛选出级联的 `instance` 实例。

点击 **图 14.3.1.6** 上部中间位置的 **Edit** 按钮，进入设置模式，然后点击 **Settings** 按钮，即可打开设置界面。点击 **Variables** 选项卡，界面中会呈现出来当前的 instance 变量的定义，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-ebdd8a9454c569314220339101e10fbd0f71ac0d%2Fgrafana_variables.png?alt=media)

**图 14.3.1.8**

现在我们要添加一个 `namespace` 变量，点击 **New variable** 按钮。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-8ba7959e1f9c415fb84693dea52607d21a14b42f%2Fnamespace_variable.png?alt=media)

**图 14.3.1.9 添加 namespace 变量**

表单项中 name 输入框我们输入 namespace ，这样我们就新建了一个变量名字，叫 namespace；Lable 输入框填入的 namespace 值，将会导致在 \*\*图 14.3.1.6 \*\* 中新增一个下拉框，且标记为 namespace，这里你也可以将其改为任何字符，比如说说改成中文名字 `集群`。

**Query options** 区域是这里配置的核心区域，首先在 **Data source** 区域选择好之前创建好的 Promethues 数据源。下面的 **Query** 表单中，**Query type** 选择 `Label values`，代表我们将从 Prometheus 数据中的 label 属性中提取数据；**Labels** 选择 `namespace` ，代表我们使用数据中 label 名字为 `namespace` 的值进行提取；**Metric** 选择 `node_version_info` ，代表我们只从 `node_version_info` 中提取 label 名字为 `namespace` 的值。

回到 **Variables** 选项卡再创建一个 `serverName` 变量，这次我们所有的操作都跟 `namespace` 类似，唯独下图中红框中标出来的内容：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-c122881860a81057048952034ef0f4ede5240c40%2Ffilter_label_variable.png?alt=media)

**图 14.3.1.10 筛选 Lable 值**

我们增加一个 `namespace =~ $namespace` 的表达式，就能够实现在指定 `namespace` 值下筛选 `serverName` Label 值的能力。对于这个表达式来说 =\~ 前面代表名字为 `namespace` 的 Prometheus Label，等号后面的代表前面我们定义的 `namespace` 变量的值。

> 注意这里我们在 lable 筛选的时候用了 `=~` ，而不是常用的 `=`，这个后面会讲到。

最后我们要修改一下原来的 instance 变量的，添加两个的 Label filters ，分别为 `serverName =~ $serverName` 和 `namespace =~ $namespace`。然后回到 Variables 选项卡，拖动调整一下三个变量的顺序，保证 namespace 第一位、serverName 第二位、instance 第三位。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9abafc03c0edc514095ffea895a1c77442dd2ca9%2Fvariables_ordered.png?alt=media)

**图 14.3.1.11 调整顺序后的变量**

上图中 namespace 和 serverName 上面标识了 ⚠️，代表当前变量没有被其他变量引用，但是我们刚才通过设置过滤表达式，已经将所有变量关联起来了，这属于误报，你可以通过点击变量列表左下角按钮 **Show dependencies**，即可查看依赖关系，正常情况下你看到的依赖关系如下图所示：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-5caac869ca3acd7bd6143dd5ab651b4659aeb96f%2Fvariables_deps.png?alt=media)

**图 14.3.1.12 变量依赖关系**

如果你看到的依赖关系没有形成上述依赖链的形式，代表上述的配置中哪个地方是有问题的，需要重新检查一遍。

最后，还有一件最重要的事情，你上面做的所有操作都只是临时操作，如果你不点击顶部右上侧的 **Save dashboard** 按钮话，所有你做的一切都将是无用功。点击保存按钮后，还是提示你写入这次更改变动的说明信息，方便你以后查阅的时候使用。

重新回调面板，我们最终就能看到一个可供级联筛选的三个下拉框了：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-aac63f057bb2d9bdccbae695ff4d543d365f2ab9%2Fvariables_selectors.png?alt=media)

**图 14.3.1.13 级联下拉框**

### 14.3.2 收集自定义指标

**代码 14.2.2.3** 和 **代码 14.2.2.4** 中演示调用 prom-client 来生成指标数据的函数，而下面代码就是来调用这些这封装函数的代码

```javascript
const SAMPLES = [20, 50, 80, 80, 100, 100, 100, 120, 120, 140, 160];
const SAMPLES_LEN = SAMPLES.length;
const STATUS = [200, 200, 200, 200, 304, 488, 510, 511];
const STATUS_LEN = STATUS.length;
http.createServer((req, res) => {
    const duration = SAMPLES[Math.floor(Math.random() * SAMPLES_LEN)];
    const begin = Date.now();
    setTimeout(function () {
        const url = req.url;rr
        console.log('url', url, req.headers['user-agent']);
        const path = url.split('?')[0];

        res.statusCode = STATUS[Math.floor(Math.random() * STATUS_LEN)];
        addReqCount(path, res.statusCode);
        collectDuration(path, Date.now() - begin);
        res.end(JSON.stringify({
            url,
            method: req.method,
        }));
    }, duration);
}).listen(port, '0.0.0.0');
```

**代码 14.3.2.1 http请求指标采集**

其中 `addReqCount` 函数的定义参见 **代码 14.2.2.3**，`collectDuration` 函数的定义参见 **代码 14.2.2.4** 。

要想制作自定义 grafana 图标，我们得首先了解 Prometheus 的基本概念。我们点击 **图 14.3.1.2** 中 nodejs 对应的 endpoint 链接，打开的页面中会包含类似于如下内容输出：

```
# HELP req_count http request count
# TYPE req_count counter
req_count{serverName="chapter14",namespace="default",path="/c",status="200"} 3
req_count{serverName="chapter14",namespace="default",path="/c",status="511"} 2
req_count{serverName="chapter14",namespace="default",path="/a",status="200"} 4
req_count{serverName="chapter14",namespace="default",path="/a",status="304"} 2
req_count{serverName="chapter14",namespace="default",path="/c",status="510"} 3

# HELP req_duration request duration
# TYPE req_duration gauge
req_duration{serverName="chapter14",namespace="default",path="/c"} 94
req_duration{serverName="chapter14",namespace="default",path="/a"} 110
req_duration{serverName="chapter14",namespace="default",path="/b"} 126
```

**输出 13.3.2.1**

由于上述输出全都是一些离散的点，理解起来有些难度，我们将其转成平面图形来进行分析：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-1a1e5af211d030086f3ff5b00b10a52d62fad3cb%2Fprometheus_time_series.drawio.png?alt=media)

**图 14.3.2.1 Prometheus 基本数据结构图示**

> 为了构图方便，这里简单认为各个 `req_count` 指标中只有 `status` 这一个 label。

我们在 **代码 14.3.1.2** 中会看到里面有一个 `scrape_interval` 参数，设定的值是 15s，代表 Prometheus 默认会每隔 15s，去 **图 14.3.1.2** 中定义的各个 endpoint 上抓取数据。如果给 **输出 13.3.2.1** 绘制一张示意图的话，那么这里面两个相邻点的时间间隔应该为 15s。

我们抽取范围向量中某一个时间点的数据集，就像我们在上图红色框中框选的那样，我们抽取 t2 时间点上的 `req_count` 指标的数据集，这个数据集被称为 **瞬时向量（Instant Vector）** 。`req_count` 这个表达式就是一个瞬时向量，它其实是一个简写，省略了当前时间，如果我们想指定非当前时间的数据需要写 `req_count @ 时间戳`，比如说 `req_count @ 1732458388` 代表 req\_count 在 2024-11-24 22:26:28 时间点的数据。因为 `req_count` 上有一个 `status` 的label，这个 lable 有很多可选值（200、500 等），所以这个瞬时向量转化成编程语言的概念的话，就可以认为是一个数组。我们给出下面一组数组来演示这个数组：

```javascript
[
	10/*lable为200的req_count值*/,
	1/*lable为500的req_count值*/,
	2/*label为501的req_count值*/
]
```

**代码 14.3.2.2 瞬时向量演示**

如果我们将数据集范围继续缩小，只取其中一个点，也就是在瞬时向量中添加 lable 属性来精确筛选出一个点对应的数值，被称之为 **标量（Scalar）**。比如说 `req_count{status="200"} @ 1732458388`， 它代表 HTTP 响应码为 200 的 req\_count 指标在 2024-11-24 22:26:28 时间点的值，也就是 **代码 14.3.2.1** 中的 `10` 这个值。

> 标量其实就是一个浮点类型的数值，是没有表达式概念的，表达式`req_count{status="200"} @ 1732458388` 依然是一个瞬时向量，只不过其对应的数组里面只有一个元素，也就是说这个数组长这样 `[10]` 。

我们用绿色框标识的几个点，是位于 `[t1,t4]` 时间段内的 `req_duration` 指标的一个数据集，这个数据集被称为 **范围向量（Range Vector）** 。如果将其转化为编程语言中的概念的话，用二维数组更好理解一些，下面是我们对其的数据演示：

```javascript
[
	[// 时间为t1的瞬时数据，t1=1732458388
		10/*lable为200的req_count值*/,
		1/*lable为500的req_count值*/,
		2/*label为501的req_count值*/
	],
	[// 时间为t2的瞬时数据, t2=1732458448
		18/*lable为200的req_count值*/,
		2/*lable为500的req_count值*/,
		3/*label为501的req_count值*/
	],
	[// 时间为t3的瞬时数据，t3=1732458508
		20/*lable为200的req_count值*/,
		3/*lable为500的req_count值*/,
		3/*label为501的req_count值*/
	],
	[// 时间为t4的瞬时数据，t4=1732458568
		22/*lable为200的req_count值*/,
		3/*lable为500的req_count值*/,
		4/*label为501的req_count值*/
	],
]
```

**代码 14.3.2.3 范围向量演示**

`req_count[4m]` 就是一个范围向量的表达式，它表示 req\_count 指标在当前时间往前 4 分钟内的所有数据。我们直接使用范围向量的情况很少，一般是作为过渡存在，再次将其转化为瞬时向量。在程序中我们使用 req\_count 来表示当前请求的累计总数，这个数字是单调递增的，但是如果我们想知道每秒请求数，可以使用 rate 函数，比如说 rate(req\_count\[4m]) 就可以计算 4 分钟内每秒请求数，也就是请求速率。根据高中物理公式，`速率 = 距离 / 时间`，我们假定在四分钟内 t1 为第一个采集到的数据，t4 为最后一个采集到的数据，则对于 `status` 为 200 的指标来说，其速率为

```
rate=(22-10)/(4*60)​=0.05 请求/秒
```

**公式 14.3.2.1 速率公式**

根据上述公式，我们依次带入各个 status 的 req\_count 首尾差值，就会得到一个瞬时向量：

```
[
	0.05/*lable为200的req_count速率*/,
	0.008/*lable为500的req_count速率*/,
	0.008/*label为501的req_count速率*/
]
```

**代码 14.3.2.4 应用 rate 函数后生成的瞬时向量**

grafana 使用 promql 来绘制图表，我们在 **图 14.3.1.7** 中看到的各个图标，都是基于 promql 语句查询绘制的界面，不过 grafana 要求这些 promql 必须是瞬时向量。最简单的瞬时向量就是直接写指标名字（例如 `req_count`）。

我们在 **图 14.3.1.7** 的右上角位置找到 **Add** 菜单，点击打开后选择 **Visualization**：

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-f886df187818ee7dae19d65429440d9affe4c3b8%2Fadd_visual.png?alt=media)

**图 14.3.2.2 添加 grafana 可视化面板菜单**

在弹出的界面中输入表达式 `req_count{instance=~"$instance"}` :

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-11e8664cf32661715adf302e9fa03e378541a5c1%2Finput_visualization_express.png?alt=media)

**图 14.3.2.3 填写可视化所需的表达式**

注意这里我们在 lable 筛选的时候用了 `=~` ，而不是常用的 `=` ，这是由于 grafana 中变量值的特殊性导致的。为了方便用户在 **图 14.3.1.13** 中的区域中能够实现多选的效果，grafana 将定义的变量，比如说 `$instance` 做成了一个正则格式的字符串，所以 `$instance` 不会出现 `127.0.0.1:3000` 这种格式的值，而是会转化成 `[127\.0\.0\.1:3000]` 这种格式。

需要注意，我们 grafana 只支持瞬时向量和标量，不支持范围向量，但是这并不代表范围向量是没有用的。对于计数器类型的指标来说，在 grafana 中将会呈现出一条单调递增的直线（如图 **图 14.2.1.1.1** 所示），不是很好对其进行分析，这时候你可以使用 Prometheus 内置函数先将计数器先转化成范围向量，然后再对范围向量内的数值做运算转成一个瞬时向量，比如说 rate 函数就是将范围向量转成瞬时向量的常用操作，这样你就可以分析单位时间内的变化速率了。

## 示例代码

本章节示例代码可以从这里找到 <https://github.com/yunnysunny/nodebook-sample/tree/master/chapter14>


# A1 Node.js 好用工具介绍

工欲善其事必先利其器，Node 语言的发展离不开一帮优质第三方库。下面提到的各个包基本在书中没有提到，属于查漏补缺，这里仅仅是列一个清单，供大家查阅。

## A1.1 log.io

可以在浏览器中实时监控服务器的日志的一个库。

<http://logio.org/>

```
npm config set unsafe-perm true 
npm install -g --prefix=/usr/local log.io
log.io server start

http://localhost:8998
```

## A1.2 socket.io

websocket给前端带来了变革，从此前端也可以光明正大的用上长连接，socket.io正是顺应此时势而生的的。它在高版本浏览器上使用 websocket ， 在低版本浏览器上使用 ajax 轮询，保证对所有浏览器的兼容。虽然本书没有对其拿出专门的章节进行介绍，但是它真的很重要。

<https://socket.io/>

```
npm install socket.io
```

## A1.3 npm-check-updates

`npm-check-updates` 是一个检查并升级 `package.json` 中依赖版本号的工具。它不安装包，只修改 package.json，实际安装仍需你运行 `npm install` / `pnpm install` / `yarn install`，- 支持交互式选择、过滤依赖类型、指定升级策略等。

可以全局安装 `npm-check-updates`

```bash
npm install npm-check-updates -g 
```

然后在项目目录中运行 `npx ncu` ，可以查看当前项目可以升级的包列表，但是不会做任何修改；运行 `npx ncu -u` 会修改 package.json，但是不会做安装动作；运行 `npx ncu -i` 会出现交互式选择界面，使用空格键可以对指定包做选择或者取消选择，使用方向键来选择某个指定包，选择完成后回车会提示你是否现在安装。

## A1.4 depcheck

`depcheck` 帮你扫描项目中「未使用」的依赖和「缺失」的依赖，保持 `package.json` 干净整洁。未使用的依赖，会导致你的项目臃肿，甚至会影响打包的体积；缺失的依赖有可能导致在生产环境中由于只安装了生产依赖，很有可能导致缺失依赖导致程序启动报错。

程序使用很简单

```bash
npm install depcheck -g
```

之后在项目根目录直接运行 `depcheck` 命令即可，如果项目中有问题，会有如下输出：

```
Unused devDependencies
* @nestjs/schematics
* @types/jest
* @typescript-eslint/utils
* jest-junit
* source-map-support
* ts-loader
* tsconfig-paths
Missing dependencies
* @opentelemetry/sdk-trace-base: .\src\instrumentation.ts
```

有一些 `devDependencies` 是开发工具用到的，项目源代码中没有直接引用，所以会被暴漏出来，所以不要着急将它们移除。`dependencies` 中的缺失依赖是我们应该重点关注的。


# A2 参考资源

## A2.1 Node.js 书籍

* [深入浅出Node.js](https://item.jd.com/11355978.html), 2013-12-01, 朴灵
* [Node与Express开发](https://item.jd.com/11644104.html), 2015-01-01, \[美] Ethan Brown, 吴海星，苏文 译
* [Node.js 硬实战](https://item.jd.com/12083430.html) ，2017-01-01，\[美] Alex R.Young（亚历克斯・荣），Marc Harter（马克・哈特） 著，承竹 等 译
* [ES 6 标准入门（第2版）](https://item.jd.com/11849235.html),2015-12-01,阮一峰

## A2.2 Node.js 教学网站

* [Node School](https://nodeschool.io/)


# A3 书籍写作规范

## A3.1 专有名词

技术类专有名词依循官方常见用法。

* Node.js
* npm
* JavaScript

## A3.2 标点符号

以中文全角标点符号为主。

```
，、。！“”（）
```

遇英文段落（整句）采用一般（半角）标点符号。中文与英文单字之间以一个空白字符隔开，标点符号与英文单字之间不需要空隔。这是为了让排版显示的自动断词断句可以正确运作，以及增加中英文混杂段落的阅读舒适。举个例子：

> Node.js 是一种适合用于 Server-side 的开发框架（Framework），相当 Nice！

## A3.3 特殊排版说明

中文同一段落为了方便编辑，可以将句子断行；但行尾必须加上半角“”倒斜线符号。因为在 HTML、EPUB 或 MOBI 格式中，换行字符会被当做空白字符处理，导致增加过多影响版面美观的空隔。

## A3.4 代码

片段程序码（snippets），使用 inline code block，并指定 language type。

```javascript
if (something) {
    alert('test');
}
```


# A4 HTTP请求参数

既然我们称 Node.js 是一门后端语言，那他就应该能处理 HTTP 请求中的请求参数，比如说我在 URL 上添加查询参数（类似于这种 `/xxx?a=1&b=2`），再比如说通过表单提交数据。 Node 确实提供了处理这两种数据的能力，只不过让人感觉到稍显“低级”。为什么这么说来，下面就一一道来。

## A4.1 GET 请求

首先我们来简单描述一下 HTTP 请求，打开浏览器，并且打开开发者工具，然后使用谷歌搜 `node`，我们定向到开发者工具的 Network 标签页，然后开第一条网络请求，鼠标单击点开这条网络请求，会显示格式化好的HTTP 请求和响应的数据包内容：\
![谷歌搜索](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2Fcb9da1c6a56a1bb875ccdcc32caabb510cf31076.png?generation=1629371714191749\&alt=media)\
**图 A4.1.2 使用谷歌搜索 node**

我们这里仅仅关注一下 `General` 部分，`Request URL` 中 `?` 后面是一大串请求参数，在我们这里是

`?newwindow=1&safe=strict&site=&source=hp&q=node&oq=node`

，由于参数太长了，我们故意省略掉一些。然后还有一个参数 `Request Method（请求方法）` 为 GET，HTTP 协议中很几种常用的请求方法：GET POST HEAD PUT DELETE，其中最常用的就是前两者。这个栗子中给出的请求方法正是 GET 请求，它将参数类似 `?key1=value&key2=value2` 方式组织在一起，拼接在请求地址的后面，发送到服务器端，服务器要解析这个请求参数，然后得到参数 key1 的值是 value1， key2 的值是 value2，继而进行逻辑处理。\
具体到上面这个栗子中，我们要在后端取到 `source` 这个参数的值，应该怎么做呢？ 可能很多之前有 php 或者 java 经验的开发者，会通过 `$_GET['source']` 或者 `request.getParamater("source")` 能够轻而易举的把这件事给办了，但是 node 不行。下面我们对**代码 3.3.1**稍加改造来实现这个目的：

```javascript
const http = require('http');
const url = require('url');
const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  const query = url.parse(req.url,true).query;
  console.log(query);
  res.setHeader('Content-Type', 'text/plain');
  res.end(`Hello World from ${query['source']}\n`);
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});
```

**代码 A4.1.1 get.js GET 请求参数处理**

运行 `node get.js`，打开浏览器输入 `http://127.0.0.1:3000/?newwindow=1&safe=strict&site=&source=hp&q=node&oq=node`，会打印出 `Hello World from hp`。

注意**代码 A4.1.1** 中的 `url` 库的 [parse](https://nodejs.org/dist/latest-v6.x/docs/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost)函数，它对 URL 地址进行解析，得到 URL 中的域名、请求路径等参数，其中第二参数在这里要设置true，否则它不会解析请求参数。

## A4.2 POST 请求

刚才我们在演示 GET 请求的使用的是谷歌搜索的栗子，它的请求参数在地址栏中都可以看到，这种方式简介明了，你甚至可以给朋友发一个网址，他就可以搜出来跟你一样的内容。但是有时候，我们还要在互联网上做一些比较隐私的事情，比如说登录某个网站，你肯定不想让你的用户名、密码出现在浏览器的地址栏中，这个时候 POST 请求就派上用场了。

我们把**代码 A4.1.1**稍微修改一番，来演示一下后端怎样读取 POST 参数：

```javascript
const http = require('http');
const url = require('url');
const qs = require('querystring');
const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  const query = url.parse(req.url,true).query;
  let postStr = '';
  req.on('data',function(data) {                
    postStr += data;
  });
  req.on('end', function() {
    const post = qs.parse(postStr);
    res.setHeader('Content-Type', 'text/plain');
    res.end(`Hello ${post['name']} World from ${query['source'] || 'unknown'}\n`);
  });

});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});
```

**代码 A4.2.1 post.js POST参数处理**

从**代码 A4.2.1**可以看出处理 POST 参数时竟然要需要从流中读取数据，这是由于在 HTTP 协议中 POST 数据被认为可以发送大容量的请求数据，所以为了防止堵塞，Node 强制使用流来处理这部分数据。

由于不能直接通过浏览器输入地址来测试 POST 请求，所以推荐大家使用一个 chrome app来测试 POST 请求，它就是 [Postman](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?utm_source=chrome-ntp-icon)。安装完成之后，打开主界面，下拉菜单区域选择 `POST`，然后输入地址 `http://localhost:3000`，打开 Body 标签，选择`x-www-form-urlencoded`，最后点击 Send 按钮，完成后就会输出`Hello sunny World from unknown`。

![Postman 发送 POST 请求](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F2d36ca24ffa8db4b348f79a7d8ee6a73fa4d2a66.png?generation=1629371714239231\&alt=media)\
**图 A4.2.1 Postman 发送 POST 请求**

这里之所以需要选择`x-www-form-urlencoded`，是由于 POST 数据包有很多组织方式，我们最常用的就是这种方式，其数据格式依然是`key1=value1&key2=value2`，而 Node 自带的 [querystring.parse函数](https://nodejs.org/dist/latest-v6.x/docs/api/querystring.html#querystring_querystring_parse_str_sep_eq_options) 正是用来处理这种字符串的。同时我们可以点击 Code 按钮，来查看 POST请求发送的请求数据包：

![POST请求数据包](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F9fca6e792743437dbe061639fe9cb7e169655243.png?generation=1629371714055810\&alt=media)\
**图 A4.2.2 POST请求数据包**

你可以留意到请求包中当中有一个空行（HTTP协议中用空行来分割请求头和请求正文），空格下面的`name=sunny&password=1234`就是我们 POST 到后台的数据。


# A5 安装多版本 Node

Node 基本上在每年国庆节放假回来后，就会发布一个 LTS 版本，会给开发者带来具有新特性的稳定版本。对于开发者来说，肯定会有跟进新版特性的需求，但是有可能在升级过程中又会发现部分项目的依赖包短时间内又不能兼容新版本的 Node，这样就导致一个比较尴尬的现象，你本地开发环境起码得维护两个 Node 版本来回切换。[nvm](https://github.com/nvm-sh/nvm) 就是解决这个需求的一个命令行工具。

## A 5.1 安装

### A 5.1.1 类 Uinx 环境

linux 和 mac 中才可以使用 nvm。

使用 curl 或者 wget 命令进行安装，也就是说 **命令 A5.1.1.1** 和 **命令 A5.1.1.2** 任选一个。

```shell
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
```

**命令 A 5.1.1.1**

```shell
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
```

**命令 A 5.1.1.2**

然后修改 `~/.bashrc` 添加如下代码

```shell
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
```

**命令 A 5.1.1.3**

然后 `source ~/.bashrc` 可以让 **命令 A5.1.1.3** 生效，运行 `nvm` 命令即可给出帮助信息打印。

### A 5.1.2 Windows 环境

Windows 环境需要使用 [nvm-windows](https://github.com/coreybutler/nvm-windows) 命令，需要去其 github 版本发布地址上下载安装包或者绿色安装文件，推荐直接下载安装包，目前最新版本 [1.1.7](https://github.com/coreybutler/nvm-windows/releases/download/1.1.7/nvm-setup.zip)。

安装过程中会让你选择 Node 程序的存放目录，如果这个目录中已经安装了 Node，使用 nvm 命令切换版本的时候，会强制将当前目录覆盖掉，不过一般我们的 Node 安装目录一般也不会放啥重要文件，所以说无所谓。

nvm-windows 比 nvm 多了一个功能，可以设定下载 Node 和 Npm 的地址。通过运行 `nvm node_mirror https://npmmirror.com/mirrors/node/ 和` nvm npm\_mirror <https://npmmirror.com/mirrors/npm/\\`> 即可从阿里镜像站下载 Node 和 Npm 了。

## A 5.2 使用

### A 5.2.1 查看当前安装的版本

```shell
$ nvm list

  * 14.17.4 (Currently using 64-bit executable)
```

### A 5.2.2 安装具体版本

这里演示的是安装 16.7.0 这个版本。

```shell
$ nvm install 16.7.0

Downloading node.js version 16.7.0 (64-bit)...
Complete
Creating d:\Users\yunny\AppData\Roaming\nvm\temp

Downloading npm version 7.20.3... Complete
Installing npm v7.20.3...

Installation complete. If you want to use this version, type

nvm use 16.7.0
```

### A 5.2.3 切换版本

将当前的版本切换为 16.7.0

```shell
$ nvm use 16.7.0
```


# A6 Node 原生扩展编译环境搭建

Node 使用 node-gyp 来编译原生扩展，需要操作系统具有原生代码的编译能力，下面分别给出类 Unix 环境和 Windows 环境的编译环境搭建说明。

## A 6.1 类 Unix 环境

linux 下安装编译环境的命令比较简单，debian 内核中可以通过 apt-get install build-essential -y 来安装，redhat 内核下可以使用 yum groupinstall "Development Tools" 来安装。

mac 下需要手动安装 Xcode。

## A 6.2 Windows 环境

Windows 环境比较复杂，需要手动安装 VS build tools 和 python。首先是 VS build tools 官网没有提供直接的下载页面，需要通过下面的链接手动下载：<https://visualstudio.microsoft.com/thank-you-downloading-visual-studio/?sku=BuildTools\\&rel=15> , 其中 `rel` 参数代表 VS 的版本号，`15` 代表 VS 2017, `16` 代表 VS 2019。

下载完成后双击运行，会弹出如下界面：

![image-20210819185317920](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2Fc105659cf748de2e97ccf60fc89f51736fb17cac.png?generation=1633696420212419\&alt=media)

**图 A 6.2.1**

点击继续按钮，会弹出安装准备进度条，这期间会下载所需的安装包：

![image-20210819185444968](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F3e41a90de5a75c19148744ed4e798ee27ae6c156.png?generation=1633696423204084\&alt=media)

**图 A 6.2.2**

安装包下载完成后，会弹出可选安装组件，只勾选 **Visual C++ 生成工具** ,然后点击 **修改** 按钮即可。

![component](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-141b1ad22c51c20376b5781f99880df325be7ef3%2Fcomponent.png?alt=media)

正常情况下，进度条走完后会显示安装成功界面

![image-20210819185549212](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2Fbfa0034ab1ed91994673456dfc79dd2fd1cbbd60.png?generation=1633696421223895\&alt=media)

**图 A 6.2.3**

这时候关闭安装界面即可。

接下来是 python 的安装过程，直接在命令行中输入 python 命令，正常情况下会弹出 Windows Store 的安装界面，

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F89a608c147a67bc117ac037add75036aca792825.png?generation=1629371714428383\&alt=media)

**图 A 6.2.4**

如果你之前已经安装完成了，会显示如下界面

![image-20210819190308564](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-M0waK6lkk4Ilj_yQF7l%2Fsync%2F3da0402209ee2522216eee9963a1d2b1c2fa7b66.png?generation=1633696422289323\&alt=media)

**图 A 6.2.5**

对于没有 windows store 的操作系统，可以直接从这个链接安装 python <https://www.python.org/ftp/python/3.10.8/python-3.10.8-amd64.exe> 安装时记得勾选添加到环境变量的标识。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-9604c21fc26ef6228d88b35f740f62706f8afce1%2Fv2-79a239ec98fbea727a0354ba553d6660_720w.jpg?alt=media)

**图 A 6.2.5**

## A 6.3 测试

接下来就可以测试一下刚才搭建的环境是否好使了，使用 `npm install native-linked-list` 来测试。注意如果你是在 Windows 上新安装的 python，那么不要在安装 python 之前的命令行的界面中运行上述测试命令，而是应该新打开一个命令行窗口来执行。


# A7 easy-monitor 环境搭建

easy-monitor 是一个 alinode 的平替开源项目。

## A 7.1 准备环境

easy-monitor 需要 MySQL 和 redis 两个外部服务来存储数据和缓存。所以在开始配置之前起码需要准备一台 MySQL 和 redis。 为了方便使用，笔者将用到服务制作成了 docker 镜像，所以这里还需要用到 docker 运行环境。

## A 7.2 拓扑结构

easy-monitor 的组件比较复杂，所以需要先讲清楚其拓扑结构才能将其正确的部署。

![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-ee8ded00e2fe44d48505c2613a88b6a982a943e4%2Feasy_monitor_data_flow.drawio.png?alt=media) **图 A7.2.1**

> 这里面 xtransit-server 是一个 websocket 服务器，其他两个服务是 http 服务器。xtransit-server 用来和 xtransit 进程建立长连接，xprofiler-console 服务用来提供管理后台，xtransit-mannager 用来处理性能日志数据。

> 虽然 xtransit 部分的功能，可以合并到应用服务代码中，但是这样有可能会干扰应用服务程序本身，所以这里给出拓扑图的时候，将其和应用服务进程分离开。

整个架构图中有两条数据流线，一条是日志收集数据流，一条是指令控制数据流。前者是将应用服务中采集到的性能指标日志发送到服务器端。后者是通过 console 控制台中发送生成 CPU Profile 或者堆快照的指令到应用程序中，同时还可以通过发送指令来命令 xtransit 进程将收集到日志文件上传到服务器端。

考虑到不同公司的网络拓扑结构会有不同，比如说有的公司内部所有服务之间网络是互通的，可以通过 IP 地址直接访问；但是有的公司会将公共服务单独隔离到一个子网中，外部必须通过网络边缘的反向代理服务器来访问。

对于需要配置反向代理服务器的情况，我们一般会选择使用 nginx，这种情况下，xtransit 进程会通过 nginx 来访问 xtransit-server 和 xprofiler-console 。下面只画出三者之间的拓扑结构： ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-cff61b0c947cb5f6e93c0e06d51cd5f2f75770f9%2Feasy_monitor_with_nginx_data_flow.drawio.png?alt=media) **图 A7.2.2** 为了实现上图的拓扑结构，需要在 nginx 中配置不同的域名来实现反代到不同服务的目的。

## A 7.3 运行服务器端

### A 7.3.1 数据库初始化

库 `xprofiler_console` 使用 [xprofiler-console/db/init.sql](https://github.com/X-Profiler/xprofiler-console/blob/master/db/init.sql) 进行初始化，库 `xprofiler_logs` 使用 [xtransit-manager/db/init.sql](https://github.com/X-Profiler/xtransit-manager/blob/master/db/init.sql) 以及 [xtransit-manager/db/date.sql](https://github.com/X-Profiler/xtransit-manager/blob/master/db/date.sql) 进行初始化。

> 使用 dbear 等工具的时候，新建完连接后，默认只能执行单条 SQL 语句，将上面的 SQL 代码全选复制到其控制台中执行时，会报语法错误。需要设置驱动属性的 `allowMultiQueries` 参数为 true ：![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-89a4900db4156a9516dff4644c00b8eae1cbde62%2Fallow_multi_sql.png?alt=media) **图 A7.3.1.1**

这里需要注意的是，easy-monitor 后端服务使用的 MySQL 库是 [egg-mysql](https://github.com/eggjs/egg-mysql)，其最终是对 [mysql](https://github.com/mysqljs/mysql)的封装，但是这个库有一个缺陷，MySQL 8 默认使用的 caching\_sha2\_password 的鉴权方式，但是这个库不支持这种鉴权模式。所以在如果你使用的数据库是 MySQL 8 的话（使用 docker 创建的 MySQL 容器，默认版本就是 8.x 的版本），在不修改默认配置的情况下，得用超级管理员新建一个用户用来提供对 `xprofiler_console` 和 `xprofiler_logs` 两个库的访问：

```sql
CREATE USER 'easy'@'%' IDENTIFIED WITH mysql_native_password BY '********';
grant all privileges on xprofiler_console.* to 'easy'@'%';
grant all privileges on xprofiler_logs.* to 'easy'@'%';
flush privileges;
```

### A 7.3.2 启动服务器端

#### A 7.3.2.1 启动容器

为了简化部署，笔者做了一个集成 xtransit-server xprofiler-console xtransit-mannager 三个服务的 docker 容器，是基于官方的 [all-in-one](https://github.com/X-Profiler/all-in-one) 项目优化而来。之所以重新做一个镜像出来，是由于官方镜像将配置信息采用磁盘文件映射的方式来提供，不是很贴合传统运维使用习惯，所以这里提供了一版全部通过环境变量来设置的镜像。

> 镜像项目地址为 [whyun-docker/node (github.com)](https://github.com/whyun-docker/node)

由于牵扯到的环境变量比较多，所以这里直接给出一个 .env 文件：

```shell
CONSOLE_MYSQL_HOST=mysql 访问 ip 或者域名
CONSOLE_MYSQL_PORT=mysql 端口号
CONSOLE_MYSQL_USER=mysql 用户名
CONSOLE_MYSQL_PASSWORD=mysql 密码
LOGS_MYSQL_HOST=mysql 访问 ip 或者域名
LOGS_MYSQL_PORT=mysql 端口号
LOGS_MYSQL_USER=mysql 用户名
LOGS_MYSQL_PASSWORD=mysql 密码
REDIS_SERVER=reidsIp1:redisPort1[,redisIp2:redisPort2]
REDIS_PASSWORD=redis 密码
CONSOLE_BASE_URL=console 服务的访问地址，例如 http://xxx-console.domian.com
```

然后我们可以通过 `sudo docker run --env-file ./.env -p 8443:8443 -p 8543:8543 -p 9190:9190 --name=easy-monitor -d yunnysunny/easy-monitor` 来启动服务。注意环境变量 CONSOLE\_BASE\_URL 的配置，根据 **图 A7.2.1** 的拓扑结构结构，其值要保证集成 easy-monitor 的 Node 服务能够访问到，否则上传性能分析文件就会失败。

#### A 7.3.2.2 配置nginx

如果当前网络拓扑结构中不需要配置 nginx，可以跳过此小节。如果需要配置 nginx 的话，则需要添加反向代理配置。其中对于 xprofiler-console 来说给出如下配置示例：

```nginx
server
{
    listen 80;
    server_name ezm-console.your-domian.com;
    client_max_body_size 100m;

    location / {
        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:8443;
    }

    access_log  /var/log/nginx/access.log  main;
}
```

> 我们这里将反代的地址写成了 127.0.0.1，这是由于我的环境中 nginx 和 docker 是部署在一台机器上的，读者如果部署时，根据情况自己做修改。

由于 xprofiler-console 是纯 HTTP 服务，所以反向代理的配置比较简单。需要注意的是，这里配置了一个 `client_max_body_size` 属性（此属性支持在 http server location 块中设置，具体使用方法参见官方[文档](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size)），这是由于 nginx 默认的上传大小是 1MB，但是我们生成的性能分析文件，大多数情况下又超过 1MB。如果使用默认值配置，稍后上传文件的时候 nginx 会返回 413 状态码，在 xprofiler-console 管理界面中会有弹出如下提示： ![](https://1301203993-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-M0waK6lkk4Ilj_yQF7l%2Fuploads%2Fgit-blob-23017e1f863aa41f043488188c50f01d7db53406%2Ftoo_large_upload_ezm.png?alt=media) 对于 xtransit-server 服务的配置，则比较特殊，因为其牵涉到 WebSocket 服务：

```nginx
# 如果没有Upgrade头，则$connection_upgrade为close，否则为upgrade
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
server
{
    listen 80;
    server_name ezm-ws.your-domain.com;

    location / {
        proxy_redirect off;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://127.0.0.1:9190;

        proxy_http_version 1.1;
        proxy_set_header Host $host;
        # 下面这两行是关键
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }

    access_log  /var/log/nginx/access.log  main;
}
```

首先反向代理的的 HTTP 协议版本要选择 1.1，其次要设置 Upgrade 和 Connection 两个头信息来请求 Node 服务，否则无法正常完成 WebSocket 的握手过程。

## A 7.4 配置客户端

为了方便读者使用，这里我将集成 xtransit 的 node 镜像也制作出来了。可以先通过一个 dockerfile 文件了解一下使用步骤：

```dockerfile
ARG IMAGE_VERSION
FROM yunnysunny/node-compiler:${IMAGE_VERSION} as build-stage

COPY package.json .
COPY yarn.lock .

RUN yarn install

FROM yunnysunny/node-xtransit:${IMAGE_VERSION}
ARG JEMALLOC_HOME=/usr/lib/x86_64-linux-gnu
RUN mkdir -p /app $JEMALLOC_HOME
WORKDIR /app
# 使用jemalloc
COPY --from=build-stage ${JEMALLOC_HOME}/libjemalloc.so.2 $JEMALLOC_HOME
COPY --from=build-stage /tmp/node_modules ./node_modules
ENV LD_PRELOAD=${JEMALLOC_HOME}/libjemalloc.so.2
COPY . .
ARG EZM_APP_ID
ENV EZM_APP_ID=${EZM_APP_ID}
ARG EZM_APP_SECRET
ENV EZM_APP_SECRET=${EZM_APP_SECRET}
ARG XTRANSIT_SERVER
ENV XTRANSIT_SERVER=${XTRANSIT_SERVER}
CMD ["node", "src/app.js"]
```

注意最后面的那几个环境变量的设置，`EZM_APP_ID` `EZM_APP_SECRET` `XTRANSIT_SERVER` 是连接到 easy-monitor 必须的环境变量，`EZM_APP_ID` `EZM_APP_SECRET` 用来填写应用的 ID 和 密钥；`XTRANSIT_SERVER` 是连接到 `xtransit-server` 的 WebSocket 地址，格式为 `ws://host:port` 。

> 如果 `xtransit-server` 启用了 https ，WebSocket 地址还可以写成 `wss://host:port` 格式。

> 这里将环境变量通过 docker build 的 --build-arg 参数来指定，这样可以不用将环境变量写死在 dockerfile 中，同时从一定程度上保护了隐私。如果仅仅是测试用，可以直接将 ENV 写死在 dockerfile 中，当然也可以在 docker run 命令中通过 -e 参数来手动指定环境变量。

配合这个 `yunnysunny/node-xtransit` 镜像，xtransit 的配置基本就搞定了。下面就剩下在应用程序中引入 xprofiler 了，如果你没有啥特殊配置的话，直接在程序入口第一行写入一句话即可：

```javascript
require('xprofiler').start();
```

> xprofiler 安装时要下载原生代码的预编译库，默认从 github 下载，考虑到众所周知原因，下载比较慢。`yunnysunny/node-xtransit` 镜像基于 `yunnysunny/node` 构建，里面配置 npm config 选项，将其下载地址指向阿里云，有效的解决了这个问题。

## A 7.5 疑难解答

### A 7.5.1 找不到应用实例

如果构建完镜像后，容器运行完之后，xprofile-console 后台中迟迟看不到启动容器的监控实例。则需要查看容器的 \~/.xtransit.log 文件，比如说在启用 nginx 的情况下，反向代理配置错误，忘记添加 Upgrade 头信息了，就会报如下错误：

```
[2024-01-19 01:31:10] [2.4.1] [info] [36] websocket client connecting to ws://ezm-ws.your-domain.com...
[2024-01-19 01:31:10] [2.4.1] [warn] [36] websocket client error: Error: Unexpected server response: 404.
```

通过日志我们就可以更快的定位问题所在。

## A 7.6 未尽事宜

由于本篇教程仅仅涉及部署内容，关于 easy-monitor 的操作指南，还请参照[官方文档](https://www.yuque.com/hyj1991/easy-monitor)。


