MVC插件实现

      本人第一篇随笔,在园子里逛了这么久,今天也记录一篇自己的劳动成果,也是给自己以后留个记录。

    最近领导让我搞一下插件化,就是实现多个web工程通过配置文件进行组装。之前由于做过一个简单的算是有点经验,当时使用的不是area,后来通过翻看orchard源码有点启发,打算使用area改一下。

    实现插件化,需要解决四个问题:

          1、如何发现插件以及加载插件及其所依赖的dll

          2、如何注册路由,正确调用插件的Controller和Action

          3、如何实现ViewEngine,正确的发现View

          4、页面中的Url如何自动生成

 以下下我们带着这四个问题依次分析解决:

 1、如何发现插件以及加载插件及其所依赖的dll

     该问题我完全使用了Nop插件的实现方式,为每个工程定义一个Plugin.txt配置文件,运行时通过注册[assembly: PreApplicationStartMethod(typeof(PluginManager), "Initialize")]这个方法,在Application_Start()之前发现和加载插件。PluginManager负责管理加载插件,通过解析Plugin.txt,识别插件的dll和它所依赖的dll。通过Assembly.Load()方法加载dll并使用BuildManager.AddReferencedAssembly(shadowCopiedAssembly)为web项目动态添加引用。由于web项目存在不同的信任级别,在FullTrust级别可以将这些dll直接拷贝到AppDomain.CurrentDomain.DynamicDirectory文件夹下面。但是在其他信任级别下无法访问该目录,Nop通过复制到一个临时目录并在web.config中修改 <probingprivatePath="Plugins/bin/" />的值来让iis自动探索该目录。

代码如下:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;

namespace Framework.Core.Plugins
{
   public class Plugin
    {
        /// <summary>
        /// 插件名称,唯一标识
        /// </summary>
        public string PluginName { get; set; }

        /// <summary>
        /// 插件显示名称
        /// </summary>
        public virtual string PluginFriendlyName { get; set; }

        /// <summary>
        /// 插件主文件(DLL)名称
        /// </summary>
        public string PluginFileName { get; set; }

        /// <summary>
        /// 插件控制器命名空间
        /// </summary>
        public string ControllerNamespace { get; set; }

        /// <summary>
        /// 插件主文件文件信息
        /// </summary>
        public virtual FileInfo PluginFileInfo { get; internal set; }

        /// <summary>
        /// 插件程序集
        /// </summary>
        public virtual Assembly ReferencedAssembly { get; internal set; }

        /// <summary>
        /// 描述
        /// </summary>
        public virtual string Description { get; set; }


        /// <summary>
        /// 显示顺序
        /// </summary>
        public virtual int DisplayOrder { get; set; }

        /// <summary>
        /// 是否已安装
        /// </summary>
        public virtual bool Installed { get; set; }
    }
}
View Code
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Web;
using System.Web.Compilation;
using Framework.Core.Plugins;
using Framework.Core.Infrastructure;

[assembly: PreApplicationStartMethod(typeof(PluginManager), "Initialize")]
namespace Framework.Core.Plugins
{
    public class PluginManager
    {
        #region Const

        private const string InstalledPluginsFilePath = "~/App_Data/InstalledPlugins.txt";
        private const string PluginsPath = "~/Plugins";
        private const string ShadowCopyPath = "~/Plugins/bin";

        #endregion

        #region Fields

        private static readonly ReaderWriterLockSlim Locker = new ReaderWriterLockSlim();
        private static DirectoryInfo _shadowCopyFolder;
        private static bool _clearShadowDirectoryOnStartup;

        #endregion

        #region Methods

        public static IEnumerable<Plugin> ReferencedPlugins { get; set; }

