Python 基础体系 · 第 79/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Django 完整基础:项目、请求链、模型、模板、Admin 和安全

Django 是一个以“项目配置 + 可复用应用 + ORM + 模板 + 中间件 + 管理后台”为核心的全栈 Web 框架。它把 HTTP 请求处理、数据库访问、HTML 渲染、用户认证、表单校验和后台管理组织成一套相互连接的机制。

本文以 Django 5.2 文档为范围,示例使用 Python 3.14。Django 5.2 的官方教程说明其支持 Python 3.10 及更高版本,因此 Python 3.14 在该范围内。(docs.djangoproject.com)


一、先建立 Django 的整体模型

一个 Django 应用通常同时包含以下几层:

flowchart LR
    C[浏览器或 API 客户端]
    S[WSGI/ASGI 服务器]
    D[Django 项目]
    M[Middleware 中间件]
    U[URL Dispatcher URL 调度器]
    V[View 视图]
    F[Form / Serializer]
    O[ORM QuerySet]
    DB[(数据库)]
    T[Template 模板]
    R[HttpResponse / DRF Response]

    C --> S --> D --> M --> U --> V
    V --> F
    V --> O --> DB
    V --> T --> R --> C

其中:

  • 项目 Project:全局配置和 URL 根入口的集合。
  • 应用 App:实现某一类业务功能的可复用模块。
  • 请求链 Request Chain:请求从服务器进入 Django,经过中间件、URL 解析、视图、ORM、模板,再返回响应的过程。
  • 模型 Model:Python 类与数据库表之间的映射。
  • 模板 Template:把上下文数据渲染为 HTML 或其他文本。
  • Admin:基于模型元数据生成的内部管理后台。
  • 安全机制:对输入、输出、会话、请求来源、数据库查询和传输过程进行分层防护。

Django 并不要求所有项目都使用模板。一个项目可以只提供 JSON API,也可以只渲染 HTML,还可以同时提供网页和 API。


二、项目、应用与启动过程

2.1 创建虚拟环境

mkdir django-blog
cd django-blog

python3.14 -m venv .venv
source .venv/bin/activate

Windows PowerShell:

py -3.14 -m venv .venv
.venv\Scripts\Activate.ps1

安装 Django:

python -m pip install "Django>=5.2,<5.3"

查看版本:

python -m django --version

预期输出类似:

5.2.x

这里使用版本上限是为了避免未来安装到不兼容的更高主版本。生产项目应将实际依赖写入锁定文件,例如:

Django==5.2.6

具体补丁版本应根据项目当前依赖和安全公告确定,不应仅凭文章中的示例版本。


2.2 创建项目

django-admin startproject config .

目录大致如下:

django-blog/
├── manage.py
└── config/
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

manage.py

manage.py 是项目级命令入口。它主要完成两件事:

  1. 设置 DJANGO_SETTINGS_MODULE
  2. 调用 Django 的命令执行器。

例如:

python manage.py check
python manage.py runserver
python manage.py makemigrations
python manage.py migrate

manage.py 本身不是 Web 服务器,也不是业务代码。它只是把项目配置加载进 Django 命令系统。

settings.py

settings.py 是配置模块,典型配置包括:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

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": [],
        "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"

INSTALLED_APPS 决定 Django 会加载哪些应用。应用不仅可以包含 Python 代码,还可能包含模型、迁移、模板、静态文件和 Admin 注册代码。

wsgi.pyasgi.py

两者都暴露一个可被服务器调用的 Django 应用对象:

application = get_wsgi_application()

或:

application = get_asgi_application()

区别在于服务器接口:

  • WSGI:传统同步 Web 服务器接口。
  • ASGI:支持异步调用、WebSocket 等异步协议的接口。

async def 视图并不意味着所有数据库操作都会自动变成异步。同步 ORM 操作、第三方库和部署服务器是否真正支持异步,需要分别确认。不能因为使用 ASGI 就认为整个请求链天然非阻塞。


2.3 创建应用

python manage.py startapp blog

目录:

blog/
├── __init__.py
├── admin.py
├── apps.py
├── migrations/
│   └── __init__.py
├── models.py
├── tests.py
└── views.py

把应用加入项目:

# config/settings.py

INSTALLED_APPS = [
    # Django 内置应用
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    # 自己的应用
    "blog",
]

项目与应用的区别

项目回答:

这个 Django 系统如何配置、有哪些根 URL、使用哪些中间件和数据库?

应用回答:

这个系统中的某项业务功能如何实现?

例如,一个电商项目可以包含:

shop_project/
├── config/
├── accounts/
├── products/
├── orders/
└── payments/

项目不是业务领域,应用才通常对应业务领域或可复用功能。


三、请求链:一个请求到底如何执行

Django 的请求处理可以抽象为:

客户端
  ↓
WSGI/ASGI 服务器
  ↓
Django handler
  ↓
请求前中间件
  ↓
URL 解析
  ↓
视图
  ↓
模型、表单、模板或序列化器
  ↓
响应
  ↓
请求后中间件
  ↓
客户端

更精确地说,中间件形成一个嵌套调用结构:

response = middleware_1(
    middleware_2(
        middleware_3(
            view(request)
        )
    )
)

