Вывод изображений товаров
На прошлом уроке мы сделали товарам галереи, теперь нужно вывести из них файлы.
Как правило, в магазинах принято выводить первую картинку галереи в качестве титульной. То есть, показывать её во всех скписках товаров, включая админку.
С этого мы и начнём - с присоединения первого изображения товара аж четырьмя способами, и выберем из них самый оптимальный.
Как Vesp выводит изображения
Vesp использует под капотом библиотеку Glide, которая может всяко-разно конвертировать и обрезать изображения, кэшируя результат. Если вы работали с MODX, то это гораздо более продвинутый и удобный аналог PhpThumbOf.
Все картинки отдаются через контроллер core/src/Controllers/Image.php, который по умолчанию просто расширяет контроллер картинок из Vesp.
Контроллер работает через метод GET и требует указать только id файла для выдачи. В запрос так же можно добавить кучу параметров Glide, все они будут переданы ему для работы.
Пример вызова: /api/image/10?w=300&h=200&fit=crop
Конечно, вы можете расширить свой контроллер Image и добавить какие-то параметры или проверки для вывода файлов.
Выбираем первую картинку
Картинки у нас сортируются по колонке rank, и некоторые могут быть отключен. Значит, нам нужно выбрать активную картинку товара с минимальным rank.
Все операции проводим в контроллере товаров core/src/Controllers/Admin/Products.php, в результате хотим получить вложенный массив с файлом сразу после category - нам нужны только id и updated_at:
Первый и самый очевидный способ - просто выбрать изображение в методе prepareRow(), который превращает объект модели в массив перед выдачей пользователю:
public function prepareRow(Model $object): array
{
// Переводим объект в массив
$array = $object->toArray();
// Если первичный ключ не указан, значит идёт выборка списка товаров
if (!$this->getPrimaryKey()) {
// И тогда на каждый товар мы добираем первый ProductFile
$productFile = $object->productFiles()
->where('active', true)->orderBy('rank')->first();
// Если такая запись существует
if ($productFile) {
// Добавляем в массив id и время изменения файла,
// чтобы обойти кэширование браузера
$array['file'] = $productFile->file->only('id', 'updated_at');
}
}
return $array;
}
Второй вариант - воспользоваться присоединением таблицы через with(), когда Eloquent делает дополнительный запрос для выборки нужной модели по списку требуемых id.
protected function afterCount(Builder $c): Builder
{
// Добавляем таблицу product_files
$c->with('productFiles', function(HasMany $c) {
// Выбираем только активные связи
$c->where('active', true);
// Сортируем по rank
$c->orderBy('rank');
// Выбираем только 2 нужные колонки
$c->select('product_id', 'file_id');
// И к этой связи добавляем еще и её связь с файлами
$c->with('file:id,updated_at');
});
return $c;
}
Проблема в том, что при использовании with() вы не можете выбрать только 1 файл для каждой записи - Eloquent выберет все файлы каждого товара, а их может быть очень много.
На практике это не сильно замедляет быстродействие, но требует дополнительную обработку в prepareRow():
public function prepareRow(Model $object): array
{
$array = $object->toArray();
// Если у товара есть файлы
if (!empty($array['product_files'])) {
// Выбираем и сохраняем первый файл из списка
$array['file'] = $array['product_files'][0]['file'];
} else {
$array['file'] = [];
}
// И удаляем остальные ненужные файлы из результатов
unset($array['product_files']);
return $array;
}
Итогом будет точно такой же вывод, как и первым методом, но быстрее в 3 раза.
А что, если мы хотим сделать прям совсем всё правильно, экономично и эффективно? Тогда нам нужно сделать join 2х таблиц. Это третий, самый сложный метод:
protected function afterCount(Builder $c): Builder
{
// Начинаем присоединение связей с файлами
$c->leftJoin('product_files', function (JoinClause $c) {
// Узнаём, какой у нас префикс таблиц
$db = $this->eloquent->getConnection();
$px = $db->getTablePrefix();
// Условие присоединения по id товаров
$c->on('product_files.product_id', '=', 'products.id');
// Присоединение первой активной связи
$c->on(
'product_files.file_id',
'=',
// Добавляем сырой подзапрос, чтобы узнать file_id
// первой активной связи с минимальным rank
$db->raw("(
select file_id from {$px}product_files where
{$px}product_files.product_id = {$px}products.id
order by `rank` limit 1
)")
);
});
// Присоединяем вторую таблицу по выбранному file_id
$c->leftJoin('files', 'files.id', '=', 'product_files.file_id');
// Так как у нас в запросе аж 3 таблицы,
// нужно указать какие колонки из них выбирать
// Выбираем все колонки товара с указанием имени таблицы
$columns = array_map(static function ($col) {
return 'products.' . $col;
// А список колонок получаем их схемы таблицы Eloquent
}, $this->eloquent->getConnection()->getSchemaBuilder()->getColumnListing('products'));
// Добавляем полученные колонки товара
$c->select($columns);
// И колонки файла, тоже с именем таблицы
$c->addSelect('files.id as file_id', 'files.updated_at as file_updated_at');
return $c;
}
В итоговом выводе мы получим поля file_id и file_updated_at, нужно привести их к требуему виду - поместить во вложенный массив.
public function prepareRow(Model $object): array
{
$array = $object->toArray();
// Проверяем наличие файла
if (!empty($array['file_id'])) {
// И добавляем вложенный масив
$array['file'] = [
'id' => $array['file_id'],
'updated_at' => $array['file_updated_at']
];
} else {
$array['file'] = [];
}
// Ненужные колонки убираем
unset($array['file_id'], $array['file_updated_at']);
return $array;
}
В результате мы видим здоровенный SQL запрос, который работает медленнее, чем второй вариант - 5 запросов за 8 миллисекунд. И, полагаю, будет тем медленнее, чем больше файлов мы загрузим в систему.
Думаю, именно поэтому авторы Eloquent не очень любят join, и не советуют его использовать.
Уже после публикации этого урока мне написали в телеграме, что есть и четвёртый вариант выборки, описанный в документации Eloquent. Я проверил этот метод и, пожалуй, он наиболее оптимален - не выбирает ничего лишнего и работает так же быстро, как и второй.
Суть его в том, чтобы прописать особую связь в модели товара и затем подключать её через with(). Редактируем модель Product:
// Эта наша связь с выборкой первого активного ProductFile
public function firstFile(): HasOne
{
return $this
->hasOne(ProductFile::class)
// Выборка сортируется по rank
->ofMany(['rank' => 'min'], function (Builder $c) {
$c->where('active', true);
})
// Добавляем и выборку конечного File
->with('file:id,updated_at');
}
// Расширяем метод toArray, чтобы он всегда заменял ключ first_file на file
public function toArray(): array
{
$array = parent::toArray();
// Если выбран первый файл
if (array_key_exists('first_file', $array)) {
// Приводим массив к нашему формату
$array['file'] = !empty($array['first_file']) ? $array['first_file']['file'] : [];
unset($array['first_file']);
}
return $array;
}
При использовании новой связи Eloquent добавляет в выборку первичный ключ модели ProductFile, который у нас составной, и это вызывает ошибку при работе. Нам нужно сказать Eloquent использовать 1 колонку для сортировки, и мы указываем file_id:
public function getKeyName(): string
{
return 'file_id';
}
Теперь ошибки не будет. Обновляем контроллеры админки и сайта core/src/Controllers/(Admin|Web)/Products.php:
protected function afterCount(Builder $c): Builder
{
$c->with('category:id,title');
// наша новая связь
$c->with('firstFile');
return $c;
}
Так как мы уже расширили Product::toArray(), то вызывать prepareRow в контроллерах больше не нужно, можно эти вызовы удалить.
Несмотря на жутковатый SQL запрос, по моим тестам всё работает очень шустро, так что оставляем этот метод основным.
Выводим картинку в админке
Я уже внёс небольшие улучшения в галерею, поправил стили, мелкие ошибки и добавил открытие картинки при клике в отдельном окне в полный размер. Все изменения можно посмотреть в этом коммите.
Сейчас нам нужно добавить вывод картинки в список товаров админки. Редактируем frontend/src/admin/pages/products.vue и добавляем новую колонку:
fields() {
return [
{key: 'id', label: this.$t('components.table.columns.id'), sortable: true},
{key: 'file', label: ''},
// ...
]
},
Вывод даты обновления я закомментировал, чтобы освободить место в таблице.
Теперь меняем раздел template и добавляем блок для вывода картинки:
<vesp-table :url="url" ...>
<template #cell(file)="{value}">
<b-img v-if="value.id" :src="$image(value, {w: 100, h: 50, fit: 'crop'})"
width="100" height="50" />
</template>
</vesp-table>
Компонент галереи генерирует событие app::admin-products::update которое слушает таблица товаров, и перезагружается при его наступлении.
Вывод изображений на сайте
Теперь нам нужно повторить ту же логику и на публичном сайте. Просто копипастим afterCount() в core/src/Controllers/Web/Products.php:
protected function afterCount(Builder $c): Builder
{
$c->with('category:id,title');
$c->with('firstFile');
return $c;
}
Теперь обновляем наш компонент вывода товаров frontend/src/site/components/list-products.vue:
<template>
<div class="products-list">
<div v-for="product in products" :key="product.id" class="product mt-2">
<b-img
v-if="product.file.id"
:src="$image(product.file, {w: 100, h: 50, fit: 'crop'})"
width="100"
height="50"
alt=""
/>
{{ product.title }} — {{ product.price }} руб.
</div>
</div>
</template>
И получаем ошибку _vm.$image is not a function, потому что такой функции на сайте действительно нет. Она есть в админке, где @vesp/frontend подключен полностью, а на сайте мы подключаем всё только по необходимости, чтобы не раздувать проект.
Редактируем наш конфиг в frontend/src/site/nuxt.config.js:
// Добавляем модуль Vesp
Config.modules = ['bootstrap-vue/nuxt', '@nuxtjs/axios', '@nuxtjs/pwa', '@vesp/frontend']
// Но подключаем из него пока только утилиты
Config.Vesp = {
components: false, // компоненты админки
scss: false, // стили компонентов
i18n: false, // поддержка мультиязычности
axios: false, // обработка ошибок при запросах в API
utils: true, // утилиты, в которых есть и нужная нам $image
filters: false, // фильтры вывода, типа форматирование дат
}
И теперь вывод изображений работает!
Заключение
После получения картинок я еще доработал вывод товаров, но расписывать его здесь не буду - потому что там нет ничего особенного, обычная работа на VueJS. Добавление кнопочек и передача параметров компонентам.
А исходники вы, как обычно, можете посмотреть в репозитории. Если что-то будет непонятно - просто спросите в комментариях, я расскажу.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
425
24.06.2022, 13:26:20
2 комментария
Игорь
26.06.2022, 05:39:26
Если не сложно, поправь для таких "чайников" как я:
- Все картинки отдаются через контроллер
- Все операции проводим в контроллерах товаров
- Теперь ошибки не будет. Обновляем контроллеры админки и сайта:
- Теперь нам нужно повторить ту же логику и на публичном сайте. Просто копипастим afterCount() в
P.S Вышеуказанные правки не критичны, но очень помогут новичкам. Но учитывая количество не описаных в заметке дополнительных правок в файлах репозитория, это их не спасет)) Отдельное спасибо за примеры выборок и тестирование скорости их обработки.
27.06.2022, 09:32:16
Спасибо за исправления, очень выручаешь =)
Именно для этого и и выкладываю все изменения отдельными коммитами, чтобы можно было посмотреть, что получилось в итоге. А заметки объясняют основные моменты для понимания.
Если же я не рассказал что-то важное - всегда можно спросить в комментарии, я объясню.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо!
Извини, тупанул.