paint-brush
Как построить динамическую файловую систему с помощью FUSE и Node.js: практический подходк@rglr
381 чтения
381 чтения

Как построить динамическую файловую систему с помощью FUSE и Node.js: практический подход

к Aleksandr Zinin33m2024/06/13
Read on Terminal Reader

Слишком долго; Читать

Вы когда-нибудь задумывались, что происходит, когда вы запускаете sshfs user@remote:~/ /mnt/remoteroot? Как файлы с удаленного сервера появляются в вашей локальной системе и так быстро синхронизируются? Слышали ли вы о WikipediaFS, которая позволяет редактировать статьи Википедии, как если бы это был файл в вашей файловой системе? Это не волшебство — это сила 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 пикселей». Если требования не выполнены, наш бэк-офис не пропустит изображение. И есть механизм дедупликации объектов: ни одно изображение не может войти в одну и ту же реку дважды.


Это приводит нас к ситуации, когда у нас есть огромный набор изображений для целей тестирования. Я использовал однострочники или псевдонимы оболочки, чтобы облегчить себе жизнь.


Например:

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


Пример шумового изображения


Комбинация bash и convert — отличный инструмент, но, очевидно, это не самый удобный способ решения проблемы. Обсуждение ситуации с командой контроля качества выявило дополнительные сложности. Помимо значительных затрат времени на создание изображения, первый вопрос, когда мы исследуем проблему: «Вы уверены, что загрузили уникальное изображение?» Я думаю, вы понимаете, насколько это раздражает.

Давайте выберем технологию, которую мы хотели бы использовать

Вы можете использовать простой подход: создать веб-службу, которая обслуживает маршрут, с помощью файла, не требующего пояснений, например GET /image/1000x100/random.zip?imagesCount=100 . Маршрут вернет ZIP-файл с набором уникальных изображений. Звучит хорошо, но это не решает нашу главную проблему: для тестирования все загружаемые файлы должны быть уникальными.


Ваша следующая мысль может быть: «Можем ли мы заменить полезную нагрузку при ее отправке?» Команда контроля качества использует 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 .
  • Параллельные запросы к файлу должны возвращать одни и те же данные.
  • Если файл содержит интенсивные операции ввода-вывода, замена никогда не произойдет.
  • Если служба, обслуживающая события Inotify, выйдет из строя, файлы останутся в файловой системе пользователя.


Чтобы решить эти проблемы, мы можем написать собственную файловую систему. Но есть и другая проблема: обычная файловая система работает в пространстве ядра ОС. Это требует от нас знаний о ядре ОС и использовании таких языков, как C/Rust. Также для каждого ядра нам следует написать определенный модуль (драйвер).


Следовательно, написание файловой системы является излишним для проблемы, которую мы хотим решить; даже если впереди длинные выходные. К счастью, есть способ приручить этого зверя: файловая система в пользовательском пространстве (FUSE). FUSE — это проект, который позволяет создавать файловые системы без редактирования кода ядра. Это означает, что любая программа или скрипт через FUSE, без какой-либо сложной логики, связанной с ядром, способна эмулировать флэш-память, жесткий диск или SSD.


Другими словами, обычный процесс пользовательского пространства может создать свою собственную файловую систему, к которой можно получить доступ через любую обычную программу по вашему желанию — Nautilus, Dolphin, ls и т. д.


Почему FUSE подходит для удовлетворения наших требований? Файловые системы на основе FUSE построены на основе процессов, разнесенных пользователем. Поэтому вы можете использовать любой известный вам язык, имеющий привязку к libfuse . Кроме того, вы получаете кроссплатформенное решение с FUSE.


У меня большой опыт работы с NodeJS и TypeScript, и я хотел бы выбрать эту (замечательную) комбинацию в качестве среды выполнения для нашей новейшей ФС. Более того, TypeScript предоставляет отличную объектно-ориентированную основу. Это позволит мне показать вам не только исходный код, который вы можете найти в общедоступном репозитории GitHub, но и структуру проекта.

Глубокое погружение в FUSE

Позвольте мне привести цитату с официальной страницы FUSE :

FUSE — это инфраструктура файловой системы пользовательского пространства. Он состоит из модуля ядра (fuse.ko), библиотеки пользовательского пространства (libfuse.*) и утилиты монтирования (fusermount).


Фреймворк для написания файловых систем звучит захватывающе.


