Dependency Injection in unter 200 Zeilen Code – ohne Leerzeilen

Injections für echte TypeScript-Junkies
Keine Kommentare

Dependency Injection hat sich bei den meisten Softwareprojekten, Frameworks und Apps durchgesetzt. Dennoch ist es erstaunlich, wie viele Fragen man in diversen Foren im Kontext von JavaScript Frameworks zu diesem Thema findet. Muss Dependency Injection kompliziert sein? Wir meinen nicht. Mit TypeScript MetaData und Decorators lässt sich eine sehr kompakte, aber wirkungsvolle Dependency Injection aufbauen.

Spätestens seit dem Java Spring Framework hat sich Inversion of Control oder Dependency Injection (DI) als Designpattern etabliert. Die damals noch nicht so verbreitete Idee war es, Methoden, Klassen und Module so zu entwerfen, dass sie durch ihre Schnittstellen wirklich vollständig beschrieben sind. Das bedeutet, dass sie kein implizites oder globales Wissen mitbringen müssen, um korrekt zu funktionieren. Ein Gegenbeispiel dazu wären verschiedene Arten von Service-Locator-Patterns oder, noch schlimmer, mehrere globale Objekte, die Services bereitstellen. Diese Muster sind Antimuster, bei denen Methoden wissen, wo sie nachsehen müssen, um benötigte Services zu beziehen, z. B. für Datenzugriff, Authentifizierung usw. Der Zugriff auf die Services erfolgt global, beispielsweise über static-Methoden wie ServiceLocator.getDataSource(), DataAccess.getDataSource() oder Security.getAuthenticationService(). Je mehr von diesen globalen Abhängigkeiten umherschwirren, desto undurchschaubarer wird der Code, und er lässt sich vor allem nicht mehr modular Unit-testen, da sämtliche globalen Objekte in den richtigen Testzustand versetzt werden müssen. Bei der DI oder Inversion of Control läuft es eben umgekehrt: Nicht die einzelnen Codefragmente wissen, woher sie Services beziehen, sondern Letztere werden ihnen beim Aufruf einfach mitgegeben, z. B. als Konstruktorargumente. Damit sind jedes Codestück, jede Klasse und Methode wirklich eindeutig durch ihre Schnittstellen definiert.

Was hat das mit JavaScript zu tun?

In der guten alten Zeit, in der Web-Apps serverseitig, z. B. über MVC-Frameworks, gerendert wurden, beschränkte sich JavaScript auf die dynamische Manipulation des DOMs und evtl. auch noch auf Ajax-Datentransfer. jQuery, ähnliche Frameworks und ihr Programmiermodell saßen fest im Webstack-Sattel. Wie Sie wissen, hat sich die Situation mit voll ausgewachsenen JavaScript-Clientapplikationen stark verändert, denn sie sind eigentlich Rich Clients (Single-Page-Applikationen), die mit Backends kommunizieren. Statt jQuery und Co. finden wir MVVC-Patterns und reaktive Programmierung. Frameworks wie Ract.js, Angular und Vue.js sind wohlbekannt, und mit Progressive Web Apps wird die Lücke zwischen echter App und Web-App nochmal deutlich verkleinert.

Wenn wir heute von JavaScript-Clients sprechen, meinen wir daher in der Regel ausgewachsene Softwaresysteme, bei denen wir den gleichen Herausforderungen gegenüberstehen wie bei traditionellen Rich Clients. Und daher wird es ganz selbstverständlich, dass wir zum Designen unserer Softwarebausteine Designpatterns wie Dependency Injection verwenden.

TypeScript-Fanboy

Wie die regelmäßigen Leser vermutlich wissen, bin ich ein TypeScript-Fanboy. Bei den heutigen komplexen (mobilen) Web-Apps wird eine statische Typisierung, die die Flexibilität von JS nicht einschränkt, zum ALM Lifesaver. Wenn man dann noch Frameworks wie beispielsweise React.js verwendet, die UI-Komponenten nach JavaScript/TypeScript transpilieren, hat man ein langersehntes Ziel der UI-Entwicklung erreicht: UI-Komponenten sind statisch typisiert und werden vom Compiler überprüft. Vorbei sind die Zeiten, in denen man erst während des Betriebs bemerkt, dass aufgrund von Typos im Data Binding Fehler zur Laufzeit auftreten. Hatten Sie auch schon mal das Property fristName als Data Binding in einer UI-Komponente?

TypeScript Decorators und Metadata

