Compare commits

..

10 Commits

Author SHA1 Message Date
Valithor Obsidion
509a49b032 Abstracting lookup method 2025-02-05 12:53:06 -05:00
Valithor Obsidion
9fdef6b250 Faster early returns 2025-02-05 11:40:41 -05:00
Valithor Obsidion
ccb5a236a0 Break out of loop sooner 2025-02-04 15:20:51 -05:00
Valithor Obsidion
d42b6bdef7 Static, externalize routes array, unit test 2025-02-04 15:05:20 -05:00
Valithor Obsidion
b923553595 Remove used assignment
$node didn't even exist anymore
2024-12-26 08:36:08 -05:00
Valithor Obsidion
1c58ff3a59 Trait, Interface, Class 2024-12-25 10:59:20 -05:00
Valithor Obsidion
1eed623c18 Strict types 2024-12-25 10:50:49 -05:00
Valithor Obsidion
34b4e4ca20 Optimize $params using array_reduce 2024-12-25 10:46:16 -05:00
Valithor Obsidion
4f5f2599f5 Update SegmentRouter.php 2024-12-25 10:22:33 -05:00
Valithor Obsidion
393022b342 Tweaks and optimizations 2024-12-25 10:09:30 -05:00
11 changed files with 501 additions and 320 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
tests/trees
/vendor/
composer.lock

View File

@ -4,7 +4,7 @@ Hey there! This repo is an experiment to create a well-tuned and speedy URI rout
- It's fast
- It's simple
Prior to this proejct, I built the [SimpleRouter](https://github.com/splashsky/simplerouter) as a fork from a very cool project called the simplePHPRouter. That router was based on regex for parameterization, and the regex made it pretty painful to maintain if I ever left it alone for too long.
Prior to this project, I built the [SimpleRouter](https://github.com/splashsky/simplerouter) as a fork from a very cool project called the simplePHPRouter. That router was based on regex for parameterization, and the regex made it pretty painful to maintain if I ever left it alone for too long.
Since working on SimpleRouter, I've played with other languages (primarily Go) and found a few new ways of doing things.
@ -23,7 +23,7 @@ Take for example these routes:
A radix (and more specifically, a PATRICIA) trie takes the commonalities in these routes and makes them into nodes, or branches. `/` exists as the root node. `api/` is turned into a node, from which `v1/` and `/v2/no` branch. `hello` is taken as another branch with the `/` and `:param` child nodes. `/foo` is naturally it's only branch from the root.
By splitting these routes up into a trie based on their segments, you're able to iterate far more quickly through the tree to find what you're looking for. If a user then requests `/api/v1/hello/sky` the router can jump from the root, to `api/`, to `v/1`, to `hello/`, then to the final node much faster than if we had to chop up, say, an associative array and compare for every registered route.
By splitting these routes up into a trie based on their segments, you're able to iterate far more quickly through the tree to find what you're looking for. If a user then requests `/api/v1/hello/sky` the router can jump from the root, to `api/`, to `v1/`, to `hello/`, then to the final node much faster than if we had to chop up, say, an associative array and compare for every registered route.
The nodes can contain any arbitrary information, such as HTTP methods or handlers. From my experience, this method of lookup prefers specificity, and so it will always prefer the edges over the inner structures.

View File

@ -1,7 +1,7 @@
{
"name": "sharkk/router",
"description": "A simple node-based router.",
"version": "1.2.5",
"version": "1.2.4",
"type": "library",
"license": "MIT",
"autoload": {
@ -15,5 +15,8 @@
"email": "email@sharkk.net"
}
],
"minimum-stability": "stable"
"minimum-stability": "stable",
"require-dev": {
"phpunit/phpunit": "^11.5"
}
}

View File

@ -1,148 +0,0 @@
<?php
namespace Sharkk\Router;
class Router
{
private array $routes = [];
/**
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
* using a colon prefix. (:id, :slug, etc)
*
* Example:
* `$r->add('GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
*/
public function add(string $method, string $route, callable $handler): Router
{
// Expand the route into segments and make dynamic segments into a common placeholder
$segments = array_map(function($segment) {
return str_starts_with($segment, ':') ? ':x' : $segment;
}, explode('/', trim($route, '/')));
// Push each segment into the routes array as a node, except if this is the root node
$node = &$this->routes;
foreach ($segments as $segment) {
// skip an empty segment, which allows us to register handlers for the root node
if ($segment === '') continue;
$node = &$node[$segment]; // build the node tree as we go
}
// Add the handler to the last node
$node[$method] = $handler;
return $this;
}
/**
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
* 405 for method not allowed.
*
* @return array ['code', 'handler', 'params']
*/
public function lookup(string $method, string $uri): array
{
// node is a reference to our current location in the node tree
$node = $this->routes;
// params will hold any dynamic segments we find
$params = [];
// if the URI is just a slash, we can return the handler for the root node
if ($uri === '/') {
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => null]
: ['code' => 405, 'handler' => null, 'params' => null];
}
// We'll split up the URI into segments and traverse the node tree
foreach (explode('/', trim($uri, '/')) as $segment) {
// if there is a node for this segment, move to it
if (isset($node[$segment])) {
$node = $node[$segment];
continue;
}
// if there is a dynamic segment, move to it and store the value
if (isset($node[':x'])) {
$params[] = $segment;
$node = $node[':x'];
continue;
}
// if we can't find a node for this segment, return 404
return ['code' => 404, 'handler' => null, 'params' => []];
}
// if we found a handler for the method, return it and any params. if not, return a 405
return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => $params ?? []]
: ['code' => 405, 'handler' => null, 'params' => []];
}
/**
* Clear all routes from the router.
*/
public function clear(): Router
{
$this->routes = [];
return $this;
}
/**
* Dump the route tree as an array.
*/
public function dump(): array
{
return $this->routes;
}
/**
* Register a GET route.
*/
public function get(string $route, callable $handler): Router
{
return $this->add('GET', $route, $handler);
}
/**
* Register a POST route.
*/
public function post(string $route, callable $handler): Router
{
return $this->add('POST', $route, $handler);
}
/**
* Register a PUT route.
*/
public function put(string $route, callable $handler): Router
{
return $this->add('PUT', $route, $handler);
}
/**
* Register a PATCH route.
*/
public function patch(string $route, callable $handler): Router
{
return $this->add('PATCH', $route, $handler);
}
/**
* Register a DELETE route.
*/
public function delete(string $route, callable $handler): Router
{
return $this->add('DELETE', $route, $handler);
}
/**
* Register a HEAD route.
*/
public function head(string $route, callable $handler): Router
{
return $this->add('HEAD', $route, $handler);
}
}

