摘要
在這篇文章裡,我將以反模式的角度來直接討論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()
這個方法有幾個問題。 第一,這種實現方式比較囉嗦。你要為每一個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,增强抽象级别。
將所屬的業務邏輯推送至對應的領域模型層。