理解Node.js 的重要概念

  Node.js是什么

  Node.js是JavaScript的运行时(runtime),终于脱离了浏览器也能运行JavasScript了。同时,Node.js又暴露fs,http等对象给JS,使JS能够进行文件读写,创建服务器等。Node.js既能运行JS,又赋于了JS更多功能(文件读写,创建服务器等),使得JS语言更加通用了。Node.js是怎么做到的?它内嵌了V8,可以编译和运行JS,也能把JS数据类型转换成C++数据类型,同时又提供了C++ bind,可以使用JS调用C++,调用libuv(异步I/O)等C++库,最后提供l了事件循环,替换了V8的默认的事件循环(V8的事件循环是插拔式的),事件循序就是一个C程序,和浏览器环境一样了。看一下Node.js暴露的fs模块,Node.js项目下的lib文件夹就是Node.js暴露给JS的所有对象,fs.js就是fs对象,文件开头

const binding = internalBinding('fs');
const { FSReqCallback, statValues } = binding;

  internalBinding就是C++ binding。internalBinding('fs'); 就是调用和加载C++实现的fs模块(src文件夹中node_file.cc文件)。看一个readFile方法最后部分

  const req = new FSReqCallback();
  req.context = context;
  req.oncomplete = readFileAfterOpen;
  binding.open(pathModule.toNamespacedPath(path),
               flagsNumber,
               0o666,
               req);
}

  new FSReqCallback() 和binding.open,JS怎么直接使用C++中的函数? 把C++函数放到内置模块的exports上和 FunctionTemplate。binding.open 就是通过exports实现的,在node_file.cc最后, NODE_BINDING_PER_ISOLATE_INIT(fs, node::fs::CreatePerIsolateProperties), CreatePerIsolateProperties方法SetMethod(target, "open", Open), setMethod设置了open 并绑定C++的open方法,binding.open相当于调用了C++ 的Open方法,还有一个问题,调用binding.open方法时,传递过来参数是JS中定义数据类型,C++ 怎么认识呢?C++ 确实不认识JS中的数据类型,需要转换。使用V8进行转换, 文件的开始部分

using v8::Array;
using v8::BigInt;
using v8::Boolean;

  new FSReqCallback()是通过V8的FunctionTemplate,V8 template可以动态生成JS中没有函数, 供JS使用。还是node_fille.cc,

 // Create FunctionTemplate for FSReqCallback
  Local<FunctionTemplate> fst = NewFunctionTemplate(isolate, NewFSReqCallback);
  fst->InstanceTemplate()->SetInternalFieldCount(
      FSReqBase::kInternalFieldCount);
  fst->Inherit(AsyncWrap::GetConstructorTemplate(isolate_data));
  SetConstructorFunction(isolate, target, "FSReqCallback", fst);

  FSReqCallback模版调用NewFSReqCallback, NewFSReqCallback又调用了FSReqCallback,

void FSReqCallback::Reject(Local<Value> reject) {
  MakeCallback(env()->oncomplete_string(), 1, &reject);
}

void FSReqCallback::Resolve(Local<Value> value) {
  Local<Value> argv[2] {
    Null(env()->isolate()),
    value
  };
  MakeCallback(env()->oncomplete_string(),
               value->IsUndefined() ? 1 : arraysize(argv),
               argv);
}

  FSReqCallback模版创建一个对象,有Resolve和Reject方法,同时oncomplete赋上回调函数readFileAfterOpen。看回bind.open使用的C++open方法,

if (argc > 3) {  // open(path, flags, mode, req)
    FSReqBase* req_wrap_async = GetReqWrap(args, 3);AsyncCall(env, req_wrap_async, args, "open", UTF8, AfterInteger,
              uv_fs_open, *path, flags, mode);

  AsyncCall就是调用libuv的open方法,它是异步的,完成后,就把AfterInteger放到事件队列中,事件循环取出执行AfterInteger。node程序执行时,主程序执行完以后,就会启动事件循环,

void AfterInteger(uv_fs_t* req) {
  FSReqBase* req_wrap = FSReqBase::from_req(req);

  if (after.Proceed())
    req_wrap->Resolve(Integer::New(req_wrap->env()->isolate(), result));
}

  req_wrap就是FSReqCallback对象,调用了它的Resove方法,MakeCallback第一个参数env()->oncomplete_string() 就是oncomplete, 最终执行了在fs.js中绑定的JS的回调函数(req.oncomplete = readFileAfterOpen;), 当然JS的执行还是调用V8来执行。

  事件循环

  Node.js的事件循环并不是循环一个队列 ,而是循环多个队列,不同类型的事件放到不同的队列中。libuv中提供了time,poll,check和close callbak等队列,Node.js本身提供nextTick队列和microtasks队列。timer队列放的是过期的setTimeout, setInterval的回调函数,poll队列放的是I/O相关的回调函数,check队列放的是setImmediate注册的回调函数。close队列放的是close事件的回调函数,比如socket.on('close', callback); callback就放到这个队列中。nextTick队列就是process.nexttick的回调函数和microtasks队列是promise回调函数。

  node index.js,index.js开始执行(同步代码执行),执行完成后,Node.js就会检查nextTick队列和microtasks队列,nextTick队列的优先级比microtasks队列优先高,先把nextTick队列中的所有事件都执行完,再执行microtasks队列中的所有事件,如果microtasks队列中的事件又调用了process.nextTick, 等microtasks队列中的所有事件都执行完毕,Node.js又会返回执行nextTick队列中的事件,直到nextTick和microtasks队列中所有事件都执行完,再看在index.js执行的过程中有没有添加事件到libuv队列中,就是看有没有active handle 或 active request。如果没有,就不会开启事件循环,如果有,就开启事件循环,事件循环按照 timer -> poll -> check -> close callbacks 的顺序循环,到timer和check阶段时,每执行完队列中的一个事件,就会检查nextTick队列和microtask队列,而poll和check阶段,是把该队列中的所有事件都执行完,再检查nextTick队列和microtask队列。都是先检查process.nextick队列,再检查microtask队列,只有next tick队列中的所有事件都执行完毕,才会执行microtask队列中的事件,process.nextick队列的优先级比microtaks队列的高。循环完一遍,再看有没有active handle或active request,如果没有,Node.js程序退出,如果有,接着按timer -> poll -> check -> close callbacks 的顺序循环,只要有active handle或active request, Node.js程序就永远执行。handle代表长期存在的对象,例如计时器,TCP/UDP 套接字,requests表示短暂的操作,例如读取或写入文件或建立网络连接。

const fs = require("fs");

Promise.resolve().then(() => console.log('promise1 resolved'));
Promise.resolve().then(() => {
    console.log('promise2 resolved');
    process.nextTick(() => console.log('next tick inside promise'));
});

process.nextTick(() => console.log('next tick1'));

setImmediate(() => console.log('set immedaite1'));

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
      console.log('setTimeout1 Promise');
  })
}, 0)

setTimeout(() => console.log('setTimeout 2'), 0);

fs.readFile(__filename, () => {
  console.log("readFile");
});


for (let i = 0; i < 2000000000; i++) {}

   执行结果如下

