Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a serialized variant of the Trie-router #402

Merged
merged 7 commits into from May 6, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .swiftformat
Expand Up @@ -8,7 +8,7 @@
--exclude .build

# rules
--disable redundantReturn, extensionAccessControl, typeSugar
--disable redundantReturn, extensionAccessControl, typeSugar, conditionalAssignment

# format options
--ifdef no-indent
Expand Down
2 changes: 1 addition & 1 deletion Benchmarks/Benchmarks/HTTP1/HTTP1ChannelBenchmarks.swift
Expand Up @@ -104,7 +104,7 @@ let benchmarks = {
try await channel.writeInbound(HTTPRequestPart.body(buffer))
try await channel.writeInbound(HTTPRequestPart.end(nil))
} responder: { request, _ in
let buffer = try await request.body.collate(maxSize: .max)
let buffer = try await request.body.collect(upTo: .max)
return .init(status: .ok, body: .init(byteBuffer: buffer))
}
}
4 changes: 2 additions & 2 deletions Benchmarks/Benchmarks/Router/Benchmarks.swift
Expand Up @@ -13,7 +13,7 @@
//===----------------------------------------------------------------------===//

import Benchmark
@testable import Hummingbird
import Hummingbird

let benchmarks = {
Benchmark.defaultConfiguration = .init(
Expand All @@ -24,6 +24,6 @@ let benchmarks = {
],
warmupIterations: 10
)
trieRouterBenchmarks()
binaryTrieRouterBenchmarks()
routerBenchmarks()
}
Expand Up @@ -13,21 +13,22 @@
//===----------------------------------------------------------------------===//

import Benchmark
@testable import Hummingbird
@_spi(Internal) import Hummingbird

