从零开始实现ASP.NET Core MVC的插件式开发(七) – 近期问题汇总及部分解决方案
标题:从零开始实现ASP.NET Core MVC的插件式开发(七) – 近期问题汇总及部分问题解决方案
作者:Lamond Lu
地址://www.cnblogs.com/lwqlun/p/12930713.html
源代码://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的插件式开发(六) – 如何加载插件引用
简介
在上一篇中,我给大家讲解插件引用程序集的加载问题,在加载插件的时候,我们不仅需要加载插件的程序集,还需要加载插件引用的程序集。在上一篇写完之后,有许多小伙伴联系到我,提出了各种各样的问题,在这里谢谢大家的支持,你们就是我前进的动力。本篇呢,我就对这其中的一些主要问题进行一下汇总和解答。
如何在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中调试运行插件以及如何实现插件间的消息传输。后续我会根据反馈,继续添加新内容,大家敬请期待。