paint-brush
如何使用 FUSE 和 Node.js 构建动态文件系统:一种实用方法经过@rglr
386 讀數
386 讀數

如何使用 FUSE 和 Node.js 构建动态文件系统:一种实用方法

经过 Aleksandr Zinin33m2024/06/13
Read on Terminal Reader

太長; 讀書

您是否想知道运行 sshfs user@remote:~/ /mnt/remoteroot 时会发生什么?远程服务器的文件如何出现在您的本地系统上并如此快速地同步?您听说过 WikipediaFS 吗?它允许您编辑 Wikipedia 文章,就像它是文件系统中的文件一样?这不是魔术 - 这是 FUSE(用户空间文件系统)的强大功能。FUSE 允许您创建自己的文件系统,而无需深入了解操作系统内核或低级编程语言。本文介绍了一种使用 FUSE 和 Node.js 和 TypeScript 的实用解决方案。我们将探索 FUSE 的底层工作原理,并通过解决实际任务来演示其应用。与我一起踏上 FUSE 和 Node.js 世界的精彩冒险之旅。
featured image - 如何使用 FUSE 和 Node.js 构建动态文件系统:一种实用方法
Aleksandr Zinin HackerNoon profile picture

您是否想知道运行sshfs user@remote:~/ /mnt/remoteroot时会发生什么?远程服务器的文件如何出现在您的本地系统上并如此快速地同步?您听说过WikipediaFS吗?它允许您编辑维基百科文章,就像它是文件系统中的文件一样?这不是魔术 - 这是 FUSE(用户空间文件系统)的力量。FUSE 允许您创建自己的文件系统,而无需深入了解操作系统内核或低级编程语言。


本文介绍了一种使用 FUSE 和 Node.js 以及 TypeScript 的实用解决方案。我们将探索 FUSE 的底层工作原理,并通过解决实际任务来展示其应用。和我一起踏上 FUSE 和 Node.js 世界的精彩冒险之旅。

介绍

我的工作是负责媒体文件(主要是图片)。这包括很多东西:侧边或顶部横幅、聊天中的媒体、贴纸等。当然,这些有很多要求,例如“横幅是 PNG 或 WEBP,300x1000 像素”。如果不满足要求,我们的后台办公室将不会让图片通过。并且有一个对象重复删除机制:没有图片可以两次进入同一条河流。


这导致我们面临这样的情况:我们有大量用于测试的图像。我使用 shell 单行命令或别名来简化我的生活。


例如:

 convert -size 300x1000 xc:gray +noise random /tmp/out.png


噪声图像示例


bashconvert的组合是一个很好的工具,但显然,这不是解决问题最方便的方法。讨论 QA 团队的情况揭示了进一步的复杂性。除了在图像生成上花费的大量时间外,我们调查问题时的第一个问题是“你确定你上传了一个独特的图像吗?”我相信你明白这有多烦人。

让我们选择我们想要使用的技术

您可以采取一种简单的方法:创建一个 Web 服务,为带有不言自明的文件的路由提供服务,例如GET /image/1000x100/random.zip?imagesCount=100 。该路由将返回一个包含一组唯一图像的 ZIP 文件。这听起来不错,但它并没有解决我们的主要问题:所有上传的文件都需要是唯一的才能进行测试。


你的下一个想法可能是“我们可以在发送时替换有效负载吗?” QA 团队使用 Postman 进行 API 调用。我调查了 Postman 的内部结构,发现我们无法“动态”更改请求主体


另一种解决方案是每次尝试读取文件时替换文件系统中的文件。Linux 有一个名为 Inotify 的通知子系统,它会提醒您有关文件系统事件(例如目录更改或文件修改)的信息。如果您收到“Visual Studio Code 无法监视这个大型工作区中的文件更改”,则 Inotify 存在问题。它可以在目录更改、文件重命名、文件打开等情况下触发事件。


完整的活动列表可以在这里找到: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html


因此,计划是:

  1. 监听IN_OPEN事件并计算文件描述符。

  2. 监听IN_CLOSE事件;如果计数降至 0,我们将替换该文件。


听起来不错,但也存在一些问题:

  • 仅 Linux 支持inotify
  • 对该文件的并行请求应该返回相同的数据。
  • 如果文件具有密集的 IO 操作,则永远不会发生替换。
  • 如果服务 Inotify 事件的服务崩溃,文件将保留在用户文件系统中。


为了解决这些问题,我们可以编写自己的文件系统。但还有另一个问题:常规文件系统在操作系统内核空间中运行。这要求我们了解操作系统内核并使用 C/Rust 等语言。此外,对于每个内核,我们都应该编写一个特定的模块(驱动程序)。


因此,编写文件系统对于我们想要解决的问题来说有点小题大做;即使前面还有个漫长的周末。幸运的是,有一种方法可以驯服这个野兽:文件系统 in Use rspace (FUSE)。FUSE 是一个允许您创建文件系统而无需编辑内核代码的项目。这意味着通过 FUSE 的任何程序或脚本,无需任何复杂的内核相关逻辑,都可以模拟闪存、硬盘或 SSD。


