Node.js 设计模式笔记 —— Node.js 的设计哲学和原理

一、Node.js 哲学

每种编程语言平台都有其特定的“哲学”,即一系列被社区普遍接受的指导原则和规范。这些规范对语言平台本身的演进以及如何设计和开发应用都有着深刻的影响。

小的核心

Node.js 核心,包含 Node.js 运行时以及所有内置的模块。这个核心遵循一系列基本的设计原则。其中一个就是尽可能只实现所需功能的最小集合,在此之外的非核心功能则由用户自行实现。用户自己开发的模块围绕在核心周围,形成了一个自由开放的软件生态。
将核心的功能限制在最小程度,其他的需求则给与社区足够的自由度,去验证和实现更广泛的解决方案。不仅提升了核心本身的可维护性,同时也给整个生态环境带来了正向的文化氛围。

小的模块

Node.js 使用模块(module)这个概念来表示程序代码的基础构件,模块是构成应用和库的基本单位。
在 Node.js 中,一个广受推崇的原则就是,设计小的模块和包。这里的“小”不仅仅是指代码本身的规模,更为关键的是功能上的“小”和集中。
上述原则深受 Unix 设计哲学的影响,即:

  • Small is beautiful(小即为美)
  • Make each program do one thing well(只做好一件事)

小的模块具有以下特点:

  • 更容易理解和使用
  • 更容易测试和维护
  • 体积小,在浏览器上运行有优势

更小、更集中的模块鼓励每一个人共享和重用每一段哪怕是最小的代码块,在一定程度上提升了代码的可重用性。牢记 Don’t Repeat Yourself (DRY) 原则。

Small surface area

除了在代码量和功能性上更小以外,Node.js 模块的另一个理想特征就是,尽可能向外界公开一组最小的功能集合。这可以帮助我们实现更清晰、不容易被错误使用的 API。模块只向外暴露单一的功能,只向外提供唯一一个清晰的、明确无误的入口。

很多 Node.js 模块的另一个特点是,模块本身被创建出来,是为了被使用而不是被扩展。通过禁止任何扩展来锁定模块内部,听起来缺乏一定的灵活性。但同时也带来了减少用例、简化实现、增强可维护性、提升可用性等优势。
在实践中,这意味着更倾向于对外暴露函数而不是类,避免向外部世界泄露任何内部的细节。

简单性和实用主义

Keep It Simple, Stupid (KISS)
设计简单的,而不是“完美”的、功能完备的软件,在实践中往往是更优的选择:

  • 更少的时间和资源去实现
  • 更快地完成交付
  • 更容易适应不断变化和增加的需求
  • 更容易理解和维护

JavaScript 是一种非常“现实”的语言。在实践中,经常见到使用更简单的类、函数和闭包替换复杂的层级结构的类。
纯粹的 OO 设计常常致力于使用数学模型完整地复制现实世界,并没有考虑到现实本身的“不完美”和复杂性。事实上,我们的软件一直都是对现实世界的接近,如果能够放弃创建“完美”软件的执念,尝试构造一个有着合理复杂度、能够快速工作的成品,有可能会获取到更大的成功。

二、Node.js 核心原理

I/O 很慢

在计算机的世界里,I/O 算得上基础操作里最慢的一种了。比如访问 RAM 的速度处于纳秒(10^-9)量级,而访问磁盘或者网络数据的速度则处于毫秒(10^-3)量级。I/O 操作通常并不消耗多少 CPU 资源,但它实际上在请求发出和操作完成之间增添了很大的延迟。
此外,我们还必须考虑人为因素。很多场景下应用的输入依赖于具体的个人,比如点击鼠标等。从而导致现实里的 I/O 速度,有可能比纯技术层面的磁盘和网络读写要慢得多。

阻塞式 I/O

在传统的阻塞式 I/O 编程中,I/O 请求关联的函数调用会阻塞线程的执行,直到 I/O 操作完成。这会导致一定程度的延迟,有可能是毫秒级别,比如 I/O 操作涉及到磁盘读写;也有可能长达几分钟甚至更久,比如等待用户提供某些输入。

1
2
3
4
// blocks the thread until the data is available
data = socket.read()
// data is available
print(data)

很明显,由阻塞式 I/O 实现的 Web 服务无法在同一个线程中同时处理多个连接请求,因为 socket 上的每个 I/O 操作都会阻塞任何其他连接的访问。解决此问题的传统方法就是借助多线程,每个独立的线程分别处理并发连接中的一个请求。
一个线程被 I/O 操作阻塞,并不会影响其他的线程继续提供服务。
Using multiple threads to process multiple connections

