You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/di/Container.ts

672 lines
23 KiB

import {
DependencyKey,
InstanceRef,
Instantiable,
isInstantiable,
StaticClass,
StaticInstantiable,
TypedDependencyKey,
} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
import {
Awaitable,
collect,
Collection,
globalRegistry,
hasOwnProperty,
logIfDebugging, Unsubscribe,
} from '../util'
import {ErrorWithContext, withErrorContext} from '../util/error/ErrorWithContext'
import {Factory} from './factory/Factory'
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
import {ClosureFactory} from './factory/ClosureFactory'
import NamedFactory from './factory/NamedFactory'
import SingletonFactory from './factory/SingletonFactory'
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint'
export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
/**
* Singletons that implement this interface receive callbacks for
* structural container events.
*/
export interface AwareOfContainerLifecycle {
awareOfContainerLifecycle: true
/** Called when this key is realized by its parent container. */
onContainerRealize?(): Awaitable<unknown>
/** Called before the parent container of this instance is destroyed. */
onContainerDestroy?(): Awaitable<unknown>
/** Called before an instance of a key is released from the container. */
onContainerRelease?(): Awaitable<unknown>
}
export function isAwareOfContainerLifecycle(what: unknown): what is AwareOfContainerLifecycle {
return Boolean(
(typeof what === 'object' || typeof what === 'function')
&& what !== null
&& hasOwnProperty(what, 'awareOfContainerLifecycle')
&& what.awareOfContainerLifecycle,
)
}
/**
* A container of resolve-able dependencies that are created via inversion-of-control.
*/
export class Container {
/**
* Set to true when we're realizing a container.
* Used to prevent infinite recursion when `getContainer()` is accidentally called
* from somewhere within the `realizeContainer()` call.
*/
protected static realizingContainer = false
/**
* List of dependency keys currently being `make`'d as a reverse stack.
* This is used to detect dependency cycles.
* @protected
*/
protected static makeStack?: Collection<DependencyKey>
/**
* The 100 most recent dependency keys that were `make`'d. Used to help with
* debugging cyclic dependency errors.
* @protected
*/
protected static makeHistory?: Collection<DependencyKey>
/**
* Given a Container instance, apply the ContainerBlueprint to it.
* @param container
*/
public static realizeContainer<T extends Container>(container: T): T {
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
ContainerBlueprint.getContainerBlueprint()
.resolveConstructable()
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
ContainerBlueprint.getContainerBlueprint()
.resolveResolutionCallbacks()
.map((listener: {key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}) => {
container.onResolve(listener.key)
.then(value => listener.callback(value))
})
container.subscribeToBlueprintChanges(ContainerBlueprint.getContainerBlueprint())
return container
}
/**
* Get the global instance of this container.
*/
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
if ( this.realizingContainer ) {
throw new ErrorWithContext('Attempted getContainer call during container realization.')
}
this.realizingContainer = true
const container = Container.realizeContainer(new Container())
globalRegistry.setGlobal('extollo/injector', container)
this.realizingContainer = false
return container
}
return existing
}
/**
* Collection of factories registered with this container.
* @type Collection<AbstractFactory>
*/
protected factories: Collection<AbstractFactory<unknown>> = new Collection<AbstractFactory<unknown>>()
/**
* Collection of singleton instances produced by this container.
* @type Collection<InstanceRef>
*/
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
/**
* Collection of static-class overrides registered with this container.
* @protected
*/
protected staticOverrides: Collection<{ base: StaticInstantiable<any>, override: StaticInstantiable<any> }> = new Collection<{base: StaticInstantiable<any>; override: StaticInstantiable<any>}>()
/**
* Collection of callbacks waiting for a dependency key to be resolved.
* @protected
*/
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>()
/**
* Collection of created objects that should have lifecycle events called on them, if they still exist.
* @protected
*/
protected waitingLifecycleCallbacks: Collection<WeakRef<AwareOfContainerLifecycle>> = new Collection()
/**
* Collection of subscriptions to ContainerBlueprint events.
* We keep this around so we can remove the subscriptions when the container is destroyed.
* @protected
*/
protected blueprintSubscribers: Collection<Unsubscribe> = new Collection()
constructor() {
this.registerSingletonInstance<Container>(Container, this)
this.registerSingleton('injector', this)
}
/** Make the container listen to changes in the given blueprint. */
private subscribeToBlueprintChanges(blueprint: ContainerBlueprint): void {
this.blueprintSubscribers.push(
blueprint.resolve$(factory => this.registerFactory(factory())),
)
this.blueprintSubscribers.push(
blueprint.resolveConstructable$(factoryClass => this.registerFactory(this.make(factoryClass))),
)
this.blueprintSubscribers.push(
blueprint.resolveResolutionCallbacks$(listener => this.onResolve(listener.key).then(value => listener.callback(value))),
)
}
/**
* Purge all factories and instances of the given key from this container.
* @param key
*/
purge(key: DependencyKey): this {
this.factories = this.factories.filter(x => !x.match(key))
this.release(key)
return this
}
/**
* Remove all stored instances of the given key from this container.
* @param key
*/
release(key: DependencyKey): this {
this.instances = this.instances.filter(x => {
if ( x.key === key && isAwareOfContainerLifecycle(x.value) ) {
x.value.onContainerRelease?.()
}
return x.key !== key
})
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
*/
register(dependency: Instantiable<any>): this {
if ( this.resolve(dependency) ) {
throw new DuplicateFactoryKeyError(dependency)
}
const factory = new Factory(dependency)
this.factories.push(factory)
return this
}
/**
* Register a static class as an override of some base class.
* @param base
* @param override
*/
registerStaticOverride<T>(base: StaticInstantiable<T>, override: StaticInstantiable<T>): this {
if ( this.hasStaticOverride(base) ) {
throw new DuplicateFactoryKeyError(base)
}
this.staticOverrides.push({
base,
override,
})
return this
}
/** Returns true if a static override exists for the given base class. */
hasStaticOverride<T>(base: StaticInstantiable<T>): boolean {
return this.staticOverrides.where('base', '=', base).isNotEmpty()
}
/**
* Get the static class overriding the base class.
* @param base
*/
getStaticOverride<T>(base: StaticInstantiable<T>): StaticInstantiable<T> {
const override = this.staticOverrides.firstWhere('base', '=', base)
if ( override ) {
return override.override
}
return base
}
/**
* Get the registered instance of the static override of a given class.
* @param base
* @param parameters
*/
makeByStaticOverride<T>(base: StaticInstantiable<T>, ...parameters: any[]): T {
const key = this.getStaticOverride(base)
return this.make(key, ...parameters)
}
/**
* Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container
* @param {function} producer - factory to produce a value
*/
registerProducer(name: DependencyKey, producer: () => any): this {
if ( this.resolve(name) ) {
throw new DuplicateFactoryKeyError(name)
}
const factory = new ClosureFactory(name, producer)
this.factories.push(factory)
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>): this {
if ( this.resolve(name) ) {
throw new DuplicateFactoryKeyError(name)
}
const factory = new NamedFactory(name, dependency)
this.factories.push(factory)
return this
}
/**
* Register a value as a singleton in the container. It will not be instantiated, but
* can be injected by its unique name.
* @param {string} key - unique name to identify the singleton in the container
* @param value
*/
registerSingleton<T>(key: DependencyKey, value: T): this {
if ( this.resolve(key) ) {
throw new DuplicateFactoryKeyError(key)
}
this.factories.push(new SingletonFactory(key, value))
return this
}
/**
* Register a static class to the container along with its already-instantiated
* instance that will be used to resolve the class.
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
if ( this.resolve(staticClass) ) {
throw new DuplicateFactoryKeyError(staticClass)
}
this.register(staticClass)
this.instances.push({
key: staticClass,
value: instance,
})
return this
}
/**
* Register a given factory with the container.
* @param {AbstractFactory} factory
*/
registerFactory(factory: AbstractFactory<unknown>): this {
if ( !this.factories.includes(factory) ) {
this.factories.push(factory)
}
return this
}
/**
* Returns true if the container has an already-produced value for the given key.
* @param {DependencyKey} key
*/
hasInstance(key: DependencyKey): boolean {
return this.instances.where('key', '=', key).isNotEmpty()
}
/**
* Get a Promise that resolves the first time the given dependency key is resolved
* by the application. If it has already been resolved, the Promise will resolve immediately.
* @param key
*/
onResolve<T>(key: TypedDependencyKey<T>): Promise<T> {
if ( this.hasInstance(key) ) {
return new Promise<T>(res => res(this.make<T>(key)))
}
// Otherwise, we haven't instantiated an instance with this key yet,
// so put it onto the waitlist.
return new Promise<T>(res => {
this.waitingResolveCallbacks.push({
key,
callback: (res as (t: unknown) => unknown),
})
})
}
/**
* Returns true if the container has a factory for the given key.
* @param {DependencyKey} key
*/
hasKey(key: DependencyKey): boolean {
return Boolean(this.resolve(key))
}
/**
* Get the already-produced value for the given key, if one exists.
* @param {DependencyKey} key
*/
getExistingInstance(key: DependencyKey): MaybeDependency {
const instances = this.instances.where('key', '=', key)
if ( instances.isNotEmpty() ) {
return instances.first()
}
}
/**
* Find the factory for the given key, if one is registered with this container.
* @param {DependencyKey} key
*/
resolve(key: DependencyKey): MaybeFactory<unknown> {
const factory = this.factories.firstWhere(item => item.match(key))
if ( factory ) {
return factory
} else {
logIfDebugging('extollo.di.injector', 'unable to resolve factory', key, factory, this.factories)
}
}
/**
* Resolve the dependency key. If a singleton value for that key already exists in this container,
* return that value. Otherwise, use the factory and given parameters to produce and return the value.
* @param {DependencyKey} key
* @param {...any} parameters
*/
resolveAndCreate(key: DependencyKey, ...parameters: any[]): any {
logIfDebugging('extollo.di.injector', 'resolveAndCreate', key, {parameters})
// If we've already instantiated this, just return that
const instance = this.getExistingInstance(key)
logIfDebugging('extollo.di.injector', 'resolveAndCreate existing instance?', instance)
if ( typeof instance !== 'undefined' ) {
return instance.value
}
// Otherwise, attempt to create it
const factory = this.resolve(key)
logIfDebugging('extollo.di.injector', 'resolveAndCreate factory', factory)
if ( !factory ) {
throw new InvalidDependencyKeyError(key)
}
// Produce and store a new instance
const newInstance = this.produceFactory(factory, parameters)
this.instances.push({
key,
value: newInstance,
})
if ( isAwareOfContainerLifecycle(newInstance) ) {
newInstance.onContainerRealize?.()
}
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
if ( waiter.key === key ) {
waiter.callback(newInstance)
return false
}
return true
})
return newInstance
}
/**
* Given a factory and manually-provided parameters, resolve the dependencies for the
* factory and produce its value.
* @param {AbstractFactory} factory
* @param {array} parameters
*/
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
logIfDebugging('extollo.di.injector', 'Make stack', Container.makeStack)
// Create the dependencies for the factory
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
const dependencies = keys.map<ResolvedDependency>(req => {
return withErrorContext(() => {
return {
paramIndex: req.paramIndex,
key: req.key,
resolved: this.resolveAndCreate(req.key),
}
}, {
producingToken: factory.getTokenName(),
constructorDependency: req,
})
}).sortBy('paramIndex')
// Build the arguments for the factory, using dependencies in the
// correct paramIndex positions, or parameters of we don't have
// the dependency.
const constructorArguments = []
const params = collect(parameters).reverse()
for ( let i = 0; i <= dependencies.max('paramIndex'); i++ ) {
const dep = dependencies.firstWhere('paramIndex', '=', i)
if ( dep ) {
constructorArguments.push(dep.resolved)
} else {
constructorArguments.push(params.pop())
}
}
// Produce a new instance
const inst = factory.produce(constructorArguments, params.reverse().all())
logIfDebugging('extollo.di.injector', 'Resolving dependencies for factory', factory)
factory.getInjectedProperties().each(dependency => {
logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency)
if ( dependency.key && inst ) {
withErrorContext(() => {
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
}, {
producingToken: factory.getTokenName(),
propertyDependency: dependency,
})
}
})
if ( isAwareOfContainerLifecycle(inst) ) {
this.waitingLifecycleCallbacks.push(new WeakRef<AwareOfContainerLifecycle>(inst))
}
return inst
}
/**
* Create an instance of the given target. The target can either be a DependencyKey registered with
* this container (in which case, the singleton value will be returned), or an instantiable class.
*
* If the instantiable class has the Injectable decorator, its injectable parameters will be automatically
* injected into the instance.
* @param {DependencyKey} target
* @param {...any} parameters
*/
make<T>(target: DependencyKey, ...parameters: any[]): T {
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
Container.makeStack.push(target)
if ( Container.makeHistory.length > 100 ) {
Container.makeHistory = Container.makeHistory.slice(1, 100)
}
Container.makeHistory.push(target)
this.checkForMakeCycles()
try {
const result = withErrorContext(() => {
if (this.hasKey(target)) {
const realized = this.resolveAndCreate(target, ...parameters)
Container.makeStack?.pop()
return realized
} else if (typeof target !== 'string' && isInstantiable(target)) {
const realized = this.produceFactory(new Factory(target), parameters)
Container.makeStack?.pop()
return realized
}
}, {
makeStack: Container.makeStack.map(x => typeof x === 'string' ? x : (x?.name || 'unknown')).toArray(),
})
if ( result ) {
return result
}
} catch (e: unknown) {
Container.makeStack.pop()
throw e
}
Container.makeStack.pop()
throw new TypeError(`Invalid or unknown make target: ${target}`)
}
/**
* Check the `makeStack` for duplicates and throw an error if a dependency cycle is
* detected. This is used to prevent infinite mutual recursion when cyclic dependencies
* occur.
* @protected
*/
protected checkForMakeCycles(): void {
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
if ( Container.makeStack.unique().length !== Container.makeStack.length ) {
const displayKey = (key: DependencyKey) => {
if ( typeof key === 'string' ) {
return 'key: `' + key + '`'
} else {
return `key: ${key.name}`
}
}
const makeStack = Container.makeStack
.reverse()
.map(displayKey)
const makeHistory = Container.makeHistory
.reverse()
.map(displayKey)
console.error('Make Stack:') // eslint-disable-line no-console
console.error(makeStack.join('\n')) // eslint-disable-line no-console
console.error('Make History:') // eslint-disable-line no-console
console.error(makeHistory.join('\n')) // eslint-disable-line no-console
throw new ErrorWithContext('Cyclic dependency chain detected', {
makeStack,
makeHistory,
})
}
}
/**
* Create a new instance of the dependency key using this container, ignoring any pre-existing instances
* in this container.
* @param key
* @param parameters
*/
makeNew<T>(key: TypedDependencyKey<T>, ...parameters: any[]): T {
if ( isInstantiable(key) ) {
const result = this.produceFactory(new Factory(key), parameters)
if ( isAwareOfContainerLifecycle(result) ) {
result.onContainerRealize?.()
}
return result
}
throw new TypeError(`Invalid or unknown make target: ${key}`)
}
/**
* Get a collection of dependency keys required by the given target, if it is registered with this container.
* @param {DependencyKey} target
*/
getDependencies(target: DependencyKey): Collection<DependencyKey> {
const factory = this.resolve(target)
if ( !factory ) {
throw new InvalidDependencyKeyError(target)
}
return factory.getDependencyKeys().pluck('key')
}
/**
* Perform any cleanup necessary to destroy this container instance.
*/
destroy(): void {
this.blueprintSubscribers.mapCall('unsubscribe')
this.waitingLifecycleCallbacks
.mapCall('deref')
.whereDefined()
.each(inst => {
if ( isAwareOfContainerLifecycle(inst) ) {
inst.onContainerRelease?.()
inst.onContainerDestroy?.()
}
})
}
/**
* Given a different container, copy the factories and instances from this container over to it.
* @param container
*/
cloneTo(container: Container): this {
container.factories = this.factories.clone()
container.instances = this.instances.clone()
return this
}
}