Архитектура MVC - Model, View, Controller
В сегодняшнем уроке мы вообще не будем писать код. Вместо этого мы поговорим о том, как вообще построить приложение на PHP так, чтобы самому в нём не запутаться. Мы поговорим о том, что вообще такое архитектура приложения. А после этого мы разберем пример архитектуры на паттерне проектирования MVC и рассмотрим, как его использовать в разработке программ на языке PHP.
Архитектура приложения
Что же такое эта архитектура?
Архитектура программного обеспечения (англ. software architecture) — совокупность важнейших решений об организации программной системы. Архитектура включает:
- выбор структурных элементов и их интерфейсов, с помощью которых составлена система, а также их поведения в рамках сотрудничества структурных элементов;
- соединение выбранных элементов структуры и поведения во всё более крупные системы;
- архитектурный стиль, который направляет всю организацию — все элементы, их интерфейсы, их сотрудничество и их соединение.
Если говорить проще, то архитектура это про то, как:
- разделить приложение на какие-то блоки;
- разложить эти блоки по своим местам;
- связать эти блоки между собой.
MVC
Архитектура приложения должна отражать в себе концепцию того, как вообще работает приложение. Если говорить о проектах на языке PHP, то в подавляющем большинстве случаев – это веб-сайты.
Согласитесь, все веб-сайты работают примерно одинаково:
- получение и обработка запроса от пользователя (GET-запрос на страничку со статьёй)
- понимание того, как на этот запрос нужно отреагировать (получить статью из базы данных и вернуть её пользователю)
- работа с данными, их получение/изменение в базе данных (получение статьи из базы данных)
- формирование представления для пользователя (заполнение HTML-шаблона данными из базы данных)
- отправка ответа пользователю (отправка сформированной HTML-странички с текстом статьи).
К нашему счастью, для такого сценария уже есть готовый паттерн проектирования, или, как ещё говорят – шаблон проектирования. Называется он MVC. Это аббревиатура трёх слов: Model, View, Controller. Это три блока, на которые будет делиться всё наше приложение.
Model (Модель)
Модель – это часть приложения, работающая с данными. Она содержит в себе данные и умеет их отображать в базу данных. То есть она может добавлять записи в базу данных, удалять их, изменять, или же просто получать их оттуда.
Задача модели – взять данные и передать их тому, кто эти данные у неё запрашивает. Если мы посмотрим на рисунок того, как устроена архитектура MVC, мы видим, что модель взаимодействует с контроллером. Таким образом контроллер может получать данные от модели, либо же передавать эти данные в модель. С другой стороны от модели как правило находится база данных, в которой модель эти данные умеет хранить и получать их оттуда.
В коде, с которым мы работали до этого, модель – это классы Article и User. Они содержат в себе данные, хоть и не умеют пока работать с базой данных. Тем не менее мы можем получить данные из этих моделей или поместить их туда. Например, создать статью, а затем получить её текст.
View (Представление)
Эта часть программы довольно проста и отвечает за то, в каком виде пользователь получит данные от приложения. В этот блок приходят какие-то данные от контроллера, например, имя HTML-шаблона и переменные, которые в этот шаблон нужно передать.
Представьте что есть просто HTML-страничка, у которой вместо конкретного заголовка в исходнике написано:
<title><?= $title ?></title>
А переменная $title передаётся в этот шаблон из контроллера. Разумеется, мы проделаем всё это чуть позже, пока что не надо думать о том, как именно этого можно добиться.
Controller (Контроллер)
Это связующее звено между запросом от пользователя, моделями и представлением. Именно контроллер является точкой входа в приложение. Сюда приходит запрос от пользователя и принимается решение о том, что с этим запросом делать. Например, создать новую статью. Тогда контроллер создаёт новую модель данных для статьи и просит её сохраниться в базу данных. Затем он берёт эту статью и передаёт её в представление. Представление берёт шаблон для вывода статьи и переменные, полученные от контроллера (заголовок, текст статьи, её автора) и после этого возвращает сформированную страничку пользователю.
Заключение
Вот, собственно, и весь MVC. Ну, точнее говоря, его концепция. В следующих уроках мы будем по кусочкам собирать эту реализацию, из которой получится полноценный фреймворк для создания практически любого приложения.
Controller в MVC
Итак, мы разобрались за что должен отвечать каждый слой модели MVC. Давайте теперь остановимся более подробно на контроллере.
Контроллер – это точка входа в наше приложение + логика того, что вообще нужно сделать. Контроллер работает с моделями и передаёт результат во View.
Звучит несложно. Так давайте же теперь создадим наш первый контроллер!
Внутри папки src/MyProject создаём папку Controllers. А внутри неё – файл с именем MainController.php. Это будет контроллер для главной страницы сайта. Отсюда и название – Main.
Содержимое этого файла делаем таким:
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
class MainController
{
public function main()
{
echo 'Главная страница';
}
}
Теперь давайте вернёмся в файл index.php и в нём создадим объект этого класса и вызовем метод main().
www/index.php
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$controller = new \MyProject\Controllers\MainController();
$controller->main();
Давайте теперь откроем в браузере наш проект и увидим следующее.
Главная страница
Вуаля! Наш первый контроллер готов.
Разумеется, контроллер может быть не один, их может быть несколько. И у контроллера может быть несколько методов.
Давайте добавим метод sayHello() в тот же контроллер. Пусть у этого метода будет один строковый аргумент $name. И всё, что будет делать этот метод – это выводить строку «Привет, $name».
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
class MainController
{
public function main()
{
echo 'Главная страница';
}
public function sayHello(string $name)
{
echo 'Привет, ' . $name;
}
}
Теперь давайте добавим в index.php обработку GET-параметра name. Если он не пустой, то мы будем вызывать метод sayHello() и передавать туда этот параметр. Иначе – мы будем вызывать метод main().
www/index.php
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$controller = new \MyProject\Controllers\MainController();
if (!empty($_GET['name'])) {
$controller->sayHello($_GET['name']);
} else {
$controller->main();
}
Теперь, если мы перейдём по адресу http://myproject.loc то увидим всё ту же «Главную страницу». Но если мы перейдём по адресу http://myproject.loc/?name=Иван то увидим сообщение «Привет, Иван».
Итак, мы разобрались с тем, что такое контроллер и с тем, что у него может быть несколько методов.
Публичные методы контроллера ещё называются action-ами (от англ. action - действие).
Чем же тогда является index.php? Это ведь и точка входа, и место, где мы создаём сам контроллер и вызываем его методы. Этот кусок кода называется фронт-контроллером. И в следующих уроках мы изучим, как можно его усовершенствовать, чтобы не писать кучу кода для создания других контроллеров и сделаем его более гибким.
Фронт-контроллер и роутинг в PHP
В прошлом уроке мы добавили в контроллер 2 экшена и стали проверять в index.php GET-параметр. В зависимости от этого параметра мы решали, какой из экшенов вызвать и что передать в качестве аргументов. А что будет, когда нам на сайте понадобится более 100 страниц? Для каждого добавлять if? Согласитесь, неудобно. В этом уроке мы сделаем удобную систему для обработки адресов сайта – роутинг (от англ. routing - маршрутизация).
Если вы не работали ранее с регулярными выражениями – пройдите урок по регуляркам в PHP.
Apache RewriteEngine
Для начала немного магии. Создайте в директории www файл .htaccess и запишите в него следующее содержимое:
RewriteEngine On
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteRule ^(.*)$ ./index.php?route=$1 [QSA,L]
Это – специальный файл с конфигурацией для веб-сервера Apache. Если забыли – то это именно он обрабатывает запросы от пользователя и передаёт их дальше интерпретатору PHP. Подробнее об этом читайте в статье “как работает PHP”. Когда Apache находит в директории файл с именем .htaccess он понимает, что это его конфиг и применяет его для директории, в которой этот конфиг лежит (и для вложенных директорий тоже).
RewriteEngine – это такой механизм в сервере Apache, который позволяет перенаправлять запросы. А теперь давайте рассмотрим каждую строку файла отдельно.
- RewriteEngine On – включаем режим перенаправления запросов
- RewriteCond %{SCRIPT_FILENAME} !-d – если в директории есть папка, соответствующая адресу запроса, то отдать её в ответе
- RewriteCond %{SCRIPT_FILENAME} !-f – если в директории есть файл, соответствующий адресу запроса, то вернуть его в ответе
- RewriteRule ^(. )$ ./index.php?route=$1 [QSA,L] – если файл или папка не найдены, то для такого запроса выполнится этот пункт. В таком случае веб-сервер перенаправить этот запрос на скрипт index.php. При этом скрипту будет передан GET-параметр route со значением запрошенного адреса. $1 – это значение, выдернутое с помощью регулярки по маске ^(. )$. То есть вся адресная строка будет передана в этот GET-параметр.
Давайте теперь это проверим. Откроем в браузере адрес http://myproject.loc/abracadabra.
Опа! Видим текст «Главная страница». Значит мы попали на index.php. Давайте теперь попробуем в index.php вывести GET-параметр route. Уберём пока код, добавленный на предыдущих уроках и оставим только автозагрузку классов и вывод этого GET-параметра.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
var_dump($_GET['route']);
Снова откроем тот же адрес http://myproject.loc/abracadabra и увидим следующее:
string 'abracadabra' (length=11)
Давайте попробуем другой адрес - http://myproject.loc/hello/username
string 'hello/username' (length=14)
ЧПУ
Такие адреса через слэши называются ЧПУ – Человеко Понятные УРЛы. То есть адреса, которые нормально воспринимаются человеком.
Согласитесь
http://myproject.loc/hello/username
лучше чем
http://myproject.loc/?action=hello&name=username
На таких ЧПУ-адресах мы и будем разрабатывать нашу систему.
Роутинг
Ну а теперь мы научимся обрабатывать такие адреса красивым и простым способом – с помощью регулярных выражений.
Для начала давайте сделаем по-простому – с помощью регулярки научимся понимать, что текущий адрес: http://myproject.loc/hello/ , где - вообще любая строка.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);
var_dump($matches);
Обратите внимание – в качестве ограничителя шаблона регулярного выражения мы использовали тильду - ~ . Мы выбрали её вместо слэша, чтобы не экранировать слэш в адресной строке. Напомню, что в качестве ограничителя может выступать вообще любой символ.
Перейдём по адресу http://myproject.loc/hello/username и увидим наши совпадения по регулярке:
array (size=2)
0 => string 'hello/username' (length=14)
1 => string 'username' (length=8)
Нулевой элемент – полное совпадение по паттерну. Первый элемент – значение, попавшее в маску (.*), то есть всё, что идёт после hello/ .
Давайте теперь добавим проверку того, что если $matches не пустой, то будем создавать контроллер MainController и вызывать у него экшен hello. В качестве аргумента будем передавать ему значение из массива по ключу 1.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$controller = new \MyProject\Controllers\MainController();
$controller->sayHello($matches[1]);
return;
}
Посмотрим, что получилось.
Привет, username
Отлично! Давайте теперь добавим обработку случая, когда мы просто зашли на http://myproject.loc/. В таком случае переменная route будет пустой строкой. Регулярка для такого случая - ^$ . Да, просто начало строки и конец строки. Проще не бывает!
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$controller = new \MyProject\Controllers\MainController();
$controller->sayHello($matches[1]);
return;
}
$pattern = '~^$~';
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$controller = new \MyProject\Controllers\MainController();
$controller->main();
return;
}
Перейдём теперь на страницу http://myproject.loc/ и увидим сообщение «Главная страница».
Остаётся только добавить обработку случая, когда ни одна из этих регулярок не подошла и просто вывести сообщение о том что страница не найдена.
Давайте просто добавим в конце index.php строку:
...
echo 'Страница не найдена';
И проверим, что всё работает, перейдя по любому другому адресу: http://myproject.loc/blabla.
А теперь давайте посмотрим на получившийся код и подумаем, нет ли здесь чего-то общего? Да конечно есть! В обоих случаях мы проверяем регулярки и в зависимости от совпадения создаём нужный контроллер, с нужным методом и нужными аргументами. Но механизм-то одинаковый! Значит, будем делать универсальную систему роутинга.
Итак, нам нужно создать такую систему, которая будет на вход получать адрес, а на выходе возвращать имя контроллера, метод и параметры.
Давайте создадим отдельный файл с такой конфигурацией. Пусть это будет файл src/routes.php. Запишем в него следующее содержимое:
src/routes.php
<?php
return [
'~^hello/(.*)$~' => [\MyProject\Controllers\MainController::class, 'sayHello'],
'~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];
То есть это просто массив, у которого ключи – это регулярка для адреса, а значение – это массив с двумя значениями – именем контроллера и названием метода.
Теперь вернёмся в index.php и научимся обрабатывать этот файл. Для начала давайте просто положим этот массив в отдельную переменную.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
var_dump($routes);
Результат:
array (size=2)
'~^hello/(.*)$~' =>
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'sayHello' (length=8)
'~^$~' =>
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'main' (length=4)
Что с этим делать? Да просто пробежаться по нему foreach-ом и найти соответствие по регулярке для текущего адреса. Как только совпадение найдено, нужно остановить перебор. Звучит несложно. Давайте сделаем это!
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$isRouteFound = true;
break;
}
}
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
var_dump($controllerAndAction);
var_dump($matches);
Я завел также специальную переменную $isRouteFound – на случай, если совпадений не было найдено, она останется false, как и до перебора. В таком случае мы выведем сообщение о том, что страница не найдена и завершим работу скрипта. В противном случае – выведем значение переменных $controllerAndAction и $matches.
Давайте проверим случай, когда нужный роут не найден - http://myproject.loc/blabla
Страница не найдена!
Всё правильно. Давайте теперь вернёмся на http://myproject.loc/
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'main' (length=4)
array (size=1)
0 => string '' (length=0)
Видим, что у нас есть имя нужного контроллера и имя метода. Всё, этого достаточно. Вот так это делается в PHP:
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$isRouteFound = true;
break;
}
}
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];
$controller = new $controllerName();
$controller->$actionName();
Да! Прямо вот так! Переменную можно использовать в качестве имени класса при создании объекта, и даже вместо имени метода!
Зайдите на http://myproject.loc/ и убедитесь, что всё прекрасно работает.
Но у нас осталась еще проблема с аргументами для методов.
Давайте вернёмся к предыдущему варианту кода, где мы просто вывели значения переменных:
...
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
var_dump($controllerAndAction);
var_dump($matches);
И перейдём по адресу http://myproject.loc/hello/username
Результат:
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'sayHello' (length=8)
array (size=2)
0 => string 'hello/username' (length=14)
1 => string 'username' (length=8)
Видим что у нас так же есть имя контроллера и имя метода. А также нужный нам аргумент в массиве $matches.
Но мы видим, что нужные нам аргументы всегда будут только после нулевого элемента, так как в нём лежит полное совпадение по паттерну. Не беда – просто удаляем этот ненужный элемент:
...
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
unset($matches[0]);
var_dump($controllerAndAction);
var_dump($matches);
Получаем следующую картину:
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'sayHello' (length=8)
array (size=1)
1 => string 'username' (length=8)
Остаётся только один вопрос – как элементы массива передать в аргументы метода? Для этого в PHP есть специальный оператор троеточия:
method(...$array)
Он передаст элементы массива в качестве аргументов методу в том порядке, в котором они находятся в массиве.
Теперь доводим до ума наш скрипт:
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$isRouteFound = true;
break;
}
}
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
unset($matches[0]);
$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];
$controller = new $controllerName();
$controller->$actionName(...$matches);
Переходим по адресу http://myproject.loc/hello/username и видим что всё работает!
Вот мы и сделали роутинг. Теперь если нам понадобится добавить новый адрес на сайте то мы просто пропишем его в routes.php, и укажем имя контроллера и метода. Остальное произойдёт автоматически!
Ах да, наш index.php - скрипт, в котором происходит обработка входящих запросов и создаются другие контроллеры, называется фронт-контроллером .
Домашнее задание
Создайте еще один экшн в контроллере – sayBye(string $name), который будет выводить «Пока, $name». Добавьте для него роут /bye/$name и убедитесь, что всё работает.
View в MVC
Сегодня мы сделаем компонент View, то самое “V” в архитектуре MVC. View – это представление, то есть та часть программы, которая формирует то, что видит пользователь.
В случае приложения на языке PHP, в подавляющем большинстве случаев представление занимается формированием HTML-кода. Вообще, это довольно простая часть кода, которой даётся только имя шаблона и список переменных, которые в этот шаблон нужно подставить.
Итак, давайте рассмотрим простейший пример и создадим для начала только шаблон. Путь до него будет следующим: templates/main/main.php
Давайте запишем в него HTML-код для нашей будущей странички
templates/main/main.php
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Мой блог</title>
<style>
.layout {
width: 100%;
max-width: 1024px;
margin: auto;
background-color: white;
border-collapse: collapse;
}
.layout tr td {
padding: 20px;
vertical-align: top;
border: solid 1px gray;
}
.header {
font-size: 30px;
}
.footer {
text-align: center;
}
.sidebarHeader {
font-size: 20px;
}
.sidebar ul {
padding-left: 20px;
}
a, a:visited {
color: darkgreen;
}
</style>
</head>
<body>
<table class="layout">
<tr>
<td colspan="2" class="header">
Мой блог
</td>
</tr>
<tr>
<td>
<h2>Статья 1</h2>
<p>Всем привет, это текст первой статьи</p>
<hr>
<h2>Статья 2</h2>
<p>Всем привет, это текст второй статьи</p>
</td>
<td width="300px" class="sidebar">
<div class="sidebarHeader">Меню</div>
<ul>
<li><a href="/">Главная страница</a></li>
<li><a href="/about-me">Обо мне</a></li>
</ul>
</td>
</tr>
<tr>
<td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
</tr>
</table>
</body>
</html>
Теперь давайте откроем наш контроллер MainController и изменим его метод main()
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
class MainController
{
public function main()
{
include __DIR__ . '/../../../templates/main/main.php';
}
}
Теперь откроем http://myproject.loc/ и полюбуемся результатом:
Для начала давайте немного облегчим шаблон и вынесем стили в отдельный файл. Для этого в папке www создадим файл styles.css.
www/styles.css
.layout {
width: 100%;
max-width: 1024px;
margin: auto;
background-color: white;
border-collapse: collapse;
}
.layout tr td {
padding: 20px;
vertical-align: top;
border: solid 1px gray;
}
.header {
font-size: 30px;
}
.footer {
text-align: center;
}
.sidebarHeader {
font-size: 20px;
}
.sidebar ul {
padding-left: 20px;
}
a, a:visited {
color: darkgreen;
}
Теперь подключим этот файл со стилями в шаблоне:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Мой блог</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<table class="layout">
<tr>
<td colspan="2" class="header">
Мой блог
</td>
</tr>
<tr>
<td>
<h2>Статья 1</h2>
<p>Всем привет, это текст первой статьи</p>
<hr>
<h2>Статья 2</h2>
<p>Всем привет, это текст второй статьи</p>
</td>
<td width="300px" class="sidebar">
<div class="sidebarHeader">Меню</div>
<ul>
<li><a href="/">Главная страница</a></li>
<li><a href="/about-me">Обо мне</a></li>
</ul>
</td>
</tr>
<tr>
<td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
</tr>
</table>
</body>
</html>
И снова убедимся, что всё работает.
Давайте теперь попробуем передавать в шаблон переменные. Вместо явно заданных статей сделаем переменную со статьями:
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
class MainController
{
public function main()
{
$articles = [
['name' => 'Статья 1', 'text' => 'Текст статьи 1'],
['name' => 'Статья 2', 'text' => 'Текст статьи 2'],
];
include __DIR__ . '/../../../templates/main/main.php';
}
}
А теперь выведем эти статьи в шаблоне:
templates/main/main.php
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Мой блог</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<table class="layout">
<tr>
<td colspan="2" class="header">
Мой блог
</td>
</tr>
<tr>
<td>
<?php foreach ($articles as $article): ?>
<h2><?= $article['name'] ?></h2>
<p><?= $article['text'] ?></p>
<hr>
<?php endforeach; ?>
</td>
<td width="300px" class="sidebar">
<div class="sidebarHeader">Меню</div>
<ul>
<li><a href="/">Главная страница</a></li>
<li><a href="/about-me">Обо мне</a></li>
</ul>
</td>
</tr>
<tr>
<td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
</tr>
</table>
</body>
</html>
Если нам понадобится в другом контроллере или другом экшне добавить логику для работы с шаблонами, нам снова придется перечислять список переменных, а затем писать include с указанием полного пути для шаблона. Звучит не очень хорошо. Поэтому мы просто вынесем логику с подключением нужного шаблона в отдельный класс.
Создадим класс View.php по пути src/MyProject/View/View.php
В конструкторе этого класса мы будем принимать путь до папки с шаблонами:
src/MyProject/View/View.php
<?php
namespace MyProject\View;
class View
{
private $templatesPath;
public function __construct(string $templatesPath)
{
$this->templatesPath = $templatesPath;
}
}
Помимо этого давайте добавим метод, в который будем передавать имя конкретного шаблона и массив с переменными.
<?php
namespace MyProject\View;
class View
{
private $templatesPath;
public function __construct(string $templatesPath)
{
$this->templatesPath = $templatesPath;
}
public function renderHtml(string $templateName, array $vars = [])
{
extract($vars);
include $this->templatesPath . '/' . $templateName;
}
}
Функция extract извлекает массив в переменные. То есть она делает следующее: в неё передаётся массив [‘key1’ => 1, ‘key2’ => 2], а после её вызова у нас имеются переменные $key1 = 1 и $key2 = 2.
После этого мы просто подключаем файл с нужным шаблоном, получив путь до него, склеив пути до папки с шаблонами и именем конкретного шаблона.
Пришло время опробовать этот код в нашем контроллере. Создадим новый объект View в конструкторе контроллера, а затем внутри экшена вызовем renderHtml().
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\View\View;
class MainController
{
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function main()
{
$articles = [
['name' => 'Статья 1', 'text' => 'Текст статьи 1'],
['name' => 'Статья 2', 'text' => 'Текст статьи 2'],
];
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Теперь мы можем снова открыть сайт, и убедиться, что всё прекрасно работает.
Буфер вывода
В тот момент, когда мы подключаем файл c HTML-кодом, либо пишем в PHP-коде echo, либо совершаем какой-либо другой вывод данных, эти данные начинают сразу передаваться в поток вывода. И если что-то пойдёт не так, мы не сможем вернуть этот вывод и вывести вместо него какую-нибудь ошибку. Но в PHP есть возможность весь этот поток вывода положить во временный буфер вывода. Выглядит его использование следующим образом:
src/MyProject/View/View.php
public function renderHtml(string $templateName, array $vars = [])
{
extract($vars);
ob_start();
include $this->templatesPath . '/' . $templateName;
$buffer = ob_get_contents();
ob_end_clean();
}
Если вы сейчас попробуете запустить наш скрипт, то увидите пустую страницу. Дело в том, что все данные, которые должны были быть переданы в поток вывода, оказались в переменной $buffer.
Для того, чтобы передать эти данные в поток вывода, достаточно только вывести переменную $buffer.
src/MyProject/View/View.php
public function renderHtml(string $templateName, array $vars = [])
{
extract($vars);
ob_start();
include $this->templatesPath . '/' . $templateName;
$buffer = ob_get_contents();
ob_end_clean();
echo $buffer;
}
Откройте страничку снова, и убедитесь, что всё вернулось на свои места.
Так в чём же профит? А профит в том, что мы можем обрабатывать ошибки, возникшие в процессе работы с шаблоном. Пока мы с вами не знакомы с понятием «Исключения», давайте предположим, что у нас при подключении шаблона произошла какая-то ошибка. Тогда мы могли бы обработать эту ошибку и не выводить пользователю неправильно отрисованный шаблон. Мы могли бы сделать что-то типа такого:
src/MyProject/View/View.php
public function renderHtml(string $templateName, array $vars = [])
{
extract($vars);
ob_start();
include $this->templatesPath . '/' . $templateName;
$buffer = ob_get_contents();
ob_end_clean();
$error = 'В шаблоне была ошибка!';
if (empty($error)) {
echo $buffer;
} else {
echo $error;
}
}
Чуть позже мы вернёмся к обработке возможных ошибок, когда познакомимся с исключениями. А пока оставим этот код в таком состоянии:
src/MyProject/View/View.php
public function renderHtml(string $templateName, array $vars = [])
{
extract($vars);
ob_start();
include $this->templatesPath . '/' . $templateName;
$buffer = ob_get_contents();
ob_end_clean();
echo $buffer;
}
Реиспользование шаблонов
Давайте в наш контроллер вернём экшн из прошлых уроков, который выводил приветствие.
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\View\View;
class MainController
{
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function main()
{
$articles = [
['name' => 'Статья 1', 'text' => 'Текст статьи 1'],
['name' => 'Статья 2', 'text' => 'Текст статьи 2'],
];
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
public function sayHello(string $name)
{
echo 'Привет, ' . $name;
}
}
Давайте изменим его, чтобы он работал через шаблон.
public function sayHello(string $name)
{
$this->view->renderHtml('main/hello.php', ['name' => $name]);
}
Ну и создадим сам шаблон для него.
templates/main/hello.php
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Мой блог</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<table class="layout">
<tr>
<td colspan="2" class="header">
Мой блог
</td>
</tr>
<tr>
<td>
Привет, <?= $name ?>!!!
</td>
<td width="300px" class="sidebar">
<div class="sidebarHeader">Меню</div>
<ul>
<li><a href="/">Главная страница</a></li>
<li><a href="/about-me">Обо мне</a></li>
</ul>
</td>
</tr>
<tr>
<td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
</tr>
</table>
</body>
</html>
Давайте теперь перейдём по адресу http://myproject.loc/hello/username и увидим, что всё прекрасно сработало:
А теперь внимательно присмотритесь к нашим двум получившимся шаблонам. Согласитесь, у них всё абсолютно одинаковое, кроме текста, который мы выводим на странице. Давайте это исправим! Всё, что выше нашего контента – вынесем в один файл, всё что ниже – в другой. А в самих наших шаблонах будем эти два файла подключать.
Итак, выносим верхнюю часть (так называемую шапку сайта - хедер) в новый файл templates/header.php
templates/header.php
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Мой блог</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<table class="layout">
<tr>
<td colspan="2" class="header">
Мой блог
</td>
</tr>
<tr>
<td>
Затем выносим нижнюю часть (называемую футером или подвалом) в файл templates/footer.php
templates/footer.php
</td>
<td width="300px" class="sidebar">
<div class="sidebarHeader">Меню</div>
<ul>
<li><a href="/">Главная страница</a></li>
<li><a href="/about-me">Обо мне</a></li>
</ul>
</td>
</tr>
<tr>
<td class="footer" colspan="2">Все права защищены (c) Мой блог</td>
</tr>
</table>
</body>
</html>
После чего редактируем наши шаблоны:
templates/main/main.php
<?php include __DIR__ . '/../header.php'; ?>
<?php foreach ($articles as $article): ?>
<h2><?= $article['name'] ?></h2>
<p><?= $article['text'] ?></p>
<hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../footer.php'; ?>
templates/main/hello.php
<?php include __DIR__ . '/../header.php'; ?>
Привет, <?= $name ?>!!!
<?php include __DIR__ . '/../footer.php'; ?>
Должна получиться вот такая структура в шаблонах:
После этого заходим на странички http://myproject.loc/hello/username и http://myproject.loc/ и радуемся результату
Согласитесь, теперь можно очень просто добавлять новые странички на сайте, а также изменять шапку и футер только в одном месте.
Давайте повторим последовательность шагов, которые необходимо сделать для добавления новой странички:
- Добавляем экшн в контроллер (либо создаём ещё и новый контроллер);
- Добавляем для него роут в routes.php;
- Описываем логику внутри экшена и в конце вызываем у компонента view метод renderHtml();
- Создаём шаблон для вывода результата.
Вот и весь View.
Создаём базу данных для будущего блога
Сегодня мы с вами создадим базу данных для нашего будущего блога. Пока что она будет состоять только из двух таблиц – пользователи и статьи. Но этого уже будет достаточно для того чтобы начать работать с ней из PHP. Поехали!
Этот и дальнейшие уроки предполагают, что вы имеете базовые представления о работе с базой данных MySQL. Если же нет – пройдите наш бесплатный курс MySQL с нуля, прежде чем идти дальше.
Итак, в первую очередь давайте создадим новую базу данных для нашего приложения. Если у вас как и у меня установлен OpenServer, то все действия будут такими же. Иначе – сами мучайтесь :D.
Открываем phpMyAdmin http://127.0.0.1/openserver/phpmyadmin/server_databases.php и открываем вкладку «Базы данных». Вводим имя нашей новой базы - my_project, и выбираем сравнение utf8_general_ci.
Таблица пользователей
Давайте теперь создадим таблицу с пользователями. Назовём её users и создадим в ней следующие поля:
- id с типом INT и типом AUTO_INCREMENT – это id пользователя
- nickname c типом VARCHAR, длиной в 128 символов и уникальным индексом – чтобы никнейм мог принадлежать только одному пользователю и не было дублей
- email c типом VARCHAR, длиной в 255 символов и уникальным индексом – чтобы один email можно было использовать лишь единожды
- is_confirmed с типом BOOLEAN – подтверждён ли email (по умолчанию - false)
- role с типом ENUM и двумя возможными значениями – admin и user. Здесь будем хранить тип пользователя.
- password_hash с типом VARCHAR и длиной в 255 символов – тут будем хранить хеш пользовательского пароля
- auth_token с типом VARCHAR и длиной в 255 символов – тут будем хранить авторизационный токен, мы пока не изучали что это такое, пока просто добавим, позже пригодится
- created_at с типом DATETIME – тут будет храниться дата и время создания статьи. По умолчанию в качестве значения будет использоваться CURRENT_TIMESTAMP – то есть текущие дата и время.
Запрос для создания таблицы:
CREATE TABLE `users` (
`id` int(11) NOT NULL,
`nickname` varchar(128) NOT NULL,
`email` varchar(255) NOT NULL,
`is_confirmed` tinyint(1) NOT NULL DEFAULT '0',
`role` enum('admin','user') NOT NULL,
`password_hash` varchar(255) NOT NULL,
`auth_token` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `users`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `nickname` (`nickname`),
ADD UNIQUE KEY `email` (`email`);
ALTER TABLE `users`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
Давайте теперь добавим пару пользователей: админа и простого пользователя. Для этого выполним запрос:
INSERT INTO `users` (`id`, `nickname`, `email`, `is_confirmed`, `role`, `password_hash`, `auth_token`, `created_at`) VALUES (NULL, 'admin', 'admin@gmail.com', '1', 'admin', 'hash1', 'token1', CURRENT_TIMESTAMP);
INSERT INTO `users` (`id`, `nickname`, `email`, `is_confirmed`, `role`, `password_hash`, `auth_token`, `created_at`) VALUES (NULL, 'user', 'user@gmail.com', '1', 'user', 'hash2', 'token2', CURRENT_TIMESTAMP);
Зайдём в нашу табличку users и убедимся в том, что появилось 2 новых записи.
Таблица для статей
Теперь давайте создадим таблицу «articles» для будущих статей.
Определяем следующие столбцы:
- id с типом int и типом AUTO_INCREMENT (A_I) – id самой статьи;
- author_id с типом int – это id автора статьи;
- name c типом VARCHAR и длиной 255 – название статьи;
- text с типом TEXT – текст статьи;
- created_at с типом DATETIME – тут будет храниться дата и время создания статьи. По умолчанию в качестве значения будет использоваться CURRENT_TIMESTAMP – то есть текущие дата и время.
Жмем «вперед» и получаем следующую картину:
Запрос для создания таблицы:
CREATE TABLE `articles` (
`id` int(11) NOT NULL,
`author_id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`text` text NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `articles`
ADD PRIMARY KEY (`id`);
ALTER TABLE `articles`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
Ну и давайте добавим парочку новых статей. Выполним следующие запросы:
INSERT INTO `articles` (`id`, `author_id`, `name`, `text`, `created_at`) VALUES (NULL, '1', 'Статья о том, как я погулял', 'Шёл я значит по тротуару, как вдруг...', CURRENT_TIMESTAMP);
INSERT INTO `articles` (`id`, `author_id`, `name`, `text`, `created_at`) VALUES (NULL, '1', 'Пост о жизни', 'Сидел я тут на кухне с друганом и тут он задал такой вопрос...', CURRENT_TIMESTAMP);
Зайдём в табличку и полюбуемся на новые записи.
Вот и всё – мы создали простейшую базу данных для блога и даже добавили в неё несколько записей. В следующих уроках мы будем работать с неё с помощью PHP.
Класс для работы с базой данных
Так как мы учимся работать в ООП-стиле, то, как вы уже наверное догадались, мы будем использовать для соединения и работы с базой данных специальный класс. Давайте создадим в нашем проекте папку src/MyProject/Services и внутри неё создадим класс Db.php
src/MyProject/Services/Db.php
<?php
namespace MyProject\Services;
class Db
{
}
Давайте также создадим файл с настройками для подключения к базе данных. Он будет представлять собой простой массив.
src/settings.php
<?php
return [
'db' => [
'host' => 'localhost',
'dbname' => 'my_project',
'user' => 'root',
'password' => '',
]
];
Если у вас как и у меня установлен OpenServer, то настройки будут такими же. Иначе – задавайте здесь свои настройки.
Теперь давайте в конструкторе нашего класса установим соединение с базой данных. Мы будем работать через PDO, подробнее об этом читайте в статье Взаимодействие PHP и MySQL.
src/MyProject/Services/Db.php
<?php
namespace MyProject\Services;
class Db
{
/** @var \PDO */
private $pdo;
public function __construct()
{
$dbOptions = (require __DIR__ . '/../../settings.php')['db'];
$this->pdo = new \PDO(
'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
$dbOptions['user'],
$dbOptions['password']
);
$this->pdo->exec('SET NAMES UTF8');
}
}
Свойство $this->pdo теперь можно использовать для работы с базой данных через PDO. Давайте напишем отдельный метод для выполнения запросов в базу.
<?php
namespace MyProject\Services;
class Db
{
/** @var \PDO */
private $pdo;
public function __construct()
{
$dbOptions = (require __DIR__ . '/../../settings.php')['db'];
$this->pdo = new \PDO(
'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
$dbOptions['user'],
$dbOptions['password']
);
$this->pdo->exec('SET NAMES UTF8');
}
public function query(string $sql, $params = []): ?array
{
$sth = $this->pdo->prepare($sql);
$result = $sth->execute($params);
if (false === $result) {
return null;
}
return $sth->fetchAll();
}
}
Всё, класс для работы с базой данных готов. Давайте теперь попробуем его использовать для вывода статей на сайте прямо из базы.
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Services\Db;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function main()
{
$articles = $this->db->query('SELECT * FROM `articles`;');
var_dump($articles);
//$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Давайте посмотрим на результат.
То есть сейчас $articles – это массив с двумя вложенными элементами-массивами, представляющих собой статьи из базы данных.
Наш класс для работы с базой данных готов. В следующем уроке мы начнём его использовать для вывода статей на сайте.
Делаем вывод статей на сайте из базы данных
Итак, в прошлом уроке мы с вами создали класс для работы с базой данных и получили с его помощью две статьи. В этом уроке мы научимся выводить эти статьи на нашем сайте.
Давайте заглянем в наш шаблон.
templates/main/main.php
<?php include __DIR__ . '/../header.php'; ?>
<?php foreach ($articles as $article): ?>
<h2><?= $article['name'] ?></h2>
<p><?= $article['text'] ?></p>
<hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../footer.php'; ?>
Как видим, здесь у нас требуются ключи ‘name’ и ‘text’. И они есть у наших статей! Нам достаточно только передать эти статьи в шаблон, чтобы вывести их.
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Services\Db;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function main()
{
$articles = $this->db->query('SELECT * FROM `articles`;');
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Как видим, нужные данные успешно были использованы в шаблоне.
Давайте добавим на нашем сайте ещё одну страничку, на которой будет выводиться только одна статья.
Давайте создадим новый контроллер.
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Services\Db;
use MyProject\View\View;
class ArticlesController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function view()
{
echo 'Здесь будет получение статьи и рендеринг шаблона';
}
}
Добавим новый роут. Пусть наши статьи будут открываться по адресу типа: http://myproject.loc/articles/1, где вместо 1 может быть любой другой id статьи.
src/routes.php
<?php
return [
'~^articles/(\d+)$~' => [\MyProject\Controllers\ArticlesController::class, 'view'],
'~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];
Давайте проверим, что наш роут успешно обрабатывается:
Отлично, давайте теперь сделаем запрос в базу, в котором получим статью с нужным id.
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Services\Db;
use MyProject\View\View;
class ArticlesController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function view(int $articleId)
{
$result = $this->db->query(
'SELECT * FROM `articles` WHERE id = :id;',
[':id' => $articleId]
);
var_dump($result);
}
}
Получили массив, в котором есть статья с id = 1.
Давайте посмотрим, что получится, если запросить статью с id = 2.
Всё снова хорошо отработало.
А что будет, если запросить статью, которой в базе нет?
Мы получим пустой массив. Отлично, теперь давайте попробуем обработать эти две ситуации.
public function view(int $articleId)
{
$result = $this->db->query(
'SELECT * FROM `articles` WHERE id = :id;',
[':id' => $articleId]
);
if ($result === []) {
// Здесь обрабатываем ошибку
return;
}
$this->view->renderHtml('articles/view.php', ['article' => $result[0]]);
}
Добавим шаблон для вывода одной статьи:
templates/articles/view.php
<?php include __DIR__ . '/../header.php'; ?>
<h1><?= $article['name'] ?></h1>
<p><?= $article['text'] ?></p>
<?php include __DIR__ . '/../footer.php'; ?>
Пришла пора добавить шаблон для страницы с ошибкой, когда что-то не найдено. Создадим ещё один шаблончик.
templates/errors/404.php
<h1>Страница не найдена</h1>
И будем подключать этот шаблон для случаев, когда наша статья не нашлась.
src/MyProject/Controllers/ArticlesController.php
...
public function view(int $articleId)
{
$result = $this->db->query(
'SELECT * FROM `articles` WHERE id = :id;',
[':id' => $articleId]
);
if ($result === []) {
$this->view->renderHtml('errors/404.php');
return;
}
$this->view->renderHtml('articles/view.php', ['article' => $result[0]]);
}
...
Попробуем теперь открыть страничку с несуществующей статьёй.
Однако, просто так написать о том, что страница не найдена не верно. Важно при этом вернуть код ответа для страницы, который даст понять поисковым системам, что эту страницу индексировать не нужно. Если мы откроем панель разработчика в Google Chrome и перезагрузим страничку, мы увидим, что текущий код ответа – 200. Это стандартный код ответа, говорящий о том, что со страничкой всё хорошо.
Нам же нужно вернуть код 404 – он говорит о том, что страница не найдена. Задать код ответа можно при помощи функции http_response_code(). В качестве аргумента ей передаётся код, который нужно вернуть.
Давайте отредактируем наш метод renderHtml() в классе View. Добавим возможность передавать код ответа.
src/MyProject/View/View.php
<?php
namespace MyProject\View;
class View
{
private $templatesPath;
public function __construct(string $templatesPath)
{
$this->templatesPath = $templatesPath;
}
public function renderHtml(string $templateName, array $vars = [], int $code = 200)
{
http_response_code($code);
extract($vars);
ob_start();
include $this->templatesPath . '/' . $templateName;
$buffer = ob_get_contents();
ob_end_clean();
echo $buffer;
}
}
По умолчанию, если мы не передадим третьим аргументом код, будет возвращён 200-ый, иначе – заданный нами.
Остаётся только добавить передачу нужного кода в том месте, где мы вызываем рендеринг страницы с ошибкой.
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Services\Db;
use MyProject\View\View;
class ArticlesController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function view(int $articleId)
{
$result = $this->db->query(
'SELECT * FROM `articles` WHERE id = :id;',
[':id' => $articleId]
);
if ($result === []) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$this->view->renderHtml('articles/view.php', ['article' => $result[0]]);
}
}
Снова проверяем, обновив страничку.
Вжух! Получили нужный код ошибки.
Итак, мы с вами сделали вывод списка статей и вывод каждой статьи отдельно. Уже немало. Давайте теперь сделаем ссылки на странице со списком, которые будут вести на отдельную статью. Для этого нам нужно только немного поправить шаблон.
templates/main/main.php
<?php include __DIR__ . '/../main/header.php'; ?>
<?php foreach ($articles as $article): ?>
<h2><a href="/articles/<?= $article['id'] ?>"><?= $article['name'] ?></a></h2>
<p><?= $article['text'] ?></p>
<hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../main/footer.php'; ?>
Зайдём в корень нашего сайтика и увидим, что теперь мы можем переходить по каждой статье отдельно.
На этом данный урок заканчивается, а в следующих мы научимся работать с базой данных, получая из неё объекты, а не массивы.
Домашнее задание
В экшне ArticlesController::view() после получения статьи, добавьте ещё один запрос на получение автора этой статьи из таблицы users. Выведите nickname автора в шаблоне.
ORM - Object Relational Mapping
ORM или Object-Relational Mapping (объектно-реляционное отображение) — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования.
Иными словами, это такой принцип, согласно которому мы работаем с базой данных в коде на уровне объектов. То есть структура и данные в базе данных будут иметь отражение в наших объектах в коде. И при работе с этими нашими объектами, мы будем изменять нашу базу данных, и наоборот. Вот как мы это будем делать:
- таблицам будут соответствовать отдельные классы. Например, таблице articles будет соответствовать класс Article.
- в классах будут описаны свойства объектов. Каждое свойство будет соответствовать полю в таблице. Например, будет свойство ->authorId, оно будет соответствовать столбцу author_id в таблице articles.
- мы будем работать с объектами таких классов. Каждый такой объект соответствует одной записи в базе данных. То есть объект класса Article будет соответствовать одной строке в таблице articles.
Как видите, суть ORM крайне проста - объекты имеют своё отражение в базе данных. При этом в коде происходит работа на уровне объектов – вот так правильно делать это при объектно-ориентированном подходе.
Реализуем свою ORM
В течение этого курса мы с вами разработаем свою собственную ORM-систему – она будет позволять получать записи из базы данных в виде объектов, а также сохранять «объекты» в базу данных. В этом уроке мы сделаем наиболее простую часть этого функционала – научимся «читать объекты» из базы данных.
Первым делом давайте отредактируем наш класс Article, представляющий собой статью. В базе данных у нас есть следующие поля: id, name, text, author_id, created_at. Давайте сделаем в нашем классе свойства объектов, которые будут соответствовать этим полям.
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\Users\User;
class Article
{
/** @var int */
private $id;
/** @var string */
private $name;
/** @var string */
private $text;
/** @var string */
private $authorId;
/** @var string */
private $createdAt;
}
Теперь нужно каким-то образом при получении статей из базы данных создать объекты этого класса и заполнить их свойства значениями из базы данных. Для этого в PDO есть специальный режим. Всё что нужно сделать – это указать класс, объекты которого нужно создать. Давайте откроем класс Db и изменим метод query() следующим образом:
src/MyProject/Services/Db.php
public function query(string $sql, array $params = [], string $className = 'stdClass'): ?array
{
$sth = $this->pdo->prepare($sql);
$result = $sth->execute($params);
if (false === $result) {
return null;
}
return $sth->fetchAll(\PDO::FETCH_CLASS, $className);
}
Третьим аргументом в этот метод будет передаваться имя класса, объекты которого нужно создавать. По умолчанию это будут объекты класса stdClass – это такой встроенный класс в PHP, у которого нет никаких свойств и методов.
В метод fetchAll() мы передали специальную константу - \PDO::FETCH_CLASS, она говорит о том, что нужно вернуть результат в виде объектов какого-то класса. Второй аргумент – это имя класса, которое мы можем передать в метод query().
Теперь зайдём в наш контроллер MainController и сделаем вывод результата запроса с помощью var_dump().
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Services\Db;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function main()
{
$articles = $this->db->query('SELECT * FROM `articles`;');
var_dump($articles);
return;
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Как видим, в результате мы получили массив объектов класса stdClass, у которых есть public-свойства, соответствующие именам столбцов в базе данных. В PHP мы можем задавать свойства объектов на лету, даже если они не были определены в классе. Это называется динамическим объявлением свойств. Если свойства у объекта нет, но мы попытаемся его задать – будет создано новое публичное свойство.
Давайте теперь попробуем в качестве класса передать имя класса Article:
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\Services\Db;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function main()
{
$articles = $this->db->query('SELECT * FROM `articles`;', [], Article::class);
var_dump($articles);
return;
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
И о чудо! Теперь у нас массив объектов класса Article! Однако, есть проблема. Свойства объектов ->authorId и ->createdAt остались со значениями null, но при этом у нас динамически добавилось два публичный свойства ->author_id и ->created_at. Так произошло из-за несоответствия имён столбцов в базе данных и свойств объектов класса Article.
Магический метод __set()
Эту проблему с несоответствием имён легко решить с помощью магического метода __set($name, $value) – если этот метод добавить в класс и попытаться задать ему несуществующее свойство, то вместо динамического добавления такого свойства, будет вызван этот метод. При этом в первый аргумент $name, попадёт имя свойства, а во второй аргумент $value – его значение. А внутри этого метода мы уже сможем решить, что с этими данными делать.
В качестве примера давайте добавим в класс Article этот метод. Всё, что он будет делать – это говорить о том, что он был вызван и какие аргументы были в него переданы.
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\Users\User;
class Article
{
/** @var int */
private $id;
/** @var string */
private $name;
/** @var string */
private $text;
/** @var string */
private $authorId;
/** @var string */
private $createdAt;
public function __set($name, $value)
{
echo 'Пытаюсь задать для свойства ' . $name . ' значение ' . $value . '<br>';
}
}
Видим, что этот метод был вызван по два раза для свойств author_id и created_at. Обратите внимание – мы только выводили сообщения на экран, больше мы ничего с этими данными не сделали. Поэтому теперь в самих объектах класса Article этих свойств нет.
Ещё раз о том, что же произошло. В тот момент, когда наш код с помощью PDO пытался сделать $this->created_at = что-то, вызывался метод __set() и просто выводил сообщение на экран. Давайте теперь в этом методе сделаем так, чтобы свойства снова устанавливались. Сделать это проще простого:
public function __set($name, $value)
{
echo 'Пытаюсь задать для свойства ' . $name . ' значение ' . $value . '<br>';
$this->$name = $value;
}
$name – имя свойства, $value – его значение. Ничего сложного. Давайте снова запустим код:
Видим, что эти свойства снова появились.
А теперь мы можем внутри этого метода сделать так, чтобы задавалось свойство с именем не $name, а какое-нибудь другое. Скажем, если туда передаётся $name равное ‘author_id’, то чтобы оно преобразовывалось в ‘authorId’ и мы задавали уже нужное свойство класса. Итак, задача – преобразовать строки вида string_with_smth в stringWithSmth.
Я сделал это вот так:
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\Users\User;
class Article
{
/** @var int */
private $id;
/** @var string */
private $name;
/** @var string */
private $text;
/** @var string */
private $authorId;
/** @var string */
private $createdAt;
public function __set($name, $value)
{
$camelCaseName = $this->underscoreToCamelCase($name);
$this->$camelCaseName = $value;
}
private function underscoreToCamelCase(string $source): string
{
return lcfirst(str_replace('_', '', ucwords($source, '_')));
}
}
Я добавил специальный метод underscoreToCamelCase() – именно он и занимается преобразованием. Вот что происходит внутри этого метода:
- Функция ucwords() делает первые буквы в словах большими, первым аргументом она принимает строку со словами, вторым аргументом – символ-разделитель (то, что стоит между словами). После этого строка string_with_smth преобразуется к виду String_With_Smth
- Функция str replace() заменяет в получившейся строке все символы ‘ ’ на пустую строку (то есть она просто убирает их из строки). После этого мы получаем строку StringWithSmth
- Функция lcfirst() просто делает первую букву в строке маленькой. В результате получается строка stringWithSmth. И это значение возвращается этим методом.
Таким образом, если мы передадим в этот метод строку «created_at», он вернёт нам строку «createdAt», если передадим «author_id», то он вернёт «authorId». Именно то, что нам нужно!
Так вот в методе __set() я получаю нужное мне имя для свойства объекта из имени, переданного в аргументе $name, а затем задаю в свойство с получившимся именем переданное значение.
Посмотрим теперь на результат:
Как видим, теперь наши свойства authorId и createdAt у объектов имеют нужные значения.
Давайте сделаем геттеры для свойств id, name и text:
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\Users\User;
class Article
{
/** @var int */
private $id;
/** @var string */
private $name;
/** @var string */
private $text;
/** @var string */
private $authorId;
/** @var string */
private $createdAt;
public function __set($name, $value)
{
$camelCaseName = $this->underscoreToCamelCase($name);
$this->$camelCaseName = $value;
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string
*/
public function getText(): string
{
return $this->text;
}
private function underscoreToCamelCase(string $source): string
{
return lcfirst(str_replace('_', '', ucwords($source, '_')));
}
}
Теперь мы можем работать с этими объектами в коде. Например – обращаться к геттерам в шаблонах.
templates/main/main.php
<?php include __DIR__ . '/../header.php'; ?>
<?php foreach ($articles as $article): ?>
<h2><a href="/articles/<?= $article->getId() ?>"><?= $article->getName() ?></a></h2>
<p><?= $article->getText() ?></p>
<hr>
<?php endforeach; ?>
<?php include __DIR__ . '/../footer.php'; ?>
И теперь снова начнём передавать наши статьи во View внутри контроллера:
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\Services\Db;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
/** @var Db */
private $db;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
$this->db = new Db();
}
public function main()
{
$articles = $this->db->query('SELECT * FROM `articles`;', [], Article::class);
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Всё прекрасно работает. А в следующих уроках мы с вами научимся добавлять объекты в базу данных.
Реализуем Active Record в PHP
Сегодня мы изучим ещё один паттерн проектирования – Active Record. Этот шаблон говорит о том, что сущность (объекты класса статьи или пользователя) сами должны управлять работой с базой данных. То есть весь остальной код, который эти сущности использует, не должен знать о базе данных. Наши контроллеры не должны работать с базой данных, получая данные и заполняя ими сущности. Они должны знать только о сущностях. Сущность сама должна позаботиться о работе с базой данных. О том, как это реализовать – читайте далее.
Для начала нужно вообще понять, как стоит работать с сущностями при помощи такой концепции. Самое простое, что мы можем реализовать – это чтение из базы данных. И мы должны сделать это, обращаясь напрямую к сущностям-объектам. То есть мы должны сказать: «Эй, Article, дай мне все статьи». Но согласитесь, глупо будет для этого создать сущности, а после этого попросить чтобы они заполнили себя данными из базы. Нам нужно сделать это как-то по другому. Например, обратиться к сущности, не создавая её, но чтобы она при этом вернула нам созданные сущности. Вспоминаем статические методы – их ведь можно вызывать, не создавая объекта. То, что нам нужно!
Давайте добавим в Article статический метод, возвращающий нам все статьи.
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Services\Db;
class Article
{
/** @var int */
private $id;
/** @var string */
private $name;
/** @var string */
private $text;
/** @var string */
private $authorId;
/** @var string */
private $createdAt;
public function __set($name, $value)
{
$camelCaseName = $this->underscoreToCamelCase($name);
$this->$camelCaseName = $value;
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string
*/
public function getText(): string
{
return $this->text;
}
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `articles`;', [], Article::class);
}
private function underscoreToCamelCase(string $source): string
{
return lcfirst(str_replace('_', '', ucwords($source, '_')));
}
}
Теперь, чтобы получить статьи в контроллере, нам нужно сделать следующее:
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function main()
{
$articles = Article::findAll();
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Заметили, как сразу упростился наш контроллер? Пропала зависимость от базы данных. Если вы сейчас попробуете выполнить этот код, то он успешно отработает.
А теперь давайте посмотрим на код этого статического метода.
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `articles`;', [], Article::class);
}
Согласитесь, можно заменить Article::class на self::class – и сюда автоматически подставится класс, в котором этот метод определен. А можно заменить его и вовсе на static::class – тогда будет подставлено имя класса, у которого этот метод был вызван. В чём разница? Если мы создадим класс-наследник SuperArticle, он унаследует этот метод от родителя. Если будет использоваться self:class, то там будет значение “Article”, а если мы напишем static::class, то там уже будет значение “SuperArticle”. Это называется поздним статическим связыванием – благодаря нему мы можем писать код, который будет зависеть от класса, в котором он вызывается, а не в котором он описан .
Итак, давайте изменим этот метод:
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `articles`;', [], static::class);
}
А теперь давайте попробуем избавиться от зависимости от таблицы “articles”. Вынесем получение названия таблицы в отдельный метод. Вот так:
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}
private static function getTableName(): string
{
return 'articles';
}
А теперь внимательно посмотрите на содержимое класса Article. Не кажется ли вам, что методы findAll(), __set(), underscoreToCamelCase() можно вот хоть сейчас взять и скопировать в сущность User, и начать их использовать? Только не нужно ничего копировать, мы ведь пишем на объектно-ориентированном языке, и можем использовать наследование! Мы можем просто вынести всю эту логику в отдельный класс, а там где она нужна, просто от него наследоваться. Давайте так и поступим.
Создадим отдельный класс, реализующий всю эту логику.
src/MyProject/Models/ActiveRecordEntity.php
<?php
namespace MyProject\Models;
abstract class ActiveRecordEntity
{
}
Так как создание самого этого класса нам не нужно, то делаем его абстрактным. А теперь переносим в него универсальный код из класса Article.
<?php
namespace MyProject\Models;
use MyProject\Services\Db;
abstract class ActiveRecordEntity
{
/** @var int */
protected $id;
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
public function __set(string $name, $value)
{
$camelCaseName = $this->underscoreToCamelCase($name);
$this->$camelCaseName = $value;
}
private function underscoreToCamelCase(string $source): string
{
return lcfirst(str_replace('_', '', ucwords($source, '_')));
}
/**
* @return static[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}
abstract protected static function getTableName(): string;
}
Давайте по порядку.
- добавили protected-свойство ->id и public-геттер для него – у всех наших сущностей будет id, и нет необходимости писать это каждый раз в каждой сущности – можно просто унаследовать;
- перенесли public-метод __set() – теперь все дочерние сущности будут его иметь
- перенесли метод underscoreToCamelCase(), так как он используется внутри метода __set()
- public-метод findAll() будет доступен во всех классах-наследниках
- и, наконец, мы объявили абстрактный protected static метод getTableName(), который должен вернуть строку – имя таблицы. Так как метод абстрактный, то все сущности, которые будут наследоваться от этого класса, должны будут его реализовать. Благодаря этому мы не забудем его добавить в классах-наследниках.
Давайте теперь посмотрим на то, во что у нас превратится класс Article. Наследуемся от полученного класса и убираем лишнее. Обратите внимание, свойства теперь становятся не private, а protected, чтобы к ним можно было достучаться из класса-родителя.
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\ActiveRecordEntity;
class Article extends ActiveRecordEntity
{
/** @var string */
protected $name;
/** @var string */
protected $text;
/** @var string */
protected $authorId;
/** @var string */
protected $createdAt;
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string
*/
public function getText(): string
{
return $this->text;
}
protected static function getTableName(): string
{
return 'articles';
}
}
Вот так вот он у нас значительно упростился. Проверим, что всё работает. И… Всё работает!
Давайте теперь добавим метод, который будет возвращать одну статью по id. Проще простого! Добавляем в наш класс ActiveRecordEntity ещё один метод getById().
src/MyProject/Models/ActiveRecordEntity.php
<?php
namespace MyProject\Models;
use MyProject\Services\Db;
abstract class ActiveRecordEntity
{
...
/**
* @param int $id
* @return static|null
*/
public static function getById(int $id): ?self
{
$db = new Db();
$entities = $db->query(
'SELECT * FROM `' . static::getTableName() . '` WHERE id=:id;',
[':id' => $id],
static::class
);
return $entities ? $entities[0] : null;
}
}
Этот метод вернёт либо один объект, если он найдётся в базе, либо null – что будет говорить об его отсутствии.
Тогда наш контроллер статей, где мы получаем только одну статью приведется к виду:
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\View\View;
class ArticlesController
{
/** @var View */
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function view(int $articleId)
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$this->view->renderHtml('articles/view.php', ['article' => $article]);
}
}
А шаблон станет таким:
templates/articles/view.php
<?php include __DIR__ . '/../header.php'; ?>
<h1><?= $article->getName() ?></h1>
<p><?= $article->getText() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>
А теперь давайте в нашем классе User добавим свойства, которые будут соответствовать его полям в базе данных.
src/MyProject/Models/Users/User.php
<?php
namespace MyProject\Models\Users;
class User
{
/** @var string */
protected $nickname;
/** @var string */
protected $email;
/** @var int */
protected $isConfirmed;
/** @var string */
protected $role;
/** @var string */
protected $passwordHash;
/** @var string */
protected $authToken;
/** @var string */
protected $createdAt;
/**
* @return string
*/
public function getEmail(): string
{
return $this->email;
}
}
А теперь внимание, просто наследуемся от нашего ActiveRecordEntity и получаем все эти возможности, что есть у сущности Article! Просто добавляем несколько строк и указываем нужную таблицу, где хранятся пользователи.
src/MyProject/Models/Users/User.php
<?php
namespace MyProject\Models\Users;
use MyProject\Models\ActiveRecordEntity;
class User extends ActiveRecordEntity
{
/** @var string */
protected $nickname;
/** @var string */
protected $email;
/** @var int */
protected $isConfirmed;
/** @var string */
protected $role;
/** @var string */
protected $passwordHash;
/** @var string */
protected $authToken;
/** @var string */
protected $createdAt;
/**
* @return string
*/
public function getNickname(): string
{
return $this->nickname;
}
protected static function getTableName(): string
{
return 'users';
}
}
Да это же магия! =)
Попробуем вывести автора статьи, для этого у статьи добавляем геттер для этого поля:
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\ActiveRecordEntity;
class Article extends ActiveRecordEntity
{
...
/**
* @return int
*/
public function getAuthorId(): int
{
return (int) $this->authorId;
}
}
Добавляем в контроллере получение нужного юзера:
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\Models\Users\User;
use MyProject\View\View;
class ArticlesController
{
/** @var View */
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function view(int $articleId)
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$articleAuthor = User::getById($article->getAuthorId());
$this->view->renderHtml('articles/view.php', [
'article' => $article,
'author' => $articleAuthor
]);
}
}
И выводим никнейм автора в шаблоне:
templates/articles/view.php
<?php include __DIR__ . '/../header.php'; ?>
<h1><?= $article->getName() ?></h1>
<p><?= $article->getText() ?></p>
<p>Автор: <?= $author->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>
Смотрим на результат:
Круто, да? Но можно ещё круче! Можно ведь попросить статью давать нам не id автора, а сразу автора! Для этого просто меняем геттер в статье:
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Models\ActiveRecordEntity;
use MyProject\Models\Users\User;
class Article extends ActiveRecordEntity
{
...
/**
* @return User
*/
public function getAuthor(): User
{
return User::getById($this->authorId);
}
}
Вот так просто! Прямо в геттере просим сущность юзера выполнить запрос в базу и получить нужного пользователя, по id, который хранится в статье. При этом запрос будет выполнен только если мы вызовем этот геттер, это называется LazyLoad (ленивая загрузка) – это когда данные не подгружаются до тех пор, пока их не запросят .
Код нашего контроллера снова упрощается:
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\View\View;
class ArticlesController
{
/** @var View */
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function view(int $articleId)
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$this->view->renderHtml('articles/view.php', [
'article' => $article
]);
}
}
А в шаблоне мы можем напрямую запросить пользователя:
templates/articles/view.php
<?php include __DIR__ . '/../header.php'; ?>
<h1><?= $article->getName() ?></h1>
<p><?= $article->getText() ?></p>
<p>Автор: <?= $article->getAuthor()->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>
Насыщенный получился урок. Надеюсь, всё было понятно. Если нет – вы знаете, что я всегда подскажу, не стесняйтесь, обращайтесь. До следующего урока!