请求阶段从上到下进入;响应阶段从下到上返回。


3.1 URL 调度器

项目根 URL:

# config/urls.py

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),
]

应用 URL:

# blog/urls.py

from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
    path("", views.article_list, name="article-list"),
    path("articles/<int:pk>/", views.article_detail, name="article-detail"),
]

URL 调度器的作用是:

  1. 从根 URL 配置开始匹配;
  2. urlpatterns 顺序尝试;
  3. 匹配成功后提取路径参数;
  4. 调用对应视图;
  5. 匹配失败时返回 404。

path("articles/<int:pk>/", ...) 中的 <int:pk> 会把路径片段转换为整数,并作为关键字参数传给视图:

def article_detail(request, pk):
    ...

请求:

GET /articles/12/

相当于调用:

article_detail(request, pk=12)

URL 名称与反向解析

不要在模板和 Python 代码中到处硬编码 URL:

<a href="/articles/12/">查看</a>

应使用名称:

<a href="{% url 'blog:article-detail' article.pk %}">
    查看
</a>

Python 中:

from django.urls import reverse

url = reverse("blog:article-detail", kwargs={"pk": article.pk})

这样修改路径结构时,不需要同步修改所有调用方。


3.2 视图

视图是接收请求并返回响应的可调用对象。

最简单的函数视图:

# blog/views.py

from django.http import HttpResponse


def hello(request):
    return HttpResponse("Hello Django")

视图的输入通常是 HttpRequest,输出必须是响应对象,例如:

  • HttpResponse
  • JsonResponse
  • TemplateResponse
  • 重定向响应

错误示例:

def broken_view(request):
    return {"message": "hello"}  # 错误:不是 HttpResponse

Django 不能把普通字典直接当成 HTTP 响应返回。


3.3 render() 的真实含义

HTML 视图通常使用:

from django.shortcuts import render

def article_list(request):
    articles = Article.objects.all()
    return render(
        request,
        "blog/article_list.html",
        {"articles": articles},
    )

render() 并不是魔法函数,它可以理解为:

template = loader.get_template("blog/article_list.html")
content = template.render({"articles": articles}, request)
return HttpResponse(content)

它完成三个步骤:

  1. 根据模板名称加载模板;
  2. 构造上下文;
  3. 把上下文渲染为字符串并封装为 HTTP 响应。

3.4 中间件

中间件是包裹请求处理过程的组件。典型用途包括:

  • 安全响应头;
  • Session;
  • CSRF;
  • 用户认证;
  • 请求日志;
  • 统一异常处理;
  • 访问控制;
  • 性能计时。

例如:

class RequestLogMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        print("request:", request.method, request.path)

        response = self.get_response(request)

        print("response:", response.status_code)
        return response

加入配置:

MIDDLEWARE = [
    "config.middleware.RequestLogMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ...
]

中间件顺序有因果关系。例如:

  • AuthenticationMiddleware 依赖 SessionMiddleware
  • CsrfViewMiddleware 应位于正常请求链中;
  • 自定义中间件如果需要读取 request.user,必须放在认证中间件之后;
  • 如果中间件在调用 get_response() 之前直接返回响应,后续视图不会执行。

因此,中间件不是普通工具函数,而是请求链中的控制节点。


3.5 404、403、500 与异常传播

Django 中常见的异常与响应关系:

情况 常见结果
URL 无法匹配 404
get_object_or_404() 找不到对象 404
CSRF 校验失败 403
用户无权限 403
未捕获异常 500
显式重定向 301、302、303、307 或 308

示例:

from django.shortcuts import get_object_or_404, render

def article_detail(request, pk):
    article = get_object_or_404(Article, pk=pk)
    return render(
        request,
        "blog/article_detail.html",
        {"article": article},
    )

它等价于:

try:
    article = Article.objects.get(pk=pk)
except Article.DoesNotExist:
    raise Http404

生产环境不应依赖 runserver 承担部署职责。Django 官方文档将 runserver 定位为开发服务器,生产环境应使用合适的 WSGI 或 ASGI 服务器,并通过 check --deploy 检查部署配置。


四、模型:Python 类、数据库表和领域约束

Django 模型是继承自 django.db.models.Model 的 Python 类:

from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    published_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

Django 根据模型字段生成数据库结构。官方文档将模型层划分为模型定义、字段、关系、QuerySet、实例方法、迁移和事务等部分。(docs.djangoproject.com)

4.1 字段选项的区别

name = models.CharField(
    max_length=100,
    null=False,
    blank=False,
    unique=True,
)

几个选项作用不同:

  • max_length:字段最大长度,通常也影响表单校验。
  • null:数据库是否允许存储 NULL
  • blank:表单和模型验证是否允许空值。
  • unique:数据库中是否允许重复。
  • default:创建对象时的默认值。

null=Trueblank=True 不是同一回事:

description = models.TextField(
    null=True,
    blank=True,
)

这表示:

  • 数据库层可以存储 NULL
  • 表单验证层允许留空。

对于字符串字段,项目通常需要明确是否使用 NULL 表示缺失,还是使用空字符串表示缺失。两种状态同时存在会增加查询复杂度:

description__isnull=True
description=""

