Магазин на Laravel 7, часть 25. Поиск по каталогу товаров, деплой проекта на хостинг TimeWeb

29.11.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровКлассПоискПрактикаТаблицаФормаШаблонСайта

Поиск по каталогу товаров

Какой каталог товаров без поиска? Тем более, что и форма у нас уже есть. Искать будем по полям name и content таблицы products, полю name таблицы categories и полю name таблицы brands. У нас должен получиться примерно такой SQL-запрос для поиска «мужская зимняя обувь».

SELECT
    `products`.*,
    IF (`products`.`name` LIKE '%мужск%', 2, 0) + IF (`products`.`content` LIKE '%мужск%', 1, 0) +
    IF (`categories`.`name` LIKE '%мужск%', 1, 0) + IF (`brands`.`name` LIKE '%мужск%', 2, 0) +
    IF (`products`.`name` LIKE '%зимн%', 2, 0) + IF (`products`.`content` LIKE '%зимн%', 1, 0) +
    IF (`categories`.`name` LIKE '%зимн%', 1, 0) + IF (`brands`.`name` LIKE '%зимн%', 2, 0) +
    IF (`products`.`name` LIKE '%обув%', 2, 0) + IF (`products`.`content` LIKE '%обув%', 1, 0) +
    IF (`categories`.`name` LIKE '%обув%', 1, 0) + IF (`brands`.`name` LIKE '%обув%', 2, 0)
    AS `relevance`
FROM
    `products`
    INNER JOIN `categories` ON `categories`.`id` = `products`.`category_id`
    INNER JOIN  `brands` ON `brands`.`id` = `products`.`brand_id`
WHERE
    `products`.`name` LIKE '%мужск%' OR `products`.`content` LIKE '%мужск%' OR
    `categories`.`name` LIKE '%мужск%' OR `brands`.`name` LIKE '%мужск%' OR 
    `products`.`name` LIKE '%зимн%' OR `products`.`content` LIKE '%зимн%' OR
    `categories`.`name` LIKE '%зимн%' OR `brands`.`name` LIKE '%зимн%' OR
    `products`.`name` LIKE '%обув%' OR `products`.`content` LIKE '%обув%' OR
    `categories`.`name` LIKE '%обув%' OR `brands`.`name` LIKE '%обув%'
ORDER BY
    `relevance` DESC

Чтобы решить проблему окончания слов — используем стеммер Портера, который отсекает лишнее, оставляя только корень. Мне удалось найти на packagist.org готовый класс стеммера для русского языка.

Стеммер Портера — алгоритм стемминга, опубликованный Мартином Портером в 1980 году. Оригинальная версия стеммера была предназначена для английского языка. Впоследствии Мартин создал проект «Snowball» и, используя основную идею алгоритма, написал стеммеры для распространённых индоевропейских языков, в том числе для русского.

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

Итак, устанавливаем пакет с использованием composer:

> composer require ladamalina/lingua-stem-ru

Все, теперь нам доступен класс LinguaStemRu. Пример использования класса:

$stemmer = new LinguaStemRu();
echo $stemmer->stem_text('Любовь к Родине – это очень сильное чувство');
любов к родин – это очен сильн чувство

Добавляем новый маршрут в файле route/web.php:

/*
 * Каталог товаров: категория, бренд и товар
 */
Route::group([
    'as' => 'catalog.', // имя маршрута, например catalog.index
    'prefix' => 'catalog', // префикс маршрута, например catalog/index
], function () {
    // главная страница каталога
    Route::get('index', 'CatalogController@index')
        ->name('index');
    // категория каталога товаров
    Route::get('category/{category:slug}', 'CatalogController@category')
        ->name('category');
    // бренд каталога товаров
    Route::get('brand/{brand:slug}', 'CatalogController@brand')
        ->name('brand');
    // страница товара каталога
    Route::get('product/{product:slug}', 'CatalogController@product')
        ->name('product');
    // страница результатов поиска
    Route::get('search', 'CatalogController@search')
        ->name('search');
});

Изменяем форму поиска в layout-шаблоне site.blade.php:

<form action="{{ route('catalog.search') }}" class="form-inline my-2 my-lg-0">
    <input class="form-control mr-sm-2" type="search" name="query"
           placeholder="Поиск по каталогу" aria-label="Search">
    <button class="btn btn-outline-light my-2 my-sm-0"
            type="submit">Поиск</button>
</form>

Добавляем новый метод search() в контроллер CatalogController:

class CatalogController extends Controller {
    /* ... */
    public function search(Request $request) {
        $search = $request->input('query');
        $query = Product::search($search);
        $products = $query->paginate(6)->withQueryString();
        return view('catalog.search', compact('products', 'search'));
    }
}

