23. Збереження файлів для web. Найкращий варіант роботи із зображенням на backend. Thumbor. S3
Відео версія
(YouTube - для зворотнього зв'язку)
TL;DR
Декілька порад щодо збереження файлів:
НІКОЛИ не зберігайте користувацькі файли на власному сервері. Використовуйте S3 сервіси. Це дешевше, зменшує навантаження на сервер, і багато S3 сервісів мають CDN з серверами по всьому світу.
S3 — це не обов'язково Amazon. Обирайте будь-який сервіс, головне, щоб він підтримував S3-compatible API. Це API підтримується більшістю бібліотек і дозволяє легко мігрувати. Всі "aws" налаштування в пакеті заміняються на будь-який S3.
S3 можна розгорнути на власних потужностях... Якщо треба.
Використовуйте S3 на stage сервері також!
Для локальної розробки - локальне сховище.
Користувачі люблять завантажувати одні й ті ж файли багато разів. Це збільшує необхідне сховище, а у випадку із зображеннями - заважає кешуванню. Цьому можна запобігти, зберігаючи унікальний ідентифікатор файлу і не завантажуючи файл повторно. Наприклад, за допомогою функції
hash('sha512', $file->getContent());
Створіть мінімальну кількість API методів для збереження файлів. А для збереження використовуйте ідентифікатор файлу. Це дуже спростить збереження файлів і тестування функціоналу!
Зображення на frontend
Гарна практика - показувати оптимізовані зображення під браузер та роздільну здатність екрана користувача.
Для цього існує тег picture
. Основні правила роботи з ним:
source
спрацьовують в порядку опису. Завантажується перше дозволене зображення зверху вниз.type
- mime type зображення, яке підтримується браузером. На даний час рекомендований пріоритет від найлегших до найважчих -,avif
webp
,jpeg
media
- власне media query, в абсолютній більшості випадків нас хвилює самеwidth
.img
- обов'язковий, туди пишемо оригінальне зображення,alt
, і рекомендується оригінал зображення, яке відкривається для детального перегляду (якщо потрібно).avif
формат об'єктивно найменший, але вимагає багато ресурсів для декодування на стороні клієнта, що є великою проблемою для мобільних пристроїв. Тому його часто ігнорують.
<picture>
<!-- WebP format for better performance and quality -->
<source srcset="images/example-mobile.webp" media="(max-width: 600px)" type="image/webp">
<source srcset="images/example-tablet.webp" media="(max-width: 1024px)" type="image/webp">
<source srcset="images/example-desktop.webp" media="(min-width: 1025px)" type="image/webp">
<!-- Fallback to JPG format -->
<source srcset="images/example-mobile.jpg" media="(max-width: 600px)" type="image/jpeg">
<source srcset="images/example-tablet.jpg" media="(max-width: 1024px)" type="image/jpeg">
<source srcset="images/example-desktop.jpg" media="(min-width: 1025px)" type="image/jpeg">
<!-- Fallback image for browsers that do not support <picture> -->
<img src="images/original.png" alt="Description of the image">
</picture>
У випадку зі статичними зображеннями, ресайз та формати зображення бере на себе фронтенд. У випадку із зображеннями з бекенду - вимагайте від бекенд розробників URL для зображень для різних розширень.
Resize/Optimize images on backend
Ресайз зображень залежить від типу проєкту. Для багатьох випадків можна ресайзити зображення при збереженні, головне - не забувайте зберегти оригінал.
Але найкращим варіантом буде збереження оригіналу та ресайз і зміна формату зображення за запитом. Існує багато сервісів, які це реалізують - thumbor, imgproxy, picfit, imaginary та інші.
Декілька порад щодо вибору:
- Обов'язковий захист від
URL Tampering
- Обов'язкова можливість роботи з S3 сховищем.
- Опціонально кеш результатів, бажано до S3. Хоча більшість рішень декларують, що вони дуже швидкі і не потребують кешування, краще трохи збільшити дешеве S3 сховище, ніж витрачати серверні потужності при великому навантаженні.
- І звісно, звертайте увагу на активність git та знайдіть бенчмарки для порівняння.
Мій вибір - thumbor. І готова docker збірка з підтримкою S3.
Конфігурація для локальної розробки виглядає так:
thumbor:
image: beeyev/thumbor-s3:7.7-slim-alpine
restart: unless-stopped
container_name: bh-thumbor
environment:
- 'ALLOW_UNSAFE_URL=False'
- 'SECURITY_KEY=${THUMBOR_SECURITY_KEY}'
- 'USE_GIFSICLE_ENGINE=True'
- 'AUTO_WEBP=True'
- 'AUTO_JPG=True'
ports:
- 8888:8888
volumes:
- thumbor_data:/data
networks:
- bh-thumbor-network
healthcheck:
test: curl -s http://localhost:8888 >/dev/null || exit 1
interval: 3s
timeout: 10s
retries: 3
volumes:
thumbor_data:
driver: local
networks:
bh-thumbor-network:
driver: bridge
name: bh-thumbor-network
Зверніть увагу на:
ALLOW_UNSAFE_URL=False
таSECURITY_KEY=${THUMBOR_SECURITY_KEY}
- завжди шифруйте URL. Зловмисникам нічого не коштує запустити скрипт з перебором широти від 1 до 100000, та довготи, і за секунди заповнити ваше сховище кешу мільярдами зображень. Почитати про захист можна тут. Для PHP є зручний пакет.USE_GIFSICLE_ENGINE
- gif зображення унікальні. Якщо вони в коментарях, їх формат не треба змінювати, вони повинні "програватися" завжди. А ось для аватарок анімація порушить дизайн-код, тому раджу брати лише перший кадр в таких випадках. Для цього існує фільтр coverAUTO_WEBP
,AUTO_JPG
- автоматична конвертація доwebp
таjpg
приAccept
хедері зimage/jpg
,image/webp
відповідно. Цей хедер генерується браузером автоматично. Це дозволить забути проtype
в HTML тегуpicture
і генерувати URL лише для media.bh-thumbor-network
- для локальної розробки сервіс повинен мати доступ до зображень, які віддаєnginx
, тобто бути з ним в одній мережі.
Не забувайте про конфігурацію:
WEBSERVER_URL=http://bh-webserver
THUMBOR_SERVER_URL=http://localhost:8888
THUMBOR_SECURITY_KEY=3333
THUMBOR_FILESYSTEM_DISK='thumbor'
FILESYSTEM_DISK=public
І додаю новий disc до storage. Використовую його для доступу thumbor до nginx контейнеру. В продакшені він буде S3.
[
'disks' => [
'thumbor' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('WEBSERVER_URL').'/storage',
'visibility' => 'public',
'throw' => false,
],
]
]
І невеличкий сервіс, який генерує зручний для фронтенду source
.
<?php
namespace Modules\Attachment\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Modules\Attachment\Enums\ScreenSize;
use Modules\Attachment\Models\Attachment;
class ImageResizer
{
private Collection $sources;
private string $src;
public function __construct(
Attachment $image,
private readonly bool $extractCover = false,
) {
$this->sources = collect();
$disk = config('thumbor.filesystems_disk');
$this->src = Storage::disk($disk)->url($image->path);
}
public function addSource(
int $width,
?ScreenSize $media = null
): self {
$source = compact('width', 'media');
$this->sources->push((object) $source);
return $this;
}
public function getSources(): Collection
{
return $this->sources->map(function ($source) {
$srcset = \Thumbor::resizeOrFit($source->width);
if ($this->extractCover) {
$srcset->addFilter('cover');
}
return (object) [
'media' => $source->media,
'srcset' => $srcset->get($this->src),
];
});
}
}
Тут треба врахувати - png
зображення можуть бути прозорими. Автоматична зміна їх фону може порушити дизайн.
Тому я генерую декілька наборів source
по набору для кожної теми з використанням фільтра background_color
Сервіс викликається в ресурсах за потреби. Якщо один і той самий тип зображення у вас має декілька варіацій - це різні ресурсні файли!
<?php
namespace Modules\Attachment\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Modules\Attachment\Enums\ScreenSize;
use Modules\Attachment\Models\Attachment;
use Modules\Attachment\Services\ImageResizer;
use OpenAPI\Properties\PropertyResourceCollection;
use OpenAPI\Properties\PropertyString;
use OpenAPI\Schemas\BaseScheme;
#[BaseScheme(
resource: AvatarResource::class,
properties: [
new PropertyString('src'),
new PropertyResourceCollection(
property: 'sources',
resource: ImageSourceResource::class,
),
]
)]
/**
* @mixin Attachment
*/
class AvatarResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'src' => Storage::url($this->path),
'sources' => ImageSourceResource::collection($this->getSources()),
];
}
/**
* @infection-ignore-all
*/
private function getSources(): Collection
{
$resizer = new ImageResizer(
image: $this->resource,
extractCover: true
);
$mobileSize = 36;
$tabletUpSize = 45;
$resizer
->addSource(
width: $mobileSize,
media: ScreenSize::Mobile
)
->addSource(
width: $tabletUpSize,
media: ScreenSize::TabletUp
);
return $resizer->getSources();
}
}