/*
next tick1
promise1 resolved
promise2 resolved
// microtasks队列添加next tick inside promise到nexttick队列。microtasks所有事件完成后,node再一次执行nextTick队列中的事件。执行完microtasks和nextTick的所有事件之后,事件循环移动到timer阶段
next tick inside promise


setTimeout 1
// setTimeout 1把setTimeout1 Promise放到micorTask队列,此时timer队列一个事件也执行完了,就检查micorTask队列,正好执行setTimeout1 Promise,再回到timer队列继续执行,setTimeout2执行了。
setTimeout1 Promise
set timeout2

// 这个有点奇怪,不应该是先(readFile),再(set immedaite1)吗?这是因为io队列的放置方式有点特别,是在I/O polling 阶段放置的。
set immedaite1
readFile
*/

  当事件循环到poll阶段,队列中有I/O事件,它会把队列中的所有I/O事件都执行完毕,但第一次进来的时候,I/O队列是空,就要到I/O  polling,  I/O polling 会检查每一个I/O有没有完成,比如readFile有没有完成,在上面的程序中,它肯定是完成了,就把readFile放到I/O队列中,到底I/O polling 要polling多长时间?如果没有active handle 和active request,就不polling了,如果有close事件,也不polling了,但如果有active handle 和active request,但没有close事件呢?这要看Timer, setImmediate 有没有?如果有setImmediate,它也不会polling,如果没有,再看timer有过期的事件要处理,如果有,它也不会polling,如果没有,node会计算,到过期时间的间隔,然后等待这个间隔,如果没有setimeout和setimmediate,它会一直在这里polling,polling也就解释了服务器程序一直不停止的原因。在上面的程序中,有setImmediate,所有先执行了set Immediate 1,  再一次循环中,执行readFile。

  注意一个seTimeout 设为0,

setTimeout(function() {
    console.log('setTimeout')
}, 0);
setImmediate(function() {
    console.log('setImmediate')
});

  以上程序的输出结果并不能被保证。node.js内部最小的timeout是1ms,即使写了0,node.js也会把它变成1ms。当用setTimeout或setInterval 添加一个定时器时,Node.js会把定时器和相应的回调函数放到定时器堆中。每一次事件循环执行到timer阶段,都会调用系统时间,到堆中看看有没有定时器过期,如果有,就会把对应的回调函数,放到队列中。获到系统时间可能需要小于1ms,也可能大于1ms的时间。如果时间少于1ms,timer 并不过期,事件循环就会到setImmediate,如果获取时间大于1ms,过期了,它就会执行setTimeout的回调函数。怎样计算过期时间?setTimeout 返回一个timeOut对象,它有两个属性,一个是_idleTimeout, 就是setTimeout第二个参数 0ms, 一个是_idleStart:它是Node程序启动后,执行setTimeout语句时创建的时间。只要执行到timer阶段,拿获取到的系统时间减去这两个时间,就会计算出有没有过期。

  异步I/O

  Node.js所有I/O是异步的(libuv库提供的),但实现方式却不相同,这是因为底层的操作系统并不完全支持异步I/O。网络I/O,Linux提供epoll,Mac提供了kqueue,windows提供了IOCP( IOCP (Input Output Completion Port)),它们原生支持异步,但文件I/O不行,Linux并不支持完全异步的文件读取,只能使用线程池。只要I/O不能通过原生异步(epoll/kqueue/IOCP)来解决,就使用线程池.  Node.js会尽最大可能地使用原生异步I/O, 对于那些阻塞的或非常复杂才能解决的I/O类型,它使用线程池。高CPU消耗的功能也是使用线程池,比如压缩,加密。node.js 默认开启4个线程

   看一下下面的代码,https网络I/O,crytpo(加密)高CPU消耗,fs 文件I/O

const https = require('https');
const crypto = require('crypto');
const fs =  require('fs');
const start = Date.now();

function doRequest() {
    https.request('https://www.baidu.com/', res => {
        res.on('data', () => {})
        res.on('end', () => {console.log('http:', Date.now() - start)})
    }).end();
}

function doPbkdf() {
    crypto.pbkdf2('a', 'b', 100000, 512, 'sha512', () => {console.log('pbkdf', Date.now() - start)})
}

doRequest();

fs.readFile(__filename, () => { console.log('fs:', Date.now() - start);})

doPbkdf();
doPbkdf();
doPbkdf();
doPbkdf();

  执行结果是

http: 113
pbkdf 416
fs: 417
pbkdf 424
pbkdf 430
pbkdf 451

  https是网络I/O,不走线程池,它调用epoll就返回了,等操作系统返回结果,它执行多少时间,就是多少时间,和其它函数没有关系。但Pbkdf和fs需要创建线程,由于默认4个线程,有一个pbkdf函数在等待中。

  由于fs.readFile是JS层面的内容,分为好几个步骤来完成的,比如先open文件,再read文件。open后就把open的回调函数(read)放到循环队列中,fs占用的线程就释放了,所以它不会一直占用线程,libuv把这个线程给了#4,加密一直占用一个线程,当事件循环执行到read时,没有线程了,FS线程只能等待,

  只用当一个pdkb2完成,才能有空余的线程,才能分给FS,所以结果中有一个hash比fs先返回了,现在4个线程4个任务,等待操作系统皇返回结果就可以了。libuv也会调度线程,用户态线程的调度。可以改变线程的个数,在文件的开头process.env.UV_THREADPOOL_SIZE = 5; 这时5个任务,5个线程,libuv不用调度线程,哪个执行完,哪个先返回。再次执行

fs: 14
http: 138
hash 416
hash 427
hash 444
hash 462

  EventEmitter(事件发射器)

  EventEmitter和events模块是纯JS层面的内容,它和事件循环没有关系,和底层的libuv也没有关系,它只是一种设计模式,事件驱动的编程方式。一个对象拥有emit和on方法,on方法就所有事件函数保存起来,当调用对象的emit,就把保存的函数,都执行一遍,什么都没有涉及,只是js层面的东西,js主线程执行,

const EventEmitter = require('node:events')
const em = new EventEmitter();

em.on('myEvent', data => console.log(data));
console.log('Statement A');
em.emit('myEvent', 'Statement B');
console.log('Statement C')

  输出Statement A, Statement B, Statement C, 程序同步执行,当emit事件时,所有监听这个事件的回调函数都执行一遍,再执行后面的代码。我们也可以实现一个EventEmitter。

class EventEmitter {
    listeners = {};  // key-value pair

    on(event, fn) {
        this.listeners[event] = this.listeners[event] || [];
        this.listeners[event].push(fn);
        return this;
    }


    emit(eventName, ...args) {
        let fns = this.listeners[eventName];
        if (!fns) return false;
        fns.forEach((f) => {
            f(...args);
        });
        return true;
    }
}

  server.on('request', () => {}), fs.on("data", () => {}) 怎么解释?当server收到请求或fs读取到数据后,会把包含server.emit('request')或fs.emit(data) 的回调函数放到事件队列中,EventLoop循环到它,就在V8中执行了emit方法,也就执行了监听事件的回调函数。

  Buffer

  Node.js能够读取文件,能够创建服务器处理HTTP请求,它就要处理二进制数据,因为收到的都是二进制数据,最基本的就是怎么把收到的二进制数据存起来?这就是Buffer的由来,创建Buffer来存储二进制数据。由于二进制数据都是一个字节一个字节进行分割的,buffer的创建也是字节为单位,想保存多少个字节,就创建多大的buffer。Buffer.alloc(5);就创建了5个字节大小的buffer,由于程序是在内存中运行,buffer就是在内存中开辟了5个字节大小的空间,拿到Buffer.alloc的返回值,就可以操作这个buffer了。由于内存空间的分配是连续的,可以把buffer看成一个数组,数组中的每一个元素都是一个字节大小,只能存储00000000-11111111之间,就是0-255之间的数字。

