node

259 阅读18分钟

介绍

Node.js 是一个开源与跨平台的 JavaScript 运行时环境

在浏览器外运行 V8 JavaScript 引擎(Google Chrome 的内核),利用事件驱动、非阻塞和异步输入输出模型等技术提高性能

可以理解为 Node.js 就是一个服务器端的、非阻塞式I/O的、事件驱动的JavaScript运行环境

非阻塞异步

Nodejs采用了非阻塞型I/O机制,在做I/O操作的时候不会造成任何的阻塞,当完成之后,以时间的形式通知执行操作

例如在执行了访问数据库的代码之后,将立即转而执行其后面的代码,把数据库返回结果的处理代码放在回调函数中,从而提高了程序的执行效率

事件驱动

事件驱动就是当进来一个新的请求的时,请求将会被压入一个事件队列中,然后通过一个循环来检测队列中的事件状态变化,如果检测到有状态变化的事件,那么就执行该事件对应的处理代码,一般都是回调函数

Node中的事件循环分为六个阶段。

img

在事件循环中的每个阶段都有一个队列,存储要执行的回调函数,事件循环机制会按照先进先出的方式执行他们直到队列为空。

这六个阶段都存储着异步回调函数,所以还是遵循先执行主线程同步代码,当同步代码执行完后再来轮询这六个阶段。

  1. Timers:用于存储定时器的回调函数(setlnterval,setTimeout)。

  2. Pendingcallbacks:执行与操作系统相关的回调函数,比如启动服务器端应用时监听端口操作的回调函数就在这里调用。

  3. idle,prepare:系统内部使用。(这个我们程序员不用管)

  4. Poll:存储1/O操作的回调函数队列,比如文件读写操作的回调函数。

    在这个阶段需要特别注意,如果事件队列中有回调函数,则执行它们直到清空队列 ,否则事件循环将在此阶段停留一段时间以等待新的回调函数进入。

    但是对于这个等待并不是一定的,而是取决于以下两个条件:

    • 如果setlmmediate队列(check阶段)中存在要执行的调函数。这种情况就不会等待。
    • timers队列中存在要执行的回调函数,在这种情况下也不会等待。事件循环将移至check阶段,然后移至Closingcallbacks阶段,并最终从timers阶段进入下一次循环。
  5. Check:存储setlmmediate的回调函数。

  6. Closingcallbacks:执行与关闭事件相关的回调,例如关闭数据库连接的回调函数等。

Node Api

  • 多进程,为了利用多核能力,nodejs提供了多进程能力,通常创建进程个数和CPU核数一致。
  • 文件,文件读写能力,通过fs模块实现
  • 网络,通过http模块可以创建一个web server。
  • 数据结构(buffer(二进制)、stream(I/O处理))服务端会更多涉及二进制数据(像I/O、网络数据)和流数据。需要通过相应地数据结构处理。
  • events.EventEmitter 事件触发与事件监听器功能的封装。

进程和线程

从开发者角度,可以理解为 进程和线程是操作系统提供给开发者的,让程序可以并发执行的能力

1.child_process

使用node执行一个脚本就会启动一个进程,脚本及其依赖的模块可以在process全局变量中访问进程相关信息

child_process模块用于操作子进程,比如spawn()exec()execFile()fork(),每个方法对应不同参数。可以执行shell或启动一个可执行文件或者执行js脚本

使用多进程架构的node web服务器通常使用master-worker的主从架构,即一个主进程和受主进程管理的多个子进程。

spawn()、exec()、execFile()、fork()

  1. spawn 可以使用流的方式进行进程间的通信,一点点传,可以接受大量数据;参数比较多,不方便使用;
  2. fork 用的比较多 叉子, 基于spawn; 特点就是默认就是node命令执行;
  3. execFile 执行文件;执行这个文件,会把结果收集好,一起返回来(stdout)
  4. exec 会产生命令行,性能稍低; execFile和exec最大区别就是shell:true,开启命令行

exec是基于execFile, execFile是基于spawn的,fork也是基于spawn

spawn

spawn会启动一个shell,并在shell上执行命令;spawn会在父子进程间建立IO流stdinstdoutstderr

spawn返回一个子进程的引用,通过这个引用可以监听子进程状态,并接收子进程的输入流。

const { spawn } = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});
通信

父子进程通信可以通过标准IO流传递json

// 父进程
const { spawn } = require('child_process');
 
child = spawn('node', ['./stdio-child.js']);
child.stdout.setEncoding('utf8');
// 父进程-发
child.stdin.write(JSON.stringify({
    type: 'handshake',
    payload: '你好吖'
}));
// 父进程-收
child.stdout.on('data', function (chunk) {
  let data = chunk.toString();
  let message = JSON.parse(data);
  console.log(`${message.type} ${message.payload}`);
});

// ./stdio-child.js
// 子进程-收
process.stdin.on('data', (chunk) => {
  let data = chunk.toString();
  let message = JSON.parse(data);
  switch (message.type) {
    case 'handshake':
      // 子进程-发
      process.stdout.write(JSON.stringify({
        type: 'message',
        payload: message.payload + ' : hoho'
      }));
      break;
    default:
      break;
  }
});

fork

fork支持传入一个NodeJS模块路径,而非shell命令,返回一个子进程引用,这个子进程的引用和父进程建立了一个内置的IPC通道,可以让父子进程通信。

// parent.js
var child_process = require('child_process');
var child = child_process.fork('./child.js');
child.on('message', function(m){
  console.log('message from child: ' + JSON.stringify(m));
});
child.send({from: 'parent'});
// child.js
process.on('message', function(m){
  console.log('message from parent: ' + JSON.stringify(m));
});
process.send({from: 'child'});

exec

execspawn不同,它接收一个回调作为参数,回调中会传入报错和IO流

const { exec } = require('child_process');
exec('cat ./test.txt', (error, stdout, stderr) => {
  if (error) {
    console.error(`exec error: ${error}`);
    return;
  }
  console.log(`stdout: ${stdout}`);
  console.error(`stderr: ${stderr}`);
});

execFile

execFileexec不同的是,它不会创建一个shell,而是直接执行可执行文件,因此效率比exec稍高一些,另外,它传入的第一个参数是可执行文件,第二个参数是执行可执行文件的参数。

const { execFile } = require('child_process');
execFile('cat', ['./test.txt'], (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return;
    }
    console.log(stdout);
});

2.cluster

cluster模块是child_process和net模块的组合应用。cluster模式有个限制,是每个子进程只能是node进程,使用child_process则可以更灵活地开启子进程,可以是其他类型的程序。

cluster.fork()方法是通过child_process.fork实现的,因此可以通过message实现进程间通信

// server.js
var cluster = require('cluster');
var cpuNums = require('os').cpus().length;
var http = require('http');

if(cluster.isMaster){ // cluster.isWorker、cluster.isMaster
  for(var i = 0; i < cpuNums; i++){
    cluster.fork();
  }
}
else{
  http.createServer(function(req, res){
    res.end(`response from worker ${process.pid}`);
  }).listen(3000);

  console.log(`Worker ${process.pid} started`);
}
  • net模块中的listen会进行判断,如果是在主进程中,则监听相应端口,如果实在子进程中,则只是建立IPC管道,等待父进程传递socket句柄然后进行处理。父进程接收到请求后,会将socket句柄传递给子进程,由于子进程使用父进程传递的句柄,对应同样的文件描述符,因此不会有冲突

