欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

SpringBoot Java实现Http方式分片下载断点续传+实现H5大视频渐进式播放

程序员文章站 2022-05-05 11:37:45
...

一、功能目的

SpringBoot 实现Http分片下载断点续传,从而实现H5页面的大视频播放问题,实现渐进式播放,每次只播放需要播放的内容就可以了,不需要加载整个文件到内存中;

二、Http分片下载断点续传实现

package com.unnet.yjs.controller.api.v1;

import com.unnet.yjs.annotation.HttpMethod;
import com.unnet.yjs.base.ContainerProperties;
import com.unnet.yjs.entity.OssFileRecord;
import com.unnet.yjs.service.OssFileRecordService;
import com.unnet.yjs.util.IOssOperation;
import com.xiaoleilu.hutool.util.StrUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.apache.catalina.connector.ClientAbortException;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

/**
 * Email: aaa@qq.com
 * Copyright (c)  2019. missbe
 * @author lyg   19-7-29 下午9:21
 *
 *
 **/
@RestController
@Api(tags = "OssStreamConvertController", description = "文件上传控制器")
@RequestMapping("/api/v1/resource/file/")
public class OssStreamConvertController {
    private static final Logger LOGGER = LoggerFactory.getLogger(OssStreamConvertController.class);
    @Resource
    private ContainerProperties containerProperties;

    @Autowired
    @Qualifier("paasOssTool")
    private IOssOperation paasOssTool;
    @Autowired
    @Qualifier("minIoOssTool")
    private IOssOperation minIoOssTool;

    @Resource
    private OssFileRecordService ossFileRecordService;


