Как должны выглядеть модели?

от автора

image

Наверняка все слышали что про модели, MVC, AR и другие замечательные слова.

Но все ли до конца понимают, что эти слова означают?
Все ли понимают что такое модель и как она должна выглядеть

Давайте порассуждаем (и не только) на эту тему.

Паттерны

Первым делом обратимся к паттернам и их определениям модели (источник design-pattern.ru):

  • MVC (Model View Controller) — модель данных. Кратенько и очень абстрактно;
  • AR (Active Record) — один объект управляет и данными, и поведением. Казалось бы рабочая модель и много где используется, но хорошо работает она ровно до тех пор, пока бизнес-логика не становится слишком насыщенной, и тогда идея хранить все в одной корзине не кажется такой уж рабочей;
  • DM (Domain Model) — каждый объект представляет собой отдельную значащую сущность. Вот это самое правильное определение для модели. Модель — это исключительно бизнес-логика и ничего больше!

После того как решили что модель — это исключительно бизнес-логика, перейдем к вопросу формирования этих моделей.

Но сначала пару слов про хранение.

Каждой модели свой репозиторий

Т.к. модель это исключительно бизнес-логика, то нам нужна прослойка, которая будет сохранять в БД наши модели.

Самый простой и правильный вариант — Репозиторий.

Еще немного теории и определений:

Репозиторий посредничает между уровнями области определения и распределения данных (domain and data mapping layers), используя интерфейс, схожий с коллекциями для доступа к объектам области определения.

Каждый репозиторий работает только с 1 моделью.

Не должно быть репозиториев типа BlogRepository который работает со всеми сущностями блога, или PostRepository который сохраняет еще и комментарии.
Графически структуру можно представить таким образом:

image

Важно отметить что в слое домена используются только интерфейсы, без конкретных реализаций.

Это нужно для изоляции домена, чтобы исключить любые зависимости на конкретных реализациях и как следствие удобства тестирования (реализовали интерфейс для тестов домена и ок).

Так как в реальных условиях, у нас скорее всего будет только 1 реализация репозитория, можно использовать репозиторий без интерфейсов домена, а сразу в сервисном слое.

Главное не строить зависимости на конкретной структуре данных, а при тестировании можно использовать моки.

Модели — это сущности, а не таблицы

В большинстве случаев проектирование системы начинается именно со структуры данных (БД).
И дальше уже проектирование моделей также отталкивается от их хранения, т.е. от таблиц.
Но это в корне неправильный подход.

Рассмотрим подробнее на примере блога.

Какие модели мы имеем:

  1. Пост
  2. Автор
  3. Комментарий
  4. Тэги
  5. Категории

Чтобы не было нагромождения кода, рассматривать будем только модель поста и наращивать функционал будет постепенно.

Начнем с поста:

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, то можно заметить заметить, что она получилась достаточно громоздкой.

А если еще внимательнее посмотреть, что она совсем не содержит никаких действий, а только хранит данные.

Post.php

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

А реализация самой модели становится более краткой, более понятной и удобной к расширению:

Post.php

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 = new AddComment();         $action->setPost($this);         $action->setComment($value);         $this->addOperation($action);          $action = new NotifyAuthorAboutChange();         $action->setPost($this);         $this->addOperation($action);     }      public function removeComment(Comment $comment)     {         $action = new RemoveComment();         $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); }

И это не правильно.

При таком решении встает сразу парочка неудобных вопросов:

  1. что указывать в условие: поля домена или поля таблицы? При первом варианте придется дополнительно преобразовывать условия домена в условия БД. При втором варианте вы привязываетесь к реализации конкретного хранилища (см. пункт 2);
  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); }

При такой реализации мы полностью абстрагируемся от структуры хранения и у нас уже не стоит вопроса что указывать в условии, т.к. мы зависим только от домена. И при изменении структуры данных нам нужно будет только переопределить реализацию методов в репозитории.

Заключение

Модели — это очень полезная вещь, но важно понимать что скрывается за этим термином.
Когда мы говорим про проекты со сложной логикой, без хорошо спроектированного слоя домена, будет очень проблемно вносить изменения и в дальнейшем поддерживать такой проект.

Представленный вариант работы с моделями дает ряд полезностей:

  1. позволяет полностью абстрагировать слой домена от реализации — это дает возможность эффективно тестировать модели и связи между ними;
  2. упрощает разработку и проектирование самих моделей т.к. не приходится думать о том как будут данные хранится, а сконцентрироваться на том как система должна функционировать;
  3. упрощает масштабирование и доработки реализации — за счет реализации конкретных методов у репозитория и гибкий бизнес-операций;

На этом все.

ссылка на оригинал статьи https://habr.com/ru/post/505400/