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)

相关推荐
Xudde.2 小时前
班级作业笔记报告0x04
笔记·学习·安全·web安全·php
晓晓hh2 小时前
JavaSE学习——迭代器
java·开发语言·学习
421!3 小时前
GPIO工作原理以及核心
开发语言·单片机·嵌入式硬件·学习
极梦网络无忧3 小时前
OpenClaw 基础使用说明(中文版)
python
codeJinger3 小时前
【Python】操作Excel文件
python·excel
AI成长日志3 小时前
【笔面试算法学习专栏】双指针专题·简单难度两题精讲:167.两数之和II、283.移动零
学习·算法·面试
XLYcmy4 小时前
一个针对医疗RAG系统的数据窃取攻击工具
python·网络安全·ai·llm·agent·rag·ai安全
Islucas4 小时前
Claude code入门保姆级教程
python·bash·claude
小手指动起来4 小时前
保姆级提示词工程学习总结(含实操示例+工具推荐)
人工智能·学习·自然语言处理