Створення простого контейнера IoC на PHP

pic

IoC Контейнер

Сучасні додатки часто покладаються на контейнери інверсії контролю (IoC) для керування залежностями та сприяння написанню чистого, зручного для підтримки коду. Але чи замислювалися ви коли-небудь, як ці контейнери працюють "під капотом"? У цій статті ми пройдемо через процес створення легковажного IoC контейнера з нуля на PHP.

Інверсія контролю (IoC) — це принцип проектування, що змінює традиційний потік управління в розробці додатків.
Замість того, щоб класи безпосередньо створювали свої залежності, зовнішній об'єкт, як-от контейнер IoC, бере на себе цю відповідальність.

Структура проєкту

Структура каталогу проєкту може виглядати так:
app/
├── contracts/
│ └── ContainerInterface.php
├── services/
│ └── ServiceContainer.php
src/
└── index.php
vendor/

Переконайтесь, що у вас встановлена версія PHP 7.4+ для використання сучасних можливостей, таких як типізовані властивості та об'єднані типи.

Налаштування Composer

Ініціалізуйте ваш проєкт за допомогою Composer для керування автозавантаженням.
Створіть файл composer.json у кореневому каталозі вашого проєкту з наступною конфігурацією:

{  
 "name": "maso/service-container",  
 "description": "Базовий IoC (інверсія контролю) контейнер для керування залежностями додатка",  
 "autoload": {  
 "psr-4": {  
 "App\\": "app/"  
 }  
 },  
 "require": {  
 "php": "^7.4 || ^8.0"  
 }  
}

Генерація файлів автозавантаження

Запустіть наступні команди у вашому терміналі:

composer update  
composer dump-autoload

Реалізація IoC контейнера

Для дотримання найкращих практик та забезпечення гнучкості давайте визначимо інтерфейс для Service Container.

    • @param string $abstract
    • @param callable|string $concrete
    • @return void
      */
      public function bind(string $abstract, object|callable|string $concrete): void;

/**
* Прив'язати сінглтон-сервіс або клас до контейнера.
*
* @param string $abstract
* @param callable|string $concrete
* @return void
*/
public function singleton(string $abstract, object|callable|string $concrete): void;

/**
* Розв'язати сервіс або клас з контейнера.
*
* @param string $abstract
* @return object
*/
public function resolve(string $abstract): object;
}
```

Тепер давайте реалізуємо клас IoC контейнера.

    • @var array
      /
      private array $bindings = [];
      /
      *
    • Масив для зберігання сінглтон-екземплярів.
    • @var array
      */
      private array $singletons = [];

public function bind(string $abstract, object|callable|string $concrete): void
{
$this->bindings[$abstract] = $concrete;
}

public function singleton(string $abstract, object|callable|string $concrete): void
{
$this->singletons[$abstract] = $concrete;
}

public function resolve(string $abstract): object
{

// Відслідковуємо розв'язані сервіси для виявлення циклічних залежностей.
static $resolving = [];

if (isset($resolving[$abstract]))
{
throw new \Exception("Виявлено циклічну залежність: {$abstract}");
}

// Позначаємо поточний клас як такий, що розв'язується.
$resolving[$abstract] = true;

// Перевіряємо, чи є це сінглтоном, і повертаємо вже існуючий екземпляр.

if (isset($this->singletons[$abstract]))
{
$this->singletons[$abstract] = $this->resolveBinding($this->singletons[$abstract]);

unset($resolving[$abstract]);

return $this->singletons[$abstract];
}

// Перевіряємо, чи існує в зв'язках і повертаємо екземпляр.
else if (isset($this->bindings[$abstract]))
{
$instance = $this->resolveBinding($this->bindings[$abstract]);

unset($resolving[$abstract]);

return $instance;
}

// За замовчуванням: намагаємось автоматично створити клас.
$instance = $this->build($abstract);

// Видаляємо поточний клас зі списку класів, які зараз розв'язуються.
unset($resolving[$abstract]);

return $instance;
}

/**
* Автоматично розв'язувати та створювати клас з його залежностями.

    • @param string $classname
    • @return object
    • @throws \Exception
      */
      protected function build(string $classname): object
      {
      if (!class_exists($classname))
      {
      throw new \Exception("Не вдалося розв'язати клас '{$classname}'.");
      }

$reflector = new \ReflectionClass($classname);

// Якщо немає конструктора, створюємо новий екземпляр.
if (!$reflector->getConstructor())
{
return $reflector->newInstance();
}

// Розв'язуємо залежності для конструктора.
$parameters = $reflector->getConstructor()->getParameters();

// Перебираємо параметри конструктора і створюємо екземпляри для них.
$dependencies = array_map(function ($param) use ($classname)
{
$type = $param->getType();

// Перевіряємо, чи є параметр іншим класом і розв'язуємо його.
if ($type && !$type->isBuiltin())
{
return $this->resolve($type->getName());
}

// Перевіряємо, чи має параметр значення за замовчуванням, і повертаємо його.

if ($param->isDefaultValueAvailable())
{
return $param->getDefaultValue();
}

throw new \Exception("Не вдалося розв'язати параметр '{$param->getName()} класу '{$classname}'.");
}, $parameters);

return $reflector->newInstanceArgs($dependencies);
}

/**
* Розв'язати зв'язування до екземпляра.
*
* @param callable|string|object $binding
* @return object
* @throws \Exception
*/
private function resolveBinding(callable|string|object $binding): object
{
if (is_callable($binding))
{
return $binding($this);
}

if (is_object($binding))
{
return $binding;
}

if (is_string($binding))
{
return $this->build($binding);
}

throw new \Exception("Невірне надане зв'язування.");
}
}
```