换句话说,一个普通的用户空间进程可以创建自己的文件系统,可以通过任何你想要的普通程序正常访问——Nautilus、Dolphin、ls 等。


为什么 FUSE 可以满足我们的需求?基于 FUSE 的文件系统基于用户空间进程构建。因此,您可以使用任何与libfuse绑定的语言。此外,您还可以通过 FUSE 获得跨平台解决方案。


我对 NodeJS 和 TypeScript 有丰富的经验,我想选择这个(很棒的)组合作为我们全新 FS 的执行环境。此外,TypeScript 提供了一个出色的面向对象基础。这将使我不仅可以向您展示源代码(您可以在公共 GitHub 存储库中找到),还可以展示项目的结构。

深入了解 FUSE

让我引用一下FUSE 官方页面上的一段话:

FUSE 是一个用户空间文件系统框架。它由一个内核模块 (fuse.ko)、一个用户空间库 (libfuse.*) 和一个挂载实用程序 (fusermount) 组成。


用于编写文件系统的框架听起来令人兴奋。


我应该解释一下每个 FUSE 部分的含义:

  1. fuse.ko正在执行所有与内核相关的低级工作;这使我们能够避免干预操作系统内核。


  2. libfuse是一个提供与fuse.ko通信的高级层的库。


  3. fusermount允许用户挂载/卸载用户空间文件系统(叫我 Captain Obvious!)。


一般原则如下:
FUSE 的一般原则


用户空间进程(本例中为ls )向虚拟文件系统内核发出请求,该内核将请求路由到 FUSE 内核模块。FUSE 模块又将请求路由回用户空间的文件系统实现(上图中的./hello )。


不要被虚拟文件系统的名称所欺骗。它与 FUSE 没有直接关系。它是内核中的软件层,为用户空间程序提供文件系统接口。为了简单起见,您可以将其视为复合模式


libfuse提供两种类型的 API:高级 API 和低级 API。它们有相似之处,但也有重大差异。低级 API 是异步的,仅适用于inodes 。在这种情况下,异步意味着使用低级 API 的客户端应该自行调用响应方法。


高级版本提供了使用便捷路径(例如/etc/shadow )而不是更“抽象”的inodes的能力,并以同步方式返回响应。在本文中,我将解释高级版本的工作方式,而不是低级版本和inodes工作原理。


如果要实现自己的文件系统,则应该实现一组负责处理来自 VFS 的请求的方法。最常用的方法是:


  • open(path, accessFlags): fd -- 按路径打开文件。该方法将返回一个数字标识符,即所谓的文件描述符(以下称为fd )。访问标志是一个二进制掩码,描述客户端程序想要执行的操作(只读、只写、读写、执行或搜索)。


  • read(path, fd, Buffer, size, offset): count of bytes read - 从与fd文件描述符链接的文件读取size个字节到传递的缓冲区。由于我们将使用 fd,因此将忽略path参数。


  • write(path, fd, Buffer, size, offset): count of bytes written ——将size个字节从 Buffer 写入到与fd链接的文件中。


  • release(fd) ——关闭fd


  • truncate(path, size) ——更改文件大小。如果您想要重写文件(我们确实这么做了),则应该定义该方法。


  • getattr(path) ——返回文件参数,如大小、创建时间、访问时间等。该方法是文件系统最可调用的方法,因此请确保创建最佳方法。


  • readdir(path) ——读取所有子目录。


上述方法对于基于高级 FUSE API 构建的每个完全可操作的文件系统都至关重要。但该列表并不完整;您可以在https://libfuse.github.io/doxygen/structfuse__operations.html上找到完整列表


重新回顾文件描述符的概念:在包括 MacOS 在内的类 UNIX 系统中,文件描述符是文件和其他 I/O 资源(如套接字和管道)的抽象。当程序打开文件时,操作系统会返回一个称为文件描述符的数字标识符。此整数用作每个进程在操作系统文件描述符表中的索引。当使用 FUSE 实现文件系统时,我们需要自己生成文件描述符。


让我们考虑客户端打开文件时的调用流程:

  1. getattr(path: /random.png) → { size: 98 };客户端获取了文件大小。


  2. open(path: /random.png) → 10;通过路径打开文件;FUSE 实现返回文件描述符编号。


  3. read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50;读取前 50 个字节。


  4. read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48;读取接下来的 50 个。由于文件大小,读取了 48 个字节。


  5. release(10);所有数据都已读取,因此接近 fd。

让我们编写一个最小可行产品并检查 Postman 对它的反应

我们的下一步是开发一个基于libfuse的最小文件系统,以测试 Postman 如何与自定义文件系统交互。


FS 的验收要求很简单:FS 的根目录应包含一个random.txt文件,每次读取时其内容应是唯一的(我们称之为“始终唯一读取”)。内容应包含一个随机 UUID 和一个 ISO 格式的当前时间,以换行符分隔。例如:

 3790d212-7e47-403a-a695-4d680f21b81c 2012-12-12T04:30:30


