跳到主要内容

前端代码有许Django 开发实战 (上)

学完了 Django 的基础知识,我们来动手实践一个简单的小项目:Django 视频网站。这部分内容将分为两个小节来完成,第一部分是需求设计与测试、视频的分片上传功能;第二部分完成权限管理、页面的整理以及最后的网站部署和上线。

1. 需求整理以及表设计

我们现在要做这样一个视频网站项目,现在将这个小项目拆分成如下几个简单的模块组合:

  • 登录以及认证模块:这个部分我们在之前已经多次实践过,比较容易实现;
  • 视频权限管理模块:这里我们简单分为两个组 (Group):用户组和管理组。用户组中的成员可以上传视频,上传的视频有2中分类:共享视频私密视频。共享视频可以被网站的所有人看到,私密视频只能上传的用户和管理员看到;另外,管理组的成员也就是管理员们除了可以上传视频外,还可以对全站所有的视频进行删除、下线 (除了管理员,所有人都看不到) 处理。这个自然而然想到的是用 django-guardian 框架来实现;
  • 大型文件上传:对于大型文件 (超过200M),我们采用分片上传的方式来实现,这个也是比较容易实现的;
  • 视频存储与显示访问: 正常情况下我们需要提供一个视频存储服务,比如使用 Ceph 搭建一个存储集群。目前条件限制,只将视频存到服务器上的某个目录下并用 Nginx 作为静态资源代理来访问。

完成上述模块后,我们的基本的视频网站就算完成了。当然页面的展示效果很关键,后续需要花大量时间去美化我们的页面,这样才能让人有兴致浏览我们的网站。

目前需要我们设计的表只有一个,就是记录上传的视频。我们设计 video 表的字段如下:

  • name::视频名称;
  • label:视频介绍;
  • size:视频大小 (单位字节);
  • path:上传路径 (相对路径),我们会设置一个存放视频的根目录。这个根目录和 path 的结合就是上传视频的绝对路径;
  • image_name: 封面图名;
  • user_id:视频的所有者;
  • type:视频类型 (0-共享|1-私密)

接着我们使用 startprojectstartapp 命令创建我们的工程和应用,这里只简单创建一个 videos 应用:

(django-manual) [root@server django-manual]# django-admin startproject video_website
(django-manual) [root@server django-manual]# cd video_website
(django-manual) [root@server django-manual]# django-admin startapp videos

新建这样一个上传视频的模型表 Video:

# 代码位置:videos/models.py
from django.db import models
from django.contrib.auth.models import User

# Create your models here.
class Video(models.Model):
type_choices = (
(0, '公开分享'),
(1, '私密'),
)

name = models.CharField('视频名称', max_length=100)
label = models.TextField('视频简介', max_length=250, default='暂无简介')
size = models.IntegerField('视频大小,单位字节', default=0)
path = models.FilePathField('视频保存路径', path="/root/test/video\_website/")
image_name = models.CharField('封面图片名称', max_length=200, default="default.jpg")
author = models.ForeignKey(User, on_delete=models.CASCADE)
shared_type = models.SmallIntegerField('视频类别,0:公开分享,1:私密', choices=type_choices, default=0)
created_at = models.DateTimeField(auto_now_add=True)

def \_\_str\_\_(self):
return self.name

class Meta:
db_table = 'video'
default_permissions = ()
permissions = (
('view\_video', '查看视频'),
('upload\_video', '上传视频'),
('edit\_video', '编辑视频'),
('delete\_video', '删除视频'),
)


接下来我们设置好项目的 settings.py 文件,和前面类似,只不过 INSTALLED_APPS 的值需要根据应用进行调整。最后我们便可以用 makemigrationsmigrate 命令生成相应的表和数据了:

(django-manual) [root@server video_website]# python manage.py makemigrations
...
(django-manual) [root@server video_website]# python manage.py migrate
...

2. 登录以及认证

首先我们使用 createsuperuser 命令创建一个超级用户

(django-manual) [root@server video_website]# python manage.py createsuperuser
...

接下来我们在 Django 的 shell 模式下创建2个普通用户:member1 和 member2。

