Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
## 1.15.0

- Faster request dispatch. A logged route call is **~2.9x** faster
(measured in-process, `APIRoot.call` on a trivial route: 2.76us -> 0.90us).

- `LoggerHandler` no longer builds the formatted log message when nothing
would consume it. Every record reaching the root listener was fully
formatted — timestamp, padded/truncated isolate and logger names, plus a
`Zone` lookup for the current `APIRequest` id — and then discarded when no
destination (`logAllTo`/`logErrorTo`/`logDbTo`/console) was configured,
which is the default. This was ~1us per record, and a route call emits two
(`CALL>` and `RESPONSE>`).
- `APIRouteHandler` caches its `CALL>` message and `RESPONSE>` prefix. Both
are fixed once a route is registered, but were re-interpolated per request
(including stringifying the declared `parameters` `Map`).
- `APIRoot._callImpl` no longer copies the path parts list just to read the
first one.
- `APIServer.toAPIRequest` no longer copies the query-parameters `Map` a
second time.

- The `routes` builder now accepts `config:` on `any`/`get`/`post`/`put`/
`delete`/`patch`/`head`, matching `APIModule.addRoute`. Previously an
`APIRouteConfig` could only be set through `addRoute`, so per-route logging
could not be turned off through the usual API:

```dart
routes.get('ping', handler, config: const APIRouteConfig(log: false));
```

Route logging is on by default and costs roughly 4x the rest of a trivial
dispatch, so this is worth setting on hot routes.

- New `benchmark/` suite covering the request path, with a layered breakdown so
a regression can be attributed rather than just observed. See
`benchmark/README.md`.

```
dart run benchmark/bones_api_benchmark.dart
```

## 1.14.0

- New `DBSQLiteAdapter`: an embedded SQLite DB adapter, backed by the
Expand Down
57 changes: 57 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Benchmarks

In-process benchmarks for the request path: building an `APIRequest`, resolving
the module/route, dispatching through `APIRoot.call`, and serializing the
response payload. No socket and no HTTP client are involved, so what is
measured is the framework's own overhead.

```bash
dart run benchmark/bones_api_benchmark.dart
```

To compare a change, record a baseline on your machine first — throughput is
hardware- and load-specific, so numbers are only meaningful relative to a run
on the same machine:

```bash
# 1. before the change
dart run benchmark/bones_api_benchmark.dart --emit-baseline

# 2. paste the emitted map into `_baseline` in bones_api_benchmark.dart

# 3. after the change
dart run benchmark/bones_api_benchmark.dart
```

The report then gains a `VS BASE` column.

## Reading the results

The suite is deliberately layered, so a regression can be attributed instead of
just observed at the top:

| Layer | What it isolates |
|---|---|
| `APIRequest.get: *` | Path splitting, parameters, `requestedUri` |
| `APIRequest.pathParts` / `pathPart(0)` | Path accessors used on every dispatch |
| `APIRoot.getModuleByRequest`, `APIModule.getRouteHandlerByRequest` | Routing table lookups |
| `APIResponse.ok(String)` | Response construction |
| `APIRouteHandler.call (direct)` | A route call without the module/root layers |
| `APIModule.call (direct)` | Adds module resolution and security checks |
| `APIRoot.call: *` | Full dispatch, including the per-call `Zone` |

Each `APIRoot.call` benchmark builds a fresh `APIRequest`, so subtract the
matching `APIRequest.get` cost to isolate the dispatch itself.

## Note on route logging

`APIRouteConfig.log` defaults to `true`, and the `CALL>` / `RESPONSE>` records
it emits are a large share of the cost of an otherwise trivial route. The suite
measures both, as `APIRoot.call: ping (empty payload)` and
`APIRoot.call: ping [route log off]`, so the trade-off stays visible.

Disable it per route when throughput matters more than the audit trail:

```dart
routes.get('ping', handler, config: const APIRouteConfig(log: false));
```
211 changes: 211 additions & 0 deletions benchmark/bones_api_benchmark.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import 'dart:convert' as dart_convert;
import 'dart:io';

import 'package:bones_api/bones_api.dart';

import 'src/bench_runner.dart';

