A high-performance radix tree (compressed trie) implementation for Crystal, optimized for speed and memory efficiency.
- High Performance: Optimized hot paths with 30-50% faster search operations
- Memory Efficient: 40-60% less memory allocation through smart caching
- Type Safe: Full Crystal type safety with generic payload support
- Flexible Matching: Named parameters (
:id), wildcards (*), and optional segments - Multiple Results: Support for multiple payloads and constraint-based matching
- Production Ready: Battle-tested in the Orion router
Oak is heavily optimized for router use cases with several advanced techniques:
- First-character caching: O(1) character lookups instead of repeated string indexing
- HashMap child lookup: Automatic O(1) lookups for nodes with many children (>10)
- Inline hot methods: Critical methods marked for compiler inlining
- Smart memory management: Eliminated unnecessary cloning in single-match searches
- Unsafe optimizations: Zero-copy byte slicing where safety is guaranteed
See PERFORMANCE.md for detailed benchmarks and optimization details.
Add this to your application's shard.yml:
dependencies:
oak:
github: obsidian/oakrequire "oak"
# Create a tree with Symbol payloads
tree = Oak::Tree(Symbol).new
# Add routes
tree.add "/products", :list_products
tree.add "/products/:id", :show_product
tree.add "/products/:id/reviews", :product_reviews
tree.add "/search/*query", :search
# Find a route (returns first match)
result = tree.find "/products/123"
if result.found?
puts result.payload # => :show_product
puts result.params["id"] # => "123"
puts result.key # => "/products/:id"
end
# Search for all matching routes
results = tree.search "/products/123"
results.each do |result|
puts result.payload
endThe payload type is defined when creating the tree:
# Single type
tree = Oak::Tree(Symbol).new
tree.add "/", :root
# Union types for flexibility
tree = Oak::Tree(Int32 | String | Symbol).new
tree.add "/", :root
tree.add "/answer", 42
tree.add "/greeting", "Hello, World!"
# Custom types
struct Route
getter handler : Proc(String)
getter middleware : Array(Proc(String))
end
tree = Oak::Tree(Route).new
tree.add "/users", Route.new(...)tree.add "/products", :products
tree.add "/about/team", :teamExtract dynamic segments from the path:
tree.add "/users/:id", :user
tree.add "/posts/:year/:month/:slug", :post
result = tree.find "/users/42"
result.params["id"] # => "42"
result = tree.find "/posts/2024/03/hello-world"
result.params["year"] # => "2024"
result.params["month"] # => "03"
result.params["slug"] # => "hello-world"Capture remaining path segments:
tree.add "/search/*query", :search
tree.add "/files/*path", :serve_file
result = tree.find "/search/crystal/radix/tree"
result.params["query"] # => "crystal/radix/tree"
result = tree.find "/files/docs/api/index.html"
result.params["path"] # => "docs/api/index.html"Use parentheses for optional path segments:
tree.add "/products(/free)/:id", :product
# Both paths match the same route
tree.find("/products/123").found? # => true
tree.find("/products/free/123").found? # => true
# Both return the same payload
tree.find("/products/123").payload # => :product
tree.find("/products/free/123").payload # => :productAdd a path and its associated payload to the tree.
tree.add "/users/:id", :show_userFind the first matching result for a path. Optimized for single-match lookups.
result = tree.find "/users/123"
if result.found?
result.payload # First matching payload
result.params # Hash of extracted parameters
result.key # Matched pattern (e.g., "/users/:id")
endSearch for all matching results.
results = tree.search "/users/123"
results.each do |result|
puts result.payload
endSearch with a block for efficient iteration without allocating an array:
tree.search("/users/123") do |result|
# Process each result
break if found_what_we_need
endReturns a visual representation of the tree structure for debugging:
puts tree.visualize
# ⌙
# ⌙ /products (payloads: 1)
# ⌙ /:id (payloads: 1)
# ⌙ /reviews (payloads: 1)Returns true if the search found matching payloads.
Returns the first matching payload. Raises if not found.
Returns the first matching payload or nil.
Returns all matching payloads (useful when multiple handlers exist for one path).
Hash of extracted parameters from the path.
The full matched pattern (e.g., /users/:id/posts/:post_id).
Oak supports multiple payloads at the same path for constraint-based routing:
tree.add "/users/:id", Route.new(constraints: {id: /\d+/})
tree.add "/users/:id", Route.new(constraints: {id: /\w+/})
# Use .payloads to access all matches
results = tree.search "/users/123"
matching = results.first.payloads.find { |route| route.matches?(request) }Efficiently find routes with constraints without allocating intermediate arrays:
tree.search(path) do |result|
if route = result.payloads.find(&.matches_constraints?(request))
route.call(context)
break
end
endTwo different named parameters cannot share the same level in the tree:
tree.add "/", :root
tree.add "/:post", :post
tree.add "/:category/:post", :category_post # => Oak::SharedKeyErrorWhy? Different named parameters at the same level would result in ambiguous parameter extraction. The value for :post or :category would be unpredictable.
Solution: Use explicit path segments to differentiate routes:
tree.add "/", :root
tree.add "/:post", :post # Post permalink
tree.add "/categories", :categories # Category list
tree.add "/categories/:category", :category # Posts under categoryThis follows good SEO practices and provides unambiguous routing.
Oak uses a compressed radix tree (also known as a Patricia trie) where nodes represent path segments. The tree structure allows for O(k) lookup time where k is the length of the path.
- Priority-based sorting: Static routes are checked before dynamic ones
- First-character indexing: O(1) child lookup using cached first character
- Automatic HashMap: Switches to hash-based lookup for nodes with >10 children
- Zero-copy operations: Uses
unsafe_byte_slicefor substring operations - Inline hot paths: Critical methods marked with
@[AlwaysInline] - Smart cloning: Eliminates unnecessary result cloning in
find()operations
Run the included benchmark suite:
crystal run --release benchmarkTypical results (compared to other Crystal radix tree implementations):
- 30-50% faster on deep path searches
- 40-60% less memory allocation
- 20-30% better throughput under concurrent load
See PERFORMANCE.md for detailed performance analysis.
- Support multiple payloads at the same level in the tree
- Return multiple matches when searching the tree
- Support optional segments in the path
- Optimize for high-performance routing
- Overcome shared key caveat
- Support for route priorities
This project was inspired by and adapted from luislavena/radix, with significant performance enhancements and additional features for production use.
- Fork it ( https://github.com/obsidian/oak/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
- Jason Waldrip - creator, maintainer