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

從零開始實現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` ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054232604-1334243390.jpg) # 前景回顧 - [從零開始實現ASP.NET Core MVC的外掛式開發(一) - 使用Application Part動態載入控制器和檢視](https://www.cnblogs.com/lwqlun/p/11137788.html) - [從零開始實現ASP.NET Core MVC的外掛式開發(二) - 如何建立專案模板](https://www.cnblogs.com/lwqlun/p/11155666.html) - [從零開始實現ASP.NET Core MVC的外掛式開發(三) - 如何在執行時啟用元件](https://www.cnblogs.com/lwqlun/p/11260750.html ) - [從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝](https://www.cnblogs.com/lwqlun/p/11343141.html ) - [從零開始實現ASP.NET Core MVC的外掛式開發(五) - 使用AssemblyLoadContext實現外掛的升級和刪除](https://www.cnblogs.com/lwqlun/p/11395828.html) - [從零開始實現ASP.NET Core MVC的外掛式開發(六) - 如何載入外掛引用](https://www.cnblogs.com/lwqlun/p/11717254.html) - [從零開始實現ASP.NET Core MVC的外掛式開發(七) - 近期問題彙總及部分解決方案](https://www.cnblogs.com/lwqlun/p/12930713.html) - [從零開始實現ASP.NET Core MVC的外掛式開發(八) - Razor檢視相關問題及解決方案](https://www.cnblogs.com/lwqlun/p/13208980.html) # 簡介 在這個專案建立的時候,專案的初衷是使用預編譯檢視來呈現介面,但是由於多次嘗試失敗,最後改用了執行時編譯檢視,這種方式在第一次載入的時候非常的慢,所有的外掛檢視都要在執行時編譯,而且從便攜性上來說,預編譯檢視更好。近日,在幾位同道的共同努力下,終於實現了這種載入方式。 --------------------------------------------------------------------------------------------------------------------------------------- 此篇要鳴謝網友 [j4587698](https://github.com/j4587698) 和 [yang-er](https://github.com/yang-er) 對針對當前專案的支援,你們的思路幫我解決了當前專案針對不能啟用**預編譯檢視**的2個主要的問題 - 在當前專案目錄結構下,啟動時載入元件,元件預編譯檢視不能正常使用 - 執行時載入元件之後,元件中的預編譯檢視不能正常使用 # 升級.NET 5 隨著.NET 5的釋出,當前專案也升級到了.NET 5版本。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054305055-1097465901.png) 整個升級的過程比我預想的簡單的多,只是修改了一下專案使用的`Target fremework`。重新編譯打包了一下外掛程式,專案就可以正常運行了,整個過程中沒有產生任何因為版本升級導致的編譯問題。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054317619-842189531.png) # 預編譯檢視不能使用的問題 在升級了.NET 5之後,我重新嘗試在啟動時關閉了執行時編譯,載入預編譯檢視View, 藉此測試.NET 5對預編譯檢視的支援情況。 ```c# 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(); ... } ``` 執行專案之後,你會發現專案竟然會得到一個無法找到檢視的錯誤。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054353255-1065032420.gif) 這裡的結果很奇怪,因為參考[第一章](https://www.cnblogs.com/lwqlun/p/11137788.html)的場景,ASP.NET Core預設是支援啟動時載入預編譯檢視的。在[第一章](https://www.cnblogs.com/lwqlun/p/11137788.html)的時候,我們建立了1個元件,在啟動時,直接載入到主`AssemblyLoadContext`中,啟動之後,我們是可以正常訪問到檢視的。 在仔細思考之後,我想到的兩種可能性。 - 一種可能是因為我們的元件載入在獨立的`AssemblyLoadContext`中,而非主`AssemblyLoadContext`中,所以可能導致載入失敗 - 外掛的目錄結構與[第一章](https://www.cnblogs.com/lwqlun/p/11137788.html#4310745)不符合,導致載入失敗 但是苦於不能除錯ASP.NET Core的原始碼,所以這一部分就暫時擱置了。直到前幾天,網友[j4587698](https://github.com/j4587698) 在專案Issue中針對執行時編譯提出的方案給我的除錯思路。 在ASP.NET Core中,預設的檢視的編譯和載入使用了2個內部類`DefaultViewCompilerProvider`和`DefaultViewCompiler`。但是由於這2個類是內部類,所以沒有辦法繼承並重寫,更談不上除錯了。 `j4587698`的思路和我不同,他的做法是,在當前主專案中,直接複製`DefaultViewCompilerProvider`和`DefaultViewCompiler`2個類的程式碼,並將其定義為公開類,在程式啟動時,替換預設依賴注入容器中的類實現,使用公開的`DefaultViewCompilerProvider `和`DefaultViewCompiler`類,替換ASP.NET Core預設指定的內部類。 根據他的思路,我新增了一個基於`IServiceCollection`的擴充套件類,追加了`Replace`方法來替換注入容器中的實現。 ``` public static class ServiceCollectionExtensions { public static IServiceCollection Replace(this IServiceCollection services) where TImplementation : TService { return services.Replace(typeof(TImplementation)); } public static IServiceCollection Replace(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 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(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(MystiqueActionDescriptorChangeProvider.Instance); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(MystiqueActionDescriptorChangeProvider.Instance); ... services.Replace(); } ``` 在`MyViewCompilerProvider`中, 直接返回了新定義的`MyViewCompiler` ```c# 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()); } public IViewCompiler GetCompiler() => _compiler; } ``` > PS: 此處只是直接複製了ASP.NET Core原始碼中`DefaultViewCompilerProvider `和`DefaultViewCompiler`2個類的程式碼,稍作修改,保證編譯通過。 ```c# public class MyViewCompiler : IViewCompiler { private readonly Dictionary> _compiledViews; private readonly ConcurrentDictionary _normalizedPathCache; private readonly ILogger _logger; public MyViewCompiler( IList compiledViews, ILogger logger) { ... } ///
public Task 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(), }); } private string GetNormalizedPath(string relativePath) { ... } } ``` 針對`DefaultViewCompiler`,這裡的重點是`CompileAsync`方法,它會根據傳入的相對路徑,在載入的編譯檢視集合中載入檢視。下面我們在此處打上斷點,並模擬進入`DemoPlugin1`的主頁。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054419340-884351543.gif) 看完這個除錯過程,你是不是發現了點什麼,當我們訪問`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`的目錄結構 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054429032-1751416108.png) 由此我們推斷出,預編譯檢視在生成的時候,會記錄當前檢視的相對路徑,而在主程式載入的外掛的過程中,由於我們使用了`Area`來區分模組,多出的一級目錄,所以導致目錄對映失敗了。因此如果我們將`DemoPlugin1`的外掛檢視目錄結構改為以上提示的6個地址之一,問題應該就解決了。 那麼這裡有沒有辦法,在不改變路徑的情況下,讓檢視正常載入呢,答案是有的。參照之前的程式碼,在載入檢視元件的時候,我們使用了內建類`CompiledRazorAssemblyPart`, 那麼讓我們來看看它的原始碼。 ```c# public class CompiledRazorAssemblyPart : ApplicationPart, IRazorCompiledItemProvider { /// /// Initializes a new instance of
. ///
/// The public CompiledRazorAssemblyPart(Assembly assembly) { Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); } /// /// Gets the . /// public Assembly Assembly { get; } /// public override string Name =>
Assembly.GetName().Name; IEnumerable IRazorCompiledItemProvider.CompiledItems { get { var loader = new RazorCompiledItemLoader(); return loader.LoadItems(Assembly); } } } ``` 這個類非常的簡單,它通過`RazorCompiledItemLoader`類物件從程式集中載入的檢視, 並將最終的編譯檢視都存放在一個`RazorCompiledItem`類的集合裡。 ```c# public class RazorCompiledItemLoader { public virtual IReadOnlyList LoadItems(Assembly assembly) { if (assembly == null) { throw new ArgumentNullException(nameof(assembly)); } var items = new List(); 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 LoadAttributes(Assembly assembly) { if (assembly == null) { throw new ArgumentNullException(nameof(assembly)); } return assembly.GetCustomAttributes(); } } ``` 這裡我們可以參考前面的除錯方式,創建出一套自己的檢視載入類,程式碼和當前的實現一模一樣 `MystiqueModuleViewCompiledItemLoader` ```c# 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` ```c# 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 IRazorCompiledItemProvider.CompiledItems { get { var loader = new MystiqueModuleViewCompiledItemLoader(); return loader.LoadItems(Assembly); } } } ``` `MystiqueModuleViewCompiledItem` ```c# public class MystiqueModuleViewCompiledItem : RazorCompiledItem { public override string Identifier { get; } public override string Kind { get; } public override IReadOnlyList 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`類的建構函式部分打上斷點。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054503107-1475303342.png) 重新啟動專案之後,你會發現當載入DemoPlugin1的檢視時,這裡的`Identifier`屬性其實就是當前編譯試圖項的對映目錄。這樣我們很容易就想到在此處動態修改對映目錄,為此我們需要將模組名稱通過建構函式傳入,以上3個類的更新程式碼如下: `MystiqueModuleViewCompiledItemLoader` ```c# 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` ```c# 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 IRazorCompiledItemProvider.CompiledItems { get { var loader = new MystiqueModuleViewCompiledItemLoader(ModuleName); return loader.LoadItems(Assembly); } } } ``` `MystiqueModuleViewCompiledItem` ```c# public class MystiqueModuleViewCompiledItem : RazorCompiledItem { public override string Identifier { get; } public override string Kind { get; } public override IReadOnlyList 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`屬性的值被動態修改,此處的值也要修改,否則除錯會不成功。 修改完成之後,我們重啟專案,來測試一下。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054523586-1205831138.gif) 編譯檢視的對映路徑動態修改成功,頁面成功被打開了,至此啟動時的預編譯檢視載入完成。 # 執行時載入編譯檢視 最後我們來到了執行載入編譯檢視的問題,有了之前的除錯方案,現在除錯起來就輕車熟路。 為了測試,我們再執行時載入編譯檢視,我們首先禁用掉`DemoPlugin1`, 然後重啟專案,並啟用`DemoPlugin1` ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054532783-698470703.gif) 通過除錯,很明顯問題出在預編譯檢視的載入上,在啟用元件之後,編譯檢視對映集合沒有更新,所以導致載入失敗。這也證明了我們之前[第三章](https://www.cnblogs.com/lwqlun/p/11260750.html)時候的推斷。當使用`IActionDescriptorChangeProvider`重置`Controller/Action`對映的時候,ASP.NET Core不會更新檢視對映集合,從而導致檢視載入失敗。 ```c# MystiqueActionDescriptorChangeProvider.Instance.HasChanged = true; MystiqueActionDescriptorChangeProvider.Instance.TokenSource.Cancel(); ``` 那麼解決問題的方式也就很清楚了,我們需要在使用`IActionDescriptorChangeProvider`重置`Controller/Action`對映之後,重新整理檢視對映集合。為此,我們需要修改之前定義的`MyViewCompilerProvider`, 新增`Refresh`方法來重新整理對映。 ```c# 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()); } public IViewCompiler GetCompiler() => _compiler; } ``` `Refresh`方法是藉助`ViewsFeature`來重新建立了一個新的`IViewCompiler`, 並填充了最新的檢視對映。 > PS: 這裡的實現方式參考了`DefaultViewCompilerProvider`的實現,該類是在構造中填充的檢視對映。 根據以上修改,在使用`IActionDescriptorChangeProvider`重置Controller/Action對映之後, 我們使用`Refresh`方法來重新整理對映。 ```c# private void ResetControllActions() { MystiqueActionDescriptorChangeProvider.Instance.HasChanged = true; MystiqueActionDescriptorChangeProvider.Instance.TokenSource.Cancel(); var provider = _context.HttpContext .RequestServices .GetService(typeof(IViewCompilerProvider)) as MyViewCompilerProvider; provider.Refresh(); } ``` 最後,我們重新啟動專案,再次在執行時啟用`DemoPlugin1`,進入外掛主頁面,頁面正常顯示了。 ![](https://img2020.cnblogs.com/blog/65831/202011/65831-20201117054543931-1228585130.gif) 至此執行時載入與編譯檢視的場景也順利解決了。