Laravel Storage Dedupler Максима Хімусова зберігає завантажені файли без дублікатів: файл ідентифікується SHA-1 хешем свого вмісту, і скільки б разів його не завантажили, на диску лежить одна копія, а моделі отримують посилання на неї. Для застосунків, де користувачі вдесяте прикріплюють той самий договір, рахунок чи логотип, це пряма економія місця, часу бекапів і грошей на S3.
Пакет нещодавно оглянув Laravel News, а за кілька днів після огляду автор випустив версію 1.0.6, яка зробила налаштовуваною найспірнішу поведінку - видалення файлу після останнього відв'язування.
Як це працює
Усередині пакета дві таблиці. dedupler_unique_files зберігає по одному запису UniqueFile на кожен унікальний вміст, і первинний ключ цього запису - сам SHA-1 хеш. Друга - поліморфна проміжна таблиця, що пов'язує файл із будь-якими моделями та пам'ятає оригінальне ім'я файлу для кожного зв'язку.
Перед записом пакет рахує хеш і шукає UniqueFile з таким ключем. Знайшов - повертає наявний запис і за потреби лише додає зв'язок із моделлю. Не знайшов - кладе файл на диск і створює запис. Файли до 1 МБ читаються в пам'ять повністю, більші хешуються потоком шматками по 1 МБ. На диску файли розкладаються за першими символами хешу: da/39/da39a3ee....pdf.
Встановлення - дві команди. Друга створює міграції й перевіряє конфігурацію:
composer require maxkhim/laravel-storage-dedupler
php artisan dedupler:install
За замовчуванням файли лягають на окремий локальний диск deduplicated у storage/app/deduplicated. Диск змінюється через DEDUPLER_DISK, а таблиці можна винести в окреме з'єднання через DEDUPLER_CONNECTION.
Фасад Dedupler
Найпростіший спосіб - фасад. Три методи для трьох джерел: завантажений файл, файл на сервері за абсолютним шляхом і сирий вміст з іменем:
$uniqueFile = Dedupler::storeFromUploadedFile($file);
$uniqueFile = Dedupler::storeFromPath($absolutePathToFile);
$uniqueFile = Dedupler::storeFromContent($content, 'direct_content_file.ext');
Є ще storeFromStream() для потоків і storeBatch() для масиву файлів.
Важлива деталь: усі ці методи повертають ?UniqueFile. Якщо джерело некоректне або запис на диск чи в базу впав, пакет пише помилку в лог і повертає null, а не кидає виняток. Тож результат треба перевіряти явно, інакше наступне $uniqueFile->id впаде вже з незрозумілою помилкою.
Трейт Deduplable для моделей
Щоб файли належали моделям, додайте трейт:
use Illuminate\Database\Eloquent\Model;
use Maxkhim\Dedupler\Traits\Deduplable;
class Post extends Model
{
use Deduplable;
}
Трейт дає ті самі методи, але вже з прив'язкою до моделі: storeUploadedFile(), storeLocalFile(), storeContentFile(). Повторне завантаження того самого вмісту поверне той самий запис:
$file1 = $post->storeUploadedFile($sameFile);
$file2 = $post->storeUploadedFile($sameFile);
$file1->id === $file2->id; // true
Файл, який уже є в системі, можна прикріпити до іншої моделі за хешем через attachUniqueFile($sha1Hash), без повторного завантаження. Перевірити зв'язок допоможе hasUniqueFile(), а всі зв'язки моделі доступні через uniqueFileUploads(). Модель має бути збережена: без id прив'язувати файл нема до чого.
Що буде з файлом після останнього detach
Окремого лічильника посилань пакет не тримає - він рахує рядки в проміжній таблиці. $post->detachUniqueFile($sha1Hash) видаляє зв'язок і перевіряє, чи лишилися інші. Якщо ні, за замовчуванням видаляються і фізичний файл, і запис UniqueFile. Поки файл потрібен хоч одній моделі, він лишається на місці.
Саме на цю поведінку поскаржилися в issue #9: для проєктів з аудитом, ревізіями чи архівом замовлень жорстке видалення - ризик. У версії 1.0.6 його можна вимкнути:
DEDUPLER_REMOVE_ORPHAN_ON_DETACH=false
Тоді файли без зв'язків лишаються, а прибирати їх можна вручну командою dedupler:prune. Вона ж чистить зв'язки, що вказують на вже видалені моделі: видалення моделі само по собі зв'язки не знімає. Спершу - завжди з --dry-run:
php artisan dedupler:prune --orphan-relations --orphan-files --dry-run
Обмеження, про які варто знати
- Звичайний
Storage не змінюється. Дедуплікація працює лише для файлів, збережених через Dedupler. Storage::put() і $request->file()->store() пишуть, як і раніше.
- Старі дублікати лишаються. Пакет не чіпає того, що вже лежить на диску. Команда
dedupler:analyse-legacy <шлях> лише рахує дублікати в теці та потенційну економію - це корисно, щоб оцінити, чи варто взагалі заводити пакет.
- SHA-1. Для звичайних завантажень випадковий збіг хешів практично неможливий. Навмисні колізії SHA-1 продемонстровані ще у 2017 році, тож теоретично зловмисник може підготувати два різні файли з однаковим хешем, і пакет вважатиме їх одним. Для більшості застосунків це не проблема, але для систем, де вміст файлу є доказом, варто пам'ятати.
- Паралельні завантаження. З коду видно, що пошук і створення запису не загорнуті в транзакцію чи блокування. Якщо два запити одночасно вантажать той самий новий файл, другий може впасти на унікальному ключі - і ви отримаєте
null.
- Зв'язок
uniqueFiles(). У версії 1.0.6 він будується лише за deduplable_id, без типу моделі. Якщо трейт мають дві моделі з однаковими id, списки файлів можуть перемішатися. Надійніше йти через uniqueFileUploads(), де поліморфний зв'язок враховує тип.
- Пакет молодий. Близько 54 зірок, версія 1.0.6, ліцензія MIT. Потребує PHP 8.2+ і підтримує Laravel 10, 11, 12 і 13.
Чи варто брати
Якщо у вас є застосунок, де ті самі документи чи картинки вантажать багато разів - CRM, документообіг, маркетплейс із типовими фото, - Dedupler розв'язує проблему без власної інфраструктури: дві таблиці й окремий диск. Почніть з dedupler:analyse-legacy на копії наявного сховища: цифра потенційної економії швидко покаже, чи є сенс.
Перед продакшеном вирішіть, чи підходить вам автовидалення після останнього detach, перевіряйте null після кожного збереження і подумайте про паралельні завантаження. Код пакета невеликий, і прочитати його цілком - найкращий спосіб зрозуміти, на що ви погоджуєтеся.