Описание проблемы

Кот рисует одинаковые, но разные картинки
Кот рисует одинаковые, но разные картинки

На прошлой работе одной из моих зон ответственности были медиа. Это достаточно большой набор задач вроде работы с картинками-баннерами, картинками-подарками и изображениями, загружаемых пользователями. Все это приправлялось требованиями вроде “баннеры должны быть в формате png или webp, 300x1000, иначе дальше бэкофис не пропустит” и дедуплицированием загрузки одинаковых изображений на стороне сервера.

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

Для упрощения этого процесса я создавал скриптовые однострочники или алиасы в командной оболочке. Например:

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

Однако такой подход был не слишком удобен и не обеспечивал нужной гибкости.

Ситуация была еще сложнее для команды QA. Во время ручного тестирования они регулярно тратили время на поиск или создание изображений с определенными характеристиками. И стандартным вопросом при решении проблем становился: "Зааплоадили уникальное изображение или оно уже могло быть на сервере?".

Для понимания хочу сделать важное уточнение: решить эту проблему на работе мне не удалось, так как я покинул проект. Поэтому текущая статья — это не столько описание решения существующей проблемы, сколько попытка разработчика развлечься и познакомиться с интересующими его технологиями, освежить в памяти основы Unix и подготовиться к предстоящим собеседованиям.

Небольшая ремарка по терминологии. Вместо многозначного слова “загрузили” я буду использовать следующие англицизмы:

  • Зааплоадили, аплоад — загрузили изображение на сервер;

  • Получили — загрузили изображение с сервера.

Выбор основной технологии

Можно пойти относительно простым путем: создать веб-сервер, который на запрос GET /image/1000x100/random.zip?filesCount=100 возвращал бы наборы изображений с необходимыми нам параметрами. Но проблему уникальности аплоада изображений это не решит. А если что-то может пойти не так как надо, то рано или поздно это произойдет.

Может быть, подменить содержимое запроса непосредственно при аплоаде? Мы используем Postman для работы с API, и после небольшого исследования стало ясно — изменить тело запроса "на лету" не получится. Менять инструмент и переучивать весь отдел QA — это явно стрельба из пушки по воробьям. Остается подменять файл в файловой системе таким образом, чтобы каждое его чтение было уникальным.

Тогда возникает вопрос: как перехватывать запросы к файлу и каким образом организовывать подмену?

Первое, что мне пришло в голову — использование inotify (подсистемы Linux), которая уведомляет о событиях в файловой системе. Изменение директории, чтение, переименование файла — обо всем этом можно узнать, подписавшись на события inotify. Если вы сталкивались с ошибкой в VSCode: “Visual Studio Code is unable to watch for file changes in this large workspace”, то это была проблема с inotify. Мы могли бы:

  • Слушать событие IN_OPEN, подсчитывая количество открытых дескрипторов;

  • Слушать событие IN_CLOSE, и если дескрипторов не осталось, подменять файл.

Звучит неплохо, но есть несколько проблем:

  • inotify это чисто линуксовая история;

  • несколько параллельных запросов к файлу вернут одинаковые данные;

  • для файлов с интенсивным чтением подмена может и не произойти;

  • если наш сервис упадёт, то всё будет выглядеть рабочим, а по факту работать не будет.

Альтернативный подход — написать собственную файловую систему. Но здесь есть проблема: рядовая файловая система работает в пространстве ядра ОС. Как следствие это накладывает на нас обязанности по знанию языков вроде C/Rust и знания API ядра, а также под каждое ядро нужно будет написать свою реализацию драйвера. Даже для разработчика без работы и ищущего развлечения, это кажется чрезмерным. Ну, или во мне пропал дух авантюризма.

На помощь приходит проект FUSE (File system in USEr space), который позволяет создать файловую систему в пользовательском пространстве без вмешательства в ядро ОС.

Если объяснять простым языком, то при помощи FUSE обычная программа или скрипт может имитировать работу подключенного жесткого диска или флешки. Вместо физического подключения устройства достаточно запустить программу — FUSE сделает остальное.

