第六章 跟踪用户动作

6 跟踪用户动作

在上一章中,你用jQuery实现了AJAX视图,并构建了一个分享其它网站内容的JavaScript书签工具。

本章中,你将学习如何构建关注系统和用户活动流。你会了解Django的信号(signals)如何工作,并在项目中集成Redis快速I/O存储,用于存储项视图。

本章将会覆盖以下知识点:

  • 用中介模型创建多对多关系
  • 构建AJAX视图
  • 创建活动流应用
  • 为模型添加通用关系
  • 优化关联对象的QuerySet
  • 使用信号进行反规范化计数
  • 在Redis中存储项的浏览次数

6.1 构建关注系统

我们将在项目中构建关注系统。用户可以相互关注,并跟踪其他用户在平台分享的内容。用户之间是多对多的关系,一个用户可以关注多个用户,也可以被多个用户关注。

6.1.1 用中介模型创建多对多关系

在上一章中,通过在一个关联模型中添加ManyToManyField,我们创建了多对多的关系,并让Django为这种关系创建了一张数据库表。这种方式适用于大部分情况,但有时候你需要为这种关系创建一个中介模型。当你希望存储这种关系的额外信息(比如关系创建的时间,或者描述关系类型的字段)时,你需要创建中介模型。

我们将创建一个中介模型用于构建用户之间的关系。我们使用中介模型有两个原因:

  • 我们使用的是Django提供的User模型,不想修改它。
  • 我们想要存储关系创建的时间。

编辑account应用的models.py文件,添加以下代码:

from django.contrib.auth.models import User

class Contact(models.Model):
    user_from = models.ForeignKey(User, related_name='rel_from_set')
    user_to = models.ForeignKey(User, related_name='rel_to_set')
    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:指向创建关系的用户的ForeignKey
  • user_to:指向被关注用户的ForeignKey
  • created:带auto_new_add=TrueDateTimeField字段,存储创建关系的时间

数据库会自动在ForeignKey字段上创建索引。我们在created字段上用db_index=True创建数据库索引。当用这个字段排序QuerySet时,可以提高查询效率。

通过ORM,我们可以创建用户user1关注用户user2的关系,如下所示:

user1 = User.objects.get(id=1)
user2 = User.objects.get(id=2)
Contact.objects.create(user_from=user1, user_to=user2)

关联管理器rel_from_setrel_to_set会返回Contact模型的QuerySet。为了从User模型访问关系的另一端,我们希望User模型包括一个ManyToManyField,如下所示:

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

这个例子中,通过在ManyToManyField字段中添加through=Contact,我们告诉Django使用自定义的中介模型。这是从User模型到它自身的多对多关系:我们在ManyToManyField字段中引用'self'来创建到同一个模型的关系。

当你在多对多的关系中需要额外字段时,可以在关系两端创建带ForeignKey的自定义模型。在其中一个关联模型中添加ForeignKey,并通过through参数指向中介模型,让Django使用该中介模型。

如果User模型属于我们的应用,我们就可以把上面这个字段添加到模型中。但是我们不能直接修改它,因为它属于django.contrib.auth应用。我们将采用略微不同的方法:动态的添加该字段到User模型中。编辑account应用的models.py文件,添加以下代码:

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

在这段代码中,我们使用Django模型的add_to_class()方法添加monkey-patchUser模型中。不推荐使用add_to_class()为模型添加字段。但是,我们在这里使用这种方法有以下几个原因:

  • 通过Django ORM的user.followers.all()user.following.all(),可以简化检索关联对象。我们使用Contact中介模型,避免涉及数据库连接(join)的复杂查询。如果我们在Profile模型中定义关系,则需要使用复杂查询。
  • 这个多对多关系的数据库表会使用Contact模型创建。因此,动态添加的ManyToManyField不会对Django的User模型数据库做任何修改。
  • 我们避免创建自定义的用户模型,充分利用Django内置的User模型。

记住,在大部分情况下都推荐使用添加字段到我们之前创建的Profile模型,而不是添加monkey-patchUser模型。Django也允许你使用自定义的用户模型。如果你想使用自定义的用户模型,请参考文档

