Skip to content

第 7 章 · 表单、文件上传与下载

本章目标:掌握用 Form 接收表单字段、用 File/UploadFile 接收文件上传(含多文件),理解表单编码的底层原理,并实现文件下载接口。

7.1 为什么需要 Form

前几章我们接收的都是 JSON 请求体,但有两类场景必须用表单

  1. HTML <form> 原生提交(登录页、无 JS 的传统页面);
  2. OAuth2 密码模式规范要求 usernamepassword 必须以表单字段发送,不能是 JSON。

表单数据使用两种编码:

  • 不带文件时是 application/x-www-form-urlencoded
  • 带文件时是 multipart/form-data

WARNING

接收表单或文件前必须安装 python-multipart,否则请求会直接报错:

bash
pip install python-multipart

这是 HTTP 协议层面的编码解析需求,不是 FastAPI 的可选组件。

7.2 用 Form 声明表单字段

Form 的用法与 BodyQuery 完全一致——支持默认值、校验、别名等所有参数。事实上官方文档明确指出:Form 是直接继承自 Body 的类。

python
from fastapi import FastAPI, Form

app = FastAPI()


@app.post("/login/")
async def login(
    username: str = Form(),          # 必填表单字段
    password: str = Form(),          # 必填表单字段
    remember: bool = Form(False),    # 带默认值的可选字段
):
    # 演示用:真实项目绝不能明文比对密码,见安全章节
    return {"username": username, "remember": remember}

用 curl 验证(注意是 -F 而不是 JSON 的 -d):

bash
curl -X POST http://127.0.0.1:8000/login/ \
     -F username=alice -F password=secret -F remember=true

不能与 JSON Body 混用

同一个路径操作里可以声明多个 Form 参数,但不能再声明期望接收 JSON 的 Body 字段——因为请求体的编码已经是 application/x-www-form-urlencodedmultipart/form-data,不再是 application/json。这是 HTTP 协议的限制,不是 FastAPI 的限制。

7.3 文件上传:bytes 与 UploadFile

File 用于声明上传文件(它继承自 Form)。FastAPI 提供两种接收方式:

python
from fastapi import FastAPI, File, UploadFile

app = FastAPI()


@app.post("/upload-small/")
async def upload_small(file: bytes = File()):
    # 方式一:声明为 bytes,整个文件内容一次性读进内存
    return {"size": len(file)}


@app.post("/upload/")
async def upload(myfile: UploadFile):   # 方式二:UploadFile,推荐
    contents = await myfile.read()      # 异步读取内容
    await myfile.seek(0)                # 读过的游标归零,方便再读一次
    return {
        "filename": myfile.filename,      # 原始文件名,如 report.pdf
        "content_type": myfile.content_type,  # MIME 类型,如 application/pdf
        "length": len(contents),
    }

两者的关键差异:

对比项bytes = File()UploadFile
内存占用全部读入内存spooled 文件:小文件在内存,超限自动落盘
元数据filename / content_type / file 等属性
接口普通 bytesfile-like 异步方法:read / write / seek / close
适用场景小文件(图标等)大文件(图片、视频、任意二进制)

UploadFile 的异步方法都需要 await;如果在普通 def 路径函数中,则可直接操作同步的 myfile.file(一个真正的 SpooledTemporaryFile)。

可选文件与元数据

把类型写成 Optional[UploadFile] 并给默认值 None 即为可选上传;也可以写 myfile: UploadFile = File(description="报表文件") 为文档添加描述。

7.4 多文件上传与上传大小限制实践

把参数声明成列表即可同时接收多个文件:

python
from typing import List
from fastapi import FastAPI, UploadFile

app = FastAPI()
MAX_SIZE = 5 * 1024 * 1024  # 业务层上限:5MB


@app.post("/uploads/")
async def uploads(files: List[UploadFile]):
    results = []
    for f in files:
        data = await f.read()
        if len(data) > MAX_SIZE:
            results.append({"filename": f.filename, "error": "文件超过 5MB"})
            continue
        if f.content_type not in ("image/png", "image/jpeg"):
            results.append({"filename": f.filename, "error": "仅支持 png/jpg"})
            continue
        # 实际项目中这里会写入对象存储(S3/OSS)而非本地磁盘
        results.append({"filename": f.filename, "bytes": len(data), "ok": True})
        await f.close()
    return {"results": results}

注意两点工程细节:

  • 大小限制要自己写业务校验:HTTP 层面没有"上传前"的大小检查,超大文件会先完整传输到服务端。生产上通常在反向代理(Nginx 的 client_max_body_size)先拦一道;
  • 务必关闭文件对象:循环处理多个文件时及时 await f.close(),避免临时文件句柄堆积。

7.5 文件下载:FileResponse

下载本质上是返回特殊响应类型。fastapi.responses 里的 FileResponse 会自动设置 Content-TypeContent-LengthLast-Modified,还支持分块传输:

python
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse

app = FastAPI()
STORE = Path("./files")  # 受控的文件目录


@app.get("/download/{name}")
async def download(name: str):
    # 关键安全点:防止路径穿越攻击(如 name=../../etc/passwd)
    safe_path = (STORE / Path(name).name).resolve()
    if not safe_path.is_relative_to(STORE.resolve()) or not safe_path.exists():
        raise HTTPException(status_code=404, detail="文件不存在")
    return FileResponse(
        path=safe_path,
        filename=f"附件-{safe_path.name}",  # 触发浏览器"另存为"的建议文件名
        media_type="application/octet-stream",
    )

filename 参数会生成 Content-Disposition: attachment 头;如果只是想让浏览器内联预览(如 PDF),省略 filename 并给出正确的 media_type 即可。

7.6 本章小结

  • 表单/文件需先安装 python-multipart;不带文件是 urlencoded,带文件是 multipart 编码;
  • Form 继承自 Body,参数能力完全一致,但不能与 JSON Body 共存于同一路径操作;
  • bytes + File() 简单但全量占内存;UploadFile 是 spooled 文件,带元数据和异步接口,是默认选择;
  • 多文件用 List[UploadFile],大小/类型校验属于业务逻辑,需要自己实现;
  • 下载用 FileResponse,永远不要把用户输入直接拼进路径——先取 .name 再 resolve 校验。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 使用 Form/File 接收表单和文件前,必须额外安装哪个包?

2. 关于 UploadFile 相比 bytes = File() 的优势,下列说法错误的是?

3. 同一个路径操作中同时声明了两个 Form 字段,还可以再加一个期望接收 JSON 的 Body 模型吗?

4. 下载接口中以用户输入的文件名拼接磁盘路径,最大的风险是什么?

🛠️ 动手实践

  1. 实现一个「头像上传」接口:只允许 jpg/png、最大 2MB,保存到 ./avatars/ 目录并把访问 URL 返回给客户端。
  2. 为第 2 题补上配套的下载接口,并用 curl -F "file=@big.png"curl -OJ 分别测试上传和下载全流程。
  3. 写一个同时接收「文件 + 表单备注」的接口(FileForm 混用),验证备注能正确到达服务端。

下一章我们处理另外两类"不起眼但常见"的输入:第 8 章 · Cookie 与 Header 参数