Виртуальная файловая система на FUSE идеально подходит для нашей задачи:

  • мы получаем полный контроль над файлами и процессами;

  • решение кроссплатформенное;

  • работая в пользовательском пространстве, мы свободны в выборе языка программирования — он ограничен только нашими знаниями и доступностью биндингов к FUSE.

Кажется, мы определились. Последние пару лет я активно работал с Node.js, так что выберем его в качестве среды выполнения. К счастью, существует несколько биндингов к FUSE.

Мы будем использовать TypeScript в качестве языка программирования. Выбрал я его, потому что будет гораздо наглядней в статье показать не исходный код скриптов, а структуру интерфейсов и классов, лишь изредка заглядывая в исходный код за интересными или не совсем очевидными моментами. В коде всегда будут полезные комментарии, поэтому не ленитесь заглядывать под спойлер.

Dive in FUSE

В этом разделе мы обсудим FUSE, определим необходимые методы и выберем подход к реализации.

Проект FUSE состоит из двух основных компонентов: FUSE-модуль ядра (для Win пользователей привычнее будет использовать слово “драйвер” вместо “модуль”) и libfuse — C-библиотеки, предоставляющей интерфейс для взаимодействия клиентского приложения с модулем FUSE.

Принципиальная схема работы выглядит так:

Классическое изображение работы FUSE
Классическое изображение работы FUSE

Приложение из пространства пользователя (на рисунке выше это ls) делает запрос к Virtual File System модулю ядра (это унифицированный интерфейс для запросов к различным форматам файловых систем), которое направляет запрос к FUSE-модулю. Затем FUSE-модуль отправляет обратно в пространство пользователя запрос к реализации виртуальной ФС.

libfuse имеет два типа API: высокоуровневый и низкоуровневый. Эти виды API похожи за несколькими исключениями — низкоуровневое API асинхронное и работает только с inodes. Асинхронность в данном случае обозначает то, что мы должны явно вызвать метод для отправки ответа на наш запрос.

Высокоуровневое же позволяет оперировать более привычными путями до файлов вместо inodes (например, /mnt/myfile) и высокоуровневые методы работают в синхронном стиле, т.е. получили запрос и в том же методе вернули ответ.

Низкоуровневое API хоть и выглядит интереснее, но в данной статье мы его рассматривать не будем, как и понятие inodes.

Для реализации нашей ФС при помощи libfuse мы должны реализовать серию методов, которые будут обрабатывать запросы поступающие от VFS. Всего их около 30, со всеми вы можете ознакомится в официальной документации: https://libfuse.github.io/doxygen/structfuse__operations.html. В рамках статьи ниже мы ознакомимся только с самыми необходимыми для ФС и/или которые мы будем использовать в нашей реализации.

  • open(path, access): number — открытие канала, по которому будет передаваться наш файл, как для чтения, так и для записи. Для каждого канала нам необходимо будет сделать уникальный числовой идентификатор, называемый Файловым дескриптором (далее в параметрах fd);

  • read(path, fd, Buffer, size, offset): number — чтение size байт файла по дескриптору fs в буфер. Возвращает кол-во записанных в буфер байт. Аргумент path тут можно игнорировать;

  • write(path, fd, Buffer, size, offset): number — запись size байт в файл из буфера. Возвращает кол-то записанных байт. Аргумент path тут можно игнорировать;

  • release(fd)— закрытие файлового дескриптора;

  • truncate(path, size) — изменение размера файла. Этот метод обязательно нужно реализовывать для поддержки перезаписи файлов;

  • create(path, mode) — создание файла;

  • getattr(path) — возвращает параметры файла. Размер, время создание, время последнего доступа и т.д. Код этой функции должен быть оптимизирован, поскольку libfuse её будет вызывать чаще всего;

  • readdir(path) — чтение поддиректорий из дирректории.

Соответственно, при открытии файла поток вызовов будет приблизительно такой:

  1. getattr(/random.png){ size: 98 };

    1. программа получит размер файла;

  2. open(/random.png)10;

    1. открытие канала, возвращаем файловый дескриптор;

  3. read(/random.png, 10 buffer, 50, 0)50

    1. чтение первых 50 байт

  4. read(/random.png, 10 buffer, 50, 50)48

    1. чтение следующих 50. Из-за размера файла прочитать удалось только 48

  5. release(10)

    1. все данные прочитаны, освобождаем дескриптор.

