Dependency Injection mit JavaScript

Wissen Blog

Cover-image by https://unsplash.com/@epan5

Constructor Injection mit Hilfe von JavaScript-Proxies: Hartcodierte Abhängigkeiten vermeiden und Algorithmen als austauschbare Strategien über einen Inversion of Control-Container bereitstellen. 

Motivation 

High cohesion und low coupling implizieren nicht, daß Assoziationen komplett vermieden werden: Abstraktionen in Programm-Code erfordern konkrete Implementierungen, damit Verträge zwischen Schnittstellen eines Systems erfüllt werden können: Wenn es nichts gibt, was mit einer Schnittstelle konkret kommunizieren kann, dann funktioniert ein Programm auch nicht. 

Abbildung 1 Von einem Computerprogramm erwarten wir in der Regel, daß Input auch Output erzeugt. 

Wenn wir uns auf Assoziationen zwischen Programmteilen einigen, und damit notwendigerweise Abhängigkeiten einführen, dann sollten wir bestrebt sein, diese Assoziationen möglichst wartbar und auch austauschbar zu implementieren. Allzu oft hat nämlich die Art und Weise, wie diese Abhängigkeiten im Quellcode verdrahtet sind, einen bitteren Beigeschmack: Tief verschachtelt verletzen sie meistens die Prinzipien von SRP und DIP

Wir brauchen Tools und Entwurfsmuster, die uns beim Entwirren des Codes helfen können. Sonst laufen wir auch Gefahr, komplizierten Boilerplate-Code zum Einrichten von Mocks und Stubs zum Testen schreiben zu müssen, oder schlimmer noch: Die Integration neuer Funktionen wird wegen unübersichtlicher Zwischenabängigkeiten durch ungewollte Seiteneffekte zum Albtraum. Zugegeben, Sprachen wie JavaScript machen es einfach, im Programmcode genutzte Abhängigkeiten zu mocken, aber andere Sprachen sind nicht so nachsichtig und Testfälle werden tendenziell komplizierter, je mehr Abhängigkeiten auch gemockt werden müssen. 

Der folgende Quellcode wurde einer REPOSITORY-Implementierung entnommen: Hier wird eine konkrete Storage-Klasse verwendet, die die Infrastruktur vor dem Client verbirgt, die mit dem eigentlichen Datenspeicher gekoppelt ist: 

 

class DataRepository { 

     async storeData(data) { 

          const {Storage} = await import("storageApi"); 
         
          const store = new Storage(...); 
    
          store.save (data); 
     } 
} 

 

Es scheint auf den ersten Blick den (persönlichen) Standards zu entsprechen, die wir mit sauberer Programmierung verbinden: Aussagekräftiger Code, der seine Absichten klar und ohne zusätzliche Kommentare vermittelt, und der an ein Unterprogramm delegiert, das für die Kommunikation mit der Infrastruktur verantwortlich ist. 

Diese Methode verwendet jedoch eine fest verdrahtete Abhängigkeit: storageApi wird als Modul importiert. Darüber hinaus wird alles, was der constructor von Storage benötigt, innerhalb der storeData()-Methode behandelt: Zum Testen dieses Codes muss der Entwickler nicht nur einen Mock für Storage erstellen, sondern auch den Importaufruf zu diesem Modul mit seinem Testcode in Einklang bringen: Das DataRepository greift direkt auf die Low-Level-API zu, obwohl seine Grenzen von diesem klar abgesteckt sein sollten: Das führt zu einer an dieser Stelle unnötigen und starken Kopplung zwischen zwei verschiedenen Schichten - auch wenn das DataRepository natürlich weiß, daß es auch eine Infrastruktur-Schicht geben muß. 

Abbildung 2 Das DataRepository greift direkt auf Storage zu, und erzeigt so eine starke Kopplung an dieser Stelle. 

Das Dependency Inversion Principle erfordert, dass High-Level-Module nichts aus Low-Level-Modulen importieren. Dies entspricht auch den Prinzipien von SOLID

Abbildung 3 Das DataRepository wird mit der Storage-Instanz konfiguriert. Jetzt kann sich das Repository auf die Verwendung der public API von Storage konzentrieren und muss sich nicht um den Import und die Konfiguration kümmern. In Tests lässt sich die API der Storage-Instanz leicht ersetzen, falls nötig. 

Dieser Artikel stellt einen Inversion of Control (IoC)-Container vor, der während der Laufzeit entscheidet, ob vorhandene Abhängigkeiten vom IoC-Container aufgelöst werden sollen - und können. Dies wird unterstützt durch Bindings, die von einem Client konfiguriert und an den IoC-Container übergeben werden: Sie liefern Informationen für die konkrete Implementierung basierend auf einem Typ, der von dem Host erwartet wird. Dieser Typ ist ("vertragsgemäß") zunächst nur als Interface oder als beliebige (abstrakte) Klasse vorhanden und muß noch zur Verfügung gestellt werden. 

Bindings können für die Anwendung auch während der Laufzeit angepasst werden, aber es empfiehlt sich, sie während des Bootstrappings zu initialisieren. Das macht es leichter, Programme mit unterschiedlichen Implementierungen für ausgewählte Clients, Kontexte oder Umgebungen auszuführen. Das alles funktioniert ohne Anpassungen im Low- oder High-Level-Code. Unter Umständen muß nur die Konfiguration geändert werden. 

Abbildung 4 Unser Proxy stellt sich vor die Konstruktoren ausgewählter Zielklassen und fügt Abhängigkeiten nach Bedarf als Argumente ein – deshalb Constructor Injection

Proxys (sic!) können helfen, Abhängigkeiten zur Laufzeit aufzulösen, und dieser Ansatz ist nicht exklusiv nur JavaScript vorbehalten: Proxys sind schon lange ein bewährtes Entwurfsmuster, wenn Stellvertreterobjekte realisiert werden sollen. Des weiteren existieren sie bspw. auch in Java, und Spring verwendet sie für seinen IoC und AOP

Ein ausführlicher Artikel über JavaScript-Proxys und wie man mit ihrer Hilfe Promises als Fluent Interface verwenden kann, findet sich hier. Der Artikel eignet sich auch gut zum Einstieg, wenn man mit dem Konzept von Proxys noch nicht so vertraut ist.  

Für die folgenden Beispiele werde ich häufiger auf die IoC-Container-Implementierung von coon.core.ioc verweisen: Dies ist eine Implementierung, die Sencha Ext JS-spezifisch ist, aber das verwendete Konzepte sowie Teile der vorgestellten Implementierung können leicht auf andere Frameworks (aber auch Framework-agnostischen Code) übertragen werden. 

Die Anforderungen

Zielklassen müssen selber angeben, ob sie injizierbar sind: Damit weiß der IoC-Container, ob der verwendete Proxy bei der Instanziierung einer Zielklasse mitwirken soll. Dies ist also notwendig, weil Abhängigkeiten von außen erzeugt werden sollen, damit der betroffene Source nicht abgeändert werden muß. Der IoC-Container wird während des Hochfahrens der Anwendung mit Bindings konfiguriert und kümmert sich dann um das Verteilen der konkreten Implementierungen der vorliegenden Typen während der Laufzeit der Anwendung. 

Abbildung 5 Der IoC-Container kümmert sich um die Auflösung von Abhängigkeiten für eine konkrete Instanz zur Laufzeit 

Doch wie sollen Informationen, die eine Zielklasse über sich selber preisgeben muß, bekanntgegeben werden? In unserem gewohnten Umfeld stehen solche Informationen ja meist erst nach Auflösung bzw. Instanziierung zur Verfügung. 

Viele Programmiersprachen bieten Werkzeuge für den Umgang mit solchen zusätzlichen Meta-Informationen: Diese Metadaten werden häufig mit Hilfe von Annotations (in Java) oder Attributes (bspw. PHP8) erstellt. 

 

Metadaten: Statische Builds vs. Laufzeitkonfiguration 

