如何实现大文件上传(超强干货)

466 阅读6分钟

大文件上传,这是一个老生常谈的问题了,网上随便一搜,也有很多解决办法。由于前段时间我也刚好遇到这个业务,亲身实践了一回大文件上传,发现里面还是有很多需要注意的地方,所以将从前端上传到后端处理整个实现思路以及需要注意的点,都整理了一遍,希望对你有点儿用。

动手前先思考几个事儿

  • 分片上传中有一个需要注意的点,就是如何保证文件的完整性。比较普遍的一个想法是先在前端生成文件的md5值,但是前段生成文件的md5值是非常耗时的,有没有其他的方式解决?
  • 分片时,如何保证文件分片属于当前上传的文件?
  • 分片上传后,后端如何进行合并,如何保证文件的完整性?
  • 怎样合并才能达到效率最大化?

接下来的内容将主要解决以上几个问题。

前端实现思路

  1. 前端拿到本次上传的文件后,考虑到前端生成大文件的 md5 值是一个非常耗时的操作,因此调用后端服务生成一个此次上传的 filekey值作为标识。
  2. 获取到 filekey 值后,进行分片上传处理,此处使用了 plupload 组件进行了分片上传,当然也可以自己实现,实现起来也不复杂,关键点就是分片上传时,需要带有每次分块的文件file以外,还必须要带有以下几个值:此次上传的fileKeychunk(第几个分片)chunks(分片总数)size(整个文件大小)

该代码片段为使用plupload组件进行分片上传的示例:

<uploader
    browse_button="browse_button"
    :url="uploadUrl"
    chunk_size="200MB"
    :max_retries="3"
    :filters="{prevent_duplicates:true}"
    :FilesAdded="filesAdded"
    :BeforeUpload="beforeUpload"
    :Error="error"
    :UploadComplete="uploadComplete"
    :UploadProgress="UploadProgress"
    @inputUploader="inputUploader"
    drop_element = "drop_element"/>
  1. 上传完毕后,根据上传是否成功进行后续的业务处理。

后端实现

  1. 后端提供上传前的准备接口,返回前端需要的 filekey 以及其他需要的业务参数。
  2. 后端接收到分片上传的请求时,进行数据有效性校验,如:filekey的非空校验、文件大小size、分片总数chunks、当前分片数等数值是否合法;
  3. 将分块数据写入到本地磁盘中,本地分片保存成功后,就同步更新分片文件的状态,如果之前已有分片上传失败,就立即返回上传错误。如果检测到所有分片文件都上传成功,就开启异步线程合并分片数据以 提高合并的效率
    异步分片处理,保存分片到本地磁盘中,具体的实现思路为

1)创建一个 fileChunksCache ,用来存储所有文件分片上传的缓存。在收到分片上传请求时,先在 fileChunksCache 中根据 fileKey 获取 filechunks 对象,如果找不到,则说明是该 fileKey 只应的文件的第一个分片,创建一个新的 fileChunks 对象放到缓存中。

   private static final int INIT_SIZE = 64;

   private static final int MAX_SIZE = 8192;
    
   /**
    * 用来存储所有文件分片上传的缓存,其中 key 是之前生成的(fileKey),value 是文件所有分片相关信息的 FileChunks 对象.
    */
   private static final Cache<String, FileChunks> fileChunksCache;

   static {
       // 缓存对象.
       fileChunksCache = Caffeine.newBuilder()
               .expireAfterAccess(1, TimeUnit.HOURS)
               .initialCapacity(INIT_SIZE)
               .maximumSize(MAX_SIZE)
               .build();
   }

2) 将当前分片数据写入到本地磁盘中,如果保存失败,就删除之前已经存在的分片文件并在两分钟后清除缓存(此处需要延迟删除缓存是为了避免立即删除之后客户端查不到历史状态数据)。 3) 分片文件保存到本地成功之后,就同步更新文件的状态,如果所有分片没有全部上传成功,就立即返回。如果检测到所有分片文件数据都上传成功,就开启线程合并分片数据,并对合并后的文件进行校验。

使用NIO的方式多线程合并分片文件,核心算法部分代码如下

/**
 * 使用 NIO 的方式来多线程并发合并分片文件.
 *
 * <p>算法:先对该目录下的所有分片进行排序,然后按顺序两两合并,一直循环,直到合并到只有一个文件为止.</p>
 *
 * @param chunkDirPath 待合并的分片文件夹路径
 * @param mergedPath 文合并后的文件存放地址
 */
