Logan 如果前面还有路,答应我,跑下去...

Node.js入门-5-FileSystem(文件系统)

2018-07-14
Logan

一、概述

  • 在Node中,与文件系统的交互是非常重要的,服务器的本质就是将本地的文件发送给远程的客户端
  • Node通过fs模块来和文件系统进行交互,该模块提供了一组类似UNIX(POSIX)标准的文件操作API来打开、读取、写入文件,以及与其交互
  • 要使用fs模块,首先要从核心模块中加载:const fs = require('fs')
  • Node.js 文件系统(fs模块) 模块中的方法均有异步和同步版本。
  • 异步的方法函数最后一个参数为回调函数,回调函数的第一个参数包含了错误信息(error)

二、权限位 mode

权限分配 文件所有者 文件所属组 其他用户
权限项 执行 执行 执行
字符表示 r w x r w x r w x
数字表示 4 2 1 4 2 1 4 2 1

在上面表格中,我们可以看出系统中针对三种类型进行权限分配,即文件所有者(自己)、文件所属组(家人)和其他用户(陌生人),文件操作权限又分为三种,读、写和执行,数字表示为八进制数,具备权限的八进制数分别为 421,不具备权限为 0

为了便于理解使用 Linux 命令 ls -al 来查目录中文件和文件夹的权限位。

$ls -al

-rw-r--r--    1 logan  staff    6060  8 31 09:57 project.js
drwxr-xr-x    4 logan  staff     128  9  3 15:24 public

开头十位字符含义如下:

  • 第一位d为文件夹,-为文件
  • 2-10位:代表当前用户、用户所属组和其他用户的权限位,按每三位划分,分别代表读(r)、写(w)和执行(x),- 代表没有当前位对应的权限。

三、标识位 flag

NodeJS 中,标识位代表着对文件的操作方式。

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

r+w+ 的区别: 当文件不存在时,r+ 不会创建文件,而会抛出异常,但 w+ 会创建文件;如果文件存在,r+ 不会自动清空文件,但 w+ 会自动把已有文件的内容清空。

四、文件描述符 fd

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

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

五、文件操作基本方法

1. 文件读取fs.readFile(path[, options], callback)

  • path: 文件名或文件描述符
  • options: 包括字符编码encoding和标识位flag(默认为r)
  • callback: 第一个参数为错误err,第二个参数为读取到的数据data,如果没有 encoding,内容为 Buffer,如果有按照传入的编码解析。
fs.readFile(path.resolve(__dirname, 'test.txt'), 'utf-8', (err, data) => {
  if (err) {
    return console.error(err)
  }
  // 输出:This is test.txt.
  console.log('读取文件成功(fs.readFile),读取内容为', data)
})

2. 文件写入fs.writeFile(file, data[, options], callback)

  • file: 文件名或文件描述符
  • data: 要写入的字符串或Buffer
  • options: 包括字符编码encoding、标识位flag(默认为w)、权限位mode(默认为0o666,可读写,不可执行)
  • callback: 参数为错误err
fs.writeFile(path.resolve(__dirname, 'test.txt'), 'This is written by fs.writeFile().', err => {
  if (err) {
    return console.error(err)
  }
  fs.readFile(path.resolve(__dirname, 'test.txt'), 'utf-8', (err, data) => {
    if (err) {
      return console.log(err)
    }
    console.log('写入文件成功(fs.writeFile),写入内容为', data)
  })
})

3. 文件追加写入fs.appendFile(path, data[, options], callback)

  • path: 文件名或文件描述符
  • data: 要追加写入的字符串或Buffer
  • options: 包括字符编码encoding、标识位flag(默认为a)、权限位mode(默认为0o666,可读写,不可执行)
  • callback: 参数为错误err
fs.appendFile(path.resolve(__dirname, 'test.txt'), '\nThis is written by fs.appendFile().', err => {
  if (err) {
    return console.error(err)
  }
  fs.readFile(path.resolve(__dirname, 'test.txt'), 'utf-8', (err, data) => {
    if (err) {
      return console.log(err)
    }
    // 输出:文件追加写入成功(fs.writeFile),当前内容为 This is origin content.This is written by fs.appendFile().
    console.log('文件追加写入成功(fs.writeFile),当前内容为', data)
  })
})

