Zaczynamy
Upewnij się, że masz zainstalowaną najnowszą wersję TypeScript (aktualnie 2.7.0). Do szybkiego testowania kodu przyda się też ts-node
, więc warto go doinstalować.
Zaczynam od skonfigurowania projektu w TypeScripcie. To nigdy nie było prostsze niż teraz:
npm init
tsc init --strict
Następnie tworzę dwa pliki: index.ts
i injector.ts
. W tym pierwszy zawrę kod mojej „aplikacji”, a w tym drugim zaimplementuję Dependency Injection.
Plan
Zasada działania dependency injection nie jest trudna i opisałem ją niegdyś w artykule:
Zanim jednak zacznę cokolwiek programować, warto byłoby mieć jakiś plan ;) Oto moje potrzeby i wymagania:
- możliwość rejestrowania zależności
- możliwość instancjonowania klas razem z automatycznie wstrzykniętymi zależnościami
Przykładowy kod:
class Foobar {
constructor(public foo: Foo, public bar: Bar) {}
}
const foobar = Injector.resolve(Foobar);
foobar.foo; // jest tutaj wstrzyknięty!
foobar.bar; // też jest tutaj!
Nie brzmi strasznie, prawda? Aby zrealizować te dwa podpunkty muszę jednak skorzystać z techniki zwanej refleksją.
Refleksja
Pragnę, aby w moim Dependency Injection zależności były wstrzykiwane automatycznie na podstawie typu argumentów przekazanych do konstruktora. Z pomocą przychodzi właśnie refleksja oraz paczka reflect-metadata
:
npm install reflect-metadata --save
Służy ona do wydobywania pewnych metadanych z obiektów. Te metadane są dodawane do, między innymi, klas, na których użyto jakiegoś dekoratora. Nie wnikam na razie w powody takiego stanu rzeczy, wiem tylko jedno: Na każdej klasie, którą chcę wstrzykiwać, muszę użyć dekoratora.
Dekorator
Dekorator to po prostu funkcja, która przyjmuje jako argument np. klasę i może ją zmodyfikować. Nic szczególnego, prawda? Brzmi prosto. Najprostszy dekorator wygląda tak:
const Injectable = Target => {}
a wykorzystać go można w ten sposób:
@Injectable
class X {}
Możliwe jest też stworzenie fabryki dekoratorów, czyli funkcji, która zwraca dekorator. Jest to rozwiązanie znacznie bardziej popularne, bo daje dużo szersze możliwości:
const Injectable = () => {
return Target => {};
};
@Injectable()
class X {}
Z tej formy będę też korzystał dalej.
Dekoratory a typy?
Domyślnie TypeScript dostarcza jeden typ ClassDecorator
— ale jest on dość ograniczony bo przede wszystkim nie jest generyczny. Dlatego napiszę kilka własnych typów do tego. Na początek potrzebuję typ dla „czegoś co mogę wywołać new
” — czyli dla klasy albo konstruktora. Zapisuję to w ten sposób:
interface Constructor<T> {
new (...args: any[]): T;
}
przyda się też nieco bardziej rozbudowany typ dla dekoratora klasy:
type ClassDecorator<T extends Function> = (Target: Constructor<T>) => T | void;
Czyli jest to typ generyczny, który jako argument typu T przyjmuje coś co rozszerza funkcję (czyli funkcję lub klasę). ClassDecorator<T>
opisuje funkcję, która jako argument przyjmuje Constructor<T>
i zwraca T
.
Ostatecznie mój dekorator Injectable
przyjmuje taką postać:
export const Injectable = (): ClassDecorator<any> => {
return target => {};
};
Injector
Mam już dekorator, a więc mam też metadane. Teraz mogę napisać serwis — Injector
— który będzie odpowiedzialny za tworzenie instancji klas wraz ze wstrzykniętymi zależnościami. Injector
będzie singletonem z jedną tylko metodą — resolve<T>(Target: Constructor<T>): T
.
Pobieranie typów
Na początek pobieram typy argumentów przekazanych do konstruktora Target
:
Reflect.getMetadata('design:paramtypes', Target)
Ta metoda zwraca tablicę konstruktorów. Przykładowo, załóżmy że mam klasy Foo
oraz Bar
, a klasa X
wymaga ich w konstruktorze:
@Injectable()
class X {
constructor(foo: Foo, bar: Bar) {}
}
Reflect.getMetadata('design:paramtypes', X); // [Foo, Bar]
Tworzenie zależności
Teraz dla każdego argumentu muszę wywołać Injector.resolve(…)
— na wypadek gdyby np. Foo
również miało w konstruktorze jakieś zależności. Następnie sprawdzone zostaną zależności Foo
, a potem zależności zależności Foo
, a potem zależności zależności zależności Foo
… i tak dalej. Gdy już dojdę do klasy, która nie ma żadnych zależności — muszę po prostu stworzyć jej instancję przez new
. Brzmi skomplikowanie? Nie, to tylko kilka linii kodu:
export const Injector = new class {
resolve<T>(Target: Constructor<T>): T {
const requiredParams = Reflect.getMetadata('design:paramtypes', Target) || [];
const resolvedParams = requiredParams.map((param: any) => Injector.resolve(param));
const instance = new Target(...resolvedParams);
return instance;
}
}();
Efekt
Testy prostego DI:
import { Injector, Injectable, Constructor } from './src/injector';
@Injectable()
class NoDeps {
doSth() {
console.log(`I'm NoDeps!`);
}
}
@Injectable()
class OneDep {
constructor(public noDeps: NoDeps) {}
doSth() {
console.log(`I'm OneDep!`);
}
}
@Injectable()
class MoarDeps {
constructor(public noDeps: NoDeps, public oneDep: OneDep) {}
doSth() {
console.log(`I'm MoarDeps!`);
}
}
const moarDeps = Injector.resolve(MoarDeps);
moarDeps.doSth();
moarDeps.noDeps.doSth();
moarDeps.oneDep.doSth();
moarDeps.oneDep.noDeps.doSth();
Oraz efekt działania:
I'm MoarDeps!
I'm NoDeps!
I'm OneDep!
I'm NoDeps!
Podsumowanie
Jak widzisz, wszystkie zależności zostały automatycznie wstrzyknięte na podstawie typów klas przekazanych do konstruktora! Great success! 😎
Cały kod znajdziesz tutaj: github.com/typeofweb/typeofweb-dependency-injection-typescript
Nie obsługuję jednak kilku rzeczy:
- circular dependencies (gdy Foo zależy od Bar, a Bar od Foo)
- innych typów niż własne klasy
- nie cache'uję stworzonych instancji klas, więc przy każdym wstrzyknięciu tworzone są nowe (to może być problem!)
- nie daję możliwości łatwego mockowania klas w injectorze (często ważny element DI)
W kolejnym wpisie postaram się dopisać coś z tej listy ;)
Podobało się?
Napisz w komentarzu! Jeśli uważasz, że to kompletnie bzdury — to również napisz :) Albo może zapisz się na szkolenie z TypeScript.