小Cの已经记不起来的博客

文件上传:写给初学者的通俗解释

最近在折腾一个小功能,页面里放个头像上传框,浏览器点一下,服务器那边没动静,日志里只看到一句 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. 文件名处理是不是太信任用户输入了?

这些东西看起来琐碎,但文件上传的问题基本都藏在这里。不是每个坑都会碰到,但只要碰到一个,就够你查半小时。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放