Contents

Django整理

Contents

参考资料

一、Django 概览

什么是 Django

Django 是一个使用 Python 编写的高级 Web 框架,提供了很多内置功能,使开发者可以快速构建高效、安全的 Web 应用。

为什么使用 Django

Django 让开发者不用重新造轮子。它有活跃的开发社区、丰富的文档,并且拥有大量的第三方库支持。

设计哲学

参考:https://cloud.tencent.com/developer/article/1160415

  • 先考虑问题的各种解决方案,选择最好的方案,并将其转换为代码
  • 将代码和逻辑划分为更小的可重用单元,通过在需要的地方调用来使用这些单元
  • 不要编写过于冗长的方法,要进行逻辑拆分,并尽量使用现有方法中已经写好的逻辑

核心组件概览

Django 采用 MTV(Model-Template-View)架构模式,核心组件包括:

  • 模型(Models):定义应用数据结构,是 Django ORM 的一部分,用于将数据库表映射到 Python 代码
  • 视图(Views):控制应用的业务逻辑,负责接收 HTTP 请求、处理并返回 HTTP 响应
  • 模板(Templates):管理应用的表示层,用特定语法和标记展示数据,通常用于生成 HTML
  • URLs:定义应用的 URL 入口,映射 URL 到相应的视图函数
  • 管理界面(Admin Interface):Django 提供的自动生成的管理界面,用于管理网站内容
  • 表单(Forms):处理用户输入,进行验证,以及重新渲染表单和错误信息

请求与响应流程

理解一个 HTTP 请求如何被 Django 接收处理,以及如何生成并返回响应的过程:

  1. 用户发起 HTTP 请求
  2. 请求经过中间件层的预处理
  3. URL 路由将请求分发到对应的视图
  4. 视图处理业务逻辑,通过模型读写数据
  5. 视图将结果传递给模板渲染
  6. 响应经过中间件层的后处理
  7. 返回 HTTP 响应给用户

二、环境搭建与项目创建

Miniconda 安装与配置

  1. 测试是否安装成功
1
conda --version
  1. 设置镜像源
1
2
3
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
conda config --set show_channel_urls yes
  1. 创建虚拟环境
1
2
conda create -n djangoproj
conda info --envs

[!NOTE|style:flat] 激活某个虚拟环境 conda activate 。。。
退出当前虚拟环境 conda deactivate
删除某个虚拟环境 conda env remove -n 。。。

Django 安装与项目配置

激活虚拟环境并安装依赖:

1
2
conda activate djangoproj
conda install django==3.2 pillow pymysql markdown

安装 MySQL 5.7.x,进入 MySQL 创建数据库:

1
2
create database my_blog;
show databases;

创建工程与 App

  1. 创建工程

在合适的路径下执行:

1
django-admin startproject website

创建一个名叫 website 的工程。

  1. 创建 App

来到 website 目录下执行:

1
python manage.py startapp blog
  1. 修改 settings.py 基本配置
1
2
3
4
5
6
7
8
9
LANGUAGE_CODE = 'zh-hans'

TIME_ZONE = 'Asia/Shanghai'

USE_I18N = True

USE_L10N = True

USE_TZ = False
  1. 配置后端数据库为 MySQL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import pymysql
pymysql.install_as_MySQLdb()

# ...

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'my_blog',      # 数据库名字
        'USER': 'root',         # 账号
        'PASSWORD': '123456',   # 密码
        'HOST': '127.0.0.1',    # IP
        'PORT': '3306',         # 端口
    }
}

[!NOTE|style:flat] 注意在 runserver 前要先启动 MySQL 服务

三、URL 与路由

path 路径映射

网站地址由统一资源定位符(URL)表示,Django 中的 path() 方法可以动态构造各种不同形态的 URL,支持传入动态参数。

路径转换器类型:

  • str:匹配除路径分隔符 '/' 之外的非空字符串
  • int:匹配零或正整数
  • slug:匹配由 ASCII 字母、数字、连字符、下划线组成的字符串,如 building-your-1st-django-site
  • uuid:匹配格式化的 UUID,如 075194d3-6885-417e-a8a8-6c931e272f00
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from django.urls import path


urlpatterns = [
    # 固定地址
    path('articles/2003/', ...),

    # 可传入 int 参数
    path('articles/<int:year>/', ...),

    # 可传入 int、str 等多个参数
    path('articles/<int:year>/<str:title>/', ...),
    # 可以匹配到 /articles/2020/awesome/
]

路由的参数可以在视图中指定默认值并获取:

1
2
3
4
def some_view(request, year=2020, title='Django'):
    date = year
    name = title
    ...

注意
GET 请求中附带的参数不能直接通过视图函数的参数取得
'/articles/2020/awesome/?month=4&day=22'
上述问号后面的参数不能作为视图函数的参数,否则会报错

正确获取 GET 请求参数的方法:

1
{% url 'parse_name' 2020 'awesome' %}?month=4&day=22
1
2
3
4
def some_view(request, year=2020, title='Django'):
    month = request.GET.get('month')
    day = request.GET.get('day')
    ...

include 路径调度

include() 的作用是把 URL 的剩余部分转发到另一个 URLconf 处理,以免单个路由文件过于庞大和凌乱。通常在根路由中使用:

1
2
3
4
5
# root/urls.py
from django.urls import path, include


path('post/', include('post.urls', namespace='post')),

后端在匹配到 post/ 后,继续在 post 模块的 urls.py 中处理剩下的部分:

1
2
# post/urls.py
path('john/', some_view, name='user')

两者配合可以匹配 /post/john/ 这样的地址。

参数 namespace(应用程序命名空间)和 name(实例命名空间)用于地址反向解析:

在模板中:

1
{% url 'post:user' %}

在视图中:

1
reverse('post:user')

在不同 app 中,重复的命名互不影响。

reverse 路由解析

1
2
from django.urls import reverse
from django.shortcuts import redirect

假设有以下路由:

1
2
path('foo/', some_view, name='foo_name'),
path('login/', some_view, name='login_name'),

从页面模板链接跳转到视图:

1
<a href="{% url 'foo_name' %}">Jump</a>

如果需要从视图函数中跳转到另一个视图函数(比如根据条件转换到不同视图),可以使用 redirect() 函数:

1
return redirect(reverse('login_name'))

reverse() 函数只要路由的 name 不变,就可以解析到正确的地址。

redirect 视图重定向

model 作为 redirect() 的参数

1
2
3
def redirect_view(request, id):
    obj = SomeModel.objects.get(id=id)
    return redirect(obj)

此时 redirect() 会调用模型实例中的 get_absolute_url() 方法,所以必须在模型中定义它:

1
2
3
class SomeModel(...):
    def get_absolute_url(self):
        return reverse('some_url', args=(self.id,))

路由部分:

1
2
path('...', redirect_view, name='...'),
path('...', destination_view, name='some_url')

当请求 redirect_view() 时,redirect() 会跳转到 destination_view() 视图。

view name 作为 redirect() 的参数

假设 URL 中有:

1
path('...', another_view, name='another_url'),

可以通过视图的命名作为参数:

1
return redirect('another_url', id=id)

URL 字符串作为 redirect() 的参数

直接把 URL 字符串作为参数:

1
return redirect('/your_url/{}/'.format(post.id))

这种方式属于硬编码,尽量少用。

四、视图

基于函数的视图与基于类的视图

基于函数的视图(FBVs)是传统的视图写法,简单直观。

基于类的视图(CBVs)提供了更好的可重用性和可扩展性,适用于更复杂的场景。

在路由中的写法区别:

1
2
3
4
5
# 函数视图
path(..., function_view, ...)

# 类视图
path(..., ClassView.as_view(), ...)

类视图 as_view 源码解析