你可以看到,关系中包括symmetrical=False。当你定义ManyToManyField到模型自身时,Django强制关系是对称的。在这里,我们设置symmetrical=False定义一个非对称关系。也就是说,如果我关注了你,你不会自动关注我。

当使用中介模型定义多对多关系时,一些关系管理器的方法将不可用,比如add()create()remove()。你需要创建或删除中介模型来代替。

执行以下命令为account应用生成初始数据库迁移:

python manage.py makemigrations account

你会看到以下输出:

Migrations for 'account':
  account/migrations/0002_contact.py
    - Create model Contact

现在执行以下命令同步数据库和应用:

python manage.py migrate account

你会看到包括下面这一行的输出:

Applying account.0002_contact... OK

现在Contact模型已经同步到数据库中,我们可以在用户之间创建关系了。但是我们的网站还不能浏览用户,或者查看某个用户的个人资料。让我们为User模型创建列表和详情视图。

6.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对象简单的列表和详情视图。user_list视图获得所有激活的用户。Django的User模型包括一个is_active标记,表示用户账户是否激活。我们通过is_active=True过滤查询,只返回激活的用户。这个视图返回了所有结果,你可以跟image_list视图那样,为它添加分页。

user_detail视图使用get_object_or_404()快捷方法,检索指定用户名的激活用户。如果没有找到指定用户名的激活用户,该视图返回HTTP 404响应。

编辑account应用的urls.py文件,为每个视图添加URL模式,如下所示:

urlpatterns= [
    # ...
    url(r'^users/$', views.user_list, name='user_list'),
    url(r'^users/(?P<username>[-\w]+)/$', views.user_detail, name='user_detail'),
]

我们将使用user_detail URL模式为用户生成标准URL。你已经在模型中定义过get_absolute_url()方法,为每个对象返回标准URL。另一种方式是在项目中添加ABSOLUTE_URL_OVERRIDES设置。

编辑项目的settings.py文件,添加以下代码:

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

Django为ABSOLUTE_URL_OVERRIDES设置中的所有模型动态添加get_absolute_url()方法。这个方法返回给定模型的对应URL。我们为给定用户返回user_detail URL。现在你可以在User实例上调用get_absolute_url()方法获得相应的URL。用python manage.py shell打开Python终端,执行以下命令测试:

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

返回的结果是期望的URL。我们需要为刚创建的视图创建模板。在account应用的templates/account/目录中添加以下目录和文件:

user/
    detail.html
    list.html

编辑account/user/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 %}
                        ![]({{ 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 %}

该模板列出网站中所有激活的用户。我们迭代给定的用户,使用sorl-thumbnail{% thumbnail %}模板标签生成个人资料的图片缩略图。

打开项目的base.html文件,在以下菜单项的href属性中包括user_list URL:

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

执行python manage.py runserver命令启动开发服务器,然后在浏览器中打开http://127.0.0.1/8000/account/users/。你会看到用户列表,如下图所示:

编辑account应用的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 %}
            ![]({{ im.url }})
        {% 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 %}模板标签显示个人资料图片。我们显示关注者总数和一个用于follow/unfollow的链接。如果用户正在查看自己的个人资料,我们会隐藏该链接,防止用户关注自己。我们将执行AJAX请求来follow/unfollow指定用户。我们在<a>元素中添加data-iddata-action属性,其中分别包括用户ID和点击链接时执行的操作(关注或取消关注),这取决于请求该页面的用户是否已经关注了这个用户。我们用list_ajax.html模板显示这个用户标记过的图片。

再次打开浏览器,点击标记过一些图片的用户。你会看到个人资料详情,如下图所示:

6.1.3 构建关注用户的AJAX视图

我们将使用AJAX创建一个简单视图,用于关注或取消关注用户。编辑account用于的views.py文件,添加以下代码:

from django.http import JsonResponse
from django.views.decorators.http import require_POST
from common.decrorators 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_follow视图跟我们之前创建的image_like视图很像。因为我们为用户的多对多关系使用了自定义的中介模型,所以ManyToManyField自动生成的管理器的默认add()remove()方法不可用了。我们使用Contact中介模型创建或删除用户关系。

account应用的urls.py文件中导入你刚创建的视图,然后添加以下URL模式:

url(r'^users/follow/$', views.user_follow, name='user_follow'),

确保你把这个模式放在user_detail模式之前。否则任何到/users/follow/的请求都会匹配user_detail模式的正则表达式,然后执行user_detail视图。记住,每一个HTTP请求时,Django会按每个模式出现的先后顺序匹配请求的URL,并在第一次匹配成功后停止。

编辑account应用的user/detail.html模板,添加以下代码:

{% 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') {
                var previous_action = $('a.follow').data('action');

                // toggle data-action
                $('a.follow').data('action', previous_action == 'follow' ? 'unfollow' : 'follow');
                // toggle link text
                $('a.follow').text(previous_action == 'follow' ? 'Unfollow' : 'Follow');

                // update total followers
                var previous_followers = parseInt($('span.count .total').text())
                $('span.count .total').text(previous_action == 'follow' ? previous_followers+1 : previous_followers - 1);
            }
        });
    });
{% endblock %}