/// Benchmarks for the request path: building an [APIRequest], resolving the
/// module/route, dispatching through [APIRoot.call] and serializing the
/// response payload.
///
/// Run with:
/// ```
/// dart run benchmark/bones_api_benchmark.dart
/// ```
///
/// These are all in-process: they measure the framework's own overhead,
/// without a socket or an HTTP client in the way.
Future<void> main(List<String> args) async {
var api = BenchmarkAPI();
await api.ensureInitialized();

var runner = BenchRunner();

// ---------------------------------------------------------------------
// `APIRequest` construction: path splitting, parameters, `requestedUri`.
// ---------------------------------------------------------------------

runner.run(
'APIRequest.get: simple path',
() => APIRequest.get('/bench/ping'),
);

runner.run(
'APIRequest.get: deep path',
() => APIRequest.get('/bench/a/b/c/d/e/f'),
);

runner.run(
'APIRequest.get: with parameters',
() => APIRequest.get(
'/bench/echo',
parameters: {'a': '1', 'b': '2', 'c': '3'},
),
);

// ---------------------------------------------------------------------
// Path accessors, called on every dispatch.
// ---------------------------------------------------------------------

var pathRequest = APIRequest.get('/bench/a/b/c/d/e/f');

runner.run('APIRequest.pathParts', () => pathRequest.pathParts);
runner.run('APIRequest.pathPart(0)', () => pathRequest.pathPart(0));

// ---------------------------------------------------------------------
// Routing: module lookup and route-handler resolution.
// ---------------------------------------------------------------------

var routeRequest = APIRequest.get('/bench/ping');

runner.run(
'APIRoot.getModuleByRequest',
() => api.getModuleByRequest(routeRequest),
);

var module = api.getModuleByRequest(routeRequest)!;

runner.run(
'APIModule.getRouteHandlerByRequest',
() => module.getRouteHandlerByRequest(routeRequest),
);

runner.run('APIRoot.acceptsRequest', () => api.acceptsRequest(routeRequest));

// ---------------------------------------------------------------------
// Dispatch breakdown: each layer measured on its own, so a regression can
// be attributed instead of just observed at the top.
// ---------------------------------------------------------------------

runner.run('APIResponse.ok(String)', () => APIResponse.ok('pong'));

var handler = module.getRouteHandlerByRequest(routeRequest)!;

await runner.runAsync(
'APIRouteHandler.call (direct)',
() => handler.call(APIRequest.get('/bench/ping')),
);

await runner.runAsync(
'APIModule.call (direct)',
() => module.call(APIRequest.get('/bench/ping')),
);

// ---------------------------------------------------------------------
// Full in-process dispatch.
// ---------------------------------------------------------------------

await runner.runAsync(
'APIRoot.call: ping (empty payload)',
() => api.call(APIRequest.get('/bench/ping')),
);

await runner.runAsync(
'APIRoot.call: echo (parameters)',
() => api.call(
APIRequest.get('/bench/echo', parameters: {'a': '1', 'b': '2'}),
),
);

await runner.runAsync(
'APIRoot.call: json (entity payload)',
() => api.call(APIRequest.get('/bench/json')),
);

await runner.runAsync(
'APIRoot.call: 404 (unmatched route)',
() => api.call(APIRequest.get('/bench/nope')),
);

// Route logging is on by default (`APIRouteConfig.log`), and dominates the
// cost of an otherwise trivial route. Measured side by side so the trade-off
// is visible rather than surprising.
await runner.runAsync(
'APIRoot.call: ping [route log off]',
() => api.call(APIRequest.get('/bench/quiet')),
);

// ---------------------------------------------------------------------
// Response payload serialization.
// ---------------------------------------------------------------------

var payload = _samplePayload();
var jsonResponse = APIResponse.ok(payload);

runner.run(
'APIResponse.payload -> JSON',
() => dart_convert.json.encode(jsonResponse.payload),
);

runner.report(baseline: _baseline);

if (args.contains('--emit-baseline')) {
print('const _baseline = <String, double>{');
runner.asBaseline().forEach((k, v) {
print(" '$k': ${v.toStringAsFixed(0)},");
});
print('};');
}

api.close();

// An initialized `APIRoot` keeps the isolate alive (shared stores, log
// queue, timers), so a benchmark run would otherwise hang after reporting.
exit(0);
}

Map<String, dynamic> _samplePayload() => {
'id': 12345,
'name': 'Joe Smith',
'email': 'joe@example.com',
'enabled': true,
'score': 98.6,
'tags': ['alpha', 'beta', 'gamma'],
'address': {
'street': '123 Main St',
'city': 'Springfield',
'state': 'NY',
'zip': '12345',
},
};

/// Reference numbers to compare a run against, shown as a `VS BASE` column.
///
/// Left empty on purpose: throughput is hardware- and load-specific, so a
/// baseline recorded on one machine would only produce misleading deltas on
/// another. To compare a change, record a baseline on *your* machine first:
///
/// ```
/// dart run benchmark/bones_api_benchmark.dart --emit-baseline
/// ```
///
/// then paste the emitted map here, apply the change, and run again.
const _baseline = <String, double>{};

class BenchmarkModule extends APIModule {
BenchmarkModule(APIRoot apiRoot) : super(apiRoot, 'bench');

@override
void configure() {
routes.get('ping', (request) => APIResponse.ok('pong'));

routes.get('echo', (request) => APIResponse.ok(request.parameters));

routes.get('json', (request) => APIResponse.ok(_samplePayload()));

// Same work as `ping`, with the per-route call/response logging disabled.
routes.get(
'quiet',
(request) => APIResponse.ok('pong'),
config: const APIRouteConfig(log: false),
);
}
}

class BenchmarkAPI extends APIRoot {
BenchmarkAPI() : super('benchmark', '1.0');

@override
Set<APIModule> loadModules() => {BenchmarkModule(this)};
}
Loading