Initial work

This commit is contained in:
Sky Johnson 2024-09-07 13:02:52 -05:00
parent 4879210fba
commit d7412d7191
10 changed files with 1699 additions and 0 deletions

59
SegmentRouter.php Normal file
View File

@ -0,0 +1,59 @@
<?php
class SegmentRouter
{
private array $routes = [];
public function add(string $method, string $route, callable $handler)
{
// Expand the route into segments
$segments = explode('/', trim($route, '/'));
// Push each segment into the routes array as a node
$node = &$this->routes;
foreach ($segments as $segment) $node = &$node[$segment];
// Add the handler to the last node
$node[$method] = $handler;
}
public function lookup(string $method, string $uri): int
{
// Expand the URI into segments
$uriSegments = explode('/', trim($uri, '/'));
$node = $this->routes;
$params = [];
// Traverse the routes array to find the handler
foreach ($uriSegments as $segment) {
// Check if the segment exists in the node, or if there's a dynamic segment
if (!isset($node[$segment])) {
$dynamicSegment = $this->matchDynamicSegment($node, $segment);
if ($dynamicSegment) {
$params[] = $segment;
$node = $node[$dynamicSegment];
} else {
return 404;
}
} else {
$node = $node[$segment];
}
}
// If the HTTP method is not supported, return 405
if (!isset($node[$method])) return 405;
// Call the handler
$handler = $node[$method];
call_user_func_array($handler, $params);
return 200;
}
private function matchDynamicSegment(array $node, string $segment)
{
foreach ($node as $key => $value) {
if (strpos($key, ':') === 0) return $key;
}
return null;
}
}

71
TrieRouter.php Normal file
View File

@ -0,0 +1,71 @@
<?php
class TrieRouter
{
private array $GET = [];
private array $POST = [];
private array $PUT = [];
private array $DELETE = [];
// Add route to the trie
public function add(string $method, string $route, callable $handler)
{
$node = &$this->{strtoupper($method)};
$segments = explode('/', trim($route, '/'));
foreach ($segments as $segment) {
if (!isset($node[$segment])) {
$node[$segment] = ['_children' => [], '_handler' => null];
}
$node = &$node[$segment]['_children'];
}
$node['_handler'] = $handler;
}
// Find and handle the route
public function handleRequest(string $method, string $uri)
{
$node = &$this->{strtoupper($method)};
$segments = explode('/', trim($uri, '/'));
$params = [];
foreach ($segments as $segment) {
if (isset($node[$segment])) {
$node = &$node[$segment]['_children'];
} else {
// Handle dynamic parameters (e.g., :id)
$dynamicSegment = $this->matchDynamicSegment($node, $segment);
if ($dynamicSegment) {
$params[] = $segment;
$node = &$node[$dynamicSegment]['_children'];
} else {
return $this->notFound();
}
}
}
// Check if a handler exists for the current node
if (isset($node['_handler'])) {
return call_user_func_array($node['_handler'], $params);
}
return $this->notFound();
}
// Match dynamic route segments like ':id'
private function matchDynamicSegment(array $node, string $segment)
{
foreach ($node as $key => $value) {
if (strpos($key, ':') === 0) return $key;
}
return null;
}
// Default 404 handler
private function notFound()
{
echo "404 Not Found";
return false;
}
}

61
tests/array.php Normal file
View File

@ -0,0 +1,61 @@
<?php
/*
This test fiel puts PHP's array to the test by running a million lookups on a handful of routes.
The routes are read from two files, blog.txt and github.txt, and added to two separate arrays.
The lookups are then run in two separate loops, one for each array.
Each lookup is timed and the memory usage is also printed out at regular intervals.
The requests are randomly picked from the array of routes.
*/
function readAndAddRoutes(string $file): array
{
$array = [];
$routes = file($file);
foreach ($routes as $route) {
[$method, $path] = explode(' ', $route);
$path = trim($path);
$array[] = [$method, $path, function() {
return true;
}];
}
return $array;
}
$blog = readAndAddRoutes('blog.txt');
$github = readAndAddRoutes('github.txt');
function echoMemoryAndTime(int $i, float $start) {
echo "($i lookups) M: " . round(memory_get_usage() / 1024, 1) . "kb - T: ". number_format(microtime(true) - $start, 10) ." seconds\n";
}
function runIterations(int $iterations, array $routes) {
echo "\n";
echo "===============================================================\n";
echo "\n";
echo "Running $iterations iterations\n";
$start = microtime(true);
$interval = $iterations / 10;
for ($i = 0; $i < $iterations; $i++) {
// pick a random route from the array
[$method, $uri, $handler] = $routes[array_rand($routes)];
$res = $handler();
if ($i !== 0 && $i % ($interval) === 0) echoMemoryAndTime($i, $start);
}
echo "Time taken: " . number_format(microtime(true) - $start, 10) . " seconds\n";
// echo the average time per request
echo "Average time per lookup: " . number_format((microtime(true) - $start) / $iterations, 10) . " seconds\n";
echo "\n";
}
echo "Starting blog lookups\n";
runIterations(100000, $blog);
runIterations(1000000, $blog);
echo "\n";
echo "===============================================================\n";
echo "\n";
echo "Starting github lookups\n";
runIterations(10000, $github);
runIterations(100000, $github);
runIterations(1000000, $github);

1000
tests/big.txt Normal file

File diff suppressed because it is too large Load Diff

4
tests/blog.txt Normal file
View File

@ -0,0 +1,4 @@
GET /
GET /:slug
GET /tags
GET /tag/:tag

203
tests/github.txt Normal file
View File

@ -0,0 +1,203 @@
GET /authorizations
GET /authorizations/:id
POST /authorizations
DELETE /authorizations/:id
GET /applications/:client_id/tokens/:access_token
DELETE /applications/:client_id/tokens
DELETE /applications/:client_id/tokens/:access_token
GET /events
GET /repos/:owner/:repo/events
GET /networks/:owner/:repo/events
GET /orgs/:org/events
GET /users/:user/received_events
GET /users/:user/received_events/public
GET /users/:user/events
GET /users/:user/events/public
GET /users/:user/events/orgs/:org
GET /feeds
GET /notifications
GET /repos/:owner/:repo/notifications
PUT /notifications
PUT /repos/:owner/:repo/notifications
GET /notifications/threads/:id
GET /notifications/threads/:id/subscription
PUT /notifications/threads/:id/subscription
DELETE /notifications/threads/:id/subscription
GET /repos/:owner/:repo/stargazers
GET /users/:user/starred
GET /user/starred
GET /user/starred/:owner/:repo
PUT /user/starred/:owner/:repo
DELETE /user/starred/:owner/:repo
GET /repos/:owner/:repo/subscribers
GET /users/:user/subscriptions
GET /user/subscriptions
GET /repos/:owner/:repo/subscription
PUT /repos/:owner/:repo/subscription
DELETE /repos/:owner/:repo/subscription
GET /user/subscriptions/:owner/:repo
PUT /user/subscriptions/:owner/:repo
DELETE /user/subscriptions/:owner/:repo
GET /users/:user/gists
GET /gists
GET /gists/:id
POST /gists
PUT /gists/:id/star
DELETE /gists/:id/star
GET /gists/:id/star
POST /gists/:id/forks
DELETE /gists/:id
GET /repos/:owner/:repo/git/blobs/:sha
POST /repos/:owner/:repo/git/blobs
GET /repos/:owner/:repo/git/commits/:sha
POST /repos/:owner/:repo/git/commits
GET /repos/:owner/:repo/git/refs
POST /repos/:owner/:repo/git/refs
GET /repos/:owner/:repo/git/tags/:sha
POST /repos/:owner/:repo/git/tags
GET /repos/:owner/:repo/git/trees/:sha
POST /repos/:owner/:repo/git/trees
GET /issues
GET /user/issues
GET /orgs/:org/issues
GET /repos/:owner/:repo/issues
GET /repos/:owner/:repo/issues/:number
POST /repos/:owner/:repo/issues
GET /repos/:owner/:repo/assignees
GET /repos/:owner/:repo/assignees/:assignee
GET /repos/:owner/:repo/issues/:number/comments
POST /repos/:owner/:repo/issues/:number/comments
GET /repos/:owner/:repo/issues/:number/events
GET /repos/:owner/:repo/labels
GET /repos/:owner/:repo/labels/:name
POST /repos/:owner/:repo/labels
DELETE /repos/:owner/:repo/labels/:name
GET /repos/:owner/:repo/issues/:number/labels
POST /repos/:owner/:repo/issues/:number/labels
DELETE /repos/:owner/:repo/issues/:number/labels/:name
PUT /repos/:owner/:repo/issues/:number/labels
DELETE /repos/:owner/:repo/issues/:number/labels
GET /repos/:owner/:repo/milestones/:number/labels
GET /repos/:owner/:repo/milestones
GET /repos/:owner/:repo/milestones/:number
POST /repos/:owner/:repo/milestones
DELETE /repos/:owner/:repo/milestones/:number
GET /emojis
GET /gitignore/templates
GET /gitignore/templates/:name
POST /markdown
POST /markdown/raw
GET /meta
GET /rate_limit
GET /users/:user/orgs
GET /user/orgs
GET /orgs/:org
GET /orgs/:org/members
GET /orgs/:org/members/:user
DELETE /orgs/:org/members/:user
GET /orgs/:org/public_members
GET /orgs/:org/public_members/:user
PUT /orgs/:org/public_members/:user
DELETE /orgs/:org/public_members/:user
GET /orgs/:org/teams
GET /teams/:id
POST /orgs/:org/teams
DELETE /teams/:id
GET /teams/:id/members
GET /teams/:id/members/:user
PUT /teams/:id/members/:user
DELETE /teams/:id/members/:user
GET /teams/:id/repos
GET /teams/:id/repos/:owner/:repo
PUT /teams/:id/repos/:owner/:repo
DELETE /teams/:id/repos/:owner/:repo
GET /user/teams
GET /repos/:owner/:repo/pulls
GET /repos/:owner/:repo/pulls/:number
POST /repos/:owner/:repo/pulls
GET /repos/:owner/:repo/pulls/:number/commits
GET /repos/:owner/:repo/pulls/:number/files
GET /repos/:owner/:repo/pulls/:number/merge
PUT /repos/:owner/:repo/pulls/:number/merge
GET /repos/:owner/:repo/pulls/:number/comments
PUT /repos/:owner/:repo/pulls/:number/comments
GET /user/repos
GET /users/:user/repos
GET /orgs/:org/repos
GET /repositories
POST /user/repos
POST /orgs/:org/repos
GET /repos/:owner/:repo
GET /repos/:owner/:repo/contributors
GET /repos/:owner/:repo/languages
GET /repos/:owner/:repo/teams
GET /repos/:owner/:repo/tags
GET /repos/:owner/:repo/branches
GET /repos/:owner/:repo/branches/:branch
DELETE /repos/:owner/:repo
GET /repos/:owner/:repo/collaborators
GET /repos/:owner/:repo/collaborators/:user
PUT /repos/:owner/:repo/collaborators/:user
DELETE /repos/:owner/:repo/collaborators/:user
GET /repos/:owner/:repo/comments
GET /repos/:owner/:repo/commits/:sha/comments
POST /repos/:owner/:repo/commits/:sha/comments
GET /repos/:owner/:repo/comments/:id
DELETE /repos/:owner/:repo/comments/:id
GET /repos/:owner/:repo/commits
GET /repos/:owner/:repo/commits/:sha
GET /repos/:owner/:repo/readme
GET /repos/:owner/:repo/keys
GET /repos/:owner/:repo/keys/:id
POST /repos/:owner/:repo/keys
DELETE /repos/:owner/:repo/keys/:id
GET /repos/:owner/:repo/downloads
GET /repos/:owner/:repo/downloads/:id
DELETE /repos/:owner/:repo/downloads/:id
GET /repos/:owner/:repo/forks
POST /repos/:owner/:repo/forks
GET /repos/:owner/:repo/hooks
GET /repos/:owner/:repo/hooks/:id
POST /repos/:owner/:repo/hooks
POST /repos/:owner/:repo/hooks/:id/tests
DELETE /repos/:owner/:repo/hooks/:id
POST /repos/:owner/:repo/merges
GET /repos/:owner/:repo/releases
GET /repos/:owner/:repo/releases/:id
POST /repos/:owner/:repo/releases
DELETE /repos/:owner/:repo/releases/:id
GET /repos/:owner/:repo/releases/:id/assets
GET /repos/:owner/:repo/stats/contributors
GET /repos/:owner/:repo/stats/commit_activity
GET /repos/:owner/:repo/stats/code_frequency
GET /repos/:owner/:repo/stats/participation
GET /repos/:owner/:repo/stats/punch_card
GET /repos/:owner/:repo/statuses/:ref
POST /repos/:owner/:repo/statuses/:ref
GET /search/repositories
GET /search/code
GET /search/issues
GET /search/users
GET /legacy/issues/search/:owner/:repository/:state/:keyword
GET /legacy/repos/search/:keyword
GET /legacy/user/search/:keyword
GET /legacy/user/email/:email
GET /users/:user
GET /user
GET /users
GET /user/emails
POST /user/emails
DELETE /user/emails
GET /users/:user/followers
GET /user/followers
GET /users/:user/following
GET /user/following
GET /user/following/:user
GET /users/:user/following/:target_user
PUT /user/following/:user
DELETE /user/following/:user
GET /users/:user/keys
GET /user/keys
GET /user/keys/:id
POST /user/keys
DELETE /user/keys/:id

45
tests/makeRoutes.php Normal file
View File

@ -0,0 +1,45 @@
<?php
$routes = [];
$methods = ['GET', 'POST', 'PUT', 'DELETE'];
$apis = ['blog', 'github', 'dragonknight', 'ecchi', 'hentai', 'harem', 'isekai', 'mecha', 'romance', 'shoujo', 'shounen', 'slice-of-life', 'supernatural', 'yuri'];
$params = ['id', 'slug', 'page', 'extra', 'foo', 'string', 'number', 'bar', 'baz', 'qux', 'quux', 'corge', 'grault', 'garply', 'waldo', 'fred', 'plugh', 'xyzzy', 'thud'];
$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('big.txt', implode("\n", $routes));
}
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;
}

79
tests/segment.php Normal file
View File

@ -0,0 +1,79 @@
<?php
/*
This test file puts the SegmentRouter to the test by running a million lookups on a handful
of routes. The routes are read from two files, blog.txt and github.txt, and added to two separate
routers. The lookups are then run in two separate loops, one for each router.
Each lookup is timed and the memory usage is also printed out at regular intervals.
The requests are randomly picked from the array of routes.
*/
require_once __DIR__ . '/../SegmentRouter.php';
require_once 'tools.php';
// Blog router
$b = new SegmentRouter();
$blog = readAndAddRoutes('blog.txt', $b);
// Github router
$g = new SegmentRouter();
$github = readAndAddRoutes('github.txt', $g);
// Big router
$big = new SegmentRouter();
$bigRoutes = readAndAddRoutes('big.txt', $big);
echoTitle("Starting github lookups");
runIterations(100000, $b, $blog);
runIterations(1000000, $b, $blog);
echoTitle("Starting github lookups");
runIterations(10000, $g, $github);
runIterations(100000, $g, $github);
runIterations(1000000, $g, $github);
echoTitle("Starting big lookups");
runIterations(10000, $big, $bigRoutes);
runIterations(100000, $big, $bigRoutes);
runIterations(1000000, $big, $bigRoutes);
echoTitle("Testing parameters");
$routes = [
['GET', '/blog/:id', function($id) {
echo $id."\n";
}],
['GET', '/blog/:id/:slug', function($id, $slug) {
echo $id . ' - ' . $slug."\n";
}],
['GET', '/blog/:id/:slug/:page', function($id, $slug, $page) {
echo $id . ' - ' . $slug . ' - ' . $page."\n";
}],
['GET', '/blog/:id/:slug/:page/:extra', function($id, $slug, $page, $extra) {
echo $id . ' - ' . $slug . ' - ' . $page . ' - ' . $extra."\n";
}],
];
$r = new SegmentRouter();
foreach ($routes as $route) {
[$method, $path, $handler] = $route;
$r->add($method, $path, $handler);
}
for ($i = 0; $i < 10; $i++) {
[$method, $uri] = $routes[array_rand($routes)];
// Generate some random parameters
$uri = str_replace(':id', rand(1, 100), $uri);
$uri = str_replace(':slug', 'slug-' . rand(1, 100), $uri);
$uri = str_replace(':page', rand(1, 100), $uri);
$uri = str_replace(':extra', 'extra-' . rand(1, 100), $uri);
$res = $r->lookup($method, $uri);
if ($res !== 200) {
echo "Failed to handle request for $uri - $res\n";
exit(1);
}
}
exit(0);

117
tests/tools.php Normal file
View File

@ -0,0 +1,117 @@
<?php
// A simple class to return a string wrapped in ANSI color codes for terminal output
class Color {
const RESET = "\033[0m";
const BOLD = "\033[1m";
const UNDERLINE = "\033[4m";
const INVERSE = "\033[7m";
const BLACK = "\033[30m";
const RED = "\033[31m";
const GREEN = "\033[32m";
const YELLOW = "\033[33m";
const BLUE = "\033[34m";
const MAGENTA = "\033[35m";
const CYAN = "\033[36m";
const WHITE = "\033[37m";
private static function format(string $color, string $string): string {
return $color . $string . self::RESET;
}
public static function bold(string $string): string {
return self::format(self::BOLD, $string);
}
public static function underline(string $string): string {
return self::format(self::UNDERLINE, $string);
}
public static function inverse(string $string): string {
return self::format(self::INVERSE, $string);
}
public static function black(string $string): string {
return self::format(self::BLACK, $string);
}
public static function red(string $string): string {
return self::format(self::RED, $string);
}
public static function green(string $string): string {
return self::format(self::GREEN, $string);
}
public static function yellow(string $string): string {
return self::format(self::YELLOW, $string);
}
public static function blue(string $string): string {
return self::format(self::BLUE, $string);
}
public static function magenta(string $string): string {
return self::format(self::MAGENTA, $string);
}
public static function cyan(string $string): string {
return self::format(self::CYAN, $string);
}
public static function white(string $string): string {
return self::format(self::WHITE, $string);
}
}
function echoMemoryAndTime(int $i, float $start) {
echo "(".Color::green("$i lookups").") M: " .
Color::blue(round(memory_get_usage() / 1024, 1) . " kb") .
" - T: ". Color::blue(number_format(microtime(true) - $start, 10) ." s") .
"\n";
}
function echoTitle(string $title) {
echo "\n";
echo Color::bold(Color::black("===============================================================")) . "\n";
echo "\n";
echo Color::bold(Color::blue($title))."\n";
echo "\n";
echo Color::bold(Color::black("===============================================================")) . "\n";
echo "\n";
}
function readAndAddRoutes(string $file, &$r): array
{
$array = [];
$routes = file($file);
foreach ($routes as $route) {
[$method, $path] = explode(' ', $route);
$path = trim($path);
$array[] = [$method, $path];
$r->add($method, $path, function() {
return true;
});
}
return $array;
}
function runIterations(int $iterations, $r, array $routes) {
echo "Running $iterations iterations\n";
$start = microtime(true);
$interval = $iterations / 10;
for ($i = 0; $i < $iterations; $i++) {
// pick a random route from the array
[$method, $uri] = $routes[array_rand($routes)];
$res = $r->lookup($method, $uri);
if ($res !== 200) {
echo Color::red("Failed to handle request for $uri - $res\n");
exit(1);
}
if ($i !== 0 && $i % ($interval) === 0) echoMemoryAndTime($i, $start);
}
echo "Time: " . Color::cyan(number_format(microtime(true) - $start, 10) . " s\n");
// echo the average time per request
echo "Avg/lookup: " . Color::yellow(number_format((microtime(true) - $start) / $iterations, 10) . " s\n");
echo "\n";
}

60
tests/trie.php Normal file
View File

@ -0,0 +1,60 @@
<?php
/*
This test file puts the PATRICIA trie to the test by running a million lookups on a handful
of routes. The routes are read from two files, blog.txt and github.txt, and added to two separate
routers and two separate arrays. The lookups are then run in two separate loops, one for each router.
Each lookup is timed and the memory usage is also printed out at regular intervals.
The requests are randomly picked from the array of routes.
*/
require_once __DIR__ . '/../TrieRouter.php';
require_once 'tools.php';
// Blog router
$b = new TrieRouter();
$blog = readAndAddRoutes('blog.txt', $b);
// Github router
$g = new TrieRouter();
$github = readAndAddRoutes('github.txt', $g);
// Big router
$big = new TrieRouter();
$bigRoutes = readAndAddRoutes('big.txt', $big);
function runIterations(int $iterations, TrieRouter $r, array $routes) {
echo "Running $iterations iterations\n";
$start = microtime(true);
$interval = $iterations / 10;
for ($i = 0; $i < $iterations; $i++) {
// pick a random route from the array
[$method, $uri] = $routes[array_rand($routes)];
$res = $r->handleRequest($method, $uri);
if ($res !== true) {
echo "Failed to handle request for $uri\n";
exit(1);
}
if ($i !== 0 && $i % ($interval) === 0) echoMemoryAndTime($i, $start);
}
echo "Time: " . Color::cyan(number_format(microtime(true) - $start, 10) . " s\n");
// echo the average time per request
echo "Avg/lookup: " . Color::yellow(number_format((microtime(true) - $start) / $iterations, 10) . " s\n");
echo "\n";
}
echoTitle("Starting blog lookups");
runIterations(100000, $b, $blog);
runIterations(1000000, $b, $blog);
echoTitle("Starting github lookups");
runIterations(10000, $g, $github);
runIterations(100000, $g, $github);
runIterations(1000000, $g, $github);
echoTitle("Starting big lookups");
runIterations(10000, $big, $bigRoutes);
runIterations(100000, $big, $bigRoutes);
runIterations(1000000, $big, $bigRoutes);
exit(0);