Skip to content

Commit

Permalink
feat: optimize routing (#626)
Browse files Browse the repository at this point in the history
Co-authored-by: homersimpsons <guillaume.alabre@gmail.com>
  • Loading branch information
blackshadev and homersimpsons authored Nov 2, 2024
1 parent 6fc2d95 commit 83f1dac
Show file tree
Hide file tree
Showing 20 changed files with 874 additions and 138 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"jenssegers/blade": "^2.0",
"nyholm/psr7": "^1.8",
"phpat/phpat": "^0.10.14",
"phpbench/phpbench": "84.x-dev",
"phpstan/phpstan": "^1.10.0",
"phpunit/phpunit": "^11.3.5",
"rector/rector": "^1.2",
Expand Down
4 changes: 4 additions & 0 deletions phpbench.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema":"./vendor/phpbench/phpbench/phpbench.schema.json",
"runner.bootstrap": "vendor/autoload.php"
}
96 changes: 3 additions & 93 deletions src/Tempest/Http/src/GenericRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
use Tempest\Http\Responses\Invalid;
use Tempest\Http\Responses\NotFound;
use Tempest\Http\Responses\Ok;
use Tempest\Http\Routing\Matching\RouteMatcher;
use function Tempest\map;
use Tempest\Reflection\ClassReflector;
use function Tempest\Support\str;
use Tempest\Validation\Exceptions\ValidationException;
use Tempest\View\View;

Expand All @@ -27,14 +27,12 @@
*/
final class GenericRouter implements Router
{
public const string REGEX_MARK_TOKEN = 'MARK';

/** @var class-string<MiddlewareClass>[] */
private array $middleware = [];

public function __construct(
private readonly Container $container,
private readonly RouteConfig $routeConfig,
private readonly RouteMatcher $routeMatcher,
private readonly AppConfig $appConfig,
) {
}
Expand All @@ -45,7 +43,7 @@ public function dispatch(Request|PsrRequest $request): Response
$request = map($request)->with(RequestToPsrRequestMapper::class);
}

$matchedRoute = $this->matchRoute($request);
$matchedRoute = $this->routeMatcher->match($request);

if ($matchedRoute === null) {
return new NotFound();
Expand Down Expand Up @@ -76,17 +74,6 @@ public function dispatch(Request|PsrRequest $request): Response
return $response;
}

private function matchRoute(PsrRequest $request): ?MatchedRoute
{
// Try to match routes without any parameters
if (($staticRoute = $this->matchStaticRoute($request)) !== null) {
return $staticRoute;
}

// match dynamic routes
return $this->matchDynamicRoute($request);
}

private function getCallable(MatchedRoute $matchedRoute): Closure
{
$route = $matchedRoute->route;
Expand Down Expand Up @@ -167,39 +154,6 @@ public function toUri(array|string $action, ...$params): string
return $uri;
}

private function resolveParams(Route $route, string $uri): ?array
{
if ($route->uri === $uri) {
return [];
}

$tokens = str($route->uri)->matchAll('#\{'. Route::ROUTE_PARAM_NAME_REGEX . Route::ROUTE_PARAM_CUSTOM_REGEX .'\}#', );

if (empty($tokens)) {
return null;
}

$tokens = $tokens[1];

$matches = str($uri)->matchAll("#^$route->matchingRegex$#");

if (empty($matches)) {
return null;
}

unset($matches[0]);

$matches = array_values($matches);

$valueMap = [];

foreach ($matches as $i => $match) {
$valueMap[trim($tokens[$i], '{}')] = $match[0];
}

return $valueMap;
}