4.2 关系字段

一对多:ForeignKey

class Comment(models.Model):
    article = models.ForeignKey(
        Article,
        on_delete=models.CASCADE,
        related_name="comments",
    )
    content = models.TextField()

数据库中通常是:

comment.article_id → article.id

on_delete=models.CASCADE 表示删除文章时级联删除评论。

常见选项:

  • CASCADE:级联删除;
  • PROTECT:存在关联对象时禁止删除;
  • SET_NULL:将外键设为 NULL,要求 null=True
  • SET_DEFAULT:设置为默认对象;
  • DO_NOTHING:Django 不主动处理,可能由数据库约束报错。

选择 CASCADE 不是“默认最佳实践”。例如订单关联用户时,随意级联删除可能造成审计数据丢失;此时可能需要软删除或 PROTECT

多对多:ManyToManyField

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)


class Article(models.Model):
    title = models.CharField(max_length=200)
    tags = models.ManyToManyField(Tag, blank=True)

Django 会创建中间表:

article_id | tag_id

保存文章后才能添加标签,因为中间表需要双方的主键:

article = Article.objects.create(title="Django", body="...")
tag = Tag.objects.create(name="Python")

article.tags.add(tag)

一对一:OneToOneField

class Profile(models.Model):
    user = models.OneToOneField(
        "auth.User",
        on_delete=models.CASCADE,
    )
    display_name = models.CharField(max_length=100)

它适合表示“一个对象最多对应另一个对象”的关系,例如用户和用户资料。


五、迁移:模型代码如何变成数据库结构

迁移是数据库模式的版本控制机制。模型修改后,通常执行:

python manage.py makemigrations
python manage.py migrate

5.1 两个命令的区别

makemigrations

models.py 的变化
        ↓
生成 blog/migrations/0001_initial.py

它只生成迁移文件,不会修改数据库。

migrate

迁移文件
        ↓
数据库表、列、索引、约束

它会根据迁移依赖顺序执行尚未应用的迁移。

查看迁移状态:

python manage.py showmigrations

可能输出:

admin
 [X] 0001_initial
 [X] 0002_logentry_remove_auto_add
auth
 [X] 0001_initial
blog
 [X] 0001_initial

[X] 表示已经应用,[ ] 表示尚未应用。

5.2 迁移不是简单的 SQL 文本

迁移描述的是数据库状态变化。例如:

class Migration(migrations.Migration):
    dependencies = [
        ("blog", "0001_initial"),
    ]

    operations = [
        migrations.AddField(
            model_name="article",
            name="slug",
            field=models.SlugField(default="", max_length=200),
        ),
    ]

增加非空字段时,如果已有数据,数据库无法凭空知道旧行应该填什么。因此 Django 要求:

  • 提供默认值;
  • 允许为空;
  • 或分阶段迁移。

风险示例:

slug = models.SlugField(unique=True)

如果表中已有多行数据,直接添加唯一且非空字段通常无法安全完成。更稳妥的流程是:

  1. 先添加可空字段;
  2. 回填每一行;
  3. 检查唯一性;
  4. 再添加非空和唯一约束。

六、QuerySet:惰性查询与数据库执行时机

Django ORM 使用 QuerySet 表达查询:

articles = Article.objects.filter(
    published_at__isnull=False
).order_by("-published_at")

此时通常还没有执行 SQL。QuerySet 具有惰性,以下操作会触发求值:

list(articles)
for article in articles:
    print(article.title)

len(articles)
bool(articles)
articles[0]

例如:

queryset = Article.objects.filter(title__icontains="django")

这只是构造查询;直到模板循环或显式转换时,数据库才真正执行。

6.1 查询、更新和删除

Article.objects.get(pk=1)
Article.objects.filter(title__startswith="Django")
Article.objects.exclude(published_at__isnull=True)
Article.objects.order_by("-created_at")
Article.objects.values("id", "title")

更新:

Article.objects.filter(pk=1).update(title="新标题")

update() 直接执行 SQL,不会为每一行构造模型实例,也不会调用模型的 save() 方法。若依赖 save() 中的自定义逻辑,不能用 update() 替代。

删除:

Article.objects.filter(pk=1).delete()

对于批量删除,级联关系、数据库约束和信号行为都需要确认。

6.2 get()filter()first()

Article.objects.get(pk=1)

要求结果恰好一条:

  • 没找到:DoesNotExist
  • 找到多条:MultipleObjectsReturned
Article.objects.filter(pk=1)

始终返回 QuerySet,即使没有结果也不会抛出 DoesNotExist

Article.objects.filter(pk=1).first()

返回对象或 None。它适合“找第一条即可”的场景,但如果业务要求主键唯一,应使用 get(),这样数据异常能尽早暴露。


七、事务:多个数据库操作如何保持一致

事务保证一组数据库操作满足原子性:

from django.db import transaction

with transaction.atomic():
    article = Article.objects.create(
        title="事务示例",
        body="正文",
    )
    Comment.objects.create(
        article=article,
        content="第一条评论",
    )

如果代码块中抛出异常,事务回滚;如果正常结束,事务提交。

7.1 为什么需要事务

假设转账需要两步:

