文件、流与上传
理解 Buffer、流和背压,以分块处理、字节上限、临时文件和失败清理建立可靠上传与文档处理流程。
建议先读:Node 运行时与进程异步、并发与取消HTTP 接口与服务分层
本页内容
本章解决什么问题#
上传一个 PDF 后,页面可能只看到一次成功提示,服务端却经历了接收、写入、校验、解析和登记状态。直接把整个文件读进内存,再交给解析器,少量文件时没有问题,并发稍高就可能挤满内存;失败留下的半截文件又会被后续任务误认为完整文档。本章目标是理解 Buffer、流、背压与文件生命周期,能实现有限大小、失败可清理、成功后才发布的文件接收核心。前置是取消信号与 HTTP 请求体读取。
前端熟悉 Blob、File 和 FormData,但它们只是浏览器侧的数据表示与封装。服务端收到的是网络字节,客户端提供的文件名和 Content-Type 都只是声明,不是事实。把 ../../config.json 当成本地路径,或把“声称是 PDF”的可执行内容直接公开,都是错误的信任方向。
从整块数据到有背压的流水线#
Buffer 表示字节序列;字符串表示解码后的文本。一个汉字通常占多个 UTF-8 字节,任意一个网络块可能切在字符编码中间。不能把每个块单独转为字符串后直接拼接,否则边界处可能出现替代字符;处理完整小文本可以先拼 Buffer 再解码,处理大文本可用流式解码器。
可读流提供数据,可写流消费数据,Transform 位于中间并改变或检查数据。想象前端虚拟列表:你不会一次把百万行都塞进 DOM;文件处理也应该让下游速度决定上游节奏。背压就是下游暂时处理不完时,上游减慢供给。它不能让磁盘无限快,但能防止生产速度持续超过消费速度时,内存无限累积。
pipeline 把流连接起来,处理背压并传播错误。只是手写 readable.on('data', chunk => writable.write(chunk)) 却忽略 write 的返回值,会破坏背压。当链路任一环失败时,还需处理流以外的业务资源,比如删除临时文件、撤销待解析状态;pipeline 不知道你的数据库记录代表什么。Node Streams 给出了流组合与错误行为的完整定义。
核心 API 合同#
createWriteStream(path, { flags: 'wx', mode: 0o600 }) 返回可写流;wx 表示以排他创建方式打开,目标已存在会报错。mode 在支持 POSIX 权限的环境有意义,Windows 的实际访问控制还取决于 ACL,不能把这一位参数当成跨平台隔离方案。
new Transform({ transform(chunk, encoding, callback) {} }) 收到一块数据,调用 callback(null, chunk) 继续传递,或 callback(error) 终止。每块必须恰好回调一次。Promise 版 pipeline(source, transform, destination, { signal }) 成功时兑现,流错误或取消时拒绝;失败会尝试销毁链路,因此直接把 HTTP IncomingMessage 放进失败的 pipeline 时,客户端可能只看到连接断开,不能承诺总能收到整齐的 JSON 413。
rename(oldPath, newPath) 返回 Promise;示例让临时文件和最终文件位于同一个目录,避免跨文件系统移动。unlink(path) 删除一个文件,目标不存在会报 ENOENT;清理时只可有意识忽略这个错误,不应吞掉权限拒绝或磁盘异常。Node File System 描述了这些操作的返回与错误。
完整示例:安全落盘核心与超限清理#
环境:Node 22.22 或 24,无依赖。保存为 save-stream.mjs,执行 node save-stream.mjs。程序在系统临时目录中创建专属练习目录,只处理自己生成的数据,验收后删除自己创建的文件和目录。它演示上传接收的存储核心,不是 multipart 解析器或完整 HTTP 上传服务。
// save-stream.mjs
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { mkdtemp, rename, unlink, readdir, rmdir, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
async function removeIfPresent(path) {
try { await unlink(path); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
}
async function saveStream(source, directory, { maxBytes, signal } = {}) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
throw new RangeError('maxBytes 必须为正安全整数');
}
// 路径完全由服务端生成,不拼接用户上传的文件名。
const id = randomUUID();
const temporary = join(directory, `${id}.part`);
const finalPath = join(directory, `${id}.bin`);
let bytes = 0;
const guard = new Transform({
transform(chunk, encoding, callback) {
bytes += chunk.length;
if (bytes > maxBytes) {
callback(Object.assign(new Error('文件超过大小上限'), { code: 'TOO_LARGE' }));
return;
}
callback(null, chunk);
},
});
try {
await pipeline(source, guard,
createWriteStream(temporary, { flags: 'wx', mode: 0o600 }),
{ signal });
// 整条流成功结束后才出现最终文件名。
await rename(temporary, finalPath);
return { id, bytes, path: finalPath };
} catch (error) {
await removeIfPresent(temporary);
throw error;
}
}
const directory = await mkdtemp(join(tmpdir(), 'node-stream-lesson-'));
try {
const file = await saveStream(
Readable.from([Buffer.from('知识'), Buffer.from('库')]),
directory,
{ maxBytes: 20, signal: AbortSignal.timeout(3000) },
);
assert.equal(file.bytes, 9);
assert.equal(await readFile(file.path, 'utf8'), '知识库');
console.log('成功写入 9 字节');
await assert.rejects(() => saveStream(
Readable.from([Buffer.alloc(8), Buffer.alloc(8)]),
directory, { maxBytes: 10 },
), { code: 'TOO_LARGE' });
const names = await readdir(directory);
assert.equal(names.length, 1);
assert.ok(names.every((name) => !name.endsWith('.part')));
console.log('超限任务失败,未留下临时文件');
} finally {
// 目录由本程序生成且不接受外部路径,只删除自己的练习文件。
for (const name of await readdir(directory)) await unlink(join(directory, name));
await rmdir(directory);
}
预期输出两行:成功写入九字节、超限任务失败且无临时文件。路径与 UUID 每次变化,示例不把它们视为固定结果。正式项目中的持久目录必须由配置提供,并有容量与权限管理,不能长期把业务资料寄存在系统临时目录。
关键代码逐段说明#
路径用随机标识生成,原始文件名可作为经过长度限制的元数据保存,但不参与本地路径拼接。临时文件与最终文件分离,使下游只处理已完成的对象;若在接收过程中就创建“已上传”状态,解析任务可能读到半截内容。数据库状态更新与对象存储操作无法天然共享一个事务,需要设计 uploading、ready、failed 等状态及补偿清理。
guard 在每块进入磁盘前检查累计字节,这比只校验客户端 Content-Length 可靠。它仍不能限制压缩内容解压后的体积,也不能证明文件格式正确。PDF 解析、图片解码、压缩包解压应各自设置页数、像素数、展开体积、CPU 时间等限制;上传大小只是第一道界限。
pipeline 成功后 rename,把“写完”与“可被使用”连接起来。同一文件系统的重命名通常适合作为可见性切换,但不等于已经满足所有断电持久性要求,也不解决随机名字极端碰撞和跨系统一致性。需要更严格保证时应采用对象存储的对象标识、校验和与明确提交步骤。
从教学核心接到真实上传#
multipart/form-data 包含边界、字段与多份内容,应使用维护中的流式解析器,并同时限制文件数、字段数、字段长度和总大小。大文件可以采用受限的预签名上传流程,让浏览器直接传对象存储;后端仍需验证上传完成对象的大小、类型、租户归属和有效期,不能收到一个 URL 就认为它属于当前用户。
下载也要重新做资源归属检查。不要把私有文档放入无需鉴权的静态目录,不能只依赖随机文件名保密。保存 HTML、SVG 或可执行内容时,还要考虑浏览器打开后的脚本行为;业务不需要在线预览时,可通过下载响应与隔离域减少风险。OWASP 文件上传建议 提供了上传检查维度。
从上传按钮看到完整的文档生命周期#
“团队文档与任务助手”接收文件以后,至少要知道上传是否完整、对象是否存在、格式能否解析、文本是否提取成功、索引是否发布。一个 uploaded 布尔值很难同时表达这些事实。更清楚的模型是把文件接收状态与解析任务状态分开,接收完成才允许领取解析任务,索引成功后才把新版本展示为可检索。
前端进度条达到百分之百,通常只意味着浏览器已经发送了请求体,不代表服务端已校验、写入持久存储或建立索引。界面可以显示“上传完成,正在处理”,并使用任务编号查询后续进度。这个区别能避免用户看到成功提示后立刻搜索,却因为后台仍在解析而以为资料丢失。
文件本身与文档实体也应分开。一个文档可对应多个上传版本,一个对象可能保存原始字节,一个解析版本又生成多个片段和缩略图。把这些都塞进单个 file_path 字段,会在重新上传、失败重试和历史恢复时失去关系。每个产物应知道来源版本、大小、校验摘要、存储位置和所属租户。
字节与字符:为什么分块边界不能随意解码#
UTF-8 中一个字符可能占多个字节,网络与磁盘读取块按缓冲与传输条件形成,不会照顾字符边界。把每个 Buffer 单独 toString 后拼接,会把被切开的编码序列分别替换成无效字符。英文样本可能永远发现不了这个问题,因此团队文档助手的最小测试应包含中文和多字节符号,而不是只有 hello。
StringDecoder(encoding) 默认采用 UTF-8,write(buffer) 返回当前能够完整解码的文本,并暂存末尾未完成的字节;end() 返回剩余内容并结束解码。它适合保持流式文本边界,但并不是严格的文件有效性验证器,损坏编码可能被替换。要求拒绝无效编码时,应采用有明确严格模式的解码方式,并决定对 BOM、换行和混合编码的策略。
Buffer.byteLength(text,'utf8') 返回编码后的字节数,字符串 length 反映 UTF-16 码元数,两者服务不同目的。网络限制用字节,用户标题限制可能用码元、码点或感知字符;不要在前端按一种方式验证,后端按另一种方式悄悄截断。二进制 PDF 更不应先转成字符串再保存,否则原始字节可能被破坏。
最小实验:一个汉字被分成三块#
保存为 utf8-stream.mjs,Node 22.22 或 24 执行 node utf8-stream.mjs,无依赖。代码故意每次只提供一个字节,展示逐块解码与有状态解码的差异。
// utf8-stream.mjs
import { StringDecoder } from 'node:string_decoder';
import assert from 'node:assert/strict';
const original='团队文档';
const bytes=Buffer.from(original,'utf8');
const chunks=Array.from(bytes,value=>Buffer.from([value]));
const broken=chunks.map(chunk=>chunk.toString('utf8')).join('');
const decoder=new StringDecoder('utf8');
const correct=chunks.map(chunk=>decoder.write(chunk)).join('')+decoder.end();
assert.notEqual(broken,original);
assert.equal(correct,original);
assert.equal(bytes.length,12);
console.log('逐块解码损坏中文;流式解码得到团队文档;原文为 12 字节');
预期输出一条说明。问题不在字符串拼接本身,而在第一次解码时已经丢失了跨块字节关系。后面再把替代字符拼起来也不能恢复原文。对于按行处理的文本,还要保留最后一个未完成行片段,因为字节块同样不保证落在换行处。
API 细读:背压、错误与完成边界#
可写流的 write(chunk) 返回布尔值,不是写入成功字节数。返回 false 表示内部缓冲达到需要减速的程度,调用者应等待 drain 再继续供给;它不表示刚才那块数据被拒收。忽略这个返回值继续写,缓冲可能持续增长。highWaterMark 是开始施加背压的阈值,不是整个进程内存的硬上限,也不会阻止上游一次创建一个巨大的 Buffer。
Transform 默认按字节流工作,objectMode 则改变块的计量和语义。transform 回调接收 chunk、encoding 和 callback;异步处理后也只能调用一次 callback。忘记调用会卡住管线,调用两次会产生错误或混乱。需要并行处理文档片段时,应另设有界任务池,不要在每次 transform 中随意启动不等待的 Promise,否则输出顺序与结束时间都难以保证。
Promise 版 pipeline 会等待流链完成,成功通常兑现为 undefined;传入 signal 后可以合作取消,没有 signal 就没有这一外部取消入口。它会传播错误并处理链路销毁,但不保证所有目的地都具备事务回滚能力。文件可能已经写入一部分,远程对象也可能产生未完成分片,因此业务层仍必须在 catch 或补偿任务中清理。
createWriteStream 默认 flags 为 w,会创建或截断文件;教学接收核心使用 wx,以避免意外覆盖已有临时路径。返回的是流,打开文件的错误可能稍后通过 error 事件出现,不能只在创建表达式外写同步 try/catch。交给 pipeline 观察错误,比只假设 createWriteStream 调用没抛就代表文件可写更可靠。
rename 的成功表示名字变更操作完成,并不自动提供数据库事务、对象内容校验或跨文件系统复制。unlink 删除的是一个路径对应的目录项,不能拿客户端传来的任意路径交给它。清理函数应只接受服务端创建并记录的对象标识,再由受控存储层解析路径;资源所有权比在删除时临时猜测路径是否安全更重要。
原练习的完整参考:边写入边计算摘要#
保存为 integrity-stream.mjs,Node 22.22 或 24 执行 node integrity-stream.mjs,无依赖。它对数据流同时做大小限制和 SHA-256,拒绝空内容,仅在成功后发布最终文件;最后删除自己的练习目录。摘要用于检测字节是否一致,不证明文件没有恶意内容。
完整代码已收录在本章末尾的练习参考答案中;可先阅读说明,再展开复制运行。
createHash('sha256') 创建有状态摘要对象,update 可调用多次并按顺序加入字节,digest('hex') 输出十六进制字符串并结束摘要过程。不能先 digest 再继续 update,也不能交换数据块顺序后期待同一结果。边流式更新只需要保留摘要状态,不需要在写完后 readFile 整个对象,从而避免第二次完整内存占用。
上传验证分成不同层次#
文件名与媒体类型适合用作初步筛选,不足以证明格式。文件头特征能排除部分错误,但也不能证明整份 PDF 可以安全解析。实际处理还需要解析器验证、页面或像素限制、压缩展开限制和隔离执行环境。每层检查解决不同问题,不能拿“已校验后缀”替代内容解析,也不能把“能解析”当成可以任意公开预览。
压缩文件尤其需要关注展开后大小。十兆压缩包可能展开成远大于上传限制的数据,递归嵌套还会增加 CPU 和文件数量。应按累计展开字节、条目数量、目录深度和执行时长建立限制,并拒绝条目路径逃离目标目录。把请求体限制提高到一百兆,不能解决解析过程中的资源放大。
图片尺寸与文件字节同样不是一回事。一个压缩后不大的图片,解码成像素缓冲后可能占用大量内存。团队助手若生成缩略图,应在解码器支持的范围内尽早读取尺寸并限制像素数量,失败时保持原文档状态可解释,不要让一个缩略图异常把整个服务进程拖垮。
对象存储直传与服务端代理的取舍#
服务端代理上传的优点是容易统一身份、大小和流式处理,代价是应用服务器承担流量与连接。浏览器直传对象存储可以减少这部分压力,但服务端仍应控制目标对象键、有效期、允许方法和必要的大小或类型条件。上传完成回调只表示客户端声称完成,后端需要核实对象存在、大小和归属,再建立 ready 状态。
不要允许客户端任意提交一个外部 URL 作为“刚上传的文件”。否则后端下载该 URL 时可能访问不应访问的内部网络,还可能拉取没有体积上限的内容。安全的直传流程通常由服务端先生成上传记录和受限目标,完成时只接受这条记录对应的对象标识。资源先有明确归属,再允许处理,能够把权限与存储路径联系起来。
大文件分片上传增加恢复能力,也增加状态复杂度。每片需要编号、大小和关联上传会话,最终合并应验证片段完整且顺序正确,并计算整体摘要。单片成功不代表整个文档可用,过期未完成会话要有清理策略。不要在没有断点续传需求时一开始就自制完整分片协议;优先评估存储服务已有的成熟能力。
调试与上线观察#
调试文件问题应记录对象编号、字节数、阶段、耗时和公开错误码,不记录整份文件正文。检查是否在读取之前就创建了巨大 Buffer,是否在日志中序列化 Buffer,是否在 Promise.all 中同时打开过多文件。流式 API 并不能弥补业务层提前把所有文件读入数组的设计,内存观察要覆盖完整调用链。
测试至少包含中文跨块、空文件、刚好达到上限、超过一字节、来源中断、目的地不可写和取消。一次正常上传成功无法证明清理路径正确。对于生产目录,不要为了测试故障去修改已有文件权限或删除共享数据;应在专属练习目录构造失败,验证只清理自己的对象。
最终发布可以是数据库版本指针切换、对象标签变更或索引版本更新。重要的是消费者只读取已确认完成的版本,失败产物不会冒充最新内容。旧版本保留多久、用户删除后如何清理派生文件、备份是否仍保留内容,都应成为生命周期设计的一部分。文件系统 API 解决字节操作,业务服务负责这些更长的承诺。
路径检查与实际操作之间也有竞争#
先检查文件不存在,再用普通写入模式创建,并不能保证中间没有另一个请求创建同名文件。应该让创建动作本身具备排他语义,并处理已存在错误。类似地,先检查目录权限再写入,只能提供一瞬间的观察,实际打开仍可能失败。文件系统操作的错误不是多余保险,而是并发世界中最终的判断依据。
把路径规范化并确认位于目标目录之下,可以阻止很多明显的路径穿越,但受控目录中的符号链接或 Windows 重解析点还可能改变实际目标。因此上传存储目录应由服务端管理,不允许普通用户随意创建链接或目录结构。业务层只传对象编号,存储层统一映射路径,比每个路由各自拼一套目录更容易维护安全边界。
磁盘满、配额不足和权限拒绝需要保留具体内部错误,向用户则返回稳定的上传失败状态。不能在清理失败时直接返回上传成功,也不能无限重试同一个不可写磁盘。清理器应记录失败对象和重试次数,让运维能看见积压的临时文件;否则看似已经在 catch 中处理的异常,最终会以磁盘逐渐耗尽的方式再次出现。
练习:计算摘要并拒绝空文件#
要求在同一条流里计算 SHA-256,返回十六进制摘要;零字节输入必须失败并清理临时文件。提示:在 Transform 中更新哈希,在 pipeline 成功后检查 bytes,再决定是否 rename。不要为了计算摘要再 readFile 把完整文件读回内存。
参考答案(含完整可运行实现)
从 node:crypto 导入 createHash,进入 saveStream 后创建哈希对象,每次通过大小检查的块调用 hash.update(chunk)。pipeline 结束后如果 bytes 等于零就抛出 EMPTY_FILE,原 catch 会删除临时文件;否则完成 rename 后调用 hash.digest('hex') 并加入返回值。新增断言比较 Buffer.from('知识库') 的离线摘要,并用 Readable.from([]) 验证空文件拒绝。下面给出包含这些行为的完整参考文件。
// integrity-stream.mjs
import { Readable,Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import { mkdtemp,rename,unlink,readdir,rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash,randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
async function save(source,directory,maxBytes=1024) {
if(!Number.isSafeInteger(maxBytes)||maxBytes<1) throw new RangeError('大小上限无效');
const id=randomUUID();const partial=join(directory,`${id}.part`);const final=join(directory,`${id}.bin`);
const hash=createHash('sha256');let bytes=0;
const guard=new Transform({transform(chunk,encoding,callback){
bytes+=chunk.length;
if(bytes>maxBytes){callback(Object.assign(new Error('内容超限'),{code:'TOO_LARGE'}));return;}
hash.update(chunk);callback(null,chunk);
}});
try {
await pipeline(source,guard,createWriteStream(partial,{flags:'wx',mode:0o600}));
if(bytes===0) throw Object.assign(new Error('不接受空文件'),{code:'EMPTY_FILE'});
const digest=hash.digest('hex');
await rename(partial,final);
return {path:final,bytes,digest};
} catch(error) {
try {await unlink(partial);} catch(cleanup){if(cleanup.code!=='ENOENT')throw new AggregateError([error,cleanup],'处理与清理都失败');}
throw error;
}
}
const directory=await mkdtemp(join(tmpdir(),'integrity-lesson-'));
try {
const data=Buffer.from('团队文档');
const result=await save(Readable.from([data.subarray(0,2),data.subarray(2)]),directory);
assert.equal(result.bytes,12);
assert.equal(result.digest,createHash('sha256').update(data).digest('hex'));
await assert.rejects(()=>save(Readable.from([]),directory),{code:'EMPTY_FILE'});
await assert.rejects(()=>save(Readable.from([Buffer.alloc(20)]),directory,10),{code:'TOO_LARGE'});
const names=await readdir(directory);
assert.equal(names.length,1);assert.ok(!names[0].endsWith('.part'));
console.log('摘要正确;空文件与超限被拒绝;仅保留一个成功文件');
} finally {
for(const name of await readdir(directory))await unlink(join(directory,name));
await rmdir(directory);
}
可验证的验收标准#
正常文字按 UTF-8 字节计数为九;超限失败后没有 .part;同一输入的摘要稳定;零字节策略有明确结果;取消时尽力清理临时文件。能解释 pipeline 只负责流的资源传播,不负责删除数据库记录,也不保证 HTTP 错误响应一定已经送达。
三个自测问题与答案#
- 为什么不能以 File.name 直接生成磁盘路径?答案:它来自不可信客户端,可能包含路径穿越、冲突与平台特殊名称。
- 背压解决文件总大小限制吗?答案:不解决,背压控制缓冲与速度,大小限制仍需显式累计字节。
- 上传成功是否代表文档可被模型检索?答案:不代表,还需要校验、解析、切分、索引和状态发布,各阶段都可能失败。
本章验证记录#
编写时使用 Node 22.22.0 对本章全部 3 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过:save-stream.mjs、utf8-stream.mjs、integrity-stream.mjs。
本章官方参考#
- Node Streams:背压、Transform 和 pipeline。
- Node File System:排他创建、重命名与文件删除。
- OWASP File Upload:类型、存储位置与资源限制。
- Node String decoder:跨字节块的文本解码。