Custom Assembly loading with Asp.Net Core
Building a plugin system in Asp.Net Core is a dream compared to previous Asp.Net versions!
In previous versions it was not really feasible to load Assemblies located outside of the /bin folder for a web application. I battled with this concept quite a long time ago and although it’s sort of possible, the notion of having a plugin system that supported loading DLLs from outside of the /bin folder was riddled with hacks/problems and not really supported OOTB.
A large part of the issues has to do with something called an ‘Assembly Load Context’. In traditional .Net there are 3 of these context types: “Load”, “LoadFrom” and “Neither”, here’s a very old but very relevant post about these contexts from Suzanne Cook. In traditional Asp.Net, the “Load” context is used as the default context and it is managed by something called Fusion (.Net’s normal Assembly Loader/Binder). The problem with this context is that it is difficult to load an assembly into it that isn’t located in Fusion’s probing paths (i.e. /bin folder). If you load in an Assembly with a different Assembly Load Context and then try to mix it’s Types with the Types from the default context … you’ll quickly see that it’s not going to work.
The “Neither” context
Here is the Neither context definition as defined by Suzanne Cook:
If the user generated or found the assembly instead of Fusion, it's in neither context. This applies to assemblies loaded by Assembly.Load(byte[]) and Reflection Emit assemblies (that haven't been loaded from disk). Assembly.LoadFile() assemblies are also generally loaded into this context, even though a path is given (because it doesn't go through Fusion).
In Asp.Net Core (targeting CoreCLR), the default Assembly Load Context is the “Neither” context. This is a flexible context because it doesn’t use Fusion and it allows for loading assemblies any way that you want - including loading an assembly from a byte array, from a path or by a name. Since all of Asp.Net Core uses this context it means that all of the types loaded in with this context can talk to each other without having the previous Asp.Net problems.
I would assume that Asp.Net Core targeting Desktop CLR would still operate the same as before and still have the 3 types of Assembly Load Context’s … Maybe someone over at Microsoft can elaborate on that one? (David Fowler… surely you know? :)
Finding referenced plugin assemblies
In many cases if you create a product that supports plugin types, developers will create plugins for your product and ship them via Nuget. This is a pretty standard approach since it allows developers that are using your product to install plugins from the Nuget command line or from within Visual Studio. In this case plugin types will be found in referenced assemblies to your application and will be automatically loaded. Asp.Net Core has an interface called Microsoft.Extensions.PlatformAbstractions.ILibraryManager that can be used to resolve your application’s currently referenced ‘Libraries’ (i.e Nuget packages) and then each ‘Library’ returned exposes the Assemblies that it includes. Asp.Net MVC 6 has an even more helpful interface called Microsoft.AspNet.Mvc.Infrastructure.IAssemblyProvider which returns a list of referenced assemblies that are filtered based on if they are assemblies that reference a subset of MVC assemblies. The default implementation of IAssemblyProvider (DefaultAssemblyProvider) is extensible and we can use it to override it’s property ReferenceAssemblies in order to supply our own product assembly names instead of the MVC ones. This is perfect since this allows us to get a list of candidate assemblies that might contain plugins for your product:
public class ReferencePluginAssemblyProvider : DefaultAssemblyProvider { //NOTE: The DefaultAssemblyProvider uses ILibraryManager to do the library/assembly querying public ReferencePluginAssemblyProvider(ILibraryManager libraryManager) : base(libraryManager) { } protected override HashSet<string> ReferenceAssemblies => new HashSet<string>(new[] {"MyProduct.Web", "MyProduct.Core"}); }
now if you want to get a list of candidate assemblies that your application is referencing you could do:
//returns all assemblies that reference your product Assemblies
var candidateReferenceAssemblies = referencedPluginAssemblyProvider.CandidateAssemblies;
Finding and loading non-referenced plugin assemblies
This is where things get fun since this is the type of thing that wasn’t really very feasible with traditional Asp.Net web apps. Lets say you have a plugin framework where a plugin is installed via your web app, not in Visual Studio and therefore not directly referenced in your project. For this example, the plugin is a self contained collection of files and folders which could consist of: Css, JavaScript, Razor Views, and Assemblies. This plugin model is pretty nice since to install the plugin would mean just dropping the plugin folder into the right directory in your app and similarly to uninstall it you can just remove the folder. The first step is to be able to load in these plugin Assemblies from custom locations. For an example, let’s assume the web app has the following folder structure:
- App Root
- App_Plugins <—This will be the directory that contains plugin folders
- MyPlugin1
- bin <—by convention we’ll search for Assemblies in the /bin folder inside of a plugin
- Views
- MyPlugin2
- bin <—by convention we’ll search for Assemblies in the /bin folder inside of a plugin
- css
- MyPlugin1
- Views
- wwwroot
- App_Plugins <—This will be the directory that contains plugin folders
IAssemblyLoader
The first thing we need is an ‘Microsoft.Extensions.PlatformAbstractions.IAssemblyLoader’, this is the thing that will do the assembly loading into the Assembly Load Context based on an AssemblyName and a location of a DLL:
public class DirectoryLoader : IAssemblyLoader { private readonly IAssemblyLoadContext _context; private readonly DirectoryInfo _path; public DirectoryLoader(DirectoryInfo path, IAssemblyLoadContext context) { _path = path; _context = context; } public Assembly Load(AssemblyName assemblyName) { return _context.LoadFile(Path.Combine(_path.FullName, assemblyName.Name + ".dll")); } public IntPtr LoadUnmanagedLibrary(string name) { //this isn't going to load any unmanaged libraries, just throw throw new NotImplementedException(); } }
IAssemblyProvider
Next up we’ll need a custom IAssemblyProvider but instead of using the one MVC ships with, this one will be totally custom in order to load and resolve the assemblies based on the plugin’s /bin folders. The following code should be pretty straight forward, the CandidateAssemblies property iterates over each found /bin folder inside of a plugin’s folder inside of App_Plugins. For each /bin folder found it creates a DirectoryLoader mentioned above and loads in each DLL found by it’s AssemblyName into the current Assembly Load Context.
/// <summary> /// This will return assemblies found in App_Plugins plugin's /bin folders /// </summary> public class CustomDirectoryAssemblyProvider : IAssemblyProvider { private readonly IFileProvider _fileProvider; private readonly IAssemblyLoadContextAccessor _loadContextAccessor; private readonly IAssemblyLoaderContainer _assemblyLoaderContainer; public CustomDirectoryAssemblyProvider( IFileProvider fileProvider, IAssemblyLoadContextAccessor loadContextAccessor, IAssemblyLoaderContainer assemblyLoaderContainer) { _fileProvider = fileProvider; _loadContextAccessor = loadContextAccessor; _assemblyLoaderContainer = assemblyLoaderContainer; } public IEnumerable<Assembly> CandidateAssemblies { get { var content = _fileProvider.GetDirectoryContents("/App_Plugins"); if (!content.Exists) yield break; foreach (var pluginDir in content.Where(x => x.IsDirectory)) { var binDir = new DirectoryInfo(Path.Combine(pluginDir.PhysicalPath, "bin")); if (!binDir.Exists) continue; foreach (var assembly in GetAssembliesInFolder(binDir)) { yield return assembly; } } } } /// <summary> /// Returns assemblies loaded from /bin folders inside of App_Plugins /// </summary> /// <param name="binPath"></param> /// <returns></returns> private IEnumerable<Assembly> GetAssembliesInFolder(DirectoryInfo binPath) { // Use the default load context var loadContext = _loadContextAccessor.Default; // Add the loader to the container so that any call to Assembly.Load // will call the load context back (if it's not already loaded) using (_assemblyLoaderContainer.AddLoader( new DirectoryLoader(binPath, loadContext))) { foreach (var fileSystemInfo in binPath.GetFileSystemInfos("*.dll")) { //// In theory you should be able to use Assembly.Load() here instead //var assembly1 = Assembly.Load(AssemblyName.GetAssemblyName(fileSystemInfo.FullName)); var assembly2 = loadContext.Load(AssemblyName.GetAssemblyName(fileSystemInfo.FullName)); yield return assembly2; } } } }
That’s pretty much it! If you have an instance of CustomDirectoryAssemblyProvider then you can get Assembly references to all of the assemblies found in App_Plugins:
//returns all plugin assemblies found in App_Plugins
var candidatePluginAssemblies = customDirectoryAssemblyProvider.CandidateAssemblies;
Integrating non-referenced plugins/Assemblies with MVC
What if you had custom plugin types as MVC Controllers or other MVC types? By default MVC only knows about assemblies that your project has references to based on the DefaultAssemblyLoader. If we wanted MVC to know about Controllers that exist in a plugin not referenced by your project (i.e. in App_Plugins) then it’s a case of registering a custom IAssemblyProvider in IoC which will get resolved by MVC. To make this super flexible we can create a custom IAssemblyProvider that wraps multiple other ones and allows you to pass in a custom referenceAssemblies filter if you wanted to use this to resolve your own plugin types:
public class CompositeAssemblyProvider : DefaultAssemblyProvider { private readonly IAssemblyProvider[] _additionalProviders; private readonly string[] _referenceAssemblies; /// <summary> /// Constructor /// </summary> /// <param name="libraryManager"></param> /// <param name="additionalProviders"> /// If passed in will concat the assemblies returned from these /// providers with the default assemblies referenced /// </param> /// <param name="referenceAssemblies"> /// If passed in it will filter the candidate libraries to ones /// that reference the assembly names passed in. /// (i.e. "MyProduct.Web", "MyProduct.Core" ) /// </param> public CompositeAssemblyProvider( ILibraryManager libraryManager, IAssemblyProvider[] additionalProviders = null, string[] referenceAssemblies = null) : base(libraryManager) { _additionalProviders = additionalProviders; _referenceAssemblies = referenceAssemblies; } /// <summary> /// Uses the default filter if a custom list of reference /// assemblies has not been provided /// </summary> protected override HashSet<string> ReferenceAssemblies => _referenceAssemblies == null ? base.ReferenceAssemblies : new HashSet<string>(_referenceAssemblies); /// <summary> /// Returns the base Libraries referenced along with any DLLs/Libraries /// returned from the custom IAssemblyProvider passed in /// </summary> /// <returns></returns> protected override IEnumerable<Library> GetCandidateLibraries() { var baseCandidates = base.GetCandidateLibraries(); if (_additionalProviders == null) return baseCandidates; return baseCandidates .Concat( _additionalProviders.SelectMany(provider => provider.CandidateAssemblies.Select( x => new Library(x.FullName, null, Path.GetDirectoryName(x.Location), null, Enumerable.Empty<string>(), new[] { new AssemblyName(x.FullName) })))); } }
To register this in IoC you just need to make sure it’s registered after you register MVC so that it overrides the last registered IAssemblyProvider:
//Add MVC services services.AddMvc(); //Replace the default IAssemblyProvider with the composite one services.AddSingleton<IAssemblyProvider, CompositeAssemblyProvider>(provider => { //create the custom plugin directory provider var hosting = provider.GetRequiredService<IApplicationEnvironment>(); var fileProvider = new PhysicalFileProvider(hosting.ApplicationBasePath); var pluginAssemblyProvider = new CustomDirectoryAssemblyProvider( fileProvider, PlatformServices.Default.AssemblyLoadContextAccessor, PlatformServices.Default.AssemblyLoaderContainer); //return the composite one - this wraps the default MVC one return new CompositeAssemblyProvider( provider.GetRequiredService<ILibraryManager>(), new IAssemblyProvider[] {pluginAssemblyProvider}); });
Your all set! Now you have the ability to load in Assemblies from any location you want, you could even load them in as byte array’s from an external data source. What’s great about all of this is that it just works and you can integrate these external Assemblies into MVC.
Some things worth noting:
- Parts of the assembly loading APIs are changing a bit in Asp.Net Core RC2: https://github.com/aspnet/Announcements/issues/149
- The above code doesn’t take into account what happens if you load in the same Assembly from multiple locations. In this case, the last one in wins/is active AFAIK – I haven’t tested this yet but I’m pretty sure that’s how it works.
- You may have some issues if load in the same Assembly more than once from multiple locations if those Assemblies have different strong names, or major versions applied to them – I also haven’t tested this yet