Browse Source

Rewrote microservice architecture

new-arch
David Ludwig 4 years ago
parent
commit
3d1767744c
11 changed files with 440 additions and 146 deletions
  1. +3
    -0
      packages/microservice/README.md
  2. +11
    -0
      packages/microservice/jest.config.ts
  3. +5
    -2
      packages/microservice/package.json
  4. +27
    -40
      packages/microservice/src/InternalService.ts
  5. +160
    -103
      packages/microservice/src/Microservice.ts
  6. +13
    -0
      packages/microservice/src/errors.ts
  7. +3
    -1
      packages/microservice/src/index.ts
  8. +21
    -0
      packages/microservice/src/schema.ts
  9. +68
    -0
      packages/microservice/test/integration/internal-services.test.ts
  10. +20
    -0
      packages/microservice/test/unit/InternalService.spec.ts
  11. +109
    -0
      packages/microservice/test/unit/Microservice.spec.ts

+ 3
- 0
packages/microservice/README.md View File

@ -0,0 +1,3 @@
# Microservice
A shared package used to quickly assemble and run microservice applications

+ 11
- 0
packages/microservice/jest.config.ts View File

@ -0,0 +1,11 @@
import base from "../../jest.config";
export default {
...base,
name: "@autoplex/microservice",
displayName: "Package: Micoservice",
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'^@test@/(.*)$': '<rootDir>/test/$1'
}
}

+ 5
- 2
packages/microservice/package.json View File

@ -5,7 +5,10 @@
"types": "dist/typings/index.d.ts",
"license": "MIT",
"scripts": {
"build": "yarn run clean && tsc",
"clean": "rimraf ./dist"
"build": "yarn run clean && ttsc",
"clean": "rimraf ./coverage ./dist",
"coverage": "yarn test --coverage",
"test": "jest --silent",
"test:verbose": "jest"
}
}

+ 27
- 40
packages/microservice/src/InternalService.ts View File

