Configuration
ModuleManager builds on two Version Zero projects; Schema to provide the system for modules to define their
configuration requirements (documented here in its own section), and Configurator to coordinate
using these schemas to load configuration assignments for the modules (documented here.
Declarative Schema
Keeping definition of a module's configurable properties co-located with the module implementation helps prevent any "abstraction leaks" where the application needs to know too much about the module internals. It also allows modules to be added or removed without having to keep configuration code in sync.
import { ModuleManager, Schema } from '@versionzero/module-manager';
// You can use simple data to define a schema if you want to avoid adding a dependency...
class Server {
static moduleInfo = {
schema: {
properties: {
host: { base: 'string', options: { default: 'localhost' }, metadata: { flagHint: 'S' } },
port: { base: 'number', options: { default: 8080 }, handlers: { validators: ['$integer']}}
}
}
}
}
// Or you can use the more ergonomic `Schema` builder API:
class Demo {
static moduleInfo = {
schema: new Schema('object')
.property('verbose', new Schema('boolean').meta('description', 'display extra info'))
.property('server', new Schema('Server').required().meta('hidden'))
}
async main() {
if (this.verbose) {
console.log(`server: ${this.server.host}:${this.server.port}`);
}
}
}
await new ModuleManager()
.register(Demo)
.register(Server)
.run()
Let's say this was in myapp.js, you could configure this module with:
% node myapp.js -v --server-host 127.0.0.1
server: 127.0.0.1:8080
The schema is also used to generate CLI help text that can be customized with metadata:
% node myapp.js --help
Usage: Demo [options]
--config (-C) [path|-] - load configuration from file (or - for stdin)
--help (-h) [advanced] - display help information
--verbose (-v) [true|false] - display extra info
--server-host (-S) [string] - (default:"localhost")
--server-port (--sp) [integer] - (default:«8080»)
See the Configurator docs for details on customization using metadata.
The computed configuration (which is then loadable via -C) can be dumped with:
% node myapp.js -vS 192.168.1.1 --sp 80 --dump -
{
"demo": {
"server": "Server",
"verbose": true
},
"server": {
"host": "192.168.1.1",
"port": 80
}
}
The demo.server value of "Server" is an artifact of --dump serialization back to a string; the runtime value
is the actual instance of the Server class.
Configuration Values
Modules can receive configuration values in two ways;
- Property dependency injection, as used above (
hostandportare injected into theServerinstance, which is itself injected into theDemoinstance, which is auto-created due to having amainlifecycle method. - By implementing an
initlifecycle method; in this example, each module would have received only the populated contents of their respectivedemoorserverconfigurations.
See the linked documentation for more details.
Module Instance References
Schemas define validation rules, but also may define data processing rules.
This can be seen above in the relatively simple transformation of the string "80" (that originated from the command line
argv array) into the integer 80. This is defined via Schema core library $number value processor, which
is used in a Schema registered with SchemaResolver under the name number. Using number as the base of
a newly constructed Schema is how the port property inherits this behavior.
The reference to new Schema('Server') in Demo.server uses exactly the same mechanism.
ModuleManager registers a new base schema for every module. Unlike the schema inside the module, this newly
created schema exists to transform string module names into module instances.
If you were to strip away error handling and convenience features, the core of module instance dependency injection
in ModuleManager boils down to this simple schema transformation pipeline:
// Approximation of the actual logic...
class InternalModuleRepo
{
// ...
buildModuleReferenceSchema(moduleName) {
return new Schema()
.normalizer('$is-string')
.normalizer('$pascal-case')
.transformer(name => {
const module = this.getModule(name); // throws if name is not a known module
// if (name !== moduleName) { assigned to different value; check compatibility }
return module.getInstance(); // return existing known instance or construct new one
})
.validator(instance => {
const module = this.getModule(moduleName);
if (!(instance instanceof module.ModuleClass)) { throw new Error('incompatible!') }
return instance;
})
}
// ...
}
Do not confuse the property that holds a module's configuration with (often similarly named) properties elsewhere that hold references to a module instance!
Providers
In the previous example, we had a 1:1 association between a module reference ("Server") and a registered
module that has that name.
Where things get more interesting is when a module setting marks itself as a provider:
import { ModuleManager, Schema } from '@versionzero/module-manager';
class AbstractPet {
static moduleProvides = 'Pet';
speak() { throw new Error('implement in subclass') }
}
class Cat extends AbstractPet {
speak() { console.log('meow'); }
}
class Dog extends AbstractPet {
speak() { console.log('woof'); }
}
class MyApp {
static moduleSchema = new Schema().property('furryFriend', new Schema('Pet').required())
async main() {
this.furryFriend.speak();
}
}
await new ModuleManager()
.register(Cat)
.register(Dog)
.register(MyApp)
.run();
% node pet.js -f cat
meow
Both Cat and Dog inherit the provides module setting from AbstractPet, which marks their module
implementations at providing values that are compatible with module references for Pet.
In addition to registering Cat and Dog as reference schemas as described in the previous section, the
Pet reference is also registered as a schema, but it has a transform that checks whether its assigned
input name corresponds to a module that has Pet in its provides setting.
This is an example of the "SOLID" Dependency Inversion Principle of "depend on abstractions".
The selection of a concrete implementation becomes a dependency-injected configurable value.
For a more complex fully documented example of providers, please refer to the
hello-world-advanced example