浅谈Node中的FS、Events、Buffer、Stream

241 阅读18分钟

一、内置模块fs

1.1. 什么是文件系统(fs)?

  • fs是File System的缩写,表示文件系统。

  • 对于任何一个服务端语言或者框架,基本上都是由自己的文件系统的

    • 因为服务器需要将各种数据、文件都存起来,供客户端使用,而此时就需要使用文件系统去操作这些数据和文件
    • 比如用户数据一般都是存在数据库中的
    • 比如一些配置文件或者用户的(图片、音视频)等资源就会以文件的形式存在操作系统上
  • 所以文件系统其实就是帮助我们去操作系统中管理和存取文件信息的一种系统。就是它定义了文件如何在操作系统中被存储、命名、已经如何通过目录结构进行组织的

  • Node也有自己的文件系统操作模块,就是fs:

    • 它是Node自己实现的一个内置模块,所以虽然不同的操作系统对于fs模块都有不同的标准
    • 但是借助于Node帮我们封装的文件系统,我们可以在任何的操作系统(window、Mac OS、Linux)上面直接去操作文件

1.2. fs的常用API

  • Node文件系统的API非常的多:

    • 下面就列出来一些常用的,其余的可以去官网查看:nodejs.org/docs/latest…

    • 在我们读取文件时,一般都需要传递第二个参数{ encoding: 'utf8' }

      • 这是用来指定这次读取出来的内容的编码格式

      • 如果不指定的话,那么它默认读取出来的就是:

        • <Buffer e4 bd a0 e5 a5 bd e5 95 8a ef bc 8c e6 9c b1 e8 bf aa>
    • 这是因为fs默认被读取的文件是一个二进制文件,所以就会将其中的内容以二进制的方式输出出来

      • 但是我们看到上面那个明显不是二进制,并且Buffer是什么呢?

      • 后续会详细讲,这会可以理解:

        • Buffer就是一个像数组一样的容器,只不过它里面的每个item都是一个字节
        • 一个字节其实等于8比特,一个比特其实就是一位二进制
        • 所以它为了让我们读起来方便,就把那8位的二进制转成2位的16进制了
        • 就出现了上面读取出来的内容
  • fs的API大多数都提供三种操作方式:

    • 方式一:同步操作文件:代码会被阻塞,不会继续执行

      • fs.xxxSync()
      const fs = require('fs')
      ​
      // 通过同步获取
      const data = fs.readFileSync(
        './aaa.txt',
        { encoding: 'utf8' }
      )
      ​
      console.log(data)
      
    • 方式二:异步回调函数操作文件:代码不会被阻塞,需要传入回调函数,当获取到结果时,回调函数被执行

      • fs.xxx('pathOrFd', callback)
      const fs = require('fs')
      ​
      // 通过异步获取
      fs.readFile('./aaa.txt', { encoding: 'utf-8' }, (err, data) => {
        if (err) {
          console.log('读取文件失败', err)
        } else {
          console.log('读取成功:', data)
        }
      })
      
    • 方式三:异步Promise操作文件:代码不会被阻塞,通过 fs.promises 调用方法操作,会返回一个Promise,可以通过then、

      catch进行处理

      • fs.promise.xxx('pathOrFd').then(() => {})
      // 通过异步获取
      fs.promises.readFile('./aaa.txt', { encoding: 'utf-8' }).then(res => {
        console.log('读取成功:', res)
      })
      