        /// <summary>
        /// 初始化插件
        /// </summary>
        public static void Initialize()
        {
            using (new WriteLockDisposable(Locker))
            {
                var pluginFolder = new DirectoryInfo(CommonHelper.MapPath(PluginsPath));
                _shadowCopyFolder = new DirectoryInfo(CommonHelper.MapPath(ShadowCopyPath));
                var referencedPlugins = new List<Plugin>();
              
                _clearShadowDirectoryOnStartup = !String.IsNullOrEmpty(ConfigurationManager.AppSettings["ClearPluginsShadowDirectoryOnStartup"]) &&
                   Convert.ToBoolean(ConfigurationManager.AppSettings["ClearPluginsShadowDirectoryOnStartup"]);

                try
                {
                    //获取已经加载的插件名称
                    var installedPluginNames = PluginFileParser.ParseInstalledPluginsFile(GetInstalledPluginsFilePath());

                    Debug.WriteLine("创建临时目录");
                    Directory.CreateDirectory(pluginFolder.FullName);
                    Directory.CreateDirectory(_shadowCopyFolder.FullName);

                    //获取临时目录中的dll文件
                    var binFiles = _shadowCopyFolder.GetFiles("*", SearchOption.AllDirectories);
                    if (_clearShadowDirectoryOnStartup)
                    {
                        //清除临时目录中的数据
                        foreach (var f in binFiles)
                        {
                            Debug.WriteLine("删除文件: " + f.Name);
                            try
                            {
                                File.Delete(f.FullName);
                            }
                            catch (Exception exc)
                            {
                                Debug.WriteLine("删除文件异常: " + f.Name + ".  异常信息: " + exc);
                            }
                        }
                    }

                    //加载插件
                    foreach (var dfd in GetPluginFilesAndPlugins(pluginFolder))
                    {
                        var pluginFile = dfd.Key;
                        var plugin = dfd.Value;
                        //验证插件名称
                        if (String.IsNullOrWhiteSpace(plugin.PluginName))
                            throw new Exception(string.Format("插件:'{0}' 没有设置名称. 请设置唯一的PluginName,重新编译.", pluginFile.FullName));
                        if (referencedPlugins.Contains(plugin))
                            throw new Exception(string.Format("插件名称:'{0}' 已经被占用,请重新设置唯一的PluginName,重新编译", plugin.PluginName));

                        //设置是否已经安装
                        plugin.Installed = installedPluginNames
                            .FirstOrDefault(x => x.Equals(plugin.PluginName, StringComparison.InvariantCultureIgnoreCase)) != null;

                        try
                        {
                            if (pluginFile.Directory == null)
                                throw new Exception(string.Format("'{0}'插件目录无效,无法解析插件dll文件", pluginFile.Name));

                            //获取插件中的所有DLL
                            var pluginDLLs = pluginFile.Directory.GetFiles("*.dll", SearchOption.AllDirectories)
                                //just make sure we're not registering shadow copied plugins
                                .Where(x => !binFiles.Select(q => q.FullName).Contains(x.FullName))
                                .Where(x => IsPackagePluginFolder(x.Directory))
                                .ToList();

                            //获取主插件文件
                            var mainPluginDLL = pluginDLLs
                                .FirstOrDefault(x => x.Name.Equals(plugin.PluginFileName, StringComparison.InvariantCultureIgnoreCase));
                            plugin.PluginFileInfo = mainPluginDLL;

                            //复制主文件到临时目录,并加载主文件
                            plugin.ReferencedAssembly = PerformFileDeploy(mainPluginDLL);

                            //加载其他插件相关dll
                            foreach (var dll in pluginDLLs
                                .Where(x => !x.Name.Equals(mainPluginDLL.Name, StringComparison.InvariantCultureIgnoreCase))
                                .Where(x => !IsAlreadyLoaded(x)))
                                    PerformFileDeploy(dll);
                            referencedPlugins.Add(plugin);
                        }
                        catch (ReflectionTypeLoadException ex)
                        {
                            var msg = string.Format("Plugin '{0}'. ", plugin.PluginFriendlyName);
                            foreach (var e in ex.LoaderExceptions)
                                msg += e.Message + Environment.NewLine;

                            var fail = new Exception(msg, ex);
                            throw fail;
                        }
                        catch (Exception ex)
                        {
                            var msg = string.Format("Plugin '{0}'. {1}", plugin.PluginFriendlyName, ex.Message);
                            var fail = new Exception(msg, ex);
                            throw fail;
                        }
                    }
                }
                catch (Exception ex)
                {
                    var msg = string.Empty;
                    for (var e = ex; e != null; e = e.InnerException)
                        msg += e.Message + Environment.NewLine;

                    var fail = new Exception(msg, ex);
                    throw fail;
                }


                ReferencedPlugins = referencedPlugins;

            }
        }

