9.2 KiB
+++ title = "#2 Plugin-Based Web App in Dotnet - Loading Plugins" date = "2024-02-4T00:00:00+00:00" author = "the1mason" authorTwitter = "the0mason" #do not include @ tags = ["dotnet", "web", "prototype"] keywords = ["prototype", "dotnet", "guide", "plugins", "plugin-based", "web application", "ASP.NET", "C#", ".NET 8", "Programming", "Software Architecture"] description = "The second part of my journey to create a modular based web app. In this part I'll tell you how do you load plugins during runtime." showFullContent = false readingTime = true hideComments = false draft = false +++
THIS IS A WORK IN PROGRESS ARTICLE! I AM ACTIVELU EDITING IT RIGHT NOW!
Chapters
Writing those takes time. Expect to see one published per one-two weeks.
-
Loading plugins
-
PluginBase, IPlugin -
Creating plugin, DependencyInjection -
Controllers, Views -
Hooks and Triggers - better event system -
Advanced: Unit tests, unloading plugins
Dynamic loading
C# being a compiled language requires to dynamically load compiled assemblies in memory using reflection in order to modify the application. In this part I will tell you about .net's built-in methods to do this. Along with achieving the needed result: assemblies being loaded, - we'll solve problems like findng plugin's main .dll, dependency resolving and avoiding dependency version conflicts.
What do we load? (Plugin folder, Manifest.json)
First of all, we need to determine where do we look for the .dll files that we want to load. My solution is simple: We will create a folder /Plugins in the app's current directory and store plugin's files in folders with this structure:
{{< code language="txt" title="Example directory tree" id="1" expand="Show" collapse="Hide" isCollapsed="false" >}}
Plugins
- ExamplePlugin
-
- ExamplePlugin.dll
-
- ExamplePlugin.deps.json
-
- ExamplePluginDependency.dll
-
- manifest.json
-
- Assets/...
- NicePlugin
-
- NicePlugin.dll
-
- NicePlugin.deps.json
-
- manifest.json
{{< /code >}}
Now we need a way to determine: which .dll file is the main plugin file. I decided to create a manifest.json file - a file that contains plugin's metadata: name, version, author and main .dll file name.
Our first step would be to load and parse all manifest.json files, then we load main .dll files, referenced in manifests.
{{< code language="json" title="manifest.json" id="1" expand="Show" collapse="Hide" isCollapsed="false" >}}
{ "id": "example.plugin", "version": "1.23.4:5678", "name": "Example Plugin", "assembly": "EamplePlugin.dll" }
{{< /code >}}
AssemblyLoadContext
image placeholder: assemblyLoadContextDiagram.svg
The next question is how are we going to load those .dll files, that we got from manifest.json files?
AssemblyLoadContext is used to isolate groups of loaded assemblies from each other to resolve version conflicts. Let's say that PluginA references CoolLibrary.Abstractions v.3.1, while PluginB is referencing the same CoolLibrary.Abstractions, but v.2.5. In order for this to work, we will create an AssemblyLoadContext for each main .dll plugin and load it's dependencies from this context.
Oh right! Plugins have dependencies, but AssemblyLoadContext WOULD NOT find those automagically. We will need to explicitly tell it to look for them. In order to do so, we will create a PluginLoadContext : AssemblyLoadContext. This class will use an AssemblyDependencyResolver class that will read .deps.json file of a provided file and resolve dependencies from it.
.deps.json is created automatically when you compile any csharp project and includes all your dependencies!
{{< code language="csharp" title="PluginLoadContext" id="2" expand="Show" collapse="Hide" isCollapsed="false" >}}
public class PluginLoadContext : AssemblyLoadContext { private AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base()
{
// initialize the dependency resolver
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly Load(AssemblyName assemblyName)
{
// use the dependency resolver to find all depended libraries and load them
string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
// if successful, load the main assembly
if (assemblyPath != null)
{
return LoadFromAssemblyPath(assemblyPath);
}
return null!;
}
// essentially, dark magic. don't think about it 😛
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (libraryPath != null)
{
return LoadUnmanagedDllFromPath(libraryPath);
}
return IntPtr.Zero;
}
}
{{< /code >}}
Project template for the plugin file
This part is more about what we will need in the future. As our main application uses the MVC pattern, our plugins would have views and controllers. We should modify our .csproj as follows:
{{< code language="xml" title="ExamplePlugin.cshtml" id="3" expand="Show" collapse="Hide" isCollapsed="false" >}}
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
</PropertyGroup>
<ItemGroup>
<Reference Include="Prototype.ModularMVC.PluginBase">
<Private>false</Private>
<HintPath>..\Prototype.ModularMVC.App\Prototype.ModularMVC.PluginBase\bin\Debug\net8.0\Prototype.ModularMVC.PluginBase.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App">
</FrameworkReference>
</ItemGroup>
<ItemGroup>
<Content Update="manifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
{{< /code >}}
Explanation:
Microsoft.NET.Sdk.Razorwill make sure that our.cshtmlrazor pages will be compiled to classes and added to the output assembly.EnableDynamicLoadinghints that this library is intended to be loaded dynamically, rather than executed or referenced directly.Microsoft.AspNetCore.Appframework reference will allow us to use ASP.NET-specific classes.AddRazorSupportForMvcspeaks for itself, doesn't it?manifest.jsonshould be created and filled by the author. Then copied to the output directory.- Referencing
PluginBaseas well as thePluginBaselibrary itself will be explained in the next chapter.
Conclusion
At this point, we can locate and load our plugins with their dependencies at runtime.
This article is based on this microsoft learn article, as well as my code base. I have added some abstractions to make it easier to extend my loading mechanism, so the original article might be a little easier to understand. I didn't simplified my examples because I think that anybody aiming to create the same plugin system as I did will eventually reach similar conclusions and will make similar modifications to the code from the original article.
- Program.cs - using manifest loader and plugin loader to load plugins
- ManifestLoader.cs - loads manifests
- ManifestBasedPluginLoader.cs - loads plugins, using paths from the
ManifestLoader. - PluginLoadContext.cs - Implementation of an
AssemblyLoadContext.csthat resolves dependencies.
In the next article, we will talk about communication between the server and loaded plugins, PluginBase library, and using dependency injection to tie everything together.