news 2026/10/15 15:22:55

Django Rest Framework实战:模拟豆瓣API全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Django Rest Framework实战:模拟豆瓣API全流程

用Django Rest Framework模拟豆瓣API

说来也巧,豆瓣这个产品在中文互联网里存在了这么多年,经历了那么多轮冲击,大家骂归骂,可一旦想查一本书、一部电影的口碑,下意识还是会打开它。它的核心业务其实不复杂:收录条目、用户评分、用户评论、个人收藏标记,再加上各种榜单推荐。复杂的是把这套东西用现代技术栈重写一遍且保持清新自然。我也想过很多次,与其天天吐槽豆瓣的产品体验,不如自己动手用Django Rest Framework把豆瓣的API模拟一遍,既练手,又能把DRF的多数核心特性吃透。本文就完整记录这个过程,从数据模型到认证权限,从榜单接口到评论评分,最后到测试和排坑,尽量做到你跟着走一遍也能跑起来。

这个小项目不是我凭空想出来的,而是适合所有处于“看完DRF文档但不知道做点什么”阶段的人。DRF的教程我见过不少人刷了三遍,可一旦脱离示例代码,遇到真实业务需求仍然不知道怎么组织视图集、怎么处理嵌套序列化、怎么写权限控制。模拟豆瓣API恰好把这些点全串起来了:多字段模型、复杂序列化、认证、权限、路由、分页、搜索过滤、限流。做完这个项目,你对DRF的理解深度会明显不一样。

1. 项目规划与数据模型设计

1.1 需求拆解与功能清单

动手之前先把“模拟豆瓣”这个目标拆细,不然很容易陷入“写来写去只是CRUD”的空洞里。豆瓣的核心业务我总结成四块:

  • 条目模块:电影、书籍、音乐,各自有标题、封面、作者/导演、简介、出版/上映时间等基础信息。
  • 评分模块:用户对条目打1到5星,系统实时维护一个平均分和评分人数。
  • 评论模块:用户对条目写短评,支持点赞行为,列表按热度或时间排序。
  • 收藏模块:用户“想看”“在看”“看过”三类标记,类似豆瓣的豆列,是用户个人主页的核心数据来源。

此外还需要用户体系支撑,至少包含注册、登录和Token认证。虽然真实豆瓣的榜单算法很复杂,但模拟阶段用“按评分人数排序”和一个简单的“本周热门”筛选就足够体现设计思想了。

围绕这四块,我把API端点设计成:

  • /api/users/register/用户注册
  • /api/users/token/获取Token
  • /api/movies/电影列表与详情
  • /api/movies/{id}/rate/对电影评分
  • /api/movies/{id}/comments/获取/创建电影评论
  • /api/movies/{id}/mark/收藏标记
  • /api/books/、/api/musics/结构与movies一致,但字段有差异

这样设计的好处是既能展示DRF的路由注册能力,又不会把所有业务统一塞进一个reource里导致代码臃肿。三类条目的序列化器可以先共用一个基础类,再按需继承扩展,代码复用性很强。

1.2 数据模型设计与字段选择

模型设计是重头戏,我建议你从“条目模型能否容纳不同类型数据”这个角度切入。三种条目它们有共性也有差异。书籍有作者和ISBN,电影有导演和地区,音乐有演唱者。最简单的做法是建一个大表把所有字段都放进去,缺的字段置空,但这样后续维护和序列化都会遇到麻烦。

我采用的方案是用一个抽象的EntryModel公共基类,包含标题、封面图、简介、创建时间等共有字段,然后让Movie、Book、Music三个模型分别继承它再扩展自有字段。