        /// <summary>
        /// 安装插件
        /// </summary>
        /// <param name="pluginName">插件名称</param>
        public static void MarkPluginAsInstalled(string pluginName)
        {
            if (String.IsNullOrEmpty(pluginName))
                throw new ArgumentNullException("pluginName");

            var filePath = CommonHelper.MapPath(InstalledPluginsFilePath);
            if (!File.Exists(filePath))
                using (File.Create(filePath))
                {
                  
                }


            var installedPluginSystemNames = PluginFileParser.ParseInstalledPluginsFile(GetInstalledPluginsFilePath());
            bool alreadyMarkedAsInstalled = installedPluginSystemNames
                                .FirstOrDefault(x => x.Equals(pluginName, StringComparison.InvariantCultureIgnoreCase)) != null;
            if (!alreadyMarkedAsInstalled)
                installedPluginSystemNames.Add(pluginName);
            PluginFileParser.SaveInstalledPluginsFile(installedPluginSystemNames,filePath);
        }

        /// <summary>
        /// 卸载插件
        /// </summary>
        /// <param name="pluginName">插件名称</param>
        public static void MarkPluginAsUninstalled(string pluginName)
        {
            if (String.IsNullOrEmpty(pluginName))
                throw new ArgumentNullException("pluginName");

            var filePath = CommonHelper.MapPath(InstalledPluginsFilePath);
            if (!File.Exists(filePath))
                using (File.Create(filePath))
                {
                   
                }

            var installedPluginSystemNames = PluginFileParser.ParseInstalledPluginsFile(GetInstalledPluginsFilePath());
            bool alreadyMarkedAsInstalled = installedPluginSystemNames
                                .FirstOrDefault(x => x.Equals(pluginName, StringComparison.InvariantCultureIgnoreCase)) != null;
            if (alreadyMarkedAsInstalled)
                installedPluginSystemNames.Remove(pluginName);
            PluginFileParser.SaveInstalledPluginsFile(installedPluginSystemNames,filePath);
        }

        /// <summary>
        /// 卸载所有插件
        /// </summary>
        public static void MarkAllPluginsAsUninstalled()
        {
            var filePath = CommonHelper.MapPath(InstalledPluginsFilePath);
            if (File.Exists(filePath))
                File.Delete(filePath);
        }

        #endregion

        #region 工具

        /// <summary>
        ///获取指定目录下的所有插件文件(Plugin.text)和插件信息(Plugin)
        /// </summary>
        /// <param name="pluginFolder">Plugin目录</param>
        /// <returns>插件文件和插件</returns>
        private static IEnumerable<KeyValuePair<FileInfo, Plugin>> GetPluginFilesAndPlugins(DirectoryInfo pluginFolder)
        {
            if (pluginFolder == null)
                throw new ArgumentNullException("pluginFolder");

            var result = new List<KeyValuePair<FileInfo, Plugin>>();
            //add display order and path to list
            foreach (var descriptionFile in pluginFolder.GetFiles("Plugin.txt", SearchOption.AllDirectories))
            {
                if (!IsPackagePluginFolder(descriptionFile.Directory))
                    continue;

                //解析插件配置文件
                var plugin = PluginFileParser.ParsePluginFile(descriptionFile.FullName);
                result.Add(new KeyValuePair<FileInfo, Plugin>(descriptionFile, plugin));
            }
            //插件排序,数字越低排名越高
            result.Sort((firstPair, nextPair) => firstPair.Value.DisplayOrder.CompareTo(nextPair.Value.DisplayOrder));
            return result;
        }

