如何优雅地实现网页播放视频

技术选型:
React.js
Python & Sanic

1.背景

最近公司要做一个培训系统,其中一个模块为课程学习,需求为上传及播放学习视频。

2.简单实现

最简单、粗暴,同时体验最差的做法就是后台直接将视频文件以bytes写入http response,同时response headers中标记media相关的属性Content-Type: video/mp4,然后丢给浏览器播放。

这种实现对于后台来说,使用Python异步web库Sanic,函数中的具体实现只需要1行代码(查找文件路径相关的代码除外):

from sanic.response import file_stream

bp = Blueprint('videos', url_prefix="/videos")

@bp.route('/<video_id>', methods=["GET"])
async def video(request, video_id):
    # get the video location with video_id
    file_path = '/path/to/file'
    return await file_stream(file_path)

3.优化思路

好吧,对于一个有理想、有追求的程序员来说,上面的这种简单做法是绝对无法接受的。

3.1 视频播放器

第一步,必须搞个视频播放器,就算后台服务再垃圾,网页的UI也必须花哨一些,至少看起来像点样子。

由于前端页面使用的React.js,了解到知乎开源了一个视频播放库Griffith,一开始满怀期待以为省了很多工作,但实际用下来发现这玩意设计上有些奇怪,而且有几处重要的bug,后来无奈换成react-player了,本文且已此为例,影响不大。

这个视频播放库使用起来也是极为简单,引入组件,定义好视频URL,一个功能强大的视频播放器就能在页面呈现了,自己调整下播放器的宽高,以适应页面大小,“看起来”就完美了。

import React from "react";
import Player from 'griffith';

export const Video = () => {

    const sources = {
        sd: {
             play_url: 'https://zhstatic.zhihu.com/cfe/griffith/zhihu2018_hd.mp4'
        },
        hd: {
             play_url: '/api/videos/001'
             // play_url: 'https://zhstatic.zhihu.com/cfe/griffith/zhihu2018_hd.mp4'
        }
    };

    return (
        <React.Fragment>
            <Player sources={sources} useAutoQuality={true} />
        </React.Fragment>
    );
};
3.2 分段缓冲

找到合适的播放器后,为什么只能说“看起来”完美呢?

经过一番尝试,发现在使用自己后台服务提供的URL时,打开页面播放视频时,播放器总是会呈现Loading状态,非得等整个视频完全下载后才开始播放。这要是播放一个稍微大点的文件,或者网速不太得劲的时候,那我岂不是要等到天荒地老?

对比Griffith示例提供的URL,这个视频在页面打开的瞬间就能播放,进度条也是渐进地增长,通过鼠标选定位置,还能跳跃式的播放!好嘛,这才是咱们想要的结果。

打开浏览器的调试工具,一遍播放一遍观察网络请求,发现了一些“不对劲”的地方。它首先发起第一个请求,会在request headers中包含range: bytes=0-1,同时reponse headers中包含accept-ranges: bytes,同时HTTP请求的Status Code为206,不是正常的200。

继续播放的过程中,又以同样的URL重新发起了多次请求,request headers中Range的参数值有所变化,看起来像是文件下载时分片下载、断点续传的思路,只不过这里是将视频文件一段一段地下载。

到这里,基本能够理解大概的思路了:

第一次请求时,请求头中发送range: bytes=0-1实际上是一次“试探”,后台服务收到请求后通过accept-ranges: bytes告诉客户端此文件是什么、大小是多少、能够支持文件内容部分下载。客户端收到信息后,就可以计算好每一次bytes的范围,一段一段地去下载文件内容;同时服务端也根据后续请求头中的range: bytes=start-end,将文件指定范围的bytes返回给客户端。重复这个步骤,直至整个视频文件下载完毕。

4.具体实现

不错,思路终于理清楚了,那么怎么实现这个逻辑呢?

前端播放器在发起第一个请求后,就要计算下一次想要下载文件的bytes范围,并且播放到一定进度的时候,还要继续发起新的请求,这里面猜想估计比较复杂,而且还涉及到视频解析相关的知识,由于能力有限,就不研究视频播放器是怎么处理这些逻辑的了。

把关注点放在后台服务的处理上!仔细研究了一下file_stream这个函数,其实它已经支持了按Range范围获取文件内容

