
想学Django真不建议一上来照着电商项目或者社交平台那种大项目抄。我见过太多人装完环境就卡壳手里教程讲了一堆概念但连一个能看见的东西都没跑起来。这篇博客想做的事很简单用Django全栈开发一个博客系统从前到后带你走一遍完整的流程。博客这个题材称得上Django最好的入门练手项目有数据模型、有后台管理、有列表详情、有分页、有删除操作覆盖了Web开发里最常用的一整套链路但整体复杂度又控制在两三周能搞定。你不用先学前端三大框架也不用纠结接口怎么写跟着把项目建起来、把文章发出去、再把删文章的逻辑搞定Django的全栈开发流程就已经在你脑子里了。读完这篇文章你可以从零开始用大概一个周末的时间跑起来一个真实可用的博客系统。1. 技术选型为什么博客是Django全栈开发的最佳练手项目1.1 博客场景覆盖的知识点刚刚好我理解很多人选项目时会犹豫Todo清单太简单电商又太复杂最后夹在中间很难受。博客系统的妙处在于它的功能刚好踩在“入门”和“实战”的分界线上。一个基础博客需要什么文章列表页、文章详情页、分类、标签、发布时间、后台发布入口。这个需求清单看起来不长但每一块都对应Django全栈开发里的核心能力ORM建表、QuerySet查询、路由匹配、视图函数编写、模板继承渲染、表单提交、后台管理配置。多一块太多比如订单支付、购物车、库存管理那些东西对新手来说是负担而不是学习少一块又太少比Todo只多了一张表练不出什么手感。所以博客项目最适合作为第一桶金。能让你完整见识一个Web项目从零到能用的全过程又不会因为业务复杂而把注意力从Django本身移开。1.2 技术栈选择和版本说明做这个项目时我建议技术栈用最经典的一套Python 3.10、Django 4.2 LTS、SQLite数据库、原生模板系统加一点点CSS。不要一上来就上Django REST Framework也不要引入Vue或React。这句话我想多说几遍因为现在的学习资料动不动就全栈微服务架构看着很唬人但对于新手来说服务和模板渲染的方式才是理解Web开发最直接的路径。所谓“全栈开发”在Django语境下本来就是一个后端框架把路由、数据库、页面渲染全包了你先学会这一套再去拆前后端分离才顺理成章。Django 4.2是长期支持版本官方维护周期覆盖到2026年4月左右文档完善坑也基本被前人踩完了。数据库先用SQLite因为它是Django默认配置零安装零配置跑起来之后再用一个命令就能切到MySQL或者PostgreSQL。我做项目时有个习惯前期把精力全部放在核心逻辑上数据库这种基础设施能不折腾就不折腾。1.3 MVT架构理解Django的请求处理链路Django用MVT这个词Model、View、Template很多人一听就紧张觉得是不是和MVC不一样是不是要重新学一套理论。其实事情没那么玄乎。举个例子你在浏览器里输入一个网址回车的那一刻发生了什么Django先拿着URL去urls.py里匹配路由找到对应的视图函数。视图函数去Model层取数据这里的Model就是数据库表的映射你可以把它理解成一个带Python方法的数据库行。取到数据后视图把数据塞进Template模板里渲染成HTML最后把这个页面返回给浏览器。整个流程下去Model对应数据View对应业务逻辑Template对应页面展示。这个链路你一定要在脑子里画清楚因为后面所有代码都是往这条链路的某个环节里填东西。写模型时想的是表结构写视图时想的是“我这个页面需要什么数据”写模板时想的是“把这些数据放到哪个标签里”。一旦建立了这个思考习惯Django全栈开发对你来说就不再是一堆文件而是一条清晰的生产线。2. 环境准备与项目初始化5分钟跑通基础骨架2.1 Python环境安装与虚拟环境创建一切开始之前先把Python装好。这里有个容易踩的坑很多人电脑里同时存在Python 2和Python 3或者在macOS/Linux上系统自带了一个老版本Python。我建议你先在终端里跑一下python3 --version确认版本是3.8以上最好是3.10或3.11。如果版本太低后面Django 4.2的一些语法糖可能用不了你会遇到一些莫名其妙的报错。然后创建虚拟环境。为什么要虚拟环境因为不同项目依赖的包版本可能不同如果你全局安装Django装了这个版本那个项目又要那个版本版本冲突会非常痛苦。虚拟环境相当于给每个项目单独开了一个小房间互不干扰。mkdir blog_project cd blog_project python3 -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate激活后你会在终端行首看到(venv)这就对了。接下来安装Djangopip install django4.2.*如果你在国内网络慢可以加镜像源pip install django4.2.* -i https://pypi.tuna.tsinghua.edu.cn/simple装完验证一下python -m django --version看到版本号输出环境就准备好了。很多人问我“怎样安装Django最稳”我的答案永远是先把虚拟环境做好再pip安装不要直接用pip install django往全局环境里塞。2.2 创建项目和应用接下来用官方命令创建项目。这里解释一下两个概念的区别很多新手在这里容易懵project是整个网站工程app是项目里的一个功能模块。一个项目里可以有多个app比如博客系统这个项目里未来你可能会拆出blog文章模块、comments评论模块、accounts用户模块。现在我们先建一个项目和一个app。django-admin startproject mysite . python manage.py startapp blog注意项目名我用了mysite而不是blog因为blog要留给app使用避免名字冲突。创建完之后你的目录结构应该是mysite/ manage.py mysite/ settings.py urls.py ... blog/ models.py views.py ...用startapp创建的blog需要手动注册到项目里。打开mysite/settings.py找到INSTALLED_APPS加上blogINSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, blog, # 新增这一行 ]这一步如果你忘了后面做迁移时Django会一脸茫然表都不知道建到哪个模型下。2.3 基础配置语言、时区与静态文件创建好项目后有几处配置需要顺手改掉不然后面会很别扭。打开settings.py把LANGUAGE_CODE en-us TIME_ZONE UTC改成LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghaizh-hans会让Django后台变成中文界面省去你看着英文后台心累的时间。时区设置为上海这样DateTimeField保存的时间才是北京时间。另外在settings.py文件末尾追加静态文件配置后面加载CSS会用到STATIC_URL static/ STATICFILES_DIRS [ BASE_DIR / static, ]同时去项目根目录手动创建一个static文件夹。Django默认每个app里可以有一个static目录但为了统一管理我习惯在项目根目录放一个全局static目录模板里写的CSS、JS、图片都放这里。2.4 启动开发服务器验证“hello world”骨架搭好后先跑一下看看世界是否正常python manage.py runserver浏览器打开http://127.0.0.1:8000/如果是英文或中文的“Congratulations”说明项目通了。这时候很多人会想赶紧改页面写hello world。别急先把开发服务器的规律搞清楚——开发服务器会在你修改代码后自动更新但前提是代码语法正确如果语法错误终端会直接红字报错浏览器页面也会出现调试信息。这是Django开发阶段特有的福利部署到线上后这些调试信息就会被关闭因为会暴露敏感信息。到这里项目骨架成型。你已经完成了项目创建、app注册、基础配置、服务器启动四步接下来可以开始设计数据模型了。3. 数据模型设计把博客内容变成数据库表3.1 设计Post、Category、Tag三张核心表博客系统最核心的数据就是文章Post。我们打开blog/models.py开始写模型。from django.db import models from django.utils import timezone from django.contrib.auth.models import User class Category(models.Model): name models.CharField(max_length100) def __str__(self): return self.name class Tag(models.Model): name models.CharField(max_length50) def __str__(self): return self.name class Post(models.Model): title models.CharField(max_length200) content models.TextField() excerpt models.CharField(max_length200, blankTrue) category models.ForeignKey(Category, on_deletemodels.CASCADE, related_nameposts) tags models.ManyToManyField(Tag, blankTrue, related_nameposts) author models.ForeignKey(User, on_deletemodels.CASCADE, related_nameposts) created_at models.DateTimeField(defaulttimezone.now) updated_at models.DateTimeField(auto_nowTrue)每个字段我都想拆开讲一下因为新手最容易在字段选择上犯错。title用了CharField(max_length200)CharField必须指定长度这是数据库层面的硬性约束。content用了TextField这种字段没有长度限制适合存大段正文。excerpt是摘要可以留空用于列表页展示。category和tags分别用了外键和多对多。这里有个关键问题为什么分类用ForeignKey标签用ManyToManyField分类和文章的关系是“一篇属于一个分类一个分类有多篇文章”这就是典型的一对多在数据库里体现为外键字段文章表存一个category_id。标签和文章的关系是“一篇文章可以有多个标签一个标签可以属于多篇文章”这就是多对多需要一张中间关系表来维护。这个设计思维是从业务出发的不是随便拍的。3.2 外键的on_delete到底怎么选很多教程里会直接用on_deletemodels.CASCADE但不解释为什么。我多说一句on_delete的意思是当被关联的对象被删除时当前对象怎么处理。CASCADE代表级联删除比如某个分类删了这个分类下的所有文章也一起删除。这个行为要做两说。简单场景下挺好用但在真实博客里删错一个分类导致几十篇文章被连带删除是很惨的。我做这个小博客时可以先用CASCADE因为逻辑简单。但如果你想模拟真实场景可以用PROTECT分类存在时不允许删除分类强制你先把文章挪走再删。设计更新一点的项目很多人也会用SET_NULL加nullTrue分类删了文章保留但分类为空。这些选项没有绝对的对错只看业务怎么要求。不过至少你要意识到这个参数不是随手填的。3.3 迁移机制makemigrations与migrate的本质模型写好后需要在数据库里生成真实的表。Django的处理方式是两步走python manage.py makemigrations python manage.py migratemakemigrations是做“迁移预案”它把你models.py里的变化记录到blog/migrations/目录下生成一个迁移文件。你可以打开这个文件看看里面是Python代码描述的表结构类似数据库的“施工图”。migrate才是真正去数据库里执行这些变化建表、改字段、加索引都在这步完成。我建议一个非常实用的习惯每次改完models.py执行makemigrations之后先去终端看一眼它输出的内容。比如它会提示Operations to perform: Apply all migrations: blog Running migrations: Applying blog.0001_initial... OK如果报错通常是因为字段类型不合法或者关联了不存在的模型。这比直接闷头migrate更容易发现问题。执行migrate后验证一下表是否建好了。Django自带一个数据库命令行工具python manage.py shell进入交互式环境后输入from blog.models import Post Post.objects.all()如果返回QuerySet []说明模型正常工作表也建好了。这里也顺带验证了QuerySet最基本的用法——它是Django ORM返回的查询对象支持链式调用和懒加载。3.4 配置Admin后台快速发布内容做博客怎么能没有发文章的入口。Django自带一个后台虽然你后期可能会自己写发布表单但开发阶段先靠它把内容装进去。打开blog/admin.pyfrom django.contrib import admin from .models import Category, Tag, Post admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display (title, category, author, created_at) list_filter (category, tags) search_fields (title, excerpt) admin.site.register(Category) admin.site.register(Tag)list_display控制后台列表页显示哪些列list_filter让分类和标签可以作为筛选条件search_fields给标题和摘要添加搜索框。这些都是最常用的后台配置。然后创建超级管理员账号python manage.py createsuperuser按提示输入用户名、邮箱、密码。邮箱可以不填密码输入时终端不会回显字符你以为没打进去其实打进去了。启动runserver打开http://127.0.0.1:8000/admin/登录后你就能在后台里创建分类、标签、文章了。先随手建一个分类“编程”一篇文章《Django入门笔记》感受一下后台发文章的操作手感。这些数据后面会出现在前端页面上。4. 视图、路由与模板理解Django的请求处理链路4.1 先搞清URL配置和请求怎么进来Django处理请求的起点是urls.py。整个项目有一个根路由文件mysite/urls.py它的作用是“分管”把不同app的URL分发给各自的urls.py。我先设计博客需要的三个URL/文章列表页/post/1/文章详情页/post/1/delete/删除文章在mysite/urls.py里写from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(, include(blog.urls)), ]然后在blog目录下新建urls.pyfrom django.urls import path from . import views app_name blog urlpatterns [ path(, views.post_list, namepost_list), path(post/int:pk/, views.post_detail, namepost_detail), path(post/int:pk/delete/, views.post_delete, namepost_delete), ]这里有个细节int:pk是URL参数转换器。它的意思是匹配一段数字并且把数字作为参数pk传给视图函数。你在浏览器访问/post/3/时Django会把pk3传递给post_detail视图。为什么要用pk这个词因为Django的模型默认主键字段叫id在ORM里经常用pk代表主键primary key两者在这个场景下是同一个东西。用pk更通用万一某个模型的键不叫id也能适配。这只是习惯问题不是强制的。4.2 视图函数怎么写FBV方案视图函数可以有两种形式函数视图FBV和类视图CBV。对新手来说我强烈建议先从FBV开始因为它把逻辑摊开放在你面前路径清晰。打开blog/views.pyfrom django.shortcuts import render, get_object_or_404, redirect from .models import Post def post_list(request): posts Post.objects.all().order_by(-created_at) return render(request, blog/post_list.html, {posts: posts}) def post_detail(request, pk): post get_object_or_404(Post, pkpk) return render(request, blog/post_detail.html, {post: post}) def post_delete(request, pk): post get_object_or_404(Post, pkpk) if request.method POST: post.delete() return redirect(blog:post_list) return render(request, blog/post_confirm_delete.html, {post: post})post_list和post_detail是常规查询加渲染页面重点说post_delete。删除操作有一个非常重要的安全习惯不要用GET请求删除数据。因为GET请求会被搜索引擎爬虫、浏览器预加载、历史记录等各种因素触发如果一个链接点击就删除数据那真的非常危险。所以我这里用POST方法才执行删除只是单纯GET访问会进入一个确认删除的页面用户需要确认之后才真正执行。request.method POST是Django里最常见的HTTP方法判断POST通常代表客户端提交表单或数据。这样的删除交互逻辑其实就是HTML表单里写一个methodpost按钮。4.3 模板继承告别复制粘贴接下来创建模板文件。Django的模板查找顺序是先去每个app的templates目录找再去根目录的templates目录找。我们在blog/templates/blog/下创建文件。首先做base.html作为整个页面的地基。这里用到了模板继承类似于搭了一个骨架子页面只需填自己的内容块。!DOCTYPE html html langzh-hans head meta charsetUTF-8 title{% block title %}我的博客{% endblock %}/title link relstylesheet href/static/css/style.css /head body header h1a href{% url blog:post_list %}我的博客/a/h1 /header main {% block content %} {% endblock %} /main /body /html{% url blog:post_list %}是模板里反向解析URL的方法。它的好处是以后你改了URL路径所有模板里引用的链接会自动跟着变不用满文件找硬编码的/post/。对一个小项目而言这个细节可能体会不深但在大项目里这就是救命稻草。4.4 列表页和详情页模板渲染基础列表页post_list.html{% extends blog/base.html %} {% block content %} {% for post in posts %} article h2a href{% url blog:post_detail post.pk %}{{ post.title }}/a/h2 p{{ post.excerpt|default: }}/p p分类{{ post.category.name }} | 作者{{ post.author.username }} | {{ post.created_at|date:Y-m-d }}/p /article {% empty %} p还没有任何文章。/p {% endfor %} {% endblock %}这里注意两个模板语法{% for %}循环遍历了posts这个查询集{{ post.excerpt|default: }}用到了过滤器default当摘要为空时显示空字符串而不是显示“None”这种难看的输出。过滤器的存在让模板不必写太多判断逻辑把显示细节控制在模板层。详情页post_detail.html{% extends blog/base.html %} {% block content %} article h2{{ post.title }}/h2 p分类{{ post.category.name }} | 发布于{{ post.created_at|date:Y-m-d H:i }}/p div {{ post.content|linebreaks }} /div /article form methodpost action{% url blog:post_delete post.pk %} {% csrf_token %} button typesubmit onclickreturn confirm(确定删除这篇文章吗);删除文章/button /form {% endblock %}{{ post.content|linebreaks }}的作用是把文章里的换行符转成HTML的br或p标签。如果你直接输出文章内容换行会消失所有文字挤成一大坨。这个过滤器很多人一开始不知道写出来后发现排版烂得一塌糊涂。注意那个删除表单里必须有{% csrf_token %}。Django的安全机制强制要求POST请求带上跨站请求伪造令牌少了它Django会直接返回403页面。这个知识点会在后面常见问题里再次提到因为太容易踩中了。4.5 静态文件加载开发模式下的路径问题base.html里引用了/static/css/style.css。你需要创建这个文件mkdir static/css touch static/css/style.css写一点简单的样式比如正文居中、文章卡片有边框、按钮颜色之类。然后在settings里确认了STATICFILES_DIRS [BASE_DIR / static]这样开发服务器就会自动伺服/static/路径下的文件。这里有个坑如果模板里写了/static/css/style.css但settings没配STATICFILES_DIRS开发模式下也能跑因为Django自动伺服app里的static目录但你项目根目录下的这个static文件夹不会被伺服必须显式配置。这个问题在我带过的项目里出现过无数次每次都是模板引用的路径和实际文件位置对不上排查半天发现是压根没配置。5. 核心功能实战从文章列表到删除对象5.1 用Paginator给文章列表分页博客文章不可能永远只有几篇随着内容增加一次性查出所有文章会让页面越来越长数据库压力也越来越大。Django自带分页工具Paginator。修改post_list视图from django.core.paginator import Paginator def post_list(request): posts_all Post.objects.all().order_by(-created_at) paginator Paginator(posts_all, 5) # 每页5篇 page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, blog/post_list.html, {page_obj: page_obj})注意我把原来直接传到模板的posts改成了page_obj因为get_page返回的是一个当前页对象它内部封装了页码、上下页、当前页的数据列表等一堆信息。模板里要稍微改一下{% for post in page_obj %} ... {% endfor %} div {% if page_obj.has_previous %} a href?page{{ page_obj.previous_page_number }}上一页/a {% endif %} span第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页/span {% if page_obj.has_next %} a href?page{{ page_obj.next_page_number }}下一页/a {% endif %} /divrequest.GET.get(page)从URL的查询字符串里取页码比如/?page3。如果用户手动输入?page999get_page会在页码超出范围时自动返回最后一页不会报错而page()方法会抛异常。所以我一直推荐get_page它对用户输入更宽容。5.2 详情页的上一篇/下一篇导航博客详情页底部放“上一篇”“下一篇”是常规操作实现方式也很简单。修改post_detail视图def post_detail(request, pk): post get_object_or_404(Post, pkpk) prev_post Post.objects.filter(created_at__ltpost.created_at).order_by(-created_at).first() next_post Post.objects.filter(created_at__gtpost.created_at).order_by(created_at).first() return render(request, blog/post_detail.html, { post: post, prev_post: prev_post, next_post: next_post, })这里有个细节filter(created_at__ltpost.created_at)里的__lt是Django ORM的字段查找语法对应SQL里的小于号。order_by(-created_at)是倒序排列-号表示倒序。first()取结果集的第一条返回单个对象或None如果没有更早的文章就是None在模板里判断一下即可。模板里加div {% if prev_post %} a href{% url blog:post_detail prev_post.pk %}上一篇{{ prev_post.title }}/a {% endif %} {% if next_post %} a href{% url blog:post_detail next_post.pk %}下一篇{{ next_post.title }}/a {% endif %} /div这种查询方式对几篇文章的小博客够用但如果文章量非常大时间字段又没加索引性能会开始变差。到时候可以考虑给created_at加db_indexTrue这是一个数据库经验常作为排序和过滤条件的字段值得加索引。5.3 删除对象delete()方法、级联与外键关系热词里有一条“django执行查询-删除对象”这里要单独展开。Django删除对象的执行方式可以分成两个层面。单条对象删除你先用get_object_or_404拿到对象然后调用它的delete()方法。post get_object_or_404(Post, pkpk) post.delete()执行完这一行Django会执行一条SQLDELETE FROM blog_post WHERE id1并且在Post模型实例上发出pre_delete和post_delete信号。信号机制这里不展开但你可以理解为删除操作前后会有两个“钩子”其他代码可以插进来做一些清理工作。多条对象删除直接对QuerySet调用delete()。Post.objects.filter(category__name旧分类).delete()注意这个行为是批量删除Django对集合并发一条高效SQL删除不会为每个对象单独发出信号。但事务处理的粒度不一样性能上批量更好但如果需要逐条做日志记录你就得用循环。重点来了级联删除。如果你删掉了一个Category对象那么所有关联该分类的Post会发生什么取决于外键的on_delete设置。前面我们用的CASCADE所以删除分类时相关的文章会被一并清掉。这个行为你在本地测试时很容易看到category Category.objects.get(name编程) category.delete() # 这个分类下的所有Post也被删除了在生产环境里我通常不建议业务数据轻易使用CASCADE风险太高。真实场景一般用软删除也就是不真的删除数据而是给模型加一个is_active models.BooleanField(defaultTrue)字段删除时只是把标记置为False查询时统一过滤掉。这样即使误操作也能恢复。小博客项目可以不做软删除但你要知道有这条退路以后在真实项目中会用到。5.4 分页中的查询优化意识前面分页的分页器默认是惰性的它不会把全表一次性加载到内存而是每次只取当前页的数据。这依赖数据库层面的LIMIT和OFFSET。你可以打开日志或者用connection.queries查看实际SQL会看到类似SELECT * FROM blog_post ORDER BY created_at DESC LIMIT 5 OFFSET 0;页码跳到第2页时OFFSET变成5。这种分页在数据量小的时候没问题但如果几万篇文章越往后翻OFFSET越大查询会变慢。进阶解决方案是游标分页或按ID偏移这里不展开。我的意思是你在写一个小小的博客时就可以养成一个习惯——用Django的QuerySet时留意它生成的SQL这对你理解数据库交互非常有帮助。查看方式很简单在django shell里from django.db import connection from blog.models import Post Post.objects.all().order_by(-created_at)[0:5] print(connection.queries[-1][sql])你会直观看到ORM背后的SQL语句。6. 新手最常踩的坑安装、迁移、静态文件与查询排查6.1 “怎样安装Django”和PyCharm导入项目的坑“怎样安装Django”看似简单但我在不同环境里遇到过不少变种问题。第一个坑是系统自带Python和Anaconda环境冲突。明明在终端里pip install django成功了django-admin --version却提示找不到命令。原因多半是pip安装到了非当前环境或者Python不同版本之间的Scripts目录不在PATH里。解决方式不用全局安装先在项目里创建虚拟环境然后用python -m pip install django注意用python -m pip这能保证安装到当前激活环境的Python里而不是安装到某个模糊的全局路径。第二个坑是在PyCharm里“导入已建立好的Django信息系统”。很多人的操作顺序是别人发了一个项目压缩包自己在PyCharm里打开结果发现Python解释器不对Django没安装打开manage.py右键没有Run选项。你需要在PyCharm的Settings里把Project Interpreter指向项目里已有的venv路径或者在Python Interpreter设置里先把Django安装到解释器。另外确认一下File Settings Languages Frameworks Django勾选Enable Django support并把项目根目录设置正确。这个配置常常被忽略导致PyCharm识别不了Django项目结构。6.2 改了models.py却“没变化”新手最常见的一个操作是在数据库和模型之间搞不清同步逻辑。你改了models.py里的字段跑到页面上刷新发现数据库没变于是开始怀疑模型写法有问题。实际上Django的模型字段和真实数据库表并不是自动同步的你必须执行python manage.py makemigrations python manage.py migrate顺序不能反。还有一次我见过一个比较隐蔽的报错同一个模型的重命名和字段删除操作撞在一起makemigrations自动生成的迁移文件里既有删除old_name字段的操作又有新增new_name字段的操作数据库迁移到一半就卡住。解决办法是复用旧字段而不是删除重建或者在迁移里加入RenameField操作。遇到迁移冲突时不要硬删数据库表python manage.py makemigrations --check --dry可以帮你检查迁移是否一致。6.3 403 CSRF验证失败表单POST的必经门槛关于csrf_token真的值得单独强调。你在一张表单里写了methodpost但忘记写{% csrf_token %}Django会直接拒绝请求提示403 CSRF验证失败。这是Django的一个安全机制作用是防止其他网站伪造登录状态提交请求。解决方式很简单模板里的form标签内部加上{% csrf_token %}。如果你是通过AJAX提交还需要在请求头加上X-CSRFToken同时从cookie里拿到csrftoken。但我们的博客系统是全栈渲染不涉及AJAX你只需要记住一条规则所有POST表单必须带{% csrf_token %}。这比任何安全配置都基础。6.4 静态文件404开发与部署的路径不同静态文件404也是个高频问题。开发模式下你访问http://127.0.0.1:8000/static/css/style.css如果返回404先看在settings里有没有配STATICFILES_DIRS并确认BASE_DIR路径拼写是否正确。Windows上路径分隔符和Linux不同BASE_DIR / static在Windows下也能正常工作Django的pathlib处理了差异。部署时又是另一回事。runserver不适合当生产服务器静态文件也不应该由Django直接伺服。通常做法是使用whitenoise或者Nginx托管静态文件。我建议至少先理解这个概念开发时Django帮你伺服静态文件生产时你要配置专门的静态文件服务。不然直接把代码扔到服务器上页面会全部裸奔。6.5 QuerySet的惰性求值与N1查询最后讲一个性能相关的坑这个坑在新手期可能感受不到但迟早会撞上。Django的QuerySet是惰性的。创建Post.objects.all()这个查询集时并没有立刻去数据库执行SQL直到你遍历它、调用list()、判断布尔值等等操作时才会真正查询数据库。这对性能有好处但也容易让人误解。比如posts Post.objects.all() print(posts) # 这里才会执行SQL再比如模板中调用post.category.name。如果你在文章列表页循环显示每篇文章的分类名而每篇文章都触发一次“查询分类”的SQL那么N篇文章就有N1次数据库查询。文章少时无感文章一多页面就会明显变慢。解决方式是使用select_related或prefetch_related。posts Post.objects.select_related(category).all().order_by(-created_at)select_related适用于外键关联它通过SQL的JOIN把关联表的数据一次性查出来减少查询次数。prefetch_related适用于多对多和外键反向关联它用额外的查询把关联数据打包好供模板使用。对于博客这种查询场景select_related(category)就够了。一旦你养成在写视图时思考“模板里会用到哪些关联数据”的习惯就离一个合格的Django开发者不远了。python manage.py shell -c from blog.models import Post; from django.db import connection; list(Post.objects.select_related(category)); print(connection.queries[-1][sql])自己跑一下对比会看到查询次数从N1变成了1次。我自己带项目的经验是Django学习里最喜欢卡住人的往往不是框架本身而是环境、迁移、路径这些琐碎细节。博客系统这个项目虽然小但该碰到的门槛基本都碰到了。你把以上这些坑都跳过去之后后续加评论、加标签云、加Markdown渲染、加全文搜索都只是在这个骨架上长肌肉。最后再分享一个小技巧多利用python manage.py shell和connection.queries观察实际SQL这在任何阶段都不过时。