Skip to content

Commit f577ec9

Browse files
committed
Add path caching and optimization in Azu router
- Introduced a PathCache struct to optimize path building for frequently requested routes, implementing an LRU caching mechanism. - Enhanced the router's path method to utilize caching for improved performance, reducing redundant path calculations. - Added tests for path building optimization, WebSocket upgrade handling, and method cache pre-computation to ensure robust functionality. - Implemented a clear_path_cache method for cache management, supporting testing and memory optimization. These changes align with the project's focus on performance and maintainability, enhancing the overall efficiency of the Azu framework.
1 parent 38bb621 commit f577ec9

2 files changed

Lines changed: 331 additions & 6 deletions

File tree

spec/azu/router_spec.cr

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ class TestEndpoint
3737
end
3838
end
3939

40+
struct TestResponse
41+
include Azu::Response
42+
43+
def render
44+
"Hello, World!"
45+
end
46+
end
47+
48+
class SimpleEndpoint
49+
include Azu::Endpoint(Azu::Request, TestResponse)
50+
51+
def call : TestResponse
52+
TestResponse.new
53+
end
54+
end
55+
4056
describe Azu::Router do
4157
describe "route registration" do
4258
it "adds GET endpoint" do
@@ -238,4 +254,218 @@ describe Azu::Router do
238254
router.should be_a(Azu::Router)
239255
end
240256
end
257+
258+
describe "path building optimization" do
259+
it "caches commonly requested paths" do
260+
router = Azu::Router.new
261+
endpoint = SimpleEndpoint.new
262+
router.get("/hello", endpoint)
263+
264+
# Create a mock context
265+
request = HTTP::Request.new("GET", "/hello")
266+
io = IO::Memory.new
267+
response = HTTP::Server::Response.new(io)
268+
context = HTTP::Server::Context.new(request, response)
269+
270+
# First call should build and cache the path
271+
router.process(context)
272+
response.close
273+
274+
# Verify the path is cached by checking internal state
275+
# Since path_cache is private, we'll test behavior indirectly
276+
io.rewind
277+
first_response = io.gets_to_end
278+
first_response.should contain("Hello, World!")
279+
280+
# Second call should use cached path
281+
io2 = IO::Memory.new
282+
response2 = HTTP::Server::Response.new(io2)
283+
context2 = HTTP::Server::Context.new(HTTP::Request.new("GET", "/hello"), response2)
284+
285+
router.process(context2)
286+
response2.close
287+
io2.rewind
288+
second_response = io2.gets_to_end
289+
second_response.should contain("Hello, World!")
290+
end
291+
292+
it "handles WebSocket upgrade paths correctly" do
293+
router = Azu::Router.new
294+
295+
# Register a WebSocket route for testing
296+
router.ws("/ws-test", TestChannel)
297+
298+
# Mock WebSocket request
299+
request = HTTP::Request.new("GET", "/ws-test")
300+
request.headers["Upgrade"] = "websocket"
301+
request.headers["Connection"] = "Upgrade"
302+
303+
io = IO::Memory.new
304+
response = HTTP::Server::Response.new(io)
305+
context = HTTP::Server::Context.new(request, response)
306+
307+
# Should handle WebSocket path building without errors
308+
result = router.process(context)
309+
# WebSocket routes may return nil or empty string depending on implementation
310+
(result.nil? || result == "").should be_true
311+
end
312+
313+
it "pre-computes method cache at initialization" do
314+
router = Azu::Router.new
315+
316+
# Test that common HTTP methods work correctly
317+
endpoint = SimpleEndpoint.new
318+
319+
# Test various HTTP methods
320+
%w(GET POST PUT PATCH DELETE).each do |method|
321+
router.add("/test-#{method.downcase}", endpoint, Azu::Method.parse(method.downcase))
322+
323+
request = HTTP::Request.new(method, "/test-#{method.downcase}")
324+
io = IO::Memory.new
325+
response = HTTP::Server::Response.new(io)
326+
context = HTTP::Server::Context.new(request, response)
327+
328+
result = router.process(context)
329+
result.should be_a(String)
330+
end
331+
end
332+
333+
it "handles path normalization correctly" do
334+
router = Azu::Router.new
335+
endpoint = SimpleEndpoint.new
336+
router.get("/test", endpoint)
337+
338+
# Test path with trailing slash
339+
request = HTTP::Request.new("GET", "/test/")
340+
io = IO::Memory.new
341+
response = HTTP::Server::Response.new(io)
342+
context = HTTP::Server::Context.new(request, response)
343+
344+
router.process(context)
345+
response.close
346+
io.rewind
347+
result = io.gets_to_end
348+
result.should contain("Hello, World!")
349+
end
350+
351+
it "clears path cache when requested" do
352+
router = Azu::Router.new
353+
endpoint = SimpleEndpoint.new
354+
router.get("/cached-test", endpoint)
355+
356+
# Make a request to populate cache
357+
request = HTTP::Request.new("GET", "/cached-test")
358+
io = IO::Memory.new
359+
response = HTTP::Server::Response.new(io)
360+
context = HTTP::Server::Context.new(request, response)
361+
362+
router.process(context)
363+
364+
# Clear the cache
365+
router.clear_path_cache
366+
367+
# Make another request - should still work
368+
io2 = IO::Memory.new
369+
response2 = HTTP::Server::Response.new(io2)
370+
context2 = HTTP::Server::Context.new(HTTP::Request.new("GET", "/cached-test"), response2)
371+
372+
router.process(context2)
373+
response2.close
374+
io2.rewind
375+
result = io2.gets_to_end
376+
result.should contain("Hello, World!")
377+
end
378+
379+
it "handles LRU cache eviction properly" do
380+
# This test would need access to internal cache size to be fully effective
381+
# For now, we'll test that the router continues to work with many requests
382+
router = Azu::Router.new
383+
endpoint = SimpleEndpoint.new
384+
385+
# Add many routes
386+
(1..1200).each do |i|
387+
router.get("/test#{i}", endpoint)
388+
end
389+
390+
# Make requests to exceed cache size (default 1000)
391+
(1..1200).each do |i|
392+
request = HTTP::Request.new("GET", "/test#{i}")
393+
io = IO::Memory.new
394+
response = HTTP::Server::Response.new(io)
395+
context = HTTP::Server::Context.new(request, response)
396+
397+
result = router.process(context)
398+
result.should be_a(String)
399+
end
400+
end
401+
end
402+
403+
describe "original functionality" do
404+
it "handles GET requests" do
405+
router = Azu::Router.new
406+
endpoint = SimpleEndpoint.new
407+
router.get("/hello", endpoint)
408+
409+
request = HTTP::Request.new("GET", "/hello")
410+
io = IO::Memory.new
411+
response = HTTP::Server::Response.new(io)
412+
context = HTTP::Server::Context.new(request, response)
413+
414+
router.process(context)
415+
response.close
416+
io.rewind
417+
result = io.gets_to_end
418+
result.should contain("Hello, World!")
419+
end
420+
421+
it "handles POST requests" do
422+
router = Azu::Router.new
423+
endpoint = SimpleEndpoint.new
424+
router.post("/data", endpoint)
425+
426+
request = HTTP::Request.new("POST", "/data")
427+
io = IO::Memory.new
428+
response = HTTP::Server::Response.new(io)
429+
context = HTTP::Server::Context.new(request, response)
430+
431+
router.process(context)
432+
response.close
433+
io.rewind
434+
result = io.gets_to_end
435+
result.should contain("Hello, World!")
436+
end
437+
438+
it "handles method override" do
439+
router = Azu::Router.new
440+
endpoint = SimpleEndpoint.new
441+
router.put("/update", endpoint)
442+
443+
request = HTTP::Request.new("POST", "/update?_method=PUT")
444+
io = IO::Memory.new
445+
response = HTTP::Server::Response.new(io)
446+
context = HTTP::Server::Context.new(request, response)
447+
448+
router.process(context)
449+
response.close
450+
io.rewind
451+
result = io.gets_to_end
452+
result.should contain("Hello, World!")
453+
end
454+
455+
it "handles unknown routes gracefully" do
456+
router = Azu::Router.new
457+
458+
request = HTTP::Request.new("GET", "/unknown")
459+
io = IO::Memory.new
460+
response = HTTP::Server::Response.new(io)
461+
context = HTTP::Server::Context.new(request, response)
462+
463+
# Should not raise exception, returns nil for 404s
464+
result = router.process(context)
465+
result.should be_nil
466+
467+
# Check that the response status is set to 404
468+
response.status_code.should eq(404)
469+
end
470+
end
241471
end

