Доработать приложение чтобы список на главной странице формировался на основании данных их БД.
Ссылки пока просто добавить, с пустыми href
. Добавить картинку. Переверстать на флексы, чтобы получилось как-то так:
Доработать приложение чтобы список на главной странице формировался на основании данных их БД.
Ссылки пока просто добавить, с пустыми href
. Добавить картинку. Переверстать на флексы, чтобы получилось как-то так:
Сделать чтобы
Картинка
и Описание
тоже брали данные из БДПеределать ObjectController.php
так чтобы он содержал логику и для ObjectImageController.php
и ObjectInfoController.php
, то есть принятие решения о том показывать картинку, краткую информацию или полную принималось по get параметрам. Например:
http://localhost:9007/space-object/1
– общая информацияhttp://localhost:9007/space-object/1?show=image
– показывает картинкуhttp://localhost:9007/space-object/1?show=info
– показывает полную информациювсех ссылки обновить соответствующим образом. Избавиться от контроллеров ObjectImageController.php
и ObjectInfoController.php
Доделать:
Как-то так:
Тут рассмотрим удаление объектов. Все так просто, что я даже задание придумать не могу 😢
Реализовать возможность редактировать объекты
[Необязательная] (обязательная для Конышев, Синчук) Реализовать REST-api контроллер
[Необязательная] Переписать взаимодействие с базой на Eloquent ORM
CRUD – это сокращение, от Create-Read-Update-Delete, то бишь Создавай-Читай-Обновляй-Удаляй. Набор стандартных операций, которые можно выполнять с данными.
В прошлой лабе мы довели систему до состояние, когда у нас четко выделены контролеры и представления, то есть это буквы VC из аббревиатуры MVС. Осталось теперь откуда-то взять модель, то бишь данные.
И так, в laragon помимо php встроен еще mysql, то есть система управления базой данных (скрщн СУБД).
А база данных – это на самом деле просто файлик или папка с файлами, в которой особым образом хранятся данные.
А система управления – это специальная программа, которая умеет в эту самую базу данных лазить и эти данные вытаскивать.
Причем у системы управления есть еще всякая дополнительная информация о данных, какие чаще используются, всякие проверки доступа и прочие штуки которые расширяют границы возможностей для манипуляции данными.
Тут мы создадим простую базу данных с одной табличкой, в которую запихаем несколько объектов. Объекты те же с которыми работали раньше. Только там все в файлах писали а тут будем в БД хранить. У меня это Андромеда и Орион.
Но чтобы все наши манипуляции с данными можно было бы выполнять более удобно, то, поставим сначала phpMyAdmin. Этот phpMyAdmin – это просто веб-приложение, написанное на php, из которого можно удобно управлять базой данных.
Идем на сайт https://www.phpmyadmin.net и качаем
сохраняем в папку /etc/apps
:
и распаковываем:
затем переименовываем
Теперь можно зайти по адресу http://localhost/phpMyAdmin/ и увидить:
но пока не будем внутрь заходить, так как БД не настроена.
Сначала разберемся с MySQL, в laragon встроена весьма устаревшая версия с которой phpMyAdmin работать уже не умеет.
С другой стороны если попытаться обновить MySQL то более менее адекватная версия весит примерно 800MB, что конечно перебор.
Поэтому мы воспользуемся альтернативой СУБД mysql, которая называется mariadb. Она полностью совместима с mysql и разрабатывается как полностью бесплатная альтернатива mysql с открытым исходным кодом. Часто включена во всякие linux-дистрибутивы по умолчанию.
В общем, идем сюда https://downloads.mariadb.org и тыкаем download
выбираем zip архив
и сохраняем в папку
распаковываем
затем тормозим laragon
переключаем mysql
на mariadb
И все запускаем обратно.
Теперь собственно можно заходить через phpMyAdmin. Идем сюда http://localhost/phpMyAdmin/
и вводим данные для входа (юзер: root, пароль пустой):
и видим такое:
тыкаем слева Создать БД
пишем название БД (я назову ее outer_space
) и выбираем кодировку utf8mb4_unicode_ci
, почему ее? Можно прочитать тут: https://ru.stackoverflow.com/questions/757733/
Вот так, и тыкаем создать:
нам сообщат что база создана, теперь добавим в нее табличку
количество столбцов особенно ни на что не влияет, все равно потом можно добавить новые или убрать лишние.
в общем, когда все пропишете можно тыкать сохранить и приступить к заполнению:
вводим данные и тыкаем Вперед
получаем такой результат
снова тыкаем Вставить
и повторяем процедуру. В принципе нам хватит штук 5:
Для общения с базой данных в php встроен специальный класс PDO.
Через него можно подключаться к большинству популярных баз данных и выполнять разные запросы.
Чтобы начать общаться с базой, необходимо создать соединение. Делается это очень просто, идем в index.php
и пишем:
<?php
// ...
$controller = new Controller404($twig);
// создаем экземпляр класса и передаем в него параметры подключения
// создание класса автоматом открывает соединение
$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");
// дальше не трогаем
if ($url == "/") {
$controller = new MainController($twig);
} elseif (preg_match("#^/andromeda/image#", $url)) {
// ...
смысл значений такой:
Пробуем открыть главную страницу, если там ничего не поменялось, то значит подключились успешно:
а если подключение не сработает, то увидим какую-то ошибку, и тут надо уже разбираться по ситуации
в общем считаем что у нас все ок.
Чтобы из контроллера можно было бы обращаться к базе, добавим в BaseController
поле $pdo
и сеттер для него
<?php
abstract class BaseController {
public PDO $pdo; // добавил поле
public function setPDO(PDO $pdo) { // и сеттер для него
$this->pdo = $pdo;
}
// остальное не трогаем
public function getContext(): array {
return [];
}
abstract public function get();
}
и теперь добавим вызов этого метода перед вызовом get
<?php
// ...
// не трогаем тут
$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");
if ($url == "/") {
// ...
}
if ($controller) {
$controller->setPDO($pdo); // а тут передаем PDO в контроллер
$controller->get();
}
Давайте попробуем вывести данные из нашей таблички на главную страницу. Идем в MainController
<?php
require_once "TwigBaseController.php";
class MainController extends TwigBaseController {
public $template = "main.twig";
public $title = "Главная";
// добавим метод getContext()
public function getContext(): array
{
$context = parent::getContext();
// подготавливаем запрос SELECT * FROM space_objects
// вообще звездочку не рекомендуется использовать, но на первый раз пойдет
$query = $this->pdo->query("SELECT * FROM space_objects");
// стягиваем данные через fetchAll() и сохраняем результат в контекст
$context['space_objects'] = $query->fetchAll();
return $context;
}
}
теперь пойдем в main.php
и посмотрим, что оказалось в space_objects
. Добавим туда:
{% block content %}
<pre>
{{ dump(space_objects) }} <!-- добавил вывод данных -->
</pre>
<ul class="list-group">
<!-- ... -->
</ul>
{% endblock %}
правда если теперь запустить страницу, то увидим ошибку
тут дело в том, что в twig есть debug режим, который по умолчанию отключен и доступен только если активировать его вручную. И специальная функция dump доступна только в этом режиме.
Поэтому нам надо его сначала включить. Делается это достаточно просто. Идем в index.php
<?php
// ...
$url = $_SERVER["REQUEST_URI"];
$loader = new \Twig\Loader\FilesystemLoader('../views');
$twig = new \Twig\Environment($loader, [
"debug" => true // добавляем тут debug режим
]);
$twig->addExtension(new \Twig\Extension\DebugExtension()); // и активируем расширение
обновляем страницу
о! данные пошли! =)
Вообще если на них посмотреть, то мы увидим:
Давай выведем название первого элемента:
{% extends "__layout.twig" %}
{% block content %}
Название: {{ space_objects[0].title }}
<!-- ... -->
получится так:
но вообще так как мы запросили список объектов то и для вывода имеет смысл выводить все элементы, причем делать через уже знакомый нам for:
{% extends "__layout.twig" %}
{% block content %}
{% for object in space_objects %}
Название: {{ object.title }} <br>
{% endfor %}
<!-- ... -->
и тогда получится уже так:
ну думаю идея понятна, теперь можно и задание поделать =)
Будем разбираться с ссылками. Начнём с большой синей кнопки, которая должна открывать страницу с общей информаций об объекте.
Раньше у нас было по отдельному контроллеру и по отдельному шаблону на каждую страницу. Собственно, AndromedaController.php
и OrionController.php
.
Ну и когда объектов всего две штуки это в принципе нормально работает.
Но если вы работаете с БД, то у вас объектов может быть очень много и даже наши 5 штук уже заставляют задуматься о целесообразности такого подхода.
И что же делать?
А мы просто делаем один шаблон и один контроллер, внутри которого принимаем решение какой объект выводить. Что же касается того, как определить какой объект показывать, тут приходит на помощь возможность задавать шаблон url.
То есть например сейчас когда мы хотим зайти на страницу Андромеды, то мы используем url /andromeda
, если хотим посмотреть Орион,то идем на /orion
. Сейчас у нас объектов стало больше и на всех имен уже не напасёшься (хотя есть способы конечно).
Поэтому можно использовать более универсальный подход. У нас у каждого объекта есть уникальный номер, который хранится в поле id
мы можем использовать его вместо явного имени в ссылке, и заходить в Галактику Андромеды, например по адресу /space-object/1
, в галактику Сигара – /space-object/4
. Таким образом получается шаблон вида:
/space-object/{id}
где id – это идентификатор объекта, для того чтобы можно было сопоставлять шаблон с адресом url, нам потребуется опять воспользоваться регулярным выражением.
С помощью регулярки мы можем описать такой шаблон следующим образом:
/space-object/\d+
\d+
- означает одна и более символов цифр идущих подряд (то есть по сути просто любое число)
Такое выражение будет соответствовать например таким комбинациям
что почти подходит, но я все же хочу чтобы проверка шла целиком всей строки, для этого я добавлю символы начала ^
и конца $
строки в регулярку, вот так:
^/space-object/\d+$
тогда совпадать будут уже только 3 первые строки
и еще я хочу как-то получить доступ собственно к числу.
Для этого в регулярном выражении можно обозначить кусок шаблона круглыми скобками и тогда можно будет получить доступ к тексту в выделенном блоке. Делаем регулярку вот такой:
^/space-objects/(\d+)$
и смотрим как его распознает система:
В принципе то что надо. Попробуем это реализовать, но сначала
Идем в index.php и удаляем все из роутера:
<?php
// ...
// оставляем только главную страницу
if ($url == "/") {
$controller = new MainController($twig);
}
// ...
я хочу чтобы роутер у меня стал отдельным классом. В который можно надобавлять url и соответствующих им контроллеров. А роутер будучи уже классом сам внутри будет принимать решение какой Controller под текущий url выбрать.
Но надо придумать где этот класс расположить.
Так как то, что мы по сути делаем это создаем микрофреймворк. Давайте создадим папку framework. И перенесем в нее BaseController.php
, TwigBaseController.php
теперь создадим файл autoload.php, чтобы нам не пришлось вручную прописывать пути к этим файлам, пишем в нем
<?php
spl_autoload_register(function($class) {
$fn = __DIR__ . '\\' . $class . '.php';
if (file_exists($fn)) {
require_once $fn;
}
});
идея этого файла такая: тут используется специальная функция spl_autoload_register
, которая регистрирует функцию которая будет вызываться когда в коде php будет упоминаться класс который не импортирован явно.
Собственно, каждый раз, когда, где-то идет обращение к такому неивзестному классу буде происходить вызов функции
function($class) {
$fn = __DIR__ . '\\' . $class . '.php';
if (file_exists($fn)) {
require_once $fn;
}
}
функция формирует абсолютный путь к файлу, то есть в $class приходит имя класса, например, TwigBaseController
,
в __DIR__
– путь к папке в которой лежит autoload.php
через точку в PHP можно склеивать строки, то есть __DIR__ . '\\' . $class . '.php';
даст нам абсолютный путь к файлу TwigBaseController.php
что-то вроде C:\Users\m\Desktop\php_01\framework\TwigBaseController.php
ну и дальше мы проверяем существует ли такой файл, и если он существует, то подключаем его.
И теперь нам надо везде убрать require_once с TwigBaseController
и добавить autoload в index.php
по идее если запустить, то главная страница должна работать как обычно:
Создаем файлик Router.php
в папке framework
и пишем в нем:
<?php
// сначала создадим класс под один маршрут
class Route {
public string $route_regexp; // тут получается шаблона url
public $controller; // а это класс контроллера
// ну и просто конструктор
public function __construct($route_regexp, $controller)
{
$this->route_regexp = $route_regexp;
$this->controller = $controller;
}
}
теперь сам роутер:
<?php
class Route {
// ...
}
class Router {
/**
* @var Route[]
*/
protected $routes = []; // создаем поле -- список под маршруты и привязанные к ним контроллеры
protected $twig; // переменные под twig и pdo
protected $pdo;
// конструктор
public function __construct($twig, $pdo)
{
$this->twig = $twig;
$this->pdo = $pdo;
}
// функция с помощью которой добавляем маршрут
public function add($route_regexp, $controller) {
// по сути просто пихает маршрут с привязанным контроллером в $routes
array_push($this->routes, new Route($route_regexp, $controller));
}
// функция которая должна по url найти маршрут и вызывать его функцию get
// если маршрут не найден, то будет использоваться контроллер по умолчанию
public function get_or_default($default_controller) {
$url = $_SERVER["REQUEST_URI"]; // получили url
// фиксируем в контроллер $default_controller
$controller = $default_controller;
// проходим по списку $routes
foreach($this->routes as $route) {
// проверяем подходит ли маршрут под шаблон
if (preg_match($route->route_regexp, $url)) {
// если подходит, то фиксируем привязанные к шаблону контроллер
$controller = $route->controller;
// и выходим из цикла
break;
}
}
// создаем экземпляр контроллера
$controllerInstance = new $controller();
// передаем в него pdo
$controllerInstance->setPDO($this->pdo);
// вызываем
return $controllerInstance->get();
}
}
тут не совсем понятно, как twig передать, так как в TwigBaseController.php он передавался через конструктор,
Давайте пойдем в TwigBaseController.php и будем его также передавать через setter:
<?php
require_once "BaseController.php";
class TwigBaseController extends BaseController {
public $title = "";
public $template = "";
protected \Twig\Environment $twig;
// убираем
// public function __construct($twig)
// {
// $this->twig = $twig;
// }
// добавляем
public function setTwig($twig) {
$this->twig = $twig;
}
public function getContext() : array
{
// ...
}
public function get() {
// ...
}
}
возвращаемся обратно в Router и добавляем в нем:
class Router {
// ...
public function get_or_default($default_controller) {
// ...
$controllerInstance = new $controller();
$controllerInstance->setPDO($this->pdo);
// проверяем не является ли controllerInstance наследником TwigBaseController
// и если является, то передает в него twig
if ($controllerInstance instanceof TwigBaseController) {
$controllerInstance->setTwig($this->twig);
}
// вызываем
return $controllerInstance->get();
}
}
тут опять много всего вычищаем, пишем:
<?php
// ...
require_once "../controllers/Controller404.php";
// $url = $_SERVER["REQUEST_URI"]; УБИРАЕМ
$loader = new \Twig\Loader\FilesystemLoader('../views');
$twig = new \Twig\Environment($loader, [
"debug" => true
]);
$twig->addExtension(new \Twig\Extension\DebugExtension());
// $controller = new Controller404($twig); УБИРАЕМ
$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");
$router = new Router($twig, $pdo);
$router->add("#/#", MainController::class);
$router->get_or_default(Controller404::class);
/*убираем
if ($url == "/") {
$controller = new MainController($twig);
}
if ($controller) {
$controller->setPDO($pdo);
$controller->get();
}
*/
останется вот так:
<?php
// ...
require_once "../controllers/Controller404.php";
$loader = new \Twig\Loader\FilesystemLoader('../views');
$twig = new \Twig\Environment($loader, [
"debug" => true
]);
$twig->addExtension(new \Twig\Extension\DebugExtension());
$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");
$router = new Router($twig, $pdo);
$router->add("#/#", MainController::class);
$router->get_or_default(Controller404::class);
тестируем
красота! =)
Давайте попробуем добавить еще какой-нибудь url:
<?php
// ...
$router = new Router($twig, $pdo);
$router->add("#/#", MainController::class);
$router->add("#/andromeda#", AndromedaController::class);
$router->get_or_default(Controller404::class);
тестим:
не срабатывает…
А! тут опять проблема с порядком, он находит первый попавшийся. Давайте сделаем так чтобы он всегда искал шаблон на полное соответствие. По идее надо писать так:
$router = new Router($twig, $pdo);
$router->add("#^/$#", MainController::class);
$router->add("#^/andromeda$#", AndromedaController::class);
тогда начинает работать:
вроде отлично, но приходится постоянно писать в начале #^
и в конце $#
, пусть это автоматом добавляется в add, идем в Router.php
и подкручиваем там:
class Router {
// ...
public function add($route_regexp, $controller) {
// обернул тут в #^ и $#
array_push($this->routes, new Route("#^$route_regexp$#", $controller));
}
и теперь в index.php оставляем так:
$router = new Router($twig, $pdo);
$router->add("/", MainController::class);
$router->add("/andromeda", AndromedaController::class);
Так ведь куда проще читать и писать.
Идем в папку controllers и создаем файлик ObjectController.php
и загоняем в него болванку twig контроллера
смотрите, у нас в принципе шаблон под object уже есть, осталось взять данные из БД
<?php
class ObjectController extends TwigBaseController {
public $template = "__object.twig"; // указываем шаблон
public function getContext(): array
{
$context = parent::getContext();
// готовим запрос к БД, допустим вытащим запись по id=3
// тут уже указываю конкретные поля, там более грамотно
$query = $this->pdo->query("SELECT description, id FROM space_objects WHERE id=3");
// стягиваем одну строчку из базы
$data = $query->fetch();
// передаем описание из БД в контекст
$context['description'] = $data['description'];
return $context;
}
}
теперь подключим это контроллер к роутеру в index.php
:
<?php
require_once "../vendor/autoload.php";
require_once "../framework/autoload.php";
require_once "../controllers/MainController.php";
require_once "../controllers/ObjectController.php"; // добавил
// ...
$router = new Router($twig, $pdo);
$router->add("/", MainController::class);
$router->add("/andromeda", AndromedaController::class);
// помните нашу регулярку, которую выше, делали, собственно вот сюда ее и загнали
$router->add("/space-object/(\d+)", ObjectController::class);
$router->get_or_default(Controller404::class);
теперь попробуем открыть ссылку со space-object, у меня http://localhost:9007/space-object/3
смотрим в браузер:
а затем в phpMyAdmin
ну вроде подцепилась =)
А теперь попробуем с другим id, например: http://localhost:9007/space-object/1
ну чет тоже самое выводит. То есть нам надо как-то этот идентификатор передать в контроллер.
Идем в Router.php
и добавляем переменную matches вот так:
идея этой переменной следующая: когда у вас в регулярке /space-object/(\d+)
присутствуют круглые скобочки, то та часть строки которая будет соответствовать тому что находится в скобках пойдет в matches, то есть если у вас всего одни набор скобочек то значение пойдет как второй элемент массива matches[1]
.
Ну то есть если у нас строка /space-object/42
, то
matches[0]
будет /space-object/42
,matches[1]
будет 42
Если бы у нас было /space-object/(\d+)/(\w+)
, а url вида /space-object/42/andromeda
то
matches[0]
было бы /space-object/42/andromeda
,matches[1]
– 42
matches[2]
– andromeda
Собственно надо этот matches передать в controller. Идем в BaseController и добавляем поле params`:
<?php
abstract class BaseController {
public PDO $pdo;
public array $params; // добавил поле
// добавил сеттер
public function setParams(array $params) {
$this->params = $params;
}
// ...
}
ну и вызываем эту функцию в Router.php
public function get_or_default($default_controller) {
// ...
$controllerInstance = new $controller();
$controllerInstance->setPDO($this->pdo);
$controllerInstance->setParams($matches); // передаем параметров
if ($controllerInstance instanceof TwigBaseController) {
$controllerInstance->setTwig($this->twig);
}
return $controllerInstance->get();
}
теперь давайте глянем что там оказывается, когда вызывается метод getContext, идем в ObjectController и вставляем туда:
<?php
class ObjectController extends TwigBaseController {
public $template = "__object.twig";
public function getContext(): array
{
$context = parent::getContext();
// добавил вывод params
echo "<pre>";
print_r($this->params);
echo "</pre>";
// ...
return $context;
}
}
смотрим
как я и обещал в $this->params
оказалась циферка из запроса.
Кстати в регулярках можно добавить имя к подвыражению, для этого надо после открывающей скобки добавить ?P<param_name>
, вот так
и теперь если перегрузить страницу мы увидим, что появился дополнительный ключ у словарика:
давайте теперь этим параметром воспользуемся.
пробуем:
невероятно! =О
Вот мы сейчас формируем запрос путем простой склейки строк. Потенциально это дыра в безопасности, потому что если по какой-то причине мы сделаем кривую регулярку, то возможный злоумышленник сможет подставить вместо цифры любое выражение, в том числе и запрос к БД.
Вот вам пример. Допустим мы сделали такую регулярку
$router->add("/space-object/(?P<id>.*)", ObjectController::class);
.*
– означает набор символов любой длины. Такое часто используется, когда надо вывести просто какое-то произвольное название чтобы url был более читаемым
Запрос http://localhost:9007/space-object/1 сработает как обычно:
А теперь приходит какой-то нехороший человек и пишет
и вот у вас внутри уже получается двойной запрос:
SELECT description, id FROM space_objects WHERE id=1; UPDATE space_objects SET title = title + '1' WHERE id = 3;
и если мы как-то неудачно дергаем данные может получится что произойдет обновление данных, а то еще и удаление. Такая атака называется SQL Injection, то есть sql инъекция
Поэтому, когда вы формируете запрос вы никогда не должны использовать простую склейку строк. Создатели PDO знают про это и поэтому предлагают альтернативный способ передачи параметров. Используется она так:
// создам запрос, под параметр создаем переменную my_id в запросе
$query = $this->pdo->prepare("SELECT description, id FROM space_objects WHERE id= :my_id");
// подвязываем значение в my_id
$query->bindValue("my_id", $this->params['id']);
$query->execute(); // выполняем запрос
// тянем данные
$data = $query->fetch();
работать будет так же:
Идем в main.twig
и заменяем там hrefы
и пробуем
В url помимо основной части адреса типа http://localhost:9007/space-object/1
часто встречается дополнительные элементы, которые добавляются в конец после знака вопроса:
http://localhost:9007/space-object/1?sort=title&filter=Галактика
это так называемые get параметры. Они представляют собой набор ключей и значений, которые разбиваются знаком амперсанда &
Используются как правило для уточнения поведения базового запроса, для всяких сортировок фильтраций и т.п.
У нас сейчас верхняя навигация не задействована. Точнее там есть какие-то пункты, но они уже не работают.
Давайте будем использовать ее, чтобы можно было выбрать какие типы объектов я хочу посмотреть. Например, у меня есть туманности, а есть галактики. И я хочу сделать чтобы кликая на одну ссылку показывались туманности, а на другую галактики.
Но сначала надо подготовить данные. Давайте добавим в базу поле, в котором будет указана группа к которой принадлежит объект.
Идем в phpMyAdmin и добавляем поле
назову его type
теперь надо его заполнить у всех объектов. Для быстрого заполнения можно просто два раза тыкать на поле и вписывать значения:
Теперь надо сделать так чтобы при формировании строки навигации выводился список возможных типов. Так как меню должно присутствовать на всех страницах, есть два способа, правильный и быстрый =)
идея его проста, в twig мы можем определить переменную, которая будет доступна из любого шаблона. Для этого в index.php
надо сделать следующее
// ...
$twig->addExtension(new \Twig\Extension\DebugExtension());
$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");
// создаем запрос к БД
$query = $pdo->query("SELECT DISTINCT type FROM space_objects ORDER BY 1");
// стягиваем данные
$types = $query->fetchAll();
// создаем глобальную переменную в $twig, которая будет достпна из любого шаблона
$twig->addGlobal("types", $types);
$router = new Router($twig, $pdo);
// ...
теперь можно пойти в базовый шаблон __layout.twig
и добавить там цикл по типам:
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... -->
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="#"><i class="fas fa-meteor"></i></i></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<!-- Оставил ссылку на главную -->
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Главная</a>
</li>
<!-- остальные заменил на цикл по типам -->
{% for type in types %}
<li class="nav-item">
<!-- type.type -- выдает тип,
а добавление |title делает первую букву с слова большой -->
<a class="nav-link" href="#">{{ type.type|title }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<div class="container pt-3 pb-3">
{% block content %}
{% endblock %}
</div>
</body>
</html>
смотрим:
то есть значения из базы подцепились, правда ссылки пока еще не работают.
Чем плох этот способ? С точки зрения шаблона – все прекрасно. А вот с точки зрения кода, не очень. У нас index.php – это входная точка приложения, которая управляет высокими материями, роутером, подключением пакетов, создания соединения с базой данных, активация генератора шаблонов, ну и как бы все это связывает между собой.
И вот эта попытка сделать запрос к БД и передать что-то в twig, выглядит как будто мы на концерте симфонической музыке дуем в свисток. В общем, не уместно, плюс невозможно тестировать.
Поэтому
Значит, более корректный подход — это создать базовый контроллер для нашего приложения (не фреймворка – фреймворк, в идеале должен быть не зависим от темы проекта) и уже все остальные контроллеры наследовать от него.
Давайте это сделаем. Создаем файлик, я его назову BaseSpaceTwigController.php
и загоню код который был в index.php, но с небольшими правками
ну а из index.php убираем
ну и заменяем везде на TwigBaseController
на BaseSpaceTwigController
работает так же
но архитектурно более грамотно.
Сейчас я хочу сделать так, чтобы тыкая на элемент навигации срабатывал один и тот же контроллер. Но чтобы он понимал, что я хочу видеть объекты только какого-то специфического типа.
Как это сделать?
Для этого надо чтобы к ссылке, ведущей на главную страницу добавились параметры запроса. Делается это следующим образом. Идем в __layout.twig
и добавляем в href у элементов навигации параметры:
{% for type in types %}
<li class="nav-item">
<!-- добавил href="/?type={{ type.type }}" -->
<a class="nav-link" href="/?type={{ type.type }}">{{ type.type|title }}</a>
</li>
{% endfor %}
глянем как это выглядит
то есть у нас в запросе появляется часть ответственная за параметры, но роутер почему-то перестает распознавать путь
Почему так происходит?
Чтобы понять, давайте добавим вывод $_SERVER["REQUEST_URI"]
в роутере
class Router {
// ...
public function get_or_default($default_controller) {
$url = $_SERVER["REQUEST_URI"];
print_r($url); // добавил вывод
// ...
}
// ...
}
смотрим страницу
то есть PHP не отделяет за нас сам адрес, от параметров адреса. К счастью имеется функция которая умеет это делать. Зовется parse_url
– знает, как из строки url вытаскивать всякие отдельные куски, на вроде схемы, адреса сайта, полного адреса и прочих элементов, которых на самом деле не так уж и мало. Проверим как она работает:
public function get_or_default($default_controller) {
$url = $_SERVER["REQUEST_URI"];
$path = parse_url($url, PHP_URL_PATH); // вытаскиваем адрес
echo $path; // выводим
// ...
}
О как:
Но помимо самого адреса нам нужны еще и параметры.
А с параметрами все проще. Набор параметров в адресной строке называется параметрами GET запроса и доступны они через специальную переменную $_GET
, давайте глянем:
public function get_or_default($default_controller) {
$url = $_SERVER["REQUEST_URI"];
$path = parse_url($url, PHP_URL_PATH);
echo $path;
echo "<pre>"; // чтобы красивее выводил
print_r($_GET); // выведем содержимое $_GET
echo "</pre>";
// ...
}
смотрим:
мы можем через & добавить еще пару параметров, например, http://localhost:9007/?type=галактика&sort=123&my_array[0]=c&my_array[1]=b и глянуть что произойдет:
то есть этот $_GET
хранит в удобном виде параметры из адресной строки.
Так давайте сначала починим роутер, чтобы он игнорил параметры и тестировал на совпадение только сам адрес, вот так:
А теперь идем в MainController и правим там:
и тестим:
ляпота! =)
Теперь поизучаем работу с формами.
Форма — это специальный блок на странице который может содержать внутри разные поля для ввода, выпадающие списки, чекбоксы и прочие неведомые штуковины. Использовать можно как правило для двух вещей:
Сейчас рассмотрим формы в плане реализации хитрого фильтра.
И так, давайте сделаем страницу для поиска по нашей базе данных. Нам понадобится SearchController
и шаблон:
также привяжем его в index.php
$router->add("/search", SearchController::class);
и добавим ссылку на панель навигации
проверяем
Уии! =)
начнем с того что мы уже делали, с поиска по типу. В прошлый раз мы по сути просто создавали ссылки для фильтра по типу и делали это руками. Добавляли там через знак вопроса параметры, все дела…
А тут теперь пойдем другим путем. Сделаем форму, на которой будет выпадающий список, и кнопочка найти.
Идем в search.twig и пишем там:
{% extends "__layout.twig" %}
{% block content %}
Поиск
<form action="">
<select name="type">
<option value="галактика">Галактика</option>
<option value="туманность">Туманность</option>
</select>
<button type="submit">Найти</button>
</form>
{% endblock %}
смотрим как это выглядит:
теперь попробуем попереключать пункт меню и понажимать “Найти”
ООО!! Как видите, когда мы нажимаем на кнопку, у нас перегружается страница, а выбранное на форме значение попадает в get параметры. То есть больше не надо писать руками =О
Кстати, если мы в select в name укажем, например, не type, а какое-нибудь другое слово, например object_type, то уже оно будет использоваться в качестве ключа в get параметре. Ну а в качестве значения будет использоваться атрибут value из выбранного тега option
давайте добавим нашей форме еще какое-нибудь поле, например, для поиска по названию
<form action="">
<select name="object_type">
<option value="галактика">Галактика</option>
<option value="туманность">Туманность</option>
</select>
<label>Название <input type="text" name="title"></label>
<button type="submit">Найти</button>
</form>
тестим
то есть, если у нас несколько полей, то они в параметрах вверху объединяются через знак амперсанда.
В общем, я думаю принцип понятен. Давайте теперь доработает чтобы наш поиск начала нормально работать и выводил бы список ссылок на объекты удовлетворяющие условию.
Немного заумно, но думаю можно разобраться:
смотрим как работает:
ура! =)
Ну вот наконец-то мы и добрались до POST запросов. В отличие от GET запросов, информация о которых (ключи-значения) видна в адресной строке, данные у POST-запроса зашифрованы в тело запроса.
Также данные POST запросов не светятся в логах nginx сервера. Что уменьшает вероятность потенциального злоумышленника получить к ним данные. А вот GET запросы видны в логах в чистом виде.
Можно, например, глянуть лог nginx в laragon вот так:
и увидеть там прямо наши запросы которые мы делали, когда использовали поиск:
Также на длину адресной строки есть ограничения. Поэтому гонять длинные данные через GET запросы не выйдет.
Собственно, поэтому для передачи больших объемов данных придумали POST запросы. Которые позволяют в зашифрованном виде передавать практически ничем неограниченные объемы данных.
И именно для создания новых сущностей в БД мы будем их использовать.
И так создаем новый контроллер и назовем его SpaceObjectCreateController
<?php
require_once "BaseSpaceTwigController.php";
class SpaceObjectCreateController extends BaseSpaceTwigController {
public $template = "space_object_create.twig";
}
создадим ему форму в шаблоне, где можно прописать все поля (пока без картинки)
{% extends "__layout.twig" %}
{% block content %}
<h1>Добавление объекта глубокого космоса</h1>
<hr>
<form class="row g-3">
<div class="col-4">
<label class="form-label">Название</label>
<input type="text" class="form-control" name="title">
</div>
<div class="col-4">
<label class="form-label">Краткое описание</label>
<input type="text" class="form-control" name="description">
</div>
<div class="col-4">
<label class="form-label">Тип</label>
<select class="form-control" name="type">
<option value="галактика">Галактика</option>
<option value="туманность">Туманность</option>
</select>
</div>
<div class="col-12">
<textarea name="info" placeholder="Полное описание..." class="form-control" rows="5"></textarea>
</div>
<div class="col-12 text-end">
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
{% endblock %}
получится так,
можете попробовать как-нибудь по-другому скомпоновать. Добавить цвета. В общем, чтобы было веселее.
Добавим в роутер
$router->add("/space-object/create", SpaceObjectCreateController::class);
ну и ссылку в навигацию
Давайте попробуем заполнить поля и попробовать нажать на кнопку добавить:
как мы уже видели в прошлом задании, при клике на кнопку происходит перегрузка страницы, а значения с формы попадают в параметры.
Это стандартное поведение формы. На самом деле мы можем указать переход на другую страницу. Для этого мы должны поменять атрибут action у тега формы. Например, после создания объекта часто логично оказаться на странице с полным список объектов. Мы можем сделать это так:
давайте повторим попытку:
кстати у нас на главной странице добавлена обработка параметра type поэтому попытка создания пока срабатывает у нас как фильтр.
В общем это не совсем то что мы хотим. Так что давайте оставим атрибут action пустым:
пустой атрибут равносилен отсутствию.
И так, я хочу реализовать такое поведение, чтобы при нажатии на кнопку добавить я оказывался на той же странице, но чтобы мне там выводилось какое-нибудь сообщение о том что объект сохранен.
Плюс я не хочу, чтобы в адресной строке были видны какие-то данные.
Почему так важно, чтобы случайные данные не попали в адресную строку?
Дело в том, что в реальном приложении там могут оказываться не только такие безобидные данные как название или описание, там вполне могут оказаться пароли в открытом виде, номер телефонов, всякие секретные токены и т.п. И все эти данные потом оказываются в истории браузера, где их можно при желании посмотреть.
Как раз для таких целей и будем использовать POST запрос.
Чтобы форма при нажатии на кнопку отправляла POST запрос, надо тегу form добавить атрибут method
давайте попробуем теперь отправить форму:
выглядит как будто ничего не произошло. Но на самом деле был вызов контроллера SpaceObjectCreateController только метод был не GET а POST.
Это можно посмотреть в консольке (F12 или Ctrl+Shift+I)
то есть тут видно и метод запроса, и его параметры.
Надо вообще понимать, что всегда, когда вы в браузере просто руками набираете в адресной строке какой-нибудь url, то отправляется GET запрос. И в ответ приходит содержимое страницы как результат этого запроса.
Когда вы у формы у которой в method указано “POST” нажимаете кнопку с type=submit, то отправляется POST запрос. И в ответ приходит содержимое страницы как результат этого запроса.
Так как у нас сейчас контроллеры не умеют отличать POST запрос от GET запроса, то результат отправки формы полностью совпадают с реакцией на открытие страницы для добавления объекта.
Давайте научим наш контроллер различать виды запросов.
Сначала нам надо понять, как определить тип запроса. Для этого в глобальном объекте $_SERVER
есть ключ REQUEST_METHOD
, попробуем его вывести:
class SpaceObjectCreateController extends BaseSpaceTwigController {
public $template = "space_object_create.twig";
public function get()
{
echo $_SERVER['REQUEST_METHOD'];
parent::get();
}
}
тестим:
В принципе неплохо. Только получается что в независимости от типа запроса мы попадаем в функцию get, которая выводит тип запроса.
Наверное, было бы неплохо, чтобы у нас был бы отдельно метод get и отдельно метод post под разные типы запросов.
Идем в BaseController и добавляем там метод process_response
abstract class BaseController {
// ...
// новая функция
public function process_response() {
$method = $_SERVER['REQUEST_METHOD']; // вытаскиваем метод
if ($method == 'GET') { // если GET запрос то вызываем get
$this->get();
} else if ($method == 'POST') { // если POST запрос то вызываем get
$this->post();
}
}
// уберем тут abstract, и просто сделаем два пустых метода под get и post запросы
public function get() {}
public function post() {}
}
теперь пойдем в Router.php и поменяем там вызов метода get контроллера на вызов process_ response
class Router {
// ...
public function get_or_default($default_controller) {
// ...
return $controllerInstance->process_response(); // теперь тут process_response вместо get
}
}
попробуем теперь вызвать нашу форму
так как у нас нет обработчика у метода post он показывает пустую страницу.
Я могу, например, сделать чтобы метод post просто вызывал метод get, вот так:
class SpaceObjectCreateController extends BaseSpaceTwigController {
public $template = "space_object_create.twig";
public function get()
{
echo $_SERVER['REQUEST_METHOD'];
parent::get();
}
// добавил
public function post() {
$this->get(); // вызываю get
}
}
смотрим:
Как видим просто открывается та же страница.
Но допустим я хочу, чтобы выводилось какое-нибудь дополнительно сообщение, типа объект создан.
Тогда мне надо чтобы из метода post я могу подправить context контроллера и передать его в get.
Для этого давайте немного подкрутим BaseController
:
abstract class BaseController {
// ...
public function process_response() {
$method = $_SERVER['REQUEST_METHOD'];
$context = $this->getContext(); // вызываю context тут
if ($method == 'GET') {
$this->get($context); // а тут просто его пробрасываю внутрь
} else if ($method == 'POST') {
$this->post($context); // и здесь
}
}
public function get(array $context) {} // ну и сюда добавил в качестве параметра
public function post(array $context) {} // и сюда
}
подкрутим TwigBaseController.php
:
class TwigBaseController extends BaseController {
// ...
public function get(array $context) { // добавил аргумент в get
echo $this->twig->render($this->template, $context); // а тут поменяем getContext на просто $context
}
}
И обновим Controller404.php
class Controller404 extends BaseSpaceTwigController {
public $template = "404.twig";
public $title = "Страница не найдена";
public function get(array $context)
{
http_response_code(404);
parent::get($context);
}
}
теперь возвращаемся в SpaceObjectCreateController
и правим там:
class SpaceObjectCreateController extends BaseSpaceTwigController {
public $template = "space_object_create.twig";
public function get(array $context) // добавили параметр
{
echo $_SERVER['REQUEST_METHOD'];
parent::get($context); // пробросили параметр
}
public function post(array $context) { // добавили параметр
$context['message'] = 'Вы успешно создали объект'; // добавили сообщение
$this->get($context); // пробросили параметр
}
}
и добавим вывод сообщения в шаблоне
проверяем:
мы конечно пока ничего не добавили, но иллюзию создали
Чтобы реально добавить объект нам надо считать параметры которые были на форме. По аналогии с GET запросом мы можем получить доступ к ним через $_POST
для этого пишем:
public function post(array $context) {
// получаем значения полей с формы
$title = $_POST['title'];
$description = $_POST['description'];
$type = $_POST['type'];
$info = $_POST['info'];
// создаем текст запрос
$sql = <<<EOL
INSERT INTO space_objects(title, description, type, info, image)
VALUES(:title, :description, :type, :info, '')
EOL;
// подготавливаем запрос к БД
$query = $this->pdo->prepare($sql);
// привязываем параметры
$query->bindValue("title", $title);
$query->bindValue("description", $description);
$query->bindValue("type", $type);
$query->bindValue("info", $info);
// выполняем запрос
$query->execute();
$context['message'] = 'Вы успешно создали объект';
$context['id'] = $this->pdo->lastInsertId(); // получаем id нового добавленного объекта
$this->get($context);
}
ну и для полного счастья давайте ссылку на этот объект добавим в сообщение
тестируем:
ляпота! =)
Теперь давайте реализуем привязку изображения к объект. Если у нас изображение это просто ссылка на картинку в интернете, то тут все просто, бери текстовое поле и пихай туда текст
Но если мы хотим добавить возможность выбрать картинку с компьютера пользователя. Тут возникают сложности, так как теперь мы должны сами загрузить картинку на свой сервер (в нашем случае в папку public) и сформировать ссылку на это изображение.
Попробуем это сделать.
Сначала нам понадобится добавить специальный инпут для выбора файла https://getbootstrap.com/docs/5.0/forms/form-control/#file-input
запихать его куда-нибудь сюда
ну и назвать его image
теперь глянем что происходит, когда мы делаем пост запрос с картинкой.
Кстати по понятным причинам нельзя загрузить картинку через GET запрос, ведь изображение может вполне весить несколько мегабайт, а если речь идет о файлах то и несколько гигабайт, ни одна адресная строка не выдержит таких данных. Но это так просто к справке…
И так, идем в наш SpaceObjectCreateController
, отключим там добавление записей в БД чтобы не наплодить лишних
и добавим вывод данных из $_POST
class SpaceObjectCreateController extends BaseSpaceTwigController {
public $template = "space_object_create.twig";
public function post(array $context) {
$title = $_POST['title'];
$description = $_POST['description'];
$type = $_POST['type'];
$info = $_POST['info'];
// добавил
echo "<pre>";
print_r($_POST);
echo "</pre>";
// ...
тестим
как видно, информации об изображении нет в пост запросе. Где ж его взять?
Дело в том, что информация о файлах хранится в еще одном специальном объект который называет $_FILES
, попробуем его вывести
public function post(array $context) {
$title = $_POST['title'];
$description = $_POST['description'];
$type = $_POST['type'];
$info = $_POST['info'];
echo "<pre>";
// print_r($_POST);
print_r($_FILES); // добавил
echo "</pre>";
пробуем еще раз:
хм, теперь выводит пустой массив…
В общем тут есть еще один тонкий момент, чтобы форма начала отправлять данные файла на сервер надо добавить ей еще один атрибут, вот так:
снова запускаем
о, пошли данные =)
Схема данных простая: есть ключ который советует значению, указанному в name
на форме, и информация о файле, включая, название, тип, размер и путь куда PHP сохранил данные на время выполнения запроса.
Таким образом, чтобы файлик стал достоянием нашего сайта, надо его скопировать в папку public. Для этого рекомендуется создать подпапку. Обычно ее называют media
, и если вы используете систему контроля версий, то эту папку надо добавить в gitignore, так как на любом более-менее активном сайта она через пару недель начинает весить гигабайты данных.
Добавляем
и теперь правим контроллер чтобы он копировал туда файлик:
class SpaceObjectCreateController extends BaseSpaceTwigController {
public $template = "space_object_create.twig";
public function post(array $context) {
$title = $_POST['title'];
$description = $_POST['description'];
$type = $_POST['type'];
$info = $_POST['info'];
// вытащил значения из $_FILES
$tmp_name = $_FILES['image']['tmp_name'];
$name = $_FILES['image']['name'];
// используем функцию которая проверяет
// что файл действительно был загружен через POST запрос
// и если это так, то переносит его в указанное во втором аргументе место
move_uploaded_file($tmp_name, "../public/media/$name");
проверим что файл действительно загрузился:
можно даже открыть его напрямую по ссылке, у меня файл называется 1615886740141917748.jpg
поэтому ссылка будет http://localhost:9007/media/1615886740141917748.jpg, вот:
и осталось теперь эту ссылку, без адреса сервера http://localhost:9007 передать в базу на этот файл
public function post(array $context) {
// ...
$tmp_name = $_FILES['image']['tmp_name'];
$name = $_FILES['image']['name'];
move_uploaded_file($tmp_name, "../public/media/$name");
$image_url = "/media/$name"; // формируем ссылку без адреса сервера
$sql = <<<EOL
INSERT INTO space_objects(title, description, type, info, image)
VALUES(:title, :description, :type, :info, :image_url) -- передаем переменную в запрос
EOL;
$query = $this->pdo->prepare($sql);
$query->bindValue("title", $title);
$query->bindValue("description", $description);
$query->bindValue("type", $type);
$query->bindValue("info", $info);
$query->bindValue("image_url", $image_url); // подвязываем значение ссылки к переменной image_url
$query->execute();
// а дальше как обычно
$context['message'] = 'Вы успешно создали объект';
$context['id'] = $this->pdo->lastInsertId();
$this->get($context);
}
проверяем:
Удаление объектов — это буква D в слове CRUD. Самая простая операция. В принципе есть 100500 способов ее реализовать. Но самый простой это сделать отдельный контроллер который будет отвечать за удаление и отправить на него post запрос через форму.
Только чтобы не удалить лишних на создавайте себе новых объектов специально под удаление, я вот три штуки сделал:
И добавим кнопочку для удаления
получится вот так:
Так как удалять имеет смысл по идентификатору, ибо он уникальный, то передать этот самый идентификатор можно двумя способами
В любом случае создаем контроллер для удаления:
<?php
// в кой то веки наследуемся не от TwigBaseController а от BaseController
class SpaceObjectDeleteController extends BaseController {
public function post(array $context)
{
$id = $_POST['id']; // взяли id
$sql =<<<EOL
DELETE FROM space_objects WHERE id = :id
EOL; // сформировали запрос
// выполнили
$query = $this->pdo->prepare($sql);
$query->bindValue(":id", $id);
$query->execute();
}
}
подключим его в index.php
$router->add("/space-object/delete", SpaceObjectDeleteController::class);
теперь подвязываем к кнопочке
пробуем тыкать:
ошибка! Почему? Потому что мы же запрашиваем id вот так $id = $_POST['id'];
но не передаем его с формы.
А чтобы передать надо инпут добавить. Давайте добавим:
тут кстати сразу один интересный момент, мы же можем сразу в инпуте в атрибуте value
указать значение по умолчанию. Вот мы и указываем в нашем случае object.id
, глянем как это выглядит
то есть с одной стороны мы проблему с отсутствием поля решили, а с другой стороны нафига они отображаются то…
К счастью, есть способ скрыть эти поля. Для этого у инпутов в атрибуте type
надо указать hidden
, вот так:
и тогда у нас как бы поле формально будет на форме. Но визуально его не будет видно:
попробуем тыкнуть:
такс, нас перекинуло на пустую страницу… но если вернуться обратно на главную, то объект действительно исчезнет:
Но естественно идея отправлять пользователя на пустую страницу так себе, поэтому существует такое понятие как редирект.
Это когда вы из контроллера, вместо ответа в виде текста возвращает запрос браузеру на переход на другую страницу сайта. Браузер его считывает и автоматически делает GET запрос на указанный в редиректе адрес.
Делается это так:
class SpaceObjectDeleteController extends BaseController {
public function post(array $context)
{
// ...
$query->execute();
// устанавливаем заголовок Location, на новый путь, я хочу перейти на главную страницу поэтому пишу /
header("Location: /");
exit; // после header("Location: ...") надо писать exit
}
пробуем:
красота! =)
второй способ, чуть более моднявый. Заключается в том, что вы вместо того чтобы использовать input под id, передаете id в action формы.
То есть создаем новый маршрут в роутере:
в контроллере вместо $_POST
используем $this->params
<?php
class SpaceObjectDeleteController extends BaseController {
public function post(array $context)
{
$id = $this->params['id']; // заменил $_POST
// ...
exit;
}
ну и на форме, оставляем только кнопку и правленый action
:
пробуем:
работает так же =)
Операция обновления или буква U
из аббревиатуры CRUD, с одной стороны самая сложная операция по реализации. А с другой стороны после того как мы уже реализовали операции добавления и удаления сделать ее не составит труда.
Я не буду тут подробно показывать как сделать обновление, а просто примерно набросаю как можно его реализовать.
Контроллер можно делать на базе контроллера Create, часть ответственная за обработку post запроса все равно почти не поменяется.
В get надо просто вытащить объект по id
в роутере добавить маршрут для редактирования по идентификатору
$router->add("/space-object/(?P<id>\d+)/edit", SpaceObjectUpdateController::class);
сам шаблон можно сделать новый или доработать старый. Главное что надо сделать, это чтобы у вас поля во всех инпутах были заполнены, то есть прописать атрибут value:
ну и кнопочку добавить куда-нибудь, которая просто будет ссылкой на страницу с edit