1.3. 文件描述符

  • 文件描述符(File descriptors)是什么呢?

    • 在常见的操作系统上,对于每个进程,操作系统的内核其实都维护着一张当前打开的文件和资源的表格
    • 每个打开的文件都分配了一个称为文件描述符的简单的数字标识符
    • 这样一来,那些文件操作系统就可以通过文件描述符来标识和追踪每个文件
  • 而我们在使用fs的一些API的时候,也可以看到,第一个参数既可以传入 path,也可以传入 fd

    • fd 其实就是文件描述符的缩写了
    • 它其实就是一个 number 类型的数据
  • 比如我们通过 fs.open 打开文件之后,由于后续可能还有读写操作,所以就会给我们一个 fd

    • 后续我们就可以通过这个 fd 去打开的文件里面进行读写操作
    • 或者也可以通过fs.fstate 方法去传入 fd,然后获取文件的大小、创建时间、修改时间等信息
    • 但是记得要关闭文件,否则就会造成性能浪费
    // 打开文件
    fs.open('./aaa.txt', (err, fd) => {
      console.log('文件描述符:', fd)
        
      // 通过fd获取文件信息
      fs.fstat(fd, (err, data) => {
        console.log('文件信息:', data)
      })
    ​
      // 通过fd读取文件内容
      fs.readFile(fd, { encoding: 'utf-8' }, (err, data) => {
        console.log("文件内容:", data)
        
        // 最后通过fd关闭文件
        fs.close(fd)
      })
    })
    

1.4. 文件的读写操作

  • 如果我们希望对文件的内容进行操作,这个时候可以使用文件的读写API:

    • fs.readFile(path, options, callback):读取文件的内容

    • fs.writeFile(file, data, options, callback):在文件中写入内容

      • 写入和读写一样,也是有同步和异步的
    const content = 'I am coderKing'
    // 打开文件
    fs.writeFile(
        './aaa.txt', 
        content, 
        { flag: 'a+', encoding: 'utf-8' }, 
        (err, data) => {
          console.log('写入成功')
        }
    )
    
  • 那么这个flag又是什么东西呢?

    • flag常见的值有下面几个:

      • w 打开文件写入,默认值(write)

      • w+ 打开文件进行读写(可读可写),如果不存在则创建文件

        • 上面两个值,是用当前内容替换文件中的所有内容
      • r 打开文件读取,读取时的默认值(read)

      • r+ 打开文件进行读写(可读可写) ,如果不存在那么抛出异常

      • a 打开要写入的文件,将内容放在文件末尾。如果不存在则创建文件(append)

      • a+ 打开文件以进行读写(可读可写),将内容放在文件末尾。如果不存在则创建文件

        • 上面两个值,是将当前内容追加到文件中的内容后面

1.5. 文件夹操作

  • 新建一个文件夹

    • 使用fs.mkdir()或fs.mkdirSync()即可创建一个新文件夹:

      fs.mkdir('./kobe', (err, data) => {
        console.log('创建成功')
      })
      
  • 获取文件夹中的内容

    • 使用fs.readdir()或fs.readdirSync()即可创建一个新文件夹:

      fs.readdir('./kobe', (err, data) => {
        console.log('文件夹中的内容', data)
      })
      ​
      // log
      文件夹中的内容 [ 'abc.txt', 'first', 'second' ]
      
    • 但是通过上面这种方式,只能读取到第一层子目录,如果我有很深的目录结构呢?

      • 也可以给fs.readdir传入第二个参数,让其连同文件的类型一块给我们

        fs.readdir('./kobe', { withFileTypes: true }, (err, data) => {
          console.log('文件夹中的内容', data)
        })
        ​
        // log
        文件夹中的内容 [
          Dirent { name: 'abc.txt', [Symbol(type)]: 1 },
          Dirent { name: 'first', [Symbol(type)]: 2 },  
          Dirent { name: 'second', [Symbol(type)]: 2 }  
        ]
        
    • 此时就可以使用递归进行读取:

      function readDir(path) {
        fs.readdir(path, { withFileTypes: true }, (err, fields) => {
          fields.forEach(field => {
            if (field.isDirectory()) {
              readDir(`${path}/${field.name}`)
            } else {
              console.log('我是文件:', field.name)
            }
          })
        })
      }
      
      readDir('./kobe')
      
  • 给文件重命名

    fs.rename('./kobe', './Judy', (err, data) => {
      console.log('修改成功', data)
    })
    
  • 所以其实无论是 webpack 还是 vite,其实它们都是在将我们的项目打包出来之后,再借助于 Node 的 fs 模块,创建出来 dist 文件和其他的文件,然后将打包出来的内容写入进去的

二、内置模块 events

