Django 的 ListView 類視圖詳解
本小節將繼續介紹 Django 中常用的 ListView 類視圖并深入分析其實現原理,最后達到完全掌握該視圖類的目的。
1. ListView 類視圖介紹和使用
ListView 類從名字上看應該是處理和列表相關的視圖,事實也是如此。我們同樣基于前面 TemplateView 中實現的例子,使用 ListView 來減少代碼,體驗下 ListView 視圖類給我們帶來的便捷。
實驗1:重現 TemplateView 功能;
首先我們完成前面 TemplateView 的簡單功能,然后在提出幾個報錯的問題,這些問題比較簡單,只要看下報錯位置和源碼信息就非常清楚了。
首先我先給出一個基本的知識:ListView 具備 TemplateView 所有的功能與屬性,并做了許多擴展。那么前面由TemplateView 實現的所有示例直接將 TemplateView 替換成 ListView 也是可以運行的?
我們以最簡單的一個模板例子進行演示:
在 hello_app/views.py
中新增一個視圖類 TestListView1:
(django-manual) [root@server first_django_app]# cat templates/test1.html
<p>{{ content }}</p>
<div>{{ spyinx.age }}</div>
class TestListView1(ListView):
template_name = 'test1.html'
在 hello_app/urls.py
中新增一個 URLConf 配置:
urlpatterns = [
# ...
path('test_list_view1/', views.TestListView1.as_view(extra_context=context_data), name='test_list_view1')
]
使用 runserver
命令啟動后,請求對應的 URL 地址,發現異常,錯誤原因也很明顯,缺少queryset。
上面的出錯是在父類的 get() 方法中,那么修改 hello_app/views.py
位置的視圖類 TestListView1,重新定義自己的 get() 方法,如下:
class TestListView1(ListView):
template_name = 'test1.html'
def get(self, request, *args, **kwargs):
return self.render_to_response(context={'content': '正文1', 'spyinx': {'age': 29}})
啟動服務后同樣報錯,不過這次錯誤不一樣了,如下:
同樣顯示的是沒有對象列表。我們通過查看源碼也能輕易解決這個問題。這個問題留到后面分析原源碼的時候去解決?,F在直接給出兩個報錯的解決方案,如下:
# 解決第一個沒有自定義get()函數報錯
class TestListView1(ListView):
template_name = 'test1.html'
queryset = Member.objects.all()
# 另一種寫法也是可以的
# model = Member
# 解決第二個自定義get()函數報錯
class TestListView1(ListView):
template_name = 'test1.html'
object_list = Member.objects.all()
def get(self, request, *args, **kwargs):
return self.render_to_response(context={'content': '正文1', 'spyinx': {'age': 29}})
最后正確的結果如下,這里直接用 curl
命令請求結果顯示即可。
[root@server ~]# curl http://127.0.0.01:8888/hello/test_list_view1/
<p>正文1</p>
<div>29</div>
實驗2:簡化分頁代碼。同樣前面 TemplateView 做的那個顯示會員列表的基礎上,簡化原來的代碼。
準備原來的模板文件,修改分頁那塊代碼:
(django-manual) [root@server first_django_app]# cat templates/test.html
<html>
<head>
<style type="text/css">
.page{
margin-top: 10px;
font-size: 14px;
}
.member-table {
width: 50%;
text-align: center;
}
</style>
</head>
<body>
<p>會員信息-第{{ page_obj.number }}頁/共{{ paginator.num_pages }}頁, 每頁{{ paginator.per_page }}條, 總共{{ paginator.count }}條</p>
<div>
<table border="1" class="member-table">
<thead>
<tr>
<th>姓名</th>
<th>年齡</th>
<th>性別</th>
<th>職業</th>
<th>所在城市</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>{{ member.name }}</td>
<td>{{ member.age }}</td>
{% if member.sex == 0 %}
<td>男</td>
{% else %}
<td>女</td>
{% endif %}
<td>{{ member.occupation }}</td>
<td>{{ member.city }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div >
<div class="page">
</div>
</div>
</div>
</body>
</html>
添加一個新的 ListView 視圖類,如下:
class TestListView2(ListView):
template_name = 'test.html'
model = Member
queryset=Member.objects.all()
paginate_by = 10
ordering = ["-age"]
context_object_name = "members"
注意:ordering 是設置顯示列表的排序字段,字符串前面的 “-” 號表示的是按照這個字段倒序排列,可以設置多個排序字段。context_object_name 一定要設置,對應模板文件中的列表數據名。
添加 URLConf 配置:
urlpatterns = [
# ...
path('test_list_view2/', views.TestListView2.as_view(), name='test_list_view2')
]
啟動 first_django_app 工程,從瀏覽器上直接訪問這個 url,就能看到和前面差不多的結果了。可以傳入 page 參數控制第幾頁,但是頁大小在視圖中已經固定,無法改變。
從這個簡單的例子,我們可以看到,相比前面用 TemplateView 手工對數據進行分頁,這里的 ListView 內部已經給我們實現了這樣的功能。我們只需要簡單的配置下,設置好相關屬性,就能夠實現對表的分頁查詢,這樣能節省重復的代碼操作,讓項目看起來簡潔優雅。但是我們一定要了解背后實現的邏輯,能看得懂源碼,這樣每一步的報錯,我們都能在源碼中找到原因,并迅速解決問題。接下來就是對 ListView 視圖類源碼的學習與分析。
2. ListView 類視圖深入分析
首先在 VScode 中整體看看 ListView 的源代碼,其源碼路徑為: djnago/views/generic/list.py
。來看看ListView 類的整體繼承關系:
在紅框中出現的對象我們是在 TemplateView 中已經遇到過了。這里可以看到 ListView 繼承的比 TemplateView 要多且復雜。我們來一個個分析這些基礎的類。
2.1 MultipleObjectTemplateResponseMixin
首先來看 MultipleObjectTemplateResponseMixin 這個對象,它是一個 Mixin。前面我們提到,一個 Mixin 就是一個包含一個或多個功能片段的對象。這里的 Mixin 是用于響應模板文件和展示列表數據的,它繼承至前面介紹到的 TemplateResponseMixin,在 TemplateResponseMixin 上做的擴展就是重寫了 get_template_names() 方法,其源碼如下:
class MultipleObjectTemplateResponseMixin(TemplateResponseMixin):
"""Mixin for responding with a template and list of objects."""
template_name_suffix = '_list'
def get_template_names(self):
"""
Return a list of template names to be used for the request. Must return
a list. May not be called if render_to_response is overridden.
"""
try:
names = super().get_template_names()
except ImproperlyConfigured:
# If template_name isn't specified, it's not a problem --
# we just start with an empty list.
names = []
# If the list is a queryset, we'll invent a template name based on the
# app and model name. This name gets put at the end of the template
# name list so that user-supplied names override the automatically-
# generated ones.
if hasattr(self.object_list, 'model'):
opts = self.object_list.model._meta
names.append("%s/%s%s.html" % (opts.app_label, opts.model_name, self.template_name_suffix))
elif not names:
raise ImproperlyConfigured(
"%(cls)s requires either a 'template_name' attribute "
"or a get_queryset() method that returns a QuerySet." % {
'cls': self.__class__.__name__,
}
)
return names
從這里的代碼,我們可以解釋第一個實驗中,第二次添加 get() 方法后報錯的原因,就在這個代碼段里。首先看這個 get() 函數:
def get(self, request, *args, **kwargs):
return self.render_to_response(context={'content': '正文1', 'spyinx': {'age': 29}})
這個 get() 函數調用 self.render_to_response() 方法時會調用這個 get_template_names() 方法。如果是在 TemplateView 中,直接這樣寫是毫無問題的,但是在 ListView 中,ListView 繼承了這個 Mixin,然后調用的get_template_names() 方法正是這里的代碼。這個 get_template_names() 方法相比原來的就是多了下半部分代碼,在程序執行到下面的語句時,由于沒有 object_list 屬性值就會觸發異常:
if hasattr(self.object_list, 'model'):
修正的方法很簡單,只要一開始加上這個 object_list 屬性值即可。對于這個object_list 屬性,它其實從名字也能看出來,表示一個對象的列表值,其實是一個 QuerySet 結果集。大概知道這些之后,我們就能理解后面的代碼了:
if hasattr(self.object_list, 'model'):
opts = self.object_list.model._meta
names.append("%s/%s%s.html" % (opts.app_label, opts.model_name, self.template_name_suffix))
elif not names:
raise ImproperlyConfigured(
"%(cls)s requires either a 'template_name' attribute "
"or a get_queryset() method that returns a QuerySet." % {
'cls': self.__class__.__name__,
}
)
對于這段代碼指的是,如果self.object_list
對應著一個模型時,代碼會在 names 中添加一個默認的模板文件名,我們可以在 shell 模式下理解下這些代碼:
(django-manual) [root@server first_django_app]# python manage.py shell
Python 3.8.1 (default, Dec 24 2019, 17:04:00)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from hello_app.models import Member
>>> object_list = Member.objects.all()
>>> object_list.model
<class 'hello_app.models.Member'>
>>> object_list.model._meta
<Options for Member>
>>> opts = object_list.model._meta
>>> opts.app_label
'hello_app'
>>> opts.model_name
'member'
這就很明顯了,最后 names 中會加上一個額外的元素:hello_app/member_list.html
?,F在我們可以立馬做一個實驗,將實驗1中的 template_name 屬性值去掉,然后將原來的 test1.html 拷貝一份放到 template/hello_app
目錄下,操作如下:
(django-manual) [root@server first_django_app]# mkdir templates/hello_app
(django-manual) [root@server first_django_app]# cp templates/test1.html templates/hello_app/member_list.html
class TestListView1(ListView):
# template_name = 'test1.html'
model = Member
啟動服務,然后運行發現也能成功。這就算對這個 Mixin 掌握了,我們也理解了它的代碼內容并獨立根據這個代碼內容完成了一個實驗。
(django-manual) [root@server first_django_app]# curl http://127.0.0.1:8888/hello/test_list_view1/
<p>正文1</p>
<div>29</div>
2.2 MultipleObjectMixin
這個 Mixin 是用來幫助視圖處理多個對象的,如列表展示,分頁查詢都是在這里。這也是 ListView 視圖類的核心所在。來看看源代碼里面關于這個 Mixin 的屬性和方法:
屬性:
-
allow_empty: 是否允許對象列表為空,默認為 True;
-
queryset:對象的查詢集;
-
model:關聯的模型;
-
paginate_by: 分頁大??;
-
paginate_orphans: 這個比較有意思,需要通過代碼來詳細解釋下其含義。
# 源碼位置 django/core/paginator.py class Paginator: def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True): self.object_list = object_list self._check_object_list_is_ordered() self.per_page = int(per_page) self.orphans = int(orphans) self.allow_empty_first_page = allow_empty_first_page # ... def page(self, number): """Return a Page object for the given 1-based page number.""" number = self.validate_number(number) bottom = (number - 1) * self.per_page top = bottom + self.per_page #這里可以看出 self.orphans 的含義 if top + self.orphans >= self.count: top = self.count return self._get_page(self.object_list[bottom:top], number, self) # ...
上面的
page()
方法是根據傳入的 number 獲取第幾頁數據,每頁的大小由per_page
屬性確定。我們在前面的 TemplateView 中的分頁實例中知道,想要獲取第幾頁的數據,可以按照如下公式:# 起始位置,number從1開始 start = (number - 1) * per_page # 結束位置,不包括end end = number * per_page # 另一種簡單寫法 end = start + per_page # 數據切片,取第number頁數據 object_list[start:end]
orphans
屬性的含義就體現在下面兩行代碼中:if top + self.orphans >= self.count: top = self.count
這里的含義是,如果計算出的下一頁的位置加上這個
orphans
屬性的值大于等于對象的總數,也就是說下一頁的數據如果少于orphans
的值,那么當前這一頁需要把下一頁剩余的元素都選中。舉個例子,假設與102個數據,現在按照每頁展示10條數據,當我展示到第10頁是,元素的位置應該是 90-99,作為切片的話,應該是[90:100],即bottom=90, top=100
。假設我設置orphans=3
,那么有100 + 3 > 102
,即最后一頁數目少于3個,因此通過上面的邏輯判斷后,top=102
,此時顯示的列表切片為 [90:102]。 -
context_object_name:這個設置上下文中對象列表名稱。我們來翻看源代碼,查看這個屬性的含義,如下。
# 源碼位置:django/views/generic/list.py class MultipleObjectMixin(ContextMixin): # ... def get_context_object_name(self, object_list): """Get the name of the item to be used in the context.""" if self.context_object_name: return self.context_object_name elif hasattr(object_list, 'model'): return '%s_list' % object_list.model._meta.model_name else: return None def get_context_data(self, *, object_list=None, **kwargs): """Get the context for this view.""" queryset = object_list if object_list is not None else self.object_list page_size = self.get_paginate_by(queryset) context_object_name = self.get_context_object_name(queryset) if page_size: paginator, page, queryset, is_paginated = self.paginate_queryset(queryset, page_size) context = { 'paginator': paginator, 'page_obj': page, 'is_paginated': is_paginated, 'object_list': queryset } else: context = { 'paginator': None, 'page_obj': None, 'is_paginated': False, 'object_list': queryset } if context_object_name is not None: context[context_object_name] = queryset context.update(kwargs) return super().get_context_data(**context) # ...
查看源碼可以知道,這個屬性不設置也是有默認值的(注意:只有在 object_list 沒設置,或者不是 QuerySet 時,才返回 None 值)。通過
get_context_data()
代碼中的這樣一條語句:context[context_object_name] = queryset
這樣,在模板文件中,我們就可以使用 context_object_name 變量來循環顯示我們的對象列表了。
-
paginator_class:用于分頁的類,這種寫法讓 django 的分頁變得可擴展,我們可以提供這樣的分頁類來替換掉 Django 中原有的分頁機制,從而實現我們自己的分頁控制。這種做法在可擴展的模式中用的非常多,不過需要仔細研讀分頁的源碼,需要定義的屬性和方法才能替換官方的分頁類。
-
page_kwarg: 查詢頁號的 key 值。這個是指,查詢的頁號是從獲取參數的這個 key 值中取出來的,可以是在 URLConf 配置中設定,也可以通過 GET 請求帶參數傳遞過來。來看看源碼里面如何使用這個屬性的,具體如下。
# 源碼路徑:django/views/generic/list.py class MultipleObjectMixin(ContextMixin): # ... def paginate_queryset(self, queryset, page_size): # ... page_kwarg = self.page_kwarg page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1 # ... # ...
-
ordering:這個屬性是用來設置查詢列表的排序,可以放入多個排序字段。比如
ordering = ['name']
,表示結果集按照 name 字段從小到大排序,如果想按照倒序的順序,直接用ordering = ['-name']
即可。
方法:
get_queryset():返回視圖的對象列表:
class MultipleObjectMixin(ContextMixin):
# ...
def get_queryset(self):
"""
Return the list of items for this view.
The return value must be an iterable and may be an instance of
`QuerySet` in which case `QuerySet` specific behavior will be enabled.
"""
if self.queryset is not None:
queryset = self.queryset
if isinstance(queryset, QuerySet):
queryset = queryset.all()
elif self.model is not None:
queryset = self.model._default_manager.all()
else:
raise ImproperlyConfigured(
"%(cls)s is missing a QuerySet. Define "
"%(cls)s.model, %(cls)s.queryset, or override "
"%(cls)s.get_queryset()." % {
'cls': self.__class__.__name__
}
)
ordering = self.get_ordering()
if ordering:
if isinstance(ordering, str):
ordering = (ordering,)
queryset = queryset.order_by(*ordering)
return queryset
在這里,代碼邏輯也是非常清楚的。首先是需要理解 Django 的 ORM 操作。在一開始設置了 queryset 屬性時,如果直接是 QuerySet 的實例,則會將用 .all()
將所有的數據取出來,得到 queryset 并返回。當然這里看代碼,不設置 queryset 也是可以的,設置好關聯的模型屬性 model,然后通過模型的默認 manager 調用 .all()
方法也能實現同樣的目標。最后,在這里還使用了 ordering
的屬性值,如果有設置,則直接用 QuerySet 的 .ordering()
方法。
幾個簡單的獲取屬性值得方法,如下:
- get_ordering():獲取排序字段;
- get_paginate_by(): 獲取分頁大小;
- get_paginator():實例化分頁對象,關聯 paginator_class 屬性值;
- get_paginate_orphans(): 獲取 paginate_orphans 這個屬性值;
- get_allow_empty():獲取 allow_empty 這個屬性值;
- get_context_object_name():處理 context_object_name 這個屬性值;
class MultipleObjectMixin(ContextMixin):
# ...
def get_ordering(self):
"""Return the field or fields to use for ordering the queryset."""
return self.ordering
# ...
def get_paginate_by(self, queryset):
"""
Get the number of items to paginate by, or ``None`` for no pagination.
"""
return self.paginate_by
def get_paginator(self, queryset, per_page, orphans=0,
allow_empty_first_page=True, **kwargs):
"""Return an instance of the paginator for this view."""
return self.paginator_class(
queryset, per_page, orphans=orphans,
allow_empty_first_page=allow_empty_first_page, **kwargs)
def get_paginate_orphans(self):
"""
Return the maximum number of orphans extend the last page by when
paginating.
"""
return self.paginate_orphans
def get_allow_empty(self):
"""
Return ``True`` if the view should display empty lists and ``False``
if a 404 should be raised instead.
"""
return self.allow_empty
def get_context_object_name(self, object_list):
"""Get the name of the item to be used in the context."""
if self.context_object_name:
return self.context_object_name
elif hasattr(object_list, 'model'):
return '%s_list' % object_list.model._meta.model_name
else:
return None
# ...
-
paginate_queryset():獲取分頁數據以及分頁信息。該函數會被 get_context_data() 方法調用生成上下文數據,用于填充模板中的變量內容。該部分源碼會結合 get_context_data() 方法一起在下一小節中詳細介紹到;
-
get_context_data():獲取渲染模板的上下文數據,也即分頁列表元素、分頁信息等,在下一部分內容會詳細介紹該函數中的內容。
2.3 BaseListView
講完上面的 MultipleObjectMixin 對象,ListView 視圖的基本功能其實就分析完了。 BaseListView 類繼承了 View 和 MultipleObjectMixin,并多添加了一個 get() 方法。這也是 ListView 能直接處理 get 請求的原因。實驗1中的第一個報錯也是源自這里:self.object_list = self.get_queryset()
。只有定義了 queryset
或者 model
屬性時,才能正常執行下去。
class BaseListView(MultipleObjectMixin, View):
"""A base view for displaying a list of objects."""
def get(self, request, *args, **kwargs):
# 獲取對象列表
self.object_list = self.get_queryset()
# 是否設置允許為空
allow_empty = self.get_allow_empty()
if not allow_empty:
# 下面的if用于判斷數據是否為空,然后相應設置is_empty值
if self.get_paginate_by(self.object_list) is not None and hasattr(self.object_list, 'exists'):
is_empty = not self.object_list.exists()
else:
is_empty = not self.object_list
# 在不許為空的條件中,如果為空直接拋出404異常
if is_empty:
raise Http404(_("Empty list and '%(class_name)s.allow_empty' is False.") % {
'class_name': self.__class__.__name__,
})
# 獲取分頁相關的數據
context = self.get_context_data()
# 渲染模板并返回
return self.render_to_response(context)
可以看到,這段代碼執行的過程非常簡單,很容易能看懂,我已經在上面做好了簡單的注釋。這段代碼中最重要的部分就在這一句中: context = self.get_context_data()
。這段代碼是要獲取相應的分頁數據結果,然后調用 self.render_to_response(context)
來返回經過渲染的模板文件,最后就是我們看到的那個會員列表頁面。self.get_context_data()
方法就是上面的 Mixin 提供的,函數源碼如下:
def get_context_data(self, *, object_list=None, **kwargs):
"""Get the context for this view."""
# 獲取對象列表
queryset = object_list if object_list is not None else self.object_list
# 獲取分頁大小
page_size = self.get_paginate_by(queryset)
# 獲取context_object_name,我們實驗2中設置的就是members,對應著模板中的變量
context_object_name = self.get_context_object_name(queryset)
if page_size:
# 核心的處理就是這一句,根據指定的分頁大小對數據集進行分析,返回分頁的對象列表,分頁信息、是否分頁等
paginator, page, queryset, is_paginated = self.paginate_queryset(queryset, page_size)
context = {
'paginator': paginator,
'page_obj': page,
'is_paginated': is_paginated,
'object_list': queryset
}
else:
# 沒有設置分頁大小,就是獲取全部數據,不進行分頁
context = {
'paginator': None,
'page_obj': None,
'is_paginated': False,
'object_list': queryset
}
# 設置模板中對象列表變量的數據
if context_object_name is not None:
context[context_object_name] = queryset
# context中再添加額外傳入的數據
context.update(kwargs)
# 最后調用父類的get_context_data()方法并返回
return super().get_context_data(**context)
上面的獲取上下文數據的代碼也比較簡單,有分頁大小就調用self.paginate_queryset()
方法查詢分頁數,沒有分頁大小就使用全部對象列表,然后構造 context 值,最后調用父類的 get_context_data()
方法并返回??梢钥吹?,整個獲取上下文數據的最核心處理就是 self.paginate_queryset()
這個方法了。它也是由上面介紹的那個 Mixin 提供的,代碼如下:
def paginate_queryset(self, queryset, page_size):
"""Paginate the queryset, if needed."""
# 核心處理就是這一句
paginator = self.get_paginator(
queryset, page_size, orphans=self.get_paginate_orphans(),
allow_empty_first_page=self.get_allow_empty())
# 查詢第幾頁的key值,默認是"page"
page_kwarg = self.page_kwarg
# 第幾頁的值會從kwargs或者GET請求中獲取,對應的key就是上面的page_kwarg,沒有就默認為1
page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
try:
# 會強制轉成int
page_number = int(page)
except ValueError:
# 當強制轉換異常時,處理邏輯也很清晰
if page == 'last':
page_number = paginator.num_pages
else:
raise Http404(_("Page is not 'last', nor can it be converted to an int."))
try:
# 生成對應的page實例。一切正常時,返回我們所需要的數據,否則拋出異常
page = paginator.page(page_number)
return (paginator, page, page.object_list, page.has_other_pages())
except InvalidPage as e:
raise Http404(_('Invalid page (%(page_number)s): %(message)s') % {
'page_number': page_number,
'message': str(e)
})
當這部分代碼能看懂時,前面實驗2部分的整個內部邏輯,你差不多也就弄清楚了。雖然我們看著只需要配置幾個屬性,但是在 Django 內部是替我們做了許多工作的,如果這部分工作并不是你想要的,這時候,就需要依據你自己的業務邏輯重寫相應的函數了。如果能掌握整個 ListView 視圖的執行流程,在繼承它的時候就會感到胸有成竹,有錯了就去根據錯誤提示追蹤下源碼,這樣就不會再碰到錯誤時,不知道從何下手。所以,閱讀源碼是在學習 Django 這樣的 Web 框架時,非常重要的一個技能,而且很多關于 Django 的功能和用法我們都可以通過源碼來獲取。
最后,我們來看看 ListView 的代碼,其實就是單純繼承前面那個處理多個對象的 Mixin 和這個 BaseListView:
class ListView(MultipleObjectTemplateResponseMixin, BaseListView):
"""
Render some list of objects, set by `self.model` or `self.queryset`.
`self.queryset` can actually be any iterable of items, not just a queryset.
"""
3. 小結
本節中,我們使用 ListView 完成了兩個小實驗,對 ListView 有了一個基本的了解。接下來,我們深入學習了和 ListView 相關的類和 mixin,并在源碼學習中完成了幾個簡單的實驗。在完成本節學習后,是不是對 ListView 有了全新的了解?之后使用 ListView 報錯后,是不是能迅速找到問題所在?如果是的話,那么本節也算起了一點小小的作用,作為本文作者,我也將感到無比榮耀。