Skip to content

Commit ea29738

Browse files
authored
feat(router)!: reboot router core with method-aware registration (#16)
* feat(router)!: reboot roux with immutable path-only API - replace legacy mutable method-based router internals with Router(routes) + match(path) - implement cursor-scanning lookup path and param stack in new matcher core - add fresh router test suite and new benchmark scripts (compare + relic_compare) - refresh README and example for the new API surface BREAKING CHANGE: removes createRouter/addRoute/findRoute/findAllRoutes/removeRoute APIs and method-based matching; the new public API is Router<T>(routes: {...}) with match(String path). * perf(router): optimize match hot path with static fast lookup * perf(router): lazy materialize params for dynamic matches * perf(router): reduce route-build allocations in compile * chore: use map literal comprehensions for routes Replace manual map construction and for-loop population with a map literal using a collection-for in bench/relic_compare.dart. Also simplify roux.Router instantiation by passing the routes map directly (relying on type inference). This makes the benchmark code more concise and idiomatic. * perf(router): specialize static route compilation and cache-free build * perf(router): fuse pattern normalization with compile scan * feat(router): add incremental route registration and single-add benchmark * feat(router): support method-aware route registration and matching
1 parent d1f3d90 commit ea29738

24 files changed

+1785
-1394
lines changed

README.md

Lines changed: 35 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
[![Pub Version](https://img.shields.io/pub/v/roux?logo=dart)](https://pub.dev/packages/roux)
44
[![Test](https://github.com/medz/roux/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/medz/roux/actions/workflows/test.yml)
55

6-
Lightweight, fast router for Dart with static, parameterized, and wildcard route
7-
matching.
6+
Lightweight, fast router for Dart with static, parameterized, and
7+
wildcard path matching.
88

99
## Install
1010

@@ -23,71 +23,56 @@ flutter pub add roux
2323
```dart
2424
import 'package:roux/roux.dart';
2525
26-
final router = createRouter<String>();
27-
28-
addRoute(router, 'GET', '/users', 'users');
29-
addRoute(router, 'GET', '/users/:id', 'user');
30-
addRoute(router, 'POST', '/users', 'create-user');
31-
addRoute(router, null, '/health', 'any-method');
26+
final router = Router<String>(
27+
routes: {
28+
'/': 'root',
29+
'/users/all': 'users-all',
30+
'/users/:id': 'users-id',
31+
'/users/*': 'users-wildcard',
32+
'/*': 'global-fallback',
33+
},
34+
);
3235
33-
final match = findRoute(router, 'GET', '/users/123');
34-
print(match?.data); // user
36+
final match = router.match('/users/123');
37+
print(match?.data); // users-id
3538
print(match?.params); // {id: 123}
36-
37-
final all = findAllRoutes(router, 'GET', '/users/123');
38-
for (final m in all) {
39-
print(m.data);
40-
}
4139
```
4240

4341
## Route Syntax
4442

4543
- Static: `/users`
4644
- Named param: `/users/:id`
47-
- Embedded params: `/files/:name.:ext`
48-
- Single-segment wildcard: `*` (unnamed, captured as `_0`, `_1`, ...)
49-
- Multi-segment wildcard: `**` (unnamed, captured as `_`) or `**:path` (named)
50-
- `**` can match an empty remainder; `**:name` requires at least one segment.
51-
- Escape literal tokens with `\\`: `/static\\:path/\\*/\\*\\*` matches
52-
`/static%3Apath/*/**`
53-
- Paths are normalized to start with `/`.
54-
- Methods are case-insensitive. `null` uses the any-method token.
45+
- Wildcard tail: `/users/*`
46+
- Global fallback: `/*`
47+
48+
Notes:
49+
- Paths must start with `/`.
50+
- `*` is only allowed as the final segment.
51+
- Embedded syntax like `/files/:name.:ext` is intentionally unsupported.
52+
- Matching is case-sensitive.
53+
- Trailing slash on input is ignored (`/users` equals `/users/`).
54+
- You can register routes via constructor (`Router(routes: {...})`) or
55+
incrementally (`add` / `addAll`).
5556

5657
## Matching Order
5758

58-
- Static > param > wildcard.
59-
- Method matching tries the requested method first, then the any-method token.
60-
61-
## Options
62-
63-
```dart
64-
final router = createRouter<String>(
65-
caseSensitive: false,
66-
anyMethodToken: 'any',
67-
);
68-
```
69-
70-
## Examples
71-
72-
### Any-method route
59+
1. Exact route (`/users/all`)
60+
2. Parameter route (`/users/:id`)
61+
3. Wildcard route (`/users/*`)
62+
4. Global fallback (`/*`)
7363

74-
```dart
75-
addRoute(router, null, '/status', 'ok');
76-
```
64+
## Benchmarks
7765

78-
### Find all matches
66+
Relic-style comparison benchmark:
7967

80-
```dart
81-
final matches = findAllRoutes(router, 'GET', '/files/report.pdf');
82-
for (final m in matches) {
83-
print(m.data);
84-
}
68+
```bash
69+
dart run bench/relic_compare.dart 500
8570
```
8671

87-
### Convert a pattern to RegExp
72+
Lookup scenario matrix benchmark:
8873

89-
```dart
90-
final re = routeToRegExp('/users/:id');
74+
```bash
75+
dart run bench/compare.dart 500
9176
```
9277

9378
## License

bench/add_single_compare.dart

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import 'package:benchmark_harness/perf_benchmark_harness.dart';
2+
import 'package:relic/relic.dart' as relic;
3+
import 'package:roux/roux.dart' as roux;
4+
import 'package:routingkit/routingkit.dart' as routingkit;
5+
6+
const _sampleCount = 1024;
7+
const _defaultStaticValue = 1;
8+
const _defaultDynamicValue = 2;
9+
10+
final _staticPatterns = List<String>.generate(
11+
_sampleCount,
12+
(i) => '/users/all_$i',
13+
growable: false,
14+
);
15+
final _dynamicPatterns = List<String>.generate(
16+
_sampleCount,
17+
(i) => '/users/:id/orders/:orderId/item$i',
18+
growable: false,
19+
);
20+
final _dynamicLookups = List<String>.generate(
21+
_sampleCount,
22+
(i) => '/users/user_$i/orders/order_$i/item$i',
23+
growable: false,
24+
);
25+
26+
var _sink = 0;
27+
var _cursor = 0;
28+
29+
int _nextIndex() {
30+
final index = _cursor;
31+
_cursor = (_cursor + 1) & (_sampleCount - 1);
32+
return index;
33+
}
34+
35+
class _CollectingEmitter extends ScoreEmitterV2 {
36+
final Map<String, double> runtimeByName = <String, double>{};
37+
38+
@override
39+
void emit(
40+
String testName,
41+
double value, {
42+
String metric = 'RunTime',
43+
String unit = 'us',
44+
}) {
45+
if (metric == 'RunTime') {
46+
runtimeByName[testName] = value;
47+
}
48+
print([testName, metric, value.toStringAsFixed(1), unit].join(';'));
49+
}
50+
}
51+
52+
abstract class _SingleAddBenchmark extends PerfBenchmarkBase {
53+
_SingleAddBenchmark(Iterable<String> grouping, _CollectingEmitter emitter)
54+
: super(grouping.join(';'), emitter: emitter);
55+
56+
@override
57+
void exercise() => run();
58+
}
59+
60+
class _StaticSingleAddRouxBenchmark extends _SingleAddBenchmark {
61+
_StaticSingleAddRouxBenchmark(_CollectingEmitter emitter)
62+
: super(['AddSingle', 'Static', 'Roux'], emitter);
63+
64+
@override
65+
void run() {
66+
final index = _nextIndex();
67+
final route = _staticPatterns[index];
68+
final router = roux.Router<int>();
69+
router.add(route, _defaultStaticValue);
70+
_sink ^= router.match(route)?.data ?? 0;
71+
}
72+
}
73+
74+
class _DynamicSingleAddRouxBenchmark extends _SingleAddBenchmark {
75+
_DynamicSingleAddRouxBenchmark(_CollectingEmitter emitter)
76+
: super(['AddSingle', 'Dynamic', 'Roux'], emitter);
77+
78+
@override
79+
void run() {
80+
final index = _nextIndex();
81+
final route = _dynamicPatterns[index];
82+
final lookup = _dynamicLookups[index];
83+
final router = roux.Router<int>();
84+
router.add(route, _defaultDynamicValue);
85+
_sink ^= router.match(lookup)?.data ?? 0;
86+
}
87+
}
88+
89+
class _StaticSingleAddRoutingkitBenchmark extends _SingleAddBenchmark {
90+
_StaticSingleAddRoutingkitBenchmark(_CollectingEmitter emitter)
91+
: super(['AddSingle', 'Static', 'Routingkit'], emitter);
92+
93+
@override
94+
void run() {
95+
final index = _nextIndex();
96+
final route = _staticPatterns[index];
97+
final router = routingkit.createRouter<int>();
98+
router.add('GET', route, _defaultStaticValue);
99+
_sink ^= router.find('GET', route)?.data ?? 0;
100+
}
101+
}
102+
103+
class _DynamicSingleAddRoutingkitBenchmark extends _SingleAddBenchmark {
104+
_DynamicSingleAddRoutingkitBenchmark(_CollectingEmitter emitter)
105+
: super(['AddSingle', 'Dynamic', 'Routingkit'], emitter);
106+
107+
@override
108+
void run() {
109+
final index = _nextIndex();
110+
final route = _dynamicPatterns[index];
111+
final lookup = _dynamicLookups[index];
112+
final router = routingkit.createRouter<int>();
113+
router.add('GET', route, _defaultDynamicValue);
114+
_sink ^= router.find('GET', lookup)?.data ?? 0;
115+
}
116+
}
117+
118+
class _StaticSingleAddRelicBenchmark extends _SingleAddBenchmark {
119+
_StaticSingleAddRelicBenchmark(_CollectingEmitter emitter)
120+
: super(['AddSingle', 'Static', 'Relic'], emitter);
121+
122+
@override
123+
void run() {
124+
final index = _nextIndex();
125+
final route = _staticPatterns[index];
126+
final router = relic.Router<int>();
127+
router.get(route, _defaultStaticValue);
128+
_sink ^= router.lookup(relic.Method.get, route).asMatch.value;
129+
}
130+
}
131+
132+
class _DynamicSingleAddRelicBenchmark extends _SingleAddBenchmark {
133+
_DynamicSingleAddRelicBenchmark(_CollectingEmitter emitter)
134+
: super(['AddSingle', 'Dynamic', 'Relic'], emitter);
135+
136+
@override
137+
void run() {
138+
final index = _nextIndex();
139+
final route = _dynamicPatterns[index];
140+
final lookup = _dynamicLookups[index];
141+
final router = relic.Router<int>();
142+
router.get(route, _defaultDynamicValue);
143+
_sink ^= router.lookup(relic.Method.get, lookup).asMatch.value;
144+
}
145+
}
146+
147+
void main() {
148+
print('single add benchmark (benchmark_harness/perf_benchmark_harness)');
149+
print('scenario: create empty router, add one route, verify by one lookup');
150+
print(
151+
'samples=$_sampleCount (pre-generated patterns to avoid constant folding)',
152+
);
153+
print('format=test;metric;value;unit');
154+
print('lower is better (us)');
155+
156+
final emitter = _CollectingEmitter();
157+
for (final benchmark in <_SingleAddBenchmark>[
158+
_StaticSingleAddRoutingkitBenchmark(emitter),
159+
_StaticSingleAddRelicBenchmark(emitter),
160+
_StaticSingleAddRouxBenchmark(emitter),
161+
_DynamicSingleAddRoutingkitBenchmark(emitter),
162+
_DynamicSingleAddRelicBenchmark(emitter),
163+
_DynamicSingleAddRouxBenchmark(emitter),
164+
]) {
165+
benchmark.report();
166+
}
167+
168+
_printRelative(
169+
emitter.runtimeByName,
170+
baseline: 'Routingkit',
171+
title: 'routingkit / roux',
172+
);
173+
_printRelative(
174+
emitter.runtimeByName,
175+
baseline: 'Relic',
176+
title: 'relic / roux',
177+
);
178+
print('sink=$_sink');
179+
}
180+
181+
void _printRelative(
182+
Map<String, double> results, {
183+
required String baseline,
184+
required String title,
185+
}) {
186+
final keyStaticBase = 'AddSingle;Static;$baseline';
187+
final keyStaticRoux = 'AddSingle;Static;Roux';
188+
final keyDynamicBase = 'AddSingle;Dynamic;$baseline';
189+
final keyDynamicRoux = 'AddSingle;Dynamic;Roux';
190+
191+
final staticRatio = results[keyStaticBase]! / results[keyStaticRoux]!;
192+
final dynamicRatio = results[keyDynamicBase]! / results[keyDynamicRoux]!;
193+
194+
print('\nrelative ($title, >1 means roux is faster)');
195+
print('single add static ${staticRatio.toStringAsFixed(2)}x');
196+
print('single add dynamic ${dynamicRatio.toStringAsFixed(2)}x');
197+
}

0 commit comments

Comments
 (0)