Реализуем Active Record в PHP

02.07.2023 в 21:11
23845
+1181

Сегодня мы изучим ещё один паттерн проектирования – Active Record. Этот шаблон говорит о том, что сущность (объекты класса статьи или пользователя) сами должны управлять работой с базой данных. То есть весь остальной код, который эти сущности использует, не должен знать о базе данных. Наши контроллеры не должны работать с базой данных, получая данные и заполняя ими сущности. Они должны знать только о сущностях. Сущность сама должна позаботиться о работе с базой данных. О том, как это реализовать – читайте далее.

Для начала нужно вообще понять, как стоит работать с сущностями при помощи такой концепции. Самое простое, что мы можем реализовать – это чтение из базы данных. И мы должны сделать это, обращаясь напрямую к сущностям-объектам. То есть мы должны сказать: «Эй, Article, дай мне все статьи». Но согласитесь, глупо будет для этого создать сущности, а после этого попросить чтобы они заполнили себя данными из базы. Нам нужно сделать это как-то по другому. Например, обратиться к сущности, не создавая её, но чтобы она при этом вернула нам созданные сущности. Вспоминаем статические методы – их ведь можно вызывать, не создавая объекта. То, что нам нужно!

Давайте добавим в Article статический метод, возвращающий нам все статьи.

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Services\Db;

class Article
{
    /** @var int */
    private $id;

    /** @var string */
    private $name;

    /** @var string */
    private $text;

    /** @var string */
    private $authorId;

    /** @var string */
    private $createdAt;

    public function __set($name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getText(): string
    {
        return $this->text;
    }

    /**
     * @return Article[]
     */
    public static function findAll(): array
    {
        $db = new Db();
        return $db->query('SELECT * FROM `articles`;', [], Article::class);
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }
}

Теперь, чтобы получить статьи в контроллере, нам нужно сделать следующее:

src/MyProject/Controllers/MainController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\View\View;

class MainController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function main()
    {
        $articles = Article::findAll();
        $this->view->renderHtml('main/main.php', ['articles' => $articles]);
    }
}

Заметили, как сразу упростился наш контроллер? Пропала зависимость от базы данных. Если вы сейчас попробуете выполнить этот код, то он успешно отработает.

А теперь давайте посмотрим на код этого статического метода.

/**
 * @return Article[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `articles`;', [], Article::class);
}

Согласитесь, можно заменить Article::class на self::class – и сюда автоматически подставится класс, в котором этот метод определен. А можно заменить его и вовсе на static::class – тогда будет подставлено имя класса, у которого этот метод был вызван. В чём разница? Если мы создадим класс-наследник SuperArticle, он унаследует этот метод от родителя. Если будет использоваться self:class, то там будет значение “Article”, а если мы напишем static::class, то там уже будет значение “SuperArticle”. Это называется поздним статическим связыванием – благодаря нему мы можем писать код, который будет зависеть от класса, в котором он вызывается, а не в котором он описан.

Итак, давайте изменим этот метод:

/**
 * @return Article[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `articles`;', [], static::class);
}

А теперь давайте попробуем избавиться от зависимости от таблицы “articles”. Вынесем получение названия таблицы в отдельный метод. Вот так:

/**
 * @return Article[]
 */
public static function findAll(): array
{
    $db = new Db();
    return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}

private static function getTableName(): string 
{
    return 'articles';
}

А теперь внимательно посмотрите на содержимое класса Article. Не кажется ли вам, что методы findAll(), __set(), underscoreToCamelCase() можно вот хоть сейчас взять и скопировать в сущность User, и начать их использовать? Только не нужно ничего копировать, мы ведь пишем на объектно-ориентированном языке, и можем использовать наследование! Мы можем просто вынести всю эту логику в отдельный класс, а там где она нужна, просто от него наследоваться. Давайте так и поступим.

Создадим отдельный класс, реализующий всю эту логику.

src/MyProject/Models/ActiveRecordEntity.php

<?php

namespace MyProject\Models;

abstract class ActiveRecordEntity
{

}

Так как создание объектов этого класса нам не нужно, то делаем его абстрактным. А теперь переносим в него универсальный код из класса Article.

<?php

namespace MyProject\Models;

use MyProject\Services\Db;

abstract class ActiveRecordEntity
{
    /** @var int */
    protected $id;

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

    public function __set(string $name, $value)
    {
        $camelCaseName = $this->underscoreToCamelCase($name);
        $this->$camelCaseName = $value;
    }

    private function underscoreToCamelCase(string $source): string
    {
        return lcfirst(str_replace('_', '', ucwords($source, '_')));
    }

    /**
     * @return static[]
     */
    public static function findAll(): array
    {
        $db = new Db();
        return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
    }

    abstract protected static function getTableName(): string;
}

Давайте по порядку.

  • добавили protected-свойство ->id и public-геттер для него – у всех наших сущностей будет id, и нет необходимости писать это каждый раз в каждой сущности – можно просто унаследовать;
  • перенесли public-метод __set() – теперь все дочерние сущности будут его иметь
  • перенесли метод underscoreToCamelCase(), так как он используется внутри метода __set()
  • public-метод findAll() будет доступен во всех классах-наследниках
  • и, наконец, мы объявили абстрактный protected static метод getTableName(), который должен вернуть строку – имя таблицы. Так как метод абстрактный, то все сущности, которые будут наследоваться от этого класса, должны будут его реализовать. Благодаря этому мы не забудем его добавить в классах-наследниках.

Давайте теперь посмотрим на то, во что у нас превратится класс Article. Наследуемся от полученного класса и убираем лишнее.

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\ActiveRecordEntity;

class Article extends ActiveRecordEntity
{
    /** @var string */
    protected $name;

