K 的一隅

Python FastAPI 后端实战

文件怎么进服务:上传与存储边界

multipart 表单、UploadFile 的用法、内存与落盘阈值,以及 API 层接收文件与存储服务职责如何划分。

9 分钟阅读 更新于 2026-07-28
本文目录

浏览器提交头像、导入 CSV、附件表单——HTTP body 不再是纯 JSON,而是 multipart/form-data:字段与文件块交替出现。服务端若自己解析字节流,要处理 boundary、filename、content-type,容易出错。FastAPI 用 UploadFileFile / Form 声明参数,Starlette 在后台把流式 body 拆成可 async 读取的文件对象。真正容易踩坑的是存储边界:文件读进内存还是 spool 到磁盘、谁算 hash、谁上传对象存储、数据库里存什么——这些应在 API / Service / Storage 之间划清,而不是在路由里堆满 open() 和 boto 调用。

multipart 与 UploadFile

客户端(或 TestClient)用表单字段 + 文件:

python
# 测试侧示意
files = {"file": ("report.csv", b"a,b\n1,2", "text/csv")}
data = {"category": "monthly"}
client.post("/imports", data=data, files=files)

服务端:

python
from typing import Annotated

from fastapi import FastAPI, File, Form, UploadFile

app = FastAPI()


@app.post("/imports")
async def create_import(
    category: Annotated[str, Form()],
    file: Annotated[UploadFile, File()],
) -> dict[str, str]:
    if file.content_type not in ("text/csv", "application/vnd.ms-excel"):
        raise HTTPException(status_code=415, detail="unsupported media type")
    content = await file.read()
    # 交给 Service,不在此写业务
    job_id = import_service.enqueue(category=category, filename=file.filename or "upload", data=content)
    return {"job_id": job_id}

UploadFile 提供:

  • filenamecontent_type 元数据(不可信,需校验扩展名与魔数)
  • await file.read() / async for chunk in file 流式读取
  • 底层可能是 SpooledTemporaryFile:小文件在内存,超过阈值写临时磁盘

大文件应用 chunk 循环 写存储,避免一次 read() 吃光内存:

python
async def save_upload_stream(file: UploadFile, dest: Path, chunk_size: int = 1024 * 1024) -> int:
    total = 0
    with dest.open("wb") as out:
        while chunk := await file.read(chunk_size):
            total += len(chunk)
            out.write(chunk)
    await file.seek(0)  # 若后续还要读,需 rewind 或禁止重复读
    return total

File 与 Form 一起用

多文件、可选文件:

python
@app.post("/albums/{album_id}/photos")
async def upload_photos(
    album_id: int,
    photos: Annotated[list[UploadFile], File()],
    caption: Annotated[str | None, Form()] = None,
) -> dict[str, object]:
    ...

list[UploadFile] 时 HTML 表单需同名多 file input。纯 JSON API 不能带文件——需改 multipart 或分「先拿上传 URL、再 PUT 对象存储」的预签名流程(大文件、移动端常用)。

预签名直传流程:API 返回 {upload_url, key},客户端 PUT 到对象存储,再 POST /assets/complete 写元数据。API 不经手大 body,带宽与超时压力在 CDN/存储侧。Service 仍管 key 规则与权限,Storage 适配器提供 presign_put(key) -> url

两种上传模式对照

课件里的「上传模式」核心不是 UploadFile 语法,而是字节走哪条路

模式数据路径优点代价
服务端中转浏览器 → FastAPI(UploadFile)→ OSS/本地盘实现简单,易在服务端扫毒/转码占应用带宽与内存,大文件易超时
客户端直传浏览器 →(预签名)→ OSS;API 只签发与确认应用服务器轻,易扩要处理回调/complete、权限与伪造 complete

直传时 API 常见两步:POST /upload/sign 校验登录与文件元数据,短事务读 OSS 配置后返回临时凭证;客户端上传成功后 POST /upload/complete 写业务表。IAM/STS 策略应最小权限(只允许 Put 到指定前缀),不要把主账号密钥下发给浏览器。中转模式适合头像、小 CSV;视频、大包优先直传。

两种模式可以并存:小文件 UploadFile,大文件 sign+complete,Service 写 metadata 的接口尽量统一,避免两套仓储逻辑。

存储边界:三层各管什么

