追踪用户行为

在之前的章节里完成了小书签将外站图片保存至本站并且点赞的功能。但是对于社交网站,一个重要的内容就是用户的follow系统即某位用户关注了某位用户。

这一章就要来建立一个用户行为的系统,包括集成Redis数据库。主要的内容有:

  • 通过中间模型建立多对多关系
  • 建立关注系统
  • 建立行为流应用(显示用户最近的行为列表)
  • 优化QuerySet查找
  • 使用signal模块对数据库进行非规范化改造
  • 在Redis中存取内容

建立关注系统

所谓关注系统,就是指用户可以关注其他用户,并且可以看到其他用户的行为(在我们的网站里,就是分享了哪些图片过来)。这里用户的关系就是多对多的关系,一个用户可以关注很多用户,也可以被很多用户关注。

在建立多对多关系之前,我们要来想一想,用户与用户之间建立关系,等于是一个表与其自身建立多对多关系。而之前的多对多关系是两个不同的表建立的。其实没有本质区别,因为多对多关系必须要通过一个第三表才能建立。一个表与自己多对多关系,依然可以建立一个第三方表。

在建立第三表的时候,还有一个很重要的考虑,就是我们要获得额外的数据,一般有关注时间,关注关系等等数据,这里我们就记录一下关注的时间。

建立多对多关系的数据模型

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

这个第三表的核心就是两个外键,都连到用户表上。如果增加一条用户关注,可以这么写:

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

但是这个第三表并不能直接就当做多对多关系使用。还记得在上一章里建立的多对多表吗,Django自动通过多对多字段建立了一张表,那个表里两个外键的组合是unique的。我们自建的表里并没有这个约束。所以还不能当做多对多关系来使用。想要当成多对多表格来使用,除了自行增加约束之外,还可以通过给User表增加一个多对多字段指定使用这个表当做中间表。

字段可以写成:

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

这个多对多字段带了一个参数 through,指定了通过刚建立的Contact类作为多对多的表,无需新建一张。其中的symmetrical参数为False表示如果增加一个A follow B,就不让多对多表里自动增加B follow A。但是由于User是内置表格,这个字段如果要增加,其实最好是增加在Profile表上。但是这里我们采取了使用一个内置方法直接给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方法,这个方法不推荐使用。但是在这里使用主要考虑到:通过这个方法简化了查询,如果定义在profile表上,那就比较累了,要通过User.profile.followers来查。还有一个优点就是不用修改User类。

这里再次强调,一般添加多对多关系,都是通过User的扩展表格来添加,或者通过自定义User表来使用。不需要采用这里的方法。

定义好类和User的多对多字段,就可以进行migrate了。有了多对多关系的数据之后,就要建立视图来展示详细的情况,用户可以看到自己follow了哪些用户,可以决定取消关注。

建立视图与模板

在account应用的views.py里添加如下内容:

@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})

这两个视图无需过多解释,一个展示全部用户,一个展示某个用户的详情。然后给两个视图配urls.py:

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

看到这里想必对后边要做什么事情也知道了,一看到正则解析里出现参数,就要做URL解析然后配置到User类中去。但是User是内置的类,要如何给User类添加.get_absolute_url()方法呢?

答案是内置的就用内置的方法来配置,修改项目的settings.py:

from django.urls import reverse_lazy

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

ABSOLUTE_URL_OVERRIDES这是一个设置,里边是键值对,键是内置模块的类名(ORM数据类),值是一个函数,这个函数默认会绑定在当前类上,所以函数的默认参数就是self也就是当前的类,这个函数就被用作调用.get_absolute_url()时候调用的函数。

这里给auth.user类加上了一个匿名函数,其中调用了reverse_lazy来用当前的用户名来反向解析出URL。

配置好了以后我们先来实验一下,打开命令行模式:

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

获取最新的用户,然后调用该方法,可以看到解析出了地址。

之后就是建立上边的两个模板了,位置都在account/templates/account/users/下:

{#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 %}

list模板中用一个循环列出了视图返回的所有激活的用户,分别显示每个用户的名称,头像缩略图,这里的新方法调用了一个内置的.get_full_name。

由于有了用户列表页了,不要忘记修改base.html,把用户列表页的链接加上来:

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

之后启动网站可以看到显示出了用户列表页面,示例如下:

一点具体用户就报错是因为马上就要编写datail.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 %}

