SpringBoot:SpringBoot+Minio+Vue实现分片上传、断点续传(分片下载)功能

前言

  开发项目的时候遇到了稍大一点的文件,做上传下载的时候有点慢,就网上冲浪下借鉴各位大佬的代码实现了分片上传、断点续传功能,在此做个记录文章。

框架环境说明

Minio使用docker部署的,版本:2024.7.4

分片上传功能

Vue实现

<template>
  <div class="container">
    <el-upload
        class="upload-demo"
        drag
        action="/xml/fileUpload"
        multiple
        :on-change="handleChange"
        :auto-upload="false">
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
    </el-upload>
    <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">上传到服务器</el-button>
  </div>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {
      file: '',
      fileList: [],
      CHUNK_SIZE: 1024 * 1024 * 100//100MB
    }
  },
  watch: {},
  created() {
  },
  methods: {
    async submitUpload() {
      //获取上传的文件信息
      const file = this.fileList[0].raw
      //分片
      const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
      for (let i = 0; i < totalChunks; i++) {
        const start = i * this.CHUNK_SIZE;
        const end = Math.min(start + this.CHUNK_SIZE, file.size);
        //将文件切片
        const chunk = file.slice(start, end);
        //组装参数
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('fileName', file.name);
        formData.append('index', i);
        //异步上传
        await fetch('/system/sys-file/upload/bigFile', {
          method: 'POST',
          body: formData
        });
      }
      //调用合并分片请求
      await fetch('/system/sys-file/merge/bigFile', {
        method: 'POST',
        body: JSON.stringify({fileName: file.name}),
        headers: {'Content-Type': 'application/json'}
      });
    },
    handleChange(file, fileList) {
      this.fileList = fileList
    }
  }
}
</script>

<style>
.container {
  display: flex;
}
</style>

Springboot实现

Controller文件

@Tag(name =  "系统公共文件管理")
@RequiredArgsConstructor
@RestController
@RequestMapping("/system/sys-file")
public class SysFileController {

    private final SysFileService sysFileService;
    
    /**
     * 文件分片-上传分片接口
     * @param file 文件
     * @param fileName 文件名称 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt.chunk.0
     * @param type 存储到Minio的文件夹目录 例:/temp/ppt/
     * @param relativePath 相对Minio桶的路径,第一次分片调用时不需要传递,第二次分片调用时将第一次结果中的相对路径传递过来
     * @return
     */
    @Operation(summary = "文件分片-上传分片接口")
    @RequestMapping(value = "/upload/bigFile", method = RequestMethod.POST)
    private R bigFileUpload(@RequestPart("file") MultipartFile file,
                            @RequestParam("fileName") String fileName,
                            @RequestParam("index") Integer index,
                            @RequestParam("type") String type,
                            @RequestParam("relativePath") String relativePath) {
        if (file.isEmpty()) {
            return R.fail("上传失败!");
        }
        // 文件临时存储路径
        Map<String, Object> map = sysFileService.uploadMinioChunksFile(file, (fileName + Constants.fileChunk + index),
                type, relativePath);
        if (map != null && !map.isEmpty()) {
            return R.ok(map);
        }
        return R.fail();
    }

    /**
     * 文件分片-合并分片接口
     * @param request fileName 将分片合并后的名称(这里需要取分片文件名称的一部分作为合并后的名称) 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt
     * @param request relativePath 最后一次分片结果的相对路径传递过来 
     * @param request size 文件的总大小,用于存储文件记录信息(看业务情况,不需要的话可以删掉)
     * @return
     */
    @Operation(summary = "文件分片-合并分片接口")
    @PostMapping("/merge/bigFile")
    public R mergeChunks(@RequestBody Map<String, String> request) {
        String filename = request.get("fileName").toString();
        String relativePath = request.get("relativePath").toString();
        long size = Long.valueOf(request.get("size").toString());
        Map<String, Object> map = sysFileService.mergeMinioChunksFile(filename, relativePath, size);
        if (map != null && map.get("id") != null) {
            return R.ok(map);
        }
        return R.fail();
    }
    
}

SysFileServiceImpl文件

@Service
@RequiredArgsConstructor
public class SysFileServiceImpl extends ServiceImpl<CommonFileMapper, SysFile> implements SysFileService {

    private final MinioService minioService;

