Skip to content

Commit 83f1dac

Browse files
feat: optimize routing (#626)
Co-authored-by: homersimpsons <[email protected]>
1 parent 6fc2d95 commit 83f1dac

20 files changed

+874
-138
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"jenssegers/blade": "^2.0",
4646
"nyholm/psr7": "^1.8",
4747
"phpat/phpat": "^0.10.14",
48+
"phpbench/phpbench": "84.x-dev",
4849
"phpstan/phpstan": "^1.10.0",
4950
"phpunit/phpunit": "^11.3.5",
5051
"rector/rector": "^1.2",

phpbench.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"$schema":"./vendor/phpbench/phpbench/phpbench.schema.json",
3+
"runner.bootstrap": "vendor/autoload.php"
4+
}

src/Tempest/Http/src/GenericRouter.php

Lines changed: 3 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
use Tempest\Http\Responses\Invalid;
1717
use Tempest\Http\Responses\NotFound;
1818
use Tempest\Http\Responses\Ok;
19+
use Tempest\Http\Routing\Matching\RouteMatcher;
1920
use function Tempest\map;
2021
use Tempest\Reflection\ClassReflector;
21-
use function Tempest\Support\str;
2222
use Tempest\Validation\Exceptions\ValidationException;
2323
use Tempest\View\View;
2424

@@ -27,14 +27,12 @@
2727
*/
2828
final class GenericRouter implements Router
2929
{
30-
public const string REGEX_MARK_TOKEN = 'MARK';
31-
3230
/** @var class-string<MiddlewareClass>[] */
3331
private array $middleware = [];
3432

3533
public function __construct(
3634
private readonly Container $container,
37-
private readonly RouteConfig $routeConfig,
35+
private readonly RouteMatcher $routeMatcher,
3836
private readonly AppConfig $appConfig,
3937
) {
4038
}
@@ -45,7 +43,7 @@ public function dispatch(Request|PsrRequest $request): Response
4543
$request = map($request)->with(RequestToPsrRequestMapper::class);
4644
}
4745

48-
$matchedRoute = $this->matchRoute($request);
46+
$matchedRoute = $this->routeMatcher->match($request);
4947

5048
if ($matchedRoute === null) {
5149
return new NotFound();
@@ -76,17 +74,6 @@ public function dispatch(Request|PsrRequest $request): Response
7674
return $response;
7775
}
7876

79-
private function matchRoute(PsrRequest $request): ?MatchedRoute
80-
{
81-
// Try to match routes without any parameters
82-
if (($staticRoute = $this->matchStaticRoute($request)) !== null) {
83-
return $staticRoute;
84-
}
85-
86-
// match dynamic routes
87-
return $this->matchDynamicRoute($request);
88-
}
89-
9077
private function getCallable(MatchedRoute $matchedRoute): Closure
9178
{
9279
$route = $matchedRoute->route;
@@ -167,39 +154,6 @@ public function toUri(array|string $action, ...$params): string
167154
return $uri;
168155
}
169156

