Создание 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: '^/': ~
Если очень грубо - даём разрешение на любые заголовки с любого домена.
Теперь снова переходим в браузер и пробуем вбить какой-либо фильтр:
Ответ получен, аллилуйя. Осталось всего ничего - распарсить и вывести его списком под текстовым полем и не забыть обработать ошибку:
<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-политикой