Как стать автором
Обновить

Уменьшение трафика за счёт сжатия изображений. На примере Laravel

Время на прочтение9 мин
Количество просмотров7.9K

Как уменьшить трафик к вашему сайту в 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. Потому было решено предложить её читателю, возможно кому-то она тоже окажется полезной.

Теги:
Хабы:
+5
Комментарии26

Публикации

Изменить настройки темы

Истории

Работа

PHP программист
155 вакансий

Ближайшие события