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

技术选型:
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('/', 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 (
        
            
        
    );
};
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('/', 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框架,也可以参考这部分代码自己来实现。

你可能感兴趣的:(如何优雅地实现网页播放视频)