本篇文章主要内容是分片上传、断点续传、秒传的实现思路

前言

分片: 分片任务是在前端由vue-simple-uploader插件完成,流程:1.前端先发送check-file(检查文件MD5)来确认文件是直接秒传还是分片上传,如果计算出文件所有片已经上传完成,则启用秒传(秒传就是不传),如果是新文件,则需要分片上传,由vue-simple-uploader插件将文件按固定大小进行切割,然后逐片上传。

断点续传: 意思就是一个大文件分了多少片,这些片已经上传了哪些,还有哪些没上传,这些都会记录在文件存储目录下的.conf文件中,当你上传大文件时,传一部分后刷新浏览器或关闭浏览器,这时候传输会中断,然后你再打开页面重新上传该文件,它会先检测还有哪些片没有上传,然后直接上传的上次未传的片,这就是断点续传。

**秒传:**文件不经过上传的步骤,直接将文件信息保存在服务器中。通过计算文件md5实现

流程

  1. 校验文件上传状态: 前端生成该文件的MD5密文并进行分片,上传之前请求check-md5接口,传入文件名和密文,接口校验文件是未上传上传了一部分已上传完成三个状态,其中未上传返回自定义状态码404,上传一部分则返回状态206+未上传的分片ID,上传完成则返回状态200
  2. 前端逐片上传: 校验完成后,根据校验结果对未上传的分片进行逐个上传,上传分片时参数主要是:总片数、当前片ID、片文件
  3. 上传接口: 上传接口会先去获取并解析该文件的conf文件(conf文件是RandomAccessFile,该类是通过提供指针的方式操作文件,文件存储的是一个二进制数组,所以可以用来数组下标标记片ID),使用setLength方法设置conf文件长度,使用seek方法跳到当前上传的片ID的位置,把该位置的值替换成127,然后将该分片使用指针偏移的方式插入到_tmp临时文件(临时文件也是RandomAccessFile文件)中,然后校验是否所有的片都上传完成,是则修改临时文件为正式文件名,至此上传完成,否则直接返回该分片上传完成
  4. 上传进度: 前端收到当前片的响应结果后,会根据已上传片数量获取到上传进度
  5. MD5的用法: 用于计算服务器是否已经存在相同md5的文件,用作秒传功能的实现。前端计算文件md5,传入后端进行查找是否已经有相同md5文件,若存在直接返回上传成功,否则走上传的步骤

后端接口

Controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
 * 检查文件MD5(文件MD5若已存在进行秒传)
 *
 * @param md5      md5
 * @param fileName 文件名称
 * @return {@link ApiResponse}
 * @author 7bin
 **/
@GetMapping(value = "/check")
public ApiResponse checkFileMd5(String md5, String fileName) {
    // Result result = fileService.checkFileMd5(md5, fileName);
    // return NovelWebUtils.forReturn(result);
    return ApiResponse.success();
}

/**
 * 断点续传方式上传文件:用于大文件上传
 *
 * @param chunkDTO   参数
 * @param request 请求
 * @return {@link ApiResponse}
 * @author 7bin
 **/
@PostMapping(value = "/breakpoint-upload", consumes = "multipart/*", headers = "content-type=multipart/form-data", produces = "application/json;charset=UTF-8")
public ApiResponse breakpointResumeUpload(Chunk chunkDTO, HttpServletRequest request) {

    String id = chunkDTO.getIdentifier();
    int chunks = Math.toIntExact(chunkDTO.getTotalChunks());
    int chunk = chunkDTO.getChunkNumber() - 1;
    long size = chunkDTO.getCurrentChunkSize();
    String name = chunkDTO.getFilename();
    MultipartFile file = chunkDTO.getFile();
    String md5 = chunkDTO.getIdentifier();
    UploadFileParam param = new UploadFileParam(id, chunks, chunk, size, name, file, md5);

    // return ApiResponse.success();
    Result result = fileService.breakpointResumeUpload(param, request);
    return NovelWebUtils.forReturn(result);
}

/**
 * 检查文件MD5(文件MD5若已存在进行秒传)
 * @param chunkMap
 * @return {@link ApiResponse}
 * @author 7bin
 **/
@GetMapping(value = "/breakpoint-upload")
public ApiResponse breakpointResumeUploadPre(
    @RequestParam Map<String, String> chunkMap) {

    String md5 = chunkMap.get("identifier");
    String filename = chunkMap.get("filename");
    Result<JSONArray> result = fileService.checkFileMd5(md5, filename);


    JSONObject res = new JSONObject();

    // 数据库中存在该md5则秒传
    if (result == null){
        res.put("skipUpload",true);
        return ApiResponse.success(res);
    }

    boolean skipUpload = false;
    if ("200".equals(result.getCode()) || "201".equals(result.getCode())) {
        skipUpload = true;
    } else if ("206".equals(result.getCode())) {
        // 已经上传部分分块
        // data中存放的是还未上传的分块
        JSONArray data = result.getData();
        res.put("missChunks",data);
    }

    res.put("skipUpload",skipUpload);

    return ApiResponse.success(res);
    // Result result = fileService.breakpointResumeUpload(param, request);
    // return NovelWebUtils.forReturn(result);
}

Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Override
public Result<JSONArray> checkFileMd5(String md5, String fileName) {

    boolean exist = fileMapper.fileIsExist(md5);
    if (exist){
        return null;
    }

    Result<JSONArray> result;
    try {
        // String realFilename = md5 + "_" + fileName;
        String realFilename = md5;
        result = LocalUpload.checkFileMd5(md5, realFilename, confFilePath, savePath);
    } catch (Exception e) {
        // e.printStackTrace();
        log.error(e.getMessage());
        throw new ServiceException(e.getMessage());
    }
    return result;

}

@Override
public Result breakpointResumeUpload(UploadFileParam param, HttpServletRequest request) {
    Result result;
    try {
        // 这里的 chunkSize(分片大小) 要与前端传过来的大小一致
        // long chunkSize = Objects.isNull(param.getChunkSize()) ? 5 * 1024 * 1024
        //     : param.getChunkSize();

        // 实际存储的文件格式为 [{md5}_{filename}]
        // String realFilename = param.getMd5() + "_" + param.getName();
        String realFilename = param.getMd5();
        param.setName(realFilename);
        result = LocalUpload.fragmentFileUploader(param, confFilePath, savePath, 5242880L, request);
        // return NovelWebUtils.forReturn(result);
    } catch (Exception e) {
        log.error(e.getMessage());
        throw new ServiceException(e.getMessage());
    }
    return result;
}

前端代码

SimpleUploader.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
<template>
  <div id="global-uploader">
    <uploader
      class="uploader-app"
      :options="initOptions"
      :file-status-text="fileStatusText"
      :auto-start="false"
      @file-added="onFileAdded"
      @file-success="onFileSuccess"
      @file-progress="onFileProgress"
      @file-error="onFileError"
    >
      <uploader-unsupport></uploader-unsupport>

      <uploader-drop>
        <uploader-btn >选择文件</uploader-btn>
        <span style="margin-left: 10px">(支持上传一个或多个文件)</span>
        <!--<uploader-btn directory>上传文件夹 </uploader-btn>-->
      </uploader-drop>
      <!--<uploader-btn id="global-uploader-btn" ref="uploadBtnRef">选择文件</uploader-btn>-->
      <!--<span>(支持上传一个或多个文件)</span>-->


      <uploader-list>
        <template #default="{ fileList }">
          <div class="file-panel">
            <!--<div class="file-title">-->
            <!--  <div class="title">文件列表</div>-->
            <!--</div>-->

            <ul class="file-list">
              <li
                  v-for="file in fileList"
                  :key="file.id"
                  class="file-item"
              >
                <uploader-file
                    ref="files"
                    :class="['file_' + file.id, customStatus]"
                    :file="file"
                    :list="true"
                ></uploader-file>
              </li>
              <div v-if="!fileList.length" class="no-file">
                <!--<Icon icon="ri:file-3-line" width="16" /> 暂无待上传文件-->
                暂无待上传文件
              </div>
            </ul>
          </div>
        </template>
      </uploader-list>
    </uploader>
  </div>
