尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Node.js异步写入文件:Express日志场景下fs/promises与async/await实践

Node.js异步写入文件:Express日志场景下fs/promises与async/await实践 看到“5分钟学编程”这个标题我知道你在期待什么不想看长篇大论要的是“快点讲完、马上能跑、最好还能避开坑”。这篇是《5分钟学编程 · Express.js篇》系列的第 11 篇主题是 Node.js 里最不起眼、却最容易写错的一类操作——异步写入文件。先给结论在 Express 项目里写日志、导出数据、落盘临时文件首选fs/promises搭配async/await的写法。它不是性能上最快的却是可读性、错误处理、工程维护成本综合起来最稳的。如果你正在用fs.writeFileSync在接口里写日志那这篇文章就是写给你的。读完你会搞明白三件事为什么同步写入在 Node.js 里很危险回调、Promise、async/await 三种异步写法到底有什么区别以及在一个真实的 Express 请求日志场景里怎样把代码写得既正确又容易维护。1. 为什么“写文件”值得单独写一篇很多新手第一次用 Express 写业务接口时会遇到类似的场景用户请求某个接口服务端要把请求时间、路径、状态码记录下来方便后面排查问题。第一反应往往是const fs require(fs); fs.writeFileSync(access.log, some content, utf8);这段代码在本地跑一次一切正常日志也写进去了。于是你觉得文件写入就是这么简单。问题出在什么时候出在流量变大以后。Node.js 是单线程事件循环模型。JavaScript 代码在同一个线程上排队执行而writeFileSync是同步阻塞操作。只要它开始写文件事件循环就会被卡住后续所有到达的请求都要排队等它写完。在普通性能的笔记本硬盘或者容器环境里单个文件写入耗时可能从几毫秒到几十毫秒不等。看起来不多但几十个请求同时触发日志写入时累积的阻塞会让接口响应时间显著上升甚至出现请求超时。所以Node.js 里写文件这种 I/O 操作应该优先走异步路径。异步写入的本质是发起写入请求后不占用当前执行线程等系统完成写入后再通过回调、Promise 或者 async/await 的方式收到结果。这样事件循环就不会被磁盘操作拖住服务端就能继续处理其他请求。这也是为什么“写入”值得单独讲一篇。不是因为它复杂而是因为它夹在“看似简单”和“一不留神就写错”之间。2. 先理清回调、Promise、async/await 到底在解决什么在 Node.js 中异步编程经历了三个阶段对应三种主流写法。它们底层都是同一套异步 I/O 机制差别在于代码组织和错误处理的方式。2.1 回调风格Callback这是 Node.js 早期最原始的异步写法。fs.writeFile、fs.appendFile都支持这种风格最后一个参数传入一个函数文件操作完成后Node 会调用这个函数并通过第一个参数传递错误对象。const fs require(fs); fs.appendFile(access.log, hello\n, utf8, (err) { if (err) { console.error(写入失败:, err); return; } console.log(写入成功); });回调风格的问题在于一旦多个异步操作有依赖关系就要在回调里嵌套回调代码层级越来越深形成“回调地狱”。比如“先创建目录再创建文件再写入内容”三层嵌套就已经很难读了。2.2 Promise 风格fs.promises从 Node.js 10 开始fs模块提供了fs.promisesAPI把异步操作封装成 Promise 对象。写法变成了链式调用const fs require(fs/promises); fs.appendFile(access.log, hello\n, utf8) .then(() console.log(写入成功)) .catch((err) console.error(写入失败:, err));Promise 解决了两件事一是通过.then()和.catch()让错误处理不再依赖回调参数二是配合Promise.all、Promise.race可以组合多个异步操作。但它依然不是最自然的阅读方式尤其当逻辑分支变多时链式调用也会变得冗长。2.3 async/await 风格async/await是 Promise 的语法糖让异步代码写起来像同步代码const fs require(fs/promises); async function writeLog() { try { await fs.appendFile(access.log, hello\n, utf8); console.log(写入成功); } catch (err) { console.error(写入失败:, err); } }这个写法的好处是没有嵌套、没有链式调用代码的执行顺序就是书写顺序。try/catch和同步代码的错误处理方式一致心智负担最低。回到文件写入的场景结论很明确新代码优先使用fs/promises加async/await。回调风格在维护老项目时还需要看懂但新项目没必要再写。我整理了一个简单的对比表方便你做技术选型时参考写法代码结构错误处理嵌套深度适合场景回调函数参数传入回调回调的第一个参数是 error深容易形成回调地狱老代码、短小的单次操作Promise链式调用.then().catch().catch()统一捕获中等可组合多个操作中等复杂度异步流程async/await同步代码风格try/catch浅接近同步代码新项目首选复杂流程首选你不需要把三种写法都背下来但至少要能读懂老代码里的回调然后知道用 async/await 重写它。3. 环境准备与项目初始化动手前先把环境准备好。到这里假设你已经安装了 Node.js 和 npm。如果还没装建议优先使用官方安装包或 nvm 这类版本管理工具安装 LTS 版本这样既能避免 PATH 配置问题后期也能在多个 Node.js 版本之间快速切换。版本方面没有太严格的要求。fs/promises从 Node.js 14 开始已经稳定可用本文示例在 Node.js 18 环境下运行都没有问题。如果你用的版本低于 14请先升级环境。先创建项目目录并初始化mkdir express-async-write-demo cd express-async-write-demo npm init -y接着安装 Expressnpm install express安装完成后目录结构大概是这样的express-async-write-demo/ ├── package.json ├── node_modules/ ├── server.js ├── callback-demo.js ├── promise-demo.js ├── sync-demo.js └── logs/后面我会逐个创建server.js、callback-demo.js、promise-demo.js和sync-demo.js。如果你只是想看核心写法直接看第 4 节如果你想在一个完整的 Express 场景里验证效果直接看第 5 节。4. 三种异步写入写法与核心代码这一节是最核心的部分。我们统一使用appendFile来演示因为它会追加内容到文件末尾不会覆盖已有内容这个行为更符合日志写入场景。注意fs.writeFile默认会覆盖整个文件如果你用它写日志第二次请求就会把第一次记录冲掉。后面“常见问题”里我会详细讲这个坑。4.1 写法一回调风格文件路径callback-demo.jsconst fs require(fs); const path require(path); const logPath path.join(__dirname, logs, access-callback.log); function appendLogCallback(line, callback) { fs.appendFile(logPath, line \n, utf8, (err) { if (err) { console.error(写入日志失败:, err); return callback(err); } callback(null); }); } // 调用示例 appendLogCallback(Hello Callback Log, (err) { if (err) { console.error(调用失败:, err); return; } console.log(回调风格写入完成); });这段代码里有一个习惯需要保持回调函数的第一参数永远约定为err。没有错误时err是null否则就是 Error 对象。这是 Node.js 早期 API 的通用约定你在很多老项目中都会看到这种写法。4.2 写法二Promise 链式风格文件路径promise-demo.jsconst fs require(fs/promises); const path require(path); const logPath path.join(__dirname, logs, access-promise.log); function appendLogPromise(line) { return fs.appendFile(logPath, line \n, utf8) .then(() { console.log(Promise 风格写入完成); }) .catch((err) { console.error(写入日志失败:, err); }); } // 调用示例 appendLogPromise(Hello Promise Log);这种写法比回调容易读一些then里的代码代表成功路径catch里的代码代表失败路径不需要再自己判断err是否为null。如果你要在多个异步操作之间做组合Promise 的优势会更明显。比如同时向两个日志文件写入Promise.all([ fs.appendFile(logPathA, 内容A\n, utf8), fs.appendFile(logPathB, 内容B\n, utf8) ]).then(() { console.log(两个文件都写完了); }).catch((err) { console.error(至少有一个文件写入失败:, err); });4.3 写法三async/await 风格推荐文件路径async-demo.js或者直接写在 Express 的server.js里。const fs require(fs/promises); const path require(path); const logPath path.join(__dirname, logs, access-async.log); async function appendLogAsync(line) { try { await fs.appendFile(logPath, line \n, utf8); } catch (err) { console.error(写入日志失败:, err); } } // 调用示例 appendLogAsync(Hello Async Log);如果你认真对比一下就会发现async/await 版本在逻辑上就是“同步代码 awaittry/catch”。它没有增加新的概念只是把 Promise 的.then()和.catch()变成了更自然的try/catch结构。对于大多数 Express 项目来说这是最推荐的方式。因为它足够直观团队里任何人接手这段代码不需要额外学习就能理解。4.4 顺手对比同步写入的问题在哪里为了讲清楚为什么不用writeFileSync我再贴一段同步写入的示例仅用来做对比不推荐在真实项目里使用。文件路径sync-demo.jsconst fs require(fs); const path require(path); const logPath path.join(__dirname, logs, access-sync.log); function appendLogSync(line) { // 注意这里会阻塞事件循环 fs.appendFileSync(logPath, line \n, utf8); } appendLogSync(Hello Sync Log); console.log(同步写入完成);在低并发写一两行日志时这个版本的代码和异步版本没有肉眼可见的差别。但一旦接口的并发量上来appendFileSync会让所有请求在事件循环里排队等待磁盘操作后果是整个服务所有接口的整体响应时间被拉长。所以在 Express 服务端代码里写文件操作要尽量走异步。如果页面能渲染出来底层流程是同步还是异步用户无感知但服务能不能扛住并发差别就在这里。5. 一个完整的 Express 请求日志写入示例下面我们把上面的写法放到一个真实的 Express 场景里每次请求到达/api/hello中间件负责记录请求方法、路径、状态码和处理耗时最后异步追加到logs/access.log文件。文件路径server.jsconst express require(express); const fs require(fs/promises); const path require(path); const app express(); const PORT 3000; const LOG_DIR path.join(__dirname, logs); const LOG_FILE path.join(LOG_DIR, access.log); // 确保日志目录存在 async function ensureLogDir() { try { await fs.mkdir(LOG_DIR, { recursive: true }); } catch (err) { console.error(创建日志目录失败:, err); } } // 异步追加写入日志 async function appendLog(line) { try { await fs.appendFile(LOG_FILE, line \n, utf8); } catch (err) { console.error(写入日志失败:, err); } } // 请求日志中间件 app.use((req, res, next) { const start Date.now(); res.on(finish, () { const duration Date.now() - start; const line ${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms; // 注意这里不 await避免阻塞响应 appendLog(line); }); next(); }); app.get(/api/hello, (req, res) { res.json({ message: Hello Express }); }); app.listen(PORT, async () { await ensureLogDir(); console.log(Server is running at http://localhost:${PORT}); });代码拆开看也很清晰ensureLogDir负责在服务启动前创建logs目录recursive: true允许递归创建多级目录目录已存在时也不会报错。appendLog封装了文件写入逻辑内部用try/catch兜住错误外层调用方不需要关心失败细节。中间件里通过res.on(finish)捕获响应结束事件在这个时机记录日志能拿到真实的状态码和耗时。中间件里没有对appendLog使用await这是刻意为之。日志写入不应阻塞响应返回异步让它在后台完成即可。如果你担心日志写入失败后没人处理可以在appendLog内部更激进一点比如把错误上报到监控系统或写入备用错误日志async function appendLog(line) { try { await fs.appendFile(LOG_FILE, line \n, utf8); } catch (err) { console.error(写入日志失败:, err); try { await fs.appendFile(path.join(LOG_DIR, error.log), err.stack \n, utf8); } catch (err2) { console.error(写入错误日志也失败了:, err2); } } }6. 运行结果与功能验证现在启动服务并验证写入是否正常。终端一启动服务node server.js预期输出Server is running at http://localhost:3000终端二发起几个请求curl http://localhost:3000/api/hello curl http://localhost:3000/api/hello curl http://localhost:3000/api/hello然后查看日志文件cat logs/access.log预期输出类似下面这样时间戳和耗时会随你的运行时间变化2025-06-10T10:15:30.123Z GET /api/hello 200 12ms 2025-06-10T10:15:31.456Z GET /api/hello 200 8ms 2025-06-10T10:15:32.789Z GET /api/hello 200 10ms每一行代表一次请求。如果你能看到三行日志说明整个流程已经跑通了。如果想单独验证三种写法也可以分别运行node callback-demo.js node promise-demo.js node sync-demo.js然后去logs目录查看对应的日志文件是否生成、内容是否正确。关于“如何判断写入成功”最直接的办法就是查看文件内容和文件修改时间。如果文件存在且内容是你期望的就说明写入成功。如果文件没生成先确认logs目录是否存在再确认代码执行过程中有没有抛出异常。7. 常见问题与排查思路异步写入在本地跑通很容易真正麻烦的是遇到问题时的排查。我整理了几个最常见的坑问题现象可能原因排查方式解决方案第二次写入后第一次的内容被覆盖使用了fs.writeFile默认 flag 是w会覆盖文件查看文件内容是否只剩最后一次写入改用fs.appendFile或传入flag: a日志内容顺序错乱多个请求并发写入没有保证执行顺序观察是否偶发错行使用单线程队列串行写入或选用 pino/winston 等日志库调用接口后日志文件没有生成日志目录不存在或写入抛错未被发现先检查logs目录再看控制台错误输出调用fs.mkdir创建目录并检查try/catch写入错误被静默吞掉异步错误没有捕获在appendLog的catch中打印错误统一错误处理或上报监控系统安装 Node.js 后执行node -v报 not foundPATH 未生效或版本管理器未切换检查which node/node -v重启终端或用 nvm 指定默认版本Windows 上安装依赖时报 VC 相关错误缺少 Visual C Redistributable 运行库查看安装日志安装对应版本的 VC Redistributable 后重试排第一的坑最常见writeFile覆盖写。它在单一请求场景下根本暴露不出来等到你写“第二个请求的日志”时才发现之前的内容全没了。另外fs.appendFile也不是完全没有竞争问题。在高并发的极端场景下如果多个请求同时向同一个文件追加内容会有顺序错乱的风险。从工程角度日志文件写入更推荐借助专门的日志库来管理轮转和格式而不是长期手写。8. 工程建议把日志写入做得更稳写日志这件事从“能写入”到“能稳定地写入”中间还差几步。第一不要把日志文件直接放在项目根目录。尤其是在多服务部署、容器化运行的环境里根目录可能没有写权限或者目录会随版本发布被覆盖。更安全的方式是单独规划日志目录并通过环境变量配置路径让运维同学可以调整const LOG_DIR process.env.LOG_DIR || path.join(__dirname, logs);第二日志文件建议按天滚动。一个access.log跑几个月文件会变得巨大排查问题、清理归档都会很痛苦。可以按日期生成文件名const dateStr new Date().toISOString().slice(0, 10); const LOG_FILE path.join(LOG_DIR, access-${dateStr}.log);第三批量写入比逐条写入更高效。如果你的业务需要一次性记录多条日志不要一条条appendFile先拼成一个大字符串再一次性写入const lines []; lines.push(requestLog1); lines.push(requestLog2); lines.push(requestLog3); await fs.appendFile(LOG_FILE, lines.join(\n) \n, utf8);这样能显著减少系统调用次数在高频场景下对性能更友好。第四不要在请求热路径上同步等待日志写完。即使你用了 async/await如果业务逻辑必须等日志落盘再返回那么接口的响应时间就包含了磁盘写入时间。更合理的做法是让日志异步在后台写入甚至在内存队列里攒一批再批量落盘。第五生产环境优先考虑成熟的日志库。这听起来像废话但真的很重要。Node.js 生态里的pino、winston已经处理好了日志级别、滚动、格式化、多传输目的地等问题。手写日志逻辑适合学习、适合小型项目但在生产环境里直接用这些库能省掉很多维护成本。第六注意日志脱敏。如果你把请求路径、查询参数、请求体原样写入文件用户手机号、身份证号、Token 这类敏感信息就可能以明文形式留在服务器上。写入前要做字段过滤或脱敏这是安全底线。9. 总结这节真正留下的三个结论回到最初的问题Node.js 异步写入文件到底应该怎么写第一个结论是服务端文件写入要优先用异步。writeFileSync在低并发时看着没问题在高并发时会把事件循环卡死这是 Node.js 单线程模型的硬约束不是编码习惯问题。第二个结论是三种异步写法里新项目首选fs/promises加async/await。回调风格是历史包袱需要能读懂但不需要在新代码里继续使用Promise 链式风格用于组合多个异步操作很合适而日常工作里async/await的代码可读性和维护性最好。第三个结论是写入日志这类操作要尽早考虑工程化。目录规划、滚动文件名、批量写入、错误处理、脱敏这些细节决定了你写的日志代码能不能扛住生产环境而不只是本地跑通一次。顺着这个方向下一步值得研究的话题是 Node.js 的Stream模块。文件日志、大文件上传下载、数据处理管道底层都会用到 Stream理解了它你对 Node.js I/O 的理解才算真正补齐。这篇先到这里建议你把示例代码跑一遍收藏备用。
返回列表