VFS - 虚拟文件系统的加载和导出

Posted isea533

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了VFS - 虚拟文件系统的加载和导出相关的知识,希望对你有一定的参考价值。

  1. VFS - 代码生成器预览功能实现
  2. VFS - 虚拟文件系统基本操作方法的封装
  3. VFS - 虚拟文件系统的加载和导出

这是 VFS 的最后一篇,前面两篇中的基本方法已经实现了一个简单的虚拟文件系统,可以创建目录和文件,可以读写文件的内容。在这最后一篇中,为了让VFS能和实际的文件系统产生交互,将真实存在的变成虚拟的,将虚拟的变成真实存在的,这就是本文最后要实现的两个大的接口。

由于VFS是一个带有目录结构的虚拟文件系统,除了能直接和操作系统的文件系统映射读写外,和 ZIP 压缩文件的转换和读写也非常的有必要,我们可以将整个虚拟文件系统转换为一个 ZIP 压缩包,不仅方便测试,也方便整个虚拟文件系统的序列化和反序列化。

再开始 VFS 具体内容前,先看看实现过程中在 ZIP 文件处理上踩到的两个坑。

两个坑

我博客2012年有一篇 Java解压缩zip - 解压缩多个文件或文件夹,后续工作中偶尔也会用到 ZIP 解压缩的功能,大多数都直接用的现成类库封装的方法。个别情况下需要基于纯内存(不从磁盘读取文件,压缩不写入磁盘)解压缩 ZIP 文件时也直接操作过 Java API。

最近遇到一些坑,有些是很基础的内容,本以为自己可以随便玩这些API了,结果被自己坑到了,都是一些细节。

如何关闭 Java 文件流

我用 ZipOutputStream 导出 zip 文件后,发现导出的 zip 文件可以用工具打开,但是不能再次通过 Java 读取?
在这里插入图片描述
生成 zip 时我是这么写的:

private void syncZip(File zip) {
  try (FileOutputStream fos = new FileOutputStream(zip)) {
    ZipOutputStream zos = new ZipOutputStream(fos);
    toZip(zos, this.name.toString());
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

创建了一个文件流,又装饰了一层 zip 输出流。最后在 try() 中关闭了文件流,是不是看着没什么大错。

这里最大的错误我关闭错了流,这是一个不该出现的BUG。

我的一些经验告诉我,有些输入输出关闭没什么用(如 ByteArrayOutputStream),有些关闭只是为了解除文件的占用(FileOutputStream),Java 装饰模式的流设计往往会嵌套很多层,关闭的时候只需要关闭外层,还是随便关闭一个都可以?

现在想想这可能不应该是一个问题,如果是我来设计,肯定也只需要关闭最外层的流,不可能脑残到让人从外往内一层层关闭或者从内往外一层层关闭,这也要求扩展的人实现时,一定要执行被装饰对象的必要方法。装饰模式的方法调用时,也必须从最外层开始调用,只有外层知道里面被装饰的对象,只有这样才能一层层清理干净。

ZipOutputStreamclose 方法中做了很多事,包括把 zip 完整的结构信息输出完整,还包含了被装饰对象的关闭操作,上面的代码只需要改成下面这样就行:

private void syncZip(File zip) {
  try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) {
    toZip(zos, this.name.toString());
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

压缩文件分隔符

现在想想在 2012 的 Java解压缩zip - 解压缩多个文件或文件夹 中有个坑能避开也只是因为我不喜欢 Windows 中的文件分隔符 \\\\,由于转义的原因需要写两遍,用 UNIX 方式的 / (正斜杠)就没那么麻烦,所以那篇博客好多地方都是统一转换 path = path.replaceAll("\\\\\\\\", "/"),这里之所以是四个 \\ 是因为正则还要一层转义,你说 Windows 的分隔符麻不麻烦。

我估计自己当时也不知道 File.separator 代表了当前系统的分隔符,如果知道可能就不做统一转换了。现在觉得自己懂了,所以我压缩文件的时候,就用 parentPath + File.separator + fileName 作为 ZipEntry 的压缩文件名。当我打开压缩好的文件时,发现了了不得的事情:
在这里插入图片描述
为什么每个目录名还同时存在一个文件,明明昨天还好好的,今天怎么就不行了。这种问题最不容易解决,同时通过对比代码也容易猜测问题,最后发现问题的原因就是昨天的代码还是 parentPath + SLASH + fileName(public static final String SLASH = "/"),经过修改测试发现问题解决,再深入了解发现下面的答案:

The .zip file specification states:

4.4.17.1 The name of the file, with optional relative path. The path stored MUST not contain a drive or device letter, or a leading slash. All slashes MUST be forward slashes ‘/’ as opposed to backwards slashes ‘’ for compatibility with Amiga and UNIX file systems etc. If input came from standard input, there is no file name field.

翻译:

4.4.17.1文件的名称,可选相对路径。存储的路径不得包含驱动器号、设备号或前导斜杠。为了与Amiga和UNIX文件系统等兼容,所有斜杠必须是与反斜杠“\\”相对的正斜杠“/”。如果输入来自标准输入,则没有文件名字段。

ZIP文件规范要求必须使用 slashes '/',看了看 Java 源码,只发现有点接近的代码:

 /**
  * Returns true if this is a directory entry. A directory entry is
  * defined to be one whose name ends with a '/'.
  * @return true if this is a directory entry
  */
 public boolean isDirectory() {
     return name.endsWith("/");
 }

Java 没有处理 Windows 上的 ZIP 压缩文件的分隔符,必须了解 ZIP 文件规范才能正常使用,这也算是 Java 的BUG。

虽然最终知道是分隔符用错了,但是没有继续深入去看为什么 Java 会同时存在同名的目录和文件。

下面回归正题,开始介绍导入和导出的方法。

VFS 导入目录和ZIP文件

VFS的作用就是修改里面的内容不影响物理目录和文件的内容,除了从头构建整个VFS外,许多时候还会基于已有的目录进行修改,此时如果要手工照着现成的目录结构创建肯定懒的不想动手。因此加载一个现有目录到VFS中就必不可少。

除了最常见的目录外,一个 ZIP 压缩包天然就是一个简单存在的虚拟文件系统,ZIP文件和这里的VFS几乎就是一对,ZIP是VFS更好的物理体现,VFS是ZIP更简单的内存抽象,VFS比ZIP操作目录结构和文件内容的API更简单和方便(VFS的内容都在内存中,比直接写入流占用的内容更多,具体使用要看场景)。

基于上述两个方便,VFS一定要能导入目录和ZIP文件,再具体实现中,根据传入的 File 来判断是目录还是 ZIP 文件,在 VFS 中有如下方法:

private static boolean isZip(File file) {
  return !file.isDirectory() && file.getName().toLowerCase().endsWith(".zip");
}

加载目录和文件后,后续还需要考虑如果要原文件写回还需要记录加载的文件信息,因此在 VFS 中还增加了下面的字段用来记录加载的文件:

private File file;

准备好上面的字段和方法后,下面开始介绍加载方法:

public static VFS load(File file) {
  if (isZip(file)) {
    return loadZip(file);
  } else if (file.isDirectory()) {
    return loadFolder(file);
  } else {
    throw new IllegalArgumentException("VFS 加载支持目录和 zip 压缩文件,不支持其他类型文件的加载");
  }
}

提供了一个静态 load 方法,方法中支持 ZIP 和目录两种形式的 File,先看 ZIP 这条路。

loadZip 加载 ZIP 文件

private static VFS loadZip(File file) {
  try (ZipFile zipFile = new ZipFile(file)) {
    //ZIP文件的根路径设置为空
    VFS vfs = VFS.of("");
    //记录加载的文件,用于后续写回ZIP文件
    vfs.file = file;
    //遍历ZIP中的所有文件
    Enumeration<? extends ZipEntry> entries = zipFile.entries();
    while (entries.hasMoreElements()) {
      //加载所有 ZipEntry
      loadZipEntry(vfs, zipFile, entries.nextElement());
    }
    return vfs;
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

上面就是遍历所有ZIP中的文件,调用的 loadZipEntry 方法如下:

private static void loadZipEntry(VFS vfs, ZipFile zipFile, ZipEntry zipEntry) {
  //目录时
  if (zipEntry.isDirectory()) {
    //根据名称创建目录,例如 src/main/java
    vfs.mkdirs(zipEntry.getName());
  } else {
    //文件时,读取文件内容
    try (InputStream inputStream = zipFile.getInputStream(zipEntry);) {
      //将文件写入到vfs
      vfs.write(zipEntry.getName(), IoUtil.readBytes(inputStream));
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }
}

通过 vfs.mkdirsvfs.write 很容易就把 ZIP 文件加载到了 VFS 中,下面再看加载目录。

loadFolder 加载目录

private static VFS loadFolder(File folder) {
  //记录文件实际的路径为根路径,后续可以支持绝对路径的写入
  VFS vfs = new VFS(folder.toPath());
  //记录目录,用于后续可能的回写
  vfs.file = folder;
  //递归加载所有子文件,加载的 folder 在前面限制过,一定是目录
  if (folder.exists() && folder.isDirectory()) {
    //加载子目录 folder.listFiles() 的所有文件
    loadFolderFiles(vfs, folder.listFiles());
  }
  return vfs;
}

private static void loadFolderFiles(VFS vfs, File[] files) {
  for (File file : files) {
    //文件时
    if (file.isFile()) {
      //写入文件内容
      vfs.write(file, FileUtil.readBytes(file));
    } else {
      //创建目录
      vfs.mkdirs(file);
      //递归获取子目录内容
      loadFolderFiles(vfs, file.listFiles());
    }
  }
}

仍然是通过 vfs.mkdirsvfs.write 就很容易就把操作系统中的目录加载到了 VFS 中,就目前的简单需求而言,这两个方法就足够创建一个VFS。

VFS 导出(同步)目录和ZIP文件

通过上面 load 可以直接创建一个带有目录结构和文件内容的 VFS,通过前面两篇文章的内容,也可以纯手工创建一个 VFS。除了直接在程序中读取VFS的内容外,有时还需要将VFS的内容生成实际的目录结构和文件,为了方便备份或者存储也会生成 ZIP 文件,导入和导出的主要区别在于迭代对象的不同,导入时迭代的是系统的目录或者ZIP文件,导出时迭代的是VFS本身的结构,下面看具体方法。

public void syncDisk() {
  //当通过 load 或者 VFS.of(File) 方式创建 VFS 时,会有 file,此时直接原文件写入即可
  if (file != null) {
    syncDisk(file);
  } else if (path.isAbsolute()) {
    //当通过 VFS.of(Path) 传入绝对路径时,可以直接写入该位置
    syncDisk(path.getParent().toFile());
  } else {
    throw new RuntimeException("VFS的根路径path[ " + path + " ]为相对路径,不存在对应的物理文件,无法通过当前方法写入磁盘");
  }
}

//写入到指定的目录或 ZIP 文件
public void syncDisk(File file) {
  if (isZip(file)) {
    //写入 ZIP
    syncZip(file);
  } else {
    //写入系统目录,创建最外层的目录
    file.mkdirs();
    //调用 VFSNode.syncDisk 方法,使用 file 所在的绝对路径创建子VFSNode
    syncDisk(file.getAbsolutePath());
  }
}

上面代码中仍然分成了导出 ZIP 和系统目录两种情况。

syncZip 导出 ZIP

private void syncZip(File zip) {
  //创建 zip 输出流,在 try() 中的流会自动关闭
  try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) {
    //VFS根目录名,可能是空、/、\\\\和具体名字
    String parentPath = this.name.toString();
    //下面两种情况下根目录没有名字
    if (parentPath.equals(SLASH) || parentPath.equals("\\\\")) {
      parentPath = "";
    }
    //如果有目录名,必须带上 / 后缀,只有带后缀才会认为是目录
    if (StrUtil.isNotEmpty(parentPath)) {
      parentPath += SLASH;
    }
    //开始写入 VFS 中的子节点(文件),下面这个方法定义在 VFSNode 中
    toZip(zos, parentPath);
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

具体看 toZip 方法:

protected void toZip(ZipOutputStream zos, String parentPath) {
  //遍历所有子节点
  forEach(node -> {
    try {
      //存在 parentPath时继续拼,否则当前作为ZIP中第一级目录名(可以有很多同级)
      String path = StrUtil.isNotEmpty(parentPath) ?
          (parentPath + node.name) : node.name.toString();
      //如果是目录
      if (node.isDirectory()) {
        //目录必须有 / 后缀
        path = path + SLASH;
        //写入目录
        zos.putNextEntry(new ZipEntry(path));
        zos.closeEntry();
        //递归子节点处理,传递 path
        node.toZip(zos, path);
      } else {
        //创建文件
        zos.putNextEntry(new ZipEntry(path));
        //写入文件内容
        IoUtil.copy(new ByteArrayInputStream(node.bytes), zos);
        zos.closeEntry();
      }
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  });
}

通过 VFSNode#toZip 方法的递归,很容易就能实现导出 ZIP 的功能。

syncDisk 根据 parentPath 写入目录

/**
 * 根据相对路径写入文件
 */
protected void syncDisk(String parentPath) {
  //根据当前的路径创建文件
  File file;
  //根据父路径和当前文件名创建绝对路径的文件
  if (StrUtil.isBlank(name.toString())) {
    file = FileUtil.file(parentPath);
  } else {
    file = FileUtil.file(parentPath, name.toString());
  }
  //当前节点是目录
  if (isDirectory()) {
    //创建目录
    file.mkdir();
  } else if (isFile()) {
    //创建文件并写入内容
    FileUtil.writeBytes(bytes, file);
  }
  //处理子级
  forEach(node -> {
    node.syncDisk(file.getAbsolutePath());
  });
}

仍然是通过递归简单的实现了生成目录的功能。

总结

实现 VFS 的基本功能花了几个小时的时间,后续补充导入导出功能和这3篇文章又花了几天的时间,虽然代码很少,但是整体耗时很多,有20%的时间在写代码,有40%的时间在测试和修改,还有40%的时间在写这3篇文章。

每当实现一个工具时,总有一个想法:“在不同的时间开始写工具(代码),实现的方式和结果都不一样”,每次真正开始动手写的时候,实现的都是某个时刻的想法,换个时间再写就会写出不一样的东西。

写东西之前能想好、设计好时有必要的,但是有时遇到的问题是 “想了很久很久,思路就是不连贯或者透彻,总是觉得很复杂,无法下手” ,此时就会在这种状态耽误很多时间,为了避免这种没有结果的思考,许多时候我会先动手随便写代码,能实现功能就行,实现的过程中再反复重构。实现功能和重构的过程是思考和设计的结果,从最终得到的代码来反推设计就得到了这3篇文章的内容,这3篇文章看着是比较透彻简单的叙述就实现了VFS,但真正的过程非常繁复。

当纯粹的思考设计没有有意义的产出时,尽早动手实现一个最小工具(产品,MVP)也是一个方法。

源码下载

链接: https://pan.baidu.com/s/14E_MWbc0WftZUA6ApQTZ8w
提取码: 29in

在这里插入图片描述
微信扫码即可获取文件

以上是关于VFS - 虚拟文件系统的加载和导出的主要内容,如果未能解决你的问题,请参考以下文章

VFS - 虚拟文件系统基本操作方法的封装

VFS - 虚拟文件系统基本操作方法的封装

VFS - 代码生成器预览功能实现

linux文件系统体系结构 和 虚拟文件系统(VFS)

虚拟文件系统

鸿蒙轻内核源码分析:虚拟文件系统VFS