起因
去年底计划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 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 具体的用途,下面贴几段代码展示真正的功能。
虽然是虚拟文件系统,但是还要和真实文件进行交互,所以先看个真实文件的例子:
@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两个类文件的源码,可以回复留下自己邮箱。