Open dazinator opened 5 years ago
As a temporary hack, I'm using something like this:
public class DynamicRouter : Router
{
delegate object RouteTableCreateDelegate(IEnumerable<Type> types);
private static readonly RouteTableCreateDelegate RouteTableCreate;
private static readonly PropertyInfo SetRoutes;
private static readonly MethodInfo Refresh;
static DynamicRouter()
{
// Lots of reflection to get at internal/private methods
// NOTE: This will most likely (100%) break on Blazor updates later.
var asm = typeof(Router).Assembly;
Type FindType(string type) => asm.GetTypes().FirstOrDefault(t => t.Name == type);
MethodInfo Find(Type type, string method) => type?
.GetMethods(BindingFlags.Instance |
BindingFlags.Static |
BindingFlags.Public |
BindingFlags.NonPublic)
.FirstOrDefault(m => m.Name == method);
MethodInfo FindMethod(string type, string method) => Find(FindType(type), method);
// public static RouteTable Create(IEnumerable<Type> types)
RouteTableCreate = (RouteTableCreateDelegate) FindMethod("RouteTable", "Create")?.CreateDelegate(typeof(RouteTableCreateDelegate));
if (RouteTableCreate == null)
Console.WriteLine("RouteTable.Create was not found");
SetRoutes = typeof(Router).GetProperty("Routes", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (SetRoutes == null)
Console.WriteLine("Router.Routes was not found");
Refresh = typeof(Router).GetMethod("Refresh", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (Refresh == null)
Console.WriteLine("Router.Refresh was not found");
}
// Basically, any time an assembly gets loaded, refresh the routes list
// This isn't the best method, but it works, and realistically, assembly loads are pretty few and far between
public DynamicRouter() => AppDomain.CurrentDomain.AssemblyLoad += RefreshRoutes;
private void RefreshRoutes(object sender, AssemblyLoadEventArgs args)
{
Console.WriteLine("Assembly loaded: " + args.LoadedAssembly.GetName().Name);
// ComponentResolver does an ass-backwards way of resolving the components in an assembly.
// It's their way of "walking" the dependency tree. But it's totally broken.
// That's the main reason why the router isn't dynamic.
var components = AppDomain.CurrentDomain.GetAssemblies()
.Where(a=>!a.IsDynamic)
.SelectMany(a => a.ExportedTypes.Where(t => typeof(IComponent).IsAssignableFrom(t) && !t.IsAbstract))
.ToList();
Console.WriteLine("Found " + components.Count + " components");
foreach(var component in components.Where(c=>c.GetCustomAttribute(typeof(Microsoft.AspNetCore.Components.RouteAttribute)) != null))
Console.WriteLine(component.FullName);
var routes = RouteTableCreate(components);
SetRoutes.SetValue(this, routes);
}
}
It's not perfect, but it at least lets me forcefully reload routes from newly loaded assemblies.
My note about the ComponentResolver is the main reason why we can't have truly dynamic routes.
Thanks for sharing @ApocDev I might use this as a temporary measure also to enable me to move on to other parts of the PoC.
I'm curious, are you working on your own form of blazor architecture that utilises plugins? And if so is it open source?
Yes, I am. Unfortunately, it's not currently OSS, however I do plan to re-implement most of it later as an OSS solution.
The biggest issue I ran into is properly dynamically loading plugins at runtime and hooking them into DI in a good, easy to use way.
While your "PluginHost" method works, it doesn't allow for full DI usage, and requires plugins to take an extra dependency, rather than just behaving as if the plugin was "built with the application". This has teething pains, among other issues.
The main drawback of Blazor client-side, is that we have no way to asynchronously do startup tasks before the entire DI system is loaded, so you end up deadlocking the browser if you attempt to manually load extra assemblies at that time.
Our current architecture calls into a GraphQL endpoint to get plugin metadata, permissions, etc, and loads only those that are allowed, or needed. Navigation is built at startup, and plugins are lazy-loaded when the route is navigated to, to avoid the massive startup time of downloading 50-100+ plugins for an enterprise application. However, this is all done in App.razor OnInitAsync, since as I said, we have no async startup mechanism that doesn't deadlock the browser.
I am settling on a similar approach!
I am thinking, on startup, load metadata from the backend about enabled plugins. That metadata will be leveraged by the front end shell - and it might mean that it adds / shows menu items for example for those enabled plugins - for example "Weather v1"
The shell will then provide a page that will act as the "plugin component loader" - i.e
and plugins are lazy-loaded when the route is navigated to
This page would have a route similar to:
@page "/Plugin/{ComponentName}/{Version}"
and when it's navigated to, it uses the information passed in the route to lookup metadata about that plugin (its assembly etc) and determing if the assembly is already loaded. If it isn't it will dynamically fetch the assembly from the server. It will then scan it for the appropriate component. Once it finds the component, it can then render it using RenderTreeBuilder.
With this model, there should be no need to register plugin assemblies with the router. All plugins that want to implement some UI would have to do so as a component. It's possible a base component could be provided for extensibility purposes.
The only thing I am not too sure about at the moment is:
- Can components be dynamically activated (and will dependencies be injected?)
Short answer, yes! But no!
We can load any assemblies we want at runtime. (This is tested and proven just by how Blazor works in general)
However, we currently can't load them "dynamically" before startup (where we can register them in the main DI scope). See: https://github.com/aspnet/AspNetCore/issues/11716
Until this is resolved, you will need to know about all your "plugins" ahead of time, or do what is being done here in this repo, and host your own IServicesCollection/IServiceProvider as a "substitute" DI provider for plugins. It works, but it's a little awkward to use sometimes.
I have done a bit of research into "hijacking" the wasm bootstrap, and doing dynamic lookups before the initial assembly is loaded, and getting some information into the browser's local db so the application can possibly read it out during startup. But we run into the same async bug. But it's a lot of JS tinkering, and prone to breaking. Plus, you need to actually go "request" the assemblies from whatever storage URL it lives at. (Which is an async operation)
Maybe we can get around this with a worker? Or possibly just stalling startup until some value is in the local db? Eg: initial JS code does the "startup config lookup -> download assemblies" while the .NET startup is "paused" somehow until the current plugin assemblies get loaded?
That doesn't really solve the "load at runtime" dynamic plugins though (which is what you really want in a web setting)
- Any benefit to using the new dependency scope component stuff in preview 9 so that plugin components are activated within their own scope which can be cleaned up afterwards?
Being able to "scope" DI instances comes in really handy for #3 below.
- Because we are loading plugins into the primary AppDomain, unloading it is not going to be faesible for the forseable future. So my goal of being able to dynamically update a plugin from one version to the next, without having the reload the entire app, may not be faesible right now. Also loading more than one version of a plugin would also not be faesible, as once a plugin assembly has been loaded by the app it will sit in the main app domain, meaning you cannot then load a different version of that plugin - you'd have to tare down the entire app domain in other words - reload the page.
AppDomains don't exist in .NET Core anymore. (The AppDomain class is really just a proxy around AssemblyLoadContext)
With ALCs, we can load up all the assemblies we want into that context, and just unload it when we're done. This is similar to how AppDomains worked, except you can't fully unload the assembly from memory. (That is something in .NET Core 3.0, which Blazor client side doesn't support. When Mono-wasm supports .NET Core 3.0, then we can fully unload a plugin)
See here for a really good plugin "framework" for .NET Core: https://github.com/natemcmaster/DotNetCorePlugins
I wound up going a similar path as @natemcmaster, except that I don't care about loading assembly dependencies multiple times. (I use the main ALC, and "load-order" ALCs as sources of truth for dependency versions) Having multiple copies of the same exact assembly loaded up in memory, causes obvious issues when you're trying to keep your memory footprint down (see: Lambda, Fargate Tasks, etc)
Some interesting points thank you.
AppDomains don't exist in .NET Core anymore. (The AppDomain class is really just a proxy around AssemblyLoadContext)
Whilst that is true, as Blazor wasm projects run in the browser under mono
not .net core.. AppDomain is still relevent for blazor wasm applications AFAIK. ALC's are purely a .Net Core thing, unless I've missed something - hopefully one day soon Net Core runtime will evolve enough to replace mono for running wasm in the browser but I believe that will be a while as it seems like that is not a quick endeavour for Microsoft!
See here for a really good plugin "framework" for .NET Core:
Ah yes we are in agreement :-). Think I've submitted some issues and PR's for that repo at some point in the past as I have used it to replace a lot of my own custom ALC building in a CMS I develop for .net core which has extensions installed dynamically via nuget packages. Being able to use a standard dependency for loading plugins in .net core apps is a god send and I'm grateful to @natemcmaster for producing that!
I'm going to keep thinking through and taking on board your points. I think I have enough ideas to evolve the current sample a bit now so that will be next on my agenda!
Some interesting points thank you.
AppDomains don't exist in .NET Core anymore. (The AppDomain class is really just a proxy around AssemblyLoadContext)
Whilst that is true, as Blazor wasm projects run in the browser under
mono
not .net core.. AppDomain is still relevent for blazor wasm applications AFAIK. ALC's are purely a .Net Core thing, unless I've missed something - hopefully one day soon Net Core runtime will evolve enough to replace mono for running wasm in the browser but I believe that will be a while as it seems like that is not a quick endeavour for Microsoft!
Technically true, but ALCs are still present due to being .NET Standard 2.0 compatible. (Mostly...) I may actually need to do some more experimenting here. I know Mono-wasm is moving pretty quickly (they already have Chrome threading implemented)
See here for a really good plugin "framework" for .NET Core:
Ah yes we are in agreement :-). Think I've submitted some issues and PR's for that repo at some point in the past as I have used it to replace a lot of my own custom ALC building in a CMS I develop for .net core which has extensions installed dynamically via nuget packages. Being able to use a standard dependency for loading plugins in .net core apps is a god send and I'm grateful to @natemcmaster for producing that!
I'm going to keep thinking through and taking on board your points. I think I have enough ideas to evolve the current sample a bit now so that will be next on my agenda!
Seems like we're in the same boat. The "hell" that is NuGet package dependency resolving. ;)
Still haven't quite figured out a "fast" method of resolving all the deps, that doesn't take a load of time at startup to ensure new versions aren't available.
Have opened blazor issue: https://github.com/aspnet/AspNetCore/issues/11463