第三章 模型
-----------
本章,我們將討論以下話題:
模型的重要性
類圖表
模型結構模式
模型行為模式
遷移
## M比V和C都更大
在Django中,模型是具有處理數據庫的一種面向對象的方法的類。通常,每個類都引用一個數據庫表,,每個屬性都引用一個數據庫列。你可以使用自動生成的API查詢這些表。
模型是很多其他組件的基礎。只要你有一個模型,你可以快速地推導模型admin,模型表單,以及所有類型的通用視圖。在每個例子中,你都需要下一個行或是兩行代碼,這樣可以讓它看上去沒有太多魔法。
模型也被用在更多超出你期望的地方。這書因為Django可以以多種方法運行。Django的一些切入點如下:
熟悉web請求-響應流程
Django的交互式shell
管理命令
測試腳本
異步任務隊列比如Celery
在多數的這些例子中,模型模塊要導入(作為django.setup()的一部分)。因此,最好保證模型遠離任何不必要的依賴,或者導入任何的其他Django組件,比如視圖。
簡而言之,恰當地設計模型是很重要的事情。現在,讓我們從SuperBook模型設計開始。
### 注釋
**自帶午餐便當**
*作者注釋:SuperBook項目的進度會以這樣的一個盒子出現。你可以跳過這個盒子,但是在web應用項目中的情況下,你缺少的是領悟,經驗 。
史蒂夫和客戶的第一周——超級英雄情報監控(簡稱為S.H.I.M)。簡單來說,這是一個大雜燴。辦公室是非常未來化的,但是不論做什么事情都需要上百個審核和簽字。
作為Django開發者的領隊,史蒂夫已經配置好了中型的運行超過兩天的4臺虛擬機。第二天的一個早晨,機器自己不翼而飛了。一個附近的清潔機器人說,機器被法務部們給帶走了,他們要對未經審核的軟件安裝做出處理。
然而,CTO哈特給予史蒂夫了極大的幫助。他要求機器在一個小時之內完好無損地給還回去。他還對SuperBook項目做出了提前審核以避免將來可能出現的任何阻礙。
那個下午的稍晚些時候,史蒂夫給他帶了一個午餐便當。身著一件米色外套和淺藍色牛仔褲的哈特如約而至。盡管高出周圍人許多,有著清爽面龐的他依舊那么帥氣,那么平易近人。他問史蒂夫如果他之前是否嘗試過構建一個60年代的超級英雄數據庫。
”嗯,對的,是哨兵項目么?“,史蒂夫說道。”是我設計的。數據庫看上去被設計成了一個條目-屬性-值形式的模式,有些地方我考慮用反模式。可能,這些天他們有一些超級英雄屬性的小想法。哈特幾乎等不到聽完最后一句,他壓低嗓門道:“沒錯。是我的錯。另外,他們只給了我兩天來設計整個架構。他們這是在要我老命啊!”
聽了這些,史蒂夫的嘴巴張的大大的,三明治也卡在口中。哈特微笑著道:“當然了,我還沒有盡全力來做這件事。只要它成長為100萬美元的單子,我們就可以多花點時間在這該死的數據庫上了。SuperBook用它就能分分鐘完事的,小史你說呢?”
史蒂夫微微點頭稱是。他從來沒有想過在這么樣的地方將會有上百萬的超級英雄出現。
## 模型搜尋
這是在SuperBook中確定模型的第一部分。通常對于一個早期試驗,我們只表示了基本模型,以及在一個類表中的表單的基本關系:
圖:略
讓我們忘掉模型一會兒,來談談我們正在構建的對象的術語。每個用戶都有一個賬戶。用戶可以寫多個回復或者多篇文章。**Like**可以同時關聯到一個單獨的用戶或者文章。
推薦你像這樣畫一個模型的類表。這個步驟的某些屬性或許缺失了,不過你可以在之后補充細節。只要整個項目用圖表表示出來,便可以輕松地分離app。
這是創建此表示的一些提示:
盒子表示條目,它將成為模型。
名詞通常作為條目的終止。
箭頭是雙向的,表示Django中三種關系類型其中的一種:一對一,一對多(通過外鍵實現),和多對多。
字段表明在基于條目-關系模型(ER-modle)的模型中定義了一對多關系。換句來說,星號是聲明外鍵的地方。
類圖表可以映射到下面的Django代碼中(它將遍及多個app):
```python
class Profile(models.Model):
user = models.OnToOneField(User)
class Post(models.Model):
posted_by = models.ForeignKey(User)
class Comment(models.Model):
commented_by = models.ForeignKey(User)
for_post = models.ForeignKey(Post)
class Like(models.Model):
liked_by = models.ForeignKey(User)
post = models.ForeignKey(Post)
```
這之后,我們不會直接地引用*User*,而是使用更加普通的*settings.AUTH_USER_MODEL*來替代。
### 分割model.py到多個文件
就像多數的Django組件那樣,一個大的model.py文件可以在一個包內分割為多個文件。**package**通過一個目錄來實現,它包含多個文件,目錄中的一個文件必須是一個稱為`__init__.py`特殊文件。
所有可以在包級別中暴露的定義都必須在`__init__.py`里使用全局變量域定義。例如,如果我們分割model.py到獨立的類,models子文件夾中的對應文件,比如,postable.py,post.py和comment.py, 之后`__init__.py`包會像這樣:
```python
from postable import Postable
from post import Post
from commnet import Comment
```
現在你可以像之前那樣導入models.Post了。
在`__init__.py`包中的任何其他代碼都會在包運行時被導入。因此,它是一個任意級別包初始化代碼的理想之地。
## 結構模式
本節包含多個幫助你設計和構建模型的設計模式。
### 模式-規范化模型
**問題**:通過設計,模型實例的重復數據引起數據不一致。
**解決方法**:通過規范化,分解模型到更小的模型。使用這些模型之間的邏輯關系來連接他們。
### 問題細節
想象一下,如果某人用下面的方法設計Post表(省略部分列):
| 超級英雄的名字 | 消息 | 發布時間 |
| ------------- |:-------------:| -----:|
| Captain Temper | 消息已經發布過了? | 2012/07/07/07:15 |
| Professor English | 應該用“Is”而不是“Has" | 2012/07/07/07:17 |
| Captain Temper | 消息已經發布過了? | 2012/07/07/07:18 |
| Capt. Temper | 消息已經發布過了? | 2012/07/07/07:19 |
我希望你注意到了在最后一行的超級英雄名字和之前不一致(船長一如既往的缺乏耐心)。
如果我們看看第一列,我們也不確定哪一個拼寫是正確的——`Captain Temper或者Capt.Temper`。這就是我們要通過規范化消除的一種數據冗余。
### 詳解
在我們看下完整的規范方案,讓我們用Django模型的上下文來個關于數據庫規范化的簡要說明。
### 規范化的三個步驟
規范化有助于你更有效地的存儲數據庫。只要模型完全地的規范化處理,他們就不會有冗余的數據,每個模型應該只包含邏輯上關聯到自身的數據。
這里給出一個簡單的例子,如果我們規范化了Post表,我們就可以不模棱兩可地引用發布消息的超級英雄,然后我們需要用一個獨立的表來隔離用戶細節。默認,Django已經創建了用戶表。因此,你只需要在第一列中引用發布消息的用戶的ID,一如下表所示:
|用戶ID|消息 |發布時間 |
|-----|:------:|---------:|
| 12 | 消息已經發布過了? | 2012/07/07/07:15 |
| 8 | 應該用“Is”而不是“Has" | 2012/07/07/07:17 |
| 12 | 消息已經發布過了? | 2012/07/07/07:18 |
| 12 | 消息已經發布過了? | 2012/07/07/07:19 |
現在,不僅僅相同用戶發布三條消息的清楚在列,而且我們可以通過查詢用戶表找到用戶的正確的名字。
通常來說,你會按照模型的完全規規范化表來設計模型,也會因為性能原因而有選擇性地非規范化設計。在數據庫中,**Normal Forms**是一組可以被應用于表,確保表被規范化的指南。一般我們建立第一,第二,第三規范表,盡管他們可以遞增至第五規范表。
這接下來的例子中,我們規范化一個表,創建對應的Django模型。想象下有個名字稱做“Sightings”的表格,它列出了某人第一次發現超級英雄使用能力或者特異功能。每個條目都提到了已知的原始身份,超能力,和第一次發現的地點,包括維度和經度。
| 名字 | 原始信息 | 能力 |第一次使用記錄(維度,經度,國家,時間) |
| ----------| :-----------:|:----:|----------------------------------:|
|Blitz | Alien |Freeze Flight|+40.75, -73.99; USA; 2014/07/03 23:12
|Hexa |Scientist |Telekinesis Flight|+35.68, +139.73; Japan; 2010/02/17 20:15
|Traveller |Billonaire |Time travel |+43.62, +1.45, France; 2010/11/10 08:20
上面的地理數據提取自http://www.golombek.com/locations.html.
### 第一規范表(1NF)
為了確認第一個規范表格,這張表必須含有:
多個沒有屬性(cell)的值
一個主鍵作為單獨一列或者一組列(合成鍵)
讓我們試著把表格轉換為一個數據庫表。明顯地,我們的`Power`列破壞了第一個規則。
更新過的表滿足第一規范表。主鍵(用一個`*`標記)是`Name`和`Power`的合并,對于每一排它都應該是唯一的。
|Name*|Origin|Power*|Latitude |Longtitude |Country|Time |
|-----|:----:|:----:|:---------:|:---------:|------------------:|
Blitz |Alien |Freeze|+40.75170 |-73.99420|USA|2014/07/03 23:12|
Blitz|Alien|Flight|+40.75170|-73.99420|USA|2013/03/12 11:30|
Hexa|Scientist|Telekinesis|+35.68330|+139.73330|Japan|2010/02/17 20:15|
Hexa|Scientist|Filght|+35.68330|+139.73330|Japan|2010/02/19 20:30|
Traveller|Billionaire|Time tavel|+43.61670|+1.45000|France|2010/11/10 08:20|
### 第二規范表
第二規范表必須滿足所有第一規范表的條件。此外,它必須滿足所有非主鍵列都必須依賴于整個主鍵的條件。
在之前的表,我們注意到`Origin`只依賴于超級英雄,即,`Name`。不論我們談論的是哪一個`Power`。因此,`Origin`不是完全地依賴于合成組件-`Name`和`Power`。
這里,讓我們只取出原始信息到一個獨立的,稱做`Origins`的表:
|Name*|Origin|
|-----|-----:|
Blitz|Alien|
Hexa|Scientist|
Traveller|Billionaire|
現在`Sightings`表更新為兼容第二規范表,它大概是這個樣子:
|Name*|Power*|Latitude |Longtitude |Country|Time |
|-----|:----:|:---------:|:---------:|------------------:|
Blitz |Freeze|+40.75170 |-73.99420|USA|2014/07/03 23:12|
Blitz||Flight|+40.75170|-73.99420|USA|2013/03/12 11:30|
Hexa|Telekinesis|+35.68330|+139.73330|Japan|2010/02/17 20:15|
Hexa|Filght|+35.68330|+139.73330|Japan|2010/02/19 20:30|
Traveller|Time tavel|+43.61670|+1.45000|France|2010/11/10 08:20|
### 第三規范表
在第三規范表中,比表格必須滿足第二規范表,而且應該額外滿足所有的非主鍵列都直接依賴整個主鍵,而且這些非主鍵列都是互相獨立的這個條件。
考慮下`Country`類。給出`維度`和`經度`,你可以輕松地得出`Country`列。即使觀測到超級英雄的地方依賴于`Name-Power`合成鍵,但是它只是間接地依賴他們。
因此,我們把詳細地址分離到一個獨立的國家表格中:
|Location ID|Latitude*|Longtitude*|Country|
|-----------|:-------:|:---------:|------:|
1|+40.75170|-73.99420|USA|
2|+35.68330|+139.73330|Japan|
3|+43.61670|+1.45000|France|
現在`Sightings`表格的第三規范表大抵如此:
|User ID*|Power*|Location ID|Time|
---------|:----:|:---------:|---:|
2|Freeze|1|2014/0703 23:12
2|Flight|1|2013/03/12 11:30
4|Telekinesis|2|2010/02/17 20:15
4|Flight|2|2010/02/19 20:30
7|Time tavel|3|2010/11/10 08:20
如之前所做的那樣,我們用對應的`User ID`替換了超級英雄的名字,這個用戶ID用來引用用戶表格。
### Django模型
現在我們可以看看這些規范化的表格可以用來表現Django模型。Django中并不直接支持合成鍵。這里用到的解決方案是應用代理鍵,以及在`Meta`類中指定`unique_together`屬性:
```python
class Origin(models.Model):
superhero = models.ForeignKey(settings.AUTH_USER_MODEL)
origin = models.CharField(max_length=100)
class Location(models.Model):
latitude = models.FloatField()
longtitude = models.FloatField()
country = models.CharField(max_length=100)
class Meta:
unique_together = ("latitude", "longtitude")
class Sighting(models.Model):
superhero = models.ForeignKey(settings.AUTH_USER_MODEL)
power = models.CharField(max_length=100)
location = models.ForeignKey(Location)
sighted_on = models.DateTimeField()
class Meta:
unique_together = ("superhero", "power")
```
### 性能和非規范化
規范化可能對性能有不利的影響。隨著模型的增長,需要應答查詢的連接數也隨之增加。例如,要在美國發現具有冷凍能力的超級英雄的數量,你需要連接四個表格。先前的內容規范后,任何信息都可以通過查詢一個單獨的表格被找到。
你應該設計模式以保持數據規范化。這可以維持數據的完整性。然而,如果你面臨擴展性問題,你可以有選擇性地從這些模型取得數據以生成非規范化的數據。
## 提示
**最佳實踐**
*因設計而規范,又因優化而非規范*
例如,在一個確定的國家中計算觀測次數是非常普通的,然后將觀測次數作為一個附加的字段到`Location`模型。現在,你可以使用Django ORM 繼承其他的查詢,而不是一個緩存的值。
然而,你需要在每次添加或者移除觀測時更新這個計數。你需要添加本次計算到*Singhting*的`save`方法,添加一個信號處理器,甚至使用一個異步任務去計算。
如果你有一個跨越多個表的負責查詢,比如國家的超能力計算,你需要創建一個獨立的非規范表格。就像前面那樣,我們需要在每一次規范化模型中的數據改變時更新這個非規范的表格。
令人驚訝的是非規范化在大型的網站中是非常普遍的,因為它是數度和存儲空間兩者之間的折衷。今天的存儲空間已經比較便宜了,然而速度也是用戶體驗中至關重要的一環。因此,如果你的查詢耗時過于久的話,那么就需要考慮非規范化了。
## 我們應該一直使用規范化嗎?
過多的規范化是是件不必要的事。有時候,它可以引入一個非必需的能夠重復更新和查詢的表格。
例如,你的`User`模型或許有好多個家庭地址的字段,你可以規范這些字段到一個`Address`模型中。可是,多數情況下,把一個額外的表引進數據庫是沒有必要的。
與其針對大多數的非規范化設計,不如在代碼重構之前仔細地衡量每個非規范化的機會,對性能和速度上做出一個折衷的選擇。
## 模式-模型mixins
**問題**:明顯地模型含有重復的相同字段/或者方法,違反了DRY原則。
**方案**:提取公共字段和方法到各種不同的可復用的模型mixins中。
## 問題細節
設計模型時,你或許某些公共屬性或者行為跨模型類共享。例如,`Post`和`Comment`模型需要一直跟蹤自己的`created`日期和`modified`日期。手動地復制-粘貼字段和它們所關聯的方法十分不符合DRY原則。
由于Django的模型是類,像合成以及繼承這樣的面向對象方法都是可以選擇的解決方案。然而,合成(具有包含一個共享類實例的屬性)需要一個額外的間接層訪問字段。
繼承是有技巧的。我們可以對`Post`和`Comment`使用一個公共基類。然而,在Django中有三種類型的繼承:**concrete(具體)**, **abstract(抽象)**, 和**proxy(代理)**。
而具體繼承的運行視派生基類而定,就像你在Python類中通常用到的那樣。不過,在Django中,這個基類將被映射到一個獨立的表中。每次你訪問基本字段時,都需要一個明確的連接。這樣做會帶來非常糟糕的性能問題。
代理繼承只能添加新的行為到父類。你不能夠添加新字段。因此,這種情況下它的用處也不大。
最后,我們只有寄希望于抽象繼承了。
## 詳解
抽象基類是用于模型之間共享數據和行為的簡潔方案。當你定義一個抽象類時,它在數據庫中并沒有創建任何與之對象的表。相反,這些字段是在派生出來的非抽象類中創建的。
訪問抽象基類字段不需要`JOIN`語句。帶有可管理字段的結果表格也是不解自明的。為了利用這些優點,大多數的Django項目都使用抽象基類實現公共字段或者方法。
抽象模型的局限在于:
* 它們不能夠擁有外鍵或者其他模型的多対多字段。
* 它們不能夠被實例化或者保存起來。
* 它們查詢中不能夠直接地使用,因為它沒有管理器。
下面展示了post和comment類如何使用一個抽象基類進行初始設計:
```python
class Postable(models.Model):
created = models.DateTimeField(auto_now_add=True)
modified = modified.DateTimeField(auto_now=True)
message = models.TextField(max_length=500)
class Meta:
abstract = True
class Post(Postable):
...
class Comment(Postable):
...
```
要將一個模型轉換到抽象基類,你需要在它的內部`Meta`類中寫上`abstract = True`。這里的`Postable`是一個抽象基類。可是,它不是那么的可復用。
實際上,如果有一個類含有`created`和`modified`字段,我們在后面就可以在幾乎任何需要時間戳的模型中重復使用這個時間戳功能。
### 模型mixins
模型mixins是一個可以把抽象類當作父類來添加的模型。不像其他的語法,比如Java那樣,Python支持多種繼承。因此,你可以列出一個模型的任意數量的父類。
Mixins應該是互相垂直的而且易于組合的。把一個mixin放進基類的列表,這些mixin應該可以正常運行。這樣看來,它們在行為上更類似于合成而非繼承。
小一些mixin的會好很多。不論何時當一個mixin變得很大,而且又違反了獨立響應原則,就要考慮把它重構到一個小一些的類中去。就讓mixin一次做好一件事吧。
在前面的例子中,用于更新`created`和`modified`的時間的模型mixin可以輕松地分解出來,一如下面代碼所示:
```python
class TimeStampedModel(models.Model):
created = modified.TimeStampModel(auto_now_add=True)
modified = modified.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Postable(TimeStampedModel):
message = models.TextField(max_length=500)
...
class Meta:
abstract = True
class Post(Postable):
...
class Comment(Postable):
...
```
我們現在有兩個超類了。不過,功能之間顯然都是獨立的。mixin可以分離到自己的模塊之內,或者在其他的上下文中被重復利用。
## 模式-用戶賬戶
**問題**:每一個網站都存儲一組不同的用戶賬戶細節。然而,Django的內建`User`模型旨在針對認證細節。
**方案**:用一對一關系的用戶模型,創建一個用戶賬戶類。
## 問題細節
Django提供一個開箱即用的相當不錯的**User**模型。你可以在創建超級用戶或者登錄amdin接口的時候用到它。它含有少量的基本字段,比如全名,用戶名,和電子郵件。
然而,大多數的現實世界項目都保留了很多關于用戶的信息,比如他們的地址,喜歡的電影,或者它們的超能力。打Django1.5開始,默認的User模型就可以被擴展或者替換掉。不過,官方文檔極力推薦只存儲認證數據,即便是在定制的用戶模型中也是如此(畢竟,用戶模型也是所屬于`auth`這個app的)。
某些項目是需要多種類型的用戶的。例如,SuperBook可以被超級英雄和非超級英雄所使用。這里或許會有一些公共字段,以及基于用戶類型的不同字段。
## 詳解
官方推薦解決方案是創建一個用戶賬戶模型。它應該和用戶模型有一個一對一的關系。其余的全部用戶信息都存儲于該模型:
```python
class Profile(models.Model):
user = models.OnToOneField(settings.AUTH_USER_MODEL, primary_key=True)
```
這里建議你明確的將`primary_key`賦值為`True`,以阻止類似PostgreSQL這樣的數據庫后端中的并發問題。剩下的模型可以包含其他的任何用戶詳情,比如生日,喜好色彩,等等。
設計賬戶模型之時,建議所有的賬戶詳情字段都必須是非空的,或者含有一個默認值。憑直覺我們就知道用戶在注冊時是不可能填寫完所有的賬戶細節的。此外,我們也要確保創建賬戶實例時,信號處理器沒有傳遞任何初始參數。
### 信號
理論上,每一次用戶模型實例的生成,其對應的用戶賬戶實例也必須創建好。這個操作通常使用信號來完成。
例如,我們可以使用下面的信號處理器偵聽用戶模型的`post_save`信號:
```python
# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from . import models
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile_handler(sender, instance, created, **kwargs):
if not created:
return
# 僅在created是最新時才創建賬戶對象
profile = models.Profile(user=instance)
profile.save()
```
注意賬戶模型除了用戶實例之外并沒有傳遞額外初始的參數。
前面的代碼中,初始化信號代碼并沒有放到特定的場所。通常,它們在`models.py`中(這樣做是不可靠的)導入或者執行。不過,隨著Django 1.7的應用載入的重構,應用初始化代碼位置的問題也很好的解決了。
首先,為你的應用創建一個`__init__.py`包以引用應用的`ProfileConfig`:
```python
default_app_config = "profile.apps.ProfileConfig"
```
接下來是`app.py`中的子類`ProfileConfig`方法,可使用`ready`方法配置信號:
```python
# app.py
from django.apps import AppConfig
class ProfileConfig(AppConfig):
name = "profiles"
verbose_name = "User Profiles"
def ready(self):
from . import signals
```
隨著信號的配置,對所有的用戶來說,訪問`user.profile`應該都返回一個`Profile`對象,即使是最新創建的用戶也是如此。
### Admin
現在,用戶的詳情存在admin內的兩個不同地方:普通用戶admin頁面中的認證細節,同一個用戶的額外賬戶細節放到了一個獨立賬戶的admin頁面, 這樣做顯得非常麻煩。
如下,為了操作方便,賬戶admin可以通過定義一個自定義的`UserAdmin`嵌入到默認的用戶admin中:
```python
# admin.py
from django.contrib import admin
from .models import Profile
from django.contrib.auth.models import User
class UserProfileInline(admin.StackedInline):
model = Profile
class UserAdmin(admin.UserAdmin):
inlines = [UserProfileInline]
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
```
### 多個賬戶類型
假設在應用中你需要幾種類型的用戶賬戶。這里需要有一個字段去跟蹤用戶使用的是哪一種賬戶類型。賬戶數據本身需要存儲在獨立的模型中,或者存儲在一個統一的模型中。
建議使用聚合賬戶的辦法,因為它能夠改變賬戶類型而不丟失賬戶細節,兼具靈活性,最小化復雜性。此辦法中,賬戶模型包含一個所有賬戶類型的字段超集。
例如,SuperBook會需要一個`SuperHero`類型賬戶,和一個`Ordinary`(非超集英雄)賬戶。它可以用一個獨立的統一賬戶模型實現:
```python
class BaseProfile(models.Model):
USER_TYPES = (
(0, 'Ordinary'),
(1, 'SuperHero'),
)
user = models.OnToOneField(settings.AUTH_USER_MODEL, primary_key=True)
user_type = models.IntegerField(max_length=1, null=True, choices=USER_TYPES)
bio = models.CharField(max_length=200, blank=True, null=True)
def __str__(self):
return "{}:{:.20}".format(self.user, self.bio or "")
class Meta:
abstract = True
class SuperHeroProfile(models.Model):
origin = models.CharField(max_length=100, blank=True, null=True)
class Meta:
abstract = True
class OrdinaryProfile(models.Model):
address = models.CharField(max_length=200, blank=True, null=True)
class Meta:
abstract = True
class Profile(SuperHeroProfile, OrdinaryProfile, BaseProfile):
pass
```
我們把賬戶細節組織到多個抽象基類再到獨立的關系中。`BaseProfile`類包含所有的不關心用戶類型的公共賬戶細節。它也有一個`user_type`字段,它持續追蹤用戶的激活賬戶。
`SuperHeroProfile`類和`OrdinaryProfile`類分別包含所有到超級英雄和非超級英雄特定賬戶細節。最后,所有這些基類的`profile`類創建了一個賬戶細節的超集。
使用該方法時要主要的一些細節:
* 所有屬于類的字段或者它抽象基類都必須是非空的,或者有一個默認值。
* 此方法或許會因為每個用戶而消耗掉更多的數據庫,但是卻帶來極大的靈活性。
* 賬戶類型的激活和非激活字段都必須在模型外部是可管理的。
* 說到,編輯賬戶的表必須顯示合乎目前激活用戶類型的字段。
## 模式-服務模式
**問題**:模型會變得龐大而且不可管控。當一個模型不止實現一個功能時,測試和維護也會變得困難。
**解決方法**:重構出一組相關方法到一個專用的`Service`對象中。
### 問題細節
富模型,瘦視圖是一個通常要告訴Django新手的格言。理論上,你的視圖不應該包含任何其他表現邏輯。
可是,隨著時間的推移,代碼段不能夠放在任意地點,除非你打算將它們放進模型中。很快,模型會變成一個代碼的垃圾場。
下面是模型可以使用`Service`對象的征兆:
1. 與擴展能服務交互,例如web服務中,檢查一個用戶具有資格。
2. 輔助任務不會處理數據庫,例如,生成一個短鏈接,或者針對用戶的驗證碼。
3. 牽涉到一個短命的對象時不會存在數據庫狀態記錄,烈日,創建一個AJAX調用的JSON響應。
4. 對于長時間運行的任務設計到了多實例,比如Celery任務。
Django中的模型遵循著激活記錄模式。理論上,它們同時封裝應用羅即數據庫訪問。不過,要記得保持應用邏輯最小化。
在測試時,如果我們發現沒有對數據庫建模的必要,甚至不會用到數據庫,那么我們需要考慮把分解模型類。建議這種場合下使用`Service`對象。
### 詳解
服務對象是封裝一個`服務`或者和系統狡猾的普通而老舊的Python對象(POPOs)。它們通常保存在一個獨立的稱為`service.py`或者`utils.py`的文件。
例如,像下面這樣,檢查一個web服務是否作為一個模型方法:
```python
class Profile(models.Model):
...
def is_superhero(self):
url = "http://api.herocheck.com/?q={0}".format(
self.user.username
)
return webclient.get(url)
```
該方法可以使用一個服務對象來重構:
```python
from .services import SuperHeroWebAPI
def is_superhero(self):
return SuperHeroWebAPI.is_superhero(self.user.username)
```
現在服務對象可以定義在`services.py`中了:
```python
API_URL = "http://api.herocheck.com/?q={0}"
class SuperHeroWebAPI:
...
@staticmehtod
def is_hero(username):
url = API_URL.format(username)
return webclient.get(url)
```
多數情況下,`Service`對象的方法是無狀態的,即,它們基于函數參數不使用任何的類屬性來獨立執行動作。因此,最好明確地把它們標記為靜態方法(就像我們對`is_hero`所做的那樣)。
可以考慮把業務邏輯和域名邏輯從模型遷移到服務對象中去。這樣,你可以在Django應用的外部很好使用它們。
想象一下,由于業務的原因,要依據某些用戶的名字把這些要成為超級英雄的用戶加進黑名單。我們的服務對象稍微改動以下就可以支持這個功能:
```python
class SuperHeroWebAPI:
...
@staticmethod
def is_hero(username):
blacklist = set(["syndrome", "kcka$$", "superfake"])
ulr = API_URL.format(username)
return username not in blacklist and webclient.get(url)
```
理論上,服務對象自包含的。這使它們易于測試而不用建模——即數據庫,同時它們也輕松地重復使用。
Django中,耗時服務以Celery這樣的異步任務隊列方式執行。通常,`Service`對象以Celery任務的方式執行操作。這樣的任務可以周期性地運行或者延遲運行。
## 檢索模式
本節包含處理模型特性的訪問,或者對模型執行查詢的設計模式。
### 模式-屬性字段
**問題**:模型的屬性以方法實現。可是,這些屬性不應該保存到數據庫。
***解決方案*:對這樣的方法使用特性裝飾器。
### 問題詳情
模型字段存儲每個實例的屬性,比如名,和姓,生日,等等。它們存儲于數據庫之中。可是,我們也需要訪問某些派生的屬性,比如一個完整的名字和年齡。
它們可以輕易地計算數據庫字段,因此它們不需要單獨地存儲。在某些情況下,它們可以成為一個檢查給出的年齡,會員積分,和激活狀態是否合格的條件語句。
簡潔明了的實現這個方法是定義類似下面的`get_age`來實現:
```python
class BaseProfile(models.Model):
birthdate = models.DateField()
#...
def get_age(self):
today = datetime.date.today()
return (today.year - self.birthdate.year) - int(
(today.month, today.day) < (self.birthdate.month, self.birthdate.day)
)
```
調用`profile.get_age()`便會通過計算調整過的月和日期所屬的那個年份的不同來返回用戶的年齡。
不過,調用`profile.age`更具可讀性(和Python范)。
### 詳解
Python類可以使用`property`裝飾器把函數當作一個屬性來使用。這樣,Django模型也可以較好地利用它。替換前面那個例子中的函數:
```python
@property
def age(self):
```
現在我們可以用`profile.age`來訪問用戶的年齡。注意,函數的名稱要盡可能的短。
就像模型的方法那樣,屬性的一個重大缺陷是它對于ORM來說是不可訪問的。你不能夠在`Queryset`對象中使用它。例如,這么做是無效的,`Profile.objects.exlude(age__lt=18)。
它也是一個定義一個屬性來隱藏類內部細節的好主意。這也正式地稱做*得墨忒耳定律*。簡單地說,定律聲明你應該只訪問自己的直屬成員或者“僅使用一個點號”。
例如,最好是定義一個`profile.birthyear`屬性,而不是訪問`profile.birthdate.year`。這樣,它有助于你隱藏`birthdate`字段的內在結構。
>## 提示
>**最佳實踐**
>*遵循得墨忒耳定律,并且訪問屬性時只使用點號*
該定律的一個不良反應是它導致在模型中有多個包裝器屬性被創建。這使模型膨脹并讓它們變得難以維護。利用定律來改進你的模型API,減少模型間的耦合,在任何地方都是可行的。
### 緩存特性
每次我們調用一個屬性時,就要重新計算函數。如果計算的代價很大,我們就想到了緩存結果。因此,下次訪問屬性,我們就拿到了緩存的結果。
```python
from django.utils.function import cached_property
#...
@cached_property
def full_name(self):
# 代價高昂的操作,比如,外部服務調用
return "{0} {1}".format(self.firstname, self.lastname)
```
緩存的值會作為Python實例的一部分而保存。只要實例一直存在,就會得到同樣的返回值。
就保護性機制來說,你或許想要強制執行代價高昂的操作以確保過期的值不會返回。這樣,設置一個`cached=False`這樣的關鍵字參數以阻止返回緩存的值。
## 模式-定制模型管理器
**問題**:某些模型的定義的查詢被重復地訪問,整個代碼也就違反了DRY原則。
**解決方案**:通過定義自定義的管理器,使常見的查詢擁有意義的名稱。
### 問題細節
每一個Django的模型都有一個默認稱做`objects`的管理器。調用`objects.all()`會返回數據庫中的這個模型的所有條目。通常,我們只對所有條目的子集感興趣。
我們應用多種過濾器以找出所需的條目組。挑選它們的原則常常是我們的核心業務邏輯。例如,我們發現使用下面的代碼可以通過public訪問文章:
```python
public = Posts.objects.filter(privacy="public")
```
這個標準在未來或許會改變。我們或許也想要檢查文章是否標記為編輯。這個改變或許如此:
```python
public = Posts.objects.filter(privacy=POST_PRIVACY.Public, draft=Flase)
```
可是,這個改變需要使在任何地方都要用到公共文章。這令人非常沮喪。這僅需要一個定義這樣常見地查詢而無需“自我重復”。
### 詳解
`Querysets`是一個功能極其強大的抽象概念。它們僅在需要時才進行惰性查詢。因此,通過鏈式方法(一個順暢的接口)構建長的`Querysets`并不影響性能。
事實上,隨著更多的過濾的應用反倒會使結果數據集合得以縮減。這樣做的話通常能夠減少內存消耗。
模型管理器是一個模型獲取自身`Queryset`對象的便利接口。換句話來講,它們有助于你使用Django的ORM訪問下層的數據庫。事實上,`QuerySet`對象上管理器以一個非常簡單的包裝器實現。請注意相同到接口:
```python
>>> Post.objects.filter(posted_by__username="a")
[<Post:a: Hello World>, <Post:a: This is Private!>]
>>> Post.objects.get_queryset().filter(posted_by__username="a")
[<Post:a: Hello World>, <Post:a: This is Private!>]
```
默認的管理器由Django創建,`objects`有多種方法返回`Queryset`,比如`all`,`filter`或者`exclude`。不過,它們僅僅是生成了一個到數據庫的低級API。
定制管理器用于創建特定的域名,高級API。這樣不僅更具可讀性,而且通過實現細節減輕所受到的影響。因此,你就能夠利用高級抽象來嚴格的對域名建模了。
如下,前面的公開文章例子就可以很輕松地轉換為一個定制的管理器:
```python
# managers.py
from django.db.models.query import Queryset
class PostQuerySet(QuerySet):
def public_posts(self):
return self.filter(privacy="public")
PostManager = PostQuerySet.as_manager
```
這是一個在Django 1.7中從`QuerySet`對象創建定制管理器的捷徑。不像前面的其他方法,這個`PostManager`對象像默認的`objects`管理器一樣是可鏈式的。
如下所示,有些時候,使用定制的管理器替換去默認的`objects`管理器也是可行的:
```python
from .managers import PostManager
class Post(Postable):
...
objects = PostManager()
```
這樣,訪問`public_posts`就相當簡單了:
```python
public = Post.objects.public_posts()
```
因為返回值是一個`QuerySet`,它們可以更進一步過濾:
```python
public_apology = Post.objects.public_posts().filter(
message_startwith = "Sorry"
)
```
`QuerySets`由多個值得注意的屬性。在下一節里,我們可以看到一些含有混合`QuerySet`的常見地模式。
### Querysets的組合動作
事實上,對于它們的名字(或者是他們名字的后一半),`QuerySets`支持多組(數學上的)操作。為了說明,考慮包含用戶對象的兩個`QuerySets`:
```python
>>> q1 = User.objects.filter(username__in["a", "b", "c"])
[<User:a>, <User:b>, <User:c>]
>>> q2 = User.objects.filter(username__in["c", "d"])
[<User:c>, <User:d>]
```
對于一些組合操作可以執行以下動作:
* *Union-交集*:合并,移除重復動作。使用`q1`|`q2`獲得[`<User: a>, <User: b>, <User: c>, <User: d>`]
* *Intersection-并集*:找出公共項。使用`q1`|`q2`獲得[`<User: c>]
* *Difference-補集*:從第一個集合中移除同時包含在第二個集合中的元素。該操作并不按邏輯來。改用`q1.exlude(pk__in=q2)獲得[<User: a>, <User: b>]
同樣的操作我們也可以用`Q`對象來完成:
```python
from django.db.models import Q
# Union 交集
>>> User.objects.filter(Q(username__in["a", "b", "c"]) | Q(username__in=["c", "d"]))
[`<User: a>, <User: b>, <User: c>, <User: d>`]
# Intersection 并集
>>> User.objects.filter(Q(username__in["a", "b", "c"]) & Q(username__in=["c", "d"]))
[<User: c>]
# Difference 補集
>>> User.objects.filter(Q(username__in=["a", "b", "c"]) & ~Q(username__in=["c", "d"]))
[<User: a>, <User: b>]
```
注意執行動作所使用`&`(和)以及`~`(否定)的不同。`Q`對象是非常強大的,它可以用來構建非常復雜的查詢。
不過,`Set`雖相似但卻不完美。`QuerySets`不像數學上的集合那樣按照順序來。因此,就這方面來說它們更接近于Python的列表數據結構。
### 鏈接多個Querysets
目前為止,我們已經合并了屬于相同基類的同類型`QuerySets`。可是,我們或許需要合并來自不同模型的`QuestSets`,并對它們執行操作。
例如,一個用戶的活動時間表包含了它們自身所有的按照反向時間順序所發布的文章和評論。之前混合`QuerySets`方法是不會起作用的。一個很天真的做法是把它們轉換到列表,連接并排列這個列表:
```python
>>> recent = list(posts)+list(comments)
>>> sorted(recent, key=lambda e: e.modified, reverse=True)[:3]
[<Post: user: Post1>, <Comment: user: Comment1>, <Post: user: Post0>]
```
不幸的是,這個操作已經對惰性的`QuerySets`對象求值了。兩個列表的內存使用算在一起可能很大內存開銷。另外,轉換一個龐大的`QuerySets`到列表是很慢很慢的。
一個更好的解決方案是使用迭代器減少內存消耗。如下,使用`itertools.chain`方法合并多個`QuerySets`:
```python
>>> from itertools import chain
>>> recent = chain(posts, comments)
>>> sorted(recent, key=lambda e: e.modified, reverse=True)[:3]
```
只要計算`QuerySets`,連接數據的開銷都會非常搞。因此,重要的是,盡可能長的僅有的不對`QuerySets`求值的操作時間。
>##提示
盡量延長`QuerySets`不求值的時間。
## 遷移
遷移讓你改變模型時更有信心。說的Django 1.7,遷移已經是開發流程中基本的易于使用的一部分了。
新的基本流程如下:
1. 第一次定義模型類的話,你需要運行:
python manage.py makemigrations <app_label>
2. 這將在`app/migrations/`文件夾內創建遷移腳本。
在同樣的(開發)環境中運行以下命令:
python manage.py migrate <app_label>
3. 這將對數據庫應用模型變更。有時候,遇到的問題有,處理默認值,重命名,等等。
4. 普及遷移腳本到其他的環境。通常,你的版本控制工具,例如,Git,會小心處理這事。當最新的源釋出時,新的遷移腳本也會隨之出現。
5. 在這些環境中運行下面的命令以應用模型的改變:
python manage.py migarte <app_label>
不論何時要將變更應用到模型,請重復以上1-5步驟。
如果你在命令里忽略了app標簽,Django會在每一個app中發現未應用的變更并遷移它們。
## 總結
模型設計要正確地操作很困難。它依然是Django開發的基礎。本章,我們學習了使用模型時多個常見模式。每個例子中,我們都見到了建議方案的作用,以及多種折衷方案。
這下一章,我們會用視圖和URL配置來驗證所遇到的常見設計模式。