Django学习实战篇六(适合略有基础的新手小白学习)(从0开发项目)

前言:

上一章中,我们完成了页面样式的配置,让之前简陋的页面变得漂亮了些。

整理一下目前已经完成的系统,从界面上看,已经完成了以下页面:

  • 首页
  • 分类列表页
  • 标签列表页口博文详情页

这离我们的需求还有些距离,还差几个页面:

  • 搜索结果页
  • 作者列表页
  • 侧边栏的热门文章
  • 文章访问统计
  • 友情链接页面
  • 评论模块

在这一节中,我们来完善剩下的页面。有了前面的基础结构,增加新的页面十分简单。Let's do it!

第六章:完成整个博客系统

6.1 增加搜索和作者过滤

按照惯例,在看到新的需求时(我们可以把这个假想为产品经理新抛过来的需求),首先要明确需求的本质是什么,以及这个需求跟之前实现的功能有何关联。

这一节中,我们需要做的是两个需求:根据关键词搜索文章和展示指定作者的文章列表。这两个需求跟之前已经完成的首页、分类列表页和标签列表页属于同一类页面,它们做的事情都是根据某种条件过滤文章。

基于之前已经写好的class-basedview,很容易就可以完成这一需求。

6.1.1 增加搜索功能

我们先来看搜索功能,根据关键字搜索对应文章。我们在最早的需求分析中已经提到了,搜索需求是一个模糊的需求,那么更明确的需求应该根据哪些数据来搜索,是标题、内容,还是分类?

这里可以根据title和desc(标题和摘要)搜索,这样的话需要怎么做呢?

其实现很简单,依然需要继承IndexView。根据我们的分析,只需控制好数据源就行了。而在IndexView中,控制数据源的部分由get_queryset 方法实现。

因此,我们在blog/views.py中新增如下代码:

python 复制代码
# blog/views.py
from django.db.models import Q #这一句放到文件的第一行

# 省略其他代码
class SearchView(IndexView):
    def get_context_data(self):
        context = super().get_context_data()
        context.update({
            'keyword': self.request.GET.get('keyword', '')
        })
        return context

    def get_queryset(self):
        queryset = super().get_queryset()
        keyword = self.request.GET.get('keyword')
        if not keyword:
            return queryset
        return queryset.filter(Q(title__icontains=keyword) | Q(desc__icontains=keyword))
        

其主要逻辑是重写数据源,但是对于搜索来说,我们还需要将用户输入的关键词展示在输入框中。

在上面的代码中,我们引入了新的内容Q,这是Django 提供的条件表达式(conditional-expression),用来完成复杂的操作。这在前面的Model部分也介绍过,这里不做过多解释。我们只需要知道,通过Q表达式实现了类似这样的SQL语句:SELECT * FROM post WHERE title LIKE '%<keyword>%' or desc ILIKE '%<keyword>%'

可以看到,上面的代码跟之前写的没有太大区别,只需要控制数据源,控制context 的内容就能完成类似需求。

**   接着配置urls.py,在url中引人Searchview,然后将下面代码增加到urlpatterns 配置中。**

python 复制代码
    path('search/', SearchView.as_view(), name='search'),

接下来,需要做的就是修改搜索部分的模板。在上一章中,我们只关注样式,并未关注搜索功能部分。现在修改搜索部分的模板,只需要把base.html中nav中的form部分(原来代码如下:)

html 复制代码
<!-- base.html -->
<form class="form-inline my-2 my-lg-0" method='GET'>
    <input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
	<button class="btn btn-outline-success" type="submit">搜索</button>
</form>

修改为:

html 复制代码
<form class="form-inline" action='/search/' method='GET'>
	<input class="form-control" type="search" name="keyword" placeholder="对文章标题或摘要进行搜索"
          aria-label="Search" value="{{ keyword }}">
	<button class="btn btn-outline-success" type="submit">搜索</button>
</form>

form 的作用是提交数据到服务端。action 用来指定提交数据到哪个URL上,这既可以是相对路径,也可以是绝对路径。methoa用来指定以哪种方法发数据,是GET还是POST。这里可以对比一下之前介绍的class-based view的处理逻辑。

在提交数据时,form中input 标签的内容会被发送到服务端。类型为submit的标签是用来完成数据提交的按钮,它可以是input 标签或者是button标签。

6.1.2 增加作者页面

有了上面的逻辑,作者页面的处理就更加容易了。你其实可以不看这部分而自行完成需求。在blog/views.py中增加下面的代码:

python 复制代码
# blog/views.py
# 省略其他代码
class AuthorView(IndexView):
    def get_queryset(self):
        queryset = super().get_queryset()
        author_id = self.kwargs.get('owner_id')
        return queryset.filter(owner_id=author_id)

相对于搜索来说,只需控制数据源。如果需要调整展示的逻辑,可以通过重写get_context_data 来完成。

接下来,还要修改urls.py,引入新增加的AuthorView,然后在urlpatterns中增加新的规则:

python 复制代码
    path('author/<int:owner_id>', AuthorView.as_view(), name='author'),

这么配置完成后,重新启动程序,看看最终结果。

6.1.3 总结

到此,我们就完成了搜索页面和作者页面。不过上一章还有些遗留内容,那就是模板中需要渲染为作者链接的部分,前面并未做处理,你可以自行处理。

6.2 增加友链页面

上一节的篇幅很短,这主要是因为基于已经完成的代码,只需要直接复用已有的代码就可以实现大部分功能。在日常开发中也是如此,如果之前的基础结构设计合理,后续开发会非常容易,反之,开发每一个新功能都会很"痛苦"。

这一节中,我们需要做一个独立的功能,跟文章没关系,是用来展示友情链接的。在博客世界中,各博主相互交换友链是一种很常见的方式。通过这种方式,我们可以结识很多朋友。你可以把这个理解为现在相互加QQ或者微信。友链的另外一个作用是可以帮各位博主把自己的博客都串联起来,而避免成为"网络孤岛"。

前面已经把Model写好了,后台录入内容的部分是可以用的,这里只需要把数据拿出来展示即可。处理逻辑跟之前一样,我们只需要继承ListView即可,但要基于同一套模板,因此共用的数据还是需要的,所以也要同时继承CommonViewMixin。

**   config/views.py 中的代码如下:**

python 复制代码
# config/views.py
from django.views.generic import ListView

from blog.views import CommonViewMixin
from .models import Link


class LinkListView(CommonViewMixin, ListView):
    queryset = Link.objects.filter(status=Link.STATUS_NORMAL)
    template_name = 'config/links.html'
    context_object_name = 'link_list'
    

接着修改 urls.py:

python 复制代码
urlpatterns =[
#省略其他代码
    path('links/', LinkListView.as_view(), name='links'),
]

**   然后新增模板config/links.html:**

html 复制代码
{% extends "blog/base.html" %}
{% block title %}友情链接{% endblock %}

{% block main %}
<table class="table">
<thead>
<tr>
    <th scope="col">#</th>
    <th scope="col">名称</th>
    <th scope="col">网址</th>
</tr>
</thead>
<tbody>
    {% for link in link_list %}
        <tr>
            <th scope="row">{{ forloop.counter }}</th>
          <td>{{ link.title }}</td>
          <td><a href="{{ link.href }}">{{ link.href }}</a></td>
        </tr>
    {% endfor %}
</tbody>
</table>
{% endblock %}

模板同样继承自blog/base.html,这可以保证整体风格一致。在友情链接模板中,我们通过表格的样式来展示友链。

写完这些代码之后,友情链接的展示页面就出来了。此外,还需要做的是提供一个友情链接申请页面,这个我们放到后面的评论中来做。