这个页面的逻辑也很直接,由于在用户列表页内点击了具体的某一个用户,因此先把这个用户的具体信息显示出来。然后在关注者的部分,先去拿到所有关注者的总数显示出来。

之后又进入到关注与不关注的做法,就是判断当前用户是否已经关注了该用户,根据关注与否显示按钮为Follow或者Unfollow。

最后又导入了这个用户所有上传的图片。由于我们将AJAX功能的页面单独拿了出来。所以这里直接导入AJAX展示图片的功能。这一点确实做的很妙。

启动服务,可以发现点击用户可以看到该用户的具体内容,以及该用户上传的图片。用户详情页面的示例如下:

使用AJAX添加关注和取消关注的功能

这个功能在编写页面的时候已经想到,与之前的喜欢/取消喜欢图片的功能如出一辙。只需要编写一个AJAX视图用户接受请求,前端一样根据返回的数据进行处理即可。继续修改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'})

这个AJAX视图的逻辑也很简单,先获取用户id和行为,如果是follow行为,就在数据库内创建一条记录,如果是unfollow,就删除数据库中的那条匹配记录。

这里要注意的是,像我们之前采取的给多对多字段通过through指定一个中间模型的方式,通过多对多字段名称直接调用的时候,是无法使用.add(),.remove()这种方法的。所以这里转而直接使用Contact类。

配上urls,注意这一句一定要写在<username>那条正则之前,否则会优先匹配一个用户名叫follow的地址然后走user_detail视图:

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

之后剩下的逻辑要添加JS代码了,之前完成了detail.html的模板编写,里边留了一个A标签显示follow还是unfollow,需要编写代码给这个按钮绑定事件发送AJAX请求:

{#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') {
                let previous_action = $('a.follow').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 %}

这个逻辑和之前的喜欢/不喜欢图片如出一辙,还是比较简单粗暴,最好重新从数据库中取一下followers的总数。但是效果确实很明显。这样就建立好了关注/取关系统,完成了本章的第一个主要任务。

建立通用行为流

很多网站可以给用户提供一个他们关注的人的最近行为,比如你看到你的朋友关注了某某,为某某的图片点赞,发表了某某内容。这些内容都是对用户行为的一种追踪记录和展示。我们通过建立一个新应用来实现这个功能。

启动一个叫 actions 的新应用然后添加到settings.py里,之后开始最先一步也是非常重要的一步就是设计数据模型。针对某个用户的行为该如何设计模型呢,一个总体的思路是将用户的行为分为若干类型,然后建立一个数据库保存用户每次行为的类型和内容。在用户完成这些动作,也就是视图函数进行成功操作的时候,就去修改这个数据库。

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

这个类为用户表建立了一个一对多的关系,用户就是指动作的主体,然后定义了一个动作字段verb用于保存具体的行为,然后就是进行动作的时间。这个模型建立了一个最简单也是最抽象的数据关系:一个用户对应一系列的动作。

使用The contenttypes framework

在之前的社交网站里,我们定义了用户的很多行为。可以说,凡是带有@login_required的每一个视图函数进行的操作,都可以视为该用户的一个行为,比如分享外部图片链接进来 ,保存图片,喜欢/不喜欢某个图片,关注/取关某个用户等。

在继续编写程序之前,需要想一下Action类到底应该存放些什么。目前的Action类里有两个实质性字段,一个是用户,一个是行为。但是好像还不够,因为每一个用户行为实际上可以由三部分构成:行为的主体,行为的客体和动作。初步的想一下,我们只要在Action类里针对每次行为保存这三个内容就可以了。行为的主体和行为的客体,一定都是其他数据表的某一行数据,而动作可以事先归类好。这看上去似乎要修改每一个视图函数,让其在完成功能的同时修改Action类,将自己刚刚完成的行为保存到数据库中去。

通过刚才的分析可以知道,主体一定是用户,就代表这一个外键连到User类,但是客体怎么办呢?关注行为的客体是User类的实例,和图片相关的行为客体是Image类的实例,难道要建立很多列外键链接到不同的数据类里去吗?

还好django提供了一个 django.contrib.contenttypes 模块用于追踪所有在项目里定义的数据模型类,提供了接口和数据模型进行互动。这个模块在默认建立项目的时候已经被添加在 settings.py里,内置的验证模块和管理后台都会使用到它。简单的说,就是用一个字段就能够找到所有的模型,如果再知道那个模型的id,就能够将这个行为对应到任何一个数据表的任何一条记录,也就意味着这个字段可以方便的设置为客体。

