從零開始實現ASP.NET Core MVC的插件式開發(九) - 升級.NET 5及啓用預編譯視圖

標題:從零開始實現ASP.NET Core MVC的插件式開發(九) - 升級.NET 5及啓用預編譯視圖
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/13992077.html
源代碼:https://github.com/lamondlu/Mystique
適用版本:.NET Core 3.1, .NET 5

前景回顧

簡介

在這個項目創建的時候,項目的初衷是使用預編譯視圖來呈現界面,但是由於多次嘗試失敗,最後改用了運行時編譯視圖,這種方式在第一次加載的時候非常的慢,所有的插件視圖都要在運行時編譯,而且從便攜性上來說,預編譯視圖更好。近日,在幾位同道的共同努力下,終於實現了這種加載方式。


此篇要鳴謝網友 j4587698yang-er 對針對當前項目的支持,你們的思路幫我解決了當前項目針對不能啓用預編譯視圖的2個主要的問題

  • 在當前項目目錄結構下,啓動時加載組件,組件預編譯視圖不能正常使用
  • 運行時加載組件之後,組件中的預編譯視圖不能正常使用

升級.NET 5

隨着.NET 5的發佈,當前項目也升級到了.NET 5版本。

整個升級的過程比我預想的簡單的多,只是修改了一下項目使用的Target fremework。重新編譯打包了一下插件程序,項目就可以正常運行了,整個過程中沒有產生任何因爲版本升級導致的編譯問題。

預編譯視圖不能使用的問題

在升級了.NET 5之後,我重新嘗試在啓動時關閉了運行時編譯,加載預編譯視圖View, 藉此測試.NET 5對預編譯視圖的支持情況。

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
    {
        ...

        IMvcBuilder mvcBuilder = services.AddMvc();

        ServiceProvider provider = services.BuildServiceProvider();
        using (IServiceScope scope = provider.CreateScope())
        {
            ...

                foreach (ViewModels.PluginListItemViewModel plugin in allEnabledPlugins)
                {
                    CollectibleAssemblyLoadContext context = new CollectibleAssemblyLoadContext(plugin.Name);
                    string moduleName = plugin.Name;

                    string filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", moduleName, $"{moduleName}.dll");
                    string viewFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", moduleName, $"{moduleName}.Views.dll");
                    string referenceFolderPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", moduleName);

                    _presets.Add(filePath);
                    using (FileStream fs = new FileStream(filePath, FileMode.Open))
                    {
                        Assembly assembly = context.LoadFromStream(fs);
                        context.SetEntryPoint(assembly);

                        loader.LoadStreamsIntoContext(context, referenceFolderPath, assembly);

                        MystiqueAssemblyPart controllerAssemblyPart = new MystiqueAssemblyPart(assembly);
                        mvcBuilder.PartManager.ApplicationParts.Add(controllerAssemblyPart);
                        PluginsLoadContexts.Add(plugin.Name, context);

                        BuildNotificationProvider(assembly, scope);
                    }

                    using (FileStream fsView = new FileStream(viewFilePath, FileMode.Open))
                    {
                        Assembly viewAssembly = context.LoadFromStream(fsView);
                        loader.LoadStreamsIntoContext(context, referenceFolderPath, viewAssembly);

                        CompiledRazorAssemblyPart moduleView = new CompiledRazorAssemblyPart(viewAssembly);
                        mvcBuilder.PartManager.ApplicationParts.Add(moduleView);
                    }

                    context.Enable();
                }
            }
        }

        AssemblyLoadContextResoving();

        ...
    }

運行項目之後,你會發現項目竟然會得到一個無法找到視圖的錯誤。

這裏的結果很奇怪,因爲參考第一章的場景,ASP.NET Core默認是支持啓動時加載預編譯視圖的。在第一章的時候,我們創建了1個組件,在啓動時,直接加載到主AssemblyLoadContext中,啓動之後,我們是可以正常訪問到視圖的。

在仔細思考之後,我想到的兩種可能性。

  • 一種可能是因爲我們的組件加載在獨立的AssemblyLoadContext中,而非主AssemblyLoadContext中,所以可能導致加載失敗
  • 插件的目錄結構與第一章不符合,導致加載失敗

