综合练习
这一课你将能做到
- 从零搭建一个完整的 Django Blog + API 项目,覆盖用户、文章、评论、标签
- 同时提供 HTML 渲染页面(全栈)和 REST API(前后端分离)两种访问方式
- 用 Docker 打包项目并部署到生产环境,附带完整的监控和备份方案
为什么要学
前面 11 课把 Django 的各个模块拆开讲,这一课把它们串起来做一个完整项目。真正的工程能力不是「会写每个模块」,而是「知道什么时候用什么模块、怎么把它们组装起来」。
这个练习会用到:项目结构、URL 路由、视图、模型、查询、模板、Admin、表单、DRF、测试、部署——全部 12 课的知识。做完它,你对 Django 的理解会从「会用」升级到「能设计」。
和本站(codeforge)对比:本站是 Spring Boot 全栈分离(Java 后端 + React 前端),这个 Django 项目同时提供两种模式:全栈渲染(DTL)和纯 API(DRF)。你会直观感受到两种架构的取舍。
先建立图像
┌─────────────────────────┐
│ Nginx / CDN │
└───────────┬─────────────┘
│
┌───────────┴─────────────┐
│ Gunicorn + Django │
│ │
浏览器 ──HTML──> │ Template Views (DTL) │
React ──JSON──> │ DRF API Views │
│ │
│ ┌───────────────────┐ │
│ │ Admin 后台管理 │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Celery Worker │ │ <── Redis
│ │ (发邮件/报表) │ │
│ └───────────────────┘ │
└───────────┬─────────────┘
│
┌───────────┴─────────────┐
│ PostgreSQL 数据库 │
└─────────────────────────┘
跟着做
步骤 1:项目初始化
mkdir django-blog && cd django-blog
python -m venv .venv
.\.venv\Scripts\Activate.ps1 # Windows
pip install django djangorestframework django-filter celery redis psycopg2-binary gunicorn whitenoise python-dotenv
django-admin startproject config .
python manage.py startapp accounts
python manage.py startapp blog
python manage.py startapp api
步骤 2:配置 settings.py
# config/settings.py
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-secret-key-change-in-production")
DEBUG = os.getenv("DJANGO_DEBUG", "True") == "True"
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# 第三方
"rest_framework",
"rest_framework.authtoken",
"django_filters",
"whitenoise.runserver_nostatic", # 开发环境用 WhiteNoise
# 本地
"accounts",
"blog",
"api",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}]
# 数据库
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# DRF 配置
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
}
# Celery 配置
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
# 静态文件
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
# 媒体文件
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# 自定义用户模型
AUTH_USER_MODEL = "accounts.User"
步骤 3:自定义用户模型
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
avatar = models.ImageField(upload_to="avatars/", blank=True)
bio = models.TextField(blank=True, max_length=500)
github = models.URLField(blank=True)
class Meta:
verbose_name = "用户"
verbose_name_plural = "用户"
def __str__(self):
return self.username
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (
("扩展信息", {"fields": ["avatar", "bio", "github"]}),
)
list_display = ["username", "email", "bio", "is_staff", "date_joined"]
步骤 4:博客模型
# blog/models.py
from django.db import models
from django.conf import settings
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "分类"
verbose_name_plural = "分类"
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class Article(models.Model):
STATUS_CHOICES = [
("draft", "草稿"),
("published", "已发布"),
]
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
content = models.TextField()
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="articles")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="articles")
tags = models.ManyToManyField(Tag, blank=True, related_name="articles")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="draft")
views = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "文章"
verbose_name_plural = "文章"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "-created_at"]),
]
def __str__(self):
return self.title
class Comment(models.Model):
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments")
content = models.TextField(max_length=1000)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.author.username} 评论了 {self.article.title}"
步骤 5:Admin 配置
# blog/admin.py
from django.contrib import admin
from .models import Article, Category, Tag, Comment
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["name", "slug", "created_at"]
prepopulated_fields = {"slug": ("name",)}
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ["name"]
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ["title", "author", "category", "status", "views", "created_at"]
list_filter = ["status", "category", "created_at"]
search_fields = ["title", "content"]
list_editable = ["status"]
autocomplete_fields = ["author", "category"]
filter_horizontal = ["tags"]
date_hierarchy = "created_at"
actions = ["make_published"]
@admin.action(description="设为已发布")
def make_published(self, request, queryset):
queryset.update(status="published")
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ["article", "author", "content", "created_at"]
list_filter = ["created_at"]
search_fields = ["content"]
步骤 6:HTML 视图(全栈模式)
# blog/views.py
from django.views.generic import ListView, DetailView, CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.contrib import messages
from .models import Article, Comment
from .forms import CommentForm
class ArticleListView(ListView):
model = Article
template_name = "blog/article_list.html"
context_object_name = "articles"
paginate_by = 10
queryset = Article.objects.filter(status="published").select_related("author", "category")
class ArticleDetailView(DetailView):
model = Article
template_name = "blog/article_detail.html"
context_object_name = "article"
slug_url_kwarg = "slug"
def get_queryset(self):
return super().get_queryset().filter(status="published").select_related("author", "category").prefetch_related("tags", "comments__author")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["comment_form"] = CommentForm()
# 增加阅读数
Article.objects.filter(pk=self.object.pk).update(views=models.F("views") + 1)
return ctx
class CommentCreateView(LoginRequiredMixin, CreateView):
model = Comment
form_class = CommentForm
def form_valid(self, form):
form.instance.author = self.request.user
form.instance.article = get_object_or_404(Article, slug=self.kwargs["slug"])
messages.success(self.request, "评论发表成功!")
return super().form_valid(form)
def get_success_url(self):
return self.object.article.get_absolute_url()
步骤 7:DRF API(前后端分离模式)
# api/serializers.py
from rest_framework import serializers
from blog.models import Article, Category, Tag, Comment
from accounts.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "avatar", "bio"]
class CategorySerializer(serializers.ModelSerializer):
article_count = serializers.IntegerField(read_only=True)
class Meta:
model = Category
fields = ["id", "name", "slug", "article_count"]
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name"]
class CommentSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
class Meta:
model = Comment
fields = ["id", "author", "content", "created_at"]
class ArticleListSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
category = CategorySerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
comment_count = serializers.IntegerField(read_only=True)
class Meta:
model = Article
fields = ["id", "title", "slug", "author", "category", "tags",
"views", "comment_count", "created_at"]
class ArticleDetailSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
category = CategorySerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = Article
fields = ["id", "title", "slug", "content", "author", "category",
"tags", "comments", "views", "status", "created_at", "updated_at"]
# api/views.py
from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from django_filters.rest_framework import DjangoFilterBackend
from blog.models import Article, Category, Tag, Comment
from .serializers import (
ArticleListSerializer, ArticleDetailSerializer,
CategorySerializer, TagSerializer, CommentSerializer
)
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Category.objects.annotate(article_count=models.Count("articles"))
serializer_class = CategorySerializer
lookup_field = "slug"
class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
class ArticleViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["status", "category__slug", "author__username", "tags__name"]
search_fields = ["title", "content"]
ordering_fields = ["created_at", "views"]
ordering = ["-created_at"]
lookup_field = "slug"
def get_queryset(self):
return Article.objects.select_related("author", "category").prefetch_related("tags", "comments__author").annotate(
comment_count=models.Count("comments")
)
def get_serializer_class(self):
if self.action == "list":
return ArticleListSerializer
return ArticleDetailSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
def like(self, request, slug=None):
article = self.get_object()
article.views = models.F("views") + 1
article.save()
article.refresh_from_db()
return Response({"views": article.views})
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
def comment(self, request, slug=None):
article = self.get_object()
serializer = CommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(author=request.user, article=article)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
def get_queryset(self):
return Comment.objects.select_related("author", "article").all()
def perform_create(self, serializer):
serializer.save(author=self.request.user)
# config/urls.py
from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from api.views import ArticleViewSet, CategoryViewSet, TagViewSet, CommentViewSet
router = DefaultRouter()
router.register(r"articles", ArticleViewSet, basename="article")
router.register(r"categories", CategoryViewSet, basename="category")
router.register(r"tags", TagViewSet, basename="tag")
router.register(r"comments", CommentViewSet, basename="comment")
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include(router.urls)),
path("api-auth/", include("rest_framework.urls")),
path("", include("blog.urls")), # HTML 页面
]
步骤 8:测试
# blog/tests.py
import pytest
from django.urls import reverse
from blog.models import Article
from blog.factories import ArticleFactory, UserFactory
@pytest.mark.django_db
class TestArticleViews:
def test_list(self, client):
ArticleFactory.create_batch(15, status="published")
response = client.get(reverse("blog:index"))
assert response.status_code == 200
assert len(response.context["articles"]) == 10 # 分页
def test_detail(self, client):
article = ArticleFactory(status="published")
response = client.get(article.get_absolute_url())
assert response.status_code == 200
def test_draft_not_visible(self, client):
article = ArticleFactory(status="draft")
response = client.get(article.get_absolute_url())
assert response.status_code == 404
@pytest.mark.django_db
class TestArticleAPI:
def test_list(self, api_client):
ArticleFactory.create_batch(5, status="published")
response = api_client.get("/api/articles/")
assert response.status_code == 200
assert response.json()["count"] == 5
def test_create_authenticated(self, api_client):
user = UserFactory()
api_client.force_authenticate(user=user)
response = api_client.post("/api/articles/", {
"title": "新文章",
"slug": "new-article",
"content": "...",
})
assert response.status_code == 201
步骤 9:Docker 部署
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制代码
COPY . .
# 收集静态文件
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
# docker-compose.yml
version: "3.8"
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY}
- DJANGO_DEBUG=False
- DB_NAME=blogdb
- DB_USER=bloguser
- DB_PASSWORD=${DB_PASSWORD}
- DB_HOST=db
- DB_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
depends_on:
- db
- redis
volumes:
- static_volume:/app/staticfiles
- media_volume:/app/media
db:
image: postgres:16
environment:
- POSTGRES_DB=blogdb
- POSTGRES_USER=bloguser
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
celery:
build: .
command: celery -A config worker --loglevel=info
environment:
- DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY}
- DJANGO_DEBUG=False
- DB_NAME=blogdb
- DB_USER=bloguser
- DB_PASSWORD=${DB_PASSWORD}
- DB_HOST=db
- CELERY_BROKER_URL=redis://redis:6379/0
depends_on:
- db
- redis
volumes:
postgres_data:
static_volume:
media_volume:
docker-compose up --build -d
docker-compose exec web python manage.py migrate
docker-compose exec web python manage.py createsuperuser
讲清楚要点
- 项目结构决定可维护性:
config/ 放全局配置,accounts/ 放用户,blog/ 放业务,api/ 放接口。每个 app 职责单一,可插拔。
- 同时支持 HTML 和 API 是 Django 的特色:同一套模型,Template Views 渲染 HTML,DRF ViewSet 提供 JSON。不是二选一,而是互补。
- N+1 问题在实战中最常见:列表页一定要
select_related("author", "category") + prefetch_related("tags", "comments"),否则 10 篇文章可能产生 30+ 次查询。
- Docker Compose 一键起全栈:web + db + redis + celery 四个服务一条命令,本地开发和生产环境完全一致。
- 测试要覆盖关键路径:列表、详情、创建、权限,至少这四个场景要有自动化测试。factory-boy 让造测试数据不再痛苦。
- 环境变量是配置的单一事实来源:开发用
.env,生产用 Docker env 或 K8s ConfigMap,代码里零硬编码。
你可能会懵的地方
-
为什么要同时做 HTML 和 API?
真实项目经常需要:SEO 页面用 HTML 渲染(搜索引擎爬虫),App/小程序用 API。一套模型两种输出,Django 的 MTV 架构天然支持。
-
Admin 和 API 的数据会同步吗?
会。它们都操作同一个数据库,Admin 改的数据 API 立即能查到。Admin 适合内部运营,API 适合终端用户。
-
Celery 在这个项目里干什么?
发新文章通知邮件、每日统计报表、图片压缩。这些耗时操作不阻塞 HTTP 响应,用户无感知。
-
docker-compose 里的 depends_on 能保证数据库 ready 吗?
不能。depends_on 只保证启动顺序,不保证服务就绪。生产环境用 healthcheck 或启动脚本里加 wait-for-it.sh。
-
如何扩展成多租户 SaaS?
加 Tenant 模型,所有业务表加 tenant 外键,中间件根据域名/请求头解析当前租户,查询时自动过滤。Django 有 django-tenants 等第三方包支持 schema 级隔离。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 所有代码堆在一个 app | 无法复用,职责混乱 | 按业务拆 accounts/blog/api |
| 视图里直接写 SQL | 失去 ORM 保护,SQL 注入风险 | 用 ORM 或 extra() 谨慎使用 |
忘记 select_related | 列表页 30+ 次 SQL | 列表/详情都加关联查询 |
| 测试不写 | 上线后才发现 bug | 关键路径必须有自动化测试 |
| 密钥硬编码在代码里 | 泄露后全部 session 失效 | 环境变量 + .env 文件 |
卡点急救
- Docker 启动后数据库连不上:
db 服务还没 ready。加 restart: unless-stopped 或启动脚本里循环检测 pg_isready。
- Admin 里 ForeignKey 下拉卡死:数据量太大。改用
autocomplete_fields 或 raw_id_fields。
- Celery 任务不执行:Redis 没启动或 broker URL 配错。
celery -A config inspect active 查看 worker 状态。
面试常问
-
Django 项目如何做水平扩展?
Web 层:Gunicorn 多 worker + Nginx 负载均衡 + 多实例。数据库层:PostgreSQL 读写分离 + 连接池。缓存层:Redis 集群。任务层:Celery 多 worker + 队列优先级。静态文件:CDN。每层独立扩展,无状态设计。
-
从 Spring Boot 迁移到 Django 的主要挑战是什么?
思维转换:Spring 是「配置驱动」,Django 是「约定优于配置」。Spring 依赖注入显式,Django 用全局单例(如 settings)。ORM 差异:JPA 更严格,Django ORM 更灵活。最大挑战是团队习惯,不是技术。
-
如何设计一个支持百万级文章的博客系统?
数据库:PostgreSQL + 分区表 + 读写分离。缓存:Redis 缓存文章列表/详情,ES 做全文搜索。队列:Celery 处理统计/通知。存储:图片/附件走对象存储(S3/OSS)。监控:Prometheus + Grafana。Django 只是 Web 层,架构设计才是关键。
自测三问
- 这个项目同时提供了 HTML 页面和 REST API,它们共享哪些组件?各自独立的部分是什么?
- 如果要把这个博客部署到 Kubernetes,需要改哪些配置?
- 如何给这个项目加一个「文章草稿自动保存」功能?需要改哪些层?
小结一句话
综合练习把 12 课知识串成完整项目:Django 全栈渲染 + DRF API + Celery 异步 + Docker 部署,一套代码两种输出,这就是现代 Web 开发的完整图景。
延伸