天天看點

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

标題:從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

作者:Lamond Lu

位址:https://www.cnblogs.com/lwqlun/p/13197683.html

源代碼:https://github.com/lamondlu/Mystique

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

系列文章

  • 從零開始實作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視圖失敗的問題。

從零開始實作ASP.NET Core MVC的插件式開發(八) - 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視圖展示方式,但是內建在動态元件系統中之後,你就會得到以下錯誤界面。

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

這裡看起來似乎依然感覺是

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的路由,你就會得到以下錯誤。

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

這說明預設

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

    中加載。
從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

但是這種程式集加載流程隻是單向的,如果預設

AssemblyLoadContext

未加載某個程式集,但某個自定義

AssemblyLoadContext

中加載了該程式集,你是不能從預設

AssemblyLoadContext

中加載到這個程式集的。

這也就是我們現在遇到的問題,如果你有興趣的話,可以去Review一下ASP.NET Core的針對RuntimeCompilation源碼部分,你會發現當ASP.NET Core的Razor視圖引擎會使用Roslyn來編譯視圖,這裡直接使用了預設的

AssemblyLoadContext

加載視圖所需的程式集引用。

綠線是我們期望的加載方式,紅線是實際的加載方式

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

為什麼不直接用預設

AssemblyLoadContext

來加載插件?

可能會有同學問,為什麼不用預設的

AssemblyLoadContext

來加載插件,這裡有2個主要原因。

首先如果都使用預設的

AssemblyLoadContext

來加載插件,當不同插件使用了兩個不同版本、相同名稱的程式集時, 程式加載會出錯,因為一個

AssemblyLoadContext

不能加載不同版本,相同名稱的程式集,是以在之前我們才設計成了這種使用自定義程式集加載不同插件的方式。

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

其次如果都是用預設的

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路由,頁面正常顯示了。

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

如何動态加載菜單

之前有小夥伴問,能不能動态加載菜單,每次都是手敲連結進入插件界面相當的不友好。答案是肯定的。

這裡我先做一個簡單的實作,如果後續其他的難點都解決了,我會将這裡的實作改為一個單獨的子產品,實作方式也改的更優雅一點。

首先在

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);
        }
    }
           

最終效果

下面我們啟動程式,來看一下最終的效果,動态菜單功能完成。

從零開始實作ASP.NET Core MVC的插件式開發(八) - Razor視圖相關問題及解決方案

總結

本篇給大家示範了處理

Razor

視圖引用問題的一個臨時解決方案和動态菜單的實作,

Razor

視圖引用問題歸根結底還是

AssemblyLoadContext

的問題,這可能就是ASP.NET Core插件開發最常見的問題了。當然視圖部分也有很多其他的問題,其實我一度感覺如果僅停留在控制器部分,僅實作ASP.NET Core Webapi的插件化可能相對更容易一些,一旦牽扯到

Razor

視圖,特别是運作時編譯

Razor

視圖,就有各種各樣的問題,後續編寫部分元件可能會遇到更多的問題,希望能走的下去,有興趣或者遇到問題的小夥伴可以給我發郵件([email protected])或者在Github(https://github.com/lamondlu/Mystique)中提Issues,感謝支援。