Усиливаем
IT-команды
с 2016 года
Все статьи
Reflection API. Заглянуть внутрь своего кода или как программа может модифицировать собственную структуру.
Bitrix24
Вадим
PHP-разработчик

Это набор классов встроенных в PHP с помощью которых, можно получить все данные о классе, функциях, doc-блоках.

Что можно делать с помощью рефлексии:

1) ORM (Object Relation Mapping)

2) Контейнеры внедрения зависимостей (Dependency Injection)

3) Резолверы контроллеров

И много других инструментов.

Рассмотрим примеры реализации контейнера DI и резолвера контроллеров. Весь представленный код работает на версии PHP 8.1+.

Для автозагрузки классов я буду использовать composer

composer.json
{
    "name": "contract/dependencies",
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    },
    "minimum-stability": "dev",
    "php": ">=8.1.0",
    "require": {
        "psr/container": "2.0.x-dev"
    }
}

Контейнер внедрения зависимостей

Контейнер внедрения зависимостей внедряет в конструктор класса какие-то объекты, которые нужны классу для работы.

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

Этот пример является плохим, потому что у нас нет выбора какой объект передать ему внутрь конструктора, придётся изменять его код.

Пример правильной практики:

public function __construct(
    private readonly Logger $logger
) {}

Такой подход предоставляет нам самим управлять зависимостями для этого класса. Главная особенность DI контейнера: не будет теряться состояние объекта при повторном его использовании, этот шаблон проектирования отлично заменил Синглтон, так как Синглтон является антипаттерном, потому что он нарушает правило SOLID «принцип единственной ответственности», изменяя в одном месте этот объект, он изменяется везде.

Итак, напишем скелет класса Контейнера.

namespace App\Service;
use Psr\Container\ContainerInterface;
class Container implements ContainerInterface
{
    private array $bindings = [];
    private array $cachedDependencies = [];
    public function get(string $id)
    {
        // TODO: Implement get() method.
    }
    public function has(string $id): bool
    {
        return isset($id, $this->cachedDependencies) || isset($id, $this->bindings);
    }
}

Контейнер реализует PSR 11 интерфейс. Метод get должен вернуть какое-то значение, обычно это объект, а has проверить есть ли “Инструкция” как его вернуть.

Создадим дополнительный метод, который запишет по ключу значение:

public function bind(string $id, $bind)
{
    $this->bindings[$id] = $bind;
}

Этот метод наполнит наш контейнер “Знаниями”.

Вся магия будет находится в методе get. Когда будет запрашиваться не описанный класс в bindings, контейнер должен его создать рекурсивно.