    /**
     * 上传文件
     * @param file 文件
     * @param fileName 文件名称 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt.chunk.0
     * @param type 存储到Minio的文件夹目录 /temp/ppt/
     * @param relativePath 相对Minio桶的路径,第一次分片调用时不需要传递,第二次分片调用时将第一次的相对路径结果传递过来
     * @return {@link String}
     */
    @Override
    public Map<String, Object> uploadMinioChunksFile(MultipartFile file, String fileName, String type, String relativePath) {
        Map<String, Object> map = new HashMap<>();
        // 验证文件保存的目录是否合法,这步可以根据大家的业务情况来改变
        type = type.lastIndexOf("/") == -1 ? type : type.substring(type.lastIndexOf("/") - 1);
        type = FILE_TYPE_UPLOAD.getType(type);
        // 相对路径不为空,进行下一步
        if(StringUtils.isNotEmpty(type)) {
            try {
                // 将路径拆分数组
                String[] split = fileName.split("/");
                // 获取文件名称中的UUID
                String uuid = split[split.length - 1].split("-")[0];
                // 去掉路径第一个 / 然后拼接UUID 
                // 这里代码目的为:分片文件存储在以文件名称中的UUID为名称的目录中,方便后续找到这个UUID目录做分片文件合并
                String prefix = FileUploadUtils.minioPrefixDir(type)+uuid+"/";
                // 相对路径的文件名
                String filename = StringUtils.isNotEmpty(relativePath) ? relativePath + fileName
                        : prefix + fileName;
                // minio 开始上传文件
                minioService.uploadFile(filename, file.getInputStream());
                map.put("filename", filename);
                map.put("name", fileName);
                map.put("type", type);
                map.put("relativePath", StringUtils.isNotEmpty(relativePath) ? relativePath : prefix);
            } catch (Exception e) {
                e.printStackTrace();
                AdminResponseEnum.SYS_UPLOAD_FILE_ERROR.assertTrue(true);
            }
        }
        return map;
    }
    
    /**
     * 合并文件
     * @param fileName 合并后的文件名称 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt
     * @param relativePath 最后一次分片调用结果的相对路径传递过来 例:/temp/ppt/5c7e4b370488494986c2f9284ea6eab9/
     * @param size 文件总大小
     * @return {@link String}
     */
    @Override
    public Map<String, Object> mergeMinioChunksFile(String fileName, String relativePath, Long size) {
        Map<String, Object> map = new HashMap<>();
        // 验证文件保存的目录是否合法,这步可以根据大家的业务情况来改变
        List<String> fileTypes = FILE_TYPE_UPLOAD.getFileTypes();
        String type = null;
        // 将相对路径拆分为集合
        List<String> split = Arrays.asList(relativePath.split("/"));
        for(String fileType : fileTypes){
            // 如果相对路径中包含 type 信息
            if(split.contains(fileType)){
                type = fileType;
                break;
            }
        }
        // 相对路径不为空且文件目录合法,进行下一步
        if(StringUtils.isNotEmpty(relativePath) && StringUtils.isNotEmpty(type)){
            // 取文件名称中的UUID, 5c7e4b370488494986c2f9284ea6eab9
            String name = fileName.replace(fileName.split("-")[0]+"-", "");
            // 将相对路径中的文件UUID路径剔除,/temp/ppt/5c7e4b370488494986c2f9284ea6eab9/ -> /temp/ppt/
            // 继续拼接 合并后的文件名称 5c7e4b370488494986c2f9284ea6eab9-A001.ppt -> /temp/ppt/5c7e4b370488494986c2f9284ea6eab9-A001.ppt
            // 这个路径就是要存储的相对路径文件名 /temp/ppt/5c7e4b370488494986c2f9284ea6eab9-A001.ppt
            String filename = relativePath.replaceAll(split.get(split.size()-1)+"/", "") + fileName;
            // 合并分片文件
            minioService.mergeFile(filename);
            // 获取上传文件url
            String fileUrl = minioService.getPublicObjectUrl(filename);
            map.put("filename", filename);
            map.put("name", name);
            map.put("type", type);
            map.put("url", fileUrl);
            String sub = fileUrl.substring(fileUrl.lastIndexOf(":") + 1);
            int start = sub.indexOf("/");
            map.put("urlNet", minioProperties.getHttpConf() + sub.substring(start + 1));
            SysFile sysFile = fileLog(size, map);
            map.put("id", sysFile.getId());
            // 获取当前的LocalDateTime实例
            LocalDateTime now = LocalDateTime.now();
            // 创建一个DateTimeFormatter实例,指定想要的格式
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            // 使用formatter格式化LocalDateTime对象
            String time = now.format(formatter);
            map.put("createTime", time);
        }
        return map;
    }
}

MinioService文件

@Service
public class MinioService {

    @Resource
    private MinioClient minioClient;

    // Minio的存储桶名称
    @Resource
    private String bucketName;
    
