Web 管理系统能够庞大到不可想像的地方,若是想就在一个 Asp.Net MVC 项目中完成开发,这个工程将会变得很是庞大,协做起来也会比较困难。为了解决这个问题,Asp.Net MVC 引入了 Areas 的概念,将模块划分到 Area 中去——然而 Area 仍然是主项目的一部分,多人协做的时候仍然很容易形成 .csproj
项目文件的冲突。html
对于这类系统,比较好的解决办法是采用 SOA 的方式,把一个大的 Web 系统划分红若干微服务,经过一个含受权中心的 Web 集散框架组织起来。不过这里我要讲的是另外一种方法,插件化的开发方案。git
完整的插件化开发会涉及到插件管理的方方面面,甚至还包括插件的热插拔处理——固然这些都是能够作到的——但今天我要说的是一个简化方案,只是将业务模块看成插件在单独的项目中开发,然后在发布的时候仍然以 Area 的形式集成到主 Web 项目当中。严格的说,这并非插件化,而只是模块化,但它是插件化的第一步。web
第一个实验的目的是为了把 Area 剥离出来做为单独的项目开发。因此先使用一样版本的 .NET Framework 的 Asp.Net MVC Framework 建立两个项目,这里咱们选用了浏览器
创建两个 MVC 项目,分别名为 PluginWebApp
和 Plugin1
。缓存
这个项目做为 Web 主项目,如今暂时不改它。但要检查一下 Global.asax.cs
中,Application_Start
事件中有这么一句:架构
protected void Application_Start() { AreaRegistration.RegisterAllAreas(); // .... }
这是在注册全部 Area。虽然如今 PluginWebApp 并无建 Area,可是这句话对于咱们来讲是必不可少的。mvc
这是做为插件的项目,咱们把它看成一个 Area 来开发。因此先添加 Area。框架
操做:在“解决方案资源管理器”中“Plugin1”项目中点击右键,选择“添加→区域(A)”,输入
Plugin1
为做 Area 名称asp.net
这样,Plugin1 项目中就存在一个 Areas
目录以及其目录 Plugin1
,再把这个项目中除 Areas
目录、packages.config
和 Web.config
以外的全部其它目录和文件删除,以后整个项目看起来就像这样:ide
注意项目中存在一个 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.dll
拷贝到 PluginWebApp
的 bin
目录下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.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 直接加载插件目录中的 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))); } }
搞定!
主 Web 程序和多个插件之间若是存在同名的 Controller,就可能形成访问 URL 的时候出现 Controller 寻址冲突,为了解决这个问题,须要在注册路径的时候指定 Controller 的命名空间
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" }); // 加了这一句 }
在做为 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 项目,因此若是以前不删除那么多东西,并加以适当的调整,它是能够独立运行的,便于开发期调试。
固然这个框架要用于工做中还须要完善很多工做,包括:
主项目或框架项目中定义插件管理器,管理插件的生命周期,实现热插拔
使用 Plugins 代替 Areas 目录,让插件与 Area 区分开来,这须要
AreaRegistration.RegisterAllAreas()
的一些功能Plugins
目录添加到 Razor 视图搜索路径中 (须要自定义 RazorViewEngine
)