Я должен объяснить, что означает каждая часть FUSE:

  1. fuse.ko выполняет все низкоуровневые задания, связанные с ядром; это позволяет нам избежать вмешательства в ядро ОС.


  2. libfuse — это библиотека, предоставляющая высокоуровневый уровень для связи с fuse.ko


  3. fusermount позволяет пользователям монтировать/размонтировать файловые системы пользовательского пространства (зовите меня Капитан Очевидность!).


Общие принципы выглядят следующим образом:
Общие принципы FUSE


Процесс пользовательского пространства (в данном случае ls ) отправляет запрос ядру виртуальной файловой системы, которое направляет запрос в модуль ядра FUSE. Модуль FUSE, в свою очередь, направляет запрос обратно в пространство пользователя к реализации файловой системы ( ./hello на рисунке выше).


Не обманывайтесь именем виртуальной файловой системы. К ПРЕДОХРАНИТЕЛЮ это не имеет прямого отношения. Это программный уровень ядра, который обеспечивает интерфейс файловой системы для программ пользовательского пространства. Для простоты можно воспринимать это как Composite паттерн .


libfuse предлагает два типа API: высокоуровневый и низкоуровневый. У них есть сходства, но существенные различия. Низкоуровневый является асинхронным и работает только с inodes . В данном случае асинхронность означает, что клиент, использующий низкоуровневый API, должен сам вызывать методы ответа.


Высокоуровневый предоставляет возможность использовать удобные пути (например, /etc/shadow ) вместо более «абстрактных» inodes и возвращает ответы синхронизированным способом. В этой статье я объясню, как работает высокоуровневый, а не низкоуровневый и inodes .


Если вы хотите реализовать собственную файловую систему, вам следует реализовать набор методов, отвечающих за обслуживание запросов от VFS. Наиболее распространенными методами являются:


  • open(path, accessFlags): fd -- открыть файл по пути. Метод должен возвращать числовой идентификатор, так называемый файловый дескриптор (далее fd ). Флаги доступа — это двоичная маска, которая описывает, какую операцию клиентская программа хочет выполнить (только чтение, только запись, чтение-запись, выполнение или поиск).


  • read(path, fd, Buffer, size, offset): count of bytes read -- чтение байтов size из файла, связанного с файловым дескриптором fd , в переданный буфер. Аргумент path игнорируется, поскольку мы будем использовать fd.


  • write(path, fd, Buffer, size, offset): count of bytes writtensize записи байтов из буфера в файл, связанный с fd .


  • release(fd) — закройте fd .


  • truncate(path, size) — изменить размер файла. Метод должен быть определен, если вы хотите перезаписать файлы (и мы это делаем).


  • getattr(path) — возвращает параметры файла, такие как размер, место создания, место доступа и т. д. Этот метод является наиболее вызываемым в файловой системе, поэтому убедитесь, что вы создали оптимальный.


  • readdir(path) — прочитать все подкаталоги.


Вышеуказанные методы жизненно важны для каждой полностью работоспособной файловой системы, построенной на основе высокоуровневого API FUSE. Но список неполный; полный список вы можете найти на https://libfuse.github.io/doxygen/structfuse__operations.html.


Возвращаясь к концепции файлового дескриптора: в UNIX-подобных системах, включая MacOS, файловый дескриптор представляет собой абстракцию файлов и других ресурсов ввода-вывода, таких как сокеты и каналы. Когда программа открывает файл, ОС возвращает числовой идентификатор, называемый дескриптором файла. Это целое число служит индексом в таблице дескрипторов файлов ОС для каждого процесса. При реализации файловой системы с использованием 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.

Напишем минимально жизнеспособный продукт и проверим реакцию на него почтальона

Наш следующий шаг — разработать минимальную файловую систему на основе libfuse чтобы проверить, как Postman будет взаимодействовать с пользовательской файловой системой.


Требования к приемке файловой системы просты: корень файловой системы должен содержать файл random.txt , содержимое которого должно быть уникальным при каждом чтении (назовем это «всегда уникальным чтением»). Содержимое должно содержать случайный UUID и текущее время в формате ISO, разделенные новой строкой. Например:

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


Минимальный продукт будет состоять из двух частей. Первый — это простой веб-сервис, который будет принимать запросы HTTP POST и выводить тело запроса на терминал. Код довольно прост и не стоит нашего времени, главным образом потому, что статья посвящена FUSE, а не Express. Вторая часть — реализация файловой системы, соответствующей требованиям. В нем всего 83 строки кода.


Для кода мы будем использовать библиотеку node-fuse-bindings, которая обеспечивает привязки к высокоуровневому API libfuse .