as_view() 是如何把类转化成函数的?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class View:

    @classonlymethod
    def as_view(cls, **initkwargs):
        """Main entry point for a request-response process."""
        for key in initkwargs:
            if key in cls.http_method_names:
                raise TypeError("You tried to pass in the %s method name as a "
                                "keyword argument to %s(). Don't do that."
                                % (key, cls.__name__))
            if not hasattr(cls, key):
                raise TypeError("%s() received an invalid keyword %r. as_view "
                                "only accepts arguments that are already "
                                "attributes of the class." % (cls.__name__, key))

        def view(request, *args, **kwargs):
            # 实例化类本身,赋值给 self
            self = cls(**initkwargs)
            if hasattr(self, 'get') and not hasattr(self, 'head'):
                self.head = self.get

            # 调用 setup() 对实例属性进行初始化
            self.setup(request, *args, **kwargs)
            if not hasattr(self, 'request'):
                raise AttributeError(
                    "%s instance has no 'request' attribute. Did you override "
                    "setup() and forget to call super()?" % cls.__name__
                )
            return self.dispatch(request, *args, **kwargs)

        # 属性赋值、修改函数签名等收尾工作
        view.view_class = cls
        view.view_initkwargs = initkwargs
        update_wrapper(view, cls, updated=())
        update_wrapper(view, cls.dispatch, assigned=())
        return view

as_view() 是个类方法,第一个参数 cls 表示类本身,跟实例方法的 self 类似,都是自动传入的。

进入 as_view() 后,开头 for 循环对传入参数做校验,避免将类自己的关键函数名覆盖或传入未定义的属性。

接着定义了 view() 函数,接收的参数和普通函数视图相同:request 对象以及从 URL 获取的 argskwargs

dispatch() 方法的实现:

1
2
3
4
5
6
7
8
9
class View:
    http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']

    def dispatch(self, request, *args, **kwargs):
        if request.method.lower() in self.http_method_names:
            handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
        else:
            handler = self.http_method_not_allowed
        return handler(request, *args, **kwargs)

dispatch() 非常简短但功能重要:如果 request.method 是 GET 请求,则调用 self.get() 方法;如果是 POST 请求,则调用 self.post() 方法。这就实现了根据 HTTP 请求类型派发到不同函数的功能,是类视图的核心。

核心流程总结:

  • as_view() 内部定义了 view() 函数,该函数对类视图进行初始化,返回并调用了 dispatch() 方法
  • dispatch() 根据请求类型的不同,调用不同的函数(如 get()post()),并将响应结果返回
  • as_view() 返回 view 函数闭包,供 path() 路由调用

get_object_or_404

1
from django.shortcuts import get_object_or_404, get_list_or_404

从模型中取出某个特定内容,最简单的方式是用模型管理器的 get() 方法:

1
obj = SomeModel.objects.get(id=1)

但如果数据库中没有 id=1 的数据条目,Django 会抛出 Error 500 错误。大部分时候,没有相关条目仅仅是因为资源不存在,应该抛出 Error 404 才对,因此有了 get_object_or_404()

1
obj = get_object_or_404(SomeModel, id=1)

等同于:

1
2
3
4
try:
    obj = SomeModel.objects.get(id=1)
except SomeModel.DoesNotExist:
    raise Http404("No SomeModel matches the given query.")

注意,get_object_or_404()get() 一样,只能返回单个结果,否则服务器将抛出错误。想获取多个结果请用 get_list_or_404()

1
objs = get_list_or_404(SomeModel, isMale=True)

等同于:

1
2
3
objs = list(SomeModel.objects.filter(isMale=True))
if not objs:
    raise Http404("No SomeModel matches the given query.")

五、模型与数据库

理解 ORM(对象关系映射)

关系型数据库中的数据以表的形式组织,表具有一定数量的列、任意数量的行,每张表又可以通过外键连接其他的表。

id(integer) title(string) created(datetime)
1 Django 2020-05-09 07:57:50
2 vs 2020-05-10 09:58:05
3 Flask 2020-05-17 17:00:13

关系型数据库的增删改查需要用到 SQL 语言。Django 附带了对象关系映射器(ORM),可以将数据库 SQL 映射到面向对象的 Python 中,使你可以在 Django 中像操作普通对象一样操作数据库。

ORM 允许以 Python 类的形式操作数据库表,而无需编写 SQL 代码。上面的表可以在 models.py 中创建:

1
2
3
4
class Post(models.Model):
    # id 字段不需要自己写
    title = models.TextField()
    created = models.DateTimeField()

用 UUID 作为模型主键

数据库中,主键是每个条目的标识符,通常也是唯一的。如果在定义模型时没有显式指定主键,Django 会附带一个自增的 id 主键:

1
2
3
4
class SomeModel(models.Model):
    # 下面这个 id 字段是不需要写的,Django 自动附送
    # id = models.AutoField(primary_key=True)
    ...

这个 id 主键从 1 开始计数,每有一条新数据则加 1,保证了主键不重复。通常用这个自增主键就够了,但不够安全——可以通过 id 的值轻易得知当前数据库中的数据总条目、各条数据的大致创建时间,甚至可以推断出相邻数据的主键。

可以考虑使用 UUID(全局唯一标识符),通常用 32 位的字符串来表现,例如 9cd0c6fa-846e-11ea-8191-94e6f7639b8c

1
2
3
4
5
6
import uuid    # Python 的标准库


class SomeModel(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid1, editable=False)
    ...

ForeignKey 关联不同模型

有时需要将"一对多" ForeignKey 关联到多个不同的模型。比如博客文章,它的作者既可以是独立的用户 Person,也可以是某一个群组 Group;但 ForeignKey 显然只支持关联到单个模型。

解决方式是引入桥接模型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 用户
class Person(models.Model):
    name = models.CharField(max_length=100)

# 群组
class Group(models.Model):
    name = models.CharField(max_length=100)

# 起过渡作用的桥接模型
class Owner(models.Model):
    person = models.OneToOneField(
        Person, null=True, blank=True, on_delete=models.CASCADE
    )
    group = models.OneToOneField(
        Group, null=True, blank=True, on_delete=models.CASCADE
    )

    def get_owner(self):
        if self.person is not None:
            return self.person
        elif self.group is not None:
            return self.group
        raise AssertionError("Neither is set")

# 文章
class Post(models.Model):
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

关键点是 ForeignKey 关联了一个 Owner 桥接模型,再由 Owner 与实际的作者作一对一关联:

1
owner = post.owner.get_owner()

这种方式有一些缺点,比如多了一个 Owner 桥接表、需要写更多辅助函数保证 owner 的正确性。需要用到这种技巧的主要地方是评论模块。

@property 与模型方法

写 Django 程序时,很容易让视图承担太多不应该的功能,以致让其快速膨胀。可以将适当的业务逻辑摆放到模型中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Person(models.Model):
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)

    def full_name_with_midname(self, midname):
        return f"{self.first_name} {midname} {self.last_name}"

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

在视图中调用:

1
2
name1 = person.full_name
name2 = person.full_name_with_midname('Trump')

因为用了 @property 装饰器,可以像获取变量一样调用 full_name(),很适合用于计算型变量。当模块功能复杂时,将逻辑从视图中抽离有助于保持视图的清爽,也方便复用。

模型的继承方式

抽象基类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Animal(models.Model):
    name = models.CharField(max_length=100)
    age = models.IntegerField(default=5)
    finger_count = models.IntegerField(default=10)

    class Meta:
        # 告诉 Django 这是个抽象基类
        abstract = True


class Bird(Animal):
    flying_height = models.IntegerField(default=2000)
    # 覆写父类字段
    age = models.TextField(default='5 years old')
    # 删除父类字段
    finger_count = None

Animal 是抽象基类,在迁移时不会在数据库里生成一张 Animal 的表。它的作用是把字段继承到子类中,并且这些字段可以被覆写,也可以被删除。

多表继承

与抽象基类不同,多表继承的父类和子类都会在数据库中生成表:

1
2
3
4
5
6
class Human(models.Model):
    name = models.CharField(max_length=100)


class Baby(Human):
    age = models.IntegerField(default=0)

数据库同时存在 HumanBaby 两张表,它们不是分离的,而是用外键链接起来的。保存一个 Baby 时同时也创建了一个 Humanname 字段存储在 Human 表中。多表继承其实有一个隐藏的 OneToOneField 外键:

1
2
3
4
5
6
human_ptr = models.OneToOneField(
    Human,
    on_delete=models.CASCADE,
    parent_link=True,
    primary_key=True,
)

注意
除了抽象基类外,Django 的模型是不允许覆写父类的字段的

代理模式

有时并不想改变父类的字段内容,而仅仅是想改变模型的某些行为模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Car(models.Model):
    name = models.CharField(max_length=30)


class MyCar(Car):
    class Meta:
        ordering = ["name"]
        proxy = True

    def do_something(self):
        pass

MyCar 不会在数据库中生成一张表,而仅仅是给父类 Car 添加了方法、改变了排序等。所有对 MyCar 的修改都会体现在 Car 的数据表中。

多重继承

Django 模型也可以具有多个父类。如果多个父类都包含一个 Meta 类,那么只有第一个会被使用,其他的将被忽略:

1
2
3
4
5
6
7
8
class Article(models.Model):
    article_id = models.AutoField(primary_key=True)

class Book(models.Model):
    book_id = models.AutoField(primary_key=True)

class BookReview(Book, Article):
    pass

User 模型的扩展

Django 内置了开箱即用的 User 用户模型,当满足不了实际开发需求(比如需要收集用户的手机号)时,可以进行扩展。

外链扩展

不改变 User 本身的结构,而是用 OneToOneField 将扩展字段链接起来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver


class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    phone_number = models.CharField(max_length=20)
    # And other fields you want...


@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    instance.profile.save()
# 代码中运用到了信号 signals,即每当 User 调用 save() 方法时
# 都会自动调用下面的两个函数,从而确保每个 User 都对应了一个 Profile

这种方式的好处是不改变 User 本身的结构,做更改也很灵活。缺点是多了一个外链就多了一层查询,降低了效率。

扩展 AbstractUser

AbstractUser 其实就是 User 的父类抽象模型,它提供了默认 User 的全部实现:

1
2
3
4
5
6
7
# models.py
from django.contrib.auth.models import AbstractUser


class MyUser(AbstractUser):
    phone_number = models.CharField(max_length=20)
    # And other fields you want...

然后要让 Django 知道你现在用的是自定义模型,在 settings.py 里加上 AUTH_USER_MODEL = 'app 的名称.MyUser'

此后内置后台中的 User 入口会消失,普通的 admin.site.register(MyUser) 会有问题(密码用明文存储),因为 Django 不知道应该怎么处理自定义的模型。需要修改 admin.py 的注册方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# admin.py
from django.contrib.auth.admin import UserAdmin


ADDITIONAL_FIELDS = ((None, {'fields': ('phone_number',)}),)

class MyUserAdmin(UserAdmin):
    fieldsets = UserAdmin.fieldsets + ADDITIONAL_FIELDS
    add_fieldsets = UserAdmin.fieldsets + ADDITIONAL_FIELDS

admin.site.register(MyUser, MyUserAdmin)

这段代码表示注册的定制 MyUser 基本沿用默认实现,并添加了扩展字段。回到后台,密码已经哈希过了,扩展字段也能正常显示。

这种方式的好处是把所有字段都整合到同一个表中,并且还具有默认 User 的完全实现。缺点是会改变用户模型的结构,谨慎使用。

save 更新部分字段

博客文章的模型通常包含浏览量计数、最近更新时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Post(models.Model):
    views = models.IntegerField(default=0)
    updated = models.DateTimeField(auto_now=True)

    # other fields...

    def increase_view(self):
        self.views += 1
        self.save(update_fields=['views'])
        # 注意要传入 update_fields 参数,指定更新文章浏览量,避免最近更新时间也一起更新了

每当访客打开文章详情页面时,浏览量需要加 1,所以在视图调用 increase_view

1
2
3
4
def some_view(request, id):
    post = Post.objects.get(id=id)
    post.increase_view()
    ...

F 函数更新数据

使用 F 函数更新浏览量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.db.models import F


post = Post.objects.get(id=id)
post.views = F('views') + 1
post.save()

# 重新取值
post = Post.objects.get(id=id)
# 或者
post.refresh_from_db()

使用 F 函数有几个显著的好处:

  • 减少操作次数:self.views += 1 是 Python 在内存中操作的,然后再从内存把数据更新到数据库;而 F('views') + 1 是直接操作数据库,减少了一个操作层级
  • 避免竞争:竞争是指多个 Python 线程同时对同一个数据进行更新。self.views += 1 有可能丢失其中的某些更新操作,而 F('views') + 1 由于是直接操作数据库,不会有丢失数据的问题

注意
因为 F 函数没有在内存中操作,因此更新完数据后需要重新刷新内存中的模型对象

Manager 自定义模型方法

如果需要在保存数据前先进行一些操作,可以覆写 save() 实例方法:

1
2
3
4
5
6
7
class Book(models.Model):
    title = models.CharField(max_length=100)

    def save(self, *args, **kwargs):
        # 在这里执行自定义逻辑
        ...
        super().save(*args, **kwargs)

如果需要在创建新模型时进行一些操作:

1
2
3
4
5
6
7
8
class Book(models.Model):
    title = models.CharField(max_length=100)

    @classmethod
    def create(cls, title):
        book = cls(title=title)
        # do something with the book
        return book

因为创建这个动作由类本身来执行,而不是实例:

1
2
book = Book.create('Foo')
book.save()

还有一种更推荐的方式——模型管理器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class BookManager(models.Manager):
    def create_book(self, title):
        book = self.create(title=title)
        # do something with the book
        book.save()
        return book


class Book(models.Model):
    title = models.CharField(max_length=100)

    objects = models.Manager()  # 默认管理器
    custom = BookManager()      # 自定义管理器
1
book = Book.custom.create_book('Bar')

QuerySet 查询

要从模型中检索对象,首先要构建一个管理器。管理器只能通过模型类获得,而不能通过实例获得:

1
2
3
4
5
6
7
8
9
# 默认情况下的管理器
>>> Post.objects
<django.db.models.manager.Manager object at ...>

>>> p = Post(title='...')
>>> p.objects
Traceback:
    ...
AttributeError: "Manager isn't accessible via Post instances."

常用查询方法:

1
2
3
4
5
6
7
8
9
# 获取所有对象
Post.objects.all()

# 检索特定对象
Post.objects.filter(created__year=2019)          # 获取 2019 年发布的文章
Post.objects.exclude(created__year=2019)         # 获取 2019 年以外时间发布的文章

# 链式查询
Post.objects.filter(created__year=2019).exclude(title__startswith='Foo')

这里的 __ 可以理解成 .,因为语法规则里关键字不能用 .。比如 title__startswith='Foo'startswith 不是模型字段,而是查找以 Foo 打头的 title 字段的相关数据。

查询方法汇总:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
exact       # 完全匹配
iexact      # 不区分大小写的完全匹配
contains    # 包含
icontains   # 不区分大小写的包含
in          # 被包含在给定的集合中,如 Post.objects.filter(id__in=[2, 3, 4])
gt          # 大于,如 Post.objects.filter(id__gt=3)
gte         # 大于或等于
lt          # 小于
lte         # 小于或等于
startswith  # 以 xx 开头
istartswith # 不区分大小写的以 xx 开头
endswith    # 以 xx 结尾
iendswith   # 不区分大小写的以 xx 结尾
range       # 在范围内
date        # 日期字段使用,如 Post.objects.filter(created__date=datetime.date(2020, 1, 1))
year, month, day...
isnull      # 是否为空
regex       # 匹配正则
iregex      # 不区分大小写匹配正则

此外,查询集在执行时不会原地修改,每次都会返回一个全新的子集。查询集在创建时是懒惰的,即并不会涉及数据库的操作,只有在使用的时候才会真正执行查询(但是如果是带有步长的切片,就会触发查询)。

get() 方法可以取得单个确定的对象,如果返回了多个或者零个结果会报错:

1
>>> Post.objects.get(id=1)

.create().first().last().count().exists() 都属于这一类。

Migrations 数据迁移

http://blog.dusaiphoto.com/migration_workflow.jpg

数据迁移是一个很强大的功能,让完全不了解 SQL 的人可以以面向对象的方式管理数据库,保持 model 和数据库完全同步。

makemigrations 生成迁移文件是完全不管数据表实际什么样,全部是通过 django_migrations 的记录和 migrations 文件计算出来的。迁移文件是 Django 进行迁移的重要依据且互相依赖,不要随意改动,并应该纳入版本管理。虽然它可以手动修改,但前提是你完全了解它的工作原理。

迁移工作流

新建一个项目,并在项目中创建一个叫 mig 的 app,然后必须在 INSTALLED_APPS 配置中添加 mig,并且 mig 还得带有 migrations/ 目录以及目录下的 __init__.py 文件,否则 Django 不会为这个 app 创建任何迁移。

models.py 中创建模型:

1
2
3
4
5
6
7
8
9
# mig/models.py
from django.db import models
from django.utils import timezone


class Pen(models.Model):
    price = models.IntegerField()
    color = models.CharField(default='black', max_length=20)
    purchase_date = models.DateTimeField(default=timezone.now)

执行 makemigrations 指令:

1
2
3
4
$ python manage.py makemigrations
Migrations for 'mig':
  mig\migrations\0001_initial.py
    - Create model Pen

指令执行完毕后会生成迁移文件。在执行 makemigrations 时,Django 不会检查数据库,而是根据目前模型的状态,创建一个操作列表,使项目状态与模型定义保持最新。此时数据库没有变化,直到执行 migrate 指令:

1
2
3
4
5
6
7
8
9
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, mig, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  ...
  Applying mig.0001_initial... OK
  ...

输出中不认识的迁移是 Django 自身运行所需要的表,关键是 Applying mig.0001_initial... OK,表示 mig 的迁移成功了。打开数据库可以看到多了 mig_pen 表,字段和模型完全匹配。

迁移文件

初次迁移完成后,如果发现 price 字段不应该为整型:

1
2
3
class Pen(models.Model):
    price = models.DecimalField(max_digits=7, decimal_places=2)
    ...

再次执行迁移,会多出 0002_auto_20200519_1659.py 文件。本次迁移依赖第一次迁移的 0001_initial.py,每个迁移文件记录的仅仅是和上一次的变化。migrations 目录下的迁移文件非常重要并且相互依赖,一般情况下不要随意去修改。对数据库的操作也尽可能通过迁移的方式,减少手动修改。

Django 内部有一套机制来尽可能判断用户对模型操作的具体类型,但如果你一次进行了很多复杂的改动(比如同时进行多项修改、删除、新增),它也会犯糊涂。

还可以通过指令查看迁移文件将实际执行的 SQL 操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ python manage.py sqlmigrate mig 0003
...
BEGIN;
--
-- Remove field color from pen
--
CREATE TABLE "new__mig_pen" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "price" decimal NOT NULL, "purchase_date" datetime NOT NULL);
INSERT INTO "new__mig_pen" ("id", "price", "purchase_date") SELECT "id", "price", "purchase_date" FROM "mig_pen";
DROP TABLE "mig_pen";
ALTER TABLE "new__mig_pen" RENAME TO "mig_pen";
COMMIT;

迁移记录表

不修改模型直接迁移:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ python manage.py makemigrations

No changes detected

$ python manage.py migrate

Operations to perform:
  Apply all migrations: ..., mig, ...
Running migrations:
  No migrations to apply.

没有任何迁移被执行。Django 是如何得知哪些操作已经执行过了?奥秘在于数据库中的 django_migrations 表,由 Django 自动管理,记录了每一次迁移的历史回溯:

id app name applied
14 mig 0001_initial 2020-05-19 …
15 mig 0002_auto_20200519_1659 2020-05-19 …
16 mig 0003_remove_pen_color 2020-05-19 …

表里的每一条记录都和迁移文件对应。如果这个表里已经有迁移记录,那么对应的迁移文件中的指令就不再执行。

迁移冲突与修复

手动将最后一个迁移文件 0003_remove_pen_color.py 删除,再重新执行迁移:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ python manage.py makemigrations

Migrations for 'mig':
  mig\migrations\0003_remove_pen_color.py
    - Remove field color from pen

$ python manage.py migrate

Operations to perform:
  Apply all migrations: ...mig, ...
Running migrations:
  No migrations to apply.

除了 0003_remove_pen_color.py 文件被重新创建外,没有任何事情发生,因为迁移记录表中已经有对应的 0003 号记录了,数据库操作不会重复执行。

再次手动删除 0003 文件,并新增一个模型字段:

1
2
3
4
class Pen(models.Model):
    price = models.DecimalField(max_digits=7, decimal_places=2)
    purchase_date = models.DateTimeField(default=timezone.now)
    length = models.IntegerField(default=10)

再次迁移:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ python manage.py makemigrations

Migrations for 'mig':
  mig\migrations\0003_auto_20200520_1051.py
    - Remove field color from pen
    - Add field length to pen

$ python manage.py migrate

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, demo, mig, sessions
Running migrations:
  Applying mig.0003_auto_20200520_1051... OK

虽然迁移内容不同,但由于新增字段导致 0003 号文件名称发生了变化,数据库更改还是成功执行了。

如果把 0002 号文件删了,重新迁移:

1
2
3
4
5
$ python manage.py makemigrations

Traceback (most recent call last):
  ...
django...NodeNotFoundError: Migration mig.0003_auto_20200520_1115 dependencies reference nonexistent parent node ('mig', '0002_auto_20200519_1659')

报错了,尝试修改 0003 号文件的依赖:

1
2
3
4
5
6
7
8
9
class Migration(migrations.Migration):
    dependencies = [
        # ('mig', '0002_auto_20200519_1659'),
        ('mig', '0001_initial'),
    ]

    operations = [
        ...
    ]

迁移可以成功,而且 Django 还补了个 0004 号文件把缺失的操作补上。

迁移伪造

如果遇到迁移表和数据库无法正常同步的问题,可以使用 --fake 指令伪造 django_migrations 表中的记录,欺骗 Django 的迁移状态:

1
$ python manage.py migrate --fake mig

Django 会把 mig 中现有的迁移文件的记录全补到 django_migrations。这样做能成功的前提是迁移文件本身没出问题。

六、模板与静态文件

Django 模板系统

在 Django 中,模板用来定义网站的结构和布局。模板中可以包含变量和标签,Django 渲染模板时会用上下文中的数据替换这些变量。

模板继承

模板继承允许定义一个基础"骨架"模板,包含了网站的全部基本元素,然后为特定页面创建子模板。

使用方式:

  • 在父模板中用 {% block name %}{% endblock %} 定义可覆盖的块
  • 在子模板中用 {% extends 'base.html' %} 继承父模板,并用 {% block name %} 覆盖对应块
  • 在文件顶部添加 {% load static %} 以引用静态文件

静态文件管理

Django Version >= 3.0

开发阶段

首先保证打开调试模式:

1
2
# settings.py
DEBUG = True

方案一:使用 contrib.staticfiles

确保注册了如下应用(Django 默认注册):

1
2
3
4
5
6
# settings.py
INSTALLED_APPS = [
    ...
    'django.contrib.staticfiles',
    ...
]

配置路径:

1
2
3
# settings.py
STATIC_URL = '/static/'
STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")]

接下来就可以在项目根目录下创建 static/ 目录,把静态文件都放到这个目录下。注意这个 static/ 目录与 templates/ 及其他 app 是同一级的。

在模板中引用静态文件:

1
2
3
<link rel="stylesheet" href="{% static 'hello.css' %}" />
<script src="{% static 'world.js' %}"></script>
<img src="{% static 'hexie.png' %}" alt="" />

方案二:手动配置静态文件路由

注释掉 'django.contrib.staticfiles'STATICFILES_DIRS,手动添加路由解析:

1
2
3
4
5
6
7
8
9
# urls.py
from django.conf import settings
from django.conf.urls.static import static


urlpatterns = [
    ...
]
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

这种方式的问题是后台 Admin 的静态文件都无法加载了,必须另外想办法手动管理,比如用部署时经常用到的 collectstatic 把相关文件收集起来再配置路径

部署阶段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# settings.py
DEBUG = False  # 关闭调试模式
ALLOWED_HOSTS = ['your-domain.com']  # 设置允许的域名

# 静态文件 URL 路径(保持与开发阶段一致)
STATIC_URL = '/static/'

# 指定收集静态文件的目标目录(通常与开发目录不同)
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

# 开发阶段的静态文件目录
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static'),
]

在部署服务器上执行 python manage.py collectstatic,会自动收集所有 app 的 static/ 目录下的文件和 STATICFILES_DIRS 中指定的目录下的文件,统一保存到 STATIC_ROOT 目录。

确保 Web 服务器对 STATIC_ROOT 目录有读取权限:

1
chmod -R 755 /path/to/your/project/staticfiles/

Nginx 配置示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
server {
    listen 80;
    server_name your-domain.com;

    # 处理静态文件请求
    location /static/ {
        alias /path/to/your/project/staticfiles/;  # 指向 STATIC_ROOT 目录
        expires 30d;  # 缓存设置(可选)
    }

    # 处理 Django 动态请求
    location / {
        proxy_pass http://localhost:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

重启 Nginx 使配置生效。此时 Django 就不再管理静态文件了,所有静态文件的请求都由高效的 Web 服务器直接处理,完全不经过 Django。

媒体文件配置

媒体文件(用户上传的文件)需要单独配置:

1
2
3
# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

注册到路由:

1
2
3
4
5
6
7
8
# urls.py
from django.conf import settings
from django.conf.urls.static import static


urlpatterns = [
    ...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

不配置其实也是可以上传的,只不过读取这些文件需要自己配置路径

自定义模板标签

侧边栏等公共组件可以通过自定义模板标签实现复用。

创建文件夹 blog/templatetags,在该文件夹下创建 blog_tags.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from django import template
from ..models import Entry, Category, Tag

register = template.Library()


@register.simple_tag
def get_recent_entries(num=5):
    return Entry.objects.all().order_by('-created_time')[:num]

@register.simple_tag
def get_popular_entries(num=5):
    return Entry.objects.all().order_by('-visiting')[:num]

@register.simple_tag
def get_tags():
    return Tag.objects.all()

在模板中使用时,先在文件顶部加载:{% load blog_tags %},然后调用:

1
2
3
4
{% get_recent_entries as recent_entry_list %} {% for entry in recent_entry_list
%}
<a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a>
{% endfor %}

七、表单

Django Forms 和 ModelForms

Django Forms 处理了 HTML 表单的渲染、数据验证和安全性方面。

ModelForm 是一个便捷的类,可以直接从模型生成表单,为用户提供了创建或更新模型实例的界面。

表单验证

Django Forms 包含了全面的数据验证系统。当用户提交表单时,Django 将自动检查数据,确保数据符合预期的格式且为合法值。

自定义表单字段和小部件

可以自定义表单字段和小部件(widgets),以更灵活的方式控制如何渲染表单以及如何处理表单数据。

批量上传图片

前端表单:

1
2
3
4
5
6
7
8
9
<form
  action="{% url 'app:uploads' %}"
  method="post"
  enctype="multipart/form-data"
>
  {% csrf_token %}
  <input type="file" name="file_field" multiple="multiple" />
  <input type="submit" value="提交" />
</form>

enctype="multipart/form-data" 允许表单提交文件,multiple="multiple" 允许一次提交多个文件。

配置媒体文件路径:

1
2
3
# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')

注册到路由:

1
2
3
4
5
6
7
8
9
# urls.py
from django.conf import settings
from django.conf.urls.static import static


urlpatterns = [
    ...
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

模型、视图、路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# models.py
class Image(models.Model):
    image = models.ImageField(upload_to='images/%Y%m%d')

# views.py
def uploads_files(request):
    if request.method == 'POST':
        files = request.FILES.getlist('file_field')
        for f in files:
            file = Image(image=f)
            file.save()
        return ...

# app/urls.py
path('uploads/', uploads_files, name='uploads')

文件上传把 ImageField 改成 FileField 就行
ImageField 字段依赖三方库 Pillow,请先将它安装到项目中

比如今天是 2020 年 5 月 11 日,照片会保存在 media/images/20200511/ 目录里。即使上传重名文件也没关系,Django 会妥善处理文件重命名的问题。还可以对上传的图片做校验,比如是否真的是图片、限制图片尺寸等,都可以在视图里扩展。

上传成功后在模板中引用:

1
<img src="{{ object.image.url }}" alt="" />

注意
当你在数据库中删除 Image 模型时,仅仅只是删除了模型对图片的引用,图片本体还是安然无恙的。你需要写额外的代码,手动管理文件本体的去留

CSRF 保护

Django 提供了 CSRF 令牌来防止站点受到跨站请求伪造的攻击。在表单中必须添加 {% csrf_token %} 标签。

八、认证与授权

使用 Django 的内置认证系统

Django 内置了完整的认证系统,包括用户登录、注销、注册等功能。

管理用户

了解如何使用 Django 的模型来管理用户账户信息,包括创建、修改、删除用户。

创建用户组和权限

Django 支持给用户分组,以及如何分配和管理权限。可以非常灵活地定义哪些用户可以访问哪些数据。

敏感信息保存与读取

如果项目是开源的,怎么保证 settings.py 中涉及到账户、密码等敏感信息不会泄露?拿 SECRET_KEY 举例,它跟数据库中用户密码加盐等内容是挂钩的,不能公开。

可以在项目根目录创建一个环境文件 env.json,把 SECRET_KEY 挪进去,其他环境变量也都可以放到这个文件中:

1
2
3
4
{
  "env": "dev",
  "SECRET_KEY": "ul#d-f%=-.......z(3(a^r4is6n8s"
}

然后在 settings.py 中调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# settings.py
import json


with open('env.json') as env:
    ENV = json.load(env)

SECRET_KEY = ENV['SECRET_KEY']
if ENV.get('env') == 'dev':
    DEBUG = True
else:
    DEBUG = False

同时在 .gitignore 中添加:

1
env.json

九、Session 会话

为什么需要 Session

浏览器和 Django 服务之间的通信采用 HTTP 协议,该协议是无状态的——服务器并不知道两次请求是否来自同一个用户。会话(Session)就是来解决这类问题的。

Session 为每个浏览器存储任意数据,并在浏览器连接时,将该数据提供给站点。Session 依赖 Cookie,但 Cookie 中仅保存一个识别值,真正的数据保存在数据库中。

配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# settings.py

INSTALLED_APPS = [
    ...
    # 默认开启
    'django.contrib.sessions',
    ...
]

MIDDLEWARE = [
    ...
    'django.contrib.sessions.middleware.SessionMiddleware',
    ...
]

基本用法

利用 Session 记录匿名用户的登录次数:

1
2
3
4
5
6
7
8
9
# views.py
def session_visits_count(request):
    # 获取 visits_count 数据,若不存在则设置为 0
    count = request.session.get('visits_count', 0)
    count += 1

    # 保存 visits_count 进 session
    request.session['visits_count'] = count
    return render(request, 'visits_count.html', context={'count': count})
1
2
3
4
# urls.py
urlpatterns = [
    path('visits-count/', session_visits_count, name='visits_count'),
]
1
2
3
4
<!-- visits_count.html -->
{% block content %}
<h4 class="col mt-4">您已经访问本页面:{{ count }} 次.</h4>
{% endblock %}

Session 的默认保存时间为 2 周,也可以手动删除:

1
del request.session['xxx']

注意事项

通常情况下对 session 的修改会自动保存,但如果是某种嵌套结构(比如字典),需要手动保存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def some_view(request):
    ...
    # session 保存字典数据
    if request.session.get('deeper_count'):
        num = request.session['deeper_count']['num']
        # 此时 session 并未更新,因为更新的仅仅是字典中的数据
        request.session['deeper_count']['num'] = num + 1
        # 通知会话已修改
        request.session.modified = True
    else:
        num = 1
        request.session['deeper_count'] = {'num': num}
    return ...

十、中间件

什么是中间件

中间件是 Django 处理请求和响应的钩子框架,是一个轻量级的、低层级的"插件"系统,用于全局改变 Django 的输入或输出。如果需要在响应请求时插入一个自定义功能或参数,中间件特别有用。

自定义中间件

假设有一个叫 middleware 的 app,在 app 中创建 middlewares.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# middleware/middlewares.py

class Md1:
    def __init__(self, get_response):
        self.get_response = get_response
        # (0) 参数的配置与初始化。初始化只执行一次。

    def __call__(self, request):

        # (1) 这里写实际视图执行之前的逻辑
        print('Md1 视图执行前..')

        # (2) get_response 是下一个中间件或视图函数的处理程序
        response = self.get_response(request)

        # (3) 这里写实际视图执行之后的逻辑
        print('Md1 视图执行后..')

        return response

注册 app 和中间件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# your_project/settings.py

INSTALLED_APPS = [
    ...
    'middleware',
]

MIDDLEWARE = [
    # Django 默认注册的中间件
    ...

    # 自定义中间件,注册规则:appName.fileName.className
    'middleware.middlewares.Md1',
]

编写测试视图:

1
2
3
4
5
6
7
# middleware/views.py
from django.http import HttpResponse


def mid_test(request):
    print('--- 视图执行中...')
    return HttpResponse('中间件测试..')

在项目根 urls.py 中添加路由:

1
2
3
4
5
6
from middleware.views import mid_test

urlpatterns = [
    ...
    path('middleware/', mid_test),
]

访问此路由,命令行打印结果如下:

1
2
3
Md1 视图执行前..
--- 视图执行中...
Md1 视图执行后..

执行顺序

Django 收到请求后,会根据 MIDDLEWARE 列表挨个执行中间件,所以列表里中间件的顺序很重要,有些是互相依赖的。比如 Django 默认开启的 SessionMiddlewareAuthenticationMiddleware,调换执行顺序后功能就会不正常。

中间件调用的 get_response() 方法之前的逻辑按照注册列表顺序执行,之后的逻辑逆序执行。就像洋葱一样——请求通过洋葱的每一层直到核心的视图函数,再带着响应从里面反着出来。

中间件钩子方法

process_template_response 方法仅当视图返回 TemplateResponse 对象才会被调用,通常用的 render 快捷方式不会触发它。

TemplateResponse 会延迟渲染,它包含了呈现模板之前的上下文数据,因此让中间件有机会去修改里面的变量。而 render 会立即呈现模板并返回 HttpResponse,故无法唤起此钩子方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.shortcuts import render
from django.template.response import TemplateResponse


# 无法调用 process_template_response()
def mid_test(request):
    return render(request, '....html', context={})

# 可以调用 process_template_response()
def mid_test(request):
    return TemplateResponse(request, '....html', context={})

中间件案例

用户拦截

假设有一个敏感路径,要求必须超级用户才能访问:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.http import HttpResponseForbidden


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

    def __call__(self, request):
        if (request.user.is_superuser != True) and (request.path == '/secret-url/'):
            return HttpResponseForbidden('<h1>超级用户方可访问此页面!</h1>')

        response = self.get_response(request)
        return response

此功能可以非常容易扩展为 IP 拦截——通过 request.META['REMOTE_ADDR'] 获取请求的 IP 地址,比对数据库中的黑名单进行拦截。

响应计时器

自定义中间件,记录从收到请求到完成响应所花费的时间:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from datetime import datetime


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

    def __call__(self, request):
        request._request_time = datetime.now()
        response = self.get_response(request)
        return response

    def process_template_response(self, request, response):
        response_time = request._request_time - datetime.now()
        response.context_data['response_time'] = abs(response_time)
        return response

有了这个中间件,所有的模板都可以获取到 {{ response_time }} 变量了。

十一、信号

为什么需要信号

有时候我们希望当某个事件发生时,另一个对象也能够知晓此事。例如用户在博客留言时,博主收到通知。

我们可以尝试在保存评论数据时,显式执行发送通知相关的代码:

1
2
3
4
5
6
7
8
from django.db import models
from somewhere import post_notification


class Comment(models.Model):
    def save(self, *args, **kwargs):
        post_notification()
        # ...

这样做的缺点是把评论模块和通知模块耦合到一起了。当很多模块都关心评论模块的保存事件时,代码可能变成这样:

1
2
3
4
5
6
7
8
9
class Comment(models.Model):
    def save(self, *args, **kwargs):
        post_notification()
        save_log()
        increase_count()
        do_this()
        do_that()
        blablabla()
        # ...

动了其中一个可能就引出一堆报错,不利于功能的扩展。像这种许多代码段对同一事件感兴趣时,信号就特别有用。

信号允许发送器通知接收器某些事件已经发生。优点是让模块之间解耦,缺点是隐式执行,使得调试变得更困难。

Django 内置的信号

每当一个 HTTP 请求发起、结束时的信号:

1
2
3
4
5
6
7
8
9
from django.core.signals import request_finished, request_started
from django.dispatch import receiver


# 装饰器 @receiver 将函数标注为接收器
# 其参数指定了具体的信号类型
@receiver(request_finished)
def signal_callback(sender, **kwargs):
    print('信号已接收..')

上面的代码会在每个请求结束时执行。同一个信号可以被多个接收器接收,一个接收器可以接收多个信号:

1
2
3
@receiver([request_finished, request_started])
def signal_callback(sender, **kwargs):
    print('信号已接收..')

还可以指定接收的发送器:

1
2
3
4
5
6
7
8
9
from django.db.models.signals import pre_save
from django.dispatch import receiver
from myapp.models import BookModel


# 只在 BookModel 保存前触发接收器,而在 PersonModel 保存前不触发
@receiver(pre_save, sender=BookModel)
def my_handler(sender, **kwargs):
    # ...

自定义信号

假设有一个叫 mySignal 的 App。

新建 mySignal/signals.py,注册自定义信号:

1
2
3
4
5
6
# mySignal/signals.py
import django.dispatch


# 注册信号
view_done = django.dispatch.Signal()

新建 mySignal/handlers.py,编写接收器并连接信号:

1
2
3
4
5
6
7
8
9
# mySignal/handlers.py
from django.dispatch import receiver
from mySignal.signals import view_done


@receiver(view_done, dispatch_uid="my_signal_receiver")
def my_signal_handler(sender, **kwargs):
    print(sender)
    print(kwargs.get('arg_1'), kwargs.get('arg_2'))

修改 mySignal/__init__.py

1
2
# mySignal/__init__.py
default_app_config = "mySignal.apps.MysignalConfig"

修改 mySignal/apps.py

1
2
3
4
5
6
7
8
9
# mySignal/apps.py
from django.apps import AppConfig


class MysignalConfig(AppConfig):
    name = 'mySignal'

    def ready(self):
        import mySignal.handlers

之后可以在任意位置发送这个信号:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# mySignal/views.py
from mySignal.signals import view_done


def some_view(request):
    # 发送信号
    view_done.send(
        sender='View function...',
        arg_1='My signal...',
        arg_2='received...'
    )
    # 其他代码...

重启服务器,访问此视图后,控制台输出:

1
2
View function...
My signal... received...

接收器成功唤醒,并且正确接收到信号携带的信息。

十二、事务

为什么需要事务

有些时候需要对数据库进行一连串的操作,如果其中某一个操作失败,那么其他的操作也要跟着回滚到操作以前的状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# models.py
from django.db import models


class Student(models.Model):
    """学生"""
    name = models.CharField(max_length=20)


class Info(models.Model):
    """学生的基本情况"""
    age = models.IntegerField()


class Address(models.Model):
    """学生的家庭住址"""
    home = models.CharField(max_length=100)

假设这三个模型必须同时创建,否则数据就是不完整的:

1
2
3
4
5
6
7
# views.py
def create_student(request):
    student = Student.objects.create(name='张三')
    info = Info.objects.create(age=19)
    address = Address.objects.create(home='北京')

    return HttpResponse('Create success...')

如果让程序故意引发错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def create_student(request):
    student = Student.objects.create(name='张三')
    info = Info.objects.create(age=19)

    # 引发错误
    oh_my_god = int('abc')

    address = Address.objects.create(home='北京')

    return HttpResponse('Create success...')

前面的 StudentInfo 都正常保存进数据库,但 Address 由于前一句报错而没有执行创建,因此学生信息变成了不完整的数据。

使用事务

装饰器方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.db import transaction


@transaction.atomic
def create_student(request):
    student = Student.objects.create(name='张三')

    # 回滚保存点
    save_tag = transaction.savepoint()

    try:
        info = Info.objects.create(age=19)

        # 引发错误
        oh_my_god = int('abc')

        address = Address.objects.create(home='北京')
    except:
        # 回滚到 save_tag 的位置
        transaction.savepoint_rollback(save_tag)

    return HttpResponse('Create success...')

上面的代码运行后,Student 表因为在保存点之前所以会成功保存,而另外两张表则都会失败。

with 语句方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def create_student(request):
    student = Student.objects.create(name='张三')

    # 事务
    with transaction.atomic():
        info = Info.objects.create(age=19)

        # 引发错误
        oh_my_god = int('abc')

        address = Address.objects.create(home='北京')

    return HttpResponse('Create success...')

注意
原笔记中 with transaction.atomic: 缺少括号,正确写法为 with transaction.atomic():

全局事务配置

如果想让所有的数据库操作都是事务,在 settings.py 里配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# settings.py

# 以 sqlite 为例
DATABASES = {
    'default': {
        'ENGINE': ...,
        'NAME': ...,
        # 加上这条
        'ATOMIC_REQUESTS': True,
    }
}

然后可以用 non_atomic_requests 标记不需要成为事务的视图:

1
2
3
@transaction.non_atomic_requests
def create_student(request):
    ...

数据库事务的 ACID 特性

  • 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行
  • 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束
  • 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行
  • 持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中

十三、安全实践

CSRF 保护

Django 提供了 CSRF 令牌来防止站点受到跨站请求伪造的攻击。在 POST 表单中必须添加 {% csrf_token %} 标签。

XSS 防护

通过自动转义模板中的变量,Django 帮助你预防跨站脚本攻击。模板中的变量默认会被自动转义,HTML 特殊字符会被替换为安全实体。

SQL 注入防护

Django ORM 的使用使得 SQL 注入攻击变得非常困难。除非直接使用原始 SQL,否则 Django 会处理 SQL 查询的安全性。

保护用户数据

Django 提供了用于数据加密和用户认证的工具,有助于保护用户数据和会话信息。

自定义错误页面

403、404、500 页面需要在 settings.pyDEBUG=False 的生产环境下才会生效。

首先在 views.py 中定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def permission_denied(request):
    '''403'''
    return render(request, 'blog/403.html', locals())


def page_not_found(request):
    '''404'''
    return render(request, 'blog/404.html', locals())


def page_error(request):
    '''500'''
    return render(request, 'blog/500.html', locals())

在项目根 urls.py 中添加:

1
2
3
4
5
from blog import views as blog_views

handler403 = blog_views.permission_denied
handler404 = blog_views.page_not_found
handler500 = blog_views.page_error

视图中可使用 get_object_or_404 来在资源不存在时自动返回 404 页面:

1
2
3
4
5
6
from django.shortcuts import get_object_or_404

def detail(request, blog_id):
    # 若给定了 blog_id 就进行文本的 render,否则返回 404 页面
    entry = get_object_or_404(models.Entry, id=blog_id)
    ...

十四、测试

编写测试

Django 支持编写单元测试和集成测试,以确保应用程序的各个部分都能如预期工作。

运行测试

Django 自带的测试运行器可以发现和运行测试用例,为你的代码提供一个测试环境。

测试覆盖率

测试覆盖率是衡量测试涵盖了多少代码的指标。Django 可以与 coverage.py 这样的工具集成来跟踪测试覆盖率。

十五、部署

将 Django 应用部署到生产环境

不同的部署选项包括使用传统的服务器、云服务,以及与 AWS、Heroku 等服务的集成。

配置和扩展

了解如何为生产环境配置 Django,以及处理扩展和高性能需求的方法。

使用云服务

现代部署通常涉及使用云服务平台,如 AWS、Heroku 等来托管 Django 应用程序。

Sitemap 站点地图

先在 settings.py 注册 app:

1
2
3
4
5
INSTALLED_APPS = [
    ...
    'django.contrib.sitemaps',
    ...
]

修改项目根 urls.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.contrib.sitemaps import GenericSitemap
from django.contrib.sitemaps.views import sitemap
from blog.models import Entry

info_dict = {
    'queryset': Entry.objects.all(),
    'date_field': 'modifyed_time'
}

urlpatterns = [
    ...
    path('sitemap.xml', sitemap, {'sitemaps': {'blog': GenericSitemap(info_dict, priority=0.6)}},
        name='django.contrib.sitemaps.views.sitemap'),
    ...
]

访问地址:http://127.0.0.1:8000/sitemap.xml

为什么需要 sitemap?
https://zhuanlan.zhihu.com/p/129264411
提交给百度站长平台,方便搜索引擎快速完成收录,使得文章可以被搜索到

十六、REST API(Django REST Framework)

序列化器(Serializers)

用于将复杂的数据类型(如查询集和模型实例)转换为 Python 数据类型,可以再被轻松渲染成 JSON、XML 或其他内容类型。

基于类的 API 视图

DRF 提供了一系列基于类的视图,用于处理 API 请求,使得你可以以类的继承方式来扩展和自定义视图的行为。

DRF 中的认证和权限

DRF 提供了多种方式来控制对 API 的访问,包括认证和权限框架,可以非常灵活地定义哪些用户可以访问哪些数据。

十七、Django 内部机制

理解 Django 的架构

深入 Django,了解它的整体架构和组件是如何工作的,例如 ORM、中间件层和模板引擎等。

请求和响应的流程

详见「一、Django 概览」中的「请求与响应流程」章节。

中间件

详见「十、中间件」章节。

信号

详见「十一、信号」章节。

十八、实践项目:个人博客

设计一个应用的大致流程

https://jsd.onmicrosoft.cn/gh/yjjyjya/mypicbed/about_Python/pic/%E6%B5%81%E7%A8%8B.png

数据库设计

https://jsd.onmicrosoft.cn/gh/yjjyjya/mypicbed/about_Python/pic/apps%E8%AE%BE%E8%AE%A1.png

模型设计

blog/models.py 中添加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
from django.db import models
from django.contrib.auth.models import User


class Category(models.Model):
    name = models.CharField('分类', max_length=128)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = '博客分类'
        verbose_name_plural = verbose_name


class Tag(models.Model):
    name = models.CharField('标签', max_length=128)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = '博客标签'
        verbose_name_plural = verbose_name


class Entry(models.Model):
    """
    包括字符串、图像、文本、正整数字段,以及外键
    """
    title = models.CharField('文章标题', max_length=128)
    author = models.ForeignKey(
        User, verbose_name='作者', on_delete=models.CASCADE
    )
    img = models.ImageField(
        upload_to='blog_img',
        null=True,
        blank=True,
        verbose_name='博客配图'
    )
    body = models.TextField('正文',)
    abstract = models.TextField(
        '摘要',
        max_length=256,
        null=True,
        blank=True
    )
    visiting = models.PositiveIntegerField('访问量', default=0)

    category = models.ManyToManyField('Category', verbose_name='博客分类')
    tags = models.ManyToManyField('Tag', verbose_name='标签')

    created_time = models.DateTimeField('创建时间', auto_now_add=True)
    modifyed_time = models.DateTimeField('修改时间', auto_now=True)

    def __str__(self):
        return self.title

    class Meta:
        ordering = ['-created_time']
        verbose_name = '博客正文'
        verbose_name_plural = verbose_name

    def get_absolute_url(self):
        # 获取当前博客详情页的 url
        return reverse("blog:blog_detail", kwargs={"blog_id": self.id})

    def increase_visiting(self):
        # 访问量加 1
        self.visiting += 1
        self.save(update_fields=['visiting'])

Admin 后台注册

blog/admin.py 中添加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.contrib import admin
from . import models


class EntryAdmin(admin.ModelAdmin):
    list_display = ['title', 'author', 'visiting', 'created_time', 'modifyed_time']


# 将模型注册到 admin 后台管理系统中,方便数据录入
admin.site.register(models.Category)
admin.site.register(models.Tag)
admin.site.register(models.Entry, EntryAdmin)

在 terminal 执行 python manage.py createsuperuser 创建 admin 的超级用户,然后在 admin 后台添加一些 blog 的类别、标签、正文等测试数据。

URL 和视图设计

项目根 urls.py

1
2
3
4
5
6
7
8
from django.contrib import admin
from django.urls import path, include


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

Blog app 的 urls.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.urls import path
from . import views


app_name = 'blog'
urlpatterns = [
    path('', views.index, name='blog_index'),
    path('<int:blog_id>/', views.detail, name='blog_detail'),
    path('category/<int:category_id>/', views.category, name='blog_category'),
    path('tag/<int:tag_id>/', views.tag, name='blog_tag'),
    path('search/', views.search, name='blog_search'),
]

视图 views.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
from . import models
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger


def make_paginator(objects, page, num=3):
    paginator = Paginator(objects, num)
    try:
        object_list = paginator.page(page)
    except PageNotAnInteger:
        object_list = paginator.page(1)
    except EmptyPage:
        object_list = paginator.page(paginator.num_pages)
    return object_list, paginator


def pagination_data(paginator, page):
    """
    用于自定义展示分页页码的方法
    :param paginator: Paginator 类的对象
    :param page: 当前请求的页码
    :return: 一个包含所有页码和符号的字典
    """
    if paginator.num_pages == 1:
        return {}
    left = []
    right = []
    left_has_more = False
    right_has_more = False
    first = False
    last = False

    try:
        page_number = int(page)
    except ValueError:
        page_number = 1
    except:
        page_number = 1

    total_pages = paginator.num_pages
    page_range = paginator.page_range

    if page_number == 1:
        right = page_range[page_number:page_number + 4]
        if right[-1] < total_pages - 1:
            right_has_more = True
        if right[-1] < total_pages:
            last = True

    elif page_number == total_pages:
        left = page_range[(page_number - 3) if (page_number - 3) > 0 else 0:page_number - 1]
        if left[0] > 2:
            left_has_more = True
        if left[0] > 1:
            first = True
    else:
        left = page_range[(page_number - 3) if (page_number - 3) > 0 else 0:page_number - 1]
        right = page_range[page_number:page_number + 2]
        if right[-1] < total_pages - 1:
            right_has_more = True
        if right[-1] < total_pages:
            last = True
        if left[0] > 2:
            left_has_more = True
        if left[0] > 1:
            first = True

    data = {
        'left': left,
        'right': right,
        'left_has_more': left_has_more,
        'right_has_more': right_has_more,
        'first': first,
        'last': last,
    }
    return data


def index(request):
    entries = models.Entry.objects.all()
    page = request.GET.get('page', 1)
    entry_list, paginator = make_paginator(entries, page)
    page_data = pagination_data(paginator, page)
    return render(request, 'blog/index.html', locals())


def detail(request, blog_id):
    entry = get_object_or_404(models.Entry, id=blog_id)
    entry.increase_visiting()
    import markdown
    md = markdown.Markdown(extensions=[
        'markdown.extensions.extra',
        'markdown.extensions.codehilite',
        'markdown.extensions.toc',
    ])
    entry.body = md.convert(entry.body)
    entry.toc = md.toc
    return render(request, 'blog/detail.html', locals())


def category(request, category_id):
    c = models.Category.objects.get(id=category_id)
    entries = models.Entry.objects.filter(category=c)
    page = request.GET.get('page', 1)
    entry_list, paginator = make_paginator(entries, page)
    page_data = pagination_data(paginator, page)
    return render(request, 'blog/index.html', locals())


def tag(request, tag_id):
    t = models.Tag.objects.get(id=tag_id)
    if t.name == "全部":
        entries = models.Entry.objects.all()
    else:
        entries = models.Entry.objects.filter(tags=t)
    page = request.GET.get('page', 1)
    entry_list, paginator = make_paginator(entries, page)
    page_data = pagination_data(paginator, page)
    return render(request, 'blog/index.html', locals())


def search(request):
    keyword = request.GET.get('keyword', None)
    if not keyword:
        error_msg = "请输入关键字"
        return render(request, 'blog/index.html', locals())
    entries = models.Entry.objects.filter(Q(title__icontains=keyword)
                                          | Q(body__icontains=keyword)
                                          | Q(abstract__icontains=keyword))
    page = request.GET.get('page', 1)
    entry_list, paginator = make_paginator(entries, page)
    page_data = pagination_data(paginator, page)
    return render(request, 'blog/index.html', locals())

模板设计要点

  • 使用 base.html 作为骨架模板,通过 {% block %} 定义可覆盖区域
  • index.htmldetail.html 通过 {% extends 'blog/base.html' %} 继承
  • 在模板中加载静态文件需要 {% load static %}
  • 模板变量使用 {{ variable }},标签使用 {% tag %}
  • 使用 |safe 过滤器渲染 HTML 内容(如 Markdown 转换后的正文和目录)
  • 日期格式化:{{ entry.created_time|date:'Y年m月d日' }}
  • 文本截断:{{ entry.body|truncatechars:180 }}

侧边栏功能

通过自定义模板标签实现,在 blog/templatetags/blog_tags.py 中定义 get_recent_entriesget_popular_entriesget_tags 等标签,在 right_side_bar.html 中调用,然后用 {% include 'blog/right_side_bar.html' %} 包含到主模板中。

返回顶部功能

base.html 中添加:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div id="back-to-top">
  <i class="glyphicon glyphicon-arrow-up"></i>
</div>

<script>
  $('#back-to-top').click(function () {
    window.scrollTo(0, 0)
  })
</script>

<link href="{% static 'blog/css/back-to-top.css' %}" rel="stylesheet" />

对应 CSS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#back-to-top {
  box-shadow: 5px 5px 5px #888888;
  border-radius: 2px;
  position: fixed;
  bottom: 70px;
  right: 70px;
  color: whitesmoke;
  background: #3ac57d;
  width: 50px;
  height: 50px;
  text-align: center;
}

#back-to-top i {
  font-size: 30px;
  margin: 10px auto;
}

拓展与后续学习建议

  • Django 官方文档是最佳的学习资源,建议通读 Tutorial 和 Topics 部分
  • Django REST Framework:掌握后端 API 开发,是前后端分离架构的必备技能
  • Celery 与异步任务:学习如何处理耗时操作(如发送邮件、数据处理),提升用户体验
  • Django Channels:了解 WebSocket 支持,实现实时通信功能
  • 缓存框架:研究 Django 的缓存机制(Redis/Memcached),优化高并发场景下的性能
  • Docker 容器化部署:将 Django 应用容器化,简化部署流程和环境管理
  • 数据库优化:学习 Django ORM 的查询优化技巧(select_related、prefetch_related),减少数据库查询次数
  • 安全进阶:深入了解 Django 的安全中间件、CORS 配置、HTTPS 部署等
  • 测试驱动开发:实践 TDD 流程,掌握 pytest-django 等测试工具
  • 国际化与本地化:学习 Django 的 i18n/l10n 支持,构建多语言应用