django.contrib.contenttypes提供了一个数据模型类叫做 ContentType,这个类的实例的代表了应用里实际的类,在项目里每添加一个数据模型类的时候,ContentType类里就自动增加一个实例。 ContentType类有如下的字段:

  • app_label 数据模型所属的app名称,这个来自模型内的Meta类里的对应属性。
  • model 数据类的名称。
  • name 给人类阅读的名称,这个来自模型内的Meta类的verbose_name属性。

讲了一些抽象的东西,来看一下初步如何使用这个模块,进入命令行模式:

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

这几个操作的意思是先导入了这个ContentType,然后像操作数据库一样,从里边取出了一条符合images应用里叫image的类。这个类是ContentType的实例。而调用.model_class()方法可以看到,取出来的东西就是Image类。

ContentType还可以直接通过具体的类拿对象:

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

这是几个例子,Django关于这个类的官方文档在此

那么这个玩意到底有什么用呢?先停下来想一想,我们的action是一个数据表,ContentType也是一个数据表,里边放着每个数据类。如果我们的action想追踪某个表,可以怎么做呢?应该想到了,就是到ContentType里去查就行了。一个数据模型在我们的Action类里也可以有多个动作,所以只要在Action类里建立一个外键连接到ContentType就可以了。

再进一步想,如果要知道Action与某一个model里的具体哪一条记录发生关系呢?那就再找一个字段放那个model的id。然后Django还内置了一个字段,就将外键和id联系起来,作为一个管理器。

这样就建立了项目里每一个模型的每一条记录与action里每一条记录的关系。有点抽象,查表的关系可以这么理解:Action表–ContentType表–某一个类表–具体某一行。来看一看修改后的Action类:

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

如前边所述,target_ct是连接到ContentType的一个外键,用于确定对应哪个模型。

target_id由于用来存放主键id,所以采用内置的PositiveIntegerField

最后的target属性就是通过前边两个属性,建立起通用对应关系的特殊字段。

在这里我们都设置了blank 和 null 为 True,表示写入Action表的时候可以不用特意指定与某条数据相关。在实际migrate的时候,只会有target_ct和target_id两个字段会生成数据库内的字段,GenericForeignKey不会生成,其实是通过django来控制的

这里也提供了一个思路,可以用这种通用的方式建立更弹性的关系,而不像外键那样约束比较大。

然后就makemigration和migrate之后将Action类加入管理后台:

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

加入管理后台之后,可以看到Action出现在后台中,点进去然后点击Add,出现如下界面:

这里可以看到,GenericForeignKey并没有出现在表中,只有target_id 和 target_ct出现。点开target_ct可以发现,这个项目使用的所有类都可以作为选项。截至目前我们定义过的类有:

  • account应用中的 Profile, Contact
  • actions应用中的 Action
  • images应用中的 Image

这些类在这里全部都有,还包括了外置的类。

然后在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()

这里需要理解的是:这个函数的target参数,就是具体的数据表某一行,将其保存在Action的target字段里。target字段是不存在于实际的数据库中的,而是django整合两个字段target_id和target_ct之后生成的Action类的字段,用来管理实际对应的某个数据类的实例(某行数据)。所以三个参数里user很简单,就是一个User实例,verb后边会看到,是预定义好的字符串,而target属性,就是某一个数据类的实例。

避免重复添加相同的行为

有些时候,用户可能在短期内连续点击同一类型的事件,比如取消又关注,关注再取消,如果把这些都保存好,那会造成大量重复的数据。为了避免这个,需要修改一下刚刚建立的utils.py:

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


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

这个函数的逻辑是先算出来一分钟之前是什么时候,然后拿着给的用户和动作去找之前一分钟内相似的行为。如果传入了对象类,就去拿到那个类,然后从相似的行为里,再去找和这个类相关联的行为。如果没有传入target,就不执行额外动作,之后判断是否存在相似行为,如果有就不保存然后返回False,如果没有就保存然后返回True。

这里还一个知识点就是通过外键到user表里查id,字段名是user,要写成user_id。

添加用户行为

