Mit jelent a függőséginjektálás (dependency injection, DI) a gyakorlatban?
Ha egy osztály nem támaszkodna a NestJS DI-jére, hogy az elérhetővé tegye számára a függőségeit, akkor valahogy így nézne ki a kód:
class UserController {
constructor() {
const config = new Config();
const database = new Database(config);
const userRepository = new UserRepository(database);
this.userService = new UserService(userRepository)
}
}
Ehhez képest, csak jeleznünk kell, hogy mit szeretnénk használni:
class UserController {
constructor(private userService: UserService) {}
}
Nem kell mindenhol nekünk felépíteni a teljes függőségi láncot.
Mi történik a színfalak mögött?
Fordításkor a típusdefiníciók kikerülnek a kódból, de ha a TypeScript konfigurációs fájljában szerepel a
"emitDecoratorMetadata": true,
, akkor a metaadatok megmaradnak és felhasználhatóak arra, hogy a kódban maradjon az az információ, hogy melyik osztálynak milyen más osztályokra van szüksége. Erre támaszkodhat az algoritmus, azon felül, hogy milyen providerek kerültek regisztálásra és, hogy milyen tokeneket definiáltunk.
A NestJS a modulban megadott providereket összegyűjti, regisztrálja a DI-konténerben, majd a függőségek feloldásakor létrehozza és kezeli a szükséges példányokat. A metaadatok alapján fel tudja építeni a függőségi gráfot, hogy mely kódrészletnek melyik providerekre van szüksége. Alapértelmezett módon egy-egy, újrafelhasználható instance jön létre, amelyet a NestJS cache-el.
Teljesítmény szempontjából teljesen érthető, hogy a singleton scope az alapértelmezett mód, de lehetőség van felülírni REQUEST szintű vagy TRANSIENT scope-ra. Ez utóbbi esetben a transient provider nincs megosztva a fogyasztók között, hanem az egyes fogyasztók külön instance-t kapnak. A REQUEST scope esetén minden beérkező kéréshez új instance jön létre.
Az injektálás maga egy rekurzív folyamat, melynek során végigmegy az algoritmus a függőségek függőségein és felépíti a szükséges kódot:
resolve(A)
A requires B
↓
resolve(B)
B requires C
↓
resolve(C)
C requires nothing
↓
create C
create B(C)
create A(B)
Körkörös függőség
A fenti logikából következik a kérdés, hogy mi történik akkor, ha A függ B-től és B függ A-tól. Hoppá… Bár célszerű kerülni az ilyesmit, például a közös elemek külön modulba szervezésével, vannak indokolható előfordulási esetek. Ilyenkor a megoldás a forwardRef().
constructor(
@Inject(forwardRef(() => CommonService))
private commonService: CommonService,
) {}
constructor(
@Inject(forwardRef(() => CatService))
private catService: CatService,
) {}
Ebben az esetben az algoritmus későbbre halaszthatja a hivatkozott osztály feloldását, akár addig, amíg a teljes gráf felépül.
Modulok esetében szintén előfordulhat kölcsönös függőség, amikor forwardRef() alkalmazására van szükség.
// CommonModule
@Module({ imports: [forwardRef(() => CatsModule)] })
export class CommonModule {}
// CatsModule
@Module({ imports: [forwardRef(() => CommonModule)] })
export class CatsModule {}
Mi mindent lehet injektálni?
Injektálni nem csak egy másik osztályt lehet, hanem változókat, vagy más providerektől függő, futásidőben számított értékeket is. Lássunk néhány példát. Konstansok:
const catConfig = {
color: 'black',
strength: 'strong',
};
@Module({
providers: [
{
provide: 'CAT_CONFIG',
useValue: catConfig,
},
],
})
Vegyük észre, hogy kapott egy nevet a providerünk. Ez lesz az azonosító token, amely alapján a NestJS később megtalálja és feloldja a providert. A gyakran használt, egysoros provider megadás egy egyszerűsítés, ekvivalens a következővel:
{
provide: CatsService,
useClass: CatsService,
},
Ha a providerhez osztály helyett például stringet vagy Symbol-t használunk injection tokenként, akkor az injektáláskor az @Inject() dekorátorral kell megadnunk a tokent.
@Injectable()
export class CatSalon {
constructor(@Inject('CAT_CONFIG') private config) {}
}
Paraméterfüggő értékek.
@Module({
providers: [
{
provide: 'CAT_MOOD',
useFactory: () => {
const hour = new Date().getHours();
return hour < 16 ? 'Calm Cat' : 'Impatient cat';
},
},
],
})
Egy tipikus példa lehet a konfigurációfüggő adatbázis kapcsolatot biztosító provider.
{
provide: 'CONNECTION',
useFactory: (config: ConfigService) => new DatabaseConnection(config.get('DB_URL')),
inject: [ConfigService],
}
Ebben az esetben az argumentum is injektálva van.
Mi a feltétele az injektálás működésének?
- @Injectable() dekorátorral látjuk el azokat az osztályokat, amelyek a függőséginjektálási rendszerben részt vehetnek.
- A modul fájlban a providers kulcs alatt felsoroljuk az összes providert, amelyeket az adott modul DI-konténere kezelhet, mert itt fogja a NestJS algoritmusa ellenőrizni, hogy mit kell az adott konténerben regisztrálni.
- Ha másik modul szeretné használni az adott providert, akkor a providert tartalmazó modulnak hozzá kell adnia az exports listához a providert, a befogadó modulnak pedig meg kell nevezni az exportáló modult az imports listában.
- Nem mindegy a fordító, mert fontos a futási időben elérhető metaadatok megtartása. Támogatott például a tsc vagy az SWC megfelelő konfigurációval.
Konklúzió
A függőséginjektálásnak köszönhetően átláthatóbb kódbázist kapunk és az alapvető logika megértésével könnyen felépíthetjük a moduljainkat, javíthatjuk a függőséginjektálással kapcsolatos hibákat. Például amikor elfelejtünk exportálni valamit (ha épp olyan kedvünkben voltunk, hogy kézzel írtuk a kódot 🙂 ).