Managers and Drivers
Mainsail makes use of a variant of the Builder pattern, known as the Manager pattern, to provide a high degree of extensibility together with a delightful developer experience through expressive and simple to understand code to create your own set of drivers to alter how Mainsail behaves for certain features.
Throughout this article we will reference the Builder pattern as Manager pattern as Mainsail internally uses the wording
Manager
for all the classes that manage the state of drivers.
Manager
In simple terms a Manager is a class that manages the state of the drivers for a specific feature. Think for example about a caching feature that needs to scale up as more data is stored. You might ship a default ArrayDriver
driver which stores data in-memory and is sufficient for a few thousand sets of data but becomes slow over time.
What the Manager pattern allows you to do is to simply create a new driver, for example RedisDriver
, based on a given implementation contract, register it with Mainsail and reap the benefits of increased performance.
This is extremely useful for extending or altering how Mainsail behaves and what it is capable of doing, all done through the use of managers and drivers with a common public API.
Driver
A driver is an extension to how a Manager exposes and manages a feature. We could for example ship a ConsoleLogger
but as our project grows we need file logging so we implement a WinstonLogger
, but a few months later our project grew so much that we need remote log storage so we implement a LogstashLogger
.
All of them are interchangeable as they have to satisfy the same implementation contract and pass the same test suite. The Manager pattern makes all of this possible and makes it as simple as possible to swap out critical parts to suite your specific use-case and needs.
Creating a Manager
Mainsail comes with a variety of managers out of the box such as CacheManager
, LogManager
and ValidationManager
. Lets learn how to create your own manager and how to register to expose it to other plugins.
We’ll create the LogManager
from scratch to have a real-world example. Lets set up some boilerplate and then we’ll break it down step-by-step.
1import { Contracts } from "@mainsail/contracts"; 2 3import { InstanceManager } from "../../support/instance-manager"; 4import { MemoryLogger } from "./drivers/memory"; 5 6export class LogManager extends InstanceManager<Contracts.Kernel.Logger> { 7 protected async createMemoryDriver(): Promise<Contracts.Kernel.Logger> { 8 return this.app.resolve(MemoryLogger).make(); 9 }10 11 protected getDefaultDriver(): string {12 return "memory";13 }14}
- We create a new class which ideally should be named as
FeatureManager
, in this case the feature isLog
which is responsible for all logging functionality so we name the classLogManager
. - We extend the
Support.Manager
class an inherit all of its methods and let it know that is responsible for managing logger drivers by type hintingContracts.Logger
. - We create a
createMemoryDriver
method which will be responsible for instantiating our console-specific driver implementation. - We create a
getDefaultDriver
method which in our case returnsmemory
as the desired default driver.
Creating a Driver
Implementing a logger driver is as simple as implementing a manager. The logger contract that is provided by Mainsail follows the log levels defined in the RFC 5424 specification . Again we’ll setup some boilerplate and then break it down.
1import { Contracts } from "@mainsail/contracts"; 2 3export class ConsoleLogger implements Contracts.Logger { 4 protected logger: Console; 5 6 public async make(): Promise<Contracts.Logger> { 7 this.logger = console; 8 9 return this;10 }11 12 public emergency(message: any): void {13 this.logger.error(message);14 }15 16 public alert(message: any): void {17 this.logger.error(message);18 }19 20 public critical(message: any): void {21 this.logger.error(message);22 }23 24 public error(message: any): void {25 this.logger.error(message);26 }27 28 public warning(message: any): void {29 this.logger.warn(message);30 }31 32 public notice(message: any): void {33 this.logger.info(message);34 }35 36 public info(message: any): void {37 this.logger.info(message);38 }39 40 public debug(message: any): void {41 this.logger.debug(message);42 }43}
- We create a new class which ideally should be named as
ImplementationType
, in this case the implementation isConsole
and the type isLogger
, which refers to the overallLog
feature. - We implement the
Contracts.Logger
contract which defines what methods a logger needs to implement and expose. - We create a
make
method which is called by theLogManager
to do all of the setup that is needed to get the driver up and running. - We implement all of the method that are specified in
Contracts.Logger
to satisfy the contract and avoid issues with how it behaves.
Using a Manager
The final step is to stitch together the manager and driver and bind it to the service container. We’ll use a service provider for this, about which we’ve learned in a previous article.
1import { interfaces } from "@mainsail/container"; 2import { Identifiers } from "@mainsail/contracts"; 3 4import { ServiceProvider as BaseServiceProvider } from "../../providers"; 5import { LogManager } from "./manager"; 6 7export class ServiceProvider extends BaseServiceProvider { 8 public async register(): Promise<void> { 9 this.app.bind<LogManager>(Identifiers.Services.Log.Manager).to(LogManager).inSingletonScope();10 11 await this.app.get<LogManager>(Identifiers.Services.Log.Manager).boot();12 13 this.app14 .bind(Identifiers.Services.Log.Service)15 .toDynamicValue((context: interfaces.Context) =>16 context.container.get<LogManager>(Identifiers.Services.Log.Manager).driver(),17 );18 }19}
- We create a new binding inside the service container by calling the
bind
method withIdentifiers.Services.Log.Manager
and theto
method withLogManager
. This will let the container know that we wish to receive an instance ofLogManager
when we later on callget(Identifiers.Services.Log.Manager)
. - We resolve the
LogManager
from the service container viaIdentifiers.Services.Log.Manager
and call theboot
method to create an instance of the previously configured default driver, in our case theMemoryLogger
. - We create a new binding inside the service container by calling the
bind
method withIdentifiers.Services.Log.Service
and thetoDynamicValue
with a function call that will return an instance of the default driver that we instantiated in the previous step.
It is important to use the container identifiers provided by
@mainsail/kernel
if you wish to extend or overwrite internal functionality. Symbols are unique and used to avoid name collisions, using the string value of a Symbol will result in duplicate bindings.