4. 文件拷贝写入fs.copyFile(src, dest[, flags], callback)

  • src: 要被拷贝的源文件名称
  • dest: 拷贝操作的目标文件名,如果 dest 已经存在会被覆盖。
  • flags: 拷贝操作修饰符 默认: 0.唯一支持的 flag 是 fs.constants.COPYFILE_EXCL ,如果 dest 已经存在,则会导致拷贝操作失败。
  • callback: 参数为错误err
fs.copyFile(path.resolve(__dirname, 'test.txt'), path.resolve(__dirname, 'copy.txt'), err => {
  if (err) {
    return console.error(err)
  }
  fs.readFile(path.resolve(__dirname, 'copy.txt'), 'utf-8', (err, data) => {
    if (err) {
      return console.log(err)
    }
    // 输出:文件拷贝写入成功(fs.writeFile),当前内容为 This is test.txt.
    console.log('文件拷贝写入成功(fs.writeFile),当前内容为', data)
  })
})

5. 删除文件fs.unlink(path, callback)

  • path - 要删除的文件路径
  • callback - 回调函数,只有一个参数,err,为错误信息
fs.unlink(path.resolve(__dirname, 'test.txt'), err => {
  if (err) {
    return console.error(err)
  }
  console.log('删除文件成功')
})

六、文件操作的高级方法

1. 打开文件fs.open(path, flags[, mode], callback)

  • path: 文件路径
  • flags: 标识位
  • mode: 权限位,默认0o666
  • callback: 第一个参数为错误err,第二个参数为文件描述符fd
fs.open(path.resolve(__dirname, 'test.txt'), 'r', (err, fd) => {
    if (err) {
        return console.error(err)
    }
    console.log('打开文件成功,文件描述符为:', fd)
})

2. 关闭文件fs.close(fd, callback)

  • fd - 通过 fs.open() 方法返回的文件描述符。
  • callback - 回调函数,有一个参数err, 为错误信息
fs.open(path.join(__dirname, 'test.txt'), 'r+', (err, fd) => {
    if (err) {
        return console.error(err)
    }
    console.log('文件打开成功(fs.open)')
    console.log('准备关闭文件(fs.close)')
    fs.close(fd, err => {
        if (err) {
            return console.error(err)
        }
        console.log('文件关闭成功(fs.close)')
    })
})

3. 读取文件fs.read(fd, buffer, offset, length, position, callback)

  • fd - 通过 fs.open() 方法返回的文件描述符。
  • buffer - 数据写入的Buffer。
  • offset - 整数,向 Buffer 写入的初始位置。
  • length - 整数,要从文件中读取的字节数。
  • position - 整数,文件读取的起始位置,如果 position 的值为 null,则会从当前文件指针的位置读取。
  • callback - 回调函数,有三个参数err, bytesRead, buffer,err 为错误信息, bytesRead 表示读取的字节数,buffer 为缓冲区对象。
// 数据写入的Buffer
let readBuf = Buffer.alloc(100)

fs.open(path.resolve(__dirname, 'test.txt'), 'r+', (err, fd) => {
    fs.read(fd, readBuf, 0, readBuf.length, 0, (err, bytesRead, buffer) => {
        console.log('文件读取成功(fs.read), 读取长度为:' + bytesRead +' 读取内容为:' + buffer.slice(0, bytesRead).toString())
    })
})

4. 同步磁盘缓存fs.fsync(fd, callback)

  • fd - 通过 fs.open() 方法返回的文件描述符。
  • callback - 回调函数,只有一个参数,err,为错误信息

在使用 fs.write 方法向文件写入数据时,由于不是一次性写入,所以最后一次fs.write写入后使用fs.fsync 方法同步磁盘缓存,强制将缓存中的内容写入到本地文件后再fs.close关闭文件。

