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

该博客介绍了如何使用SpringBoot实现HTTP分片下载和断点续传,以解决H5页面大视频播放的问题。通过创建本地缓存、处理HTTP头部信息如Range和Content-Range,实现了渐进式播放,减少了内存负担。同时,配置了文件缓存大小限制和定时清理策略,确保资源管理的有效性。
摘要由CSDN通过智能技术生成

一、功能目的
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: love1208tt@foxmail.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);
            //warning:判断是否到了最后不足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:需要注意的几个方面:

Http状态码为206,表示获取部分内容;
Content-Range,格式为[要下载的开始位置]-[结束位置]/[文件总大小];
Accept-Ranges 设置为bytes;
可以参考:
用 Java 实现断点续传 (HTTP)
Content-disposition中Attachment和inline的区别
HTTP headers
————————————————
版权声明:本文为CSDN博主「进修的CODER」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lovequanquqn/article/details/104562945

三、前面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: love1208tt@foxmail.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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值