Skip to content

Allow runtime compilation to be configured via the hosting startup. #39639

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,16 @@ internal static void AddServices(IServiceCollection services)

if (actionDescriptorProvider != null)
{
// RuntimeCompilation registers an instance of PageActionDescriptorProvider(PageADP). CompiledPageADP and runtime compilation
// cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for
// ones from non-physical file providers). We'll instead remove CompiledPageActionDescriptors from the DI container if present.
services.Remove(actionDescriptorProvider);
}

// Add PageActionDescriptorProvider and the matcher policy that supports runtime compilation.
// We only want to add support for this if we know AddRazorPages was called. In the absence of this, several services registered by Razor Pages
// will be absent. We'll use the presence of the CompiledPageActionDescriptorProvider service as a poor way to test this.
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IActionDescriptorProvider, PageActionDescriptorProvider>());
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IActionDescriptorProvider, PageActionDescriptorProvider>());

services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());
}
services.TryAddEnumerable(ServiceDescriptor.Singleton<MatcherPolicy, PageLoaderMatcherPolicy>());

services.TryAddSingleton<RuntimeCompilationFileProvider>();
services.TryAddSingleton<RazorReferenceManager>();
Expand Down
29 changes: 19 additions & 10 deletions src/Mvc/Mvc.Razor.RuntimeCompilation/src/PageLoaderMatcherPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,27 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure
{
internal class PageLoaderMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
{
private readonly PageLoader _loader;

public PageLoaderMatcherPolicy(PageLoader loader)
private PageLoader? _loader;

/// <remarks>
/// The <see cref="PageLoader"/> service is configured by <c>app.AddRazorPages()</c>.
/// If the app is configured as <c>app.AddControllersWithViews().AddRazorRuntimeCompilation()</c>, the <see cref="PageLoader"/>
/// service will not be registered. Since Razor Pages is not a pre-req for runtime compilation, we'll defer reading the service
/// until we need to load a page in the body of <see cref="ApplyAsync(HttpContext, CandidateSet)"/>.
/// </remarks>
public PageLoaderMatcherPolicy()
: this(loader: null)
{
if (loader == null)
{
throw new ArgumentNullException(nameof(loader));
}
}

public PageLoaderMatcherPolicy(PageLoader? loader)
{
_loader = loader;
}

Expand Down Expand Up @@ -76,6 +83,8 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
var page = endpoint.Metadata.GetMetadata<PageActionDescriptor>();
if (page != null)
{
_loader ??= httpContext.RequestServices.GetRequiredService<PageLoader>();

// We found an endpoint instance that has a PageActionDescriptor, but not a
// CompiledPageActionDescriptor. Update the CandidateSet.
var compiled = _loader.LoadAsync(page, endpoint.Metadata);
Expand All @@ -88,15 +97,15 @@ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
{
// In the most common case, GetOrAddAsync will return a synchronous result.
// Avoid going async since this is a fairly hot path.
return ApplyAsyncAwaited(candidates, compiled, i);
return ApplyAsyncAwaited(_loader, candidates, compiled, i);
}
}
}

return Task.CompletedTask;
}

