Skip to content

Commit f0dc1b1

Browse files
authored
Merge pull request #658 from flightphp/router-performance
Router performance enhancements
2 parents 69b50fc + 204ffbb commit f0dc1b1

File tree

4 files changed

+346
-10
lines changed

4 files changed

+346
-10
lines changed

flight/net/Router.php

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ class Router
2929
*/
3030
protected array $routes = [];
3131

32+
/**
33+
* Routes grouped by HTTP method for faster lookups
34+
*
35+
* @var array<string, array<int, Route>>
36+
*/
37+
protected array $routesByMethod = [];
38+
3239
/**
3340
* The current route that is has been found and executed.
3441
*/
@@ -82,6 +89,7 @@ public function getRoutes(): array
8289
public function clear(): void
8390
{
8491
$this->routes = [];
92+
$this->routesByMethod = [];
8593
}
8694

8795
/**
@@ -134,6 +142,14 @@ public function map(string $pattern, $callback, bool $pass_route = false, string
134142

135143
$this->routes[] = $route;
136144

145+
// Group routes by HTTP method for faster lookups
146+
foreach ($methods as $method) {
147+
if (!isset($this->routesByMethod[$method])) {
148+
$this->routesByMethod[$method] = [];
149+
}
150+
$this->routesByMethod[$method][] = $route;
151+
}
152+
137153
return $route;
138154
}
139155

@@ -228,17 +244,48 @@ public function group(string $groupPrefix, callable $callback, array $groupMiddl
228244
*/
229245
public function route(Request $request)
230246
{
231-
while ($route = $this->current()) {
232-
$urlMatches = $route->matchUrl($request->url, $this->caseSensitive);
233-
$methodMatches = $route->matchMethod($request->method);
234-
if ($urlMatches === true && $methodMatches === true) {
235-
$this->executedRoute = $route;
236-
return $route;
237-
// capture the route but don't execute it. We'll use this in Engine->start() to throw a 405
238-
} elseif ($urlMatches === true && $methodMatches === false) {
239-
$this->executedRoute = $route;
247+
$requestMethod = $request->method;
248+
$requestUrl = $request->url;
249+
250+
// If we're in the middle of iterating (index > 0), continue with the original iterator logic
251+
// This handles cases where the Engine calls next() and continues routing (e.g., when routes return true)
252+
if ($this->index > 0) {
253+
while ($route = $this->current()) {
254+
$urlMatches = $route->matchUrl($requestUrl, $this->caseSensitive);
255+
$methodMatches = $route->matchMethod($requestMethod);
256+
if ($urlMatches === true && $methodMatches === true) {
257+
$this->executedRoute = $route;
258+
return $route;
259+
} elseif ($urlMatches === true && $methodMatches === false) {
260+
$this->executedRoute = $route;
261+
}
262+
$this->next();
263+
}
264+
return false;
265+
}
266+
267+
// Fast path: check method-specific routes first, then wildcard routes (only on first routing attempt)
268+
$methodsToCheck = [$requestMethod, '*'];
269+
foreach ($methodsToCheck as $method) {
270+
if (isset($this->routesByMethod[$method])) {
271+
foreach ($this->routesByMethod[$method] as $route) {
272+
if ($route->matchUrl($requestUrl, $this->caseSensitive)) {
273+
$this->executedRoute = $route;
274+
// Set iterator position to this route for potential next() calls
275+
$this->index = array_search($route, $this->routes, true);
276+
return $route;
277+
}
278+
}
279+
}
280+
}
281+
282+
// If no exact match found, check all routes for 405 (method not allowed) cases
283+
// This maintains the original behavior where we capture routes that match URL but not method
284+
foreach ($this->routes as $route) {
285+
if ($route->matchUrl($requestUrl, $this->caseSensitive) && !$route->matchMethod($requestMethod)) {
286+
$this->executedRoute = $route; // Capture for 405 error in Engine
287+
// Don't return false yet, continue checking for other potential matches
240288
}
241-
$this->next();
242289
}
243290

244291
return false;

tests/EngineTest.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,108 @@ public function getInitializedVar()
174174
$engine->start();
175175
}
176176

177+
public function testDoubleReturnTrueRoutesContinueIteration(): void
178+
{
179+
$_SERVER['REQUEST_METHOD'] = 'GET';
180+
$_SERVER['REQUEST_URI'] = '/someRoute';
181+
182+
$engine = new class extends Engine {
183+
public function getInitializedVar()
184+
{
185+
return $this->initialized;
186+
}
187+
};
188+
189+
// First route that returns true (should continue routing)
190+
$engine->route('/someRoute', function () {
191+
echo 'first route ran, ';
192+
return true;
193+
}, true);
194+
195+
// Second route that should be found and executed
196+
$engine->route('/someRoute', function () {
197+
echo 'second route executed!';
198+
}, true);
199+
200+
$this->expectOutputString('first route ran, second route executed!');
201+
$engine->start();
202+
}
203+
204+
public function testDoubleReturnTrueWithMethodMismatchDuringIteration(): void
205+
{
206+
$_SERVER['REQUEST_METHOD'] = 'POST';
207+
$_SERVER['REQUEST_URI'] = '/someRoute';
208+
209+
$engine = new class extends Engine {
210+
public function getInitializedVar()
211+
{
212+
return $this->initialized;
213+
}
214+
215+
public function getLoader()
216+
{
217+
return $this->loader;
218+
}
219+
};
220+
221+
// Mock response to prevent actual headers
222+
$engine->getLoader()->register('response', function () {
223+
return new class extends Response {
224+
public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): self
225+
{
226+
return $this;
227+
}
228+
};
229+
});
230+
231+
// First route that returns true and matches POST
232+
$engine->route('POST /someRoute', function () {
233+
echo 'first POST route ran, ';
234+
return true;
235+
}, true);
236+
237+
// Second route that matches URL but wrong method (GET) - should be captured for 405
238+
$engine->route('GET /someRoute', function () {
239+
echo 'should not execute';
240+
}, true);
241+
242+
// Third route that matches POST and should execute
243+
$engine->route('POST /someRoute', function () {
244+
echo 'second POST route executed!';
245+
}, true);
246+
247+
$this->expectOutputString('first POST route ran, second POST route executed!');
248+
$engine->start();
249+
}
250+
251+
public function testIteratorReachesEndWithoutMatch(): void
252+
{
253+
$_SERVER['REQUEST_METHOD'] = 'GET';
254+
$_SERVER['REQUEST_URI'] = '/someRoute';
255+
256+
$engine = new class extends Engine {
257+
public function getInitializedVar()
258+
{
259+
return $this->initialized;
260+
}
261+
};
262+
263+
// Route that returns true (continues iteration)
264+
$engine->route('/someRoute', function () {
265+
echo 'first route ran, ';
266+
return true;
267+
}, true);
268+
269+
// Route with different URL that won't match
270+
$engine->route('/differentRoute', function () {
271+
echo 'should not execute';
272+
}, true);
273+
274+
// No more matching routes - should reach end of iterator and return 404
275+
$this->expectOutputString('<h1>404 Not Found</h1><h3>The page you have requested could not be found.</h3>');
276+
$engine->start();
277+
}
278+
177279
public function testDoubleStart()
178280
{
179281
$engine = new Engine();

tests/performance/index.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require __DIR__ . '/vendor/autoload.php';
6+
7+
// Route to list all available test routes
8+
Flight::route('GET /', function () {
9+
echo "<h2>Available Test Routes:</h2><ul>";
10+
echo "<li><a href='/route0'>/route0</a> (static)</li>";
11+
echo "<li><a href='/user1/123'>/user1/123</a> (single param)</li>";
12+
echo "<li><a href='/post2/tech/my-article'>/post2/tech/my-article</a> (multiple params)</li>";
13+
echo "<li><a href='/api3/456'>/api3/456</a> (regex constraint)</li>";
14+
echo "<li>/submit4/document (POST only)</li>";
15+
echo "<li><a href='/admin5/dashboard'>/admin5/dashboard</a> (grouped)</li>";
16+
echo "<li><a href='/admin5/users/789'>/admin5/users/789</a> (grouped with regex)</li>";
17+
echo "<li><a href='/file6/path/to/document.pdf'>/file6/path/to/document.pdf</a> (complex regex)</li>";
18+
echo "<li><a href='/resource7/999'>/resource7/999</a> (multi-method)</li>";
19+
echo "</ul>";
20+
echo "<h3>Performance Test URLs:</h3>";
21+
echo "<p>Static routes: /route0, /route8, /route16, /route24, /route32, /route40, /route48</p>";
22+
echo "<p>Param routes: /user1/123, /user9/456, /user17/789</p>";
23+
echo "<p>Complex routes: /post2/tech/article, /api3/123, /file6/test.txt</p>";
24+
});
25+
26+
27+
for ($i = 0; $i < 50; $i++) {
28+
$route_type = $i % 8;
29+
30+
switch ($route_type) {
31+
case 0:
32+
// Simple static routes
33+
Flight::route("GET /route{$i}", function () use ($i) {
34+
echo "This is static route {$i}";
35+
});
36+
break;
37+
38+
case 1:
39+
// Routes with single parameter
40+
Flight::route("GET /user{$i}/@id", function ($id) use ($i) {
41+
echo "User route {$i} with ID: {$id}";
42+
});
43+
break;
44+
45+
case 2:
46+
// Routes with multiple parameters
47+
Flight::route("GET /post{$i}/@category/@slug", function ($category, $slug) use ($i) {
48+
echo "Post route {$i}: {$category}/{$slug}";
49+
});
50+
break;
51+
52+
case 3:
53+
// Routes with regex constraints
54+
Flight::route("GET /api{$i}/@id:[0-9]+", function ($id) use ($i) {
55+
echo "API route {$i} with numeric ID: {$id}";
56+
});
57+
break;
58+
59+
case 4:
60+
// POST routes with parameters
61+
Flight::route("POST /submit{$i}/@type", function ($type) use ($i) {
62+
echo "POST route {$i} with type: {$type}";
63+
});
64+
break;
65+
66+
case 5:
67+
// Grouped routes
68+
Flight::group("/admin{$i}", function () use ($i) {
69+
Flight::route("GET /dashboard", function () use ($i) {
70+
echo "Admin dashboard {$i}";
71+
});
72+
Flight::route("GET /users/@id:[0-9]+", function ($id) use ($i) {
73+
echo "Admin user {$i}: {$id}";
74+
});
75+
});
76+
break;
77+
78+
case 6:
79+
// Complex regex patterns
80+
Flight::route("GET /file{$i}/@path:.*", function ($path) use ($i) {
81+
echo "File route {$i} with path: {$path}";
82+
});
83+
break;
84+
85+
case 7:
86+
// Multiple HTTP methods
87+
Flight::route("GET|POST|PUT /resource{$i}/@id", function ($id) use ($i) {
88+
echo "Multi-method route {$i} for resource: {$id}";
89+
});
90+
break;
91+
}
92+
}
93+
// Add some predictable routes for easy performance testing
94+
Flight::route('GET /test-static', function () {
95+
$memory_start = memory_get_usage();
96+
$memory_peak = memory_get_peak_usage();
97+
echo "Static test route";
98+
if (isset($_GET['memory'])) {
99+
echo "\nMemory: " . round($memory_peak / 1024, 2) . " KB";
100+
}
101+
});
102+
103+
Flight::route('GET /test-param/@id', function ($id) {
104+
$memory_start = memory_get_usage();
105+
$memory_peak = memory_get_peak_usage();
106+
echo "Param test route: {$id}";
107+
if (isset($_GET['memory'])) {
108+
echo "\nMemory: " . round($memory_peak / 1024, 2) . " KB";
109+
}
110+
});
111+
112+
Flight::route('GET /test-complex/@category/@slug', function ($category, $slug) {
113+
$memory_start = memory_get_usage();
114+
$memory_peak = memory_get_peak_usage();
115+
echo "Complex test route: {$category}/{$slug}";
116+
if (isset($_GET['memory'])) {
117+
echo "\nMemory: " . round($memory_peak / 1024, 2) . " KB";
118+
}
119+
});
120+
Flight::start();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/bin/bash
2+
3+
# Allow URL to be set via environment variable or first command-line argument, default to localhost for safety
4+
URL="${URL:-${1:-http://localhost:8080/test-static}}"
5+
REQUESTS=1000
6+
CONCURRENCY=10
7+
ITERATIONS=10
8+
9+
declare -a times=()
10+
total=0
11+
12+
echo "Benchmarking: $URL"
13+
echo "Requests per test: $REQUESTS"
14+
echo "Concurrency: $CONCURRENCY"
15+
echo "Iterations: $ITERATIONS"
16+
echo "========================================"
17+
18+
# First, get a baseline memory reading
19+
echo "Getting memory baseline..."
20+
memory_response=$(curl -s "${URL}?memory=1")
21+
baseline_memory=$(echo "$memory_response" | grep "Memory:" | awk '{print $2}')
22+
echo "Baseline memory usage: ${baseline_memory} KB"
23+
echo "----------------------------------------"
24+
25+
for i in $(seq 1 $ITERATIONS); do
26+
printf "Run %2d/%d: " $i $ITERATIONS
27+
28+
# Run ab and extract time per request
29+
result=$(ab -n $REQUESTS -c $CONCURRENCY $URL 2>/dev/null)
30+
time_per_request=$(echo "$result" | grep "Time per request:" | head -1 | awk '{print $4}')
31+
requests_per_sec=$(echo "$result" | grep "Requests per second:" | awk '{print $4}')
32+
33+
times+=($time_per_request)
34+
total=$(echo "$total + $time_per_request" | bc -l)
35+
36+
printf "%.3f ms (%.2f req/s)\n" $time_per_request $requests_per_sec
37+
done
38+
39+
# Calculate statistics
40+
average=$(echo "scale=3; $total / $ITERATIONS" | bc -l)
41+
42+
# Find min and max
43+
min=${times[0]}
44+
max=${times[0]}
45+
for time in "${times[@]}"; do
46+
if (( $(echo "$time < $min" | bc -l) )); then
47+
min=$time
48+
fi
49+
if (( $(echo "$time > $max" | bc -l) )); then
50+
max=$time
51+
fi
52+
done
53+
54+
echo "========================================"
55+
echo "Results:"
56+
echo "Average Time per Request: $average ms"
57+
echo "Min Time per Request: $min ms"
58+
echo "Max Time per Request: $max ms"
59+
echo "Range: $(echo "scale=3; $max - $min" | bc -l) ms"
60+
echo "Baseline Memory Usage: ${baseline_memory} KB"
61+
62+
# Get final memory reading after stress test
63+
echo "----------------------------------------"
64+
echo "Getting post-test memory reading..."
65+
final_memory_response=$(curl -s "${URL}?memory=1")
66+
final_memory=$(echo "$final_memory_response" | grep "Memory:" | awk '{print $2}')
67+
echo "Final memory usage: ${final_memory} KB"

0 commit comments

Comments
 (0)