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 tag
<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 та інші.

Декілька порад щодо вибору:

  1. Обов'язковий захист від URL Tampering
  2. Обов'язкова можливість роботи з S3 сховищем.
  3. Опціонально кеш результатів, бажано до S3. Хоча більшість рішень декларують, що вони дуже швидкі і не потребують кешування, краще трохи збільшити дешеве S3 сховище, ніж витрачати серверні потужності при великому навантаженні.
  4. І звісно, звертайте увагу на активність git та знайдіть бенчмарки для порівняння.

Мій вибір - thumbor. І готова docker збірка з підтримкою S3.

Конфігурація для локальної розробки виглядає так:

docker-compose.yml
  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

Зверніть увагу на:

  1. ALLOW_UNSAFE_URL=False та SECURITY_KEY=${THUMBOR_SECURITY_KEY} - завжди шифруйте URL. Зловмисникам нічого не коштує запустити скрипт з перебором широти від 1 до 100000, та довготи, і за секунди заповнити ваше сховище кешу мільярдами зображень. Почитати про захист можна тут. Для PHP є зручний пакет.
  2. USE_GIFSICLE_ENGINE - gif зображення унікальні. Якщо вони в коментарях, їх формат не треба змінювати, вони повинні "програватися" завжди. А ось для аватарок анімація порушить дизайн-код, тому раджу брати лише перший кадр в таких випадках. Для цього існує фільтр cover
  3. AUTO_WEBP, AUTO_JPG - автоматична конвертація до webp та jpg при Accept хедері з image/jpg, image/webp відповідно. Цей хедер генерується браузером автоматично. Це дозволить забути про type в HTML тегу picture і генерувати URL лише для media.
  4. bh-thumbor-network - для локальної розробки сервіс повинен мати доступ до зображень, які віддає nginx, тобто бути з ним в одній мережі.

Не забувайте про конфігурацію:

.env.example
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.

config/filesystems.php
 [
    'disks' => [
      'thumbor' => [
               'driver' => 'local',
               'root' => storage_path('app/public'),
               'url' => env('WEBSERVER_URL').'/storage',
               'visibility' => 'public',
               'throw' => false,
           ],
    ]    
]

І невеличкий сервіс, який генерує зручний для фронтенду source.

Modules/Attachment/Services/ImageResizer.php
<?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

Сервіс викликається в ресурсах за потреби. Якщо один і той самий тип зображення у вас має декілька варіацій - це різні ресурсні файли!

Modules/Attachment/Transformers/AvatarResource.php
<?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();
    }
}