前端代码有许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. 参考文献