Dependency Injection

Erhöhung der Testbarkeit von Java-Code


12.02.2021 / interne Präsentation pagina GmbH

Kai Weber / @fruehlingstag


[Leertaste für nächste Folie]

Was ist Dependency Injection?

  • ein Entwurfsmuster zur Entkopplung von Abhängigkeiten eines Objekts
  • Abhängkeiten eines Objekts werden erst zur Laufzeit durch eine zentrale Komponente verwaltet und realisiert:
    • im Gegensatz zu herkömmlicher OOP, wo jedes Objekt seine Abhängigkeiten selbst verwaltet
    • erhöht die Flexibilität, da Objekte weniger Annahmen über ihre Umgebung treffen müssen
  • fördert die Verwendung von Interfaces und damit die Verallgemeinerbarkeit von Funktionen
  • wird häufig (aber nicht zwingend) durch Verwendung von Frameworks realisiert (z. B. Spring)

Wozu soll das gut sein?

  • erhöht die Konfigurierbarkeit von Programmteilen
  • erhöht die Testbarkeit von Programmteilen
  • erhöht die Verallgemeinerbarkeit von Programmteilen
  • erhöht die Wiederverwendbarkeit und Wartbarkeit von Programmteilen
  • verringert die Abhängigkeit von Programmteilen von irrelevanten Implementierungsdetails
  • eignet sich gut für Code-Refactoring
  • eignet sich gut zur Abstraktion von Zugriffen auf Dateisysteme, Datenbanken, Internet-APIs

Was soll hier gezeigt werden?

  • die Grundprinzipien von Dependency Injection werden hier ohne Verwendung eines Frameworks an konkreten Beispielen verdeutlicht („manuelle“ Dependency Injection)
  • Beispiele für Unit Tests mit Dependency Injection
  • Beispiele für Refactorings von Legacy Code

Ein Beispiel:

betriebssystemspezifischer Code (1)

			                
import java.io.File;

public class Installer {
    // Pfad-Trenner (Windows: \   Mac und Linux: / )
	private final String sep = File.separator;
	private final String operatingSystem = System.getProperty("os.name");
	
	public String getMySpecialFilePath() {
	    switch(operatingSystem) ...
	    // hier steht betriebssystemspezifischer Code
	    return mySpecialFilePath;
	}
}
			                
			            

Frage: Wieso kann diese Funktion nicht mit Unit Tests getestet werden?

Ein Beispiel:

betriebssystemspezifischer Code (2)

  • Der zuvor gezeigte Programmcode lässt sich nicht (vollständig) automatisiert testen, da jedes Testframework auf einem konkreten Betriebssystem läuft.
  • Je nach Betriebssystem wird jeweils nur der Teil der Funktion getMySpecial­File­Path() getestet, der für das Betriebs­system, auf dem der Test gerade läuft, gedacht ist.
  • Ein Unit Test erwartet auf einer bestimmten Eingabe eine bestimmte Ausgabe - weicht die Ausgabe von der Erwartung ab, gilt der Test als fehlgeschlagen.
    • Sind als Rückgabewerte einer Funktion je nach Betriebssystem unterschiedliche Ausgaben korrekt, müsste der Unit Test so geschrieben werden, dass er je nach System unterschiedliche Ausgaben als korrekt betrachtet, z. B. C:\MyProgramFolder\MySubfolder vs. /opt/etc/MyProgramFolder/MySubfolder
    • Das erhöht den Aufwand beim Schreiben von Tests und löst immer noch nicht das Grundproblem, dass auf jedem System nur ein Teil der Funktion getestet werden kann.

Ein Lösungsbeispiel:

betriebssystemspezifischer Code (3)

			                
import java.io.File;

public class Installer {
	
	public String getMySpecialFilePath(String separator, String operatingSystem) {
	    switch(operatingSystem) ...
	    // hier steht betriebssystemspezifischer Code
	    return mySpecialFilePath;
	}
}
			            
  • die Funktion getMySpecialFilePath erhält ihre Abhängigkeiten nun als Parameter beim Aufruf, die Installer-Klasse muss sich nun nicht mehr selbst um die Ermittlung des Betriebssystems kümmern
  • es erfolgt eine „Inversion of control“: Nicht mehr die Installer-Klasse kontrolliert, auf welchem Betriebssystem sie ausgeführt wird, sondern derjenige, der die Funktion getMySpecialFilePath aufruft.

Beispiel Unit Tests:

betriebssystemspezifischer Code (4)

			                
@Test
public void testMySpecialFilePathOnMacOs() {
    assertEquals(getMySpecialFilePath("/", "macOS"), "/opt/etc/MyProgramFolder/MySubfolder");
}

@Test
public void testMySpecialFilePathOnWindows() {
    assertEquals(getMySpecialFilePath("\", "win"), "C:\MyProgramFolder\MySubfolder");
}
			                
			            

Jetzt können die Unit Tests für die Funktion mit systemspezifischem Verhalten auf jedem beliebigen Host-Betriebssystem ausgeführt werden.

Dependency Injection i.e.S.

Das gezeigte Beispiel verdeutlichte die „Inversion of Control“, die man mit DI erreichen will, war aber noch nicht DI im engeren Sinne.

In umfangreicheren Programmen bestehen die Abhängigkeiten, die man per DI verwalten möchte, zwischen Objekten (i. Ggs. zu einzelnen Konfigurationswerten wie im ersten Beispiel).

Als Daumenregel in OO-Programmiersprachen sollte man sich bewusst sein, dass bei jeder Verwendung des Schlüsselwortes new eine Abhängigkeit zu einer konkreten Klasse entsteht.

Bei DI versucht man dies so weit wie möglich zu vermeiden, verwendet Interfaces statt konkrete Klassen und injiziert die benötigten Objekte „von außen“.

Arten der Dependency Injection (1)

Constructor Injection

			                
interface FileSystemService {
    public String getPathSeparator();
    public File getFrameworkBaseDir();
    ...
}

interface SystemAccessService {
    public String getOperatingSystem();
    ...
}

class Installer {

    private FileSystemService fss;
    private SystemAccessService sas;
    
    public Installer (FileSystemService fss, SystemAccessService sas) {
        this.fss = fss;
        this.sas = sas;
    }
    
    public void install() {
        String frameworkDirectory = fss.getFrameworkBaseDir();
        switch(sas.getOperatingSystem()) {
            case("win"): 
                ...
                break;
            case("macOS"):
                ...
                break;
            case("linux"):
                ...
                break;
        }
    }
}
			                
			            

Arten der Dependency Injection (2)

Setter Injection (mit/ohne Interface)

			                
interface FileSystemService {
    public String getPathSeparator();
    public File getFrameworkBaseDir();
    ...
}

interface SystemAccessService {
    public String getOperatingSystem();
    ...
}

/* evtl. mit Injector-Interfacte */
interface ServiceSetter {
    public void setFileSystemService();
    public void setSystemAccessService();
}

class Installer /* evtl. mit implements ServiceSetter */ {

    private FileSystemService fss;
    private SystemAccessService sas;
    
    public Installer () {
    }
    
    public void setFileSystemService(FileSystemService fss) {
        this.fss = fss;
    }
    
    public void setSystemAccessService(SystemAccessService sas) {
        this.sas = sas;
    }
    
    public void install() {
        if(null == fss || null == sas) {
            throw new NullPointerException("cannot install, some required service object is not injected yet...");
        }
        
        String frameworkDirectory = fss.getFrameworkBaseDir();
        switch(sas.getOperatingSystem()) {
            case("win"): 
                ...
                break;
            case("macOS"):
                ...
                break;
            case("linux"):
                ...
                break;
        }
    }
}
			                
			            

Arten der Dependency Injection (3)

Autowiring (Beispiel: Spring Framework)

			                
interface FileSystemService {
    public String getPathSeparator();
    public File getFrameworkBaseDir();
    ...
}

interface SystemAccessService {
    public String getOperatingSystem();
    ...
}

@Component
class Installer {

    @Autowired
    private FileSystemService fss;
    
    @Autowired
    private SystemAccessService sas;
    
    public Installer () {
    }
    
    public void install() {
        String frameworkDirectory = fss.getFrameworkBaseDir();
        switch(sas.getOperatingSystem()) {
            case("win"): 
                ...
                break;
            case("macOS"):
                ...
                break;
            case("linux"):
                ...
                break;
        }
    }
}
			                
			            

Anwendungen verdrahten (1)

  • Damit Dependency Injection funktioniert, genügt es natürlich nicht, die Klassen entsprechend der bis hierher gezeigten Muster anzulegen.
  • Es muss beim Starten oder während der Laufzeit des Programms jemanden geben, der die Injektionen (bzw. die „Verdrahtung“ / das „Wiring“) durchführt.

Anwendungen verdrahten (2)

  • in selbst gebauter, „manueller“ Dependency Injection kümmert sich in der Regel die main-Methode darum
  • in einer Unit-Test-Umgebung schreibt man bei Bedarf Hilfsklassen zur Test-Initialisierung, die entsprechend testbar initialisierte Zustände herstellen
  • bei Verwendung eines Dependency-Injection-Frameworks genügen Konfigurationen, die z. B. als XML-Datei oder Java-Klasse mit Annotationen definiert sein können.
  • Frameworks mit stark vorgegebenen Konventionen („convention over configuration“) können ein Autowiring durch Annotation, Reflection und Auswertung des Java-Klassenpfads vornehmen.

Beispiel-Refactoring (1)

  • Ausgangszustand: im parsX-Installer gibt es viele statische globale Variablen, die zum Startzeitpunkt mit JVM-Systemvariablen belegt werden oder in betriebssystemspezifischer Weise Dateipfade zusammenbauen, auf die später zugegriffen wird.
  • Ziel: Per Dependency Injection für höhere Testbarkeit und bessere Kapselung von Funktionalität sorgen.
  • Erster Ansatz: Interfaces und Default-Implementierungen für Systemzugriffe schreiben (vgl. pagina GitLab [mit Login])
		                    
package de.paginagmbh.commons.systemaccess;

import java.io.File;
import java.io.IOException;
import java.util.logging.FileHandler;

/**
 * An interface for abstracting file system access operations
 *
 * The main purpose of this interface is to enable unit tests for functions that 
 * deal with the file system. In production environments the FileSystemImpl class
 * is meant to be used. In unit test environments, mock classes can implement the
 * interface to catch file system operations.
 *
 * @author      Kai Weber
 * @copyright   pagina GmbH, Tübingen
 */
public interface FileSystem {

	public FileHandler getFileHandler(String filePath) throws IOException;
	public File getFrameworkBaseDir(String frameworkBasePath);
	public String getInstallerJarDir() throws java.io.UnsupportedEncodingException;
	public File getInstallerJarLibDir(String installerJarDir);
	public File getInstallerLibDir(String installerBaseDir);
	public String getPathSeparator();
	public File newFileObject(String filePath);
	public File newFileObject(File parent, String child);
	public File newFileObject(String parent, String child);
}

package de.paginagmbh.commons.systemaccess;

/**
 * An interface for abstracting access to environment variables of the system
 * (JVM or operating system)
 *
 * The main purpose of this interface is to enable unit tests for functions that 
 * deal with system properties. In production environments the SystemPropertiesImpl
 * class is meant to be used. In unit test environments, mock classes can implement 
 * the interface to provide properties without actual system access.
 *
 * @author      Kai Weber
 * @copyright   pagina GmbH, Tübingen
 */
public interface SystemProperties {

	public String getGenericProperty(String key);
	public String getUserHomeDir();
}

			                
			            

Beispiel-Refactoring (2)

To Dos

  • der parsX-Installer setzt massiv auf statische Funktionen und öffentliche globale statische Variablen
  • der parsX-Installer muss nach objektorientierten Kriterien umgebaut werden; die Hauptarbeit sollte an ein objektorientiert gekapseltes Installer-Objekt übergeben werden
  • diesem Installer-Objekt werden Implementierungen der Systemzugriffs-Interfaces injiziert

XSLT? (1)

Die Frage die alle paginist:innen interessiert: Und was ist mit XSLT?

  • Dependency Injection ist ein Konzept aus der Welt der objekt­orientierten Program­mie­rung, XSLT ist keine objektorientierte Sprache: Eine 1:1-Anwendung von DI auf XSLT ist nicht möglich
  • aber ganz allgemein kann DI dazu anregen, sich über Abhängigkeiten von Programm­teilen Gedanken zu machen
  • Templates und Funktionen, die nur mit ihrem aktuellen Kontextknoten und Parametern arbeiten, haben keine Abhängigkeitsprobleme und sind somit unproblematisch
  • Bei Templates und Funktionen, welche globale Variablen oder Systemzugriffe (document()-Funktion u.ä.) verwenden, lohnt sich ein genauer Blick:
    • Wann, wie und von wem werden die Variablen belegt? Ist das hart codiert, oder kann man die Belegung flexibel von außem steuern?
    • Sind document()-Zugriffe hart codiert oder parametrisiert?

XSLT? (2)

Ende

Fragen? Diskussionen?

Literaturtipp: Martin Fowler, Inversion of Control Containers and the Dependency Injection Pattern.