9
src/RouterInterface.php Normal file
View File

@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace Sharkk\Router;
interface RouterInterface
{
public static function add(array &$routes, string $method, string $route, callable $handler): void;
public static function lookup(array &$routes, string $method, string $uri): array;
}

8
src/SegmentRouter.php Normal file
View File

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace Sharkk\Router;
class SegmentRouter implements SegmentRouterInterface
{
use SegmentRouterTrait;
}

View File

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace Sharkk\Router;
interface SegmentRouterInterface extends RouterInterface
{
//private function addNode(array &$node, array $segments, string $method, callable $handler): void;
public static function clear(array &$routes): void;
//public static function dump(array &$routes): array; // Deprecated, routes are now passed by reference
public static function get(array &$routes, string $route, callable $handler): void;
public static function post(array &$routes, string $route, callable $handler): void;
public static function put(array &$routes, string $route, callable $handler): void;
public static function patch(array &$routes, string $route, callable $handler): void;
public static function delete(array &$routes, string $route, callable $handler): void;
public static function head(array &$routes, string $route, callable $handler): void;
}

215
src/SegmentRouterTrait.php Normal file
View File

@ -0,0 +1,215 @@
<?php declare(strict_types=1);
namespace Sharkk\Router;
trait SegmentRouterTrait
{
/**
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
* using a colon prefix. (:id, :slug, etc)
*
* Example:
* `$r->add('GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
*/
public static function add(array &$routes, string $method, string $route, callable $handler): void
{
// Recursively build the node tree
self::addNode(
$routes,
array_map( // Expand the route into segments and make dynamic segments into a common placeholder
fn($segment) => str_starts_with($segment, ':') ? ':x' : $segment,
explode('/', trim($route, '/'))
),
$method,
$handler
);
}
/**
* Adds a handler to the routing node based on the provided segments and method.
*
* This method recursively traverses the segments array, creating nested nodes as needed,
* and assigns the handler to the final node corresponding to the method.
*
* @param array $node The current routing node.
* @param array $segments The segments of the route to add.
* @param string $method The HTTP method (e.g., 'GET', 'POST') for which the handler is being added.
* @param callable $handler The handler to be executed for the given route and method.
*
* @return void
*/
private static function addNode(array &$node, array $segments, string $method, callable $handler): void
{
// Base case: if no more segments, add the handler
if (empty($segments)) {
$node[$method] = $handler;
return;
}
// Get the current segment
$segment = array_shift($segments);
// Skip empty segments
if ($segment === '') {
self::addNode($node, $segments, $method, $handler);
return;
}
// Ensure the segment exists in the node
if (!isset($node[$segment])) {
$node[$segment] = [];
}
// Recur for the next segment
self::addNode($node[$segment], $segments, $method, $handler);
}
/**
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
* 405 for method not allowed.
*
* @return array ['code', 'handler', 'params']
*/
public static function lookup(array &$routes, string $method, string $uri): array
{
// node is a reference to our current location in the node tree
$node = $routes;
// if the URI is the root, and the method is defined, return the handler
if (self::isRootUri($uri, $node, $method)) {
return ['code' => 200, 'handler' => $node[$method], 'params' => null];
}
// params will hold any dynamic segments we find
$params = [];
$segments = explode('/', trim($uri, '/'));
if (!self::traverseSegments($node, $segments, $params)) {
return ['code' => 404, 'handler' => null, 'params' => []];
}
return self::getHandler($node, $method, $params) ?? ['code' => 405, 'handler' => null, 'params' => []];
}
/**
* Traverses the given segments and updates the node and params accordingly.
*
* This method iterates over the segments and attempts to match each segment
* with the corresponding node. If a segment matches, it continues to the next
* segment. If a segment does not match but a wildcard ':x' is found, the segment
* is added to the params array and traversal continues. If no match is found,
* the traversal stops and returns false.
*
* @param array $node The current node to traverse.
* @param array $segments The segments to traverse through the node.
* @param array $params The parameters collected during traversal.
* @return bool Returns true if traversal is successful, otherwise false.
*/
private static function traverseSegments(array &$node, array $segments, array &$params): bool
{
return ($node = array_reduce($segments, function ($carry, $segment) use (&$params) {
if (isset($carry[$segment])) {
return $carry[$segment];
}
if (isset($carry[':x'])) {
$params[] = $segment;
return $carry[':x'];
}
return false;
}, $node)) !== false;
}
/**
* Checks if the given URI is the root URI and if the specified method exists in the node array.
*
* @param string $uri The URI to check.
* @param array &$node The node array to check against.
* @param string $method The HTTP method to check for in the node array.
* @return bool Returns true if the URI is the root URI and the method exists in the node array, false otherwise.
*/
public static function isRootUri(string $uri, array &$node, string $method): bool
{
return ($uri === '/' && isset($node[$method]) && array_key_exists($method, $node));
}
// if we found a handler for the method, return it and any params. if not, return a 405
/**
* Retrieves the handler for a given node and method.
*
* @param array $node The node containing possible handlers.
* @param string $method The method to look up in the node.
* @param array $params The parameters to pass to the handler.
* @return array|null An array containing the handler information if found, or null if not found.
*/
public static function getHandler($node, $method, $params): ?array
{
return array_key_exists($method, $node)
? ['code' => 200, 'handler' => $node[$method], 'params' => $params]
: null;
}
/**
* Clear all routes from the router.
*/
public static function clear(array &$routes): void
{
$routes = [];
}
/**
* Dump the route tree as an array.
*/
/*public static function dump(array &$routes): array
{
return $routes;
}*/
/**
* Register a GET route.
*/
public static function get(array &$routes, string $route, callable $handler): void
{
self::add($routes, 'GET', $route, $handler);
}
/**
* Register a POST route.
*/
public static function post(array &$routes, string $route, callable $handler): void
{
self::add($routes, 'POST', $route, $handler);
}
/**
* Register a PUT route.
*/
public static function put(array &$routes, string $route, callable $handler): void
{
self::add($routes, 'PUT', $route, $handler);
}
/**
* Register a PATCH route.
*/
public static function patch(array &$routes, string $route, callable $handler): void
{
self::add($routes, 'PATCH', $route, $handler);
}
/**
* Register a DELETE route.
*/
public static function delete(array &$routes, string $route, callable $handler): void
{
self::add($routes, 'DELETE', $route, $handler);
}
/**
* Register a HEAD route.
*/
public static function head(array &$routes, string $route, callable $handler): void
{
self::add($routes, 'HEAD', $route, $handler);
}
}

