1 Grudnia, 2020

1275 słów 6 min.

Wzorzec repozytorium w Laravelu

Wzorzec repozytorium w Laravelu

Od dłuższego czasu szukam najlepszego rozwiązania, które pozwoliłoby mi w Laravelu oddzielić swój kod od warstwy bazy danych. Eloquent, czyli domyślny ORM dla Laravela, jest zbudowany na bazie wzorca ActiveRecord, co oznacza, że każda Encja/Model, mają zaszyty w sobie dostęp do bazy danych i nijak nie da się go z tamtąd “wytargać”.

Nie chcę się tutaj skupiać na tym, gdzie i jak należy używać wzorca Repository, ponieważ to jest całkowicie osobny temat i może jeszcze kiedyś się tego podejmę, ale jeszcze nie dziś. W skrócie to dlaczego chcę oddzielić kod biznesowy od warstwy bazy danych, to chęć lepszego podziału odpowiedzialności w klasach, oraz możliwość testowania kodu całkowicie niezależnie od bazy danych. Oczywiście takie testowanie ma sens tam, gdzie mamy jakąś logikę biznesową, jeśli mamy prostego CRUD’a, to nie ma potrzeby ani stosować repozytorium, ani testować bez użycia bazy danych.

Pomysłów jak to zrobić miałem w zasadzie dwa:

1. POPOs

Pierwszym z nich i wydawałoby się najbardziej odpowiednim, przy praktykowaniu DDD, to całkowicie odciąć się od frameworka i zmapować modele eloquenta na POPO (Plain Old PHP Object). Repozytorium wydaje się idealnym miejscem do tego. Dużym plusem zastosowania tego rozwiązania jest na pewno to, że możemy używać funkcjonalności “visibility”, czyli np. ukryć atrybuty klasy.

Mapowanie encji, nie jest jednak zbyt przyjemne, a przy tym wymaga od nas dodatkowej pracy, która nie przyniesie nam wymiernych korzyści. Jeśli chcielibyśmy uzyskać w pełni czyste obiekty, z prywatnymi polami, musielibyśmy zaprzęgnąć do całego rozwiązania refleksje, albo dodać generowanie snapshootów naszych encji, co przysporzy nam jeszcze więcej pracy. Jeśli naprawdę chcemy to zrobić to lepiej już podmienić ORM np. na Doctrine, który jest już implementacją wzorca DataMapper, który zdecydowanie bardziej się do tego nadaje.

2. Ukrycie operacji na bazie za Repozytorium na zwykłych modelach

To rozwiązanie wydawało mi się trochę niestabilne. Jeśli nasze repozytoria będą zwracały nadal modele eloquenta, to nie zabronimy nikomu wykonać metody, która dotknie bazy danych poza repozytorium. Dodatkowo, jeśli stosujemy podejście DDD, to wszystkie pola naszej encji nadal będą publiczne, a co za tym idzie, jeśli będziemy chcieli zbudować agregat, nie mamy możliwości ustawienia pól jako prywatne. Publiczne pola umożliwiają łamanie niezmienników w naszych agregatach.

Na plus jednak jest to, że nie musimy nic sami dodatkowo mapować i tak naprawdę ta zmiana w mało inwazyjny sposób wpływa na naszą pracę z frameworkiem. Dodatkowo nadal pozostajemy przy Eloquencie i możemy bardzo szybko klepać moduły CRUD’owe.

Dalsze poszukiwania

Szukałem w internecie informacji, które rozwiałyby moje wątpliwości. Niestety materiałów dla PHP nie ma aż tak dużo (albo ja nie trafiłem na właściwe), a jeśli już są, to poruszają ten temat w sposób bardzo uproszczony i w zasadzie nie ma za dużo uzasadnień, dlaczego akurat tak.

Postanowiłem więc zrobić to, co najczęściej robię w takich sytuacjach i poszukać poza PHP, w świecie, w którym również działa się na ActiveRecord. Tym światem, w którym programiści muszą zmierzać się z podobnymi problemami, jest Ruby on Rails. Jak Ruby, to już wiadomo do kogo się z tym udać 😄. Oczywiście Andrzej Krzywda i Arkency! Tak się składa, że w ostatni weekend Andrzej postanowił uruchomić promocję na black friday i udostępnił wszystkie książki Arkency za 99$*. W tym zestawie znalazły się dwie pozycje, które poruszają temat ActiveRecord i repozytoriów. Są to “Fearless Refactoring” oraz “Domain-Driven Rails”. Mimo że obie pokazują przykłady z RoR, to wiedza jest na tyle uniwersalna, że można ją z powodzeniem wykorzystać w PHP.

W książkach tych znajdziemy przykład implementacji drugiego z powyższych rozwiązań, czyli ukrycie metod, które dotykają bazy danych, za warstwą repozytorium. W książce pojawiają się również wszystkie minusy tego rozwiązania i chyba właśnie tej informacji mi brakowało w materiałach, które znalazłem do tej pory. Te informacje potwierdzają mi, że jest to w pełni świadome i przemyślane rozwiązanie.

Z minusami takiego podejścia, trzeba najzwyczajniej w świecie nauczyć się pracować. Przede wszystkim należy zapewnić sobie dobrą komunikację z resztą zespołu, co pozwoli uniknąć takich sytuacji jak nieuprawniona zmiana parametru agregatu, czy wywołanie metody bazodanowej poza repozytorium.