        /// <summary>
        /// 判断程序集是否已经加载
        /// </summary>
        /// <param name="fileInfo">程序集文件</param>
        /// <returns>Result</returns>
        private static bool IsAlreadyLoaded(FileInfo fileInfo)
        {

            try
            {
                string fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileInfo.FullName);
                if (fileNameWithoutExt == null)
                    throw new Exception(string.Format("无法获取文件名:{0}", fileInfo.Name));
                foreach (var a in AppDomain.CurrentDomain.GetAssemblies())
                {
                    string assemblyName = a.FullName.Split(new[] { ',' }).FirstOrDefault();
                    if (fileNameWithoutExt.Equals(assemblyName, StringComparison.InvariantCultureIgnoreCase))
                        return true;
                }
            }
            catch (Exception exc)
            {
                Debug.WriteLine("无法判断程序集是否加载。" + exc);
            }
            return false;
        }

        /// <summary>
        ///执行解析文件
        /// </summary>
        /// <param name="plug">插件文件</param>
        /// <returns>Assembly</returns>
        private static Assembly PerformFileDeploy(FileInfo plug)
        {
            if (plug.Directory.Parent == null)
                throw new InvalidOperationException("插件" + plug.Name + ":目录无效" );

            FileInfo shadowCopiedPlug;

            if (CommonHelper.GetTrustLevel() != AspNetHostingPermissionLevel.Unrestricted)
            {
                //运行在MediumTrust下(在MediumTrust下无法访问DynamicDirectory,也无法设置ResolveAssembly event)
                //需要将所有插件dll都需要拷贝到~/Plugins/bin/下的临时目录,因为web.config中的probingPaths设置的是该目录
                var shadowCopyPlugFolder = Directory.CreateDirectory(_shadowCopyFolder.FullName);
                shadowCopiedPlug = InitializeMediumTrust(plug, shadowCopyPlugFolder);
            }
            else
            {
                //运行在FullTrust下,可以直接使用标准的DynamicDirectory文件夹,作为临时目录
                var directory = AppDomain.CurrentDomain.DynamicDirectory;
                Debug.WriteLine(plug.FullName + " to " + directory);
                shadowCopiedPlug = InitializeFullTrust(plug, new DirectoryInfo(directory));
            }

            //加载程序集
            var shadowCopiedAssembly = Assembly.Load(AssemblyName.GetAssemblyName(shadowCopiedPlug.FullName));

            //添加引用信息到BuildManager
            Debug.WriteLine("添加到BuildManager: '{0}'", shadowCopiedAssembly.FullName);
            BuildManager.AddReferencedAssembly(shadowCopiedAssembly);

            return shadowCopiedAssembly;
        }

        /// <summary>
        /// FullTrust级别下的插件初始化
        /// </summary>
        /// <param name="plug"></param>
        /// <param name="shadowCopyPlugFolder"></param>
        /// <returns></returns>
        private static FileInfo InitializeFullTrust(FileInfo plug, DirectoryInfo shadowCopyPlugFolder)
        {
            var shadowCopiedPlug = new FileInfo(Path.Combine(shadowCopyPlugFolder.FullName, plug.Name));
            try
            {
                File.Copy(plug.FullName, shadowCopiedPlug.FullName, true);
            }
            catch (IOException)
            {
                Debug.WriteLine(shadowCopiedPlug.FullName + " 文件已被锁, 尝试重命名");
                //可能被 devenv锁住,可以通过重命名来解锁
                try
                {
                    var oldFile = shadowCopiedPlug.FullName + Guid.NewGuid().ToString("N") + ".old";
                    File.Move(shadowCopiedPlug.FullName, oldFile);
                }
                catch (IOException exc)
                {
                    throw new IOException(shadowCopiedPlug.FullName + " 重命名失败, 无法初始化插件", exc);
                }
                //重新尝试复制
                File.Copy(plug.FullName, shadowCopiedPlug.FullName, true);
            }
            return shadowCopiedPlug;
        }