Вы можете пропустить код ниже; Ниже я собираюсь написать краткое описание кода.

 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. Затем создается случайный контент и сохраняется в хранилище значений ключа с дескриптором файла в качестве ключа. При выполнении вызова чтения возвращается соответствующая часть содержимого.


При вызове выпуска содержимое удаляется. Не забудьте использовать SIGINT для размонтирования файловой системы после нажатия Ctrl+C. В противном случае нам придется сделать это вручную в терминале, используя fusermount -u ./MOUNT_PATH .


Теперь приступим к тестированию. Запускаем веб-сервер, затем создаем пустую папку как корневую для предстоящей ФС и запускаем основной скрипт. После того, как напечатается строка «Сервер прослушивает порт 3000», откройте Postman и отправьте пару запросов к веб-серверу подряд, не меняя никаких параметров.
Слева — ФС, справа — веб-сервер.


Все выглядит хорошо! Как мы и предвидели, каждый запрос имеет уникальное содержимое файла. Журналы также доказывают, что поток вызовов открытия файлов, описанный выше в разделе «Глубокое погружение в FUSE», верен.


Репозиторий GitHub с MVP: https://github.com/pinkiesky/node-fuse-mvp . Вы можете запустить этот код в своей локальной среде или использовать этот репозиторий в качестве шаблона для собственной реализации файловой системы.

Основная идея

Подход проверен — теперь пришло время первичной реализации.


Прежде чем реализовать «всегда уникальное чтение», первое, что мы должны реализовать, — это операции создания и удаления для исходных файлов. Мы реализуем этот интерфейс через каталог в нашей виртуальной файловой системе. Пользователь помещает оригинальные изображения, которые он хочет сделать «всегда уникальными» или «рандомизированными», а файловая система подготовит все остальное.


Здесь и в следующих разделах термины «всегда уникальное чтение», «случайное изображение» или «случайный файл» относятся к файлу, который возвращает уникальное содержимое в двоичном виде каждый раз при чтении, при этом визуально он остается максимально похожим. к оригиналу.


Корень файловой системы будет содержать два каталога: Image Manager и Images. Первая — это папка для управления исходными файлами пользователя (ее можно рассматривать как CRUD-репозиторий). Второй — это неуправляемый каталог с точки зрения пользователя, содержащий случайные изображения.
Пользователь взаимодействует с файловой системой


Дерево ФС как вывод терминала


Как вы можете видеть на изображении выше, мы также реализуем не только «всегда уникальные» изображения, но и конвертер файлов! Это дополнительный бонус.


Основная идея нашей реализации заключается в том, что программа будет содержать дерево объектов, в котором каждый узел и лист предоставляют общие методы FUSE. Когда программа получает вызов FS, она должна найти узел или лист в дереве по соответствующему пути. Например, программа получает вызов getattr(/Images/1/original/) и затем пытается найти узел, к которому адресован путь.


Что-то вроде этого: Пример дерева ФС


Следующий вопрос — как мы будем хранить исходные изображения. Изображение в программе будет состоять из двоичных данных и метаинформации (мета включает в себя исходное имя файла, mime-тип файла и т. д.). Двоичные данные будут храниться в двоичном хранилище. Давайте упростим это и построим бинарное хранилище как набор бинарных файлов в файловой системе пользователя (или хоста). Метаинформация будет храниться аналогично: JSON внутри текстовых файлов в файловой системе пользователя.


Как вы помните, в разделе «Напишем минимально жизнеспособный продукт» мы создали файловую систему, возвращающую текстовый файл по шаблону. Он содержит случайный UUID плюс текущую дату, поэтому уникальность данных не была проблемой — уникальность достигалась за счет определения данных. Однако с этого момента программа должна работать с предустановленными пользовательскими изображениями. Итак, как мы можем создавать изображения, похожие, но всегда уникальные (с точки зрения байтов и, следовательно, хешей) на основе исходного?


Решение, которое я предлагаю, довольно простое. Давайте поместим квадрат шума RGB в верхний левый угол изображения. Квадрат шума должен иметь размер 16х16 пикселей. Это обеспечивает почти ту же картину, но гарантирует уникальную последовательность байтов. Хватит ли этого, чтобы обеспечить множество разных изображений? Давайте займемся математикой. Размер квадрата — 16,16×16 = 256 пикселей RGB в одном квадрате. Каждый пиксель имеет 256×256×256 = 16 777 216 вариантов.


