Node.js is a platform built on Chrome’s JavaScript runtime for easily building fast, scalable network applications.
Node简介
Node是JavaScript语言的服务器运行环境,是一个基于Chrome V8引擎的JavaScript运行时。
所谓“运行环境”有两层意思:
- JavaScript语言通过Node在服务器运行,在这个意义上,Node有点像JavaScript虚拟机的意思。
- Node提供大量工具库,使得JavaScript语言与操作系统互动(例如读写文件、新建子进程),在一层Node又是JavaScript的工具库。
Node的特点
异步I/O
对于异步的I/O这个概念,对于用过Ajax的人来说,要理解起来再简单不过了。我们通过发起一个Ajax请求:
$.post('/url', {title: 'test.js'}, function (data) {
console.log('收到响应');
});
console.log('发送Ajax请求结束');
// 发送Ajax请求结束 => 收到响应
复制代码
我们在调用$.post()
后,后续代码是被立即执行的,但“收到响应”的执行时间不可预估的。如下图:
在Node中,异步I/O也是差不多的原理。我们以文件读取为例:
cosnt fs = require('fs')
fs.readFile('/path', (err, file) => {
console.log('读取文件完成')
})
console.log('发起读取文件')
// 发起读取文件 => 读取文件完成
复制代码
I/O是耗时昂贵的,分布式I/O更加昂贵。加载一个资源来自两个不同位置的数据返回结果,第一个资源需要M毫秒耗时,第二个资源需要N毫秒。如果采用同步的方式:
getData('from_db'); // 消费时间为M
getData('form_remote_api') // 消费时间为N
复制代码
如果我们采用异步的方式,第一个资源的获取并不会阻塞第二个资源,也即第二个资源的请求并不会依赖第一个资源的结束,因此我们可以享受到并行的优势:
getData('form_db', (req, res) => {
// 消费时间为M
});
getData('form_remote_api', (req, res) => {
// 消费时间为N
})
复制代码
二者的时间总消耗,前者为M+N, 后者为max(M + N)。随着应用复杂性的提高,同步与异步的优劣势将会逐渐凸显出来。
关于整个Node的异步I/O流程如下:
事件驱动/事件循环
- 每个Node进程只有一个主线程在执行代码,形成一个执行栈(execution context stack)。
- 除主线程之外,还维护了一个“事件队列(Event Queue)”。当网络请求和其他的异步操作进来时,Node都会放入Event Queue中,但是并不回立即执行,因此代码也并不会被阻塞,继续运行后面的代码,直到主线程执行完毕。
- 再从线程池中分配一个线程去执行,然后第三个,第四个。主线程不断的检查事件队列中是否有未执行的事件,直到事件队列中所有事件都执行完了,此后每当有新的事件加入到事件队列中,都会通知主线程按顺序取出交EventLoop处理。当有事件执行完毕后,会通知主线程,主线程执行回调,线程归还给线程池。
- 主线程不断重复步骤三,知道执行栈全部消费完。
每次事件循环都包含了6个阶段。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
复制代码
- timers 阶段:这个阶段执行timer(
setTimeout
、setInterval
)的回调 - I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调
- idle, prepare 阶段:仅node内部使用
- poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
- check 阶段:执行
setImmediate()
的回调 - close callbacks 阶段:执行
socket
的close
事件回调。
回调函数
除了异步和事件之外,回调函数也是它的一大特点,回调函数也是最好接受异步调用返回的数据的方式之一。但是这种编程方式对于很多习惯同步思路编程的人来说,是特别不习惯的。当我们的业务逻辑复杂的时候,回调的嵌套过多,代码复杂度增加,可读性降低,维护起来也复杂,调试也复杂,这个就是回调地狱。
function doByCallback(id, next) {
startJobA(id, (err, resA) => {
startJobB(id, (err, resB) => {
startJobC(id, (err, resC) => {
startJobD(id, (err, resD) => {
if (err) return next(err);
return next(null, resD);
})
})
})
})
}
复制代码
当然现在我们很多方法的可以解决这个问题,例如async
,then.js
,generator
等等。
const methodAsync = async (id, next) => {
try {
const resA = await startJobA(id);
const resB = await startJobB(id);
...
} catch (err) {
return next(err);
}
}
function *methodGenerator(id, next) {
try {
const resA = yield startJobA(id);
const resB = yield startJobB(id);
...
} catch (err) {
return next(err);
}
}
复制代码
单线程
JavaScript是单线程的,在Node中保持了这个特点。并且在Node中,JavaScript与其余线程是无法共享任何状态的。单线程的最大好处就是不用像多线程那样处处留意状态的同步问题,没有死锁,没有上下文交换的性能开销。Node将所有阻塞操作都交给了内部的线程池去实现,而自身只负责不断的往返调度,并没有真正的I/O操作,从而实现异步非阻塞I/O。
但是单线程也有自身的弱点:
- 无法利用多核CPU
- 错误可能会引起整个应用退出,健壮性堪忧
- 大量计算占用CPU导致无法继续调用异步I/O
但是后来HTML5定制了Web Workers的标准。Web Workers能够创建工作线程来进行计算,来解决JavaScript大量计算阻塞UI渲染的问题。
Node与操作系统的交互
从Node整个库的设计架构来看,Node大致被分为三层结构:
- Node standard library层,是由JavaScript编写的,即是我们能够直接调用到的API,我们可以在源码中的lib目录下看到。
- No bindings,这一层是JavaScript与底层C/C++沟通的关键,上层通过bindings调用下层,相互交换数据。
- 最后这一次基建层,是支撑Node.js运行的关键,同样也是由C/C++编写。
- V8:Google 推出的 Javascript VM,也是 Node.js 为什么使用的是 Javascript的关键,它为 Javascript提供了在非浏览器端运行的环境,它的高效是 Node.js 之所以高效的原因之一。
- Libuv:为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力,是 Node.js 如此强大的关键。
- C-ares:提供了异步处理 DNS 相关的能力。
- ….
我们通过一个例子来简单了解一下Node与操作系统的一个交互逻辑:
const fs = require('fs');
fs.open('./test.json', 'w', (err, fd) => {
// do something
});
复制代码
上面是一个简单的读取本地test.json
文件的例子,整个调用流程是这样一个逻辑:
lib/fs.js → src/node_file.cc → uv_fs
async function open(path, flags, mode) {
mode = modeNum(mode, 0o666);
path = getPathFromURL(path);
validatePath(path);
validateUint32(mode, 'mode');
return new FileHandle(
await binding.openFileHandle(pathModule.toNamespacedPath(path),
stringToFlags(flags),
mode,
kUsePromises
));
}
复制代码
node_file.cc
static void Open(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
const int argc = args.Length();
if (req_wrap_async != nullptr) { // open(path, flags, mode, req)
AsyncCall(env, req_wrap_async, args, "open", UTF8, AfterInteger,
uv_fs_open, *path, flags, mode);
} else { // open(path, flags, mode, undefined, ctx)
CHECK_EQ(argc, 5);
FSReqWrapSync req_wrap_sync;
FS_SYNC_TRACE_BEGIN(open);
int result = SyncCall(env, args[4], &req_wrap_sync, "open",
uv_fs_open, *path, flags, mode);
FS_SYNC_TRACE_END(open);
args.GetReturnValue().Set(result);
}
}
复制代码
uv_fs
dstfd = uv_fs_open(NULL,
&fs_req,
req->new_path,
dst_flags,
statsbuf.st_mode,
NULL);
uv_fs_req_cleanup(&fs_req);
复制代码
包与NMP
Node组织了自身的核心模块,同时也让第三方文件模块可以有序的编写和使用。但是由于在第三方模块中,模块与模块之间的仍然是散列分布的,相互之间无法直接应用。而在模块之外,包和NPM则是将模块联系在一起的一种机制。
包结构
├── package.json # 包的描述文件
├── bin # 用于存放JavaScript代码的目录
├── lib # 用于存放JavaScript代码的目录
├── src # 用于存放JavaScript代码的目录
├── doc # 用于存放文档的目录
└── test # 用于存放单元测试用例的目录
复制代码
包描述文件与NPM
包描述文件主要用于表达非代码相关的信息。他是一个JSON格式的文件,一般位于项目的根目录下,只包的必要组成部分。在CommonJS中package.json文件定义了如下一些必须的字段。
{
"main": "index.ts", // 定义了 npm 包的入口文件,browser 环境和 node 环境均可使用,模块引入方法require()在引入包时,会有限检查这个字段,并将其作为包中其余模块的入口
"module": "index.ts", // 定义 npm 包的ESM规范的入口文件,browser 环境和 node 环境均可使用
"scripts": {}, // 脚本对象说明
"dependencies": {}, // 使用当前包所需要依赖的包列表。
"devDependencies": {}, // 只在开发时需要依赖。
}
复制代码
NPM包安装命令
npm install xxxx --save
npm install xxxx --save-dev
npm install xxxx -g
复制代码