</template>

<script setup>
import useCurrentInstance from "@/utils/currentInstance";
import { generateMD5 } from "@/components/Uploader/utils/md5";
import { ElNotification } from "element-plus";
import { addFileToDrive } from "@/api/drive/drive";
import { checkAuth } from "@/api/admin/user";
const { proxy } = useCurrentInstance();


// TODO 上传组件还有bug 上传成功时动作按钮没有隐藏;后端出现错误上传失败时背景色没变红

// props


// emits
const emits = defineEmits(['uploadSuccess']);


const drivePath = import.meta.env.VITE_APP_DRIVE_API;

const initOptions = {
  target: drivePath + "/file/breakpoint-upload",
  chunkSize: '5242880',
  forceChunkSize: true,
  fileParameterName: 'file',
  maxChunkRetries: 3,
  // 是否开启服务器分片校验
  testChunks: true,
  // 服务器分片校验函数,秒传及断点续传基础
  checkChunkUploadedByResponse: function (chunk, message) {
    let skip = false
    // console.log("checkChunkUploadedByResponse chunk:", chunk);
    // console.log("checkChunkUploadedByResponse message:", message);
    try {
      let objMessage = JSON.parse(message)
      // console.log("objMessage:", objMessage);
      if (objMessage.code === 200) {
        if (objMessage.data.skipUpload) {
          skip = true
        } else if (objMessage.data.missChunks == null){
          skip = false;
        } else {
          skip = (objMessage.data.missChunks || []).indexOf(chunk.offset.toString()) < 0
        }
      }

    } catch (e) {}
    // console.log("skip: " + chunk.offset + " " + skip);
    return skip
  },
  query: (file, chunk) => {
    // console.log("query:", file);
    return {
      ...file.params
    }
  }
}

const customStatus = ref('')

const fileStatusText = {
  success: '上传成功',
  error: '上传失败',
  uploading: '上传中',
  paused: '已暂停',
  waiting: '等待上传'
}

// const uploaderRef = ref()
// const uploader = computed(() => uploaderRef.value?.uploader)

async function onFileAdded(file) {
  // 判断用户是否已经登录了,登录才可以添加
  await checkAuth();

  // 暂停文件
  // 选择文件后暂停文件上传,上传时手动启动
  file.pause()
  // console.log("onFileAdded file: ", file);
  // panelShow.value = true
  // trigger('fileAdded')
  // 将额外的参数赋值到每个文件上,以不同文件使用不同params的需求
  // file.params = customParams.value
  // 计算MD5
  const md5 = await computeMD5(file)
  startUpload(file, md5)
}
function computeMD5(file) {
  // 文件状态设为"计算MD5"
  statusSet(file.id, 'md5')

  // 计算MD5时隐藏"开始"按钮
  nextTick(() => {
    // document.querySelector(`.file_${file.id} .uploader-file-resume`).style.display = 'none'
    document.querySelector(`.file_${file.id} .uploader-file-actions`).style.display = 'none'
  })
  // 开始计算MD5
  return new Promise((resolve, reject) => {
    generateMD5(file, {
      onProgress(currentChunk, chunks) {
        // 实时展示MD5的计算进度
        nextTick(() => {
          const md5ProgressText = '校验MD5 ' + ((currentChunk / chunks) * 100).toFixed(0) + '%'
          document.querySelector(`.custom-status-${file.id}`).innerText = md5ProgressText
        })
      },
      onSuccess(md5) {
        statusRemove(file.id)
        resolve(md5)
      },
      onError() {
        error(`文件${file.name}读取出错,请检查该文件`)
        file.cancel()
        statusRemove(file.id)
        reject()
      }
    })
  })
}
// md5计算完毕,开始上传
function startUpload(file, md5) {
  file.uniqueIdentifier = md5
  file.resume()
}

