Re-architectured App Bootstrapping + Configuring remote host by hostname instead of IP Address
TL;DR
This re-architecture opens up some possibilities to allow for plugins and connectors to rebind certain dependencies, allowing the main app to provide implementations of certain interfaces that a module can then override. The plus side is that this change also makes it a little less ambiguous when certain bindings are set up.
Use-Case
The main use-case that I used as motivation was to enable configuring a host address using a domain address. It would still require you as a driver to set up Dynamic DNS, but ultimately makes it simple for a race engineer to correctly configure a host when not on the same network and you don't have to provide an updated IP Address if your ISP had decided to change your public-facing IP Address.
The Details
Ninject provides a whole host of tools to allow the loading of plugins at run-time. I've leveraged this to try and make the loading process a little more explicit. After reaching out to you via email you sounded like you'd welcome a contribution that cleans things up a little and I hope that this is to your liking.
The old process
From what I understood, the previous process was as follows:
- On startup, load all DLLs in the main directory not already loaded into memory.
- load the DLLs in the "plugins" directory into memory.
- Scan the assemblies for types that implement the
ISecondMonitorPlugin
interface. - Instantiate instances using
Activator
. - Load the DLLs in the "Connectors" directory into memory.
- Scan the assemblies for types that implement the
IGameConnector
interface. - Instantiate instances using the
Activator
Behind the scenes, the default constructor for each of these instances instantiated by the Activator
would query the KernelWrapper
for their dependencies. If the KernelWrapper
doesn't have a binding for the dependency, it will scan all loaded assemblies for instances implementing the INinjectModuleBootstrapper
interface, instantiate that and call the GetModules
method to get the various instances of objects inheriting from the NinjectModule
type. This would then be loaded into a singleton StandardKernel
that was maintained by the KernelWrapper
.
It means that implicitly new modules will get loaded as plugins and connectors are loaded into memory, but it's hidden deep beneath a complex process that's calling into the KernalWrapper
singleton. This now also puts application dependency bootstrapping at the centre of the application's architecture and allows for later calls to the KernalWrapper
singleton instance to resolve a dependency, only to load whatever new modules are available. The side effect of this is that a plugin/connector can't provide a rebinding of a specific interface, because the call to get a dependency from the KernelWrapper
won't find a fault that would warrant loading the relevant dependencies into the kernel.
The new process
Due to the fact that there are multiple executable applications, each having slightly different needs with regards to application bootstrapping, I've pulled this responsibility out of the core of the architecture and hoisted it to the outer layer. The introduction of the SecondMonitor.Bootstrapping
project introduces a few extension methods that enable a consumer to load dynamic modules as it sees fit.
This changes the process to the following:
- On App startup, construct a
StandardKernal
instance. - Use the
LoadCoreAssemblies
extension method to load app DLLs as assemblies and scans for any types with a default constructor inheriting fromNinjectModule
which then gets loaded into the kernel. - Use the
LoadPlugins
to load plugin DLLs into memory and load the relevantNinjectModules
into the kernel.* - Use the
LoadConnectors
to load connector DLLs into memory and load the relevantNinjectModules
into the kernel.* - Use the
kernel.Get<>()
method to resolve the application's execution root and run the execution root.
Steps 3 and 4 also take care of loading any NinjectModule
types found in the newly loaded DLLs as well as creating a convention-based binding for the plugin/connector interface.
In my opinion, this now allows me to more easily reason about application bootstrapping and it still allows for plugins & connectors to be built that can stand completely on their own without needing to add dependencies like Ninject
or other projects within the application. It's also removing weird binding errors where modules aren't loaded because it's relying on binding failures, but rather making it a more explicit part of application bootstrapping.
AssemblyLoaderCollector
I'd like to make a quick special note on a type I created. It's nothing fancy, but it allowed me to use AppDomain.Current.Load()
in such a way that I only get the assemblies that haven't already been loaded. This is important because the kernel.Load(assemblies)
method would throw an exception if an assembly has a NinjectModule
that has already been loaded into the kernel. It's a small little helper type that made it easier than having to query to see if an assembly had already been loaded.
Removed
This allowed me to get rid of the following types:
KernelWrapper
BootstrapHelper
-
INinjectModuleBootstrapper
and all of its implementations
Conclusion
There are still one or two architectural quirks that have the potential to get in my way for a future contribution, but I feel that this already makes things a little clearer. Truth be told, if this contribution does get accepted and released it will already make life much easier when trying to have a remote race engineer. I feel that I've learned a lot from going through this exercise and that it is at least paving the way towards realising a seamless experience where the remote application can switch between active servers without any input required from the race engineer. I do hope that these changes won't have too much of an impact on current feature work you're doing, but I don't believe I changed the core of what you've done and rather just found a way to enhance it a little. :)