Models builder: Move InMemoryAuto models builder and razor runtime compilation into its own package to enable hot reload (#20187)

* Move in memory models builder out of core

* Move runtime validations into backoffice development project

* Obsolete ModelsMode enum

* Move the InMemoryModelsbuilder/RRC novel into the Backoffice development umbraco builder extension

* Add runtime validator to warn if InMemoryAuto is selected but the package isn't installed

* Add backoffice development to template

* Remove propertyGroup

* Remove oopsie

* Check for modelsbuilder in notification handler instead of runtime validator

* Update src/Umbraco.Cms.Api.Management/Controllers/ModelsBuilder/BuildModelsBuilderController.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Infrastructure/Runtime/RuntimeModeValidators/ModelsBuilderModeValidator.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove ModelsMode enum and ModelsModeExtensions

* Apply suggestions from code review

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>

* Move project to source folder

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
This commit is contained in:
Mole
2025-09-23 11:58:09 +02:00
committed by GitHub
parent 5d17920a73
commit 859505e751
45 changed files with 373 additions and 318 deletions

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.DependencyInjection;
public class BackofficeDevelopmentComposer : IComposer
{
public void Compose(IUmbracoBuilder builder) => builder.AddBackofficeDevelopment();
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
using Umbraco.Extensions;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.DependencyInjection;
/*
* OVERVIEW:
*
* The CSharpCompiler is responsible for the actual compilation of razor at runtime.
* It creates a CSharpCompilation instance to do the compilation. This is where DLL references
* are applied. However, the way this works is not flexible for dynamic assemblies since the references
* are only discovered and loaded once before the first compilation occurs. This is done here:
* https://github.com/dotnet/aspnetcore/blob/114f0f6d1ef1d777fb93d90c87ac506027c55ea0/src/Mvc/Mvc.Razor.RuntimeCompilation/src/CSharpCompiler.cs#L79
* The CSharpCompiler is internal and cannot be replaced or extended, however it's references come from:
* RazorReferenceManager. Unfortunately this is also internal and cannot be replaced, though it can be extended
* using MvcRazorRuntimeCompilationOptions, except this is the place where references are only loaded once which
* is done with a LazyInitializer. See https://github.com/dotnet/aspnetcore/blob/master/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RazorReferenceManager.cs#L35.
*
* The way that RazorReferenceManager works is by resolving references from the ApplicationPartsManager - either by
* an application part that is specifically an ICompilationReferencesProvider or an AssemblyPart. So to fulfill this
* requirement, we add the MB assembly to the assembly parts manager within the InMemoryModelFactory when the assembly
* is (re)generated. But due to the above restrictions, when re-generating, this will have no effect since the references
* have already been resolved with the LazyInitializer in the RazorReferenceManager. There is a known public API
* where you can add reference paths to the runtime razor compiler via it's IOptions: MvcRazorRuntimeCompilationOptions
* however this falls short too because those references are just loaded via the RazorReferenceManager and lazy initialized.
*
* The services that can be replaced are: IViewCompilerProvider (default is the internal RuntimeViewCompilerProvider) and
* IViewCompiler (default is the internal RuntimeViewCompiler). There is one specific public extension point that I was
* hoping would solve all of the problems which was IMetadataReferenceFeature (implemented by LazyMetadataReferenceFeature
* which uses RazorReferencesManager) which is a razor feature that you can add
* to the RazorProjectEngine. It is used to resolve roslyn references and by default is backed by RazorReferencesManager.
* Unfortunately, this service is not used by the CSharpCompiler, it seems to only be used by some tag helper compilations.
*
* There are caches at several levels, all of which are not publicly accessible APIs (apart from RazorViewEngine.ViewLookupCache
* which is possible to clear by casting and then calling cache.Compact(100); but that doesn't get us far enough).
*
* For this to work, several caches must be cleared:
* - RazorViewEngine.ViewLookupCache
* - RazorReferencesManager._compilationReferences
* - RazorPageActivator._activationInfo (though this one may be optional)
* - RuntimeViewCompiler._cache
*
* What are our options?
*
* a) We can copy a ton of code into our application: CSharpCompiler, RuntimeViewCompilerProvider, RuntimeViewCompiler and
* RazorReferenceManager (probably more depending on the extent of Internal references).
* b) We can use reflection to try to access all of the above resources and try to forcefully clear caches and reset initialization flags.
* c) We hack these replace-able services with our own implementations that wrap the default services. To do this
* requires re-resolving the original services from a pre-built DI container. In effect this re-creates these
* services from scratch which means there is no caches.
*
* ... Option C worked, however after a breaking change from dotnet, we cannot go with this options any longer.
* The reason for this is that when the default RuntimeViewCompiler loads in the assembly using Assembly.Load,
* This will not work for us since this loads the compiled views into the default AssemblyLoadContext,
* and our compiled models are loaded in the collectible UmbracoAssemblyLoadContext, and as per the breaking change
* you're no longer allowed reference a collectible load context from a non-collectible one
* That is the non-collectible compiled views are not allowed to reference the collectible InMemoryAuto models.
* https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/7.0/collectible-assemblies
*
* So what do we do then?
* We've had to go with option a unfortunately, and we've cloned the above classes
* There has had to be some modifications to the ViewCompiler (CollectibleRuntimeViewCompiler)
* First off we've added a new class InMemoryAssemblyLoadContextManager, the role of this class is to ensure that
* no one will take a reference to the assembly load context (you cannot unload an assembly load context if there's any references to it).
* This means that both the InMemoryAutoFactory and the ViewCompiler uses the LoadContextManager to load their assemblies.
* This serves another purpose being that it keeps track of the location of the models assembly.
* This means that we no longer use the RazorReferencesManager to resolve that specific dependency, but instead add and explicit dependency to the models assembly.
*
* With this our assembly load context issue is solved, however the caching issue still persists now that we no longer use the RefreshingRazorViewEngine
* To clear these caches another class the RuntimeCompilationCacheBuster has been introduced,
* this keeps a reference to the CollectibleRuntimeViewCompiler and the RazorViewEngine and is injected into the InMemoryModelsFactory to clear the caches when rebuilding modes.
* In order to avoid having to copy all the RazorViewEngine code the cache buster uses reflection to call the internal ClearCache method of the RazorViewEngine.
*/
public static class UmbracoBuilderExtensions
{
public static IUmbracoBuilder AddBackofficeDevelopment(this IUmbracoBuilder builder)
{
if (builder.Config.GetRuntimeMode() != RuntimeMode.BackofficeDevelopment)
{
return builder;
}
builder.AddMvcAndRazor(mvcBuilder =>
{
mvcBuilder.AddRazorRuntimeCompilation();
});
builder.AddInMemoryModelsRazorEngine();
builder.RuntimeModeValidators()
.Add<InMemoryModelsBuilderModeValidator>();
builder.AddNotificationHandler<ModelBindingErrorNotification, ModelsBuilderBindingErrorHandler>();
return builder;
}
// See notes in RefreshingRazorViewEngine for information on what this is doing.
private static IUmbracoBuilder AddInMemoryModelsRazorEngine(this IUmbracoBuilder builder)
{
// We should only add/replace these services when models builder is InMemory, otherwise we'll cause issues.
// Since these services expect the ModelsMode to be InMemoryAuto
if (builder.Config.GetModelsMode() == ModelsModeConstants.InMemoryAuto)
{
builder.Services.AddSingleton<UmbracoRazorReferenceManager>();
builder.Services.AddSingleton<CompilationOptionsProvider>();
builder.Services.AddSingleton<IViewCompilerProvider, UmbracoViewCompilerProvider>();
builder.Services.AddSingleton<RuntimeCompilationCacheBuster>();
builder.Services.AddSingleton<InMemoryAssemblyLoadContextManager>();
builder.Services.AddSingleton<InMemoryModelFactory>();
// Register the factory as IPublishedModelFactory
builder.Services.AddSingleton<IPublishedModelFactory, InMemoryModelFactory>();
return builder;
}
// This is what the community MB would replace, all of the above services are fine to be registered
builder.Services.AddSingleton<IPublishedModelFactory>(factory => factory.CreateDefaultPublishedModelFactory());
return builder;
}
}

View File

@@ -0,0 +1,125 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using Microsoft.AspNetCore.Razor.Hosting;
using Microsoft.AspNetCore.Razor.Language;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
/*
* This is a clone of Microsofts implementation.
* https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Razor.RuntimeCompilation/src/ChecksumValidator.cs
* The purpose of this class is to check if compiled views has changed and needs to be recompiled.
*/
internal static class ChecksumValidator
{
public static bool IsRecompilationSupported(RazorCompiledItem item)
{
if (item == null)
{
throw new ArgumentNullException(nameof(item));
}
// A Razor item only supports recompilation if its primary source file has a checksum.
//
// Other files (view imports) may or may not have existed at the time of compilation,
// so we may not have checksums for them.
IReadOnlyList<IRazorSourceChecksumMetadata> checksums = item.GetChecksumMetadata();
return checksums.Any(c => string.Equals(item.Identifier, c.Identifier, StringComparison.OrdinalIgnoreCase));
}
// Validates that we can use an existing precompiled view by comparing checksums with files on
// disk.
public static bool IsItemValid(RazorProjectFileSystem fileSystem, RazorCompiledItem item)
{
if (fileSystem == null)
{
throw new ArgumentNullException(nameof(fileSystem));
}
if (item == null)
{
throw new ArgumentNullException(nameof(item));
}
IReadOnlyList<IRazorSourceChecksumMetadata> checksums = item.GetChecksumMetadata();
// The checksum that matches 'Item.Identity' in this list is significant. That represents the main file.
//
// We don't really care about the validation unless the main file exists. This is because we expect
// most sites to have some _ViewImports in common location. That means that in the case you're
// using views from a 3rd party library, you'll always have **some** conflicts.
//
// The presence of the main file with the same content is a very strong signal that you're in a
// development scenario.
IRazorSourceChecksumMetadata? primaryChecksum = checksums
.FirstOrDefault(c => string.Equals(item.Identifier, c.Identifier, StringComparison.OrdinalIgnoreCase));
if (primaryChecksum == null)
{
// No primary checksum, assume valid.
return true;
}
RazorProjectItem projectItem = fileSystem.GetItem(primaryChecksum.Identifier, fileKind: null);
if (!projectItem.Exists)
{
// Main file doesn't exist - assume valid.
return true;
}
var sourceDocument = RazorSourceDocument.ReadFrom(projectItem);
if (!string.Equals(sourceDocument.GetChecksumAlgorithm(), primaryChecksum.ChecksumAlgorithm) ||
!ChecksumsEqual(primaryChecksum.Checksum, sourceDocument.GetChecksum()))
{
// Main file exists, but checksums not equal.
return false;
}
for (var i = 0; i < checksums.Count; i++)
{
IRazorSourceChecksumMetadata checksum = checksums[i];
if (string.Equals(item.Identifier, checksum.Identifier, StringComparison.OrdinalIgnoreCase))
{
// Ignore primary checksum on this pass.
continue;
}
RazorProjectItem importItem = fileSystem.GetItem(checksum.Identifier, fileKind: null);
if (!importItem.Exists)
{
// Import file doesn't exist - assume invalid.
return false;
}
sourceDocument = RazorSourceDocument.ReadFrom(importItem);
if (!string.Equals(sourceDocument.GetChecksumAlgorithm(), checksum.ChecksumAlgorithm) ||
!ChecksumsEqual(checksum.Checksum, sourceDocument.GetChecksum()))
{
// Import file exists, but checksums not equal.
return false;
}
}
return true;
}
private static bool ChecksumsEqual(string checksum, byte[] bytes)
{
if (bytes.Length * 2 != checksum.Length)
{
return false;
}
for (var i = 0; i < bytes.Length; i++)
{
var text = bytes[i].ToString("x2", CultureInfo.InvariantCulture);
if (checksum[i * 2] != text[0] || checksum[(i * 2) + 1] != text[1])
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,489 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Microsoft.AspNetCore.Razor.Hosting;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Umbraco.Extensions;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class CollectibleRuntimeViewCompiler : IViewCompiler
{
private readonly Lock _cacheLock = new();
private readonly Dictionary<string, CompiledViewDescriptor> _precompiledViews;
private readonly ConcurrentDictionary<string, string> _normalizedPathCache;
private readonly IFileProvider _fileProvider;
private readonly RazorProjectEngine _projectEngine;
private IMemoryCache _cache;
private readonly ILogger<CollectibleRuntimeViewCompiler> _logger;
private readonly UmbracoRazorReferenceManager _referenceManager;
private readonly CompilationOptionsProvider _compilationOptionsProvider;
private readonly InMemoryAssemblyLoadContextManager _loadContextManager;
public CollectibleRuntimeViewCompiler(
IFileProvider fileProvider,
RazorProjectEngine projectEngine,
IList<CompiledViewDescriptor> precompiledViews,
ILogger<CollectibleRuntimeViewCompiler> logger,
UmbracoRazorReferenceManager referenceManager,
CompilationOptionsProvider compilationOptionsProvider,
InMemoryAssemblyLoadContextManager loadContextManager)
{
if (precompiledViews == null)
{
throw new ArgumentNullException(nameof(precompiledViews));
}
_fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider));
_projectEngine = projectEngine ?? throw new ArgumentNullException(nameof(projectEngine));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_referenceManager = referenceManager;
_compilationOptionsProvider = compilationOptionsProvider;
_loadContextManager = loadContextManager;
_normalizedPathCache = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
// This is our L0 cache, and is a durable store. Views migrate into the cache as they are requested
// from either the set of known precompiled views, or by being compiled.
_cache = new MemoryCache(new MemoryCacheOptions());
// We need to validate that the all of the precompiled views are unique by path (case-insensitive).
// We do this because there's no good way to canonicalize paths on windows, and it will create
// problems when deploying to linux. Rather than deal with these issues, we just don't support
// views that differ only by case.
_precompiledViews = new Dictionary<string, CompiledViewDescriptor>(
precompiledViews.Count,
StringComparer.OrdinalIgnoreCase);
foreach (CompiledViewDescriptor precompiledView in precompiledViews)
{
_logger.LogDebug("Initializing Razor view compiler with compiled view: '{ViewName}'", precompiledView.RelativePath);
// View ordering has precedence semantics, a view with a higher precedence was
// already added to the list.
_precompiledViews.TryAdd(precompiledView.RelativePath, precompiledView);
}
if (_precompiledViews.Count == 0)
{
_logger.LogDebug("Initializing Razor view compiler with no compiled views");
}
}
internal void ClearCache()
{
// I'm pretty sure this is not necessary, since it should be an atomic operation,
// but let's make sure that we don't end up resolving any views while clearing the cache.
lock (_cacheLock)
{
_cache = new MemoryCache(new MemoryCacheOptions());
}
}
public Task<CompiledViewDescriptor> CompileAsync(string relativePath)
{
if (relativePath == null)
{
throw new ArgumentNullException(nameof(relativePath));
}
// Attempt to lookup the cache entry using the passed in path. This will succeed if the path is already
// normalized and a cache entry exists.
if (_cache.TryGetValue<Task<CompiledViewDescriptor>>(relativePath, out Task<CompiledViewDescriptor>? cachedResult) && cachedResult is not null)
{
return cachedResult;
}
var normalizedPath = GetNormalizedPath(relativePath);
if (_cache.TryGetValue(normalizedPath, out cachedResult) && cachedResult is not null)
{
return cachedResult;
}
// Entry does not exist. Attempt to create one.
cachedResult = OnCacheMiss(normalizedPath);
return cachedResult;
}
private Task<CompiledViewDescriptor> OnCacheMiss(string normalizedPath)
{
ViewCompilerWorkItem item;
TaskCompletionSource<CompiledViewDescriptor> taskSource;
MemoryCacheEntryOptions cacheEntryOptions;
// Safe races cannot be allowed when compiling Razor pages. To ensure only one compilation request succeeds
// per file, we'll lock the creation of a cache entry. Creating the cache entry should be very quick. The
// actual work for compiling files happens outside the critical section.
lock (_cacheLock)
{
// Double-checked locking to handle a possible race.
if (_cache.TryGetValue<Task<CompiledViewDescriptor>>(normalizedPath, out Task<CompiledViewDescriptor>? result) && result is not null)
{
return result;
}
if (_precompiledViews.TryGetValue(normalizedPath, out CompiledViewDescriptor? precompiledView))
{
_logger.LogTrace("Located compiled view for view at path '{Path}'", normalizedPath);
item = CreatePrecompiledWorkItem(normalizedPath, precompiledView);
}
else
{
item = CreateRuntimeCompilationWorkItem(normalizedPath);
}
// At this point, we've decided what to do - but we should create the cache entry and
// release the lock first.
cacheEntryOptions = new MemoryCacheEntryOptions();
Debug.Assert(item.ExpirationTokens != null);
for (var i = 0; i < item.ExpirationTokens.Count; i++)
{
cacheEntryOptions.ExpirationTokens.Add(item.ExpirationTokens[i]);
}
taskSource = new TaskCompletionSource<CompiledViewDescriptor>(creationOptions: TaskCreationOptions.RunContinuationsAsynchronously);
if (item.SupportsCompilation)
{
// We'll compile in just a sec, be patient.
}
else
{
// If we can't compile, we should have already created the descriptor
Debug.Assert(item.Descriptor != null);
taskSource.SetResult(item.Descriptor);
}
_cache.Set(normalizedPath, taskSource.Task, cacheEntryOptions);
}
// Now the lock has been released so we can do more expensive processing.
if (item.SupportsCompilation)
{
Debug.Assert(taskSource != null);
if (item.Descriptor?.Item != null &&
ChecksumValidator.IsItemValid(_projectEngine.FileSystem, item.Descriptor.Item))
{
// If the item has checksums to validate, we should also have a precompiled view.
Debug.Assert(item.Descriptor != null);
taskSource.SetResult(item.Descriptor);
return taskSource.Task;
}
_logger.LogTrace("Invalidating compiled view at path '{Path}' with a file since the checksum did not match", item.NormalizedPath);
try
{
CompiledViewDescriptor descriptor = CompileAndEmit(normalizedPath);
descriptor.ExpirationTokens = cacheEntryOptions.ExpirationTokens;
taskSource.SetResult(descriptor);
}
catch (Exception ex)
{
taskSource.SetException(ex);
}
}
return taskSource.Task;
}
private ViewCompilerWorkItem CreatePrecompiledWorkItem(string normalizedPath, CompiledViewDescriptor precompiledView)
{
// We have a precompiled view - but we're not sure that we can use it yet.
//
// We need to determine first if we have enough information to 'recompile' this view. If that's the case
// we'll create change tokens for all of the files.
//
// Then we'll attempt to validate if any of those files have different content than the original sources
// based on checksums.
if (precompiledView.Item == null || !ChecksumValidator.IsRecompilationSupported(precompiledView.Item))
{
return new ViewCompilerWorkItem()
{
// If we don't have a checksum for the primary source file we can't recompile.
SupportsCompilation = false,
ExpirationTokens = Array.Empty<IChangeToken>(), // Never expire because we can't recompile.
Descriptor = precompiledView, // This will be used as-is.
};
}
var item = new ViewCompilerWorkItem()
{
SupportsCompilation = true,
Descriptor = precompiledView, // This might be used, if the checksums match.
// Used to validate and recompile
NormalizedPath = normalizedPath,
ExpirationTokens = GetExpirationTokens(precompiledView),
};
// We also need to create a new descriptor, because the original one doesn't have expiration tokens on
// it. These will be used by the view location cache, which is like an L1 cache for views (this class is
// the L2 cache).
item.Descriptor = new CompiledViewDescriptor()
{
ExpirationTokens = item.ExpirationTokens,
Item = precompiledView.Item,
RelativePath = precompiledView.RelativePath,
};
return item;
}
private ViewCompilerWorkItem CreateRuntimeCompilationWorkItem(string normalizedPath)
{
IList<IChangeToken> expirationTokens = new List<IChangeToken>
{
_fileProvider.Watch(normalizedPath),
};
RazorProjectItem projectItem = _projectEngine.FileSystem.GetItem(normalizedPath, fileKind: null);
if (!projectItem.Exists)
{
_logger.LogTrace("Could not find a file for view at path '{Path}'", normalizedPath);
// If the file doesn't exist, we can't do compilation right now - we still want to cache
// the fact that we tried. This will allow us to re-trigger compilation if the view file
// is added.
return new ViewCompilerWorkItem()
{
// We don't have enough information to compile
SupportsCompilation = false,
Descriptor = new CompiledViewDescriptor()
{
RelativePath = normalizedPath,
ExpirationTokens = expirationTokens,
},
// We can try again if the file gets created.
ExpirationTokens = expirationTokens,
};
}
_logger.LogTrace("Found file at path '{Path}'", normalizedPath);
GetChangeTokensFromImports(expirationTokens, projectItem);
return new ViewCompilerWorkItem()
{
SupportsCompilation = true,
NormalizedPath = normalizedPath,
ExpirationTokens = expirationTokens,
};
}
private IList<IChangeToken> GetExpirationTokens(CompiledViewDescriptor precompiledView)
{
IReadOnlyList<IRazorSourceChecksumMetadata> checksums = precompiledView.Item.GetChecksumMetadata();
var expirationTokens = new List<IChangeToken>(checksums.Count);
for (var i = 0; i < checksums.Count; i++)
{
// We rely on Razor to provide the right set of checksums. Trust the compiler, it has to do a good job,
// so it probably will.
expirationTokens.Add(_fileProvider.Watch(checksums[i].Identifier));
}
return expirationTokens;
}
private void GetChangeTokensFromImports(IList<IChangeToken> expirationTokens, RazorProjectItem projectItem)
{
// OK this means we can do compilation. For now let's just identify the other files we need to watch
// so we can create the cache entry. Compilation will happen after we release the lock.
IImportProjectFeature[] importFeature = _projectEngine.ProjectFeatures.OfType<IImportProjectFeature>().ToArray();
foreach (IImportProjectFeature feature in importFeature)
{
foreach (RazorProjectItem? file in feature.GetImports(projectItem))
{
if (file.FilePath != null)
{
expirationTokens.Add(_fileProvider.Watch(file.FilePath));
}
}
}
}
private CompiledViewDescriptor CompileAndEmit(string relativePath)
{
RazorProjectItem projectItem = _projectEngine.FileSystem.GetItem(relativePath, fileKind: null);
RazorCodeDocument codeDocument = _projectEngine.Process(projectItem);
RazorCSharpDocument cSharpDocument = codeDocument.GetCSharpDocument();
if (cSharpDocument.Diagnostics.Count > 0)
{
throw CompilationExceptionFactory.Create(
codeDocument,
cSharpDocument.Diagnostics);
}
Assembly assembly = CompileAndEmit(codeDocument, cSharpDocument.GeneratedCode);
// Anything we compile from source will use Razor 2.1 and so should have the new metadata.
var loader = new RazorCompiledItemLoader();
RazorCompiledItem item = loader.LoadItems(assembly).Single();
return new CompiledViewDescriptor(item);
}
internal Assembly CompileAndEmit(RazorCodeDocument codeDocument, string generatedCode)
{
var startTimestamp = _logger.IsEnabled(LogLevel.Debug) ? Stopwatch.GetTimestamp() : 0;
var assemblyName = Path.GetRandomFileName();
CSharpCompilation compilation = CreateCompilation(generatedCode, assemblyName);
EmitOptions emitOptions = _compilationOptionsProvider.EmitOptions;
var emitPdbFile = _compilationOptionsProvider.EmitPdb && emitOptions.DebugInformationFormat != DebugInformationFormat.Embedded;
using (var assemblyStream = new MemoryStream())
using (MemoryStream? pdbStream = emitPdbFile ? new MemoryStream() : null)
{
EmitResult result = compilation.Emit(
assemblyStream,
pdbStream,
options: _compilationOptionsProvider.EmitOptions);
if (result.Success is false)
{
UmbracoCompilationException compilationException = CompilationExceptionFactory.Create(
codeDocument,
generatedCode,
assemblyName,
result.Diagnostics);
LogCompilationFailure(compilationException);
throw compilationException;
}
assemblyStream.Seek(0, SeekOrigin.Begin);
pdbStream?.Seek(0, SeekOrigin.Begin);
Assembly assembly = _loadContextManager.LoadCollectibleAssemblyFromStream(assemblyStream, pdbStream);
return assembly;
}
}
private void LogCompilationFailure(UmbracoCompilationException compilationException)
{
IEnumerable<string>? messages = compilationException.CompilationFailures?
.WhereNotNull()
.SelectMany(x => x.Messages!)
.WhereNotNull()
.Select(x => x.FormattedMessage)
.WhereNotNull();
foreach (var message in messages ?? Enumerable.Empty<string>())
{
_logger.LogError(compilationException, "Compilation error occured with message: {ErrorMessage}", message);
}
}
private CSharpCompilation CreateCompilation(string compilationContent, string assemblyName)
{
IReadOnlyList<MetadataReference> refs = _referenceManager.CompilationReferences;
var sourceText = SourceText.From(compilationContent, Encoding.UTF8);
SyntaxTree syntaxTree = SyntaxFactory
.ParseSyntaxTree(sourceText, _compilationOptionsProvider.ParseOptions)
.WithFilePath(assemblyName);
CSharpCompilation compilation = CSharpCompilation
.Create(assemblyName)
.AddSyntaxTrees(syntaxTree)
.AddReferences(refs)
.WithOptions(_compilationOptionsProvider.CSharpCompilationOptions);
// We'll add the reference to the InMemory assembly directly, this means we don't have to hack around with assembly parts.
// We might be asked to compile views before the InMemory models assembly is created tho (if you replace the no-nodes for instance)
// In this case we'll just skip the InMemory models assembly reference
if (_loadContextManager.ModelsAssemblyLocation is null)
{
_logger.LogInformation("No InMemory models assembly available, skipping reference");
return compilation;
}
PortableExecutableReference inMemoryAutoReference = MetadataReference.CreateFromFile(_loadContextManager.ModelsAssemblyLocation);
compilation = compilation.AddReferences(inMemoryAutoReference);
return compilation;
}
private string GetNormalizedPath(string relativePath)
{
Debug.Assert(relativePath != null);
if (relativePath.Length == 0)
{
return relativePath;
}
if (!_normalizedPathCache.TryGetValue(relativePath, out var normalizedPath))
{
normalizedPath = NormalizePath(relativePath);
_normalizedPathCache[relativePath] = normalizedPath;
}
return normalizedPath;
}
// Taken from: https://github.com/dotnet/aspnetcore/blob/a450cb69b5e4549f5515cdb057a68771f56cefd7/src/Mvc/Mvc.Razor/src/ViewPath.cs
// This normalizes the relative path to the view, ensuring that it matches with what we have as keys in _precompiledViews
private static string NormalizePath(string path)
{
var addLeadingSlash = path[0] != '\\' && path[0] != '/';
var transformSlashes = path.IndexOf('\\') != -1;
if (!addLeadingSlash && !transformSlashes)
{
return path;
}
var length = path.Length;
if (addLeadingSlash)
{
length++;
}
return string.Create(length, (path, addLeadingSlash), (span, tuple) =>
{
(string pathValue, bool addLeadingSlashValue) = tuple;
var spanIndex = 0;
if (addLeadingSlashValue)
{
span[spanIndex++] = '/';
}
foreach (var ch in pathValue)
{
span[spanIndex++] = ch == '\\' ? '/' : ch;
}
});
}
private sealed class ViewCompilerWorkItem
{
public bool SupportsCompilation { get; set; } = default!;
public string NormalizedPath { get; set; } = default!;
public IList<IChangeToken> ExpirationTokens { get; set; } = default!;
public CompiledViewDescriptor Descriptor { get; set; } = default!;
}
}

View File

@@ -0,0 +1,137 @@
using System.Globalization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
/*
* This is a partial clone of the frameworks CompilationFailedExceptionFactory, a few things has been simplified to fit our needs.
* https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Razor.RuntimeCompilation/src/CompilationFailedExceptionFactory.cs
*/
internal static class CompilationExceptionFactory
{
public static UmbracoCompilationException Create(
RazorCodeDocument codeDocument,
IEnumerable<RazorDiagnostic> diagnostics)
{
// If a SourceLocation does not specify a file path, assume it is produced from parsing the current file.
var messageGroups = diagnostics.GroupBy(
razorError => razorError.Span.FilePath ?? codeDocument.Source.FilePath,
StringComparer.Ordinal);
var failures = new List<CompilationFailure>();
foreach (var group in messageGroups)
{
var filePath = group.Key;
var fileContent = ReadContent(codeDocument, filePath);
var compilationFailure = new CompilationFailure(
filePath,
fileContent,
compiledContent: string.Empty,
messages: group.Select(parserError => CreateDiagnosticMessage(parserError, filePath)));
failures.Add(compilationFailure);
}
return new UmbracoCompilationException{CompilationFailures = failures};
}
public static UmbracoCompilationException Create(
RazorCodeDocument codeDocument,
string compilationContent,
string assemblyName,
IEnumerable<Diagnostic> diagnostics)
{
var diagnosticGroups = diagnostics
.Where(diagnostic => diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error)
.GroupBy(diagnostic => GetFilePath(codeDocument, diagnostic), StringComparer.Ordinal);
var failures = new List<CompilationFailure>();
foreach (var group in diagnosticGroups)
{
var sourceFilePath = group.Key;
string sourceFileContent;
if (string.Equals(assemblyName, sourceFilePath, StringComparison.Ordinal))
{
// The error is in the generated code and does not have a mapping line pragma
sourceFileContent = compilationContent;
}
else
{
sourceFileContent = ReadContent(codeDocument, sourceFilePath!);
}
var compilationFailure = new CompilationFailure(
sourceFilePath,
sourceFileContent,
compilationContent,
group.Select(GetDiagnosticMessage));
failures.Add(compilationFailure);
}
return new UmbracoCompilationException{ CompilationFailures = failures};
}
private static string ReadContent(RazorCodeDocument codeDocument, string filePath)
{
RazorSourceDocument? sourceDocument;
if (string.IsNullOrEmpty(filePath) || string.Equals(codeDocument.Source.FilePath, filePath, StringComparison.Ordinal))
{
sourceDocument = codeDocument.Source;
}
else
{
sourceDocument = codeDocument.Imports.FirstOrDefault(f => string.Equals(f.FilePath, filePath, StringComparison.Ordinal));
}
if (sourceDocument != null)
{
var contentChars = new char[sourceDocument.Length];
sourceDocument.CopyTo(0, contentChars, 0, sourceDocument.Length);
return new string(contentChars);
}
return string.Empty;
}
private static DiagnosticMessage GetDiagnosticMessage(Diagnostic diagnostic)
{
var mappedLineSpan = diagnostic.Location.GetMappedLineSpan();
return new DiagnosticMessage(
diagnostic.GetMessage(CultureInfo.CurrentCulture),
CSharpDiagnosticFormatter.Instance.Format(diagnostic, CultureInfo.CurrentCulture),
mappedLineSpan.Path,
mappedLineSpan.StartLinePosition.Line + 1,
mappedLineSpan.StartLinePosition.Character + 1,
mappedLineSpan.EndLinePosition.Line + 1,
mappedLineSpan.EndLinePosition.Character + 1);
}
private static string GetFilePath(RazorCodeDocument codeDocument, Diagnostic diagnostic)
{
if (diagnostic.Location == Location.None)
{
return codeDocument.Source.FilePath;
}
return diagnostic.Location.GetMappedLineSpan().Path;
}
private static DiagnosticMessage CreateDiagnosticMessage(
RazorDiagnostic razorDiagnostic,
string filePath)
{
var sourceSpan = razorDiagnostic.Span;
var message = razorDiagnostic.GetMessage(CultureInfo.CurrentCulture);
return new DiagnosticMessage(
message: message,
formattedMessage: razorDiagnostic.ToString(),
filePath: filePath,
startLine: sourceSpan.LineIndex + 1,
startColumn: sourceSpan.CharacterIndex,
endLine: sourceSpan.LineIndex + 1,
endColumn: sourceSpan.CharacterIndex + sourceSpan.Length);
}
}

View File

@@ -0,0 +1,200 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.Extensions.DependencyModel;
using Microsoft.Extensions.Hosting;
using DependencyContextCompilationOptions = Microsoft.Extensions.DependencyModel.CompilationOptions;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
/*
* This is a partial Clone'n'Own of microsofts CSharpCompiler, this is just the parts relevant for getting the CompilationOptions
* Essentially, what this does is that it looks at the compilation options of the Dotnet project and "copies" that
* https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Razor.RuntimeCompilation/src/CSharpCompiler.cs
*/
internal class CompilationOptionsProvider
{
private readonly IWebHostEnvironment _hostingEnvironment;
private CSharpParseOptions? _parseOptions;
private CSharpCompilationOptions? _compilationOptions;
private bool _emitPdb;
private EmitOptions? _emitOptions;
private bool _optionsInitialized;
public CompilationOptionsProvider(IWebHostEnvironment hostingEnvironment) =>
_hostingEnvironment = hostingEnvironment;
public virtual CSharpParseOptions ParseOptions
{
get
{
EnsureOptions();
return _parseOptions;
}
}
public virtual CSharpCompilationOptions CSharpCompilationOptions
{
get
{
EnsureOptions();
return _compilationOptions;
}
}
public virtual bool EmitPdb
{
get
{
EnsureOptions();
return _emitPdb;
}
}
public virtual EmitOptions EmitOptions
{
get
{
EnsureOptions();
return _emitOptions;
}
}
[MemberNotNull(nameof(_emitOptions), nameof(_parseOptions), nameof(_compilationOptions))]
private void EnsureOptions()
{
if (!_optionsInitialized)
{
var dependencyContextOptions = GetDependencyContextCompilationOptions();
_parseOptions = GetParseOptions(_hostingEnvironment, dependencyContextOptions);
_compilationOptions = GetCompilationOptions(_hostingEnvironment, dependencyContextOptions);
_emitOptions = GetEmitOptions(dependencyContextOptions);
_optionsInitialized = true;
}
Debug.Assert(_parseOptions is not null);
Debug.Assert(_compilationOptions is not null);
Debug.Assert(_emitOptions is not null);
}
private DependencyContextCompilationOptions GetDependencyContextCompilationOptions()
{
if (!string.IsNullOrEmpty(_hostingEnvironment.ApplicationName))
{
var applicationAssembly = Assembly.Load(new AssemblyName(_hostingEnvironment.ApplicationName));
var dependencyContext = DependencyContext.Load(applicationAssembly);
if (dependencyContext?.CompilationOptions != null)
{
return dependencyContext.CompilationOptions;
}
}
return DependencyContextCompilationOptions.Default;
}
private EmitOptions GetEmitOptions(DependencyContextCompilationOptions dependencyContextOptions)
{
// Assume we're always producing pdbs unless DebugType = none
_emitPdb = true;
DebugInformationFormat debugInformationFormat;
if (string.IsNullOrEmpty(dependencyContextOptions.DebugType))
{
debugInformationFormat = DebugInformationFormat.PortablePdb;
}
else
{
// Based on https://github.com/dotnet/roslyn/blob/1d28ff9ba248b332de3c84d23194a1d7bde07e4d/src/Compilers/CSharp/Portable/CommandLine/CSharpCommandLineParser.cs#L624-L640
switch (dependencyContextOptions.DebugType.ToLowerInvariant())
{
case "none":
// There isn't a way to represent none in DebugInformationFormat.
// We'll set EmitPdb to false and let callers handle it by setting a null pdb-stream.
_emitPdb = false;
return new EmitOptions();
case "portable":
debugInformationFormat = DebugInformationFormat.PortablePdb;
break;
case "embedded":
// Roslyn does not expose enough public APIs to produce a binary with embedded pdbs.
// We'll produce PortablePdb instead to continue providing a reasonable user experience.
debugInformationFormat = DebugInformationFormat.PortablePdb;
break;
case "full":
case "pdbonly":
debugInformationFormat = DebugInformationFormat.PortablePdb;
break;
default:
throw new InvalidOperationException();
}
}
var emitOptions = new EmitOptions(debugInformationFormat: debugInformationFormat);
return emitOptions;
}
private static CSharpCompilationOptions GetCompilationOptions(
IWebHostEnvironment hostingEnvironment,
DependencyContextCompilationOptions dependencyContextOptions)
{
var csharpCompilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
// Disable 1702 until roslyn turns this off by default
csharpCompilationOptions = csharpCompilationOptions.WithSpecificDiagnosticOptions(
new Dictionary<string, ReportDiagnostic>
{
{"CS1701", ReportDiagnostic.Suppress}, // Binding redirects
{"CS1702", ReportDiagnostic.Suppress},
{"CS1705", ReportDiagnostic.Suppress}
});
if (dependencyContextOptions.AllowUnsafe.HasValue)
{
csharpCompilationOptions = csharpCompilationOptions.WithAllowUnsafe(
dependencyContextOptions.AllowUnsafe.Value);
}
OptimizationLevel optimizationLevel;
if (dependencyContextOptions.Optimize.HasValue)
{
optimizationLevel = dependencyContextOptions.Optimize.Value ?
OptimizationLevel.Release :
OptimizationLevel.Debug;
}
else
{
optimizationLevel = hostingEnvironment.IsDevelopment() ?
OptimizationLevel.Debug :
OptimizationLevel.Release;
}
csharpCompilationOptions = csharpCompilationOptions.WithOptimizationLevel(optimizationLevel);
if (dependencyContextOptions.WarningsAsErrors.HasValue)
{
var reportDiagnostic = dependencyContextOptions.WarningsAsErrors.Value ?
ReportDiagnostic.Error :
ReportDiagnostic.Default;
csharpCompilationOptions = csharpCompilationOptions.WithGeneralDiagnosticOption(reportDiagnostic);
}
return csharpCompilationOptions;
}
private static CSharpParseOptions GetParseOptions(
IWebHostEnvironment hostingEnvironment,
DependencyContextCompilationOptions dependencyContextOptions)
{
var configurationSymbol = hostingEnvironment.IsDevelopment() ? "DEBUG" : "RELEASE";
var defines = dependencyContextOptions.Defines.Concat(new[] { configurationSymbol }).Where(define => define != null);
var parseOptions = new CSharpParseOptions(preprocessorSymbols: (IEnumerable<string>)defines);
parseOptions = parseOptions.WithLanguageVersion(LanguageVersion.Latest);
return parseOptions;
}
}

View File

@@ -0,0 +1,65 @@
using System.Reflection;
using System.Runtime.Loader;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class InMemoryAssemblyLoadContextManager
{
private UmbracoAssemblyLoadContext? _currentAssemblyLoadContext;
public InMemoryAssemblyLoadContextManager() =>
AssemblyLoadContext.Default.Resolving += OnResolvingDefaultAssemblyLoadContext;
private string? _modelsAssemblyLocation;
public string? ModelsAssemblyLocation => _modelsAssemblyLocation;
/// <summary>
/// Handle the event when a reference cannot be resolved from the default context and return our custom MB assembly reference if we have one
/// </summary>
/// <remarks>
/// This is required because the razor engine will only try to load things from the default context, it doesn't know anything
/// about our context so we need to proxy.
/// </remarks>
private Assembly? OnResolvingDefaultAssemblyLoadContext(AssemblyLoadContext assemblyLoadContext, AssemblyName assemblyName)
=> assemblyName.Name == RoslynCompiler.GeneratedAssemblyName
? _currentAssemblyLoadContext?.LoadFromAssemblyName(assemblyName)
: null;
internal void RenewAssemblyLoadContext()
{
// If there's a current AssemblyLoadContext, unload it before creating a new one.
_currentAssemblyLoadContext?.Unload();
// We must create a new assembly load context
// as long as theres a reference to the assembly load context we can't delete the assembly it loaded
_currentAssemblyLoadContext = new UmbracoAssemblyLoadContext();
}
/// <summary>
/// Loads an assembly into the collectible assembly used by the factory
/// </summary>
/// <remarks>
/// This is essentially just a wrapper around the <see cref="UmbracoAssemblyLoadContext"/>,
/// because we don't want to allow other clases to take a reference on the AssemblyLoadContext
/// </remarks>
/// <returns>The loaded assembly</returns>
public Assembly LoadCollectibleAssemblyFromStream(Stream assembly, Stream? assemblySymbols)
{
_currentAssemblyLoadContext ??= new UmbracoAssemblyLoadContext();
return _currentAssemblyLoadContext.LoadFromStream(assembly, assemblySymbols);
}
public Assembly LoadCollectibleAssemblyFromPath(string path)
{
_currentAssemblyLoadContext ??= new UmbracoAssemblyLoadContext();
return _currentAssemblyLoadContext.LoadFromAssemblyPath(path);
}
public Assembly LoadModelsAssembly(string path)
{
Assembly assembly = LoadCollectibleAssemblyFromPath(path);
_modelsAssemblyLocation = assembly.Location;
return assembly;
}
}

View File

@@ -0,0 +1,876 @@
using System.Collections;
using System.Collections.Frozen;
using System.Reflection;
using System.Reflection.Emit;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Infrastructure.ModelsBuilder;
using Umbraco.Cms.Infrastructure.ModelsBuilder.Building;
using Umbraco.Extensions;
using File = System.IO.File;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
{
internal sealed partial class InMemoryModelFactory : IAutoPublishedModelFactory, IRegisteredObject, IDisposable
{
private static readonly Regex s_usingRegex = GetUsingRegex();
[GeneratedRegex("^using(.*);", RegexOptions.Multiline | RegexOptions.Compiled)]
private static partial Regex GetUsingRegex();
private static readonly Regex s_aattrRegex = GetAssemblyRegex();
[GeneratedRegex("^\\[assembly:(.*)\\]", RegexOptions.Multiline | RegexOptions.Compiled)]
private static partial Regex GetAssemblyRegex();
private static readonly Regex s_assemblyVersionRegex = GetAssemblyVersionRegex();
[GeneratedRegex("AssemblyVersion\\(\"[0-9]+.[0-9]+.[0-9]+.[0-9]+\"\\)", RegexOptions.Compiled)]
private static partial Regex GetAssemblyVersionRegex();
private static readonly FrozenSet<string> s_ourFiles = FrozenSet.Create("models.hash", "models.generated.cs", "all.generated.cs", "all.dll.path", "models.err", "Compiled");
private readonly ReaderWriterLockSlim _locker = new ReaderWriterLockSlim();
private readonly IProfilingLogger _profilingLogger;
private readonly ILogger<InMemoryModelFactory> _logger;
private readonly FileSystemWatcher? _watcher;
private readonly Lazy<UmbracoServices> _umbracoServices; // TODO: this is because of circular refs :(
private readonly IHostEnvironment _hostEnvironment;
private readonly IApplicationShutdownRegistry _hostingLifetime;
private readonly ModelsGenerationError _errors;
private readonly IPublishedValueFallback _publishedValueFallback;
private readonly InMemoryAssemblyLoadContextManager _loadContextManager;
private readonly RuntimeCompilationCacheBuster _runtimeCompilationCacheBuster;
private readonly Lazy<string> _pureLiveDirectory = null!;
private readonly int _debugLevel;
private Infos _infos = new Infos { ModelInfos = null, ModelTypeMap = new Dictionary<string, Type>() };
private volatile bool _hasModels; // volatile 'cos reading outside lock
private bool _pendingRebuild;
private int _ver;
private int? _skipver;
private RoslynCompiler? _roslynCompiler;
private ModelsBuilderSettings _config;
private bool _disposedValue;
public InMemoryModelFactory(
Lazy<UmbracoServices> umbracoServices,
IProfilingLogger profilingLogger,
ILogger<InMemoryModelFactory> logger,
IOptionsMonitor<ModelsBuilderSettings> config,
IHostingEnvironment hostingEnvironment,
IHostEnvironment hostEnvironment,
IApplicationShutdownRegistry hostingLifetime,
IPublishedValueFallback publishedValueFallback,
InMemoryAssemblyLoadContextManager loadContextManager,
RuntimeCompilationCacheBuster runtimeCompilationCacheBuster)
{
_umbracoServices = umbracoServices;
_profilingLogger = profilingLogger;
_logger = logger;
_config = config.CurrentValue;
_hostEnvironment = hostEnvironment;
_hostingLifetime = hostingLifetime;
_publishedValueFallback = publishedValueFallback;
_loadContextManager = loadContextManager;
_runtimeCompilationCacheBuster = runtimeCompilationCacheBuster;
_errors = new ModelsGenerationError(config, _hostEnvironment);
_ver = 1; // zero is for when we had no version
_skipver = -1; // nothing to skip
if (!hostingEnvironment.IsHosted)
{
return;
}
config.OnChange(x => _config = x);
_pureLiveDirectory = new Lazy<string>(PureLiveDirectoryAbsolute);
if (!Directory.Exists(_pureLiveDirectory.Value))
{
Directory.CreateDirectory(_pureLiveDirectory.Value);
}
// BEWARE! if the watcher is not properly released then for some reason the
// BuildManager will start confusing types - using a 'registered object' here
// though we should probably plug into Umbraco's MainDom - which is internal
_hostingLifetime.RegisterObject(this);
_watcher = new FileSystemWatcher(_pureLiveDirectory.Value);
_watcher.Changed += WatcherOnChanged;
_watcher.EnableRaisingEvents = true;
// get it here, this need to be fast
_debugLevel = _config.DebugLevel;
}
/// <summary>
/// Gets the currently loaded Live models assembly
/// </summary>
/// <remarks>
/// Can be null
/// </remarks>
public Assembly? CurrentModelsAssembly { get; private set; }
/// <inheritdoc />
public object SyncRoot { get; } = new();
private UmbracoServices UmbracoServices => _umbracoServices.Value;
/// <summary>
/// Gets the RoslynCompiler
/// </summary>
private RoslynCompiler RoslynCompiler
{
get
{
if (_roslynCompiler != null)
{
return _roslynCompiler;
}
_roslynCompiler = new RoslynCompiler();
return _roslynCompiler;
}
}
/// <inheritdoc />
public bool Enabled => _config.ModelsMode == ModelsModeConstants.InMemoryAuto;
public IPublishedElement CreateModel(IPublishedElement element)
{
// get models, rebuilding them if needed
Dictionary<string, ModelInfo>? infos = EnsureModels().ModelInfos;
if (infos == null)
{
return element;
}
// be case-insensitive
var contentTypeAlias = element.ContentType.Alias;
// lookup model constructor (else null)
infos.TryGetValue(contentTypeAlias, out var info);
// create model
return info is null || info.Ctor is null ? element : info.Ctor(element, _publishedValueFallback);
}
/// <inheritdoc />
public Type GetModelType(string? alias)
{
Infos infos = EnsureModels();
// fail fast
if (alias is null ||
infos.ModelInfos is null ||
!infos.ModelInfos.TryGetValue(alias, out ModelInfo? modelInfo) ||
modelInfo.ModelType is null)
{
return typeof(IPublishedElement);
}
return modelInfo.ModelType;
}
// this runs only once the factory is ready
// NOT when building models
public Type MapModelType(Type type)
{
Infos infos = EnsureModels();
return ModelType.Map(type, infos.ModelTypeMap);
}
// this runs only once the factory is ready
// NOT when building models
public IList CreateModelList(string? alias)
{
Infos infos = EnsureModels();
// fail fast
if (alias is null || infos.ModelInfos is null || !infos.ModelInfos.TryGetValue(alias, out ModelInfo? modelInfo))
{
return new List<IPublishedElement>();
}
Func<IList>? ctor = modelInfo.ListCtor;
if (ctor != null)
{
return ctor();
}
if (modelInfo.ModelType is null)
{
return new List<IPublishedElement>();
}
Type listType = typeof(List<>).MakeGenericType(modelInfo.ModelType);
ctor = modelInfo.ListCtor = ReflectionUtilities.EmitConstructor<Func<IList>>(declaring: listType);
return ctor is null ? new List<IPublishedElement>() : ctor();
}
/// <inheritdoc />
public void Reset()
{
if (Enabled)
{
ResetModels();
}
}
// tells the factory that it should build a new generation of models
private void ResetModels()
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Resetting models.");
}
try
{
_locker.EnterWriteLock();
_hasModels = false;
_pendingRebuild = true;
if (!Directory.Exists(_pureLiveDirectory.Value))
{
Directory.CreateDirectory(_pureLiveDirectory.Value);
}
// clear stuff
var modelsHashFile = Path.Combine(_pureLiveDirectory.Value, "models.hash");
var dllPathFile = Path.Combine(_pureLiveDirectory.Value, "all.dll.path");
if (File.Exists(dllPathFile))
{
File.Delete(dllPathFile);
}
if (File.Exists(modelsHashFile))
{
File.Delete(modelsHashFile);
}
}
finally
{
if (_locker.IsWriteLockHeld)
{
_locker.ExitWriteLock();
}
}
}
// ensure that the factory is running with the lastest generation of models
internal Infos EnsureModels()
{
if (_debugLevel > 0)
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Ensuring models.");
}
}
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
try
{
_locker.EnterUpgradeableReadLock();
if (_hasModels)
{
return _infos;
}
_locker.EnterWriteLock();
// we don't have models,
// either they haven't been loaded from the cache yet
// or they have been reseted and are pending a rebuild
using (!_profilingLogger.IsEnabled(Core.Logging.LogLevel.Debug) ? null : _profilingLogger.DebugDuration<InMemoryModelFactory>("Get models.", "Got models."))
{
try
{
Assembly assembly = GetModelsAssembly(_pendingRebuild);
CurrentModelsAssembly = assembly;
/*
* We used to use an event here, and a RefreshingRazorViewEngine to bust the caches,
* this worked by essentially completely recreating the entire ViewEngine/ViewCompiler every time we generate models.
* There was this note about first load:
* NOTE: That on first load, if there is content, this will execute before the razor view engine
* has loaded which means it hasn't yet bound to this event so there's no need to worry about if
* it will be eagerly re-generated unnecessarily on first render. BUT we should be aware that if we
* change this to use the event aggregator that will no longer be the case.
*
* Now we have our own ViewCompiler, and clear the caches more directly, however what the comment mentioned
* is not really a big problem since this will execute before the razor view engine has loaded,
* which means the cache will be empty already.
*/
_runtimeCompilationCacheBuster.BustCache();
IEnumerable<Type> types = assembly.ExportedTypes.Where(x => x.Inherits<PublishedContentModel>() || x.Inherits<PublishedElementModel>());
_infos = RegisterModels(types);
_errors.Clear();
}
catch (Exception e)
{
try
{
_logger.LogError(e, "Failed to build models.");
_logger.LogWarning("Running without models."); // be explicit
_errors.Report("Failed to build InMemory models.", e);
}
finally
{
CurrentModelsAssembly = null;
_infos = new Infos { ModelInfos = null, ModelTypeMap = new Dictionary<string, Type>() };
}
}
// don't even try again
_hasModels = true;
}
return _infos;
}
finally
{
if (_locker.IsWriteLockHeld)
{
_locker.ExitWriteLock();
}
if (_locker.IsUpgradeableReadLockHeld)
{
_locker.ExitUpgradeableReadLock();
}
}
}
public string PureLiveDirectoryAbsolute() => _hostEnvironment.MapPathContentRoot(Core.Constants.SystemDirectories.TempData + "/InMemoryAuto");
// This is NOT thread safe but it is only called from within a lock
private Assembly ReloadAssembly(string pathToAssembly)
{
_loadContextManager.RenewAssemblyLoadContext();
// NOTE: We cannot use in-memory assemblies due to the way the razor engine works which must use
// application parts in order to add references to it's own CSharpCompiler.
// These parts must have real paths since that is how the references are loaded. In that
// case we'll need to work on temp files so that the assembly isn't locked.
// Get a temp file path
// NOTE: We cannot use Path.GetTempFileName(), see this issue:
// https://github.com/dotnet/AspNetCore.Docs/issues/3589 which can cause issues, this is recommended instead
var tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
File.Copy(pathToAssembly, tempFile, true);
// Load it in
Assembly assembly = _loadContextManager.LoadModelsAssembly(tempFile);
return assembly;
}
// This is NOT thread safe but it is only called from within a lock
private Assembly GetModelsAssembly(bool forceRebuild)
{
if (!Directory.Exists(_pureLiveDirectory.Value))
{
Directory.CreateDirectory(_pureLiveDirectory.Value);
}
IList<TypeModel> typeModels = UmbracoServices.GetAllTypes();
var currentHash = TypeModelHasher.Hash(typeModels);
var modelsHashFile = Path.Combine(_pureLiveDirectory.Value, "models.hash");
var modelsSrcFile = Path.Combine(_pureLiveDirectory.Value, "models.generated.cs");
var projFile = Path.Combine(_pureLiveDirectory.Value, "all.generated.cs");
var dllPathFile = Path.Combine(_pureLiveDirectory.Value, "all.dll.path");
// caching the generated models speeds up booting
// currentHash hashes both the types & the user's partials
if (!forceRebuild)
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Looking for cached models.");
}
if (File.Exists(modelsHashFile) && File.Exists(projFile))
{
var cachedHash = File.ReadAllText(modelsHashFile);
if (currentHash != cachedHash)
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Found obsolete cached models.");
}
forceRebuild = true;
}
// else cachedHash matches currentHash, we can try to load an existing dll
}
else
{
_logger.LogDebug("Could not find cached models.");
forceRebuild = true;
}
}
Assembly assembly;
if (!forceRebuild)
{
// try to load the dll directly (avoid rebuilding)
//
// ensure that the .dll file does not have a corresponding .dll.delete file
// as that would mean the the .dll file is going to be deleted and should not
// be re-used - that should not happen in theory, but better be safe
if (File.Exists(dllPathFile))
{
var dllPath = File.ReadAllText(dllPathFile);
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Cached models dll at {dllPath}.",dllPath);
}
if (File.Exists(dllPath) && !File.Exists(dllPath + ".delete"))
{
assembly = ReloadAssembly(dllPath);
ModelsBuilderAssemblyAttribute? attr = assembly.GetCustomAttribute<ModelsBuilderAssemblyAttribute>();
if (attr != null && attr.IsInMemory && attr.SourceHash == currentHash)
{
// if we were to resume at that revision, then _ver would keep increasing
// and that is probably a bad idea - so, we'll always rebuild starting at
// ver 1, but we remember we want to skip that one - so we never end up
// with the "same but different" version of the assembly in memory
_skipver = assembly.GetName().Version?.Revision;
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Loading cached models (dll).");
}
return assembly;
}
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Cached models dll cannot be loaded (invalid assembly).");
}
}
else if (!File.Exists(dllPath))
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Cached models dll does not exist.");
}
}
else if (File.Exists(dllPath + ".delete"))
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Cached models dll is marked for deletion.");
}
}
else
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Cached models dll cannot be loaded (why?).");
}
}
}
// must reset the version in the file else it would keep growing
// loading cached modules only happens when the app restarts
var text = File.ReadAllText(projFile);
Match match = s_assemblyVersionRegex.Match(text);
if (match.Success)
{
text = text.Replace(match.Value, "AssemblyVersion(\"0.0.0." + _ver + "\")");
File.WriteAllText(projFile, text);
}
_ver++;
try
{
var assemblyPath = GetOutputAssemblyPath(currentHash);
RoslynCompiler.CompileToFile(projFile, assemblyPath);
assembly = ReloadAssembly(assemblyPath);
File.WriteAllText(dllPathFile, assembly.Location);
File.WriteAllText(modelsHashFile, currentHash);
TryDeleteUnusedAssemblies(dllPathFile);
}
catch
{
ClearOnFailingToCompile(dllPathFile, modelsHashFile, projFile);
throw;
}
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Loading cached models (source).");
}
return assembly;
}
// need to rebuild
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Rebuilding models.");
}
// generate code, save
var code = GenerateModelsCode(typeModels);
// add extra attributes,
// IsLive=true helps identifying Assemblies that contain live models
// AssemblyVersion is so that we have a different version for each rebuild
var ver = _ver == _skipver ? ++_ver : _ver;
_ver++;
string mbAssemblyDirective = $@"[assembly:ModelsBuilderAssembly(IsInMemory = true, SourceHash = ""{currentHash}"")]
[assembly:System.Reflection.AssemblyVersion(""0.0.0.{ver}"")]";
code = code.Replace("//ASSATTR", mbAssemblyDirective);
File.WriteAllText(modelsSrcFile, code);
// generate proj, save
var projFiles = new Dictionary<string, string>
{
{ "models.generated.cs", code },
};
var proj = GenerateModelsProj(projFiles);
File.WriteAllText(projFile, proj);
// compile and register
try
{
var assemblyPath = GetOutputAssemblyPath(currentHash);
RoslynCompiler.CompileToFile(projFile, assemblyPath);
assembly = ReloadAssembly(assemblyPath);
File.WriteAllText(dllPathFile, assemblyPath);
File.WriteAllText(modelsHashFile, currentHash);
TryDeleteUnusedAssemblies(dllPathFile);
}
catch
{
ClearOnFailingToCompile(dllPathFile, modelsHashFile, projFile);
throw;
}
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Done rebuilding.");
}
return assembly;
}
private static void TryDeleteUnusedAssemblies(string dllPathFile)
{
if (File.Exists(dllPathFile))
{
var dllPath = File.ReadAllText(dllPathFile);
DirectoryInfo? dirInfo = new DirectoryInfo(dllPath).Parent;
IEnumerable<FileInfo>? files = dirInfo?.GetFiles().Where(f => f.FullName != dllPath);
if (files is null)
{
return;
}
foreach (FileInfo file in files)
{
try
{
File.Delete(file.FullName);
}
catch (UnauthorizedAccessException)
{
// The file is in use, we'll try again next time...
// This shouldn't happen anymore.
}
}
}
}
private string GetOutputAssemblyPath(string currentHash)
{
var dirInfo = new DirectoryInfo(Path.Combine(_pureLiveDirectory.Value, "Compiled"));
if (!dirInfo.Exists)
{
Directory.CreateDirectory(dirInfo.FullName);
}
return Path.Combine(dirInfo.FullName, $"generated.cs{currentHash}.dll");
}
private void ClearOnFailingToCompile(string dllPathFile, string modelsHashFile, string projFile)
{
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
{
_logger.LogDebug("Failed to compile.");
}
// the dll file reference still points to the previous dll, which is obsolete
// now and will be deleted by ASP.NET eventually, so better clear that reference.
// also touch the proj file to force views to recompile - don't delete as it's
// useful to have the source around for debugging.
try
{
if (File.Exists(dllPathFile))
{
File.Delete(dllPathFile);
}
if (File.Exists(modelsHashFile))
{
File.Delete(modelsHashFile);
}
if (File.Exists(projFile))
{
File.SetLastWriteTime(projFile, DateTime.Now);
}
}
catch
{ /* enough */
}
}
private static Infos RegisterModels(IEnumerable<Type> types)
{
Type[] ctorArgTypes = new[] { typeof(IPublishedElement), typeof(IPublishedValueFallback) };
var modelInfos = new Dictionary<string, ModelInfo>(StringComparer.InvariantCultureIgnoreCase);
var map = new Dictionary<string, Type>();
foreach (Type type in types)
{
ConstructorInfo? constructor = null;
Type? parameterType = null;
foreach (ConstructorInfo ctor in type.GetConstructors())
{
ParameterInfo[] parms = ctor.GetParameters();
if (parms.Length == 2 && typeof(IPublishedElement).IsAssignableFrom(parms[0].ParameterType) && typeof(IPublishedValueFallback).IsAssignableFrom(parms[1].ParameterType))
{
if (constructor != null)
{
throw new InvalidOperationException($"Type {type.FullName} has more than one public constructor with one argument of type, or implementing, IPropertySet.");
}
constructor = ctor;
parameterType = parms[0].ParameterType;
}
}
if (constructor == null)
{
throw new InvalidOperationException($"Type {type.FullName} is missing a public constructor with one argument of type, or implementing, IPropertySet.");
}
PublishedModelAttribute? attribute = type.GetCustomAttribute<PublishedModelAttribute>(false);
var typeName = attribute == null ? type.Name : attribute.ContentTypeAlias;
if (modelInfos.TryGetValue(typeName, out var modelInfo))
{
throw new InvalidOperationException($"Both types {type.FullName} and {modelInfo.ModelType?.FullName} want to be a model type for content type with alias \"{typeName}\".");
}
// TODO: use Core's ReflectionUtilities.EmitCtor !!
// Yes .. DynamicMethod is uber slow
// TODO: But perhaps https://docs.microsoft.com/en-us/dotnet/api/system.reflection.emit.constructorbuilder?view=netcore-3.1 is better still?
// See CtorInvokeBenchmarks
var meth = new DynamicMethod(string.Empty, typeof(IPublishedElement), ctorArgTypes, type.Module, true);
ILGenerator gen = meth.GetILGenerator();
gen.Emit(OpCodes.Ldarg_0);
gen.Emit(OpCodes.Ldarg_1);
gen.Emit(OpCodes.Newobj, constructor);
gen.Emit(OpCodes.Ret);
var func = (Func<IPublishedElement, IPublishedValueFallback, IPublishedElement>)meth.CreateDelegate(typeof(Func<IPublishedElement, IPublishedValueFallback, IPublishedElement>));
modelInfos[typeName] = new ModelInfo { ParameterType = parameterType, Ctor = func, ModelType = type };
map[typeName] = type;
}
return new Infos { ModelInfos = modelInfos.Count > 0 ? modelInfos : null, ModelTypeMap = map };
}
private string GenerateModelsCode(IList<TypeModel> typeModels)
{
if (!Directory.Exists(_pureLiveDirectory.Value))
{
Directory.CreateDirectory(_pureLiveDirectory.Value);
}
foreach (var file in Directory.GetFiles(_pureLiveDirectory.Value, "*.generated.cs"))
{
File.Delete(file);
}
var builder = new TextBuilder(_config, typeModels);
var codeBuilder = new StringBuilder();
builder.Generate(codeBuilder, builder.GetModelsToGenerate());
var code = codeBuilder.ToString();
return code;
}
private static string GenerateModelsProj(IDictionary<string, string> files)
{
// ideally we would generate a CSPROJ file but then we'd need a BuildProvider for csproj
// trying to keep things simple for the time being, just write everything to one big file
// group all 'using' at the top of the file (else fails)
var usings = new List<string>();
foreach (string k in files.Keys.ToList())
{
files[k] = s_usingRegex.Replace(files[k], m =>
{
usings.Add(m.Groups[1].Value);
return string.Empty;
});
}
// group all '[assembly:...]' at the top of the file (else fails)
var aattrs = new List<string>();
foreach (string k in files.Keys.ToList())
{
files[k] = s_aattrRegex.Replace(files[k], m =>
{
aattrs.Add(m.Groups[1].Value);
return string.Empty;
});
}
var text = new StringBuilder();
foreach (var u in usings.Distinct())
{
text.Append("using ");
text.Append(u);
text.Append(";\r\n");
}
foreach (var a in aattrs)
{
text.Append("[assembly:");
text.Append(a);
text.Append("]\r\n");
}
text.Append("\r\n\r\n");
foreach (KeyValuePair<string, string> f in files)
{
text.Append("// FILE: ");
text.Append(f.Key);
text.Append("\r\n\r\n");
text.Append(f.Value);
text.Append("\r\n\r\n\r\n");
}
text.Append("// EOF\r\n");
return text.ToString();
}
private void WatcherOnChanged(object sender, FileSystemEventArgs args)
{
var changed = args.Name;
// don't reset when our files change because we are building!
//
// comment it out, and always ignore our files, because it seems that some
// race conditions can occur on slow Cloud filesystems and then we keep
// rebuilding
// if (_building && OurFiles.Contains(changed))
// {
// //_logger.LogInformation<InMemoryModelFactory>("Ignoring files self-changes.");
// return;
// }
// always ignore our own file changes
if (changed != null && s_ourFiles.Contains(changed))
{
return;
}
_logger.LogInformation("Detected files changes.");
// don't reset while being locked
lock (SyncRoot)
{
ResetModels();
}
}
public void Stop(bool immediate)
{
Dispose();
_hostingLifetime.UnregisterObject(this);
}
private void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
if (_watcher is not null)
{
_watcher.EnableRaisingEvents = false;
_watcher.Dispose();
}
_locker.Dispose();
}
_disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
}
internal sealed class Infos
{
public Dictionary<string, Type>? ModelTypeMap { get; set; }
public Dictionary<string, ModelInfo>? ModelInfos { get; set; }
}
internal sealed class ModelInfo
{
public Type? ParameterType { get; set; }
public Func<IPublishedElement, IPublishedValueFallback, IPublishedElement>? Ctor { get; set; }
public Type? ModelType { get; set; }
public Func<IList>? ListCtor { get; set; }
}
}
}

