A plugin system with .NET Core
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.
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.
Implementing the plugins in another assembly
The plugin interface defined by the application is simple.
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.
Don’t be surprised by the chosen operations - they are well-known.
Putting all together
Let’s get seriously about our code and do some math!
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.
Just after the scan, the list is growing and our plugin assembly is added to the list: CodeTherapistBlogPluginA.dll.
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.
The plugin host will load the assembly (CodeTherapistBlogPluginA.dll) again and execute all calculations.
Triggering GC will remove our plugin assembly again.
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:
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:
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 :)