1、FUSE概述
1.1、什么是FUSE
存在于操作系统内核中的VFS(虚拟文件系统),为操作系统基于内核态实现的各种具体文件系统(EXT/EXT2/FAT32/NTFS等)提供了统一的操作接口。这有利于操作系统的上层应用程序能够忽略具体文件系统的技术细节,专注于应用程序的业务工作。如下图所示:
但是随着应用程序对文件操作的需求日趋复杂化,封装了具体文件系统的VFS在一些特殊场景显得有些力不从心。但是为了保持功能的稳定,VFS又不可能随时加入具有特异性需求的新功能。例如,某种应用程序需要对文件系统的访问权限进行再细分和再控制、某种应用程序需要对落盘的文件实现实时加解密并伴有"一文一密"的特定要求,再例如某种应用程序需要将远程OSS对象存储的文件信息以磁盘虚拟目录的形式呈现在本地操作系统中......
这种针对文件系统操作的特异性需求,可以用过操作系统内核中提供的一种基于用户态的文件系统开发接口,由应用程序开发人员自行实现一个由上层应用系统支持的文件系统。这个统一的用户态文件系统接口就是FUSE,如下图所示:
从上图可以看出,通过FUSE的支持,应用程序对文件的读、写、查询、查找等操作,可以由用户态的另一个应用程序进行响应。这个应用程序可以是由多种开发语言中的某一种编写而成(C++、golang、java等等)。
1.2、FUSE的应用场景
- 远程文件系统封装
也就是说处理文件读写请求的应用程序,实际上是另一个文件系统客户端,而后者这个文件系统可能是一个存在于远端的文件系统/分布式文件系统。例如分布式文件系统Ceph就有FUSE-Client,可以实现在本地文件系统直接通过FUSE进行挂载。
通过Ceph提供的一个客户端应用程序通过Ceph-FUSE,在本地挂载远程的分布式文件系统(上图来源于网络)。
- 加密/压缩文件系统封装
在一些追求文件存储安全的应用系统建设中,客户会提出对落盘的数据必须进行加密存储的要求。而且还要求实际存储介质可以指定到本地,也可以指定到远程的某种分布式文件系统上。另一个要求是,已经实际落盘的文件,如果采用非法的访问方式,或者不是当前使用的账号进行访问,那么就算取得了文件,那么文件因为已经加密所以是不能打开的。
类似这样的需求,有两种解决方式,第一种解决方式是直接对VFS进行二次开发扩展。但这种方式有几个限制,第一个限制是只能使用C++进行开发;第二个限制是只能对本地落盘的具体文件系统产生影响。第二个解决方式,就是基于FUSE进行二次开发扩展。这种方式不但可以有更多编程语言进行支持,而且实现的FUSE-Client可以根据实际情况配置加密后的文件具体落盘方式------是在本地的文件系统上还是在远程文件系统上。但是使用FUSE的解决方式也并不是没有缺点,由于再一次操作请求过程中内核端和用户端会进行多次交互,所以其理论上的性能肯定是没有纯内核态文件系统的性能好的。
2、FUSE编程接口
2.1、多语言支持
目前流行的大多数编程语言,都可以用来编写基于FUSE用户端的应用程序。诸如:C++、Golang、Java(JVM系列语言)、Python等等。而且不同语言可以使用的FUSE组件甚至都可以有很多选择。所以某种具体语言、具体组件的FUSE实现肯定具有这个语言或者这个组件的一些特异化要求。
但这种特异化不会妨碍本文在这里讨论FUSE编程接口的实现规范,因为在这种特异化实现要求之下,还有FUSE的工作规范进行更底层的逻辑约束------底层逻辑是FUSE技术是建立在操作系统内核基础上的(WINDOWS操作系统下,也是安装软件来模拟Kernel文件模块的工作过程),不论FUSE在用户端使用哪种编程语言进行实现,在内核端的工作逻辑都是一样的。以下示例了文件打开环节时,不同编程语言、不同FUSE组件被触发的默认方法:
Java代码:
java
// 打开文件
public int open(String path, FuseFileInfo fi)
Golang代码
go
// 打开文件
var open = func(req fuse.Req, nodeid uint64, fi *fuse.FileInfo) (result int32)
2.2、重要结构讲解
在讲解FUSE的重要工作流程和方法前,我们需要介绍一下在操作系统中对文件信息的一些标准描述。这些描述将被应用在随后的FUSE编程工作中(注意:本文不会试图去科普Kernel、VFS等相关技术,如果读者对于这些还不太了解,可以参考网络上的其它资料)。
- 操作系统中对文件类型的定义
b 块设备文件 (鼠标,U盘等)
c 字符设备文件
d 文件夹
- 普通文件(二进制可执行文件,代码,文本)等等
l 链接文件 (软链接文件)
s socket文件
p 管道文件
在实际工作中或者说大部分FUSE文件系统的设计、实现过程中,我们用得最多的文件类型有:文件夹(d)、普通文件(-)以及链接文件(l)。而在编程语言中,这些文件类型一般会通过宏定义或者常量定义进行描述,以便提高其适配度。如下:
文件类型定义 | 文件类型的宏定义或常量定义 | 类型的中文意义 |
---|---|---|
b | S_IFBLK 或 S_IFBLK() | 块设备文件(鼠标,U盘等) 或者特殊文件 |
c | S_IFCHR 或 S_IFCHR() | 字符设备文件 |
d | S_IFDIR 或 S_IFDIR() | 文件夹 |
- | S_IFREG 或 S_IFREG() | 普通文件(二进制可执行文件,代码,文本)等等 |
l | S_IFLNK 或 S_IFLNK() | 链接文件 (软链接文件) |
s | S_IFSOCK 或 S_IFSOCK() | socket文件 |
p | S_IFIFO 或 S_IFIFO() | 管道文件 |
- 操作系统中文件操作模式的定义
Unix及衍生的各种Linux操作系统的抽象文件系统中,也就是上文多次提到的VFS中,系统可以为文件拥有者、文件拥有者所在用户组、非文件拥有者各自设定文件权限 。这个权限包括读权限(记为r)、写权限(记为w)和可执行权限(记为x)。以下是一种常见的文件描述效果:
可以看到以上的描述中,文件名为apache-zookeeper-3.7.2-bin.tar.gz的文件,针对文件拥有者具有读写权限,文件拥有者所在用户组 和其它用户仅具有文件的读权限。那么这种给每个文件赋权的功能特性,可以在FUSE中由研发人员根据实际情况进行指定------主要是在获取文件/文件夹状态这个环节进行获取。以下是Go和Java两种语言下,某种FUSE组件的文件状态获取代码:
Java代码,在获取文件/文件夹状态时,设定文件权限:
java
// ......
// 设定文件类型问"文件夹",权限方式为 用户rwx,用户所属用户组rwx,其它用户rwx
stat.st_mode.set(FileStat.S_IFDIR | 0777);
// ......
Golang代码,在获取文件/文件夹状态时,设定文件权限:
java
// ......
// 设定文件类型为"普通文件",权限方式为 用户rwx,用户所属用户组r,其它用户r
tat.Stat.Mode = uint32(syscall.S_IFREG) | uint32(0744)
// ......
以上代码中的777、744或者755这样的值,就是在设定当前文件/文件夹的权限,至于为什么是777而不是RWX这样的值的原因,本文不会进行解释,如果有不理解的读者请参考其它网络资料(实际上就是x=1、w=2、r=4这三个数值在二进制后的多种组合)。
- FUSE用户端关键操作状态值
FUSE的用户端操作在完成后,一般会返回一个操作状态。这些操作状态有很多,需要设计人员重点知晓的已用粗体进行了标记(不过这些操作状态在具体的编程语言中,以什么具体的性质呈现可能有差异。例如有的编程语言体现为一个32位整型,有的编程语言会首先体现为一个枚举值,然后再转换为一个32位整型):
(下表只根据实际版本和实际使用场景,列举了常见状态值,并不完整)
操作状态值 | 状态值意义 |
---|---|
E2BIG | 参数过长 |
EACCES | 不被允许的访问权限 |
EADDRINUSE | 地址已被使用 |
EADDRNOTAVAIL | 无法分配请求的地址 |
EAGAIN | 要求重试 |
EALREADY | 操作已在进行中 |
EBADF | 错误的文件状态 |
EBADFD | 文件描述符处于错误状态 |
EBUSY | 设备忙 |
ECANCELED | 操作被取消 |
ECONNREFUSED | 连接被拒绝 |
ECONNRESET | 连接被重置 |
EEXIST | 文件存在 |
EFAULT | 错误的地址 |
EFBIG | 文件过大 |
EINPROGRESS | 操作正在进行中 |
EINVAL | 无效参数 |
EISDIR | 是一个文件夹 |
ELOOP | 过多符号链接 |
EMFILE | 打开的文件太多 |
ENAMETOOLONG | 文件名过长 |
ENODEV | 没有指定的设备 |
ENOENT | 没有指定的文件或者文件夹 |
ENOEXEC | 执行文件格式错误 |
ENOMEM | 内存溢出 |
ENOPROTOOPT | 协议不可用 |
ENOSPC | 设备上没有剩余空间 |
ENOTDIR | 不是目录 |
ENOTEMPTY | 目录不为空 |
ENXIO | 没有指定的设备或者地址 |
EPERM | 不允许操作 |
EROFS | 只读的文件系统 |
...... | ...... |
2.3、重要流程和方法讲解
无论读者使用哪种语言实现FUSE用户端的代码,都要基于FUSE核心端的工作过程,所以以下介绍的重要流程和方法在各种不同的编程语言、FUSE组件中都可以适用:
2.3.1、FUSE工作规范中,最重要的文件/文件夹状态获取环节
基本上后文介绍的FUSE工作过程,都是建立在这个工作环节之上的。其工作目标就是要获取各种类型(上文所示的文件类型定义)文件的状态。这个环节在不同的编程语言中,方法名一般都会被定义为getattr,如下:
某种基于Java语言的FUSE组件:
java
// 获取指定文件的属性信息
// 第一个入参是当前文件(各种类型的文件,文件夹实际上也是一种类型的文件)的完整路径
// 第二个参数可用来设定文件的各种属性
// 返回值是操作状态值(请参见2.2中的状态表格),如果操作正确,则可以返回0
public int getattr(String path, FileStat stat);
某种基于Golang语言的FUSE组件:
go
// 注意第二个入参,nodeid表示这个文件信息在系统中的唯一编号
// 返回值有两个,一个是FileStat结构体设定的文件属性,一个是操作状态值(请参见2.2中的状态表格),如果操作正确,则可以返回0
var getattr = func(req fuse.Req, nodeid uint64) (fsStat *fuse.FileStat, result int32)
2.3.2、文件创建流程
创建文件前getattr方法会被触发,并且这个方法要返回状态值证明这个文件不存在(例如返回ENOENT)。接着create方法会被触发,以下是两种不同编程语言中,某种FUSE组件的相关触发方法。
某种基于Java语言的FUSE组件:
java
// 其中path是指本次创建文件的完整路径
public int create(String path, long mode, FuseFileInfo fi);
某种基于Golang语言的FUSE组件:
go
// 注意golang中具体文件创建时运行什么方法,是可以进行闭包引用的,所以具体运行的方法名可以是任何名字
// parentid 是指父级文件(夹)在系统中的唯一编号
// name 是指要创建的文件名
// 返回值有两个,最重要的还是那个result名字的整型,是指操作状态码。如果操作过程没有异常,则返回0即可
var create = func(req fuse.Req, parentid uint64, name string, mode uint32, fi *fuse.FileInfo) (fsStat *fuse.FileStat, result int32);
需要注意的是:
-
文件创建流程只是创建文件本身,如果这个文件类型支持文件内容的写入(例如文件类型为"-"的普通文件),那么写入操作将不会在create方法中进行,而是会在write方法中进行(而且可能是多次分块进行写入,见后文)。
-
如果文件创建时,发现这个文件已经存在。那么FUSE内核会通知用户端走"替换"过程,而不是create创建过程。
2.3.3、文件夹创建流程
创建文件夹前getattr方法也会被触发, 并且这个方法要返回状态值证明这个文件件不存在(例如返回ENOENT)。接着mkdir方法会被触发,以下是两种语言下,某种FUSE组件的相关触发方法。
某种基于Java语言的FUSE组件:
java
// 其中path是指本次创建文件夹的完整路径
// 返回值方面,如果处理过程没有错误,返回0即可
public int mkdir(String path, long mode);
某种基于Golang语言的FUSE组件:
go
// parentid 是指父级文件(夹)在系统中的唯一编号
// name 是指要创建的文件名
// 返回值有两个,最重要的还是那个result名字的整型,是指操作状态码。如果操作过程没有异常,则返回0即可
var mkdir = func(req fuse.Req, parentid uint64, name string, mode uint32) (fsStat *fuse.FileStat, result int32)
2.3.4、文件内容的写入
文件内容写入的方法叫做write,文件内容的写入最可能发生在两个情况下:
-
当支持文件内容写入的文件被创建后(例如文件类型为"-"的各种普通文件),文件内容写入的过程会被触发。也就是说方法的执行顺序为 getattr -> open -> create -> write -> release;
-
当支持文件内容写入的文件被同样文件名的新文件替换时,文件内容写入的过程会被触发。也就是说说方法的执行顺序为:getattr -> open -> ftruncate(并不是强制规范,如果替换时文件大小发生变化,某些特定的FUSE组件会触发) -> rename -> write -> release;以下是两种不同编程语言中,不同FUSE组件的相关触发方法。
某种基于Java语言的FUSE组件
java
// 发生文件大小变化时,该方法会被触发
// path:发生文件大小变动的完整文件路径
// size:新的文件大小,单位byte
public int ftruncate(String path, long size, FuseFileInfo fi);
// 被替换、被重命名、被移动时,该方法都会触发
// oldpath:指变化前的完整文件名
// newpath:指变化后的完整文件名
public int rename(String oldpath, String newpath);
// 文件真正的内容写入(注意,在一次操作过程中,该方法可能被调用多次)
// path:写入文件内容的完整文件名
// buf:存放本次方法调用时的写入内容
// size:本次写入内容的大小(注意,并不一定是文件大小,因为可能本文件需要多次分片写入)
// offset:本次写入内容是从文件的哪个位置开始的
public int write(String path, Pointer buf, long size, long offset, FuseFileInfo fi);
某种基于Golang语言的FUSE组件:
go
// 被替换、被重命名、被移动时,该方法都会触发
// parentid:被重命名的文件原来的父级文件
// name:被重命名的文件原来的文件名
// newparentid:被重命名文件新的父级文件
// newname:被重命名文件新的文件名
var rename = func(req fuse.Req, parentid uint64, name string, newparentid uint64, newname string) (result int32)
// 文件真正的内容写入,同样的,一次操作该方法可能被调用多次
// nodeid:进行写入操作的文件唯一编号
// byte:这次需要写入的内容在这里
// offset:本次写入内容是从文件的哪个位置开始的
// 注意返回值有两个,第1个是指此次操作真实被写入的文件内容大小;第二个返回值同样是操作标识,没有错误返回0即可
var write = func(req fuse.Req, nodeid uint64, buf []byte, offset uint64, fi fuse.FileInfo) (size uint32, result int32)
注意:本小节中还提到了两个名字分别为open和release的方法,这两个方法的意义见后文表格中的说明(也是非常重要的方法)。
2.3.5、文件内容的读取
文件内容读取的方法叫做read,文件内容的读取有以下两种场景:
- 文件内容需要被预览
- 文件内容正式被读取
文件内容读取的过程为:getattr -> open -> read(可能被多次调用,每次读取只读取文件内容的一部分)-> release。首先getattr方法会被触发,以便确认当前要读取的文件真实存在。接着open方法会被触发,表示由某个用户操作请求打开文件,一个文件可以被多个用户进程同时打开,只要设计人员确保这个文件没有在此时发生写操作过程;然后read方法会被正式触发,系统会按照一定的规则对文件进行多次内容读取,每一次可能只会读取文件的部分内容。在文件操作完成后,release方法会被触发,以表示某个用户进程操作的整个过程结束。以下是两种语言下,某种FUSE组件的相关触发方法。
某种基于Java语言的FUSE组件
java
// buf:读取的内容最终需要存储到这里
// size:本次需要读取文件内容的最大大小(也就是说,可能本次读取的内容会小于size)
// offset:本次读取的内容从文件的哪个位置开始
public int read(String path, Pointer buf, long size, long offset, FuseFileInfo fi);
某种基于Golang语言的FUSE组件
go
// nodeid:本次进行读取操作的文件,在系统中的唯一编号
// size:本次需要读取文件内容的最大大小(也就是说,可能本次读取的内容会小于size)
// offset:本次读取的内容从文件的哪个位置开始
// 注意,返回值有两个:第一个返回值是一个byte数组,也就是本次读取的内容信息将需要作为返回值进行返回。
// 第二个返回值已经介绍了多次,这里就不再赘述了
var read = func(req fuse.Req, nodeid uint64, size uint32, offset uint64, fi fuse.FileInfo) (content []byte, result int32)
2.3.6、其它需要实现的常规FUSE用户端方法
以下表格中的一些示例使用java语言,一些使用golang,但基本原理不会受到影响。
操作方法 | 方法示例 | 重点参数/返回值说明 | 注意事项说明 |
---|---|---|---|
文件打开 | var open = func(req fuse.Req, nodeid uint64, fi *fuse.FileInfo) (result int32) | 有的组件使用path来表示当前被打开的方法,有的组件使用nodeid来表示,都不会对方法的处理原则产生影响 | 在这个方法中,操作人员可以标识该文件被同时打开的副本数量 |
文件夹打开 | public int opendir(String path, FuseFileInfo fi) | 文件夹被打开,其性质和open一样,只是该方法在文件夹被打开时触发 | |
文件删除 | public int unlink(String path) | 是的,文件删除并不是什么delete方法,FUSE用户端标准方法中也没有这样的方法,文件的删除方法一般名字为unlink | |
文件夹删除 | var rmdir = func(req fuse.Req, parentid uint64, name string) (res int32) | 文件夹在进行删除前,该文件夹下所有直接或间接关联的子级文件/文件夹都会被要求删除。那么就是说unlink方法会被调用多次,以确保以上条件成立 | |
文件夹读取 | public int readdir(String path, Pointer buf, FuseFillDir filter, long offset, FuseFileInfo fi) | filter:文件夹中的各个项目,通过这个对象进行添加 | 如果需要获取文件夹的配置信息,可以将文件夹配置放置到buf对象中 |
文件释放 | var release = func(req fuse.Req, nodeid uint64, fi fuse.FileInfo) (result int32) | 和open方法对应 | |
文件夹释放 | public int releasedir(String path, FuseFileInfo fi) | 和opendir方法对应 |
3、FUSE组件推荐
在进行FUSE开发时,我们可以使用远程调试的方式,基于远程的Linux操作系统运行FUSE-Client应用程序的状态进行调试。好消息是,windows通过安装特定软件,也可以直接在windows下模拟FUSE的用户端工作效果。winfsp就是这样的软件,可以通过以下地址进行下载,也可前往官网直接下载最新版本(https://winfsp.dev)
点击下载:
https://github.com/winfsp/sshfs-win/releases/download/v3.5.20357/sshfs-win-3.5.20357-x64.msi
下载安装后,就可以直接在windows系统下调试你的FUSE应用程序了。当然在正式编写完成后,还是建议在多个Linux操作系统上进行验证测试。
-
Golang的FUSE组件:
go-fuse是一个基于golang语言实现的FUSE用户端组件。可以在https://github.com/hanwen/go-fuse找到它,以及它的开发文档。
-
Java的FUSE组件:
JNR-FUSE是一个基于Java语言实现的FUSE用户端组件,可以在https://github.com/ComposeDAO/jnr-fuse找到它(目前最新版本是0.5.8),以及它的开发文档。maven工程的引入方式为:
xml
<dependencies>
<dependency>
<groupId>com.github.serceman</groupId>
<artifactId>jnr-fuse</artifactId>
<version>0.5.8</version>
</dependency>
</dependencies>
但是在查阅相关资料时,笔者发现Alluxio分布式存储方案中,对JNR-UFSE进行了较大规模改写并命名为JNI-FUSE。后来笔者试图在各种取到获取JNI-FUSE,结果都没有成功,就看各位读者能不能找到JNI-FUSE,并成功引入它了。