6.3 增加评论模块

评论是网站重要的功能之一,这一节中我们就来添加该功能。

如果把博客(网站)比作一个装在盒子里的系统,我们之前完成的admin后台就是用来给系统输入数据的,这些数据是内部数据。而最近这几章在做的事情其实是输出(展示)内部数据。评论则是提供给用户的输入接口,让用户能够把数据输入到系统中。

6.3.1 评论提交的方式

从我的经验来说,网站评论的实现方式有以下几种:

  • JavaScript异步提交数据
  • 当页提交
  • 单独页面提交

后面两个其实是一类,只是在不同的页面完成数据提交而已,差别在于是否有独立的URL和View。

第一种是比较流行的方式,基于前端的交互完成评论的提交。这样就可以在不刷新页面的情况下提交数据并展示数据,避免了无效的页面请求。如果是大型网站,建议采用这种方式。

这几种方法在后端实现上没有本质的区别。就像前面我说到网站的输出格式既可以是HTML,也可以是JSON,只是展示形式不同,这里也一样,只是接收到的数据格式不同。

这里我们使用最后一种:单独页面提交。

6.3.2 评论模块通用化改造

在开发评论功能之前,我们需要做一件事,那就是增加评论的范围。上一节中我们说到,可以通过评论来完成友链的申请,这意味着可以在友情链接页面下增加评论内容的展示和提交。

而我们的模型设计是针对Post 对象的,因此需要稍作调整。具体调整逻辑有两种,我们可以对比一下差异,然后选择一个。

第一种方式是把Comment 中的target 改为CharField,里面存放着被评论内容的网址。就像很多其他社交化评论所做的那样,只需要有一个能够唯一标识当前页面地址的标记即可。但这种方式存在的问题是,在admin后台无法处理权限,因为是多用户系统,理论上只有文章的作者才能删除当前文章下的评论。

第二种方式是使用GenericForeignKey,这种方式值得一说。我们在前面知道Model中ForeignKey的作用一一关联两个模型(表),通过名字可以猜测GenericForeignkey 意味着更通用的外键,什么意思呢?通常来说,外键只能针对一个表(模型),但有时我们有针对多个表的需求。比方说,现在有一个Comment 模型,它能关联Post,同时也能关联Link(这里其实也是伪需求,但是不妨碍理解GenericForeignKey的用法)。

怎么做到关联多种模型呢?在解释之前,我们先来思考这个问题,在Django中,通过外键关联Model是怎么关联上的呢?答案是外键字段,比方说Post 模型中的category字段。这个字段是存储在Post上的,存储的内容是Category模型的主键(primary key,简称pk),这样在使用时,就可以通过这个主键找到跟当前Post 关联的category了。

所以,这是通过一个字段来存储指定模型的主键,那么这个模型能不能通过另外一种方式来

指定呢?因为在使用ForeignKey时,所指定的模型就已经固定了。

答案是可以通过增加一个字段content_type 来存储对应的模型类型,这里拿Comment来举例。在Comment 模型中,我们定义了 object_id来存储对应模型(表)的主键值,定义了content_type 来存储当前记录对应的是哪个模型(表)。这样,就可以动态存放数据,存放多种数据了。

用一个图来表示的话,详见下图。

从上图中可以一目了然地看到,如何通过多增加一个字段来实现通用外键。

但是这又新增了一个问题,那就是content_type 里面存放的字符串是由谁来定义并且写入的?总不能每新增一条数据,都要自己写入'link'或者'post'这样的字符串吧。因此,在Django中提供了一个这样的Model一一一 ContentType,用它来实现。如果你注意过settings中INSTALLED_APPS 里面的内容,就会发现存在一个这样的App一'django.contrib.contenttypes',它的作用就是维护Model和我们要用到的content_type 之间的关系。

比方说,在ContentType 表里,Post 模型对应1,Link 模型对应2,那么在Comment中如果要写入一条post id为1的记录,那就是content_type =1,object_id = 1。

到这里,不知道你是否能明白上面的内容。简单来说,就是为了实现通用外键,需要多维护一个字段和一张表(模型)。既然Django 为我们提供了GenericForeignKey 这样的字段,那么肯定是把麻烦的操作都已经封装好了。不过在实际使用中,唯一的问题是,我们需要操作两个模型(表),这多少会对性能有些影响,因此我们往往会想办法自己来实现对应的逻辑。这其实也是基于通用性和特殊性之间的考虑,通用性能够得到更易用的逻辑,但是性能上会有损耗,而特殊性的处理逻辑在性能上会有一些优势,却降低了易用性。

具体的实现也比较清晰,因为实际的业务开发往往是很有针对性的,比如像上面,Comment既可以关联Post,也可以关联Link。因此,我们可以不使用Django提供的方法,毕竟它要做更通用的处理,会带来复杂度,我们只需要在代码中建立Model和对应的content_type 的映射即可。

第二种方式说了这么多,主要是为了解释通用外键这个字段类型,理解它能够帮助你更好地设计某些业务下的模型关系。

那么,选择哪一种呢?上面说的关于Comment 和Link的部分其实是伪需求,因为只需要对友链页面可以评论即可,不需要对每一条友链都进行评论。因此,我们可以采用第一种方法。如果确实需要处理评论部分的权限,我们可以在业务层来处理。简单来说,就是通过target中存储的path来处理来获取文章id,然后判断用户。

6.3.3 实现评论

理解了上面的两种方案以及选择之后,我们来修改模型。需要修改comment/models.py中target 的字段类型:

python 复制代码
# comment/models.py
target = models.CharField(max_length=100, verbose_name='评论目标')

这样里面就可以存放任意字符了,也能兼容更多的场景。

前面我们讲过migrate的作用,这里需要再次使用它(在终端中输入):

python manage.py makemigrations

python manage.py migrate

完成字段修改之后,就可以开发评论功能了。

首先,我们需要在文章页面下方添加一个评论的form,这样用户才能添加评论。那么,这个form怎么处理呢?有两种方式,第一种是我们写原生的HTML代码,但这样无法利用Django的优势。第二是使用Django 的form,并且用它来渲染成HTML。

显然,我们应该使用第二种。下面来梳理一下这种方式的数据流程和所需组件。

下图表示了大体流程,我们再用文字说明具体要做的工作:

  • 展示评论内容和评论框。
  • 用户提交评论后,可以保存评论并且展示结果页。

展示评论显然需要在博文详情页来做,也就是PostDetailView中。不过在此之前,需要先完成Form 层的逻辑,毕竟 View需要操作Form,然后在模板中渲染Form。

我们在comment 这个 App下新建一个文件 forms.py,它用来放置 Form相关的代码。在其中增加评论的Form:

python 复制代码
# comment/forms.py
from django import forms

from .models import Comment


class CommentForm(forms.ModelForm):
    nickname = forms.CharField(
        label='昵称',
        max_length=50,
        widget=forms.widgets.Input(
            attrs={'class': 'form-control', 'style': "width: 60%;"}
        )
    )
    email = forms.CharField(
        label='Email',
        max_length=50,
        widget=forms.widgets.EmailInput(
            attrs={'class': 'form-control', 'style': "width: 60%;"}
        )
    )
    website = forms.CharField(
        label='网站',
        max_length=100,
        widget=forms.widgets.URLInput(
            attrs={'class': 'form-control', 'style': "width: 60%;"}
        )
    )

    content = forms.CharField(
        label="内容",
        max_length=500,
        widget=forms.widgets.Textarea(
            attrs={'rows': 6, 'cols': 60, 'class': 'form-control'}
        )
    )

    def clean_content(self):
        content = self.cleaned_data.get('content')
        if len(content) < 10:
            raise forms.ValidationError('内容长度怎么能这么短呢!!')
        return content

    class Meta:
        model = Comment
        fields = ['nickname', 'email', 'website', 'content']

