Pull to refresh

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

Reading time 7 min
Views 19K
Original author: Adel F

На прошлой неделе я написал статью о бесполезности шаблона Репозиторий для 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, но перегружать классы сущностей обязанностями по выборке самих себя — не самая лучшая затея и вынос этой обязанности в классы репозиториев выглядит логичным. Так ли это? Я специально визуально разделил этот интерфейс на две части. Первая часть методов будет использована в операциях записи.


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


  • конструирование нового обьекта и вызов PostRepository::save
  • PostRepository::getById, манипуляции с сущностью и вызов PostRepository::save
  • вызов PostRepository::delete

В операциях записи нет использования методов выборки. В операциях чтения же используются только методы 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. Причин этому может быть несколько:


  • Все поля таблицы почти никогда не нужны. Для lastPosts запроса обычно необходимы только id, title и, например, published_at поля. Запрашивать несколько тяжелых текстов публикаций лишь даст ненужную нагрузку на базу или кеш. Eloquent умеет выбирать только нужные поля, но все это очень неявно. Клиенты PostQueries не знают точно какие поля выбраны, без залезания внутрь реализации.
  • Кеширование по умолчанию использует сериализацию. Eloquent классы занимают чрезмерно много места в сериализованном виде. Если для простых сущностей это не особо заметно, то для сложных сущностей со связями это становится проблемой (как вам таскать даже из кеша объекты размером в мегабайт?). На одном проекте обычный класс с публичными полями в кеше занимал раз в 10 меньше места, чем Eloquent вариант (там было много мелких подсущностей). Можно при кешировании кешировать только attributes, но это сильно усложнит процесс.

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


<?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. Но когда проект большой и разработкой занимаются несколько разработчиков, которые часто меняются (увольняются и приходят новые), правила игры становятся немного иными. Код необходимо писать защищенным, чтобы новый разработчик через несколько лет не смог сделать что-то неправильно. Совсем исключить такую вероятность, конечно, невозможно, но необходимо уменьшать ее вероятность. Кроме того, это обычная декомпозиция системы. Можно собрать все кеширующие декораторы и классы для инвалидации кеша в некий "модуль кеширования", таким образом избавив остальное приложение от информации про кеширование. Мне приходилось рыться в больших запросах, которые были окружены вызовами кеша. Это мешает. Особенно, если логика кеширования не такая простейшая, как описано выше.

Tags:
Hubs:
+19
Comments 28
Comments Comments 28

Articles