Creating isolated plugins in .NET Core

C# DotNetCore

For one of my current projects at home I was planning on adding support for plugins and tools for sharing them on a website. But the idea had some flaws.

The problems

As I, in the long run, wanted the project to support community developed plugins the idea of setting up a long list of guidelines and package versions that needed to be followed wasn't that tempting.

Because if another developer would add a reference to a Nuget package that would already be in the main project and it would be a different version, problems would occur at runtime when the plugin would be loaded.

Creating a demo environment

So to start working with this problem I set up a test project structure in Visual Studio. The following projects were created.

Main: This project would represent my project that will load the plugins.

PluginBase: A shared library, that all plugins will add a reference to and contains a set of interfaces that plugins can implement so that the Main project can detect them.

Plugin1: The first example plugin, that have a reference the PluginBase project and to Newtonsoft.Json 10.0.1.

Plugin2: The first example plugin, that have a reference the PluginBase project and to Newtonsoft.Json 12.0.1.

The code for the example interface would look the file PluginBase.cs and the implementation of the interface in one of the plugin projects would look like Plugin.cs.

public interface IPluginBase
{
    void PrintData();
}

public class PluginExample : IPluginBase
{
    public void PrintData()
    {
        Console.WriteLine($"PluginExample: {typeof(Newtonsoft.Json.JsonConverter).AssemblyQualifiedName}");
    }
}

Finding the solution

Earlier in the .NET Framework version this was possible in a way with creating a new Application Domain and loading Assemblies into that domain. This is no longer possible as these parts of the runtime are not implemented in .NET Core.

That solution also had some problems in its own way, but I wont go into that in this post.

But I did find that this is somewhat replaced with the abstract class AssemblyLoadContext, so I started testing with this class hoping to find a working solution.

Walktrough of the solution

This file is a working simple solution of loading plugins in an isolated context, with an explaination for each part below.

public class PluginAssemblyLoadContext : AssemblyLoadContext
{
    private List<Assembly> loadedAssemblies;
    private Dictionary<string, Assembly> sharedAssemblies;

    private string path;

    public PluginAssemblyLoadContext(string path, params Type[] sharedTypes)
    {
        this.path = path;

        this.loadedAssemblies = new List<Assembly>();
        this.sharedAssemblies = new Dictionary<string, Assembly>();

        foreach (Type sharedType in sharedTypes)
            sharedAssemblies[Path.GetFileName(sharedType.Assembly.Location)] = sharedType.Assembly;
    }

    public void Initialize()
    {
        foreach (string dll in Directory.EnumerateFiles(path, "*.dll"))
        {
            if (sharedAssemblies.ContainsKey(Path.GetFileName(dll)))
                continue;

            loadedAssemblies.Add(this.LoadFromAssemblyPath(dll));
        }
    }

    public IEnumerable<T> GetImplementations<T>()
    {
        return loadedAssemblies
            .SelectMany(a => a.GetTypes())
            .Where(t => typeof(T).IsAssignableFrom(t))
            .Select(t => Activator.CreateInstance(t))
            .Cast<T>();
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        string filename = $"{assemblyName.Name}.dll";
        if (sharedAssemblies.ContainsKey(filename))
            return sharedAssemblies[filename];

        return Assembly.Load(assemblyName);
    }
}

Constructor

The constructor will take information about how to load the plugin, as the path parameter will point to which folder that contains all DLLs that the plugin needs to work.

The sharedTypes parameter should list all types that should be guaranteed to be loaded later with the GetImplementations() method. This is because if one of these types exists in one of the loaded DLL files it will be loaded in a different context then the main application. This will result in that the main application and the plugin context will not share the assembly for these types and the main application wont be able to load instances of these types from the plugin context.

The shared types will also be converted to a list of assemblies, as they are loaded in the context of the application.

Initialize()

This method will load all DLL files that can be found in the plugin folder into this context. The if statement will check so that any DLL that any of the shared types is not loaded from the plugin. As this would load that DLL into the plugin context.

GetImplementations()

This method is a helper method to find all types that implements a specific interface or class and creates instances of them. This will help you work with the shared types earlier specified.

Note, this method will be called from the application context, but the instances will be created from the plugin context.

Load(AssemblyName assemblyName)

The Load method is an overidden method that the context will call when any of the loaded DLL files have a reference to a assembly (DLL) that was not included in the folder.

An example might be that a plugin have reference to the System.IO to work with files, this is a reference that exists in the runtime and will therefor be loaded from the runtime with static Assembly.Load method.

This will also occur for any references to a assembly for a shared type, this is why there is an if statement. This will catch this and return the reference to the assembly that is loaded the application context. This will create the behavior that the types can be shared between the contexts.

An example

Below is an example where the above code is in use.

static void Main(string[] args)
{
    string[] plugins = new string[]
    {
        @"...\Path\to\Plugin1",
        @"...\Path\to\Plugin2"
    };

    Type pluginType = typeof(IPluginBase);

    List<PluginAssemblyLoadContext> contexts = new List<PluginAssemblyLoadContext>();
    foreach(string pluginPath in plugins)
    {
        PluginAssemblyLoadContext context = new PluginAssemblyLoadContext(pluginPath, pluginType);
        context.Initialize();

        contexts.Add(context);
    }

    foreach (var context in contexts)
        foreach (var plugin in context.GetImplementations<IPluginBase>())
            plugin.PrintData();

    Console.ReadLine();
}

This code will result in two plugin contexts, one for each plugin. The application will then try to find all implementations of IPluginBase and execute it.

As stated earlier, both of these plugins contains a reference to two different versions of Newtonsoft.Json. Below follows an image that will show the output of the application.

Console Output

Disposing?

As of writing this blog, there is currently no supported way to dispose and unload an assembly context in .NET Core 2.1/2.2.

But Microsoft have stated in the current .NET Core 3.0 preview, there will be support for unloading an assembly context, which should make this even more powerfull.

Final word

Full code of the project is available on GitHub here: https://github.com/trembon/Blog-IsolatedPlugins

As this might be a bit of a complex scenario to grasp for some, I do hope my code helps someone else that might have an idea for a project with plugin support. If you have any ideas on how to improve some of the the explainations and the code examples, just reach out in any way under the Contact page or on GitHub.