func trieRouterBenchmarks() {
var trie: RouterPathTrie<String>!
Benchmark("TrieRouter", configuration: .init(scalingFactor: .kilo)) { benchmark in
func binaryTrieRouterBenchmarks() {
var trie: BinaryTrie<String>!
Benchmark("BinaryTrieRouter", configuration: .init(scalingFactor: .kilo)) { benchmark in
let testValues = [
"/test/",
"/test/one",
"/test/one/two",
"/doesntExist",
"/api/v1/users/1/profile",
]
benchmark.startMeasurement()

for _ in benchmark.scaledIterations {
blackHole(testValues.map { trie.getValueAndParameters($0) })
blackHole(testValues.map { trie.resolve($0) })
}
} setup: {
let trieBuilder = RouterPathTrieBuilder<String>()
Expand All @@ -36,27 +37,48 @@ func trieRouterBenchmarks() {
trieBuilder.addEntry("/test/one/two", value: "/test/one/two")
trieBuilder.addEntry("/test/:value", value: "/test/:value")
trieBuilder.addEntry("/test/:value/:value2", value: "/test/:value:/:value2")
trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile")
trieBuilder.addEntry("/test2/*/*", value: "/test2/*/*")
trie = trieBuilder.build()
trie = BinaryTrie(base: trieBuilder)
}

var trie2: RouterPathTrie<String>!
Benchmark("TrieRouterParameters", configuration: .init(scalingFactor: .kilo)) { benchmark in
var trie2: BinaryTrie<String>!
Benchmark("BinaryTrieRouterParameters", configuration: .init(scalingFactor: .kilo)) { benchmark in
let testValues = [
"/test/value",
"/test/value1/value2",
"/test2/one/two",
"/api/v1/users/1/profile",
]
benchmark.startMeasurement()

for _ in benchmark.scaledIterations {
blackHole(testValues.map { trie2.getValueAndParameters($0) })
blackHole(testValues.map { trie2.resolve($0) })
}
} setup: {
let trieBuilder = RouterPathTrieBuilder<String>()
trieBuilder.addEntry("/test/:value", value: "/test/:value")
trieBuilder.addEntry("/test/:value/:value2", value: "/test/:value:/:value2")
trieBuilder.addEntry("/test2/*/*", value: "/test2/*/*")
trie2 = trieBuilder.build()
trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile")
trie2 = BinaryTrie(base: trieBuilder)
}

var trie3: BinaryTrie<String>!
Benchmark("BinaryTrie:LongPaths", configuration: .init(scalingFactor: .kilo)) { benchmark in
let testValues = [
"/api/v1/users/1/profile",
"/api/v1/a/very/long/path/with/lots/of/segments",
]
benchmark.startMeasurement()

for _ in benchmark.scaledIterations {
blackHole(testValues.map { trie3.resolve($0) })
}
} setup: {
let trieBuilder = RouterPathTrieBuilder<String>()
trieBuilder.addEntry("/api/v1/a/very/long/path/with/lots/of/segments", value: "/api/v1/a/very/long/path/with/lots/of/segments")
trieBuilder.addEntry("/api/v1/users/:id/profile", value: "/api/v1/users/:id/profile")
trie3 = BinaryTrie(base: trieBuilder)
}
}
37 changes: 18 additions & 19 deletions Benchmarks/Benchmarks/Router/RouterBenchmarks.swift
Expand Up @@ -15,6 +15,7 @@
import Benchmark
import HTTPTypes
import Hummingbird
import NIOEmbedded
import NIOHTTPTypes
@_spi(Internal) import HummingbirdCore
import Logging
Expand All @@ -35,14 +36,15 @@ struct BenchmarkBodyWriter: Sendable, ResponseBodyWriter {
func write(_: ByteBuffer) async throws {}
}

typealias ByteBufferWriter = (ByteBuffer) async throws -> Void
extension Benchmark {
@discardableResult
convenience init?<Context: RequestContext>(
name: String,
context: Context.Type = BasicBenchmarkContext.self,
configuration: Benchmark.Configuration = Benchmark.defaultConfiguration,
request: HTTPRequest,
writeBody: @escaping @Sendable (StreamedRequestBody.InboundStream.TestSource) async throws -> Void = { _ in },
writeBody: @escaping @Sendable (ByteBufferWriter) async throws -> Void = { _ in },
setupRouter: @escaping @Sendable (Router<Context>) async throws -> Void
) {
let router = Router(context: Context.self)
Expand All @@ -54,19 +56,16 @@ extension Benchmark {
for _ in 0..<50 {
try await withThrowingTaskGroup(of: Void.self) { group in
let context = Context(
allocator: ByteBufferAllocator(),
channel: EmbeddedChannel(),
logger: Logger(label: "Benchmark")
)
let (inbound, source) = NIOAsyncChannelInboundStream<HTTPRequestPart>.makeTestingStream()
let streamer = StreamedRequestBody(iterator: inbound.makeAsyncIterator())
let requestBody = RequestBody.stream(streamer)
let (requestBody, source) = RequestBody.makeStream()
let Request = Request(head: request, body: requestBody)
group.addTask {
let response = try await responder.respond(to: Request, context: context)
_ = try await response.body.write(BenchmarkBodyWriter())
}
try await writeBody(source)
source.yield(.end(nil))
try await writeBody(source.yield)
source.finish()
}
}
Expand Down Expand Up @@ -98,14 +97,14 @@ func routerBenchmarks() {
name: "Router:PUT",
configuration: .init(warmupIterations: 10),
request: .init(method: .put, scheme: "http", authority: "localhost", path: "/")
) { bodyStream in
bodyStream.yield(.body(buffer))
bodyStream.yield(.body(buffer))
bodyStream.yield(.body(buffer))
bodyStream.yield(.body(buffer))
) { write in
try await write(buffer)
try await write(buffer)
try await write(buffer)
try await write(buffer)
} setupRouter: { router in
router.put { request, _ in
let body = try await request.body.collate(maxSize: .max)
let body = try await request.body.collect(upTo: .max)
return body.readableBytes.description
}
}
Expand All @@ -114,11 +113,11 @@ func routerBenchmarks() {
name: "Router:Echo",
configuration: .init(warmupIterations: 10),
request: .init(method: .post, scheme: "http", authority: "localhost", path: "/")
) { bodyStream in
bodyStream.yield(.body(buffer))
bodyStream.yield(.body(buffer))
bodyStream.yield(.body(buffer))
bodyStream.yield(.body(buffer))
) { write in
try await write(buffer)
try await write(buffer)
try await write(buffer)
try await write(buffer)
} setupRouter: { router in
router.post { request, _ in
Response(status: .ok, headers: [:], body: .init { writer in
Expand All @@ -134,7 +133,7 @@ func routerBenchmarks() {
configuration: .init(warmupIterations: 10),
request: .init(method: .get, scheme: "http", authority: "localhost", path: "/")
) { router in
struct EmptyMiddleware<Context>: MiddlewareProtocol {
struct EmptyMiddleware<Context>: RouterMiddleware {
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
return try await next(request, context)
}
Expand Down
2 changes: 0 additions & 2 deletions Sources/Hummingbird/Deprecations.swift
Expand Up @@ -57,8 +57,6 @@ public typealias HBRouterMethods = RouterMethods
public typealias HBRouterOptions = RouterOptions
@_documentation(visibility: internal) @available(*, deprecated, renamed: "RouterPath")
public typealias HBRouterPath = RouterPath
@_documentation(visibility: internal) @available(*, deprecated, renamed: "RouterResponder")
public typealias HBRouterResponder = RouterResponder

@_documentation(visibility: internal) @available(*, deprecated, renamed: "CORSMiddleware")
public typealias HBCORSMiddleware = CORSMiddleware
Expand Down
163 changes: 163 additions & 0 deletions Sources/Hummingbird/Router/BinaryTrie/BinaryTrie+resolve.swift
@@ -0,0 +1,163 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Hummingbird server framework project
//
// Copyright (c) 2024 the Hummingbird authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See hummingbird/CONTRIBUTORS.txt for the list of Hummingbird authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import NIOCore

extension BinaryTrie {
/// Resolve a path to a `Value` if available
@_spi(Internal) public func resolve(_ path: String) -> (value: Value, parameters: Parameters)? {
var trie = trie
let pathComponents = path.split(separator: "/", omittingEmptySubsequences: true)
var pathComponentsIterator = pathComponents.makeIterator()
var parameters = Parameters()
guard var node: BinaryTrieNode = trie.readBinaryTrieNode() else { return nil }
while let component = pathComponentsIterator.next() {
node = self.matchComponent(component, in: &trie, parameters: &parameters)
if node.token == .recursiveWildcard {
// we have found a recursive wildcard. Go through all the path components until we match one of them
// or reach the end of the path component array
var range = component.startIndex..<component.endIndex
while let component = pathComponentsIterator.next() {
var recursiveTrie = trie
let recursiveNode = self.matchComponent(component, in: &recursiveTrie, parameters: &parameters)
if recursiveNode.token != .deadEnd {
node = recursiveNode
break
}
// extend range of catch all text
range = range.lowerBound..<component.endIndex
}
parameters.setCatchAll(path[range])
}
if node.token == .deadEnd {
return nil
}
}
return self.value(for: node.index, parameters: parameters)
}

/// If `index != nil`, resolves the `index` to a `Value`
/// This is used as a helper in `descendPath(in:parameters:components:)`
private func value(for index: UInt16?, parameters: Parameters) -> (value: Value, parameters: Parameters)? {
if let index, let value = self.values[Int(index)] {
return (value: value, parameters: parameters)
}

return nil
}

/// Match sibling node for path component
private func matchComponent(
_ component: Substring,
in trie: inout ByteBuffer,
parameters: inout Parameters
) -> BinaryTrieNode {
while let node = trie.readBinaryTrieNode() {
let result = self.matchComponent(component, withToken: node.token, in: &trie, parameters: &parameters)
switch result {
case .match, .deadEnd:
return node
default:
trie.moveReaderIndex(to: Int(node.nextSiblingNodeIndex))
}
}
// should never get here
return .init(index: 0, token: .deadEnd, nextSiblingNodeIndex: UInt32(trie.writerIndex))
}

private enum MatchResult {
case match, mismatch, ignore, deadEnd
}

private func matchComponent(
_ component: Substring,
withToken token: BinaryTrieTokenKind,
in trie: inout ByteBuffer,
parameters: inout Parameters
) -> MatchResult {
switch token {
case .path:
// The current node is a constant
guard
trie.readAndCompareString(
to: component,
length: Integer.self
)
else {
return .mismatch
}

return .match
case .capture:
// The current node is a parameter
guard
let parameter = trie.readLengthPrefixedString(as: Integer.self)
else {
return .mismatch
}

parameters[Substring(parameter)] = component
return .match
case .prefixCapture:
guard
let suffix = trie.readLengthPrefixedString(as: Integer.self),
let parameter = trie.readLengthPrefixedString(as: Integer.self),
component.hasSuffix(suffix)
else {
return .mismatch
}

parameters[Substring(parameter)] = component.dropLast(suffix.count)
return .match
case .suffixCapture:
guard
let prefix = trie.readLengthPrefixedString(as: Integer.self),
let parameter = trie.readLengthPrefixedString(as: Integer.self),
component.hasPrefix(prefix)
else {
return .mismatch
}

parameters[Substring(parameter)] = component.dropFirst(prefix.count)
return .match
case .wildcard:
// Always matches, descend
return .match
case .prefixWildcard:
guard
let suffix = trie.readLengthPrefixedString(as: Integer.self),
component.hasSuffix(suffix)
else {
return .mismatch
}

return .match
case .suffixWildcard:
guard
let prefix = trie.readLengthPrefixedString(as: Integer.self),
component.hasPrefix(prefix)
else {
return .mismatch
}

return .match
case .recursiveWildcard:
return .match
case .null:
return .ignore
case .deadEnd:
return .deadEnd
}
}
}