但是苦於不能調試ASP.NET Core的源碼,所以這一部分就暫時擱置了。直到前幾天,網友j4587698 在項目Issue中針對運行時編譯提出的方案給我的調試思路。

在ASP.NET Core中,默認的視圖的編譯和加載使用了2個內部類DefaultViewCompilerProviderDefaultViewCompiler。但是由於這2個類是內部類,所以沒有辦法繼承並重寫,更談不上調試了。

j4587698的思路和我不同,他的做法是,在當前主項目中,直接複製DefaultViewCompilerProviderDefaultViewCompiler2個類的代碼,並將其定義爲公開類,在程序啓動時,替換默認依賴注入容器中的類實現,使用公開的DefaultViewCompilerProvider DefaultViewCompiler類,替換ASP.NET Core默認指定的內部類。

根據他的思路,我新增了一個基於IServiceCollection的擴展類,追加了Replace方法來替換注入容器中的實現。

    public static class ServiceCollectionExtensions
    {
        public static IServiceCollection Replace<TService, TImplementation>(this IServiceCollection services)
            where TImplementation : TService
        {
            return services.Replace<TService>(typeof(TImplementation));
        }

        public static IServiceCollection Replace<TService>(this IServiceCollection services, Type implementationType)
        {
            return services.Replace(typeof(TService), implementationType);
        }

        public static IServiceCollection Replace(this IServiceCollection services, Type serviceType, Type implementationType)
        {
            if (services == null)
            {
                throw new ArgumentNullException(nameof(services));
            }

            if (serviceType == null)
            {
                throw new ArgumentNullException(nameof(serviceType));
            }

            if (implementationType == null)
            {
                throw new ArgumentNullException(nameof(implementationType));
            }

            if (!services.TryGetDescriptors(serviceType, out var descriptors))
            {
                throw new ArgumentException($"No services found for {serviceType.FullName}.", nameof(serviceType));
            }

            foreach (var descriptor in descriptors)
            {
                var index = services.IndexOf(descriptor);

                services.Insert(index, descriptor.WithImplementationType(implementationType));

                services.Remove(descriptor);
            }

            return services;
        }

        private static bool TryGetDescriptors(this IServiceCollection services, Type serviceType, out ICollection<ServiceDescriptor> descriptors)
        {
            return (descriptors = services.Where(service => service.ServiceType == serviceType).ToArray()).Any();
        }

        private static ServiceDescriptor WithImplementationType(this ServiceDescriptor descriptor, Type implementationType)
        {
            return new ServiceDescriptor(descriptor.ServiceType, implementationType, descriptor.Lifetime);
        }
    }

並在程序啓動時,使用公開的MyViewCompilerProvider類,替換了原始注入類DefaultViewCompilerProvider

    public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
    {
        _serviceCollection = services;

        services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
        services.AddSingleton<IMvcModuleSetup, MvcModuleSetup>();
        services.AddScoped<IPluginManager, PluginManager>();
        services.AddScoped<ISystemManager, SystemManager>();
        services.AddScoped<IUnitOfWork, Repository.MySql.UnitOfWork>();
        services.AddSingleton<INotificationRegister, NotificationRegister>();
        services.AddSingleton<IActionDescriptorChangeProvider>(MystiqueActionDescriptorChangeProvider.Instance);
        services.AddSingleton<IReferenceContainer, DefaultReferenceContainer>();
        services.AddSingleton<IReferenceLoader, DefaultReferenceLoader>();
        services.AddSingleton(MystiqueActionDescriptorChangeProvider.Instance);

        ...

        services.Replace<IViewCompilerProvider, MyViewCompilerProvider>();
    }

MyViewCompilerProvider中, 直接返回了新定義的MyViewCompiler

    public class MyViewCompilerProvider : IViewCompilerProvider
    {
        private readonly MyViewCompiler _compiler;

        public MyViewCompilerProvider(
            ApplicationPartManager applicationPartManager,
            ILoggerFactory loggerFactory)
        {
            var feature = new ViewsFeature();
            applicationPartManager.PopulateFeature(feature);

            _compiler = new MyViewCompiler(feature.ViewDescriptors, loggerFactory.CreateLogger<MyViewCompiler>());
        }

        public IViewCompiler GetCompiler() => _compiler;
    }

