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

На прошлом уроке мы сделали товарам галереи, теперь нужно вывести из них файлы.
Как правило, в магазинах принято выводить первую картинку галереи в качестве титульной. То есть, показывать её во всех скписках товаров, включая админку.
С этого мы и начнём - с присоединения первого изображения товара аж четырьмя способами, и выберем из них самый оптимальный.

Как 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 комментария

Если не сложно, поправь для таких "чайников" как я:
  1. Все картинки отдаются через контроллер
core/srs/Controllers/Image.php 
  1. Все операции проводим в контроллерах товаров
core/src/Controllers/Admin/Products.php
core/src/Controllers/Web/Products.php
  1. Теперь ошибки не будет. Обновляем контроллеры админки и сайта:
core/src/Controllers/Admin/Products.php
core/src/Controllers/Web/Products.php
  1. Теперь нам нужно повторить ту же логику и на публичном сайте. Просто копипастим afterCount() в
core/src/Controllers/Web/Products.php
P.S Вышеуказанные правки не критичны, но очень помогут новичкам. Но учитывая количество не описаных в заметке дополнительных правок в файлах репозитория, это их не спасет)) Отдельное спасибо за примеры выборок и тестирование скорости их обработки.
Василий Наумкин
Спасибо за исправления, очень выручаешь =)
Но учитывая количество не описаных в заметке дополнительных правок в файлах репозитория
Именно для этого и и выкладываю все изменения отдельными коммитами, чтобы можно было посмотреть, что получилось в итоге. А заметки объясняют основные моменты для понимания.
Если же я не рассказал что-то важное - всегда можно спросить в комментарии, я объясню.
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Дмитрий
21.12.2024, 13:27:06
Здравствуйте.В ModX есть полезная функция "заморозить url родителя". При ее включении вместо: УРЛ п...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
22.11.2024, 03:33:54
Спасибо!
inna
06.11.2024, 15:47:13
Да. Все работает. Спасибо.
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!