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

Питання на співбесіді: Сервіс-контейнер

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

9 питань

Обидва кажуть контейнеру, як створювати сервіс. Різниця - у тому, скільки разів це станеться.

bind() створює новий екземпляр щоразу:

$this->app->bind(ReportBuilder::class, function ($app) {
    return new ReportBuilder($app->make(Connection::class));
});

app(ReportBuilder::class) === app(ReportBuilder::class);   // false

singleton() створює один раз і повертає той самий обʼєкт до кінця запиту:

$this->app->singleton(WeatherClient::class, function ($app) {
    return new WeatherClient(config('services.weather.key'));
});

app(WeatherClient::class) === app(WeatherClient::class);   // true

Є ще scoped() - як singleton, але скидається на кожному запиті. Це важливо для Octane, де застосунок живе між запитами й звичайний singleton зберігав би стан довше, ніж треба.

Що обирати. Singleton доречний, коли створення дороге (клієнт із конфігурацією, зʼєднання) або коли стан має бути спільним. bind() - коли обʼєкт накопичує стан і два різних місця не повинні його ділити.

Головна пастка singleton - утримання стану. Сервіс, що накопичує щось усередині, віддасть ці дані наступному, хто його запросить:

class Basket
{
    private array $items = [];

    public function add(Item $item): void
    {
        $this->items[] = $item;   // у singleton лишиться на весь запит
    }
}

Під Octane це вже не «на запит», а між запитами - і дані одного користувача протікають до іншого. Тому в singleton кладуть сервіси без стану, а стан тримають явно.

Докладніше в документації: Сервіс-контейнер

Контейнер уміє будувати класи сам: читає конструктор через рефлексію й рекурсивно створює залежності.

class InvoiceService
{
    public function __construct(private PdfRenderer $pdf, private Mailer $mailer) {}
}

// у контролері достатньо оголосити - контейнер створить усе дерево
public function send(InvoiceService $invoices) { /* ... */ }

Без прив'язки працює, якщо всі залежності - конкретні класи без скалярних параметрів.

Прив'язка потрібна в трьох випадках:

  1. Інтерфейс. Контейнер не вгадує реалізацію:
$this->app->bind(PaymentGateway::class, StripeGateway::class);

Без цього - виняток Target [PaymentGateway] is not instantiable.

  1. Скалярні параметри - ключ API, таймаут. Їх передають контекстною прив'язкою чи атрибутом #[Config('services.stripe.key')].

  2. Особливий спосіб створення або один екземпляр - клієнт з налаштуванням, singleton() для дорогого об'єкта.

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

Докладніше в документації: Розв'язання без конфігурації

Service Container (IoC-контейнер) керує залежностями класів і виконує dependency injection. Він уміє автоматично будувати об'єкти, рекурсивно резолвлячи їхні залежності через type-hints конструктора.

// біндинг інтерфейсу до реалізації (зазвичай у Service Provider)
$this->app->bind(PaymentGateway::class, StripeGateway::class);

// тепер усюди, де просять PaymentGateway, прийде StripeGateway
public function __construct(private PaymentGateway $gateway) {}
  • bind() - нова реалізація щоразу.
  • singleton() - один екземпляр на весь життєвий цикл запиту.
  • app(PaymentGateway::class) - ручне резолвлення.

Це серце Laravel: контролери, middleware, події - усе резолвиться через контейнер.

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

Dependency Injection - патерн, за якого клас отримує залежності ззовні (зазвичай через конструктор), а не створює їх сам. Це знижує зв'язування й полегшує тестування (можна підсунути мок).

class OrderController
{
    public function __construct(
        private PaymentGateway $gateway, // інжектується контейнером
    ) {}
}

У Laravel DI працює «з коробки»: контейнер читає type-hints і автоматично будує граф залежностей. Інжектити можна й у методи контролера (method injection), зокрема сам Request.

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

Атрибути переносять налаштування контейнера з провайдера ближче до класу, якого вони стосуються.

#[Bind] на інтерфейсі - яку реалізацію впроваджувати, з урахуванням оточення:

#[Bind(RedisEventPusher::class)]
#[Bind(FakeEventPusher::class, environments: ['local', 'testing'])]
interface EventPusher {}

Замінює $this->app->bind(EventPusher::class, ...) у провайдері.

#[Singleton] і #[Scoped] на класі - скільки екземплярів:

#[Singleton]
class ExchangeRates {}

Singleton - один на весь процес, Scoped - один на запит чи завдання.

