您是否想知道运行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
bash
和convert
的组合是一个很好的工具,但显然,这不是解决问题最方便的方法。讨论 QA 团队的情况揭示了进一步的复杂性。除了在图像生成上花费的大量时间外,我们调查问题时的第一个问题是“你确定你上传了一个独特的图像吗?”我相信你明白这有多烦人。
您可以采取一种简单的方法:创建一个 Web 服务,为带有不言自明的文件的路由提供服务,例如GET /image/1000x100/random.zip?imagesCount=100
。该路由将返回一个包含一组唯一图像的 ZIP 文件。这听起来不错,但它并没有解决我们的主要问题:所有上传的文件都需要是唯一的才能进行测试。
你的下一个想法可能是“我们可以在发送时替换有效负载吗?” QA 团队使用 Postman 进行 API 调用。我调查了 Postman 的内部结构,发现我们无法“动态”更改请求主体
另一种解决方案是每次尝试读取文件时替换文件系统中的文件。Linux 有一个名为 Inotify 的通知子系统,它会提醒您有关文件系统事件(例如目录更改或文件修改)的信息。如果您收到“Visual Studio Code 无法监视这个大型工作区中的文件更改”,则 Inotify 存在问题。它可以在目录更改、文件重命名、文件打开等情况下触发事件。
因此,计划是:
监听IN_OPEN
事件并计算文件描述符。
监听IN_CLOSE
事件;如果计数降至 0,我们将替换该文件。
听起来不错,但也存在一些问题:
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.ko)、一个用户空间库 (libfuse.*) 和一个挂载实用程序 (fusermount) 组成。
用于编写文件系统的框架听起来令人兴奋。
我应该解释一下每个 FUSE 部分的含义:
fuse.ko
正在执行所有与内核相关的低级工作;这使我们能够避免干预操作系统内核。
libfuse
是一个提供与fuse.ko
通信的高级层的库。
fusermount
允许用户挂载/卸载用户空间文件系统(叫我 Captain Obvious!)。
用户空间进程(本例中为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 实现文件系统时,我们需要自己生成文件描述符。
让我们考虑客户端打开文件时的调用流程:
getattr(path: /random.png) → { size: 98 };
客户端获取了文件大小。
open(path: /random.png) → 10;
通过路径打开文件;FUSE 实现返回文件描述符编号。
read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50;
读取前 50 个字节。
read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48;
读取接下来的 50 个。由于文件大小,读取了 48 个字节。
release(10);
所有数据都已读取,因此接近 fd。
我们的下一步是开发一个基于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 服务器发送几个请求,不更改任何参数。
一切看起来都很好!正如我们所预见的,每个请求都有唯一的文件内容。日志还证明了上面“深入研究 FUSE”部分中描述的文件打开调用流程是正确的。
带有 MVP 的 GitHub 仓库: https://github.com/pinkiesky/node-fuse-mvp 。您可以在本地环境中运行此代码,也可以将此仓库用作您自己的文件系统实现的样板。
方法已经检查完毕——现在是进行主要实施的时候了。
在实现“始终唯一读取”之前,我们应该实现的第一件事是原始文件的创建和删除操作。我们将通过虚拟文件系统中的目录实现此接口。用户将放入他们想要“始终唯一”或“随机化”的原始图像,然后文件系统将准备其余部分。
这里以及在以下章节中,“始终唯一读取”、“随机图像”或“随机文件”是指每次读取时以二进制方式返回唯一内容的文件,同时在视觉上与原始文件尽可能相似。
文件系统的根目录将包含两个目录:Image Manager 和 Images。第一个是用于管理用户原始文件的文件夹(您可以将其视为 CRUD 存储库)。第二个是从用户角度来看的非托管目录,其中包含随机图像。
正如您在上图中看到的,我们不仅会实现“始终唯一”的图像,还会实现文件转换器!这是一个额外的好处。
我们实现的核心思想是,程序将包含一个对象树,每个节点和叶子都提供通用的 FUSE 方法。当程序收到 FS 调用时,它应该通过相应的路径在树中找到一个节点或叶子。例如,程序获取getattr(/Images/1/original/)
调用,然后尝试找到该路径指向的节点。
下一个问题是我们将如何存储原始图像。程序中的图像将由二进制数据和元信息组成(元信息包括原始文件名、文件 mime 类型等)。二进制数据将存储在二进制存储中。让我们简化它,并将二进制存储构建为用户(或主机)文件系统中的一组二进制文件。元信息将以类似的方式存储:JSON 存储在用户文件系统中的文本文件中。
您可能还记得,在“让我们编写一个最小可行产品”部分中,我们创建了一个文件系统,它通过模板返回一个文本文件。它包含一个随机 UUID 和一个当前日期,因此数据的唯一性不是问题——唯一性是通过数据的定义实现的。但是,从这一点来看,该程序应该可以处理预加载的用户图像。那么,我们如何才能基于原始图像创建相似但始终唯一的图像(就字节和哈希值而言)呢?
我建议的解决方案很简单。让我们在图像的左上角放置一个 RGB 噪声方块。噪声方块应为 16x16 像素。这提供了几乎相同的图片,但保证了唯一的字节序列。这足以确保大量不同的图像吗?让我们做一些计算。方块的大小是 16。单个方块中有 16×16 = 256 个 RGB 像素。每个像素有 256×256×256 = 16,777,216 个变体。
因此,唯一正方形的数量为 16,777,216^256——这个数字有 1,558 位,远远超过可观测宇宙中的原子数量。这是否意味着我们可以减小正方形的大小?不幸的是,像 JPEG 这样的有损压缩会显著减少唯一正方形的数量,因此 16x16 是最佳尺寸。
IFUSEHandler
是一个为常见 FUSE 调用提供服务的接口。您可以看到我分别用readAll/writeAll
替换了read/write
。我这样做是为了简化读写操作:当IFUSEHandler
对整个部分进行读/写时,我们能够将部分读/写逻辑移到另一个地方。这意味着IFUSEHandler
不需要知道有关文件描述符、二进制数据等的任何信息。
同样的事情也发生在open
FUSE 方法中。树的一个值得注意的方面是它是按需生成的。程序不会将整个树存储在内存中,而是仅在访问节点时才创建节点。这种行为允许程序避免在创建或删除节点时出现树重建问题。
检查ObjectTreeNode
接口,您会发现children
不是一个数组而是一个方法,因此这就是它们按需生成的方式FileFUSETreeNode
和DirectoryFUSETreeNode
是抽象类,其中一些方法会引发NotSupported
错误(显然, FileFUSETreeNode
永远不应实现readdir
)。
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; }
在进行下一步之前,让我们仔细看看程序上下文中文件描述符是什么。
ReadWriteFileDescriptor
是一个将文件描述符存储为数字并将二进制数据WriteFileDescriptor
为缓冲区的类。该类具有readToBuffer
和writeToBuffer
方法,可用于读取和写入文件描述符缓冲区中的数据。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
和树的相关代码。现在是时候深入研究与图像相关的代码了。
ImageMeta
是存储图像元信息的对象。IImageMetaStorage 是描述元存储的接口。程序仅有一个该接口的实现: IImageMetaStorage
FSImageMetaStorage
实现IImageMetaStorage
接口来管理存储在单个 JSON 文件中的图像元数据。
它使用缓存将元数据存储在内存中,并在需要时通过读取 JSON 文件来确保缓存已补充。该类提供创建、检索、列出和删除图像元数据的方法,并将更改写回到 JSON 文件以保留更新。缓存通过减少 IO 操作次数来提高性能。
显然, ImageBinary
是一个具有二进制图像数据的对象。Image 接口是Image
和ImageBinary
ImageMeta
组合。
IBinaryStorage
是用于二进制数据存储的接口。二进制存储应该与图像分离,可以存储任何数据:图像、视频、JSON 或文本。这个事实对我们很重要,您将会明白为什么。
IImageGenerator
是一个描述生成器的接口。生成器是程序的重要组成部分。它获取原始二进制数据和元数据,并基于此生成图像。为什么程序需要生成器?没有它们程序可以运行吗?
可以,但生成器将为实现增加灵活性。生成器允许用户上传图片、文本数据,广义上讲,任何你编写生成器的数据。
流程如下:从存储中加载二进制数据(上图中的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
类是一个逻辑上结合存储和生成器的外观- 换句话说,它实现了您在上面读到的流程。
IImageVariant
是用于创建各种图像变体的接口。在此上下文中,变体是“动态”生成的图像,将在查看文件系统中的文件时显示给用户。与生成器的主要区别在于,它以图像而不是原始数据作为输入。
该程序有三个变体: ImageAlwaysRandom
, ImageOriginalVariant
和ImageWithText
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 的连续数字。
ImageCacheWrapper
的用途与变体不同,它通过缓存特定IImageVariant
类的结果充当包装器。它将用于包装不变的实体,如图像转换器、文本到图像生成器等。这种缓存机制可以加快数据检索速度,主要是在多次读取同一幅图像时。
好了,我们已经介绍了程序的所有主要部分。现在是时候将所有内容组合在一起了。
下面的类图表示树类如何与其图像对应项组合。应从下往上阅读该图RootDir
(让我避免在名称中ImagesManagerDir
FUSETreeNode
后缀)是程序正在实现的文件系统的根目录。移至上行,看到两个目录: ImagesDir
和ImagesManagerDir
包含用户图像列表并允许控制它们。然后, 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
。它是实现的核心,也是前面描述的IFUSEHandler
和IImageVariant
的组成部分。这是我们一直努力构建的文件。
让我们测试最终的文件系统如何处理对同一文件的并行请求。为此,我们将在多个线程中运行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 文件中提取帧、视频转码甚至通过工作器并行化任务等功能。
然而,完美是优秀的敌人。从现有的东西开始,让它运转起来,然后迭代。祝你编码愉快!