Blog

A plugin system with .NET Core

August 14, 2019 | 11 Minute Read

Implementing a (mini) plugin system with .NET Core 3.0


Prerequisites

You need VS 2019 and .NET Core 3.0 (currently in preview 8 while posting this).

Getting started

In this post I show how you could implement a plugin system that can unload the plugins dynamically. I also provide some background information behind the techniques and classes involved. Unlike the AppDomain, the AssemblyLoadContext let’s you unload the plugin types and the owned assemblies - sounds promising, right?

The PluginFinder

Usually before we load an assembly in our application, we should probe it for plugins that our application supports.

The PluginHost

The plugin host acts as a registry of the known plugins.

The Plugin

Every plugin needs at least a name to be identified and properly hosted by the plugin host.

Be aware that the following implementation is an example and not bullet proof production ready.

Implementing the PluginFinder class

The plugin finder is responsible for loading and scanning an assembly for plugins. This means we need to store the information about which assemblies have plugins and unload the assembly after scanning.

public class PluginFinder<TPlugin> where TPlugin : IPlugin
{
    public PluginFinder() { }

    public IReadOnlyCollection<string> FindAssemliesWithPlugins(string path)
    {
        var assemblies = Directory.GetFiles(path, "*.dll", new EnumerationOptions() { RecurseSubdirectories = true });
        return FindPluginsInAssemblies(assemblies);
    }

    private IReadOnlyCollection<string> FindPluginsInAssemblies(string[] assemblyPaths)
    {
        var assemblyPluginInfos = new List<string>();
        var pluginFinderAssemblyContext = new PluginAssemblyLoadingContext(name: "PluginFinderAssemblyContext");
        foreach (var assemblyPath in assemblyPaths)
        {
            var assembly = pluginFinderAssemblyContext.LoadFromAssemblyPath(assemblyPath);
            if (GetPluginTypes(assembly).Any())
            {
                assemblyPluginInfos.Add(assembly.Location);
            }
        }
        pluginFinderAssemblyContext.Unload();
        return assemblyPluginInfos;
    }

    public static IReadOnlyCollection<Type> GetPluginTypes(Assembly assembly)
    {
        return  assembly.GetTypes()
                        .Where(type =>
                        !type.IsAbstract &&
                        typeof(TPlugin).IsAssignableFrom(type))
                        .ToArray();
    }
}

Implementing the PluginHost class

The plugin host stores all plugin instances by name and allows unloading them. We load the assembly into the _pluginAssemblyLoadingContext. After that, the Activator creates a new instance of our plugin types and adds it to the dictionary.

public class PluginHost<TPlugin> where TPlugin : IPlugin
{
    private Dictionary<string, TPlugin> _plugins = new Dictionary<string, TPlugin>();
    private readonly PluginAssemblyLoadingContext _pluginAssemblyLoadingContext;

    public PluginHost()
    {
        _pluginAssemblyLoadingContext = new PluginAssemblyLoadingContext("PluginAssemblyContext");
    }

    public TPlugin GetPlugin(string pluginName)
    {
        return _plugins[pluginName];
    }

    public IReadOnlyCollection<TPlugin> GetPlugins()
    {
        return _plugins.Values;
    }
               
    public void LoadPlugins(IReadOnlyCollection<string> assembliesWithPlugins)
    {
        foreach (var assemblyPath in assembliesWithPlugins)
        {
            var assembly = _pluginAssemblyLoadingContext.LoadFromAssemblyPath(assemblyPath);
            var validPluginTypes = PluginFinder<TPlugin>.GetPluginTypes(assembly);
            foreach (var pluginType in validPluginTypes)
            {
                var plutinInstance = (TPlugin)Activator.CreateInstance(pluginType);
                RegisterPlugin(plutinInstance);
            }
        }
    }

    public void Unload()
    {
        _plugins.Clear();
        _pluginAssemblyLoadingContext.Unload();
    }
}

Implementing the plugins in another assembly

The plugin interface defined by the application is simple.

public interface IPlugin
{
    string Name { get; }
}

If we leave it that way, our plugin can not do anything yet. That’s boring, right? Lets add another interface to be suitable for math operations.

public interface IMathOperationPlugin : IPlugin
{
    decimal Calculate(decimal operand1, decimal operand2);   
}

Don’t be surprised by the chosen operations - they are well-known.

public class AdditionOperation : PluginBase, IMathOperationPlugin
{
    public override string Name => nameof(AdditionOperation);

    public decimal Calculate(decimal operand1, decimal operand2)
    {
        return operand1 + operand2;
    }
}

public class DivideOperation : PluginBase, IMathOperationPlugin
{
    public override string Name => nameof(DivideOperation);

    public decimal Calculate(decimal operand1, decimal operand2)
    {
        return operand1 / operand2;
    }
}

public class MultiplyOperation : PluginBase, IMathOperationPlugin
{
    public override string Name => nameof(MultiplyOperation);

    public decimal Calculate(decimal operand1, decimal operand2)
    {
        return operand1 * operand2;
    }
}