View File

@ -0,0 +1,84 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use Sharkk\Router\SegmentRouter;
final class SegmentRouterTest extends TestCase
{
private SegmentRouter $router;
protected function setUp(): void
{
$this->router = new SegmentRouter();
}
public function testAddAndLookupRoute(): void
{
$routes = [];
SegmentRouter::add($routes, 'GET', '/blog/:id', function($id) {
return "Blog post $id";
});
$result = SegmentRouter::lookup($routes, 'GET', '/blog/123');
$this->assertEquals(200, $result['code']);
$this->assertIsCallable($result['handler']);
$this->assertEquals(['123'], $result['params']);
$this->assertEquals("Blog post 123", $result['handler'](...$result['params']));
}
public function testLookupNotFound(): void
{
$routes = [];
SegmentRouter::add($routes, 'GET', '/blog/:id', function($id) {
return "Blog post $id";
});
$result = SegmentRouter::lookup($routes, 'GET', '/nonexistent');
$this->assertEquals(404, $result['code']);
$this->assertNull($result['handler']);
$this->assertEmpty($result['params']);
}
public function testMethodNotAllowed(): void
{
$routes = [];
SegmentRouter::add($routes, 'GET', '/blog/:id', function($id) {
return "Blog post $id";
});
$result = SegmentRouter::lookup($routes, 'POST', '/blog/123');
$this->assertEquals(405, $result['code']);
$this->assertNull($result['handler']);
$this->assertEmpty($result['params']);
}
public function testClearRoutes(): void
{
$routes = [];
SegmentRouter::add($routes, 'GET', '/blog/:id', function($id) {
return "Blog post $id";
});
SegmentRouter::clear($routes);
$this->assertEmpty($routes);
}
public function testAddMultipleMethods(): void
{
$routes = [];
SegmentRouter::add($routes, 'GET', '/blog/:id', function($id) {
return "GET Blog post $id";
});
SegmentRouter::add($routes, 'POST', '/blog/:id', function($id) {
return "POST Blog post $id";
});
$resultGet = SegmentRouter::lookup($routes, 'GET', '/blog/123');
$this->assertEquals(200, $resultGet['code']);
$this->assertEquals("GET Blog post 123", $resultGet['handler'](...$resultGet['params']));
$resultPost = SegmentRouter::lookup($routes, 'POST', '/blog/123');
$this->assertEquals(200, $resultPost['code']);
$this->assertEquals("POST Blog post 123", $resultPost['handler'](...$resultPost['params']));
}
}

