Django-RESTful Api之ViewSets和Routers

这篇讲什么

  • ViewSet 把「按 HTTP 方法写函数」换成「按动作写方法」,Router 再用一行 router.register() 把列表、详情、增删改查的路由和反解名字全生成出来。
  • 这篇把 ViewSet 与 Mixin 的关系、不用 Router 时的手动映射、@action 自定义路由、反解与超链接逐个跑通,末尾列了实测踩到的新手卡点。实测环境 Django 5.2.19 + DRF 3.18.3。

目的

  • ViewSet 是比 APIView 抽象程度更高的一层:它不再按 HTTP 方法写函数,而是按动作(list / create / retrieve / update / partial_update / destroy)写,路由由 Router 自动生成。
  • 一个资源从「列表 + 详情 + 增删改查」到「URL + 反解名字」全部由一行 router.register() 搞定,代码量最少。
  • 本篇实测环境:Django 5.2.19 + DRF 3.18.3。

ViewSet 与 Mixin 的关系

  • 实测继承链:

    1
    2
    3
    4
    ModelViewSet 的 MRO: ['ModelViewSet', 'CreateModelMixin', 'RetrieveModelMixin',
    'UpdateModelMixin', 'DestroyModelMixin', 'ListModelMixin',
    'GenericViewSet', 'ViewSetMixin', ...]
    ReadOnlyModelViewSet 的 Mixin: ['ListModelMixin', 'RetrieveModelMixin', ...]
    • 也就是说 ModelViewSet = 五个 CRUD Mixin + GenericViewSet,不用自己写任何方法就能全套增删改查。
    • 只读接口用 ReadOnlyModelViewSet(只挂 list + retrieve,实测 POST 会 405)。
school/views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from rest_framework import permissions, viewsets

from school.models import Grade, Student
from school.permissions import IsOwnerOrReadOnly
from school.serializers import GradeSerializer, StudentSerializer


class StudentViewSet(viewsets.ModelViewSet):
queryset = Student.objects.all()
serializer_class = StudentSerializer
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly)

def perform_create(self, serializer):
serializer.save(owner=self.request.user)


class GradeViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Grade.objects.all()
serializer_class = GradeSerializer

手动映射方法(不用 Router 时)

  • ViewSet 的入口是 as_view({HTTP方法: 动作}):

    urls.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    student_list = StudentViewSet.as_view({"get": "list", "post": "create"})
    student_detail = StudentViewSet.as_view({
    "get": "retrieve", "put": "update", "patch": "partial_update", "delete": "destroy",
    })

    urlpatterns = format_suffix_patterns([
    re_path(r"^students/$", student_list, name="studentList"),
    re_path(r"^students/(?P<pk>\d+)", student_detail, name="studentDetail"),
    ])
  • 一个实测细节:actions 这个映射表是 as_view() 时才生成的,直接实例化 ViewSet 拿不到:

    1
    2
    hasattr(StudentViewSet, "actions"): False | 直接实例化 hasattr: False
    as_view({"get": "list"}) 之后才有: True | 内容: {'get': 'list'}

    调试时想看某个 ViewSet 支持哪些动作,就 SomeViewSet.as_view({"get": "list"}).actions 这样看。

Router:一行注册全套路由

school/urls.py
1
2
3
4
5
6
7
8
9
10
11
from django.urls import include, path
from rest_framework.routers import DefaultRouter

from school import views

router = DefaultRouter()
router.register(r"vs/students", views.StudentViewSet)
router.register(r"vs/grades", views.GradeViewSet)
router.register(r"vs/users", views.UserViewSet)

