
Наверняка все слышали что про модели, 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 за теоретический ликбез в комментах. В целом все остались при своем мнении, но чтиво полезное!
