Создание autoselect-компонента на Symfony/Vue. Часть 2.

Создание autoselect-компонента на Symfony/Vue. Часть 2.

junsenioradmin

Сегодня зафиналим наш autoselect-компонент. Но для начала расскажу, где я был: дорабатывал положенные 2 недели на старой работе и готовился к выходу на новую. Больше пока сказать не могу, кроме того, что компания большая, требуют там больше и работать надо будет упорно, чтобы пройти испытательный. Зато буду больше писать о всяком. А теперь к нашим баранам. В первой части мы немного затронули Vue, написали первый компонент и закончили на этом. Сегодня так просто отделаться не получится - будет и бэк, и фронт, и интересные либы и много нового материала.

Для начала нужно мОкнуть данные с бэка. Что такое мокнуть? Это значит подделать (получить фэйковые данные для тестов). С этими данными будет работать наш компонент, написанный в 1-ой части.

Чтобы это сделать, нам нужно некоторое количество записей в какой-либо таблице. Я создам таблицу Product, каждая запись в которой будет состоять из 4-х частей: id, название продукта, дата поступления и количество.

Чтобы всё сделать по науке мы познакомимся такой вещью, как фикстуры - классы, которые автоматизируют создание записей и используется для тестов. Я познакомился с этим понятием относительно недавно в одном из манов от symfonycast. Но обо всём по порядку. Сначала установим свежую копию Symfony. Устанавливать буду пакет symfony/skeleton - без дополнительных библиотек. Как это сделать я описывал в одном из первых уроков на канале, там всё максимально подробно и с картинками :) Привожу только листинг:

composer create-project symfony/skeleton autoselect-backend
composer require symfony/web-server-bundle --dev

Первая команда накатит Symfony, вторая - dev-сервер.

Дальше - установим и настроим базу данных. В качестве БД возьмём mysql. Установка:

sudo apt install mysql-server
sudo mysql_secure_installation

Вторая команда запросит ввести пароль для root-пользователя. Тут на вкус и цвет. Теперь создадим пользователя, базу и дадим этому пользователю права на эту базу:

sudo mysql -u root -p
create database autoselect-db;
create user 'autoselect-user'@'localhost' identified by 'test1234';
grant all privileges on autoselect_db.* to 'autoselect-user'@'localhost';
flush privileges;
quit;

Соответственно, база называется 'autoselect-db', пользователь - 'autoselect-user', пароль для него - 'test1234'. Проверим, что права этому пользователю выданы корректно и он видит базу:

mysql -u autoselect-user -p
show databases;
quit;

Теперь поставим пакеты для symfony, чтобы играться с базой через ORM:

composer require symfony/orm-pack
composer require --dev symfony/maker-bundle

Откроем файл .env и настроим подключение:

DATABASE_URL=mysql://autoselect-user:test1234@127.0.0.1:3306/autoselect_db

Чтобы протестить, посмотрим, что скажет symfony на запрос корректности схемы данных:

bin/console d:s:v

Лично у меня появилась ошибка, что отсутствует драйвер подключения к БД. Всё верно, забыли накатить адаптер php к mysql. Исправим:

sudo apt install php-mysql

Ещё раз:

bin/console d:s:v
Всё отлично :)

Итак, у нас есть проект, есть подключенная к нему база. Давай создадим сущность Product с уже упомянутыми выше полями:

  • id
  • название
  • дата поступления
  • количество

Чтобы создать сущность через консольку - нужно установить 'maker-bundle' - бандл, через который можно удобно создавать команды, контроллеры, сущности и всякое:

composer require symfony/maker-bundle

Теперь можем создать сущность Product:

bin/console make:entity

Дальше вводим название и перечисляем поля. В моём случае это выглядит как-то так:

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

bin/console d:s:u --force

Теперь напишем фикстуру, которая будет проводить махинации с базой. Фикстура, как я уже сказал выше, этот некоторый метод, заполняющий базу фэйковыми данными. За подробностями можешь заглянуть в документацию. А мы тем временем установим пакет, чтобы начать пользоваться фикстурами:

composer require --dev orm-fixtures

Далее, через только что установленный maker-bundle создадим фикстуру:

bin/console make:fixture

Посмотрим, что получилось:

<?php

namespace App\DataFixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

class ProductFixture extends Fixture
{
    public function load(ObjectManager $manager)
    {
        // $product = new Product();
        // $manager->persist($product);

        $manager->flush();
    }
}

Метод load будет вызываться при старте фикстуры. В нём создадим обычным циклом 20 объектов, которые заполним рандомными данными. Чтобы было интересней, я установлю и использую пакет, который был использован в мане от symfonycast - faker. Он умеет много чего, но мы его заюзаем для генерации красивых рандомных имён (абсолютно бесполезно, но чертовски весело):

