From 0ea16ddd57e084159a1a2a42b9563aa8346e62ba Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Thu, 31 May 2018 16:52:59 -0700 Subject: [PATCH] Code dump of dispatcher prototype code Here's a code dump of the parts of the Dispatcher prototype codebase that are needed to get us off the ground. This first cut attempts to use part of routing where possible, and not all of those changes will be long-lasting. I'll leave comments through thoughout the PR for education. --- Routing.sln | 15 + .../DispatcherSample.Web.csproj | 17 + samples/DispatcherSample.Web/Program.cs | 26 ++ samples/DispatcherSample.Web/Startup.cs | 46 +++ samples/RoutingSample.Web/Program.cs | 26 +- samples/RoutingSample.Web/Startup.cs | 43 +++ .../Endpoint.cs | 22 ++ .../IEndpointFeature.cs | 17 + .../MetadataCollection.cs | 101 +++++ .../DispatcherApplicationBuilderExtensions.cs | 20 + .../CompositeEndpointDataSource.cs | 70 ++++ .../DefaultEndpointDataSource.cs | 30 ++ .../ConfigureDispatcherOptions.cs | 38 ++ .../DispatcherServiceCollectionExtensions.cs | 55 +++ .../DispatcherMiddleware.cs | 114 ++++++ .../DispatcherOptions.cs | 12 + .../EndpointDataSource.cs | 15 + .../EndpointFeature.cs | 17 + .../EndpointMiddleware.cs | 77 ++++ .../Internal/DataSourceDependantCache.cs | 55 +++ .../Matchers/AmbiguousMatchException.cs | 25 ++ .../Matchers/IMatcherFactory.cs | 10 + .../Matchers/Matcher.cs | 25 ++ .../Matchers/MatcherEndpoint.cs | 42 +++ .../Matchers/TreeMatcher.cs | 348 ++++++++++++++++++ .../Matchers/TreeMatcherFactory.cs | 40 ++ .../Properties/AssemblyInfo.cs | 1 + .../Properties/Resources.Designer.cs | 14 + .../Resources.resx | 3 + .../Tree/InboundRouteEntry.cs | 2 + .../Tree/TreeRouteBuilder.cs | 164 +-------- .../Tree/TreeRouter.cs | 106 +----- .../Tree/Treenumerator.cs | 112 ++++++ .../Tree/UrlMatchingTree.cs | 167 +++++++++ 34 files changed, 1582 insertions(+), 293 deletions(-) create mode 100644 samples/DispatcherSample.Web/DispatcherSample.Web.csproj create mode 100644 samples/DispatcherSample.Web/Program.cs create mode 100644 samples/DispatcherSample.Web/Startup.cs create mode 100644 samples/RoutingSample.Web/Startup.cs create mode 100644 src/Microsoft.AspNetCore.Routing.Abstractions/Endpoint.cs create mode 100644 src/Microsoft.AspNetCore.Routing.Abstractions/IEndpointFeature.cs create mode 100644 src/Microsoft.AspNetCore.Routing.Abstractions/MetadataCollection.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Builder/DispatcherApplicationBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs create mode 100644 src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs create mode 100644 src/Microsoft.AspNetCore.Routing/DependencyInjection/ConfigureDispatcherOptions.cs create mode 100644 src/Microsoft.AspNetCore.Routing/DependencyInjection/DispatcherServiceCollectionExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Routing/DispatcherMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Routing/DispatcherOptions.cs create mode 100644 src/Microsoft.AspNetCore.Routing/EndpointDataSource.cs create mode 100644 src/Microsoft.AspNetCore.Routing/EndpointFeature.cs create mode 100644 src/Microsoft.AspNetCore.Routing/EndpointMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Internal/DataSourceDependantCache.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Matchers/AmbiguousMatchException.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Matchers/IMatcherFactory.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Matchers/Matcher.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpoint.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcher.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcherFactory.cs create mode 100644 src/Microsoft.AspNetCore.Routing/Tree/Treenumerator.cs diff --git a/Routing.sln b/Routing.sln index e1478b7f15..851dea0a2e 100644 --- a/Routing.sln +++ b/Routing.sln @@ -43,6 +43,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{6DC6B416 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Routing.Performance", "benchmarks\Microsoft.AspNetCore.Routing.Performance\Microsoft.AspNetCore.Routing.Performance.csproj", "{F3D86714-4E64-41A6-9B36-A47B3683CF5D}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DispatcherSample.Web", "samples\DispatcherSample.Web\DispatcherSample.Web.csproj", "{4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{D5F39F59-5725-4127-82E7-67028D006185}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarkapps", "benchmarkapps", "{7F5914E2-C63F-4759-898E-462804357C90}" @@ -161,6 +163,18 @@ Global {91F47A60-9A78-4968-B10D-157D9BFAC37F}.Release|Mixed Platforms.Build.0 = Release|Any CPU {91F47A60-9A78-4968-B10D-157D9BFAC37F}.Release|x86.ActiveCfg = Release|Any CPU {91F47A60-9A78-4968-B10D-157D9BFAC37F}.Release|x86.Build.0 = Release|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Debug|x86.Build.0 = Debug|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Release|Any CPU.Build.0 = Release|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Release|x86.ActiveCfg = Release|Any CPU + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -175,6 +189,7 @@ Global {5C73140B-41F3-466F-A07B-3614E4D80DF9} = {95359B4B-4C85-4B44-A75B-0621905C4CF6} {F3D86714-4E64-41A6-9B36-A47B3683CF5D} = {D5F39F59-5725-4127-82E7-67028D006185} {91F47A60-9A78-4968-B10D-157D9BFAC37F} = {7F5914E2-C63F-4759-898E-462804357C90} + {4EBE7A6F-3183-4A7D-B3D7-A6A9EC3867A5} = {C3ADD55B-B9C7-4061-8AD4-6A70D1AE3B2E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {36C8D815-B7F1-479D-894B-E606FB8DECDA} diff --git a/samples/DispatcherSample.Web/DispatcherSample.Web.csproj b/samples/DispatcherSample.Web/DispatcherSample.Web.csproj new file mode 100644 index 0000000000..52b1699c7b --- /dev/null +++ b/samples/DispatcherSample.Web/DispatcherSample.Web.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp2.2 + $(TargetFrameworks);net461 + + + + + + + + + + + + diff --git a/samples/DispatcherSample.Web/Program.cs b/samples/DispatcherSample.Web/Program.cs new file mode 100644 index 0000000000..45c4e82afa --- /dev/null +++ b/samples/DispatcherSample.Web/Program.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace DispatcherSample.Web +{ + public class Program + { + public static void Main(string[] args) + { + var webHost = GetWebHostBuilder().Build(); + webHost.Run(); + } + + // For unit testing + public static IWebHostBuilder GetWebHostBuilder() + { + return new WebHostBuilder() + .UseKestrel() + .UseIISIntegration() + .UseStartup(); + } + } +} diff --git a/samples/DispatcherSample.Web/Startup.cs b/samples/DispatcherSample.Web/Startup.cs new file mode 100644 index 0000000000..41dfcf113e --- /dev/null +++ b/samples/DispatcherSample.Web/Startup.cs @@ -0,0 +1,46 @@ +// 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.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.DependencyInjection; + +namespace DispatcherSample.Web +{ + public class Startup + { + private static readonly byte[] _helloWorldPayload = Encoding.UTF8.GetBytes("Hello, World!"); + + public void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + services.AddDispatcher(options => + { + options.DataSources.Add(new DefaultEndpointDataSource(new[] + { + new MatcherEndpoint((next) => (httpContext) => + { + var response = httpContext.Response; + var payloadLength = _helloWorldPayload.Length; + response.StatusCode = 200; + response.ContentType = "text/plain"; + response.ContentLength = payloadLength; + return response.Body.WriteAsync(_helloWorldPayload, 0, payloadLength); + }, + "/plaintext", new { }, 0, EndpointMetadataCollection.Empty, "Plaintext"), + })); + }); + } + + public void Configure(IApplicationBuilder app) + { + app.UseDispatcher(); + + // Imagine some more stuff here... + + app.UseEndpoint(); + } + } +} diff --git a/samples/RoutingSample.Web/Program.cs b/samples/RoutingSample.Web/Program.cs index 27a1a8b55e..c8449236ba 100644 --- a/samples/RoutingSample.Web/Program.cs +++ b/samples/RoutingSample.Web/Program.cs @@ -1,21 +1,13 @@ // 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.Text.RegularExpressions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Constraints; -using Microsoft.Extensions.DependencyInjection; namespace RoutingSample.Web { public class Program { - private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(10); - public static void Main(string[] args) { var webHost = GetWebHostBuilder().Build(); @@ -28,23 +20,7 @@ namespace RoutingSample.Web return new WebHostBuilder() .UseKestrel() .UseIISIntegration() - .ConfigureServices(services => services.AddRouting()) - .Configure(app => app.UseRouter(routes => - { - routes.DefaultHandler = new RouteHandler((httpContext) => - { - var request = httpContext.Request; - return httpContext.Response.WriteAsync($"Verb = {request.Method.ToUpperInvariant()} - Path = {request.Path} - Route values - {string.Join(", ", httpContext.GetRouteData().Values)}"); - }); - - routes.MapGet("api/get/{id}", (request, response, routeData) => response.WriteAsync($"API Get {routeData.Values["id"]}")) - .MapMiddlewareRoute("api/middleware", (appBuilder) => appBuilder.Use((httpContext, next) => httpContext.Response.WriteAsync("Middleware!"))) - .MapRoute( - name: "AllVerbs", - template: "api/all/{name}/{lastName?}", - defaults: new { lastName = "Doe" }, - constraints: new { lastName = new RegexRouteConstraint(new Regex("[a-zA-Z]{3}", RegexOptions.CultureInvariant, RegexMatchTimeout)) }); - })); + .UseStartup(); } } } diff --git a/samples/RoutingSample.Web/Startup.cs b/samples/RoutingSample.Web/Startup.cs new file mode 100644 index 0000000000..3205b4f42d --- /dev/null +++ b/samples/RoutingSample.Web/Startup.cs @@ -0,0 +1,43 @@ +// 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.Text.RegularExpressions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.Extensions.DependencyInjection; + +namespace RoutingSample.Web +{ + public class Startup + { + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(10); + + public void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouter(routes => + { + routes.DefaultHandler = new RouteHandler((httpContext) => + { + var request = httpContext.Request; + return httpContext.Response.WriteAsync($"Verb = {request.Method.ToUpperInvariant()} - Path = {request.Path} - Route values - {string.Join(", ", httpContext.GetRouteData().Values)}"); + }); + + routes.MapGet("api/get/{id}", (request, response, routeData) => response.WriteAsync($"API Get {routeData.Values["id"]}")) + .MapMiddlewareRoute("api/middleware", (appBuilder) => appBuilder.Use((httpContext, next) => httpContext.Response.WriteAsync("Middleware!"))) + .MapRoute( + name: "AllVerbs", + template: "api/all/{name}/{lastName?}", + defaults: new { lastName = "Doe" }, + constraints: new { lastName = new RegexRouteConstraint(new Regex("[a-zA-Z]{3}", RegexOptions.CultureInvariant, RegexMatchTimeout)) }); + }); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/Endpoint.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/Endpoint.cs new file mode 100644 index 0000000000..21a2b06bad --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/Endpoint.cs @@ -0,0 +1,22 @@ +// 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 +{ + [DebuggerDisplay("{DisplayName,nq}")] + public abstract class Endpoint + { + protected Endpoint(EndpointMetadataCollection metadata, string displayName) + { + // Both allowed to be null + Metadata = metadata ?? EndpointMetadataCollection.Empty; + DisplayName = displayName; + } + + public string DisplayName { get; } + + public EndpointMetadataCollection Metadata { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/IEndpointFeature.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/IEndpointFeature.cs new file mode 100644 index 0000000000..5ecf72cb56 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/IEndpointFeature.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + public interface IEndpointFeature + { + Endpoint Endpoint { get; set; } + + Func Invoker { get; set; } + + RouteValueDictionary Values { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/MetadataCollection.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/MetadataCollection.cs new file mode 100644 index 0000000000..a90f8590e8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/MetadataCollection.cs @@ -0,0 +1,101 @@ +// 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.Linq; + +namespace Microsoft.AspNetCore.Routing +{ + public class EndpointMetadataCollection : IReadOnlyList + { + public static readonly EndpointMetadataCollection Empty = new EndpointMetadataCollection(Array.Empty()); + + private readonly object[] _items; + + public EndpointMetadataCollection(IEnumerable items) + { + if (items == null) + { + throw new ArgumentNullException(nameof(items)); + } + + _items = items.ToArray(); + } + + public object this[int index] => _items[index]; + + public int Count => _items.Length; + + public T GetMetadata() where T : class + { + for (var i = _items.Length -1; i >= 0; i--) + { + var item = _items[i] as T; + if (item !=null) + { + return item; + } + } + + return default; + } + + public IEnumerable GetOrderedMetadata() where T : class + { + for (var i = 0; i < _items.Length; i++) + { + var item = _items[i] as T; + if (item != null) + { + yield return item; + } + } + } + + public Enumerator GetEnumerator() => new Enumerator(this); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public struct Enumerator : IEnumerator + { + private object[] _items; + private int _index; + private object _current; + + internal Enumerator(EndpointMetadataCollection collection) + { + _items = collection._items; + _index = 0; + _current = null; + } + + public object Current => _current; + + public void Dispose() + { + } + + public bool MoveNext() + { + if (_index < _items.Length) + { + _current = _items[_index++]; + return true; + } + + _current = null; + return false; + } + + public void Reset() + { + _index = 0; + _current = null; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Builder/DispatcherApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.Routing/Builder/DispatcherApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..c3cc3df56c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Builder/DispatcherApplicationBuilderExtensions.cs @@ -0,0 +1,20 @@ +// 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 Microsoft.AspNetCore.Routing; + +namespace Microsoft.AspNetCore.Builder +{ + public static class DispatcherApplicationBuilderExtensions + { + public static IApplicationBuilder UseDispatcher(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + + public static IApplicationBuilder UseEndpoint(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs b/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs new file mode 100644 index 0000000000..93fd595ede --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/CompositeEndpointDataSource.cs @@ -0,0 +1,70 @@ +// 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.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Routing +{ + public class CompositeEndpointDataSource : EndpointDataSource + { + private readonly EndpointDataSource[] _dataSources; + private readonly object _lock; + + private IChangeToken _changeToken; + private IReadOnlyList _endpoints; + + internal CompositeEndpointDataSource(IEnumerable dataSources) + { + if (dataSources == null) + { + throw new ArgumentNullException(nameof(dataSources)); + } + + _dataSources = dataSources.ToArray(); + _lock = new object(); + } + + public override IChangeToken ChangeToken + { + get + { + EnsureInitialized(); + return _changeToken; + } + } + + public override IReadOnlyList Endpoints + { + get + { + EnsureInitialized(); + return _endpoints; + } + } + + // Defer initialization to avoid doing lots of reflection on startup. + private void EnsureInitialized() + { + if (_changeToken == null) + { + Initialize(); + } + } + + // Note: we can't use DataSourceDependantCache here because we also need to handle a list of change + // tokens, which is a complication most of our code doesn't have. + private void Initialize() + { + lock (_lock) + { + _changeToken = new CompositeChangeToken(_dataSources.Select(d => d.ChangeToken).ToArray()); + _endpoints = _dataSources.SelectMany(d => d.Endpoints).ToArray(); + + _changeToken.RegisterChangeCallback((state) => Initialize(), null); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs b/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs new file mode 100644 index 0000000000..6dfbdbb38c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/DefaultEndpointDataSource.cs @@ -0,0 +1,30 @@ +// 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.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Routing +{ + public class DefaultEndpointDataSource : EndpointDataSource + { + private readonly List _endpoints; + + public DefaultEndpointDataSource(IEnumerable endpoints) + { + if (endpoints == null) + { + throw new ArgumentNullException(nameof(endpoints)); + } + + _endpoints = new List(); + _endpoints.AddRange(endpoints); + } + + public override IChangeToken ChangeToken { get; } = NullChangeToken.Singleton; + + public override IReadOnlyList Endpoints => _endpoints; + } +} diff --git a/src/Microsoft.AspNetCore.Routing/DependencyInjection/ConfigureDispatcherOptions.cs b/src/Microsoft.AspNetCore.Routing/DependencyInjection/ConfigureDispatcherOptions.cs new file mode 100644 index 0000000000..e916dd0e18 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/DependencyInjection/ConfigureDispatcherOptions.cs @@ -0,0 +1,38 @@ +// 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; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection +{ + internal class ConfigureDispatcherOptions : IConfigureOptions + { + private readonly IEnumerable _dataSources; + + public ConfigureDispatcherOptions(IEnumerable dataSources) + { + if (dataSources == null) + { + throw new ArgumentNullException(nameof(dataSources)); + } + + _dataSources = dataSources; + } + + public void Configure(DispatcherOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + foreach (var dataSource in _dataSources) + { + options.DataSources.Add(dataSource); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/DependencyInjection/DispatcherServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Routing/DependencyInjection/DispatcherServiceCollectionExtensions.cs new file mode 100644 index 0000000000..d1f5d96f5f --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/DependencyInjection/DispatcherServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +// 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 Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class DispatcherServiceCollectionExtensions + { + public static IServiceCollection AddDispatcher(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + // Collect all data sources from DI. + services.TryAddEnumerable(ServiceDescriptor.Transient, ConfigureDispatcherOptions>()); + + // Allow global access to the list of endpoints. + services.TryAddSingleton(s => + { + var options = s.GetRequiredService>(); + return new CompositeEndpointDataSource(options.Value.DataSources); + }); + + // + // Default matcher implementation + // + services.TryAddSingleton(); + + return services; + } + + public static IServiceCollection AddDispatcher(this IServiceCollection services, Action configuration) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddDispatcher(); + if (configuration != null) + { + services.Configure(configuration); + } + + return services; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/DispatcherMiddleware.cs b/src/Microsoft.AspNetCore.Routing/DispatcherMiddleware.cs new file mode 100644 index 0000000000..98bfce4601 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/DispatcherMiddleware.cs @@ -0,0 +1,114 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Matchers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Routing +{ + internal sealed class DispatcherMiddleware + { + private readonly MatcherFactory _matcherFactory; + private readonly ILogger _logger; + private readonly IOptions _options; + private readonly RequestDelegate _next; + + private Task _initializationTask; + + public DispatcherMiddleware( + MatcherFactory matcherFactory, + IOptions options, + ILogger logger, + RequestDelegate next) + { + if (matcherFactory == null) + { + throw new ArgumentNullException(nameof(matcherFactory)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + _matcherFactory = matcherFactory; + _options = options; + _logger = logger; + _next = next; + } + + public async Task Invoke(HttpContext httpContext) + { + var feature = new EndpointFeature(); + httpContext.Features.Set(feature); + + // There's an inherit race condition between waiting for init and accessing the matcher + // this is OK because once `_matcher` is initialized, it will not be set to null again. + var matcher = await InitializeAsync(); + + await matcher.MatchAsync(httpContext, feature); + if (feature.Endpoint != null) + { + Log.EndpointMatched(_logger, feature); + } + + await _next(httpContext); + } + + // Initialization is async to avoid blocking threads while reflection and things + // of that nature take place. + // + // We've seen cases where startup is very slow if we allow multiple threads to race + // while initializing the set of endpoints/routes. Doing CPU intensive work is a + // blocking operation if you have a low core count and enough work to do. + private Task InitializeAsync() + { + if (_initializationTask != null) + { + return _initializationTask; + } + + var initializationTask = new TaskCompletionSource(); + if (Interlocked.CompareExchange>( + ref _initializationTask, + initializationTask.Task, + null) == null) + { + // This thread won the race, do the initialization. + var dataSource = new CompositeEndpointDataSource(_options.Value.DataSources); + var matcher = _matcherFactory.CreateMatcher(dataSource); + initializationTask.SetResult(matcher); + } + + return _initializationTask; + } + + private static class Log + { + private static readonly Action _matchSuccess = LoggerMessage.Define( + LogLevel.Information, + new EventId(0, "MatchSuccess"), + "Request matched endpoint '{EndpointName}'."); + + public static void EndpointMatched(ILogger logger, EndpointFeature feature) + { + _matchSuccess(logger, feature.Endpoint.DisplayName, null); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/DispatcherOptions.cs b/src/Microsoft.AspNetCore.Routing/DispatcherOptions.cs new file mode 100644 index 0000000000..1ec4c2f397 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/DispatcherOptions.cs @@ -0,0 +1,12 @@ +// 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.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing +{ + public class DispatcherOptions + { + public IList DataSources { get; } = new List(); + } +} diff --git a/src/Microsoft.AspNetCore.Routing/EndpointDataSource.cs b/src/Microsoft.AspNetCore.Routing/EndpointDataSource.cs new file mode 100644 index 0000000000..a102ff2e39 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/EndpointDataSource.cs @@ -0,0 +1,15 @@ +// 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.Collections.Generic; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Routing +{ + public abstract class EndpointDataSource + { + public abstract IChangeToken ChangeToken { get; } + + public abstract IReadOnlyList Endpoints { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs b/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs new file mode 100644 index 0000000000..62d943ca84 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing +{ + public sealed class EndpointFeature : IEndpointFeature + { + public Endpoint Endpoint { get; set; } + + public Func Invoker { get; set; } + + public RouteValueDictionary Values { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/EndpointMiddleware.cs b/src/Microsoft.AspNetCore.Routing/EndpointMiddleware.cs new file mode 100644 index 0000000000..51351c0b09 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/EndpointMiddleware.cs @@ -0,0 +1,77 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Routing +{ + internal sealed class EndpointMiddleware + { + private readonly ILogger _logger; + private readonly RequestDelegate _next; + + public EndpointMiddleware(ILogger logger, RequestDelegate next) + { + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + _logger = logger; + _next = next; + } + + public async Task Invoke(HttpContext httpContext) + { + var feature = httpContext.Features.Get(); + if (feature.Invoker != null) + { + Log.ExecutingEndpoint(_logger, feature.Endpoint); + + try + { + await feature.Invoker(_next)(httpContext); + } + finally + { + Log.ExecutedEndpoint(_logger, feature.Endpoint); + } + + return; + } + + await _next(httpContext); + } + + private static class Log + { + private static readonly Action _executingEndpoint = LoggerMessage.Define( + LogLevel.Information, + new EventId(0, "ExecutingEndpoint"), + "Executing endpoint '{EndpointName}'."); + + private static readonly Action _executedEndpoint = LoggerMessage.Define( + LogLevel.Information, + new EventId(1, "ExecutedEndpoint"), + "Executed endpoint '{EndpointName}'."); + + public static void ExecutingEndpoint(ILogger logger, Endpoint endpoint) + { + _executingEndpoint(logger, endpoint.DisplayName, null); + } + + public static void ExecutedEndpoint(ILogger logger, Endpoint endpoint) + { + _executingEndpoint(logger, endpoint.DisplayName, null); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Internal/DataSourceDependantCache.cs b/src/Microsoft.AspNetCore.Routing/Internal/DataSourceDependantCache.cs new file mode 100644 index 0000000000..4ea432ca8d --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Internal/DataSourceDependantCache.cs @@ -0,0 +1,55 @@ +// 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.Threading; + +namespace Microsoft.AspNetCore.Routing.Internal +{ + internal class DataSourceDependantCache where T : class + { + private readonly EndpointDataSource _dataSource; + private readonly Func, T> _initializeCore; + private readonly Func _initializer; + private readonly Action _initializerWithState; + + private object _lock; + private bool _initialized; + private T _value; + + public DataSourceDependantCache(EndpointDataSource dataSource, Func, T> initialize) + { + if (dataSource == null) + { + throw new ArgumentNullException(nameof(dataSource)); + } + + _dataSource = dataSource; + _initializeCore = initialize; + + _initializer = Initialize; + _initializerWithState = (state) => Initialize(); + _lock = new object(); + } + + public T Value => _value; + + public T EnsureInitialized() + { + return LazyInitializer.EnsureInitialized(ref _value, ref _initialized, ref _lock, _initializer); + } + + private T Initialize() + { + lock (_lock) + { + var changeToken = _dataSource.ChangeToken; + _value = _initializeCore(_dataSource.Endpoints); + + changeToken.RegisterChangeCallback(_initializerWithState, null); + return _value; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/AmbiguousMatchException.cs b/src/Microsoft.AspNetCore.Routing/Matchers/AmbiguousMatchException.cs new file mode 100644 index 0000000000..e0d7a75397 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/AmbiguousMatchException.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; +using System.Runtime.Serialization; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + /// + /// An exception which indicates multiple matches in endpoint selection. + /// + [Serializable] + internal class AmbiguousMatchException : Exception + { + public AmbiguousMatchException(string message) + : base(message) + { + } + + protected AmbiguousMatchException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/IMatcherFactory.cs b/src/Microsoft.AspNetCore.Routing/Matchers/IMatcherFactory.cs new file mode 100644 index 0000000000..3975443b0e --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/IMatcherFactory.cs @@ -0,0 +1,10 @@ +// 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. + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal abstract class MatcherFactory + { + public abstract Matcher CreateMatcher(EndpointDataSource dataSource); + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/Matcher.cs b/src/Microsoft.AspNetCore.Routing/Matchers/Matcher.cs new file mode 100644 index 0000000000..9cb5921e6a --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/Matcher.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.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + /// + /// An interface for components that can select an given the current request, as part + /// of the execution of . + /// + internal abstract class Matcher + { + /// + /// Attempts to asynchronously select an for the current request. + /// + /// The associated with the current request. + /// + /// The associated with the current request. The + /// will be mutated to contain the result of the operation. + /// A which represents the asynchronous completion of the operation. + public abstract Task MatchAsync(HttpContext httpContext, IEndpointFeature feature); + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpoint.cs b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpoint.cs new file mode 100644 index 0000000000..a4bc0fc9ea --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/MatcherEndpoint.cs @@ -0,0 +1,42 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal sealed class MatcherEndpoint : Endpoint + { + public MatcherEndpoint( + Func invoker, + string template, + object values, + int order, + EndpointMetadataCollection metadata, + string displayName) + : base(metadata, displayName) + { + if (invoker == null) + { + throw new ArgumentNullException(nameof(invoker)); + } + + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + Invoker = invoker; + Template = template; + Values = new RouteValueDictionary(values); + } + + public int Order { get; } + public Func Invoker { get; } + public string Template { get; } + + public IReadOnlyDictionary Values { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcher.cs b/src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcher.cs new file mode 100644 index 0000000000..be952cddc4 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcher.cs @@ -0,0 +1,348 @@ +// 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 System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Internal; +using Microsoft.AspNetCore.Routing.Template; +using Microsoft.AspNetCore.Routing.Tree; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class TreeMatcher : Matcher + { + private readonly IInlineConstraintResolver _constraintFactory; + private readonly ILogger _logger; + private readonly DataSourceDependantCache _cache; + + public TreeMatcher( + IInlineConstraintResolver constraintFactory, + ILogger logger, + EndpointDataSource dataSource) + { + if (constraintFactory == null) + { + throw new ArgumentNullException(nameof(constraintFactory)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (dataSource == null) + { + throw new ArgumentNullException(nameof(dataSource)); + } + + _constraintFactory = constraintFactory; + _logger = logger; + _cache = new DataSourceDependantCache(dataSource, CreateTrees); + _cache.EnsureInitialized(); + } + + public override async Task MatchAsync(HttpContext httpContext, IEndpointFeature feature) + { + if (httpContext == null) + { + throw new ArgumentNullException(nameof(httpContext)); + } + + if (feature == null) + { + throw new ArgumentNullException(nameof(feature)); + } + + var values = new RouteValueDictionary(); + feature.Values = values; + + var cache = _cache.Value; + for (var i = 0; i < cache.Length; i++) + { + var tree = cache[i]; + var tokenizer = new PathTokenizer(httpContext.Request.Path); + + var treenumerator = new Treenumerator(tree.Root, tokenizer); + + while (treenumerator.MoveNext()) + { + var node = treenumerator.Current; + foreach (var item in node.Matches) + { + var entry = item.Entry; + var matcher = item.TemplateMatcher; + + values.Clear(); + if (!matcher.TryMatch(httpContext.Request.Path, values)) + { + continue; + } + + Log.MatchedTemplate(_logger, httpContext, entry.RouteTemplate); + + if (!MatchConstraints(httpContext, values, entry.Constraints)) + { + continue; + } + + await SelectEndpointAsync(httpContext, feature, (MatcherEndpoint[])entry.Tag); + + if (feature.Endpoint != null) + { + if (feature.Endpoint is MatcherEndpoint endpoint) + { + foreach (var kvp in endpoint.Values) + { + if (!feature.Values.ContainsKey(kvp.Key)) + { + feature.Values[kvp.Key] = kvp.Value; + } + } + } + + return; + } + } + } + } + } + + private bool MatchConstraints( + HttpContext httpContext, + RouteValueDictionary values, + IDictionary constraints) + { + if (constraints != null) + { + foreach (var kvp in constraints) + { + var constraint = kvp.Value; + if (!constraint.Match(httpContext, null, kvp.Key, values, RouteDirection.IncomingRequest)) + { + values.TryGetValue(kvp.Key, out var value); + + Log.ConstraintFailed(_logger, value, kvp.Key, kvp.Value); + return false; + } + } + } + + return true; + } + + private Task SelectEndpointAsync(HttpContext httpContext, IEndpointFeature feature, IReadOnlyList endpoints) + { + switch (endpoints.Count) + { + case 0: + { + Log.MatchFailed(_logger, httpContext); + return Task.CompletedTask; + } + + case 1: + { + var endpoint = endpoints[0]; + Log.MatchSuccess(_logger, httpContext, endpoint); + + feature.Endpoint = endpoint; + feature.Invoker = endpoint.Invoker; + + return Task.CompletedTask; + } + + default: + { + Log.MatchAmbiguous(_logger, httpContext, endpoints); + var message = Resources.FormatAmbiguousEndpoints( + Environment.NewLine, + string.Join(Environment.NewLine, endpoints.Select(a => a.DisplayName))); + throw new AmbiguousMatchException(message); + } + } + } + + private UrlMatchingTree[] CreateTrees(IReadOnlyList endpoints) + { + var groups = new Dictionary>(); + + for (var i = 0; i < endpoints.Count; i++) + { + var endpoint = endpoints[i] as MatcherEndpoint; + if (endpoint == null) + { + continue; + } + + var order = endpoint.Order; + if (!groups.TryGetValue(new Key(order, endpoint.Template), out var group)) + { + group = new List(); + groups.Add(new Key(order, endpoint.Template), group); + } + + group.Add(endpoint); + } + + var entries = new List(); + foreach (var group in groups) + { + var template = TemplateParser.Parse(group.Key.Template); + var entryExists = entries.Any(item => item.RouteTemplate.TemplateText == template.TemplateText); + if (!entryExists) + { + entries.Add(MapInbound(template, group.Value.ToArray(), group.Key.Order)); + } + } + + var trees = new List(); + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + + while (trees.Count <= entry.Order) + { + trees.Add(new UrlMatchingTree(entry.Order)); + } + + var tree = trees[entry.Order]; + tree.AddEntry(entry); + } + + return trees.ToArray(); + } + + private InboundRouteEntry MapInbound(RouteTemplate template, Endpoint[] endpoints, int order) + { + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + var entry = new InboundRouteEntry() + { + Precedence = RoutePrecedence.ComputeInbound(template), + RouteTemplate = template, + Order = order, + Tag = endpoints, + }; + + var constraintBuilder = new RouteConstraintBuilder(_constraintFactory, template.TemplateText); + foreach (var parameter in template.Parameters) + { + if (parameter.InlineConstraints != null) + { + if (parameter.IsOptional) + { + constraintBuilder.SetOptional(parameter.Name); + } + + foreach (var constraint in parameter.InlineConstraints) + { + constraintBuilder.AddResolvedConstraint(parameter.Name, constraint.Constraint); + } + } + } + + entry.Constraints = constraintBuilder.Build(); + + entry.Defaults = new RouteValueDictionary(); + foreach (var parameter in entry.RouteTemplate.Parameters) + { + if (parameter.DefaultValue != null) + { + entry.Defaults.Add(parameter.Name, parameter.DefaultValue); + } + } + return entry; + } + + private struct Key : IEquatable + { + public readonly int Order; + public readonly string Template; + + public Key(int order, string routePattern) + { + Order = order; + Template = routePattern; + } + + public bool Equals(Key other) + { + return Order == other.Order && string.Equals(Template, other.Template, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is Key ? Equals((Key)obj) : false; + } + + public override int GetHashCode() + { + var hash = new HashCodeCombiner(); + hash.Add(Order); + hash.Add(Template, StringComparer.OrdinalIgnoreCase); + return hash; + } + } + + private static class Log + { + private static readonly Action _matchSuccess = LoggerMessage.Define( + LogLevel.Debug, + new EventId(0, "MatchSuccess"), + "Request matched endpoint '{EndpointName}' for request path '{Path}'."); + + private static readonly Action _matchFailed = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "MatchFailed"), + "No endpoints matched request path '{Path}'."); + + private static readonly Action, Exception> _matchAmbiguous = LoggerMessage.Define>( + LogLevel.Error, + new EventId(2, "MatchAmbiguous"), + "Request matched multiple endpoints for request path '{Path}'. Matching endpoints: {AmbiguousEndpoints}"); + + private static readonly Action _constraintFailed = LoggerMessage.Define( + LogLevel.Debug, + new EventId(3, "ContraintFailed"), + "Route value '{RouteValue}' with key '{RouteKey}' did not match the constraint '{RouteConstraint}'."); + + private static readonly Action _matchedTemplate = LoggerMessage.Define( + LogLevel.Debug, + new EventId(4, "MatchedTemplate"), + "Request matched the route pattern '{RouteTemplate}' for request path '{Path}'."); + + public static void MatchSuccess(ILogger logger, HttpContext httpContext, Endpoint endpoint) + { + _matchSuccess(logger, endpoint.DisplayName, httpContext.Request.Path, null); + } + + public static void MatchFailed(ILogger logger, HttpContext httpContext) + { + _matchFailed(logger, httpContext.Request.Path, null); + } + + public static void MatchAmbiguous(ILogger logger, HttpContext httpContext, IEnumerable endpoints) + { + _matchAmbiguous(logger, httpContext.Request.Path, endpoints.Select(e => e.DisplayName), null); + } + + public static void ConstraintFailed(ILogger logger, object routeValue, string routeKey, IRouteConstraint routeConstraint) + { + _constraintFailed(logger, routeValue, routeKey, routeConstraint, null); + } + + public static void MatchedTemplate(ILogger logger, HttpContext httpContext, RouteTemplate template) + { + _matchedTemplate(logger, httpContext.Request.Path, template.TemplateText, null); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcherFactory.cs b/src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcherFactory.cs new file mode 100644 index 0000000000..1e90b82a14 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matchers/TreeMatcherFactory.cs @@ -0,0 +1,40 @@ +// 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 Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class TreeMatcherFactory : MatcherFactory + { + private readonly IInlineConstraintResolver _constraintFactory; + private readonly ILogger _logger; + + public TreeMatcherFactory(IInlineConstraintResolver constraintFactory, ILogger logger) + { + if (constraintFactory == null) + { + throw new ArgumentNullException(nameof(constraintFactory)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + _constraintFactory = constraintFactory; + _logger = logger; + } + + public override Matcher CreateMatcher(EndpointDataSource dataSource) + { + if (dataSource == null) + { + throw new ArgumentNullException(nameof(dataSource)); + } + + return new TreeMatcher(_constraintFactory, _logger, dataSource); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs index d8b03e1556..e78ab5ccec 100644 --- a/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Routing/Properties/AssemblyInfo.cs @@ -4,3 +4,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Routing.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("DispatcherSample.Web, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs index 6d2bb0a7c9..9384008378 100644 --- a/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Routing/Properties/Resources.Designer.cs @@ -402,6 +402,20 @@ namespace Microsoft.AspNetCore.Routing internal static string FormatTemplateRoute_Exception(object p0, object p1) => string.Format(CultureInfo.CurrentCulture, GetString("TemplateRoute_Exception"), p0, p1); + /// + /// The request matched multiple endpoints. Matches: {0}{0}{1} + /// + internal static string AmbiguousEndpoints + { + get => GetString("AmbiguousEndpoints"); + } + + /// + /// The request matched multiple endpoints. Matches: {0}{0}{1} + /// + internal static string FormatAmbiguousEndpoints(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("AmbiguousEndpoints"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Routing/Resources.resx b/src/Microsoft.AspNetCore.Routing/Resources.resx index d883906f7c..354bdfda0e 100644 --- a/src/Microsoft.AspNetCore.Routing/Resources.resx +++ b/src/Microsoft.AspNetCore.Routing/Resources.resx @@ -201,4 +201,7 @@ An error occurred while creating the route with name '{0}' and template '{1}'. + + The request matched multiple endpoints. Matches: {0}{0}{1} + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Tree/InboundRouteEntry.cs b/src/Microsoft.AspNetCore.Routing/Tree/InboundRouteEntry.cs index 7c4a5f0abc..1a1dd3daa7 100644 --- a/src/Microsoft.AspNetCore.Routing/Tree/InboundRouteEntry.cs +++ b/src/Microsoft.AspNetCore.Routing/Tree/InboundRouteEntry.cs @@ -52,5 +52,7 @@ namespace Microsoft.AspNetCore.Routing.Tree /// Gets or sets the . /// public RouteTemplate RouteTemplate { get; set; } + + public object Tag { get; set; } } } diff --git a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs index a746e7d170..400424f4c1 100644 --- a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs +++ b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouteBuilder.cs @@ -266,7 +266,7 @@ namespace Microsoft.AspNetCore.Routing.Tree trees.Add(entry.Order, tree); } - AddEntryToTree(tree, entry); + tree.AddEntry(entry); } return new TreeRouter( @@ -288,167 +288,5 @@ namespace Microsoft.AspNetCore.Routing.Tree InboundEntries.Clear(); OutboundEntries.Clear(); } - - private void AddEntryToTree(UrlMatchingTree tree, InboundRouteEntry entry) - { - // The url matching tree represents all the routes asociated with a given - // order. Each node in the tree represents all the different categories - // a segment can have for which there is a defined inbound route entry. - // Each node contains a set of Matches that indicate all the routes for which - // a URL is a potential match. This list contains the routes with the same - // number of segments and the routes with the same number of segments plus an - // additional catch all parameter (as it can be empty). - // For example, for a set of routes like: - // 'Customer/Index/{id}' - // '{Controller}/{Action}/{*parameters}' - // - // The route tree will look like: - // Root -> - // Literals: Customer -> - // Literals: Index -> - // Parameters: {id} - // Matches: 'Customer/Index/{id}' - // Parameters: {Controller} -> - // Parameters: {Action} -> - // Matches: '{Controller}/{Action}/{*parameters}' - // CatchAlls: {*parameters} - // Matches: '{Controller}/{Action}/{*parameters}' - // - // When the tree router tries to match a route, it iterates the list of url matching trees - // in ascending order. For each tree it traverses each node starting from the root in the - // following order: Literals, constrained parameters, parameters, constrained catch all routes, catch alls. - // When it gets to a node of the same length as the route its trying to match, it simply looks at the list of - // candidates (which is in precence order) and tries to match the url against it. - // - - var current = tree.Root; - var matcher = new TemplateMatcher(entry.RouteTemplate, entry.Defaults); - - for (var i = 0; i < entry.RouteTemplate.Segments.Count; i++) - { - var segment = entry.RouteTemplate.Segments[i]; - if (!segment.IsSimple) - { - // Treat complex segments as a constrained parameter - if (current.ConstrainedParameters == null) - { - current.ConstrainedParameters = new UrlMatchingNode(length: i + 1); - } - - current = current.ConstrainedParameters; - continue; - } - - Debug.Assert(segment.Parts.Count == 1); - var part = segment.Parts[0]; - if (part.IsLiteral) - { - UrlMatchingNode next; - if (!current.Literals.TryGetValue(part.Text, out next)) - { - next = new UrlMatchingNode(length: i + 1); - current.Literals.Add(part.Text, next); - } - - current = next; - continue; - } - - // We accept templates that have intermediate optional values, but we ignore - // those values for route matching. For that reason, we need to add the entry - // to the list of matches, only if the remaining segments are optional. For example: - // /{controller}/{action=Index}/{id} will be equivalent to /{controller}/{action}/{id} - // for the purposes of route matching. - if (part.IsParameter && - RemainingSegmentsAreOptional(entry.RouteTemplate.Segments, i)) - { - current.Matches.Add(new InboundMatch() { Entry = entry, TemplateMatcher = matcher }); - } - - if (part.IsParameter && part.InlineConstraints.Any() && !part.IsCatchAll) - { - if (current.ConstrainedParameters == null) - { - current.ConstrainedParameters = new UrlMatchingNode(length: i + 1); - } - - current = current.ConstrainedParameters; - continue; - } - - if (part.IsParameter && !part.IsCatchAll) - { - if (current.Parameters == null) - { - current.Parameters = new UrlMatchingNode(length: i + 1); - } - - current = current.Parameters; - continue; - } - - if (part.IsParameter && part.InlineConstraints.Any() && part.IsCatchAll) - { - if (current.ConstrainedCatchAlls == null) - { - current.ConstrainedCatchAlls = new UrlMatchingNode(length: i + 1) { IsCatchAll = true }; - } - - current = current.ConstrainedCatchAlls; - continue; - } - - if (part.IsParameter && part.IsCatchAll) - { - if (current.CatchAlls == null) - { - current.CatchAlls = new UrlMatchingNode(length: i + 1) { IsCatchAll = true }; - } - - current = current.CatchAlls; - continue; - } - - Debug.Fail("We shouldn't get here."); - } - - current.Matches.Add(new InboundMatch() { Entry = entry, TemplateMatcher = matcher }); - current.Matches.Sort((x, y) => - { - var result = x.Entry.Precedence.CompareTo(y.Entry.Precedence); - return result == 0 ? x.Entry.RouteTemplate.TemplateText.CompareTo(y.Entry.RouteTemplate.TemplateText) : result; - }); - } - - private static bool RemainingSegmentsAreOptional(IList segments, int currentParameterIndex) - { - for (var i = currentParameterIndex; i < segments.Count; i++) - { - if (!segments[i].IsSimple) - { - // /{complex}-{segment} - return false; - } - - var part = segments[i].Parts[0]; - if (!part.IsParameter) - { - // /literal - return false; - } - - var isOptionlCatchAllOrHasDefaultValue = part.IsOptional || - part.IsCatchAll || - part.DefaultValue != null; - - if (!isOptionlCatchAllOrHasDefaultValue) - { - // /{parameter} - return false; - } - } - - return true; - } } } diff --git a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs index 31a1091093..223b65e0e4 100644 --- a/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs +++ b/src/Microsoft.AspNetCore.Routing/Tree/TreeRouter.cs @@ -2,9 +2,7 @@ // 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.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Routing.Internal; @@ -179,7 +177,7 @@ namespace Microsoft.AspNetCore.Routing.Tree var tokenizer = new PathTokenizer(context.HttpContext.Request.Path); var root = tree.Root; - var treeEnumerator = new TreeEnumerator(root, tokenizer); + var treeEnumerator = new Treenumerator(root, tokenizer); // Create a snapshot before processing the route. We'll restore this snapshot before running each // to restore the state. This is likely an "empty" snapshot, which doesn't allocate. @@ -233,108 +231,6 @@ namespace Microsoft.AspNetCore.Routing.Tree } } - private struct TreeEnumerator : IEnumerator - { - private readonly Stack _stack; - private readonly PathTokenizer _tokenizer; - - public TreeEnumerator(UrlMatchingNode root, PathTokenizer tokenizer) - { - _stack = new Stack(); - _tokenizer = tokenizer; - Current = null; - - _stack.Push(root); - } - - public UrlMatchingNode Current { get; private set; } - - object IEnumerator.Current => Current; - - public void Dispose() - { - } - - public bool MoveNext() - { - if (_stack == null) - { - return false; - } - - while (_stack.Count > 0) - { - var next = _stack.Pop(); - - // In case of wild card segment, the request path segment length can be greater - // Example: - // Template: a/{*path} - // Request Url: a/b/c/d - if (next.IsCatchAll && next.Matches.Count > 0) - { - Current = next; - return true; - } - // Next template has the same length as the url we are trying to match - // The only possible matching segments are either our current matches or - // any catch-all segment after this segment in which the catch all is empty. - else if (next.Depth == _tokenizer.Count) - { - if (next.Matches.Count > 0) - { - Current = next; - return true; - } - else - { - // We can stop looking as any other child node from this node will be - // either a literal, a constrained parameter or a parameter. - // (Catch alls and constrained catch alls will show up as candidate matches). - continue; - } - } - - if (next.CatchAlls != null) - { - _stack.Push(next.CatchAlls); - } - - if (next.ConstrainedCatchAlls != null) - { - _stack.Push(next.ConstrainedCatchAlls); - } - - if (next.Parameters != null) - { - _stack.Push(next.Parameters); - } - - if (next.ConstrainedParameters != null) - { - _stack.Push(next.ConstrainedParameters); - } - - if (next.Literals.Count > 0) - { - UrlMatchingNode node; - Debug.Assert(next.Depth < _tokenizer.Count); - if (next.Literals.TryGetValue(_tokenizer[next.Depth].Value, out node)) - { - _stack.Push(node); - } - } - } - - return false; - } - - public void Reset() - { - _stack.Clear(); - Current = null; - } - } - private VirtualPathData GetVirtualPathForNamedRoute(VirtualPathContext context) { OutboundMatch match; diff --git a/src/Microsoft.AspNetCore.Routing/Tree/Treenumerator.cs b/src/Microsoft.AspNetCore.Routing/Tree/Treenumerator.cs new file mode 100644 index 0000000000..5da4d3ce09 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Tree/Treenumerator.cs @@ -0,0 +1,112 @@ +// 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.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.AspNetCore.Routing.Internal; + +namespace Microsoft.AspNetCore.Routing.Tree +{ + internal struct Treenumerator : IEnumerator + { + private readonly Stack _stack; + private readonly PathTokenizer _tokenizer; + + public Treenumerator(UrlMatchingNode root, PathTokenizer tokenizer) + { + _stack = new Stack(); + _tokenizer = tokenizer; + Current = null; + + _stack.Push(root); + } + + public UrlMatchingNode Current { get; private set; } + + object IEnumerator.Current => Current; + + public void Dispose() + { + } + + public bool MoveNext() + { + if (_stack == null) + { + return false; + } + + while (_stack.Count > 0) + { + var next = _stack.Pop(); + + // In case of wild card segment, the request path segment length can be greater + // Example: + // Template: a/{*path} + // Request Url: a/b/c/d + if (next.IsCatchAll && next.Matches.Count > 0) + { + Current = next; + return true; + } + // Next template has the same length as the url we are trying to match + // The only possible matching segments are either our current matches or + // any catch-all segment after this segment in which the catch all is empty. + else if (next.Depth == _tokenizer.Count) + { + if (next.Matches.Count > 0) + { + Current = next; + return true; + } + else + { + // We can stop looking as any other child node from this node will be + // either a literal, a constrained parameter or a parameter. + // (Catch alls and constrained catch alls will show up as candidate matches). + continue; + } + } + + if (next.CatchAlls != null) + { + _stack.Push(next.CatchAlls); + } + + if (next.ConstrainedCatchAlls != null) + { + _stack.Push(next.ConstrainedCatchAlls); + } + + if (next.Parameters != null) + { + _stack.Push(next.Parameters); + } + + if (next.ConstrainedParameters != null) + { + _stack.Push(next.ConstrainedParameters); + } + + if (next.Literals.Count > 0) + { + UrlMatchingNode node; + Debug.Assert(next.Depth < _tokenizer.Count); + if (next.Literals.TryGetValue(_tokenizer[next.Depth].Value, out node)) + { + _stack.Push(node); + } + } + } + + return false; + } + + public void Reset() + { + _stack.Clear(); + Current = null; + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Tree/UrlMatchingTree.cs b/src/Microsoft.AspNetCore.Routing/Tree/UrlMatchingTree.cs index 90528d75b9..96e6940653 100644 --- a/src/Microsoft.AspNetCore.Routing/Tree/UrlMatchingTree.cs +++ b/src/Microsoft.AspNetCore.Routing/Tree/UrlMatchingTree.cs @@ -1,6 +1,11 @@ // 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.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.AspNetCore.Routing.Template; + namespace Microsoft.AspNetCore.Routing.Tree { /// @@ -26,5 +31,167 @@ namespace Microsoft.AspNetCore.Routing.Tree /// Gets the root of the . /// public UrlMatchingNode Root { get; } = new UrlMatchingNode(length: 0); + + internal void AddEntry(InboundRouteEntry entry) + { + // The url matching tree represents all the routes asociated with a given + // order. Each node in the tree represents all the different categories + // a segment can have for which there is a defined inbound route entry. + // Each node contains a set of Matches that indicate all the routes for which + // a URL is a potential match. This list contains the routes with the same + // number of segments and the routes with the same number of segments plus an + // additional catch all parameter (as it can be empty). + // For example, for a set of routes like: + // 'Customer/Index/{id}' + // '{Controller}/{Action}/{*parameters}' + // + // The route tree will look like: + // Root -> + // Literals: Customer -> + // Literals: Index -> + // Parameters: {id} + // Matches: 'Customer/Index/{id}' + // Parameters: {Controller} -> + // Parameters: {Action} -> + // Matches: '{Controller}/{Action}/{*parameters}' + // CatchAlls: {*parameters} + // Matches: '{Controller}/{Action}/{*parameters}' + // + // When the tree router tries to match a route, it iterates the list of url matching trees + // in ascending order. For each tree it traverses each node starting from the root in the + // following order: Literals, constrained parameters, parameters, constrained catch all routes, catch alls. + // When it gets to a node of the same length as the route its trying to match, it simply looks at the list of + // candidates (which is in precence order) and tries to match the url against it. + // + + var current = Root; + var matcher = new TemplateMatcher(entry.RouteTemplate, entry.Defaults); + + for (var i = 0; i < entry.RouteTemplate.Segments.Count; i++) + { + var segment = entry.RouteTemplate.Segments[i]; + if (!segment.IsSimple) + { + // Treat complex segments as a constrained parameter + if (current.ConstrainedParameters == null) + { + current.ConstrainedParameters = new UrlMatchingNode(length: i + 1); + } + + current = current.ConstrainedParameters; + continue; + } + + Debug.Assert(segment.Parts.Count == 1); + var part = segment.Parts[0]; + if (part.IsLiteral) + { + UrlMatchingNode next; + if (!current.Literals.TryGetValue(part.Text, out next)) + { + next = new UrlMatchingNode(length: i + 1); + current.Literals.Add(part.Text, next); + } + + current = next; + continue; + } + + // We accept templates that have intermediate optional values, but we ignore + // those values for route matching. For that reason, we need to add the entry + // to the list of matches, only if the remaining segments are optional. For example: + // /{controller}/{action=Index}/{id} will be equivalent to /{controller}/{action}/{id} + // for the purposes of route matching. + if (part.IsParameter && + RemainingSegmentsAreOptional(entry.RouteTemplate.Segments, i)) + { + current.Matches.Add(new InboundMatch() { Entry = entry, TemplateMatcher = matcher }); + } + + if (part.IsParameter && part.InlineConstraints.Any() && !part.IsCatchAll) + { + if (current.ConstrainedParameters == null) + { + current.ConstrainedParameters = new UrlMatchingNode(length: i + 1); + } + + current = current.ConstrainedParameters; + continue; + } + + if (part.IsParameter && !part.IsCatchAll) + { + if (current.Parameters == null) + { + current.Parameters = new UrlMatchingNode(length: i + 1); + } + + current = current.Parameters; + continue; + } + + if (part.IsParameter && part.InlineConstraints.Any() && part.IsCatchAll) + { + if (current.ConstrainedCatchAlls == null) + { + current.ConstrainedCatchAlls = new UrlMatchingNode(length: i + 1) { IsCatchAll = true }; + } + + current = current.ConstrainedCatchAlls; + continue; + } + + if (part.IsParameter && part.IsCatchAll) + { + if (current.CatchAlls == null) + { + current.CatchAlls = new UrlMatchingNode(length: i + 1) { IsCatchAll = true }; + } + + current = current.CatchAlls; + continue; + } + + Debug.Fail("We shouldn't get here."); + } + + current.Matches.Add(new InboundMatch() { Entry = entry, TemplateMatcher = matcher }); + current.Matches.Sort((x, y) => + { + var result = x.Entry.Precedence.CompareTo(y.Entry.Precedence); + return result == 0 ? x.Entry.RouteTemplate.TemplateText.CompareTo(y.Entry.RouteTemplate.TemplateText) : result; + }); + } + + private static bool RemainingSegmentsAreOptional(IList segments, int currentParameterIndex) + { + for (var i = currentParameterIndex; i < segments.Count; i++) + { + if (!segments[i].IsSimple) + { + // /{complex}-{segment} + return false; + } + + var part = segments[i].Parts[0]; + if (!part.IsParameter) + { + // /literal + return false; + } + + var isOptionlCatchAllOrHasDefaultValue = part.IsOptional || + part.IsCatchAll || + part.DefaultValue != null; + + if (!isOptionlCatchAllOrHasDefaultValue) + { + // /{parameter} + return false; + } + } + + return true; + } } }