统计各个分类下的文章数

在我们的博客侧边栏有分类列表,显示博客已有的全部文章分类。现在想在分类名后显示该分类下有多少篇文章,该怎么做呢?最优雅的方式就是使用 Django 模型管理器的 annotate 方法。

模型回顾

回顾一下我们的模型代码,Django 博客有一个 Post 和 Category 模型,分别表示文章和分类:

blog/models.py

class Post(models.Model):
    title = models.CharField(max_length=70)
    body = models.TextField()
    category = models.ForeignKey('Category')
    # 其它属性...

    def __str__(self):
        return self.title

class Category(models.Model):
    name = models.CharField(max_length=100)

我们知道从数据库取数据都是使用模型管理器 objects 的方法实现的。比如获取全部分类是:Category.objects.all() ,假设有一个名为 test 的分类,那么获取该分类的方法是:Category.objects.get(name='test') 。objects 除了 allget 等方法外,还有很多操作数据库的方法,而其中有一个 annotate 方法,该方法正可以帮我们实现本文所关注的统计分类下的文章数量的功能。

数据库数据聚合

annotate 方法在底层调用了数据库的数据聚合函数,下面使用一个实际的数据库表来帮助我们理解 annotate 方法的工作原理。在 Post 模型中我们通过 ForeignKey 把 Post 和 Category 关联了起来,这时候它们的数据库表结构就像下面这样:

Post 表:

id title body category_id
1 post 1 ... 1
2 post 2 ... 1
3 post 3 ... 1
4 post 4 ... 2

Category 表:

name id
category 1 1
category 2 2

这里前 3 篇文章属于 category 1,第 4 篇文章属于 category 2。

当 Django 要查询某篇 post 对应的分类时,比如 post 1,首先查询到它分类的 id 为 1,然后 Django 再去 Category 表找到 id 为 1 的那一行,这一行就是 post 1 对应的分类。反过来,如果要查询 category 1 对应的全部文章呢?category 1 在 Category 表中对应的 id 是 1,Django 就在 Post 表中搜索哪些行的 category_id 为 1,发现前 3 行都是,把这些行取出来就是 category 1 下的全部文章了。同理,这里 annotate 做的事情就是把全部 Category 取出来,然后去 Post 查询每一个 Category 对应的文章,查询完成后只需算一下每个 category id 对应有多少行记录,这样就可以统计出每个 Category 下有多少篇文章了。把这个统计数字保存到每一条 Category 的记录就可以了(当然并非保存到数据库,在 Django ORM 中是保存到 Category 的实例的属性中,每个实例对应一条记录)。

使用 Annotate

以上是原理方面的分析,具体到 Django 中该如何用呢?在我们的博客中,获取侧边栏的分类列表的方法写在模板标签 get_categories 里,因此我们修改一下这个函数,具体代码如下:

blog/templatetags/blog_tags.py

from django.db.models.aggregates import Count
from blog.models import Category

@register.simple_tag
def get_categories():
    # 记得在顶部引入 count 函数
    # Count 计算分类下的文章数,其接受的参数为需要计数的模型的名称
    return Category.objects.annotate(num_posts=Count('post')).filter(num_posts__gt=0)

这个 Category.objects.annotate 方法和 Category.objects.all 有点类似,它会返回数据库中全部 Category 的记录,但同时它还会做一些额外的事情,在这里我们希望它做的额外事情就是去统计返回的 Category 记录的集合中每条记录下的文章数。代码中的 Count 方法为我们做了这个事,它接收一个和 Categoty 相关联的模型参数名(这里是 Post,通过 ForeignKey 关联的),然后它便会统计 Category 记录的集合中每条记录下的与之关联的 Post 记录的行数,也就是文章数,最后把这个值保存到 num_posts 属性中。

此外,我们还对结果集做了一个过滤,使用 filter 方法把 num_posts 的值小于 1 的分类过滤掉。因为 num_posts 的值小于 1 表示该分类下没有文章,没有文章的分类我们不希望它在页面中显示。关于 filter 函数以及查询表达式(双下划线)在之前已经讲过,具体请参考 分类与归档

在模板中引用新增的属性

现在在 Category 列表中每一项都新增了一个 num_posts 属性记录该 Category 下的文章数量,我们就可以在模板中引用这个属性来显示分类下的文章数量了。

templates/base.html

