Search

Nestjs가 모듈을 생성하는 방법

subtitle
MODULE_ID는 뭐하는 놈들일까
Tags
오픈소스
nestjs
Created
2021/12/12
2 more properties

nestjs axios의 HTTP_MODULE_ID

nestjs discord에 올라온 질문. HttpModule에서 HTTP_MODULE_ID 값으로 randomStringGenerator() 를 쓰는 이유가 뭘까?
먼저 저분이 올려주신 링크를 타고 가면 이런 코드가 나온다. randomStringGenerator 함수를 타고 들어가보면 uuid 라이브러리를 사용해서 랜덤한 문자열을 생성한다.
@Module({ providers: [ HttpService, { provide: AXIOS_INSTANCE_TOKEN, useValue: Axios, }, ], exports: [HttpService], }) export class HttpModule { static register(config: HttpModuleOptions): DynamicModule { return { module: HttpModule, providers: [ { provide: AXIOS_INSTANCE_TOKEN, useValue: Axios.create(config), }, { provide: HTTP_MODULE_ID, useValue: randomStringGenerator(), }, ], }; } // 생략 }
TypeScript
복사
이렇게 함으로써 HttpModule을 생성할 때 axios config에 따라 모듈을 새로 생성할 수 있다. nestjs는 token 값에 따라 모듈을 재사용하거나 새로 생성한다. 한번 생성한 모듈을 그대로 재사용해도 된다면 상관없지만 HttpModule과 같이 config 값에 따라 모듈을 새로 생성해야 한다면 DynamicModule을 반환하는 팩토리 함수를 만들어서 HTTP_MODULE_ID처럼 module id를 제공해야한다. 그리고 useValue에 랜덤한 값을 넣어주어야 한다.

injector/container.ts

addModule 함수를 보면 다음과 같다.
public async addModule( metatype: Type<any> | DynamicModule | Promise<DynamicModule>, scope: Type<any>[], ): Promise<Module | undefined> { // In DependenciesScanner#scanForModules we already check for undefined or invalid modules // We still need to catch the edge-case of `forwardRef(() => undefined)` if (!metatype) { throw new UndefinedForwardRefException(scope); } const { type, dynamicMetadata, token } = await this.moduleCompiler.compile( metatype, ); if (this.modules.has(token)) { return this.modules.get(token); } const moduleRef = new Module(type, this); moduleRef.token = token; this.modules.set(token, moduleRef); await this.addDynamicMetadata( token, dynamicMetadata, [].concat(scope, type), ); if (this.isGlobalModule(type, dynamicMetadata)) { this.addGlobalModule(moduleRef); } return moduleRef; }
TypeScript
복사
moduleCompiler에서 metatype을 compile하니 type, dynamicMetadata, token 값이 나온다.
const { type, dynamicMetadata, token } = await this.moduleCompiler.compile( metatype, ); // #1 if (this.modules.has(token)) { return this.modules.get(token); } // #2 const moduleRef = new Module(type, this); // #3 moduleRef.token = token; this.modules.set(token, moduleRef);
TypeScript
복사
#1: 여기서 얻은 token 값으로 전체 모듈 중 해당 토큰과 매핑된 모듈이 있다면 그 모듈을 가져온다.
#2: 만약 해당되는 모듈이 없으면 type을 가지고 모듈을 새로 생성한다.
#3: 같은 모듈을 참조하는 경우 #1로 가도록 모듈과 token을 매핑한다.
module의 생성 여부가 생성된 모듈 중 token 값과 매핑된 모듈 여부에 달려있다. token 값이 다르다면 같은 HttpModule이라도 새로 생성될 것이다. 따라서 token 값을 어떻게 생성하는지 보면 nestjs가 HTTP_MODULE_ID와 randomGenerateString()으로 어떻게 모듈을 구분하는지 알 수 있을 것이다.

injector/compiler.ts

compile함수를 보자.
import { DynamicModule, Type } from '@nestjs/common/interfaces'; import { ModuleTokenFactory } from './module-token-factory'; export interface ModuleFactory { type: Type<any>; token: string; dynamicMetadata?: Partial<DynamicModule>; } export class ModuleCompiler { constructor(private readonly moduleTokenFactory = new ModuleTokenFactory()) {} public async compile( metatype: Type<any> | DynamicModule | Promise<DynamicModule>, ): Promise<ModuleFactory> { const { type, dynamicMetadata } = this.extractMetadata(await metatype); const token = this.moduleTokenFactory.create(type, dynamicMetadata); return { type, dynamicMetadata, token }; } public extractMetadata(metatype: Type<any> | DynamicModule): { type: Type<any>; dynamicMetadata?: Partial<DynamicModule> | undefined; } { if (!this.isDynamicModule(metatype)) { return { type: metatype }; } const { module: type, ...dynamicMetadata } = metatype; return { type, dynamicMetadata }; } public isDynamicModule( module: Type<any> | DynamicModule, ): module is DynamicModule { return !!(module as DynamicModule).module; } }
TypeScript
복사
첫 줄의 extractMetadata 함수로 type과 dynamicMetadata를 가져온다. 이건 어디서 가져오는 걸까.
HttpModule을 예로 들면 compile 함수에 전달되는 인자 metatype은 register 함수로 생성한 dynamic module 객체이다. isDynamicModule 이 true이므로 const { module: type, ...dynamicMetadata } = metatype; 이 분기를 탄다. module은 type이 되고, providers는 dynamicMetadata 라는 이름으로 반환한다.
{ module: HttpModule, providers: [ { provide: AXIOS_INSTANCE_TOKEN, useValue: Axios.create(config), }, { provide: HTTP_MODULE_ID, useValue: randomStringGenerator(), }, ], };
TypeScript
복사
그리고 extractMetadata 함수 실행 다음 줄에 드디어 token 생성 로직이 나온다.
const token = this.moduleTokenFactory.create(type, dynamicMetadata);
TypeScript
복사

module-token-factory.ts

import { DynamicModule } from '@nestjs/common'; import { Type } from '@nestjs/common/interfaces/type.interface'; import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; import stringify from 'fast-safe-stringify'; import * as hash from 'object-hash'; export class ModuleTokenFactory { private readonly moduleIdsCache = new WeakMap<Type<unknown>, string>(); public create( metatype: Type<unknown>, dynamicModuleMetadata?: Partial<DynamicModule> | undefined, ): string { const moduleId = this.getModuleId(metatype); const opaqueToken = { id: moduleId, module: this.getModuleName(metatype), dynamic: this.getDynamicMetadataToken(dynamicModuleMetadata), }; return hash(opaqueToken, { ignoreUnknown: true }); } public getDynamicMetadataToken( dynamicModuleMetadata: Partial<DynamicModule> | undefined, ): string { // Uses safeStringify instead of JSON.stringify to support circular dynamic modules // The replacer function is also required in order to obtain real class names // instead of the unified "Function" key return dynamicModuleMetadata ? stringify(dynamicModuleMetadata, this.replacer) : ''; } public getModuleId(metatype: Type<unknown>): string { let moduleId = this.moduleIdsCache.get(metatype); if (moduleId) { return moduleId; } moduleId = randomStringGenerator(); this.moduleIdsCache.set(metatype, moduleId); return moduleId; } public getModuleName(metatype: Type<any>): string { return metatype.name; } private replacer(key: string, value: any) { if (typeof value === 'function') { const funcAsString = value.toString(); const isClass = /^class\s/.test(funcAsString); if (isClass) { return value.name; } return hash(funcAsString, { ignoreUnknown: true }); } if (typeof value === 'symbol') { return value.toString(); } return value; } }
TypeScript
복사
하나씩 살펴보자.
const moduleId = this.getModuleId(metatype); ... public getModuleId(metatype: Type<unknown>): string { let moduleId = this.moduleIdsCache.get(metatype); if (moduleId) { return moduleId; } moduleId = randomStringGenerator(); this.moduleIdsCache.set(metatype, moduleId); return moduleId; }
TypeScript
복사
metatype은 extractMetadata로 가져온 type, dynamicMedtadta 중 type이다. 위의 예를 들면 type은 HttpModule이 될 것이다. moduleIdsCache는 WeakMap을 사용해서 객체를 키로 사용한다. HttpModule과 매핑된 moduleId가 있으면 반환하고 없으면 randomStringGenerator 으로 랜덤한 문자열을 생성해 매핑한다.
그 다음은 opaqueToken을 만든다. 프로그래밍에서 opaque 데이터라고 하면 보통 인터페이스로 정의되지 않은 약한 구조의 객체를 의미한다. 타입이 명확하지 않아서 불투명 데이터라고 하는 것 같다.
const opaqueToken = { id: moduleId, module: this.getModuleName(metatype), dynamic: this.getDynamicMetadataToken(dynamicModuleMetadata), };
TypeScript
복사
getModuleName은 모듈 클래스의 이름을 가져온다. HttpModule은 HttpModule 이 될 것이다.
getDynamicMetadataToken은 providers 부분을 인자로 받는다. stringify로 providers 객체 전체를 문자열로 변환한다.
public getModuleName(metatype: Type<any>): string { return metatype.name; } public getDynamicMetadataToken( dynamicModuleMetadata: Partial<DynamicModule> | undefined, ): string { // Uses safeStringify instead of JSON.stringify to support circular dynamic modules // The replacer function is also required in order to obtain real class names // instead of the unified "Function" key return dynamicModuleMetadata ? stringify(dynamicModuleMetadata, this.replacer) : ''; }
TypeScript
복사
module-token-factory.spec.ts 를 보면 이해가 좀 더 쉽다.
it('should serialize symbols in a dynamic metadata object', () => { const metadata = { providers: [ { provide: Symbol('a'), useValue: 'a', }, { provide: Symbol('b'), useValue: 'b', }, ], }; expect(factory.getDynamicMetadataToken(metadata)).to.be.eql( '{"providers":[{"provide":"Symbol(a)","useValue":"a"},{"provide":"Symbol(b)","useValue":"b"}]}', ); });
TypeScript
복사
이렇게 만든 opaqueToken을 object-hash 라이브러리의 hash로 해싱한다. (hash, hash, hash..)
module-token-factory.ts
const opaqueToken = { id: moduleId, module: this.getModuleName(metatype), dynamic: this.getDynamicMetadataToken(dynamicModuleMetadata), }; return hash(opaqueToken, { ignoreUnknown: true });
TypeScript
복사
injector/compiler.ts
이 해시 값이 토큰으로 반환된다.
public async compile( metatype: Type<any> | DynamicModule | Promise<DynamicModule>, ): Promise<ModuleFactory> { const { type, dynamicMetadata } = this.extractMetadata(await metatype); const token = this.moduleTokenFactory.create(type, dynamicMetadata); return { type, dynamicMetadata, token }; }
TypeScript
복사
container.ts 파일의 addModule 함수로 다시 돌아오면 이제 MODULE_ID를 random한 문자열 값으로 설정하는지 이해가 될 것이다.
public async addModule( metatype: Type<any> | DynamicModule | Promise<DynamicModule>, scope: Type<any>[], ): Promise<Module | undefined> { // In DependenciesScanner#scanForModules we already check for undefined or invalid modules // We still need to catch the edge-case of `forwardRef(() => undefined)` if (!metatype) { throw new UndefinedForwardRefException(scope); } const { type, dynamicMetadata, token } = await this.moduleCompiler.compile( metatype, ); if (this.modules.has(token)) { return this.modules.get(token); } const moduleRef = new Module(type, this); moduleRef.token = token; this.modules.set(token, moduleRef); await this.addDynamicMetadata( token, dynamicMetadata, [].concat(scope, type), ); if (this.isGlobalModule(type, dynamicMetadata)) { this.addGlobalModule(moduleRef); } return moduleRef; }
TypeScript
복사
container.ts의 addModule -> injector/compiler.ts의 compile -> module-token-factory.ts, module-token-factory.spec.ts -> container.ts의 addModule 순으로 보면 이해가 쉽다.

정리

MODULE_ID를 랜덤한 문자열로 만드는 이유는, 컨테이너에서 모듈 생성할 때 다른 config마다 다른 인스턴스 생성하게 하기 위해서이다.
config 값에 따라 동적으로 모듈을 생성해야하는 경우가 있다면 providers의 useValue에 랜덤한 문자열 값을 사용해서 매번 새로운 인스턴스를 생성하게 할 수 있다.

whitekiwi 님의 테스트 코드

nestjs 디스코드의 고독한 고양이방