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 应用领域及特点
设计高性能 Web 服务器的要点:事件驱动、非阻塞 I/O。
Node 从 Ryan Dahl 一开始设想的 Web 服务器,发展成一个强制不共享任何资源的单线程、单进程系统,包含十分适宜网络的库,为构建大型分布式应用程序提供基础设施的网络应用平台。
Node 与浏览器的异同
Chrome 浏览器除了 V8 作为 JavaScript 引擎外,还有一个 WebKit 布局引擎。
Node 的结构和 Chrome 十分相似,都是基于事件驱动的异步架构。浏览器通过事件驱动来服务界面上的交互,Node 通过事件驱动来服务 I/O。
Node 的特点
异步 I/O
事件与回调函数
事件的编程方式具有轻量级、松耦合、只关注事务点等优势,但是在多个异步任务的场景下,各事件之间各自独立,如何协作是一个问题。
单线程
保持了 JS 在浏览器中单线程的特点。在 Node 中,JS 与其余线程无法共享状态。
好处:
- 不用在意状态同步问题;
- 没有死锁;
- 没有线程上下文交换带来的性能开销;
弱点:
- 无法利用多核 CPU;
- 错误会引起整个应用退出,应用的健壮性值得考验;
- 大量计算占用 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 将该数据源作为数据接口,发挥异步并行的优势。
分布式应用
模块
使用模块的好处
- 大大提高代码的可维护性;
- 可以随时引用;
- 避免函数名和变量名冲突;
CommonJS 规范
CommonJS 主要是在 Node 服务器端的规范。意义在于将类聚的方法与变量限定在私有的作用域中,使得用户完全不必考虑变量污染。
CommonJS 对模块的定义主要分为模块引用、模块定义、模块标识三个部分:
模块引用
一个模块要引用其他模块暴露的变量,用var foo = require('module_name')
就拿到了引用模块的变量。
模块定义
上下文提供了 exports 对象用于导出当前模块的方法或者变量,并且它是唯一的出口。
在模块中,还存在一个 module 对象,代表模块自身,而 exports 是 module 的属性。
一个模块要对外暴露变量(函数也是变量),可以用module.exports = variable;
。
模块标识
即传递给require()
方法的参数。
模块实现
在 Node 中引入模块,需要经历如下三个步骤:
- 路径分析
- 文件定位
- 编译执行
在 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 | define(function(){ |
可以看到 AMD 的模块定义与 Node 模块相似,不同之处在于 AMD 模块需要用define
来明确定义一个模块,而在 Node 实现中是隐式包装的;以及内容需要通过返回的方式实现导出。
想要进一步了解,可参考 Javascript模块化编程(二):AMD规范 - 阮一峰的网络日志。
深入了解模块原理
Node.js 的“模块”功能利用了 JavaScript 函数式编程的特性,通过闭包实现。
代码的组织和部署
包描述文件中的部分属性
- maintainers。包维护者列表,npm 通过该属性进行权限认证。
- bin。将包作为命令行工具使用,需配置好 bin 字段,通过
npm install package_name -g
命令将脚本添加到执行路径中。之后可以在命令行中直接执行。例如:"bin": { "express": "./bin/express" }
全局模式安装
-g
将一个包安装为全局可用的可执行命令。通过全局模式安装的所有模块包都被安装进了一个统一的目录下,这个目录可以通过如下方式推算出来:
1 | path.resolve(process.execPath, '..', '..', 'lib', 'node_modules'); |
基本模块
Node.js 内置的常用模块是为了实现基本的服务器功能,底层代码是用 C/C++ 在 Node.js 运行环境中实现。
global
Node.js 环境中唯一的全局变量。
process
代表当前 Node.js 进程。
判断 JavaScript 执行环境
1 | if(typeof(window) === 'undefined'){ |
文件操作
不同系统下路径的标准化
标准化之后的路径里的斜杠在 Windows 系统下是\
,而在 Linux 系统下是/
。如果想保证任何系统下都使用/
作为路径分隔符的话,需要用.replace(/\\/g, '/')
再替换一下标准路径。
遍历目录
1 | // 同步遍历获取某一文件夹下的所有文件,用 callback 处理 |
网络操作
监听端口的权限问题
在 Linux 系统下,监听 1024 以下端口需要 root 权限。因此,如果想监听 80 或 443 端口的话,需要使用 sudo 命令启动程序。
URL 的完整组成
1 | href |
网络操作常见问题
问: 为什么通过
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
属性来处理。
进程操作
降权
1 | // 降权 |
注意点:
如果是通过
sudo
获取 root 权限的,运行程序的用户的 UID 和 GID 保存在环境变量SUDO_UID
和SUDO_GID
里边。如果是通过chmod +s
方式获取 root 权限的,运行程序的用户的 UID 和 GID 可直接通过process.getuid
和process.getgid
方法获取。process.setuid
和process.setgid
方法只接受number
类型的参数。降权时必须先降 GID 再降 UID,否则顺序反过来的话就没权限更改程序的 GID 了。
进程间通讯
如果父子进程都是 NodeJS 进程,就可以通过 IPC(进程间通讯)双向传递数据。
1 | /* parent.js */ |
数据在传递过程中,会先在发送端使用JSON.stringify
方法序列化,再在接收端使用JSON.parse
方法反序列化。
守护进程
1 | // 守护子进程 |
异步编程
domain 捕获异常
1 | function async(request, callback){ |
陷阱
使用uncaughtException
或domain
捕获异常,代码执行路径里涉及到了 C/C++ 部分的代码时,如果不能确定是否会导致内存泄漏等问题,最好在处理完异常后重启程序比较妥当。而使用try
语句捕获异常时一般捕获到的都是 JS 本身的异常,不用担心上述问题。
结语
NodeJS 的学习其实主要分为三块:
- JavaScript 语言本身。要学会借助 ECMAScript 规范 加深自己的理解。
- NodeJS 的 API。要熟悉官方 API 文档,主要是熟悉 NodeJS 提供的功能以及知道该查询文档的哪块地方。不推荐死记硬背,因为新版本会更改和弃用部分 API。
- 生态圈中的各种三方库。要学习检索、过滤、去其糟粕取其精华,利用但不迷信。
而在 NodeJS 开发时,首先要有一个全局的设计,再再实现的过程中对之间忽略掉的细节进行设计上的改进,为二次迭代做准备。
参考资料
除开文章开头提到的资料,结语部分参考了 当我们学习 Node.js 时,我们在学习什么?。
下一步学习
- 《Node.js 包教不包会》 by alsotang
- 《深入浅出Node.js》
- 自己撸个爬虫