Привет, Хабр! Это продолжение истории про Waypoint — PHP-роутер, о создании которого я рассказывал в предыдущей статье. То, что начиналось как эксперимент, похоже, перерастает во что-то большее.
В комментариях к ней развернулась показательная дискуссия. Один из читателей попросил ИИ проанализировать репозиторий и получил ответ: «Это стандартный алгоритм, такой подход используется десятилетиями». Другой заметил, что «и в Symfony, и в FastRoute группировка идёт по trie-like структурам». Справедливо ли это?
Да и нет. Trie — действительно классическая структура данных, описанная в любом учебнике. Хеш-таблица — тем более. Но вот конкретная композиция этих структур, способ их взаимодействия и набор оптимизаций поверх — это то, чего я не нашёл ни в одном существующем PHP-роутере. И это не голословное утверждение — ниже я покажу код и объясню, чем конкретно отличается каждый компонент.
Предыдущая статья была про процесс создания. Эта — про инженерию. Не маркетинг, а алгоритмы и код.
Контекст: как работают популярные роутеры
Прежде чем погружаться в Waypoint, давайте вспомним, что делают «большие» — и разберёмся, в чём именно «trie-like структуры» Symfony отличаются от того, что делает Waypoint.
FastRoute (nikic/fast-route) компилирует маршруты в несколько больших регулярных выражений («chunk regex»). Каждый chunk объединяет десятки маршрутов в одну regex с numbered groups. Даже если URI /about полностью статичен, он проходит через regex-движок. Кеш — var_export() массива.
Symfony Routing строит StaticPrefixCollection — дерево, которое группирует маршруты по общим строковым префиксам (не по сегментам). Это действительно trie-подобная структура, но с принципиальными отличиями: она не разделяет статические и динамические сегменты на уровне узлов, не поддерживает O(1) hash-map lookup по сегменту, а конечный матчинг всё равно выполняется полным regex по всему URI. CompiledUrlMatcher генерирует PHP-файл с каскадом switch/if, кеш — var_export().
Laravel Router — обёртка над Symfony Routing с дополнительным слоем middleware, model binding и прочих фич. Наследует все характеристики Symfony.
Все три подхода работают хорошо. Но у каждого есть слепые зоны, которые становятся заметны при масштабировании. И главное — ни один из них не комбинирует O(1) хеш-таблицу для статических маршрутов, посегментный trie с hash-map на каждом уровне, prefix-grouped fallback и трёхфазный кеш с OPcache shared memory. Именно эта композиция — суть Waypoint.
Архитектура матчинга: три уровня, от O(1) до regex
Waypoint не выбирает одну стратегию. Он каскадирует три, переходя от самой быстрой к более общей:
Запрос: GET /api/health │ ▼ ┌─────────────────────────────┐ │ Tier 0: Static Hash Table │ O(1) │ key = "GET:/api/health" │──── найдено? → готово └─────────────────────────────┘ │ нет ▼ ┌─────────────────────────────┐ │ Tier 1: Prefix-Trie │ O(глубина URI) │ посегментный обход │──── найдено? → готово └─────────────────────────────┘ │ нет ▼ ┌─────────────────────────────┐ │ Tier 2: Fallback │ O(n), но n << N │ prefix-grouped regex │──── найдено? → готово └─────────────────────────────┘ │ нет ▼ 404 / 405
Давайте посмотрим на каждый уровень в деталях.
Tier 0: Хеш-таблица статических маршрутов
Факт, который упускают из виду: в типичном приложении 30–50% маршрутов не содержат параметров. Это /login, /logout, /api/health, /dashboard, страницы со статическими URL. Они не нуждаются ни в trie, ни в regex.
Waypoint хранит такие маршруты в хеш-таблице с ключом "METHOD:/uri":
// RouteCollection::performMatch() $key = $method . ':' . $uri; if (isset($this->staticTable[$key])) { return new RouteMatchResult($this->staticTable[$key], []); }
Одна операция isset() — и маршрут найден. Без обхода дерева, без regex, без аллокации промежуточных структур.
FastRoute пропустит такой маршрут через chunk regex. Symfony обойдёт дерево префиксов. Waypoint сделает lookup за O(1) и вернёт результат.
Tier 1: Prefix-Trie с посегментным матчингом
Для маршрутов с параметрами Waypoint использует классический prefix-trie, но с важной деталью: матчинг идёт посегментно, а не по всему URI сразу.
Каждый узел trie содержит два набора потомков:
final class RouteTrie { /** Статические потомки — O(1) lookup по значению сегмента. */ private array $staticChildren = []; /** Динамические потомки — regex применяется к одному сегменту. */ private array $paramChildren = []; /** Маршруты, завершающиеся в этом узле. */ private array $routes = []; }
Алгоритм обхода при матчинге:
public function match(string $method, array $segments, int $depth, ...): ?array { if ($depth === count($segments)) { // Все сегменты пройдены — проверяем маршруты в этом узле foreach ($this->routes as $route) { if ($route->allowsMethod($method)) { return ['route' => $route, 'params' => $params]; } // Собираем allowed methods для будущего 405 foreach ($route->getMethods() as $m) { $allowedMethods[$m] = true; } } return null; } $segment = $segments[$depth]; // 1. Сначала статический потомок — O(1) hash-map lookup if (isset($this->staticChildren[$segment])) { $result = $this->staticChildren[$segment]->match(...); if ($result !== null) return $result; } // 2. Затем динамические потомки — regex только к одному сегменту foreach ($this->paramChildren as $child) { if (preg_match($child['regex'], $segment)) { $childParams[$child['paramName']] = $segment; $result = $child['node']->match(...); if ($result !== null) return $result; } } return null; }
Ключевые отличия от других реализаций:
Приоритет static над dynamic. Если путь
/users/adminможет совпасть и со статическим сегментомadmin, и с параметром{id}, статический проверяется первым. Это гарантирует предсказуемое поведение без магии порядка регистрации.Backtracking. Если статическая ветвь привела в тупик (совпал сегмент, но дальше маршрут не нашёлся), алгоритм откатывается и пробует динамические потомки. Это корректно обрабатывает случаи, когда статический и динамический маршруты перекрываются на разных глубинах.
Regex применяется к одному сегменту, не к URI. Выражение
\d+проверяется на строке"42", а не на/users/42/posts. Это быстрее и проще для отладки.
Tier 2: Fallback с prefix-группировкой
Не все маршруты помещаются в trie. Паттерн files/{path:.+} (cross-segment capture) или report-{year:\d{4}}.pdf (смешанный статический/динамический сегмент) — это легитимные маршруты, которые не укладываются в модель «один сегмент = один узел».
Waypoint автоматически определяет совместимость:
public static function isCompatible(string $pattern): bool { foreach (explode('/', $path) as $part) { if ($part === '' || !str_contains($part, '{')) { continue; // Статический сегмент — всегда ОК } // Должен быть ровно один параметр на весь сегмент if (!preg_match('#^\{(\w+)(?::([^{}]*))?}$#', $part, $m)) { return false; // Смешанный сегмент — не trie } // Regex параметра не должен матчить '/' if (preg_match($fullRegex, '/') === 1) { return false; // Cross-segment — не trie } } return true; }
Несовместимые маршруты попадают в fallback. Но не в наивный линейный список. Они группируются по первому сегменту URI:
// Маршрут /api/report-{year}.pdf → группа "api" // Маршрут /files/{path:.+} → группа "files" // Маршрут /{lang}/about → группа "*" (catch-all)
При матчинге проверяются только маршруты из нужной группы:
$firstSeg = $segments[0] ?? ''; $candidates = self::mergeFallbackGroups( $this->fallbackRouteMap[$firstSeg] ?? [], // Конкретный префикс $this->fallbackRouteMap['*'] ?? [], // Catch-all );
Merge двух отсортированных списков выполняется за O(|A|+|B|) — классический merge sort step. В результате из 200 fallback-маршрутов проверяются, допустим, 3–5, а не все 200.
Трёхфазное кеширование: от development до zero-allocation production
Большинство PHP-роутеров имеют два режима: «без кеша» и «с кешем». Waypoint вводит три фазы, каждая из которых — отдельный уровень оптимизации.
Phase 1: Development — ленивый runtime trie
В development маршруты регистрируются через API или PHP-атрибуты. Trie строится лениво при первом вызове match(). Reflection используется для анализа контроллеров. Это удобно при разработке — изменил маршрут, обновил страницу, всё работает.
Phase 2: Compiled Data — массивы без объектов
$router->compileTo(__DIR__ . '/cache/routes.php');
Trie сериализуется в plain PHP-массивы. Матчинг идёт напрямую по массивам через RouteTrie::matchArray() — без создания объектного графа RouteTrie:
public static function matchArray( array $trieNode, // Просто массив из кеша array $routeData, // Плоский список данных маршрутов string $method, array $segments, int $depth, array $params, array &$allowedMethods, ): ?array { if ($depth === count($segments)) { foreach ($trieNode['routes'] as $routeIndex) { if (in_array($method, $routeData[$routeIndex]['methods'], true)) { return ['index' => $routeIndex, 'params' => $params]; } } return null; } // ... тот же алгоритм, но по массивам, не по объектам }
Ни один объект RouteTrie не создаётся. Route-объект материализуется только для найденного маршрута.
Phase 3: Compiled Matcher — generated PHP class + OPcache shared memory
Это ключевая оптимизация, которой нет у конкурентов. Waypoint генерирует именованный PHP-класс:
// Auto-generated by Waypoint RouteCompiler. Do not edit. if (!class_exists('WaypointCompiledMatcher_a1b2c3d4e5f6', false)) { class WaypointCompiledMatcher_a1b2c3d4e5f6 implements CompiledMatcherInterface { private const ROUTES = [ 0 => ['h' => ['App\Controller\UserController', 'list'], 'M' => ['GET'], 'p' => '/users'], 1 => ['h' => ['App\Controller\UserController', 'show'], 'M' => ['GET'], 'p' => '/users/{id:\d+}', 'r' => '#^/users/(\d+)$#', 'N' => ['id'], 'a' => [['source' => 'param', 'name' => 'id', 'cast' => 'int']]], // ... ]; private const STATIC_TABLE = [ 'GET:/users' => 0, 'GET:/api/health' => 5, // ... ]; private const TRIE = [ 'static' => ['users' => ['static' => [], 'param' => [...], 'routes' => [0]]], 'param' => [], 'routes' => [], ]; private const NAME_INDEX = ['users.list' => 0, 'users.show' => 1]; private const STATIC_ONLY_URIS = ['/users' => true, '/api/health' => true]; // ...методы матчинга... } } return new WaypointCompiledMatcher_a1b2c3d4e5f6();
Почему это важно?
PHP OPcache хранит private const массивы в shared memory. Это значит:
Данные загружаются один раз при первом запросе worker-процесса.
На каждом последующем запросе ничего не аллоцируется — PHP использует immutable массивы прямо из shared memory.
Guard
class_exists(..., false)гарантирует, что при повторномinclude(тот же worker) определение класса не дублируется — выполняется толькоreturn new ClassName().
Сравним с конкурентами:
FastRoute |
Symfony |
Waypoint |
|
|---|---|---|---|
Формат кеша |
|
|
Именованный класс + |
OPcache |
Массив копируется в каждый запрос |
Массив копируется в каждый запрос |
|
Аллокация при загрузке |
O(размер таблицы) |
O(размер таблицы) |
O(1) |
Разница особенно заметна при большом количестве маршрутов (500+), где размер таблицы маршрутов начинает влиять на потребление памяти.
Pre-computed Argument Resolution Plans: нулевой Reflection в runtime
Большинство фреймворков выполняют Reflection на каждый запрос, чтобы понять, какие аргументы нужны контроллеру. Waypoint делает это один раз — при компиляции кеша — и сохраняет готовый «план»:
// При компиляции (RouteCompiler::buildArgPlan): foreach ($reflection->getParameters() as $param) { $name = $param->getName(); $type = $param->getType(); if (is_a($type->getName(), ServerRequestInterface::class, true)) { $plan[] = ['source' => 'request']; } elseif (isset($routeParamNames[$name])) { $plan[] = ['source' => 'param', 'name' => $name, 'cast' => 'int']; } elseif (!$type->isBuiltin()) { $plan[] = ['source' => 'container', 'class' => $type->getName()]; } elseif ($param->isDefaultValueAvailable()) { $plan[] = ['source' => 'default', 'value' => $param->getDefaultValue()]; } }
Результат попадает в кеш. При диспатче RouteHandler использует план напрямую:
// RouteHandler::resolveFromPlan() — runtime, zero Reflection: foreach ($plan as $entry) { $args[] = match ($entry['source']) { 'request' => $request, 'param' => $this->coercePlanValue( $this->parameters[$entry['name']], $entry['cast'], ), 'container' => $this->container->get($entry['class']), 'default' => $entry['value'] ?? null, }; }
Один match expression, никакого ReflectionMethod, никакого ReflectionParameter::getType(). Для контроллера с 5 параметрами это экономит ~20 вызовов Reflection API на каждый запрос.
Для сравнения: в Symfony ArgumentResolver каждый запрос проходит через цепочку ArgumentValueResolverInterface, каждый из которых может использовать Reflection. В Laravel — аналогично через RouteDependencyResolverTrait.
Early 405: мгновенный ответ без обхода trie
Представьте: 50 статических маршрутов, 200 динамических, 10 fallback. Приходит POST /about, а маршрут /about зарегистрирован только для GET. Стандартный роутер:
Не найдёт
POST:/aboutв статической таблице.Обойдёт trie — пустой результат.
Проверит fallback-маршруты — пустой результат.
Соберёт allowed methods и выбросит 405.
Waypoint делает это иначе. На этапе компиляции он вычисляет набор «purely static» URI — тех, для которых ни один динамический маршрут не может совпасть:
private function computeStaticOnlyUris(...): array { foreach ($staticUris as $uri => $_) { // Проверка 1: на пути по trie нет параметризованных потомков if (!$this->hasNoParamChildrenAlongPath($trieArray, $uri)) { continue; } // Проверка 2: ни один fallback-маршрут не матчит этот URI $matchesFallback = false; foreach ($fallbackIndices as $idx) { if ($allRoutes[$idx]->match($uri) !== null) { $matchesFallback = true; break; } } if (!$matchesFallback) { $result[$uri] = true; } } return $result; }
При матчинге в runtime:
$staticAllowed = $this->compiledMatcher->staticMethods($uri); if ($staticAllowed !== []) { if ($this->compiledMatcher->isStaticOnly($uri)) { // URI "purely static" — 405 мгновенно, без trie и fallback throw new MethodNotAllowedException($staticAllowed, $method, $uri); } // Pre-populate allowed methods для будущего 405 foreach ($staticAllowed as $m) { $allowedMethods[$m] = true; } }
Для purely static URI ответ 405 возвращается за два lookup в const-массивах — без обхода trie и без проверки fallback-маршрутов.
Lazy Route Hydration: материализуй только то, что нужно
При загрузке из кеша Waypoint не создаёт Route-объекты для всех маршрутов. Объект создаётся только когда он действительно нужен:
private function getCachedCompactRoute(int $idx): Route { return $this->routeCache[$idx] ??= Route::fromCompactArray( $this->compiledMatcher->getRoute($idx) ); }
Паттерн ??= (null coalescing assignment) гарантирует, что объект создаётся ровно один раз и кешируется.
Это значит:
На запрос матчинга — 1 Route-объект (найденный маршрут).
На запрос URL generation — 1 Route-объект (именованный маршрут).
Полная гидратация — только при вызове
all()(диагностика, вывод таблицы маршрутов).
Для приложения с 500 маршрутами разница ощутима: 1 объект вместо 500 на каждый запрос.
Автоматическая классификация маршрутов: trie или fallback?
Разработчику не нужно знать, какие маршруты «trie-совместимы», а какие нет. API одинаковый:
// Оба маршрута регистрируются одинаково $router->get('/users/{id:\d+}', [UserController::class, 'show']); // → trie $router->get('/files/{path:.+}', [FileController::class, 'download']); // → fallback $router->get('/report-{year:\d{4}}.pdf', [ReportController::class, 'pdf']); // → fallback
Waypoint автоматически проверяет каждый маршрут через RouteTrie::isCompatible() и направляет его в нужную стратегию. Правила просты:
Каждый сегмент — либо целиком статический, либо целиком параметр.
Regex параметра не матчит
/.Нет смешанных сегментов (
prefix-{name}.txt).
Если хотя бы одно условие нарушено — fallback. Прозрачно, без ошибок, без конфигурации.
Сводная таблица
Особенность |
FastRoute |
Symfony Routing |
Waypoint |
|---|---|---|---|
O(1) для статических маршрутов |
Нет |
Нет |
Да |
Prefix-trie |
Нет |
Частично |
Полноценный |
Fallback с prefix-группировкой |
Нет |
Нет |
Да |
OPcache shared memory ( |
Нет |
Нет |
Да |
Pre-computed argument plans |
Нет |
Нет |
Да |
Early 405 без trie traversal |
Нет |
Нет |
Да |
Lazy route hydration |
Нет |
Частично |
Полная |
Автоклассификация trie/fallback |
N/A |
N/A |
Да |
PSR-15 из коробки |
Нет |
Нет |
Да |
Заключение: стандартные алгоритмы, нестандартная композиция
Вернёмся к критике из комментариев к предыдущей статье. «Это стандартный алгоритм» — и да, и нет.
Да: trie — стандартная структура данных. Хеш-таблица — стандартная. Regex — стандартный. Каждый из этих компонентов по отдельности описан в учебниках и реализован тысячи раз.
Нет: конкретная композиция — три каскадных уровня матчинга с автоматической классификацией маршрутов, трёхфазный кеш с генерацией именованного PHP-класса и immutable const в OPcache shared memory, pre-computed argument resolution plans, early 405 через статический анализ trie на этапе компиляции, lazy hydration с материализацией ровно одного объекта на запрос — этого набора оптимизаций нет ни в FastRoute, ни в Symfony Routing, ни в каком-либо другом PHP-роутере, который мне удалось найти.
Waypoint — это не попытка заменить Symfony или FastRoute. Это исследование вопроса: а что если не выбирать компромисс, а использовать несколько стратегий одновременно?
В совокупности они дают роутер, который:
Тратит O(1) на статические маршруты (а их обычно 30–50%).
Не создаёт лишних объектов при загрузке из кеша.
Не вызывает Reflection при диспатче.
Использует OPcache shared memory вместо per-request аллокаций.
Автоматически выбирает лучшую стратегию для каждого маршрута.
Проект open source, лицензия MIT: github.com/ascetic-soft/Waypoint.
Буду рад вопросам и замечаниям в комментариях. Особенно тем, которые содержат конкретные примеры, где описанный подход не работает или уступает существующим решениям — это самая ценная обратная связь.
SerafimArts
Во-первых, preg_match внутри foreaсh - это крайне медленная и затратная операция. Подобное подходит для того, чтобы сделать "в лоб", просто протестировать решение. Однако на скорости оно сказывается катастрофически, компилируя выражение в MARK-группы (имею ввиду подход с
\G+(*MARK:route_name)для каждого роута) можно получить прирост более чем в 13000% (я не оговорился, именно тысяч). Это выкладки из Hoa issue.Возможно именно по-этому на 10к итерациях мы получаем разницу ~5000% между вашим решением и symfony:
P.S. Нет, не из-за этого. Это из-за "лишней ответственности", а именно из-за вызовов коллбека + миддлварей.
Если сделать полностью статичные роуты (без регулярок, то отличия):
Если закомментить вызов миддлварей, хендлера и вообще весь код, оставив только "match", то:
P.P.S. Я кстати ХЗ почему symfony быстрее fastroute на дефолтных кейсах. Отдебажил, вроде всё ок... Вот говнокод с бенчмарками. Допускаю, что у меня где-то косяк, но не нашёл.
Во-вторых, кажется, у вас архитектура неправильная (т.е. постановка задачи изначальная, как следствие и нарушение SRP + Open Closed). Задачи роутера - это найти нужный путь для самого роута, т.е.:
Мы отправляем реквест и получаем нужный роут для дальнейшей работы.
В вашем же случае Router
Зачем-то зависит от Container, который потом размазывается по всем зависимостям внутри.
Выполняет роль диспатчера (RequestHandlerInterface), хотя это задача не роутера, а как раз хендлера. Да, можно сделать адаптер RouterInterface <-> RequestHandlerInterface, но это более высокий уровень.
Захардкожен на 1 единственный AttributeLoader, т.е. не только не поддерживает несколько лоадров, но ещё и отвечает не только за роутинг, но и за сборку роутов.
Плюс ещё отвечает за кеширование
Помимо этого коллекция роутов почему-то является не коллекцией роутов, а ещё и куском самого роутера: Что там делает
public function match(string $method, string $uri): RouteMatchResult?и т.д... Если требуется более глубокий анализ и больше проблемных мест -- дайте знать. Я лишь поверхностно просмотрел код и оценил архитектуру.
Ну и плюс, как вы уже знаете, из-за вашего подхода в роутере есть баг, когда приоритет роутов меняется, вы указываете:
Но последний, из-за "статики" поднимается наверх и имеет приоритет выше, нежели первый указанный.
В общем, вы безусловно молодец и ваше решение имеет место быть. Все эти оптимизации -- тоже прикольные. Но на практике, кажется, всё довольно плохо.
SerafimArts
P.S. OPCache использует mmap по дефолту, а не Shared Memory ;)
kotafey Автор
Наконец я дождался отличного комментария!
По поводу ответственности - гнался за автовайрингом... Наверное было бы правильно вынести в мидлваре зависимость от контейнера и вообще в отдельный проект.
С остальными замечаниями тоже согласен. В целом архитектурно тут всё плохо. Это я понимал изначально. С другой стороны хотелось собрать как гипотезу - собрал. Работает.
С бенчмарками у меня примерно такая-же ситуация. Симфони и фастроут впереди, хотя тесты сделаны с учётом жц приложения. Основная нагрузка на PSR, хотя немного оптимизировал. Далее падает на fallback. Регулярки в цикле... В общем всё так-же. Либо допиливать, либо выкидывать на помойку.
Тем не менее, среди всех PSR этот самый быстрый. Роутеры, где под капотом фастроут - от PSR замедляются в сотни раз. Не стоило изначально эти стандарты брать... Не зря в симфони от них отказались.
kotafey Автор
PS. В общем, я понял. Сами тесты некорректные. Получается PSR можно тестировать либо только с другими PSR роутерами, либо делать полноценные какие-то приложения. Мы сейчас берем сам матчер (Symfony, FastRoute), и гоняем его по циклу, с другой стороны PSR весь пыхтит как полноценное приложение - делает много работы, которой в принципе нет в FastRoute и Symfony.
Symfony в бенчмарке работает как голый URL-matcher: строка на вход → строка на выход. Waypoint — это полноценный PSR-15 RequestHandler, который прогоняет весь HTTP lifecycle с настоящими PSR-7 объектами. В реальном приложении Symfony тоже создаёт Request/Response через HttpKernel, но это не входит в бенчмарк роутинга.
Вот тут как раз можно сделать рефакторинг архитектуры, вычленить матчер и уже гонять только его. Тогда будет честно.
SerafimArts
Ну да, я когда начал копаться - именно до этого и добрался:
kotafey Автор
Там по говнокоду нельзя было матч вытащить. Пришлось рефачить. Бенчмарк выложил, жду от Вас замечаний, если они еще остались. В целом по результатам я понял, что не зря потратил время. Пациент скорее жив, чем мертв ))
kotafey Автор
Отрефачил сколько смог. Результаты даже лучше чем я ожидал...
https://github.com/ascetic-soft/WaypointBenchmark