文件上传 的十种打开方式:工程实践笔记
先说坑:上传真不是 “form 里加个 file 就完了”
最近又碰到一次线上上传 500,用户报“传不上去”。结果一看日志,浏览器明明选了 80MB 视频,后端 Multer 直接抛 LIMIT_FILE_SIZE,Nginx 又先回了个 413,前端只拿到 Internal Server Error,排查得我一晚上没睡踏实。后来才意识到,文件上传这个功能特别容易低估:小文件能用,大文件就不行;本地能传,容器里不行;开发环境正常,线上 CDN 和反代一叠,全乱了。
下面把我这些年工程里实际用过的十种上传方式整理一下,基本按从小到大、从后端到前端、从能传到稳传的顺序来。有些命令直接抄,有些配置需要根据你的环境改,不保证每个域名和版本号永远能用,但思路是通用的。
第一种:最基础的 multipart 表单直传后端
这玩意儿就是最古老但最实用的方式,适合小文件、后台表单、临时文件。前端一个 form:
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file">
<button type="submit">上传</button>
</form>
Node/Express 用 Multer 接:
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
const upload = multer({
dest: path.join(__dirname, 'tmp'),
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter(req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.jpg', '.png', '.pdf', '.mp4'].includes(ext)) {
return cb(new Error('file type not allowed'));
}
cb(null, true);
}
});
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ saved: req.file.filename, size: req.file.size });
});
Multer 的 dest 会用系统临时目录,文件名随机,不会保留原文件名。这里其实已经有个安全点:不要把用户给的文件名直接落盘,不然 ../../etc/passwd 这种路径穿越能玩出花。
第二种:后端收到文件后先落盘,再异步处理
如果上传的是图片、视频、模型文件,别在请求线程里直接做压缩、转码、病毒扫描、数据库入库。最稳的是先写临时文件,返回一个任务 ID,再让 worker 慢慢处理。
const crypto = require('crypto');
app.post('/upload', upload.single('file'), async (req, res) => {
const taskId = crypto.randomUUID();
await store.save(req.file.path);
await queue.add('process-file', { taskId, userId: req.user.id });
res.status(202).json({ taskId, status: 'processing' });
});
这个方式的坑是临时文件残留。你 worker 失败了、机器重启了、容器被杀了,/tmp 里可能留一堆没人要的文件。生产环境记得挂一个清理任务,别指望系统帮你定期清。
第三种:前端直传对象存储,走预签名 URL
文件一大,就别让后端当二道贩子了。上传 100MB、1GB 视频,还从浏览器进后端再转存 S3/OSS/MinIO,带宽和内存都能被打爆。更常见的是后端签发一个短时效的预签名 PUT URL,浏览器直接 PUT 到对象存储。
后端签发:
const url = await storage.signedUrl({
Bucket: 'my-bucket',
Key: `users/${userId}/${uuid}.mp4`,
Expires: 600,
Method: 'PUT',
ContentType: 'video/mp4'
});
前端上传:
await fetch(signedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': 'video/mp4'
}
});
这里容易踩的坑是签名里的 Content-Type 和请求头必须一致,差一个都不行。AWS SDK v3 的 @aws-sdk/s3-request-presigner 会把这个坑放大给你看,报一个非常友好的 SignatureDoesNotMatch。
第四种:大文件分片上传
单文件 PUT 预签名 URL 简单,但浏览器网络一抖,几十 MB、几百 MB 就全白传。真正稳一点的分片上传流程一般是三步:初始化上传、上传分片、合并分片。
# 后端返回 uploadId、partSize、每个分片的 signedUrl
curl -X POST https://api.example.com/uploads/init \
-H 'Content-Type: application/json' \
-d '{"file":"big.zip","size":1073741824}'
前端按固定大小切片:
const partSize = 10 * 1024 * 1024;
for (let offset = 0; offset < file.size; offset += partSize) {
const blob = file.slice(offset, Math.min(offset + partSize, file.size));
const partNumber = Math.floor(offset / partSize) + 1;
await uploadPart(blob, partNumber);
}
S3 multipart upload 要求除最后一个 part 外,每个 part 至少 5MB,这个规则不知道坑了多少人。你要是用 1KB 分片玩,合并直接报错,还不容易一眼看出来。
第五种:秒传和哈希去重
秒传听起来很高级,其实就是先算文件哈希,上传前先问后端:“这个文件你是不是已经有了?”
后端如果要算本地文件哈希,别一次性读进内存,直接流式算:
const crypto = require('crypto');
const fs = require('fs');
const hash = crypto.createHash('sha256');
fs.createReadStream('/path/to/big.zip')
.pipe(hash)
.on('finish', () => {
console.log(hash.digest('hex'));
});
命令行里临时验证也很方便:
sha256sum big.zip
后端收到哈希后查表,命中就直接返回成功,没命中再给上传链接。注意哈希碰撞不要想太多,工程里用 SHA-256 基本够了;但去重要带大小、哈希、bucket 前缀一起判断,别拿文件名当唯一标识。
浏览器里如果大文件也想要秒传,别直接 file.arrayBuffer(),整个文件读进内存会卡。要么小文件才走 WebCrypto,要么就别在浏览器里硬算。
第六种:浏览器 input file 用 fetch 传,别再整页刷新
很多人还在用原生 form 提交上传,能传是能传,就是没有进度、没有取消、没有失败重试。工程里一般用 fetch 或 XMLHttpRequest 包一下。
const form = new FormData();
form.append('file', file);
form.append('taskId', taskId);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
setProgress((e.loaded / e.total) * 100);
}
};
xhr.send(form);
fetch 做上传进度确实麻烦,尤其浏览器 fetch 流式进度兼容性没那么舒服。你要是写后台管理系统,用 XMLHttpRequest 反而更直接,少折腾。
第七种:Docker 里的上传目录别踩权限
本地跑得好好的,进 Docker 就 EACCES: permission denied。原因通常很简单,挂载目录宿主权限和容器用户权限不匹配。
mkdir -p ./data/uploads
chmod 777 ./data/uploads
docker run --rm \
-v $(pwd)/data/uploads:/app/data/uploads \
-e UPLOAD_DIR=/app/data/uploads \
my-upload-service:latest
chmod 777 是排查用的偷懒办法,生产别这么干。正式一点就是 Dockerfile 里创建用户、目录,然后 chown:
RUN mkdir -p /app/data/uploads \
&& chown -R node:node /app/data/uploads
USER node
容器化之后文件持久化也得想清楚,/tmp 在容器里可能一重启就没了。你需要挂载卷,或者干脆把上传后的最终文件放到对象存储,本地只留临时态。
第八种:Nginx 413 和超时怎么排查
上传 50MB 文件,前端直接 413 Request Entity Too Large,多半是 Nginx 默认限制。改 client_max_body_size 就行:
http {
client_max_body_size 200M;
proxy_request_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
如果前面还有 Kong、Traefik、ALB、CDN,别只改一个 Nginx,一层一层查。很多时候最后发现是 API 网关也设了 body limit,或者负载均衡器超时太短,大文件传到一半就被掐了。
另一个坑是 proxy_request_buffering off 开不开。开了可以让 Nginx 不把整个请求体缓存到临时文件,边收边转发;但是上游服务挂掉或响应慢的时候,行为会不一样,测试环境别直接线上抄。
第九种:私有文件上传后怎么安全下载
上传到 bucket 不难,难的是上传完怎么给正确的人看。如果把文件放成 public-read,等于谁拿到 URL 谁能访问,过几天爬虫、扫描器、离职员工转发的链接全来了。
更稳的是私有读,后端根据用户权限签发 GET URL:
const downloadUrl = await storage.getSignedUrl({
Bucket: 'my-bucket',
Key: record.objectKey,
Expires: 300,
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(record.filename)}"`
});
前端拿到 URL 后直接 window.open 或者 <a download>。如果文件类型是图片,浏览器会优先 inline 展示;要强制下载,就设 Content-Disposition。这个细节经常被人忽略,最后问“为什么 PDF 上传后点链接变成浏览器打开了”。
第十种:上传失败重试和幂等
上传最容易让人骂娘的地方不是成功不了,而是成功不了之后重复点击,数据库里多出一堆垃圾记录,或者文件传了两次。工程里需要给上传操作一个 uploadId,所有失败重试都基于同一个 ID。
app.post('/uploads/init', async (req, res) => {
const { fileName, size, sha256, idempotencyKey } = req.body;
const upload = await getOrCreateUpload({
idempotencyKey,
fileName,
size,
sha256,
userId: req.user.id
});
res.json(upload);
});
合并完成后,如果对象存储返回成功但数据库没写进去,重试时不能再创建一份对象。可以先用 metadata 或数据库状态记录 pending / uploading / completed / failed,最后再原子标记完成。
最后怎么选
小文件、后台表单,直接 multipart + Multer,够用。大文件、公网用户多、带宽贵,前端直传对象存储。要稳定续传,分片上传。要省流量,秒传哈希。要安全,权限判断放后端,下载 URL 短时效。别把上传当成“前端一个 input,后端一个 req.file”,那只是 demo。真到线上,文件类型、大小、权限、存储、网络、重试、清理、审计,一个都跑不掉。
我现在更倾向的做法是:小文件走后端中转,大文件走直传;对象存储只当冷存储和最终落地;临时文件尽量短命;上传链路一定带 idempotencyKey。这样至少用户反馈“上传失败”时,我能看懂日志,不用半夜猜是不是 Nginx 又背锅了。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。