文件相关职责
API收 UploadFile、大小/类型初筛、调 Service
Service病毒扫描策略、命名规则、写元数据记录、事务
Storage 适配器本地目录 / S3 / MinIO 的 put/get/delete
python
class StorageBackend(Protocol):
    async def put(self, key: str, data: bytes, content_type: str) -> str: ...
    def url_for(self, key: str) -> str: ...


class LocalStorage:
    def __init__(self, root: Path) -> None:
        self.root = root

    async def put(self, key: str, data: bytes, content_type: str) -> str:
        path = self.root / key
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(data)
        return key

    def url_for(self, key: str) -> str:
        return f"/static/{key}"

Service:

python
class AssetService:
    def __init__(self, storage: StorageBackend, repo: AssetRepository) -> None:
        self.storage = storage
        self.repo = repo

    async def store_avatar(self, user_id: int, file: UploadFile) -> str:
        raw = await file.read()
        if len(raw) > 2 * 1024 * 1024:
            raise AppError(code="FILE_TOO_LARGE", message="max 2MB")
        key = f"avatars/{user_id}/{uuid.uuid4().hex}.webp"
        await self.storage.put(key, raw, file.content_type or "application/octet-stream")
        self.repo.save_asset(user_id=user_id, key=key)
        return self.storage.url_for(key)

数据库里存 key 或 URL,不要默认把 BLOB 塞进业务表——除非文件极小且强一致事务要求同事务提交(仍要评估备份体积)。

AssetRepository 只存 user_id, key, size, checksum, created_at;下载时 Service 调 storage.url_for 或生成限时签名。删除用户时要 cascade 删对象存储里的 key,通常 Service 编排 repo.list_keys + storage.delete,避免孤儿文件。

安全与校验

  • 限制大小:反向代理 client_max_body_size + 应用内 read 前查 Content-Length(可伪造,以流计数为准)。
  • 类型:不信 content_type,结合扩展名与文件头(magic bytes)。
  • 路径:保存时用生成的 UUID key,禁止用户控制 ../ 路径。
  • 病毒扫描:异步队列处理,上传接口先收再扫,失败则删对象并更新状态。

图片还可做尺寸/比例校验、重新编码 strip EXIF,减少恶意 metadata。CSV 导入应对行数上限做 Service 级限制,避免一次上传拖垮 worker。

依赖注入 Storage

与 DB 相同,Storage 通过 Depends 注入,测试 override 为 InMemoryStorage

python
def get_storage(settings: SettingsDep) -> StorageBackend:
    return LocalStorage(root=settings.upload_dir)


StorageDep = Annotated[StorageBackend, Depends(get_storage)]

路由只收 UploadFile 并传给 AssetService;Service 构造函数接受 StorageBackend,不 import 具体 S3 客户端类。

测试

TestClient 传 bytes 不需真磁盘:

python
def test_upload_avatar(client: TestClient) -> None:
    app.dependency_overrides[get_storage] = lambda: InMemoryStorage()
    try:
        resp = client.post(
            "/users/1/avatar",
            files={"file": ("a.png", b"\x89PNG\r\n", "image/png")},
        )
        assert resp.status_code == 200
        assert "url" in resp.json()
    finally:
        app.dependency_overrides.clear()

集成测试可对临时目录 LocalStorage 断言文件存在。

同步 vs 异步上传路由

async def 路由应用 await file.read();若在 sync 路由里接 UploadFile,Starlette 仍可能阻塞线程池读盘。大文件 + 慢存储(S3 upload)应用 async storage client 或 run_in_threadpool 包同步 SDK。Worker 超时(Uvicorn timeout)要大于最慢上传预期,或改异步任务:接口只收文件登记 job_id,后台 Celery/ARQ 慢慢传。

表单字段与 JSON 混用限制

同一请求不能既 application/json 又 multipart。前端改传 FormData 时,Query 参数仍可用,但 body 只能是表单。文档要在 OpenAPI 里写清 multipart/form-data schema,避免客户端仍 POST JSON 导致 422。列表页上传多图时,注意浏览器与 Starlette 对多 file 字段名的约定,集成测试用 TestClient files=[(...), (...)] 覆盖。

清理与合规

用户删号、 GDPR 删除请求时,Service 应列出该用户全部 object key 并调 storage 批量 delete,再删 DB 行。只删 DB 不删对象,桶里会堆孤儿文件,成本与合规都有风险。生命周期规则(S3 lifecycle expire)是兜底,不应替代应用层主动删。

图片处理与衍生版本