如果不考虑样式,只需要配置model和fields就行。但是为了样式,我们还要重新定义各字段的组件。自定义部分的内容不难理解,都是样式方面的。另外,我们要在代码中使用clean_content 方法(用来处理对应字段数据的方法)来控制评论的长度,如果内容太少,则直接抛出异常。

Form 定义完成之后,我们需要在Model层提供接口,用来返回某篇文章下的所有有效评论。

**   下面在comment/models.py中的Comment类中增加类方法:**

python 复制代码
# comment/models.py
from django.db import models

from blog.models import Post


class Comment(models.Model):
    STATUS_NORMAL = 1
    STATUS_DELETE = 0
    STATUS_ITEMS = (
        (STATUS_NORMAL, '正常'),
        (STATUS_DELETE, '删除'),
    )
    target = models.CharField(max_length=100, verbose_name='评论目标')
    # target = models.ForeignKey(Post, verbose_name='评论目标', on_delete=models.CASCADE)  # 外键是Post中的id
    aim = models.ForeignKey(Post, verbose_name='评论文章', on_delete=models.CASCADE)
    content = models.CharField(max_length=2000, verbose_name='内容')
    nickname = models.CharField(max_length=50, verbose_name='昵称')
    website = models.URLField(verbose_name='网站')
    email = models.EmailField(verbose_name='邮箱')
    status = models.PositiveIntegerField(choices=STATUS_ITEMS, default=STATUS_NORMAL, verbose_name='状态')
    created_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

    class Meta:
        verbose_name = verbose_name_plural = '评论'
    
    # 新增代码如下!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    @classmethod
    def get_by_target(cls, target):
        return cls.objects.filter(target=target, status=cls.STATUS_NORMAL)

这些都完成之后,素材就准备好了,接下来需要通过 View 层把CommentForm 和评论的数据传递到模板层。我们需要在PostDetailview中重写get_context_data方法:

python 复制代码
# blog/views.py
from comment.form import CommentForm
from comment.models import Comment

# 省略其他代码
class PostDetailView(CommonViewMixin, DetailView):
    queryset = Post.objects.filter(status=Post.STATUS_NORMAL)
    template_name = 'blog/detail.html'
    context_object_name = 'post'
    pk_url_kwarg = 'post_id'
	
	# 新增代码如下!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context.update({
            'comment_form': CommentForm,
            'comment_list': Comment.get_by_target(self.request.path),
        })
        return context
# 省略其他代码        

这样就可以在blog/detail.html 模板中拿到comment_form 和comment_list变量了,我们需要做的就是把它们渲染出来。

对于Form来说,渲染起来很简单,可以直接使用。列表的展示需要多写点代码。

**   我们需要在detail.html模板中增加代码**

html 复制代码
<!-- detail.html -->
 {% extends "blog/base.html" %}
{% block title %} {{ post.title }} {% endblock %}

{% block main %}
    {% if post %}
    <h1>{{ post.title }}</h1>
    <div>
        <span>分类:<a href="{% url 'category-list' post.category_id %}">{{ post.category.name }}</a></span>
        <span>作者:<a href="#">{{ post.owner.username }}</a></span>
        <span>创建时间:{{ post.created_time }}</span>
    </div>
    <hr/>
    <p>
        {{ post.content }}
    </p>
    {% endif %}
<!-- 新增代码如下!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
    <hr/>
    <div class="comment">
        <form class="form-group" action="/comment/" method="POST">
            {% csrf_token %}
            <input name="target" type="hidden" value="{{ request.path }}"/>
            {{ comment_form }}
            <input type="submit" value="写好了!"/>
        </form>

        <!-- 评论列表 -->
        <ul class="list-group">
            {% for comment in comment_list %}
            <li class="list-group-item">
                <div class="nickname">
                    <a href="{{ comment.website }}">{{ comment.nickname }}</a>
                        <span>{{ comment.created_time }}</span>
                </div>
                <div class="comment-content">
                    {{ comment.connect }}
                </div>
            </li>
            {% endfor %}
        </ul>
    </div>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
{% endblock %}

启动项目,然后就能看到评论功能了。不过还不能提交评论。上面form标签中action定义为/comment/,这是一个新的URL。因此我们需要在comment/views.py中对应创建一个新的View。完整代码如下:

python 复制代码
# comment/views.py
from django.shortcuts import redirect
from django.views.generic import TemplateView

from comment.form import CommentForm


class CommentView(TemplateView):
    http_method_names = ['post']
    template_name = 'comment/result.html'

    def post(self, request, *args, **kwargs):
        comment_form = CommentForm(request.POST)  # 创建一个CommentForm实例,并使用POST请求中的数据填充它。
        target = request.POST.get('target')  # 从POST请求中获取target参数的值,这个值可能是一个URL,表示评论提交后要重定向到的页面。

        if comment_form.is_valid():  # 检查表单数据是否有效。
            instance = comment_form.save(commit=False)  # 如果表单有效,保存表单数据到一个模型实例中,但不立即提交到数据库。
            instance.target = target   # 设置模型实例的target属性。
            instance.save()  # 将模型实例保存到数据库。
            succeed = True  # 设置succeed变量为True,表示评论提交成功。
            return redirect(target)  # 重定向到target指定的URL。
        else:
            succeed = False   # 如果表单无效,设置succeed变量为False。

        context = {
            'succeed': succeed,
            'form': comment_form,
            'target': target,
        }
        return self.render_to_response(context)   # 使用context中的数据渲染模板,并返回渲染后的页面。
    

这里直接使用TemplateView来完成,这个View 只提供了POST方法。其逻辑是通过CommentForm 来处理接收的数据,然后验证并保存。最后渲染评论结果页,如果中间有校验失败的部分,也会展示到评论结果页。

接下来,我们需要在typeidea/templates中创建comment目录,在新建好的comment目录下创建result.html文件,然后填入如下代码:

html 复制代码
<!-- result.html -->
<!DOCTYPE HTML>
<html lang="en">
    <head>
        <title>评论结果页 - typeidea</title>
        <style>
        body {TEXT-ALIGN: center;}
        .result {
            text-align: center;
            width: 40%;
            margin: auto;
        }
        .errorlist {color: red;}
        ul li {
            list-style-type: None;
        }
        </style>
    </head>
    <body>
        <div class="result">
            {% if succeed %}
                评论成功!
                <a href="{{ target }}">返回</a>
            {% else %}
                <ul class="errorlist">
                {% for field, message in form.errors.items %}
                    <li>{{ message }}</li>
                {% endfor %}
                </ul>
                <a href="javascript:window.history.back();">返回</a>
            {% endif %}
        </div>
    </body>
</html>

完成之后需要实现最后一步,那就是配置URL。在urlpatterns中增加新的规则:

python 复制代码
from comment.views import CommentView

urlpatterns =[
#省略其他代码
    path('comment/', CommentView.as_view(), name='comment'),
]

配置完成后,可以启动项目,添加一下评论。然后可以考虑改改其中的代码,比如希望评论完成后并不是实时展示的,而需要网站管理员审核通过之后才能展示。

6.3.4 抽象出评论模块组件和Mixin

上面的实现满足了基本功能,但是结构上不太合理,因为我们还需要在blog/views.py中来操作comment的数据。这意味着,如果要在友链页面上增加评论,也得去修改View层的代码。还记得之前说的"开-闭原则"吗?我们需要把评论弄成一个即插即用的组件。

