feat(core): discover by decorator, explorer pattern

This commit is contained in:
Kamil Myśliwiec
2023-07-24 11:32:42 +02:00
parent 50bf928415
commit 7e3da2253b
12 changed files with 453 additions and 10 deletions

View File

@@ -0,0 +1,35 @@
import { Test } from '@nestjs/testing';
import { expect } from 'chai';
import { AppModule } from '../src/app.module';
import { WebhooksExplorer } from '../src/webhooks.explorer';
describe('DiscoveryModule', () => {
it('should discover all providers & handlers with corresponding annotations', async () => {
const builder = Test.createTestingModule({
imports: [AppModule],
});
const testingModule = await builder.compile();
const webhooksExplorer = testingModule.get(WebhooksExplorer);
expect(webhooksExplorer.getWebhooks()).to.be.eql([
{
handlers: [
{
event: 'start',
methodName: 'onStart',
},
],
name: 'cleanup',
},
{
handlers: [
{
event: 'start',
methodName: 'onStart',
},
],
name: 'flush',
},
]);
});
});

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { MyWebhookModule } from './my-webhook/my-webhook.module';
import { WebhooksExplorer } from './webhooks.explorer';
@Module({
imports: [MyWebhookModule, DiscoveryModule],
providers: [WebhooksExplorer],
})
export class AppModule {}

View File

@@ -0,0 +1,6 @@
import { DiscoveryService } from '@nestjs/core';
export const Webhook = DiscoveryService.createDecorator<{ name: string }>();
export const WebhookHandler = DiscoveryService.createDecorator<{
event: string;
}>();

View File

@@ -0,0 +1,9 @@
import { Webhook, WebhookHandler } from '../decorators/webhook.decorators';
@Webhook({ name: 'cleanup' })
export class CleanupWebhook {
@WebhookHandler({ event: 'start' })
onStart() {
console.log('cleanup started');
}
}

View File

@@ -0,0 +1,9 @@
import { Webhook, WebhookHandler } from '../decorators/webhook.decorators';
@Webhook({ name: 'flush' })
export class FlushWebhook {
@WebhookHandler({ event: 'start' })
onStart() {
console.log('flush started');
}
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { CleanupWebhook } from './cleanup.webhook';
import { FlushWebhook } from './flush.webhook';
@Module({ providers: [CleanupWebhook, FlushWebhook] })
export class MyWebhookModule {}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { Webhook, WebhookHandler } from './decorators/webhook.decorators';
@Injectable()
export class WebhooksExplorer {
constructor(
private readonly discoveryService: DiscoveryService,
private readonly metadataScanner: MetadataScanner,
) {}
getWebhooks() {
const webhooks = this.discoveryService.getProviders({
metadataKey: Webhook.KEY,
});
return webhooks.map(wrapper => {
const { name } = this.discoveryService.getMetadataByDecorator(
Webhook,
wrapper,
);
return {
name,
handlers: this.metadataScanner
.getAllMethodNames(wrapper.metatype.prototype)
.map(methodName => {
const { event } = this.discoveryService.getMetadataByDecorator(
WebhookHandler,
wrapper,
methodName,
);
return {
methodName,
event,
};
}),
};
});
}
}

View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"noImplicitAny": false,
"removeComments": true,
"noLib": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2021",
"sourceMap": true,
"allowJs": true,
"outDir": "./dist",
"paths": {
"@nestjs/common": ["../../packages/common"],
"@nestjs/common/*": ["../../packages/common/*"],
"@nestjs/core": ["../../packages/core"],
"@nestjs/core/*": ["../../packages/core/*"],
"@nestjs/microservices": ["../../packages/microservices"],
"@nestjs/microservices/*": ["../../packages/microservices/*"],
"@nestjs/websockets": ["../../packages/websockets"],
"@nestjs/websockets/*": ["../../packages/websockets/*"],
"@nestjs/testing": ["../../packages/testing"],
"@nestjs/testing/*": ["../../packages/testing/*"],
"@nestjs/platform-express": ["../../packages/platform-express"],
"@nestjs/platform-express/*": ["../../packages/platform-express/*"],
"@nestjs/platform-socket.io": ["../../packages/platform-socket.io"],
"@nestjs/platform-socket.io/*": ["../../packages/platform-socket.io/*"],
"@nestjs/platform-ws": ["../../packages/platform-ws"],
"@nestjs/platform-ws/*": ["../../packages/platform-ws/*"]
}
},
"include": [
"src/**/*",
"e2e/**/*"
],
"exclude": [
"node_modules",
]
}

