Паттерн Singleton в PHP
Сегодня мы с вами изучим ещё один паттерн – Singleton. Этот паттерн относится к числу порождающих паттернов проектирования, то есть тех, с помощью которых в нашей программе создаются объекты. Прежде чем перейти непосредственно к самому паттерну синглтон, давайте поймём проблему, которую он решает.
Давайте взглянем более детально на код наших сущностей User и Article. Оба этих класса наследуются от класса ActiveRecordEntity, а следственно имеют методы getById() и findAll(). Давайте посмотрим их код.
src/MyProject/Models/ActiveRecordEntity.php
/**
* @return static[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}
/**
* @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;
}
Как видим, каждый раз при вызове этих методов у нас создаётся новый объект класса Db. Разумеется, это приводит к тому, что каждый раз вызывается конструктор класса Db и устанавливается новое соединение с базой данных.
То есть вот такой код:
$article = Article::getById($articleId);
$nickname = $article->getAuthor()->getNickname();
приведёт к тому, что будет создан новый объект Db и установлено новое соединение с базой данных при:
- вызове метода Article::getById($articleId)
- вызове метода User::getById($this->authorId) внутри $article-> getAuthor()
То есть одно и то же действие будет выполнено дважды. А ведь создание нового объекта, установка соединения с базой данных - всё это занимает время, а программы нужно писать так, чтобы они выполнялись за минимальное количество времени.
Давайте чтобы убедиться в том, что объект действительно создаётся дважды, создадим статическое свойство у класса Db, в котором будем хранить число вызовов конструктора. Мы уже проделывали подобное в уроке “Статические методы и свойства в PHP”.
Итак, давайте добавим классу статическое свойство $instancesCount, по умолчанию равное нулю.
src/MyProject/Services/Db.php
<?php
namespace MyProject\Services;
class Db
{
private static $instancesCount = 0;
/** @var \PDO */
private $pdo;
public function __construct()
{
self::$instancesCount++;
...
Сделаем его приватным. В конструкторе в самом начале будем увеличивать этот счётчик на единицу.
Также давайте добавим публичный статический метод, который будет возвращать значение этого счётчика.
src/MyProject/Services/Db.php
...
public static function getInstancesCount(): int
{
return self::$instancesCount;
}
...
Давайте теперь временно добавим вывод этого значения в конце выполнения программы. Просто добавим вывод с помощью var_dump() в конце нашего фронт-контроллера.
www/index.php
...
var_dump(\MyProject\Services\Db::getInstancesCount());
Теперь перейдём на страничку со списком статей http://myproject.loc/ и увидим внизу странички значение 1.
Всё в порядке – одно единственное соединение с базой данных. Но что будет, если мы перейдём на страничку с одной статьей, где мы выводим автора? Давайте проверим: http://myproject.loc/articles/1 - теперь значение уже 2. А что будет, если мы потом добавим статьям хотя бы такой функционал как рубрики и комментарии? Будет уже создано 4 объекта и установлено 4 соединения с базой! Это будет значительно замедлять наш скрипт.
А как на счёт того, чтобы использовать статическое свойство класса для того, чтобы хранить единственный созданный экземпляр этого класса? То есть в свойство класса мы положим созданный объект класса Db, а потом сможем использовать его, когда нам потребуется. Ведь статические свойства принадлежат классу и всем его объектам целиком и в единственном экземпляре.
Давайте создадим в классе Db статическое свойство $instance, в котором будет храниться созданный объект.
src/MyProject/Services/Db.php
<?php
namespace MyProject\Services;
class Db
{
private static $instancesCount = 0;
private static $instance;
...
А теперь давайте добавим в этот класс специальный статический метод, который будет делать следующее:
- Проверять, что свойство $instance не равно null
- Если оно равно null, будет создан новый объект класса Db, а затем помещён в это свойство
- Вернёт значение этого свойства.
Давайте напишем этот простейший код:
src/MyProject/Services/Db.php
...
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
Теперь мы можем создавать объекты класса Db с помощью этого метода, вот так:
$db = Db::getInstance();
Теперь, когда мы вызовем этот метод несколько раз подряд, то произойдёт следующее:
- Во время первого запуска self::$instance будет равен null, поэтому создастся новый объект класса Db и задастся в это свойство. Затем этот объект просто вернётся в качестве результата
- При всех последующих запусках в свойстве $instance уже будет лежать объект и условие не выполнится. Вместо создания нового объекта вернётся уже созданный ранее.
А для того чтобы нельзя было в других местах кода создать новые объекты этого класса, стоит сделать конструктор приватным – тогда создать объект можно будет только с помощью этого метода.
src/MyProject/Services/Db.php
private function __construct()
{
self::$instancesCount++;
$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');
}
Если мы теперь попробуем запустить наш скрипт http://myproject.loc/articles/1 то получим ошибку о том, что нельзя вызвать приватный конструктор.
Давайте изменим места в коде, в которых мы создавали новые объекты класса Db напрямую. Мы делали это в классе ActiveRecordEntity. Заменим все места с кодом
$db = new Db();
на
$db = Db::getInstance();
Получим следующее:
src/MyProject/Models/ActiveRecordEntity.php
<?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 = Db::getInstance();
return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}
/**
* @param int $id
* @return static|null
*/
public static function getById(int $id): ?self
{
$db = Db::getInstance();
$entities = $db->query(
'SELECT * FROM `' . static::getTableName() . '` WHERE id=:id;',
[':id' => $id],
static::class
);
return $entities ? $entities[0] : null;
}
abstract protected static function getTableName(): string;
}
Попробуем снова открыть страничку с выводом одной статьи http://myproject.loc/articles/1.
И что мы видим внизу странички? Число 1! То есть несмотря на то, что мы выполнили 2 запроса к базе данных (получение статьи и получение пользователя), мы при этом создали только один объект базы данных и только одно соединение!
Так вот этот шаблон проектирования называется Singleton (синглтон). Этот паттерн говорит о том, что в рамках одного запущенного приложения будет гарантироваться что будет использован только один объект какого-то класса. Классы, реализующие паттерн синглтон сами гарантируют, что будет использоваться только один их экземпляр – создать объекты можно только с помощью специального метода, ведь конструктор больше недоступен извне. А этот метод следит за тем, чтобы не было более одного созданного объекта и предоставляет единую точку доступа к этому экземпляру. Вот и вся суть паттерна Singleton.
Давайте теперь приберемся и удалим вывод отладочной информации во фронт-контроллере www/index.php, а также удалим логику подсчёта числа созданных экземпляров в классе Db – она нам больше не нужна, так как всегда теперь будет один объект.
src/MyProject/Services/Db.php
<?php
namespace MyProject\Services;
class Db
{
private static $instance;
/** @var \PDO */
private $pdo;
private 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, 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);
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
}
Теперь во всех местах, где нам нужна будет база данных, мы будем писать:
Db::getInstance()
Вот такой вот довольно простой но очень полезный паттерн проектирования, о котором начинающих часто спрашивают на собеседовании.
PHP Reflection API
Всем привет. Поговорим для начала о том, что же вообще такое рефлексия (от англ. reflection - отражение) в программировании. Этот термин пришёл к нам из психологии. Там данное слово означает способность человека к самоанализу, оценке своих поступков, мыслей и прочего вот этого всего. Кроме того, человек в процессе всего этого может ещё и изменять свою точку зрения, что приведёт к изменению его поведения. Конечно, наверняка есть более подходящее понятие, но для нас главное понять суть.
Попробуем теперь перенести это понятие из жизни человека на время выполнения программы. Получим что-то типа того, что программа во время своего выполнения может в реальном времени «узнавать» о своём состоянии и изменять своё поведение . Википедия же предлагает следующее определение: “Рефлексия означает процесс, во время которого программа может отслеживать и модифицировать собственную структуру и поведение во время выполнения.”
В PHP имеется очень мощный набор инструментов, позволяющий реализовать рефлексию. Рассмотрим некоторые инструменты для рефлексии, о которых мы уже знаем. Языковые конструкции self и static , магические константы DIR и CLASS , функции get_defined_vars() , func_get_args() или eval() . В конце концов возможность создавать объект класса, имя которого хранится в переменной:
$obj = new $className();
а затем и вызов метода, название которого так же хранится в переменной:
$obj->$methodName();
Всё это рефлексия, всё это используется для того, чтобы влиять на поведение программы непосредственно во время её выполнения.
Однако есть в PHP кое-что ещё более мощное — это PHP Reflection API .
PHP Reflection API – это набор специальных классов-рефлекторов, позволяющих вывести рефлексию на новый уровень. С помощью этих классов мы можем создавать объекты-рефлекторы для разных типов данных в PHP, которые позволят творить с ними всё что только вздумается.
Перейдём к практике и рассмотрим класс-рефлектор для функций — ReflectionFunction .
Создадим новую функцию:
/**
* @param $a
* @param $b
* @return int
*/
function sum($a, $b)
{
return $a + $b;
}
Теперь создадим объект-рефлектор для неё:
$sumReflector = new ReflectionFunction('sum');
После чего нам становится доступен ряд методов у этого объекта, благодаря которым мы можем делать с этой функцией очень интересные вещи. Например, узнать в каком файле объявлена функция:
echo $sumReflector->getFileName();
или узнать строки её начала и конца:
echo $sumReflector->getStartLine();
echo $sumReflector->getEndLine();
как вы понимаете, этого уже достаточно для получения тела этой функции - мы можем просто прочитать эти строки из файла.
Ещё мы можем получить комментарий к функции в формате PHPDoc (почитайте о PHPDoc, если до сих пор этого не сделали):
echo $sumReflector->getDocComment();
Все методы мы рассматривать не будем, если стало интересно — почитайте документацию.
Рефлексия объектов
Помимо этого можно создавать рефлекторы объектов. Давайте попробуем создать объект-рефлектор для нашей сущности Article.
Сделаем это в контроллере для статей. И давайте сразу используем этот рефлектор для вывода свойств объекта Article.
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);
$reflector = new \ReflectionObject($article);
$properties = $reflector->getProperties();
var_dump($properties);
return;
...
Мы видим массив объектов-рефлекторов для свойств объекта (ReflectionProperty). Они содержат два свойства - имя свойство и имя класса, в котором оно объявлено. Обратите внимание, что свойство id успешно унаследовано от класса ActiveRecordEntity.
А теперь давайте просто создадим массив, содержащий только имена свойств, в виде строк.
src/MyProject/Controllers/ArticlesController.php
...
public function view(int $articleId)
{
$article = Article::getById($articleId);
$reflector = new \ReflectionObject($article);
$properties = $reflector->getProperties();
$propertiesNames = [];
foreach ($properties as $property) {
$propertiesNames[] = $property->getName();
}
var_dump($propertiesNames);
return;
...
Эти знания понадобятся нам в следующем уроке. А пока ещё несколько интересных методов для рефлектора объектов.
Получить все методы:
->getMethods()
Получить все константы:
->getConstants()
Создание нового объекта (даже с непубличным конструктором)
->newInstance()
Создание нового объекта без вызова конструктора (o_O)
->newInstanceWithoutConstructor()
Этих знаний достаточно, чтобы начать использовать Reflection API . Главное помните — использования этого инструмента следует по возможности избегать, так как это работает довольно медленно. Большинство задач можно решить без использования рефлексии, но знать о ней настоящий профи обязан. Иногда её использование позволяет создать довольно изящные решения, одно из которых мы рассмотрим в следующем уроке. А со всеми возможные рефлекторами можно ознакомиться в официальной документации. До встречи
Обновление с помощью Active Record
В этом уроке мы научимся обновлять данные в базе данных в стиле объектно-ориентированного подхода. Для этого, как вы уже наверное догадались, мы будем использовать всё те же сущности ActiveRecord.
Давайте представим, что у нас есть объект класса Article, который был прочитан из базы данных, и который мы хотим изменить.
Давайте для изменения статей сделаем отдельный роут (^articles/(\d+)/edit$):
src/routes.php
<?php
return [
'~^articles/(\d+)$~' => [\MyProject\Controllers\ArticlesController::class, 'view'],
'~^articles/(\d+)/edit$~' => [\MyProject\Controllers\ArticlesController::class, 'edit'],
'~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];
Теперь добавим в контроллере новый экшн edit, в котором мы пока просто будем получать статью и выводить её с помощью var_dump();
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): void
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$this->view->renderHtml('articles/view.php', [
'article' => $article
]);
}
public function edit(int $articleId): void
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
var_dump($article);
}
}
Перейдём по новому URL и убедимся, что все работает - http://myproject.loc/articles/1/edit
Теперь, предположим, что мы решили изменить этот объект. Давайте изменим у этого объекта свойства name и text (не забудьте добавить сеттеры для этих полей в классе Article):
src/MyProject/Controllers/ArticlesController.php
public function edit(int $articleId): void
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$article->setName('Новое название статьи');
$article->setText('Новый текст статьи');
var_dump($article);
}
Как видим, свойства у объекта успешно были изменены. Но это никак не повлияло на его состояние в базе данных.
И это логично, ведь мы после этого не выполнили запросов на обновление записи в базе. Давайте реализуем этот механизм.
Давайте создадим метод save() в классе ActiveRecordEntity, который будет сохранять текущее состояние обоъекта в базе.
Для того, чтобы обновить запись в базе данных, нам нужно выполнить запрос в MySQL:
UPDATE `articles` SET `id`=1,`author_id`=1,`name`='Новое название статьи',`text`='Новый текст статьи',`created_at`='2018-06-26 22:06:21' WHERE id = 1
где во все поля подставить текущие значения у объекта.
Но ведь у разных наследников класса ActiveRecordEntity разные свойства. Вопрос – как их получить, не привязываясь к конкретному классу. Да с помощью рефлексии, которую мы изучили на прошлом занятии!
Алгоритм у нас будет такой:
- Получаем имена свойств объекта с помощью рефлексии, например, authorId
- Преобразовываем это значение из camelCase в строку_с_подчеркушками, например, author_id – именно так называется поле в базе данных
- Составляем результирующий запрос на обновление записи в базе данных.
Итак, давайте теперь сделаем это!
Для начала давайте напишем метод, который будет преобразовывать строки типа authorId в author_id.
Это можно сделать с помощью регулярного выражения: перед каждой заглавной буквой мы добавляем символ подчеркушки «_», а затем приводим все буквы к нижнему регистру:
src/MyProject/Models/ActiveRecordEntity.php
...
private function camelCaseToUnderscore(string $source): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $source));
}
Отлично, теперь давайте напишем метод, который прочитает все свойства объекта и создаст массив вида:
[
'название_свойства1' => значение свойства1,
'название_свойства2' => значение свойства2
]
src/MyProject/Models/ActiveRecordEntity.php
...
private function mapPropertiesToDbFormat(): array
{
$reflector = new \ReflectionObject($this);
$properties = $reflector->getProperties();
$mappedProperties = [];
foreach ($properties as $property) {
$propertyName = $property->getName();
$propertyNameAsUnderscore = $this->camelCaseToUnderscore($propertyName);
$mappedProperties[$propertyNameAsUnderscore] = $this->$propertyName;
}
return $mappedProperties;
}
private function camelCaseToUnderscore(string $source): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $source));
}
Здесь мы получили все свойства, и затем каждое имяСвойства привели к имя_свойства. После чего в массив $mappedProperties мы стали добавлять элементы с ключами «имя_свойства» и со значениями этих свойств.
Давайте посмотрим, что у нас получилось. Выведем массив, полученный с помощью этого метода в методе save().
public function save(): void
{
$mappedProperties = $this->mapPropertiesToDbFormat();
var_dump($mappedProperties);
}
private function mapPropertiesToDbFormat(): array
{
$reflector = new \ReflectionObject($this);
$properties = $reflector->getProperties();
$mappedProperties = [];
foreach ($properties as $property) {
$propertyName = $property->getName();
$propertyNameAsUnderscore = $this->camelCaseToUnderscore($propertyName);
$mappedProperties[$propertyNameAsUnderscore] = $this->$propertyName;
}
return $mappedProperties;
}
private function camelCaseToUnderscore(string $source): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $source));
}
Теперь вызовем этот метод сущности в контроллере:
src/MyProject/Controllers/ArticlesController.php
public function edit(int $articleId): void
{
$article = Article::getById($articleId);
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
$article->setName('Новое название статьи');
$article->setText('Новый текст статьи');
$article->save();
}
Посмотрим на то, что этот код выдаёт.
Отлично! Мы имеем структуру, которая соответствует структуре в базе данных. Теперь на её основе можно построить запрос!
Но перед этим нам стоит обратить внимание, что метод save() может быть вызван как у объекта, который уже есть в базе данных, так и у нового (если мы создали его с помощью new Article и заполнили ему свойства). Для первого нам нужно будет выполнить UPDATE-запрос, а для второго - INSERT-запрос. Как понять, с каким типом объекта мы работаем? Да всё проще простого – у объекта, которому соответствует запись в базе, свойство id не равно null, а если мы только создали объект, у него свойства id ещё нет.
Поэтому нам нужно разделить логику метода save() для этих двух случаев. Вот что у нас должно получиться:
src/MyProject/Models/ActiveRecordEntity.php
...
public function save(): void
{
$mappedProperties = $this->mapPropertiesToDbFormat();
if ($this->id !== null) {
$this->update($mappedProperties);
} else {
$this->insert($mappedProperties);
}
}
private function update(array $mappedProperties): void
{
//здесь мы обновляем существующую запись в базе
}
private function insert(array $mappedProperties): void
{
//здесь мы создаём новую запись в базе
}
...
В этом уроке мы напишем реализацию только одного метода – update(). Синтаксис запроса выглядит следующим образом:
UPDATE table_name
SET column1 = :param1, column2 = :param2, ...
WHERE condition;
После этого нам нужно подставить в запрос параметры:
:param1 = value1
:param2 = value2
У нас есть массив column1 = value1, column2 = value2. Всё что нам нужно – разделить его на 2 массива:
- будет содержать строки: column1 = :param1
- будет содержать ключ => значение вида: [:param1 => value1]
и собрать из этих частей готовый запрос!
Чтобы было проще понимать, что происходит, будем делать это поэтапно. Для начала напишем код, который будет создавать два этих массива:
private function update(array $mappedProperties): void
{
$columns2params = [];
$params2values = [];
$index = 1;
foreach ($mappedProperties as $column => $value) {
$param = ':param' . $index; // :param1
$columns2params[] = $column . ' = ' . $param; // column1 = :param1
$params2values[':param' . $index] = $value; // [:param1 => value1]
$index++;
}
var_dump($columns2params);
var_dump($params2values);
}
После этого мы получим следующий результат:
Теперь дело за малым – сформировать запрос:
private function update(array $mappedProperties): void
{
$columns2params = [];
$params2values = [];
$index = 1;
foreach ($mappedProperties as $column => $value) {
$param = ':param' . $index; // :param1
$columns2params[] = $column . ' = ' . $param; // column1 = :param1
$params2values[':param' . $index] = $value; // [:param1 => value1]
$index++;
}
$sql = 'UPDATE ' . static::getTableName() . ' SET ' . implode(', ', $columns2params) . ' WHERE id = ' . $this->id;
var_dump($sql);
var_dump($params2values);
}
Остаётся только выполнить этот запрос и передать нужные параметры!
private function update(array $mappedProperties): void
{
$columns2params = [];
$params2values = [];
$index = 1;
foreach ($mappedProperties as $column => $value) {
$param = ':param' . $index; // :param1
$columns2params[] = $column . ' = ' . $param; // column1 = :param1
$params2values[':param' . $index] = $value; // [:param1 => value1]
$index++;
}
$sql = 'UPDATE ' . static::getTableName() . ' SET ' . implode(', ', $columns2params) . ' WHERE id = ' . $this->id;
$db = Db::getInstance();
$db->query($sql, $params2values, static::class);
}
Сейчас этот скрипт ничего не выведет, но если мы зайдём в базу данных, мы обнаружим, что запись, соответствующая нашей статье – изменилась!
Давайте теперь попробуем посмотреть нашу обновленную статью, перейдя по адресу http://myproject.loc/articles/1
Итак, в этом уроке мы создали универсальный метод, который позволит обновлять записи в бд для любых объектов, являющимися наследниками класса ActiveRecordEntity.
Домашнее задание
Напишите самостоятельно метод insert(), который будет добавлять в базу новую запись. Не торопитесь, разбейте задачу на несколько компонентов и решите, какую последовательность действий нужно сделать.
Вставка с помощью Active Record
Привет! В предыдущем уроке мы написали метод save(), который в зависимости от того, есть ли у объекта id, решает – обновить запись или создать новую. Он вызывает в свою очередь другие методы:
- update(), если id у объекта есть;
- insert(), если это свойство у объекта равно null.
В прошлом уроке мы реализовали метод update(), который позволяет обновлять уже существующие записи в базе. В этом уроке мы напишем метод insert(), который будет создавать новые записи в нашей базе данных.
Давайте добавим новый роут, с помощью которого будут создаваться новые статьи.
src/routes.php
<?php
return [
...
'~^articles/add$~' => [\MyProject\Controllers\ArticlesController::class, 'add'],
...
];
Добавим новый экшн в контроллере:
src/MyProject/Controllers/ArticlesController.php
public function add(): void
{
$author = User::getById(1);
$article = new Article();
$article->setAuthor($author);
$article->setName('Новое название статьи');
$article->setText('Новый текст статьи');
$article->save();
var_dump($article);
}
И добавим сеттер автора в сущности Article:
src/MyProject/Models/Articles/Article.php
/**
* @param User $author
*/
public function setAuthor(User $author): void
{
$this->authorId = $author->getId();
}
Если сейчас мы перейдём по новому адресу:
http://myproject.loc/articles/add
То увидим созданный нами объект.
Разумеется, в базе данных он сейчас не появится, так как метод insert() в классе ActiveRecordEntity сейчас пуст.
Самое время его написать
Реализация метода insert()
Давайте рассмотрим синтаксис SQL-запроса, который нам нужно написать:
INSERT INTO <имя таблицы> (<имя столбца 1>, <имя столбца 2>,...) VALUES (<значение столбца 1>, <значение столбца 2>,…);
Для сущности Article запрос на вставку будет выглядеть вот так:
INSERT INTO `articles` (`author_id`, `name`, `text`) VALUES (:author_id, :name, :text)
А затем нам нужно будет передать массив с параметрами, вроде такого:
[':author_id' => 1, ':name' => 'Название', ':text' => 'Текст']
Первым делом давайте посмотрим на то, какие данные вообще у нас есть:
src/MyProject/Models/ActiveRecordEntity.php
private function insert(array $mappedProperties): void
{
var_dump($mappedProperties);
}
Мы видим, что у нас есть 2 поля со значением null. Это поля id и created_at. Поле id нам в запросе не нужно, так как для него будет автоматически выдано значение на уровне базы данных, так как оно типа AUTOINCREMENT, а для поля created_at задано значение по умолчанию – CURRENT_TIMESTAMP. Таким образом, эти поля можно вообще убрать из запроса. Для этого мы отфильтруем элементы в массиве от тех, значение которых = NULL:
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
var_dump($filteredProperties);
}
Отлично, теперь у нас осталось всего 3 поля. Давайте для начала сформируем массив, содержащий названия столбцов в таблице:
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
$columns = [];
foreach ($filteredProperties as $columnName => $value) {
$columns[] = '`' . $columnName. '`';
}
var_dump($columns);
}
А теперь подготовим массив с именами подстановок, вроде :author_id и :name.
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
$columns = [];
$paramsNames = [];
foreach ($filteredProperties as $columnName => $value) {
$columns[] = '`' . $columnName. '`';
$paramName = ':' . $columnName;
$paramsNames[] = $paramName;
}
var_dump($columns);
var_dump($paramsNames);
}
Ну и, наконец, подготовим параметры, которые нужно будет подставить в запрос.
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
$columns = [];
$paramsNames = [];
$params2values = [];
foreach ($filteredProperties as $columnName => $value) {
$columns[] = '`' . $columnName. '`';
$paramName = ':' . $columnName;
$paramsNames[] = $paramName;
$params2values[$paramName] = $value;
}
var_dump($columns);
var_dump($paramsNames);
var_dump($params2values);
}
Все части для запроса готовы. Остаётся лишь собрать готовый запрос.
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
$columns = [];
$paramsNames = [];
$params2values = [];
foreach ($filteredProperties as $columnName => $value) {
$columns[] = '`' . $columnName. '`';
$paramName = ':' . $columnName;
$paramsNames[] = $paramName;
$params2values[$paramName] = $value;
}
$columnsViaSemicolon = implode(', ', $columns);
$paramsNamesViaSemicolon = implode(', ', $paramsNames);
$sql = 'INSERT INTO ' . static::getTableName() . ' (' . $columnsViaSemicolon . ') VALUES (' . $paramsNamesViaSemicolon . ');';
var_dump($sql);
}
Остаётся выполнить запрос, подставив нужные параметры.
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
$columns = [];
$paramsNames = [];
$params2values = [];
foreach ($filteredProperties as $columnName => $value) {
$columns[] = '`' . $columnName. '`';
$paramName = ':' . $columnName;
$paramsNames[] = $paramName;
$params2values[$paramName] = $value;
}
$columnsViaSemicolon = implode(', ', $columns);
$paramsNamesViaSemicolon = implode(', ', $paramsNames);
$sql = 'INSERT INTO ' . static::getTableName() . ' (' . $columnsViaSemicolon . ') VALUES (' . $paramsNamesViaSemicolon . ');';
$db = Db::getInstance();
$db->query($sql, $params2values, static::class);
}
Если мы сейчас обновим нашу страничку, то увидим, что в базе данных успешно добавилась новая запись.
Обратите внимание, что в контроллере мы вывели наш объект уже после сохранения в базу:
src/MyProject/Controllers/ArticlesController.php
public function add(): void
{
$author = User::getById(1);
$article = new Article();
$article->setAuthor($author);
$article->setName('Новое название статьи');
$article->setText('Новый текст статьи');
$article->save();
var_dump($article);
}
Однако, у него не обновилось поле id, а ведь это нам необходимо. Потому что если мы что-либо изменим сейчас в этом объекте и снова вызовем метод save(), то вместо обновления записи в базе, будет создана ещё одна. Так произойдет, потому что в методе save() мы проверяем значение поля id, и если оно равно null, то мы вызываем insert(), а не update(). Давайте исправим это недоразумение. Для того, чтобы получить id последней вставленной записи в базе (в рамках текущей сессии работы с БД) можно использовать метод lastInsertId() у объекта PDO. Давайте в нашем классе Db добавим следующий метод:
src/MyProject/Services/Db.php
public function getLastInsertId(): int
{
return (int) $this->pdo->lastInsertId();
}
Теперь используем его в методе insert():
private function insert(array $mappedProperties): void
{
$filteredProperties = array_filter($mappedProperties);
$columns = [];
$paramsNames = [];
$params2values = [];
foreach ($filteredProperties as $columnName => $value) {
$columns[] = '`' . $columnName. '`';
$paramName = ':' . $columnName;
$paramsNames[] = $paramName;
$params2values[$paramName] = $value;
}
$columnsViaSemicolon = implode(', ', $columns);
$paramsNamesViaSemicolon = implode(', ', $paramsNames);
$sql = 'INSERT INTO ' . static::getTableName() . ' (' . $columnsViaSemicolon . ') VALUES (' . $paramsNamesViaSemicolon . ');';
$db = Db::getInstance();
$db->query($sql, $params2values, static::class);
$this->id = $db->getLastInsertId();
}
Снова перезагрузим страницу.
Как видим, теперь у объекта после вызова метода save() появился id.
Однако, в свойстве createdAt по-прежнему null. Доработать этот недостаток вы сможете в домашнем задании. А на этом данный урок заканчивается.
Домашнее задание
Доработайте метод insert() таким образом, чтобы поля объекта обновлялись значениями из БД. Например, в поле createdAt должна появиться строка с датой.
Удаление в Active Record
Всем привет! Сегодня мы дошли до самого интересного – операции удаления в Active Record
Удаление объекта мы будем производить вот так:
$article->delete();
То есть берём какой-то объект-наследник класса ActiveRecordEntity и вызываем у него метод delete(). Это должно привести к двум вещам:
- Должна удалиться запись в базе данных, соответствующая этому объекту
- Свойство id у этого объекта должно стать null (ведь его больше нет, логично же)
Удаление записей из таблиц выполняется следующим запросом:
DELETE FROM `название таблицы` WHERE id = :id;
А дальше идёт текст для совсем ленивых. Если Вы не такой – напишите метод delete() самостоятельно – это очень просто.
Итак, создаём в нашем классе ActiveRecordEntity метод delete().
src/MyProject/Models/ActiveRecordEntity.php
public function delete(): void
{
$db = Db::getInstance();
$db->query(
'DELETE FROM `' . static::getTableName() . '` WHERE id = :id',
[':id' => $this->id]
);
$this->id = null;
}
Всё! Метод для удаления готов!
Испытать его я вам предлагаю самостоятельно. О том, что именно требуется сделать, вы узнаете в домашнем задании.
Домашнее задание
Создайте роут для удаления статей - http://myproject.loc/articles/2/delete
При заходе на него получайте объект статьи по id и после этого удаляйте статью. Если статьи с таким id нет, то выдавайте соответствующее сообщение. Если же статья была, то выводите объект с помощью var_dump после удаления.
CRUD-операции
Всем привет! В этом небольшом уроке мы затронем такое понятие как CRUD .
CRUD – это аббревиатура четырех слов, означающих следующие операции:
- Create (создание);
- Read (чтение);
- Update (обновление);
- Delete (удаление).
При работе с базами данных практически любое приложение должно поддерживать эти операции.
В базе данных MySQL этим операциям соответствуют запросы:
- INSERT;
- SELECT;
- UPDATE;
- DELETE.
Если вы дошли до этого урока, то вы знаете, что все эти операции реализованы в нашем приложении в классе ActiveRecordEntity.
Весь этот урок – лишь пояснение того, что это за аббревиатура, так как Вас на собеседовании могут просто спросить: «Что такое CRUD?». И Вы с лёгкостью ответите, что это, а также, что Вы самостоятельно этот функционал реализовывали. Вот в общем-то и всё
M в MVC
Всем привет! В этом уроке мы добрались до последнего элемента архитектуры MVC, скрывающегося за буквой M. Речь сегодня пойдёт о Model (модели).
Model в MVC – это слой приложения, отвечающий за работу с данными, и содержащий в себе бизнес-логику. Бизнес-логика - это логика приложения, которая описывает то, что требуется от кода со стороны бизнеса. Например, бизнесу требуется, чтобы админ мог создавать записи в блоге. Значит при создании новой записи в блоге нужно проверять, что пользователь, создающий запись, является админом. И конкретно эта логика должна описываться в слое модели.
Таким образом, работа с базой данных, CRUD-операции и бизнес-логика – всё это должно описываться в модели.
Если говорить о моделях в нашем приложении, то это будет класс ActiveRecordEntity и его наследники – ведь именно они работают с базой данных и хранят в себе данные. При этом это не мешает нам создать какие-то дополнительные классы, которые будут промежуточным звеном между классами, реализующими Active Record, и контроллерами. Эти классы тоже могут содержать в себе бизнес-логику, но при этом не будут работать напрямую с базой данных. Они тоже будут относиться к слою модели.
Вот и весь урок о том, что же такое Модель в MVC. Как видите, мы эту часть приложения уже успешно реализовали. Если есть вопросы – задавайте в комментариях.
Работа с исключениями в PHP
Сегодня мы с вами разберем такую тему как исключения в PHP. Но прежде чем перейти к деталям, давайте дадим простое понятие термину «Исключительная ситуация». Исключительная ситуация в программе – это ситуация, при которой дальнейшее выполнение кода не имеет смысла. Например, новости на страничке /post/add могут добавлять только администраторы, но при этом это пытается сделать неавторизованный пользователь. Здесь имеет смысл проверить в самом начале права пользователя и если их недостаточно, обработать эту исключительную ситуацию. Или вот ещё один пример: наше приложение работает с базой данных, но при подключении к серверу MySQL обнаруживается, что такой базы данных на сервере нет. При такой ситуации тоже не имеет смысла продолжать выполнение скрипта – это исключительная ситуация.
В PHP для работы с такими исключительными ситуациями есть специальный механизм - исключения. Исключение – это такой объект специального класса. Этот класс является встроенным в PHP и называется Exception. Создание исключения выглядит следующим образом:
<?php
$exception = new Exception();
В качестве аргументов в конструктор можно передать сообщение и код ошибки:
$exception = new Exception('Сообщение об ошибке', 123);
При этом класс Exception это немного нестандартный класс – объекты этого класса можно «бросать». Для этого используется оператор throw. Делается это вот так:
<?php
$exception = new Exception('Сообщение об ошибке', 123);
throw $exception;
Если мы сейчас запустим этот скрипт, то увидим следующее:
В ошибке говорится о том, что произошло «непойманное» исключение, или как это принято говорить в русскоязычном варианте, необработанное исключение.
Для того, чтобы исключение поймать, используется специальная конструкция try-catch:
<?php
try {
throw new Exception('Сообщение об ошибке', 123);
} catch (Exception $e) {
echo 'Было поймано исключение: ' . $e->getMessage() . '. Код: ' . $e->getCode();
}
В чем же смысл такой сложной конструкции? Дело в том, что исключение будет подниматься по стеку вызовов выше и выше, до тех пор, пока оно не будет поймано. Вот что это значит:
Пусть у нас есть 3 финкции: func1, func2 и func3. func1 вызывает внутри себя func2, а func2 вызывает func3.
function func1()
{
// какой-то код
func2();
}
function func2()
{
// какой-то код
func3();
}
function func3()
{
// код, в котором возможна исключительная ситуация
throw new Exception('Ошибка при подключении к БД');
}
Для того, чтобы обработать это исключение уровнем выше, достаточно написать блок try-catch на уровне func2, обернув вызов func3 внутри секции try:
<?php
function func1()
{
// какой-то код
func2();
}
function func2()
{
try {
// какой-то код
func3();
} catch (Exception $e) {
echo 'Было поймано исключение: ' . $e->getMessage();
}
}
function func3()
{
// код, в котором возможна исключительная ситуация
throw new Exception('Ошибка при подключении к БД');
}
func1();
А можем и вовсе поймать его и в func1:
<?php
function func1()
{
try {
// какой-то код
func2();
} catch (Exception $e) {
echo 'Было поймано исключение: ' . $e->getMessage();
}
}
function func2()
{
// какой-то код
func3();
}
function func3()
{
// код, в котором возможна исключительная ситуация
throw new Exception('Ошибка при подключении к БД');
}
func1();
В этом случае исключение будет брошено внутри func3, поднимется на уровень func2, там его никто не поймает, и оно пойдет на еще уровень выше, в место, где была вызвана func2 – внутри func1. И вот здесь-то оно и будет поймано и обработано. После того как исключение обработано, будет выполнен код, который идет после блока try-catch.
<?php
function func1()
{
try {
// какой-то код
func2();
} catch (Exception $e) {
echo 'Было поймано исключение: ' . $e->getMessage();
}
echo 'А теперь выполнится этот код';
}
function func2()
{
// какой-то код
func3();
}
function func3()
{
// код, в котором возможна исключительная ситуация
throw new Exception('Ошибка при подключении к БД');
echo 'Этот код не выполнится, так как идет после места, где было брошено исключение';
}
func1();
Код, который идёт после того, где было брошено исключение, выполнен не будет. Исключение прерывает выполнение кода, и только после места, где оно было поймано и обработано, код продолжит выполняться.
Наследование классов-исключений
От класса Exception можно наследоваться. Таким образом, мы можем создавать свои классы исключений для разных ситуаций. Например, для ошибок при работе с базой данных мы можем создать класс DbException, а для ошибок при работе с файлами – FileSystemException.
Блок try-catch позволяет обрабатывать разные типы исключений, это выглядит так:
try {
// тут какой-то код
} catch (DbException $e) {
// обработка исключений, связанных с базой данных
} catch (FileSystemException $e) {
// обработка исключений, связанных с файловой системой
}
Давайте теперь попробуем использовать этот механизм в нашем приложении. В качестве примера я покажу, как это можно использовать для обработки ошибок при подключении к БД.
Для начала давайте зададим некорректное название базы данных для подключения и попробуем запустить наше приложение.
src/settings.php
<?php
return [
'db' => [
'host' => 'localhost',
'dbname' => 'my_project2',
'user' => 'root',
'password' => '',
]
];
Как видим, возникла ошибка – непойманное исключение типа PDOException. Это такой встроенный в PHP класс для исключений при работе с базой данных через PDO.
Давайте теперь создадим собственный класс исключений специально для базы данных. Назовём его DbException.
src/MyProject/Exceptions/DbException.php
<?php
namespace MyProject\Exceptions;
class DbException extends \Exception
{
}
Теперь создадим «ловушки» для стандартных исключениий класса PDOException, и будем заменять их своими исключениями. Вот так:
src/MyProject/Services/Db.php
private function __construct()
{
$dbOptions = (require __DIR__ . '/../../settings.php')['db'];
try {
$this->pdo = new \PDO(
'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
$dbOptions['user'],
$dbOptions['password']
);
$this->pdo->exec('SET NAMES UTF8');
} catch (\PDOException $e) {
throw new DbException('Ошибка при подключении к базе данных: ' . $e->getMessage());
}
}
Снова перезагрузим страничку и увидим уже наше сообщение об ошибке.
Давайте теперь обработаем наше исключение. Для этого нам требуется поймать исключение уже с типом DbException. Так как ошибка при работе с базой данных – это критичная ошибка, которая наверняка не позволит выполняться программе дальше, нам стоит ловить её на самом низком уровне нашего приложения – во фронт-контроллере. Обернем в блок try-catch код фронт-контроллера.
www/index.php
try {
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);
} catch (\MyProject\Exceptions\DbException $e) {
echo $e->getMessage();
}
Если мы теперь обновим страничку, то увидим уже только наше сообщение, без каких-либо необработанных исключений. Мы поймали исключение и просто вывели текст ошибки через echo.
А теперь давайте во фронт-контроллере научимся выводить ошибки, используя наш компонент View. То есть мы будем выводить ошибки через шаблоны. Это позволит избежать дублирования кода и всегда использовать один механизм для вывода.
Первым делом создадим новый шаблон для критичных ошибок, которые говорят о том, что сайт в данный момент недоступен. Для этого принято использовать код ответа 500. Поэтому мы так и назовём этот шаблон.
templates/errors/500.php
<h1>Хьюстон, у нас проблема!</h1>
<?= $error ?>
А теперь просто обработаем исключение во фронт-контроллере по-новому.
www/index.php
...
$controller = new $controllerName();
$controller->$actionName(...$matches);
} catch (\MyProject\Exceptions\DbException $e) {
$view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
$view->renderHtml('500.php', ['error' => $e->getMessage()], 500);
}
Отлично, теперь мы видим красивую, а главное – понятную нам ошибку. Вернём настройки базы данных, и убедимся, что блог работает.
А теперь давайте посмотрим на вот этот кусок кода во фронт-контроллере:
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
Этот код работает, когда не нужен нужный роутинг. Это ведь тоже исключительная ситуация! Давайте сделаем исключение для случаев, когда страничка не найдена.
src/MyProject/Exceptions/NotFoundException.php
<?php
namespace MyProject\Exceptions;
class NotFoundException extends \Exception
{
}
И начнём бросать его в случае, когда роут не найден. Для этого заменяем код
www/index.php
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
на
if (!$isRouteFound) {
throw new \MyProject\Exceptions\NotFoundException();
}
И проверяем на несуществующем роуте, например:
Видим ошибку о непойманном исключении, так давайте же его поймаем! Для этого добавляем еще один блок catch.
www/index.php
<?php
try {
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) {
throw new \MyProject\Exceptions\NotFoundException();
}
unset($matches[0]);
$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];
$controller = new $controllerName();
$controller->$actionName(...$matches);
} catch (\MyProject\Exceptions\DbException $e) {
$view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
$view->renderHtml('500.php', ['error' => $e->getMessage()], 500);
} catch (\MyProject\Exceptions\NotFoundException $e) {
$view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
$view->renderHtml('404.php', ['error' => $e->getMessage()], 404);
}
Пробуем снова зайти по несуществующему адресу:
Теперь мы видим ошибку из шаблона, и при этом сервер вернул код 404 – то, что нужно!
А теперь – внимание. Помните, мы уже использовали шаблон ошибки 404? Мы тогда писали в контроллере статей что-то типа такого:
src/MyProject/Controllers/ArticlesController.php
if ($article === null) {
$this->view->renderHtml('errors/404.php', [], 404);
return;
}
А теперь мы можем просто кинуть там исключение, вот так:
if ($article === null) {
throw new NotFoundException();
}
И оно всплывет через все слои нашей программы до фронт-контроллера, где будет успешно поймано!
Давайте перепишем контроллер статей:
src/MyProject/Controllers/ArticlesController.php
<?php
namespace MyProject\Controllers;
use MyProject\Exceptions\NotFoundException;
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) {
throw new NotFoundException();
}
$this->view->renderHtml('articles/view.php', [
'article' => $article
]);
}
public function edit(int $articleId)
{
$article = Article::getById($articleId);
if ($article === null) {
throw new NotFoundException();
}
$article->setName('Новое название статьи');
$article->setText('Новый текст статьи');
$article->save();
}
public function add(): void
{
$author = User::getById(1);
$article = new Article();
$article->setAuthor($author);
$article->setName('Новое название статьи');
$article->setText('Новый текст статьи');
$article->save();
$article->delete();
var_dump($article);
}
}
А теперь попробуем зайти на адрес с несуществующей статьёй: http://myproject.loc/articles/123
Видим всё ту же ошибку! Давайте теперь посмотрим, что произошло. Поставим breakpoint в отладчике на место, где бросается исключение и обновим страничку.
Видите, слева внизу есть стек вызовов. Если переключиться на предыдущий уровень (index.php), мы увидим, где был вызван экшн контроллера:
Это произошло в файле index.php, внутри блока try-catch. Так как в самом экшене брошенное исключение никто не ловит, оно всплывет на уровень, где был вызван экшен – в файл index.php. Если мы сейчас нажмем F8, то увидим, что исключение было успешно поймано и мы попали в нужный нам блок catch.
Здесь мы вывели сообщение об ошибке – вот и всё! Теперь, если нам в приложении потребуется где-то вывести сообщение о несуществующей странице – мы просто бросим исключение с нужным типом.
На этом всё, за домашку =)
Домашнее задание
Самостоятельно прочитайте в официальной документации о новом типе ошибок в PHP 7 – Error. Их тоже можно бросать, однако на данном этапе мы их использовать не будем и пока ограничимся только типом Exception. Также изучите самостоятельно интерфейс Throwable. Попробуйте реализовать этот интерфейс в каком-нибудь классе.