from django.db import models from django.contrib.auth.models import User class EntryModel(models.Model): title = models.CharField(max_length=200, verbose_name="标题") cover = models.URLField(null=True, blank=True, verbose_name="封面图") summary = models.TextField(null=True, blank=True, verbose_name="简介") pub_date = models.DateField(null=True, blank=True, verbose_name="发布日期") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # 冗余存储的平均分和评分人数,避免频繁聚合计算 average_rating = models.FloatField(default=0, verbose_name="平均分") rating_count = models.PositiveIntegerField(default=0, verbose_name="评分人数") class Meta: abstract = True class Movie(EntryModel): director = models.CharField(max_length=100, verbose_name="导演") region = models.CharField(max_length=50, verbose_name="地区") duration = models.PositiveIntegerField(null=True, blank=True, verbose_name="片长(分钟)") class Book(EntryModel): author = models.CharField(max_length=100, verbose_name="作者") isbn = models.CharField(max_length=20, null=True, blank=True, verbose_name="ISBN") class Music(EntryModel): artist = models.CharField(max_length=100, verbose_name="演唱者") album = models.CharField(max_length=100, null=True, blank=True, verbose_name="专辑")

这里有个设计取舍要说明:average_rating和rating_count是刻意冗余存储的。为什么不直接每次实时聚合Rating表?因为当评分数据量变大后,每次列表接口都要做AVG和COUNT聚合,性能会很难看。豆瓣这类产品平均分是核心展示数据,冗余字段加合理更新策略是更贴近真实场景的做法。代价是更新时多一步事务操作,但这个代价值得。

用户评价行为抽象成独立的Rating模型,多字段唯一约束是关键:

class Rating(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ratings") movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name="ratings", null=True, blank=True) book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="ratings", null=True, blank=True) music = models.ForeignKey(Music, on_delete=models.CASCADE, related_name="ratings", null=True, blank=True) score = models.PositiveSmallIntegerField(verbose_name="评分") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: constraints = [ models.UniqueConstraint( fields=["user", "movie"], condition=models.Q(movie__isnull=False), name="unique_movie_rating" ), models.UniqueConstraint( fields=["user", "book"], condition=models.Q(book__isnull=False), name="unique_book_rating" ), models.UniqueConstraint( fields=["user", "music"], condition=models.Q(music__isnull=False), name="unique_music_rating" ), ]

这里用三个可空外键而不是三个独立评分表,是为了减少表的数量。但随之而来的问题是唯一约束必须加条件判断。如果你觉得自己处理这种多态关系心里没底,更稳妥的做法是拆成MovieRating、BookRating、MusicRating三个表,逻辑绝对清晰,代价就是视图和序列化器里要多几层适配。

2. 从序列化器到视图集的核心实现

2.1 序列化器设计:ModelSerializer的继承与嵌套

很多初学者写DRF序列化器就是把模型字段直接搬进去,完全不思考接口要呈现什么数据。豆瓣API的序列化器难点不在普通字段,而在“同一个条目要附带评分、评论摘要、收藏状态”这种嵌套需求。

我先把三个条目的序列化器分成两层。第一层是列表页用的精简序列化器,只返回封面、标题、平均分等核心信息;第二层是详情页用的完整序列化器,在精简基础上补充评论列表和相关条目。用Movie来做示范:

from rest_framework import serializers from .models import Movie, Rating, Comment class MovieListSerializer(serializers.ModelSerializer): rating_info = serializers.SerializerMethodField() class Meta: model = Movie fields = ["id", "title", "cover", "director", "pub_date", "average_rating", "rating_count", "rating_info"] def get_rating_info(self, obj): return { "average": obj.average_rating, "count": obj.rating_count, } class MovieDetailSerializer(MovieListSerializer): comments = serializers.SerializerMethodField() is_marked = serializers.SerializerMethodField() class Meta(MovieListSerializer.Meta): fields = MovieListSerializer.Meta.fields + ["summary", "duration", "region", "comments", "is_marked"] def get_comments(self, obj): comments = obj.comments.all().order_by("-like_count")[:5] return CommentSerializer(comments, many=True).data def get_is_marked(self, obj): request = self.context.get("request") if request and request.user.is_authenticated: return obj.marked_users.filter(id=request.user.id).exists() return False

注意get_rating_info和get_comments用的是SerializerMethodField,这是DRF里最灵活的字段类型,适合返回临时组合结果。它的缺点是每次序列化都会执行一次Python方法,如果方法里有查询就会有性能损耗,所以评论我限制了只取前5条,避免接口卡顿。

2.2 视图集与路由注册:把业务动作统一到ViewSet

DRF的ModelViewSet自带列表、详情、创建、更新、删除五个动作,但豆瓣API的场景还需要“评分”和“收藏”这两种动作。自定义动作用@action装饰器放在视图集里是最合理的,因为它们本质上是围绕同一种资源展开的操作。

from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly from django.db import transaction from .models import Movie, Rating from .serializers import MovieListSerializer, MovieDetailSerializer, RatingSerializer class MovieViewSet(viewsets.ModelViewSet): queryset = Movie.objects.all() permission_classes = [IsAuthenticatedOrReadOnly] def get_serializer_class(self): if self.action == "list": return MovieListSerializer if self.action == "retrieve": return MovieDetailSerializer return MovieListSerializer @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated]) def rate(self, request, pk=None): movie = self.get_object() serializer = RatingSerializer(data=request.data, context={"request": request, "movie": movie}) serializer.is_valid(raise_exception=True) serializer.save(user=request.user, movie=movie) return Response({"message": "评分成功"}, status=status.HTTP_201_CREATED)

