Considering how hard ASP.NET Core is making this, I'm open to being told I'm going about this the wrong way.
Objective
The objective is to be able to permit end users to upload Modules package (eg: nuget package,zip, etc.) containing API Controllers.
Great Lead
The following has great advice on using IActionDescriptorChangeProvider to permit adding Controllers later:
Works (almost)
Based on the above advice, had some partial success...
Works somewhat in the Default AssemblyLoadContext
When loading an external Module into the Default AssemblyLoadContext:
- Module's
ExampleM1Controller(without constructor dependencies) is accessible (impressed!) - Module's
ExampleM2Controller(with a dependency on aIExampleHServicepreviously registered inIServiceCollectionat startup) works (highly impressed!). - Module's
ExampleM3Controller(with a dependency on aIExampleMServicedefined in the Module, and therefore not registered at Startup/known toIServiceCollection) raises an exception it can't instantiateIExampleMService.
Doesn't work much in a Custom AssemblyLoadContext
Beyond the small issue noted above, the situation is actually a little bit worse...
Because it was uploaded into the Default AssemblyLoadContext, the module can't be unloaded/replaced (starting/stopping the app would be highly disruptive to other users).
If I change the above to load into a Custom AssemblyLoadContext and try again this time:
ExampleM1Controller(without dependencies) is accessibleExampleM2Controller(with a dependency on a service defined in Host) raises an exception it can't instantiate theIExampleMServiceFIXED: SEE EDIT BELOW REGARDING 'PRIVATE' FLAG...ExampleM3Controller(with a dependency on a service defined in Modules) raises an exception it can't instantiate theIExampleHService
So...that's turned out to be useless when the tires are kicked :-(
ServiceCollection is all locked up
So then went back to see if that Startup sequence couldn't be squeezed into delivering more functionality:
var builder = WebApplication.CreateBuilder(args);
//Add a service
builder.Services.AddSingleton<IExampleSharedService, ExampleSharedService>();
// Add services to the container.
builder.Services.AddControllersWithViews();
// Wire up custom Resetter invoked by upload controller.
AddActionDescriptorChangeProvider(builder);
//That was the last chance to add anthing before Build is called:
var app = builder.Build();
//Works, no problem:
var x1 = app.Services.GetService<IExampleSharedService>();
// Adding additional Services after the build event:
// Will raise Exception
// Not fixable by setting the flag by refletion, as it's too late.
//builder.Services.GetType()
// .GetProperties(
// BindingFlags.Public|BindingFlags.SetProperty|BindingFlags.Instance
// )
// .Where(x => x.Name == "IsReadOnly")
// .First()
// .SetValue(builder.Services, false);
// Without using reflection to re-permit adding services without raising
// Exception "Cannot modify ServiceCollection after application is built."
// it's still too late (Host already built, so no longer looking at source info)
//builder.Services.AddSingleton<ILateService, LateService>();
// Calling build twice also raises an exception:
// builder.Build();
// So...if we load a new DLL into a new AppDomainLoadContext,
// that contains a Controller
// that has a dependency on a Service,
// how do we teach the DI (IServiceProvider) how to build
// it, and dependency service first???
// Returns null.
// ie: Unable to register late Controller or Module defined service:
var x2 = app.Services.GetService<ILateService>();
A Module Load Event - But how can it be put to use?
Some examples regarding Plugins do exist on the net reference and discuss catching an event when an assembly is loaded, iterating over the Types in the Assembly that match an interface (eg: ICalculator), and instantiating them.
But...
a) It's the other way around (we're trying to inject into the domain descriptors about services that already exist in the Host)
b) even if we caught the event to teach the app's IServiceCollection about old and new services...it's locked as per above.
Impossible/Wrong way?
So, the fact
- that ASP.NET Core framework is really designed to lock in services at startup,
- doesn't seem to have a mechanism to late register them
- combined with a funny feeling that security -- even if ONLY giving Modules access to very specific Application Layer (ie, API) services -- might not be that hot...
...makes me hesitate and wonder whether I'm going about this all wrong.
- Yet...isn't there a single website out there that caters for end users to upload new modules to make an ecosystem, no? Not one? Really? Can this kind of thing only be done with a scripted language (eg: PHP), where files trigger an instant update?
- Or does the website HAVE to be reset to repass 'Go' and build up the
IServiceCollectionthenIServiceProvider. (And that kind of disruption requires the uploaded modules only being loaded into memory at some deferred time -- eg midnight?) - THAT sounds SO naff, just hard to believe that would be a recommended approach.
- Or is it that Modules are kept separate. and client side javascript is used to inject in ux modules at the right place on a page, that call back to their own services, hosted on their own servers, which in turn invoke Host APIs behind the scenes...?
Thanks
Thanks for any and all input leading to making SPA capable webpages+APIs+SPA modular.
Edit
Doesn't solve everything, but I missed something crucial!
As per https://learn.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support
One MUST not forget to mark the source/shared assembly as Private.
Once that was changed, the second example started working, as it was ensured that the Controller's Constructor recognised the IExampleHService as being the one defined in the Host assembly -- and not something to look for in the Module directory.
Whew. With that solved, only one condition to go -- the third example, trying to resolve a Service that is unknown at Startup - but the IServiceCollection is no longer taking changes.
I think @davidfowl is onto something, and it's time to investigate taking over the controller creator. My current idea is so...yuck...I hesitate to write it down here. But first thoughts on it involves
a) importing the Module b) Using Autofac to create a new scope c) iterating over its Types for valid Interface/Instance types to register in the scope d) Save the scope, associated to the imported Assembly, in a singleton dictionary (!) e) Use a Controller Factory, that uses the Controller Type's Assembly to find the right child scope again, to use it when instantiating the controller.... f) Hope? It's messy as...but if one can't update the root container...worth giving it a shot and reporting back if it worked at all.