Метод bind:
Цей метод використовується для реєстрації сервісу або залежності в контейнері в масиві bindings.
Коли ви реєструєте сервіс за допомогою bind, кожного разу при його розв'язуванні створюється новий екземпляр сервісу.

Метод singleton:
Цей метод реєструє сервіс у контейнері таким чином, що створюється лише один екземпляр сервісу, який використовується в усьому застосунку. Якщо сервіс розв'язується кілька разів, кожного разу повертається той самий екземпляр.

Параметри:
$abstract: Сервіс. Це часто є ім'ям класу або інтерфейсом.
$concrete: Реалізація сервісу. Це може бути:

  • Ім'я класу: Контейнер створить екземпляр цього класу при розв'язанні.
  • Екземпляр об'єкта: Контейнер безпосередньо поверне наданий об'єкт.
  • Callable: Closure, що вказує, як створювати сервіс.

Тепер давайте продемонструємо метод resolve:

public function resolve(string $abstract): object  
 {  

 // Відстеження розв'язаних сервісів для виявлення кругових залежностей.
static $resolving = [];  

 if (isset($resolving[$abstract]))  
 {  
 throw new \Exception("Виявлено кругову залежність: {$abstract}");  
 }  

 // Позначити поточний клас як такий, що розв'язується.  
 $resolving[$abstract] = true;  

 // Перевірити, чи це сінглтон і повернути існуючий екземпляр.  
 if (isset($this->singletons[$abstract]))  
 {  
 $this->singletons[$abstract] = $this->resolveBinding($this->singletons[$abstract]);  

 unset($resolving[$abstract]);  

 return $this->singletons[$abstract];  
 }  


 // Перевірити, чи існує це в біндінгах і повернути екземпляр.   
 else if (isset($this->bindings[$abstract]))  
 {  
 $instance = $this->resolveBinding($this->bindings[$abstract]);  

 unset($resolving[$abstract]);  

 return $instance;  
 }  

 // За замовчуванням: спробувати автоматично побудувати клас.  
 $instance = $this->build($abstract);  

 // Видалити поточний клас зі списку класів, що зараз розв'язуються.
unset($resolving[$abstract]);  

 return $instance;  
 }

Метод resolve відповідає за отримання або створення екземплярів сервісів або класів.

Іноді ваш код може зіткнутися з круговою залежністю (circular dependency), ситуацією, коли два або більше класи або компоненти залежать один від одного, утворюючи цикл. Це створює значну проблему, оскільки залежності не можуть бути розв'язані, що призводить до блокування процесу розв'язування залежностей.

Приклад кругової залежності

class A {  
 public function __construct(B $b) {}  
}  

class B {  
 public function __construct(A $a) {}  
}

У цьому прикладі, клас A не може бути інстанційований без класу B, і клас B не може бути інстанційований без класу A. Це призводить до безкінечного циклу під час розв'язування.

Щоб уникнути кругових залежностей, метод resolve включає статичний масив, $resolving, який використовується для моніторингу процесу розв'язування.
До того, як спробувати розв'язати клас, він буде позначений як true у масиві. Якщо система виявить, що клас вже знаходиться в процесі розв'язування, вона викине виняток (exception).
Після того як розв'язування класу буде успішно завершено, він буде знятий з позначки, що забезпечить можливість розв'язування інших залежностей без проблем.

Наступний крок при розв'язуванні класу — це визначення, чи існує він у масивах singletons або bindings:

// Перевіряємо, чи це сінглтон, і повертаємо існуючий екземпляр.  
if (isset($this->singletons[$abstract]))  
 {  
 $this->singletons[$abstract] = $this->resolveBinding($this->singletons[$abstract]);  

 unset($resolving[$abstract]);  

 return $this->singletons[$abstract];  
 }  

 // Перевіряємо, чи існує він у bindings і повертаємо екземпляр.