PS: 此處只是直接複製了ASP.NET Core源碼中DefaultViewCompilerProvider DefaultViewCompiler2個類的代碼,稍作修改,保證編譯通過。

    public class MyViewCompiler : IViewCompiler
    {
        private readonly Dictionary<string, Task<CompiledViewDescriptor>> _compiledViews;
        private readonly ConcurrentDictionary<string, string> _normalizedPathCache;
        private readonly ILogger _logger;

        public MyViewCompiler(
            IList<CompiledViewDescriptor> compiledViews,
            ILogger logger)
        {
            ...
        }

        /// <inheritdoc />
        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 (_compiledViews.TryGetValue(relativePath, out var cachedResult))
            {

                return cachedResult;
            }

            var normalizedPath = GetNormalizedPath(relativePath);
            if (_compiledViews.TryGetValue(normalizedPath, out cachedResult))
            {

                return cachedResult;
            }

            // Entry does not exist. Attempt to create one.

            return Task.FromResult(new CompiledViewDescriptor
            {
                RelativePath = normalizedPath,
                ExpirationTokens = Array.Empty<IChangeToken>(),
            });
        }

        private string GetNormalizedPath(string relativePath)
        {
            ...
        }
    }

針對DefaultViewCompiler,這裏的重點是CompileAsync方法,它會根據傳入的相對路徑,在加載的編譯視圖集合中加載視圖。下面我們在此處打上斷點,並模擬進入DemoPlugin1的主頁。

看完這個調試過程,你是不是發現了點什麼,當我們訪問DemoPlugin1的主頁路由/Modules/DemoPlugin/Plugin1/HelloWorld的時候,ASP.NET Core嘗試查找的視圖相對路徑是·

  • /Areas/DemoPlugin1/Views/Plugin1/HelloWorld.cshtml
  • /Areas/DemoPlugin1/Views/Shared/HelloWorld.cshtml
  • /Views/Shared/HelloWorld.cshtml
  • /Pages/Shared/HelloWorld.cshtml
  • /Modules/DemoPlugin1/Views/Plugin1/HelloWorld.cshtml
  • /Views/Shared/HelloWorld.cshtml

而當我們查看現在已有的編譯視圖映射是,你會發現註冊的對應視圖路徑確是/Views/Plugin1/HelloWorld.cshtml
下面我們再回過頭來看看DemoPlugin1的目錄結構

由此我們推斷出,預編譯視圖在生成的時候,會記錄當前視圖的相對路徑,而在主程序加載的插件的過程中,由於我們使用了Area來區分模塊,多出的一級目錄,所以導致目錄映射失敗了。因此如果我們將DemoPlugin1的插件視圖目錄結構改爲以上提示的6個地址之一,問題應該就解決了。

那麼這裏有沒有辦法,在不改變路徑的情況下,讓視圖正常加載呢,答案是有的。參照之前的代碼,在加載視圖組件的時候,我們使用了內置類CompiledRazorAssemblyPart, 那麼讓我們來看看它的源碼。

    public class CompiledRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider
    {
        /// <summary>
        /// Initializes a new instance of <see cref="CompiledRazorAssemblyPart"/>.
        /// </summary>
        /// <param name="assembly">The <see cref="System.Reflection.Assembly"/></param>
        public CompiledRazorAssemblyPart(Assembly assembly)
        {
            Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
        }

        /// <summary>
        /// Gets the <see cref="System.Reflection.Assembly"/>.
        /// </summary>
        public Assembly Assembly { get; }

        /// <inheritdoc />
        public override string Name => Assembly.GetName().Name;

        IEnumerable<RazorCompiledItem> IRazorCompiledItemProvider.CompiledItems
        {
            get
            {
                var loader = new RazorCompiledItemLoader();
                return loader.LoadItems(Assembly);
            }
        }
    }

