Django 开发实战

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.pyfrom django.db import modelsfrom django.contrib.auth.models import User# Create your models here.class Video(models.Model):type_choices = ((, '公开分享'),(, '私密'),)name = models.CharField('视频名称', max_length=)label = models.TextField('视频简介', max_length=, default='暂无简介')size = models.IntegerField('视频大小,单位字节', default=)path = models.FilePathField('视频保存路径', path=/root/test/video_website/)image_name = models.CharField('封面图片名称', max_length=, default=default.jpg)author = models.ForeignKey(User, on_delete=models.CASCADE)shared_type = models.SmallIntegerField('视频类别,0:公开分享,1:私密', choices=type_choices, default=) 
    created_at = models.DateTimeField(auto_now_add=True)
 def __str__(self):return self.nameclass 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: px/></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:px>上传视频</a><a href=/logout/ style=margin-left:px>退出</a></p>

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

# 代码位置:video_website/views.pyfrom django.shortcuts import render, redirectfrom django.views.generic import Viewfrom django import formsfrom django.contrib.auth.models import Userfrom django.contrib.auth import authenticatefrom videos.models import Videofrom utils.constants import HOME_URL, LOGIN_URL, SESSION_EXPIRED_SECONDSfrom .forms import LoginFormdef 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 = Falseform = 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 = Falseerr_msg = 用户名密码不正确else:request.user = user
                request.session['has_login'] = Truerequest.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 = ########################################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.pyimport osimport shutilfrom django.shortcuts import render, redirectfrom django.views.generic import Viewfrom django.views.decorators.http import require_http_methodsfrom django.http.response import JsonResponsefrom django.contrib.auth.models import Userfrom videos.models import Videofrom 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):passdef post(self, request, *args, **kwargs):
        新增上传视频
        success = Trueerr_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 =  if is_private != 'true' else 
        logined_user = Noneif 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 = /sharedelse:# 私密视频,放到个人目录下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 = Falseerr_msg = 'error: {}'.format(str(e))return JsonResponse({'success': success, 'err_msg': err_msg})def put(self, request, *args, **kwargs):passdef delete(self, request, *args, **kwargs):passdef 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)))breakreturn JsonResponse({'merge':True, 'file_name': file_name})

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

  • 分片视频上传 (video_upload);

  • 合并分片视频 (merge_chunks);

  • 上传视频信息入库 (VideoView.post);

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

# 代码位置:videos/urls.pyfrom django.urls import pathfrom 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:px>  <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:;>状态</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 
       }
   })

   function formatSizeUnits(bytes){  if      (bytes >= ) { bytes = (bytes / ).toFixed() +  GB; }  else if (bytes >= )    { bytes = (bytes / ).toFixed() +  MB; }  else if (bytes >= )       { bytes = (bytes / ).toFixed() +  KB; }  else if (bytes > )           { bytes = bytes +  bytes; }  else if (bytes == )          { 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是你要点击上传文件的idmultiple : false }, // 不压缩image, 默认如果是jpeg,文件上传前会压缩一把再上传! resize : true, auto : false, //开启分片上传 chunked : true, chunkSize :  *  * , 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 * ).toFixed() + '%')
   });
   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 - ) / 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进行断点续传