    /**
     * 上传文件流
     * @param objectName 对象名称 /temp/ppt/5c7e4b370488494986c2f9284ea6eab9/5c7e4b370488494986c2f9284ea6eab9-A001.ppt.chunk.3
     * @param inputStream   文件流
     * @return ObjectWriteResponse
     */
    @SneakyThrows
    public ObjectWriteResponse uploadFile(String objectName, InputStream inputStream) {
        return minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(inputStream, inputStream.available(), -1)
                        .build());
    }
    
    /**
     * 合并分片文件
     * @param objectName  对象名 例:/temp/ppt/5c7e4b370488494986c2f9284ea6eab9-A001.ppt
     * @return ObjectWriteResponse
     */
    @SneakyThrows
    public void mergeFile(String objectName) {
        // 将相对路径文件名拆分成数组
        String[] split = objectName.split("/");
        // 将相对路径文件名去除文件UUID名称 和 路径第一个字符 / -> temp/ppt/
        String parentPath = objectName.replaceAll(split[split.length - 1], "").substring(1);
        // 获取文件名称中的UUID -> 5c7e4b370488494986c2f9284ea6eab9
        String uuid = split[split.length - 1].split("-")[0];
        // 拼接分片文件存储的相对路径 -> temp/ppt/5c7e4b370488494986c2f9284ea6eab9/
        String prefix = parentPath + uuid + "/";
        // 获取所有分片对象
        Iterable<Result<Item>> parts = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(prefix)  // 将分片文件存储的相对路径下的所有文件查出来
                        .recursive(false)
                        .build());
        // 将所有分片对象存储在一个列表中以便后续操作
        List<Item> partList = new ArrayList<>();
        for (Result<Item> result : parts) {
            Item item = result.get();
            if (!item.isDir()) {
                partList.add(item);
            }
        }
        // 按分片编号排序
        Collections.sort(partList, Comparator.comparingInt(o -> extractPartNumber(o.objectName())));
        //获取需要合并的分片组装成ComposeSource
        List<ComposeSource> sourceObjectList = new ArrayList<>(partList.size());
        for (Item object : partList) {
            // 每个分片的相对路径文件名称
            String partName = object.objectName();
            sourceObjectList.add(
                    ComposeSource.builder()
                            .bucket(bucketName)
                            .object(partName)
                            .build()
            );
        }
        //合并分片
        try {
            ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
                    .bucket(bucketName)
                    //合并后的相对路径文件名称
                    .object(objectName)
                    //指定源文件
                    .sources(sourceObjectList)
                    .build();
            // 开始合并
            minioClient.composeObject(composeObjectArgs);
        } catch (Exception e) {
            System.err.println("Error merge object " + objectName + ": " + e.getMessage());
            throw e;
        }
        // 删除所有分片对象,保存分片的临时UUID目录也会因为分片文件删除后自动清除
        for (Item part : partList) {
            String partName = part.objectName();
            try {
                // 删除所有临时存储的分片文件
                minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(partName).build());
            } catch (Exception e) {
                System.err.println("Error removing object " + partName + ": " + e.getMessage());
                throw e;
            }
        }
    }
    
    /**
     * 获得永不失效的文件外链
     * @param objectName 对象名称
     * @return url
     */
    @SneakyThrows
    public String getPublicObjectUrl(String objectName) {
        GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .method(Method.GET)
                .build();

        URI uri = new URI(minioClient.getPresignedObjectUrl(args));
        String path = uri.getPath().replaceAll("//", "/");
        return new URI(uri.getScheme(), uri.getAuthority(), path, null, null).toString();
    }
    
}

断点续传功能

Vue实现

<template>
  <div class="main">
    <div class="fileList">
      <div class="title">
        文件列表
        <!-- <hr> -->
      </div>

      <el-table :data="fileList" border style="width: 360px">
        <el-table-column prop="name" label="文件名" width="150">
        </el-table-column>

        <el-table-column prop="size" label="文件大小" width="110">
          <template #default="scope">
            {{ formatSize(scope.row) }}
          </template>
        </el-table-column>
        <el-table-column prop="" label="操作" width="100">
          <template #default="scope">
            <el-button
              size="small"
              type="primary"
              @click="downloadFile(scope.row)"
              >下载</el-button
            >
          </template>
        </el-table-column>
      </el-table>
    </div>

    <div class="downloadList">
      <el-divider content-position="left">下载列表</el-divider>

      <div v-for="file in downloadingFileList">
        <div class="downloading">
          <span class="fileName">{{ file.name }}</span>
          <span class="fileSize">{{ formatSize(file) }}</span>
          <span class="downloadSpeed">{{ file.downloadSpeed }}</span>

          <div class="progress">
            <span>下载进度:</span>

            <el-progress
              :text-inside="true"
              :stroke-width="16"
              :percentage="file.downloadPersentage"
            >
            </el-progress>

            <el-button circle link @click="changeDownloadStop(file)">
              <el-icon size="20" v-if="file.downloadingStop == false"
                ><VideoPause
              /></el-icon>
              <el-icon size="20" v-else><VideoPlay /></el-icon>
            </el-button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import axios from "axios";