View File

@@ -0,0 +1,153 @@
import { Type } from '@nestjs/common';
import { InstanceWrapper } from '../injector/instance-wrapper';
import { ModulesContainer } from '../injector/modules-container';
export class DiscoverableMetaHostCollection {
/**
* A map of class references to metadata keys.
*/
public static readonly metaHostLinks = new Map<Type | Function, string>();
/**
* A map of metadata keys to instance wrappers (providers) with the corresponding metadata key.
* The map is weakly referenced by the modules container (unique per application).
*/
private static readonly providersByMetaKey = new WeakMap<
ModulesContainer,
Map<string, Set<InstanceWrapper>>
>();
/**
* A map of metadata keys to instance wrappers (controllers) with the corresponding metadata key.
* The map is weakly referenced by the modules container (unique per application).
*/
private static readonly controllersByMetaKey = new WeakMap<
ModulesContainer,
Map<string, Set<InstanceWrapper>>
>();
/**
* Adds a link between a class reference and a metadata key.
* @param target The class reference.
* @param metadataKey The metadata key.
*/
public static addClassMetaHostLink(
target: Type | Function,
metadataKey: string,
) {
this.metaHostLinks.set(target, metadataKey);
}
/**
* Inspects a provider instance wrapper and adds it to the collection of providers
* if it has a metadata key.
* @param hostContainerRef A reference to the modules container.
* @param instanceWrapper A provider instance wrapper.
* @returns void
*/
public static inspectProvider(
hostContainerRef: ModulesContainer,
instanceWrapper: InstanceWrapper,
) {
return this.inspectInstanceWrapper(
hostContainerRef,
instanceWrapper,
this.providersByMetaKey,
);
}
/**
* Inspects a controller instance wrapper and adds it to the collection of controllers
* if it has a metadata key.
* @param hostContainerRef A reference to the modules container.
* @param instanceWrapper A controller's instance wrapper.
* @returns void
*/
public static inspectController(
hostContainerRef: ModulesContainer,
instanceWrapper: InstanceWrapper,
) {
return this.inspectInstanceWrapper(
hostContainerRef,
instanceWrapper,
this.controllersByMetaKey,
);
}
public static insertByMetaKey(
metaKey: string,
instanceWrapper: InstanceWrapper,
collection: Map<string, Set<InstanceWrapper>>,
) {
if (collection.has(metaKey)) {
const wrappers = collection.get(metaKey);
wrappers.add(instanceWrapper);
} else {
const wrappers = new Set<InstanceWrapper>();
wrappers.add(instanceWrapper);
collection.set(metaKey, wrappers);
}
}
public static getProvidersByMetaKey(
hostContainerRef: ModulesContainer,
metaKey: string,
): Set<InstanceWrapper> {
const wrappersByMetaKey = this.providersByMetaKey.get(hostContainerRef);
return wrappersByMetaKey.get(metaKey);
}
public static getControllersByMetaKey(
hostContainerRef: ModulesContainer,
metaKey: string,
): Set<InstanceWrapper> {
const wrappersByMetaKey = this.controllersByMetaKey.get(hostContainerRef);
return wrappersByMetaKey.get(metaKey);
}
private static inspectInstanceWrapper(
hostContainerRef: ModulesContainer,
instanceWrapper: InstanceWrapper,
wrapperByMetaKeyMap: WeakMap<
ModulesContainer,
Map<string, Set<InstanceWrapper>>
>,
) {
const metaKey =
DiscoverableMetaHostCollection.getMetaKeyByInstanceWrapper(
instanceWrapper,
);
if (!metaKey) {
return;
}
let collection: Map<string, Set<InstanceWrapper>>;
if (wrapperByMetaKeyMap.has(hostContainerRef)) {
collection = wrapperByMetaKeyMap.get(hostContainerRef);
} else {
collection = new Map<string, Set<InstanceWrapper>>();
wrapperByMetaKeyMap.set(hostContainerRef, collection);
}
this.insertByMetaKey(metaKey, instanceWrapper, collection);
}
private static getMetaKeyByInstanceWrapper(
instanceWrapper: InstanceWrapper<any>,
) {
return this.metaHostLinks.get(
// NOTE: Regarding the ternary statement below,
// - The condition `!wrapper.metatype` is needed because when we use `useValue`
// the value of `wrapper.metatype` will be `null`.
// - The condition `wrapper.inject` is needed here because when we use
// `useFactory`, the value of `wrapper.metatype` will be the supplied
// factory function.
// For both cases, we should use `wrapper.instance.constructor` instead
// of `wrapper.metatype` to resolve processor's class properly.
// But since calling `wrapper.instance` could degrade overall performance
// we must defer it as much we can.
instanceWrapper.metatype || instanceWrapper.inject
? instanceWrapper.instance?.constructor ?? instanceWrapper.metatype
: instanceWrapper.metatype,
);
}
}

