ХабрПостыНовостиМегапосты?

Полезные репозитории с Eloquent?

На прошлой неделе я написал статью о бесполезности шаблона Репозиторий для Eloquent сущностей, однако пообещал рассказать как можно частично его использовать с пользой. Для этого попробую проанализировать как обычно используют этот шаблон в проектах. Минимально необходимый набор методов для репозитория:


<?php
interface PostRepository
{
    public function getById($id): Post;
    public function save(Post $post);
    public function delete($id);
}

Однако, в реальных проектах, если репозитории таки было решено использовать, в них часто добавляются методы для выборок записей:


<?php
interface PostRepository
{
    public function getById($id): Post;
    public function save(Post $post);
    public function delete($id);

    public function getLastPosts();
    public function getTopPosts();
    public function getUserPosts($userId);
}

Эти методы можно было бы реализовать через Eloquent scopes, но перегружать классы сущностей обязанностями по выборке самих себя — не самая лучшая затея и вынос этой обязанности в классы репозиториев выглядит логичным. Так ли это? Я специально визуально разделил этот интерфейс на две части. Первая часть методов будет использована в операциях записи.


Стандартные операции записи это:



В операциях записи нет использования методов выборки. В операциях чтения же используются только методы get*. Если почитать про Interface Segregation Principle (буква I в SOLID), то станет понятно, что наш интерфейс получился слишком большим и выполняющим как минимум две разные обязанности. Пора делить его на два. Метод getById необходим в обоих, однако при усложнении приложения его реализации будут разными. Это мы увидим чуть позднее. Про бесполезность write-части я писал в прошлой статье, поэтому в этой я про нее просто забуду.


Read же часть мне кажется не такой уж и бесполезной, поскольку даже для Eloquent здесь может быть несколько реализаций. Как назвать класс? Можно ReadPostRepository, но к шаблону Repository он уже имеет малое отношение. Можно просто PostQueries:


<?php
interface PostQueries
{
    public function getById($id): Post;
    public function getLastPosts();
    public function getTopPosts();
    public function getUserPosts($userId);
}

Его реализация с помощью Eloquent довольно проста:


<?php
final class EloquentPostQueries implements PostQueries
{
    public function getById($id): Post
    {
        return Post::findOrFail($id);
    }

    /**
    * @return Post[] | Collection
    */
    public function getLastPosts()
    {
        return Post::orderBy('created_at', 'desc')
            ->limit(/*some limit*/)
            ->get();
    }
    /**
    * @return Post[] | Collection
    */
    public function getTopPosts()
    {
        return Post::orderBy('rating', 'desc')
            ->limit(/*some limit*/)
            ->get();
    }

    /**
    * @param int $userId
    * @return Post[] | Collection
    */
    public function getUserPosts($userId)
    {
        return Post::whereUserId($userId)
            ->orderBy('created_at', 'desc')
            ->get();
    }
}

Интерфейс должен быть связан с реализацией, например в AppServiceProvider:


<?php
final class AppServiceProvider extends ServiceProvider 
{
    public function register()
    {
        $this->app->bind(PostQueries::class, 
            EloquentPostQueries::class);
    }
}

Данный класс уже полезен. Он реализует свою ответственность, разгрузив этим либо контроллеры, либо класс сущности. В контроллере он может быть использован так:


<?php
final class PostsController extends Controller
{
    public function lastPosts(PostQueries $postQueries)
    {
        return view('posts.last', [
            'posts' => $postQueries->getLastPosts(),
        ]);
    }
} 

Метод PostsController::lastPosts просто просит себе какую-нибудь реализацию PostsQueries и работает с ней. В провайдере мы связали PostQueries с классом EloquentPostQueries и в контроллер будет подставлен этот класс.


Давайте представим, что наше приложение стало очень популярным. Тысячи пользователей в минуту открывают страницу с последними публикациями. Наиболее популярные публикации тоже читаются очень часто. Базы данных не очень хорошо справляются с такими нагрузками, поэтому используют стандартное решение — кеш. Кроме базы данных, некий слепок данных хранится в хранилище оптимизированном к определенным операциям — memcached или redis.


Логика кеширования обычно не такая сложная, но реализовывать ее в EloquentPostQueries не очень правильно (хотя бы из-за Single Responsibility Principle). Намного более естественно использовать шаблон Декоратор и реализовать кеширование как декорирование главного действия:


<?php
use Illuminate\Contracts\Cache\Repository;

final class CachedPostQueries implements PostQueries
{
    const LASTS_DURATION = 10;

    /** @var PostQueries */
    private $base;

    /** @var Repository */
    private $cache;

    public function __construct(
        PostQueries $base, Repository $cache) 
    {
        $this->base = $base;
        $this->cache = $cache;
    }

    /**
    * @return Post[] | Collection
    */
    public function getLastPosts()
    {
        return $this->cache->remember('last_posts', 
            self::LASTS_DURATION, 
            function(){
                return $this->base->getLastPosts();
            });
    }

