從零開始實現ASP.NET Core MVC的外掛式開發(八) - Razor檢視相關問題及解決方案

LamondLu發表於2020-06-29

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

前景回顧

簡介

在上一篇中,我給大家分享了程式除錯問題的解決方案以及如何實現外掛中的訊息傳遞,完稿之後,又收到了不少問題反饋,其中最嚴重的問題應該就是執行時編譯Razor檢視失敗的問題。

本篇我就給大家分享一下我針對此問題的解決方案,最後還會補上上一篇中鴿掉的動態載入選單(T.T)。

Razor檢視中引用出錯問題

為了模擬一下當前的問題,我們首先之前的外掛1中新增一個新類TestClass, 並在HelloWorld方法中建立一個TestClass物件作為檢視模型傳遞給Razor檢視,並在Razor檢視中展示出TestClassMessage屬性。

  • 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事件來解決這個問題。

AssemblyLoadContextResolving事件是在當前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,感謝支援。

相關文章