website-hugo/content/posts/modular-app-2.md

207 lines
9.2 KiB
Markdown

+++
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
+++
<script src="/js/repo-card.js"></script>
<div class="repo-card" data-repo="the1mason/Prototype.ModularMVC" data-theme="dark-theme"></div>
# 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.
1. [Idea, Stack](/posts/modular-app-1)
2. Loading plugins
3. ~~PluginBase, IPlugin~~
4. ~~Creating plugin, DependencyInjection~~
5. ~~Controllers, Views~~
6. ~~Hooks and Triggers - better event system~~
7. ~~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 >}}
[ManifestLoader.cs](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.PluginBase/Impl/ManifestLoaders/ManifestLoader.cs)
[Program.cs](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.App.Server/Program.cs)
---
# 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 >}}
[PluginLoadContext.cs](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.PluginBase/PluginLoadContext.cs)
---
# 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" >}}
<Project Sdk="Microsoft.NET.Sdk.Razor">
<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>
</Project>
{{< /code >}}
Explanation:
- `Microsoft.NET.Sdk.Razor` will make sure that our `.cshtml` razor pages will be compiled to classes and added to the output assembly.
- `EnableDynamicLoading` hints that this library is intended to be loaded dynamically, rather than executed or referenced directly.
- `Microsoft.AspNetCore.App` framework reference will allow us to use ASP.NET-specific classes.
- `AddRazorSupportForMvc` speaks for itself, doesn't it?
- `manifest.json` should be created and filled by the author. Then copied to the output directory.
- Referencing `PluginBase` as well as the `PluginBase` library 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](https://learn.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support), 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](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.App.Server/Program.cs) - using manifest loader and plugin loader to load plugins
- [ManifestLoader.cs](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.PluginBase/Impl/ManifestLoaders/ManifestLoader.cs) - loads manifests
- [ManifestBasedPluginLoader.cs](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.PluginBase/Impl/PluginLoaders/ManifestBasedPluginLoader.cs) - loads plugins, using paths from the `ManifestLoader`.
- [PluginLoadContext.cs](https://github.com/the1mason/Prototype.ModularMVC/blob/869cebee5e55cb3e8bf8f228d712bbda621d6b17/Prototype.ModularMVC.App/Prototype.ModularMVC.PluginBase/PluginLoadContext.cs) - Implementation of an `AssemblyLoadContext.cs` that 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.