# Django 后端学习路线

推荐从上往下看。

# Django 官方教程 关键步骤

本小节记录了 官方中文教程(3.1 版本) (opens new window) 中的关键步骤。

# 1. 创建项目、项目和一个视图

  • 安装:pip install Django
  • 验证安装:python -m django --version
  • 创建并初始化项目文件夹:django-admin startproject <projectname>
  • 即时预览:在 <projectname> 目录下 python manage.py runserver [port]
  • 创建应用:python manage.py startapp <appname>
  • 编写视图:
# polls/views.py
from django.http import HttpResponse

def index(request):
    return HttpResponse("Hello, world. You're at the polls index.")
  • 在应用中添加写好的视图:
# polls/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
]
  • 在站点中添加应用的视图:
# mysite/urls.py
from django.contrib import admin
from django.urls import include, path

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

# 2. 数据库使用、管理员

# 2.1 配置数据库

  • 安装 mysqlclient:pip install mysqlclient
  • 修改项目配置文件:
# mysite/settings.py

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'django_learn',
        'USER': 'root',
        'PASSWORD': 'yourpassword',
        'HOST': '127.0.0.1',
        'PORT': '3306',
    }
}

# 2.2 创建模型并迁移至数据库

一个 Django 模型对于一个 SQL 数据表。

  • 创建模型:
# polls/models.py
from django.db import models


class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')
    def __str__(self):
        return self.question_text


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)
    def __str__(self):
        return self.choice_text
  • 激活模型:
# mysite/settings.py
INSTALLED_APPS = [
    'polls.apps.PollsConfig' # 添加这一项
]
  • 将模型更改写入数据库:
    • 根据类的更改,生成一个 迁移(一个存储在 <app_lable>/migrations 下的 py 文件,存储了变化):python manage.py makemigrations [app_label]文档 (opens new window)
    • 将一个 迁移 应用到数据库,并迁移数据:python manage.py migrate [app_label] [migration_name]文档 (opens new window)
    • 查看一个 迁移 将对数据库造成的影响:python manage.py sqlmigrate <app_label> <migration_name>文档 (opens new window)
    • 一般来说,类变更以后,需要:python manage.py makemigrations && python manage.py migrate
    • 第一次部署的时候,需要 python manage.py makemigrations <app1> <app2> <...appn> && python manage.py migrate

对了,migrations 文件夹应当加入 .gitignore,否则不同开发者的 migrations 就要冲突啦。

# 2.3 数据库 API

  • 进入 Python 命令行:python manage.py shell
  • 使用前先引入类:from polls.models import Choice, Question

对于一个数据表:

  • 一个表的所有元素:Question.objects.all()
  • 以成员筛选记录:Question.objects.filter(id=1)
  • pub_date 成员的 year 成员筛选(成员方法同理):pub_date.yearQuestion.objects.filter(pub_date__year)

对于一个记录:

  • 构造一个新记录:q = Question(question_text="What's new?", pub_date=timezone.now())
  • 将记录插入表:q.save()
  • 查询、修改记录的属性(同理可调用其方法):q.question_text
  • 删除一个记录:q.delete()

对于一个外键(Choice 存在外键,为 Question):

  • 查询一个 Choice 对应的 Question:c.qeustion
  • 查询一个 Question 对应的 Choice:q.choice_set.all()
  • 为 Question 创建一个 Choice:q.choice_set.create(choice_text='Not much', votes=0)

# 2.4 管理员相关

  • 创建管理员:python manage.py createsuperuser
  • 管理员登录界面:http://127.0.0.1:8000/admin/
  • 在管理员页面中添加 Question 模型:
# polls/admin.py
from django.contrib import admin

from .models import Question

admin.site.register(Question)

# 3. 视图和 urls

# 3.1 添加更多视图,并用参数匹配 url

# /polls/views.py
def detail(request, question_id: int):
    return HttpResponse("You're looking at question %s." % question_id)
    # 这里可以做更多的事情,比如调用其他 Python 包
# /polls/url.py
urlpatterns = [
        path('<int:question_id>/', views.detail, name='detail'),
]

访问 /polls/34 会返回 You're looking at question 34.

# 3.2 使用 HTML 模板

编写一个 HTML 模板:

<!-- /polls/templates/polls/index.html -->
{% if latest_question_list %}
    <ul>
    {% for question in latest_question_list %}
        <li><a href="/polls/{{ question.id }}/">{{ question.question_text }}</a></li>
    {% endfor %}
    </ul>
{% else %}
    <p>No polls are available.</p>
{% endif %}

再在视图中:加载模板、用数据渲染、然后转为 HTTP Response,三步使用 django.shortcuts.render() 完成

