ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django REST Framework进阶:CBV、分页与筛选实战指南

Django REST Framework进阶:CBV、分页与筛选实战指南

如果你已经能把DRF的APIView写得飞起,却还是被真实项目里这些需求折磨——列表接口越写越多、每个视图重复粘贴序列化代码、数据一多接口响应慢、前端要的关键字搜索不知道从哪下手——那day55这篇内容就是为你准备的。今天聊的是Django REST Framework进阶三件套:CBV(类视图)、数据分页、筛选。这三样东西在教程里常常被一笔带过,但实际做后台管理系统、App接口、数据中台时,几乎每个列表页都绕不开它们。我尽量按真实项目的推进方式来讲,不是单纯给你抄代码,而是讲清楚每一步为什么要这么写。

先说个背景:这一篇属于“Python 100天”系列的中后段,建议你已经有Python基础,装好Django和DRF,并且能跑通一个最简单的API接口。如果你还在纠结Python环境配置或者vscode里怎么跑Django,先花半小时把环境理顺,再回来看这篇,效果会好很多。下面进入正题。

1. 从函数视图到类视图,到底多省事:CBV的底层逻辑

1.1 APIView只是开始,GenericAPIView才是进阶入口

很多初学者写DRF接口时,第一个接触的是APIView,写法跟Django原生的View差不多,只是多了个返回Response的能力。比如下面这样:

from rest_framework.views import APIView from rest_framework.response import Response class ArticleList(APIView): def get(self, request): articles = Article.objects.all() serializer = ArticleSerializer(articles, many=True) return Response(serializer.data)

这段代码没毛病,但它暴露了一个问题:如果你有十个列表接口,每个都要写“取queryset、塞进序列化器、返回Response”这三个动作,代码就全是重复的。而且这还只是最基础的场景,一旦加上分页、筛选、权限、缓存,每个视图都要手动处理,维护成本直线上升。

DRF真正的设计哲学是让框架帮你把通用流程封装好,你只需要声明“查什么数据、用什么序列化器、要不要分页”。这就是GenericAPIView做的事。它的核心思路是把ListView里那套“查数据→分页→序列化→返回”的流程抽成钩子方法,你只需要重写对应的属性或方法:

from rest_framework.generics import ListAPIView class ArticleList(ListAPIView): queryset = Article.objects.all() serializer_class = ArticleSerializer

就这么简单?就这么简单。ListAPIView继承了GenericAPIView和ListModelMixin,内部帮你干了这些事:调用get_queryset拿数据、调用get_serializer序列化、调用paginate_queryset做分页、最后返回响应。你只需要告诉它数据从哪来、序列化成什么样。

这里有个容易混淆的点:GenericAPIView本身是不带list/create这些动作的。它只提供底座,真正干活的是那些Mixin,比如ListModelMixin、CreateModelMixin。而ListView、CreateView这些类,就是GenericAPIView + 对应Mixin的组合产物。所以有时候你看源码会发现ListAPIView就三行代码,因为逻辑全在父类里。

1.2 ViewSet与ModelViewSet:把“动作”变成“方法”

如果只是省掉重复代码,GenericAPIView已经够用。但真实项目里还有一个需求:同一个资源有增删改查四个操作,你能不能只写一个类?这时候就要上ViewSet系。

from rest_framework.viewsets import ModelViewSet class ArticleViewSet(ModelViewSet): queryset = Article.objects.select_related('author', 'category').all() serializer_class = ArticleSerializer pagination_class = StandardPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = ArticleFilter search_fields = ['title', 'content'] ordering_fields = ['created_time', 'views']

看到没,增删改查全在这一段里声明完了。ModelViewSet背后干了这些事:把list、create、retrieve、update、partial_update、destroy这六个动作,分别映射到HTTP的GET、POST、GET(单个)、PUT、PATCH、DELETE方法上。也就是说,你不需要再写6个不同的视图类,router会自动帮你生成对应的URL路由。

