写博客系统这件事我前前后后做过不只一次。带新人的时候、自己练手的时候、给朋友搭个人站的时候几乎每次都是拿Django全栈博客当切入点。为什么因为一个博客系统虽然看起来简单但用户注册、登录、发文、列表分页、详情展示、删除数据、后台管理、前端渲染全都能碰到恰好把Django全栈开发的主干过了一遍。这篇就把我实际搭建过程中的完整思路、模型设计、关键代码、踩过的坑一次说清楚新手可以直接照着做有基础的朋友也能从里面捞到一些平时文档里不写的细节。1. 项目思路与整体设计1.1 博客系统为什么适合做全栈入门项目我经常被问到一个问题想学Django全栈第一站做什么项目比较好我的答案很固定——博客系统。原因是它具备一个Web应用的完整闭环有数据存储、有用户体系、有内容生产、有权限控制、有后台管理、有前端展示。麻雀虽小五脏俱全。做电商系统前置知识太多做企业级后台中间件和权限模型容易把人劝退做博客你得处理的业务逻辑刚好踩在全栈开发的核心技能线上但又不会复杂到让人崩溃。一个标准博客系统从用户注册登录开始到文章列表、文章详情、文章新增、编辑、删除再到后台文章管理整个过程几乎覆盖了Django从Model到View到Template的完整数据流。更重要的一点是博客系统天然适合渐进式迭代。第一版可以做简单的前后端不分离的Django全栈后续再扩展REST API、或者把前端拆成Vue/React这是很好的学习路径。我做第一版的时候只用了Django自带的模板系统半年后重构了一遍加了API接口和独立前端两次重构让我对Django的架构理解完全不一样了。1.2 功能范围怎么划不要一上来就贪大第一次做博客系统最容易犯的毛病是功能清单越列越长。评论区要做标签云要做RSS订阅要做搜索引擎优化要做甚至还想做多语言。我强烈建议第一版只保留四条核心链路。用户链路注册、登录、登出文章用户链路文章列表、文章详情、个人发文、编辑、删除后台链路Django Admin管理全站内容基础展示链路模板渲染、分页、Markdown内容支持别小看这个范围写完你会发现工作量已经不小了。我自己第一版贪心加了站内信功能写了两天最后发现跟博客主流程没关系又删了白白浪费时间。全栈入门的核心目标是理解Django的数据流和请求响应循环而不是把功能堆得多大。2. 环境准备与项目初始化2.1 版本选型Python和Django怎么配版本选型是我觉得新手最容易忽略、后续最头疼的事。2024到2025年这个节点我的推荐是Python 3.10以上Django选4.2 LTS版本。4.2是长期支持版官方支持周期长周边生态适配也好。如果追求新特性可以上Django 5.0系列但没必要博客项目用4.2就非常稳定。我用的是Python 3.11 Django 4.2的组合。选择一个LTS版本的好处是出了问题你搜索到的解决方案更成熟第三方库兼容性更好。比如我们用到的django-unfold后台美化库对4.2的支持就很完善。环境搭建用虚拟环境是必须的我习惯用venv或者pipenv但不管用什么项目依赖隔离是底线。实际环境准备流程大概是这样python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install Django4.2,5.0 pip install pillow这里pillow先装好后面做文章封面图片上传会用到。做博客系统的劝你一步到位装上否则后续加图片字段又要折腾一遍。2.2 创建项目和应用的规范动作项目和应用这两个概念新手常常混淆。我用一句话说透你建的Django工程是整站配置而app是模块化的业务功能块。博客系统我通常创建一个blog应用来放文章相关逻辑一个users应用放用户相关逻辑。虽然Django的auth模块已经带用户体系但独立出users应用便于以后扩展用户资料等需求。django-admin startproject blog_project . python manage.py startapp blog python manage.py startapp users注意我在startproject后面加了点号这样项目配置文件生成在当前目录不会多嵌套一层目录这一步很多人会漏掉。应用创建完记得去INSTALLED_APPS里注册这个坑太常见了漏掉之后执行makemigrations会提示No changes detected其实不是检测不到而是应用根本没加载。2.3 settings配置里容易踩的细节注册完应用之后settings里有几处我每次都会调整。首先是语言和时区中国区开发者一定把默认时区改掉LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghai USE_TZ True这里要说明一个容易误解的点USE_TZ True时Django存数据库的时间是UTC时间只有模板渲染时会转成TIME_ZONE指定的时区。很多新手看到数据库里的时间跟本地时间差8小时就以为是配置错了其实这是正常的。第二处是数据库配置。开发环境用默认的SQLite就够了我自己做博客第一版就是SQLite零配置、单文件、够用。等真正部署上线再接PostgreSQL也不迟。Django的ORM把数据库切换的成本降得很低前期不用纠结选型。静态文件和媒体文件的配置建议提前做好否则开发环境下图片会加载不出来MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static]3. 数据模型设计的关键决策3.1 核心模型文章、分类、标签模型设计是全栈开发的底层地基通常我会在动手写视图前先花半小时把模型理清楚。博客系统的核心模型就三个分类、标签、文章。分类是层次化的标签是扁平的文章是主体。文章模型的重点字段设计我直接贴出来每个字段的选择后面都会影响功能开发# blog/models.py from django.db import models from django.contrib.auth.models import User from django.utils.text import slugify class Category(models.Model): name models.CharField(分类名称, max_length20) slug models.SlugField(URL别名, max_length40, uniqueTrue) class Meta: verbose_name_plural 分类 def __str__(self): return self.name class Tag(models.Model): name models.CharField(标签名称, max_length20) slug models.SlugField(URL别名, max_length40, uniqueTrue) class Meta: verbose_name_plural 标签 def __str__(self): return self.name class Article(models.Model): title models.CharField(标题, max_length100) slug models.SlugField(URL别名, max_length120, uniqueTrue, blankTrue) author models.ForeignKey(User, verbose_name作者, on_deletemodels.CASCADE) category models.ForeignKey(Category, verbose_name分类, on_deletemodels.SET_NULL, nullTrue) tags models.ManyToManyField(Tag, verbose_name标签, blankTrue) content models.TextField(正文) cover models.ImageField(封面图, upload_tocovers/, blankTrue, nullTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) def save(self, *args, **kwargs): if not self.slug: self.slug slugify(self.title)[:120] super().save(*args, **kwargs) class Meta: ordering [-created_at] def __str__(self): return self.title这里有个关键点外键author指向Django内置的User模型on_deletemodels.CASCADE意味着用户删了文章一起删。但分类用的是SET_NULL因为我不希望删掉一个分类导致下面所有文章消失这个属于业务上的取舍没有标准答案看你想怎么处理数据关系。3.2 为什么文章要单独存一个slug字段我特意在文章里加了一个slug字段很多人第一版会忽略它直接用文章的id拼URL。像/article/3/这样开发是没问题但如果要做博客的搜索引擎优化URL里有语义化的slug比纯数字id友好得多。比如/article/django-queryset-delete/一看就知道内容主体是什么。我在save方法里自动用slugify生成slug但是要注意slugify对中文标题会转成空字符串所以中文标题的文章需要单独处理。我的处理方式是手动在后台管理时填一下英文slug或者生成一个随机短串兜底。这一点属于细节但开工前想清楚能省去后面改URL结构的麻烦。3.3 迁移和初始数据填充的执行顺序模型写完执行两条命令python manage.py makemigrations python manage.py migratemakemigrations是在生成迁移文件migrate才是真正把改动应用到数据库。很多新人混淆这两个命令习惯性只敲一条结果表没建出来还以为是模型写错了。开发阶段为了方便调试我还会写一个自定义管理命令创建几条测试数据。这里给个小技巧不写脚本的话直接在Django shell里操作更快python manage.py shellfrom blog.models import Category, Tag, Article from django.contrib.auth.models import User user User.objects.create_user(usernameadmin, passwordadmin123) cat Category.objects.create(nameDjango, slugdjango) tag Tag.objects.create(name后端, slugbackend) Article.objects.create(title第一篇博客, slugfirst-post, authoruser, categorycat, content欢迎光临)这种方式适合快速验证模型真正要批量造数据还是建议写脚本或直接后台录入。4. 用户系统与登录功能实现4.1 直接用Django内置认证还是自定义用户模型用户系统这块我的建议非常明确第一版项目直接用Django内置的User模型和认证视图不要自定义用户模型。虽然Django官方文档建议新项目一开始就自定义AbstractUser以便后续扩展但对一个入门项目来说这等于额外增加了复杂度。我自己做第一版博客就是从内置User开始的login、logout、authenticate这些API开箱即用你只需要在模板里处理一下表单状态就行。等之后真的需要手机号登录或者用户头像这种扩展再迁移也不迟。有一点值得注意如果决定使用自定义用户模型一定要在第一次migrate之前配置好AUTH_USER_MODEL否则后期切换用户模型是非常痛苦的。4.2 登录功能的两种实现方式登录功能是热词里反复出现的功能点我用两种方式都实现过这里对比一下。第一种直接用Django内置的视图代码量很少from django.contrib.auth.views import LoginView, LogoutView urlpatterns [ path(login/, LoginView.as_view(template_nameregistration/login.html), namelogin), path(logout/, LogoutView.as_view(), namelogout), ]这种写法的好处是快、稳不需要自己处理POST请求和表单验证。但缺点是如果你要往登录页加自定义字段比如记录登录日志就得扩展。第二种自己写视图函数灵活可控。实际项目里我更常用这种from django.contrib.auth import authenticate, login as auth_login from django.shortcuts import render, redirect def login_view(request): if request.method POST: username request.POST.get(username) password request.POST.get(password) user authenticate(request, usernameusername, passwordpassword) if user is not None: auth_login(request, user) return redirect(blog:article_list) return render(request, login.html, {error: 用户名或密码不正确}) return render(request, login.html)authenticate负责验证用户名密码login负责把用户状态写进session。很多新手分不清这两个函数的区别简单记authenticate是验身份证login是发通行证。两个一起用才是完整的登录流程。模板里要注意CSRF令牌Django的表单POST请求必须有{% csrf_token %}否则会报403。这个坑几乎每周都能在新手群看到一次。4.3 注册、登出和登录状态的页面联动注册功能相对简单用UserCreationForm就能快速搞定但默认表单的样式比较朴素我通常继承它加几个字段from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User class CustomUserCreationForm(UserCreationForm): email forms.EmailField(requiredTrue, label邮箱) class Meta: model User fields [username, email, password1, password2]登出功能要注意一个细节LogoutView在Django 4.1之后如果是POST请求方式需要显式配置登出后跳转地址。我自己写的是LOGIN_REDIRECT_URL和LOGOUT_REDIRECT_URL这两个settings项这样登录成功、登出成功自动跳转到首页省得在每个视图里写redirect。另外模板里区分登录与未登录状态非常关键。基模板里我会这样写{% if user.is_authenticated %} p欢迎{{ user.username }}a href{% url logout %}登出/a/p {% else %} a href{% url login %}登录/a a href{% url register %}注册/a {% endif %}注意user这里不是模板里凭空冒出来的是django.core.context_processors.request和auth上下文处理器共同作用的结果默认settings里是启用的所以模板才能直接访问user对象。5. 文章模块查询、展示与删除5.1 文章列表的分页查询文章列表是整个博客的流量入口也是Django ORM查询用得最密集的地方。第一版我打算用函数视图来讲解因为逻辑直观适合新手跟读。# blog/views.py from django.core.paginator import Paginator from django.shortcuts import render from .models import Article def article_list(request): article_list Article.objects.select_related(author, category).prefetch_related(tags) paginator Paginator(article_list, 5) page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, blog/article_list.html, {page_obj: page_obj})这里我做了两个性能优化select_related和prefetch_related。前者用于外键关联也就是作者和分类它是通过SQL的JOIN一次性查出来的后者用于多对多关联也就是标签它会先查文章再查标签关系用第二条查询把所有标签捞出来。没有这两个优化在页面上循环打印作者和标签会触发N1查询文章多的时候页面会很慢。分页器的用法是先实例化Paginator把文章列表和每页数量传进去然后根据请求里的page参数拿当前页数据。get_page方法比page更安全传非数字页码时不会抛异常返回的是第一页内容这个细节我挺喜欢。模板里需要处理上一页下一页链接{% for article in page_obj %} h2a href{% url blog:article_detail article.slug %}{{ article.title }}/a/h2 p{{ article.created_at|date:Y-m-d }}/p p{{ article.content|truncatechars:100 }}/p {% endfor %} {% 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 %}truncatechars是Django自带模板过滤器截断字符串到指定长度对列表页非常实用。5.2 文章详情页与URL设计文章详情页用slug做URL参数比用id好看也好记。URL配置和视图这样写# blog/urls.py from django.urls import path from . import views app_name blog urlpatterns [ path(, views.article_list, namearticle_list), path(article/slug:slug/, views.article_detail, namearticle_detail), ]from django.shortcuts import get_object_or_404 def article_detail(request, slug): article get_object_or_404( Article.objects.select_related(author, category).prefetch_related(tags), slugslug, ) return render(request, blog/article_detail.html, {article: article})get_object_or_404是Django里特高频的工具它的作用翻译过来就是查不到就直接返回404。这里我把查询优化也加上了这是我从经验里养成的习惯——只要查询一个对象顺手把可能用到的关联关系都带上这样模板里无论怎么访问关联字段都不会触发额外查询。URL里的slug:slug是路径转换器会匹配字母、数字、连字符和下划线组成的字符串刚好匹配我们模型里的SlugField类型。5.3 Django ORM删除对象实战不只是delete()热词里专门提到了Django执行查询和删除对象说明很多人对这个环节感兴趣。ORM删除操作表面上就是一行article.delete()但实际上有几个细节值得展开讲。删除单个对象直接用实例方法article Article.objects.get(slugdjango-queryset-delete) article.delete()当你在视图中需要按条件批量删除用QuerySet的delete()方法Article.objects.filter(authorrequest.user, category__slugdraft).delete()这里filter后面跟的category__slug是跨外键查询双下划线是Django ORM的灵魂操作符——__用来表示穿透关系category__slug的意思是文章所属分类的slug字段等于某个值。对应的SQL大概是JOIN blog_category ON ... WHERE category.slug draft。批量删除时最需要警惕的是on_deletemodels.CASCADE的外键关联会把关联数据一并删掉。比如我删掉一个作者他的所有文章都没了。如果只是想把文章从某个作者名下摘除把外键置空就需要SET_NULL。这个在上文模型设计里已经铺垫过这里再强调一遍删除操作是Django中对数据影响最大的操作之一没有之一。视图里实际写删除逻辑必须注意请求方式。不要用GET触发删除否则搜索引擎爬虫或者预加载工具路过你的URL就直接把文章删了。正确的做法是用POST表单提交删除动作from django.shortcuts import redirect, get_object_or_404 from django.contrib.auth.decorators import login_required login_required def article_delete(request, slug): article get_object_or_404(Article, slugslug, authorrequest.user) if request.method POST: article.delete() return redirect(blog:my_articles) return render(request, blog/article_confirm_delete.html, {article: article})删除前加一个确认页让用户二次确认也是保护数据的有效手段。我的经验里见过太多误删案例尤其开发阶段手滑在admin后台把测试数据全删了全靠加确认页兜底。6. 后台管理优化与前端页面组装6.1 用Django Unfold提升后台体验几乎所有Django项目的后台行政管理系统都是默认的Admin朴素但功能齐全。如果你想让后台看起来更现代我推荐用Django Unfold这个第三方库。它是一套专门美化Django Admin界面的主题系统安装和配置非常简单。pip install django-unfold配置在INSTALLED_APPS里有个顺序讲究必须把unfold放到django.contrib.admin前面INSTALLED_APPS [ unfold, django.contrib.admin, ... ]然后在admin.py里把文章注册进去并对后台展示稍作定制from django.contrib import admin from .models import Article, Category, Tag admin.register(Article) class ArticleAdmin(admin.ModelAdmin): list_display (title, author, category, created_at) list_filter (category, tags) search_fields (title, content) prepopulated_fields {slug: (title,)} date_hierarchy created_atprepopulated_fields是新手很容易忽略的功能后台填标题时slug会自动填充成标题的slug形式省去手打英文别名。不过前面说过中文标题的情况需要手动补这是Django的slugify对中文支持不够导致的不是你的代码问题。用了unfold之后后台整体风格会现代许多侧边栏、卡片布局都更精致。这套库的项目维护活跃度不错我实测在Django 4.2上没遇到兼容问题。6.2 模板继承从一个base.html开始Django模板系统最有价值的设计就是继承机制。一个好的base.html能让你全站页面风格统一维护成本骤降。我的博客项目基模板核心结构是!-- templates/base.html -- !DOCTYPE html html langzh-hans head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}我的博客{% endblock %}/title /head body header nav a href{% url blog:article_list %}首页/a {% if user.is_authenticated %} a href{% url blog:my_articles %}我的文章/a {% endif %} /nav /header main {% block content %} {% endblock %} /main /body /html子模板只需要两句话{% extends base.html %} {% block content %} ... 页面主体 ... {% endblock %}这种模式一定要从一开始就养成别等页面多了再重构。我见过直接把所有页面写成独立HTML的项目改一个导航栏要全站文件翻一遍那种痛苦吃过一次就再也不想吃了。6.3 Markdown内容的渲染博客正文如果只支持纯文本写起来太痛苦。我建议直接在模板里用Markdown渲染。做法是安装markdown库然后在视图中转换pip install markdownimport markdown def article_detail(request, slug): article get_object_or_404( Article.objects.select_related(author, category).prefetch_related(tags), slugslug, ) article.content_html markdown.markdown(article.content, extensions[extra, codehilite, fenced_code]) return render(request, blog/article_detail.html, {article: article})这里要注意markdown.markdown接收的是原始文本在视图中转换比在模板中转换更容易控制。同时直接输出HTML可能导致XSS风险解决方案是最好在模型层提供一个带有没有XSS过滤的属性或者借助其他工具做净化。入门阶段先了解这个方向等真做上线部署时再补上严格的过滤逻辑。我在文章正文中存的是Markdown源码渲染时才转换数据层保持干净这是比较合理的方案。7. 常见问题排查与避坑实录7.1 迁移、数据库相关的诡异报错开发过程中最容易遇到的报错第一是Table already exists第二是No such column。前者通常是因为你已经migrate过但迁移文件被人为删了后者则是迁移文件和数据库状态不同步。我的排查套路很简单先跑python manage.py showmigrations看哪些迁移没有执行再用python manage.py migrate同步。如果开发阶段数据不重要最干脆的办法是清掉数据库重新migrate或者把有问题的app的迁移文件重置。生产环境千万别这么干但开发环境怎么快怎么来。7.2 静态文件加载不出来的真凶开发环境配好了STATICFILES_DIRS,结果页面还是裸奔没有样式。九成原因是请求URL写错了或者用python manage.py runserver默认没生效。Django的runserver在DEBUGTrue时理论上会自动服务静态文件但模板里的静态资源路径要用{% static css/style.css %}这种写法不能直接写/static/css/style.css否则换到生产环境就乱了。{% static %}标签需要模板顶部加载{% load static %}。另外如果按照前面的base.html方案每个页面继承base只需要在基模板里{% load static %}一次就行子页面不用重复加载。这是Django模板系统一个隐形但又很省事的细节。7.3 时区问题带来的发布时间错乱博客系统对发布时间很敏感发布时间的坑在文章列表的排序和显示上最典型。在使用USE_TZ True的前提下你往数据库里写入created_at时Django会自动转为UTC存储。读取的时候如果不用模板过滤器而直接打印article.created_at出来的可能是UTC时间跟北京时间差了8小时。解决方法就是在模板里结合date过滤器输出{{ article.created_at|date:Y-m-d H:i }}Django的模板渲染在输出DateTimeField时会自动转换为TIME_ZONE设定的时区所以只要设置对了TIME_ZONE Asia/Shanghai模板显示的通常就是本地时间。但如果直接在Python代码里打印时间或在API里返回原始时间就需要注意时区转换。有一年我做数据导出时就是因为忽略这个细节导出来的CSV时间整整偏移8小时排查了很久。7.4 文章删除后外键数据丢失的教训最后分享一个我真实踩过的坑。有次我做数据清理直接用User.objects.all().delete()清测试用户结果把文章表全清空了。虽然当时只是测试数据但这个教训让我记住了CASCADE的威力。从那以后我再操作真实数据任何批量删除前都会先查关联数据量from django.contrib.auth.models import User user User.objects.get(usernametest_user) print(user.article_set.count()) # 关联文章数 user.delete()article_set是反向关联的默认名称因为Article的外键指向了User。如果你给外键设置了related_namearticles那么这里就要写user.articles.count()。这个细节和命名规范建议在设计模型时就定下来我习惯统一加related_name避免默认生成的xxx_set在多层关联时读起来莫名其妙。8. 下一步扩展的方向和建议很多人在博客系统第一版跑通后会问我下一步该做什么。我的彩蛋建议是先把这版用localhost跑通跑稳不要急着部署。然后做三件小事一是把文章列表加一个按分类筛选的侧边栏二是给文章加上按标题和正文的搜索功能三是用F表达式实现一个阅读量自增字段。这三件事分别锻炼了URL参数处理、多条件查询Q对象和数据库原子操作是全栈开发里非常实用的技能。尤其是F表达式很多人做点赞或者阅读量时会先SELECT再UPDATE两行代码之间有竞态窗口数据并发时会出错。用F表达式一行搞定原子自增from django.db.models import F Article.objects.filter(slugslug).update(viewsF(views) 1)这是少即是多的典型代码更少Bug更少性能更好。我记得有个读者朋友跟我聊过他照着教程做完了博客但第二天打开电脑突然不知道从哪里改起。我给他的回答是找到一个你每天都想用的痛点功能把它做出来。比如你自己想写技术笔记那就把创建文章时自动生成分类这种顺手的小功能做出来这比纯照着教程敲十遍代码更能提升你对Django的理解。全栈开发不是终点是你开始用代码解决自己问题的起点。