从零开始实现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的插件式开发(一) - 使用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的插件式开发(七) - 近期问题汇总及部分解决方案
 
简介
在上一篇中,我给大家分享了程序调试问题的解决方案以及如何实现插件中的消息传递,完稿之后,又收到了不少问题反馈,其中最严重的问题应该就是运行时编译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,感谢支持。
从零开始实现ASP.NET Core MVC的插件式开发(八) - Razor视图相关问题及解决方案的更多相关文章
- 从零开始实现ASP.NET Core MVC的插件式开发(七) - 近期问题汇总及部分解决方案
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(七) - 问题汇总及部分解决方案 作者:Lamond Lu 地址:https://www.cnblogs.com/lwqlun/p/12 ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(九) - 升级.NET 5及启用预编译视图
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(九) - 如何启用预编译视图 作者:Lamond Lu 地址:https://www.cnblogs.com/lwqlun/p/1399 ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(一) - 使用ApplicationPart动态加载控制器和视图
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(一) - 使用Application Part动态加载控制器和视图 作者:Lamond Lu 地址:http://www.cnblogs ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(二) - 如何创建项目模板
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(二) - 如何创建项目模板 作者:Lamond Lu 地址:https://www.cnblogs.com/lwqlun/p/11155 ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(三) - 如何在运行时启用组件
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(三) - 如何在运行时启用组件 作者:Lamond Lu 地址:https://www.cnblogs.com/lwqlun/p/112 ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(四) - 插件安装
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(四) - 插件安装 作者:Lamond Lu 地址:https://www.cnblogs.com/lwqlun/p/11260750. ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(五) - 插件的删除和升级
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(五) - 使用AssemblyLoadContext实现插件的升级和删除 作者:Lamond Lu 地址:https://www.cnb ...
 - 从零开始实现ASP.NET Core MVC的插件式开发(六) - 如何加载插件引用
		
标题:从零开始实现ASP.NET Core MVC的插件式开发(六) - 如何加载插件引用. 作者:Lamond Lu 地址:https://www.cnblogs.com/lwqlun/p/1171 ...
 - asp.net mvc5轻松实现插件式开发
		
在研究Nopcommece项目代码的时候,发现Nop.Admin是作为独立项目开发的,但是部署的时候却是合在一起的,感觉挺好 这里把他这个部分单独抽离出来, 主要关键点: 确保你的项目是MVC5 而不 ...
 
随机推荐
- js运算符和if语句,switch语句
			
逻辑运算符 类型 运算符 算数运算符 + - * / % ++ -- 赋值运算符 = 比较运算符 > < >= <= == != ...
 - Java 蓝桥杯 算法训练 字符串的展开 (JAVA语言实现)
			
** 算法训练 字符串的展开 ** 题目: 在初赛普及组的"阅读程序写结果"的问题中,我们曾给出一个字符串展开的例子:如果在输入的字符串中,含有类似于"d-h" ...
 - Java实现蓝桥杯历届试题填字母游戏
			
题目描述 小明经常玩 LOL 游戏上瘾,一次他想挑战K大师,不料K大师说: "我们先来玩个空格填字母的游戏,要是你不能赢我,就再别玩LOL了". K大师在纸上画了一行n个格子,要小 ...
 - 从源码研究如何不重启Springboot项目实现redis配置动态切换
			
上一篇Websocket的续篇暂时还没有动手写,这篇算是插播吧.今天讲讲不重启项目动态切换redis服务. 背景 多个项目或微服务场景下,各个项目都需要配置redis数据源.但是,每当运维搞事时(修改 ...
 - tensorflow2.0学习笔记
			
今天我们开始学习tensorflow2.0,用一种简单和循循渐进的方式,带领大家亲身体验深度学习.学习的目录如下图所示: 1.简单的神经网络学习过程 1.1张量生成 1.2常用函数 1.3鸢尾花数据读 ...
 - 95题--不同的二叉搜索树II(java、中等难度)
			
题目描述:给定一个整数 n,生成所有由 1 ... n 为节点所组成的 二叉搜索树 . 示例如下: 分析:这一题需要对比LeetCode96题来分析:https://www.cnblogs.com/K ...
 - 【快手初面】要求3个线程按顺序循环执行,如循环打印A,B,C
			
[背景]这个题目是当时远程面试时,手写的题目.自己比较惭愧,当时写的并不好,面试完就又好好的完善了下. 一.题意分析 3个线程要按顺序执行,就要通过线程通信去控制这3个线程的执行顺序. 而线程通信的方 ...
 - mysql explain的type的
			
导语 很多情况下,有很多人用各种select语句查询到了他们想要的数据后,往往便以为工作圆满结束了.这些事情往往发生在一些学生亦或刚入职场但之前又没有很好数据库基础的小白身上,但所谓闻道有先后,只要我 ...
 - fiddler修改请求参数
			
1.打开fiddler ,点击界面左侧左侧底部 2.此图标为before request请求(修改请求参数时,设置这个,可以修改请求参数) 3..再次点击该按钮,将图标切换到下图after respo ...
 - C++中为什么按两次ctrl+D才能结束标准I/O
			
参考资料: https://www.douban.com/group/topic/127062773/ 今天学习了C++语言的标准I/O,也就是std::cin和std::cout,但是我发现当系统在 ...