Skip to content

Commit

Permalink
Merge b03de5d into 377fa08
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Dec 10, 2018
2 parents 377fa08 + b03de5d commit 3d8fe30
Show file tree
Hide file tree
Showing 9 changed files with 946 additions and 71 deletions.
121 changes: 121 additions & 0 deletions docs/site/Context.md
Expand Up @@ -229,3 +229,124 @@ class HelloController {
These "sugar" decorators allow you to quickly build up your application without
having to code up all the additional logic by simply giving LoopBack hints (in
the form of metadata) to your intent.

## Context watcher

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 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 `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';

// Set up a context chain
const appCtx = new Context('app');
const serverCtx = new Context(appCtx, 'server'); // server -> app

// Define a binding filter to select bindings with tag `controller`
const controllerFilter = binding => binding.tagMap.controller != null;

// Watch for bindings with tag `controller`
const watcher = serverCtx.watch(controllerFilter);

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

// Bind Controller1 to server context
serverCtx
.bind('controllers.Controller1')
.toClass(Controller1)
.tag('controller');

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

// Bind Controller2 to app context
appCtx
.bind('controllers.Controller2')
.toClass(Controller2)
.tag('controller');

// Resolve to an instance of Controller1 and 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(); // 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.

```ts
import {inject, Getter} from '@loopback/context';
import {DataSource} from '@loopback/repository';

export class DataSourceTracker {
constructor(
// The target type is `Getter` function
@inject.filter({tags: ['datasource']})
private dataSources: Getter<DataSource[]>,
) {}

async listDataSources(): Promise<DataSource[]> {
// Use the Getter function to resolve data source instances
return await this.dataSources();
}
}
```

The `@inject.filter` decorator can take a `BindingFilter` function in addition
to `BindingScopeAndTags`. And it can be applied to properties too. For example:

```ts
export class DataSourceTracker {
// The target type is `ContextWatcher`
@inject.filter(binding => binding.tagMap['datasource'] != null)
private dataSources: ContextWatcher<DataSource>;

async listDataSources(): Promise<DataSource[]> {
// Use the Getter function to resolve data source instances
return await this.dataSources.values();
}

// ...
}
```

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
- ContextWatcher -> An instance of ContextWatcher
- other -> An array of values resolved from the current state of the context
12 changes: 11 additions & 1 deletion docs/site/Dependency-injection.md
Expand Up @@ -50,7 +50,7 @@ important when testing code interacting with external services like a database
or an OAuth2 provider. Instead of making expensive network requests, the test
can provide a lightweight implementation returning pre-defined responses.

## Configuring what to inject
## Configure what to inject

Now that we write a class that gets the dependencies injected, you are probably
wondering where are these values going to be injected from and how to configure
Expand Down Expand Up @@ -239,6 +239,16 @@ export class MyController {
}
```

## Additional `inject.*` decorators

There are a few special decorators from the `inject` namespace.

- @inject.getter
- @inject.setter
- @inject.context
- @inject.tag
- @inject.filter

## Circular dependencies

LoopBack can detect circular dependencies and report the path which leads to the
Expand Down
137 changes: 137 additions & 0 deletions packages/context/src/context-watcher.ts
@@ -0,0 +1,137 @@
// Copyright IBM Corp. 2018. All Rights Reserved.
// Node module: @loopback/context
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Context, BindingFilter, ContextEventType} from './context';
import {Binding} from './binding';
import {ResolutionSession} from './resolution-session';
import {resolveList, ValueOrPromise} from './value-promise';
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.
*
* This class is the key utility to implement dynamic extensions for extension
* points. For example, the RestServer can react to `controller` bindings even
* they are added/removed/updated after the application starts.
*
*/
export class ContextWatcher<T = unknown> implements ContextListener {
protected _cachedBindings: Readonly<Binding<T>>[] | undefined;
protected _cachedValues: ValueOrPromise<T[]> | undefined;

constructor(
protected readonly ctx: Context,
public readonly filter: BindingFilter,
) {}

/**
* Start watching events from the context
*/
watch() {
debug('Start watching context %s', this.ctx.name);
this.ctx.subscribe(this);
}

/**
* Stop watching events from the context
*/
unwatch() {
debug('Stop watching context %s', this.ctx.name);
this.ctx.unsubscribe(this);
}

/**
* Get the list of matched bindings. If they are not cached, it tries to find
* them from the context.
*/
get bindings(): Readonly<Binding<T>>[] {
debug('Reading bindings');
if (this._cachedBindings == null) {
this._cachedBindings = this.findBindings();
}
return this._cachedBindings;
}

/**
* Find matching bindings and refresh the cache
*/
protected findBindings() {
debug('Finding matching bindings');
this._cachedBindings = this.ctx.find(this.filter);
return this._cachedBindings;
}

/**
* Listen on `bind` or `unbind` and invalidate the cache
*/
listen(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
this.reset();
}

/**
* Reset the watcher by invalidating its cache
*/
reset() {
debug('Invalidating cache');
this._cachedBindings = undefined;
this._cachedValues = undefined;
}

/**
* Resolve values for the matching bindings
* @param session Resolution session
*/
resolve(session?: ResolutionSession) {
debug('Resolving values');
this._cachedValues = resolveList(this.bindings, b => {
return b.getValue(this.ctx, ResolutionSession.fork(session));
});
return this._cachedValues;
}

/**
* Get the list of resolved values. If they are not cached, it tries tp find
* and resolve them.
*/
values(): Promise<T[]> {
debug('Reading values');
// [REVIEW] We need to get values in the next tick so that it can pick up
// binding changes as `Context` publishes such events in `process.nextTick`
return new Promise<T[]>(resolve => {
process.nextTick(async () => {
if (this._cachedValues == null) {
this._cachedValues = this.resolve();
}
resolve(await this._cachedValues);
});
});
}

/**
* As a `Getter` function
*/
asGetter(): Getter<T[]> {
return () => this.values();
}
}

0 comments on commit 3d8fe30

Please sign in to comment.