Как уменьшить трафик к вашему сайту в 200 раз? Возможно ли такое? Иногда можно добиться подобного с помощью оптимизации скачиваемых изображений. Рассмотрим вариант, делать это преобразование "на лету" и избежать его минусов.

Если на сайте есть страница, со списком объектов, каждый из которых снабжен изобра��ением, то загрузка всех картинок такой страницы может вызвать значительную нагрузку. Типичное решение для уменьшение трафика - применить превьюшки. Их создание возможно при загрузке на сайт исходного изображения и "по запросу". Вариант, с ручным добавлением превьюшек не будем рассматривать как наиболее трудоёмкий для поддержки сайта :)

Создание превьюшек при загрузке изображения

Для получения набора заготовленых на все случаи жизни изображений можно прямо в админке обрабатывать каждое загружаемое на сайт фото.

В ситуации, когда есть банк изображений с заранее выбранным масштабом вы можете обречь посетителей своего сайта качать "лишние" мегабайты, либо затребовать такое разрешение фотографии которого у вас нет и получить ошибку 404. Ситуация усугубляется вопросом коммуникации frontend- и backend- разработчиков, например ваш api-проект, управляющий банком изображений, развивается в совершенно другом репозитории, со всеми вытекающими вопросами согласования версий одного и другого проекта. У вас может измениться дизайн сайта или появиться мобильное приложение, которому потребуется особый набор превьюшек и придётся дорабатывать скрипт для масштабирования картинок в вашей админке и проделывать эту процедуру с уже созданными картинками.

К плюсам можно отнести скорость с которой будет возвращаться затребованный браузером статичный файл.

Создание превьюшек "на лету"

Для идеальной "подгонки" размеров картинок хочется сделать так, чтобы размер изображения мог быть заказан напрямую с фронтенда на основе URL изображения. Но если файла по зад��нному адресу нет - получится 404 ошибка. Потому целесообразно сделать обработчик 404 ошибки, с помощью которого можно будет генерить превьюшку. Разумеется это возможно будет если по самому URL можно будет понять на основе какого изображения генерить и в каком размере. В этом случае вместо 404 ошибки будет возвращена сама картинка.

Масштабирование "на лету" по данным из URL
Масштабирование "на лету" по данным из URL

К минусам этого решения следует отнести высокую нагрузку необходимую на преобразование изображений. Причём - это как правило будут одни и те же фотографии в тех же разрешениях что запрашивались ранее.

Объединение сильных сторон обеих решений

Каждый раз заставлять сервер проделывать одну и ту же работу не очень хорошая идея. Идеально было бы сгенерить масштабированные фото только один раз а далее они бы возвращались как обычные статические файлы. Для этогодостаточно после генерации изображения, не только вернуть его вместо 404-ошибки но ещё и сохранить на диск под запрошеным в URL размещением.

Например, если исходный URL https://example.com/storage/product/[width_100]/2.jpg то создаётся папка [width_100]в которую, под именем 2.jpg помещается результат генерации превьюшки по соответствующему HTTP-запросу. Последующий аналогичный HTTP-запрос не вызывает обработчик 404 ошибки и возвращает статический файл. Так по мере необходимости возникнет идеально подогнанный банк изображений.

Реал��зация на Laravel

  • Потребутся готовый или вновь созданный проект на Laravel. Я использовал проект на Laravel Framework 8.83.12.

  • Для генерации картинок в PHP воспользовался библиотекой GD.

Начать можно с blade-шаблона resources/views/index.blade.php:

<ol>
  @foreach($files as $file)
  <li><a href="/card/{{$file}}">
      @php
        $src   = '/storage/product/'.$file;
      @endphp
      <img src="{{$src}}" width="60" alt=""/> {{$src}}
    </a>
  </li>
  @endforeach
</ol>

Потребуется PHP-класс для набора статических методов:

php artisan make:controller ScaledImage404Controller

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

/**
 * @param string $fname имя файла (если нужен путь к файлу)
                        Ex.: '0.jpg' по умолч пуст.строка
 * @return string Ex.: '/home/andrew/project/shop/public/storage/product/' */
public static function fsPath($fname='') {
    return public_path("storage/product/{$fname}");
}

Далее можно создать маршруты в routes/web.php:

use App\Http\Controllers\ScaledImage404Controller;

//список
Route::get('/', function () {
    //return view('welcome');
    $files=[]; //['0.jpg', '1.jpg', '2.jpg',...];
    foreach(scandir(ScaledImage404Controller::fsPath()) as $f) {
        if ( ('.' != $f{0}) && 
             (!is_dir(ScaledImage404Controller::fsPath($f)))  
        ) $files[] = $f;
    }
    return view('index', ['files'=>$files]);
});

На этом этапе при выполняющемся php artisan serve можно будет видеть список картинок из папки public/storage/product/
Например:

Пример того что увидит пользователь открыв список товаров
Пример того что увидит пользователь открыв список товаров

В этом списке используются исходные изображения. В репозитории что я прилагаю - 29 картинок суммарным объёмом 20Мб. Попробуем сжать эти изображения, чтобы ширина каждого была 100 пикселей, а высота такой чтобы при этом не пострадали пропорции. В дальнейшем суммарный объём этих изображений не превысит 90Кб.

Для написания обработчика 404-ошибки в данной ситуации понадобится статический метод который будет по указанному URL проверять:

  1. Запрашивается ли изображение .git .png .jpg (или .jpeg)?

  2. Содержит ли URL правильную папку (команду) для масштабирования картинки?

  3. Существует ли оригинал картинки?

Если всё так и есть - создать папку (команду) если её нет и вернуть:

  1. имя исходного файла;

  2. имя файла для сохранения новой картинки;

  3. тип изображения (gif, png или jpeg) для указания в "Content-type: image/$type";

  4. собственно команду ресайза типа /[width_100]/;

Вариант реализации этого метода с зависимостями в ScaledImage404Controller:

const R = '/\/(\[width_\d+\])\//';    

/** Определить параметры масшатирования, проверить источник и место назначения 
    (создать если не создана папка назначения)
 * @param string $uri Ex.: '/storage/product/[width_100]/2.jpg'
 * @return array|boolean false если не требуется resize или ошибка; массив с
    осн. данными ресайза Ex.: ['outsize'=>'[width_100]', 'type'=>'jpeg', 
     'src'=>"/home/andrew/project/shop/public/storage/product/2.jpg", 
     'dest'=>"/home/andrew/project/shop/public/storage/product/[width_100]/2.jpg"]
 */
public static function outsizeArr($uri) {
    // картинка или нет?
    $typeImage = static::imageFileTypeFromExt($uri); // 'png' | false
    if (false === $typeImage) return false;

    //содержит ли команду ресайза?
    $needResize=preg_match(static::R, $uri, $m);//$m=["/[width_100]/", "[width_100]"]
    if (!$needResize) return false;
    $outsize = $m[1]; // '[width_300]'

    // убрать outsize из $url
    $URI = str_replace($m[0], '/', $uri);  // '/storage/product/2.jpg'

    $startsWith = Str::of($URI)->startsWith($storage_uri = '/storage'); // true
    if (!$startsWith) return false;

    $file = static::uriToPath($URI); // '/home/.../public/storage/product/2.jpg'
    if (!file_exists($file)) return false;

    //имя файла
    $name = basename($file); //'2.jpg'

    //путь для файла (без ��мени)
    $pathToCreate = static::uriToPath(substr($uri, 0,-1*strlen($name))); 
    // '/home/.../public/storage/product/[width_100]/2.jpg'

    //создать новый путь для файла если его ещё нет. при неудаче - выйти
    if (
      !file_exists($pathToCreate) && !mkdir($pathToCreate, 0777, true)
    ) return false;

    return [
      'outsize'=>$outsize,
      'type'=>$typeImage,
      'src'=>$file,
      'dest'=>$pathToCreate.$name
    ];
}

/** вычислить mime-тип файла-изображения ('Content-type: image/'...)
    на основе расширения файла-изображения
 * @param string Имя файла\URI и т.п. Ex.1: '1.png' Ex.2: '/storage/product/10.jpg'
 * @return string|boolean тип  Ex.: 'png' или false если файл
           не из '*.gif', '*.jpeg', '*.jpg', '*.png' */
public static function imageFileTypeFromExt($filename) {
    $ext = strtolower(fileext($filename));
    if ($ext==".jpg") $ext = '.jpeg';
    if (in_array($ext, ['.gif', '.jpeg', '.png'])) return substr($ext, 1);
    else return false;
}

/**
 * @param string $uri Ex.: '/storage/product/2.jpg'
 * @return string Ex.: '/home/andrew/project/shop/public/storage/product/2.jpg' */
public static function uriToPath($uri='') {
    return public_path($uri);
}

и вспомогательная функция (в нём-же):

/** расширение имени файла (включая точку)
 * @param string $filename имя файла с полным путём или без него
 * @return string расширение имени, включая точку
                  или пустую строку, если нет расширения
 * Ex.1: fileext('script.js');//'.js'
 * Ex.2: fileext('www/.htaccess');//'.htaccess'
 * Ex.2: fileext('www/README');//'' */
function fileext($filename)	{
    $ext='';
    $filename=basename($filename); // оставили только имя
    if (($pos=strrpos($filename, '.'))!==false) $ext=substr($filename, $pos);
    return $ext;
}

Далее на основе ScaledImage404Controller::outsizeArr и некоторых других статических методов (которые создадим чуть позже) можно переходить к обработчику 404 ошибки в app/Exceptions/Handler.php. В метод register()добавим:

$this->renderable(function (NotFoundHttpException $e, $request) {
    if (
      is_array($a = ScaledImage404Controller::outsizeArr("/".$request->path()))
      &&
      ($imSrc = ScaledImage404Controller::getImage($a['src']))
      &&
      ($im = ScaledImage404Controller::imageResize(
        $imSrc, 
        $a['outsize'],
        $a['type']
      ))
    ) {
        $type = $a['type'];
        ob_start();
        switch($type) {
            case 'gif':
                imagegif($im);
                imagegif($im, $a['dest']);
                break;
            case 'png':
                imagepng($im);
                imagepng($im, $a['dest']);
                break;
            default:
                imagejpeg($im);
                imagejpeg($im, $a['dest']);
        }
        $buffer = ob_get_contents();
        ob_end_clean();
        imagedestroy($im);
        return response($buffer, 200)->header('Content-type', 'image/'.$type);
    }
});

Оставшиеся два метода ScaledImage404Controller::getImage и ScaledImage404Controller::imageResize:

/** Созд. img на основе имени файла */
public static function getImage($filename) {
    $img = false;
    //угадываем тип по расширению
    $type = static::imageFileTypeFromExt($filename);
    switch ($type) {
        case 'png':	$img = @imagecreatefrompng($filename); break;
        case 'gif':	$img = @imagecreatefromgif($filename); break;
        case 'jpeg':$img = @imagecreatefromjpeg($filename);break;
    }
    //не угадали - пробуем всё подряд
    if (false === $img){
        $img = @imagecreatefromgif($filename);
        if (false !== $img) return $img;
        $img = @imagecreatefrompng($filename);
        if (false !== $img) return $img;
        $img = @imagecreatefromjpeg($filename);
        if (false !== $img) return $img;
    }

    //если ничего не удалось - вернуть false
    return $img;
}

/** Ресайз изображений с сохранением пропорций
 * @param resource $im  gd image
 * @param string $outsize Строка с командой ресайза Ex.: "[width_200]"
 * @param string $type Один из типов 'jpeg'|'png'|'gif'
                       для указания в "Content-type: image/{$type}"
 * @return boolean|resource False - если ошибка или 
                            gd image если удалось перемасштабировать */
public static function imageResize($im, $outsize, $type) {
    $old_w	= imagesx($im);
    $old_h	= imagesy($im);
    $new_w = $old_w;
    $new_h = $old_h;

    if (preg_match('/\[.*_(.+)\]/', $outsize, $m)) { 
        // "[width_200]" => $m = ["[width_200]", '200']
        $new_w = intval($m[1]);
        $new_h = round(($new_w/$old_w) * $old_h); // k*old_h - расчёт нов. высоты
    }

    //создание нового изображения с новыми размерами (его и будем возвращать)
    $image = imagecreatetruecolor($new_w, $new_h);

    // подготовка альфа-канала (для форматов поддерживающих прозрачность)
    if (in_array($type, ['png', 'gif'])) {
        imagesavealpha($image, true);
        $trans_colour = imagecolorallocatealpha($image, 0, 0, 0, 127);
        imagefilledrectangle(
          $image, 0, 0, imagesx($image), imagesy($image), $trans_colour
        );
        imagealphablending($image, false);
    }

    // собственно масштабирование (копирование с растягиванием\сжатием картинки)
    // и убивание более ненужного исходника
    if (imagecopyresampled($image, $im, 0, 0, 0, 0, $new_w, $new_h, $old_w, $old_h)) {
        imagedestroy($im);
    } else {
        Log::warning('[ScaledImage404Controller]::imageResize Picture not copied');
        imagedestroy($image);
        $image = $im;
    }

    return $image;
}

На этом обработка завершена и можно поправить index.blade.php

@foreach($files as $file)
<li><a href="/card/{{$file}}">
        @php
            $src   = '/storage/product/'.$file;
            $scaled= '/storage/product/[width_100]/'.$file;
        @endphp
        <img src="{{$scaled}}" width="60" alt=""/> {{$src}}
    </a>
</li>
@endforeach

Теперь при открытии той же страницы что и вначале получим прокачивание 87 Кб вместо 20Мб. Правда первое открытие страницы вместо исходных 1.82 секунды потребует уже 4.02 секунды, но это только в первый раз, когда идёт генерация превьюшек. А далее - те же 87 Кб будут загружаться уже за 0.718 секунды.

В спойлере скрины с примерами

Список исходных изображений

29 исходных картинок (+favicon) 20Мб за 1.82 сек
29 исходных картинок (+favicon) 20Мб за 1.82 сек
Первый запуск с масштабированными картинками 29 (+favicon) 88Кб за 4 сек
Первый запуск с масштабированными картинками 29 (+favicon) 88Кб за 4 сек
Последующие загрузки с масштабированными картинками 29 (+favicon) те же 88Кб за 0.7 сек
Последующие загрузки с масштабированными картинками 29 (+favicon) те же 88Кб за 0.7 сек

Загрузка конкретного изображения

На примере фото 0.jpg из исходного набора (2400x1600 888Кб) можно показать сколько времени тратится на генерацию конкретного изображения:

Первый запуск: 742 мс потребовалось чтобы вернуть фото шириной 100пикс вместо 2400пикс.
Первый запуск: 742 мс потребовалось чтобы вернуть фото шириной 100пикс вместо 2400пикс.

Ускорение достигается за счёт параллельности обработки сервером таких запросов, потому время генерации некорректно просто суммировать.

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

Что улучшить?

Получается - трафик уменьшен в 200 раз по сравнению с прокачкой исходных фотографий, скорость загрузки увеличена в 2.5 раза (при реальных условиях может быть ещё больше, т.к. не совсем корректно сравнивать скорость загрузки с localhost). Кроме того не нужно заботиться о банке изображений с превьюшками. Нагрузка по сути только для формирования кэша из картинок.

Из минусов: из-за процесса генерации картинок первая загрузка картинок происходит относительно долго (в 2 раза дольше чем исходные фото). Второй минус - могут найтись злоумышленники, которые заставят ваш сайт начать генерить массу различных вариантов изображений и тем заполнят ваш дисковый лимит на сервере да и создадут лишнюю нагрузку. Решить эти проблемы позволит

  • Защита от брутфорс-атак.

  • Анализ заголовка Referer в обработчике 404 ошибки (чтобы там были только адреса ваших сайтов).

  • В обработчик 404 ошибки стоит внести некоторое разумное ограничение на масштаб, например не позволять созвать картинки свыше 500 пикселей в ширину - возвращая при таком запросе исходное фото.

  • Можно добавить шаг масштабирования: например, разрешить создавать фото с шириной 70, 80, 90 и 100 пикселей но нельзя 88 или 91 пиксель - в этом случае возвращать ближайшую по размеру картинку вместо запрошенной (это усложнит обработчик 404 ошибки и несколько снизит идеальность подгонки разрешения превьюшек).

Вместо заключения

В завершении хочется сказать, что идеальных решений не бывает - бывают целесообразные в каждом конкретном случае.

Автору статьи уже несколько раз приходилось реализовывать данную идею и не только с GD и Laravel. Потому было решено предложить её читателю, возможно кому-то она тоже окажется полезной.