Контекстні атрибути на параметрах - що саме впровадити:

public function __construct(
    #[Config('app.timezone')] private string $timezone,
    #[Storage('s3')] private Filesystem $disk,
    #[CurrentUser] private User $user,
) {}

Є також Cache, DB, Log, Auth, Context, Give, RouteParameter, Tag.

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

Докладніше в документації: Атрибут Bind

Через контекстні атрибути чи контекстну прив'язку - тоді залежність видно в конструкторі, а не всередині методів.

Атрибутами (найкоротше):

class ReportExporter
{
    public function __construct(
        #[Storage('reports')] private Filesystem $disk,
        #[Config('reports.per_page')] private int $perPage,
        #[Log('reports')] private LoggerInterface $log,
    ) {}
}

Контекстною прив'язкою в провайдері - коли атрибут на чужому класі не поставиш:

$this->app->when(ReportExporter::class)
    ->needs('$perPage')
    ->giveConfig('reports.per_page');

$this->app->when(ReportExporter::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('reports'));

Чим це краще за Storage::disk('reports') усередині методу:

  • залежності чесно перелічені в конструкторі - видно, від чого клас залежить;
  • у тесті їх підставляють напряму, без підміни фасадів;
  • різним класам можна дати різні реалізації того самого інтерфейсу.

Власні атрибути створюють, реалізувавши контракт ContextualAttribute з методом resolve().

Докладніше в документації: Контекстні атрибути

Contextual binding дозволяє віддавати різні реалізації одного інтерфейсу залежно від класу, що його запитує.

$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('local'));

$this->app->when(VideoController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('s3'));

Тобто PhotoController отримає локальний диск, VideoController - S3, хоча обидва просять Filesystem.

Споріднені можливості:

  • giveTagged() - впорснути всі сервіси з певним тегом.
  • Прив'язка примітивів: ->needs('$apiKey')->give(config('services.x.key')).

Корисно, коли одна абстракція має кілька конфігурацій у різних частинах застосунку.

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

У класичному PHP-FPM застосунок народжується й помирає з кожним запитом, тож singleton() фактично означає «один на запит». Під Octane чи у воркері черги процес живе годинами - і різниця стає критичною.

  • singleton() - один екземпляр на весь процес. Під Octane він переживає запит і обслуговує наступних користувачів.
  • scoped() - один екземпляр на запит чи завдання. Контейнер скидає його на початку кожного.
$this->app->singleton(ExchangeRates::class);   // незмінні дані - безпечно
$this->app->scoped(CurrentCart::class);        // стан користувача - лише scoped

Класична помилка:

$this->app->singleton(CartService::class, fn ($app) => new CartService($app['request']->user()));

Під Octane перший користувач «приклеїться» до сервісу, і наступні побачать його кошик.

Правила:

  • синглтон не тримає стану запиту: користувача, Request, локаль, налаштування з сесії;
  • якщо сервісу потрібен запит - scoped() або передавати запит у метод, а не в конструктор;
  • статичні властивості й кеш у пам'яті класу так само переживають запит.

Те саме стосується воркерів черги: завдання N бачить синглтони, створені під час завдання N-1.

Докладніше в документації: Атрибут Scoped

Методом extend() контейнера - він отримує вже створений сервіс і повертає те, що віддаватиметься всім споживачам.

// AppServiceProvider::register()
$this->app->extend(GeocoderInterface::class, function (GeocoderInterface $geocoder, Application $app) {
    return new CachedGeocoder($geocoder, $app->make('cache.store'));
});
final class CachedGeocoder implements GeocoderInterface
{
    public function __construct(private GeocoderInterface $inner, private Repository $cache) {}

    public function locate(string $address): Coordinates
    {
        return $this->cache->remember('geo:' . md5($address), 86400, fn () => $this->inner->locate($address));
    }
}

Це патерн «декоратор»: той самий інтерфейс, а всередині - оригінал плюс нова поведінка. Код, що просить GeocoderInterface, нічого не помічає.

Чому це краще за альтернативи:

  • не треба наслідувати клас пакета й підміняти прив'язку - пакет може змінити конструктор;
  • декоратори нашаровуються: кеш поверх журналу поверх повторів, кожен простий і тестується окремо.

Суміжні інструменти:

  • resolving() - викликати код щоразу, коли сервіс створюється (налаштувати, а не замінити);
  • tag() і tagged() - зібрати всі реалізації інтерфейсу, щоб передати їх масивом.

Докладніше в документації: Розширення прив'язок