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