项目实战二
需求
以前后端分离的方式实现学生的增删改查操作
学生列表接口
url:/students/
请求方法:get
参数:
- 格式:查询参数
参数名 | 类型 | 是否必传 | 说明 |
---|---|---|---|
page | int | 否 | 页码,默认为1 |
size | init | 否 | 每页数据条数默认为10 |
name | str | 否 | 根据姓名过滤 |
age | int | 否 | 根据年龄过滤 |
sex | int | 否 | 根据性别过滤 |
phone | str | 否 | 根据手机过滤 |
channel | int | 否 | 根据渠道过滤 |
响应:
- 状态码:200
-
格式:json
-
响应示例:
{
"total": 7,
"page": 1,
"next_page": null,
"pre_page": null,
"results": [
{
"id": 8,
"name": "yaya",
"age": 18,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-09-01T06:30:48.417Z"
},
{
"id": 7,
"name": "yaya",
"age": 18,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-09-01T06:10:41.869Z"
},
{
"id": 6,
"name": "心蓝",
"age": 20,
"sex": 1,
"phone": "15873061798",
"channel": "",
"c_time": "2022-08-31T12:21:04.068Z"
},
{
"id": 5,
"name": "小简",
"age": 16,
"sex": 0,
"phone": null,
"channel": "抖音",
"c_time": "2022-08-23T13:10:05.317Z"
},
{
"id": 3,
"name": "张柏芝",
"age": null,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-08-23T08:00:15.165Z"
},
{
"id": 2,
"name": "刘德华",
"age": null,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-08-23T08:00:08.035Z"
},
{
"id": 1,
"name": "心蓝",
"age": null,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-08-23T07:59:29.417Z"
}
]
}
学生详情接口
url:/students/pk/
请求方法:get
参数:
- 格式:路径参数
响应数据:
-
状态码:200
-
格式:json
- 响应实例:
{
"id": 1,
"name": "心蓝",
"age": null,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-08-23T07:59:29.417Z"
}
学生添加接口
url:/students/
请求方法:post
参数:
- 格式:json
参数名 | 类型 | 是否必传 | 说明 |
---|---|---|---|
name | str | 是 | 姓名 |
age | int | 否 | 年龄 |
sex | int | 否 | 性别 |
phone | str | 否 | 手机 |
channel | int | 否 | 渠道id |
- 请求实例:
{
"name": "刘德华",
"age": 60,
"sex": 1,
"phone": '13888888888',
"channel": 1
},
响应:
-
状态码:201
-
格式:json
- 响应示例:
{
"id": 8,
"name": "yaya",
"age": 18,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-09-01T06:10:41.869Z"
}
学生修改接口
url:/students/pk/
请求方法:put/patch
参数:
- 格式:json,路径
参数名 | 类型 | 是否必传 | 说明 |
---|---|---|---|
name | str | 否 | 姓名 |
age | int | 否 | 年龄 |
sex | int | 否 | 性别 |
phone | str | 否 | 手机 |
channel | int | 否 | 渠道id |
- 请求实例:
{
"name": "刘德华",
"age": 60,
"sex": 1,
"phone": '13888888888',
"channel": 1
},
响应:
-
状态码:200
-
格式:json
- 响应示例:
{
"id": 8,
"name": "yaya",
"age": 18,
"sex": 1,
"phone": null,
"channel": "",
"c_time": "2022-09-01T06:10:41.869Z"
}
学生删除接口
url:/students/pk/
请求方法:delete
参数:
- 格式:路径
响应:
- 状态码:204
- 格式:无响应内容
后端代码
视图
class StudentView(View):
def serialize(self, item):
return {
'id': item.id,
'name': item.name,
'age': item.age,
'sex': item.sex,
'phone': item.phone,
'channel': item.channel.title if item.channel else '',
'c_time': item.c_time
}
def get(self, request, pk=None):
if pk is not None:
# 查看学生详情
obj = self.get_obj(pk)
data = self.serialize(obj)
return JsonResponse(data)
# 1. 获取查询参数
query_params = {key: value for key, value in request.GET.items()}
# 2. 获取分页参数
page = int(query_params.pop('page', 1))
size = int(query_params.pop('size', 10))
# 3. 获取查询集
queryset = Student.objects.all()
for key, value in query_params.items():
try:
queryset = queryset.filter(**{key: value})
except:
pass
# 4. 分页处理
# 数据总条数
total_num = queryset.count()
# 总页数
total_page = math.ceil(total_num / size)
# 下一页
absolute_url = self.request.build_absolute_uri()
next_url = None
pre_url = None
if page < total_page:
if 'page' in absolute_url:
next_url = re.sub(r'page=\d*', 'page={}'.format(page+1), absolute_url)
else:
next_url = absolute_url + '&page={}'.format(page+1)
# 上一页
if page > 1:
if 'page' in absolute_url:
pre_url = re.sub(r'page=\d*', 'page={}'.format(page-1), absolute_url)
else:
pre_url = absolute_url + '&page={}'.format(page-1)
# 分页过滤
queryset = queryset[(page - 1) * size:page * size]
# 5.序列化
students = [
self.serialize(item) for item in queryset
]
data = {
'total': total_num,
'page': page,
'next_page': next_url,
'pre_page': pre_url,
'results': students
}
# 6. 返回响应
return JsonResponse(data)
def post(self, request):
# 1.接受参数
create_data = json.loads(request.body)
# 2.实例化表达
form = StudentForm(create_data)
# 3.校验
if form.is_valid():
instance = form.save()
# 4.序列化
data = self.serialize(instance)
return JsonResponse(data, status=201)
else:
# 5.错误信息
data = {'errors': form.errors}
return JsonResponse(data, status=400)
def get_obj(self, pk):
obj = get_object_or_404(Student, pk=pk)
return obj
def put(self, request, pk):
# 1. 获取对象
obj = self.get_obj(pk)
# 2. 接收参数
update_data = json.loads(request.body)
# 2. 实例化表单
form = StudentForm(update_data, instance=obj)
# 3. 校验
if form.is_valid():
instance = form.save()
# 4. 序列化
data = self.serialize(instance)
return JsonResponse(data, status=200)
else:
# 5.错误信息
data = {'errors': form.errors}
return JsonResponse(data, status=400)
def delete(self, request, pk):
# 1. 获取对象
obj = self.get_obj(pk)
# 2. 删除对象
try:
obj.delete()
return HttpResponse(status=204)
except Exception as e:
return JsonResponse(data={'errors': str(e)}, status=400)
路由
path('students/', views.StudentView.as_view(), name='student-list-create'),
path('students/<int:pk>/', views.StudentView.as_view(), name='student-retrieve-update-delete')
前端代码
列表页面
<!-- student_list_single.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 上述3个meta标签*必须*放在最前面,任何其他内容都*必须*跟随其后! -->
<title>Bootstrap 101 Template</title>
<!-- Bootstrap -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<!-- HTML5 shim 和 Respond.js 是为了让 IE8 支持 HTML5 元素和媒体查询(media queries)功能 -->
<!-- 警告:通过 file:// 协议(就是直接将 html 页面拖拽到浏览器中)访问页面时 Respond.js 不起作用 -->
<!--[if lt IE 9]>
<script src="https://fastly.jsdelivr.net/npm/html5shiv@3.7.3/dist/html5shiv.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/respond.js@1.4.2/dest/respond.min.js"></script>
<![endif]-->
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div class="container" style="width: 1000px" id="app">
<h1>学生列表</h1>
<a class="btn btn-success" style="float: right" href="./student_detail_single.html">添加</a>
<table class="table table-hover table-bordered table-condensed" v-cloak>
<thead>
<tr>
<th>序号</th>
<th>姓名</th>
<th>性别</th>
<th>年龄</th>
<th>phone</th>
<th>渠道</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(stu, index) in students">
<td>{{ index }}</td>
<td>{{ stu.name }}</td>
<td>{{ stu.sex }}</td>
<td>{{ stu.age }}</td>
<td>{{ stu.phone }}</td>
<td>{{ stu.channel }}</td>
<td>{{ stu.c_time }}</td>
<td style="text-align: center;padding: 0.75em 0"><a :href="'./student_detail_single.html?id='+stu.id"
class="btn btn-primary btn-sm">编辑</a>
<button class="btn btn-danger btn-sm" @click="delStudent(stu.id, index)">删除</button>
</td>
</tr>
</tbody>
</table>
<nav aria-label="...">
<ul class="pager">
<li class="previous" :class="{ disabled: !pre_url }"><a
href="{{ students.pre_url }}"><span
aria-hidden="true">←</span> 上一页</a></li>
<li class="next" :class="{ disabled: !next_url }"><a
href="{{ students.next_url }}">下一页 <span
aria-hidden="true">→</span></a>
</li>
</ul>
</nav>
</div>
<!-- jQuery (Bootstrap 的所有 JavaScript 插件都依赖 jQuery,所以必须放在前边) -->
<script src="https://fastly.jsdelivr.net/npm/jquery@1.12.4/dist/jquery.min.js"
integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ"
crossorigin="anonymous"></script>
<!-- 加载 Bootstrap 的所有 JavaScript 插件。你也可以根据需要只加载单个插件。 -->
<script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"
integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
let baseUrl = 'http://127.0.0.1:8000'
const app = new Vue({
el: '#app',
data: {
students: [],
next_url: null,
pre_url: null,
},
computed: {
},
methods: {
delStudent(sid, index) {
axios.delete(baseUrl + '/crm/students/' + sid + '/').then(res => {
this.students.splice(index, 1)
})
}
},
created() {
axios.get(baseUrl + '/crm/students/').then(res => {
this.students = res.data.results
this.next_url = res.data.next_url
this.pre_url = res.data.pre_url
})
}
})
</script>
</body>
</html>
详情页
<!-- student_detail_single.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 上述3个meta标签*必须*放在最前面,任何其他内容都*必须*跟随其后! -->
<title>Bootstrap 101 Template</title>
<!-- Bootstrap -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<!-- HTML5 shim 和 Respond.js 是为了让 IE8 支持 HTML5 元素和媒体查询(media queries)功能 -->
<!-- 警告:通过 file:// 协议(就是直接将 html 页面拖拽到浏览器中)访问页面时 Respond.js 不起作用 -->
<!--[if lt IE 9]>
<script src="https://fastly.jsdelivr.net/npm/html5shiv@3.7.3/dist/html5shiv.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/respond.js@1.4.2/dest/respond.min.js"></script>
<![endif]-->
<style>
[v-cloak]{
display: none;
}
</style>
</head>
<body>
<div class="container" style="width: 800px" id="app" v-cloak>
<h1>{{ title }}</h1>
<form class="form-horizontal">
<div class="form-group">
<label for="name" class="col-sm-2 control-label">姓名</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="name" name="name" v-model="stu.name" placeholder="姓名">
</div>
</div>
<div class="form-group">
<label for="sex" class="col-sm-2 control-label">性别</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="sex" name="sex" v-model="stu.sex" placeholder="性别">
</div>
</div>
<div class="form-group">
<label for="age" class="col-sm-2 control-label">年龄</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="age" name="age" v-model="stu.age" placeholder="年龄">
</div>
</div>
<div class="form-group">
<label for="qq" class="col-sm-2 control-label">qq</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="qq" name="qq" v-model="stu.qq" placeholder="qq">
</div>
</div>
<div class="form-group">
<label for="phone" class="col-sm-2 control-label">手机号码</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="phone" name="phone" v-model="stu.phone"
placeholder="手机号码">
</div>
</div>
<div class="form-group">
<label for="channel" class="col-sm-2 control-label">渠道</label>
<div class="col-sm-10">
<select name="channel" id="channel" class="form-control" v-model="stu.channel">
<option value="">--------</option>
<option v-for="channel in channels" :value="channel.id">{{ channel.name }}</option>
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button class="btn btn-info float-right"
@click.prevent="btnSubmit(stu)">{{ btnName }}</button>
</div>
</div>
</form>
</div>
<!-- jQuery (Bootstrap 的所有 JavaScript 插件都依赖 jQuery,所以必须放在前边) -->
<script src="https://fastly.jsdelivr.net/npm/jquery@1.12.4/dist/jquery.min.js"
integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ"
crossorigin="anonymous"></script>
<!-- 加载 Bootstrap 的所有 JavaScript 插件。你也可以根据需要只加载单个插件。 -->
<script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"
integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
let baseUrl = 'http://127.0.0.1:8000'
const app = new Vue({
el: '#app',
data: {
title: '',
stu: {},
channels: [{name: '百度', id: 1}, {name: '抖音', id: 2}],
btnName: ''
},
methods: {
delStudent(sid, index) {
axios.delete(baseUrl + '/crm/students/' + sid + '/' ).then(res => {
this.students.splice(index, 1)
})
},
getParams() {
let query = window.location.search.substring(1)
let vars = query.split("&");
let data = {}
for (let v of vars) {
v = v.split('=');
data[v[0]] = v[1]
}
return data
},
saveStu(sid, stu){
axios.put(baseUrl + '/crm/students/' + sid + '/' , stu).then(res=>{
location.href = 'student_list_single.html'
})
},
createStu(stu){
axios.post( baseUrl + '/crm/students/', stu).then(res=>{
location.href = 'student_list_single.html'
})
},
btnSubmit(stu){
let sid = this.getParams().id;
if (sid){
this.saveStu(sid, stu)
}else {
this.createStu(stu)
}
}
},
created() {
let sid = this.getParams().id;
if (sid) {
this.title = '学生详情';
this.btnName = '保存';
axios.get(baseUrl + '/crm/students/' + sid + '/' ).then(res => {
this.stu = res.data
})
} else {
this.title = '添加学生';
this.btnName = '添加'
}
}
})
</script>
</body>
</html>
CORS
后端服务写好后,通过postman可以正常访问,但通过浏览器时,发送ajax请求回报CORS
错误。
要理解CORS需要先了解几个概念
同源策略
同源策略是一个重要的安全策略,它是浏览器最核心最基本的安全功能。
它限制web应用程序只能从加载应用程序的同一个域请求HTTP资源。
当向不同的域请求HTTP资源时就发生了跨域,默认请情况下浏览器会阻止跨域的请求。
那如何判断是否同源呢?
如果两个URL的协议,端口和主机都相同的话,则这两个URL是同源。
例如以下所有资源都具有相同的来源:
http://example.com/
http://example.com:80/
http://example.com/path/file
每个url都有相同的协议,主机和端口号。
而以下每个资源都与其他不同源:
http://example.com/
http://example.com:8080/
http://www.example.com/
https://example.com:80/
https://example.com/
http://example.org/
http://ietf.org/
所以所谓的同源策略简单的理解就是,打开某个页面后,这个页面上的ajax请求默认只能向和页面同源的url发送http请求。
同源策略固然保证了安全,但同时也限制了应用的灵活性,所以出现了CORS.
什么是CORS
CORS是一个W3C标准,全称是"跨域资源共享"(cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出XMLHttpRequest
请求,从而克服了AJAX只能同源使用的限制。
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能。
整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于前端开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。
因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。
CORS原理
跨域请求
浏览器将跨域请求分为两类:简单请求和非简单请求。
只要同时满足一下两个条件,就属于简单请求:
- 请求方法是一下三种方法之一:
- head
- get
- post
- http请求的头信息不超出以下几种字段:
- accept
- accept-language
- content-language
- Last-Event-ID
- Content-Type的值仅限于下列三者之一:
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
凡是不同时满足上面两个条件,就属于非简单请求。
浏览器对这两种请求的处理是不一样的。
简单请求CORS步骤
对于简单请求CORS的基本流程如下:
第一步:客户端(浏览器)请求
当浏览器发出跨域请求时,该浏览器会添加一个包含当前源(协议,主机和端口)的Origin
头。
第二步:服务器响应
在服务器,当服务器看到Origin
头并想要允许访问时,就需要在响应中加入一个Access-Control-Allow-Origin
响应头来指定请求源(例如加入*
表示允许任何源)
第三步:浏览器接受响应
当浏览器看到带有相应Access-Control-Allow-Origin
响应头的响应时,即允许与客户端网站共享响应数据。否则抛出CORS异常。
注意同源策略只是浏览器遵守的规则,使用别的工具进行请求不会遵循同源策略的影响。
复杂请求CORS步骤
第一步:发送预检请求
浏览器会根据需要创建预检请求。该请求是一个options
请求,会在实际请求消息之前被发送。
预检请求中关键请求头是origin
表示请求来自哪个源。除了origin
字段,预检请求头的信息还包含两个特殊字段。
access-control-request-method
该字段是必须的,用来列出接下来的CORS请求会用到哪些HTTP方法,上面图片中的是PATCH
access-control-request-headers
这个字段是一个逗号分隔的字符串,指定接下来的CORS请求还会携带哪些额外的字段,上面图片中的是content-type
第二步:响应预检请求
服务器收到预检请求后,检查origin
,access-control-request-method
,access-control-request-headers
字段后,就可以返回响应。
响应中的access-control-allow-origin
字段表示允许跨域的源,*表示允许任意跨域请求。其他CORS相关响应头如下:
Access-Control-Allow-Methods
逗号分隔的一个字符串,表明服务器允许的跨域请求方法
Access-Control-Allow-Headers
逗号分隔的一个字符串,表明服务器支持的头字段
Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期,单位为秒。上图中的有效期是一天(86400秒),在此期间不用发出另一条预检请求。
注意:如果服务器否定了预检请求,也会返回一个正常的HTTP响应,但是不包含任何CORS相关的响应头。
第三步:发送跨域请求
一旦服务器通过了预检请求,以后每次浏览器正常的CORS请求,就跟简单请求一样,会有一个origin
头字段。服务器的回应,也会有一个access-control-allow-origin
头信息字段。
cookie跨域
出于隐私原因,CORS请求默认不带cookie。如果想要在使用CORS时发送cookie,就需要发送请求时携带cookie并且服务器也同意。
请求
ajax请求需要打开withCredentials
属性才可以携带cookie:
const request = axios.create({
baseURL: 'http://127.0.0.1:8000',
timeout: 5000,
withCredentials: true // 设置为true 跨域时会携带cookie
})
响应
如果要接受cookie跨域,access-control-allow-origin
就不能设置为星号,必须指定明确,并且响应头中必须包含字段Access-Control-Allow-Credentials
,值为true
。
同时cookie依然遵循同源策略,只有服务器指明的域名的cookie才会上传。
django-cors-headers
在django项目中要实现CORS可以手写(重复造轮子),也可以使用成熟插件(推荐)。
django-cors-headers是一个处理跨域资源共享(CORS)所需服务器器头信息的django应用。
它的使用非常简单:
安装
pip install django-cors-headers
添加到apps
INSTALLED_APPS = [
...,
"corsheaders",
...,
]
设置中间件
Django-cors-headers
是通过中间件实现cors头设置的,所以需要设置对应的中间件
MIDDLEWARE = [
...,
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
...,
]
CorsMiddleware应该放在尽可能高的位置,特别是在任何可以生成响应的中间件之前,比如django的CommonMiddleware
。否则无法将CORS头添加到这些响应中。
配置
要使用CORS,还需要在settings.py
模块中添加如下配置:
# CORS设置
# 允许跨域的域名列表
CORS_ALLOWED_ORIGINS = [
'http://localhost:8080'
]
CORS_ALLOW_ALL_ORIGINS = True # 表示y
# 允许cookies跨域
CORS_ALLOW_CREDENTIALS = True