diff --git a/docs/site/Life-cycle.md b/docs/site/Life-cycle.md new file mode 100644 index 000000000000..d78171af6cfa --- /dev/null +++ b/docs/site/Life-cycle.md @@ -0,0 +1,282 @@ +--- +lang: en +title: 'Life cycle events and observers' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Life-cycle.html +--- + +## Overview + +A LoopBack application has its own life cycles at runtime. There are two methods +to control the transition of states of `Application`. + +- start(): Start the application +- stop(): Stop the application + +It's often desirable for various types of artifacts to participate in the life +cycles and perform related processing upon `start` and `stop`. Good examples of +such artifacts are: + +- Servers + + - start: Starts the HTTP server listening for connections. + - stop: Stops the server from accepting new connections. + +- Components + + - A component can register life cycle observers + +- DataSources + + - connect: Connect to the underlying database or service + - disconnect: Disconnect from the underlying database or service + +- Custom scripts + - start: Custom logic to be invoked when the application starts + - stop: Custom logic to be invoked when the application stops + +## The `LifeCycleObserver` interface + +To react on life cycle events, a life cycle observer implements the +`LifeCycleObserver` interface. + +```ts +import {ValueOrPromise} from '@loopback/context'; + +/** + * Observers to handle life cycle start/stop events + */ +export interface LifeCycleObserver { + start?(): ValueOrPromise; + stop?(): ValueOrPromise; +} +``` + +Please note all methods are optional so that an observer can opt in certain +events. Each main events such as `start` and `stop` are further divided into +three sub-phases to allow the multiple-step processing. + +## Register a life cycle observer + +A life cycle observer can be registered by calling `lifeCycleObserver()` of the +application. It binds the observer to the application context with a special +tag - `CoreTags.LIFE_CYCLE_OBSERVER`. + +```ts +app.lifeCycleObserver(MyObserver); +``` + +Please note that `app.server()` automatically registers servers as life cycle +observers. + +Life cycle observers can be registered via a component too: + +```ts +export class MyComponentWithObservers { + lifeCycleObservers: [XObserver, YObserver]; +} +``` + +## Discover life cycle observers + +The `Application` finds all bindings tagged with `CoreTags.LIFE_CYCLE_OBSERVER` +within the context chain and resolve them as observers to be notified. + +## Notify life cycle observers of start/stop related events by order + +There may be dependencies between life cycle observers and their order of +processing for `start` and `stop` need to be coordinated. For example, we +usually start a server to listen on incoming requests only after other parts of +the application are ready to handle requests. The stop sequence is typically +processed in the reverse order. To support such cases, we introduce +two-dimension steps to control the order of life cycle actions. + +### Observer groups + +First of all, we allow each of the life cycle observers to be tagged with a +group. For example: + +- datasource + + - connect/disconnect + - mongodb + - mysql + +- server + - rest + - gRPC + +We can then configure the application to trigger observers group by group as +configured by an array of groups in order such as `['datasource', 'server']`. +Observers within the same group can be notified in parallel. + +For example, + +```ts +app + .bind('observers.MyObserver') + .toClass(MyObserver) + .tag({ + [CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: 'g1', + }) + .apply(asLifeCycleObserverBinding); +``` + +The observer class can also be decorated with `@bind` to provide binding +metadata. + +```ts +@bind( + { + tags: { + [CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: 'g1', + }, + }, + asLifeCycleObserverBinding, +) +export class MyObserver { + // ... +} + +app.add(createBindingFromClass(MyObserver)); +``` + +The order of observers are controlled by a `groupsByOrder` property of +`LifeCycleObserverRegistry`, which receives its options including the +`groupsByOrder` from `CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS`. Thus the +initial `groupsByOrder` can be set as follows: + +```ts +app + .bind(CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS) + .to({groupsByOrder: ['g1', 'g2', 'server']}); +``` + +Or: + +```ts +const registry = await app.get(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY); +registry.setGroupsByOrder(['g1', 'g2', 'server']); +``` + +Observers are sorted using `groupsByOrder` as the relative order. If an observer +is tagged with a group that are not in `groupsByOrder`, it will come before any +groups within `groupsByOrder`. Such custom groups are also sorted by their names +alphabetically. + +In the example below, `groupsByOrder` is set to `['g1', 'g2']`. Given the +following observers: + +- 'my-observer-1' ('g1') +- 'my-observer-2' ('g2') +- 'my-observer-4' ('2-custom-group') +- 'my-observer-3' ('1-custom-group') + +The sorted observer groups will be: + +```ts +{ + '1-custom-group': ['my-observer-3'], + '2-custom-group': ['my-observer-4'], + 'g1': ['my-observer-1'], + 'g2': ['my-observer-2'], +} +``` + +### Event phases + +It's also desirable for certain observers to do some processing before, upon, or +after the `start` and `stop` events. To allow that, we notify each observer in +three phases: + +- start: preStart, start, and postStart +- stop: preStop, stop, and postStop + +Combining groups and event phases, it's flexible to manage multiple observers so +that they can be started/stopped gracefully in order. + +For example, with a group order as `['datasource', 'server']` and three +observers registered as follows: + +- datasource group: MySQLDataSource, MongoDBDataSource +- server group: RestServer + +The start sequence will be: + +1. MySQLDataSource.preStart +2. MongoDBDataSource.preStart +3. RestServer.preStart + +4. MySQLDataSource.start +5. MongoDBDataSource.start +6. RestServer.start + +7. MySQLDataSource.postStart +8. MongoDBDataSource.postStart +9. RestServer.postStart + +## Add custom life cycle observers by convention + +Each application can have custom life cycle observers to be dropped into +`src/observers` folder as classes implementing `LifeCycleObserver`. + +During application.boot(), such artifacts are discovered, loaded, and bound to +the application context as life cycle observers. This is achieved by a built-in +`LifeCycleObserverBooter` extension. + +## CLI command + +To make it easy for application developers to add custom life cycle observers, +we introduce `lb4 observer` command as part the CLI. + +To add a life cycle observer: + +1. cd +2. lb4 observer + +``` +? Observer name: Hello + create src/observers/my.hello-observer.ts + update src/observers/index.ts + +Observer Hello was created in src/observers/ +``` + +The generated class looks like: + +```ts +import {bind} from '@loopback/context'; +import { + /* inject, Application, */ + CoreBindings, + LifeCycleObserver, +} from '@loopback/core'; + +/** + * This class will be bound to the application as a `LifeCycleObserver` during + * `boot` + */ +@bind({tags: {[CoreBindings.LIFE_CYCLE_OBSERVER_GROUP]: ''}}) +export class HelloObserver implements LifeCycleObserver { + /* + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) private app: Application, + ) {} + */ + + /** + * This method will be invoked when the application starts + */ + async start(): Promise { + // Add your logic for start + } + + /** + * This method will be invoked when the application stops + */ + async stop(): Promise { + // Add your logic for start + } +} +``` diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index 1eb6d2fc1256..b6479b0ece12 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -145,6 +145,10 @@ children: url: DataSources.html output: 'web, pdf' + - title: 'Life cycle events and observers' + url: Life-cycle.html + output: 'web, pdf' + - title: 'Routes' url: Routes.html output: 'web, pdf' diff --git a/packages/core/docs.json b/packages/core/docs.json index f4634ab5e5c0..925b1678681b 100644 --- a/packages/core/docs.json +++ b/packages/core/docs.json @@ -5,7 +5,8 @@ "src/component.ts", "src/index.ts", "src/keys.ts", - "src/server.ts" + "src/server.ts", + "src/lifecycle.ts" ], "codeSectionDepth": 4 } diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index f7eee379a5cc..66bdbcf7a444 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -4,11 +4,30 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/debug": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.2.tgz", + "integrity": "sha512-jkf6UiWUjcOqdQbatbvOm54/YbCdjt3JjiAzT/9KS2XtMmOkYHdKsI5u8fulhbuTUuiqNBfa6J5GSDiwjK+zLA==", + "dev": true + }, "@types/node": { "version": "10.12.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.30.tgz", "integrity": "sha512-nsqTN6zUcm9xtdJiM9OvOJ5EF0kOI8f1Zuug27O/rgtxCRJHGqncSWfCMZUP852dCKPsDsYXGvBhxfRjDBkF5Q==", "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" } } } diff --git a/packages/core/package.json b/packages/core/package.json index 456b21825b4d..bfe7364b53c2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,12 +20,14 @@ "copyright.owner": "IBM Corp.", "license": "MIT", "dependencies": { - "@loopback/context": "^1.8.1" + "@loopback/context": "^1.8.1", + "debug": "^4.1.0" }, "devDependencies": { "@loopback/build": "^1.4.0", "@loopback/testlab": "^1.2.1", "@loopback/tslint-config": "^2.0.3", + "@types/debug": "^4.1.2", "@types/node": "^10.11.2" }, "files": [ diff --git a/packages/core/src/__tests__/unit/application-lifecycle.unit.ts b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts new file mode 100644 index 000000000000..a51d6cf11040 --- /dev/null +++ b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts @@ -0,0 +1,149 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingScope, Constructor, Context} from '@loopback/context'; +import {expect} from '@loopback/testlab'; +import { + Application, + asLifeCycleObserverBinding, + Component, + CoreBindings, + CoreTags, + LifeCycleObserver, + Server, +} from '../..'; + +describe('Application life cycle', () => { + describe('start', () => { + it('starts all injected servers', async () => { + const app = new Application(); + app.component(FakeComponent); + const component = await app.get( + `${CoreBindings.COMPONENTS}.FakeComponent`, + ); + expect(component.status).to.equal('not-initialized'); + await app.start(); + const server = await app.getServer(FakeServer); + + expect(server).to.not.be.null(); + expect(server.listening).to.equal(true); + expect(component.status).to.equal('started'); + await app.stop(); + }); + + it('starts servers bound with `LIFE_CYCLE_OBSERVER` tag', async () => { + const app = new Application(); + app + .bind('fake-server') + .toClass(FakeServer) + .tag(CoreTags.LIFE_CYCLE_OBSERVER, CoreTags.SERVER) + .inScope(BindingScope.SINGLETON); + await app.start(); + const server = await app.get('fake-server'); + + expect(server).to.not.be.null(); + expect(server.listening).to.equal(true); + await app.stop(); + }); + + it('starts/stops all registered components', async () => { + const app = new Application(); + app.component(FakeComponent); + const component = await app.get( + `${CoreBindings.COMPONENTS}.FakeComponent`, + ); + expect(component.status).to.equal('not-initialized'); + await app.start(); + expect(component.status).to.equal('started'); + await app.stop(); + expect(component.status).to.equal('stopped'); + }); + + it('starts/stops all observers from the component', async () => { + const app = new Application(); + app.component(FakeComponentWithAnObserver); + const observer = await app.get( + 'lifeCycleObservers.MyObserver', + ); + expect(observer.status).to.equal('not-initialized'); + await app.start(); + expect(observer.status).to.equal('started'); + await app.stop(); + expect(observer.status).to.equal('stopped'); + }); + + it('starts/stops all registered life cycle observers', async () => { + const app = new Application(); + app.lifeCycleObserver(MyObserver, 'my-observer'); + + const observer = await app.get( + 'lifeCycleObservers.my-observer', + ); + expect(observer.status).to.equal('not-initialized'); + await app.start(); + expect(observer.status).to.equal('started'); + await app.stop(); + expect(observer.status).to.equal('stopped'); + }); + + it('does not attempt to start poorly named bindings', async () => { + const app = new Application(); + app.component(FakeComponent); + + // The app.start should not attempt to start this binding. + app.bind('controllers.servers').to({}); + await app.start(); + await app.stop(); + }); + }); +}); + +class FakeComponent implements Component { + status = 'not-initialized'; + servers: { + [name: string]: Constructor; + }; + constructor() { + this.servers = { + FakeServer, + FakeServer2: FakeServer, + }; + } + start() { + this.status = 'started'; + } + stop() { + this.status = 'stopped'; + } +} + +class FakeServer extends Context implements Server { + listening: boolean = false; + constructor() { + super(); + } + async start(): Promise { + this.listening = true; + } + + async stop(): Promise { + this.listening = false; + } +} + +class MyObserver implements LifeCycleObserver { + status = 'not-initialized'; + + start() { + this.status = 'started'; + } + stop() { + this.status = 'stopped'; + } +} + +class FakeComponentWithAnObserver implements Component { + lifeCycleObservers = [MyObserver]; +} diff --git a/packages/core/src/__tests__/unit/application.unit.ts b/packages/core/src/__tests__/unit/application.unit.ts index 4cc86c6b430d..61d993999f0d 100644 --- a/packages/core/src/__tests__/unit/application.unit.ts +++ b/packages/core/src/__tests__/unit/application.unit.ts @@ -7,13 +7,12 @@ import { bind, Binding, BindingScope, - Constructor, Context, inject, Provider, } from '@loopback/context'; import {expect} from '@loopback/testlab'; -import {Application, Component, CoreBindings, Server} from '../..'; +import {Application, Component, CoreBindings, CoreTags, Server} from '../..'; describe('Application', () => { describe('controller binding', () => { @@ -24,17 +23,21 @@ describe('Application', () => { it('binds a controller', () => { const binding = app.controller(MyController); - expect(Array.from(binding.tagNames)).to.containEql('controller'); + expect(Array.from(binding.tagNames)).to.containEql(CoreTags.CONTROLLER); expect(binding.key).to.equal('controllers.MyController'); expect(binding.scope).to.equal(BindingScope.TRANSIENT); - expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); + expect(findKeysByTag(app, CoreTags.CONTROLLER)).to.containEql( + binding.key, + ); }); it('binds a controller with custom name', () => { const binding = app.controller(MyController, 'my-controller'); - expect(Array.from(binding.tagNames)).to.containEql('controller'); + expect(Array.from(binding.tagNames)).to.containEql(CoreTags.CONTROLLER); expect(binding.key).to.equal('controllers.my-controller'); - expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); + expect(findKeysByTag(app, CoreTags.CONTROLLER)).to.containEql( + binding.key, + ); }); it('binds a singleton controller', () => { @@ -61,14 +64,14 @@ describe('Application', () => { it('binds a component', () => { const binding = app.component(MyComponent); expect(binding.scope).to.equal(BindingScope.SINGLETON); - expect(findKeysByTag(app, 'component')).to.containEql( + expect(findKeysByTag(app, CoreTags.COMPONENT)).to.containEql( 'components.MyComponent', ); }); it('binds a component with custom name', () => { app.component(MyComponent, 'my-component'); - expect(findKeysByTag(app, 'component')).to.containEql( + expect(findKeysByTag(app, CoreTags.COMPONENT)).to.containEql( 'components.my-component', ); }); @@ -191,7 +194,7 @@ describe('Application', () => { it('defaults to constructor name', async () => { const binding = app.server(FakeServer); expect(binding.scope).to.equal(BindingScope.SINGLETON); - expect(Array.from(binding.tagNames)).to.containEql('server'); + expect(Array.from(binding.tagNames)).to.containEql(CoreTags.SERVER); const result = await app.getServer(FakeServer.name); expect(result.constructor.name).to.equal(FakeServer.name); }); @@ -213,8 +216,8 @@ describe('Application', () => { it('allows binding of multiple servers as an array', async () => { const bindings = app.servers([FakeServer, AnotherServer]); - expect(Array.from(bindings[0].tagNames)).to.containEql('server'); - expect(Array.from(bindings[1].tagNames)).to.containEql('server'); + expect(Array.from(bindings[0].tagNames)).to.containEql(CoreTags.SERVER); + expect(Array.from(bindings[1].tagNames)).to.containEql(CoreTags.SERVER); const fakeResult = await app.getServer(FakeServer); expect(fakeResult.constructor.name).to.equal(FakeServer.name); const AnotherResult = await app.getServer(AnotherServer); @@ -226,46 +229,11 @@ describe('Application', () => { } }); - describe('start', () => { - it('starts all injected servers', async () => { - const app = new Application(); - app.component(FakeComponent); - - await app.start(); - const server = await app.getServer(FakeServer); - expect(server).to.not.be.null(); - expect(server.listening).to.equal(true); - await app.stop(); - }); - - it('does not attempt to start poorly named bindings', async () => { - const app = new Application(); - app.component(FakeComponent); - - // The app.start should not attempt to start this binding. - app.bind('controllers.servers').to({}); - await app.start(); - await app.stop(); - }); - }); - function findKeysByTag(ctx: Context, tag: string | RegExp) { return ctx.findByTag(tag).map(binding => binding.key); } }); -class FakeComponent implements Component { - servers: { - [name: string]: Constructor; - }; - constructor() { - this.servers = { - FakeServer, - FakeServer2: FakeServer, - }; - } -} - class FakeServer extends Context implements Server { listening: boolean = false; constructor() { diff --git a/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts b/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts new file mode 100644 index 000000000000..b1003febf0a1 --- /dev/null +++ b/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts @@ -0,0 +1,99 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + BindingScope, + Context, + createBindingFromClass, +} from '@loopback/context'; +import {expect} from '@loopback/testlab'; +import { + asLifeCycleObserverBinding, + CoreBindings, + CoreTags, + LifeCycleObserver, + LifeCycleObserverRegistry, +} from '../..'; + +describe('LifeCycleRegistry', () => { + let context: Context; + let registry: LifeCycleObserverRegistry; + const events: string[] = []; + + beforeEach(() => events.splice(0, events.length)); + beforeEach(givenContext); + beforeEach(givenLifeCycleRegistry); + + it('starts all registered observers', async () => { + givenObserver('1'); + givenObserver('2'); + await registry.start(); + expect(events).to.eql(['1-start', '2-start']); + }); + + it('stops all registered observers in reverse order', async () => { + givenObserver('1'); + givenObserver('2'); + await registry.stop(); + expect(events).to.eql(['2-stop', '1-stop']); + }); + + it('starts all registered observers by group', async () => { + givenObserver('1', 'g1'); + givenObserver('2', 'g2'); + givenObserver('3', 'g1'); + registry.setGroupsByOrder(['g1', 'g2']); + await registry.start(); + expect(events).to.eql(['1-start', '3-start', '2-start']); + }); + + it('stops all registered observers in reverse order by group', async () => { + givenObserver('1', 'g1'); + givenObserver('2', 'g2'); + givenObserver('3', 'g1'); + registry.setGroupsByOrder(['g1', 'g2']); + await registry.stop(); + expect(events).to.eql(['2-stop', '3-stop', '1-stop']); + }); + + it('starts observers by alphabetical group names if no group order is configured', async () => { + givenObserver('1', 'g1'); + givenObserver('2', 'g2'); + givenObserver('3', 'g1'); + await registry.start(); + expect(events).to.eql(['1-start', '3-start', '2-start']); + }); + + function givenContext() { + context = new Context('app'); + } + + async function givenLifeCycleRegistry() { + context + .bind(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY) + .toClass(LifeCycleObserverRegistry) + .inScope(BindingScope.SINGLETON); + registry = await context.get(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY); + } + + function givenObserver(name: string, group = '') { + @bind({tags: {[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: group}}) + class MyObserver implements LifeCycleObserver { + start() { + events.push(`${name}-start`); + } + stop() { + events.push(`${name}-stop`); + } + } + const binding = createBindingFromClass(MyObserver, { + key: `observers.observer-${name}`, + }).apply(asLifeCycleObserverBinding); + context.add(binding); + + return MyObserver; + } +}); diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 501c659d1b86..ad7d58b4113d 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -10,19 +10,31 @@ import { Context, createBindingFromClass, } from '@loopback/context'; +import * as debugFactory from 'debug'; import {Component, mountComponent} from './component'; import {CoreBindings, CoreTags} from './keys'; +import { + asLifeCycleObserverBinding, + isLifeCycleObserverClass, + LifeCycleObserver, +} from './lifecycle'; +import {LifeCycleObserverRegistry} from './lifecycle-registry'; import {Server} from './server'; +const debug = debugFactory('loopback:core:application'); /** * Application is the container for various types of artifacts, such as * components, servers, controllers, repositories, datasources, connectors, * and models. */ -export class Application extends Context { +export class Application extends Context implements LifeCycleObserver { constructor(public options: ApplicationConfig = {}) { super('application'); + // Bind the life cycle observer registry + this.bind(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY) + .toClass(LifeCycleObserverRegistry) + .inScope(BindingScope.SINGLETON); // Bind to self to allow injection of application context in other modules. this.bind(CoreBindings.APPLICATION_INSTANCE).to(this); // Make options available to other modules as well. @@ -46,6 +58,7 @@ export class Application extends Context { * ``` */ controller(controllerCtor: ControllerClass, name?: string): Binding { + debug('Adding controller %s', name || controllerCtor.name); const binding = createBindingFromClass(controllerCtor, { name, namespace: CoreBindings.CONTROLLERS, @@ -77,12 +90,13 @@ export class Application extends Context { ctor: Constructor, name?: string, ): Binding { + debug('Adding server %s', name || ctor.name); const binding = createBindingFromClass(ctor, { name, namespace: CoreBindings.SERVERS, type: CoreTags.SERVER, defaultScope: BindingScope.SINGLETON, - }); + }).apply(asLifeCycleObserverBinding); this.add(binding); return binding; } @@ -136,40 +150,28 @@ export class Application extends Context { } /** - * Start the application, and all of its registered servers. + * Start the application, and all of its registered observers. * * @returns {Promise} * @memberof Application */ public async start(): Promise { - await this._forEachServer(s => s.start()); + const registry = await this.getLifeCycleObserverRegistry(); + await registry.start(); } /** - * Stop the application instance and all of its registered servers. + * Stop the application instance and all of its registered observers. * @returns {Promise} * @memberof Application */ public async stop(): Promise { - await this._forEachServer(s => s.stop()); + const registry = await this.getLifeCycleObserverRegistry(); + await registry.stop(); } - /** - * Helper function for iterating across all registered server components. - * @protected - * @template T - * @param {(s: Server) => Promise} fn The function to run against all - * registered servers - * @memberof Application - */ - protected async _forEachServer(fn: (s: Server) => Promise) { - const bindings = this.find(`${CoreBindings.SERVERS}.*`); - await Promise.all( - bindings.map(async binding => { - const server = await this.get(binding.key); - return await fn(server); - }), - ); + private async getLifeCycleObserverRegistry() { + return await this.get(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY); } /** @@ -194,12 +196,16 @@ export class Application extends Context { * ``` */ public component(componentCtor: Constructor, name?: string) { + debug('Adding component: %s', name || componentCtor.name); const binding = createBindingFromClass(componentCtor, { name, namespace: CoreBindings.COMPONENTS, type: CoreTags.COMPONENT, defaultScope: BindingScope.SINGLETON, }); + if (isLifeCycleObserverClass(componentCtor)) { + binding.apply(asLifeCycleObserverBinding); + } this.add(binding); // Assuming components can be synchronously instantiated const instance = this.getSync(binding.key); @@ -216,6 +222,26 @@ export class Application extends Context { public setMetadata(metadata: ApplicationMetadata) { this.bind(CoreBindings.APPLICATION_METADATA).to(metadata); } + + /** + * Register a life cycle observer class + * @param ctor A class implements LifeCycleObserver + * @param name Optional name for the life cycle observer + */ + public lifeCycleObserver( + ctor: Constructor, + name?: string, + ): Binding { + debug('Adding life cycle observer %s', name || ctor.name); + const binding = createBindingFromClass(ctor, { + name, + namespace: CoreBindings.LIFE_CYCLE_OBSERVERS, + type: CoreTags.LIFE_CYCLE_OBSERVER, + defaultScope: BindingScope.SINGLETON, + }).apply(asLifeCycleObserverBinding); + this.add(binding); + return binding; + } } /** diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 7e84a09efed2..e06f28096105 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -11,6 +11,7 @@ import { Provider, } from '@loopback/context'; import {Application, ControllerClass} from './application'; +import {LifeCycleObserver} from './lifecycle'; import {Server} from './server'; /** @@ -67,6 +68,8 @@ export interface Component { [name: string]: Constructor; }; + lifeCycleObservers?: Constructor[]; + /** * An array of bindings to be aded to the application context. For example, * ```ts @@ -126,4 +129,10 @@ export function mountComponent(app: Application, component: Component) { app.server(component.servers[serverKey], serverKey); } } + + if (component.lifeCycleObservers) { + for (const observer of component.lifeCycleObservers) { + app.lifeCycleObserver(observer); + } + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5381aeaa6ee8..87e4b81ac6fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,8 @@ export * from './server'; export * from './application'; export * from './component'; export * from './keys'; +export * from './lifecycle'; +export * from './lifecycle-registry'; // Re-export public Core API coming from dependencies export * from '@loopback/context'; diff --git a/packages/core/src/keys.ts b/packages/core/src/keys.ts index a7a8e0563fa0..6cd42c8388d4 100644 --- a/packages/core/src/keys.ts +++ b/packages/core/src/keys.ts @@ -5,6 +5,10 @@ import {BindingKey} from '@loopback/context'; import {Application, ApplicationMetadata, ControllerClass} from './application'; +import { + LifeCycleObserverOptions, + LifeCycleObserverRegistry, +} from './lifecycle-registry'; /** * Namespace for core binding keys @@ -74,6 +78,21 @@ export namespace CoreBindings { * context */ export const CONTROLLER_CURRENT = BindingKey.create('controller.current'); + + export const LIFE_CYCLE_OBSERVERS = 'lifeCycleObservers'; + /** + * Binding key for life cycle observer options + */ + export const LIFE_CYCLE_OBSERVER_REGISTRY = BindingKey.create< + LifeCycleObserverRegistry + >('lifeCycleObserver.registry'); + + /** + * Binding key for life cycle observer options + */ + export const LIFE_CYCLE_OBSERVER_OPTIONS = BindingKey.create< + LifeCycleObserverOptions + >('lifeCycleObserver.options'); } export namespace CoreTags { @@ -91,4 +110,14 @@ export namespace CoreTags { * Binding tag for controllers */ export const CONTROLLER = 'controller'; + + /** + * Binding tag for life cycle observers + */ + export const LIFE_CYCLE_OBSERVER = 'lifeCycleObserver'; + + /** + * Binding tag for group name of life cycle observers + */ + export const LIFE_CYCLE_OBSERVER_GROUP = 'lifeCycleObserverGroup'; } diff --git a/packages/core/src/lifecycle-registry.ts b/packages/core/src/lifecycle-registry.ts new file mode 100644 index 000000000000..2474f41429c4 --- /dev/null +++ b/packages/core/src/lifecycle-registry.ts @@ -0,0 +1,241 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding, ContextView, inject} from '@loopback/context'; +import {CoreBindings, CoreTags} from './keys'; +import {LifeCycleObserver, lifeCycleObserverFilter} from './lifecycle'; +import debugFactory = require('debug'); +const debug = debugFactory('loopback:core:lifecycle'); + +/** + * A group of life cycle observers + */ +export type LifeCycleObserverGroup = { + /** + * Observer group name + */ + group: string; + /** + * Bindings for observers within the group + */ + bindings: Readonly>[]; +}; + +export type LifeCycleObserverOptions = { + /** + * Control the order of observer groups for notifications. For example, + * with `['datasource', 'server']`, the observers in `datasource` group are + * notified before those in `server` group during `start`. Please note that + * observers are notified in the reverse order during `stop`. + */ + groupsByOrder: string[]; + /** + * Notify observers of the same group in parallel, default to `true` + */ + parallel?: boolean; +}; + +/** + * A context-based registry for life cycle observers + */ +export class LifeCycleObserverRegistry implements LifeCycleObserver { + constructor( + @inject.view(lifeCycleObserverFilter) + protected observersView: ContextView, + @inject(CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS, {optional: true}) + protected options: LifeCycleObserverOptions = { + parallel: true, + groupsByOrder: ['server'], + }, + ) {} + + setGroupsByOrder(groups: string[]) { + this.options.groupsByOrder = groups || ['server']; + } + + /** + * Get observer groups ordered by the group + */ + protected getObserverGroupsByOrder(): LifeCycleObserverGroup[] { + const bindings = this.observersView.bindings; + const groups = this.sortObserverBindingsByGroup(bindings); + if (debug.enabled) { + debug( + 'Observer groups: %j', + groups.map(g => ({ + group: g.group, + bindings: g.bindings.map(b => b.key), + })), + ); + } + return groups; + } + + /** + * Get the group for a given life cycle observer binding + * @param binding Life cycle observer binding + */ + protected getObserverGroup( + binding: Readonly>, + ): string { + // First check if there is an explicit group name in the tag + let group = binding.tagMap[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]; + if (!group) { + // Fall back to a tag that matches one of the groups + group = this.options.groupsByOrder.find(g => binding.tagMap[g] === g); + } + group = group || ''; + debug( + 'Binding %s is configured with observer group %s', + binding.key, + group, + ); + return group; + } + + /** + * Sort the life cycle observer bindings so that we can start/stop them + * in the right order. By default, we can start other observers before servers + * and stop them in the reverse order + * @param bindings Life cycle observer bindings + */ + protected sortObserverBindingsByGroup( + bindings: Readonly>[], + ) { + // Group bindings in a map + const groupMap: Map< + string, + Readonly>[] + > = new Map(); + for (const binding of bindings) { + const group = this.getObserverGroup(binding); + let bindingsInGroup = groupMap.get(group); + if (bindingsInGroup == null) { + bindingsInGroup = []; + groupMap.set(group, bindingsInGroup); + } + bindingsInGroup.push(binding); + } + // Create an array for group entries + const groups: LifeCycleObserverGroup[] = []; + for (const [group, bindingsInGroup] of groupMap) { + groups.push({group, bindings: bindingsInGroup}); + } + // Sort the groups + return groups.sort((g1, g2) => { + const i1 = this.options.groupsByOrder.indexOf(g1.group); + const i2 = this.options.groupsByOrder.indexOf(g2.group); + if (i1 !== -1 || i2 !== -1) { + // Honor the group order + return i1 - i2; + } else { + // Neither group is in the pre-defined order + // Use alphabetical order instead so that `1-group` is invoked before + // `2-group` + return g1.group < g2.group ? -1 : g1.group > g2.group ? 1 : 0; + } + }); + } + + /** + * Notify an observer group of the given event + * @param group A group of bindings for life cycle observers + * @param event Event name + */ + protected async notifyObservers( + observers: LifeCycleObserver[], + bindings: Readonly>[], + event: keyof LifeCycleObserver, + ) { + if (!this.options.parallel) { + let index = 0; + for (const observer of observers) { + debug( + 'Invoking %s observer for binding %s', + event, + bindings[index].key, + ); + index++; + await this.invokeObserver(observer, event); + } + return; + } + + // Parallel invocation + const notifiers = observers.map((observer, index) => { + debug('Invoking %s observer for binding %s', event, bindings[index].key); + return this.invokeObserver(observer, event); + }); + await Promise.all(notifiers); + } + + /** + * Invoke an observer for the given event + * @param observer A life cycle observer + * @param event Event name + */ + protected async invokeObserver( + observer: LifeCycleObserver, + event: keyof LifeCycleObserver, + ) { + if (typeof observer[event] === 'function') { + await observer[event]!(); + } + } + + /** + * Emit events to the observer groups + * @param events Event names + * @param groups Observer groups + */ + protected async notifyGroups( + events: (keyof LifeCycleObserver)[], + groups: LifeCycleObserverGroup[], + reverse = false, + ) { + const observers = await this.observersView.values(); + const bindings = this.observersView.bindings; + if (reverse) groups = groups.reverse(); + for (const group of groups) { + const observersForGroup: LifeCycleObserver[] = []; + const bindingsInGroup = reverse + ? group.bindings.reverse() + : group.bindings; + for (const binding of bindingsInGroup) { + const index = bindings.indexOf(binding); + observersForGroup.push(observers[index]); + } + + for (const event of events) { + debug('Beginning notification %s of %s...', event); + await this.notifyObservers(observersForGroup, group.bindings, event); + debug('Finished notification %s of %s', event); + } + } + } + + /** + * Notify all life cycle observers by group of `start` + * + * @returns {Promise} + */ + public async start(): Promise { + debug('Starting the %s...'); + const groups = this.getObserverGroupsByOrder(); + await this.notifyGroups(['start'], groups); + } + + /** + * Notify all life cycle observers by group of `stop` + * + * @returns {Promise} + */ + public async stop(): Promise { + debug('Stopping the %s...'); + const groups = this.getObserverGroupsByOrder(); + // Stop in the reverse order + await this.notifyGroups(['stop'], groups, true); + } +} diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts new file mode 100644 index 000000000000..70630de9c1af --- /dev/null +++ b/packages/core/src/lifecycle.ts @@ -0,0 +1,63 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Binding, + BindingFilter, + Constructor, + ValueOrPromise, +} from '@loopback/context'; +import {CoreTags} from './keys'; + +/** + * Observers to handle life cycle start/stop events + */ +export interface LifeCycleObserver { + /** + * The method to be invoked during `start` + */ + start?(): ValueOrPromise; + /** + * The method to be invoked during `stop` + */ + stop?(): ValueOrPromise; +} + +const lifeCycleMethods: (keyof LifeCycleObserver)[] = ['start', 'stop']; + +/** + * Test if an object implements LifeCycleObserver + * @param obj An object + */ +export function isLifeCycleObserver(obj: { + [name: string]: unknown; +}): obj is LifeCycleObserver { + return lifeCycleMethods.some(m => typeof obj[m] === 'function'); +} + +/** + * Test if a class implements LifeCycleObserver + * @param ctor A class + */ +export function isLifeCycleObserverClass( + ctor: Constructor, +): ctor is Constructor { + return ctor.prototype && isLifeCycleObserver(ctor.prototype); +} + +/** + * Configure the binding as life cycle observer + * @param binding Binding + */ +export function asLifeCycleObserverBinding(binding: Binding) { + return binding.tag(CoreTags.LIFE_CYCLE_OBSERVER); +} + +/** + * Find all life cycle observer bindings. By default, a binding tagged with + * `CoreTags.LIFE_CYCLE_OBSERVER` + */ +export const lifeCycleObserverFilter: BindingFilter = binding => + binding.tagMap[CoreTags.LIFE_CYCLE_OBSERVER] != null; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 615988257f67..09a800fb036d 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -3,6 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {LifeCycleObserver} from './lifecycle'; + /** * Defines the requirements to implement a Server for LoopBack applications: * start() : Promise @@ -15,18 +17,9 @@ * @export * @interface Server */ -export interface Server { +export interface Server extends LifeCycleObserver { /** * Tells whether the server is listening for connections or not */ readonly listening: boolean; - - /** - * Start the server - */ - start(): Promise; - /** - * Stop the server - */ - stop(): Promise; }