public class SubstractOperation : PluginBase, IMathOperationPlugin
{
    public override string Name => nameof(SubstractOperation);

    public decimal Calculate(decimal operand1, decimal operand2)
    {
        return operand1 - operand2;
    }
}

Putting all together

Let’s get seriously about our code and do some math!

class Program
{
    static void Main(string[] args)
    {
        DoCalculation();
        GC.Collect();
        GC.WaitForPendingFinalizers();

        Console.ReadKey();

    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void DoCalculation()
    {
        // Create a plugin finder and scan the sub directory "Plugins" for assemblies with plugin defined.
        var pluginFinder = new PluginFinder<IMathOperationPlugin>();
        var assemblyPaths = pluginFinder.FindAssemliesWithPlugins(Path.Combine(Directory.GetCurrentDirectory(), "Plugins"));

        GC.Collect();
        GC.WaitForPendingFinalizers();

        // Create a plugin host and load the plugin assemblies.
        var pluginHost = new PluginHost<IMathOperationPlugin>();
        pluginHost.LoadPlugins(assemblyPaths);
        
        // Use the plugins and print the result of each calculation.
        decimal value1 = 10;
        decimal value2 = 5;
        foreach (var operation in pluginHost.GetPlugins())
        {
            var result = operation.Calculate(value1, value2);
            Console.WriteLine($"Calculation with {operation.Name}: {result}");
        }
        pluginHost.Unload();
    }
}

The [MethodImpl(MethodImplOptions.NoInlining)] attribute is required to ensure the method is not inlined by the runtime - otherwise everything would live until the end of the application and would prevent the unloading of our assemblies.

Maybe you wonder about the calls of GC.Collect() and GC.WaitForPendingFinalizers(). Those calls are added to demonstrate immediately the effect of AssemblyLoadContext.Unload() method. By design AssemblyLoadContext.Unload() only triggers the unloading process and the actual unloading will happen when the garbage collection runs - this behavior can be observed during debugging. When for whatever reason a type is referenced by long lived object on the heap (e.g. a static field), the assembly can never be unloaded!

Let’s debug it and see what’s happening with our module list. Before we load any plugin assembly, our module list contains everything that is actually used by the console app.

dotnet-30-before-find-plugin

Just after the scan, the list is growing and our plugin assembly is added to the list: CodeTherapistBlogPluginA.dll.

dotnet-30-after-find-plugin

Even though we have already called AssemblyLoadContext.Unload() (inside pluginFinder.FindAssemliesWithPlugins), the assembly stays in the module list. Right after a full GC, the plugin assembly named CodeTherapistBlogPluginA.dll is removed.

dotnet-30-after-find-plugin-collected

The plugin host will load the assembly (CodeTherapistBlogPluginA.dll) again and execute all calculations.

dotnet-30-after-calculation

Triggering GC will remove our plugin assembly again.

dotnet-30-after-calculation-plugin-collected

The AssemblyLoadContext

Basically the AssemblyLoadContext is the successor of the AppDomain and provides identical and more functionality - except the security boundary (isolation). The smallest security boundary is the process and therefore you would need to use inter-process communication to properly isolate data and code execution.

The AppDomain is obsolete and you should prefer AssemblyLoadContext especially for new work and .NET Core. Under .NET Core the AppDomain is already limited. It does not provide isolation, unloading, or security boundaries.

Every .NET App has at least one (not collectible) AssemblyLoadContext named “Default” where all the assemblies are loaded by the .NET runtime.

Type != Type

When you deal with multiple AssemblyLoadContext instances you could run in the following exception:

dotnet-30-type!=type

This happens because you can load different versions of the same assembly side by side into the same process. The direct referenced assembly has a different version than the side loaded library.

Migrate from AppDomain to AssemblyLoadContext

Maybe you still using the AppDomain in an application. Now, the following code shows how to replace AppDomain methods by the appropriate equivalent method of AssemblyLoadContext:

    // Create new "context" for loading assemblies:
    var appDomain = AppDomain.CreateDomain("MyAppDomain");
    var assemblyLoadContext = new MyAssemblyLoadContext(name: "MyAssemblyLoadContext", isCollectible: true);

    // Get all assemblies:
    var assembliesFromAppDomain = AppDomain.CurrentDomain.GetAssemblies();
    var assembliesFromAssemblyLoadContext = AssemblyLoadContext.Default.Assemblies;

    // Load an assembly:
    AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName("path"));
    AssemblyLoadContext.Default.LoadFromAssemblyName(AssemblyName.GetAssemblyName("path"));

    // Load an assembly from path or byte array:
    AppDomain.CurrentDomain.Load(File.ReadAllBytes("path"));
    AssemblyLoadContext.Default.LoadFromStream(File.OpenRead("path"));
    // or
    AssemblyLoadContext.Default.LoadFromAssemblyPath("path");

Conclusion

I’m excited about the new capability of the AssemblyLoadContext class and how it is implemented. It extends the possibilities regarding the architecture and functionality of an application. Hopefully you like my post and you could take something useful away from it. Let me know what you think :)