这段JavaScript代码执行关注或取消关注指定用户的AJAX请求,同时切换follow/unfollow链接。我们用jQuery执行AJAX请求,并根据之前的值设置data-action属性和<a>元素的文本。AJAX操作执行完成后,我们更新页面显示的关注总数。打开一个已存在用户的详情页面,点击FOLLOW链接,测试我们刚添加的功能。

6.2 构建通用的活动流应用

很多社交网站都会给用户显示活动流,让用户可以跟踪其他用户在平台上做了什么。活动流是一个或一组用户最近执行的活动列表。比如,Facebook的News Feed就是一个活动流。又或者用户X标记了图片Y,或者用户X不再关注用户Y。我们将构造一个活动流应用,让每个用户都可以看到他关注的用户最近的操作。要实现这个功能,我们需要一个模型,存储用户在网站中执行的操作,并提供简单的添加操作的方式。

用以下命令在项目中创建actions应用:

django-admin startapp actions

在项目的settings.py文件的INSTALLED_APPS中添加actions,让Django知道新应用已经激活:

INSTALLED_APPS = (
    # ...
    'actions',
)

编辑actions应用的models.py文件,添加以下代码:

from django.db import models
from django.contrib.auth.models import User

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

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

这是Actioin模型,用于存储用户活动。该模型的字段有:

  • user:执行这个操作的用户。这是一个指向Django的User模型的ForeignKey
  • verb:描述用户执行的操作。
  • created:该操作创建的日期和时间。我们使用auto_now_add=True自动设置为对象第一次在数据库中保存的时间。

通过这个基础模型,我们只能存储类似用户X做了某些事情的操作。我们需要一个额外的ForeignKey字段,存储涉及目标对象的操作,比如用户X标记了图片Y,或者用户X关注了用户Y。你已经知道,一个普通的ForeignKey字段只能指向另一个模型。但是我们需要一种方式,让操作的目标对象可以是任何一个已经存在的模型的实例。这就是Django的contenttypes框架的作用。

6.2.1 使用contenttypes框架

Django的contenttypes框架位于django.contrib.contenttypes中。这个应用可以跟踪项目中安装的所有模型,并提供一个通用的接口与模型交互。

当你使用startproject命令创建新项目时,django.contrib.contenttypes已经包括在INSTALLED_APPS设置中。它被其它contrib包(比如authentication框架和admin应用)使用。

contenttypes应用包括一个ContentType模型。这个模型的实例代表你的应用中的真实模型,当你的项目中安装了一个新模型时,会自动创建一个新的ContentType实例。ContentType模型包括以下字段:

  • app_label:模型所属应用的名字。它会自动从模型Meta选项的app_label属性中获得。例如,我们的Image模型属于images应用。
  • model:模型的类名。
  • name:模型的人性化名字。它自动从模型Meta选项的verbose_name属性中获得。

让我们看下如何与ContentType对象交互。使用python manage.py shell命令打开Python终端。通过执行带label_namemodel属性的查询,你可以获得指定模型对应的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>

这些只是使用contenttypes的几个示例。Django提供了更多使用它们的方式。你可以在官方文档学习contenttypes框架。

6.2.2 在模型中添加通用关系