要完成这个需求,就要用到Django的template tag(自定义标签)这部分接口了。可以先说下我们期待的使用方式:在任何需要添加评论的地方,我们只需要使用{% comment_block request.path %}即可。之所以叫comment_block,是因为comment 是Django内置的tag,用来做大块代码的注释。

在开始写代码之前,还是先来看一下Django中的tag。

在前面的模板代码中已经多次用到了,比如说 for 循环和if判断等,这些都是内置的,我们需要自定义tag。

这里就直接使用实际需求来代替演示吧,因为使用起来并不复杂。

第一步需要做的是在comment App下新建templatetags目录,同时在该目录下新增__init__.py和comment_block.py这两个文件。

**   第二步就是在comment_block.py文件中编写自定义标签的代码:**

python 复制代码
# comment_block.py
from django import template

from comment.forms import CommentForm
from comment.models import Comment

register = template.Library()


@register.inclusion_tag('comment/block.html')
def comment_block(target):
    return {
        'target': target,
        'comment_form': CommentForm(),
        'comment_list': Comment.get_by_target(target),
    }

其实现并不复杂,其他类型的方法使用也不复杂。唯一需要注意的是目录结构,这跟静态文件的目录和模板目录一样,Django会进行自动查找,因此需要放到正确的位置。

上面的代码编写完成之后,就可以把PostDetailView 中新增的那个get_context_data去掉了,同时也可以去掉评论相关的引用了。

**   然后再删除blog/detail.html中的评论代码,修改后的的blog/detail.html代码如下:**

html 复制代码
<!-- blog/detail.html -->
{% extends "blog/base.html" %}
{% block title %} {{ post.title }} {% endblock %}

{% block main %}
    {% if post %}
    <h1>{{ post.title }}</h1>
    <div>
        <span>分类:<a href="{% url 'category-list' post.category_id %}">{{ post.category.name }}</a></span>
        <span>作者:<a href="#">{{ post.owner.username }}</a></span>
        <span>创建时间:{{ post.created_time }}</span>
    </div>
    <hr/>
    <p>
        {{ post.content_html }}
    </p>
    {% endif %}
{% endblock %}

接着编写模板,也就是上面用到的comment/block.html,这个模板里面的代码直接从blog/detail.html中剪切粘贴过来即可。唯一需要处理的是target部分,因为是自定义标签,默认是没有request对象的。所以上面手动将target 渲染到了页面中。comment/block.html中的完整代码如下:

html 复制代码
<!-- comment/block.html -->
<hr/>
<div class="comment">
    <form class="form-group" action="/comment/" method="POST">
        {% csrf_token %}
        <input name="target" type="hidden" value="{{ target }}"/>
        {{ comment_form }}
        <input type="submit" value="写好了!"/>
    </form>

    <!-- 评论列表 -->
    <ul class="list-group">
        {% for comment in comment_list %}
        <li class="list-group-item">
            <div class="nickname">
                <a href="{{ comment.website }}">{{ comment.nickname }}</a> <span>{{ comment.created_time }}</span>
            </div>
            <div class="comment-content">
                {{ comment.content }}
            </div>
        </li>
        {% endfor %}
    </ul>
</div>

编写完tag 和模板之后,我们的工作就完成了,现在在文章页面中可增加评论。

因为是自定义的tag,所以需要在config/links.html和blog/detial.html模板的最上面(但是需要在extends下面)增加{% load comment_block %},用来加载我们自定义的标签文件。

然后在需要展示评论的地方增加{% comment_block request.path %}即可。config/links.html代码如下:

html 复制代码
<!-- config/links.html -->
{% extends "blog/base.html" %}
{% load comment_block %}  	<!-- 新增代码!!!!!!!!!!!!!! -->
{% block title %}友情链接{% endblock %}

{% block main %}
<table class="table">
<thead>
<tr>
    <th scope="col">#</th>
    <th scope="col">名称</th>
    <th scope="col">网址</th>
</tr>
</thead>
<tbody>
    {% for link in link_list %}
        <tr>
            <th scope="row">{{ forloop.counter }}</th>
          <td>{{ link.title }}</td>
          <td><a href="{{ link.href }}">{{ link.href }}</a></td>
        </tr>
    {% endfor %}
</tbody>
</table>
{% comment_block request.path %}	<!-- 新增代码!!!!!!!!!!!!!! -->
{% endblock %}

blog/detial.html代码如下:

html 复制代码
<!-- blog/detial.html -->
{% extends "blog/base.html" %}
{% load comment_block %}			<!-- 新增代码!!!!!!!!!!!!!! -->
{% block title %} {{ post.title }} {% endblock %}

{% block main %}
    {% if post %}
    <h1>{{ post.title }}</h1>
    <div>
        <span>分类:<a href="{% url 'category-list' post.category_id %}">{{ post.category.name }}</a></span>
        <span>作者:<a href="#">{{ post.owner.username }}</a></span>
        <span>创建时间:{{ post.created_time }}</span>
    </div>
    <hr/>
    <p>
        {{ post.content_html }}
    </p>
    {% endif %}
{% comment_block request.path %}		<!-- 新增代码!!!!!!!!!!!!!! -->
{% endblock %}

这里我们也可以在友链页面增加评论,它使用的是同样的逻辑。

6.3.5 修改最新评论模板

之前我们写过最新评论的模板,是基于外键关联Post的方式,现在修改为通用的方法。针对某个URL,我们需要修改config/sidebar_comments.html的代码为:

html 复制代码
<!-- config/sidebar_comments.html -->
<ul>
{% for comment in comments %}
<li><a href="{{ comment.target }}">{{ comment.target.title }}</a> | {{ comment.nickname }} : {{ comment.content }}
{% endfor %}
</ul>

6.3.6 总结

到目前为止,我们完成了评论模块的改造,不过只采用了其中一种实现方式。你可以尝试其他实现方式,在实际工作中有些东西是可以通过技术来定,但很多东西还需要考虑场景和效果。

**   页面展示:**

6.3.7 参考资料

Django CSRF 配置:

https://docs.djangoproject.com/zh-hans/4.2/ref/csrf/

Django Form 初始化:

https:/docs.djangoproject.com/zh-hans/4.2/ref/forms/api/#dynamic-initial-values

Django 模板:

https://docs.djangoproject.com/zh-hans/4.2/ref/templates/builtins/#include

6.4 配置Markdown编写文章的支持

如果你是一步一步跟着本书走的话,现在应该得到一个功能基本完整的博客系统了,其中包含一开始说到的大部分功能。

但是体验上还有些差距,比如说我编写文章,没有任何格式可以使用:既没有可视化的编辑器,也没有Markdown这样的格式可供选择。这显然不够友好。在这一节中,我们就来增加对Markdown的处理。

6.4.1 Markdown 第三方库

Markdown的处理主要依赖于Python第三方库。相关库有很多,这里我们选择mistune这个库,其他库用起来类似。

Markdown的格式就不过多介绍了,它现在已经算是比较流行的文档格式了,无论是写文档,还是开源项目都会用到。mistune 这个库的使用非常简单,只需要传入写好的 Markdown 格式文本,就会返回格式化好的HTML代码:

python 复制代码
import mistune
html = mistune.markdown(your_text_string)

当然,在使用之前需要先安装该库:

Bash 复制代码
pip install mistune

6.4.2 评论内容支持

我们先来对评论内容增加Markdown的处理,那么在什么位置处理合适呢?要找到合适的位置,必须了解数据的传递流程。用户提交评论到评论展示的流程如下:

用户填写评论,提交表单→CommentForm 处理表单→验证通过→保存数据到instance→instance.save 方法把数据保存到数据库→用户刷新页面→通过comment_block模板自定义标签获取并展示数据

