NodeJS 入门笔记

上学期曾在 NodeJS 的门槛前匆匆一瞥而不得入,暑假终于有大块的时间一次入门。这篇笔记以 七天学会NodeJS 为主干,辅以廖雪峰 JavaScript 教程中相关内容以及其他渠道的一些知识整理而成。内容主要是脑图,一部分细碎知识点和代码以文字呈现。

在学习 NodeJS 过程中,感觉对软件工程基础课程的融会贯通有了更深的理解。作为 JavaScript 的服务器端环境,NodeJS 操作文件、操作进程等功能和上学期的 Unix 课程有着较高的相通性。可惜我 Unix 没有好好听过。网络操作的内容则和 HTTP 以及 Socket 协议的知识紧密联系,这些大概在大三下学期的计算机网络课程中。到时要督促自己按时到勤认真听课了。

虽然总觉得大学授课的水平参差不齐,课程内容可能重要性也分三六九等,但是回头才发现全面的知识体系对今后学习的帮助,不管哪方面知识都是可以迁移的。说实话 whu iss 的专业课安排还是比较科学的(不过学长说计算机网络有点晚,应该放到大三上学期)。希望自己珍惜在学校的最后一年多的时间,不管是学业还是别的方面,不要留太多遗憾。

更新记录:

  • 17.08.30 根据《深入浅出 Node.js》的笔记,对”NodeJS 应用领域及特点”部分进行文字补充。
  • 17.09.02 根据《深入浅出 Node.js》的笔记,对”模块”部分进行文字补充。

NodeJS 应用领域及特点

Node.js+概念一览.png

设计高性能 Web 服务器的要点:事件驱动、非阻塞 I/O

Node 从 Ryan Dahl 一开始设想的 Web 服务器,发展成一个强制不共享任何资源的单线程、单进程系统,包含十分适宜网络的库,为构建大型分布式应用程序提供基础设施的网络应用平台

Node 与浏览器的异同

Chrome 浏览器除了 V8 作为 JavaScript 引擎外,还有一个 WebKit 布局引擎。

Node 的结构和 Chrome 十分相似,都是基于事件驱动的异步架构。浏览器通过事件驱动来服务界面上的交互,Node 通过事件驱动来服务 I/O。

Node 的特点

异步 I/O

事件与回调函数

事件的编程方式具有轻量级松耦合只关注事务点等优势,但是在多个异步任务的场景下,各事件之间各自独立,如何协作是一个问题。

单线程

保持了 JS 在浏览器中单线程的特点。在 Node 中,JS 与其余线程无法共享状态。

好处:

  1. 不用在意状态同步问题;
  2. 没有死锁;
  3. 没有线程上下文交换带来的性能开销;

弱点:

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

浏览器端,Web Workers 能够创建工作线程进行计算。为了不阻塞主线程,工作线程采用消息传递的方式来传递运行结果,使得工作线程不能访问主线程的 UI。

Node 采用同样的思路解决单线程中大计算量的问题:child_process。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息传递结果。

子进程的出现,意味着 Node 可以从容应对单线程在健壮性和无法利用多核 CPU 方面的问题。

跨平台

操作系统与 Node 上层模块之间构建了一层平台层架构,即libuv

Node 的应用场景

I/O 密集型

Node 面向网络且擅长并行 I/O,能够有效组织更多的硬件资源。

I/O 密集的优势在于Node 利用事件循环的处理能力(而非为每一个服务启动一个线程),资源占用极少。

不擅长 CPU 密集型业务?

由于 JavaScript 单线程的原因,如果有长时间运行的计算(比如大循环),将导致 CPU 时间片不能释放,使得后续 I/O 无法发起。适当调整和分解大型运算任务能使运算适时释放,不阻塞 I/O 调用的发起。

与遗留系统和平共处

可以将稳定的旧有系统作为后端接口与中间件,而让 Node 将该数据源作为数据接口,发挥异步并行的优势。