sender.balance -= amount
sender.save()

receiver.balance += amount
receiver.save()

如果第一步成功、第二步失败,系统会出现钱被扣除但未到账的中间状态。使用事务:

from django.db import transaction

with transaction.atomic():
    sender.balance = F("balance") - amount
    sender.save(update_fields=["balance"])

    receiver.balance = F("balance") + amount
    receiver.save(update_fields=["balance"])

这里还涉及并发问题。两个请求同时读取相同余额时,仅使用 Python 中的数值计算可能造成“丢失更新”。F() 表达式让数据库直接执行相对更新,减少读取—修改—写入之间的竞态。

对于需要读取后加锁的场景,可使用:

with transaction.atomic():
    account = (
        Account.objects
        .select_for_update()
        .get(pk=account_id)
    )
    if account.balance < amount:
        raise ValueError("余额不足")

    account.balance -= amount
    account.save(update_fields=["balance"])

select_for_update() 通常要求数据库支持行级锁,并且必须在事务中使用。SQLite 的锁语义与 PostgreSQL、MySQL 不同,不能假设所有数据库表现一致。


八、模板:数据如何变成 HTML

Django 模板是文本模板,不是嵌入 Python 的执行环境。它支持变量、标签、过滤器、模板继承等机制,但默认不会执行任意 Python 表达式。(docs.djangoproject.com)

创建目录:

blog/
└── templates/
    └── blog/
        ├── article_list.html
        └── article_detail.html

列表视图:

# blog/views.py

from django.shortcuts import get_object_or_404, render
from .models import Article


def article_list(request):
    articles = (
        Article.objects
        .filter(published_at__isnull=False)
        .order_by("-published_at")
    )
    return render(
        request,
        "blog/article_list.html",
        {"articles": articles},
    )


def article_detail(request, pk):
    article = get_object_or_404(Article, pk=pk)
    return render(
        request,
        "blog/article_detail.html",
        {"article": article},
    )

模板:

<!-- blog/templates/blog/article_list.html -->

<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="utf-8">
    <title>文章列表</title>
</head>
<body>
    <h1>文章列表</h1>

    {% for article in articles %}
        <article>
            <h2>
                <a href="{% url 'blog:article-detail' article.pk %}">
                    {{ article.title }}
                </a>
            </h2>

            <time>
                {{ article.published_at|date:"Y-m-d H:i" }}
            </time>

            <p>{{ article.body|truncatewords:30 }}</p>
        </article>
    {% empty %}
        <p>暂无文章。</p>
    {% endfor %}
</body>
</html>

模板中:

{{ article.title }}

是变量输出。

{% for article in articles %}

是控制流程标签。

{{ article.body|truncatewords:30 }}

是过滤器链。

8.1 自动转义

Django 模板默认对 HTML 输出进行自动转义。例如上下文变量为:

{"name": "<script>alert(1)</script>"}

模板:

<p>{{ name }}</p>

输出中的 <> 等字符会被转义,因此不会直接作为脚本执行。

危险写法:

{{ user_content|safe }}

safe 会告诉模板系统“不要再转义”。只有当内容经过可靠清洗,或本身是可信的固定 HTML 时,才可以使用。自动转义不能防护所有上下文,例如把用户输入直接拼进 JavaScript、CSS 或 URL 属性中仍可能产生漏洞。

8.2 模板继承

基础模板:

<!-- blog/templates/blog/base.html -->

<!doctype html>
<html lang="zh-CN">
<head>
    <title>{% block title %}博客{% endblock %}</title>
</head>
<body>
    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

子模板:

<!-- blog/templates/blog/article_detail.html -->

{% extends "blog/base.html" %}

{% block title %}{{ article.title }}{% endblock %}

{% block content %}
    <h1>{{ article.title }}</h1>
    <p>{{ article.body }}</p>
{% endblock %}

模板继承解决的是重复 HTML 结构,不应把复杂业务逻辑塞进模板。查询、权限判断、数据聚合应在视图、表单、模型方法或服务层中完成。


九、表单与 POST 请求

Django 表单负责:

  1. 从请求中读取输入;
  2. 类型转换;
  3. 字段校验;
  4. 生成错误信息;
  5. 重新渲染表单;
  6. 必要时保存模型。

定义表单:

# blog/forms.py

from django import forms
from .models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "body", "published_at"]

视图:

# blog/views.py

from django.shortcuts import redirect, render
from .forms import ArticleForm


def article_create(request):
    if request.method == "POST":
        form = ArticleForm(request.POST)

        if form.is_valid():
            form.save()
            return redirect("blog:article-list")
    else:
        form = ArticleForm()

    return render(
        request,
        "blog/article_form.html",
        {"form": form},
    )

模板:

<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">保存</button>
</form>

这里使用 PRG,即 Post/Redirect/Get

POST 保存
  ↓
302 Redirect
  ↓
GET 列表页

如果保存成功后直接返回 HTML,用户刷新页面可能重复提交 POST。重定向把刷新动作变成 GET,降低重复提交风险,但对于支付、订单等场景仍需使用幂等键和数据库约束。


十、Admin:基于模型元数据的管理后台