从这个流程中看,我们发现几个点可以用来对内容的格式进行处理。

  • 在form层保存数据之前,我们对数据进行转换,让保存到数据库中的数据(content)是Markdown处理之后的。
  • 给Comment 模型新增属性content_markdown,这个属性的作用是将原content 内容进行Markdown处理,然后在模板中不使用comment.content 而使用 comment.content_markdown。

显然,对于博客这种读大于写的系统来说,我更倾向于在写数据时进行转换,因为这种业务下大部分只有一次写操作。

所以我们在comment/forms.py中修改clean_content方法,在returncontent 之前增加一句content=mistune.markdown(content)。当然,别忘了在文件最上面加上语句 import mistune。具体代码如下:

python 复制代码
# comment/forms.py
import mistune

class CommentForm(forms.ModelForm):
	# 省略其他代码
    def clean_content(self):
        content = self.cleaned_data.get('content')
        if len(content) < 10:
            raise forms.ValidationError('内容长度怎么能这么短呢!!')
        content = mistune.markdown(content)   
        return content

修改完成之后,建议你启动项目,然后添加评论试试。此时你会遇到新的问题,那就是我们的HTML代码直接展示在页面上了,没有被浏览器渲染。这其实是Django的安全措施。我们需要手动关闭Django模板的自动转码功能。

**   在comment/block.html代码中的{{comment.content }}位置上下增加 autoescape off的处理,完整代码如下:**

html 复制代码
<!-- comment/block.html -->
<hr/>
<div class="comment">
    <form class="form-group" action="/comment/" method="POST">
        {% csrf_token %}
        <input name="target" type="hidden" value="{{ target }}"/>
        {{ comment_form }}
        <input type="submit" value="写好了!"/>
    </form>

    <!-- 评论列表 -->
    <ul class="list-group">
        {% for comment in comment_list %}
        <li class="list-group-item">
            <div class="nickname">
                <a href="{{ comment.website }}">{{ comment.nickname }}</a> <span>{{ comment.created_time }}</span>
            </div>
            <div class="comment-content">
                {% autoescape off %}
                {{ comment.content }}
                {% endautoescape %}
            </div>
        </li>
        {% endfor %}
    </ul>
</div>

此外,侧边栏评论展示模板config/sidebar_comments.html也需要关闭自动转码。

html 复制代码
<!-- config/sidebar_comments.html -->
<ul>
{% for comment in comments %}
<li><a href="{{ comment.target }}">{{ comment.target.title }}</a> |
    {% autoescape off %}
    {{ comment.nickname }} : {{ comment.content }}
    {% endautoescape %}
{% endfor %}
</ul>

6.4.3 文章正文使用Markdown

接着,再来处理文章的内容,其逻辑跟上面一致,我们同样可以在adminform中来处理。但是有一个问题,评论的内容目前没有设置可修改功能。但是文章正文我们可能随时都会修改,如果直接把content 转为HTML格式然后存储,不便于下次修改。因此,我们需要新增一个字段content_html,用来存储Markdown处理之后的内容。而对应地,在模板中我们也需要使用content_html 来替代之前的content。

我们在Post 模型中新增如下字段:

python 复制代码
# blog/models.py
content_html = models.TextField(verbose_name='正文html代码', blank=True, editable=False)

这里之所以配置 editable=False,是因为这个字段不需要人为处理。编写完代码后,需要进行迁移操作:

python manage.py makemigrations

python manage.py migrate

接着需要重写save方法,因为在form中处理这种类型的转换已经不合适了:

python 复制代码
# blog/models.py
class Post (models.Model):
	#省略其他代码(添加如下代码即可)
    def save(self, *args, **kwargs):   # 将content_html中的内容与content同步
        self.content_html = mistune.markdown(self.content)
        super().save(*args, **kwargs)

**   最后,调整模板中展示的部分。修改后blog/detail.html中的代码为:**

html 复制代码
<!-- blog/detail.html -->
{% extends "blog/base.html" %}
{% load comment_block %}
{% block title %} {{ post.title }} {% endblock %}

{% block main %}
    {% if post %}
    <h1>{{ post.title }}</h1>
    <div>
        <span>分类:<a href="{% url 'category-list' post.category_id %}">{{ post.category.name }}</a></span>
        <span>作者:<a href="#">{{ post.owner.username }}</a></span>
        <span>创建时间:{{ post.created_time }}</span>
    </div>
    <hr/>
    <p>
        {% autoescape off %}
        {{ post.content_html }}
        {% endautoescape %}
    </p>
    {% endif %}
{% comment_block request.path %}
{% endblock %}

再次运行,然后在后台新增Markdown 格式的内容。如果不知道Markdown格式,那么可以搜索一下,找篇文章看看。

6.4.4 配置代码高亮

在上面的代码中,我们已经完成了对Markdown文本的处理,但是对于程序员来说,写的内容大部分都会包含代码。默认情况下,Markdown只是帮我们把代码放到了<code>标签中,没做特殊处理,因此需要借助另外的工具。

做代码高亮需要依赖前端的库,这里有几种选择,一个是Google 出的code-prettify,另外一个是highlight.js。当然,还有其他选择,不过这里我们选择highlight.js 来做,code-prettify用起来也大同小异。

在此之前,我们需要先来修改 blog/base.html模板,在上面增加一个新的 block块,用来在子模板中实现特定逻辑。你可以理解为开放一个新的 block 给子模板填充数据用:

html 复制代码
        {% block extra_head %}
        {% endblock %}

**   然后在blog/detail.html的{% block main %}上面新增如下代码:**

html 复制代码
{% block extra_head %}
<link rel="stylesheet" href="https://cdn.bootcss.com/highlight.js/9.12.0/styles/googlecode.min.css">
<script src="https://cdn.bootcss.com/highlight.js/9.12.0/highlight.min.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
{% endblock %}

我们既可以直接使用网上开放的CDN来使用highlight突出显示代码,也可以像Bootstrap那样,把内容下载到本地,通过我们自己的静态文件服务来处理。你可以到highlight官网https://highlightjs.org/download/进行定制下载。

通常情况下,<script>标签应该放到页面底部,这是为了防止浏览器在加载JavaScript时页面内容停止渲染,造成用户等待时间过长。但是碍于文章中的编码需要依赖这些样式,所以需要等待 highlight的资源加载完成。CSS也是一样的逻辑,所以在前端性能优化时,需要根据场景来决定资源的放置位置。

说回我们的代码高亮工作,引人上面资源后就基本完成了。其工作原理是,通过Markdown贴的代码,会被渲染到<pre><code></code></pre>标签中,而highlightjs 或者其他前端代码高亮的库,会提取<code>块中的代码,然后进行分析,进而通过不同的标签进行包装,最后通过CSS展示为不同的颜色。

举个例子来说,比如我们在Markdown中写下:

python 复制代码
## 背景
刚开始学习《Django企业实战开发》,这是第一篇学习记录
## 内容
'''
import Django
print('Hello Django')
'''

我们Markdown处理完之后会变为:

python 复制代码
<h2>背景</h2>
<P>刚开始学习《Django企业实战开发》,这是第一篇学习记录</p>
<h2>内容</h2>
<pre><code>import Django
print('Hello Django')
</code></pre>

为了便于阅读,渲染之后的HTML代码中的\n处理为换行。

接下来,highlight.js处理之后,代码的部分就会变为:

python 复制代码
<pre><code class="hljs coffeescript hljs "<span class="hljs-keyword"><span class=
	"hljs-reserved">import</span></span> Django
	