在通用关系中,ContentType对象指向关系中使用的模型。在模型中设置通用关系,你需要三个字段:

  • 一个ForeignKey字段指向ContentType。这会告诉我们关系中的模型。
  • 一个存储关联对象主键的字段。通常这是一个PositiveIntegerField,来匹配Django自动生成的主键字段。
  • 一个使用上面两个字段定义和管理通用关系的字段。contenttypes框架为此定义了GenericForeignKey字段。

编辑actions应用的models.py文件,如下所示:

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

class Action(models.Model):
    user = models.ForeignKey(User, related_name='actions', db_index=True)
    verb = models.CharField(max_length=255)

    target_ct = models.ForeignKey(ContentType, blank=True, null=True, related_name='target_obj')
    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模型的ForeignKey字段。
  • target_id:一个用于存储关联对象主键的PositiveIntegerField
  • target:一个指向由前两个字段组合的关联对象的GenericForeignKey字段。

Django不会在数据库中为GenericForeignKey字段创建任何字段。只有target_cttarget_id字段会映射到数据库的字段。因为这两个字段都有blank=Truenull=True属性,所以保存Action对象时target对象不是必需的。

使用通用关系有意义的时候,你可以使用它代替外键,让应用更灵活。

执行以下命令为这个应用创建初始的数据库迁移:

python manage.py makemigrations actions

你会看到以下输出:

Migrations for 'actions':
  actions/migrations/0001_initial.py
    - Create model Action

接着执行以下命令同步应用和数据库:

python manage.py migrate

这个命令的输入表示新的数据库迁移已经生效:

Applying actions.0001_initial... OK

当我们把Action模型添加到管理站点。编辑actions应用的admin.py文件,添加以下代码:

from django.contrib import admin
from .models import Action

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

admin.site.register(Action, ActionAdmin)

你刚刚在管理站点注册了Action模型。执行python manage.py runserver命令启动开服务器,然后在浏览器中打开http://127.0.0.1:8000/actions/action/add/。你会看到创建一个新的Action对象的页面,如下图所示:

正如你所看到的,只有target_cttarget_id字段映射到实际的数据库字段,而GenericForeignKey没有在这里出现。target_ct允许你选择在Django项目中注册的任何模型。使用target_ct字段的limit_choices_to属性,可以让contenttypes从一个限制的模型集合中选择:limit_choices_to属性允许你限制ForeignKey字段的内容为一组指定的值。

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()方法允许我们创建Action对象,其中包括一个可选的target对象。我们可以在任何地方使用这个函数添加新操作到活动流中。

6.2.3 避免活动流中的重复操作

有时候用户可能执行一个操作多次。他们可能在很短的时间内多次点击like/unlike按钮,或者执行同一个操作多次。最终会让你存储和显示重复操作。为了避免这种情况,我们会完善create_acion()函数,避免大部分重复操作。

编辑actions应用的utils.py文件,如下所示:

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):
    # check for any similar action made in the last minute
    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, targt_id=target.id)

    if not similar_actions:
        # no existing actions found
        action = Action(user=user, verb=verb, target=target)
        action.save()
        return True
    return False

我们修改了create_action()函数,避免保存重复操作,并返回一个布尔值,表示操作是否保存。我们是这样避免重复的:

  • 首先使用Django提供的timezone.now()方法获得当前时间。这个函数的作用与datetime.datetime.now()相同,但它返回一个timezone-aware对象。Django提供了一个USE_TZ设置,用于启用或禁止时区支持。使用startproject命令创建的默认settings.py文件中,包括USE_TZ=True
  • 我们使用last_minute变量存储一分钟之前的时间,然后我们检索用户从那之后执行的所有相同操作。
  • 如果最后一分钟没有相同的操作,则创建一个Action对象。如果创建了Action对象,则返回True,否则返回False

6.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()

new_user.save()
profile = 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)

正如你所看到的,多亏了Action模型和帮助函数,让我们很容易的在活动流中保存新操作。

6.2.5 显示活动流

最后,我们需要为每个用户显示活动流。我们将把它包括在用户的仪表盘中。编辑account应用的views.py文件。导入Action模型,并修改dashboard视图:

from actions.models import Action

@login_required
def dashboard(request):
    # Display all actions by default
    actions = Action.objects.exclude(user=request.user)
    following_ids = request.user.following.values_list('id', flat=True)
    if following_ids:
        # If user is following others, retrieve only their actions
        actions = actions.filter(user_id__in=following_ids)
    actions = actions[:10]

    return render(request,
                  'account/dashboard.html',
                  {'section': 'dashboard', 'actions': actions})

在这个视图中,我们从数据库中检索当前用户之外的所有用户执行的操作。如果用户还没有关注任何人,我们显示其它用户最近的操作。这是用户没有关注其他用户时的默认行为。如果用户关注了其他用户,我们限制查询只显示他关注的用户执行的操作。最后,我们限制只返回前10个操作。在这里没有使用order_by()进行排序,因为我们使用Action模型的Meta选项提供的默认排序。因为我们在Action模型中设置了ordering = ('-created',),所以会先返回最新的操作。

6.2.6 优化涉及关联对象的QuerySet

每次检索一个Action对象时,你可能需要访问与它关联的User对象,以及该用户关联的Profile对象。Django ORM提供了一种方式,可以一次检索关联对象,避免额外的数据库查询。

6.2.6.1 使用select_related

Django提供了一个select_related方法,允许你检索一对多关系的关联对象。它会转换为单个更复杂的QuerySet,但是访问关联对象时,可以避免额外的查询。select_related方法用于ForeignKeyOneToOne字段。它在SELECT语句中执行SQL JOIN,并且包括了关联对象的字段。

要使用select_related(),需要编辑之前代码的这一行:

actions = actions.filter(user_id__in=following_ids)

并在你会使用的字段上添加select_related

actions = actions.filter(user_id__in=following_ids)\
    .select_related('user', 'user__profile')

我们用user__profile在单条SQL查询中连接了Profile表。如果调用select_related()时没有传递参数,那么它会从所有ForeignKey关系中检索对象。总是将之后会访问的关系限制为select_related()

仔细使用select_related()可以大大减少执行时间。

6.2.6.2 使用prefetch_related

正如你所看到的,在一对多关系中检索关联对象时,select_related()会提高执行效率。但是select_related()不能用于多对多或者多对一关系。Django提供了一个名为prefetch_relatedQuerySet方法,除了select_related()支持的关系之外,还可以用于多对多和多对一关系。prefetch_related()方法为每个关系执行独立的查询,然后用Python连接结果。该方法还支持GenericRelationGenericForeignKey的预读取。

GenericForeignKey字段target添加prefetch_related(),完成这个查询:

actions = actions.filter(user_id__in=following_ids)\
    .select_related('user', 'user__profile')\
    .prefetch_related('target')

现在查询已经优化,用于检索包括关联对象的用户操作。

6.2.7 为操作创建模板

我们将创建模板用于显示特定的Action对象。在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 }}">
                        ![]({{ im.url }})
                    </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 }}">
                                ![]({{ im.url }})
                            </a>
                        {% endthumbnail %}
                    {% endif %}
                {% endwith %}
            {% endif %}
        </div>
        <div class="info">
            <p>
                <span class="date">{{ action.created|timesince }} age</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 %}模板标签检索执行操作的用户和他们的个人资料。接着,如果Action对象有关联的target对象,则显示target对象的图片。最后,我们显示执行操作的用户链接,描述,以及target对象(如果有的话)。

现在编辑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分页,在活动流中添加无限滚动,就像我们在image_list视图中那样。

6.3 使用信号进行反规范化计数

某些情况下你希望对数据进行反规范化处理。反规范化(denormalization)是在一定程度上制造一些冗余数据,从而优化读取性能。你必须小心使用反规范化,只有当你真的需要的时候才使用。反规范化最大的问题是很难保持数据的更新。

我们将通过一个例子解释如何通过反规范化计数来改善查询。缺点是我们必须保持冗余数据的更新。我们将在Image模型中使用反规范数据,并使用Django的信号来保持数据的更新。

6.3.1 使用信号

Django自带一个信号调度程序,当特定动作发生时,允许接收函数获取通知。当某些事情发生时,你的代码需要完成某些工作,信号非常有用。你也可以创建自己的信号,当事件发生时,其他人可以获得通知。