Aber genug der Schwärmerei. TypeScript-Code lässt sich ähnlich wie in .NET mit Attributes oder wie in Java mit Annotations mit Metainformationen anreichern, die in TypeScript „Decorators“ heißen. Einen ersten Decorator sehen wir hier:

@injectionTarget()
class TestClass {

}

Die Klasse TestClass wird mit dem Decorator @injectionTarget() dekoriert. Daher wird dieser Decorator Klassen-Decorator genannt. Wie wir sehen werden, gibt es u. a. auch noch Parameter Decorators.

Unsere Testklasse erhält dadurch die Metainformation, dass offensichtlich irgendetwas in diese Klasse injiziert wird. Aber bei TypeScript bzw. JavaScript kommt ein entscheidender Vorteil einer interpretierten Skriptsprache zum Tragen: Wenn eine Skriptdatei geladen wird (über <script>>-Tags, import, require oder Ähnliches), wird der Code sequenziell durchlaufen und ausgeführt und die entsprechenden JS-Objekte werden angelegt. Es wird in unserem Fall z. B. das Klassenobjekt (oder JS-Konstruktorfunktionsobjekt) TestClass angelegt. Diese dynamische Lade- und Ausführungssequenz bietet ein paar spannende Möglichkeiten wie das Modifizieren von Klassenobjekten beim Laden. Und genau das macht den Unterschied zwischen .NET Attributes und TypeScript Decorators aus: Decorators sind Funktionen, nicht nur Labels oder Tags.

„Ja und?“ kann man jetzt fragen. Und die Antwort lautet: „Man kann in Decorators alles tun – alles – was man in JS-Funktionen auch tun kann.“ Um es gleich konkreter zu machen, werfen wir einen Blick auf Listing 1, wo wir sehen, was der TS-Transpiler aus dem @injectionTarget() macht.

let TestClass = class TestClass {

};
TestClass = __decorate([
  junkie_1.injectionTarget(),
  ...
  __metadata("design:paramtypes", [String, String, 
Object, Object, InjectedTestClass, IDummyInterface])
], TestClass);

Es wird die Funktion __decorate aufgerufen, die als Parameter unseren Decorator erhält. Ohne auf die Details der __decorate-Funktion einzugehen, führt das dazu, dass zur Ladezeit unserer Testklasse der Decorator mit der Klasse als Parameter aufgerufen wird. Das ist auch schon die Hauptidee: Wir haben nun die Klasse in der Hand und können sie manipulieren. Wir werden später noch auf die Codedetails des Decorators eingehen.

Grundidee der Dependency Injection mittels Decorators

Ein Klassen-Decorator kann also eine Klasse manipulieren. Wir werden dadurch die Konstruktorfunktion so modifizieren, dass wir beim Erzeugen einer Instanz der Klasse die zu injizierenden Daten/Objekte hineinschmuggeln können. Der grundlegende Ablauf (Abb. 1) ist der folgende:

  • Beim Laden der Skriptdateien werden die Klassenobjekte angelegt (Listing 1: let TestClass = class TestClass…)
    • Pro Klasse wird der Klassen-Decorator @injectionTarget() aufgerufen.
      • Der Decorator erzeugt eine neue Klasse, die von der originalen erbt (extends).
  • Zur Ausführungszeit der Applikation:
    • Es werden die zu injizierenden Objekte/Daten in der DI Registry registriert.
    • Es werden Instanzen der dekorierten Klassen erzeugt, z. B. testObject = new TestClass(…).
      • Es wird der constructor der neuen, vom Decorator erstellten Klasse aufgerufen.
        • Diese holt aus der DI Registry die zu injizierenden Objekte/Werte.
        • Er ruft den Original-Constructor auf und fügt dabei die zu injizierenden Objekte/Daten als Parameter ein.
Abb. 1: Vereinfachter Ablauf: Laden des Codes und Ausführen der App

Abb. 1: Vereinfachter Ablauf: Laden des Codes und Ausführen der App

Aber halt!

Woher wissen wir, welche Parameter durch injizierte ersetzt werden? Bisher haben wir nur darüber gesprochen, dass die Hauptmagie darin besteht, dass der Klassen-Decorator eine neue Klasse anlegt und deren constructor irgendwie die zu injizierenden Objekte hineinschmuggelt. Aber woher weiß der neue constructor, welche Parameter zu injizieren sind? An dieser Stelle kommen die sogenannten Parameter-Decorators zum Einsatz, wie wir sie in Listing 2 sehen.

