Sõltuvuste süstimine (dependency injection)

Sõltuvuste süstimine on disainimuster, kus klassis vajaminevad sõltuvused "süstitakse sisse" väljastpoolt klassi ennast (tavaliselt konstruktori kaudu).

Sõltuvuste süstimisel on kolm eriti silmatorkavat eelist:

Sõltuvustest sõltumatu disain

Illustreerimiseks kasutame triviaalset näidet. Ütleme, et peame ellu viima programmi, mille ainuke eesmärk on salvestada kasutaja sisse antud teksti.

public class TextSaver {
    public interface Persistence {
        void saveText(String text);
        List<String> getTexts();
    }

    private final Persistence persistence;       // <- here is our dependency
    private final BufferedReader inputReader = new BufferedReader(new InputStreamReader(System.in));

    public TextSaver(Persistence persistence) {  // <- dependency injection happens here
        this.persistence = persistence;          // <-
    }

    void run() throws IOException {
        while (true) {
            switch (inputReader.readLine()) {
                case "save" -> saveText();
                case "get" -> printTexts();
                default -> {}
            }
        }
    }
    void saveText() throws IOException {
        var reader = new BufferedReader(new InputStreamReader(System.in));
        persistence.saveText(reader.readLine());
    }

    void printTexts() {
        System.out.println(persistence.getTexts());
    }
}

Pane tähele, mis me oleme teinud. Me oleme kirjutanud kasutajalt pärimise, ning kasutajaga suhtlemise funktsionaalsuse, ilma kordagi mainimata, kuidas teksti reaalselt salvestama peaks. On võimalik, et salvestamise implementatsiooni pole veel isegi loodud. Seda kõike tänu sellele, et salvestamise sõltuvus on sisse antud konstruktorist liidesena - meid huvitab vaid kas ja milmoel me saame teksti salvestada, mitte kuidas see juhtub.

Alles siis, kui klassi põhjal objekti konstrueerime, peame otsustama, kuidas salvestamine peaks toimuma:

var textSaver1 = new TextSaver(databaseConnection);
var textSaver2 = new TextSaver(apiConnection);
var textSaver3 = new TextSaver(filesystem);
// we could choose any ^^^
textSaver3.run();

Konstruktorisse sisse süstitud sõltuvus võib olla ükskõik milline, mis peab kinni liideses kirjeldatud lepingust.

Sõltuvuste süstimine võimaldab keskenduda probleemi olemusele, ning täita realiseerimisdetailid ära hiljem.

Klasside taaskasutatavus ja paindlikkus

Sõltuvuste süstimine võimaldab klasse komponeerida - väiksematest, vähem kivisse raiutud tükkidest luua suuremaid, kasulikemaid süsteeme.

Saame taaskasutada samu klasse erinevateks otstarveteks:

var textReader = switch (getUserPreference()) {
    case LOCAL_PERSISTENCE -> new TextReader(filesystem);
    case CLOUD_PERSISTENCE -> new TextReader(apiConnection);
    default -> new TextReader(apiConnection);
};

Komponeerimine pole piiratud ainult ühele sõltuvusele - tihtipeale võib olla klassis mitu erinevat tükki, mida on loogiline teha väljavahetatavaks.

Mingil määral võib see meenutada strateegia mustrit.

Testimise hõlbustamine

Kuidas testida isolatsioonis tarkvarakomponenti, mis sõltub andmebaasiühendusesest? Võrgupäringu tulemusest? Mis siis, kui projektis on tuhandeid teste, mis kõik sõltuvad andmebaasiühendusest? Mis siis, kui võrgupäring on teenuse pihta, mis on tasuline? Mis siis, kui vajaminev teenus on juhtumisi just täna maas - kas testid peaksid läbi kukkuma? Mis siis, kui keegi pole veel isegi vajaminevat sõltuvust ellu viinud või programmeerinud?

Kuigi tarkvara võib olla mõistlik testida reaalsetes olukordades, on sageli kasu ka väiksemamastaabilistest ning isoleeritud testidest, et valideerida mingi kindla komponendi funktsionaalsust. Sõltuvuste süstimine võimaldab programmeerijal ise valida, mis tingimustes peaks tarkvarakomponenti katsetav test jooksma.

Teisisõnu, kui me testime tarkvarakomponenti A, siis loetelu asjadest, mis võiks testi tulemust mõjutada ei peaks sisaldama:

Ning võiks sisaldada:

  • Kas tarkvarakomponent A funktsioneerib ootuspäraselt arvestades sisendeid

Selliseid väikseid teste nimetatakse üksustestideks (ingl. k. unit test), ning sõltuvuste süstimine aitab neid hõlbustada.

Vaatame näidet. Ütleme, et meil on klass, mis vastutab kasutaja tervitamise eest.

