2024 iThome 鐵人賽2024 iThome 鐵人賽

這是 Django Ninja 系列教學的第 23 篇。要開始介紹進階功能了!

現代 Web 服務中,檔案上傳是一個常見的情境。

無論是使用者上傳照片、夾帶附件,檔案上傳都是不可或缺的功能。

本文介紹如何在 Django Ninja 中實現圖片上傳功能,以使用者「上傳大頭貼」(以下都稱為 avatar,因為大頭貼感覺太可愛🥹)API 為例,帶你一步步了解這個過程。

本文所有的程式碼改動,可參考這個 PR


不過,在此之前,我們要先了解,本章有哪些主題。

第六章「API 進階功能」簡介

對 API 專案而言,進階功能能幫助我們應對複雜場景大型專案的挑戰。

雖然這是一個入門指南,但我們仍會涵蓋一些常見的進階功能,這些功能不僅提升 API 的靈活性,還能增強了系統效能與使用者體驗。

本章共有 5 篇,介紹 3 個常見的進階功能:

這些技術不僅對大型專案至關重要,也讓你能在 API 開發中,有效應對多變的需求。


了解了本章重點後,我們開始講述第一個功能——檔案上傳。

檔案上傳的主角——UploadedFile

在 Django Ninja 中,我們可以使用UploadedFile來接收上傳的檔案,它是 Django UploadedFile重新封裝,兩者基本上大同小異。

UploadedFile 概述

UploadedFile 是 Django 處理檔案上傳的核心物件。當使用者上傳檔案時,Django 會自動將檔案封裝成UploadedFile實例,方便我們進行後續的檔案處理和儲存

UploadedFile物件有很多屬性,其中比較常用的有:

  • name:上傳檔案的名稱。這可以用來存取檔案的原始檔名
  • size:檔案的大小(以位元組計算)。我們可以用來進行檔案大小的驗證。
  • content_type:檔案的 MIME 類型。這對於驗證上傳檔案的格式非常有用,比如確保檔案是圖片格式。我們後面會用到!
  • read():用來讀取檔案的內容。當需要進行自訂檔案處理時,我們可以使用這個方法來取得檔案的二進位資料。
  • chunks():當檔案非常大時,使用這個方法可以分塊讀取檔案,避免過多的記憶體佔用。

這些特性使得UploadedFile非常靈活,能夠應對各種上傳需求,從簡單的圖片上傳到大型檔案的處理。

OK,關於檔案上傳,了解UploadedFile這個核心元件就足夠了。

開始實作「上傳 avatar」API 的程式碼之前,我們要先進行一些「前置作業」。


檔案上傳,有很多部分其實和 Django 比較有關,而非 Django Ninja 範疇,所以我會重點帶過。

Django 專案相關設定

在實作檔案上傳功能之前,我們需要先告訴 Django:如何處理上傳的檔案。這涉及到MEDIA_URLMEDIA_ROOT的設定。

MEDIA_URLMEDIA_ROOT設定

  • MEDIA_URL:這是檔案的 URL 前綴,所有上傳的檔案會透過這個 URL 來存取。
  • MEDIA_ROOT:這是 Django 伺服器內部,實際儲存上傳檔案的路徑。

在專案的settings.py新增程式碼如下:

1
2
3
4
5
# NinjaForum/settings.py
...

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

如此一來,上傳的檔案會儲存在專案根目錄的media資料夾中,並通過/media/路徑來存取。

開發環境下的檔案存取

我們需要 Django 提供的static方法,來讓開發環境能夠直接存取這些檔案。

在專案的urls.py加上這行:

1
2
3
4
5
6
7
8
9
10
# NinjaForum/urls.py
from django.conf import settings
from django.conf.urls.static import static
...

urlpatterns = [
path('admin/', admin.site.urls),
path('', api.urls),
# 讓開發環境可以存取上傳的檔案,僅供開發環境使用
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

這段程式碼允許 Django 在開發環境下提供靜態檔案的存取。

建立ImageField欄位

ImageField是 Django 專門用來存放圖片的欄位,它實際上是存儲圖片的檔案路徑。類似的欄位還有FileField

程式碼如下:

1
2
3
4
# user/models.py
class User(AbstractUser):
...
avatar = models.ImageField(upload_to='avatars/', null=True)

搭配之前的settings.py設定,avatar欄位會將上傳的圖片存在media/avatars/資料夾中——這個路徑由MEDIA_ROOT與欄位upload_to共同決定。

移至本分支後,專案中已經有新的遷移檔,記得要資料庫遷移

1
2
3
python manage.py migrate
# 或
make migrate

附帶一提,ImageField依賴第三方套件——Pillow。這個套件為欄位提供了處理圖片功能,要先安裝欄位才能正常運作:

1
2
3
pip install Pillow
# 或
poetry add pillow

使用 Poetry 的讀者,直接在合併後的分支poetry install即可。


實作:上傳 avatar

前置作業結束,我們終於可以進入重頭戲。

以下是完整的「上傳 avatar」功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from ninja import File, Router, UploadedFile
from ninja.errors import HttpError
...

@router.post('/users/{int:user_id}/avatar/',summary='上傳 avatar')
def upload_avatar(
request: HttpRequest,
user_id: int,
avatar_file: UploadedFile = File()
) -> dict[str, str]:
"""
上傳 avatar
"""
# 檢查檔案類型
if not avatar_file.content_type.startswith('image/'):
raise HttpError(400, '檔案必須是圖片格式')

user = User.objects.get(id=user_id)
user.avatar = avatar_file
user.save()
return {'detail': '圖片上傳成功'}

以下是對這段程式碼的重點解析。

一、UploadedFile參數定義

在 view 函式的簽名中,UploadedFile作為 type hint 使用,而avatar_file參數則代表上傳的檔案。

你可以任意命名avatar_file這個參數,比如文件範例中是叫file。我特地取不同名字,就是要強調它的名字是完全可自訂的。

但是!無論你取什麼名稱,在發請求時,body 中的 key 也要使用相同的名稱

此時的 HTTP 請求應該是長這樣的:(留意 body 中的avatar_file

1
2
3
4
5
6
7
8
9
10
POST /users/1/avatar/
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

# 以下是 body 內容
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar_file"; filename="example.jpg"
Content-Type: image/jpeg

(binary image data here)
------WebKitFormBoundary7MA4YWxkTrZu0gW--

Header 中的Content-Typemultipart/form-data,而且每個 key-value 對都是以Content-Disposition: form-data;開頭。這種格式允許在一個請求中同時傳送多個不同類型的資料,包括文字和二進位檔案。

其中的細節,可以參考這篇〈multipart/form-data 初探〉。

二、File函式

定義=File()目的是在告訴 Django Ninja,這個參數應該從 HTTP 請求中的「上傳檔案」部分獲取。類似做法還有我們第 11 篇提到的Query

如果沒有這個標記,框架可能無法正確識別並處理上傳的內容。

三、檢查檔案類型

我們使用了UploadedFilecontent_type屬性,獲得這個 body 內容的檔案類型,確認它是圖片,然後才發行。

1
2
3
# 檢查檔案類型
if not avatar_file.content_type.startswith('image/'):
raise HttpError(400, '檔案必須是圖片格式')

這個方法雖然粗糙,但對於簡單的圖片上傳功能來說已經足夠。

測試上傳純文字文件的結果:

1
2
3
4
// 400 Bad Request
{
"detail": "檔案必須是圖片格式"
}

在生產環境中,你需要更嚴格的檢查,例如使用專門的圖片處理套件來驗證檔案內容。

四、儲存圖片

最後,我們將圖片賦值給Useravatar欄位,並呼叫save()方法。

Django 會自動處理檔案的儲存,如果名稱重複,它還會自動產生唯一的檔名,然後把檔案放到我們之前指定的位置。

透過 API 上傳了兩次一模一樣的 avatar 後,我們在專案根目錄使用tree指令看一下結果:

1
2
3
4
5
6
7
❯ tree media
media
└── avatars
├── my-avatar.png
└── my-avatar_gVwgCiG.png # 相同檔名第二次上傳,自動更名

2 directories, 2 files

可以看到,第二次上傳的圖片被「自動更名」了。這保證了檔名的唯一性

在生產環境中,最好自行定義檔案的統一命名格式,以確保更好的管理和安全性。


小結與下一步

本文介紹了如何在 Django Ninja 中實作檔案上傳功能,從前置設定到 API 實作,詳述了UploadedFile的使用方式。

接下來,我們將介紹另一項進階功能——分頁(Pagination),它能幫助你在回應大量資料時,有效提升效能與使用者體驗。