從零開始實現ASP.NET Core MVC的插件式開發(七) – 近期問題匯總及部分解決方案

標題:從零開始實現ASP.NET Core MVC的插件式開發(七) – 近期問題匯總及部分問題解決方案
作者:Lamond Lu
地址://www.cnblogs.com/lwqlun/p/12930713.html
源程式碼://github.com/lamondlu/Mystique

前景回顧

簡介

在上一篇中,我給大家講解插件引用程式集的載入問題,在載入插件的時候,我們不僅需要載入插件的程式集,還需要載入插件引用的程式集。在上一篇寫完之後,有許多小夥伴聯繫到我,提出了各種各樣的問題,在這裡謝謝大家的支援,你們就是我前進的動力。本篇呢,我就對這其中的一些主要問題進行一下匯總和解答。

如何在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中調試運行插件以及如何實現插件間的消息傳輸。後續我會根據回饋,繼續添加新內容,大家敬請期待。