1. 程式人生 > 其它 >Django2實戰示例 第六章 追蹤使用者行為

Django2實戰示例 第六章 追蹤使用者行為

第六章 追蹤使用者行為

在之前的章節裡完成了小書籤將外站圖片儲存至本站的功能,並且實現了通過jQuery傳送AJAX請求,讓使用者可以對圖片進行喜歡/不喜歡操作。

這一章將學習如何建立一個使用者關注系統和建立使用者行為流資料,還將學習Django的訊號框架使用和整合Redis資料庫到Django中。主要的內容有:

  • 通過中間模型建立多對多關係
  • 建立關注系統
  • 建立行為流應用(顯示使用者最近的行為列表)
  • 為模型新增通用關係
  • 優化QuerySet查詢外來鍵關聯模型
  • 使用signal模組對資料庫進行非規範化改造
  • 在Redis中存取內容

1建立關注系統

所謂關注系統,就是指使用者可以關注其他使用者,並且可以看到所關注使用者的行為。關注關係在使用者之間是多對多的關係,一個使用者可以關注很多使用者,也可以被很多使用者關注。

1.1通過中間模型建立多對多關係

在之前的章節中,通過ManyToManyField建立了多對多關係,然後讓Django建立了資料表。對於大多數情況,直接使用多對多欄位已經足夠。在需要為多對多關係儲存額外的資訊時(比如建立多對多關係的時間欄位,描述多對多關係性質的欄位),可能需要自定義一個模型作為多對多關係的中間模型。

我們將建立一箇中間模型用來建立使用者之間的多對多關係,原因是:

  • 我們將使用內建的User模型,但不想修改它
  • 想儲存一個使用者關注另外一個使用者的時間

account應用的models.py中建立新Contact類:

class Contact(models.Model):
    user_from = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='rel_from_set', on_delete=models.CASCADE)
    user_to = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='rel_to_set', on_delete=models.CASCADE)
    created = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-created',)

    def __str__(self):
        return '{} follows {}'.format(self.user_from, self.user_to)

這個Contact類將用來記錄使用者關注關係,包含如下欄位:

  • user_from:發起關注的使用者外來鍵
  • user_to:被關注的使用者外來鍵
  • created:該關注關係建立的時間,使用auto_now_add=True自動記錄時間

資料庫對於外來鍵會自動建立索引,這裡還使用了db_index=Truecreated欄位建立了索引。

使用ORM的時候,如果user1關注了user2,實際操作的語句可以寫成這樣:

user1 = User.objects.get(id=n)
user2 = User.objects.get(id=m)
Contact.objects.create(user_from=user1, user_to=user2)

基於Contact模型,可以通過為兩個外來鍵欄位設定的名稱rel_from_setrel_to_set作為管理器名稱進行查詢。為了從User模型中也可以進行查詢,User模型應該有一個多對多關係關聯到其自己,類似這樣:

following = models.ManyToManyField('self',
    through=Contact,
    related_name='followers',
    symmetrical=False)

在上邊這行程式碼裡,我們through=Contact告訴Django以Contact類作為中間表格建立多對多關係,這是一個User模型與自己的多對多關係,其中的'self'引數表示模型自己。

當需要在多對多關係中記錄額外資料時,建立一個關聯到兩個模型的中間表格,然後手動指定ManyToManyFieldthrough引數,將中間表格作為多對多關係的中間表。

如果User模型是我們自定義的模型,可以很方便的為其新增following欄位,但我們不想修改User類,這裡可以採用一個動態的方法為其新增欄位。在account應用裡的models.py裡增加如下內容:

from django.contrib.auth.models import User
User.add_to_class('following',
                  models.ManyToManyField('self', through=Contact, related_name='followers', symmetrical=False))

這裡用了一個add_to_class()方法給User打了一個猴子補丁,不推薦使用該方法。但是在這裡使用主要考慮如下原因:

  • 通過這個方法簡化了查詢,通過使用user.followers.all()user.following.all()可以迅速查詢。如果通過一對一關係定義在Profile模型上,查詢就要複雜很多。
  • 通過這種方法新增的多對多欄位實際是通過Contact模型生效,不會實際修改資料庫中的User資料表
  • 也無需建立自定義的User模型替換原User模型

這裡需要在此強調的是,在大部分情況下需要為內建資料模型增加額外資料時,優先通過一對一的方式如Profile模型進行擴充套件,將額外資訊和關係欄位都新增在擴充套件的資料上;其次是自定義新的資料模型取代原資料模型,而不是直接通過猴子補丁。否則給後續開發和測試帶來很大困難。關於自定義使用者模型可以參考https://docs.djangoproject.com/en/2.0/topics/auth/customizing/#specifying-a-custom-user-model

這裡還有一個引數是symmetrical=False對稱引數,當建立一個關聯到自身的多對多欄位的時候,Django預設關係是對稱的,即A關注了B,會自動新增B也關注A的記錄,這與實際情況不符,所以必須設定為False

使用中間表格作為多對多關係的中間表時,一些管理器的內建方法如add()create()remove()等無法使用,必須編寫直接操作中間表的程式碼。

定義好中間表後,執行資料遷移過程。現在模型已經建好,我們需要建立展示使用者關注關係的列表和詳情檢視。

1.2建立使用者關注關係的列表和詳情檢視

在account應用的views.py裡新增如下內容:

from django.shortcuts import get_object_or_404
from django.contrib.auth.models import User

@login_required
def user_list(request):
    users = User.objects.filter(is_active=True)
    return render(request, 'account/user/list.html', {'section': 'people', 'users': users})

