Маршрутизация модулей: подходы и реализации

Раздел: Маршрутизация в PHP -> Маршрутизация модулей

Маршрутизация модулей 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;
    }
}

Пояснение:

Этот подход позволяет добавлять маршруты без изменения кода (горячая замена). Однако требует синхронизации кеша и может быть медленнее файловых методов.

Маршрутизация модулей PHP - comments

En
Modules php route (php)