4.4 文件上传与流式处理


4.4 文件上传与流式处理

参数解析那一层管不了文件——multipart 的字节流超出了 JSON 解析器的职责。本节处理进层半程最重的一种输入:上传。核心只有两个字,流式:让字节从请求流向磁盘或对象存储,全程不在内存里攒大块。上传讲透之后,顺带把下载侧的流式输出也收掉,进出两个方向的流就都齐了。

multipart 的来龙去脉

浏览器提交带文件的表单时,请求头的 content-type 变成 multipart/form-data; boundary=----koaboundary,请求体由 boundary 分隔成多个段,每段有自己的头(字段名、文件名、类型)与字节内容。Koa 内核不解析它,第 3.2 节的 bodyparser 也不管——专职轮子是 koa-body(或社区的 formidable 封装):

npm install koa-body
const { koaBody } = require('koa-body'); router.post('/api/v1/attachments', auth(), koaBody({ multipart: true, formidable: { maxFileSize: 20 * 1024 * 1024, // 单文件上限 20MB,超限报错 uploadDir: './uploads', // 先落到临时目录,别进内存 }, }), async (ctx) => { const file = ctx.request.files && ctx.request.files.file; // 表单字段名为 file if (!file) throw new BadRequest('缺少文件字段 file'); ctx.body = { saved: file.filepath, name: file.originalFilename }; } );

koa-body 默认把上传内容写到临时文件(formidable 的行为),处理器拿到的是磁盘路径加元信息。这一步的内存账要算清:临时文件方案下,Node 进程的内存占用与文件大小基本无关;如果配置成写内存,一个 20MB 的并发上传就能轻松吃掉数个 GB——选型时认准"落盘优先"。

流式转存:临时目录到最终归宿

临时文件之后通常要搬到对象存储或最终目录。搬法有讲究:fs.copyFile 会"读整个再写整个",对流来说没必要;用管道把读写两端接起来,内存里同一时刻只有一小块缓冲:

const fs = require('fs'); const { pipeline } = require('stream/promises'); const { Readable } = require('stream'); router.post('/api/v1/attachments/:id/archive', auth(), async (ctx) => { const meta = store.get(ctx.params.id); if (!meta) throw new NotFound(); const source = fs.createReadStream(meta.tmpPath); const target = fs.createWriteStream(meta.finalPath, { flags: 'wx' }); // 已存在即失败 try { await pipeline(source, target); // 任一端出错,pipeline 负责销毁两端 ctx.body = { archived: true }; } catch (err) { await fs.promises.unlink(meta.finalPath).catch(() => {}); // 清理半成品 throw err; // 继续冒泡给兜底层 } });

stream/promises 的 pipeline 是流处理的现代标准:成功时自动关闭两端,失败时销毁两端并抛错——比手写 error 监听少一半代码,也少了"忘了销毁导致句柄泄漏"这类隐患。catch 里的半成品清理加再抛出,则是 3.1 节包裹型中间件的同一纪律在流世界的翻版。

下载侧:把流交给 body

出方向的流更简单——Koa 原生支持把可读流赋给 ctx.body,内核负责接回响应(2.3 节埋过这个伏笔):

router.get('/api/v1/attachments/:id', auth(), async (ctx) => { const meta = store.get(ctx.params.id); if (!meta) throw new NotFound(); ctx.set('Content-Type', meta.mimetype || 'application/octet-stream'); ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(meta.name)}"`); ctx.length = meta.size; // 提前告知体积,浏览器能显示进度条 ctx.body = fs.createReadStream(meta.finalPath); });

三个头各有讲究:Content-Disposition 的 filename 经 encodeURIComponent 处理,中文文件名不会乱码;Content-Length 让客户端显示下载进度(stream 不知道总长,必须显式给);如果是客户端中断(下载到一半取消),流的 error 事件会带着 ECONNRESET 冒出来,兜底层里对这类错误做降噪处理即可(第 2.4 节清单里提过)。

上传的三道门槛

文件接口是对手最爱光顾的地方,门槛要在进层路径上依次立好。第一道,大小:koa-body 的 maxFileSize 挡超大文件,注意它是在解析阶段生效,超限直接报错。第二道,类型:extension 与 mimetype 都来自客户端、都可伪造,可靠的判断是"读文件头魔数"——PNG 的开头固定是特定字节序列,文档类有各自的 magic number;至少做到白名单后缀加 mimetype 双重确认,把可执行与脚本类后缀挡死。第三道,落点:保存文件名绝不能用客户端原名直接拼路径——../../etc/passwd 这类路径穿越(7.1 节的主角之一)就从这进来;正确姿势是服务端生成随机名,原名只当元信息存。

// 三道门槛的落点代码 const crypto = require('crypto'); const path = require('path'); const ALLOWED = new Set(['image/png', 'image/jpeg', 'application/pdf']); async function saveUpload(file) { if (!ALLOWED.has(file.mimetype)) throw new BadRequest('不支持的文件类型'); const safe = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${path.extname(file.originalFilename)}`; const dest = path.join('./uploads', safe); // 服务端随机名,穿越无从谈起 await fs.promises.rename(file.filepath, dest); return { path: dest, name: file.originalFilename, type: file.mimetype }; }

⚠️ 常见坑:上传接口没有大小限制直连生产。没有 maxFileSize 的 multipart 解析会把整个请求体慢慢吞进临时空间(甚至内存,取决于配置),一次恶意的大包上传足以拖垮服务。任何文件入口,大小门槛是上线前的必检项——限流层挡频率,大小门槛挡单次,两道都要有。

本节要点回顾

  • multipart 专职解析:koa-body 落盘优先,内存占用与文件大小脱钩;
  • 流式搬运用 pipeline:成功自动收尾、失败自动销毁,半成品要清理、异常要再抛;
  • 下载把流交给 body:配齐 Content-Disposition、Content-Type、Content-Length;
  • 三道门槛:大小限制、类型白名单(后缀加 mimetype,最好验魔数)、服务端随机命名防穿越;
  • 断传是常态:客户端中断产生的 ECONNRESET 在兜底层降噪,不算事故。

至此进层半程走完:路由找到了归宿、语义落成了矩阵、参数成了可信输入、文件流也过了门槛。下一章调转机位——请求将带着定型的响应逐层退出,那是出层半程的故事。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U