composer require fzaninotto/faker --dev

Инициализируем:

protected $faker;

public function __construct()
{
    $this->faker = Factory::create();
}

И теперь определяем метод load:

public function load(ObjectManager $manager)
{
    for ($i = 0; $i < 20; $i++) {

        $product = new Product();
        $product->setName($this->faker->company)
            ->setAddDate($this->faker->dateTime)
            ->setCount(rand(10, 100));
        $manager->persist($product);

        try {
            $manager->flush();
        } catch (\Exception $exception) {
            echo $exception->getMessage() . PHP_EOL;
        }
    }
}

Далее вызовем фикстуру и посмотрим, что появится в базе:

php bin/console doctrine:fixtures:load
Если твой английский такой же, как и у меня - то просто запомни, что фикстур удаляет все данные из базы, поэтому использовать их нужно только на этапе тестов.

Теперь давай откроем базу прямо в PhpStorm и посмотрим, что получилось. Нажимаем на Database -> "+" -> Data Source -> Mysql. Вводим логин, пароль и название базы. База появится в списке, и дальше может быть 2 варианта: либо как у меня, либо по-человечески :) Если второй - всё будет корректно и можно сразу посмотреть, что находится в таблице Product. Если как у меня, то нужно нажать правой клавишей по базе, затем Database Tools -> Manage Shown Schemas.. -> поставить галочку на схему с названием нашей БД:

Еебой, в базе 20 записей с рандомными именами - то, что нам и нужно было.

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

bin/console make:controller


Начнём с репозитория. Переходим в него и описываем новый метод:

/**
 * @return Product[] Returns an array of Product objects
 */
public function findByFilter(string $filter)
{
    return $this->createQueryBuilder('p')
        ->andWhere('p.name like :filter')
        ->setParameter('filter', '%' . $filter . '%')
        ->orderBy('p.name', 'ASC')
        ->getQuery()
        ->getArrayResult();
}

'like' в mysql берёт соответствие поля слева к переменной справа. в setParameter мы указали символ '%' с двух сторон от переданной переменной - это значит, что like будет искать слово, которое совпадает с переданным фильтром и можем иметь дополнительные символы справа и слева от фильтра. Например, в таблице у нас 5 строк:

  • 11aoa11
  • 12aoa12
  • bob
  • jon
  • 11a11

В качестве фильтра мы передали комбинацию 'aoa'. В ответ метод вернёт 1 и 2-ую строку, так как в них найден фильтр. Всё просто. Теперь переходим в созданный класс с контроллерами и описываем новый контроллер:

/**
 * @Route("/product", name="product", methods="GET")
 */
public function getProducts(Request $request, ProductRepository $productRepository)
{
    $filter = $request->query->get('filter');
    $products = $productRepository->findByFilter($filter);

    return $this->json([
        'products' => $products,
    ]);
}

Теперь нужно проверить, что в итоге вернётся. Для тестовых запросов как нельзя лучше подходит такая программа, как Postman. Как скачивать и устанавливать описывать не буду - всё описано на официальном сайте :)

Запускаем сервер:

bin/console server:start

И открываем Postman. Добавляем новый запрос, вводим точку входа, на которую хотим ударить и указываем значение фильтра. Тип запроса - GET:

Интересно.. Давай проверим, правда ли в базе только одно название, где есть комбинация буква "na"?

Либо я плохо смотрю, либо и правда фикстура сгенерировала только одну такую строку. А вот комбинацию букв "co" я вижу в нескольких строках. Давай посмотрим, что вернёт запрос с таким фильтром:

http://127.0.0.1:8000/product?filter=co

Воу, целых 3 объекта! Ну, я думаю, что данные мы успешно мокнули и подготовили бэк к взаимодействию с фронтом.

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

npm run server

Иии.. в браузере открылась наша страничка:

В прошлый раз код компонента выглядел так:

<template>
  <div class="autocomplete">
    <input
            type="text"
            v-model="filter"
            @keypress="filterOnKeyPress"
    >
  </div>
</template>

<script>
  export default {
    data: function() {
      return {
        filter: null
      }
    },
    methods: {
      filterOnKeyPress() {
        // eslint-disable-next-line no-console
        console.log(this.filter);
      }
    }
  }
</script>

Теперь нам нужно сделать так, чтобы значение, введённое в текстовое поле передавалось на сервер в виде фильтра в ту точку входа (читай контроллер), которую мы создали выше. Ответ мы будем парсить и выводить списком под текстовым полем.

