Практическое построение фреймворка на PHP: от идеи до реализации

Раздел: Разработка веб-приложений на PHP -> PHP фреймворки: обзор и разработка

Основные компоненты PHP фреймворка

Разработка собственного фреймворка начинается с выбора архитектуры. Ниже рассмотрены ключевые части: маршрутизация, контейнер внедрения зависимостей, шаблонизатор и обработка ошибок. Для каждой части приводится основное решение (класс rbase) и альтернативные варианты (класс rvar).

Как организовать маршрутизацию запросов?

Вариант 1: простой роутер на операторе switch. Подходит для очень маленьких проектов.

$uri = $_SERVER['REQUEST_URI'];
switch (true) {
    case $uri === '/':
        $controller = new HomeController();
        break;
    case preg_match('#^/user/(\d+)$#', $uri, $matches):
        $controller = new UserController($matches[1]);
        break;
    default:
        http_response_code(404);
        exit;
}

разработка php фреймворка (разработка php фреймворка)

Проблема: при увеличении числа маршрутов код становится нечитаемым. Ошибка: пропущен break или забыт return. Решение: использовать класс Router.

Вариант 2: роутер на основе массива правил с регулярными выражениями.

class Router {
    private array $routes = [];

    public function add(string $pattern, callable $handler): void {
        $this->routes[$pattern] = $handler;
    }

    public function dispatch(string $uri): void {
        foreach ($this->routes as $pattern => $handler) {
            if (preg_match($pattern, $uri, $params)) {
                $handler($params);
                return;
            }
        }
        throw new \RuntimeException('Route not found');
    }
}
Проблема: производительность при большом количестве маршрутов (линейный поиск). Решение: компилировать маршруты в дерево или использовать библиотеку fast-route.

Основное решение: композиция роутера с контейнером внедрения зависимостей. Роутер хранит маршруты, а после сопоставления извлекает контроллер из контейнера.

class Router {
    private array $routes = [];

    public function add(string $method, string $pattern, string $controllerClass, string $action): void {
        $this->routes[] = compact('method', 'pattern', 'controllerClass', 'action');
    }

    public function resolve(Container $container): void {
        $method = $_SERVER['REQUEST_METHOD'];
        $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        foreach ($this->routes as $route) {
            if ($route['method'] !== $method) continue;
            if (preg_match($route['pattern'], $uri, $params)) {
                $controller = $container->get($route['controllerClass']);
                $controller->{$route['action']}(...array_slice($params, 1));
                return;
            }
        }
        throw new \RuntimeException('Not Found', 404);
    }
}
Проблема: параметры из URI могут быть не только числами. Решение: добавить валидацию типов в роутер или использовать библиотеку symfony/routing.

Как реализовать контейнер внедрения зависимостей?

Вариант 1: ручное создание объектов. Простой, но нарушает принцип инверсии зависимостей.

$db = new PDO('sqlite:db.sqlite');
$repository = new UserRepository($db);
$controller = new UserController($repository);

Вариант 2: простой контейнер на ассоциативном массиве. Не поддерживает автозаполнение.

$container = [
    'db' => new PDO('sqlite:db.sqlite'),
    'repository' => function () use ($container) {
        return new UserRepository($container['db']);
    },
];
$controller = $container['repository']();
Проблема: нужно вручную прописывать все зависимости. Решение: использовать рефлексию для автоматического разрешения.

Основное решение: PSR-11 контейнер с поддержкой автозаполнения через Reflection.

class Container {
    private array $definitions = [];
    private array $instances = [];

    public function set(string $id, callable $factory): void {
        $this->definitions[$id] = $factory;
    }

    public function get(string $id): object {
        if (!isset($this->instances[$id])) {
            $this->instances[$id] = $this->definitions[$id]($this);
        }
        return $this->instances[$id];
    }