Вспомним что такое файловый дескриптор. ФД в UNIX-подобных системах, включая Linux — это абстракция, используемая для работы с файлами и другими вводно-выводными ресурсами, как, например, сокетами и каналами. Когда программа открывает файл, операционная система возвращает числовой идентификатор, известный как файловый дескриптор. Этот идентификатор используется для дальнейших операций ввода-вывода с файлом. Файловый дескриптор — это просто целое число, которое является индексом в таблице файловых дескрипторов, управляемой операционной системой для каждого процесса. При реализации ФС при помощи FUSE, мы должны будем сами генерировать файловые дескрипторы.

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

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

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

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

Минимально жизнеспособный продукт будет состоять из двух частей. Первая — это простой веб-сервер, который будет принимать HTTP POST запросы и выводить в консоль полученное тело запроса. Код простой и рассматривать в рамках статьи мы его не будем, т.к. непосредственно с FUSE веб-сервер никак не связан.

Вторая часть — это непосредственно реализация нашей ФС. Уложилась она в 83 строки кода.

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

Код с комментариями:

// MOUNT_PATH это путь, по которому будет доступна наша файловая система. Для win это будет путь вида '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() {
  // fd это простой счетчик, который увеличивается при каждом открытии файла
  // по нему мы можем получить содержимое файла, которое уникально для каждого открытия
  let fdCounter = 0;

  // fd2ContentMap это мапа, которая хранит содержимое файла по fd
  const fd2ContentMap = new Map();

  // Postman не работает стабильно, если мы передадим ему файл с размером 0 или просто с неправильным размером,
  // поэтому заранее вычисляем размер файла
  // гарантируется что размер файла будет всегда одинаковый в рамках одного запуска, поэтому с этим проблем не возникнет
  const randomTxtSize = getRandomContent().length;

  // fuse.mount это функция, которая монтирует файловую систему
  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 это время последней модификации файла
            mtime: new Date(),
            // atime это время последнего доступа к файлу
            atime: new Date(),
            // ctime это время последнего изменения метаданных или содержимого файла
            ctime: new Date(),
            size: 100,
            // mode это права доступа к объекту ФС
            // это маска, которая определяет права доступа для разных типов пользователей
            // и сам тип файла
            // 40 -- указатель на то, что это директория
            // 755 -- маска доступа, о ней ниже по тексту
            mode: 0o40755,
            // владельцы файла
            // для нашего случая это будет владелец текущего процесса
            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: 0o100644,
            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);
    },
  );
}

// отдельно обрабатываем сигнал SIGINT, чтобы корректно отмонтировать файловую систему
// без этого ФС не будет отмонтирована и будет висеть в системе
// если по разным причинам unmount не был вызван, то можно принудительно отмонтировать ФС через команду
// fusermount -u ./MOUNT_PATH
process.on('SIGINT', function () {
  fuse.unmount(MOUNT_PATH, function () {
    console.log('filesystem at ' + MOUNT_PATH + ' unmounted');
    process.exit();
  });
});

main();

Немного освежим наши знания о масках доступа.

Маска доступа представляет собой бинарное представление прав доступа к любому объекту ФС. В Linux каждый файл обладает набором разрешений, определяемых для трех групп: владельца файла, группы пользователей и всех остальных.

Для каждой из этих групп могут быть установлены разрешения на чтение (r), запись (w) и выполнение файла или чтение содержимого директории (x). Обычно, эти разрешения представляются трехразрядными числами: чтение (4 или '100' в двоичной системе), запись (2 или '010') и выполнение (1 или '001'). Суммируя эти значения, можно задать комбинированные разрешения. Например, маска '110' (или 6 в десятичной системе) предоставляет права на чтение и запись, но не на выполнение.

Таким образом, если владелец файла имеет маску доступа 7 (111 в бинарном виде, что означает чтение, запись и выполнение), группа пользователей — 5 (101, чтение и выполнение), а остальные пользователи — 4 (100, только чтение), то полная маска доступа для файла будет выглядеть как 754 в десятичном представлении.

