ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node.js异步写入实战:Express接口中回调、Promise与流式写法对比

Node.js异步写入实战:Express接口中回调、Promise与流式写法对比 Node.js 的异步写入看起来只是fs.writeFile一行调用的事可真正放进 Express.js 接口里你会碰到回调嵌套、错误丢失、并发覆盖、大文件撑爆内存这些实际问题。这篇教程专门拆三种写法回调风格、Promise 风格、流式写入全部放在 Express 路由场景里讲目标是让刚开始写 Node 接口的人理解“为什么有这么多写法”以及接到需求时应该选哪一种。先给结论日常接口优先用fs.promises.writeFile配合async/await简单一次性写入可以用回调大文件上传、日志落盘、代理转发这类持续写入场景要用流式。三个方向对应不同代价没有哪个最好只有哪个更合适。后面我会从最基础的阻塞问题开始搭一个最小的 Express 测试项目把三种写法逐个跑一遍最后再补上路径、权限、并发、报错排查这几个最容易踩坑的点。1. 先理解为什么 Express 接口里不能随便用同步写入1.1 同步写入会让事件循环直接堵住Node.js 是单线程事件循环模型。所谓“单线程”不是说底层只能用一颗 CPU而是 JS 代码在执行过程中同一时间只处理一个任务。文件写入这个动作如果走同步 API比如fs.writeFileSync整个事件循环会停在写入这一步等磁盘落盘完成才继续处理后面的代码。在本地写一个小文件速度很快几百毫秒内完成你几乎感觉不到阻塞。但生产环境里一个接口同时收到大量请求某个请求触发了比较大的写入这个写文件的过程就会让其他所有请求排队。用户看到的直接表现是接口变慢严重时连接堆积超时、宕机都可能出现。const fs require(fs); app.post(/save-sync, (req, res) { // 不推荐写入期间事件循环被阻塞 fs.writeFileSync(path.join(DATA_DIR, sync.txt), JSON.stringify(req.body)); res.json({ ok: true }); });这段代码在本地能跑但它把“写入中”的时间全部压在了服务进程上。只要并发上来问题就会被放大。练习时可以用生产环境不建议。1.2 异步写入解决的是“不阻塞”不是“更快落盘”异步写入调用之后马上返回真正写盘的动作交给操作系统在后台处理完成之后通过回调、Promise 或者流事件来通知你。对服务进程来说写入期间它还在继续处理其他请求这是异步方案最重要的价值。但异步也有代价你需要自己处理“写入完成”和“写入失败”这两个结果。同步写法里异常直接抛出你一眼能看到异步写法里如果忘了处理错误回调或者忘了await失败信息可能被悄悄吞掉接口返回成功文件却没写成。这种问题比同步阻塞更难排查。所以下文三种写法本质都是在不同 API 风格下解决同一件事告诉 Node 你什么时候写完、写失败了怎么办。2. 搭一个最小的 Express 测试环境2.1 Node.js 版本和依赖准备在开始之前先确认本机 Node.js 版本。命令行执行node -v npm -v推荐使用 LTS 版本。三种写法对版本的要求不太一样回调式fs.writeFile从最早的 Node 就有Promise 式需要fs.promises这个 API 从 Node 10 开始可用建议至少 Node 14 以上流式写入fs.createWriteStream同样是很早就有的 API。如果你机器上装了多个 Node 版本或者还没装好可以用 nvm 管理版本。很多人在安装 Node.js、切换高低版本时踩坑比如nvm install下载失败、版本号写错、装完在终端里执行node -v没反应。这种情况先确认 nvm 是否生效再确认终端是否重启过最后再看下载源是否顺畅一步步排查不要一开始就怀疑代码。新项目初始化npm init -y npm install express练习项目不需要额外依赖装完 express 就可以开始。2.2 创建基础路由和目录结构在项目根目录建app.js代码如下const express require(express); const fs require(fs); const path require(path); const app express(); app.use(express.json()); const DATA_DIR path.join(__dirname, data); app.post(/save, (req, res) { res.json({ ok: true }); }); app.listen(3000, () { console.log(server running at http://localhost:3000); });这里先不写任何文件写入只把路由跑通。注意DATA_DIR用path.join(__dirname, data)拼出来__dirname是当前文件所在目录这样无论从哪里启动服务目录都不会跑偏。启动服务node app.js再开一个终端测试curl -X POST http://localhost:3000/save \ -H Content-Type: application/json \ -d {name:node}如果返回{ok:true}说明环境没问题。接下来三种写法都直接替换/save这个回调里的内容。3. 回调风格最基础但嵌套一多就乱3.1 一次写入的回调写法把/save路由改成回调式写入app.post(/save, (req, res) { const filePath path.join(DATA_DIR, callback.txt); const content JSON.stringify(req.body, null, 2); fs.writeFile(filePath, content, (err) { if (err) { console.error(写入失败:, err); return res.status(500).json({ ok: false, message: 写入失败 }); } console.log(写入完成); res.json({ ok: true }); }); });fs.writeFile的签名是fs.writeFile(path, data, options, callback)。不传 options 时默认用utf8编码、w模式覆盖写入。回调只有一个参数err写入成功时它是null。这种写法在语法上最直白也不需要额外引入任何东西很多老项目里都能看到。但要注意一点回调里的return很重要。如果不写 returnerr分支执行完后函数还会继续往下走可能再次调用res.json导致 “Cannot set headers after they are sent to the client” 这类二次响应报错。3.2 为什么业务接口里不建议写多层回调一次写入用回调还可以如果连续做多个文件操作问题就来了。比如先写文件 A再往文件 B 追加内容最后把 B 改名为 C用回调写出来是下面这样fs.writeFile(fileA, data, (err) { if (err) return handleError(err); fs.appendFile(fileB, moreData, (err) { if (err) return handleError(err); fs.rename(fileB, fileC, (err) { // 到这里已经三层了 }); }); });这就是经典的“回调地狱”。缩进越来越深每一层都要判断错误少写一个return就可能提前执行后续逻辑。更重要的是Express 路由本身的异常处理机制对这种嵌套不友好一旦回调内部抛出未捕获异常外层拿不到排查时要翻很多层日志。我的建议是回调风格可以学用来理解 Node 早期异步模型但在 Express 业务接口里尽量少用。除非你只是在某个独立工具脚本里做一次简单写入否则优先用 Promise。4. Promise 风格日常接口最推荐4.1 使用 fs.promises.writeFile 配合 async/awaitNode 的fs模块从很早的版本开始就提供了 Promise 形式的 API通过fs.promises访问。推荐写法是解构出来const { promises: fsp } require(fs); app.post(/save, async (req, res) { try { const filePath path.join(DATA_DIR, promise.txt); await fsp.writeFile(filePath, JSON.stringify(req.body, null, 2), utf8); console.log(写入完成); res.json({ ok: true }); } catch (err) { console.error(写入失败:, err); res.status(500).json({ ok: false, message: 写入失败 }); } });fsp.writeFile返回一个 Promiseawait之后写入成功才继续往下一行走写入失败会抛出异常被catch接住。相比回调代码顺序和人的直觉一致没有缩进地狱错误处理也集中在一个地方。async关键字让路由函数变成异步函数Express 4 和 Express 5 都支持这种写法。注意Express 4 里异步函数内部如果抛出异常不会自动进入错误中间件所以必须自己用try/catch包住。如果你不想在每一个路由里都写 try/catch可以封装一个工具函数const wrap (fn) (req, res, next) Promise.resolve(fn(req, res, next)).catch(next);路由变成app.post(/save, wrap(async (req, res) { await fsp.writeFile(filePath, content, utf8); res.json({ ok: true }); }));然后通过统一的错误中间件处理app.use((err, req, res, next) { console.error(统一错误处理:, err); res.status(500).json({ ok: false, message: 服务器内部错误 }); });这种方式把路由代码压缩到最简洁又不丢错误信息。4.2 先创建目录再写入很多人第一次用fsp.writeFile时明明代码没问题却报ENOENT: no such file or directory。原因不是文件名写错而是目标目录不存在。writeFile不会自动创建目录父目录缺失就直接报错。解决办法是先确保目录存在const filePath path.join(DATA_DIR, promise.txt); await fsp.mkdir(path.dirname(filePath), { recursive: true }); await fsp.writeFile(filePath, content, utf8);mkdir的recursive: true表示目录不存在就一级一级创建已经存在也不会报错。这是实际项目里很常用的防护写法尤其当接口路径可能动态变化时先建目录再写文件能少排查很多问题。4.3 追加写入用 appendFile如果需求是往一个文件后面追加内容而不是覆盖整个文件用fsp.appendFileawait fsp.appendFile( path.join(DATA_DIR, access.log), ${new Date().toISOString()} ${req.ip} ${req.method} ${req.path}\n, utf8 );appendFile的底层实现相当于writeFile加flag: a文件不存在会创建存在就追加。做日志记录、审计流水这类需求很合适。5. 流式写入大文件和持续写入的正解5.1 createWriteStream 与 writeFile 的本质区别writeFile是一次性把整段数据交给文件系统写入期间整份文件内容都存在于内存里。文件小还没什么比如几十 KB、几百 KB如果文件上 GB一次性写入不仅占用大量内存还可能出现性能抖动。流式写入用fs.createWriteStream创建一个可写流数据分块写入。Node 内部有背压机制写入速度快于磁盘处理能力时会暂时暂停读取避免内存无限增长。对超大文件、大文件上传、持续生成日志这类场景这是更稳的选择。5.2 在 Express 路由里用流式接收请求体并落盘Express 的req本身就是一个可读流。最直接的流式写法是把请求体管道到可写流app.post(/upload, (req, res) { const filePath path.join(DATA_DIR, stream-${Date.now()}.txt); const ws fs.createWriteStream(filePath); ws.on(finish, () { console.log(流式写入完成); if (!res.headersSent) { res.json({ ok: true, file: path.basename(filePath) }); } }); ws.on(error, (err) { console.error(流式写入失败:, err); if (!res.headersSent) { res.status(500).json({ ok: false, message: 写入失败 }); } }); req.pipe(ws); });这里要重点解释finish事件和error事件finish在数据全部写入后触发表示写入完成。error在写入过程中出错时触发比如磁盘满、权限不足。两个事件里都检查res.headersSent防止响应已经发送后又试图第二次发送。req.pipe(ws)会自己处理数据流动和背压你不需要手动控制读取暂停和恢复。如果需要在处理完之后返回其他信息可以在finish里读取文件大小或统计信息。5.3 什么时候不该用流式流式虽然稳定但也有代价代码比 Promise 版本长事件回调里的逻辑要更小心生命周期也更复杂。如果只是往一个文件里写一段 JSON比如几十 KB 的配置、小请求体强行用流式没有必要。我判断的标准很简单数据量在几 MB 以内且是一次性写入用fsp.writeFile数据量很大或者数据是持续到达的比如上传下载、日志管道、代理转发用流式。此外用流式写入时要注意进程退出问题。如果服务在写入过程中被强制终止流还没写完文件可能不完整。生产环境中关停服务前要确保流已经结束或者把未写完整的数据标记出来方便后续恢复。6. 三种写法怎么选场景、参数和并发6.1 直接看对照表写法适用场景代码量错误处理内存占用推荐程度回调式 writeFile独立脚本、简单单次写入少容易漏整段数据在内存学习用Promise 式 writeFileExpress 接口、业务落盘、小文件中try/catch 清晰整段数据在内存日常首选流式 createWriteStream大文件上传、日志、持续写入稍多事件监听分块占用低大文件场景实际项目里三种不是互斥关系。比如一个 Express 上传接口先用流式把文件落盘再用fsp.appendFile写一条上传日志最后用fsp.writeFile生成一个 JSON 响应摘要。不同步骤选不同工具没有任何冲突。6.2 常用 flags 和编码参数writeFile第三个参数可以传 options常见选项参数可选值含义encodingutf8、base64、binary文件编码flagw、a、wx、ax写入模式w是默认值覆盖写入a是追加写入wx是文件不存在才写已存在就报错。最后这个在“不允许覆盖已有文件”的场景很有用比如生成一次性密钥、防止重复提交覆盖记录。6.3 并发写入同一个文件要注意什么这是很多人在单机测试中不会遇到、上了生产才头疼的问题。多个请求同时写同一个文件结果不可预测多个writeFile同时写同一个路径最后一次写入覆盖前面的内容。多个appendFile同时追加可能出现内容交错。一个读一个写可能读到半个文件。如果只是记录访问日志并发没那么严格appendFile够用但要注意行内容要完整避免单行被拆开。如果是在生成报表、任务结果这类“每个请求应该写到独立文件”的场景建议用唯一文件名const fileName result-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json;如果多个请求必须写入同一个文件并且要求数据不能交错、顺序必须一致那就要自己加队列或者用文件锁甚至换数据库。不要指望 Node 的异步文件 API 自动解决并发冲突它没有这个能力。7. 常见报错与排查思路7.1 先看这几个错误码文件写入出问题时错误信息里的code字段最有价值。常见的有错误码含义排查方向ENOENT文件或目录不存在先确认父目录是否存在用 mkdir recursive 创建EACCES权限不足检查运行用户对写入目录是否有写权限EISDIR路径指向的是目录确认文件路径不是目录名EEXIST文件已存在检查是否误用了 wx 标志ENOSPC磁盘空间不足检查磁盘容量实际遇到最多的是ENOENT。我一般会先打印出完整路径人工确认这个目录到底在不在。很多时候不是 Node 的问题是路径拼接错了或者目录在部署时没有创建。7.2 写入没反应、日志没输出如果接口返回成功但文件里没有内容或者根本没有文件生成按这个顺序查确认路由是否真的被触发。看接口日志或打印一行console.log。确认写入代码是否真的执行到。如果回调里的日志没打印说明回调没触发。确认错误是否被吞掉。回调式写法里忘记检查err时错误不会报出来Promise 式写法里没写 catch 时异常可能被静默丢弃。确认写入路径和当前工作目录。用绝对路径最稳妥。确认是否被其他逻辑覆盖。比如先写后删或者重复启动时清空目录。排查时不要一上来就怀疑 Node 或 Express。先用简单的fsp.writeFile(/tmp/test.txt, hello)单独测一次单独测能写说明环境没问题问题在业务代码单独测也报错那才需要看权限、磁盘和路径。7.3 路径不要凭感觉写在 Express 项目里最容易被忽略的路径坑有两个。第一个是相对路径。fs.writeFile(data.txt, ...)里的data.txt是相对当前工作目录解析的而当前工作目录取决于你启动服务时所在的目录。同一个项目在根目录启动和进入子目录启动可能写到不同地方。解决办法是始终用path.join(__dirname, ...)基于代码文件位置拼绝对路径。第二个是文件名。如果文件名为用户上传的原始文件名要过滤路径分隔符和危险字符防止路径穿越。这个在公共服务里尤其重要不要直接信任前端传过来的文件名。7.4 失败重试要考虑“部分写入”流式写入如果中途失败文件里可能已经有部分内容。下次启动服务或下次写入时要判断这个文件是否完整。简单做法是写入临时文件完全成功后改名到正式文件名。rename的原子性比写入过程好很多能避免读到半个文件。如果用writeFile一次写入的原子程度更高但也不是绝对可靠。对外输出重要文件时先写.tmp再rename是更稳的习惯const tmpPath filePath .tmp; await fsp.writeFile(tmpPath, content, utf8); await fsp.rename(tmpPath, filePath);这样即使写入过程中进程崩溃正式目录里最多缺这个文件不会出现一个内容残缺的半成品。8. 落到实际项目时建议的落地顺序8.1 新手练习按三个台阶往前走如果你是刚开始接触 Node 异步写入不要跳到最复杂的方案里。先走第一步把回调式写法手写一遍理解“传入的函数会在写入完成后被调用”这个模型。这一步不需要写 Express直接在脚本里调fs.writeFile就可以。第二步把同一个写入放到 Express 路由里改成fs.promises.writeFileasync/await并补上try/catch。这一步要验证两件事写入成功后接口返回正常写入失败后接口能返回 500 并打日志。把这两件事跑通日常业务接口文件写入就算过关了。第三步做一次大文件上传或模拟持续写入日志把流式写法接进来。重点观察两个东西内存占用是否稳定finish和error事件是否都能正确触发响应。这个流程走完三种写法的适用边界基本就清楚了。8.2 生产项目先把目录、错误处理和文件命名规则定下来真正落到生产时我建议先定规则再写业务逻辑。这里的规则包括四个部分第一是目录规则。统一用一个DATA_DIR常量必要时启动时调用mkdir确保目录存在。部署环境里还要注意目录权限不要为了省事把目录放在无写权限的
返回列表