Django在django.db.models.signals中为模型提供了几种信号,其中包括:

  • pre_savepost_save:调用模型的save()方法之前或之后发送
  • pre_deletepost_delete:调用模型或QuerySetdelete()方法之前或之后发送
  • m2m_changed:当模型的ManyToManyField改变时发送

这只是Django提供了部分信号。你可以在这里查看Django的所有内置信号。

我们假设你想获取热门图片。你可以使用Django聚合函数,按用户喜欢数量进行排序。记住你已经在第三章中使用了聚合函数。以下代码按喜欢数量查询图片:

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')

但是,通过统计图片的喜欢数量比直接使用一个存储喜欢数量的字段更费时。你可以在Image模型中添加一个字段,用来反规范化喜欢数量,从而提高涉及这个字段的查询性能。如何保持这个字段的更新呢?

编辑images应用的models.py文件,为Image模型添加以下字段:

total_likes = models.PositiveIntegerField(db_index=True, default=0)

total_likes字段允许我们存储每张图片被用户喜欢的数量。当你希望过滤或者排序QuerySet时,反规范计数非常有用。

在使用反规范字段之前,你必须考虑其它提升性能的方式。比如数据库索引,查询优化和缓存。

执行以下命令为新添加的字段创建数据库迁移:

python manage.py makemigrations images

你会看到以下输出:

Migrations for 'images':
  images/migrations/0002_image_total_likes.py
    - Add field total_likes to image

接着执行以下命令让迁移生效:

python manage.py migrate images

输出中会包括这一行:

Applying images.0002_image_total_likes... OK

我们将会为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.throuth,只有这个发送者发起m2m_changed信号时,这个方法才会被调用。还可以使用Signal对象的connect()方法来注册receiver()函数。

Django信号是同步和阻塞的。不要用异步任务导致信号混乱。但是,当你的代码从信号中获得通知时,你可以组合两者来启动异步任务。

你必须把接收器函数连接到一个信号,这样每次发送信号时,接收器函数才会调用。注册信号的推荐方式是在应用配置类的ready()函数中导入它们。Django提供了一个应用注册表,用于配置和内省应用。

6.3.2 定义应用配置类

Django允许你为应用指定配置类。要为应用提供一个自定义配置,你需要创建一个自定义类,它继承自位于django.apps中的AppConfig类。应用配置类允许为应用存储元数据和配置,并提供内省。

你可以在这里阅读更多关于应用配置的信息。

为了注册你的信号接收函数,当你使用receiver()装饰器时,你只需要在AppConfig类的ready()方法中导入应用的信号模块。一旦应用注册表完全填充,就会调用这个方法。这个方法中应该包括应用的所有初始化工作。

images应用目录下创建apps.py文件,并添加以下代码:

from django.apps import AppConfig


class ImagesConfig(AppConfig):
    name = 'images'
    verbose_name = 'Image bookmarks'

    def ready(self):
        # import signal handlers
        import images.signals

译者注:Django 1.11版本中,默认已经生成了apps.py文件,只需要在其中添加ready()方法。

其中,name属性定义应用的完整Python路径;verbose_name属性设置应用的可读名字。它会在管理站点中显示。我们在ready()方法中导入该应用的信号。

现在我们需要告诉Django应用配置的位置。编辑images应用目录的__init__.py文件,添加这一行代码:

default_app_config = 'images.apps.ImagesConfig'

在浏览器中查看图片详情页面,并点击like按钮。然后回到管理站点查看total_likes属性。你会看到total_likes已经更新,如下图所示:

现在你可以使用total_likes属性按热门排序图片,或者在任何地方显示它,避免了用复杂的查询来计算。以下按图片被喜欢的总数量排序的查询:

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

可以变为这样:

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

通过更快的SQL查询就返回了这个结果。这只是使用Django信号的一个示例。

小心使用信号,因为它会让控制流更难理解。如果你知道会通知哪个接收器,很多情况下就能避免使用信号。

你需要设置初始计数,来匹配数据库的当前状态。使用python manage.py shell命令打开终端,执行以下命令:

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