import { ref, reactive, getCurrentInstance } from "vue";
import emitter from "../utils/eventBus.js";
import { VideoPause, VideoPlay } from "@element-plus/icons-vue";
const { appContext } = getCurrentInstance();
const request = appContext.config.globalProperties.request;
var fileList = reactive([]);
var downloadingFileList = ref([]);
//上传文件之后,重新加载文件列表
emitter.on("reloadFileList", () => {
  load();
});
function load() {
  fileList.length = 0;
  request({
    url: "/fileList",
    method: "get",
  }).then((res) => {
    // console.log("res", res.data.data);
    fileList.push(...res.data.data);
  });
}
load();

//换算文件的大小单位
function formatSize(file) {
  //console.log("size",file.size);
  var size = file.size;
  var unit;
  var units = [" B", " K", " M", " G"];
  var pointLength = 2;
  while ((unit = units.shift()) && size > 1024) {
    size = size / 1024;
  }
  return (
    (unit === "B"
      ? size
      : size.toFixed(pointLength === undefined ? 2 : pointLength)) + unit
  );
}
//点击暂停下载
function changeDownloadStop(file) {
  file.downloadingStop = !file.downloadingStop;
  if (!file.downloadingStop) {
    console.log("开始。。");

    downloadChunk(1, file);
  }
}
//点击下载文件
function downloadFile(file) {
  // console.log("下载", file);
  file.downloadingStop = false;
  file.downloadSpeed = "0 M/s";
  file.downloadPersentage = 0;
  file.blobList = [];
  file.chunkList = [];
  downloadingFileList.value.push(file);

  downloadChunk(1, file);
}
//点击下载文件分片
function downloadChunk(index, file) {
  // 每次分片下载的文件大小
  var chunkSize = 1024 * 1024 * 5;
  // 文件下载总次数
  var chunkTotal = Math.ceil(file.size / chunkSize);

  if (index <= chunkTotal) {
    // console.log("下载进度",index);
    var exit = file.chunkList.includes(index);
    console.log("存在", exit);

    if (!exit) {
      if (!file.downloadingStop) {
        var formData = new FormData();
        formData.append("fileId", file.id);
        formData.append("chunkSize", chunkSize);
        formData.append("index", index);
        formData.append("chunkTotal", chunkTotal);
        if (index * chunkSize >= file.size) {
          chunkSize = file.size - (index - 1) * chunkSize;
          formData.set("chunkSize", chunkSize);
        }

        var startTime = new Date().valueOf();

        axios({
          url: "http://localhost:8080/system/sys-file/export/file/bigFile",
          method: "post",
          data: formData,
          responseType: "blob",
          timeout: 50000,
        }).then((res) => {
          file.chunkList.push(index);
          var endTime = new Date().valueOf();
          var timeDif = (endTime - startTime) / 1000;
          file.downloadSpeed = (5 / timeDif).toFixed(1) + " M/s";
          //todo
          file.downloadPersentage = parseInt((index / chunkTotal) * 100);
          // var chunk = res.data.data.chunk
          // const blob = new Blob([res.data]);
          const blob = res.data;

          file.blobList.push(blob);
          // console.log("res", blobList);
          if (index == chunkTotal) {
            var resBlob = new Blob(file.blobList, {
              type: "application/octet-stream",
            });
            // console.log("resb", resBlob);

            let url = window.URL.createObjectURL(resBlob); // 将获取的文件转化为blob格式
            let a = document.createElement("a"); // 此处向下是打开一个储存位置
            a.style.display = "none";
            a.href = url;
            // 下面两行是自己项目需要的处理,总之就是得到下载的文件名(加后缀)即可

            var fileName = file.name;

            a.setAttribute("download", fileName);
            document.body.appendChild(a);
            a.click(); //点击下载
            document.body.removeChild(a); // 下载完成移除元素
            window.URL.revokeObjectURL(url); // 释放掉blob对象
          }

          downloadChunk(index + 1, file);
        });
      }
    } else {
      file.downloadPersentage = parseInt((index / chunkTotal) * 100);
      downloadChunk(index + 1, file);
    }
  }
}
</script>

