[webshake] Продвинутый курс «ООП в PHP» - Часть 3

Паттерн 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 и установлено новое соединение с базой данных при:

  1. вызове метода Article::getById($articleId)
  2. вызове метода 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;
    ...

А теперь давайте добавим в этот класс специальный статический метод, который будет делать следующее:

  1. Проверять, что свойство $instance не равно null
  2. Если оно равно null, будет создан новый объект класса Db, а затем помещён в это свойство
  3. Вернёт значение этого свойства.

Давайте напишем этот простейший код:

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();

Теперь, когда мы вызовем этот метод несколько раз подряд, то произойдёт следующее:

  1. Во время первого запуска self::$instance будет равен null, поэтому создастся новый объект класса Db и задастся в это свойство. Затем этот объект просто вернётся в качестве результата
  2. При всех последующих запусках в свойстве $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;
        ...

Посмотрим на результат:
Вывод свойств объекта с помощью PHP Reflection API

Мы видим массив объектов-рефлекторов для свойств объекта (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 . Главное помните — использования этого инструмента следует по возможности избегать, так как это работает довольно медленно. Большинство задач можно решить без использования рефлексии, но знать о ней настоящий профи обязан. Иногда её использование позволяет создать довольно изящные решения, одно из которых мы рассмотрим в следующем уроке. А со всеми возможные рефлекторами можно ознакомиться в официальной документации. До встречи :wink:

Обновление с помощью 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 разные свойства. Вопрос – как их получить, не привязываясь к конкретному классу. Да с помощью рефлексии, которую мы изучили на прошлом занятии!

Алгоритм у нас будет такой:

  1. Получаем имена свойств объекта с помощью рефлексии, например, authorId
  2. Преобразовываем это значение из camelCase в строку_с_подчеркушками, например, author_id – именно так называется поле в базе данных
  3. Составляем результирующий запрос на обновление записи в базе данных.

Итак, давайте теперь сделаем это!
Для начала давайте напишем метод, который будет преобразовывать строки типа 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 массива:

  1. будет содержать строки: column1 = :param1
  2. будет содержать ключ => значение вида: [: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);
}

После этого мы получим следующий результат:
Почти готовый запрос на UPDATE

Теперь дело за малым – сформировать запрос:

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 сейчас пуст.
Отсутствие записи в БД

Самое время его написать :slight_smile:

Реализация метода 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);
}

Сформированный 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();
}

Снова перезагрузим страницу.

Объект с полем id

Как видим, теперь у объекта после вызова метода save() появился id.
Однако, в свойстве createdAt по-прежнему null. Доработать этот недостаток вы сможете в домашнем задании. А на этом данный урок заканчивается.

Домашнее задание

Доработайте метод insert() таким образом, чтобы поля объекта обновлялись значениями из БД. Например, в поле createdAt должна появиться строка с датой.

Удаление в Active Record

Всем привет! Сегодня мы дошли до самого интересного – операции удаления в Active Record :slight_smile:

Удаление объекта мы будем производить вот так:

$article->delete();

То есть берём какой-то объект-наследник класса ActiveRecordEntity и вызываем у него метод delete(). Это должно привести к двум вещам:

  1. Должна удалиться запись в базе данных, соответствующая этому объекту
  2. Свойство 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?». И Вы с лёгкостью ответите, что это, а также, что Вы самостоятельно этот функционал реализовывали. Вот в общем-то и всё :slight_smile:

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 не поймано

Как видим, возникла ошибка – непойманное исключение типа 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 – то, что нужно!

А теперь – внимание. Помните, мы уже использовали шаблон ошибки 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. Попробуйте реализовать этот интерфейс в каком-нибудь классе.

5 симпатий