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

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 项目,分别名为 PluginWebAppPlugin1

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.configWeb.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 拷贝到 PluginWebAppbin 目录下
  • 在 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 之后,再次构建插件的并将插件内容 (binView) 拷贝到主项目 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

源代码

参考



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

时间: 2024-10-16 14:09:45

Asp.Net MVC 插件化开发简化方案的相关文章

ASP.NET MVC 插件化机制

概述 nopCommerce的插件机制的核心是使用BuildManager.AddReferencedAssembly将使用Assembly.Load加载的插件程序集添加到应用程序域的引用中.具体实现可以参考nopCommerce解决方案中Nop.Core项目的Plugins目录下的相关文件.其中PluginManager.cs文件是核心文件,包含了处理插件化的核心代码.nopCommerce的注释中感谢了其参考的Umbraco项目,并给出了主要参考文章的链接. 我们直接从nopCommerce

.NET MVC插件化开发(支持Script和css压缩)

上一篇博文里面,没有支持Script和css的压缩功能以及script和css的路径问题也没有解决,所以重新发布一个版本,解决了这几个问题,并且优化了插件路由注册,现在可以很方便的实现热插拔web插件,但web插件热插拔需要对路由表进行修改,如果是线上项目,热插拔期间可能会有请求异常,所以暂时不提供web热插拔,插件安装应该有计划的在维护期间进行,如果大家都希望启用web插件热插拔,我可以很快提供给大家,大家可以一起多多沟通,毕竟我个人的见解有限. 这里先简单介绍下新版改动: 大家看插件里的这两

Android组件化和插件化开发

http://www.cnblogs.com/android-blogs/p/5703355.html 什么是组件化和插件化? 组件化开发就是将一个app分成多个模块,每个模块都是一个组件(Module),开发的过程中我们可以让这些组件相互依赖或者单独调试部分组件等,但是最终发布的时候是将这些组件合并统一成一个apk,这就是组件化开发.插件化开发和组件化开发略有不用,插件化开发时将整个app拆分成很多模块,这些模块包括一个宿主和多个插件,每个模块都是一个apk(组件化的每个模块是个lib),最终

asp.net mvc+jquery easyui开发实战教程之网站后台管理系统开发4- 后台模板html页面创建

上一篇教程<asp.net mvc+jquery easyui开发实战教程之网站后台管理系统开发3-登录模块开发>完成了本项目的登录模块,登录后就需要进入后台管理首页了,需要准备一个后台模板,本文主要讲解如何创建这个后台模板,本文创建的后台模板不集成到项目内部,只是静态html页面.后台模板是系统开发必须的,一般小公司有个3套后台模板就够用了.本项目后台模板主要是jquery easyui插件创建的,不需要美工设计就可以创建出来,而且效果还可以,具体效果图如下: 这个版面的缺点是,只能适应2层

Android插件化开发---运行未安装apk中的Service

如果你还不知道什么叫插件化开发,那么你应该先读一读之前写的这篇博客:Android插件化开发,初入殿堂 上一篇博客主要从整体角度分析了一下Android插件化开发的几个难点与动态加载没有被安装的apk中的Activity和资源的方法.其实一般的插件开发主要也就是加载个Activity,读取一些资源图片之类的.但是总有遇到特殊情况的时候,比如加载Service. 要动态加载Service,有两种思路:一是通过NDK的形式,将Service通过C++运行起来(这种方法我没有尝试,只听群里的朋友说实现

MVC 插件式开发

在开发一个OA系统是,我们可能遇到 A模块. B模块 .C模块,这也模块组成一个完整的系统,买给客服.现在又有一个客服要我们做一个OA系统,唉我们发现,跟上一个OA系统差不多,但没有C模块.怎么办? 修改源码,系统简单还好,但是一系统复杂到一定程度,修改源码改这改这就像重写了! 怎么办,MVC插件式开发帮你解决问题,先看演示,再看代码.CCAV.WebSite 是主站,引用 CCAV.Modules.Category CCAV.Modules.Category 就像当于一个模块,具体看演示. 通

MVC 插件化框架支持原生MVC的Area和路由特性

.NET MVC 插件化框架支持原生MVC的Area和路由特性 前面开放的源码只是简单的Plugin的实现,支持了插件的热插拔,最近晚上偶然想到,原生的MVC提供Areas和RouteAtrribute等路由特性标签,按照先前的做法,无法解析插件的路由特性和Areas,所以花费了两个晚上的时间,把插件给改进到支持Areas和路由特性,但同时也放弃了Web类插件的热插拔,Func类的插件依然支持热插拔. 下面是实现支持插件使用Areas和路由特性标签的流程: 原生的MVC在启动的时候需要执行两条代

Android 使用动态加载框架DL进行插件化开发

如有转载,请声明出处: 时之沙: http://blog.csdn.net/t12x3456    (来自时之沙的csdn博客) 概述: 随着应用的不断迭代,应用的体积不断增大,项目越来越臃肿,冗余增加.项目新功能的添加,无法确定与用户匹配性,发生严重异常往往牵一发而动全身,只能紧急发布补丁版本,强制用户进行更新.结果频繁的更新,反而容易降低用户使用黏性.或者是公司业务的不断发展,同系的应用越来越多,传统方式需要通过用户量最大的主项目进行引导下载并安装. 怎么办?参考浏览器-插件开发模式: 一.

Android 插件化开发-主题皮肤更换

参考 http://www.2cto.com/kf/201501/366859.html 本项目是以插件化开发思想进行的,主要工作和代码如下 资源文件,这里以color资源为例 1.首先我们需要准备一个皮肤包,这个皮肤包里面不会包含任何Activity,里面只有资源文件,这里我为了简单,仅仅加入一个color.xml(其实就相当于Android系统中的framework_res.apk) <!--?xml version="1.0" encoding="utf-8&qu