Решение, чтобы раз и навсегда избавиться от потерянных загрузок файлов

Примечание. Полностью рабочий пример доступен на Gibhub: https://github.com/mkalus/self-cleaning-blob-storage-example

При написании веб-программного обеспечения одна из самых неприятных вещей - это загрузка файлов. Я всегда их ненавидел, даже в первые годы моей работы с ASP, Perl и PHP, около 2000. В то время у вас был тег формы файла, который позволял отправлять двоичные данные на веб-сервер. В большинстве случаев веб-сервер будет получать данные, сохранять двоичные данные во временный файл для дальнейшей обработки. Это было полезно, но неудобно, особенно когда оценка формы по какой-то причине не проходила: отправьте пользователя обратно в форму - напомните ему выполнить загрузку еще раз, или каким-то образом запомните сохраненный файл для следующей загрузки и т. Д. Проблема была: что делать делать со всеми этими временными данными? Я не могу вспомнить, как часто я создавал задания cron, которые периодически очищали временные папки загрузки ...

В последнее время все стало немного лучше, когда все начали использовать эти причудливые виджеты загрузки AJAX. Файл был загружен на сервер, где он ждал, пока пользователь отправит форму. Вам по-прежнему приходилось иметь дело с временными загрузками - на самом деле даже в большей степени, поскольку пользователи часто могли удалять файлы из формы, если они вообще отправляли форму. Это также верно для одностраничных приложений (SPA), использующих Dropzone или тому подобное.

Хуже того, как только вы каким-то образом сохранили свои данные для использования в приложении, люди будут редактировать или удалять наборы данных. Это приведет к увеличению количества загрузок, удалению файловых подключений и т. Д.

Бедные сироты

В какой-то момент на вашем складе появятся сироты. Сирота - это двоичные данные, которые больше не связаны из вашей базы данных или того, что может использовать ваше приложение. Поиск и устранение "сирот" может быть довольно нетривиальным и требующим много времени для больших наборов данных: найдите все активные подключения, найдите все файлы и удалите только те, которые не подключены. Конечно, если вы используете облачное хранилище, вы можете подумать: «Ну и что?». Вам все равно придется платить за сирот каждый месяц ... И, возможно, есть еще одна проблема, с которой я столкнулся во многих системах управления веб-контентом: люди удаляют свои новостные статьи, но не удаляют изображения, которые шли вместе с ними. . Через несколько лет кто-нибудь из сотрудников компании обнаружит эти изображения с помощью веб-поиска. Файлы по-прежнему хранятся на веб-сервере, чтобы их мог увидеть весь мир, верно? Это приводит к неистовым призывам к веб-программистам, потому что на этих старых рисунках могут быть показаны бывшие деловые отношения, а теперь - ожесточенные соперники, недовольные бывшие сотрудники или что-то еще не очень живописное.

Итак, было бы неплохо, если бы приложение и его хранилище позаботились о таких беспорядках?

Один из способов - хранить двоичные данные рядом с набором данных в самой базе данных. Это позволяет напрямую связывать данные друг с другом и использовать внутренние механизмы системы управления базами данных для поддержания чистоты двоичных данных. Но это часто считается плохой инженерией. Базы данных лучше всего работают с текстовыми или числовыми данными. Хранить большие двоичные объекты в базах данных кажется немного - ну, плохо. Хорошо, также могут быть довольно накладные расходы на сериализацию и десериализацию двоичных данных, создание дампов базы данных станет кошмаром (я могу рассказать эту историю ...), и на самом деле немного неудобно просто «посмотреть» на файл изображения, если он хранится в поле базы данных (в конце концов, здесь нет хорошего проводника, поисковика, наутилуса и т. д.).

Концепция

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

В одном из своих недавних проектов я реализовал такую ​​систему с помощью микросервиса FeathersJS. Это узловое приложение на основе Express, которое может подключаться к большому количеству баз данных. Он поддерживает как REST, так и Websockets. Его самым большим преимуществом в нашем случае является то, что он поддерживает хуки до и после для любой операции CRUD, поэтому мы можем позаботиться о ссылках на файлы на этом уровне. В качестве бэкэнда базы данных мы будем использовать MongoDB и Mongoose. Для хранения файлов мы будем использовать службу хранилище абстрактных BLOB-объектов, которая может сохранять двоичные данные на различных серверах (локальный диск, S3, Azure,…).

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

Это наша обязательная запись в блоге с заголовком и текстом, но пользователь также может загрузить несколько изображений для записи в блоге, а также один (или ни один) прикрепленный файл, например PDF-файл или другой документ для загрузки. Загрузка осуществляется с помощью методов drag-n-drop и AJAX.

Вот что мы должны учитывать:

  • При редактировании новой записи пользователь может закрыть свой браузер, отказаться от статьи и т.п. Загруженные данные будут автоматически потеряны.
  • Редактируя существующую запись, пользователь может удалить существующие файлы, заменить их новыми и т. Д.
  • Запись может быть удалена, а связанные файлы станут потерянными.
  • Наш набор данных содержит как список изображений (или массив), так и ссылку на один файл (строку).
  • Пользователи могут загружать идентичные файлы либо для более чем одной записи данных, либо потому, что они просто отказались от своей статьи и начали заново, снова загружая те же файлы.

