A modern Plugin System for .NET

Motivation

Some time ago I was looking for the possibility to extend a .NET application with plugins.

I wanted it to support .NET Framework and .NET Core. Because of this requirement the solution needed to be implemented with .NET Standard to ensure maximum compatibility.

I began my journey looking for a great solution hosted on Nuget but i couldn’t find a library that satisfied my needs. So what else can you do as a developer than writing your own.

Microsoft already provides a guide how to use plugins in .NET Core applications (https://docs.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support), but it would be great to have a library that supports more features and make things easier for developers.

Requirements

If you want to create a plugin system you need to define what features it should have. I thought about it and found the following features to be useful.

  • Load a specific type of plugins for your applications.
  • Define versions of a plugin that are compatible with your application.
  • Allow multiple plugins of the same type to be loaded.
  • Call functions for multiple loaded plugins at the same time.
  • Unload a plugin without restarting the application.

After we defined the features our plugin should have, we should try to design our plugin system.

I see this blog post as a documentation of my development process. So this post will be updated during my development.

Implementation

Because our plugin system should be usable within .NET Framework and .NET Core projects we should implement everything that is possible in a .NET Standard library project. I said “everything that is possible” because the loading an unloading of plugins works totally different in .NET Framework and .NET Core and there is no solution in .NET Standard that works for both frameworks. But we will cover everything in detail later on.

With this information in mind we will split our solution in these 5 different projects:

Visual Studio project structure
Visual Studio project structure

The projects ConsoleTest and PluginImpl are irrelevant for now. They are only used for testing the code. The relevant projects are:

  • PluginEngine.Common
  • PluginEngine.NetCore
  • PluginEngine.NetFramework

Lets start with the implementation of the things that can be shared in the PluginEngine.Common project.

Our Plugin class

At first we need to define our base class for a plugin…

What does a plugin need?! Lets say a name and a version.

I think we should use an abstract class instead of an interface, because maybe we need to define functionality for a plugin later. Within abstract classes we can define core functionality every plugin should have. With interfaces we do not have this possibility. So maybe our base class for our plugin may look like this:

namespace PluginEngine.Common
{
    public abstract class Plugin : MarshalByRefObject
    {
        public virtual string Name => nameof(Plugin);
        public virtual string Version => "0.0";

        public virtual void PrintInfo()
        {
            Console.WriteLine($"Object is executing in AppDomain \"{AppDomain.CurrentDomain.FriendlyName}\"");
        }
    }
}

There are some parts of the code that you probably don’t really understand right now. I will explain later why we let our plugin extend MarshalByRefObject.

We use getters for the name and the version of the plugin, because we don’t want any other code be able to set this properties. Only the plugin itself should be responsible for these information. The default value nameof(Plugin) for the name just returns “Plugin” as the name. We could have used “Plugin” as string but nameof ensures that the name of the class is used. With this construct we would get a compiler warning if we rename our class. It would force us to adapt the default value.

The function PrintInfo() currently prints out the AppDomain where the code is currently executed. You don’t need to know anything about AppDomains now. It will be relevant later, when we talk about loading and unloading plugins in .NET Framework. Just ignore this for now.

PluginInformation

When we load our plugins we want some information about the loaded plugin. The path of the loaded DLL for example could be interesting. So lets encapsulate our loaded plugin.

namespace PluginEngine.Common
{
    public class PluginInformation<TPluginType> where TPluginType : Plugin
    {
        public TPluginType Plugin { get; set; }
        public string FilePath { get; set; }
    }
}

The code means the following: A PluginInformation contains the path of the DLL and the instance of our plugin. The plugin is of a specific type that has to be passed with a generic parameter (TPluginType). This makes it easier to work with our plugin later.

PluginManager

After we defined how our plugin should look and what type of information should be returned when we load our plugins, we should define a class that manages a loaded plugin of a specific type. Lets call this class PluginManager.

using System;
using System.Collections.Generic;
using System.Linq;

namespace PluginEngine.Common
{
    public abstract class PluginManager<TPluginInformation, TPluginType> where TPluginInformation : PluginInformation<TPluginType> where TPluginType : Plugin
    {
        protected string BaseDirectory;
        protected string SearchPattern;

        protected List<TPluginInformation> LoadedPluginInformations = new List<TPluginInformation>();

        public abstract TPluginInformation LoadPlugin();
        public abstract void UnloadPlugin();

        public PluginManager()
        {
            BaseDirectory = AppDomain.CurrentDomain.BaseDirectory;
            SearchPattern = "*_Module.dll";
        }

        public PluginManager(string baseDirectory, string searchPattern)
        {
            BaseDirectory = baseDirectory;
            SearchPattern = searchPattern;
        }

        public TPluginInformation GetLoadedPlugin()
        {
            return LoadedPluginInformations.FirstOrDefault();
        }
    }
}

Our PluginManager class is an abstract class, because we need different implementations for .NET Core and .NET Framework. All we know is, that it should be able to load a plugin (LoadPlugin()) and unload a plugin (UnloadPlugin()). These two functions need to be implemented in our platform specific version of the PluginManager class. With a combination of BaseDirectory and SearchPattern we are able to provide a pattern with which the loader searches for DLLs. Furthermore we store information about our loaded plugins in a list and provide a function to access it. Regarding our intentions to be able to load multiple plugins of the same type, we use a list to store the loaded plugins an not a variable of a single type. Initially we will only provide the functionality to load one single plugin of a type.

These classes should provide us with a solid base for our platform specific implementations. But why do we need to use platform specific code, why can’t we just use .NET Standard all along the line? Well the reason as said above, is that we need to load our DLLs differently and i will try to explain why.

Loading of Assemblies in .NET Framework and .NET Core

Every program has a default application domain (AppDomain) where the code gets executed. Assemblies are loaded in a specific application domain. If you know the path of your DLL you can easily load the Assembly and create an instance of a class in that assembly. It is even possible to do this in .NET Standard without platform specific code. Altough you can load a specific assembly in an AppDomain, you can not unload it. You can only unload the whole AppDomain. Because of this we need to create a new AppDomain and load our plugin in this specific instance. If we now unload the new AppDomain also our plugin gets unloaded with it. This works great in .NET Framework.

The problem here is, that in .NET Core there can only be one AppDomain. On the documentation page for the AppDomain class in the .NET Framework you can find the following text:

On .NET Core, the AppDomain implementation is limited by design and does not provide isolation, unloading, or security boundaries. For .NET Core, there is exactly one AppDomain. Isolation and unloading are provided through AssemblyLoadContext. Security boundaries should be provided by process boundaries and appropriate remoting techniques.

https://docs.microsoft.com/en-us/dotnet/api/system.appdomain?view=netframework-4.8#remarks

.NET Core uses AssemblyLoadContext to provide the ability to load and unload our assemblies. Therefore we have to separate the code to load and unload our plugins in the platform specific libraries/projects PluginEngine.NetCore and PluginEngine.NetFramework.

In the following posts we will describe the implementation of the PluginManager class and its load and unload functions for each platform.

Markus

I'm a full time .NET developer for a living, love coffee and I'm interested in new technologies.

Leave a Reply

avatar
  Subscribe  
Notify of