<ul>
  {% for category in category_list %}
  <li>
    <a href="{% url 'blog:category' category.pk %}">{{ category.name }}
      <span class="post-count">({{ category.num_posts }})</span>
    </a>
  </li>
  {% empty %}
  暂无分类!
  {% endfor %}
</ul>

也就是在模板中通过模板变量 {{ category.num_posts }} 显示 num_posts 的值。开启开发服务器,可以看到分类名后正确地显示了该分类下的文章数了,而没有文章分类则不会在分类列表中出现。

将 Annotate 用于其它关联关系

此外,annotate 方法不局限于用于本文提到的统计分类下的文章数,你也可以举一反三,只要是两个 model 类通过 ForeignKey 或者 ManyToMany 关联起来,那么就可以使用 annotate 方法来统计数量。比如下面这样一个标签系统:

class Post(models.Model):
    title = models.CharField(max_length=70)
    body = models.TextField()
    Tags = models.ManyToManyField('Tag')

    def __str__(self):
        return self.title

class Tag(models.Model):
    name = models.CharField(max_length=100)

统计标签下的文章数:

from django.db.models.aggregates import Count
from blog.models import Tag

# Count 计算分类下的文章数,其接受的参数为需要计数的模型的名称
tag_list = Tag.objects.annotate(num_posts=Count('post'))

关于 annotate 方法官方文档的说明在这里:annotate。同时也建议了解了解 objects 下的其它操作数据库的方法,以便在遇到相关问题时知道去哪里查阅。

总结

本章节的代码位于:Step21: number of post in category

如果遇到问题,请通过下面的方式寻求帮助。

-- EOF --


54 条评论 / 22 人参与
deanglc
{{ category.post_all.count }} 我直接改的html文档,好像成功了,这样做会不会有弊端

deanglc deanglc
{{ category.post_set.count }} 是这个,上面的打错了

喊着666的咸鱼 deanglc

没啥影响,实现一个功能的方法本来就是多种多样的,这里还可以到模型里面定义方法来实现。


lllong33
num_posts__gt=0 代码中__gt是另外的删除用法吗? filter不是筛选符合条件的吗?

lllong33 lllong33
gt 就是 greate than,表示大于,不是大于等于。大于等于是 gte


okok 解决了

lucahai
倒数第2个代码段有点小问题,
Tags = models.ManyToManyField('Tag')才对?

追梦人物 [博主] lucahai
是的,感谢指出!

lucahai lucahai
呃,应该是Tags = models.ManyToManyField(Tag, blank=True)才对。

GodLovesMee
非常感谢博主的教程,登入评论下,为点个star

追梦人物 [博主] GodLovesMee
感谢支持(✪ω✪)

科兴一把刀
"代码中的 Count 方法为我们做了这个事,它接收一个和 Categoty 相关联的模型参数名(这里是 Post,通过 ForeignKey 关联的)",为什么代码num_posts=Count('post')是小写的post呢?

追梦人物 [博主] 科兴一把刀
django 支持这种做法,你可以尝试一下用 ‘Post’ 看 django 是否也支持。

科兴一把刀 追梦人物 [博主]
用"Post"不行,提示FieldError,Cannot resolve keyword 'Post' into field. Choices are: id, name, post。

追梦人物 [博主] 科兴一把刀
那就只能小写,这是 django 规定。

mozhemeng 追梦人物 [博主]
博主我想问一下,这里这个Count('post'),能对post加条件吗,比如我的post还有一个visable属性,我想让他只统计post.visable=1的post数量

追梦人物 [博主] mozhemeng
你可以先用 filter筛选出需要的post

mozhemeng 追梦人物 [博主]
那个count()函数不是接受的是一个字符串吗,
Category.objects.annotate(num_posts=Count('post')).filter(num_posts__gt=0)
这个filter应该加在哪呢,我试了加在最后一个filter前,可是结果不对。想想加在那逻辑是不对

追梦人物 [博主] mozhemeng
你这样试一下

Category.objects.filter(visible=1).annotate(
num_posts=Count('post')).filter(num_posts__gt=0)

mozhemeng 追梦人物 [博主]
不行额这样,说Category没有visible这个字段。。

追梦人物 [博主] mozhemeng
换成你的model 里定义的字段。。

想不出名字的乙方程序狗 mozhemeng

我刚刚按照自己的理解尝试了一下,我觉得是可以实现的。

