Previously in this Series

Contents

Introduction

This article is the fourth in a series I’m dedicating to reviewing the code and design of the ASP.NET Core GitHub repository. The series tries to explain the underlying mechanisms of ASP.NET Core, and how all the code fits together to create the framework as we know it at the published date.

This article will discuss action selection within ASP.NET Core 2.0. The previous article discussed how actions and controllers are discovered within your application. This article will focus on how a specific action is selected, given a set of action descriptors found on start up.

I will also try and discuss how the brand new razor pages fits into this, and why the process of action selection is the same for both MVC and razor pages.

The ActionSelector Class

/// <summary> /// A default <see cref="IActionSelector"/> implementation. /// </summary> public class ActionSelector : IActionSelector { ... } /// <summary> /// Defines an interface for selecting an MVC action to invoke for the current request. /// </summary> public interface IActionSelector { IReadOnlyList<ActionDescriptor> SelectCandidates(RouteContext context); ActionDescriptor SelectBestCandidate(RouteContext context, IReadOnlyList<ActionDescriptor> candidates); }

The ActionSelector class is the central piece of infrastructure for ASP.NET Core action selection.

The ActionSelector class itself is registered with the global dependency injection system against the IActionSelector abstraction. Like all registrations in the ASP.NET Core dependency injection system, it is only added if there is no previous registration:

services.TryAddSingleton<IActionSelector, ActionSelector>();

This means that you can easily create your own implementation of IActionSelector, and provide your own logic for selecting an action descriptor:

public class CustomActionSelector : IActionSelector { public IReadOnlyList<ActionDescriptor> SelectCandidates(RouteContext context) { ... } ActionDescriptor SelectBestCandidate(RouteContext context, IReadOnlyList<ActionDescriptor> candidates) { ... } } ... and then at startup configuration ... services.AddSingleton<IActionSelector, CustomActionSelector>();

The IActionSelector abstraction is used by MvcAttributeRouteHandler and MvcRouteHandler. Both of these are the main registered IRouter implementations.

When selecting an action to run, the action selector does not care whether it is a controller or razor page action, all it has is metadata described by an action descriptor. If you would like to learn more about the creation of action descriptors, see my previous article about how the action descriptor collection is populated.

Dependencies

public ActionSelector( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, ActionConstraintCache actionConstraintCache, ILoggerFactory loggerFactory) { ... }

The ActionSelector Class has multiple dependencies, and IActionDescriptorCollectionProvider is no doubt the most important. This dependency provides all the action descriptors that are currently registered within the system.

The action constraint cache is used to retrieve the constraint(s) on a specific action-e.g., HttpPost.

The Route Value Cache

A route value is the value given to a specific route key for a given action. For example, an action by default has the following route key/value pairs:

controller -> Home action -> Index

Where home and index are the controller and action names, respectively.

The ActionSelector class will take all the action descriptors provided by the action descriptor collection provider. With these, it creates it’s own internal cache of mapping route keys to route values.

The action selector class does this by looping through all the actions given to it, and extracting the RouteValues for each action:

// 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]); }

For each and every action descriptor, there will be a route value for every route key defined within the system. If the route key was not specified for a particular action, this will be filled in as null for that particular action.

For example, an action may have an Area attribute, which specifies that for the RouteKey “area”, use the value Blog:

[Area("Blog")] public void Index() { return View(); }

Due to the declaration of this route value, the system will assign null to every other action descriptor for the route key “area”.

Assigning a null value to route keys is necessary for scenarios such as Razor Pages. Razor pages add a “page” route value. If the current context has an “action” route value, then it should match when the action has “page” set. This can be seen explained here in code comments.

Going back to the caching mechanism, The cache keys are used as an array of route values for an action, and the actual entries will be a list of action descriptors that matched those values. For example, given the action:

public class HomeController : Controller { public void Index() { return View(); } }

The route keys and value for this action will be:

controller -> Home action -> Index

The cache will store as it’s key/value pair the following:

Key: new string[] { "Home", "Index" }, Value: new List<ActionDescriptor> { action }

Conventional Routing

The ActionSelector class provides a way to retrieve actions based on conventional routing. The SelectCandidates method’s signature of the ActionSelector class is the following:

IReadOnlyList<ActionDescriptor> SelectCandidates(RouteContext context);

This method retrieves a read only list of action descriptors. Each one of these action descriptors describes a particular action within the current application based on conventional routing.

Using the provided route data from the passed in RouteContext, the action selector will loop through all the registered route keys that is given from the cache-i.e. “action”, “controller”, “area” etc.:

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); } }

and try and retrieve the actual value matching this route key, from the RouteData given in the RouteContext.

Each loop will try and receive the value associated with the key. If the value is not null, then it assigns it to the values array. This values array is then used as the key for the entries calculated in the cache:

if (cache.OrdinalEntries.TryGetValue(values, out var matchingRouteValues) || cache.OrdinalIgnoreCaseEntries.TryGetValue(values, out matchingRouteValues)) { return matchingRouteValues; }

These ordinal entries are built up from the actual route values defined from the conventional routing within your application.

So from this, the matching route values have been found, and so we are now ready to actually select an action from the selected candidates.

A Note on Cache Efficiency

The cache that the action selector uses is done in a very clever way. As previously discussed, to speed up the actual selection of action candidates, it uses the actual route values as a key, as this is beneficial from the perspective of the incoming request’s route data. However, the actual cache uses two dictionaries, not just one:

// We need to build two maps for all of the route values. OrdinalEntries = new Dictionary >(StringArrayComparer.Ordinal); OrdinalIgnoreCaseEntries = new Dictionary >(StringArrayComparer.OrdinalIgnoreCase);

One dictionary is for a case-sensitive key/value lookup, and the other is case-inensitive. Why have two?

It comes down to speed. When using dictionaries, a case-sensitive key is faster than a case-insensitive key. So the code is utilising this fact.

However, action selection is actually case-insensitive, so two actions can actually equal when one is “Index” and another is “index” for a particular route value.

So essentially, if a match is not found with a case-sensitive match first, a case-insensitive match is then used, as shown earlier.

Attribute Routing

The MvcAttributeRouteHandler is the handler for attribute routing. This does not use the “SelectCandidates” method to retrieve action descriptors. Attribute routing uses the AttributeRoute class to retrieve applicable action descriptors.

The AttributeRoute class, like the ActionSelector class, uses the IActionDescriptorCollectionProvider to retrieve all the action descriptors in the system:

var attributeRoutedActions = actions.Where(a => a.AttributeRouteInfo?.Template != null);

The AttributeRoute class is only concerned with actions that have attribute route information on them. Contrast this with the ActionSelector class, that is only concerned with actions that don’t have attribute routing-i.e., conventional routing.

Attribute Routing with Route Templates

A route template is either a built-in or custom part of a URL path. For example, you may have a URL path similar to what my blog site uses:

/2017/09/03/my-custom-blog-post

How does this get translated into selecting a specific action?

public class BlogController : Controller { [Route("{year:int}/{month:int}/{day:int}/{title}", Name = "BlogPostDetails")] public IActionResult Get(int year, int month, int day, string title) { ... } }

In the above route attribute, we have defined a route template. This route template is made up of four different route parameters: year, month, day, and title. So to match this action, the URL path needs to have each of these route parameters provided.

I also say that the year, month, and day all have to be integers:

{year:int}/{month:int}/{day:int}

The section after the colon (:) signifies an inline constraint. If the part before the first path separator (/) is not an integer, then this action will not match the URL, and so on for all the route parameters.

You can learn more about route templates in the Microsoft documentation.

public ActionDescriptor SelectBestCandidate(RouteContext context, IReadOnlyList candidates) { ... }

The SelectBestCandidate method is called by both the MvcAttributeRouteHandler class, and the MvcRouteHandler class. They both handle matching an action to the HTTP request through attribute routing and conventional routing, respectively.

Both handlers are used by the routing system in order to handle incoming HTTP requests, and both use the ActionSelector class to achieve those goals.

The SelectBestCandidate method accepts a read only list of action descriptors. These are either sourced from the action selector itself, or through the attribute routing system.

The following code is the core of this method:

var matches = EvaluateActionConstraints(context, candidates); var finalMatches = SelectBestActions(matches);

Evaluating the Constraints

The SelectBestCandidate method is the point in time when the action constraints are evaluated. After the selection of the best candidates, each action is evaluated based on it’s action constraints.

Action constraints can be used to further narrow the selection of an action, when there are currently multiple candidates that match against the given route values.

You can define your own action constraints by implementing the IActionConstraint interface:

public interface IActionConstraint : IActionConstraintMetadata { int Order { get; } bool Accept(ActionConstraintContext context); }

There are two pieces of functionality to implement. One is the Order property, and the other is the Accept method.

The order property defines what stage your action constraint will be evaluated. Action constraints that are of a lower order will be evaluated first.

N.B., if there are two actions that match a request, and one has an action constraint, then it is deemed a more specific match, and is selected over the action with no action constraint. This is explained on the actual IActionConstraint interface in the source code.

Once the constraints are evaluated, then the results are checked, and if there is more than one matching action, then the infamous AmbiguousActionException is thrown.

Summary

Action selection knows nothing about your controllers. It knows nothing about your application. All it deals with is a list of action descriptors. Action descriptors that have already been evaluated at start up.

This post went through how an action descriptor is selected based on an incoming request, and discussed the extension points of the selection process. It also discussed how attribute routing, and conventional routing, share common code, and how they differ in the sourcing of these global action descriptors.

Thank you for reading and I hope this helps. Please share with a friend.