Добавляем новый метод scopeSearch() в модель Product:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stem\LinguaStemRu;

class Product extends Model {
    /**
     * Позволяет искать товары по заданным словам
     *
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param string $search
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function scopeSearch($query, $search) {
        // обрезаем поисковый запрос
        $search = iconv_substr($search, 0, 64);
        // удаляем все, кроме букв и цифр
        $search = preg_replace('#[^0-9a-zA-ZА-Яа-яёЁ]#u', ' ', $search);
        // сжимаем двойные пробелы
        $search = preg_replace('#\s+#u', ' ', $search);
        $search = trim($search);
        if (empty($search)) {
            return $query->whereNull('id'); // возвращаем пустой результат
        }
        // разбиваем поисковый запрос на отдельные слова
        $temp = explode(' ', $search);
        $words = [];
        $stemmer = new LinguaStemRu();
        foreach ($temp as $item) {
            if (iconv_strlen($item) > 3) {
                // получаем корень слова
                $words[] = $stemmer->stem_word($item);
            } else {
                $words[] = $item;
            }
        }
        $relevance = "IF (`products`.`name` LIKE '%" . $words[0] . "%', 2, 0)";
        $relevance .= " + IF (`products`.`content` LIKE '%" . $words[0] . "%', 1, 0)";
        $relevance .= " + IF (`categories`.`name` LIKE '%" . $words[0] . "%', 1, 0)";
        $relevance .= " + IF (`brands`.`name` LIKE '%" . $words[0] . "%', 2, 0)";
        for ($i = 1; $i < count($words); $i++) {
            $relevance .= " + IF (`products`.`name` LIKE '%" . $words[$i] . "%', 2, 0)";
            $relevance .= " + IF (`products`.`content` LIKE '%" . $words[$i] . "%', 1, 0)";
            $relevance .= " + IF (`categories`.`name` LIKE '%" . $words[$i] . "%', 1, 0)";
            $relevance .= " + IF (`brands`.`name` LIKE '%" . $words[$i] . "%', 2, 0)";
        }

        $query->join('categories', 'categories.id', '=', 'products.category_id')
            ->join('brands', 'brands.id', '=', 'products.brand_id')
            ->select('products.*', DB::raw($relevance . ' as relevance'))
            ->where('products.name', 'like', '%' . $words[0] . '%')
            ->orWhere('products.content', 'like', '%' . $words[0] . '%')
            ->orWhere('categories.name', 'like', '%' . $words[0] . '%')
            ->orWhere('brands.name', 'like', '%' . $words[0] . '%');
        for ($i = 1; $i < count($words); $i++) {
            $query = $query->orWhere('products.name', 'like', '%' . $words[$i] . '%');
            $query = $query->orWhere('products.content', 'like', '%' . $words[$i] . '%');
            $query = $query->orWhere('categories.name', 'like', '%' . $words[$i] . '%');
            $query = $query->orWhere('brands.name', 'like', '%' . $words[$i] . '%');
        }
        $query->orderBy('relevance', 'desc');
        return $query;
    }
}

Искать будем по следующим полям таблиц базы данных:

  • поле name таблицы products (название товара)
  • поле content таблицы products (описание товара)
  • поле name таблицы categories (название категории)
  • поле name таблицы brands (название бренда)

Причем вклад в релевантность у них разный — у названия товара и названия бренда больше, у названия категории и описания товара меньше.

Осталось только создать шаблон views/catalog/search.blade.php:

@extends('layout.site', ['title' => 'Поиск по каталогу'])

@section('content')
    <h1>Поиск по каталогу</h1>
    <p>Поисковый запрос: {{ $search ?? 'пусто' }}</p>
    @if (count($products))
        <div class="row">
            @foreach ($products as $product)
                @include('catalog.part.product', ['product' => $product])
            @endforeach
        </div>
        {{ $products->links() }}
    @else
        <p>По вашему запросу ничего не найдено</p>
    @endif
@endsection

Деплой на хостинг TimeWeb

1. Создаем репозиторий на GitHub

Сначала создадим новый репозиторий на GitHub, у меня это laravel-7-shop:

Сразу после создания нового репозитория получим подсказки, что делать дальше.

На локальном компьютере тоже создаем репозиторой, чтобы выложить на GitHub:

$ cd D:/work/localhost21/www # это директория проекта
$ git init # создаем пустой репозиторий
Initialized empty Git repository in D:/work/localhost21/www/.git/
$ git add --all  # добавляем все файлы проекта
$ git commit -m "Initial сommit" # первый коммит

У нас уже есть подсказка от GitHub, что надо сделать, чтобы выложить проект (не торопитесь выполнять):

$ git remote add origin https://github.com/tokmakov/laravel-7-shop.git
$ git branch -M main # изменить имя с master на main
$ git push -u origin main
Разработчики GitHub предлагают еще изменить имя ветки с master на main. Потому что имя master им представляется недостаточно толерантным. Но мы не будем следовать сомнительным советам, имя master вполне подходит.

Добавляем себе ссылку (pointer) на удаленный репозиторий:

$ git remote add origin https://github.com/tokmakov/laravel-7-shop.git

Теперь удаленный репозиторий доступен как origin:

$ git remote
origin

Отправляем текущую ветку master (которая у нас сейчас всего одна) в удаленную ветку master репозитория origin:

$ git push origin master

Настроим текущую ветку master на отслеживание удаленной ветки master:

$ git branch -u origin/master
Branch master set up to track remote branch master from origin.

2. Клонируем проект на сервере

В панели управления хостинга нужно включить доступ по SSH и подключиться из командной строки к серверу. На хостинге TimeWeb уже установлен Composer, только нужно задать алиасы (см. здесь) для composer и php.

$ pwd
/home/c/ca12345
$ nano .bash_profile
alias composer='/opt/php72/bin/php -d memory_limit=1024M /usr/local/bin/composer'
alias php='/opt/php72/bin/php -d memory_limit=1024M'

Чтобы применить изменения из файла .bash_profile:

$ source ~/.bash_profile

Проверяем, что Composer нам теперь доступен:

$ composer
   ______
  / ____/___  ____ ___  ____  ____  ________  _____
 / /   / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__  )  __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
                    /_/
Composer version 1.9.0 2019-08-02 20:55:32

Usage:
  command [options] [arguments]
..........

Создаем директорию для нашего проекта в панели управления хостингом, у меня это laravel-7-shop. Внутри будет автоматически создана директория public_html, которая является корнем сервера. Нам это директория не нужна, потому что у нас корень сервера — директория public.

$ rm -r ~/laravel-7-shop/public_html

Клонируем в laravel-7-shop репозиторий с GitHub:

$ git clone https://github.com/tokmakov/laravel-7-shop.git laravel-7-shop

3. Устанавливаем проект на сервере

Устанавливаем проект с помощью Composer:

$ cd laravel-7-shop
$ composer install --no-dev
Loading composer repositories with package information
Installing dependencies (including require-dev) from lock file
Package operations: 122 installs, 0 updates, 0 removals
  - Installing phpdocumentor/reflection-common (2.2.0): Downloading (100%)
  - Installing phpdocumentor/type-resolver (1.4.0): Downloading (100%)
..........
Discovered Package: nunomaduro/collision
Package manifest generated successfully.

4. Создаем новую базу данных

В панели управления хостингом создаем новую базу данных, у меня это ca12345_lar7shop. Создаем файл конфигурации приложения, указываем имя БД, имя пользователя БД и пароль.

$ cp .env.example .env
$ nano .env
APP_NAME="Магазин одежды и обуви"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://laravel-7-shop.tokmakov.msk.ru

LOG_CHANNEL=stack

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=ca12345_lar7shop
DB_USERNAME=ca12345_lar7shop
DB_PASSWORD=FapeTZ61

Создаем ключ приложения Laravel:

$ php artisan key:generate
Application key set successfully.

5. Создаем таблицы базы данных

Запускаем миграцию, чтобы создать таблицы БД и тестовые данные:

$ php artisan migrate:fresh --seed
**************************************
*     Application In Production!     *
**************************************

 Do you really wish to run this command? (yes/no) [no]:
 > yes

Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table

  Illuminate\Database\QueryException

  SQLSTATE[HY000]: General error: 1709 Index column size too large. The maximum column 
  size is 767 bytes. (SQL: alter table `users` add unique `users_email_unique`(`email`))

Исправить это легко, изменяем файл AppServiceProvider.php:

class AppServiceProvider extends ServiceProvider {
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register() {
        Schema::defaultStringLength(191);
    }
    /* ... */
}