@login_required
def user_detail(request, username):
    user = get_object_or_404(User, username=username, is_active=True)
    return render(request, 'account/user/detail.html', {'section': 'people', 'user': user})

這是兩個簡單的展示所有用列表戶和某個具體使用者資訊的檢視,如果使用者較多,還可以為user_list新增分頁功能。

user_detail使用了get_object_or_404方法,如果找不到使用者就會返回一個404錯誤。

編輯account應用的urls.py檔案,為這兩個檢視配置URL:

    path('users/', views.user_list, name='user_list'),
    path('users/<username>/', views.user_detail, name='user_detail'),

這裡我們看到,需要通過URL傳引數給檢視,需要建立規範化URL,為模型新增get_absolute_url(),除了通過自定義的方法之外,對於User這種內建的模型,還有一種方法是設定ABSOLUTE_URL_OVERRIDES

修改專案的settings.py檔案:

from django.urls import reverse_lazy

ABSOLUTE_URL_OVERRIDES = {
    'auth.user': lambda u: reverse_lazy('user_detail',
                                        args=[u.username])

Django動態的為所有ABSOLUTE_URL_OVERRIDES中列出的模型新增get_absolute_url()方法,這個方法按照設定中的結果返回規範化URL。這裡通過一個匿名函式返回規範化URL,這個匿名函式被繫結在物件上,作為呼叫get_absolute_url()時候實際呼叫的函式。

配置好了以後我們先來實驗一下,開啟命令列模式:

>>> from django.contrib.auth.models import User
>>> user = User.objects.latest('id')
>>> str(user.get_absolute_url())
>>>'/account/users/caidaye/'

可以看到解析出了地址,之後需要建立模板,在account應用的templates/account/目錄下建立如下目錄和檔案結構:

/user/
    detail.html
    list.html

之後編寫其中的list.html

{#list.html#}
{% extends "base.html" %}
{% load thumbnail %}
{% block title %}People{% endblock %}
{% block content %}
    <h1>People</h1>
    <div id="people-list">
        {% for user in users %}
            <div class="user">
                <a href="{{ user.get_absolute_url }}">
                    {% thumbnail user.profile.photo "180x180" crop="100%" as im %}
                        <img src="{{ im.url }}">
                    {% endthumbnail %}
                </a>
                <div class="info">
                    <a href="{{ user.get_absolute_url }}" class="title">
                        {{ user.get_full_name }}
                    </a>
                </div>
            </div>
        {% endfor %}
    </div>
{% endblock %}

這個模板中用一個迴圈列出了檢視返回的所有活躍使用者,分別顯示每個使用者的名稱和頭像,使用{% thumbnail %}顯示縮圖。

base.html中新增這個模板的路徑,作為使用者關注系統的連結首頁:

<li {% if section == 'people' %}class="selected"{% endif %}><a href="{% url 'user_list' %}">People</a></li>

之後啟動網站,到http://127.0.0.1:8000/account/users/可以看到顯示出了使用者列表頁面,示例如下:

如果無法顯示縮圖,記得在settings.py中設定THUMBNAIL_DEBUG = True,在命令列視窗中檢視錯誤資訊。

編寫account/user/detail.html來展示具體使用者:

{% extends "base.html" %}
{% load thumbnail %}
{% block title %}{{ user.get_full_name }}{% endblock %}
{% block content %}
    <h1>{{ user.get_full_name }}</h1>
    <div class="profile-info">
        {% thumbnail user.profile.photo "180x180" crop="100%" as im %}
            <img src="{{ im.url }}" class="user-detail">
        {% endthumbnail %}
    </div>
    {% with total_followers=user.followers.count %}
        <span class="count">
<span class="total">{{ total_followers }}</span>
follower{{ total_followers|pluralize }}
</span>
        <a href="#" data-id="{{ user.id }}" data-action="{% if request.user in user.followers.all %}un{% endif %}follow" class="follow button">
            {% if request.user not in user.followers.all %}
                Follow
            {% else %}
                Unfollow
            {% endif %}
        </a>
        <div id="image-list" class="image-container">
            {% include "images/image/list_ajax.html" with images=user.images_created.all %}
        </div>
    {% endwith %}
{% endblock %}

在這個詳情頁面,同樣展示使用者名稱稱和使用{% thumbnail %}展示使用者頭像縮圖。此外還展示了關注該使用者的人數,以及提供了一個按鈕供當前使用者關注/取消關注該使用者。和上一章類似,我們將使用AJAX技術來完成關注/取消關注行為,為此在<a>標籤中增加了data-iddata-action屬性用於儲存使用者ID和初始動作。還通過引入images/image/list_ajax.html展示了該使用者上傳的所有圖片。

啟動站點,點選某個具體的使用者,可以看到使用者詳情頁面的示例如下:

1.3建立使用者關注行為的AJAX檢視

編輯account應用的views.py檔案:

from django.http import JsonResponse
from django.views.decorators.http import require_POST
from common.decorators import ajax_required
from .models import Contact

@ajax_required
@require_POST
@login_required
def user_follow(request):
    user_id = request.POST.get('id')
    action = request.POST.get('action')
    if user_id and action:
        try:
            user = User.objects.get(id=user_id)
            if action == "follow":
                Contact.objects.get_or_create(user_from=request.user, user_to=user)
            else:
                Contact.objects.filter(user_from=request.user, user_to=user).delete()
            return JsonResponse({'status': 'ok'})
        except User.DoesNotExist:
            return JsonResponse({'status': 'ko'})

    return JsonResponse({'status': 'ko'})

這個檢視與之前喜歡/不喜歡圖片的功能如出一轍。由於我們使用了自定義的中間表作為多對多欄位中間表,無法通過User模型直接使用管理器的add()remove()方法,因此這裡直接操作Contact模型。

編輯account應用的urls.py檔案,新增一行

    path('users/follow/', views.user_follow, name="user_follow"),

注意這一行一定要在user_detail的URL配置之前,否則所有訪問/users/follow/路徑的請求都會被路由至user_detail檢視。記住Django匹配URL的順序是從上到下停在第一個匹配成功的地方。

修改account應用的user/detail.html,添加發送AJAX請求的JavaSCript程式碼:

{% block domready %}
$('a.follow').click(function (e) {
    e.preventDefault();
    $.post('{% url 'user_follow' %}', {
            id: $(this).data('id'),
            action: $(this).data('action')
        },
        function (data) {
            if (data['status'] === 'ok') {
                let previous_action = $('a.follow').data('action');
                // 切換 data-action 屬性
                $('a.follow').data('action', previous_action === 'follow' ? 'unfollow' : 'follow');
                // 切換按鈕文字
                $('a.follow').text(previous_action === 'follow' ? 'unfollow' : 'follow');
                // 更新關注人數
                let previous_followers = parseInt($('span.count .total').text());
                $('span.count .total').text(previous_action === 'follow' ? previous_followers + 1 : previous_followers - 1);
            }
        }
    );
});
{% endblock %}

這個函式的邏輯也和上一章的喜歡/不喜歡功能很相似。使用者點選按鈕時,首先將使用者ID和行為傳送至檢視,根據返回的結果,相應切換行為屬性和顯示的文字,同時更新關注人數。嘗試開啟一個使用者詳情頁面並且點選喜歡,之後可以看到顯示如下:

譯者注:這個函式和之前的AJAX函式一樣,更新關注人數的邏輯比較簡單粗暴,關注人數最好從資料庫中取followers的總數。原書明顯是為了讓讀者看到立竿見影的效果。

2建立通用行為流應用

許多社交網站向其使用者展示其他使用者的行為流,供使用者追蹤其他使用者最近在網站中做了什麼。一個行為流是一個使用者或者一組使用者最近進行的所有活動的列表。例如Facebook介面的News Feed就是一個行為流。對於我們的網站來說,X使用者上傳了Y圖片或者X使用者關注了Y使用者,都是行為流中的一個數據。我們也準備建立一個行為流應用,讓使用者可以看到他們所關注的使用者最近的所有活動。為了實現這個功能,我們需要建立一個模型,用於儲存一個使用者最近在網站上做過的所有事情,及向模型中新增行為記錄的方法。

新建一個叫actions應用然後新增到settings.py裡,如下所示:

INSTALLED_APPS = [
    # ...
    'actions.apps.ActionsConfig',
]

action應用中編輯models.py

from django.db import models

class Action(models.Model):
    user = models.ForeignKey('auth.user', related_name='actions', db_index=True, on_delete=models.CASCADE)
    verb = models.CharField(max_length=255)
    created = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-created',)

上邊的程式碼建立了一個Action模型,用於存放使用者的所有行為記錄,模型的欄位有這些:

  • user:進行行為的主體,即使用者,採用了ForeignKey關聯至內建的User模型
  • verb:行為的動詞,描述使用者進行了什麼行為
  • created:記錄使用者執行行為的時間,採用auto_now_add=True自動記錄建立該條資料的時間

使用這個模型,我們目前只能記錄行為的主體和行為動詞,即使用者X關注了...或者使用者X上傳了...,還缺少行為的目標物件。顯然我們還需要一個外來鍵關聯到使用者操作的具體物件上,這樣才能夠展示出類似使用者X關注了使用者Y這樣的行為流。在之前我們已經知道,一個ForeignKey欄位只能關聯到一個模型,很顯然無法滿足我們的需求。目標物件必須可以是任意一個已經存在的模型的物件,這個時候Django的content types框架就該登場了。

2.1使用contenttypes框架

django.contrib.conttenttypes模組中提供了一個contenttypes框架,這個框架可以追蹤當前專案內所有已啟用的應用中的所有模型,並且提供一個通用的介面可以操作模型。

django.contrib.conttenttypes同時也是一個應用,在預設設定中已經包含在INSTALLED_APPS中,其他contrib包中的程式也使用這個框架,比如內建認證模組和管理後臺。

conttenttypes應用中包含一個ContentType模型。這個模型的例項代表專案中一個實際的資料模型。當專案中每新建一個模型時,ContentType的新例項會自動增加一個,對應該新增模型。ContentType模型包含如下欄位:

  • app_label:資料模型所屬的應用名稱,這個來自模型內的Meta類裡的app_label屬性。我們的Image模型就屬於images應用
  • model:模型的名稱
  • name:給人類閱讀的名稱,這個來自模型內的Meta類的verbose_name屬性。

來看一下如何使用ContentType物件,開啟系統命令列視窗,可以通過指定app_labelmodel屬性,在ContentType模型中查詢得到一個具體物件:

>>> from django.contrib.contenttypes.models import ContentType
>>> image_type = ContentType.objects.get(app_label='images', model='image')
>>> image_type
<ContentType: image>

還可以對剛獲得的ContentType物件呼叫model_class()方法檢視型別:

>>> image_type.model_class()
<class 'images.models.Image'>

還可以直接通過具體的類名獲取對應的ContentType物件:

>>> from images.models import Image
>>> ContentType.objects.get_for_model(Image)
<ContentType: image>

這是幾個簡單的例子,還有更多的方法可以操作,詳情可以閱讀官方文件:https://docs.djangoproject.com/en/2.0/ref/contrib/contenttypes/

2.2為模型新增通用關係

通常來說,通過獲取ContentType模型的例項,就可以與整個專案中任何一個模型建立關係。為了建立通用關係,需要如下三個欄位:

  • 一個關聯到ContentType模型的ForeignKey,這會用來反映與外來鍵所在模型關聯的具體模型。
  • 一個儲存具體的模型的主鍵的欄位,通常採用PositiveIntegerField欄位,以匹配主鍵自增欄位,這個欄位用於從相關的具體模型中確定一個物件。
  • 一個使用前兩個欄位,用於管理通用關係的欄位,content types框架提供了一個GenericForeignKey專門用於管理通用關係。

編輯actions應用的models.py檔案:

from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey

class Action(models.Model):
    user = models.ForeignKey('auth.user', related_name='actions', db_index=True, on_delete=models.CASCADE)
    verb = models.CharField(max_length=255)
    target_ct = models.ForeignKey(ContentType, blank=True, null=True, related_name='target_obj',
                                      on_delete=models.CASCADE)
    target_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
    target = GenericForeignKey('target_ct', 'target_id')
    created = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-created',)

我們將下列欄位增加到了Action模型中:

  • target_ct:一個外來鍵欄位,關聯到ContentType模型
  • target_id:一個PositiveIntegerField欄位,用於儲存相關模型的主鍵
  • target:一個GenericForeignKey欄位,通過組合前兩個欄位得到

Django並不會為GenericForeignKey建立資料表中的欄位,只有target_cttarget_id會被寫入資料表。這兩個欄位都設定了blank=Truenull=True,這樣新增Action物件的時候不會強制要有關聯的目標物件。

如果確實需要的話,建立通用關係比使用外來鍵可以建立更靈活的關係。

建立完模型之後,執行資料遷移程式,然後將Action模型新增到管理後臺中,編輯actions應用的admin.py檔案:

from django.contrib import admin
from .models import Action

@admin.register(Action)
class ActionAdmin(admin.ModelAdmin):
    list_display = ('user', 'verb', 'target', 'created')
    list_filter = ('created',)
    search_fields = ('verb',)

加入管理後臺之後,開啟http://127.0.0.1:8000/admin/actions/action/add/,可以看到如下介面:

這裡可以看到,只有target_idtarget_ct出現,GenericForeignKey並沒有出現在表單中。target_ct欄位允許選擇專案中的所有模型,可以使用limit_choices_to屬性來限制可以選擇的模型。

actions應用中新建utils.py檔案,在其中將編寫一個函式用來快捷的建立新Action物件:

from django.contrib.contenttypes.models import ContentType
from .models import Action

def create_action(user, verb, target=None):
    action = Action(user=user, verb=verb, target=target)
    action.save()

這個create_action()函式的引數有一個target,就是行為所關聯的目標物件,可以在任意地方匯入該檔案然後使用這個函式來快速為行為流新增新行為物件。

2.3避免新增重複的行為

有些時候,使用者可能在短期內連續點選同一型別的事件,比如取消又關注,關注再取消,如果即使儲存所有的行為,會造成大量重複的資料。為了避免這種情況,需要修改一下剛剛建立的utils.py檔案中的create_action()函式:

import datetime
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from .models import Action

def create_action(user, verb, target=None):
    # 檢查最後一分鐘內的相同動作
    now = timezone.now()
    last_minute = now - datetime.timedelta(seconds=60)
    similar_actions = Action.objects.filter(user_id=user.id, verb=verb, created__gte=last_minute)

    if target:
        target_ct = ContentType.objects.get_for_model(target)
        similar_actions = similar_actions.filter(target_ct=target_ct, target_id=target.id)

    if not similar_actions:
        # 最後一分鐘內找不到相似的記錄
        action = Action(user=user, verb=verb, target=target)
        action.save()
        return True
    return False

我們修改了create_action()函式避免在一分鐘內重複儲存相同的動作,並且返回一個布林值以表示是否成功儲存。這個函式的邏輯解釋如下:

  • 首先,通過timezone.now()獲取當前的時間,這個方法與datetime.datetime.now()相同,但是返回一個timezone-aware物件。Django使用USE_TZ設定控制是否支援時區,使用startapp命令建立的專案預設USE_TZ=True
  • 使用last_minute變數儲存之前一分鐘,然後獲取之前一分鐘到現在,當前使用者進行的所有動詞相同的行為。
  • 如果沒有找到任何相同的行為,就直接建立Action物件,並返回True,否則返回False

2.4向行為流中新增行為

現在需要編輯檢視,新增一些功能來建立行為流。我們將對下邊的行為建立行為流:

  • 任意使用者上傳了圖片
  • 任意使用者喜歡了一張圖片
  • 任意使用者建立賬戶
  • 任意使用者關注其他使用者

編輯images應用的views.py檔案:

from actions.utils import create_action

image_create檢視中,在儲存圖片之後新增create_action()語句:

new_item.save()
create_action(request.user, 'bookmarked image', new_item)

image_like檢視中,在將使用者新增到users_like關係之後新增create_action()語句:

image.users_like.add(request.user)
create_action(request.user, 'likes', image)

編輯account應用的views.py檔案,新增如下匯入語句:

from actions.utils import create_action

register視圖裡,在建立Profile物件之後新增create_action()語句:

Profile.objects.create(user=new_user)
create_action(new_user, 'has created an account')

user_follow視圖裡也新增create_action()

Contact.objects.get_or_create(user_from=request.user, user_to=user)
create_action(request.user, 'is following', user)

從上邊的程式碼中可以看到,由於建立好了Aciton模型,可以方便的新增各種行為。

2.5展示使用者行為流

最後,需要展示每個使用者的行為流,我們將在使用者的登入後頁面中展示行為流。編輯account應用的views.py檔案,修改dashboard檢視,如下:

from actions.models import Action

@login_required
def dashboard(request):
    # 預設展示所有行為,不包含當前使用者
    actions = Action.objects.exclude(user=request.user)
    following_ids = request.user.following.values_list('id', flat=True)

    if following_ids:
        # 如果當前使用者有關注的使用者,僅展示被關注使用者的行為
        actions = actions.objects.filter(user_id__in=following_ids)
    actions = actions[:10]
    return render(request, 'account/dashboard.html', {'section': 'dashboard', 'actions': actions})

在上邊程式碼中,首先從資料庫中獲取除了當前使用者之外的全部行為流資料。如果當前使用者有關注其他使用者,則在所有的行為流中篩選出屬於關注使用者的行為流。最後限制展示的數量為10條。在QuerySet中我們並沒有使用order_by()方法,因為預設已經按照ordering=('-created')進行了排序。

2.6優化QuerySet查詢關聯物件

現在我們每次獲取一個Action物件時,都會去查詢關聯的User物件,然後還會去查詢User物件關聯的Profile物件,要查詢兩次。Django ORM提供了一種簡便的方法獲取相關聯的物件,而無需反覆查詢資料庫。

Django提供了select_related()方法用於一對多欄位查詢關聯物件。這個方法實際上會得到一個更加複雜的QuerySet,然而卻避免了反覆查詢關聯物件。select_related()方法僅能用於ForeignKeyOneToOneField,其實際生成的SQL語句是JOIN連表查詢,方法的引數則是SELECT語句之後的欄位名。

為了使用select_related(),修改下邊這行程式碼:

actions = actions[:10]

將其修改成:

actions = actions.select_related('user', 'user__profile')[:10]

我們使用user__profile在查詢中將Profile資料表進行了連表查詢。如果不給select_related()傳任何引數,會將所有該表外來鍵關聯的表格都進行連表操作。最好每次都指定具體要關聯的表。

進行連表操作的時候注意避免不需要的額外連表,以減少查詢時間。

select_related()僅能用於一對一和一對多關係,不能用於多對多(ManyToMany)和多對一關係(反向的ForeignKey關係)。Django提供了QuerySet的prefetch_related()方法用於多對多和多對一關係查詢,這個方法會對每個物件的關係進行一次單獨查詢,然後再把結果連線起來。這個方法還支援查詢GenericRelationGenericForeignKey欄位。

編輯account應用的views.py檔案,為GenericForeignKey增加prefetch_related()方法:

    actions = actions.select_related('user', 'user__profile').prefetch_related('target')[:10]

現在我們就完成了優化查詢的工作。

2.7建立行為流模板

現在來建立展示使用者行為的頁面,在actions應用下建立templates目錄,新增如下檔案結構:

actions/
    action/
        detail.html

編輯actions/action/detail.html模板,新增如下內容:

{% load thumbnail %}
{% with user=action.user profile=action.user.profile %}
    <div class="action">
        <div class="images">
            {% if profile.photo %}
                {% thumbnail user.profile.photo "80x80" crop="100%" as im %}
                    <a href="{{ user.get_absolute_url }}">
                        <img src="{{ im.url }}" alt="{{ user.get_full_name }}"
                             class="item-img">
                    </a>
                {% endthumbnail %}
            {% endif %}
            {% if action.target %}
                {% with target=action.target %}
                    {% if target.image %}
                        {% thumbnail target.image "80x80" crop="100%" as im %}
                            <a href="{{ target.get_absolute_url }}">
                                <img src="{{ im.url }}" class="item-img">
                            </a>
                        {% endthumbnail %}
                    {% endif %}
                {% endwith %}
            {% endif %}
        </div>
        <div class="info">
            <p>
                <span class="date">{{ action.created|timesince }} ago</span>
                <br/>
                <a href="{{ user.get_absolute_url }}">
                    {{ user.first_name }}
                </a>
                {{ action.verb }}
                {% if action.target %}
                    {% with target=action.target %}
                        <a href="{{ target.get_absolute_url }}">{{ target }}</a>
                    {% endwith %}
                {% endif %}
            </p>
        </div>
    </div>
{% endwith %}

這是展示Action物件的模板。首先我們使用{% with %}標籤儲存當前使用者和當前使用者的Profile物件;然後如果Action物件存在關聯的目標物件而且有圖片,就展示這個目標物件的圖片;最後,展示執行這個行為的使用者的連結,動詞,和目標物件。

然後編輯account應用裡的dashboard.html,把這個頁面包含到content塊的底部:

<h2>What's happening</h2>
<div id="action-list">
    {% for action in actions %}
        {% include 'actions/action/detail.html' %}
    {% endfor %}
</div>

啟動站點,開啟http://127.0.0.1:8000/account/,使用已經存在的使用者登入,然後進行一些行為。再更換另外一個使用者登入,關注之前的使用者,然後到登入後頁面看一下行為流,如下圖所示:

我們就建立了一個完整的行為流應用,可以方便的新增使用者行為。還可以為這個頁面新增之前的AJAX動態載入頁面的效果。

3使用signals非規範化資料

有些時候你可能需要非規範化資料庫。非規範化(Denormalization)是一種資料庫方面的名詞,指通過向資料庫中新增冗餘資料以提高效率。非規範化只有在確實必要的情況下再考慮使用。使用非規範化資料的最大問題是如何保持非規範化資料始終更新。

我們將通過一個例子展示如何通過非規範化資料提高查詢效率,缺點就是必須額外編寫程式碼以保持資料更新。我們將非規範化Image模型並通過Django的訊號功能保持資料更新。

譯者注:規範化簡單理解就是不會儲存物件非必要的額外資訊,就像我們現在為止的所有設計,來自於物件基礎資訊以外的額外資訊(如求和,分組)都通過設計良好的表間關係和查詢手段獲得,而且這些基礎資訊都在對應的檢視內得到操作和更新。非規範化是與規範化相反的手段,新增冗餘資料用於提高資料庫的效率。這是結構化程式設計思想中的執行時間與佔用空間關係在資料庫結構方面的反映。

3.1使用signal功能

Django提供一個訊號模組,可以讓receiver函式在某種動作發生的時候得到通知。訊號功能在實現每當發生什麼動作就執行一些程式碼的時候很有用,也可以建立自定義的訊號用於通知其他程式

Django在django.db.models.signals中提供了一些訊號功能,其中有如下的訊號:

  • pre_savepost_save,在呼叫save()方法之前和之後傳送訊號
  • pre_deletepost_delete,在呼叫delete()方法之前和之後傳送訊號
  • m2m_changed在多對多欄位發生變動的時候傳送訊號

這只是部分訊號功能,完整的內建訊號功能見官方文件https://docs.djangoproject.com/en/2.0/ref/signals/

舉個例子來看如何使用訊號功能。如果在圖片列表頁,想給圖片按照受歡迎的程度排序,可以使用聚合函式,對喜歡該圖片的使用者合計總數,程式碼是這樣:

from django.db.models import Count
from images.models import Image
images_by_popularity = Image.objects.annotate(total_likes=Count('users_like')).order_by('-total_likes')

在效能上來說,通過合計users_like欄位,生成臨時表再進行排序的操作,遠沒有直接通過一個欄位排序的效率高。我們可以直接在Image模型上增加一個欄位,用於儲存圖片的被喜歡數合計,這樣雖然使資料庫非規範化,但顯著的提高了查詢效率。現在的問題是,如何保持這個欄位始終為最新值?

先到images應用的models.py中,為Image模型增加一個欄位total_likes

class Image(models.Model):
    # ...
    total_likes = models.PositiveIntegerField(db_index=True, default=0)

total_likes用來儲存喜歡該圖片的使用者總數,這個非規範化的欄位在查詢和排序的時候非常簡便。

在使用非規範化手段之前,還有幾種方法可以提高效率,比如使用索引,優化查詢和使用快取。

新增完欄位之後執行資料遷移程式。

之後需要給m2m_changed訊號設定一個receiver函式,在images應用目錄內新建一個signals.py檔案,新增如下程式碼:

from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Image

@receiver(m2m_changed, sender=Image.users_like.through)
def users_like_changed(sender, instance, kwargs):
    instance.total_likes = instance.users_like.count()
    instance.save()

首先,使用@receiver裝飾器,將users_like_changed函式註冊為一個事件的接收receiver函式,然後將其設定為監聽m2m_changed型別的訊號,並且設定訊號來源為Image.users_like.through,這表示來自於Image.users_like欄位的變動會觸發該接收函式。除了如此設定之外,還可以採用Signal物件的connect()方法進行設定。

DJango的訊號是同步阻塞的,不要將訊號和非同步任務的概念搞混。可以將二者有效結合,讓程式在收到某個訊號的時候啟動非同步任務。

配置好receiver接收函式之後,還必須將函式匯入到應用中,這樣就可以在每次傳送訊號的時候呼叫函式。推薦的做法是在應用配置類的ready()方法中,匯入接收函式。這就需要再瞭解一下應用配置類。

3.2應用配置類

Django允許為每個應用設定一個單獨的應用配置類。當使用startapp命令建立一個應用時,Django會在應用目錄下建立一個apps.py檔案,並在其中自動設定一個名稱為“首字母大寫的應用名+Config”並繼承AppConfig類的應用配置類。

使用應用配置類可以儲存這個應用的元資料,應用配置和提供自省功能。應用配置類的官方文件https://docs.djangoproject.com/en/2.0/ref/applications/

我們已經使用@receiver裝飾器註冊好了訊號接收函式,這個函式應該在應用一啟動的時候就可以進行呼叫,所以要註冊在應用配置類中,其他類似的需要在應用初始化階段就呼叫的功能也要註冊在應用配置類中。編輯images應用的apps.py檔案:

from django.apps import AppConfig

class ImagesConfig(AppConfig):
    name = 'images'

    def ready(self):
        # 匯入訊號接收函式
        import images.signals

通過ready()方法匯入之後,在images應用載入的時候該函式就會被匯入。

啟動程式,選中一張圖片並點選LIKE按鈕,然後到管理站點檢視該圖片,例如http://127.0.0.1:8000/admin/images/image/1/change/,可以看到新增的total_likes欄位。還可以看到total_likes欄位已經得到了更新,如圖所示:

現在可以用total_likes欄位排序圖片並且顯示總數量,避免複雜的查詢。看一下本章開頭的查詢語句:

from django.db.models import Count

images_by_popularity = Image.objects.annotate(likes=Count('users_like')).order_by('-likes')

現在上邊的查詢可以改成下邊這樣:

images_by_popularity = Image.objects.order_by('-total_likes')

現在這個查詢的開銷要比原來小很多,這是一個使用訊號的例子。

使用訊號功能會讓控制流變得更加難以追蹤,在很多情況下,如果明確知道需要進行什麼操作,無需使用訊號功能。

對於已經存在表內的物件,total_likes欄位中還沒有任何資料,需要為所有物件設定當前的值,通過python manage.py shell進入帶有當前Django環境的Python命令列,並輸入下列命令:

>>> from images.models import Image
>>>for image in Image.objects.all():
>>>    image.total_likes = image.users_like.count()
>>>    image.save()

現在每個圖片的total_likes欄位已被更新。

4使用Redis資料庫

Redis是一個先進的鍵值對資料庫,可以儲存多種型別的資料並提供高速存取服務。Redis執行時的資料儲存在記憶體中,也可以定時將資料持久化到磁碟中或者通過日誌輸出。Redis相比普通的鍵值對儲存,具有一系列強力的命令支援不同的資料格式,比如字串、雜湊值、列表、集合和有序集合,甚至是點陣圖或HyperLogLogs資料。

儘管SQL資料庫依然是儲存結構化資料的最佳選擇,對於迅速變化的資料、反覆使用的資料和快取需求,採用Redis有著獨特的優勢。本節來看一看如何通過Redis為我們的專案增加一個新功能。

4.1安裝Redis

https://redis.io/download下載最新的Redis資料庫,解壓tar.gz檔案,進入redis目錄,然後使用make命令編譯安裝Redis:

cd redis-4.0.9
make

在安裝完成後,在命令列中輸入如下命令來初始化Redis服務:

src/redis-server

可以看到如下輸出:

# Server initialized
* Ready to accept connections

說明Redis服務已經啟動。Redis預設監聽6379埠。可以使用--port引數指定埠,例如redis-server --port 6655

保持Redis服務執行,新開一個系統終端視窗,啟動Redis客戶端:

src/redis-cli

可以看到如下提示:

127.0.0.1:6379>

說明已經進入Redis命令列模式,可以直接執行Redis命令,我們來試驗一些命令:

譯者注:Redis官方未提供Windows版本,可以在https://github.com/MicrosoftArchive/redis/releases找到Windows版,安裝好之後預設已經新增Redis服務,預設埠號和Linux系統一樣是6379。進入cmd,輸入redis-cli進入Redis命令列模式。

使用SET命令儲存一個鍵值對:

127.0.0.1:6379> SET name "Peter"
OK

上邊的命令建立了一個name鍵,值是字串"Peter"OK表示這個鍵值對已被成功儲存。可以使用GET命令取出該鍵值對:

127.0.0.1:6379> GET name
"Peter"```

使用`EXIST`命令檢測某個鍵是否存在,返回整數1表示True,0表示False:

127.0.0.1:6379> EXISTS name
(integer) 1```

使用EXPIRE設定一個鍵值對的過期秒數。還可以使用EXPIREAT以UNIX時間戳的形式設定過期時間。過期時間對於將Redis作為快取時很有用:

127.0.0.1:6379> GET name
"Peter"
127.0.0.1:6379> EXPIRE name 2
(integer) 1

等待超過2秒鐘,然後嘗試獲取該鍵:

127.0.0.1:6379> GET name
(nil)

(nil)說明是一個null響應,即沒有找到該鍵。使用DEL命令可以刪除鍵和值,如下:

127.0.0.1:6379> SET total 1
OK
127.0.0.1:6379> DEL total
(integer) 1
127.0.0.1:6379> GET total
(nil)

這是Redis的基礎操作,Redis對於各種資料型別有很多命令,可以在https://redis.io/commands檢視命令列表,Redis所有支援的資料格式在https://redis.io/topics/data-types

譯者注:特別要看一下Redis中有序集合這個資料型別,以下會使用到。

4.2通過Python操作Redis

同使用PostgreSQL一樣,在Python安裝支援該資料庫的模組redis-py

pip install redis==2.10.6

該模組的文件可以在https://redis-py.readthedocs.io/en/latest/找到。

redis-py提供了兩大功能模組,StrictRedisRedis,功能完全一樣。區別是前者只支援標準的Redis命令和語法,後者進行了一些擴充套件。我們使用嚴格遵循標準Redis命令的StrictRedis模組,開啟Python命令列介面,輸入以下命令:

>>> import redis
>>> r = redis.StrictRedis(host='localhost', port=6379, db=0)

上述命令使用本機地址和埠和資料庫編號例項化資料庫連線物件,在Redis內部,資料庫的編號是一個整數,共有0-16號資料庫,預設客戶端連線到的資料庫是0號資料庫,可以通過修改redis.conf更改預設資料庫。

通過Python存入一個鍵值對:

>>> r.set('foo', 'bar')
True

返回True表示成功存入鍵值對,通過get()方法取鍵值對:

>>> r.get('foo')
b'bar'

可以看到,這些方法源自同名的標準Redis命令。

瞭解Python中使用Redis之後,需要把Redis整合到Django中來。編輯bookmarks應用的settings.py檔案,新增如下設定:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0

這是Redis服務的相關設定。

4.3在Redis中儲存圖片瀏覽次數

我們需要儲存一個圖片被瀏覽過的總數。如果我們使用Django ORM來實現,每次展示一個圖片時,需要通過檢視執行SQL的UPDATE語句並寫入磁碟。如果我們使用Redis,只需要每次對儲存在記憶體中的一個數字增加1,相比之下Redis的速度要快很多。

編輯images應用的views.py檔案,在最上邊的匯入語句後邊新增如下內容:

import redis
from django.conf import settings

r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB)

通過上述語句,在檢視檔案中例項化了一個Redis資料庫連線物件,等待其他檢視的呼叫。編輯image_detail檢視,讓其看起來如下:

@login_required
def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # 瀏覽數+1
    total_views = r.incr('image:{}:views'.format(image.id))
    return render(request, 'images/image/detail.html',
                  {'section': 'images', 'image': image, 'total_views': total_views})

這個檢視使用了incr命令,將該鍵對應的值增加1。如果鍵不存在,會自動建立該鍵(初始值為0)然後將值加1。incr()方法返回增加1這個操作之後的結果,也就是最新的瀏覽總數。然後用total_views儲存瀏覽總數並傳入模板。我們採用Redis的常用格式建立鍵名,如object-type:id:field(例如image:33:id)。

Redis資料庫的鍵常用冒號分割的字串來建立類似於帶有名稱空間一樣的鍵值,這樣的鍵名易於閱讀,而且在其名字中有共同的部分,便於對應至具體物件和查詢。

編輯images/image/detail.html,在<span class="count">之後追加:

<span class="count">
    {{ total_views }} view{{ total_views|pluralize }}
</span>

開啟一個圖片的詳情頁面,然後按F5重新整理幾次,能夠看到訪問數“ * views”不斷上升,如下圖所示:

現在我們就將Redis整合到Django中並用其顯示數量了。

4.4在Redis中儲存排名

現在用Redis來實現一個更復雜一些的功能:建立一個排名,按照圖片的訪問量將圖片進行排名。為了實現這個功能,將使用Redis的有序集合資料型別。有序集合是一個不重複的字串集合,其中的每一個字串都對應一個分數,按照分數的大小進行排序。

編輯images應用裡的views.py檔案,繼續修改image_detail檢視:

@login_required
def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    total_views = r.incr('image:{}:views'.format(image.id))
    `# 在有序集合image_ranking裡,把image.id的分數增加1`
    r.zincrby('image_ranking', image.id, 1)
    return render(request, 'images/image/detail.html',
                  {'section': 'images', 'image': image, 'total_views': total_views})

使用zincrby方法建立一個image_ranking有序集合物件,在其中儲存圖片的id,然後將對應的分數加1。這樣就可以在每次圖片被瀏覽之後,更新該圖片被瀏覽的次數以及所有圖片被瀏覽的次數的排名。

在當前的views.py檔案裡建立一個新的檢視用於展示圖片排名:

@login_required
def image_ranking(request):
    # 獲得排名前十的圖片ID列表
    image_ranking = r.zrange('image_ranking', 0, -1, desc=True)[:10]
    image_ranking_ids = [int(id) for id in image_ranking]
    # 取排名最高的圖片然後排序
    most_viewed = list(Image.objects.filter(id__in=image_ranking_ids))
    most_viewed.sort(key=lambda x: image_ranking_ids.index(x.id))
    return render(request, 'images/image/ranking.html', {'section': 'images', 'most_viewed': most_viewed})

這個image_ranking檢視工作邏輯如下:

  1. 使用zrange()命令從有序集合中取元素,後邊的兩個引數表示開始和結束索引,給出0-1的範圍表示取全部元素,desc=True表示將這些元素降序排列。最後使用[:10]切片列表前10個元素。
  2. 使用列表推導式,取得了鍵名對應的整數構成的列表,存入image_ranking_ids中。然後查詢id屬於該列表中的所有Image物件。由於要按照image_ranking_ids中的順序對查詢結果進行排序,所以使用list()將查詢結果列表化。
  3. 按照每個Image物件的id在image_ranking_ids中的順序,對查詢結果組成的列表進行排序。

images/image/模板目錄內建立ranking.html,新增下列程式碼:

{% extends 'base.html' %}
{% block title %}
    Images Ranking
{% endblock %}

{% block content %}
    <h1>Images Ranking</h1>
    <ol>
        {% for image in most_viewed %}
            <li>
                <a href="{{ image.get_absolute_url }}">{{ image.title }}</a>
            </li>
        {% endfor %}
    </ol>
{% endblock %}

這個頁面很簡單,迭代most_viewed中的每個Image物件,展示圖片內容、名稱和對應的詳情連結。

最後為新的檢視配置URL,編輯images應用的urls.py檔案,增加一行:

path('ranking/', views.image_ranking, name='ranking'),

譯者注:原書此處有誤,name引數的值設定成了create,按作者的一貫寫法,應該為'ranking'

之後啟動站點,訪問不同圖片的詳情頁,反覆重新整理拖杆次,然後開啟http://127.0.0.1:8000/images/ranking/,即可看到排名頁面:

4.5進一步使用Redis

Redis無法替代SQL資料庫,但其使用記憶體儲存的特性可以用來完成模型具體任務,把Redis加入到你的工具庫裡,在必要的時候就可以使用它。下邊是一些適合使用Redis的場景:

  • 計數:從我們的例子可以看出,使用Redis管理計數非常便捷,incr()incrby()方法可以方便的實現計數功能。
  • 儲存最新的專案:使用lpush()rpush()可以向一個佇列的開頭和末尾追加資料,lpop()rpop()則是從佇列開始和末尾彈出元素。如果操作造成佇列長度改變,還可以用ltrim()保持佇列長度。
  • 佇列:除了上邊的poppush系列方法,Redis還提供了阻塞佇列的方法
  • 快取:expire()expireat()方法讓使用者可以把Redis當做快取來使用,還可以找到一些第三方開發的將Redis配置為Django快取後端的模組。
  • 訂閱/釋出:Redis提供訂閱/釋出訊息模式,可以向一些頻道傳送訊息,訂閱該頻道的Redis客戶端可以接受到該訊息。
  • 排名和排行榜:Redis的有序集合可以方便的建立排名相關的資料。
  • 實時跟蹤:Redis的高速I/O可以用在實時追蹤並更新資料方面。

總結

這一章裡完成了兩大任務,一個是使用者之間的互相關注系統,一個是使用者行為流系統。還學習了使用Django的訊號功能,和將Redis整合至Django。

在下一章,我們將開始一個新的專案,建立一個電商網站。將學習建立商品品類,通過session建立購物車,以及使用Celery啟動非同步任務。