Таким образом, количество уникальных квадратов составляет 16 777 216^256 — число из 1558 цифр, что намного больше, чем количество атомов в наблюдаемой Вселенной. Означает ли это, что мы можем уменьшить размер квадрата? К сожалению, сжатие с потерями, такое как JPEG, значительно уменьшит количество уникальных квадратов, поэтому оптимальный размер — 16x16.


Пример изображений с квадратами шума

Прохождение через классы

Дерево
Диаграмма классов UML, показывающая интерфейсы и классы для системы на основе FUSE. Включает интерфейсы IFUSEHandler, ObjectTreeNode и IFUSETreeNode, при этом FileFUSETreeNode и DirectoryFUSETreeNode реализуют IFUSETreeNode. В каждом интерфейсе и классе перечислены атрибуты и методы, иллюстрирующие их отношения и иерархию.

IFUSEHandler — это интерфейс, который обслуживает обычные вызовы FUSE. Вы можете видеть, что я заменил read/write на readAll/writeAll соответственно. Я сделал это для упрощения операций чтения и записи: когда IFUSEHandler производит чтение/запись для всей части, мы можем переместить частичную логику чтения/записи в другое место. Это означает, что IFUSEHandler не нужно ничего знать о файловых дескрипторах, двоичных данных и т. д.


То же самое произошло и с open методом FUSE. Примечательным аспектом дерева является то, что оно создается по требованию. Вместо того, чтобы хранить все дерево в памяти, программа создает узлы только при доступе к ним. Такое поведение позволяет программе избежать проблемы с перестроением дерева в случае создания или удаления узла.


Проверьте интерфейс ObjectTreeNode , и вы обнаружите, что children — это не массив, а метод, поэтому именно так они генерируются по требованию. FileFUSETreeNode и DirectoryFUSETreeNode — абстрактные классы, некоторые методы которых выдают ошибку NotSupported (очевидно, FileFUSETreeNode никогда не должен реализовывать readdir ).

ПРЕДОХРАНИТЕЛЬФасад

Диаграмма классов UML, показывающая интерфейсы и их отношения для системы FUSE. Схема включает интерфейсы IFUSEHandler, IFUSETreeNode, IFileDescriptorStorage и класс FUSEFacade. IFUSEHandler имеет имя атрибута и методы checkAvailability, create, getattr, readAll, delete и writeAll. IFileDescriptorStorage имеет методы get, openRO, openWO и Release. IFUSETreeNode расширяет IFUSEHandler. FUSEFacade включает методы конструктора, создания, getattr, open, read, readdir, Release, rmdir, SafeGetNode, unlink и write, а также взаимодействует как с IFUSETreeNode, так и с IFileDescriptorStorage.


FUSEFacade — это наиболее важный класс, который реализует основную логику программы и связывает различные части вместе. 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 реализует IFileDescriptor с дополнительным конструктором, методами readToBuffer и writeToBuffer. ReadFileDescriptor и WriteFileDescriptor расширяют ReadWriteFileDescriptor, причем ReadFileDescriptor имеет метод writeToBuffer, а WriteFileDescriptor имеет метод readToBuffer.

ReadWriteFileDescriptor — это класс, который хранит дескрипторы файлов в виде чисел и двоичные данные в виде буфера. Класс имеет методы readToBuffer и writeToBuffer , которые предоставляют возможность чтения и записи данных в буфер дескриптора файла. ReadFileDescriptor и WriteFileDescriptor — это реализации дескрипторов только для чтения и только для записи.


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 имеет атрибуты буфер и размер. ImageMeta имеет атрибуты id, name, originalFileName и originalFileType. Изображение имеет атрибуты «двоичный» и «мета», где двоичный файл имеет тип ImageBinary, а мета — тип ImageMeta. В IImageMetaStorage есть методы создания, получения, перечисления и удаления.


ImageMeta — это объект, хранящий метаинформацию об изображении. IImageMetaStorage — это интерфейс, описывающий хранилище метаданных. В программе имеется только одна реализация интерфейса: класс FSImageMetaStorage реализует интерфейс IImageMetaStorage для управления метаданными изображения, хранящимися в одном файле JSON.


Он использует кеш для хранения метаданных в памяти и обеспечивает гидратацию кеша путем чтения из файла JSON, когда это необходимо. Класс предоставляет методы для создания, получения, перечисления и удаления метаданных изображения, а также записывает изменения обратно в файл JSON для сохранения обновлений. Кэш повышает производительность за счет уменьшения количества операций ввода-вывода.


