前言
大文件分片上传和断点续传是为了解决在网络传输过程中可能遇到的问题,以提高文件传输的效率和稳定性。
- 首先,大文件分片上传是将大文件分割成较小的片段进行上传。这样做的好处是可以减少单个文件的传输时间,因为较小的文件片段更容易快速上传到目标服务器。同时,如果在传输过程中出现错误或中断,只需要重新上传出现问题的文件片段,而不需要重新上传整个文件,从而减少了传输的时间和带宽消耗。
- 其次,断点续传是指在文件传输过程中,如果传输被中断或者发生错误,可以从上一次中断的地方继续传输,而不是从头开始。这对于大文件的传输尤为重要,因为传输一个大文件可能需要较长的时间,而中断可能是由网络问题、电源故障、软件崩溃或其他因素引起的。断点续传功能允许用户在中断后恢复传输,而无需重新开始,节省了时间和资源。
大文件分片上传和断点续传在以下情况下尤为重要:
- 低带宽网络环境:在网络速度较慢或不稳定的情况下,将大文件分割为较小的片段进行上传可以降低传输的时间和失败的风险。
- 大文件传输:对于大文件,一次性完整上传可能需要很长时间,而且中途出现问题时需要重新传输整个文件,因此将文件分割并实现断点续传功能可以提高效率和可靠性。
- 网络中断或传输错误:网络中断、电源故障或软件崩溃等因素可能导致文件传输中断,断点续传功能可以从中断处恢复,避免重新传输整个文件。
- 多用户并发上传:在有多个用户同时上传文件的情况下,分片上传和断点续传可以减少对服务器资源的占用,提高并发传输的效率。
前期准备
- 如果项目是通过nginx转发的,那么在nginx中需要有如下配置
# 以下为默认值,第一行是默认超时时间75s,第二行默认请求体式1m,需要调大。这两个参数在nginx.conf下的http下 keepalive_timeout 75; client_max_body_size 1m;
- springboot配置文件中的配置
# 其中,spring.servlet.multipart.max-file-size定义了单个文件的最大大小,而spring.servlet.multipart.max-request-size定义了整个请求的最大大小(包括所有文件和其他请求参数)。需要根据需求自己调大 spring.servlet.multipart.max-file-size=1MB spring.servlet.multipart.max-request-size=10MB
后端实现
⏮:首先需要服务器中搭建有minio,这个搭建起来很快的,当然如果不是minio,别的oss也可以
1️⃣:需要的依赖
<!--minio--> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.2.0</version> </dependency>
2️⃣:minio在properties中的相关配置
# 服务地址 minio.endpoint=http://127.0.0.1:9000 # 账号 minio.accessKey=admin # 密码 minio.secretKey=123456 # 桶名称 minio.bucketName=xiaobo
3️⃣:配置和创建MinIO客户端以及其工具类
package com.todoitbo.tallybookdasmart.config; import com.todoitbo.tallybookdasmart.exception.BusinessException; import io.minio.MinioClient; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * @author xiaobo * @date 2022/8/5 */ @Data @Configuration @Slf4j public class MinIoClientConfig { @Value("${minio.endpoint}") private String endpoint; @Value("${minio.accessKey}") private String accessKey; @Value("${minio.secretKey}") private String secretKey; /** * 注入minio 客户端 * * @return MinioClient */ @Bean public MinioClient minioClient() { try { return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build(); } catch (Exception e) { throw new BusinessException("-----创建Minio客户端失败-----", e.getMessage()).setCause(e).setLog(); } } }
4️⃣:minio的工具类
package com.todoitbo.tallybookdasmart.utils; import com.alibaba.fastjson2.JSON; import com.todoitbo.tallybookdasmart.constant.BaseBoConstants; import com.todoitbo.tallybookdasmart.exception.BusinessException; import io.minio.*; import io.minio.messages.Bucket; import io.minio.messages.Item; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.InputStream; import java.text.DecimalFormat; import java.util.*; /** * @author xiaobo * @date 2023/5/21 */ @Component public class MinioUtil { private static MinioClient minioClient; @Autowired public void setMinioClient(MinioClient minioClient) { MinioUtil.minioClient = minioClient; } /** * description: 文件上传 * * @param bucketName 桶名称 * @param file 文件 * @param fileName 文件名 * @author bo * @date 2023/5/21 13:06 */ public static String upload(String bucketName, MultipartFile file, String fileName) { // 返回客户端文件系统中的原始文件名 String originalFilename = file.getOriginalFilename(); InputStream inputStream = null; try { inputStream = file.getInputStream(); minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(fileName) .stream(inputStream, file.getSize(), -1) .build()); return bucketName + "/" + fileName; } catch (Exception e) { throw new BusinessException("文件上传失败:", e.getMessage()).setCause(e).setLog(); } finally { if (inputStream != null) { try { inputStream.close(); } catch (IOException e) { e.printStackTrace(); } } } } /** * description: 文件删除 * * @author bo * @date 2023/5/21 11:34 */ public static boolean delete(String bucketName, String fileName) { try { minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName) .object(fileName).build()); return BaseBoConstants.TRUE; } catch (Exception e) { throw new BusinessException("Minio文件删除失败", e.getMessage()).setCause(e).setLog(); } } /** * description: 删除桶 * * @param bucketName 桶名称 * @author bo * @date 2023/5/21 11:30 */ public static boolean removeBucket(String bucketName) { try { List<Object> folderList = getFolderList(bucketName); List<String> fileNames = new ArrayList<>(); if (!folderList.isEmpty()) { for (Object value : folderList) { Map o = (Map) value; String name = (String) o.get("fileName"); fileNames.add(name); } } if (!fileNames.isEmpty()) { for (String fileName : fileNames) { delete(bucketName, fileName); } } minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build()); return BaseBoConstants.TRUE; } catch (Exception e) { throw new BusinessException("Minio删除桶失败:", e.getMessage()).setCause(e).setLog(); } } /** * description: 获取桶下所有文件的文件名+大小 * * @param bucketName 桶名称 * @author bo * @date 2023/5/21 11:39 */ public static List<Object> getFolderList(String bucketName) throws Exception { Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).build()); Iterator<Result<Item>> iterator = results.iterator(); List<Object> items = new ArrayList<>(); String format = "{'fileName':'%s','fileSize':'%s'}"; while (iterator.hasNext()) { Item item = iterator.next().get(); items.add(JSON.parse((String.format(format, item.objectName(), formatFileSize(item.size()))))); } return items; } /** * description: 格式化文件大小 * * @param fileS 文件的字节长度 * @author bo * @date 2023/5/21 11:40 */ private static String formatFileSize(long fileS) { DecimalFormat df = new DecimalFormat("#.00"); String fileSizeString = ""; String wrongSize = "0B"; if (fileS == 0) { return wrongSize; } if (fileS < 1024) { fileSizeString = df.format((double) fileS) + " B"; } else if (fileS < 1048576) { fileSizeString = df.format((double) fileS / 1024) + " KB"; } else if (fileS < 1073741824) { fileSizeString = df.format((double) fileS / 1048576) + " MB"; } else { fileSizeString = df.format((double) fileS / 1073741824) + " GB"; } return fileSizeString; } /** * 讲快文件合并到新桶 块文件必须满足 名字是 0 1 2 3 5.... * * @param bucketName 存块文件的桶 * @param bucketName1 存新文件的桶 * @param fileName1 存到新桶中的文件名称 * @return boolean */ public static boolean merge(String bucketName, String bucketName1, String fileName1) { try { List<ComposeSource> sourceObjectList = new ArrayList<ComposeSource>(); List<Object> folderList = getFolderList(bucketName); List<String> fileNames = new ArrayList<>(); if (!folderList.isEmpty()) { for (Object value : folderList) { Map o = (Map) value; String name = (String) o.get("fileName"); fileNames.add(name); } } if (!fileNames.isEmpty()) { fileNames.sort(new Comparator<String>() { @Override public int compare(String o1, String o2) { if (Integer.parseInt(o2) > Integer.parseInt(o1)) { return -1; } return 1; } }); for (String name : fileNames) { sourceObjectList.add(ComposeSource.builder().bucket(bucketName).object(name).build()); } } minioClient.composeObject( ComposeObjectArgs.builder() .bucket(bucketName1) .object(fileName1) .sources(sourceObjectList) .build()); return BaseBoConstants.TRUE; } catch (Exception e) { throw new BusinessException("Minio合并桶异常", e.getMessage()).setCause(e).setLog(); } } /** * description: 获取桶列表 * * @author bo * @date 2023/5/21 12:06 */ public static List<String> getBucketList() { List<Bucket> buckets = null; try { buckets = minioClient.listBuckets(); } catch (Exception e) { throw new BusinessException("Minio获取桶列表失败:", e.getMessage()).setCause(e).setLog(); } List<String> list = new ArrayList<>(); for (Bucket bucket : buckets) { String name = bucket.name(); list.add(name); } return list; } /** * description: 创建桶 * * @param bucketName 桶名称 * @author bo * @date 2023/5/21 12:08 */ public static boolean createBucket(String bucketName) { try { boolean b = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); if (!b) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); } return BaseBoConstants.TRUE; } catch (Exception e) { throw new BusinessException("Minio创建桶失败:", e.getMessage()).setCause(e).setLog(); } } /** * description: 判断桶是否存在 * * @author bo * @date 2023/5/21 12:11 */ public static boolean bucketExists(String bucketName) { try { return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); } catch (Exception e) { throw new BusinessException("Minio判断桶是否存在出错:", e.getMessage()).setCause(e).setLog(); } } }
5️⃣:service相关实现
package com.todoitbo.tallybookdasmart.service.impl; import com.todoitbo.tallybookdasmart.entity.SysUpload; import com.todoitbo.tallybookdasmart.mapper.SysUploadMapper; import com.todoitbo.tallybookdasmart.service.ISysUploadService; import com.todoitbo.tallybookdasmart.service.base.BaseServiceImpl; import com.todoitbo.tallybookdasmart.utils.JedisUtil; import com.todoitbo.tallybookdasmart.utils.MinioUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; /** * (SysUpload)服务 * * @author todoitbo * @since 2023-05-21 14:05:46 */ @Service public class SysUploadServiceImpl extends BaseServiceImpl<SysUploadMapper,SysUpload> implements ISysUploadService{ @Resource protected SysUploadMapper mapper; @Value("${minio.bucketName}") private String bucketName; /** * description: 创建临时桶 * * @param identify 桶名 * @return boolean * @author bo * @date 2023/5/21 15:34 */ @Override public boolean createTempBucket(String identify) { // 1.校验文件md5是否存在 Boolean md5Hava = JedisUtil.exists(identify); if (md5Hava) { return true; } // 2.创建临时桶 boolean b = MinioUtil.bucketExists(identify); if (b) { // 存在先删除在创建 MinioUtil.removeBucket(identify); } MinioUtil.createBucket(identify); // 将MD5存到redis中过期时间为1天,断点续传用到 JedisUtil.setJson(identify, String.valueOf(0), 24 * 60 * 60); return false; } /** * description: 合并桶 * * @param identify 文件唯一id * @param fileName 文件名 * @return boolean * @author bo * @date 2023/5/21 15:37 */ @Override public boolean mergeTempBucket(String identify, String fileName) { // 1.合并块 boolean merge = MinioUtil.merge(identify, bucketName, fileName); // 删除redis中存在的临时桶的id JedisUtil.delKey(identify); // 3.删除临时桶 boolean removeBucket = MinioUtil.removeBucket(identify); return removeBucket && merge; } }
⚠️:上面的service仅仅是为了演示上传文件,相关的数据库操作自行完成
6️⃣:接口实现
package com.todoitbo.tallybookdasmart.controller; import com.todoitbo.tallybookdasmart.constant.BaseBoConstants; import com.todoitbo.tallybookdasmart.dto.BoResult; import com.todoitbo.tallybookdasmart.entity.SysUpload; import com.todoitbo.tallybookdasmart.exception.BusinessException; import com.todoitbo.tallybookdasmart.service.ISysUploadService; import com.todoitbo.tallybookdasmart.utils.JedisUtil; import com.todoitbo.tallybookdasmart.utils.MinioUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import redis.clients.jedis.Jedis; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantLock; /** * (SysUpload)控制器 * * @author todoitbo * @since 2023-05-21 15:26:08 */ @Slf4j @RestController @RequestMapping(value = "sysUpload") public class SysUploadController { @Resource protected ISysUploadService service; /** * description: get方法初校验 * * @author bo * @date 2023/5/21 19:18 */ @GetMapping("/upload") public BoResult upload(SysUpload param) { String identifier = param.getIdentifier(); Integer totalChunks = param.getTotalChunks(); Integer chunkNumber = param.getChunkNumber(); Map<String, Object> data = new HashMap<>(1); Boolean exists = JedisUtil.exists(identifier); // 判断redis中是否还有此文件MD5 if (exists){ Map<String,Object> map = new HashMap<>(2); int identifiers = Integer.parseInt(JedisUtil.getJson(identifier)); int[] uploadedChunks = new int[identifiers]; for (int i = 1; i <= identifiers; i++) { uploadedChunks[i-1]=i; } map.put("uploadedChunks",uploadedChunks); return BoResult.resultOk(map); }else { // 判断是否是多片,如果是多片则创建临时桶 if (totalChunks>1){ service.createTempBucket(param.getIdentifier()); } } return BoResult.resultOk("ok"); } /** * description: post方法实现真正的上传逻辑 * * @author bo * @date 2023/5/21 19:18 */ @PostMapping(value = "/upload",consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public BoResult uploadAll(SysUpload param, HttpServletResponse response) { //判断文件是否是多片 Integer totalChunks = param.getTotalChunks(); String identifier = param.getIdentifier(); LocalDate localDate = LocalDate.now(); DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd/"); String format = localDate.format(dateTimeFormatter); // 设置文件名路径 String fileName = "public/"+format+param.getFile().getOriginalFilename(); Boolean exists = JedisUtil.exists(identifier); Jedis jedis = JedisUtil.getJedis(); // 如果非多片,直接上传 if (totalChunks==1){ return BoResult.resultOk(MinioUtil.upload("xiaobo", param.getFile(), fileName)); } boolean register = service.createTempBucket(param.getIdentifier()); MinioUtil.upload(param.getIdentifier(), param.getFile(), String.valueOf(param.getChunkNumber())); // 如果上传临时桶成功,redis+1 if (register){ try { assert jedis != null; jedis.incr(identifier); }catch (Exception e){ throw new BusinessException("redis递增桶的片出错!",e.getMessage()).setCause(e); }finally { assert jedis != null; jedis.close(); } // 如果redis中分片大小等于桶的总分片大小,则合并分片 if (JedisUtil.getJson(identifier).equals(String.valueOf(totalChunks))) { return BoResult.resultOk(service.mergeTempBucket(param.getIdentifier(), fileName)); } } return BoResult.resultOk(BaseBoConstants.TRUE); } }
7️⃣:vue的部分代码
这里前端代码就不都贴了,如果需要可以私要,或者百度一大把(uploader)
// 分片大小,7MB,这里注意一下,如果低于5M会报错 ->size 4194304 must be greater than 5242880 const CHUNK_SIZE = 7 * 1024 * 1024; // 是否开启服务器分片校验。默认为 true testChunks: true, // 真正上传的时候使用的 HTTP 方法,默认 POST uploadMethod: "post", // 分片大小 chunkSize: CHUNK_SIZE, // 并发上传数,默认为 3 simultaneousUploads: 5,
8️⃣:效果图如下