Python API 開發:善用 Enum 的三大關鍵特性
文章目錄
from Pixabay
想必你知道 Python 中有一個內建的特殊類別叫 Enum(來自 enum 模組),專門用來處理「列舉」態型的資料集合。
如同 collection 模組中的各種容器(比如:deque
、Counter
),Enum 已經定義好很多「內建特性(屬性、方法、行為)」供你使用。這些特性會讓你在處理特定情境時非常順手。
但是,這些特性也使得 Enum 類別與一般類別有著很大的差異,增加了學習門檻。
如你所見,Enum 的特性頗多,這讓人在學習、使用之前,難免有點望之卻步——至少我是這樣!
本文主旨與架構
本文分享我最近才開始把 Enum 應用在 API 開發中的經驗——從它的三大特性入手,並輔以一個實際問題情境。
Enum 的特性不少,但只要知曉這三件事,就可以在遇到「列舉」欄位時,善用 Enum 來提升開發效率,同時增進程式碼的簡潔與穩健。
不過話說回來,即使不是開發 API,也不影響你對本文的理解。只是我的經驗是從後端開發而來。
本文架構
為了讓你感受 Enum 的強大與美妙,本文的架構經過精心設計。共分為三個部分:
- 問題情境:我會先提出一個問題情境,突顯舊方法的不足。
- 然後再介紹 Enum 的三大特性,帶你進入 Enum 的世界。
- 最後看 Enum 特性在問題情境中如何有效發揮,讓程式碼變得更加優雅——也就是它解決的痛點。
藉由這個流程,相信你對 Enum 會有更進一步的理解。
話不多說,先從問題情境開始。
第一部分:問題情境
為了有效講解,舉個實例是必要的,但我會盡可能簡化,只著眼於能夠彰顯 Enum 價值的部分。
作為一個 Django API 開發者,不管是什麼專案,幾乎都會出現「列舉型」資料。此時 Django ORM 中對應的就是「choices」欄位。
假設我們現在要開發一個 Docker 相關的服務(這是我實際遇到的情境),其中一個 API 是「新增容器」。而容器有一個需要使用者輸入的設定是:restart policy。
從官方文件可知,Docker 容器的 restart policy 總共就只有 4 種而已——沒錯,它屬於列舉型資料!
Django Model 欄位設計
此時,我們的 Django model 欄位會長這樣:
1 | from django.db import models |
為了方便講解與避免混淆,這裡我採用了「數字(0-3)」作為列舉成員。但實際工作中,我使用的是「字串」,也就是'no'
、'always'
等:
1 | class Container(models.Model): |
這是一個重要的細節,以後會另篇討論:什麼時候列舉成員應該用數字,而什麼時候用字串更好。
為了專注於對 Enum 的學習,這裡採最常見的「數字」版本。
以往的做法
以前用 DRF(Django REST framework)開發 API 列舉資料時,除了在 model 增加了上述的 choices 限制,我並沒有使用 Enum。
這裡有一個問題:那前端人員怎麼知道列舉的「成員」有幾個、有哪些?當然只能靠 API 文件!
以往我們都是用 API Blueprint 寫 API 文件。說真的,我不喜歡(我的後端同事們也都不喜歡XD),太多手刻細節了——難怪它的普及率不高🙂↔️
雖然在 codebase 中看不到 Enum 的身影,但是,為了增加程式碼可讀性,我們還是會額外設計這樣的類別(這是為了後端開發者們在維護上的考慮):
1 | class RestartPolicy(): |
如何使用這個類別?
一個典型的情境是,在 view 函式中將變數值與列舉成員進行比較:
1 | if restart_policy == RestartPolicy.UNLESS_STOPPED |
有了RestartPolicy
,程式碼變得更加直觀,易讀。
換句話說,我們不會直接這樣寫:
1 | if restart_policy == 2 # 咦,2 是什麼東西? |
驗證列舉成員
如果列舉資料只限於後端自行使用,那上述的寫法其實就足夠了。
但是,很多時候列舉資料是從前端(使用者輸入)來的。
此時你首先要驗證前端給的資料,是不是屬於這個列舉集合的成員!套用上面的寫法就會顯得囉嗦:
1 | if payload.restart_policy not in ( |
為了不要讓程式這麼冗長,我們通常會幫這個類別加上一個簡單的ALL
屬性:
1 | class RestartPolicy(): |
於是程式碼變成這樣:
1 | if payload.restart_policy not in RestartPolicy.ALL: |
好像還可以,不是嗎?——但這顯然不是最佳實踐。
雖然在這個例子中,restart policy 主要就這 4 種,而且也不太會變動。
但如果在別的列舉場合,偶爾甚至不時會增加成員時,我們的 ALL 屬性的值也要跟著修正,這會產生潛在的「同步」問題——忘記一併更新 ALL 屬性。
使用序列化器驗證
話說回來,DRF 有序列化器模組,作為驗證輸入資料正確性的手段。
只要你有寫序列化器,通常也不需要像上述程式碼一般手工驗證。
本文適用於沒有序列化器輔助的情況,或者說,你就是要在某些自定義的邏輯進行手動驗證。
小結:目前寫法的問題
假設沒有序列化器輔助,要在程式碼中進行手動驗證,則上述寫法有兩個不妥之處:
- 要驗證 input 資料是否為合法列舉成員,太囉嗦、冗長了。
- 如果加上
ALL
這類自定義屬性,雖然可以減緩程式碼冗長問題,又產生了潛在的同步問題。
事實上,即使沒有ALL
屬性,第一種寫法一樣存在同步問題。因為你要手動維護這個驗證條件。
ALL
屬性只是把這個「新增、刪除列舉成員」的同步議題從 view 函式中(或任何其它用到的地方)抽離出來,統一移到類別中控管。
這或許可以算是一個改進:當情況變動時,需要跟著修改的地方只剩下一個——ALL
本身。但依舊不是最佳解法。
第二部分:介紹 Python Enum 的三大特性
自從寫 Django Ninja 後,為了渲染出嚴謹的 API 文件,我不得不用 Enum 了(可參考文章最後的補充部分),同時也感受到了它的強大!
我們來看看,採用 Enum 之後會有什麼改變。
在此之前,我們要先介紹 Enum 中,我認為最重要的三大特性。至少在我的例子中,知道這三個特性會非常有用。
我們看一下加入了 Enum 後的類別模樣:
1 | from enum import Enum |
我把ALL
屬性移除了,因為它不再需要。
我們就以這個新類別為例,來看看 Enum 的三大特性。
一、Enum 類別可以用 for 迴圈迭代
一般的類別並不能以 for 迴圈迭代,因為沒有實作__iter__
方法,但 Enum 有!
對於「列舉」這種有限成員的情況,能迭代它非常重要。
還記得原來的ALL
屬性嗎? ALL = [0, 1, 2, 3]
這個ALL
屬性的值,其實就是為了取得類別中的每一個屬性值。但有了 Enum,我們不用這麼麻煩了!
那我要怎麼取得 Enum 類別中的所有屬性值?這就要看下面第二個特性。
二、了解 Enum 實例
這段特別長,因為它包含了三個子命題,都和 Enum 實例有關。
這裡要先問:如何取得 Enum 實例?
Enum 實例的建構與條件
沒錯,和一般的類別相同,都是從建構與初始化開始,依舊使用上面的例子,我們可以像這樣獲得 Enum 實例:
1 | p = RestartPolicy(2) |
和一般類別不同的是,這裡的建構引數,只能是「任一 Enum 成員的屬性值」。也就是範例類別中的0
、1
、2
、3
。
這是 Enum 類別的限制,也是它的重要特性。
換句話說,所有 Enum 類別的實例,都一定是由 Enum 成員值(比如上述的 2)建構而來的。
此外,沒有引數也不行!
1 | # 沒有引數會報錯 |
而建構出的實例,就是「代表該成員」的 Enum 實例:
1 | 2) RestartPolicy( |
Enum 類別屬性「就是」Enum 實例
這是 Enum 最重要的特性之一,也是最容易讓人困惑的地方。
除了透過建構,其實直接呼叫類別屬性,也能夠得到同一個 Enum 成員實例!
這是一般類別所難以想像的,因為一般類別呼叫類別屬性後,只會得到單純的值,比如本文一開始的例子:
1 | # 這裡是非 Enum 版的 RestartPolicy |
回到 Enum。換言之,下面兩種寫法,獲得的結果完全相同:
1 | 2) RestartPolicy( |
1 | RestartPolicy.UNLESS_STOPPED |
不了解這個特性,會讓你對 Enum 的使用產生很大困惑。
單例模式
更進一步說,Enum 類別的每一個成員實例,實際上都是單例。
這意味著對於每個成員,無論你在程式碼中呼叫多少次,都是指向「同一個」物件。
1 | 2) p1 = RestartPolicy( |
了解這一點,就能夠更好地利用 Enum 來處理列舉資料。
Enum 實例的兩個屬性
所有 Enum 成員實例都有兩個內建的屬性:name
與value
。
name
是成員的名稱,也就是類別屬性名稱;value
則是成員的值。
我們分別使用兩種不同的實例取得方式來呼叫這兩個屬性!
1 | 2).name RestartPolicy( |
1 | RestartPolicy.UNLESS_STOPPED.value |
綜上所述,如果用 for 迴圈迭代RestartPolicy
,你將得到(這裡使用repr
突顯):
1 | for i in RestartPolicy: |
得到RestartPolicy
的每一個成員實例。
三、Enum 實例之間可以進行比較
Enum 成員之間,支援身分比較(is
)和等值(==
)比較。
只不過,如前所述,每一個 Enum 成員都是「單例」。所以身分比較和等值比較對於 Enum 成員是等價的,也就是結果都相同。因為每個成員都是唯一的。
1 | 2) == RestartPolicy.UNLESS_STOPPED RestartPolicy( |
我認為大部分時候,使用等值(==
)比較就已經足夠了。
第三部分:使用 Enum 改進原有程式!
現在你對 Enum 的特性已經有了相當的了解。我們來看看原來的程式碼在使用 Enum 版本後,會發生什麼樣的變化。
回顧一下之前的問題:
- 要驗證 input 資料是否為合法列舉成員,太囉嗦、冗長了。
- 如果加上
ALL
這類自定義屬性,雖然可以減緩程式碼冗長問題,又產生了潛在的同步問題。
對問題一的改善
現在,你想要驗證 input 值是否屬於列舉成員,可以這樣寫(我們假設 input 值為 2,代表'unless-stopped'
:
1 | if payload.restart_policy in [p.value for p in RestartPolicy]: |
因為 RestartPolicy
類別現在是可以用 for
迭代了!而每一個元素則是成員實例,需要透過value
屬性取值,所以可以像上面那樣寫。
乍看之下,好像也沒有方便到哪去?確實如此😅,沒關係,我們還有後手。
Pydantic 預處理
如果你有用 Pydantic 對資料預處理(FastAPI、Django Ninja 等後端框架會自動做這件事),通常payload.restart_policy
的值,就已經是一個 Enum 實例了。
當然,如果有用 Pydantic 預處理,這個驗證根本不需要XD,這裡只是為了舉例。
無論如何,現在你有一個 Enum 實例,那驗證成員身分的寫法將異常簡單:
1 | if payload.restart_policy in RestartPolicy: |
對,就這麼簡單!
也就是你可以輕鬆寫下這樣的句型:
1 | if <Enum 實例> in <Enum 類別>: |
關於in
運算子
因為in
運算子的內部,本來就會「視情況」迭代後面接的物件了——in
會優先調用__contains__
方法,沒有實作此方法,才退回__iter__
,也就是所謂的迭代。
所以in
後面可以接兩種物件,以落實成員的查找:
- 有實作
__contains__
方法的物件。比如set
、dict
等。 - 有實作
__iter__
方法的物件。(所有的 iterable 都有)
而 Enum 類別正是一個 iterable——它實作了__iter__
方法。
透過「建立 Enum 實例」進行驗證
但如果你的payload.restart_policy
,就只是一般的值,而不是 Enum 成員。那該怎麼辦?難道真的要像上面那樣,寫落落長的[p.value in p in RestartPolicy]
嗎?
當然不!其實做法一樣非常簡單,那就是「直接用這個值來建立實例」。
前面說過,Enum 實例只能由 Enum 成員值建構而來。所以,如果你輸入的值不是合法的 Enum 成員,建構實例時就會拋出ValueError
。
換句話說,只要能成功建立實例,就是合法的成員!所以你可以這樣寫:
1 | try: |
如果是不合法的,比如 4,則會拋出ValueError
:
1 | 4) RestartPolicy( |
總之,記得處理這個例外。
對問題二的改善
既然 Enum 類別本身就可以用 for 迭代以取得所有成員(甚至直接使用in
運算子),那就完全沒必要再寫前面像ALL
一樣的屬性,然後還要維護它。
由此可見,Enum 真的棒!
補充:Enum 類別在 Django Ninja 中的應用
前面說到我會開始用 Enum,正是因為 Django Ninja。
Django Ninja 的 Schema 實際上就是 Pydantic 的 BaseModel。
Schema 用來描述 API 輸入與輸出的資料結構。主要的功能有二:
- 驗證輸入資料是否符合規範。
- 自動產生 API 文件。
我們看一下使用了 Enum 型別後的 Schema:
1 | class CreateContainerRequest(Schema): |
直接將restart_policy
欄位型別標記為RestartPolicy
,如此渲染出的 API 文件,不止能限定輸入資料的型別,連「值域」也會有明確標示:
型別:integer;值域:僅限 0、1、2、3
甚至還能把RestartPolicy
類別中的docstring
直接渲染成欄位說明,太貼心了吧!偉哉 Pydantic!
好處不止如此,一旦你變更了RestartPolicy
的內容(比如增加了成員選項),API 文件也會自動更新。
不必再手動修改 API 文件,也不必修改 Schema 的 type hints,完全省去了「同步」的煩惱。
使用「字串」列舉
除了上述的「數字列舉」版,我同時再附上「字串列舉」版本,看圖你應該就能理解,為何我一開始說,有時候列舉資料用字串會更好。
直接採用字串選項,對前端人員更加友善——更容易了解每個選項代表的意義。