带你重新认识Node
最初做Node的目的是什么?
Node作者Ryan Dahl:
基于V8创建一个轻量级的高性能Web服务器并提供一套库
为什么是JavaScript?
Ryan Dahl是一名资深的C/C++程序员,创造出Node之前主要工作是围绕Web高性能服务器进行的
他发现Web高性能服务器的两个要点:
- 事件驱动
- 非阻塞I / O
Ryan Dahl也曾评估过使用C、Lua、Haskell、Ruby等语言作为备选实现,得出以下结论:
- C的开发门槛高,可以预见不会有太多的开发者能将它用于业务开发
- Ryan Dahl觉得自己还不足够玩转Haskell,所以舍弃它
- Lua自身已经含有很多阻塞 I / O 库,为其构建非阻塞 I / O 库不能改变开发者使用习惯
- Ruby的虚拟机性能不佳
JavaScript的优势:
- 开发门槛低
- 在后端领域没有历史包袱
- 第二次浏览器大战渐渐分出高下,Chrome浏览器的JavaScript引擎V8摘得性能第一的桂冠
Node给JavaScript带来的意义
除了HTML、Webkit和显卡这些UI相关技术没有支持外,Node的结构与Chrome十分相似。他们都是基于事件驱动的异步架构:
- 浏览器通过事件驱动来服务界面上的交互
- Node通过事件驱动来服务 I / O
在Node中,JavaScript还被赋予了新的能力:
- 随心所欲地访问本地文件
- 搭建WebSocket服务端
- 连接数据库,进行业务研发
- 像Web Worker一样玩转多进程
Node使JavaScript可以运行在不同的地方,不再限制在浏览器中、DOM树打交道。如果HTTP协议是水平面,Node就是浏览器在协议栈另一边的倒影。
Node不处理UI,但用与浏览器相同的机制和原理运行,打破了JavaScript只能在浏览器中运行的局面。前后端编程环境统一,可以大大降低前后端转换所需要的上下文代价。
Node的特点
异步I / O
- 以读取文件为例
var fs = require('fs'); |
熟悉的用户必知道,“读取文件完成”是在“发起读取文件”之后输出的
fs.readFile后的代码是被立即执行的,而“读取文件完成”的执行时间是不被预期的
只知道它将在这个异步操作后执行,但并不知道具体的时间点
异步调用中对于结果值的捕获是符合“Don’t call me, I will call you”原则的
这也是注重结果,不关心过程的一种表现
Node中,绝大多数操作都以异步的方式进行调用,Ryan Dahl排除万难,在底层构建了很多异步I / O的API,从文件读取到网络请求等。使开发者很已从语言层面很自然地进行并行I / O操作,在每个调用之间无需等待之前的I / O调用结束,在编程模型上可以极大提升效率
注:异步I / O机制将在下文中详细阐述
事件与回调函数
事件
随着Web2.0的到来,JavaScript在前端担任了更多的职责,时间也得到了广泛的应用。将前端浏览器中广泛应用且成熟的事件与回到函数引入后端,配合异步I / O ,可以很好地将事件发生的时间点暴露给业务逻辑。
- 服务端例子
对于服务器绑定了request事件
对于请求对象,绑定了data和end事件
var http = require('http'); |
- 前端例子
发出请求后,只需关心请求成功时执行相应的业务逻辑即可
request({ |
事件的编程方式具有轻量级、松耦合、只关注事务点等优势,但是在多个异步任务的场景下,事件与事件之间各自独立,如何协作是一个问题,后续也出现了一系列异步编程解决方案:
- 事件发布/订阅模式
- Promise、async / await
- 流程控制库
回调函数
- Node除了异步和事件外,回调函数也是一大特色
- 纵观下来,回调函数也是最好的接收异步调用返回数据的方式
- 但是这种编程方式对于很多习惯同步思路编程的人来说,也许是十分不习惯的
- 代码的编写顺序与执行顺序并无关系,这对他们可能造成阅读上的障碍
- 在流程控制方面,因为穿插了异步方法和回调函数,与常规的同步方式相比变得不那么一目了然了
- 转变为异步编程思维后,通过对业务的划分和对事件的提炼,在流程控制方面处理业务的复杂度是与同步方式实际上是一致的
单线程
Node保持了JavaScript在浏览器中单线程的特点
JavaScript与其他线程是无法共享任何状态的,最大的好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销
- 单线程的缺点
- 无法利用多核CPU
- 错误会引起整个应用退出,健壮性较差
- 大量计算占用CPU导致无法继续调用异步I / O
- 后续也推出了child_process和cluster模块较好地缓解了以上缺点
跨平台
起初Node只能在Linux平台上运行,如果想在Windows平台上学习和使用Node,则必须通过Cygwin / MinGW,后微软投入通过基于libuv实现跨平台架构
- libuv
在操作系统与Node上层模块系统之间构建了一层平台架构
通过良好的架构,Node的第三方C++模块也可以借助libuv实现跨平台
Node模块机制 - CommonJS
背景:
在其他高级语言中,Java有类文件,Python有import机制,Ruby有require,PHP有include和require。而JavaScript通过script标签引入代码的方式显得杂乱无章,。人们不得不用命名空间等方式人为地约束代码,以达到安全和易用的目的。
直到后来出现了CommonJS…
愿景
希望JavaScript能欧在任何地方运行
出发点
对于JavaScript自身而言,它的规范依然是薄弱的,还有以下缺陷:
- 没有模块系统
- 标准库较少
- ECMAScript仅定义了部分核心库
- 对于文件系统 I / O流等常见需求没有标准API
- 没有标准接口
- 在JavaScript中,几乎没有定义过如Web服务器或者数据库之类的标准统一接口
- 缺乏包管理系统
- 导致JavaScript应用中基本没有自动加载和安装以来的能力
CommonJS的提出,主要是为了弥补当前JavaScript没有标准的缺陷,以达到像Python、Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段,希望可以利用JavaScript开发:
- 服务端JavaScript程序
- 命令行工具
- 桌面图形界面应用程序
- 混合应用
CommonJS规范涵盖了:
- 模块
- 二进制
- Buffer
- 字符集编码
- I / O流
- 进程环境
- 文件系统
- 套接字
- 单元测试
- Web服务器网关接口
- 包管理
Node与浏览器以及W3C组织、CommonJS组织、ECMAScript之间的关系,共同构成了一个繁荣的生态系统
模块规范
- 模块定义
上下文提供了exports对象用于导出当前模块的方法或者变量,并且它是导出的唯一出口
在模块中,还存在一个module对象,它代表模块自身,而exports是module的属性
在Node中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式
// math.js |
- 模块引用
const math = require('./math'); |
在CommonJS规范中,存在require方法,这个方法接受模块标识,以此引入一个模块的API到当前上下文中
- 模块标识
模块标识就是传递给require方法的参数,可以是:
- 如何小驼峰命名的字符串
- 以./ 、…/ 开头的相对路径 or 绝对路径
- 可以没有文件名后缀.js
模块的定义十分简单,接口也十分简洁
每个模块具有独立的空间,它们互不干扰,在引用时也显得干净利落
- 意义:
将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅地连接上下游依赖
模块实现
在Node引入模块,需要经历以下三个步骤
- 路径分析
- 文件定位
- 编译执行
Node中模块分为两类:
- 核心模块
编译过程中,编译进了二进制执行文件
在Node进程启动时,部分核心模块就直接被加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略,并且在路径分析中优先判断,所以它的加载速度是最快的。
- 用户编写的文件模块
运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢
优先从缓存加载
与浏览器会缓存静态脚本文件以提高性能一样,Node对引入过的模块都会进行二次缓存,以减少二次引入时的开销。不同点在于:
- 浏览器仅缓存文件
- Node缓存的是编译和执行之后的对象
无论核心模块还是文件模块,require方法对相同模块的二次加载都一律采用缓存优先的方式
路径分析和文件定位
标识符分析(路径)
前面说到过,require方法接受一个参数作为标识符,分为以下几类:
- 核心模块
优先级仅次于缓存加载,在Node的源代码编译过程中已编译为二进制代码,加载过程最快
注:加载一个与核心模块标识符相同的自定义模块是不会成功的,只能通过选择不同的标识符 / 换用路径的方式实现
- 路径形式的文件模块
以 ./ 、…/ 开头的标识符都被当做文件模块处理
require方法会将路径转为真实路径,并以真实路径为索引,将编译执行后的结果存放到缓存中,以使二次加载更快
文件模块给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度仅慢于核心模块
- 自定义模块
是一种特殊的文件模块,是一个文件或者包的形式
这类模块的查找是最费时的,也是最慢的一种
先介绍一下模块路径这个概念,也是定位文件模块时制定的查找策略,具体表现为一个路径组成的数组
console.log(module.path)
- 你可以得到一个路径数组
[‘/home/bytedance/reasearch/node_modules’,
‘/home/bytedance/node_modules’,
‘home/node_module’, /node_modules’]
可以看出规则如下:
- 当前文件目录下的node_modules目录
- 父目录下的node_modules目录
- 父目录的父目录下的node_modules目录
- 沿路径向上逐级递归,直到根目录下的node_modules目录
它的生成方式与JavaScript原型链 / 作用域链的查找方式十分类似
在加载过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件
文件路径越深,模块查找耗时会越多,这是自定义模块的加载速度最慢的原因
文件定位
- 文件扩展名分析
require分析标识符会出现不包含文件扩展名的情况
会按.js、.json、.node的次序补足扩展名,一次尝试
过程中,需调用fs模块同步阻塞地判断文件是否存在,Node单线程因此会引起性能问题
如果是.node / .json文件带上扩展名能加快点速度,配合缓存机制,可大幅缓解Node单线程阻塞调用的缺陷
- 目录分析和包
分析标识符的过程中可能没有找到文件,却得到一个目录,则会将目录当做一个包来处理
通过解析package.json文件对应该包的main属性指定的文件名
如果main相应文件解析错误 / 没有package.json文件,node会将index作为文件名
一次查找index.js index.json index.node
该目录没有定位成功则进行下一个模块路径进行查找
直到模块路径数组都被遍历完依然没有查找到目标文件则抛出异常
模块编译
在Node中,每个文件模块都是一个对象
function Module(id, parent) { |
- js文件
- 通过fs模块同步读取文件后编译执行
- node文件
- 这是用C/C++编写的扩展文件,通过dlopen方法加在最后编译生成的文件
- json文件
- 通过fs模块同步读取文件后,JSON.parse解析返回的结果
- 其他
- 都被当作js文件载入
每一个编译成功的模块都会将其文件路径作为索引存在Module.cache对象上,以提高二次引入的性能
包与NPM
Node组织了自身核心模块,也使得第三方文件模块可以有序地编写和使用
但是在第三方模块中,模块与模块之间仍然是散列在各地的,相互之间不能直接引用
而在模块之外,包和 NPM 则是将模块联系起来的一种机制
一定程度上解决了变量依赖、依赖关系等代码组织性问题
包结构
包实际上是一个存档文件,即一个目录直接打包为一个.zip/tar.gz格式的文件,安装后解压还原为目录
- 符合CommonJS规范的包目录应该包含如下文件
- package.json 包描述文件
- bin 用于存放可执行二进制文件
- lib 用于存放JavaScript代码的目录
- doc 用于存放文档的目录
- test 用于存放单元测试用例的代码
包描述文件
package.json
CommonJS为package.json定义了如下一些必须的字段
- name 包名
- description 包简介
- version 版本号
- keywords 关键词数组,用于做npm搜索
- maintainers 包维护者列表
- contributors 贡献者列表
- bugs 一个可以反馈bug的网页地址 / 邮件地址
- licenses 许可证列表
- repositories 托管源代码的位置列表
- dependencies 使用当前包所需要依赖的包
- homepage 当前包的网站地址
- os 操作系统支持列表
- aix、freebsd、linux、macos、solaris、vxworks、windows
- cpu CPU架构的支持列表
- arm、mips、ppc、sparc、x86、x86_64
- builtin 标志当前包是否是内建在底层系统的标准组件
- implements 实现规范的列表
- scripts 脚本说明对象
包规范的定义可以帮助Node解决依赖包安装的问题,而NPM正是基于该规范进行了实现
NPM 常用功能
CommonJS包规范是理论,NPM是其中一种实践
NPM于Node,相当于gem于Ruby,pear于PHP
帮助完成了第三方模块的发布、安装和依赖等
- 查看帮助
- 查看版本
npm -v
- 查看命令
npm
- 安装依赖包
npm install {packageName} |
执行该命令后,NPM会在当前目录下创建node_modules目录下创建包目录,接着将相应的包解压到这个目录下
- 全局安装模式
npm install {packageName} -g |
全局模式并不是将一个模块包安装为一个全局包的意思,它并不意味着可以从任何地方reuqire它
全局模式这个成为并不精确,-g 实际上是将一个包安装为全局可用的执行命令
它根据包描述文件中的bin字段配置,将实际脚本链接到与Node可执行文件相同的路径下
- 从本地安装
对于一些没有发布到NPM上的包,或者因为网络原因无法直接安装的包
可以通过将包下按在到本地,然后本地安装
npm install <tarball file> |
- 从非官方源安装
如果不能通过官方源安装,可以通过镜像源安装
npm install --registry={urlResource} |
如果使用过程中几乎全使用镜像源,可以指定默认源
npm config set registry {urlResource} |
- NPM 钩子命令
package.json中scripts字段的提出就是让包在安装或者卸载等过程中提供钩子机制
"scripts":{ |
- Install
- 在以上字段执行
npm install <package>
时,preinstall指向的脚本会被加载执行,然后install指向的脚本会被执行
- 在以上字段执行
- Uninstall
- 执行
npm uninstall <package>
时,uninstall指向的脚本也许会做一些清理工作
- 执行
- Test
- 执行
npm test
将会运行test指向的脚本,一个优秀的包应当包含测试用例,并在package.json文件正配置好运行测试的命令,方便用户运行测试用例,以便检验包是否稳定可靠
- 执行
局域 NPM
- 背景
企业的限制在于,一方面需要享受到模块开发带来的低耦合和项目组织上的好处,另一方面却要考虑模块保密性的问题。所以,通过NPM共享和发布存在潜在的风险。
- 解决方案
为了同时能够享受到NPM上众多的包,同时对自己的包进行保密和限制,现有的解决方案就是企业搭建自己的NPM仓库,NPM无论是它的服务端和客户端都是开源的。
局域NPM仓库的搭建方法与搭建镜像站的方式几乎一样,与镜像仓库不同的地方在于可以选择不同步官方源仓库中的包
- 作用
- 私有的可重用模块可以打包到局域NPM仓库中,这样可以保持更新的中心化,不至于让各个小项目维护相同功能的模块
- 杜绝通过复制粘贴实现代码共享的行为
异步I / O
为什么需要异步 I / O ?
- 用户体验
浏览器中JavaScript在单线程上执行,还和UI渲染共用一个线程
《高性能JavaScript》曾总结过,如果脚本执行的时间超过100ms用户就会感到页面卡顿
如果网页临时需要获取一个网络资源,通过同步的方式获取,JS需要等资源完全从服务器获取后才能继续执行,这期间UI将停顿,不响应用户的交互行为。可以想象,这样的用户体验将会多差。
而采用异步请求,JavaScript和UI的执行都不会处于等待状态,给用户一个鲜活的页面
I / O是昂贵的,分布式I / O 是更昂贵的
只有后端能够快速响应资源,才能让前端体验变好
- 资源分配
计算机在发展过程中将组件进行了抽象,分为了I / O设备和计算设备
假设业务场景有一组互不相关的任务需要完成,主流方法有两种:
- 多线程并行完成
多线程的代价在于创建线程和执行线程上下文切换的开销较大。
在复杂的业务中经常面临锁、状态同步等问题。但是多线程在多核CPU上能够有效提升CPU利用率
- 单线程串行依次执行
单线程顺序执行任务比较符合编程人员按顺序思考的思维方式,依然是主流的编程方式
串行执行的缺点在于性能,任意一个略慢的任务都会导致后续执行代码被阻塞
在计算机资源中,通常I / O与CPU计算是可以并行的,同步编程模型导致的问题是,I / O的进行会让后续任务等待,这造成资源不能更好地被利用
- Node在两者之间给出了它的答案
利用单线程,远离多线程死锁、状态同步等问题;
利用异步I / O,让单线程可以远离阻塞,更好地使用CPU
为了弥补单线程无法利用多核CPU的缺点,Node提供了类似前端浏览器中Web Workers的子进程,该子进程可以通过工作进程高效地利用CPU和I / O
异步I / O的提出是期望I / O的调用不再阻塞后续运算,将原有等待I / O完成的这段时间分配给其余需要的业务去执行
异步I / O现状
异步I / O与非阻塞I / O
操作系统内核对于I / O方式只有两种:阻塞与非阻塞
在调用阻塞I / O时,应用程序需要等待I / O完成才返回结果
特点:调用之后一定要等到系统内核层面完成所有操作后调用才结束
例子:系统内核在完成磁盘寻道、读取数据、复制数据到内幕才能中之后,这个调用才结束》
非阻塞I / O与阻塞I / O的差别为调用之后会立即返回
非阻塞I / O返回之后,CPU的时间片可以用来处理其他事务,此时的性能提升是明显的
存在的问题:
- 由于完整的I / O没有完成,立即返回的并不是业务层期望的数据而仅仅是当前调用的状态
- 为了获取完整的数据,应用程序需要重复调用I / O操作来确认是否完成,称之为“轮询”。
主要的轮询技术
- read
它是最原始、性能最低的一种,通过重复调用检查I / O的状态来完成数据的完整读取
在得到最终数据前,CPU一直耗用在等待上
- select
它是在read的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断
限制:它采用一个1024长度的数组来存储状态,最多可以同时检查1024个文件描述符
- poll
较select有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查
文件描述符较多时,它的性能还是十分低下的
- epoll
该方案是Linux下效率最高的I / O事件通知机制,在进入轮询的时候如果没有检查到I / O事件,将会进行休眠,直到事件将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高
理想的非阻塞异步I / O
尽管epoll已经利用了时间来降低CPU的耗用,但是休眠期间CPU几乎是限制的,对于当前线程而言利用率不够
完美的异步I / O应该是应用程序发起非阻塞调用,无需通过遍历或者时间唤醒等方式轮询
可以直接处理下一个任务,只需在I / O完成后通过信号或回调将数据传递给应用程序即可
Linux下存在原生提供的一种异步I / O方式(AIO)就是通过信号或者回调来传递数据的
缺点:
- 仅Linux下有
- 仅支持I / O中的O_DIRECT方式读取,导致无法利用系统缓存
现实的异步I / O
通过让部分线程进行阻塞I / O或者非阻塞I / O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I / O得到的数据进行传递,这就轻松实现了异步I / O(尽管它是模拟的
- libeio实质上是采用线程池与阻塞I / O模拟异步I / O
- Node最初在*nix平台下采用libeio配合libev实现异步I / O,后通过自行实现线程池完成
- Windows下的IOCP
- 调用异步方法,等待I / O完成之后的通知,执行回调,用户无需考虑轮询
- 内部其实仍是线程池的原理,不同之处在于这些线程池由系统内核接手管理
- 与Node异步调用模型十分近似
- 由于Windows平台和*nix平台的差异,Node提供了libuv作为抽象封装层,做兼容性判断
- 保证上层Node与下层的自定义线程池和IOCP各自独立
- 我们时常提到Node是单线程的
- 这里的单线程仅仅只是JavaScript执行在单线程中罢了
- 无论是*nix还是Windows平台,内部完成I / O任务的另有线程池
Node的异步I / O
Node完成整个异步I / O环节的有事件循环、观察者和请求对象等
事件循环
着重强调一下Node自身的执行模型——事件循环
Node进程启动时,会创建一个类似while(true)的循环
每次循环体的过程称之为Tick,每个Tick的过程就是查看是否有事件待处理
如果有就取出事件及其相关的回调函数,并执行它们
观察者
每个事件循环中有一个或多个观察者,而判断是否有事件要处理的过程就是向这些观察者询问是否有要处理的事件
- 浏览器采用了类似的机制
- 事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者
- Node中事件主要来源于网络请求、文件I / O等
- 这些时间对应的观察者有文件I / O观察者、网络I / O观察者等,将事件进行了分类
- 事件循环是一个典型的生产者 / 消费者模型
- 异步I / O、网络请求等则是事件的生产者
- 这些事件被传递到对应的观察者,事件循环则从观察者那取出事件并处理
小结
事件循环、观察者、请求对象、I / O线程池这四者共同构成了NOde异步I / O模型的基本要素
由于我们知道JavaScipt是单线程的,所以按尝试很容易理解它不能充分利用多核CPU
事实上在Node中,除了JavaScript是单线程外,Node自身其实是多喜爱昵称的,只是I / O线程使用的CPU较少
另一个需要注意的点是,除了用户代码无法并行执行以外,所有的I / O是可以并行执行的
注:图为Node整个异步I / O过程
事件驱动与高性能服务器
前面对异步的讲解,也基本勾勒出了事件驱动的实质,即通过主循环加事件触发的方式来运行程序
下面为几种经典的服务器模型:
- 同步式
- 一次只能处理一个请求,并且其余请求都处于等待状态
- 进程 / 请求
- 这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多
- 线程 / 请求
- 尽管线程比进程要清凉,但是由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢
- 比进程 / 请求要好,但对于大型站点而言依然不够
- 总结
- 线程 / 请求的方式目前还被Apache所采用
- Node通过事件驱动的方式处理请求,无需为每一个请求创建额外的线程,可以省掉创建线程和销毁线程的开销
- 同时操作系统在调度任务时因为线程较少,上下文的代价很低
- 这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受上下文切换开销的影响,这也是Node高性能的一个原因
事件驱动带来的高效已经渐渐开始为业界所重视
知名服务器Nginx也摒弃了多线程的方式,采用和Node相同的事件驱动
不同之处在于Nginx采用纯C写成,性能较高,但是它仅适合于做Web服务器,用于反向代理或者负载均衡服务,在业务处理方面较为欠缺
Node则是一套高性能平台,可以利用它构建与Nginx相同的功能,也可以处理各种具体业务
Node没有Nginx在Web服务器方面那么专业,但场景更大,自身性能也不错
在实际项目中可以结合它们各自的优点以达到应用的最优性能
JavaScript在服务器端近乎空白,使得Node没有任何历史包袱,而Node在性能优化上的表现使得它一下子就在社区中流行了起来~
写在最后
本文介绍了Node被创造的目的、语言选型、特点、模块机制、包管理机制以及异步I / O等相关知识,希望能让你对Node有一个新的认识。最近一直也在计划学习Node和服务端相关知识,感兴趣的同学可以一起学习和交流~
掘金:前端LeBron
知乎:前端LeBron
持续分享技术博文,关注微信公众号👇🏻