const firstBuffer = Buffer.alloc(5); // 默认buffer中所有元素都是0
firstBuffer[0] = 1; // 只能赋值数字
firstBuffer[1] = 2;
   除了alloc创建buffer,还可以使用Buffer.from。Buffer.from能够从任何对象(如字符串、已有buffer、数组和 ArrayBuffer())创建新的buffer,它的参数就是这些对象。如果使用字符串创建buffer,默认情况下,按照utf-8 编码,把字符串先转化成二进制,再存储buffer 中。
const firstBuffer = Buffer.from('abc');
const secondbuffer = Buffer.from('你');
// <Buffer 61 62 63> <Buffer e4 bd a0>
// 在UTF-8下,一个英文占一个字符,一个汉字占3个字节,
console.log(firstBuffer, secondbuffer);

  读取buffer,可以使用数组索引一个字节一个字节地读取,也可以使用toString()和toJSON()把buffer看成一个整体进行读取。toJSON() 返回buffer中每一个字节所对应的整数表现形式, 以十进制进行输出

const stringBuffer = Buffer.from('abc');
console.log(stringBuffer[0])

// { type: 'Buffer', data: [ 97, 98, 99 ] } 
// 为了和其它JSON对象进行区分, 永远有一个type:'Buffer'属性,data属性才是整个buffer数据的整数展示形式
console.log(stringBuffer.toJSON()) 

  toString() 就是把buffer中的二进制数字转成字符串,默认按照utf-8 进行转化。

const stringBuffer = Buffer.from('abc');
console.log(stringBuffer.toString()); //'abc'

  这里一定要注意字符串转化成buffer的时候用的什么编码,调用toString转换回来的时候,就要用什么编码转换。Buffer.from('abc') 创建的buffer在内存中表现形式可以描述为 [01100001, 01100010, 01100011]。 当看到一个buffer的时候,应该看到的是0101... 的排列组合,它本身已经无法表示什么意义了。它具体什么意义,就看我们怎么读取了。如果按utf-16进行读取

console.log(stringBuffer.toString('utf16le')); //

  toString('utf16le') 就是按照utf-16编码来读取该buffer。utf-16,每一个字符都占两个字节,所以它就读取buffer的前两个字节:01100001 01100010, utf16le对字节进行操作的时候调换了一个顺序,01100001 01100010 转化成了01100010 01100001,  01100010 01100001 对应的十进制是25185,25185对应的unicode字符是'扡'。读完前两个字节,再往下读,还剩下一个字节, 因为不够两个字节,所以就不操作了。

  修改buffer的值,可以使用数组索引的方式,和赋值一样,也可以用write() 方法,它接受一个字符串,用于替换原buffer的内容。

const stringBuffer = Buffer.from('abc');
stringBuffer.write('ABC');
// 如果写的内容多于原buffer的大小, 多余的内容会被忽略
stringBuffer.write('ABCDEF');
console.log(stringBuffer.toString()); // 'ABC',’DEF‘ 被忽略了
// 如果写入的内容少于原buffer的内容, 写入几个,替换几个
stringBuffer.write('X');
console.log(stringBuffer.toString()); // 'Xbc' 只替换掉了原buffer中的第一个字符,剩余的内容不会动

  除了write()方法,还可以用copy()方法。source.copy(target). 把source中的内容复制到target。只要哪个buffer中有想要的内容,就调用copy(),传给它需要这些内容的buffer。

const uppcaseBuffer = Buffer.from('ABC');
const lowercaseBuffer = Buffer.from('abc');
uppcaseBuffer.copy(lowercaseBuffer)
console.log(lowercaseBuffer.toString()); // 'ABC' 

  如果只想替换某一部分内容,只把’A‘ 复制过来替换’a‘,那就给copy()多传几个参数 。source.copy(target, targetStart, sourceStart, sourceEnd)。targetStart: 目的地buffer的起始位置,把复制过来的内容从哪个位置开始替换target中的内容。sourceStart, sourceEnd, 就是表示要复制sourceStart 到sourceEnd 之间的内容。

const uppcaseBuffer = Buffer.from('ABC');
const lowercaseBuffer = Buffer.from('abc');
uppcaseBuffer.copy(lowercaseBuffer, 0, 0, 1)
console.log(lowercaseBuffer.toString()); // 'Abc' 

  File System 文件系统

  文件就是0101的二进制序列,不论是文本文件,还是音视频文件,图像文件,在计算机中的都是0101......,没什么区别,真正的区别在于怎么解析这些二进制文件。不可能用记事本打开图片和视频文件,只能使用画图和视频软件来打开图片和视频。程序也是一样,要用正确的库来操作对应的文件。操作系统默认支持两种文件,一个是文本文件,一个是可执行文件,想要支持其他文件的操作,就要使用第三方库,比如,操作视频用ffmpeg。不论是库还是图形化界面安装的软件,它们本质上是能对0101......的二进制进行正确的解析。

  在Node.js中,有三种不同的方式来操作文件,promise的方式,callback方式和同步的方式。通常情况下,使用promise的方式,考虑性能的时候使用callback,官方说callback比promise快。只有在某些特定的情况下,使用同步方式,比如启动服务器的时候读取配置文件,因为它只读取一次。

const fs = require('node:fs'); // node: 是node命名空间,node下面的什么模块
// 异步callback 方式,回调函数都是error first的形式
fs.writeFile('./data.txt', '你好', err => {
    if (err) { console.error(err); }
    console.log('写入成功');
})

//异步promise的形式
const fsp = require('node:fs/promises')
fsp.writeFile('./text.txt', '你好')
    .then(() => {
        fsp.readFile('./data.txt')
            .then(data => { console.log(data.toString());})
    })
    .catch(err => { console.log(err); })

// 同步方式
fs.writeFileSync('./text.txt', "你好")
fs.readFileSync('./text.txt')

  writeFile()向文件写数据时,如果没有文件,就创建文件,如果有文件,就把文件内容清空,再从头开始写。想在文件后面追加内容,就要提供option参数, 包含 {encoding, mode, flag}的一个对象,encoding: 指定用什么编码写入,文件中永远都是二进制数据,提供的参数是字符串,就要把字符串转换成二进制数据,默认UTF-8; mode: 文件的权限模式,默认是可读,可写。flag 表示用什么模式去写,默为是'w', 如果想追加内容,使用 'a' 就可以了。readFile把读取的二进制数据转换成字符串时,用什么编码写入的,就要用什么编码转换。

  readFile()和writeFile()简单好用,但不适合大文件操作,因为它们是把文件一次性读取到内存中,再一次性写入到目的地,内存占用过大。为了粒度更小的操作文件,有了fs.open(),fs.read(), fs.write() 和fs.close()。打开文件,读或写,关闭文件。打开文件有一个参数是打开方式,只读,只写,还是既能读,又能写,打开成功返回文件描述符(打开的文件的引用),拿到它,就能操作文件,进行读写

const fs = require('node:fs');
const buffer = Buffer.alloc(3);

fs.open('./data.txt',  'r' , (err, fd) => { // r, 以只读方式打开文件
    // 第二个参数buffer:读到的数据放到buffer中,第三个参数 0,表示从buffer的第0处开始放,buffer类数组
    // 第四个参数buffer.length是读取多少个字节, 通常是把整个buffer都放满,所以是buffer.length
    // 第五个参数 0是从源文件(data.txt)的哪个位置开始读,第一次读,肯定是从0开始
    // 回调函数中readBytes从源文件中读取到多少个字节,data读取到的数据 buffer类型
    fs.read(fd, buffer, 0, buffer.length, 0, (err, readBytes, data) => {
        console.log(readBytes); //读取了3个字节
        console.log(data.toString()); //
    })
})

  buffer是3个字节大小,所以只读了一个字。为了读取完整个文件, 在读取完第一个字后,要读第二个字,也就是在回调函数中,再调用fs.read(), 不要注意参数,它不再是从源文件的0字节处开始读,而是从3字节处开始读取,就是已经读取buffer要进行累加。 fs.read的回调函数中调fs.read, 自己调用自己,递归了,所以要把fs.read 抽成 一个函数。递归肯定有结束条件。如果在fs.read()的回调函数中readbytes是0,文件没有内容可读了,就结束读取,并关闭文件