2.1. 初识events模块

  • 这个 events 模块其实和我们熟知的事件总线基本上是一样的,就不多做赘述了

  • 它的核心功能也是发出事件和监听事件

  • 发出事件和监听事件都是通过EventEmitter类来完成的,它们都属于events对象

    • emitter.on(eventName, listener):监听事件,也可以使用addListener
    • emitter.off(eventName, listener):移除事件监听,也可以使用removeListener
    • emitter.emit(eventName, [ ...args]):发出事件,可以携带一些参数
    const EventEmitter = require('events')
    
    const events = new EventEmitter()
    
    const handleFn = () => {
      console.log('监听到事件咯')
    }
    events.on('Judy', handleFn)
    
    setTimeout(() => {
      events.emit('Judy')
    
      // 移除事件,下面的setTimeout就不触发了
      events.off('Judy', handleFn)
      
      setTimeout(() => {
        events.emit('Judy')
      }, 1000)
    
    }, 2000)
    

2.2. events模块常见方法

  • 这个 events 模块还有其他一些事件,可以获取到一些信息:

    • emitter.eventNames():返回当前 EventEmitter对象注册的事件字符串数组

    • emitter.getMaxListeners():返回当前 EventEmitter对象的最大监听器数量,可以通过setMaxListeners()来修改,默认是10

      • 但是最好不要修改,否则发出一个事件之后,要执行很多操作,就会降低用户体验
    • emitter.listenerCount(事件名称):返回当前 EventEmitter对象某一个事件名称,监听器的个数

    • emitter.listeners(事件名称):返回当前 EventEmitter对象某个事件监听器上所有的事件处理函数数组

    const events = new EventEmitter();
    
    events.on("Judy", () => {});
    events.on("Judy", () => {});
    events.on("Judy", () => {});
    events.on("Judy", () => {});
    
    events.on("kobe", () => {});
    events.on("kobe", () => {});
    
    console.log(events.eventNames());
    console.log(events.getMaxListeners());
    console.log(events.listenerCount("Judy"));
    console.log(events.listeners("Judy"));
    

2.3. events模块方法补充

  • 还有一些不常用的方法,就不演示了,比如:

    • emitter.once(eventName, listener):事件监听一次
    • emitter.prependListener():将监听事件添加到最前面
    • emitter.prependOnceListener():将监听事件添加到最前面,但是只监听一次
    • emitter.removeAllListeners([eventName]):移除所有的监听器

三、Buffer类的使用

3.1. 数据的二进制

  • 计算机中所有的内容:文字、数字、图片、音频、视频最终都会使用二进制来表示。

  • 但是我们在进行前端开发的时候,一般都是在处理字符串

  • 但是前端不也能展示图片和音视频吗?

    • 事实上在前端里面,图片和音视频一直都是交给浏览器处理的
    • 而我们处理的只是它们的url地址,也就是字符串。然后将url地址赋给相应的元素
  • 但是对于服务器开发是不一样的:

    • 比如某一个保存文本的文件并不是使用 utf-8进行编码的, 而是用了其他的编码格式,那么我们必须读取到他们的二进制数据,再通过对应的编码格式转换成对应的文字

    • 比如我们需要读取的是一张图片数据(二进制) ,然后再通过某些手段对图片数据进行二次的处理(裁剪、格式转换、旋转、添加滤镜)

      • 而Node中就有一个Sharp的库,它就是通过读取图片或者手动传入图片的==Buffer==,然后再对其再进行处理的
    • 所以我们再进行服务端开发的时候,就必须得学会如何去处理二进制的数据