ImageBinary , очевидно, является объектом, имеющим двоичные данные изображения. Интерфейс Image представляет собой композицию ImageMeta и ImageBinary .

Изображения: двоичная память и генераторы

Диаграмма классов UML, показывающая интерфейсы и их отношения для создания изображений и двоичного хранения. Схема включает интерфейсы IBinaryStorage, IImageGenerator и классы FSBinaryStorage, ImageGeneratorComposite, PassThroughImageGenerator, TextImageGenerator и ImageLoaderFacade. IBinaryStorage имеет методы загрузки, удаления и записи. FSBinaryStorage реализует IBinaryStorage и имеет дополнительный конструктор. IImageGenerator имеет метод генерации. PassThroughImageGenerator и TextImageGenerator реализуют IImageGenerator. ImageGeneratorComposite имеет методы addGenerator иgenerate. ImageLoaderFacade имеет конструктор и метод загрузки и взаимодействует с 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 имеет методы загрузки, удаления и записи. FSBinaryStorage реализует IBinaryStorage и имеет дополнительный конструктор. IImageGenerator имеет метод генерации. PassThroughImageGenerator и TextImageGenerator реализуют IImageGenerator. ImageGeneratorComposite имеет методы addGenerator иgenerate. ImageLoaderFacade имеет конструктор и метод загрузки и взаимодействует с IBinaryStorage и IImageGenerator.


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 возвращает изображение с написанным поверх него текстом. Это будет полезно, когда мы создадим предопределенные варианты одного изображения. Например, если нам нужно 10 случайных вариаций одного изображения, мы должны отличить эти вариации друг от друга.


Решение здесь состоит в том, чтобы создать 10 изображений на основе оригинала, где мы визуализируем порядковый номер от 0 до 9 в верхнем левом углу каждого изображения.

Последовательность изображений, показывающая бело-черную кошку с широко раскрытыми глазами. Изображения помечены числами, начиная с 0 слева, увеличиваясь на 1 и заканчивая многоточиями до 9 справа. Выражение лица кошки остается одинаковым на каждом изображении.


ImageCacheWrapper имеет другое назначение, чем варианты, и действует как оболочка, кэшируя результаты конкретного класса IImageVariant . Он будет использоваться для обертывания объектов, которые не изменяются, таких как преобразователь изображений, генераторы текста в изображение и т. д. Этот механизм кэширования обеспечивает более быстрое извлечение данных, главным образом, когда одни и те же изображения читаются несколько раз.


Итак, мы рассмотрели все основные части программы. Пришло время объединить все воедино.

Древовидная структура

Диаграмма классов UML, показывающая иерархию и отношения между различными узлами дерева FUSE, связанными с управлением изображениями. Классы включают ImageVariantFileFUSETreeNode, ImageCacheWrapper, ImageItemAlwaysRandomDirFUSETreeNode, ImageItemOriginalDirFUSETreeNode, ImageItemCounterDirFUSETreeNode, ImageManagerItemFileFUSETreeNode, ImageItemDirFUSETreeNode, ImageManagerDirFUSETreeNode, ImagesDirFUSETreeNode и RootDirFUSETreeNode. Каждый класс имеет атрибуты и методы, относящиеся к метаданным изображения, двоичным данным и файловым операциям, таким как create, readAll, writeAll, delete и getattr.


Диаграмма классов ниже показывает, как классы деревьев комбинируются с их аналогами изображений. Диаграмму следует читать снизу вверх. RootDir (позвольте мне избежать постфикса FUSETreeNode в именах) — это корневой каталог файловой системы, которую реализует программа. Перейдя в верхний ряд, вы увидите два каталога: ImagesDir и ImagesManagerDir . 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 отвечает за каждый каталог. Он включает в себя все доступные варианты; как вы помните, количество вариантов — три. Каждый вариант представляет собой каталог, содержащий окончательные файлы изображений в разных форматах (в настоящее время: jpeg, png и webm). ImagesItemOriginalDir и ImagesItemCounterDir оборачивают все порожденные экземпляры ImageVariantFile в кэш.


Это необходимо, чтобы избежать постоянного перекодирования исходных изображений, поскольку кодирование требует ресурсов процессора. Вверху диаграммы находится 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, перекодирование видео или даже распараллеливание задач через рабочие процессы.


Однако идеальное – враг хорошего. Начните с того, что у вас есть, заставьте это работать, а затем повторяйте. Приятного кодирования!