Wzorzec Dekorator w Laravelu: Cache dla repozytorium odczytu

8 min. czytania

Cache w modelu Eloquent albo kontrolerze sprawia, że unieważnianie i obserwowalność stają się problemem wszystkich. Dekorator utrzymuje operację odczytu małą, a infrastrukturę umieszcza na jej brzegu.

php
<?php

declare(strict_types=1);

namespace App\Catalog;

use App\Models\Product;

interface ProductReader
{
    public function findPublishedBySlug(string $slug): ?Product;
}
php
<?php

declare(strict_types=1);

namespace App\Catalog;

use Illuminate\Contracts\Cache\Repository as Cache;

final readonly class CachedProductReader implements ProductReader
{
    public function __construct(private ProductReader $inner, private Cache $cache) {}

    public function findPublishedBySlug(string $slug): ?\App\Models\Product
    {
        return $this->cache->remember("catalog.product.{$slug}", now()->addMinutes(10), fn (): ?\App\Models\Product => $this->inner->findPublishedBySlug($slug));
    }
}

Provider składa go jawnie. Powiązanie kontraktu z nim samym wewnątrz dekoratora spowodowałoby nieskończoną rekurencję.

php
$this->app->bind(ProductReader::class, function ($app): ProductReader {
    $database = new EloquentProductReader();

    return new CachedProductReader($database, $app->make(Cache::class));
});

Dodaj LoggedProductReader wokół readera z cache, jeśli wolne odczyty wymagają ustrukturyzowanego pomiaru czasu. Unieważnianie należy do ścieżki zapisu: po publikacji lub zmianie produktu wywołaj Cache::forget() dla właściwego klucza. Przy szerokim unieważnianiu użyj klucza wersjonowanego albo tagów cache tylko wtedy, gdy skonfigurowany store je obsługuje.

Kiedy go nie używać

Nie wprowadzaj repozytorium jedynie po to, aby opakować Product::find(). Dekoratory zwracają się wtedy, gdy granica odczytu ma już nazwę — wyszukiwanie w katalogu, kursu walut czy uprawnienia — i potrzebuje dwóch implementacji albo zachowania przekrojowego. Cache zapytań Laravela nie jest automatyczny; jawnie określaj świeżość danych.

Zdefiniuj kształt odczytu, zanim dodasz infrastrukturę

Pierwotny kontrakt jest celowo wąski: zwraca jeden publiczny produkt dla jednego sluga. Nie zmieniaj go w imitację Eloquent z all, save i dowolnymi callbackami zapytań. Generyczne repozytorium tylko ukrywa użyteczne możliwości ORM-u, wciąż przepuszczając jego złożoność. Ten kontrakt nazywa zapytanie, które może współdzielić kilka wywołujących: strona produktu, endpoint Open Graph i usługa powiązanych elementów potrzebują tej samej publicznej reprezentacji.

php
<?php

declare(strict_types=1);

namespace App\Catalog;

use App\Models\Product;

interface ProductReader
{
    public function findPublishedBySlug(string $slug): ?Product;
}

final class EloquentProductReader implements ProductReader
{
    public function findPublishedBySlug(string $slug): ?Product
    {
        return Product::query()
            ->select(['id', 'category_id', 'slug', 'name', 'description', 'price_cents', 'published_at'])
            ->where('slug', $slug)
            ->whereNotNull('published_at')
            ->where('published_at', '<=', now())
            ->with('category:id,name,slug')
            ->first();
    }
}

Eager loading jest częścią obietnicy readera. Cache może sprawić, że zapytanie N+1 zdarza się rzadziej, ale nie może uczynić go poprawnym. Jeśli strona produktu potrzebuje obrazów, cen lub danych kategorii, załaduj dokładne relacje tutaj albo zwróć dedykowany DTO odczytowy. Nie wywołuj Product::find() i nie decyduj o publiczności rekordu później: zcache’owany szkic, choćby na moment, jest błędem ujawnienia danych.

Jeśli widoczność zależy od locale, klienta albo uprawnienia, ten kontekst musi trafić do kontraktu i klucza. Klucz oparty wyłącznie o slug jest bezpieczny jedynie wtedy, gdy odpowiedź jest identyczna dla każdego anonimowego gościa.

Niech warstwa cache będzie prosta i deterministyczna

Dekorator cache jest właścicielem konstrukcji klucza oraz dopuszczalnego okna nieaktualności. Nie dodaje warunków zapytania, nie przechwytuje po cichu błędów bazy i nie decyduje o wyglądzie strony. remember() ujawnia delegację: tylko cache miss wywołuje wewnętrzny reader.

