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

Это набор классов встроенных в 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+ значительно улучшилась производительность, поэтому при реализации такого функционала не происходит снижения производительности.

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

Поделитесь

Напишите нам. Мы поможем решить вашу задачу