3.2. Buffer和二进制

  • 我们会发现,对于前端开发来说,通常很少会和二进制直接打交道,但是对于服务器端为了做很多的功能,我们必须直接去操作其二进制的数据

    • 但是直接操作0101的话,很难操作,而且也很复杂
    • 所以Node给我们提供了一个全局类:Buffer,这个Buffer类主要就是用来处理二进制数据的
  • 我们前面说过,Buffer中存储的是二进制数据,那么到底是如何存储呢?

    • 我们可以将Buffer看成是一个存储二进制的数组
    • 这个数组中的每一项,都可以保存8位二进制: 0000 0000
  • 为什么是8位呢?

    • 因为一位二进制存储的数据是十分有限的,所以在计算机中,我们很少会直接操作一位二进制(bit)
    • 所以通常就是将8位二进制合在一起作为一个单元,这个单元就被称之为==字节(byte)==
    • 也就是说 1byte = 8bit,1kb = 1024byte,1MB = 1024kb
  • 字节在计算机中是非常常见的:

    • 比如Java中的int类型是4个字节,long类型时8个字节

    • 比如TCP传输的是字节流,在写入和读取时都需要说明字节的个数

    • 比如rgb的三个值最大的分别就只能是255

      • 为什么最大只能是255呢?

        • 因为三原色的每个颜色的色调值都是用一个字节来存储的

        • 而一个字节就是8位二进制,那么也就是说,一个字节最大的就是 1111 1111

          • 这1111 1111二进制转成十进制就是255,转成十六进制当然就是 ff 了
        • 所以最大的就只能是255了

  • 所以本质上,计算机虽然最终都是用比特来存储数据的,但是都是用字节来作为数据的单位表示的

3.3. Buffer和字符串之间的转换

  • Buffer相当于是一个存储字节的数组,数组中的每一项对应一个字节的大小

  • 如果我们想要将一个字符串放入Buffer中,那么就有两种方式:

    • 方式一:直接 new Buffer(),但是这种方式官方已经不推荐使用了

      const buffer = new Buffer('Judy')
      console.log(buffer)
      
      // log
      <Buffer 4a 75 64 79>
      
    • 方式二:通过 Buffer.from()方法

      const buffer = Buffer.from('Judy')
      console.log(buffer)
      
      // log
      <Buffer 4a 75 64 79>
      
  • 那么它是怎样将字符串存储在Buffer中的呢?

    • 其实就是在拿到字符串之后,==默认通过 utf-8 编码格式==,拿到字符串中每个字符的16进制编码

      • utf-8 中默认就包含了 ascii 编码
    • 然后再将每个字符的编码依次存入到 Buffer 中

  • 如果我们想要将 Buffer 中的字节转成字符串的话,就可以通过 buffer.toString()方法

    • Buffer.from 方法还可以传入第二个参数,就是编码格式
    • buffer.toString 方法也可以传入一个参数,表示解码格式
    • 但是如果编码和解码格式不同的话,就会报错了
    • 所以尽量别传编码格式,让它编码和解码时都是用默认的 utf-8 即可
    const buffer = Buffer.from('Judy')
    console.log(buffer)
    console.log(buffer.toString())
    

3.4. Buffer的其他创建方式

  • Buffer 类还有其他许多的静态方法,在使用到的时候可以自己查一下

  • 其中比较常用的就是Buffer.alloc方法

    • 这个方法可以提前申请内存,比如 Buffer.alloc(8) 就是申请了8个字节大小的一个位置

    • 默认其中的每个字节的值都是 00 了

      const buffer = Buffer.alloc(8)
      console.log(buffer)
      
      // log
      <Buffer 00 00 00 00 00 00 00 00>
      
  • 我们可以通过像改数组中的item的那种方式,通过下标去修改 buffer 里面的某个字节的值

    const buffer = Buffer.alloc(8)
    console.log(buffer)
    
    // 将a对应的asic码中的16进制的值存入到buffer[0]这个字节中
    buffer[0] = 'a'.charCodeAt()
    // 存入16进制的63
    buffer[1] = 0x63
    
    console.log(buffer)  // <Buffer 61 63 00 00 00 00 00 00>
    console.log(buffer[0].toString(16))  // 61
    

