Skip to content

Commit

Permalink
Merge bfeb532 into 377fa08
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Dec 7, 2018
2 parents 377fa08 + bfeb532 commit 3b0eea9
Show file tree
Hide file tree
Showing 9 changed files with 841 additions and 71 deletions.
98 changes: 98 additions & 0 deletions docs/site/Context.md
Expand Up @@ -229,3 +229,101 @@ 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. Ideally, a controller can be added context even after the application
starts.

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.

```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(); => []

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

// Resolve to an instance of Controller1
await watcher.values(); // => [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(); // => [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];
```

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();
}

// ...
}
```

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
121 changes: 121 additions & 0 deletions packages/context/src/context-watcher.ts
@@ -0,0 +1,121 @@
// 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');

/**
* 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> {
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 3b0eea9

Please sign in to comment.