赞
踩
这是 VFS 的最后一篇,前面两篇中的基本方法已经实现了一个简单的虚拟文件系统,可以创建目录和文件,可以读写文件的内容。在这最后一篇中,为了让VFS能和实际的文件系统产生交互,将真实存在的变成虚拟的,将虚拟的变成真实存在的,这就是本文最后要实现的两个大的接口。
由于VFS是一个带有目录结构的虚拟文件系统,除了能直接和操作系统的文件系统映射读写外,和 ZIP 压缩文件的转换和读写也非常的有必要,我们可以将整个虚拟文件系统转换为一个 ZIP 压缩包,不仅方便测试,也方便整个虚拟文件系统的序列化和反序列化。
再开始 VFS 具体内容前,先看看实现过程中在 ZIP 文件处理上踩到的两个坑。
我博客2012年有一篇 Java解压缩zip - 解压缩多个文件或文件夹,后续工作中偶尔也会用到 ZIP 解压缩的功能,大多数都直接用的现成类库封装的方法。个别情况下需要基于纯内存(不从磁盘读取文件,压缩不写入磁盘)解压缩 ZIP 文件时也直接操作过 Java API。
最近遇到一些坑,有些是很基础的内容,本以为自己可以随便玩这些API了,结果被自己坑到了,都是一些细节。
我用 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 装饰模式的流设计往往会嵌套很多层,关闭的时候只需要关闭外层,还是随便关闭一个都可以?
现在想想这可能不应该是一个问题,如果是我来设计,肯定也只需要关闭最外层的流,不可能脑残到让人从外往内一层层关闭或者从内往外一层层关闭,这也要求扩展的人实现时,一定要执行被装饰对象的必要方法。装饰模式的方法调用时,也必须从最外层开始调用,只有外层知道里面被装饰的对象,只有这样才能一层层清理干净。
ZipOutputStream
的 close
方法中做了很多事,包括把 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的作用就是修改里面的内容不影响物理目录和文件的内容,除了从头构建整个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 这条路。
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.mkdirs
和 vfs.write
很容易就把 ZIP 文件加载到了 VFS 中,下面再看加载目录。
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.mkdirs
和 vfs.write
就很容易就把操作系统中的目录加载到了 VFS 中,就目前的简单需求而言,这两个方法就足够创建一个VFS。
通过上面 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 和系统目录两种情况。
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 的功能。
/** * 根据相对路径写入文件 */ 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)也是一个方法。
代码已经在2022年底开源,项目地址:https://github.com/mybatis-mapper/rui
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。