可以看到使用cluster.fork创建了子进程,实际上cluster.fork调用了child_process.fork来创建子进程。创建好后,cluster会自动进行负载均衡。

负载均衡?

cluster支持设置负载均衡策略,有两种策略:轮询和操作系统默认策略。

可以通过设置cluster.schedulingPolicy = cluster.SCHED_RR;指定轮询策略,设置cluster.schedulingPolicy = cluster.SCHED_NONE;指定用操作系统默认策略。也可以设置环境变量NODE_CLUSTER_SCHED_POLICYrr/none来实现。

让人比较在意的是,cluster是如何解决端口冲突问题的呢?

我们看到代码中使用了http.createServer,并监听了端口8000,但实际上子进程并未监听8000,net模块的server.listen方法(http继承自net)判断在cluster子进程中不监听端口,而是创建一个socket并发送到父进程,以此将自己注册到父进程,所以只有父进程监听了端口,子进程通过socket和父进程通信,当一个请求到来后,父进程会根据轮询策略选中一个子进程,然后将请求的句柄(其实就是一个socket)通过进程通信发送给子进程,子进程拿到socket后使用这个socket和客户端通信,响应请求。

那么net中又是如何判断是否是在cluster子进程中的呢?cluster.fork对进程做了标识,因此net可以区分出来。

cluster是一个典型的master-worker架构,一个master负责管理worker,而worker才是实际工作的进程。

3.worker_threads

特点

  1. 每个线程执行一个事件循环。
  2. 每个线程运行单个 JS 引擎实例。
  3. 每个线程执行单个 Node.js 实例。

语法

  1. 创建线程

    const { Worker } = require('worker_threads');

    new Worker(__dirname + '/compute.js');

  2. 通信

    parentPort.postMessage() 发送的消息在使用 worker.on('message') 的父线程中可用。

    使用 worker.postMessage() 从父线程发送的消息在使用 parentPort.on('message') 的该线程中可用。

示例

//api.js
const Koa = require('koa');
const app = new Koa();

const { Worker } = require('worker_threads');

app.use(async (ctx) => {
    const url = ctx.request.url;
    if (url === '/') {
        ctx.body = 'hello';
    }

    if (url === '/compute') {
        const sum = await new Promise(resolve => {
            const worker = new Worker(__dirname + '/compute.js');
            //接收信息
            worker.on('message', data => {
                resolve(data);
            })

        });
        ctx.body = `${sum}`;
    }
})

app.listen(3000, () => {
    console.log('http://localhost:3000/ start')
});


//computer.js
const { parentPort } = require('worker_threads')
let sum = 0;
for (let i = 0; i < 1e20; i++) {
    sum += i;
}

//发送信息
parentPort.postMessage(sum);

4. 进程间通信

通常进程通信有几种方法:共享内存、消息队列、管道、socket、信号。

其中对于共享内存和消息队列,NodeJS并未提供原生的进程间通信支持,需要依赖第三方实现,比如通过C++shared-memory-disruptor addon插件实现共享内存的支持、通过redis、MQ实现消息队列的支持。

下面介绍在NodeJS中通过socket、管道、信号实现的进程间通信。

socket

socket是应用层与TCP/IP协议族通信的中间抽象层,是一种操作系统提供的进程间通信机制,是操作系统提供的,工作在传输层的网络操作API。

socket提供了一系列API,可以让两个进程之间实现客户端-服务端模式的通信。

通过socket实现IPC的方法可以分为两种:

  1. TCP/UDP socket,原本用于进行网络通信,实际就是两个远程进程间的通信,但两个进程既可以是远程也可以是本地,使用socket进行通信的方式就是一个进程建立server,另一个进程建立client,然后通过socket提供的能力进行通信。
  2. UNIX Domain socket,这是一套由操作系统支持的、和socket很相近的API,但用于IPC,名字虽然是UNIX,实际Linux也支持。socket 原本是为网络通讯设计的,但后来在 socket 的框架上发展出一种 IPC 机制,就是 UNIX domain socket。虽然网络 socket 也可用于同一台主机的进程间通讯(通过 loopback 地址 127.0.0.1),但是 UNIX domain socket 用于 IPC 更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC 机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。

开源的node-ipc方案就是使用了socket方案

NodeJS如何使用socket进行通信呢?答案是通过net模块实现,看下面的例子。

// server
const net = require('net');
net.createServer((stream => {
  stream.end(`hello world!\n`);
})).listen(3302, () => {
  console.log(`running ...`);
});

// client
const net = require('net');
const socket = net.createConnection({port: 3302});
socket.on('data', data => {
  console.log(data.toString());
});

UNIX Domain socket在NodeJS层面上提供的API和TCP socket类似,只是listen的是一个文件描述符,而不是端口,相应的,client连接的也是一个文件描述符(path)。

// 创建进程
const net = require('net')
const unixSocketServer = net.createServer(stream => {
  stream.on('data', data => {
    console.log(`receive data: ${data}`)
  })
});

unixSocketServer.listen('/tmp/test', () => {
  console.log('listening...');
});

// 其他进程
const net = require('net')
const socket = net.createConnection({path: '/tmp/test'})
socket.on('data', data => {
  console.log(data.toString());
});
socket.write('my name is vb');
// 输出结果
listening...

管道

管道是一种操作系统提供的进程通信方法,它是一种半双工通信,同一时间只能有一个方向的数据流。

管道本质上就是内核中的一个缓存,当进程创建一个管道后,Linux会返回两个文件描述符,一个是写入端的描述符(fd[1]),一个是输出端的描述符(fd[0]),可以通过这两个描述符往管道写入或者读取数据。

NodeJS中也是通过net模块实现管道通信,与socket区别是server listen的和client connect的都是特定格式的管道名。

管道的通信效率比较低下,一般不用它作为进程通信方案。

下面是使用net实现进程通信的示例。

var net = require('net');
var PIPE_NAME = "mypipe";
var PIPE_PATH = "\\\\.\\pipe\\" + PIPE_NAME;
var server = net.createServer(function(stream) {
     console.log('Server: on connection')
    stream.on('data', function(c) {
        console.log('Server: on data:', c.toString());
    });
    stream.on('end', function() {
        console.log('Server: on end')
        server.close();
    });
    stream.write('Take it easy!');
});

server.on('close',function(){
    console.log('Server: on close');
})

server.listen(PIPE_PATH, function(){
    console.log('Server: on listening');
})

// == Client part == //
var client = net.connect(PIPE_PATH, function() {
    console.log('Client: on connection');
})

client.on('data', function(data) {
    console.log('Client: on data:', data.toString());
    client.end('Thanks!');
});

client.on('end', function() {
    console.log('Client: on end');
})

// Server: on listening
// Client: on connection
// Server: on connection
// Client: on data: Take it easy!
// Server: on data: Thanks!
// Client: on end
// Server: on end
// Server: on close

信号

作为完整健壮的程序,需要支持常见的中断退出信号,使得程序能够正确的响应用户和正确的清理退出。

信号是操作系统杀掉进程时候给进程发送的消息,如果进程中没有监听信号并做处理,则操作系统一般会默认直接粗暴地杀死进程,如果进程监听信号,则操作系统不默认处理。

这种进程通信方式比较局限,只用在一个进程杀死另一个进程的情况。

在NodeJS中,一个进程可以杀掉另一个进程,通过制定要被杀掉的进程的id来实现:process.kill(pid, signal)/child_process.kill(pid, signal)

进程可以监听信号:

process.on('SIGINT', () => {
    console.log('ctl + c has pressed');
});

5. 进程管理