用户行为之前说了后很多类型,比如用户传入一个图片,用户点赞一个图片,用户建立一个账户,用户关注另外一个用户。很显然按照我们之前的分析,还是得需要完成动作的视图函数来操作增加这个活动。以图片相关的视图为例,修改images应用里的views.py里的image_create视图:

from actions.utils import create_action
# 在保存图片的语句之后添加
    ...
    new_item.save()
    create_action(request.user, 'bookmarked image', new_item)

这里联系上下文看,user就是当前进行操作的用户,第二个参数是行为,就是保存了图片。最后一个是参数是Image类的一个实例。这样就在action里保存了一条记录。

在image_like视图里,在完成了操作之后也添加一行:

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

然后到account应用里的views.py里也导入create_action,之后在register视图里添加:

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

在user_follow视图里添加:

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

我们一共添加了四个行为,分别是

  • 1 用户上传了一个图片
  • 2 用户点赞一个图片
  • 3 新注册一个用户
  • 4 用户新关注一个用户

其中1,2,4都是有行为的客体存在,所以调用create_action方法传入了三个参数,分别是当前的用户,描述动作的字符串,和动作的客体。3是单独的主体行为,就没有传入客体。

修改展示用户行为所用的视图

前边说了这么多业务逻辑,其实就是一句话,通过ContentType类,可以取到整个项目里任意一个数据库的任意一行。利用这个功能,我们就做出来了Action表与具体某一行关联。

剩下的问题就是展示页面了,为此先要修改account应用里的dashboaard视图,空白了这么久的dahsboard视图函数终于要发挥作用了:

@login_required
def dashboard(request):
    # 展示用户行为
    # 不取当前用户相关的字段
    actions = Action.objects.exclude(user=request.user)
    # 列出当前用户所有关注的用户的ID
    following_ids = request.user.following.value_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})

这里要解释的就是通过连接到User表的外键字段名user,再去查id,要写成user_id,通过这个查询将属于当前用户关注的用户们的行为都查询出来。

提高QuerySet查询效率

下一步不是要建立模板了吗?别着急,这里作者估计是有意考验大家的耐心,前边业务逻辑讲了这么多,就是不给大家展示。当然真正的原因还是现在对于ORM的操作要求越来越高,还是有必要讲一下深入的操作。

现在的dashboard视图中,由于在页面里还需要展示用户的头像等存在Profile类中的数据,因此针对每个用户还需要再用外键查询,如果转换成SQL语句,会发现每一次通过外键查询都会去进行一次SQL查询。但实际上在学习数据库的时候大家学习过使用INNERJOIN来连表查询,这样会生成一张虚拟表,通过这个表再查,就会速度快很多。

如果想在ORM中使用连表查询提高效率,避免反复查询数据库,就需要两个特别的方法:select_related()prefetch_related()

select_related()

这个方法用于一对一一对多中一的那一方查询,其本质就是数据库的连表查询。SQL语句中需要指定查哪张表和连哪张表,在这个方法中也是一样,通过实际例子来看一下:

    # 修改dashboard视图中的 actions = actions[:10]为:
    actions = actions.select_related('user', 'user__profile')[:10]

select_related()的第一个参数是外键字段名称,这里就是Action表的user字段,表示通过这个外键字段去连接。第二个参数表示连哪个表,这里要传入一个具体的外键字段对应的外键字段(这里是因为Action表没有直接关联到Profile表,所以无法使用related_name。user名就相当于通过user外键找到了User表,后边加的__profile表示在User表里再去找profile字段对应的Profile类。),就传入了user__profile。如果有连续多个外键,可以一直这样加下去。

如果单独调用select_related(),不传入任何参数,则结果就是一个将Action里的所有外键都进行连表操作的一个大表,有的时候会查询非常慢,所以尽量还是指定参数比较好。

prefetch_related()

这个方法是专门用于多对多多对一中多的那一方进行连表查询。而且还支持通过GenericRelationGenericForeignKey字段进行查询。

    # 修改dashboard视图中刚刚修改的最后一行:
    actions = actions.select_related('user', 'user__profile').prefetch_related('target')[:10]

其中的参数是GenericForeignKey target,表示通过这个键去进行多对多查询。

在这样执行了查询之后,目前的actions查询结果集,用数据库的说法来说的话,是actions里的所有只属于当前用户关注的用户的记录,再连上Profile表和所有的关联模型的具体数据行的表。

这个表里的字段,实际上除了action表里的字段之外,还可以使用profile表的字段,以及具体的对应的字段。