最小产品将由两部分组成。第一部分是一个简单的 Web 服务,它将接受 HTTP POST 请求并将请求主体打印到终端。代码相当简单,不值得我们花时间,主要是因为这篇文章是关于 FUSE 的,而不是 Express。第二部分是满足要求的文件系统的实现。它只有 83 行代码。


对于代码,我们将使用 node-fuse-bindings 库,它提供与libfuse高级 API 的绑定。


您可以跳过下面的代码;我将在下面写一个代码摘要。

 const crypto = require('crypto'); const fuse = require('node-fuse-bindings'); // MOUNT_PATH is the path where our filesystem will be available. For Windows, this will be a path like 'D://' const MOUNT_PATH = process.env.MOUNT_PATH || './mnt'; function getRandomContent() { const txt = [crypto.randomUUID(), new Date().toISOString(), ''].join('\n'); return Buffer.from(txt); } function main() { // fdCounter is a simple counter that increments each time a file is opened // using this we can get the file content, which is unique for each opening let fdCounter = 0; // fd2ContentMap is a map that stores file content by fd const fd2ContentMap = new Map(); // Postman does not work reliably if we give it a file with size 0 or just the wrong size, // so we precompute the file size // it is guaranteed that the file size will always be the same within one run, so there will be no problems with this const randomTxtSize = getRandomContent().length; // fuse.mount is a function that mounts the filesystem fuse.mount( MOUNT_PATH, { readdir(path, cb) { console.log('readdir(%s)', path); if (path === '/') { return cb(0, ['random.txt']); } return cb(0, []); }, getattr(path, cb) { console.log('getattr(%s)', path); if (path === '/') { return cb(0, { // mtime is the file modification time mtime: new Date(), // atime is the file access time atime: new Date(), // ctime is the metadata or file content change time ctime: new Date(), size: 100, // mode is the file access flags // this is a mask that defines access rights to the file for different types of users // and the type of file itself mode: 16877, // file owners // in our case, it will be the owner of the current process uid: process.getuid(), gid: process.getgid(), }); } if (path === '/random.txt') { return cb(0, { mtime: new Date(), atime: new Date(), ctime: new Date(), size: randomTxtSize, mode: 33188, uid: process.getuid(), gid: process.getgid(), }); } cb(fuse.ENOENT); }, open(path, flags, cb) { console.log('open(%s, %d)', path, flags); if (path !== '/random.txt') return cb(fuse.ENOENT, 0); const fd = fdCounter++; fd2ContentMap.set(fd, getRandomContent()); cb(0, fd); }, read(path, fd, buf, len, pos, cb) { console.log('read(%s, %d, %d, %d)', path, fd, len, pos); const buffer = fd2ContentMap.get(fd); if (!buffer) { return cb(fuse.EBADF); } const slice = buffer.slice(pos, pos + len); slice.copy(buf); return cb(slice.length); }, release(path, fd, cb) { console.log('release(%s, %d)', path, fd); fd2ContentMap.delete(fd); cb(0); }, }, function (err) { if (err) throw err; console.log('filesystem mounted on ' + MOUNT_PATH); }, ); } // Handle the SIGINT signal separately to correctly unmount the filesystem // Without this, the filesystem will not be unmounted and will hang in the system // If for some reason unmount was not called, you can forcibly unmount the filesystem using the command // fusermount -u ./MOUNT_PATH process.on('SIGINT', function () { fuse.unmount(MOUNT_PATH, function () { console.log('filesystem at ' + MOUNT_PATH + ' unmounted'); process.exit(); }); }); main();


我建议重新认识一下文件中的权限位。权限位是与文件关联的一组位;它们以二进制形式表示谁有权读取/写入/执行文件。“谁”包括三组:所有者、所有者组和其他人。


可以为每个组单独设置权限。通常,每个权限都用一个三位数表示:读取(4 或二进制中的“100”)、写入(2 或“010”)和执行(1 或“001”)。如果将这些数字加在一起,将创建一个组合权限。例如,4 + 2(或“100”+“010”)将等于 6(“110”),这意味着读取 + 写入(RO)权限。


如果文件所有者的访问掩码为 7(二进制为 111,表示读、写和执行),则该组的访问掩码为 5(101,表示读和执行),其他人的访问掩码为 4(100,表示只读)。因此,文件的完整访问掩码为十进制的 754。请记住,执行权限变为目录的读取权限。


让我们回到文件系统实现并制作一个文本版本:每次打开文件(通过open调用)时,整数计数器都会递增,产生由 open 调用返回的文件描述符。然后创建随机内容并将其保存在以文件描述符为键的键值存储中。进行 read 调用时,将返回相应的内容部分。


调用 release 后,内容将被删除。请记住在按下 Ctrl+C 后处理SIGINT以卸载文件系统。否则,我们必须在终端中使用fusermount -u ./MOUNT_PATH手动执行此操作。