const fs = require('node:fs');
const buffer = Buffer.alloc(3);
// 从源文件的哪里开始读。
let readBegin = 0;

function myReadFile(fd) {
    fs.read(fd, buffer, 0, buffer.length, readBegin, (err, readBytes, data) => {
        // 文件内容最终会读完,所以最后调用fs.read读取到的字节数readBytes是0.
        if (readBytes === 0) {
            fs.close(fd);
            return;
        }
        console.log(data.toString());
        readBegin += readBytes; // 累加读取到的数据,下一次读取的时候从该位置读取

        myReadFile(fd);
    })
}

fs.open('./data.txt', 'r', (err, fd) => {
    myReadFile(fd);
})

  fs.write() 需要fs.open() 以写'w' 的方式打开文件

const fs = require('node:fs');
const buffer = Buffer.from("你好");
fs.open('./anoterdata.txt', 'w', (err, fd) => {
    // 第二个参数buffer是要写入的内容, 可以全部写入,也可以拿出一部分写入,这就是第三个和第四个参数。
    // 第三个参数是从buffer中的哪个位置开始读取,第四个参数是要从buffer读取多少个字节,一般是全部读取,0, buffer.length
    // 第五个参数是从目的地文件的哪个位置开始写入
    // 回调函数,也是三个参数(err, bytesWritten, buffer),error,表示写入成功或失败
    fs.write(fd, buffer, 0, buffer.length, 0, (err, bytesWritten, buffer) => {
        console.log("写入成功");
        fs.close(fd);
    })
})

  和fs.read()方法一样,如果读取的要写入的内容过长,还是要递归,

const fs = require('fs');
const data = Buffer.from("你好啊");

let begin = 0; // 从哪个位置开始写入,从data的哪个位置开始读取
const size = 3; // 一次写入多少个节字

function write(fd) {
    if (begin >= data.length) {
        console.log("写入成功");
        fs.close(fd);
        return;
    }

    fs.write(fd, data, begin, size, begin, err => {
        begin += size;
        write(fd);
    })
}

fs.open('./anoterdata.txt', 'w', (err, fd) => {
    write(fd);
})

  以r+打开文件,可以对文件进行读写,如果打开文件不存在,会报错。创建一个text.text文件,内容写abcdef. 文件可以进行读写,就分为两种情况下,先读再写,先写再读。打开文件的时候,有一个offset,默认它是0,就是游标在文件开头。先读再写的时候,先读把文件内容读完,游标到了文件末尾,再写,那就相当于追加内容。

const fs = require('fs');

fs.open('./text.txt','r+', (err,fd) => {
  fs.read(fd, (err, bytes, buffer) => {
    console.log(buffer.toString())

    fs.write(fd, 'write this line', (err,bytes,string) => {
      console.log(string)
     })
   })
})

  先写再读,由于在默认情况下,打开文件,游标在文件开头,先写就意味着覆盖式写入,实际写多少内容,就覆盖多少内容。写入完成后,游标停留在写完的位置而不是文件的末尾,此时再进行读取,可以读出未被新内容覆盖的文档内容,如果文件内容都覆盖完了,那什么就读不出来了。

fs.open('./text.txt', 'r+', (err, fd) => {

  fs.write(fd, 'write this line', (err, bytes, string) => {
    fs.read(fd, (err, bytes, buffer) => {
      console.log(buffer.toString())
    })
  })
})

  以w+打开文件,也可以对文件进行读写,但w+时,没有文件会创建新文件,如果有文件,则会清空文件,r+ 则不会清空文件,所以对于w+来说,打开文件时,文件永远都是空,先读再写没有意义,只有先写再读,但先写之后,游标到了文件末尾,需要把游标放到文件开头,才能读取到刚写的内容。fs.read的第二个参数,可以接受position参数,

const fs = require('fs');
fs.open('./text.txt', 'w+', (err, fd) => {
  fs.write(fd, 'write this line', (err, bytes, string) => {
    fs.read(fd, { position: 0 }, (err, bytes, buffer) => {
      console.log(buffer.toString())
    })
  })
})

  fs.read()和fs.write()方法又太细节了,于是有了用流来操作文件,读文件创建可读流,写文件创建可写流。可读流pipe可写流,就可以实现文件的复制

const fs = require('node:fs');
const readStream = fs.createReadStream('./data.text');
const writeStream = fs.createWriteStream('./anoterdata.txt');

readStream.pipe(writeStream)

  fs 使用相对路径读取文件时,相对路径相对的是启动node 进程时所在的路径。打开命令行窗口,当在某个路径下,启动node 程序,shell 就会把当成路径传递给node程序,fs相对路径,就是相对的传递过来的路径,比如 当前shell在用户目录下,node path/app/index.js 启动node程序,fs相对的路径就是 path/app, 可以在程序中,使用process.cwd() 来获取传递过来的路径,所以fs 读取建议使用绝对路径,__dirname配合path.join。

  简单说一下process和path对象。node index.js会创建一个进程,process对象就代表这个进程。因此,可以通过process对象获取到进程执行相关的信息,比如环境变量,程序执行时的参数,标准输入,输入流等。process.env获取整个环境信息,比如用户名,PATH环境变量。process.env[变量名],获取某个环境变量,在linux终端 NODE_ENV=prod node index.js, process.env.NODE_ENV就能获取到prod。process.argv获取命令行参数。process.exit()退出程序。path操作文件和目录的路径,basename(),

const path = require('path');
const filename = "/home/sam/Documents/node-learning/main.js";
const directoy = "/home/sam/Documents/node-learning/";
// basename(): 获取路径的基础名称
console.log(path.basename(filename)) // main.js

// basename(filename, '.js'), 第二个参数 去掉指定的后缀
console.log(path.basename(filename, '.js')); // main

// 如果参数是目录,返回最后一个,后面的/(node-learning/) 会去掉
console.log(path.basename(directoy)) // directoy

  path.join和path.resolve(),拼接多个路径片段,只不过path.join并不是总会返回绝对路径,如果拼接的字符串是相对路径的话,它返回的是相对路径。比如 path.join('./a', 'b') 返回的是'a/b', 并不是绝对路径, 拼接的字符串是绝对路径,才返回绝对路径,resolve() 永运 返回绝对路径。parse() 解析路径,返回一个对象

const path = require('path');
const filename = "/home/sam/Documents/node-learning/main.js";
const directoy = "/home/sam/Documents/node-learning/";
/* 参数是文件
 {
  root: '/',
  dir: '/home/sam/Documents/node-learning',
  base: 'main.js',
  ext: '.js',
  name: 'main'
}
 */
console.log(path.parse(filename))

/* 参数是目录,
 {
  root: '/',
  dir: '/home/sam/Documents',
  base: 'node-learning',
  ext: '',
  name: 'node-learning'
}
 */
