Tworzymy własne Dependency Injection w TypeScript

Najlepiej uczy się na konkretnych przykładach. Dzisiaj napiszesz własną bibliotekę do Dependency Injection w TypeScripcie! Przydadzą nam się dekoratory, metadane, refleksja i kilka sztuczek. Do dzieła 🙂

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.tsinjector.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:

Wzorce Projektowe: Dependency Injection

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, wszystki 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/mmiszy/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 🙂