现在,开始测试。我们运行 Web 服务器,然后创建一个空文件夹作为即将到来的 FS 的根文件夹,并运行主脚本。打印出“服务器监听端口 3000”行后,打开 Postman,连续向 Web 服务器发送几个请求,不更改任何参数。
左侧是 FS,右侧是 Web 服务器


一切看起来都很好!正如我们所预见的,每个请求都有唯一的文件内容。日志还证明了上面“深入研究 FUSE”部分中描述的文件打开调用流程是正确的。


带有 MVP 的 GitHub 仓库: https://github.com/pinkiesky/node-fuse-mvp 。您可以在本地环境中运行此代码,也可以将此仓库用作您自己的文件系统实现的样板。

核心理念

方法已经检查完毕——现在是进行主要实施的时候了。


在实现“始终唯一读取”之前,我们应该实现的第一件事是原始文件的创建和删除操作。我们将通过虚拟文件系统中的目录实现此接口。用户将放入他们想要“始终唯一”或“随机化”的原始图像,然后文件系统将准备其余部分。


这里以及在以下章节中,“始终唯一读取”、“随机图像”或“随机文件”是指每次读取时以二进制方式返回唯一内容的文件,同时在视觉上与原始文件尽可能相似。


文件系统的根目录将包含两个目录:Image Manager 和 Images。第一个是用于管理用户原始文件的文件夹(您可以将其视为 CRUD 存储库)。第二个是从用户角度来看的非托管目录,其中包含随机图像。
用户与文件系统交互


FS 树作为终端输出


正如您在上图中看到的,我们不仅会实现“始终唯一”的图像,还会实现文件转换器!这是一个额外的好处。


我们实现的核心思想是,程序将包含一个对象树,每个节点和叶子都提供通用的 FUSE 方法。当程序收到 FS 调用时,它应该通过相应的路径在树中找到一个节点或叶子。例如,程序获取getattr(/Images/1/original/)调用,然后尝试找到该路径指向的节点。


像这样: FS 树示例


下一个问题是我们将如何存储原始图像。程序中的图像将由二进制数据和元信息组成(元信息包括原始文件名、文件 mime 类型等)。二进制数据将存储在二进制存储中。让我们简化它,并将二进制存储构建为用户(或主机)文件系统中的一组二进制文件。元信息将以类似的方式存储:JSON 存储在用户文件系统中的文本文件中。


您可能还记得,在“让我们编写一个最小可行产品”部分中,我们创建了一个文件系统,它通过模板返回一个文本文件。它包含一个随机 UUID 和一个当前日期,因此数据的唯一性不是问题——唯一性是通过数据的定义实现的。但是,从这一点来看,该程序应该可以处理预加载的用户图像。那么,我们如何才能基于原始图像创建相似但始终唯一的图像(就字节和哈希值而言)呢?


我建议的解决方案很简单。让我们在图像的左上角放置一个 RGB 噪声方块。噪声方块应为 16x16 像素。这提供了几乎相同的图片,但保证了唯一的字节序列。这足以确保大量不同的图像吗?让我们做一些计算。方块的大小是 16。单个方块中有 16×16 = 256 个 RGB 像素。每个像素有 256×256×256 = 16,777,216 个变体。


因此,唯一正方形的数量为 16,777,216^256——这个数字有 1,558 位,远远超过可观测宇宙中的原子数量。这是否意味着我们可以减小正方形的大小?不幸的是,像 JPEG 这样的有损压缩会显著减少唯一正方形的数量,因此 16x16 是最佳尺寸。


带有噪声方块的图像示例

传递类

那个树
UML 类图显示了基于 FUSE 的系统的接口和类。包括接口 IFUSEHandler、ObjectTreeNode 和 IFUSETreeNode,其中 FileFUSETreeNode 和 DirectoryFUSETreeNode 实现 IFUSETreeNode。每个接口和类都列出了属性和方法,说明了它们的关系和层次结构

IFUSEHandler是一个为常见 FUSE 调用提供服务的接口。您可以看到我分别用readAll/writeAll替换了read/write 。我这样做是为了简化读写操作:当IFUSEHandler对整个部分进行读/写时,我们能够将部分读/写逻辑移到另一个地方。这意味着IFUSEHandler不需要知道有关文件描述符、二进制数据等的任何信息。


同样的事情也发生在open FUSE 方法中。树的一个值得注意的方面是它是按需生成的。程序不会将整个树存储在内存中,而是仅在访问节点时才创建节点。这种行为允许程序避免在创建或删除节点时出现树重建问题。


检查ObjectTreeNode接口,您会发现children不是一个数组而是一个方法,因此这就是它们按需生成的方式FileFUSETreeNodeDirectoryFUSETreeNode是抽象类,其中一些方法会引发NotSupported错误(显然, FileFUSETreeNode永远不应实现readdir )。

FUSE外观