private async Task ApplyAsyncAwaited(CandidateSet candidates, Task<CompiledPageActionDescriptor> actionDescriptorTask, int index)
private static async Task ApplyAsyncAwaited(PageLoader pageLoader, CandidateSet candidates, Task<CompiledPageActionDescriptor> actionDescriptorTask, int index)
{
var compiled = await actionDescriptorTask;

Expand All @@ -115,7 +124,7 @@ private async Task ApplyAsyncAwaited(CandidateSet candidates, Task<CompiledPageA
var page = endpoint.Metadata.GetMetadata<PageActionDescriptor>();
if (page != null)
{
compiled = await _loader.LoadAsync(page, endpoint.Metadata);
compiled = await pageLoader.LoadAsync(page, endpoint.Metadata);

candidates.ReplaceEndpoint(i, compiled.Endpoint, candidates[i].Values);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,5 @@ public void AddServices_ReplacesActionDescriptorProvider()
serviceDescriptor = Assert.Single(services, service => service.ServiceType == typeof(MatcherPolicy));
Assert.Equal(typeof(PageLoaderMatcherPolicy), serviceDescriptor.ImplementationType);
}

[Fact]
public void AddServices_DoesNotPageActionDescriptor_IfItWasNotPreviouslyFound()
{
// we want to make sure Page specific featurees are only added if AddRazorPages was called by the user.
// Arrange
var services = new ServiceCollection();

// Act
RazorRuntimeCompilationMvcCoreBuilderExtensions.AddServices(services);

// Assert
Assert.Empty(services.Where(service => service.ServiceType == typeof(IActionDescriptorProvider)));
Assert.Empty(services.Where(service => service.ServiceType == typeof(MatcherPolicy)));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;

Expand All @@ -34,6 +35,32 @@ public async Task ApplyAsync_UpdatesCandidateSet()
Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint);
}

[Fact]
public async Task ApplyAsync_ReadsLoaderFromRequestServices()
{
// Arrange
var compiled = new CompiledPageActionDescriptor();
compiled.Endpoint = CreateEndpoint(new PageActionDescriptor());

var candidateSet = CreateCandidateSet(compiled);
var loader = new Mock<PageLoader>();
loader.Setup(l => l.LoadAsync(It.IsAny<PageActionDescriptor>(), It.IsAny<EndpointMetadataCollection>()))
.Returns(Task.FromResult(compiled))
.Verifiable();
var policy = new PageLoaderMatcherPolicy();
var httpContext = new DefaultHttpContext
{
RequestServices = new ServiceCollection().AddSingleton(loader.Object).BuildServiceProvider(),
};

// Act
await policy.ApplyAsync(httpContext, candidateSet);

// Assert
Assert.Same(compiled.Endpoint, candidateSet[0].Endpoint);
loader.Verify();
}

[Fact]
public async Task ApplyAsync_UpdatesCandidateSet_IfLoaderReturnsAsynchronously()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Filters;
Expand Down Expand Up @@ -107,8 +108,19 @@ internal static void AddRazorPagesServices(IServiceCollection services)
services.TryAddSingleton<PageActionEndpointDataSourceIdProvider>();

// Action description and invocation
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IActionDescriptorProvider, CompiledPageActionDescriptorProvider>());
var actionDescriptorProvider = services.FirstOrDefault(f =>
f.ServiceType == typeof(IActionDescriptorProvider) &&
f.ImplementationType == typeof(PageActionDescriptorProvider));

if (actionDescriptorProvider is null)
{
// RuntimeCompilation registers an instance of PageActionDescriptorProvider (PageADP). CompiledPageADP and runtime compilation
// cannot co-exist since CompiledPageADP will attempt to resolve action descriptors for lazily compiled views (such as for
// ones from non-physical file providers). We'll instead avoid adding it if PageADP is already registered. Similarly,
// AddRazorRuntimeCompilation will remove CompiledPageADP if it is registered.
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IActionDescriptorProvider, CompiledPageActionDescriptorProvider>());
}
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPageRouteModelProvider, CompiledPageRouteModelProvider>());
services.TryAddSingleton<PageActionEndpointDataSourceFactory>();
Expand Down
2 changes: 1 addition & 1 deletion src/Mvc/Mvc.RazorPages/src/RazorPagesOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class RazorPagesOptions : IEnumerable<ICompatibilitySwitch>
/// Gets a collection of <see cref="IPageConvention"/> instances that are applied during
/// route and page model construction.
/// </summary>
public PageConventionCollection Conventions { get; internal set; } = default!;
public PageConventionCollection Conventions { get; internal set; } = new();

/// <summary>
/// Application relative path used as the root of discovery for Razor Page files.
Expand Down
3 changes: 2 additions & 1 deletion src/Mvc/Mvc.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj",
"src\\DataProtection\\Extensions\\src\\Microsoft.AspNetCore.DataProtection.Extensions.csproj",
"src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj",
"src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
"src\\Features\\JsonPatch\\src\\Microsoft.AspNetCore.JsonPatch.csproj",
"src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj",
"src\\FileProviders\\Manifest.MSBuildTask\\src\\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj",
Expand All @@ -24,7 +25,6 @@
"src\\Html.Abstractions\\src\\Microsoft.AspNetCore.Html.Abstractions.csproj",
"src\\Http\\Authentication.Abstractions\\src\\Microsoft.AspNetCore.Authentication.Abstractions.csproj",
"src\\Http\\Authentication.Core\\src\\Microsoft.AspNetCore.Authentication.Core.csproj",
"src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
"src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj",
"src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj",
"src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj",
Expand Down Expand Up @@ -133,6 +133,7 @@
"src\\Servers\\IIS\\IIS\\src\\Microsoft.AspNetCore.Server.IIS.csproj",
"src\\Servers\\Kestrel\\Core\\src\\Microsoft.AspNetCore.Server.Kestrel.Core.csproj",
"src\\Servers\\Kestrel\\Kestrel\\src\\Microsoft.AspNetCore.Server.Kestrel.csproj",
"src\\Servers\\Kestrel\\Transport.Quic\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Quic.csproj",
"src\\Servers\\Kestrel\\Transport.Sockets\\src\\Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj",
"src\\SignalR\\common\\Http.Connections.Common\\src\\Microsoft.AspNetCore.Http.Connections.Common.csproj",
"src\\SignalR\\common\\Http.Connections\\src\\Microsoft.AspNetCore.Http.Connections.csproj",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Mvc.FunctionalTests;