现在每张图片被喜欢的总数量已经更新了。

6.4 用Redis存储项视图

Redis是一个高级的键值对数据库,允许你存储不同类型的数据,并且能进行非常快速的I/O操作。Redis在内存中存储所有数据,但数据集可以一次性持久化到硬盘中,或者添加每条命令到日志中。与其它键值对存储相比,Redis更通用:它提供了一组功能强大的命令,并支持各种各样的数据结构,比如stringshasheslistssetsordered sets,甚至bitmapsHyperLogLogs

SQL最适合于模式定义的持久化数据存储,而当处理快速变化的数据,短暂的存储,或者快速缓存时,Redis有更多的优势。让我们看看如何使用Redis为我们的项目添加新功能。

6.4.1 安装Redis

这里下载最新的Redis版本。解压tar.gz文件,进入redis目录,使用make命令编译Redis:

cd redis-3.2.8
make

安装完成后,使用以下命令初始化Redis服务器:

src/redis-server

你会看到结尾的输出为:

19608:M 08 May 17:04:38.217 # Server started, Redis version 3.2.8
19608:M 08 May 17:04:38.217 * The server is now ready to accept connections on port 6379

默认情况下,Redis在6379端口运行,但你可以使用--port之指定自定义端口,比如:redis-server --port 6655。服务器就绪后,使用以下命令在另一个终端打开Redis客户端:

src/redis-cli

你会看到Redis客户端终端:

127.0.0.1:6379>

你可以直接在Redis客户端执行Redis命令。让我们尝试一下。在Redis终端输入SET命令,在键中存储一个值:

127.0.0.1:6379> SET name "Peter"
OK

以上命令在Redis数据库中创建了一个字符串值为Petername键。输出OK表示键已经成功保存。接收,使用GET命令查询值:

127.0.0.1:6379> GET name
"Peter"

我们也可以使用EXISTS命令检查一个叫键是否存在。如果存在返回1,否则返回0

127.0.0.1:6379> EXISTS name
(integer) 1

你可以使用EXPIRE命令为键设置过期时间,这个命令允许你设置键的存活秒数。另一个选项是使用EXPIREAT命令,它接收一个Unix时间戳。把Redis作为缓存,或者存储临时数据时,键过期非常有用:

127.0.0.1:6379> EXPIRE name 2
(integer) 1

等待2秒,再次获取同样的键:

127.0.0.1:6379> GET name
(nil)

返回值(nil)是一个空返回,表示没有找到键。你也可以使用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为每种数据类型(比如stringshasheslistssetsordered sets等等)提供了大量的命令。你可以在这里查看所有Redis命令,在这里查看所有Redis数据类型。

6.4.2 在Python中使用Redis

我们需要为Redis绑定Python。通过pip安装redis-py

pip install redis

你可以在这里查看redis-py的文档。

redis-py提供了两个类用于与Redis交互:StricRedisRedis。两个类提供了相同的功能。StricRedis类视图遵守官方Redis命令语法。Redis类继承自StricRedis,覆写了一些方法,提供向后的兼容性。我们将使用StrictRedis类,因为它遵循Redis命令语法。打开Python终端,执行以下命令:

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

这段代码创建了一个Redis数据连接。在Redis中,数据由整数索引区分,而不是数据库名。默认情况下,客户端连接到数据库0。Redis数据库有效的数字到16,但你可以在redis.conf文件中修改这个值。

现在使用Python终端设置一个键:

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

命令返回True表示键创建成功。现在你可以使用get()命令查询键:

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

正如你锁看到的,StrictRedis方法遵循Redis命令语法。

让我们在项目中集成Redis。编辑bookmarks项目的settings.py文件,添加以下设置:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0

以上设置了Redis服务器和我们在项目中使用的数据库。

6.4.3 在Redis中存储项的浏览次数

让我们存储一张图片被查看的总次数。如果我们使用Django ORM,则每次显示图片后,都会涉及UPDATE语句。如果使用Redis,我们只需要增加内存中的计数,从而获得更好的性能。

编辑images应用的views.py文件,添加以下代码:

import redis
from django.conf import settings

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

