Skip to content

Commit

Permalink
V13: Optimize custom MVC routing (#16218)
Browse files Browse the repository at this point in the history
* Introduce EagerMatcherPolicy to conditionally bypass content routing

* Ensure that the candidate we disable dynamic routing for is valid

* Skip Umbraco endpoints

* Simplify logic a bit

* Move install logic to matcher

* Ensure that dynamic routing is still skipped when in upgrade state

* Fixup comments

* Reduce nesting a bit

* Don't show maintenance page when statically routed controllers are hít

* Remove excess check, since installer requests are statically routed
  • Loading branch information
nikolajlauridsen authored May 10, 2024
1 parent cfcdc9c commit ba9ddd1
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 29 deletions.
1 change: 1 addition & 0 deletions src/Umbraco.Core/Constants-Web.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static class Routing
public const string ControllerToken = "controller";
public const string ActionToken = "action";
public const string AreaToken = "area";
public const string DynamicRoutePattern = "/{**umbracoSlug}";
}

public static class RoutePath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder)
builder.Services.AddSingleton<IUmbracoVirtualPageRoute, UmbracoVirtualPageRoute>();
builder.Services.AddSingleton<IUmbracoRouteValuesFactory, UmbracoRouteValuesFactory>();
builder.Services.AddSingleton<IRoutableDocumentFilter, RoutableDocumentFilter>();
builder.Services.AddSingleton<MatcherPolicy, EagerMatcherPolicy>();
builder.Services.AddSingleton<MatcherPolicy, SurfaceControllerMatcherPolicy>();

builder.Services.AddSingleton<FrontEndRoutes>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.Cms.Web.Common.Middleware;
using Umbraco.Cms.Web.Website.Routing;
Expand Down Expand Up @@ -39,7 +40,7 @@ public static IUmbracoEndpointBuilderContext UseWebsiteEndpoints(this IUmbracoEn

FrontEndRoutes surfaceRoutes = builder.ApplicationServices.GetRequiredService<FrontEndRoutes>();
surfaceRoutes.CreateRoutes(builder.EndpointRouteBuilder);
builder.EndpointRouteBuilder.MapDynamicControllerRoute<UmbracoRouteValueTransformer>("/{**slug}");
builder.EndpointRouteBuilder.MapDynamicControllerRoute<UmbracoRouteValueTransformer>(Constants.Web.Routing.DynamicRoutePattern);

return builder;
}
Expand Down
229 changes: 229 additions & 0 deletions src/Umbraco.Web.Website/Routing/EagerMatcherPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Extensions;

namespace Umbraco.Cms.Web.Website.Routing;


/**
* A matcher policy that discards the catch-all (slug) route if there are any other valid routes with a lower order.
*
* The purpose of this is to skip our expensive <see cref="UmbracoRouteValueTransformer"/> if it's not required,
* for instance if there's a statically routed endpoint registered before the dynamic route,
* for more information see: https://github.com/umbraco/Umbraco-CMS/issues/16015.
* The core reason why this is necessary is that ALL routes get evaluated:
* "
* all routes get evaluated, they get to produce candidates and then the best candidate is selected.
* Since you have a dynamic route, it needs to run to produce the final endpoints and
* then those are ranked in along with the rest of the candidates to choose the final endpoint.
* "
* From: https://github.com/dotnet/aspnetcore/issues/45175#issuecomment-1322497958
*
* This also handles rerouting under install/upgrade states.
*/

internal class EagerMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
private readonly IRuntimeState _runtimeState;
private readonly EndpointDataSource _endpointDataSource;
private readonly UmbracoRequestPaths _umbracoRequestPaths;
private GlobalSettings _globalSettings;
private readonly Lazy<Endpoint> _installEndpoint;
private readonly Lazy<Endpoint> _renderEndpoint;

