C# – Generic Plugin Loader

This article explains how to create a generic plugin loader that solves many real world problems when loading plugins in .NET.

Besides being generic, this plugin loader also solves the following real world problems when working with plugins:

  • Loads assemblies that have dependencies.
  • Load assemblies that have unmanaged dependencies (like C++ DLLs).
  • Solves the “IsAssignableFrom incorrectly returns false” problem that happens in .NET Core.
  • Unloads assemblies.
  • Can pass in parameters to plugin constructors.

If you find that this generic plugin loader doesn’t solve a real world problem for you, go ahead and add the functionality yourself (or leave a comment explaining the problem).

Note: Used in .NET Core 3.1.

What are plugins?

To put this in concrete terms, in a plugin architecture you have three assemblies:

  • An executable program that uses plugins.
  • A DLL that defines a plugin interface.
  • One or more assemblies that implement the plugin interface.
Plugin architecture diagram with generic plugin loader

This approach is very flexible and allows you extend the program by plugging in new Plugin DLLs instead of having to modify the main program logic.

Create the generic plugin loader

Plugin Loader class

Note: This only loads one plugin type from each assembly. If you need to load multiple plugins from the same assembly, go ahead and update this to loop through assemby.GetTypes() instead of using assembly.GetTypes.GetFirstOrDefault().

public class GenericPluginLoader<T> where T : class { private readonly List<GenericAssemblyLoadContext<T>> loadContexts = new List<GenericAssemblyLoadContext<T>>(); public List<T> LoadAll(string pluginPath, string filter="*.dll", params object[] constructorArgs) { List<T> plugins = new List<T>(); foreach (var filePath in Directory.EnumerateFiles(pluginPath, filter, SearchOption.AllDirectories)) { var plugin = Load(filePath, constructorArgs); if(plugin != null) { plugins.Add(plugin); } } return plugins; } private T Load(string pluginPath, params object[] constructorArgs) { var loadContext = new GenericAssemblyLoadContext<T>(pluginPath); loadContexts.Add(loadContext); var assembly = loadContext.LoadFromAssemblyPath(pluginPath); var type = assembly.GetTypes().FirstOrDefault(t => typeof(T).IsAssignableFrom(t)); if (type == null) { return null; } return (T)Activator.CreateInstance(type, constructorArgs); } public void UnloadAll() { foreach(var loadContext in loadContexts) { loadContext.Unload(); } } }
Code language: C# (cs)

Plugin Load Context class

public class GenericAssemblyLoadContext<T> : AssemblyLoadContext where T : class { private AssemblyDependencyResolver _resolver; private HashSet<string> assembliesToNotLoadIntoContext; public GenericAssemblyLoadContext(string pluginPath) : base(isCollectible: true) { var pluginInterfaceAssembly = typeof(T).Assembly.FullName; assembliesToNotLoadIntoContext = GetReferencedAssemblyFullNames(pluginInterfaceAssembly); assembliesToNotLoadIntoContext.Add(pluginInterfaceAssembly); _resolver = new AssemblyDependencyResolver(pluginPath); } private HashSet<string> GetReferencedAssemblyFullNames(string ReferencedBy) { return AppDomain.CurrentDomain .GetAssemblies().FirstOrDefault(t => t.FullName == ReferencedBy) .GetReferencedAssemblies() .Select(t => t.FullName) .ToHashSet(); } protected override Assembly Load(AssemblyName assemblyName) { //Do not load the Plugin Interface DLL into the adapter's context //otherwise IsAssignableFrom is false. if (assembliesToNotLoadIntoContext.Contains(assemblyName.FullName)) { return null; } string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); if (assemblyPath != null) { return LoadFromAssemblyPath(assemblyPath); } return null; } protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) { string libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); if (libraryPath != null) { return LoadUnmanagedDllFromPath(libraryPath); } return IntPtr.Zero; } }
Code language: C# (cs)

Why exclude the plugin interface library?

If I were to load the plugin interface library into the assembly load context, then IsAssignableFrom() would be false even though it should be true.

The way around this problem is to not load the plugin library or its dependencies into the plugin’s load context.

Example of using the generic plugin loader

Define a plugin interface

There’s nothing special about the IMessageProcessingPlugin interface. It simply gives the plugin loader a type to look for that all plugins will implement.

public interface IMessageProcessingPlugin { void Process(IMessage message); } public interface IMessage { string Text { get; set; } }
Code language: C# (cs)

Implement a plugin

In a new class library project I created this MessageLoggingPlugin class.

public class MessageLoggingPlugin : IMessageProcessingPlugin { private readonly string DateFormat; public MessageLoggingPlugin(string dateFormat) { this.DateFormat = dateFormat; } public void Process(IMessage message) { Console.WriteLine($"Message Logger: {DateTime.Now.ToString(DateFormat)}\tReceived message {message.Text}"); } }
Code language: C# (cs)

Note: I’m outputting the build to C:\MessagePluginProcessors\MessageLoggingPlugin\.

Load the plugins, use them, then unload them

Here’s a simple example that illustrates the three key steps when working with plugins: load, use, and unload.

static void Main(string[] args) { var pluginLoader = new GenericPluginLoader<IMessageProcessingPlugin>(); //Load all plugins from a folder and tell to use the "o" (ISO-8601) date format var messageProcessingPlugins = pluginLoader.LoadAll(pluginPath: @"C:\MessagePluginProcessors\", constructorArgs: "o"); Console.WriteLine($"Loaded {messageProcessingPlugins.Count} plugin(s)"); //Use plugins List<IMessage> messages = GetMessages(); foreach (var message in messages) { foreach (var plugin in messageProcessingPlugins) { plugin.Process(message); } } //unload plugins pluginLoader.UnloadAll(); Console.WriteLine("Unloaded plugins"); Console.ReadKey(); }
Code language: C# (cs)


Leave a Comment