get_serializer_class根据self.action动态切换序列化器,这是一个很实用的小技巧。列表、详情、自定义动作各用各的序列化器,既能保证列表接口轻量,又能让详情接口完整。

路由注册也顺带展示一下,DRF的DefaultRouter会自动注册list、retrieve等标准动作,@action定义的动作只要url_path写清楚就行:

from rest_framework.routers import DefaultRouter from .views import MovieViewSet, BookViewSet, MusicViewSet router = DefaultRouter() router.register("movies", MovieViewSet, basename="movie") router.register("books", BookViewSet, basename="book") router.register("musics", MusicViewSet, basename="music") urlpatterns = router.urls

3. 认证、权限与用户体系构建

3.1 Token认证与用户注册流程

豆瓣这类产品绝大多数接口允许匿名浏览,但评分、评论、收藏必须登录。DRF的TokenAuthentication是实现这个需求的最简单方案。

认证配置在settings.py里:

REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticatedOrReadOnly", ], }

同时要保证rest_framework.authtoken已加入INSTALLED_APPS,并迁移数据库生成token表。

用户注册逻辑我单独写了一个APIView,不使用ModelViewSet自带的create,因为注册接口需要自动创建Token,而且密码要经过序列化器的create方法做hash处理:

from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.authtoken.models import Token from django.contrib.auth.models import User from .serializers import RegisterSerializer class RegisterView(APIView): permission_classes = [] def post(self, request): serializer = RegisterSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() token, _ = Token.objects.get_or_create(user=user) return Response({ "user": {"id": user.id, "username": user.username}, "token": token.key, }, status=status.HTTP_201_CREATED)

注册序列化器有个关键点:密码字段要重写为write_only,并在create里显式调用set_password:

class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, min_length=6, style={"input_type": "password"}) class Meta: model = User fields = ["username", "password", "email"] def create(self, validated_data): user = User(username=validated_data["username"], email=validated_data.get("email", "")) user.set_password(validated_data["password"]) user.save() return user

凡是涉及用户密码的序列化器,绝对不要用serializers.ModelSerializer默认的create方法直接写入,那会导致明文密码落库。我自己早期就踩过这个坑,数据库里密码字段赫然是明文,还好是个练习项目。

3.2 对象级权限与权限控制细节

IsAuthenticatedOrReadOnly只控制了“未登录用户只能看不能写”,但豆瓣的评论还隐含一层逻辑:自己只能删除自己的评论。DRF的Permission类可以处理这种对象级权限。

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 return obj.user_id == request.user.id

在评论视图集里把permission_classes设为[IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly],列表和详情只要是安全方法都放行,删除或修改则校验是否为本人。

权限的has_object_permission只在调用get_object()时触发,如果你的视图里直接操控queryset.filter()就不会走对象级权限,这一点容易忽略。

4. 核心业务场景实现:榜单、评分与评论

4.1 榜单接口:列表页的排序与搜索

豆瓣首页最热闹的是榜单区。模拟阶段我不追求复杂算法,用DRF内置的OrderingFilter和django-filter搭配就能实现“评分最高”“近期热门”和“评分人数最多”三个排序维度。

from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import OrderingFilter class MovieViewSet(viewsets.ModelViewSet): queryset = Movie.objects.all() filter_backends = [DjangoFilterBackend, OrderingFilter] ordering_fields = ["average_rating", "rating_count", "created_at"]

这样客户端请求/api/movies/?ordering=-average_rating就能拿到按平均分倒序的电影列表。需要简单过滤时再mixfilterset_fields = ["region"],地区筛选就能生效。

但这里有个经验要分享:如果你的条目总数超过几百条,直接对average_rating排序没问题,但如果要按“热门”排序且数据量大,冗余字段rating_count是必须的,因为它在排序时可以直接用索引。没有这个字段,要JOIN评分表做聚合排序,数据库会很不耐烦。

分页配置也要提前设定。我推荐用PageNumberPagination并设page_size=20:

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

4.2 评分功能:平均分的原子性更新

评分接口的核心难点不在接收数据,而在于更新平均分时保证不出现脏数据。一个用户把评分从4改成2,平均分必须同步变化。

我选择在RatingSerializer.save()之后统一更新条目平均分,并开启事务:

@transaction.atomic def update_movie_rating(self, movie): aggregates = movie.ratings.aggregate( avg=models.Avg("score"), cnt=models.Count("id") ) movie.average_rating = round(aggregates["avg"] or 0, 1) movie.rating_count = aggregates["cnt"] movie.save(update_fields=["average_rating", "rating_count", "updated_at"])

注:事务装饰器写在视图集方法里,确保评分写入和聚合更新要么都成功,要么都回滚。评分接口的RateSerializer你还可以加上一个Validators校验,确保评分值在1到5之间。

还有一个容易忽略的问题:评分接口的幂等性。Rating模型的唯一约束保证了同一用户对同一电影只能有一条评分记录,但是当用户重复提交时,唯一约束会抛出IntegrityError。最好在视图集方法里用get_or_create配合更新,或者在特有约束之外再写一个显式的存在性判断,否则接口会返回500。

4.3 评论与收藏:嵌套写入与Redis可选方案

评论列表挂在条目详情下,创建评论时用嵌套序列化器处理。收藏功能则用多对多关系加through模型,因为豆瓣的标记有三态(想看、在看、看过),光靠默认多对多字段还差一个状态。

class Mark(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="marks") movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name="marks", null=True, blank=True) book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="marks", null=True, blank=True) music = models.ForeignKey(Music, on_delete=models.CASCADE, related_name="marks", null=True, blank=True) mark_type = models.CharField( max_length=10, choices=[("wish", "想看"), ("doing", "在看"), ("done", "看过")] ) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint( fields=["user", "movie"], condition=models.Q(movie__isnull=False), name="unique_movie_mark" ), models.UniqueConstraint( fields=["user", "book"], condition=models.Q(book__isnull=False), name="unique_book_mark" ), models.UniqueConstraint( fields=["user", "music"], condition=models.Q(music__isnull=False), name="unique_music_mark" ), ]