比如我存在一篇未来时间的文章,不想在分类中计算(这样会导致错误),所以我在Count之前对article先进行过滤。

Category.objects.filter(article__publish_time__lte=timezone.now()).annotate(
        num_articles=Count('article')).filter(num_articles__gt=0)

这里的 Article = Post 模型,publish_time 是 Article 的一个属性。通过双下划线来选择 Article 属性。

不知道这样子讲清楚没。。嘿嘿,有问题可以再讨论


bpzj 想不出名字的乙方程序狗

https://docs.djangoproject.com/en/1.10/topics/db/aggregation/#following-relationships-backwards

为什么Count()的参数用小写,在这里。

The lowercase name of related models and double-underscores are used here too.


bpzj 科兴一把刀

追梦人物 [博主] bpzj

It's a convention in django.


gruiyuan 想不出名字的乙方程序狗

感谢分享,纠结了很久,不想通过extra实现这个需求,没想到可以这么用。

也感谢博主的文章和提供了一个这么好的平台供大家学习讨论~


cgDeepLearn
请问下博主,归档下怎么统计每个月的文章数

追梦人物 [博主] cgDeepLearn
思路还是使用 annotate 的,具体实现留给你自己探索啦。提示:看 django model 层的文档。

cgDeepLearn 追梦人物 [博主]
好的,我试试,谢谢博主!

吉超 cgDeepLearn

你好,你解决了吗


Zeng1998 吉超

你好,你解决了吗?o(╯□╰)o


吉超 Zeng1998

用另外一种方法解决了


_CindyL_
多谢博主提供这么棒的教程,我想请问下这里的Category.objects.annotate和category.post_set.count有什么区别呢?

追梦人物 [博主] _CindyL_
这样只需查询一次数据库,而 category.post_set.count 每调用一次查询一次数据库。

宝子要加油 _CindyL_
请问用category.post_set.count该怎么实现呢?

_CindyL_ 宝子要加油
博主前面的文章有说过类似的。

哈喽诗11
请问楼主, num_posts__gt=0 这句是什么意思啊?

追梦人物 [博主] 哈喽诗11
gt 是 django 的查询表达式,表示 greate than or equal,实际翻译过来就是 num_posts >= 0

追梦人物 [博主] 哈喽诗11
更正一下,gt 就是 greate than,表示大于,不是大于等于。大于等于是 gte

哈喽诗11 追梦人物 [博主]
Category.objects.annotate(num_posts=Count('post')).filter(num_posts__gt=0)

这行代码一直报错.

如下:
Reverse for 'category' with arguments '(1,)' and keyword arguments '{}' not found. 0 pattern(s) tried: []

追梦人物 [博主] 哈喽诗11
这行代码与视图函数无关,不应该有这个异常错误。

DawnOct 追梦人物 [博主]
我把filter方法去掉也报错这样的错误...

mygit-li

qiqiming
filter(num_posts__lt=1), 或许是__gte = 1吧。

追梦人物 [博主] qiqiming
感谢你这么仔细的阅读,是我手残了,已修正。

DawnOct 追梦人物 [博主]
def get_categories():
# 别忘了在顶部引入 Category 类
return Category.objects.annotate(num_posts=Count('post')).filter(num_posts__gt=0)

报错了...

mihelloO
博主很勤力,32赞。希望有空可以教授下二级评论定制。

追梦人物 [博主] mihelloO
多谢!这个实现还是需要一定技巧,主要是我现在没太多时间写教程。推荐你看看 django contrib comments 和 django mptt 的文档,也许能找到实现思路。

mihelloO 追梦人物 [博主]
好,最近研究websocket所以没细看。我这几天抽空研究。谢谢:)

JustBreaking 追梦人物 [博主]
test~

mihelloO 追梦人物 [博主]
我今天测试了下mptt,它可以控制最大层级吗?比如博主的评论最大层级是2。

追梦人物 [博主] mihelloO
先取根节点,然后取根节点的全部子节点就是二级了,可以任意级。

mihelloO 追梦人物 [博主]
获取到根节点的所有sub node, 1. 将sub node 的parent_id 指向根节点?
2. 或者,在前端,将获取的sub node设置样式,实现层级2的树形? 暂时想到这两点

追梦人物 [博主] mihelloO
模板用两层循环,第一层循环根节点,然后从根节点获取子节点循环。

mihelloO 追梦人物 [博主]
好,测试下。谢谢。