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
declare(strict_types=1);
namespace App\Catalog;
use App\Models\Product;
interface ProductReader
{
public function findPublishedBySlug(string $slug): ?Product;
}
<?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ę.
$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
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
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
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
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
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
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.