Модель данных

Используя FeathersJS и MongoDB, мы можем реализовать описанные выше случаи, создав следующие сервисы:

  • Сервис / модель BlogEntry, которая будет служить нашим примером подключения данных к двоичным файлам.
  • Служба FileReference, которая запоминает, какие файлы к каким наборам данных подключены (например, MongoIds).
  • Служба выгрузки, которая позаботится о фактическом хранении файлов в хранилище BLOB-объектов.

Вы можете использовать пример кода или создать приложение FeathersJS с помощью инструмента командной строки - прочтите документацию FeathersJS для получения дополнительной информации. Затем создайте две службы Mongoose, BlogEntry и FileReferences, а также настраиваемую службу под названием Upload. Нам также понадобится несколько пакетов, поэтому не забудьте запустить npm i перья-крючки-общие перья-blob fs-blob-store multer.

Мы изменим модель BlogEntry, чтобы она содержала необходимые поля:

Как видите, у нас есть заголовок и текстовые поля, а также массив записей изображений и ссылка на вложения. Изображения и вложения будут содержать ссылки на имена файлов, хранящиеся в хранилище BLOB-объектов. Ничего особенного, правда?

FileReference будет основными данными, содержащими ссылки на то, какие файлы связаны с какими данными. Итак, модель довольно проста:

Как видите, _id - это не MongoId, а обычный текст - та же ссылка на файл использовалась в BlogEntry выше. Ссылки связаны с MongoIds, я решил оставить их в виде строк, чтобы сделать модель более гибкой. Не стесняйтесь использовать здесь ObjectIds как тип. Чтобы ускорить поиск данных в этой коллекции, мы устанавливаем индексы как для ссылок, так и для поля updatedAt (которое автоматически поддерживается путем установки параметра timestamps в вызове конструктора схемы).

Наши две модели довольно просты. А как насчет службы загрузки? Поскольку мы создали настраиваемую службу, у нас нет модели данных, и она нам не нужна. Скорее, мы реализуем здесь хранилище абстрактных BLOB-объектов, что на самом деле довольно легко сделать в FeathersJS, поскольку мы можем просто интегрировать существующие пакеты:

Большая его часть взята из Кулинарной книги FeathersJS. Короче говоря, мы создаем конечную точку службы, которая будет передавать загруженные двоичные данные в службу больших двоичных объектов. Он будет использовать локальное хранилище, но его легко изменить на AWS, Azure, Google или другое облачное хранилище, если хотите. Multer позаботится о загрузке составных / форм-данных и будет ожидать, что поле загрузки файла будет называться uri. Это важно помнить.

perfs-blob имеет еще одну важную особенность: он переименовывает ваши файлы и дает им уникальные имена. По умолчанию именем файла является его значение SHA512 и суффикс файла mime (например, jpeg, png и т. Д.). Это удобно, поскольку идентичные файлы будут сохранены в хранилище файлов только один раз. С другой стороны, вы теряете имя файла - если вы не можете смириться с этим, пакет предлагает варианты, чтобы изменить это поведение.

После того, как вы создали сервис, вы можете начать загружать файлы, используя curl или что угодно. Подождите, не совсем так. Вы должны указать Express, чтобы он действительно принимал большие объемы данных. Итак, в вашем app.js вам нужно изменить / добавить следующие строки:

Это позволит вам загружать действительно большие файлы.

Хуки - ядро ​​приложения

Теперь давайте создадим три перехватчика в / src / hooks, чтобы структура папок выглядела как на изображении выше. Все три перехватчика будут вызваны после сохранения набора данных. Таким образом, c ontext.result будет содержать сохраненные данные вашего BlogEntry или какой-либо другой модели.

create-reference.js будет вызываться при загрузке файла:

Уловка довольно проста - проверьте, был ли загружен файл и создан ли идентификатор (хеш-имя SHA512 + суффикс файла), если да, то создайте пустую запись. На этом этапе ссылка на наборы данных создаваться не будет.

Мы сделаем это во втором хуке под названием connect-links.js:

Этот файл намного сложнее и требует пояснений. Перехватчики принимают ряд аргументов, хранящихся в массиве fields (строка 9). Здесь вы можете определить, какие поля вашей модели будут содержать ссылки на файлы - в нашем BlogEntry это будут «изображения» и «вложения». Строки 21–36 проверяют эти поля и объединяют все идентификаторы ссылок на файлы, найденные в сохраненной модели в массиве connectedIds. Код проверит, является ли поле простой строкой или массивом. В последнем случае он будет брать от него идентификаторы. Следовательно, это не проблема, если ваша модель содержит одно или несколько полей со ссылками на файлы и если эти поля содержат только одну ссылку или список.

Найдя все идентификаторы, давайте подключим их. Это делается в строках 38–46, и это демонстрирует красоту MongoDB: метод updateOne в сочетании с параметром upsert либо создаст, либо обновит ссылку на файл, $ addToSet добавит идентификатор сохраненного набора данных в список ссылок, только если он еще не существует. Таким образом, всего за одну операцию с базой данных мы можем присоединить сохраненные данные к нашему BLOB-объекту.

