【腾讯课堂】视频点播上云实践

4,316 阅读13分钟

本文作者: IMWeb团队 原文链接

总体介绍

腾讯课堂是一款通过线上的直播与点播向用户提供在线教育服务的产品,从 2014 年成立至今,已累计存储了 250 万个视频,共 600 TB,累计时长 150 万小时。之前一直采用的是腾讯视频的方案,但使用的是 MP4 格式,用户拿到了播放链接之后很容易盗版,所以趁着上云的潮流,我们将视频点播迁移到了腾讯云 - 云点播上,本文主要会讲一讲我们整体的方案、Web 接入的方法和遇到的一些问题

视频点播分为视频上传和视频播放两个部分,下面的表格整理了上云前后的部分数据对比:

腾讯视频 腾讯云
Web 视频上传成功率 92% 99.5%
视频转码速度(两小时左右的视频) > 60 分钟 < 20 分钟
播放成功率 - PC 99% 98.7%
播放成功率 - H5 97% 97.1%

可以看出来上传成功率和视频转码速度有了极大的提升,PC 和 H5 侧的播放成功率云和腾讯视频基本持平。

整体方案

考虑到存量视频较多,没法短时间内全部从腾讯视频迁移至腾讯云,同时迁移过程中用户可能继续使用老的方式向腾讯视频上传,所以整个点播上云分为两期进行:

  1. 第一期主要工作是接入腾讯云的上传、转码和播放功能,确保用户新上传的视频均走云的流程,同时后台将新上传的视频旁路一份到腾讯视频,这样既可以在用户播放云视频失败时前端降级至腾讯视频播放,也方便出现重大问题时快速切回至老的腾讯视频方案。
  2. 第二期工作则是将存量的腾讯视频全部迁移至腾讯云上,同时接入云的 AI 功能,进行鉴黄、鉴暴和鉴政。待现网数据稳定且达到预期后,即可彻底摒弃老的方案。

就是干

视频上传流程

录播上传流程

视频上传整体方案如上图所示,主要涉及三块:

  1. 向业务后台获取签名
  2. 调用云SDK 进行视频上传
  3. 云服务器进行视频转码

上面三块中最重要也最容易出问题的是"调用 SDK 上传"这一部分,直接决定了上传成功率,但也很容易受用户网络状况的影响,需要重点关注,建议记录详细的用户日志以便进行问题定位与排查。

另外,其实上述流程图与腾讯云文档给出的客户端上传指引略微有点差别,主要在于第 4 步通知业务后台上传完成这里,官方文档中是云后台来通知,我们实际采用的方式是 Web 侧来通知,从而避免出现 Web 侧调后台接口出错提示用户上传失败后,云后台又通知业务后台保存相关数据的情况。

视频播放流程

在以前使用腾讯视频的方案时,出于种种考虑,我们并未对视频做加密处理,导致有些课程被他人恶意盗录。目前上云之后,我们使用的是加密 HLS 的方案,通过云提供的 Key 防盗链DRM(数字版权管理)方案,我们对视频做了加密处理,就算被拿到了视频地址,也无法进行盗录,进一步打击了恶意行为,保护了老师的版权。

视频播放流程

用户浏览器在播放视频时主要流程如上图所示,其中依靠第 1 步获取 Token 和第 3 步获取 DK 进行版权的保护,他们的作用分别为:

  • Token 用于防盗链,可以 限制视频 URL 的过期时间、最大允许播放 IP 数等,具体的计算方法和验证逻辑由业务方自定义。
  • DK 用于对视频的加密切片进行解密,用户直接获取到的视频分片均通过 AES-128 进行了加密,其值由腾讯云密钥管理服务(KMS)提供。

Web 接入的流程

视频上传

接入方法

视频上传主要依赖云提供的 vod-js-sdk-v6,用 TypeScript 编写,具有较为完善的的测试用例,代码质量很高 👍 其底层依赖的是 cos-js-sdk-v5,也是由腾讯云提供的对象存储能力。