Идея нашей реализации проста. При каждом открытии файла (вызов open) мы инкрементируем целочисленный счётчик — это наш файловый дескриптор. Затем создаём случайный контент и сохраняем в наше key-value хранилище с ключом, равным файловому десткриптору. При последующем вызове read по файловому дескриптору мы отдаём запрошенные части контента. При вызове release мы удаляем контент.

Не забываем обрабатывать SIGINT, чтобы отмонтировать нашу ФС после нажатия Crtl+C. Иначе нам придется делать это в терминале вручную: fusermount -u ./MOUNT_PATH

Теперь переходим к тестированию. Запускаем тестовый веб-сервер, создаём папку для монтирования ФС и запускаем программу с реализацией нашей ФС. Затем идём в постман и отправляем несколько запросов.

Логи. Справа ФС, слева веб-сервер
Логи. Справа ФС, слева веб-сервер

Слева логи ФС, справа логи сервера. Всё отлично работает! Каждый новый отправленный запрос — это отправка уникального файла, хотя в самом Postman мы ничего не меняем.

По логам ФС видно, что описанный в главе Dive in FUSE процесс открытия файла верен.

Репозиторий с MVP: https://github.com/pinkiesky/node-fuse-mvp

Вы можете попробовать запустить ФС у себя на ПК, либо использовать этот репозиторий как отправную точку для написания собственной виртуальной ФС.

Основное решение

В первую очередь пользователю нужно позволить создавать и удалять изображения, которые он хочет получить случайными. Сделаем этот интерфейс через папку внутри нашей виртуальной ФС.

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

Внутри корня ФС будет две папки: Image Manager и Images. Через первую мы будем управлять нашими файлами, а во второй будут результаты обработки и результаты генерации случайных изображений. Копируем файл в Image Manager — это создает нужные структуры и файлы в Image. Удаляем из Image Manager — данные удаляются из Image.

Пример контроля наших изображений
Пример контроля наших изображений
Эталонная структура корня ФС
Эталонная структура корня ФС

Идея, положенная в основу, у нас следующая: в памяти будем создавать дерево объектов, каждый узел которого (терминальный или нет) будет реализовывать основные методы FUSE. Выглядит это примерно так.

Когда libfuse будет получать вызов, например, getattr(/Images/1/original/1.png), мы будем искать в дереве узел по пути Images/1/original/1.png . Если мы его нашли, то вызываем у него запрошенный FUSE метод.

Маленькой хитростью тут будет то, что дочерние узлы будут динамические. Если вы взгляните на интерфейс ObjectTreeNode на рисунке ниже, то заметите, что children — не массив с дочерними узлами, а функция. Сделано это для того, чтобы мы могли "на лету" генерировать все дочерние узлы. Это поможет нам игнорировать вопрос перестроения дерева при удалении или создании изображения.

Классы для реализации дерева и FUSE

Вот так выглядит диаграмма классов для реализации дерева ФС.

Особенность IFUSEHandler в том, что это интерфейс для реализации обработки основных вызовов FUSE, но с прицелом для упрощения чтения и записи. Делая чтение или запись целым буфером, мы перемещаем логику чтения “по частям” — в код обработки запросов к FS, а не к файлу. Метода open у IFUSETreeHandler нет, т.к. этот вызов обрабатывается в классе FUSEFacade.

FileFUSETreeNode и DirectoryFUSETreeNode — это абстрактные классы, в которых некоторые методы просто выкидывают ошибку. Например, в FileFUSETreeNode ошибку выкидывает метод readdir.

FUSEFacade — это важнейший класс, который реализует базовую логику нашего приложения и связывает компоненты воедино. Методы этого класса по сигнатурам подходят к высокоуровневым методам библиотеки libfuse.

node-fuse-bindings для обработки вызовов libfuse принимает на вход методы, которые должны вызвать колбэк с первым числовым аргументом в качестве ошибки. Второй аргумент — это возвращаемое значение. Это не очень подходит для нашего класса FUSEFacade, но мы обходим это кодом вида

  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.warn(`FUSE error: ${err}`);
          return cb(err.code, null as T);
        }

        console.error(err);
        cb(fuse.EIO, null as T);
      });
  };

// Ex. usage: 
// open(path, flags, cb) {
//   handleResultWrapper(fuseFacade.open(path, flags), cb);
// },