Теперь все становится немного сложнее. Мы связали наши данные с большими двоичными объектами, что, если мы заменим файлы во фронтенте, отбросив старые капли? Строки 48–59 позаботятся об этих случаях. Сначала мы ищем все ссылки, которые связаны с нашими сохраненными данными, но не являются частью connectedIds. Очевидно, что эти файлы были отсоединены, поэтому мы обновляем эти случаи, используя оператор обновления $ pull, чтобы удалить идентификатор сохраненного набора данных из списка. Опять же, элегантное решение в MongoDB.

Наконец, мы хотим удалить устаревшие сиротские ссылки и капли, строки 61–83. Под устаревшим я имею в виду слишком старый - в нашем случае это будет 5 минут (строка 64). В производственных средах вы можете захотеть изменить это число на большее, потому что большие двоичные объекты могут быть загружены через connect-links.js выше и сидеть там некоторое время, прежде чем набор данных будет сохранен пользователем. (отправив форму, например, сохранив наш новый BlogEntry). Строка 78 сначала удалит ссылочную запись, строка 81 удалит фактический BLOB-объект (с помощью службы, созданной выше, что опять же, довольно просто). Конечно, в загруженной производственной среде вы можете захотеть вызвать этот последний фрагмент кода асинхронно - эй, это всего лишь пример.

Последний созданный нами хук обрабатывает удаление наборов данных и очищает ссылки удаленной записи (disconnect-links.js):

Это похоже на функцию, описанную выше, поэтому я пропущу здесь более подробные объяснения. Подобно подключению, мы сначала проверим ссылки на удаленную запись (строки 15–22, в перьях, это будет единственная информация, которую мы оставили в хуке after). Затем мы извлечем идентификатор из этих ссылок (строки 24–28) и в конце удалим бесхозные капли (строки 30–50). В отличие от connect, мы не будем искать устаревшие ссылки, а будем искать те, которые пусты после предыдущего обновления - если вы действительно хотите быть уверены, что ни один файл не будет удален, пока он загружен в течение тех же нескольких микросекунд, вы можете удалить только устаревшие сироты здесь и позвольте connect или вашему асинхронному заданию обрабатывать фактическое удаление файлов.

Добавление хуков в приложение

Мы добавили хуки, теперь давайте добавим их в наше приложение. Сначала мы добавим ловушку create в службу загрузки uploads.hooks.js:

В строке 33 вы можете увидеть ловушку после создания, в которую мы добавили ловушку из create-reference.js. Таким образом, каждый раз, когда файл загружается, создается пустая ссылка на файл. Это важно, потому что теперь мы можем отслеживать все загруженные большие двоичные объекты, даже те, которые никогда не подключены. Мы хотим, в конце концов, избавиться от несвежих сирот, помните?

Более того, в коде ловушки мы отключили все функции загрузки для внешних, кроме create - единственной конечной точки, которая здесь имеет смысл. Вы также можете проверить, вошел ли пользователь в систему, но в нашем примере мы просто разрешим всем загружать данные - только не делайте этого в производственной среде, верно? Не нужно заполнять диск вашего драгоценного сервера, будет закачивать спам…

Следующий зацеп более интересен:

В blog-entry.hooks.js мы добавим обработчик connectReference after для всех остальных действий, которые создают или обновляют наш BlogEntry (строки 19–21), а disconnectReferences, который нужно удалить (строка 22). Вызовы connectReference передают два параметра, чтобы уведомить ловушку о том, какие поля могут содержать ссылки на большие двоичные объекты. Опять же, это довольно просто.

Наконец, мы закроем нашу службу ссылок на файлы для посторонних глаз, добавив внешний запрет для хуков all before:

Наша служба завершена, и вы можете опробовать ее, запустив сервер разработки с помощью npm start dev, как и другие серверы FeathersJS (см. Руководства по FeatherJS для получения дополнительной информации). Если у вас нет запущенного локального сервера MongoDB, вы можете использовать Docker и docker-compose для его запуска - см. Пример проекта на Github.

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

Наша основная серверная служба готова! Давайте проверим это в браузере. Пример на Github содержит простое приложение Vue, которое будет подключаться к локальному серверу. Он использует облегченный фреймворк Buefy / Bulma, чтобы он выглядел красиво, и FeathersVuex для соединения Vue с FeathersJS через диспетчер состояний Vuex.

Вы можете запустить его, перейдя в каталог fe, запустив npm install и npm run serve и указав в браузере http: // localhost: 8080 /. Откроется очень простое приложение, позволяющее просмотреть список записей в блоге:

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

Посмотрев на командную строку службы FeathersJS, вы увидите записи журнала, в которых рассказывается, что происходит с вашими большими двоичными объектами. По умолчанию ваши BLOB-объекты будут находиться в папке public / blobs вашего приложения FeathersJS, поэтому вы также можете следить за изменениями в этой папке.

Удачного кодирования!