console.log(path.parse(directoy))

  

  流处理数据,是把数据分割成一块一块的进行处理。数据读取时,读取到一块内容,就会发送事件,有机会去处理这一块内容。读取一块数据,处理一块数据,数据不会一直占用内存中,内存使用效率高,更有可能来处理大文件。数据的写入也是一块一块写,比如网络下载,服务器一块一块地写数据,浏览器一块一块的读数据,时间高效。在Node.js中,有4种流:

  可读流(readable stream): 从里面读取数据的流。它负责从数据源里读取数据(到内存),程序负责从它里面读取数据,把它看作数据源的抽象。

  可写流(writable stream): 向里面写入数据的流,程序向可写流里写入数据(把内存中的数据写入到可写流中),它负责向目的地写入数据,它是目的地的抽象。

  双向流或双工流(duplex stream): 既可以从它里面读取数据,也可以向它里面写入数据。

  转换流(transform stream):特殊的双工流,向它里面写入数据,它可以对写入的内容进行转换,然后把数据放到它的读端,可以从里面读取到转换到后的内容。

  可读流

  可读流有两种模式:pause和flow。pause是默认模式,就是创建可读流后,它并不会从数据源中读取数据,

const fs = require('fs'); 
const readable = fs.createReadStream("a.txt"); 

setInterval(() => {
  // 监听可读流读取了多少数据
  console.log(readable.bytesRead, 'bytesRead')
}, 1000);

// readable.on('readable', () => {})

  控制台永远输出0,不会读取数据。继续使用pause模式,为了让可读流从数据源中读取数据,需要监听readable事件,打开程序最后一行注释,控制台永远输出65536 bytes (64kb),程序读取64kb的内容,也不会再读了, pause了。这是因为在pause模式下,可读流使用了缓冲区技术,它内部有一个缓冲区,默认大小64kb,可读流读取数据到缓冲区中,当认为数据可读时,发出readable事件。如果监听readable 事件,并没有提供处理函数, 可读流填充满缓冲区就不读了,它并不会从缓冲区中删除数据。readable事件处理函数就要从缓冲区读取数据,清空缓冲区,调用可读流的read()方法。

const fs = require('node:fs');
const readable = fs.createReadStream("a.txt");

readable.on('readable', () => {
    console.log('readable event')
    let chunk;
    // read() 方法,如果从缓冲区读取不到数据,就会返回null。
    while (null !== (chunk = readable.read())) {
        console.log(chunk.length)
    }
});

  read方法还可以接受一个参数,表示一次从缓冲区读取多少字节,如果没有提供参数(像上面一样),就把缓冲区的所有数据全读取出来,上面的程序中控制台先输出65536 就是证明。给read参数提供30000

const fs = require('fs'); 
const readable = fs.createReadStream("a.txt"); 

readable.on('readable', () => {
  console.log('readable event')
  let chunk; 
  
  while (null !== (chunk = readable.read(30000))) { 
    console.log(chunk.length)
  } 
}); 

/** read 从缓冲区中一次读取30000 byte
readable event
30000
30000
readable event
30000
readable event
125
 */

  这时发现一个现象,提前读取,当buffer 中数量少时,会提前读取。程序每次从缓冲区拉取数据,当缓冲区的数据较少时,可读流就会进行读取操作,填充空余的缓冲区,发出一次readable事件。pause 模式下,监听readable事件,事件处理函数中调用read()方法,处理数据,如下图所示

  但有时候,异步的数据处理逻辑,需要等到buffer中的数据全部读取完毕,再触发下一次读取,不需要提前读取,这就要使用once,手动添加readable 事件。

const fs = require('fs');
const stream = fs.createReadStream('a.txt');

stream.once('readable', consume); // 触发第一次可读流读取操作

async function consume() {
    let chunk;
    // 消费数据,
    while ((chunk = stream.read()) !== null) {
        await asyncHandle(chunk);
    }
    //消费完毕,触发另一次可读流的读取操作
    stream.once('readable', consume);
}

async function asyncHandle(chunk) {
    console.log(chunk);
}

  说完pause,再说flow。监听可读流的data事件,流就自动转化成flow模式,不停地从数据源中读取数据,读取一块数据,就发送data事件,它不管你处不处理,也不管你处理的快慢,它就是按自己的速度读取数据,直到读完为止。

const fs = require('fs'); 
const readable = fs.createReadStream("a.txt"); 

setInterval(() => {
  // 监听可读流读取了多少数据
  console.log(readable.bytesRead, 'bytesRead')
}, 1000);

readable.on('data', () => {})

  控制台显示了整个文件的大小,没有处理data事件,仍然从源中读取数据。

  这就有一个问题,可读流推送数据太快,事件处理函数处理太慢,要么丢失数据,要么无限缓存,消耗大量内存。比如data事件处理函数中,把数据写入到另外一个文件,写入很慢。怎么办?可写流默认内置了一个缓冲区,数据先写到缓冲区,再写入到目的地。如果缓冲区满了,那不要再写数据了,等清空缓冲区,再写入数据,这就是背压(backpressure)。

  可写流write方法返回true表示写入成功后内置的buffer仍有空闲,仍然可以写入。返回false表示写入后内置的buffer满了,不能再写入了。可写流写入到目的地后,内部的buffer就有空闲,它就会发出发出drain事件,收到drain事件,就可以继续向可写流中写入数据了。

const fs = require('fs');

const readable = fs.createReadStream('./biji.txt');
const writeable = fs.createWriteStream('anotehr.txt');

readable.on('data', (data) => {
    if (!writeable.write(data)) {
        readable.pause();
        writeable.on('drain', () => {
            readable.resume();
        })
    }
})

  监听可读流的data事件,可读流去读取数据,同时在事件监听函数中,调用可写流的write()方法写入数据。如果write()返 回false, 可读流就就要暂停读取。同时,可写流要监听'drain'事件,在事件中调用可读流的resume()方法。一旦在可读流的buffer中有空间,可写流发出'drain'事件,继续可读流的读取数据。Node.js对这些操作进行了抽象,形成了pipe方法,readableStream.pipe(writeStream); 可读流的内容向可写流里面写。同时pipe返回第一个参数,如果第一个参数是转换流或双工流,pipe写到它们的可写端,由于又返回了它们,它们有可读端,可以继续从里面读取数据,形成pipe的链式调用。

  双工流内部有读缓冲区和写缓冲区,可读可写,写就写到它的写缓冲区中,读就从它的读缓冲区中读取数据,读缓冲区和写缓冲区可以没有任何关系,只有net模块使用了双工流。

  转化流是特殊的双工流,调用它的write方法写入到内部的写缓冲区中,然后它自己把写缓冲区中的数据放到它内部的读缓冲区中,外界监听可读流事件读取转化流中的数据。写入的数据成为可读的数据,当然写缓冲区数据到读缓冲区之间,可以对数据做转化,如果对数据什么都不做,就成了pass through流了。转化流内部的write和read方法,只是从两个缓冲区中搬运数据。

  zlib模块是转化流,对文件压缩和解压缩。

const { createGzip } = require('node:zlib');
const { createReadStream, createWriteStream } = require('node:fs');

const gzip = createGzip();
const source = createReadStream('input.txt');
const destination = createWriteStream('input.txt.gz');

source.pipe(gzip).pipe(destination)

  pipe的使用并没有处理error,如果pipe出现了error怎么办?使用pipe()方法,并不会关闭流发出事件。事件依然有效,可以监听source,gzip,destination流的error事件,但这样不太友好,用pipeline方法,第一个参数是readableStream, 中间参数是0,1,或多个转化流,后面再跟可写流,最后一个参数是回调函数,pipeline完成后,执行的回调。source.pipe(gzip).pipe(destination) 换成

