The Origin of the Problem
Both NestJS and MidwayJS implement Dependency Injection (DI) and Inversion of Control (IoC) through decorators and metadata. This confuses many people, so let me use a common NestJS example:
@Injectable()
class CatService {
constructor(public readonly animalService: AnimalService) {}
run(){
this.animalService.run()
}
}Have you ever wondered how animalService gets automatically instantiated as AnimalService? And what about controllers:
@Controller('cats')
export class CatsController {
constructor(public readonly catService: CatService) {}
@Get('/run')
run(){
this.catService.run()
}
}Can you guess how @Controller and @Get decorators map the CatsController's run method to the "/cats/run" route?
Fundamentals
Class Decorators
Let's use a code snippet from Ruan Yifeng's tutorials:
@testable
class MyTestableClass {
// ...
}
function testable(target) {
target.isTestable = true;
}
MyTestableClass.isTestable // trueAs we can see, the decorator function receives the class itself through its target parameter.
Method Decorators
Let's review method decorators with another example:
class Math {
@log
add(a, b) {
return a + b;
}
}
function log(target, name, descriptor) {
var oldValue = descriptor.value;
descriptor.value = function() {
console.log(`Calling ${name} with`, arguments);
return oldValue.apply(this, arguments);
};
return descriptor;
}
const math = new Math();
// passed parameters should get logged now
math.add(2, 4);As shown above, a method decorator can completely redefine the decorated method's content. It can access the method itself through the third parameter descriptor.
reflect-metadata
Metadata is an ES7 proposal primarily used to attach and read metadata at declaration time. TypeScript 1.5+ already supports it. We can install it via npm:
npm i reflect-metadata --saveThen configure the emitDecoratorMetadata option in tsconfig.json:
{
"compilerOptions": {
"emitDecoratorMetadata": true
}
}Let's look at the basic usage of reflect-metadata:
@Reflect.metadata('inClass', 'A')
class Test {
@Reflect.metadata('inMethod', 'B')
public hello(): string {
return 'hello world';
}
}
console.log(Reflect.getMetadata('inClass', Test)); // 'A'
console.log(Reflect.getMetadata('inMethod', new Test(), 'hello')); // 'B'When used as a decorator, Reflect.metadata can add metadata to both classes and methods. Most importantly, it can retrieve function parameter types via Reflect.getMetadata("design:paramtypes", target, key).
Custom Keys
In addition to getting type information, reflect-metadata can also store data with custom keys:
function classDecorator(): ClassDecorator {
return target => {
// Define metadata on the class with key 'classMetaData' and value 'a'
Reflect.defineMetadata('classMetaData', 'a', target);
};
}
function methodDecorator(): MethodDecorator {
return (target, key, descriptor) => {
// Define metadata on the class prototype property 'someMethod' with key 'methodMetaData' and value 'b'
Reflect.defineMetadata('methodMetaData', 'b', target, key);
};
}
@classDecorator()
class SomeClass {
@methodDecorator()
someMethod() {}
}
Reflect.getMetadata('classMetaData', SomeClass); // 'a'
Reflect.getMetadata('methodMetaData', new SomeClass(), 'someMethod'); // 'b'Implementation
Inversion of Control
Let's add an IoC factory function to our earlier example:
@Injectable()
class CatService {
constructor(public readonly animalService: AnimalService) {}
run(){
this.animalService.run()
}
}
const factory = <T>(target: Constructor<T>): T => {
const providers = Reflect.getMetadata('design:paramtypes', target); // [AnimalService]
const args = providers.map((provider: Constructor) => new provider()); // Instantiate all
return new target(...args);
};
factory(CatService).run();Here we use Reflect.getMetadata('design:paramtypes', target) to get the constructor parameter types.
Implementing @Controller and @Get
Let's borrow a code snippet from "Deep Dive into TypeScript":
const METHOD_METADATA = 'method';
const PATH_METADATA = 'path';
const Controller = (path: string): ClassDecorator => {
return target => {
// Store the path parameter passed to @Controller
Reflect.defineMetadata(PATH_METADATA, path, target);
}
}
const createMappingDecorator = (method: string) => (path: string): MethodDecorator => {
return (target, key, descriptor) => {
// Store the request method and path separately, using descriptor.value
Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
Reflect.defineMetadata(METHOD_METADATA, method, descriptor.value);
}
}
const Get = createMappingDecorator('GET');Then implement route mapping:
function mapRoute(instance: Object) {
const prototype = Object.getPrototypeOf(instance);
// Filter out method names from the class
const methodsNames = Object.getOwnPropertyNames(prototype)
.filter(item => !isConstructor(item) && isFunction(prototype[item]));
return methodsNames.map(methodName => {
// fn is the descriptor.value from earlier
const fn = prototype[methodName];
// Get all defined metadata
const route = Reflect.getMetadata(PATH_METADATA, fn);
const method = Reflect.getMetadata(METHOD_METADATA, fn);
return {
route,
method,
fn,
methodName
}
})
};At this point, we can iterate over mapRoute and mount the routes on Express or Koa. The "Deep Dive into TypeScript" book covers this implementation in its Reflect Metadata chapter; readers new to TypeScript may want to review the fundamentals first.
