Is it possible to reference ASP.NET Core Razor views from separate assembly at runtime?
I know how to load controllers dynamically using IActionDescriptorChangeProvider
but cannot find a way as for views.
I'd like to create a simple plugin system and manage plugins without restart app.
You can add support for Pages to any ASP.NET Core MVC app by simply adding a Pages folder and adding Razor Pages files to this folder. Razor Pages use the folder structure as a convention for routing requests.
Razor Pages is sometimes described as implementing the MVVM (Model, View ViewModel) pattern. It doesn't. The MVVM pattern is applied to applications where the presentation and model share the same layer. It is popular in WPF, mobile application development, and some JavaScript libraries.
Razor allows you to write a mix of HTML and server-side code using C# or Visual Basic. Razor view with visual basic syntax has . vbhtml file extension and C# syntax has . cshtml file extension.
I am creating a dynamic and fully modular (plugin-based) application in which the user can drop a plugin assembly at run time in a file watched directory to add controllers and compiled views.
I ran in the same issues than you. At first, both controllers and views were not being 'detected' by MVC, even though I had correctly added the assemblies through the ApplicationPartManager service.
I solved the controllers issue which, as you said, can be handled with the IActionDescriptorChangeProvider.
For the views issue, though, it seemed there was no similar mechanism built-in. I crawled google for hours, found your post (and many others), but none were answered. I almost gave up. Almost.
I started crawling the ASP.NET Core sources and implemented all services I thought were related to finding the compiled views. A good part of my evening was gone pulling my hairs, and then... EUREKA.
I found that the service responsible for supplying those compiled views was the default IViewCompiler (aka DefaultViewCompiler), which was in turn provided by the IViewCompilerProvider (aka DefaultViewCompilerProvider).
You actually need to implement both those to get it working as expected.
The IViewCompilerProvider:
public class ModuleViewCompilerProvider
: IViewCompilerProvider
{
public ModuleViewCompilerProvider(ApplicationPartManager applicationPartManager, ILoggerFactory loggerFactory)
{
this.Compiler = new ModuleViewCompiler(applicationPartManager, loggerFactory);
}
protected IViewCompiler Compiler { get; }
public IViewCompiler GetCompiler()
{
return this.Compiler;
}
}
The IViewCompiler:
public class ModuleViewCompiler
: IViewCompiler
{
public static ModuleViewCompiler Current;
public ModuleViewCompiler(ApplicationPartManager applicationPartManager, ILoggerFactory loggerFactory)
{
this.ApplicationPartManager = applicationPartManager;
this.Logger = loggerFactory.CreateLogger<ModuleViewCompiler>();
this.CancellationTokenSources = new Dictionary<string, CancellationTokenSource>();
this.NormalizedPathCache = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
this.PopulateCompiledViews();
ModuleViewCompiler.Current = this;
}
protected ApplicationPartManager ApplicationPartManager { get; }
protected ILogger Logger { get; }
protected Dictionary<string, CancellationTokenSource> CancellationTokenSources { get; }
protected ConcurrentDictionary<string, string> NormalizedPathCache { get; }
protected Dictionary<string, CompiledViewDescriptor> CompiledViews { get; private set; }
public void LoadModuleCompiledViews(Assembly moduleAssembly)
{
if (moduleAssembly == null)
throw new ArgumentNullException(nameof(moduleAssembly));
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
this.CancellationTokenSources.Add(moduleAssembly.FullName, cancellationTokenSource);
ViewsFeature feature = new ViewsFeature();
this.ApplicationPartManager.PopulateFeature(feature);
foreach(CompiledViewDescriptor compiledView in feature.ViewDescriptors
.Where(v => v.Type.Assembly == moduleAssembly))
{
if (!this.CompiledViews.ContainsKey(compiledView.RelativePath))
{
compiledView.ExpirationTokens = new List<IChangeToken>() { new CancellationChangeToken(cancellationTokenSource.Token) };
this.CompiledViews.Add(compiledView.RelativePath, compiledView);
}
}
}
public void UnloadModuleCompiledViews(Assembly moduleAssembly)
{
if (moduleAssembly == null)
throw new ArgumentNullException(nameof(moduleAssembly));
foreach (KeyValuePair<string, CompiledViewDescriptor> entry in this.CompiledViews
.Where(kvp => kvp.Value.Type.Assembly == moduleAssembly))
{
this.CompiledViews.Remove(entry.Key);
}
if (this.CancellationTokenSources.TryGetValue(moduleAssembly.FullName, out CancellationTokenSource cancellationTokenSource))
{
cancellationTokenSource.Cancel();
this.CancellationTokenSources.Remove(moduleAssembly.FullName);
}
}
private void PopulateCompiledViews()
{
ViewsFeature feature = new ViewsFeature();
this.ApplicationPartManager.PopulateFeature(feature);
this.CompiledViews = new Dictionary<string, CompiledViewDescriptor>(feature.ViewDescriptors.Count, StringComparer.OrdinalIgnoreCase);
foreach (CompiledViewDescriptor compiledView in feature.ViewDescriptors)
{
if (this.CompiledViews.ContainsKey(compiledView.RelativePath))
continue;
this.CompiledViews.Add(compiledView.RelativePath, compiledView);
};
}
public async Task<CompiledViewDescriptor> CompileAsync(string relativePath)
{
if (relativePath == null)
throw new ArgumentNullException(nameof(relativePath));
if (this.CompiledViews.TryGetValue(relativePath, out CompiledViewDescriptor cachedResult))
return cachedResult;
string normalizedPath = this.GetNormalizedPath(relativePath);
if (this.CompiledViews.TryGetValue(normalizedPath, out cachedResult))
return cachedResult;
return await Task.FromResult(new CompiledViewDescriptor()
{
RelativePath = normalizedPath,
ExpirationTokens = Array.Empty<IChangeToken>(),
});
}
protected string GetNormalizedPath(string relativePath)
{
if (relativePath.Length == 0)
return relativePath;
if (!this.NormalizedPathCache.TryGetValue(relativePath, out var normalizedPath))
{
normalizedPath = this.NormalizePath(relativePath);
this.NormalizedPathCache[relativePath] = normalizedPath;
}
return normalizedPath;
}
protected string NormalizePath(string path)
{
bool addLeadingSlash = path[0] != '\\' && path[0] != '/';
bool transformSlashes = path.IndexOf('\\') != -1;
if (!addLeadingSlash && !transformSlashes)
return path;
int length = path.Length;
if (addLeadingSlash)
length++;
return string.Create(length, (path, addLeadingSlash), (span, tuple) =>
{
var (pathValue, addLeadingSlashValue) = tuple;
int spanIndex = 0;
if (addLeadingSlashValue)
span[spanIndex++] = '/';
foreach (var ch in pathValue)
{
span[spanIndex++] = ch == '\\' ? '/' : ch;
}
});
}
}
Now, you need to find the existing IViewCompilerProvider descriptor, and replace it with your own, as follows:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
ServiceDescriptor descriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IViewCompilerProvider));
services.Remove(descriptor);
services.AddSingleton<IViewCompilerProvider, ModuleViewCompilerProvider>();
}
Then, upon loading a compiled view plugin assembly, just make the following call:
ModuleViewCompiler.Current.LoadModuleCompiledViews(compiledViewsAssembly);
Upon unloading a compiled view plugin assembly, make that call:
ModuleViewCompiler.Current.UnloadModuleCompiledViews(compiledViewsAssembly);
That will cancel and get rid of the IChangeToken that we have associated with the compiled views loaded with our plugin assembly. This is very important if you intend to load, unload then reload a specific plugin assembly at runtime, because otherwise MVC will keep track of it, possibly forbidding the unloading of your AssemblyLoadContext, and will throw error upon compilation because of model types mismatch (model x from assembly z loaded at time T is considered different than model x from assembly z loaded at time T+1)
Hope that helps ;)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With