    /**
     * 对象存储中转请求链接-根据文件名字请求对象存储的文件流
     *
     * @param fileName 文件名称
     */
    @GetMapping("video/{fileName}")
    @ApiOperation(value = "对象存储文件流中转接口", httpMethod = HttpMethod.GET)
    @ApiImplicitParams({
            @ApiImplicitParam(name = "fileName", value = "文件名称", paramType = "path")
    })
    public void videoPlayer(@PathVariable(value = "fileName") String fileName, HttpServletRequest request,HttpServletResponse response) throws IOException {
        if(paasOssTool == null || minIoOssTool == null){
            OutputStream out = response.getOutputStream();
            out.write(("OSS文件服务器配置出现问题,请修复后重试.").getBytes());
            out.flush();
            out.close();
            return;
        }

        ///是否开启本地缓存视频mp4文件
        String filePath = containerProperties.getFileCacheLocation() + fileName;
        File file = new File(filePath);
        File parentDir = file.getParentFile();
        if (!parentDir.exists()) {
           boolean isMakeParentDir = parentDir.mkdirs();
           if(isMakeParentDir){
               LOGGER.info("创建文件夹{}成功.",parentDir.getAbsolutePath());
           }else {
               LOGGER.error("创建文件夹{}失败.",parentDir.getAbsolutePath());
           }
        }//end if
        if (!file.exists()) {
            ///本地文件不存在,从OSS下载到本地
            boolean isMakeNewFile = file.createNewFile();
            if(isMakeNewFile){
                LOGGER.info("创建文件{}成功.",file.getAbsolutePath());
            }else {
                LOGGER.error("创建文件{}失败.",file.getAbsolutePath());
            }
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            InputStream is = null;
            try {
                if (StrUtil.equalsIgnoreCase(containerProperties.getFileUploadType(), "myOss")) {
                    is = minIoOssTool.load(fileName);
                }
                if (StrUtil.equalsIgnoreCase(containerProperties.getFileUploadType(), "paas")) {
                    is = paasOssTool.load(fileName);
                }
            } catch (Exception e) {
                e.printStackTrace();
                e.printStackTrace();
                LOGGER.error("对象存储加载文件失败,msg:"+e.getLocalizedMessage());
                OutputStream out = response.getOutputStream();
                out.write(("对象存储加载文件失败,msg:"+e.getLocalizedMessage()).getBytes());
                out.flush();
                out.close();
                return;
            }
            ////判断流不为空
            Objects.requireNonNull(is);

            BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file));
            byte[] buffer = new byte[4096];
            int length;
            while ((length = is.read(buffer)) != -1) {
                bos.write(buffer, 0, length);
            }
            bos.flush();
            bos.close();
        }///end if
        LOGGER.info("文件:{},总长度:{}",file.getName(),file.length());
        ///对文件执行分块
        fileChunkDownload(filePath,request,response);
        /////添加文件访问记录
        OssFileRecord ossFileRecord = ossFileRecordService.findByFileName(fileName);
        if (Objects.nonNull(ossFileRecord)) {
            ossFileRecord.setFileLength(String.valueOf(file.length()));
            ossFileRecord.setVisitCount(ossFileRecord.getVisitCount() + 1);
        }else{
            ossFileRecord = new OssFileRecord();
            ossFileRecord.setFileName(fileName);
            ossFileRecord.setFileLength(String.valueOf(file.length()));
            ossFileRecord.setVisitCount(1);
            ossFileRecord.setRemarks("OssFileRecord");
        }
        ossFileRecordService.insertOrUpdate(ossFileRecord);
    }

    /**
     * 文件支持分块下载和断点续传
     * @param filePath 文件完整路径
     * @param request 请求
     * @param response 响应
     */
    private void fileChunkDownload(String filePath, HttpServletRequest request, HttpServletResponse response) {
        String range = request.getHeader("Range");
        LOGGER.info("current request rang:" + range);
        File file = new File(filePath);
        //开始下载位置
        long startByte = 0;
        //结束下载位置
        long endByte = file.length() - 1;
        LOGGER.info("文件开始位置:{},文件结束位置:{},文件总长度:{}", startByte, endByte, file.length());

        //有range的话
        if (range != null && range.contains("bytes=") && range.contains("-")) {
            range = range.substring(range.lastIndexOf("=") + 1).trim();
            String[] ranges = range.split("-");
            try {
                //判断range的类型
                if (ranges.length == 1) {
                    //类型一:bytes=-2343
                    if (range.startsWith("-")) {
                        endByte = Long.parseLong(ranges[0]);
                    }
                    //类型二:bytes=2343-
                    else if (range.endsWith("-")) {
                        startByte = Long.parseLong(ranges[0]);
                    }
                }
                //类型三:bytes=22-2343
                else if (ranges.length == 2) {
                    startByte = Long.parseLong(ranges[0]);
                    endByte = Long.parseLong(ranges[1]);
                }

            } catch (NumberFormatException e) {
                startByte = 0;
                endByte = file.length() - 1;
                LOGGER.error("Range Occur Error,Message:{}",e.getLocalizedMessage());
            }
        }

        //要下载的长度
        long contentLength = endByte - startByte + 1;
        //文件名
        String fileName = file.getName();
        //文件类型
        String contentType = request.getServletContext().getMimeType(fileName);

        ////解决下载文件时文件名乱码问题
        byte[] fileNameBytes = fileName.getBytes(StandardCharsets.UTF_8);
        fileName = new String(fileNameBytes, 0, fileNameBytes.length, StandardCharsets.ISO_8859_1);

        //各种响应头设置     
        //支持断点续传,获取部分字节内容:
        response.setHeader("Accept-Ranges", "bytes");
        //http状态码要为206:表示获取部分内容
        response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
        response.setContentType(contentType);
        response.setHeader("Content-Type", contentType);
        //inline表示浏览器直接使用,attachment表示下载,fileName表示下载的文件名
        response.setHeader("Content-Disposition", "inline;filename=" + fileName);
        response.setHeader("Content-Length", String.valueOf(contentLength));       
        // Content-Range,格式为:[要下载的开始位置]-[结束位置]/[文件总大小]
        response.setHeader("Content-Range", "bytes " + startByte + "-" + endByte + "/" + file.length());

        BufferedOutputStream outputStream = null;
        RandomAccessFile randomAccessFile = null;
        //已传送数据大小
        long transmitted = 0;
        try {
            randomAccessFile = new RandomAccessFile(file, "r");
            outputStream = new BufferedOutputStream(response.getOutputStream());
            byte[] buff = new byte[4096];
            int len = 0;
            randomAccessFile.seek(startByte);
            //坑爹地方四:判断是否到了最后不足4096(buff的length)个byte这个逻辑((transmitted + len) <= contentLength)要放前面!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
            //不然会会先读取randomAccessFile,造成后面读取位置出错,找了一天才发现问题所在
            while ((transmitted + len) <= contentLength && (len = randomAccessFile.read(buff)) != -1) {
                outputStream.write(buff, 0, len);
                transmitted += len;
            }
            //处理不足buff.length部分
            if (transmitted < contentLength) {
                len = randomAccessFile.read(buff, 0, (int) (contentLength - transmitted));
                outputStream.write(buff, 0, len);
                transmitted += len;
            }

            outputStream.flush();
            response.flushBuffer();
            randomAccessFile.close();
           LOGGER.info("下载完毕:" + startByte + "-" + endByte + ":" + transmitted);
        } catch (ClientAbortException e) {
            LOGGER.warn("用户停止下载:" + startByte + "-" + endByte + ":" + transmitted);
            //捕获此异常表示拥护停止下载
        } catch (IOException e) {
            e.printStackTrace();
            LOGGER.error("用户下载IO异常,Message:{}", e.getLocalizedMessage());
        } finally {
            try {
                if (randomAccessFile != null) {
                    randomAccessFile.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }///end try
    }

 }

ps:需要注意的几个方面:

三、前面Html页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>操作视频</title>
</head>
<body>
<div style="text-align: center; margin-top: 50px; ">

    <h2>操作视频</h2>
    <video width="75%" height=600" controls="controls" controlslist="nodownload">
        <source id="my_video_1" src="http://localhost:9070/api/v1/resource/file/video/small.mp4" type="video/ogg"/>
        <source id="my_video_2" src="http://localhost:9070/api/v1/resource/file/video/small.mp4" type="video/mp4"/>
        Your browser does not support the video tag.
    </video>
   
   <h2 style="color: red;">该操作视频不存在或已经失效.</h2>
 
</div>
</body>
</html>

四、缓存文件定时删除任务

package com.unnet.yjs.config;

import com.unnet.yjs.base.ContainerProperties;
import com.unnet.yjs.entity.Log;
import com.unnet.yjs.entity.OssFileRecord;
import com.unnet.yjs.service.OssFileRecordService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.io.File;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;

/**
 * Email: aaa@qq.com
 * Copyright (c)  2019. missbe
 * @author lyg   19-7-10 下午7:48
 *
 *
 **/
@Component
@Configuration
@EnableScheduling
public class ScheduleTaskConfig {
    private static final Logger LOGGER = LoggerFactory.getLogger(ScheduleTaskConfig.class);
    @Resource
    private ContainerProperties containerProperties;
    @Resource
    private OssFileRecordService ossFileRecordService;

    @Scheduled(cron =  "0 0 2 1/7 * ?")
//    @Scheduled(cron =  "0/40 * * * * ?")
    private void scheduleDeleteFile(){
        Log sysLog = new Log();
        sysLog.setBrowser("Default");
        sysLog.setUsername("OssFileTask");
        sysLog.setType("OssFileTask");
        sysLog.setTitle("OssFileTask");
        long startTime = System.currentTimeMillis();
        LOGGER.info("开始定时删除缓存文件任务,控制文件夹【{}】大小.",containerProperties.getFileCacheLocation());
        long folderSize = getFolderSize();
        long gUnit = 1073741824L;
        String restrictSizeString = containerProperties.getRestrictSize();
        long restrictSize = Long.parseLong(restrictSizeString);
        LOGGER.info("文件夹:【{}】,大小:【{}】,限制大小为:【{}G】",containerProperties.getFileCacheLocation(),formatFileSize(folderSize),restrictSize);
        ///如果超出限制进行清理
        restrictSize = restrictSize * gUnit;
        if ( restrictSize < folderSize) {
            List<OssFileRecord> ossFileRecords = ossFileRecordService.findAll();
            List<OssFileRecord> waitDeleteRecord = new ArrayList<>();
            ////删除限制大小的一半内容
            restrictSize = restrictSize / 2;
            long totalWaitDeleteSize = 0L;
            for (OssFileRecord record : ossFileRecords) {
                if (totalWaitDeleteSize < restrictSize) {
                    waitDeleteRecord.add(record);
                    totalWaitDeleteSize += Long.parseLong(record.getFileLength());
                }else {
                    break;
                }///end if
            }///end for
            File waitDeleteFile;
            StringBuilder builder = new StringBuilder();
            for (OssFileRecord record : waitDeleteRecord) {
                waitDeleteFile = new File(containerProperties.getFileCacheLocation() + record.getFileName());
                boolean isDelete = waitDeleteFile.delete();
                if (isDelete) {
                    LOGGER.info("文件【{}】删除成功.",record);
                    ///删除该条记录
                    record.deleteById();
                }else{
                    builder.append(record);
                    LOGGER.info("文件【{}】删除失败.",record);
                }///end if
            }
            sysLog.setException(builder.toString());
            LOGGER.info("结束定时删除缓存文件任务,此次删除【{}】文件.文件夹剩余大小:{}",formatFileSize(totalWaitDeleteSize),formatFileSize(getFolderSize()));
        }else {
            LOGGER.info("结束定时删除缓存文件任务,文件夹【{}】未超过限定大小.",containerProperties.getFileCacheLocation());
        }
        sysLog.setUseTime(System.currentTimeMillis() - startTime);
        sysLog.setRemoteAddr("127.0.0.1");
        sysLog.setRequestUri("/api/v1/resource/file/video");
        sysLog.setClassMethod(this.getClass().getName());
        sysLog.setHttpMethod("Native");
        ///add sys log
        sysLog.insert();
    }
    private long getFolderSize(){
        String folderPath = containerProperties.getFileCacheLocation();
        File parentDir = new File(folderPath);
        if (!parentDir.exists()) {
            boolean isMakeParentDir = parentDir.mkdirs();
            if(isMakeParentDir){
                LOGGER.info("创建文件夹{}成功.",parentDir.getAbsolutePath());
            }else {
                LOGGER.error("创建文件夹{}失败.",parentDir.getAbsolutePath());
            }
        }//end if
        return getFileSize(parentDir);
    }

    /**
     * 递归计算文件夹或文件占用大小
     * @param file 文件
     * @return 文件总大小
     */
    private long getFileSize(File file){
        if (file.isFile()) {
            return file.length();
        }
        long totalSize = 0;
        File[] listFiles = file.listFiles();
        assert listFiles != null;
        for (File f : listFiles) {
            if (f.isDirectory()) {
                totalSize += getFileSize(f);
            }else{
                totalSize += f.length();
            }
        }///end for
        return totalSize;
    }
    private  String formatFileSize(long fileByte) {
        DecimalFormat df = new DecimalFormat("#.00");
        String fileSizeFormat = "";
        if (fileByte < 1024) {
            fileSizeFormat = df.format((double) fileByte) + "B";
        } else if (fileByte < 1048576) {
            fileSizeFormat = df.format((double) fileByte / 1024) + "K";
        } else if (fileByte < 1073741824) {
            fileSizeFormat = df.format((double) fileByte / 1048576) + "M";
        } else {
            fileSizeFormat = df.format((double) fileByte / 1073741824) + "G";
        }
        return fileSizeFormat;
    }

}

ps:文件缓存在本地,设置缓存上界,当达到了阈值时进行删除文件操作,文件删除根据访问次数,每次文件访问记录访问历史,根据访问历史来进行清除;其它配置字段如下:

###配置本地文件缓存位置-以/结尾【对象存储才会缓存到本地】
ok.resource.file.cache.location=/data/oneclick/video/

###配置本地视频缓存文件大小,默认5G;超过大小根据访问次数进行清理
ok.resource.file.cache.restricted.size=5

五、功能实现图

SpringBoot Java实现Http方式分片下载断点续传+实现H5大视频渐进式播放
SpringBoot Java实现Http方式分片下载断点续传+实现H5大视频渐进式播放

相关标签: web java