【万字长文】Vue+SpringBoot实现大文件秒传、断点续传和分片上传完整教程(提供Gitee源码)

news/2024/11/17 20:30:20/

前言:最近在实际项目中碰到一个需求,客户可能会上传比较大的文件,如果采用传统的文件上传方案可能会存在服务器压力大、资源浪费甚至内存溢出的一些安全风险,所以为了解决一系列问题,需要采用新的技术方案来实现大文件的上传;空闲的时候参考了网上的一些相关教程,最后自己归纳总结了一下,本篇博客我就逐步讲解了我是如何一步步去实现大文件秒传、断点续传和分片的这三个功能的,每段代码都会进行讲解,在博客的最后我会提供Gitee源码供大家下载。

目录

一、为什么要使用该技术方案 

二、什么是秒传

三、什么是断点续传 

四、什么是分片上传

五、搭建SpringBoot项目

5.1、准备工作 

5.1.1、导入pom依赖

5.1.2、yml配置文件

5.1.3、mybatis-config.xml配置文件

5.1.4、SQL文件

5.2、常量类 

5.2.1、HttpStatus常见返回状态码常量

5.3、实体类

5.3.1、AjaxResult统一结果封装

5.3.2、文件切片

5.4、GlobalCorsConfig全局CORS跨域配置

5.5、持久层

5.5.1、FileChunkMapper.xml文件

5.5.2、FileChunkMapper文件

5.6、文件上传核心服务类(重要)

5.6.1、文件上传检查的逻辑

5.6.2、实现分片上传核心逻辑

5.6.3、文件分片处理

5.6.4、完整代码

5.7、FileController请求层

六、搭建Vue项目

6.1、准备工作

6.2、HomeView页面 

6.2.1、使用uploader组件

6.2.2、初始化data数据

6.2.3、onFileAdded方法

6.2.4、getFileMD5方法

6.2.5、完整代码

七、运行项目

7.1、分片上传

7.2、断点续传 

7.3、秒传 

八、Gitee源码地址

九、总结


一、为什么要使用该技术方案 

如果前端一次性上传一个非常大的文件(如1G),不采用分片/断点续传等技术方案,主要会面临以下几个隐患或问题:

1、网络传输速度慢

上传时间长大文件einmal性完整上传需要占用持续稳定的上行带宽,如果网络条件不好,上传会非常慢,损耗用户体验。

2、中间失败需重新上传

上传过程中如果由于网络等原因发生中断,整个传输会失败。这就需要用户重新再上传一遍完整文件,重复劳动。

3、服务器压力大

服务端需要占用较多资源持续处理一个大文件,对服务器性能压力较大,可能影响到其他服务。

4、流量资源浪费

一次完整上传大文件,如果遇到已经存在相同文件,会重复消耗大量网络流量,是数据浪费。

5、难以实现上传进度提示

用户无法感知上传进度,如果上传失败也不知道已经上传了多少数据。

所以为了解决这些问题,使用分片、断点续传等技术就非常重要。它可以分批次上传数据块,避免一次性全量上传的弊端。同时结合校验、记录已上传分片等手段,可以使整个上传过程可控、可恢复、节省流量,大幅提升传输效率。

二、什么是秒传

我就以本项目通俗易懂的来讲解一下秒传的实现逻辑。

1、客户端vue在上传文件时,先计算该文件的md5值,然后将md5值发送到springboot服务器。

2springboot服务器收到md5值后,使用mybatis查询mysql数据库,检查是否已存在相同md5值的文件。

3、如果存在,表示该文件已上传过,服务器直接从数据库查询到该文件存在哪些分片,并返回给客户端。

4、客户端拿到文件分片信息后,会直接组装完整的文件,而不再上传实际文件内容。

5、如果数据库不存在该md5值,表示文件未上传过,服务器会返回需要客户端上传整个文件。

6、客户端上传完文件后,服务器才会在mysql数据库中新增该文件与md5的对应关系,以及存储文件分片信息。

7、下次再上传同样文件时,通过md5值就可以实现秒传了。

所以核心就是利用mysql数据库记录每个文件的md5和分片信息,在上传时通过md5查询mysql以判断是否允许秒传,从而避免重复上传相同文件。

三、什么是断点续传 

接着我再以本项目通俗易懂讲解一下断点续传的概念。

1、前端Vue在上传文件时,将文件切成多个小块,每次上传一个小块。

2、每上传一个小块,后端SpringBoot会记录这个小块的信息,比如该小块的序号、文件MD5、内容Hash等。可以保存在MySQL数据库中。

3、如果上传中断了,Vue端可以向SpringBoot询问已经上传了哪些小块。

4、SpringBoot从数据库中查询,返回已上传小块的信息给Vue。

5、Vue就可以接着只上传中间中断的那一部分小块。

6、SpringBoot会根据小块的序号、文件MD5来把这些小块重新拼接成完整的文件。

7、以后如果这个文件再次上传,通过MD5值就可以知道该文件已经存在了,则直接返回上传成功,无需上传实际内容。这样通过切片上传和持久化记录已上传切片的信息,就可以实现断点续传。

关键是SpringBoot要提供接口记录和获取已上传切片信息,Vue端要切片并按顺序上传,最后SpringBoot拼文件,简单的来说就是由于网络等原因导致上传中断,通过记录已传输的数据量,在中断后继续上传剩余数据的一种技术方案。

四、什么是分片上传

最后我还是结合本项目通俗易懂讲解一下分片上传的概念。

1、分片上传的目的是将大文件切割为多个小块,实现并发上传以提高传输速度。

2、可以按配置的分片大小(例如50M一个分片)将大文件分割。

3、Vue项目将切割的每个分片按顺序上传至SpringBoot服务器,然后一块块按顺序进行上传。

4、SpringBoot服务器收到分片后可以暂存于本地,并记录这个分片的特征信息,如分片序号、文件MD5等,写入到数据库。