View File

@ -8,38 +8,28 @@ $params = ['id', 'slug', 'page', 'extra', 'foo', 'string', 'number', 'bar', 'baz
$endpoint = ['edit', 'create', 'delete', 'view', 'change', 'modify', 'generate', 'lift', 'lower', 'raise', 'drop', 'pick', 'choose', 'select', 'deselect', 'unselect', 'reselect', 'pick', 'unpick', 'repick', 'reselect', 'reunpick', 'rechoose', 'reselect'];
$midpoint = ['do', 'cause', 'effect', 'affect', 'impact', 'influence', 'change', 'modify', 'transform', 'alter', 'shift', 'adjust', 'adapt', 'convert', 'translate'];
// Generate routes
for ($i = 0; $i < 1000; $i++) {
$routes[] = makeRoute();
// write the routes array to a file
file_put_contents('routes/big.txt', implode("\n", $routes));
file_put_contents('routes/big.txt', implode("\n", array_map(fn() => makeRoute(), range(1, 1000))));
enum RoutePart: string {
case PARAMS = 'params';
case ENDPOINT = 'endpoint';
case MIDPOINT = 'midpoint';
public function matches(array $params, array $endpoint, array $midpoint): string {
return '/' . match ($this) {
self::PARAMS => ':' . $params[array_rand($params)],
self::ENDPOINT => $endpoint[array_rand($endpoint)],
self::MIDPOINT => $midpoint[array_rand($midpoint)],
};
}
}
function makeRoute(): string
{
global $methods, $apis, $params, $endpoint, $midpoint;
$method = $methods[array_rand($methods)];
$api = $apis[array_rand($apis)];
$route = "/$api";
$length = rand(1, 8);
$options = ['params', 'endpoint', 'midpoint'];
for ($i = 0; $i < $length; $i++) {
$option = $options[array_rand($options)];
switch ($option) {
case 'params':
$param = $params[array_rand($params)];
$route .= "/:$param";
break;
case 'endpoint':
$end = $endpoint[array_rand($endpoint)];
$route .= "/$end";
break;
case 'midpoint':
$mid = $midpoint[array_rand($midpoint)];
$route .= "/$mid";
break;
}
}
return $method . ' ' . $route;
return $methods[array_rand($methods)] .
' /' .
$apis[array_rand($apis)] .
implode('', array_map(fn($option) => $option->matches($params, $endpoint, $midpoint), array_rand(array_flip(RoutePart::cases()), rand(1, 8))));
}

View File

@ -2,6 +2,8 @@
require_once 'color.php';
require_once __DIR__ . '/../src/RouterInterface.php';
require_once __DIR__ . '/../src/SegmentRouterTrait.php';
require_once __DIR__ . '/../src/SegmentRouterInterface.php';
require_once __DIR__ . '/../src/SegmentRouter.php';
const ROUTES = __DIR__ . '/routes/';
@ -18,42 +20,42 @@ if (in_array('-f', $argv)) {
// create the trees directory if it doesn't exist
if (!is_dir(TREES)) mkdir(TREES);
$r = new SegmentRouter();
$routes = [];
// Blog lookups
$blog = readAndAddRoutes(ROUTES . 'blog.txt', $r);
writeRoutesToFile($r->dump(), TREES . 'blog.txt');
$blog = readAndAddRoutes(ROUTES . 'blog.txt', $routes);
writeRoutesToFile($routes, TREES . 'blog.txt');
echoTitle("Starting blog lookups");
runIterations(10000, $r, $blog);
runIterations(100000, $r, $blog);
runIterations(1000000, $r, $blog);
runIterations(10000, $routes, $blog);
runIterations(100000, $routes, $blog);
runIterations(1000000, $routes, $blog);
unset($blog);
// Github lookups
$r->clear();
$github = readAndAddRoutes(ROUTES . 'github.txt', $r);
writeRoutesToFile($r->dump(), TREES . 'github.txt');
SegmentRouter::clear($routes);
$github = readAndAddRoutes(ROUTES . 'github.txt', $routes);
writeRoutesToFile($routes, TREES . 'github.txt');
echoTitle("Starting github lookups");
runIterations(10000, $r, $github);
runIterations(100000, $r, $github);
runIterations(1000000, $r, $github);
runIterations(10000, $routes, $github);
runIterations(100000, $routes, $github);
runIterations(1000000, $routes, $github);
unset($github);
// Big lookups
$r->clear();
$big = readAndAddRoutes(ROUTES . 'big.txt', $r);
writeRoutesToFile($r->dump(), TREES . 'big.txt');
SegmentRouter::clear($routes);
$big = readAndAddRoutes(ROUTES . 'big.txt', $routes);
writeRoutesToFile($routes, TREES . 'big.txt');
echoTitle("Starting big lookups");
runIterations(10000, $r, $big);
runIterations(100000, $r, $big);
runIterations(1000000, $r, $big);
runIterations(10000, $routes, $big);
runIterations(100000, $routes, $big);
runIterations(1000000, $routes, $big);
unset($big);
// Parameter testing
$r->clear();
SegmentRouter::clear($routes);
echoTitle("Testing parameters");
$routes = [
$testRoutes = [
['GET', '/blog/:id', function($id) {
echo $id."\n";
}],
@ -68,13 +70,10 @@ $routes = [
}],
];
foreach ($routes as $route) {
[$method, $path, $handler] = $route;
$r->add($method, $path, $handler);
}
foreach ($testRoutes as $route) SegmentRouter::add($routes, ...$route);
for ($i = 0; $i < 10; $i++) {
[$method, $uri] = $routes[array_rand($routes)];
[$method, $uri] = $testRoutes[array_rand($testRoutes)];
// Generate some random parameters
$uri = str_replace(':id', rand(1, 100), $uri);
@ -82,22 +81,22 @@ for ($i = 0; $i < 10; $i++) {
$uri = str_replace(':page', rand(1, 100), $uri);
$uri = str_replace(':extra', 'extra-' . rand(1, 100), $uri);
$res = $r->lookup($method, $uri);
$res = SegmentRouter::lookup($routes, $method, $uri);
if ($res['code'] !== 200) {
echo "Failed to handle request for $uri - $res\n";
echo "Failed to handle request for $uri - " . json_encode($res) . "\n";
exit(1);
}
$res['handler'](...$res['params']);
}
$r->clear();
SegmentRouter::clear($routes);
echoTitle('Testing root node');
$r->add('GET', '/', function() {
SegmentRouter::add($routes, 'GET', '/', function() {
echo "Root node is gtg!\n";
});
$res = $r->lookup('GET', '/');
$res = SegmentRouter::lookup($routes, 'GET', '/');
if ($res['code'] !== 200) {
echo "Failed to handle request for /\n";
exit(1);
@ -116,22 +115,26 @@ function echoTitle(string $title) {
echo "\n";
}
function readAndAddRoutes(string $file, &$r): array
function readAndAddRoutes(string $file, &$routes): array
{
$array = [];
$routes = file($file);
foreach ($routes as $route) {
return array_map(function($route) use (&$routes) {
[$method, $path] = explode(' ', $route);
$path = trim($path);
$array[] = [$method, $path];
$r->add($method, $path, function() {
return true;
});
}
return $array;
SegmentRouter::add($routes, $method, $path, fn() => true);
return [$method, $path];
}, file($file));
}
function runIterations(int $iterations, $r, array $routes) {
function addRoutes($routes): array
{
return array_map(function($route) use (&$routes) {
[$method, $path, $handler] = $route;
SegmentRouter::add($routes, $method, $path, $handler);
return [$method, $path, $handler];
}, $routes);
}
function runIterations(int $iterations, &$routes, array $routesList) {
echo "\n🚀 Running $iterations iterations...\n";
$start = microtime(true); // start the timer
@ -143,7 +146,7 @@ function runIterations(int $iterations, $r, array $routes) {
for ($i = 0; $i < $iterations; $i++) {
// pick a random route from the array
[$method, $path] = $routes[array_rand($routes)];
[$method, $path] = $routesList[array_rand($routesList)];
// replace all :params/ with random values
$uri = preg_replace_callback('/:(\w+)/', function($matches) {
@ -152,7 +155,7 @@ function runIterations(int $iterations, $r, array $routes) {
$start2 = microtime(true); // start the timer for the lookup
$res = $r->lookup($method, $uri); // run the lookup
$res = SegmentRouter::lookup($routes, $method, $uri); // run the lookup
$reqs += microtime(true) - $start2; // add this lookup time
if ($shortest == 0 || microtime(true) - $start2 < $shortest) {