初识 NodeJS
NodeJS & 传统服务器区别
当客户端向传统的服务端 (apache、resin、tomcat、iis)发起请求时, 所属的进程都会单独开辟一个线程来处理该请求, 该线程又会去数据库中 (mysql、redis、mongodb、memcached) 找相应的数据返回给该线程, 线程再返回给客户端, 此时该线程就会被销毁, 但这个过程可能会有很多原因导致返回时间较长
Tips
- 针对上述过程, 服务端有相应的优化手段: 线程池概念(多线程模式)
- 程序 > 进程 > 线程
- 进程是操作系统分配资源和调度任务的基本单位
但 NodeJS 是一个单线程、非阻塞 I/O 服务器, 当客户端向服务端发起请求时, NodeJS 会调用唯一的主线程对请求进行处理, 去请求数据库中相应的数据, 但是不会等数据库返回, 而是去处理下一个客户端的请求, 等数据库中返回了数据后, 唯一的主线程才会去接收返回的数据返回给客户端
非阻塞 I/O: 数据库的请求不会堵塞主线程
NodeJS 优点
- 节约内存: 多线程 -> 单线程, 使用事件驱动、非阻塞 I/O 模型, 轻量 & 高效
- 节约上下文 (context 环境) 切换时间: 传统多线程并不是在同一个时间执行多个任务, 而是不停、快速的切换操作系统的时间片来实现的
- 不需要考虑并发资源处理, 锁的问题: 多线程的时候会同时运行, 但是当同时运行的线程都需要访问同一个资源的时候就会出现问题; JAVA 锁的实现: 用的时候把它锁上, 用完后解锁
- 执行效率高: 基于 Chrome V8 引擎的 JavaScript 运行环境, 让 JS 执行效率和低端 C 语言相近的执行效率
- 社区庞大: NodeJS 的包管理器 npm, 是全球最大的开原生态系统
但是在 NodeJS 中, 如果一个线程崩了, 那么整个服务器就 down 了
NodeJS 特点
为什么 JavaScript 是单线程?
- 由于 JS 这门脚本语言的用途决定的: 开发页面
- 之后出现了 Web Worker 多线程, 但它并没有改变 JS 单线程的本质
- 完全受主线程控制
- 不能操作 DOM
浏览器模型
- 用户界面: 包括地址栏、前进/后退按钮、书签菜单等
- 浏览器引擎: 在用户界面和呈现引擎之间传送指令
- 呈现引擎(渲染引擎/浏览器内核): 在线程方面又称 UI 线程
- 网络: 用于网络调用, 如 HTTP 请求
- 用户界面后端: 用于绘制基本的窗口小部件, UI 线程和 JS 线程是共用一个线程
- JS 解释器: 用于解析和执行 JS 代码
- 数据存储: 持久层, 浏览器需要在硬盘中保存各种数据, 如 Cookie
除 JS 线程和 UI 线程之外的其它线程
- 浏览器事件触发线程
- 定时触发器线程
- 异步 HTTP 请求线程
任务队列
- 所有同步任务都在主线程上执行, 形成一个执行栈
- 主线程之外, 还存在一个任务队列; 只要异步任务有了运行结果, 就在任务队列中放置一个事件
- 一旦执行栈中的所有同步任务执行完毕, 系统就会读取任务队列, 看看里面有哪些事件; 那些对应的异步任务结束等待状态, 进入执行栈, 开始执行
- 主线程不断重复上面三步
Event Loop
主线程从任务队列中读取事件, 这个过程是循环不断的, 所以整个的这种运行机制又称为 Event Loop (事件循环)注意: 只有当栈中同步任务执行完后, 才会执行 EventLoop 去异步任务队列中取
NodeJS 中的 Event Loop
V8 引擎解析 JS 脚本; 解析后的代码调用 Node API; libuv 库负责 Node API 的执行, 它将不同的任务分配给不同的线程, 形成一个 Event Loop(事件循环), 以异步的方式将任务的执行结果返回给 V8 引擎; V8 引擎再将结果返回给用户libuv 实现异步 I/O 的原理是事件「事件池 + 同步 I/O」
同步与异步 & 阻塞与非阻塞
同步和异步关注的是消息通知机制- 同步: 发出调用后, 没得到结果前, 该调用不返回, 一旦调用返回, 就得到返回值了; 简而言之就是调用者主动等待这个调用结果
- 异步: 调用者在发出调用后这个调用就直接返回了, 所以没有返回结果; 换句话说, 当一个异步过程调用发出后, 调用者不会立刻得到结果, 而是调用发出后, 被调用者通过状态、通知或回调函数处理这个调用
阻塞和非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态
- 阻塞调用: 调用结果返回之前, 当前线程会被挂起, 调用线程只有在得到结果后才会返回
- 非阻塞调用: 不能立刻得到结果之前, 该调用不会阻塞当前线程
同步异步取决于被调用者, 阻塞非阻塞取决于调用者: 同步阻塞、异步阻塞、同步非阻塞、异步非阻塞
REPL
REPL 所指的是 read eval print loop, 可交互式运行环境, 方便开发者测试 JS 代码
安装 node 后, 打开 iterm / cmd 等命令行容器中, 输入 node 即可进入 REPL 运行环境
REPL 操作
- 变量的操作, 声明普通变量和对象
- eval(计算)
- 函数的书写
- 下划线访问最近使用的表达式
- 多行书写
- REPL 运行环境中的上下文对象
具体操作可进入后输入 .help 查看
1 | /* 访问最近使用的表达式 */ |
Node 中的模块
Console 控制台
- 标准输出(1):
log
、info
- 错误输出(2):
warn
、error
- 用来统计两段代码之间的执行时间:
time(label)
&timeEnd(label)
label 要一致 - 断言:
assert
1
2
3
4
5
6// 如果表达式为 true 则什么都不发生, 如果为 false 则会报错
// nagios 监控服务器, 发生错误会报警、发通知
console.assert(1 === 1, 'ERROR')
// e.g
function sum(a, b) { return a + b }
console.assert(sum(1, 2) === 3, 'ERROR')进阶的重要标志就是写代码会有完善的测试, 包括单元测试、集成测试、持续集成、TDD 测试驱动开发、BDD 行为驱动开发
- 可列出对象结构:
dir
- 跟踪当前代码调用栈:
trace
Globals 全局对象
windows 里也有全局对象, 但是不能直接访问, 我们在浏览器里访问 global 是通过 window 实现的
- global 的变量都是全局变量
- 所有的全局变量都是 global 的属性
global 中常用的属性
console
process
当前进程- argv
- chdir: change directory 改变当前的工作目录
- memoryUsage: 内存使用量
- chdir: 切换目录
- cwd: current working directory 当前工作目录
- nextTick
- stdout stderr stdin
Buffer
clearImmediate
&clearInterval
&clearTimeout
&setImmediate
&setInterval
&setTimeout
1
2
3
4
5
6
7
8
9
10process.cwd(); // 当前目录
process.chdir('..') // 切换到上级目录
process.memoryUsage();
// V8 引擎最大使用内存量是 1.7 个G
{
rss: 22867968, // 常驻内存
heapTotal: 4648960, // 堆的总申请量
heapUsed: 2641664, // 已经使用的量
external: 828902 // 外部内存的使用量
}
nextTick & setImmediate 区别与联系
- nextTick 把回调函数放在当前执行栈的底部
- setImmediate 把回调函数放在事件队列的尾部
events 事件触发器
在 NodeJS 中用于实现各种事件处理的 event 模块中, 定义了 EventEmitter 类, 所有可能触发事件的对象都是一个继承自 EventEmitter 类的子类实例对象
方法和参数 | 描述 |
---|---|
addListener(event, listener) |
对指定事件的绑定事件动作处理函数 |
on(event, listener) |
对指定事件的绑定事件动作处理函数 |
once(event, listener) |
对指定事件指定只执行一次的事件处理函数 |
removeListener(event, listener) |
对指定事件的解除绑定事件动作处理函数 |
removeAllListener([event]) |
对指定事件的解除所有绑定事件动作处理函数 |
setMaxListeners(n) |
指定事件处理函数的最大数量, n 为整数值 |
listeners(event) |
获取指定事件的所有事件处理函数 |
emit(event, [arg1], [arg2], [...]) |
手动触发指定事件 |
1 | // let EventEmitter = require('events'); |
实现 events 中的方法
1 | function EventEmitter() { |
util 实用工具
1 | let util = require('util'); |
debugger 调试器
1 | node inspect <filename> # 调试 |
process.nextTick
1 | function Clock() { |
Module 模块
模块也是 NodeJS 中的模块之一
模块的发展历史
- 命名空间
- 闭包、自执行函数
- require.js AMD
- sea.js CMD
- node.js CommonJS
- es6 ESModule
- umd AMD + CMD + CommonJS + ESModule
JS 模块化的不足
- JS 没有模块系统, 不支持封闭的作用域和依赖管理
- 没有标准库, 没有文件系统和 I/O 流 API, 也没有包管理系统
CommonJS 规范
- 封装功能
- 封闭作用域
- 可能解决依赖问题
- 效率更高, 重构方便
NodeJS 中的 CommonJS
- 在 NodeJS 中每一个单独的文件都是一个单独的模块
- 通过 require 方法实现模块间的依赖管理
1 | /** |
1 | /** |
在 NodeJS 中通过 require 方法加载其它模块(这个加载是同步的)
为什么加载是同步的?
因为模块实现了缓存, 但第一次夹在一个模块的时候, 会缓存这个模块的 exports 对象, 以后如果再次加载这个模块的话, 则直接去缓存中取, 不需要再次加载了缓存的 key 是什么?
不同文件, 不同变量去引入相同的文件时, 都会走缓存, 因为引用的时候该文件的绝对路径, 和在哪儿引用、用什么变量引用没有关系
- 找到这个文件: dirname 取得当前模块文件所在目录的绝对路径
- 读取该文件模块的内容
- 把它封装在一个函数中立刻执行
- 执行后把模块的 module.exports 对象赋值给加载该模块的模块
module 对象中包含以下几种属性
id
: 模块 ID, 入口模块的 ID 用于为 .exports
: 导出对象, 默认是一个空对象parent
: 父模块, 此模块是由哪个模块加载的filename
: 当前模块的绝对路径loaded
: 是否加载完成children
: 此模块加载了哪些模块, 默认是一个空数组path
: 第三方模块的查找路径
require 对象中包含以下几种属性
resolve
: 只获取模块的绝对路径, 但又不会加载这个模块main
: 入口模块extensions
: 在 Node 中模块的类型有三种「JS 模块、json 模块(先找文件, 读取文件内容后 JSON.parse 转成对象返回)、node C++ 扩展二进制模块(这个属于二进制模块)」
- 当 require 加载一个模块的时候, 会先找该文件, 如果找不到, 再依次去找 .js、.json、.node
cache
: 对 require 模块的缓存
Node 模块分类
原生(内置)模块: 放在 node.exe 中
http
path
fs
utils
events
编译成二进制, 通过名称来加载, 加载速度最快
文件模块: 在硬盘的某个位置, 加载速度非常慢, 文件模块通过名称或路径来加载, 文件模块的后缀有三种
- 后缀名为
.js
的 JavaScript 脚本文件, 需要先读入内存再运行 - 后缀名为
.json
的 JSON 文件, fs 读入内存后转化为 JSON 对象 - 后缀名为
.node
的经过编译后的二进制 C/C++ 扩展模块文件, 可以直接调用一般自己写的通过相对/绝对路径来加载, 别人写的通过名称去当前目录或全局的 node_modules 下面去找
- 后缀名为
第三方模块
- 如果 require 函数只指定名称则视为从 node_modeles 下面加载文件, 这样的话可以移动模块而不需要修改引用的模块路径
- 第三方模块的查询路径包括 module.paths 和全局目录
- 全局目录
windows 如果在环境变量中设置了NODE_PATH
变量, 并将变量设置为一个有效的磁盘目录, require 在本地找不到此模块时, 会在此目录下找这个模块; UNIX 操作系统会从$HOME/.node_modules
$HOME/.node_libraries
目录下寻找 - 模块的加载策略
- 文件模块查找规则
Buffer 缓冲器
JS 没有二进制数据类型, 而在处理 TCP 和文件流的时候, 必须要处理二进制数据; NodeJS 提供了一个 Buffer 对象来提供对二进制数据的操作, 是一个表示固定内存分配的全局对象, 暂时存放输入数据数据的一段文字, 也就是要放到缓存区中的字节数需要提前确定, Buffer 好比由一个8位字节元素组成的数据, 可以有效地在 JS 中表示二进制数据
Buffer 中显示的永远输出 16 进制
定义 Buffer 的三种方式
- 通过长度定义 Buffer
1
2
3
4
5// 表示分配一个长度为6个字节的 Buffer, 默认会把所有字节设置为0
const buf1 = Buffer.alloc(6); // <Buffer 00 00 00 00 00 00>
const buf2 = Buffer.alloc(6, 2); // <Buffer 02 02 02 02 02 02>
// 分配一块没有初始化的内存
const buf3 = Buffer.allocUnsafe(6); // <Buffer 90 fe 00 03 01 00> - 通过数据定义 Buffer
1
const buf4 = Buffer.from([1, 2, 3]); // <Buffer 01 02 03>
- 字符串创建
1
2// 根据字符串来初始化 Buffer
const buf4 = Buffer.from('中国'); // <Buffer e4 b8 ad e5 9b bd>
Buffer 常用方法
buf.fill(填充值, 开始索引, 结束索引)
1
2let buf = Buffer.alloc(3); // <Buffer 00 00 00>
buf.fill(3, 1, 3); // <Buffer 01 01 00>buf.write(写入的字符串, 写入前跳过的字节数「默认0」, 写入最大字节数, 字符编码「默认 utf8」)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16let buf = Buffer.alloc(6); // <Buffer 00 00 00 00 00 00>
buf.write('中国', 0, 6, 'utf8'); // <Buffer e4 b8 ad e5 9b bd>
buf.write('中国', 3, 6, 'utf8'); // <Buffer 00 00 00 e4 b8 ad>
buf.write('中国', 0, 3, 'utf8'); // <Buffer e4 b8 ad 00 00 00>
// writeInt8(值, 索引): 向指定的索引写入一个八位的整数(也就是占用一个字节的整数)
let buf1 = Buffer.alloc(6); // <Buffer 00 00 00 00 00 00>
buf1.writeInt8(0, 0); // <Buffer 00 00 00 00 00 00>
buf1.writeInt8(16, 1); // <Buffer 00 10 00 00 00 00>
buf1.writeInt8(32, 2); // <Buffer 00 10 20 00 00 00>
// writeInt16BE(): Big Endian 高位在前
// writeInt16LE(): Little Endian 低位在前
let buf2 = Buffer.alloc(4);
buf2.writeInt16BE(256, 0); // <Buffer 01 00 00 00>
buf2.readInt16BE(0); // 256
buf2.writeInt16LE(256, 2); // <Buffer 01 00 ff 00>
buf2.readInt16LE(2); // 256buf.toString(encoding, start, end)
1
2
3// 将 Buffer 转成字符串
let buf = Buffer.from('中国');
let res = buf.toString('utf8', 3, 6); // '国'buf.slice(start, end)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 注意: slice 是浅拷贝
let buf = Buffer.alloc(6, 1);
let res = buf.slice(2, 6); // <Buffer 01 01 01 01>
let res2 = res.fill(4); // <Buffer 04 04 04 04>
console.log(res); // <Buffer 04 04 04 04>
// string_decoder: 解决截取乱码的问题
// write 就是读取 buffer 内容, 返回一个字符串
// write 时会判断是不是一个字符串, 如果是的话就输出, 不是的话则缓存在对象内部,
// 等下次 write 时会把前面缓存的字符加到第二次 write 的 buffer 上在进行判断
const { StringDecoder } = require('string_decoder');
const sd = new StringDecoder();
let buf = Buffer.from('中国');
let buf1 = buf.slice(0, 4).toString(); // '中�;
let buf2 = buf.slice(4).toString(); // <Buffer 9b bd>
console.log(sd.write(buffer.slice(0, 4))); // '中'
console.log(sd.write(buffer.slice(4))); // '国'buf.copy(target, targetStart, sourceStart, sourceEnd)
1
2
3
4
5
6
7
8
9const buf1 = Buffer.allocUnsafe(26);
const buf2 = Buffer.allocUnsafe(26).fill('!');
for (let i = 0; i < 26; i++) {
// 97 是 'a' 的十进制 ASCII 值。
buf1[i] = i + 97;
}
// 拷贝 `buf1` 中第 16 至 19 字节偏移量的数据到 `buf2` 第 8 字节偏移量开始。
buf1.copy(buf2, 8, 16, 20);
buf2.toString('ascii', 0, 25) // !!!!!!!!qrst!!!!!!!!!!!!!Buffer.concat(list, totalLength)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// 连接 buffer
let buf1 = Buffer.from('中');
let buf2 = Buffer.from('中');
Buffer.concat([buf1, buf2]); // <Buffer e4 b8 ad e4 b8 ad>
// Buffer.concat 实现
Buffer.concat = function (list, total = list.reduce((len, item) => len + item.length, 0)) {
if (list.length == 1) return list[0];
// Buffer
let result = Buffer.alloc(total);
let index = 0;
// 循环 buffer
for (let buf of list) {
// 循环 buffer 中的每个字节
for (let b of buf) {
if (index >= total) {
return result;
} else {
result[index++] = b;
}
}
}
return result;
}Buffer.isBuffer
: 判断是否是 bufferlength
: 获取字节长度(显示是字符串所代表 buffer 的长度)1
2Buffer.byteLength('中国');
buffer.length;
fs 系统文件
- 在 NodeJS 中, 使用 fs 模块来实现所有有关文件及目录的创建、写入及删除操作
- 在 fs 模块中, 所有的方法都分为同步和异步两种实现, 具有
sync
后缀的方法为同步方法, 不具有sync
后缀的方法为异步方法
整体读取 & 写入文件
1 | const fs = require('fs'); |
上述方式都是把文件当成一个整体来操作, 当文件特别大时, 大于内存的是无法执行这样的操作的, 所以需要控制读取的字节
1 | /* |
为了实现节约内存的拷贝, 需要读一点写一点
1 | const fs = require('fs'); |
目录操作 & 递归创建/删除目录
1 | const fs = require('fs'); |
递归同步删除非空目录
1 | /* |
递归删除非空文件夹
1 | // 同步 |
读取目录下所有文件
1 | const fs = require('fs'); |
遍历算法
目录是一个树状结构, 在遍历时一般使用深度优先 + 先序遍历算法; 深度优先, 意味着到达一个节点后, 首先遍历子节点而不是相邻节点; 先序遍历, 意味着首次到达了某节点就算遍历完成, 而不是最后一次返回某节点才算数
深度优先 + 先序遍历
1 | const fs = require('fs'); |
广度优先 + 先序遍历
1 | const fs = require('fs'); |
监视文件/目录
1 | // 语法: fs.watchFile(filename[, options], listener) |
path 路径
1 | const path = require('path'); |