function onFileProgress(rootFile, file, chunk) {
  console.log(
    `上传中 ${file.name},chunk:${chunk.startByte / 1024 / 1024} ~ ${
      chunk.endByte / 1024 / 1024
    }`
  )
}

const onFileError = (rootFile, file, response, chunk) => {
  // console.log('error', file)
  error(response)
}
function error(msg) {
  ElNotification({
    title: '错误',
    message: msg,
    type: 'error',
    duration: 2000
  })
}
const onFileSuccess = (rootFile, file, response, chunk) => {
  // console.log("上传成功")
  // console.log("rootFile",rootFile)
  // file的relativePath是文件夹的相对路径(如果上传的是文件夹的话)
  // console.log("file",file)
  // console.log("response",JSON.parse(response))
  // console.log("chunk",chunk)
  // addFileToDrive(file.name, file.uniqueIdentifier, file.size).then(() => {
  //   proxy.$modal.msgSuccess("文件上传成功");
  // })

  // 服务端自定义的错误(即http状态码为200,但是是错误的情况),这种错误是Uploader无法拦截的
  let res = JSON.parse(response)
  console.log("onFileSuccess res:", res);
  if (res.code !== 200) {
    error(res.message)
    // 文件状态设为“失败”
    statusSet(file.id, 'failed')
    return
  }

  emits("uploadSuccess", file);
}


/**
 * 新增的自定义的状态: 'md5'、'merging'、'transcoding'、'failed'
 * @param id
 * @param status
 */
function statusSet(id, status) {
  const statusMap = {
    md5: {
      text: '校验MD5',
      bgc: '#fff'
    },
    failed: {
      text: '上传失败',
      bgc: '#e2eeff'
    }
  }

  customStatus.value = status
  nextTick(() => {
    const statusTag = document.createElement('span')
    statusTag.className = `custom-status-${id} custom-status`
    statusTag.innerText = statusMap[status].text
    statusTag.style.backgroundColor = statusMap[status].bgc

    // custom-status 样式不生效
    // 由于 style脚本 设置了 scoped,深层的样式修改不了
    // 通过给当前组件设置一个id,在该id下设置样式,就可以保证样式不全局污染
    // statusTag.style.position = 'absolute';
    // statusTag.style.top = '0';
    // statusTag.style.left = '0';
    // statusTag.style.right = '0';
    // statusTag.style.bottom = '0';
    // statusTag.style.zIndex = '1';

    const statusWrap = document.querySelector(`.file_${id} .uploader-file-status`)
    statusWrap.appendChild(statusTag)
  })
}
function statusRemove(id) {
  customStatus.value = ''
  nextTick(() => {
    const statusTag = document.querySelector(`.custom-status-${id}`)
    document.querySelector(`.file_${id} .uploader-file-actions`).style.display = 'block'
    statusTag.remove()
  })
}
</script>

md5.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import SparkMD5 from 'spark-md5'

/**
 * 分段计算MD5
 * @param file {File}
 * @param options {Object} - onProgress | onSuccess | onError
 */
export function generateMD5(file, options = {}) {
  const fileReader = new FileReader()
  const time = new Date().getTime()
  const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
  const chunkSize = 10 * 1024 * 1000
  const chunks = Math.ceil(file.size / chunkSize)
  let currentChunk = 0
  const spark = new SparkMD5.ArrayBuffer()
  const loadNext = () => {
    let start = currentChunk * chunkSize
    let end = start + chunkSize >= file.size ? file.size : start + chunkSize

    fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
  }

  loadNext()

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

    if (currentChunk < chunks) {
      currentChunk++
      loadNext()
      if (options.onProgress && typeof options.onProgress == 'function') {
        options.onProgress(currentChunk, chunks)
      }
    } else {
      let md5 = spark.end()

      // md5计算完毕
      if (options.onSuccess && typeof options.onSuccess == 'function') {
        options.onSuccess(md5)
      }

      console.log(
        `MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${
          new Date().getTime() - time
        } ms`
      )
    }
  }

  fileReader.onerror = function () {
    console.log('MD5计算失败')
    if (options.onError && typeof options.onError == 'function') {
      options.onError()
    }
  }
}