View File

@@ -1,15 +1,48 @@
import { flatten, Injectable } from '@nestjs/common';
import {
CustomDecorator,
flatten,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { uid } from 'uid';
import { InstanceWrapper } from '../injector/instance-wrapper';
import { Module } from '../injector/module';
import { ModulesContainer } from '../injector/modules-container';
import { DiscoverableMetaHostCollection } from './discoverable-meta-host-collection';
/**
* @publicApi
*/
export interface DiscoveryOptions {
export interface FilterByInclude {
/**
* List of modules to include (whitelist) into the discovery process.
*/
include?: Function[];
}
/**
* @publicApi
*/
export interface FilterByMetadataKey {
/**
* A key to filter controllers and providers by.
* Only instance wrappers with the specified metadata key will be returned.
*/
metadataKey?: string;
}
/**
* @publicApi
*/
export type DiscoveryOptions = FilterByInclude | FilterByMetadataKey;
/**
* @publicApi
*/
export type DiscoverableDecorator<T> = ((opts?: T) => CustomDecorator) & {
KEY: string;
};
/**
* @publicApi
*/
@@ -17,24 +50,108 @@ export interface DiscoveryOptions {
export class DiscoveryService {
constructor(private readonly modulesContainer: ModulesContainer) {}
getProviders(
/**
* Creates a decorator that can be used to decorate classes and methods with metadata.
* The decorator will also add the class to the collection of discoverable classes (by metadata key).
* Decorated classes can be discovered using the `getProviders` and `getControllers` methods.
* @param metadataKey The metadata key to use.
* @returns A decorator function.
*/
static createDecorator<T>(): DiscoverableDecorator<T> {
const metadataKey = uid(21);
const decoratorFn =
(opts: T) =>
(target: object | Function, key?: string | symbol, descriptor?: any) => {
if (!descriptor) {
DiscoverableMetaHostCollection.addClassMetaHostLink(
target as Function,
metadataKey,
);
}
SetMetadata(metadataKey, opts ?? {})(target, key, descriptor);
};
decoratorFn.KEY = metadataKey;
return decoratorFn as DiscoverableDecorator<T>;
}
/**
* Returns an array of instance wrappers (providers).
* Depending on the options, the array will contain either all providers or only providers with the specified metadata key.
* @param options Discovery options.
* @param modules A list of modules to filter by.
* @returns An array of instance wrappers (providers).
*/
public getProviders(
options: DiscoveryOptions = {},
modules: Module[] = this.getModules(options),
): InstanceWrapper[] {
if ('metadataKey' in options) {
const providers = DiscoverableMetaHostCollection.getProvidersByMetaKey(
this.modulesContainer,
options.metadataKey,
);
return Array.from(providers);
}
const providers = modules.map(item => [...item.providers.values()]);
return flatten(providers);
}
getControllers(
/**
* Returns an array of instance wrappers (controllers).
* Depending on the options, the array will contain either all controllers or only controllers with the specified metadata key.
* @param options Discovery options.
* @param modules A list of modules to filter by.
* @returns An array of instance wrappers (controllers).
*/
public getControllers(
options: DiscoveryOptions = {},
modules: Module[] = this.getModules(options),
): InstanceWrapper[] {
if ('metadataKey' in options) {
const controllers =
DiscoverableMetaHostCollection.getControllersByMetaKey(
this.modulesContainer,
options.metadataKey,
);
return Array.from(controllers);
}
const controllers = modules.map(item => [...item.controllers.values()]);
return flatten(controllers);
}
/**
* Retrieves metadata from the specified instance wrapper.
* @param decorator The decorator to retrieve metadata of.
* @param instanceWrapper Reference to the instance wrapper.
* @param methodKey An optional method key to retrieve metadata from.
* @returns Discovered metadata.
*/
public getMetadataByDecorator<T extends DiscoverableDecorator<any>>(
decorator: T,
instanceWrapper: InstanceWrapper,
methodKey?: string,
): T extends DiscoverableDecorator<infer R> ? R | undefined : T | undefined {
if (methodKey) {
return Reflect.getMetadata(
decorator.KEY,
instanceWrapper.instance[methodKey],
);
}
const clsRef =
instanceWrapper.instance?.constructor ?? instanceWrapper.metatype;
return Reflect.getMetadata(decorator.KEY, clsRef);
}
/**
* Returns a list of modules to be used for discovery.
*/
protected getModules(options: DiscoveryOptions = {}): Module[] {
if (!options.include) {
const includeInOpts = 'include' in options;
if (!includeInOpts) {
const moduleRefs = [...this.modulesContainer.values()];
return moduleRefs;
}

View File

@@ -5,6 +5,7 @@ import {
} from '@nestjs/common/constants';
import { Injectable, Type } from '@nestjs/common/interfaces';
import { ApplicationConfig } from '../application-config';
import { DiscoverableMetaHostCollection } from '../discovery/discoverable-meta-host-collection';
import {
CircularDependencyException,
UndefinedForwardRefException,
@@ -223,7 +224,7 @@ export class NestContainer {
relatedModule,
);
const related = this.modules.get(relatedModuleToken);
moduleRef.addRelatedModule(related);
moduleRef.addImport(related);
}
public addProvider(
@@ -238,7 +239,12 @@ export class NestContainer {
if (!moduleRef) {
throw new UnknownModuleException();
}
return moduleRef.addProvider(provider, enhancerSubtype) as Function;
const providerKey = moduleRef.addProvider(provider, enhancerSubtype);
const providerRef = moduleRef.getProviderByKey(providerKey);
DiscoverableMetaHostCollection.inspectProvider(this.modules, providerRef);
return providerKey as Function;
}
public addInjectable(
@@ -268,6 +274,12 @@ export class NestContainer {
}
const moduleRef = this.modules.get(token);
moduleRef.addController(controller);
const controllerRef = moduleRef.controllers.get(controller);
DiscoverableMetaHostCollection.inspectController(
this.modules,
controllerRef,
);
}
public clear() {
@@ -292,7 +304,7 @@ export class NestContainer {
if (target === globalModule || target === this.internalCoreModule) {
return;
}
target.addRelatedModule(globalModule);
target.addImport(globalModule);
}
public getDynamicMetadataByToken(token: string): Partial<DynamicModule>;

View File

@@ -240,11 +240,11 @@ export class Module {
return instanceWrapper;
}
public addProvider(provider: Provider): Provider | InjectionToken;
public addProvider(provider: Provider): InjectionToken;
public addProvider(
provider: Provider,
enhancerSubtype: EnhancerSubtype,
): Provider | InjectionToken;
): InjectionToken;
public addProvider(provider: Provider, enhancerSubtype?: EnhancerSubtype) {
if (this.isCustomProvider(provider)) {
if (this.isEntryProvider(provider.provide)) {
@@ -517,6 +517,13 @@ export class Module {
});
}
public addImport(moduleRef: Module) {
this._imports.add(moduleRef);
}
/**
* @deprecated
*/
public addRelatedModule(module: Module) {
this._imports.add(module);
}