diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/EndpointMetadataCollectionBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/EndpointMetadataCollectionBenchmark.cs new file mode 100644 index 0000000000..14c1498462 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/EndpointMetadataCollectionBenchmark.cs @@ -0,0 +1,127 @@ +// 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 BenchmarkDotNet.Attributes; + +namespace Microsoft.AspNetCore.Routing +{ + public class EndpointMetadataCollectionBenchmark + { + private object[] _items; + private EndpointMetadataCollection _collection; + + [Params(3, 10, 25)] + public int Count { get; set; } + + [GlobalSetup] + public void Setup() + { + var seeds = new Type[] + { + typeof(Metadata1), + typeof(Metadata2), + typeof(Metadata3), + typeof(Metadata4), + typeof(Metadata5), + typeof(Metadata6), + typeof(Metadata7), + typeof(Metadata8), + typeof(Metadata9), + }; + + _items = new object[Count]; + for (var i = 0; i < _items.Length; i++) + { + _items[i] = seeds[i % seeds.Length]; + } + + _collection = new EndpointMetadataCollection(_items); + } + + // This is a synthetic baseline that visits each item and does an as-cast. + [Benchmark(Baseline = true, OperationsPerInvoke = 5)] + public void Baseline() + { + var items = _items; + for (var i = items.Length - 1; i >= 0; i--) + { + GC.KeepAlive(_items[i] as IMetadata1); + } + + for (var i = items.Length - 1; i >= 0; i--) + { + GC.KeepAlive(_items[i] as IMetadata2); + } + + for (var i = items.Length - 1; i >= 0; i--) + { + GC.KeepAlive(_items[i] as IMetadata3); + } + + for (var i = items.Length - 1; i >= 0; i--) + { + GC.KeepAlive(_items[i] as IMetadata4); + } + + for (var i = items.Length - 1; i >= 0; i--) + { + GC.KeepAlive(_items[i] as IMetadata5); + } + } + + [Benchmark(OperationsPerInvoke = 5)] + public void GetMetadata() + { + GC.KeepAlive(_collection.GetMetadata()); + GC.KeepAlive(_collection.GetMetadata()); + GC.KeepAlive(_collection.GetMetadata()); + GC.KeepAlive(_collection.GetMetadata()); + GC.KeepAlive(_collection.GetMetadata()); + } + + [Benchmark(OperationsPerInvoke = 5)] + public void GetOrderedMetadata() + { + foreach (var item in _collection.GetOrderedMetadata()) + { + GC.KeepAlive(item); + } + + foreach (var item in _collection.GetOrderedMetadata()) + { + GC.KeepAlive(item); + } + + foreach (var item in _collection.GetOrderedMetadata()) + { + GC.KeepAlive(item); + } + + foreach (var item in _collection.GetOrderedMetadata()) + { + GC.KeepAlive(item); + } + + foreach (var item in _collection.GetOrderedMetadata()) + { + GC.KeepAlive(item); + } + } + + private interface IMetadata1 { } + private interface IMetadata2 { } + private interface IMetadata3 { } + private interface IMetadata4 { } + private interface IMetadata5 { } + private class Metadata1 : IMetadata1 { } + private class Metadata2 : IMetadata2 { } + private class Metadata3 : IMetadata3 { } + private class Metadata4 : IMetadata4 { } + private class Metadata5 : IMetadata5 { } + private class Metadata6 : IMetadata1, IMetadata2 { } + private class Metadata7 : IMetadata2, IMetadata3 { } + private class Metadata8 : IMetadata4, IMetadata5 { } + private class Metadata9 : IMetadata1, IMetadata2 { } + } +} diff --git a/src/Microsoft.AspNetCore.Routing.Abstractions/EndpointMetadataCollection.cs b/src/Microsoft.AspNetCore.Routing.Abstractions/EndpointMetadataCollection.cs index 54624d8717..ee12d0b8e7 100644 --- a/src/Microsoft.AspNetCore.Routing.Abstractions/EndpointMetadataCollection.cs +++ b/src/Microsoft.AspNetCore.Routing.Abstractions/EndpointMetadataCollection.cs @@ -3,8 +3,10 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; namespace Microsoft.AspNetCore.Routing { @@ -24,6 +26,7 @@ namespace Microsoft.AspNetCore.Routing public static readonly EndpointMetadataCollection Empty = new EndpointMetadataCollection(Array.Empty()); private readonly object[] _items; + private readonly ConcurrentDictionary _cache; /// /// Creates a new . @@ -37,6 +40,7 @@ namespace Microsoft.AspNetCore.Routing } _items = items.ToArray(); + _cache = new ConcurrentDictionary(); } /// @@ -67,18 +71,23 @@ namespace Microsoft.AspNetCore.Routing /// /// The most significant metadata of type or null. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public T GetMetadata() where T : class { - for (var i = _items.Length - 1; i >= 0; i--) + if (_cache.TryGetValue(typeof(T), out var result)) { - var item = _items[i] as T; - if (item != null) - { - return item; - } + var length = result.Length; + return length > 0 ? (T)result[length - 1] : default; } - return default; + return GetMetadataSlow(); + } + + private T GetMetadataSlow() where T : class + { + var array = GetOrderedMetadataSlow(); + var length = array.Length; + return length > 0 ? array[length - 1] : default; } /// @@ -87,16 +96,32 @@ namespace Microsoft.AspNetCore.Routing /// /// The type of metadata. /// A sequence of metadata items of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] public IEnumerable GetOrderedMetadata() where T : class { + if (_cache.TryGetValue(typeof(T), out var result)) + { + return (T[])result; + } + + return GetOrderedMetadataSlow(); + } + + private T[] GetOrderedMetadataSlow() where T : class + { + var items = new List(); for (var i = 0; i < _items.Length; i++) { var item = _items[i] as T; if (item != null) { - yield return item; + items.Add(item); } } + + var array = items.ToArray(); + _cache.TryAdd(typeof(T), array); + return array; } /// diff --git a/src/Microsoft.AspNetCore.Routing/DataTokensMetadata.cs b/src/Microsoft.AspNetCore.Routing/DataTokensMetadata.cs new file mode 100644 index 0000000000..99a07f038d --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/DataTokensMetadata.cs @@ -0,0 +1,31 @@ +// 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; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Metadata that defines data tokens for an . This metadata + /// type provides data tokens value for associated + /// with an endpoint. + /// + public sealed class DataTokensMetadata : IDataTokensMetadata + { + public DataTokensMetadata(IReadOnlyDictionary dataTokens) + { + if (dataTokens == null) + { + throw new ArgumentNullException(nameof(dataTokens)); + } + + DataTokens = dataTokens; + } + + /// + /// Get the data tokens. + /// + public IReadOnlyDictionary DataTokens { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs b/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs index 396f9b00ee..c87db04900 100644 --- a/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs +++ b/src/Microsoft.AspNetCore.Routing/EndpointFeature.cs @@ -54,7 +54,20 @@ namespace Microsoft.AspNetCore.Routing { if (_routeData == null) { - _routeData = new RouteData(_values); + _routeData = _values == null ? new RouteData() : new RouteData(_values); + + // Note: DataTokens won't update if someone else overwrites the Endpoint + // after route values has been set. This seems find since endpoints are a new + // feature and DataTokens are for back-compat. + var dataTokensMetadata = Endpoint?.Metadata.GetMetadata(); + if (dataTokensMetadata != null) + { + var dataTokens = _routeData.DataTokens; + foreach (var kvp in dataTokensMetadata.DataTokens) + { + _routeData.DataTokens.Add(kvp.Key, kvp.Value); + } + } } return _routeData; diff --git a/src/Microsoft.AspNetCore.Routing/IDataTokenMetadata.cs b/src/Microsoft.AspNetCore.Routing/IDataTokenMetadata.cs new file mode 100644 index 0000000000..9cd6455d6c --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/IDataTokenMetadata.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 System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Metadata that defines data tokens for an . This metadata + /// type provides data tokens value for associated + /// with an endpoint. + /// + public interface IDataTokensMetadata + { + /// + /// Get the data tokens. + /// + IReadOnlyDictionary DataTokens { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/ISuppressMatchingMetadata.cs b/src/Microsoft.AspNetCore.Routing/ISuppressMatchingMetadata.cs new file mode 100644 index 0000000000..8baa0e0a77 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/ISuppressMatchingMetadata.cs @@ -0,0 +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. + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Metadata used to prevent URL matching. The associated endpoint will not be + /// considered URL matching for incoming requests. + /// + public interface ISuppressMatchingMetadata + { + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Routing/Internal/DfaGraphWriter.cs b/src/Microsoft.AspNetCore.Routing/Internal/DfaGraphWriter.cs index 9a1c8773f3..2d036ac76b 100644 --- a/src/Microsoft.AspNetCore.Routing/Internal/DfaGraphWriter.cs +++ b/src/Microsoft.AspNetCore.Routing/Internal/DfaGraphWriter.cs @@ -39,7 +39,7 @@ namespace Microsoft.AspNetCore.Routing.Internal for (var i = 0; i < endpoints.Count; i++) { var endpoint = endpoints[i] as MatcherEndpoint; - if (endpoint != null) + if (endpoint != null && endpoint.Metadata.GetMetadata() == null) { builder.AddEndpoint(endpoint); } diff --git a/src/Microsoft.AspNetCore.Routing/Matching/DataSourceDependentMatcher.cs b/src/Microsoft.AspNetCore.Routing/Matching/DataSourceDependentMatcher.cs index 5ff901b96d..9dced3dc3d 100644 --- a/src/Microsoft.AspNetCore.Routing/Matching/DataSourceDependentMatcher.cs +++ b/src/Microsoft.AspNetCore.Routing/Matching/DataSourceDependentMatcher.cs @@ -40,7 +40,7 @@ namespace Microsoft.AspNetCore.Routing.Matching // register other endpoint types, which are non-routable, and it's // ok that we won't route to them. var endpoint = endpoints[i] as MatcherEndpoint; - if (endpoint != null) + if (endpoint != null && endpoint.Metadata.GetMetadata() == null) { builder.AddEndpoint(endpoint); } diff --git a/src/Microsoft.AspNetCore.Routing/SuppressMatchingMetadata.cs b/src/Microsoft.AspNetCore.Routing/SuppressMatchingMetadata.cs new file mode 100644 index 0000000000..da7a3bce1d --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/SuppressMatchingMetadata.cs @@ -0,0 +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. + +namespace Microsoft.AspNetCore.Routing +{ + /// + /// Metadata used to prevent URL matching. The associated endpoint will not be + /// considered URL matching for incoming requests. + /// + public sealed class SuppressMatchingMetadata : ISuppressMatchingMetadata + { + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/EndpointMetadataCollectionTests.cs b/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/EndpointMetadataCollectionTests.cs index a7e645c047..12c75020a4 100644 --- a/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/EndpointMetadataCollectionTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Routing.Abstractions.Tests/EndpointMetadataCollectionTests.cs @@ -44,5 +44,98 @@ namespace Microsoft.AspNetCore.Routing value => Assert.Equal(2, value), value => Assert.Equal(3, value)); } + + [Fact] + public void GetMetadata_Match_ReturnsLastMatchingEntry() + { + // Arrange + var items = new object[] + { + new Metadata1(), + new Metadata2(), + new Metadata3(), + }; + + var metadata = new EndpointMetadataCollection(items); + + // Act + var result = metadata.GetMetadata(); + + // Assert + Assert.Same(items[1], result); + } + + [Fact] + public void GetMetadata_NoMatch_ReturnsNull() + { + // Arrange + var items = new object[] + { + new Metadata3(), + new Metadata3(), + new Metadata3(), + }; + + var metadata = new EndpointMetadataCollection(items); + + // Act + var result = metadata.GetMetadata(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetOrderedMetadata_Match_ReturnsItemsInAscendingOrder() + { + // Arrange + var items = new object[] + { + new Metadata1(), + new Metadata2(), + new Metadata3(), + }; + + var metadata = new EndpointMetadataCollection(items); + + // Act + var result = metadata.GetOrderedMetadata(); + + // Assert + Assert.Collection( + result, + i => Assert.Same(items[0], i), + i => Assert.Same(items[1], i)); + } + + [Fact] + public void GetOrderedMetadata_NoMatch_ReturnsEmpty() + { + // Arrange + var items = new object[] + { + new Metadata3(), + new Metadata3(), + new Metadata3(), + }; + + var metadata = new EndpointMetadataCollection(items); + + // Act + var result = metadata.GetOrderedMetadata(); + + // Assert + Assert.Empty(result); + } + + private interface IMetadata1 { } + private interface IMetadata2 { } + private interface IMetadata3 { } + private interface IMetadata4 { } + private interface IMetadata5 { } + private class Metadata1 : IMetadata1, IMetadata4, IMetadata5 { } + private class Metadata2 : IMetadata2, IMetadata5 { } + private class Metadata3 : IMetadata3 { } + } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Routing.Tests/EndpointFeatureTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/EndpointFeatureTest.cs new file mode 100644 index 0000000000..5ffd868630 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/EndpointFeatureTest.cs @@ -0,0 +1,58 @@ +// 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.Linq; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.AspNetCore.Routing.Patterns; +using Xunit; + +namespace Microsoft.AspNetCore.Routing +{ + public class EndpointFeatureTest + { + [Fact] + public void RouteData_CanIntializeDataTokens_WithMetadata() + { + // Arrange + var expected = new RouteValueDictionary(new { foo = 17, bar = "hello", }); + + var feature = new EndpointFeature() + { + Endpoint = new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse("/"), + 0, + new EndpointMetadataCollection(new DataTokensMetadata(expected)), + "test"), + }; + + // Act + var routeData = ((IRoutingFeature)feature).RouteData; + + // Assert + Assert.NotSame(expected, routeData.DataTokens); + Assert.Equal(expected.OrderBy(kvp => kvp.Key), routeData.DataTokens.OrderBy(kvp => kvp.Key)); + } + + [Fact] + public void RouteData_DataTokensIsEmpty_WithoutMetadata() + { + // Arrange + var feature = new EndpointFeature() + { + Endpoint = new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse("/"), + 0, + new EndpointMetadataCollection(), + "test"), + }; + + // Act + var routeData = ((IRoutingFeature)feature).RouteData; + + // Assert + Assert.Empty(routeData.DataTokens); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/EndpointRoutingMiddlewareTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/EndpointRoutingMiddlewareTest.cs index df5c8d831c..186eb9f0e3 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/EndpointRoutingMiddlewareTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/EndpointRoutingMiddlewareTest.cs @@ -82,6 +82,30 @@ namespace Microsoft.AspNetCore.Routing Assert.Equal("testValue", endpointFeature.Values["testKey"]); } + [Fact] + public async Task Invoke_BackCompatGetDataTokens_ValueUsedFromEndpointMetadata() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = new TestServiceProvider(); + + var middleware = CreateMiddleware(); + + // Act + await middleware.Invoke(httpContext); + var routeData = httpContext.GetRouteData(); + var routeValue = httpContext.GetRouteValue("controller"); + var endpointFeature = httpContext.Features.Get(); + + // Assert + Assert.NotNull(routeData); + Assert.Equal("Home", (string)routeValue); + + // changing route data value is reflected in endpoint feature values + routeData.Values["testKey"] = "testValue"; + Assert.Equal("testValue", endpointFeature.Values["testKey"]); + } + private EndpointRoutingMiddleware CreateMiddleware(Logger logger = null) { RequestDelegate next = (c) => Task.FromResult(null); diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matching/DataSourceDependentMatcherTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matching/DataSourceDependentMatcherTest.cs index 07c1db9537..74b87b56fc 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matching/DataSourceDependentMatcherTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matching/DataSourceDependentMatcherTest.cs @@ -67,6 +67,27 @@ namespace Microsoft.AspNetCore.Routing.Matching Assert.Empty(inner.Endpoints); } + [Fact] + public void Matcher_Ignores_SuppressedEndpoint() + { + // Arrange + var dataSource = new DynamicEndpointDataSource(); + var endpoint = new MatcherEndpoint( + MatcherEndpoint.EmptyInvoker, + RoutePatternFactory.Parse("/"), + 0, + new EndpointMetadataCollection(new SuppressMatchingMetadata()), + "test"); + dataSource.AddEndpoint(endpoint); + + // Act + var matcher = new DataSourceDependentMatcher(dataSource, TestMatcherBuilder.Create); + + // Assert + var inner = Assert.IsType(matcher.CurrentMatcher); + Assert.Empty(inner.Endpoints); + } + [Fact] public void Cache_Reinitializes_WhenDataSourceChanges() {