Middle: питання на співбесіді з теми «Патерни й SOLID»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Три схожі назви - три різні речі:
Dependency Injection (впровадження залежностей) - техніка: об'єкт отримує залежності ззовні (через конструктор), а не створює їх сам.
// Без DI
class ReportService { private $mailer; public function __construct() { $this->mailer = new SmtpMailer(); } }
// З DI
class ReportService { public function __construct(private Mailer $mailer) {} }
Dependency Inversion Principle (DIP, «D» у SOLID) - принцип проєктування:
- Модулі високого рівня (бізнес-логіка) не мають залежати від модулів низького рівня (база, пошта, HTTP). Обидва залежать від абстракцій.
- Абстракції не залежать від деталей - деталі залежать від абстракцій.
«Інверсія» в тому, що інтерфейс належить бізнес-логіці й описаний її мовою, а інфраструктура його реалізує. Не «у нас є Stripe, давайте загорнемо його API в інтерфейс», а «бізнес-логіці потрібно PaymentGateway::charge(), а Stripe - одна з реалізацій».
IoC-контейнер (Service Container у Laravel) - інструмент, який автоматизує DI: сам створює об'єкти й підставляє залежності.
Зв'язок: DI можна робити без DIP - впроваджувати конкретний клас StripeClient. Тоді залежність ззовні, але бізнес-логіка все одно прив'язана до деталі. DIP додає абстракцію, а DI - спосіб її доставити.
Коли DIP виправданий: межі з зовнішнім світом, які справді можуть змінитися чи які треба підміняти в тестах - платежі, пошта, сторонні API, сховища. Інтерфейс на кожен клас «про всяк випадок» лише додає файлів і непрямих викликів без користі.
Усі три загортають інші об'єкти, але з різною метою:
Adapter - змінює інтерфейс: приводить чужий об'єкт до інтерфейсу, який очікує ваш код.
final class TwilioSmsAdapter implements SmsSender // ваш інтерфейс
{
public function __construct(private TwilioClient $twilio) {}
public function send(PhoneNumber $to, string $text): void
{
$this->twilio->messages->create((string) $to, ['body' => $text, 'from' => '...']);
}
}
Decorator - той самий інтерфейс, додана поведінка. Загортає об'єкт і додає щось до або після виклику; декоратори можна нашаровувати.
final class CachedExchangeRates implements ExchangeRates
{
public function __construct(private ExchangeRates $inner, private CacheRepository $cache) {}
public function rate(string $from, string $to): float
{
return $this->cache->remember("rate:$from:$to", 3600, fn () => $this->inner->rate($from, $to));
}
}
Кешування, логування, повтори, метрики - типові декоратори. Обгорнутий і обгортка взаємозамінні.
Facade (класичний, GoF) - спрощений інтерфейс до складної підсистеми: один клас з кількома простими методами замість десятка класів з тонкими налаштуваннями.
Фасади Laravel (Cache::, DB::) - інше: це статичний доступ до сервісів з контейнера (ближче до Service Locator). Назва схожа, але патерн GoF не про це.
Як розрізнити на співбесіді:
- інтерфейс змінюється - Adapter;
- інтерфейс той самий, поведінки більше - Decorator;
- інтерфейс простіший за те, що за ним, - Facade.
Ще схожий Proxy - той самий інтерфейс, але контролює доступ до об'єкта: ліниве створення, перевірка прав, віддалений виклик.
Singleton гарантує, що клас має один екземпляр, і дає до нього глобальний доступ:
final class Config
{
private static ?self $instance = null;
public static function getInstance(): self
{
return self::$instance ??= new self();
}
private function __construct() {}
}
Config::getInstance()->get('app.name');
Чому його вважають антипатерном (точніше - класичну реалізацію):
- прихована залежність: клас, що десь усередині викликає
Config::getInstance(), з конструктора не показує, що залежить від конфігурації; - глобальний стан: будь-який код може змінити спільний об'єкт - важко відстежити, хто й коли;
- тестування: статичний екземпляр не підміниш фейком без хитрощів, а стан «перетікає» між тестами;
- порушення SRP: клас відповідає і за свою роботу, і за керування власним життєвим циклом;
- довгоживучі процеси (Octane, черги): статичний стан живе між запитами - дані одного користувача можуть потрапити до іншого.
Задача «один екземпляр на застосунок» справжня - з'єднання з базою, клієнт API, конфігурація. Розв'язувати її краще контейнером:
// AppServiceProvider
$this->app->singleton(PaymentGateway::class, fn () => new StripeGateway(config('services.stripe.secret')));
// використання - звичайна залежність
final class CheckoutService
{
public function __construct(private PaymentGateway $payments) {}
}
- один екземпляр - контейнер створює об'єкт один раз і повертає його всім;
- явна залежність у конструкторі;
- підміна в тестах:
$this->app->instance(PaymentGateway::class, new FakeGateway)чиswap; - клас нічого не знає про те, що він «єдиний» - це рішення конфігурації, а не самого класу.
scoped() замість singleton() для Octane: екземпляр живе в межах одного запиту чи задачі в черзі і скидається між ними - захист від витоку стану між користувачами.
Фасади Laravel - не Singleton у класичному сенсі: це статичний доступ до об'єкта з контейнера, який можна підмінити (Cache::fake(), Mail::fake()). Але залежність через фасад так само прихована в коді методу, тож для бізнес-класів явне впровадження через конструктор зазвичай читабельніше.
Успадкування повторно використовує код через ієрархію «є різновидом» (Admin extends User). Композиція - через «має» чи «використовує»: об'єкт отримує інші об'єкти й делегує їм роботу.
Проблеми глибокого успадкування:
class Report { public function generate() { /* ... */ } }
class PdfReport extends Report { /* ... */ }
class CachedPdfReport extends PdfReport { /* ... */ }
class CachedEmailedPdfReport extends CachedPdfReport { /* ... */ }
// а потрібен ще CachedEmailedCsvReport...
- комбінаторний вибух: кожна комбінація можливостей - новий клас;
- крихкий базовий клас: зміна в
Reportнепередбачувано ламає нащадків; - жорсткий зв'язок: нащадок залежить від внутрішніх деталей батька (
protected), а не лише від публічного API; - одне успадкування в PHP: клас не може взяти поведінку з двох батьків.
Композицією:
final class ReportGenerator
{
public function __construct(
private ReportFormatter $formatter, // Pdf чи Csv
private ReportDelivery $delivery, // Email чи Storage
private CacheRepository $cache,
) {}
public function run(ReportQuery $query): void
{
$data = $this->cache->remember($query->key(), 3600, fn () => $query->fetch());
$this->delivery->send($this->formatter->format($data));
}
}
Будь-яка комбінація - різні об'єкти в конструкторі, без нових класів. Поведінку можна змінити під час виконання й легко підмінити в тестах.
Інструменти композиції: впровадження залежностей, Strategy, Decorator (обгортання з тим самим інтерфейсом), делегування.
Трейти PHP - механізм повторного використання коду без успадкування, але це радше «копіювання методів» у клас: вони не створюють окремих об'єктів і можуть мати ті самі проблеми прихованих залежностей. Добрі для невеликої поведінки (HasFactory, SoftDeletes), погані як заміна архітектури.
Коли успадкування доречне:
- справжнє відношення «є різновидом», де нащадок повністю замінює батька (принцип Лісков);
- точки розширення фреймворку:
extends Model,extends Controller,extends Command- фреймворк так спроєктований; - абстрактний клас з невеликою спільною логікою і шаблонним методом;
- неглибока ієрархія (1-2 рівні).
Корисне правило: класи за замовчуванням final. Відкривати для успадкування - свідоме рішення, а не випадковість.
Command (Команда) - запит на дію оформлюється окремим об'єктом, що містить усе потрібне для виконання: що зробити і з якими даними. Тоді дію можна передати, відкласти, поставити в чергу, повторити, записати в журнал чи скасувати.
Laravel-джоба - класичний Command:
final class GenerateInvoicePdf implements ShouldQueue
{
use Queueable;
public function __construct(public Invoice $invoice) {}
public function handle(PdfRenderer $renderer): void
{
$this->invoice->update(['pdf_path' => $renderer->render($this->invoice)]);
}
}
GenerateInvoicePdf::dispatch($invoice); // виконати пізніше в черзі
GenerateInvoicePdf::dispatchSync($invoice); // виконати зараз
- об'єкт-команда містить дані (
$invoice) і знає, що робити (handle); - виконавець (воркер черги) не знає деталей - просто викликає
handle; - відправник не знає, коли й де команда виконається.
Що дає такий підхід:
- відкладене й асинхронне виконання - черги;
- повтори при збоях (
$tries,backoff) - команда серіалізується й виконується знову; - ланцюжки й пакети:
Bus::chain([...]),Bus::batch([...])- послідовність команд як дані; - журнал: кожна команда - запис у черзі з параметрами, спробами, помилками (Horizon);
- middleware для команд (
RateLimited,WithoutOverlapping) - спільна поведінка навколо будь-якої команди.
Інші Command у Laravel: Artisan-команди, дії (action-класи з одним методом handle/__invoke), Pipeline з об'єктами-кроками.
Command + скасування (класичний варіант патерну): команда має execute() і undo() - основа функцій «Скасувати» в редакторах. У вебзастосунках частіше роблять компенсуючі дії (повернення коштів, а не «відкат» оплати).
CQRS-підхід з шиною команд: контролер створює команду PlaceOrder, шина знаходить обробник PlaceOrderHandler. Це розділяє «що потрібно зробити» і «як», але додає шар непрямості - доречно у великих доменах, а в звичайному Laravel-застосунку часто досить action-класів.
Важливо для черг: команда має бути ідемпотентною або захищеною від повторного виконання (ShouldBeUnique, перевірка стану) - повтори при збоях і «at least once» доставка можуть виконати її двічі.