Django 实战:做一个个人博客 · 第 2 / 8 篇(展开目录)

全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。

  1. Django1:起步——安装、运行与项目结构
  2. Django2:配置与路由——一个请求在 Django 里怎么走
  3. Django3:模型与迁移——把博客的数据存进数据库
  4. Django4:ORM——查询、更新、统计与事务
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:表单与后台——让作者在网页上写文章
  7. Django7:登录与权限——谁能改哪篇文章
  8. Django8:上线与维护——测试、部署、备份与发布

下载可运行的示例项目(ZIP)

约 20 分钟 · 接上一篇的项目

本篇目标: 搞清一个请求在 Django 内部经过哪些环节;读懂 settings.py 和两个 urls.py。

能回答的问题: 为什么关闭 DEBUG 后网站报 400?为什么新建的应用后台看不到?为什么写了视图却是 404?

1. 一个请求的旅程

以访问 /p/first-post/ 为例:

浏览器发出 GET /p/first-post/
  → 中间件依次处理请求(会话、身份、CSRF 检查……)
  → config/urls.py:以空前缀开头,交给 blog/urls.py
  → blog/urls.py:匹配 p/<slug:slug>/,提取 slug="first-post"
  → 视图 post_detail:查询已发布的这篇文章
  → 模板 post_detail.html:把文章渲染成 HTML
  → 中间件按相反顺序处理响应
  → 浏览器显示页面

请求可能在任何一个环节提前结束。选择下面的场景,看看请求分别停在哪里:

交互演示加载中…(需要启用 JavaScript)

这也是排错顺序

先判断请求停在哪一层:400 看域名配置,403 看 CSRF,404 先看路由再看查询条件,500 看视图代码和数据库。不要一出错就怀疑“Django 没装好”。

2. 完整的 settings.py

示例项目用一个配置文件,通过环境变量区分本地和生产。本地什么都不设,就是开发模式;上线时由第 8 篇的 Compose 传入生产配置。

import os
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
    if not DEBUG:
        raise ImproperlyConfigured("生产环境必须设置 DJANGO_SECRET_KEY")
    SECRET_KEY = "dev-only-key-for-local-learning-do-not-use-in-production"


def env_list(name, default=""):
    return [item.strip() for item in os.getenv(name, default).split(",") if item.strip()]


ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "blog.apps.BlogConfig",
]
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "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"
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],
    "APP_DIRS": True,
    "OPTIONS": {"context_processors": [
        "django.template.context_processors.request",
        "django.contrib.auth.context_processors.auth",
        "django.contrib.messages.context_processors.messages",
    ]},
}]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"

if os.getenv("DB_ENGINE", "sqlite") == "postgres":
    DATABASES = {"default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": os.getenv("POSTGRES_HOST", "127.0.0.1"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
    }}
else:
    DATABASES = {"default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }}

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "blog:mine"
LOGOUT_REDIRECT_URL = "blog:list"

# 仅在已接入可信 HTTPS 反向代理时设为 1。
HTTPS_ENABLED = os.getenv("DJANGO_HTTPS", "0") == "1"
SECURE_SSL_REDIRECT = HTTPS_ENABLED
SESSION_COOKIE_SECURE = HTTPS_ENABLED
CSRF_COOKIE_SECURE = HTTPS_ENABLED
SECURE_HSTS_SECONDS = int(os.getenv("DJANGO_HSTS_SECONDS", "0"))
if os.getenv("DJANGO_TRUST_PROXY", "0") == "1":
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": "INFO"},
}

不用一次看懂全部。按用途分成五组,下面逐组讲:

分组 配置项 在哪一节
运行模式与安全 DEBUG、SECRET_KEY、ALLOWED_HOSTS 第 3 节
应用与中间件 INSTALLED_APPS、MIDDLEWARE 第 4、5 节
路由与模板 ROOT_URLCONF、TEMPLATES、STATIC_* 第 6、7 节
数据库与语言 DATABASES、TIME_ZONE 第 7 节;切换 PostgreSQL 见第 8 篇
HTTPS 与日志 SECURE_*、LOGGING 第 8 篇

3. DEBUG、SECRET_KEY 与域名

DEBUG 打开时,出错页面会显示调用栈和配置,方便开发;生产环境必须关闭,否则会泄露内部信息。

环境变量都是字符串

bool("0") 的结果是 True。所以这里用 == "1" 判断开关,并且只认 0 和 1,写 true、yes 都不会生效。

SECRET_KEY 用于 Django 的签名(会话、CSRF 等),必须是足够长的随机值。代码里的回退值只能在本机开发用;生产环境没设置时会直接拒绝启动。它和用户密码、数据库密码是三种不同的东西。

ALLOWED_HOSTS 列出允许访问的域名或 IP,不带 https://、端口和路径。用列表以外的域名访问会得到 400。

DJANGO_ALLOWED_HOSTS=blog.example.com,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=https://blog.example.com

