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; } } }
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 } }
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; }
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" } ); }
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" }; } }
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); }
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; }
在页面中可以如下调用: @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