头像场景可在 Service 里调用 Pillow 生成 thumbnail,原图与缩略图两个 key 都存 storage。CPU 重的工作放任务队列,上传接口只 accept 并 enqueue。失败时更新 job 状态,客户端轮询或 WebSocket 通知——UploadFile 只负责入口字节,不负责整条 pipeline。

合规保留与审计

某些行业要求文件保留 N 年;storage lifecycle 与 DB retention_until 字段一致。审计 log 记谁在上传、checksum、大小,不记全文件内容。下载接口记 access log 满足追溯。

与三层架构对齐回顾

Upload 路由 → AssetService → AssetRepository + StorageBackend。路由不出现 boto3,Repository 不出现 Pillow。测试分别 override storage 与 repo,Service 单测用 Fake 二者。这与本系列第 13 篇分层原则一一对应。

断点续传与分片(概念)

超大文件可用分片 upload:客户端每片 POST,Service 记录 etags,最后 complete multipart upload。API 形态与单次 UploadFile 不同,但 Storage 适配器接口可复用。MVP 用单文件 UploadFile 即可,规模上来再演进,不必第一天就上 multipart S3 API。

常见误解

UploadFile.file 当普通同步 IO 在 async 路由里狂读。 优先 await file.read();同步磁盘 IO 量大时用 run_in_threadpool

上传完成等于可公开访问。 私有桶应走签名 URL 或鉴权下载路由,不要把内网 path 直接暴露。

一个路由里既解析 CSV 又发邮件又写库。 解析可以 Service 分步;路由只接文件。

忘记 await file.close() 框架通常在请求结束清理;若在依赖或后台任务里复制 UploadFile,要自己管生命周期。

内容分发与 CDN

公开静态资源可走 CDN,API 只返回 signed URL。上传仍进源站或直传 bucket,CDN 只加速读。缓存头 Cache-Control 由 storage 或下载路由控制,与 UploadFile 接收无关但属于同一「文件生命周期」故事线。

文件进服务、元数据进库、对象进存储——边界清楚之后,后面的配置生命周期、事务长度、认证注入都会落在明确的层上,而不是挤在同一个路由函数里。

选型本地盘 vs 对象存储:开发用 LocalStorage,生产用 S3 兼容,接口都是 StorageBackend,Service 无感切换。迁移历史文件时 batch job 走 Service + Repository 更新 key,UploadFile 路径不参与。监控 bucket 用量与上传 413/415 比例,异常升高可能是攻击或客户端 bug。

表单上传适合中小文件与浏览器直传;移动端大视频倾向 presigned。两种入口可以共存,不同路由,同一 Service 写 metadata。文档写清 size limit 与 supported types,减少无效上传浪费带宽。

小结

multipart + UploadFile 接收文件,Storage 适配器持久化,Repository 存 metadata。大文件流式读写,安全校验不信任客户端 MIME。测试用 bytes 与 InMemoryStorage,生产可换 S3 而不改 Service 签名。

与 JSON API 并存时,OpenAPI 文档应对 multipart endpoint 单独示例,前端 axios/fetch 需用 FormData 而非 JSON.stringify。后端 async 路由 + 流式写盘,可避免 worker 被大文件 block。上传失败应返回统一 error envelope(415 类型不对、413 过大),与系列异常处理篇一致,便于客户端统一 toast 或表单错误展示。

下载接口返回 FileResponse 或 redirect 到 signed URL 时,仍保持 API 层薄:Service 校验权限,Storage 生成 URL,路由只组装 Response。

UploadFile 解决「怎么收」;Storage 解决「怎么存」;Repository 解决「记什么」——三者边界清楚,上传功能才不易长成路由里的泥球。

multipart 边界由客户端生成,服务端勿手动拼接 raw body;始终用框架提供的 UploadFile/Form 解析,减少安全漏洞与编码错误。

上传与下载对称设计:存 key、验权限、再读 storage,三步骤模式复用。

进度反馈:大文件上传可返回 job_id,客户端轮询 status endpoint;UploadFile 只负责入口,异步 pipeline 在 Service 与 worker 中完成,避免 HTTP 请求超时断开导致半成品文件。

存储适配器接口稳定后,换云厂商只改实现类,业务与路由无感。

校验失败尽早返回,避免读完整个大文件才 415。

流式校验 Content-Type 与 magic bytes,别等整文件落盘后再拒绝。

UploadFile 负责收字节,Storage 负责持久化,分工明确才更容易测、换实现。