想学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),这就对了。接下来安装Django:
pip install django==4.2.*如果你在国内网络慢,可以加镜像源:
pip install django==4.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,加上blog:
INSTALLED_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/Shanghai'zh-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_length=100) def __str__(self): return self.name class Tag(models.Model): name = models.CharField(max_length=50) def __str__(self): return self.name class Post(models.Model): title = models.CharField(max_length=200) content = models.TextField() excerpt = models.CharField(max_length=200, blank=True) category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='posts') tags = models.ManyToManyField(Tag, blank=True, related_name='posts') author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts') created_at = models.DateTimeField(default=timezone.now) updated_at = models.DateTimeField(auto_now=True)每个字段我都想拆开讲一下,因为新手最容易在字段选择上犯错。
title用了CharField(max_length=200),CharField必须指定长度,这是数据库层面的硬性约束。content用了TextField,这种字段没有长度限制,适合存大段正文。excerpt是摘要,可以留空,用于列表页展示。category和tags分别用了外键和多对多。
这里有个关键问题:为什么分类用ForeignKey,标签用ManyToManyField?分类和文章的关系是“一篇属于一个分类,一个分类有多篇文章”,这就是典型的一对多,在数据库里体现为外键字段,文章表存一个category_id。标签和文章的关系是“一篇文章可以有多个标签,一个标签可以属于多篇文章”,这就是多对多,需要一张中间关系表来维护。这个设计思维是从业务出发的,不是随便拍的。
3.2 外键的on_delete到底怎么选
很多教程里会直接用on_delete=models.CASCADE,但不解释为什么。我多说一句:on_delete的意思是,当被关联的对象被删除时,当前对象怎么处理。CASCADE代表级联删除,比如某个分类删了,这个分类下的所有文章也一起删除。
这个行为要做两说。简单场景下挺好用,但在真实博客里,删错一个分类导致几十篇文章被连带删除是很惨的。我做这个小博客时可以先用CASCADE,因为逻辑简单。但如果你想模拟真实场景,可以用PROTECT,分类存在时不允许删除分类,强制你先把文章挪走再删。设计更新一点的项目,很多人也会用SET_NULL加null=True,分类删了文章保留但分类为空。这些选项没有绝对的对错,只看业务怎么要求。不过至少你要意识到,这个参数不是随手填的。
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.py:
from 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.py:
from django.urls import path from . import views app_name = 'blog' urlpatterns = [ path('', views.post_list, name='post_list'), path('post/<int:pk>/', views.post_detail, name='post_detail'), path('post/<int:pk>/delete/', views.post_delete, name='post_delete'), ]这里有个细节:<int:pk>是URL参数转换器。它的意思是匹配一段数字,并且把数字作为参数pk传给视图函数。你在浏览器访问/post/3/时,Django会把pk=3传递给post_detail视图。
为什么要用pk这个词?因为Django的模型默认主键字段叫id,在ORM里经常用pk代表主键(primary key),两者在这个场景下是同一个东西。用pk更通用,万一某个模型的键不叫id也能适配。这只是习惯问题,不是强制的。
4.2 视图函数怎么写:FBV方案
视图函数可以有两种形式:函数视图(FBV)和类视图(CBV)。对新手来说,我强烈建议先从FBV开始,因为它把逻辑摊开放在你面前,路径清晰。
打开blog/views.py:
from 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, pk=pk) return render(request, 'blog/post_detail.html', {'post': post}) def post_delete(request, pk): post = get_object_or_404(Post, pk=pk) 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表单里写一个method="post"按钮。
4.3 模板继承:告别复制粘贴
接下来创建模板文件。Django的模板查找顺序是:先去每个app的templates目录找,再去根目录的templates目录找。我们在blog/templates/blog/下创建文件。
首先,做base.html作为整个页面的地基。这里用到了模板继承,类似于搭了一个骨架,子页面只需填自己的内容块。
<!DOCTYPE html> <html lang="zh-hans"> <head> <meta charset="UTF-8"> <title>{% block title %}我的博客{% endblock %}</title> <link rel="stylesheet" href="/static/css/style.css"> </head> <body> <header> <h1><a 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> <h2><a 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 method="post" action="{% url 'blog:post_delete' post.pk %}"> {% csrf_token %} <button type="submit" onclick="return 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 %} </div>request.GET.get('page')从URL的查询字符串里取页码,比如/?page=3。如果用户手动输入?page=999,get_page会在页码超出范围时自动返回最后一页,不会报错;而page()方法会抛异常。所以我一直推荐get_page,它对用户输入更宽容。
5.2 详情页的上一篇/下一篇导航
博客详情页底部放“上一篇”“下一篇”是常规操作,实现方式也很简单。
修改post_detail视图:
def post_detail(request, pk): post = get_object_or_404(Post, pk=pk) prev_post = Post.objects.filter(created_at__lt=post.created_at).order_by('-created_at').first() next_post = Post.objects.filter(created_at__gt=post.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__lt=post.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_index=True,这是一个数据库经验:常作为排序和过滤条件的字段,值得加索引。
5.3 删除对象:delete()方法、级联与外键关系
热词里有一条“django执行查询-删除对象”,这里要单独展开。Django删除对象的执行方式可以分成两个层面。
单条对象删除:你先用get_object_or_404拿到对象,然后调用它的delete()方法。
post = get_object_or_404(Post, pk=pk) post.delete()执行完这一行,Django会执行一条SQLDELETE FROM blog_post WHERE id=1,并且在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(default=True)字段,删除时只是把标记置为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,真的值得单独强调。你在一张表单里写了method="post",但忘记写{% 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的惰性求值与N+1查询
最后讲一个性能相关的坑,这个坑在新手期可能感受不到,但迟早会撞上。
Django的QuerySet是惰性的。创建Post.objects.all()这个查询集时,并没有立刻去数据库执行SQL,直到你遍历它、调用list()、判断布尔值等等操作时,才会真正查询数据库。这对性能有好处,但也容易让人误解。比如:
posts = Post.objects.all() print(posts) # 这里才会执行SQL再比如模板中调用post.category.name。如果你在文章列表页循环显示每篇文章的分类名,而每篇文章都触发一次“查询分类”的SQL,那么N篇文章就有N+1次数据库查询。文章少时无感,文章一多页面就会明显变慢。
解决方式是使用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'])"自己跑一下对比,会看到查询次数从N+1变成了1次。
我自己带项目的经验是,Django学习里最喜欢卡住人的往往不是框架本身,而是环境、迁移、路径这些琐碎细节。博客系统这个项目虽然小,但该碰到的门槛基本都碰到了。你把以上这些坑都跳过去之后,后续加评论、加标签云、加Markdown渲染、加全文搜索都只是在这个骨架上长肌肉。最后再分享一个小技巧:多利用python manage.py shell和connection.queries观察实际SQL,这在任何阶段都不过时。