VFS
VFS - 代码生成器预览功能实现VFS - 虚拟文件系统基本操作方法的封装VFS - 虚拟文件系统的加载和导出 起因去年底计划1月份开源新版 mybatis-mapper 并发布 1.0 的正式版,整个项目的主要功能已经稳定,为了更方便开发人员使用,计划提供一个代码生成器,然后就把精力投入代码生成器的设计和实现,由于石家庄疫情和多方面的原因搁置。
后来有时间之后就开始设计并实现最简单的代码生成器,代码生成器非常简单,功能很强大,这是一个和 MyBatis 没有直接关系的工具,因此不包含在 mybatis-mapper 项目中,mybatis-mapper项目中会包含一个可用的代码生成器 jar 包和模板示例文件,这个代码生成器已经可以使用,不过由于目前的精力在这个独立的代码生成器,因此还没发布 mybatis-mapper 的 1.0 正式版,距离正式发布不远了。
代码生成器可以直接在磁盘生成文件,基本功能实现之后就开始扩展一些更方便的功能。能不能在生成文件之前先预览生成的代码呢?能不能修改预览的代码后在写入到实际的文件?
在实现代码生成器过程中就设计了很多可以扩展的接口,这些接口用于创建目录和创建文件,因此想到了实现一个虚拟文件系统 VFS 来实现预览,而且基于 VFS 还可以有更多的方便的功能可以集成到代码生成器。
场景新设计实现的这个代码生成器不仅仅可以生成代码文件,还可以生成完整的项目结构,可以是简单的一个模块,还可以是 Maven 多模块项目,因此生成代码时,会生成复杂的项目结构,像具体的目录中会生成静态、代码、配置等文件。
提供一个 VFS,在生成目录时创建一个虚拟的目录结构,写入文件时也写入到虚拟的文件中,通过这种简单的方式就能实现项目结构和代码的预览功能。VFS不仅可以用于这里的预览,只要和目录和文件有关的场景都可以用到。
设计思路和接口说明代码一共两个类,700 行左右代码,行数较多,但是主要功能原理非常简单,这里主要介绍设计思路和接口的说明。
字段设计一个目录或者文件,最重要的就是名称和内容,还要有办法区分是目录还是文件。对于支持多级结构的文件系统来说,记录文件的结构非常重要。想要作为一个虚拟文件系统,所有文件有必要全部使用 相对路径。Java中想要方便处理路径,需要灵活使用 java.nio.file.Path。
为了用尽可能少的字段实现必要的功能,VFSNode 虚拟文件系统节点类中用到的字段如下:
/** * 文件名 */protected Path name;/** * 文件内容,常用的文本,个别情况有特殊类型的资源文件 */protected byte[] bytes;/** * 文件历史 <时间,内容> */protected Map<String, byte[]> history;/** * 文件类型 enum Type {/*目录*/DIR, /*文件*/FILE} */protected Type type;/** * 父节点,当前节点删除时需要通过父节点断开和当前节点的关系 */protected transient VFSNode parent;/** * 下级目录 */protected List<VFSNode> files; 基本方法设计因为是虚拟文件系统,不能使用操作系统提供的接口判断文件类型,因此创建该类型时需要提供文件名和类型:
protected VFSNode(Path name, Type type) { this.name = name; this.type = type;}对于虚拟文件来说,上面两个属性决定了一个唯一的文件,因此重写下面两个方法:
@Overridepublic boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } VFSNode vfsNode = (VFSNode) o; return name.equals(vfsNode.name) && type == vfsNode.type;}@Overridepublic int hashCode() { return Objects.hash(name, type);}根据 Type 可以很简单的判断是目录还是文件:
protected boolean isDirectory() { return Type.DIR == type;}protected boolean isFile() { return Type.FILE == type;}当需要读取文件时,80% 是在读取文本,因此直接提供一个极简的读文件内容方法:
protected String read() { if (isFile()) { return new String(this.bytes); } return null;}写入文件内容时稍微复杂一个,上面有一个没提到的 Map<String, byte[]> history 字段,为了记住文件的修改历史和修改时间,直接通过一个简单的 Map 进行了记录,方便修改时查看历史版本。
protected void write(byte[] bytes) { //如果已经存在内容就记录到历史 if (ArrayUtil.isNotEmpty(this.bytes)) { if (CollUtil.isEmpty(history)) { this.history = new LinkedHashMap<>(); } this.history.put(DateUtil.now(), this.bytes); } this.bytes = bytes;}工具类如 DateUtil 都使用的 hutool
再看一个简单的方法,想要删除当前节点,有多种方式,这里设计了最接近 Java 文件本身的操作,就是在节点上执行 delete() 方法,虚拟文件系统不会有文件真正删除,需要把层级关系断掉,因此删除当前节点时就需要从父节点的子节点列表中删除当前节点。
protected void delete() { if (this.parent != null) { this.parent.files.remove(this); this.parent = null; this.files = null; this.bytes = null this.history = null; } else { throw new UnsupportedOperationException("无法删除根目录"); }} 遍历子文件方法默认的 File 提供了 listFiles 方法来获取子文件,当前类中除了 setter 和 getter 方法外,如果要遍历子文件,总要判断子文件是否为空才能继续,因此提供一个方便的 forEach 方法进行遍历:
public void forEach(Consumer<VFSNode> action) { if (CollUtil.isNotEmpty(files)) { files.forEach(action); }}在后续打印文件目录结构的一个方法中,遍历时需要判断当前节点是否为最后一个节点,因此提供一个上面遍历节点的变种方法:
public void forEach(BiConsumer<VFSNode, Boolean> action) { if (CollUtil.isNotEmpty(files)) { int size = files.size(); for (int i = 0; i < size; i++) { action.accept(files.get(i), i < size - 1); } }}上面方法通过一个示例来展示用法,如何输出当前的目录结构:
protected void print(StringBuilder buffer, String prefix, String childrenPrefix) { buffer.append(prefix); buffer.append(name); buffer.append('\n'); forEach((next, hasNext) -> { if (hasNext) { next.print(buffer, childrenPrefix + "├── ", childrenPrefix + "│ "); } else { next.print(buffer, childrenPrefix + "└── ", childrenPrefix + " "); } });}后面测试时会用该方法输出一个树形结构的效果。
重点方法 - 创建文件当我们在当前目录下面增加一个文件时,实现非常简单:
private void addChild(VFSNode child) { if (CollUtil.isEmpty(this.files)) { this.files = new ArrayList<>(); } this.files.add(child); child.parent = this;}同样获取指定名词的子节点也非常容易:
private VFSNode getChild(Path name) { if (CollUtil.isNotEmpty(files)) { for (VFSNode file : files) { if (file.name.equals(name)) { return file; } } } return null;}基于这些简单的方法,当我们在当前目录增加一个相对路径为 a/b/c/d.txt 文件时,就开始复杂了。
先看下面的方法定义:
/** * 添加子孙级节点 * * @param node 节点信息 * @param relativePath 节点相对路径 */protected void addVFSNode(VFSNode node, Path relativePath)方法中的是参数为要添加的文件(节点)node,该文件对应的相对路径relativePath。虚拟文件中想要处理好目录结构,一定要使用相对路径,并且处理好路径之间的关系。
相对路径 relativePath 在这里的作用就是要根据路径找到当前节点要 addChild 的位置,找到位置加进去就可以,在找路径的过程中,如果路径不存在,就创建相应的路径节点,一级一级添加,直到 node 节点找到父级将自己 addChild。下面是方法的实现:
protected void addVFSNode(VFSNode node, Path relativePath) { //获取相对路径有几级(几个/分开的内容) int nameCount = relativePath.getNameCount(); //大于1的时候存在多级,等于1的时候就到当前 node 节点了 if (nameCount > 1) { //获取第一级目录名 Path name = relativePath.getName(0); //查找当前目录是否存在对应的子节点 VFSNode vfsNode = getChild(name); //子节点不存在时就创建 if (vfsNode == null) { //中间节点一定是 DIR 类型 vfsNode = new VFSNode(name, Type.DIR); //添加到当前子节点 addChild(vfsNode); } //现在有了子节点 vfsNode,对于原有的 a/b/c/d.txt 来说,现在 a 就是 vfsNode //接下来就在在 a 中查找或者创建 b/c/d.txt,也就是 a/b/c/d.txt 需要截取 a/ //然后调用 vfsNode.addVFSNode(node, "b/c/d.txt") //当递归到 d.txt 时就是下面 nameCount == 1 时了 vfsNode.addVFSNode(node, relativePath.subpath(1, nameCount)); } else if (nameCount == 1) { //当在 c.add(node, "d.txt") 时就到了这里 //此时判断该文件是否已经存在,如果不存在就直接 addChild 添加到子节点 //已经到最后一级,如果不存在子文件,或者子文件不包含当前文件,就添加进去 if (CollUtil.isEmpty(this.files) || !this.files.contains(node)) { addChild(node); } }}现在可以添加任意的相对路径文件了。
重点方法 - 查找文件有了记录好的 VFS 文件后,现在如何获取指定相对路径的文件,得到文件后可以读取内容、写入内容,还可以删除文件,因此 查找文件 是许多功能的基础。
protected VFSNode getVFSNode(Path relativePath) { //获取层级数 int nameCount = relativePath.getNameCount(); //多级时 if (nameCount > 1) { //获取第一级 Path name = relativePath.getName(0); //查找第一级 VFSNode vfsNode = getChild(name); //如果存在就递归查找下一级 if (vfsNode != null) { //到下一级时,相对路径需要去掉第一级 return vfsNode.getVFSNode(relativePath.subpath(1, nameCount)); } } else if (nameCount == 1) { //已经到最后一级,此时的 relativePath 就是最后要查找的文件名 //从子节点查找即可 return getChild(relativePath); } //节点不存在时返回 null return null;} 封装 VFS到现在也只是在一个 VFSNode 中实现了几个方法,还看不到如何真正应用,而且这里提供的 Path relativePath 最初是相对谁的位置呢?
为了方便使用,在这个基础上继续封装,增加 VFS 类如下:
public class VFS extends VFSNode { private Path path; private VFS(Path path) { super(path.getFileName() != null ? path.getFileName() : path, Type.DIR); this.path = path; } /** * 创建VFS * * @param path 根路径 * @return */ public static VFS of(Path path) { return new VFS(path); } /** * 创建VFS * * @param path 根路径 * @return */ public static VFS of(String path) { return new VFS(toPath(path)); }这里的VFS相当于根目录,通过 path 指定,所有其他文件都是相对 path 的相对路径。VFS中提供了一些简单的路径转换方法:
/** * 相对路径 * * @param file 文件 * @return */public Path relativize(File file) { return path.relativize(file.toPath());}/** * 相对路径转换 * * @param relativePath 相对路径 * @return */public static Path toPath(String relativePath) { return Paths.get(relativePath);}因为使用的相对路径,并且通过 VFS.path 确定了根目录,因此当添加文件时必须是 VFS.path 下面的文件,当计算相对路径时,如果出现 ../../a/b,其中 ../ 意思是当前目录的上级目录,有多个就需要逐级向上查找。因为不能超出 VFS.path 目录,所以不能出现 ../ 的情况,所以增加下面检查的方法:
/** * 检查相对路径 * * @param relativePath 相对路径 */private void checkRelativePath(Path relativePath) { if (relativePath.getNameCount() > 0) { if (relativePath.getName(0).toString().equals("..")) { throw new RuntimeException(relativePath + " 超出当前虚拟文件系统的范围"); } }}有了上面基础,再看如何创建目录。
VFS - mkdirs大部分方法为了方便调用,提供了多种参数形式,例如 File file,String relativePath 和 Path relativePath, 前两种参数通过上面的转换方法都可以变成 Path relativePath,mkdirs 对应3种参数的方法如下:
/*** 创建指定目录 * * @param file 文件 */public void mkdirs(File file) { mkdirs(relativize(file));}/** * 创建指定目录 * * @param relativePath 相对路径 */public void mkdirs(String relativePath) { mkdirs(toPath(relativePath));}真正要实现的 mkdirs 方法:
/** * 创建相对目录 * * @param relativePath 相对路径 */public void mkdirs(Path relativePath) { checkRelativePath(relativePath); addVFSNode(new VFSNode(relativePath.getFileName(), Type.DIR), relativePath);}似乎也没做什么,直接调用了 addVFSNode 方法,这个方法参数为 VFSNode node, Path relativePath, 所以这个方法是通过 new VFSNode(relativePath.getFileName(), Type.DIR) 创建了最终要添加的 node 节点, 添加的位置就是相对路径 relativePath。到这里就用上了 VFSNode 中的方法,有了 mkdirs 方法后, 创建目录的示例代码如下:
VFS vfs = VFS.of("/");vfs.mkdirs("/a");vfs.mkdirs("/a/b");vfs.mkdirs("/a/c");//为了验证不会重复添加vfs.mkdirs("/a/c");vfs.mkdirs("/a/d/e.txt");此时调用前面提供的 print 方法时输出的内容如下:
/└── a ├── b ├── c └── d └── e.txt前面 print 方法有好多个参数,该怎么用呢,直接在 VFS 中封装如下:
public String print() { StringBuilder print = new StringBuilder(); print(print, "", ""); return print.toString();}在控制台输出树形结构时就简单的:
System.out.println(vfs.print()); 未完,待续…本想今晚不写代码,写个博客早点睡,没想到博客也写了几个小时还没介绍完,关于代码内容的介绍就先到这里,后续再继续写,为了让大家提前看到这个 VFS 具体的用途,下面贴几段代码展示真正的功能。
虽然是虚拟文件系统,但是还要和真实文件进行交互,所以先看个真实文件的例子:
@Testpublic void test() { String userDir = System.getProperty("user.dir"); //绝对路径 VFS vfs = VFS.of(userDir); //绝对路径 vfs.mkdirs(new File(userDir + File.separator + "doc")); //创建相对路径的目录 vfs.mkdirs("src/main/java"); //写入文件 vfs.write("README.md", "# Hello VFS"); //写入绝对路径文件(相对userDir) vfs.write(new File(userDir + File.separator + "pom.xml"), "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); //输出文件结构 System.out.println(vfs.print()); //写入到指定磁盘目录 vfs.syncDisk(new File(userDir, "target/Hello")); //输出到压缩文件 vfs.syncDisk(new File(userDir, "target/Hello.zip"));}输出的目录结构如下:
vfs├── doc├── src│ └── main│ └── java├── README.md└── pom.xml生成的文件如下: 上面方法最后将虚拟文件内容写入到指定的目录和ZIP压缩文件中了。同样还提供了方法可以直接加载指定目录或者ZIP文件:
@Testpublic void testLoad() { VFS vfs = VFS.load(new File(userDir)); System.out.println(vfs.print()); vfs.syncDisk(new File(userDir, "target/loadFile.zip"));}输出的目录结构如下:
vfs├── .DS_Store├── target│ ├── test-classes│ │ ├── tk-mapper.zip│ │ ├── io│ │ │ └── mybatis│ │ │ └── rui│ │ │ └── test│ │ │ └── VFSTest.class│ │ ├── tk-mapper│ │ │ ├── mapper.java│ │ │ ├── model-lombok.java│ │ │ ├── mapper.xml│ │ │ ├── generator-demo.yaml│ │ │ └── model.java│ │ └── simplelogger.properties│ ├── generated-sources│ │ └── annotations│ ├── classes│ │ └── io│ │ └── mybatis│ │ └── rui│ │ ├── VFSNode$Type.class│ │ ├── VFSNode.class│ │ └── VFS.class│ ├── Hello.zip│ ├── generated-test-sources│ │ └── test-annotations│ └── Hello│ └── vfs│ ├── pom.xml│ ├── README.md│ ├── doc│ └── src│ └── main│ └── java├── vfs.iml├── pom.xml├── README.md└── src ├── test │ ├── resources │ │ ├── tk-mapper.zip │ │ ├── tk-mapper │ │ │ ├── mapper.java │ │ │ ├── model-lombok.java │ │ │ ├── mapper.xml │ │ │ ├── generator-demo.yaml │ │ │ └── model.java │ │ └── simplelogger.properties │ └── java │ └── io │ └── mybatis │ └── rui │ └── test │ └── VFSTest.java └── main └── java └── io └── mybatis └── rui ├── VFS.java └── VFSNode.java有了这个VFS之后,通过适当的使用就能实现一些方便的功能,比如 预览代码,将生成的代码保存到一个压缩包中从浏览器下载。
获取源码整个代码生成器在前期不会开源,会提供很多方便的工具可以直接免费使用,代码生成器中的部分代码通过博客、文档等方式进行介绍和分享,如果理解文章内容,而且对 VFS 有需求就可以自己实现一下。
如果你想直接获取 VFS两个类文件的源码,可以回复留下自己邮箱。
VFS - 代码生成器预览功能实现