@injectionTarget()
class TestClass {
  constructor(public arg0: string, public arg1: string,
    // Inject a named value. 
    @inject("key1") public arg2?: any,

    // Inject a singleton instance of a class.
    @inject(InjectedTestClass) public arg3?: any,

    // Inject the singleton instance of a class. 
    // The class is taken from the parameter type.
    @inject() public arg4?: InjectedTestClass,

    // Inject the singleton instance of an interface. 
    // The interface-class is taken from the parameter type.
    @inject() public arg5?: IDummyInterface,
  ) {
    console.log("TestClass.ctor: args == " + JSON.stringify(arguments));
  }
};

Parameter-Decorators sind ebenfalls Funktionen, die zum Ladezeitpunkt der Klasse pro Parameter aufgerufen werden. In Listing 3 wird der komplette, transpilierte JS-Code gezeigt.

let TestClass = class TestClass {
  constructor(arg0, arg1, 
  // Inject a named value. 
  arg2, 
  // Inject a singleton instance of a class.
  arg3, 
  // Inject the singleton instance of a class. 
  // The class is taken from the parameter type.
  arg4, 
  // Inject the singleton instance of an interface. 
  // The interface-class is taken from the parameter type.
  arg5) {
    this.arg0 = arg0;
    this.arg1 = arg1;
    this.arg2 = arg2;
    this.arg3 = arg3;
    this.arg4 = arg4;
    this.arg5 = arg5;
    console.log("TestClass.ctor: args == " + JSON.stringify(arguments));
  }
};
TestClass = __decorate([
  junkie_1.injectionTarget(),
  __param(2, junkie_1.inject("key1")),
  __param(3, junkie_1.inject(InjectedTestClass)),
  __param(4, junkie_1.inject()),
  __param(5, junkie_1.inject()),
  __metadata("design:paramtypes", [String, String, 
    Object, Object, InjectedTestClass, IDummyInterface])
], TestClass);

Dem Parameter-Decorator werden das Klassenobjekt sowie der Parameterindex übergeben. Letzterer ist der Schlüssel für das Injizieren der Objekte: Im Decorator speichern wir den Parameterindex und den Key (z. B. key1 aus Listing 3). Damit ist es im constructor der neuen Klasse möglich, den passenden Parameter mit dem passenden Objekt aus der DI Registry zu ersetzen. Die Details und der genaue Code dazu folgen noch.

Die DI Registry speichert also die zu injizierenden Objekte mit dem zugehörigen Schlüssel ab (Abb. 1). Welche Arten von Schlüssel wollen wir dabei unterstützen? Im einfachsten Fall verwenden wir einen String als Schlüssel, wie im Beispiel des arg2 aus Listing 2.

Oft geht es aber um zentrale Services, z. B. Data Access Service, die als Singleton-Instanz der jeweiligen Service-Klasse leben. Dazu ist es hilfreich, nicht von Hand einen Schlüssel vergeben zu müssen, sondern einfach die Klasse selbst als Schlüssel zu verwenden, wie bei arg3 aus Listing 2. Dazu wird ein Hash der Klasse als Schlüssel für die DI Registry verwendet.

Falls beim @inject() gar kein Key als Parameter übergeben wird, wird der Datentyp des Parameters zur Key-Generierung verwendet, wie bei arg4 aus Listing 2. Der Decorator erkennt dabei den Datentyp InjectedTestClass und verfährt wie im vorherigen Fall.

Und last but not least: Interfaces. Interfaces können leider nicht nach JS transpiliert werden, da es in JS keine Interfaces gibt. Daher behelfen wir uns mit einem TypeScript-Trick: Wir definieren Interfaces mit dem class-Schlüsselwort, verwenden aber dennoch implements für Implementierungsklassen (Listing 4).

/**
 * Note: We deliberately use "class" because TS does not keep 
 * interface info at runtime.
 * => This is a workaround for injecting interfaces.
 */
class IDummyInterface {
  propOfDummyInterface: string
}

/**
 * Use TypeScript "Trick": implement IDummyInterface 
 * although it is defined as "class".
 */
class DummyInterfaceImpl implements IDummyInterface {
  propOfDummyInterface: string
};

Und nun die schmutzigen Details: Der Parameter-Decorator

Endlich kommen wir in Listing 5 zu den Details des @inject()-Decorators. Er überprüft in der Set-up-Phase die übergebenen Parameter.