Django Admin 是面向可信内部用户的模型管理界面。官方文档明确指出,Admin 适合组织内部管理工具,不适合直接替代整个面向用户的前端。(docs.djangoproject.com)

创建管理员:

python manage.py createsuperuser

输入用户名、邮箱和密码后启动:

python manage.py runserver

访问:

/admin/

注册模型:

# blog/admin.py

from django.contrib import admin
from .models import Article, Comment, Tag


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ["title", "published_at", "created_at"]
    list_filter = ["published_at", "created_at"]
    search_fields = ["title", "body"]
    ordering = ["-created_at"]


@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
    list_display = ["article", "created_at"]
    search_fields = ["content"]


@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ["name"]
    search_fields = ["name"]

ModelAdmin 是模型在 Admin 中的展示和操作配置。常用选项:

  • list_display:列表页显示列;
  • list_filter:右侧过滤器;
  • search_fields:搜索字段;
  • ordering:默认排序;
  • readonly_fields:只读字段;
  • fieldsets:编辑页分组;
  • autocomplete_fields:关联对象自动补全;
  • list_select_related:列表页优化外键查询。

10.1 Admin 权限不是业务权限的全部

Django Admin 依赖认证、用户、组和模型权限。默认模型权限通常包括:

  • add;
  • change;
  • delete;
  • view。

但“能修改模型”不等于“能执行所有业务操作”。例如:

  • 运营人员可以修改文章标题;
  • 但不能修改已发布订单金额;
  • 管理员可以查看用户;
  • 但不应看到用户密码哈希以外的敏感认证信息。

复杂流程应通过自定义 Admin action、权限判断或独立业务视图实现,而不是仅依赖字段是否出现在 Admin 表单中。


十一、安全:Django 的防护边界

Django 的安全是分层的。开启某一个中间件,不代表整个应用自动安全。官方安全文档将 XSS、CSRF、SQL 注入、点击劫持、HTTPS、Host 校验、Session 和用户上传文件分别列为安全主题。(docs.djangoproject.com)


11.1 XSS:输出上下文中的不可信内容

XSS,即跨站脚本攻击,核心条件是:

攻击者可控输入
  +
输入进入浏览器可执行上下文
  +
没有正确编码或清洗

普通模板输出:

{{ comment.content }}

默认会自动转义,属于较安全路径。

危险写法:

<div>{{ comment.content|safe }}</div>

如果评论内容是:

<img src=x onerror=alert(1)>

使用 safe 后可能被浏览器解析为 HTML。

反例:

<script>
    const name = "{{ user.name }}";
</script>

即使模板有 HTML 自动转义,也不能把它当作 JavaScript 字符串安全编码方案。应使用 JSON 编码、data-* 属性并配合正确的上下文编码。


11.2 CSRF:伪造浏览器携带凭证的请求

CSRF,即跨站请求伪造,常见于基于 Cookie 的会话认证。

攻击条件:

  1. 用户已经登录;
  2. 浏览器会自动携带 Session Cookie;
  3. 攻击者诱导用户访问恶意页面;
  4. 恶意页面向目标网站发起状态修改请求;
  5. 目标网站无法确认请求是否来自自己的页面。

Django 表单中应包含:

<form method="post">
    {% csrf_token %}
    ...
</form>

前端发送 AJAX 请求时,需要读取 CSRF Cookie 并设置请求头:

fetch("/articles/create/", {
    method: "POST",
    headers: {
        "X-CSRFToken": csrftoken,
        "Content-Type": "application/json"
    },
    body: JSON.stringify(data)
});

CSRF 与 CORS 不是同一个问题:

  • CORS 控制一个源的浏览器脚本能否读取另一个源的响应;
  • CSRF 防止攻击者借助用户已有凭证执行状态修改。

如果 API 使用 Authorization: Bearer ...,通常不依赖浏览器自动携带的 Session Cookie,因此 CSRF 模型不同;但认证、权限、令牌泄露和重放风险仍然存在。


11.3 SQL 注入与 ORM 查询

安全写法:

Article.objects.filter(title__icontains=request.GET.get("q", ""))

Django 会把查询值作为参数绑定,而不是把输入拼接成 SQL 语句。

危险写法:

from django.db import connection

sql = f"SELECT * FROM blog_article WHERE title = '{title}'"

with connection.cursor() as cursor:
    cursor.execute(sql)

如果 title 是:

' OR '1'='1

拼接后的 SQL 语义就可能被改变。

必须使用参数化:

with connection.cursor() as cursor:
    cursor.execute(
        "SELECT * FROM blog_article WHERE title = %s",
        [title],
    )

即使使用 ORM,RawSQLextra()、动态排序字段和自定义数据库函数仍可能引入风险。用户只能选择白名单字段:

ORDERING_FIELDS = {
    "newest": "-created_at",
    "oldest": "created_at",
}

ordering = ORDERING_FIELDS.get(
    request.GET.get("ordering"),
    "-created_at",
)

articles = Article.objects.order_by(ordering)

不要直接写:

Article.objects.order_by(request.GET["ordering"])

11.4 Host、HTTPS 与安全 Cookie

生产配置至少应审查:

DEBUG = False

ALLOWED_HOSTS = [
    "example.com",
    "www.example.com",
]

CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000

SECURE_HSTS_SECONDS 不是可以盲目开启的配置。HSTS 会让浏览器在一段时间内强制使用 HTTPS;如果域名、子域名或证书链尚未准备好,可能造成访问中断。Django 的系统检查也会对 SECRET_KEY、CSRF 中间件、点击劫持和安全 Session Cookie 给出警告。(docs.djangoproject.com)

运行部署检查:

python manage.py check --deploy

它只能检查 Django 能识别的配置,不能代替:

  • 反向代理配置检查;
  • TLS 证书检查;
  • 依赖漏洞扫描;
  • 权限模型审计;
  • 日志和密钥泄露审计。

11.5 SECRET_KEY 与敏感配置

不要把生产密钥提交到 Git:

SECRET_KEY = "django-insecure-..."

开发环境可以使用自动生成值,生产环境应从环境变量或密钥管理系统读取:

import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

如果密钥泄露,攻击者可能伪造签名数据、影响 Session 或其他依赖 Django 签名机制的功能。更换密钥可能使已有 Session、密码重置链接或签名数据失效,因此需要制定轮换与恢复流程。


11.6 用户上传文件

上传文件的风险不止是“文件名不可信”,还包括:

  • 文件内容伪装;
  • 超大文件导致资源耗尽;
  • SVG 或 HTML 被浏览器当作可执行内容;
  • 文件路径穿越;
  • 压缩包炸弹;
  • 恶意图片解析;
  • 上传文件被同源直接执行。

表单示例:

class AvatarForm(forms.Form):
    avatar = forms.ImageField()

视图:

def upload_avatar(request):
    if request.method == "POST":
        form = AvatarForm(request.POST, request.FILES)

        if form.is_valid():
            uploaded = form.cleaned_data["avatar"]
            # 保存前还应检查大小、类型、权限和存储策略

不要仅依据客户端提供的 Content-Type 判断文件类型。生产部署通常应把用户上传内容放在独立域名或不可执行存储中,并限制大小和可接受格式。


十二、性能边界:N+1 查询

模型关系访问很方便,但也可能隐藏大量 SQL。

articles = Article.objects.all()

for article in articles:
    print(article.author.username)

如果 author 是外键,可能产生:

1 次:查询文章
N 次:每篇文章查询作者
总计:1 + N 次查询

这就是 N+1 查询。

对于单值关系,例如 ForeignKeyOneToOneField

articles = Article.objects.select_related("author")

它通常通过 SQL JOIN 一次取出作者。

对于多值关系,例如反向外键和多对多:

articles = Article.objects.prefetch_related("tags", "comments")

它通常执行有限次数的查询,再在 Python 层组装关系。

示例:

articles = (
    Article.objects
    .select_related("author")
    .prefetch_related("tags")
    .order_by("-created_at")
)

不要机械地给所有字段都加 select_related()。应根据实际查询路径、返回数据量和 SQL 执行计划选择。查询是否优化,最终要通过 Django Debug Toolbar、数据库日志或 QuerySet.explain() 验证,而不是凭代码外观判断。


十三、从 Django 页面到 REST API

Django 的传统视图一般返回 HTML:

HttpRequest → View → Template → HttpResponse

Django REST Framework,简称 DRF,则主要用于:

Request → Authentication → Permission → Throttle
        → Serializer → ViewSet
        → JSON Response

DRF 的 APIView 会使用自己的 RequestResponse,并在进入处理方法前执行认证、权限、限流和内容协商。(django-rest-framework.org)


13.1 Serializer:验证输入和控制输出

定义序列化器:

# blog/serializers.py

from rest_framework import serializers
from .models import Article


class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = [
            "id",
            "title",
            "body",
            "published_at",
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]

Serializer 有两个方向:

Python 对象 / QuerySet
        ↓
    序列化
        ↓
JSON 等原生数据

请求数据
        ↓
    反序列化
        ↓
字段校验
        ↓
validated_data
        ↓
create() / update()

例如:

serializer = ArticleSerializer(data=request.data)

if serializer.is_valid():
    article = serializer.save()

错误时:

serializer.errors

可能得到:

{
    "title": ["This field is required."]
}

ModelSerializer 是根据模型自动生成字段和部分校验逻辑的快捷方式,但它不等于完整业务校验。跨字段约束、当前用户归属、幂等性和状态机规则仍应显式实现。DRF 官方文档将 Serializer 描述为既负责模型对象到原生数据的转换,也负责输入反序列化和校验。(django-rest-framework.org)


13.2 ViewSet 与 Router

# blog/api.py

from rest_framework import permissions, viewsets
from .models import Article
from .serializers import ArticleSerializer


class ArticleViewSet(viewsets.ModelViewSet):
    serializer_class = ArticleSerializer
    permission_classes = [
        permissions.IsAuthenticatedOrReadOnly,
    ]

    def get_queryset(self):
        return (
            Article.objects
            .filter(published_at__isnull=False)
            .select_related("author")
            .order_by("-published_at")
        )

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

注册路由:

# config/urls.py

from django.contrib import admin
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from blog.api import ArticleViewSet

router = DefaultRouter()
router.register("articles", ArticleViewSet, basename="article")

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include(router.urls)),
]