<span class="hljs-built_in"><span class="hljs-built_in">print</span></span> (<span class="hljs-string"><span
class="hljs-string">'Hello Django'</span></span>)</code></pre>

根据最后的标签,可以看到它的分析逻辑。

6.4.5 总结

到此为止,我们就完成了Markdown内容以及代码高亮的处理。没有太多复杂的逻辑,其核心是理解数据的保存流程,在合适的位置使用Markdown的库进行格式化处理。

接下来,我们来处理文章访问统计。

**   页面展示:**

6.4.6 参考资料

Google 代码高亮前端库的用法:

https:/github.com/google/code-prettify/blob/master/docs/getting_started.md

highlight.js 的用法:

https://highlightjs.org/

6.5 增加访问统计

在这一节中,我们来处理访问统计的业务。这是一个企业项目必须具备的功能,新的产品经过多个组的协作、几个月的开发,最终上线了。那上线之后的效果怎么样?有没有用户访问?访问量多大?用户的访问习惯是什么?这些都是我们需要关心的问题。

因此,---定要把开发完成的东西放出来,让大家能看到,接受用户反馈。从商业产品的角度来说,产品发布上线只是一个起点,后面还有很多事情要做。因此,需要理解的是,对于一个产品的生命周期,开发只是其中一部分。

统计也是一个很大的话题,有很多维度的统计,这里只说文章访问量的统计。我们开发博客,然后将其部署上去。想要知道哪篇文章访问量最高,应该怎么做?

通常来说,有以下几种方式:

  • 基于当次访问后端实时处理。
  • 基于当次访问后端延迟处理------Celery(分布式任务队列)。
  • 前端通过JavaScript埋点或者img标签来统计。
  • 基于Nginx日志分析来统计。

下面挨个来说。第一种方式中,当用户访问文章正文时,会经过我们编写的PostDetailView,因此可以在这里"做手脚"。当用户请求文章时,对当前文章的PV和UV进行+1操作,对应到代码就是:

python 复制代码
# blog/views.py
class PostDetailView(CommonViewMixin, DetailView):
	# 省略其他代码
    
    def get(self, request, *args, **kwargs):
        response = super().get(request, *args, **kwargs)
        Post.objects.filter(pk=self.object.id).update(pv=F('pv') + 1, uv=F('uv') + 1)
        
        from django.db import connection
        print(connection.queries)
        return response
        # 这样写sql语句会较短

这样就完成了用户访向一次就+1的操作。有人可能会问,为什么你不使用这样的代码:

python 复制代码
self.object.pv = self.object.pv + 1
self.object.uv = self.object.uv + 1
self.object.save()
# 这样写sql语句会很长很长

因为在竞争条件下,这种方式会出现很大的误差,整个+1操作不够原子性。因此,采用F这样的表达式来实现数据库层面的+1。你可以把上面的代码运行一次,看看print出来的SQL语句。

这种方式存在一个比较大的问题,那就是每次访问都会造成一次写入操作。写入一些数据库的成本远高于读一次数据,因此写入的耗时会影响页面的响应速度。

于是就引出第二种方式了,通过异步化的方式来处理访问统计。所谓异步化,就是当前需要执行某种操作,但是我自己不执行,让别人帮忙执行,这样我的时间就可以省出来了。异步化的方式有多种选择,Celery 就是其中之一,不过这并不是本书需要涉及的内容。Celery 集成到Django项目中是非常容易的事情,只需要跟着文档一步一步走就行。

第三种方式和第四种方式是大规模系统很常用的统计方法,毕竟每天大量的访问量,不可能在业务代码里面来处理统计逻辑。因此就需要有一个独立的系统来完成一系列统计业务。

第三种方式类似于百度统计这样的系统。通过在你的页面配置JavaScript代码,就可以帮你统计页面的访问量,但是带来的问题是统计数据跟业务相分离。拿我们的博客系统来说,在博客系统中拿不到访问数据,需要调用统计系统的接口才能拿到数据,这也是正常逻辑。在实际环境下会更复杂,业务系统需要拿统计系统的数据做展示,统计系统需要拿业务系统的数据做分析。

第四种方式跟第三种方式很类似,只是这种情况下统计系统可以拿到业务系统的前端Nginx的访问日志(这里的前端是指系统架构上的前端,不是指HTML这些)。其他的流程跟第三种方式没什么差别。只是第三种可以做得更加独立。

了解了这么多统计方式之后,我们来实现最简单的一种------基于当次访问后端实时处理,也是对于小型系统成本最低的一种。

6.5.1 文章访问统计分析

在实现之前,我们还得再说一下第一种方式实现的问题,除了性能问题外,还有被刷的问题。如果有人连续刷页面,不应该累计 PV,因为这种情况是无效访问。另外,对于 UV来说,我们需要根据日期来处理,一个用户每天访问某一篇文章,始终应该只能增加一个UV。不然 UV的统计就没意义了。

那么,问题又来了,怎么区分用户呢?怎么知道用户 A已经访问过某篇文章了呢?你可以思考一下这个问题。面试中很常见的一个问题就是,Web系统是如何针对不同用户提供服务的。当然,这个问题只是引子。

对于我们的需求,有下面几种方法来做。

  • 根据用户的IP和浏览器类型等一些信息生成MD5来标记这个用户。

  • 系统生成唯一的用户id,并将其放置到用户cookie中。

  • 让用户登录。

第一种方式有一个很大的问题,那就是用户会重合,同一个下可能有非常多用户。

第二种方式也是基于浏览器的,可以生成唯一的 id 来标识每个用户。但问题是如果用户换浏览器,那就会产生一个新用户。

第三种方式最合理,但是实施难度最大。对于内容型网站来说,没人会登录之后才来看文章。因此,我们采用第二种方式,通过生成用户id来标记一个用户。接着就是来做具体控制了。我们需要在用户访问时记录用户的访问数据,这些数据应该放到缓存中,因为都是临时数据,并且特定时间就会过期。

方案定了,就需要考虑具体实现了,有几个点需要考虑:

  • 如何生成唯一的用户id。
  • 在哪一步给用户配置id。
  • 使用什么缓存。

我们一个一个来解决。针对第一个问题,可以使用Python内置的uuid这个库来生成唯一id:

python 复制代码
import uuid
uid = uuid.uuid4().hex

对于第二个问题,在一个Web系统中,显示是在请求的越早阶段鉴定/标记用户越好。因此,对于Django系统,我们放到middleware中来做。

对于第三个问题,我们可以直接使用Django 提供的缓存接口。Django缓存在后端支持多种配置,比如memcache、MySQL、文件系统、内存。当然,还有很多第三方插件来对Redis 做支持。

6.5.2 实现文章访问统计

上面分析得已经很清楚了,接下来只需要完成代码即可。首先需要新建一个 middleware,在blog App 下新建如下结构:

**   然后在user_id.py中增加如下代码:**

python 复制代码
# blog/middleware/user_id.py
import uuid

USER_KEY = 'uid'
TEN_YEARS = 60 * 60 * 24 * 365 * 10


class UserIDMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response  # 类的初始化方法,接收一个get_response参数,这是一个函数,用于获取响应对象。

    def __call__(self, request):
        uid = self.generate_uid(request)  # 调用generate_uid方法生成或获取UID,然后将这个UID存储在请求对象的uid属性中
        request.uid = uid
        response = self.get_response(request)  # 调用get_response函数获取响应对象

        # 通过set_cookie方法设置cookie,其中cookie的键为USER_KEY,值为UID,最大有效时间为10年,且设置了httponly=True,表示只在服务端能访问。
        response.set_cookie(USER_KEY, uid, max_age=TEN_YEARS, httponly=True)
        return response

    def generate_uid(self, request):
        # 尝试从请求的cookie中获取UID,如果cookie中不存在UID,则使用uuid.uuid4().hex生成一个新的UUID字符串作为UID。
        try:
            uid = request.COOKIES[USER_KEY]
        except KeyError:
            uid = uuid.uuid4().hex
        return uid