CSRF_TRUSTED_ORIGINS 要带协议(https://),它告诉 CSRF 检查哪些来源可信。它不是关闭 CSRF,也不是跨域(CORS)配置。

4. 应用为什么要注册

startapp blog 只是生成了一个目录。只有把 "blog.apps.BlogConfig" 加进 INSTALLED_APPS,Django 才会:

  • 加载 blog/models.py 里的模型,并为它们生成迁移;
  • 在 blog/templates/、blog/static/ 中查找模板和静态文件。

注册之后,后台能看到模型还需要 admin.py 注册,网页能访问还需要路由连接。这三步缺一不可:

创建应用目录 → 加入 INSTALLED_APPS → 在 admin.py 注册模型 / 在 urls.py 连接视图

5. 中间件为什么讲究顺序

中间件像一层层“洋葱皮”:请求从上到下穿过 MIDDLEWARE 列表,响应从下到上返回。

中间件 作用
SecurityMiddleware HTTPS 跳转、安全响应头
SessionMiddleware 读取会话 Cookie,提供 request.session
CommonMiddleware 检查 Host、规范化 URL
CsrfViewMiddleware 拦截没有有效 token 的 POST
AuthenticationMiddleware 根据会话设置 request.user
MessageMiddleware 支持“保存成功”这类一次性提示
XFrameOptionsMiddleware 禁止别的网站用 iframe 嵌入本站

顺序有依赖关系:AuthenticationMiddleware 要用会话,所以必须排在 SessionMiddleware 之后。不要随意调整这个列表的顺序。

6. 路由:URL 怎样找到视图

总路由 config/urls.py:

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/login/", auth_views.LoginView.as_view(), name="login"),
    path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
    path("", include("blog.urls")),
]

博客路由 blog/urls.py:

from django.urls import path
from . import views

app_name = "blog"
urlpatterns = [
    path("", views.post_list, name="list"),
    path("healthz/", views.health, name="health"),
    path("my/posts/", views.post_mine, name="mine"),
    path("posts/new/", views.post_create, name="create"),
    path("posts/<int:pk>/edit/", views.post_update, name="update"),
    path("posts/<int:pk>/delete/", views.post_delete, name="delete"),
    path("p/<slug:slug>/", views.post_detail, name="detail"),
]

三个要点:

  1. 路径转换器 负责提取并校验参数:<int:pk> 只接受整数,<slug:slug> 只接受字母、数字、连字符、下划线。所以中文标题要配一个英文网址标识,例如“我的第一篇文章” → my-first-post。
  2. name 给路由起名。代码里用 reverse("blog:detail", kwargs={"slug": ...}),模板里用 {% url 'blog:list' %},以后改路径不必到处找硬编码链接。
  3. app_name = "blog" 是命名空间,所以名字写成 blog:list 而不是 list,避免和其他应用重名。

手工搭建的读者注意

上面的路由指向第 5–7 篇才写的视图。如果你走的是路线 A,现在只接入已经写好的视图,否则启动时会报 AttributeError。

7. 模板、静态文件、数据库与时区

模板: DIRS 把项目根目录的 templates/ 加入搜索路径,APP_DIRS=True 让 Django 也去各应用的 templates/ 里找。本例统一放在根目录的 templates/。

静态文件: CSS 源文件在 blog/static/blog/style.css。STATIC_URL 是浏览器访问的前缀,STATIC_ROOT 是上线时 collectstatic 汇总文件的输出目录——平时不要在那里改 CSS。

数据库: 本地默认用 SQLite(项目目录下的 db.sqlite3)。设置 DB_ENGINE=postgres 后改用 PostgreSQL,第 8 篇会用到。

时区: TIME_ZONE="Asia/Shanghai" 加 USE_TZ=True 表示数据库里存 UTC,显示时转换成北京时间。代码里取当前时间要用 django.utils.timezone.now(),不要用 datetime.now()。

8. 确认配置真的生效

临时用生产模式检查一下(macOS / Linux):

export DJANGO_DEBUG=0
export DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(64))')"
export DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
python manage.py check
python manage.py shell -c 'from django.conf import settings; print(settings.DEBUG, settings.DATABASES["default"]["ENGINE"])'

应该输出 False django.db.backends.sqlite3。检查完恢复开发模式:

unset DJANGO_DEBUG DJANGO_SECRET_KEY DJANGO_ALLOWED_HOSTS

不要把整个 settings 打印到日志

里面可能有数据库密码。排查时只打印需要的那一项。

小练习

1. 部署后访问 https://blog.example.com 得到 400,最可能是哪项配置的问题?

ALLOWED_HOSTS 里没有 blog.example.com。注意只写域名,不写 https://。

2. 写了视图 about,访问 /about/ 却是 404,按什么顺序排查?
  1. blog/urls.py 有没有 path("about/", views.about, name="about");
  2. config/urls.py 有没有 include("blog.urls");
  3. 路径末尾的斜杠是否一致。

404 而不是 500,说明请求根本没到视图——先查路由。可以在上面的演示里选“地址写错”的场景对照。

3. 设置了 DJANGO_DEBUG=true 想打开调试,为什么没生效?

本例的判断是 os.getenv("DJANGO_DEBUG", "1") == "1","true" == "1" 为假,所以 DEBUG 反而被关掉了。环境变量都是字符串,本例约定只用 0 和 1。