文件分片上传 springboot+vue3

文件分片上传

项目地址

效果

效果

后端

使用了开源组件:x-file-storage gitee地址 组件官网:官网

代码

    @Autowired
    private FileStorageService fileStorageService;


    /**
     * 初始化 分片信息
     * @param fileUploadInfo
     * @return
     */
    @GetMapping(value = "/uploader/chunk")
    public FileInfo init(FileUploadInfo fileUploadInfo) {
        return fileStorageService.initiateMultipartUpload()
                .setSize(fileUploadInfo.getTotalSize())
                .setOriginalFilename(fileUploadInfo.getFilename())
                .init();
    }

    /**
     * 上传 分片文件
     * @param fileUploadInfo
     * @return
     */
    @PostMapping(value = "/uploader/chunk")
    public void bigFile(FileUploadInfo fileUploadInfo) {
        final FileInfo fileInfo = fileStorageService.getFileInfoByUrl(fileUploadInfo.getUrl());

        final MultipartFileWrapper fileWrapper = new MultipartFileWrapper(
                fileUploadInfo.getUpfile(),
                fileUploadInfo.getUpfile().getOriginalFilename(),
                fileUploadInfo.getUpfile().getContentType(),
                fileUploadInfo.getUpfile().getSize());

        fileStorageService.uploadPart(fileInfo, fileUploadInfo.getChunkNumber(),
                        fileWrapper, fileUploadInfo.getCurrentChunkSize().longValue()).upload();
    }

    /**
     * 合并 分片文件
     * @param url
     * @return
     */
    @PostMapping(value = "/uploader/merge")
    public FileInfo merge(@RequestParam String url) {
        final FileInfo fileInfo = fileStorageService.getFileInfoByUrl(url);
        return fileStorageService.completeMultipartUpload(fileInfo).complete();
    }
@Data
public class FileUploadInfo {
    /**
     * 操作 文件 的key
    */
    private String url;

    private Integer chunkNumber;
    private Integer chunkSize;
    private Integer currentChunkSize;
    private Long totalSize;
    private String identifier;
    private String filename;
    private String relativePath;
    private Integer totalChunks;

    private MultipartFile upfile;
}

配置

docker pull minio/minio

mkdir -p /opt/minio/{data,config}

docker run \
-p 9000:9000 \
-p 9090:9090 \
--name minio \
-e "MINIO_ROOT_USER=minio" \
-e "MINIO_ROOT_PASSWORD=minio123456" \
-v /opt/minio/data:/data \
-v /opt/minio/config:/root/.minio \
minio/minio server /data --console-address ":9090" -address ":9000"
server:
  port: 8000

spring:
  datasource:
    url: jdbc:h2:file:./data/test_file/file;AUTO_SERVER=TRUE;MODE=MYSQL;DB_CLOSE_DELAY=-1;AUTO_RECONNECT=TRUE
    username: root
    password: 123456
    driver-class-name: org.h2.Driver

  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 201MB

dromara:
  x-file-storage:
    default-platform: minio
    minio:
      - platform: minio
        enable-storage: true
        access-key: zygoJJAKsIZHyXkdKfa4
        secret-key: RBd3Coi6LJnJPEhv6IEUEEnhm0Mums4VMi1bS6SK
        end-point: http://192.168.64.129:9000
        bucket-name: test
        domain:
        base-path:

前端

参考博客1:在vue3项目中实现文件分片上传vue-simple-uploader_vue3分片上传插件-CSDN博客

参考博客2:基于vue-simple-uploader封装文件分片上传组件-CSDN博客

代码

<template>
  <div>
    <!-- 上传器 -->
    <uploader
        ref="uploaderRef"
        :options="options"
        :autoStart="false"
        :file-status-text="fileStatusText"
        class="uploader-ui"
        @file-added="onFileAdded"
        @file-success="onFileSuccess"
        @file-progress="onFileProgress"
        @file-error="onFileError"
    >
      <uploader-unsupport></uploader-unsupport>
      <uploader-drop>
        <div>
          <uploader-btn id="global-uploader-btn" ref="uploadBtn" :attrs="attrs">
            选择文件
            <el-icon><Upload /></el-icon>
          </uploader-btn>
        </div>
      </uploader-drop>
      <uploader-list></uploader-list>
    </uploader>
  </div>
</template>

<script setup>
import { ACCEPT_CONFIG } from '@/config/accept.ts';
import { reactive, ref } from 'vue';
import SparkMD5 from 'spark-md5';
import { mergeFile } from '@/api/fileUpload/index';
import { ElMessage } from 'element-plus';