多线程的缺点在于,从资源消耗的角度看,线程并不是一个低廉的选择。他会消耗内存,引发上下文切换等。一个长时间运行的只处理一个网络请求的线程,实际上有可能大部分时间并没有在工作,这意味着对内存和 CPU 资源的浪费。

非阻塞式 I/O

除了阻塞式 I/O 外,现代的操作系统还支持另一种访问资源的机制,称为非阻塞式 I/O。在这种模式下,系统调用会立即返回,无需等待读写操作彻底完成。若返回时还没有获取到任何结果,则返回一个预定义的对象,该对象表明此时没有任何数据可以获取到。
处理非阻塞式 I/O 的最基本的模式就是,通过循环主动轮询资源池中的资源,直到某个对象返回了实际的数据。这种方式称为 busy-waiting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
resources = [socketA, socketB, fileA]
while (!resources.isEmpty()) {
for (resource of resources) {
// try to read
data = resource.read()
if (data === NO_DATA_AVAILABLE) {
// there is no data to read at the moment
continue
}
if (data === RESOURCE_CLOSED) {
// the resource was closed, remove it from the list
resources.remove(i)
} else {
//some data was received, process it
consumeData(data)
}
}
}

通过上述简单的机制,不同的资源即能够在同一个线程中被处理。但仍然不够高效。实际上,CPU 的大部分时钟都被循环用来查询还没有准备好的资源,轮询算法通常意味着 CPU 资源的大量浪费。

解多路复用

Busy-waiting 并不是处理非阻塞资源的理想技术,好在现代的大部分操作系统还提供了一种高效的原生机制,专门用来处理并发的非阻塞需求。该机制称为 synchronous event demultiplexerevent notification interface
multiplexing 是指将多路信号合并到一条通信链路中进行传输;demultiplexing 则是指相反的操作,将合并到一条链路中的数据重新还原成原本的多路信号。

synchronous event demultiplexer 会同时监听多个资源,当其中任何一个资源对应的读写操作完成时,就会返回一个或一系列新的事件。它的优势在于 synchronous,即它是同步的,当没有任何新的事件需要处理时,它会一直处于阻塞状态。
因而我们可以在同一个线程中处理多个 I/O 操作,同时不至于像 busy-waiting 那样持续轮询消耗资源。
Using a single thread to process multiple connections

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
watchedList.add(socketA, FOR_READ)
watchedList.add(fileB, FOR_READ)
while (events = demultiplexer.watch(watchedList)) {
// event loop
for (event of events) {
// This read will never block and will always return data
data = event.resource.read()
if (data === RESOURCE_CLOSED) {
// the resource was closed, remove it from the watched list
demultiplexer.unwatch(event.resource)
} else {
// some actual data was received, process it
consumeData(data)
}
}
}

其中的 demultiplexer.watch() 方法是同步的,当它监听的资源没有任何一个准备好时,它会一直处于阻塞状态。直到有任意资源准备好后,才会返回一系列新的事件。这个时间点返回的事件及其关联的资源由于是已经“准备好”的,可以被直接读取而不会阻塞。

Reactor pattern

Reactor 模式背后的主要理念,就是给每一个 I/O 操作绑定一个 handler。在 Node.js 中可以使用回调函数来表示 handler。当某个事件被 event loop 生产和处理完之后,对应的 handler 就会立即被触发。
The reactor pattern

  • 应用首先向 Event Demultiplexer 提交一个请求,由此生成一个新的 I/O 操作。与此同时应用会为该请求绑定一个 handler,当 I/O 操作结束时自动被调用。向 Event Demultiplexer 提交请求的操作是非阻塞的,该操作提交后程序控制权会立即返还给应用
  • 当一系列 I/O 操作完成后,Event Demultiplexer 会向 Event Queue 中推入对应的事件
  • Event Loop 会不断遍历 Event Queue 中的事件,调用每一个事件对应的 handler
  • handler 代码实际上是应用本身的一部分,它在执行完毕后又会把控制权给到 Event Loop。在 handler 执行的过程中,应用仍然可以向 Event Demultiplexer 提交新的异步操作请求

简单来说,所谓的异步行为,就是应用先在某个时间点表达出想要访问某个资源的兴趣(这个操作是非阻塞的),并给这个资源定义一个 handler。在另一个时间节点当资源能够被访问之后,绑定的 handler 自动被调用,处理对应的资源。

参考资料

Node.js Design Patterns: Design and implement production-grade Node.js applications using proven patterns and techniques, 3rd Edition