Сертификат о прохождении курса — 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 процентов и требующих условий на каждой реализации.