MySpace——压缩文件夹优化

43 阅读5分钟

背景:压缩文件夹

image.png

@Override
public BaseResponse<GetFolderZipResult> getFolderZipById(String taskId, String fileId, HttpServletRequest request) {
    // 校验文件夹是否存在
    long start1 = System.currentTimeMillis();
    File folder = this.getById(fileId);
    if (folder == null || folder.getIsFolder() != 1) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件夹不存在或操作资源不是文件夹");
    }

    String userId = folder.getUserId();
    // 创建临时目录和文件
    String tempDirPath = ZIP_TEMP_DIR + "/" + userId + "/" + java.util.UUID.randomUUID() + "/";
    String zipFileName = "folder_" + System.currentTimeMillis() + ".zip";
    java.io.File tempZipFile = new java.io.File(tempDirPath, zipFileName);
    // 绝对路径
    String absolutePath = tempZipFile.getAbsolutePath();
    // 压缩文件的地址
    LambdaUpdateWrapper<Downloadtask> updateWrapper = new LambdaUpdateWrapper<Downloadtask>()
            .eq(Downloadtask::getTaskId, taskId).set(Downloadtask::getStoragePath, absolutePath);
    if (!downloadtaskService.update(updateWrapper)) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "本地路径存储失败");
    }

    try {
        // 确保目录存在
        FileUtil.mkdir(tempDirPath);

        // 创建ZIP输出流
        try (ZipOutputStream zipOutputStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(tempZipFile)))) {
            // 获取所有子文件
            List<File> fileList = getAllChildren(fileId, userId);
            Map<String, List<File>> childrenByParent = fileList.stream()
                    .collect(Collectors.groupingBy(File::getParentId));
            Map<String, File> fileMap = fileList.stream()
                    .collect(Collectors.toMap(File::getFileId, Function.identity()));

            // 添加根目录
            String rootDirName = folder.getName() + "/";
            zipOutputStream.putNextEntry(new ZipEntry(rootDirName));
            zipOutputStream.closeEntry();

            // 递归添加文件到ZIP
            processChildrenForZipping(fileId, rootDirName, childrenByParent, fileMap, zipOutputStream);
        }
        long end1 = System.currentTimeMillis();
        System.out.println("执行本地压缩消耗的时间:" + (end1 - start1));
        // 上传到COS
        String cosStoragePath = FilePathUtil.generateStoragePath(
                FileConstant.BIZ_TYPE_ZIP,
                userId,
                folder.getName() + ".zip"
        );
        cosManager.putObject(cosStoragePath, tempZipFile);
        long end2 = System.currentTimeMillis();
        System.out.println("执行上传过程消耗的时间:" + (end2 - end1));
        String url = FilePathUtil.generateAccessUrl(cosStoragePath);
        GetFolderZipResult result = new GetFolderZipResult();
        result.setUrl(url);
        result.setStoragePath(absolutePath);
        result.setCompleted(true);
        result.setTotalSize(tempZipFile.length());
        return ResultUtils.success(result);
    } catch (IOException e) {
        log.error("ZIP文件创建失败:", e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "压缩文件创建失败");
    } catch (CosClientException e) {
        log.error("COS上传失败:", e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件上传失败");
    }
}

这是原本的代码,发现执行耗时非常的长,明明只是压缩20M左右的文件夹,但是执行总耗时却要47秒(差不多一分钟)。

主包肯定是不能允许这种情况存在的。

这显然很不合理,那么为什么会这么耗时呢?主要是因为我的系统的做法是将文件存储到第三方对象存储中,然后如果用户进行文件夹下载的话就需要去不断的从cos获取文件并写入到压缩文件对象中,然后最后进行返回,可见整个流程肯定是非常耗时的。

我一开始排查耗时的原因的时候首先我怀疑的是因为使用的是java原生的方式进行压缩,导致非常的耗时,后面就采用 zstd 进行压缩,但是我发现好像影响不是很大,哪怕我直接把 level调整为1,耗时也只比之前快了不到一秒的时间。

那这不是等于优化了一个寂寞?

那么很明显肯定还有其他的地方执行耗时比较高

long startDownload = System.currentTimeMillis();
COSObject object = cosClientConfig.cosClient().getObject(cosClientConfig.getBucket(), parseStoragePath(file.getStoragePath()));
long downloadTime = System.currentTimeMillis() - startDownload;
System.out.println(downloadTime);
522
118
118
105
86
68
81
82
72
82
79
……

