I’ve recently added MVVM features to the CODEGATOR CG.Blazor
NUGET package. Doing so got me thinking about other things I might be able to add. Something I’ve always wanted for web projects are plugins. Plugins are a mainstay for Microsoft’s PRISM library but they’ve never been part of ASP.NET. My guess is, that’s because of the differences in how runtime resources are located, and loaded. Also, web projects are structured differently than desktop projects, making it tougher to deal with truly dynamic content, at runtime.
Still, it would be nice to have PRISM style plugins for Blazor. Let’s see if we can make that happen. I’ll add a quick start sample application, to the Github repository for the CODEGATOR CG.Blazor
NUGET package. That sample will demonstrate everything I’ll cover in this blog.
First off, obviously, PRISM is not exactly a trivial library and I have neither the time, nor the inclination, to try to duplicate it for Blazor. Instead, I’ll focus solely on the plugin aspect.
Second, this approach won’t work for client side Blazor. I currently have no path to client side Blazor plugins. Part of that’s because I don’t know enough about client side Blazor. Part of it is because web assembly is a different kind of beast and I’m not sure It’s possible, or even desirable, to try to dynamically load assemblies on the client.
Finally, it won’t be a simple modification to add plugins. I expect this topic to extend for at least 2 or three blog posts. So, get comfy and let’s explore this idea together.
I thought I might begin with an interface for plugin modules. Here is what that looks like:
public interface IModule { void ConfigureServices( IServiceCollection serviceCollection, IConfiguration configuration ); void Configure( IApplicationBuilder app, IWebHostEnvironment env ); }
Next, I’ll create a corresponding base class, since it’s entirely possible we’ll end up with code that’s common to all plugins, at some point. Here is what that class looks like:
public abstract class ModuleBase : IModule { public abstract void ConfigureServices( IServiceCollection serviceCollection, IConfiguration configuration ); public abstract void Configure( IApplicationBuilder app, IWebHostEnvironment env ); }
Ok, so, any plugin module that wants to be initialized at load time will derive from my ModuleBase
class, then override the abstract methods ConfigureServices
and Configure
. Here is what that might look like:
public class MyModule : ModuleBase { public override void void ConfigureServices( IServiceCollection serviceCollection, IConfiguration configuration ) { // do initialization stuff here ... } public override void Configure( IApplicationBuilder app, IWebHostEnvironment env ) { // do configuration stuff here ... } }
I think that works. It’s possible I might need to add things later on but, for now, that’s a good start.
The module is easy, it’s really just an entry point for the plugin assembly – a way to ensure there’s a mechanism for registering services, and so on. From here on out though, things start to get a little trickier. For instance, I envision the plugin assemblies themselves to be nothing more than a Razor Class Library (RCL). That means each plugin library might contain pages and components that will probably require ASP.NET routing support, at runtime. I’ll have to keep that in mind …
The next thing to think about is, RCL assemblies often contain static content – style sheets, java scripts, images, etc. Sometimes they also contain embedded resources. Either way, It would be nice if I could dream up a mechanism whereby ASP.NET, which expects all content to be statically served from a central ‘wwwroot’ folder, could learn to live with content located in other places, possible living in other formats …
Finally, I want these plugins to work either as traditional RCL projects – where the plugin project lives in the same Visual Studio solution file, and everything is linked into the main web project at compile time – or, as something more modular (PRISM like?) where the plugin might not live in the same Visual Studio solution at all, and probably won’t be linked to the main website at compile time. We’ll want to make sure that, whatever we come up, it supports both development approaches.
Let’s look into the need for routing support for any pages or components that might live within a plugin assembly. Blazor already has a process for adding external assemblies, that require routing support. Traditionally, we’d do something like this to the App.razor file:
<Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="[my assemblies here]"> <Found Context="routeData"> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router>
Where the “[my assemblies here]” part would be added by me, with a list of plugin assemblies. That works except for the fact that I can’t hard code the list of plugins. So, I’ll need to keep track of a list of any plugin assemblies that require router support, then inject that list in the App.razor file. This tells me I’m going to need a class utility for holding ‘stuff’ related to plugins. I see it looking something like this:
public static class BlazorResources { public static IList<Assembly> RoutedAssemblies { get; } = new List<Assembly>(); public static IList<string> StyleSheets { get; } = new List<string>(); public static IList<string> Scripts { get; } = new List<string>(); internal static IList<IModule> Modules { get; } = new List<IModule>(); public static string RenderStyleSheetLinks() { var sb = new StringBuilder(); foreach (var link in StyleSheets) { sb.Append(link); sb.Append(" "); } var rawHTml = sb.ToString(); return rawHTml; } public static string RenderScriptTags() { var sb = new StringBuilder(); foreach (var tag in Scripts) { sb.Append(tag); sb.Append(" "); } var rawHTml = sb.ToString(); return rawHTml; } public static void Clear() { BlazorResources.RoutedAssemblies.Clear(); BlazorResources.Scripts.Clear(); BlazorResources.StyleSheets.Clear(); BlazorResources.Modules.Clear(); } }
I’m sure I’ll add more to this class as things progress. For right now though, I only see the need to track style sheets, javascripts, and, of course, the assemblies themselves. The list of modules is temporary, as we’ll see soon enough. It’s just to prevent me from having to load each plugin assembly twice, then create an instance of each Module class twice.
So, with my class utility in mind, the changes to the App.razor file look more like this:
<Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="@BlazorResources.RoutedAssemblies"> <Found Context="routeData"> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router>
That approach works as long as I can build a list of plugins to inject. I’ll get to that when I start thinking about configuring this entire thing.
Next I’ll focus on injecting static resources from plugins. In Blazor, static resources are typically manually added to the head element in the _Host.cshtml file. I’ve already added two methods, RenderStyleSheetLinks
and RenderScriptTags
, for dynamically injecting those links. That code looks like this:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>CG.Blazor.QuickStart</title> <base href="~/" /> <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" /> <link href="css/site.css" rel="stylesheet" /> @(Html.Raw(BlazorResources.RenderStyleSheetLinks())) @(Html.Raw(BlazorResources.RenderScriptTags())) </head> <body> <!-- body elided for clarity --> </body> </html>
I tried several variations, using the MarkupString
type, since I’d seen so many examples of it on the Interwebz. But, I soon realized that the _Host.cshtml file is not a _Host.razor file, and the MarkupString
approach wasn’t going to help me, so, I went back to using the Html.Raw
helper to inject my raw HTML links into the head tag. This approach works beautifully. All I need now is a way to build up the lists of style sheet links and script tags. Once again, I’ll do that when I get to configuring things.
Let’s stop and regroup. So far, I’ve devised a way of dynamically notifying Blazor that some of our plugin assemblies require routing services. I’ve also figured out a way of dynamically injecting links, for static resources, into the HTML head element. That’s a pretty good start!
So what do I have left to do? Hmm, well, a few things, I suppose. Configuration seems like a good next step. Let’s go look at that now …
So far I know that I’ll need to configure a list of plugin assemblies, and that those assemblies will need to be able to indicate whether they require ASP.NET router, at runtime. I also know that each assembly will need to support a list of (at least) stylesheets and javascript tags, to be injected into the hosts HTML head element. I’m thinking of a configuration section like this:
{ "Plugins": { "Modules": [ { //"Assembly": "name of a plugin assembly here", "Assembly": "path to a plugin assembly here", "Routed": true, "StyleSheets": [ "path to an embedded stylesheet" ], "Scripts": [ "path to an embedded script" ], "EntryPoint": "name of a module type here" } ] } }
I’ve commented the duplicate Assembly
node out here to highlight an important fact. That node needs to be able to accept either a physical path, or, a simple assembly name. Why? Well, its driven by how the plugin project we’re referencing, was included in the web application’s Visual Studio project. As I see it, there are two possibilities:
- The plugin project is part of the web application’s Visual Studio solution, so it’s probably referenced by the web application using a project link. In that case, there’s no need to physically reference the assembly, since our projects already know about each other, through Visual Studio. In this case, a simple assembly name is sufficient.
- The plugin project is NOT part of the web application’s Visual Studio project – or, at the very least, isn’t directly reference by the web application at compile time. In this case we will need to know where to locate the assembly at runtime, so, we’ll need a complete physical path to the plugin’s assembly.
I imaging this will be somewhat forgiving, but, we’ll need to know how to locate plugin assemblies at runtime or this whole thing falls apart. Bottom line is, if you don’t mind rebuilding your web project whenever you add or remove a plugin, then use the traditional approach and add your plugin project(s) to the main web solution, then link everything together with project references.
On the other hand, if you don’t want to have to rebuild your web every time you add or remove a plugin, don’t use project references between your plugin project(s) and main web project. In that case, you’ll need a physical path to each plugin assembly, in order to locate them at runtime.
Make sense? Ok, moving on then …
The next thing I’ll need is to decide how I’ll go about reading in this bit of configuration JSON. Personally, I prefer to bind my configuration directly into one or more option classes. that way, I don’t create dozens of interdependencies between my code and the underlying configuration source. If you’re unfamiliar with Microsoft’s Options pattern, HERE is a link to read.
So, I’ll next create my options, for configuring these Blazor plugin extensions. Here is what my options classes look like:
public class ModuleOptions : OptionsBase { [Required] public string Assembly { get; set; } public bool Routed { get; set; } public IList<string> StyleSheets { get; set; } public IList<string> Scripts { get; set; } public string EntryPoint { get; set; } } public class PluginOptions : OptionsBase { public const string SectionKey = "Plugins"; [Required] public IList<ModuleOptions> Modules { get; set; } }
The PluginOptions
class contains a list of type IList<ModuleOptions>
and the ModuleOptions
class contains the properties that I’ve already looked at, on the JSON side. The Assembly
property contains the name (or path) of the plugin assembly. The Routed
property indicates whether the plugin requires ASP.NET router support, or not. The StyleSheets
property contains a list of style sheet links. The Scripts
property contains a list of javascript tags. The EntryPoint
property contains the full-name of an IModule
type, for the plugin. EntryPoint
is optional, in case your plugin doesn’t need a module.
The OptionsBase
base class, and the use of the
decoration, on the RequiredAttribute
Assembly
property, are features of my extensions located in the CODEGATOR CG.Options
NUGET package. I’ll cover those in another blog post, but, for now, just know that these allow me to validate the configuration options, after I’ve bound them to an IConfiguration
object.
Alright, I now have a candidate JSON configuration that I’ll add to the sample’s appsetttings.json file. I also have a set of options classes that I can bind the sample application’s IConfiguration
object to. That should get everything started, at least.
Now I need code to actually read the configuration, bind it to my options, then use that information to drive the setup of any plugins, in Blazor. That sounds like a bunch of code to add to a typical web application’s Startup
class, so, I’ll write an extension method to isolate that code. Here is that method:
public static IServiceCollection AddPlugins( this IServiceCollection serviceCollection, IConfiguration configuration ) { Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection)) .ThrowIfNull(configuration, nameof(configuration)); BlazorResources.RoutedAssemblies.Clear(); BlazorResources.Scripts.Clear(); BlazorResources.StyleSheets.Clear(); serviceCollection.ConfigureOptions<PluginOptions>( configuration.GetSection(PluginOptions.SectionKey), out var pluginOptions ); var asmNameSet = new HashSet<string>(); foreach (var module in pluginOptions.Modules) { Assembly asm = null; if (module.Assembly.EndsWith(".dll")) { asm = Assembly.LoadFrom(module.Assembly); } else { asm = Assembly.Load(module.Assembly); } var safeAsmName = asm.GetName().Name; if (asmNameSet.Contains(safeAsmName)) { continue; } if (module.Routed) { BlazorResources.RoutedAssemblies.Add( asm ); } var staticResourceNames = asm.GetManifestResourceNames(); BuildStyleSheetLinks( asm, staticResourceNames, module ); BuildScriptTags( asm, staticResourceNames, module ); if (false == string.IsNullOrEmpty(module.EntryPoint)) { try { var type = asm.GetType( module.EntryPoint, true ); var moduleObj = Activator.CreateInstance( type ) as IModule; if (null != moduleObj) { moduleObj.Initialize( serviceCollection ); BlazorResources.Modules.Add(moduleObj); } } catch (Exception ex) { throw new InvalidOperationException( message: $"It appears the module: '{module.EntryPoint}' either doesn't exist in assembly '{safeAsmName}'. See inner exceptions for more detail.", innerException: ex ); } } } return serviceCollection; }
Alright, let’s go through this … I start, as always, by validating my incoming parameters. Next I clear out anything that was previously stored in the BlazorResources
class utility. Next, I call my ConfigureOptions
extension method on the IServiceCollection
object. That extension method is also part of the CODEGATOR CG.Options
NUGET package. I’ll write about it at some point, but, for now, just know that this method: (1) creates an instance of my PluginOptions
class, then (2), binds that object to the IConfiguration
object coming from Blazor, then (3) validates the PluginOptions
object, to ensure it contains valid information, then (4), if I had encrypted properties on my options (which I don’t, in this case), it would decrypt those for me here, then finally (5), it registers the populated, validated, decrypted instance of PluginOptions
as a singleton service, with the IServiceCollection
instance.
Next, I start using those options to drive the setup of any plugins. Since resources are added at the assembly level, I need to avoid adding any given assembly more than once. I create a hash set for that purpose. Next, I loop though all the modules from the options, and for each module, I determine if the Assembly
property contains a path, or an assembly name. Based on that, I load the assembly into memory. Once I’ve done that, I get a safe name for the assembly, that I’ll use later. Next, I check my hash set to see if I’ve already loaded this assembly. I don’t check before I load the assembly because I don’t know, until that time, whether I have a path or an assembly name, and it’s possible an assembly might be added to the configuration twice – once as a path, and again as an assembly name.
Once I have the assembly in memory, I look for a manifest. Next, I call BuildStyleSheetLinks
and BuildScriptTags
, passing in the manifest, to process those resources. We’ll look at the code for the BuildStyleSheetLinks
and BuildScriptTags
methods, soon. Next, if the assembly has an associated entry point configured, I build a type for the class, create an instance, and call the Initialize
method, passing in the IServiceCollection
instance. Finally, I temporarily cache the module instance since I’ll need it again in the UsePlugins
extension method, and it doesn’t make sense not to cache it for that purpose.
At the end of this method, I’ve read the configuration section for plugins and used that information to populate the properties on the BlazorResources
class utility. I’ve also loaded and initialized any configured plugin module types. Let’s go look at the BuildStyleSheetLinks
and BuildScriptTags
methods now:
private static void BuildScriptTags( Assembly asm, string[] staticResourceNames, ModuleOptions module ) { if (null != module.Scripts) { foreach (var resource in module.Scripts) { if (resource.IsHTML()) { throw new InvalidOperationException( message: $"It appears the script path '{resource}' contains HTML. HTML is not allowed in the path." ); } if (resource.StartsWith('/')) { if (staticResourceNames.Contains($"{asm.GetName().Name}.wwwroot.{resource.Substring(1)}")) { throw new InvalidOperationException( message: $"It appears the script '{resource}' is not an embedded resource in assembly '{asm.GetName().Name}'!" ); } BlazorResources.Scripts.Add( $"<script src=\"_content/{asm.GetName().Name}{resource}\"></script>" ); } else { if (staticResourceNames.Contains($"{asm.GetName().Name}.wwwroot.{resource}")) { throw new InvalidOperationException( message: $"It appears the script '{resource}' is not an embedded resource in assembly '{asm.GetName().Name}'!" ); } BlazorResources.Scripts.Add( $"<script src=\"_content/{asm.GetName().Name}/{resource}\"></script>" ); } } } }
The BuildScriptTags
method begins by checking if the options contain any configured links for scripts. Assuming they do, I then loop through those links, and, for each one, I check the incoming link for HTML using the IsHTML
extension method. That method is part of the CODEGATOR CG.Core
NUGET package. I’ll cover IsHTML
in another blog post, but, for now, just know that it’s checking for any embedded HTML tags in the tag. If I find anyone has tried to sneak HTML into a tag, I throw an exception. From there, I format the link I’ll actually pass along to Blazor. Then, after I’ve done that, I add the link to the BlazorResources
class utility.
The BuildStyleSheetLinks
method follows a similar pattern:
private static void BuildStyleSheetLinks( Assembly asm, string[] staticResourceNames, ModuleOptions module ) { if (null != module.StyleSheets) { foreach (var resource in module.StyleSheets) { if (resource.IsHTML()) { throw new InvalidOperationException( message: $"It appears the style sheet path '{resource}' contains HTML. HTML is not allowed in the path." ); } if (resource.StartsWith('/')) { if (staticResourceNames.Contains($"{asm.GetName().Name}.wwwroot.{resource.Substring(1)}")) { throw new InvalidOperationException( message: $"It appears the style sheet '{resource}' is not an embedded resource in assembly '{asm.GetName().Name}'!" ); } BlazorResources.StyleSheets.Add( $"<link rel=\"stylesheet\" href=\"_content/{asm.GetName().Name}{resource}\" />" ); } else { if (staticResourceNames.Contains($"{asm.GetName().Name}.wwwroot.{resource}")) { throw new InvalidOperationException( message: $"It appears the style sheet '{resource}' is not an embedded resource in assembly '{asm.GetName().Name}'!" ); } BlazorResources.StyleSheets.Add( $"<link rel=\"stylesheet\" href=\"_content/{asm.GetName().Name}/{resource}\" />" ); } } } }
In both cases, I check for embedded HTML, then format the link, then check to make sure the resource I’m trying to link to, actually exist. If the resource is missing, I throw what I hope is an nicely informative error message, for the developers benefit.
Let’s regroup, again. At this point I’ve outlined code to read a configuration and use it to inject plugin assemblies, at runtime, into a Blazor project, where Blazor itself may not have known anything about those plugins at compile time. In addition, I have demonstrated how my extensions inject links for static resources, housed in plugins, without requiring code changes or recompiles. Finally, I have demonstrated how my plugins notify Blazor that they need optional router support, at runtime. So, what’s left? It turns out, more than I imaged …
This article is getting longer than I like, so, I’ll wrap this topic up in a ‘Part 2’ post. There, I’ll cover what I discovered about reading resources from, plugin files, and how I got around those issues.
See everyone then.
Photo by Markus Winkler on Unsplash