分布式应用

模块

模块.png

使用模块的好处

  1. 大大提高代码的可维护性;
  2. 可以随时引用;
  3. 避免函数名和变量名冲突;

CommonJS 规范

CommonJS 主要是在 Node 服务器端的规范。意义在于将类聚的方法与变量限定在私有的作用域中,使得用户完全不必考虑变量污染。

CommonJS 对模块的定义主要分为模块引用、模块定义、模块标识三个部分:

模块引用

一个模块要引用其他模块暴露的变量,用var foo = require('module_name')就拿到了引用模块的变量。

模块定义

上下文提供了 exports 对象用于导出当前模块的方法或者变量,并且它是唯一的出口。

在模块中,还存在一个 module 对象,代表模块自身,而 exports 是 module 的属性

一个模块要对外暴露变量(函数也是变量),可以用module.exports = variable;

模块标识

即传递给require()方法的参数。

模块实现

在 Node 中引入模块,需要经历如下三个步骤:

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

在 Node 中,模块分为 Node 提供的核心模块和用户编写的文件模块

核心模块部分在 Node 源代码的编译过程中,编译进了二进制执行文件。在 Node 进程启动时,部分核心模块就直接加载进内存中。因此可以省略文件定位和编译执行两个步骤,且在路径分析中优先判断,加载速度最快

文件模块运动时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。

优先从缓存加载

模块加载会有缓存,且其根据绝对路径识别。因此同样的模块名放在不同的路径之中多次 require 不会重新加载。

Node 对引入过的模块都会以编译和执行后的对象的形式进行缓存。核心模块和文件模块都采用缓存优先进行二次加载,核心模块的缓存检查先于文件模块。

路径分析和文件定位

模块路径是 Node 在定位文件模块的具体文件时制定的查找策略,具体表现为一个路径组成的数组。

其生成规则为从当前文件目录下的 node_modules 目录开始,沿路径向上逐级递归,直到根目录下的 node_modules 目录。

Node 在当前目录下查找 package.json,通过JSON.parse()解析出包描述对象,从中取出main属性制定的文件名进行定位。

模块编译

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

每一个编译成功的模块都会将其文件路径作为索引缓存在 Module._cache 对象上,以提高二次引入的性能。

如果要达成 require 引入一个类的效果,请赋值给 module.exports 对象。

前后端共用模块

浏览器端的 JavaScript 需要经历从同一个服务器端分发到多个客户端执行,瓶颈在于带宽;而服务器端 JavaScript 则是相同的代码需要多次执行,瓶颈在于 CPU 和内存等资源

CommonJS 是同步加载的,在服务器端模块文件一般存放在本地,再加上有缓存,加载速度很快。而在浏览器端就可能导致“假死”,因此浏览器端采用另一种异步加载方式 - AMD(Asynchronous Module Definition,异步模块定义)规范。

1
2
3
4
5
6
7
define(function(){
var exports = {}
exports.sayHello = function() {
alert('Hello from module: ' + module.id)
}
return exports
})

可以看到 AMD 的模块定义与 Node 模块相似,不同之处在于 AMD 模块需要用define来明确定义一个模块,而在 Node 实现中是隐式包装的;以及内容需要通过返回的方式实现导出。

想要进一步了解,可参考 Javascript模块化编程(二):AMD规范 - 阮一峰的网络日志

深入了解模块原理

Node.js 的“模块”功能利用了 JavaScript 函数式编程的特性,通过闭包实现。

代码的组织和部署

代码的组织和部署.png

包描述文件中的部分属性

  • maintainers。包维护者列表,npm 通过该属性进行权限认证。
  • bin。将包作为命令行工具使用,需配置好 bin 字段,通过npm install package_name -g命令将脚本添加到执行路径中。之后可以在命令行中直接执行。例如:"bin": { "express": "./bin/express" }

