Microkernel for Server Applications
Microkernel is JavaScript library, for use in Node.js environments, to structure and manage server applications with the help of modules, a stateful life-cycle, hooks, events, services and resources. It follows the Component-Orientation paradigm.
$ npm install microkernel
The microkernel design follows the primary concepts State Transitions, Module Groups & Tags, Module Dependencies and Module Definitions.
There are states the microkernel can be in. For transitioning between states, the microkernel calls optional enter (or leave) transition method in (reverse) order on all topologically sorted modules.
By default, there are 6 states pre-defined (but you can easily re-define the entire state transition scheme):
+---[boot]--+ +-[latch]--+ +[configure]+ +-[prepare]-+ +---[start]--+
| | | | | | | | | |
| V | V | V | V | V
+----------+ +----------+ +----------+ +----------+ +----------+ +----------+
| | | | | | | | | | | |
| dead | | booted | | latched | |configured| | prepared | | started |
| | | | | | | | | | | |
+----------+ +----------+ +----------+ +----------+ +----------+ +----------+
^ | ^ | ^ | ^ | ^ |
| | | | | | | | | |
+-[shutdown]+ +-[unlatch]+ +--[reset]--+ +-[release]-+ +---[stop]---+
This means, for instance, if you trigger the microkernel to transition
from state booted
to configured
it will (1) give all modules a
chance to perform work in their latch
method, then (2) transition to
intermediate state latched
, then (3) give all modules a chance to
perform work in their configure
method and finally (4) transition to
state configured
.
Similarly, for instance, if you the trigger the microkernel to transition back
from state configured
to booted
it will (1) give all modules a
chance to perform work in their reset
method, then (2) transition to
intermediate state latched
, then (3) give all modules a chance to
perform work in their unlatch
method and finally (4) transition to
state booted
.
The default state transition scheme is defined with:
kernel.transitions([
{ state: "dead", enter: null, leave: null },
{ state: "booted", enter: "boot", leave: "shutdown" },
{ state: "latched", enter: "latch", leave: "unlatch" },
{ state: "configured", enter: "configure", leave: "reset" },
{ state: "prepared", enter: "prepare", leave: "release" },
{ state: "started", enter: "start", leave: "stop" }
])
The default states are intended for:
dead
: the modules are dead (lowest internal state the microkernel can be in)booted
: the modules are booted, i.e., base services like logging are established.latched
: the modules are latched into services of each other.configured
: the modules are fully configured, i.e., their parameters are known.prepared
: the modules are prepared, i.e., their resources are loaded.started
: the modules are started, i.e., their services are operating.
Modules can be assigned to a group and/or assigned a tag.
Belonging to a group X
is the same as tagging the module with X
and
having an after
dependency to the group preceeding X
(if there is
one) plus a before
dependency to the group following X
(if there is
one). For module dependencies see below. In other words: a module group
acts like a chronological phase to easily cluster modules.
By default, there are 6 groups pre-defined (but you can easily re-define the module groups scheme):
+--------+ +--------+ +--------+ +--------+ +--------+
| \ | \ | \ | \ | \
| BOOT \| BASE \| RESOURCE \| SERVICE \| USECASE \
| /| /| /| /| /
| / | / | / | / | /
+--------+ +--------+ +--------+ +--------+ +--------+
The default module groups scheme is defined with:
kernel.groups([
"BOOT", "BASE", "RESOURCE", "SERVICE", "USECASE"
])
The default groups are intended for:
BOOT
: modules performing bootstrapping functionalities, e.g., determining base directories, application information, etc.BASE
: modules providing base functionalities, e.g. process forking, daemonizing, option parsing, configuration loading, establishing logging, etc.RESOURCE
: modules providing resources, e.g. database connections, etc.SERVICE
: modules providing internal services, e.g. server framework setup, authentication, authorization, accounting, etc.USECASE
: modules providing request functionality processing, e.g. the various use-cases of the application.
Modules can have after
and/or before
dependencies to other modules
(or groups or tags).
+---[before]--+
| |
| V
+----------+ +----------+
| | | |
| Module-1 | | Module-2 |
| | | |
+----------+ +----------+
^ |
| |
+---[after]---+
This means Module-1
comes before Module-2
in the topological order of all modules.
A module under run-time is just an object with a control field module
and zero or more enter/leave transition methods (see "State Transitions"
above). In TypeScript notation, a module has to conform to the following
interface (assuming the default transition configuration):
interface Promise {
then(
onSuccess: (value?: any) => Promise | any | void,
onError?: (error: any) => Promise | any | void
): Promise;
}
interface Module {
/* mandatory module descriptor.
name: unique name of module, by convention in all lower-case.
group: one group, by convention in all upper-case, to associate the module with.
tag: one or more tags, by convention in all upper-case, to associate the module with.
before: one or more modules (or groups or tags) the current module has to come before.
after: one or more modules (or groups or tags) the current module has to come after. */
module: {
name: string; /* e.g. "foo" */
group?: string; /* e.g. "RESOURCE" */
tag?: string | Array<string>; /* e.g. "RESOURCE" */
before?: string | Array<string>; /* e.g. [ "BOOT", "bar" ] */
after?: string | Array<string>; /* e.g. "quux" */
};
/* optional default enter/leave methods
kernel: backreference to the microkernel the module runs under */
boot?: (kernel: Kernel) => void | Promise;
latch?: (kernel: Kernel) => void | Promise;
configure?: (kernel: Kernel) => void | Promise;
prepare?: (kernel: Kernel) => void | Promise;
start?: (kernel: Kernel) => void | Promise;
stop?: (kernel: Kernel) => void | Promise;
release?: (kernel: Kernel) => void | Promise;
reset?: (kernel: Kernel) => void | Promise;
unlatch?: (kernel: Kernel) => void | Promise;
shutdown?: (kernel: Kernel) => void | Promise;
}
A module is usually defined as (ECMAScript 6):
export default class Module {
get module () { return { name: "module1", group: "BOOT" } }
start (k) { ... }
stop (k) { ... }
...
}
Alternatively, a corresponding ECMAScript 5 definition is:
var Module = function () {
this.module = { name: "module1", group: "BOOT" }
}
Module.prototype = {
start: function (k) { ... },
stop: function (k) { ... },
...
}
module.exports = Module
In TypeScript notation, the Application Programming Interface (API) is:
declare module Microkernel {
/* promise instance */
interface Promise {
then(
onSuccess: (value?: any) => Promise | any | void,
onError?: (error: any) => Promise | any | void
): Promise;
}
/* module instance */
interface Module {
/* module descriptor.
name: unique name of module, by convention in all lower-case.
group: one group, by convention in all upper-case, to associate the module with.
tag: one or more tags, by convention in all upper-case, to associate the module with.
before: one or more modules the current module has to come before.
after: one or more modules the current module has to come after. */
module: {
name: string; /* "foo" */
group?: string; /* "RESOURCE" */
tag?: string | Array<string>; /* "RESOURCE" */
before?: string | Array<string>; /* [ "BOOT", "bar" ] */
after?: string | Array<string>; /* "quux" */
};
}
/* module class */
interface ModuleClass {
/* instanciate the module */
new(): Module;
}
/* kernel instance */
interface Kernel {
/* library version for programatic comparison and/or displaying by applications.
major: major version number (bumped on revisions, definitely incompatible)
minor: minor version number (bumped on improvements, potentially incompatible).
micro: micro version number (bumped on bugfixes, fully compatible).
date: date of release (in YYYYMMDD format). */
version: {
major: number; /* 0 */
minor: number; /* 9 */
micro: number; /* 2 */
date: number; /* 20150412 */
};
/* add a module to the kernel, either by auto-instanciating
a module class or by a pre-instanciated module object */
add(module: ModuleClass | Module): Kernel;
/* delete a module from the kernel, either by a module class (effectively
deleting all instances of it) or by a pre-instanciated module object */
del(module: ModuleClass | Module): Kernel;
/* get a module from the kernel by its name */
get(name: string): Module;
/* require() and execute one or more procedure files (potentially asynchronously).
Each file is expected to export a single function of type
"f(Kernel): Promise | any | void". It is called with the kernel
as its parameter and can return a promise in case it executes
asynchronously. */
exec(...files: string[]): Promise;
/* require() and instanciate one or more module class files (fully synchronously).
Each file is expected to export a class/function of type
"ModuleClass". It is instanciated and added to the kernel. */
load(...files: string[]): Kernel;
/* configure the state transitions */
transitions(
transitions: {
state: string;
enter: string;
leave: string;
}[]
): Kernel;
/* configure the module groups */
groups(
groups: string[]
): Kernel;
/* Retrieve the current state or trigger a transition to a new
state. There are 5+1 states and their corresponding enter (from
lower to higher) or leave (from higher to lower) method names:
State Enter Leave
---------- --------- ---------
dead (none) (none)
booted boot shutdown
latched latch unlatch
configured configure reset
prepared prepare release
started start stop
One can trigger the kernel to go to arbitrary states.
It will transiton through all intermediate states
automatically. For instance, if there are two modules A and
B and (B comes after A) and the kernel is in "dead" state,
then a kernel.state("started") will trigger the following
method calls (in exactly this order):
A.boot(kernel)
B.boot(kernel)
A.latch(kernel)
B.latch(kernel)
A.configure(kernel)
B.configure(kernel)
A.prepare(kernel)
B.prepare(kernel)
A.start(kernel)
B.start(kernel)
A subsequent kernel.state("dead") will trigger the following
method calls (notice that A and B are now reversed, too):
B.stop(kernel)
A.stop(kernel)
B.release(kernel)
A.release(kernel)
B.reset(kernel)
A.reset(kernel)
B.unlatch(kernel)
A.unlatch(kernel)
B.shutdown(kernel)
A.shutdown(kernel)
*/
state(newState?: string): string;
/* Latch into a named hook with the help of a callback function.
The callback function is called as "ctx.callback(...params, result)"
once "kernel.hook(name, proc, ...params)" is called. The trailing
appended parameter is the initial or intermediate result and the function
is expected to return the next intermediate or final result.
The "latch" function returns a unique id for subsequent unlatching.
There is a short-hand method "at" which is equivalent to "latch". */
at(
name: string,
callback: (...params: any[]) => any,
ctx?: any
): number;
latch(
name: string,
callback: (...params: any[]) => any,
ctx?: any
): number;
/* Unlatch from a named hook, based on the unique id given by "latch" */
unlatch(name: string, id: number): Kernel;
/* Enter a named hook with a particular processing step and parameters.
The processing step can be: "none", "pass", "or", "and", "mult", "add",
"append", "push", "concat", "set", "insert", "assign" and this controls
how the intermediate values are processed. The whole process
is fully synchronous. */
hook(name: string, proc: string, ...params: any[]): any;
/* Subscribe to a named event with the help of a callback function.
The callback function is called as "ctx.callback(...params)"
once "kernel.publish(name, ...params)" is called.
The "subscribe" function returns a unique id for subsequent unsubscribing.
There is a short-hand method "on" which is equivalent to "subscribe". */
on(
name: string,
callback: (...params: any[]) => Promise | any | void,
ctx?: any
): number;
subscribe(
name: string,
callback: (...params: any[]) => Promise | any | void,
ctx?: any
): number;
/* Unsubscribe from a named event, based on the unique id given by "subscribe" */
unsubscribe(name: string, id: number): Kernel;
/* Publish a named event with optional parameters.
The whole event delivery process is fully asynchronous,
hence the function returns a promise which resolves once
all callbacks finished their potentially asynchronous
(and Promise-controlled) processing. */
publish(name: string, ...params: any[]): Promise;
/* Register a named service with the help of a callback function.
The callback function is called as "ctx.callback(...params)"
once "kernel.service(name, ...params)" is called.
There can only be exactly one registered callback per service.
There is a short-hand method "sv" which is equivalent to "service". */
register(
name: string,
callback: (...params: any[]) => any,
ctx?: any
): number;
/* Unregister a named event. */
unregister(name: string, id: number): Kernel;
/* Publish a named event with optional parameters.
The whole event delivery process is fully asynchronous,
hence the function returns a promise which resolves once
all callbacks finished their potentially asynchronous
(and Promise-controlled) processing. */
sv(name: string, ...params: any[]): any;
service(name: string, ...params: any[]): any;
/* Get or set a resource value under a key.
There is a short-hand method "rs" which is equivalent to "resource". */
rs(key: string, value?: any): any;
resource(key: string, value?: any): any;
}
/* kernel class */
interface KernelClass {
/* instanciate the kernel */
new(): Kernel;
}
}
The following is a small example to show the various microkernel functionalities:
app.js
:
/* import Microkernel library */
import Microkernel from "microkernel"
/* instanciate a microkernel */
const kernel = new Microkernel()
/* import application modules (order does not matter) */
import Mod1 from "./app-mod1"
import Mod2 from "./app-mod2"
import Mod3 from "./app-mod3"
import Mod4 from "./app-mod4"
/* load application modules into microkernel */
kernel.add(Mod1)
kernel.add(Mod2)
kernel.add(Mod3)
kernel.add(Mod4)
/* startup microkernel and its modules */
kernel.state("started").catch((err) => {
console.log(`ERROR: failed to start: ${err}\n${err.stack}`)
})
app-mod1.js
:
export default class Mod1 {
get module () { return { name: "mod1", group: "BOOT" } }
boot (k) { console.log(`boot: ${this.module.name}`) }
start (k) { console.log(`start: ${this.module.name}`) }
stop (k) { console.log(`stop: ${this.module.name}`) }
shutdown (k) { console.log(`shutdown: ${this.module.name}`) }
}
app-mod2.js
:
export default class Mod2 {
get module () { return { name: "mod2", group: "BASE" } }
boot (k) { console.log(`boot: ${this.module.name}`) }
start (k) { console.log(`start: ${this.module.name}`) }
stop (k) { console.log(`stop: ${this.module.name}`) }
shutdown (k) { console.log(`shutdown: ${this.module.name}`) }
}
app-mod3.js
:
export default class Mod3 {
get module () { return { name: "mod3", group: "BASE", after: "mod2" } }
boot (k) { console.log(`boot: ${this.module.name}`) }
start (k) { console.log(`start: ${this.module.name}`) }
stop (k) { console.log(`stop: ${this.module.name}`) }
shutdown (k) { console.log(`shutdown: ${this.module.name}`) }
}
app-mod4.js
:
export default class Mod4 {
get module () { return { name: "mod4", group: "SERVICE" } }
boot (k) { console.log(`boot: ${this.module.name}`) }
start (k) { console.log(`start: ${this.module.name}`) }
stop (k) { console.log(`stop: ${this.module.name}`) }
shutdown (k) { console.log(`shutdown: ${this.module.name}`) }
}
When starting this app you can see the life-cycle in action:
$ node-babel --presets es2015 app.js
boot: mod1
boot: mod2
boot: mod3
boot: mod4
start: mod1
start: mod2
start: mod3
start: mod4
stop: mod4
stop: mod3
stop: mod2
stop: mod1
shutdown: mod4
shutdown: mod3
shutdown: mod2
shutdown: mod1
Check out the set of existing Microkernel extension procedures and modules. They encapsulate common functionalities a Microkernel-based server application usually needs.
Copyright (c) 2015-2021 Dr. Ralf S. Engelschall (http://engelschall.com/)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.