第六章 追踪用户行为

在之前的章节里完成了小书签将外站图片保存至本站的功能,并且实现了通过jQuery发送AJAX请求,让用户可以对图片进行喜欢/不喜欢操作。

这一章将学习如何创建一个用户关注系统和创建用户行为流数据,还将学习Django的信号框架使用和集成Redis数据库到Django中。主要的内容有:

1创建关注系统

所谓关注系统,就是指用户可以关注其他用户,并且可以看到所关注用户的行为。关注关系在用户之间是多对多的关系,一个用户可以关注很多用户,也可以被很多用户关注。

1.1通过中间模型创建多对多关系

在之前的章节中,通过ManyToManyField创建了多对多关系,然后让Django创建了数据表。对于大多数情况,直接使用多对多字段已经足够。在需要为多对多关系存储额外的信息时(比如创建多对多关系的时间字段,描述多对多关系性质的字段),可能需要自定义一个模型作为多对多关系的中间模型。

我们将创建一个中间模型用来建立用户之间的多对多关系,原因是:

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类将用来记录用户关注关系,包含如下字段:

数据库对于外键会自动创建索引,这里还使用了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打了一个猴子补丁,不推荐使用该方法。但是在这里使用主要考虑如下原因:

这里需要在此强调的是,在大部分情况下需要为内置数据模型增加额外数据时,优先通过一对一的方式如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模型,用于存放用户的所有行为记录,模型的字段有这些:

使用这个模型,我们目前只能记录行为的主体和行为动词,即用户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模型包含如下字段:

来看一下如何使用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模型的实例,就可以与整个项目中任何一个模型建立关系。为了建立通用关系,需要如下三个字段:

编辑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模型中:

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()函数避免在一分钟内重复保存相同的动作,并且返回一个布尔值以表示是否成功保存。这个函数的逻辑解释如下:

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提供了一种简便的方法获取相关联的对象,而无需反复查询数据库。

2.6.1使用select_related()

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()传任何参数,会将所有该表外键关联的表格都进行连表操作。最好每次都指定具体要关联的表。

进行连表操作的时候注意避免不需要的额外连表,以减少查询时间。

2.6.2使用prefetch_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中提供了一些信号功能,其中有如下的信号:

这只是部分信号功能,完整的内置信号功能见官方文档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的场景:

总结

这一章里完成了两大任务,一个是用户之间的互相关注系统,一个是用户行为流系统。还学习了使用Django的信号功能,和将Redis集成至Django。

在下一章,我们将开始一个新的项目,创建一个电商网站。将学习创建商品品类,通过session创建购物车,以及使用Celery启动异步任务。