else if (isset($this->bindings[$abstract]))  
 {  
 $instance = $this->resolveBinding($this->bindings[$abstract]);  

 unset($resolving[$abstract]);  

 return $instance;  
 }

Метод resolveBinding відповідає за розв'язування заданого зв'язку в реальний екземпляр класу. Він обробляє різні типи зв'язків і забезпечує їх правильну ініціалізацію або виконання.

/**  
 * Розв'язати зв'язок в екземпляр.
*  
 * @param callable|string|object $binding  
 * @return object  
 * @throws \Exception  
 */  
 private function resolveBinding(callable|string|object $binding): object  
 {  
 if (is_callable($binding))  
 {  
 return $binding($this);  
 }  

 if (is_object($binding))  
 {  
 return $binding;  
 }  

 if (is_string($binding))  
 {  
 return $this->build($binding);  
 }  


 throw new \Exception("Invalid binding provided.");  
 }

Метод resolveBinding розв'язує заданий зв'язок у екземпляр об'єкта, обробляючи три типи вхідних значень:

  1. Callable: Виконує викликаний елемент з контейнером сервісів як аргумент і повертає результат.
  2. Object: Повертає об'єкт без змін, що підходить для заздалегідь створених сінглтонів.
    3.
    String: Розглядає рядок як назву класу та використовує метод build для створення його екземпляра.

Якщо зв'язок не є callable, object або string, генерується exception, що гарантує обробку лише дійсних зв'язків.

Однак, коли клас, який ми хочемо розв'язати, є типом string або не зареєстрований в контейнері, контейнер намагається автоматично розв'язати клас за допомогою методу build.

/**  
 * Автоматично розв'язує та створює клас з його залежностями.  
 *   
 * @param string $classname   
 * @return object  
 * @throws \Exception  
 */  
 protected function build(string $classname): object  
 {  
 if (!class_exists($classname))  
 {  
 throw new \Exception("Не вдалося розв'язати клас '{$classname}'.");  
 }  

 $reflector = new \ReflectionClass($classname);  

 // Якщо конструктора немає, створюємо новий екземпляр.  
 if (!$reflector->getConstructor())  
 {  
 return $reflector->newInstance();  
 }  

 // Розв'язуємо залежності для конструктора.
$parameters = $reflector->getConstructor()->getParameters();  


 // Перебираємо параметри конструктора і створюємо їх екземпляри.  
 $dependencies = array_map(function ($param) use ($classname)  
 {  
 $type = $param->getType();  

 // Перевіряємо, чи є параметр іншим класом, і розв'язуємо його.  
 if ($type && !$type->isBuiltin())  
 {  
 return $this->resolve($type->getName());  
 }  

 // Перевіряємо, чи має параметр значення за замовчуванням і повертаємо його.
if ($param->isDefaultValueAvailable())  
 {  
 return $param->getDefaultValue();  
 }  

 throw new \Exception("Cannot resolve parameter '{$param->getName()} of the class '{$classname}'.");  
 }, $parameters);  

 return $reflector->newInstanceArgs($dependencies);  
 }

Метод build використовує PHP’s ReflectionClass для аналізу метаданих класу та обробляє ін'єкцію залежностей через конструктор.

Процес відбувається через такі кроки:

if (!class_exists($classname))  
 {  
 throw new \Exception("Cannot resolve class '{$classname}'.");  
 }
  1. Перевірка наявності класу
  • Перш ніж продовжити, перевіряється, чи існує клас за допомогою class_exists.
  • Якщо клас не існує, викидається exception з детальним повідомленням про помилку.
  1. Ініціалізація Reflection для аналізу класу
$reflector = new \ReflectionClass($classname);  

 // Якщо немає конструктора, створюється новий екземпляр.
if (!$reflector->getConstructor())  
 {  
 return $reflector->newInstance();  
 }  

 // Resolve dependencies for the constructor.  
 $parameters = $reflector->getConstructor()->getParameters();

ReflectionClass ініціалізується з ім'ям класу ($classname).

Далі перевіряється, чи є в класу конструктор за допомогою виклику getConstructor():

  • Якщо конструктор відсутній (тобто повертається null), це означає, що клас не потребує вирішення залежностей. У таких випадках метод безпосередньо створює новий екземпляр класу за допомогою newInstance().
  • Якщо клас має конструктор, метод отримує його параметри через getConstructor()->getParameters().

Однак, якщо клас має конструктор, його залежності вирішуються за допомогою ітерації через них за допомогою array_map.

// Перебір параметрів конструктора та створення екземплярів для них.  