        /// <summary>
        ///  MediumTrust级别下的插件初始化
        /// </summary>
        /// <param name="plug"></param>
        /// <param name="shadowCopyPlugFolder"></param>
        /// <returns></returns>
        private static FileInfo InitializeMediumTrust(FileInfo plug, DirectoryInfo shadowCopyPlugFolder)
        {
            var shouldCopy = true;
            var shadowCopiedPlug = new FileInfo(Path.Combine(shadowCopyPlugFolder.FullName, plug.Name));

            //检查插件是否存在,如果存在,判断是否需要更新
            if (shadowCopiedPlug.Exists)
            {
                var areFilesIdentical = shadowCopiedPlug.CreationTimeUtc.Ticks >= plug.CreationTimeUtc.Ticks;
                if (areFilesIdentical)
                {
                    Debug.WriteLine("插件已经存在,不需要更新: '{0}'", shadowCopiedPlug.Name);
                    shouldCopy = false;
                }
                else
                {
                    //删除现有插件
                    Debug.WriteLine("有新插件; 删除现有插件: '{0}'", shadowCopiedPlug.Name);
                    File.Delete(shadowCopiedPlug.FullName);
                }
            }

            if (shouldCopy)
            {
                try
                {
                    File.Copy(plug.FullName, shadowCopiedPlug.FullName, true);
                }
                catch (IOException)
                {
                    Debug.WriteLine(shadowCopiedPlug.FullName + " 文件已被锁, 尝试重命名");
                    //可能被 devenv锁住,可以通过重命名来解锁
                    try
                    {
                        var oldFile = shadowCopiedPlug.FullName + Guid.NewGuid().ToString("N") + ".old";
                        File.Move(shadowCopiedPlug.FullName, oldFile);
                    }
                    catch (IOException exc)
                    {
                        throw new IOException(shadowCopiedPlug.FullName + " 重命名失败, 无法初始化插件", exc);
                     
                    }
                    //重新尝试复制
                    File.Copy(plug.FullName, shadowCopiedPlug.FullName, true);
                }
            }

            return shadowCopiedPlug;
        }
        
        /// <summary>
        ///判断文件是否属于插件目录下的文件(Plugins下)
        /// </summary>
        /// <param name="folder"></param>
        /// <returns></returns>
        private static bool IsPackagePluginFolder(DirectoryInfo folder)
        {
            if (folder == null) return false;
            if (folder.Parent == null) return false;
            if (!folder.Parent.Name.Equals("Plugins", StringComparison.InvariantCultureIgnoreCase)) return false;
            return true;
        }

        /// <summary>
        /// 获取InstalledPlugins.txt文件的物理路径
        /// </summary>
        /// <returns></returns>
        private static string GetInstalledPluginsFilePath()
        { 
            return CommonHelper.MapPath(InstalledPluginsFilePath);
        }

        #endregion
    }
}
View Code

 

2、如何注册路由,正确调用插件的Controller和Action

    路由我通过扩展现Mvc的RouteCollection的MapRoute方法,将插件名称作为area强行插入到DataToken中,这样在ViewEngine中可以使用area规则来发现视图。然后重写RegisterRoutes方法,通过遍历所有插件集合,添加指定的路由,并将所有插件的Controller的命名空间写入到插件匹配模式中,这样可以解决不同插件之间Controller重名的问题。

  public static Route MapRoute(this RouteCollection routes, string name, string url, object defaults, object constraints, string[] namespaces,string area)
        {
            if (routes == null)
            {
                throw new ArgumentNullException("routes");
            }
            if (url == null)
            {
                throw new ArgumentNullException("url");
            }

            Route route = new Route(url, new MvcRouteHandler())
            {
                Defaults = new RouteValueDictionary(defaults),
                Constraints = new RouteValueDictionary(constraints),
                DataTokens = new RouteValueDictionary()
            };

            if ((namespaces != null) && (namespaces.Length > 0))
            {
                route.DataTokens["Namespaces"] = namespaces;
            }

            if (!string.IsNullOrEmpty(area))
            {
                route.DataTokens["area"] = area;
            }

            routes.Add(name, route);

            return route;
        }
