IOC - okładka

Inversion of Control z użyciem Dependency Injection

Inversion of Control jest bardzo wartościowym wzorcem postępowania podczas pisania programów pisanych w oparciu o programowanie zorientowane obiektowo. W tym artykule dowiesz się, na czym polega Inversion of Control (lub też swojskie – odwrócenie sterowania), a także poznasz jedną z najczęściej spotykanych form zastosowania odwrócenia sterowania czyli Dependency Injection (wstrzyknięcie zależności).

W artykule wykorzystuję przykłady napisane w TypeScripcie, natomiast myślę że są na tyle proste, że brak znajomości TSa nie powinno być przeszkodą w ich zrozumieniu.

Inversion of Control

Działanie Inversion of Control bardzo często tłumaczy się korzystając z Hollywood Principle:

Don’t call us, we’ll call you.

O ile samo porównanie jest jak najbardziej trafne, tak bez odpowiedniego wytłumaczenia i kontekstu jest bardzo enigmatyczne i nie odpowiada w pełni na pytanie czym jest Inversion of Control.

Odwrócenie sterowania jest odpowiedzią na problem zależności pomiędzy klasami w kodzie. Przede wszystkim należy sobie uświadomić dlaczego posiadanie zależności w kodzie jest problemem. Po pierwsze, posiadanie zależności w kodzie prowadzi do powstawania wysokiego couplingu pomiędzy klasami co w konsekwencji może prowadzić do sytuacji gdzie zmiana w kodzie jednej klasy wymusza zmianę w kodzie innej klasy a co gorsza ta zmiana może być konieczna do zaaplikowania w wielu miejscach.
Drugim problemem wynikającym z powstawania zależności w kodzie jest problem z napisaniem dobrych testów jednostkowych. Powiązane klasy mogą okazać się problematyczne, a czasami wręcz niemożliwe do przetestowania z użyciem testów jednostkowych. Poniżej przedstawiam fragment kodu posiadający omawiane problemy:


import axios from  'axios';

class WeatherStatus {
    public async getTemperature( city: string ): Promise {
        const httpClient: IHttpClient = axios.create( {
            baseURL: 'https://some.api.com'
        } );

        const temperature: number = await httpClient.get( `api/v1/temperature?city=${ city }` );

        return `Temperature in ${ city }: ${ temperature }`;
    }
}

Powyższa klasa posiada jedną publiczną metodę getTemperature, która dla podanego miasta zwraca temperaturę. Teoretycznie wydawać by się mogło, że powyższy kod spełnia swoje zadanie i nie ma powodów do niezadowolenia. Otóż moim zdaniem są.

Przede wszystkim tego kodu nie da się dobrze przetestować z użyciem testów jednostkowych. W obecnym stanie nie istnieje możliwość stworzenia mocka klienta HTTP oraz odpowiedzi z metody get. Jedno z założeń testów jednostkowych mówi, że wszystkie zewnętrzne komponenty (czyli w omawianym przypadku klient HTTP) powinny zostać zmockowane. Więcej o testach, w tym testach jednostkowych możesz dowiedzieć się z artykułu Podstawy testów automatycznych oprogramowania. Wywołanie metody w teście spowoduje faktyczne wykonanie zapytania do API, także z pewnością nie będzie to test jednostkowy. Będzie to test integracyjny.

Drugim problemem przedstawionego kodu jest jawna inicjalizacja instancji klienta HTTP wraz z bezpośrednim podaniem konfiguracji. Będzie to problematyczne zarówno podczas pisania testów, jak i podczas potencjalnych refactorów w przyszłości. Ponadto występuje tu jawne konfigurowanie niskopoziomego komponentu w klasie logiki biznesowej co powoduje mieszanie się warstw aplikacji i ogólny regres jakości kodu.

Kolejny problem powstanie w momencie chęci zmiany axiosa na jakiekolwiek inne rozwiązanie. Załóżmy, że klas tj. WeatherStatus mamy w systemie kilka/kilkanaście/kilkadziesiąt. W takim przypadku, w każdym miejscu należy dotknąć kodu biznesowego z powodu podmiany niskopoziomowego komponentu. Jest to złe nie tylko z powodu konieczności wykonania żmudnej pracy, ale też z powodu potencjalnego ryzyka uszkodzenia kodu biznesowego. Przypominam, że powyższy kod jest problematyczny do przetestowania z użyciem testów jednostkowych, co tylko podwyższa potencjalne ryzyko!

Rozwiązanie problemu

Rozwiązaniem problemu jest dokonanie Inversion of Control poprzez pozybycie się zależności pomiędzy klientem HTTP a klasą WeatherStatus. Przede wszystkim klient HTTP powinien być konfigurowany na zewnątrz, czyli spoza klasy WeatherStatus. Klasa WeatherStatus powinna dostać gotowy komponent do komunikacji z API – skonfigurowany, działający i gotowy do działania. Pozbycie się zależności miedzy klientem HTTP a klasą WeatherStatus pozwoli też na przetestowanie zarówno klasy biznesowej jak i klienta HTTP z użyciem testów jednostkowych. Ponadto, wyeliminowana zostanie konieczność modyfikacji kodu logiki biznesowej w przypadku podmiany niskopoziomowego komponentu.