Implementacja

Istnieją biblioteki, które implementują wzorzec repozytorium i są stworzone specjalnie dla Laravela, ale moim zdaniem nic takiego nikomu nie jest potrzebne. Taka paczka to tylko dodatkowa zależność, która co prawda wprowadza kilka fajnych feature’ów, ale pytanie, czy na prawdę ich potrzebujesz? Moim zdaniem nie ma sensu zaczynać od tej biblioteki, zawsze można ją wprowadzić, ciężej wyrzucić.

Przykład z bardzo prostym modelem posta np. dla jakiegoś systemu blogowego i wykonanie na nim testu bez bazy danych:

Najpierw dodałem model posta:

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;
}

Nic niezwykłego.

Od razu tworzę fabrykę, ponieważ przyda mi się ona do testów:

<?php
namespace Database\Factories;

use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition()
    {
        return [];
    }
}

Tworzę teraz klasę z serwisem do obsługi postów:

<?php
namespace App\Post;

use App\Models\Post;

class PostService
{
    private PostRepository $postRepository;

    public function __construct(PostRepository $postRepository)
    {
        $this->postRepository = $postRepository;
    }

    public function createPost(): void
    {
        $post = new Post();
        $post->id = 2;
        $post->title = "Title";
        $this->postRepository->save($post);
    }

    public function getPost(): Post
    {
        return $this->postRepository->find(2);
    }
}

Podstawowe metody, czyli dodawanie i usuwanie posta. Jak widać, operacje te wykonuję od razu na repozytorium, które wstrzykuję za pomocą konstruktora serwisu.

PostRepository jest tak naprawdę interfejsem, który będziemy mogli w prosty sposób podmienić:

<?php
namespace App\Post;

use App\Models\Post;

interface PostRepository
{
    public function find(int $id): Post;
    public function save(Post $post): void;
}

I na koniec tworzę prostą implementację repozytorium przy użyciu modelu Eloquenta:

<?php
namespace App\Post;

use App\Models\Post;

class EloquentPostRepository implements PostRepository
{
    public function find(int $id): Post
    {
        return Post::find($id);
    }

    public function save(Post $post): void
    {
        $post->save();
    }
}

I tyle, mamy zaimplementowane repozytorium.

Teraz spróbujmy to przetestować. W tym celu tworze drugą implementację repozytorium, które będzie działało w pamięci:

<?php
namespace App\Post;

use App\Models\Post;

class InMemoryPostRepository implements PostRepository
{
    private array $posts;

    public function __construct()
    {
        $this->posts = [];
    }

    public function find(int $id): Post
    {
        return $this->posts[$id];
    }

    public function save(Post $post): void
    {
        $this->posts[$post->id] = $post;
    }
}

Jak widać implementacja jest oparta o najzwyklejszą PHP’ową tablicę, ale równie dobrze, a nawet lepiej będzie, jeśli oprzemy je np. na kolekcji.

Z takim repozytorium możemy już napisać test działający w pamięci:

<?php
namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use App\Post\PostService;
use App\Post\InMemoryPostRepository;
use App\Models\Post;

class ExampleTest extends TestCase
{
    public function testBasicTest()
    {
        $postService = new PostService(new InMemoryPostRepository());

        $postService->createPost();
        $post = $postService->getPost();

        $expectedPost = Post::factory()->make(["id" => 2]);
        self::assertEquals($expectedPost, $post);
    }
}

Tak przygotowany test można już z łatwością uruchomić: Succeed test
Bez migracji, bez bazy danych, a jednak z użyciem encji rozszerzającej Eloquent\Model i działa 🙂.

Powyższy przykład jest banalny i w prawdziwym kodzie dla tak prostego przypadku nie zastosowałbym nigdy repozytorium, ponieważ jest to CRUD, nic się tutaj nie dzieje, nie ma logiki biznesowej. W powyższym teście jednostkowym również nie pokazuję dobrych praktyk, skupiam się tutaj tylko na implementacji wzorca repozytorium.

Podsumowanie

Pamiętajmy, że wzorzec ten nie jest lekiem na wszystkie bóle tego świata i wciskanie go w każde miejsce nie jest dobrym rozwiązaniem. Trafiłem ostatnio na pewną prezentację, której jeszcze nie oglądałem, ale już sam tytuł daje do myślenia, “SOLID Architecture in Slices not Layers”. Moja interpretacja tego tytułu to żeby pamiętać o problemie, jaki mamy rozwiązać i to jest nasz główny cel, więc nie dokładajmy warstw na siłę, tylko podzielmy nasz problem, a warstwy powstaną same.

Być może w przyszłości na blogu pojawi się jakiś post, w którym opiszę sam wzorzec, ale na teraz podrzucam kilka materiałów, które mogą być pomocne: - https://martinfowler.com/eaaCatalog/repository.html - https://designpatternsphp.readthedocs.io/pl/latest/More/Repository/README.html - https://blog.arkency.com/2015/06/thanks-to-repositories/ - https://rails-refactoring.com/?_ga=2.8151026.1592713566.1606829479-47874975.1604187459 - https://products.arkency.com/domain-driven-rails/

*Niestety aktualnie jest już po promocji, ale z tego, co wiem, w przyszłym roku znów pojawi się podobna, więc myślę, że warto zapisać sobie w kalendarzu i obserwować Andrzeja Krzywdę na Instagramie i Twitterze 🙂