尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Django实战:从投票应用到WebSocket实时推送与Admin美化全记录

Django实战:从投票应用到WebSocket实时推送与Admin美化全记录 1. 官网教程的起点我为什么放弃视频课拿投票应用当实战底座先说结论Django官网那份Writing your first Django app教程看起来幼稚实则是所有所谓Django实战项目的地基。我这次实战的最终结果——一个带实时推送、前后端联动、后台换肤的小系统——就是从官网那个投票应用polls长出来的。我不是没试过视频课。看视频有最大的问题它是线性的老师讲什么你听什么等你自己动手写代码时遇到一个报错前面讲的内容跟这个报错完全不相关你只能从头翻视频。官网教程不一样它是按你此刻正在做什么组织的。写模型就讲模型的迁移写视图就讲视图的渲染写表单就讲CSRF——完全跟着你的操作节奏走。学完之后你不光记住了语法还记住了Django遇到问题该去哪翻文档的肌肉记忆这件事比任何代码本身都值钱。另外官网教程最后会告诉你去看看Admin后台、把投票应用改造成生产级……这是一条明确的延展线索。我当时的想法很直接既然教程已经把MTV架构、ORM、Admin、表单处理都过了一遍我就把它当作种子项目往里面嫁接WebSocket推送和Admin换肤最后跑出来的结果就是一篇完整的实战记录。适合读这篇内容的人大概是这三类刚看完官网教程、但不知道下一步做什么的新手正在做Django项目想实现后台有数据变化前端实时收到提醒的朋友想折腾Admin后台颜值又不想自己写整套前端的管理员。下面的内容不绕弯子全程是我的操作记录和踩坑记录代码能直接抄但更希望你抄完以后能理解为什么这么写。2. 从零搭出投票应用创建项目与创建App的完整链路这一章节我会把官网教程第一步到第三步实际操作一遍补齐教程没细说的环境问题再讲清楚django-admin和manage.py的差别。2.1 环境准备我用的是Python 3.12和最新的Django 5.x动手前先确认版本。我用的Python是3.12Django是5.0系列。Django 5要求Python 3.10以上如果你还停在Python 3.8建议先升级不然后面装channels、django-unfold这些扩展库时依赖解析会相当痛苦。创建虚拟环境是必须的别偷懒把包装进全局环境。我在实战里用了Python自带的venv命令如下mkdir django_blog_project cd django_blog_project python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install django这里有个新手容易栽的坑激活虚拟环境后命令行前面会多一个(venv)前缀。有些朋友看到这个前缀没出现就直接pip install全部装进系统Python了等后面运行python manage.py runserver时又报找不到Django其实就是环境没配对。2.2 创建项目、创建App的顺序到底怎么理解官网教程里的命令是这样的django-admin startproject mysite cd mysite python manage.py startapp polls我当初看到这两条命令心里一直嘀咕为什么创建项目用django-admin创建app却用manage.py后来想明白了。django-admin startproject是在还没有任何Django配置的情况下创建一个完整项目的骨架包括settings.py、urls.py、manage.py。而startapp是在项目已经存在、需要往项目里添加一个功能模块时执行的它得让当前项目知道我要挂一个新的app所以必须用这个项目的manage.py来执行。manage.py其实就是django-admin的薄封装它会在执行命令前自动把项目的配置路径加到环境变量里。因此python manage.py startapp polls无论你在项目根目录的哪个位置执行都能正确找到配置。创建完polls之后不要急着写模型先去settings.py的INSTALLED_APPS把polls注册进去。这是新手最常见的问题模型建好了makemigrations时却提示No changes detected一半以上是因为app没有注册。# mysite/settings.py INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, polls, # 我新加的 ]2.3 项目结构哪些文件是实战时真正会频繁改的创建完项目后我习惯先把目录结构扫一遍。官网教程不会强调这个但实战多了以后你会发现mysite/settings.py、mysite/urls.py、polls/models.py、polls/views.py、polls/urls.py是改动最频繁的五个文件。需要特别注意的是在app内部创建一个urls.py是官网教程会教的好习惯但很多从视频课学过来的朋友不知道这件事——他们会把所有的URL都塞进项目的mysite/urls.py里。这样做的坏处是app一旦多了urls.py会变成一团乱麻而且app可复用的特性也被削弱了。我现在的习惯是一个app对应一个urls.py项目根路由用include()挂载。写好投票应用最简版的代码后我立刻跑了python manage.py runserver在浏览器里打开http://127.0.0.1:8000/polls/看到Hello, world. Youre at the polls index.的那一刻整个官网教程的创建项目与创建app阶段才算闭环。3. 模型、查询与删除官网教程没细讲但实战必考的内容官网教程的投票应用有Question和Choice两个模型一个ForeignKey关联。这个模型非常简单但Django ORM的很多关键行为都藏在这里面。我这一轮实战专门把查询和删除的细节彻底撸了一遍。3.1 模型设计的补充为什么不建议在ForeignKey上设nullTrue教程里Choice的ForeignKey用的是默认行为也就是说关联的Question被删除时所有Choice会被级联删除。这个行为在Django里叫on_deletemodels.CASCADE的默认语义实际上Django 2.0以后必须显式声明on_delete。我见过很多新手图省事把外键写成了question models.ForeignKey(Question, on_deletemodels.SET_NULL, nullTrue)理由是删除问题时不希望选项丢失。但实战中SET_NULL带来的麻烦是查询时要处理question为None的脏数据而且你根本没法判断一条数据到底是曾经关联的问题被删了还是本来就没关联。如果你做的是投票系统不希望投票数据被抹掉正确的做法是软删除——加一个is_active或deleted_at字段而不是依赖外键的null。这是我的模型设计# polls/models.py from django.db import models class Question(models.Model): question_text models.CharField(max_length200) pub_date models.DateTimeField(date published) def __str__(self): return self.question_text class Choice(models.Model): question models.ForeignKey(Question, on_deletemodels.CASCADE) choice_text models.CharField(max_length200) votes models.IntegerField(default0) def __str__(self): return self.choice_text3.2 执行查询Queryset是惰性的这个坑让我做实时推送时踩过一次Django ORM的查询集QuerySet是惰性的。什么叫惰性就是Question.objects.filter(pub_date__year2024)这个语句执行时并不会立刻去数据库查它只是返回一个查询集对象真正执行SQL是在你遍历它或者对它做求值操作时。这个特性在官网教程里几乎没强调但做WebSocket推送时我被它坑了一次。当时我在视图里写了这样一段代码questions Question.objects.filter(pub_date__year2024) # 这里questions没有被求值 # 然后我把questions传给了模板模板里渲染没问题因为模板引擎会迭代它。但如果在视图里先判断if questions:再遍历这个if就会触发一次数据库查询后面的遍历又触发一次——同一个查询集被执行了两次。数据量小无所谓数据量大时性能直接崩。我的建议是如果要对查询集做多次操作先强转为列表或者用list()强制求值再操作。question_list list(Question.objects.filter(pub_date__year2024))查询中还有个特性就是链式过滤。Question.objects.filter(...).exclude(...).order_by(...)每调用一次就产生一个新的查询集而不是修改原来的查询集。这一点倒是很符合函数式风格习惯之后写起来很顺手。3.3 删除对象delete()的返回值藏着玄机执行查询里的删除对象这部分官网教程压根没提但它真是面试和实战的高频点。Django删除对象用一个方法obj.delete()。我把它解剖给你看obj.delete()返回的是一个元组(total_deleted, {app.model: count})第一个值是总共删了多少行第二个值是按模型分组统计的删除数量。外键级联删除是数据库层面和ORM层面共同完成的行为你要是有跨表的关联删除时会一并把关联对象删掉。实战中我建议先在shell里模拟一下python manage.py shellfrom polls.models import Question, Choice q Question.objects.get(pk1) print(q.delete()) # 输出示例: (2, {polls.Question: 1, polls.Choice: 1})这里有个很隐晦的点如果你在模型里定义了ForeignKey(..., on_deletemodels.PROTECT)Django会拒绝删除该对象并且抛出ProtectedError。我在一个后台管理项目里用过PROTECT来保护审计日志表效果很好但要注意它和Admin里批量删除的交互——批量删除时遇到受保护对象会直接整体失败而不是跳过那几条。这一点无论官网教程还是文档都藏得很深你必须自己踩一次才能记住。3.4 实战中的查询优化select_related和prefetch_related官网教程的投票应用列表页每次显示问题时都会查一次关联选项N1查询问题就这样产生了。我在实战项目里把列表接口改成了prefetch_related一次把关联数据查出来。questions Question.objects.prefetch_related(choice_set).filter(pub_date__year2024)select_related用于单值外键比如Choice - Question它通过SQL的JOIN一次查出来prefetch_related用于多值关系比如Question - choice_set它通过额外一次查询和Python内存关联的方式解决。判断用哪个的简单方法外键用select_related反向外键和ManyToMany用prefetch_related。4. WebSocket实战后台有数据变化前端如何实时收到推送这是整个实战里最让我有成就感的部分。Django官方的投票应用是同步请求-响应模型用户刷新页面才能看到最新票数。但很多真实业务场景需要后台数据一变前端立刻弹个通知。这就得靠WebSocket加上Django的Channels库。4.1 为什么普通视图做不到实时推送必须上WebSocketHTTP是无状态的客户端不请求服务器就没法主动把数据推过去。虽然有轮询短轮询和长轮询这种伪装实时的办法但轮询的延迟、带宽浪费都很明显。WebSocket是单条TCP连接上全双工通信的协议。客户端和服务器只要完成一次握手之后两边随时可以互发消息。Django原生不支持WebSocket需要安装channels库它让Django跑在ASGI异步服务器网关接口而不是传统的WSGI上。安装命令pip install channels pip install channels-redis # 生产环境必须用Redis做channel layer官网教程完全没涉及这部分所以我在这里把每一步都讲得细一点。4.2 改造ASGI配置从WSGI到ASGI的切换安装完channels后要在settings.py注册channels并指定ASGI应用# mysite/settings.py INSTALLED_APPS [ # ... 其他app channels, polls, ] ASGI_APPLICATION mysite.asgi.application然后修改mysite/asgi.py注意不是默认生成的WSGI.py# mysite/asgi.py import os from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack from polls import routing as polls_routing os.environ.setdefault(DJANGO_SETTINGS_MODULE, mysite.settings) application ProtocolTypeRouter({ http: get_asgi_application(), websocket: AuthMiddlewareStack( URLRouter( polls_routing.websocket_urlpatterns ) ), })这里你会发现ProtocolTypeRouter可以同时处理HTTP和WebSocket请求。有了它你原来的Django视图依然能正常工作只是额外多了一个WebSocket入口。4.3 写一个Consumer接收前端连接并实时推送数据Consumer在Channels里的地位相当于视图在Django里的地位。我们需要在polls/consumers.py里写一个Consumer让前端连上后后台只要有新数据就推给前端。我最终选择了异步Consumer因为WebSocket本身就是异步协议用同步Consumer会阻塞事件循环。# polls/consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class VoteConsumer(AsyncWebsocketConsumer): async def connect(self): self.group_name votes await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, code): await self.channel_layer.group_discard(self.group_name, self.channel_name) # 该方法用于接收来自channel layer的消息然后发送给WebSocket客户端 async def vote_message(self, event): await self.send(text_datajson.dumps({ type: vote, question_id: event[question_id], choice_id: event[choice_id], votes: event[votes], }))再创建polls/routing.py# polls/routing.py from django.urls import re_path from . import consumers websocket_urlpatterns [ re_path(rws/votes/$, consumers.VoteConsumer.as_asgi()), ]4.4 后台有数据变化时怎么把消息发出去现在问题来了Consumer只负责接收连接真正后台有数据时的推送需要在投票发生后调用channel_layer.group_send。我改写了polls/views.py的vote视图# polls/views.py import json from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from .models import Choice csrf_exempt def vote_api(request, choice_id): if request.method POST: choice Choice.objects.get(pkchoice_id) choice.votes 1 choice.save() # 获取channel layer向组内所有连接发送消息 channel_layer get_channel_layer() async_to_sync(channel_layer.group_send)( votes, { type: vote_message, question_id: choice.question_id, choice_id: choice.id, votes: choice.votes, }, ) return JsonResponse({status: ok, votes: choice.votes})这里有一个很关键的注意点group_send是异步的但在同步视图里你得用async_to_sync把它包起来。如果视图本身就是异步函数那可以直接await channel_layer.group_send(...)。这个混用最容易绕晕人我的经验是项目没到一定复杂度之前坚持同步视图 async_to_sync逻辑清晰排查问题也方便。4.5 前端JS怎么连上WebSocket前端就一个static/polls/index.html里面放点JavaScriptconst ws new WebSocket(ws:// window.location.host /ws/votes/); ws.onmessage function(e) { const data JSON.parse(e.data); if (data.type vote) { const el document.querySelector(#choice-${data.choice_id} .votes); if (el) { el.textContent data.votes; } } };你可能会问为什么不用ws://localhost:8000而是用window.location.host因为项目换域名、换端口时前端代码不用跟着改。这个小习惯我是从生产环境踩坑里学来的。运行python manage.py runserver后同时打开两个浏览器窗口在一个窗口投票另一个窗口的票数会瞬间变化完全不需要刷新。这就是后台有数据前端推送的真实效果。4.6 生产环境必须处理的Redis Channel Layer本地开发时channels默认使用InMemoryChannelLayer所有消息都存在内存里重启服务就丢了。前后端同时连接没问题但一旦换成runserver多进程或者上生产环境InMemory模式会直接失效——进程间的channel layer不互通。所以实战项目一定要配置Redis# settings.py CHANNEL_LAYERS { default: { BACKEND: channels_redis.core.RedisChannelLayer, CONFIG: { hosts: [(127.0.0.1, 6379)], }, }, }安装channels-redis之后记得要把Redis跑起来。这一步不算难却是我见过最多人在部署阶段翻车的点。5. 后台管理界面改造Django Unfold到底该不该上官网教程会带你创建超级用户进入Django自带Admin后台。那个界面功能齐全但样式实在停留在十年前。我这次实战顺手尝试了Django Unfold一个基于Tailwind CSS重构的后台主题。5.1 安装Unfold并接入现有Admin安装很简单pip install django-unfold然后在settings.py的INSTALLED_APPS里把unfold放在django.contrib.admin之前INSTALLED_APPS [ unfold, django.contrib.admin, # 其他app ]这个顺序非常重要。Unfold是一个Admin site的替代品它需要在Django内置admin加载之前抢先注册模板和静态资源。如果顺序放错后台会直接白屏或者样式全乱。然后再修改polls/admin.py让Unfold接管# polls/admin.py from django.contrib import admin from unfold.admin import ModelAdmin from .models import Question, Choice admin.register(Question) class QuestionAdmin(ModelAdmin): list_display (question_text, pub_date) list_filter (pub_date,) admin.register(Choice) class ChoiceAdmin(ModelAdmin): list_display (choice_text, question, votes)对比原来的admin.ModelAdminUnfold的ModelAdmin提供了更现代的表格样式、搜索框和筛选器几乎零改造就能让后台焕然一新。5.2 Unfold的配置项侧边栏、主题色、仪表盘Unfold支持在settings.py里通过UNFOLD字典配置主题。我这里配置了深色侧边栏和品牌色# settings.py UNFOLD { SITE_TITLE: 投票后台, SITE_HEADER: 投票系统管理, SITE_URL: /, SITE_ICON: None, THEME: dark, SIDEBAR: { show_search: True, show_all_applications: True, navigation: [ { title: 内容管理, separator: False, items: [ {title: 问题列表, icon: question_answer, link: /admin/polls/question/}, {title: 选项列表, icon: fact_check, link: /admin/polls/choice/}, ], }, ], }, }这个配置上手很快但有几个限制要提前知道Unfold对自定义Admin视图依然支持但如果你完全重写了Admin的模板Unfold的样式可能会失效需要额外适配如果你使用第三方库自带的管理ModelAdmin比如django-import-export它的导出按钮和Unfold样式可能冲突。我当时的解决方案是保留原生的导出功能不去强行改Unfold源码。5.3 实测结果Unfold的颜值提升与性能代价跑起来之后后台加载速度明显比原生Admin慢一点毕竟多了Tailwind样式和前端脚本。但对于后台管理系统来说这点性能代价可以接受。真正的坑出现在我把Unfold和WebSocket推送功能同时跑起来时——后台每次保存Question前端会收到一条JSON推送消息而这条消息里带的pub_date是datetime对象不能直接json.dumps。古早的Django版本也有这个问题但Django 5里你还是得自己处理JsonResponse的序列化。一种做法是在视图里手动转换from django.core.serializers.json import DjangoJSONEncoder import json data json.dumps({pub_date: question.pub_date}, clsDjangoJSONEncoder)这个细节真的很关键不然你会在async_to_sync那一步突然遇到TypeError: Object of type datetime is not JSON serializable。6. 实战排错我遇到的三类最隐蔽的问题这一部分不是教程步骤而是我实战过程中真实遇到、花时间排查的问题。希望对正在做类似项目的你有帮助。6.1 时区问题为什么发布时间的日期对不上官网教程里settings.py默认的TIME_ZONE是UTCUSE_TZ默认是True。我一开始没改导致在Admin后台创建Question时显示的pub_date比本地时间慢了8小时。解决方法是改TIME_ZONE Asia/Shanghai。但请注意如果项目已经在生产环境跑过且数据表里已存在用UTC时间存储的时间戳改时区后新老时间混在一起处理起来非常麻烦。所以我的建议是项目一开始就明确时区配置推进到中途再改一定会碰脏数据。6.2 static文件404runserver下页面没样式多半是配置顺序问题我在跑Unfold后台时遇到过CSS加载不出来。排查后发现是自己写的一个自定义Admin视图没有把{{ MEDIA_URL }}和{% static %}模板标签用对。Django里static文件的路径处理是开发时由runserver自动托管你要确保STATIC_URL设置正确且模板里用{% load static %}。这个问题新手很容易忽略因为Django自带Admin在开发模式下几乎从来不报404但一旦模板里引用了自定义CSS或JS就立刻暴露出这个基础配置的薄弱点。6.3 Channels和Django Dev Server的冲突浏览器连不上WebSocket我在改完ASGI配置后浏览器一直报WebSocket connection to ws://127.0.0.1:8000/ws/votes/ failed。排查了很久最后发现是runserver没有以ASGI模式运行。这个坑的根源在于你虽然装好了channels但如果ASGI_APPLICATION配置有误或者你运行的还是旧的WSGI服务器WebSocket请求就不会被正确处理。正确的启动方式依然是python manage.py runserver但前提是channels必须被INSTALLED_APPS正确加载并且asgi.py的ProtocolTypeRouter里明确定义了websocket路由。我把排查步骤整理成一个表方便你按顺序检查现象检查点解决方案WebSocket无法握手settings.py是否配置了ASGI_APPLICATION添加ASGI_APPLICATION mysite.asgi.application连接能建但收不到推送Redis/Channel Layer配置是否正确用python manage.py shell手动测试group_send消息收到但JSON解析失败数据里是否含datetime、Decimal对象使用DjangoJSONEncoder统一序列化生产部署后连接断开是不是用了多进程但没配Redis换用channels_redis确保所有worker连接同一个Redis7. 实战结果复盘我拿到的东西不止是一个投票系统整个项目的目录结构最终是这样的mysite/ ├── manage.py ├── mysite/ │ ├── settings.py │ ├── urls.py │ ├── asgi.py │ └── wsgi.py └── polls/ ├── models.py ├── views.py ├── urls.py ├── consumers.py ├── routing.py ├── admin.py └── static/polls/和官网教程生成的原始结构相比多的就是consumers.py、routing.py和asgi.py的改造。这恰好对应了我这次实战的三个增量点实时推送、Admin美化、以及从同步到异步的思维转变。如果我给你一句浓缩的经验那就是官网教程只是把Django的地基给你画了一遍真正干活时你会不断地往上面盖楼。我这次盖的楼是WebSocket和Unfold但依然有无数楼层可以继续盖比如接入Celery做异步任务用Django REST Framework把接口改造成RESTful给投票应用加上用户权限系统等等。还有个从个人经验里得出的建议如果你也是零基础入门Django不要指望看完一遍官网教程就马上能写大型项目。最好的路径是像我这样——先把官网的投票应用完整跑通再选一个你最感兴趣的功能点实时推送、后台美化、接口化改造往上嫁接。这个过程里踩的坑比看二十遍教程都管用。最后再分享一个收尾的小技巧我做完这套实战后专门把Question模型的__str__方法写得更可读一些方便在Admin后台和WebSocket推送日志里快速识别对象。这种细节不算核心技术但能让你在调试的时候省下大量时间。Django官网上那句All the best projects are built by people who are passionate about what theyre doing大概就是这个意思——你投入进去把它当成自己的作品才能真正拿到实战结果。
返回列表