Выкладываем изменения на GitHub:

$ git commit -a -m "default string length"
$ git push

На сервере получаем изменения:

$ git pull

Снова запускаем миграцию и получаем следующую порцию ошибок, что класс App\Category не найден. Забыл указать новое пространство имен, когда перемещал модели из директории app в директорию app/Models. Так что надо исправить файлы в директории database/seeds и в директории database/factories.

use Illuminate\Database\Seeder;

class CategoryTableSeeder extends Seeder {
    public function run() {
        // создать 10 категорий
        factory(App\Models\Category::class, 10)->create();
    }
}
use Illuminate\Database\Seeder;

class BrandTableSeeder extends Seeder {
    public function run() {
        // создать 10 брендов
        factory(App\Models\Brand::class, 10)->create();
    }
}
use Illuminate\Database\Seeder;

class ProductTableSeeder extends Seeder {
    public function run() {
        // создать 30 товаров
        factory(App\Models\Product::class, 30)->create();
    }
}
use App\Models\Category;
use Illuminate\Support\Str;
use Faker\Generator as Faker;

$factory->define(Category::class, function (Faker $faker) {
    /* ... */
});
use App\Models\Brand;
use Faker\Generator as Faker;

$factory->define(Brand::class, function (Faker $faker) {
    /* ... */
});
use App\Models\Product;
use Faker\Generator as Faker;