Jeśli omówiony problem i rozwiązanie wydają Ci się znajome i kojarzą ci się z piątą zasadą SOLID, czyli Dependency Inversion Principle, to jest to dobre skojarzenie. Jeśli natomiast nie kojarzysz zasad SOLID, to serdecznie zachęcam do nadrobienia zaległości i przeczytanie artykułu SOLID, KISS i DRY.

Dependency Injection

Dependency Injection, czyli wstrzyknięcie zależności jest jedną z form implementacji Inversion of Control. Warto w tym miejscu podkreślić: wykorzystanie Dependency Injection jest równoznaczne z wykorzystaniem Inversion of Control, lecz wykorzystanie Inversion of Control oznacza wykorzystania Dependency Injection.

Cała magia, w przedstawionym przeze mnie przykładzie, polega na wstrzyknięciu do klasy WeatherStatus klienta HTTP. Jednym z wariantów Dependency Injection jest wstrzyknięcie z wykorzystaniem konstruktora. W omawianym przypadku wydaje się on mieć najwięcej sensu. Rezultat przeprowadzonego refactoru i wykorzystanie Dependency Injection przedstawiam poniżej:


interface IHttpClient {
    get( url: string ): Promise;
}

class WeatherStatus {
    public constructor( private readonly _httpClient: IHttpClient ) {}

    public async getTemperature( city: string ): Promise {

        const temperature: number = await this._httpClient.get( `api/v1/temperature?city=${ city }` );

        return `Temperature in ${ city }: ${ temperature }`;
    }
}

Tak banalny zabieg jak odseparowanie klienta HTTP od klasy z logiką biznesową poprzez wstrzyknięcie sprawił, że:

  • Zależność jest konfigurowana z zewnątrz. Klasa biznesowa nie wie nic o konfiguracji klasy niskopoziomowej.
  • Możliwe staje się przetestowanie klasy WeatherStatus z użyciem testów jednostkowych. Zamiast klienta HTTP można w testach wstrzyknąć mocka, który zamiast wysyłać zapytanie będzie zwracał uprzednio przygotowaną odpowiedź.
  • Zamiana jednego klienta na innego nie będzie wymagało dotknięcia kodu logiki biznesowej. Jedynym wymaganiem dla nowego klienta HTTP w przypadku TypeScripta jest spełnianie warunków kontraktu zdefiniowanego w interfejsie IHttpClient.

Kiedy nie stosować Inversion of Control?

Na pierwszy rzut oka ciężko jest znaleźć jakieś istotne mankamenty odwrócenia sterowania. O ile kod z odwróconym sterowaniem jest prostszy w testowaniu, zmianie i rozbudowie, tak dla niektórych może on być nieco bardziej skomplikowany. Szczególnie widoczne może to być w przypadku dużych klas. Przedstawiony we wpisie przykład nie oddaje wystarczajco istoty problemu. Ponadto, nie w każdej aplikacji wystąpią testy i przyszła rozbudowa. W przypadku mikro projektów czy prostych skryptów/procedur taka inżynieria może okazać się armatą na muchę. W przypadku gdy nie potrzebujemy testów oraz jesteśmy pewni, że pisany kod w najbliższej przyszłości nie będzie zmieniany warto rozważyć podążanie za zasadą YAGNI (You Ain’t Gonna Need It). Oczywiście każdy przypadek należy rozważyć indeywidualnie. Nie istnieje bowiem żadna reguła pozwalająca określić, kiedy Inversion of Control jest konieczne a kiedy nie. Niemniej jednak, w pewnych sytuacjach lepsze jest wrogiem dobrego 🙂

Podsumowanie

Pisząc ten artykuł, bardzo mi zależało aby wytłumaczyć omawiane zagadnienia jak najprościej. Zdaję sobie sprawę, że przeczytanie artykułu często pozwala zrozumieć temat, ale gdy przychodzi czas na praktyczne wykorzystanie wiedzy to pojawia się problem. Dlatego też zachęcam do praktycznego zastosowanie IoC w jednej z napisanych przez Ciebie aplikacji. Bardzo zachęcam też do zapoznania się ze źródłami i materiałami dodatkowymi. Zestawienie informacji z kilku źródeł pozwoli Ci bardziej zrozumieć temat, poznać inne (być może bardziej zrozumiałe dla Ciebie) przykłady i punkty widzenia.

Zachęcam też do pozostawienia oceny, komentarza, oraz udostępnienia wpisu jeśli dowiedziałeś/aś się dzieki niemu czegoś przydatnego 🙂

Źródła i materiał dodatkowe: