Как уменьшить трафик к вашему сайту в 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. Потому было решено предложить её читателю, возможно кому-то она тоже окажется полезной.
Комментарии (15)
t38c3j
24.05.2022 14:30+1Почему бы не использовать уже готовые решения? Например https://github.com/cshum/imagor
Fortistello
24.05.2022 19:03+2Как вариант, при первой загрузке можно в очередь кидать задачу на ресайз, а возвращать обычное большое изображение, чтобы пользователя не заставлять ждать
FanatPHP
24.05.2022 19:21+4Неплохая ученическая работа, но вот портить её желтушным заголовком совсем не стоило.
SevaXXL
24.05.2022 20:31Или проверку наличия файла сделать в .htaccess
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^image-([0-9]+).jpg$ script.php?width=$1 [L]
а в скрипте ресайзить, сохранять и отдавать первый раз.FanatPHP
25.05.2022 09:49+1Не хочется вас расстраивать, но на большинстве профессиональных серверов этот файлик будет бесполезен чуть более, чем полностью.
И в целом непонятно, зачем городить отдельную запись в конфиге веб-сервера, если и без неё всё прекрасно работает.
AcidWave
25.05.2022 09:27А не проще в webp призагрузке складывать картинки и потом уже в очереди их ресайзить? Тем более webp весит намного меньше
AmdY
25.05.2022 13:36Всегда удивляет, как люди умудряются писать такую кучу плохого кода. Обычно когда спешишь и говнокодишь, то получается совсем мало кода, но с проблемами в поддержке. А здесь и кода тьма, и качество ужасное, и проблемы с безопасностью.
AmdY
25.05.2022 15:54Вот пример с глайдом, всего пару строк, если не хочется морочиться с конфигурированием сервера https://glide.thephpleague.com/2.0/config/integrations/laravel/
psycho-coder
25.05.2022 13:55Подскажите, кто с ларкой работатет плотно. Это нормальный код для проектов на ларавеле или нет? Мне кажется это ужасным, местами лишние методы, переусложнения и вообще индусский код напоминает
Djeux
26.05.2022 10:00Нет понятия нормальный код для X фреймворка или нет. Фигню как и качественный код можно писать практически на любом фреймворке. Минус лары скорее в том что документация особо не склоняет к написанию правильного и чистого кода.
psycho-coder
26.05.2022 12:28Часто вижу такой код именно в ларке, поэтому и спросил. Если с вордпрессом еще понятно, почему там иногда накручено много, то здесь возникают вопросы.
Мой вопрос опять был составлен не совсем корректно.Минус лары скорее в том что документация особо не склоняет к написанию правильного и чистого кода
Соглашусь.
Если вовремя поймать этот момент можно исправить кучу косяков или недопустить их.
Djeux
Вместо выплевывания картинок посредством Laravel, лучше глянуть в сторону nginx x-accel-redirect.
NickyX3
а еще лучше в сторону nginx-http-image-filter, и генерить картинки "на лету" (через проксирующий nginx с кешем). А если прикрутить nginx-let-module, то можно еще и duble density выдавать вместо обычной для ретина-дисплеев (вмысле при запросе картинки 100х100, выдавать картинку 200х200)