本系列目录

本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。

  1. Django1:部署——从本地启动到服务器运行
  2. Django2:配置——看懂项目结构与请求流程
  3. Django3:数据库——模型设计、关系与迁移
  4. Django4:ORM——增删改查、事务与查询优化
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:使用——表单、文章管理与 Admin
  7. Django7:权限——登录、会话与访问控制
  8. Django8:维护——测试、日志、备份与升级

下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。

查看完整项目源码 · 查看示例验证范围

上一篇把博客运行起来了。现在我们要回答三个实际问题:为什么开发时能访问、关闭 DEBUG 后却报错?为什么新建了 blog 应用,后台仍然看不到它?为什么写了视图函数,浏览器却返回 404?这些问题都需要从项目结构和配置入口开始排查。

本篇沿用第一篇的 config 项目和 blog 应用。所有路径均相对于 manage.py 所在目录。已经复制完整示例的读者可以对照阅读;手工搭建的读者应保留 Django 生成的文件,再按下面内容调整。

1. 项目与应用分别是什么

一个项目代表一个可以独立运行的网站。一个应用代表其中一组相对集中的功能。

在博客案例里,config 管理全站配置与入口,blog 管理文章、分类和标签。如果以后增加商城,可以新增 shop 应用,而不必创建第二个 Django 项目。

文件 用途 修改它的典型场景
manage.py 加载项目后执行管理命令 通常保持生成内容
config/settings.py 全站配置 接数据库、改时区、调整模板路径
config/urls.py 总路由 /accounts//admin/ 等入口接到对应视图
config/wsgi.py WSGI 服务器入口 Gunicorn 加载 Django 应用
config/asgi.py ASGI 服务器入口 将来使用 ASGI 部署时加载应用
blog/models.py 数据模型 定义文章字段和关系
blog/views.py 请求处理 查询文章,渲染页面或返回响应
blog/urls.py 应用路由 将文章地址对应到视图
blog/forms.py 表单字段与验证 验证用户提交的数据

WSGI 和 ASGI 是服务器与 Python 应用之间的接口。本系列使用同步视图和 Gunicorn WSGI 部署;存在 asgi.py 不表示应用已经在异步运行。Django 部署入口

2. 一次请求经过哪些步骤

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

浏览器发出 GET 请求
  → 代理与应用服务器
  → 中间件处理请求
  → config/urls.py 分配路由
  → blog/urls.py 提取 slug="first-post"
  → post_detail 查询已发布文章
  → 模板接收 post 对象并生成 HTML
  → 中间件处理响应
  → 浏览器展示页面

这也给出了排错顺序。连接失败通常先看服务和端口;返回 404 先看路由和查询条件;模板不存在再查模板目录;数据库报错则查连接与迁移。不要把所有错误都归因于“Django 没装好”。

3. 完整配置文件

配套项目使用一个配置文件,通过环境变量区分本地与生产。这种组织方式适合本系列规模。将来环境增多时,可以再拆成 base.pydevelopment.pyproduction.py,并通过 DJANGO_SETTINGS_MODULE 选择入口。

以下是完整 config/settings.py

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"},
}

从上到下可以分成五组:目录与环境、应用与中间件、模板与路由、数据库与语言、安全与日志。下面结合本例解释。

4. DEBUG、SECRET_KEY 与域名

DEBUG=True 便于定位开发错误,但生产环境的错误页面不应展示内部配置和调用栈。本例在 Compose 中固定 DJANGO_DEBUG=0,并在生产密钥缺失时直接拒绝启动。

环境变量本身是字符串。bool("0") 仍然为真,因此本例明确使用 == "1" 解析开关。输入 trueyes 不会被本例识别为开启,文档和部署配置统一使用 01

SECRET_KEY 用于 Django 的签名机制等功能,应为足够长的随机值。开发用回退值只方便本机演示。它与用户的登录密码、数据库密码是不同的凭据。

ALLOWED_HOSTS 接受主机名或 IP,不带 https://、端口和路径。例如:

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

这两项解决不同问题:前者验证请求的 Host,后者影响跨来源表单请求的来源信任。对于正常的同源请求,正确的 Host、协议和 CSRF token 才是基础。CSRF_TRUSTED_ORIGINS 不是关闭 CSRF,也不是 CORS 配置。Django 设置参考

5. 应用为什么要注册

python manage.py startapp blog 只生成目录。把 "blog.apps.BlogConfig" 加入 INSTALLED_APPS 后,Django 才能将它纳入应用注册表、模型加载、迁移、应用模板和静态文件查找流程。

后台能否展示模型,还取决于 admin.py 是否注册;前台能否访问业务视图,还取决于路由是否连接。这三件事不能互相替代:

创建目录 → 注册应用 → 定义并注册模型/路由

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

本例保留常见的默认中间件组合。SessionMiddleware 先让请求具备会话能力,AuthenticationMiddleware 随后才能根据会话确定用户。CsrfViewMiddleware 检查需要保护的请求,MessageMiddleware 支持“保存成功”等跨跳转提示。

中间件在请求方向按列表顺序进入,响应方向按相反顺序返回。因此,修改顺序可能改变行为,不能把列表当作无序开关。中间件工作机制

7. 路由是显式连接的

完整 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"),
]

总路由把博客交给 include("blog.urls")。博客内部再将 /p/<slug>/ 分配给详情页,把 /posts/<数字>/edit/ 分配给编辑页。

<int:pk> 只接受整数,<slug:slug> 接受 ASCII 字母、数字、连字符和下划线。本例的中文文章标题与英文网址标识分开保存,例如标题“我的第一篇 Django 文章”,网址标识 my-first-django-post

app_name = "blog" 定义命名空间,所以可以使用 reverse("blog:list") 或模板中的 {% url 'blog:list' %}。以后改变 URL 路径时,不必在页面里逐个寻找硬编码链接。URL 分发规则

以上路由指向的是完整示例里的视图。若从空项目手工搭建,尚未定义的视图会导致启动时报 AttributeError;只接入已实现的视图,或者先按第五至七篇补齐它们。

8. 模板、静态文件与时区

TEMPLATES[0]["DIRS"] 把项目根目录的 templates/ 加入搜索路径。APP_DIRS=True 同时允许查找已安装应用里的 templates/。本例采用统一模板目录,布局清晰,便于初学者寻找文件。

STATIC_URL 是浏览器访问静态文件的地址前缀,STATIC_ROOTcollectstatic 的输出目录。源样式位于 blog/static/blog/style.css,发布时汇集到 staticfiles/;不要把这个输出目录作为平时编辑 CSS 的地方。

TIME_ZONE="Asia/Shanghai" 配合 USE_TZ=True 使用。业务代码获取当前时间时使用 django.utils.timezone.now(),避免混用有时区与无时区的时间对象。数据库和展示层的时间表达不同,不应仅凭数据库工具里显示的小时数判断时间是否存错。时区支持

9. 如何确认配置真的生效

本地默认使用 SQLite。要临时验证生产模式,可以在 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"])'

预期输出包括 Falsedjango.db.backends.sqlite3。本例 check 不会因此自动开启 HTTPS;是否启用 HTTPS 取决于 DJANGO_HTTPS 及代理设置。验证后恢复本地调试:

unset DJANGO_DEBUG DJANGO_SECRET_KEY DJANGO_ALLOWED_HOSTS

不要为了排查一个开关而把所有配置完整打印到共享日志,里面可能有数据库密码。只查看当前需要的字段即可。下一篇 Django3:数据库 将把博客业务具体映射到数据库表。


上一篇:Django1:部署——从本地启动到服务器运行 · 下一篇:Django3:数据库——模型设计、关系与迁移