urlpatterns += [path("", include(router.urls))]
  • 实测 Router 生成的路由名与形状:

    1
    2
    3
    4
    5
    router 生成的名字: ['grade-detail', 'grade-list', 'student-detail', 'student-list',
    'user-detail', 'user-list']
    list 路由: ^vs/students/$
    detail 路由: ^vs/students/(?P<pk>[^/.]+)/$
    带后缀路由: ^vs/students/(?P<pk>[^/.]+)\.(?P<format>[a-z0-9]+)/?$
    • DefaultRouter 比 SimpleRouter 多做两件事:生成 API 根视图、自动加格式后缀路由。
    • 主键那段用 [^/.]+(排除点号),就是为了把 .json 让给格式后缀。
  • API 根视图在哪(实测)

    1
    2
    3
    4
    /api/vs/ : 404
    /api/ : 200 | {"vs/students": "http://testserver/api/vs/students/",
    "vs/grades": "http://testserver/api/vs/grades/",
    "vs/users": "http://testserver/api/vs/users/"}
    • 根视图挂在 include(router.urls) 的位置上,不是挂在 register 的前缀上;所以 router.register(r"vs/students", ...) + path("", include(router.urls)) + path("api/", include("school.urls")) 的根是 /api/。
  • basename 什么时候必须写(实测)

    1
    2
    3
    显式 basename 可用: ['g-list', 'g-detail']
    不写 basename 且没有 queryset: AssertionError `basename` argument not specified, and
    could not automatically determine the name from the viewset, as it does not have a `.queryset` attribute.
    • 用 viewsets.ViewSet(不带 queryset)或动态 get_queryset() 时,Router 推不出名字,必须显式 register("acts", ActViewSet, basename="act")。
  • 结尾斜杠:GET 会 301,POST 直接抛异常(实测)

    1
    2
    3
    4
    5
    6
    7
    8
    /api/vs/students    GET -> 301 | Location=/api/vs/students/
    /api/vs/students/ GET -> 200
    /api/vs/students/1 GET -> 301 | Location=/api/vs/students/1/

    POST 无斜杠直接抛: RuntimeError: You called this URL via POST, but the URL doesn't end in a
    slash and you have APPEND_SLASH set. Django can't redirect to the slash URL while
    maintaining POST data. Change your form to point to testserver/api/vs/students/
    (note the trailing slash), or set APPEND_SLASH=False in your Django settings.

    这是 Router 最容易踩的坑:Router 生成的路由都带结尾斜杠,客户端漏了斜杠时 GET 能被 APPEND_SLASH 救回来(301),POST/PUT 救不回来(301 会丢请求体),直接 500。前端调用一律写全斜杠。

@action:给 ViewSet 加自定义路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import viewsets


class ActViewSet(viewsets.ViewSet):
@action(detail=False, methods=["get"])
def stats(self, request):
return Response({"总条数": Student.objects.count()})

@action(detail=True, methods=["post"], url_path="mark")
def mark(self, request, pk=None):
return Response({"标记": pk})

@action(detail=True, methods=["get"])
def extra_info(self, request, pk=None):
return Response({"key": "extra_info"})
  • 实测 Router 为它生成的三条路由:

    1
    2
    3
    ('act-stats',       '^acts/stats/$')
    ('act-mark', '^acts/(?P<pk>[^/.]+)/mark/$')
    ('act-extra-info', '^acts/(?P<pk>[^/.]+)/extra_info/$')
    • detail=False → 挂在列表位置(acts/stats/),detail=True → 挂在详情位置(acts/<pk>/xxx/)。
    • url_path="mark" 可以改 URL 上的名字;方法名里的下划线在路由名里会被转成连字符(extra_info → 路由名 act-extra-info),反解时要用带连字符的名字。
    • @action 里 methods 不写默认只有 get。

反解与超链接

  • Router 生成的名字带 -list / -detail 后缀,反解写法:

    1
    2
    3
    4
    from django.urls import reverse

    reverse("school:student-list") # /api/vs/students/
    reverse("school:student-detail", kwargs={"pk": 8}) # /api/vs/students/8/
    • 应用 urls.py 里写了 app_name 时必须带命名空间(这条在《Django-视图》和《Django-RESTful-Api设计风格》里都实测过:不写就 NoReverseMatch,超链接序列化器还会整个接口 500)。
  • 想在 ViewSet 里拿到当前请求的绝对地址,用 self.get_request_url() / serializer.context["request"],别自己拼域名。

新手卡点清单

  • 都是实测踩过的:

    卡点 症状(实测原文) 正确做法
    客户端 POST 漏结尾斜杠 RuntimeError: You called this URL via POST, but the URL doesn't end in a slash... URL 一律写全 /students/
    viewsets.ViewSet 注册时不写 basename AssertionError: basename argument not specified ... register(prefix, ViewSet, basename="x")
    直接实例化 ViewSet 看 actions AttributeError: 'StudentViewSet' object has no attribute 'actions' SomeViewSet.as_view({"get": "list"}).actions
    以为 API 根在注册前缀下 实测 /api/vs/ 404,根在 /api/ 根视图在 include(router.urls) 的位置
    只读接口用 ModelViewSet 写操作全部开放 用 ReadOnlyModelViewSet
    @action 方法名带下划线却按下划线反解 NoReverseMatch 路由名里下划线变连字符(act-extra-info)
    在 @action(detail=True) 里不接 pk 拿不到对象 签名写 def mark(self, request, pk=None)
    Router + app_name 后超链接序列化器 500 ImproperlyConfigured Could not resolve URL ... "student-detail" extra_kwargs 里写 view_name="school:student-detail"

参考

本文结束 感谢您的阅读
正在加载今日诗词....