记一下大文件上传

178 阅读6分钟

好久没有动手写文章,最近碰上了一个文件上传功能,使用mac机器做服务器且只能使用无线,文件超过100m后经常上传失败,于是第一次真实开发大文件上传,在此记录一下流程和思路,供大家参考,本文章涉及​技术包括前后端,纯前端的同学可以参考实现,后面碰上类似需求,心中也能对整个需求的逻辑有个完整的认知

​思路

先列一下要解决的根本问题

  1. 大文件上传失败

  2. 上传失败后无法从之前已上传的地方继续上传 

解决上述根本问题,能想到的办法

  1. 将文件切分成多个切片后单个上传到服务器

  2. 服务器对文件切片进行整合

由此引发出的技术点

  1. 如何切片文件

  2. 服务器如何判断文件切片的归属

  3. 如何组合文件

归纳出节点,接下来就可以跟着这些节点,逐个实现他们

开干!!!

**文件切片
**

既然是对文件进行分片上传,那么第一个问题,如何对文件进行分片,**
**

我这里最初选择的方案是使用FileReader,将文件转成ArrayBuffer再进行切割,但实际上

根据类型提示看到,FileReader读出来是string,到后面上传的时候,request body就很大,还得处理一系列由此引发的问题,所以这个方案被放弃了,这个坑你们也就不用再来踩了
后来发现,File的原型链上本身是有slice方法的,并且切分出来还是Blob,通过formdata发送对服务端很友好,不墨迹,贴上分割文件的代码

import Mywork from '../worker/getmdWork.js?worker'
// 分割文件
export type BufferItem = { file: Blob, hash: string, idx: number, fileName: string }
/**
 * 切割文件
 * @param file // 目标文件
 * @param size // 切割大小,单位:mb
 * @returns BufferItem[]
 */
const splitFile: (file: File, size: number) => Promise<BufferItem[]> = (file, size = 5) => {
  return new Promise((resolve, reject) => {
    // webwork里获取文件md5,防止文件太大时,计算md5影响GUI线程
    const mywork = new Mywork()
    mywork.postMessage(file)
    mywork.onmessage = (data) => {
      if (data.data.success) {
        const bufferArr: BufferItem[] = []
        let cur = 0
        let idx = 0
        while (cur < file.size) {
          bufferArr.push({ file: file.slice(cur, cur + (size * 1024 * 1024)), hash: data.data.md5, idx, fileName: file.name })
          cur += size * 1024 * 1024
          idx++
        }
        resolve(bufferArr)
      } else {
        reject(data.data.err)
      }
    }
  })
}

注意promise中的注释,为了保证服务器能知道当前切片是归属于哪个文件,所以我获取了文件md5,但在最初的一版代码中,发现在计算md5时,页面会很卡,于是引入了webworker做了优化

// getmdWork.js
import BMF from 'browser-md5-file'

onmessage = (e) => {
  const bmf = new BMF()

  bmf.md5(e.data, (err, md5) => {
    if (err) {
      postMessage({
        success: false,
        err
      })
    } else {
      postMessage({
        success: true,
        md5
      })
    }
  })
}

注意看切割文件后返回的类型

type BufferItem = { file: Blob, hash: string, idx: number, fileName: string }

说下几个字段的作用

{  file: 文件切片  hash: 计算出的文件MD5值,用做服务端校验  idx: 当前切片的序号  fileName: 文件名称}

到了这里,切割文件基本就结束了,下一步就是考虑如何把这些切片文件一个个上传了,这里说下思路,两种办法,第一种即是定义个请求栈,依次进栈出栈,另外一种就是,对文件切片数组进行分割,一批一批的上传,我选择了第二种方式,心智模型稍微简单点,但无论选哪种方式,都需要先将第一个切片发到服务器去获得对应已上传信息,才能进行接下来的操作