有了这样一个内容丰富的查询结果集之后,就可以来展示用户行为了。

建立模板

现在来建立展示用户行为的页面,在actions应用下建立templates/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 %}

这块东西的核心逻辑就是展示主体,动作和客体,对于主体和客体判断了一下是否存在图,有图就显示图。对于客体判断了一下是否存在,不存在就不展示。

之后修改一下account应用里的dashboard.html,把这个页面包含到content块的底部:

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

之后就可以启动服务到用户dashboard来看了:

最简明的回顾一下这个功能:通过ContentType和GenericForeignKey,实现了将Action类与所有的类的任意一行关联的功能。让视图函数在动作成功的时候在Aciton里保存一条记录,就可以展示出用户行为。完成了本章的第二个大任务。

使用Signal 非规范化数据

所谓非规范化(Denormalization)是一种数据库方面的名词,与之对应的是规范化。

规范化指的是在建立关系型数据库的时候,合理拆分关系,消除存储冗余。而非规范化就是反其道而行之,添加冗余数据,让数据变得更加易于查询。

在日常应用的很多场景来说,可能需要非规范化数据,添加冗余字段,让数据更易于查询,也减少设计数据库的时间。

在之前的项目中,我们都尽量使用了规范化的设计手段,从模型建立到视图编写大家可以看到,所有的字段都通过用户操作和后端视图得到了操作,没有添加什么不需要的冗余字段。

当然,与之对应的就是ORM查询会比较复杂,尤其在用户行为这一章,还采用了整合外键字段的方式和多表连接的方式进行操作数据库。

如果能有一些额外的字段,会加速数据库的查找。但是增加额外字段的最大问题就是,这些字段在目前的视图函数里没有得到操作,就算我们手工添加了,以后数据变更的时候,很可能就失效了。所以非规范化的最大问题就是如何保持数据的时效性。

这一小节里就来看看如何使用Django 的signal 功能,来对数据库进行非规范化修改,以加速查询。

使用Signal

Django有一个信号模块,专门在某个事件发生的时候,产生信号通知其他程序。django为ORM类提供了一个信号功能的类,位于django.db.models.signals,有这么几个方法:

  • pre_savepost_save,在调用save()方法之前和之后发送信号
  • pre_deletepost_delete,在调用delete()方法之前和之后发送信号
  • m2m_changed 在 多对多字段发生变动的时候发送信号

有关singal的官方文档

举个例子来看如何使用signal:

如果在图片列表页,想给图片按照受欢迎的程度排序,我们的做法一般是使用聚合函数,对喜欢该图片的用户合计总数,然后按照总数分开排列。这一个操作在数据库里需要先连表,再分组计数,生成中间表后再排序后返回具体对象,开销很大。写出的代码大概是这样:

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

如果咱们的图片类能有一个整型字段,里边就写着喜欢这个图片的人数,那查询只需将当前表排个序立刻就出来结果了,无需查其他表,效率肯定大幅提升。这个想法,就是要对数据库进行非规范化操作。

那么接下来的问题就变成了,如何让这个字段在用户进行喜欢/不喜欢操作的时候能够自动更新呢?

先到images应用的models.py里修改Image类增加一个字段total_likes:

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

然后makemigration和migrate一下。

添加了这个默认为0的正整形字段以后,就要想办法来更新它。之前说过。signal可以发信号,逻辑就变成其实只要在用户喜欢成功之后将这个字段加1,不喜欢之后减1就行了。User和Image类之间是多对多的关系,下边就来实现这个逻辑:

在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()

这段代码的意思是:设置了一个发送信号的事件,是Image.users_like.through的变化。变化的时候发送m2m_changed信号。被装饰成这个信号的接收器的函数是自定义的函数,每次接收信号的时候,就去更新total_likes字段。

除了装饰器之外,还有一种注册接收器的方式是使用signals.connect()。不再详述。

DJango的信号是同步阻塞的,不要异步发送信号,会造成错乱。想要实现异步,最好是在信号接收器的那一端用异步。

有了发送器和接收器之后,剩下的事情就是要在合适的时候执行。编写好signals.py之后,要让其在应用中发挥作用,肯定需要在某个地方运行signals.py才行。对于这种在应用一开始就要发挥作用的功能,必须要在应用配置类内注册该功能。

使用应用配置类

还记得每次增加一个应用,都要到settings.py里增加appname.apps.AppConfig吗?

