標題:從零開始實現ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/13197683.html
源代碼:https://github.com/lamondlu/Mystique

系列文章
- 從零開始實現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視圖相關問題及解決方案
簡介
在上一篇中,我給大家分享了程序調試問題的解決方案以及如何實現插件中的消息傳遞,完稿之后,又收到了不少問題反饋,其中最嚴重的問題應該就是運行時編譯Razor視圖失敗的問題。

本篇我就給大家分享一下我針對此問題的解決方案,最后還會補上上一篇中鴿掉的動態加載菜單(T.T)。
Razor視圖中引用出錯問題
為了模擬一下當前的問題,我們首先之前的插件1中添加一個新類TestClass, 並在HelloWorld方法中創建一個TestClass對象作為視圖模型傳遞給Razor視圖,並在Razor視圖中展示出TestClass的Message屬性。
- TestClass.cs
public class TestClass
{
public string Message { get; set; }
}
- HelloWorld.cshtml
@using DemoPlugin1.Models;
@model TestClass
@{
}
<h1>@ViewBag.Content</h1>
<h2>@Model.Message</h2>
- Plugin1Controller.cs
[Area("DemoPlugin1")]
public class Plugin1Controller : Controller
{
private INotificationRegister _notificationRegister;
public Plugin1Controller(INotificationRegister notificationRegister)
{
_notificationRegister = notificationRegister;
}
[HttpGet]
public IActionResult HelloWorld()
{
string content = new Demo().SayHello();
ViewBag.Content = content + "; Plugin2 triggered";
TestClass testClass = new TestClass();
testClass.Message = "Hello World";
_notificationRegister.Publish("LoadHelloWorldEvent", JsonConvert.SerializeObject(new LoadHelloWorldEvent() { Str = "Hello World" }));
return View(testClass);
}
}
這個代碼看似很簡單,也是最常用的MVC視圖展示方式,但是集成在動態組件系統中之后,你就會得到以下錯誤界面。

這里看起來似乎依然感覺是AssemblyLoadContext的問題。主要的線索是,如果你將插件1的程序集直接引入主程序工程中,重新啟動項目之后,此處代碼能夠正常訪問,所以我猜想Razor視圖才進行運行時編譯的時候,使用了默認的AssemblyLoadContext,而非插件AssemblyPart所在的AssemblyLoadContext。
由此我做了一個實驗,我在MystiqueSetup方法中,在插件加載的時候,也向默認AssemblyLoadContext中加載了插件程序集
public static void MystiqueSetup(this IServiceCollection services,
IConfiguration configuration)
{
...
using (IServiceScope scope = provider.CreateScope())
{
MvcRazorRuntimeCompilationOptions option = scope.ServiceProvider.GetService<MvcRazorRuntimeCompilationOptions>();
IUnitOfWork unitOfWork = scope.ServiceProvider.GetService<IUnitOfWork>();
List<ViewModels.PluginListItemViewModel> allEnabledPlugins = unitOfWork.PluginRepository.GetAllEnabledPlugins();
IReferenceLoader loader = scope.ServiceProvider.GetService<IReferenceLoader>();
foreach (ViewModels.PluginListItemViewModel plugin in allEnabledPlugins)
{
...
using (FileStream fs = new FileStream(filePath, FileMode.Open))
{
System.Reflection.Assembly assembly = context.LoadFromStream(fs);
context.SetEntryPoint(assembly);
loader.LoadStreamsIntoContext(context, referenceFolderPath, assembly);
...
fs.Position = 0;
AssemblyLoadContext.Default.LoadFromStream(fs);
}
context.Enable();
}
}
...
}
重新運行程序,訪問插件1的路由,你就會得到以下錯誤。

這說明默認AssemblyLoadContext中的程序集正常加載了,只是和視圖中需要的類型不匹配,所以此處也可以說明Razor視圖的運行時編譯使用的是默認AssemblyLoadContext
Notes: 這個場景在前幾篇中遇到過,在不同
AssemblyLoadContext加載相同的程序集,系統會將嚴格的將他們區分開,插件1中的AssemblyPart引用是插件1所在AssemblyLoadContext中的DemoPlugin1.Models.TestClass類型,這與默認AssemblyLoadContext中加載的DemoPlugin1.Models.TestClass不符。
在之前系列文章中,我介紹過兩次,在ASP.NET Core的設計文檔中,針對AssemblyLoadContext部分的是這樣設計的
- 每個ASP.NET Core程序啟動后,都會創建出一個唯一的默認
AssemblyLoadContext - 開發人員可以自定義
AssemblyLoadContext, 當在自定義AssemblyLoadContext加載某個程序集的時候,如果在當前自定義的AssemlyLoadContext中找不到該程序集,系統會嘗試在默認AssemblyLoadContext中加載。