5、全部分片上传完成后,SpringBoot按序号顺序重新组装成原完整文件。

五、搭建SpringBoot项目

接下来我会一步步阐述如何搭建这个项目的后端,这是完整的项目截图。

5.1、准备工作 

5.1.1、导入pom依赖

这就是后端完整的依赖信息。

完整代码:

<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- 常用工具类 --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId></dependency><!-- MySQL依赖 --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.29</version></dependency><!-- Mybatis依赖 --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.2.2</version></dependency><!-- Lombok依赖 --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies>

5.1.2、yml配置文件

主要配置后端端口为909、文件单次限制最大为100MB、MySQL的配置信息以及MyBatis的配置信息。

完整代码:

server:port: 9090spring:servlet:multipart:max-request-size: 100MBmax-file-size: 100MBdatasource:username: 用户名password: 密码url: jdbc:mysql://localhost:3306/数据库名?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTCdriver-class-name: com.mysql.cj.jdbc.Driver# MyBatis配置
mybatis:# 搜索指定包别名typeAliasesPackage: com.example.**.domain# 配置mapper的扫描,找到所有的mapper.xml映射文件mapperLocations: classpath:mapping/*.xml# 加载全局的配置文件configLocation: classpath:mybatis/mybatis-config.xml

5.1.3、mybatis-config.xml配置文件

在resource文件夹下面新建一个mybatis文件夹,用于存放mybatis的配置文件。

完整代码:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configurationPUBLIC "-//mybatis.org//DTD Config 3.0//EN""http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration><!-- 全局参数 --><settings><!-- 使全局的映射器启用或禁用缓存 --><setting name="cacheEnabled"             value="true"   /><!-- 允许JDBC 支持自动生成主键 --><setting name="useGeneratedKeys"         value="true"   /><!-- 配置默认的执行器.SIMPLE就是普通执行器;REUSE执行器会重用预处理语句(prepared statements);BATCH执行器将重用语句并执行批量更新 --><setting name="defaultExecutorType"      value="SIMPLE" /><!-- 指定 MyBatis 所用日志的具体实现 --><setting name="logImpl"                  value="SLF4J"  /><!-- 使用驼峰命名法转换字段 --><setting name="mapUnderscoreToCamelCase" value="true"/></settings></configuration>

5.1.4、SQL文件

-- upload.file_chunk definitionCREATE TABLE `file_chunk` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',`identifier` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8_general_ci NOT NULL COMMENT '文件md5',`chunk_number` int NOT NULL COMMENT '当前分块序号',`chunk_size` bigint NOT NULL COMMENT '分块大小',`current_chunk_size` bigint NOT NULL COMMENT '当前分块大小',`total_size` bigint NOT NULL COMMENT '文件总大小',`total_chunks` int NOT NULL COMMENT '分块总数',`filename` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8_general_ci NOT NULL COMMENT '文件名',`create_time` datetime NOT NULL COMMENT '创建时间',`update_time` datetime DEFAULT NULL COMMENT '更新时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='文件分片表';

5.2、常量类 

5.2.1、HttpStatus常见返回状态码常量

完整代码:

package com.example.bigupload.constant;/*** 返回状态码* @author HTT*/
public class HttpStatus
{/*** 操作成功*/public static final int SUCCESS = 200;/*** 对象创建成功*/public static final int CREATED = 201;/*** 请求已经被接受*/public static final int ACCEPTED = 202;/*** 操作已经执行成功,但是没有返回数据*/public static final int NO_CONTENT = 204;/*** 资源已被移除*/public static final int MOVED_PERM = 301;/*** 重定向*/public static final int SEE_OTHER = 303;/*** 资源没有被修改*/public static final int NOT_MODIFIED = 304;/*** 参数列表错误(缺少,格式不匹配)*/public static final int BAD_REQUEST = 400;/*** 未授权*/public static final int UNAUTHORIZED = 401;/*** 访问受限,授权过期*/public static final int FORBIDDEN = 403;/*** 资源,服务未找到*/public static final int NOT_FOUND = 404;/*** 不允许的http方法*/public static final int BAD_METHOD = 405;/*** 资源冲突,或者资源被锁*/public static final int CONFLICT = 409;/*** 不支持的数据,媒体类型*/public static final int UNSUPPORTED_TYPE = 415;/*** 系统内部错误*/public static final int ERROR = 500;/*** 接口未实现*/public static final int NOT_IMPLEMENTED = 501;
}

5.3、实体类

5.3.1、AjaxResult统一结果封装

完整代码:

package com.example.bigupload.domain;import com.example.bigupload.constant.HttpStatus;
import org.apache.commons.lang3.ObjectUtils;import java.util.HashMap;/*** 操作消息提醒** @author HTT*/
public class AjaxResult extends HashMap<String, Object>
{private static final long serialVersionUID = 1L;/** 状态码 */public static final String CODE_TAG = "code";/** 返回内容 */public static final String MSG_TAG = "msg";/** 数据对象 */public static final String DATA_TAG = "data";/*** 初始化一个新创建的 AjaxResult 对象,使其表示一个空消息。*/public AjaxResult(){}/*** 初始化一个新创建的 AjaxResult 对象** @param code 状态码* @param msg 返回内容*/public AjaxResult(int code, String msg){super.put(CODE_TAG, code);super.put(MSG_TAG, msg);}/*** 初始化一个新创建的 AjaxResult 对象** @param code 状态码* @param msg 返回内容* @param data 数据对象*/public AjaxResult(int code, String msg, Object data){super.put(CODE_TAG, code);super.put(MSG_TAG, msg);if (ObjectUtils.isNotEmpty(data)){super.put(DATA_TAG, data);}}/*** 返回成功消息** @return 成功消息*/public static AjaxResult success(){return AjaxResult.success("操作成功");}/*** 返回成功数据** @return 成功消息*/public static AjaxResult success(Object data){return AjaxResult.success("操作成功", data);}/*** 返回成功消息** @param msg 返回内容* @return 成功消息*/public static AjaxResult success(String msg){return AjaxResult.success(msg, null);}/*** 返回成功消息** @param msg 返回内容* @param data 数据对象* @return 成功消息*/public static AjaxResult success(String msg, Object data){return new AjaxResult(HttpStatus.SUCCESS, msg, data);}/*** 返回错误消息** @return*/public static AjaxResult error(){return AjaxResult.error("操作失败");}/*** 返回错误消息** @param msg 返回内容* @return 警告消息*/public static AjaxResult error(String msg){return AjaxResult.error(msg, null);}/*** 返回错误消息** @param msg 返回内容* @param data 数据对象* @return 警告消息*/public static AjaxResult error(String msg, Object data){return new AjaxResult(HttpStatus.ERROR, msg, data);}/*** 返回错误消息** @param code 状态码* @param msg 返回内容* @return 警告消息*/public static AjaxResult error(int code, String msg){return new AjaxResult(code, msg, null);}/*** 方便链式调用** @param key 键* @param value 值* @return 数据对象*/@Overridepublic AjaxResult put(String key, Object value){super.put(key, value);return this;}
}

5.3.2、文件切片

完整代码:

package com.example.bigupload.domain;import lombok.Data;
import org.springframework.web.multipart.MultipartFile;import java.io.Serializable;
import java.util.Date;@Data
public class FileChunk implements Serializable {/*** 主键*/private Long id;/*** 文件 md5*/private String identifier;/*** 当前分块序号*/private Integer chunkNumber;/*** 分块大小*/private Long chunkSize;/*** 当前分块大小*/private Long currentChunkSize;/*** 文件总大小*/private Long totalSize;/*** 分块总数*/private Integer totalChunks;/*** 文件名*/private String filename;/*** 创建时间*/private Date createTime;/*** 文件分片数据*/private MultipartFile file;}

5.4、GlobalCorsConfig全局CORS跨域配置

1、@Configuration注解表示这是一个配置类。

2、WebMvcConfigurer接口用于自定义SpringMVC的配置。

3、addCorsMappings方法用于定义跨域访问策略。

4、addMapping("/**")表示拦截所有请求路径。

5、allowedOriginPatterns("*")表示允许所有域名的请求。

6、allowCredentials(true)表示允许携带cookie。

7、allowedHeaders表示允许的请求头。

8、allowedMethods表示允许的请求方法。

9、maxAge(3600)表示预检请求的有效期为3600秒。

这样就实现了一个全局的CORS跨域配置,允许所有域名的请求访问本服务,可以自由设置请求头和方法,最大有效期为1小时。

完整代码:

package com.example.bigupload.config;import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration
public class GlobalCorsConfig implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOriginPatterns("*").allowCredentials(true).allowedHeaders("GET", "POST", "PUT", "DELETE", "OPTIONS").allowedHeaders("Authorization", "Cache-Control", "Content-Type").maxAge(3600);}
}

5.5、持久层

5.5.1、FileChunkMapper.xml文件

主要撰写了个2个SQL,一个是根据md5加密信息去查询数据库的所有分片信息,还有个便是记录每次分片上传成功的文件信息。

完整代码:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bigupload.mapper.FileChunkMapper"><select id="findFileChunkParamByMd5" resultType="com.example.bigupload.domain.FileChunk">SELECT * FROM file_chunk where identifier =#{identifier}</select><insert id="insertFileChunk" parameterType="com.example.bigupload.domain.FileChunk">INSERT INTO file_chunk<trim prefix="(" suffix=")" suffixOverrides=","><if test="identifier != null">identifier,</if><if test="chunkNumber != null">chunk_number,</if><if test="chunkSize != null">chunk_size,</if><if test="currentChunkSize != null">current_chunk_size,</if><if test="totalSize != null">total_size,</if><if test="totalChunks != null">total_chunks,</if><if test="filename != null">filename,</if><if test="createTime != null">create_time,</if></trim><trim prefix="VALUES (" suffix=")" suffixOverrides=","><if test="identifier != null">#{identifier},</if><if test="chunkNumber != null">#{chunkNumber},</if><if test="chunkSize != null">#{chunkSize},</if><if test="currentChunkSize != null">#{currentChunkSize},</if><if test="totalSize != null">#{totalSize},</if><if test="totalChunks != null">#{totalChunks},</if><if test="filename != null">#{filename},</if><if test="createTime != null">#{createTime},</if></trim></insert>
</mapper>

5.5.2、FileChunkMapper文件

和xml文件一一对应的2个接口,交给MyBatis去映射。

完整代码:

package com.example.bigupload.mapper;import com.example.bigupload.domain.FileChunk;
import org.apache.ibatis.annotations.Mapper;import java.util.List;/*** @author HTT*/
@Mapper
public interface FileChunkMapper {public List<FileChunk> findFileChunkParamByMd5(String identifier);public int insertFileChunk(FileChunk fileChunk);
}

5.6、文件上传核心服务类(重要)

5.6.1、文件上传检查的逻辑

这段代码是文件上传检查的逻辑,主要是检查该文件是否已经存在于服务器,实现秒传的效果。

具体逻辑:

1、根据文件标识identifier(md5)从数据库查询是否已存在该文件。

List<FileChunk> list = fileChunkMapper.findFileChunkParamByMd5(fileChunk.getIdentifier());
Map<String, Object> data = new HashMap<>(1);

2、如果查询不到,表示文件未上传过,返回uploaded=false。

if (list == null || list.size() == 0) {data.put("uploaded", false);return AjaxResult.success("文件上传成功",data);
}

3、如果查询到只有一片,则表示整个文件已上传,返回uploaded=true。