在每次新建一个应用的时候,django会在应用目录里新增一个apps.py,其中可以编写一个继承Appconfig类的设置类,这个类就是当前应用的应用配置类。新创建的应用的应用配置类只有一个属性就是name。

使用应用配置类可以存储这个应用的元数据,初始化配置和自省功能。应用配置类的官方文档在此

由于我们的信号收发功能是应用一旦运行就要开始的功能,所以要注册在应用配置类中。修改apps.py

from django.apps import AppConfig


class ImagesConfig(AppConfig):
    name = 'images'

    def ready(self):
        import images.signals

之后这个功能就被添加到了应用配置类中,启动应用的时候就会执行signals.py里的内容。应用配置类的name表示该app的名称,可以用来在项目中进行索引。还可以配置verbose_name用来命名给人读的名称。

启动程序,到管理后台admin/images/image/XX/change/看一下(XX为某个图片的ID),可以看到新增的total_likes字段,在其上边的users_like字段中随便选择用户,点击save以后再到这个页面来,注意看total_likes字段的变化:

现在新增的total_likes字段正常工作了。回到文章开头的查询需求,现在就通过这个字段就简单搞定了:

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

还有一点要注意的是,对于已经存在表内的数据,total_likes字段中还没有任何数据,如果排序会报错,处理方法也很简单,只需要在命令行里循环处理一下Image类中的所有对象即可:

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

这样就添加了可以随时更新的冗余数据行,用于快速按照数量排序。注意,这并不是说今后都可以按此操作,ORM的高级使用技巧还是需要牢牢掌握。设计数据库的时候也尽可能按照规范化设计,以此来锻炼解耦能力。

使用Redis数据库

Redis数据库的内容不再赘述了。优点可以看这里。虽然大量存储结构化数据显然还是需要大型数据库,但Redis对于反复访问而且查询简单的数据效率非常高,或者作为数据缓存辅助大型数据库使用。

这一节就编写一个功能在我们的站点里使用Redis数据库。

Redis数据库的安装和基础使用

同PostgreSQL一样,Redis也是开源的,其官方网站是https://redis.io/,在其官网下载目前的稳定版4.0.11。注意,官网的Redis只能在Linux下使用,在这里可以找到windows 和 Linux 下使用Redis的教程。

这里介绍windows下的使用。安装好Redis之后会默认添加Redis服务,之后在cmd里启动redis-cli进入数据库命令行界面,默认端口号是6379,可以测试一下Redis的基本命令 :

# 存入一个键name,值是字符串Peter
127.0.0.1:6379> SET name "Peter"
OK

# 取键name的值,如果不存在会返回(nil),类似于python里的None
127.0.0.1:6379> GET name
"Peter"

# 检测某个键是否存在,返回整数1或0,1表示True,0表示False
127.0.0.1:6379> EXISTS name
(integer) 1

# 设置一个键值对的过期秒数,成功返回1,失败返回0。还可以使用
127.0.0.1:6379> EXPIRE name 3
(integer) 1

# 设置过期时间,采用UNIX时间戳
127.0.0.1:6379> EXPIREAT name 3
(integer) 1

# 删除键值对
127.0.0.1:6379> DEL name
(integer) 1

Redis的命令看这里,支持的数据格式看这里

Python和Django中操作Redis

同PostgreSQL一样,想让Django操作该数据库,Python必须安装支持该数据库的模块redis-py:

pip install redis==2.10.6

该模块的文档可以在这里找到。

redis-py提供了两个大功能模块,StrictRedis和Redis,功能完全一样。区别是前者只支持标准的Redis命令和语法,后者进行了一些扩展。由于不是深入研究Redis,我们就使用StrictRedis模块。

使用Python命令行来试验:

>>> import redis

    # 用本机地址和端口实例化数据库连接对象
>>> r = redis.StrictRedis(host='localhost', port=6379, db=0)

    # 新建键值对
>>> r.set('foo', 'bar')
True

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

知道了Python 中如何使用Redis,就把Redis集成到Django中来。编辑settings.py:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0

在Redis中进行存储

这里我们需要通过Redis存储一个图片总共被浏览的次数。如果我们使用Django ORM,对于一个访问量大的站点来说,Image类上的某行数据会被反复的查询,其中的计数字段会被频繁的update。

