作者:代昌松
项目详情代码请参考:
vote_api:https://gitee.com/dcstempt_ping/vote_api
在传统的Web应用开发中,大多数程序员会将浏览器作为前端和后端的分界线,将浏览器中为用户进行页面展示的部分称之为前端,而将运行在服务器为前端提供业务逻辑和数据准备的所有代码统称为后端。所谓前后端分离开发,就是前后端的工程师约定好数据交接口,并进行开发和测试,后端只提供数据,不负责将数据渲染到页面上,前端通过HTTP请求头获取数据并负责将数据渲染到页面上,这个工作是交给浏览器中的JavaScript来实现的。
使用前后端分离开发有诸多好处,下面我们简要说明这些有点:
首先再Windows的命令控制台输入django-admin startproject vote_api
创建一个名为vote_api的投票项目,
通过Pycharm打开项目后为其配置虚拟环境,搭建项目依赖项,若此处有疑问,请参考我的码云:https://gitee.com/dcstempt_ping/carsys_back_end_rendering,当中对Django项目的创建有详细的描述。
创建好Django项目后,接下来修改settings.py
文件,
# 将setting.py文件中
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
# 修改为
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
运行项目,如果能看到如下图的页面,就表示我们的项目经建立成功了
接下来创建项目的应用,我们将其命名为polls
,在pycharm终端中执行以下命令建立我们的应用
django-admin startapp polls
并在settings.py
文件中添加polls
INSTALLED_APPS = [
"""将polls添加到此列表中"""
'polls'
]
本项目中会用到四个html的页面,分别为subjects.html
、teachers.html
、login.html
和register.html
,还需要用到老师的头像和是否热门的火焰小图标,请进入我的码云vote_api获取项目静态资源。
添加好静态资源后,需要配置settings.py
文件
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static'), ]
STATIC_URL = '/static/'
完成了以上操作,本项目的所以准备工作已经完成,下面就将进行项目的正式搭建环节
本项目需要借助与MySQL来提供相应的数据,我们将在MySQL中建立两张表分别为tb-teacher
和tb_subject
,但是在项目中,我们还需要一个表格来保存已经注册的用户,这里我们先不建立用户表,到后边再告诉大家如何处理。
建立老师表和学生表的SQL语句如下
drop database if exists `vote_api`;
create database `vote_api` default charset utf8;
use `vote`;
-- 创建学科表
create table `tb_subject` (
`no` integer not null auto_increment comment '编号',
`name` varchar(50) not null comment '名称',
`intro` varchar(1000) not null default '' comment '介绍',
`is_hot` boolean not null default 0 comment '是否热门',
primary key (`no`)
);
-- 创建老师表
create table `tb_teacher` (
`no` integer not null auto_increment comment '编号',
`name` varchar(20) not null comment '姓名',
`sex` boolean not null default 1 comment '性别',
`birth` date not null comment '出生日期',
`intro` varchar(1000) not null default '' comment '介绍',
`photo` varchar(255) not null default 'default.png' comment '照片',
`gcount` integer not null default 0 comment '好评数',
`bcount` integer not null default 0 comment '差评数',
`sno` integer not null comment '所属学科',
primary key (`no`),
foreign key (`sno`) references `tb_subject` (`no`)
);
【注意】:在tb_teacher
中,老师的所属学科sno
是一个外键,关联着学科的信息
建立好了两张表格之后,我们为他们插入相应的数据,SQL语句如下
-- 插入学科记录
insert into `tb_subject`
(`name`, `intro`, `is_hot`)
values
('Python全栈+人工智能', 'Python是一门简单、优雅、高效的编程语言,国内外诸多互联网企业都在使用Python语言并将其应用于后端开发、数据采集、数据分析、量化交易、自动化运维、自动测试等领域,从国内的华为、阿里、腾讯、美团到国外的Facebook、LinkedIn、Amazon、Google,很多互联网企业都有自己的Python研发团队。近年来随着大数据和人工智能产业的崛起以及企业对数据驱动决策的需求,数据分析和人工智能等领域出现了巨大的Python人才缺口,企业对Python开发者的需求更是以每年150%的趋势增长。国内一线城市,Python开发者平均月薪高达20000元以上,数据分析和人工智能的从业者月薪更是远高于行业平均水平。', 1),
('全链路UI/UE设计', '以行业潮流设计为导向设计课程,抛弃传统的培训模式,采用以全链路的形式培养行业急需的复合型设计人才,让学员学会将设计的价值融入每一个和用户的接触点中,让整个业务的用户体验质量得到几何级数的增长,并培养学员具备整合、统筹整个设计环节的能力。全链路UI/UE坚持紧跟行业急需设计升级课程,保证满足行业的需求。同时采用学科联合的模式开发真实上线项目,让学员感受企业项目开发过程、团队配合、项目提案及路演全过程,让学员从思想上转变为设计师身份,提高学习和动手能力,毕业快速适应企业的工作节奏与项目分工。', 0),
('JavaEE+分布式开发', 'Java语言历经20余年的雕琢,已经涵盖到软件开发的各个领域。从桌面应用到Web应用,从个人手持设备到大型服务器集群,IT行业中随处可见Java的身影;从小规模的单体应用到中等规模的集群应用再到千万级访问的高并发应用,Java生态圈都能给出完美的解决方案。作为编程领域的王者,Java语言拥有全球最大的开发者群体和最强的技术生态圈;作为后端开发的首选语言,Java语言为企业孕育了一批又一批程序员和架构师,引领着整个软件行业的发展。', 0),
('HTML5大前端', '随着互联网和移动互联网的飞速发展,形形色色的网站和五花八门的App层出不穷;为了打造更好的互联网产品,游戏、社交、电商、金融、教育等行业对前端开发者的需求更是达到了前所未有的高度。HTML5作为万维网的核心和前端开发的基石, 凭借着跨平台、低成本、快速迭代、高效分发等优势,迅速成为了互联网产品研发的关键技术。近年来,从PC端开发到移动端开发再到小程序开发,前端开发的相关技术和工具一直处于持续发展状态;从初创企业到上市公司再到行业巨头,对前端开发者的人才需求一直保持巨大缺口,优秀的Web前端工程师仍然是“一将难求”。', 1);
-- 插入老师记录
insert into `tb_teacher`
(`name`, `sex`, `birth`, `intro`, `photo`, `sno`)
values
('骆昊', 1, '1980-11-28', '15年以上产品设计和研发经验,工学博士,参与过3项国家自然科学基金项目和多项科技攻关项目的研发,发表过1篇SCI和3篇EI论文。分布式网络性能测量系统的设计者,perf-TTCN3语言的发明者。CSDN博客专家,GitHub网站8w+星标项目Python-100-Days作者。精通C/C++、Java、Python、SQL等编程语言,擅长OOAD、系统架构、算法设计、数据分析,一直践行“用知识创造快乐”的教学理念,善于总结,乐于分享。', 'luohao.png', 1),
('余婷', 0, '1992-4-9', '5年以上移动互联网项目开发经验和教学经验,曾担任上市游戏公司高级软件研发工程师和技术负责人,参与多个游戏类应用移动端和后台程序研发,拥有丰富的项目开发和管理经验,在苹果的AppStore上发布过多款应用。精通Python、Objective-C、Swift等开发语言,熟悉移动App和RESTful接口开发相关技术。授课条理清晰、细致入微,有较强的亲和力,教学过程注重理论和实践的结合,在学员中有良好的口碑。', 'yuting.png', 1),
('张无忌', 1, '1988-7-6', '中土明教第三十四代教主。武当七侠之一张翠山与天鹰教紫微堂主殷素素之子,明教四大护教法王之一金毛狮王谢逊义子。出生起在冰火岛过着原始生活,踏足中土后即幼失怙恃,中玄冥神掌寒毒命危,后在蝴蝶谷带病习医,义送孤儿至西域,在昆仑仙谷绝处逢生。忍受寒毒煎熬七年,福缘际会,融合九阳神功、乾坤大挪移、太极拳(及太极剑)和圣火令神功四大盖世武功为一体,当世无敌。此外还精研医术和毒术,术绝尘寰。', 'zhangwuji.png', 2),
('韦一笑', 1, '1979-12-5', '在明教四大护教法王“紫白金青”中排行最末,因其轻功绝顶,又吸食鲜血,故绰号“青翼蝠王”。与布袋和尚说不得是生死之交。其轻功可谓无与伦比,当世无双,这种绝世轻功不是练出来的,而是天赋异禀可以说是天赐,在修炼“寒冰绵掌”时出了差错,经脉中郁积了寒毒,一用内力寒毒就会发作,要吸人血免去全身血脉凝结成冰。后得到张无忌“九阳神功”治疗祛除了寒毒摆脱嗜血的命运。', 'weiyixiao.png', 4);
在虚拟环境中安装连接MySQL说需要的依赖项:mysqlclient
pip install mysqlclient -i https://pypi.doubanio.com/simple
修改settings.py
文件中,添加我们的数据库的相关信息
DATABASES = {
'default': {
# 数据库引擎配置
'ENGINE': 'django.db.backends.mysql',
# 数据库名称
'NAME': 'TPIS',
# 启动MySQL服务的端口号
'PORT': '3306',
# 数据库服务器的IP地址
'HOST': 'localhost',
# 数据库用户名
'USER': 'TPIS',
# 数据库口令
'PASSWORD': '[email protected]',
# 数据库使用的字符集
'CHARSET': 'utf8',
# 数据库时间日期的时区设定
'TIME-ZONE': 'Asia/Shanghai'
}
}
配置好项目后,我们运行项目,若MySQL配置成功,控制台会出现如下语句
Django version 2.2.12, using settings 'vote_api.settings'
Django框架提供了ORM来解决数据持久化问题,ORM翻译成中文叫”对象关系映射“。因为Python是面向对象的编程语言,在python中使用对象模型来保存数据,而关系型数据库使用关系模型,用二维表来保存数据,两种模型并不匹配。使用ORM是为了实现对象模型到关系模型的双向转换,这样就不用在Python中书写SQL语句和游标操作,因为遮羞都会由ORM自动完成。利用Django的ORM,可以直接将创建的车辆信息表和违章记录表编程Django中的模型类。
python manage.py inspectdb > polls/models.py
Django完成了ORM模型的映射之后,我们将类名稍作修改,使我们的代码更加简洁
class TbSubject(models.Model)
class TbTeacher(models.Model)
# 修改为
class Subject(models.Model)
class Teacher(models.Model)
创建好模型类之后,可以通过Django框架自带的后台管理应用(admin
应用)实现对模型的管理。虽然在实际应用中,这个后台可能并不能满足我们的需求,但是在学习Django框架使,我们可以利用admin
应用来管理我们的模型,同时也可以通过它来了解一个项目的后台管理系统需要哪些功能。使用Django自带的admin
应用步骤如下所示。
将admin
应用所需的表迁移到数据库中。admin
应用本身也是需要数据库的支持,而且在admin
应用中已经定义好了相关的数据模型类,我们只需要通过模型迁移操作就能自动在数据库中生成所需的二维表。
python manage.py migrate
执行了这条命令之后,我们可以发现在我们的MySQL服务器中,新增加了10张二维表,Django就是利用这10张表格来管理后台
创建访问admin
应用的超级用户账号,这里需要输入用户名、邮箱和口令(登录密码)。
python manage.py createsuperuser
在这里输入密码时,不能够显示也不能退格,所以需要我们一气呵成,如果输错就重新创建吧
http://127.0.0.1:8000/admin
,用刚刚创建的超级管理员账号和密码进行登录登录成功后将会看到如下页面,此时这里就是Django的管理员操作平台
但是此时,我们还不能够管理学科和老师的信息,,因为我们暂时还没有在admin
应用中创建这两个类,因此,我们需要在polls
应用的admin.py
文件中对需要管理的模型进行注册。
模型注册类
from django.contrib import admin
from polls.models import Teacher, Subject
admin.site.register(Subject)
admin.site.register(Teacher)
对模型进行CRUD操作。可以在管理员平台对模型进行C(新增)、R(查看)、U(更新)、D(删除)操作,如下图所示
- 查看所有学科
- 删除和更新
担心当我们进行增删改查的操作过程中,会发现一个问题,在后台管理系统查看学科信息和老师信息的时候,显示的信息并不是非常直观。为此我们需要修改注册管理类模型,以便于在后台管理系统中看到更好的管理模型。
from django.contrib import admin
from polls.models import Teacher, Subject
class SubjectModelAdmin(admin.ModelAdmin):
list_display = ('no', 'name', 'intro', 'is_hot')
search_fields = ('name',)
ordering = ('no',)
class TeacherModelAdmin(admin.ModelAdmin):
list_display = ('no', 'name', 'sex', 'birth', 'intro',
'photo', 'good_count', 'bad_count', 'subject')
search_fields = ('name',)
ordering = ('no',)
admin.site.register(Subject)
admin.site.register(Teacher)
为了更好地查看模型,我们需要在models.py
文件中为ORM映射出地两个类添加__str__
魔法方法,并在该方法中返回学科地名字,这样在后台管理系统中查看老师所属的学科,就不再会是Subject object(1)
,而是准确的学科名称。我们同时也希望在后台学科和老师地管理后台中,每个文本框前边都是中文而不是模型中地英文名称,此时需要用到verbose_name
方法返回中文名称。
from django.db import models
class Subject(models.Model):
no = models.AutoField(primary_key=True, verbose_name='编号')
name = models.CharField(max_length=50, verbose_name='学科名称')
intro = models.CharField(max_length=1000, verbose_name='学科介绍')
is_hot = models.IntegerField(verbose_name='是否热门')
def __str__(self):
return self.name
class Meta:
managed = False
db_table = 'tb_subject'
verbose_name = '学科'
verbose_name_plural = '学科'
SEX_OPTIONS = (
(True, '男'),
(False, '女')
)
class Teacher(models.Model):
no = models.AutoField(primary_key=True,verbose_name='编号')
name = models.CharField(max_length=20, verbose_name='姓名')
sex = models.BooleanField(default=True, choices=SEX_OPTIONS, verbose_name='性别')
birth = models.DateField(verbose_name='生日')
intro = models.CharField(max_length=1000, verbose_name='教师简介')
photo = models.ImageField(max_length=255, verbose_name='头像')
good_count = models.IntegerField(verbose_name='好评', db_column='gcount')
bad_count = models.IntegerField(verbose_name='差评', db_column='bcount')
subject = models.ForeignKey(to=Subject, on_delete=models.DO_NOTHING, db_column='sno', verbose_name='所属学科')
class Meta:
managed = False
db_table = 'tb_teacher'
verbose_name = '教师'
verbose_name_plural = '教师'
【说明】:
tb_teacher
中没有good_count
、bad_count
和subject
字段,这里需要用db_column
告诉Django映射的字段。SEX_OPTION
在将性别选项的布尔值替换为中文的男
和女
。class Meta
中添加verbose_name
和verbose_name_purl
属性告诉管理平台,在中文中,教师和学科的单复数形式都用同一个词语表示,这样就不会出现学科s
和教师s
的情况了把软件(Software)、平台(Platform)、基础设施(Infrastructure)做成服务(Service)是很多IT企业一直在做的事情,这就是Sass(软件即服务)、Pass(平台即服务)和Lass(基础设施即服务)。实现面向服务的架构(SOA)有诸多方式,包括RPC(远程过程调用)、Web Service、REST等,在技术层面上,SOA是一种抽象的、松散耦合的粗粒度软件架构;在业务层面上,SOA的核心理念是“重用”和“互操作”,它将系统资源合成可操作的、标准的服务,使得这些资源能够被重新组合和应用。实现SOA的煮多方案中,REST被认为是最适合互联网应用的架构,符合REST规范的架构也常被称之为RESTful架构。
REST这个词,是Roy Thomas Fieding在他2000年的博士论文中提出的,Roy是HTTP协议(1.0和1.1版)的主要设计者、Apache服务器软件主要作者、Apache基金会第一任主席。在他的博士论文中,Roy把他对互联网软甲的架构原则定名为REST,即REpresentation State Transfer的缩写,中文翻译为:“表现层状态转移”或“表述层状态转移。
这个“表现层”其实指的是“资源”的“表现层”。所谓资源,就是网络上的一个实体,也可以称之为网络上的一个具体信息。它可以是一段文本、一张图片、一首歌曲或一种服务。我们可以用一个URL(统一资源定位符)指向资源,要获取到这个资源,访问它的URL即可,URL就是资源在互联网上的唯一标识。资源可以有多种外在表现形式。我们把资源具体呈现出来的形式,叫做它的“表现层”。比如,文本可以用text/plain
格式表现,也可以用text/html
格式、application/json
格式表现,甚至可以采用二进制格式;图片可以用image/jepg
格式表现,也可以用image/png
格式表现。URL只代表资源的实体,不代表他的变现形式。严格地说,有些网址最后地.html
后缀名是不必要的。因为这个后缀名表示格式,属于“表现层”范畴,而URL应该只代表“资源” 的位置,它的具体表现形式,应该在HTTP请求头的信息中用Accept
和Content-type
字段指定,这两个字段才是对“表现层”的描述。
访问一个网站,就代表了客户端和服务器的一个互动过程。在这个过程中,势必涉及到数据和状态的变化。Web应用通常使用HTTP作为通讯协议,客户端想要操作服务器,必须通过HTTP请求头,让服务器端发生“状态”转移,而这种状态转移是建立在表现层之上的,所以就是“表现层状态转移”。客户端通过HTTP的动词GET、POST、PUT(或PATCH)、DELETE,分别对应对资源的四种基本操作,其中GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT(或PATCH)用来更新资源,DELETE用来删除资源。
当我们在设计Web应用是,如果需要想客户提供资源需求,就可以使用REST风格的URL,这是实现RESTful架构的第一步。当然,真正的RESTful架构并不只是URL符合REST风格,更重要的是“无状态”和“幂等性”两个词。
下面我将根据本项目给出一些符合REST风格的URL,参考如下
请求方法(HTTP动词) | URL | 解释 |
---|---|---|
GET | /students/ | 获取所有学生 |
POST | /students/ | 新建一个学生 |
GET | /students/ID/ | 获取指定ID的学生信息 |
PUT | /students/ID/ | 更新指定ID学生信息 |
PATCH | /students/ID/ | 更新指定ID学生的信息 |
DELETE | /students/ID/ | 删除指定ID学生信息 |
GET | /students/ID/friends/ | 列出指定ID学生的所有朋友 |
DELETE | /students/ID/friends/ID/ | 删除定影ID的学生的指定ID的朋友 |
在Django项目中,如果要实现REST架构,即将网站的资源发布成REST风格的API接口,可以使用著名的Python三方库djangorestframework
,在此处,将其简称为DRF
安装DRF
pip install djangorestframework
配置DRF:需要在settings.py
文件中修改配置文件
INSTALLED_APPS = [
'rest_framework',
]
前后端分离开发需要后端为前端、移动端提供API数据接口,而API接口通常情况下都是返回JSON格式的数据,这就需要对模型对象进行序列化处理。DRF中封装了serializer
类和ModelSerializer
类用于实现序列化操作,通过继承Serializer
类或ModelSerializer
类,我们可以自定义序列化器,用于将对象处理为字典。
首先,我们需要在polls
应用文件下,创建一个新的python文件serializers
,先用来编写“学科”的序列化器,具体代码如下:
from rest_framework import serializers
from polls.models import Subject, Teacher
class SubjectSerializer(serializers.ModelSerializer):
class Meta:
model = Subject
fields = "__all__"
有利学科的序列化器,我们就可以完成学科的视图函数了
def show_index(request: HttpRequest) -> HttpResponse:
return redirect('/static/html/subjects.html')
def show_subjects(request: HttpRequest) -> HttpResponse:
queryset = Subject.objects.all().order_by('no')
serializers = SubjectSerializer(queryset, many=True)
return Response({'subjects': serializers.data})
【说明】
show_index
函数会在请求http://127.0.0.1:8000
时直接重定向到subjects.html
queryset
传给序列化器,由于queryset
含有多条数据,需要加上many=True
参数Response
返回给浏览器JSON
格式的数据给浏览器.data
表示能拿到序列化好后的属性添加URL映射
from polls.views import show_index, show_subjects
urlpatterns = [
path('admin/', admin.site.urls),
path('', show_index),
path('api/subjects/', show_subjects),
]
这是当我跑起项目,在浏览器地址栏中请求http://127.0.0.1:8000/api/subjects/
,会发现有一个.accepted_renderer not set on Response
的错误提示。如果需要返回DRF框架中封装的Response对象,需要给视图函数加一个装饰器
@api_view(('GET', ))
# 表示通过装饰器只接受GET请求,因为需要拿到数据
def show_subjects(request: HttpRequest) -> HttpResponse:
queryset = Subject.objects.all().order_by('no')
serializers = SubjectSerializer(queryset, many=True)
return Response({'subjects': serializers.data})
之后再刷新网页,就能看到如下图:
这样的查询,也被称之为具有REST风格的查询
再用同样的方式,书写拿到老师数据的序列化器和视图函数。由于在MySQL的tb_teacher
中subject
是一个外键,所以在老师的序列化器,需要分别序列化学科字段和老师字段,但是对于tb_subject
中,查找老师的数据,只需要用到学科编号即可,所以这里需要对学科序列化器做一个简单的处理
class SubjectSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Subject
fields = ('no', 'name')
class TeacherSerializer(serializers.ModelSerializer):
class Meta:
model = Teacher
exclude = ('subject', )
【说明】通过fields
可以拿到我们想要序列化的字段,fields = "__all__"
表示序列化当前模型的所有字段;通过exclude
表示出去我们不想序列化的字段
拿到老师的api接口数据的视图函数如下:
@api_view(('GET', ))
def show_teachers(request: HttpRequest) -> HttpResponse:
try:
sno = int(request.GET.get('sno'))
subject = Subject.objects.only('name').get(no=sno)
teachers = Teacher.objects.filter(subject=subject).defer('subject').order_by('no')
subject_serializer = SubjectSimpleSerializer(subject)
# 老师和学科的关系是多对一,所以一个老师只对应一门学科
teacher_serializer = TeacherSerializer(teachers, many=True)
return Response({'subject': subject_serializer.data,
'teachers': teacher_serializer.data})
except (TypeError, ValueError, Subject.DoesNotExist):
return Response(stutus=404)
投射老师视图函数的URL映射
path('api/teachers/', show_teachers),
当我们在网页上请求http://127.0.0.1:8000/api/teachers
会发现并没有返回老师的JSON数据,而是返回的404异常,这是因为我们在show_teachers
中请求了sno
,也就是老师对应的学科编号,sno
为空,所以会报异常,所以需要在浏览器中请求http://127.0.0.1:8000/api_teachers/sno=1
,这时就可以拿到学科编号no=1
对应的老师的数据了。
这里我们将通过vue.js
做前端渲染,这时一个渐进式前端MVVM框架(更好的MVC框架),在使用vue.js
时,我们需要先在https://www.bootcdn.cn/ 拿到vue.js
的链接,通过vue.js
,咱们的后端只需要提供数据,它就可以帮助我们渲染页面,这样就可以避免写繁琐的javascript
的DOM
操作的代码。
<body>
<div id="container">
<div class="user">
<a href="/static/html/login.html">用户登录a>
<a href="/static/html/register.html">快速注册a>
div>
<h1>必出精品学堂所有学科h1>
<hr>
<div id="main" v-loading.fullscreen.lock="loading">
<dl v-for="subject in subjects">
<dt>
<a :href="'/static/html/teachers.html?sno=' + subject.no">{{ subject.name }}a>
<img v-if="subject.is_hot" src="/static/images/hot-icon-small.png">
dt>
<dd>
{{ subject.intro }}
dd>
dl>
div>
div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.min.js">script>
<script>
let app = new Vue({
el: '#container',
data: {
subjects: [],
loading: true,
},
created() {
fetch('/api/subjects/')
.then(resp => resp.json())
.then(json => {
this.loading = false
this.subjects = json.subjects
})
}
})
script>
body>
再次运行咱们的项目,就可以看到学科首页了
通过同样的方式再渲染老师页面,就可点击课程名称的超链接,查看到该课程老师的信息。
<body>
<div id="container" v-loading.fullscreen.lock="loading">
<h1>{{ subject.name }}学科的老师信息</h1>
<hr>
<h2 v-if="teachers.length == 0">暂无该学科老师的信息</h2>
<div class="teacher" v-for="teacher in teachers">
<div class="photo">
<img :src="'/static/images/' + teacher.photo" height="140" alt="">
</div>
<div class="info">
<div>
<span><strong>姓名:{{ teacher.name }}</strong></span>
<span>性别:{{ teacher.sex | maleOrFemale }}</span>
<span>出生日期:{{ teacher.birth }}</span>
</div>
<div class="intro">
{{ teacher.intro }}
</div>
<div class="comment">
<a href="" @click.prevent="vote(teacher, true)">好评</a>
(<strong>{{ teacher.good_count }}</strong>)
<a href="" @click.prevent="vote(teacher, false)">差评</a>
(<strong>{{ teacher.bad_count }}</strong>)
</div>
</div>
</div>
<a href="/">返回首页</a>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.min.js"></script>
<script>
let app = new Vue({
el: '#container',
data: {
subject: null,
teachers: [],
loading: true
},
created() {
fetch('/api/teachers' + location.search)
.then(resp => resp.json())
.then(json => {
this.loading = false
this.subject = json.subject
this.teachers = json.teachers
})
},
filters: {
maleOrFemale(sex) {
return sex ? '男' : '女'
}
}
})
</script>
【说明】:这里我先提前给投票的div
加上一个@click.prevent="vote(teacher, true)"
函数,方便一会我们继续编写投票功能
接下来需要进行好评和差评功能的实现,很明显假如能够在不刷新整个页面的情况下就可以实现这两个功能,会带来更好的用户体验,因此,我们考虑使用Ajax技术来实现”受理“和”删除“。Ajax是Ansynchronous Javascript And XML的缩写,简单的说,使用Ajax技术可以在不重新加载整个页面的情况下对页面经行局部刷新。
对于传统的Web应用,每次页面上需要加载新的内容都需要重新请求服务器并刷新整个页面,如果服务器短时间内无法给予响应或网络状况不理想,那么可能造成浏览器长时间的空白并使得用户处于等待状态,这个期间用户什么都做不了。很显然这样的Web应用并不能带来很好的用户体验。
对于Ajax技术的Web应用,浏览器可以向服务器发起异步请求获取数据。异步请求不会中断用户体验,当服务器返回了新的数据,我们就可以通过JavaScript代码的DOM操作来实现对页面的局部刷新,这样就相当于在不刷新整个页面的情况下更新了页面的内容。在这里,我们采用Vue.js来替代DOM操作。
为了实现这个功能,我们需要通过视图函数,通过Django封装的JsonResponse类将字典序列化为JSON字符串作为返回浏览器的响应内容。具体代码如下:
def praise_and_criticize(request: HttpRequest) -> HttpResponse:
try:
tno = int(request.GET.get('tno'))
teacher = Teacher.objects.get(no=tno)
if request.path.startswith('/praise/'):
teacher.good_count += 1
count = teacher.good_count
else:
teacher.bad_count += 1
count = teacher.bad_count
teacher.save()
data = {'code': 20000, 'mesg': '投票成功', 'count': count}
except (ValueError, Teacher.DoesNotExist):
data = {'code': 20001, 'mesg': '投票失败'}
return JsonResponse(data)
并在urls.py
文件中映射函数
urlpatterns = [
path('criticize/', praise_and_criticize),
]
并在teachers.html
文件的vue.js代码中添加method
方法
methods: {
vote(teacher, isPraise) {
let url = (isPraise? '/praise/?tno=' : '/criticize/?tno=') + teacher.no
fetch(url)
.then(resp => resp.json())
.then(json => {
if (json.code === 20000) {
if (isPraise) {
teacher.good_count = json.count
} else {
teacher.bad_count = json.count
}
} else {
alert(json.mesg)
if (json.code === 20002) {
location.href = '/static/html/login.html'
}
}
})
}
这时,我们就可以通过异步请求的方式,完成对学科老师的好评和差评了。但是此时,不能达到本项目预期的目的,本项目需要用户登录后才可以经行投票,为登录的用户需要先登录。接下来要完成的就是注册和登录的功能,并且完善投票的视图函数。
前文我们已经提到过,HTTP协议是无状态的,一次请求结束断开连接,下次服务器再收到请求,它就不知道这个请求是哪个用户发过来的。但是对于一个Web应用而言,它是需要有状态管理的,这样才能让服务器知道HTTP请求来自哪个用户,从而判断是否允许该用户请求以及为用户提供更好的服务,这个过程就是常说的会话管理。
在基于后端开发的Django项目中,可以通过用户登录成功之后,在服务器通过一个session
对象保存用户的相关数据,然后把session
的ID写入浏览器的cookie中,下一次请求时,HTTP请求头中会携带cookie数据,服务器从HTTP请求头中读取cookie中的sessionid
,根据这个标识符找到对应的session对象,这样就能够获取到之前保存在session中的用户数据。我们刚才说过,REST架构是最适合互联网应用的架构,它强调了HTTP的无状态性,这样才能保证应用的水平扩展能力(当并发请求量增加时,可以通过增加新的服务器节点来为系统扩容)。显然,基于session实现用户跟踪方式需要服务器保存session对象,在做水平扩展增加新的服务器节点时,需要复制和同步session对象,这显然是非常麻烦的。解决这个问题有两种方案,一种是架设缓存服务器(如Redis),让多个服务器节点共享缓存服务并将session对象直接置于缓存服务器中;另一种方式放弃session的用户跟踪,使用基于token的用户跟踪
基于token的用户跟踪是在用户登录成功之后,为用户生成身份表示并保存在浏览器本地存储(localStorage
、sessionStorage
、cookie
等)中,这样的化服务器不需要保存用户状态,从而可以很容易做到水平扩展。基于token的用户跟踪具体流程如下:
localStorage
或sessionStorage
中,对于使用Vue.js的前端项目来说,还可以通过Vuex进行状态管理);localStorage
中有误token,如果没有则跳转到登录页;通过上面的描述,相信大家已经发现了,基于token的用户跟踪最为关键的是在用户登录成功时,要为用户生成一个token作为用户的身份表示。生成token的方法很多,其中一种比较成熟的解决方案时使用JSON Web Token
JSON Web Token通常被称为JWT,它是一种开放标准。随着RESTful架构的流行,越来越多的项目使用JWT作为用户身份标识的认证方式。JWT相当于是三个JSON对象经过编码后,用.
分隔符并组合到一起,这三个JSON对象分些事头部(header)、载荷(payload)和签名(signature),如下图所示
头部
{
"alg":"HS256",
"ytp":"JWT"
}
其中,alg
表示签名的算法,默认时HMAC SH256(简写成sh256
);typ
属性表示这个令牌的类型,JWT中都统一书写为jwt
。
载荷
载荷部分用来存放实际需要传递的数据。JWT官文中规定了7个可选字段:
除了官方定义的字典,我们可以根据应用需要添加自定义字段,如下所示。
{
"sub":"0123456789",
"nikname":"dcstemp_ping",
"role":"admin"
}
签名
签名部分时对前两个部分生成一个指纹,防止数据伪造和篡改。显示签名需要首先指定一个密钥。这个密钥只有服务器才知道,不能泄露给用户。然后使用头部指定的签名算法(默认sh256
),按照如下公式产生签名。
HS256(base64Encode(header) + '.' + base64Encode(payload), secret)
算出签名后,把头部、载荷、签名三个部分拼接成一个字符串,每个部分用.
进行分割,这样一个JWT就生成好了。
使用JWT的有点非常明显,包括:
CSRF
攻击,因为在请求头中添加localStorage
或sessioStorage
中的token必须靠JavaScript代码来完成,而不是自动添加到请求头中。JWT也有部分缺点,使用时许需要引起注意,具体包括:
在python代码中,可以使用三方库pyjwt
生成验证码JWT
pip install pyjwt -i https://pypi.doubanio.com/simple
生成令牌
payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1),
'userid': 10001 }
token = jwt.encode(payload, settings.SECRET_KEY).decode()
验证令牌
try:
token'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1OTQ4NzIzOTEsInVzZXJpZCI6MTAwMDF9 .FM-bNxemWLqQQBIsRVvc4gq71y42I9m2zt5nlFxNHUo'
payload = jwt.decode(token, settings.SECRET_KEY)
except InvalidTokenError:
raise AuthenticationFailed('无效得令牌或令牌已过期')
在完成登录功能之前,我们还需要在咱们的MySQL中建立一个tb_user
二维表用来存放用户的信息
class User(models.Model):
"""用户"""
no = models.AutoField(primary_key=True, verbose_name='编号')
username = models.CharField(max_length=20, unique=True, verbose_name='用户名')
# 用户密码在数据库中保存对应的哈希摘要(签名、指纹)
password = models.CharField(max_length=32, verbose_name='密码')
tel = models.CharField(max_length=20, verbose_name='手机号')
reg_date = models.DateTimeField(auto_now_add=True, verbose_name='注册时间')
last_visited = models.DateTimeField(null=True, verbose_name='最后登录时间')
class Meta:
managed = True
db_table = 'tb_user'
verbose_name = '用户'
verbose_name_plural = '用户'
【说明】
User
类能够使用,我们需要让他继承models.Modes
,这样才能使用Django提供的ORM的双向转换。User
将编号作为tb_user
的主键,而不用username
考虑到了不同的用户可能会存在相同的用户名,而每个用户的编号是唯一的reg_date
中有一个auto_now_add=True
属性,可以拿到用户注册信息的时间,也就是创建User对象的时间,作为注册的时间。last_visited
拿到用户最后的登录时间,可以更好地了解用户的活性。manage = True表示该
User类是ORM下的受管理模型,需要借助Django的ORM在数据库中生成对应的
tb_user`。在终端中运行
python manage.py makemigrations polls
# 执行迁移
python manage.py migrate polls
这时我们会发现在数据库中新增加了tb_user
表
选择MD5将用户密码转换为哈希摘要,这里需要在polls
文件中创建一个名为utils
的python文件,用来书写哈希函数。
import hashlib
def gen_md5_digest(content):
return hashlib.md5(content.encode()).hexdigest()
首先修改login.html
文件
<body>
<div id="container">
<h1>用户登录h1>
<hr>
<p class="hint">{{ hint }}p>
<form action="" method="post">
<fieldset>
<legend>用户信息legend>
<div class="input">
<label>用户名:label>
<input type="text" v-model="username">
div>
<div class="input">
<label>密码:label>
<input type="password" v-model="password">
div>
fieldset>
<div class="button">
<input type="submit" value="登录" @click.prevent="login()">
<input type="reset" value="重置">
div>
form>
<div>
<a href="/">返回首页a>
<a href="/static/html/register.html">注册新用户a>
div>
div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.min.js">script>
<script>
let app = new Vue({
el: '#container',
data: {
hint: '',
username: '',
password: '',
},
methods: {
login() {
fetch('/login/', {
method: 'post',
body: JSON.stringify({
'username': this.username,
'password': this.password
}),
headers: {'content-type': 'application/json'}
}).then(resp => resp.json()).then(json => {
if (json.code === 10000) {
sessionStorage.token = json.token
sessionStorage.username = json.username
location.href = '/'
} else {
this.username = ''
this.password = ''
this.hint = json.mesg
}
})
}
}
})
script>
body>
【说明】:
JSON.stringfy
方法将用户名和密码放入HTTP请求的消息体中发送给服务器headers: {'content-type': 'application/json'}
。sessionStorage.token = json.token
,sessionStorage.username = json.username
,目的是为了在登录成功之后让用户名显示在首页的右上角,并且让浏览器保存用户的身份令牌,下次请求需要带上token请求,服务器才能有效地识别出用户;通过location.href = '/'
重定向到学科首页。根据登录页面需要后端提供地接口,来完成投票功能的视图函数:
@api_view(('POST', ))
def login(request: HttpRequest) -> HttpResponse:
username = request.data.get('username')
password = request.data.get('password')
if username and password:
password = gen_md5_digest(password)
user = User.objects.filter(username=username, password=password).first()
if user:
payload = {
'exp': timezone.now() + datetime.timedelta(days=1),
'userin': user.no
}
token = jwt.encode(payload, settings.SECRET_KEY).decode()
return Response({'code': 10000, 'token': token, 'username': username})
else:
hint = '请输入有效的用户名和密码'
return Response({'code': 10001, 'mesg': hint})
【说明】:
根据login.html
可以知道,此处不再是从post
表单中拿取数据,而是从消息体中拿到数据。所以需要用到request.data
获取前端通过HTTP消息提发过来的JSON
格式的数据。
导入utils.py
文件中写好的gen_md5_digest
,将密码转换成MD5摘要保存在数据库中。
登录失败则通过JSON
格式的数据返回错误信息。
登录成功,则会通过pyjwt
生成用户的token令牌,此处在令牌中只保存的用户的id
,并将过期时间设置为1天。
settings.SECRET_KEY
表示令牌的密钥,而SECRET_KEY
就在Django的配置文件中:
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 't99vzuo7rve#_ee0zh1$u5das)$&*o0-tcxffw^n$o-0b09(@b'
这个很显然是一个强口令,所以只要保存好这个强口令,别人就没有办法伪造或是篡改已经保存了的token令牌。若不想使用SECRET_KEY
也可以用自己熟悉的强口令作为密钥交给jwt
处理。
通过Response
将用户名和令牌返回给服务器。
<a v-if="!sessionStorage.token" href="/static/html/login.html">用户登录a>
若服务器拿到了用户的token令牌,则可以登录成功。
前文已经提到过,需要对学科进行投票需要先进行用户登录,所以这里还需要完善投票的视图函数
def praise_or_criticize(request: HttpRequest) -> HttpResponse:
token = request.META.get('HTTP_TOKEN')
if token:
try:
jwt.decode(token, settings.SECRET_KEY)
tno = int(request.GET.get('tno'))
teacher = Teacher.objects.get(no=tno)
if request.path.startswith('/praise/'):
teacher.good_count += 1
count = teacher.good_count
else:
teacher.bad_count += 1
count = teacher.bad_count
teacher.save()
data = {'code': 20000, 'mesg': '投票成功', 'count': count}
except (ValueError, Teacher.DoesNotExist):
data = {'code': 20001, 'mesg': '投票失败'}
except InvalidTokenError:
data = {'code': 20002, 'mesg': '登录已过期,请重新登录'}
else:
data = {'code': 20002, 'mesg': '请先登录'}
return JsonResponse(data)
并且修改teacher.html
中的vote()
功能函数
methods: {
vote(teacher, isPraise) {
let url = (isPraise? '/praise/?tno=' : '/criticize/?tno=') + teacher.no
fetch(url, {
headers: {
"token": sessionStorage.token
}
})
.then(resp => resp.json())
.then(json => {
if (json.code === 20000) {
if (isPraise) {
teacher.good_count = json.count
} else {
teacher.bad_count = json.count
}
} else {
alert(json.mesg)
if (json.code === 20002) {
location.href = '/static/html/login.html'
}
}
})
}
}
【说明】:
headers: {"token": sessionStorage.token}
,随后服务器从请求头将令牌发送给服务器验证其是否有效。token = request.META.get('HTTP_TOKEN')
获取到令牌。decode()
验证令牌,如果没有问题,会返回一个字典,字典中就是之前放到token中的payload数据。InvalidTokenError
的异常。这里只需要在subject.html
文件中,新添加一个方法logout()
就可以实现此功能
<a v-if="!!sessionStorage.token" href="" @click.prevent="logout()">退出登录</a>
""""""
methods: {
logout() {
// delete localStorage.token
// delete localStorage.username
this.username = ''
}
}
【说明】只需要在浏览器中将用户的token和用户名删除掉,就自动退出登录了
整个注册功能的思路相比登录操作稍微简单,只需要将用户提供的用户名、密码、手机号保存到数据库中就能够实现用户的注册,但是这样的做法并不是妥善的,我们还需要用户勾选相应的条款,以及发送手机验证码进行验证。
由于发送验证码需要接入三方平台,这里我们先完成在没有验证码情况下的用户登录
在不需要验证短信验证码的情况下,登录操作的视图函数如下:
def register(request: HttpRequest) -> HttpResponse:
hint = ''
if request.method == 'POST':
agreement = request.POST.get('agreement')
user = User()
if agreement:
user.username = request.POST.get('username')
user.password = request.POST.get('password')
user.tel = request.POST.get('tel')
if user.username and user.password and user.tel:
try:
user.password = gen_md5_digest(user.password)
user.save()
return redirect('/login/')
except:
hint = '该用户名已被使用'
else:
hint = '请将注册信息填写完整'
else:
hint = '请勾选用户协议'
return render(request, 'register.html', {'hint': hint})
可见,在用户勾选了条款协议的情况下,只需要从浏览器中拿到用户输入的相应信息,并将其保存到数据库中,即可完成注册。此处密码依然会通过gen_md5_digest
函数转换为哈希摘要保证密码的安全性。
这里我将省略在无验证码验证的情况下,register.html
的代码,读者可以自行完成,如有疑问,请参考我的码云:https://gitee.com/dcstempt_ping/Django_Vote中templates/register.html
的前端页代码,这里不再详细描述。我将重点放在基于手机验证码验证的用户注册中。
若在项目中,无法自行完成的功能,可以借助三方平台来实现,接入三方平台基本上有两种方式:
很显然,在不了解第三方库的情况下,调用API接口无疑是最为安全和保险的。此处我将使用https://my.luosimao.com/来进行手机验证码的发送。在使用之前需要在此网站进行用户注册,随后需要拿到每个账号的API-KEY
在 polls/utils.py
工具文件下添加发送验证码的函数
import random
import re
import requests
def gen_mobile_code(length=6):
"""生成指定长度的手机验证码"""
return ''.join(random.choices('0123456789', k=length))
TEL_PATTERN = re.compile(r'1[3-9]\d{9}')
def check_tel(tel):
"""检查手机号是否有效"""
return TEL_PATTERN.fullmatch(tel) is not None
def send_message_by_sms(tel, message):
resp = requests.post(
url='http://sms-api.luosimao.com/v1/send.json',
auth=('api', '0f68b3e48e7b4751d4f0e7358d910666'),
data={
'mobile': tel,
'message': message
},
timeout=5,
verify=False
)
return resp.json()
【说明】
send_message_by_sms
为发送短信的函数,此函数的模板可以在https://my.luosimao.com中找到对应编程语言的代码模板(如下图),但是此模板写的比较差劲,所以我们需要对其稍作调整通过传入`tel`短信内容`message`的实参,就可以完成发送代码的功能,但是请注意,由于是借助三方平台,所以在发送短信时,短信结尾需要跟上`【铁壳测试】`,否则无法发送。
gen_mobile_code
函数能够生成随机验证码。
check_tel
函数使用正则表达式检测手机号是否有效。
将随机生成的验证码作为短信内容,就可以发送短信验证码了。
在短信验证的过程中,需要将发送的短信保存到Redis缓存中,才能进行响应的验证,所以需要在Django接入redis缓存。在视图函数,通过Django框架封装好的变量caches,相当于一个字典,通过键default
可以拿到对应的值
caches['default']
拿到settings.py
文件中redis的配置的默认缓存
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': [
'redis://121.199.18.215:54321/0',
],
'KEY_PREFIX': 'vote:polls',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'CONNECTION_POOL_KWARGS': {
'max_connections': 512,
},
'PASSWORD': '5201314@Dcs',
}
},
}
由于在redis中存放的是键值对,通过设置
caches['default'].set(f'tel:valid:{tel}', code, timeout=600)
# 表示验证码暂存600秒
caches['default'].set(f'tel:block:{tel}', code, timeout=120)
# 表示在120秒之内不能重复发送验证码
在将验证码存入redis之前,我们还需要做一个检查,表示已经发送过验证码,这是就不需要再发送验证码了。我们需要将手机号作为键,通过redis拿到手机号对应的值,若拿到这个键表示不为空,则表示在120秒之内已经发送过短信了,这时候就不能发送短信验证码;若拿不到这个值,才执行发送验证码的功能
if caches['default'].get(f'tel:block:{tel}'):
data = {'code': 40003, 'message': '请不要在120秒内重复发送短信验证码'}
else:
code = gen_mobile_code()
message = f'您的短信验证码是{code},打死也不能告诉别人。【铁壳测试】'
result = send_message_by_sms(tel, message)
if result['error'] == 0:
caches['default'].set(f'tel:valid:{tel}', code, timeout=600)
caches['default'].set(f'tel:block:{tel}', code, timeout=120)
data = {'code': 40000, 'message': '短信验证码已发送到您的手机'}
else:
data = {'code': 40001, 'message': '短信验证码发送失败,请稍后重试'}
完整的短信验证码的视图函数代码如下:
@api_view(('GET', ))
def send_mobile_code(request: HttpRequest, tel) -> HttpResponse:
if check_tel(tel):
if caches['default'].get(f'tel:block:{tel}'):
data = {'code': 40003, 'message': '请不要在120秒内重复发送短信验证码'}
else:
code = gen_mobile_code()
message = f'您的短信验证码是{code},打死也不能告诉别人。【铁壳测试】'
result = send_message_by_sms(tel, message)
if result['error'] == 0:
caches['default'].set(f'tel:valid:{tel}', code, timeout=600)
caches['default'].set(f'tel:block:{tel}', code, timeout=120)
data = {'code': 40000, 'message': '短信验证码已发送到您的手机'}
else:
data = {'code': 40001, 'message': '短信验证码发送失败,请稍后重试'}
else:
data = {'code': 40002, 'message': '请输入有效的手机号码'}
return Response(data)
完成了发送验证码的视图函数后,我们需要在register.html
的前端页面添加一个手机验证码的接口,并绑定一个发送验证码的button
,这里我们要求当用户按下button
按钮后,在按钮上绑定一个120秒的倒计时,并且设置button
按钮为不可选定的状态,通过这样的方式告诉用户在120秒内无法重新发送验证码。
<div class="input mobile">
<label>手机号:</label>
<input type="tel" v-model="tel">
<input type="button" value="发送验证码" @click="sendCode()" :disabled="isBlocked">
</div>
<!--isBlocked是一个布尔值,默认是false,表示按钮没有被禁用,发送短信成功将其赋值为True-->
methods: {
sendCode() {
fetch('/api/mobile/' + this.tel)
.then(resp => resp.json())
.then(json => {
alert(json.message)
if (json.code === 40000) {
this.isBlocked = true
setTimeout(() => {this.isBlocked = false}, 120000)
}
})
},
修改好了前端也之后,我们还需要完善注册功能的视图函数。这里我们需要再从redis中拿到手机号和对应的验证码,然后再拿到前端页输入的验证码,比较两组验证码是否保持一致
tel = request.data.get('tel')
mobilecode = request.data.get('mobilecode', '0')
# 从前端页表单拿到的验证码(用户输入的验证码)
mobilecode2 = caches['default'].get(f'tel:valid:{tel}', '1')
# 从redis缓存中拿到的验证码
验证成功之后,才能执行我们的注册流程,否则就给用户验证码的错误提示。
完整的register
的视图函数如下:
@api_view(('POST',))
def register(request: HttpRequest) -> HttpResponse:
agreement = request.data.get('agreement')
if agreement:
tel = request.data.get('tel')
mobilecode = request.data.get('mobilecode', '0')
mobilecode2 = caches['default'].get(f'tel:valid:{tel}', '1')
if mobilecode == mobilecode2:
username = request.data.get('username')
password = request.data.get('password')
if username and password and tel:
try:
password = gen_md5_digest(password)
user = User(username=username, password=password, tel=tel)
user.save()
return Response({'code': 30000, 'mesg': '注册成功'})
except DatabaseError:
hint = '注册失败,请尝试更换用户名'
else:
hint = '请输入有效的注册信息'
else:
hint = '请输入有效的手机验证码'
else:
hint = '请勾选同意网站用户协议及隐私政策'
return Response({'code': 30001, 'mesg': hint})