public class Greeter {
    public String greet(String name) {
        int hour = LocalTime.now().getHour();

        String greeting;
        if (hour >= 5 && hour < 12) {
            greeting = "Good morning";
        } else if (hour >= 12 && hour < 18) {
            greeting = "Good afternoon";
        } else {
            greeting = "Good evening";
        }

        return String.format("%s, %s!", greeting, name);
    }
}

Tahame testida, et tervitused oleks alati õiged:

class GreeterTest {
    private final Greeter greeter = new Greeter();

    @Test
    void whenIsMorning_ShouldGreetWithGoodMorning() {
        var greeting = greeter.greet("Alice");
        assertEquals("Good morning, Alice!", greeting);
    }

    @Test
    void whenIsAfternoon_ShouldGreetWithGoodAfternoon() {
        var greeting = greeter.greet("Alice");
        assertEquals("Good afternoon, Alice!", greeting);
    }

    @Test
    void whenIsEvening_ShouldGreetWithGoodEvening() {
        var greeting = greeter.greet("Alice");
        assertEquals("Good evening, Alice!", greeting);
    }
}

Muidugi, me ei suuda kontrollida kellaaega (või vähemalt mitte väärtust, mida LocalTime.Now() tagastab), seega kaks kolmest testist kukuvad alati läbi:

../_images/failed-greeter-tests.png

Parandame mure sõltuvuste süstimisega. Selle asemel, et Greeter kasutaks alati LocalTime.now() argumentideta meetodit sõltuvusena (mis tagastab alati praeguse kellaaja), süstime talle kellaaja saamise sõltuvuse konstruktorist sisse:

public class Greeter {
    private final Clock clock;

    public Greeter(Clock clock) {
        this.clock = clock;
    }

    public String greet(String name) {
        int hour = LocalTime.now(clock).getHour();

        String greeting;
        if (hour >= 5 && hour < 12) {
            greeting = "Good morning";
        } else if (hour >= 12 && hour < 18) {
            greeting = "Good afternoon";
        } else {
            greeting = "Good evening";
        }

        return String.format("%s, %s!", greeting, name);
    }

Nüüd saab Greeter kasutada LocalTime.now(clock) meetodit, väliselt määratud kellaga.

Testid saavad nüüd olla sellised, kus me ise määrame kellaaja:

class GreeterTest {
    private final Clock midnight = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);

    @Test
    void whenIsMorning_ShouldGreetWithGoodMorning() {
        var morning = Clock.offset(midnight, Duration.of(6, ChronoUnit.HOURS));
        var greeter = new Greeter(morning);
        var greeting = greeter.greet("Alice");

        assertEquals("Good morning, Alice!", greeting);
    }

    @Test
    void whenIsAfternoon_ShouldGreetWithGoodAfternoon() {
        var afternoon = Clock.offset(midnight, Duration.of(13, ChronoUnit.HOURS));
        var greeter = new Greeter(afternoon);
        var greeting = greeter.greet("Alice");

        assertEquals("Good afternoon, Alice!", greeting);
    }

    @Test
    void whenIsEvening_ShouldGreetWithGoodEvening() {
        var evening = Clock.offset(midnight, Duration.of(20, ChronoUnit.HOURS));
        var greeter = new Greeter(evening);
        var greeting = greeter.greet("Alice");

        assertEquals("Good evening, Alice!", greeting);
    }
}

Kõik testid valideerivad kellaajast sõltumatult Greeter klassi oodatud funktsionaalsust:

../_images/greeter-passed-tests.png

Näitele sarnaselt saab võtteid rakendada ka andmebaasipäringutega, võrgupäringutega, ning igasuguste muude sõltuvustega.

Võltsobjektid

Eelmises näites süstisime sisse abstraktse klassi Clock, mille konkreetse implementatsiooni hankimine oli õnneks tänu staatilistele meetodile lihtne.

Tihtipeale ei ole asi nii lihtne, ning on vaja luua võltsobjekte (ingl. k. mock objects), mis matkivad mingit liidest (nt. tagastavad usutavaid vastuseid andmebaasipäringutele).

Õnneks ei pea andmebaasi võltsimiseks ise andmebaasi nullist kirjutama. Objektide võltsimiseks on kirjutatud eraldi teeke. Java jaoks on näiteks olemas populaarne teek Mockito.

Näiteks, kui meil on olemas UserDatabase liides, siis saame Mockitot kasutades kirjutada selliseid asju:

@Test
void test() {
    UserDatabase database = mock();
    when(database.get("john32")).thenReturn(new User("john32"));
    // ...
}

Ning Mockito genereerib liidesele vastava implementatsiooni, mis käitub justkui andmebaas, milles on kasutaja john32. Selle võltsobjekti saab süstida sõltuvusena sisse teistesse tarkvarakomponentidesse, et neid testida.