// 点击上传方法
const uploadHandler = async (e: ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files ? e.target.files[0] : null
  if (!file) {
    message.error('请选择文件')
    return false
  }
  const fileType = file.name.split('.').pop()
  if (fileType !== 'apk' && fileType !== 'aab' && !isMac) {
    message.error('仅支持apk/aab上传')
    return false
  }
  try {
    setLoading(true)
    // 分割文件
    const bArr = await splitFile(file, 10)
    // 投石问路-先取出第一个切片到服务器获取信息
    const fir = bArr[0]
    const firFm = new FormData()
    // 放入切片
    firFm.append('file', fir.file)
    // 告知服务器该切片的排序
    firFm.append('idx', fir.idx.toString())
    // 告知服务器切片总数量
    firFm.append('length', bArr.length.toString())
    // 告知服务器原文件的md5
    firFm.append('md5', fir.hash)
    // 告知服务器源文件的名称
    firFm.append('fileName', fir.fileName)
    const { data: res } = await httpApi({
      // ...请求参数
    }).request
    if (res.status === 0) {
      // 服务器返回的已上传idx集合
      const { already } = res.data
      if (res.data.complete) {
        // 之前已上传成功,不再执行重复上传
        message.success('上传成功')
      } else {
        // 计算还需要上传的切片
        const needUploadIdxs = bArr.filter(v => !already.includes(v.idx))
        // 走完人生路-将还需要上传的切片逐个上传,uploadPiece方法在下面
        const pieceResult: number = needUploadIdxs.length > 0 && await uploadPiece(needUploadIdxs, bArr.length)
        // pieceResult即为本次上传失败的切片集合,根据它来计算出已上传百分比
        degRef.current = 1 - (pieceResult / bArr.length)
        if (pieceResult === 0) {
          message.success('上传成功')
        } else {
          throw new Error('上传结束,但是有部分分片上传失败')
        }
      }
    } else {
      throw new Error(res.message)
    }
  } catch (e) {
    Modal.confirm({
      title: `已上传${(degRef.current * 100).toFixed(0)}%`,
      content: '上传文件失败,是否从断开处继续上传?',
      okText: '重新上传',
      cancelText: '取消',
      onOk: () => {
        if (uploadRef.current) {
          uploadRef.current.value = ''
        }
        uploadRef.current!.click()
      }
    })
  } finally {
    if (uploadRef.current) {
      uploadRef.current.value = ''
    }
    setLoading(false)
  }
}
const uploadPiece = async (list: BufferItem[], length: number) => {
  let cur = 0
  const failList: any = []
  while (cur < list.length) {
    const rqList = list.slice(cur, cur + 5)
    const pieceResList = await Promise.allSettled(rqList.map(v => {
      const fm = new FormData()
      fm.append('file', v.file)
      fm.append('fileName', v.fileName)
      fm.append('length', String(length))
      fm.append('idx', String(v.idx))
      fm.append('md5', v.hash)
      return httpApi({
        apiId: 'pieceupload',
        state,
        method: 'POST',
        timeout: 120000,
        data: fm
      }).request
    }))
    const fail = pieceResList.filter(v => v.status === 'rejected' || v.value?.data?.status !== 0)
    failList.push(...fail)
    cur += 5
  }
  return failList.length
}

至此,前端部分完成

SERVER

先祭出我的思维导图 image.png

controller