经过我的排查发现,这个从cos获取文件的代码貌似就是罪魁祸首,然后我输出执行耗时发现,虽然每个消耗的时间都不多,但是当文件一多起来的时候就需要消耗很多的时间,主要原因还是因为是串行执行

那么到这里要修改的方式就很简单了,那么就是改成并行执行

@Override
  public BaseResponse<GetFolderZipResult> getFolderZipById(String taskId, String fileId, HttpServletRequest request) {
      // 校验文件夹是否存在
      File folder = this.getById(fileId);
      if (folder == null || folder.getIsFolder() != 1) {
          throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件夹不存在或操作资源不是文件夹");
      }

      String userId = folder.getUserId();
      // 创建临时目录和文件
      String tempDirPath = ZIP_TEMP_DIR + "/" + userId + "/" + java.util.UUID.randomUUID() + "/";
      String zipFileName = "folder_" + System.currentTimeMillis() + ".tar.zst";
      java.io.File tempZipFile = new java.io.File(tempDirPath, zipFileName);
      // 绝对路径
      String absolutePath = tempZipFile.getAbsolutePath();
      // 压缩文件的地址
      LambdaUpdateWrapper<Downloadtask> updateWrapper = new LambdaUpdateWrapper<Downloadtask>()
              .eq(Downloadtask::getTaskId, taskId).set(Downloadtask::getStoragePath, absolutePath);
      if (!downloadtaskService.update(updateWrapper)) {
          throw new BusinessException(ErrorCode.OPERATION_ERROR, "本地路径存储失败");
      }

      try {
          // 确保目录存在
          FileUtil.mkdir(tempDirPath);

          // 1. 准备待下载文件列表
          List<FileDownloadInfo> downloadInfos = prepareFilesForZip(fileId, userId, folder.getName());

          // 2. 并行下载文件
          Map<String, byte[]> downloadedFiles = new ConcurrentHashMap<>();
          ExecutorService executor = Executors.newFixedThreadPool(10); // 调整线程池大小
          List<CompletableFuture<Void>> futures = new ArrayList<>();

          for (FileDownloadInfo downloadInfo : downloadInfos) {
              if (!downloadInfo.isFolder()) {
                  futures.add(CompletableFuture.runAsync(() -> {
                      try {
                          COSObject object = cosClientConfig.cosClient().getObject(cosClientConfig.getBucket(), parseStoragePath(downloadInfo.getStoragePath()));
                          try (InputStream cosStream = object.getObjectContent();
                               ByteArrayOutputStream bos = new ByteArrayOutputStream()) { // 或下载到临时文件
                              IOUtils.copy(cosStream, bos);
                              downloadedFiles.put(downloadInfo.getRelativePath(), bos.toByteArray()); // 或存储临时文件路径
                          }
                      } catch (CosClientException | IOException e) {
                          log.error("COS 文件下载失败:{}", downloadInfo.getStoragePath(), e);
                          // 可以选择抛出异常或记录错误,根据业务需求处理
                      }
                  }, executor));
              }
          }

          CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
          executor.shutdown();

          long startCompress = System.currentTimeMillis();
          // 3. 写入 ZIP 归档
          try (OutputStream fOut = new FileOutputStream(tempZipFile);
               ZstdCompressorOutputStream zstdOut = new ZstdCompressorOutputStream(fOut, 1);
               TarArchiveOutputStream tarOut = new TarArchiveOutputStream(zstdOut)) {

              tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
              writeToZipArchive(downloadInfos, downloadedFiles, tarOut);
          }
          long endCompress = System.currentTimeMillis();
          System.out.println("执行本地压缩消耗的时间:" + (endCompress - startCompress));

          // 4. 上传到 COS (与原逻辑相同)
          String cosStoragePath = FilePathUtil.generateStoragePath(
                  FileConstant.BIZ_TYPE_ZIP,
                  userId,
                  folder.getName() + ".tar.zst"
          );
          cosManager.putObject(cosStoragePath, tempZipFile);
          long endUpload = System.currentTimeMillis();
          System.out.println("执行上传过程消耗的时间:" + (endUpload - endCompress));
          String url = FilePathUtil.generateAccessUrl(cosStoragePath);
          GetFolderZipResult result = new GetFolderZipResult();
          result.setUrl(url);
          result.setStoragePath(absolutePath);
          result.setCompleted(true);
          result.setTotalSize(tempZipFile.length());
          System.out.println("压缩文件的大小:" + tempZipFile.length());
          return ResultUtils.success(result);

      } catch (IOException e) {
          log.error("ZIP 文件创建失败:", e);
          throw new BusinessException(ErrorCode.SYSTEM_ERROR, "压缩文件创建失败");
      } catch (CosClientException e) {
          log.error("COS 上传失败:", e);
          throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件上传失败");
      }
  }

  private List<FileDownloadInfo> prepareFilesForZip(String parentId, String userId, String rootFolderName) {
      List<File> allFiles = getAllChildren(parentId, userId);
      List<FileDownloadInfo> downloadInfos = new ArrayList<>();
      Map<String, File> fileMap = allFiles.stream().collect(Collectors.toMap(File::getFileId, Function.identity()));

      // 添加根目录
      downloadInfos.add(new FileDownloadInfo(getById(parentId), rootFolderName + "/"));

      // 递归构建相对路径
      buildDownloadInfo(parentId, rootFolderName + "/", fileMap, downloadInfos);
      return downloadInfos;
  }

  private void buildDownloadInfo(String parentId, String parentPath, Map<String, File> fileMap, List<FileDownloadInfo> downloadInfos) {
      List<File> children = getAllChildrenByParentId(parentId); // 假设有这个方法
      for (File child : children) {
          String relativePath = parentPath + child.getName() + (child.getIsFolder() == 1 ? "/" : "");
          downloadInfos.add(new FileDownloadInfo(child, relativePath));
          if (child.getIsFolder() == 1) {
              buildDownloadInfo(child.getFileId(), relativePath, fileMap, downloadInfos);
          }
      }
  }

  private void writeToZipArchive(List<FileDownloadInfo> downloadInfos, Map<String, byte[]> downloadedFiles, TarArchiveOutputStream tarOut) throws IOException {
      for (FileDownloadInfo fileInfo : downloadInfos) {
          if (fileInfo.isFolder()) {
              addDirectoryToTar(fileInfo.getRelativePath(), fileInfo.getUpdateTime(), tarOut);
          } else {
              TarArchiveEntry entry = new TarArchiveEntry(fileInfo.getRelativePath());
              entry.setSize(fileInfo.getSize() != null ? fileInfo.getSize() : 0);
              entry.setModTime(fileInfo.getUpdateTime().getTime());
              entry.setMode(0644);
              tarOut.putArchiveEntry(entry);
              byte[] content = downloadedFiles.get(fileInfo.getRelativePath());
              if (content != null) {
                  IOUtils.copy(new ByteArrayInputStream(content), tarOut);
              } else {
                  log.warn("文件内容未找到:{}", fileInfo.getRelativePath());
                  // 可以选择抛出异常或记录警告
              }
              tarOut.closeArchiveEntry();
          }
      }
  }

  private List<File> getAllChildrenByParentId(String parentId) {
      // 实现根据 parentId 查询子文件和文件夹的逻辑
      LambdaQueryWrapper<File> queryWrapper = new LambdaQueryWrapper<>();
      queryWrapper.eq(File::getParentId, parentId)
              .orderByAsc(File::getIsFolder); // 文件夹在前
      // 可以直接在 getAllChildren 方法中处理层级关系,返回扁平列表,然后在 prepareFilesForZip 中构建层级信息
      return this.list(queryWrapper); // 替换为实际的数据库查询
  }

  private void addDirectoryToTar(String entryPath, Date modifiedTime, TarArchiveOutputStream tarOut) throws IOException {
      TarArchiveEntry dirEntry = new TarArchiveEntry(entryPath);
      dirEntry.setSize(0); // 文件夹大小强制为0
      dirEntry.setMode(0755);
      if (modifiedTime != null) {
          dirEntry.setModTime(modifiedTime.getTime());
      }
      tarOut.putArchiveEntry(dirEntry);
      tarOut.closeArchiveEntry();
  }
  

使用多线程之后,那个速度可谓是非常的快啊

和之前的简直就是一个天一个地

只能说是非一般的感觉好吧

我这里设置的线程池大小是10,其实我也试过20,会更加的快一点,但是其实也没必要了,10够用了,没必要再去浪费资源,大家可以根据自己的系统去进行设计。

最后如果各位大佬还有什么优化的思路的话,欢迎大家进行指教,我一定会好好向各位大佬学习的。

谢谢这位靓仔靓女的观看。