<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
СТРУКТУРНИЙ ПАТЕРН

ДекораторDecorator

Нова поведінка додається обгорткою з тим самим інтерфейсом, тож ні обгорнутий клас, ні код, що його викликає, не змінюються.

Laravel Symfony складність 2 з 3 5 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Репозиторій, який кешує, логує й рахує час

Сторінку вакансії відкривають набагато частіше, ніж вакансію редагують, тож у репозиторій додали кеш. Потім лог повільних запитів. Потім прапорець, щоб адмінка бачила свіжі дані. Тепер запит до бази загубився серед коду, який до нього не має стосунку.

src/Jobs/EloquentJobRepository.php
1final class EloquentJobRepository implements JobRepository
2{
3 public function find(int $id, bool $useCache = true): ?Job
1Прапорець $useCache зʼявився заради адмінки, а думати про нього тепер мусить кожен виклик.
4 {
5 if ($useCache && $job = Cache::get("job.{$id}")) {
6 return $job;
7 }
8
9 $started = microtime(true);
10 $job = Job::query()->with('company')->find($id);
2Єдиний рядок, заради якого існує клас, сидить між кешем і логом.
11 Log::debug('job.find', ['id' => $id, 'ms' => (microtime(true) - $started) * 1000]);
12
13 if ($useCache && $job !== null) {
3Щоб перевірити сам запит, тест мусить підробити кеш або не забути його вимкнути.
14 Cache::put("job.{$id}", $job, now()->addMinutes(10));
15 }
16
17 return $job;
18 }
19}
02 · ІДЕЯ РІШЕННЯ

Кеш загортає репозиторій і має той самий інтерфейс

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
find()$innerController«interface»JobRepositoryfind(id): JobEloquentJobRepositoryдекораторCachedJobRepositoryPostgreSQLRedis
Controller«interface»JobRepositoryfind(id): JobEloquentJobRepositoryдекораторCachedJobRepositoryPostgreSQLRedis
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Controller використовує JobRepository (find()).
  • EloquentJobRepository реалізує JobRepository.
  • CachedJobRepository реалізує JobRepository.
  • CachedJobRepository використовує JobRepository ($inner).
  • CachedJobRepository використовує Redis.
  • EloquentJobRepository використовує PostgreSQL.
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class EloquentJobRepository implements JobRepository
2{
3 public function find(int $id, bool $useCache = true): ?Job
4 {
5 if ($useCache && $job = Cache::get("job.{$id}")) {
6 return $job;
7 }
8
9 $started = microtime(true);
10 $job = Job::query()->with('company')->find($id);
11 Log::debug('job.find', ['id' => $id, 'ms' => (microtime(true) - $started) * 1000]);
12
13 if ($useCache && $job !== null) {
14 Cache::put("job.{$id}", $job, now()->addMinutes(10));
15 }
16
17 return $job;
18 }
19}
1<?php
2
3final readonly class Job
4{
5 public function __construct(public int $id, public string $title) {}
6}
7
81interface JobRepository
9{
10 public function find(int $id): ?Job;
11}
12
13final class DatabaseJobRepository implements JobRepository
14{
15 /** @param array<int, string> $rows */
16 public function __construct(private readonly array $rows) {}
17
18 public function find(int $id): ?Job
19 {
20 echo " SQL: select * from jobs where id = {$id}\n";
21
22 return isset($this->rows[$id]) ? new Job($id, $this->rows[$id]) : null;
23 }
24}
25
26final class CachedJobRepository implements JobRepository
27{
28 /** @var array<int, ?Job> */
29 private array $cache = [];
30
312 public function __construct(private readonly JobRepository $inner) {}
32
33 public function find(int $id): ?Job
34 {
35 if (! array_key_exists($id, $this->cache)) {
36 $this->cache[$id] = $this->inner->find($id);
37 }
38
39 return $this->cache[$id];
40 }
41}
42
433final class LoggingJobRepository implements JobRepository
44{
45 public function __construct(private readonly JobRepository $inner) {}
46
47 public function find(int $id): ?Job
48 {
49 $job = $this->inner->find($id);
50 echo " лог: find({$id}) ", $job === null ? 'нічого не знайшов' : 'знайшов вакансію', "\n";
51
52 return $job;
53 }
54}
55
564$jobs = new LoggingJobRepository(
57 new CachedJobRepository(
58 new DatabaseJobRepository([7 => 'Senior PHP Developer, Львів']),
59 ),
60);
61
62foreach ([1, 2, 3] as $visit) {
63 echo "Перегляд {$visit}:\n";
64 $job = $jobs->find(7);
65 echo " {$job?->title}\n";
66}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 8–11

    Спільний інтерфейс. Його реалізують і справжній репозиторій, і обидві обгортки.

  2. 2 рядки 31–40

    Декоратор отримує внутрішній репозиторій у конструкторі й звертається до нього лише тоді, коли в кеші порожньо.

  3. 3 рядки 43–54

    Другий декоратор додає лог і теж нічого не знає про базу чи кеш.

  4. 4 рядки 56–60

    Шари збирають ззовні. Лог стоїть зовні кешу, тому бачить кожен виклик, а SQL виконується лише раз.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Container::extend()

