標題:從零開始實現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
前景回顧
- 從零開始實現ASP.NET Core MVC的外掛式開發(一) - 使用Application Part動態載入控制器和檢視
- 從零開始實現ASP.NET Core MVC的外掛式開發(二) - 如何建立專案模板
- 從零開始實現ASP.NET Core MVC的外掛式開發(三) - 如何在執行時啟用元件
- 從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝
- 從零開始實現ASP.NET Core MVC的外掛式開發(五) - 使用AssemblyLoadContext實現外掛的升級和刪除
- 從零開始實現ASP.NET Core MVC的外掛式開發(六) - 如何載入外掛引用
- 從零開始實現ASP.NET Core MVC的外掛式開發(七) - 近期問題彙總及部分解決方案
- 從零開始實現ASP.NET Core MVC的外掛式開發(八) - Razor檢視相關問題及解決方案
簡介
在這個專案建立的時候,專案的初衷是使用預編譯檢視來呈現介面,但是由於多次嘗試失敗,最後改用了執行時編譯檢視,這種方式在第一次載入的時候非常的慢,所有的外掛檢視都要在執行時編譯,而且從便攜性上來說,預編譯檢視更好。近日,在幾位同道的共同努力下,終於實現了這種載入方式。
此篇要鳴謝網友 j4587698 和 yang-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個內部類DefaultViewCompilerProvider
和DefaultViewCompiler
。但是由於這2個類是內部類,所以沒有辦法繼承並重寫,更談不上除錯了。
j4587698
的思路和我不同,他的做法是,在當前主專案中,直接複製DefaultViewCompilerProvider
和DefaultViewCompiler
2個類的程式碼,並將其定義為公開類,在程式啟動時,替換預設依賴注入容器中的類實現,使用公開的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
和DefaultViewCompiler
2個類的程式碼,稍作修改,保證編譯通過。
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
,進入外掛主頁面,頁面正常顯示了。
至此執行時載入與編譯檢視的場景也順利解決了。