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

Posted isea533

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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;
}

对于虚拟文件来说,上面两个属性决定了一个唯一的文件,因此重写下面两个方法:

@Override
public 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;
}

@Override
public 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 fileString relativePathPath relativePath
前两种参数通过上面的转换方法都可以变成 Path relativePathmkdirs 对应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 具体的用途,下面贴几段代码展示真正的功能。

虽然是虚拟文件系统,但是还要和真实文件进行交互,所以先看个真实文件的例子:

@Test
public 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文件:

@Test
public 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 - 代码生成器预览功能实现的主要内容,如果未能解决你的问题,请参考以下文章

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

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

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

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

片段中的 Android 相机预览

JS通过使用PDFJS实现基于文件流的预览功能