使用 Django2 快速开发 Web 服务

Django是一款基于 Python 编写并且采用 MVC 设计模式的开源的 Web 应用框架,早期是作为劳伦斯出版集团新闻网站的 CMS 内容管理系统而开发,后于 2005 年 7 月 在 BSD 许可协议下开源,并于 2017 年 12 月 2 日 发布 2.0 正式版。虽然近几年 Go 语言在 Web 开发领域异军突起,但是在框架成熟度以及语言生态方面与 Python 还存有一定差距,针对于需要快速开发的原型类项目以及性能要求不高的 CMS 和 Admin 类型项目,已经发展了 12 年之久的 Django 依然是非常明智的选择。

本文基于《Django 官方 Tutorials》以及《Django REST framework 官方 Tutorials》编写,发稿时所使用的 Django 版本为2.1.4,Python 版本为3.6.6,文中涉及的代码都已经由笔者验证运行通过,最终形成了一个简单项目并推送至笔者Github上的jungle项目当中,需要的朋友可以基于此来逐步步完善成为一个产品化的项目。

新建 Django 项目

下面的命令行展示了在 Windows 操作系统下,基于 venv 虚拟环境搭建一个 Django 项目的步骤:

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
# 建立虚拟环境
C:\Workspace\django
λ python -m venv venv

# 激活虚拟环境
C:\Workspace\django
λ .\venv\Scripts\activate.bat
(venv) λ

# 安装Django
C:\Workspace\django
(venv) λ pip install Django
Looking in indexes: https://mirrors.aliyun.com/pypi/simple/
Collecting Django
Using cached https://mirrors.aliyun.com/pypi/packages/fd/9a/0c028ea0fe4f5803dda1a7afabeed958d0c8b79b0fe762ffbf728db3b90d/Django-2.1.4-py3-none-any.whl
Collecting pytz (from Django)
Using cached https://mirrors.aliyun.com/pypi/packages/f8/0e/2365ddc010afb3d79147f1dd544e5ee24bf4ece58ab99b16fbb465ce6dc0/pytz-2018.7-py2.py3-none-any.whl
Installing collected packages: pytz, Django
Successfully installed Django-2.1.4 pytz-2018.7

# 进入虚拟环境目录,新建一个Django项目
C:\Workspace\django
(venv) λ django-admin startproject mysite

C:\Workspace\django
(venv) λ ls
mysite/ venv/

# 进入新建的Django项目,建立一个应用
C:\Workspace\django
(venv) λ cd mysite\

C:\Workspace\django\mysite
(venv) λ python manage.py startapp demo

C:\Workspace\django\mysite
(venv) λ ls
demo/ manage.py* mysite/

# 同步数据库
C:\Workspace\django\mysite
(venv) λ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying sessions.0001_initial... OK

# 启动开发服务
(venv) λ python manage.py runserver 8080
Performing system checks...

System check identified no issues (0 silenced).
January 03, 2019 - 21:31:48
Django version 2.1.4, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8080/
Quit the server with CTRL-BREAK.


# 返回uinika虚拟环境目录,并将当前虚拟环境的依赖导入至requirements.txt
C:\Workspace\django\mysite
(venv) λ cd ..

C:\Workspace\django
(venv) λ pip freeze > requirements.txt

C:\Workspace\django
(venv) λ ls
mysite/ requirements.txt venv/

通过django-admin startproject命令创建的外部mysite/目录是 Web 项目的容器,而manage.py文件是用于与 Django 项目交互的命令行工具,更多的使用方式可以参阅django-admin 文档。。

1
2
3
4
5
6
7
mysite/
manage.py
mysite/
__init__.py
settings.py
urls.py
wsgi.py

内部嵌套的mysite/目录是用于放置项目中具体的 Python 包,它的名称是您需要用来导入其中任何内容的 Python 包名称,例如mysite.urls

  • mysite/__init__.py: 空文件,用于提示系统将当前目录识别为一个 Python 包。
  • mysite/settings.py: Django 项目的配置文件,更多配置请查阅Django settings
  • mysite/urls.py: 当前 Django 项目的 URL 声明,更多内容请参阅URL dispatcher
  • mysite/wsgi.py: 兼容 WSGI 规范的当前项目入口点,更多细节可以阅读如果使用 WSGI 进行部署

建立mysite项目之后,上面的命令行还通过了py manage.py startapp建立了一个demo/应用目录,Django 当中一个项目(mysite)可以拥有多个应用(demo),demo/目录下的文件结构如下:

1
2
3
4
5
6
7
8
9
demo/
__init__.py
admin.py
apps.py
migrations/
__init__.py
models.py
tests.py
views.py

使用命令python manage.py runserver启动 Django 服务时,默认会监听localhost地址下的80端口,如果希望网络里的其它主机能够正常访问服务,必须在mysite/settings.py显式的声明当前允许的主机地址:

1
ALLOWED_HOSTS = ['10.102.16.79']

然后使用manage.py启动服务时,指定好主机名和对应的端口号:

1
2
3
4
5
6
7
8
9
C:\Workspace\cloud-key\mysite (master -> origin)
(venv) λ python manage.py runserver 10.102.16.79:8000
Performing system checks...

System check identified no issues (0 silenced).
January 09, 2019 - 14:09:48
Django version 2.1.5, using settings 'mysite.settings'
Starting development server at http://10.102.16.79:8000/
Quit the server with CTRL-BREAK.

请求与响应

首先进入 Python 虚拟环境并进入mysite目录后,执行如下命令:

1
2
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py startapp polls

polls/views.py

新建一个polls应用之后,打开该目录下的polls/views.py源码文件,输入以下代码:

1
2
3
4
from django.http import HttpResponse

def index(request):
return HttpResponse("你好,这是一个投票应用!")

polls/urls.py

接下来,我们需要将上面修改的视图文件views.py映射到一个 URL,先在polls/目录下新建一个urls.py文件,然后键入下面这段代码:

1
2
3
4
5
6
7
from django.urls import path

from . import views

urlpatterns = [
path('', views.index, name='index'),
]

mysite/urls.py

最后,将上面定义的应用的 URL 声明文件polls/urls.py模块包含至项目的mysite/urls.py代码当中,

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

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

上面代码中出现的include()函数主要用于引入其它 URL 配置文件,这样我们就可以通过http://localhost:8080/polls/路径访问到如下信息了:

模型和管理页面

  • Model 类:Django 中每一个 Model 都是django.db.models.Model的子类,每个 Model 类都映射着一个数据库表,Model 的每个属性则相当于一个数据库字段。
1
2
3
4
5
6
from django.db import models

# Django会在Blog类创建后自动生成`CREATE TABLE`语句。
class Blog(models.Model):
title = models.CharField(max_length=50)
content = models.CharField(max_length=800)
  • Model 实例:Model 类的实例用来表示数据库表中的一条特定记录,可以通过向 Model() 类传递关键字参数,然后调用save()方法保存至数据库,从而创建出一个 Model 类实例。
1
2
3
4
from blog.models import Blog

blog = Blog(title='Bit by bit', content='一些内容。')
blog.save() # 该方法没有返回值,在底层会生成并执行一条insert语句
  • QuerySet: QuerySet 表示数据库查询的结果集(SELECT 语句),该结果集拥有一个或多个过滤方法(WHERE 或 LIMIT 子句)。获取结果集可通过 Model 的 Manager 管理器(用于向 Django 模型提供数据库查询操作的接口),而Manager则默认由结果集的objects属性获得。
1
2
Blog.objects # <django.db.models.manager.Manager object at ...>
Blog.objects.all() # 包含Blog对象里的所有记录

Django 当中,QuerySet用来执行记录级操作Model实例则用来进行表级操作

mysite/settings.py

mysite/settings.py文件包含了项目的基本配置,该文件通过如下声明默认使用 Django 内置的 SQLite 作为项目数据库。

1
2
3
4
5
6
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}

如果使用其它数据库,则可以将配置书写为下面的格式:

1
2
3
4
5
6
7
8
9
10
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql', # 数据库引擎名称
'NAME': 'db', # 数据库连接名称
'USER': 'uinika', # 数据库连接用户名
'PASSWORD': 'test', # 数据库连接密码
'HOST': 'localhost', # 数据库主机地址
'PORT': '3306', # 数据库端口
}
}

其中ENGINE属性可以根据项目所使用数据库的不同而选择如下值:

  • SQLite:django.db.backends.sqlite3
  • MySQL:django.db.backends.mysql
  • PostgreSQL:django.db.backends.postgresql
  • Oracle:django.db.backends.oracle

接下来继续修改mysite/settings.py,设置TIME_ZONE属性为项目使用国家的时区。

1
2
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Chongqing'

mysite/settings.py文件头部的INSTALLED_APPS属性定义了当前项目使用的应用程序。

1
2
3
4
5
6
7
8
INSTALLED_APPS = [
'django.contrib.admin', # 管理员站点
'django.contrib.auth', # 认证授权系统
'django.contrib.contenttypes', # 内容类型框架
'django.contrib.sessions', # 会话框架
'django.contrib.messages', # 消息框架
'django.contrib.staticfiles', # 静态文件管理
]

在前面命令行中执行的python manage.py migrate命令会检查INSTALLED_APPS属性的设置,并为其中的每个应用创建所需的数据表,实际上migrate命令只会为对INSTALLED_APPS里声明了的应用进行数据库迁移

polls/models.py

了解项目配置文件的一些设置之后,现在来编辑polls/models.py文件新建Question(问题)Choice(选项)两个数据模型:

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

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

class Choice(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
choice_text = models.CharField(max_length=200)
votes = models.IntegerField(default=0)

每个自定义模型都是django.db.models.Model的子类,模型里的类变量都表示一个数据库字段,每个字段实质都是Field类的实例。注意在Choice使用了ForeignKey属性定义了一个与Question的外键关联关系,Django 支持所有常用的多对一、多对多和一对一数据库关系。

mysite/settings.py

数据库模型建立完成之后,由于PollsConfig类位于polls/apps.py文件当中,所以其对应的点式路径为polls.apps.PollsConfig,现在我们需要将该路径添加至mysite/settings.py文件的INSTALLED_APPS属性:

1
2
3
4
5
6
7
8
9
INSTALLED_APPS = [
'polls.apps.PollsConfig', # 添加PollsConfig
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]

将数据模型迁移至数据库

通过manage.py提供的makemigrations命令,可以将模型的更新迁移至 SQLite 数据库当中。

1
2
3
4
5
6
7
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py makemigrations polls
Migrations for 'polls':
polls\migrations\0001_initial.py
- Create model Choice
- Create model Question
- Add field question to choice

我们还可以通过manage.py提供的sqlmigrate命令,查看数据迁移过程中执行了哪些 SQL 语句,该命令并不会实质性执行 Django 模型到数据库的迁移任务。

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
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py sqlmigrate polls 0001
BEGIN;
--
-- Create model Choice
--
CREATE TABLE "polls_choice" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "choice_text" varchar(200) NOT NULL, "v
otes" integer NOT NULL);
--
-- Create model Question
--
CREATE TABLE "polls_question" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "question_text" varchar(200) NOT NULL
, "pub_date" datetime NOT NULL);
--
-- Add field question to choice
--
ALTER TABLE "polls_choice" RENAME TO "polls_choice__old";
CREATE TABLE "polls_choice" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "choice_text" varchar(200) NOT NULL, "v
otes" integer NOT NULL, "question_id" integer NOT NULL REFERENCES "polls_question" ("id") DEFERRABLE INITIALLY DEFERR
ED);
INSERT INTO "polls_choice" ("id", "choice_text", "votes", "question_id") SELECT "id", "choice_text", "votes", NULL FR
OM "polls_choice__old";
DROP TABLE "polls_choice__old";
CREATE INDEX "polls_choice_question_id_c5b4b260" ON "polls_choice" ("question_id");
COMMIT;

Django 模型的数据库主键 ID 会被自动创建, 并会在外键字段名称后追加_id字符串作为后缀。

接下来运行manage.py提供的migrate命令,在根据新定义的模型创建相应的数据库表。

1
2
3
4
5
6
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, polls, sessions
Running migrations:
Applying polls.0001_initial... OK

为了便于在版本管理系统提交迁移数据,Django 将模型的修改分别独立为生成应用两个命令,因此修改 Django 模型会涉及如下 3 个步骤:

  1. 编辑models.py文件修改模型。
  2. 运行python manage.py makemigrations为模型的改变生成迁移文件。
  3. 运行python manage.py migrate来应用数据库迁移。

完成上述 Django 模型与数据库的同步之后,接下来可以通过manage.py提供的shell命令,在命令行工具内运行 Django 提供的交互式 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py shell
Python 3.6.6 (v3.6.6:4cf1f54eb7, Jun 27 2018, 03:37:03) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from polls.models import Choice, Question
>>> Question.objects.all()
<QuerySet []>
>>> from django.utils import timezone
>>> q = Question(question_text="What's new?", pub_date=timezone.now())
>>> q.save()
>>> q.id
1
>>> q.question_text
"What's new?"
>>> q.pub_date
datetime.datetime(2019, 1, 4, 9, 10, 1, 955820, tzinfo=<UTC>)
>>> q.question_text = "What's up?"
>>> q.save()
>>> Question.objects.all()
<QuerySet [<Question: Question object (1)>]>

上面命令行执行结果中的<Question: Question object (1)>对于实际开发没有意义,因此可以考虑为上面建立的 Django 模型增加__str__()方法直接打印模型对象的属性数据。为了便于进一步测试,这里还为Question类添加一个自定义的was_published_recently()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

import datetime
from django.db import models
from django.utils import timezone

class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField('date published')
# 自定义was_published_recently()方法
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
# 添加__str__()方法
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)
# 添加__str__()方法
def __str__(self):
return self.choice_text

完成修改工作之后,再一次运行python manage.py shell命令:

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
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py shell
Python 3.6.6 (v3.6.6:4cf1f54eb7, Jun 27 2018, 03:37:03) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from polls.models import Choice, Question
>>> Question.objects.all()
<QuerySet [<Question: What's up?>]>
>>> Question.objects.filter(id=1)
<QuerySet [<Question: What's up?>]>
>>> Question.objects.filter(question_text__startswith='What')
<QuerySet [<Question: What's up?>]>
>>> from django.utils import timezone
>>> current_year = timezone.now().year
>>> Question.objects.get(pub_date__year=current_year)
<Question: What's up?>
>>> Question.objects.get(id=2)
Traceback (most recent call last):
File "<console>", line 1, in <module>
File "C:\Workspace\django\venv\lib\site-packages\django\db\models\manager.py", line 82, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
File "C:\Workspace\django\venv\lib\site-packages\django\db\models\query.py", line 399, in get
self.model._meta.object_name
polls.models.Question.DoesNotExist: Question matching query does not exist.
>>> Question.objects.get(pk=1)
<Question: What's up?>
>>> q = Question.objects.get(pk=1)
>>> q.was_published_recently()
True
>>> q = Question.objects.get(pk=1)
>>> q.choice_set.all()
<QuerySet []>
>>> q.choice_set.create(choice_text='Not much', votes=0)
<Choice: Not much>
>>> q.choice_set.create(choice_text='The sky', votes=0)
<Choice: The sky>
>>> c = q.choice_set.create(choice_text='Just hacking again', votes=0)
>>> c.question
<Question: What's up?>
>>> q.choice_set.all()
<QuerySet [<Choice: Not much>, <Choice: The sky>, <Choice: Just hacking again>]>
>>> q.choice_set.count()
3
>>> Choice.objects.filter(question__pub_date__year=current_year)
<QuerySet [<Choice: Not much>, <Choice: The sky>, <Choice: Just hacking again>]>
>>> c = q.choice_set.filter(choice_text__startswith='Just hacking')
>>> c.delete()
(1, {'polls.Choice': 1})

管理站点

Django 能够根据模型自动创建后台管理界面, 这里我们执行manage.py提供的createsuperuser命令创建一个管理用户:

1
2
3
4
5
6
7
C:\Workspace\django\mysite (master -> origin)
(venv) λ python manage.py createsuperuser
Username (leave blank to use 'zhenghang'): hank
Email address: uinika@outlook.com
Password: ********
Password (again): ********
Superuser created successfully.

启动 Django 服务之后,就可以通过 URL 地址http://localhost:8080/admin/login并使用上面新建的用户名和密码进行登陆管理操作:

登陆后默认只能对权限相关的UserGroup进行管理,如果我们需要将Question数据模型纳入管理,那么必须要在polls/admin.py文件对其进行注册。

1
2
3
4
from django.contrib import admin
from .models import Question

admin.site.register(Question)

完成注册之后,刷新管理站点页面即可查看到Question管理选项:

视图与模板

polls/views.py

Django 使用URLconfs配置将 URL 与视图关联,即将 URL 映射至视图,下面我们将向polls/views.py文件添加一些能够接收参数的视图:

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

def index(request):
return HttpResponse("你好,这是一个投票应用!")

def detail(request, question_id):
return HttpResponse("你正在查看问题 %s 。" % question_id)

def results(request, question_id):
response = "你看到的是问题 %s 的结果。"
return HttpResponse(response % question_id)

def vote(request, question_id):
return HttpResponse("你正在对问题 %s 进行投票。" % question_id)

polls.urls

然后将这些新的视图添加至polls.urls模块:

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

urlpatterns = [
# 访问 http://localhost:8080/polls/
path('', views.index, name='index'),
# 访问 http://localhost:8080/polls/5/
path('<int:question_id>/', views.detail, name='detail'),
# 访问 http://localhost:8080/polls/5/results/
path('<int:question_id>/results/', views.results, name='results'),
# 访问 http://localhost:8080/polls/5/vote/
path('<int:question_id>/vote/', views.vote, name='vote'),
]

修改 polls/views.py

Django 的每个视图只会完成两个任务:返回一个包含被请求页面内容的HttpResponse对象,或是抛出一个Http404这样的异常。这里为了展示数据库里按照发布日期排序的最近五个投票问题,我们再向polls/views.py代码文件的index()函数添加如下内容:

1
2
3
4
5
6
from .models import Question

def index(request):
latest_question_list = Question.objects.order_by('-pub_date')[:5]
output = ', '.join([q.question_text for q in latest_question_list])
return HttpResponse(output)

添加 templates 模板目录

这样直接将数据库查询结果输出到页面的方式并不优雅,实际开发环境当中我们通常会使用模板页面来展示数据,首先在polls应用目录下创建一个用来存放模板文件的templates目录。由于站点配置文件mysite/settings.pyTEMPLATES属性的默认设置,能够让 Django 在每个INSTALLED_APPS文件夹中自动寻找templates子目录,从而正确定位出模板的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

接下来继续在templates下面新建一个polls目录,然后在里边放置一个index.html文件,此时通过 URL 地址http://localhost:8080/polls/就可以访问到这个模板文件,模板文件会将按照发布日期排序了的Question列表latest_question_list放置到HttpResponse上下文,并在polls/index.html模板当中完成数据绑定。

1
2
3
4
5
6
7
8
9
10
11
from django.http import HttpResponse
from django.template import loader
from .models import Question

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

render()快捷方法

事实上,通过使用render()方法,Django 能够以更加简化的方式完成载入模板、填充上下文、返回 HttpResponse 对象这一系列步骤:

1
2
3
4
5
6
7
from django.shortcuts import render
from .models import Question

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)

Http404 处理

接下来处理投票详情页面,这里会有一个新原则,即如果指定ID所对应的Question不存在,那么视图就会抛出一个Http404异常。在polls/views.py添加如下代码,

1
2
3
4
5
6
7
8
9
10
from django.http import Http404
from django.shortcuts import render
from .models import Question

def detail(request, question_id):
try:
question = Question.objects.get(pk=question_id)
except Question.DoesNotExist:
raise Http404("问题不存在!")
return render(request, 'polls/detail.html', {'question': qu

然后暂时向polls/templates/polls/detail.html添加一行简单的{{ question }}代码便于测试上面的代码。

Django 提供了诸如get_object_or_404()get_list_or_404()这样的快捷函数语法糖来解决Http404判断的问题,因而上一步的代码依然可以进一步简化为下面这样:

1
2
3
4
5
6
from django.shortcuts import get_object_or_404, render
from .models import Question

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

templates/polls/detail.html

让我们进一步完善polls/templates/polls/detail.html,填充完整的视图代码:

1
2
3
4
5
6
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>

通过在模板代码中使用.符号来访问变量属性,例如对于上面代码中的{{ question.question_text }}, Django 首先会尝试对question对象使用字典查找(既obj.get(str)),如果失败再尝试属性查找(既obj.str),如果依然失败就会尝试列表查找(即obj[int])。另外循环for中的question.choice_set.all语句会被解析为question.choice_set.all()的 Python 的函数调用,完成后将返回一个可迭代的Choice对象,该对象仅限于for循环标签内部使用。

polls/templates/polls/index.html编写的投票链接里使用了诸如<a href="/polls/{{ question.id }}/">{{ question.question_text }}</a>这样的硬编码,但是这样容易造成视图与后端业务的耦合,因此 Django 提供了url标签来解决这个问题。

1
<a href="{% url 'detail' question.id %}">{{ question.question_text }}</a>

事实上,在mysite/polls/urls.py里的path('<int:question_id>/', views.detail, name='detail')函数调用当中,path()name属性就是作用于url标签中的这个特性的。

URL 命名空间

为了避免项目当中各个应用的 URL 重名,导致url标签在使用时产生歧义,需要在polls/urls.py上添加应用的命名空间作为区分。

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

app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/results/', views.results, name='results'),
path('<int:question_id>/vote/', views.vote, name='vote'),
]

然后编辑polls/templates/polls/index.html文件,为每个url标签添加上面声明的polls:命名空间。

1
2
3
4
5
<li>
<a href="{% url 'polls:detail' question.id %}"
>{{ question.question_text }}</a
>
</li>

表单和通用视图

首先为投票详细页面polls/detail.html添加一个<form>表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<h1>{{ question.question_text }}</h1>

{% if error_message %}
<p><strong>{{ error_message }}</strong></p>
{% endif %}

<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %} {% for choice in question.choice_set.all %}
<input
type="radio"
name="choice"
id="choice{{ forloop.counter }}"
value="{{ choice.id }}"
/>
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label
><br />
{% endfor %} <input type="submit" value="Vote" />
</form>

上面代码中,除了前端模板常用的for循环标签,还使用了csrf_token标签来防止跨站点请求伪造,建议所有针对内部 URL 的 POST 表单都应该使用它。另外,代码中的表达式forloop.counter用来指示for标签的执行次数。

接下来,创建一个 Django 视图来处理上面表单提交的数据,在上一步当中,我们在polls/urls.py里创建的URLconf如下:

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

app_name = 'polls'

urlpatterns = [
# ... ...
path('<int:question_id>/vote/', views.vote, name='vote'),
# ... ...
]

这里我们需要处理对应的vote()的函数,下面代码中通过request.POST['choice']以字符串形式返回选择的ChoiceIDrequest.POST的值永远是字符串)。如果request.POST['choice']中不存在choice,将会引发一个KeyError, 下面代码通过异常检查机制来处理KeyError,如果choice不存在将会重新显示Question表单以及一个错误提示信息。选择并且投票成功之后,Choice的得票数会自增1,同时通过返回HttpResponseRedirect重定向到指定的URL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse

from .models import Choice, Question

def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
# 重新显示问题的投票表单。
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "你没有进行选择!",
})
else:
selected_choice.votes += 1
selected_choice.save()
# 请求成功后总是返回一个HttpResponseRedirect,这样可以防止用户点击返回按钮后表单被再次发送。
return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

注意:HttpResponseRedirect()函数中使用了一个reverse()函数,该函数可以根据URLconf生成相应的URL,从而避免了在视图函数中硬编码,这里reverse()函数的返回结果为'/polls/1/results/'

当完成对Question的投票操作之后,vote()视图会将请求重定向到一个结果视图results(),继续修改polls/views.py中的results()函数:

1
2
3
4
5
from django.shortcuts import get_object_or_404, render

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

然后添加视图对应的模板页面polls/templates/polls/results.html

1
2
3
4
5
6
7
8
9
10
11
12
<h1>{{ question.question_text }}</h1>

<ul>
{% for choice in question.choice_set.all %}
<li>
{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes |
pluralize }}
</li>
{% endfor %}
</ul>

<a href="{% url 'polls:detail' question.id %}">再次投票?</a>

通用视图

正如前面一系列代码所展示的那样,根据 URL 中的参数从数据库中获取数据、载入模板文件然后返回渲染后的模板,这是 Web 开发当中常见的情况,Django 将这个过程抽象为一套通用视图的快捷方法,下面我们将基于通用视图来进行修改。

改进前的 URLconf:

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

app_name = 'polls'

urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/results/', views.results, name='results'),
path('<int:question_id>/vote/', views.vote, name='vote'),
]

改进后的 URLconf:

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

from . import views

app_name = 'polls'
urlpatterns = [
path('', views.IndexView.as_view(), name='index'),
# 注意:路径字符串中匹配模式的名称由<question_id>修改为<pk>。
path('<int:pk>/', views.DetailView.as_view(), name='detail'),
path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),
path('<int:question_id>/vote/', views.vote, name='vote'),
]

改进前的polls/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
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse

from .models import Choice, Question

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)

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

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

def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
# 重新显示问题的投票表单。
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "你没有进行选择!",
})
else:
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

改进后的polls/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
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views import generic

from .models import Choice, Question

class IndexView(generic.ListView):
template_name = 'polls/index.html'
context_object_name = 'latest_question_list'

def get_queryset(self):
""" 返回最近发布的5个问题。 """
return Question.objects.order_by('-pub_date')[:5]

class DetailView(generic.DetailView):
model = Question
template_name = 'polls/detail.html'

class ResultsView(generic.DetailView):
model = Question
template_name = 'polls/results.html'

def vote(request, question_id):
''' vote()函数定义同上 '''

这份代码中,使用了ListView显示一个对象的列表)和DetailView显示一个指定对象的详细信息)两个通用视图。

通用视图DetailView期望从 URL 获取名为pk的主键值,所以上述代码将question_id改为pk。默认情况下DetailView会使用路径为<app name>/<model name>_detail.html的模板,通过template_name属性可以指定自定义的模板来进行渲染。

通用视图ListView同样使用<app name>/<model name>_list.html作为默认模板,因此代码中也通过template_name属性告诉ListView使用已经创建好了的polls/index.html作为模板。在前面章节当中,与视图模板一起使用的,还有一个包含有questionlatest_question_list变量的context,而ListView当中,提供了一个context_object_name快捷属性,可以显式指定当前需要使用的 Context 上下文变量是latest_question_list

好了,到目前为止问题投票应用 polls的功能已经大功告成,接下来的章节会讲解一些附加功能以及相关的第三方扩展的使用。

测试

静态文件

Web 应用中的 CSS、图片、JavaScript 通常需要放置在一个静态目录当中,Django 通过mysite/settings.py中的'django.contrib.staticfiles'应用提供了相关支持,默认情况下,该应用会自动在应用目录(比如polls)下的static目录查询静态文件。在进行下一步操作之前,请先按照如下的目录结构来建立文件。

1
2
3
4
5
6
7
8
C:\Workspace\jungle\mysite\polls\static (master -> origin)
(venv) λ tree /f

C:.
└─polls
style.css
└─images
└─background.jpg

static/polls/style.css

1
2
3
4
5
6
7
li a {
color: red;
}

body {
background: url("images/background.jpg");
}

template/polls/index.html

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
<html>
<head>
{% load static %}
<link
rel="stylesheet"
type="text/css"
href="{% static 'polls/style.css' %}"
/>
</head>
<body>
{% if latest_question_list %}
<ul>
{% for question in latest_question_list %}
<li>
<a href="{% url 'polls:detail' question.id %}"
>{{ question.question_text }}</a
>
</li>
{% endfor %}
</ul>
{% else %}
<p>No polls are available.</p>
{% endif %} {% load static %}
</body>
</html>

自定义管理站点

如何编写可复用的 Web 应用

执行原生 SQL

Django 提供了 2 种执行原生查询的方式:一种是使用Manager.raw()执行查询并返回一个Model实例,另一种是完全避开Model层,直接执行自定义的 SQL 语句。

执行原生查询

Django 提供了raw()管理器方法来执行一个原生 SQL 语句,并且返回一个django.db.models.query.RawQuerySet实例,该实例可以像普通 QuerySet 一样进行迭代,从而提供Model对象的实例。

1
Manager.raw(raw_query, params=None, translations=None)

首先,编写一名称为Person的 Model:

1
2
3
class Person(models.Model):
first_name = models.CharField()
last_name = models.CharField()

然后,在该 Model 上调用row()方法执行 SQL 语句:

1
2
for person in Person.objects.raw('SELECT * FROM myapp_person'):
print(person)

也可以在row()方法中指定需要查询的列:

1
Person.objects.raw('SELECT id, first_name, last_name FROM myapp_person')

遇到 Model 属性名与列名不匹配的情况,可以使用AS子句手动进行映射:

1
2
3
4
Person.objects.raw('''SELECT pk    AS id,
first AS first_name,
last AS last_name,
FROM myapp_person''')

也可以使用raw()方法的translations参数来完成映射工作,该参数是一个包含了数据库字段到 Model 属性映射的字典:

1
2
name_map = {'pk': 'id', 'first': 'first_name', 'last': 'last_name'}
Person.objects.raw('SELECT * FROM myapp_person', translations=name_map)

raw()方法支持索引,如果只需要第 1 条结果,可以像下面这样编写代码:

1
first_person = Person.objects.raw('SELECT * FROM myapp_person')[0]

然而,索引和切片并不在数据库级别上执行,如果数据量较大,直接在 SQL 级别上进行限制查询将会获得更佳的性能:

1
first_person = Person.objects.raw('SELECT * FROM myapp_person LIMIT 1')[0]

row()方法查询返回的Person对象将是deferred模型实例,这意味着查询中省略的字段将会按需加载,例如:

1
2
3
for person in Person.objects.raw('SELECT id, first_name FROM myapp_person'):
print(person.first_name, # 由原始查询检索
person.last_name) # 按需检索

由于 Django 使用主键来标识模型实例,原始查询中必须包含主键字段,忽略主键字段将会引发InvalidQuery异常。

假如在定义 Model 时添加了一个birth_dat属性来保存生日数据,那么就可以借用 PostgreSQL 提供的age()方法来根据出生日期计算年龄:

1
2
3
persons = Person.objects.raw('SELECT *, age(birth_date) AS age FROM myapp_person')
for person in persons:
print("%s 已经 %s 岁了!" % (person.first_name, person.age)) # Hank 已经 18 岁了!

实际开发环境下,可以通过Func()表达式来避免使用原始 SQL 计算注解。

如果需要使用带参数的查询,那么可以向raw()方法传递params参数:

1
2
name = 'Hank'
Person.objects.raw('SELECT * FROM myapp_person WHERE last_name = %s', [name])

params参数可以是一个列表或者字典,相应的查询参数在 SQL 中的占位符可以是%s或者%(key)s但是需要注意 SQLite 不支持字典参数,因此须将参数作为列表传递

不要在原始查询或占位符上使用格式化字符串,因为这样会引发 SQL 注入漏洞。

直接运行自定义 SQL

如果认为Manager.raw()还不够灵活,或者不需要将查询结果映射到Model,那么可以通过django.db.connection去使用默认的数据库连接,例如调用connection.cursor()去获取指针对象,调用cursor.execute(sql, [params])去执行 SQL,调用cursor.fetchone()cursor.fetchall()去返回结果集。

1
2
3
4
5
6
7
8
9
from django.db import connection

def my_custom_sql(self):
with connection.cursor() as cursor:
cursor.execute("UPDATE bar SET foo = 1 WHERE baz = %s", [self.baz])
cursor.execute("SELECT foo FROM bar WHERE baz = %s", [self.baz])
row = cursor.fetchone()

return row

为了避免数据库注入攻击,必须禁止在%s周围使用引号。

注意,如果需要在查询中包含文字百分比符号,那么传递参数时必须将重复书写它们为%%:

1
2
cursor.execute("SELECT foo FROM bar WHERE baz = '30%'")
cursor.execute("SELECT foo FROM bar WHERE baz = '30%%' AND id = %s", [self.id])

在使用多个数据库的时候,可以使用django.db.connections去获得数据库的连接和游标,django.db.connections是一个允许使用别名查询指定数据库连接的字典对象。

1
2
3
from django.db import connections
with connections['my_db_alias'].cursor() as cursor:
# 自定义代码

默认情况下,Python 数据库 API 会返回没有字段名称的列表,而非一个字典。

1
2
3
4
5
6
7
def dictfetchall(cursor):
"以字典方式从一个游标返回所有行"
columns = [col[0] for col in cursor.description]
return [
dict(zip(columns, row))
for row in cursor.fetchall()
]

另一个选择是使用 Python 标准库中的collections.namedtuple(),一个namedtuple是一个类似元组的对象,可以通过属性去访问字段,同样支持索引与迭代,其结果是不可变的。

1
2
3
4
5
6
7
from collections import namedtuple

def namedtuplefetchall(cursor):
"以元组方式从一个游标返回所有行"
desc = cursor.description
nt_result = namedtuple('Result', [col[0] for col in desc])
return [nt_result(*row) for row in cursor.fetchall()]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> cursor.execute("SELECT id, parent_id FROM test LIMIT 2");
>>> cursor.fetchall()
((54360982, None), (54360880, None))

>>> cursor.execute("SELECT id, parent_id FROM test LIMIT 2");
>>> dictfetchall(cursor)
[{'parent_id': None, 'id': 54360982}, {'parent_id': None, 'id': 54360880}]

>>> cursor.execute("SELECT id, parent_id FROM test LIMIT 2");
>>> results = namedtuplefetchall(cursor)
>>> results
[Result(id=54360982, parent_id=None), Result(id=54360880, parent_id=None)]
>>> results[0].id
54360982
>>> results[0][0]
54360982

数据库连接与游标

Django 的connectioncursor实现了除事务处理外的大部份 Python 数据库 API。如果不熟悉 Python 数据库 API,需要注意cursor.execute()中的 SQL 语句使用了占位符%s,而非直接在 SQL 语句中添加参数,这种技术会在底层按需转译相应参数。

需要注意 Django 使用的占位符是%s,而非 Python 自带 SQLite 绑定的占位符?

使用游标作cursor为上下文管理器:

1
2
with connection.cursor() as c:
c.execute(...)

等同于:

1
2
3
4
5
c = connection.cursor()
try:
c.execute(...)
finally:
c.close()

存储过程调用

可以通过输入params序列或者kparams字典参数去调用具有特定名称的数据库存储过程(仅有 Oracle 支持kparams)。