這個類非常的簡單,它通過RazorCompiledItemLoader類對象從程序集中加載的視圖, 並將最終的編譯視圖都存放在一個RazorCompiledItem類的集合裏。

    public class RazorCompiledItemLoader
    {
        public virtual IReadOnlyList<RazorCompiledItem> LoadItems(Assembly assembly)
        {
            if (assembly == null)
            {
                throw new ArgumentNullException(nameof(assembly));
            }

            var items = new List<RazorCompiledItem>();
            foreach (var attribute in LoadAttributes(assembly))
            {
                items.Add(CreateItem(attribute));
            }

            return items;
        }
      
        protected virtual RazorCompiledItem CreateItem(RazorCompiledItemAttribute attribute)
        {
            if (attribute == null)
            {
                throw new ArgumentNullException(nameof(attribute));
            }

            return new DefaultRazorCompiledItem(attribute.Type, attribute.Kind, attribute.Identifier);
        }
       
        protected IEnumerable<RazorCompiledItemAttribute> LoadAttributes(Assembly assembly)
        {
            if (assembly == null)
            {
                throw new ArgumentNullException(nameof(assembly));
            }

            return assembly.GetCustomAttributes<RazorCompiledItemAttribute>();
        }
    }

這裏我們可以參考前面的調試方式,創建出一套自己的視圖加載類,代碼和當前的實現一模一樣

MystiqueModuleViewCompiledItemLoader

    public class MystiqueModuleViewCompiledItemLoader : RazorCompiledItemLoader
    {
        public MystiqueModuleViewCompiledItemLoader()
        {
        }

        protected override RazorCompiledItem CreateItem(RazorCompiledItemAttribute attribute)
        {
            if (attribute == null)
            {
                throw new ArgumentNullException(nameof(attribute));
            }

            return new MystiqueModuleViewCompiledItem(attribute);
        }

    }

MystiqueRazorAssemblyPart

    public class MystiqueRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider
    {
        public MystiqueRazorAssemblyPart(Assembly assembly)
        {
            Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
            AreaName = areaName;
        }

        public Assembly Assembly { get; }

        public override string Name => Assembly.GetName().Name;

        IEnumerable<RazorCompiledItem> IRazorCompiledItemProvider.CompiledItems
        {
            get
            {
                var loader = new MystiqueModuleViewCompiledItemLoader();
                return loader.LoadItems(Assembly);
            }
        }
    }

MystiqueModuleViewCompiledItem

    public class MystiqueModuleViewCompiledItem : RazorCompiledItem
    {
        public override string Identifier { get; }

        public override string Kind { get; }

        public override IReadOnlyList<object> Metadata { get; }

        public override Type Type { get; }

        public MystiqueModuleViewCompiledItem(RazorCompiledItemAttribute attr, string moduleName)
        {
            Type = attr.Type;
            Kind = attr.Kind;
            Identifier = attr.Identifier;

            Metadata = Type.GetCustomAttributes(inherit: true).ToList();
        }                                                                                           
    }

這裏我們在MystiqueModuleViewCompiledItem類的構造函數部分打上斷點。

重新啓動項目之後,你會發現當加載DemoPlugin1的視圖時,這裏的Identifier屬性其實就是當前編譯試圖項的映射目錄。這樣我們很容易就想到在此處動態修改映射目錄,爲此我們需要將模塊名稱通過構造函數傳入,以上3個類的更新代碼如下:

MystiqueModuleViewCompiledItemLoader

    public class MystiqueModuleViewCompiledItemLoader : RazorCompiledItemLoader
    {
        public string ModuleName { get; }

        public MystiqueModuleViewCompiledItemLoader(string moduleName)
        {
            ModuleName = moduleName;
        }

        protected override RazorCompiledItem CreateItem(RazorCompiledItemAttribute attribute)
        {
            if (attribute == null)
            {
                throw new ArgumentNullException(nameof(attribute));
            }

            return new MystiqueModuleViewCompiledItem(attribute, ModuleName);
        }
    }

MystiqueRazorAssemblyPart

    public class MystiqueRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider
    {
        public MystiqueRazorAssemblyPart(Assembly assembly, string moduleName)
        {
            Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
            ModuleName = moduleName;
        }

        public string ModuleName { get; }

        public Assembly Assembly { get; }

        public override string Name => Assembly.GetName().Name;

        IEnumerable<RazorCompiledItem> IRazorCompiledItemProvider.CompiledItems
        {
            get
            {
                var loader = new MystiqueModuleViewCompiledItemLoader(ModuleName);
                return loader.LoadItems(Assembly);
            }
        }
    }