from django.shortcuts import render

def index(request):
    latest_question_list = Question.objects.order_by('-pub_date')[:5]
    context = {'latest_question_list': latest_question_list}
    return render(request, 'polls/index.html', context)

# 3.3 抛出 404 错误码

from django.http import Http404

def detail(request, question_id):
    try:
        question = Question.objects.get(pk=question_id)
    except Question.DoesNotExist:
        raise Http404("Question does not exist")
    return render(request, 'polls/detail.html', {'question': question})

也可以使用 django.shortcuts.get_object_or_404。该函数在 object 不存在会 raise Http404()

from django.shortcuts import get_object_or_404, render

def detail(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/detail.html', {'question': question})

也有 get_list_or_404() 函数,工作原理和 get_object_or_404() 一样,除了 get() 函数被换成了 filter() 函数。如果列表为空的话会抛出 Http404 异常。

# 3.4 使用 name 替代 URL 中的硬编码、为 URL 名称添加命名空间(app_name)

https://docs.djangoproject.com/zh-hans/3.1/intro/tutorial03/#removing-hardcoded-urls-in-templates

https://docs.djangoproject.com/zh-hans/3.1/intro/tutorial03/#namespacing-url-names

# 4. 编写一个简单的表单

因为我想用 Django 做纯 REST 后端,所以这部分略。

https://docs.djangoproject.com/zh-hans/3.1/intro/tutorial04/

# 5. 测试

https://docs.djangoproject.com/zh-hans/3.1/intro/tutorial05/

关于测试还是值得单独拿一个章节出来的:测试

# 6. 插入静态文件

https://docs.djangoproject.com/zh-hans/3.1/intro/tutorial06/

# 7. 修改 Admin 页面

https://docs.djangoproject.com/zh-hans/3.1/intro/tutorial07/

如果想要修改某元素对应外键的信息(而不是修改其外键),可以参考 django.contrib.admin.StackedInline (opens new window)

如果想要汉化 Admin 页面,可以参考:https://blog.csdn.net/aaazz47/article/details/78666099

# Django 用户认证

# Django 用户认证(后端篇)

MDN 教程:https://developer.mozilla.org/zh-CN/docs/Learn/Server-side/Django/Authentication
文档:https://docs.djangoproject.com/zh-hans/3.1/topics/auth/default/

  • 创建用户:user = User.objects.create_user('john', 'lennon@thebeatles.com', 'johnpassword')
  • 创建超级用户:在命令行中 python manage.py createsuperuser
  • 登录:
from django.contrib.auth import authenticate, login

def my_view(request):
    username = request.POST['username']
    password = request.POST['password']
    user = authenticate(request, username=username, password=password)
    if user is not None:
        login(request, user)
        # Redirect to a success page.
        ...
    else:
        # Return an 'invalid login' error message.
        ...
  • 判断用户身份:可以通过 request.user.is_authenticated==False 表示为匿名者;否则 request.user 会被设置为 User 实例。
  • 更改密码:
from django.contrib.auth.models import User

u = User.objects.get(username='john')
u.set_password('new password')
u.save()
from django.contrib.auth.decorators import login_required

@login_required
def my_view(request):
  • 登出:django.contrib.auth.logout(request)

# Django 用户认证(前端篇)

Django 的用户认证是用 Session 实现的,和其他的 Session 应该是类似的。但对于零基础前后端开发的我,不清楚这之中究竟发生了什么。于是我简单测试了一下。

在登录成功后,应答的 headers 中就会出现 Set-Cookies 字段:

$ http POST http://127.0.0.1:8000/api/accounts/login/ <<< '{"username":"lyh543@outlook.com", "password":"xxxxxxxx"}'
HTTP/1.1 200 OK
Allow: OPTIONS, POST
Connection: close
Content-Length: 493
Content-Type: application/json
Date: Tue, 09 Feb 2021 05:34:41 GMT
Referrer-Policy: same-origin
Server: WSGIServer/0.2 CPython/3.9.1
Set-Cookie: csrftoken=sJQyvoxpJ7nIwFpbgXSKKiBIoo7GxogKKTmsFwJshfyFMBIEyPlhQrvl8OK6FlQR; expires=Tue, 08 Feb 2022 05:34:40 GMT; Max-Age=31449600; Path=/; SameSite=Lax
Set-Cookie: sessionid=kmst16goqdwof54ycuynbz7wzk1scboc; expires=Tue, 23 Feb 2021 05:34:40 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
Vary: Accept, Cookie, Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

前一个 csrftoken 是防止跨站请求的,如果项目是前后端分离的话,就需要进行配置(关于 CSRF,可以看 和 CSRF 与 CORS 斗智斗勇);
后一个 sessionid 就是登录成功后的 sessionid 了。如果我们在下次请求中的 headers 中加入了这个 sessionid,服务器就能识别到我们。对于 Django 来说,就是 request.user 为登录的这个用户。

对于浏览器、requests.sessions.Session 等,会自动设置 Cookie。下面是利用 requests.sessions.Session 完成登录、查询管理员字段的过程:

In [2]: import requests

In [3]: s = requests.Session()

In [20]: r1 = s.post("http://localhost:8000/api/accounts/login/", data={"username":"lyh543@outlook.com", "password":"xxxxxxxx"})

In [21]: r1
Out[21]: <Response [200]>

In [22]: dict(s.cookies)
Out[22]:
{'csrftoken': 's1aepuw0A08k9PsFtTWn6CbCkLU24MU6tTuk3DieW4uj1b6PAXwSjrfHqfCvufz3',
 'sessionid': 'f9ofrqwar9rs0phbg4p89647pwryowrs'}

In [23]: r1.request.headers
Out[23]: {'User-Agent': 'python-requests/2.18.4', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Length': '47', 'Content-Type': 'application/x-www-form-urlencoded'}

In [25]: r1.headers
Out[25]: {'Date': 'Tue, 09 Feb 2021 03:48:32 GMT', 'Server': 'WSGIServer/0.2 CPython/3.9.1', 'Content-Type': 'application/json', 'Vary': 'Accept, Cookie, Origin', 'Allow': 'OPTIONS, POST', 'X-Frame-Options': 'DENY', 'Content-Length': '493', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'same-origin', 'Set-Cookie': 'csrftoken=s1aepuw0A08k9PsFtTWn6CbCkLU24MU6tTuk3DieW4uj1b6PAXwSjrfHqfCvufz3; expires=Tue, 08 Feb 2022 03:48:32 GMT; Max-Age=31449600; Path=/; SameSite=Lax, sessionid=f9ofrqwar9rs0phbg4p89647pwryowrs; expires=Tue, 23 Feb 2021 03:48:32 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax', 'Connection': 'close'}

In [26]: r1 = s.get("http://localhost:8000/api/activities/1/admin/")

In [27]: r1
Out[27]: <Response [200]>

而对于非登录操作、或登录失败,应答中的字段就不会有 Set-Cookies 字段,requests.sessions.Session 也不会设置 Cookies

In [2]: import requests

In [3]: s = requests.Session()

In [7]: r1 = s.get("http://localhost:8000/api/activities/1/admin/")

In [8]: r1
Out[8]: <Response [403]>

In [9]: s.cookies
Out[9]: <RequestsCookieJar[]>

In [10]: list(s.cookies)
Out[10]: []

Django Session 的过期时间也是可以通过修改 SESSION_COOKIE_AGE (opens new window) 来修改的。

# 使用 JWT 进行身份验证

Django 自带的 Session 对于很多项目已经够用了。如果想要更高级一点的安全验证,如 Json Web Token,可以尝试 Simple JWT (opens new window) 配合 Django REST Framework 食用。文档给的示例代码很详细,有需要也可以仿照源码编写自己的 API。

# Django 定时任务

可参考 Django-crontab (opens new window)

# Django REST Framework

这部分就另开一篇博文来写了。

# Django 项目部署 (WSGI)

Django 部署可以采用 WSGI,也可以使用 ASGI。WSGI 是为同步 Web Server 编写的,而 ASGI 是为异步 Web Server 编写的。虽然可以混用,但是同步函数和异步函数可以混用,但是会有约 1ms 的用于线程切换的性能损失。

。如果你主要使用的是异步函数,你可以快进到下一章,进行 ASGI 的部署。看完以后,再回来看看如何 处理静态文件

# Gunicorn

诚然,python manage.py runserver 8000 然后将 8000 端口交给 Nginx / Apache / Caddy 反向代理到 80(http) / 443(https),是最简单且最直接的方法。但是,其替代方案有多线程、占用内存小等优势。

Django 的管理命令 startproject 生成了一个最小化的默认 WSGI 配置,你可以按照自己项目的需要去调整这个配置,任何兼容 WSGI 的应用程序服务器都可以直接使用。

而其中一个 WSGI 应用程序服务器的方案,就是使用 Gunicorn。由于细节比较多,各位先不要急着实践,建议先通读这部分,再决定是否采用这种方式还是直接 startproject

安装 Gunicorn:

python -m pip install gunicorn

在项目文件夹下运行:

gunicorn -b "127.0.0.1:8000" <projectname>.wsgi

其中 <projectname>.wsgi 也是 Python 的模块的表示方法,其表示 ./<projectname>/wsgi.py 这个模块。

可以将执行这条命令的过程写为 Systemd 服务,并实现 2 进程、每进程 3 线程,以及自动重启等:

# djangoproject.service
[Unit]
Description=Django Project
After=network.target
StartLimitIntervalSec=0

[Service]
Type=simple
Restart=always
RestartSec=1
User=root
WorkingDirectory=/path/to/<projectname>/
ExecStart=/usr/local/bin/gunicorn -b "127.0.0.1:8000" \
    --workers=2 \
    --threads=3 \
    --access-logfile - \
    <projectname>.wsgi

[Install]
WantedBy=multi-user.target

命令的 --access-logfile - 表示将 log 输出在控制台,在 Systemd 中即表示可以通过 systemctl status djangoproject 查询日志。

然后就是将这项服务复制到 /etc/systemd/system/,然后 enable 和 start 了:

sudo cp ./djangoproject.service /etc/systemd/system/
sudo systemctl enable djangoproject # 激活
sudo systemctl start djangoproject  # 启动
sudo systemctl status djangoproject # 查询状态

# 处理静态文件

但是!这并没有完成部署。访问 localhost:8000 时,可以看到 Django 有正常响应,但是所有静态文件全部失效,Swagger 文档生成也失效了。

为了解决这个问题,需要配置静态文件。

<projectname>/settings.py 中配置以下几个参数

import os
STATIC_ROOT = os.path.join(BASE_DIR, '.static')
STATIC_URL = '/api/static/'

STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static')
]

三个参数的意义如下:

  • BASE_DIR/static 是开发中静态文件所在文件夹
  • BASE_DIR/.static 是项目生成后静态文件所在文件夹,应当加入 .gitignore
  • /api/static/ 是在网页中访问静态文件的路径

整个过程是这样的:

  1. 开发者将所需的静态文件放入 BASE_DIR/static
  2. 开发者运行 python3 manage.py collectstatic,Django 将开发者提供的 BASE_DIR/static 文件,和 Swagger 等 APP 提供的静态文件,一并复制进 BASE_DIR/.static
  3. 用户在浏览器中访问 /api/static/ 路径,表示用户想访问的文件夹是 BASE_DIR/.static

所以还需要进行以下两步:

  1. 运行 python3 manage.py collectstatic
  2. 通过 Nginx / Apache / Caddy 等将静态文件提供给用户

Gunicorn 提供了一个 Nginx.conf 配置模板 (opens new window),我也提供一份 Caddy 的配置模板:

example.com {
    handle /api/static/* {
        uri strip_prefix /api/static
        root * /etc/uestcmsc_webapp/backend/.static
        file_server
    }

    handle /api/* {
        reverse_proxy localhost:8000
    }
    
    handle {
        root * /etc/uestcmsc_webapp/frontend
        try_files {path} /index.html
        file_server
    }
}

需要注意的是,这种配置的前提是所有 REST API 放在了 /api/ 下,这种方法使用的 <projectname>/urls.py 如下:

api_urlpatterns = [
    url(r'^docs(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
    url(r'^docs/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
    url(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
    path('admin/', admin.site.urls),
    # ...
]

urlpatterns = [
    url('api/', include(api_urlpatterns))
]

# Gunicorn 日志 ip 总是显示 127.0.0.1

出现这个问题,我第一反应是 Caddy 反代的锅,第二反应是 Django 的锅,最后查了一下才发现是 Gunicorn 的锅。

Gunicorn doesn't log real ip from nginx - Stack Overflow (opens new window)

回答也说的很清楚,只需要按照格式修改好后追加到 --access-logformat 参数即可。

我把时间、两个 -和 127.0.0.1 去掉以后的配置如下:

ExecStart=/usr/local/bin/gunicorn -b "127.0.0.1:8000" \
    --workers=2 \
    --threads=3 \
    --access-logfile - \
    --access-logformat "%({X-Real-IP}i)s \"%(r)s\" %(s)s %(b)s \"%(f)s\" \"%(a)s\"" \
    <projectname>.wsgi

# Django 项目部署 (ASGI)

Django 主推的 ASGI 部署方式,应该是它自己维护的 Daphne (opens new window)

安装 Daphne

python -m pip install daphne

在项目文件夹下运行:

daphne <projectname>.asgi:application
ExecStart=/usr/local/bin/daphne -p 8000 <projectname>.asgi:application

目前 Daphne 还不支持多进程,如需多进程,请使用 uvicorn

# 项目开源地址

上面提到的项目开源在 GitHub (opens new window)