3.5. Buffer读取文件

  • 我们也可以直接通过 fs.readfile 读取一个文件,获取到的默认就是一个 Buffer

    • 那么现在我们知道什么是 Buffer 了,也就知道其实我们在 readfile 时,不传入 encoding 也是可以的
    • 其中一个字母就是一个字节一个常见汉字是3字节,生僻字是4字节
    fs.readFile('./bbb.txt', (err, data) => {
      console.log(data)
      console.log(data.toString())
    })
    
    // log
    <Buffer 49 20 61 6d 20 63 6f 64 65 72 4b 69 6e 67>
    I am coderKing
    
  • 如果我们读取一个图片的话,也可以获取到它的 Buffer

    • 前面说的 Node 中的 sharpe 库,其实就是让我们传入一个图片的 Buffer,然后再传入相应的操作,从而对图片进行操作之后,再返回出来一个操作之后图片的 Buffer

3.6. Buffer的创建过程

  • Node 性能之所以高,是因为它在很多地方都做过一些优化

  • 比如当我们创建 Buffer 时,并不会频繁的向操作系统申请内存

  • 而是会默认就申请一个 8 * 1024byte,也就是8kb大小的内存

  • 比如我们要是通过 Buffer.from 创建一个 Buffer 的话

  • 那它就会先去判断之前申请的内存剩余的长度是否还足够放下我们传入的那个字符串

  • 如果不够,那就会重新执行 createPool 申请新内存

  • 如果够了,那就直接使用,但是之后,poolOffset 的值就要往后偏移

    • 就算我们使用 Buffer.alloc 方法申请内存,也是从创建出来的这 8kb 里面申请的
  • 如果存储的某个数据直接就已经大于等于 4kb 了,那么就会直接为当前数据申请新的内存,而不会往之前申请的那 8kb 里面添加

四、Stream的使用

4.1. Stream的概念理解

  • 什么是Stream 呢?

    • 我们第一反应应该就是溪水、河流
    • 那么程序中的流也是类似的含义,我们可以想象当我们从一个文件中读取数据的时候,文件中的二进制(字节)数据就会源源不断的被读取到我们的程序中
    • 这一连串的字节,就是程序中的流
  • 所以我们可以这样理解流:

    • 流是一种连续字节的表示形式和抽象概念
    • 那么流既然是数据,那它肯定即是可读的也是可写的
  • 在之前学习文件的读写时,我们可以直接通过 readFile或者 writeFile方式读写文件,为什么还需要流呢?

    • 这是因为直接读写文件的方式,虽然简单,但是无法控制一些细节的操作
    • 比如从什么位置开始读、读到什么位置、一次性读取多少个字节
    • 又或者读到某个位置后,暂停读取,某个时刻恢复继续读取等等
    • 或者如果这个文件非常大,比如一个视频文件,一次性全部读取就不合适了
  • Node.js中主要有两种基本流类型:

    • Writable:可以向其写入数据的流(例如 fs.createWriteStream())
    • Readable:可以从中读取数据的流(例如 fs.createReadStream())
    • 这两个方法其实都是实现了 events 中的所有方法的,所以它们的实例,既可以 on 监听事件,也可以 emit 发出事件

4.2. 可读流的使用:readable

  • 之前我们是通过 fs.readfile 将一个文件中的所有内容都一次性读到我们的程序中的,但是这种读取方式会带来很多问题:

    • 文件过大、读取的位置、结束的位置、一次读取的大小等等,上面已经详细说过了
  • 那么此时,我们就可以使用 fs.createReadStream 这个方法,不再一次性读取文件中的所有内容,而是慢慢的来读取文件流

    • 它主要有两个参数:

      • 参数一:文件路径
      • 参数二:{ start: 开始读取的字节位置, end: 结束读取的字节位置, highWaterMark: 一次可以读取的字节长度,默认64kb }
  • readable的使用

    • 创建对应文件的读取流:

      const readStream = fs.createReadStream('./bbb.txt', {
        start: 4,
        end: 10,
        highWaterMark: 3
      })
      
    • 我们可以通过监听 readStream 的 data 事件,来监听到 createReadStream 内部给我们发出的 emit 事件

      • 当读取到数据的时候,它就会发出 emit 事件,并将当前这次读取到的数据作为参数传过来

        readStream.on('data', (data) => {
          console.log(data.toString())
        })
        
  • 我们还可以通过文件流监听当前文件的:打开、读取结束、关闭等事件

    • 当我们开始读取一个文件的时候,肯定会有上面那些动作的,这就有点像**==读取文件==这个操作的生命周期**
    • 并且也可以通过文件的读取流的 pauseresume 方法,来暂停读取和恢复读取
    const readStream = fs.createReadStream('./bbb.txt', {
      start: 4,
      end: 10,
      highWaterMark: 3
    })
    
    readStream.on('data', (data) => {
      console.log(data.toString())
    
      // 暂停读取
      readStream.pause()
    
      // 两秒后继续读取
      setTimeout(() => {
        readStream.resume()
      }, 2000)
    
    })
    
    readStream.on('open', fd => {
      console.log('文件被打开了', fd)
    })
    
    readStream.on('end', () => {
      console.log('文件读取结束')
    })
    
    readStream.on('open', () => {
      console.log('文件被关闭了')
    })
    