View File

@@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Infrastructure.Runtime;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
/// <summary>
/// Validates that the ModelsBuilder mode is not set to InMemoryAuto when in development runtime mode.
/// </summary>
public class InMemoryModelsBuilderModeValidator : IRuntimeModeValidator
{
private readonly IOptionsMonitor<ModelsBuilderSettings> _modelsBuilderSettings;
public InMemoryModelsBuilderModeValidator(IOptionsMonitor<ModelsBuilderSettings> modelsBuilderSettings)
{
_modelsBuilderSettings = modelsBuilderSettings;
}
public bool Validate(RuntimeMode runtimeMode, [NotNullWhen(false)] out string? validationErrorMessage)
{
if (runtimeMode != RuntimeMode.BackofficeDevelopment &&
_modelsBuilderSettings.CurrentValue.ModelsMode == ModelsModeConstants.InMemoryAuto)
{
validationErrorMessage = "ModelsBuilder mode cannot be set to InMemoryAuto in development mode.";
return false;
}
validationErrorMessage = null;
return true;
}
}

View File

@@ -0,0 +1,20 @@
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
/// <summary>
/// Indicates that an Assembly is a Models Builder assembly.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly /*, AllowMultiple = false, Inherited = false*/)]
public sealed class ModelsBuilderAssemblyAttribute : Attribute
{
/// <summary>
/// Gets or sets a value indicating whether the assembly is a InMemory assembly.
/// </summary>
/// <remarks>A Models Builder assembly can be either InMemory or a normal Dll.</remarks>
public bool IsInMemory { get; set; }
/// <summary>
/// Gets or sets a hash value representing the state of the custom source code files
/// and the Umbraco content types that were used to generate and compile the assembly.
/// </summary>
public string? SourceHash { get; set; }
}