全局模式安装

-g将一个包安装为全局可用的可执行命令。通过全局模式安装的所有模块包都被安装进了一个统一的目录下,这个目录可以通过如下方式推算出来:

1
path.resolve(process.execPath, '..', '..', 'lib', 'node_modules');

基本模块

基本模块.png

Node.js 内置的常用模块是为了实现基本的服务器功能,底层代码是用 C/C++ 在 Node.js 运行环境中实现。

global

Node.js 环境中唯一的全局变量。

process

代表当前 Node.js 进程。

判断 JavaScript 执行环境

1
2
3
4
5
if(typeof(window) === 'undefined'){
console.log('node.js');
} else {
console.log('browser');
}

文件操作

文件操作.png

不同系统下路径的标准化

标准化之后的路径里的斜杠在 Windows 系统下是\,而在 Linux 系统下是/。如果想保证任何系统下都使用/作为路径分隔符的话,需要用.replace(/\\/g, '/')再替换一下标准路径。

遍历目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 同步遍历获取某一文件夹下的所有文件,用 callback 处理
function travel(dir, callback){
fs.readdirSync(dir).forEach((file) => {
var pathname = path.join(dir, file);

if(fs.statSync(pathname).isDirectory()){
travel(pathname, callback);
} else {
callback(pathname);
}
})
}

// 异步遍历
function travel(dir, callback, finish){
fs.readdir(dir, (err, files) => {
(function next(i){
if(i < files.length) {
var pathname = path.join(dir, files[i]);

fs.stat(pathname, (err, stats) => {
if(stats.isDirectory()){
travel(pathname, callback, () => {
next(i + 1);
});
} else {
callback(pathname, () => {
next(i + 1);
})
}
});
} else {
finish && finish();
}
})(0);
})
}

网络操作

网络操作.png

监听端口的权限问题

在 Linux 系统下,监听 1024 以下端口需要 root 权限。因此,如果想监听 80 或 443 端口的话,需要使用 sudo 命令启动程序。

URL 的完整组成

1
2
3
4
5
6
7
8
9
                           href
-----------------------------------------------------------------
host path
--------------- ----------------------------
http: // user:pass @ host.com : 8080 /p/a/t/h ?query=string #hash
----- --------- -------- ---- -------- ------------- -----
protocol auth hostname port pathname search hash
------------
query

网络操作常见问题

  • 问: 为什么通过headers对象访问到的 HTTP 请求头或响应头字段不是驼峰的?

    答: 从规范上讲,HTTP 请求头和响应头字段都应该是驼峰的。但现实中不是每个 HTTP 服务端或客户端程序都严格遵循规范,所以 NodeJS 在处理从别的客户端或服务端收到的头字段时,都统一地转换为了小写字母格式,以便开发者能使用统一的方式来访问头字段,例如headers['content-length']

  • 问: 为什么http模块创建的 HTTP 服务器返回的响应是chunked传输方式的?

    答: 因为默认情况下,使用.writeHead方法写入响应头后,允许使用.write方法写入任意长度的响应体数据,并使用.end方法结束一个响应。由于响应体数据长度不确定,因此 NodeJS 自动在响应头里添加了Transfer-Encoding: chunked字段,并采用chunked传输方式。但是当响应体数据长度确定时,可使用.writeHead方法在响应头里加上Content-Length字段,这样做之后 NodeJS 就不会自动添加Transfer-Encoding字段和使用chunked传输方式。

  • 问: 为什么使用http模块发起 HTTP 客户端请求时,有时候会发生socket hang up错误?

    答: 发起客户端 HTTP 请求前需要先创建一个客户端。http模块提供了一个全局客户端http.globalAgent,可以让我们使用.request.get方法时不用手动创建客户端。但是全局客户端默认只允许5个并发 Socket 连接,当某一个时刻 HTTP 客户端请求创建过多,超过这个数字时,就会发生socket hang up错误。解决方法也很简单,通过http.globalAgent.maxSockets属性把这个数字改大些即可。另外,https模块遇到这个问题时也一样通过https.globalAgent.maxSockets属性来处理。