public EagerMatcherPolicy(
IRuntimeState runtimeState,
EndpointDataSource endpointDataSource,
UmbracoRequestPaths umbracoRequestPaths,
IOptionsMonitor<GlobalSettings> globalSettings)
{
_runtimeState = runtimeState;
_endpointDataSource = endpointDataSource;
_umbracoRequestPaths = umbracoRequestPaths;
_globalSettings = globalSettings.CurrentValue;
globalSettings.OnChange(settings => _globalSettings = settings);
_installEndpoint = new Lazy<Endpoint>(GetInstallEndpoint);
_renderEndpoint = new Lazy<Endpoint>(GetRenderEndpoint);
}

// We want this to run as the very first policy, so we can discard the UmbracoRouteValueTransformer before the framework runs it.
public override int Order => int.MinValue + 10;

// We know we don't have to run this matcher against the backoffice endpoints.
public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints) => true;

public async Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
var handled = await HandleInstallUpgrade(httpContext, candidates);
if (handled)
{
return;
}
}

// If there's only one candidate, we don't need to do anything.
if (candidates.Count < 2)
{
return;
}

// If there are multiple candidates, we want to discard the catch-all (slug)
// IF there is any candidates with a lower order. Since this will be a statically routed endpoint registered before the dynamic route.
// Which means that we don't have to run our UmbracoRouteValueTransformer to route dynamically (expensive).
var lowestOrder = int.MaxValue;
int? dynamicId = null;
RouteEndpoint? dynamicEndpoint = null;
for (var i = 0; i < candidates.Count; i++)
{
CandidateState candidate = candidates[i];

// If it's not a RouteEndpoint there's not much we can do to count it in the order.
if (candidate.Endpoint is not RouteEndpoint routeEndpoint)
{
continue;
}

if (routeEndpoint.Order < lowestOrder)
{
// We have to ensure that the route is valid for the current request method.
// This is because attribute routing will always have an order of 0.
// This means that you could attribute route a POST to /example, but also have an umbraco page at /example
// This would then result in a 404, because we'd see the attribute route with order 0, and always consider that the lowest order
// We'd then disable the dynamic endpoint since another endpoint has a lower order, and end up with only 1 invalid endpoint.
// (IsValidCandidate does not take this into account since the candidate itself is still valid)
HttpMethodMetadata? methodMetaData = routeEndpoint.Metadata.GetMetadata<HttpMethodMetadata>();
if (methodMetaData?.HttpMethods.Contains(httpContext.Request.Method) is false)
{
continue;
}

lowestOrder = routeEndpoint.Order;
}

// We only want to consider our dynamic route, this way it's still possible to register your own custom route before ours.
if (routeEndpoint.DisplayName != Constants.Web.Routing.DynamicRoutePattern)
{
continue;
}

dynamicEndpoint = routeEndpoint;
dynamicId = i;
}

// Invalidate the dynamic route if another route has a lower order.
// This means that if you register your static route after the dynamic route, the dynamic route will take precedence
// This more closely resembles the existing behaviour.
if (dynamicEndpoint is not null && dynamicId is not null && dynamicEndpoint.Order > lowestOrder)
{
candidates.SetValidity(dynamicId.Value, false);
}
}

/// <summary>
/// Replaces the first endpoint candidate with the specified endpoint, invalidating all other candidates,
/// guaranteeing that the specified endpoint will be hit.
/// </summary>
/// <param name="candidates">The candidate set to manipulate.</param>
/// <param name="endpoint">The target endpoint that will be hit.</param>
/// <param name="routeValueDictionary"></param>
private static void SetEndpoint(CandidateSet candidates, Endpoint endpoint, RouteValueDictionary routeValueDictionary)
{
candidates.ReplaceEndpoint(0, endpoint, routeValueDictionary);

for (int i = 1; i < candidates.Count; i++)
{
candidates.SetValidity(1, false);
}
}

private Endpoint GetInstallEndpoint()
{
Endpoint endpoint = _endpointDataSource.Endpoints.First(x =>
{
ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata<ControllerActionDescriptor>();
return descriptor?.ControllerTypeInfo.Name == "InstallController"
&& descriptor.ActionName == "Index";
});

return endpoint;
}

