# Dependency injection

Minulú kapitolu o singletone sme ukončili poznámkou, že existuje len veľmi málo jeho validných použití a za normálnych okolností sa mu treba vyhnúť. To nás však vracia k pôvodnej otázke - ako vyriešiť problematiku tried, ktoré môžu mať málo, resp. nanajvýš jednu existujúcu inštanciu v danom čase? Odpoveďou je princíp inverzie kontroly (angl. inversion of control) a návrhový vzor vkladanie závislostí (angl. dependency injection), ktorý je na ňom postavený.

Poznámka

Po prečítaní tejto kapitoly a neskôr aj kapitoly stratégia zistíte, že tieto návrhové vzory sa veľmi podobajú - z pohľadu kódu sú dokonca totožné. Treba si však uvedomiť, že rozdiel je v probléme, ktorý sa snažia oba vzory vyriešiť. Cieľom dependency injection je extrahovanie manažmentu a vytvárania závislostí mimo tried, ktoré ich len potrebujú využívať a nemali by sa starať o to, ako sú vytvárané - možnosť vymienať implementácie je len bonus. Naopak, cieľom stratégie je poskytnúť modulárny spôsob na zmenu správania závislých tried.

# Modelová situácia

Pre túto modelovú situáciu máme za úlohu vytvoriť systém na správu vyrovnávajúcej pamäte (cache) v súborovom systéme operačného systému. Podmienkou je pri tom nepoužiť zabudované Java IO alebo NIO API, ale využiť konzolové rozhranie hosťovského operačného systému. Zatiaľ neimplementovaná trieda Cache vyzerá nasledovne:

public class Cache {

    public boolean save(String key) throws IOException;

    public boolean delete(String key) throws IOException;
}

V rámci tejto ukážky nebudeme v súboroch ukladať žiadne dáta, len ich vytvoríme so správnym menom a cestou podľa kľúča. Kľúč key je oddelený bodkami, kde jednotlivé úrovne reprezentujú priečinky. Napr. app.settings.language by sa uložilo ako súbor language v adresáte app/settings. Pre začiatok vytvorme metódu, ktorá spustí ľubovoľný príkaz v operačnom systéme a vráti nám informáciu o jeho úspešnosti:

public class Cache {

    private boolean runCommand(String... commandParts) throws IOException {
        Process process = new ProcessBuilder()
            .command(commandParts)
            .start();
        int exitCode;
        try {
            exitCode = process.waitFor();
        } catch (InterruptedException e) {
            throw new RuntimeException("Failed to get exit code for command", e);
        }
        return exitCode == 0;
    }

    // rest omitted for brevity
}

Používame zabudovaný ProcessBuilder, ktorému najprv predáme príkaz (ktorý sa môže skladať z ľubovoľného počtu reťazcov, pričom každý reťazec reprezentuje príkaz/argument). Volaním metódy start proces okamžite spusíme a metódou waitFor počkáme na jeho ukončenie a prečítame jeho výstupný kód. Úspešný proces vráti 0, akákoľvek iná hodnota signalizuje problém.

Následne potrebujeme vytvoriť metódu na ukladanie do vyrovnávajúcej pamäte. Tá je v princípe jednoduchá - vstupný kľúč sa podľa regulárneho výrazu \. rozdelí na časti po bodkách a skontroluje sa, či ide o vnorenú hodnotu. Ak áno, vytvorí sa jej príslušný adresát a následne sa v ňom vytvorí prázdny súbor. Ukážka ráta s tým, že systém je Unix alebo podobný a preto používa príkazy mkdir (s parametrom -p, ktorý zároveň vytvorí aj neexistujúce podpriečinky) a touch:

public class Cache {

    public boolean save(String key) throws IOException {
        String[] parts = Objects.requireNonNull(key).split("\\.");

        if (parts.length > 1) {
            String[] directoryParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
            String directoryPath = String.join("/", directoryParts);
            runCommand("mkdir", "-p", directoryPath);
        }

        String cachePath = key.replace(".", "/");
        return runCommand("touch", cachePath);
    }

