zoukankan      html  css  js  c++  java
  • MVC插件

    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

  • 相关阅读:
    Mac 10.12安装Atom文本增强编辑工具
    Mac 10.12安装SecureCRT
    Mac 10.12安装WebStorm
    Mac 10.12安装Command+Q误按提示工具
    Mac 10.12安装FTP工具FileZilla
    Mac 10.12安装VirtualBox
    Mac 10.12安装数据库管理工具MySQL Workbench
    Mac 10.12安装Homebrew图形化界面管理工具Cakebrew
    Mac 10.12安装图片切换工具ArcSoft Photo+
    Mac 10.12安装Git管理工具SourceTree
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/6624426.html
Copyright © 2011-2022 走看看