src/azu/router.cr

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,55 @@ module Azu
4040
RESOURCES = %w(connect delete get head options patch post put trace)
4141
METHOD_OVERRIDE = "_method"
4242

43+
# Path cache for frequently requested paths
44+
# LRU cache with configurable maximum size
45+
private struct PathCache
46+
DEFAULT_MAX_SIZE = 1000
47+
48+
def initialize(@max_size : Int32 = DEFAULT_MAX_SIZE)
49+
@cache = Hash(String, String).new
50+
@access_order = Array(String).new
51+
end
52+
53+
def get(key : String) : String?
54+
if cached_path = @cache[key]?
55+
# Move to end (most recently used)
56+
@access_order.delete(key)
57+
@access_order << key
58+
cached_path
59+
end
60+
end
61+
62+
def set(key : String, value : String) : Nil
63+
# Remove if already exists to update position
64+
if @cache.has_key?(key)
65+
@access_order.delete(key)
66+
elsif @cache.size >= @max_size
67+
# Remove least recently used
68+
if oldest = @access_order.shift?
69+
@cache.delete(oldest)
70+
end
71+
end
72+
73+
@cache[key] = value
74+
@access_order << key
75+
end
76+
77+
def clear : Nil
78+
@cache.clear
79+
@access_order.clear
80+
end
81+
end
82+
4383
getter radix : Radix::Tree(Route)
84+
private getter path_cache : PathCache
85+
private getter method_cache : Hash(String, String)
4486

