ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Express.js异步写入文件:回调、Promise与async/await实践

Express.js异步写入文件:回调、Promise与async/await实践 在 Express.js 项目中异步写入是绕不开的操作写请求日志、保存上传文件、导出统计结果都会落到磁盘 I/O 上。很多 Node.js 初学者会把fs.writeFileSync直接放进路由本地跑只有一两个请求时看不出问题一旦并发上来整条请求链路都会被同步阻塞。本文围绕一个具体的 Express.js 场景——把每个 POST 请求的 body 写入本地文件讲清楚 Node.js 异步写入的三种写法Callback 回调、Promise 链式、async/await。你会看到三种写法各自的代码形态、错误处理方式、适用场景以及生产环境里最容易踩的坑。学完后你能在 Express 路由里正确选型、正确排错也能够把写入逻辑封装成可维护的模块。1. 为什么在 Express 里写文件必须用异步方式1.1 从 Node.js 事件循环看写入的两种路径Node.js 是单线程事件循环模型JavaScript 代码运行在一个主线程上但写文件这类 I/O 操作不会全部压在主线程上。fs.writeFileSync会让主线程等待系统调用返回后才继续执行下一行fs.writeFile则会把文件写入交给 libuv 底层线程池执行完再通过事件循环通知应用代码。在 Express 中主线程同时负责接收新请求、解析 body、响应结果如果某个路由用同步写文件它停留多少毫秒后续请求就得排队等多少毫秒。请求一多超时、内存占用上升、接口整体变慢就随之而来。这个模型也解释了异步回调为什么“看起来不按顺序执行”。文件写入完成后回调不是立刻执行而是先进入事件循环的待处理队列等主线程空闲后再被取出来执行。因此异步写文件只适合用在“不需要马上得到结果”的场景或者说不应该依赖写入完成后的状态来继续执行和写入无关的逻辑。1.2 同步 API 什么时候才可用这并不代表同步 API 要完全戒掉。程序启动阶段执行一次性检查比如判断目录是否存在、创建日志目录用fs.existsSync和fs.mkdirSync是没问题的因为它们只发生一次不会长期占住主线程。但在请求处理函数里不建议出现fs.writeFileSync、fs.readFileSync。demo 里写在路由里看似简洁实际是把每次请求的耗时都变成阻塞。如果一个路由里同时有数据库查询、远程接口调用、文件写入同步写法会把所有耗时累加在同一个请求线程上。换成异步写法之后每一段等待都会把事件循环让出去其他请求才有机会被处理。这也是判别一个 Node.js 服务是否健壮的基础看它在等待外部 I/O 时是不是继续响应用户请求。1.3 三种写法不是三套引擎Callback、Promise、async/await 本质上是同一层异步 I/O 的不同表达方式。底层仍然是 Node.js 的fs模块和 libuv最终调用操作系统的写文件能力。区别在于回调靠函数参数通知结果Promise 把异步状态封装成对象async/await 是 Promise 的语法糖。理解了这一点就不会把“异步写入”当成魔法也不会认为换一种写法就一定能提升性能。写法只会影响代码可读性和错误处理方式不会改变磁盘 I/O 本身的成本。2. 准备一个可运行的最小 Express 项目2.1 环境要求和依赖版本建议使用 Node.js 14 以上的版本。因为require(fs/promises)从 Node.js 14 开始稳定可用如果使用更早版本可以改成require(fs).promises。Express 示例基于 4.x通过 npm 安装会得到当前可用的 4.x 版本。如果项目已经用了 Express 5正文路由代码基本适用但异步路由的异常处理行为有差异这一点会在后文单独说明。依赖版本建议说明Node.js14需要fs/promises支持express4.x示例基于 Express 4Express 5 基本兼容包管理器npm使用 npm 初始化项目2.2 初始化项目目录先创建一个目录再通过 npm 初始化。命令如下mkdir express-async-write-demo cd express-async-write-demo npm init -y npm install express4预期会生成package.json和node_modules目录。接下来手动创建app.js并让项目启动后自动创建logs目录。目录结构如下express-async-write-demo/ ├── app.js ├── logs/ │ └── request.log └── package.json2.3 创建基础 app.js在app.js中创建 Express 应用同时准备好日志文件路径。这里的关键点是把目录创建工作放在启动阶段完成避免真正写入文件时因为目录不存在而报ENOENT。const express require(express); const fs require(fs); const path require(path); const fsPromises require(fs/promises); const app express(); app.use(express.json()); const LOG_DIR path.join(__dirname, logs); const LOG_FILE path.join(LOG_DIR, request.log); if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } app.get(/health, (req, res) { res.json({ status: up }); }); // 后续小节会为 /api/echo/callback、/api/echo/promise、/api/echo/async 添加路由 app.listen(3000, () { console.log(server: http://localhost:3000); });运行npm start后用curl验证服务是否正常curl http://localhost:3000/health正常响应为{status:up}这里有几个细节需要注意fs.mkdirSync(LOG_DIR, { recursive: true })会在目录不存在时自动创建不会因为目录已存在而报错。path.join(__dirname, logs)使用绝对路径避免运行时因为工作目录不同而找不到文件。日志文件本身没有提前创建第一次写入时由fs.writeFile自动创建。3. 异步写入的第一种写法Callback 回调风格3.1 用 fs.writeFile 发起异步写入Callback 是 Node.js 最早的异步风格。fs.writeFile接收路径、数据、可选的 options 和回调函数。写入完成后回调函数会被调用。为了在 Express 路由中演示我添加一个/api/echo/callback接口把请求 body 序列化后追加到request.log。app.post(/api/echo/callback, (req, res) { const payload { time: new Date().toISOString(), body: req.body || {} }; const line JSON.stringify(payload) \n; fs.writeFile(LOG_FILE, line, { encoding: utf8, flag: a }, (err) { if (err) { console.error(write failed:, err); return res.status(500).json({ error: write_failed, detail: err.message }); } res.json({ received: true, timestamp: payload.time }); }); });注意fs.writeFile默认的flag是w含义是打开文件写入并截断原内容。如果多个请求都写同一个文件后面的请求会把前面内容覆盖掉。这里显式传入flag: a表示追加写入更适合记录日志。3.2 回调参数和错误处理Node.js 回调风格有一个约定回调的第一个参数是错误对象err没有错误时它是null或undefined。fs.writeFile的回调没有成功结果参数因为写入完成后不需要返回数据你只需要知道“写成功了”还是“写失败了”。很多新手会写成下面这样fs.writeFile(LOG_FILE, line, { flag: a }, () { res.json({ received: true }); });这个写法的问题是完全没有检查err。如果磁盘满了、目录被删除、权限不足接口仍然响应成功但数据其实没有写入。生产环境里这种错误最隐蔽因为它不会让服务崩溃只会让日志或者文件数据静默丢失。正确的做法是写入失败时记录错误并返回给客户端一个明确的失败状态。3.3 回调地狱与它的代价如果一次请求需要处理多个写入步骤比如先写一个临时文件再写一个索引文件回调写法会层层嵌套fs.writeFile(file1, data1, () { fs.writeFile(file2, data2, () { fs.writeFile(file3, data3, () { // 越来越深 }); }); });这种结构被称为回调地狱。它的问题不只是缩进变深更严重的是错误处理会变得混乱哪一层出错、错误由谁捕获、如何中断后续步骤都要靠开发者手工维护。于是 Node.js 引入了 Promise用来把这套流程改成更接近线性的表达。4. 异步写入的第二种写法Promise 链式风格4.1 使用 fs/promises 的 writeFilefs/promises模块提供了 Promise 版本的文件操作 API。fsPromises.writeFile的行为和fs.writeFile一致但它返回一个 Promise不再接收回调函数。用then处理成功用catch处理失败。app.post(/api/echo/promise, (req, res) { const payload { time: new Date().toISOString(), body: req.body || {} }; const line JSON.stringify(payload) \n; fsPromises.writeFile(LOG_FILE, line, { encoding: utf8, flag: a }) .then(() { res.json({ received: true, timestamp: payload.time }); }) .catch((err) { console.error(write failed:, err); res.status(500).json({ error: write_failed, detail: err.message }); }); });这段代码和回调版本最大的区别是错误处理被收拢到了catch里。只要writeFile内部抛出异常无论是文件不存在、权限问题还是编码问题都会被同一个catch捕获。这对普通写入场景已经够用了。4.2 多个写入操作如何编排Promise 带来的第二个好处是可以编排多个异步操作。如果多个不同文件之间没有依赖关系可以用Promise.all并行写入const writeTask fsPromises.writeFile(fileA, dataA); const readTask2 fsPromises.writeFile(fileB, dataB); Promise.all([writeTask, readTask2]) .then(() { res.json({ received: true }); }) .catch((err) { console.error(batch write failed:, err); res.status(500).json({ error: write_failed }); });如果多个写入步骤之间存在先后依赖可以直接在then里返回下一个 Promise形成链式调用。这样虽然没有完全消除缩进但比回调嵌套更容易理解错误也会传递到最后的catch。建议只有当需要同时写多个独立文件时才使用并行写入如果多个写操作指向同一个日志文件并行写入反而可能造成内容交错。4.3 Promise 写法的剩余问题Promise 解决了回调地狱的一部分问题但长时间使用.then().catch()会让代码显得琐碎。遇到多层await、条件分支、循环写入时链式表达读起来仍然不够自然。于是 async/await 在 Promise 之上提供了更接近同步代码的写法。5. 异步写入的第三种写法async/await 风格5.1 把写入逻辑改造成同步阅读体验async/await是 Promise 的语法糖。函数前面加上async函数内部就可以使用await等待一个 Promise 完成。等待期间事件循环不会被阻塞代码却看起来像同步写法。app.post(/api/echo/async, async (req, res) { try { const payload { time: new Date().toISOString(), body: req.body || {} }; const line JSON.stringify(payload) \n; await fsPromises.writeFile(LOG_FILE, line, { encoding: utf8, flag: a }); res.json({ received: true, timestamp: payload.time }); } catch (err) { console.error(write failed:, err); res.status(500).json({ error: write_failed, detail: err.message }); } });这里await fsPromises.writeFile(...)会暂停当前 async 函数的执行直到文件写完成。如果写入失败异常会被catch捕获。和 Promise 链式写法相比缩进更少逻辑顺序和阅读顺序一致适合放在 Express 路由中。5.2 Express 4 与 Express 5 的异步错误处理差异使用 async 路由时一个容易忽略的坑是 Express 4 不会自动捕获异步路由中产生的 Promise rejection。也就是说如果 async 函数里没有try/catch写入失败时 Express 4 不会把它送到错误中间件而是可能出现UnhandledPromiseRejection客户端请求也会一直挂起直到超时。// 反例Express 4 下不要这样写 app.post(/api/echo/async, async (req, res) { await fsPromises.writeFile(LOG_FILE, line, { flag: a }); res.json({ received: true }); });Express 5 对异步错误处理做了改进会把 async 路由中抛出的异常自动传给错误中间件。但当前大量项目仍基于 Express 4稳妥的做法是显式使用try/catch不要依赖框架自动兜底。这既适用于写入操作也适用于任何数据库、缓存、外部接口调用。5.3 将写入封装成独立模块当写入逻辑变复杂后不要把fsPromises.writeFile散落在每个路由里。建议封装成一个独立模块例如logger.jsconst fs require(fs); const path require(path); const fsPromises require(fs/promises); const LOG_DIR path.join(__dirname, logs); const LOG_FILE path.join(LOG_DIR, app.log); if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } async function writeLog(payload) { const line JSON.stringify({ time: new Date().toISOString(), payload }) \n; await fsPromises.writeFile(LOG_FILE, line, { encoding: utf8, flag: a }); } module.exports { writeLog };路由里的调用就变得很干净const { writeLog } require(./logger); app.post(/api/echo/async, async (req, res) { try { await writeLog(req.body || {}); res.json({ received: true }); } catch (err) { console.error(write failed:, err); res.status(500).json({ error: write_failed }); } });封装之后后续如果需要增加日志轮转、不同日志级别、写入队列只需要改动logger.js路由代码不受影响。6. 三种写法对比与选型6.1 三种写法横向对比写法典型 API错误处理方式适用场景Callbackfs.writeFile(path, data, opts, cb)回调函数第一个参数err旧代码、简单一次性写入PromisefsPromises.writeFile(...).then().catch().catch收尾需要编排多个异步操作async/awaitawait fsPromises.writeFile(...)try/catchExpress 路由、复杂业务逻辑从维护性角度看新项目建议优先使用async/await。它最接近同步代码的阅读习惯错误处理也更集中。回调风格仅用于兼容旧代码不要在新增业务里继续写深嵌套回调。6.2 写文件参数速查fs.writeFile和fsPromises.writeFile的参数基本一致常用的options字段如下参数默认值含义encodingutf8字符串写入时使用的字符编码mode0o666文件权限会受系统umask影响flagw文件打开模式w表示覆盖a表示追加signal无传入AbortSignal可以取消写入其中最容易出错的是flag。默认的w会在写入前截断文件如果用于日志场景第二次请求就会把第一次请求覆盖掉。推荐日志写入明确指定flag: a。如果你希望更直观也可以使用fs.appendFile或fsPromises.appendFile它等价于以追加模式写入。6.3 并发写入同一个文件的坑即使使用了异步写入也不能完全避免并发问题。多个请求同时调用fsPromises.writeFile写同一个文件即使指定的flag: a也不能保证大段数据之间不会交错。对于一行一行的日志内容较短时通常问题不明显但一旦数据量变大或写入频率增加就可能出现半行、乱序、内容交错。因此在需要高并发写同一文件时不要裸写文件。更稳妥的做法是引入写入队列或者使用pino、winston等成熟日志库它们内部已经处理了批量写入、轮转和背压问题。7. 常见问题写不进、被覆盖、乱码、异步异常7.1 ENOENT目录不存在或路径错误写入时报ENOENT: no such file or directory, open ...最常见原因是日志目录没有创建。fs.writeFile只能创建文件不能递归创建不存在的父目录。解决方式是在应用启动阶段调用fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });也可以把LOG_FILE设计成绝对路径避免因为工作目录变化导致找不到文件。排查时先看路径字符串本身再确认目录是否真实存在。7.2 文件内容始终只剩最后一条如果每次请求后打开日志文件发现里面只有最后一次请求的记录基本可以断定使用了默认flag: w。覆盖写入对配置文件生成可能没问题但对日志和追加场景是错误的选择。解决方案有两种// 显式使用追加 flag fsPromises.writeFile(LOG_FILE, line, { flag: a }); // 或者使用专门的追加 API fsPromises.appendFile(LOG_FILE, line);推荐优先使用appendFile语义更明确也避免每次传flag出错。7.3 中文内容乱码写入中文后用编辑器打开文件看到乱码通常不是 Node.js 写错了而是写入编码和读取编码不一致。默认utf8写入但某些 Windows 编辑器默认用 ANSI 或 GBK 打开文件就会显示乱码。处理方式写入时显式指定encoding: utf8编辑器打开时也选择 UTF-8。还可以在文件开头写入一个不可见的 BOM 标记但日志文件通常不需要这里不推荐。如果你不是在写字符串而是写 Buffer要确保 Buffer 内容本身已经完成了正确的编码转换。7.4 async 路由抛异常但客户端一直转圈客户端请求一直没有响应最典型的原因是 async 路由里发生了异常但没有被try/catch捕获并且 Express 4 又不能自动处理 Promise rejection。此时终端往往会打印未处理的 rejection但请求没有返回。检查顺序如下路由是否声明了async关键字。函数内是否有try/catch包裹await操作。是否有全局错误中间件。Express 版本是 4 还是 5。修复方式很简单把await写入包进try/catch失败时返回 500。如果项目统一使用 Express 5可以依赖错误中间件但不要在 Express 4 里赌这个行为。8. 生产环境写日志别在请求里裸写文件8.1 从 Demo 到生产还差什么Demo 演示的三种写法只解决了“能不能写”的问题生产环境还需要考虑日志按天或按大小轮转避免单个文件无限增长。写入频率高时增加批量写入或队列机制。每条日志带上请求 ID、用户 ID、耗时等上下文信息。文件权限、目录权限按部署用户最小化设置。写入失败时要有监控和告警而不是只打印到终端。不要把日志路径硬编码在代码里通过环境变量注入。对于小型项目可以先把logger.js封装好再加入pino这类日志库。对于大型项目建议直接接入集中式日志服务应用本身只负责输出结构化日志分析组件负责采集和检索。8.2 一个简单的串行写入队列如果不想引入额外依赖又要解决并发写同一文件时的交错问题可以维护一个 Promise 队列让所有写入操作串行执行。下面是一个最小示例const fsPromises require(fs/promises); let writeQueue Promise.resolve(); function appendLine(file, line) { const task writeQueue.then(() fsPromises.writeFile(file, line, { encoding: utf8, flag: a }) ); writeQueue task.catch(() {}); return task; }appendLine的每次调用都会在前一个写入任务完成后才开始新的写入从应用层面避免了并发写同一文件。注意这里单独给writeQueue挂了catch防止某次写入失败导致整个队列后续任务永远不执行。这个写法适合中低频率写入高频场景仍建议使用专门的日志库。8.3 发布前检查清单每次写完文件相关的功能发布前可以按下面清单检查[ ] 日志目录是否在启动阶段创建路径是否为绝对路径。[ ] 写操作是否使用异步 API路由里没有writeFileSync。[ ] 写入模式明确日志场景使用追加模式。[ ] 异步路由有try/catch或明确依赖 Express 5 的错误处理。[ ] 错误日志是否包含时间、请求 ID、错误堆栈等可排查信息。[ ] 并发写入同一文件是否有队列或日志库兜底。[ ] 日志文件是否有轮转策略磁盘占用是否可控。[ ] 是否预留了通过环境变量覆盖日志路径和级别的能力。这三种异步写入写法本身并不复杂真正决定项目质量的是是否理解了事件循环模型是否在路由里错误使用了同步 API以及是否在并发和错误处理上做了提前设计。建议从async/await开始写新代码用回调风格阅读旧
返回列表