文件上传:写给初学者的通俗解释
最近在折腾一个小功能,页面里放个头像上传框,浏览器点一下,服务器那边没动静,日志里只看到一句 413 Request Entity Too Large。我当时还以为是文件太大,结果改完 Nginx 配置,过两分钟又发现上传成功了一半,文件在临时目录里躺着,业务代码却拿不到。
后面我把整个流程从头捋了一遍,发现文件上传这个事,很多初学者卡住的地方其实不是“不会写代码”,而是脑子里没有一张清晰的路径图:浏览器到底提交了什么,服务器收到的是什么,文件什么时候落盘,什么时候只是临时缓冲,什么时候被容器删除,什么时候被反向代理拦掉。
下面是我重新整理过、自己敲通了一遍的通俗版本。可能不完美,但至少适合刚开始写后端页面的人照着跑。
上传并不是“把文件发过去”这么简单
我们平时点一个“提交”按钮,浏览器发的是 HTTP 请求。普通表单默认是 application/x-www-form-urlencoded,里面大概就是一堆键值对:
name=tom&age=18
这种格式很适合文本,但不适合文件。文件是二进制内容,可能里面有换行、有特殊字符、可能很大,不能简单塞进键值对里。
所以文件上传要用另一个表单编码类型:
enctype="multipart/form-data"
它的重点是:一个 HTTP 请求里被拆成了多个 part,每个 part 都有自己的头部和内容。浏览器会自动生成边界分隔符,服务端再按这个边界把文件部分解析出来。
你不需要自己手写这个边界,但你要知道:文件上传不是“把磁盘上的文件偷偷挪到服务器”,而是浏览器先读取文件内容,再把内容重新装进请求体里发给服务器。
一个最小的上传页面
先把前端搞简单一点,只放一个文件输入框。
<!doctype html>
<html>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<input name="avatar" type="file" />
<button>上传</button>
</form>
</body>
</html>
这里最容易忽略的是 name="avatar"。后端最后取文件的时候,通常就是靠这个 name 去找,不是靠你页面上写了什么文字,也不是靠文件输入框的 id。
如果后端写成:
request.files.get("avatar")
那前端就必须有:
<input name="avatar" type="file" />
名字不一致,拿到的就是空的。这种错误很蠢,但真的很容易犯。
下面是一个实测可用的后端最小版本
这个例子需要借助 docker,不然你就慢慢配置 Python、Flask、依赖环境吧。
首先把镜像拉下来:
docker pull python:3.12-slim
我这里没配加速域名。不需要的或者拉的时候报错了,可以换镜像源,没准儿你用的时候这个镜像源已经不可用了。
然后启动容器并进入 bash:
docker run -it --rm -v $PWD:/app -p 8000:8000 python:3.12-slim /bin/bash
这里用了 --rm,所以当你退出这个容器后会自动删除容器。这个设计很省事,但如果你忘了挂载目录,就会非常痛苦。
进入容器后,直接:
cd /app
pip install --no-cache-dir flask
然后在宿主机当前目录创建 app.py,内容如下:
import os
import uuid
from pathlib import Path
from flask import Flask, jsonify, request
from werkzeug.utils import secure_filename
app = Flask(__name__)
UPLOAD_FOLDER = Path("uploads")
UPLOAD_FOLDER.mkdir(exist_ok=True)
# 10MB,先做个小限制,省得一上来就把自己打崩
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024
@app.route("/")
def index():
return """
<form action="/upload" method="post" enctype="multipart/form-data">
<input name="avatar" type="file" />
<button>上传</button>
</form>
"""
@app.route("/upload", methods=["POST"])
def upload():
file = request.files.get("avatar")
if not file or not file.filename:
return jsonify(error="没有收到文件"), 400
original_name = file.filename
safe_name = secure_filename(original_name)
suffix = Path(original_name).suffix.lower()
if suffix not in [".png", ".jpg", ".jpeg", ".webp"]:
return jsonify(error="暂时只支持常见图片格式"), 400
stored_name = f"{uuid.uuid4().hex}{suffix}"
save_path = UPLOAD_FOLDER / stored_name
file.save(save_path)
return jsonify(
ok=True,
stored_name=stored_name,
original_name=original_name,
path=str(save_path),
)
不出问题的话就没有问题了。
在容器里运行:
flask --app app run --host 0.0.0.0 --port 8000 --debug
浏览器打开:
http://localhost:8000
选个图片上传,应该会看到一段 JSON。回到宿主机的当前目录,会发现多了一个 uploads 文件夹,里面已经有文件了。
这里有个很容易疑惑的点:容器里路径显示的是 uploads/xxx.png,看起来像容器内部路径,但其实因为启动时用了:
-v $PWD:/app
所以宿主机当前目录和容器里的 /app 是同一块。文件最后就落在你当前目录的 uploads 里。
如果你没有挂载目录,退出容器后文件就会随着容器一起消失。你以为上传成功了,其实服务器只是在你面前演了一场戏。
也可以用 curl 上传
浏览器上传有时候不方便调试,尤其是你想看请求到底发了什么。可以用 curl 试试:
curl -X POST -F "avatar=@/path/to/test.png" http://localhost:8000/upload
这里 -F 是表单文件上传,avatar 要和后端接收的名字一致,@ 后面是本地文件路径。
这个命令很适合排查问题。比如你怀疑后端没收到,可以先不用前端页面,直接用 curl 打上去。如果 curl 能成,说明后端大概率没问题,问题在前端表单;如果 curl 也失败,说明请求路径、编码、文件大小限制或者代理层更值得怀疑。
为什么经常是“文件没上传成功”但错误五花八门
文件上传链路比较长,任何一个环节都可能挡你。
第一个常见坑是请求体大小限制。很多反代会默认限制上传大小,比如 Nginx:
client_max_body_size 10m;
如果你上传 20MB 文件,后端代码还没见到文件,Nginx 就先返回 413 了。
第二个坑是表单编码没写。有人写成:
enctype="application/x-www-form-urlencoded"
这个类型下文件不会被当成 multipart 上传,后端 request.files 很可能拿不到东西。
第三个坑是后端只检查扩展名。扩展名可以随便改,virus.exe 改成 cat.jpg 照样能骗过简单校验。生产里至少要再看 MIME,更稳一点可以看文件头。不过初学者阶段先理解“扩展名不可信”就已经很重要了。
第四个坑是直接用用户上传的文件名保存。文件名里可能有空格、中文、换行,最恶心的是类似:
../../something.png
所以不要拿原始文件名直接拼接路径。至少要做安全过滤,最好自己生成随机文件名,把原始名字另外保存到数据库字段里。
第五个坑是权限。服务启动用户能不能在目标目录里写文件?容器里的路径对不对?挂载目录有没有权限?这些东西在错误日志里有时候很安静,安静到你以为代码没报错,其实就是写不进去。
文件到底什么时候才真正“上传完成”
浏览器开始读取文件并发送请求时,服务端不一定已经完整收到文件。HTTP 是流式传输,大文件可能一点点进缓冲区。
Flask、Werkzeug 这类框架通常会处理 multipart 解析。文件可能先进入内存,或者进入临时文件,超过一定阈值后再落盘。不同框架行为不一样。
所以你看到的 file.save(...) 并不是“浏览器点一下上传,文件瞬间出现在磁盘上”。更准确的说法是:请求先被服务器接收,框架解析出文件对象,你的代码再把这个文件对象保存到指定位置。
如果请求被代理截断,如果客户端网络断了,如果超时了,如果服务端在保存前崩了,都可能留下不完整文件。生产系统里经常要处理这种半成功状态。
那要不要把文件直接存数据库
初学的时候很容易想:既然文件已经上传了,那就存进数据库吧,这样查询方便,管理方便。
实际上多数情况下不建议把文件本体塞进数据库,尤其是图片、视频、压缩包这类东西。数据库更擅长存结构化数据,不擅长存大二进制。你把文件塞进去,备份、迁移、查询、连接池、内存都会开始抱怨你。
比较常见的是:文件本体存磁盘或者对象存储,数据库只存路径、原始文件名、大小、MIME、哈希、创建时间、上传用户。
如果只是本地学习,用这个最小例子里的磁盘方案就够用了。等后面你真的要做生产,再去研究对象存储、临时目录、生命周期、防盗链、图片压缩、病毒扫描那些东西。
一个最小配置检查清单
如果你现在正卡在上传功能上,可以按这个顺序查:
1. 前端有没有 enctype="multipart/form-data"?
2. input 的 name 和后端获取的名字是不是完全一致?
3. 浏览器 Network 里请求有没有变成 POST?
4. 请求里有没有看到 file 部分,而不是空表单?
5. 后端有没有文件目录写入权限?
6. 文件保存路径是不是你以为的那个路径?
7. 有没有 Nginx / 网关 / CDN 的 body size 限制?
8. 服务端有没有捕获 413、400、500?
9. 容器环境里有没有挂载上传目录?
10. 文件名处理是不是太信任用户输入了?
这些东西看起来琐碎,但文件上传的问题基本都藏在这里。不是每个坑都会碰到,但只要碰到一个,就够你查半小时。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。