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

当然如果视频文件放在专门的服务器的磁盘上可以直接通过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/22400

0-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
  • 使用组件
<video controls="controls" controls="controls">
  <source src="http://ip:port/xxxxxxx" type="video/mp4" />
</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);
                        }
                    }
                    //断点传输下载视频返回206
                    response.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 (
            <ReactPlayer
                className='react-player'
                // url='https://stream7.iqilu.com/10339/upload_transcode/202002/18/20200218093206z8V1JuPlpe.mp4'
                url='/api/baseInfo/file/videoPlayback'
                width='100%'
                height='100%'
                playing={true}
                controls
            />

        )
    }
}
  • 后端处理
@ApiOperation("视频流播放")
    @RequestMapping(value = "/videoPlayback", method = RequestMethod.GET)
    @Override
    public 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();

            //解析Range
            Map<String, Integer> 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<String, Integer> analyzeRange(String range, Integer fileSize) {
        String[] split = range.split("-");
        Map<String, Integer> 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 ,说明服务器支持按字节下载。