async def file_stream(
    location: Union[str, PurePath],
    status: int = 200,
    chunk_size: int = 4096,
    mime_type: Optional[str] = None,
    headers: Optional[Dict[str, str]] = None,
    filename: Optional[str] = None,
    chunked="deprecated",
    _range: Optional[Range] = None,
) -> StreamingHTTPResponse:
    """Return a streaming response object with file data.

    :param location: Location of file on system.
    :param chunk_size: The size of each chunk in the stream (in bytes)
    :param mime_type: Specific mime_type.
    :param headers: Custom Headers.
    :param filename: Override filename.
    :param chunked: Deprecated
    :param _range:
    """
    if chunked != "deprecated":
        warn(
            "The chunked argument has been deprecated and will be "
            "removed in v21.6"
        )

    headers = headers or {}
    if filename:
        headers.setdefault(
            "Content-Disposition", f'attachment; filename="{filename}"'
        )
    filename = filename or path.split(location)[-1]
    mime_type = mime_type or guess_type(filename)[0] or "text/plain"
    if _range:
        start = _range.start
        end = _range.end
        total = _range.total

        headers["Content-Range"] = f"bytes {start}-{end}/{total}"
        status = 206

    async def _streaming_fn(response):
        async with await open_async(location, mode="rb") as f:
            if _range:
                await f.seek(_range.start)
                to_send = _range.size
                while to_send > 0:
                    content = await f.read(min((_range.size, chunk_size)))
                    if len(content) < 1:
                        break
                    to_send -= len(content)
                    await response.write(content)
            else:
                while True:
                    content = await f.read(chunk_size)
                    if len(content) < 1:
                        break
                    await response.write(content)

    return StreamingHTTPResponse(
        streaming_fn=_streaming_fn,
        status=status,
        headers=headers,
        content_type=mime_type,
    )

只不过在参数的传递上,缺少了_range,所以一开始的做法永远只会一次下载整个文件。那么只要从request headers中获取range,然后构造_range作为参数传递给file_stream函数,那么此函数中if _range:这部分逻辑就能够正常执行,每次请求时就会获取指定范围的bytes,而不是整个文件了,同时response header也包含了预期的信息。

最终,后台服务代码调整如下,同样省略查找文件路径相关的代码:

from sanic.handlers import ContentRangeHandler
from sanic.response import file_stream
from sanic.compat import stat_async

bp = Blueprint('videos', url_prefix="/videos")

@bp.route('/<video_id>', methods=["GET"])
async def video(request, video_id):
    # get the video location with video_id
    file_path = '/path/to/file'
    stats = await stat_async(file_path)
    _range = ContentRangeHandler(request, stats)
    return await file_stream(file_path, _range=_range)

到这里为止,用自己的后台服务提供的URL进行播放,效果基本与示例的效果一样了。

5.再次优化

那么,现在就优雅地实现了视频播放吗?并没有!

上面的处理中,后台服务的逻辑是根据video_id去服务器本地目录查找文件,这显然是不符合实际情况的。文件存储的方案通常不会选择直接存储在服务器本地,而是选择一些对象存储的服务。

所以上面的代码中提及的查找文件路径,再获取文件内容的逻辑就需要调整了。如此一来,file_stream这个函数就无用武之地了,因为它支持按路径读取本地文件。

不过呢,虽然不能直接使用file_stream,咱们依然可以参照它的处理方式,按照请求头中指定的文件bytes范围,从对象存储获取相应的部分文件并返回,而不是从本地路径读取文件。

这部分代码实际上将file_stream稍作调整就完成,这里就不贴出来了,有兴趣的可以动手实践一下!

6.总结

实现一个支持分段缓冲的视频播放功能,主要工作在于发起请求时,在request headers中携带Range属性,告知服务器想要获取的部分文件,服务器在接收到请求后获取Range中指定的范围,按这个范围返回文件内容。

本文中请求相关的处理,视频播放器已经帮我们做了,而后台服务相关的逻辑,web框架也提供了一些支持,也就是file_stream这个函数,尽管只支持读取服务器本地文件。如果选择使用其他编程语言和web框架,也可以参考这部分代码自己来实现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容