本人第一篇隨筆,在園子里逛了這么久,今天也記錄一篇自己的勞動成果,也是給自己以后留個記錄。
最近領導讓我搞一下插件化,就是實現多個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
