大视频文件的缓冲播放原理以及实现
创始人
2025-05-31 05:23:00

问题来源

当我们项目中涉及到大视频文件的在线播放的时候,一般情况下直接返回这个文件的流就会导致响应时间很长,这个可能是由于这个流必须要全部加载到客户端才能够在客户端展示出来,现在我们想要完成的是一个大视频文件的边缓冲边播放而不是一次全部返回过来然后在进行播放。

当然如果视频文件放在专门的服务器的磁盘上可以直接通过nginx配置静态资源访问直接通过url访问到视频文件。但是本文章要做的是将服务器的视频文件以流的方式返回到前端展示并且是边缓冲边展示,避免响应时间过长。

实现原理

参考文章1
参考文章2

我们可以将这个大文件每次读取一部分然后返还给前端,前端将这一部分播放出来,播放的时候继续请求下面的一部分视频内容,这样即可做到边加载边播放,同时如果用户点击后面的部分可以直接发起请求请求视频后面部分的内容。

http请求就有相关断点传输的协议,http协议状态码206就是实现断点传输的协议,Http请求头部需要指定获取数据的范围: Range: bytes=first-end,first,开始数据的索引位置,end,结束数据的索引位置。例如:Range: bytes=0-2000。Range参数还支持多个区间,用逗号分隔,例如:Range: bytes=0-5,6-10。这时response的Content-Type不再是原文件mime类型,而用一种multipart/byteranges类型表示。Http响应需要指定范围响应头:content-range bytes first-end,并且http状态码设置为206。

  • Range的格式
Range: bytes=0-499 表示第 0-499 字节范围的内容 
Range: bytes=500-999 表示第 500-999 字节范围的内容 
Range: bytes=-500 表示最后 500 字节的内容 
Range: bytes=500- 表示从第 500 字节开始到文件结束部分的内容 
Range: bytes=0-0,-1 表示第一个和最后一个字节 
Range: bytes=500-600,601-999 同时指定几个范围
  • 响应头Content-Range的格式

Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity legth]
例如:

Content-Range: bytes 0-499/224000-499 是指当前发送的数据的范围,而 22400 则是文件的总大小。而在响应完成后,返回的响应头内容也不同:HTTP/1.1 200 Ok(不使用断点续传方式) 
HTTP/1.1 206 Partial Content(使用断点续传方式)

这个都是在前端处理的,当然前端我们可以直接用组件内部封装好的请求即可。

第一种实现方式

前端使用video.js

  • 首先安装组件
npm  install video.js 
  • 引入组件
import Video from 'video.js'
import 'video.js/dist/video-js.css'
Vue.prototype.$video = Video
  • 使用组件

这个组件在点击视频内容的时候会直接向后端请求所需要的内容的部分,后端根据请求的是视频的那一部分的内容直接传递响应部分的文件内容即可。

  • 后端接收
	//path为本地文件路劲public void play(String path, HttpServletRequest request, HttpServletResponse response) {RandomAccessFile targetFile = null;OutputStream outputStream = null;try {outputStream = response.getOutputStream();response.reset();//获取请求头中Range的值String rangeString = request.getHeader(HttpHeaders.RANGE);//打开文件File file = new File(path);if (file.exists()) {//使用RandomAccessFile读取文件targetFile = new RandomAccessFile(file, "r");long fileLength = targetFile.length();long requestSize = (int) fileLength;//分段下载视频if (StringUtils.hasText(rangeString)) {//从Range中提取需要获取数据的开始和结束位置long requestStart = 0, requestEnd = 0;String[] ranges = rangeString.split("=");if (ranges.length > 1) {String[] rangeDatas = ranges[1].split("-");requestStart = Integer.parseInt(rangeDatas[0]);if (rangeDatas.length > 1) {requestEnd = Integer.parseInt(rangeDatas[1]);}}if (requestEnd != 0 && requestEnd > requestStart) {requestSize = requestEnd - requestStart + 1;}//根据协议设置请求头response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");response.setHeader(HttpHeaders.CONTENT_TYPE, "video/mp4");if (!StringUtils.hasText(rangeString)) {response.setHeader(HttpHeaders.CONTENT_LENGTH, fileLength + "");} else {long length;if (requestEnd > 0) {length = requestEnd - requestStart + 1;response.setHeader(HttpHeaders.CONTENT_LENGTH, "" + length);response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + requestStart + "-" + requestEnd + "/" + fileLength);} else {length = fileLength - requestStart;response.setHeader(HttpHeaders.CONTENT_LENGTH, "" + length);response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + requestStart + "-" + (fileLength - 1) + "/"+ fileLength);}}//断点传输下载视频返回206response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);//设置targetFile,从自定义位置开始读取数据targetFile.seek(requestStart);} else {//如果Range为空则下载整个视频response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=test.mp4");//设置文件长度response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileLength));}//从磁盘读取数据流返回byte[] cache = new byte[4096];try {while (requestSize > 0) {int len = targetFile.read(cache);if (requestSize < cache.length) {outputStream.write(cache, 0, (int) requestSize);} else {outputStream.write(cache, 0, len);if (len < cache.length) {break;}}requestSize -= cache.length;}} catch (IOException e) {// tomcat原话。写操作IO异常几乎总是由于客户端主动关闭连接导致,所以直接吃掉异常打日志//比如使用video播放视频时经常会发送Range为0- 的范围只是为了获取视频大小,之后就中断连接了log.info(e.getMessage());}} else {throw new RuntimeException("文件路劲有误");}outputStream.flush();} catch (Exception e) {log.error("文件传输错误", e);throw new RuntimeException("文件传输错误");}finally {if(outputStream != null){try {outputStream.close();} catch (IOException e) {log.error("流释放错误", e);}}if(targetFile != null){try {targetFile.close();} catch (IOException e) {log.error("文件流释放错误", e);}}}}