5. 写入文件fs.write(fd, buffer[, offset[, length[, position]]], callback)

  • fd - 通过 fs.open() 方法返回的文件描述符。
  • buffer - 存储将要写入文件数据的 Buffer
  • offset - 整数,从 Buffer 读取数据的初始位置
  • length - 整数,读取 Buffer 数据的字节数
  • position - 整数,写入文件初始位置,如果 position 的值为 null,则数据从当前位置写入。
  • callback - 回调函数,有三个参数err、bytesWritten、buffer,err 为错误信息, bytesRead 表示写入的字节数,buffer 为缓冲区对象。
let buf = Buffer.from('你还要我怎样,要怎样,最后还不是像父亲把你原谅')
fs.open(path.resolve(__dirname, 'test.txt'), 'r+', (err, fd) => {
    fs.write(fd, 0, buf.length, 6, (err, bytesWritten, buffer) => {
        console.log(`写入${bytesWritten}字节,内容为:${buffer.toString()}`)
        // 同步磁盘缓存
        fs.fsync(fd, err => {
            // 关闭文件
            fs.close(fd, err => {
                console.log('关闭文件')
            })
        })
    })
})

6. 实现大文件复制函数

在 NodeJS 中进行文件操作,多次读取和写入时,一般一次读取数据大小最大为 64k,写入数据大小最大为 16k。

所以要实现复制大文件的功能,需要进行多次读取和多次写入,我们手动维护的下次读取位置和下次写入位置,如果参数 readedwrited 的位置传入 null,NodeJS 会自动帮我们维护这两个值。

function copy(src, dest, size = 16 * 1024, callback) {
  let n = 0
  // 以读模式打开源文件
  fs.open(src, 'r', (err, readFd) => {
    if (err) { return console.error(err) }
    // 以写模式打开目标文件
    fs.open(dest, 'w', (err, writeFd) => {
      if (err) { return console.error(err) }
      let buf = Buffer.alloc(size)
      let readPos = 0 // 下次读取的位置
      let writePos = 0 // 下次写入的位置

      ;(function next() {
        n++
        console.log(`第${n}次读取写入`)
        // 读取源文件
        fs.read(readFd, buf, 0, size, readPos, (err, bytesRead) => {
          // 如果读取不到内容,则关闭源文件
          if (!bytesRead) {
            fs.close(readFd, err => console.log('读取不到内容,关闭源文件'))
          } else {
            readPos += bytesRead // 更新下次读取的位置
            console.log('下次读取位置', readPos)
          }

          // 写入内容至目标文件
          fs.write(writeFd, buf, 0, bytesRead, writePos, (err, bytesWritten) => {
            // 如果没有内容了同步缓存,并关闭文件后执行回调,停止执行
            if (!bytesWritten) {
              fs.fsync(writeFd, err => {
                fs.close(writeFd, err => {
                  console.log('无写入内容,关闭目标文件')
                  callback(n - 1)
                })
              })
              return true
            }
            writePos += bytesWritten // 更新下次写入的位置
            console.log('下次写入位置', writePos)

            // 继续读取、写入
            next()
          })
        })
      })();
    })
  })
}

下面我们拷贝一个大小约为94kb的图片测试拷贝函数,一次写入16kb内容,预期6次写入后完成拷贝。

let src = path.resolve(__dirname, '1.jpg')
let dest = path.resolve(__dirname, '2.jpg')
let size = 16 * 1024 // 即16kb
let callback = n => {
    console.log(`经过${n}次写入后复制完成`)
}

copy(src, dest, size, callback)

// 第1次读取写入
// 下次读取位置 16384
// 下次写入位置 16384
// 第2次读取写入
// 下次读取位置 32768
// 下次写入位置 32768
// 第3次读取写入
// 下次读取位置 49152
// 下次写入位置 49152
// 第4次读取写入
// 下次读取位置 65536
// 下次写入位置 65536
// 第5次读取写入
// 下次读取位置 81920
// 下次写入位置 81920
// 第6次读取写入
// 下次读取位置 93256
// 下次写入位置 93256
// 第7次读取写入
// 读取不到内容,关闭源文件
// 无写入内容,关闭目标文件
// 经过6次写入后复制完成

