第 7 章 · 表单、文件上传与下载
本章目标:掌握用
Form接收表单字段、用File/UploadFile接收文件上传(含多文件),理解表单编码的底层原理,并实现文件下载接口。
7.1 为什么需要 Form
前几章我们接收的都是 JSON 请求体,但有两类场景必须用表单:
- HTML
<form>原生提交(登录页、无 JS 的传统页面); - OAuth2 密码模式规范要求
username和password必须以表单字段发送,不能是 JSON。
表单数据使用两种编码:
- 不带文件时是
application/x-www-form-urlencoded; - 带文件时是
multipart/form-data。
WARNING
接收表单或文件前必须安装 python-multipart,否则请求会直接报错:
pip install python-multipart这是 HTTP 协议层面的编码解析需求,不是 FastAPI 的可选组件。
7.2 用 Form 声明表单字段
Form 的用法与 Body、Query 完全一致——支持默认值、校验、别名等所有参数。事实上官方文档明确指出:Form 是直接继承自 Body 的类。
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):
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-urlencoded 或 multipart/form-data,不再是 application/json。这是 HTTP 协议的限制,不是 FastAPI 的限制。
7.3 文件上传:bytes 与 UploadFile
File 用于声明上传文件(它继承自 Form)。FastAPI 提供两种接收方式:
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 等属性 |
| 接口 | 普通 bytes | file-like 异步方法:read / write / seek / close |
| 适用场景 | 小文件(图标等) | 大文件(图片、视频、任意二进制) |
UploadFile 的异步方法都需要 await;如果在普通 def 路径函数中,则可直接操作同步的 myfile.file(一个真正的 SpooledTemporaryFile)。
可选文件与元数据
把类型写成 Optional[UploadFile] 并给默认值 None 即为可选上传;也可以写 myfile: UploadFile = File(description="报表文件") 为文档添加描述。
7.4 多文件上传与上传大小限制实践
把参数声明成列表即可同时接收多个文件:
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-Type、Content-Length 和 Last-Modified,还支持分块传输:
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. 下载接口中以用户输入的文件名拼接磁盘路径,最大的风险是什么?
🛠️ 动手实践
- 实现一个「头像上传」接口:只允许 jpg/png、最大 2MB,保存到
./avatars/目录并把访问 URL 返回给客户端。 - 为第 2 题补上配套的下载接口,并用
curl -F "file=@big.png"与curl -OJ分别测试上传和下载全流程。 - 写一个同时接收「文件 + 表单备注」的接口(
File与Form混用),验证备注能正确到达服务端。
下一章我们处理另外两类"不起眼但常见"的输入:第 8 章 · Cookie 与 Header 参数。