/**
 * Class interface.
 * TypeScript way of declaring class parameter types, 
 * e.g. in generic functions.
 */
export interface IClass<T> {
  new(...varargs: any[]): T;
  prototype: any;
  name: string;
}

/**
 * Decorator to inject a value either by key or by class.
 * Classes must be registered by {@link registerC} and ordinary 
 * values/objects by {@link register}.
 * If key is a class, the key for looking up the value in the registry 
 * is generated by the same mechanism as in {@link registerC}.
 * If key == undefined (so neither a value nor a class), the parameter 
 * type must be a class. This class will be used as the key.
 * @param key
 */
export function inject<T>(key?: string | IClass<T> | Symbol | undefined) {
  let keyAny: any = key;
  let clazz: IClass<any> | undefined = undefined;

  // "Setup-Phase" of decorator: 
  // Use the parameters passed by @inject().

  // Is the key a ES6 class?
  if (keyAny && MrReflect.isClass(keyAny)) {
    clazz = (key as IClass<T>);
    key = keyAny = Junkie.generateInjectorKey(keyAny);
  } else if (key instanceof Symbol) {
    keyAny = key.toString();
  } else if (typeof key === "string" || key instanceof String) {
    keyAny = key;
  }

  /**
   * Execution phase of the decorator: Handle the parameter.
   */
  return function (target: Object,
    propertyKey: string | symbol, paramInx: number): any {
    preventUnused(propertyKey);
    // If no key is given, we use the parameter position 
    // and its type to generate the DI lookup key.
    if (!keyAny) {
      let ctorParamTypes = Reflect.getMetadata("design:paramtypes", target);
      let currentParamType = ctorParamTypes[paramInx];
      // Helper function: Check if ES6 class. If not => Error.
      if (!MrReflect.isClass(currentParamType)) {
        throw new RumbleError(
          `Neither key nor class was passed to @inject.
          This is valid only if parameter has a class type 
          because it will used as key for the DI registry.
          But parameter number ${paramInx} is of type 
          ${currentParamType.name}`);
      }
      // Generate a hash, same as in {@link registerC}
      keyAny = Junkie.generateInjectorKey(currentParamType);
      clazz = currentParamType;
    }
    // Save the arg descriptor in the class object.
    Junkie.pushInjectedCtorArgDescriptor(
      (target as IClass<any>),
      { paramInx, key: keyAny }
    );
  } 
}

/**
 * Descriptor for injected ctor arguments.
 */
export interface InjectCtorArgDescriptor {
  paramInx: number;
  key: string;
}

/**
 * Get the descriptors for injected ctor-arguments.
 * @param clazz
 */
static getInjectedCtorArgsDescriptors(clazz: IClass<any>): 
  InjectCtorArgDescriptor[] {
  Ass.defined(clazz, "clazz");
  let injectedArgDescriptors = 
    (clazz as any)[Junkie.classCtorInjectedArgsDescriptors] =
    (clazz as any)[Junkie.classCtorInjectedArgsDescriptors] || [];
  return injectedArgDescriptors;
}

/**
 * Save a descriptor for an injected ctor-argument.
 * @param clazz
 * @param argDescriptor
 */
static pushInjectedCtorArgDescriptor(clazz: IClass<any>,
  argDescriptor: InjectCtorArgDescriptor) {
  Ass.defined(clazz, "clazz");
  Ass.defined(argDescriptor, " argDescriptor ");
  let injectedArgs = Junkie.getInjectedCtorArgsDescriptors(clazz);
  injectedArgs.push(argDescriptor);
}

Falls es sich um eine Klasse handelt, wird – ähnlich wie beim Registrieren in der DI Registry – ein Hash aus der Klasse erzeugt: (generateInjectorKey()). Im Fall eines Symbols wird dieses in einen String umgewandelt. Die Ausführungsphase – also der Aufruf der vom Decorator retournierten Funktion – verwendet drei Schlüsselelemente:

  • Parameter target: Die Klasse der Methode des Parameters, in unserem Fall TestClass
  • Parameter paramInx: Der Parameterindex
  • Den Parametertyp: Er wird von der Metadatenfunktion Reflect.getMetadata() geliefert.

Falls beim @inject() ein Key mitangegeben wird, wird dieser verwendet. Wenn der Parametertyp eine ES6-Klasse ist, wird ein Hash Key generiert.

