Open edX与视频流

在Open edX的众多组件和服务中,并不包含视频流服务。不可否认的是,在线教育中,视频是要素之一,也许是最重要的要素之一,对一些人而言,甚至没有之一。

视频流一般被视为一个common server,市面上有数不清的商业或是开源解决方案,Open edX没有去重造车轮,而是和youtube做了很多整合。我们与youtube无缘。当然作为通用的组件,Open edX中的视频模块支持一般的视频资源(url),无论是云存储还是自建服务

自建视频流

如果准备自建视频流服务,可以参考@MT的在内部网络为edX配置视频服务。对于局域网内的用户(学校/企业),自建服务是个有诱惑力的方案。

不过这里边存在的坑是,视频流服务搭建不难,搭建一个友好的客户端,上传管理视频却颇为不易。在此推荐使用minio作为管理视频资源的工具,细节可以参考我的这篇文章:构建类s3存储系统(Minio)

使用云存储

视频解决方案有很多,大家可以自行google,看大家的对比评测,再结合自己的需求选型,在此就不多推荐了

我比较偏好七牛云。对开发者友好,api写得很漂亮

在此演示如何使用七牛云为open edX提供视频服务,并将客户端(js)集成其中

成果展示

思路与设计

如何集成

首先我们需要考虑一个问题,视频管理入口以什么形态集成到Open edX中合适(如何集成七牛云存储)。换个角度,Open edX有哪些拓展方式呢。毕竟我们可以把集成外部存储系统,看做一次对系统的拓展

Extending edX中,官方给出了集中常见的拓展方式。此外还有两种很典型的拓展:

  • 对django开发者而言还可以直接侵入式拓展open edx,通过添加django app或者修改增强mvt中的任何一个环节
  • 模仿insights的做法,完全构建一个新的服务(网站),之后使用oauth2来打通用户系统

因为我们希望将系统集成到open edx内部,所以决定采用添加django app的做法。 用户上传和管理视频资源需要UI界面,参考Adding a UI Page,发现侵入式地定制open edx很是繁琐,我们决定为此功能写一个独立的页面,绕开繁重的前端架构

为何不是xblock

也许许多Open edX用户会觉得为何放着xlock不用,而采用侵入性更大的django app来拓展呢。原因有二:

  • 视频管理是一个用户视角下,全局性的操作,应该有一个同意的资源管理入口,而不是每次需要先添加一个组件,再在组件里边管理视频,逻辑上,这样也能做出来。我们可以把xblock视为必须实例化(instance)为组件的东西
  • 我们不想放弃既有的视频组件(数据采集等强大功能)

技术背景

关于七牛云你需要了解的知识和上传管理的逻辑,可以参考我此前的文章:为Open edX构建存储服务

如果你想读懂接下来的源码,你需要了解django和django-restful-framework,如果只是用的话,就无所谓

just do it

后端部分

我们直接在/edx/app/edxapp/edx-platform/cms/djangoapps添加一个django appqiniu_storage,形如:

1
2
3
4
5
6
7
8
9
├── add_the_app.sh
├── ajax.js
├── __init__.py
├── models.py
├── permissions.py
├── readme.md
├── serializers.py
├── urls.py
├── views.py

我们重点介绍model和view部分,其他不赘述

models.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env python
# encoding: utf-8

from __future__ import unicode_literals
from django.db import models
#from django.contrib.auth.models import User

class QiniuFiles(models.Model):
    course_id = models.CharField(max_length=100,blank=True)
    username = models.CharField(max_length=50,blank=True) # 上传用户,资源所有者
    file_key = models.CharField(max_length=100)
    file_url = models.CharField(max_length=100,blank=True)
    file_name = models.CharField(max_length=100)
    file_size = models.CharField(max_length=20,default="0")
    #endUser = Column(String(100),nullable=True)
    create_time = models.DateTimeField(u'创建时间',auto_now=True)

    class Meta:
        ordering = ('create_time',)

views.py

只列出关键部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
qiniu_access_key = getattr(settings,  "QINIU_ACCESS_KEY", None)
class QiniuFilesViewSet(viewsets.ModelViewSet):
    authentication_classes = (TokenAuthentication, SessionAuthentication,) 
    serializer_class = QiniuFilesSerializer
    permission_classes = (permissions.IsAuthenticatedOrReadOnly,IsOwnerOrReadOnly,)

    def get_queryset(self):
        """
        This view should return a list of all the purchases
        for the currently authenticated user.
        """
        return QiniuFiles.objects.filter(username=self.request.user.username) #用户级别的管理权限,每个用户只能管理自己上传的文件
    # 删除功能暂不演示

