What is Context?
- An abstraction of all state and dependencies in your application
- LoopBack uses context to manage everything
- A global registry for anything/everything in your app (all configs, state,dependencies, classes, etc)
- An inversion of controlcontainer used to inject dependencies into your code
Why is it important?
- You can use the context as a way to give loopback more “info” so that otherdependencies in your app may retrieve it. It works as a centralized place/global built-in/in-memory storage mechanism.
- LoopBack can help “manage” your resources automatically (throughDependency Injection and decorators).
- You have full access to updated/real-time application and request state at alltimes.
How to create a context?
A context can be created with an optional parent and an optional name. If thename is not provided, a UUID will be generated as the value. Context instancescan be chained using the parent
to form a hierarchy. For example, the codebelow creates a chain of three contexts: reqCtx -> serverCtx -> rootCtx
.
import {Context} from '@loopback/context';
const rootCtx = new Context('root-ctx'); // No parent
const serverCtx = new Context(rootCtx, 'server-ctx'); // rootCtx as the parent
const reqCtx = new Context(serverCtx); // No explicit name, a UUID will be generated
LoopBack’s context system allows an unlimited amount of Context instances, eachof which may have a parent Context.
An application typically has three “levels” of context: application-level,server-level, and request-level.
Application-level context (global)
- Stores all the initial and modified app states throughout the entire life ofthe app (while the process is alive)
- Generally configured when the application is created (though the context maybe modified while running)Here is a simple example:
import {Application} from '@loopback/core';
// Please note `Application` extends from `Context`
const app = new Application(); // `app` is a "Context"
class MyController {}
app.controller(MyController);
In this case, you are using the .controller
helper method to register a newcontroller. The important point to note is MyController
is actually registeredinto the Application Context (app
is a Context).
Server-level context
Server-level context:
- Is a child of application-level context
- Holds configuration specific to a particular server instanceYour application will typically contain one or more server instances, each ofwhich will have the application-level context as its parent. This means that anybindings that are defined on the application will also be available to theserver(s), unless you replace these bindings on the server instance(s) directly.
For example,@loopback/rest
has the RestServer
class, which sets up a running HTTP/S server on a port, aswell as defining routes on that server for a REST API. To set the port bindingfor the RestServer
, you would bind the RestBindings.PORT
key to a number.
We can selectively re-bind this value for certain server instances to changewhat port they use:
// src/application.ts
async start() {
// publicApi will use port 443, since it inherits this binding from the app.
app.bind(RestBindings.PORT).to(443);
const publicApi = await app.getServer<RestServer>('public');
const privateApi = await app.getServer<RestServer>('private');
// privateApi will be bound to 8080 instead.
privateApi.bind(RestBindings.PORT).to(8080);
await super.start();
}
Request-level context (request)
Using@loopback/rest
as an example, we can create custom sequences that:
- are dynamically created for each incoming server request
- extend the application level context to give you access to application-leveldependencies during the request/response lifecycle
- are garbage-collected once the response is sent for memory managementLet’s see this in action:
import {DefaultSequence, RestBindings, RequestContext} from '@loopback/rest';
class MySequence extends DefaultSequence {
async handle(context: RequestContext) {
// RequestContext provides request/response properties for convenience
// and performance, but they are still available in the context too
const req = await this.ctx.get(RestBindings.Http.REQUEST);
const res = await this.ctx.get(RestBindings.Http.RESPONSE);
this.send(res, `hello ${req.query.name}`);
}
}
this.ctx
is available to your sequence- allows you to craft your response using resources from the app in addition tothe resources available to the request in real-time (right when you need it)The context hierarchy is illustrated in the diagram below:
Storing and retrieving items from a Context
Items in the Context are indexed via a key and bound to a BoundValue
. ABindingKey
is simply a string value and is used to look up whatever you storealong with the key. For example:
// app level
const app = new Application();
app.bind('hello').to('world'); // BindingKey='hello', BoundValue='world'
console.log(app.getSync<string>('hello')); // => 'world'
In this case, we bind the ‘world’ string BoundValue to the ‘hello’ BindingKey.When we fetch the BoundValue via getSync
, we give it the BindingKey and itreturns the BoundValue that was initially bound (we can do other fancy thingstoo – ie. instantiate your classes, etc)
The process of registering a BoundValue into the Context is known as binding.Please find more details at Binding.
For a list of the available functions you can use for binding, visit theContext API Docs.
Dependency injection
- Many configs are adding to the Context during app instantiation/boot time byyou/developer.
- When things are registered, the Context provides a way to use yourdependencies during runtime.How you access these things is via low level helpers like
app.getSync
or thesequence
class that is provided to you as shown in the example in the previoussection.
However, when using classes, LoopBack provides a better way to get at stuff inthe context via the @inject
decorator:
import {inject} from '@loopback/context';
import {Application} from '@loopback/core';
const app = new Application();
app.bind('defaultName').to('John');
export class HelloController {
constructor(@inject('defaultName') private name: string) {}
greet(name?: string) {
return `Hello ${name || this.name}`;
}
}
Notice we just use the default name as though it were available to theconstructor. Context allows LoopBack to give you the necessary information atruntime even if you do not know the value when writing up the Controller. Theabove will print Hello John
at run time.
Note:
@inject
decorator is not able to leverage the value-type informationassociated with a binding key yet, therefore the TypeScript compiler will notcheck that the injection target (e.g. a constructor argument) was declaredwith a type that the bound value can be assigned to.
Please refer to Dependency injection for furtherdetails.
Context metadata and sugar decorators
Other interesting decorators can be used to help give LoopBack hints toadditional metadata you may want to provide in order to automatically set thingsup. For example, let’s take the previous example and make it available on theGET /greet
route using decorators provided by@loopback/rest
:
class HelloController {
// tell LoopBack you want this controller method
// to be available at the GET /greet route
@get('/greet')
greet(
// tell LoopBack you want to accept
// the name parameter as a string from
// the query string
@param.query.string('name') name: string,
) {
return `Hello ${name}`;
}
}
These “sugar” decorators allow you to quickly build up your application withouthaving to code up all the additional logic by simply giving LoopBack hints (inthe form of metadata) to your intent.
Context events
The Context
emits the following events:
bind
: Emitted when a new binding is added to the context.- binding: the newly added binding object
- context: Owner context of the binding object
unbind
: Emitted when an existing binding is removed from the context- binding: the newly removed binding object
- context: Owner context of the binding object
error
: Emitted when an observer throws an error during the notificationprocess- err: the error object thrownWhen an existing binding key is replaced with a new one, an
unbind
event isemitted for the existing binding followed by abind
event for the new binding.
- err: the error object thrownWhen an existing binding key is replaced with a new one, an
If a context has a parent, binding events from the parent are re-emitted on thecontext when the binding key does not exist within the current context.
Context observers
Bindings can be added or removed to a context object. With emitted contextevents, we can add listeners to a context object to be invoked when bindingscome and go. There are a few caveats associated with that:
- The binding object might not be fully configured when a
bind
event isemitted.
For example:
const ctx = new Context();
ctx
.bind('foo')
.to('foo-value')
.tag('foo-tag');
ctx.on('bind', binding => {
console.log(binding.tagNames); // returns an empty array `[]`
});
The context object emits a bind
event when ctx.bind
method is called. Itdoes not control the fluent apis .to('foo-value').tag('foo-tag')
, whichhappens on the newly created binding object. As a result, the bind
eventlistener receives a binding object which only has the binding key populated.
A workaround is to create the binding first before add it to a context:
const ctx = new Context();
const binding = Binding.create('foo')
.to('foo-value')
.tag('foo-tag');
ctx.add(binding);
ctx.on('bind', binding => {
console.log(binding.tagMap); // returns `['foo-tag']`
});
- It’s hard for event listeners to perform asynchronous operations.
To make it easy to support asynchronous event processing, we introduceContextObserver
and corresponding APIs on Context
:
ContextObserverFn
type andContextObserver
interface
/**
* Listen on `bind`, `unbind`, or other events
* @param eventType - Context event type
* @param binding - The binding as event source
* @param context - Context object for the binding event
*/
export type ContextObserverFn = (
eventType: ContextEventType,
binding: Readonly<Binding<unknown>>,
context: Context,
) => ValueOrPromise<void>;
/**
* Observers of context bind/unbind events
*/
export interface ContextObserver {
/**
* An optional filter function to match bindings. If not present, the listener
* will be notified of all binding events.
*/
filter?: BindingFilter;
/**
* Listen on `bind`, `unbind`, or other events
* @param eventType - Context event type
* @param binding - The binding as event source
*/
observe: ContextObserverFn;
}
/**
* Context event observer type - An instance of `ContextObserver` or a function
*/
export type ContextEventObserver = ContextObserver | ContextObserverFn;
If filter
is not required, we can simply use ContextObserverFn
.
- Context APIs
subscribe(observer: ContextEventObserver)
Add a context event observer to the context chain, including its ancestors
unsubscribe(observer: ContextEventObserver)
Remove the context event observer from the context chain
close()
Close the context and release references to other objects in the contextchain. Please note a child context registers event listeners with its parentcontext. As a result, the close
method must be called to avoid memory leakif the child context is to be recycled.
To react on context events asynchronously, we need to implement theContextObserver
interface or provide a ContextObserverFn
and register itwith the context.
For example:
const app = new Context('app');
server = new Context(app, 'server');
const observer: ContextObserver = {
// Only interested in bindings tagged with `foo`
filter: binding => binding.tagMap.foo != null,
observe(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
if (event === 'bind') {
console.log('bind: %s', binding.key);
// ... perform async operation
} else if (event === 'unbind') {
console.log('unbind: %s', binding.key);
// ... perform async operation
}
},
};
server.subscribe(observer);
server
.bind('foo-server')
.to('foo-value')
.tag('foo');
app
.bind('foo-app')
.to('foo-value')
.tag('foo');
// The following messages will be printed:
// bind: foo-server
// bind: foo-app
Please note when an observer subscribes to a context, it will be registered withall contexts on the chain. In the example above, the observer is added to bothserver
and app
contexts so that it can be notified when bindings are addedor removed from any of the context on the chain.
Observers are called in the next turn ofPromise micro-task queue
When there are multiple async observers registered, they are notified inseries for an event.
When multiple binding events are emitted in the same event loop tick and thereare async observers registered, such events are queued and observers arenotified by the order of events.
Observer error handling
It’s recommended that ContextEventObserver
implementations should not throwerrors in their code. Errors thrown by context event observers are reported asfollows over the context chain.
Check if the current context object has
error
listeners, if yes, emit anerror
event on the context and we’re done. if not, try its parent contextby repeating step 1.If no context object of the chain has
error
listeners, emit anerror
event on the current context. As a result, the process exits abnormally. Seehttps://nodejs.org/api/events.html#events_error_events for more details.
Context view
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 theRestServer
starts. When a controller is added after the application starts,new routes are bound into the application context. Ideally, the RestServer
should be able to pick up these new routes without restarting.
To support the dynamic tracking of such artifacts registered within a contextchain, we introduce ContextObserver
interface and ContextView
class that canbe used to watch a list of bindings matching certain criteria depicted by aBindingFilter
function and an optional BindingComparator
function to sortmatched bindings.
import {Context, ContextView} 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 view = serverCtx.createView(controllerFilter);
// No controllers yet
await view.values(); // returns []
// Bind Controller1 to server context
serverCtx
.bind('controllers.Controller1')
.toClass(Controller1)
.tag('controller');
// Resolve to an instance of Controller1
await view.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 view.values(); // returns [an instance of Controller1, an instance of Controller2];
// Unbind Controller2
appCtx.unbind('controllers.Controller2');
// No more instance of Controller2
await view.values(); // returns [an instance of Controller1];
The key benefit of ContextView
is that it caches resolved values until contextbindings matching the filter function are added/removed. For most cases, wedon’t have to pay the penalty to find/resolve per request.
To fully leverage the live list of extensions, an extension point such asRoutingTable
should either keep a pointer to an instance of ContextView
corresponding to all routes
(extensions) in the context chain and use thevalues()
function to match again the live routes
per request or implementitself as a ContextObserver
to rebuild the routes upon changes of routes inthe context with listen()
.
If your dependency needs to follow the context for values from bindings matchinga filter, use @inject.view
for dependencyinjection.
ContextView events
A ContextView
object can emit one of the following events:
- ‘refresh’: when the view is refreshed as bindings are added/removed
- ‘resolve’: when the cached values are resolved and updated
- ‘close’: when the view is closed (stopped observing context events)Such as events can be used to update other states/cached values other than thevalues watched by the
ContextView
object itself. For example:
class MyController {
private _total: number | undefined = undefined;
constructor(
@inject.view(filterByTag('counter'))
private taggedAsFoo: ContextView<Counter>,
) {
// Invalidate cached `_total` if the view is refreshed
taggedAsFoo.on('refresh', () => {
this._total = undefined;
});
}
async total() {
if (this._total != null) return this._total;
// Calculate the total of all counters
const counters = await this.taggedAsFoo.values();
let result = 0;
for (const c of counters) {
result += c.value;
}
this._total = result;
return this._total;
}
}
Configuration by convention
To allow bound items in the context to be configured, we introduce someconventions and corresponding APIs to make it simple and consistent.
We treat configurations for bound items in the context as dependencies, whichcan be resolved and injected in the same way of other forms of dependencies. Forexample, the RestServer
can be configured with RestServerConfig
.
Let’s first look at an example:
export class RestServer {
constructor(
@inject(CoreBindings.APPLICATION_INSTANCE) app: Application,
@inject(RestBindings.CONFIG, {optional: true})
config: RestServerConfig = {},
) {
// ...
}
// ...
}
The configuration (RestServerConfig
) itself is a binding(RestBindings.CONFIG
) in the context. It’s independent of the binding forRestServer
. The caveat is that we need to maintain a different binding key forthe configuration. Referencing a hard-coded key for the configuration also makesit impossible to have more than one instances of the RestServer
to beconfigured with different options, such as protocol
or port
.
To solve these problems, we introduce an accompanying binding for an item thatexpects configuration. For example:
servers.RestServer.server1
: RestServerservers.RestServer.server1:$config
: RestServerConfigservers.RestServer.server2
: RestServerservers.RestServer.server2:$config
: RestServerConfigThe following APIs are available to enforce/leverage this convention:ctx.configure('servers.RestServer.server1')
=> Binding for theconfigurationBinding.configure('servers.RestServer.server1')
=> Creates a accompanyingbinding for the configuration of the target bindingctx.getConfig('servers.RestServer.server1')
=> Get configuration@config
to inject corresponding configuration@config.getter
to inject a getter function for corresponding configuration@config.view
to inject aContextView
for corresponding configurationTheRestServer
can now use@config
to inject configuration for the currentbinding ofRestServer
.
export class RestServer {
constructor(
@inject(CoreBindings.APPLICATION_INSTANCE) app: Application,
@config()
config: RestServerConfig = {},
) {
// ...
}
// ...
}
The @config.*
decorators can take an optional propertyPath
parameter toallow the configuration value to be a deep property of the bound value. Forexample, @config('port')
injects RestServerConfig.port
to the target.
export class MyRestServer {
constructor(
@config('host')
host: string,
@config('port')
port: number,
) {
// ...
}
// ...
}
We also allow @config.*
to be resolved from another binding than the currentone:
export class MyRestServer {
constructor(
// Inject the `rest.host` from the application config
@config({fromBinding: 'application', propertyPath: 'rest.host'})
host: string,
// Inject the `rest.port` from the application config
@config({fromBinding: 'application', propertyPath: 'rest.port'})
port: number,
) {
// ...
}
// ...
}
Now we can use context.configure()
to provide configuration for targetbindings.
const appCtx = new Context();
appCtx.bind('servers.RestServer.server1').toClass(RestServer);
appCtx
.configure('servers.RestServer.server1')
.to({protocol: 'https', port: 473});
appCtx.bind('servers.RestServer.server2').toClass(RestServer);
appCtx.configure('servers.RestServer.server2').to({protocol: 'http', port: 80});
Please note that @config.
is different from @inject.
as @config.
injects configuration based on the current binding where @config.
is applied.No hard-coded binding key is needed. The @config.*
also allows the same classsuch as RestServer
to be bound to different keys with different configurationsas illustrated in the code snippet above.
All configuration accessors or injectors (such as ctx.getConfig
, @config
) bydefault treat the configuration binding as optional, i.e. return undefined
ifno configuration was bound. This is different from ctx.get
and @inject
APIs,which require the binding to exist and throw an error when the requested bindingis not found. The behavior can be customized via ResolutionOptions.optional
flag.
Allow configuration to be changed dynamically
Some configurations are designed to be changeable dynamically, for example, thelogging level for an application. To allow that, we introduce @config.getter
to always fetch the latest value of the configuration.
export class Logger {
@config.getter()
private getLevel: Getter<string>;
async log(level: string, message: string) {
const currentLevel = await getLevel();
if (shouldLog(level, currentLevel)) {
// ...
}
}
}