if (list.get(0).getTotalChunks() == 1) {data.put("uploaded", true);data.put("url", "");return AjaxResult.success("文件上传成功",data);
}

4、如果查询到多片数据,则表示这是一个分片上传的大文件。

5、遍历这些分片数据,获取每个文件块的编号保存到uploadedFiles数组中。

6、最后返回uploadedChunks给前端。

// 处理分片
int[] uploadedFiles = new int[list.size()];
int index = 0;
for (FileChunk fileChunkItem : list) {uploadedFiles[index] = fileChunkItem.getChunkNumber();index++;
}
data.put("uploadedChunks", uploadedFiles);
return AjaxResult.success("文件上传成功",data);

7、前端拿到这些数据后,就知道该大文件已上传了些什么分片,还剩什么分片需要上传。

8、然后继续断点上传剩余分片,实现断点续传的效果所以这段代码主要是通过查询文件数据库,判断该文件是否全部或部分已存在,从而判断是否需要上传完整文件或者继续断点上传。

这样可以避免重复上传,提高传输效率,是实现秒传和断点续传的关键逻辑。

关键代码:

    public AjaxResult checkUpload(FileChunk fileChunk) {List<FileChunk> list = fileChunkMapper.findFileChunkParamByMd5(fileChunk.getIdentifier());Map<String, Object> data = new HashMap<>(1);// 判断文件存不存在if (list == null || list.size() == 0) {data.put("uploaded", false);return AjaxResult.success("文件上传成功",data);}// 处理单文件if (list.get(0).getTotalChunks() == 1) {data.put("uploaded", true);data.put("url", "");return AjaxResult.success("文件上传成功",data);}// 处理分片int[] uploadedFiles = new int[list.size()];int index = 0;for (FileChunk fileChunkItem : list) {uploadedFiles[index] = fileChunkItem.getChunkNumber();index++;}data.put("uploadedChunks", uploadedFiles);return AjaxResult.success("文件上传成功",data);}

5.6.2、实现分片上传核心逻辑

这段代码是使用RandomAccessFile实现分片上传写入的逻辑。

具体逻辑:

1、创建一个RandomAccessFile对象,根据文件路径进行存放,模式为读写rw。

RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "rw");

2、计算每个分片的大小chunkSize,如果前端没有传入,则使用默认值(50MB,是我定义的常量)。

public static final long DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024;
long chunkSize = fileChunk.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : fileChunk.getChunkSize().longValue();

3、计算当前分片的偏移量offset,通过(分片序号-1)*分片大小来计算。

long offset = chunkSize * (fileChunk.getChunkNumber() - 1);

4、通过seek()方法定位到该分片的偏移量位置。

randomAccessFile.seek(offset);

5、通过write()方法将当前分片的文件内容bytes写入。

randomAccessFile.write(fileChunk.getFile().getBytes());

6、重复该过程,直到所有分片都写入完成。

7、最后关闭文件。

randomAccessFile.close();

RandomAccessFile可以以任意位置读写文件,所以可以按分片顺序写入指定位置,实现分片上传的效果。这种方式可以充分利用操作系统的文件缓存,比较高效。每个分片只写入一次,不需要再读取修改文件,节约了IO操作。这就是通过RandomAccessFile实现分片上传的常用方式,把各个分片拼接成完整的文件。

下面我通过介绍一个简单的实际例子来通俗易懂的讲解一下:

根据这段关键代码:

// 计算每个分片大小 
long chunkSize = fileChunk.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : fileChunk.getChunkSize().longValue();// 计算分片的偏移量
long offset = chunkSize * (fileChunk.getChunkNumber() - 1); 

offset = 分片大小 * (分片序号 - 1)

以230MB文件为例:

1、分片大小chunkSize是50MB.

2、第一片分片号fileChunk、getChunkNumber()是1。

3、那么第一片偏移量offset = 50MB * (1 - 1) = 0。

第二片分片号是2,那么第二片偏移量offset = 50MB * (2 - 1) = 50MB。以此类推,可以计算出每一片的偏移量。

第n片的偏移量公式就是: offset = 分片大小 * (第n片分片序号 - 1)。

所以完整的执行过程就是这样:

230MB这个文件会被切分成5个分片:

1、第一个分片:50MB,偏移量为0。

2、第二个分片:50MB,偏移量为50MB。

3、第三个分片:50MB,偏移量为100MB。

4、第四个分片:50MB,偏移量为150MB。

5、第五个分片:30MB,偏移量为200MB。

客户端可以并发上传这5个分片:

1、上传第一个分片,写入偏移量0处。

2、上传第二个分片,写入偏移量50MB处。

3、上传第三个分片,写入偏移量100MB处。

4、上传第四个分片,写入偏移量150MB处。

5、上传第五个分片,写入偏移量200MB处。

服务器端通过RandomAccessFile可以按照偏移量直接写入每个分片的内容。这样就可以通过5个50MB的分片很快上传完成这个230MB的大文件,实现了分片上传的效果,提高了传输速度。

