建立一個更高階別的查詢 API:正確使用Django ORM 的方式

pythontab發表於2013-02-25

摘要

在這篇文章裡,我將以反模式的角度來直接討論Django的低階ORM查詢方法的使用。作為一種替代方式,我們需要在包含業務邏輯的模型層建立與特定領域相關的查詢API,這些在Django中做起來不是非常容易,但透過深入地瞭解ORM的內容原理,我將告訴你一些簡捷的方式來達到這個目的。

概覽

當編寫Django應用程式時,我們已經習慣透過新增方法到模型裡以此達到封裝業務邏輯並隱藏實現細節。這種方法看起來是非常的自然,而且實際上它也用在Django的內建應用中。

>>> from django.contrib.auth.models import User
>>> user = User.objects.get(pk=5)
>>> user.set_password('super-sekrit')
>>> user.save()

這裡的set_password就是一個定義在django.contrib.auth.models.User模型中的方法,它隱藏了對密碼進行雜湊操作的具體實現。相應的程式碼看起來應該是這樣:

from django.contrib.auth.hashers import make_password
class User(models.Model):
    # fields go here..
    def set_password(self, raw_password):
        self.password = make_password(raw_password)

我們正在使用Django,建立一個特定領域的頂部通用介面,低等級的ORM工具。在此基礎上,增加抽象等級,減少互動程式碼。這樣做的好處是使程式碼更具可讀性、重用性和健壯性。

我們已經在單獨的例子中這樣做了,下面將會把它用在獲取資料庫資訊的例子中。

為了描述這個方法,我們使用了一個簡單的app(todo list)來說明。

注意:這是一個例子。因為很難用少量的程式碼展示一個真實的例子。不要過多的關心todo list繼承他自己,而要把重點放在如何讓這個方法執行。

下面就是models.py檔案:

from django.db import models
 
PRIORITY_CHOICES = [(1, 'High'), (2, 'Low')]
 
class Todo(models.Model):
    content = models.CharField(max_length=100)
    is_done = models.BooleanField(default=False)
    owner = models.ForeignKey('auth.User')
    priority = models.IntegerField(choices=PRIORITY_CHOICES, default=1

想像一下,我們將要傳遞這些資料,建立一個view,來為當前使用者展示不完整的,高優先順序的 Todos。這裡是程式碼:

def dashboard(request):
 
    todos = Todo.objects.filter(
        owner=request.user
    ).filter(
        is_done=False
    ).filter(
        priority=1
    )
 
    return render(request, 'todos/list.html', {
        'todos': todos,
    })


注意:這裡可以寫成request.user.todo_set.filter(is_done=False, priority=1)。但是這裡只是一個實驗。

為什麼這樣寫不好呢?

首先,程式碼冗長。七行程式碼才能完成,正式的專案中,將會更加複雜。

其次,洩露實現細節。比如程式碼中的is_done是BooleanField,如果改變了他的型別,程式碼就不能用了。

然後就是,意圖不清晰,很難理解。

最後,使用中會有重複。例:你需要寫一行命令,透過cron,每週傳送給所有使用者一個todo list,這時候你就需要複製-貼上著七行程式碼。這不符合DRY(do not repeat yourself)

讓我們大膽的猜測一下:直接使用低等級的ORM程式碼是反模式的。

如何改進呢?

使用 Managers 和 QuerySets

首先,讓我們先了解一下概念。

Django 有兩個關係密切的與表級別操作相關的構圖:managers 和 querysets

manager(django.db.models.manager.Manager的一個例項)被描述成 “透過查詢資料庫提供給Django的外掛”。Manager是表級別功能的通往ORM大門。每一個model都有一個預設的manager,叫做objects。

Quesyset (django.db.models.query.QuerySet) 是“資料庫中objects的集合”。本質上是一個SELECT查詢,也可以使用過濾,排序等(filtered,ordered),來限制或者修改查詢到的資料。用來 建立或操縱 django.db.models.sql.query.Query例項,然後透過資料庫後端在真正的SQL中查詢。


啊?你還不明白?

隨著你慢慢深入的瞭解ORM,你就會明白Manager和QuerySet之間的區別了。

人們會被所熟知的Manager介面搞糊塗,因為他並不是看上去那樣。

Manager介面就是個謊言。

QuerySet方法是可連結的。每一次呼叫QuerySet的方法(如:filter)都會返回一個複製的queryset等待下一次的呼叫。這也是Django ORM 流暢之美的一部分。

但是當Model.objects 是一個 Manager時,就出現問題了。我們需要呼叫objects作為開始,然後連結到結果的QuerySet上去。

那麼Django又是如何解決呢?

介面的謊言由此暴露,所有的QuerySet 方法基於Manager。在這個方法中,透過

讓我們立刻回到todo list ,解決query介面的問題。Django推薦的方法是自定義Manager子類,並加在models中。

self.get_query_set()的代理,重新建立一個QuerySet。
class Manager(object):
 
    # SNIP some housekeeping stuff..
 
    def get_query_set(self):
        return QuerySet(self.model, using=self._db)
 
    def all(self):
        return self.get_query_set()
 
    def count(self):
        return self.get_query_set().count()
 
    def filter(self, *args, **kwargs):
        return self.get_query_set().filter(*args, **kwargs)
 
    # and so on for 100+ lines...


你也可以在model中增加多個managers,或者重新定義objects,也可以維持單個的manager,增加自定義方法。


下面讓我們實驗一下這幾種方法:


方法1:多managers

class IncompleteTodoManager(models.Manager):
    def get_query_set(self):
        return super(TodoManager, self).get_query_set().filter(is_done=False)
 
class HighPriorityTodoManager(models.Manager):
    def get_query_set(self):
        return super(TodoManager, self).get_query_set().filter(priority=1)
 
class Todo(models.Model):
    content = models.CharField(max_length=100)
    # other fields go here..
 
    objects = models.Manager() # the default manager
 
    # attach our custom managers:
    incomplete = models.IncompleteTodoManager()
    high_priority = models.HighPriorityTodoManager()

這個介面將以這樣的方式展現:

>>> Todo.incomplete.all()
>>> Todo.high_priority.all()

這個方法有幾個問題。

第一,這種實現方式比較囉嗦。你要為每一個query自定義功能定義一個class。

第二,這將會弄亂你的名稱空間。Django開發者吧Model.objects看做表的入口。這樣做會破壞命名規則。

第三,不可連結的。這樣做不能將managers組合在一起,獲得不完整,高優先順序的todos,還是回到低等級的ORM程式碼:Todo.incomplete.filter(priority=1) 或Todo.high_priority.filter(is_done=False)

綜上,使用多managers的方法,不是最優選擇。

方法2: Manager 方法

現在,我們試下其他Django允許的方法:在單個自定義Manager中的多個方法

class TodoManager(models.Manager):
    def incomplete(self):
        return self.filter(is_done=False)
 
    def high_priority(self):
        return self.filter(priority=1)
 
class Todo(models.Model):
    content = models.CharField(max_length=100)
    # other fields go here..
 
    objects = TodoManager()

我們的API 現在看起來是這樣:

>>> Todo.objects.incomplete()
>>> Todo.objects.high_priority()

這個方法顯然更好。它沒有太多累贅(只有一個Manager類)並且這種查詢方法很好地在物件後預留名稱空間。(譯註:可以很形象、方便地新增更多的方法)

不過這還不夠全面。 Todo.objects.incomplete() 返回一個普通查詢,但我們無法使用 Todo.objects.incomplete().high_priority() 。我們卡在 Todo.objects.incomplete().filter(is_done=False),沒有使用。

方法3:自定義QuerySet

現在我們已進入Django尚未開放的領域,Django文件中找不到這些內容。。。

class TodoQuerySet(models.query.QuerySet):
    def incomplete(self):
        return self.filter(is_done=False)
 
    def high_priority(self):
        return self.filter(priority=1)
 
class TodoManager(models.Manager):
    def get_query_set(self):
        return TodoQuerySet(self.model, using=self._db)
 
class Todo(models.Model):
    content = models.CharField(max_length=100)
    # other fields go here..
 
    objects = TodoManager()

我們從以下呼叫的檢視程式碼中可以看出端倪:

>>> Todo.objects.get_query_set().incomplete()
>>> Todo.objects.get_query_set().high_priority()
>>> # (or)
>>> Todo.objects.all().incomplete()

差不多完成了!這並有比第2個方法多多少累贅,得到方法2同樣的好處,和額外的效果(來點鼓聲吧...),它終於可鏈式查詢了!

>>> Todo.objects.all().incomplete().high_priority()

然而它還不夠完美。這個自定義的Manager僅僅是一個樣板而已,而且 all() 還有瑕疵,在使用時不好把握,而更重要的是不相容,它讓我們的程式碼看起來有點怪異。

>>> Todo.objects.all().high_priority()

方法3a:複製Django,代理做所有事

現在我們讓以上”假冒Manager API“討論變得有用:我們知道如何解決這個問題。我們簡單地在Manager中重新定義所有QuerySet方法,然後代理它們返回我們自定義QuerySet:

class TodoQuerySet(models.query.QuerySet):
    def incomplete(self):
        return self.filter(is_done=False)
 
    def high_priority(self):
        return self.filter(priority=1)
 
class TodoManager(models.Manager):
    def get_query_set(self):
        return TodoQuerySet(self.model, using=self._db)
 
    def incomplete(self):
        return self.get_query_set().incomplete()
 
    def high_priority(self):
        return self.get_query_set().high_priority()

這個能更好地提供我們想要的API:

Todo.objects.incomplete().high_priority() # yay!

除上面那些輸入部分、且非常不DRY,每次你新增一個檔案到QuerySet,或是更改現有的方法標記,你必須記住在你的Manager中做相同的更改,否則它可能不會正常工作。這是配置的問題。

方法3b: django-model-utils


Python 是一種動態語言。 我們就一定能避免所有模組?一個名叫Django-model-utils的第三方應用帶來的一點小忙,就會有點不受控制了。先執行 pip install django-model-utils ,然後……

from model_utils.managers import PassThroughManager
 
class TodoQuerySet(models.query.QuerySet):
    def incomplete(self):
        return self.filter(is_done=False)
 
    def high_priority(self):
        return self.filter(priority=1)
 
class Todo(models.Model):
    content = models.CharField(max_length=100)
    # other fields go here..
 
    objects = PassThroughManager.for_queryset_class(TodoQuerySet)()


這要好多了。我們只是象之前一樣 簡單地定義了自定義QuerySet子類,然後透過django-model-utils提供的PassThroughManager類附加這些QuerySet到我們的model中。


PassThroughManager 是由__getattr__ 實現的,它能阻止訪問到django定義的“不存在的方法”,並且自動代理它們到QuerySet。這裡需要小心一點,檢查確認我們沒有在一些特性中沒有無限遞迴(這是我為什麼推薦使用django-model-utils所提供的用不斷嘗試測試的方法,而不是自己手工重複寫)。

做這些有什麼幫助?

記得上面早些定義的檢視程式碼麼?

def dashboard(request):
 
    todos = Todo.objects.filter(
        owner=request.user
    ).filter(
        is_done=False
    ).filter(
        priority=1
    )
 
    return render(request, 'todos/list.html', {
        'todos': todos,
    })

加點小改動,我們讓它看起來象這樣:

def dashboard(request):
 
    todos = Todo.objects.for_user(
        request.user
    ).incomplete().high_priority()
 
    return render(request, 'todos/list.html', {
        'todos': todos,
    })

希望你也能同意第二個版本比第一個更簡便,清晰並且更有可讀性。

Django能幫忙麼?


讓這整個事情更容易的方法,已經在django開發郵件列表中討論過,並且得到一個相關票據(譯註:associated ticket叫啥名更好?)。Zachary Voase則建議如下:

class TodoManager(models.Manager):
 
    @models.querymethod
    def incomplete(query):
        return query.filter(is_done=False)

透過這個簡單的裝飾方法的定義,讓Manager和QuerySet都能使不可用的方法神奇地變為可用。

我個人並不完全贊同使用基於裝飾方法。它略過了詳細的資訊,感覺有點“嘻哈”。我感覺好的方法,增加一個QuerSet子類(而不是Manager子類)是更好,更簡單的途徑。

或者我們更進一步思考。退回到在爭議中重新審視Django的API設計決定時,也許我們能得到真實更深的改進。能不再爭吵Managers和QuerySet的區別嗎(至少澄清一下)?

我很確信,不管以前是否曾經有過這麼大的重構工作,這個功能必然要在Django 2.0 甚至更後的版本中。

因此,簡單概括一下:

在檢視和其他高階應用中使用源生的ORM查詢程式碼不是很好的主意。而是用django-model-utils中的PassThroughManager將我們新加的自定義QuerySet API加進你的模型中,這能給你以下好處:

   囉嗦程式碼少,並且更健壯。

   增加DRY,增強抽象級別。

  將所屬的業務邏輯推送至對應的域模型層。


相關文章