@PostMapping(value = "/uploadSource", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AvalonHttpResp<?> uploadSource (SourceUploadDto dto) throws AvalonException,IOException {
    App app = appService.getById(dto.getAppId());
    if (null == app) {
        throw new AvalonException(PARAM_ERROR, "无效的appId");
    }
    return AvalonHttpResp.ok(uploadFileService.UploadSourcePiece(dto, app.getAppId()));
}

impl

@Override
public UploadResDto UploadSourcePiece (
        SourceUploadDto dto,
        String appCode
) throws AvalonException, IOException {
    UploadResDto result = new UploadResDto();
    // 存放目录
    String rootPath = PathUtil.isWindows() ? winPath : unixPath;
    rootPath = rootPath.endsWith("/") ? rootPath : rootPath + "/";
    String path = rootPath + motherPath + appCode + "/";
    // 验证map种是否已有对应Md5
    SourceRepoItem target = SourceUploadRepo.getValue(dto.getMd5());
    if (target == null) { // map中没有对应md5key
        // 查询是否有md5目录,有则说明上次未上传成功,且中途map被清理过
        File md5File = new File(path + dto.getMd5());
        dealFileWithOutKey(md5File, dto.getLength(), dto.getMd5(), dto.getFileName(), appCode);
        // 此时map中已经有了对应md5key
    }
    // 校验当前片是否上传过
    target = SourceUploadRepo.getValue(dto.getMd5());
    if (target.length == 0) { // 之前已上传成功
        result.setSuccess(true);
        result.setComplete(true);
        result.setAlready(new HashSet<>());
        return result;
    } else {
        boolean hasSave = target.getAlready().contains(Integer.parseInt(dto.getIdx()));
        if (!hasSave) {
            // 存放切片
            // 判断目录是否存在
            File dir = new File(path + dto.getMd5());
            if (!dir.exists()) {
                dir.mkdirs();
            }
            // 写入文件
            FileUtils.byteFileToFile(dto.getFile(), path + dto.getMd5() + "/" + dto.getIdx());
            // 录入Map
            Set<Integer> before = target.getAlready();
            before.add(Integer.parseInt(dto.getIdx()));
            target.setAlready(before);
            SourceUploadRepo.setSourceMap(dto.getMd5(), target);
        }
        result.setSuccess(true);
        result.setAlready(target.getAlready());
        result.setComplete(target.getLength() == target.getAlready().size());
    }
    // 上传完成操作
    if (target.length == target.getAlready().size()) {
        dealFileComplete(dto.getMd5(), dto.getFileName(), target.getLength(), appCode);
        SourceUploadRepo.delSourceMapKey(dto.getMd5());
        motherPackagesService.saveMotherPackageInfo(dto.getFileName(), appCode);
    }
    return result;
}

/**
 * 处理分片上传时,map中无法找到对应key的情况
 * @param file
 */
public void dealFileWithOutKey (File file, String length, String md5, String fileName, String appCode) throws IOException {
    // 存放目录
    String rootPath = PathUtil.isWindows() ? winPath : unixPath;
    rootPath = rootPath.endsWith("/") ? rootPath : rootPath + "/";
    String path = rootPath + motherPath + appCode + "/";

    Set<Integer> set = new HashSet<>(); // already
    SourceRepoItem saveItem = new SourceRepoItem(); // md5: value<-
    if (file.exists()) {
        File[] fileList = file.listFiles();
        if (Objects.requireNonNull(fileList).length > 0) {
            for (File item : fileList) {
                try {
                    Integer idx = Integer.parseInt(item.getName());
                    set.add(idx);
                } catch (NumberFormatException e) {
                    log.error(file.getName() + "非碎片文件");
                }
            }
        }

        saveItem.setLength(Integer.parseInt(length));
        saveItem.setAlready(set);
    } else {
        File alreadyFile = new File(path + fileName);
        if (alreadyFile.exists()) {
            String alreadyMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(alreadyFile));
            if (alreadyMd5.equals(md5)) {
                saveItem.setLength(0);
            } else {
                saveItem.setLength(Integer.parseInt(length));
            }
        } else {
            saveItem.setLength(Integer.parseInt(length));
        }
        saveItem.setAlready(new HashSet<>());
    }
    SourceUploadRepo.setSourceMap(md5, saveItem);
}

/**
 * 上传完成组合文件
 */
public void dealFileComplete (String md5, String fileName, Integer length, String appCode) throws IOException {
    // 存放目录
    String rootPath = PathUtil.isWindows() ? winPath : unixPath;
    rootPath = rootPath.endsWith("/") ? rootPath : rootPath + "/";
    String path = rootPath + motherPath + appCode + "/";

    File dir = new File(path + md5);
    if (dir.exists()) { // md5目录存在,才进行文件组合,如果不在,则说明本次服务启动期间,已经上传完成
        RandomAccessFile raf = null;
        raf = new RandomAccessFile(new File(path + fileName), "rw");
        try {
            for (int i = 0; i < length; i++) {
                File file = new File(path + md5 + "/" + i);
                if (!file.exists()) {
                    throw new AvalonException(UPLOAD_ERROR, i + "文件丢失");
                }
                RandomAccessFile reader = new RandomAccessFile(file, "r");
                byte[] b = new byte[1024];

                int n = 0;

                while((n = reader.read(b)) != -1){

                    raf.write(b, 0, n);//一边读,一边写

                }
                reader.close();
            }
        } finally {
            try {
                raf.close();
                FileUtils.deleteDir(path + md5);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

需要注意的是,内存中有个map结构,存放了以md5为key的属性,这就是server判断文件上传状态的依据,比较复杂的在于,当上传到一半,服务器突然断电了,那么重启后的处理逻辑就比较绕,我在导图和代码中也做了对应表述