4587
def initialize
4688
@radix = Radix::Tree(Route).new
89+
@path_cache = PathCache.new
90+
@method_cache = Hash(String, String).new
91+
precompute_method_cache
4792
end
4893

4994
record Route,
@@ -140,14 +185,59 @@ module Azu
140185
raise DuplicateRoute.new("http_method: #{method}, path: #{path}, endpoint: #{endpoint}")
141186
end
142187

143-
private def path(context)
188+
# Pre-compute method cache at startup to avoid repeated downcasing
189+
private def precompute_method_cache : Nil
190+
RESOURCES.each do |method|
191+
@method_cache[method.upcase] = method.downcase
192+
end
193+
194+
# Add common HTTP methods that might not be in RESOURCES
195+
%w(HEAD OPTIONS).each do |method|
196+
@method_cache[method] = method.downcase
197+
end
198+
end
199+
200+
# Optimized path building with caching and efficient string operations
201+
private def path(context) : String
202+
request = context.request
203+
method_str = request.method
204+
path_str = request.path
144205
upgraded = upgrade?(context)
145-
String.build do |str|
146-
str << "/"
147-
str << "ws" if upgraded
148-
str << context.request.method.downcase unless upgraded
149-
str << context.request.path.rstrip('/')
206+
207+
# Create cache key for this specific request combination
208+
cache_key = if upgraded
209+
"ws:#{path_str}"
210+
else
211+
"#{method_str}:#{path_str}"
212+
end
213+
214+
# Check cache first
215+
if cached_path = @path_cache.get(cache_key)
216+
return cached_path
217+
end
218+
219+
# Build path efficiently using pre-allocated capacity
220+
built_path = if upgraded
221+
# WebSocket path: "/ws" + normalized_path
222+
normalized_path = path_str.rstrip('/')
223+
String.build(capacity: 4 + normalized_path.bytesize) do |str|
224+
str << "/ws"
225+
str << normalized_path
226+
end
227+
else
228+
# HTTP path: "/" + method + normalized_path
229+
normalized_path = path_str.rstrip('/')
230+
method_lower = @method_cache[method_str]? || method_str.downcase
231+
String.build(capacity: 1 + method_lower.bytesize + normalized_path.bytesize) do |str|
232+
str << "/"
233+
str << method_lower
234+
str << normalized_path
235+
end
150236
end
237+
238+
# Cache the result for future requests
239+
@path_cache.set(cache_key, built_path)
240+
built_path
151241
end
152242

153243
private def method_override(context)
@@ -161,5 +251,10 @@ module Azu
161251
return unless upgrade.compare("websocket", case_insensitive: true) == 0
162252
context.request.headers.includes_word?("Connection", "Upgrade")
163253
end
254+
255+
# Clear path cache (useful for testing or memory management)
256+
def clear_path_cache : Nil
257+
@path_cache.clear
258+
end
164259
end
165260
end

0 commit comments

Comments
 (0)