    // rest omitted for brevity
}

Opačná metóda na zmazanie je podstatne jednoduchšia - tá len odstráni súbor podľa kľúča a priečinok nechá v pôvodnom stave:

public class Cache {

    public boolean delete(String key) throws IOException {
        String cachePath = key.replace('.', '/');
        return runCommand("rm", cachePath);
    }

    // rest omitted for brevity
}

Keď sa pozrieme na celú triedu, ktorú sme vytvorili, zistíme, že rieši priveľa vecí - nielen správu vyrovnávajúcej pamäte, ale aj rozhranie s operačným systémom. To má mnoho nevýhod - testovanie je ťažšie, údržba je náročnejšia a modulárnosť je nulová. Preto z triedy Cache vyberieme komunikáciu s operačným systémom a presunieme ju do osobitnej triedy s názvom UnixLikeTerminal:

public class UnixLikeTerminal {

    public boolean createDirectory(String directoryPath) throws IOException {
        return runCommand("mkdir", "-p", directoryPath);
    }

    public boolean createFile(String filePath) throws IOException {
        return runCommand("touch", filePath);
    }

    public boolean removeDirectory(String directoryPath) throws IOException {
        return runCommand("rm", "-rf", directoryPath);
    }

    public boolean removeFile(String filePath) throws IOException {
        return runCommand("rm", filePath);
    }

    private boolean runCommand(String... commandParts) throws IOException {
        Process process = new ProcessBuilder()
            .command(commandParts)
            .start();
        int exitCode;
        try {
            exitCode = process.waitFor();
        } catch (InterruptedException e) {
            throw new RuntimeException("Failed to get exit code for command", e);
        }
        return exitCode == 0;
    }
}

Z triedy pre správu vyrovnávajúcej pamäte tak môžeme odstrániť prebytočný kód a nahradiť ho volaním inštancie terminálu:



 







 



 




 



public class Cache {

    private final UnixLikeTerminal terminal = new UnixLikeTerminal();

    public boolean save(String key) throws IOException {
        String[] parts = Objects.requireNonNull(key).split("\\.");

        if (parts.length > 1) {
            String[] directoryParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
            String directoryPath = String.join("/", directoryParts);
            terminal.createDirectory(directoryPath);
        }

        String cachePath = key.replace(".", "/");
        return terminal.createFile(cachePath);
    }

    public boolean delete(String key) throws IOException {
        String cachePath = key.replace('.', '/');
        return terminal.removeFile(cachePath);
    }
}

Môže sa zdať, že trieda Cache je plne izolovaná od UnixLikeTerminal, avšak stále tomu nie je celkom tak - Cache si naďalej interne rieši vytváranie inštancie, čo znamená, že je plne v jej moci - s akými parametrami inštanciu vytvorí, či ju obalí napr. do adaptéra a podobne. Našim cieľom je však to, aby sa vytváranie terminálu dostalo o úroveň vyššie a správa vyrovnávajúcej pamäte už konkrétnu inštanciu terminálu len konzumovala. To sa dá urobiť tak, že vytvoríme konštruktor, ktorý prijme už vytvorený UnixLikeTerminal a ten si uloží ako vnútornú premennú. Po zmene to bude vyzerať nasledovne:



 

 
 





public class Cache {

    private final UnixLikeTerminal terminal;

    public Cache(UnixLikeTerminal terminal) {
        this.terminal = terminal;
    }

    // rest omitted for brevity
}

To nám umožní vytváranie terminálu mimo správy vyrovnávajúcej pamäte a dáva nám možnosť prispôsobiť ho podľa situácie, napr. inými parametrami konštruktora, resp. aplikovaním ďalších štrukturálnych návrhových vzorov:

 


 


> UnixLikeTerminal terminal = new UnixLikeTerminal();
Defined field UnixLikeTerminal terminal = sk.tuke.fei.kpi.oop.chapters.creational.dependencyinjection.UnixLikeTerminal@36f78abc

> Cache cache = new Cache(terminal);
Defined field Cache cache = sk.tuke.fei.kpi.oop.chapters.creational.dependencyInjection.Cache@efa967a1

