H5播放视频流


由于项目需求,在公司系统中需要播放录音。前端我就采用了H5的 <audio> 标签,而后端就是常规 的输出流操作,即通过response的outPutStream输出文件,对于前端来说就是文件下载。开始测试的时候因为使用的都是一些小文件音频(每个音频文件大概就几M吧)都很正常,但是当测到一些较大的音频文件时,就出现了问题,七分钟的音频(十多M的文件)只能播放出三四分钟,通过F12发现,那些小音频文件,一次请求就加载完成了,而对于七分钟的大音频,audio标签没有一次加载完,而且在播放一段时间后,会再次请求服务加载后面的音频。也就是说它是分段加载的,刚开始分段的请求正常,在播放了一半后,后面的分段请求会失败,导致后面的音频不能完整播放。也是由于没有流媒体经验,解决这个问题费了些周折,故在此记录一下。

文件输出流

我先说一下,常规的输出流方式会出现的问题。在公司时我使用的是<audio> 播放音频,于是回家后,我又用<video> 标签测试了一下,同样会出现视频卡主的现象,F12后同样是伴随分段视频流下载失败的异常。前端代码如下

        <div>
            <video src="http://127.0.0.1:8090/video/player" 
                   style="width: 500px;height: 300px" 
                   controls="controls"> </video>
        </div>

后端 /video/player 接口代码如下

    @GetMapping("/video/player")
    public void videoPlayer(
            HttpServletRequest request,
            HttpServletResponse response) throws IOException {
        String range = request.getHeader("Range");
        if(range == null)
            range = "bytes=0-";
        String[] rs = range.split("\\=");
        range = rs[1].split("\\-")[0];

        File file = new File("F:\\test\\异界.1080p.HD中字.mp4");
        if(!file.exists())
            throw new RuntimeException("视频文件不存在 --> 404");
        OutputStream os = response.getOutputStream();
        FileInputStream fis = new FileInputStream(file);
        long length = file.length();
        System.out.println("file length : " + length);
        // 播放进度
        int count = 0;
        // 播放百分比
        int percent = (int)(length * 0.4);

        int irange = Integer.parseInt(range);
        length = length - irange;

        response.addHeader("Accept-Ranges", "bytes");
        response.addHeader("Content-Length", length + "");
        response.addHeader("Content-Range", "bytes " + range + "-" + length + "/" + length);
        response.addHeader("Content-Type", "video/mp4;charset=UTF-8");

        int len = 0;
        byte[] b = new byte[1024];
        while ((len = fis.read(b)) != -1) {
            os.write(b, 0, len);
            count += len;
            if(count >= percent){
                break;
            }
        }
        System.out.println("count: " + count + ", percent: " + percent);
        fis.close();
        os.close();
    }

这里我播放的电影有1.4G,肯定会出现分段请求。在开始的时候,一切看起来很正常,视频可以正常播放,在播放一段时间后,在分段请求时就会出现如下异常

可以看到,有几个请求出现了failed,视频也卡主了,一直在转圈加载。查看控制台异常信息如下

而我在公司播放音频时碰到异常如下

两个异常不太相同,分别是 ERR_CONTENT_LENGTH_MISMATCHERR_CONNECTION_RESET 200 。但其实都是由于前端播放流文件时,响应头不对的原因,对于前端的分段请求,后端必须要支持断点续传才行。下面就来看一下这种方式后端应该怎么写代码

断点续传流

同样拿刚刚的H5视频做实验,前端代码不变,后端我新加了一个接口用于支持断点续传的方式,前端代码如下

        <div>
            <video src="http://127.0.0.1:8090/video/player2"
                   style="width: 500px;height: 300px"
                   controls="controls"> </video>
        </div>