Bei der schier unüberschaubaren Menge an Tooling-Möglichkeiten für JavaScript (vgl. https://npmjs.org) würde die Verwendung von Annotations wahrscheinlich wenig Implementierungs-Aufwand bedeuten; Allerdings würde es auch heißen, dass sich der Build-Stack unseres Ziel-Projekts ändert: Ein zusätzliches Tool, das die Implementierung zum Parsen unseres Quellcodes enthält, würde auch Metadaten extrahieren und übersetzen. Natürlich müßten wir unsere Tests anpassen. Letztendlich geht es darum, einen stabilen und sicheren Build für den Produktivbetrieb zu erhalten, der auch auf Basis gegebener Metadaten funktioniert.

Folgende Annotation wäre denkbar: 

 

/**
 * @injectable store:Storage
 */
class Repository {
   ...
}

 

Zusätzliche Annotations könnten von uns gepflegt werden, und diese könnten regelbasiert abgearbeitet werden. 

Das Tool, das auch den Parser beinhaltet, könnte dann Konfigurationsdateien aus den in den Quellen gefundenen Metadaten erstellen, sie mit den Namen der Zielklassen (und den Pfaden zu den Importen) und den Eigenschaften (d.h. in unserem Fall: Den Namen der Instanzvariablen der Zielklassen) mappen. Das Ersetzen findet dann zur Laufzeit über die von uns konfigurierten und im IoC-Container gespeicherten Bindings statt. 

 

Abbildung 6 Die Verwendung von Anmerkungen mit JavaScript-Code würde einen zusätzlichen Build-Step erfordern. 

Wir streben eine Implementierung an, die solche zusätzlichen Tools nicht benötigt, auch, um Entwicklungsversionen ohne zusätzliche Build-Roundtrips zu ermöglichen: Wir werden solche Metadaten in Form von statischen Eigenschaften den injizierbaren Klassen zur Verfügung stellen. 

Der folgende Quellcode demonstriert die Verwendung dieser static property: Eine Eigenschaft namens required ist die Eigenschaft, die Metainformationen enthalten soll. Diese wird vom IoC-Container und seinem DependencyResolver berücksichtigt: Sie enthält alle Namen der Instanzvariablen, die von außen konfiguriert werden können, und enthalten einen Hinweis auf den erwarteten Typ der Instanz. In dem Beispiel funktioniert eine Instanz von Repository nur mit einem store-Member, der eine Referenz auf eine Instanz von Storage enthält. 

 

class Repository {
   static: {
      required: {
        store: "Storage"
      }
   }

   ...
}

 

Mit Hilfe der Proxy-Api können wir dann einen sogenannten Trap für den Konstruktor der Zielklasse, in diesem Fall der Repository-Klasse, registrieren: 

 

class Repository {
   static: {
      required: {
        store: "Storage"
      }
   }

   constructor({store}) {
       this.store = store;
   }
   
   ...
}


const constructorHandler = {

    construct (target, argumentsList, newTarget) {
        if (target.required) {
            // container holds a reference to the ioc-container
            container.inject(argumentsList, target.require); 
        }      

        return new target(...argumentsList);
    }

};


Repository = new Proxy(Repository, constructorHandler)

 

Der Handler delegiert an den IoC-Container, bevor die Instanz der Zielklasse erstellt wird: Der IoC-Container sucht dann in der Argumentliste nach fehlenden Eigenschaften in einem zuvor vertraglich festgelegten Argumentobjekt, das zum Konfigurieren der Instanz verwendet werden soll. Der Name der Instanzvariablen, der in der required-Eigenschaft angegeben ist, muss mit dem Eigenschaftsnamen identisch sein, den der Konstruktor auswertet: 

 

// IoC-container will not inject anything, since the instance gets configured
// with a "store"-property
new Repository({store: new Storage(), uri: "/resourceUri"});


// since the "store"-property is missing, the IoC-container will
// inject a concrete of "Storage" according to the available bindings
new Repository({, uri: "/resourceUri"});

 

  

Die Bindings konfigurieren 

Bindings repräsentieren einen Point of Truth in unserer Anwendung, da sie die Builder und Resolver veranlassen, sich um die Instanziierung von Assoziationen zu kümmern. Bindings bilden konkrete Implementierungen auf bestimmte Typen ab, das bedeutet also: Sie binden eine typisierte Variable (typisiert im Sinne von über die required-Property beschriebene Konfiguration) mit einer erwarteten konkrete Instanz dieses Typs, damit der IoC-Container weiß, was wo wie anzuwenden ist (das Wann wird durch die Verwendung des ConstructorInjectors impliziert).  Wir erwarten also einen Repräsentanten eines Interfaces oder einer (abstrakten) Klasse, und das LSP gibt uns die Freiheit, eine beliebige Implementierungen desselben bereitzustellen. 

 

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T. — Barbara LiskovData Abstraction and Hierarchy 

Da wir keine strikte Typisierung in JavaScript haben, muss unser DependencyResolver (eine Form von Builder) sicherstellen, dass unsere bereitgestellten Bindings tatsächlich Instanzen des jeweils erforderlichen Typs enthalten. 

  

Eine gemeinsame Sprache finden 

Wir werden eine Art Modellsprache einführen, die uns bei der Formulierung der Bindings helfen wird. 

Wir haben eine Klasse A, die eine Instanz einer Klasse B verwendet: 

Abbildung 7 A benötigt B.

Der Code für  

A hat eine Abhängigkeit von Typ B, und diese Abhängigkeit soll in der Instanzvariable b von A referenziert werden.“ 

könnte so aussehen: 

 

// Pseudo code

abstract class B {
    abstract calculate();
}


class A {

    constructor (B b)
    {
      this.b = b;
    }


    calculation()
    {
        this.b.calculate();
    }
} 

 

Offensichtlich muß [Instanz von A].b gegeben sein — sobald calculation() an b.calculate() delegiert und wenn b undefined ist, wird eine Exception geworfen. 

Wir suchen nach einer formalen (aber einfachen) Sprache, um diese Abhängigkeiten als Konfiguration zu definieren: Wir einigen uns auf JSON als Format, um Schlüssel/Werte-Paare zu speichern. Schlüssel sind vom Typ String und Werte können String, Integer, Boolean, NULL, Object und Array sein – wir werden hauptsächlich String und Object verwenden. 

Wir schauen uns noch einmal die Anforderungen detaillierter an: 

 

when A
  requires B
  give new instance of B

 

Das ist eine recht einfache Anforderung, die später vom Dependency Resolver als Grundlage für eine Zuweisung verwendet wird. In JSON sieht es folgendermaßen aus (die erläuternden Kommentare werden nicht in die JSON-Datei überführt): 

 

 {
    /* when */
    "A": {
      /* "needs": "give" */ 
      "B" : "InstanceOfB" 
    }   
}

 

 

Use Case: Austausch von Authentifizierungsmethoden

Mit coon.core.ioc als Teil einer coon.js-Anwendung ist hier ein typischer Aufruf zu coon.core.ioc.Container.bind()

 

// Some class names have been shortened in favor of
// readability.
coon.core.ioc.Container.bind({
        "conjoon.dev.cn_mailsim": {
            "conjoon.SimletAdapter": "conjoon.BasicAuthSimletAdapter"
        },
        "conjoon.cn_mail": {
            "coon.core.data.request.Configurator": {
                "$ref": "#/$defs/RequestConfiguratorSingleton"
            }
        },        
        "$defs": {
            "RequestConfiguratorSingleton": {
                "xclass": "conjoon.cn_imapuser.data.request.Configurator",
                "singleton": true
            }
        }

});

 

Diese Konfiguration beinhaltet Bindings des extjs-app-imapuser-Package: Es ermöglicht die Benutzerauthentifizierung für extjs-app-webmail von conjoon bereitstellt, einem in JavaScript geschriebenen E-Mail-Client. 

Wenn das Package extjs-app-imapuser mit conjoon verwendet wird, müssen die User-Credentials über diesen Login-Screen bekanntgegeben werden. 

extjs-app-webmail kommuniziert mit einem Backend, dem die verwendete Authentifizierungsmethode unbekannt ist – es weiß nur, daß ein Request entsprechenden Sicherheitsprüfungen unterzogen werden muß.

Diese Architektur ermöglicht das Absichern von Endpoints mit beliebigen Authentifizierungsmethoden. So könnte eine einfache Zugriffsauthentifizierung basierend auf Basic Autentication stattfinden, oder die API kann einen Guard verwenden, der tokenbasierte Requests überwacht. Aus diesem Grund muss der anfragende Client – in diesem Fall extjs-app-webmail – entsprechend des Backends die Requests anpassen. Dies geschieht durch die Verwendung eines sogenannten RequestConfigurator, der sich in (einige/alle/keine) ausgehende Requests einklinkt und weitere Informationen hinzufügen kann: So kann leicht ein Authorization-Header-Feld mit Bearer-, Basic- oder anderen Informationen hinzugefügt werden, ohne, daß dies in der Basisimplementierung des Webmail-Clients bekanntgegeben werden muß. 

 

Bindings im Detail 

Wir schauen uns einmal die gegebenen Bindings genauer an. Zunächst fällt auf, daß hier die Bindings mit Namensräumen anstelle von Klassennamen eingeführt werden: Dies bietet die Möglichkeit, Bindings modulumfassend zu konfigurieren, anstatt die einzelnen Klassen anzugeben, für die diese Informationen benötigt werden. Anstatt also einzeln Abhängigkeiten für 

 

conjoon.dev.cn_mailsim.A, 
conjoon.dev.cn_mailsim.B, 
conjoon.dev.cn_mailsim.C, …

 

zu definieren, greifen wir hier auf ihren gemeinsamen Namespace zurück, sodass der Dependency Resolver die Bindings auflösen kann, wenn die Zielklasse nicht explizit in der Konfiguration angegeben ist.

Natürlich haben Zielklassen immer Vorrang, damit eine Namespace-Konfiguration nicht die feiner ausgearbeiteten Konfigurationen für spezifische Klassen überschreibt. 

Der nächste Abschnitt zeigt, daß “give” nicht den Namen einer Klasse beinhaltet: Es ist eine Konfiguration, die auf einen anderen Abschnitt der Konfiguration verweist. 

 

  "conjoon.cn_mail": {
      "coon.core.data.request.Configurator": {
         "$ref": "#/$defs/RequestConfiguratorSingleton"
     }
  }

 

Basierend auf der JSON-Schema-Spezifikation verwendet $ref einen URI, um auf einen anderen Abschnitt des Dokuments zu verweisen. Dadurch können komplexe Konfiguration einmal erstellt und an anderer Stelle referenziert werden. 

Das (aufgelöste) $ref im obigen Beispiel besagt: 

 

when any class of conjoon.cn_mail
  requires coon.core.data.request.Configurator
  give Singleton of conjoon.cn_imapuser.data.request.Configurator

 

An dieser Stelle verwenden wir Singletons, weil unsere Zielinstanzen zustandslos sein werden. Wir reduzieren durch die Wiederverwendbarkeit den Speicherbedarf in unserer Anwendung. 

Abhängigkeiten auflösen – Factory trapping! 

Das Klassensystem von Sencha Ext JS nutzt überwiegend Factories, um Instanzen von Klassen zu erzeugen. Dies ist nützlich für das dynamische (Nach-)Laden von Abhängigkeiten: Der Microloader kümmert sich um die Abbildung der Klassennamen auf die vorhandene Verzeichnisstruktur eines Projekts. Allerdings erfolgt das Laden synchron, weshalb man Nachladen zur Laufzeit vermeiden sollte.   

Abbildung 8 Sencha Ext JS Factory-Methoden kümmern sich um das Auflösen von Abhängigkeiten und das Instanziieren von Objekten. 

Die Verwendung von Factorys und Factory-Methoden im gesamten Framework macht es jedoch einfach, Constructor Injection mit Proxys zu realisieren: Die Auswahl eines Konstruktors einer injizierbarer Zielklasse kann automatisiert erfolgen, wenn wir unseren Proxy vor die entsprechenden Methoden der Factories schalten. 

Ein Wrapper-Proxy wird installiert, sobald coon.core.ioc.Container.bind() aufgerufen wird: 

 

 installProxies () {
        const me = this;

        Ext.Factory = new Proxy(
            Ext.Factory, 
            Ext.create("coon.core.ioc.sencha.resolver.FactoryHandler")
        );

        Ext.create = new Proxy(
            Ext.create, 
            Ext.create("coon.core.ioc.sencha.resolver.CreateHandler")
        );
    },

 

Für unsere Umsetzung benutzen wir zwei verschiedene Proxy-Handler: 

 Handler für Ext.create 

 Der CreateHandler ist eine Methode um Aufrufe zu Ext.create() abzufangen. Es überprüft das an Ext.create() übergebene Argument wie folgt:  

  • Wenn das Argument ein String ist, wird davon ausgegangen, dass es sich um den Namen der Klasse handelt, für die eine Instanz erstellt werden soll 

  • Wenn das Argument ein Objekt ist, bedeutet das, dass der Client eine Konfiguration übermittelt, die eine xtype-Property oder eine xclass-Property enthält. In diesem Fall enthält das Objekt auch alle weiteren Informationen, die für die Konfiguration der ZIelinstanz benötigt werden

{
    "xtype": "alias-of-class",
    // or "xclass": "fqn.of.class"
    "cArg1": "foo",
    "cArg2": "bar"  
}

 

Wie Abbildung 9 zeigt, löst der Handler das classresolved-Event aus und sendet Information, die den Klassennamen und den JavaScript-Prototyp der aufgelösten Klasse beinhalten. 

Abbildung 9 Der für Ext.create installierte Handler löst ein Ereignis aus, sobald die Anforderung an eine Klasse erfolgreich aufgelöst wurde. 

Handler für Ext.Factory 

 Der FactoryHandler implementiert Traps für  

  • ... Eigenschaften, die von einem Client angefordert werden. Hierzu wird intern get() vorgeschaltet und aufgerufen 

  • ... jede Methode, die möglicherweise eine Factory-Methode sein könnte. Hierzu wird die vor Funktionen vorgeschaltete apply() Methode aufgerufen. 

Factory-Methoden in Ext JS basieren auf Typen, die über Klassendefinitionen veröffentlicht werden. Aliase werden durchweg in dem Framework verwendet, um bspw. Lazy Instantiation zu realisieren. Diese Aliase verwenden Präfixe, die die Domäne darstellen, zu denen die Repräsentanten funktional einzuordnen sind, z. B. haben Aliase für Ext.data.Store das Präfix store, Ext.app.Controller verwenden das Präfix controller usw. 

Ext.Factory verdrahtet diese Konfigurationen mit den entsprechenden Factory-Methoden, und hier kommt ein vom get()-Handler installierter apply()-Handler ins Spiel: Er funktioniert ähnlich wie der CreateHandler - am Ende löst dieser Proxy das classresolved-Ereignis aus mit entsprechenden Informationen über die Zielklasse. 

 

if (cls) {
    const className = Ext.ClassManager.getName(cls);
    me.fireEvent("classresolved", me, className, cls);
}

 

 

Constructor Proxying 

Es läuft alles auf den ConstructorInjector hinaus: Sobald das classresolved-Ereignis abgeschickt wurde, kann ein entsprechender Observer den ConstructorInjector verwenden, um zu entscheiden, ob Abhängigkeiten in den Konstruktor der Zielklasse injiziert werden sollen: Er prüft, ob die Klasse injizierbar ist, und wenn dies der Fall ist, wird der vorher über den Proxy registrierte Trap für den Konstruktor verwendet. 

Das ganze macht sich die Eigenschaften des Strategy-Pattern zunutze, das es uns ermöglicht, Implementierungsdetails zur Laufzeit auszutauschen. Beachtet werden muß allerdings, dass der ConstructorInjectormit einem Objekt arbeitet, das als Argument an den Konstruktor übergeben wird, und nicht mit einer Liste von Argumenten. Der ConstructorInjector ist also ein Property Injector: Wir benutzen den Namen ConstructorInjector hier, da dadurch der Einsatzzweck verdeutlicht werden soll - und damit auch das o.a. Wann

Der Trap für den Konstruktor untersucht die Zielklasse nach allen erforderlichen Abhängigkeiten (definiert als Metainformationen in der vorher vereinbarten required-Property) und verwendet dann den Dependency Resolver, um aus den Bindings, die zuvor mit coon.core.io.Container.bind() registriert wurden, eine Zielinstanz mit allen benötigten Abhängigkeiten zu erstellen. 

Abbildung 10 Wenn der Client eine neue Instanz anfordert, stellt der ConstructorInjector sicher, dass die von der Zielinstanz benötigten Abhängigkeiten erstellt und zugewiesen werden, falls nicht bereits durch die Zielinstanz selber gegeben

Anmerkungen 

Das Repository für coon.js und die IoC-Container-Implementierung befindet sich auf Github. Es wird bereits in der neuesten Version von conjoon verwendet, unserer Referenzimplementierung für npm-basierte Sencha Ext JS Projekte.  

Der Artikel wurde ursprünglich in Englisch veröffentlicht und ist hier zu finden.

Thorsten Suckow-Homberg

Full Stack Senior bei eyeworkers
kontakt@eyeworkers.de
+ 49 721 183960