大概说一下上面的逻辑,Django的middleware在项目启动时会被初始化,等接受请求之后,会根据settings中的MIDDLEWARE 配置顺序挨个调用,传递request作为参数。

上面的逻辑在接受请求之后,先生成uid,然后把uid赋值给request 对象。因为request是一个类的实例,可以动态赋值。因此,我们动态给其添加uid属性,这样在后面的 View中就可以拿到uid并使用了。最后返回response时,我们设置cookie,并且设置为httponly(即只在服务端能访问)。这样用户再次请求时,就会带上同样的uid信息了。

接着,需要把我们开发的middleware配置到settings.py中。根据middleware的路径,在配置MIDDLEWARE的第一行增加如下代码:

python 复制代码
MIDDLEWARE = [
	'blog.middleware.user_id.UserIDMiddleware',
	#省略其他代码
]

这样进来的所有请求会先经过middleware,在后面的流程中request对象上就多了一个uid属性。

**   接着,我们再来完善 View层的逻辑,在PostDetailView 中新增一个方法来专门处理PV和UV统计。我们可以直接使用Django的cache接口,使用其默认配置:**

python 复制代码
# blog/views.py
from datetime import date
from django.core.cache import cache

#省略其他代码
class PostDetailView(CommonViewMixin, DetailView):
    queryset = Post.objects.filter(status=Post.STATUS_NORMAL)
    template_name = 'blog/detail.html'
    context_object_name = 'post'
    pk_url_kwarg = 'post_id'

    def get(self, request, *args, **kwargs):
        """ 重写了DetailView的get方法。在调用父类的get方法后(负责渲染页面),调用了自定义的
        handle_visited方法来处理访问量的增加。"""
        response = super().get(request, *args, **kwargs)
        self.handle_visited()
        return response

    def handle_visited(self):
        increase_pv = False  # 用于标记是否需要增加页面浏览量(PV)。
        increase_uv = False  # 用于标记是否需要增加独立访客数(UV)。

        uid = self.request.uid  # 从请求对象(self.request)中获取uid

        # pv_key 和 uv_key: 分别用于缓存PV和UV的键,它们由用户ID、日期(对于UV)和请求路径组成,以确保缓存的唯一性。
        pv_key = 'pv:%s:%s' % (uid, self.request.path)  # 构造uv_key,用于缓存当前用户在当前页面的UV访问情况。这里还包含了日期信息,以确保UV是按天计算的。
        uv_key = 'uv:%s:%s:%s' % (uid, str(date.today()), self.request.path)

        if not cache.get(pv_key):
            """
            使用cache.get(uv_key)检查缓存中是否已存在该键。
            如果不存在,说明用户尚未在今天内访问过该页面,
            因此将increase_uv设置为True,并设置缓存(有效期为24小时),表示用户已访问。
            """
            increase_pv = True
            cache.set(pv_key, 1, 1 * 60)  # 1分钟有效

        if not cache.get(uv_key):
            increase_uv = True
            cache.set(uv_key, 1, 24 * 60 * 60)  # 24小时有效

        if increase_pv and increase_uv:
            Post.objects.filter(pk=self.object.id).update(pv=F('pv') + 1, uv=F('uv') + 1)
        elif increase_pv:
            Post.objects.filter(pk=self.object.id).update(pv=F('pv') + 1)
        elif increase_uv:
            Post.objects.filter(pk=self.object.id).update(uv=F('uv') + 1)

上面的逻辑很直观,用于判断是否有缓存,如果没有,则进行+1操作,最后的几个条件语句是避免执行两次更新操作。

Django的缓存在未配置的情况下,使用的是内存缓存。如果是单进程,这没有问题;如果是多进程,就会出现问题。因为内存缓存是进程间独立的。

因此,可以暂时这么使用。或者,你可以尝试进行其他配置(对于小型系统,直接用文件系统或者数据库表缓存即可;对于大型系统,推荐使用 memcached 或者Redis)。

6.5.3 更加合理的方案

上述的统计方案针对小型项目和个人项目来说问题不大,在访问量不高的情况下,读数据的请求中同时写数据不会有太大的影响。但是我们需要意识到的是,对于所有的数据库来说,写操作都是一件成本很高的事情。因此,在实际项目中会尽量避免用户在请求数据过程中进行写操作,所以合理的方案应该是:独立的统计服务,通过前面给出的第三种方式或者第四种方式来统计。这也是我们日常业务中在用的方案。

6.5.4 总结

有了上面的统计,接下来要做的就是根据统计进行排序,可以选择PV或者UV。第4章已经封装好了一个hot_posts方法,因此我们只需要在后台新建一个侧边栏,然后在类型中选择热门文章即可。

统计方式有很多种,不同的业务场景、不同的团队规模所使用的都不同,我们需要理解的是其中的统计逻辑。

**   页面展示:**

6.5.5 参考资料

Django 缓存相关文档:

https://docs.djangoproject.com/zh-hans/4.2/topics/cache/

6.6 配置RSS和sitemap

在前面的章节中,我们已经完成了博客所有的功能,这一节就来提供一个RSS和sitemap输出的接口。RSS(Really Simple Syndication,简易信息聚合)用来提供订阅接口,让网站用户可以通过RSS阅读器订阅我们的网站,在有更新时,RSS阅读器会自动获取最新内容,网站用户可以在RSS阅读器中看到最新的内容,从而避免每次都需要打开网站才能看到是否有更新。

sitemap(站点地图)用来描述网站的内容组织结构,其主要用途是提供给搜索引擎,让它能更好地索引/收录我们的网站。

这两个组件在Django中都是现成的,我们可以直接使用。

6.6.1 实现 RSS输出

这里我们直接使用 Django的 RSS 模块 django.contrib.syndication.views.Feed来实现RSS输出。下面还是直接来看代码,在blog目录下新增rss.py文件:

python 复制代码
# blog/rss.py
from django.contrib.syndication.views import Feed
from django.urls import reverse
from django.utils.feedgenerator import Rss201rev2Feed


from .models import Post


class LatestPostFeed(Feed):
    feed_type = Rss201rev2Feed
    title = "Typeidea Blog System"
    link = "/rss/"
    description = "typeidea is a blog system power by django"

    def items(self):
        return Post.objects.filter(status=Post.STATUS_NORMAL)[:5]

    def item_title(self, item):
        return item.title

    def item_description(self, item):
        return item.desc

    def item_link(self, item):
        return reverse('post-detail', args=[item.pk])

其中feed_type 可以不写,默认使用Rss201rev2Feed,这里写出来是标明这个地方可以被赋值为其他类型。我们可以进行定制。

**   上面的代码并没有输出正文部分,我们可以通过自定义feed_type 来实现:**

python 复制代码
# blog/rss.py
from django.contrib.syndication.views import Feed
from django.urls import reverse
from django.utils.feedgenerator import Rss201rev2Feed


from .models import Post


class ExtendedRSSFeed(Rss201rev2Feed):
    def add_item_elements(self, handler, item):
        super(ExtendedRSSFeed, self).add_item_elements(handler, item)
        handler.addQuickElement('content:html', item['content_html'])


