Plugins Isolation Without AppDomains Overhead
July 12, 2016 | | Tags : Reflection C# Impromptu Runtime Plugins
I’ve just released Impromptu - a lightweight .NET framework for plugin based systems. It replaces the default .NET assembly binding mechanism with a custom one. This allows to isolate the resolution of plugin’s referenced assemblies within the plugin’s own directory. As a result there is no need to isolate each plugin into its own AppDomain, which is extremely expensive and inefficient. The framework also makes plugin instantiation extremely fast, since upon the first plugin use, it creates, compiles, and stores a special static Instantiator class, which makes subsequent creation of plugin instances almost as fast as if it was bound during the compile time.
Here is a brief description of a problem that I am trying to solve:
Each AppDomain in .NET has a Base Directory where all application assemblies are placed when a .NET application is built. If an assembly uses types from another assembly, it stores a reference to the full name of that assembly. Build process copies the referenced assembly to the output folder of a referencing assembly. During the execution, all referenced assemblies are being looked up either in the Base Directory or in GAC (Global Assembly Cache). But what happens when both assemblies, referencing and referenced, are both independently referencing other assemblies that happen to have the same name? For example - one references Newtonsoft.Json.dll v7.0.1, and another - Newtonsoft.Json.dll v9.0.1? Since there can be only one Newtonsoft.Json.dll in the output folder, the first assembly wins, and only one version will be present in the output folder of an application. In any case there may be a risk that the second assembly, that lost, may have used some methods that only existed in its own referenced version of Newtonsoft.Json.dll. This will lead to the runtime errors. In the end these types of errors can be easily caught and resolved during the application testing, and developers can find a way to resolve these issues, one way or another.
It is even worse with plugins. They can be developed by third party vendors, who may not keep up to date with the versions of the assemblies used in the main project. Such vendors may even independently use the same assembly names. The default .NET binding mechanism will first try to resolve the referenced assemblies by name from the main application Base Directory or GAC, regardless of the fact that a plugin have brought all of its dependencies with it. This makes a risk of having runtime errors even higher.
One of the approaches to isolate plugins - is to use separate AppDomains for each plugin’s assemblies. However this comes at a huge price of crossing AppDomain borders. . Even a simple call across these borders takes way more time than a call to an assembly in the same AppDomain. And this is not the only one issue with this approach.
The framework has he following requirements:
- A plugin must be distributed as a NuGet package with a special structure - all of the plugin assemblies need to be placed into a package folder “impromptu”.
- A plugin needs to implement a base class or an interface (Base Type) that is also known to the main application, therefore the only assembly that will be resolved to the main application binding context, will be the one that contains that type (Shared Assembly). Inside the repository, there is an example of an application “calculator” that uses plugins Additor and Subtractor.
- All communication between main application and plugins needs to use only .NET framework types, or types that are defined in the Shared Assembly. If main application and a plugin resolves the type each to their own local assembly, then a type mismatch exception will be thrown. To avoid this, such types need to be serialized by caller, and deserialized by callee. Don’t use standard serializer, use JSON.NET instead.
- Main application AppDomain needs to be modified as it is demonstrated in the “calculator” example. We need to prevent default assembly dependency resolution in the AppDomain Base Directory:
appDomainSetup.DisallowApplicationBaseProbing = true;
There are only a few simple steps that are necessary to create new instances of a plugin:
-
Restart an application in a domain with restricted Base Directory probing:
appDomainSetup.DisallowApplicationBaseProbing = true;
-
Wire up resolver in the default context:
DefaultContext.WireUpResolver();
-
Create an instance of the type responsible for retrieving NuGet packages with plugins inside:
var nugetPackageRetriever = new Impromptu.Package.NugetPackageRetriever(new[] { [path_to_nuget_source] });
-
Create Instantiator Factory that will be responsible for creating new instances:
var factory = new InstantiatorFactory<[BaseType]>;(nugetPackageRetriever);
-
Start creating instances:
var instance = factory.Instantiate(new InstantiatorKey("[name_of_nuget_package]", "[version_of_nuget_package]", "[type to instantiate]"), [constructorArg1], ..., [constructorArgN]);
The first in stance creation might be a bit slow, but all subsequent instances will be created at a lightning speed. Of course, since I am not using separate AppDomains, I am not able to unload assemblies, but in my opinion it is a minor inconvenience.