    // другие методы практически такие же
}

Не обращайте внимания на интерфейс Repository в конструкторе. По непонятной причине так решили назвать интерфейс для кеширования в Laravel.


Класс CachedPostQueries реализует только кеширование. $this->cache->remember проверяет нет ли данной записи в кеше и если нет, то вызывает callback и записывает в кеш вернувшееся значение. Осталось только внедрить данный класс в приложение. Нам необходимо, чтобы все классы, которые в приложении просят реализацию интерфейса PostQueries стали получать экземпляр класса CachedPostQueries. Однако сам CachedPostQueries в качестве параметра в конструктор должен получить класс EloquentPostQueries, поскольку он не может работать без "настоящей" реализации. Меняем AppServiceProvider:


<?php
final class AppServiceProvider extends ServiceProvider 
{
    public function register()
    {
        $this->app->bind(PostQueries::class, 
            CachedPostQueries::class);

        $this->app->when(CachedPostQueries::class)
            ->needs(PostQueries::class)
            ->give(EloquentPostQueries::class);
    }
}

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


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


Итог: мы использовали не один, а целых два шаблона. Шаблон Command Query Responsibility Segregation (CQRS) предлагает полностью разделить операции чтения и записи на уровне интерфейсов. Я пришел к нему через Interface Segregation Principle, что говорит о том, что я умело манипулирую шаблонами и принципами и вывожу один из другого как теорему :) Разумеется, не каждому проекту необходима такая абстракция на выборки сущностей, но я поделюсь с вами фокусом.На начальном этапе разработки приложения можно просто создать класс PostQueries с обычной реализацией через Eloquent:


<?php
final class PostQueries
{
    public function getById($id): Post
    {
        return Post::findOrFail($id);
    }

    // другие методы
}

Когда возникнет необходимость в кешировании, легким движением можно создать интерфейс (или абстрактный класс) на месте этого класса PostQueries, его реализацию скопировать в класс EloquentPostQueries и перейти к схеме, описанной мною ранее. Остальной код приложения менять не надо.


Однако, осталась проблема с тем, что используются те же сущности Post, которые могут изменять данные. Это не совсем CQRS.



Никто не мешает получить сущность Post из PostQueries, изменить ее и сохранить изменения с помощью простого ->save(). И это будет работать.
Через некоторое время команда перейдет на master-slave репликацию в базе данных и PostQueries будет работать с read-репликами. Операции записи на read-репликах принято блокировать. Ошибка вскроется, но придется много поработать чтобы исправить все такие косяки.


Выход очевидный — разделить read и write части полностью. Можно продолжать использовать Eloquent, но создав класс для моделей "только для чтения". Пример: https://github.com/adelf/freelance-example/blob/master/app/ReadModels/ReadModel.php Все операции изменения данных заблокированы. Создать новую модель, например ReadPost (можно оставить Post, но переместив в другой namespace):


<?php
final class ReadPost extends ReadModel
{
    protected $table = 'posts';
}

interface PostQueries
{
    public function getById($id): ReadPost;
}

Такие модели могут быть использованы только для чтения и их можно безопасно кешировать.



Другой вариант: отказаться от Eloquent. Причин этому может быть несколько:



Простой пример как это может выглядеть:


<?php
final class PostHeader
{
    public int $id;
    public string $title;
    public DateTime $publishedAt;
}

final class Post
{
    public int $id;
    public string $title;
    public string $text;
    public DateTime $publishedAt;
}

interface PostQueries
{
    public function getById($id): Post;

    /**
    * @return PostHeader[]
    */
    public function getLastPosts();

    /**
    * @return PostHeader[]
    */
    public function getTopPosts();

    /**
    * @var int $userId 
    * @return PostHeader[]
    */
    public function getUserPosts($userId);
}

Разумеется, все это кажется диким переусложнением логики. "Возьми Eloquent scopes и все будет хорошо. Зачем придумывать все это?" Для проектов попроще это правильно. Совершенно не нужно переизобретать scopes. Но когда проект большой и разработкой занимаются несколько разработчиков, которые часто меняются (увольняются и приходят новые), правила игры становятся немного иными. Код необходимо писать защищенным, чтобы новый разработчик через несколько лет не смог сделать что-то неправильно. Совсем исключить такую вероятность, конечно, невозможно, но необходимо уменьшать ее вероятность. Кроме того, это обычная декомпозиция системы. Можно собрать все кеширующие декораторы и классы для инвалидации кеша в некий "модуль кеширования", таким образом избавив остальное приложение от информации про кеширование. Мне приходилось рыться в больших запросах, которые были окружены вызовами кеша. Это мешает. Особенно, если логика кеширования не такая простейшая, как описано выше.

@Adelf
26.03.2019 22:01 UTC
Первоисточник