A simple library that allows to implement a plugin-based architecture.
A simple library that allows to implement a plugin-based architecture.
See the API documentation for more information on this project.
This library contains these features:
.json or .env.This library contains these limitations:
I designed this library for use it in the DentallApp project and for other projects according to my needs.
I wanted to share my knowledge with the community. I love open source.
I'm a big fan of plugin-based architecture. I always had the desire to create my own plugin system ever since I was playing SA-MP (San Andreas Multiplayer, a multiplayer mod for GTA San Andreas).
In SA-MP it is possible to extend the functionalities provided by the game server (samp-server) without having to know its source code. Just go to the server.cfg file and specify the plug-ins to load and that's all. Amazing!
It consists of a host application (or a main application) that provides public API which the plug-in can use, including a way for plug-ins to load into the host application. Plug-ins depend on the services (public API) provided by the host application and do not usually work by themselves. Conversely, the host application operates independently of the plug-ins, making it possible for developers to create plug-in projects without making changes to the host application or knowing how it works.
Rules to be complied with:
There are three components for this pattern:
An example where this pattern can be applied is this:
An ERP software has many modules such as accounting, project management, payroll, treasury, sales, among others. However, what happens if the customer does not need all the modules? What if the customer only needs the accounting module?
That's where the plugin-based architecture comes in. You can make your system flexible by converting your modules into plug-ins. This way the customer will not have access to other functionalities, only the ones he/she has purchased.
This is amazing, because if your main application has a configuration file, you can write there the plug-ins to be loaded for this customer. Oh yeah!
This also has the benefit of reducing the size of an application by not loading unused features.
When implementing this pattern in .NET there can be a number of technical challenges:
Define the API that is shared between the host application and the plugins. The API must be stable enough; otherwise, it may lead to breaking changes, so this will affect all plugins.
Resolve the dependencies of each plugin; failure to do so may cause errors when running the host application. In this case you should read about AssemblyDependencyResolver.
Several plugin can use the same dependency but with different version. Therefore, the plugins must be isolated in different contexts. In this case you should read about AssemblyLoadContext.
Ideally, plugins should not depend on each other (reduce coupling), but in such cases a mechanism must be found that allows them to communicate with each other (e.g. a message broker).
There are cases where the host application and the plugins have a reference to the same version of a dependency, so in their output directories they will have a copy of the same dependency. This may cause unexpected behavior when running the host application. See this thread or this one too.
To correctly implement this pattern in .NET, it is necessary to know how AssemblyLoadContext works. This article explains it very well.
Install the main package using dotnet CLI:
dotnet add package CPlugin.NetThis package was designed to be used in host applications such as a web api or a console application.
You must also install this secondary package that will be used in your plugins:
dotnet add package CPlugin.Net.AttributesThis package provides only one type: PluginAttribute and is used only in plugins.
Your host application must reference the CPlugin.Net package.
You must import the namespace types at the beginning of your class file:
using CPlugin.Net;This library provides four main types:
CPluginEnvConfigurationCPluginJsonConfigurationPluginLoaderTypeFinderSee the API documentation for more information on these types.
For this case you need to install the Microsoft.Extensions.Configuration.Json package to read application settings from the JSON configuration file.
Your .json file must use the Plugins section to specify the names of each plugin.
Example:
{
"Plugins": [
"MyPlugin1.dll",
"MyPlugin2.dll",
"MyPlugin3.dll"
]
}Then you can use the CPluginJsonConfiguration type to get the plugin files.
var configurationRoot = new ConfigurationBuilder()
.AddJsonFile("./appsettings.json")
.Build();
var jsonConfiguration = new CPluginJsonConfiguration(configurationRoot);
List<string> pluginFiles = jsonConfiguration.GetPluginFiles().ToList();GetPluginFiles method will get the full path to each plugin that is in the Plugins section of the .json file.
Example:
/home/admin/HostApplication/bin/Debug/net8.0/plugins/MyPlugin1/MyPlugin1.dll
/home/admin/HostApplication/bin/Debug/net8.0/plugins/MyPlugin2/MyPlugin2.dll
/home/admin/HostApplication/bin/Debug/net8.0/plugins/MyPlugin3/MyPlugin3.dllIt is very important that the plugins are always in the plugins folder and in their own directory as in the previous example.
MyPlugin1.dll is inside the MyPlugin1 directory and in turn, it is in the plugins directory.
Why should this be so? Well, the plugin loader must somehow locate the plugins, right?
You can also put the names of each plugin in an .env file, although for this you must install a dotenv package that allows you to read and parse .env files. Use the DotEnv.Core package.
Your .env file must use the PLUGINS key to specify the names of each plugin.
Example:
PLUGINS=MyPlugin1.dll MyPlugin2.dll MyPlugin3.dllPlugin files must be separated by spaces. However, you can also use multi-line values.
Example:
PLUGINS="
MyPlugin1.dll
MyPlugin2.dll
MyPlugin3.dll
"Then you can use the CPluginEnvConfiguration type to get the plugin files.
// Load the .env file.
new DotEnv.Core.EnvLoader()
.AddEnvFile(".env")
.Load();
var envConfiguration = new CPluginEnvConfiguration();
List<string> pluginFiles = envConfiguration.GetPluginFiles().ToList();GetPluginFiles method will get the full path to each plugin that is in the PLUGINS key of the .env file.
Example:
/home/admin/HostApplication/bin/Debug/net8.0/plugins/MyPlugin1/MyPlugin1.dll
/home/admin/HostApplication/bin/Debug/net8.0/plugins/MyPlugin2/MyPlugin2.dll
/home/admin/HostApplication/bin/Debug/net8.0/plugins/MyPlugin3/MyPlugin3.dllAs mentioned in the previous section. The plugins must be in the plugins directory and in their own directory so that the plugin loader can locate them.
I like this approach because the path is calculated automatically. You no longer have to hardcode the path manually.
Your host application is responsible for loading the plugins at runtime using the PluginLoader type.
In the Program.cs (entry point) call the plugin loader.
An example using as configuration source a .env file:
new DotEnv.Core.EnvLoader()
.AddEnvFile(".env")
.Load();
var envConfiguration = new CPluginEnvConfiguration();
// Loads the plugins from the .env file.
PluginLoader.Load(envConfiguration);An example using as configuration source a .json file:
var configurationRoot = new ConfigurationBuilder()
.AddJsonFile("./appsettings.json")
.Build();
var jsonConfiguration = new CPluginJsonConfiguration(configurationRoot);
// Loads the plugins from the .appsettings file.
PluginLoader.Load(jsonConfiguration);Load method will load the plugin in a different context, so it doesn't matter if your plugins have conflicting dependencies. All this thanks to AssemblyLoadContext.
It is important to know that the PluginLoader.Load is idempotent, so if you call the method several times it will have the same effect as if it had been the first call. It will not load the same plugin in the current process in which the application is running.
Each plugin represents an assembly containing a collection of types and methods.
After loading plugins from a configuration source, you can obtain the loaded assemblies using the Assemblies property of the PluginLoader type.
PluginLoader.Load(configuration);
IEnumerable<Assembly> assemblies = PluginLoader.Assemblies;This property is very useful when you want to add the loaded assemblies to a third-party dependency (Fluent Validation, Scrutor, MediatR, among others) so that can search the types.
An example using ASP.NET Core as host application:
var builder = WebApplication.CreateBuilder(args);
var jsonConfiguration = new CPluginJsonConfiguration(builder.Configuration);
PluginLoader.Load(jsonConfiguration);
IMvcBuilder mvcBuilder = builder.Services.AddControllers();
foreach (Assembly assembly in PluginLoader.Assemblies)
{
// This allows to register the controllers for each loaded plugin.
mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(assembly));
}If your plugins have controllers, then you must make the plugin part of the application; otherwise, the controllers will never be recognized by the host.
The host application does not have a direct reference to the plugins, there is no such coupling, so how do they communicate?
These are communicated through contracts. The host application must expose a set of contracts where each plugin must implement it.
These contracts can be represented by interfaces or abstract classes of C#. Remember a contract is only a specification, it indicates the "what you do" but not the "how you do it". It is just a set of rules that each plugin must follow.
For example, we can create a project called Plugin.Contracts and it contains the following contract:
public interface ICommand
{
string Name { get; }
string Description { get; }
int Execute();
}This contract indicates three rules that the plugin must comply with:
Following the example above, the ICommand interface represents the supertype, so it can have many subtypes, which means that there will be concrete implementations.
These subtypes will be encapsulated in their own plugins, so the host application does not know about them (has no idea about them).
So a mechanism must be applied so that the host application can create the instance of the subtype that implements the supertype when the application is running.
Therefore, you can use the TypeFinder type in your host application after you have loaded the plugins.
var configurationRoot = new ConfigurationBuilder()
.AddJsonFile("./appsettings.json")
.Build();
var jsonConfiguration = new CPluginJsonConfiguration(configurationRoot);
PluginLoader.Load(jsonConfiguration);
IEnumerable<ICommand> commands = TypeFinder.FindSubtypesOf<ICommand>();
foreach(ICommand command in commands)
{
command.Execute();
}FindSubtypesOf method will search for the subtypes of ICommand in each plugin that has been loaded; if no subtype is found, it returns an empty enumerable.
For this method to work correctly, each plugin must use the PluginAttribute type to specify the subtypes. This is mandatory because the TypeFinder type creates the instances of the subtypes using this attribute.
This attribute must be applied at the assembly level in the plugin project. Do not forget to install the CPlugin.Net.Attributes package in the plugin project in order to be able to use the PluginAttribute type.
Example:
using CPlugin.Net;
using Project.PluginExample;
using Plugin.Contracts;
// This line is mandatory.
[assembly: Plugin(typeof(HelloWorldCommand))]
namespace Project.PluginExample;
public class HelloWorldCommand : ICommand
{
public string Name => nameof(HelloWorldCommand);
public string Description => "Outputs Hello Word";
public int Execute()
{
System.Console.WriteLine("Hello World!");
return 0;
}
}HelloWorldCommand type is a subtype of ICommand. Absolutely nobody knows about this implementation, not even the host application. However, this attribute is not necessary if the plugin does not need to implement any contract.
It is recommended to create a .props file that is used globally for all plugin projects.
When you compile your plugins, the result of the compilation should be copied to the output directory of the host application, so you can specify it in the Directory.Build.props file.
Given the following project structure:
└── MyApp/
├── src/
│ ├── HostApplication/
│ │ ├── bin/Debug/net8.0/plugins/
│ │ │ ├── MyPlugin1/
│ │ │ │ └── MyPlugin1.dll
│ │ │ ├── MyPlugin2/
│ │ │ │ └── MyPlugin2.dll
│ │ │ └── MyPlugin3/
│ │ │ └── MyPlugin3.dll
│ │ ├── Program.cs
│ │ └── HostApplication.csproj
│ ├── Plugins/
│ │ ├── MyPlugin1/
│ │ │ └── MyPlugin1.csproj
│ │ ├── MyPlugin2/
│ │ │ └── MyPlugin2.csproj
│ │ ├── MyPlugin3/
│ │ │ └── MyPlugin3.csproj
│ │ └── Directory.Build.props
│ └── Contracts/
│ ├── ICommand.cs
│ ├── IWebStartup.cs
│ └── Contracts.csproj
├── tests/
└── MyApp.slnNOTE: This structure is only an example.
Plugins directory must contain the Directory.Build.props file that will be shared for each plugin project.
The .props file could look like this:
<Project>
<PropertyGroup>
<Configuration Condition="$(Configuration) == ''">Debug</Configuration>
<ProjectRootDir>$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'MyApp.sln'))</ProjectRootDir>
<OutDir>$(ProjectRootDir)/src/HostApplication/bin/$(Configuration)/$(TargetFramework)/plugins/$(MSBuildProjectName)</OutDir>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(ProjectRootDir)/src/Contracts/Contracts.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<PackageReference Include="CPlugin.Net.Attributes" Version="1.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
</Project><Configuration Condition="$(Configuration) == ''">Debug</Configuration>If the $(Configuration) property is not defined, then by default it will be Debug. This is useful when you want to compile a particular plugin project with dotnet build, to ensure that the configuration is set even if its value is an empty string.
<ProjectRootDir>$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), 'MyApp.sln'))</ProjectRootDir>GetDirectoryNameOfFileAbove is MSBuild property function that allows you to search for a specific file in the parent directories.
Function signature:
GetDirectoryNameOfFileAbove(string startingDirectory, string fileName)Description:
Locate and return the directory of a file in either the directory specified or a location in the directory structure above that directory.
In this case this function is used to obtain the root directory of the project, so the idea is to search the MyApp.sln file until it is found.
$(MSBuildThisFileDirectory) will give us the path where the current Directory.Build.props file is located (in this case it is MyApp/src/Plugins/).
<OutDir>$(ProjectRootDir)/src/HostApplication/bin/$(Configuration)/$(TargetFramework)/plugins/$(MSBuildProjectName)</OutDir>Once the root directory of the project is obtained, it is necessary to indicate where the compilation result of our plugins should be copied.
OutDir will contain these possible values:
/home/admin/MyApp/src/HostApplication/bin/Debug/net8.0/plugins/MyPlugin1/
/home/admin/MyApp/src/HostApplication/bin/Debug/net8.0/plugins/MyPlugin2/
/home/admin/MyApp/src/HostApplication/bin/Debug/net8.0/plugins/MyPlugin3/Remember that the host application needs to know where to locate the plugins in order to load it.
<EnableDynamicLoading>true</EnableDynamicLoading>NuGet references are copied locally.
See EnableDynamicLoading.
This tag is necessary because the third-party dependencies used by the plugin must be copied to the output directory; otherwise, the host application may throw an exception when loading the plugins, because the NuGet references are not found.
<ProjectReference Include="$(ProjectRootDir)/src/Contracts/Contracts.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>These are the contracts shared between the host application and the plugins and in order not to have to copy this same reference in each .csproj file of a plugin, we can add it at once in the Directory.Build.props file.
<Private>false</Private>. This tells MSBuild not to copy Contracts.dll to the plugin output directory.
<ExcludeAssets>runtime</ExcludeAssets>. This setting has the same effect as <Private>false</Private> but works on package references that the Contracts project or one of its dependencies may include.
The Contracts.dll assembly must only be copied to the output directory of the host application; otherwise, the FindSubtypesOf method will always return an empty enumerable.
<PackageReference Include="CPlugin.Net.Attributes" Version="1.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference><ExcludeAssets>runtime</ExcludeAssets>. This avoids having to copy CPlugin.Net.Attributes.dll and its dependencies to the plugin output directory.
Some plugins have a reference to the CPlugin.Net.Attributes package, so you should not copy the CPlugin.Net.Attributes.dll assembly to the plugin output directory. This is because the host application already contains such an assembly; otherwise, the FindSubtypesOf method will always return an empty enumerable.
See this thread: Why can't I copy assemblies like Example.Contracts.dll and CPlugin.Net.Attributes.dll to the plugin output directory?
You need to add the package called CopyPluginsToPublishDirectory in the project file of the host application. This package allows to copy the plugins directory from the output directory (e.g. bin/Debug/net8.0) to the publish directory.
Example:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CopyPluginsToPublishDirectory" Version="1.0.0" />
</ItemGroup>
</Project>NOTE: Do not forget to compile the plugins before publishing. The easiest way is to have a solution file (.sln) with all the plugins so you can compile it at once.
You can find a complete and functional example in these projects:
Any contribution is welcome! Remember that you can contribute not only in the code, but also in the documentation or even improve the tests.
Follow the steps below: