標題:從零開始實現ASP.NET Core MVC的插件式開發(七) - 近期問題匯總及部分問題解決方案
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/12930713.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視圖相關問題及解決方案
簡介
在上一篇中,我給大家講解插件引用程序集的加載問題,在加載插件的時候,我們不僅需要加載插件的程序集,還需要加載插件引用的程序集。在上一篇寫完之后,有許多小伙伴聯系到我,提出了各種各樣的問題,在這里謝謝大家的支持,你們就是我前進的動力。本篇呢,我就對這其中的一些主要問題進行一下匯總和解答。
如何在Visual Studio中以調試模式啟動項目?
在所有的問題中,提到最多的問題就是如何在Visual Studio中使用調試模式啟動項目的問題。當前項目在默認情況下,可以在Visual Studio中啟動調試模式,但是當你嘗試訪問已安裝插件路由時,所有的插件視圖都打不開。
這里之前給出臨時的解決方案是在bin\Debug\netcoreapp3.1
目錄中使用命令行dotnet Mystique.dll
的方式啟動項目。
視圖找不到的原因及解決方案
這個問題的主要原因就是主站點在Visual Studio中以調試模式啟動的時候,默認的Working directory
是當前項目的根目錄,而非bin\Debug\netcoreapp3.1
目錄,所以當主程序查找插件視圖的時候,按照所有的內置規則,都找不到指定的視圖文件, 所以就給出了The view 'xx' was not found
的錯誤信息。
因此,這里我們要做的就是修改一下當前主站點的Working directory
即可,這里我們需要將Working directory
設置為當前主站點下的bin\Debug\netcoreapp3.1
目錄。
PS: 我在開發過程中,將.NET Core升級到了3.1版本,如果你還在使用.NET Core 2.2或者.NET Core 3.0,請將
Working directory
配置為相應目錄
這樣當你在Visual Studio中再次以調試模式啟動項目之后,就能訪問到插件視圖了。
隨之而來的樣式丟失問題
看完前面的解決方案之后,你不是已經躍躍欲試了?
但是當你啟動項目之后,會心涼半截,你會發現整站的樣式和Javascript腳本文件引用都丟失了。
這里的原因是主站點的默認靜態資源文件都放置在項目根目錄的wwwroot
子目錄中,但是現在我們將Working directory
改為了bin\Debug\netcoreapp3.1
了,在bin\Debug\netcoreapp3.1
中並沒有wwwroot
子目錄,所以在修改Working directory
后,就不能正常加載靜態資源文件了。
這里為了修復這個問題,我們需要對代碼做兩處修改。
首先呢,我們需要知道當我們使用app.UseStaticFiles()
添加靜態資源文件目錄,並以在Visual Studio中以調試模式啟動項目的時候,項目查找的默認目錄是當前項目根目錄中的wwwroot
目錄,所以這里我們需要將這個地方改為PhysicalFileProvider
的實現方式,並指定當前靜態資源文件所在目錄是項目目錄下的wwwroot
目錄。
其次,因為當前配置只是針對Visual Studio調試的,所以我們需要使用預編譯指令#if DEBUG
和`#if !DEBUG針對不同的場景進行不同的靜態資源文件目錄配置。
所以Configure()
方法最終的修改結果如下:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
#if DEBUG
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(@"G:\D1\Mystique\Mystique\wwwroot")
});
#endif
#if !DEBUG
app.UseStaticFiles();
#endif
app.MystiqueRoute();
}
在完成修改之后,重新編譯項目,並以調試模式啟動項目后,你就會發現,我們熟悉的界面又回來了。
如何實現插件間的消息傳遞?
這個問題是去年年底和衣明志大哥討論動態插件開發的時候,衣哥提出來的功能,本身實現思路不麻煩,但是實踐過程中,我卻讓AssemblyLoadContext
給絆了一跤。
基本思路
這里因為需要實現兩個不同插件的消息通信,最簡單的方式是使用消息注冊訂閱。
PS: 使用第三方消息隊列也是一種實現方式,但是本次實踐中只是為了簡單,沒有使用額外的消息注冊訂閱組件,直接使用了進程內的消息注冊訂閱
基本思路:
- 定義
INotificationHandler
接口來處理消息 - 在每個獨立組件中,我們通過
INotificationProvider
接口向主程序公開當前組件訂閱的消息及處理程序 - 在主站點中,我們通過
INotificationRegister
接口實現一個消息注冊訂閱容器,當站點啟動,系統可以通過每個組件的INotificationProvider
接口實現,將訂閱的消息和處理程序注冊到主站點的消息發布訂閱容器中。 - 每個插件中,使用
INotifcationRegister
接口的Publish
方法發布消息
根據以上思路,我們首先定義一個消息處理接口INotification
public interface INotificationHandler
{
void Handle(string data);
}
這里我沒有采用強類型的來規范消息的格式,主要原因是如果使用強類型定義消息,不同的插件勢必都要引用一個存放強類型強類型消息定義的的程序集,這樣會增加插件之間的耦合度,每個插件就開發起來變得不那么獨立了。
PS: 以上設計只是個人喜好,如果你喜歡使用強類型也完全沒有問題。
接下來,我們再來定義消息發布訂閱接口以及消息處理程序接口
public interface INotificationProvider
{
Dictionary<string, List<INotificationHandler>> GetNotifications();
}
public interface INotificationRegister
{
void Subscribe(string eventName, INotificationHandler handler);
void Publish(string eventName, string data);
}
這里代碼非常的簡單,INotificationProvider
接口提供一個消息處理器的集合,INotificationRegister
接口定義了消息訂閱和發布的方法。
下面我們在Mystique.Core.Mvc
項目中完成INotificationRegister
的接口實現。
public class NotificationRegister : INotificationRegister
{
private static Dictionary<string, List<INotificationHandler>>
_containers = new Dictionary<string, List<INotificationHandler>>();
public void Publish(string eventName, string data)
{
if (_containers.ContainsKey(eventName))
{
foreach (var item in _containers[eventName])
{
item.Handle(data);
}
}
}
public void Subscribe(string eventName, INotificationHandler handler)
{
if (_containers.ContainsKey(eventName))
{
_containers[eventName].Add(handler);
}
else
{
_containers[eventName] = new List<INotificationHandler>() { handler };
}
}
}
最后,我們還需要在項目啟動方法MystiqueSetup
中配置消息訂閱器的發現和綁定。
public static void MystiqueSetup(this IServiceCollection services,
IConfiguration configuration)
{
...
using (IServiceScope scope = provider.CreateScope())
{
...
foreach (ViewModels.PluginListItemViewModel plugin in allEnabledPlugins)
{
...
using (FileStream fs = new FileStream(filePath, FileMode.Open))
{
...
var providers = assembly.GetExportedTypes()
.Where(p => p.GetInterfaces()
.Any(x => x.Name == "INotificationProvider"));
if (providers != null && providers.Count() > 0)
{
var register = scope.ServiceProvider
.GetService<INotificationRegister>();
foreach (var p in providers)
{
var obj = (INotificationProvider)assembly
.CreateInstance(p.FullName);
var result = obj.GetNotifications();
foreach (var item in result)
{
foreach (var i in item.Value)
{
register.Subscribe(item.Key, i);
}
}
}
}
}
}
}
...
}
完成以上基礎設置之后,我們就可以嘗試在插件中發布訂閱消息了。
首先這里我們在DemoPlugin2中創建消息LoadHelloWorldEvent
,並創建對應的消息處理器LoadHelloWorldEventHandler
.
public class NotificationProvider : INotificationProvider
{
public Dictionary<string, List<INotificationHandler>> GetNotifications()
{
var handlers = new List<INotificationHandler> { new LoadHelloWorldEventHandler() };
var result = new Dictionary<string, List<INotificationHandler>>();
result.Add("LoadHelloWorldEvent", handlers);
return result;
}
}
public class LoadHelloWorldEventHandler : INotificationHandler
{
public void Handle(string data)
{
Console.WriteLine("Plugin2 handled hello world events." + data);
}
}
public class LoadHelloWorldEvent
{
public string Str { get; set; }
}
然后我們修改DemoPlugin1的HelloWorld方法,在返回視圖之前,發布一個LoadHelloWorldEvent
的消息。
[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";
_notificationRegister.Publish("LoadHelloWorldEvent", JsonConvert.SerializeObject(new LoadHelloWorldEvent() { Str = "Hello World" }));
return View();
}
}
public class LoadHelloWorldEvent
{
public string Str { get; set; }
}
AssemblyLoadContext產生的靈異問題
上面的代碼看起來很美好,但是實際運行的時候,你會遇到一個靈異的問題,就是系統不能將DemoPlugin2中的NotificationProvider
轉換為INotificationProvider
接口類型的對象。
這個問題困擾了我半天,完全想象不出可能的問題,但是我隱約感覺這是一個AssemblyLoadContext
引起的問題。
在上一篇中,我們曾經查找過.NET Core的程序集加載設計文檔。
在.NET Core的設計文檔中,對於程序集加載有這樣一段描述
If the assembly was already present in A1's context, either because we had successfully loaded it earlier, or because we failed to load it for some reason, we return the corresponding status (and assembly reference for the success case).
However, if C1 was not found in A1's context, the Load method override in A1's context is invoked.
- For Custom LoadContext, this override is an opportunity to load an assembly before the fallback (see below) to Default LoadContext is attempted to resolve the load.
- For Default LoadContext, this override always returns null since Default Context cannot override itself.
這里簡單來說,意思就是當在一個自定義LoadContext
中加載程序集的時候,如果找不到這個程序集,程序會自動去默認LoadContext
中查找,如果默認LoadContext
中都找不到,就會返回null
。
這里我突然想到會不會是因為DemoPlugin1、DemoPlugin2以及主站點的AssemblyLoadContext
都加載了Mystique.Core.dll
程序集的緣故,雖然他們加載的是同一個程序集,但是因為LoadContext
不同,所以系統認為它是2個程序集。
PS: 主站點的
AssemblyLoadContext
即默認的LoadContext
其實對於DemoPlugin1和DemoPlugin2來說,它們完全沒有必須要加載Mystique.Core.dll
程序集,因為主站點的默認LoadContext
已經加載了此程序集,所以當DemoPlugin1和DemoPlugin2使用Mystique.Core.dll
程序集中定義的INotificationProvider
時,就會去默認的LoadContext
中加載,這樣他們加載的程序集就都是默認LoadContext
中的了,就不存在差異了。
於是根據這個思路,我修改了一下插件程序集加載部分的代碼,將Mystique.Core.*
程序集排除在加載列表中。
重新啟動項目之后,項目正常運行,消息發布訂閱能正常運行。
項目后續嘗試添加的功能
由於篇幅問題,剩余的其他問題和功能會在下一篇中來完成。以下是項目后續會逐步添加的功能
- 添加/移除插件后,主站點導航欄自動加載插件入口頁面(已完成,下一篇中說明)
- 在主站點中,添加頁面管理模塊
- 嘗試一個頁面加載多個插件,當前的插件只能實現一個插件一個頁面。
不過如果大家如果有什么其他想法,也可以給我留言或者在Github上提Issue,你們的建議就是我進步的動力。
總結
本篇針對前一陣子Github Issue和文檔評論中比較集中的問題進行了說明和解答,主要講解了如何在Visual Studio中調試運行插件以及如何實現插件間的消息傳輸。后續我會根據反饋,繼續添加新內容,大家敬請期待。