(django-manual) [root@server video_website]# python manage.py shell
Python 3.8.1 (default, Dec 24 2019, 17:04:00)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-39)] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from django.contrib.auth.models import User
>>> m1 = User(username='member1', email='1035334375@qq.com')
>>> m1.set_password('123456')
>>> m1.save()
>>> m2 = User(username='member2', email='2894577759@qq.com')
>>> m2.set_password('test123')
>>> m2.save()

接下来的代码和之前类似,不过位置上做了一些调整。首先登录页面 login.html 不变:

{# 代码位置:template/login.html #}
{% load staticfiles %}
<link rel="stylesheet" type="text/css" href="{% static 'css/main.css' %}" />
<form action="/login/" method="POST">
{% csrf\_token %}
<div><span>{{ form.name.label }}</span>{{ form.name }}
<div><span>{{ form.password.label }}</span>{{ form.password }}
<div><input class="input-text input-red" type="submit" value="登录" style="width: 214px"/></div>
{% if err\_msg %}
<div><label class="color-red">{{ err\_msg }}</label</div>
{% endif %}
</form>

接着简单弄个首页,展示用户信息以及上传视频的入口,由于没有用 bootstrap 调整下样式,所以看起来会有点丑:

{# 代码位置:template/home.html #}
{% load staticfiles %}
<link rel="stylesheet" type="text/css" href="{% static 'css/main.css' %}" />
<h1>主页</h1>

<p>您好:{{ username }}
<a href="/videos/upload/" style="margin-left:30px">上传视频</a>
<a href="/logout/" style="margin-left:30px">退出</a>
</p>

上面这些请求的 URL 地址是提前规划好的,后面也会介绍到。登录功能现在移动到 video_website 目录下,不妨到应用目录下,视图层代码如下:

# 代码位置:video\_website/views.py
from django.shortcuts import render, redirect
from django.views.generic import View
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import authenticate

from videos.models import Video
from utils.constants import HOME_URL, LOGIN_URL, SESSION_EXPIRED_SECONDS
from .forms import LoginForm

def home\_page(request, \*args, \*\*kwargs):
if request.session.get('has\_login', False):
logined_user = User.objects.all().get(id=int(request.session['user\_id']))
request.user = logined_user
videos = Video.objects.all()
return render(request, "home.html", {"username": logined_user.username, "videos": videos})
return redirect(LOGIN_URL)

class LoginView(View):
"""
登录相关
"""
def get(self, request, \*args, \*\*kwargs):
success = False
form = LoginForm()
if request.session.get('has\_login', False):
return redirect(HOME_URL)
return render(request, "login.html", {'form': form})

def post(self, request, \*args, \*\*kwargs):
form = LoginForm(request.POST)
err_msg = ""
if form.is_valid():
login_data = form.clean()
name = login_data['name']
password = login_data['password']
user = authenticate(username=name, password=password)
if not user:
success = False
err_msg = "用户名密码不正确"
else:
request.user = user
request.session['has\_login'] = True
request.session['user\_id'] = user.id
# 设置session过期时间
request.session.set_expiry(SESSION_EXPIRED_SECONDS)
return redirect(HOME_URL)
else:
err_msg = "提交表单不正确"
return render(request, 'login.html', {'err\_msg': err_msg, 'form': form})

def logout(request, \*args, \*\*kwargs):
"""
登出操作,清除session,重定向到登录页面
"""
if 'has\_login' in request.session:
del request.session["has\_login"]
if 'user\_id' in request.session:
del request.session["user\_id"]
request.session.flush()
return redirect(LOGIN_URL)


我们会固定一些常用的变量放到某个 python 文件中,这样子方便调整和修改:

# 代码位置:utils/constants.py

"""
常量相关
"""
#########################################
LOGIN_URL="/login/"
HOME_URL="/home/"

########################################
SESSION_EXPIRED_SECONDS = 600

########################################
UPLOAD_BASE_DIR="/root/test/video\_website/"

form 表单内容和之前一样,这里就不再贴出来了,直接来看运行的效果:

插入视频 35-1

3. 视频分片上传

视频分片上传这个会稍微有点复杂,我们页尽量简单做一下,尽量不考虑异常情况,细节等后面大家自己慢慢优化。对于大文件上传,往往采用的方式是将大文件切片,然后分片上传,最后全部分片上传完毕后发送合并请求,将服务器上的分片文件合成最终的文件。这个需求需要前后端一同配合操作,前端有许多线程的组件供我们使用,由于我们用的是纯 html/css/js 开发前端页面,所以直接用 Baidu WebFE(FEX) 团队开发的 WebUploader 来帮助我们完成前端的分片上传工作。

对于 Django 的后端上传视频的思路如下:

  • 首先确定好一个固定上传根目录 UPLOAD_BASE_DIR (如/root/test/video_website);
  • 上传的分片会按照如下命名方式保存到临时目录 (${UPLOAD_BASE_DIR}/tmpfiles/) 下:
文件名-块编号-总块数

  • 如果是共享文件保存到共享目录 (KaTeX parse error: Expected 'EOF', got '下' at position 28: …_DIR}/shared/) 下̲,私密文件保存到个人的目录 ({UPLOAD_BASE_DIR}/用户名/) 下

视频上传的代码主要在 videos 应用下,先看视图代码,如下:

# 代码位置:videos/views.py

import os
import shutil

from django.shortcuts import render, redirect
from django.views.generic import View
from django.views.decorators.http import require_http_methods
from django.http.response import JsonResponse
from django.contrib.auth.models import User

from videos.models import Video
from utils.constants import LOGIN_URL, UPLOAD_BASE_DIR

TMP_DIR = os.path.join(UPLOAD_BASE_DIR, "tmpfiles")
SHARED_DIR = os.path.join(UPLOAD_BASE_DIR, "shared")
if not os.path.exists(TMP_DIR):
os.makedirs(TMP_DIR)

if not os.path.exists(SHARED_DIR):
os.makedirs(SHARED_DIR)

"""
将部分操作加上装饰器,需要登录才能进行操作
"""

class VideoView(View):
"""
视频管理
"""
def get(self, request, \*args, \*\*kwargs):
pass

def post(self, request, \*args, \*\*kwargs):
"""
新增上传视频
"""
success = True
err_msg = ''

name = request.POST.get('name', '')
label = request.POST.get('label', '')
size = int(request.POST.get('size', '0'))
is_private = request.POST.get('is\_private', 'false')
shared_type = 0 if is_private != 'true' else 1
logined_user = None
if request.session.get('has\_login', False):
logined_user = User.objects.all().get(id=int(request.session['user\_id']))
if not logined_user or not isinstance(logined_user, User):
return JsonResponse({'success': False, 'err\_msg': 'please login in first!'})
print('登录用户:{}'.format(logined_user.username))

if not name:
return JsonResponse({'success': False, 'err\_msg': 'name is empty!'})

file_path = os.path.join(UPLOAD_BASE_DIR, name)
if not os.path.exists(file_path):
return JsonResponse({'success': False, 'err\_msg': '{} not upload succeeded!'.format(name)})

# 共享视频放到 share 目录下,其余放到各自用户下
old_path = os.path.join(UPLOAD_BASE_DIR, name)
if not shared_type:
new_dir = SHARED_DIR
path = "/shared"
else:
# 私密视频,放到个人目录下
username = logined_user.username
new_dir = os.path.join(UPLOAD_BASE_DIR, username)
path = "/{}".format(username)
if not os.path.isdir(new_dir):
os.makedirs(new_dir)
print('移动文件{}到目录{}下'.format(old_path, new_dir))
shutil.move(old_path, new_dir)

video_upload = Video(name=name, label=label, size=size, shared_type=shared_type, path=path)
video_upload.author = logined_user
try:
video_upload.save()
except Exception as e:
success = False
err_msg = 'error: {}'.format(str(e))
return JsonResponse({'success': success, 'err\_msg': err_msg})

def put(self, request, \*args, \*\*kwargs):
pass

def delete(self, request, \*args, \*\*kwargs):
pass

def video\_upload(request, \*args, \*\*kwargs):
"""
分片上传视频
"""
if request.method == 'POST':
# 异常考虑
name = request.POST.get("name")
chunk_id = request.POST.get("chunk", "0")
chunks = request.POST.get("chunks", "0")
file_name = "%s-%s-%s" % (name, chunk_id, chunks)
video_file = request.FILES.get("file")
with open(os.path.join(TMP_DIR, file_name), 'wb') as f:
for chunk in video_file.chunks():
f.write(chunk)
return JsonResponse({'upload\_part': True})
return render(request, "video\_upload.html", {})


@require_http_methods(["POST"])
def merge\_chunks(request, \*args, \*\*kwargs):
"""
合并上传视频
"""
file_name = request.POST.get("name")
chunks = int(request.POST.get("chunks", "0"))
# 完成的文件的地址为
path = os.path.join(UPLOAD_BASE_DIR, file_name)
with open(path, 'wb') as fp:
for chunk in range(chunks):
try:
name = os.path.join(TMP_DIR, '{}-{}-{}'.format(file_name, chunk, chunks))
with open(name, 'rb') as f:
fp.write(f.read())
# 当图片写入完成后,分片就没有意义了,删除
os.remove(name)
except Exception as e:
print('异常:{}'.format(str(e)))
break

return JsonResponse({'merge':True, 'file\_name': file_name})

代码的逻辑是比较清楚的,主要的完成了如下几个功能:

  • 分片视频上传 (video_upload);
  • 合并分片视频 (merge_chunks);
  • 上传视频信息入库 (VideoView.post);

接着是 URLConf 的配置,代码如下:

# 代码位置:videos/urls.py
from django.urls import path
from videos import views

urlpatterns = [
# 视频的管理
path('op/', views.VideoView.as_view(), name="video\_operation"),
# 视频上传
path('upload/', views.video_upload, name="upload"),
path('video\_merge/', views.merge_chunks, name='merge\_chunks'),
]


最后,看下我们使用 WebUploader 和 Bootstrap 功能完成的一个分片上传页面,内容稍多,需要耐心阅读。首先要先完成视频上传,然后才是添加视频的描述信息并提交。

{# 代码位置:template/video\_upload.html #}
{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>webuploader上传</title>
<link rel="stylesheet" type="text/css" href="{% static 'css/main.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/webuploader.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/bootstrap.min.css' %}">
<script type="text/javascript" src="{% static 'js/jquery-3.5.0.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js/webuploader.min.js' %}"></script>
</head>
<body>
<div class="row">
<div class="col-md-6">
<form class="form-horizontal upload-video-container" class="col-sm-6">
{% csrf\_token %}
<div class="form-group">
<label class="col-sm-4 control-label">视频名称</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="video-name" placeholder="视频名称" name="video\_name">
</div>
</div>
<div class="form-group">
<label class="col-sm-4 control-label">视频简介</label>
<div class="col-sm-8">
<textarea class="form-control" rows="5" name="video\_label"></textarea>
</div>
</div>
<div class="form-group">
<label for="inputPassword3" class="col-sm-4 control-label">上传视频</label>
<div class="col-sm-8">
<div id="picker">点击这里选择视频</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-4 col-sm-8">
<div class="checkbox">
<label style="font-size:14px">
<input type="checkbox" name="is\_private"> 设为私密
</label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-4 col-sm-8">
<button id="form-submit" class="btn btn-primary" type = "button">提交</button>
</div>
</div>
</form>
</div>

<div id="uploader" class="col-md-5 upload-video-container">
<!--用来存放文件信息-->
<div id="thelist" class="row">
<div class="panel panel-primary">
<div class="panel-heading">视频文件上传</div>
<table class="table table-striped table-bordered" id="uploadTable">
<thead style="text-align: center;">
<tr>
<th>文件名称</th>
<th>文件大小</th>
<th>上传进度</th>
<th style="width:15%;">状态</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class="panel-footer">
<button id="upload-btn" class="btn btn-primary">开始上传</button>
</div>
</div>
</div>

</div>
</div>
</body>
<script type="text/javascript">
success = false
current\_upload\_file = ''

$('#form-submit').on('click', function(){
if (current\_upload\_file !== null && current\_upload\_file !== undefined && current\_upload\_file !== '' && success){
csrf\_token = $("input[name='csrfmiddlewaretoken']").val()
name = $("input[name='video\_name']").val()
label = $("textarea").val()
is\_private = $("input[name='is\_private']").is(':checked')
$.ajax({
type: "POST",
url: "{% url 'video\_operation'%}",
data: {
csrfmiddlewaretoken: csrf\_token,
name: name,
label: label,
size: current\_upload\_file.size,
is\_private: is\_private
},
success : function(response) {
console.log(response)
if (response.success) {
alert('提交视频记录完成')
} else {
alert(response.err\_msg)
}
}
});
} else {
alert('请先上传完成文件')
return 0
}
})

function formatSizeUnits(bytes){
if (bytes >= 1073741824) { bytes = (bytes / 1073741824).toFixed(2) + " GB"; }
else if (bytes >= 1048576) { bytes = (bytes / 1048576).toFixed(2) + " MB"; }
else if (bytes >= 1024) { bytes = (bytes / 1024).toFixed(2) + " KB"; }
else if (bytes > 1) { bytes = bytes + " bytes"; }
else if (bytes == 1) { bytes = bytes + " byte"; }
else { bytes = "0 bytes"; }
return bytes;
}
var uploader = WebUploader.create({
// swf文件路径
swf : 'https://cdnjs.cloudflare.com/ajax/libs/webuploader/0.1.1/Uploader.swf',
// 文件接收服务端。
server : "{% url 'upload' %}",
// 选择文件的按钮。可选。
// 内部根据当前运行是创建,可能是input元素,也可能是flash.
pick : {
id : '#picker',//这个id是你要点击上传文件的id
multiple : false
},
// 不压缩image, 默认如果是jpeg,文件上传前会压缩一把再上传!
resize : true,
auto : false,
//开启分片上传
chunked : true,
chunkSize : 10 \* 1024 \* 1024,
accept : {
extensions : "flv,mp4",
mimeTypes : '.flv,.mp4'
}
});

uploader.on('fileQueued', function(file) {
current\_upload\_file = file
// 选中文件时要做的事情,比如在页面中显示选中的文件并添加到文件列表,获取文件的大小,文件类型等
name = file.name
size = file.size
$('#video-name').val(name)
file\_upload\_html = "<tr><td>" + name + "</td><td>" + formatSizeUnits(size) + "</td><td>0%</td><td><a>准备上传</a></td>"
$('#uploader table tbody').html(file\_upload\_html)
$("#upload-btn").removeAttr("disabled")
});

uploader.on('uploadBeforeSend',function (object, data, header){
data['csrfmiddlewaretoken'] = $("input[name='csrfmiddlewaretoken']").val()
});

// 文件上传过程中创建进度条实时显示。
uploader.on('uploadProgress', function(file, percentage) {
$('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(3)").text('上传中')
$('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(2)").text((percentage \* 100).toFixed(2) + '%')
});
uploader.on('uploadSuccess', function(file) {
console.log('上传成功')
});
uploader.on('uploadError', function(file) {
$('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(2)").text('上传失败')
});
uploader.on('uploadComplete', function(file) {
$('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(3)").text('合并文件中...')
csrf\_token = $("input[name='csrfmiddlewaretoken']").val()
$.ajax({
type: "POST",
url: "{% url 'merge\_chunks'%}",
data: {
csrfmiddlewaretoken: csrf\_token,
name: file.name,
chunks: parseInt((file.size + uploader.options.chunkSize - 1) / uploader.options.chunkSize)
},
success : function(response) {
success = true
uploader.removeFile(file);
$('#thelist').find('tbody').find('tr:eq(0)').find("td:eq(3)").text('上传完成')
$("#upload-btn").attr("disabled", "disabled")
}
});
});
uploader.on('all', function(type) {
console.log('all, type=' + type)
});

$('#upload-btn').on('click', function(){
uploader.upload();
});

</script>
</html>

注意:这里的前端代码有许多细节没有考虑,比如错误情况,以及实现暂停上传和查询已上传分片等功能,后续读者可以自行优化。

这里的前端代码参考了官方文档和一些 CSDN 博客介绍,用比较简单的方式去完成这个分片上传。主要是上传组件监听的事件以及 jquery 的使用。这里细节不在深究,我们直接看演示的效果。

插入视频 35-2

4. 小结

本小节中,我们完成了一个视频网站的最基本的部分,重点在于视频的分片上传功能。接下来我们会完成权限的管理功能以及最后的视频网站部署和上线。

5. 参考文献

1、webuploader与django进行断点续传