Контейнер уміє загорнути вже зареєстрований сервіс: extend() отримує готовий обʼєкт і повертає обгортку. Кожен, хто просить JobRepository, отримує версію з кешем.

// AppServiceProvider::register()
$this->app->bind(JobRepository::class, EloquentJobRepository::class);
 
$this->app->extend(
    JobRepository::class,
    fn (JobRepository $jobs) => new CachedJobRepository($jobs),
);

Ви вже користуєтесь цим, коли вмикаєте кеш одним рядком у провайдері, а контролери й EloquentJobRepository не чіпаєте.

SYMFONY

#[AsDecorator]

Атрибут #[AsDecorator] каже контейнеру підставити цей клас замість сервісу, а оригінал передати в параметр з #[AutowireDecorated].

#[AsDecorator(decorates: JobRepository::class)]
final class CachedJobRepository implements JobRepository
{
    public function __construct(
        #[AutowireDecorated] private JobRepository $inner,
        private CacheInterface $cache,
    ) {}
}

Ви вже користуєтесь цим, коли додаєте клас з атрибутом, і всі, хто отримував JobRepository, тепер отримують обгортку.

✓ ЯК ЦЕ ТЕСТУВАТИ

Декоратор тестуйте з простим фейком усередині, який рахує виклики. Так видно саму поведінку обгортки без бази й справжнього кешу.

it('asks the inner repository only once', function () {
    $inner = new CountingJobRepository([7 => job(7)]);
    $jobs = new CachedJobRepository($inner);
 
    $jobs->find(7);
    $jobs->find(7);
 
    expect($inner->calls)->toBe(1);
});
05 · КОЛИ НЕ ТРЕБА

Один виклик не потребує обгортки

Якщо кеш потрібен в одному місці, Cache::remember() там і читається легше за новий клас. Декоратор окупається, коли поведінку треба додати всім, хто користується інтерфейсом. А якщо в інтерфейсі дванадцять методів, обгортці доведеться передавати далі кожен з них.

$job = Cache::remember(
    "job.{$id}",
    now()->addMinutes(10),
    fn () => $this->jobs->find($id),
);
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Наслідування привʼязує кеш до однієї реалізації. Декоратор загортає будь-який JobRepository, а шари можна комбінувати: кеш, лог, повтори, без класу на кожну комбінацію.

Так. Якщо лог загортає кеш, він бачить кожен виклик. Якщо кеш загортає лог, лог бачить лише промахи кешу. Порядок задають там, де збирають обʼєкт: у провайдері чи конфігурації контейнера.

Обгортка все одно мусить реалізувати всі дванадцять і передати одинадцять далі. Часто це сигнал розділити інтерфейс: окремо читання вакансії, окремо решта операцій, і загорнути лише потрібну частину.