4.3. 可写流的使用:writable

  • 之前我们通过 fs.writefile 方法来为某个文件写入内容,但是这种方式也有一些问题:

    • 比如我们可能想要做到一点点的写入内容,精确每次写入的位置等等
  • 那么此时,我们就可以使用 fs.createWriteStream 这个方法,不再一次性写入所有内容,而是慢慢的来写入文件流

    • 它主要有两个参数:

      • 参数一:flags:默认是w,就是前面讲过的写入方式,如果想追加就使用 a 或者 a+

      • 参数二:start:写入的位置

        • 如果要使用这个参数的话,要注意:兼容性问题

          • 如果是mac电脑,用 r+ 或者 a+ 都是可以的
          • 但是如果是windows电脑,就必须只能用 ==r+==,如果用 a+,会发现并没有把内容插入到 start 所指向的位置
    • 并且我们也可以监听文件的打开

    const writeStream = fs.createWriteStream('./ccc.txt', {
      flags: 'a+'
    })
    
    writeStream.write('Hello World')
    writeStream.write('Hello Judy')
    writeStream.write('Hello kobe')
    
    writeStream.on('open', fd => {
      console.log('文件被打开了', fd)
    })
    
    writeStream.on('close', fd => {
      console.log('文件被关闭了', fd)
    })
    
  • 但是会发现,我们并不能监听到文件的 close

    • 这是因为写入流在打开文件之后,并不会自动关闭,而是需要我们手动关闭

      writeStream.close()
      
    • 并且当我们关闭了文件之后,也能监听到文件写入完成的 finish 事件

      writeStream.on("finish", () => {
        console.log("文件流写入完成");
      });
      
  • 如果我们不想手动调用 close 方法关闭文件的话,也可以使用 end 方法来进行最后一次写入

    • 这个方法其实就是包含了两个操作:write 和 close

      writeStream.end('结束咯')
      

4.4. pipe方法

  • pipe 有管道的意思,它其实就是可以将我们的可读流和可写流连接在一起

    • 它是读取流的一个方法,接收一个写入流的参数
    • 从而让我们实现一边读取一边写入的效果
  • 比如我们如果想要复制一个文件的话,就有三种做法:

    • 做法一:使用 fs.readfilefs.writefile

      fs.readFile('./bbb.txt', (err, data) => {
        fs.writeFile('./bbb_copy_01.txt', data, (err) => {
          console.log(err)
        })
      })
      
    • 做法二:使用读取流和写入流

      const readStream = fs.createReadStream('./bbb.txt')
      const writeStream = fs.createWriteStream('./bbb_copy_02.txt')
      
      readStream.on('data', data => {
        writeStream.write(data, err => {
          console.log(err)
        })
      })
      
    • 做法三:使用 pipe 方法

      • 可以看到这是非常方便的
      const readStream = fs.createReadStream('./bbb.txt')
      const writeStream = fs.createWriteStream('./bbb_copy_03.txt')
      
      readStream.pipe(writeStream)
      

结语:

  • 后续还会从源码的角度剖析一下 Express 和 Koa 的异同,有兴趣的话,点波关注~