$factory->define(Product::class, function (Faker $faker) {
    /* ... */
});
use App\Models\User;
use Faker\Generator as Faker;
use Illuminate\Support\Str;

$factory->define(User::class, function (Faker $faker) {
    /* ... */
});

Выкладываем изменения на GitHub:

$ git commit -a -m "seeder namespace error"
$ git push

На сервере получаем изменения:

$ $ git pull

В третий раз запускаем миграцию:

$ php artisan migrate:fresh --seed
Dropped all tables successfully.
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (0.04 seconds)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (0.04 seconds)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (0.03 seconds)
Migrating: 2020_09_28_130327_create_categories_table
Migrated:  2020_09_28_130327_create_categories_table (0.04 seconds)
Migrating: 2020_09_28_130335_create_brands_table
Migrated:  2020_09_28_130335_create_brands_table (0.06 seconds)
Migrating: 2020_09_28_130346_create_products_table
Migrated:  2020_09_28_130346_create_products_table (0.13 seconds)
Migrating: 2020_10_02_120033_create_baskets_table
Migrated:  2020_10_02_120033_create_baskets_table (0.03 seconds)
Migrating: 2020_10_02_120730_create_basket_product_table
Migrated:  2020_10_02_120730_create_basket_product_table (0.13 seconds)
Migrating: 2020_10_09_081701_alter_users_table
Migrated:  2020_10_09_081701_alter_users_table (0.03 seconds)
Migrating: 2020_10_10_105603_create_orders_table
Migrated:  2020_10_10_105603_create_orders_table (0.08 seconds)
Migrating: 2020_10_10_111729_create_order_items_table
Migrated:  2020_10_10_111729_create_order_items_table (0.14 seconds)
Migrating: 2020_10_25_090836_alter_orders_table
Migrated:  2020_10_25_090836_alter_orders_table (0.04 seconds)
Migrating: 2020_10_27_060337_create_pages_table
Migrated:  2020_10_27_060337_create_pages_table (0.04 seconds)
Migrating: 2020_11_01_064359_create_profiles_table
Migrated:  2020_11_01_064359_create_profiles_table (0.07 seconds)
Migrating: 2020_11_08_101100_alter_products_table
Migrated:  2020_11_08_101100_alter_products_table (0.03 seconds)
Seeding: CategoryTableSeeder
Seeded:  CategoryTableSeeder (0.06 seconds)
Таблица категорий загружена данными!
Seeding: BrandTableSeeder
Seeded:  BrandTableSeeder (0.01 seconds)
Таблица брендов загружена данными!
Seeding: ProductTableSeeder
Seeded:  ProductTableSeeder (0.05 seconds)
Таблица товаров загружена данными!
Database seeding completed successfully.

На этот раз все прошло успешно.

5. Создаем символические ссылки

Мы удалили директорию public_html внутри laravel-7-shop, которая на хостинге является корнем веб-сервера. Теперь вместо нее создадим символическую ссылку public_html (см. здесь), которая будет указывать на директорию public.

$ ln -s ~/laravel-7-shop/public ~/laravel-7-shop/public_html

Кроме того, создаем символическую ссылку public/storage на директорию storage/app/public:

$ php artisan storage:link

Исходные коды здесь, демо-сайт здесь.

Post Scriptum

Чтобы на сервере работала отправка почты, нужно создать почтовый ащик в панели управления хостинга, а потом отредактировать файл .env.

$ nano .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.timeweb.ru
MAIL_PORT=465
MAIL_USERNAME=laravel-7-shop@tokmakov.msk.ru
MAIL_PASSWORD=пароль-почтового-ящика
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=laravel-7-shop@tokmakov.msk.ru
MAIL_FROM_NAME="${APP_NAME}"

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Интернет магазин • Каталог товаров • Класс • Поиск • Практика • Таблица • Форма • Шаблон сайта

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.