Перейти до основного вмісту

Контейнер залежностей у Joomla 6: як працює Dependency Injection

Що таке контейнер у Joomla

У документації Joomla використовується назва Dependency Injection Container, або скорочено DIC. Технічно це сховище сервісів, де кожен сервіс має ключ і значення або функцію, яка знає, як це значення створити.

Ключем найчастіше виступає повне ім’я PHP-класу або інтерфейсу:


Joomla\Database\DatabaseInterface::class

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

Спрощено контейнер можна уявити як таблицю:

Ключ Що повертає контейнер
DatabaseInterface::class Об’єкт для роботи з базою даних
SomeFactoryInterface::class Конкретну реалізацію фабрики
MyServiceInterface::class Сервіс, зареєстрований розширенням

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

Навіщо Joomla використовує Dependency Injection

Без Dependency Injection клас часто сам створює все, що йому потрібно:


final class ProductService
{
    public function __construct()
    {
        $this->repository = new ProductRepository();
    }
}

У такому коді ProductService жорстко пов’язаний із конкретним ProductRepository. Замінити реалізацію, передати тестовий об’єкт або змінити спосіб створення репозиторію стає складніше.

При Dependency Injection залежність передається класу ззовні:


final class ProductService
{
    public function __construct(
        private ProductRepositoryInterface $repository
    ) {
    }
}

Тепер ProductService не вирішує, як створити репозиторій. Він лише повідомляє, що йому потрібен об’єкт, який реалізує ProductRepositoryInterface.

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

Це дає кілька практичних переваг:

  • класи менше залежать від конкретних реалізацій;
  • об’єкти простіше тестувати;
  • створення складних залежностей концентрується в одному місці;
  • один сервіс можна замінити іншою реалізацією;
  • спільні об’єкти не потрібно вручну створювати в різних частинах розширення.
Головний контейнер Joomla

Під час запуску Joomla створює основний DI-контейнер та реєструє в ньому системні сервіси. У вихідному коді Joomla контейнер використовується вже на етапі запуску застосунку.

Отримати головний контейнер можна через:


use Joomla\CMS\Factory;

$container = Factory::getContainer();

Наприклад, об’єкт бази даних можна отримати за інтерфейсом DatabaseInterface:


use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;

$db = Factory::getContainer()->get(DatabaseInterface::class);

Контейнер знаходить сервіс, зареєстрований для DatabaseInterface::class, створює його за необхідності та повертає готовий об’єкт.

Технічно Factory::getContainer()->get() дозволяє отримати сервіс практично з будь-якого місця. Але постійно звертатися до глобального контейнера всередині бізнес-класів не слід. У власних класах краще явно передавати необхідні залежності через конструктор або setter.

Як працює get()

Основний метод отримання сервісу — get():


$service = $container->get(ServiceInterface::class);

Коли викликається get(), контейнер:

  • знаходить запис за переданим ключем;
  • визначає, чи потрібно створити об’єкт;
  • виконує зареєстрований callback, якщо сервіс ще не створений;
  • повертає отриманий об’єкт;
  • для shared-сервісу зберігає створений екземпляр і надалі повертає його повторно.

Перед отриманням необов’язкового сервісу можна перевірити його наявність:


if ($container->has(MyServiceInterface::class)) {
    $service = $container->get(MyServiceInterface::class);
}
set() і share(): реєстрація сервісів

Метод set() додає ресурс до контейнера. Наприклад, маємо власний сервіс:


namespace Joomla\Component\PavlukShop\Administrator\Service;

final class PriceFormatter
{
    public function format(float $price): string
    {
        return number_format($price, 2, '.', ' ');
    }
}

Його можна зареєструвати за ім’ям класу:


use Joomla\Component\PavlukShop\Administrator\Service\PriceFormatter;
use Joomla\DI\Container;

$container->set(
    PriceFormatter::class,
    static function (Container $container): PriceFormatter {
        return new PriceFormatter();
    }
);

Після цього:


$formatter = $container->get(PriceFormatter::class);

Метод share() використовується, коли контейнер повинен створити об’єкт один раз і надалі повертати той самий екземпляр:


$container->share(
    PriceFormatter::class,
    static function (Container $container): PriceFormatter {
        return new PriceFormatter();
    }
);