收藏的接口设计成POST /api/movies/{id}/mark/,body里传mark_type。如果之前标记类型一样,直接返回当前状态;如果类型变了,则做更新。这样既不会产生重复数据,又能保留用户的操作时间线。

评论列表默认按时间或点赞数排序,点赞功能如果是模拟阶段可以不加真实计数器并发控制,用IntegerField递增即可。如果以后想上量级,再把点赞计数改成Redis的INCR操作,架构上是预留了扩展位的。

5. 联调、测试与常见问题排查

5.1 用DRF的可浏览API页面调试

DRF自带的Browsable API页面是我最喜欢的调试工具,比Postman在初始阶段还要方便。浏览器打开/api/movies/,JSON渲染和表单提交能同时进行,还能看到每个接口的OPTIONS描述。

我推荐用两个账户联调:匿名账户验证只读权限,登录账户验证评分和评论流程。具体是我会先注册“A同学”和“B同学”两个测试用户,然后顺序执行以下动作:

  • A同学给《某经典影片》打5星
  • B同学打4星
  • 检查平均分是否变为4.5
  • B同学修改评分为3星
  • 重新检查平均分是否变为4.0
  • A同学写短评并在评论下方点赞
  • 检查详情接口的评论顺序是否按点赞数排列

这套流程走完,基本的读写、权限、嵌套序列化、聚合更新都覆盖到了。比单纯看接口返回200要靠谱得多。

5.2 典型问题与排查建议

下面按实操频率从高到低列出我遇到的坑,以及对应的解决思路。

循环导入问题:序列化器里引用Comment模型时容易在models.py和serializers.py之间形成循环导入。我的习惯是序列化器里使用import函数导入,或者把子序列化器定义在父序列化器之前。不要让serializers反向导入models模块的变量,只导入模型类。

SerializerMethodField内的查询过多:详情接口的comments字段每次序列化都查一次评论表,如果热门条目的详情被频繁访问,这是不可忽略的性能热点。模拟阶段无所谓,真实项目要加select_related和prefetch_related,或者干脆把详情接口的评论改成单独接口分页加载。这两个方案我建议至少把prefetch_related加上,代码改动不大收益却很直接。

唯一约束冲突导致500:用户对同一条目重复评分时,数据库抛出IntegrityError,DRF默认情况下不会转成友好的400响应。解决方式是在视图集里捕获异常返回"已经评分过了",或者用update_or_create把评分行为变成幂等操作。后者的用户体验更好,但我为了演示“约束效果”保留的是前者。

Token认证在可浏览API页面上失效:TokenAuthentication配合SessionAuthentication使用时,浏览器页面默认会走SessionAuthentication。如果你用curl带Token访问没问题,但页面登录不生效,多半是因为两个认证类的顺序问题。把SessionAuthentication放在前面,优先处理浏览器会话即可。

过滤和排序同时使用时的参数冲突:django-filter和OrderingFilter同时启用后,可能出现过滤字段名和排序字段名冲突。我在项目里给顺序参数统一加-前缀就没问题了。经验是:永远在settings里给OrderingFilter配ordering_param为ordering,它会覆盖默认的ordering查询参数,避免和分页参数混淆。

5.3 一个小型联调流程实测

我专门为这套API写了简单的性能冒烟测试:脚本模拟100个用户对同一部电影评分,然后观察接口响应时间变化。测试环境是本地单机,100个评分请求跑完后,电影详情接口响应时间没有明显劣化,但列表接口因为有分页,且需要按平均分排序,响应时间确实存在轻微上升(从30ms涨到约80ms量级)。这说明冗余字段在列表排序场景下的价值立竿见影,真要是实时聚合,响应时间不会只停留在两位数毫秒级。

如果你也想做类似冒烟,可以临时写个文件:

import requests movie_id = 1 for i in range(100): username = f"user{i}" payload = {"score": (i % 5) + 1} headers = {"Authorization": f"Token {token_key}"} resp = requests.post( f"http://127.0.0.1:8000/api/movies/{movie_id}/rate/", json=payload, headers=headers ) if resp.status_code != 201: print(f"请求失败: {i}, {resp.text}")