php
<?php

declare(strict_types=1);

namespace App\Catalog;

use App\Models\Product;
use Illuminate\Contracts\Cache\Repository as Cache;

final readonly class CachedProductReader implements ProductReader
{
    public function __construct(
        private ProductReader $inner,
        private Cache $cache,
    ) {}

    public function findPublishedBySlug(string $slug): ?Product
    {
        return $this->cache->remember(
            self::key($slug),
            now()->addMinutes(10),
            fn (): ?Product => $this->inner->findPublishedBySlug($slug),
        );
    }

    public static function key(string $slug): string
    {
        return "catalog.product.{$slug}";
    }
}

Dziesięć minut to decyzja publikacyjna, nie uniwersalne ustawienie cache. Może być akceptowalne dla katalogu ofertowego i nieakceptowalne dla stanu magazynu albo ceny regulowanej prawnie. Traktuj TTL jako siatkę bezpieczeństwa; ścieżka zapisu normalnie powinna unieważnić wpis wcześniej. Równie ostrożnie podchodź do negatywnego cache’owania. Sterowniki cache nie zawsze tak samo odróżniają zapisany null od braku wpisu, a zcache’owana nieobecność może ukryć produkt właśnie opublikowany przez redaktora. Gdy ruch botów rzeczywiście to uzasadnia, użyj jawnego obiektu wyniku i krótszego TTL dla braków.

Dodaj pomiary osobno od cache

Obserwowalność jest kolejną niezależną troską. Umieszczenie tego dekoratora na zewnątrz cache odpowiada na pytanie „jak długo czekał wywołujący?”. Umieszczenie go wewnątrz odpowiada na pytanie „jak wolne były cache missy?”. Wybierz wariant według potrzebnej metryki; ta wersja mierzy całą operację. Blok finally zapisuje także awarie, ale nie zmienia semantyki wyjątku.

php
<?php

declare(strict_types=1);

namespace App\Catalog;

use App\Models\Product;
use Psr\Log\LoggerInterface;

final readonly class LoggedProductReader implements ProductReader
{
    public function __construct(
        private ProductReader $inner,
        private LoggerInterface $logger,
    ) {}

    public function findPublishedBySlug(string $slug): ?Product
    {
        $startedAt = hrtime(true);

        try {
            return $this->inner->findPublishedBySlug($slug);
        } finally {
            $this->logger->info('catalog.product_read', [
                'slug' => $slug,
                'duration_ms' => (hrtime(true) - $startedAt) / 1_000_000,
            ]);
        }
    }
}

W ruchliwym katalogu logowanie każdego udanego odczytu może stworzyć więcej szumu i kosztu niż wartości. Preferuj metrykę, próbkuj sukcesy lub loguj tylko wolne odczyty. Nigdy nie wkładaj do kontekstu logu opisu produktu, indywidualnej ceny ani innych wrażliwych danych tylko dlatego, że dekorator zna wynik.

Składaj konkretne warstwy od środka na zewnątrz

Nie rozwiązuj ProductReader podczas rejestrowania powiązania ProductReader. Prosi to kontener o powiązanie właśnie budowane i kończy się rekurencją. Utwórz implementację bazodanową bezpośrednio, następnie opakuj ten konkretny obiekt. Kolejność jest oczywista w review i łatwa do świadomej zmiany.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Catalog\CachedProductReader;
use App\Catalog\EloquentProductReader;
use App\Catalog\LoggedProductReader;
use App\Catalog\ProductReader;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;

final class CatalogServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(ProductReader::class, function (): ProductReader {
            $database = new EloquentProductReader();
            $cached = new CachedProductReader($database, $this->app->make(Cache::class));

            return new LoggedProductReader($cached, $this->app->make(LoggerInterface::class));
        });
    }
}

Taka kompozycja utrzymuje testy lokalne. Test cache nie potrzebuje providera ani bazy; potrzebuje fake readera ze zliczaniem i array cache. Test zapytania nie musi dowodzić mechaniki cache. Rozdzielenie tych trybów awarii przyspiesza suite i ułatwia diagnozę.

Unieważniaj po udanym commicie

Reader nie może wiedzieć, kiedy redaktor zmienił produkt. Właścicielem tego faktu jest akcja zapisu. Usuń klucz po commicie bazy, a nie przed nim: w przeciwnym razie inny request może nie trafić w cache, odczytać stary stan po ostatnim commicie i ponownie wypełnić klucz, gdy transakcja piszącego wciąż jest otwarta.

