Хранение и раздача файлов через Ensi Storage
Файлы в платформе делятся на 2 вида: публичные и приватные. Все они хранятся на физическом диске Ensi Storage, который доступен в каждом сервисе в директории storage/ensi.
Структура этого диска:
.
├── public
│ ├── domain_1
│ │ └── hash
│ │ └── filename.png
│ └── domain_2
└── protected
├── domain_1
└── domain_2
При использовании [Ensi Local Ctl] можно увидеть содержимое диска в ensi-local-ctl/data/es/data
Для скачивания файлов существует 3 способа:
- Домен https://es-public.project.ru - раздаёт файлы директории public. Доступен всем в интернете. При необходимости эту ссылку можно передать в imgproxy и получить обработанную картинку
- Домен https://es-protected.project.ru - раздаёт файлы директории protected. Доступен только внутри защищенной среды, например под vpn
- Эдпоинт раздачи приватных файлов на витрины/админки (Подробнее ниже)
При использовании [Ensi Local Ctl] домены подниманиюся с помощью ensi global start es
Диски в сервисе
Нужны одни и те же логические диски — независимо от языка:
- Диски для работы с файлами текущего домена — загрузка и ссылки для раздачи
ensi_{domain}_public— папкаpublic/domain/ensi_{domain}_protected— папкаprotected/domain/
- Диск для чтения файлов чужих доменов
ensi— корень физического диска
Laravel. Пакет laravel-ensi-filesystem регистрирует эти диски через EnsiStorageConfig::addDisk(...) в config/filesystems.php. Код текущего домена задаётся в config/ensi-filesystem.php (default_domain_code).
Go (Goravel). Отдельного пакета пока нет: те же диски описывают вручную в config/filesystems.go. Локально корень обычно storage/ensi, в кластере том часто смонтирован в /var/data. Пример для домена catalog:
"disks": map[string]any{
"ensi_catalog_public": map[string]any{
"driver": "local",
"root": path.Storage("ensi/public/catalog"),
"url": config.Env("ENSI_PUBLIC_DISK_URL", "").(string) + "/catalog",
},
"ensi_catalog_protected": map[string]any{
"driver": "local",
"root": path.Storage("ensi/protected/catalog"),
"url": config.Env("ENSI_PROTECTED_DISK_URL", "").(string) + "/catalog",
},
"ensi": map[string]any{
"driver": "local",
"root": path.Storage("ensi"),
},
// local / public — как в шаблоне Goravel
},
Имена дисков и раскладка каталогов должны совпадать с PHP-сервисами того же домена, иначе пути в БД и URL разъедутся.
Как загружать файлы
Для загрузки файла создаётся отдельный эндпоинт, например /module/entity/{id}:upload-file
Принимает запрос в формате multipart/form-data. Ниже — типичный Action: случайное имя, хеш-поддиректории из md5 имени файла (ab/3f), запись на public-диск домена, в БД — только относительный путь.
Laravel
use Ensi\LaravelEnsiFilesystem\EnsiFilesystemManager;
class SaveFileAction
{
public function __construct(protected EnsiFilesystemManager $fileManager)
{
}
public function execute(int $modelId, UploadedFile $file): Model
{
/** @var Model $model */
$model = Model::findOrFail($customerId);
$hash = Str::random(20);
$extension = $file->getClientOriginalExtension();
$fileName = "{$modelId}_{$hash}.{$extension}";
$hashedSubDirs = $this->fileManager->getHashedDirsForFileName($fileName);
$disk = Storage::disk($this->fileManager->publicDiskName());
$path = $disk->putFileAs("model/{$hashedSubDirs}", $file, $fileName);
if (!$path) {
throw new RuntimeException("Unable to save file $fileName to directory model/{$hashedSubDirs}");
}
if ($model->file) {
$disk->delete($model->file);
}
$model->file = $path;
$model->save();
return $model;
}
}
Go (Goravel)
Файл из multipart берут через ctx.Request().File(...) (тип filesystem.File) и передают в action. Хеш-поддиректории считают так же, как в PHP (md5 от имени → первые 4 hex-символа как xx/yy):
func hashedDirsForFileName(fileName string) string {
sum := md5.Sum([]byte(fileName))
h := hex.EncodeToString(sum[:])
return h[0:2] + "/" + h[2:4]
}
type SaveFileAction struct{}
func (a *SaveFileAction) Execute(ctx context.Context, modelID uint, file filesystem.File) (*db.Model, error) {
model := &db.Model{}
if err := facades.Orm().WithContext(ctx).Query().FindOrFail(model, modelID); err != nil {
return nil, err
}
ext := file.GetClientOriginalExtension()
fileName := fmt.Sprintf("%d_%s.%s", modelID, str.Random(20), ext)
hashedSubDirs := hashedDirsForFileName(fileName)
dir := "model/" + hashedSubDirs
disk := facades.Storage().Disk("ensi_catalog_public")
path, err := disk.PutFileAs(dir, file, fileName)
if err != nil || path == "" {
return nil, fmt.Errorf("unable to save file %s to directory %s: %w", fileName, dir, err)
}
if model.File != nil && *model.File != "" {
_ = disk.Delete(*model.File)
}
model.File = &path
if err := facades.Orm().WithContext(ctx).Query().Save(model); err != nil {
return nil, err
}
return model, nil
}
Удаление файлов происходит по аналогии (disk.Delete / $disk->delete).
Какие данные о файлах хранить в БД
В БД хранится только путь до файла
Какие данные о файле сервис отдаёт
В ответе API обычно отдают объект с полями path, root_path и url.
Laravel. Класс \Ensi\LaravelEnsiFilesystem\Models\EnsiFile собирает их из пути: EnsiFile::public($model->file). В BaseJsonResource есть хелперы вроде $this->mapPublicFileToResponse($this->file).
Go. Тот же JSON удобно собрать маленькой структурой: path — как в БД, root_path — с префиксом public/{domain}/ или protected/{domain}/, url — через facades.Storage().Disk(...).Url(path) (URL диска из ENSI_PUBLIC_DISK_URL / ENSI_PROTECTED_DISK_URL):
type EnsiFile struct {
Path string `json:"path"`
RootPath string `json:"root_path"`
URL string `json:"url"`
}
func PublicEnsiFile(path string) EnsiFile {
return EnsiFile{
Path: path,
RootPath: "public/catalog/" + strings.TrimLeft(path, "/"),
URL: facades.Storage().Disk("ensi_catalog_public").Url(path),
}
}
Как раздать приватный файл пользователю
Для раздачи файла требуется, чтобы пользователь прошел аутентификацию и можно было проверить доступ к запрашиваемому файлу.
Для этого в gui-backend реализуется эндпоинт, который получает данные о запрашиваевом файле, проверяет доступ пользователя, и отдаёт файл, если проверка пройдена
Данные о файле также генерирует предварительно gui-backend. Состоят они из следующих полей:
- entity - сущность, файл которой мы пытаемся скачать, например pim/category
- entity_id - конкретная сущность, файл которой мы пытаемся скачать, например ID категории
- file_type - если у сущности несколько видов файлов, то тут указывается типа (по сути это обозначение поля в БД), например preview
- file - если файлов одного типа много, то можно указать path|id на конкретный файл
Пример реализации можно посмотреть тут