const { pipeline } = require('node:stream');
pipeline(source, gzip, destination, (err) => {
  if (err) {
    console.error('An error occurred:', err);
    process.exitCode = 1;
  }
});

  自定义转换流就是继承Transform, 重写_transfrom方法和可选的_flush方法。 _transform的三个参数是 chunk(数据), encoding(字符编码), 和callback(回调函数),它的作用就是读取上游传递过来的数据,然后把转换后的数据再push回去。_flush则是,如果上游发送的数据完了,但在 transform流中还有数据,那就在_flush中把剩余的数据push出去,它接受一个回调函数,也是通知的作用

const { Transform } = require('stream');

class MyTransform extends Transform {
    _transform(chunk, encoding, callback) {
        var upperChunk = chunk.toString().toUpperCase();
        this.push(upperChunk); // 调用push方法,向流中push数据。
        callback();
    }
}

  流还有一种Object 模式,流中流动的是object对象,一个对象一个对象的流动。

const { Transform, Readable } = require('node:stream')

class SumProfit extends Transform {
    constructor(opts = {}) {
        super({ ...opts, readableObjectMode: true, writeableObjectMode: true })
        this.total = 0;
    }

    _transform(record, encoding, cb) {
        this.total += JSON.parse(record).profit;
        cb();
    }

    _flush(cb) {
        this.push(this.total.toString() + ' \n')
        cb()
    }
}
// Readable.from创建可读流,默认是Object模式,流中都是object。一个元素一个object。
// 由于流中只能是字符串,buffer,所以要把对象进行序列化,转化成字符串
// 可读流,还会触发end事件
const objRead = Readable.from([
    JSON.stringify({profit: parseInt(Math.random() * 100)}),
    JSON.stringify({profit: parseInt(Math.random() * 100)})
])

objRead.pipe(new SumProfit()).pipe(process.stdout)

  当可读流读取到数据时给转换流时,一次data事件,一次_transform函数的调用。当可读流中没有数据,它就不调_transform了,但转化流中还存在this.total 数据没有处理,当可读流发出end事件之前时,转换流的_flush方法会被调用,所以正好利用这个机会,在_flush方法中把this.total 放到转化流的可读缓冲区。实现可读流的时候,一定要表示可读流结束,实现可写流时,也要表示结束

const { Readable } = require('node:stream')

const objRead = new Readable({ objectMode: true });
objRead._read = () => {};  
let i = 0;
const id = setInterval(() => {                            
    objRead.push(JSON.stringify({
        profit: parseInt(Math.random() * 100)
    }));
    i++
    if( i == 10) {
        objRead.push(null); // push(null) 可读流读取数据结束了
        clearInterval(id)
    }
}, 100);

  Socket 网络编程

  两台计算机要进行通信,首先要能找到对方,然后发送消息。操作系统实现了TCP/IP协议,帮我们寻找对方,发送消息。怎样使用TCP/IP协议呢?Node.js提供了net模块,可以直接使用TCP的传输功能,创建TCP server 和 TCP client,client发送数据给server,server 返回数据给client,实现通信,通信的两端称为socket。创建server.js

const net = require('node:net');
const server = net.createServer(); // 创建服务器

// 监听客户端连接,连接成功,socket就是对应的客户端标记
server.on('connection', (socket) => { 

    //socket是一个双工流,接收客户端发送过来的数据,就监听data事件,
    socket.on('data', (data) => {
        console.log(data)
    })

    socket.on('end', () => {
        console.log('end')
    })
})

// 监听3000 端口
server.listen(3000, '127.0.0.1', () => {
    console.log("server")
})

  client.js

const net = require('node:net');
const socket = net.createConnection({ // 连接server
    host: 'localhost', port: 3000
}, () => { // 成功连接后,执行回调函数
    socket.write('hello')
    socket.end() // 断开连接
})

  node server.js, 然后node client.js 成功发送消息。创建一个文件发送和接收服务,server.js

const net = require('net');
const fs = require('node:fs/promises');

const server = net.createServer();

let filehandler;
let writeStream;

server.on('connection', (socket) => {

    socket.on('data', async (data) => {
        if (!filehandler) {
            // data事件触发太快,而fs.open太慢,比如data 事件发生了5次,
            // fs.open才打开,那这5次事件,fs.open就要执行5次。
            // 所以,没有打开文件,就不接收数据了,等打开文件后,把已经接收到数据写入,再开始接收数据。
            socket.pause();

            const indexOfDivdier = data.indexOf('-------');
            // 客户端写过来的数据是 filename: ddddd.txt-----
            // 由于是filename: 占10个字节,所以从10开始截取。
            const fileName = data.subarray(10, indexOfDivdier).toString();

            filehandler = await fs.open(`storage/${fileName}`, 'w');
            writeStream = filehandler.createWriteStream();
            // 从 ------- 后面开始写
            writeStream.write(data.subarray(indexOfDivdier + 7));

            socket.resume()

            writeStream.on('drain', () => {
                socket.resume()
            })
        } else {
            if (!writeStream.write(data)) {
                socket.pause()
            }
        }

    })

    socket.on('end', () => {
        console.log('end')
        filehandler.close()
        // 虽然把流关了,但是 filehander 仍然存在
        filehandler = null;
        writeStream = null; 
    })
})

server.listen(3000, '127.0.0.1', () => {
    console.log("server")
})

  client.js

const net = require('net');

const fs = require('node:fs/promises');
const path = require('path');

const socket = net.createConnection({
    host:"localhost", port: 3000
}, async () => {
    // 启动客户端时用 node client text.txt
    const filePath = process.argv[2];
    // filePath是绝对路径,所以取basename。
    const fileName = path.basename(filePath);

    const filehandler = await fs.open(filePath, 'r')
    const fileReadStream = filehandler.createReadStream();

    // showing the upload process
    const fileSize = (await filehandler.stat()).size;
    let uploadedPercentage = 0;
    let bytesUploaded = 0;

    socket.write(`fileName: ${fileName}-------`);

    fileReadStream.on('data', data => {
        if(!socket.write(data)) {
            fileReadStream.pause()
        }

        bytesUploaded += data.length;
        let newPercentage = Math.floor(bytesUploaded / fileSize * 100)

        // 因为有太多的data事件,不想展示太多
        if(newPercentage % 5 === 0 && newPercentage !== uploadedPercentage) {
            uploadedPercentage = newPercentage;
            console.log('Uploading.........')
        }
    })

    socket.on('drain', () => {
        fileReadStream.resume()
    })

    fileReadStream.on('end', () => {
        console.log("The file was successfully uploaded");
        socket.end()
    })
})

  在client和server 同级目录下,创建text.txt和storage文件夹,node server.js 启动服务, node client.js text.txt, 文件成功上传。上面有一个细节需要注意,client在发送消息的时候,先发送filename: ----, 再发送数据,而服务端解析的时候,也是认定先有filename:,占10个字节,然后解析文件名,读取数据。这就是一种协议,只不过是自己定义的,只要向服务器发送数据,就先发filename:--再发送数据,因为都是0101序列,需要按照某种格式进行解析。只要有人向服务端发送数据,你就要告诉他,先发送filename: ---再发送数据,非常麻烦,于是就出现了标准化的协议,HTTP,FTP等, HTTP协议只是规定了发送消息的格式,好让客户端和服务端都能正确的解析数据。

  HTTP

  http协议只是规定了发送消息的格式。客户端向服务端发送消息,要有请求头,如果有数据,还要有请求体,请求头包含请求方法,请求路径,如果有请求体,请求头还要包含请求体的格式。服务端向客户端发送消息的时候,也要告诉客户端端发送的是什么。Content-Type表示什么类型,它的值MIME(media type)。media type的格式是 type/subtype,比如text/css,它还有一个可选的key=value, 比如text/html;charset=utf-8. 还有一种type是multipart, 比如文件上传,multipart/form-data。Content-Length 表示发送了多少个字节。创建http服务器,它就帮你把客户端发送过来的请求解析完成,封装了request对象中,同时把客户端连接封装到response对象中。http服务器程序,只操作两个对象,一个是request对象,表示请求,有method, url,headers等属性, 一个是response对象,用于响应,主要有setHeader,write,end方法,向客户端写入数据。当用write的时候,一定要调用end表示结束,因为写入的都是buffer,客户端也不知道什么时候结束,就一直等待。get请求比较简单,就是服务器返回数据。