    public function autowire(string $class): object {
        $ref = new ReflectionClass($class);
        $constructor = $ref->getConstructor();
        if (!$constructor) {
            return $ref->newInstance();
        }
        $params = [];
        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();
            if ($type && !$type->isBuiltin()) {
                $params[] = $this->get($type->getName());
            } else {
                // можно задавать значения по умолчанию
                $params[] = $param->getDefaultValue();
            }
        }
        return $ref->newInstanceArgs($params);
    }
}
Проблема: циклические зависимости приводят к бесконечной рекурсии. Решение: отслеживать разрешаемые сейчас зависимости и выбрасывать исключение.

Как работать с шаблонами?

Вариант 1: вывод HTML прямо внутри PHP (спагетти-код).

$name = 'World';
echo '<!DOCTYPE html><html><head><title>Hello</title></head><body><h1>Hello ' . htmlspecialchars($name) . '</h1></body></html>';

Вариант 2: простой шаблонизатор с буферизацией вывода (отдельные файлы .phtml).

function render(string $template, array $data): string {
    extract($data);
    ob_start();
    include __DIR__ . '/templates/' . $template . '.phtml';
    return ob_get_clean();
}
echo render('home', ['name' => 'World']);
Проблема: нет экранирования переменных по умолчанию, легко допустить XSS. Решение: обернуть все переменные в функцию escape.

Основное решение: использование готового шаблонизатора с экранированием и наследованием, например Twig.

use Twig\Environment;
use Twig\Loader\FilesystemLoader;

$loader = new FilesystemLoader(__DIR__ . '/templates');
$twig = new Environment($loader, ['cache' => __DIR__ . '/cache']);
echo $twig->render('home.twig', ['name' => 'World']);
Проблема: шаблонизатор может быть избыточен для простых проектов. Решение: выбирать по размеру проекта.

Как обрабатывать ошибки?

Вариант 1: использование стандартных функций set_error_handler и set_exception_handler.

set_exception_handler(function (Throwable $e) {
    http_response_code(500);
    echo '<h1>500 Internal Server Error</h1><p>' . htmlspecialchars($e->getMessage()) . '</p>';
});
Проблема: нет логирования, сообщение об ошибке выводится пользователю. Решение: передавать ошибку в лог и показывать общее сообщение.

Основное решение: создание специализированного класса ErrorHandler, который логирует ошибку и возвращает JSON или HTML с кодом ответа.

class ErrorHandler {
    private $logger;

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

    public function handle(Throwable $e): void {
        ($this->logger)($e);
        http_response_code(500);
        header('Content-Type: application/json');
        echo json_encode(['error' => 'Internal server error']);
    }
}

// использование
$handler = new ErrorHandler(fn($e) => error_log((string)$e));
set_exception_handler([$handler, 'handle']);
Проблема: не обрабатываются фатальные ошибки (типа E_ERROR). Решение: зарегистрировать shutdown-функцию для перехвата последней ошибки.

Расширенные примеры реализации компонентов фреймворка

Полноценный роутер с поддержкой middleware

Роутер может выполнять цепочку middleware перед вызовом контроллера. Пример основан на PSR-15.

Пример
interface MiddlewareInterface {
    public function handle(callable $next): void;
}

class Router {
    private array $routes = [];
    private array $middlewares = [];

    public function addMiddleware(MiddlewareInterface $middleware): void {
        $this->middlewares[] = $middleware;
    }

    public function add(string $method, string $pattern, callable $handler): void {
        $this->routes[] = compact('method', 'pattern', 'handler');
    }

    public function run(): void {
        $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        $method = $_SERVER['REQUEST_METHOD'];
        foreach ($this->routes as $route) {
            if ($route['method'] !== $method) continue;
            if (preg_match($route['pattern'], $uri, $params)) {
                $core = function () use ($route, $params) {
                    ($route['handler'])($params);
                };
                // оборачиваем middleware снаружи внутрь
                $pipeline = array_reduce(
                    array_reverse($this->middlewares),
                    function ($next, MiddlewareInterface $mw) {
                        return function () use ($mw, $next) {
                            $mw->handle($next);
                        };
                    },
                    $core
                );
                $pipeline();
                return;
            }
        }
        http_response_code(404);
    }
}

