# 在ASP.NET MVC应用中开发插件框架（中英对照）

[译文] 在ASP.NET MVC应用中开发一个插件框架

I’ve recently spent quite a lot of time researching and prototyping different ways to create a plugin engine in ASP.NET MVC3 and primarily finding a nice way to load plugins (DLLs) in from outside of the ‘bin’ folder. Although this post focuses on MVC3, I am sure that the same principles will apply for other MVC versions.

The Issues

Loading DLLs from outside of the ‘bin’ folder isn’t really anything new or cutting edge, however when working with MVC this becomes more difficult. This is primarily due to how MVC loads/finds types that it needs to process including controllers, view models (more precisely the generic argument passed to a ViewPage or used with the @model declaration in Razor), model binders, etc… MVC is very tied to the BuildManager which is the mechanism for compiling views, and locating other services such as controllers. By default the BuildManager is only familiar with assembies in the ‘bin’ folder and in the GAC, so if you start putting DLLs in folders outside of the ‘bin’ then it won’t be able to locate the MVC services and objects that you might want it to be referencing.

Another issue that needs to be dealt with is DLL file locking. When a plugin DLL is loaded and is in use the CLR will lock the file. This becomes an issue if developers want to update the plugin DLL while the website is running since they won’t be able to unless they bump the web.config or take the site down. This holds true for Managed Extensibility Framework (MEF) and how it loads DLLs as well.

"bin"目录以外加载DLLs已不是什么新鲜、前沿的事儿，然而使用MVC时，这将变得更加困难。这主要是由于在处理控制器、模型视图（更准确的说是给ViewPage传递参数或者在Razor中使用@model定义）、模型绑定等等的过程中加载/查找类型的方式引起的。与BuildManager的机制息息相关，BuildManager是编译视图和定位诸如控制器等其他服务的机制。默认情况下，BuildManager只对"bin"目录和全局程序集中的程序集感冒，所以如果一开始就将需要引用的服务和对象的DLLs放置在"bin"目录之外，MVC将不能定位到它们。

.Net 4 to the rescue… almost

One of the new features in .Net 4 is the ability to execute code before the app initializes which compliments another new feature of the BuildManager that lets you add assembly references to it at runtime (which must be done on application pre-init). Here’s a nice little reference to these new features from Phil Haack: http://haacked.com/archive/2010/05/16/three-hidden-extensibility-gems-in-asp-net-4.aspx.  This is essential to making a plugin framework work with MVC so that the BuildManager knows where to reference your plugin DLLs outside of the ‘bin’. However, this isn’t the end of the story.

.Net 4的救援措施

.Net 4中的一个新功能是在应用程序初始化之前执行代码的能力，它与BuildManager的另一个新功能相辅相成，它允许您在运行时向其添加程序集引用（必须在应用程序预启动时完成）。 以下是Phil Haack对这些新功能的一个很好的参考：http：//haacked.com/archive/2010/05/16/three-hidden-extensibility-gems-in-asp-net-4.aspx。 这对于使插件框架与MVC一起工作至关重要，以便BuildManager知道在"bin"目录之外引用插件DLLs的位置。 然而，故事到这里还没完。

Strongly typed Views with model Types located in plugin DLLs

Unfortunately if you have a view that is strongly typed to a model that exists outside of the ‘bin’, then you’ll find out very quickly that it doesn’t work and it won’t actually tell you why. This is because the RazorViewEngine  uses the BuildManager to compile the view into a dynamic assembly but then uses Activator.CreateInstance to instantiate the newly compiled object. This is where the problem lies, the current AppDomain doesn’t know how to resolve the model Type for the strongly typed view since it doesn’t exist in the ‘bin’ or GAC.  An even worse part about this scenario is that you don’t get any error message telling you why this isn’t working, or where the problem is. Instead you get the nice MVC view not found error: “…or its master was not found or no view engine supports the searched locations. The following locations were searched: ….” telling you that it has searched for views in all of the ViewEngine locations and couldn’t find it… which is actually not the error at all.  Deep in the MVC3 source, it tries to instantiate the view object from the dynamic assembly and it fails so it just keeps looking for that view in the rest of the ViewEngine paths.

NOTE: Even though in MVC3 there’s a new IViewPageActivator which should be responsible for instantiating the views that have been compiled with the BuildManager, implementing a custom IViewPageActivator to handle this still does not work because somewhere in the MVC3 codebase fails before the call to the IViewPageActivator which has to do with resolving an Assembly that is not in the ‘bin’.

Full trust

When working in Full Trust we have a few options for dealing with the above scenario:

•  Use the AppDomain’s ResolveAssembly event
•  By subscribing to this event, you are able to instruct the AppDomain where to look when it can’t find a reference to a Type.
•  This is easily done by checking if your plugin assemblies match the assembly being searched for, and then returning the Assembly object if found:
static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
var pluginsFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/Plugins"));
return (from f in pluginsFolder.GetFiles("*.dll", SearchOption.AllDirectories)
let assemblyName = AssemblyName.GetAssemblyName(f.FullName)
where assemblyName.FullName == args.Name || assemblyName.FullName.Split(',')[0] == args.Name
}
•  This is the directory that the BuildManager compiles it’s dynamic assemblies into and is also a directory that the AppDomain looks to when resolving Type’s from Assemblies.
•  You can shadow copy your plugin DLLs to this folder on app pre-init and everything ‘should just work’
•  Replace the RazorViewEngine with a custom razor view engine that compiles views manually but makes references to the appropriate plugin DLLs
•  I actually had this working in an Umbraco v5 prototype but it is hugely overkill and unnecessary plus you actually would have to replace the RazorViewEngine which is pretty absurd.

Full Trust中工作时，我们有几个选项来处理上述场景：

•  使用AppDomain的ResolveAssembly事件
•  通过订阅此事件，您可以指示AppDomain在无法找到对Type的引用时查找的位置。
•  通过检查插件程序集是否与要搜索的程序集匹配，然后返回Assembly对象（如果找到），可以轻松完成此操作：
static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
var pluginsFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/Plugins"));
return (from f in pluginsFolder.GetFiles("*.dll", SearchOption.AllDirectories)
let assemblyName = AssemblyName.GetAssemblyName(f.FullName)
where assemblyName.FullName == args.Name || assemblyName.FullName.Split(',')[0] == args.Name
}
•  将插件DLLs副本复制到AppDomain的DynamicDirectory中。
•  这是BuildManager编译动态程序集的目录，也是AppDomain在从程序集中解析Type时所查找的目录。
•  你可以在应用pre-init上将你的插件DLLs副本复制到这个文件夹，一切都应该“正常”
•  用自定义的Razor视图引擎替换RazorViewEngine，该引擎手动编译视图但引用相应的插件DLLs
•  我实际上是在Umbraco v5原型中使用它，但它杀伤力过大也其实没那么必要，再加上你实际上必须非常荒谬的更换RazorViewEngine。

The burden of Medium Trust

In the MVC world there’s only a couple hurdles to jump when loading in plugins from outside of the ‘bin’ folder in Full Trust. In Medium Trust however, things get interesting. Unfortunately in Medium Trust it is not possible to handle the AssemblyResolve event and it’s also not possible to access the DynamicDirectory of the AppDomain so the above two solutions get thrown out the window. Further to this it seems as though you can’t use CodeDom in Medium Trust to custom compile views.

MVC世界中，当从Full Trust中的'bin'文件夹外部加载插件时只需要跳过几个障碍就可以了。然而，在Medium Trust中，事情变得有趣。不幸的是，在Medium Trust中，无法处理AssemblyResolve事件，也无法访问AppDomain的DynamicDirectory，因此上述两个解决方案都会被抛出窗外。除此之外，似乎您无法在Medium Trust中使用CodeDom来自定义编译视图。

Previous attempts

For a while I began to think that this wasn’t possible and I thought I tried everything:

•  Shadow copying DLLs from the plugins folder into the ‘bin’ folder on application pre-init
•  This fails because even during app pre-init, the application pool will still recycle. Well, it doesn’t actually ‘fail’ unless you keep re-copying the DLL into the bin. If you check if it already exists and don’t copy into the bin than this solution will work for you but it’s hardly a ‘solution’ since you might as well just put all your DLLs into the ‘bin’ in the first place.
•  Trying to use sub folders of the ‘bin’ folder to load plugins.
•  Turns out that ASP.Net doesn’t by default load in DLLs that exist in sub folders of the bin, though from research it looks like standard .Net apps actually do.
•  Another interesting point was that if you try to copy a DLL into a sub folder of the bin during application pre-init you get a funky error:  “Storage scopes cannot be created when _AppStart is executing”. It seems that ASP.Net is monitoring all changes in the bin folder regardless of whether or not they are in sub folders but still doesn’t load or reference those assemblies.

•  DLL从插件文件夹中复制到应用程序pre-init上的“bin”文件夹中
•  这失败了，因为即使在app pre-init期间，应用程序池仍将回收循环使用。好吧，除非你不断将DLL重新复制到bin中，否则它也不能说是彻底“失败”。如果你检查它是否已经存在，如果它已经存在并且不需要再复制到bin中，那么这个解决方案对你有用，但它不是一个“解决方案”，因为你可能只是把所有的DLLs放在'bin'中。
•  尝试使用'bin'文件夹的子文件夹来加载插件。
•  事实证明，ASP.Net默认情况下不会加载存储在bin的子文件夹中的DLLs，尽管从研究看起来它看起来像标准.Net应用程序的做法。
•  另一个有趣的观点是，如果您尝试在应用程序预初始化期间将DLL复制到bin的子文件夹中，则会出现一个时髦的错误：“_AppStart执行时无法创建存储范围”。似乎ASP.Net正在监视bin文件夹中的所有更改，无论它们是否在子文件夹中，但仍然不加载或引用这些程序集。

An easy solution