进程操作

进程管理.png

降权

1
2
3
4
5
6
7
8
9
// 降权
http.createServer(callback).listen(80, () => {
var env = process.env,
uid = parseInt(env['SUDO_UID'] || process.getuid(), 10);
gid = parseInt(env['SUDO_GID'] || process.getgid(), 10);

process.setgid(gid);
process.setuid(uid);
})

注意点:

  1. 如果是通过sudo获取 root 权限的,运行程序的用户的 UID 和 GID 保存在环境变量SUDO_UIDSUDO_GID里边。如果是通过chmod +s方式获取 root 权限的,运行程序的用户的 UID 和 GID 可直接通过process.getuidprocess.getgid方法获取。

  2. process.setuidprocess.setgid方法只接受number类型的参数。

  3. 降权时必须先降 GID 再降 UID,否则顺序反过来的话就没权限更改程序的 GID 了。

进程间通讯

如果父子进程都是 NodeJS 进程,就可以通过 IPC(进程间通讯)双向传递数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* parent.js */
// 在 options.stdio 字段中通过 ipc 开启一条 IPC 通道
var child = child_process.spawn('node', ['child.js'], {
stdio: [0, 1, 2, 'ipc']
});

child.on('message', (msg) => {
console.log(msg);
})

// 给子进程发送消息
child.send({ hello: 'hello' });

/* child.js */
process.on('message', (msg) => {
msg.hello = msg.hello.toUpperCase();
process.send(msg);
})

数据在传递过程中,会先在发送端使用JSON.stringify方法序列化,再在接收端使用JSON.parse方法反序列化。

守护进程

1
2
3
4
5
6
7
8
9
10
11
12
13
// 守护子进程
/* daemon.js */
function spawn(mainModule){
var worker = child_process.spawn('node', [mainModule]);

worker.on('exit', (code) => {
if(code !== 0){
spawn(mainModule);
}
});
}

spawn('worker.js');

异步编程

异步编程.png

domain 捕获异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function async(request, callback){
// Do something
asyncA(request, (data) => {
// Do something
asyncB(request, (data) => {
// Do something
asyncC(request, (data) => {
// Do something
callback(data);
})
})
})
}

http.createServer((request, response) => {
var d = domain.create();

d.on('error', () => {
response.writeHead(500);
response.end();
});

d.run(() => {
async(request, (data) => {
response.writeHead(200);
response.end(data);
})
})
})

陷阱

使用uncaughtExceptiondomain捕获异常,代码执行路径里涉及到了 C/C++ 部分的代码时,如果不能确定是否会导致内存泄漏等问题,最好在处理完异常后重启程序比较妥当。而使用try语句捕获异常时一般捕获到的都是 JS 本身的异常,不用担心上述问题。

结语

NodeJS 的学习其实主要分为三块:

  1. JavaScript 语言本身。要学会借助 ECMAScript 规范 加深自己的理解。
  2. NodeJS 的 API。要熟悉官方 API 文档,主要是熟悉 NodeJS 提供的功能以及知道该查询文档的哪块地方。不推荐死记硬背,因为新版本会更改和弃用部分 API。
  3. 生态圈中的各种三方库。要学习检索、过滤、去其糟粕取其精华,利用但不迷信。

而在 NodeJS 开发时,首先要有一个全局的设计,再再实现的过程中对之间忽略掉的细节进行设计上的改进,为二次迭代做准备。

参考资料

除开文章开头提到的资料,结语部分参考了 当我们学习 Node.js 时,我们在学习什么?

下一步学习

  1. 《Node.js 包教不包会》 by alsotang
  2. 《深入浅出Node.js》
  3. 自己撸个爬虫