你可能会问:ModelViewSet这么好用,那APIView是不是就没用了?我的看法是:简单资源优先ModelViewSet,复杂定制逻辑选APIView,中间状态选GenericAPIView加Mixin组合。比如登录接口、文件上传接口这种跟资源无关的操作,硬套ViewSet反而别扭。再比如某个列表页需要非常特殊的过滤逻辑、或者返回格式跟标准结构完全不同,这时候用APIView更灵活。用哪个不是面子问题,是代码维护成本问题。

1.3 为什么要用CBV:不只是少写代码

CBV的收益容易被新手低估。首先是一致性:所有列表接口的行为模式都一样,新同事接手时能快速定位修改点。其次是可扩展性:比如你想给所有列表接口加个“只能看自己创建的数据”,那就在基类里重写get_queryset,所有接口一次性生效:

class OwnerListMixin: def get_queryset(self): queryset = super().get_queryset() return queryset.filter(user=self.request.user)

这就是类继承的力量。你要是用函数视图写,同一个逻辑得复制到每个函数里。而且CBV还有个隐形好处:DRF文档、API调试页面、路由注册都对类视图有天然的适配,你定义好ViewSet之后,用DefaultRouter注册一下,连URLconf都省得手写了:

from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register('articles', ArticleViewSet) urlpatterns = router.urls

到这里为止,你应该明白一件事:CBV不是花架子,它是DRF设计者希望你长期维护项目时走的正道。接下来分页和筛选,都是建立在这套类视图结构上的能力。

2. 数据分页:接口响应慢的元凶,往往就是没分页

2.1 为什么接口必须分页:数据库性能与用户体验的平衡

先算一笔账:假设文章表有10万条数据,一次查询把所有记录都扔给前端,会发生三件事。第一,数据库要一次性把这些数据查出来,如果没有索引和优化,可能在数据库层面就直接超时了;第二,即使查出来了,DRF要对这10万条数据逐一序列化,内存占用轻松突破几百兆,接口响应时间奔着十秒去了;第三,前端拿到这10万条数据,根本没法渲染,浏览器直接卡死。所以分页的本质是:每次只拿一小块数据,让数据库查询快、序列化快、传输快、前端渲染快。

DRF内置了三种分页器,原理完全不同,适用场景也不一样。我把它们整理成了一张对比表,实际选型时对着这张表挑就行:

分页器核心参数优点缺点适用场景
PageNumberPaginationpage, page_size前端直观,支持任意跳页深翻页时数据库要扫描前面的所有数据,性能随页码增加下降数据量几万的普通管理系统、后台列表
LimitOffsetPaginationlimit, offset实现简单,可以看成SQL里的LIMIT和OFFSET和页码分页一样有深翻页性能问题,且数据删除后偏移量会错位适合需要“从第N条开始取”的场景,比如移动端简单列表
CursorPaginationcursor性能恒定,深度分页也不慢,结果稳定不支持跳页,只支持点击“下一页”数据量大且持续增长的流式数据,比如订单流、新闻流

我见过的真实踩坑是:电商后台订单列表用了PageNumberPagination,数据到了几十万量级之后,点击“第1000页”的接口能等十秒。后来改成CursorPagination,“下一页”的响应直接降到几百毫秒。代价是用户不能跳页了,但对于持续滚动加载的列表来说,这完全不是问题。

2.2 全局配置与自定义分页器,一步到位的写法

分页的配置有两个层级:全局配置和视图级配置。全局配置写在settings.py的REST_FRAMEWORK里:

REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 20, }

这样所有继承GenericAPIView的列表接口会默认分页,每页20条。但全局配置的问题是:所有接口的每页条数都一样了,而且前端没法改。实际项目里我一般会自定义一个标准分页类,覆盖几个关键属性:

from rest_framework.pagination import PageNumberPagination class StandardPagination(PageNumberPagination): page_size = 20 page_size_query_param = 'page_size' # 前端可以传 ?page_size=50 max_page_size = 100 # 最多允许一页100条 page_query_param = 'page' # 默认就是 'page',可自定义

然后每个要分页的视图里指定:

class ArticleList(ListAPIView): queryset = Article.objects.all() serializer_class = ArticleSerializer pagination_class = StandardPagination

这里有个细节:如果你在settings里配置了全局分页,而某个接口不想分页,怎么取消?把它的pagination_class设为None反而是不对的,正确做法是覆盖pagination_class并指向一个你自定义的“不用分页”类。不过说实话,我从没见过哪个列表接口真的不需要分页,最多是把每页条数调大而已。

分页后DRF返回的JSON结构是这样的:

{ "count": 100, "next": "http://api.example.com/articles?page=2", "previous": null, "results": [...] }

前端拿到count之后就能计算总页数,next和previous是现成的翻页链接。这个结构对前后端协作非常友好,前端根本不需要自己拼页码参数。

2.3 分页时的性能优化:别让你的查询比数据库还慢

分页只是“每次少取一些数据”,但如果你的查询本身写得糙,照样会出问题。最常见的坑是N+1查询:序列化一页20条文章,每篇文章要查一次作者、一次分类、一次标签,结果就是1条主查询加上60条附加查询,数据库连接直接被打爆。

解决办法是在get_queryset里提前把关联数据查出来:

class ArticleViewSet(ModelViewSet): queryset = Article.objects.select_related('author', 'category').prefetch_related('tags')

select_related适合ForeignKey、OneToOne这种单条关联,原理是用SQL的JOIN一次性查出来;prefetch_related适合ManyToMany、反向外键这种多条关联,原理是额外查一次,然后在Python内存里做拼接。用上之后,同样的分页20条,SQL数量从61条降到3条左右,响应时间能快一个数量级。

我实测过的场景:一个文章列表接口从400ms降到不到50ms,唯一的改动就是加上select_related。所以我在写接口时有个习惯,先看一眼序列化器里有哪些字段是关联字段,然后回头在queryset里补齐优化,这一步做完再考虑分页、筛选之类的功能。顺序反了的话,你会发现加了分页之后接口还是慢,排查半天才发现问题出在N+1上。

3. 筛选与搜索:把Excel里的“多条件筛选”搬到服务端

3.1 先手写get_queryset,掌握最原始的筛选逻辑

筛选用大白话说就是:根据前端传来的参数,动态过滤queryset。最简单的方式当然是手写:

class ArticleViewSet(ModelViewSet): serializer_class = ArticleSerializer def get_queryset(self): queryset = Article.objects.all() category_id = self.request.query_params.get('category_id') status = self.request.query_params.get('status') if category_id: queryset = queryset.filter(category_id=category_id) if status: queryset = queryset.filter(status=status) return queryset

这段逻辑很直白,适合筛选条件很少、就两三个的场景。但问题在于:条件一多,这段代码就会变成一团if组成的意大利面,而且每个视图都要写一遍。更麻烦的是,你很难处理“同一个字段用不同方式匹配”的需求,比如标题要模糊搜索、创建时间要范围筛选、状态要精确匹配——手写的话你得自己记着每个参数对应什么查询方式,时间一长自己也迷糊。

所以DRF提供了更优雅的方案:声明式筛选。你只需要定义一个FilterSet类,把这些规则声明出来,框架自动帮你完成“参数解析→queryset过滤”的过程。这也跟Excel里做多条件筛选是一个思路:你先定义好哪些列可以筛、怎么筛,剩下的交给工具执行。

3.2 接入django-filter:声明式筛选的实际配置

django-filter是DRF官方推荐的筛选库,需要单独安装,然后加进app列表和DRF的filter_backends:

pip install django-filter
INSTALLED_APPS = [ # ... 'django_filters', ] REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], }

定义一个FilterSet,声明筛选字段和匹配方式:

import django_filters from .models import Article class ArticleFilter(django_filters.FilterSet): title = django_filters.CharFilter(field_name='title', lookup_expr='icontains') category = django_filters.NumberFilter(field_name='category_id') created_after = django_filters.DateTimeFilter(field_name='created_time', lookup_expr='gte') created_before = django_filters.DateTimeFilter(field_name='created_time', lookup_expr='lte') status = django_filters.ChoiceFilter(choices=Article.STATUS_CHOICES) class Meta: model = Article fields = ['title', 'category', 'status', 'created_after', 'created_before']

然后在视图里指定filterset_class:

class ArticleViewSet(ModelViewSet): queryset = Article.objects.all() serializer_class = ArticleSerializer filter_backends = [DjangoFilterBackend] filterset_class = ArticleFilter

这样前端请求这个接口时,就可以带一堆参数了:

GET /api/articles/?title=Python&status=published&created_after=2024-01-01&created_before=2024-12-31&category=2

django-filter会自动解析这些参数,拼接成对应的ORM查询。你不用再写任何if判断,代码量直线下降,而且参数的合法性校验也交给框架了。有一点要注意:filterset_class字段名跟前端传参名是对应的,如果你希望前端传的参数名跟模型字段名不一致,可以用字段声明中的name参数映射。

3.3 搜索和排序:SearchFilter与OrderingFilter的绝配

除了精确筛选,列表接口通常还要支持关键词搜索和排序。DRF内置了SearchFilter和OrderingFilter,用起来非常简单:

from rest_framework.filters import SearchFilter, OrderingFilter class ArticleViewSet(ModelViewSet): queryset = Article.objects.all() serializer_class = ArticleSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = ArticleFilter search_fields = ['title', 'content', 'author__username'] # 关联字段用双下划线 ordering_fields = ['created_time', 'views', 'comments_count']

SearchFilter的原理是把search参数的值拆成多个关键词,在search_fields指定的字段里逐个做icontains匹配,字段之间默认是OR关系。OrderingFilter则允许前端传ordering参数,比如?ordering=-created_time表示按创建时间倒序,字段前面加负号就是降序。

这里有三个容易踩的坑:

  • search_fields里的关联字段:比如author__username这种写法时,必须先确认queryset里有没有用select_related把author查出来,否则会多出N条查询。
  • ordering_fields的字段必须是模型里有的,不能随便排序一个非模型字段,否则会报错。
  • filter_backends的顺序会影响最终结果:筛选、搜索、排序、分页之间是有先后关系的。正确的执行顺序是先筛选,再搜索,再排序,最后分页。DRF内部也是这么处理的,但如果你在filter_backends里把OrderingFilter放在DjangoFilterBackend前面,排序就会先于筛选执行,在某些情况下会出现排序结果被后续筛选打乱的问题。

3.4 筛选、排序、分页如何协作:完整链路梳理

很多人分开看这三个功能都能理解,但合在一起就懵了。其实它们的关系是一条流水线:原始queryset → 按filter参数筛选 → 按search参数搜索 → 按ordering参数排序 → 分页取数据 → 序列化返回。你可以这样理解:数据库把符合条件的所有数据都找出来,不需要把每条数据都传给前端;框架先在前几道工序里把数据范围缩小,只把最终需要的20条传给序列化器。这个链条上的每一步都是对前面结果的精加工,顺序不能乱。

实际开发中你还会遇到一个场景:筛选条件里要用到当前登录用户。比如“我只想看我发布过的文章”,这时候FilterSet里可以覆盖方法:

class ArticleFilter(django_filters.FilterSet): author = django_filters.NumberFilter(method='filter_author') def filter_author(self, queryset, name, value): return queryset.filter(author=self.request.user)

注意FilterSet里访问request的方式:在DRF集成中,FilterSet会自动把请求对象挂到实例上,你可以在filter方法里用self.request。这个技巧尤其适合按用户隔离数据的场景,算是进阶里比较高级的用法了。

4. 综合实战:一个带分页、筛选、搜索的完整专栏API

4.1 项目目录与数据模型设计

光讲概念不够,我带你从零搭一个真实的API。假设我们在做一个“Python学习博客”,需要提供一个文章列表接口,支持按分类筛选、关键词搜索、发布时间范围筛选、按浏览量排序和分页。模型设计如下:

from django.db import models class Category(models.Model): name = models.CharField(max_length=50) class Article(models.Model): title = models.CharField(max_length=200) content = models.TextField() category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='articles') author = models.ForeignKey('auth.User', on_delete=models.CASCADE, related_name='articles') tags = models.ManyToManyField('Tag', related_name='articles') status = models.CharField(max_length=10, choices=(('draft', '草稿'), ('published', '已发布')), default='draft') views = models.IntegerField(default=0) created_time = models.DateTimeField(auto_now_add=True) updated_time = models.DateTimeField(auto_now=True) class Tag(models.Model): name = models.CharField(max_length=30)