pm2与egg-cluster

child_process和 cluster模块都比较底层一些,如果要在线上部署的话,稳定才是第一要素。那么就需要额外地配置很多东西,例如日志输出,异常重启,开机自动启动等。

pm2与egg-cluster除了集群管理,在实际应用运行时候,还有很多进程管理的工作,比如:进程的启动、暂停、重启、记录当前有哪些进程、进程的后台运行、守护进程监听进程崩溃重启、终止不稳定进程(频繁崩溃重启)等等。

社区也有比较成熟的工具做进程管理,比如pm2和egg-cluster

pm2

描述:pm2是一个守护进程管理工具,它可以让我们的程序以守护进程方式运行,并且提供了很多很强大的功能来保证我们的程序稳定运行。

使用:www.cnblogs.com/zhenfeng95/…

原理:segmentfault.com/a/119000002…

pm2是一个社区很流行的NodeJS进程管理工具,直观地看,它提供了几个非常好用的能力:

  1. 后台运行。
  2. 自动重启。
  3. 集群管理,支持cluster多进程模式。

其他的功能还包括0s reload、日志管理、终端监控、开发调试等等。

pm2的大概原理是,建立一个守护进程(daemon),用来管理机器上通过pm2启动的应用。当用户通过命令行执行pm2命令对应用进行操作时候,其实是在和daemon通信,daemon接收到指令后进行相应的操作。这是一种C/S架构,命令行相当于客户端(client),守护进程daemon相当于服务器(server),这种模式和docker的运行模式相同,docker也是有一个守护进程接收命令行的指令,再执行对应的操作。

客户端和daemon通过rpc进行通信,daemon是真正的“进程管理者”。

由于有守护进程,在启动应用时候,命令行使用pm2客户端通过rpc向daemon发送信息,daemon创建进程,这样进程不是由客户端创建的,而是daemon创建的,因此客户端退出也不会受到影响,这就是pm2启动的应用可以后台运行的原因。

daemon还会监控进程的状态,崩溃会自动重启(当然频繁重启的进程被认为是不稳定的进程,存在问题,不会一直重启),这样就实现了进程的自动重启。

pm2利用NodeJS的cluster模块实现了集群能力,当配置exec_modecluster时候,pm2就会自动使用cluster创建多个进程,也就有了负载均衡的能力。

pm2的使用

PM2 是一个用于管理 Node.js 进程的工具,它可以在后台启动、守护和监控多个 Node.js 应用程序。PM2 的守护进程原理主要包括以下几个方面:

  1. 启动应用:当用户使用 PM2 启动应用时,PM2 会创建一个子进程,并将应用程序作为子进程来启动。同时,PM2 会记录该应用程序的相关信息,如 PID(进程 ID)、状态、日志等,并且会将这些信息保存到 PM2 的数据库中。
  2. 监控应用:一旦应用程序被启动,PM2 就会监控它的运行情况。如果应用程序意外退出或发生异常,PM2 将会自动重启应用程序。同时,PM2 会定期检查应用程序的资源占用情况,并且可以根据需要调整进程数、CPU 使用率等参数。
  3. 守护进程:为了确保 PM2 能够长时间稳定运行,PM2 本身也需要一个守护进程来监控其运行情况。该守护进程会定期检查 PM2 的健康状态,并且在 PM2 出现异常情况时进行相应的处理,例如重启进程、发送警告通知等。
  4. 日志管理:PM2 还提供了丰富的日志管理功能,可以将应用程序的日志导出到文件或远程服务器,并且支持实时查看、过滤等操作。这些日志信息对于排查问题、分析业务数据等都非常有用。

综上所述,PM2 的守护进程原理主要是将应用程序作为子进程启动,并在后台监控其运行情况。同时,PM2 本身也会被一个守护进程来监控和管理,以确保整个系统的稳定性和可靠性。

1. 运行方式:命令行和配置文件

使用pm2运行node程序有两种方式:命令行和配置文件

我们可以使用pm2命令来管理进程,进行开启或者停止等操作

// 运行node程序
pm2 start app.js
// 运行其他程序
pm2 start bashscript.sh
pm2 start python-app.py --watch
pm2 start binary-file -- --port 1520
// 其他操作
pm2 restart <app_name|process_id>
pm2 reload  <app_name|process_id>
pm2 stop  <app_name|process_id>
pm2 delete  <app_name|process_id>

也可以使用配置文件:ecosystem file

生成配置文件模板 pm2 ecosystem,这个命令会生成一个ecosystem.config.js:

接下来我们就可以通过配置文件操作我们的程序了

pm2 [start|restart|stop|delete] ecosystem.config.js

配置文件有两个主要字段:app和deploy

其中app中配置了程序运行相关参数,deploy配置了程序部署相关参数

2. 进程管理

pm2提供了一些api来管理进程

  • start,启动一个进程
  • stop,停止一个进程
  • delete,停止一个进程,并从pm2 list中删除
  • restart,重启一个进程
  • reload,0秒停机重载进程(主要用于网络进程),只在cluster模式下生效
  • list,获取pm2管理的运行中的进程列表

3. cluster负载均衡

使用pm2启动程序有两种模式:'cluster'和'fork',默认为fork。

两者的区别在于,cluster模式使用node的cluster模块管理进程,子进程只能是node程序,提供了端口复用和负载均衡、集群稳定的一些机制;fork模式使用node的child_process的fork方法管理子进程,子进程可以是其他程序(如php、Python),需要开发者自己实现端口分配和负载均衡的子进程业务逻辑

4. 扩展集群

// 指定子进程数量为2
pm2 scale app 2

// 增加3个子进程
pm2 scale app +3

5. 日志

pm2 logs命令用来输出pm2的日志

也可以在ecosystem.config.js中配置log_file字段来指定日志输出

6. 监控

我们可以通过pm2 monitor或者PM2.io监控应用程序,提进程运行情况的信息

pm2 webapi则会启动一个叫pm2-http-interface的web sever,监听9615端口。我们访问相应主机上的端口即可获取CPU、内存运行情况等信息

7. watch模式

--watch参数用来让pm2监控文件改动自动重启

pm2 start app.js --watch
pm2 stop app.js --watch

egg-cluster

参考文章:juejin.cn/post/684490…

egg-cluster是egg项目开源的一个进程管理工具,它的作用和pm2类似,但两者也有很大的区别,比如pm2的进程模型是master-worker,master负责管理worker,worker负责执行具体任务。egg-cluster的进程模型是master-agent-worker,其中多出来的agent有什么作用呢?

有些工作其实不需要每个 Worker 都去做,如果都做,一来是浪费资源,更重要的是可能会导致多进程间资源访问冲突

既然有了pm2,为什么egg要自己开发一个进程管理工具呢?可以参考作者的回答

www.zhihu.com/question/29…

  1. PM2 的理念跟我们不一致,它的大部分功能我们用不上,用得上的部分却又做的不够极致。
  2. PM2 是AGPL 协议的,对企业应用不友好。

pm2虽然很强大,但还不能说完美,比如pm2并不支持master-agent-worker模型,而这个是实际项目中很常见的一个需求。因此egg-cluster基于实际的场景实现了进程管理的一系列功能。

Path

const path = require('path')

// console.log(__filename)

// 1 获取路径中的基础名称 
/**
 * 01 返回的就是接收路径当中的最后一部分 
 * 02 第二个参数表示扩展名,如果说没有设置则返回完整的文件名称带后缀
 * 03 第二个参数做为后缀时,如果没有在当前路径中被匹配到,那么就会忽略
 * 04 处理目录路径的时候如果说,结尾处有路径分割符,则也会被忽略掉
 */