// Пример middleware для логирования
class LoggingMiddleware implements MiddlewareInterface {
    public function handle(callable $next): void {
        error_log('Request: ' . $_SERVER['REQUEST_URI']);
        $next();
    }
}

Контейнер с поддержкой PSR-11 и автозаполнением

Реализация, соответствующая интерфейсу Psr\Container\ContainerInterface, с автоматическим разрешением зависимостей через Reflection и обработкой циклических ссылок.

Пример
use Psr\Container\ContainerInterface;

class Container implements ContainerInterface {
    private array $definitions = [];
    private array $instances = [];
    private array $resolving = []; // для обнаружения циклов

    public function set(string $id, callable $factory): void {
        $this->definitions[$id] = $factory;
    }

    public function get(string $id): object {
        if (isset($this->resolving[$id])) {
            throw new \RuntimeException("Ciclic dependency for $id");
        }
        if (!isset($this->instances[$id])) {
            $this->resolving[$id] = true;
            if (isset($this->definitions[$id])) {
                $this->instances[$id] = $this->definitions[$id]($this);
            } else {
                $this->instances[$id] = $this->autowire($id);
            }
            unset($this->resolving[$id]);
        }
        return $this->instances[$id];
    }

    public function has(string $id): bool {
        return isset($this->definitions[$id]) || class_exists($id);
    }

    private function autowire(string $class): object {
        $ref = new ReflectionClass($class);
        if (!$ref->isInstantiable()) {
            throw new \InvalidArgumentException("Class $class is not instantiable");
        }
        $constructor = $ref->getConstructor();
        if (!$constructor) {
            return $ref->newInstance();
        }
        $args = [];
        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();
            if ($type && !$type->isBuiltin()) {
                $args[] = $this->get($type->getName());
            } elseif ($param->isDefaultValueAvailable()) {
                $args[] = $param->getDefaultValue();
            } else {
                throw new \RuntimeException("Cannot resolve parameter \${$param->getName()} for class $class");
            }
        }
        return $ref->newInstanceArgs($args);
    }
}

Шаблонизатор с наследованием (простая реализация)

Минималистичная система шаблонов, поддерживающая блоки и extends, вдохновлённая Smart или Twig.

Пример
class Template {
    private string $basePath;

    public function __construct(string $basePath) {
        $this->basePath = $basePath;
    }

    public function render(string $template, array $data = []): string {
        extract($data);
        ob_start();
        include $this->basePath . '/' . $template . '.php';
        return ob_get_clean();
    }

    // Пример использования в шаблоне:
    // {% extends 'layout' %}
    // {% block content %}...{% endblock %}
}

// Функция-помощник для парсинга extends и block (упрощённо)
function parseTemplate(string $content, Template $engine): string {
    // можно реализовать замену с помощью регулярных выражений
    return $content;
}

Результат работы шаблонизатора с наследованием (входной шаблон):

Базовая структура применена: блок content заменён на переданный контент.

Обработчик ошибок с поддержкой разных форматов

Класс, который определяет формат ответа в зависимости от заголовка Accept.

Пример
class HttpErrorHandler {
    public function __invoke(Throwable $e): void {
        $statusCode = ($e instanceof \InvalidArgumentException) ? 400 : 500;
        http_response_code($statusCode);

        $accept = $_SERVER['HTTP_ACCEPT'] ?? 'text/html';
        if (strpos($accept, 'application/json') !== false) {
            header('Content-Type: application/json');
            echo json_encode([
                'error' => $e->getMessage(),
                'code' => $statusCode,
            ]);
        } else {
            header('Content-Type: text/html; charset=utf-8');
            echo sprintf(
                '<h1>Ошибка %d</h1><p>%s</p>',
                $statusCode,
                htmlspecialchars($e->getMessage())
            );
        }
    }
}

// Регистрация
set_exception_handler(new HttpErrorHandler());

Разработка PHP фреймворка - comments

En
разработка php фреймворка (php)