View File

@@ -0,0 +1,64 @@
using System.Reflection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class ModelsBuilderBindingErrorHandler : INotificationHandler<ModelBindingErrorNotification>
{
/// <summary>
/// Handles when a model binding error occurs.
/// </summary>
public void Handle(ModelBindingErrorNotification notification)
{
ModelsBuilderAssemblyAttribute? sourceAttr =
notification.SourceType.Assembly.GetCustomAttribute<ModelsBuilderAssemblyAttribute>();
ModelsBuilderAssemblyAttribute? modelAttr =
notification.ModelType.Assembly.GetCustomAttribute<ModelsBuilderAssemblyAttribute>();
// if source or model is not a ModelsBuider type...
if (sourceAttr == null || modelAttr == null)
{
// if neither are ModelsBuilder types, give up entirely
if (sourceAttr == null && modelAttr == null)
{
return;
}
// else report, but better not restart (loops?)
notification.Message.Append(" The ");
notification.Message.Append(sourceAttr == null ? "view model" : "source");
notification.Message.Append(" is a ModelsBuilder type, but the ");
notification.Message.Append(sourceAttr != null ? "view model" : "source");
notification.Message.Append(" is not. The application is in an unstable state and should be restarted.");
return;
}
// both are ModelsBuilder types
var pureSource = sourceAttr.IsInMemory;
var pureModel = modelAttr.IsInMemory;
if (sourceAttr.IsInMemory || modelAttr.IsInMemory)
{
if (pureSource == false || pureModel == false)
{
// only one is pure - report, but better not restart (loops?)
notification.Message.Append(pureSource
? " The content model is in memory generated, but the view model is not."
: " The view model is in memory generated, but the content model is not.");
notification.Message.Append(" The application is in an unstable state and should be restarted.");
}
else
{
// both are pure - report, and if different versions, restart
// if same version... makes no sense... and better not restart (loops?)
Version? sourceVersion = notification.SourceType.Assembly.GetName().Version;
Version? modelVersion = notification.ModelType.Assembly.GetName().Version;
notification.Message.Append(" Both view and content models are in memory generated, with ");
notification.Message.Append(sourceVersion == modelVersion
? "same version. The application is in an unstable state and should be restarted."
: "different versions. The application is in an unstable state and should be restarted.");
}
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
public class ModelsModeConstants
{
public const string InMemoryAuto = "InMemoryAuto";
}

View File

@@ -0,0 +1,85 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.DependencyModel;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
public class RoslynCompiler
{
public const string GeneratedAssemblyName = "ModelsGeneratedAssembly";
private readonly OutputKind _outputKind;
private readonly CSharpParseOptions _parseOptions;
private readonly IEnumerable<MetadataReference> _refs;
/// <summary>
/// Initializes a new instance of the <see cref="RoslynCompiler" /> class.
/// </summary>
/// <remarks>
/// Roslyn compiler which can be used to compile a c# file to a Dll assembly
/// </remarks>
public RoslynCompiler()
{
_outputKind = OutputKind.DynamicallyLinkedLibrary;
_parseOptions =
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion
.Latest); // What languageversion should we default to?
// In order to dynamically compile the assembly, we need to add all refs from our current
// application. This will also add the correct framework dependencies and we won't have to worry
// about the specific framework that is currently being run.
// This was borrowed from: https://github.com/dotnet/core/issues/2082#issuecomment-442713181
// because we were running into the same error as that thread because we were either:
// - not adding enough of the runtime dependencies OR
// - we were explicitly adding the wrong runtime dependencies
// ... at least that the gist of what I can tell.
if (DependencyContext.Default != null)
{
MetadataReference[] refs =
DependencyContext.Default.CompileLibraries
.SelectMany(cl => cl.ResolveReferencePaths())
.Select(asm => MetadataReference.CreateFromFile(asm))
.ToArray();
_refs = refs.ToList();
}
else
{
_refs = Enumerable.Empty<MetadataReference>();
}
}
/// <summary>
/// Compile a source file to a dll
/// </summary>
/// <param name="pathToSourceFile">Path to the source file containing the code to be compiled.</param>
/// <param name="savePath">The path where the output assembly will be saved.</param>
public void CompileToFile(string pathToSourceFile, string savePath)
{
var sourceCode = File.ReadAllText(pathToSourceFile);
var sourceText = SourceText.From(sourceCode);
SyntaxTree syntaxTree = SyntaxFactory.ParseSyntaxTree(sourceText, _parseOptions);
// Not entirely certain that assemblyIdentityComparer is nececary?
var compilation = CSharpCompilation.Create(
GeneratedAssemblyName,
new[] { syntaxTree },
_refs,
new CSharpCompilationOptions(
_outputKind,
optimizationLevel: OptimizationLevel.Release,
assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default));
EmitResult emitResult = compilation.Emit(savePath);
if (!emitResult.Success)
{
throw new InvalidOperationException("Roslyn compiler could not create ModelsBuilder dll:\n" +
string.Join("\n", emitResult.Diagnostics.Select(x => x.GetMessage())));
}
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class RuntimeCompilationCacheBuster
{
private readonly IViewCompilerProvider _viewCompilerProvider;
private readonly IRazorViewEngine _razorViewEngine;
public RuntimeCompilationCacheBuster(
IViewCompilerProvider viewCompilerProvider,
IRazorViewEngine razorViewEngine)
{
_viewCompilerProvider = viewCompilerProvider;
_razorViewEngine = razorViewEngine;
}
private RazorViewEngine ViewEngine
{
get
{
if (_razorViewEngine is RazorViewEngine typedViewEngine)
{
return typedViewEngine;
}
throw new InvalidOperationException(
"Unable to resolve RazorViewEngine, this means we can't clear the cache and views won't render properly");
}
}
private CollectibleRuntimeViewCompiler ViewCompiler
{
get
{
if (_viewCompilerProvider.GetCompiler() is CollectibleRuntimeViewCompiler collectibleCompiler)
{
return collectibleCompiler;
}
throw new InvalidOperationException("Unable to resolve CollectibleRuntimeViewCompiler, and is unable to clear the cache");
}
}
public void BustCache()
{
ViewCompiler.ClearCache();
Action<RazorViewEngine>? clearCacheMethod = ReflectionUtilities.EmitMethod<Action<RazorViewEngine>>("ClearCache");
clearCacheMethod?.Invoke(ViewEngine);
}
}

View File

@@ -0,0 +1,22 @@
using System.Reflection;
using System.Runtime.Loader;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class UmbracoAssemblyLoadContext : AssemblyLoadContext
{
/// <summary>
/// Initializes a new instance of the <see cref="UmbracoAssemblyLoadContext" /> class.
/// </summary>
/// <remarks>
/// Collectible AssemblyLoadContext used to load in the compiled generated models.
/// Must be a collectible assembly in order to be able to be unloaded.
/// </remarks>
public UmbracoAssemblyLoadContext()
: base(true)
{
}
// we never load anything directly by assembly name. This method will never be called
protected override Assembly? Load(AssemblyName assemblyName) => null;
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Diagnostics;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class UmbracoCompilationException : Exception, ICompilationException
{
public IEnumerable<CompilationFailure?>? CompilationFailures { get; init; }
}

View File

@@ -0,0 +1,89 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Reflection.PortableExecutable;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
/*
* This is a clone'n'own of Microsofts RazorReferenceManager, please check if there's been any updates to the file on their end
* before trying to mock about fixing issues:
* https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Razor.RuntimeCompilation/src/RazorReferenceManager.cs
*
* This class is used by the the ViewCompiler (CollectibleRuntimeViewCompiler) to find the required references when compiling views.
*/
internal sealed class UmbracoRazorReferenceManager
{
private readonly ApplicationPartManager _partManager;
private readonly MvcRazorRuntimeCompilationOptions _options;
private object _compilationReferencesLock = new object();
private bool _compilationReferencesInitialized;
private IReadOnlyList<MetadataReference>? _compilationReferences;
public UmbracoRazorReferenceManager(
ApplicationPartManager partManager,
IOptions<MvcRazorRuntimeCompilationOptions> options)
{
_partManager = partManager;
_options = options.Value;
}
public IReadOnlyList<MetadataReference> CompilationReferences
{
get
{
return LazyInitializer.EnsureInitialized(
ref _compilationReferences,
ref _compilationReferencesInitialized,
ref _compilationReferencesLock,
GetCompilationReferences)!;
}
}
private IReadOnlyList<MetadataReference> GetCompilationReferences()
{
var referencePaths = GetReferencePaths();
return referencePaths
.Select(CreateMetadataReference)
.ToList();
}
// For unit testing
internal IEnumerable<string> GetReferencePaths()
{
var referencePaths = new List<string>(_options.AdditionalReferencePaths.Count);
foreach (var part in _partManager.ApplicationParts)
{
if (part is ICompilationReferencesProvider compilationReferenceProvider)
{
referencePaths.AddRange(compilationReferenceProvider.GetReferencePaths());
}
else if (part is AssemblyPart assemblyPart)
{
referencePaths.AddRange(assemblyPart.GetReferencePaths());
}
}
referencePaths.AddRange(_options.AdditionalReferencePaths);
return referencePaths;
}
private static MetadataReference CreateMetadataReference(string path)
{
using (var stream = File.OpenRead(path))
{
var moduleMetadata = ModuleMetadata.CreateFromStream(stream, PEStreamOptions.PrefetchMetadata);
var assemblyMetadata = AssemblyMetadata.Create(moduleMetadata);
return assemblyMetadata.GetReference(filePath: path);
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Razor.Compilation;
using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Exceptions;
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class UmbracoViewCompilerProvider : IViewCompilerProvider
{
private readonly RazorProjectEngine _razorProjectEngine;
private readonly UmbracoRazorReferenceManager _umbracoRazorReferenceManager;
private readonly CompilationOptionsProvider _compilationOptionsProvider;
private readonly InMemoryAssemblyLoadContextManager _loadContextManager;
private readonly ApplicationPartManager _applicationPartManager;
private readonly ILogger<CollectibleRuntimeViewCompiler> _logger;
private readonly Func<IViewCompiler> _createCompiler;
private object _initializeLock = new object();
private bool _initialized;
private IViewCompiler? _compiler;
private readonly MvcRazorRuntimeCompilationOptions _options;
public UmbracoViewCompilerProvider(
ApplicationPartManager applicationPartManager,
RazorProjectEngine razorProjectEngine,
ILoggerFactory loggerFactory,
IOptions<MvcRazorRuntimeCompilationOptions> options,
UmbracoRazorReferenceManager umbracoRazorReferenceManager,
CompilationOptionsProvider compilationOptionsProvider,
InMemoryAssemblyLoadContextManager loadContextManager)
{
_applicationPartManager = applicationPartManager;
_razorProjectEngine = razorProjectEngine;
_umbracoRazorReferenceManager = umbracoRazorReferenceManager;
_compilationOptionsProvider = compilationOptionsProvider;
_loadContextManager = loadContextManager;
_options = options.Value;
_logger = loggerFactory.CreateLogger<CollectibleRuntimeViewCompiler>();
_createCompiler = CreateCompiler;
}
public IViewCompiler GetCompiler()
{
return LazyInitializer.EnsureInitialized(
ref _compiler,
ref _initialized,
ref _initializeLock,
_createCompiler)!;
}
private IViewCompiler CreateCompiler()
{
var feature = new ViewsFeature();
_applicationPartManager.PopulateFeature(feature);
return new CollectibleRuntimeViewCompiler(
GetCompositeFileProvider(_options),
_razorProjectEngine,
feature.ViewDescriptors,
_logger,
_umbracoRazorReferenceManager,
_compilationOptionsProvider,
_loadContextManager);
}
private static IFileProvider GetCompositeFileProvider(MvcRazorRuntimeCompilationOptions options)
{
IList<IFileProvider> fileProviders = options.FileProviders;
if (fileProviders.Count == 0)
{
throw new PanicException();
}
else if (fileProviders.Count == 1)
{
return fileProviders[0];
}
return new CompositeFileProvider(fileProviders);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Title>Umbraco CMS - DevelopmentMode - Backoffice</Title>
<Description>Adds backoffice development mode.</Description>
<RootNamespace>Umbraco.Cms.DevelopmentMode.Backoffice</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Web.Common\Umbraco.Web.Common.csproj" />
</ItemGroup>
</Project>