So, the easy solution is to just set a ‘privatePath’ on the ‘probing’ element in your web.config to tell the AppDomain to also look for Assemblies/Types in the specified folders. I did try this before when trying to load plugins from sub folders in the bin and couldn’t get it to work. I’m not sure if I was ‘doing it wrong’ but it definitely wasn’t working then, either that or attempting to set this in sub folders of the bin just doesn’t work.

<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="Plugins/temp" />

<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="Plugins/temp" />

DLL file locking

Since plugin DLLs get locked by the CLR when they are loaded, we need to work around this. The solution is to shadow copy the DLLs to another folder on application pre-init. As mentioned previously, this is one of the ways to get plugins loaded in Full Trust and in my opinion is the nicest way to do it since it kills 2 birds with one stone. In Medium Trust however, we’ll have to jump through some hoops and shadow copy the DLLs to a temp folder that exists within the web application. IMPORTANT: When you’re copying DLLs you might be tempted to modify the name of the DLL by adding a version number or similar, but this will NOT work and you’ll get a “The located assembly's manifest definition … does not match the assembly reference.” exception.

DLL文件锁的问题

Solution

UPDATE: The latest version of this code can be found in the Umbraco v5 source code. The following code does work but there’s been a lot of enhancements to it in the Umbraco core. Here’s the latest changeset as of 16/16/2012 Umbraco v5 PluginManager.cs

Working in Full Trust, the simplest solution is to shadow copy your plugin DLLs into your AppDomain DynamicDirectory. Working in Medium Trust you’ll need to do the following:

•  On application pre-init:
•  Shadow copy all of your plugin DLLs to a temporary folder in your web application (not in the ‘bin’)
•  Add all of the copied DLLs to be referenced by the BuildManager
•  Add all folder paths to the privatePath attribute of the probing element in your web.config to point to where you will be copying your DLLs
•  If you have more than one, you need to semi-colon separate them

Thanks to Glenn Block @ Microsoft who gave me a few suggestions regarding DLL file locking with MEF, Assembly load contexts and probing paths! You put me back on track after I had pretty much given up.

Here’s the code to do the shadow copying and providing the Assemblies to the BuildManager on application pre-init (make sure you set the privatePath on the probing element in your web.config first!!)

•  在应用程序pre-init上：
•  将所有插件DLLs副本拷贝到Web应用程序中的临时文件夹（不要在“bin”目录中）
•  BuildManager引用所有插件DLLs的副本
•  将所有文件夹路径添加到web.config中probing配置节的privatePath属性，以指向插件DLLs副本的存储位置
•  如果你有多个，需要用分号分开它们

using System.Linq;
using System.Web;
using System.IO;
using System.Web.Hosting;
using System.Web.Compilation;
using System.Reflection;

[assembly: PreApplicationStartMethod(typeof(PluginFramework.Plugins.PreApplicationInit), "Initialize")]

namespace PluginFramework.Plugins
{
public class PreApplicationInit
{

static PreApplicationInit()
{
PluginFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/plugins"));
}

/// <summary>
/// The source plugin folder from which to shadow copy from
/// </summary>
/// <remarks>
/// This folder can contain sub folderst to organize plugin types
/// </remarks>

/// <summary>
/// The folder to shadow copy the plugin DLLs to use for running the app
/// </summary>

public static void Initialize()
{

//clear out plugins)
foreach (var f in ShadowCopyFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
f.Delete();
}

foreach (var plug in PluginFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
// NOTE: You cannot rename the plugin DLL to a different name, it will fail because the assembly name is part if it's manifest
// (a reference to how assemblies are loaded: http://msdn.microsoft.com/en-us/library/yx7xezcf )
File.Copy(plug.FullName, Path.Combine(di.FullName, plug.Name), true);
}

// Now, we need to tell the BuildManager that our plugin DLLs exists and to reference them.
// There are different Assembly Load Contexts that we need to take into account which
// http://blogs.msdn.com/b/suzcook/archive/2003/05/29/57143.aspx

// * This will put the plugin assemblies in the 'Load' context
// This works but requires a 'probing' folder be defined in the web.config
foreach (var a in
.GetFiles("*.dll", SearchOption.AllDirectories)
.Select(x => AssemblyName.GetAssemblyName(x.FullName))
{
}

// * This will put the plugin assemblies in the 'LoadFrom' context
// This works but requires a 'probing' folder be defined in the web.config
// This is the slowest and most error prone version of the Load contexts.
//foreach (var a in
//    .GetFiles("*.dll", SearchOption.AllDirectories)
//{
//}

// * This will put the plugin assemblies in the 'Neither' context ( i think )
// This nearly works but fails during view compilation.
// This DOES work for resolving controllers but during view compilation which is done with the RazorViewEngine,
// the CodeDom building doesn't reference the plugin assemblies directly.
//foreach (var a in
//    .GetFiles("*.dll", SearchOption.AllDirectories)
//{
//}

}
}
}

posted @ 2019-01-17 18:22 MikeCheers 阅读(...) 评论(...) 编辑 收藏