almdev Разработка с ИИ

Хранилище файлов за интерфейсом: локальный диск и S3
Один контракт, две реализации и место, где абстракция всё-таки протекает.

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

Подкатегория: Бэкенд

Чтение
6 мин
Технологии / версии
Symfony · Архитектура · S3 · Файлы
16 сен 2026 · 6 мин · 24 просмотра · Архив
Единый интерфейс для локального и удалённого хранилища
Бэкенд 09/2026

Сертификат о прохождении курса — PDF, который генерируется в фоне и потом скачивается сотрудником. У клиента в закрытом контуре он должен лежать на диске. В облаке — в объектном хранилище, потому что дисков там несколько и они эфемерные.

Первая версия знала про диск. Знала в шести местах: генератор, контроллер скачивания, команда очистки, тест, шаблон письма и сидер. Переделка на два хранилища начиналась именно с поиска этих шести мест.

Что кладём в контракт

Интерфейс получился короче, чем я планировал:

interface CertificateStorageBackendInterface
{
    public function put(string $key, string $contents): void;

    public function get(string $key): string;

    public function exists(string $key): bool;

    public function delete(string $key): void;

    public function urlFor(string $key, \DateInterval $ttl): string;
}

Пять методов, и каждый прошёл отбор.

Чего в контракте нет и не будет — всего, что описывает устройство конкретного хранилища. Никаких путей файловой системы, никаких имён корзин, никаких заголовков. $key — это certificates/2026/03/018f2a....pdf, строка, которую обе реализации понимают по-своему.

Отдельно про urlFor(). Долго думал, стоит ли пускать в контракт метод, которого у локального диска в естественном виде нет. Решил, что стоит: потребность «дай ссылку, по которой сотрудник скачает файл» есть в обоих режимах, и лучше явный метод, чем условие в контроллере.

Чего я не стал добавлять, хотя хотелось: list(). Перечисление содержимого нужно ровно одной консольной команде очистки, и ради неё тащить в контракт метод, который в объектном хранилище работает пагинацией с курсором, а на диске — рекурсивным обходом, — плохая сделка. Команда очистки ходит в базу, а не в хранилище.

Две реализации

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

final readonly class LocalCertificateStorage implements CertificateStorageBackendInterface
{
    public function __construct(
        private string $basePath,
        private UrlSignerInterface $signer,
    ) {}

    public function put(string $key, string $contents): void
    {
        $path = $this->path($key);
        $dir = \dirname($path);

        if (!is_dir($dir) && !mkdir($dir, 0o775, true) && !is_dir($dir)) {
            throw new StorageException("Не удалось создать каталог: {$dir}");
        }

        if (file_put_contents($path, $contents, LOCK_EX) === false) {
            throw new StorageException("Не удалось записать файл: {$path}");
        }
    }

    public function urlFor(string $key, \DateInterval $ttl): string
    {
        // подписанный маршрут приложения, файлы лежат вне public/
        return $this->signer->sign('certificate_download', ['key' => $key], $ttl);
    }
}

Двойная проверка is_dir после mkdir — не паранойя, а защита от гонки: два воркера создают один каталог одновременно, один получает false и совершенно рабочий каталог.

Метод path() обязан отвергать абсолютные пути, .. и нулевой байт, а после нормализации проверять, что результат остался внутри $basePath. Сейчас ключи создаёт приложение, но контракт не должен превращать будущую ошибку в запись за пределами каталога сертификатов.

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

Реализация для объектного хранилища тонкая — клиент делает почти всё сам. Ниже фрагмент фабрики:

final class CertificateS3ClientFactory
{
    public function create(): S3Client
    {
        return new S3Client([
            'version' => 'latest',
            'region' => $this->region,
            'endpoint' => $this->endpoint,
            'use_path_style_endpoint' => $this->pathStyle,
            'credentials' => ['key' => $this->key, 'secret' => $this->secret],
        ]);
    }
}

use_path_style_endpoint — параметр, из-за которого я потерял полдня. С отечественными и self-hosted совместимыми хранилищами адресация через поддомен корзины часто не работает, а сообщение об ошибке про это не говорит ничего внятного.

Сама фабрика не делает клиент ленивым. Чтобы он создавался при первом обращении, сервис клиента надо явно объявить lazy в контейнере:

App\Storage\CertificateS3Client:
    class: Aws\S3\S3Client
    factory: ['@App\Storage\CertificateS3ClientFactory', 'create']
    lazy: true

В коробочном режиме S3-клиент вообще не должен инициализироваться. Без lazy одна фабрика этого не гарантирует.

Выбор реализации

Никаких условий в прикладном коде — только конфигурация конкретной поставки. Динамический alias через %env()% здесь не подходит: контейнер разрешает alias при компиляции. Поэтому в коробочную и облачную сборки попадают разные небольшие файлы. Это фрагменты двух конфигураций:

# config/services/storage_local.yaml
services:
    App\Storage\LocalCertificateStorage:
        arguments:
            $basePath: '%kernel.project_dir%/var/certificates'

    App\Storage\CertificateStorageBackendInterface:
        alias: App\Storage\LocalCertificateStorage
# config/services/storage_cloud.yaml
services:
    App\Storage\CertificateStorageBackendInterface:
        alias: App\Storage\S3CertificateStorage

Нужный файл импортируется при сборке поставки. Если реализацию требуется выбирать во время выполнения, вместо alias нужна фабрика с service locator.

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

Кто отвечает за сирот

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

Я пробовал два подхода.

Первый — удалять файл в тот же момент, что и сущность, через события жизненного цикла Doctrine. Не сработало: событие срабатывает внутри транзакции, транзакция откатывается, файл уже удалён. Обратно его не вернуть.

Второй — двухфазное удаление, и на нём я остановился. Сущность помечается удалённой, файл не трогается. Раз в сутки команда проходит по помеченным старше семи дней и убирает файлы:

foreach ($this->certificates->findDeletedBefore($cutoff) as $certificate) {
    $this->storage->delete($certificate->getStorageKey());
    $this->em->remove($certificate);
}

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

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

Тесты

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

final class InMemoryCertificateStorage implements CertificateStorageBackendInterface
{
    private array $files = [];

    public function put(string $key, string $contents): void
    {
        $this->files[$key] = $contents;
    }

    public function get(string $key): string
    {
        return $this->files[$key] ?? throw new StorageException("Нет файла: {$key}");
    }
}

Двадцать строк, ничего не оставляет после себя. Тест проверяет, что после генерации сертификата в хранилище появился ключ нужного вида и что содержимое начинается с сигнатуры PDF.

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

Где абстракция протекает

Обещание «одинаково для обеих реализаций» держится ровно до больших файлов.

Метод put(string $key, string $contents) принимает содержимое строкой. Для сертификата на 200 килобайт это нормально. Для видеозаписи вебинара на 700 мегабайт это 700 мегабайт в памяти PHP-процесса, и лимит памяти заканчивается раньше, чем загрузка.

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

Я оставил как есть и записал ограничение прямо в докблок интерфейса: «для файлов до 10 МБ». Крупные вложения в этом проекте загружаются другим путём — напрямую в хранилище по предподписанной ссылке, минуя PHP вообще. Это честнее, чем делать вид, что одна абстракция закрывает оба сценария.

Что вынес

Абстракция окупилась, но не там, где я ожидал. Я думал, что выигрыш — в переносимости между клиентами. На деле главным выигрышем оказались тесты: реализация в памяти убрала из функционального прогона зависимость от диска и очистку временных файлов.

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

#symfony #arhitektura #s3 #fayly