MystiqueModuleViewCompiledItem

    public class MystiqueModuleViewCompiledItem : RazorCompiledItem
    {
        public override string Identifier { get; }

        public override string Kind { get; }

        public override IReadOnlyList<object> Metadata { get; }

        public override Type Type { get; }

        public MystiqueModuleViewCompiledItem(RazorCompiledItemAttribute attr, string moduleName)
        {
            Type = attr.Type;
            Kind = attr.Kind;
            Identifier = "/Modules/" + moduleName + attr.Identifier;

            Metadata = Type.GetCustomAttributes(inherit: true).Select(o =>
                o is RazorSourceChecksumAttribute rsca
                    ? new RazorSourceChecksumAttribute(rsca.ChecksumAlgorithm, rsca.Checksum, "/Modules/" + moduleName + rsca.Identifier)
                    : o).ToList();
        }                                                                                        
    }

PS: 這裏有個容易疏漏的點,就是MystiqueModuleViewCompiledItem中的MetaData, 它使用了Identifier屬性的值,所以一旦Identifier屬性的值被動態修改,此處的值也要修改,否則調試會不成功。

修改完成之後,我們重啓項目,來測試一下。

編譯視圖的映射路徑動態修改成功,頁面成功被打開了,至此啓動時的預編譯視圖加載完成。

運行時加載編譯視圖

最後我們來到了運行加載編譯視圖的問題,有了之前的調試方案,現在調試起來就輕車熟路。

爲了測試,我們再運行時加載編譯視圖,我們首先禁用掉DemoPlugin1, 然後重啓項目,並啓用DemoPlugin1

通過調試,很明顯問題出在預編譯視圖的加載上,在啓用組件之後,編譯視圖映射集合沒有更新,所以導致加載失敗。這也證明了我們之前第三章時候的推斷。當使用IActionDescriptorChangeProvider重置Controller/Action映射的時候,ASP.NET Core不會更新視圖映射集合,從而導致視圖加載失敗。

    MystiqueActionDescriptorChangeProvider.Instance.HasChanged = true;
    MystiqueActionDescriptorChangeProvider.Instance.TokenSource.Cancel();

那麼解決問題的方式也就很清楚了,我們需要在使用IActionDescriptorChangeProvider重置Controller/Action映射之後,刷新視圖映射集合。爲此,我們需要修改之前定義的MyViewCompilerProvider, 添加Refresh方法來刷新映射。

    public class MyViewCompilerProvider : IViewCompilerProvider
    {
        private MyViewCompiler _compiler;
        private ApplicationPartManager _applicationPartManager;
        private ILoggerFactory _loggerFactory;

        public MyViewCompilerProvider(
            ApplicationPartManager applicationPartManager,
            ILoggerFactory loggerFactory)
        {
            _applicationPartManager = applicationPartManager;
            _loggerFactory = loggerFactory;
            Refresh();
        }

        public void Refresh()
        {
            var feature = new ViewsFeature();
            _applicationPartManager.PopulateFeature(feature);

            _compiler = new MyViewCompiler(feature.ViewDescriptors, _loggerFactory.CreateLogger<MyViewCompiler>());
        }

        public IViewCompiler GetCompiler() => _compiler;
    }

Refresh方法是藉助ViewsFeature來重新創建了一個新的IViewCompiler, 並填充了最新的視圖映射。

PS: 這裏的實現方式參考了DefaultViewCompilerProvider的實現,該類是在構造中填充的視圖映射。

根據以上修改,在使用IActionDescriptorChangeProvider重置Controller/Action映射之後, 我們使用Refresh方法來刷新映射。

    private void ResetControllActions()
    {
        MystiqueActionDescriptorChangeProvider.Instance.HasChanged = true;
        MystiqueActionDescriptorChangeProvider.Instance.TokenSource.Cancel();
        
        var provider = _context.HttpContext
    					.RequestServices
    					.GetService(typeof(IViewCompilerProvider)) as MyViewCompilerProvider;
        provider.Refresh();
    }

最後,我們重新啓動項目,再次在運行時啓用DemoPlugin1,進入插件主頁面,頁面正常顯示了。

至此運行時加載與編譯視圖的場景也順利解決了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章