@ -1,73 +1,60 @@
import { Microservice } from "./Microservice";
import EventEmitter from "events";
import { Microservice } from "./Microservice";
/**
* A generic service
* Define the dispatchable methosd available in the internal service
*/
export abstract class InternalService<T extends Microservice = Microservice> extends EventEmitter
export type IDispatchableMethods = {
[K in "boot"|"start"|"shutdown"]: () => Promise<void>
}
export abstract class InternalService<M extends Microservice = Microservice> extends EventEmitter implements IDispatchableMethods
{
/**
* The application instance
* The name of the service
*/
protected readonly app: T;
public abstract readonly NAME: string;
/**
* Enable/disable logging for this service
* Store a reference to the owning microservice instance
*/
public logging: boolean = true;
readonly #microservice: M;
/**
* Create a new service
* Create a new internal service instance
*/
public constructor(app: T) {
public constructor(microservice: M) {
super();
this.app = app;
this.#microservice = microservice;
}
// Required Service Implementation -------------------------------------------------------------
/**
* The service name
*/
public abstract readonly NAME: string;
/**
* Boot the service
*/
public async boot(): Promise<void> {
// no-op
public async boot() {
// NO-OP
}
/**
* Shut the application down
* Start the service
*/
public async shutdown(): Promise<void> {
// no-op
}
// Miscellaneous ------------------------------------------------------------------------------
public async start() {
// NO-OP
}
/**
* Link to local services
* Shutdown the service
*/
public link(app: T) {
// no-op
public async shutdown() {
// NO-OP
}
/**
* Indicate the application is ready
*/
public start() {
// no-op
};
// Accessors -----------------------------------------------------------------------------------
/**
* Service-specific logging
* Fetch the reference to the owning microservice instance
*/
public log(...args: any[]) {
if (!this.logging) {
return;
}
console.log(`[${this.NAME}]:`, ...args);
public get microservice() {
return this.#microservice;
}
}

+ 160
- 103
packages/microservice/src/Microservice.ts View File

@ -1,156 +1,202 @@
import { InternalService } from "./InternalService";
import assert from "assert";
import EventEmitter from "events";
import { IDispatchableMethods, InternalService } from "./InternalService";
import { MicroserviceState } from "./schema";
import { InternalServiceConflictError, InternalServiceNotFoundError } from "./errors";
/**
* Application InternalService map
* The InternalService constructor type
*/
interface InternalServiceMap {
[name: string]: InternalService
}
type InternalServiceConstructor<T extends Microservice = Microservice> = new (microservice: T) => InternalService<T>;
/**
* The InternalService constructor type
* Declare EventEmitter types
*/
type InternalServiceConstructor<T extends Microservice = Microservice> = new (app: T) => InternalService<T>;
interface Events {
"ready": () => void,
"finished": () => void
}
/**
* Microservice states
* Torrent IPC events
*/
export enum MicroserviceState {
Idling,
Booting,
Starting,
Running,
Quitting,
Finished
export declare interface Microservice {
on<U extends keyof Events>(event: U, listener: Events[U]): this,
emit<U extends keyof Events>(event: U, ...args: Parameters<Events[U]>): boolean
}
/**
* The main application class
*/
export class Microservice
export class Microservice extends EventEmitter
{
/**
* Maintain a static reference to the application instance
* The exec promise used to wait for quit event
*/
private static __instance: Microservice;
#execPromise?: Promise<number>;
/**
* A handler function to quit the microservice application
*/
private __quitHandler!: (value: number | PromiseLike<number>) => void;
#quitHandler?: (value: number | PromiseLike<number>) => void;
/**
* All available services
*/
protected services: InternalServiceMap = {};
#services = new Map<ThisType<this>, InternalService<any>>();
/**
* The current state of the microservice
*/
protected state: MicroserviceState;
#state: MicroserviceState = MicroserviceState.Idling;
// Event Handling ------------------------------------------------------------------------------
/**
* Return the current application instance
* Invoked when the application has finished booting
*/
public static instance() {
return this.__instance;
protected onStateChange(state: MicroserviceState): void|Promise<void> {
switch(state) {
case MicroserviceState.Running:
this.emit("ready");
break;
case MicroserviceState.Finished:
this.emit("finished");
break;
}
}
// Microservice Management ---------------------------------------------------------------------
/**
* Create a new application instance
* Run the application
*/
public constructor() {
Microservice.__instance = this;
this.state = MicroserviceState.Idling;
}
public async exec() {
// Exit if not in an idling state
if (this.state !== MicroserviceState.Idling) {
console.error("Cannot exec an already-started microservice");
return 1;
}
// Run the microservice application
let exitCode = await (async () => {
// Create the microservice execution promise to listen for quit events
let hasQuit = false;
this.#execPromise = new Promise<number>(resolve => this.#quitHandler = (exitCode) => {
resolve(exitCode);
console.log("Quit has been invoked");
hasQuit = true;
});
// Boot the microservice and internal services
if (!await this.boot()) { // no need to check for hasQuit
console.error("Failed to boot the microservice");
return 1;
}
// Start the internal services
if (!hasQuit && !await this.start()) {
console.error("Failed to start the microservice");
return 2;
}
// If the application has not quit, we can run the app
let exitCode: number;
if (!hasQuit) {
exitCode = await this.run();
} else {
exitCode = await this.#execPromise;
}
// Overridable --------------------------------------------------------------------------------
// Shutdown the microservice
if (!await this.shutdown()) {
console.error("Failed to shutdown the microservice");
return 3;
}
return exitCode;
})();
this.state = MicroserviceState.Finished;
return exitCode;
}
/**
* Invoked when the application has finished booting
* Quit the application
*/
protected onStart(): void|Promise<void> {}
public async quit(code: number = 0) {
if (this.state === MicroserviceState.Idling) {
this.state = MicroserviceState.Finished;
return;
}
if (this.state > MicroserviceState.Running || this.#quitHandler === undefined) {
return;
}
this.#quitHandler(code);
}
// Application Management ----------------------------------------------------------------------
// Microservice Internal Handling --------------------------------------------------------------
/**
* Boot the application and all of the services
* Dispatch a method call to the services, returning a boolean indicating the result
*/
protected async boot() {
let InternalServices = Object.values(this.services);
return Promise.all(InternalServices.map(InternalService => InternalService.boot()));
protected dispatch<T extends Microservice>(this: T, method: keyof IDispatchableMethods,
onFail: (service: InternalService<T>, error: Error|string) => void)
{
return new Promise<boolean>(resolve => {
let services = Array.from(this.#services.values());
Promise.all(services.map(service => (async () => {
try {
await service[method]();
} catch(e) {
onFail(service, e);
throw e;
}
})()))
.then(() => resolve(true))
.catch(() => resolve(false));
});
}
/**
* Shutdown the application
* Boot the application and all of the services
*/
protected shutdown() {
let InternalServices = Object.values(this.services);
return Promise.all(InternalServices.map(InternalService => InternalService.shutdown()));
protected boot() {
console.log("Booting...");
this.state = MicroserviceState.Booting;
return this.dispatch("boot", (service, error) => {
console.error(`Failed to boot service: ${service.NAME}\n`, error);
});
}
/**
* Start the application
* Start the application servvices
*/
public async exec() {
// Exit if not in an idling state
if (this.state !== MicroserviceState.Idling) {
return -1;
}
try {
// Boot the microservice
console.log("Booting services...");
this.state = MicroserviceState.Booting;
await this.boot();
// Linking the internal services
console.log("Linking services...");
for (let service of Object.values(this.services)) {
service.link(this);
}
// Start the microservice
console.log("Starting services...");
this.state = MicroserviceState.Starting
await this.onStart();
for (let service of Object.values(this.services)) {
service.start();
}
} catch(e) {
console.error("Failed to start the microservice:", e);
return 1;
}
protected start() {
console.log("Starting...");
this.state = MicroserviceState.Starting;
return this.dispatch("start", (service, error) => {
console.error(`Failed to start service: ${service.NAME}\n`, error);
});
}
// Run the microservice
console.log("Running");
/**
* Run the application and wait for shutdown
*/
protected run() {
console.log("Running.");
this.state = MicroserviceState.Running;
process.on("SIGINT", this.quit.bind(this));
let exitCode = await new Promise<number>((resolve) => this.__quitHandler = resolve);
// Shutdown the microservice
console.log("Shutting down...");
await this.shutdown().catch(() => {
console.log("Error ocurred during shutdown...");
exitCode = 1;
});
// Return the exit code
return exitCode;
return this.#execPromise!;
}
/**
* Quit the application
* Shutdown the application
*/
public async quit(code: number = 0) {
if (this.state !== MicroserviceState.Running) {
return;
}
this.__quitHandler(code);
protected shutdown() {
console.log("Shutting down...");
this.state = MicroserviceState.Quitting;
process.off("SIGINT", this.quit.bind(this));
return this.dispatch("shutdown", (service, error) => {
console.error(`Failed to shutdown service: ${service.NAME}\n`, error);
});
}
// InternalService Management --------------------------------------------------------------------------
// Internal Service Management -----------------------------------------------------------------
/**
* Install InternalServices into the application
@ -165,25 +211,36 @@ export class Microservice
* Install a InternalService into the application
*/
public installService<T extends Microservice>(this: T, InternalServiceClass: InternalServiceConstructor<T>) {
let InternalService = new InternalServiceClass(this);
if (InternalService.NAME in this.services) {
throw new Error("Install Service Error: Attempted to register multiple services with the same name");
if (this.#services.has(InternalServiceClass)) {
throw new InternalServiceConflictError(InternalServiceClass);
}
this.services[InternalService.NAME] = InternalService;
this.#services.set(InternalServiceClass, new InternalServiceClass(this));
}
// Accessors -----------------------------------------------------------------------------------
/**
* Get all available services
* Get an application services instance
*/
public serviceList() {
return Object.keys(this.services);
public service<T extends Microservice, U extends InternalServiceConstructor<T>>(this: T, InternalServiceClass: U) {
if (!this.#services.has(InternalServiceClass)) {
throw new InternalServiceNotFoundError(InternalServiceClass);
}
return <InstanceType<U>>this.#services.get(InternalServiceClass);
}
/**
* Get an application services instance
* Get the current state of the microservice
*/
public get state() {
return this.#state;
}
/**
* Set the current state and invoke an onChange event
*/
public service<T extends InternalService<Microservice>>(InternalServiceName: string) {
assert(InternalServiceName in this.services);
return <T>this.services[InternalServiceName];
protected set state(state: MicroserviceState) {
this.#state = state;
this.onStateChange(state);
}
}

+ 13
- 0
packages/microservice/src/errors.ts View File

@ -0,0 +1,13 @@
export class InternalServiceConflictError extends Error {
public constructor(internalServiceClass: any) {
super(`Unable to register service '${internalServiceClass}' as it conflicts with an already registered service.`);
Object.setPrototypeOf(this, InternalServiceConflictError.prototype);
}
}
export class InternalServiceNotFoundError extends Error {
public constructor(internalServiceClass: any) {
super(`Internal service not found: ${internalServiceClass}.`);
Object.setPrototypeOf(this, InternalServiceNotFoundError.prototype);
}
}

+ 3
- 1
packages/microservice/src/index.ts View File

@ -1,2 +1,4 @@
export * from "./Microservice";
export * from "./errors";
export * from "./schema";
export * from "./InternalService";
export * from "./Microservice";

+ 21
- 0
packages/microservice/src/schema.ts View File

@ -0,0 +1,21 @@
/**
* Microservice states
*/
export enum MicroserviceState {
Idling,
Booting,
Starting,
Running,
Quitting,
Finished
}
/**
* Microservice exit codes
*/
export enum ExitCode {
Ok,
BootError,
StartupError,
ShutdownError
}

+ 68
- 0
packages/microservice/test/integration/internal-services.test.ts View File

@ -0,0 +1,68 @@
import { ExitCode, InternalService, Microservice, MicroserviceState } from "../../src";
class MockService<M extends Microservice> extends InternalService<M> {
public NAME = "Mock Service";
public hasBooted = false;
public hasStarted = false;
public hasShutdown = false;
public override async boot() { this.hasBooted = true; }
public override async start() { this.hasStarted = true; }
public override async shutdown() { this.hasShutdown = true; }
}
class MockServiceBootFail<M extends Microservice> extends InternalService<M> {
public NAME = "Boot Fail";
public override async boot() {
throw new Error();
}
}
class MockServiceStartFail<M extends Microservice> extends InternalService<M> {
public NAME = "Start Fail";
public override async start() {
throw new Error();
}
}
class MockServiceShutdownFail<M extends Microservice> extends InternalService<M> {
public NAME = "Shutdown Fail";
public override async shutdown() {
throw new Error();
}
}
describe("Microservice/Internal Service Integration", () => {
test("Check that internal service state methods have been executed", async () => {
let microservice = new Microservice();
microservice.installService(MockService);
let executing = microservice.exec();
microservice.on("ready", () => microservice.quit());
await executing;
expect(microservice.service(MockService).hasBooted).toBe(true);
expect(microservice.service(MockService).hasStarted).toBe(true);
expect(microservice.service(MockService).hasShutdown).toBe(true);
});
test("Trigger boot failure", async () => {
let microservice = new Microservice();
microservice.installService(MockServiceBootFail);
let exitCode = await microservice.exec();
expect(exitCode).toEqual(ExitCode.BootError);
expect(microservice.state).toEqual(MicroserviceState.Finished);
});
test("Trigger start failure", async () => {
let microservice = new Microservice();
microservice.installService(MockServiceStartFail);
let exitCode = await microservice.exec();
expect(exitCode).toEqual(ExitCode.StartupError);
expect(microservice.state).toEqual(MicroserviceState.Finished);
});
test("Trigger shutdown failure", async () => {
let microservice = new Microservice();
microservice.installService(MockServiceShutdownFail);
let execPromise = microservice.exec();
microservice.on("ready", () => microservice.quit());
let exitCode = await execPromise;
expect(exitCode).toEqual(ExitCode.ShutdownError);
expect(microservice.state).toEqual(MicroserviceState.Finished);
});
});

+ 20
- 0
packages/microservice/test/unit/InternalService.spec.ts View File

@ -0,0 +1,20 @@
import { InternalService, Microservice } from "../../src";
class MockMicroservice extends Microservice {
public readonly customProp: string = "Test property";
}
class MockInternalService<M extends Microservice> extends InternalService<M> { NAME = "Test"; }
describe("InternalService", () => {
test("Get owning microservice instance", () => {
let microservice = new Microservice();
let service = new MockInternalService(microservice);
expect(service.microservice).toBeInstanceOf(Microservice);
});
test("Access properties from custom microservices", () => {
let microservice = new MockMicroservice();
let service = new MockInternalService<MockMicroservice>(microservice);
expect(service.microservice.customProp).toEqual("Test property");
});
});

+ 109
- 0
packages/microservice/test/unit/Microservice.spec.ts View File

@ -0,0 +1,109 @@
import { InternalService, Microservice } from "../../src";
import { InternalServiceConflictError, InternalServiceNotFoundError } from "../../src/errors";
import { ExitCode, MicroserviceState } from "../../src/schema";
class MockInternalService1 extends InternalService<Microservice> { NAME = "1"; }
class MockInternalService2 extends InternalService<Microservice> { NAME = "2"; }
class MockedMicroservice extends Microservice {
public readonly quitState: MicroserviceState;
public hasBooted = false;
public hasStarted = false;
public hasRun = false;
public hasShutdown = false;
public constructor(quitState: MicroserviceState) {
super();
this.quitState = quitState;
}
protected override async boot() {
this.hasBooted = true;
let result = await super.boot();
this.quitState === MicroserviceState.Booting && this.quit(25);
return result;
}
protected override async start() {
this.hasStarted = true;
let result = await super.start();
this.quitState === MicroserviceState.Starting && this.quit(50);
return result;
}
protected override async run() {
this.hasRun = true;
return await super.run();
}
protected override async shutdown() {
this.hasShutdown = true;
let result = super.shutdown();
this.quitState === MicroserviceState.Quitting && this.quit(100);
return result;
}
}
describe("Microservice:", () => {
describe("Internal Service Installation", () => {
test("Install an internal service", () => {
let microservice = new Microservice();
microservice.installServices([MockInternalService1, MockInternalService2]);
expect(microservice.service(MockInternalService1)).toBeInstanceOf(MockInternalService1);
expect(microservice.service(MockInternalService2)).toBeInstanceOf(MockInternalService2);
expect(microservice.service(MockInternalService1)).not.toBeInstanceOf(MockInternalService2);
});
test("Prevent installing already-installed services", () => {
let microservice = new Microservice();
expect(() => microservice.installServices([MockInternalService1, MockInternalService1])).toThrowError(InternalServiceConflictError);
});
test("Fail when accessing non-installed services", () => {
let microservice = new Microservice();
expect(() => microservice.service(MockInternalService1)).toThrowError(InternalServiceNotFoundError);
});
});
describe("Microservice State", () => {
// Quitting
test("Quit before executing process", async () => {
let microservice = new Microservice();
microservice.quit();
expect(microservice.state).toEqual(MicroserviceState.Finished);
expect(await microservice.exec()).toEqual(ExitCode.BootError);
});
test("Quit during boot state", async () => {
let microservice = new MockedMicroservice(MicroserviceState.Booting);
let exitCode = await microservice.exec();
expect(microservice.state).toEqual(MicroserviceState.Finished);
expect(microservice.hasBooted).toBe(true);
expect(microservice.hasShutdown).toBe(true);
expect(microservice.hasStarted).toBe(false);
expect(exitCode).toEqual(25);
});
test("Quit during starting state", async () => {
let microservice = new MockedMicroservice(MicroserviceState.Starting);
let exitCode = await microservice.exec();
expect(microservice.state).toEqual(MicroserviceState.Finished);
expect(microservice.hasBooted).toBe(true);
expect(microservice.hasShutdown).toBe(true);
expect(microservice.hasStarted).toBe(true);
expect(exitCode).toEqual(50);
});
test("Quit during running state", async () => {
let microservice = new MockedMicroservice(MicroserviceState.Running);
let runningPromise = microservice.exec();
setImmediate(() => microservice.quit(75));
let exitCode = await runningPromise;
expect(microservice.state).toEqual(MicroserviceState.Finished);
expect(exitCode).toEqual(75);
});
test("Quit during quitting state", async () => {
let microservice = new MockedMicroservice(MicroserviceState.Quitting);
let runningPromise = microservice.exec();
microservice.quit();
let exitCode = await runningPromise;
expect(microservice.state).toEqual(MicroserviceState.Finished);
expect(exitCode).toEqual(0);
});
});
});

Loading…
Cancel
Save