Schlussendlich werden die Informationen durch pushInjectedCtorArgDescriptor() in der Klasse gespeichert. Und Sie ahnen es: Genau diese Informationen ermöglichen es dem neuen constructor, die richtigen Parameter zu injizieren. (Hinweis: Der readonly Member Junkie.classCtorInjectedArgsDescriptors in Listing 5 enthält den String mr_injector_ctor_args).

Weitere Details: Der Klassen-Decorator

Als letztem Codedetail widmen wir uns dem Klassen-Decorator aus Listing 6.

/**
 * Class decorator for classes you need to inject into.
 * @returns a ctor function that will wrap the original ctor.
 *   The new ctor checks for all properties marked by {@link inject} 
 *   and looks up their value from the registry.
 */
export function injectionTarget() {
  return function <T extends IClass<any>>(constructorFunction: T) {

    // Generate nuew a ctor function.
    let injectedClass = class ExtendedByJunkieForDI 
      extends constructorFunction {
      
      constructor(...args: any[]) {
        // Get the arg descriptors from the 
        // class object (== constructorFunction)
        let ctorArgDescriptors =
          Junkie.getInjectedCtorArgsDescriptors(constructorFunction);
        if (ctorArgDescriptors) {
          // Augment the arguments array if needed.
          for (let descriptor of ctorArgDescriptors) {
            if (args.length - 1 < descriptor.paramInx) {
              for (let i = 0; 
                i < descriptor.paramInx - args.length; i++) {
                args.push(undefined);
              }
            }
            let value2Inject = Junkie.get(descriptor.key);
            // Only inject parameter if not yet set! 
            // => We can unit test class as usual.
            if (args[descriptor.paramInx] === undefined) {
              args[descriptor.paramInx] = value2Inject;
            }
          }
        }
        // Call original ctor.
        super(...args);
      }
    };
    // Save the original ctor function for debugging purposes.
    (<any>injectedClass).origCtorFunction = constructorFunction;

    // NOTE: "function.name" is readonly property. 
    // If we change it, the JS VM throws an error. 
    //      => Use "Object.defineProperty"
    // If we do not change the name, 
    // all classes will have the same name "ExtendedByJunkieForDI".  
    //      => Debugging nightmare, especially in reactjs, 
    // because all components have the same name!
    Object.defineProperty(injectedClass, 
      "name", 
      { writable: true, 
        value: `${constructorFunction.name}_ExtByJunkie4DI` }
    );
    return injectedClass;
  }
}

Hier gibt es wieder einen wichtigen TypeScript-Trick: Wenn der Klassen-Decorator eine Klasse zurückliefert, wird diese zum Konstruieren neuer Objekte verwendet. Das ist genau das, was wir brauchen. Aber schrittweise:

  • Wir erzeugen eine neue Klasse, die von der ursprünglichen erbt.
  • Der constructor dieser Klasse wird aufgerufen, wenn eine Instanz der dekorierten Klasse erzeugt wird, z. B. durch new TestClass().
  • Die Parameter werden als var-args-Array an den constructor übergeben.
  • Der constructor holt zuerst die Deskriptoren der zu injizierenden Argumente.
  • Pro Deskriptor wird der zu descriptor.key passende Wert aus der DI Registry gelesen.
  • Dieser Wert wird an den im Deskriptor angegebenen Parameterindex des varg-args-Arrays gesetzt. Das ist die eigentliche Injection.
  • Zu guter Letzt wird noch der Basis/Super-Constructor aufgerufen.

Bevor die neue Klasse retourniert wird, setzen wir eine Property name, die den ursprünglichen Klassennamen mit dem Suffix ExtByJunkie4DI enthält. Damit wird sichergestellt, dass man beim Debuggen leicht erkennen kann, um welche Klasse es sich wirklich handelt.

All together now

In den Abbildungen 2 und 3 sind noch einmal die detaillierten Abläufe beim Laden und beim Instanziieren der Klasse inklusive Code-Snippets zusammengefasst.

Abb. 2: Detaillierter Ablauf beim Laden der Klasse

Abb. 2: Detaillierter Ablauf beim Laden der Klasse

Abb. 3: Detaillierter Ablauf beim Instanziieren der Klasse

Abb. 3: Detaillierter Ablauf beim Instanziieren der Klasse

Fazit

TypeScript ist cool! Mit einer einfachen Registry und TypeScript Decorators lassen sich Klassen so manipulieren, dass man beim Instanziieren neue Werte in die Parameter ihrer Konstruktoren injizieren kann. Diese simple Dependency Injection genügt für viele Anwendungsfälle und lässt sich natürlich noch ausbauen.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -