diff --git a/benchmark/package-lock.json b/benchmark/package-lock.json index 89c63b974b0c..c9a69868720e 100644 --- a/benchmark/package-lock.json +++ b/benchmark/package-lock.json @@ -13,6 +13,12 @@ "@types/node": "*" } }, + "@types/benchmark": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/@types/benchmark/-/benchmark-1.0.31.tgz", + "integrity": "sha512-F6fVNOkGEkSdo/19yWYOwVKGvzbTeWkR/XQYBKtGBQ9oGRjBN9f/L4aJI4sDcVPJO58Y1CJZN8va9V2BhrZapA==", + "dev": true + }, "@types/byline": { "version": "4.2.31", "resolved": "https://registry.npmjs.org/@types/byline/-/byline-4.2.31.tgz", @@ -175,6 +181,15 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" }, + "benchmark": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/benchmark/-/benchmark-2.1.4.tgz", + "integrity": "sha1-CfPeMckWQl1JjMLuVloOvzwqVik=", + "requires": { + "lodash": "^4.17.4", + "platform": "^1.3.3" + } + }, "binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", @@ -721,8 +736,7 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "log-symbols": { "version": "3.0.0", @@ -1016,6 +1030,11 @@ "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", "dev": true }, + "platform": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.5.tgz", + "integrity": "sha512-TuvHS8AOIZNAlE77WUDiR4rySV/VMptyMfcfeoMgs4P8apaZM3JrnbzBiixKUv+XR6i+BXrQh8WAnjaSPFO65Q==" + }, "pretty-bytes": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.3.0.tgz", diff --git a/benchmark/package.json b/benchmark/package.json index 144d7a446370..d85a416e2090 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -19,6 +19,7 @@ "test": "lb-mocha \"dist/__tests__/**/*.js\"", "prestart": "npm run build", "benchmark:routing": "node ./dist/rest-routing/routing-table", + "benchmark:context": "node ./dist/context-binding/context-binding", "start": "node ." }, "author": "IBM Corp.", @@ -32,6 +33,7 @@ "!*/__tests__" ], "dependencies": { + "@loopback/context": "^3.7.0", "@loopback/example-todo": "^3.3.0", "@loopback/openapi-spec-builder": "^2.1.3", "@loopback/rest": "^4.0.0", @@ -40,6 +42,7 @@ "@types/request-promise-native": "^1.0.17", "autocannon": "^4.6.0", "axios": "^0.19.2", + "benchmark": "^2.1.4", "byline": "^5.0.0", "debug": "^4.1.1", "path-to-regexp": "^6.1.0", @@ -49,6 +52,7 @@ "@loopback/build": "^5.3.1", "@loopback/testlab": "^3.1.3", "@types/autocannon": "^4.1.0", + "@types/benchmark": "^1.0.31", "@types/mocha": "^7.0.2", "@types/node": "^10.17.21", "mocha": "^7.1.2", diff --git a/benchmark/src/context-binding/README.md b/benchmark/src/context-binding/README.md new file mode 100644 index 000000000000..816e12c73394 --- /dev/null +++ b/benchmark/src/context-binding/README.md @@ -0,0 +1,30 @@ +# Context binding benchmark + +This directory contains a simple benchmarking to measure the performance of +different styles of context bindings. + +## Basic use + +```sh +npm run -s benchmark:context +``` + +For example: + +``` +npm run -s benchmark:context +``` + +## Base lines + +| Test | Ops/sec | Relative margin of error | Runs sampled | Count | +| ------------------------- | --------- | ------------------------ | ------------ | ----- | +| factory - getSync | 1,282,238 | ±1.11% | 93 | 68537 | +| factory - get | 1,222,587 | ±1.19% | 87 | 66558 | +| asyncFactory - get | 362,457 | ±1.78% | 78 | 23284 | +| staticProvider - getSync | 484,494 | ±1.04% | 92 | 26260 | +| staticProvider - get | 475,130 | ±1.13% | 86 | 27435 | +| asyncStaticProvider - get | 339,359 | ±1.43% | 86 | 19046 | +| provider - getSync | 368,127 | ±1.14% | 87 | 21162 | +| provider - get | 366,649 | ±0.86% | 86 | 21358 | +| asyncProvider - get | 291,054 | ±1.94% | 82 | 16557 | diff --git a/benchmark/src/context-binding/context-binding.ts b/benchmark/src/context-binding/context-binding.ts new file mode 100644 index 000000000000..ef610cbe1bff --- /dev/null +++ b/benchmark/src/context-binding/context-binding.ts @@ -0,0 +1,136 @@ +// Copyright IBM Corp. 2018,2020. All Rights Reserved. +// Node module: @loopback/benchmark +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context, inject, Provider, ValueFactory} from '@loopback/context'; +import Benchmark from 'benchmark'; + +/** + * Option 1 - use a sync factory function + */ +const factory: ValueFactory = ({context}) => { + const user = context.getSync('user'); + return `Hello, ${user}`; +}; + +/** + * Option 2 - use an async factory function + */ +const asyncFactory: ValueFactory = async ({context}) => { + const user = await context.get('user'); + return `Hello, ${user}`; +}; + +/** + * Option 3 - use a value factory provider class with sync static value() method + * parameter injection + */ +class StaticGreetingProvider { + static value(@inject('user') user: string) { + return `Hello, ${user}`; + } +} + +/** + * Option 4 - use a value factory provider class with async static value() method + * parameter injection + */ +class AsyncStaticGreetingProvider { + static value(@inject('user') user: string) { + return Promise.resolve(`Hello, ${user}`); + } +} + +/** + * Option 5 - use a regular provider class with sync value() + */ +class GreetingProvider implements Provider { + @inject('user') + private user: string; + + value() { + return `Hello, ${this.user}`; + } +} + +/** + * Option 6 - use a regular provider class with async value() + */ +class AsyncGreetingProvider implements Provider { + @inject('user') + private user: string; + + value() { + return Promise.resolve(`Hello, ${this.user}`); + } +} + +setupContextBindings(); + +function setupContextBindings() { + const ctx = new Context(); + ctx.bind('user').to('John'); + ctx.bind('greeting.syncFactory').toDynamicValue(factory); + ctx.bind('greeting.asyncFactory').toDynamicValue(asyncFactory); + ctx + .bind('greeting.syncStaticProvider') + .toDynamicValue(StaticGreetingProvider); + ctx + .bind('greeting.asyncStaticProvider') + .toDynamicValue(AsyncStaticGreetingProvider); + ctx.bind('greeting.syncProvider').toProvider(GreetingProvider); + ctx.bind('greeting.asyncProvider').toProvider(AsyncGreetingProvider); + return ctx; +} + +function runBenchmark(ctx: Context) { + const options: Benchmark.Options = { + initCount: 1000, + onComplete: (e: Benchmark.Event) => { + const benchmark = e.target as Benchmark; + console.log('%s %d', benchmark, benchmark.count); + }, + }; + const suite = new Benchmark.Suite('context-bindings'); + suite + .add( + 'factory - getSync', + () => ctx.getSync('greeting.syncFactory'), + options, + ) + .add('factory - get', () => ctx.get('greeting.syncFactory'), options) + .add('asyncFactory - get', () => ctx.get('greeting.asyncFactory'), options) + .add( + 'staticProvider - getSync', + () => ctx.getSync('greeting.syncStaticProvider'), + options, + ) + .add( + 'staticProvider - get', + () => ctx.get('greeting.syncStaticProvider'), + options, + ) + .add( + 'asyncStaticProvider - get', + () => ctx.get('greeting.asyncStaticProvider'), + options, + ) + .add( + 'provider - getSync', + () => ctx.getSync('greeting.syncProvider'), + options, + ) + .add('provider - get', () => ctx.get('greeting.syncProvider'), options) + .add( + 'asyncProvider - get', + () => ctx.get('greeting.asyncProvider'), + options, + ) + .run({async: true}); +} + +if (require.main === module) { + const ctx = setupContextBindings(); + runBenchmark(ctx); +} diff --git a/benchmark/tsconfig.json b/benchmark/tsconfig.json index 076cb14c53e6..f65422c27403 100644 --- a/benchmark/tsconfig.json +++ b/benchmark/tsconfig.json @@ -13,6 +13,9 @@ { "path": "../examples/todo/tsconfig.json" }, + { + "path": "../packages/context/tsconfig.json" + }, { "path": "../packages/openapi-spec-builder/tsconfig.json" }, diff --git a/docs/site/Binding.md b/docs/site/Binding.md index cbf365e1e0d7..e628d7e57b4a 100644 --- a/docs/site/Binding.md +++ b/docs/site/Binding.md @@ -83,6 +83,47 @@ binding.toDynamicValue(() => new Date()); binding.toDynamicValue(() => Promise.resolve('my-value')); ``` +The factory function can receive extra information about the context, binding, +and resolution options. + +```ts +import {ValueFactory} from '@loopback/context'; + +// The factory function now have access extra metadata about the resolution +const factory: ValueFactory = resolutionCtx => { + return `Hello, ${resolutionCtx.context.name}#${ + resolutionCtx.binding.key + } ${resolutionCtx.options.session?.getBindingPath()}`; +}; +const b = ctx.bind('msg').toDynamicValue(factory); +``` + +Object destructuring can be used to further simplify a value factory function +that needs to access `context`, `binding`, or `options`. + +```ts +const factory: ValueFactory = ({context, binding, options}) => { + return `Hello, ${context.name}#${ + binding.key + } ${options.session?.getBindingPath()}`; +}; +``` + +An advanced form of value factory is a class that has a static `value` method +that allows parameter injection. + +```ts +import {inject} from '@loopback/context'; + +class GreetingProvider { + static value(@inject('user') user: string) { + return `Hello, ${user}`; + } +} + +const b = ctx.bind('msg').toDynamicValue(GreetingProvider); +``` + #### A class The binding can represent an instance of a class, for example, a controller. A @@ -119,6 +160,9 @@ class MyValueProvider implements Provider { binding.toProvider(MyValueProvider); ``` +The provider class serves as the wrapper to declare dependency injections. If +dependency is not needed, `toDynamicValue` can be used instead. + #### An alias An alias is the key with optional path to resolve the value from another diff --git a/packages/context/src/__tests__/unit/binding.unit.ts b/packages/context/src/__tests__/unit/binding.unit.ts index deb390591ace..38da414103fb 100644 --- a/packages/context/src/__tests__/unit/binding.unit.ts +++ b/packages/context/src/__tests__/unit/binding.unit.ts @@ -15,6 +15,7 @@ import { inject, Provider, } from '../..'; +import {ValueFactory} from '../../binding'; const key = 'foo'; @@ -172,6 +173,56 @@ describe('Binding', () => { expect(b.type).to.equal(BindingType.DYNAMIC_VALUE); }); + it('support a factory to access context/binding/session', async () => { + const factory: ValueFactory = ({ + context, + binding: _binding, + options, + }) => { + return `Hello, ${context.name}#${ + _binding.key + } ${options.session?.getBindingPath()}`; + }; + const b = ctx.bind('msg').toDynamicValue(factory); + const value = await ctx.get('msg'); + expect(value).to.equal('Hello, test#msg msg'); + expect(b.type).to.equal(BindingType.DYNAMIC_VALUE); + }); + + it('supports a factory to use context to look up a binding', async () => { + ctx.bind('user').to('John'); + ctx.bind('greeting').toDynamicValue(async ({context}) => { + const user = await context.get('user'); + return `Hello, ${user}`; + }); + const value = await ctx.get('greeting'); + expect(value).to.eql('Hello, John'); + }); + + it('supports a factory to use static provider', () => { + class GreetingProvider { + static value(@inject('user') user: string) { + return `Hello, ${user}`; + } + } + ctx.bind('user').to('John'); + ctx.bind('greeting').toDynamicValue(GreetingProvider); + const value = ctx.getSync('greeting'); + expect(value).to.eql('Hello, John'); + }); + + it('supports a factory to use async static provider', async () => { + class GreetingProvider { + static async value(@inject('user') user: string) { + return `Hello, ${user}`; + } + } + ctx.bind('user').to('John'); + ctx.bind('greeting').toDynamicValue(GreetingProvider); + const value = await ctx.get('greeting'); + expect(value).to.eql('Hello, John'); + }); + it('triggers changed event', () => { const events = listenOnBinding(); binding.toDynamicValue(() => Promise.resolve('hello')); @@ -582,7 +633,7 @@ describe('Binding', () => { }); function givenBinding() { - ctx = new Context(); + ctx = new Context('test'); binding = new Binding(key); } diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 5342e40e25a5..09a0bad2355c 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -9,11 +9,13 @@ import {BindingAddress, BindingKey} from './binding-key'; import {Context} from './context'; import {inspectInjections} from './inject'; import {createProxyWithInterceptors} from './interception-proxy'; +import {invokeMethod} from './invocation'; import {JSONObject} from './json-types'; import {ContextTags} from './keys'; import {Provider} from './provider'; import { asResolutionOptions, + ResolutionContext, ResolutionOptions, ResolutionOptionsOrSession, ResolutionSession, @@ -170,11 +172,62 @@ export type BindingEventListener = ( event: BindingEvent, ) => void; -type ValueGetter = ( - ctx: Context, - options: ResolutionOptions, +/** + * A factory function for `toDynamicValue` + */ +export type ValueFactory = ( + resolutionCtx: ResolutionContext, ) => ValueOrPromise; +/** + * A class with a static `value` method as the factory function for + * `toDynamicValue`. + * + * @example + * ```ts + * import {inject} from '@loopback/context'; + * + * export class DynamicGreetingProvider { + * static value(@inject('currentUser') user: string) { + * return `Hello, ${user}`; + * } + * } + * ``` + */ +export interface DynamicValueProviderClass + extends Constructor, + Function { + value: (...args: BoundValue[]) => ValueOrPromise; +} + +/** + * Adapt the ValueFactoryProvider class to be a value factory + * @param provider - ValueFactoryProvider class + */ +function toValueFactory( + provider: DynamicValueProviderClass, +): ValueFactory { + return resolutionCtx => + invokeMethod(provider, 'value', resolutionCtx.context, [], { + skipInterceptors: true, + }); +} + +/** + * Check if the factory is a value factory provider class + * @param factory - A factory function or a dynamic value provider class + */ +function isDynamicValueProviderClass( + factory: unknown, +): factory is DynamicValueProviderClass { + // Not a class + if (typeof factory !== 'function' || !String(factory).startsWith('class ')) { + return false; + } + const valueMethod = (factory as DynamicValueProviderClass).value; + return typeof valueMethod === 'function'; +} + /** * Binding represents an entry in the `Context`. Each binding has a key and a * corresponding value getter. @@ -208,7 +261,7 @@ export class Binding extends EventEmitter { } private _cache: WeakMap; - private _getValue?: ValueGetter; + private _getValue?: ValueFactory; private _valueConstructor?: Constructor; private _providerConstructor?: Constructor>; @@ -357,7 +410,13 @@ export class Binding extends EventEmitter { const result = ResolutionSession.runWithBinding( s => { const optionsWithSession = Object.assign({}, options, {session: s}); - return this._getValue!(ctx, optionsWithSession); + // We already test `this._getValue` is a function. It's safe to assert + // that `this._getValue` is not undefined. + return this._getValue!({ + context: ctx, + binding: this, + options: optionsWithSession, + }); }, this, options.session, @@ -464,16 +523,19 @@ export class Binding extends EventEmitter { * Set the `_getValue` function * @param getValue - getValue function */ - private _setValueGetter(getValue: ValueGetter) { + private _setValueGetter(getValue: ValueFactory) { // Clear the cache this._clearCache(); - this._getValue = (ctx: Context, options: ResolutionOptions) => { - if (options.asProxyWithInterceptors && this._type !== BindingType.CLASS) { + this._getValue = resolutionCtx => { + if ( + resolutionCtx.options.asProxyWithInterceptors && + this._type !== BindingType.CLASS + ) { throw new Error( `Binding '${this.key}' (${this._type}) does not support 'asProxyWithInterceptors'`, ); } - return getValue(ctx, options); + return getValue(resolutionCtx); }; this.emitChangedEvent('value'); } @@ -539,13 +601,21 @@ export class Binding extends EventEmitter { * ); * ``` */ - toDynamicValue(factoryFn: () => ValueOrPromise): this { + toDynamicValue( + factory: ValueFactory | DynamicValueProviderClass, + ): this { /* istanbul ignore if */ if (debug.enabled) { - debug('Bind %s to dynamic value:', this.key, factoryFn); + debug('Bind %s to dynamic value:', this.key, factory); + } + let factoryFn: ValueFactory; + if (isDynamicValueProviderClass(factory)) { + factoryFn = toValueFactory(factory); + } else { + factoryFn = factory; } this._type = BindingType.DYNAMIC_VALUE; - this._setValueGetter(ctx => factoryFn()); + this._setValueGetter(resolutionCtx => factoryFn(resolutionCtx)); return this; } @@ -572,10 +642,10 @@ export class Binding extends EventEmitter { } this._type = BindingType.PROVIDER; this._providerConstructor = providerClass; - this._setValueGetter((ctx, options) => { + this._setValueGetter(({context, options}) => { const providerOrPromise = instantiateClass>( providerClass, - ctx, + context, options.session, ); return transformValueOrPromise(providerOrPromise, p => p.value()); @@ -596,12 +666,12 @@ export class Binding extends EventEmitter { debug('Bind %s to class %s', this.key, ctor.name); } this._type = BindingType.CLASS; - this._setValueGetter((ctx, options) => { - const instOrPromise = instantiateClass(ctor, ctx, options.session); + this._setValueGetter(({context, options}) => { + const instOrPromise = instantiateClass(ctor, context, options.session); if (!options.asProxyWithInterceptors) return instOrPromise; return createInterceptionProxyFromInstance( instOrPromise, - ctx, + context, options.session, ); }); @@ -621,8 +691,8 @@ export class Binding extends EventEmitter { } this._type = BindingType.ALIAS; this._alias = keyWithPath; - this._setValueGetter((ctx, options) => { - return ctx.getValueOrPromise(keyWithPath, options); + this._setValueGetter(({context, options}) => { + return context.getValueOrPromise(keyWithPath, options); }); return this; } diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 0a79cb2d9a71..4c61314f8733 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -40,8 +40,17 @@ const METHODS_KEY = MetadataAccessor.create( 'inject:methods', ); +// TODO(rfeng): We may want to align it with `ValueFactory` interface that takes +// an argument of `ResolutionContext`. /** - * A function to provide resolution of injected values + * A function to provide resolution of injected values. + * + * @example + * ```ts + * const resolver: ResolverFunction = (ctx, injection, session) { + * return session.currentBinding?.key; + * } + * ``` */ export interface ResolverFunction { ( @@ -377,7 +386,7 @@ export namespace inject { * ``` */ export const context = function injectContext() { - return inject('', {decorator: '@inject.context'}, ctx => ctx); + return inject('', {decorator: '@inject.context'}, (ctx: Context) => ctx); }; } diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts index fd38c1ca75dc..ab78b36416d2 100644 --- a/packages/context/src/resolution-session.ts +++ b/packages/context/src/resolution-session.ts @@ -6,6 +6,7 @@ import {DecoratorFactory} from '@loopback/metadata'; import debugModule from 'debug'; import {Binding} from './binding'; +import {Context} from './context'; import {Injection} from './inject'; import {BoundValue, tryWithFinally, ValueOrPromise} from './value-promise'; @@ -366,3 +367,21 @@ export function asResolutionOptions( } return optionsOrSession ?? {}; } + +/** + * Contextual metadata for resolution + */ +export interface ResolutionContext { + /** + * The context for resolution + */ + readonly context: Context; + /** + * The binding to be resolved + */ + readonly binding: Readonly>; + /** + * The options used for resolution + */ + readonly options: ResolutionOptions; +}