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

article/2025/10/16 8:01:50
  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()) {//加载所有 ZipEntryloadZipEntry(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/javavfs.mkdirs(zipEntry.getName());} else {//文件时,读取文件内容try (InputStream inputStream = zipFile.getInputStream(zipEntry);) {//将文件写入到vfsvfs.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)) {//写入 ZIPsyncZip(file);} else {//写入系统目录,创建最外层的目录file.mkdirs();//调用 VFSNode.syncDisk 方法,使用 file 所在的绝对路径创建子VFSNodesyncDisk(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();//递归子节点处理,传递 pathnode.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)也是一个方法。

2023年补充

代码已经在2022年底开源,项目地址:https://github.com/mybatis-mapper/rui


http://chatgpt.dhexx.cn/article/mWmZeaEf.shtml

相关文章

如何优雅的使用javascript递归画一棵结构树

递归和尾递归 简单的说&#xff0c;递归就是函数自己调用自己&#xff0c;它作为一种算法在程序设计语言中广泛应用。其核心思想是把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解。一般来说&#xff0c;递归需要有边界条件、递归前进阶段和递归返回阶段…

python数据结构和算法 时间复杂度分析 乱序单词检测 线性数据结构 栈stack 字符匹配 表达式求值 queue队列 链表 递归 动态规划 排序和搜索 树 图

python数据结构和算法 参考 本文github 计算机科学是解决问题的研究。计算机科学使用抽象作为表示过程和数据的工具。抽象的数据类型允许程序员通过隐藏数据的细节来管理问题领域的复杂性。Python是一种强大但易于使用的面向对象语言。列表、元组和字符串都是用Python有序集合…

黑马毕向东Java课程笔记(day20-1——20-17)IO流:File类及相关方法、递归、递归的相关练习、Properties、PrintWriter类与PrintStream类、合并流与切割流

1、File类概述   File是文件和目录路径名的抽象表示形式。 用来将文件或者文件夹封装成对象&#xff0c;方便对文件与文件夹的属性信息进行操作。   前面说到的“流”&#xff0c;它只能操作数据&#xff0c;想要操作由数据封装成的文件的信息&#xff0c;必须使用File对象…

算法: 如何优雅的使用javascript递归画一棵结构树

递归和尾递归 简单的说&#xff0c;递归就是函数自己调用自己&#xff0c;它做为一种算法在程序设计语言中广泛应用。其核心思想是把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解。一般来说&#xff0c;递归需要有边界条件、递归前进阶段和递归返回阶段…

day04函数名 闭包 迭代器 生成器(各种推导式和生成器表达式) 内置函数 装饰器 递归...

一.今日内容概 1. 函数本质就是一个内存地址. 用起来和变量一模一样 2. 闭包 内层函数对外层变量的使用 1. 保护变量 2. 让一个变量常驻内存 3. 迭代器 可迭代对象: __iter__ 迭代器: __iter__ __next__ from collecti…

linux 迭代列出文件,讲解在Linux命令行下使用ls命令列出文件的技巧

Linux ls 命令可以说是在 Linux 中常见到的命令之一&#xff0c;因为使用它可以掌握系统中文件所在目录中的内容&#xff0c;从而能够查看与修改文件&#xff0c;如果你正在使用 Linux ls 命令&#xff0c;不妨看一下以下技巧&#xff0c;它能帮助你更快速的完成任务。 ls 命令…

学习递归的另一种方法

每个学期&#xff0c;我都会通过一项调查&#xff0c;以获取有关我的教学的一些反馈。 上学期终于有人给我写一篇新文章的想法。 特别是&#xff0c;他们想了解有关递归的更多信息&#xff0c;所以我认为我会综合一些技巧。 递归概述 对于那些可能是第一次学习递归的人&#x…

用非递归方法实现递归算法时_学习递归的另一种方法

用非递归方法实现递归算法时 每个学期&#xff0c;我都会通过一项调查&#xff0c;以获取有关我的教学的一些反馈。 上学期终于有人给我写一篇新文章的想法。 特别是&#xff0c;他们想了解有关递归的更多信息&#xff0c;所以我认为我会综合一些技巧。 递归概述 对于那些可能…

bat 文件夹移动

echo off echo hello world ::得到当天 set pathLog%date:~0,4%%date:~5,2%%date:~8,2% :: eg:能得到 秒:set pathLog%date:~0,4%%date:~5,2%%date:~8,2%%time:~0,2%%time:~3,2%%time:~6,2% :: set pathLog%pathLog: 0% ::创建文件夹 md "d:\log\log%pathLog% ec…

【Python零基础入门篇 · 19】:os模块、可迭代对象和迭代器对象

目录 一、os模块 1、os模块中的命令&#xff1a; 2、常用命令的代码演示 os.getcwd() os.chdir(path) os.listdir(path) os.mkdir(path) os.makedirs(path) os.rename(旧名,新名) 3、举例&#xff1a;查找文件夹下所有满足要求的文件 二、可迭代对象和迭代器对象 1、…

day18:File(构造方法、创建、删除文件或者文件夹、 判断性、重命名与剪切、得到性、过滤性)、递归(遍历文件夹文件)

一 回顾 1.HashMap集合 特点: A.数据结构也是Hash表结构 B.多线程中是不安全 C.默认的数组的初始化容量是16 2.HashMap与HashSet的比较 相同点:都是hash表结构来存储 不同点: A.HashSet的底层就是使用HashMap来实现 B.HashSet的数据结构针对与是元素 HashMap的…

Python 文件和文件夹 01

Python文件和文件夹 01 ① 修改当前目录&#xff0c;首次利用 pandas 读取 excel 表 os.chdir import os import pandas as pd os.chdir(C:/aa/bb/cc) os.chdir(rC:\aa\bb\cc)数据 pd.read_excel("temp.xlsx") 等同于当前路径下的 temp.xlsx 文件。print(数据)② 字…

删除win10无限嵌套文件夹

解决由于失误操作导致WIN10系统产生无限循环的文件夹问题 昨天本想自己写一个拷贝文件的小程序&#xff0c;结果出现了点小问题&#xff0c;整出了一个无限循环的文件夹&#xff0c;直接删除出出现错误代码提示&#xff0c;显示无法删除&#xff0c;然后我就去网上找解决方案&…

计算机专硕一般研二在干嘛,专硕一般研二在干嘛,专硕两年怎么安排

一般学习两年。 硕士学位的学制取决于您申请的学校和专业。 不同的学校可能不同&#xff0c;同一所学校的硕士学位也可能不同。 许多学校还设有两年半的学制&#xff0c;甚至三年制的学制。本文一起来看一下吧~ 一.什么人适合读专硕 1、英语相对一般的人 学硕主要考英语一试卷&…

研二导师画大饼,不给时间实习,咋办

一个小学弟最近咨询我 我是本硕都在一所双非一本就读&#xff0c;软件工程&#xff0c;目前研二&#xff0c;23 届。我想在暑期进行一下今年的实习&#xff0c;想着可能对后面秋招和来年春招有帮助&#xff0c;但是实验室管的严导师不放时间(其实我当时是为了毕业条件和平时研…

2022年终总结与2023新年展望

前言 时间过得太快了&#xff0c;虽然写博客已经很多年了&#xff0c;但是年终总结一直由于种种原因没有写过&#xff0c;2022年确实是魔幻的一年&#xff0c;不知不觉自己也已经研二了&#xff0c;因为疫情的原因突然放开&#xff0c;提前放假回家&#xff0c;借此机会写一下…

研二(上学期)计划安排

今天是9.17号了&#xff0c;时间过得很快&#xff0c;学习的脚步永远停不下来。 时间的安排就不说了&#xff0c;真的是计划赶不上变化&#xff0c;一句话&#xff0c;除了外聘上课和研助&#xff0c;其他的时间必须到达实验室&#xff0c;&#xff08;一个星期一次总结&#…

计算机科学与探索、计算机工程与应用投稿经验分享

目录 等级&#xff1a; 经验&#xff1a; 总结&#xff1a; 等级&#xff1a; 计算机科学与探索 CCFb 计算机工程与应用 CCFB(2022年由c升为b) 经验&#xff1a; 首先本人主要关注计算机人工智能图像处理领域&#xff0c;北京某高校研二学生&#xff0c;水平不高。在研一…

计算机专业学生如何写一份优秀的校招简历(大三、研二学生请进)

计算机专业学生如何写一份优秀的校招简历(大三、研二学生请进) 最近讲了一节简历的公开课&#xff0c;还是蛮有价值的&#xff0c;想分享给大家 主要是讲解计算机相关专业的学生&#xff0c;就业找工作的简历&#xff0c;该如何书写。 内容包含&#xff1a; 1、快速掌握一份校…

快速傅里叶变换(研二的我终于弄懂了)

研二的我仍然对快速傅里叶变换一知半解&#xff0c;于是乎&#xff0c;本着待在家里&#xff0c;能耗时间就多耗点&#xff0c;不知道何年马月我才可以在外面快乐的奔跑~~ 快速傅里叶变换的实现&#xff08;c版本&#xff09; 在做项目的时候&#xff0c;需要用到matlab里的ff…