private Endpoint GetRenderEndpoint()
{
Endpoint endpoint = _endpointDataSource.Endpoints.First(x =>
{
ControllerActionDescriptor? descriptor = x.Metadata.GetMetadata<ControllerActionDescriptor>();
return descriptor?.ControllerTypeInfo == typeof(RenderController)
&& descriptor.ActionName == nameof(RenderController.Index);
});

return endpoint;
}

private Task<bool> HandleInstallUpgrade(HttpContext httpContext, CandidateSet candidates)
{
if (_runtimeState.Level != RuntimeLevel.Upgrade)
{
// We need to let the installer API requests through
// Currently we do this with a check for the installer path
// Ideally we should do this in a more robust way, for instance with a dedicated attribute we can then check for.
if (_umbracoRequestPaths.IsInstallerRequest(httpContext.Request.Path))
{
return Task.FromResult(true);
}

SetEndpoint(candidates, _installEndpoint.Value, new RouteValueDictionary
{
[Constants.Web.Routing.ControllerToken] = "Install",
[Constants.Web.Routing.ActionToken] = "Index",
[Constants.Web.Routing.AreaToken] = Constants.Web.Mvc.InstallArea,
});

return Task.FromResult(true);
}

// Check if maintenance page should be shown
// Current behaviour is that statically routed endpoints still work in upgrade state
// This means that IF there is a static route, we should not show the maintenance page.
// And instead carry on as we normally would.
var hasStaticRoute = false;
for (var i = 0; i < candidates.Count; i++)
{
CandidateState candidate = candidates[i];
IDynamicEndpointMetadata? dynamicEndpointMetadata = candidate.Endpoint.Metadata.GetMetadata<IDynamicEndpointMetadata>();
if (dynamicEndpointMetadata is null || dynamicEndpointMetadata.IsDynamic is false)
{
hasStaticRoute = true;
break;
}
}

if (_runtimeState.Level != RuntimeLevel.Upgrade
|| _globalSettings.ShowMaintenancePageWhenInUpgradeState is false
|| hasStaticRoute)
{
return Task.FromResult(false);
}

// Otherwise we'll re-route to the render controller (this will in turn show the maintenance page through a filter)
// With this approach however this could really just be a plain old endpoint instead of a filter.
SetEndpoint(candidates, _renderEndpoint.Value, new RouteValueDictionary
{
[Constants.Web.Routing.ControllerToken] = ControllerExtensions.GetControllerName<RenderController>(),
[Constants.Web.Routing.ActionToken] = nameof(RenderController.Index),
});

return Task.FromResult(true);

}
}
28 changes: 0 additions & 28 deletions src/Umbraco.Web.Website/Routing/UmbracoRouteValueTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,23 +139,6 @@ public UmbracoRouteValueTransformer(
public override async ValueTask<RouteValueDictionary> TransformAsync(
HttpContext httpContext, RouteValueDictionary values)
{
// If we aren't running, then we have nothing to route. We allow the frontend to continue while in upgrade mode.
if (_runtime.Level != RuntimeLevel.Run && _runtime.Level != RuntimeLevel.Upgrade)
{
if (_runtime.Level == RuntimeLevel.Install)
{
return new RouteValueDictionary()
{
//TODO figure out constants
[ControllerToken] = "Install",
[ActionToken] = "Index",
[AreaToken] = Constants.Web.Mvc.InstallArea,
};
}

return null!;
}

// will be null for any client side requests like JS, etc...
if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext))
{
Expand All @@ -172,17 +155,6 @@ public override async ValueTask<RouteValueDictionary> TransformAsync(
return null!;
}

// Check if the maintenance page should be shown
if (_runtime.Level == RuntimeLevel.Upgrade && _globalSettings.ShowMaintenancePageWhenInUpgradeState)
{
return new RouteValueDictionary
{
// Redirects to the RenderController who handles maintenance page in a filter, instead of having a dedicated controller
[ControllerToken] = ControllerExtensions.GetControllerName<RenderController>(),
[ActionToken] = nameof(RenderController.Index),
};
}

// Check if there is no existing content and return the no content controller
if (!umbracoContext.Content?.HasContent() ?? false)
{
Expand Down

0 comments on commit ba9ddd1

Please sign in to comment.