    /** @var string */
    protected $text;

    /** @var string */
    protected $authorId;

    /** @var string */
    protected $createdAt;

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @return string
     */
    public function getText(): string
    {
        return $this->text;
    }

    protected static function getTableName(): string
    {
        return 'articles';
    }
}

Вот так вот он у нас значительно упростился. Проверим, что всё работает. И... Всё работает!
Давайте теперь добавим метод, который будет возвращать одну статью по id. Проще простого! Добавляем в наш класс ActiveRecordEntity ещё один метод getById().

src/MyProject/Models/ActiveRecordEntity.php

<?php

namespace MyProject\Models;

use MyProject\Services\Db;

abstract class ActiveRecordEntity
{
    ...

    /**
     * @param int $id
     * @return static|null
     */
    public static function getById(int $id): ?self
    {
        $db = new Db();
        $entities = $db->query(
            'SELECT * FROM `' . static::getTableName() . '` WHERE id=:id;',
            [':id' => $id],
            static::class
        );
        return $entities ? $entities[0] : null;
    }
}

Этот метод вернёт либо один объект, если он найдётся в базе, либо null – что будет говорить об его отсутствии.

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

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $this->view->renderHtml('articles/view.php', ['article' => $article]);
    }
}

А шаблон станет таким:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getText() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

А теперь давайте в нашем классе User добавим свойства, которые будут соответствовать его полям в базе данных.

src/MyProject/Models/Users/User.php

<?php

namespace MyProject\Models\Users;

class User
{
    /** @var string */
    protected $nickname;

    /** @var string */
    protected $email;

    /** @var int */
    protected $isConfirmed;

    /** @var string */
    protected $role;

    /** @var string */
    protected $passwordHash;

    /** @var string */
    protected $authToken;

    /** @var string */
    protected $createdAt;

    /**
     * @return string
     */
    public function getEmail(): string
    {
        return $this->email;
    }
}

А теперь внимание, просто наследуемся от нашего ActiveRecordEntity и получаем все эти возможности, что есть у сущности Article! Просто добавляем несколько строк и указываем нужную таблицу, где хранятся пользователи.

src/MyProject/Models/Users/User.php

<?php

namespace MyProject\Models\Users;

use MyProject\Models\ActiveRecordEntity;

class User extends ActiveRecordEntity
{
    /** @var string */
    protected $nickname;

    /** @var string */
    protected $email;

    /** @var int */
    protected $isConfirmed;

    /** @var string */
    protected $role;

    /** @var string */
    protected $passwordHash;

    /** @var string */
    protected $authToken;

    /** @var string */
    protected $createdAt;

    /**
     * @return string
     */
    public function getNickname(): string
    {
        return $this->nickname;
    }

    protected static function getTableName(): string
    {
        return 'users';
    }
}

Да это же магия! =)
Попробуем вывести автора статьи, для этого у статьи добавляем геттер для этого поля:

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\ActiveRecordEntity;

class Article extends ActiveRecordEntity
{
    ...
    /**
     * @return int
     */
    public function getAuthorId(): int
    {
        return (int) $this->authorId;
    }
}

Добавляем в контроллере получение нужного юзера:

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\Models\Users\User;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $articleAuthor = User::getById($article->getAuthorId());

        $this->view->renderHtml('articles/view.php', [
            'article' => $article,
            'author' => $articleAuthor
        ]);
    }
}

И выводим никнейм автора в шаблоне:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getText() ?></p>
    <p>Автор: <?= $author->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

Смотрим на результат:

Вывод автора статьи

Круто, да? Но можно ещё круче! Можно ведь попросить статью давать нам не id автора, а сразу автора! Для этого просто меняем геттер в статье:

src/MyProject/Models/Articles/Article.php

<?php

namespace MyProject\Models\Articles;

use MyProject\Models\ActiveRecordEntity;
use MyProject\Models\Users\User;

class Article extends ActiveRecordEntity
{
    ... 
    /**
     * @return User
     */
    public function getAuthor(): User
    {
        return User::getById($this->authorId);
    }
}

Вот так просто! Прямо в геттере просим сущность юзера выполнить запрос в базу и получить нужного пользователя, по id, который хранится в статье. При этом запрос будет выполнен только если мы вызовем этот геттер, это называется LazyLoad (ленивая загрузка) – это когда данные не подгружаются до тех пор, пока их не запросят.

Код нашего контроллера снова упрощается:

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Models\Articles\Article;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            $this->view->renderHtml('errors/404.php', [], 404);
            return;
        }

        $this->view->renderHtml('articles/view.php', [
            'article' => $article
        ]);
    }
}

А в шаблоне мы можем напрямую запросить пользователя:

templates/articles/view.php

<?php include __DIR__ . '/../header.php'; ?>
    <h1><?= $article->getName() ?></h1>
    <p><?= $article->getText() ?></p>
    <p>Автор: <?= $article->getAuthor()->getNickname() ?></p>
<?php include __DIR__ . '/../footer.php'; ?>

Насыщенный получился урок. Надеюсь, всё было понятно. Если нет – вы знаете, что я всегда подскажу, не стесняйтесь, обращайтесь. До следующего урока!

Текущая версия проекта на гитхабе.

loader
02.07.2023 в 21:11
23845
+1181
Комментарии
Этот урок набрал набрал достаточно большое количество комментариев и дальнейшее его комментирование отключено. Если вы хотели убедиться в правильности выполнения ДЗ или у вас возник вопрос по уроку, посмотрите ранее добавленные комментарии, кликнув по кнопке ниже. Скорее всего вы найдете там то, что искали. Если это не помогло - задайте вопрос в чате в телеграме - https://t.me/php_zone
Логические задачи с собеседований