这个模型引用了外键author、category,还有多对多tags,正好可以用来演示select_related和prefetch_related的优化效果。序列化器这样写:

from rest_framework import serializers from .models import Article class ArticleSerializer(serializers.ModelSerializer): category_name = serializers.CharField(source='category.name', read_only=True) author_name = serializers.CharField(source='author.username', read_only=True) tags = serializers.StringRelatedField(many=True, read_only=True) class Meta: model = Article fields = ['id', 'title', 'category_name', 'author_name', 'tags', 'status', 'views', 'created_time']

这里的source字段用来把关联信息直接展平,避免前端还要再发一次请求去查分类和作者的详情。

4.2 settings与视图完整配置

settings.py里做全局配置:

REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'blog.pagination.StandardPagination', 'PAGE_SIZE': 20, 'DEFAULT_FILTER_BACKENDS': [ 'django_filters.rest_framework.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', 'rest_framework.filters.OrderingFilter', ], }

pagination.py里自定义分页类:

from rest_framework.pagination import PageNumberPagination class StandardPagination(PageNumberPagination): page_size = 10 page_size_query_param = 'page_size' max_page_size = 50

views.py里直接上ModelViewSet,把所有能力组合起来:

from rest_framework.viewsets import ModelViewSet from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import SearchFilter, OrderingFilter from .models import Article from .serializers import ArticleSerializer from .filters import ArticleFilter from .pagination import StandardPagination class ArticleViewSet(ModelViewSet): queryset = Article.objects.select_related('category', 'author').prefetch_related('tags').all() serializer_class = ArticleSerializer pagination_class = StandardPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = ArticleFilter search_fields = ['title', 'content'] ordering_fields = ['created_time', 'views']

最后在urls.py里注册路由:

from rest_framework.routers import DefaultRouter from .views import ArticleViewSet router = DefaultRouter() router.register('articles', ArticleViewSet, basename='article') urlpatterns = router.urls

这样一个接口同时具备筛选、搜索、排序、分页能力。前端只需要在URL上拼接参数就行:

GET /api/articles/?category=2&search=django&ordering=-views&page_size=20&page=1

这条请求的完整处理流程是:先在过滤器中把category=2筛出来,再用SearchFilter把“django”在title和content里做模糊匹配,然后按views降序排序,最后每页20条返回第一页。整套逻辑用声明式配置实现,没有一行手写的if判断。

4.3 前端对接时需要注意的响应格式

当前端对接这个接口时,它拿到的数据是这样的:

{ "count": 156, "next": "http://localhost:8000/api/articles/?category=2&page=2", "previous": null, "results": [ { "id": 1, "title": "Django入门指南", "category_name": "Python", "author_name": "admin", "tags": ["Django", "教程"], "status": "published", "views": 2300, "created_time": "2025-01-15T10:30:00" } ] }

前端要根据page和page_size自己计算总页数,公式是Math.ceil(count / page_size)。如果你的前端用的是ElementUI的el-pagination,它期望的total字段就是count;如果你用的是Ant Design Pro,它甚至可以直接对接next和previous来做上一页下一页。我建议前后端联调之前,先把响应格式文档定下来,避免前端拿到数据才发现字段名对不上。

4.4 一整套优化清单:从查询到序列化的性能心法

到这里,功能已经全部实现,但上线前你还需要做一轮性能体检。我自己的优化顺序是这样的:

第一,检查queryset的关联加载。上面代码里我已经用了select_related和prefetch_related,但实际项目里最好打印一下SQL日志确认一下。第二,给筛选字段加索引。especially对status、category_id、created_time这种经常被过滤的字段,建索引后查询速度会明显提升:

class Article(models.Model): # ... class Meta: indexes = [ models.Index(fields=['category', 'status']), models.Index(fields=['created_time']), ]

第三,大数据量下考虑搜索引擎。当数据量突破百万级,django-filter配合数据库索引也扛不住深度分页时,就该考虑Elasticsearch这类方案了。不过那是另一个重量级话题,普通项目到不了那个量级之前,上面这套组合拳完全够用。

5. 常见问题速查表:分页与筛选的坑,我替你踩过了

这一节整理了我带项目时被问得最多、以及我自己踩过的高频问题。你可以收藏下来,遇到类似情况直接对着查。

问题现象原因解决方案
列表接口不分页一次返回全部数据settings里没配全局分页,视图也没指定pagination_class全局配置DEFAULT_PAGINATION_CLASS或在视图指定pagination_class
分页后关联字段查询暴涨接口响应慢,数据库压力大序列化器里访问了外键字段,产生N+1查询在get_queryset里用select_related/prefetch_related
前端传了page_size不生效每页固定10条,想改50改不了分页类没设置page_size_query_param自定义分页类,添加page_size_query_param和max_page_size
筛选参数不生效传了status=published,结果还是全部数据视图没加filter_backends,或者没指定filterset_class检查filter_backends = [DjangoFilterBackend]和filterset_class
搜索不区分大小写字母搜“Python”搜不到“python”SearchFilter默认用icontains,应该没问题,但如果自定义了精确匹配就有问题确认search_fields里字段的匹配方式,默认icontains无需改
排序时报错FieldError: Cannot resolve keywordordering_fields里写了模型里不存在的字段检查ordering_fields列表,只保留模型字段或关联字段双下划线
深度分页性能急剧下降第1000页比第1页慢几十倍PageNumberPagination深度翻页时OFFSET太大换成CursorPagination,或限制最大页码
筛选接口返回总数不准count显示全部数据filter_backends顺序有问题,或者筛选条件写在了get_queryset之外确保所有筛选逻辑都走filter_backends,并把DjangoFilterBackend放在第一位
用户只能看到自己的数据目前能看全部数据get_queryset没根据request.user过滤重写get_queryset,在返回前filter(user=self.request.user)

另外说一个容易被忽略的细节:在ViewSet里重写get_queryset时,一定要用self.action判断当前是列表还是详情。因为retrieve(查单个对象)的时候,DRF会调用get_object()再从queryset里取一条,你如果在这时候也加载了分页或额外筛选,会导致单个详情查不到。我一般这样处理:

def get_queryset(self): queryset = Article.objects.all() if self.action == 'list': queryset = queryset.filter(status='published') return queryset

凡是“列表跟详情行为不一样”的场景,都要记住这个self.action套路。同时要注意:重写get_queryset时,原来在类属性里写的select_related同样要保留,否则详情接口的关联查询也会出现N+1。

还有一个跟业务相关的点:如果文章有草稿和已发布两种状态,而你的筛选允许前端传status=draft,那实际上你把“草稿”也暴露给线上用户了。安全做法是:在前端能筛选的字段里去掉status,status只能在后台接口用,或者通过权限来控制。这个不光是API设计问题,更是数据安全问题。

最后再分享一个小技巧:调试分页和筛选时,别光看浏览器里的结果,直接从命令行用curl或者HTTPie带参数请求,能帮你快速定位是前端参数拼错了,还是后端筛选逻辑写错了。我经常用的一行命令是:

curl "http://127.0.0.1:8000/api/articles/?category=2&search=python&ordering=-views" | python -m json.tool

输出格式化了之后,一眼就能看出字段对应关系,排查效率比反复刷新浏览器高得多。这也是我这些年做接口调试攒下的经验——别急着在代码里加print,先确认请求参数和后端返回,80%的问题都出在这层皮上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进