<style  scoped>
.main {
  display: flex;
}
.fileList {
  width: 400px;
}
.downloadList {
  width: 450px;
}
.title {
  margin-top: 5px;
  margin-bottom: 5px;
}
.downloading {
  margin-top: 10px;
}
.downloading .fileName {
  margin-left: 76px;
  margin-right: 30px;
}
.downloading .fileSize {
  /* margin-left: 70px; */
  margin-right: 30px;
}
.downloading .progress {
  display: flex;
}
.progress .el-progress {
  /* font-size: 18px; */
  width: 310px;
}
</style>

Springboot实现

Controller文件

@Tag(name =  "系统公共文件管理")
@RequiredArgsConstructor
@RestController
@RequestMapping("/system/sys-file")
public class SysFileController {

    private final SysFileService sysFileService;
    
    /**
     * 大文件分片下载
     * @param fileId  文件ID
     * @param sliceSize 分片大小(字节)
     * @param sliceNum  分片总次数
     * @param index 当前次数
     * @param response
     */
    @Operation(summary = "大文件分片下载")
    @GetMapping("/export/file/bigFile")
    @SaCheckPermission("dataset.download.export.file.big")
    public void exportBigFile(@RequestParam("fileId") Long fileId,
                              @RequestParam("chunkSize") Integer sliceSize,
                              @RequestParam("chunkTotal") Integer sliceNum,
                              @RequestParam("index") Integer index,
                              HttpServletResponse response) {
        sysFileService.fileDownload(response, fileId, sliceSize, sliceNum, index);
    }
    
}

SysFileServiceImpl文件

@Service
@RequiredArgsConstructor
public class SysFileServiceImpl extends ServiceImpl<CommonFileMapper, SysFile> implements SysFileService {

    private final MinioService minioService;

    /**
     * 大文件分片下载
     * @param response
     * @param fileId 文件ID
     * @param sliceSize 分片大小(字节)
     * @param sliceNum 分片次数
     * @param index 当前次数
     */
    @Override
    public void fileDownload(HttpServletResponse response, Long fileId,
                             Integer sliceSize, Integer sliceNum, Integer index) {
        SysFileVo file = sysFileControllerFeign.getInfoById(fileId);
        if(file == null) return;
        // 计算起始字节位置
        long offset = (long) sliceSize * (index - 1);
        // 如果当前次数为最后一次
        if(Objects.equals(index, sliceNum)){
            offset = Long.valueOf(file.getSize()) - sliceSize;
        }
        InputStream fileInputStream = null;
        BufferedInputStream bis = null;
        ServletOutputStream outputStream = null;
        try {
            // 指定起始结束字节获取Minio的文件数据
            fileInputStream = minioService.getObject(file.getFilename(), offset, sliceSize);
            ContentDisposition disposition = ContentDisposition.builder("attachment")
                    .filename(new String(URLEncoder.encode("bytes " + offset + "-" + (offset+sliceSize) + "/" + file.getSize(), "UTF-8")
                            .getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1)).build();
            response.addHeader("Content-Disposition", disposition.toString());
            response.setContentType("application/force-download");
            response.setCharacterEncoding("UTF-8");
            outputStream = response.getOutputStream();
            bis = new BufferedInputStream(fileInputStream);
            byte[] buff = new byte[1024];
            int readLength = 0;
            while ((readLength = bis.read(buff)) != -1) {
                outputStream.write(buff, 0, readLength);
            }
            outputStream.flush();
        } catch (Exception e) {
            log.error("下载失败");
        } finally {
            try {
                if (bis != null) {
                    bis.close();
                }
                if (fileInputStream != null) {
                    fileInputStream.close();
                }
                if (outputStream != null) {
                    outputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    
}

MinioService文件

@Service
public class MinioService {

    @Resource
    private MinioClient minioClient;

    // Minio的存储桶名称
    @Resource
    private String bucketName;
    
    /**
     * 断点下载
     * @param objectName 文件名称
     * @param offset     起始字节的位置
     * @param length     要读取的长度
     * @return 二进制流
     */
    @SneakyThrows
    public InputStream getObject(String objectName, long offset, long length) {
        return minioClient.getObject(
                GetObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .offset(offset)
                        .length(length)
                        .build());
    }
    
}

 

 文章整合至:SpringBoot+Vue实现大文件上传(分片上传)java实现大文件的分片上传与下载(springboot+vue3)

posted @ 2025-03-12 15:18  怒吼的萝卜  阅读(1221)  评论(0)    收藏  举报