ORM、@property 與 Celery 調試:
“為什麼 .only() 報告欄位不存在?”
在設計安全性重要的系統時,常會將 API Key 或 Secret 以加密形式存於模型,並透過 @property 只暴露解密後的值。
例如,結構大致如下:
_api_key→ 真實資料庫欄位(存儲加密值)api_key→ 透過@property進行解密後回傳api_key.setter→ 在儲存時自動加密
這種模式既乾淨又安全,但在與 ORM(以 Django ORM 為例)結合時,會不小心落入一個陷阱。
“我在模型裡一直用
api_key,為什麼 ORM 只說欄位不存在?”
本文整理了這個陷阱的成因、為何調試特別困難,以及如何避免。
1. 問題情境:欄位存在卻被報告不存在
在使用加密欄位的模型時,某天執行以下 ORM 查詢:
Model.objects.only("api_key").first()
或:
Model.objects.values("api_key")
或:
Model.objects.filter(api_key__isnull=False)
結果總是相同:
FieldDoesNotExist: Model has no field named 'api_key'
開發者自然會想:
“欄位確實存在,我每天都用
obj.api_key。”
實際模型大致如下(簡化示例):
class Model(models.Model):
_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
@property
def api_key(self):
if not self._api_key:
return None
return decrypt(self._api_key)
@api_key.setter
def api_key(self, value):
self._api_key = encrypt(value)
- 實例層:
obj.api_key正常工作 - 儲存層:
obj.api_key = "plain-text"也正常
但在 ORM 層卻報告欄位不存在。這看似奇怪,實際上從 ORM 的角度來說並不奇怪。
2. 原因:ORM 只認識「欄位名稱」,不會把 @property 當作欄位
核心很簡單:
@property製作的api_key不是實際欄位。
Django ORM 所謂「欄位」是指 models.Field 的子類別,亦即 Model._meta.fields 裡的項目。
在上述例子中,ORM 只知道:
_api_key = models.CharField(db_column="api_key", max_length=255, null=True)
這裡:
- 模型欄位名稱 →
_api_key - 資料庫欄位名稱 →
"api_key" @property api_key→ ORM 完全不知情
總結如下:
我們日常使用的名稱 (obj.api_key) |
ORM 了解的名稱 (Model._meta) |
|---|---|
api_key (property) |
_api_key (field name) |
因此,當寫:
Model.objects.only("api_key")
Model.objects.values("api_key")
Model.objects.filter(api_key__isnull=False)
ORM 會認為「沒有這個欄位」並拋出 FieldDoesNotExist。
ORM 只會以「欄位名稱」為基準:
- ✅
only("_api_key") - ✅
values("_api_key") - ✅
filter(_api_key__isnull=False) - ❌
only("api_key") - ❌
filter(api_key=...)
簡而言之:
Python 層使用的 property 名稱與 ORM 了解的欄位名稱混淆。
3. 為什麼在 Celery 裡更危險?
這看似「稍微麻煩」的錯誤,實際上在 Celery 任務內部會變得更棘手。
假設程式碼如下:
@shared_task(bind=True, max_retries=3)
def send_something(self, model_id):
try:
obj = Model.objects.only("api_key").get(id=model_id)
call_external_api(obj.api_key)
except SomeRecoverableError as exc:
raise self.retry(exc=exc)
真正的問題是:
only("api_key")觸發FieldDoesNotExist- 這個例外被某個上層捕捉,或被 Celery 的 retry 機制誤判為「暫時性錯誤」
- Celery 會將任務重新排程,導致「看似網路問題」的錯誤持續重試
結果:
- Celery 任務不斷重試
- 外部 API 甚至根本沒有收到請求
- 若不仔細查看日誌,容易誤以為是「外部伺服器無回應」
這種「靜默」的 bug 讓調試難度大幅提升。
4. 解決方案:在 ORM 中只使用「真實欄位名稱」
結論很簡單:
property 只在實例層使用,ORM 查詢時必須使用真實欄位名稱。
正確寫法:
# 錯誤示例(FieldDoesNotExist)
obj = Model.objects.only("api_key").get(...)
# 正確示例
obj = Model.objects.only("_api_key").get(...)
real_key = obj.api_key # 之後再用 property 取值
同理,過濾也要改成:
# 錯誤示例
Model.objects.filter(api_key__isnull=False)
Model.objects.filter(api_key="plain-text")
# 正確示例
Model.objects.filter(_api_key__isnull=False)
Model.objects.filter(_api_key=encrypt("plain-text"))
若想更安全,團隊可制定規則:
- 「以
_開頭的欄位僅在 ORM 內使用」 - 「對外暴露的 property 名稱不應在 ORM 查詢中使用」
- 審查時檢查
.values("...")、.only("...")、.filter(...)是否包含 property 名稱
5. 進階技巧:使用自訂欄位隱藏加密邏輯
在大型專案中,除了 @property,還可以考慮自訂模型欄位,例如 EncryptedCharField:
class EncryptedCharField(models.CharField):
def get_prep_value(self, value):
if value is None:
return None
return encrypt(value)
def from_db_value(self, value, expression, connection):
if value is None:
return None
return decrypt(value)
class Model(models.Model):
api_key = EncryptedCharField(max_length=255, db_column="api_key", null=True)
這樣:
- ORM 仍認為
api_key是真實欄位 - 代碼中可直接使用
obj.api_key,看似平文 .only("api_key")、.values("api_key")、.filter(api_key__isnull=False)都能正常工作
缺點:
- 需要實作並測試自訂欄位
- 可能影響遷移或相容性
根據專案規模與團隊情況,選擇「簡單的 @property + 規則」或「自訂欄位」即可。
6. 總結:易忽略但關鍵的注意點
- ORM 只理解「欄位名稱」;
@property不算欄位。 - 在 Celery 等非同步環境,錯誤可能被 retry 機制吞掉,變成「靜默」bug。
- 只在 ORM 查詢中使用真實欄位名稱,property 只在實例層使用。
- 若想更安全,可考慮自訂欄位或制定團隊規則。
這些注意點能幫你避免因加密欄位而導致的重複失敗與調試困難。

目前沒有評論。