Есть несколько библиотек, которые позволяют отправлять запросы с клиентской части на серверную. Я не буду придумывать велосипед и почти целиком возьму пример из этой статьи документации - запросы будем отправлять с помощью библиотеки axios, а функцию обернём в метод debounce из библиотеки lodash. debounce создаёт ссылку на обёрнутый метод и устанавливает интервал обращений к нему. Допустим, мы обернули функцию, выводящую сообщение в консоль и установили интервал в 1 секунду. В этом случае, даже если мы будем вызывать функцию 10 раз за секунду, она сработает 1 раз - по интервалу. Для нашего кейса это крайне удобно, чтобы не отсылать запросы на каждое нажатие клавиш и поднимать кучу копий php-fpm на каждый запрос.

Устанавливаем axios и lodash:

npm install axios lodash

Теперь импортируем их в компонент и пишем логику запроса:

<template>
    <div class="autocomplete">
        <input
                type="text"
                v-model="filter"
                @keypress="filterOnKeyPress"
        >
    </div>
</template>

<script>
    import axios from "axios";
    import _ from "lodash";

    export default {
        created: function () {
            this.debouncedGetProductByFilter = _.debounce(this.getProductByFilter, 500)
        },
        data: function () {
            return {
                filter: null,
                products: [],
                error: null
            }
        },
        methods: {
            filterOnKeyPress: async function () {
                await this.debouncedGetProductByFilter();
                // eslint-disable-next-line no-console
                console.log(this.products);
            },
            getProductByFilter: async function () {
                axios.get(`http://127.0.0.1:8000/product?filter=${this.filter}`)
                    .then((response) => {
                        this.products = response.data.products;
                    })
                    .catch((error) => {
                        this.error = 'Ошибка! Не могу связаться с API. ' + error
                    })
            }
        },
    }
</script>

В created компонента мы оборачиваем функцию getProductByFilter в debounce и ставим интервал в 500 мс. getProductByFilter бьёт запрос на наш бэк-сервер и передаёт фильтр, введённый в текстовое поле. Если ответ будет без ошибок - в массив products запишутся продукты по фильтру. Если с ошибками - в поле error будет ошибка. Её мы обработаем чуть позже.

Давай попробуем:

И мы успешно ловим ошибку CORS'а. Что такое CORS - тема для отдельной статьи. Сейчас можно его описать как некая минимальная прослойка защиты нашего бэка, не позволяющая левым запросам с подозрительными заголовками стучаться на наш бэк. Чтобы это исправить - нужно изменить немного настроить конфиги приложения. Но сначала надо установить пакет, который умеет разбираться с cors-политикой:

composer require cors 

После чего в конфигах появится новый - nelmio_cors.yaml:

Открываем и изменяем содержимое на следующее:

nelmio_cors:
    defaults:
        origin_regex: true
        allow_origin: ['*']
        allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
        allow_headers: ['*']
        max_age: 3600
    paths:
        '^/': ~

Если очень грубо - даём разрешение на любые заголовки с любого домена.

Теперь снова переходим в браузер и пробуем вбить какой-либо фильтр:

Ура! Теперь можно рассказать пацанам во дворе, что ты пишешь сложные REST-API :)

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

<template>
    <div class="autocomplete">
        <input
                type="text"
                v-model="filter"
                @keypress="filterOnKeyPress"
        >
        <template v-if="null!==error">
            {{error}}
        </template>
        <template v-else>
            <ul>
                <li v-for="product in products">
                    {{product.name}}
                </li>
            </ul>
        </template>
    </div>
</template>

<script>
    import axios from "axios";
    import _ from "lodash";

    export default {
        created: function () {
            this.debouncedGetProductByFilter = _.debounce(this.getProductByFilter, 500)
        },
        data: function () {
            return {
                filter: null,
                products: [],
                error: null
            }
        },
        methods: {
            filterOnKeyPress: async function () {
                await this.debouncedGetProductByFilter();
                // eslint-disable-next-line no-console

            },
            getProductByFilter: async function () {
                axios.get(`http://127.0.0.1:8000/product?filter=${this.filter}`)
                    .then((response) => {
                        this.error = null;
                        this.products = response.data.products;
                    })
                    .catch((error) => {
                        this.error = 'Ошибка! Не могу связаться с API. ' + error
                    })
            }
        },
    }
</script>

Итак, смотрим как это работает:

Ну вот и всё :) Давай подведём итоги того, чему мы сегодня научились:

  • Развернули проект с минимальной базы, подтягивая руками все пакеты
  • Научились работать с БД в IDE
  • Научились писать фикстуры и использовать рандомайзер имён
  • Затронули библиотеку lodash и axios, научились лимитировать обращения к методам, отправлять запросы и получать ответ от бэка
  • Немного поигрались с CORS-политикой
Спасибо всем за обратную связь и за то, что это читаете :)













Report Page