/* console.log(path.basename(__filename))
console.log(path.basename(__filename, '.js'))
console.log(path.basename(__filename, '.css'))
console.log(path.basename('/a/b/c'))
console.log(path.basename('/a/b/c/')) */

// 2 获取路径目录名 (路径)
/**
 * 01 返回路径中最后一个部分的上一层目录所在路径
 */
/* console.log(path.dirname(__filename))
console.log(path.dirname('/a/b/c'))
console.log(path.dirname('/a/b/c/')) */

// 3 获取路径的扩展名
/**
 * 01 返回 path路径中相应文件的后缀名
 * 02 如果 path 路径当中存在多个点,它匹配的是最后一个点,到结尾的内容
 */
/* console.log(path.extname(__filename))
console.log(path.extname('/a/b'))
console.log(path.extname('/a/b/index.html.js.css'))
console.log(path.extname('/a/b/index.html.js.'))  */

// 4 解析路径
/**
 * 01 接收一个路径,返回一个对象,包含不同的信息
 * 02 root dir base ext name
 */
// const obj = path.parse('/a/b/c/index.html')
// const obj = path.parse('/a/b/c/')
/* const obj = path.parse('./a/b/c/')
console.log(obj.name) */

// 5 序列化路径
/* const obj = path.parse('./a/b/c/')
console.log(path.format(obj)) */

// 6 判断当前路径是否为绝对
/* console.log(path.isAbsolute('foo'))
console.log(path.isAbsolute('/foo'))
console.log(path.isAbsolute('///foo'))
console.log(path.isAbsolute(''))
console.log(path.isAbsolute('.'))
console.log(path.isAbsolute('../bar')) */

// 7 拼接路径
/* console.log(path.join('a/b', 'c', 'index.html'))
console.log(path.join('/a/b', 'c', 'index.html'))
console.log(path.join('/a/b', 'c', '../', 'index.html'))
console.log(path.join('/a/b', 'c', './', 'index.html'))
console.log(path.join('/a/b', 'c', '', 'index.html'))
console.log(path.join('')) */

// 8 规范化路径
/* console.log(path.normalize(''))
console.log(path.normalize('a/b/c/d'))
console.log(path.normalize('a///b/c../d'))
console.log(path.normalize('a//\\/b/c\\/d'))
console.log(path.normalize('a//\b/c\\/d')) */

// 9 绝对路径
// console.log(path.resolve())
/**
 * resolve([from], to)
 */
// console.log(path.resolve('/a', '../b'))
console.log(path.resolve('index.html'))

fs模块

一、是什么

fs(filesystem),该模块提供本地文件的读写能力,基本上是POSIX文件操作命令的简单包装

可以说,所有与文件的操作都是通过fs核心模块实现

导入模块如下:

const fs = require('fs');

这个模块对所有文件系统操作提供异步(不具有sync 后缀)和同步(具有 sync 后缀)两种操作方式,而供开发者选择

二、文件知识

在计算机中有关于文件的知识:

  • 权限位 mode
  • 标识位 flag
  • 文件描述为 fd

权限位 mode

img

针对文件所有者、文件所属组、其他用户进行权限分配,其中类型又分成读、写和执行,具备权限位4、2、1,不具备权限为0

如在linux查看文件权限位:

drwxr-xr-x 1 PandaShen 197121 0 Jun 28 14:41 core
-rw-r--r-- 1 PandaShen 197121 293 Jun 23 17:44 index.md

在开头前十位中,d为文件夹,-为文件,后九位就代表当前用户、用户所属组和其他用户的权限位,按每三位划分,分别代表读(r)、写(w)和执行(x),- 代表没有当前位对应的权限

标识位

标识位代表着对文件的操作方式,如可读、可写、即可读又可写等等,如下表所示:

符号含义
r读取文件,如果文件不存在则抛出异常。
r+读取并写入文件,如果文件不存在则抛出异常。
rs读取并写入文件,指示操作系统绕开本地文件系统缓存。
w写入文件,文件不存在会被创建,存在则清空后写入。
wx写入文件,排它方式打开。
w+读取并写入文件,文件不存在则创建文件,存在则清空后写入。
wx+和 w+ 类似,排他方式打开。
a追加写入,文件不存在则创建文件。
ax与 a 类似,排他方式打开。
a+读取并追加写入,不存在则创建。
ax+与 a+ 类似,排他方式打开。

文件描述为 fd

操作系统会为每个打开的文件分配一个名为文件描述符的数值标识,文件操作使用这些文件描述符来识别与追踪每个特定的文件

Window系统使用了一个不同但概念类似的机制来追踪资源,为方便用户,NodeJS抽象了不同操作系统间的差异,为所有打开的文件分配了数值的文件描述符

NodeJS中,每操作一个文件,文件描述符是递增的,文件描述符一般从 3 开始,因为前面有 012三个比较特殊的描述符,分别代表 process.stdin(标准输入)、process.stdout(标准输出)和 process.stderr(错误输出)

三、方法

下面针对fs模块常用的方法进行展开:

  • 文件读取
  • 文件写入
  • 文件追加写入
  • 文件拷贝
  • 创建目录

文件信息

