1.断点续传
- 断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
- 通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。
断点续传流程如下图:
流程如下:
- 前端上传前先把文件分成块。
- 一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传。
- 各分块上传完成最后在服务端合并文件。
2.分块与合并测试
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
2.1 分块测试
2.1.1 流程分析
文件分块的流程如下:
- 获取源文件长度
- 根据设定的分块文件的大小计算出块数
- 从源文件读数据依次向每一个块文件写数据。
2.1.2 代码实现
import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; /** * 分块文件测试 * * @author 狐狸半面添 * @create 2023-02-07 17:14 */ public class BigFileChunkDemo { public static void main(String[] args) throws IOException { // 1.指定要进行分块的源文件 File sourceFile = new File("D:\\SystemDefault\\video\\不为谁而作的歌.mp4"); // 2.指定分块文件存储路径 File chunkFolderPath = new File("D:\\SystemDefault\\video\\chunk\\"); // chunk文件夹不存在则创建 if (!chunkFolderPath.exists()) { chunkFolderPath.mkdirs(); } // 3.分块的大小 - 10MB int chunkSize = 1024 * 1024 * 10; // 4.根据分块大小得到源文件的分块数量(向上转型) long chunkNum = (long) Math.ceil(sourceFile.length() * 1.0 / chunkSize); /* 分块思路:使用流对象读取源文件,向分块文件写数据,达到分块大小不再写 */ // 5.使用流对象 rafRead 读取源文件 RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r"); // 6.设置每次读取的缓冲区大小 byte[] b = new byte[1024]; RandomAccessFile rafWrite; // 7.开始分块 for (long i = 0; i < chunkNum; i++) { // 7.1 指定分块文件 File file = new File("D:\\SystemDefault\\video\\chunk\\" + i); // 7.2 如果分块文件存在,则删除 if (file.exists()) { file.delete(); } // 7.3 创建一个空的分块文件 boolean newFile = file.createNewFile(); if (newFile) { // 7.4 向分块文件写数据流对象 rafWrite = new RandomAccessFile(file, "rw"); int len = -1; // 7.5 读取源文件,每次读取的大小为设置的缓冲区的大小 while ((len = rafRead.read(b)) != -1) { // 7.6 将缓冲区的数据写入到分块文件中 rafWrite.write(b, 0, len); // 7.7 达到分块大小不再写了,继续下一次循环,将后面的数据写入新的分块文件中 if (file.length() >= chunkSize) { break; } } // 7.8 关闭该分块文件流,释放资源 rafWrite.close(); } } // 8.分块完成,关闭源文件流,释放资源 rafRead.close(); System.out.println("分块文件完成"); } }
2.2 合并测试
2.2.1 流程分析
- 找到要合并的文件并按文件合并的先后进行排序。
- 创建合并文件。
- 依次从合并的文件中读取数据向合并文件写入数。
2.2.2 代码实现
<dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.13</version> </dependency>
import org.apache.commons.codec.digest.DigestUtils; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.RandomAccessFile; import java.util.Arrays; import java.util.List; /** * 合并文件测试 * * @author 狐狸半面添 * @create 2023-02-07 17:37 */ public class BigFileMergeDemo { public static void main(String[] args) throws IOException { // 1.指定源文件 - 目的是后面比较合并后的文件与源文件是否相同 File sourceFile = new File("D:\\SystemDefault\\video\\不为谁而作的歌.mp4"); // 2.指定分块文件存储路径 File chunkFolderPath = new File("D:\\SystemDefault\\video\\chunk\\"); // 3.指定并创建合并后的文件 File mergeFile = new File("D:\\SystemDefault\\video\\不为谁而作的歌(合并).mp4"); boolean success = mergeFile.createNewFile(); if (!success) { System.out.println("error: 文件创建失败"); return; } /* 思路:使用流对象读取分块文件,按顺序将分块文件依次向合并文件写数据 */ // 4.获取分块文件列表,按文件名升序排序 // 4.1 获取分块文件列表 File[] chunkFiles = chunkFolderPath.listFiles(); if (chunkFiles == null) { System.out.println("error: 分块文件列表为空"); return; } // 4.2 由数组变为list集合 List<File> chunkFileList = Arrays.asList(chunkFiles); // 4.3 按文件名升序排序 chunkFileList.sort((o1, o2) -> Integer.parseInt(o1.getName()) - Integer.parseInt(o2.getName())); // 5.创建合并文件的流对象 RandomAccessFile rafWrite = new RandomAccessFile(mergeFile, "rw"); // 6.设置每次读取的缓冲区大小 byte[] b = new byte[1024]; // 7.逐一读取分块文件,将数据写入到合并文件中 for (File file : chunkFileList) { // 7.1 获取读取分块文件的流对象 RandomAccessFile rafRead = new RandomAccessFile(file, "r"); int len = -1; // 7.2 读取当前分块文件,每次读取的大小为设置的缓冲区的大小,直到将当前分块文件读取完毕 while ((len = rafRead.read(b)) != -1) { // 7.3 将缓冲区数据写入到合并文件中 rafWrite.write(b, 0, len); } // 7.4 关闭分块文件流对象,释放资源 rafRead.close(); } // 8.关闭流对象,释放资源 rafWrite.close(); // 9.校验合并后的文件是否正确 FileInputStream sourceFileStream = new FileInputStream(sourceFile); FileInputStream mergeFileStream = new FileInputStream(mergeFile); String sourceMd5Hex = DigestUtils.md5Hex(sourceFileStream); String mergeMd5Hex = DigestUtils.md5Hex(mergeFileStream); if (sourceMd5Hex.equals(mergeMd5Hex)) { System.out.println("合并成功"); } } }
3.前后端分离上传视频流程分析
- 前端上传文件前请求媒资接口层检查文件是否存在,如果已经存在则不再上传。
- 如果文件在系统不存在则前端开始上传,首先对视频文件进行分块。
- 前端分块进行上传,上传前首先检查分块是否上传,如已上传则不再上传,如果未上传则开始上传分块。
- 前端请求媒资管理接口层请求上传分块。
- 接口层请求服务层上传分块。
- 服务端将分块信息上传到MinIO。
- 前端将分块上传完毕请求接口层合并分块。
- 接口层请求服务层合并分块。
- 服务层根据文件信息找到MinIO中的分块文件,下载到本地临时目录,将所有分块下载完毕后开始合并 。
- 合并完成将合并后的文件上传到MinIO。
4.实战开发 - 思路分析
- 前端准备上传一个视频文件,需要先发送请求——检查文件是否已存在,携带参数为文件的Md5十六进制值。
- 后端根据 md5十六进制值 去数据库查询该文件是否存在。
- 如果存在,则将文件信息加密返回,前端拿到加密信息再发送其它请求保存到数据库如课程资源表中。流程结束。
- 如果不存在,就提醒前端不存在该文件,前端接下来就需要进行分块上传处理。
- 前端发现不存在该文件,就将文件进行分块,每上传一个分块文件前,都需要发送请求——检查当前分块文件是否存在,携带参数为文件的Md5十六进制值以及当前分块索引(第几块,从0开始)。
- 后端就根据参数去 minio 中查找是否存在该分块文件。
- 如果存在了,就告诉前端不需要上传该分块文件了。
- 如果不存在,就告诉前端需要发送请求上传分块文件。
- 前端发现不存在该分块文件,就发送请求——上传分块文件,携带参数为文件的分块文件,Md5十六进制值以及当前分块索引(第几块,从0开始)。
- 后端根据参数将 分块文件 保存在 minio 中。
- 所有分块文件上传完毕,前端发送请求——合并分块文件与上传合并后的文件,携带参数为文件的Md5十六进制值,文件名,文件标签,文件块总数。
- 后端就根据参数进行合并与上传处理:
- 从 minio 下载所有原文件的分块文件。
- 将分块文件进行合并处理。
- 计算合并后文件的md5值,如果和前端参数中的md5值一致,则说明正确合并。否则上传失败,流程结束。
- 再将合并后的文件断点续传到 minio。
- 将文件信息保存至数据库中。
- 最后将文件信息加密返回,前端拿到加密信息再发送其它请求保存到数据库如课程资源表中。流程结束。
5.实战开发 - 准备工作
5.1 数据库设计
CREATE TABLE service_media_file( `id` BIGINT UNSIGNED PRIMARY KEY COMMENT '主键id(雪花算法)', `file_name` VARCHAR(255) NOT NULL COMMENT '文件名称', `file_type` CHAR(2) NOT NULL COMMENT '文件类型:文本,图片,音频,视频,其它', `file_format` VARCHAR(128) NOT NULL COMMENT '文件格式', `tag` VARCHAR(32) NOT NULL COMMENT '标签', `bucket` VARCHAR(32) NOT NULL COMMENT '存储桶', `file_path` VARCHAR(512) NOT NULL COMMENT '文件存储路径', `file_md5` CHAR(32) NOT NULL UNIQUE COMMENT '文件的md5值', `file_byte_size` BIGINT UNSIGNED NOT NULL COMMENT '文件的字节大小', `file_format_size` VARCHAR(24) NOT NULL COMMENT '文件的格式大小', `user_id` BIGINT NOT NULL COMMENT '上传人id', `create_time` DATETIME NOT NULL COMMENT '创建时间(上传时间)', `update_time` DATETIME NOT NULL COMMENT '修改时间' )ENGINE = INNODB CHARACTER SET = utf8mb4 COMMENT '第三方服务-媒资文件表';
5.2 核心 pom.xml
<!--根据扩展名取mimetype--> <dependency> <groupId>com.j256.simplemagic</groupId> <artifactId>simplemagic</artifactId> <version>1.17</version> </dependency> <!--对象存储服务--> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.2.1</version> </dependency> <dependency> <groupId>me.tongfei</groupId> <artifactId>progressbar</artifactId> <version>0.5.3</version> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.8.1</version> </dependency> <!--生成文件对象的md5十六进制值--> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.13</version> </dependency> <!--用于文件类型判断--> <dependency> <groupId>org.apache.tika</groupId> <artifactId>tika-core</artifactId> <version>2.4.0</version> </dependency>
5.3 application.yaml核心配置
spring: servlet: multipart: max-file-size: 3MB max-request-size: 5MB minio: # 指定连接的ip和端口 endpoint: http://192.168.65.129:9000 # 指定 访问秘钥(也称用户id) accessKey: minioadmin # 指定 私有秘钥(也称密码) secretKey: minioadmin
5.4 MinioConfig.java配置类
package com.zhulang.waveedu.service.config; import io.minio.MinioClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author 狐狸半面添 * @create 2023-02-08 16:26 */ @Configuration public class MinioConfig { /** * 连接的ip和端口 */ @Value("${minio.endpoint}") private String endpoint; /** * 访问秘钥(也称用户id) */ @Value("${minio.accessKey}") private String accessKey; /** * 私有秘钥(也称密码) */ @Value("${minio.secretKey}") private String secretKey; @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }