Наверняка все слышали что про модели, MVC, AR и другие замечательные слова.
Но все ли до конца понимают, что эти слова означают?
Все ли понимают что такое модель и как она должна выглядеть
Давайте порассуждаем (и не только) на эту тему.
Паттерны
Первым делом обратимся к паттернам и их определениям модели (источник design-pattern.ru):
- MVC (Model View Controller) — модель данных. Кратенько и очень абстрактно;
- AR (Active Record) — один объект управляет и данными, и поведением. Казалось бы рабочая модель и много где используется, но хорошо работает она ровно до тех пор, пока бизнес-логика не становится слишком насыщенной, и тогда идея хранить все в одной корзине не кажется такой уж рабочей;
- DM (Domain Model) — каждый объект представляет собой отдельную значащую сущность. Вот это самое правильное определение для модели. Модель — это исключительно бизнес-логика и ничего больше!
*Под формулировкой "Модель — это исключительно бизнес-логика" подразумевается что модель — отражает сущности, данные и поведение предметной области, и никак не касается данных и объектов сервисного слоя и слоя приложения.
**Модель и сущность далее по тексту — это одно и тоже, и подразумевают domain object (в рамках концепции Domain Model).
После того как решили что модель — это исключительно бизнес-логика, перейдем к вопросу формирования этих моделей.
Но сначала пару слов про хранение.
Каждой модели свой репозиторий
Т.к. модель это в слое домена, то нам нужна прослойка, которая будет сохранять в БД наши модели.
Самый простой и правильный вариант — Репозиторий.
Еще немного теории и определений:
Репозиторий посредничает между уровнями области определения и распределения данных (domain and data mapping layers), используя интерфейс, схожий с коллекциями для доступа к объектам области определения.
Каждый репозиторий работает только с 1 моделью.
Не должно быть репозиториев типа BlogRepository
который работает со всеми сущностями блога, или PostRepository
который сохраняет еще и комментарии.
Графически структуру можно представить таким образом:
Важно отметить что в слое домена используются только интерфейсы, без конкретных реализаций.
Это нужно для изоляции домена, чтобы исключить любые зависимости на конкретных реализациях и как следствие удобства тестирования (реализовали интерфейс для тестов домена и ок).
Так как в реальных условиях, у нас скорее всего будет только 1 реализация репозитория, можно использовать репозиторий без интерфейсов домена, а сразу в сервисном слое.
Главное не строить зависимости на конкретной структуре данных, а при тестировании можно использовать моки.
Модели — это сущности, а не таблицы
В большинстве случаев проектирование системы начинается именно со структуры данных (БД).
И дальше уже проектирование моделей также отталкивается от их хранения, т.е. от таблиц.
Но это в корне неправильный подход.
Рассмотрим подробнее на примере блога.
Какие модели мы имеем:
- Пост
- Автор
- Комментарий
- Тэги
- Категории
Чтобы не было нагромождения кода, рассматривать будем только модель поста и наращивать функционал будет постепенно.
Начнем с поста:
interface PostRepository
{
public function save(Post $model);
}
class Post
{
protected $id;
protected $title;
protected $content;
public function setId(int $id)
{
$this->id = $id;
}
public function getId(): ?int
{
return $this->id;
}
public function setTitle(string $title)
{
$this->title = $title;
}
public function getTitle(): string
{
return $this->title ?: '';
}
public function setContent(string $content)
{
$this->content = $content;
}
public function getContent(): string
{
return $this->content ?: '';
}
}
Работа с такой моделью и репой, будет выглядеть примерно так:
$post = new Post();
$post->setTitle('Title');
$post->setContent('...');
$repo->save($post);
Теперь внедрим сущность Автор
, для этого нам нужно добавить методы в исходную модель:
class Post
{
// ...
protected $author;
public function setAuthor(Author $author)
{
$this->author = $author;
}
public function getAuthor(): Author
{
return $this->author;
}
}
Пример работы:
$author = $authorRepo->getById(1);
$post->setAuthor($author);
$repo->save($post);
Теперь внедрим сущность Комментарий
, в данной ситуации тоже бы добавить сеттер и соответствующее поле, но если говорить про бизнес-смыслы, то у нас нет такого понятия как "указать комментарии", у нас есть такие понятия как "добавить комментарий" и "удалить комментарий".
Поэтому нашу исходную модель преобразуем таким образом:
class Post
{
// another code
protected $comments;
protected $addComments = [];
protected $removeComments = [];
public function getComments()
{
return $this->comments;
}
public function addComment(Comment $comment)
{
$this->addComments[] = $comment;
}
public function getAddComments()
{
return $this->addComments;
}
public function removeComment(Comment $comment)
{
$this->removeComments[] = $comment;
}
public function getRemoveComments()
{
return $this->removeComments;
}
}
Работа с комментариями в таком случае у нас будет выглядеть так:
$newComment = new Comment();
$newComment->setContent("...");
$removeComment = $commentRepo->getById(1);
$post->addComment($newComment);
$post->removeComment($removeComment);
$repo->save($post);
В момент сохранения поста, у нас также должны обрабатываться удаление и добавление комментариев.
Но сам PostRepository
не должен работать с хранилищем комментариев, он должен делегировать это на CommentRepository
.
То есть репозиторий постов, должен выглядеть примерно так:
class ConcretePostRepository implements PostRepository
{
protected $commentRepository;
public function setCommentRepository(CommentRepository $commentRepository)
{
$this->commentRepository = $commentRepository;
}
public function save(Post $post)
{
if ($post->getId()) {
$this->update($post);
}
else {
$this->insert($post);
}
foreach ($post->getAddComments() as $comment) {
$this->commentRepository->save($comment);
$this->linkCommentToPost($post, $comment);
}
foreach ($post->getRemoveComments() as $comment) {
$this->unlinkCommentToPost($post, $comment);
$this->commentRepository->remove($comment);
}
}
}
При этом важно учесть, что если мы посмотрим на связь "пост — комментарий" со стороны комментария, то у нас не должно быть метода setPost
, т.к. он нарушает связь "целое — часть".
Причем метод getPost
эту логику не нарушает и вполне может существовать.
В примере ниже, мы меняем "владельца" комментария, что противоречит бизнес-логике: мы не можем комментарий переместить в другой пост, мы можем только добавить, удалить или изменить комментарий.
$post = $repo->getById(1);
$comment = new Comment();
$comment->setContent('...');
$comment->setPost($post); // этого метода быть не должно!
$commentRepo->save($comment);
$post = $comment->getPost(); // данный метод корректен
Перейдем к сущности Тэги
, если говорить про бизнес-смысл, то здесь вполне корректным является действие "установить тэги".
Но если говорить про удобство использования, методы addTag
и removeTag
также стоит добавить.
Таким образом дополняем модель следующими методами:
class Post
{
// another code
protected $tags;
public function setTags(TagCollection $tags)
{
$this->tags = $tags;
}
public function addTag(Tag $tag)
{
if (!$this->tags) {
$this->tags = new TagCollection;
}
$this->tags->add($tag);
}
public function removeTag(Tag $tag)
{
if ($this->tags instanceof TagCollection) {
$this->tags->remove($tag);
}
}
}
Работа с тегами будет выглядеть так:
$tag1 = $tagRepo->getById(1);
$tag2 = new Tag();
$tag2->setValue('...');
$tag3 = $tagRepo->getById(3);
$post->setTags(new TagCollection($tag1, $tag2));
// или
$post->addTag($tag1);
$post->addTag($tag2);
$post->removeTag($tag3);
$repo->save($post);
Если посмотреть на связь "пост — тэги" со стороны сущности Тэги
, то мы опять не может добавить метод setPosts
т.к. нарушаем связь "целое — часть" (потому что post has tag
, а не tag has post
).
Но при этом и метод getPosts
мы также не можем использовать, потому что он также нарушает связь "целое — часть".
Правильным решение будет вынести метод получения списка постов конкретного тега в репозиторий:
interface PostRepository
{
public function getListByTag(Tag $tag);
}
Ну и наконец перейдем к сущности Категории
.
Казалось бы тут все тоже самое что и у тэгов, но как раз наоборот: с точки зрения бизнес-логики посты являются частью категорий, т.е. посты "складываются" в категории как в папки, а не категории привязываются к постам, как в случае с тэгами.
Поэтому в модель поста вы добавляем лишь геттер:
class Post
{
// another code
protected $categories;
public function getCategories()
{
return $this->categories;
}
}
А работа с категориями будет выглядеть так:
$post = $postRepo->getById(1);
$cat1 = $categoryRepo->getById(1);
$cat1->addPost($post);
$categoryRepo->save($cat1); // сохраняем привязку к категории
$cat2 = $categoryRepo->getById(2);
$cat2->addPost($post);
$categoryRepo->save($cat2); // сохраняем привязку к категории
$cat3 = $categoryRepo->getById(3);
$cat3->removePost($post);
$categoryRepo->save($cat3); // убираем привязку к категории
[$cat1, $cat2] = $post->getCategories();
Бизнес-действия
Если посмотреть на конечную реализацию модели Post
, то можно заметить заметить, что она получилась достаточно громоздкой.
А если еще внимательнее посмотреть, что она совсем не содержит никаких действий, а только хранит данные.
class Post
{
protected $id;
protected $title;
protected $content;
protected $author;
protected $tags;
protected $comments;
protected $addComments = [];
protected $removeComments = [];
public function setId(int $id)
{
$this->id = $id;
}
public function getId(): ?int
{
return $this->id;
}
public function setTitle(string $title)
{
$this->title = $title;
}
public function getTitle(): string
{
return $this->title ?: '';
}
public function setContent(string $content)
{
$this->content = $content;
}
public function getContent(): string
{
return $this->content ?: '';
}
public function setAuthor(Author $author)
{
$this->author = $author;
}
public function getAuthor(): Author
{
return $this->author;
}
public function getComments()
{
return $this->comments;
}
public function addComment(Comment $comment)
{
$this->addComments[] = $comment;
}
public function getAddComments()
{
return $this->addComments;
}
public function removeComment(Comment $comment)
{
$this->removeComments[] = $comment;
}
public function getRemoveComments()
{
return $this->removeComments;
}
public function setTags(TagCollection $tags)
{
$this->tags = $tags;
}
public function addTag(Tag $tag)
{
if (!$this->tags) {
$this->tags = new TagCollection;
}
$this->tags->add($tag);
}
public function removeTag(Tag $tag)
{
if ($this->tags instanceof TagCollection) {
$this->tags->remove($tag);
}
}
}
Например, при добавлении комментария к посту, у нас автоматически должно уходить уведомление автору этого поста.
Причем уходить уведомление должно, только после сохранения комментария.
Чтобы решить и ту и другую задачу, можно ввести "бизнес-действия", которые будут обрабатываться в момент сохранения.
Это атомарные классы, которые выполняют одно конкретное действие.
Интерфейсы могут выглядеть таким образом:
abstract class BusinessOperation
{
abstract public function run();
public static function createInstance(): self
{
// DI контейнер
return Container::getInstance()->make(get_called_class());
}
}
abstract class Model
{
private $operations = [];
protected function addOperation(BusinessOperation $item)
{
$this->operations[] = $item;
}
public function getOperations(): array
{
return $this->operations;
}
}
Реализация действия "уведомить автора поста" может выглядеть так:
class NotifyAuthorAboutComment extends BusinessOperation
{
protected $service;
protected $post;
public function __construct(NotifyService $service)
{
$this->service = $service;
}
public function setPost(Post $post)
{
$this->post = $post;
}
public function run()
{
$author = $this->post->getAuthor();
$notify = new Nofity();
$notify->setOwner($author);
$notify->setContent("Новые комментарий к записи '{$this->post->title}'");
$this->service->send($notify);
}
}
Соответственно репозиторий у нас тоже становиться более абстрактным и будет выглядеть так:
abstract class ModelRepository
{
protected function saveInternal(Model $model)
{
try {
$this->startTransaction();
if ($model->isNewRecord()) {
$this->insert($model);
}
else {
$this->update($model);
}
foreach ($model->getOperations() as $operation) {
$operation->run();
}
$this->commitTransaction();
}
catch (Throwable $e) {
$this->rollbackTransaction();
throw $e;
}
}
}
class ConcretePostRepository extends ModelRepository implements PostRepository
{
public function save(Post $post)
{
$this->saveInternal($post);
}
}
А реализация самой модели становится более краткой, более понятной и удобной к расширению:
class Post extends Model
{
protected $id;
protected $title;
protected $content;
protected $author;
protected $tags;
protected $comments;
public function setId(int $id)
{
$this->id = $id;
}
public function getId(): ?int
{
return $this->id;
}
public function setTitle(string $title)
{
$this->title = $title;
}
public function getTitle(): string
{
return $this->title ?: '';
}
public function setContent(string $content)
{
$this->content = $content;
}
public function getContent(): string
{
return $this->content ?: '';
}
public function setAuthor(Author $author)
{
$this->author = $author;
}
public function getAuthor(): Author
{
return $this->author;
}
public function getComments()
{
return $this->comments;
}
public function addComment(Comment $comment)
{
$action = AddComment::createInstance();
$action->setPost($this);
$action->setComment($value);
$this->addOperation($action);
$action = NotifyAuthorAboutComment::createInstance();
$action->setPost($this);
$this->addOperation($action);
}
public function removeComment(Comment $comment)
{
$action = RemoveComment::createInstance();
$action->setPost($this);
$action->setComment($value);
$this->addOperation($action);
}
public function setTags(TagCollection $tags)
{
$this->tags = $tags;
}
public function addTag(Tag $tag)
{
if (!$this->tags) {
$this->tags = new TagCollection;
}
$this->tags->add($tag);
}
public function removeTag(Tag $tag)
{
if ($this->tags instanceof TagCollection) {
$this->tags->remove($tag);
}
}
}
Почему плохо нарушать связь "целое — часть"
Ранее неоднократно упоминалось что мы не можем реализовать тот или иной метод из-за нарушения связи "целое — часть".
Рассмотрим на примере процедуры оформления заказа, чтобы понять насколько критично может быть нарушение данной связи.
Входные данные: заказ у нас имеет товары, информацию об оплате и доставке, при изменении статуса оплаты у нас меняется статус самого заказа, статус доставки (разрешается доставка) и отправляются уведомления ответственным сотрудникам и самому клиенту.
Допустим мы получили от клиента оплату и сохраняем ее таким образом:
// в данной ситуации не важно как мы получили объект оплаты, важно то как мы его сохранили
$payment = $repo->getById(1);
$payment = $order->getPayment();
$payment->setPaidAmount(100);
if ($payment->isPaid()) {
$payment->setStatus(IS_PAID);
}
$repo->save($payment);
Что произойдет?
Мы изменим сумму и статус оплаты, и полностью проигнорируем остальную цепочку действий.
Но если обработку полученной оплаты мы будем выполнять таким образом:
$order = $repo->getById(1);
$order->setPaidAmount(100);
$repo->save($order);
То при сохранении заказа выполнятся все связные действия и обновятся все необходимые данные.
Очень важно контролировать связь "целое — часть", чтобы бизнес-логика отрабатывала как нужно.
На самом деле все кроется на уровне формулировок: вместо "получили оплату", нужно использовать формулировку "получили оплату по заказу", и тогда станет ясно где целое и как нужно обработать данные.
Пара слов про репозитории
Часто можно заметить в интерфейсах/реализациях подобные конструкции:
interface BaseRepository
{
/**
* @param Condition $condition условие выборки
*/
public function getList(Condition $condition);
}
И это не правильно.
При таком решении встает сразу парочка неудобных вопросов:
- что указывать в условие: поля домена или поля таблицы? При первом варианте придется дополнительно преобразовывать условия домена в условия БД. При втором варианте вы привязываетесь к реализации конкретного хранилища (см. пункт 2);
- как изменять структуру хранения без боли? Например, мы изменили структуру данных: ранее у нас была связь 1:M, теперь стала N:M. Т.е. фактически мы убрали столбец и создали новую таблицу. Следовательно нам теперь нужно изменить все использования старого столбца в Condition, и переделать их на вызовы связной таблицы (а если условия составные, то это не так уж и просто будет).
Как этого избежать?
Ответ на самом деле прост — не использовать абстрактные условия, а выносить все в конкретные методы:
interface CommentRepository
{
public function getListByPost(Post $post);
public function getLastByPost(Post $post);
public function getListPopular();
public function getById(int $id);
}
При такой реализации мы полностью абстрагируемся от структуры хранения и у нас уже не стоит вопроса что указывать в условии, т.к. мы зависим только от домена. И при изменении структуры данных нам нужно будет только переопределить реализацию методов в репозитории.
Заключение
Модели — это очень полезная вещь, но важно понимать что скрывается за этим термином.
Когда мы говорим про проекты со сложной логикой, без хорошо спроектированного слоя домена, будет очень проблемно вносить изменения и в дальнейшем поддерживать такой проект.
Представленный вариант работы с моделями дает ряд полезностей:
- позволяет полностью абстрагировать слой домена от реализации — это дает возможность эффективно тестировать модели и связи между ними;
- упрощает разработку и проектирование самих моделей т.к. не приходится думать о том как будут данные хранится, а сконцентрироваться на том как система должна функционировать;
- упрощает масштабирование и доработки реализации — за счет реализации конкретных методов у репозитория и гибкий бизнес-операций;
На этом все.
UPD: спасибо lair за теоретический ликбез в комментах. В целом все остались при своем мнении, но чтиво полезное!