Це суттєва різниця: для shared-сервісу повторний виклик get() не повинен створювати новий об’єкт.

Чому сервіси часто реєструють за інтерфейсом

У реальному розширенні краще, щоб код залежав від контракту, а не від конкретного класу.

Наприклад:


namespace Joomla\Component\PavlukShop\Administrator\Service;

interface PriceFormatterInterface
{
    public function format(float $price): string;
}

Реалізація:


namespace Joomla\Component\PavlukShop\Administrator\Service;

final class PriceFormatter implements PriceFormatterInterface
{
    public function format(float $price): string
    {
        return number_format($price, 2, '.', ' ');
    }
}

У контейнері сервіс можна зареєструвати за інтерфейсом:


$container->share(
    PriceFormatterInterface::class,
    static function (Container $container): PriceFormatterInterface {
        return new PriceFormatter();
    }
);

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


final class ProductPriceService
{
    public function __construct(
        private PriceFormatterInterface $formatter
    ) {
    }

    public function getFormattedPrice(float $price): string
    {
        return $this->formatter->format($price);
    }
}

Контейнер Joomla також підтримує aliases — альтернативні ключі для вже зареєстрованого ресурсу:


$container->alias(
    PriceFormatter::class,
    PriceFormatterInterface::class
);

У самому Joomla aliases активно використовуються для зв’язування різних імен сервісів та інтерфейсів з однією реалізацією.

Що таке services/provider.php

Для розширень Joomla основним місцем налаштування DI-контейнера є файл services/provider.php.

Він повертає об’єкт, який реалізує Joomla\DI\ServiceProviderInterface:


<?php

defined('_JEXEC') or die;

use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class implements ServiceProviderInterface
{
    public function register(Container $container): void
    {
        // Register extension services here.
    }
};

Joomla завантажує цей файл і викликає метод register(). Саме тут розширення повідомляє контейнеру, які сервіси воно надає та як їх створювати.

Наприклад, власний сервіс компонента можна зареєструвати так:


<?php

defined('_JEXEC') or die;

use Joomla\Component\PavlukShop\Administrator\Service\PriceFormatter;
use Joomla\Component\PavlukShop\Administrator\Service\PriceFormatterInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class implements ServiceProviderInterface
{
    public function register(Container $container): void
    {
        $container->share(
            PriceFormatterInterface::class,
            static function (Container $container): PriceFormatterInterface {
                return new PriceFormatter();
            }
        );
    }
};
Як передавати залежності у власні класи

Припустимо, сервісу компонента одночасно потрібна база даних і форматувальник ціни:


namespace Joomla\Component\PavlukShop\Administrator\Service;

use Joomla\Database\DatabaseInterface;

final class ProductService
{
    public function __construct(
        private DatabaseInterface $db,
        private PriceFormatterInterface $formatter
    ) {
    }
}

Сам ProductService не звертається до Factory і не створює залежності через new. Усі необхідні об’єкти передаються йому через конструктор.

Їх можна зібрати в provider:


use Joomla\Component\PavlukShop\Administrator\Service\PriceFormatterInterface;
use Joomla\Component\PavlukShop\Administrator\Service\ProductService;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;

$container->share(
    ProductService::class,
    static function (Container $container): ProductService {
        return new ProductService(
            $container->get(DatabaseInterface::class),
            $container->get(PriceFormatterInterface::class)
        );
    }
);

Тут добре видно роль контейнера: він відповідає за створення та зв’язування об’єктів, а ProductService відповідає тільки за свою прикладну логіку.

Саме services/provider.php є нормальним місцем для викликів $container->get(), необхідних для побудови об’єкта. Натомість розміщувати такі виклики безпосередньо всередині кожного бізнес-класу зазвичай немає потреби.

Дочірні контейнери розширень

Архітектура Joomla не обмежується одним глобальним контейнером. Під час завантаження розширення Joomla створює для нього дочірній DI-контейнер.

Це дозволяє розширенню реєструвати власні сервіси окремо від головного контейнера Joomla.

При цьому дочірній контейнер пов’язаний із батьківським:

  • set() реєструє сервіс у контейнері розширення;
  • get() спочатку шукає сервіс у дочірньому контейнері;
  • якщо сервіс там не знайдений, пошук переходить до головного контейнера Joomla.