public function get(string $id): mixed
{
    if (!$this->has($id) && class_exists($id)) {
        return $this->make($id);
    }
    if (isset($this->cachedDependencies[$id])) {
        return $this->cachedDependencies[$id];
    }
    if (isset($this->bindings[[$id])) {
        $binding = $this->bindings[$id]
    } else {
        throw new ServiceNotRegister($id);
    }
    if ($binding instanceof \Closure) {
        $this->bindings[$id] = $binding($this);
        return $this->bindings[$id];
    }
    return $binding;
}

Здесь используется исключение ServiceNotRegister, которое генерируется если класс не найден.
ServiceNotRegister реализует интерфейс NotFoundExceptionInterface

class ServiceNotRegister extends \Exception implements NotFoundExceptionInterface {}

Появились дополнительные приватные методы make(string $className) и resolveType(\ReflectionParameter $parameter).

private function make(string $className)
{
    $reflection = new \ReflectionClass($className);
    if ($reflection->isInterface()) {
        if (!$this->has($className)) throw new ServiceNotRegister($className);
    }
    $constructor = $reflection->getConstructor();
    if (empty($constructor)) {
        return new $className();
    }
    $parameters = $constructor->getParameters();
    if (empty($parameters)) {
        return new $className();
    }
    $constructor = [];
    foreach ($parameters as $param) {
        $nameType = $this->resolveType($param);
        if ($this->has($nameType)) {
            if ($param->isPassedByReference()) {
                $constructor[] = &$this->bindings[$nameType];
            } else {
                $constructor[] = $this->get($nameType);
            }
        } else {
            if (class_exists(!$nameType)) {
                throw new ResolveException($nameType);
            }
            $constructor[] = $this->make($nameType);
        }
    }
    $object = $reflection->newInstanceArgs($constructor);
    $this->cachedDependencies[$className] = $object;
    return $object;
}
public function resolveType(\ReflectionParameter $parameter)
{
    $type = $parameter->getType();
    $name = $type?->getName();
    if ($name === null || $this->isScalar($name)) {
        $name = $parameter->getName();
    }
    return $name;
}

Как раз здесь используется рефлексия. Она инспектирует конструктор класса и смотрит какие параметры он требует, и создает рекурсивно все объекты, проверяя у каждого класса конструктор.
Получается дерево зависимостей.

Взглянем на метод resolveType. Так как у параметра в конструкторе может и не быть тайп хиттинга или быть скалярное значение. Тогда будет браться название переменной и проверяться есть ли такое значение в контейнере.
Так же, например, в конструктор может передаваться массив по ссылке, проверяем методом
$param->isPassedByReference() и если вернёт true, то передаём таким образом:

$constructor[] = &$this->bindings[$nameType];

Так же с помощью контейнера можно сделать инверсию зависимостей.

Придумаем кейс:
В нашем приложении есть наблюдатель, который логирует какие-то действия пользователя, ошибки и тд. Но логировать Logger может по-разному, сохранять запись в БД или записывать в файл.

Создадим общий интерфейс:

interface Logger
{
    public function log(string $message): void;
}

Сам класс Наблюдатель:

class Observer
{
    public function __construct(
        private readonly Logger $logger
    ) {}
    public function log(string $message)
    {
        $this->logger->log($message);
    }
}

И создадим 2 класса, которые будут реализовывать интерфейс Logger

class LogFile implements Logger
{
    public function log(string $message): void
    {
        file_put_contents("/new.log", $message, FILE_APPEND);
        file_put_contents("/new.log", "\n", FILE_APPEND);
    }
}

class LogDatabase implements Logger
{
    public function log(string $message): void
    {
        Db::getInstance()->query("INSERT INTO `logs` (message) VALUES (:message)", ['message' => $message]);
    }
}

При создании класса Observer Контейнер не сможет создать интерфейс Logger, потому что он не знает как это делать.
Мы должны ему “помочь”.

$container = new \App\Service\Container();
$container->bind(Logger::class, LogFile::class);

Таким образом контейнер поймёт, что нужно создавать объект LogFile, когда запрашивается Logger

Вызов Observer будет выглядеть таким образом:

$observer = $container->get(Observer::class);
$observer->log($message);

И на моменте, когда захотим писать логи в базу, можно спокойно заменить в биндингах на LogDatabase.

$container->bind(Logger::class, LogDatabase::class);

И это не сломает наше приложение!

Резолвер контроллеров

Кейс:
Вы заходите на сайт по url mysite.ru/author/1
Где 1 - ID автора из БД

Регулярное выражение будет выглядеть ~^/author/(\d+)$ И 1 попадает нам в контроллер и наш контроллер выглядит так:

class Controller
{
    public function __invoke(int $authorId)
    {
        $author = Author::find($authorId);
        if ($author === null) {
            throw new \Exception(‘Страница не найдена’, 404);
        }
        …Какой-то код…
    }
}

С помощью резолвера наш контроллер выглядел бы так:

class Controller
{
    public function __invoke(Author $author)
    {
        …Какой-то код…
    }
}

Что изменилось? На этом моменте резолвер сходил в БД и получил пользователя с id = 1.
Резолвер это понял благодаря тайп-хиттинг Author и внедрил в параметр метода. В методе контроллера можно так же попросить Request, Logger и тд. И метод контроллера уже будет выглядеть так:

public function __invoke(Author $author, Request $request, Logger $logger)
{
    …Какой-то код…
}

Благодаря рефлексии и контейнера зависимостей мы можем это реализовать.

Создадим интерфейс, по которому резолвер будет понимать, что нужно сходить в базу за записью.

interface Model
{
    public static function find($id):?self;
}

Модель Author:

class Author implements Model
{
    public static function find(int $id): ?self
    {
        $container = Container::getInstance();
        $db = $container->get(DB::class);
        return $db->query('SELECT * FROM `authors` WHERE id = :id', ['id' => $id], self::class);
    }
}

Так контроллер может ждать скалярный тип string, int и тд. Создадим для контейнера метод isScalar()

Список скалярных значений:

const SKALAR_TYPES = [
    'int', 'string', 'array', 'object', 'float', 'bool'
];

сам метод в контейнере:

public function isScalar(string $type): bool
{
    return in_array($type, self::SKALAR_TYPES);
}

С резолвером создание контроллера выглядит так:

$matches = [1];
$controller = $container->get(Controller::class);
$action = ‘__invloke’;
$paramsAction = $this->resolve($controller, $action, $matches);
$controller->$action(...$paramsAction);

Резолвер должен отдавать в нужном порядке параметры, созданные через контейнер.

Покажу отрывки кода как выглядит резолвер:

public function __construct(
    private array $matches = [],
    private array ContainerInterface $container
) {}

public function resolveAction(object $controller, string $action): array
{
    $reflection = new \ReflectionMethod($controller, $action);
    return $this->getParams($reflection);
}

private function getParams(\ReflectionMethod $reflection): array
{
    $params = $reflection->getParameters();
    $dependencies = [];
    foreach ($params as $param) {
        $type = $param->getType();
        if ($type === null || $this->container->isScalar($id = $type)) {
            $dependencies[] = $this->firstMatch();
            continue;
        }
        $dependencies[] = $this->resolveClass($id);
    }
    return $dependencies;
}

private function resolveClass(string $id)
{
    if (!class_exists($id)) {
        throw new \Exception('class ' . $id . '  not exists', 500);
    }
    if ($id === $this->container::class) {
        return $this->container;
    }
    $reflectionClass = new \ReflectionClass($id);
    $isModel = $reflectionClass->implementsInterface(Model::class);
    if ($isModel) {
        $match = $this->firstMatch();
        /**
         * @var ActiveRecord $id
         */
        $object = $id::findOrFail($match);
    } else {
        $object = $this->container->get($id);
    }
    return $object;
}

Так как контроллер может ждать значение без тайп-хиттинга то его тип $type === null или же это скалярный тип, то резолвер должен достать первое значение из matches:

private function firstMatch()
{
    $match = array_shift($this->matches);
    if ($match === null) {
        throw new \Exception('Not Matches', 500);
    };
    return $match;
}

Через класс \ReflectionMethod можно проинспектировать метод какого-то класса, в нашем случае контроллера, и понять какие он требует параметры.
Так как я определил для моделей общий интерфейс, резолвер посмотрит реализует ли этот класс интерфейс Model в строчке:

$isModel = $reflectionClass->implementsInterface(Model::class);

И если он вернёт true, тогда он достает первое значение из matches (цифра 1), которое передали в контруктор и сходит в базу за автором с id = 1.
В конце он отдаст массив параметров в нужном порядке, который мы передаём в экшен контроллера:

$controller->$action(...$paramsAction);

Таким образом контроллеры могут получать авто внедрение зависимостей в конструктор и в экшен метода благодаря контейнеру внедрения зависимостей и резолверу.

Быстродействие

В версиях PHP 8+ значительно улучшилась производительность, поэтому при реализации такого функционала не происходит снижения производительности.

В современных фреймворках уже давно используются эти инструменты, но, чтобы знать, как работает тот или иной инструмент, нужно научиться его писать.

Другие наши статьи
Создание своей триггерной рассылки битрикс на закрытых событиях
Bitrix: управление сайтом
Вадим
PHP-разработчик
Забудьте про автоматизацию на списках: смарт-процессы в Битрикс24 решают все
Bitrix24
Юлиана
PHP-разработчик
Связаться с нами
Оставьте заявку,
чтобы обсудить условия