ModelViewSet 通常提供:

HTTP 方法 动作
GET /api/articles/ list
POST /api/articles/ create
GET /api/articles/1/ retrieve
PUT /api/articles/1/ update
PATCH /api/articles/1/ partial_update
DELETE /api/articles/1/ destroy

ViewSet 把一组相关动作组合在一个类中,Router 负责生成 URL。这样减少了重复路由配置,但也牺牲了一部分显式性;如果接口行为非常特殊,普通 APIView 或显式 Generic View 可能更清楚。(django-rest-framework.org)


13.3 权限:认证不等于授权

认证回答:

你是谁?

权限回答:

你是否可以做这件事?

DRF 权限类在视图主体执行前检查。如果检查失败,视图方法不会继续执行。默认权限策略是 AllowAny,因此生产 API 不应假设“配置了认证类就自动禁止匿名访问”。(django-rest-framework.org)

全局配置:

# config/settings.py

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

局部覆盖:

class ArticleViewSet(viewsets.ModelViewSet):
    permission_classes = [
        permissions.IsAuthenticatedOrReadOnly,
    ]

不同动作使用不同权限:

def get_permissions(self):
    if self.action in {"list", "retrieve"}:
        classes = [permissions.AllowAny]
    else:
        classes = [permissions.IsAuthenticated]

    return [permission() for permission in classes]

仅设置权限类还不够。列表接口的对象可见范围通常应放到 get_queryset()

def get_queryset(self):
    user = self.request.user

    if user.is_staff:
        return Article.objects.all()

    return Article.objects.filter(author=user)

原因是对象级权限通常适用于 retrieve、update、destroy;列表接口需要通过 QuerySet 过滤掉不可见对象,否则可能把不应展示的数据先返回给客户端。(django-rest-framework.org)

创建对象时,权限规则常需要放在:

  • Serializer 的 validate()
  • create()
  • ViewSet 的 perform_create()

例如,不能只依赖客户端提交的:

{
  "author": 999
}

而应由服务器根据当前认证用户设置作者:

def perform_create(self, serializer):
    serializer.save(author=self.request.user)

13.4 分页

大型列表不能无限制返回全部数据。DRF 支持多种分页方式,但只有 Generic View 和 ViewSet 会自动执行分页;直接使用 APIView 时需要手动调用分页 API。(django-rest-framework.org)

全局配置:

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": (
        "rest_framework.pagination.PageNumberPagination"
    ),
    "PAGE_SIZE": 20,
}

响应可能类似:

{
  "count": 105,
  "next": "http://example.com/api/articles/?page=2",
  "previous": null,
  "results": [
    {
      "id": 1,
      "title": "Django"
    }
  ]
}

常见策略:

  • PageNumberPagination?page=2,适合后台和普通列表;
  • LimitOffsetPagination?limit=20&offset=40,适合偏移式查询;
  • CursorPagination:适合按稳定排序持续翻页,避免深偏移的部分问题。

分页不是单纯的 UI 功能。它会影响:

  • 数据库排序;
  • 深分页性能;
  • 总数统计;
  • 数据在分页期间新增或删除时的稳定性;
  • 客户端缓存和重复消费。

13.5 限流

DRF 限流使用 Django 缓存后端保存计数状态。(django-rest-framework.org)

配置示例:

REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "30/min",
        "user": "300/min",
    },
}

限流的基本状态可以表示为:

key = 用户 ID 或 IP + scope
count = 当前时间窗口内的请求数

若 count >= limit:
    返回 429 Too Many Requests
否则:
    count += 1
    放行

DRF 内置限流实现存在并发竞态:高并发下可能放行少量超额请求。官方文档明确说明,如果业务必须严格保证并发条件下的请求数量,需要实现自己的限流类。(django-rest-framework.org)

因此:

  • 一般 API 防滥用可以使用内置限流;
  • 计费、配额、验证码、短信发送等严格计数场景,应使用原子 Redis 操作、数据库锁或专用网关;
  • 限流不能替代认证、权限和输入校验。

十四、传统 Django、DRF 与 SQLAlchemy 的边界

Django ORM 和 SQLAlchemy 2.0 都能完成数据库访问,但抽象方式不同。

Django ORM

articles = (
    Article.objects
    .filter(published_at__isnull=False)
    .select_related("author")
)

特点:

  • 与 Django Model、迁移、Admin、表单深度集成;
  • QuerySet 惰性求值;
  • 事务通过 transaction.atomic() 管理;
  • 适合 Django 全栈项目。

SQLAlchemy 2.0

概念上通常分为:

Engine → Connection / Session → ORM 映射 → select() → Transaction

示例:

from sqlalchemy import select
from sqlalchemy.orm import Session

with Session(engine) as session:
    stmt = (
        select(Article)
        .where(Article.published_at.is_not(None))
        .order_by(Article.published_at.desc())
    )

    articles = session.scalars(stmt).all()

两者都需要理解:

  • 查询何时真正执行;
  • Session 或事务的生命周期;
  • 关系加载策略;
  • 提交、回滚和异常恢复;
  • N+1 查询;
  • 数据库约束与并发。

