Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Патерни проєктування й SOLID

20 питань · ~20 хв · Версія v3.0

Увійдіть, щоб продовжити

Принципи SOLID на прикладах, породжувальні, структурні й поведінкові патерни, їх реалізація в PHP і Laravel - питання від middle до lead.

За спробу
20
У пулі
57
Проходжень
0
Середній бал
-
Пройшли на 70%+
-

Питання для підготовки

16 питань

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

interface ShippingCalculator
{
    public function cost(Order $order): Money;
}

final class NovaPoshtaShipping implements ShippingCalculator { /* ... */ }
final class UkrposhtaShipping implements ShippingCalculator { /* ... */ }
final class PickupShipping implements ShippingCalculator { /* ... */ }

final class Checkout
{
    public function __construct(private ShippingCalculator $shipping) {}

    public function total(Order $order): Money
    {
        return $order->subtotal()->add($this->shipping->cost($order));
    }
}

Що це дає:

  • новий спосіб доставки - новий клас, без зміни Checkout (принцип відкритості/закритості);
  • кожну стратегію легко протестувати окремо;
  • немає switch ($order->shipping_method), що розростається з кожним новим варіантом.

У Laravel цей патерн скрізь - під назвою «драйвери»:

  • кеш: file, redis, database за контрактом Store;
  • файлові диски: локальний, S3, FTP за спільним API Storage;
  • черги, пошта, логи, сесії, хешування - той самий принцип.

Конкретна стратегія обирається конфігурацією (CACHE_STORE=redis), а код працює з контрактом.