UML 类图显示了 FUSE 系统的接口及其关系。该图包括 IFUSEHandler、IFUSETreeNode、IFileDescriptorStorage 接口和 FUSEFacade 类。IFUSEHandler 具有属性 name 和方法 checkAvailability、create、getattr、readAll、remove 和 writeAll。IFileDescriptorStorage 具有方法 get、openRO、openWO 和 release。IFUSETreeNode 扩展了 IFUSEHandler。FUSEFacade 包括构造函数、create、getattr、open、read、readdir、release、rmdir、safeGetNode、unlink 和 write 方法,并与 IFUSETreeNode 和 IFileDescriptorStorage 交互。


FUSEFacade 是实现程序主要逻辑并将不同部分绑定在一起的最关键类。node node-fuse-bindings有一个基于回调的 API,但 FUSEFacade 方法是基于 Promise 的。为了解决这个不便,我使用了如下代码:

 const handleResultWrapper = <T>( promise: Promise<T>, cb: (err: number, result: T) => void, ) => { promise .then((result) => { cb(0, result); }) .catch((err) => { if (err instanceof FUSEError) { fuseLogger.info(`FUSE error: ${err}`); return cb(err.code, null as T); } fuseLogger.warn(err); cb(fuse.EIO, null as T); }); }; // Ex. usage: // open(path, flags, cb) { // handleResultWrapper(fuseFacade.open(path, flags), cb); // },


FUSEFacade方法包装在handleResultWrapper中。使用路径的每种FUSEFacade方法都只是解析路径,在树中找到一个节点,然后调用请求的方法。


