Вывод изображений товаров

На прошлом уроке мы сделали товарам галереи, теперь нужно вывести из них файлы.

Как правило, в магазинах принято выводить первую картинку галереи в качестве титульной. То есть, показывать её во всех скписках товаров, включая админку.

С этого мы и начнём - с присоединения первого изображения товара аж четырьмя способами, и выберем из них самый оптимальный.

Как 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;
}

Смотрим, что происходит на сервере - 26 запросов за 19 миллисекунд, выглядит не очень оптимально.

Второй вариант - воспользоваться присоединением таблицы через 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;
}

Смотрим в отладку и видим гораздо более приятный результат - 6 запросов за 6 миллисекунд.

Проблема в том, что при использовании 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>

И на этом всё, смотрим результат (кликните на GIFку):

Компонент галереи генерирует событие 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 }} &mdash; {{ 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. Добавление кнопочек и передача параметров компонентам.

Вот итоговый результат (кликаем)

А исходники вы, как обычно, можете посмотреть в репозитории. Если что-то будет непонятно - просто спросите в комментариях, я расскажу.

← Предыдущая заметка
Галерея товаров
Следующая заметка →
Вывод товаров на сайте
Комментарии (2)
bezumkinВасилий Наумкин
27.06.2022 06:32

Спасибо за исправления, очень выручаешь =)

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

Именно для этого и и выкладываю все изменения отдельными коммитами, чтобы можно было посмотреть, что получилось в итоге. А заметки объясняют основные моменты для понимания.

Если же я не рассказал что-то важное - всегда можно спросить в комментарии, я объясню.

bezumkin
Василий Наумкин
09.04.2024 01:45
Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. Во...
futuris
Futuris
04.04.2024 05:56
Я просто немного запутался. Когда в абзаце &quot;Vesp/Core&quot; ты пишешь про &quot;новый trait Fil...
bezumkin
Василий Наумкин
20.03.2024 18:21
Volledig!
Андрей
14.03.2024 10:47
Василий! Как всегда очень круто! Моё почтение!
russelgal
russel gal
09.03.2024 17:17
А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал ...
inetlover
Александр Наумов
27.01.2024 00:06
Василий, спасибо! Извини, тупанул.
bezumkin
Василий Наумкин
22.01.2024 04:43
Давай-давай!
bezumkin
Василий Наумкин
24.12.2023 11:26
Спасибо!
bezumkin
Василий Наумкин
27.11.2023 02:43
Ура!
bezumkin
Василий Наумкин
25.11.2023 08:30
Vesp тянет 2 зависимости: vesp-frontent для фронта и vesp-core для бэкенда. Их можно обновлять, но э...