其中的IsOwnerOrReadOnly值得关注,校验用户与资源的关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from rest_framework import permissions

class IsOwnerOrReadOnly(permissions.BasePermission):
    """
    Object-level permission to only allow owners of an object to edit it.
    Assumes the model instance has an `owner` attribute.
    """

    def has_object_permission(self, request, view, obj):

        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.username == request.user.username

前端部分

前端部分主要参考七牛的js-sdk,使用了clipboard.js用于点击事件,使用了noty用于消息提醒

代码形如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$(function() {
    var uploader = Qiniu.uploader({
        runtimes: 'html5,flash,html4',
        browse_button: 'pickfiles',//是哪个可上传的元素
        container: 'container',
        drop_element: 'container',//是否可拖动,且是哪个元素
        max_file_size: '1000mb',
       // flash_swf_url: 'bower_components/plupload/js/Moxie.swf',
        dragdrop: true,
        chunk_size: '4mb',
        //uptoken: 'xxx' //测试用
        uptoken_url: '/qiniu/uptoken',  //key由后端生成,定制化的规则包含在载荷中
        domain: 'xxx',//这是域名的绑定地址
        get_new_uptoken: false,
        unique_names: true,
        auto_start: true,
        log_level: 5,
        ...

上传流程涉及的代码

在七牛的上传原理中,上传需要凭证,我们来看看凭证的生成规则

1
2
3
4
5
6
@api_view(['GET'])
def make_uptoken(request, format=None):
            test_uptoken = QiniuTool().get_test_uptoken(request)
            #跨域的问题 Access-Control-Allow-Origin
            response = Response({"uptoken": test_uptoken})
            return response

其中Qiniu类为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class QiniuTool(object):
        '''
        #处理七牛凭证相关的工具,生成uptoken
        存储相关的部分被抽象为rest服务
        函数只接受get和post
        '''
        callback_url = 'http://studio.xxx.com/qiniu/post_from_qiniu'
        #http://developer.qiniu.com/article/kodo/kodo-developer/up/vars.html 所有的魔法变量
        #callback_body = 'filename=$(fname)&filesize=$(fsize)&key=$(key)&mimeType=$(mimeType)&endUser=$(endUser)&etag=$(etag)'
        access_key = getattr(settings,  "QINIU_ACCESS_KEY", None)
        secret_key = getattr(settings,  "QINIU_SECRET_KEY", None)
        q = Auth(access_key, secret_key) # access_key和secret_key来自settings里
        bucket_name = "easy-edx"
        def get_test_uptoken(self,request):
            callback_body = 'file_name=$(fname)&file_size=$(fsize)&file_key=$(key)&mimeType=$(mimeType)&endUser=$(endUser)&etag=$(etag)&username={}'.format(request.user.username)
            # 上传策略有许多可选的参数,方便服务于业务逻辑:参考[python-sdk](http://developer.qiniu.com/docs/v6/sdk/python-sdk.html)
            #上传文件到七牛后, 七牛将文件名和文件大小回调给业务服务器。
            policy={
                    'scope':self.bucket_name,
                    'callbackUrl':self.callback_url, #回调 请求方式为POST
                    'callbackBody':callback_body
            }
            #token = q.upload_token(bucket_name,3600,policy)
            token = self.q.upload_token(self.bucket_name,policy=policy)
            return token

视频上传好之后,七牛会可以发送一个消息给服务器,我们在此存下文件信息即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from qiniu_files.serializers import QiniuFilesSerializer

@api_view(['POST'])
def post_from_qiniu(request, format=None):
            origin_authorization = request.META.get('HTTP_AUTHORIZATION', None)
            access_key = re.split(r'\W',origin_authorization)[1]
            request.data["file_url"] = "http://media.xxx.com/"+ request.data["file_key"]
            request.data["file_size"] = request.data["file_size"]
            serializer =  QiniuFilesSerializer(data=request.data)
            if access_key == Qiniu().access_key and serializer.is_valid():
                serializer.save() #把信息存储到qiniu_storage模型里
                instance = serializer.save()
                data = file_info_format(request.data)
                #使用序列化就能存入本地
                data["id"]=instance.pk
                return Response(data)
            return Response({"success":False,"message":u"请求不合格"})

todo

还有许多细节可以改进,诸如校验用户是否有教师权限

后记

上边实际给出了open edx集成外部存储的方式,思路是通用的,不限于七牛。诸如你也可以将你自建的视频存储集成到open edx中,区别仅在抽象的存储接口(我们可以用minio构建)