Маршрутизация модулей: подходы и реализации
Маршрутизация модулей PHP
В модульной архитектуре PHP каждый модуль (bundle, plugin, component) может иметь собственные маршруты. Основная задача - организовать загрузку, объединение и диспетчеризацию этих маршрутов без дублирования и конфликтов. Ниже рассмотрено несколько вариантов решения, от простого централизованного файла до продвинутой загрузки на основе атрибутов PHP 8.
Основное решение: загрузка маршрутов из конфигурационных файлов модулей
Как организовать маршрутизацию, чтобы каждый модуль сам отвечал за свои пути?
Наиболее эффективный способ - хранить маршруты каждого модуля в отдельном файле (например, routes.php) и объединять их в центральном роутере при инициализации приложения. Это позволяет модулям быть независимыми и легко добавлять/удалять функционал.
// Пример модуля 'admin' – routes.php
return [
'/dashboard' => ['AdminController', 'dashboard'],
'/users' => ['AdminController', 'users'],
'/users/{id}' => ['AdminController', 'userDetail'],
];
Modules php route (маршрутизация модулей php)
// Центральный загрузчик маршрутов
class ModuleRouter {
private array $routes = [];
private array $moduleConfig;
public function __construct(array $moduleConfig) {
$this->moduleConfig = $moduleConfig; // ['admin' => '/path/to/admin', 'api' => '/path/to/api']
}
public function loadRoutes(): void {
foreach ($this->moduleConfig as $moduleName => $modulePath) {
$file = $modulePath . '/routes.php';
if (file_exists($file)) {
$moduleRoutes = require $file;
$prefix = '/' . $moduleName;
foreach ($moduleRoutes as $route => $handler) {
$fullRoute = $prefix . $route;
// Добавляем обработчик с параметрами из URL {id}
$this->routes[$fullRoute] = [
'handler' => $handler,
'pattern' => $this->convertToRegex($fullRoute),
];
}
}
}
}
private function convertToRegex(string $route): string {
// Преобразуем {param} в (?P[^/]+)
return preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $route);
}
public function dispatch(string $uri): ?array {
foreach ($this->routes as $route => $config) {
if (preg_match('#^' . $config['pattern'] . '$#', $uri, $matches)) {
// Извлекаем именованные параметры
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return ['handler' => $config['handler'], 'params' => $params];
}
}
return null;
}
}
// Использование
$router = new ModuleRouter([
'admin' => __DIR__ . '/modules/admin',
'api' => __DIR__ . '/modules/api',
]);
$router->loadRoutes();
$result = $router->dispatch('/admin/users/42');
// $result['handler'] => ['AdminController', 'userDetail']
// $result['params'] => ['id' => '42']
Пояснение каждого шага:
- Модуль возвращает ассоциативный массив маршрутов, где ключ - относительный путь, значение - обработчик.
- Центральный роутер принимает конфигурацию модулей (имя → путь к директории).
- Для каждого модуля подключается файл routes.php, и все маршруты получают префикс /имя_модуля.
- Шаблоны с параметрами преобразуются в регулярные выражения для последующего извлечения значений.
- Метод dispatch пробегает по всем маршрутам и возвращает соответствующий обработчик с параметрами.
Типичные ошибки и проблемы:
- Конфликт маршрутов: если два модуля определяют одинаковый путь (например, /admin/dashboard), последний загруженный перезапишет предыдущий. Решение - добавить проверку уникальности или использовать приоритет модулей.
- Кеширование: при каждом запросе перезагружать все файлы routes.php неэффективно. Рекомендуется кешировать объединённые маршруты в файл или память (например, через Redis).
- Порядок маршрутов: при использовании регулярных выражений важно сначала проверять более конкретные маршруты (с фиксированными частями), чтобы избежать ложных совпадений.
Вариант 1: централизованный файл маршрутов
Как быстро начать, когда проект ещё мал и модулей всего несколько?
Можно хранить все маршруты в одном файле, подключая их из разных модулей через include или через единый массив. Пример:
// app/routes.php – единый файл
require_once 'modules/admin/routes.php';
require_once 'modules/shop/routes.php';
$globalRoutes = array_merge($adminRoutes, $shopRoutes);
Недостатки: при большом количестве модулей файл становится нечитаемым, сложно отключать модули, возможны конфликты имён. Этот вариант подходит для прототипов или проектов с 2-3 модулями.
Проблемы:
- Трудно поддерживать порядок загрузки маршрутов.
- Зависимости между модулями требуют ручной правки файла.
Вариант 2: использование атрибутов PHP 8
Как сделать маршрутизацию декларативной, разместив маршруты прямо в контроллерах?
Создаём атрибут #[Route] и сканируем все классы контроллеров модулей с помощью Reflection. Маршрут извлекается из атрибута и регистрируется в роутере.
// Атрибут
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Route {
public function __construct(public string $path, public string $method = 'GET') {}
}
// Контроллер модуля 'user'
#[Route('/user')]
class UserController {
#[Route('/profile')]
public function profile() { /* ... */ }
#[Route('/settings', method: 'POST')]
public function updateSettings() { /* ... */ }
}
// Сканер и регистратор
class AttributeRouter {
private array $routes = [];
public function registerFromDir(string $dir, string $modulePrefix = ''): void {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') continue;
$className = ...; // Извлекаем FQCN из файла
$refClass = new ReflectionClass($className);
$classRoute = $this->getClassRoute($refClass);
foreach ($refClass->getMethods() as $method) {
$methodRoute = $this->getMethodRoute($method);
if ($methodRoute) {
$fullPath = $modulePrefix . $classRoute . $methodRoute;
$this->routes[] = [
'path' => $fullPath,
'method' => $methodRoute->method,
'handler' => [$className, $method->getName()],
];
}
}
}
}
// ... методы getClassRoute, getMethodRoute извлекают атрибут
}
Цели использования
Подходит для современных проектов с PHP 8+, где важна читаемость кода и автоматическая регистрация маршрутов. Минусы: необходимость сканирования всех файлов (можно кешировать) и возможные проблемы с производительностью при большом количестве контроллеров.
Типичные ошибки:
- Забывание указать атрибут на классе - маршруты методов не будут иметь префикса.
- Несоответствие методов HTTP (например, POST вместо GET) может привести к 405 ошибке.
Вариант 3: маршрутизация на основе PSR-4 и неймспейсов
Как автоматически связать URL с контроллером по имени модуля и неймспейсу?
Можно использовать соглашения: модуль 'admin' → неймспейс App\Admin\Controller, а URL /admin/dashboard → метод dashboard в DashboardController. Пример реализации:
function resolveController(string $uri): ?callable {
// /admin/dashboard/export → модуль 'admin', контроллер 'Dashboard', действие 'export'
$parts = explode('/', trim($uri, '/'));
$module = $parts[0] ?? 'home';
$controllerName = ucfirst($parts[1] ?? 'index') . 'Controller';
$action = $parts[2] ?? 'index';
$class = 'App\\' . ucfirst($module) . '\\Controller\\' . $controllerName;
if (class_exists($class)) {
return [new $class, $action];
}
return null;
}
Такой подход прост для понимания, но негибкий: сложно задавать произвольные пути, обрабатывать параметры в середине URL, поддерживать RESTful маршруты.
Проблемы:
- Невозможность задать кастомный URL для действия.
- Конфликт, если имена модулей совпадают с зарезервированными словами.
Вариант 4: FastRoute в модульном исполнении
Как использовать популярную библиотеку FastRoute для модульной маршрутизации?
FastRoute позволяет собирать маршруты из разных источников. Каждый модуль регистрирует свои маршруты через callback, который передаётся в общий диспетчер.
use FastRoute\RouteCollector;
$routeCollector = new RouteCollector(new FastRoute\RouteParser\Std(), new FastRoute\DataGenerator\GroupCountBased());
// Модуль 'shop'
(function (RouteCollector $r) {
$r->addRoute('GET', '/shop/products', 'ShopController::list');
$r->addRoute('GET', '/shop/product/{id:\d+}', 'ShopController::detail');
})($routeCollector);
// Модуль 'support'
(function (RouteCollector $r) {
$r->addRoute('GET', '/support/tickets', 'SupportController::tickets');
})($routeCollector);
$dispatcher = new FastRoute\Dispatcher\GroupCountBased($routeCollector->getData());
Этот вариант быстрый, поддерживает параметры, методы HTTP. Однако требует внешней зависимости и не предоставляет автоматическую загрузку из модулей - нужно явно вызывать каждый модуль.
Расширенные примеры кода
Пример 1: модульная маршрутизация с кешированием через файл
Используем решение из rbase, но добавляем кеширование объединённых маршрутов в файл, чтобы не перезагружать модули при каждом запросе.
class CachedModuleRouter {
private string $cacheFile;
private array $moduleConfig;
public function __construct(array $moduleConfig, string $cacheFile = 'routes_cache.php') {
$this->moduleConfig = $moduleConfig;
$this->cacheFile = $cacheFile;
}
public function loadRoutes(bool $forceReload = false): array {
if (!$forceReload && file_exists($this->cacheFile)) {
return include $this->cacheFile;
}
$routes = [];
foreach ($this->moduleConfig as $moduleName => $modulePath) {
$file = $modulePath . '/routes.php';
if (file_exists($file)) {
$moduleRoutes = require $file;
$prefix = '/' . $moduleName;
foreach ($moduleRoutes as $route => $handler) {
$fullRoute = $prefix . $route;
$pattern = preg_replace('/\{([a-zA-Z_]+)\}/', '(?P<$1>[^/]+)', $fullRoute);
$routes[] = [
'route' => $fullRoute,
'pattern' => '#^' . $pattern . '$#',
'handler' => $handler,
];
}
}
}
file_put_contents($this->cacheFile, '<?php return ' . var_export($routes, true) . ';');
return $routes;
}
}
// Использование
$router = new CachedModuleRouter(['admin' => __DIR__ . '/modules/admin']);
$routes = $router->loadRoutes(false);
// Содержимое routes_cache.php (пример)
<?php return array (
0 => array (
'route' => '/admin/dashboard',
'pattern' => '#^/admin/dashboard$#',
'handler' => array (0 => 'AdminController', 1 => 'dashboard'),
),
);
Пояснение:
При первом запуске маршруты собираются из всех модулей и сериализуются в PHP-файл. При последующих запросах файл просто подключается (быстро). Метод loadRoutes с параметром true принудительно перезагружает кеш.
Пример 2: атрибуты с группировкой и middleware
Усложнённый вариант атрибута #[Route] с поддержкой middleware и групп.
#[Attribute(Attribute::TARGET_METHOD)]
class Route {
public function __construct(
public string $path,
public string $method = 'GET',
public array $middleware = []
) {}
}
// Контроллер с middleware
class UserController {
#[Route('/profile', middleware: ['auth', 'log'])]
public function profile() { /* ... */ }
}
// Анализатор, собирающий middleware
class AdvancedAttributeRouter {
private array $routes = [];
public function registerClass(string $className, string $modulePrefix): void {
$refClass = new ReflectionClass($className);
foreach ($refClass->getMethods() as $method) {
$attrs = $method->getAttributes(Route::class);
foreach ($attrs as $attr) {
$route = $attr->newInstance();
$fullPath = $modulePrefix . $route->path;
$this->routes[] = [
'path' => $fullPath,
'method' => $route->method,
'handler' => [$className, $method->getName()],
'middleware' => $route->middleware,
];
}
}
}
public function getRoutes(): array { return $this->routes; }
}
// Использование
$router = new AdvancedAttributeRouter();
$router->registerClass('UserController', '/user');
var_dump($router->getRoutes());
array(1) {
[0] =>
array(4) {
'path' => string(9) "/user/profile"
'method' => string(3) "GET"
'handler' => array(2) {
[0] => string(14) "UserController"
[1] => string(7) "profile"
}
'middleware' => array(2) {
[0] => string(4) "auth"
[1] => string(3) "log"
}
}
}
Пояснение:
Теперь каждый маршрут может нести дополнительную информацию (например, список middleware). Это позволяет строить гибкую цепочку обработки без жёсткой привязки к роутеру.
Пример 3: FastRoute с загрузкой маршрутов из YAML-файлов модулей
Используем YAML для описания маршрутов, а FastRoute для диспетчеризации.
// modules/shop/routes.yaml
routes:
- path: /products
method: GET
handler: ShopController::list
- path: /product/{id}
method: GET
handler: ShopController::detail
requirements:
id: '\d+'
// Загрузчик YAML
use Symfony\Component\Yaml\Yaml;
use FastRoute\RouteCollector;
function loadYamlRoutes(string $yamlFile, string $prefix, RouteCollector $collector): void {
$parsed = Yaml::parseFile($yamlFile);
foreach ($parsed['routes'] as $route) {
$fullPath = $prefix . $route['path'];
$collector->addRoute($route['method'], $fullPath, $route['handler']);
}
}
$collector = new RouteCollector(new FastRoute\RouteParser\Std(), new FastRoute\DataGenerator\GroupCountBased());
loadYamlRoutes(__DIR__ . '/modules/shop/routes.yaml', '/shop', $collector);
loadYamlRoutes(__DIR__ . '/modules/admin/routes.yaml', '/admin', $collector);
$dispatcher = new FastRoute\Dispatcher\GroupCountBased($collector->getData());
// После диспетчеризации запроса /shop/product/123
$routeInfo = $dispatcher->dispatch('GET', '/shop/product/123');
// $routeInfo[0] === FastRoute\Dispatcher::FOUND
// $routeInfo[1] => 'ShopController::detail'
// $routeInfo[2] => ['id' => '123']
Пояснение:
YAML-формат удобен для нетехнических специалистов или для интеграции с CI/CD. Каждый модуль содержит свой YAML-файл, который загружается с префиксом модуля. FastRoute обеспечивает быструю диспетчеризацию.
Пример 4: динамическая загрузка модулей из БД или API
Иногда маршруты могут храниться в базе данных (например, для плагинов). Пример загрузки из PDO:
class DatabaseModuleRouter {
private PDO $pdo;
private array $routes = [];
public function loadFromDatabase(): void {
$stmt = $this->pdo->query('SELECT module, route, handler, method FROM routes');
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
$fullRoute = '/' . $row['module'] . $row['route'];
$this->routes[] = [
'pattern' => '#^' . $fullRoute . '$#',
'handler' => $row['handler'],
'method' => $row['method'],
];
}
}
public function dispatch(string $method, string $uri): ?array {
foreach ($this->routes as $route) {
if ($route['method'] === $method && preg_match($route['pattern'], $uri)) {
return $route;
}
}
return null;
}
}
Пояснение:
Этот подход позволяет добавлять маршруты без изменения кода (горячая замена). Однако требует синхронизации кеша и может быть медленнее файловых методов.