Let’s Explore Core v3: Part 2 — Bootstrap & Events
This is part 2 of the Let’s Explore ARK Core which documents the development of the next major release of ARK Core alongside some tips & tricks on how to get started with contributing and building your next idea today.
Introduction
In Part 1 of this series, we gave a rough overview of how the Core infrastructure was revamped to allow for greater control over multiple processes. Today, we’ll continue by taking a closer look at how the application is bootstrapped and what role events play in this.
Bootstrapping
In Core v2, the bootstrapping process was very basic and difficult to adjust. It consisted of a single method and an accompanying plugin loader class that contained all logic to register and boot things. This was bad for 2 reasons.
- Testing was very difficult as those things were hardcoded in the depths of the application object with no way of easily accessing them.
- All logic was centralized in a single entity which meant that every small change could break anything and disabling certain steps for testing was not possible.
Core v3 tackles the above issues by introducing a new bootstrapping implementation. Every step that is required for Core to get up and running is split into a bootstrapper class that is responsible for a single task. Those bootstrapper classes only expose a single public bootstrap method that is called by Core to execute the task it is responsible for.
Let's have a closer look at what bootstrappers are available when they are executed and what they do.
Available Bootstrappers
Currently, a handful of bootstrappers are used within Core to break down the application start into small pieces with as little responsibility as possible to make testing and finding problems within them easier.
// Application
class RegisterErrorHandler implements Bootstrapper
class RegisterBaseConfiguration implements Bootstrapper
class RegisterBaseServiceProviders implements Bootstrapper
class RegisterBaseBindings implements Bootstrapper
class RegisterBaseNamespace implements Bootstrapper
class RegisterBasePaths implements Bootstrapper
class LoadEnvironmentVariables implements Bootstrapper
class LoadConfiguration implements Bootstrapper
class LoadCryptography implements Bootstrapper
class WatchConfiguration implements Bootstrapper// Service Providers
class LoadServiceProviders implements Bootstrapper
class RegisterServiceProviders implements Bootstrapper
class BootServiceProviders implements Bootstrapper
As you can see, all classes implement the Bootstrapper contract which lets Core know that this class contains a bootstrap method that should be executed. The order in which the bootstrappers are included and executed matters as they are all responsible for taking care of small tasks, for example, trying to register a plugin before its configuration is loaded doesn’t make sense.
Application Start
In Core v3, the starting of the application is split into 2 steps, bootstrap and boot. Let's take a look at what those methods do and when they are called.
Bootstrap
The first step is to bootstrap the application which takes care of 3 steps in preparation for the execution of bootstrapper classes.
- The event dispatcher is registered as the first service as it is needed early on in the application lifecycle.
- The initial configuration and CLI flags are stored in the container to make them easily accessible during the lifecycle of the application.
- The ServiceProviderRepository is registered which is a repository that holds the state of registered services through plugins. Those states are registered, loaded, failed and deferred; more on those in a later part.
public async bootstrap(config: JsonObject): Promise<void> {
await this.registerEventDispatcher(); this.container
.bind<JsonObject>(Identifiers.ConfigBootstrap)
.toConstantValue(config); this.container
.bind<ServiceProviderRepository>(Identifiers.ServiceProviderRepository)
.to(ServiceProviderRepository)
.inSingletonScope(); await this.bootstrapWith("app");
}
The bootstrapWith method accepts a single argument which lets it know what bootstrappers should be executed. This is important so the application is only bootstrapped but not actually started, think of it as packing everything into your car for your vacation but not yet starting to drive. Everything is ready and just waiting for you to turn the keys and start.
Boot
The second step is to boot the application. This will call the register and boot methods on service providers that are exposed by packages that were discovered during the bootstrap process.
public async boot(): Promise<void> {
await this.bootstrapWith("serviceProviders"); this.booted = true;
}
Again, we are calling the bootstrapWith method but this time with serviceProviders as the argument. This will let Core know to run the bootstrappers that are responsible for registering and booting packages through their exposed service providers.
The bootstrapWith method
As we have seen in the previous section, the bootstrapWith method is at the heart of getting the application up and running. Let's break down the following code snippet to get a better idea of what is happening.
private async bootstrapWith(type: string): Promise<void> {
const bootstrappers: Array<Constructor<Bootstrapper>> = Object.values(Bootstrappers[type]); for (const bootstrapper of bootstrappers) {
this.events.dispatch(`bootstrapping:${bootstrapper.name}`, this); await this.container.resolve<Bootstrapper>(bootstrapper).bootstrap(); this.events.dispatch(`bootstrapped:${bootstrapper.name}`, this);
}
}
- We get a key-value pair of available bootstrappers, currently, only app and serviceProviders exist.
- We loop over all available bootstrappers.
- We fire a
bootstrapping:{name}
event before executing the bootstrapper. This will look something likebootstrapping:LoadEnvironmentVariables
. - We resolve the bootstrapper class from the container and call the bootstrap method to execute its task.
- We fire a
bootstrapped:{name}
event after executing the bootstrapper. This will look something likebootstrapped:LoadEnvironmentVariables
.
As you can see Core dispatches an event for every bootstrapper that is executed. This is useful for internal tasks that are performed outside of bootstrappers without needing any hacks as they can rely on the event-driven architecture of the bootstrapping feature.
Event Dispatcher
You might have noticed in the previous example that the event dispatcher no longer provides an on or emit method. This is due to a complete replacement of the native event dispatcher with our own implementation that provides more features to aid the use of an event-driven architecture for certain features.
First, let's have a look at the implementation contract that is specified within core-kernel
. This contract needs to be satisfied by all event dispatcher implementations, core ships with an in-memory solution by default but something like Redis should be easy enough to implement as an alternative.
export interface EventDispatcher {
/**
* Register a listener with the dispatcher.
*/
listen(event: EventName, listener: EventListener): () => void;/**
* Register many listeners with the dispatcher.
*/
listenMany(events: Array<[EventName, EventListener]>): Map<EventName, () => void>;/**
* Register a one-time listener with the dispatcher.
*/
listenOnce(name: EventName, listener: EventListener): void;/**
* Remove a listener from the dispatcher.
*/
forget(event: EventName, listener?: EventListener): void;/**
* Remove many listeners from the dispatcher.
*/
forgetMany(events: Array<[EventName, EventListener]>): void;/**
* Remove all listeners from the dispatcher.
*/
flush(): void;/**
* Get all of the listeners for a given event name.
*/
getListeners(event: EventName): EventListener[];/**
* Determine if a given event has listeners.
*/
hasListeners(event: EventName): boolean;/**
* Fire an event and call the listeners in asynchronous order.
*/
dispatch<T = any>(event: EventName, data?: T): Promise<void>;/**
* Fire an event and call the listeners in sequential order.
*/
dispatchSeq<T = any>(event: EventName, data?: T): Promise<void>;/**
* Fire an event and call the listeners in synchronous order.
*/
dispatchSync<T = any>(event: EventName, data?: T): void;/**
* Fire many events and call the listeners in asynchronous order.
*/
dispatchMany<T = any>(events: Array<[EventName, T]>): Promise<void>;/**
* Fire many events and call the listeners in sequential order.
*/
dispatchManySeq<T = any>(events: Array<[EventName, T]>): Promise<void>;/**
* Fire many events and call the listeners in synchronous order.
*/
dispatchManySync<T = any>(events: Array<[EventName, T]>): void;
}
Already, at first sight, you’ll notice that our own event dispatcher provides a lot more methods than the default node.js event dispatcher does. The biggest benefit of this new event dispatcher is that it has built-in support for asynchronous dispatching of events.
Core v2.4 started to make use of a few internal events in core-p2p
to decouple certain tasks like banning and disconnecting a peer. Previously, tasks of that nature were just thrown in wherever they fit best at the time rather than being placed in an entity where they actually belong to.
This new event dispatcher will provide us the tools we need to make more use of an event-driven architecture to help loosen up coupling and make testing even easier as a result of that. This will be an ongoing process as there will always be room for improvements or a performance gain by dispatching an event and executing all its tasks in an asynchronous manner while waiting for the result without blocking the main thread.
What’s next?
This concludes Part 2 of the ARK Let’s Explore Core series. In the next part, we will delve into how essential parts of the system are split into services in ARK Core v3 to provide a robust and testable framework for the future.
Let’s Explore Core Series
If you have missed other Let’s Explore Core series post, you can read them by following the links below:
- Part 1: Infrastructure
- Part 2: Bootstrap & Events (you are currently reading it)
- Part 3: Kernel & Services
- Part 4: Extensibility
- Part 5: Maintainability & Testability
- Part 6: Tooling