Строим свой маленький Dependency Injection Container
Строим свой маленький DIC(Dependency Injection Container)
TLDR
Суть контейнера это некий обьект, который для создания и резолвинга зависимостей использует Reflection API
Проблема управления многими зависимостями
Откуда взялся этот контейнер, этот DI ? Современные программы построены как лего. Большие компоненты состоят из маленьких,те еще из более маленьких. И управлять этой стуктурой неудобно. Тут на сцену выходят DI и DI контейнер. Что такое DI? Посмотрим вики
Внедрение зависимости (англ. Dependency injection, DI) — процесс предоставления внешней зависимости программному компоненту
Что такое DIC? Соответсвенно это контейнер, где лежат эти зависимости, и этот контейнер както помогает управлять зависимостями. Вроде бы все понятно,но возникает куча вопросов Какие зависимости? Для чего? Как происходит это управление?
Небольшой пример, есть у нас магазин с товарами.У этого магазина есть пользователи и этим пользователям в конце месяца приходит статистика их покупок.Т.е. гдето в приложении, у нас будет класс ReportService, который будет генерировать этот репорт, и отправлять по email. Этот класс зависит от 3 классов UserRepository, ProductRepository ,EmailSender т.е. надо написать
class ReportService{
__construct(UserRepository $userRepository, ProductRepository $productRepository, EmailSender $emailSender){
$this->userRepository = $userRepository;
$this->productRepository = $productRepository;
$this->emailSender = $emailSender;
}
}
$reportService = new ReportService(
new UserRepository(),
new ProductRepository(),
new EmailSender()));
$reportService->send($userId);
Как видим код создания ReportService достаточно громоздкий и неудобный. Вручную с этим разбираться сложно, какие куда зависимости, что в каком порядке передавать. Пусть за нас это сделает некий обьект.
$report = $container->make(ReportService::class)
// создаст экземпляр ReportService и подставит зависимости
Также было бы неплохо дать возможность регистрировать классы в контейнере, для этого нам нужен метод register
$container->register(ReportService::class);
Container
Весь код приведенный в статье можно скачать с github tinydi
git clone git@github.com:R11baka/tinydi.git
cd tinydi;composer install
git checkout first_iteration // переключаемся на тег
Слона надо перевозить в лифте по частям. Поэтому начнем с чегото очень простого, а именно с создания класса без аргументов.В этом нам помогут юнит тесты.Исходный код тестов можно увидеть ContainerTest.Поэтому, для начала напишем тест
class ContainerTest extends TestCase
{
/**
* @test
*/
public function register_simple_class()
{
$container = new Container();
$container->register(FooController::class);
$controller = $container->make(FooController::class);
$this->assertInstanceOf(FooController::class, $controller);
}
}
Тест конечно же работать не будет, поэтому создаем класс Container, добавляем в него методы register
и make
А вот и код этого контейнера
class Container
{
private array $services = [];
public function register($className)
{
$this->services[$className] = $className;
}
public function make($className)
{
if (isset($this->services[$className])) {
return new $className();
}
throw new \InvalidArgumentException("$className not found");
}
}
Запускаем unit тесты
./vendor/bin/phpunit
OK (2 tests, 2 assertions)
и видим тесты пройдены, класс создан Пока, что абсолютно ничего сложного и абсолютно бесполезно, так как метод make, может создавать только простые классы, без аргументов.Надо както его научить создавать классы с аргументами.И поможет нам в этом Reflection API
Reflection API
Для начала научимся создавать классы без аргументов с помощью Reflection, а потом попробуем создать класс с типизированными аргументами.
Конструктор без аргументов
В Reflection API, будем использовать
- ReflectionClass дающий информацию о классе
- Метод isInstantiable класса ReflectionClass определяющий можно ли создать класс.
- Метод getConstructor ReflectionClass возвращающий конструктор класса.
- Метод getNumberOfParameters возвращающий количество параметров функции
Пример использования reflection api на php sandbox
class Controller{};
class ComplexController{
private $config;
public function __construct($config){
$this->config = $config;
}
}
$simpleControllerReflect = new \ReflectionClass(Controller::class);
var_dump("Simple controller constructor =>",$simpleControllerReflect->getConstructor());
/*
string(32) "Simple controller constructot =>"
NULL
*/
$complexControllerReflect = new \ReflectionClass(ComplexController::class);
var_dump("Complex controller constructor =>",$complexControllerReflect->getConstructor());
/*
string(33) "Complex controller constructor =>"
object(ReflectionMethod)#3 (2) {
["name"]=>
string(11) "__construct"
["class"]=>
string(17) "ComplexController"
}
*/
Исходный код можно глянуть здесь
git checkout base_reflection_api
Обновленная версия контейнера.
<?php
namespace App;
class Container
{
private array $services = [];
public function register($itemName)
{
$this->services[$itemName] = $itemName;
}
public function make($itemName)
{
if (isset($this->services[$itemName])) {
$className = $this->services[$itemName];
return $this->resolveClass($className);
}
throw new \InvalidArgumentException("Item with $itemName not found");
}
private function resolveClass($className)
{
$reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isInstantiable() === false) {
throw new \InvalidArgumentException("Can't create $className.Not instantiable");
}
$constructorReflection = $reflectionClass->getConstructor();
if ($constructorReflection === null) {
return new $className;
}
if ($constructorReflection->getNumberOfParameters() === 0) {
return new $className;
}
throw new \InvalidArgumentException("Can't create $className");
}
}
Добавился метод resolveClass, который получает на вход имя класса, и пытается его создать. Из интересного есть метод
$constructorReflection = $reflectionClass->getConstructor();
если $constructor null или у него нет параметров, то можем спокойно создать через new. В тестах появился кейс конструктора без аргументов.
/**
* @test
*/
public function make_class_with_zero_arguments()
{
$this->container->register(FooControllerEmptyConstructor::class);
$controller = $this->container->make(FooControllerEmptyConstructor::class);
$this->assertInstanceOf(FooControllerEmptyConstructor::class, $controller);
}
Конструктор с аргументами
С созданием класса без аргументов разобрались, по сути мы вызываем new $className и все.Теперь надо понять как создать класс с аргументами. Для этого нам поможет
- ReflectionMethod Дающий инфо о функции в классе
- Метод getParameters дающий информацию о аргументах функции
- Метод класса ReflectionClass newInstanceArgs
- Метод getClass
Исходный код лежит здесь
git checkout reflection_api
Пишем сначала простой тест, который естественно ломается
/**
* @test
*/
public function make_class_with_arguments()
{
$this->container->register(ClassWithArgumentsInConstructor::class);
$controller = $this->container->make(ClassWithArgumentsInConstructor::class);
$this->assertInstanceOf(ClassWithArgumentsInConstructor::class, $controller);
}
/**
* @test
*/
public function make_class_with_untyped_args()
{
$this->expectException(InvalidArgumentException::class);
$this->container->register(ClassWithUntypedArgs::class);
$this->container->make(ClassWithUntypedArgs::class);
}
ClassWithArgumentsInConstructor выглядит так
class ClassWithArgumentsInConstructor
{
private FooController $controller;
/**
* ClassWithArgumentsInConstructor constructor.
*/
public function __construct(FooController $controller)
{
$this->controller = $controller;
}
}
А потом и обновленный код resolveClass
private function resolveClass($className)
{
$reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isInstantiable() === false) {
throw new \InvalidArgumentException("Can't create $className.Not instantiable");
}
$constructorReflection = $reflectionClass->getConstructor();
if ($constructorReflection === null) {
return new $className;
}
if ($constructorReflection->getNumberOfParameters() === 0) {
return new $className;
}
$params = $constructorReflection->getParameters();
$constructorArgs = [];
foreach ($params as $v) {
$paramClass = $v->getClass();
if ($paramClass !== null) {
$this->register($paramClass->name);
$constructorArgs [] = $this->make($paramClass->name);
} else {
throw new \InvalidArgumentException("Can't resolve parameter for $className");
}
}
return $reflectionClass->newInstanceArgs($constructorArgs);
}
Здесь мы получаем аргументы конструктора, извлекаем подсказу типа с помощью getClass, регистрируем в контейнере и рекурсивно создаем.
Добавление интерфейсов в DI контейнер
Исходный код лежит здесь
git checkout 4_add_impl
Теперь вернемся к классу ReportService вначале.Этот класс зависит от конкретных реализаций, не от абстракций. Поэтому хотелось чтоб DI контейнер, както понимал,что если от него хотят например EmailSenderInterface, то он должен подсунуть экземпляр класса EmailSender.И класс будет выглядеть так
class ReportService{
__construct(UserRepositoryInterface $userRepository,ProductRepositoryInterface $productRepository,EmailSenderInterface $emailSender){
$this->userRepository = userRepository;
$this->productRepository = productRepository;
$this->emailSender = $emailSender;
}
}
Чтобы это реализовать, нам надо внести небольшие изменения в метод register, он принимал вторым параметром реализацию класса.
public function register_interface()
{
$this->container->register(TestInterface::class, Test::class);
$instanceOfTest = $this->container->make(TestInterface::class);
$this->assertInstanceOf(Test::class, $instanceOfTest);
}
и модифицированный метод register
public function register($abstraction, $implementation = null)
{
if ($implementation === null) {
$implementation = $abstraction;
}
$this->services[$abstraction] = $implementation;
}
Наконец-то ReportService
Исходный код лежит здесь
git checkout 6_register_report_service
Теперь попробуем написать тест с созданием класса ReportService
/**
* @test
*/
public function test_report_service()
{
$this->container->register(ReportService::class);
$class = $this->container->make(ReportService::class);
$this->assertInstanceOf(ReportService::class, $class);
}
Запускаем и тест падает с
InvalidArgumentException : Can't create App\Tests\TestClasses\UserRepositoryInterface.Not instantiable
Не зарегистрировали в контейнере соответствие между интерфейсом и реализацией Обновляем тесты
/**
* @test
*/
public function test_report_service()
{
$this->container->register(ReportService::class);
$this->container->register(UserRepositoryInterface::class, UserRepository::class);
$this->container->register(ProductRepositoryInterface::class, ProductRepository::class);
$this->container->register(EmailSenderInterface::class, EmailSender::class);
$class = $this->container->make(ReportService::class);
$this->assertInstanceOf(ReportService::class, $class);
}
Запускаем phpunit,опять падает c той-же ошибкой.
InvalidArgumentException : Can't create App\Tests\TestClasses\UserRepositoryInterface.Not instantiable
Метод register перетирает соответсвие между абстракцией и реализацией. Добавляем метод registerIfNotExists. Запускаем тесты и все проходит. В результате получилось написать маленький DI контейнер. Еще можно добавить кеширование,строковые параметры в констукторы, поддержку замыканий.