How to load ASP.NET Core Razor view dynamically at

2020-04-05 09:09发布

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.

1条回答
可以哭但决不认输i
2楼-- · 2020-04-05 09:40

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 add 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 service I thought was 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 ;)

查看更多
登录 后发表回答