public class RazorRuntimeCompilationHostingStartupTest : IClassFixture<MvcTestFixture<RazorBuildWebSite.StartupWithHostingStartup>>
{
public RazorRuntimeCompilationHostingStartupTest(MvcTestFixture<RazorBuildWebSite.StartupWithHostingStartup> fixture)
{
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(b => b.UseStartup<RazorBuildWebSite.StartupWithHostingStartup>());
factory = factory.WithWebHostBuilder(b => b.ConfigureTestServices(serviceCollection => serviceCollection.Configure<MvcRazorRuntimeCompilationOptions>(ConfigureRuntimeCompilationOptions)));

Client = factory.CreateDefaultClient();

static void ConfigureRuntimeCompilationOptions(MvcRazorRuntimeCompilationOptions options)
{
// Workaround for incorrectly generated deps file. The build output has all of the binaries required to compile. We'll grab these and
// add it to the list of assemblies runtime compilation uses.
foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
{
options.AdditionalReferencePaths.Add(path);
}
}
}

public HttpClient Client { get; }

[Fact]
public async Task RazorViews_CanBeServedAndUpdatedViaRuntimeCompilation()
{
// Arrange
var expected1 = "Original content";
var path = "/Views/UpdateableViews/Index.cshtml";

// Act - 1
var body = await Client.GetStringAsync("/UpdateableViews");

// Assert - 1
Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true);

// Act - 2
await UpdateFile(path, "@GetType().Assembly");
body = await Client.GetStringAsync("/UpdateableViews");

// Assert - 2
var actual2 = body.Trim();
Assert.NotEqual(expected1, actual2);

// Act - 3
// With all things being the same, expect a cached compilation
body = await Client.GetStringAsync("/UpdateableViews");

// Assert - 3
Assert.Equal(actual2, body.Trim(), ignoreLineEndingDifferences: true);

// Act - 4
// Trigger a change in ViewImports
await UpdateFile("/Views/UpdateableViews/_ViewImports.cshtml", "new content");
body = await Client.GetStringAsync("/UpdateableViews");

// Assert - 4
Assert.NotEqual(actual2, body.Trim());
}

[Fact]
public async Task RazorPages_CanBeServedAndUpdatedViaRuntimeCompilation()
{
// Arrange
var expected1 = "Original content";

// Act - 1
var body = await Client.GetStringAsync("/UpdateablePage");

// Assert - 1
Assert.Equal(expected1, body.Trim(), ignoreLineEndingDifferences: true);

// Act - 2
await UpdateRazorPages();
await UpdateFile("/Pages/UpdateablePage.cshtml", "@page" + Environment.NewLine + "@GetType().Assembly");
body = await Client.GetStringAsync("/UpdateablePage");

// Assert - 2
var actual2 = body.Trim();
Assert.NotEqual(expected1, actual2);

// Act - 3
// With all things being unchanged, we should get the cached page.
body = await Client.GetStringAsync("/UpdateablePage");

// Assert - 3
Assert.Equal(actual2, body.Trim(), ignoreLineEndingDifferences: true);
}

private async Task UpdateFile(string path, string content)
{
var updateContent = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "path", path },
{ "content", content },
});

var response = await Client.PostAsync($"/UpdateableViews/Update", updateContent);
response.EnsureSuccessStatusCode();
}

private async Task UpdateRazorPages()
{
var response = await Client.PostAsync($"/UpdateableViews/UpdateRazorPages", new StringContent(string.Empty));
response.EnsureSuccessStatusCode();
}
}
8 changes: 0 additions & 8 deletions src/Mvc/test/WebSites/RazorBuildWebSite/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,6 @@ public void Configure(IApplicationBuilder app)
});
}

public static void Main(string[] args)
{
var host = CreateWebHostBuilder(args)
.Build();

host.Run();
}

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
new WebHostBuilder()
.UseContentRoot(Directory.GetCurrentDirectory())
Expand Down
Loading