$dependencies = array_map(function ($param) use ($classname)  
 {  
 $type = $param->getType();  

 // Check if parameter is another class and resolve it.  
 if ($type && !$type->isBuiltin())  
 {  
 return $this->resolve($type->getName());  
 }  

 // Check if parameter have default value and return it.  

$dependencies = array_map(function ($param) use ($classname)
{
$type = $param->getType();

// Перевірка, чи є параметр іншим класом і його вирішення.
if ($type && !$type->isBuiltin())
{
return $this->resolve($type->getName());
}

// Перевірка, чи має параметр значення за замовчуванням і повернення його.

if ($param->isDefaultValueAvailable())
{
return $param->getDefaultValue();
}

throw new \Exception("Cannot resolve parameter '{$param->getName()} of the class '{$classname}'.");
}, $parameters);
```

Для кожного параметра:

  • Визначення типу параметра:
    Якщо параметр є класом (а не вбудованим типом), він вирішується рекурсивно через виклик методу resolve() з ім'ям класу.
  • Перевірка наявності значень за замовчуванням:
    Якщо параметр має значення за замовчуванням, метод його отримує і використовує.
  • Обробка нездійсненних параметрів:
    Якщо параметр не може бути вирішений і не має значення за замовчуванням, викидається виняток, який вказує на нерозв'язаний параметр і клас, до якого він належить.

Після того, як параметри конструктора відображені на їх залежності, метод продовжує створення екземпляра класу.

Це досягається за допомогою newInstanceArgs(), який інжектує вирішені залежності в конструктор.

return $reflector->newInstanceArgs($dependencies);

Індексний файл

Індексний файл є точкою входу для нашого застосунку.

'/../vendor/autoload.php';

use App\Services\ServiceContainer;

// Ініціалізація контейнера сервісів
$container = new ServiceContainer();

//...

Приклад використання

Уявіть наступні класи з їх відповідними залежностями:

  • Logger — логує повідомлення.
  • Database — використовує Logger для логування виконаних запитів.
  • UserService — використовує Database для взаємодії з базою даних.

Оголошення інтерфейсів

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

// Інтерфейс Logger  
interface LoggerInterface {  
 public function log(string $message): void;  
}  

// Інтерфейс Database  
interface DatabaseInterface {  
 public function query(string $sql): mixed;  
}

Реалізація класів

Надайте конкретні реалізації для визначених інтерфейсів.

// Клас Logger: Реалізує інтерфейс LoggerInterface  
class Logger implements LoggerInterface {  
 public function log(string $message): void {  
 echo "[LOG]: $message\n";  
 }  
}  

// Клас Database: Реалізує інтерфейс DatabaseInterface  
class Database implements DatabaseInterface {  
 private LoggerInterface $logger;  

 public function __construct(LoggerInterface $logger) {  
 $this->logger = $logger;  
 }  

 public function query(string $sql): mixed {  
 $this->logger->log("Executing query: $sql");  
 // Симуляція виконання запиту до бази даних  
 return "Result of $sql";  
 }  
}  

// Клас UserService: Залежить від DatabaseInterface  
class UserService {  
 private DatabaseInterface $database;  

 public function __construct(DatabaseInterface $database) {  
 $this->database = $database;  
 }  

 public function getUser(int $id): mixed {  
 return $this->database->query("SELECT * FROM users WHERE id = $id");  
 }  
}

Розширення індексного файлу

Інтегруйте прив'язки сервісів у контейнер сервісів і вирішіть клас UserService.

// Ініціалізація контейнера сервісів  
$container = new ServiceContainer();  

// Реєстрація прив'язок сервісів  
$container->bind(LoggerInterface::class, function () {  
 return new Logger();  
});  

// Реєстрація Database як синглтона для забезпечення лише однієї інстанції  
$container->singleton(DatabaseInterface::class, function ($container) {  
 return new Database($container->resolve(LoggerInterface::class));  
});  

// Вирішення UserService і використання його  
$userService = $container->resolve(UserService::class);  

// Використання вирішеного сервісу  
echo $userService->getUser(1);

Вивід

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

[LOG]: Executing query: SELECT * FROM users WHERE id = 1  
Result of SELECT * FROM users WHERE id = 1

Висновок

У цій статті ми дослідили можливості та гнучкість контейнера сервісів для керування залежностями.
Розуміння того, як працює контейнер IoC (Inversion of Control), дає цінні уявлення про внутрішню роботу популярних фреймворків, таких як Laravel та Spring.

Для повної реалізації, будь ласка, ознайомтесь з репозиторієм на GitHub:
MajdSoubh/IoC-Container

Перекладено з: Building a Simple IoC Container in PHP

Leave a Reply

Your email address will not be published. Required fields are marked *