Проектируем фреймворк на PHP: от идеи до реализации
В этой статье рассматривается процесс создания собственного PHP-фреймворка. Основное внимание уделяется архитектурным решениям, которые помогают строить гибкие, поддерживаемые приложения. Приводится несколько подходов, от простого маршрутизатора до полноценного MVC-фреймворка с контейнером зависимостей. Каждый вариант сопровождается вопросом, на который он отвечает, и типичными проблемами.
Подходы к созданию фреймворка
Как организовать минимальную маршрутизацию без сторонних библиотек?
Первый вариант: микрофреймворк на замыканиях. Всё строится вокруг простого роутера, который сопоставляет URI с анонимными функциями. Такой подход подходит для очень маленьких проектов или прототипов, где не нужна сложная логика.
// index.php
$routes = [];
$routes['GET']['/'] = function () { echo 'Главная'; };
$routes['GET']['/user/{id}'] = function ($id) { echo "Пользователь $id"; };
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
foreach ($routes[$method] ?? [] as $pattern => $handler) {
$pattern = preg_replace('/\{([a-z]+)\}/', '(?P<$1>[^/]+)', $pattern);
if (preg_match('#^' . $pattern . '$#', $uri, $matches)) {
$handler(...array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY));
exit;
}
}
http_response_code(404);
echo '404';
Php создание фреймворка (создание фреймворка php)
Проблемы: отсутствие поддержки middleware, нет DI-контейнера, сложно расширять. Ошибка: если не экранировать слэши в регулярном выражении, роутинг сломается. Решение: использовать rawurldecode и экранирование.
Как реализовать обработку запросов через цепочку middleware на основе PSR-15?
Второй вариант: фреймворк с использованием PSR-7 (HTTP messages) и PSR-15 (HTTP handlers). Позволяет строить приложения в виде цепочки middleware.
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class RouterMiddleware implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
// разбор URI, выбор обработчика
$handler = $routes[$request->getMethod()][$path] ?? null;
if (!$handler) {
return new Response(404, [], 'Not Found');
}
return $handler($request);
}
}
// Входная точка
$request = \Laminas\Diactoros\ServerRequestFactory::fromGlobals();
$pipeline = new \Middlewares\Utils\Stack($router, [
new ErrorMiddleware(),
new AuthMiddleware()
]);
$response = $pipeline->handle($request);
(new \Laminas\HttpHandlerRunner\Emitter\SapiEmitter())->emit($response);
Проблема: выбор совместимых пакетов (например, laminas-diactoros для Request/Response). Типичная ошибка: забыть реализовать интерфейс RequestHandlerInterface и получить фатальную ошибку. Решение: всегда проверять сигнатуры методов.
Как построить полноценный MVC-фреймворк с контейнером зависимостей?
Основное и наиболее эффективное решение - создание фреймворка с поддержкой внедрения зависимостей (DI), маршрутизации, контроллеров и шаблонизатора. Это даёт максимальную гибкость и тестируемость.
Шаги реализации:
- Создать файловую структуру: app/Controllers, app/Models, app/Views, core (ядро), public/index.php.
- Реализовать автозагрузку через Composer или собственный spl_autoload_register.
- Создать класс Router, который хранит маршруты и умеет вызывать контроллеры.
- Создать Container с поддержкой autowiring или простого разрешения зависимостей через Reflection.
- Подключить шаблонизатор (например, самописный простой php-шаблонизатор).
- Обработка ошибок и исключений.
Пример простого контейнера:
class Container
{
private array $bindings = [];
private array $instances = [];
public function set(string $abstract, callable $factory): void
{
$this->bindings[$abstract] = $factory;
}
public function get(string $abstract)
{
if (isset($this->instances[$abstract])) {
return $this->instances[$abstract];
}
if (!isset($this->bindings[$abstract])) {
// autowiring через Reflection
$ref = new ReflectionClass($abstract);
$constructor = $ref->getConstructor();
if (!$constructor) {
$this->instances[$abstract] = $ref->newInstance();
return $this->instances[$abstract];
}
$params = [];
foreach ($constructor->getParameters() as $param) {
$type = $param->getType();
if ($type && !$type->isBuiltin()) {
$params[] = $this->get($type->getName());
}
}
$this->instances[$abstract] = $ref->newInstanceArgs($params);
return $this->instances[$abstract];
}
$this->instances[$abstract] = call_user_func($this->bindings[$abstract], $this);
return $this->instances[$abstract];
}
}
Пример использования в контроллере:
class UserController {
private $userRepository;
public function __construct(UserRepository $userRepository) {
$this->userRepository = $userRepository;
}
public function show(int $id) {
$user = $this->userRepository->find($id);
// рендеринг шаблона
}
}
Проблемы и ошибки:
- Циклические зависимости (например, A зависит от B, B от A). Решение: внедрить интерфейсы и использовать абстракции.
- Reflection может быть медленным. Решение: кешировать зависимости (например, в файл при development, в память при production).
- Отсутствие чёткой маршрутизации (например, /user/1 и /user/create конфликтуют). Решение: использовать именованные параметры и порядок маршрутов.
Как сделать фреймворк на основе готовых компонентов (Symfony Components)?
Четвёртый вариант: собрать фреймворк из отдельных библиотек Symfony (HttpKernel, Routing, DependencyInjection). Это компромисс между написанием всего с нуля и использованием монолитного Symfony.
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\HttpFoundation\Request;
$routes = new RouteCollection();
$routes->add('home', new Route('/', ['_controller' => 'App\Controller\HomeController::index']));
$routes->add('user_show', new Route('/user/{id}', ['_controller' => 'App\Controller\UserController::show']));
$request = Request::createFromGlobals();
$context = new RequestContext();
$context->fromRequest($request);
$matcher = new UrlMatcher($routes, $context);
try {
$parameters = $matcher->match($request->getPathInfo());
list($class, $method) = explode('::', $parameters['_controller']);
$controller = new $class();
echo $controller->$method($parameters['id'] ?? null);
} catch (\Symfony\Component\Routing\Exception\ResourceNotFoundException $e) {
echo '404';
}
Проблемы: необходимость загружать много зависимостей, высокая сложность конфигурации. Ошибка: забыть установить пакет symfony/routing. Решение: всегда проверять composer.json.
Расширенные примеры реализации компонентов фреймворка
Реализация маршрутизатора с поддержкой групп и middleware
Ниже показан более продвинутый роутер, который поддерживает группы маршрутов с общим middleware.
class Router
{
private array $routes = [];
private array $groupStack = [];
public function group(array $attributes, callable $callback): void
{
$this->groupStack[] = $attributes;
call_user_func($callback, $this);
array_pop($this->groupStack);
}
public function add(string $method, string $path, callable $handler, array $middleware = []): void
{
$prefix = '';
$groupMiddleware = [];
foreach ($this->groupStack as $group) {
$prefix .= $group['prefix'] ?? '';
$groupMiddleware = array_merge($groupMiddleware, $group['middleware'] ?? []);
}
$fullPath = $prefix . $path;
$this->routes[] = [
'method' => $method,
'path' => $fullPath,
'handler' => $handler,
'middleware' => array_merge($groupMiddleware, $middleware)
];
}
public function dispatch(string $method, string $uri): void
{
foreach ($this->routes as $route) {
if ($route['method'] !== $method) continue;
$pattern = preg_replace('/\{([a-z]+)\}/', '(?P<$1>[^/]+)', $route['path']);
if (preg_match('#^' . $pattern . '$#', $uri, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
// Выполнение middleware
$stack = $route['middleware'];
$handler = $route['handler'];
$next = function ($request) use ($handler, $params) {
return $handler($request, ...$params);
};
while ($middleware = array_pop($stack)) {
$next = function ($request) use ($middleware, $next) {
return $middleware($request, $next);
};
}
$next(['uri' => $uri, 'method' => $method]);
return;
}
}
throw new \RuntimeException('Route not found');
}
}
// Использование
$router = new Router();
$router->group(['prefix' => '/admin', 'middleware' => ['AuthMiddleware']], function ($r) {
$r->add('GET', '/dashboard', function ($req) { echo 'Admin dashboard'; });
});
$router->add('GET', '/', function ($req) { echo 'Home'; });
try {
$router->dispatch('GET', '/admin/dashboard');
} catch (\RuntimeException $e) {
echo '404';
}
Результат: при запросе GET /admin/dashboard будет выполнен AuthMiddleware, затем обработчик. Без авторизации можно выдать перенаправление.
Результат (при отсутствии авторизации): // Допустим, AuthMiddleware перенаправляет на /login // Вывод может быть пустым или 302 редирект
Создание контейнера с поддержкой фабрик и псевдонимов
Расширенная версия DI-контейнера включает регистрацию фабрик, общих экземпляров (singleton) и псевдонимов.
class Container
{
private array $definitions = [];
private array $shared = [];
private array $aliases = [];
public function set(string $id, $definition, bool $shared = false): void
{
if (is_string($definition) && class_exists($definition)) {
$definition = function (Container $c) use ($definition) {
return $c->build($definition);
};
}
$this->definitions[$id] = ['factory' => $definition, 'shared' => $shared];
}
public function alias(string $alias, string $original): void
{
$this->aliases[$alias] = $original;
}
public function get(string $id)
{
$id = $this->aliases[$id] ?? $id;
if (isset($this->shared[$id])) {
return $this->shared[$id];
}
if (!isset($this->definitions[$id])) {
return $this->build($id); // autowire
}
$obj = call_user_func($this->definitions[$id]['factory'], $this);
if ($this->definitions[$id]['shared']) {
$this->shared[$id] = $obj;
}
return $obj;
}
private function build(string $class)
{
$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());
} elseif ($param->isDefaultValueAvailable()) {
$params[] = $param->getDefaultValue();
} else {
throw new \RuntimeException("Cannot resolve parameter \${$param->getName()} for $class");
}
}
return $ref->newInstanceArgs($params);
}
}
// Пример использования
$container = new Container();
$container->set(LoggerInterface::class, function () {
return new FileLogger('/var/log/app.log');
}, shared: true);
$container->alias('logger', LoggerInterface::class);
$service = $container->get('logger'); // получит один экземпляр
Результат: объект FileLogger, при повторном вызове get вернётся тот же экземпляр.
Реализация PSR-7 middleware pipeline
Продемонстрируем собственный pipeline для обработки HTTP запросов через middleware.
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
interface MiddlewareInterface
{
public function process(ServerRequestInterface $request, callable $next): ResponseInterface;
}
class Pipeline
{
private array $middlewares = [];
public function add(MiddlewareInterface $middleware): void
{
$this->middlewares[] = $middleware;
}
public function run(ServerRequestInterface $request, callable $coreHandler): ResponseInterface
{
$stack = array_reverse($this->middlewares);
$next = $coreHandler;
foreach ($stack as $middleware) {
$next = function (ServerRequestInterface $req) use ($middleware, $next) {
return $middleware->process($req, $next);
};
}
return $next($request);
}
}
// Пример middleware
class AuthMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, callable $next): ResponseInterface
{
if (!isset($request->getCookieParams()['token'])) {
return new Response(401, [], 'Unauthorized');
}
return $next($request);
}
}
// Использование
$pipeline = new Pipeline();
$pipeline->add(new AuthMiddleware());
$request = new \Laminas\Diactoros\ServerRequest();
$response = $pipeline->run($request, function ($req) {
return new Response(200, [], 'OK');
});
Результат: если нет cookie token, ответ 401, иначе 200.