深入浅出Node.js学习笔记(四)
第九章 玩转进程
服务模型的变迁
石器时代:同步
它的服务模式是一次只为一个请求服务,所有请求都 得按次序等待服务。这意味除了当前的请求被处理外,其余请求都处于耽误的状态。它的处理能 力相当低下,假设每次响应服务耗用的时间稳定为N秒,这类服务的QPS为1/N。
青铜时代:复制进程
通过进程的复制同时服务更多的请求和用 户。这样每个连接都需要一个进程来服务,即100个连接需要启动100个进程来进行服务,这是非 常昂贵的代价。在进程复制的过程中,需要复制进程内部的状态,对于每个连接都进行这样的复 制的话,相同的状态将会在内存中存在很多份,造成浪费。并且这个过程由于要复制较多的数据, 启动是较为缓慢的。
为了解决启动缓慢的问题,预复制(prefork)被引入服务模型中,即预先复制一定数量的进 程。同时将进程复用,避免进程创建、销毁带来的开销。
假设通过进行复制和预复制的方式搭建的服务器有资源的限制,且进程数上限为M,那这类 服务的QPS为M/N。
白银时代:多线程
让一个线程服务一个请求。线程相 对进程的开销要小许多,并且线程之间可以共享数据,内存浪费的问题可以得到解决,并且利用线 程池可以减少创建和销毁线程的开销。
由于一个CPU核心在 一个时刻只能做一件事情,操作系统只能通过将CPU切分为时间片的方法,让线程可以较为均匀地 使用CPU资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时, 时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。
如果忽略掉多线程上下文切换的开销,假设线程所占用的资源为进程的1/L,受资源上限的 影响,它的QPS则为M * L/N。
黄金时代:事件驱动
由于所有处理都在单线程上进行,影响事件驱动服务模型性能的点在于CPU的计算能力,它 的上限决定这类服务模型的性能上限,但它不受多进程或多线程模式中资源上限的影响,可伸缩 性远比前两者高。
多进程架构
Master-Worker模式,又称主从模式。
图9-1中的进程分为两种:主进程和工 作进程。这是典型的分布式架构中用于并行处理业务的模式,具备较好的可伸缩性和稳定性。主 进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。工作进程负责 具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以工作进程的稳定性 值得开发者关注。
创建子进程
child_process模块给予Node可以随意创建子进程(child_process)的能力。它提供了4个方 法用于创建子进程。
spawn():启动一个子进程来执行命令。
exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。
execFile():启动一个子进程来执行可执行文件。
fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间,一旦创建的进程运行超过设定的时间将会被杀死。
exec()与execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。
如果是JavaScript文件通过execFile()运行,它的首行内容必须添加如下代码:
1
进程间通信
为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才能通过message和send()传递消息。
进程间通信原理
IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同 的进程能够互相访问资源并进行协调工作。
Node中实现IPC通道的是管道(pipe) 技术。表现在应 4 用层上的进程间通信只有简单的message事件和send()方法,接口十分简洁和消息化。
父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通 过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中, 根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。
IPC通道属于双向通信。在Node中,IPC通道被抽象为Stream对 象,在调用send()时发送数据(类似于write()),接收到的消息会通过message事件(类似于data) 触发给应用层。
句柄传递
通过代理,可以避免端口不能重复监听的问题,甚至可以在代理进程上做适当的负载均衡, 使得每个子进程可以较为均衡地执行任务。由于进程每接收到一个连接,将会用掉一个文件描述 符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件 描述符。操作系统的文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了 系统的扩展能力。
句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述 符。
发送句柄意味着什么?在前一个问题中,我们可以去掉代理这种方案,使主进程接收到socket 请求后,将这个socket直接发送给工作进程,而不是重新与工作进程之间建立新的socket连接来转 发数据。文件描述符浪费的问题可以通过这样的方式轻松解决。
1. 句柄发送与还原
目前子进程对象send()方法可以发送的句柄类型包括如下几种。
net.Socket。TCP套接字。
net.Server。TCP服务器,任意建立在TCP服务上的应用层服务都可以享受到它带来的
好处。
net.Native。C++层面的TCP套接字或IPC管道。
dgram.Socket。UDP套接字。
dgram.Native。C++层面的UDP套接字。
send()方法在将消息发送到IPC管道前,将消息组装成两个对象,一个参数是handle,另一个 是message。
1 | { |
发送到IPC管道中的实际上是我们要发送的句柄文件描述符,文件描述符实际上是一个整数 值。这个message对象在写入到IPC管道时也会通过JSON.stringify()进行序列化。所以最终发送 到IPC通道中的信息都是字符串,send()方法能发送消息和句柄并不意味着它能发送任意对象。
连接了IPC通道的子进程可以读取到父进程发来的消息,将字符串通过JSON.parse()解析还 原为对象后,才触发message事件将消息体传递给应用层使用。在这个过程中,消息对象还要被 进行过滤处理,message.cmd的值如果以NODE_为前缀,它将响应一个内部事件internalMessage。
如果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。
Node进程之间只有消息传递,不会真正地传递对象,这种错 觉是抽象封装的结果。
2. 端口共同监听
Node底层对每个端口监听都设置了SO_REUSEADDR选项,这个选项的涵义是不同进程可以就相 同的网卡和端口进行监听,这个服务器端套接字可以被不同的进程复用
由于独立启动的进程互相之间并不知道文件描述符,所以监听相同端口时就会失败。但对于 send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引 起异常。
多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求 向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务。 这些进程服务是抢占式的。
集群稳定之路
进程事件
- error:当子进程无法被复制创建、无法被杀死、无法发送消息时会触发该事件。
- exit:子进程退出时触发该事件,子进程如果是正常退出,这个事件的第一个参数为退出 码,否则为null。如果进程是通过kill()方法被杀死的,会得到第二个参数,它表示杀死进程时的信号。kill()方法并不能真正地将通过IPC相连的子进程杀死,它只是给子进程 发送了一个系统信号。默认情况下,父进程将通过kill()方法给子进程发送一个SIGTERM信号。
- close:在子进程的标准输入输出流中止时触发该事件,参数与exit相同。
- disconnect:在父进程或子进程中调用disconnect()方法时触发该事件,在调用该方法时将关闭监听IPC通道。
自动重启
1. 自杀信号
在退出的流程中增加一个自杀 (suicide)信号。工作进程在得知要退出时,向主进程发送一个自杀信号,然后才停止接收新的 连接,当所有连接断开后才退出。主进程在接收到自杀信号后,立即创建新的工作进程服务。
2. 限量重启
工 作进程不能无限制地被重启,如果启动的过程中就发生了错误,或者启动后接到连接就收到错误, 会导致工作进程被频繁重启,这种频繁重启不属于我们捕捉未知异常的情况,因为这种短时间内 频繁重启已经不符合预期的设置,极有可能是程序编写的错误。
为了消除这种无意义的重启,在满足一定规则的限制下,不应当反复重启。比如在单位时间 内规定只能重启多少次,超过限制就触发giveup事件,告知放弃重启工作进程这个重要事件。
负载均衡
保证多个处理单元工作量公平的策略叫负载均衡。
Node默认提供的机制是采用操作系统的抢占式策略。所谓的抢占式就是在一堆工作进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务。
对于Node而言,需要分清的是它的繁忙是由CPU、I/O两个部分构成的,影响抢占的是CPU 的繁忙度。对不同的业务,可能存在I/O繁忙,而CPU较为空闲的情况,这可能造成某个进程能 够抢到较多请求,形成负载不均衡的情况。
Round-Robin,又叫轮叫调度。轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作 进程。分发的策略是在N个工作进程中,每次选择第i = (i + 1) mod n个进程来发送连接。
Round-Robin非常简单,可以避免CPU和I/O繁忙差异导致的负载不均衡。Round-Robin策略也 可以通过代理服务器来实现,但是它会导致服务器上消耗的文件描述符是平常方式的两倍。
状态共享
Node进程中不宜存放太多数据,Node也不允许在多个进程之间共享数据
1. 第三方数据存储
解决数据共享最直接、简单的方式就是通过第三方来进行数据存储,比如将数据存放到数据 库、磁盘文件、缓存服务(如Redis)中,所有工作进程启动时将其读取进内存中。但这种方式 存在的问题是如果数据发生改变,还需要一种机制通知到各个子进程,使得它们的内部状态也得 到更新。
定时轮询带来的问题是轮询时间不能过密,如果子进程过多,会形成并发处理,如果数据没 有发生改变,这些轮询会没有意义,白白增加查询状态的开销。如果轮询时间过长,数据发生改 变时,不能及时更新到子进程中,会有一定的延迟。
2. 主动通知
当数据发生更新时,主动通知子进程。
我们将这种用来发送通知和查询状态是否更改的进程叫做通知进程。为了不混合业务逻辑,可以 将这个进程设计为只进行轮询和通知,不处理任何业务逻辑
这种推送机制如果按进程间信号传递,在跨多台服务器时会无效,是故可以考虑采用TCP或 UDP的方案。进程在启动时从通知服务处除了读取第一次数据外,还将进程信息注册到通知服务 处。一旦通过轮询发现有数据更新后,根据注册信息,将更新后的数据发送给工作进程。由于不 涉及太多进程去向同一地方进行状态查询,状态响应处的压力不至于太过巨大,单一的通知服务 轮询带来的压力并不大,所以可以将轮询时间调整得较短,一旦发现更新,就能实时地推送到各 个子进程中。
Cluster 模块
v0.8时直接引入了cluster模块,用以解决多核CPU的利用率问题,同时也提供了较完 善的API,用以处理进程的健壮性问题
Cluster 工作原理
cluster模块就是child_process和net模块的组合应用。
cluster启动时,如同我们在 9.2.3节里的代码一样,它会在内部启动TCP服务器,在cluster.fork()子进程时,将这个TCP服 务器端socket的文件描述符发送给工作进程。如果进程是通过cluster.fork()复制出来的,那么 它的环境变量里就存在NODE_UNIQUE_ID,如果工作进程中存在listen()侦听网络端口的调用,它 将拿到该文件描述符,通过SO_REUSEADDR端口重用,从而实现多个子进程共享端口。对于普通方 式启动的进程,则不存在文件描述符传递共享等事情。
Cluster 事件
- fork:复制一个工作进程后触发该事件。
- online:复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程收到消息后,触发该事件。
- listening:工作进程中调用listen()(共享了服务器端Socket)后,发送一条listening消息给主进程,主进程收到消息后,触发该事件。
- disconnect:主进程和工作进程之间IPC通道断开后会触发该事件。
- exit:有工作进程退出时触发该事件。
- setup:cluster.setupMaster()执行后触发该事件。
第十章 测试
单元测试
单元测试的意义
编写可测试代码的原则:
单一职责。如果一段代码承担的职责越多,为其编写单元测试的时候就要构造更多的输入数据,然后推测它的输出。
接口抽象。通过对程序代码进行接口抽象后,我们可以针对接口进行测试,而具体代码
实现的变化不影响为接口编写的单元测试。
层次分离。层次分离实际上是单一职责的一种实现。在MVC结构的应用中,就是典型的层次分离模型,如果不分离各个层次,无法想象这个代码该如何切入测试。通过分层之后,可以逐层测试,逐层保证。
单元测试介绍
单元测试主要包含断言、测试框架、测试用例、测试覆盖率、mock、持续集成等几个方面, 由于Node的特殊性,它还会加入异步代码测试和私有方法的测试这两个部分。
1. 断言
断言就是单元测试中用来保证最小单元是否正常的检测方法。断言用于检查程序在运行时是否满足期望。
在断言规范中,我们定义了以下几种检测方法。
- ok():判断结果是否为真。
- equal():判断实际值与期望值是否相等。
- notEqual():判断实际值与期望值是否不相等。
- deepEqual():判断实际值与期望值是否深度相等(对象或数组的元素是否相等)。
- notDeepEqual():判断实际值与期望值是否不深度相等。
- strictEqual():判断实际值与期望值是否严格相等(相当于===)
- notStrictEqual():判断实际值与期望值是否不严格相等(相当于!==)。
- throws():判断代码块是否抛出异常。
- doesNotThrow():判断代码块是否没有抛出异常。
- ifError():判断实际值是否为一个假值(null、undefined、0、’’、false),如果实际值为真值,将会抛出异常。
2. 测试框架
测试框架用于为测试服务,它本身并不参与测试,主要用于管理测试用例和生成测试报告, 提升测试用例的开发速度,提高测试用例的可维护性和可读性,以及一些周边性的工作。
测试风格
我们将测试用例的不同组织方式称为测试风格,现今流行的单元测试风格主要有TDD(测试驱动开发)和BDD(行为驱动开发)两种,它们的差别如下所示。
- 关注点不同。TDD关注所有功能是否被正确实现,每一个功能都具备对应的测试用例;BDD关注整体行为是否符合预期,适合自顶向下的设计方式。
- 表达方式不同。TDD的表述方式偏向于功能说明书的风格;BDD的表述方式更接近于自然语言的习惯。
BDD对测试用例的组织主要采用describe和it进行组织。describe可以描述多层级的结构, 具体到测试用例时,用it。另外,它还提供before、after、beforeEach和afterEach这4个钩子方 法,用于协助describe中测试用例的准备、安装、卸载和回收等工作。before和after分别在进入 和退出describe时触发执行,beforeEach和afterEach则分别在describe中每一个测试用例(it) 执行前和执行后触发执行。
TDD对测试用例的组织主要采用suite和test完成。suite也可以实现多层级描述,测试用例用test。它提供的钩子函数仅包含setup和teardown,对应BDD中的before和after。
3. 测试用例
测试用例最少需要通过正向测试和反向测试来保证测试对功能的覆盖,这是最基本的测试用 例。对于Node而言,不仅有这样简单的方法调用,还有异步代码和超时设置需要关注。
4. 私有方法的测试
rewire模块提供了一种巧妙的 方式实现对私有方法的访问。每一个被rewire引入的模块都有set()和get()方法。它巧妙地利用了闭包的诀窍,在eval()执行时,实现了对模块内部局部变量的访问,从而可以将局部变量导出给测试用例调用执行。
工程化与自动化
1. 工程化
Node在*nix系统下可以很好地利用一些成熟工具,其中Makefile比较小巧灵活,适合用来构建工程。
- Makefile文件的缩进必须是tab符号,不能用空格。
- 记得在包描述文件中配置blanket。
2. 持续集成
GitHub提供了代码托管和社交编程的良好环境,程 序员们可以在上面很社交化地进行代码的clone、fork、pull request、issues等操作,travis-ci则补足了GitHub在持续集成方面的缺点。Git版本控制系统提供了hook机制,用户在push代码后会 触发一个hook脚本,而travis-ci即是通过这种方式与GitHub衔接起来的。
(1) 在https://travis-ci.org/上通过OAuth授权绑定你的GitHub账号。
(2) 在GitHub仓库的管理面板(admin)中打开services hook页,在这个页面中可以发现GitHub 上提供了很多基于git hook方式的钩子服务。
(3) 找到travis服务,点击激活即可。
(4) 每次将代码push到GitHub的仓库上后,将会触发该钩子服务。
性能测试
基准测试要统计的就是在多少 时间内执行了多少次某个方法。为了增强可比性,一般会以次数作为参照物,然后比较时间,以 此来判别性能的差距。
压力测试
对网络接口做压力测试需要考查的几个指标有吞吐率、响应时间和 并发数,这些指标反映了服务器的并发处理能力。
最常用的工具是ab、siege、http_load等
基准测试驱动开发
BDD,全称为Benchmark Driven Development,即基准测试驱动开发
第十一章 产品化
项目工程化
所谓的工程化,可以理解为项目的组织能力。体现在文件上,就是文件的组织能力。对于不同 类型的项目,其组织方式也有所不同。除此之外,还应当有能够将整个项目串联起来的灵魂性文件。
目录结构
常见的Web应用都是以MVC为主要框架的,其余部分在这个基础上进行扩展。
日志
异常日志
异常日志通常用来记录那些意外产生的异常错误。通过日志的记录,开发者可以根据异常信 息去定位bug出现的具体位置,以快速修复问题。
异常日志通常有完善的分级,Node中提供的console对象就简单地实现了这几种划分
- console.log:普通日志。
- console.info:普通信息。
- console.warn:警告信息。
- console.error:错误信息。
监控报警
监控
- 日志监控
- 响应时间
- 进程监控
- 磁盘监控
- 内存监控
- CPU占用监控
- CPU load监控
- I/O负载
- 网络监控
- 应用状态监控
- DNS监控
报警的实现
- 邮件报警
- 短信或电话报警
监控系统的稳定性
稳定性
- 多机器
- 多机房
- 容灾备份
完结撒花🎉
(终于把这本书看完了,笔记也不是很认真,希望以后在实践中慢慢领悟吧~)