const options = reactive({
  //目标上传 URL,默认POST, import.meta.env.VITE_API_URL = api
  // target ==》http://localhost:6666/api/uploader/chunk
  target: import.meta.env.VITE_API_URL + '/uploader/chunk',
  query: {},
  headers: {
    // 需要携带token信息,当然看各项目情况具体定义
    token: "your_token",
  },
  //分块大小(单位:字节)
  chunkSize: '5242880',
  url: '',
  //上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
  fileParameterName: 'upfile',
  //失败后最多自动重试上传次数
  maxChunkRetries: 3,
  //是否开启服务器分片校验,对应GET类型同名的target URL
  testChunks: true,
  // 服务器分片校验函数
  checkChunkUploadedByResponse: function (chunk, response_msg) {
    let objMessage = JSON.parse(response_msg);

    if (!this.url) {
      options.url = objMessage?.url;
    }

    if (objMessage?.attr?.skipUpload) {
      return true;
    }

    return (objMessage.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;
  },
  processParams: function(params, file) {
    if (!params.url) {
      params.url = options.url
    }
    return params
  }
});
const attrs = reactive({
  accept: ACCEPT_CONFIG.getAll(),
});
const fileStatusText = reactive({
  success: '上传成功',
  error: '上传失败',
  uploading: '上传中',
  paused: '暂停',
  waiting: '等待上传',
});
onMounted(() => {
});
function onFileAdded(file) {
  computeMD5(file);
}

function onFileSuccess(rootFile, file, response, chunk) {
  //refProjectId为预留字段,可关联附件所属目标,例如所属档案,所属工程等
  file.refProjectId = '';
  mergeFile(options.url)
      .then((responseData) => {
        if (responseData.data.code === 415) {
          console.log('合并操作未成功,结果码:' + responseData.data.code);
        }
        ElMessage.success(responseData.data);
        options.url = '';
      })
      .catch(function (error) {
        console.log('合并后捕获的未知异常:' + error);
      })
}

function onFileError(rootFile, file, response, chunk) {
  console.log('上传完成后异常信息:' + response);
}

function onFileProgress(rootFile, file, chunk) {
  // 文件进度的回调
  // console.log('on-file-progress', rootFile, file, chunk)
}

/**
 * 计算md5,实现断点续传及秒传
 * @param file
 */
function computeMD5(file) {
  file.pause();

  //单个文件的大小限制2G
  let fileSizeLimit = 2 * 1024 * 1024 * 1024;
  console.log('文件大小:' + file.size);
  console.log('限制大小:' + fileSizeLimit);
  if (file.size > fileSizeLimit) {
    file.cancel();
  }

  let fileReader = new FileReader();
  let time = new Date().getTime();
  let blobSlice =
      File.prototype.slice ||
      File.prototype.mozSlice ||
      File.prototype.webkitSlice;
  let currentChunk = 0;
  const chunkSize = 10 * 1024 * 1000;
  let chunks = Math.ceil(file.size / chunkSize);
  let spark = new SparkMD5.ArrayBuffer();
  //由于计算整个文件的Md5太慢,因此采用只计算第1块文件的md5的方式
  let chunkNumberMD5 = 1;

  loadNext();

  fileReader.onload = (e) => {
    spark.append(e.target.result);

    if (currentChunk < chunkNumberMD5) {
      loadNext();
    } else {
      let md5 = spark.end();
      file.uniqueIdentifier = md5;
      file.resume();
      console.log(
          `MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${
              file.size
          } 用时:${new Date().getTime() - time} ms`
      );
    }
  };

  fileReader.onerror = function () {
    error(`文件${file.name}读取出错,请检查该文件`);
    file.cancel();
  };

  function loadNext() {
    let start = currentChunk * chunkSize;
    let end = start + chunkSize >= file.size ? file.size : start + chunkSize;

    fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
    currentChunk++;
    console.log('计算第' + currentChunk + '块');
  }
}
const uploaderRef = ref();
function close() {
  uploaderRef.value.cancel();
}
function error(msg) {
  console.log(msg, 'msg');
}
</script>

<style scoped>
.uploader-ui {
  padding: 15px;
  margin: 40px auto 0;
  font-size: 12px;
  font-family: Microsoft YaHei;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
}
.uploader-ui .uploader-btn {
  margin-right: 4px;
  font-size: 12px;
  border-radius: 3px;
  color: #fff;
  background-color: #409eff;
  border-color: #409eff;
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
}
.uploader-ui .uploader-list {
  max-height: 440px;
  overflow: auto;
  overflow-x: hidden;
  overflow-y: auto;
}
</style>

与上面的参考代码差不多,不过在兼容后端有一些调整。以下为差异点

差异1
差异2

  • 7
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
这里给出一个简单的实现思路,代码可能需要根据实际情况进行适当的修改。 前端实现: 1. 在前端页面中,使用 `<input type="file" />` 选择需要上传文件。 2. 将文件进行分片,每个分片的大小可以根据实际情况进行调整,一般建议在 1MB - 5MB 之间。 3. 使用 XMLHttpRequest 对每个分片进行上传上传时需要注意设置正确的 Content-Range 头信息。 4. 上传完成后,前端需要将每个分片上传结果记录下来,可以使用一个数组来保存。 后端实现: 1. 在后端中,需要提供一个接口用于接收每个分片上传请求。 2. 对于每个分片上传请求,需要将其保存到一个临时文件中,文件名可以根据上传文件的唯一标识进行命名。 3. 当所有分片上传完成后,需要将这些分片合并成一个完整的文件。 代码实现前端代码: ```javascript const CHUNK_SIZE = 1024 * 1024; // 每个分片的大小,这里设置为 1MB function upload(file) { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); // 总分片数 const chunks = []; // 保存每个分片上传结果 let uploadedChunks = 0; // 已经上传成功的分片数 // 将文件进行分片 for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min((i + 1) * CHUNK_SIZE, file.size); const chunk = file.slice(start, end); chunks.push(chunk); } // 上传每个分片 for (let i = 0; i < totalChunks; i++) { const chunk = chunks[i]; const xhr = new XMLHttpRequest(); xhr.open('POST', '/uploadChunk'); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('Content-Range', `bytes ${i * CHUNK_SIZE}-${(i + 1) * CHUNK_SIZE - 1}/${file.size}`); xhr.onload = function() { if (xhr.status === 200) { uploadedChunks++; chunks[i] = true; // 标记当前分片上传成功 if (uploadedChunks === totalChunks) { // 所有分片上传完成,触发合并文件的操作 mergeChunks(file.name, totalChunks); } } }; xhr.send(chunk); } // 合并分片的函数 function mergeChunks(filename, totalChunks) { const xhr = new XMLHttpRequest(); xhr.open('POST', '/mergeChunks'); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function() { if (xhr.status === 200) { console.log(`文件 ${filename} 上传成功!`); } }; xhr.send(JSON.stringify({ filename, totalChunks })); } } ``` 后端代码: ```java @RestController public class UploadController { // 临时文件存放目录 private static final String TEMP_DIR = "/temp"; // 上传分片的接口 @PostMapping("/uploadChunk") public ResponseEntity<Void> uploadChunk(@RequestParam("file") MultipartFile file, @RequestHeader("Content-Range") String range) { // 解析 Content-Range 头信息,获取当前分片的起始位置和结束位置 long start = Long.parseLong(range.substring(range.indexOf(" ") + 1, range.indexOf("-"))); long end = Long.parseLong(range.substring(range.indexOf("-") + 1, range.indexOf("/"))); // 将分片保存到临时文件中 String filename = UUID.randomUUID().toString(); String tempFilePath = TEMP_DIR + "/" + filename; File tempFile = new File(tempFilePath); try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(tempFile, true))) { out.write(file.getBytes()); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } return ResponseEntity.ok().build(); } // 合并分片的接口 @PostMapping("/mergeChunks") public ResponseEntity<Void> mergeChunks(@RequestBody MergeRequest mergeRequest) { String filename = mergeRequest.getFilename(); int totalChunks = mergeRequest.getTotalChunks(); // 检查所有分片是否已经上传完成 boolean allChunksUploaded = true; for (int i = 0; i < totalChunks; i++) { File chunkFile = new File(TEMP_DIR + "/" + filename + "." + i); if (!chunkFile.exists()) { allChunksUploaded = false; break; } } // 如果所有分片已经上传完成,进行合并操作 if (allChunksUploaded) { String filePath = "/upload/" + filename; File file = new File(filePath); try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file))) { for (int i = 0; i < totalChunks; i++) { File chunkFile = new File(TEMP_DIR + "/" + filename + "." + i); try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(chunkFile))) { byte[] buffer = new byte[1024]; int len; while ((len = in.read(buffer)) > 0) { out.write(buffer, 0, len); } } chunkFile.delete(); // 删除临时分片文件 } } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } return ResponseEntity.ok().build(); } else { return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).build(); } } } ``` 需要注意的是,这里的代码只是一个简单的实现,实际使用时可能需要进行一些优化和改进,例如增加断点续传的支持、限制上传文件的大小等。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值