Как уменьшить трафик к вашему сайту в 200 раз? Возможно ли такое? Иногда можно добиться подобного с помощью оптимизации скачиваемых изображений. Рассмотрим вариант, делать это преобразование "на лету" и избежать его минусов.
Если на сайте есть страница, со списком объектов, каждый из которых снабжен изобра��ением, то загрузка всех картинок такой страницы может вызвать значительную нагрузку. Типичное решение для уменьшение трафика - применить превьюшки. Их создание возможно при загрузке на сайт исходного изображения и "по запросу". Вариант, с ручным добавлением превьюшек не будем рассматривать как наиболее трудоёмкий для поддержки сайта :)
Создание превьюшек при загрузке изображения
Для получения набора заготовленых на все случаи жизни изображений можно прямо в админке обрабатывать каждое загружаемое на сайт фото.
В ситуации, когда есть банк изображений с заранее выбранным масштабом вы можете обречь посетителей своего сайта качать "лишние" мегабайты, либо затребовать такое разрешение фотографии которого у вас нет и получить ошибку 404. Ситуация усугубляется вопросом коммуникации frontend- и backend- разработчиков, например ваш api-проект, управляющий банком изображений, развивается в совершенно другом репозитории, со всеми вытекающими вопросами согласования версий одного и другого проекта. У вас может измениться дизайн сайта или появиться мобильное приложение, которому потребуется особый набор превьюшек и придётся дорабатывать скрипт для масштабирования картинок в вашей админке и проделывать эту процедуру с уже созданными картинками.
К плюсам можно отнести скорость с которой будет возвращаться затребованный браузером статичный файл.
Создание превьюшек "на лету"
Для идеальной "подгонки" размеров картинок хочется сделать так, чтобы размер изображения мог быть заказан напрямую с фронтенда на основе URL изображения. Но если файла по зад��нному адресу нет - получится 404 ошибка. Потому целесообразно сделать обработчик 404 ошибки, с помощью которого можно будет генерить превьюшку. Разумеется это возможно будет если по самому URL можно будет понять на основе какого изображения генерить и в каком размере. В этом случае вместо 404 ошибки будет возвращена сама картинка.

К минусам этого решения следует отнести высокую нагрузку необходимую на преобразование изображений. Причём - это как правило будут одни и те же фотографии в тех же разрешениях что запрашивались ранее.
Объединение сильных сторон обеих решений
Каждый раз заставлять сервер проделывать одну и ту же работу не очень хорошая идея. Идеально было бы сгенерить масштабированные фото только один раз а далее они бы возвращались как обычные статические файлы. Для этогодостаточно после генерации изображения, не только вернуть его вместо 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 проверять:
Запрашивается ли изображение .git .png .jpg (или .jpeg)?
Содержит ли URL правильную папку (команду) для масштабирования картинки?
Существует ли оригинал картинки?
Если всё так и есть - создать папку (команду) если её нет и вернуть:
имя исходного файла;
имя файла для сохранения новой картинки;
тип изображения (gif, png или jpeg) для указания в "Content-type: image/$type";
собственно команду ресайза типа
/[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 секунды.
В спойлере скрины с примерами
Список исходных изображений



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

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

Что улучшить?
Получается - трафик уменьшен в 200 раз по сравнению с прокачкой исходных фотографий, скорость загрузки увеличена в 2.5 раза (при реальных условиях может быть ещё больше, т.к. не совсем корректно сравнивать скорость загрузки с localhost). Кроме того не нужно заботиться о банке изображений с превьюшками. Нагрузка по сути только для формирования кэша из картинок.
Из минусов: из-за процесса генерации картинок первая загрузка картинок происходит относительно долго (в 2 раза дольше чем исходные фото). Второй минус - могут найтись злоумышленники, которые заставят ваш сайт начать генерить массу различных вариантов изображений и тем заполнят ваш дисковый лимит на сервере да и создадут лишнюю нагрузку. Решить эти проблемы позволит
Защита от брутфорс-атак.
Анализ заголовка Referer в обработчике 404 ошибки (чтобы там были только адреса ваших сайтов).
В обработчик 404 ошибки стоит внести некоторое разумное ограничение на масштаб, например не позволять созвать картинки свыше 500 пикселей в ширину - возвращая при таком запросе исходное фото.
Можно добавить шаг масштабирования: например, разрешить создавать фото с шириной 70, 80, 90 и 100 пикселей но нельзя 88 или 91 пиксель - в этом случае возвращать ближайшую по размеру картинку вместо запрошенной (это усложнит обработчик 404 ошибки и несколько снизит идеальность подгонки разрешения превьюшек).
Вместо заключения
В завершении хочется сказать, что идеальных решений не бывает - бывают целесообразные в каждом конкретном случае.
Автору статьи уже несколько раз приходилось реализовывать данную идею и не только с GD и Laravel. Потому было решено предложить её читателю, возможно кому-то она тоже окажется полезной.