const http = require('node:http'); //node: 表示它是node内置的module,不是第三方module。
const fs = require("node:fs/promises")
const server = http.createServer()

server.on('request', async (request, response) => {
    if (request.url === '/' && request.method === 'GET') {
        response.setHeader("content-type", 'text/html');
        const fileHanlde = await fs.open('./public/index.html', 'r');
        const fileStream = fileHanlde.createReadStream();
        fileStream.pipe(response)
    }

    if (request.url === '/styles.css' && request.method === 'GET') {
        response.setHeader("content-type", 'text/css');
        const fileHanlde = await fs.open('./public/styles.css', 'r');
        const fileStream = fileHanlde.createReadStream();
        fileStream.pipe(response)
    }

    if (request.url === '/scripts.js' && request.method === 'GET') {
        response.setHeader("content-type", 'text/javascript');

        const fileHanlde = await fs.open('./public/scripts.js', 'r');
        const fileStream = fileHanlde.createReadStream();
        fileStream.pipe(response)
    }

})

server.listen(3000, () => {
    console.log('server listening on 3000')
})

  post请求会携带数据,所以要确定数据类型,不同的类型,不同的处理方式。request对象是一个可读流,发送过来的数据在流中,只要监听data事件,就能获取post发送过来的数据,request有一个header属性,它有一个content-type,可以知道发送过来的是什么类型。如果是发送的form表单,可以这么处理

function post(req, res) {
    if (req.headers["content-type"] === "application/x-www-form-urlencoded") {
        const input = [];

        req.on("data", (chunk) => {
            input.push(chunk);
        });

        req.on("end", () => {
            const parsedInput = Buffer.concat(input).toString()
            // 告诉客户端已经写完了。write只是写trunk,没有结束标志
            res.end(http.STATUS_CODES[200] + " " + parsedInput);
        });
    }
}

  如果发送过来的是json数据

function post(req, res) {
    if (req.headers["content-type"] === "application/json") {
        const input = [];
        req.on("data", (chunk) => {
       input.push(chunk)           
        });

        const parsed = JSON.parse(Buffer.concat(data).toString());

        if (parsed.err) {
            error(400, "Bad Request", res);
            return;
        }

        console.log("Received data: ", parsed);
        res.end('{"data": ' + input + "}");
    }
}

  上传文件则比较复杂,Browsers embed files being uploaded into multipart messages。 Multipart messages allow multiple pieces of content to be combined into one payload. To handle multipart messages, we need to use a multipart parser. 这里要用到第三方模块formidable

function post(req, res) {
    if (/multipart\/form-data/.test(req.headers["content-type"])) {
        const form = formidable({
            multiples: true,
            uploadDir: "./uploads",
        });
    
        form.parse(req, (err, fields, files) => {
            if (err) return err;
    
            res.writeHead(200, {
                "Content-Type": "application/json",
            });
    
            res.end(JSON.stringify({ fields, files, }));
        });
    }
}

  http 模块还能请求别的服务器。它有一个get方法,可以直接发送get请求

const http = require("http");
http.get("http://jsonplaceholder.typicode.com/posts/1",
    (res) => res.pipe(process.stdout));

  发送post请求,则使用http.request()方法,它的第一 个参数是对象,配置post请求的参数,比如hostname, 第二个参数就是回调函数,接受返回的数据

const http = require('http');

const payload =JSON.stringify({
    name: "Beth",
    job: "Web"
})

const opts = {
    method: "POST",
    hostname: "postman-echo.com",
    path: "/post",
    header: {
        "Content-type": "application/json",
        'Content-Length': Buffer.byteLength(payload)
    }
}

const req = http.request(opts, (res) => {
    res.setEncoding('utf8');
    res.on('data', (chunk) => {
        console.log(`BODY: ${chunk}`);
    });
    res.on('end', () => {
        console.log('No more data in response.');
    });
});

req.on('error', (e) => {
    console.error(`problem with request: ${e.message}`);
});

// 发送post请求,带着数据,也可以直接res.end(payload) 
req.write(payload);
req.end();

  当用http.request,如果调用write方法写数据时,一定要调end方法,告诉server写完了, 因为它的发送就transfer coding 是buffer,服务端也不知道写没写完。const request = http.request() request.write(); request.end()。如果不调用end(), 要在request的header里面写content-length, 告诉服务器器发送了多少数据,服务器直接读取这么些数据,就可以了。  

  child process和Worker threads(多进程和多线程)

  JS是单线程,不适合计算任务重(CPU密集型)的应用,如果应用大部分是I/O密集型任务,只有极少数CPU密集型任务,可以创建子进程或多线程来处理。 不要阻塞事件循环,就是在事件循环中要执行的回调函数,不要执行时间过长,因为js是单线程,只有一个执行栈。  这时如果要在某个回调函数中,进行计算,那就阻塞了Node.js的事件循环,服务器性能下降, 由于计算,不能响应请求。child_process 模块提供了四种方式来创建子进程 spawn(), fork(), exec(), and execFile().  spawn()只是单纯地创建一个子进程,所以要给它一个执行程序,exec() 也是创建一个子进程,不过它同时会创建一个shell,在shell中执行shell命令。spawn()会返回一个事件发射器对象,可以监听事件,而exec则将执⾏结果缓存起来统⼀放在回调函数的参数⾥⾯

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

// ⼦进程有⾃⼰的stdin、stdout、stderr,监听⼦进程的stdout的data事件,
ls.stdout.pipe(process.stdout);
//
// ls.stdout.on('data', function(data) {
//  process.stdout.write(data);
// });
// 当一个子进程退出时,它会发出exit事件,但不一定会发出close事件。
// 子进程使用的stdio流关闭,才会发出close事件,如果多个子进程共享一个stdio, 一个子进程退出,其他子进程仍然在使用。 
ls.on('close', () => console.log('close'))
ls.on('exit', () => console.log('exit'))

  需要注意的是子进程的stdout/stderr是可读流,而stdin是可写流,正好和主进程相反。由于stdin是可写流,可以使用它来给子进程传递参数

const { spawn } = require('node:child_process');

const child = spawn("wc");

process.stdin.pipe(child.stdin); // 输入些字符,按enter,再按ctrl + D, 输入的字符就会变成 wc 命令的参数。
child.stdout.on("data", data => {
  console.log(`child stdout:\n${data}`);
});  

   exec 是接收shell命令,同样实现ls的功能

exec('ls -lh /usr', function(err, stdout, stderr){
 console.log(stdout);
});

  其实spawn可以接受参数,在创建新进程的时候,同时创建shell,

