文件 IO✅
本主题涵盖 Node.js 中文件系统操作的核心概念,包括文件读写、流式处理、路径操作、Buffer 以及错误处理等。
什么是 Stream ,有什么作用?
答案
核心概念:
- Stream 是按块增量处理数据的抽象,避免单次读取大量文件导致的内存峰值暴涨,通过字节流来实现高效处理。
- 流的类型
- Readable 只读流,用于单纯消费场景,例如 HTTP 请求体、文件读取等
- Writable 只写流,用于单纯写入场景,例如 HTTP 响应体、文件写入等
- Duplex 可读可写流,例如 TCP 套接字
- Transform 可在流动中转换数据(如压缩、转码)
- 背压(Backpressure)用于协调生产与消费速率,避免内存暴涨;Node 提供
highWaterMark、pause/resume、drain
等机制 - 管道(pipe、pipeline) 内置了对流的组合与错误处理支持,使得多个流可以方便地连接在一起。默认支持背压。
示例说明:
延伸阅读
- node 详细讲解流的使用
- stream api 流的 API 详解
- node stream 流的历史文档
什么是 Buffer?为什么在 Node.js 中需要它?
答案
核心概念
- 定义:
Buffer
是 Node.js 提供的一个全局类,用于处理二进制数据。它是一块在 V8 堆外部分配的固定大小的原始内存区域。 - 原因:JavaScript 语言本身没有高效处理二进制数据的机制。在 Node.js 中,处理 TCP 流、文件系统 I/O 等场景时,必须直接操作二进制数据流。
Buffer
的出现正是为了弥补这一不足。 - 特性:
Buffer
实例类似于整数数组,但其大小在创建后不能更改。- 与字符编码紧密相关,可以在
Buffer
和字符串之间进行转换(如Buffer.from('hello', 'utf8')
)。 Buffer
API 与 Uint8Array 兼容,可以互相转换。
注意 Buffer 使用的是 原始内存 不受 V8 垃圾回收机制的管理,因此在使用时需要注意内存的分配和释放。
示例说明
// 1. 从字符串创建 Buffer
const buf1 = Buffer.from('Hello, Buffer!', 'utf8')
console.log(buf1) // <Buffer 48 65 6c 6c 6f 2c 20 42 75 66 66 65 72 21>
console.log(buf1.toString('hex')) // 48656c6c6f2c2042756666657221
console.log(buf1.toString('utf8')) // Hello, Buffer!
// 2. 分配指定大小的 Buffer
const buf2 = Buffer.alloc(10) // 创建一个 10 字节并用 0 填充的 Buffer
console.log(buf2) // <Buffer 00 00 00 00 00 00 00 00 00 00>
// 3. 写入和读取 Buffer
buf2.write('abc')
console.log(buf2.toString('utf8')) // abc
// 4. Buffer 合并
const buf3 = Buffer.from('Node')
const buf4 = Buffer.from('.js')
const combinedBuf = Buffer.concat([buf3, buf4])
console.log(combinedBuf.toString()) // Node.js
延伸阅读
好的,完全理解。为了确保能够全面地考察候选人对 Node.js 文件系统的深入理解,我们需要补充一些涉及更底层操作和更复杂场景的问题。
以下是两个新增的、更具深度的问题,它们专注于文件描述符、状态信息和资源管理,并紧密结合实际应用场景。
在什么场景下需要直接使用文件描述符?如何确保句柄正确关闭?
答案
核心概念
- 文件描述符 (File Descriptor):在操作系统层面,当一个文件被打开时,系统会返回一个非负整数作为该文件的引用,即文件描述符。后续的读、写、关闭等操作都通过这个描述符进行,比直接操作文件路径更高效。
- 句柄 (Handle):在 Node.js 中提供了三种典型的获取句柄方式
fs.openSync()
:同步打开文件,返回一个文件描述符。fs.open()
:异步回调模式打开文件,返回一个文件描述符。fsPromises.open()
promise 对象, 也可直接通过import { open } from 'node:fs/promises'
引用 open
- 资源管理:文件描述符是有限的系统资源(可通过
ulimit -n
查看)。如果打开后不关闭,会导致句柄泄漏 (Handle Leak),最终耗尽资源使应用崩溃。因此,确保句柄被正确关闭至关重要。
实际应用场景
当你需要对文件进行更精细化的控制时,readFile
/writeFile
这种高级抽象就不够用了。以下场景需要使用文件描述符:
- 大文件分块读写:需要从文件的特定位置读取或写入指定长度的数据,例如实现断点续传或编辑大型二进制文件。
- 保证写入原子性:在多进程或多线程环境下,需要确保对文件的写入操作不被其他操作中断。虽然 Node.js 单线程模型避免了大部分并发问题,但在与外部系统交互或使用 worker threads 时仍需考虑。
- 文件锁定:在写入文件前锁定它,防止其他进程同时写入,确保数据一致性。(注意:Node.js 核心
fs
模块没有内置文件锁定,通常需要借助第三方库如proper-lockfile
,但其底层原理仍依赖于文件描述符)。 - 持续写入日志:对于一个长期运行的服务,打开一个日志文件并持续向其追加(append)内容,避免了每次写入都重新打开文件的开销。
示例说明:可靠地向文件追加内容
使用 fs.promises.open
和 try...finally
结构是现代 Node.js 中管理文件句柄的最佳实践。finally
代码块可以保证无论 try
块中是否发生错误,handle.close()
都会被执行。
延伸阅读
如何获取文件元信息?fs.stat
和 fs.access
的区别是什么?
答案
fs.stat
:用于获取一个路径的详细元信息(metadata)。如果路径不存在,它会抛出错误。fs.access
:用于检查当前进程对一个路径是否具有特定的操作权限(如读、写、执行)。它不返回元信息,如果检查失败(如权限不足或路径不存在),会抛出错误。
API | 目的 | 返回值 (成功时) | 核心场景 | 常见陷阱 |
---|---|---|---|---|
fs.stat(path) | 获取文件/目录的状态信息 | 一个 fs.Stats 对象,包含大小、创建/修改时间、是否为文件/目录等。 | 读取文件属性(如 mtime 判断缓存是否过期),遍历目录时区分文件类型。 | 如果路径不存在,会抛出异常。 |
fs.lstat(path) | 类似 fs.stat ,但用于符号链接 (symlink) 本身 | 一个 fs.Stats 对象,描述的是符号链接的信息,而不是它指向的目标。 | 需要处理符号链接的场景,避免穿透到目标文件。 | 同 fs.stat 。 |
fs.access(path, mode) | 检查访问权限 | undefined (仅表示操作成功) | 在执行敏感操作前预检权限。 | 存在 TOCTOU 竞争条件 (见下文),不推荐用于 open/read/write 之前。 |
关键区别:TOCTOU 竞争条件
不应该使用 fs.access()
来检查文件的可读写性,然后再调用 fs.open()
或 fs.readFile()
。这会产生一个典型的“检查时-使用时”(Time-of-check to time-of-use, TOCTOU) 的竞态条件。也就是说,在你检查权限和实际操作文件之间的短暂间隙,文件状态可能已经发生了改变(例如被另一个进程删除或修改了权限)。
正确做法是:直接尝试操作,并通过 try...catch
处理可能出现的错误。
示例说明
- fs.stat 使用
- fs.access 错误用法
- fs.access 正确思想
import { promises as fs } from 'node:fs'
import path from 'node:path'
async function logFileStats (filePath) {
try {
const stats = await fs.stat(filePath)
console.log(`Stats for ${filePath}:`)
console.log(` - Is it a file? ${stats.isFile()}`)
console.log(` - Is it a directory? ${stats.isDirectory()}`)
console.log(` - Size: ${stats.size} bytes`)
console.log(` - Last modified: ${stats.mtime.toISOString()}`)
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`File not found: ${filePath}`)
} else {
console.error('An error occurred:', error)
}
}
}
// 准备一个文件
fs.writeFile('./my-file.txt', 'hello world')
logFileStats('./my-file.txt')
logFileStats('./non-existent-file.txt')
import { promises as fs, constants } from 'node:fs'
const filePath = './my-file.txt'
// 错误示范:Check-then-act (有 TOCTOU 风险)
async function wrongReadFile (file) {
try {
await fs.access(file, constants.R_OK)
// 在 access 和 readFile 之间,文件可能被删除或修改权限
console.log('(Wrong) File is readable, proceeding to read...')
const content = await fs.readFile(file, 'utf8')
console.log(content)
} catch (error) {
console.error('(Wrong) Could not read file:', error.message)
}
}
wrongReadFile(filePath)
import { promises as fs } from 'node:fs';
const filePath = './my-file.txt';
// 正确示范:Try-to-act (直接操作并处理错误)
async function correctReadFile(file) {
try {
const content = await fs.readFile(file, 'utf8');
console.log('(Correct) File content:', content);
} catch (error) {
// 统一处理所有可能的错误:文件不存在、无权限等
console.error(`(Correct) Failed to read file:`, error.message);
}
}
correctReadFile(filePath);
延伸阅读
readFile
、readFileSync
和 promises.readFile
有何区别?
答案
API | I/O 模型 | 执行方式 | 错误处理 | 适用场景 |
---|---|---|---|---|
readFileSync | 阻塞 I/O | 同步执行,阻塞事件循环 | try...catch | 命令行工具、应用启动时读取配置文件 |
readFile | 非阻塞 I/O | 异步执行,不阻塞事件循环 | 回调函数第一个参数 (err ) | 高并发、I/O 密集型应用 |
promises.readFile | 非阻塞 I/O | 返回 Promise,异步执行 | Promise.catch 或 async/await 中的 try...catch | 现代异步编程,推荐使用 |
核心概念
- 同步 vs 异步:
readFileSync
会阻塞 Node.js 的事件循环,直到文件读取完成,这在高并发场景下会严重影响性能。而readFile
和promises.readFile
是异步的,它们会将 I/O 操作交给底层系统处理,不会阻塞事件循环。 - 回调 vs Promise:
readFile
是 Node.js 传统的异步回调风格(CPS,Continuation-Passing Style)。而fs/promises
模块提供了基于 Promise 的 API,更易于与async/await
结合使用,避免了回调地狱,是现代 Node.js 开发的首选。
示例说明
用过 readline 吗, 解决什么问题?
Buffer alloc 和 allocUnsafe 有什么区别?
答案
核心概念:
- Buffer.alloc 会将新分配内存清零(全 0),更安全但略慢;适合默认场景
- Buffer.allocUnsafe 返回未初始化的内存块,速度更快,但读取前必须先完全写满或手动 fill
- 未初始化意味着可能读到旧内存残留数据,存在信息泄露与逻辑不确定性风险
- 若需大块且避开内部池,也可用 allocUnsafeSlow;通常很少需要
示例说明:
延伸阅读:
- Node.js: Buffer — alloc/allocUnsafe 语义、参数与注意事项
- Node.js: Buffer pooling — 内部内存池与性能影响
- Node.js: Security best practices — 安全与内存初始化的实务建议
path.join
和 path.resolve
有什么区别?
答案
方法 | 功能描述 | 关键行为 |
---|---|---|
path.join([...paths]) | 将所有给定的路径片段连接在一起,然后规范化生成的路径。 | 遇到 .. 会解析上一级目录,但不会生成绝对路径(除非传入的片段本身就能构成绝对路径)。 |
path.resolve([...paths]) | 将路径或路径片段的序列解析为绝对路径。 | 从右到左处理路径序列,直到构造出绝对路径。如果处理完所有路径还未生成绝对路径,则使用当前工作目录。 |
核心概念
path.join
主要用于拼接路径,它像cd
命令一样,会处理.
和..
,但最终结果是相对路径还是绝对路径取决于输入。在不同系统下统一使用path.join
来拼接避免路径分隔符不一致的问题。path.resolve
主要用于解析得到一个绝对路径。它总是返回一个从根目录开始的完整路径,非常适合用于定位文件。存在多个目录时,path.resolve
会优先考虑从最右侧开始的路径结合,如果遇到绝对路径,会忽略之前的所有路径。
示例说明
import path from 'node:path'
// --- path.join ---
// 拼接普通路径
console.log('join 1:', path.join('/foo', 'bar', 'baz/asdf', 'quux', '..'))
// 输出: /foo/bar/baz/asdf
// 拼接相对路径
console.log('join 2:', path.join('foo', {}, 'bar'))
// 抛出 TypeError: Path must be a string. Received {}
// --- path.resolve ---
// 不带参数,返回当前工作目录
console.log('resolve 1:', path.resolve())
// 输出: /path/to/current/working/directory
// 带相对路径
console.log('resolve 2:', path.resolve('tmp', 'file.txt'))
// 输出: /path/to/current/working/directory/tmp/file.txt
// 如果遇到绝对路径,会忽略之前的所有路径
console.log('resolve 3:', path.resolve('/foo/bar', './baz'))
// 输出: /foo/bar/baz
console.log('resolve 4:', path.resolve('/foo/bar', '/tmp/file/'))
// 输出: /tmp/file
面试官视角
这是一个非常高频的实践性问题,用于考察面试者在处理文件路径时的严谨性和对常用工具的熟悉度。
- 良:能说出
join
是拼接,resolve
是生成绝对路径。 - 优:能清晰地解释两者在处理
/
(根路径)和..
时的不同行为,并能给出在什么场景下应该使用哪一个(例如,拼接项目内相对路径用join
,需要文件完整绝对路径时用resolve
)。
延伸阅读