考虑FUSEFacade类中的几种方法。

 async create(path: string, mode: number): Promise<number> { this.logger.info(`create(${path})`); // Convert path `/Image Manager/1/image.jpg` in // `['Image Manager', '1', 'image.jpg']` // splitPath will throw error if something goes wrong const parsedPath = this.splitPath(path); // `['Image Manager', '1', 'image.jpg']` const name = parsedPath.pop()!; // 'image.jpg' // Get node by path (`/Image Manager/1` after `pop` call) // or throw an error if node not found const node = await this.safeGetNode(parsedPath); // Call the IFUSEHandler method. Pass only a name, not a full path! await node.create(name, mode); // Create a file descriptor const fdObject = this.fdStorage.openWO(); return fdObject.fd; } async readdir(path: string): Promise<string[]> { this.logger.info(`readdir(${path})`); const node = await this.safeGetNode(path); // As you see, the tree is generated on the fly return (await node.children()).map((child) => child.name); } async open(path: string, flags: number): Promise<number> { this.logger.info(`open(${path}, ${flags})`); const node = await this.safeGetNode(path); // A leaf node is a directory if (!node.isLeaf) { throw new FUSEError(fuse.EACCES, 'invalid path'); } // Usually checkAvailability checks access await node.checkAvailability(flags); // Get node content and put it in created file descriptor const fileData: Buffer = await node.readAll(); // fdStorage is IFileDescriptorStorage, we will consider it below const fdObject = this.fdStorage.openRO(fileData); return fdObject.fd; }

文件描述符

在进行下一步之前,让我们仔细看看程序上下文中文件描述符是什么。

UML 类图显示了 FUSE 系统中文件描述符的接口及其关系。该图包括 IFileDescriptor、IFileDescriptorStorage 接口以及 ReadWriteFileDescriptor、ReadFileDescriptor 和 WriteFileDescriptor 类。IFileDescriptor 具有属性 binary、fd、size 和方法 readToBuffer、writeToBuffer。IFileDescriptorStorage 具有方法 get、openRO、openWO 和 release。ReadWriteFileDescriptor 使用附加构造函数、readToBuffer 和 writeToBuffer 方法实现 IFileDescriptor。ReadFileDescriptor 和 WriteFileDescriptor 扩展了 ReadWriteFileDescriptor,其中 ReadFileDescriptor 具有 writeToBuffer 方法,WriteFileDescriptor 具有 readToBuffer 方法

ReadWriteFileDescriptor是一个将文件描述符存储为数字并将二进制数据WriteFileDescriptor为缓冲区的类。该类具有readToBufferwriteToBuffer方法,可用于读取和写入文件描述符缓冲区中的数据。ReadFileDescriptor 和ReadFileDescriptor是只读和只写描述符的实现。


IFileDescriptorStorage是一个描述文件描述符存储的接口。程序中只有一个该接口的实现: InMemoryFileDescriptorStorage 。从名字就可以看出,它将文件描述符存储在内存中,因为我们不需要对描述符进行持久化。


让我们检查一下FUSEFacade如何使用文件描述符和存储:

 async read( fd: number, // File descriptor to read from buf: Buffer, // Buffer to store the read data len: number, // Length of data to read pos: number, // Position in the file to start reading from ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Read data into the buffer and return the number of bytes read return fdObject.readToBuffer(buf, len, pos); } async write( fd: number, // File descriptor to write to buf: Buffer, // Buffer containing the data to write len: number, // Length of data to write pos: number, // Position in the file to start writing at ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Write data from the buffer and return the number of bytes written return fdObject.writeToBuffer(buf, len, pos); } async release(path: string, fd: number): Promise<0> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Safely get the node corresponding to the file path const node = await this.safeGetNode(path); // Write all the data from the file descriptor object to the node await node.writeAll(fdObject.binary); // Release the file descriptor from storage this.fdStorage.release(fd); // Return 0 indicating success return 0; }


上面的代码很简单。它定义了读取、写入和释放文件描述符的方法,确保在执行操作之前文件描述符有效。release 方法还将数据从文件描述符对象写入文件系统节点并释放文件描述符。


我们已经完成了libfuse和树的相关代码。现在是时候深入研究与图像相关的代码了。

图像:“数据传输对象”部分
UML 类图显示了用于图像处理的接口及其关系。该图包括 ImageBinary、ImageMeta、Image 和 IImageMetaStorage 接口。ImageBinary 具有属性 buffer 和 size。ImageMeta 具有属性 id、name、originalFileName 和 originalFileType。Image 具有属性 binary 和 meta,其中 binary 属于 ImageBinary 类型,meta 属于 ImageMeta 类型。IImageMetaStorage 具有方法 create、get、list 和 remove


ImageMeta是存储图像元信息的对象。IImageMetaStorage 是描述元存储的接口。程序仅有一个该接口的实现: IImageMetaStorage FSImageMetaStorage实现IImageMetaStorage接口来管理存储在单个 JSON 文件中的图像元数据。


它使用缓存将元数据存储在内存中,并在需要时通过读取 JSON 文件来确保缓存已补充。该类提供创建、检索、列出和删除图像元数据的方法,并将更改写回到 JSON 文件以保留更新。缓存通过减少 IO 操作次数来提高性能。


显然, ImageBinary是一个具有二进制图像数据的对象。Image 接口是ImageImageBinary ImageMeta组合。

图像:二进制存储和生成器

UML 类图显示了图像生成和二进制存储的接口及其关系。该图包括 IBinaryStorage、IImageGenerator 接口以及 FSBinaryStorage、ImageGeneratorComposite、PassThroughImageGenerator、TextImageGenerator 和 ImageLoaderFacade 类。IBinaryStorage 具有方法 load、remove 和 write。FSBinaryStorage 实现 IBinaryStorage 并具有附加构造函数。IImageGenerator 具有方法 generate。PassThroughImageGenerator 和 TextImageGenerator 实现 IImageGenerator。ImageGeneratorComposite 具有方法 addGenerator 和 generate。ImageLoaderFacade 具有构造函数和 load 方法,并与 IBinaryStorage 和 IImageGenerator 交互


IBinaryStorage是用于二进制数据存储的接口。二进制存储应该与图像分离,可以存储任何数据:图像、视频、JSON 或文本。这个事实对我们很重要,您将会明白为什么。


IImageGenerator是一个描述生成器的接口。生成器是程序的重要组成部分。它获取原始二进制数据和元数据,并基于此生成图像。为什么程序需要生成器?没有它们程序可以运行吗?


可以,但生成器将为实现增加灵活性。生成器允许用户上传图片、文本数据,广义上讲,任何你编写生成器的数据。


该图显示了使用 IImageGenerator 接口将文本文件转换为图像的过程。左侧有一个文本文件图标,标记为“myfile.txt”,内容为“Hello, world!”。标记为“IImageGenerator”的箭头指向右侧,其中有一个图像文件图标,标记为“myfile.png”,图像中显示相同的文本“Hello, world!”


流程如下:从存储中加载二进制数据(上图中的myfile.txt ),然后二进制文件传递给生成器。它会“动态”生成图像。您可以将其视为从一种格式到另一种格式的转换器,这对我们来说更方便。


让我们看一个生成器的例子:

 import { createCanvas } from 'canvas'; // Import createCanvas function from the canvas library to create and manipulate images const IMAGE_SIZE_RE = /(\d+)x(\d+)/; // Regular expression to extract width and height dimensions from a string export class TextImageGenerator implements IImageGenerator { // method to generate an image from text async generate(meta: ImageMeta, rawBuffer: Buffer): Promise<Image | null> { // Step 1: Verify the MIME type is text if (meta.originalFileType !== MimeType.TXT) { // If the file type is not text, return null indicating no image generation return null; } // Step 2: Determine the size of the image const imageSize = { width: 800, // Default width height: 600, // Default height }; // Extract dimensions from the name if present const imageSizeRaw = IMAGE_SIZE_RE.exec(meta.name); if (imageSizeRaw) { // Update the width and height based on extracted values, or keep defaults imageSize.width = Number(imageSizeRaw[1]) || imageSize.width; imageSize.height = Number(imageSizeRaw[2]) || imageSize.height; } // Step 3: Convert the raw buffer to a string to get the text content const imageText = rawBuffer.toString('utf-8'); // Step 4: Create a canvas with the determined size const canvas = createCanvas(imageSize.width, imageSize.height); const ctx = canvas.getContext('2d'); // Get the 2D drawing context // Step 5: Prepare the canvas background ctx.fillStyle = '#000000'; // Set fill color to black ctx.fillRect(0, 0, imageSize.width, imageSize.height); // Fill the entire canvas with the background color // Step 6: Draw the text onto the canvas ctx.textAlign = 'start'; // Align text to the start (left) ctx.textBaseline = 'top'; // Align text to the top ctx.fillStyle = '#ffffff'; // Set text color to white ctx.font = '30px Open Sans'; // Set font style and size ctx.fillText(imageText, 10, 10); // Draw the text with a margin // Step 7: Convert the canvas to a PNG buffer and create the Image object return { meta, // Include the original metadata binary: { buffer: canvas.toBuffer('image/png'), // Convert canvas content to a PNG buffer }, }; } }


ImageLoaderFacade类是一个逻辑上结合存储和生成器的外观- 换句话说,它实现了您在上面读到的流程。

图片:变体

UML 类图显示了图像生成和二进制存储的接口及其关系。该图包括 IBinaryStorage、IImageGenerator 接口以及 FSBinaryStorage、ImageGeneratorComposite、PassThroughImageGenerator、TextImageGenerator 和 ImageLoaderFacade 类。IBinaryStorage 具有方法 load、remove 和 write。FSBinaryStorage 实现 IBinaryStorage 并具有附加构造函数。IImageGenerator 具有方法 generate。PassThroughImageGenerator 和 TextImageGenerator 实现 IImageGenerator。ImageGeneratorComposite 具有方法 addGenerator 和 generate。ImageLoaderFacade 具有构造函数和 load 方法,并与 IBinaryStorage 和 IImageGenerator 交互


IImageVariant是用于创建各种图像变体的接口。在此上下文中,变体是“动态”生成的图像,将在查看文件系统中的文件时显示给用户。与生成器的主要区别在于,它以图像而不是原始数据作为输入。


该程序有三个变体: ImageAlwaysRandomImageOriginalVariantImageWithText ImageAlwaysRandom带有随机RGB噪声方块的原始图像。


 export class ImageAlwaysRandomVariant implements IImageVariant { // Define a constant for the size of the random square edge in pixels private readonly randomSquareEdgeSizePx = 16; // Constructor takes the desired output format for the image constructor(private readonly outputFormat: ImageFormat) {} // Asynchronous method to generate a random variant of an image async generate(image: Image): Promise<ImageBinary> { // Step 1: Load the image using the sharp library const sharpImage = sharp(image.binary.buffer); // Step 2: Retrieve metadata and raw buffer from the image const metadata = await sharpImage.metadata(); // Get image metadata const buffer = await sharpImage.raw().toBuffer(); // Get raw pixel data // the buffer size is plain array with size of image width * image height * channels count (3 or 4) // Step 3: Apply random pixel values to a small square region in the image for (let y = 0; y < this.randomSquareEdgeSizePx; y++) { for (let x = 0; x < this.randomSquareEdgeSizePx; x++) { // Calculate the buffer offset for the current pixel const offset = y * metadata.width! * metadata.channels! + x * metadata.channels!; // Set random values for RGB channels buffer[offset + 0] = randInt(0, 255); // Red channel buffer[offset + 1] = randInt(0, 255); // Green channel buffer[offset + 2] = randInt(0, 255); // Blue channel // If the image has an alpha channel, set it to 255 (fully opaque) if (metadata.channels === 4) { buffer[offset + 3] = 255; // Alpha channel } } } // Step 4: Create a new sharp image from the modified buffer and convert it to the desired format const result = await sharp(buffer, { raw: { width: metadata.width!, height: metadata.height!, channels: metadata.channels!, }, }) .toFormat(this.outputFormat) // Convert to the specified output format .toBuffer(); // Get the final image buffer // Step 5: Return the generated image binary data return { buffer: result, // Buffer containing the generated image }; } }


我使用sharp库作为在 NodeJS 中操作图像最方便的方式: https://github.com/lovell/sharp


ImageOriginalVariant返回一张没有任何变化ImageWithText图像(但它可以返回不同压缩格式的图像)。ImageWithText 返回一张上面有文字的图像。当我们创建单个图像的预定义变体时,这将很有用。例如,如果我们需要一张图像的 10 个随机变体,我们必须将这些变体彼此区分开来。


这里的解决方案是在原始图片的基础上创建 10 张图片,我们在每张图片的左上角呈现从 0 到 9 的连续数字。

一组图片展示了一只睁着大眼睛的白猫和黑猫。图片上标有数字,左侧为 0,以 1 为增量,右侧为 9,中间用省略号标记。每张图片中的猫咪表情都相同


ImageCacheWrapper的用途与变体不同,它通过缓存特定IImageVariant类的结果充当包装器。它将用于包装不变的实体,如图像转换器、文本到图像生成器等。这种缓存机制可以加快数据检索速度,主要是在多次读取同一幅图像时。


好了,我们已经介绍了程序的所有主要部分。现在是时候将所有内容组合在一起了。

树形结构

UML 类图显示了与图像管理相关的各种 FUSE 树节点之间的层次结构和关系。类包括 ImageVariantFileFUSETreeNode、ImageCacheWrapper、ImageItemAlwaysRandomDirFUSETreeNode、ImageItemOriginalDirFUSETreeNode、ImageItemCounterDirFUSETreeNode、ImageManagerItemFileFUSETreeNode、ImageItemDirFUSETreeNode、ImageManagerDirFUSETreeNode、ImagesDirFUSETreeNode 和 RootDirFUSETreeNode。每个类都有与图像元数据、二进制数据和文件操作(如 create、readAll、writeAll、remove 和 getattr)相关的属性和方法


下面的类图表示树类如何与其图像对应项组合。应从下往上阅读该图RootDir (让我避免在名称中ImagesManagerDir FUSETreeNode后缀)是程序正在实现的文件系统的根目录。移至上行,看到两个目录: ImagesDirImagesManagerDir包含用户图像列表并允许控制它们。然后, ImagesManagerItemFile是特定文件的节点。此类实现 CRUD 操作。


将 ImagesManagerDir 视为节点的通常实现:

 class ImageManagerDirFUSETreeNode extends DirectoryFUSETreeNode { name = 'Image Manager'; // Name of the directory constructor( private readonly imageMetaStorage: IImageMetaStorage, private readonly imageBinaryStorage: IBinaryStorage, ) { super(); // Call the parent class constructor } async children(): Promise<IFUSETreeNode[]> { // Dynamically create child nodes // In some cases, dynamic behavior can be problematic, requiring a cache of child nodes // to avoid redundant creation of IFUSETreeNode instances const list = await this.imageMetaStorage.list(); return list.map( (meta) => new ImageManagerItemFileFUSETreeNode( this.imageMetaStorage, this.imageBinaryStorage, meta, ), ); } async create(name: string, mode: number): Promise<void> { // Create a new image metadata entry await this.imageMetaStorage.create(name); } async getattr(): Promise<Stats> { return { // File modification date mtime: new Date(), // File last access date atime: new Date(), // File creation date // We do not store dates for our images, // so we simply return the current date ctime: new Date(), // Number of links nlink: 1, size: 100, // File access flags mode: FUSEMode.directory( FUSEMode.ALLOW_RWX, // Owner access rights FUSEMode.ALLOW_RX, // Group access rights FUSEMode.ALLOW_RX, // Access rights for all others ), // User ID of the file owner uid: process.getuid ? process.getuid() : 0, // Group ID for which the file is accessible gid: process.getgid ? process.getgid() : 0, }; } // Explicitly forbid deleting the 'Images Manager' folder remove(): Promise<void> { throw FUSEError.accessDenied(); } }


接下来, ImagesDir包含以用户图像命名的子目录。ImagesItemDir 负责每个目录。它包含所有可用的变体;您还记得,变体数量为 3。每个变体都是一个目录,其中包含ImagesItemDir格式的最终图像文件(当前格式ImagesItemCounterDir ImagesItemOriginalDir所有生成的ImageVariantFile实例包装在缓存中。


这是必要的,因为编码会消耗 CPU,因此可以避免不断重新编码原始图像。图的顶部是ImageVariantFile 。它是实现的核心,也是前面描述的IFUSEHandlerIImageVariant的组成部分。这是我们一直努力构建的文件。

测试

让我们测试最终的文件系统如何处理对同一文件的并行请求。为此,我们将在多个线程中运行md5sum实用程序,它将从文件系统读取文件并计算其哈希值。然后,我们将比较这些哈希值。如果一切正常,哈希值应该不同。

 #!/bin/bash # Loop to run the md5sum command 5 times in parallel for i in {1..5} do echo "Run $i..." # `&` at the end of the command runs it in the background md5sum ./mnt/Images/2020-09-10_22-43/always_random/2020-09-10_22-43.png & done echo 'wait...' # Wait for all background processes to finish wait


我运行了脚本并检查了以下输出(为了清晰起见,进行了一些清理):

 Run 1... Run 2... Run 3... Run 4... Run 5... wait... bcdda97c480db74e14b8779a4e5c9d64 0954d3b204c849ab553f1f5106d576aa 564eeadfd8d0b3e204f018c6716c36e9 73a92c5ef27992498ee038b1f4cfb05e 77db129e37fdd51ef68d93416fec4f65


太棒了!所有的哈希值都不同,这意味着文件系统每次都会返回一个唯一的图像!

结论

我希望本文能启发您编写自己的 FUSE 实现。请记住,此项目的源代码可在此处获取: https://github.com/pinkiesky/node-fuse-images


我们构建的文件系统经过简化,以展示使用 FUSE 和 Node.js 的核心原则。例如,它没有考虑正确的日期。还有很大的改进空间。想象一下添加诸如从用户 GIF 文件中提取帧、视频转码甚至通过工作器并行化任务等功能。


然而,完美是优秀的敌人。从现有的东西开始,让它运转起来,然后迭代。祝你编码愉快!