从终端打印信息可知,第7次读取写入时已无内容,经过6次读取写入完成复制,符合预期。

七、文件夹操作方法

1. 查看文件或文件夹操作权限fs.access(path[, mode], callback)

  • path: 文件或文件夹路径
  • mode: 文件访问常亮,指定要执行的可访问性检查,默认为 fs.constants.F_OK
    • fs.constants.F_OK 该标志表明文件对于调用进程是可见的。
    • fs.constants.R_OK 该标志表明文件可被调用进程读取。
    • fs.constants.W_OK 该标志表明文件可被调用进程写入。
    • fs.constants.X_OK 该标志表明文件可被调用进程执行。
  • callback: 参数为错误err, 如果可访问性检查失败,则错误参数会是一个 Error 对象。
// 检查文件是否存在于当前目录,且是否可写。
fs.access('test.txt', fs.constants.F_OK | fs.constants.W_OK, (err) => {
  if (err) {
    console.error(`test.txt ${err.code === 'ENOENT' ? '不存在' : '只可读'}`)
  } else {
    console.log(`test.txt 存在,且可写`)
  }
})

2. 获取文件或文件夹的Stats对象fs.stat(path[, options], callback)

  • path: 文件或文件夹路径
  • options:
    • bigint: 在返回的fs.Stats 对象中的数字类型值是否为bigint. Default: false.
  • callback:
    • err: 可能的错误信息
    • stats: fs.Stats对象
fs.stat(path.resolve(__dirname, 'test.txt'), (err, stats) => {
  if (err) {
    return console.error(err)
  }
  console.log('test.txt的fs.Stats对象', stats)
})

3. 创建文件夹fs.mkdir(path[, options], callback)

  • path: 文件夹路径
  • options:
    • recursive: 是否递归,默认为false
    • mode 权限位,windows下不支持此配置。默认: 0o777.
  • callback:
    • err: 可能的错误信息
fs.mkdir(path.resolve(__dirname, 'dir'), err => {
  if (err) {
    return console.error(err)
  }
  console.log('创建文件夹成功')
})

4. 删除文件夹fs.rmdir(path, callback)

  • path: 文件夹路径
  • callback:
    • err: 可能的错误信息
fs.rmdir(path.resolve(__dirname, 'dir'), err => {
  if (err) {
    return console.error(err)
  }
  console.log('创建文件夹成功')
})

5. 读取文件夹fs.readdir(path[, options], callback)

  • path: 文件夹路径
  • options:
    • encoding: 字符编码,默认为utf-8
  • callback:
    • err: 可能的错误信息
    • files: 目录中不包括 ... 的文件名的数组(深度为1层)。

八、实现递归创建目录

在使用fs.mkdir或者fs.mkdirSync创建目录的时候,必须保证传入的路径除了最后一级目录,之前的目录都存在,否则会抛出异常。

我们来实现按照路径创建文件夹目录(不存在则创建)的一个函数

1. 递归创建目录 - 同步实现

function mkpathSync(dest) {
  // path.sep 文件路径分隔符(mac 与 window 不同)
  // 转变成数组,如 ['a', 'b', 'c']
  let parts = dest.split(path.sep);
  for(let i = 1; i <= parts.length; i++) {
    // 重新拼接成 a a/b a/b/c
    let current = path.join(__dirname, parts.slice(0, i).join(path.sep));

    // accessSync 路径不存在则抛出错误在 catch 中创建文件夹
    try {
      fs.accessSync(current)
    } catch(e) {
      fs.mkdirSync(current)
    }
  }
}

mkpathSync(path.join('a', 'b', 'c'))

2. 递归创建目录 - 异步实现

