從零開始實現ASP.NET Core MVC的外掛式開發(九) - 升級.NET 5及啟用預編譯檢視

LamondLu發表於2020-11-17

標題:從零開始實現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,進入外掛主頁面,頁面正常顯示了。

至此執行時載入與編譯檢視的場景也順利解決了。

相關文章