Вы когда-нибудь задумывались, что происходит, когда вы запускаете 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.
Итак, план такой:
Прослушивание события IN_OPEN
и подсчет файловых дескрипторов.
Прослушивание события IN_CLOSE
; если счетчик упадет до 0, мы заменим файл.
Звучит хорошо, но есть пара проблем:
inotify
.
Чтобы решить эти проблемы, мы можем написать собственную файловую систему. Но есть и другая проблема: обычная файловая система работает в пространстве ядра ОС. Это требует от нас знаний о ядре ОС и использовании таких языков, как C/Rust. Также для каждого ядра нам следует написать определенный модуль (драйвер).
Следовательно, написание файловой системы является излишним для проблемы, которую мы хотим решить; даже если впереди длинные выходные. К счастью, есть способ приручить этого зверя: файловая система в пользовательском пространстве (FUSE). FUSE — это проект, который позволяет создавать файловые системы без редактирования кода ядра. Это означает, что любая программа или скрипт через FUSE, без какой-либо сложной логики, связанной с ядром, способна эмулировать флэш-память, жесткий диск или SSD.
Другими словами, обычный процесс пользовательского пространства может создать свою собственную файловую систему, к которой можно получить доступ через любую обычную программу по вашему желанию — Nautilus, Dolphin, ls и т. д.
Почему FUSE подходит для удовлетворения наших требований? Файловые системы на основе FUSE построены на основе процессов, разнесенных пользователем. Поэтому вы можете использовать любой известный вам язык, имеющий привязку к libfuse
. Кроме того, вы получаете кроссплатформенное решение с FUSE.
У меня большой опыт работы с NodeJS и TypeScript, и я хотел бы выбрать эту (замечательную) комбинацию в качестве среды выполнения для нашей новейшей ФС. Более того, TypeScript предоставляет отличную объектно-ориентированную основу. Это позволит мне показать вам не только исходный код, который вы можете найти в общедоступном репозитории GitHub, но и структуру проекта.
Позвольте мне привести цитату с официальной страницы FUSE :
FUSE — это инфраструктура файловой системы пользовательского пространства. Он состоит из модуля ядра (fuse.ko), библиотеки пользовательского пространства (libfuse.*) и утилиты монтирования (fusermount).
Фреймворк для написания файловых систем звучит захватывающе.
Я должен объяснить, что означает каждая часть FUSE:
fuse.ko
выполняет все низкоуровневые задания, связанные с ядром; это позволяет нам избежать вмешательства в ядро ОС.
libfuse
— это библиотека, предоставляющая высокоуровневый уровень для связи с fuse.ko
fusermount
позволяет пользователям монтировать/размонтировать файловые системы пользовательского пространства (зовите меня Капитан Очевидность!).
Общие принципы выглядят следующим образом:
Процесс пользовательского пространства (в данном случае 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 written
— size
записи байтов из буфера в файл, связанный с fd
.
release(fd)
— закройте fd
.
truncate(path, size)
— изменить размер файла. Метод должен быть определен, если вы хотите перезаписать файлы (и мы это делаем).
getattr(path)
— возвращает параметры файла, такие как размер, место создания, место доступа и т. д. Этот метод является наиболее вызываемым в файловой системе, поэтому убедитесь, что вы создали оптимальный.
readdir(path)
— прочитать все подкаталоги.
Вышеуказанные методы жизненно важны для каждой полностью работоспособной файловой системы, построенной на основе высокоуровневого API FUSE. Но список неполный; полный список вы можете найти на https://libfuse.github.io/doxygen/structfuse__operations.html.
Возвращаясь к концепции файлового дескриптора: в UNIX-подобных системах, включая MacOS, файловый дескриптор представляет собой абстракцию файлов и других ресурсов ввода-вывода, таких как сокеты и каналы. Когда программа открывает файл, ОС возвращает числовой идентификатор, называемый дескриптором файла. Это целое число служит индексом в таблице дескрипторов файлов ОС для каждого процесса. При реализации файловой системы с использованием 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 будет взаимодействовать с пользовательской файловой системой.
Требования к приемке файловой системы просты: корень файловой системы должен содержать файл 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.
IFUSEHandler
— это интерфейс, который обслуживает обычные вызовы FUSE. Вы можете видеть, что я заменил read/write
на readAll/writeAll
соответственно. Я сделал это для упрощения операций чтения и записи: когда IFUSEHandler
производит чтение/запись для всей части, мы можем переместить частичную логику чтения/записи в другое место. Это означает, что IFUSEHandler
не нужно ничего знать о файловых дескрипторах, двоичных данных и т. д.
То же самое произошло и с open
методом FUSE. Примечательным аспектом дерева является то, что оно создается по требованию. Вместо того, чтобы хранить все дерево в памяти, программа создает узлы только при доступе к ним. Такое поведение позволяет программе избежать проблемы с перестроением дерева в случае создания или удаления узла.
Проверьте интерфейс ObjectTreeNode
, и вы обнаружите, что children
— это не массив, а метод, поэтому именно так они генерируются по требованию. FileFUSETreeNode
и DirectoryFUSETreeNode
— абстрактные классы, некоторые методы которых выдают ошибку NotSupported
(очевидно, FileFUSETreeNode
никогда не должен реализовывать readdir
).
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; }
Прежде чем сделать следующий шаг, давайте подробнее рассмотрим, что такое файловый дескриптор в контексте нашей программы.
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
и дерева. Пришло время углубиться в код, связанный с изображениями.
ImageMeta
— это объект, хранящий метаинформацию об изображении. IImageMetaStorage
— это интерфейс, описывающий хранилище метаданных. В программе имеется только одна реализация интерфейса: класс FSImageMetaStorage
реализует интерфейс IImageMetaStorage
для управления метаданными изображения, хранящимися в одном файле JSON.
Он использует кеш для хранения метаданных в памяти и обеспечивает гидратацию кеша путем чтения из файла JSON, когда это необходимо. Класс предоставляет методы для создания, получения, перечисления и удаления метаданных изображения, а также записывает изменения обратно в файл JSON для сохранения обновлений. Кэш повышает производительность за счет уменьшения количества операций ввода-вывода.
ImageBinary
, очевидно, является объектом, имеющим двоичные данные изображения. Интерфейс Image
представляет собой композицию ImageMeta
и ImageBinary
.
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
возвращает изображение с написанным поверх него текстом. Это будет полезно, когда мы создадим предопределенные варианты одного изображения. Например, если нам нужно 10 случайных вариаций одного изображения, мы должны отличить эти вариации друг от друга.
Решение здесь состоит в том, чтобы создать 10 изображений на основе оригинала, где мы визуализируем порядковый номер от 0 до 9 в верхнем левом углу каждого изображения.
ImageCacheWrapper
имеет другое назначение, чем варианты, и действует как оболочка, кэшируя результаты конкретного класса IImageVariant
. Он будет использоваться для обертывания объектов, которые не изменяются, таких как преобразователь изображений, генераторы текста в изображение и т. д. Этот механизм кэширования обеспечивает более быстрое извлечение данных, главным образом, когда одни и те же изображения читаются несколько раз.
Итак, мы рассмотрели все основные части программы. Пришло время объединить все воедино.
Диаграмма классов ниже показывает, как классы деревьев комбинируются с их аналогами изображений. Диаграмму следует читать снизу вверх. 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, перекодирование видео или даже распараллеливание задач через рабочие процессы.
Однако идеальное – враг хорошего. Начните с того, что у вас есть, заставьте это работать, а затем повторяйте. Приятного кодирования!