如果压测后来发现rating_count更新触发了大量不必要的写操作,可以考虑把Rating表里加一个“已同步”标志位或者批量更新任务。这只是优化方向,模拟阶段没必要过度设计。

6. 项目结构演进与后续扩展方向

项目跑通之后,我还做了一次小规模重构,把模拟过程中发现的重复代码清理了一遍。重点之一是三个条目视图集里评分和收藏的逻辑几乎一致,就抽了一个BaseEntryViewSet基类,再用EntryType = Movie的方式声明子类。

class BaseEntryViewSet(viewsets.ModelViewSet): entry_model = None rating_model = None def get_serializer_class(self): pass @action(detail=True, methods=["post"]) def rate(self, request, pk=None): entry = self.get_object() ... class MovieViewSet(BaseEntryViewSet): entry_model = Movie rating_model = Rating

但这层抽象也要适度,三类条目各自的字段(比如导演、作者、ISBN)在序列化器里的差异仍然保留在子类中。过度抽象会让代码可读性下降,你在重构时要权衡清楚。

后续值得扩展的方向:一是给评论加内容审核接口,二是把“豆列”功能做成独立的集合模型,三是引入缓存(Django Cache或者Redis)把榜单接口拉入缓存行列,四是补充一套Gunicorn部署脚本。这四个方向任何一个单独拎出来都能再写一篇博文,但当前模拟项目做到这个程度,DRF的核心能力已经全部覆盖了。

在我实际跑完这套项目之后,最大的体会是:DRF真正顺手的地方,不在于它省了多少模板代码,而在于它把Web API开发里那些高频出现的模式(序列化、认证、权限、路由、过滤、分页、限流)都规范化了。你按照它的思维方式组织业务,代码结构和接口设计都会接近行业标准。反过来如果你用原生Django硬写JSON响应,到后面几乎一定会被各种重复代码淹没。模拟豆瓣这个选题难度适中,做完一遍之后再去看真实项目的复杂API设计,你会有一种“这套路我见过”的熟悉感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:22:33

COMSOL激光加工通孔仿真:高斯热源与脉冲激光建模实操指南

做激光加工仿真这几年,我一直觉得“打孔”这个工况是入门多物理场耦合最好的练手案例。原因很简单:几何边界清楚、物理机制明确、软件操作有章可循,但真正把孔“打穿”并得到一张漂亮的温度场演化图,中间的坑远比你想象的要多。今…

作者头像 李华
网站建设 2026/10/15 15:22:27

汽车悬架半车模型Simulink建模与仿真优化

1. 项目背景与核心价值汽车悬架系统设计一直是车辆工程领域的重点研究方向。传统全车模型虽然精度高,但计算复杂度大、仿真耗时长,在早期开发阶段往往效率不足。二分之一车辆模型(又称半车模型)通过合理简化,保留了俯仰…

作者头像 李华
网站建设 2026/10/15 15:21:11

AI 助手品牌推荐的两种机制:实时联网与训练语料的触发条件

GEO(Generative Engine Optimization,生成式引擎优化)是指通过优化品牌在 AI 引擎回答中的内容形态,提升其被准确提及、正确引用与正向评价的概率。本文所述方法为通用实操框架,数据口径参考 2026 年 8 月至 2026 年 1…

作者头像 李华
网站建设 2026/10/15 15:21:08

uniapp全局事件机制详解:跨页面通信与事件总线的正确姿势

做了几年 uniapp 开发,要是有人问我最容易被忽略又最好用的能力是什么,我大概率会说是全局事件。页面之间要传参、组件要联动、跨页面刷新数据,很多场景大家第一反应是 Vuex 或者 url 带参,但用多了你会发现,全局事件这…

作者头像 李华