170-
private function resolveParams(Route $route, string $uri): ?array
171-
{
172-
if ($route->uri === $uri) {
173-
return [];
174-
}
175-
176-
$tokens = str($route->uri)->matchAll('#\{'. Route::ROUTE_PARAM_NAME_REGEX . Route::ROUTE_PARAM_CUSTOM_REGEX .'\}#', );
177-
178-
if (empty($tokens)) {
179-
return null;
180-
}
181-
182-
$tokens = $tokens[1];
183-
184-
$matches = str($uri)->matchAll("#^$route->matchingRegex$#");
185-
186-
if (empty($matches)) {
187-
return null;
188-
}
189-
190-
unset($matches[0]);
191-
192-
$matches = array_values($matches);
193-
194-
$valueMap = [];
195-
196-
foreach ($matches as $i => $match) {
197-
$valueMap[trim($tokens[$i], '{}')] = $match[0];
198-
}
199-
200-
return $valueMap;
201-
}
202-
203157
private function createResponse(Response|View $input): Response
204158
{
205159
if ($input instanceof View) {
@@ -209,50 +163,6 @@ private function createResponse(Response|View $input): Response
209163
return $input;
210164
}
211165

212-
private function matchStaticRoute(PsrRequest $request): ?MatchedRoute
213-
{
214-
$staticRoute = $this->routeConfig->staticRoutes[$request->getMethod()][$request->getUri()->getPath()] ?? null;
215-
216-
if ($staticRoute === null) {
217-
return null;
218-
}
219-
220-
return new MatchedRoute($staticRoute, []);
221-
}
222-
223-
private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute
224-
{
225-
// If there are no routes for the given request method, we immediately stop
226-
$routesForMethod = $this->routeConfig->dynamicRoutes[$request->getMethod()] ?? null;
227-
if ($routesForMethod === null) {
228-
return null;
229-
}
230-
231-
// First we get the Routing-Regex for the request method
232-
$matchingRegexForMethod = $this->routeConfig->matchingRegexes[$request->getMethod()];
233-
234-
// Then we'll use this regex to see whether we have a match or not
235-
$matchResult = preg_match($matchingRegexForMethod, $request->getUri()->getPath(), $matches);
236-
237-
if (! $matchResult || ! array_key_exists(self::REGEX_MARK_TOKEN, $matches)) {
238-
return null;
239-
}
240-
241-
$route = $routesForMethod[$matches[self::REGEX_MARK_TOKEN]];
242-
243-
// TODO: we could probably optimize resolveParams now,
244-
// because we already know for sure there's a match
245-
$routeParams = $this->resolveParams($route, $request->getUri()->getPath());
246-
247-
// This check should _in theory_ not be needed,
248-
// since we're certain there's a match
249-
if ($routeParams === null) {
250-
return null;
251-
}
252-
253-
return new MatchedRoute($route, $routeParams);
254-
}
255-
256166
private function resolveRequest(PsrRequest $psrRequest, MatchedRoute $matchedRoute): Request
257167
{
258168
// Let's find out if our input request data matches what the route's action needs

src/Tempest/Http/src/Route.php

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,18 @@
66

77
use Attribute;
88
use Tempest\Reflection\MethodReflector;
9-
use function Tempest\Support\arr;
10-
use function Tempest\Support\str;
119

1210
#[Attribute]
1311
class Route
1412
{
1513
public MethodReflector $handler;
1614

17-
/** @var string The Regex used for matching this route against a request URI */
18-
public readonly string $matchingRegex;
19-
2015
/** @var bool If the route has params */
2116
public readonly bool $isDynamic;
2217

18+
/** @var string[] Route parameters */
19+
public readonly array $params;
20+
2321
public const string DEFAULT_MATCHING_GROUP = '[^/]++';
2422

2523
public const string ROUTE_PARAM_NAME_REGEX = '(\w*)';
@@ -36,17 +34,8 @@ public function __construct(
3634
public array $middleware = [],
3735
) {
3836

39-
// Routes can have parameters in the form of "/{PARAM}/" or /{PARAM:CUSTOM_REGEX},
40-
// these parameters are replaced with a regex matching group or with the custom regex
41-
$matchingRegex = (string)str($this->uri)->replaceRegex(
42-
'#\{'. self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX .'\}#',
43-
fn ($matches) => '(' . trim(arr($matches)->get('2', self::DEFAULT_MATCHING_GROUP)). ')'
44-
);
45-
46-
$this->isDynamic = $matchingRegex !== $this->uri;
47-
48-
// Allow for optional trailing slashes
49-
$this->matchingRegex = $matchingRegex . '\/?';
37+
$this->params = self::getRouteParams($this->uri);
38+
$this->isDynamic = ! empty($this->params);
5039
}
5140

5241
public function setHandler(MethodReflector $handler): self
@@ -55,4 +44,29 @@ public function setHandler(MethodReflector $handler): self
5544

5645
return $this;
5746
}
47+
48+
/** @return string[] */
49+
public static function getRouteParams(string $uriPart): array
50+
{
51+
$regex = '#\{'. self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX .'\}#';
52+
53+
preg_match_all($regex, $uriPart, $matches);
54+
55+
return $matches[1] ?? [];
56+
}
57+
58+
/**
59+
* Splits the route URI into separate segments
60+
*
61+
* @example '/test/{id}/edit' becomes ['test', '{id}', 'edit']
62+
* @return string[]
63+
*/
64+
public function split(): array
65+
{
66+
$parts = explode('/', $this->uri);
67+
68+
return array_values(
69+
array_filter($parts, static fn (string $part) => $part !== '')
70+
);
71+
}
5872
}

src/Tempest/Http/src/RouteConfig.php

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Tempest\Http;
66

7+
use Tempest\Http\Routing\Construction\MarkedRoute;
8+
use Tempest\Http\Routing\Construction\RoutingTree;
79
use Tempest\Reflection\MethodReflector;
810

911
final class RouteConfig
@@ -14,12 +16,15 @@ final class RouteConfig
1416
/** @var array<string, string> */
1517
public array $matchingRegexes = [];
1618

19+
public RoutingTree $routingTree;
20+
1721
public function __construct(
1822
/** @var array<string, array<string, \Tempest\Http\Route>> */
1923
public array $staticRoutes = [],
2024
/** @var array<string, array<string, \Tempest\Http\Route>> */
2125
public array $dynamicRoutes = [],
2226
) {
27+
$this->routingTree = new RoutingTree();
2328
}
2429

2530
public function addRoute(MethodReflector $handler, Route $route): self
@@ -29,7 +34,14 @@ public function addRoute(MethodReflector $handler, Route $route): self
2934
if ($route->isDynamic) {
3035
$this->regexMark = str_increment($this->regexMark);
3136
$this->dynamicRoutes[$route->method->value][$this->regexMark] = $route;
32-
$this->addToMatchingRegex($route, $this->regexMark);
37+
38+
$this->routingTree->add(
39+
new MarkedRoute(
40+
mark: $this->regexMark,
41+
route: $route,
42+
)
43+
);
44+
3345
} else {
3446
$uriWithTrailingSlash = rtrim($route->uri, '/');
3547

@@ -40,25 +52,33 @@ public function addRoute(MethodReflector $handler, Route $route): self
4052
return $this;
4153
}
4254

55+
public function prepareMatchingRegexes(): void
56+
{
57+
if (! empty($this->matchingRegexes)) {
58+
return;
59+
}
60+
61+
$this->matchingRegexes = $this->routingTree->toMatchingRegexes();
62+
}
63+
4364
/**
44-
* Build one big regex for matching request URIs.
45-
* See https://github.com/tempestphp/tempest-framework/pull/175 for the details
65+
* __sleep is called before serialize and returns the public properties to serialize. We do not want the routingTree
66+
* to be serialized, but we do want the result to be serialized. Thus prepareMatchingRegexes is called and the
67+
* resulting matchingRegexes are stored.
4668
*/
47-
private function addToMatchingRegex(Route $route, string $routeMark): void
69+
public function __sleep(): array
4870
{
49-
// Each route, say "/posts/{postId}", which would have the regex "/posts/[^/]+", is marked.
50-
// e.g "/posts/[^/]+ (*MARK:a)".
51-
// This mark can then be used to find the matched route via a hashmap-lookup.
52-
$routeRegexPart = "{$route->matchingRegex} (*" . GenericRouter::REGEX_MARK_TOKEN . ":{$routeMark})";
53-
54-
if (! array_key_exists($route->method->value, $this->matchingRegexes)) {
55-
// initialize matching regex for method
56-
$this->matchingRegexes[$route->method->value] = "#^(?|{$routeRegexPart})$#x";
71+
$this->prepareMatchingRegexes();
5772

58-
return;
59-
}
73+
return ['staticRoutes', 'dynamicRoutes', 'matchingRegexes'];
74+
}
6075

61-
// insert regex part of this route into the matching group of the regex for the method
62-
$this->matchingRegexes[$route->method->value] = substr_replace($this->matchingRegexes[$route->method->value], "|{$routeRegexPart}", -4, 0);
76+
/**
77+
* __wakeup is called after unserialize. We do not serialize the routingTree thus we need to provide some default
78+
* for it. Otherwise, it will be uninitialized and cause issues when tempest expects it to be defined.
79+
*/
80+
public function __wakeup(): void
81+
{
82+
$this->routingTree = new RoutingTree();
6383
}
6484
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Routing\Construction;
6+
7+
use InvalidArgumentException;
8+
use Tempest\Http\Route;
9+
10+
final class DuplicateRouteException extends InvalidArgumentException
11+
{
12+
public function __construct(Route $route)
13+
{
14+
parent::__construct("Route '{$route->uri}' already exists.");
15+
}
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http\Routing\Construction;
6+
7+
use Tempest\Http\Route;
8+
9+
final readonly class MarkedRoute
10+
{
11+
public const string REGEX_MARK_TOKEN = 'MARK';
12+
13+
public function __construct(
14+
public string $mark,
15+
public Route $route,
16+
) {
17+
}
18+
}

0 commit comments

Comments
 (0)