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

СтратегіяStrategy

Різні способи зробити одну справу ховаються за спільним інтерфейсом, і код вибирає потрібний, не знаючи деталей.

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

Switch, який росте з кожним перевізником

Магазин рахує доставку для Нової пошти, Укрпошти й Meest. Спочатку це один if, потім switch на сто рядків, і кожен новий перевізник змушує лізти в код, який уже працює. Тести на одного перевізника ламаються через зміни в іншому.

src/Shipping/ShippingCalculator.php
1final class ShippingCalculator
2{
3 public function quote(Order $order): Money
4 {
5 switch ($order->carrier) {
1Кожен новий перевізник додає сюди ще одну гілку.
6 case 'nova_poshta':
7 $base = 60 + $order->weight * 12;
8 return Money::uah($order->cod ? $base + 20 : $base);
9 case 'ukrposhta':
10 return Money::uah(35 + $order->weight * 8);
11 case 'meest':
12 $zone = $this->zones->for($order->city);
2Залежність $zones потрібна лише Meest, але живе в спільному класі й заважає тестам інших.
13 return Money::uah($zone->rate * $order->weight);
14 // case 'rozetka': ...
3Щоб додати Rozetka, треба відкрити й заново перевірити код, який уже працює.
15 default:
16 throw new UnknownCarrier($order->carrier);
17 }
18 }
19}
02 · ІДЕЯ РІШЕННЯ

Кожен перевізник за спільним інтерфейсом

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
quote()Checkouttotal(Order)«interface»ShippingRatequote(Order): MoneyNovaPoshtaRateUkrposhtaRateMeestRateновий класRozetkaRate
quote()Checkouttotal(Order)«interface»ShippingRatequote(Order): MoneyNovaPoshtaRateUkrposhtaRateMeestRateновий класRozetkaRate
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Checkout використовує ShippingRate (quote()).
  • NovaPoshtaRate реалізує ShippingRate.
  • UkrposhtaRate реалізує ShippingRate.
  • MeestRate реалізує ShippingRate.
  • RozetkaRate реалізує ShippingRate.
03 · КОД

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

PHP 8.5
1final class ShippingCalculator
2{
3 public function quote(Order $order): Money
4 {
5 switch ($order->carrier) {
6 case 'nova_poshta':
7 $base = 60 + $order->weight * 12;
8 return Money::uah($order->cod ? $base + 20 : $base);
9 case 'ukrposhta':
10 return Money::uah(35 + $order->weight * 8);
11 case 'meest':
12 $zone = $this->zones->for($order->city);
13 return Money::uah($zone->rate * $order->weight);
14 // case 'rozetka': ...
15 default:
16 throw new UnknownCarrier($order->carrier);
17 }
18 }
19}
1<?php
2
31interface ShippingRate
4{
5 public function quote(Order $order): int;
6}
7
82final class NovaPoshtaRate implements ShippingRate
9{
10 public function quote(Order $order): int
11 {
12 return 60 + $order->weight * 12;
13 }
14}
15
16final class UkrposhtaRate implements ShippingRate
17{
18 public function quote(Order $order): int
19 {
20 return 35 + $order->weight * 8;
21 }
22}
23
24final class Checkout
25{
263 public function __construct(private ShippingRate $rate) {}
27
28 public function total(Order $order): int
29 {
30 return $order->sum + $this->rate->quote($order);
31 }
32}
33
34final class Order
35{
36 public function __construct(public int $sum, public int $weight) {}
37}
38
39$order = new Order(sum: 1200, weight: 3);
40
414foreach ([new NovaPoshtaRate, new UkrposhtaRate] as $rate) {
42 echo str_pad($rate::class, 16), (new Checkout($rate))->total($order), " грн\n";
43}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–6

    Інтерфейс описує, що вміє будь-яка стратегія. Один метод, нічого зайвого.

  2. 2 рядки 8–22

    Кожен перевізник у своєму класі. Тарифи Нової пошти змінюються, не зачіпаючи Укрпошту.

  3. 3 рядки 26–31

    Checkout отримує стратегію в конструкторі й не знає, яка саме прийшла.

  4. 4 рядки 41–43

    Стратегію вибирають ззовні: тут у циклі, у Laravel це робить контейнер або Manager.

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

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

LARAVEL

Драйвери через Manager

Драйвери кешу, черг і пошти: Cache::store('redis') чи 'file', а код, що викликає Cache::get(), однаковий. Під капотом Manager вибирає стратегію за назвою з конфігу.

// .env: CACHE_STORE=redis
Cache::get('rates:np');
 
Cache::store('file')->get('rates:np');
// той самий виклик, інша стратегія

Ви вже користуєтесь цим, коли змінюєте CACHE_STORE у .env і не чіпаєте жодного рядка коду.

SYMFONY

Локатор теґованих сервісів

Сервіси з тегом і #[AutowireLocator]: усі реалізації інтерфейсу збираються в локатор, і потрібну беруть за ключем.

public function __construct(
    #[AutowireLocator('app.shipping_rate', indexAttribute: 'key')]
    private ContainerInterface $rates,
) {}
 
$this->rates->get($order->carrier)->quote($order);

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

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

Кожну стратегію перевіряйте окремо, а Checkout з простою фейковою стратегією: так тест не залежить від справжніх тарифів.

it('adds the rate to the sum', function () {
    $checkout = new Checkout(new FakeRate(price: 50));
 
    expect($checkout->total(order(sum: 1200)))
        ->toBe(1250);
});
05 · КОЛИ НЕ ТРЕБА

Два варіанти не потребують патерну

Якщо варіантів два й нових не буде, звичайний match читається легше за три класи й інтерфейс.

$fee = match ($order->delivery) {
    'pickup'  => 0,
    'courier' => 120,
};
06 · СУСІДИ

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

Стратегія
Strategy
ВИ ТУТ
ХТО ВИБИРАЄ
Клієнт або конфіг вибирає один спосіб зробити справу.
ЩО ЗМІНЮЄТЬСЯ
Алгоритм цілком. Стратегії не знають одна про одну.
Стан
State
ХТО ВИБИРАЄ
Обʼєкт сам перемикає свій стан після дії.
Декоратор
Decorator
ЩО ЗМІНЮЄТЬСЯ
Нічого не замінюється: поведінка додається зверху, обгорток може бути кілька.
07 · ПЕРЕВІР СЕБЕ

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

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

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

CacheManager за назвою драйвера з конфігу створює потрібне сховище: redis, file, database. Усі вони реалізують контракт Store, тому код, що викликає Cache::get(), не залежить від вибору.

Позначити інтерфейс #[AutoconfigureTag], а в сервісі прийняти #[AutowireLocator] або #[AutowireIterator]. Контейнер сам збере всі реалізації, потрібну беруть за ключем.