实现大文件上传技术(spring boot + vue-simple-uploader )

文件上传功能在开发中是经常遇到的一个需求,普通的文件上传方案,如果文件太大,甚至在几百兆时,就会出现上传速度很慢,请求超时等问题。如果遇到网络问题中途断开后,用户需要重新上传文件,体验十分不好。因此面对这种情况,采用大文件分片上传技术,利用断传,秒传功能,解决上述难题。

一、前端实现

前端要引入vue-simple-uploader和MD5

//安装
npm install vue-simple-uploader --save
npm install spark-md5 --save

//main.js  全局引用
import uploader from 'vue-simple-uploader'
Vue.use(uploader)

vue-simple-uploader有template插槽,支持自定义样式;

<div style="margin-left: 30px;">
            <uploader
            ref="uploader"
            :options="initOptions"
              :autoStart="false"
              @file-added="onFileAdded"
              @file-success="onFileSuccess"
              @file-error="onFileError"
              @file-progress="onFileProgress"
              >
              <uploader-btn type="primary" ref="uploadBtn">选择文件<i class="el-icon-upload el-icon-right"></i></uploader-btn>
              
              <el-progress style="width: 150px;margin-top: 8px"  :percentage="percentage" v-if="(progressShow&&statuShow)"/>
              <el-progress status="exception" style="width: 150px;margin-top: 8px"  :percentage="percentage" v-if="(progressShow&&(!statuShow))"/>
            </uploader>
</div>

initOptions相关配置

percentage: 0,
progressShow: false,
statuShow: true,

initOptions: {
    target: '/upload', // 上传的服务器地址
    chunkSize: 1024 * 1024 * 5,  // 文件分块的大小
    fileParameterName: 'file', // 后端参数的名字
    maxChunkRetries: 3,  //最大重试次数
    testChunks: true, // 是否开启服务器分片校验,如果要实现断传和秒传功能,必须为true
    headers: {       
    	'token': getToken()  // 自定义请求头部信息
    },
	successStatus: [201,202], //成功状态,由于后端一般统一返回状态码,所以该配置可以忽略
    // 服务器分片校验函数,判断是否需要跳过该分片
	checkChunkUploadedByResponse: function (chunk, message) {
        let skip = false
        try {
            let objMessage = JSON.parse(message)
            if(objMessage.code != 200) {
            this.statuShow = false
            // 暂停上传
            return true
    	}
        if (objMessage.data.skipUpload) {
            skip = true
        } else {
            skip = (objMessage.data.uploaded || []).indexOf(chunk.offset + 1) >= 0
        }
    	} catch (e) {}
   		 return skip
    },
}

点击上传动作后,首先执行onFileAdded函数,一般可以在这步校验文件等操作;这里我们先初始化进度条的状态和计算文件的md5md5的作用是保证文件的唯一性和不被更改,假如文件的内容改变了,它的值也随着改变。

onFileAdded(file) {
      // 初始化进度条
      this.progressShow = true
      this.percentage = 0
      this.statuShow = true
      // 计算MD5
      computeMD5(file).then((result) => this.startUpload(result))
    }

md5计算完毕后,开始发送请求到服务器

startUpload({md5, file}) {
      file.uniqueIdentifier = md5
      file.resume()
    },

注意:要支持文件断传和秒传功能,前端必须设置testChunk为true。simple-uploader会先发一次检测请求,用于检测文件块有哪些已经成功上传,或者文件是否已经上传成功,达到断传和秒传的功能。

这时checkChunkUploadedByResponse被调用,如果返回的结果code不为200发生异常,或者objMessage.data.skipUpload为true,文件已经上传过了并成功,则直接跳过。否则,根据返回的uploaded集合判断该chunk位移是否在集合中,在则跳过,不在后续发送该chunk上传请求。

该代码在initOption中

checkChunkUploadedByResponse: function (chunk, message) {
        let skip = false
        try {
            let objMessage = JSON.parse(message)
            if(objMessage.code != 200) {
            this.statuShow = false
            // 暂停上传
            return true
    		}
            if (objMessage.data.skipUpload) {
                skip = true
            } else {
                skip = (objMessage.data.uploaded || []).indexOf(chunk.offset + 1) >= 0
            }
    	} catch (e) {}
   		 return skip
},

文件上传成功执行:

onFileSuccess(rootFile, file, response, chunk) {
    let res = JSON.parse(response)
    // 服务端自定义的错误(即http状态码为200,但是是错误的情况),这种错误是Uploader无法拦截的
    if (!res || res.code != 200 || (res.data.status != 'success')) {
   		this.error()
        return
    }
    this.percentage = 100
},

文件上传错误执行:

onFileError(rootFile, file, response, chunk) {
	this.error()
},

error() {
    this.percentage = 100
    this.statuShow = false
}

分块上传请求监控:

onFileProgress(rootFile, file, chunk) {
    var res = chunk.xhr.response
    if(res) {
        var jsonRes = JSON.parse(chunk.xhr.response)
        if(jsonRes.code == 200) {
        	this.percentage = jsonRes.data.percentage
        }
    }
}

md5工具封装

import SparkMD5 from 'spark-md5'
  /**
   * 计算md5值,以实现断点续传及秒传
   * @param file
   * @returns Promise
   */
  export function computeMD5(file) {
    let fileReader = new FileReader()
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
    let currentChunk = 0
    const chunkSize = 1024 * 1024
    let chunks = Math.ceil(file.size / chunkSize)
    let spark = new SparkMD5.ArrayBuffer()

    // 文件状态设为"计算MD5"
    // this.statusSet(file.id, 'md5')
    file.pause()
    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()

    return new Promise((resolve, reject) => {
      fileReader.onload = (e) => {
        spark.append(e.target.result)

        if (currentChunk < chunks) {
          currentChunk++
          loadNext()
        } else {
          let md5 = spark.end()

          // md5计算完毕
          resolve({md5, file})

          console.log("计算md5完毕:" + md5)
        }
      }

      fileReader.onerror = function () {
        file.cancel()
        reject()
      }
    })
  }
后端实现

完整后端代码

无论是第一次检测还是后续分块上传,请求都到同一个接口,代码如下

// Post请求会导致第一次侦探请求失败!!!
    @RequestMapping("/upload")
    public Result uploadFile(Chunk chunk) {
       String path = parent + File.separator + chunk.getIdentifier();
        FileChunkResult chunkResult = FileUtil.chunkUpload(chunk, path);
        if (FileUploadConstant.FAILURE.equals(chunkResult.getStatus())) {
            return Result.error(ResultEnum.FILE_UPLOAD_ERROR);
        }
        return Result.success(chunkResult);
    }

首先要区分处理检测请求和分块上传请求,第一个检测请求的file为空且chunkNumber为1.

在前端配置正确的情况下,总是会在检测请求响应完毕后才会发送后续的上传分块。因此可以在检测请求收到后执行对应的初始化工作,如创建文件和检查文件是否已经上传过等,并将结果直接返回。

分块上传的请求,是已经根据检测请求校验过该分块尚未上传或者上传失败的,因此可以直接执行写入文件动作,并实时更新此时的文件进度。这里采用的是随机访问文件,支持多线程同时写入。还有一种方案是每个分块生成一个临时文件,都上传成功后再在前端的FileSuccess处理里发送合并请求。

那我们是如何知道上传的进度?这里采用的是多写一个配置文件,文件内容长度为文件分块的总数量,如果分块上传成功,则同时往配置文件写入Byte.MAX_VALUE

public static FileChunkResult chunkUpload(Chunk chunk, String path) {

        FileChunkResult fileChunkResult = new FileChunkResult();
        fileChunkResult.setStatus(FileUploadConstant.LOADING);

        try {
            if (chunk.getChunkNumber() == 1 && Objects.isNull(chunk.getFile())) {
                initUpload(chunk, path, fileChunkResult);
                return fileChunkResult;
            }
            // 新的文件块,执行写入操作
            chunkWrite(path, chunk, fileChunkResult);
            checkoutProgress(path, chunk, fileChunkResult);
        } catch (IOException e) {
            logger.error("上传文件块:{}-{}失败,原因:{}", chunk.getFilename(), chunk.getChunkNumber(), e.getMessage());
            fileChunkResult.setStatus(FileUploadConstant.FAILURE);
        } finally {
            return fileChunkResult;
        }
        
    }

初始化代码,如果文件没有创建则新建文件,confFile是用于记录文件块上传状态:

private static FileChunkResult initUpload(Chunk chunk, String path, FileChunkResult fileChunkResult) throws IOException {

        File tmpDir = new File(path);
        // 该文件用每个字节记录对应位置上传的状态,127代表上传成功
        File confFile = new File(path, chunk.getFilename() + ".conf");
        File tmpFile = new File(path, chunk.getFilename());

        // 目录不存在 新建目录
        if (!tmpDir.exists()) {
            tmpDir.mkdirs();
        }
        // 文件不存在 创建文件
        if (!confFile.exists()) {
            confFile.createNewFile();
            tmpFile.createNewFile();
        }
        // 第一次探测,初始化
        fileChunkResult.setStatus(FileUploadConstant.INIT);
        checkoutProgress(path, chunk, fileChunkResult);
        return fileChunkResult;
    }

写入文件代码,RandomAccessFile 可以多个文件块同时写入:

private static void chunkWrite(String tmpDir, Chunk chunk, FileChunkResult fileChunkResult) throws IOException {
        File tmpFile = new File(tmpDir, chunk.getFilename());
        File tmpConf = new File(tmpDir, chunk.getFilename() + ".conf");

        try (
                //将块文件写入文件中
                InputStream fos = chunk.getFile().getInputStream();
                RandomAccessFile raf = new RandomAccessFile(tmpFile, "rw");
                RandomAccessFile ramConf = new RandomAccessFile(tmpConf, "rw")
        ) {
            // 写入临时文件
            int len = -1;
            byte[] buffer = new byte[1024];
            raf.seek((chunk.getChunkNumber() - 1) * 1024 * 1024 * 5);
            while ((len = fos.read(buffer)) != -1) {
                raf.write(buffer, 0, len);
            }
            // 文件块写入成功,记录缓存文件
            ramConf.setLength(chunk.getTotalChunks());
            ramConf.seek(chunk.getChunkNumber() - 1);
            ramConf.write(Byte.MAX_VALUE);

        } catch (IOException e) {
            throw new IOException(e);
        }
    }

检查进度条:

private static void checkoutProgress(String path, Chunk chunk, FileChunkResult fileChunkResult) throws IOException {

        File confFile = new File(path, chunk.getFilename() + ".conf");
        List<Integer> uploaded = new ArrayList();
        byte[] completeList = FileUtils.readFileToByteArray(confFile);
        for (int i = 0; i < completeList.length; i++) {
            if (Byte.MAX_VALUE == completeList[i]) {
                uploaded.add(i + 1);
            }
        }
        fileChunkResult.setUploaded(uploaded);
        fileChunkResult.setPercentage(fileChunkResult.getUploaded().size() * 100 / chunk.getTotalChunks());

        if (uploaded.size() == chunk.getTotalChunks()) {
            fileChunkResult.setNeedMerge(true);
            fileChunkResult.setStatus(FileUploadConstant.SUCCESS);
            fileChunkResult.setPath(path + File.separator + chunk.getFilename());
        }
    }

分块请求实体:

@Data
public class Chunk implements Serializable {
    /**
     * 当前文件块,从1开始
     */
    private Integer chunkNumber;

    /**
     * 分块大小
     */
    private Long chunkSize;

    /**
     * 当前分块大小
     */
    private Long currentChunkSize;

    /**
     * 总大小
     */
    private Long totalSize;

    /**
     * 文件标识
     */
    private String identifier;

    /**
     * 文件名
     */
    private String filename;

    /**
     * 相对路径
     */
    private String relativePath;
    /**
     * 总块数
     */
    private Integer totalChunks;

    /**
     * 二进制文件
     */
    private MultipartFile file;


}

结果封装

@Data
public class FileChunkResult implements Serializable {

    /**
     * 分片是否需要跳过
     */
    private Boolean skipUpload = false;

    /**
     * 是否需要合并
     */
    private Boolean needMerge;

    /**
     * 已经上传的分片集合
     */
    private List<Integer> uploaded;

    /**
     * 文件上传成功后路径
     */
    private String path;

    /**
     * 目前文件上传状态
     */
    private String status;

    /**
     * 已上传的百分比
     */
    private int percentage;

}

文件上传状态常量

public class FileUploadConstant {

    public static final String INIT = "init";

    public static final String LOADING = "loading";

    public static final String SUCCESS = "success";

    public static final String FAILURE = "fail";
}