private function createResponse(Response|View $input): Response
{
if ($input instanceof View) {
Expand All @@ -209,50 +163,6 @@ private function createResponse(Response|View $input): Response
return $input;
}

private function matchStaticRoute(PsrRequest $request): ?MatchedRoute
{
$staticRoute = $this->routeConfig->staticRoutes[$request->getMethod()][$request->getUri()->getPath()] ?? null;

if ($staticRoute === null) {
return null;
}

return new MatchedRoute($staticRoute, []);
}

private function matchDynamicRoute(PsrRequest $request): ?MatchedRoute
{
// If there are no routes for the given request method, we immediately stop
$routesForMethod = $this->routeConfig->dynamicRoutes[$request->getMethod()] ?? null;
if ($routesForMethod === null) {
return null;
}

// First we get the Routing-Regex for the request method
$matchingRegexForMethod = $this->routeConfig->matchingRegexes[$request->getMethod()];

// Then we'll use this regex to see whether we have a match or not
$matchResult = preg_match($matchingRegexForMethod, $request->getUri()->getPath(), $matches);

if (! $matchResult || ! array_key_exists(self::REGEX_MARK_TOKEN, $matches)) {
return null;
}

$route = $routesForMethod[$matches[self::REGEX_MARK_TOKEN]];

// TODO: we could probably optimize resolveParams now,
// because we already know for sure there's a match
$routeParams = $this->resolveParams($route, $request->getUri()->getPath());

// This check should _in theory_ not be needed,
// since we're certain there's a match
if ($routeParams === null) {
return null;
}

return new MatchedRoute($route, $routeParams);
}

private function resolveRequest(PsrRequest $psrRequest, MatchedRoute $matchedRoute): Request
{
// Let's find out if our input request data matches what the route's action needs
Expand Down
46 changes: 30 additions & 16 deletions src/Tempest/Http/src/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,18 @@

use Attribute;
use Tempest\Reflection\MethodReflector;
use function Tempest\Support\arr;
use function Tempest\Support\str;

#[Attribute]
class Route
{
public MethodReflector $handler;

/** @var string The Regex used for matching this route against a request URI */
public readonly string $matchingRegex;

/** @var bool If the route has params */
public readonly bool $isDynamic;

/** @var string[] Route parameters */
public readonly array $params;

public const string DEFAULT_MATCHING_GROUP = '[^/]++';

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

// Routes can have parameters in the form of "/{PARAM}/" or /{PARAM:CUSTOM_REGEX},
// these parameters are replaced with a regex matching group or with the custom regex
$matchingRegex = (string)str($this->uri)->replaceRegex(
'#\{'. self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX .'\}#',
fn ($matches) => '(' . trim(arr($matches)->get('2', self::DEFAULT_MATCHING_GROUP)). ')'
);

$this->isDynamic = $matchingRegex !== $this->uri;

// Allow for optional trailing slashes
$this->matchingRegex = $matchingRegex . '\/?';
$this->params = self::getRouteParams($this->uri);
$this->isDynamic = ! empty($this->params);
}

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

return $this;
}

/** @return string[] */
public static function getRouteParams(string $uriPart): array
{
$regex = '#\{'. self::ROUTE_PARAM_NAME_REGEX . self::ROUTE_PARAM_CUSTOM_REGEX .'\}#';

preg_match_all($regex, $uriPart, $matches);

return $matches[1] ?? [];
}

/**
* Splits the route URI into separate segments
*
* @example '/test/{id}/edit' becomes ['test', '{id}', 'edit']
* @return string[]
*/
public function split(): array
{
$parts = explode('/', $this->uri);

return array_values(
array_filter($parts, static fn (string $part) => $part !== '')
);
}
}
52 changes: 36 additions & 16 deletions src/Tempest/Http/src/RouteConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Tempest\Http;

use Tempest\Http\Routing\Construction\MarkedRoute;
use Tempest\Http\Routing\Construction\RoutingTree;
use Tempest\Reflection\MethodReflector;

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

public RoutingTree $routingTree;

public function __construct(
/** @var array<string, array<string, \Tempest\Http\Route>> */
public array $staticRoutes = [],
/** @var array<string, array<string, \Tempest\Http\Route>> */
public array $dynamicRoutes = [],
) {
$this->routingTree = new RoutingTree();
}

public function addRoute(MethodReflector $handler, Route $route): self
Expand All @@ -29,7 +34,14 @@ public function addRoute(MethodReflector $handler, Route $route): self
if ($route->isDynamic) {
$this->regexMark = str_increment($this->regexMark);
$this->dynamicRoutes[$route->method->value][$this->regexMark] = $route;
$this->addToMatchingRegex($route, $this->regexMark);

$this->routingTree->add(
new MarkedRoute(
mark: $this->regexMark,
route: $route,
)
);

} else {
$uriWithTrailingSlash = rtrim($route->uri, '/');

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

public function prepareMatchingRegexes(): void
{
if (! empty($this->matchingRegexes)) {
return;
}

$this->matchingRegexes = $this->routingTree->toMatchingRegexes();
}

/**
* Build one big regex for matching request URIs.
* See https://github.com/tempestphp/tempest-framework/pull/175 for the details
* __sleep is called before serialize and returns the public properties to serialize. We do not want the routingTree
* to be serialized, but we do want the result to be serialized. Thus prepareMatchingRegexes is called and the
* resulting matchingRegexes are stored.
*/
private function addToMatchingRegex(Route $route, string $routeMark): void
public function __sleep(): array
{
// Each route, say "/posts/{postId}", which would have the regex "/posts/[^/]+", is marked.
// e.g "/posts/[^/]+ (*MARK:a)".
// This mark can then be used to find the matched route via a hashmap-lookup.
$routeRegexPart = "{$route->matchingRegex} (*" . GenericRouter::REGEX_MARK_TOKEN . ":{$routeMark})";

if (! array_key_exists($route->method->value, $this->matchingRegexes)) {
// initialize matching regex for method
$this->matchingRegexes[$route->method->value] = "#^(?|{$routeRegexPart})$#x";
$this->prepareMatchingRegexes();

return;
}
return ['staticRoutes', 'dynamicRoutes', 'matchingRegexes'];
}

// insert regex part of this route into the matching group of the regex for the method
$this->matchingRegexes[$route->method->value] = substr_replace($this->matchingRegexes[$route->method->value], "|{$routeRegexPart}", -4, 0);
/**
* __wakeup is called after unserialize. We do not serialize the routingTree thus we need to provide some default
* for it. Otherwise, it will be uninitialized and cause issues when tempest expects it to be defined.
*/
public function __wakeup(): void
{
$this->routingTree = new RoutingTree();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Routing\Construction;

use InvalidArgumentException;
use Tempest\Http\Route;

final class DuplicateRouteException extends InvalidArgumentException
{
public function __construct(Route $route)
{
parent::__construct("Route '{$route->uri}' already exists.");
}
}
18 changes: 18 additions & 0 deletions src/Tempest/Http/src/Routing/Construction/MarkedRoute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Routing\Construction;

use Tempest\Http\Route;

final readonly class MarkedRoute
{
public const string REGEX_MARK_TOKEN = 'MARK';

public function __construct(
public string $mark,
public Route $route,
) {
}
}
Loading

0 comments on commit 83f1dac

Please # to comment.