关键代码:

    private boolean uploadFileByRandomAccessFile(String resultFileName, FileChunk fileChunk) {try {RandomAccessFile randomAccessFile = new RandomAccessFile(resultFileName, "rw");// 分片大小必须和前端匹配,否则上传会导致文件损坏long chunkSize = fileChunk.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : fileChunk.getChunkSize().longValue();// 偏移量long offset = chunkSize * (fileChunk.getChunkNumber() - 1);// 定位到该分片的偏移量randomAccessFile.seek(offset);// 写入randomAccessFile.write(fileChunk.getFile().getBytes());randomAccessFile.close();} catch (IOException e) {log.error("文件上传失败:" + e);return false;}return true;}

5.6.3、文件分片处理

1、构造上传文件在服务器存放的完整路径filePath,这里是D盘的一个目录。

2、调用uploadFileByRandomAccessFile方法,以filePath为参数,写入该分片的文件数据。

3、如果写入失败,返回上传失败的结果。

4、如果写入成功,设置分片的创建时间,并保存分片信息到数据库中。

5、返回上传成功结果。

这里最关键的就是uploadFileByRandomAccessFile这个方法,它实现了对文件的分片随机读写,可以把上传的文件分片数据写入到正确的偏移位置。 

关键代码:

    public AjaxResult uploadChunkFile(FileChunk fileChunk) throws Exception {String filePath = "D:\\大文件分片存放\\" + fileChunk.getFilename();boolean flag = uploadFileByRandomAccessFile(filePath, fileChunk);if (!flag) {return AjaxResult.error("文件上传失败");}fileChunk.setCreateTime(new Date());fileChunkMapper.insertFileChunk(fileChunk);return AjaxResult.success("文件上传成功");}

5.6.4、完整代码

package com.example.bigupload.service;import com.example.bigupload.domain.AjaxResult;
import com.example.bigupload.domain.FileChunk;
import com.example.bigupload.mapper.FileChunkMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;/*** @author HTT*/
@Slf4j
@Service
public class UploadService {/*** 默认的分片大小:50MB*/public static final long DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024;@Resourceprivate FileChunkMapper fileChunkMapper;public AjaxResult checkUpload(FileChunk fileChunk) {List<FileChunk> list = fileChunkMapper.findFileChunkParamByMd5(fileChunk.getIdentifier());Map<String, Object> data = new HashMap<>(1);// 判断文件存不存在if (list == null || list.size() == 0) {data.put("uploaded", false);return AjaxResult.success("文件上传成功",data);}// 处理单文件if (list.get(0).getTotalChunks() == 1) {data.put("uploaded", true);data.put("url", "");return AjaxResult.success("文件上传成功",data);}// 处理分片int[] uploadedFiles = new int[list.size()];int index = 0;for (FileChunk fileChunkItem : list) {uploadedFiles[index] = fileChunkItem.getChunkNumber();index++;}data.put("uploadedChunks", uploadedFiles);return AjaxResult.success("文件上传成功",data);}/*** 上传分片文件* @param fileChunk* @return* @throws Exception*/public AjaxResult uploadChunkFile(FileChunk fileChunk) throws Exception {String filePath = "D:\\大文件分片存放\\" + fileChunk.getFilename();boolean flag = uploadFileByRandomAccessFile(filePath, fileChunk);if (!flag) {return AjaxResult.error("文件上传失败");}fileChunk.setCreateTime(new Date());fileChunkMapper.insertFileChunk(fileChunk);return AjaxResult.success("文件上传成功");}private boolean uploadFileByRandomAccessFile(String filePath, FileChunk fileChunk) {try {RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "rw");// 分片大小必须和前端匹配,否则上传会导致文件损坏long chunkSize = fileChunk.getChunkSize() == 0L ? DEFAULT_CHUNK_SIZE : fileChunk.getChunkSize().longValue();// 偏移量long offset = chunkSize * (fileChunk.getChunkNumber() - 1);// 定位到该分片的偏移量randomAccessFile.seek(offset);// 写入randomAccessFile.write(fileChunk.getFile().getBytes());randomAccessFile.close();} catch (IOException e) {log.error("文件上传失败:" + e);return false;}return true;}
}

5.7、FileController请求层

写的很简洁,之所以都是相同的/upload的接口,请求方式分别是get和post,是因为uploader组件检验的时候先调用/upload的get请求,然后上传分片会调用/upload的post请求。

1、首先用GET请求校验文件是否存在,实现秒传。

2、然后客户端按顺序用POST请求上传各个分片,服务端收到分片后写入文件。

完整代码:

package com.example.bigupload.controller;import com.example.bigupload.domain.AjaxResult;
import com.example.bigupload.domain.FileChunk;
import com.example.bigupload.service.UploadService;
import org.springframework.web.bind.annotation.*;import javax.annotation.Resource;@RestController
@RequestMapping("/file")
public class FileController {@Resourceprivate UploadService uploadService;@GetMapping("/upload")public AjaxResult checkUpload(FileChunk fileChunk){return uploadService.checkUpload(fileChunk);}@PostMapping("/upload")public AjaxResult uploadChunkFile(FileChunk fileChunk) throws Exception {return uploadService.uploadChunkFile(fileChunk);}}

六、搭建Vue项目

这边我使用的是Vue2.0,这是项目完整截图

6.1、准备工作

1、安装uploader

npm install --save vue-simple-uploader

2、按照spark-md5

npm install --save spark-md5

3、在main.js中引入组件

import uploader from 'vue-simple-uploader'
Vue.use(uploader)

6.2、HomeView页面 

6.2.1、使用uploader组件

这个组件使用的是第三方库vue-uploader,它是一个上传组件。

参数解释:

1、ref:给组件一个引用名,这里是uploader。

2、options:上传的选项,例如上传地址、接受的文件类型等。

3、autoStart:是否自动开始上传,默认是true,这里设置为false。

4、fileStatusText:自定义上传状态提示文字。

5、@file-added:文件添加时的钩子。

6、@file-success:文件上传成功时的钩子。

7、@file-error:文件上传失败时的钩子。

8、@file-progress:文件上传进度的钩子。

包含三个子组件:

1、uploader-unsupport:如果浏览器不支持,显示这个组件。

2、uploader-drop:拖拽上传区域组件,可以自定义内容。

3、uploader-btn:上传按钮组件。

4、uploader-files:已选择待上传文件的列表组件。

所以这个组件实现了选择文件、拖拽上传、显示上传进度、回调上传结果等完整的文件上传功能。我们可以通过options来配置上传参数,通过各种钩子来处理上传结果,并可以自定义上传区域的样式,实现一个完整的上传组件。

完整代码:

<template><div class="home"><uploaderref="uploader":options="options":autoStart="false":file-status-text="fileStatusText"@file-added="onFileAdded"@file-success="onFileSuccess"@file-error="onFileError"@file-progress="onFileProgress"class="uploader-example"><uploader-unsupport></uploader-unsupport><uploader-drop><p>将文件拖放到此处以上传</p><uploader-btn>选择文件</uploader-btn></uploader-drop><uploader-files> </uploader-files></uploader><br /></div>
</template>

6.2.2、初始化data数据

先提前引入Md5工具和初始化分片大小。

import SparkMD5 from "spark-md5";
const CHUNK_SIZE = 50 * 1024 * 1024;

options参数:

1、target:上传的接口地址

2、testChunks:是否开启分片上传校验。分片上传是把文件分成小块后并发上传,可以提高大文件上传效率。

3、uploadMethod:上传方法,默认是POST。

4、chunkSize:分片大小,默认1MB。这个可根据实际情况调整,分片太小会增加请求次数,太大上传失败重传就重传很多。

5、simultaneousUploads:并发上传块数,默认是3,可根据需要调整。

6、checkChunkUploadedByResponse:检查分片是否上传完成的方法。它会在响应中解析判断一个分片是否已上传完成。

它的两个参数:

chunk:当前分片的信息,包含偏移offset等数据。

message:上传分片后服务端返回的响应内容函数内部首先把服务端返回的响应内容message解析成JSON对象。

函数内部首先把服务端返回的响应内容message解析成JSON对象。

let messageObj = JSON.parse(message);

然后取出data字段作为数据对象。

let dataObj = messageObj.data;

接着判断data对象中是否包含uploaded字段,如果有直接返回uploaded的值,uploaded字段通常是服务端表示分片是否上传完成的标志。

if (dataObj.uploaded !== undefined) {return dataObj.uploaded;
}

如果没有uploaded字段,则检查data对象的uploadedChunks数组是否包含当前分片的序号,比如offset是5,那么数组应该包含值6,表示第6个分片已上传,这段代码可以用来判断是否还需要继续进行分片上传。

return (dataObj.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;

具体逻辑:

第一步、dataObj.uploadedChunks是一个数组,记录了已经上传成功的分片的偏移量(offset)。

第二步、chunk.offset是当前分片的偏移量。

第三步、chunk.offset+1就是当前分片结束位置后的下一个分片的偏移量。

第四步、如果dataObj.uploadedChunks数组中存在chunk、offset+1这个值,说明当前分片结束位置后的那个分片已经上传过了。

第五步、那么就可以判断不需要再上传当前分片了,返回false。

第六步、反之,如果dataObj.uploadedChunks中不存在chunk.offset+1这个值,说明后续分片还未上传,还需要继续上传当前分片,返回true所以这段代码通过判断当前分片结束位置后是否存在已上传的分片,来决定是否还需要上传当前分片,实现了分片上传的断点续传逻辑。

7、parseTimeRemaining:格式化剩余上传时间的方法。会把原始的时间格式化成xx天xx时xx分xx秒的格式。

parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {return parsedTimeRemaining.replace(/\syears?/, "年").replace(/\days?/, "天").replace(/\shours?/, "小时").replace(/\sminutes?/, "分钟").replace(/\sseconds?/, "秒");
},

所以这些配置主要是为了实现分片上传,通过切块、并发、校验等机制来提高大文件上传效率和体验。

关键代码:

    options: {target: 'http://127.0.0.1:9090/file/upload',testChunks: true,uploadMethod: "post",chunkSize: CHUNK_SIZE,// 并发上传数,默认为 3simultaneousUploads: 3,checkChunkUploadedByResponse: (chunk, message) => {let messageObj = JSON.parse(message);let dataObj = messageObj.data;if (dataObj.uploaded !== undefined) {return dataObj.uploaded;}return (dataObj.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;},parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {return parsedTimeRemaining.replace(/\syears?/, "年").replace(/\days?/, "天").replace(/\shours?/, "小时").replace(/\sminutes?/, "分钟").replace(/\sseconds?/, "秒");},},

其他参数:

参数作用:

1、fileStatus:定义上传状态的文字描述,会用在显示上传状态的组件中。

2、uploadFileList:已上传成功的文件列表,可以用来保存上传成功的文件信息。

关键代码:

fileStatus: {success: "上传成功",error: "上传错误",uploading: "正在上传",paused: "停止上传",waiting: "等待中",
},
uploadFileList: [],

6.2.3、onFileAdded方法

具体逻辑:

1、把添加的文件添加到上传文件列表uploadFileList中。

2、计算文件MD5,并请求后台判断是否已上传过,如果上传过则取消本次上传。

3、此时设置文件对象的唯一标识uniqueIdentifier为该MD5,并调用file.resume()取消上传。

所以这段逻辑是用MD5避免重复上传相同文件,只有MD5不存在时才会继续上传。

关键代码:

    onFileAdded(file) {this.uploadFileList.push(file);// 2. 计算文件 MD5 并请求后台判断是否已上传,是则取消上传this.getFileMD5(file, (md5) => {if (md5 != "") {// 修改文件唯一标识file.uniqueIdentifier = md5;// 恢复上传file.resume();}});},

6.2.4、getFileMD5方法

计算文件上传前的MD5值。

具体逻辑:

1、创建SparkMD5对象spark,用于计算MD5值。

let spark = new SparkMD5.ArrayBuffer();

2、创建FileReader对象fileReader,用于读取文件内容。

let fileReader = new FileReader();

3、获取文件分片blobSlice方法,不同浏览器有不同实现。

let blobSlice =File.prototype.slice ||File.prototype.mozSlice ||File.prototype.webkitSlice;

4、计算分片总数chunks,每片大小是CHUNK_SIZE。

let currentChunk = 0;
let chunks = Math.ceil(file.size / CHUNK_SIZE);

5、记录开始时间startTime。

let startTime = new Date().getTime();

6、暂停上传文件file.pause()。

file.pause();

7、loadNext函数是用来加载文件分片的,

具体逻辑:

第一步、计算当前分片的起始位置start,是当前分片序号currentChunk乘以分片大小CHUNK_SIZE。

第二步、计算当前分片的结束位置end: 如果start+分片大小大于等于总大小file.size,则end就是总大小file.size,否则end就是start+分片大小CHUNK_SIZE。

第三步、通过blobSlice方法获取一个文件的分片Blob对象,它截取文件从start到end的部分。

4、调用FileReader的readAsArrayBuffer方法读取这个Blob分片对象的内容。

5、readAsArrayBuffer会触发FileReader的onload事件,在事件回调中可以获取分片内容并进行后续处理。

所以loadNext的作用就是按照分片大小和序号,获取指定文件的一个分片Blob,然后用FileReader读取这个分片的ArrayBuffer内容。

    function loadNext() {const start = currentChunk * CHUNK_SIZE;const end =start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));}

8、这段代码是FileReader的onload事件回调函数,作用是处理读取分片内容后进行MD5累加计算。

具体逻辑:

1、通过spark.append(e.target.result)将读取到的分片ArrayBuffer内容追加到spark中,进行MD5累加。

2、判断如果分片序号currentChunk还小于总分片数chunks,则说明还有分片未读:currentChunk自增,调用loadNext()加载下一分片。

3、否则,说明所有分片已读取完毕:通过spark、end()计算最终的MD5值md5,打印计算耗时,通过callback回调返回md5值。

4、callback函数会在外部接收到md5,判断文件是否重复并决定后续逻辑。

所以这段代码的作用是递归读取文件分片,累加计算MD5,最后返回MD5结果,完成了整个文件的MD5校验。通过分片加载的方式,避免了一次性加载整个大文件导致的性能问题,实现了高效的MD5计算。

      fileReader.onload = function (e) {spark.append(e.target.result);if (currentChunk < chunks) {currentChunk++;loadNext();} else {let md5 = spark.end();console.log(`MD5计算完毕:${md5},耗时:${new Date().getTime() - startTime} ms.`);callback(md5);}};fileReader.onerror = function () {this.$message.error("文件读取错误");file.cancel();};

关键代码:

getFileMD5(file, callback) {let spark = new SparkMD5.ArrayBuffer();let fileReader = new FileReader();let blobSlice =File.prototype.slice ||File.prototype.mozSlice ||File.prototype.webkitSlice;let currentChunk = 0;let chunks = Math.ceil(file.size / CHUNK_SIZE);let startTime = new Date().getTime();file.pause();loadNext();fileReader.onload = function (e) {spark.append(e.target.result);if (currentChunk < chunks) {currentChunk++;loadNext();} else {let md5 = spark.end();console.log(`MD5计算完毕:${md5},耗时:${new Date().getTime() - startTime} ms.`);callback(md5);}};fileReader.onerror = function () {this.$message.error("文件读取错误");file.cancel();};function loadNext() {const start = currentChunk * CHUNK_SIZE;const end =start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));}},

6.2.5、完整代码

<template><div class="home"><uploaderref="uploader":options="options":autoStart="false":file-status-text="fileStatusText"@file-added="onFileAdded"@file-success="onFileSuccess"@file-error="onFileError"@file-progress="onFileProgress"class="uploader-example"><uploader-drop><p>将文件拖放到此处以上传</p><uploader-btn>选择文件</uploader-btn></uploader-drop><uploader-files> </uploader-files></uploader><br /></div>
</template><script>
import HelloWorld from '@/components/HelloWorld.vue'
import SparkMD5 from "spark-md5";
const CHUNK_SIZE = 50 * 1024 * 1024;
export default {name: 'HomeView',components: {HelloWorld},data() {return {options: {target: 'http://127.0.0.1:9090/file/upload',testChunks: true,uploadMethod: "post",chunkSize: CHUNK_SIZE,simultaneousUploads: 3,checkChunkUploadedByResponse: (chunk, message) => {let messageObj = JSON.parse(message);let dataObj = messageObj.data;if (dataObj.uploaded !== undefined) {return dataObj.uploaded;}return (dataObj.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;},parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {return parsedTimeRemaining.replace(/\syears?/, "年").replace(/\days?/, "天").replace(/\shours?/, "小时").replace(/\sminutes?/, "分钟").replace(/\sseconds?/, "秒");},},fileStatus: {success: "上传成功",error: "上传错误",uploading: "正在上传",paused: "停止上传",waiting: "等待中",},uploadFileList: [],};},methods: {onFileAdded(file) {this.uploadFileList.push(file);// 2. 计算文件 MD5 并请求后台判断是否已上传,是则取消上传this.getFileMD5(file, (md5) => {if (md5 != "") {// 修改文件唯一标识file.uniqueIdentifier = md5;// 恢复上传file.resume();}});},onFileSuccess(rootFile, file, response, chunk) {console.log("上传成功");},onFileError(rootFile, file, message, chunk) {console.log("上传出错:" + message);},onFileProgress(rootFile, file, chunk) {console.log(`当前进度:${Math.ceil(file._prevProgress * 100)}%`);},getFileMD5(file, callback) {let spark = new SparkMD5.ArrayBuffer();let fileReader = new FileReader();let blobSlice =File.prototype.slice ||File.prototype.mozSlice ||File.prototype.webkitSlice;let currentChunk = 0;let chunks = Math.ceil(file.size / CHUNK_SIZE);let startTime = new Date().getTime();file.pause();loadNext();fileReader.onload = function (e) {spark.append(e.target.result);if (currentChunk < chunks) {currentChunk++;loadNext();} else {let md5 = spark.end();console.log(`MD5计算完毕:${md5},耗时:${new Date().getTime() - startTime} ms.`);callback(md5);}};fileReader.onerror = function () {this.$message.error("文件读取错误");file.cancel();};function loadNext() {const start = currentChunk * CHUNK_SIZE;const end =start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));}},fileStatusText(status) {console.log(11111)console.log(status)if (status === "md5") {return "校验MD5";}return this.fileStatus[status];},},
}
</script>

七、运行项目

下面我来实际运行一下本次项目,给大家演示一下效果。

7.1、分片上传

我提前准备了一个大小为198.8MB的文件进行上传,默认切成了3片进行上传。

这是数据库的写入的信息。 

在这里大家有没有很奇怪,为什么198M的文件不会被切成4块,反而只被切成了3块,而且第三块的大小为98MB,下面我来详细讲解一下原因: 

1、文件总大小是198MB,约等于208,490,306字节。

2、分片大小是50MB,约等于52,428,800字节。

3、按分片大小50MB均匀切分,应该切成4块:第1块:50MB、第2块:50MB、第3块:50MB、第4块:48MB(包含余数)。

4、但是该实现的规则是,最后一块可以包含不足分片大小的余数。

5、所以前2块依然按50MB切分,但是最后一块包含余数。

6、前2块已经占用了2*50MB=100MB。

7、那么剩余未切分的就是总大小198MB减去已经切分的100MB,所以最后一块就是这98MB的余数部分。

8、因此,该文件只切分成3块:第1块:50MB、第2块:50MB、第3块:98MB(包含余数)。

综上,按照该规则,198MB文件最后一块包含不足分片大小的余数,这个余数部分正好是198MB减去已经切分的100MB,即98MB。

7.2、断点续传 

有的时候,可能会出现一些网络原因或者是客户自己想留着剩下的到明天再上传。 

下次会继续保留上次的上传进度,直到上传成功为止! 

这是数据库的写入信息 

7.3、秒传 

秒传就很简单了,我把刚才的超大文件再次选择上传一次。 

很明显,并没有进行切片,只调用了一次验证接口,在1s内完成了上传! 

八、Gitee源码地址

这边我把项目完整的代码进行开源,大家可以自行学习!

前端:Vue+SpringBoot实现大文件秒传+断点续传+分片上传: 这是完整的前端代码

后端:Vue+SpringBoot实现大文件秒传+断点续传+分片上传: 这是完整的后端代码

九、总结

以上就是我个人对Vue+SpringBoot实现大文件秒传、断点续传和分片上传的完整过程的讲解,几乎把每个地方都讲到位了,我的博客不玩套路,只讲干货,如有问题,欢迎评论区讨论!


http://www.ppmy.cn/news/1022728.html

相关文章

tomcat多实例与动静分离

实验&#xff1a;在一台虚拟机上配置多台tomcat 1.配置 tomcat 环境变量 vim /etc/profile.d/tomcat.sh source /etc/profile.d/tomcat.sh 2.修改 tomcat2 中的 server.xml 文件&#xff0c;要求各 tomcat 实例配置不能有重复的端口号 vim /usr/local/tomcat/tomcat2/conf/…

RK3566的ota升级方式

一、在recovery模式下使用adb指令升级 进入recovery模式: adb reboot recovery adb查看是否识别到 recovery 模式: adb devices此时出现recovery菜单,轻触触摸屏任意位置可以上下选择,选择 "Apply update frame ADB",长按触摸屏任意位置,进入等待 ota 包发送模式. 开…

uniapp文件下载并预览

大概就是这样的咯&#xff0c;文件展示到页面上&#xff0c;点击文件下载并预览该文件&#xff1b; 通过点击事件handleDownLoad(file.path)&#xff0c;file.path为文件的地址&#xff1b; <view class"files"><view class"cont" v-for"(…

Boost开发指南-4.3optional

optional 在实际的软件开发过程中我们经常会遇到“无效值”的情况&#xff0c;例如函数并不是总能返回有效值&#xff0c;很多时候函数正确执行了&#xff0c;但结果却不是合理的值。如果用数学语言来解释&#xff0c;就是返回值位于函数解空间之外。 求一个数的倒数&#xf…

在Linux上进行项目部署--手动和自动

在Linux上进行项目部署–手动和自动 文章目录 在Linux上进行项目部署--手动和自动1、手动部署项目2、通过Shell脚本自动部署项目 1、手动部署项目 1、在IDEA中开发SpringBoot项目并打成jar包 在idea中的Maven中的package&#xff08;基于Springboot项目&#xff09; 2、将jar包…

谷粒商城第十一天-完善商品分组(主要添上关联属性)

目录 一、总述 二、前端部分 2.1 改良前端获取分组列表接口及其调用 2.2 添加关联的一整套逻辑 三、后端部分 四、总结 一、总述 前端部分和之前的商品品牌添加分类差不多。 也是修改一下前端的分页获取列表的接口&#xff0c;还有就是加上关联的那一套逻辑&#xff0c;…

分享一颗能用在TYPE-C接口取电协议芯片LDR6328Q,方便好用

芯片功能&#xff1a;诱导PD充电器输出最大功率&#xff0c;支持最大诱骗20V电压。支持协议&#xff1a;PD/QC/三星AFC/华为SCP等主流快充协议 芯片封装&#xff1a;QFN16,SOP8多封装选择 芯片应用&#xff1a; 桶形连接器替换&#xff08;BCR&#xff09;&#xff0c;USB-A和m…

NIO 非阻塞式IO

NIO Java NIO 基本介绍 Java NIO 全称 Java non-blocking IO&#xff0c;是指 JDK 提供的新 API。从 JDK1.4 开始&#xff0c;Java 提供了一系列改进的输入/输出的新特性&#xff0c;被统称为 NIO&#xff08;即 NewIO&#xff09;&#xff0c;是同步非阻塞的。NIO 相关类都被…