function mkpathAsync(dest) {
  let parts = dest.split(path.sep)
  let index = 1
  function next() {
    // 目录创建完毕时退出
    if (index > parts.length) {
      console.log('创建目录完毕')
      return
    }
    let current = path.join(__dirname, parts.slice(0, index).join(path.sep))
    index++
    // 如果路径检查成功说明已经有该文件目录,则继续创建下一级
    // 失败则创建目录,成功后递归 next 创建下一级
    fs.access(current, fs.constants.F_OK, err => {
      if (err) {
        fs.mkdir(current, next)        
      } else {
        next()
      }
    })
  }
  next()
}

mkpathAsync(path.join('a', 'b', 'c'))

3. 递归创建目录 - async/await实现

使用 async 函数中 await 等待的异步操作必须转换成 Promise,我们使用 util 模块下的 promisify 方法进行转换

promisify原理

function promisify(fn) {
  return function(...args) {
    return new Promise((resolve, reject) => {
      fn.call(null, ...args, (err, ...datas) => err ? reject(err) : resolve(datas))
    })
  }
}

递归创建文件目录 —— 异步 async/await

const util = require('util')

const access = util.promisify(fs.access)
const mkdir = util.promisify(fs.mkdir)

async function mkpath(dest) {
  let parts = dest.split(path.sep)
  console.log(parts)
  for (let i = 1; i <= parts.length; i++) {
    let current = path.join(__dirname, parts.slice(0, i).join(path.sep))
    try {
      await access(current)
    } catch (error) {
      console.log(error)
      await mkdir(current)
    }
  }
}

mkpath(path.join('a', 'b', 'c')).then(() => {
  console.log('创建目录完成')
})

九、常见系统错误

当使用fs.stat()等API时,如果发生了错误,回调内的err.code为错误码,常见错误码如下:

  • EACCES : (拒绝访问): 试图以被一个文件的访问权限禁止的方式访问一个文件。
  • EADDRINUSE : (地址已被使用): 试图绑定一个服务器(net、http 或 https)到本地地址,但因另一个本地系统的服务器已占用了该地址而导致失败。
  • ECONNREFUSED : (连接被拒绝): 无法连接,因为目标机器积极拒绝。 这通常是因为试图连接到外部主机上的废弃的服务。
  • ECONNRESET : (连接被重置): 一个连接被强行关闭。 这通常是因为连接到远程 socket 超时或重启。 常发生于 http 和 net 模块。
  • EEXIST : (文件已存在): 一个操作的目标文件已存在,而要求目标不存在。
  • EISDIR : (是一个目录): 一个操作要求一个文件,但给定的路径是一个目录。
  • EMFILE : (系统打开了太多文件): 已达到系统文件描述符允许的最大数量,且描述符的请求不能被满足直到至少关闭其中一个。 当一次并行打开多个文件时会发生这个错误,尤其是在进程的文件描述限制数量较低的操作系统(如 macOS)。 要解决这个限制,可在运行 Node.js 进程的同一 shell 中运行 ulimit -n 2048。
  • ENOENT : (无此文件或目录): 通常是由 fs 操作引起的,表明指定的路径不存在,即给定的路径找不到文件或目录。
  • ENOTDIR : (不是一个目录): 给定的路径虽然存在,但不是一个目录。 通常是由 fs.readdir 引起的。
  • ENOTEMPTY : (目录非空): 一个操作的目标是一个非空的目录,而要求的是一个空目录。 通常是由 fs.unlink 引起的。
  • EPERM : (操作不被允许): 试图执行一个需要更高权限的操作。
  • EPIPE : (管道损坏): 写入一个管道、socket 或 FIFO 时没有进程读取数据。 常见于 net 和 http 层,表明远端要写入的流已被关闭。
  • ETIMEDOUT : (操作超时): 一个连接或发送的请求失败,因为连接方在一段时间后没有做出合适的响应。 常见于 http 或 net。 往往标志着 socket.end() 没有被正确地调用。

十、总结

在 fs 所有模块都有同步异步两种实现,同步方法的特点就是阻塞代码,导致性能差,异步代码的特点就是回调函数嵌套多,在使用 fs 应尽量使用异步方式编程来保证性能,如果觉得回调函数嵌套不好维护,可以使用 Promise 和 async/await 的方式解决。

参考文章

Node中fs模块 API详解


留言

目录