一、概述
- 在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 |
在上面表格中,我们可以看出系统中针对三种类型进行权限分配,即文件所有者(自己)、文件所属组(家人)和其他用户(陌生人),文件操作权限又分为三种,读、写和执行,数字表示为八进制数,具备权限的八进制数分别为 4
、2
、1
,不具备权限为 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。
所以要实现复制大文件的功能,需要进行多次读取和多次写入,我们手动维护的下次读取位置和下次写入位置,如果参数 readed
和 writed
的位置传入 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 的方式解决。
参考文章