接入 SDK 的方法很简单,只涉及两方面:

  1. 传入获取签名的函数来初始化 SDK,SDK 会在需要时自动调用。目前来看,SDK 会在上传前、上传中以及上传成功后各获取一次签名。
  2. 调用 SDK 的 upload 函数上传视频。
import TCVod from 'vod-js-sdk-v6';

// 用签名函数触发
const uploader = new TCVod({
  getSignature,
});

// 向业务后台获取签名
function getSignature() {
  return fetch('FAKE_CGI_URL').then((result) => {
    return result.sign;
  })
}

// 调用 SDK 上传
function uploadVideo(videoFile) {
  const upVideo = uploader.upload({ videoFile });
  upVideo.on('video_progress', (info) => {
    // 此处获取上传进度
    // 例如上传百分比、上传速度等
  });

  upVideo.done().then((result) => {
    // 此处获取上传结果
    // 例如 fileId、CDN 源文件地址等
  }).catch((error) => {
    // 上传失败
  });
}

uploadVideo(fileA);
uploadVideo(fileB);

so easy

虽然上传的 SDK 用起来很简单,但在我们灰度的过程中,还是遇到了一些问题,因而强烈建议在代码中加入详细的上报日志,例如上面的 DEMO 中可以加入的日志信息包括:获取签名的开始、成功与失败,文件上传的开始、成功与失败等。

遇到的问题

1. 默认只开启了重庆存储区

上线后我们发现视频上传的链接均是 xxx.cos.ap-chongqing.myqcloud.com 的形式,这看起来不太对呀,怎么都往 chongqing(重庆区)上传了呢?难道不支持就近上传的能力吗?后来我们联系云的同事得知,由于视频云的底层依赖的是腾讯云的对象存储(COS),所以具体往哪传,怎么传比较快是由 COS 保证的,需要在云控制台开启相关配置。

COS 存储区选择

2. SDK 上传部分报错

上传初期进行灰度时发现上传成功率为 97%,距离预期的 99% 还存在一定距离,通过双方的合作排查,最终发现主要是由两个问题引起的:

  • 用户本地时间与服务器时间不一致时,依赖的 cos-js-sdk-v5 鉴权报错,导致出现 403;
  • 用户网络抖动时,云视频的 vod-js-sdk-v6 对签名的处理存在问题,导致出现 403。

目前在最新版的 vod-js-sdk-v6 中上述问题均已解决,上传成功率在全量后也在 99.5% 以上。

PC & H5 视频播放

前面已经简单提过了视频播放流程,我们这里再来详细说明一下。

流程简介

点播播放其实很简单,简单来说就是下面这个流程:

播放1

第一步: 获取m3u8地址

第二步:调用播放器播放

就是这么简单。

这时候我们发现一个问题,有了m3u8地址,所有人都能播放了。这个m3u8地址可以肆无忌惮的传播,任何人拿到链接都可以播放,就没有付费课的概念了。于是我们开始引入前面提到的第一个技术,我们称之为Key 防盗链 。防盗链参数是动态变化的,引入之后我们的流程就变成了:

播放2

加了防盗链之后,缺少防盗链参数的链接就没法播放了。就算带防盗链参数的m3u8地址传播出去,因为有时效性,这个链接过一阵子也会失效。

这时候,聪明的小伙伴应该又发现了另外一个问题,假设在防盗链参数失效之前把m3u8文件下载下来,一样是可以拿来传播的。

要解决这个问题,我们可以简单来看下m3u8的格式。

m3u8
m3u8

简单的说,m3u8是一个遵循某种格式的文本文件,里面是一些TS分片的索引,通过这些索引就可以找到所有的视频分片。

回到我们加密的主题,如果是每一个TS分片做加密,是不是就算把m3u8下载下来,也没法播放了呢?HLS 的普通 AES 加密技术正是这样做的。引入了HLS普通加密之后,整个流程就变成了这样:

播放3

