image


Наверняка все слышали что про модели, 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 который сохраняет еще и комментарии.
Графически структуру можно представить таким образом:


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

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


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


  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. упрощает масштабирование и доработки реализации — за счет реализации конкретных методов у репозитория и гибкий бизнес-операций;

На этом все.


UPD: спасибо lair за теоретический ликбез в комментах. В целом все остались при своем мнении, но чтиво полезное!