您的位置:首页 > 编程语言 > ASP

Asp.Net MVC 插件化开发简化方案

2017-09-03 21:29 645 查看
Web 管理系统可以庞大到不可想像的地方,如果想就在一个 Asp.Net MVC 项目中完成开发,这个工程将会变得非常庞大,协作起来也会比较困难。为了解决这个问题,Asp.Net MVC 引入了 Areas 的概念,将模块划分到 Area 中去——然而 Area 仍然是主项目的一部分,多人协作的时候仍然很容易造成
.csproj
项目文件的冲突。对于这类系统,比较好的解决办法是采用 SOA 的方式,把一个大的 Web 系统划分成若干微服务,通过一个含授权中心的 Web 集散框架组织起来。不过这里我要讲的是另一种方法,插件化的开发方案。完整的插件化开发会涉及到插件管理的方方面面,甚至还包括插件的热插拔处理——当然这些都是可以做到的——但今天我要说的是一个简化方案,只是将业务模块当作插件在单独的项目中开发,而后在发布的时候仍然以 Area 的形式集成到主 Web 项目当中。严格的说,这并不是插件化,而只是模块化,但它是插件化的第一步。

第 1 个实验

第一个实验的目的是为了把 Area 剥离出来作为单独的项目开发。所以先使用同样版本的 .NET Framework 的 Asp.Net MVC Framework 创建两个项目,这里我们选用了.NET Framework 4.6

Microsoft.AspNet.Mvc 5.2.3

建立两个 MVC 项目,分别名为
PluginWebApp
Plugin1



PluginWebApp 项目

这个项目作为 Web 主项目,现在暂时不改它。但要检查一下
Global.asax.cs
中,
Application_Start
事件中有这么一句:
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
// ....
}
这是在注册所有 Area。虽然现在 PluginWebApp 并没有建 Area,但是这句话对于我们来说是必不可少的。

Plugin1 项目

这是作为插件的项目,我们把它当作一个 Area 来开发。所以先添加 Area。操作:在“解决方案资源管理器”中“Plugin1”项目中点击右键,选择“添加→区域(A)”,输入
Plugin1
为作 Area 名称这样,Plugin1 项目中就存在一个
Areas
目录以及其目录
Plugin1
,再把这个项目中除
Areas
目录、
packages.config
Web.config
之外的所有其它目录和文件删除,之后整个项目看起来就像这样:

注意项目中存在一个
Plugin1AreaRegistration.cs
文件,在向 Web 应用中注册 Area 的时候需要它。现在在
Controllers
目录下面添加控制器
TestController
,相应的在
Views
下面添加
Test/Index.cshtml
视图文件。内容都不重要,只要能识别出来就行,所以在
Test/Index.cshtml
中修改
<h2>
中的内容为
<h2>Testing Page Index</h2>

准备运行

AreaRegistration.RegisterAllAreas()
会在加载的 Assembly 中查找所有 Area 定义(
AreaRegistration
的子类),完成 Area 的注册。所以我们可以干两件事情来安装 Plugin把 Plugin1 项目的编译结果
Plugin1.dll
拷贝到
PluginWebApp
bin
目录下

在 PluginWebApp 项目下创建
Areas
目录,下建
Plugin1
目录,再把 Plugin1 项目的
~/Areas/Plugin1/Views
目录拷贝过来

猜测做了这些操作之后,应该可以运行 PluginWebApp,输入正常的 url 路径之后可以访问到 Plugin1 的 Test 页面。运行,并在浏览器中输入
http://localhost:5760/plugin1/test
(这里的端口号是由 VS 自动分配的,请注意修改)——结果还不错

解耦