1
CursorWrapper.callproc(procname, params=None, kparams=None

例如对于 Oracle 中给定的存储过程:

1
2
3
4
5
6
7
8
CREATE PROCEDURE "TEST_PROCEDURE"(v_i INTEGER, v_text NVARCHAR2(10)) AS
p_i INTEGER;
p_text NVARCHAR2(10);
BEGIN
p_i := v_i;
p_text := v_text;
...
END;

那么在 Django 中可以这样调用它:

1
2
with connection.cursor() as cursor:
cursor.callproc('test_procedure', [1, 'test'])

Django REST framework

Django REST framework 是一款用来构建强大灵活 API 的工具包,支持 OAuth1a 和 OAuth2 身份验证策略,并且生成可以通过 Web 浏览器进行可视化访问的 API,以及同时支持 ORM 和非 ORM 两种数据源的序列化。

本文所使用的 Django REST framework 版本为 3.9.0,支持当前最新版的 Python3.7 以及 Django 2.1,并同时兼容 Python2.x 和 Django1.x;除此之外,Django REST framework 还支持如下可选的扩展包:

  • coreapi (1.32.0+) - Schema 生成支持。
  • Markdown (2.1.0+) - 为浏览器 API 添加 Markdown 支持。
  • django-filter (1.0.1+) - 过滤器支持。
  • django-crispy-forms - 针对过滤器的增强 HTML 显示。
  • django-guardian (1.1.1+) - Object 级别的权限支持。

接下下来,可以像下面这样安装可选的支持包以及djangorestframework

1
2
3
pip install djangorestframework
pip install markdown # 可选
pip install django-filter # 可选

然后在站点设置里添加如下支持:

1
2
3
4
INSTALLED_APPS = (
...
'rest_framework',
)

如果尝试使用可浏览的 API,开发人员可能也需要添加 REST 框架的登入登出页面,添加如下代码到你的urls.py文件。

1
2
3
4
urlpatterns = [
...
url(r'^api-auth/', include('rest_framework.urls'))
]

快速开始

接下来我们建立一个简单的 API,用来查看和编辑 Django 管理站点当中默认的usersgroups数据模型。

项目设置

复用前面章节内容中建立的jungle项目脚手架,激活虚拟环境后,建立名为tutorial的 Django 项目,然后启动一个quickstart应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 安装
> pip install djangorestframework

# 建立一个Django项目
> django-admin startproject tutorial

# 建立应用
> cd tutorial
> django-admin startapp quickstart

# 将模型同步至数据库
> python manage.py migrate

# 建立管理站点用户
> python manage.py createsuperuser --email admin@example.com --username admin

串行器

首先定义一个串行器(Serializer, /'siəriəlaiz/),然后建立一个名为tutorial/quickstart/serializers.py的模块用于接下来的数据展示。

1
2
3
4
5
6
7
8
9
10
11
12
from django.contrib.auth.models import User, Group
from rest_framework import serializers

class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = User
fields = ('url', 'username', 'email', 'groups')

class GroupSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Group
fields = ('url', 'name')

注意这段代码中,我们通过HyperlinkedModelSerializer建立了超链接关联,当然也可以选择主键或其它关联方式,但是对于 RESTful 设计而言超链接模式是一种较好的选择。

视图

这里我们最好是编写一些视图,打开tutorial/quickstart/views.py文件进行如下编辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from django.contrib.auth.models import User, Group
from rest_framework import viewsets
from quickstart.serializers import UserSerializer, GroupSerializer


class UserViewSet(viewsets.ModelViewSet):
"""
允许users被查看和编辑的API终点
"""
queryset = User.objects.all().order_by('-date_joined')
serializer_class = UserSerializer


class GroupViewSet(viewsets.ModelViewSet):
"""
允许groups被查看和编辑的API终点
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer

与其整合多个视图,不如将它们相似的行为整合到一个ViewSets类,当然如果需要也可以方便的将它们分离为多个单独的视图,但使用ViewSets可以保持视图逻辑更加清晰有条理。

URL

好了,这里让我们在tutorial/urls.py写下 API 的 URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
from django.conf.urls import url, include
from rest_framework import routers
from quickstart import views

router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet)
router.register(r'groups', views.GroupViewSet)

# 使用自动URL路由连接我们的API,并将登陆url包含至可浏览的API。
urlpatterns = [
url(r'^', include(router.urls)),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework'))
]

由于在前面代码中,我们使用了ViewSets代替Views,通过简单的将viewSets注册到router类,所以我们能够方便的为 API 生成 URL 配置。当然如果你觉得有必要,也可以继续使用传统的Views类并且显式注册 URL 配置。上面代码里,我们还将默认的登入登出页面整合为可浏览的 API,这是可选的,但是对于需要进行权限校验的场景又是必要的。

配置 settings.py

tutorial/settings.py添加分页配置可以控制 API 返回对象的数量:

1
2
3
4
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10
}

然后注册'rest_framework'INSTALLED_APPS属性当中:

1
2
3
4
5
6
7
8
9
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework', # 注册django-rest-framework至Django应用
]

测试 API

激动人心的时刻到来,我们可以通过python manage.py runserver启动服务然后访问http://127.0.0.1:8000/测试 API:

1
2
3
4
5
6
7
8
9
C:\Workspace\jungle\tutorial (master -> origin)
(venv) λ python manage.py runserver
Performing system checks...

System check identified no issues (0 silenced).
January 08, 2019 - 17:34:15
Django version 2.1.5, using settings 'tutorial.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CTRL-BREAK.

使用 Django2 快速开发 Web 服务

http://www.uinio.com/Python/Django/

作者

Hank

发布于

2017-03-10

更新于

2018-09-26

许可协议