public static void concurrentMergeFiles(String chunkDirPath, String mergedPath) {
    while (true) {
        // 获取该目录下的所有文件名,并将文件名转换为 int 数字,然后排序
        // 如果当前目录下只剩下一个分片,说明合并完毕,直接返回即可.
        int[] fileNames = getSortedFileNames(chunkDirPath);
        int chunks = fileNames.length;
        if (chunks == 1) {
            moveFile(chunkDirPath + File.separator + fileNames[0], mergedPath);
            return;
        }

        // 根据分片数计算出分组的对数,按顺序两两分组,
        // 即: 1、2 为一组,3、4 为一组,...,如果最后只剩下一个分片文件,就使其单独为一组.
        int pairSize = chunks % 2 == 1 ? (chunks + 1) / 2 : chunks / 2;
        ChunkPathPair[] chunkPathPairs = new ChunkPathPair[pairSize];
        for (int i = 0; i < chunks; i = i + 2) {
            chunkPathPairs[i / 2] = new ChunkPathPair(
                    Paths.get(chunkDirPath + File.separator + fileNames[i]),
                    i == chunks - 1 ? null : Paths.get(chunkDirPath + File.separator + fileNames[i + 1]));
        }

        // 多线程并发合并分片文件,每次合并后都会删除多余被合并的文件,保留合并后的文件,本轮未合并完毕就一直阻塞.
        CompletableFuture
                .allOf(Stream.of(chunkPathPairs)
                        .map(chunkPathPair -> CompletableFuture.runAsync(() -> {
                            try {
                                mergeTwoFiles(chunkPathPair.leftPath(), chunkPathPair.rightPath());
                            } catch (Exception e) {
                                throw new RunException(e, "【分片合并 -> 失败】将文件【{}】追加合并到【{}】文件中出错!",
                                        chunkPathPair.rightPath(), chunkPathPair.leftPath());
                            }
                        }, fileExecutor))
                        .toArray(CompletableFuture[]::new))
                .join();
    }
}


/**
 * 合并两个文件,将右边的文件合并到左边的文件中,合并完成之后再删除掉右边的文件,左边的文件即为合并后的文件.
 *
 * @param leftPath 左边的文件路径
 * @param rightPath 右边的文件路径
 */
private static void mergeTwoFiles(Path leftPath, Path rightPath) throws IOException {
    if (rightPath != null) {
        try (FileChannel inChannel = FileChannel.open(rightPath, StandardOpenOption.READ);
                FileChannel outChannel = FileChannel.open(leftPath, CREATE_APPEND_OPTIONS)) {
            inChannel.transferTo(0, inChannel.size(), outChannel);
            Files.deleteIfExists(rightPath);
        }
    }
}

/**
 * 获取某个目录中所有文件的有序文件名,文件名以 int 表示,以有序数组返回.
 *
 * @param dirPath 目录路径
 * @return 文件名的有序数组
 */
private static int[] getSortedFileNames(String dirPath) {
    // 如果该目录下没有文件,则直接返回空数组.
    String[] fileNames = new File(dirPath).list();
    int len;
    if (fileNames == null || (len = fileNames.length) == 0) {
        return new int[0];
    }

    // 由于数字字符串的 ASCII 比较时, '10' 比 '2' 小. 所以,为了对文件名进行排序,必须先转换为数字再排序.
    int[] nameArr = new int[len];
    for (var i = 0; i < len; ++i) {
        nameArr[i] = Integer.parseInt(fileNames[i]);
    }
    Arrays.sort(nameArr);
    return nameArr;
}

/**
 * 文件分片路径对,用于文件两两分组,并发合并分片的场景.
 *
 * @param leftPath 左边的文件路径
 * @param rightPath 右边的文件路径
 */
private record ChunkPathPair(Path leftPath, Path rightPath) {

}
  1. 合并成功后,更新合并状态,并删除合并过程中的所有分片数据,如果合并失败,就更新状态为“合并失败”。
  2. 最后校验发布包分片后的文件大小是否与原分片前的大小相等,以保证 文件的完整性

总结

分片上传的几个要点总结如下:

  1. 不需要在前端计算md5值,前端计算大文件的md5值太耗时间,而是调用后端服务生成一个唯一的key值即可;
  2. 后端接收分片上传时,失败则立即删除已上传的分片文件且立即返回错误;
  3. 所有分片文件上传完毕后,开启异步多线程合并文件。