FastAPI – 表單與檔案上傳:UploadFile 屬性深入解析
簡介
在現代 Web 應用中,檔案上傳是最常見的需求之一,無論是使用者上傳頭像、簡報、或是批次匯入資料,都離不開對檔案的接收與處理。FastAPI 以其非同步設計與型別提示(type‑hint)支援,讓檔案上傳變得既簡潔又高效。
本單元聚焦於 UploadFile 這個核心類別,特別說明它的三個常用屬性:filename、content_type 與 file.read()。了解這些屬性的意義與正確使用方式,能讓開發者在 表單 + 檔案 的 API 中,快速完成驗證、儲存與後續處理,避免常見的效能與安全問題。
核心概念
1. 為什麼使用 UploadFile 而不是 bytes?
FastAPI 允許兩種方式接收檔案:
| 方式 | 型別 | 儲存方式 | 典型使用情境 |
|---|---|---|---|
bytes |
bytes |
直接讀入記憶體 | 檔案非常小(如 < 1 KB) |
UploadFile |
UploadFile |
暫存於磁碟(或系統暫存) | 大檔案、需要非同步讀寫、或要重複讀取 |
使用 UploadFile 可以 避免一次性將整個檔案載入記憶體,提升效能與穩定性,尤其在上傳圖片、影片或 CSV 等較大的檔案時格外重要。
2. UploadFile 的三大屬性
| 屬性 | 型別 | 目的 | 範例 |
|---|---|---|---|
filename |
str |
原始檔名(含副檔名) | "avatar.png" |
content_type |
str |
MIME 類型(如 image/png) |
"image/png" |
file |
SpooledTemporaryFile(類似檔案物件) |
透過 read()、write()、seek() 操作實體檔案 |
await upload_file.read() |
⚡ 小技巧:
content_type只是一個 聲明,若需嚴格驗證,仍要自行檢查檔案內容。
3. 非同步讀取檔案內容
UploadFile.file 本身是一個 非同步 的檔案物件,提供 await file.read()、await file.write() 等方法。以下範例示範如何在不阻塞事件迴圈的情況下,讀取全部位元組或分塊讀取。
程式碼範例 1:基礎檔案上傳與屬性取得
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
@app.post("/upload/basic")
async def upload_basic(file: UploadFile = File(...)):
# 取得檔名與 MIME 類型
filename = file.filename
mime = file.content_type
# 讀取全部內容(小檔案適用)
content = await file.read()
return {
"filename": filename,
"content_type": mime,
"size": len(content) # 以位元組為單位
}
說明:
File(...)表示此欄位為必填。await file.read()會一次性把檔案讀入記憶體,適合檔案小於 1 MB 左右。
程式碼範例 2:分塊讀取大型檔案(流式)
from fastapi import FastAPI, File, UploadFile
import aiofiles
app = FastAPI()
@app.post("/upload/stream")
async def upload_stream(file: UploadFile = File(...)):
# 以非同步方式寫入磁碟
async with aiofiles.open(f"tmp/{file.filename}", "wb") as out_file:
while chunk := await file.read(1024 * 1024): # 每次 1 MB
await out_file.write(chunk)
return {"detail": f"檔案已儲存至 tmp/{file.filename}"}
重點:
- 使用
while chunk := await file.read(size)讓程式只在需要時才讀取資料,降低記憶體佔用。aiofiles為非同步檔案 I/O 套件,配合UploadFile可完整保持非同步。
程式碼範例 3:驗證 MIME 類型與副檔名
from fastapi import FastAPI, File, UploadFile, HTTPException
app = FastAPI()
ALLOWED_TYPES = {
"image/jpeg": ".jpg",
"image/png": ".png",
"application/pdf": ".pdf",
}
@app.post("/upload/validate")
async def upload_validate(file: UploadFile = File(...)):
# 1️⃣ 檢查 MIME
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(status_code=400, detail="不支援的檔案類型")
# 2️⃣ 檢查副檔名是否相符(避免偽裝)
expected_ext = ALLOWED_TYPES[file.content_type]
if not file.filename.lower().endswith(expected_ext):
raise HTTPException(status_code=400, detail="副檔名與 MIME 不符")
# 3️⃣ 讀取前 512 位元組做簡易魔術數檢查(可自行擴充)
head = await file.read(512)
await file.seek(0) # 讀完後必須回到開頭,否則後續寫入會失敗
# 此處可加入更嚴格的檔案內容驗證
# 暫存至磁碟
async with aiofiles.open(f"uploads/{file.filename}", "wb") as f:
await f.write(head)
while chunk := await file.read(1024 * 1024):
await f.write(chunk)
return {"detail": "上傳成功"}
說明:
await file.seek(0)必須 在讀取過檔案後呼叫,否則後續的寫入會從檔案結尾開始。- 這個範例示範 MIME + 副檔名雙重驗證,降低惡意上傳的風險。
程式碼範例 4:同時接收多個檔案與一般欄位
from fastapi import FastAPI, File, Form, UploadFile
from typing import List
app = FastAPI()
@app.post("/upload/multi")
async def upload_multi(
description: str = Form(...),
files: List[UploadFile] = File(...)
):
saved = []
for f in files:
async with aiofiles.open(f"multi/{f.filename}", "wb") as out:
while chunk := await f.read(1024 * 1024):
await out.write(chunk)
saved.append(f.filename)
return {"description": description, "saved_files": saved}
亮點:
- 使用
Form(...)取得普通文字欄位。List[UploadFile]讓 API 能一次接收多個檔案,不需要額外迴圈在路由層。
4. UploadFile 內建的 spoiled 行為
UploadFile 內部使用 SpooledTemporaryFile,當檔案大小低於系統預設的 max_size(預設 1 MB) 時,會暫存於記憶體;超過時自動寫入磁碟。這意味著:
- 小檔案:
await file.read()仍會一次性載入記憶體。 - 大檔案:即使使用
await file.read(),FastAPI 也會在背後把檔案寫入磁碟,再一次性讀回,仍會佔用大量記憶體。
因此,對於超過 1 MB 的檔案,建議使用分塊讀取(如範例 2)以避免突發的記憶體尖峰。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方式 |
|---|---|---|
直接使用 await file.read() 讀取大型檔案 |
記憶體 OOM(Out‑Of‑Memory) | 改用 分塊讀取,或設定 max_length 限制檔案大小 |
忘記 await file.seek(0) |
後續寫入或再次讀取時得到空內容 | 讀完檔案後立即回到檔案開頭 |
只依賴 content_type 進行驗證 |
攻擊者可偽造 MIME,導致惡意檔案上傳 | 同時檢查 副檔名、魔術數(檔案前幾位元組) |
| 未設定檔案大小上限 | 大量上傳可能導致服務資源耗盡 | 在路由或全局層面使用 max_length、UploadFile 的 File(..., max_length=...) |
同步寫檔 (open(..., "wb")) |
阻塞事件迴圈,降低吞吐量 | 使用 aiofiles 或其他非同步 I/O 套件 |
推薦的最佳實踐
- 限制檔案大小:
File(..., max_length=5_242_880)(限制 5 MB)。 - 分塊處理:對於 > 1 MB 的檔案,使用
while chunk := await file.read(CHUNK_SIZE)。 - 雙重驗證:MIME + 副檔名 + 必要時的魔術數檢查。
- 非同步 I/O:全程保持非同步,避免
open()、write()等阻塞。 - 清理暫存檔:若自行將檔案寫入磁碟,完成後適時刪除或搬移至永久儲存區。
實際應用場景
| 場景 | 為何需要 UploadFile |
典型實作要點 |
|---|---|---|
| 使用者頭像上傳 | 圖片通常在 100 KB–2 MB,需即時回傳 URL | 直接 await file.read(),儲存至雲端儲存(S3、Google Cloud Storage),回傳公開 URL |
| 批次匯入 CSV/Excel | 檔案大小可能達數十 MB,需要逐行解析 | 使用分塊讀取,搭配 csv.AsyncReader 或 pandas.read_csv(..., chunksize=…) |
| 上傳 PDF 報表 | 需要驗證檔案類型並儲存於內部文件庫 | 先檢查 content_type、副檔名與前 4 位元組 %PDF,再寫入磁碟或雲端 |
| 影片或音訊檔上傳 | 檔案往往超過 100 MB,需要流式寫入 | 以 5 MB 為單位分塊寫入,並在寫入完成後觸發背景任務(轉碼、縮圖) |
| 多檔案表單(如問卷) | 同時上傳多張圖片與文字說明 | 使用 List[UploadFile] + Form,迭代處理每個檔案,保留對應的表單欄位值 |
總結
UploadFile 是 FastAPI 處理檔案上傳的 關鍵利器,透過 filename、content_type 與 file.read()(或 file.seek())等屬性,我們可以:
- 快速取得檔案原始資訊(檔名、MIME),便於日誌與驗證。
- 安全且效能友好地讀寫檔案:小檔案直接讀取,大檔案分塊處理,全部保持非同步。
- 結合表單欄位,同時接受文字與多檔案,滿足大多數實務需求。
在實作時,務必 限制檔案大小、使用分塊讀取、雙重驗證檔案類型,並配合 aiofiles 等非同步 I/O 库,才能打造既安全又高效的檔案上傳 API。掌握這些概念後,你就能在 FastAPI 專案中自信地處理任何檔案上傳情境,為使用者提供流暢且可靠的服務體驗。祝開發順利!