Skip to content

Commit

Permalink
chore(context): review - 1
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Dec 10, 2018
1 parent 36c89b6 commit 90af513
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 51 deletions.
39 changes: 31 additions & 8 deletions docs/site/Context.md
Expand Up @@ -235,12 +235,16 @@ the form of metadata) to your intent.
Bindings in a context can come and go. It's often desirable for an artifact
(especially an extension point) to keep track of other artifacts (extensions).
For example, the `RestServer` needs to know routes contributed by `controller`
classes. Ideally, a controller can be added context even after the application
starts.
classes or other handlers. Such routes can be added or removed after the
`RestServer` starts. For example, a controller can be added even after the
application starts and it causes a new route to be bound into the application
context. Ideally, the `RestServer` should be able to pick the new route without
restarting.

To support the dynamic tracking of such artifacts registered within a context
chain, we introduce the `ContextWatcher` class that can be used to watch a list
of bindings matching certain criteria depicted by a `BindingFilter` function.
chain, we introduce `ContextListener` interface and `ContextWatcher` class that
can be used to watch a list of bindings matching certain criteria depicted by a
`BindingFilter` function.

```ts
import {Context, ContextWatcher} from '@loopback/context';
Expand All @@ -256,7 +260,7 @@ const controllerFilter = binding => binding.tagMap.controller != null;
const watcher = serverCtx.watch(controllerFilter);

// No controllers yet
await watcher.values(); => []
await watcher.values(); // returns []

// Bind Controller1 to server context
serverCtx
Expand All @@ -265,7 +269,7 @@ serverCtx
.tag('controller');

// Resolve to an instance of Controller1
await watcher.values(); // => [an instance of Controller1];
await watcher.values(); // returns [an instance of Controller1];

// Bind Controller2 to app context
appCtx
Expand All @@ -274,15 +278,26 @@ appCtx
.tag('controller');

// Resolve to an instance of Controller1 and an instance of Controller2
await watcher.values(); // => [an instance of Controller1, an instance of Controller2];
await watcher.values(); // returns [an instance of Controller1, an instance of Controller2];

// Unbind Controller2
appCtx.unbind('controllers.Controller2');

// No more instance of Controller2
await watcher.values(); // => [an instance of Controller1];
await watcher.values(); // returns [an instance of Controller1];
```

The key benefit of `ContextWatcher` is that it caches resolved values until
context bindings matching the filter function are added/removed. For most cases,
we don't have to pay the penalty to find/resolve per request.

To fully leverage the live list of extensions, an extension point such as
`RoutingTable` should either keep a pointer to an instance of `ContextWatcher`
corresponding to all `routes` (extensions) in the context chain and use the
`values()` function to match again the live `routes` per request or implement
itself as a `ContextListener` to rebuild the routes upon changes of routes in
the context with `listen()`.

If your dependency needs to follow the context for values from bindings matching
a filter, use `@inject.filter` for dependency injection.