不要把 Django 的 QuerySet、SQLAlchemy 的 Session 和 DRF 的 Serializer 当成同一层对象:

概念 所属层
Django Model 数据模型与 ORM 映射
QuerySet Django 查询表达式和结果集合
SQLAlchemy Engine 数据库连接池和方言入口
SQLAlchemy Session ORM 工作单元与身份映射
DRF Serializer API 输入输出与校验
Django Template 文本展示层
Django Admin 内部模型管理界面

例如,Serializer 不应该承担数据库连接池管理;Template 不应该承担业务权限判断;Admin 不应该被当成公开业务前端。


十五、一个完整的端到端请求

以请求:

GET /api/articles/12/

为例,完整路径如下:

  1. 浏览器或客户端建立 HTTP 请求;
  2. WSGI 或 ASGI 服务器把请求交给 Django;
  3. Django 创建 HttpRequest
  4. 中间件执行请求前逻辑;
  5. 根 URL 匹配 api/
  6. DRF Router 将路径映射到 ArticleViewSet.retrieve
  7. DRF 创建自己的 Request
  8. 执行认证;
  9. 执行权限检查;
  10. 调用 get_queryset()
  11. 根据主键获取文章;
  12. ArticleSerializer 把模型对象转换为原生 Python 数据;
  13. 内容协商选择 JSON Renderer;
  14. 生成 Response
  15. 中间件执行响应后逻辑;
  16. 服务器把 HTTP 响应返回客户端。

如果第 8 步认证失败,可能得到 401 或 403;如果第 9 步权限失败,视图主体不会执行;如果第 11 步对象不存在,通常得到 404;如果序列化或数据库阶段出现未处理异常,可能得到 500。

这条链说明了一个重要事实:

“返回 JSON”只是响应格式变化,并没有消除 URL、认证、权限、数据库、事务、异常和安全问题。


十六、诊断方法:从现象反推请求链

16.1 页面返回 404

依次检查:

python manage.py check

然后确认:

  1. config/urls.py 是否 include("blog.urls")
  2. 应用 URL 是否导入正确视图;
  3. 路径末尾斜杠是否一致;
  4. <int:pk> 是否收到非整数;
  5. 视图内部是否调用了 get_object_or_404()
  6. 请求是否命中了错误的 URL 顺序。

16.2 页面返回 403

常见原因:

  • POST 表单缺少 {% csrf_token %}
  • AJAX 缺少 X-CSRFToken
  • 用户已经认证但没有 DRF 权限;
  • ALLOWED_HOSTS 配置错误;
  • 反向代理和 HTTPS 配置不一致。

不要看到 403 就直接关闭 CSRF。应先确认请求是否来自自己的页面、Cookie 是否正确、代理是否正确传递协议头。

16.3 页面查询次数异常

检查:

articles = (
    Article.objects
    .select_related("author")
    .prefetch_related("tags")
)

然后观察:

  • 模板是否访问了外键;
  • Serializer 是否嵌套访问关系;
  • SerializerMethodField 是否每次触发查询;
  • 列表接口是否调用了未优化的 get_queryset()
  • 分页是否只限制了返回数量,却没有优化排序和关联加载。

16.4 迁移失败

先查看:

python manage.py showmigrations
python manage.py sqlmigrate blog 0001

sqlmigrate 可以查看某个迁移预计执行的 SQL。恢复时不要直接删除生产数据库迁移记录;应先判断:

  • 迁移是否部分执行;
  • 数据库是否已经改变;
  • 迁移是否可逆;
  • 是否需要新建修复迁移;
  • 是否需要先备份和回滚应用版本。

十七、必须掌握的核心边界

Django 的自动化降低了常见 Web 开发的重复劳动,但以下边界不能被自动化掩盖:

  1. 模型定义不是完整数据治理
    仍然需要数据库约束、索引、事务和并发设计。

  2. ORM 不是性能保证
    惰性 QuerySet、关系访问和嵌套 Serializer 都可能产生 N+1。

  3. 模板自动转义不是全上下文安全
    HTML、JavaScript、CSS、URL 需要不同的编码策略。

  4. 认证不等于权限
    登录用户仍然可能无权访问某个对象。

  5. Admin 不等于业务系统
    Admin 适合内部模型管理,不适合自动承载复杂公开流程。

  6. 限流不等于严格配额
    内置限流在并发下可能存在少量超额,需要严格计数时应使用原子状态存储。

  7. ASGI 不等于所有代码异步
    同步 ORM 和阻塞式第三方库仍会阻塞执行线程。

  8. 迁移文件不等于数据迁移完成
    模式变化、数据回填、约束启用和回滚策略需要分别验证。

掌握 Django 的关键,不是记住某个命令或类名,而是能够沿着完整请求链回答:

请求从哪里进入?
经过哪些中间件?
如何匹配到视图?
视图读取了哪些输入?
哪些代码访问数据库?
查询什么时候执行?
如何保证事务一致性?
谁可以访问对象?
数据如何序列化或渲染?
异常如何转成响应?
响应中是否泄露或执行了不可信内容?

当这些问题都能被明确回答时,项目、请求链、模型、模板、Admin 和安全才真正形成了一个可推理的 Django 基础体系。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。