最初做Node的目的是什么?
为什么是JavaScript?
- 事件驱动
- 非阻塞I / O
Ryan Dahl也曾评估过使用C、Lua、Haskell、Ruby等语言作为备选实现,得出以下结论:
- C的开发门槛高,可以预见不会有太多的开发者能将它用于业务开发
- Ryan Dahl觉得自己还不足够玩转Haskell,所以舍弃它
- Lua自身已经含有很多阻塞 I / O 库,为其构建非阻塞 I / O 库不能改变开发者使用习惯
- Ruby的虚拟机性能不佳
JavaScript的优势:
- 开发门槛低
- 在后端领域没有历史包袱
- 第二次浏览器大战渐渐分出高下,Chrome浏览器的JavaScript引擎V8摘得性能第一的桂冠
Node给JavaScript带来的意义
- 浏览器通过事件驱动来服务界面上的交互
- Node通过事件驱动来服务 I / O
- 随心所欲地访问本地文件
- 搭建WebSocket服务端
- 连接数据库,进行业务研发
- 像Web Worker一样玩转多进程
Node的特点
异步I / O
- 以读取文件为例
var fs = require('fs');
fs.readFile('/path', function (err, file) {
console.log('读取文件完成')
});
console.log('发起读取文件');
Node中,绝大多数操作都以异步的方式进行调用,Ryan Dahl排除万难,在底层构建了很多异步I / O的API,从文件读取到网络请求等。使开发者很已从语言层面很自然地进行并行I / O操作,在每个调用之间无需等待之前的I / O调用结束,在编程模型上可以极大提升效率
事件与回调函数
事件
随着Web2.0的到来,JavaScript在前端担任了更多的职责,时间也得到了广泛的应用。将前端浏览器中广泛应用且成熟的事件与回到函数引入后端,配合异步I / O ,可以很好地将事件发生的时间点暴露给业务逻辑。
- 服务端例子
var http = require('http');
var querystring = require('querystring');
// 侦听服务器的request事件
http.createServer(function (req, res) {
var postData = '';
req.setEncoding('utf8');
// 侦听请求的data事件
req.on('data', function (trunk) {
postData += trunk;
});
// 侦听请求的end事件
req.on('end', function () {
res.end(postData);
});
}).listen(8080);
console.log('服务器启动完成');
- 前端例子
request({
url: '/url',
method: 'POST',
data: {},
success: function (data) {
// success事件
}
});
- 事件发布/订阅模式
- Promise、async / await
- 流程控制库
回调函数
- Node除了异步和事件外,回调函数也是一大特色
纵观下来,回调函数也是最好的接收异步调用返回数据的方式
- 但是这种编程方式对于很多习惯同步思路编程的人来说,也许是十分不习惯的
- 代码的编写顺序与执行顺序并无关系,这对他们可能造成阅读上的障碍
在流程控制方面,因为穿插了异步方法和回调函数,与常规的同步方式相比变得不那么一目了然了
- 转变为异步编程思维后,通过对业务的划分和对事件的提炼,在流程控制方面处理业务的复杂度是与同步方式实际上是一致的
单线程
单线程的缺点
- 无法利用多核CPU
- 错误会引起整个应用退出,健壮性较差
- 大量计算占用CPU导致无法继续调用异步I / O
- 后续也推出了child_process和cluster模块较好地缓解了以上缺点
跨平台
- libuv
Node模块机制 - CommonJS
愿景
出发点
对于JavaScript自身而言,它的规范依然是薄弱的,还有以下缺陷:
- 没有模块系统
标准库较少
- ECMAScript仅定义了部分核心库
- 对于文件系统 I / O流等常见需求没有标准API
没有标准接口
- 在JavaScript中,几乎没有定义过如Web服务器或者数据库之类的标准统一接口
缺乏包管理系统
- 导致JavaScript应用中基本没有自动加载和安装以来的能力
CommonJS的提出,主要是为了弥补当前JavaScript没有标准的缺陷,以达到像Python、Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段,希望可以利用JavaScript开发:
- 服务端JavaScript程序
- 命令行工具
- 桌面图形界面应用程序
- 混合应用
CommonJS规范涵盖了:
- 模块
- 二进制
- Buffer
- 字符集编码
- I / O流
- 进程环境
- 文件系统
- 套接字
- 单元测试
- Web服务器网关接口
- 包管理
模块规范
- 模块定义
// math.js
exports.add = function(a, b){
return a + b;
}
- 模块引用
const math = require('./math');
const res = math.add(1, 1);
console.log(res);
// 2
- 模块标识
模块标识就是传递给require方法的参数,可以是:
- 如何小驼峰命名的字符串
- 以./ 、../ 开头的相对路径 or 绝对路径
- 可以没有文件名后缀.js
- 意义:
模块实现
- 路径分析
- 文件定位
- 编译执行
Node中模块分为两类:
- 核心模块
- 用户编写的文件模块
优先从缓存加载
- 浏览器仅缓存文件
- Node缓存的是编译和执行之后的对象
路径分析和文件定位
标识符分析(路径)
前面说到过,require方法接受一个参数作为标识符,分为以下几类:
- 核心模块
- 路径形式的文件模块
- 自定义模块
先介绍一下模块路径这个概念,也是定位文件模块时制定的查找策略,具体表现为一个路径组成的数组
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目录
文件定位
- 文件扩展名分析
- 目录分析和包
模块编译
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
if (parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}
js文件
- 通过fs模块同步读取文件后编译执行
node文件
- 这是用C/C++编写的扩展文件,通过dlopen方法加在最后编译生成的文件
json文件
- 通过fs模块同步读取文件后,JSON.parse解析返回的结果
其他
- 都被当作js文件载入
包与NPM
包结构
符合CommonJS规范的包目录应该包含如下文件
- package.json 包描述文件
- bin 用于存放可执行二进制文件
- lib 用于存放JavaScript代码的目录
- doc 用于存放文档的目录
- test 用于存放单元测试用例的代码
包描述文件
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 脚本说明对象
NPM 常用功能
- 查看帮助
- 查看版本
npm -v
- 查看命令
npm
- 安装依赖包
npm install {packageName}
- 全局安装模式
npm install {packageName} -g
- 从本地安装
npm install <tarball file>
npm install <tarball url>
npm install folder>
- 从非官方源安装
npm install --registry={urlResource}
npm config set registry {urlResource}
- NPM 钩子命令
"scripts":{
"preinstall": "preinstall.js",
"install": "install.js",
"uninstall": "uninstall.js",
"test": "test.js",
}
Install
- 在以上字段执行
npm install <package>
时,preinstall指向的脚本会被加载执行,然后install指向的脚本会被执行
- 在以上字段执行
Uninstall
- 执行
npm uninstall <package>
时,uninstall指向的脚本也许会做一些清理工作
- 执行
Test
- 执行
npm test
将会运行test指向的脚本,一个优秀的包应当包含测试用例,并在package.json文件正配置好运行测试的命令,方便用户运行测试用例,以便检验包是否稳定可靠
- 执行
局域 NPM
- 背景
企业的限制在于,一方面需要享受到模块开发带来的低耦合和项目组织上的好处,另一方面却要考虑模块保密性的问题。所以,通过NPM共享和发布存在潜在的风险。
- 解决方案
为了同时能够享受到NPM上众多的包,同时对自己的包进行保密和限制,现有的解决方案就是企业搭建自己的NPM仓库,NPM无论是它的服务端和客户端都是开源的。
作用
- 私有的可重用模块可以打包到局域NPM仓库中,这样可以保持更新的中心化,不至于让各个小项目维护相同功能的模块
- 杜绝通过复制粘贴实现代码共享的行为
异步I / O
为什么需要异步 I / O ?
- 用户体验
- 资源分配
- 多线程并行完成
多线程的代价在于创建线程和执行线程上下文切换的开销较大。
在复杂的业务中经常面临锁、状态同步等问题。但是多线程在多核CPU上能够有效提升CPU利用率
- 单线程串行依次执行
单线程顺序执行任务比较符合编程人员按顺序思考的思维方式,依然是主流的编程方式
串行执行的缺点在于性能,任意一个略慢的任务都会导致后续执行代码被阻塞
在计算机资源中,通常I / O与CPU计算是可以并行的,同步编程模型导致的问题是,I / O的进行会让后续任务等待,这造成资源不能更好地被利用
- Node在两者之间给出了它的答案
异步I / O现状
异步I / O与非阻塞I / O
- 由于完整的I / O没有完成,立即返回的并不是业务层期望的数据而仅仅是当前调用的状态
- 为了获取完整的数据,应用程序需要重复调用I / O操作来确认是否完成,称之为“轮询”。
主要的轮询技术
- read
- select
- poll
- epoll
理想的非阻塞异步I / O
完美的异步I / O应该是应用程序发起非阻塞调用,无需通过遍历或者时间唤醒等方式轮询
可以直接处理下一个任务,只需在I / O完成后通过信号或回调将数据传递给应用程序即可
Linux下存在原生提供的一种异步I / O方式(AIO)就是通过信号或者回调来传递数据的
缺点:
- 仅Linux下有
- 仅支持I / O中的O_DIRECT方式读取,导致无法利用系统缓存
现实的异步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进程启动时,会创建一个类似while(true)的循环
每次循环体的过程称之为Tick,每个Tick的过程就是查看是否有事件待处理
如果有就取出事件及其相关的回调函数,并执行它们
观察者
浏览器采用了类似的机制
- 事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者
Node中事件主要来源于网络请求、文件I / O等
- 这些时间对应的观察者有文件I / O观察者、网络I / O观察者等,将事件进行了分类
事件循环是一个典型的生产者 / 消费者模型
- 异步I / O、网络请求等则是事件的生产者
- 这些事件被传递到对应的观察者,事件循环则从观察者那取出事件并处理
小结
由于我们知道JavaScipt是单线程的,所以按尝试很容易理解它不能充分利用多核CPU
事实上在Node中,除了JavaScript是单线程外,Node自身其实是多喜爱昵称的,只是I / O线程使用的CPU较少
另一个需要注意的点是,除了用户代码无法并行执行以外,所有的I / O是可以并行执行的
事件驱动与高性能服务器
下面为几种经典的服务器模型:
同步式
- 一次只能处理一个请求,并且其余请求都处于等待状态
进程 / 请求
- 这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多
线程 / 请求
- 尽管线程比进程要清凉,但是由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢
- 比进程 / 请求要好,但对于大型站点而言依然不够
总结
- 线程 / 请求的方式目前还被Apache所采用
- Node通过事件驱动的方式处理请求,无需为每一个请求创建额外的线程,可以省掉创建线程和销毁线程的开销
同时操作系统在调度任务时因为线程较少,上下文的代价很低
- 这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受上下文切换开销的影响,这也是Node高性能的一个原因
写在最后
本文介绍了Node被创造的目的、语言选型、特点、模块机制、包管理机制以及异步I / O等相关知识,希望能给你带来对Node有一个新的认识。最近一直也在计划学习Node和服务端相关知识,感兴趣的同学可以一起学习和交流~