const { spawn } = require('node:child_process');
spawn("ls -lh /usr", {
    stdio: "inherit", // 继承(使用父进程的standard IO)
    shell: true // 创建shell
});

  fork(): fork是特殊的spawn,主要用来创建新的node.js进程,它比spawn多了进程间的通信,父子进程通过message进行通信。

const { fork } = require("child_process")

const childProcess = fork("./forkedChild.js") // 参数是node.js程序的路径
childProcess.send({ number: 10 }) // 给node子进程发送消息

childProcess.on("message", message => { // 接收子进程传递过来的消息
   console.log(message)
})

  forkedChild.js

process.on("message", message => { // 子进程监听message, 获取父进程传递过来的消息
    const result = message.number * 100
    process.send(result) // 子进程向父进程发送消息
    process.exit() // 子进程退出,避免成为孤儿进程
})

  execFile()和exec类似,只不过它不创建shell,可以运行一个可执行文件,比如.exe文件。如果脚本文件不需要shell也能运行,execFile也能运行脚本文件。在Linux下,脚本文件不总是需要shell,但在windows下不行,.bat and .cmd 文件总是需要shell才能执行,不需要shell的脚本用execFile执行,需要shell的脚本用exec执行。在Linux下,创建一个bash脚本 processNodejsImage.sh

#!/bin/bash
curl -s https://nodejs.org/static/images/logos/nodejs-new-pantone-black.svg > nodejs-logo.svg
base64 nodejs-logo.svg

  chmod u+x processNodejsImage.sh  使 .sh文件可以执行,写一个node程序,使用execFile 执行 processNodejsImage.sh

const { execFile } = require("child_process")

execFile(__dirname + '/processNodejsImage.sh', (error, stdout, stderr) => {
    if (error || stderr) {
        console.error(`error`);
        return;
    }
   console.log(`stdout:\n${stdout}`);
});

  使用子进程有两个问题,一是只有父子进程之间才能进行通信,子进程和子进程之间不能进行通信,二是每一个进程都有单独的内存空间,造成时间和资源的消耗。此时可以使用多进程work thread,因为线程之间共享内存。 multiThread.js

const { Worker } = require("node:worker_threads")

function runWorker(workerData) {
    return new Promise((resolve, reject) => {
        //第一个参数是线程路径,第二个参数是向线程传递的消息
        const worker = new Worker("./sumOfPrimesWorker.js", {
            workerData, // workerData 是copy and paste
        })
        worker.on("message", resolve) // 接收线程传递过来的消息
        worker.on("error", reject)
        worker.on("exit", code => {
            if (code !== 0) {
                reject(new Error(`Worker stopped with exit code ${code}`))
            }
        })
    })
}

function divideWorkAndGetSum() {
    const start1 = 2
    const end1 = 150000
    const start2 = 150001
    const end2 = 300000
    const start3 = 300001
    const end3 = 450000
    const start4 = 450001
    const end4 = 600000
    // 创建线程
    const worker1 = runWorker({ start: start1, end: end1 })
    const worker2 = runWorker({ start: start2, end: end2 })
    const worker3 = runWorker({ start: start3, end: end3 })
    const worker4 = runWorker({ start: start4, end: end4 })
    // 等待数据
    return Promise.all([worker1, worker2, worker3, worker4])
}

(async function name(params) {
    const sum = await divideWorkAndGetSum()
    .then( values => values.reduce((accumulator, part) => accumulator + part.result, 0) //reduce is used to sum all the results from the workers
    )
    .then(finalAnswer => finalAnswer)
    
    console.log(sum, 'sum')
})()

  sumOfPrimesWorker.js

const { workerData, parentPort } = require("worker_threads") //workerData 就是传递过来的数据

const start = workerData.start
const end = workerData.end

var sum = 0
for (var i = start; i <= end; i++) {
    for (var j = 2; j <= i / 2; j++) {
        if (i % j == 0) {
            break
        }
    }
    if (j > i / 2) {
        sum += i
    }
}

parentPort.postMessage({
    start: start,
    end: end,
    result: sum,
})

  Cluster 集群

  在child_process的基础上,更进一步,它自动fork进程,创建了主从架构,主进程以轮流的方式把请求分陪给子进程。cluster.js

const cluster = require("cluster")
const http = require("http")
const cpuCount = require("os").cpus().length // 返回cpu多少核

if (cluster.isPrimary) {
    masterProcess()
} else {
    childProcess()
}

function masterProcess() {
    console.log(`Master process ${process.pid} is running`)

    //fork workers.
    for (let i = 0; i < cpuCount; i++) {
        console.log(`Forking process number ${i}...`)
        cluster.fork() //creates new node js processes
    }
    cluster.on("exit", (worker, code, signal) => {
        console.log(`worker ${worker.process.pid} died`)
        cluster.fork() //forks a new process if any process dies
    })
}

function childProcess() {
    //workers can share TCP connection
    const server = http.createServer((req, res) => {
        res.end(`hello from server ${process.pid}`)
    })

    server.listen(5555, () =>
        console.log(`server ${process.pid} listening on port 5555`)
    )
}

  第一次运行cluster.js时, cluster.isPrimary是true,masterProcess执行产生了4个子进程(假设CPU有4核),当子进程运行时,还是运行这个文件(cluster.js), 但是cluster.isPrimary是false,childProcess() 执行4 次, 创建了4个http服务器实例。 后续的http请求被随机分配到四个http服务器实例。

  Node.js 设计模式

  API设计要么全同步,要么全异步,如果读取了数据并缓存,下一次从缓存中读取数据,它是同步的,可以使用process.nextTick 来让它变成异步。

function readFileIfRequired(cb) {
    if (!content) {
        fs.readFile(__filename, 'utf8', function (err, data) {
            content = data;
            console.log('readFileIfRequired: readFile');
            cb(err, content);
        });
    } else {
        process.nextTick(function () {
            console.log('readFileIfRequired: cached');
            cb(null, content);
        });
    }
}

  依赖注入:组件只声明使用某个或某些依赖,依赖相当于变成了参数,需要传入,在组件内部不会创建依赖对象,在使用组件的时候,给它注入依赖,便于解耦。

   Service只声明了它依赖特定接口的dependency, injector创建了一个实现接口的实例,把它注入到service中。怎么声明它需要依赖呢?一个是构造函数,一个是set方法

export class Blog {
    constructor (db) {
        this.db = db
        this.dbRun = promisify(db.run.bind(db))
        this.dbAll = promisify(db.all.bind(db))
    }
}

  在构造函数中,db不再是new db, 而是需要一个参数,传递进来。在index.js中,使用Blog时,要先创建db 实例,注入进来,再使用Blog的方法

async function main () {
    const db = createDb(join(__dirname, 'data.sqlite'))
    const blog = new Blog(db)
    await blog.initialize() 
}

  但这要手动管理依赖,很麻烦,就出现了控制翻转。依赖的管理交由第三方,通常是一个容器

  代理模式:代理是一个对象,它控制着对另一个对象的访问。代理对象和被代理对象有相同的接口(方法)。代理拦截对被代理对象的部分或所有操作,然后进行增强或补充这个形为。

  The proxy forwards each operation to the subject, enhancing its behavior with addtional preprocessing or postprocessing. 创建代理对象后,都是直接访问代理对象,因为代理对象做了增强。

  装饰器模式:动态地增强原对象的功能,我们还是调用原对象,不过执行的是装饰后的方法。dynamically augmenting the behavior of an existing object.

  an adapter converts an object with a given interface so that it can be used in a context where a different interface is expected.

posted @ 2024-06-03 22:54  SamWeb  阅读(41)  评论(0编辑  收藏  举报