class LatestPostFeed(Feed):
    feed_type = ExtendedRSSFeed
    title = "Typeidea Blog System"
    link = "/rss/"
    description = "typeidea is a blog system power by django"

    def items(self):
        return Post.objects.filter(status=Post.STATUS_NORMAL)[:5]

    def item_title(self, item):
        return item.title

    def item_description(self, item):
        return item.desc

    def item_link(self, item):
        return reverse('post-detail', args=[item.pk])

    def item_extra_kwargs(self, item):
        return {'content_html': self.item_content_html(item)}

    def item_content_html(self, item):
        return item.content_html

6.6.2 实现 sitemap

sitemap 的实现跟Feed类似,都是输出文章列表,但是格式和内容均不相同。在blog目录下新增sitemap.py,其内容如下:

python 复制代码
# blog/sitemap.py
from django.contrib.sitemaps import Sitemap
from django.urls import reverse

from .models import Post


class PostSitemap(Sitemap):
    changefreq = "always"
    priority = 1.0
    protocol = 'https'

    def items(self):
        return Post.objects.filter(status=Post.STATUS_NORMAL)

    def lastmod(self, obj):
        return obj.created_time

    def location(self, obj):
        return reverse('post-detail', args=[obj.pk])
    

这段代码中我们实现了3个方法:items 返回所有正常状态的文章,lastmod返回每篇文章的创建时间(或者最近更新时间),location返回每篇文章的URL。

**   编写好sitemap数据处理的代码后,再来编写对应的模板,新增文件templates/sitemap.xml**

**   其内容可以直接从Django文档贴过来。<news:news>部分根据我们的Model进行调整。其内容如下:**

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<urlset
  xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"
  xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
{% spaceless %}
{% for url in urlset %}
  <url>
    <loc>{{ url.location }}</loc>
    {% if url.lastmod %}<lastmod>{{ url.lastmod|date:"Y-m-d" }}</lastmod>{% endif %}
    {% if url.changefreq %}<changefreq>{{ url.changefreq }}</changefreq>{% endif %}
    {% if url.priority %}<priority>{{ url.priority }}</priority>{% endif %}
    <news:news>
      {% if url.item.created_time %}<news:publication_date>{{ url.item.created_time|date:"Y-m-d" }}</news:publication_date>{% endif %}
      {% if url.item.tags %}<news:keywords>{{ url.item.tags }}</news:keywords>{% endif %}
    </news:news>
   </url>
{% endfor %}
{% endspaceless %}
</urlset>

这里大概解释一下代码,上面的{% spaceless %}标签的作用是去除多余的空行,因为在Django 模板中使用for循环会产生很多空行,我们在前面的模板中并未使用它,你可以查看网页源码观察到这一结果。配置好spaceless之后,可以方便地去掉多余的空行。

后面的for 循环就是遍历上面PostSitemap输出的结果,只是做了包装而已。

这里用到的url.item.tags需做下支持,因为我们的Post 模型有tag这样一个多对多的关联,所以可以在模型中增加一个属性来输出配置好的tags。接着修改blog/models.py中Post的部分:

python 复制代码
# blog/models.py
from django.utils.functional import cached_property # 在合适的位置引入
# 省略其他代码
	
class Post (models.Model):
	# 省略其他代码
    @cached_property
    def tags(self):
        return ','.join(self.tag.values_list('name', flat=True))

这里面用到了 Django 提供的一个工具cached_property,它的作用是帮我们把返回的数据绑到实例上,不用每次访问时都去执行tags 函数中的代码。关于这点,你可以对比Python内置的property。

配置好这些后,RSS和sitemap就配置完成了,接下来配置urls.py让其生效。

6.6.3 配置RSS 和sitemap的urIs.py

**   这里还是直接看代码:**

python 复制代码
from django.contrib.sitemaps import views as sitemap_views
from blog.rss import LatestPostFeed
from blog.sitemap import PostSitemap

urlpatterns = [
	# 省略其他代码
    re_path(r'^rss|feed/', LatestPostFeed(), name='rss'),
    path('sitemap.xml', sitemap_views.sitemap, {'sitemaps': {'posts': PostSitemap}}, name='sitemap'),
]

这样配置完成后,可以启动项目,访问 http://127.0.0.1:8000/rss/ 以及 http://127.0.0.1:8000/sitemap.xml 来查看效果。

对于网页特别多的系统来说,sitemap还需要进一步拆分。毕竟如果存在上万或者上百万的文章,生成单一的sitemap.xml也是一个挑战,况且 sitemap单个文件也有条数和文件大小的限制。

因此,可以使用sitemap index的方式(即 sitemap中的loc部分不是网页地址,而是另外一个sitemap地址)来拆分sitemap。

6.6.4 总结

如果前面的章节你都能够理解并掌握,其实会发现,在基于Django开发的过程中,我们除了理解需要做什么之外,还需要知道 Django 给我们提供了哪些能力,比方说 RSS和 sitemap这两个功能。如果完全自己开发或许也不难,但是有现成的并且扩展性很好的基础组件,岂不是更好。

**   页面效果:**

6.7 本章总结

截至目前,我们已经完成了博客系统的基础功能开发,如果你一直是随着本书的内容编写代码,应该能得到如下图所示的界面。

通过本章,我们能够直观地感受到,当基于一个成熟的框架构建好一套流程之后,新增功能时会非常方便。这得益于Django的优势------内置了很多对网站开发有用的功能。如果能够很好地掌握这些内置模块,就能够极大地提高开发速度。

结尾:

到此为止,我们的博客系统开发完毕,基础功能都已实现,大家可以使用第三方插件来继续完善博客系统,丰富其内容和页面。本系列博客就跟大家暂时告一段落了,后续可能会继续完善其功能。

本博客借鉴了《Django企业开发实战》一书,在书中的基础上更新了python和Django版本(书中的版本有点太老了,很多第三方插件都不支持了),但是博主学艺欠缺,后期的知识点没能融会贯通,所以也没有继续开发博客系统了,后期再继续深造一下。

最后,本博客所涉及的项目源码会开源在GitHub上:https://github.com/1273055646/typeidea 。每个章节都有对应的分支,比如03-admin就是对应第3章的内容,读者可以自行切换分支来查看对应代码。

**   祝好!**

链接:

项目开源代码GitHub:https://github.com/1273055646/typeidea

Django学习实战篇一(适合略有基础的新手小白学习)(从0开发项目)

Django学习实战篇二(适合略有基础的新手小白学习)(从0开发项目)

Django学习实战篇三(适合略有基础的新手小白学习)(从0开发项目)

Django学习实战篇四(适合略有基础的新手小白学习)(从0开发项目)

Django学习实战篇五(适合略有基础的新手小白学习)(从0开发项目)

Django学习实战之评论验证码功能(附A)

相关推荐
CyreneSimon25 分钟前
使用 LoRA 进行模型微调的步骤
python·transformer
ymchuangke27 分钟前
数据清洗-缺失值处理-缺失值可视化图(竖线)
python·算法·数学建模
计算机学姐1 小时前
基于python+django+vue的旅游网站系统
开发语言·vue.js·python·mysql·django·旅游·web3.py
程序员小羊!1 小时前
Python语言基础教程(下)4.0
开发语言·python
huanxiangcoco1 小时前
73. 矩阵置零
python·leetcode·矩阵
林太白1 小时前
❤Node09-用户信息token认证
数据库·后端·mysql·node.js
一晌小贪欢2 小时前
Python基础知识——字典排序(不断补充)
python·json·python基础·字典·字典排序·python学习
YOLO数据集工作室2 小时前
Python介绍
开发语言·python
骆晨学长2 小时前
基于Springboot的助学金管理系统设计与实现
java·spring boot·后端
蒙娜丽宁2 小时前
深入理解Go语言中的接口定义与使用
开发语言·后端·golang·go