На прошлой неделе я написал статью о бесполезности шаблона Репозиторий для 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 IlluminateContractsCacheRepository;
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 и перейти к схеме, описанной мною ранее. Остальной код приложения менять не надо.
Все эти фокусы с классами, интерфейсами, Dependency Injection и CQRS подробно описаны в моей книге "Архитектура сложных веб приложений". Там же разгадка загадки почему все мои классы в примерах к этой статье помечены как final.
Автор: Adelf