拓冰建站拓冰建站
首页 / 资讯中心 / 正文

DjangoBlog项目实战:从数据模型到部署排错全流程

前阵子有朋友问我只做一个个人博客有必要上 Django 吗我几乎是秒回的——如果你不想把半年时间耗在造轮子上那就很有必要。DjangoBlog 这类项目乍看就是一套简单的 CRUD发文章、改文章、删文章。可真等你把分类、标签、后台发布、阅读量统计、资源下载、部署上线全部串在一起框架选型就决定了后面要踩多少雷。我最早用 Flask 手写过一个小博客跑了大半年后整体迁到了 Django核心原因就两个自带的后台管理太好用了ORM 对查询和删除的抽象也足够省心。这篇文章就结合我自己做 DjangoBlog 的完整过程把从项目搭建、数据模型、admin 后台、视图模板到 MySQL 接入和上线排错的经验一次讲透。适合已经有 Python 基础、想拿一个完整项目练手的人看也适合那些 Djangoo 入门后发现教程太碎、想系统梳理一遍的读者。1. 技术选型内容型站点用 Django 到底赢在哪1.1 和 Flask、Spring Boot 的直观对比做技术选型时很多人喜欢纠结框架排名我说句实在话选型要看项目类型。博客是典型的“读多写少”内容型站点核心诉求不是并发有多高而是开发效率、内容维护体验、SEO 亲和力这三件事。框架后台管理ORM 成熟度学习成本适合方向Django自带 Admin开箱即用高多表关联和迁移很顺手中概念集中内容型站点、管理后台、运营系统Flask无需自己拼中常配 SQLAlchemy低微框架自由度高轻接口、原型、小工具Spring Boot需整合 Spring Security 等高高企业级中后台、大型分布式这里必须强调一点后台管理对内容型产品不是“锦上添花”而是生命线。写博客的人最核心的重复劳动就是发布和修改内容Django 自带 Admin 等于项目才起步就把这套运营工具白送给你了。Flask 不是不能用但你要自己写表单、自己写登录、自己处理权限这些时间足够你把博客的核心业务逻辑再打磨两遍了。1.2 MVT 模型在博客里的真实映射Django 说的 MVT也就是 Model、View、Template它在博客项目里的映射非常清晰Model 对应数据库表比如 Post 表、Category 表、Tag 表View 是业务逻辑层负责从数据库取数据、判断状态、把数据交给模板Template 负责渲染 HTML模板语言里的{{ object }}这类语法就是面向这个场景设计出来的。很多初学者容易被“前后端分离”带偏一上来就想 Django 只做 API前端再用 Vue 或 React 重写一套。对于博客这种对 SEO 敏感的项目我劝你不要这么做。服务端直接渲染 HTML搜索引擎爬虫拿到的是完整页面文章详情页、分类页都能直接被收录。这套 DjangoBlog 我坚持用服务端渲染原因就在这里。你当然可以在某个子模块里用 Django 写接口给移动端调但主体页面走 Template 渲染是内容型项目的最佳路径。2. 项目骨架和数据模型写代码之前先决策2.1 创建 Django 项目和 app动手第一步不是写代码而是把项目结构想清楚。通常一个博客系统会有两个核心模块用户认证模块和博客内容模块。用户认证 Django 已经内置了所以我们只需要额外创建一个 blog app。# 安装 Django建议创建虚拟环境后装 pip install django # 创建项目DjangoBlog 这里的名字你可以改成自己想要的 django-admin startproject DjangoBlog cd DjangoBlog # 创建博客应用 python3 manage.py startapp blog创建完后的目录大概是这样的DjangoBlog/ ├── DjangoBlog/ │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── blog/ │ ├── migrations/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ ├── tests.py │ └── views.py └── manage.py关键一步是把 blog 注册进INSTALLED_APPS。很多人漏了这一步结果后面执行迁移时发现表建不出来还以为是代码写错了。# DjangoBlog/settings.py INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, blog, # 新加的 app ]关于中文路径和 Python 环境这里多说一句项目目录别带中文Python 解释器尽量用 3.8 以上版本。我见过不少新人因为虚拟环境里装了多个 Python导致命令行python和python3指向不同解释器最后迁移时数据库版本对不上排查半天才发现问题出在环境上。2.2 文章、分类、标签的模型设计模型是整个博客系统的地基。Post 表里不是塞一个 title 和 content 就完了我建议把核心扩展字段提前设计好。下面这个模型是我在 DjangoBlog 里的实际写法可以直接参考# blog/models.py from django.db import models from django.utils import timezone from django.urls import reverse class Category(models.Model): name models.CharField(分类名, max_length50) slug models.SlugField(别名, uniqueTrue) class Meta: verbose_name 分类 verbose_name_plural 分类 def __str__(self): return self.name class Tag(models.Model): name models.CharField(标签名, max_length30) slug models.SlugField(别名, uniqueTrue) class Meta: verbose_name 标签 verbose_name_plural 标签 def __str__(self): return self.name class Post(models.Model): STATUS_CHOICES ( (draft, 草稿), (published, 已发布), ) title models.CharField(标题, max_length200) slug models.SlugField(链接别名, uniqueTrue) summary models.TextField(摘要, max_length500, blankTrue) category models.ForeignKey( Category, verbose_name分类, on_deletemodels.SET_NULL, nullTrue, blankTrue, ) tags models.ManyToManyField(Tag, verbose_name标签, blankTrue) author models.ForeignKey( auth.User, verbose_name作者, on_deletemodels.CASCADE, ) content models.TextField(正文) status models.CharField( 状态, max_length10, choicesSTATUS_CHOICES, defaultdraft ) views models.PositiveIntegerField(阅读量, default0) created_at models.DateTimeField(创建时间, defaulttimezone.now) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at] verbose_name 文章 verbose_name_plural 文章 def __str__(self): return self.title def get_absolute_url(self): return reverse(blog:post_detail, args[self.slug])这里有几个设计决策要解释清楚第一个是category和tags分别用了外键和多对多。分类是树形结构一篇文章属于一个分类用 ForeignKey 最自然标签是一篇文章可以打多个标签反过来一个标签也能挂在多篇文章下这是典型的多对多关系直接用 ManyToManyField 就好。第二个是Category的on_deletemodels.SET_NULL。这是我从坑里爬出来后的选择。早期我把外键写成默认的CASCADE结果运营人员在后台删掉了一个分类连带这个分类下二十多篇文章全部被级联删除了。文章内容是创作者最宝贵的资产分类删错了可以新建文章没了就是灾难。所以内容类模型我建议优先用SET_NULL让关联关系断裂而不是把数据吞掉。第三个是slug字段。很多简单教程会用文章 ID 做详情页 URL比如/post/123/。这样做短期没问题但对 SEO 不友好。Slug 是文章标题转成的 URL 别名比如“DjangoBlog 博客系统实战”会变成djangoblog-blog-system-guide阅读者看到链接大概就知道内容是什么。Django 里SlugField会自动帮你处理非法字符再配合 admin 里的prepopulated_fields就能自动填充。2.3 迁移流程数据库表结构变更的流水账写完 models.py 之后接下来是 Django 不同于其他框架的一个关键动作迁移。# 在项目根目录执行 python3 manage.py makemigrations blog python3 manage.py migratemakemigrations会根据模型改动生成一个迁移文件它就像数据库的“流水账”记录了哪张表新增了字段、哪个字段改了类型。真正执行migrate时才把变更同步到数据库。很多初学者搞不清 migrations 文件夹里那些文件是干嘛的于是直接删掉重来。我建议不要这样做。迁移文件一旦被其他同事同步过很可能造成历史记录不一致导致后面 migrate 各种报错。遇到想调整模型的情况正确姿势是改 models.py然后删掉出错的那个迁移文件里你不想要的历史是可以的但一旦上了生产环境优先新增迁移文件而不是回滚删除。另外启动项目前记得创建一个超级用户否则你连后台都进不去python3 manage.py createsuperuser3. 管理后台从“能用”到“好用”的 admin 调优3.1 注册模型时的字段清单Django admin 不是摆设。如果你只是注册一下模型就开用那它只能算“能用”真正让它“好用”需要动一点配置。先看我在admin.py里是怎么写的# blog/admin.py from django.contrib import admin from .models import Category, Tag, Post admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display (name, slug) search_fields (name,) admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display (name, slug) search_fields (name,) admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display (title, category, status, views, created_at) list_filter (status, category, tags) search_fields (title, summary, content) prepopulated_fields {slug: (title,)} date_hierarchy created_at list_editable (status,) list_per_page 30这段配置每行都有讲究list_display决定列表页显示哪些列。文章标题、分类、状态、阅读量、创建时间一眼扫过去就知道内容运营的全貌。list_filter按状态、分类、标签做筛选在文章量上了千篇之后尤其好用。运营想看“所有草稿”或“某个分类下已发布的内容”点一下就能筛出来。search_fields设置搜索范围标题和正文都算上方便快速定位。date_hierarchy会在列表页顶部生成一个时间层级导航可以按年、按月筛选文章对内容库特别实用。list_editable允许直接在列表页改状态发布、撤回都不要再点进详情页。一个小提醒list_editable的字段不能出现在list_display的第一列因为第一列默认是链接入口两个功能会冲突。如果你想让某个字段既能点进详情又能在列表页编辑布局上要注意避开第一列。3.2 定制按钮、搜索与批量操作Admin 里的方法可以做更多事情。比如我给文章列表加了一个“复制文章”的功能运营想写同类型内容时可以直接复制草稿再改省去重复搭建框架的时间# blog/admin.py from django.http import HttpResponseRedirect admin.register(Post) class PostAdmin(admin.ModelAdmin): # ... 前面的配置省略 ... actions [copy_post] def copy_post(self, request, queryset): count 0 for post in queryset: post.pk None post.status draft post.title post.title _副本 post.views 0 post.save() count 1 self.message_user(request, f已复制 {count} 篇文章) copy_post.short_description 复制所选文章 def save_model(self, request, obj, form, change): if not change: obj.author request.user super().save_model(request, obj, form, change)save_model这个重写很有价值。通常我们不想让发布者在创建文章时手动选作者而是默认取当前登录用户这样既省事又避免权限错乱。另外如果你管理的文章表已经涨到几万条建议在PostAdmin里加一个get_queryset方法用select_related把关联的分类、作者查出来不然列表页每显示一行就会多一次 SQL 查询页面会明显变慢。3.3 后台美化的常用思路后台美化是热搜词里被问得很多的点。说直接点admin 原版界面确实比较朴素但它最大的价值是稳定和可预期。美化可以从几个方向考虑给 admin 整体换模板比如覆盖admin/base_site.html替换站点标题和 CSS 变量用第三方主题如 django-simpleui、django-grappelli但要注意版本兼容性在settings.py里给admin.site.site_header设置成自己的品牌名这是最低成本的美化。我给 DjangoBlog 用的是自定义 base_site 模板方案。因为博客站点的后台只需要改个标题、换个配色、调整一下侧边栏文字就能让运营同事觉得“这系统是我们自己做的”没必要为了外观引入整套组件库从而提高升级维护成本。这里的原则是能改样式变量解决的问题就别引入新的依赖。4. 从 URL 到页面路由、视图、模板的完整串联4.1 URL 设计别把所有逻辑堆在项目级 urls.py项目级urls.py应该只做分发具体业务路由放到 app 下的urls.py这样模块边界清晰以后扩展新功能不用改全局文件。# DjangoBlog/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, namepost_list), path(post/slug:slug/, views.post_detail, namepost_detail), ]这里我用的是slug:slug而不是str:slug。Path 转换器里slug只匹配字母、数字、连字符和下划线天然过滤掉 URL 里的非法字符安全性更好。如果用str你大概率要自己再做一遍清洗。4.2 列表页视图与分页列表页我用的不是函数式视图写死一切而是直接操作 QuerySet 加 Django 内置的 Paginator。# blog/views.py from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .models import Post def post_list(request): posts ( Post.objects .filter(statuspublished) .select_related(category) ) paginator Paginator(posts, 10) page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, blog/post_list.html, {page_obj: page_obj})分页这里有两个关键知识点Paginator.get_page(page_number)比page(page_number)更抗造它遇到非法页码时返回第一页而不是直接抛 404select_related可以避免列表页每篇文章都查询一次分类表这就是N1问题的典型解法。对应的模板片段大概是这样的!-- blog/templates/blog/post_list.html -- {% for post in page_obj %} article h2a href{{ post.get_absolute_url }}{{ post.title }}/a/h2 p{{ post.summary }}/p span{{ post.category.name }} | {{ post.created_at|date:Y-m-d }}/span /article {% empty %} p还没有发布文章/p {% endfor %} div classpagination {% 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 %} /div4.3 详情页、跳转和数据传递详情页直接用get_object_or_404顺着 slug 找文章找不到就让 Django 自动返回 404 页面def post_detail(request, slug): post get_object_or_404(Post, slugslug, statuspublished) return render(request, blog/post_detail.html, {post: post})说到跳转和数据传递微信群里常有人问“Django 重定向怎么带参数”。这个要分场景如果是一次性提示比如“保存成功”用 Django 内置的 messages 框架后台self.message_user就是它的封装如果是临时状态数据可以放 session如果希望刷新后 URL 里还能看到参数那就拼查询字符串reverse(blog:post_detail, args[post.slug]) ?fromadmin。我之前见过有人把提示信息拼在 URL 查询参数里然后一路从视图传到模板再手动读request.GET.get(status)这完全是重复造轮子。消息提示用官方 messages 就够了视图里写messages.success(request, 文章已发布)模板里{% for message in messages %}依次渲染。5. ORM 查询与删除这些行为边界必须清楚5.1 QuerySet 是惰性的不等于列表热搜词里有一条是“django 执行查询-删除对象”这其实对应了一个高频误区QuerySet 是惰性的。很多初学者以为posts Post.objects.filter(statuspublished)这一行一执行SQL 就发到数据库了。其实不是。QuerySet 在你真正用到结果时才去查库比如遍历它或者强转 list。# 这行不会立刻查库 posts Post.objects.filter(statuspublished) # 遍历时才真正执行 SQL for post in posts: print(post.title)延迟加载本身是为了提高效率但也带来两个坑第一个是“重复查询”。同一个 QuerySet 被遍历两次Django 会执行两次 SQL。如果数据量不大倒还好但列表页一旦做了复杂过滤效率会很难看。需要多次使用时请先list()一下或者直接复用同一个 QuerySet 对象Django 有结果缓存。第二个是我踩过的切片会强制 QuerySet 执行 SQL。posts[:10]这一步会发出带 LIMIT 的查询如果你没注意好顺序后续再对切片结果做操作可能拿到的是旧数据。这也是为什么分页逻辑最好放在视图层统一处理而不是在模板里反复切片。5.2 删除对象时最容易踩的级联坑删除操作的坑比查询更隐蔽。Django 的delete()方法是会收集关联对象的。比如我们删除一篇文章post Post.objects.get(pk1) post.delete()你以为只删了这一行其实 Django 会先检查需要一起删除的相关对象。如果某个外键的on_delete是CASCADE那么关联它的对象也会被一并删除并且返回值会告诉你删了多少对象。result post.delete() print(result) # (6, {blog.Post: 1, blog.Post_tags: 3, ...})这个返回值堪称“清除菜单”它把这次删除操作波及到的所有表和记录数都列出来了。我强烈建议你在执行删除前后都打印一下这个结果尤其在对分类、标签这种被多篇文章引用的对象动手之前。再回到分类的外键设计。我前面把Category的on_delete设置成了SET_NULL本质就是为了防止误删分类时把文章一起带走。如果你已经写成了CASCADE同时想临时做一次批量删除至少要先看清会连带删掉什么category Category.objects.get(pk3) # 看看这个分类下有多少文章再决定删不删 print(category.post_set.count())5.3 批量操作与性能Django 的批量删除有两种姿势对单个 QuerySet 调用 delete或者循环中逐个删。两者差别很大# 推荐一条 DELETE SQL Post.objects.filter(statusdraft).delete() # 不推荐产生 N 条 SQL for post in Post.objects.filter(statusdraft): post.delete()批量 delete 的难点在于如果模型存在关联对象Django 会先把受影响的主键收集齐再逐张表去删这个过程中可能产生大量查询。所以对大型数据表做批量删除时最好放事务里同时先统计好数量。我自己在处理脏数据时就遇到过一次性删几千条草稿、数据库锁了十几秒的情况后来改成凌晨低峰期执行并且把删除条件加索引速度立刻不一样了。6. 接入 MySQL 与文件下载上线前的两个硬骨头6.1 mysqlclient 安装与数据库配置博客数据量大了之后SQLite 一般撑不住并发写多数项目会切到 MySQL。这里最大的坎经常出现在安装mysqlclient这个依赖上。pip install mysqlclient如果你运气不好可能会碰到mysql_config not found或各种编译错误。原因是 mysqlclient 依赖 MySQL 的 C 客户端库。根据不同系统先装系统依赖再重试# Debian / Ubuntu sudo apt-get install python3-dev default-libmysqlclient-dev build-essential # macOS brew install mysql-client echo export PATH/usr/local/opt/mysql-client/bin:$PATH ~/.zshrc如果编译环境一直搞不定也可以退而求其次用 PyMySQL然后在项目__init__.py里做兼容。但我的建议是生产环境最好还是解决 mysqlclient它的性能和稳定性更靠谱PyMySQL 作为备用方案没问题别长期依赖。配置好依赖后数据库连接信息在settings.py里要改成这样DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: djangoblog, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: {charset: utf8mb4}, } }utf8mb4这个字符集必须显式指定否则 emoji 和一些生僻汉字会存不进去报错让你完全摸不着头脑。字符集问题属于那种“看数据库全表都是正常的数据但写入就报错”的隐藏地雷。6.2 FileResponse 和 StreamingHttpResponse 的选择博客系统里经常有附件下载需求比如上传 PDF、压缩包、代码示例。热搜里提到的StreamingHttpResponse和它的content_type、Content-Disposition参数这里一并说清楚。先明确一个原则下载文件优先用FileResponse它是 Django 为文件下载专门封装的响应类内部会用分块方式把文件流式写入客户端不会把整个文件一次性读进内存。StreamingHttpResponse适合的是你手头有一个生成器或迭代器、动态产生内容的情况。# blog/views.py from django.http import FileResponse from django.utils.http import urlquote def download_file(request, file_path): response FileResponse( open(file_path, rb), content_typeapplication/octet-stream, ) # 关键Content-Disposition 控制浏览器以下载方式处理 response[Content-Disposition] attachment; filename*UTF-8 urlquote(file_name) return response这里有两个容易踩的细节。第一个是content_type。application/octet-stream这个类型告诉浏览器“别尝试解析当成二进制流下载”。如果你设成text/html浏览器很可能直接把它在页面上打开而不是触发下载。第二个是中文文件名。Content-Disposition直接写attachment; filename攻略.pdf在老旧浏览器里会乱码。用filename*UTF-8加 URL 编码是目前兼容性最好的写法。另外文件路径一定要校验防止用户传入..穿越目录读取服务器上其他敏感文件。基础的防护就是先把路径规范化再判断它是否在允许的下载目录内。7. 部署联调与一次真实排错复盘7.1 静态文件收集与 host 配置本地开发时Django 会自动处理静态文件但到了生产环境通常不会再用 Django 进程直接服务静态资源。常规做法是settings.py里配置好STATIC_ROOT然后执行python3 manage.py collectstatic把散落在各个 app 里的静态文件收集到一个统一目录。部署后常见的 400 错误多半是ALLOWED_HOSTS没有把域名加进去ALLOWED_HOSTS [your-domain.com, www.your-domain.com]如果你用了 Nginx 反代还要保证X-Forwarded-For和X-Forwarded-Proto这两个头被正确传递否则后台的 HTTPS 跳转会无限循环。7.2 一次 500 错误的完整排查链路分享一次我实际处理过的 500 问题。现象是DjangoBlog 后台列表页能打开但点进某篇具体文章就 500。我第一反应是模板报错但本地跑同样代码又是好的这说明大概率不是语法问题而是环境和数据差异。排查链路按这个顺序走看日志。打开DEBUGFalse之后Django 的 500 页面不再显示具体异常必须去服务日志里找关键报错。当时日志里写的是database is locked。定位锁来源。SQLite 在并发写时很容易锁库后台自动保存、日志写入、定时任务同时执行就可能触发。确认场景。那篇文章恰好是运营正在编辑保存时被访问写锁阻塞了读请求。解决方向。我把数据库切到了 MySQL锁粒度更细并发性能也更好。之后同类问题就没再出现过。这次排查给我最深的印象是不要一上来就怀疑代码逻辑先把DEBUGFalse、ALLOWED_HOSTS、数据库权限、静态文件路径这些部署四件套检查完它们才是线上出问题的最大来源。如果日志里只给了一行很隐晦的异常就去 settings 里临时打开LOGGING配置把 SQL 也打出来很多时候 500 的答案就藏在最后一条 SQL 里。另外再补充一个常用的定位技巧生产环境把django-debug-toolbar挂上去并不合适你可以在本地复制线上的一行数据用manage.py shell手工执行一遍视图函数这样能快速把环境差异和数据差异分开。数据差异导致的 500 很容易被忽略比如某篇文章的摘要超过了max_length对应的数据库字段长度MySQL 在严格模式下会直接报错而在本地 SQLite 上可能没这么敏感。遇到这种问题用同一个数据样本在本地重现是最快的办法。做完 DjangoBlog 这个项目后我自己最大的体会是博客系统的难度不在某单个功能而在瞬间把 Django 的各个零件同时拼起来的能力。路由怎么分、模型外键怎么设、admin 怎么配、ORM 查询何时执行、响应头怎么写每一个环节单独看都有教程但它们连在一起时才会真实地爆发问题。如果你也正卡在某个细节上可以参考我上面这些处理思路先用最小步骤跑通再逐步把条件加上去。后面我还计划给这个博客加上全文搜索、RSS 订阅、Markdown 编辑器支持这些扩展点也都是在现有模型结构上继续生长。先把地基打扎实后面加功能会顺畅非常多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门