Завдяки цьому services/provider.php компонента може отримати системний сервіс:


$container->get(DatabaseInterface::class);

навіть якщо DatabaseInterface реєструвався не самим компонентом, а Joomla.

Service Provider у компонентах Joomla

У компонентах services/provider.php використовується не тільки для власних прикладних сервісів. Через нього Joomla реєструє основну Extension-клас компонента та його інфраструктурні залежності.

Типовий provider компонента містить реєстрацію MVC Factory та Component Dispatcher Factory:


use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class implements ServiceProviderInterface
{
    public function register(Container $container): void
    {
        $container->registerServiceProvider(
            new MVCFactory('\\Acme\\Component\\Shop')
        );

        $container->registerServiceProvider(
            new ComponentDispatcherFactory('\\Acme\\Component\\Shop')
        );

        $container->set(
            ComponentInterface::class,
            static function (Container $container): ComponentInterface {
                $component = new MVCComponent(
                    $container->get(ComponentDispatcherFactoryInterface::class)
                );

                $component->setMVCFactory(
                    $container->get(MVCFactoryInterface::class)
                );

                return $component;
            }
        );
    }
};

Метод registerServiceProvider() дозволяє підключити інший Service Provider. Фактично Joomla викликає його метод register(), а той додає необхідні сервіси до контейнера.

Тому файли services/provider.php, які зустрічаються в компонентах, модулях і плагінах Joomla, — це не просто шаблонний код. Вони формують зв’язки між основними об’єктами розширення.

Container і Dependency Injection — не одне й те саме

Важливо розрізняти дві схожі конструкції.

Перший варіант:


final class ProductService
{
    public function execute(): void
    {
        $db = Factory::getContainer()->get(DatabaseInterface::class);
    }
}

Клас сам звертається до контейнера і шукає свою залежність. Це підхід, відомий як Service Locator.

Другий варіант:


final class ProductService
{
    public function __construct(
        private DatabaseInterface $db
    ) {
    }
}

Клас явно оголошує свою залежність, а контейнер використовується зовні для створення ProductService. Це вже Dependency Injection.

Для архітектури власного розширення другий варіант зазвичай кращий: залежності класу видно з конструктора, клас простіше тестувати, а його код не залежить від глобального стану Joomla.

Практичні правила роботи з контейнером
  • Використовуйте services/provider.php для реєстрації сервісів розширення.
  • За можливості залежте від інтерфейсів, а не від конкретних реалізацій.
  • Передавайте залежності у класи через конструктор або передбачені Joomla setter/aware-механізми.
  • Не створюйте вручну системні об’єкти Joomla, якщо для них уже існує сервіс або фабрика.
  • Використовуйте share(), якщо в межах контейнера потрібен один спільний екземпляр сервісу.
  • Не використовуйте контейнер як глобальне сховище довільних даних.
  • Не додавайте Factory::getContainer() у кожен клас тільки тому, що це швидший спосіб отримати залежність.

Не слід вважати, що будь-який клас Joomla автоматично можна отримати через $container->get(SomeClass::class). У контейнері доступні тільки зареєстровані ресурси та їхні aliases. Для частини API Joomla правильним способом отримання об’єкта є спеціальна Factory або API самого Application.

Контейнер Joomla коротко

Dependency Injection Container відповідає не за бізнес-логіку, а за створення та зв’язування об’єктів. Joomla реєструє в ньому системні сервіси, а розширення можуть додавати власні через services/provider.php.

На практиці схема виглядає так:


Interface
    ↓
services/provider.php
    ↓
Container
    ↓
Concrete implementation
    ↓
Constructor injection
    ↓
Your service

Якщо класу потрібна база даних, фабрика, dispatcher або власний сервіс, клас не повинен обов’язково знати, як створити цей об’єкт. Це завдання можна винести до DI-контейнера, а готову залежність передати класу під час його створення.

Використані джерела:
Joomla Programmers Documentation — Dependency Injection
Joomla Programmers Documentation — The Dependency Injection Container
Joomla Programmers Documentation — Extensions and Child Containers
Joomla Programmers Documentation — Plugin Methods and Arguments
Joomla Framework — DI Package
Joomla CMS — official source code

Зв'язатися зі мною

Залиште свої контакти, і я зв'яжуся з вами найближчим часом.