diff --git a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index cb8a46daea..d41bb4590e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -145,9 +145,6 @@ namespace Microsoft.Extensions.DependencyInjection services.TryAddSingleton(); services.TryAddSingleton(); - // Performs caching - services.TryAddSingleton(); - // Will be cached by the DefaultActionSelector services.TryAddEnumerable( ServiceDescriptor.Transient()); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelectionDecisionTree.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelectionDecisionTree.cs deleted file mode 100644 index 2a92cd9e6e..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelectionDecisionTree.cs +++ /dev/null @@ -1,98 +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.Linq; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.DecisionTree; - -namespace Microsoft.AspNetCore.Mvc.Internal -{ - /// - public class ActionSelectionDecisionTree : IActionSelectionDecisionTree - { - private readonly DecisionTreeNode _root; - - /// - /// Creates a new . - /// - /// The . - public ActionSelectionDecisionTree(ActionDescriptorCollection actions) - { - Version = actions.Version; - - var conventionalRoutedActions = actions.Items.Where(a => a.AttributeRouteInfo?.Template == null).ToArray(); - _root = DecisionTreeBuilder.GenerateTree( - conventionalRoutedActions, - new ActionDescriptorClassifier()); - } - - /// - public int Version { get; private set; } - - /// - public IReadOnlyList Select(IDictionary routeValues) - { - var results = new List(); - Walk(results, routeValues, _root); - - return results; - } - - private void Walk( - List results, - IDictionary routeValues, - DecisionTreeNode node) - { - for (var i = 0; i < node.Matches.Count; i++) - { - results.Add(node.Matches[i]); - } - - for (var i = 0; i < node.Criteria.Count; i++) - { - var criterion = node.Criteria[i]; - var key = criterion.Key; - - object value; - routeValues.TryGetValue(key, out value); - - DecisionTreeNode branch; - if (criterion.Branches.TryGetValue(value ?? string.Empty, out branch)) - { - Walk(results, routeValues, branch); - } - } - } - - private class ActionDescriptorClassifier : IClassifier - { - public ActionDescriptorClassifier() - { - ValueComparer = new RouteValueEqualityComparer(); - } - - public IEqualityComparer ValueComparer { get; private set; } - - public IDictionary GetCriteria(ActionDescriptor item) - { - var results = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (item.RouteValues != null) - { - foreach (var kvp in item.RouteValues) - { - // null and string.Empty are equivalent for route values, so just treat nulls as - // string.Empty. - results.Add(kvp.Key, new DecisionCriterionValue(kvp.Value ?? string.Empty)); - } - } - - return results; - } - } - } -} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs index ef4b6a3cfd..3d389b8ed6 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelector.cs @@ -3,12 +3,15 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ActionConstraints; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc.Internal @@ -18,27 +21,51 @@ namespace Microsoft.AspNetCore.Mvc.Internal /// public class ActionSelector : IActionSelector { - private readonly IActionSelectorDecisionTreeProvider _decisionTreeProvider; + private static readonly IReadOnlyList EmptyActions = Array.Empty(); + + private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; private readonly ActionConstraintCache _actionConstraintCache; - private ILogger _logger; + private readonly ILogger _logger; + + private Cache _cache; /// /// Creates a new . /// - /// The . + /// + /// The . + /// /// The that /// providers a set of instances. /// The . public ActionSelector( - IActionSelectorDecisionTreeProvider decisionTreeProvider, + IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, ActionConstraintCache actionConstraintCache, ILoggerFactory loggerFactory) { - _decisionTreeProvider = decisionTreeProvider; + _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; _logger = loggerFactory.CreateLogger(); _actionConstraintCache = actionConstraintCache; } + private Cache Current + { + get + { + var actions = _actionDescriptorCollectionProvider.ActionDescriptors; + var cache = Volatile.Read(ref _cache); + + if (cache != null && cache.Version == actions.Version) + { + return cache; + } + + cache = new Cache(actions); + Volatile.Write(ref _cache, cache); + return cache; + } + } + public IReadOnlyList SelectCandidates(RouteContext context) { if (context == null) @@ -46,8 +73,31 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(context)); } - var tree = _decisionTreeProvider.DecisionTree; - return tree.Select(context.RouteData.Values); + var cache = Current; + + // The Cache works based on a string[] of the route values in a pre-calculated order. This code extracts + // those values in the correct order. + var keys = cache.RouteKeys; + var values = new string[keys.Length]; + for (var i = 0; i < keys.Length; i++) + { + context.RouteData.Values.TryGetValue(keys[i], out object value); + + if (value != null) + { + values[i] = value as string ?? Convert.ToString(value); + } + } + + if (cache.OrdinalEntries.TryGetValue(values, out var matchingRouteValues) || + cache.OrdinalIgnoreCaseEntries.TryGetValue(values, out matchingRouteValues)) + { + Debug.Assert(matchingRouteValues != null); + return matchingRouteValues; + } + + _logger.NoActionsMatched(context.RouteData.Values); + return EmptyActions; } public ActionDescriptor SelectBestCandidate(RouteContext context, IReadOnlyList candidates) @@ -159,14 +209,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - // If we don't find a 'next' then there's nothing left to do. + // If we don't find a next then there's nothing left to do. if (order == null) { return candidates; } // Since we have a constraint to process, bisect the set of actions into those with and without a - // constraint for the 'current order'. + // constraint for the current order. var actionsWithConstraint = new List(); var actionsWithoutConstraint = new List(); @@ -214,7 +264,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - // If we have matches with constraints, those are 'better' so try to keep processing those + // If we have matches with constraints, those are better so try to keep processing those if (actionsWithConstraint.Count > 0) { var matches = EvaluateActionConstraintsCore(context, actionsWithConstraint, order); @@ -234,5 +284,168 @@ namespace Microsoft.AspNetCore.Mvc.Internal return EvaluateActionConstraintsCore(context, actionsWithoutConstraint, order); } } + + // The action selector cache stores a mapping of route-values -> action descriptors for each known set of + // of route-values. We actually build two of these mappings, one for case-sensitive (fast path) and one for + // case-insensitive (slow path). + // + // This is necessary because MVC routing/action-selection is always case-insensitive. So we're going to build + // a case-sensitive dictionary that will behave like the a case-insensitive dictionary when you hit one of the + // canonical entries. When you don't hit a case-sensitive match it will try the case-insensitive dictionary + // so you still get correct behaviors. + // + // The difference here is because while MVC is case-insensitive, doing a case-sensitive comparison is much + // faster. We also expect that most of the URLs we process are canonically-cased because they were generated + // by Url.Action or another routing api. + // + // This means that for a set of actions like: + // { controller = "Home", action = "Index" } -> HomeController::Index1() + // { controller = "Home", action = "index" } -> HomeController::Index2() + // + // Both of these actions match "Index" case-insensitively, but there exist two known canonical casings, + // so we will create an entry for "Index" and an entry for "index". Both of these entries match **both** + // actions. + private class Cache + { + public Cache(ActionDescriptorCollection actions) + { + // We need to store the version so the cache can be invalidated if the actions change. + Version = actions.Version; + + // We need to build two maps for all of the route values. + OrdinalEntries = new Dictionary>(StringArrayComparer.Ordinal); + OrdinalIgnoreCaseEntries = new Dictionary>(StringArrayComparer.OrdinalIgnoreCase); + + // We need to first identify of the keys that action selection will look at (in route data). + // We want to only consider conventionally routed actions here. + var routeKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < actions.Items.Count; i++) + { + var action = actions.Items[i]; + if (action.AttributeRouteInfo == null) + { + // This is a conventionally routed action - so make sure we include its keys in the set of + // known route value keys. + foreach (var kvp in action.RouteValues) + { + routeKeys.Add(kvp.Key); + } + } + } + + // We need to hold on to an ordered set of keys for the route values. We'll use these later to + // extract the set of route values from an incoming request to compare against our maps of known + // route values. + RouteKeys = routeKeys.ToArray(); + + for (var i = 0; i < actions.Items.Count; i++) + { + var action = actions.Items[i]; + if (action.AttributeRouteInfo != null) + { + // This only handles conventional routing. Ignore attribute routed actions. + continue; + } + + // This is a conventionally routed action - so we need to extract the route values associated + // with this action (in order) so we can store them in our dictionaries. + var routeValues = new string[RouteKeys.Length]; + for (var j = 0; j < RouteKeys.Length; j++) + { + action.RouteValues.TryGetValue(RouteKeys[j], out routeValues[j]); + } + + if (!OrdinalIgnoreCaseEntries.TryGetValue(routeValues, out var entries)) + { + entries = new List(); + OrdinalIgnoreCaseEntries.Add(routeValues, entries); + } + + entries.Add(action); + + // We also want to add the same (as in reference equality) list of actions to the ordinal entries. + // We'll keep updating `entries` to include all of the actions in the same equivalence class - + // meaning, all conventionally routed actions for which the route values are equalignoring case. + // + // `entries` will appear in `OrdinalIgnoreCaseEntries` exactly once and in `OrdinalEntries` once + // for each variation of casing that we've seen. + if (!OrdinalEntries.ContainsKey(routeValues)) + { + OrdinalEntries.Add(routeValues, entries); + } + } + } + + public int Version { get; set; } + + public string[] RouteKeys { get; set; } + + public Dictionary> OrdinalEntries { get; set; } + + public Dictionary> OrdinalIgnoreCaseEntries { get; set; } + } + + private class StringArrayComparer : IEqualityComparer + { + public static readonly StringArrayComparer Ordinal = new StringArrayComparer(StringComparer.Ordinal); + + public static readonly StringArrayComparer OrdinalIgnoreCase = new StringArrayComparer(StringComparer.OrdinalIgnoreCase); + + private readonly StringComparer _valueComparer; + + private StringArrayComparer(StringComparer valueComparer) + { + _valueComparer = valueComparer; + } + + public bool Equals(string[] x, string[] y) + { + if (object.ReferenceEquals(x, y)) + { + return true; + } + + if (x == null ^ y == null) + { + return false; + } + + if (x.Length != y.Length) + { + return false; + } + + for (var i = 0; i < x.Length; i++) + { + if (string.IsNullOrEmpty(x[i]) && string.IsNullOrEmpty(y[i])) + { + continue; + } + + if (!_valueComparer.Equals(x[i], y[i])) + { + return false; + } + } + + return true; + } + + public int GetHashCode(string[] obj) + { + if (obj == null) + { + return 0; + } + + var hash = new HashCodeCombiner(); + for (var i = 0; i < obj.Length; i++) + { + hash.Add(obj[i], _valueComparer); + } + + return hash.CombinedHash; + } + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelectorDecisionTreeProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelectorDecisionTreeProvider.cs deleted file mode 100644 index dc685293b8..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ActionSelectorDecisionTreeProvider.cs +++ /dev/null @@ -1,51 +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 Microsoft.AspNetCore.Mvc.Core; -using Microsoft.AspNetCore.Mvc.Infrastructure; - -namespace Microsoft.AspNetCore.Mvc.Internal -{ - /// - public class ActionSelectorDecisionTreeProvider : IActionSelectorDecisionTreeProvider - { - private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider; - private ActionSelectionDecisionTree _decisionTree; - - /// - /// Creates a new . - /// - /// - /// The . - /// - public ActionSelectorDecisionTreeProvider( - IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) - { - _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider; - } - - /// - public IActionSelectionDecisionTree DecisionTree - { - get - { - var descriptors = _actionDescriptorCollectionProvider.ActionDescriptors; - if (descriptors == null) - { - throw new InvalidOperationException( - Resources.FormatPropertyOfTypeCannotBeNull( - "ActionDescriptors", - _actionDescriptorCollectionProvider.GetType())); - } - - if (_decisionTree == null || descriptors.Version != _decisionTree.Version) - { - _decisionTree = new ActionSelectionDecisionTree(descriptors); - } - - return _decisionTree; - } - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/IActionSelectorDecisionTreeProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/IActionSelectorDecisionTreeProvider.cs deleted file mode 100644 index 4302cf878d..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/IActionSelectorDecisionTreeProvider.cs +++ /dev/null @@ -1,20 +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. - -namespace Microsoft.AspNetCore.Mvc.Internal -{ - /// - /// Stores an for the current value of - /// . - /// - public interface IActionSelectorDecisionTreeProvider - { - /// - /// Gets the . - /// - IActionSelectionDecisionTree DecisionTree - { - get; - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj index 35bb2d167e..e504838983 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj @@ -27,7 +27,6 @@ Microsoft.AspNetCore.Mvc.RouteAttribute - diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Routing/IActionSelectionDecisionTree.cs b/src/Microsoft.AspNetCore.Mvc.Core/Routing/IActionSelectionDecisionTree.cs deleted file mode 100644 index 96569db131..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Core/Routing/IActionSelectionDecisionTree.cs +++ /dev/null @@ -1,29 +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.Collections.Generic; -using Microsoft.AspNetCore.Mvc.Abstractions; - -namespace Microsoft.AspNetCore.Mvc.Internal -{ - /// - /// A data structure that retrieves a list of matches based on the values - /// supplied for the current request by . - /// - public interface IActionSelectionDecisionTree - { - /// - /// Gets the version. The same as the value of - /// . - /// - int Version { get; } - - /// - /// Retrieves a set of based on the route values supplied by - /// / - /// - /// The route values for the current request. - /// A set of matching the route values. - IReadOnlyList Select(IDictionary routeValues); - } -} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/DefaultActionSelectorTests.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs similarity index 87% rename from test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/DefaultActionSelectorTests.cs rename to test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs index 31197410da..7a06fd8479 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Infrastructure/DefaultActionSelectorTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ActionSelectorTest.cs @@ -22,9 +22,7 @@ using Xunit; namespace Microsoft.AspNetCore.Mvc.Infrastructure { - // Most of the in-depth testing for SelectCandidates is part of the descision tree tests. - // This is just basic coverage of the API in common scenarios. - public class DefaultActionSelectorTests + public class ActionSelectorTest { [Fact] public void SelectCandidates_SingleMatch() @@ -173,6 +171,110 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure Assert.Empty(candidates); } + // In this context `CaseSensitiveMatch` means that the input route values exactly match one of the action + // descriptor's route values in terms of casing. This is important because we optimize for this case + // in the implementation. + [Fact] + public void SelectCandidates_Match_CaseSensitiveMatch_IncludesAllCaseInsensitiveMatches() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() // This won't match the request + { + DisplayName = "A3", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var expected = actions.Take(2).ToArray(); + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + routeContext.RouteData.Values.Add("controller", "Home"); + routeContext.RouteData.Values.Add("action", "Index"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + Assert.Equal(expected, candidates); + } + + // In this context `CaseInsensitiveMatch` means that the input route values do not match any action + // descriptor's route values in terms of casing. This is important because we optimize for the case + // where the casing matches - the non-matching-casing path is handled a bit differently. + [Fact] + public void SelectCandidates_Match_CaseInsensitiveMatch_IncludesAllCaseInsensitiveMatches() + { + var actions = new ActionDescriptor[] + { + new ActionDescriptor() + { + DisplayName = "A1", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() + { + DisplayName = "A2", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "home" }, + { "action", "Index" } + }, + }, + new ActionDescriptor() // This won't match the request + { + DisplayName = "A3", + RouteValues = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "controller", "Home" }, + { "action", "About" } + }, + }, + }; + + var expected = actions.Take(2).ToArray(); + + var selector = CreateSelector(actions); + + var routeContext = CreateRouteContext("GET"); + routeContext.RouteData.Values.Add("controller", "HOME"); + routeContext.RouteData.Values.Add("action", "iNDex"); + + // Act + var candidates = selector.SelectCandidates(routeContext); + + // Assert + Assert.Equal(expected, candidates); + } + [Fact] public void SelectBestCandidate_AmbiguousActions_LogIsCorrect() { @@ -661,15 +763,14 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure var actionDescriptorCollectionProvider = new ActionDescriptorCollectionProvider( new[] { actionDescriptorProvider }, Enumerable.Empty()); - var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionDescriptorCollectionProvider); var actionConstraintProviders = new[] { new DefaultActionConstraintProvider(), }; - + var actionSelector = new ActionSelector( - decisionTreeProvider, + actionDescriptorCollectionProvider, GetActionConstraintCache(actionConstraintProviders), NullLoggerFactory.Instance); @@ -679,7 +780,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure private ControllerActionDescriptorProvider GetActionDescriptorProvider() { - var controllerTypes = typeof(DefaultActionSelectorTests) + var controllerTypes = typeof(ActionSelectorTest) .GetNestedTypes(BindingFlags.NonPublic) .Select(t => t.GetTypeInfo()) .ToList(); @@ -753,9 +854,8 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure var actionProvider = new Mock(MockBehavior.Strict); actionProvider - .Setup(p => p.ActionDescriptors).Returns(new ActionDescriptorCollection(actions, 0)); - - var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionProvider.Object); + .Setup(p => p.ActionDescriptors) + .Returns(new ActionDescriptorCollection(actions, 0)); var actionConstraintProviders = new IActionConstraintProvider[] { new DefaultActionConstraintProvider(), @@ -763,7 +863,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure }; return new ActionSelector( - decisionTreeProvider, + actionProvider.Object, GetActionConstraintCache(actionConstraintProviders), loggerFactory); } diff --git a/test/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs b/test/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs index 3049b3b705..317de9385b 100644 --- a/test/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs +++ b/test/Microsoft.AspNetCore.Mvc.Performance/ActionSelectorBenchmark.cs @@ -208,6 +208,11 @@ namespace Microsoft.AspNetCore.Mvc.Performance private static void Verify(IReadOnlyList expected, IReadOnlyList actual) { + if (expected.Count == 0 && actual == null) + { + return; + } + if (expected.Count != actual.Count) { throw new InvalidOperationException("The count is different."); @@ -227,7 +232,7 @@ namespace Microsoft.AspNetCore.Mvc.Performance var actionCollection = new MockActionDescriptorCollectionProvider(actions); return new ActionSelector( - new ActionSelectorDecisionTreeProvider(actionCollection), + actionCollection, new ActionConstraintCache(actionCollection, Enumerable.Empty()), NullLoggerFactory.Instance); }