оборачивая в адаптер handleResultWrapper все вызываемые методы.

Каждый метод FUSEFacade где на вход подается путь к файлу (path) парсит этот путь, находит узел в дереве и вызывает необходимый метод узла.

Рассмотрим для примера пару интересных методов в FUSEFacade.

async create(path: string, mode: number): Promise<number> {
  this.logger.info(`create(${path})`);

  // Преобразуем путь вида `/Image Manager/1/image.jpg` в 
  //   `['Image Manager', '1', 'image.jpg']`
  // Выбрасываем внутри splitPath ошибку, если что-то пошло не так
  const dirs = this.splitPath(path);
  // name -- последний аргумент массива (image.jpg)
  const name = dirs.pop()!;

  // По нашему пути до директории (`/Image Manager/1` после вызова `pop`)
  //   получаем узел дерева, либо выкидываем ошибку
  const node = await this.safeGetNode(dirs);

  // Вызов метода из IFUSEHandler. Передаём не полный путь, а только имя!
  await node.create(name, mode);
  // Создаем файловый дескриптор на чтение и возвращаем его числовой id
  const fdObject = this.fdStorage.openWO();

  return fdObject.fd;
}

async readdir(path: string): Promise<string[]> {
  this.logger.info(`readdir(${path})`);

  // простой метод, но он хорошо показывает что динамический children для наших узлов будет
  // относительно нагруженным вызовом в том плане, что мы будем создавать 
  // большое количество объектов узлов, что подтолкнет нас к кэшированию
  // созданных дочерних узлов, но это мы рассмотрим ниже
  const node = await this.safeGetNode(path);
  return (await node?.children()).map((child) => child.name);
}

async open(path: string, flags: number): Promise<number> {
  this.logger.info(`open(${path}, ${flags})`);

  // По пути до файла (`/Image/1/original/1.jpg`)
  //   получаем узел дерева, либо выбрасываем ошибку
  const node = await this.safeGetNode(path);

  // open невозможно вызвать на директорию (не терминальный узел)
  if (!node.isLeaf) {
    throw new FUSEError(fuse.EACCES, 'invalid path');
  }

  // Обычно в методе checkAvailability проверка на права доступа
  // но всё зависит от конкретной реализации узла
  await node.checkAvailability(flags);

  // Получаем содержимое узла и сохраняем его в объект файлового дескриптора
  const fileData: Buffer = await node.readAll();
  // fdStorage -- экземпляр IFileDescriptorStorage, о котором ниже
  const fdObject = this.fdStorage.openRO(fileData);

  return fdObject.fd;
}

Прежде чем двинутся дальше, давайте рассмотрим что такое файловый дескриптор в разрезе нашей системы.

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

IFileDescriptorStorage — это интерфейс, который описывает хранилище файловых дескрипторов. Он служит для возвращения уникальных номеров файловых дескрипторов. Метод openRO и openWO должны возвращать дескрипторы поддерживающие чтение или запись соответственно.

Давайте рассмотрим как мы обрабатываем чтение, запись и закрытие файла:

async read(
  fd: number,
  buf: Buffer,
  len: number,
  pos: number,
): Promise<number> {
  this.logger.info(`read(${fd}, ${len}, ${pos})`);

  const fdObject = this.fdStorage.get(fd);
  if (!fdObject) {
    throw new FUSEError(fuse.EBADF, 'invalid fd');
  }

  return fdObject.readToBuffer(buf, len, pos);
}

async write(
  fd: number,
  buf: Buffer,
  len: number,
  pos: number,
): Promise<number> {
  this.logger.info(`write(${fd}, ${len}, ${pos})`);

  const fdObject = this.fdStorage.get(fd);
  if (!fdObject) {
    throw new FUSEError(fuse.EBADF, 'invalid fd');
  }

  return fdObject.writeToBuffer(buf, len, pos);
}

async release(path: string, fd: number): Promise<0> {
  this.logger.info(`release(${fd})`);

  const fdObject = this.fdStorage.get(fd);
  if (!fdObject) {
    throw new FUSEError(fuse.EBADF, 'invalid fd');
  }

  const node = await this.safeGetNode(path);

  await node.writeAll(fdObject.binary);
  this.fdStorage.release(fd);

  return 0;
}