Expand Down Expand Up @@ -322,6 +337,14 @@ export class DataSourceTracker {
}
```

Please note that `@inject.filter` has two flavors:

- inject a snapshot of values from matching bindings without watching the
context. This is the behavior if the target type is an array instead of Getter
or ContextWatcher.
- inject a Getter/ContextWatcher so that it keeps track of context binding
changes.

The resolved value from `@inject.filter` injection varies on the target type:

- Function -> a Getter function
Expand Down
20 changes: 18 additions & 2 deletions packages/context/src/context-watcher.ts
Expand Up @@ -11,6 +11,22 @@ import {Getter} from './inject';
import * as debugFactory from 'debug';
const debug = debugFactory('loopback:context:watcher');

/**
* Listeners of context bind/unbind events
*/
export interface ContextListener {
/**
* A filter function to match bindings
*/
filter: BindingFilter;
/**
* Listen on `bind` or `unbind` and invalidate the cache
*/
listen(
event: ContextEventType,
binding: Readonly<Binding<unknown>>,
): ValueOrPromise<void>;
}
/**
* Watching a given context chain to maintain a live list of matching bindings
* and their resolved values within the context hierarchy.
Expand All @@ -20,7 +36,7 @@ const debug = debugFactory('loopback:context:watcher');
* they are added/removed/updated after the application starts.
*
*/
export class ContextWatcher<T = unknown> {
export class ContextWatcher<T = unknown> implements ContextListener {
protected _cachedBindings: Readonly<Binding<T>>[] | undefined;
protected _cachedValues: ValueOrPromise<T[]> | undefined;

Expand Down Expand Up @@ -95,7 +111,7 @@ export class ContextWatcher<T = unknown> {
}

/**
* Get the list of resolved values. If they are not cached, it tries tp find
* Get the list of resolved values. If they are not cached, it tries to find
* and resolve them.
*/
values(): Promise<T[]> {
Expand Down
54 changes: 27 additions & 27 deletions packages/context/src/context.ts
Expand Up @@ -7,7 +7,7 @@ import * as debugModule from 'debug';
import {v1 as uuidv1} from 'uuid';
import {Binding, TagMap} from './binding';
import {BindingAddress, BindingKey} from './binding-key';
import {ContextWatcher} from './context-watcher';
import {ContextWatcher, ContextListener} from './context-watcher';
import {ResolutionOptions, ResolutionSession} from './resolution-session';
import {
BoundValue,
Expand Down Expand Up @@ -52,9 +52,9 @@ export class Context {
protected _parent?: Context;

/**
* A list of registered context watchers
* A list of registered context listeners
*/
protected watchers: Set<ContextWatcher> = new Set();
protected listeners: Set<ContextListener> = new Set();

/**
* Create a new context. For example,
Expand Down Expand Up @@ -121,9 +121,9 @@ export class Context {
this.registry.set(key, binding);
if (existingBinding !== binding) {
if (existingBinding != null) {
this.notifyWatchers(ContextEventType.unbind, existingBinding);
this.notifyListeners(ContextEventType.unbind, existingBinding);
}
this.notifyWatchers(ContextEventType.bind, binding);
this.notifyListeners(ContextEventType.bind, binding);
}
return this;
}
Expand All @@ -145,31 +145,31 @@ export class Context {
if (binding && binding.isLocked)
throw new Error(`Cannot unbind key "${key}" of a locked binding`);
const found = this.registry.delete(key);
this.notifyWatchers(ContextEventType.unbind, binding);
this.notifyListeners(ContextEventType.unbind, binding);
return found;
}

/**
* Add the context watcher as an event listener to the context chain,
* Add the context listener as an event listener to the context chain,
* including its ancestors
* @param watcher Context watcher
* @param listener Context listener
*/
subscribe(watcher: ContextWatcher) {
subscribe(listener: ContextListener) {
let ctx: Context | undefined = this;
while (ctx != null) {
ctx.watchers.add(watcher);
ctx.listeners.add(listener);
ctx = ctx._parent;
}
}

/**
* Remove the context watcher from the context chain
* @param watcher Context watcher
* Remove the context listener from the context chain
* @param listener Context listener
*/
unsubscribe(watcher: ContextWatcher) {
unsubscribe(listener: ContextListener) {
let ctx: Context | undefined = this;
while (ctx != null) {
ctx.watchers.delete(watcher);
ctx.listeners.delete(listener);
ctx = ctx._parent;
}
}
Expand All @@ -178,33 +178,33 @@ export class Context {
* Watch the context chain with the given binding filter
* @param filter A function to match bindings
*/
watch(filter: BindingFilter) {
const watcher = new ContextWatcher(this, filter);
this.subscribe(watcher);
return watcher;
watch<T = unknown>(filter: BindingFilter) {
const listener = new ContextWatcher<T>(this, filter);
this.subscribe(listener);
return listener;
}

/**
* Publish an event to the registered watchers. Please note the
* Publish an event to the registered listeners. Please note the
* notification happens using `process.nextTick` so that we allow fluent APIs
* such as `ctx.bind('key').to(...).tag(...);` and give watchers the fully
* such as `ctx.bind('key').to(...).tag(...);` and give listeners the fully
* populated binding
*
* @param event Event names: `bind` or `unbind`
* @param binding Binding bound or unbound
*/
protected notifyWatchers(
protected notifyListeners(
event: ContextEventType,
binding: Readonly<Binding<unknown>>,
) {
// Notify watchers in the next tick
process.nextTick(() => {
for (const watcher of this.watchers) {
if (watcher.filter(binding)) {
// Notify listeners in the next tick
process.nextTick(async () => {
for (const listener of this.listeners) {
if (listener.filter(binding)) {
try {
watcher.listen(event, binding);
await listener.listen(event, binding);
} catch (err) {
debug('Error thrown by a watcher is ignored', err, event, binding);
debug('Error thrown by a listener is ignored', err, event, binding);
// Ignore the error
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/context/src/index.ts
Expand Up @@ -27,7 +27,7 @@ export {ResolutionSession} from './resolution-session';
export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject';
export {Provider} from './provider';

export {ContextWatcher} from './context-watcher';
export {ContextWatcher, ContextListener} from './context-watcher';

export {instantiateClass, invokeMethod} from './resolver';
// internals for testing
Expand Down
92 changes: 79 additions & 13 deletions packages/context/test/acceptance/context-watcher.acceptance.ts
@@ -1,30 +1,38 @@
import {expect} from '@loopback/testlab';
import {Context, ContextWatcher, Getter, inject} from '../..';
import {
Context,
ContextWatcher,
ContextListener,
Getter,
inject,
ContextEventType,
Binding,
} from '../..';

describe('ContextWatcher - watches matching bindings', () => {
let ctx: Context;
let server: Context;
let contextWatcher: ContextWatcher;
beforeEach(givenContextWatcher);
beforeEach(givenControllerWatcher);

it('watches matching bindings', async () => {
// We have ctx: 1, parent: 2
expect(await getControllers()).to.eql(['1', '2']);
ctx.unbind('controllers.1');
server.unbind('controllers.1');
// Now we have parent: 2
expect(await getControllers()).to.eql(['2']);
ctx.parent!.unbind('controllers.2');
server.parent!.unbind('controllers.2');
// All controllers are gone from the context chain
expect(await getControllers()).to.eql([]);
// Add a new controller - ctx: 3
givenController(ctx, '3');
givenController(server, '3');
expect(await getControllers()).to.eql(['3']);
});

function givenContextWatcher() {
ctx = givenContext();
contextWatcher = ctx.watch(Context.bindingTagFilter('controller'));
givenController(ctx, '1');
givenController(ctx.parent!, '2');
function givenControllerWatcher() {
server = givenServerWithinAnApp();
contextWatcher = server.watch(Context.bindingTagFilter('controller'));
givenController(server, '1');
givenController(server.parent!, '2');
}

function givenController(_ctx: Context, _name: string) {
Expand Down Expand Up @@ -96,7 +104,7 @@ describe('@inject.filter - injects a live collection of matching bindings', asyn
});

function givenPrimeNumbers() {
ctx = givenContext();
ctx = givenServerWithinAnApp();
givenPrime(ctx, 3);
givenPrime(ctx.parent!, 5);
}
Expand All @@ -109,7 +117,65 @@ describe('@inject.filter - injects a live collection of matching bindings', asyn
}
});

function givenContext() {
describe('ContextListener - listens on matching bindings', () => {
let server: Context;
let contextListener: MyListenerForControllers;
beforeEach(givenControllerListener);

it('receives notifications of matching binding events', async () => {
// We have ctx: 1, parent: 2
expect(await getControllers()).to.eql(['1', '2']);
server.unbind('controllers.1');
// Now we have parent: 2
expect(await getControllers()).to.eql(['2']);
server.parent!.unbind('controllers.2');
// All controllers are gone from the context chain
expect(await getControllers()).to.eql([]);
// Add a new controller - ctx: 3
givenController(server, '3');
expect(await getControllers()).to.eql(['3']);
});

class MyListenerForControllers implements ContextListener {
controllers: Set<string> = new Set();
filter = Context.bindingTagFilter('controller');
listen(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
if (event === ContextEventType.bind) {
this.controllers.add(binding.tagMap.name);
} else if (event === ContextEventType.unbind) {
this.controllers.delete(binding.tagMap.name);
}
}
}

function givenControllerListener() {
server = givenServerWithinAnApp();
contextListener = new MyListenerForControllers();
server.subscribe(contextListener);
givenController(server, '1');
givenController(server.parent!, '2');
return contextListener;
}

function givenController(_ctx: Context, _name: string) {
class MyController {
name = _name;
}
_ctx
.bind(`controllers.${_name}`)
.toClass(MyController)
.tag('controller', {name: _name});
}

async function getControllers() {
return new Promise<string[]>(resolve => {
// Wrap it inside `setImmediate` to make the events are triggered
setImmediate(() => resolve(Array.from(contextListener.controllers)));
});
}
});

function givenServerWithinAnApp() {
const parent = new Context('app');
const ctx = new Context(parent, 'server');
return ctx;
Expand Down

0 comments on commit 90af513

Please sign in to comment.