diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/Candidate.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/Candidate.cs deleted file mode 100644 index dc768ef113..0000000000 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/Candidate.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; - -namespace Microsoft.AspNetCore.Routing.Matchers -{ - // This is not yet fleshed out - consider this part of the - // work-in-progress definition of CandidateSet. - internal readonly struct Candidate - { - public readonly MatcherEndpoint Endpoint; - - public readonly string[] Parameters; - - public Candidate(MatcherEndpoint endpoint) - { - Endpoint = endpoint; - Parameters = Array.Empty(); - } - - public Candidate(MatcherEndpoint endpoint, string[] parameters) - { - Endpoint = endpoint; - Parameters = parameters; - } - } -} diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherAzureBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherAzureBenchmark.cs index 979309e912..8e2a114137 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherAzureBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherAzureBenchmark.cs @@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers _samples = SampleRequests(EndpointCount, SampleCount); _baseline = (BarebonesMatcher)SetupMatcher(new BarebonesMatcherBuilder()); - _dfa = SetupMatcher(new DfaMatcherBuilder()); + _dfa = SetupMatcher(CreateDfaMatcherBuilder()); _tree = SetupMatcher(new TreeRouterMatcherBuilder()); _feature = new EndpointFeature(); diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs index 9bdc2b0f1d..f13c36e9d2 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherBenchmarkBase.cs @@ -22,9 +22,18 @@ namespace Microsoft.AspNetCore.Routing.Matchers { var services = new ServiceCollection(); services.AddLogging(); + services.AddOptions(); + services.AddRouting(); + services.AddDispatcher(); return services.BuildServiceProvider(); } + private protected DfaMatcherBuilder CreateDfaMatcherBuilder() + { + var services = CreateServices(); + return ActivatorUtilities.CreateInstance(services); + } + private protected static MatcherEndpoint CreateEndpoint(string template, string httpMethod = null) { var metadata = new List(); diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherGithubBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherGithubBenchmark.cs index 59916da3e8..4aeb9a3a02 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherGithubBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherGithubBenchmark.cs @@ -24,7 +24,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers SetupRequests(); _baseline = (BarebonesMatcher)SetupMatcher(new BarebonesMatcherBuilder()); - _dfa = SetupMatcher(new DfaMatcherBuilder()); + _dfa = SetupMatcher(CreateDfaMatcherBuilder()); _tree = SetupMatcher(new TreeRouterMatcherBuilder()); _feature = new EndpointFeature(); diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs index 14749a95f1..edf73d6a48 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesAzureBenchmark.cs @@ -28,7 +28,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers _samples = SampleRequests(EndpointCount, SampleCount); _baseline = (BarebonesMatcher)SetupMatcher(new BarebonesMatcherBuilder()); - _dfa = (DfaMatcher)SetupMatcher(new DfaMatcherBuilder()); + _dfa = (DfaMatcher)SetupMatcher(CreateDfaMatcherBuilder()); } [Benchmark(Baseline = true, OperationsPerInvoke = SampleCount)] diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs index a9aee41044..f40382dd5d 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesGithubBenchmark.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers SetupRequests(); _baseline = (BarebonesMatcher)SetupMatcher(new BarebonesMatcherBuilder()); - _dfa = (DfaMatcher)SetupMatcher(new DfaMatcherBuilder()); + _dfa = (DfaMatcher)SetupMatcher(CreateDfaMatcherBuilder()); } [Benchmark(Baseline = true, OperationsPerInvoke = EndpointCount)] diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs index a04925b3b1..1769ae37b9 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSingleEntryBenchmark.cs @@ -24,7 +24,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Requests[0].Request.Path = "/plaintext"; _baseline = (TrivialMatcher)SetupMatcher(new TrivialMatcherBuilder()); - _dfa = (DfaMatcher)SetupMatcher(new DfaMatcherBuilder()); + _dfa = (DfaMatcher)SetupMatcher(CreateDfaMatcherBuilder()); } private Matcher SetupMatcher(MatcherBuilder builder) diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs index 491a64a61f..790c8993f3 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSelectCandidatesSmallEntryCountBenchmark.cs @@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers SetupRequests(); _baseline = (TrivialMatcher)SetupMatcher(new TrivialMatcherBuilder()); - _dfa = (DfaMatcher)SetupMatcher(new DfaMatcherBuilder()); + _dfa = (DfaMatcher)SetupMatcher(CreateDfaMatcherBuilder()); _feature = new EndpointFeature(); } diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSingleEntryBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSingleEntryBenchmark.cs index 9da2025ccf..c13f0a9abf 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSingleEntryBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/MatcherSingleEntryBenchmark.cs @@ -31,7 +31,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers Requests[0].Request.Path = "/plaintext"; _baseline = (BarebonesMatcher)SetupMatcher(new BarebonesMatcherBuilder()); - _dfa = SetupMatcher(new DfaMatcherBuilder()); + _dfa = SetupMatcher(CreateDfaMatcherBuilder()); _route = SetupMatcher(new RouteMatcherBuilder()); _tree = SetupMatcher(new TreeRouterMatcherBuilder()); diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Microsoft.AspNetCore.Routing.Performance.csproj b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Microsoft.AspNetCore.Routing.Performance.csproj index 17f2c89904..e3ee4a1f6a 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Microsoft.AspNetCore.Routing.Performance.csproj +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Microsoft.AspNetCore.Routing.Performance.csproj @@ -18,23 +18,12 @@ for perf comparisons. --> - - - Matchers\MatcherBuilder.cs - Matchers\BarebonesMatcher.cs Matchers\BarebonesMatcherBuilder.cs - - Matchers\DfaMatcher.cs - - - Matchers\DfaMatcherBuilder.cs - - Matchers\RouteMatcher.cs diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/Properties/AssemblyInfo.cs index 848ab925ed..fc39ed7ef2 100644 --- a/src/Microsoft.AspNetCore.Routing.Abstractions/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/Properties/AssemblyInfo.cs @@ -3,4 +3,5 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ No newline at end of file +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/RouteValueDictionary.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/RouteValueDictionary.cs index ad3da79e3e..182147b9af 100644 --- a/src/Microsoft.AspNetCore.Routing.Abstractions/RouteValueDictionary.cs +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/RouteValueDictionary.cs @@ -24,6 +24,57 @@ namespace Microsoft.AspNetCore.Routing internal PropertyStorage _propertyStorage; private int _count; + /// + /// Creates a new from the provided array. + /// The new instance will take ownership of the array, and may mutate it. + /// + /// The items array. + /// A new . + public static RouteValueDictionary FromArray(KeyValuePair[] items) + { + if (items == null) + { + throw new ArgumentNullException(nameof(items)); + } + + // We need to compress the array by removing non-contiguous items. We + // typically have a very small number of items to process. We don't need + // to preserve order. + var start = 0; + var end = items.Length - 1; + + // We walk forwards from the beginning of the array and fill in 'null' slots. + // We walk backwards from the end of the array end move items in non-null' slots + // into whatever start is pointing to. O(n) + while (start <= end) + { + if (items[start].Key != null) + { + start++; + } + else if (items[end].Key != null) + { + // Swap this item into start and advance + items[start] = items[end]; + items[end] = default; + start++; + end--; + } + else + { + // Both null, we need to hold on 'start' since we + // still need to fill it with something. + end--; + } + } + + return new RouteValueDictionary() + { + _arrayStorage = items, + _count = start, + }; + } + /// /// Creates an empty . /// @@ -134,7 +185,6 @@ namespace Microsoft.AspNetCore.Routing } else { - _arrayStorage[index] = new KeyValuePair(key, value); } } @@ -325,7 +375,7 @@ namespace Microsoft.AspNetCore.Routing EnsureCapacity(Count); var index = FindInArray(item.Key); - var array = _arrayStorage; + var array = _arrayStorage; if (index >= 0 && EqualityComparer.Default.Equals(array[index].Value, item.Value)) { Array.Copy(array, index + 1, array, index, _count - index); @@ -426,7 +476,7 @@ namespace Microsoft.AspNetCore.Routing var property = storage.Properties[i]; array[i] = new KeyValuePair(property.Name, property.GetValue(storage.Value)); } - + _arrayStorage = array; _propertyStorage = null; return; diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs b/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs new file mode 100644 index 0000000000..857baa24cc --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/Candidate.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal readonly struct Candidate + { + public readonly MatcherEndpoint Endpoint; + + // Used to optimize out operations that modify route values. + public readonly CandidateFlags Flags; + + // Data for creating the RouteValueDictionary. We assign each key its own slot + // and we fill the values array with all of the default values. + // + // Then when we process parameters, we don't need to operate on the RouteValueDictionary + // we can just operate on an array, which is much much faster. + public readonly KeyValuePair[] Slots; + + // List of parameters to capture. Segment is the segment index, index is the + // index into the values array. + public readonly (string parameterName, int segmentIndex, int slotIndex)[] Captures; + + // Catchall parameter to capture (limit one per template). + public readonly (string parameterName, int segmentIndex, int slotIndex) CatchAll; + + // Complex segments are processed in a separate pass because they require a + // RouteValueDictionary. + public readonly (RoutePatternPathSegment pathSegment, int segmentIndex)[] ComplexSegments; + + public readonly MatchProcessor[] MatchProcessors; + + // Used in tests. + public Candidate(MatcherEndpoint endpoint) + { + Endpoint = endpoint; + + Slots = Array.Empty>(); + Captures = Array.Empty<(string parameterName, int segmentIndex, int slotIndex)>(); + CatchAll = default; + ComplexSegments = Array.Empty<(RoutePatternPathSegment pathSegment, int segmentIndex)>(); + MatchProcessors = Array.Empty(); + + Flags = CandidateFlags.None; + } + + public Candidate( + MatcherEndpoint endpoint, + KeyValuePair[] slots, + (string parameterName, int segmentIndex, int slotIndex)[] captures, + (string parameterName, int segmentIndex, int slotIndex) catchAll, + (RoutePatternPathSegment pathSegment, int segmentIndex)[] complexSegments, + MatchProcessor[] matchProcessors) + { + Endpoint = endpoint; + Slots = slots; + Captures = captures; + CatchAll = catchAll; + ComplexSegments = complexSegments; + MatchProcessors = matchProcessors; + + Flags = CandidateFlags.None; + for (var i = 0; i < slots.Length; i++) + { + if (slots[i].Key != null) + { + Flags |= CandidateFlags.HasDefaults; + } + } + + if (captures.Length > 0) + { + Flags |= CandidateFlags.HasCaptures; + } + + if (catchAll.parameterName != null) + { + Flags |= CandidateFlags.HasCatchAll; + } + + if (complexSegments.Length > 0) + { + Flags |= CandidateFlags.HasComplexSegments; + } + + if (matchProcessors.Length > 0) + { + Flags |= CandidateFlags.HasMatchProcessors; + } + } + + [Flags] + public enum CandidateFlags + { + None = 0, + HasDefaults = 1, + HasCaptures = 2, + HasCatchAll = 4, + HasSlots = HasDefaults | HasCaptures | HasCatchAll, + HasComplexSegments = 8, + HasMatchProcessors = 16, + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/CandidateSet.cs b/src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs similarity index 94% rename from test/Microsoft.AspNetCore.Routing.Tests/Matchers/CandidateSet.cs rename to src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs index 4814082eb7..ad85378f5c 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/CandidateSet.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/CandidateSet.cs @@ -5,8 +5,6 @@ using System; namespace Microsoft.AspNetCore.Routing.Matchers { - // This is not yet fleshed out - this is a work in progress to - // unblock the benchmarks. internal class CandidateSet { public static readonly CandidateSet Empty = new CandidateSet(Array.Empty(), Array.Empty()); @@ -56,4 +54,4 @@ namespace Microsoft.AspNetCore.Routing.Matchers return groups; } } -} +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs new file mode 100644 index 0000000000..75a700a02b --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcher.cs @@ -0,0 +1,290 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal sealed class DfaMatcher : Matcher + { + private readonly EndpointSelector _endpointSelector; + private readonly DfaState[] _states; + + public DfaMatcher(EndpointSelector endpointSelector, DfaState[] states) + { + _endpointSelector = endpointSelector; + _states = states; + } + + public sealed override Task MatchAsync(HttpContext httpContext, IEndpointFeature feature) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + if (feature == null) + { + throw new ArgumentNullException(nameof(feature)); + } + + // The sequence of actions we take is optimized to avoid doing expensive work + // like creating substrings, creating route value dictionaries, and calling + // into policies like versioning. + var path = httpContext.Request.Path.Value; + + // First tokenize the path into series of segments. + Span buffer = stackalloc PathSegment[FastPathTokenizer.DefaultSegmentCount]; + var count = FastPathTokenizer.Tokenize(path, buffer); + var segments = buffer.Slice(0, count); + + // SelectCandidates will process the DFA and return a candidate set. This does + // some preliminary matching of the URL (mostly the literal segments). + var candidates = SelectCandidates(path, segments); + if (candidates.GroupCount == 0) + { + return Task.CompletedTask; + } + + // At this point we have a candidate set, defined as a list of groups + // of candidates. Each member of a given group has the same priority + // (priority is defined by order, precedence and other factors like http method + // or version). + // + // We don't yet know that any candidate can be considered a match, because + // we haven't processed things like route constraints and complex segments. + // + // Now we'll go group by group to capture route values, process constraints, + // and process complex segments. + // + // Perf: using groups.Length - 1 here to elide the bounds check. We're relying + // on assumptions of how Groups works. + var candidatesArray = candidates.Candidates; + var groups = candidates.Groups; + + for (var i = 0; i < groups.Length - 1; i++) + { + var start = groups[i]; + var length = groups[i + 1] - groups[i]; + var group = candidatesArray.AsSpan(start, length); + + // Yes, these allocate. We should revise how this interaction works exactly + // once the extensibility is more locked down. + // + // Would could produce a fast path for a small number of members in + // a group. + var members = new BitArray(group.Length); + var groupValues = new RouteValueDictionary[group.Length]; + + if (FilterGroup( + httpContext, + path, + segments, + group, + members, + groupValues)) + { + // We must have some matches because FilterGroup returned true. + + // So: this code is SUPER SUPER temporary. We don't intent to keep + // EndpointSelector around for very long. + var candidatesForEndpointSelector = new List(); + for (var j = 0; j < group.Length; j++) + { + if (members.Get(j)) + { + candidatesForEndpointSelector.Add(group[j].Endpoint); + } + } + + var result = _endpointSelector.SelectBestCandidate(httpContext, candidatesForEndpointSelector); + if (result != null) + { + // Find the route values, based on which endpoint was selected. We have + // to do this because the endpoint selector returns an endpoint + // instead of mutating the feature. + for (var j = 0; j < group.Length; j++) + { + if (ReferenceEquals(result, group[j].Endpoint)) + { + feature.Endpoint = result; + feature.Invoker = ((MatcherEndpoint)result).Invoker; + feature.Values = groupValues[j]; + return Task.CompletedTask; + } + } + } + + // End super temporary code + } + } + + return Task.CompletedTask; + } + + internal CandidateSet SelectCandidates(string path, ReadOnlySpan segments) + { + var states = _states; + + var destination = 0; + for (var i = 0; i < segments.Length; i++) + { + destination = states[destination].Transitions.GetDestination(path, segments[i]); + } + + return states[destination].Candidates; + } + + private bool FilterGroup( + HttpContext httpContext, + string path, + ReadOnlySpan segments, + ReadOnlySpan group, + BitArray members, + RouteValueDictionary[] groupValues) + { + var hasMatch = false; + for (var i = 0; i < group.Length; i++) + { + // PERF: specifically not copying group[i] into a local. It's a relatively + // fat struct and we don't want to eagerly copy it. + var flags = group[i].Flags; + + // First process all of the parameters and defaults. + RouteValueDictionary values; + if ((flags & Candidate.CandidateFlags.HasSlots) == 0) + { + values = new RouteValueDictionary(); + } + else + { + // The Slots array has the default values of the route values in it. + // + // We want to create a new array for the route values based on Slots + // as a prototype. + var prototype = group[i].Slots; + var slots = new KeyValuePair[prototype.Length]; + + if ((flags & Candidate.CandidateFlags.HasDefaults) != 0) + { + Array.Copy(prototype, 0, slots, 0, prototype.Length); + } + + if ((flags & Candidate.CandidateFlags.HasCaptures) != 0) + { + ProcessCaptures(slots, group[i].Captures, path, segments); + } + + if ((flags & Candidate.CandidateFlags.HasCatchAll) != 0) + { + ProcessCatchAll(slots, group[i].CatchAll, path, segments); + } + + values = RouteValueDictionary.FromArray(slots); + } + + groupValues[i] = values; + + // Now that we have the route values, we need to process complex segments. + // Complex segments go through an old API that requires a fully-materialized + // route value dictionary. + var isMatch = true; + if ((flags & Candidate.CandidateFlags.HasComplexSegments) != 0) + { + isMatch &= ProcessComplexSegments(group[i].ComplexSegments, path, segments, values); + } + + if ((flags & Candidate.CandidateFlags.HasMatchProcessors) != 0) + { + isMatch &= ProcessMatchProcessors(group[i].MatchProcessors, httpContext, values); + } + + members.Set(i, isMatch); + hasMatch |= isMatch; + } + + return hasMatch; + } + + private void ProcessCaptures( + KeyValuePair[] slots, + (string parameterName, int segmentIndex, int slotIndex)[] captures, + string path, + ReadOnlySpan segments) + { + for (var i = 0; i < captures.Length; i++) + { + var parameterName = captures[i].parameterName; + if (segments.Length > captures[i].segmentIndex) + { + var segment = segments[captures[i].segmentIndex]; + if (parameterName != null && segment.Length > 0) + { + slots[captures[i].slotIndex] = new KeyValuePair( + parameterName, + path.Substring(segment.Start, segment.Length)); + } + } + } + } + + private void ProcessCatchAll( + KeyValuePair[] slots, + (string parameterName, int segmentIndex, int slotIndex) catchAll, + string path, + ReadOnlySpan segments) + { + if (segments.Length > catchAll.segmentIndex) + { + var segment = segments[catchAll.segmentIndex]; + slots[catchAll.slotIndex] = new KeyValuePair( + catchAll.parameterName, + path.Substring(segment.Start)); + } + } + + private bool ProcessComplexSegments( + (RoutePatternPathSegment pathSegment, int segmentIndex)[] complexSegments, + string path, + ReadOnlySpan segments, + RouteValueDictionary values) + { + for (var i = 0; i < complexSegments.Length; i++) + { + var segment = segments[complexSegments[i].segmentIndex]; + var text = path.Substring(segment.Start, segment.Length); + if (!RoutePatternMatcher.MatchComplexSegment(complexSegments[i].pathSegment, text, values)) + { + return false; + } + } + + return true; + } + + private bool ProcessMatchProcessors( + MatchProcessor[] matchProcessors, + HttpContext httpContext, + RouteValueDictionary values) + { + for (var i = 0; i < matchProcessors.Length; i++) + { + var matchProcessor = matchProcessors[i]; + if (!matchProcessor.ProcessInbound(httpContext, values)) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs new file mode 100644 index 0000000000..36f4b2c6fe --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaMatcherBuilder.cs @@ -0,0 +1,435 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Routing.Template; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class DfaMatcherBuilder : MatcherBuilder + { + private readonly List _entries = new List(); + private readonly IInlineConstraintResolver _constraintResolver = new DefaultInlineConstraintResolver(Options.Create(new RouteOptions())); + + private readonly MatchProcessorFactory _matchProcessorFactory; + private readonly EndpointSelector _endpointSelector; + + public DfaMatcherBuilder( + MatchProcessorFactory matchProcessorFactory, + EndpointSelector endpointSelector) + { + _matchProcessorFactory = matchProcessorFactory ?? throw new ArgumentNullException(nameof(matchProcessorFactory)); + _endpointSelector = endpointSelector ?? throw new ArgumentNullException(nameof(endpointSelector)); + } + + public override void AddEndpoint(MatcherEndpoint endpoint) + { + _entries.Add(new MatcherBuilderEntry(endpoint)); + } + + public DfaNode BuildDfaTree() + { + // We build the tree by doing a BFS over the list of entries. This is important + // because a 'parameter' node can also traverse the same paths that literal nodes + // traverse. This means that we need to order the entries first, or else we will + // miss possible edges in the DFA. + _entries.Sort(); + + // Since we're doing a BFS we will process each 'level' of the tree in stages + // this list will hold the set of items we need to process at the current + // stage. + var work = new List<(MatcherBuilderEntry entry, List parents)>(); + + var root = new DfaNode() { Depth = 0, Label = "/" }; + + // To prepare for this we need to compute the max depth, as well as + // a seed list of items to process (entry, root). + var maxDepth = 0; + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + maxDepth = Math.Max(maxDepth, entry.Pattern.Segments.Count); + + work.Add((entry, new List() { root, })); + } + + // Now we process the entries a level at a time. + for (var depth = 0; depth <= maxDepth; depth++) + { + // As we process items, collect the next set of items. + var nextWork = new List<(MatcherBuilderEntry entry, List parents)>(); + + for (var i = 0; i < work.Count; i++) + { + var (entry, parents) = work[i]; + + if (!HasAdditionalRequiredSegments(entry, depth)) + { + for (var j = 0; j < parents.Count; j++) + { + var parent = parents[j]; + parent.Matches.Add(entry); + } + } + + // Find the parents of this edge at the current depth + var nextParents = new List(); + var segment = GetCurrentSegment(entry, depth); + if (segment == null) + { + continue; + } + + for (var j = 0; j < parents.Count; j++) + { + var parent = parents[j]; + if (segment.IsSimple && segment.Parts[0].IsLiteral) + { + var literal = segment.Parts[0].Text; + if (!parent.Literals.TryGetValue(literal, out var next)) + { + next = new DfaNode() + { + Depth = parent.Depth + 1, + Label = parent.Label + literal + "/", + }; + parent.Literals.Add(literal, next); + } + + nextParents.Add(next); + } + else if (segment.IsSimple && segment.Parts[0].IsCatchAll) + { + // A catch all should traverse all literal nodes as well as parameter nodes + // we don't need to create the parameter node here because of ordering + // all catchalls will be processed after all parameters. + nextParents.AddRange(parent.Literals.Values); + if (parent.Parameters != null) + { + nextParents.Add(parent.Parameters); + } + + // We also create a 'catchall' here. We don't do further traversals + // on the catchall node because only catchalls can end up here. The + // catchall node allows us to capture an unlimited amount of segments + // and also to match a zero-length segment, which a parameter node + // doesn't allow. + if (parent.CatchAll == null) + { + parent.CatchAll = new DfaNode() + { + Depth = parent.Depth + 1, + Label = parent.Label + "{*...}/", + }; + + // The catchall node just loops. + parent.CatchAll.Parameters = parent.CatchAll; + parent.CatchAll.CatchAll = parent.CatchAll; + } + + parent.CatchAll.Matches.Add(entry); + } + else if (segment.IsSimple && segment.Parts[0].IsParameter) + { + if (parent.Parameters == null) + { + parent.Parameters = new DfaNode() + { + Depth = parent.Depth + 1, + Label = parent.Label + "{...}/", + }; + } + + // A parameter should traverse all literal nodes as well as the parameter node + nextParents.AddRange(parent.Literals.Values); + nextParents.Add(parent.Parameters); + } + else + { + // Complex segment - we treat these are parameters here and do the + // expensive processing later. We don't want to spend time processing + // complex segments unless they are the best match, and treating them + // like parameters in the DFA allows us to do just that. + if (parent.Parameters == null) + { + parent.Parameters = new DfaNode() + { + Depth = parent.Depth + 1, + Label = parent.Label + "{...}/", + }; + } + + nextParents.AddRange(parent.Literals.Values); + nextParents.Add(parent.Parameters); + } + } + + if (nextParents.Count > 0) + { + nextWork.Add((entry, nextParents)); + } + } + + // Prepare the process the next stage. + work = nextWork; + } + + return root; + } + + private TemplateSegment GetCurrentSegment(MatcherBuilderEntry entry, int depth) + { + if (depth < entry.Pattern.Segments.Count) + { + return entry.Pattern.Segments[depth]; + } + + if (entry.Pattern.Segments.Count == 0) + { + return null; + } + + var lastSegment = entry.Pattern.Segments[entry.Pattern.Segments.Count - 1]; + if (lastSegment.IsSimple && lastSegment.Parts[0].IsCatchAll) + { + return lastSegment; + } + + return null; + } + + public override Matcher Build() + { + var root = BuildDfaTree(); + + var states = new List(); + var tables = new List(); + AddNode(root, states, tables); + + var exit = states.Count; + states.Add(new DfaState(CandidateSet.Empty, null)); + tables.Add(new JumpTableBuilder() { DefaultDestination = exit, ExitDestination = exit, }); + + for (var i = 0; i < tables.Count; i++) + { + if (tables[i].DefaultDestination == JumpTableBuilder.InvalidDestination) + { + tables[i].DefaultDestination = exit; + } + + if (tables[i].ExitDestination == JumpTableBuilder.InvalidDestination) + { + tables[i].ExitDestination = exit; + } + } + + for (var i = 0; i < states.Count; i++) + { + states[i] = new DfaState(states[i].Candidates, tables[i].Build()); + } + + return new DfaMatcher(_endpointSelector, states.ToArray()); + } + + private int AddNode(DfaNode node, List states, List tables) + { + node.Matches.Sort(); + + var stateIndex = states.Count; + var candidates = new CandidateSet( + node.Matches.Select(CreateCandidate).ToArray(), + CandidateSet.MakeGroups(GetGroupLengths(node))); + states.Add(new DfaState(candidates, null)); + + var table = new JumpTableBuilder(); + tables.Add(table); + + foreach (var kvp in node.Literals) + { + if (kvp.Key == null) + { + continue; + } + + var transition = Transition(kvp.Value); + table.AddEntry(kvp.Key, transition); + } + + if (node.Parameters != null && + node.CatchAll != null && + ReferenceEquals(node.Parameters, node.CatchAll)) + { + // This node has a single transition to but it should accept zero-width segments + // this can happen when a node only has catchall parameters. + table.DefaultDestination = Transition(node.Parameters); + table.ExitDestination = table.DefaultDestination; + } + else if (node.Parameters != null && node.CatchAll != null) + { + // This node has a separate transition for zero-width segments + // this can happen when a node has both parameters and catchall parameters. + table.DefaultDestination = Transition(node.Parameters); + table.ExitDestination = Transition(node.CatchAll); + } + else if (node.Parameters != null) + { + // This node has paramters but no catchall. + table.DefaultDestination = Transition(node.Parameters); + } + else if (node.CatchAll != null) + { + // This node has a catchall but no parameters + table.DefaultDestination = Transition(node.CatchAll); + table.ExitDestination = table.DefaultDestination; + } + + return stateIndex; + + int Transition(DfaNode next) + { + // Break cycles + return ReferenceEquals(node, next) ? stateIndex : AddNode(next, states, tables); + } + } + + // internal for tests + internal Candidate CreateCandidate(MatcherBuilderEntry entry) + { + var assignments = new Dictionary(StringComparer.OrdinalIgnoreCase); + var slots = new List>(); + var captures = new List<(string parameterName, int segmentIndex, int slotIndex)>(); + (string parameterName, int segmentIndex, int slotIndex) catchAll = default; + + foreach (var kvp in entry.Endpoint.Defaults) + { + assignments.Add(kvp.Key, assignments.Count); + slots.Add(kvp); + } + + for (var i = 0; i < entry.Pattern.Segments.Count; i++) + { + var segment = entry.Pattern.Segments[i]; + if (!segment.IsSimple) + { + continue; + } + + var part = segment.Parts[0]; + if (!part.IsParameter) + { + continue; + } + + if (!assignments.TryGetValue(part.Name, out var slotIndex)) + { + slotIndex = assignments.Count; + assignments.Add(part.Name, slotIndex); + + var hasDefaultValue = part.DefaultValue != null || part.IsCatchAll; + slots.Add(hasDefaultValue ? new KeyValuePair(part.Name, part.DefaultValue) : default); + } + + if (part.IsCatchAll) + { + catchAll = (part.Name, i, slotIndex); + } + else + { + captures.Add((part.Name, i, slotIndex)); + } + } + + var complexSegments = new List<(RoutePatternPathSegment pathSegment, int segmentIndex)>(); + for (var i = 0; i < entry.Pattern.Segments.Count; i++) + { + var segment = entry.Pattern.Segments[i]; + if (segment.IsSimple) + { + continue; + } + + complexSegments.Add((segment.ToRoutePatternPathSegment(), i)); + } + + var matchProcessors = new List(); + for (var i = 0; i < entry.Endpoint.MatchProcessorReferences.Count; i++) + { + var reference = entry.Endpoint.MatchProcessorReferences[i]; + var processor = _matchProcessorFactory.Create(reference); + matchProcessors.Add(processor); + } + + return new Candidate( + entry.Endpoint, + slots.ToArray(), + captures.ToArray(), + catchAll, + complexSegments.ToArray(), + matchProcessors.ToArray()); + } + + private int[] GetGroupLengths(DfaNode node) + { + if (node.Matches.Count == 0) + { + return Array.Empty(); + } + + var groups = new List(); + + var length = 1; + var exemplar = node.Matches[0]; + + for (var i = 1; i < node.Matches.Count; i++) + { + if (!exemplar.PriorityEquals(node.Matches[i])) + { + groups.Add(length); + length = 0; + + exemplar = node.Matches[i]; + } + + length++; + } + + groups.Add(length); + + return groups.ToArray(); + } + + private static bool HasAdditionalRequiredSegments(MatcherBuilderEntry entry, int depth) + { + for (var i = depth; i < entry.Pattern.Segments.Count; i++) + { + var segment = entry.Pattern.Segments[i]; + if (!segment.IsSimple) + { + // Complex segments always require more processing + return true; + } + + var part = segment.Parts[0]; + if (part.IsLiteral) + { + return true; + } + + if (!part.IsOptional && + !part.IsCatchAll && + part.DefaultValue == null && + !entry.Endpoint.Defaults.ContainsKey(part.Name)) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs new file mode 100644 index 0000000000..bf16975db8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaNode.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + // Intermediate data structure used to build the DFA. Not used at runtime. + [DebuggerDisplay("{DebuggerToString(),nq}")] + internal class DfaNode + { + public DfaNode() + { + Literals = new Dictionary(StringComparer.OrdinalIgnoreCase); + Matches = new List(); + } + + // The depth of the node. The depth indicates the number of segments + // that must be processed to arrive at this node. + public int Depth { get; set; } + + // Just for diagnostics and debugging + public string Label { get; set; } + + public List Matches { get; } + + public Dictionary Literals { get; } + + public DfaNode Parameters { get; set; } + + public DfaNode CatchAll { get; set; } + + private string DebuggerToString() + { + var builder = new StringBuilder(); + builder.Append(Label); + builder.Append(" d:"); + builder.Append(Depth); + builder.Append(" m:"); + builder.Append(Matches.Count); + builder.Append(" c: "); + builder.Append(string.Join(", ", Literals.Select(kvp => $"{kvp.Key}->({FormatNode(kvp.Value)})"))); + return builder.ToString(); + + // DfaNodes can be self-referential, don't traverse cycles. + string FormatNode(DfaNode other) + { + return ReferenceEquals(this, other) ? "this" : other.DebuggerToString(); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs b/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs new file mode 100644 index 0000000000..cdab988567 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/DfaState.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + [DebuggerDisplay("{DebuggerToString(),nq}")] + internal readonly struct DfaState + { + public readonly CandidateSet Candidates; + public readonly JumpTable Transitions; + + public DfaState(CandidateSet candidates, JumpTable transitions) + { + Candidates = candidates; + Transitions = transitions; + } + + public string DebuggerToString() + { + return $"m: {Candidates.Candidates?.Length ?? 0}, j: ({Transitions?.DebuggerToString()})"; + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherBuilder.cs b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilder.cs similarity index 100% rename from test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherBuilder.cs rename to src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilder.cs diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherBuilderEntry.cs b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilderEntry.cs similarity index 73% rename from test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherBuilderEntry.cs rename to src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilderEntry.cs index ba84ac7b44..f6cca216ee 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/MatcherBuilderEntry.cs +++ b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherBuilderEntry.cs @@ -14,9 +14,7 @@ namespace Microsoft.AspNetCore.Routing.Matchers { Endpoint = endpoint; - HttpMethod = endpoint.Metadata - .OfType() - .FirstOrDefault()?.HttpMethods.Single(); + HttpMethod = endpoint.Metadata.OfType().FirstOrDefault()?.HttpMethods.Single(); Precedence = RoutePrecedence.ComputeInbound(endpoint.ParsedTemplate); } @@ -44,15 +42,12 @@ namespace Microsoft.AspNetCore.Routing.Matchers return comparison; } - // Treat the presence of an HttpMethod as a boolean for the purposes of - // comparison. We want HttpMethod != null to mean *more specific*. - comparison = (HttpMethod == null).CompareTo(other.HttpMethod == null); - if (comparison != 0) - { - return comparison; - } - return Pattern.TemplateText.CompareTo(other.Pattern.TemplateText); } + + public bool PriorityEquals(MatcherBuilderEntry other) + { + return Order == other.Order && Precedence == other.Precedence; + } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs index 9ac6d9b90c..6855b8a518 100644 --- a/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs +++ b/src/Microsoft.AspNetCore.Routing/Patterns/RoutePatternMatcher.cs @@ -161,7 +161,7 @@ namespace Microsoft.AspNetCore.Routing } if (!pathSegment.IsSimple) { - if (!MatchComplexSegment(pathSegment, requestSegment.ToString(), Defaults, values)) + if (!MatchComplexSegment(pathSegment, requestSegment.ToString(), values)) { return false; } @@ -287,10 +287,9 @@ namespace Microsoft.AspNetCore.Routing return false; } - private bool MatchComplexSegment( + internal static bool MatchComplexSegment( RoutePatternPathSegment routeSegment, string requestSegment, - IReadOnlyDictionary defaults, RouteValueDictionary values) { var indexOfLastSegment = routeSegment.Parts.Count - 1; @@ -307,7 +306,7 @@ namespace Microsoft.AspNetCore.Routing if (routeSegment.Parts[indexOfLastSegment] is RoutePatternParameterPart parameter && parameter.IsOptional && routeSegment.Parts[indexOfLastSegment - 1].IsSeparator) { - if (MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment)) + if (MatchComplexSegmentCore(routeSegment, requestSegment, values, indexOfLastSegment)) { return true; } @@ -322,21 +321,19 @@ namespace Microsoft.AspNetCore.Routing return MatchComplexSegmentCore( routeSegment, requestSegment, - Defaults, values, indexOfLastSegment - 2); } } else { - return MatchComplexSegmentCore(routeSegment, requestSegment, Defaults, values, indexOfLastSegment); + return MatchComplexSegmentCore(routeSegment, requestSegment, values, indexOfLastSegment); } } - private bool MatchComplexSegmentCore( + private static bool MatchComplexSegmentCore( RoutePatternPathSegment routeSegment, string requestSegment, - IReadOnlyDictionary defaults, RouteValueDictionary values, int indexOfLastSegmentUsed) { @@ -499,7 +496,7 @@ namespace Microsoft.AspNetCore.Routing { foreach (var item in outValues) { - values.Add(item.Key, item.Value); + values[item.Key] = item.Value; } return true; diff --git a/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs index b654ae0cdf..1da3395469 100644 --- a/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs @@ -5,3 +5,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Routing.Performance, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Routing.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/RouteValueDictionaryTests.cs b/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/RouteValueDictionaryTests.cs index 294e034131..5cb45fc14c 100644 --- a/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/RouteValueDictionaryTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/RouteValueDictionaryTests.cs @@ -1489,6 +1489,76 @@ namespace Microsoft.AspNetCore.Routing.Tests Assert.Equal("value3", storage[1].Value); } + [Fact] + public void FromArray_TakesOwnershipOfArray() + { + // Arrange + var array = new KeyValuePair[] + { + new KeyValuePair("a", 0), + new KeyValuePair("b", 1), + new KeyValuePair("c", 2), + }; + + var dictionary = RouteValueDictionary.FromArray(array); + + // Act - modifying the array should modify the dictionary + array[0] = new KeyValuePair("aa", 10); + + // Assert + Assert.Equal(3, dictionary.Count); + Assert.Equal(10, dictionary["aa"]); + } + + [Fact] + public void FromArray_EmptyArray() + { + // Arrange + var array = Array.Empty>(); + + // Act + var dictionary = RouteValueDictionary.FromArray(array); + + // Assert + Assert.Empty(dictionary); + } + + [Fact] + public void FromArray_RemovesGapsInArray() + { + // Arrange + var array = new KeyValuePair[] + { + new KeyValuePair(null, null), + new KeyValuePair("a", 0), + new KeyValuePair(null, null), + new KeyValuePair(null, null), + new KeyValuePair("b", 1), + new KeyValuePair("c", 2), + new KeyValuePair("d", 3), + new KeyValuePair(null, null), + }; + + // Act - calling From should modify the array + var dictionary = RouteValueDictionary.FromArray(array); + + // Assert + Assert.Equal(4, dictionary.Count); + Assert.Equal( + new KeyValuePair[] + { + new KeyValuePair("d", 3), + new KeyValuePair("a", 0), + new KeyValuePair("c", 2), + new KeyValuePair("b", 1), + new KeyValuePair(null, null), + new KeyValuePair(null, null), + new KeyValuePair(null, null), + new KeyValuePair(null, null), + }, + array); + } + private class RegularType { public bool IsAwesome { get; set; } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/Candidate.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/Candidate.cs deleted file mode 100644 index dc768ef113..0000000000 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/Candidate.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; - -namespace Microsoft.AspNetCore.Routing.Matchers -{ - // This is not yet fleshed out - consider this part of the - // work-in-progress definition of CandidateSet. - internal readonly struct Candidate - { - public readonly MatcherEndpoint Endpoint; - - public readonly string[] Parameters; - - public Candidate(MatcherEndpoint endpoint) - { - Endpoint = endpoint; - Parameters = Array.Empty(); - } - - public Candidate(MatcherEndpoint endpoint, string[] parameters) - { - Endpoint = endpoint; - Parameters = parameters; - } - } -} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcher.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcher.cs deleted file mode 100644 index 429c34f07e..0000000000 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcher.cs +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing.EndpointConstraints; - -namespace Microsoft.AspNetCore.Routing.Matchers -{ - internal class DfaMatcher : Matcher - { - private readonly State[] _states; - - public DfaMatcher(State[] states) - { - _states = states; - } - - public override Task MatchAsync(HttpContext httpContext, IEndpointFeature feature) - { - if (httpContext == null) - { - throw new ArgumentNullException(nameof(httpContext)); - } - - if (feature == null) - { - throw new ArgumentNullException(nameof(feature)); - } - - var path = httpContext.Request.Path.Value; - Span segments = stackalloc PathSegment[FastPathTokenizer.DefaultSegmentCount]; - var count = FastPathTokenizer.Tokenize(path, segments); - - var candidates = SelectCandidates(path, segments.Slice(0, count)); - - var matches = new List<(Endpoint, RouteValueDictionary)>(); - - // This code ignores groups for right now. - for (var i = 0; i < candidates.Candidates.Length; i++) - { - var isMatch = true; - - var candidate = candidates.Candidates[i]; - var values = new RouteValueDictionary(); - var parameters = candidate.Parameters; - if (parameters != null) - { - for (var j = 0; j < parameters.Length; j++) - { - var parameter = parameters[j]; - if (parameter != null && segments[j].Length == 0) - { - isMatch = false; - break; - } - else if (parameter != null) - { - var value = path.Substring(segments[j].Start, segments[j].Length); - values.Add(parameter, value); - } - } - } - - // This is some super super temporary code so we can pass the benchmarks - // that do HTTP method matching. - var httpMethodConstraint = candidate.Endpoint.Metadata.GetMetadata(); - if (httpMethodConstraint != null && !MatchHttpMethod(httpContext.Request.Method, httpMethodConstraint)) - { - isMatch = false; - } - - if (isMatch) - { - matches.Add((candidate.Endpoint, values)); - } - } - - feature.Endpoint = matches.Count == 0 ? null : matches[0].Item1; - feature.Values = matches.Count == 0 ? null : matches[0].Item2; - - return Task.CompletedTask; - } - - // This is some super super temporary code so we can pass the benchmarks - // that do HTTP method matching. - private bool MatchHttpMethod(string httpMethod, HttpMethodEndpointConstraint constraint) - { - foreach (var supportedHttpMethod in constraint.HttpMethods) - { - if (string.Equals(supportedHttpMethod, httpMethod, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - public CandidateSet SelectCandidates(string path, ReadOnlySpan segments) - { - var states = _states; - var current = 0; - - for (var i = 0; i < segments.Length; i++) - { - current = states[current].Transitions.GetDestination(path, segments[i]); - } - - return states[current].Candidates; - } - - [DebuggerDisplay("{DebuggerToString(),nq}")] - public readonly struct State - { - public readonly CandidateSet Candidates; - public readonly JumpTable Transitions; - - public State(CandidateSet candidates, JumpTable transitions) - { - Candidates = candidates; - Transitions = transitions; - } - - public string DebuggerToString() - { - return $"m: {Candidates.Candidates?.Length ?? 0}, j: ({Transitions?.DebuggerToString()})"; - } - } - } -} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilder.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilder.cs deleted file mode 100644 index cdfe233a7d..0000000000 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilder.cs +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using Microsoft.AspNetCore.Routing.Template; -using static Microsoft.AspNetCore.Routing.Matchers.DfaMatcher; - -namespace Microsoft.AspNetCore.Routing.Matchers -{ - internal class DfaMatcherBuilder : MatcherBuilder - { - private List _entries = new List(); - - public override void AddEndpoint(MatcherEndpoint endpoint) - { - var parsed = TemplateParser.Parse(endpoint.Template); - _entries.Add(new MatcherBuilderEntry(endpoint)); - } - - public override Matcher Build() - { - _entries.Sort(); - - var root = new Node() { Depth = -1 }; - - // We build the tree by doing a BFS over the list of entries. This is important - // because a 'parameter' node can also traverse the same paths that literal nodes traverse. - var maxDepth = 0; - for (var i = 0; i < _entries.Count; i++) - { - maxDepth = Math.Max(maxDepth, _entries[i].Pattern.Segments.Count); - } - - for (var depth = 0; depth <= maxDepth; depth++) - { - for (var i = 0; i < _entries.Count; i++) - { - var entry = _entries[i]; - if (entry.Pattern.Segments.Count < depth) - { - continue; - } - - // Find the parents of this edge at the current depth - var parents = new List() { root }; - for (var j = 0; j < depth; j++) - { - var next = new List(); - for (var k = 0; k < parents.Count; k++) - { - next.Add(Traverse(parents[k], entry.Pattern.Segments[j])); - } - - parents = next; - } - - if (entry.Pattern.Segments.Count == depth) - { - for (var j = 0; j < parents.Count; j++) - { - var parent = parents[j]; - parent.Matches.Add(entry); - } - - continue; - } - - var segment = entry.Pattern.Segments[depth]; - for (var j = 0; j < parents.Count; j++) - { - var parent = parents[j]; - if (segment.IsSimple && segment.Parts[0].IsLiteral) - { - if (!parent.Literals.TryGetValue(segment.Parts[0].Text, out var next)) - { - next = new Node() { Depth = depth, }; - parent.Literals.Add(segment.Parts[0].Text, next); - } - } - else if (segment.IsSimple && segment.Parts[0].IsParameter) - { - if (!parent.Literals.TryGetValue("*", out var next)) - { - next = new Node() { Depth = depth, }; - parent.Literals.Add("*", next); - } - } - else - { - throw new InvalidOperationException("We only support simple segments."); - } - } - } - } - - var states = new List(); - var tables = new List(); - AddNode(root, states, tables); - - var exit = states.Count; - states.Add(new State(CandidateSet.Empty, null)); - tables.Add(new JumpTableBuilder() { DefaultDestination = exit, ExitDestination = exit, }); - - for (var i = 0; i < tables.Count; i++) - { - if (tables[i].DefaultDestination == JumpTableBuilder.InvalidDestination) - { - tables[i].DefaultDestination = exit; - } - - if (tables[i].ExitDestination == JumpTableBuilder.InvalidDestination) - { - tables[i].ExitDestination = exit; - } - } - - for (var i = 0; i < states.Count; i++) - { - states[i] = new State(states[i].Candidates, tables[i].Build()); - } - - return new DfaMatcher(states.ToArray()); - } - - private Node Traverse(Node node, TemplateSegment segment) - { - if (!segment.IsSimple) - { - throw new InvalidOperationException("We only support simple segments."); - } - - if (segment.Parts[0].IsLiteral) - { - return node.Literals[segment.Parts[0].Text]; - } - - return node.Literals["*"]; - } - - private static int AddNode(Node node, List states, List tables) - { - node.Matches.Sort(); - - var index = states.Count; - - // This is just temporary. This code ignores groups for now, and creates - // a single group with all matches. - var candidates = new CandidateSet( - node.Matches.Select(CreateCandidate).ToArray(), - CandidateSet.MakeGroups(new int[] { node.Matches.Count, })); - - // JumpTable temporarily null. Will be patched later. - states.Add(new State(candidates, null)); - - var table = new JumpTableBuilder(); - tables.Add(table); - - foreach (var kvp in node.Literals) - { - if (kvp.Key == "*") - { - continue; - } - - var transition = AddNode(kvp.Value, states, tables); - table.AddEntry(kvp.Key, transition); - } - - var defaultIndex = -1; - if (node.Literals.TryGetValue("*", out var exit)) - { - defaultIndex = AddNode(exit, states, tables); - } - - table.DefaultDestination = defaultIndex; - return index; - } - - private static Candidate CreateCandidate(MatcherBuilderEntry entry) - { - var parameters = entry.Pattern.Segments - .Select(s => s.IsSimple && s.Parts[0].IsParameter ? s.Parts[0].Name : null) - .ToArray(); - return new Candidate(entry.Endpoint, parameters); - } - - private static Node DeepCopy(Node node) - { - var copy = new Node() { Depth = node.Depth, }; - copy.Matches.AddRange(node.Matches); - - foreach (var kvp in node.Literals) - { - copy.Literals.Add(kvp.Key, DeepCopy(kvp.Value)); - } - - return node; - } - - [DebuggerDisplay("{DebuggerToString(),nq}")] - private class Node - { - public int Depth { get; set; } - - public List Matches { get; } = new List(); - - public Dictionary Literals { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - - private string DebuggerToString() - { - var builder = new StringBuilder(); - builder.Append("d:"); - builder.Append(Depth); - builder.Append(" m:"); - builder.Append(Matches.Count); - builder.Append(" c: "); - builder.Append(string.Join(", ", Literals.Select(kvp => $"{kvp.Key}->({kvp.Value.DebuggerToString()})"))); - return builder.ToString(); - } - } - } -} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs new file mode 100644 index 0000000000..5cbc613562 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherBuilderTest.cs @@ -0,0 +1,626 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.AspNetCore.Routing.EndpointConstraints; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + public class DfaMatcherBuilderTest + { + [Fact] + public void BuildDfaTree_SingleEndpoint_Empty() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint = CreateEndpoint("/"); + builder.AddEndpoint(endpoint); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Same(endpoint, Assert.Single(root.Matches).Endpoint); + Assert.Null(root.Parameters); + Assert.Empty(root.Literals); + } + + [Fact] + public void BuildDfaTree_SingleEndpoint_Literals() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint = CreateEndpoint("a/b/c"); + builder.AddEndpoint(endpoint); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + Assert.Null(a.Parameters); + + next = Assert.Single(a.Literals); + Assert.Equal("b", next.Key); + + var b = next.Value; + Assert.Empty(b.Matches); + Assert.Null(b.Parameters); + + next = Assert.Single(b.Literals); + Assert.Equal("c", next.Key); + + var c = next.Value; + Assert.Same(endpoint, Assert.Single(c.Matches).Endpoint); + Assert.Null(c.Parameters); + Assert.Empty(c.Literals); + } + + [Fact] + public void BuildDfaTree_SingleEndpoint_Parameters() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint = CreateEndpoint("{a}/{b}/{c}"); + builder.AddEndpoint(endpoint); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Empty(root.Literals); + + var a = root.Parameters; + Assert.Empty(a.Matches); + Assert.Empty(a.Literals); + + var b = a.Parameters; + Assert.Empty(b.Matches); + Assert.Empty(b.Literals); + + var c = b.Parameters; + Assert.Same(endpoint, Assert.Single(c.Matches).Endpoint); + Assert.Null(c.Parameters); + Assert.Empty(c.Literals); + } + + [Fact] + public void BuildDfaTree_SingleEndpoint_CatchAll() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint = CreateEndpoint("{a}/{*b}"); + builder.AddEndpoint(endpoint); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Empty(root.Literals); + + var a = root.Parameters; + + // The catch all can match a path like '/a' + Assert.Same(endpoint, Assert.Single(a.Matches).Endpoint); + Assert.Empty(a.Literals); + Assert.Null(a.Parameters); + + // Catch-all nodes include an extra transition that loops to process + // extra segments. + var catchAll = a.CatchAll; + Assert.Same(endpoint, Assert.Single(catchAll.Matches).Endpoint); + Assert.Empty(catchAll.Literals); + Assert.Same(catchAll, catchAll.Parameters); + Assert.Same(catchAll, catchAll.CatchAll); + } + + [Fact] + public void BuildDfaTree_SingleEndpoint_CatchAllAtRoot() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint = CreateEndpoint("{*a}"); + builder.AddEndpoint(endpoint); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Same(endpoint, Assert.Single(root.Matches).Endpoint); + Assert.Empty(root.Literals); + + // Catch-all nodes include an extra transition that loops to process + // extra segments. + var catchAll = root.CatchAll; + Assert.Same(endpoint, Assert.Single(catchAll.Matches).Endpoint); + Assert.Empty(catchAll.Literals); + Assert.Same(catchAll, catchAll.Parameters); + } + + [Fact] + public void BuildDfaTree_MultipleEndpoint_LiteralAndLiteral() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint1 = CreateEndpoint("a/b1/c"); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("a/b2/c"); + builder.AddEndpoint(endpoint2); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + + Assert.Equal(2, a.Literals.Count); + + var b1 = a.Literals["b1"]; + Assert.Empty(b1.Matches); + Assert.Null(b1.Parameters); + + next = Assert.Single(b1.Literals); + Assert.Equal("c", next.Key); + + var c1 = next.Value; + Assert.Same(endpoint1, Assert.Single(c1.Matches).Endpoint); + Assert.Null(c1.Parameters); + Assert.Empty(c1.Literals); + + var b2 = a.Literals["b2"]; + Assert.Empty(b2.Matches); + Assert.Null(b2.Parameters); + + next = Assert.Single(b2.Literals); + Assert.Equal("c", next.Key); + + var c2 = next.Value; + Assert.Same(endpoint2, Assert.Single(c2.Matches).Endpoint); + Assert.Null(c2.Parameters); + Assert.Empty(c2.Literals); + } + + [Fact] + public void BuildDfaTree_MultipleEndpoint_LiteralAndParameter() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint1 = CreateEndpoint("a/b/c"); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("a/{b}/c"); + builder.AddEndpoint(endpoint2); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + + next = Assert.Single(a.Literals); + Assert.Equal("b", next.Key); + + var b = next.Value; + Assert.Empty(b.Matches); + Assert.Null(b.Parameters); + + next = Assert.Single(b.Literals); + Assert.Equal("c", next.Key); + + var c1 = next.Value; + Assert.Collection( + c1.Matches, + e => Assert.Same(endpoint1, e.Endpoint), + e => Assert.Same(endpoint2, e.Endpoint)); + Assert.Null(c1.Parameters); + Assert.Empty(c1.Literals); + + var b2 = a.Parameters; + Assert.Empty(b2.Matches); + Assert.Null(b2.Parameters); + + next = Assert.Single(b2.Literals); + Assert.Equal("c", next.Key); + + var c2 = next.Value; + Assert.Same(endpoint2, Assert.Single(c2.Matches).Endpoint); + Assert.Null(c2.Parameters); + Assert.Empty(c2.Literals); + } + + [Fact] + public void BuildDfaTree_MultipleEndpoint_ParameterAndParameter() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint1 = CreateEndpoint("a/{b1}/c"); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("a/{b2}/c"); + builder.AddEndpoint(endpoint2); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Empty(a.Matches); + Assert.Empty(a.Literals); + + var b = a.Parameters; + Assert.Empty(b.Matches); + Assert.Null(b.Parameters); + + next = Assert.Single(b.Literals); + Assert.Equal("c", next.Key); + + var c = next.Value; + Assert.Collection( + c.Matches, + e => Assert.Same(endpoint1, e.Endpoint), + e => Assert.Same(endpoint2, e.Endpoint)); + Assert.Null(c.Parameters); + Assert.Empty(c.Literals); + } + + [Fact] + public void BuildDfaTree_MultipleEndpoint_LiteralAndCatchAll() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint1 = CreateEndpoint("a/b/c"); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("a/{*b}"); + builder.AddEndpoint(endpoint2); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + + next = Assert.Single(a.Literals); + Assert.Equal("b", next.Key); + + var b1 = next.Value; + Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Null(b1.Parameters); + + next = Assert.Single(b1.Literals); + Assert.Equal("c", next.Key); + + var c1 = next.Value; + Assert.Collection( + c1.Matches, + e => Assert.Same(endpoint1, e.Endpoint), + e => Assert.Same(endpoint2, e.Endpoint)); + Assert.Null(c1.Parameters); + Assert.Empty(c1.Literals); + + var catchAll = a.CatchAll; + Assert.Same(endpoint2, Assert.Single(catchAll.Matches).Endpoint); + Assert.Same(catchAll, catchAll.Parameters); + Assert.Same(catchAll, catchAll.CatchAll); + } + + [Fact] + public void BuildDfaTree_MultipleEndpoint_ParameterAndCatchAll() + { + // Arrange + var builder = CreateDfaMatcherBuilder(); + + var endpoint1 = CreateEndpoint("a/{b}/c"); + builder.AddEndpoint(endpoint1); + + var endpoint2 = CreateEndpoint("a/{*b}"); + builder.AddEndpoint(endpoint2); + + // Act + var root = builder.BuildDfaTree(); + + // Assert + Assert.Empty(root.Matches); + Assert.Null(root.Parameters); + + var next = Assert.Single(root.Literals); + Assert.Equal("a", next.Key); + + var a = next.Value; + Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Empty(a.Literals); + + var b1 = a.Parameters; + Assert.Same(endpoint2, Assert.Single(a.Matches).Endpoint); + Assert.Null(b1.Parameters); + + next = Assert.Single(b1.Literals); + Assert.Equal("c", next.Key); + + var c1 = next.Value; + Assert.Collection( + c1.Matches, + e => Assert.Same(endpoint1, e.Endpoint), + e => Assert.Same(endpoint2, e.Endpoint)); + Assert.Null(c1.Parameters); + Assert.Empty(c1.Literals); + + var catchAll = a.CatchAll; + Assert.Same(endpoint2, Assert.Single(catchAll.Matches).Endpoint); + Assert.Same(catchAll, catchAll.Parameters); + Assert.Same(catchAll, catchAll.CatchAll); + } + + [Fact] + public void CreateCandidate_JustLiterals() + { + // Arrange + var endpoint = CreateEndpoint("/a/b/c"); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal(Candidate.CandidateFlags.None, candidate.Flags); + Assert.Empty(candidate.Slots); + Assert.Empty(candidate.Captures); + Assert.Equal(default, candidate.CatchAll); + Assert.Empty(candidate.ComplexSegments); + Assert.Empty(candidate.MatchProcessors); + } + + [Fact] + public void CreateCandidate_Parameters() + { + // Arrange + var endpoint = CreateEndpoint("/{a}/{b}/{c}"); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal(Candidate.CandidateFlags.HasCaptures, candidate.Flags); + Assert.Equal(3, candidate.Slots.Length); + Assert.Collection( + candidate.Captures, + c => Assert.Equal(("a", 0, 0), c), + c => Assert.Equal(("b", 1, 1), c), + c => Assert.Equal(("c", 2, 2), c)); + Assert.Equal(default, candidate.CatchAll); + Assert.Empty(candidate.ComplexSegments); + Assert.Empty(candidate.MatchProcessors); + } + + [Fact] + public void CreateCandidate_Parameters_WithDefaults() + { + // Arrange + var endpoint = CreateEndpoint("/{a=aa}/{b=bb}/{c=cc}"); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal( + Candidate.CandidateFlags.HasDefaults | Candidate.CandidateFlags.HasCaptures, + candidate.Flags); + Assert.Collection( + candidate.Slots, + s => Assert.Equal(new KeyValuePair("a", "aa"), s), + s => Assert.Equal(new KeyValuePair("b", "bb"), s), + s => Assert.Equal(new KeyValuePair("c", "cc"), s)); + Assert.Collection( + candidate.Captures, + c => Assert.Equal(("a", 0, 0), c), + c => Assert.Equal(("b", 1, 1), c), + c => Assert.Equal(("c", 2, 2), c)); + Assert.Equal(default, candidate.CatchAll); + Assert.Empty(candidate.ComplexSegments); + Assert.Empty(candidate.MatchProcessors); + } + + [Fact] + public void CreateCandidate_Parameters_CatchAll() + { + // Arrange + var endpoint = CreateEndpoint("/{a}/{b}/{*c=cc}"); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal( + Candidate.CandidateFlags.HasDefaults | + Candidate.CandidateFlags.HasCaptures | + Candidate.CandidateFlags.HasCatchAll, + candidate.Flags); + Assert.Collection( + candidate.Slots, + s => Assert.Equal(new KeyValuePair("c", "cc"), s), + s => Assert.Equal(new KeyValuePair(null, null), s), + s => Assert.Equal(new KeyValuePair(null, null), s)); + Assert.Collection( + candidate.Captures, + c => Assert.Equal(("a", 0, 1), c), + c => Assert.Equal(("b", 1, 2), c)); + Assert.Equal(("c", 2, 0), candidate.CatchAll); + Assert.Empty(candidate.ComplexSegments); + Assert.Empty(candidate.MatchProcessors); + } + + // Defaults are processed first, which affects the slot ordering. + [Fact] + public void CreateCandidate_Parameters_OutOfLineDefaults() + { + // Arrange + var endpoint = CreateEndpoint("/{a}/{b}/{c=cc}", new { a = "aa", d = "dd", }); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal( + Candidate.CandidateFlags.HasDefaults | Candidate.CandidateFlags.HasCaptures, + candidate.Flags); + Assert.Collection( + candidate.Slots, + s => Assert.Equal(new KeyValuePair("a", "aa"), s), + s => Assert.Equal(new KeyValuePair("d", "dd"), s), + s => Assert.Equal(new KeyValuePair("c", "cc"), s), + s => Assert.Equal(new KeyValuePair(null, null), s)); + Assert.Collection( + candidate.Captures, + c => Assert.Equal(("a", 0, 0), c), + c => Assert.Equal(("b", 1, 3), c), + c => Assert.Equal(("c", 2, 2), c)); + Assert.Equal(default, candidate.CatchAll); + Assert.Empty(candidate.ComplexSegments); + Assert.Empty(candidate.MatchProcessors); + } + + [Fact] + public void CreateCandidate_Parameters_ComplexSegments() + { + // Arrange + var endpoint = CreateEndpoint("/{a}-{b=bb}/{c}"); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal( + Candidate.CandidateFlags.HasDefaults | + Candidate.CandidateFlags.HasCaptures | + Candidate.CandidateFlags.HasComplexSegments, + candidate.Flags); + Assert.Collection( + candidate.Slots, + s => Assert.Equal(new KeyValuePair("b", "bb"), s), + s => Assert.Equal(new KeyValuePair(null, null), s)); + Assert.Collection( + candidate.Captures, + c => Assert.Equal(("c", 1, 1), c)); + Assert.Equal(default, candidate.CatchAll); + Assert.Collection( + candidate.ComplexSegments, + s => Assert.Equal(0, s.segmentIndex)); + Assert.Empty(candidate.MatchProcessors); + } + + [Fact] + public void CreateCandidate_MatchProcessors() + { + // Arrange + var endpoint = CreateEndpoint("/a/b/c", matchProcessors: new MatchProcessorReference[] + { + new MatchProcessorReference("a", new IntRouteConstraint()), + }); + + var builder = CreateDfaMatcherBuilder(); + + // Act + var candidate = builder.CreateCandidate(new MatcherBuilderEntry(endpoint)); + + // Assert + Assert.Equal( Candidate.CandidateFlags.HasMatchProcessors, candidate.Flags); + Assert.Empty(candidate.Slots); + Assert.Empty(candidate.Captures); + Assert.Equal(default, candidate.CatchAll); + Assert.Empty(candidate.ComplexSegments); + Assert.Single(candidate.MatchProcessors); + } + + private static DfaMatcherBuilder CreateDfaMatcherBuilder() + { + var dataSource = new CompositeEndpointDataSource(Array.Empty()); + return new DfaMatcherBuilder( + Mock.Of(), + new EndpointSelector( + dataSource, + new EndpointConstraintCache(dataSource, Array.Empty()), + NullLoggerFactory.Instance)); + } + + private MatcherEndpoint CreateEndpoint( + string template, + object defaults = null, + IEnumerable matchProcessors = null) + { + matchProcessors = matchProcessors ?? Array.Empty(); + + return new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + template, + new RouteValueDictionary(defaults), + new RouteValueDictionary(), + matchProcessors.ToList(), + 0, + new EndpointMetadataCollection(Array.Empty()), + "test"); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherConformanceTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherConformanceTest.cs index 07431ea51d..d8e05d3ae8 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherConformanceTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/DfaMatcherConformanceTest.cs @@ -1,13 +1,39 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + namespace Microsoft.AspNetCore.Routing.Matchers { - public class DfaMatcherConformanceTest : MatcherConformanceTest + public class DfaMatcherConformanceTest : FullFeaturedMatcherConformanceTest { + // See the comments in the base class. DfaMatcher fixes a long-standing bug + // with catchall parameters and empty segments. + public override async Task Quirks_CatchAllParameter(string template, string path, string[] keys, string[] values) + { + // Arrange + var (matcher, endpoint) = CreateMatcher(template); + var (httpContext, feature) = CreateContext(path); + + // Act + await matcher.MatchAsync(httpContext, feature); + + // Assert + DispatcherAssert.AssertMatch(feature, endpoint, keys, values); + } + internal override Matcher CreateMatcher(params MatcherEndpoint[] endpoints) { - var builder = new DfaMatcherBuilder(); + var services = new ServiceCollection() + .AddLogging() + .AddOptions() + .AddRouting() + .AddDispatcher() + .AddTransient() + .BuildServiceProvider(); + + var builder = services.GetRequiredService(); for (int i = 0; i < endpoints.Length; i++) { builder.AddEndpoint(endpoints[i]); diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/FullFeaturedMatcherConformanceTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/FullFeaturedMatcherConformanceTest.cs index a3fa0bee46..0bed93f541 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/FullFeaturedMatcherConformanceTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/FullFeaturedMatcherConformanceTest.cs @@ -171,10 +171,14 @@ namespace Microsoft.AspNetCore.Routing.Matchers DispatcherAssert.AssertMatch(feature, endpoint, keys, values); } + // Historically catchall segments don't match an empty segment, but only if it's + // the first one. So `/a/b//` would match, but `/a//` would not. This is pretty + // wierd and inconsistent with the intent of using a catch all. The DfaMatcher + // fixes this issue. [Theory] - [InlineData("/{a}/{*b=b}", "/a///")] - [InlineData("/{a}/{*b=b}", "/a//c/")] - public virtual async Task NotMatch_CatchAllParameter(string template, string path) + [InlineData("/{a}/{*b=b}", "/a///", new[] { "a", "b", }, new[] { "a", "//" })] + [InlineData("/{a}/{*b=b}", "/a//c/", new[] { "a", "b", }, new[] { "a", "/c/" })] + public virtual async Task Quirks_CatchAllParameter(string template, string path, string[] keys, string[] values) { // Arrange var (matcher, endpoint) = CreateMatcher(template); @@ -185,6 +189,11 @@ namespace Microsoft.AspNetCore.Routing.Matchers // Assert DispatcherAssert.AssertNotMatch(feature); + + // Need to access these to prevent a warning from the xUnit analyzer. + // Some of these tests will match (and process the values) and some will not. + GC.KeepAlive(keys); + GC.KeepAlive(values); } [Theory] diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs index f6645374eb..6f0cba2f23 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matchers/TreeRouterMatcherBuilder.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Routing.EndpointConstraints; using Microsoft.AspNetCore.Routing.Internal; -using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.Routing.Tree; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.ObjectPool;