【騰訊課堂】影片點播上雲實踐
- 2019 年 12 月 4 日
- 筆記
本文作者:IMWeb IMWeb團隊 原文出處:IMWeb社區 未經同意,禁止轉載
總體介紹
騰訊課堂是一款通過線上的直播與點播向用戶提供在線教育服務的產品,從 2014 年成立至今,已累計存儲了 250 萬個影片,共 600 TB,累計時長 150 萬小時。之前一直採用的是騰訊影片的方案,但使用的是 MP4 格式,用戶拿到了播放鏈接之後很容易盜版,所以趁著上雲的潮流,我們將影片點播遷移到了騰訊雲 – 雲點播上,本文主要會講一講我們整體的方案、Web 接入的方法和遇到的一些問題。
影片點播分為影片上傳和影片播放兩個部分,下面的表格整理了上雲前後的部分數據對比:
|
騰訊影片 |
騰訊雲 |
---|---|---|
Web 影片上傳成功率 |
92% |
99.5% |
影片轉碼速度(兩小時左右的影片) |
> 60 分鐘 |
< 20 分鐘 |
播放成功率 – PC |
99% |
98.7% |
播放成功率 – H5 |
97% |
97.1% |
可以看出來上傳成功率和影片轉碼速度有了極大的提升,PC 和 H5 側的播放成功率雲和騰訊影片基本持平。
整體方案
考慮到存量影片較多,沒法短時間內全部從騰訊影片遷移至騰訊雲,同時遷移過程中用戶可能繼續使用老的方式向騰訊影片上傳,所以整個點播上雲分為兩期進行:
- 第一期主要工作是接入騰訊雲的上傳、轉碼和播放功能,確保用戶新上傳的影片均走雲的流程,同時後台將新上傳的影片旁路一份到騰訊影片,這樣既可以在用戶播放雲影片失敗時前端降級至騰訊影片播放,也方便出現重大問題時快速切回至老的騰訊影片方案。
- 第二期工作則是將存量的騰訊影片全部遷移至騰訊雲上,同時接入雲的 AI 功能,進行鑒黃、鑒暴和鑒政。待現網數據穩定且達到預期後,即可徹底摒棄老的方案。
影片上傳流程

影片上傳整體方案如上圖所示,主要涉及三塊:
- 向業務後台獲取簽名
- 調用雲SDK 進行影片上傳
- 雲伺服器進行影片轉碼
上面三塊中最重要也最容易出問題的是"調用 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 的方法很簡單,只涉及兩方面:
- 傳入獲取簽名的函數來初始化 SDK,SDK 會在需要時自動調用。目前來看,SDK 會在上傳前、上傳中以及上傳成功後各獲取一次簽名。
- 調用 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);
雖然上傳的 SDK 用起來很簡單,但在我們灰度的過程中,還是遇到了一些問題,因而強烈建議在程式碼中加入詳細的上報日誌,例如上面的 DEMO 中可以加入的日誌資訊包括:獲取簽名的開始、成功與失敗,文件上傳的開始、成功與失敗等。
遇到的問題
1. 默認只開啟了重慶存儲區
上線後我們發現影片上傳的鏈接均是 xxx.cos.ap-chongqing.myqcloud.com
的形式,這看起來不太對呀,怎麼都往 chongqing(重慶區)上傳了呢?難道不支援就近上傳的能力嗎?後來我們聯繫雲的同事得知,由於影片雲的底層依賴的是騰訊雲的對象存儲(COS),所以具體往哪傳,怎麼傳比較快是由 COS 保證的,需要在雲控制台開啟相關配置。

2. SDK 上傳部分報錯
上傳初期進行灰度時發現上傳成功率為 97%,距離預期的 99% 還存在一定距離,通過雙方的合作排查,最終發現主要是由兩個問題引起的:
- 用戶本地時間與伺服器時間不一致時,依賴的 cos-js-sdk-v5 鑒權報錯,導致出現 403;
- 用戶網路抖動時,雲影片的 vod-js-sdk-v6 對簽名的處理存在問題,導致出現 403。
目前在最新版的 vod-js-sdk-v6
中上述問題均已解決,上傳成功率在全量後也在 99.5% 以上。
PC & H5 影片播放
前面已經簡單提過了影片播放流程,我們這裡再來詳細說明一下。
流程簡介
點播播放其實很簡單,簡單來說就是下面這個流程:

第一步: 獲取m3u8
地址
第二步:調用播放器播放
就是這麼簡單。
這時候我們發現一個問題,有了m3u8
地址,所有人都能播放了。這個m3u8
地址可以肆無忌憚的傳播,任何人拿到鏈接都可以播放,就沒有付費課的概念了。於是我們開始引入前面提到的第一個技術,我們稱之為Key 防盜鏈 。防盜鏈參數是動態變化的,引入之後我們的流程就變成了:

加了防盜鏈之後,缺少防盜鏈參數的鏈接就沒法播放了。就算帶防盜鏈參數的m3u8
地址傳播出去,因為有時效性,這個鏈接過一陣子也會失效。
這時候,聰明的小夥伴應該又發現了另外一個問題,假設在防盜鏈參數失效之前把m3u8
文件下載下來,一樣是可以拿來傳播的。
要解決這個問題,我們可以簡單來看下m3u8
的格式。


簡單的說,m3u8
是一個遵循某種格式的文本文件,裡面是一些TS
分片的索引,通過這些索引就可以找到所有的影片分片。
回到我們加密的主題,如果是每一個TS
分片做加密,是不是就算把m3u8
下載下來,也沒法播放了呢?HLS
的普通 AES 加密技術正是這樣做的。引入了HLS
普通加密之後,整個流程就變成了這樣:

為了簡單起見,我們忽略了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
格式里用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
則用於出現了播放失敗等情況時的排查。
小程式影片播放
小程式端有兩個問題需要解決:
- 騰訊雲並沒有提供可用的雲播放組件供前端使用,所以需要我們自己封裝一個組件,提供雲影片播放能力;
- 小程式沒有cookie,而且m3u8文件獲取解密密鑰的方法是由video自動完成的,程式碼無法控制,所以小程式端只能採用QueryString 傳遞身份認證資訊的方案去鑒權;
我們先來看一下小程式組件騰訊雲影片播放的一個基本流程:

- 課堂這邊是開啟了防盜鏈和HLS加密的,所以上述的判斷流程都走綠色的路徑;
- tokenObj 是防盜鏈的token,裡面包括: 播放地址的過期時間戳、試看時長、鏈接標識、防盜鏈簽名。參考Key 防盜鏈;
- drmToken 是m3u8獲取解密密鑰需要用到的鑒權token,具體規則由前後端在業務層約定加密規則。參考QueryString 傳遞身份認證資訊;
<cloud-player-video />
組件內部的播放還是用的小程式的<video />
組件,只是提供了通過參數獲取真正播放地址的功能;- 目前
<cloud-player-video />
是我們自己研發的組件,還在持續迭代優化中,後續會加入倍速切換,清晰度切換等播放器常用功能;
- 小程式端通過業務的cgi拿到對應的fileId,然後通過getCloudUrlToken的介面獲取對應的 tokenObj ;
- 通過登錄介面獲取的內容經過加密生成 drmToken 用以解密時的鑒權;
- 結合對應騰訊雲業務的 appid 以及獲取到的 tokenObj 、 drmToken 、 fileId 這四個關鍵參數傳遞給雲播放組件
<cloud-player-video />
; - 在組件內部利用 appid 、 tokenObj 、 fileId 這三個參數可以到騰訊雲拿到加密的m3u8地址(通過getPlayInfo),然後利用 drmToken 資訊附加到原始 m3u8 地址上(通過getUrlToken);
- 將新的 m3u8 地址傳遞給小程式的video組件,獲取到的 m3u8 文件內部就會將 drmToken 的資訊注入到 EXT-X-KEY 欄位的URI中,以 QueryString 的方式傳遞,最終 drmToken 將會注入到 m3u8 文件內,圖片上面已經貼過,再貼一遍

- video組件會自動讀取這個URI去拿到解密的密鑰將TS文件解密然後進行播放;
課堂小程式中獲取 tokenObj 、 drmToken ,由於這兩個參數的獲取方式是業務決定的,內部流程就不贅述了,貼一下的步驟程式碼:
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" ></cloud-player-video>
然後是 <cloud-player-video />
組件內部的一些關鍵方法,getPlayInfo是根據 appid 、 tokenObj 、 fileId 獲取原始 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; }
寫在最後
雖然在上雲的過程中遇到了一些問題,但都能順利地解決,而且最後的產品數據與用戶體驗都比之前有了提升,希望越來越多業務能積極地擁抱雲的時代!