对于这种情况,最好的方式就是采用像Redis这样的存储方式,在内存里存一个整数,然后频繁的通过视图去+1就行了。在images应用的views.py里增加如下内容:

import redis
from django.conf import settings

# 连接redis数据库
r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB)

# 修改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))
    return render(request, 'images/image/detail.html',
                  {'section': 'images', 'image': image, 'total_views': total_views})

这里需要解释的是incr方法,表示将该键对应的值增加1。键的名称则通过图片id自动生成。

既然给模板传了一个新参数也就是redis对象,修改images/image/detail.html,在<span class="count">之后追加:

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

之后打开一个图片的详情页面,然后按F5刷新,能够看到访问数“ * views”不断上升。

用Redis保存一个排名

现在再用Redis来实现一个更复杂一些的功能,就是将图片按照访问量排名。

回想一下上一个功能,每一个图片,在Redis里都保存了image:xx:views这样一个名称的键值对。对于这个需求,初步的想法可能是将所有图片遍历一下,然后拿到访问量,再进行排名。

这么做固然可以,但是这里可以依赖Redis 的sorted sets 有序集合。所谓有序集合,就是一个不重复的键的集合,每个键对应一个值,键是按照值的顺序排列的。

在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方法,向Redis中存了一个image_ranking键,这个键对应的是一系列的键值对,将其中的名为image.id的键对应的值增加1。

有了有序集合之后,我们来建立一个视图专门显示图片排名,就在当前的views.py里继续编写:

# 显示图片排名的视图函数
@login_required
def image_ranking(request):
    # 获得最高10个图片排名的ids
    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})

这里要解释的有:

  • zrange方法用于从有序集合中取元素,后边的两个参数表示开始和结束的范围,给出0到-1的范围表示取全部元素,后边的desc=True表示将这些元素降序排列。之后切了这个列表的前10个。
  • 之后用列表生成式,取得了键名对应的整数构成的列表。由于之前生成有序集合时候的键名就是image.id,这里就得到了访问最多的10个图片的id组成的列表。
  • 然后从Image类中拿到了前10个id组成的结果集,然后列表化。
  • 调用列表的.sort方法,自定义排序函数返回每个图片的id在id列表中的索引,也就是按照image_ranking_ids的顺序排序。

之后要建立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 %}

页面很简单,不赘述,之后配置好images应用的URL:

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

注意,原书这里有误,name的值设置成了create,按作者的一贯写法,应该为’ranking’

之后启动站点,images/ranking 下即可看到排名页面:

Redis的使用展望

Redis无法替代SQL数据库,而是扩展了站点后台所使用的数据功能。在如下需求出现的时候,可以考虑使用Redis:

  • 计数:使用Redis非常方便和快速,使用incr和incrby方法即可
  • 存储最新的项目:lpush()和rpush()从字面意思就能看出来是从开始和末尾追加东西,lpop()和rpop()则是从开始和末尾弹出元素。如果造成数量有变化,可以用ltrim()保持队列数量
  • 队列:除了上边的pop和push方法,Redis还提供了阻塞队列的方法
  • 缓存:expire和expireat让用户可以把Redis当做缓存来使用
  • 订阅/发布:频繁的向频道发布消息,这些消息就可以用Redis存储
  • 排名和排行榜:动态和实时更新的排名类应用,为了避免反复大量查询数据库,可以将这些功能的数据建立在Redis中
  • 实时跟踪:比如社交网站中一些大V的实时动态,可以用Redis进行存放,方便大量关注大V的人以较快速度获取内容。

总结

这一章到现在,其实主要是两大任务,一个是用户之间的互相关注系统,一个是追踪所有用户行为的系统。这一章给人的最大启发其实还是Web开发虽然用户交互等前端很重要,但对于后端来说,最核心的还是数据结构的处理。

要实现一个功能,如果设计合理的数据结构,然后数据结构落实到数据库上,是编写视图等业务逻辑的基础。这个能力还得在今后不断锻炼。

总结一下本章的要点:

  • 多对多关系字段的建立和使用
  • 与上一章类似,采用AJAX发送小功能的请求
  • 使用ContentType类操作项目中所有的数据类
  • GenericForeignKey的使用
  • select_related() 和 prefetch_related()的使用
  • 使用应用配置类
  • 用signal更新非规范化字段(而不是通过视图)
  • Redis数据库的安装和使用
  • 通过Redis应对大量的简单重复查询