你是不是也想做一个自己的博客系统,却不知道怎么选技术栈?我的建议一直很直接:想入门全栈开发,就从 Django 动手。博客这个项目麻雀虽小、五脏俱全,从数据库建模、ORM 查询到路由、模板渲染、后台管理、文件上传、部署上线,几乎把 Django 全栈开发的核心链路完整走了一遍,做完之后你会对整个 Web 开发流程有一个扎实的体感。
这篇文章围绕“Django全栈开发入门:构建一个博客系统”展开,适合刚看完 Python 基础、想跳出教程自己动手的人,也适合已经会用 Flask 这类轻量框架、但想体验全家桶框架完整度的朋友。我会把从零搭一个博客的完整思路、代码、配置、部署和踩坑过程都整理出来,尽量让你跟着就能复现。
1. 先拆解项目:博客系统的范围与技术选型
1.1 为什么是 Django,而不是其他框架
市面上 Python Web 框架不少,Flask 灵活、FastAPI 性能好、Django 则是一套全家桶。很多人纠结怎么选,我的建议是:如果想系统性地理解全栈开发,Django 是最合适的入门工具。
原因很简单:Django 自带 Admin 后台、ORM、表单处理、认证系统、静态文件管理、模板引擎,这些全是 Web 开发的高频组件。用 Flask 时你可能还要自己去拼 SQLAlchemy、Jinja2、Flask-Login、Flask-Admin,光选型就消耗不少精力;Django 帮你把这些都打通了,你只需要关注业务本身。
而且 Django 的 ORM 设计得很好,Model 层写好后,迁移、建表、查询、关联、删除这些操作都有清晰的规范。对于入门者来说,这种“官方给你标准答案”的体验非常友好,你能把精力放在理解业务逻辑上,而不是纠结某个库怎么配置。
我用的是 Django 4.2 LTS 版本。为什么不追最新版?LTS(Long Term Support)意味着长期维护,社区资料多、坑少,尤其在生产环境,稳定比“新”重要得多。Python 版本我推荐 3.10 以上,4.2 对 3.10/3.11 的支持都很成熟。
1.2 博客系统的功能范围怎么定
很多新手做项目时容易犯一个错误:功能越加越多,最后烂尾。我踩过这个坑,所以这次做博客,我一开始就明确边界,只做核心闭环:
- 文章的发布、编辑、删除(通过 Admin 后台管理,不单独做前端管理页面)
- 文章分类与标签
- 首页文章列表 + 分页
- 文章详情页
- 图片上传与视频上传
- 部署上线
评论区、用户注册、点赞这些先不做,不是难,而是它们会分散你对主线的注意力。入门项目的核心目标是跑通“数据模型 → 视图 → 模板 → 部署”这条链路,而不是功能越多越好。
等到这个基础版本跑通了,再往上加功能就很容易。Django 项目不像单体脚本,它天然是模块化的,加一个应用(app)或加一张表,不会伤筋动骨。
1.3 环境准备与虚拟环境管理
开发环境我建议直接在本地 Linux/macOS 上做,Windows 也能做,只是个别命令需要调整。这里我把虚拟环境和项目初始化一起讲了。
先用 venv 创建虚拟环境,这是 Django 项目的第一步。虚拟环境的作用是隔离项目的 Python 依赖,不同项目之间不会互相污染,特别是你同时在维护两个 Django 项目时,一个要 Django 3,一个要 Django 4,不隔离就会乱套。
mkdir django-blog cd django-blog python3 -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate虚拟环境激活后,命令行前面会出现(venv)标记,这时再安装依赖:
pip install django pillowpillow是 Python 处理图片的库,Django 的ImageField依赖它,不安装的话,后面上传图片功能会直接报错。
创建项目和 App:
django-admin startproject blogproject . python manage.py startapp blog这里我在项目名后面加了一个点.,意思是把manage.py放在当前目录,而不是再套一层目录。很多教程没写这个点,导致后面目录结构多一层,很容易懵。startapp blog则是创建博客这个应用,Django 里“项目”(project)和“应用”(app)是两个概念:项目是整个网站,应用是其中的功能模块。一个项目里可以有多个应用,比如blog、users、comments,每个应用各管一摊。
然后记得把blog加到settings.py的INSTALLED_APPS里,否则 Django 不认识这个应用。
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', # ... 'blog', ]2. 数据库与核心模型设计
2.1 文章、分类、标签三个核心模型
博客的数据模型不复杂,但设计得好不好,直接影响后续开发的体验。我的设计是这样的:
Category(分类):分类是层级简单的对象,每个分类有名称和别名。Tag(标签):标签和文章是多对多关系。Post(文章):文章包含标题、正文、封面图、视频、所属分类、标签、发布时间、更新时间等。
写代码时,我建议字段尽量一次想清楚,但要留好扩展的余地。比如状态字段,我先用draft和published两种状态,后续如果要加“置顶”“审核中”,只需要加枚举值,不需要改表结构。
以下是blog/models.py的核心代码:
from django.db import models from django.urls import reverse from django.utils import timezone class Category(models.Model): name = models.CharField('分类名称', max_length=50) slug = models.SlugField('别名', unique=True) class Meta: verbose_name = '分类' verbose_name_plural = verbose_name def __str__(self): return self.name class Tag(models.Model): name = models.CharField('标签名称', max_length=50) slug = models.SlugField('别名', unique=True) class Meta: verbose_name = '标签' verbose_name_plural = verbose_name def __str__(self): return self.name class Post(models.Model): STATUS_CHOICES = ( ('draft', '草稿'), ('published', '已发布'), ) title = models.CharField('标题', max_length=200) slug = models.SlugField('别名', unique=True) category = models.ForeignKey(Category, verbose_name='分类', on_delete=models.PROTECT, related_name='posts') tags = models.ManyToManyField(Tag, verbose_name='标签', blank=True, related_name='posts') cover = models.ImageField('封面图', upload_to='cover/%Y/%m/%d/', blank=True, null=True) video = models.FileField('视频', upload_to='video/%Y/%m/%d/', blank=True, null=True) body = models.TextField('正文') status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='draft') created_at = models.DateTimeField('创建时间', default=timezone.now) updated_at = models.DateTimeField('更新时间', auto_now=True) class Meta: verbose_name = '文章' verbose_name_plural = verbose_name ordering = ('-created_at',) def __str__(self): return self.title def get_absolute_url(self): return reverse('blog:post_detail', kwargs={'slug': self.slug})这里有几个关键点需要展开说。
on_delete=models.PROTECT是特意选的。分类如果被文章引用,PROTECT会阻止删除分类并抛出异常,避免误删导致整个 blog 文章的分类变成空指针。新手经常用CASCADE,但博客场景下分类删除会连带删除所有文章,这通常不是你想要的结果。如果你确定某个分类删掉后文章也一起删,那用CASCADE没错,但博客场景更合理的是PROTECT或SET_NULL。
related_name也很重要。设置了related_name='posts'后,你可以通过category.posts.all()拿到这个分类下的所有文章,反向查询语义清晰。如果不设置,Django 默认生成category.post_set.all(),能看但不够直观。
ImageField和FileField的upload_to我做了日期路径,这样上传的文件会按cover/2025/06/18/xxx.jpg组织,避免一个目录下文件太多。
2.2 图片上传与视频上传的字段设计
最初设计时,图片和视频我合并成了一个通用FileField,后来发现不行。ImageField会自动校验文件是不是合法图片,并在 Admin 后台生成预览缩略图,这对写博客体验很重要。所以封面图用ImageField,视频单独用FileField。
两个字段我都设置了blank=True, null=True,表示封面图和视频不是必填项。注意blank是表单层面的校验,null是数据库层面的约束,两者一起用才是“可以不填”。
视频上传比图片麻烦的地方在于体积和格式。图片一般压缩后几百 KB 到几 MB,视频动辄几十 MB 甚至更大。开发环境runserver无所谓,但生产环境如果你用的是 Nginx,通常还要配一个客户端请求大小的限制,比如client_max_body_size 100m;,否则上传大视频会直接被挡在 Nginx 层,Django 根本收不到请求,这个问题我到部署章节再细说。
2.3 数据迁移与 ORM 查询/删除对象
模型写好后,执行迁移:
python manage.py makemigrations blog python manage.py migratemakemigrations是生成迁移文件,不直接改数据库;migrate才是把迁移执行到数据库。这两步很多人搞混,记住:makemigrations是“准备变更”,migrate是“应用变更”。
写好模型后进入 Django Shell 测试一下 ORM 操作,这是个很好的习惯,能帮你及时发现问题:
python manage.py shell在 shell 里可以做这些操作:
from blog.models import Category, Tag, Post from django.utils import timezone # 创建分类和标签 cat = Category.objects.create(name='Python', slug='python') tag = Tag.objects.create(name='Django', slug='django') # 创建文章 post = Post.objects.create( title='我的第一篇 Django 博客', slug='my-first-django-post', category=cat, body='正文内容……', status='published', ) # 关联标签 post.tags.add(tag) # 查询 Post.objects.filter(status='published') Post.objects.filter(category__name='Python') post = Post.objects.get(slug='my-first-django-post') # 删除对象 post.delete()这里我要重点说说“删除对象”这个热词。ORM 删除有几种方式,区别很大。
obj.delete():删除单个对象,返回(总删除数, 明细字典),它会把关联的对象按规则处理。QuerySet.delete():批量删除,比如Post.objects.filter(category=cat).delete()会删除所有匹配的文章。Category.objects.get(slug='python').delete():由于我们用了PROTECT,如果分类下有文章,这一步会直接抛出ProtectedError。
我在实际项目中遇到过一个问题:批量删除时没看返回值,以为删干净了,结果关联表里还有残留数据。所以删除操作一定要先确认归属关系,尤其是有外键和多对多的模型。
3. 视图、路由与模板三件套
3.1 首页与详情页视图逻辑
入门阶段,我建议先用函数视图(FBV)理解流程,跑通后再升级到类视图(CBV)。这里我先展示 FBV 写法,后面也会给出 CBV 的版本作为升级参考。
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(status='published') paginator = Paginator(posts, 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}) def post_detail(request, slug): post = get_object_or_404(Post, slug=slug, status='published') return render(request, 'blog/post_detail.html', {'post': post})get_object_or_404是 Django 的快捷方式:如果查询结果不存在,直接返回 404 页面,不用手动写 try/except。
分页用的是Paginator,参数 5 表示每页 5 篇文章。在模板里可以展示上一页/下一页以及页码列表,这个后面会讲。
升级后的 CBV 版本长这样:
from django.views.generic import ListView, DetailView from .models import Post class PostListView(ListView): model = Post template_name = 'blog/post_list.html' context_object_name = 'page_obj' paginate_by = 5 def get_queryset(self): return super().get_queryset().filter(status='published') class PostDetailView(DetailView): model = Post template_name = 'blog/post_detail.html' slug_url_kwarg = 'slug'CBV 代码更少,但新手看它会有一种“魔法感”——你得知道ListView默认去查什么、模板里默认上下文变量叫什么。我建议先用 FBV 写一遍,再看 CBV 就会豁然开朗。
3.2 URL 设计与命名空间
在blog/urls.py里定义路由:
from django.urls import path from . import views app_name = 'blog' urlpatterns = [ path('', views.post_list, name='post_list'), path('post/<slug:slug>/', views.post_detail, name='post_detail'), ]然后在项目的blogproject/urls.py里包含进来:
from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('', include('blog.urls')), ]注意两个细节:第一,app_name = 'blog'定义了命名空间,这样在模板中可以使用{% url 'blog:post_detail' slug=post.slug %},即使将来改了 URL 路径,模板代码不需要变动;第二,<slug:slug>是 URL 转换器,限制参数格式为字母、数字、连字符、下划线,避免非法字符。
之前我看有人把所有业务路由都写在项目根urls.py里,应用一多就变得极难维护。Django 的哲学是“应用自治”,每个 app 有自己的urls.py,根urls.py只管include,这个习惯越早养成越好。
3.3 模板继承与页面渲染
模板是 Django 里很容易被忽略但很重要的部分。我并没有直接用 Django Admin 的界面作为前台,而是自己写了一套模板,原因是我们需要理解模板渲染的机制。
先创建一个基础模板templates/base.html:
<!DOCTYPE html> <html lang="zh-hans"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}我的博客{% endblock %}</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <div class="container"> <a class="navbar-brand" href="/">我的博客</a> </div> </nav> <div class="container mt-4"> {% block content %} {% endblock %} </div> </body> </html>然后templates/blog/post_list.html:
{% extends 'base.html' %} {% block title %}首页 - 我的博客{% endblock %} {% block content %} <h1>文章列表</h1> {% for post in page_obj %} <article class="mb-4"> <h2> <a href="{% url 'blog:post_detail' slug=post.slug %}">{{ post.title }}</a> </h2> <p class="text-muted">{{ post.created_at|date:"Y-m-d" }} | {{ post.category.name }}</p> {% if post.cover %} <img src="{{ post.cover.url }}" alt="{{ post.title }}" class="img-fluid" style="max-width: 300px;"> {% endif %} <p>{{ post.body|truncatechars:100 }}</p> </article> {% empty %} <p>还没有文章,去后台发布一篇吧。</p> {% endfor %} <nav aria-label="Page navigation"> <ul class="pagination"> {% if page_obj.has_previous %} <li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">上一页</a></li> {% endif %} <li class="page-item disabled"><span class="page-link">第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span></li> {% if page_obj.has_next %} <li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">下一页</a></li> {% endif %} </ul> </nav> {% endblock %}templates/blog/post_detail.html:
{% extends 'base.html' %} {% block title %}{{ post.title }} - 我的博客{% endblock %} {% block content %} <article> <h1>{{ post.title }}</h1> <p class="text-muted">{{ post.created_at|date:"Y-m-d H:i" }} | {{ post.category.name }}</p> {% if post.cover %} <img src="{{ post.cover.url }}" alt="{{ post.title }}" class="img-fluid"> {% endif %} <div class="mt-4"> {{ post.body|linebreaks }} </div> {% if post.video %} <video controls class="w-100 mt-3"> <source src="{{ post.video.url }}" type="video/mp4"> 你的浏览器不支持 video 标签。 </video> {% endif %} <p class="mt-3"> 标签: {% for tag in post.tags.all %} <span class="badge bg-secondary">{{ tag.name }}</span> {% endfor %} </p> </article> <p><a href="{% url 'blog:post_list' %}">返回列表</a></p> {% endblock %}模板继承机制很容易理解:base.html是框架,block是插槽,子模板通过extends继承框架并填充block。这比在每一个页面里复制导航栏、页脚代码要优雅得多。
还有几个模板过滤器值得记住:|date:"Y-m-d"格式化日期,|truncatechars:100截断文本,|linebreaks把换行转成<br>和<p>,这些都是在实际写作中非常高频的功能。
4. Admin 后台与媒体文件管理
4.1 注册模型与后台增强
Django Admin 是这套框架最大的卖点之一。写博客时,我不需要单独做一套后台 UI,直接用 Admin 就能管文章、分类、标签。
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') prepopulated_fields = {'slug': ('name',)} @admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display = ('name', 'slug') prepopulated_fields = {'slug': ('name',)} @admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display = ('title', 'category', 'status', 'created_at', 'updated_at') list_filter = ('status', 'category', 'tags') search_fields = ('title', 'body') prepopulated_fields = {'slug': ('title',)} filter_horizontal = ('tags',) date_hierarchy = 'created_at' list_editable = ('status',)prepopulated_fields的作用是:在 Admin 页面输入标题时,slug 字段会自动根据标题生成,省去手动输入的麻烦。注意中文标题生成 slug 会变成空串,所以实际发布中文文章时,还是需要手动填一下 slug,推荐用拼音或者英文字段。
filter_horizontal让多对多字段的标签选择变成左右双栏选择,体验比默认的下拉框好得多。list_editable让你可以直接在列表页修改状态,不用点进详情,非常方便。
创建超级管理员:
python manage.py createsuperuser然后启动开发服务器:
python manage.py runserver访问http://127.0.0.1:8000/admin/登录,就可以在后台发布文章了。
4.2 静态文件与媒体文件配置
刚入门时,很多人会把静态文件(CSS、JS、图片)和媒体文件(用户上传的内容)混在一起,其实它们的处理逻辑完全不同。
静态文件是项目自带的资源,用STATICFILES_DIRS配置收集目录;媒体文件是运行时动态上传的,用MEDIA_URL和MEDIA_ROOT配置。
settings.py里我建议这样配置:
import os # 静态文件 STATIC_URL = '/static/' STATICFILES_DIRS = [BASE_DIR / 'static'] STATIC_ROOT = BASE_DIR / 'staticfiles' # 媒体文件 MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media'STATIC_ROOT是生产环境执行collectstatic后所有静态文件汇总的目录,这是给 Nginx 用的,开发环境不需要。如果没设STATIC_ROOT,部署时执行collectstatic会报错。
开发环境要能正常访问媒体文件,还需要在根urls.py中加一段:
from django.conf import settings from django.conf.urls.static import static urlpatterns = [ # ... 你的路由 ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)这段代码只在 DEBUG 模式下生效,生产环境媒体文件由 Nginx 直接服务,不需要 Django 来处理,否则性能会非常差。
4.3 富文本/Markdown 方案
默认的TextField在 Admin 后台就是一个文本框,写文章体验比较原始。我试过几种方案:CKEditor、TinyMCE、django-markdownx,最后选择了django-markdownx,原因是它和 Admin 的集成最顺滑,支持实时预览。
安装配置:
pip install django-markdownxsettings.py添加:
INSTALLED_APPS = [ # ... 'markdownx', ]urls.py添加:
urlpatterns += [ path('markdownx/', include('markdownx.urls')), ]然后把模型里的body字段改成:
from markdownx.models import MarkdownxField body = MarkdownxField('正文')这样 Admin 里就会出现带预览的编辑框,写 Markdown 时右侧实时渲染 HTML,非常香。
不过 Markdown 渲染后,前面的post.body|linebreaks就不能直接用了,需要改成安全输出。为了简单,我建议在post_detail.html里把正文渲染成 HTML 后手动用|safe过滤器,或者干脆写一个自定义模板过滤器把 Markdown 转成 HTML。这里给你一个简单的自定义过滤器示例:
在blog/templatetags/下创建markdown_extras.py:
import markdown from django import template from django.utils.safestring import mark_safe register = template.Library() @register.filter(name='markdown') def render_markdown(text): md = markdown.Markdown(extensions=['extra', 'codehilite']) return mark_safe(md.convert(text))然后在模板里:
{% load markdown_extras %} <div class="mt-4"> {{ post.body|markdown }} </div>用 Markdown 写博客的好处很多:格式干净、不依赖编辑器、版本管理友好。缺点是没有图形界面的排版按钮,但博客写作场景完全够用。
5. 部署上线与常见问题排查
5.1 Linux 服务器部署:宝塔环境为例
本地开发跑通了,距离“全栈”还差最后一步:部署。很多新手死在部署这一步,其实部署本身不难,难的是理解其中的流程。
我先说自己踩过的一个大坑:直接在生产环境用runserver。Django 官方文档说得非常清楚,runserver只适合开发调试,性能差且不安全,生产环境必须用 WSGI 服务器(Gunicorn 或 uWSGI)来运行 Django 应用,再用 Nginx 反向代理。
这里以宝塔面板 + Gunicorn + Nginx 为例,因为宝塔的 Python 项目管理器可以简化不少操作。
安装 Gunicorn:
pip install gunicorn在项目根目录执行以下命令,验证 Gunicorn 能正常启动:
gunicorn blogproject.wsgi:application --bind 0.0.0.0:8000blogproject.wsgi:application是 WSGI 入口,0.0.0.0:8000表示监听所有网卡的 8000 端口。如果这一步能跑通,说明项目基本可以上线了。
然后是在 Nginx 中配置反向代理。核心配置如下:
server { listen 80; server_name your_domain.com; client_max_body_size 100m; location /static/ { alias /www/wwwroot/django-blog/staticfiles/; } location /media/ { alias /www/wwwroot/django-blog/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }部署前先执行:
python manage.py collectstatic这个命令会把所有静态文件统一复制到STATIC_ROOT指定的目录,Nginx 的alias指向这里。如果不执行collectstatic,你会看到一个只有 HTML 没有样式的页面,甚至控制台报 404。
生产环境里settings.py还需要修改几个关键项:
DEBUG = False ALLOWED_HOSTS = ['your_domain.com', 'your_server_ip']DEBUG = False之后,Django 不再输出详细的错误页面,而是显示一个通用 500 页面。如果你需要排查错误,可以看日志,或者临时把DEBUG打开。
5.2 高频报错与排查实录
我整理了一张速查表,基本覆盖了 Django 博客项目从开发到部署最常见的报错:
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
ModuleNotFoundError: No module named 'PIL' | 没装 Pillow | pip install pillow |
No migrations to apply | 迁移文件已是最新,或数据库表已存在 | 检查migrations目录,必要时python manage.py migrate --fake |
TemplateDoesNotExist | 模板路径不对,或APP_DIRS未开 | 检查TEMPLATES配置和templates目录位置 |
Invalid HTTP_HOST header | ALLOWED_HOSTS没配置 | 在ALLOWED_HOSTS中加入域名或 IP |
Bad Request (400) | ALLOWED_HOSTS配置不正确 | 同上 |
DisallowedHost | 请求的 Host 不在ALLOWED_HOSTS中 | 同上 |
SyntaxError: (unicode error) | Python 版本或编码问题 | 使用 UTF-8 编码,检查 Python 版本 |
OperationalError: no such column | 模型字段改动后没迁移 | python manage.py makemigrations && python manage.py migrate |
AttributeError: 'NoneType' object has no attribute 'split' | SECRET_KEY读取失败或环境变量缺失 | 检查环境变量 |
UnicodeDecodeError | 文件编码问题 | 在 Linux 上设置export LC_ALL=C.UTF-8 |
413 Request Entity Too Large | 上传文件超过 Nginx 限制 | 设置client_max_body_size |
502 Bad Gateway | Gunicorn 没启动或端口不对 | 检查 Gunicorn 进程与 Nginx proxy_pass 是否一致 |
这里我单独说一个最常见的坑:python manage.py migrate报django.db.utils.ProgrammingError: relation "blog_post" already exists。这种情况通常是你之前migrate过一次,后来又手动删了数据库,但迁移表的记录还在,或者你手动删了表但迁移记录还在。
解决方法是进django_migrations表看一下记录,确认哪些迁移已经被记录。如果确实是自己手动改动了数据库结构,最简单的办法是备份数据后,把blog应用的所有迁移文件删掉重新生成,再执行migrate。注意千万别在生产环境随便删迁移,删了之后恢复数据很麻烦。
5.3 安全与性能注意事项
入门项目上线后,有几个安全项必须检查:
SECRET_KEY不要硬编码在代码里,尤其别推到公开仓库。我自己遇到过密钥泄露导致 CSRF 校验失败的教训。- 数据库不要用默认的 SQLite 跑生产环境。SQLite 在低并发场景还能用,但换到 MySQL 或 PostgreSQL 才是长久的方案。Django 的 ORM 屏蔽了大部分差异,切换代价不大。
- 上传文件一定要限制类型和大小。虽然 Django 的 ImageField 会校验图片格式,但恶意文件仍然可能通过 FileField 上传。安全起见,可以在视图或表单层加一层扩展名校验。
性能方面,博客项目在初期不会遇到太大瓶颈,但有一点值得提前做:给常用查询加select_related或prefetch_related,避免 N+1 查询。比如文章列表页如果每篇文章都显示分类名,默认会为每篇文章单独查询一次分类表,文章多了性能就低了。优化方式是:
posts = Post.objects.filter(status='published').select_related('category')对于多对多的 tags,用prefetch_related('tags')。我在做列表页时加了这个优化后,100 篇文章的页面查询从 100+ 次 SQL 降到了 3 次,效果非常明显。
6. 后续扩展方向
6.1 从 Django REST Framework 走向前后端分离
如果你把博客的 HTML 模板版本做完了,下一步我建议学 Django REST Framework(DRF),把同一个博客改造成 API 服务。前后端分离是目前全栈开发的主流形态,后端只提供 JSON 接口,前端用 Vue 或 React 单独开发。
DRF 的使用思路和 Django 一脉相承。你不需要重新设计数据库,只需要为现有的Post模型写序列化器:
from rest_framework import serializers from .models import Post class PostSerializer(serializers.ModelSerializer): class Meta: model = Post fields = ['id', 'title', 'slug', 'category', 'tags', 'created_at']然后写一个视图集和路由,就能对外暴露 API 了。这种渐进式扩展的好处是:你的数据模型不用推翻重来,只需加一层接口层。
6.2 Django + Vue 整合实操
“Django 和 Vue 整合”是热词,这个话题看起来复杂,其实核心就一句话:Vue 负责前端页面,Django 负责 API。
实际项目里有两种常见做法。第一种是前后端完全分离,Django 部署为 API 服务,Vue 打包后的静态文件放在 Nginx 里,两者通过/api/路径通信。第二种是混合开发,把 Vue 打包后放到 Django 的static目录中,由 Django 直接托管前端页面,适合小项目部署。
我做过第二种方案,体验不错。具体步骤是:用 Vite 构建 Vue 项目,npm run build生成dist目录,然后把dist下的内容复制到 Django 的static/vue/目录,再在 Django 中写一个视图渲染index.html。这样部署时只需要跑一个 Django 服务,省心不少。
6.3 继续补充的全栈技能点
如果你打算以全栈开发为目标,做完了这个博客后,还可以继续挑战以下技能点:
- 给文章加评论功能:学习 Django 的
Form、ModelForm、CSRF 防护和用户认证。 - 加一个搜索功能:可以先在 Django 里用
icontains做最简单的模糊搜索,再了解 Elasticsearch 这类搜索引擎的接入方式。 - 加一个 RSS 订阅:Django 内置了
django.contrib.syndication,几行代码就能给博客加上 RSS 输出。 - 了解 Docker 部署:把 Django、Nginx、MySQL 全部打包成容器,这是目前企业里很常见的交付方式。
这个博客项目只是起点,但它把全栈开发的骨架搭了出来:前端页面、后端接口、数据库模型、文件上传、部署上线,你都已经走了一遍。后面每一个扩展方向,都是在往这个骨架上添加更专业的血肉。按照我个人的经验,把一个简单的项目完整走完,比跟着教程敲十遍代码要有效得多。希望这篇实战笔记能帮你少踩一些我踩过的坑。