第二种实现的方式

当然实现方式不只是这一种,不使用vedio.js使用其他的方式一样可以实现,实现的原理是差不多的。
下面我们介绍一下另一种方式,使用的是ReactPlayer。

  • 前端实现
import React from 'react'
import ReactPlayer from 'react-player'
export default class OperationEdit extends React.Component {render() {return ()}
}
  • 后端处理
    @ApiOperation("视频流播放")@RequestMapping(value = "/videoPlayback", method = RequestMethod.GET)@Overridepublic void videoPlayback() throws IOException {String filepath = "C:\\Users\\admin\\Pictures\\测试视频\\16MSize.mp4";//检查是否是Range请求if (request.getHeader("Range") != null) {//读取文件File targetFile = new File(filepath);BufferedInputStream in = new BufferedInputStream(new FileInputStream(targetFile));Long fileSize = targetFile.length();//解析RangeMap range = this.analyzeRange(request.getHeader("Range"), fileSize.intValue());//设置响应头response.setContentType("video/mp4");response.setHeader("Content-Length", String.valueOf(fileSize.intValue()));response.setHeader("Content-Range", "bytes " + range.get("startByte") + "-" + range.get("endByte") + "/" + fileSize.intValue());response.setHeader("Access-Control-Allow-Origin", "*");response.setHeader("Accept-Ranges", "bytes");response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);//开始输出OutputStream os = response.getOutputStream();int length = range.get("endByte") - range.get("startByte");System.out.println("length:" + length);byte[] buffer = new byte[length < 1024 ? length : 1024];in.skip(range.get("startByte"));int i = in.read(buffer);length = length - buffer.length;while (i != -1) {os.write(buffer, 0, i);if (length <= 0) {break;}i = in.read(buffer);length = length - buffer.length;}os.flush();//关闭os.close();in.close();return;}}/*** 解析range,解析出起始byte(startByte)和结束byte(endBytes)** @param range    请求发来的range* @param fileSize 目标文件的大小* @return*/private Map analyzeRange(String range, Integer fileSize) {String[] split = range.split("-");Map result = new HashMap<>();if (split.length == 1) {//从xxx长度读取到结尾Integer startBytes = new Integer(range.replaceAll("bytes=", "").replaceAll("-", ""));result.put("startByte", startBytes);result.put("endByte", fileSize - 1);} else if (split.length == 2) {//从xxx长度读取到yyy长度Integer startBytes = new Integer(split[0].replaceAll("bytes=", "").replaceAll("-", ""));Integer endBytes = new Integer(split[1].replaceAll("bytes=", "").replaceAll("-", ""));result.put("startByte", startBytes);result.put("endByte", endBytes > fileSize ? fileSize : endBytes);} else {log.info("未识别的range:", range);}return result;}

增强校验

原文连接

在实际场景中,可能会出现这样一种情况,即在终端发起续传请求时,URL 对应的文件内容在服务器端已经发生变化,此时续传的数据肯定是错误的,如果不进行这种错误处理文件传输完成肯定是不对的。如何解决这个问题呢?此时需要有一个标识文件唯一性的方法。

在 RFC2616 中有相应的定义,比如实现 Last-Modified 来标识文件的最后修改时间,这样即可判断出续传文件时是否已经发生过改动。同时 FC2616 中还定义有一个 ETag 的头,可以使用 ETag 头来放置文件的唯一标识。

Last-Modified
If-Modified-Since,和 Last-Modified 一样都是用于记录页面最后修改时间的 HTTP 头信息,只是 Last-Modified 是由服务器往客户端发送的 HTTP 头,而 If-Modified-Since 则是由客户端往服务器发送的头,可以看到,再次请求本地存在的 cache 页面时,客户端会通过 If-Modified-Since 头将先前服务器端发过来的 Last-Modified 最后修改时间戳发送回去,这是为了让服务器端进行验证,通过这个时间戳判断客户端的页面是否是最新的,如果不是最新的,则返回新的内容,如果是最新的,则返回 304 告诉客户端其本地 cache 的页面是最新的,于是客户端就可以直接从本地加载页面了,这样在网络上传输的数据就会大大减少,同时也减轻了服务器的负担。

Etag
Etag(Entity Tags)主要为了解决 Last-Modified 无法解决的一些问题。

  • 一些文件也许会周期性的更改,但是内容并不改变(仅改变修改时间),这时候我们并不希望客户端认为这个文件被修改了,而重新 GET。

  • 某些文件修改非常频繁,例如:在秒以下的时间内进行修改(1s 内修改了 N 次),If-Modified-Since 能检查到的粒度是 s 级的,这种修改无法判断(或者说 UNIX 记录 MTIME 只能精确到秒)。

  • 某些服务器不能精确的得到文件的最后修改时间。

为此,HTTP/1.1 引入了 Etag。Etag 仅仅是一个和文件相关的标记,可以是一个版本标记,例如:v1.0.0;或者说 “627-4d648041f6b80” 这么一串看起来很神秘的编码。但是 HTTP/1.1 标准并没有规定 Etag 的内容是什么或者说要怎么实现,唯一规定的是 Etag 需要放在 “” 内。

If-Range

用于判断实体是否发生改变,如果实体未改变,服务器发送客户端丢失的部分,否则发送整个实体。

  • 格式:If-Range: Etag | HTTP-Date

也就是说,If-Range 可以使用 Etag 或者 Last-Modified 返回的值。当没有 ETage 却有 Last-modified 时,可以把 Last-modified 作为 If-Range 字段的值。
例如:

If-Range: “627-4d648041f6b80”
If-Range: Fri, 22 Feb 2013 03:45:02 GMT

If-Range 必须与 Range 配套使用。如果请求报文中没有 Range,那么 If-Range 就会被忽略。如果服务器不支持 If-Range,那么 Range 也会被忽略。

如果请求报文中的 Etag 与服务器目标内容的 Etag 相等,即没有发生变化,那么应答报文的状态码为 206。如果服务器目标内容发生了变化,那么应答报文的状态码为 200。

用于校验的其他 HTTP 头信息:If-Match/If-None-Match、If-Modified-Since/If-Unmodified-Since。

工作原理

Etag 由服务器端生成,客户端通过 If-Range 条件判断请求来验证资源是否修改。请求一个文件的流程如下:

第一次请求:

  • 客户端发起 HTTP GET 请求一个文件。
  • 服务器处理请求,返回文件内容以及相应的 Header,其中包括 Etag(例如:627-4d648041f6b80)(假设服务器支持 Etag 生成并已开启了 Etag)状态码为 200。

第二次请求(断点续传):

  • 客户端发起 HTTP GET 请求一个文件,同时发送 If-Range(该头的内容就是第一次请求时服务器返回的 Etag:627-4d648041f6b80)。
  • 服务器判断接收到的 Etag 和计算出来的 Etag 是否匹配,如果匹配,那么响应的状态码为 206;否则,状态码为 200。

检测服务器是否支持断点续传

curl -i --range 0-9 http://www.baidu.com/img/bdlogo.gif

在返回的内容中如果能够找到 Content-Range,则表明服务器支持断点续传。有些服务器还会返回 Accept-Ranges,输出结果 Accept-Ranges: bytes ,说明服务器支持按字节下载。

本文转自 https://blog.csdn.net/qq_45401910/article/details/125628963,如有侵权,请联系删除。

相关内容

热门资讯

谁懂啊!雨季去龙华古镇,我被“... 谁懂啊!之前总怕雨季去龙华古镇踩雷 怕下雨逛不了古街、怕山路滑 更怕看不到心心念念的云海 结果这次住...
原创 海... 海边偶遇:绿影映碧波 夏日的海边,海风裹挟着咸湿气息扑面而来,碧海与白沙在阳光下相映成趣。我漫步沙...
成都定制游旅行社怎么选?202... 随着旅游消费的升级,越来越多游客不再满足于千篇一律的跟团游,个性化、深度化的定制旅行需求激增。然而,...
镜像寻美!摄影大咖聚焦那香海“... 清晨微光初现,清澈的湖面波光粼粼,天鹅或在水中悠然游弋,或展翅低飞,与远方的摩天轮、初升的朝阳构成一...
东沙古渔镇:淡季里的烟火与匠心 岱山下的东沙镇,因踞东沙湾之东得名,这座曾汇集四海渔民客商的古渔镇,如今仍完整留存着青石板铺就的古街...