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