一、功能目的
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