В данном фрагменте код без каких-либо ухищрений. При чтении/записи копируем данные в/из буфера, при закрытии файлового дескриптора сохраняем записанные данные в наше бинарное хранилище.

С кодом вокруг libfuse мы закончили, переходим непосредственно к изображениям.

“DTO” классы для изображений

Скучная, но важная часть нашей системы.

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

ImageBinary, как нетрудно догадаться, служит для хранения бинарных данных изображений.

Image в данном случае это композиция ImageMeta и ImageBinary.

Классы изображений

IBinaryStorage — это хранилище бинарных данных, на основании которых обеспечивается генерация картинок для виртуальной ФС. Там может лежать всё что угодно — от простых изображений до текстовых документов, мы не делаем каких-либо ограничений. Сейчас у нас только одна реализация, которая хранит данные в файлах на нашей реальной ФС.

IImageGenerator — интерфейс для генераторов. Генератор — это класс, который принимает сырые бинарные данные и возвращает бинарные данные в формате изображения. Либо null, если создать изображение не удается.

Для чего нам вообще нужны генераторы? Почему мы не можем напрямую загружать бинарные данные из хранилища? Использование генераторов позволяет пользователю загружать в нашу виртуальную ФС не только изображения, но и другие типы данных: текстовые файлы или ссылки на сайт. Пользователь при этом получит изображение, которое будет генерироваться "на лету" на основании этих данных. Пользователь загружает текстовый файл, а получает изображение с текстом из этого файла.

txt -> IImageGenerator -> png
txt -> IImageGenerator -> png

Класс ImageLoaderFacade — это фасад, который логикой объединяет хранилище и генератор. Он загружает бинарные данные, передает их в контейнер генераторов (ImageGeneratorComposite) и возвращает изображение, если его удалось создать.

IImageVariant — это интерфейс для создания различных вариантов изображений. Вариант изображения в данном случае, это изображение, генерируемое "на лету" которое будет отображаться пользователю при просмотре файлов в нашей виртуальной ФС. Здесь главное отличие от генераторов в том, что он принимает изображение, а не сырые данные.

Сейчас у нас реализовано три варианта. Первый это ImageAlwaysRandom — возвращает всегда случайное изображение. Мы добиваемся этого, добавляя в левый верхний угол оригинального изображения квадрат размером 16х16 из случайных RGB-пикселей.

Достаточно ли это эффективная рандомизация? Давайте посчитаем. 16х16 = 256 пикселей, в каждом пикселе может быть записано число от 0 до 16 777 216 (3 байта, RGB-кодирование). Следовательно, количество вариантов это 16 777 216^256 — число из 1558 цифр! Это в разы больше, чем атомов в обозримой вселенной (количество атомов во вселенной описывается числом из 80 цифр).

Создается впечатление, что можем легко уменьшить квадрат случайности. Но на практике сжатие с потерями (JPEG) срежет количество вариантов, так что 16х16 — оптимальный размер.

Метки в углу изображения всегда разные
Метки в углу изображения всегда разные

Два оставшихся скучнее.

ImageOriginalVariant возвращает оригинальное изображение, а ImageWithText возвращает картинку, с заданным текстом в левом верхнем углу.

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

Кошки, по порядку рассчитайсь!
Кошки, по порядку рассчитайсь!

ImageCacheWrapper стоит особняком от вариантов и является оберткой (или декоратором) — он кэширует результат работы класса IImageVariant. В ImageCacheWrapper будут обернуты сущности, не меняющиеся в процессе работы. Это позволит нам ускорить получение данных, особенно при множественном чтении одинаковых изображений.

Пытаемся теперь всё это склеить вместе.

Реализация дерева

Диаграмма выше напрямую отображает то, как выглядит структура каталогов нашей ФС.

Начнем снизу вверх: RootDir — это корневая директория нашей ФС. Поднимаемся на строку выше и видим что рут содержит две папки ImagesDir и ImagesManagerDir.

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

Давайте на примере ImagesManagerDir посмотрим на типичную реализацию нашего узла:

class ImageManagerDirFUSETreeNode extends DirectoryFUSETreeNode {
  name = 'Image Manager';

  constructor(
    private readonly imageMetaStorage: IImageMetaStorage,
    private readonly imageBinaryStorage: IBinaryStorage,
  ) {
    super();
  }

  async children(): Promise<IFUSETreeNode[]> {
    // динамически создаем потомков
    // в некоторых местах динамика нас подводит, и приходится делать кэш
    // потомков, чтоб избежать лишнего создания IFUSETreeNode классов
    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> {
    await this.imageMetaStorage.create(name);
  }

  async getattr(): Promise<Stats> {
    return {
      // дата модификации файла
      mtime: new Date(),
      // дата последего доступа к файлу
      atime: new Date(),
      // дата создания файла
      // мы не храним даты для наших изображений,
      // поэтому не стесняемся просто возвращать текущую дату
      ctime: new Date(),
      // количество ссылок
      nlink: 1,
      size: 100,
      // флаги доступа к файлу
      mode: FUSEMode.directory(
        FUSEMode.ALLOW_RWX, // права доступа для владельца
        FUSEMode.ALLOW_RX,  // права доступа для группы
        FUSEMode.ALLOW_RX,  // права доступа для всех остальных
      ),
      // id пользователя владельца файла 
      uid: process.getuid ? process.getuid() : 0,
      // id группы, которой доступен файл
      gid: process.getgid ? process.getgid() : 0,
    };
  }

  // явно запрещаем удалять папку Images Manager
  remove(): Promise<void> {
    throw FUSEError.accessDenied();
  }
}

Переходим к ImagesDir. Эта директория, как можно легко догадаться, содержит директории с именами изображений пользователя — ImagesItemDir. ImagesItemDir содержит в себе доступные варианты, сейчас у нас их три. Каждый вариант — это директория, содержащая в себе конечные файлы изображения в разных форматах (на текущий момент три — jpeg, png и webm).

Обратите внимание, что ImagesItemOriginalDir и ImagesItemCounterDir обрачивают все порождаемые ImageVariantFile в кэш. Это необходимо чтобы избежать постоянного перекодирования для оригинальных изображений. ImagesItemAlwaysRandom в кэш мы не можем поместить по понятным причинам.

Сверху диаграммы расположился ImageVariantFile, венец нашей реализации и композиция описанных выше IFUSEHandler и ImageVariant. Это и есть файл, ради которого всё затевалось.

Тестирование

Давайте проверим, как наша файловая система обрабатывает параллельные запросы к одному и тому же случайному изображению. Для этого мы запустим в несколько потоков утилиту md5sum, которая будет читать файлы из нашей ФС и считать их хэши. После этого мы сравним их. Если мы всё сделали правильно, то хэши должны быть разными.

#!/bin/bash

for i in {1..5}
do
  echo "Run $i..."

  # `&` в конце команды запускает её в фоновом режиме
  md5sum ./mnt/Images/2020-09-10_22-43/always_random/2020-09-10_22-43.png &
done

echo 'wait...'

# ждем завершения всех запущенных в фоне процессов
wait

Запускаем и видим следующий вывод (немного почистил его для наглядности):

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

Отлично! Все хэши разные, значит наша ФС каждый раз возвращает уникальное, в бинарном смысле слова, изображение.

Конец

Надеюсь, удалось вдохновить кого-нибудь на создание собственной реализации FUSE.

Ссылка на исходный код всего, что было описано в статье: https://github.com/pinkiesky/node-fuse-images

По структуре статьи и классов видно, что у нас получился “фреймворк” для создания древовидной структуры, а она затем преобразуется в ФС. Это вполне является областью, которую можно выделить в отдельную библиотеку. Если не найду работу еще с месяц, то обязательно займусь этим.

Сама ФС получилась в немного урезанном формате. Например, нет правильных дат. Из интересного функционала можно добавить раскадровку GIF-файлов пользователя, добавить перекодирование видео, распараллелить все через воркеры…

Но лучшее — враг хорошего.

Комментарии (1)


  1. dmitriym09
    01.12.2023 16:26

    Спасибо, интересная статья!

    А не думали использовать для данного кейса WebDAV, для которого уже есть fuse реализация? Или увидели в данном подходе какие-то ограничения?