后端 /video/player2 接口代码如下

   @GetMapping("/video/player2")
    public void videoPlayer2(
            HttpServletRequest request,
            HttpServletResponse response) throws IOException {
        File downloadFile = new File("F:\\test\\异界.1080p.HD中字.mp4");
        if (!downloadFile.exists()) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        long fileLength = downloadFile.length();// 记录文件大小
        long pastLength = 0;// 记录已下载文件大小
        // 0:从头开始的全文下载;
        // 1:从某字节开始的下载(bytes=27000-);
        // 2:从某字节开始到某字节结束的下载(bytes=27000-39000)
        int rangeSwitch = 0;
        long contentLength = 0;// 客户端请求的字节总量
        String rangeBytes = "";// 记录客户端传来的形如“bytes=27000-”或者“bytes=27000-39000”的内容
        RandomAccessFile raf = null;// 负责读取数据
        OutputStream os = null;// 写出数据
        OutputStream out = null;// 缓冲
        int bsize = 1024;// 缓冲区大小
        byte b[] = new byte[bsize];// 暂存容器

        String range = request.getHeader("Range");
        int responseStatus = 200;
        // 客户端请求的下载的文件块的开始字节
        if (range != null && range.trim().length() > 0 && !"null".equals(range)) {
            responseStatus = javax.servlet.http.HttpServletResponse.SC_PARTIAL_CONTENT;
            System.out.println("request.getHeader Range=" + range);
            rangeBytes = range.replaceAll("bytes=", "");
            if (rangeBytes.endsWith("-")) {
                rangeSwitch = 1;
                rangeBytes = rangeBytes.substring(0, rangeBytes.indexOf('-'));
                pastLength = Long.parseLong(rangeBytes.trim());
                contentLength = fileLength - pastLength;
            } else {
                rangeSwitch = 2;
                String temp0 = rangeBytes.substring(0, rangeBytes.indexOf('-'));
                String temp2 = rangeBytes.substring(rangeBytes.indexOf('-') + 1, rangeBytes.length());
                pastLength = Long.parseLong(temp0.trim());
            }
        }
        // 客户端要求全文下载
        else {
            contentLength = fileLength;
        }


        // 清除首部的空白行
        response.reset();
        // 告诉客户端允许断点续传多线程连接下载,响应的格式是:Accept-Ranges: bytes
        response.setHeader("Accept-Ranges", "bytes");
        // 如果是第一次下,还没有断点续传,状态是默认的 200,无需显式设置;响应的格式是:HTTP/1.1
        if (rangeSwitch != 0) {
            response.setStatus(responseStatus);
            // 不是从最开始下载,断点下载响应号为206
            // 响应的格式是:
            // Content-Range: bytes [文件块的开始字节]-[文件的总大小 - 1]/[文件的总大小]
            switch (rangeSwitch) {
                case 1: {
                    String contentRange = "bytes " + pastLength + "-" + (fileLength-1) + "/" + fileLength;
                    response.setHeader("Content-Range", contentRange);
                    break;
                }
                case 2: {
                    String contentRange = range.replace("=", " ") + "/" + fileLength;
                    response.setHeader("Content-Range", contentRange);
                    break;
                }
                default: {
                    break;
                }
            }
        }else{
            String contentRange = "bytes " + "0-" + (fileLength - 1) + "/" + fileLength;
            response.setHeader("Content-Range", contentRange);
        }
        try {
            response.setContentType("video/mp4;charset=UTF-8");
            response.setHeader("Content-Length", String.valueOf(contentLength));
            os = response.getOutputStream();
            out = new BufferedOutputStream(os);
            raf = new RandomAccessFile(downloadFile, "r");
            try {
                long outLength = 0;// 实际输出字节数
                switch (rangeSwitch) {
                    case 0: {
                    }
                    case 1: {
                        raf.seek(pastLength);
                        int n = 0;
                        while ((n = raf.read(b)) != -1) {
                            out.write(b, 0, n);
                            outLength += n;
                        }
                        break;
                    }
                    case 2: {
                        raf.seek(pastLength);
                        int n = 0;
                        long readLength = 0;// 记录已读字节数
                        // 大部分字节在这里读取
                        while (readLength <= contentLength - bsize) {
                            n = raf.read(b);
                            readLength += n;
                            out.write(b, 0, n);
                            outLength += n;
                        }
                        // 余下的不足 1024 个字节在这里读取
                        if (readLength <= contentLength) {
                            n = raf.read(b, 0, (int) (contentLength - readLength));
                            out.write(b, 0, n);
                            outLength += n;
                        }
                        break;
                    }
                    default: {
                        break;
                    }
                }
                System.out.println("Content-Length为:" + contentLength + ";实际输出字节数:" + outLength);
                out.flush();
            } catch (IOException ie) {
                // ignore
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally{
            if (out != null) {
                try {
                    out.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (raf != null) {
                try {
                    raf.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

前端请求 player2 就可以正常播放了,快进快退也很顺畅丝滑。通过F12观察一段时间后,也没有出现请求 failed 的情况

查看每个请求的响应头可以发现,每个请求是只请求了视频的一部分内容,不是像第一种方式那样把整个文件都输出给前端了,从后端代码也可以看出这点,因为后端文件读取使用了RandomAccessFile,这个是用于文件随机读取的类。前端每次请求头中会带有 Range 属性,告诉后端要读取哪部分文件内容。

每次请求到的文件长度不一样

总结

第一种方式其实也是可以的,只不过接口不支持流媒体,前端还是需要等待整个资源下载下来之后,才能做进度拖拽,播放等功能。因此在对于大文件来说肯定不合适。第二种方式是流媒体方式,即边下载边播放。需要HTTP支持断点续传。

HTTP的断点续传

文件上传下载时,记录上一次上传下载的位置,再从标记位置继续传输,或者多线程下载,根据标记可按需获取。在以前版本的 HTTP 协议是不支持断点的,HTTP/1.1 开始就支持了。断点下载时需要用到 Range 和 Content-Range 实体头。

请求头 Range

请求资源的部分内容(不包括响应头的大小),单位是byte,即字节,从0开始,如果服务器能够正常响应的话,服务器会返回 206 Partial Content 的状态码及说明,如果不能处理这种Range的话,就会返回整个资源以及响应状态码为 200 OK,(这个要注意,要分段下载时,后端代码要先判断这个)用于请求头中,指定第一个字节的位置和最后一个字节的位置,一般格式:Range: bytes=start-end

  1. Range: bytes=10- :第10个字节及最后个字节的数据
  2. Range: bytes=40-100 :第40个字节到第100个字节之间的数据
响应头 Content-Range
  1. Content-Range: bytes 0-100/5000:表示相应了0到100个字节的数据,共有5000个字节大小
  2. Content-Length: 5000 :表示总文件大小

Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
H5视频流加密 H5视频流加密
之前做过在页面上实现音频播放功能,但是业务又提出一个需求,就是音频只能在线播放,不允许下载。经过一番研究发现前端页面屏蔽下载功能只是个障眼法,用户可以直接请求后端接口下载得到原音频文件,因此要实现禁止下载功能还是得在后端接口上做文章,一开始
2020-05-10
Next 
React-Native项目搭建 React-Native项目搭建
React Native 结合了 Web 应用和 Native 应用的优势,可以使用 JavaScript 来开发 iOS 和 Android 原生应用。在 JavaScript 中用 React 抽象操作系统原生的 UI 组件,代替 DO
2020-05-05
  TOC