View Code
        public static void RegisterPluginRoutes(RouteCollection routes)
        {

            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            foreach (var plugin in PluginManager.ReferencedPlugins)
            {
    
                routes.MapRoute(plugin.PluginName,
                    string.Concat(plugin.PluginName, "/{controller}/{action}/{id}"),
                    new { area= plugin.PluginName, controller = "Home", action = "Index", id = UrlParameter.Optional },
                   new string[]{ plugin.ControllerNamespace}, plugin.PluginName);
            }

            routes.MapRoute(
                 name: "Default",
                 url: "{controller}/{action}/{id}",
                 defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
                 namespaces:new string[] { "GWT.Framework.Web.Controllers" }
              );
    

        }
View Code

3、如何实现ViewEngine,正确的发现View

   关于这个问题我发现Nop和Orchard中好多地方都是硬编码,通过VIEW(~/Plugin/XXX/views/XXX/XX.csthml)的方式来发现视图。不知他们是何用意,我觉这样耦合度过高。此处我通过前面路由中插入的area并配合实现一个继承自RazorViewEngine的视图引擎,将所有的插件请求定位到~/Plugins/{area}/Views/{controller}/{action}.cshtml。同时替换掉原有的视图引擎。代码如下:

 

    public class PluginViewEngine : RazorViewEngine
    {
        public PluginViewEngine()
        {


            AreaViewLocationFormats = new[] {
                "~/Areas/{2}/Views/{1}/{0}.cshtml",
                "~/Areas/{2}/Views/Shared/{0}.cshtml",
                "~/Plugins/{2}/Views/{1}/{0}.cshtml",
                "~/Plugins/{2}/Views/Shared/{0}.cshtml"
            };
            AreaMasterLocationFormats = new[] {
                "~/Areas/{2}/Views/{1}/{0}.cshtml",
                "~/Areas/{2}/Views/Shared/{0}.cshtml",
                 "~/Plugins/{2}/Views/{1}/{0}.cshtml",
                "~/Plugins/{2}/Views/Shared/{0}.cshtml"
            };
            AreaPartialViewLocationFormats = new[] {
                "~/Areas/{2}/Views/{1}/{0}.cshtml",
                "~/Areas/{2}/Views/Shared/{0}.cshtml",
                "~/Plugins/{2}/Views/{1}/{0}.cshtml",
                "~/Plugins/{2}/Views/Shared/{0}.cshtml"
            };

            FileExtensions = new[] { "cshtml" };
        }
    }
View Code
 protected void Application_Start()
        {
            ViewEngines.Engines.Clear();
            ViewEngines.Engines.Add(new PluginViewEngine());

            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            ApplicationStartup.RegisterPluginRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
View Code

 4、页面中的Url如何自动生成

   我们知道页面中的url可以使用硬编码方式比如/Home/Index,也可以使用Html.ActionLink(“Index”,“Home”)或者Url.Action方式实现。前者硬编码的方式已经不适用于插件化,因为开发者不知道是否会被用作插件,如果强行写入/Pluin1/Home/Index,势必导致本地无法运行。在插件系统中应该使用后两者,因为他们都是用过路由系统输出URL的。MVC框架会基于当前的Controller到路由系统中找到匹配的路径返回给前台页面。

   对于URL我们可以使用Html和Url帮助器生成,但是对于Script和css等内容文件MVC框架就无能为力了。为了解决内容文件的加载,我扩展了UrlHelper帮助器,根据当前的请求中是否有area来生成相对路径。代码如下

        public static string PluginContent(this UrlHelper urlHelper, string url)
        {
            if (urlHelper.RequestContext.RouteData.Values.Keys.Contains("area"))
            {
                var area = urlHelper.RequestContext.RouteData.Values["area"].ToString();
                if (!string.IsNullOrEmpty(area))
                {
                    url = url.Substring(url.IndexOf("/") + 1);
                    return string.Format("~/Plugins/{0}/{1}", area, url);
                }
            }
            return url;


        }
View Code

在页面中可以如下调用: @Url.PluginContent("/Views/Shared/_Layout.cshtml")

 

参考文档:

https://shazwazza.com/post/Developing-a-plugin-framework-in-ASPNET-with-medium-trust.aspx

http://www.cnblogs.com/longyunshiye/p/5786446.html

 

posted @ 2017-03-23 18:23  Pippen88  阅读(994)  评论(10编辑  收藏  举报