为了简单起见,我们忽略了COS CDN 这一块的图示。解释一下上图:

首先是加密,要加密就要要密钥。这时候就引入了KMS,我们暂时不关心KMS内部实现,简单认为做了就是提供密钥的工作。腾讯云收到了业务后台发起的视频加密请求之后,就会从KMS 获取对应的加密密钥,对文件进行加密处理。这就是上图蓝色字的部分。

然后是解密,业务前端在拿到m3u8的内容的时候,发现需要解密TS的,所以需要解密密钥,于是就会请求业务后台去获得解密密钥。业务后台怎么认为请求是合法的呢?当然是要有用户的身份信息(cookie)。腾讯云提供了两种方式,具体可以看HLS 普通加密 。上图示例即是第一种方案,用例子来解释一下。我们看一个 m3u8 地址示例:

https://1258712167.vod2.myqcloud.com/fb8e6c92vodtranscq1258712167/c896adc25285890789334843878/drm/voddrm.token.dWluPTt2b2RfdHlwZT0yO2NpZD00MDY4NDQ7dGVybV9pZD0xMDA0ODUxNzc7cHNrZXk9O2V4dD0=.v.f3071.m3u8?t=5d2f1647&exper=0&us=7776585111527298975&sign=195ed8bcbc08bb5e40f4823c49e71696

这里的dWluPTt2b2RfdHlwZT0yO2NpZD00MDY4NDQ7dGVybV9pZD0xMDA0ODUxNzc7cHNrZXk9O2V4dD0=即是需要带给业务后台的鉴权token。再看看这个文件的内容:

m3u8

m3u8格式里用EXT-X-KEY 值用于解密,上图的cgi-bin/qcloud/get_dk即是我们图示里的第 5 步,携带身份信息,向业务后台获取解密密钥。获得解密密钥之后,就可以对TS文件解密并且播放啦~

代码实现

了解了流程之后,代码其实就很简单了。

首先:获取 m3u8 地址,并拼接上 token

async getM3U8List(fileId: string) {
  const { termId, onError } = this.props;
  try {
    // 获取防盗链参数,对应流程图里第2步
    const urlParams = await getUrlToken({
      termId,
      fileId,
    });
    // 获取 m3u8 地址,对应流程图里第3步
    const videoInfo = await getPlayInfo(fileId, urlParams);
    // 获取拼接了 token 之后的 m3u8 地址
    const m3u8List = getPlayListWithToken(videoInfo, {
      termId,
    });

    return m3u8List;
  } catch (e) {
    onError(e);
  }
}

其次,调用播放器,这里可以参考超级播放器 或者 tcplayerlite。文档比较详细,这里就不赘述了。我们播放完整流程图里的第 4 步则是由播放器发起的,第 5 步由浏览器自己发起的。

播放质量监控

关于监控,播放目前是使用内部 monitor + tdw + badjs 上报做监控的。

monitor用于告警和数据累积量的查看。

tdw用于报表、日报、周报的生成。

badjs则用于出现了播放失败等情况时的排查。

小程序视频播放

小程序端有两个问题需要解决:

  1. 腾讯云并没有提供可用的云播放组件供前端使用,所以需要我们自己封装一个组件,提供云视频播放能力;
  2. 小程序没有cookie,而且m3u8文件获取解密密钥的方法是由video自动完成的,代码无法控制,所以小程序端只能采用QueryString 传递身份认证信息的方案去鉴权;

我们先来看一下小程序组件腾讯云视频播放的一个基本流程:

weapp-process

  • 课堂这边是开启了防盗链和HLS加密的,所以上述的判断流程都走绿色的路径;
  • tokenObj 是防盗链的token,里面包括: 播放地址的过期时间戳、试看时长、链接标识、防盗链签名。参考Key 防盗链;
  • drmToken 是m3u8获取解密密钥需要用到的鉴权token,具体规则由前后端在业务层约定加密规则。参考QueryString 传递身份认证信息
  • <cloud-player-video /> 组件内部的播放还是用的小程序的 <video /> 组件,只是提供了通过参数获取真正播放地址的功能;
  • 目前 <cloud-player-video /\> 是我们自己研发的组件,还在持续迭代优化中,后续会加入倍速切换,清晰度切换等播放器常用功能;
  1. 小程序端通过业务的cgi拿到对应的fileId,然后通过getCloudUrlToken的接口获取对应的 tokenObj
  2. 通过登录接口获取的内容经过加密生成 drmToken 用以解密时的鉴权;
  3. 结合对应腾讯云业务的 appid 以及获取到的 tokenObjdrmTokenfileId 这四个关键参数传递给云播放组件 <cloud-player-video />
  4. 在组件内部利用 appidtokenObjfileId 这三个参数可以到腾讯云拿到加密的m3u8地址(通过getPlayInfo),然后利用 drmToken 信息附加到原始 m3u8 地址上(通过getUrlToken);
  5. 将新的 m3u8 地址传递给小程序的video组件,获取到的 m3u8 文件内部就会将 drmToken 的信息注入到 EXT-X-KEY 字段的URI中,以 QueryString 的方式传递,最终 drmToken 将会注入到 m3u8 文件内,图片上面已经贴过,再贴一遍

m3u8

  1. video组件会自动读取这个URI去拿到解密的密钥将TS文件解密然后进行播放;

课堂小程序中获取 tokenObjdrmToken ,由于这两个参数的获取方式是业务决定的,内部流程就不赘述了,贴一下的步骤代码:

getCloudUrlToken(params)
.then(tokenObj => {
  const drmToken = getDrmToken({ term_id: termId });
  this.setData({
    fileId,
    appId: '1258712167', // pro
    drmToken,
    tokenObj,
  });
})
.catch(({ err_code, err_msg }) => {
  // 降级播放
  this.init(this.properties.playInfo, null, true);
});

然后将四个关键参数传递给组件,如下:

<cloud-player-video
  player-id="course-video-player{{r}}"
  file-id="{{fileId}}"
  app-id="{{appId}}"
  token-obj="{{tokenObj}}"
  drm-token="{{drmToken}}"
  safety
  poster="{{poster && tools.renderUrl(poster)}}"
  bindplay="onPlay"
  bindpause="onPause"
  binderror="onVideoError"
  bindended="onEnded"
  bindmedianotsup="onMediaNotSup"![](http://imweb-io-1251594266.cos.ap-guangzhou.myqcloud.com/b645c306e5a3695be09104cfdb27183a.png)
></cloud-player-video>

然后是 <cloud-player-video /> 组件内部的一些关键方法,getPlayInfo是根据 appidtokenObjfileId 获取原始 m3u8 播放地址的方法;formatUrlWithToken是为 m3u8 地址附加drmToken的方法:

// 获取视频播放地址的方法
getPlayInfo() {
  const {
    fileId,
    appId,
    safety,
    tokenObj: {
      t,
      us,
      sign,
      exper = 0,
    },
  } = this.properties;
  // 当前版本默认获取playInfo的地址
  let url = `https://playvideo.qcloud.com/getplayinfo/v2/${appId}/${fileId}`;
  // 如果开启了防盗链,将防盗链信息加到querystring里面
  if (safety) {
    url += `?t=${t}&us=${us}&sign=${sign}&exper=${exper}`;
  }

  return request({ url });
}

// 附加drmToken的方法
formatUrlWithToken(m3u8 = '', drmToken) {
  const reg = /(\/drm\/)/g;
  let tokenUrl = m3u8.replace(/http:/, 'https:');
  tokenUrl = tokenUrl.replace(reg, `$1voddrm.token.${drmToken}.`);
  return tokenUrl;
}

写在最后

虽然在上云的过程中遇到了一些问题,但都能顺利地解决,而且最后的产品数据与用户体验都比之前有了提升,希望越来越多业务能积极地拥抱云的时代!