第一个实验成功,实事证明猜想没有问题。但于对开发来说,就有问题了。插件动态库放在
PluginWebApp/bin
中,与 PluginWebApp 的编译结果混在一起了,这在以后发布、更新的时候可能造成麻烦。而且既然是插件,似乎应该独立一点,如果 Plugin1 发布的所有东西都只在
PluginWebApp/Areas/Plugin1
目录下就好了。基于这个设想,
PluginWebApp/Areas/Plugin1
目录应该会是这样一个结构:
Plugin1
|---bin
`---Views
当然,把
Plugin1.dll
拷贝到
bin
目录中去很容易,但还得让 Asp.Net 加载它。于是尝试在
Application_Start
中写了几句代码来加载
// 先不考虑任意插件的问题,只加载 Plugin1 作为实验var dll = Sever.MapPath("~/Areas/Plugin/bin/Plugin1.dll");
Assembly.LoadFile(dll);
加载是加载了,但是
http://localhost:5760/plugin1/test
打不开,失败!

使用 BuildManager 和 PreApplicationStartMethodAttribute

上网查资料之后得知需要使用
BuildManager.AddReferencedAssembly()
将加载的 Assembly 添加到引用集合中,而这个事情似乎必须在
Application_Start
之前完成
。文档里说应该在
Application_PreStartInit
阶段,不过我准备使用
PreApplicationStartMethodAttribute
来完成。为此,在 PluginWebApp 项目的
App_Start
下添加了一个
PluginInitializer
类来干这个事情:
using System.Web;
using System.Web.Hosting;
using System.Web.Compilation;

[assembly: PreApplicationStartMethod(typeof(PluginWebApp.PluginInitializer), "Initialize")]
namespace PluginWebApp
{
public static partial class PluginInitializer
{
public static void Initialize()
{
var dll = HostingEnvironment("~/Areas/Plugin1/bin/Plugin1.dll");
var assembly = Assembly.LoadFile(dll);
BuildManager.AddReferencedAssembly(assembly);
}
}
}
再次运行,成功!

搜索并加载插件

到目前为止还是直接加载的 Plugin1 插件,实际工作中应该去检查
Areas
下面的子目录,加载其
bin
目录下的动态库。所以还需要修改
PluginInitializer
,让它动态搜索各插件目录的
bin/*.dll
,并加载。为此,不妨专门写一个
PluginLoader
类,因为这个类现在只由
PluginInitializer
使用,所以直接写成它的嵌套类
public static partial class PluginInitializer
{
public sealed class PluginLoader
{
public void Load()
{
FindPluginDll(HostingEnvironment("~/Areas"))
// 并行处理不是必须的,但在插件多的时候可能会更快
.AsParallel()
.ForAll(file => BuildManager.AddReferencedAssembly(Assembly.Load(file)));
}

// 从指定的插件根目录 (这里是 Areas) 搜索带 bin 目录的插件目录
// 并将其中的 *.dll 找出来
private static string[] FindPluginDll(string root)
{
return Directory.EnumerateDirectories(root)
.Select(dir => Path.Combine(dir, "bin"))
// 如果没有 bin 目录就忽略
.Where(Directory.Exists)
// 将 bin 目录下的所有 dll 加载到集合中
.SelectMany(bin => Directory
.EnumerateFiles(bin, "*.dll", SearchOption.AllDirectories))
.ToArray();
}
}
}
动态检索的问题解决了,但在实际开发中又存在另一个问题:运行 Web 之后,再次构建插件的并将插件内容 (
bin
View
) 拷贝到主项目
Areas
下面对应的插件目录中时,会因为原来的 dll 文件在使用而不能覆盖。

解决不能在 Web 运行状态下更新插件的问题

在解决这个问题就不能让 Web 直接加载插件目录中的 dll。采用 Asp.Net 的 Shadow Copy 的思想,我们可以在
App_Data
目录中创建一个
PluginCache
目录,然后在加载插件 dll 之前把所有 dll 拷贝到这个目录下来,再从这个目录加载 dll。再来改造一下
PluginLoader
:创建目录和清空缓存都很简单,这里就不展示这两个步骤的代码了。
FindPluginDll
的代码在前面可以找到
public sealed class PluginLoader
{
string PluginFolder { get; } = HostingEnvironment.MapPath("~/Areas");
string PluginCacheFolder { get; } = HostingEnvironment.MapPath("~/App_Data/PluginCache");

public void Load()
{
// 上述两个目录不存在,则创建,保证目录存在
MakeSureFolderExists();
// 先清空缓存,避免已废弃的插件还缓存在这里
ClearCacheFolder();
// 从各插件目录把 dll 拷贝到缓存目录
CachePlugins();
// 从缓存目录加载所有 dll
LoadAssemblies();
}

private void CachePlugins()
{
// 找到所有插件的 dll
FindPluginDll(PluginFolder)
// 并行处理
.AsParallel()
.ForAll(file =>
{
var target = Path.Combine(PluginCacheFolder, Path.GetFileName(file));
// 拷贝到缓存目录
File.Copy(file, target, true);
});
}

private void LoadAssemblies()
{
// 在缓存目录中查找所有 dll
Directory.EnumerateFiles(PluginCacheFolder, "*.dll", SearchOption.AllDirectories)
// 并行
.AsParallel()
// 加载所有 assembly
.ForAll(file => BuildManager.AddReferencedAssembly(Assembly.LoadFile(file)));
}
}
搞定!

细节处理

解决 Controller 寻址冲突

主 Web 程序和多个插件之间如果存在同名的 Controller,就可能造成访问 URL 的时候出现 Controller 寻址冲突,为了解决这个问题,需要在注册路径的时候指定 Controller 的命名空间

主项目 PluginWebApp 的
App_Start/RouteConfig.cs

public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
namespaces: new[] { "PluginWebApp.Controllers" }    // 加了这句话
);
}

插件的
Plugin1AreaRegistration.cs

public override void RegisterArea(AreaRegistrationContext context)
{
context
.MapRoute(
"Plugin1_default",
"Plugin1/{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = UrlParameter.Optional },
new[] { "Plugin1.Areas.Plugin1.Controllers" }); // 加了这一句
}

处理删除或拷贝 dll 文件时可能出现的异常

在作为
ForAll
的 Lambda 表达式中,每次删除文件或拷贝文件都有可能出现异常,而出现这些异常的时候,不应该中断整个处理过程,所以需要使用
try ... catch
来处理异常。正常的处理方式应该是记录日志,这里偷个懒,直接忽略(生产环境严重不推荐忽略异常)。由于这个操作在几个地方都会用到,所以写一个
IgnoreError
来封装 Lambda:
private static Action<T> IgnoreError<T>(Action<T> action)
{
return arg =>
{
try
{
action(arg);
}
catch
{
// ignore exceptions,
// should log the error in production environment
}
};
}
然后在
ForAll
中这样使用:
.ForAll(IgnoreError<string>(file => DealWithFile(file)));

后记

上述内容充其量只是一个插件化开发的简化方案。不过这个方案基本上也把一个插件化框架的结构介绍清楚了。而且采用这种方式开发还有一个好处:Plugin1 本身就是一个 Web 项目,所以如果之前不删除那么多东西,并加以适当的调整,它是可以独立运行的,便于开发期调试。当然这个框架要用于工作中还需要完善不少工作,包括:定义插件接口和抽象基类,提供初始化,注入上下文(比如应用配置等),注册路由等接口方法。

主项目或框架项目中定义插件管理器,管理插件的生命周期,实现热插拔
加载、注册

检查更新、新增插件等

卸载插件 Assembly 并重新加载

使用 Plugins 代替 Areas 目录,让插件与 Area 区分开来,这需要
在插件管理器中实现
AreaRegistration.RegisterAllAreas()
的一些功能

Plugins
目录添加到 Razor 视图搜索路径中 (需要自定义
RazorViewEngine
)

设计插件间的资源共享和通信机制

插件管理的 UI 或 CLI

源代码

on Gitee.com

参考

ASP.NET 插件化机制

ASP.NET MVC 4 插件化架构简单实现-实例篇




欢迎关注作者的开发技术微信公众号



内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  asp net 插件化