Tento prístup sa nazýva vkladanie závislostí cez konštruktor, angl. constructor dependency injection.

# Vkladanie závislostí cez argument metódy

Niekedy celá trieda nepotrebuje závislosť na inom komponente, ale len jej časť (metóda), resp. metóda má možnosť akceptovať rôzne implementácie komponentu bez toho, aby sa museli vytvárať iné triedy. Taký čas je možné závislosť posunúť metóde ako argument - hovoríme o vkladaní závislostí cez metódu, angl. method dependency injection. Mení sa len miesto vkladania (a zároveň fakt, že si závislosť neukladáme), no princíp ostáva rovnaký.

# Best practices

  • Tento návrhový vzor si dobre rozumie s princípom jednej zodpovednosti (angl. single responsibility principle). Namiesto veľkých tried sa snažte vytvárať malé, ktoré riešia len jeden problém, a ako závislosti ich predávajte ďalším, ktoré ich môžu používať. Pri správnej implementácii je výsledkom čistejší, prehľadnejší, udržateľný a dobre testovateľný kód.
  • Pri predávaní závislostí cez konštruktor sa rýchlo ukáže, ak ich má trieda priveľa. Je to dobrý obranný mechanizmus na to, aby ste si sami uvedomili, že vaša trieda neúmerne narástla a pravdepodobne je nutné ju refaktorovať.
  • Pri predávaní závislostí cez metódu dávajte závislosti na začiatok listu argumentov.
  • Ak je to možné, preferujte vkladanie závislostí cez konštruktor nad vkladaním závislostí cez metódu

# Úlohy

# 1. Izolujte metódy terminálu do osobitného rozhrania

Trieda vyrovnávajúcej pamäte je aktuálne závislá od konkrétneho typu terminálu - pre Unixové operačné systémy. Aby sa Cache mohol stať nezávislý na typu operačného systému, vašou úlohou bude na začiatok vytvoriť rozhranie Terminal, ktoré bude definovať rovnaké verejné metódy, ako má UnixLikeTerminal. Následne zabezpečte, aby Cache akceptoval už len toto rozhranie a pôvodný Unixový terminál rozhranie implementoval. Nezabudnite vaše zmeny otestovať.

# 2. Vytvorte implementáciu terminálu pre Windows

Po vytvorení zálohového systému pre Unixové operačné systémy by nás klient mohol poprosiť, aby sme pridali podporu aj Windowsu. Vytvorte teda triedu WindowsTerminal, ktorá bude implementovať rozhranie Terminal a bude presne kopírovať správanie Unixovej verzie.

Poznámka

Pre operačný systém Windows je nutné príkazy spúšťať cez program príkazového riadku. To je možné docieliť tak, že pred každý príkaz zadávaný do metódy command triedy ProcessBuilder pridáte ešte argumenty cmd a /c.

Poznámka

Vo Windowse je oddeľovať cesty k súborom a priečinkom spätné lomítko \. Zabezpečte, aby terminák pre Windows tento rozdiel vyriešil bez toho, aby sa musel upravovať už existujúci kód, resp. aby používateľ terminálov mohol pokračovať v používaní klasických lomítok.

# 3. Vytvorte terminálovú factory triedu

V praxi očakávame, že budeme musieť zistiť akutálny operačný systém a na základe toho poskytnúť správe vyrovnávajúcej pamäti ten správny terminál. Vašou úlohou je vytvoriť factory triedu, ktorá bude mať nasledovné metódy:

  • makeTerminal(String os) - vráti terminál pre daný OS, alebo vyhodí výnimku, ak OS nie je podporovaný
  • makeTerminal() - automaticky zistí aktuálny OS a preň vráti terminál, resp. vyhodí výnimku, ak OS nie je podporovaný

Zvyšné prevedenie factory triedy je plne na vás.

Poznámka

Názov aktuálneho hosťovského operačného systému môžete v Jave získať pomocou volania System.getProperty("os.name").

# Riešenia úloh

# Pozri tiež

# Diskusia