Plugin architecture with ASP.NET Core and Autofac
The plugin architecture is definitely the trickiest part of TeamScreen yet. I encountered many problems during its creation and needed to compromise on few things. Treat this article more like proof of concept rather than the fully-mature solution – it works, but I believe it could be done better. If you have better idea, please share it in comments – I’m always open to constructive feedback 🙂
The requirements for the architecture are – the plugin itself is packed in a DLL file or something similar, after copying it to the main TeamScreen directory and application restart – it’s recognized and can be used. TeamScreen provides only main structure for the plugin, which allows for a great degree of freedom when developing it.
I started work by moving all of the TeamCity logic to another repository. Most of it started working from the get go. The main issue I haven’t been able to handle yet is the lack of intellisense and false-positive errors on cshtml plugin’s view. When the application is run everything works just fine, that’s why I mentioned false-positive errors. Even stack-overflow haven’t been able to help me. Apart from that – it works.
Views are now set as an embedded resource, so they’re attached to DLL file – one less thing to copy 🙂 To allow ASP.NET Core to find such views I needed to set it up in Startup class:
1 2 3 4 5 6 7 8 9 10 11 |
private void SetupEmbeddedViewsForPlugins(IServiceCollection services, IEnumerable<Assembly> pluginAssemblies) { services.Configure<RazorViewEngineOptions>(options => { foreach (var assembly in pluginAssemblies) { var embeddedFile = new EmbeddedFileProvider(assembly); options.FileProviders.Add(embeddedFile); } }); } |
To find plugin assemblies I use this code:
1 2 3 4 5 6 7 |
private Assembly[] GetPluginAssemblies() { return Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "TeamScreen.Plugin.*.dll", SearchOption.AllDirectories) .Where(x => !x.Contains("TeamScreen.Plugin.Base.dll")) .Select(AssemblyLoadContext.Default.LoadFromAssemblyPath) .ToArray(); } |
I search the current directory for DLL files which names starts with TeamScreen.Plugin. I omit TeamScreen.Plugin.Base.dll, because it’s assembly containing base types for plugins. Next, I load assemblies. It must be done this way, loading using AssemblyName doesn’t work for some reason.
TeamScreen.Plugin.Base project serves as a base project for all Plugins. Right now, the most important part of it is IPlugin interface:
1 2 3 4 5 6 7 8 9 10 11 |
using Microsoft.AspNetCore.Mvc; namespace TeamScreen.Plugin.Base { public interface IPlugin { string Name { get; } string GetContentUrl(IUrlHelper urlHelper); string GetSettingsUrl(IUrlHelper urlHelper); } } |
I believe it’s self-explanatory, every plugin needs to provide its name, URL to view and URL to settings view, for TeamCity plugin it is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using Microsoft.AspNetCore.Mvc; using TeamScreen.Plugin.Base; using TeamScreen.Plugin.TeamCity.Controllers; namespace TeamScreen.Plugin.TeamCity { public class TeamCityPlugin : IPlugin { public string Name { get; } = "TeamCity"; public string GetContentUrl(IUrlHelper urlHelper) { return urlHelper.Action(nameof(TeamCityController.Content), "TeamCity"); } public string GetSettingsUrl(IUrlHelper urlHelper) { return urlHelper.Action(nameof(TeamCityController.Settings), "TeamCity"); } } } |
With the creation of plugin architecture, built-in Dependency Injection container wasn’t sufficient – I changed it to the Autofac, which is one of the most popular DI containers in the .NET world. I used it with success in many projects in past. What I needed from Autofac are two features – modules and multiple implementations of a single interface. Modules allow you to separate registrations to different classes and even different assemblies. Module for TeamCity plugin:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
using Autofac; using TeamScreen.Plugin.Base; using TeamScreen.Plugin.TeamCity.Integration; using TeamScreen.Plugin.TeamCity.Mapping; namespace TeamScreen.Plugin.TeamCity { public class TeamCityModule : Module { protected override void Load(ContainerBuilder builder) { builder.RegisterType<TeamCityService>().As<ITeamCityService>(); builder.RegisterType<BuildMapper>().As<IBuildMapper>(); builder.RegisterType<TeamCityPlugin>().As<IPlugin>().PreserveExistingDefaults(); } } } |
All plugins will implement IPlugin interface, basing on that and using Autofac ability to resolve such interface I can easily get a list of all plugins in the system, which is the job of PluginService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc; using TeamScreen.Models.Settings; using TeamScreen.Plugin.Base; namespace TeamScreen.Services.Plugins { public interface IPluginService { string[] GetUsedPluginsUrls(IUrlHelper urlHelper); PluginSettingsEndpoint[] GetPluginSettingsUrls(IUrlHelper urlHelper); } public class PluginService : IPluginService { private readonly IEnumerable<IPlugin> _plugins; public PluginService(IEnumerable<IPlugin> plugins) { this._plugins = plugins; } public string[] GetUsedPluginsUrls(IUrlHelper urlHelper) { return _plugins .Select(x => x.GetContentUrl(urlHelper)) .ToArray(); } public PluginSettingsEndpoint[] GetPluginSettingsUrls(IUrlHelper urlHelper) { return _plugins .Select(x => new PluginSettingsEndpoint(x.Name, x.GetSettingsUrl(urlHelper))) .ToArray(); } } } |
By using parameter IEnumerable<IPlugin> I tell Autofac to resolve all classes that implement IPlugin interface. During registration we must additional use method PreserveExistingDefaults, otherwise, next registration will overwrite previous one. Usage in TeamCityModule:
1 |
builder.RegisterType<TeamCityPlugin>().As<IPlugin>().PreserveExistingDefaults(); |
I also created PluginController, which uses PluginService to deliver information about plugins on UI:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using TeamScreen.Models.Settings; using TeamScreen.Services.Plugins; namespace TeamScreen.Controllers { public class PluginController : Controller { private readonly IPluginService _pluginService; public PluginController(IPluginService pluginService) { _pluginService = pluginService; } public IActionResult GetUsedPluginsUrls() { return Json(_pluginService.GetUsedPluginsUrls(Url)); } public IActionResult GetPluginsEndpoints() { var coreSettingsEndpoint = new PluginSettingsEndpoint(Const.CorePluginName, Url.Action("CoreSettings","Settings")); var pluginSettingsUrls = _pluginService.GetPluginSettingsUrls(Url); var result = new List<PluginSettingsEndpoint>{coreSettingsEndpoint}; result.AddRange(pluginSettingsUrls); return Json(result); } } } |
The code is I believe straight-forward, I only needed in GetPluginsEndpoints method to add core settings for TeamScreen itself.
UI created in previous posts was also needed to be altered to take into account, plugin list can be dynamic. Container view for displaying plugins:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
@model TeamScreen.Models.Settings.CoreSettings <div id="app"> <div id="contentContainer"></div> <div class="controls"> <i class="fa fa-chevron-left" aria-hidden="true" v-on:click="loadPreviousPlugin"></i> <i class="fa fa-pause" aria-hidden="true" v-if="!paused" v-on:click="pause"></i> <i class="fa fa-play" aria-hidden="true" v-if="paused" v-on:click="resume"></i> <i class="fa fa-chevron-right" aria-hidden="true" v-on:click="loadNextPlugin"></i> <a href='@Url.Action("Index","Settings")'> <i class="fa fa-cog settings-button" aria-hidden="true"></i> </a> </div> </div> @section Scripts{ <script type="text/javascript"> var vm = new Vue({ el: "#app", data: { index: 0, availablePlugins: [], interval: @Model.Interval, paused: false, timer: 0, }, methods: { loadPreviousPlugin: function () { if (this.index > 0) this.index--; else this.index = this.availablePlugins.length - 1; $("#contentContainer").load(this.availablePlugins[this.index]); }, loadNextPlugin: function () { if (this.index < this.availablePlugins.length - 1) this.index++; else this.index = 0; $("#contentContainer").load(this.availablePlugins[this.index]); }, pause: function() { this.paused = true; }, resume: function() { this.paused = false; }, tick: function () { if (this.paused) return; this.timer++; if (this.timer == this.interval) { this.loadNextPlugin(); this.timer = 0; } }, handleKeyboard: function (event) { if (event.keyCode === 32) //space this.paused = !this.paused; else if (event.keyCode === 37) //left arrow this.loadPreviousPlugin(); else if (event.keyCode === 39) //right arrow this.loadNextPlugin(); event.preventDefault(); }, }, mounted: function () { this.$http.get('@Url.Action("GetUsedPluginsUrls", "Plugin")').then(function(response) { this.availablePlugins = response.body; this.loadNextPlugin(); setInterval(this.tick, 1000); }, function(response) { alert("Error during retrieving list of used plugins"); }); $(document).keydown(this.handleKeyboard); } }); </script> } |
Settings view for displaying all settings:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
<h1>Settings</h1> <div id="app"> <ul class="nav nav-tabs"> <li v-for="endpoint in settingsEndpoints"><a data-toggle="tab" v-bind:href="'#' + endpoint.pluginName">{{endpoint.pluginName}}</a></li> </ul> <div class="tab-content"> <div v-for="endpoint in settingsEndpoints" v-bind:id="endpoint.pluginName" class="tab-pane fade"> </div> </div> </div> @section Scripts{ <script type="text/javascript"> var vm = new Vue({ el: "#app", data: { settingsEndpoints: [] }, methods: { loadSettingsContent: function () { for (var i = 0; i < this.settingsEndpoints.length; i++) { $("#" + this.settingsEndpoints[i].pluginName).load(this.settingsEndpoints[i].settingsUrl); } $(".tab-pane").first().addClass("active in"); } }, mounted: function() { this.$http.get('@Url.Action("GetPluginsEndpoints","Plugin")').then(function(response) { this.settingsEndpoints = response.body; setTimeout(this.loadSettingsContent, 0); }); }, }); </script> } |
With all that code, the application is now much more flexible. There is still a lot of work – I created mostly frame for future, but it is something that needed to be done. Thank you for reading and hope to see you next time 🙂
3 thoughts on “Plugin architecture with ASP.NET Core and Autofac”
Hi, did you put this project on GitHub? I couldn’t find any info about that in your posts.
Yes, project is available on GitHub – https://github.com/kkalinowski/TeamScreen