但是這種程序集加載流程只是單向的,如果默認AssemblyLoadContext未加載某個程序集,但某個自定義AssemblyLoadContext中加載了該程序集,你是不能從默認AssemblyLoadContext中加載到這個程序集的。
這也就是我們現在遇到的問題,如果你有興趣的話,可以去Review一下ASP.NET Core的針對RuntimeCompilation源碼部分,你會發現當ASP.NET Core的Razor視圖引擎會使用Roslyn來編譯視圖,這里直接使用了默認的AssemblyLoadContext加載視圖所需的程序集引用。
綠線是我們期望的加載方式,紅線是實際的加載方式

為什么不直接用默認AssemblyLoadContext來加載插件?
可能會有同學問,為什么不用默認的AssemblyLoadContext來加載插件,這里有2個主要原因。
首先如果都使用默認的AssemblyLoadContext來加載插件,當不同插件使用了兩個不同版本、相同名稱的程序集時, 程序加載會出錯,因為一個AssemblyLoadContext不能加載不同版本,相同名稱的程序集,所以在之前我們才設計成了這種使用自定義程序集加載不同插件的方式。

其次如果都是用默認的AssemblyLoadContext來加載插件,插件的卸載和升級會變成一個大問題,但是如果我們使用自定義AssemblyLoadContext的加載插件,當升級和卸載插件時,我們可以毫不猶豫的Unload當前的自定義AssemblyLoadContext。
臨時的解決方案
既然不能使用默認AssemblyLoadContext來加載程序集了,那么是不是只能重寫Razor視圖運行時編譯代碼來滿足當前需求呢?
答案當然是否定了,這里我們可以通過AssemblyLoadContext提供的Resolving事件來解決這個問題。
AssemblyLoadContext的Resolving事件是在當前AssemblyLoadContext不能加載指定程序集時觸發的。所以當Razor引擎執行運行時視圖編譯的時候,如果在默認AssemblyLoadContext中找不到某個程序集,我們可以強制讓它去自定義的AssemblyLoadContext中查找,如果能找到,就直接返回匹配的程序。這樣我們的插件1視圖就可以正常展示了。
public static void MystiqueSetup(this IServiceCollection services, IConfiguration configuration)
{
...
AssemblyLoadContext.Default.Resolving += (context, assembly) =>
{
Func<CollectibleAssemblyLoadContext, bool> filter = p =>
p.Assemblies.Any(p => p.GetName().Name == assembly.Name
&& p.GetName().Version == assembly.Version);
if (PluginsLoadContexts.All().Any(filter))
{
var ass = PluginsLoadContexts.All().First(filter)
.Assemblies.First(p => p.GetName().Name == assembly.Name
&& p.GetName().Version == assembly.Version);
return ass;
}
return null;
};
...
}
Note: 這里其實還有一個問題,如果插件1和插件2都引用了相同版本和名稱的程序集,可能會出現插件1的視圖匹配到插件2中程序集的問題,就會出現和前面一樣的程序集沖突。這塊最終的解決肯定還是要重寫Razor的運行時編譯代碼,后續如果能完成這部分,再來更新。
臨時的解決方案是,當一個相同版本和名稱的程序集被2個插件共同使用時,我們可以使用默認
AssemblyLoadContext來加載,並跳過自定義AssemblyLoadContext針對該程序集的加載。
現在我們重新啟動項目,訪問插件1路由,頁面正常顯示了。

如何動態加載菜單
之前有小伙伴問,能不能動態加載菜單,每次都是手敲鏈接進入插件界面相當的不友好。答案是肯定的。
這里我先做一個簡單的實現,如果后續其他的難點都解決了,我會將這里的實現改為一個單獨的模塊,實現方式也改的更優雅一點。
首先在Mystique.Core項目中添加一個特性類Page, 這個特性只允許在方法上使用,Name屬性保存了當前頁面的名稱。
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class Page : Attribute
{
public Page(string name)
{
Name = name;
}
public string Name { get; set; }
}
第二步,創建一個展示導航欄菜單用的視圖模型類PageRouteViewModel,我們會在導航部分使用到它。
public class PageRouteViewModel
{
public PageRouteViewModel(string pageName, string area, string controller, string action)
{
PageName = pageName;
Area = area;
Controller = controller;
Action = action;
}
public string PageName { get; set; }
public string Area { get; set; }
public string Controller { get; set; }
public string Action { get; set; }
public string Url
{
get
{
return $"{Area}/{Controller}/{Action}";
}
}
}
第三步,我們需要使用反射,從所有啟用的插件程序集中加載所有帶有Page特性的路由方法,並將他們組合成一個導航欄菜單的視圖模型集合。
public static class CollectibleAssemblyLoadContextExtension
{
public static List<PageRouteViewModel> GetPages(this CollectibleAssemblyLoadContext context)
{
var entryPointAssembly = context.GetEntryPointAssembly();
var result = new List<PageRouteViewModel>();
if (entryPointAssembly == null || !context.IsEnabled)
{
return result;
}
var areaName = context.PluginName;
var types = entryPointAssembly.GetExportedTypes().Where(p => p.BaseType == typeof(Controller));
if (types.Any())
{
foreach (var type in types)
{
var controllerName = type.Name.Replace("Controller", "");
var actions = type.GetMethods().Where(p => p.GetCustomAttributes(false).Any(x => x.GetType() == typeof(Page))).ToList();
foreach (var action in actions)
{
var actionName = action.Name;
var pageAttribute = (Page)action.GetCustomAttributes(false).First(p => p.GetType() == typeof(Page));
result.Add(new PageRouteViewModel(pageAttribute.Name, areaName, controllerName, actionName));
}
}
return result;
}
else
{
return result;
}
}
}
Notes: 這里其實可以集成MVC的路由系統來生成Url, 這里為了簡單演示,就采取了手動拼湊Url的方式,有興趣的同學可以自己改寫一下。
最后我們來修改主站點的母版頁_Layout.cshtml, 在導航欄尾部追加動態菜單。
@using Mystique.Core.Mvc.Extensions;
@{
var contexts = Mystique.Core.PluginsLoadContexts.All();
var menus = contexts.SelectMany(p => p.GetPages()).ToList();
}
...
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">DynamicPluginsDemoSite</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Plugins" asp-action="Index">Plugins</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Plugins" asp-action="Assemblies">Assemblies</a>
</li>
@foreach (var item in menus)
{
<li class="nav-item">
<a class="nav-link text-dark" href="/Modules/@item.Url">@item.PageName</a>
</li>
}
</ul>
</div>
</div>
</nav>
</header>
這樣基礎設施部分的代碼就完成了,下面我們來嘗試修改插件1的代碼,在HelloWorld路由方法上我們添加特性[Page("Plugin One")], 這樣按照我們的預想,當插件1啟動的時候,導航欄中應該出現Plugin One的菜單項。
[Area("DemoPlugin1")]
public class Plugin1Controller : Controller
{
private INotificationRegister _notificationRegister;
public Plugin1Controller(INotificationRegister notificationRegister)
{
_notificationRegister = notificationRegister;
}
[Page("Plugin One")]
[HttpGet]
public IActionResult HelloWorld()
{
string content = new Demo().SayHello();
ViewBag.Content = content + "; Plugin2 triggered";
TestClass testClass = new TestClass();
testClass.Message = "Hello World";
_notificationRegister.Publish("LoadHelloWorldEvent", JsonConvert.SerializeObject(new LoadHelloWorldEvent() { Str = "Hello World" }));
return View(testClass);
}
}
最終效果
下面我們啟動程序,來看一下最終的效果,動態菜單功能完成。

總結
本篇給大家演示了處理Razor視圖引用問題的一個臨時解決方案和動態菜單的實現,Razor視圖引用問題歸根結底還是AssemblyLoadContext的問題,這可能就是ASP.NET Core插件開發最常見的問題了。當然視圖部分也有很多其他的問題,其實我一度感覺如果僅停留在控制器部分,僅實現ASP.NET Core Webapi的插件化可能相對更容易一些,一旦牽扯到Razor視圖,特別是運行時編譯Razor視圖,就有各種各樣的問題,后續編寫部分組件可能會遇到更多的問題,希望能走的下去,有興趣或者遇到問題的小伙伴可以給我發郵件(309728709@qq.com)或者在Github(https://github.com/lamondlu/Mystique)中提Issues,感謝支持。