我们建立了Redis连接,以便在视图中使用。修改image_detail视图,如下所示:

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # increament total image views by 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。incr()方法返回执行操作之后键的值,我们把它存在total_views变量中。我们用object-type:id:field(比如image:33:id:views)构建Redis键。

Redis键的命名惯例是使用冒号分割,来创建带命名空间的键。这样键名会很详细,并且相关的键共享部分相同的模式。

编辑image/detail.html模板,在<span class="count">元素之后添加以下代码:

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

现在在浏览器中打开图片详情页面,加载多次。你会看到每次执行视图,显示的浏览总数都会加1,如下图所示:

你已经成功的在项目集成了Redis,来存储项的浏览次数。

6.4.4 在Redis中存储排名

让我们用Redis构建更多功能。我们将创建浏览次数最多的图片排名。我们将使用Redis的sorted set来构建排名。一个sorted set是一个不重复的字符串集合,每个成员关联一个分数。项通过它们的分数存储。

编辑images应用的views.py文件,修改image_detail视图,如下所示:

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # increament total image views by 1
    total_views = r.incr('image:{}:views'.format(image.id))
    # increament image ranking by 1
    r.zincrby('image_ranking', image.id, 1)
    return render(request, 
                  'images/image/detail.html', 
                  {'section': 'images', 'image': image, 'total_views': total_views})

我们用zincrby()命令在sorted set中存储图片浏览次数,其中键为image_ranking。我们存储图片id,分数1会被加到sorted set中这个元素的总分上。这样就可以全局追踪所有图片的浏览次数,并且有一个按浏览次数排序的sorted set

现在创建一个新视图,用于显示浏览次数最多的图片排名。在views.py文件中添加以下代码:

@login_required
def image_ranking(request):
    # get image ranking dictinary
    image_ranking = r.zrange('image_ranking', 0, -1, desc=True)[:10]
    image_ranking_ids = [int(id) for id in image_ranking]
    # get most viewed images
    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视图。我们用zrange()命令获得sorted set中的元素。这个命令通过最低和最高分指定自定义范围。通过0作为最低,-1作为最高分,我们告诉Redis返回sorted set中的所有元素。我们还指定desc=True,按分数的降序排列返回元素。最后,我们用[:10]切片操作返回分数最高的前10个元素。我们构建了一个返回的图片ID列表,并作为整数列表存在image_ranking_ids变量中。我们迭代这些ID的Image对象,并使用list()函数强制执行查询。强制QuerySet执行很重要,因为之后我们要调用列表的sort()方法(此时我们需要一组对象,而不是一个QuerySet)。我们通过Image对象在图片排名中的索引进行排序。现在我们可以在模板中使用most_viewed列表显示浏览次数最多的前10张图片。

创建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文件,添加以下模式:

url(r'^/ranking/$', views.image_ranking, name='ranking')

在浏览器中打开http://127.0.0.1:8000/images/ranking/,你会看到图片排名,如下图所示:

6.4.5 Redis的后续功能

Redis不是SQL数据库的替代者,而是更适用于特定任务的快速的内存存储。当你真的需要时可以使用它。Redis非常适合以下场景:

  • 计数:正如你所看到的,使用Redis管理计算非常简单。你可以使用incr()incrby()计数。
  • 存储最近的项:你可以使用lpush()rpush()在列表开头或结尾添加项。使用lpop()rpop()移除并返回第一或最后一项。你可以使用ltrim()截断列表长度。
  • 队列:除了pushpop命令,Redis还提供了阻塞队列的命令。
  • 缓存:使用expire()expireat()允许你把Redis当做缓存。你还可以找到Django的第三方Redis缓存后台。
  • 订阅/发布:Redis还为订阅/取消订阅,以及发送消息给频道提供了命令。
  • 排名和排行榜:Redis的sorted set可以很容易创建排行榜。
  • 实时跟踪:Redis的快速I/O非常适合实时场景。

6.5 总结

这一章中,你构建了关注系统和用户活动流。你学习了Django信号是如何工作的,并在项目中集成了Redis。

下一章中,你会学习如何构建一个在线商店。你将创建一个产品目录,并使用会话构建购物车。你还讲学习如何使用Celery启动异步任务。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容