fs.stat('/Users/joe/test.txt', (err, stats) => {
  if (err) {
 	 	console.error(err);
  }
  console.log(stats);
// 我们可以访问文件的stats
}

文件读取

fs.readFileSync

同步读取,参数如下:

  • 第一个参数为读取文件的路径或文件描述符
  • 第二个参数为 options,默认值为 null,其中有 encoding(编码,默认为 null)和 flag(标识位,默认为 r),也可直接传入 encoding

结果为返回文件的内容

const fs = require("fs");

let buf = fs.readFileSync("1.txt");
let data = fs.readFileSync("1.txt", "utf8");

console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(data); // Hello

fs.readFile

异步读取方法 readFilereadFileSync 的前两个参数相同,最后一个参数为回调函数,函数内有两个参数 err(错误)和 data(数据),该方法没有返回值,回调函数在读取文件成功后执行

const fs = require("fs");

fs.readFile("1.txt", "utf8", (err, data) => {
   if(!err){
       console.log(data); // Hello
   }
});

fs.createReadStream

const fs = require('fs')

let rs = fs.createReadStream('test.txt', {
  flags: 'r',
  encoding: null, 
  fd: null,
  mode: 438,
  autoClose: true, 
  start: 0,
  // end: 3,
  highWaterMark: 4
})

rs.on('open', (fd) => {
  console.log(fd, '文件打开了')
})

rs.on('close', () => {
  console.log('文件关闭了')
})

let bufferArr = []
rs.on('data', (chunk) => {
  bufferArr.push(chunk)
})

rs.on('end', () => {
  console.log(Buffer.concat(bufferArr).toString())
  console.log('当数据被清空之后')
})

rs.on('error', (err) => {
  console.log('出错了')
})

文件写入

writeFileSync

同步写入,有三个参数:

  • 第一个参数为写入文件的路径或文件描述符
  • 第二个参数为写入的数据,类型为 String 或 Buffer
  • 第三个参数为 options,默认值为 null,其中有 encoding(编码,默认为 utf8)、 flag(标识位,默认为 w)和 mode(权限位,默认为 0o666),也可直接传入 encoding
const fs = require("fs");

fs.writeFileSync("2.txt", "Hello world");
let data = fs.readFileSync("2.txt", "utf8");

console.log(data); // Hello world

writeFile

异步写入,writeFilewriteFileSync 的前三个参数相同,最后一个参数为回调函数,函数内有一个参数 err(错误),回调函数在文件写入数据成功后执行

const fs = require("fs");

fs.writeFile("2.txt", "Hello world", err => {
    if (!err) {
        fs.readFile("2.txt", "utf8", (err, data) => {
            console.log(data); // Hello world
        });
    }
});

fs.createWriteStream

const fs = require('fs')

const ws = fs.createWriteStream('test.txt', {
  flags: 'w', 
  mode: 438,
  fd: null,
  encoding: "utf-8",
  start: 0,
  highWaterMark: 3
})
let buf = Buffer.from('abc')
ws.write("2")
// end 执行之后就意味着数据写入操作完成
ws.end('拉勾教育')
// error
ws.on('error', (err) => {
  console.log('出错了')
})

文件追加写入

appendFileSync

参数如下:

  • 第一个参数为写入文件的路径或文件描述符
  • 第二个参数为写入的数据,类型为 String 或 Buffer
  • 第三个参数为 options,默认值为 null,其中有 encoding(编码,默认为 utf8)、 flag(标识位,默认为 a)和 mode(权限位,默认为 0o666),也可直接传入 encoding
const fs = require("fs");

fs.appendFileSync("3.txt", " world");
let data = fs.readFileSync("3.txt", "utf8");

appendFile

异步追加写入方法 appendFileappendFileSync 的前三个参数相同,最后一个参数为回调函数,函数内有一个参数 err(错误),回调函数在文件追加写入数据成功后执行

const fs = require("fs");

fs.appendFile("3.txt", " world", err => {
    if (!err) {
        fs.readFile("3.txt", "utf8", (err, data) => {
            console.log(data); // Hello world
        });
    }
});

文件拷贝

copyFileSync

const fs = require("fs");

fs.copyFileSync("3.txt", "4.txt");
let data = fs.readFileSync("4.txt", "utf8");

console.log(data); // Hello world

copyFile

异步拷贝

const fs = require("fs");

fs.copyFile("3.txt", "4.txt", () => {
    fs.readFile("4.txt", "utf8", (err, data) => {
        console.log(data); // Hello world
    });
});

创建目录

mkdirSync

同步创建,参数为一个目录的路径,没有返回值,在创建目录的过程中,必须保证传入的路径前面的文件目录都存在,否则会抛出异常

// 假设已经有了 a 文件夹和 a 下的 b 文件夹
fs.mkdirSync("a/b/c")

mkdir

异步创建,第二个参数为回调函数

fs.mkdir("a/b/c", err => {
    if (!err) console.log("创建成功");
});

网络模块

http

Node提供了3个主要的用于网络编程的模块:net、dgram和http。

其中,net用来实现TCP数据传输;dgram用来实现UDP数据传输;http则用来实现http协议的数据传输。

每个模块都提供了API用来发送请求和接受请求并响应。

对于网络请求的发送,我们关注的API是

  • 如何发送请求
  • 如何接受响应

而对于server的实现,我们关注的API是

  • 如何监听端口
  • 如何接受请求
  • 如何返回响应

下面我们通过代码示例来看下我们关注的API

1. net

建立TCP服务

// server.js

var net = require('net');

var PORT = 3000;
var HOST = '127.0.0.1';

// tcp服务端
var server = net.createServer(function(socket) {
    console.log('客户端已连接');

    socket.on('data', function(data) {
        console.log('服务端:收到客户端数据,内容为{'+ data +'}');

        // 给客户端返回数据
        socket.write('你好,我是服务端');
    });
});

server.listen(PORT, HOST, function() {
    console.log('服务端:开始监听来自客户端的请求');
});

// client.js

const net = require('net');
const client = net.createConnection({ port: 8124 }, () => {
  //'connect' listener
  console.log('connected to server!');
  client.write('world!\r\n');
});
client.on('data', (data) => {
  console.log(data.toString());
  client.end();
});
client.on('end', () => {
  console.log('disconnected from server');
});

我们可以看到使用net模块需要调用其方法创建client或者server

client.write()用来发送请求;client.on('data')用来接收响应

server.listen用来监听端口;socket.write()用来返回响应;socket.on('data')用来接受响应;

2. dgram

TCP与UDP特点对比分析

特点TCPUDP
连接面向连接无连接
可靠传输可靠不可靠
传输效率
通信方式一对一单播、组播、广播

diagram.createSocket()用来创建一个client或者serve

client.send()用来发送请求

server.bind()用来监听端口;server.on('message')用来接受请求

2.1. dgram单播

单播,即地址为单一目标的传播方式,用于两个主机之间的端对端通信。

单播优缺点:

  • 服务器及时响应客户端请求;
  • 服务器针对每个客户端不同请求提供不同数据,提供个性化服务;
  • 由于服务器需要向网络中每一个客户端提供请求响应处理,造成服务端压力过大
import dgram from 'dgram';
// 创建指定 type 的 dgram.Socket 对象。type:udp4\udp6
const udpServer = dgram.createSocket('udp4');

/*****************dgram.Socket 事件***********************************/ 

// 在使用 close() 关闭套接字后会触发 'close' 事件。
udpServer.on('close', () => {
  console.log('server is closed')
})
// 在套接字关联到远程地址作为成功的 connect() 调用的结果之后触发
udpServer.on('connect', () => {
  console.log('有connect')
})
// 每当发生任何错误时都会触发 'error' 事件。
udpServer.on('error', (err) => {
  console.log(err)
})
udpServer.on('message', (msg,rinfo) => {
  console.log(`receive message from ${rinfo.address}:${rinfo.port}`);
  console.log(msg)
})
// 一旦 dgram.Socket 可寻址并且可以接收数据,则会触发 'listening' 事件。
udpServer.on('listening', () => {
  console.log('socked 正在监听中')
})

/*******************dgram.Socket 方法******************************/
// 绑定服务端口
udpServer.bind(8866)
// 返回包含套接字地址信息的对象。
const address = udpServer.address();
// 套接字接收缓冲区大小
const recvBufferSize = udpServer.getRecvBufferSize();
// 套接字发送缓冲区大小
const sendBufferSize = udpServer.getSendBufferSize();
// 
udpServer.send('将要发送的消息', 41235, 'localhost', (err) => {
  if (err) return;
  console.log('消息已发送')
})
// 关闭底层套接字并停止监听其上的数据。
udpServer.close()

2.2. dgram广播

广播,即地址为网络中所有目标的传播方式,广播与单播的区别就是IP不同,广播使用广播地址(根据ip地址和子网掩码进行与计算得到),将消息发送到某一广播网络上的所有目标设备;值得注意广播不会被路由设备转发。简单理解就是向广播网内所有设备喊话,需要指名端口号,不可能接收者的所有端口都接受广播内容。

广播优缺点:

  • 由于服务器不用向每个客户机单独发送数据,所以服务器流量负载比较低;
  • 无法针对每个客户的要求和时间及时提供个性化服务;

实现广播的前提,我们首先要知道广播地址,广播地址的计算:

  1. 获取本地ip地址
  2. 获取子网掩码
  3. ip地址和子网掩码做运算
  4. 地址中主机位都为1就是广播地址
udpServer.on('listening', () => {
  // 开启广播
  udpServer.setBroadcast(true);
  // 发送指定广播地址
  udpServer.send('各位注意,这是广播消息', 88124, '1.1.1.255')
})

2.3. dgram组播

UDP组播 组播,即对同一网络中的设备进行分组,只有一组内的网络设备可以收到数据,在广域网上组播的时候,其中的交换机和路由器只向需要获取数据的主机复制并转发数据。主机可以向路由器请求加入或退出某个组,网络中的路由器和交换机有选择地复制并传输数据,将数据仅仅传输给组内的主机。

组播优缺点:

  • 需要相同数据流的客户端加入相同的组共享一条数据流,节省了服务器的负载。

  • 由于组播协议是根据接受者的需要对数据流进行复制转发,所以服务端的服务总带宽不受客户接入端带宽的限制。

dgram组播 组播地址是实现组播的关键,首先我们要了解组播地址。

IANA将D类地址(224.0.0.0-239.255.255.255)分配给IP组播,用来标识一个IP组播组,由IGMP(组管理协议)协议维护组成员关系:

  • 224.0.0.0~224.0.0.255为永久组地址,地址224.0.0.0保留不做分配,其它地址供路由协议使用
  • 224.0.1.0~224.0.1.255是公用组播地址,可以用于Internet;
  • 224.0.2.0~238.255.255.255为用户可用的组播地址(临时组地址),全网范围内有效;
  • 239.0.0.0~239.255.255.255为本地管理组播地址,仅在特定的本地范围内有效。
// 组播
const multicalAddress = '224.10.10.1';
udpServer.on('listening', () => {
  // 添加组播
  udpServer.addMembership(multicalAddress)
  // 发送组播消息
  udpServer.send('各位成员好,这是组内消息', 88124, multicalAddress);
})

3. http

var http = require('http');
 
// 创建服务器
http.createServer(function (request, response) {
  // 设置响应
  response.write('Hello, Node');
  // 发送响应数据
  response.end(); 
}).listen(8080);

// 控制台会输出以下信息
console.log('Server running at http://127.0.0.1:8080/');

回调中的req和res都是Stream类型的实例,使用Stream相关的接口可以处理数据

我们web server最常用的是http模块。目前主流的Node web server 框架express和koa就是对http模块的封装。这些框架的封装主要屏蔽了请求解析的细节,比如通常我们使用的路由功能,都是框架对请求的url进行解析,然后找到使用框架的开发者注册的路由对应的响应方法调用,就实现了相应的功能

4. https

https用法与http很像

https客户端:

var https = require('https');

https.get('https://www.baidu.com', function(res){
    res.on('data', function(data){
        process.stdout.write(data);
    });
});

可以看到,https客户端用法与http类似。需要考虑的问题是,

代码执行方式是:

https.get(options, callback)

options中包括访问的url和其他配置信息信息

如果访问的网站安全证书不受信任,https模块会报错。有两种方法可以访问证书不受信任的网站

  1. options中配置忽略安全警告

  2. options中配置证书(需要提前下载)

https server端:

创建https server需要证书,执行如下:

https.createServer(options, callback)

options中配置了私钥和证书文件路径。证书和私钥可以通过购买或者使用openssl工具生成。

Stream

计算机处理数据时候,可能有不同的情况:可能拿到需要的全部数据进行处理,再输出结果;也可能拿到数据的一部分,然后处理这部分并输出结果,然后再取数据、处理、输出结果,直到处理完所有的数据。

比如,直播时候的流媒体,就是等到网络中的音视频数据传递过来时候存入buffer,然后不断播放buffer中的数据,也就是Stream。

流的概念

流(Stream),是一个数据传输手段,是端到端信息交换的一种方式。

它的独特之处在于,它不像传统的程序那样一次将一个文件读入内存,而是逐块读取数据、处理其内容然后抛出一系列事件让应用层代码处理,处理完后释放内存,循环往复。而不是将其全部保存在内存中

流可以分成三部分:sourcedestpipe

sourcedest之间有一个连接的管道pipe,它的基本语法是source.pipe(dest)sourcedest就是通过pipe连接,让数据从source流向了dest,如下图所示:

image-20240126095527314转存失败,建议直接上传图片文件

Node stream

流是一个抽象接口,被Node中很多对象实现

  • http模块的request、response

    const server = http.createServer(function (req, res) {
        const method = req.method; // 获取请求方法
        if (method === 'GET') { // get 请求
            const fileName = path.resolve(__dirname, 'data.txt');
            let stream = fs.createReadStream(fileName);
            stream.pipe(res); // 将 res 作为 stream 的 dest
        }
    });
    server.listen(8000);
    
  • process.stdin、process.stdout

  • net模块的socket

  • fs.createReadStream()

  • zlib.createDeflate()

流类型

Node中有4种基本的流类型

可读流和可写流都是单向的,比较容易理解,而双工流和转换流都是双向的

  • Readable:用来读取数据,比如 fs.createReadStream()

  • Writable:用来写数据,比如 fs.createWriteStream()

  • Duplex(双工流):可读+可写,比如 net.Socket()

  • Transform:在读写的过程中,可以对数据进行修改,比如 zlib.createDeflate()(数据压缩/解压)

    let {Transform} = require('stream')
    
    
    class MyTransform extends Transform{
      constructor() {
        super()
      }
      _transform(chunk, en, cb) {
        this.push(chunk.toString().toUpperCase())
        cb(null)
      }
    }
    
    let t = new MyTransform()
    
    t.write('a')
    
    t.on('data', (chunk) => {
      console.log(chunk.toString())
    })
    

stream的方法

常用的方法有

  • read() 读入流

  • write() 写入流

  • on() 监听事件

    所有的Stream对象都是EventEmitter的实例,常用事件有

    • data 当有数据可读时触发。
    • end 没有更多的数据可读时触发。
    • error 在接收和写入过程中发生错误时触发。
    • finish 所有数据已被写入到底层系统时触发。
  • pipe() 输出到另一个流

下面是代码示例

// read()

const fs = require('fs');
var readable = fs.createReadStream('./test.js');
readable.setEncoding('UTF8');
readable.on('data', () => {
  let chunk;
  while (null !== (chunk = readable.read(100))) {
    console.log(`接收到 ${chunk.length} 字节的数据`);
  }
});
// write()

const fs = require('fs');
const writeStram = fs.createWriteStream(./test.js);
writeStram.write('hello world');
writeStram.end();
// read and write
const fs = require('fs')

let rs = fs.createReadStream('./test.txt')
let ws = fs.createWriteStream('./test1.txt')

rs.pipe(ws)
// on()

const fs = require('fs');

const readStream = fs.createReadStream('./test.js');
const content = '';

readStream.setEncoding('utf8');

readStream.on('data', function(chunk){
	content += chunk;
});

readStream.on('end', function(chunk){
	// 文件读取完成
	console.log('文件读取完成,文件内容是 [%s]', content);
});
// pipe()

// 将input.txt内容复制到output.txt中
const fs = require('fs');

const readStream = fs.createReadStream('./input.txt');
const writeStream = fs.createWriteStream('./output.txt');

readStream.pipe(writeStream);

console.log('执行完毕');

// 压缩
const fs = require('fs');
const zlib = require('zlib');

fs.createReadStream('./input.txt')
	.pipe(zlib.createGzip())
	.pipe(fs.createWriteStream('input.txt.gz'));

console.log('执行完毕');
	
// 解压
const fs = require('fs');
const zlib = require('zlib');

fs.createReadStream('./input.txt.gz')
	.pipe(zlib.createGunzip())
	.pipe(fs.createWriteStream('input.txt'));

console.log('执行完毕');

Buffer

在Node.js中,buffers 是一个可以存储二进制数据的特殊类型。buffer 代表内存块-通常指在计算机中分配的 RAW 。buffer 的大小是不能更改的。

buffer 存储字节。八位(Bits)序列称为一个字节(byte)。位(Bits)在计算机中是最基本的存储单元,他们可以保存0 或 1的数值。

译者注:计算机是存储二进制数据的,二进制主要是 0 和 1的集合。

Node.js 在全局作用域中可直接使用 Buffer 类(不需要像其他模块一样导入)。使用这个API,你可以获取一系列函数和抽象来操作原始的二进制文件。

一个 buffer 在Node.js中就如以下表示:

<Buffer 61 2e 71 3b 65 2e 31 2f 61 2e> 

在示例中,你可以看到 10对字母和数字的组成,每一对表示存储在缓冲区中的字节,这个缓冲区的总大小为10。

你可能会问自己:“如果这些是位和字节,那 0 和 1 在哪里呢?”

那是因为Node.js使用十六进制系统显示字节。这样,每个字节都可以仅使用两位数表示(一对数字和字母是从0-9 和 "a" to "f")

为什么需要 buffers?因为在 buffers 出现之前,在JavaScript中并没有简单的方式去处理二进制数据,你必须采用类似于字符串的primitives,这种方式是比较慢的,也没有专门的工具来处理二进制文件。所以Buffers 会被创建,且提供一些简单和高效的API可以操作位和字节。

buffers使用

让我们看看使用buffers可以做的一些事情。

你会注意到使用 buffer 有点类似于 JavaScript 中使用数组的方式。例如,你可以使用.slice(),concat().length 操作buffer。 缓冲区也是可迭代的,可以使用例如for-of之类的构造器迭代。

如果你是在计算机上操作示例,记住 Buffer 类是全局的。你不需要单独的引入。

译者注: 虽然 Buffer 类在全局作用域内可用,但仍然建议通过 import 或 require 语句显式地引用它

创建 buffers

有三种方法创建Buffers。

  • Buffer.from()
  • Buffer.alloc()
  • Buffer.allocUnsafe()

buffers在以前是使用 Buffer 类构造函数(例如,new Buffer() )创建的。此语法已被弃用。

Buffer.from()

使用buffer.from()是创建buffer 的最直接方法。它可接受字符串、数组、ArrayBuffer,或也可以是另一个 buffer 实例。根据传递的参数, Buffer.from() 将以不同的方式创建缓冲区。

传入字符串时,将创建一个包含该字符串的新缓冲区对象。默认情况下,它将使用 utf-8 作为编码解析你的输入

// 使用字符串"heya!"创建一个新缓冲区
// 如果第二个参数,没有传入编码类型,将使用默认 'utf-8' 编码类型
Buffer.from("heya!");

// 创建一个和上面相同的缓冲区,但是输入十六进制编码字符串
Buffer.from("6865796121", "hex");

你还可以将字节数组传给Buffer.from()。这里,我传入跟之前相同的字符串("heya!"),但是使用十六进制的字符数组表示。

// Also writes 'heya!' to the buffer, but passes a array of bytes
Buffer.from([0x68, 0x65, 0x79, 0x61, 0x21]);

如果你不熟悉0xNN语法,则意味着 0x之后的字符应该解释为十六进制值。

将buffer实例传入 Buffer.from()时,Node.js 会复制该实例到当前的缓冲区中。由于新缓冲区会分配在不同的内存区域中,故你可以独立的修改它。

const buffer1 = Buffer.from('cars');
const buffer2 = Buffer.from(buffer1);

buffer2[0] = 0x6d;
console.log(buffer1.toString()); // --> "cars"
console.log(buffer2.toString()); // --> "mars"

这些应该覆盖了你使用 Buffer.from() 的大多数情况。详情可参考文档 docs

Buffer.alloc()

.alloc() 方法在您想要创建空缓冲区时很有用,不需要初始化数据填充。默认情况下,它接受一个数字并返回一个给定大小并且填充了 0 的缓冲区。

Buffer.alloc(6) // --> <Buffer 00 00 00 00 00 00>

你可以在之后填充你想要的任何数据。

const buff = Buffer.alloc(1);
buff[0] = 0x78;
console.log(buff.toString('utf-8')); // x

你还可以使用 0 以外的其他内容和给定的编码填充缓冲区。

Buffer.alloc(6, "X", "utf-8");

Buffer.allocUnsafe()

使用 .allocUnsafe(),可以跳过清理和用 0 填充 buffer 的过程。 buffer 将被分配在可能包含旧数据的内存区域中(这就是“Unsafe”的部分来源)。例如,以下代码很可能每次运行时都会打印一些随机数据

// 分配大小为 10000 的随机内存区域
// 不清理它(用 0 填充)所以它可能包含旧数据
const buff = Buffer.allocUnsafe(1000);

// 打印加载的随机数
console.log(buff.toString('utf-8'));

.allocUnsafe() 有一个好处的使用情况是当你复制一个被安全分配的缓冲区。由于你复制buffer 时是会完整的覆盖,所以所有旧字节数据都将被可预测的数据替换:

// Creates a buffer from a string
const buff = Buffer.from('hi, I am a safely allocated buffer');

// Creates a new empty buffer with `allocUnsafe` of the sameconst buffCopy = Buffer.allocUnsafe(buff2.length);
// length as the previous buffer. It will be initally filled with old data.
const buffCopy = Buffer.allocUnsafe(buff.length);

// Copies the original buffer into the new, unsafe buffer.
// Old data will be overwritten with the bytes
// from 'hi, I am a safely allocated buffer' string.
buff.copy(buffCopy);

console.log(buffCopy.toString());
// --> 'hi, I am a safely allocated buffer'

通常来说,.allocUnsafe()应当仅被使用在你有很好的理由使用的情况下(例如,性能优化)使用。每当使用它时,请确保永远不在没有使用新数据填充完整它的情况下返回 buffer 实例,否则你可能会泄漏敏感的信息。

写入buffers

Buffer.write()是将数据写入 buffers 的方法。 默认情况下,它将写入一个以 utf-8编码的、没有偏移(从 buffer 的第一个位置开始写入)的字符串。它会返回一个数字,是写入buffer中的字节数。

const buff = Buffer.alloc(9);

buff.write("hey there"); // 返回 9(写入的字节数)

// 如果写入的字节数超过缓冲区支持的字节数,
// 您的数据将被截断以适合缓冲区。
buff.write("hey christopher"); // retuns 9 (number of bytes written)

console.log(buff.toString());
// -> 'hey chris'

请记住,并非所有字符都会占用 buffer 中的单个字节 !

const buff = Buffer.alloc(2);

// 版权符号('©')占用两个字节,
//所以下面的操作将完全填满缓冲区
buff.write("©"); // returns 2

//如果缓冲区太小,无法存储字符,则不会写入。
const tinyBuff = Buffer.alloc(1);

tinyBuff.write("©"); // returns 0 (nothing was written)

console.log(tinyBuff);
// --> <Buffer 00> (empty buffer)

另外请注意到,2不是字符拥有最大的字节数。例如,utf-8编码类型支持最多4字节的字符。 由于无法修改缓冲区的大小,所以始终需要注意你正在编写的内容它将会占用多少空间(缓冲区的大小与内容的大小)。

另一个写入buffer的方法是通过类似于数组的语法 add方法,把字节添加到buffer的特殊位置。需要注意的是,任何超过 1 个字节的数据都需要分解并设置在buffer的每个位置

const buff = Buffer.alloc(5);

buff[0] = 0x68; // 0x68 is the letter "h"
buff[1] = 0x65; // 0x65 is the letter "e"
buff[2] = 0x6c; // 0x6c is the letter "l"
buff[3] = 0x6c; // 0x6c is the letter "l"
buff[4] = 0x6f; // 0x6f is the letter "o"

console.log(buff.toString());
// --> 'hello'

// ⚠️ 警告: 如果你尝试设置超过 2 个字节的字符到一个位置,它会失败
buff[0] = 0xc2a9; // 0xc2a9 is the symbol '©'

console.log(buff.toString());
// --> '�ello'

// 但是如果你分别写每个字节...
buff[0] = 0xc2;
buff[1] = 0xa9;

console.log(buff.toString());
// --> '©llo'

虽然你可以使用类似数组的语法写入buffers,但我建议你尽可能坚持使用 Buffer.from() 。管理输入的长度是一项艰巨的任务,并且会给你的代码带来复杂性。使用 .from(),您可以无担忧的在 buffer 中写入内容,并通过检查是否未写入任何内容(返回 0 时)来处理输入过大的情况。

迭代buffers

你可以使用类似于数组的现代JavaScript 结构去迭代 buffer。例如: 使用 for-of

const buff = Buffer.from('hello!');

for (const b of buff) {
   // `.toString(16)`返回十六进制格式内容
  console.log(b.toString(16));
}

其他的遍历方法 例如 .entries(), .values().keys()也同样也适用于 buffers,栗子:使用 .entries()

const buff = Buffer.from("hello!");
const copyBuff = Buffer.alloc(buff.length);

for (const [index, b] of buff.entries()) {
  copyBuff[index] = b;
}

console.log(copyBuff.toString());
// -> 'hello!'

走的更远: Buffers and TypeArrays

在 JavaScript(我的意思是一般的 JavaScript,不限于 Node.js)中,可以使用特殊的 ArrayBuffer 类分配内存。我们很少直接操作 ArrayBuffer 对象。相反,我们使用一组引用底层数组缓冲区的“视图”对象。这些视图对象是:

Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array等。

这些上面列出的视图对象统称为TypedArray。所有视图对象都通过原型从 TypedArray 继承方法。 TypedArray 构造函数不是全局公开的,必须始终使用其中一种视图方法。如果你看到一些使用 new TypedArray() 的教程或文档,这意味着它正在使用任何视图对象(Uint8Array、Float64Array 等)

在 Node.js 中,从 Buffer 类创建的对象也是 Uint8Array 的实例。所以BUffer是 Node.JS 中用于操作 ArrayBuffer 的视图,是 TypedArray[3] 的一种。

EventEmitter

EventEmitter基于观察者模式,是Node实现事件驱动的基础。

EventEmitter 为我们提供了事件订阅机制,通过引入 events 模块来使用它。

const EventEmitter = require('events')

const ev = new EventEmitter()

ev.on('事件1', () => {
  console.log('事件1执行了')
})

ev.on('事件1', () => {
  console.log('2222')
})

ev.emit('事件1')

常用方法:

  • emitter.addListener(eventName, listener) :添加类型为 eventName 的监听事件到事件数组尾部
  • emitter.on(eventName, listener) :添加类型为 eventName 的监听事件到事件数组尾部
  • emitter.once(eventName, listener):添加类型为 eventName 的监听事件,以后只能执行一次并删除
  • emitter.prependListener(eventName, listener):添加类型为 eventName 的监听事件到事件数组头部
  • emitter.removeListener(eventName, listener):移除类型为 eventName 的监听事件
  • emitter.off(eventName, listener):移除类型为 eventName 的监听事件
  • emitter.removeAllListeners([eventName]): 移除全部类型为 eventName 的监听事件
  • emitter.emit(eventName[, ...args]):触发类型为 eventName 的监听事件

例子

绑定:on、addListener、prependListener

eventEmitter.on("data", () => {
    console.log("data");
});

eventEmitter.addListener("data", () => {
    console.log("data");
});

使用 on 方法绑定事件时,并不会做去重检查

解绑:off、removeListener、removeAllListeners

const {EventEmitter} = require('events');
const eventEmitter = new EventEmitter();

eventEmitter.on("data", () => {
    console.log("listener1");
});

eventEmitter.off("data", listener1);
// eventEmitter.removeListener("data", listener1);

触发:once、on

使用 once 可以绑定一个只执行一次的回调函数,当触发一次之后,该回调函数便自动会被解绑

const {EventEmitter} = require('events');
const eventEmitter = new EventEmitter();

eventEmitter.once('data', () => {
    console.log("listener1");
});

eventEmitter.on('data', () => {
    console.log("listener1");
});

eventEmitter.emit('data')

原理

class EventEmitter {
  constructor() {
      this.events = {};
  }

  on(type, handler) {
      if (!this.events[type]) {
          this.events[type] = [];
      }
      this.events[type].push(handler);
  }

  addListener(type,handler){
      this.on(type,handler)
  }

  prependListener(type, handler) {
      if (!this.events[type]) {
          this.events[type] = [];
      }
      this.events[type].unshift(handler);
  }

  removeListener(type, handler) {
      if (!this.events[type]) {
          return;
      }
      this.events[type] = this.events[type].filter(item => item !== handler);
  }

  off(type,handler){
      this.removeListener(type,handler)
  }

  emit(type, ...args) {
      this.events[type].forEach((item) => {
          Reflect.apply(item, this, args);
      });
  }

  once(type, handler) {
      this.on(type, this._onceWrap(type, handler, this));
  }

  _onceWrap(type, handler) {
      let fired;
      return function foo(...args) {
        if (!fired) {
            fired = true;
            Reflect.apply(handler, this, args);
            this.off(type, foo);
        };
      }
  }
}

VM模块

沙箱,即sandbox,顾名思义,就是让你的程序跑在一个隔离的环境下,不对外界的其他程序造成影响,通过创建类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响。

例如:在服务区中使用docker创建一个独立的应用容器;与我们最相近的就是我们的浏览器窗口,每个浏览器窗口其实也是一个沙箱。

使用场景

  1. 实现JS在线编辑器:可以把用户输入的代码放到沙箱中运行,以免用户输入的信息影响页面的运行。

  2. 服务端渲染:例如在vue服务端渲染时,服务端会利用node中的vm创建一个沙箱,将前端的bundle代码放入沙箱中运行,以免影响node服务的正常运行。

作用:创建一个相对独立的环境用来运行不可信的代码或程序,以防这些程序污染到全局。

如何实现沙箱

以前我在看jquery源码时会看到作者创建了一个立即执行函数以防内部的变量污染全局并对外暴露$,这其实就是一个简易的沙箱,但它不安全,仅仅只是一个作用域沙箱,并不是一个独立的运行环境,他依然能够访问外部的全局变量。

(function(window) {
  window.$ = ....
})(window)

利用iframe实现沙箱

iframe实际上就是一个封闭的沙箱环境,用户可以在页面中使用iframe内嵌页面

<iframe src="..."  />

虽然iframe使用方便,功能强大,但是也存在一些缺点:

  1. URL不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

Noded 的 Vm

node中原生实现了一个vm模块,利用他vm中的createContextrunInContext可以创建一个执行的上下文:

const vm = require('vm');
const code = `var c = a + ' ' + b;`;
const context = { a: 'hello', b: 'world', }
vm.createContext(context);
vm.runInContext(code, context);
console.log(context.c); // hello world