Як обирати стратегію під час виконання: через контейнер (прив'язати інтерфейс до реалізації), фабрику чи мапу [метод => клас]. Якщо варіантів два й вони ніколи не множитимуться, простий if часто чесніший за патерн.

Докладніше в документації: Strategy

Принцип відкритості/закритості (Open/Closed Principle, «O» в SOLID): модуль має бути відкритим для розширення і закритим для змін. Нову поведінку додають новим кодом, а не переписуванням того, що вже працює й протестоване.

Порушення - кожна нова можливість змінює той самий код:

class ShippingCalculator
{
    public function cost(Order $order, string $carrier): int
    {
        return match ($carrier) {
            'nova_poshta' => 70 + $order->weight * 5,
            'ukrposhta' => 45 + $order->weight * 3,
            // новий перевізник - правка цього класу, ризик зламати наявних
        };
    }
}

Відповідно до OCP - розширення через новий клас:

interface ShippingMethod
{
    public function cost(Order $order): int;
}

final class NovaPoshta implements ShippingMethod { /* ... */ }
final class Ukrposhta implements ShippingMethod { /* ... */ }
final class Meest implements ShippingMethod { /* ... */ }   // новий - без змін в інших

class ShippingCalculator
{
    public function cost(Order $order, ShippingMethod $method): int
    {
        return $method->cost($order);
    }
}

Типові механізми розширення:

  • поліморфізм через інтерфейси (Strategy);
  • події: нова реакція на «замовлення оплачено» - новий слухач, а не правка контролера;
  • декоратори й middleware - додати поведінку навколо наявної;
  • конфігурація й реєстрація: нова команда, драйвер, канал сповіщень підключається в сервіс-провайдері.

Як це виглядає в Laravel: нові драйвери кешу, черг, файлових систем (Storage::extend()), канали сповіщень, правила валідації, макроси - фреймворк закритий для змін, але відкритий для розширення.

Застереження - не передбачати все наперед. OCP не означає «робити інтерфейс на кожен клас про всяк випадок». Розширювані точки варто вводити, коли з'являється друга реалізація чи явна потреба (правило трьох: втретє однакова зміна - час для абстракції). Передчасна гнучкість - це складність, яка може ніколи не знадобитися.

Корисний тест: чи доводилося останні кілька разів змінювати той самий match/switch, додаючи варіант? Якщо так - це місце, де OCP окупиться.

Докладніше в документації: Принцип відкритості/закритості

SRP - перша літера SOLID: у класу має бути одна причина для змін. Роберт Мартін формулює це так: модуль має відповідати перед одним «актором» - однією групою людей, чиї вимоги можуть його змінити.

Порушення:

class InvoiceService
{
    public function create(Order $order): Invoice { /* розрахунок сум, податків */ }
    public function renderPdf(Invoice $invoice): string { /* верстка */ }
    public function send(Invoice $invoice): void { /* SMTP, шаблон листа */ }
}

У класу три причини змінюватися: бухгалтерія змінює правила податків, дизайнер - вигляд PDF, маркетинг - текст листа. Зміна одного може зламати інше, а тест розрахунку змушує налаштовувати пошту.

Після розділення:

class InvoiceCalculator { public function create(Order $order): Invoice {} }
class InvoicePdf        { public function render(Invoice $invoice): string {} }
class InvoiceMailer     { public function send(Invoice $invoice): void {} }

Як розпізнати порушення:

  • назва класу з «And», «Manager», «Helper», «Util»;
  • клас важко назвати одним іменником;
  • методи класу використовують різні, непересічні групи властивостей;
  • зміна в одній функції регулярно ламає тести іншої.

Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.

Докладніше в документації: Single-responsibility principle

Factory (фабричний метод, фабрика) ховає рішення, який саме об'єкт створити, і як.

Доречна, коли:

  • конкретний клас залежить від даних під час виконання: NotificationChannelFactory::for($user->preferred_channel);
  • створення потребує залежностей, яких не має код-споживач (клієнт API з налаштуваннями, токеном, ретраями);
  • створення - нетривіальна послідовність, яку не хочеться повторювати.

Іменовані конструктори (Money::fromCents(1999), Period::lastMonth()) - найлегша форма фабрики, яка часто краща за складний конструктор.

Builder збирає складний об'єкт покроково, коли параметрів багато й більшість необов'язкові:

$query = User::query()
    ->where('active', true)
    ->whereHas('orders')
    ->orderBy('name')
    ->limit(20);

$mail = (new MailMessage)
    ->subject('Рахунок')
    ->line('Ваш рахунок готовий.')
    ->action('Переглянути', $url);

Доречний, коли конструктор з десятьма параметрами нечитабельний, частина з них взаємозалежна, а об'єкт треба валідувати як ціле перед створенням. У Laravel так влаштовані Query Builder, MailMessage, Http::withHeaders()->retry()->get().

Коли це зайве:

  • фабрика, що просто викликає new без жодного рішення, - додатковий файл без користі;
  • Builder для об'єкта з трьома обов'язковими полями - іменовані аргументи PHP 8 вирішують ту саму задачу: new Invoice(number: ..., total: ..., dueDate: ...);
  • контейнер Laravel уже є фабрикою для більшості сервісів: автовпровадження й прив'язки часто замінюють ручні фабрики.

Правило: патерн має прибирати складність, яку ви вже маєте, а не складність, що «може з'явитися». Ввести фабрику, коли з'явився другий варіант, легше, ніж підтримувати абстракцію, якої ніколи не знадобилося.

Докладніше в документації: Builder

Принцип розділення інтерфейсів (Interface Segregation Principle, «I» в SOLID): клієнти не повинні залежати від методів, якими вони не користуються. Краще кілька вузьких інтерфейсів, ніж один «товстий».

Порушення:

interface Storage
{
    public function read(string $path): string;
    public function write(string $path, string $contents): void;
    public function delete(string $path): void;
    public function temporaryUrl(string $path, DateTimeInterface $expires): string;
    public function listContents(string $dir): array;
}

Клас, якому потрібно лише читати файли, залежить від усього інтерфейсу. Реалізація для сховища, де немає тимчасових посилань, змушена кидати NotSupportedException - а це вже порушення й принципу Лісков.

Відповідно до ISP:

interface ReadsFiles
{
    public function read(string $path): string;
}

interface WritesFiles
{
    public function write(string $path, string $contents): void;
    public function delete(string $path): void;
}

interface GeneratesTemporaryUrls
{
    public function temporaryUrl(string $path, DateTimeInterface $expires): string;
}

final class ReportGenerator
{
    public function __construct(private ReadsFiles $files) {}   // лише те, що потрібно
}

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

Навіщо:

  • простіші тести: фейк для ReadsFiles - один метод, а не п'ять заглушок;
  • менше зв'язності: зміна методу запису не зачіпає класи, що лише читають;
  • чесні реалізації: немає методів, що кидають «не підтримується»;
  • зрозумілі залежності: з конструктора видно, що саме клас використовує.

Приклади в PHP і Laravel:

  • PHP: Countable, IteratorAggregate, ArrayAccess, JsonSerializable, Stringable - маленькі інтерфейси, кожен про одну можливість;
  • Laravel: ShouldQueue, ShouldBroadcast, HasLocalePreference, Arrayable, Jsonable, Responsable - вузькі контракти, які клас реалізує за потреби.

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

Докладніше в документації: Принцип розділення інтерфейсів

Прочитати - ще не значить знати

20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.