Реализуем Active Record в PHP
Сегодня мы изучим ещё один паттерн проектирования – Active Record. Этот шаблон говорит о том, что сущность (объекты класса статьи или пользователя) сами должны управлять работой с базой данных. То есть весь остальной код, который эти сущности использует, не должен знать о базе данных. Наши контроллеры не должны работать с базой данных, получая данные и заполняя ими сущности. Они должны знать только о сущностях. Сущность сама должна позаботиться о работе с базой данных. О том, как это реализовать – читайте далее.
Для начала нужно вообще понять, как стоит работать с сущностями при помощи такой концепции. Самое простое, что мы можем реализовать – это чтение из базы данных. И мы должны сделать это, обращаясь напрямую к сущностям-объектам. То есть мы должны сказать: «Эй, Article, дай мне все статьи». Но согласитесь, глупо будет для этого создать сущности, а после этого попросить чтобы они заполнили себя данными из базы. Нам нужно сделать это как-то по другому. Например, обратиться к сущности, не создавая её, но чтобы она при этом вернула нам созданные сущности. Вспоминаем статические методы – их ведь можно вызывать, не создавая объекта. То, что нам нужно!
Давайте добавим в Article статический метод, возвращающий нам все статьи.
src/MyProject/Models/Articles/Article.php
<?php
namespace MyProject\Models\Articles;
use MyProject\Services\Db;
class Article
{
/** @var int */
private $id;
/** @var string */
private $name;
/** @var string */
private $text;
/** @var string */
private $authorId;
/** @var string */
private $createdAt;
public function __set($name, $value)
{
$camelCaseName = $this->underscoreToCamelCase($name);
$this->$camelCaseName = $value;
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @return string
*/
public function getText(): string
{
return $this->text;
}
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `articles`;', [], Article::class);
}
private function underscoreToCamelCase(string $source): string
{
return lcfirst(str_replace('_', '', ucwords($source, '_')));
}
}
Теперь, чтобы получить статьи в контроллере, нам нужно сделать следующее:
src/MyProject/Controllers/MainController.php
<?php
namespace MyProject\Controllers;
use MyProject\Models\Articles\Article;
use MyProject\View\View;
class MainController
{
/** @var View */
private $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/../../../templates');
}
public function main()
{
$articles = Article::findAll();
$this->view->renderHtml('main/main.php', ['articles' => $articles]);
}
}
Заметили, как сразу упростился наш контроллер? Пропала зависимость от базы данных. Если вы сейчас попробуете выполнить этот код, то он успешно отработает.
А теперь давайте посмотрим на код этого статического метода.
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `articles`;', [], Article::class);
}
Согласитесь, можно заменить Article::class на self::class – и сюда автоматически подставится класс, в котором этот метод определен. А можно заменить его и вовсе на static::class – тогда будет подставлено имя класса, у которого этот метод был вызван. В чём разница? Если мы создадим класс-наследник SuperArticle, он унаследует этот метод от родителя. Если будет использоваться self:class, то там будет значение “Article”, а если мы напишем static::class, то там уже будет значение “SuperArticle”. Это называется поздним статическим связыванием – благодаря нему мы можем писать код, который будет зависеть от класса, в котором он вызывается, а не в котором он описан.
Итак, давайте изменим этот метод:
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `articles`;', [], static::class);
}
А теперь давайте попробуем избавиться от зависимости от таблицы “articles”. Вынесем получение названия таблицы в отдельный метод. Вот так:
/**
* @return Article[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}
private static function getTableName(): string
{
return 'articles';
}
А теперь внимательно посмотрите на содержимое класса Article. Не кажется ли вам, что методы findAll(), __set(), underscoreToCamelCase() можно вот хоть сейчас взять и скопировать в сущность User, и начать их использовать? Только не нужно ничего копировать, мы ведь пишем на объектно-ориентированном языке, и можем использовать наследование! Мы можем просто вынести всю эту логику в отдельный класс, а там где она нужна, просто от него наследоваться. Давайте так и поступим.
Создадим отдельный класс, реализующий всю эту логику.
src/MyProject/Models/ActiveRecordEntity.php
<?php
namespace MyProject\Models;
abstract class ActiveRecordEntity
{
}
Так как создание объектов этого класса нам не нужно, то делаем его абстрактным. А теперь переносим в него универсальный код из класса Article.
<?php
namespace MyProject\Models;
use MyProject\Services\Db;
abstract class ActiveRecordEntity
{
/** @var int */
protected $id;
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
public function __set(string $name, $value)
{
$camelCaseName = $this->underscoreToCamelCase($name);
$this->$camelCaseName = $value;
}
private function underscoreToCamelCase(string $source): string
{
return lcfirst(str_replace('_', '', ucwords($source, '_')));
}
/**
* @return static[]
*/
public static function findAll(): array
{
$db = new Db();
return $db->query('SELECT * FROM `' . static::getTableName() . '`;', [], static::class);
}
abstract protected static function getTableName(): string;
}
Давайте по порядку.
- добавили protected-свойство ->id и public-геттер для него – у всех наших сущностей будет id, и нет необходимости писать это каждый раз в каждой сущности – можно просто унаследовать;
- перенесли public-метод __set() – теперь все дочерние сущности будут его иметь
- перенесли метод underscoreToCamelCase(), так как он используется внутри метода __set()
- public-метод findAll() будет доступен во всех классах-наследниках
- и, наконец, мы объявили абстрактный protected static метод getTableName(), который должен вернуть строку – имя таблицы. Так как метод абстрактный, то все сущности, которые будут наследоваться от этого класса, должны будут его реализовать. Благодаря этому мы не забудем его добавить в классах-наследниках.
Давайте теперь посмотрим на то, во что у нас превратится класс Article. Наследуемся от полученного класса и убираем лишнее.
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'; ?>
Насыщенный получился урок. Надеюсь, всё было понятно. Если нет – вы знаете, что я всегда подскажу, не стесняйтесь, обращайтесь. До следующего урока!
Текущая версия проекта на гитхабе.
Комментарии