php
<?php

declare(strict_types=1);

namespace App\Catalog;

use App\Models\Product;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Facades\DB;

final readonly class PublishProduct
{
    public function __construct(private Cache $cache) {}

    public function handle(Product $product): void
    {
        DB::transaction(function () use ($product): void {
            $product->forceFill(['published_at' => now()])->save();

            DB::afterCommit(function () use ($product): void {
                $this->cache->forget(CachedProductReader::key($product->slug));
            });
        });
    }
}

Zmiana sluga wymaga zapomnienia starego i nowego klucza. Jeśli zcache’owany produkt zawiera dane kategorii, aktualizacja kategorii dotyczy także jej produktów. Tagi cache Redisa i Memcached mogą modelować szerokie unieważnienie, ale nie są przenośne do każdego store’a Laravela. Wersjonowany klucz katalogu jest często czytelniejszą alternatywą, gdy szerokie odświeżenie jest akceptowalne.

Udowodnij granicę precyzyjnymi testami Pest

Centralna obietnica dekoratora to jedna delegacja, a potem ponowne użycie. Można to udowodnić bez bazy, a osobny test feature powinien potwierdzić, że nieopublikowane lub datowane w przyszłości produkty nigdy nie wychodzą z EloquentProductReader.

php
<?php

use App\Catalog\CachedProductReader;
use App\Catalog\ProductReader;
use App\Models\Product;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;

it('delegates one slug lookup only once while cached', function (): void {
    $product = Product::factory()->make(['slug' => 'desk-lamp']);
    $reader = new class($product) implements ProductReader {
        public int $calls = 0;

        public function __construct(private Product $product) {}

        public function findPublishedBySlug(string $slug): ?Product
        {
            $this->calls++;

            return $this->product;
        }
    };

    $cached = new CachedProductReader($reader, new Repository(new ArrayStore()));

    expect($cached->findPublishedBySlug('desk-lamp'))->toBe($product)
        ->and($cached->findPublishedBySlug('desk-lamp'))->toBe($product)
        ->and($reader->calls)->toBe(1);
});

Kiedy nie używać tego wzorca

Do jednorazowego administracyjnego lookupu użyj bezpośrednio query scope i Cache::remember(). Użyj Cache::memo() albo once(), gdy powielona praca istnieje tylko w obrębie jednego requestu; żadne z nich nie tworzy problemu unieważniania rozproszonego cache. Gdy wynikiem jest duża odpowiedź JSON, często lepszy od nawodnionego modelu Eloquent będzie dedykowany read model.

Na koniec nie wciskaj niepowiązanych odczytów do jednego interfejsu. Jeśli wywołujący potrzebują innych kolumn, relacji, autoryzacji i filtrów, reader albo urośnie do listy parametrów, której nikt nie rozumie, albo zwróci z cache kształt błędny dla części odbiorców. Najpierw podziel stabilne przypadki użycia. Dekoratory zwracają się wyłącznie, gdy kontrakt odczytu jest spójny, kolejność warstw przekrojowych ma znaczenie, a ktoś jest właścicielem świeżości na ścieżce zapisu.

Decyzje produkcyjne, które warto podjąć świadomie

Wygaśnięcie może wywołać cache stampede: wiele requestów widzi ten sam brak klucza i wszystkie uruchamiają zapytanie do bazy. Nie dodawaj rozproszonego locka odruchowo; może on sprawić, że zdrowa strona będzie czekała za wolnym requestem. Dla kosztownego odczytu o dużym ruchu zdecyduj jawnie, czy lepszym trybem awarii będzie krótki lock, cache stale-while-revalidate Laravela czy job rozgrzewający cache. Właściwy wybór zależy od tego, czy odbiorcy wolą nieco starszą stronę katalogu, czy sporadyczną wolną odpowiedź.

Ustal też, czy modelem reprezentacji, który chcesz zachować, jest model Eloquent. Zmiany castów, accessorów, ukrytych atrybutów albo relacji, których dotyka strona, mogą uczynić dawniej niewinny wpis cache zaskakującym. Mały, niemutowalny DTO czytelniej określa payload cache i publiczną granicę dla długowiecznego odczytu. Nie wprowadzaj jednak DTO tylko po to, by spełnić wzorzec: dla dziesięciominutowego wewnętrznego cache z jednym konsumentem starannie załadowany model nadal może być prostszym i łatwiejszym w utrzymaniu wyborem.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.