ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Django REST Framework视图家族与十大接口实战指南

Django REST Framework视图家族与十大接口实战指南 1. 从“能跑就行”到“接口规范化”DRF视图家族到底解决了什么问题刚接触Django REST Framework那会儿我写接口的方式特别原始——一个函数一个函数地堆每个视图里手动解析request.body手动拼JSON响应手动处理状态码。一个简单的用户列表接口光序列化就得写十几行遇到分页、过滤、权限校验更是重复劳动。后来被前辈点醒“你这不是在写REST API你是在用Django写PHP。”这句话刺激我去认真研究了DRF的视图家族和十大接口体系才发现之前写的代码有多“野路子”。DRF的视图家族本质上是一套渐进式的抽象层。从最底层的APIView到GenericAPIView再到各种Mixins最后到ModelViewSet和ReadOnlyModelViewSet每一层都在帮你封装重复逻辑。而所谓的“十大接口”指的是基于ModelViewSet配合DefaultRouter自动生成的那一套标准RESTful路由——增删改查各两个列表/详情再加上一些扩展动作刚好凑成十个左右的端点。这套东西用好了一个模型从建表到拥有完整API可能只需要二十行代码。这篇文章适合两类人一是刚学完Django基础、准备上手DRF但被各种View类搞晕的开发者二是已经在用DRF但还在用APIView硬写所有逻辑、想系统梳理视图家族体系的老手。我会从设计思路讲到实操细节把每个类的适用场景、参数配置、踩坑经验都摊开说清楚。你不需要从头到尾按顺序读但建议至少把“视图家族继承关系”和“十大接口映射表”这两块吃透后面遇到任何接口需求都能快速定位到该用哪个类。2. 视图家族全景拆解从APIView到ModelViewSet的进化逻辑2.1 为什么要有这么多层抽象很多新手看到DRF的视图类继承图会懵APIView、GenericAPIView、GenericViewSet、ModelViewSet……名字长得差不多到底有什么区别其实这背后是一条非常清晰的职责分离思路。APIView是最基础的入口它继承自Django的View但做了几件关键事把请求封装成Request对象支持request.data直接解析JSON和表单、把响应封装成Response对象自动内容协商、提供认证、权限、限流等钩子。你可以把它理解成“一个更懂REST的View”。但APIView不帮你做任何数据库操作get、post方法里该写什么还得自己写。GenericAPIView在APIView基础上加了三个核心属性queryset、serializer_class、lookup_field。有了这三个东西它就能自动帮你完成“获取查询集”和“获取序列化器”这两件高频重复的事。比如get_queryset()方法会根据queryset属性返回数据get_serializer()会根据serializer_class实例化序列化器。这一步抽象省掉了大量样板代码。再往上Mixins把具体的增删改查逻辑拆成了五个独立的类ListModelMixin、CreateModelMixin、RetrieveModelMixin、UpdateModelMixin、DestroyModelMixin。每个Mixin只负责一个动作比如ListModelMixin的list()方法就是“取查询集→分页→序列化→返回响应”这一套。这种设计的好处是组合灵活——你需要哪些功能就继承哪些Mixin不需要的就不继承。最后GenericViewSet和ModelViewSet把GenericAPIView和Mixins组合在一起。ModelViewSet继承了全部五个Mixin所以它天然支持列表、创建、详情、更新、删除五个动作。配合DefaultRouter注册后自动生成两套路由列表级和详情级这就是“十大接口”的来源。2.2 十大接口的精确映射关系所谓“十大接口”其实是一个约数核心是五个标准动作在列表级和详情级上的展开。我用一张表把路由、HTTP方法、Mixin方法、URL名称的对应关系列清楚序号URL模式HTTP方法Mixin方法动作含义路由名称1/users/GETlist()获取用户列表user-list2/users/POSTcreate()创建新用户user-list3/users/{id}/GETretrieve()获取单个用户user-detail4/users/{id}/PUTupdate()全量更新用户user-detail5/users/{id}/PATCHpartial_update()部分更新用户user-detail6/users/{id}/DELETEdestroy()删除用户user-detail7/users/{id}/set_password/POST自定义action设置密码user-set-password8/users/{id}/profile/GET自定义action获取用户档案user-profile9/users/statistics/GET自定义action获取统计信息user-statistics10/users/batch_delete/POST自定义action批量删除user-batch-delete前六个是ModelViewSet自动提供的后四个是通过action装饰器扩展的自定义动作。实际项目中这十个接口基本覆盖了90%以上的业务需求。剩下的10%可能是更复杂的嵌套路由或非标准动作那时候再考虑用APIView手写。注意PUT和PATCH的区别经常被混淆。PUT要求客户端提交完整字段缺失的字段会被置为默认值或报错PATCH只更新提交的字段。实际开发中建议优先用PATCH因为前端表单往往只改一两个字段用PUT容易误伤数据。2.3 路由注册的两种方式与选择依据DRF提供两种路由注册方式SimpleRouter和DefaultRouter。两者的区别很小但很关键DefaultRouter会额外生成一个API根视图访问/时显示所有注册的路由列表和格式后缀路由如.json、.api。SimpleRouter则更干净只生成你注册的资源路由。我的选择习惯是开发阶段用DefaultRouter因为根视图能快速查看所有接口方便调试生产环境用SimpleRouter避免暴露不必要的路由信息也减少格式后缀带来的内容协商复杂度。注册代码大概长这样from rest_framework.routers import DefaultRouter, SimpleRouter # 开发环境 router DefaultRouter() router.register(rusers, UserViewSet, basenameuser) router.register(rarticles, ArticleViewSet, basenamearticle) # 生产环境换成 SimpleRouter 即可 # router SimpleRouter()basename参数在queryset属性存在时可以省略但如果你在ViewSet里重写了get_queryset()且没有设置queryset属性就必须显式指定basename否则路由生成会报错。这个坑我踩过不止一次尤其是在用get_queryset()做多租户数据隔离的时候。3. 核心实操从零搭建一套完整的ModelViewSet接口3.1 模型与序列化器的准备工作假设我们要做一个文章管理系统的API。先定义模型from django.db import models from django.contrib.auth.models import User class Article(models.Model): STATUS_CHOICES ( (draft, 草稿), (published, 已发布), (archived, 已归档), ) title models.CharField(max_length200, verbose_name标题) content models.TextField(verbose_name正文) author models.ForeignKey(User, on_deletemodels.CASCADE, related_namearticles) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultdraft) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: ordering [-created_at] verbose_name 文章 verbose_name_plural verbose_name序列化器要处理几个细节author字段应该只读由当前登录用户自动填充status字段的展示需要可读化created_at和updated_at也应该是只读的。from rest_framework import serializers from .models import Article class ArticleSerializer(serializers.ModelSerializer): author_name serializers.CharField(sourceauthor.username, read_onlyTrue) status_display serializers.CharField(sourceget_status_display, read_onlyTrue) class Meta: model Article fields [id, title, content, author, author_name, status, status_display, created_at, updated_at] read_only_fields [author, created_at, updated_at]这里有个小技巧author_name和status_display是通过source参数从关联对象或模型方法中取值的只读字段。这样前端拿到的不只是author的ID还有可读的用户名省去了一次额外查询。3.2 ViewSet的完整实现与关键配置接下来是核心的ArticleViewSet。我会把常用的配置项都写上并解释每个配置的作用from rest_framework import viewsets, filters, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly from django_filters.rest_framework import DjangoFilterBackend from .models import Article from .serializers import ArticleSerializer class ArticleViewSet(viewsets.ModelViewSet): queryset Article.objects.select_related(author).all() serializer_class ArticleSerializer permission_classes [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] filter_backends [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields [status, author] search_fields [title, content] ordering_fields [created_at, updated_at] ordering [-created_at] lookup_field id lookup_url_kwarg id def perform_create(self, serializer): serializer.save(authorself.request.user) def get_queryset(self): queryset super().get_queryset() # 非管理员只能看到已发布的文章或自己的文章 if not self.request.user.is_staff: from django.db.models import Q queryset queryset.filter( Q(statuspublished) | Q(authorself.request.user) ) return queryset action(detailTrue, methods[post], url_pathpublish) def publish(self, request, idNone): article self.get_object() if article.author ! request.user: return Response({detail: 只有作者本人可以发布文章。}, statusstatus.HTTP_403_FORBIDDEN) article.status published article.save(update_fields[status, updated_at]) return Response({status: published}) action(detailFalse, methods[get], url_pathmy-articles) def my_articles(self, request): queryset self.get_queryset().filter(authorrequest.user) page self.paginate_queryset(queryset) if page is not None: serializer self.get_serializer(page, manyTrue) return self.get_paginated_response(serializer.data) serializer self.get_serializer(queryset, manyTrue) return Response(serializer.data)这段代码里有几个值得展开说的点。select_related(author)是性能优化的关键。因为序列化器里用到了author.username如果不做预加载列表接口会为每篇文章单独查一次用户表N1查询问题立刻出现。加上select_related后一次JOIN查询就能拿到所有数据。实测在100条文章的列表接口上响应时间从800ms降到了120ms左右。perform_create钩子是专门用来处理“创建时自动填充字段”的。DRF的CreateModelMixin在调用serializer.save()之前会先执行perform_create(serializer)所以在这里传入authorself.request.user是最干净的做法。不要试图在序列化器的create()方法里取request那样耦合太深。get_queryset()里的权限过滤是数据隔离的常见手段。注意这里用了Q对象做“或”条件非管理员用户能看到所有已发布文章加上自己的草稿。这个逻辑要根据实际业务调整但核心思路是永远不要相信前端传来的过滤参数数据权限必须在服务端强制过滤。3.3 自定义action的三种典型用法action装饰器是扩展ModelViewSet的利器它能在不破坏标准路由结构的前提下增加业务端点。根据detail参数的不同有三种典型用法detailTrue methods[post]针对单个资源的操作比如上面的publish。URL是/articles/{id}/publish/方法内通过self.get_object()获取当前文章实例。detailFalse methods[get]针对集合的操作比如my_articles。URL是/articles/my-articles/注意这个路由会注册在详情路由之前所以不会和/articles/{id}/冲突。detailTrue methods[get, put]同时支持多种HTTP方法的自定义动作。比如一个/articles/{id}/settings/端点GET返回设置PUT更新设置。实操心得action的url_path参数如果不指定默认用方法名下划线会转成连字符。但方法名如果是my_articles默认URL是my-articles。我建议显式写url_path避免方法名重构时路由跟着变导致前端404。4. 权限、过滤、分页的深度配置与避坑指南4.1 权限类的组合逻辑与自定义DRF的权限类是按“与”逻辑组合的——permission_classes列表里的每个权限都必须通过请求才被放行。IsAuthenticatedOrReadOnly的意思是读操作GET/HEAD/OPTIONS任何人可访问写操作必须登录。IsOwnerOrReadOnly则是读操作任何人可访问写操作只有对象所有者可以操作。这两个组合在一起效果是未登录用户只能看已发布文章登录用户可以看自己的草稿只有作者本人能修改或删除自己的文章。但这里有个细节IsOwnerOrReadOnly需要对象有author属性如果你的模型里所有者字段叫owner或user就需要自定义权限类from rest_framework.permissions import BasePermission, SAFE_METHODS class IsOwnerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True # 兼容不同的所有者字段名 owner getattr(obj, author, None) or getattr(obj, owner, None) return owner request.user注意has_object_permission只在get_object()被调用时触发也就是详情接口。列表接口的权限过滤要靠get_queryset()来实现两者缺一不可。很多人只写了has_object_permission就以为万事大吉结果列表接口把所有人的数据都返回了这是非常危险的安全漏洞。4.2 过滤后端的选择与性能考量DRF官方提供了SearchFilter和OrderingFilter但字段过滤需要额外的django-filter库。安装后在INSTALLED_APPS里加上django_filters然后在ViewSet里配置DjangoFilterBackend。filterset_fields适合简单的等值过滤比如?statuspublishedauthor1。如果需要更复杂的过滤逻辑比如范围查询、模糊匹配、多字段组合就要写FilterSet类import django_filters from .models import Article class ArticleFilter(django_filters.FilterSet): created_after django_filters.DateTimeFilter(field_namecreated_at, lookup_exprgte) created_before django_filters.DateTimeFilter(field_namecreated_at, lookup_exprlte) title_contains django_filters.CharFilter(field_nametitle, lookup_expricontains) class Meta: model Article fields [status, author, created_after, created_before, title_contains]然后在ViewSet里把filterset_class ArticleFilter替换掉filterset_fields。这样前端就能用?created_after2024-01-01title_containsdjango这样的组合查询了。性能提醒icontains在大表上会导致全表扫描因为LIKE %xxx%无法利用索引。如果文章表超过十万行建议改用全文检索方案或者至少给title字段加上数据库索引并限制搜索频率。4.3 分页配置的三种模式与选择DRF内置三种分页器PageNumberPagination页码分页、LimitOffsetPagination偏移分页、CursorPagination游标分页。它们的适用场景差异很大分页器参数格式优点缺点适用场景PageNumberPagination?page2page_size20简单直观前端好实现数据插入时页码会漂移后台管理系统LimitOffsetPagination?limit20offset40灵活可跳页深分页性能差需要灵活控制的场景CursorPagination?cursorxxx性能稳定无漂移不能跳页只能上下翻信息流、时间线全局配置在settings.py里设置REST_FRAMEWORK { DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 20, }如果某个ViewSet需要不同的分页大小可以单独设置pagination_class。我通常会在ViewSet里定义一个自定义分页类from rest_framework.pagination import PageNumberPagination class ArticlePagination(PageNumberPagination): page_size 10 page_size_query_param page_size max_page_size 100page_size_query_param允许前端通过?page_size50自定义每页数量max_page_size防止前端恶意请求超大页面导致数据库压力。这个配置在列表接口数据量大时特别重要我见过有人不设上限结果前端传了page_size100000直接把服务打挂的案例。5. 常见报错与排查技巧实录5.1 路由注册报错“basename argument not specified”这个报错通常出现在你重写了get_queryset()但没有设置queryset属性的时候。DefaultRouter在注册时会尝试从queryset属性推断basename如果找不到就报错。解决方法有两个要么在ViewSet里保留queryset Article.objects.all()即使你在get_queryset()里会覆盖它要么在router.register()时显式传basenamearticle。我推荐后者因为queryset属性如果保留但实际不用容易让后续维护的人困惑。显式传basename更清晰。5.2 序列化器字段验证失败但前端收不到具体错误DRF默认的错误响应格式是{field_name: [错误信息]}但如果你的序列化器里用了source参数指向了一个不存在的字段或者read_only_fields和fields列表有冲突可能会返回500而不是400。排查方法是先在Shell里手动实例化序列化器测试from .serializers import ArticleSerializer s ArticleSerializer(data{title: , content: test}) print(s.is_valid()) print(s.errors)如果is_valid()返回False但errors是空的检查是否有字段的source指向了模型上不存在的方法或属性。另外validate()方法里如果抛出了非ValidationError的异常也会导致500错误。5.3 自定义action的URL和标准路由冲突如果你定义了一个detailFalse的action方法名叫list或create会直接覆盖标准动作导致路由行为异常。同样detailTrue的action方法名如果叫retrieve或update也会覆盖标准详情动作。DRF不会报错但行为会变得不可预测。避免方法很简单自定义action的方法名统一加业务前缀比如publish_article、batch_delete不要用list、create、retrieve、update、destroy、partial_update这些保留名。5.4 分页和过滤同时使用时数据不一致当DjangoFilterBackend和分页器同时工作时过滤是在数据库层面执行的分页是在过滤后的查询集上执行的。但如果你在get_queryset()里做了Python层面的过滤比如列表推导式分页器拿到的查询集和实际返回的数据可能不一致。核心原则所有过滤逻辑尽量在数据库层面完成用filter()、exclude()、Q对象不要用Python的列表操作。如果确实需要Python层面处理就在list()方法里手动分页不要依赖DRF的分页器。5.5 常见问题速查表报错信息可能原因解决方法basename argument not specified没有queryset属性且未传basename显式传basename或保留querysetGot AttributeError when attempting to get a value for field序列化器字段的source指向不存在检查source路径用getattr兜底Method GET not allowed自定义action的methods参数没包含GET在action里加上methods[get]Invalid page分页参数超出范围设置PageNumberPagination的invalid_page_messageAuthentication credentials were not provided权限类要求登录但请求未带Token检查permission_classes和认证配置Cannot apply DjangoModelPermissions on a view that does not have .queryset用了DjangoModelPermissions但没有queryset设置queryset或改用其他权限类6. 从ViewSet到APIView什么时候该“降级”使用ModelViewSet虽然方便但不是银弹。有些场景下强行用ModelViewSet反而会让代码变得别扭。我总结了几种应该“降级”到GenericAPIView甚至APIView的情况。非CRUD为主的接口比如一个发送验证码的接口它不涉及任何模型的增删改查用APIView写一个post方法最直接。硬套ModelViewSet只会增加理解成本。需要高度定制响应结构的接口ModelViewSet的响应结构由序列化器决定如果你需要返回嵌套很深的聚合数据比如一个仪表盘接口同时返回用户信息、订单统计、消息数量用APIView手动组装响应更灵活。多模型联合操作的接口比如一个“下单”接口需要同时创建订单、扣减库存、生成支付记录。这种跨多个模型的事务操作用APIView配合transaction.atomic更清晰。性能极度敏感的接口ModelViewSet的通用流程会做一些额外的检查和处理虽然开销不大但在QPS极高的场景下手写APIView可以省掉不必要的序列化器实例化和权限检查。降级不代表倒退而是在合适的场景用合适的工具。我的经验是标准CRUD用ModelViewSet复杂业务逻辑用GenericAPIView配合Mixin完全自定义的用APIView。三者可以在同一个项目里共存通过路由分别注册即可。7. 接口文档自动化与联调效率提升视图家族配置好之后接口文档的生成就水到渠成了。DRF生态里有几个常用的文档方案我实际用过的是drf-spectacular它基于OpenAPI 3.0规范能自动从ViewSet和序列化器中提取参数、响应结构、权限要求。安装配置很简单pip install drf-spectacular在settings.py里注册INSTALLED_APPS [ # ... drf_spectacular, ] REST_FRAMEWORK { DEFAULT_SCHEMA_CLASS: drf_spectacular.openapi.AutoSchema, }然后在urls.py里加上文档路由from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns [ path(api/schema/, SpectacularAPIView.as_view(), nameschema), path(api/docs/, SpectacularSwaggerView.as_view(url_nameschema), namedocs), ]访问/api/docs/就能看到交互式文档每个接口的参数、请求体、响应示例都自动生成好了。action装饰的自定义动作也会被正确识别只要你在方法上加了extend_schema装饰器补充说明。实操技巧在action上配合extend_schema(summary发布文章, description将草稿状态的文章改为已发布)文档里就会显示中文说明。如果不加文档里显示的是方法名对前端联调不够友好。联调阶段我习惯让前端直接对着Swagger文档开发后端只需要保证文档和实际行为一致。drf-spectacular有个--validate命令可以检查schema是否符合OpenAPI规范建议加到CI流程里避免文档和代码脱节。8. 一些让我少踩坑的配置习惯最后分享几个我在多个项目里沉淀下来的配置习惯都是实际踩坑后总结的。统一响应格式DRF默认的响应结构是裸数据分页时又变成{count, next, previous, results}。我习惯用一个自定义的Response封装类把所有响应统一成{code, message, data}格式。虽然有人觉得这是过度设计但在前后端分离项目里统一格式能省掉大量前端判断逻辑。序列化器的to_representation慎用有些教程教人在to_representation里做字段裁剪或格式转换但这会让序列化器的行为变得难以预测。更好的做法是用SerializerMethodField或source参数保持to_representation的默认行为。ViewSet的queryset属性尽量保留即使你重写了get_queryset()保留queryset Model.objects.all()也能让DRF的某些内部机制正常工作比如权限推断、路由basename推断。如果担心误用可以在get_queryset()里加注释说明。测试用例覆盖每个actionModelViewSet自动生成的路由很容易让人忽略测试。我建议至少为list、create、retrieve、update、destroy各写一个测试用例自定义action也要覆盖。用DRF的APITestCase配合force_authenticate可以快速模拟不同权限的用户。版本控制从第一天就做DRF支持URL路径版本/api/v1/和请求头版本Accept: application/json; version1.0。我推荐URL路径版本因为直观且容易调试。在urls.py里用include()把不同版本的ViewSet分开注册后期升级接口时不会影响老客户端。这套视图家族体系我用了三年多从最初只会APIView硬写到后来熟练组合Mixins再到根据场景灵活选择抽象层级最大的体会是DRF的抽象不是让你偷懒而是让你把精力集中在业务逻辑上。标准CRUD交给ModelViewSet复杂逻辑用GenericAPIView定制特殊需求用APIView手写。三者配合才能既保证开发效率又不失灵活性。
RELATED READING

延伸阅读

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