SOLID cz.3 – DIP Dependency Inversion Principle

Wstęp

Wywoływanie kodu niskopoziomowego w modułach wysokopoziomowych niesie za sobą ryzyko nieprzewidzianych zmian w kodzie z logiką biznesową, uniemożliwienia testowania jednostkowego i zwiększenia kosztów utrzymania.

Moduł wysokopoziomowy to moduł, który zawiera reguły biznesowe i logikę aplikacji, która bardzo rzadko ulega zmianom (jedynie gdy zmieniane są wymagania). Przykładem może być np. logika zapisywania stworzonego dokumentu. Reguła ta raczej nie jest zmienna, ponieważ nie usuwamy możliwości zapisywania plików w oprogramowaniu a jego przebieg jest prawie zawsze taki sam: naciśnij zapisz -> wybierz miejsce zapisu -> nazwij plik -> zapisz. Zmienić się może lokalizacja przycisku zapisz, nazwa, wygląd, skrót klawiszowy, okno do wyboru miejsca, rozszerzenia, formaty i wiele innych ale sam sposób zapisywania jest niezmienny od wielu lat.

Niskopoziomowe moduły to takie, które zawierają  szczegóły implementacji o których klient lub użytkownik nie ma w ogóle pojęcia np. sposób zapisywania plików na poziomie użytej biblioteki. Moduły niskopoziomowe mogą się zmieniać w czasie np. ze względu na zmiany systemowe, zmiany sposobu przechowywania plików, ze względu na wydajność lub nową wersję biblioteki lub platformy. Jako niskopoziomowy kod uważamy wszystkie klasy z zewnętrznych bibliotek, frameworków, sdk na które nie mamy wpływu.

Przykład 1: Zapisywanie dokumentów

Spójrz na poniższy przykład zapisywania dokumentów, który może być częścią większego systemu:

rys. 1.1

Jak widać na rys. 1.1 w systemie może istnieć wiele różnych formatów plików oraz mogą być zapisywane w różnym formacie. Na powyższym diagramie umieściłem jedynie 2 formaty plików dla zobrazowania omawianego problemu. Spójrzmy na implementację klasy PDFSaver.

PDFSaver

Klasa PDFSaver implementuje interfejs DocumentSaver  i jest odpowiedzialna za zapisywanie obiektów typu Document jako plik PDF na dysku lokalnym. Interfejs DocumentSaver powoduje, że klasa PDFSaver  spełnia zasadę OCP i możemy w przypadku jakiś zmian stworzyć nową implementację bez konieczności edytowania PDFSaver . Rozważmy uproszczoną wersję klasy PDFSaver:

public class PDFSaver implements DocumentSaver {

    public static final String EXTENSION = ".pdf";
    private Document document;
    private PDFGeneratorFactory pdfGeneratorFactory;
    private File saveCatalogue;

    public PDFSaver(Document document, File saveCatalogue, PDFGeneratorFactory pdfGeneratorFactory) {
        this.document = document;
        this.pdfGeneratorFactory = pdfGeneratorFactory;
        this.saveCatalogue = saveCatalogue;
    }

    @Override
    public void saveDocument() throws CannotSaveFileException {
        byte[] pdfData = generatePdfFormat();
        saveFile(pdfData);
    }

    private byte[] generatePdfFormat() {
        Generator generator = pdfGeneratorFactory.getGenerator(document);
        return generator.generate();
    }

    private void saveFile(byte[] pdfData) throws CannotSaveFileException {
        File pdfFile = createNewFile();
        writeDataToFile(pdfData, pdfFile);

    }

    private void writeDataToFile(byte[] pdfData, File pdfFile) throws CannotSaveFileException {
        try (FileOutputStream fileOutputStream = new FileOutputStream(pdfFile)) {
            fileOutputStream.write(pdfData);
        } catch (IOException e) {
            throw new CannotSaveFileException();
        }
    }

    private File createNewFile() {
        String fileName = createFilename();
        return new File(saveCatalogue, fileName);
    }

    private String createFilename() {
        return document.getTitle() + EXTENSION;
    }
}

Zauważcie, że powyższa klasa jest zależna od klas FileOutputStream,  File  i  PDFGeneratorFactory.

PDFGeneratorFactory jest klasą odpowiedzialną za stworzenie obiektu Generator, który dokonuje konwersji obiektu typu Document do formatu PDF. PDFGeneratorFactory jest także interfejsem dlatego można dostarczać różne jego implementacje do PDFSaver bez konieczności edytowania jej.

FileOutputStream oraz File są standardowymi klasami Javy odpowiedzialnymi za operacje na plikach i raczej są klasami niezmiennymi tzn. istnieje małe prawdopodobieństwo, że zostaną usunięte.

Na pierwszy rzut oka klasa wydaje się być napisana dobrze, ponieważ wszystkie zależności są wstrzykiwane do klasy. W razie większych zmian dzięki interfejsowi można stworzyć nową implementację nie naruszając OCP. Nic bardziej mylnego, jest to tylko pozornie dobre rozwiązanie, które w przypadku większej skali może sprawić nam niemałe problemy.

Co jest zatem nie tak z tym kodem? Gdzie jest haczyk?

Wyobraź sobie powyższy przykład ale w większej skali. W systemie jest ok. 100 klas umożliwiających zapisywanie plików w różnych formatach. Pewnego dnia na jakimś spotkaniu projektowym zostaje dodane nowe wymaganie, które mówi, że wszystkie pliki w systemie mają być zapisywane tylko i wyłącznie w chmurze do której jest dostarczone zewnętrzne API.

W momencie gdy system jest zaimplementowany tak jak w powyższym przykładzie to aby wprowadzić zmianę będzie trzeba stworzyć 100 nowych klas zapisujących pliki lub naruszyć zasadę OCP dokonując modyfikacji w istniejących klasach. Gdy zmieni się usługodawca chmury i będzie wymagana ponowna zmiana API to nastąpi znowu konieczność zmiany 100 klas. Jednym słowem każda zmiana sposobu zapisywania plików pociągnie za sobą ok. 100 różnych zmian. Tracimy przy tym mnóstwo pieniędzy, czasu oraz elastyczność na poziomie kodu oraz biznesu.

Oczywiście Uncle Bob rusza z pomocą i serwuje zasadę Dependecy Inversion Principle (DIP) czyli główny temat tego artykułu. A mówi ona, że

  • Moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. I jedne, i drugie powinny zależeć od abstrakcji.
  • Abstrakcje nie powinny zależeć od szczegółów. To szczegóły powinny zależeć od abstrakcji

Powyższy problem jest wynikiem bezpośredniej zależności PDFSaver od klas niskiego poziomu:

rys. 1.2

na rys. 1.2 widać, że PDFSaver  z pakietu com.company.savers jest zależny od niskopoziomowej klasy File i FileOutputStream z oddalonego pakietu java.io. Klasy File i FileOutputStream„niestety” nie są abstrakcją, dzięki której można byłoby żonglować implementacjami.

Standardowo do rozwiązania takiego problemu używa się wzorca Fasada, który ukrywa implementację zapisywania plików udostępniając jedynie metody wyższego poziomu. Rozwiązałoby to nasz problem ale PDFSaver nadal nie spełniałoby DIP. Dlaczego? Fasada byłaby tworzona bazując na implementacji zapisywania plików za pomocą  File oraz FileOutputStream i istniałby ryzyko, że zaprojektowana Fasada nie pasowałaby do funkcjonalności zapisywania danych w chmurze. Jak już wspomniałem w artykule o SRP (Single Responsibility Principle) kluczem do sukcesu nie są same wzorce projektowe ale ich odpowiednie użycie.

Nie pójdę tym tokiem myślenia, że abstrakcję tworzy się pod kątem kodu, który ma pod tą abstrakcją się kryć. Abstrakcje nie powinny zależeć od szczegółów. To szczegóły powinny zależeć od abstrakcji!  

Zasada odwracania zależności przyznaje pierwszeństwo modułom wyższego poziomu nad modułami niższego poziomu. Z tego wynika, że abstrakcje powinny zależeć od modułów wysokopoziomowych i to na ich bazie powinny być tworzone. Zasada ta nazywa się zasadą odwracania zależności, ponieważ odwracamy zależność modułu wysokopoziomowego od modułu niskopoziomowego na zależność modułu niskopoziomowego od abstrakcji wysokopoziomowej.

Żeby stworzyć wysokopoziomową abstrakcję, trzeba odpowiedzieć na pytanie: Jakie potrzeby ma klasa PDFSaver względem nowej abstakcji?

Zapisywanie pliku odbywa się tuż po konwersji obiektu typu Document do tablicy bajtów, która reprezentuje plik. Następnie tablica bajtów zapisywana jest do pliku z pomocą klasy File oraz FileOutputStream. W przypadku zmiany docelowego miejsca zapisania plików nadal będziemy operować na bajtowej reprezentacji pliku.

Nowy interfejs będzie miał metodę do zapisania tablicy bajtów. Dodatkowo wraz z tablicą bajtów dostarczymy obiekt typu Metadata, który przechowuje metadane pliku takie jak rozszerzenie, nazwę pliku, autora itp. oraz obiekt typu Destination, który reprezentuje docelowe miejsce zapisu (może to być katalog, tabela w bazie danych lub metoda API)

Budując interfejs FileSaver w oparciu o klasę, która będzie się nim posługiwała pozwala na uzależnienie szczegółów od wysokopoziomowego interfejsu. Tworząc zależność w ten sposób wymuszamy aby zewnętrzne biblioteki oraz kod niższego poziomu dostosował się do modułów wysokopoziomowych a nie nasze moduły wysokopoziomowe do obcych bibliotek.

rys. 3

Utworzenie interfejsu FileSaver pozwoliło ukryć pod nim niskopoziomową implementację LocalDriveFileSaver i uniezależnić klasę PDFSaver od niskopoziomowych operacji. PDFSaver jest zależny od interfejsu FileSaver a nie od obcych klas File i FileOutputSteam

Takie rozwiązanie pozwala na zmianę sposobu zapisywania plików w całym systemie poprzez dodanie nowej implementacji FileSaver.

Warstwy kodu

Aby stosowanie DIP nie nastręczało wielu problemów (w szczególności w zespołach wieloosobowych) powinniście jawnie określić strukturę warstw abstrakcji w projekcie. Opis warstw oraz cechy przynależnych do nich klas powinny znajdować się w dokumentacji lub ogólnodostępnym dokumencie aby każdy z członków zespołu mógł się z nim zapoznać i w razie wątpliwości czy sporów stanowiłby on źródło cennej wiedzy.

W przeważającej większości projektów wystarczy aby system składał się z 3 warstw abstrakcji:

  • Warstwa Logiki Biznesowej / Domenowa (Warstwa wysokiego poziomu) – najwyższa warstwa abstrakcji w projekcie zawierająca logikę biznesową systemu. Nie znajdziecie tutaj niskopoziomowego kodu związanego z konwersjami, zapisywaniem plików czy budowaniem ciągów znaków. Implementacje metod na tym poziomie abstrakcji najczęściej będą bardzo krótkie i będą zawierały wywołania metod z interfejsów, które komunikują się z niższymi warstwami. Kod w tej warstwie będzie najrzadziej podlegał modyfikacjom. Jedynym powodem do zmiany tych klas będzie zmiana wymagań funkcjonalnych. Nie chcemy, żeby najważniejsza część systemu czyli sposób działania aplikacji był podatny na jakiekolwiek zmiany związane ze zmianą wersji API lub biblioteki. Warstwa ta nie powinna zależeć od platformy i zewnętrznych bibliotek.
  • Warstwa Mechanizmu (Warstwa średniego poziomu) – warstwa ta zawiera mechanizmy aplikacji czyli algorytmy związane ze sposobem działania aplikacji. Znajduje się tutaj kod odpowiedzialny za pobieranie danych ze źródeł (także wykorzystując interfejsy) oraz obsługę błędów wysokiego poziomu. Jest to warstwa która pośredniczy warstwie Logiki Biznesowej z warstwą Bibliotek.
  • Warstwa Bibliotek (Warstwa niskiego poziomu) – tutaj znajdują się algorytmy najniższego poziomu, tzn wszystkie rodzaju szyfrowania, konwersje, parsowanie, pobieranie danych, odwołania do API, operacje na klasach zewnętrznych bibliotek, obsługa błędów HTTP. Może to Was zdziwić ale tutaj znajdują się także wszystkie operacje na elementach bezpośrednio związanych z UI. Czemu? Ponieważ zależą one od platformy oraz od wersji platformy, a przecież nie możemy uzależniać wyższych warstw od elementów które są zmienne w czasie (w szczególności od elementów UI które zmieniają się prawie co roku w przypadku Androida).

Jak zidentyfikować zależności w modułach wysokopoziomowych, które powinno się odwrócić?

  • Jeśli w konstruktorze jakiś parametr nie jest interfejsem oznacza to, że klasa zależy od implementacji a nie od abstrakcji.
  • Jeśli w klasie znajduje się słowo kluczowe new możemy mieć prawie całkowitą pewność, że nasza klasa nie spełnia DIP. Wyjątek stanowią klasy trwałe takie jak StringIntegerStringBuilder etc. oraz klasy prywatne, które służą tylko i wyłącznie do zwiększenia czytelności implementacji. Trzeba jednak pamiętać, że wszystko zależy od kontekstu. Np. przechowywanie sformatowanego tekstu w klasie String lub SpannedString w bardziej zaawansowanych edytorach tekstu może być niewystarczające i w późniejszej fazie projektu będzie konieczność podmiany dużej części implementacji.
  • Jeśli w liście importu pakietów znajdują się pakiety z zewnętrznych bibliotek lub pakiety SDK np. android.view
  • Jeśli w napisaniu testu do klasy przeszkodą jest któraś z zależności
  • Wyjątek stanowią oczywiście biblioteki będące rozszerzeniami języka lub implementacją określonych wzorców projektowych, takim przykładem może być RxJava lub Retrolambda – chociaż tutaj też zalecałbym rozsądek, nadmierne używanie jakiejkolwiek biblioteki może doprowadzić do sytuacji w, której aktualizacja bez kompatybilności wstecznej spowoduje pojawienie się ogromnych kosztów. Tak jak miało to miejsce w aktualizacji RxJava 1 na RxJava 2.

Przykład 2: Tabela ASCI z poprzedniego artykułu

DIP w UserTableScreenWriter

Zastosuję powyższą zasadę w kodzie z poprzednich 2 artykułów o SRP i OCP. Cały kod źródłowy przykładu sprzed refaktoryzacji znajdziesz tutaj:
https://github.com/androidCoder-pl/SOLID/tree/OCP
a tutaj kod po refaktoryzacji:
https://github.com/androidCoder-pl/SOLID

Kod ma już dobrze zorganizowaną strukturę, jest elastyczny i posiada testy do większości klas. Klasa UsersTableScreenWriter nie ma testów i nie spełnia 5 zasady SOLID, ponieważ w 18 linii wypisujemy dane na ekran przy pomocy niskopoziomowego obiektu PrintStream pobieranego za pomocą zmiennej System.out:

public class UsersTableScreenWriter implements UsersWriter {

    private UsersToStringConverter stringConverter;

    public UsersTableScreenWriter(UsersToStringConverter userTable) {
        this.stringConverter = userTable;
    }

    @Override
    public void writeData(List<User> usersData) {
        System.out.println(stringConverter.toString(usersData));
    }
}

Powyższa klasa ściśle zależy od niskopoziomowej klasy systemowej, gdyby trzeba było wypisać dane na ekranie podłączonym poprzez port COM lub USB musielibyśmy utworzyć nową klasę. Możemy tego uniknąć poprzez stworzenie abstrakcji AlphanumericScreen przeznaczonej do wyświetlania danych w formacie alfanumerycznym na ekranie.

public interface AlphanumericScreen {
    void print(String text);
}

Następnie należy stworzyć implementację, która będzie wypisywała na ekranie znaki alfanumeryczne. Oczywiście w stworzonej klasie nie użyjemy bezpośrednio metody System.out.println, tylko wstrzykniemy tę zależność w konstruktorze, tak aby klasa była testowalna i bardziej uniwersalna.

public class DefaultScreen implements AlphanumericScreen {
    private PrintStream printStream;

    public DefaultScreen(PrintStream printStream) {
        this.printStream = printStream;
    }

    @Override
    public void print(String text) {
        printStream.println(text);
    }
}
public class DefaultScreenTest {
    private PrintStream printStream;

    private DefaultScreen defaultScreen;

    private ByteArrayOutputStream outputStream;

    @Before
    public void setUp() {
        outputStream = new ByteArrayOutputStream();
        printStream = new PrintStream(outputStream);

        defaultScreen = new DefaultScreen(printStream);
    }


    @Test
    public void shouldPrintAndMoveToNextLine() {
        String something = "Some text to print".trim();
        defaultScreen.print(something);

        String result = new String(outputStream.toByteArray(), StandardCharsets.UTF_8);
        assertEquals("\n", result.substring(result.length() - 1));
    }
}

Zabieg ten sprawił, ze klasa UsersTableScreenWriter uniezależniła się od komponentów niskopoziomowych i spełnia zasadę DIP. Stworzenie interfejsu AlphanumericScreen sprawiło, że można teraz wyświetlić tabelę na dowolnym monitorze o ile dostarczymy implementację interfejsu. Klasę można już bez przeszkód przetestować tworząc mock dla AlphanumericScreen.

public class UsersTableScreenWriter implements UsersWriter {

    private UsersToStringConverter stringConverter;
    private AlphanumericScreen alphanumericScreen;

    public UsersTableScreenWriter(
        UsersToStringConverter userTable, 
        AlphanumericScreen alphanumericScreen) {

        this.stringConverter = userTable;
        this.alphanumericScreen = alphanumericScreen;
    }

    @Override
    public void writeData(List<User> usersData) {
      alphanumericScreen.print(stringConverter.toString(usersData));
    }
}
public class UsersTableScreenWriter implements UsersWriter {

    private UsersToStringConverter stringConverter;

    public UsersTableScreenWriter(UsersToStringConverter userTable) {
        this.stringConverter = userTable;
    }

    @Override
    public void writeData(List<User> usersData) {
        System.out.println(stringConverter.toString(usersData));
    }
}
public class UsersTableScreenWriterTest {

    private String mockTable = "[TABLE]";

    private UsersToStringConverter stringConverter = new MockUsersToStringConverter(listOfUsers(), mockTable);

    private SpyAlphanumericScreen alphanumericScreen = new SpyAlphanumericScreen();

    private UsersTableScreenWriter screenWriter;

    @Before
    public void setUp() {
        screenWriter = new UsersTableScreenWriter(stringConverter, alphanumericScreen);
    }

    @Test
    public void shouldWriteUsersDataOnAlphanumericScreen() throws Exception {
        screenWriter.writeData(listOfUsers());

        alphanumericScreen.verifyPrinted(mockTable);
    }

    private List<User> listOfUsers() {
        List<User> users = new ArrayList<>();
        users.add(new User("Avery", "Horn", "a123"));
        users.add(new User("Henry", "Black", "a125"));
        return users;
    }
}

DIP w JsonFileUsersData

public class JsonFileUsersData implements UsersData {

    private List<User> users;

    public JsonFileUsersData(File file, Gson gson) {
        try (FileReader fileReader = new FileReader(file)) {
            convertJsonToUserList(gson, fileReader);
        } catch (IOException e) {
            makeListEmpty();
        }
    }

    private void convertJsonToUserList(Gson gson, FileReader fileReader) {
        User[] usersList = gson.fromJson(fileReader, User[].class);
        users = Arrays.asList(usersList);
    }

    private void makeListEmpty() {
        users = Collections.emptyList();
    }

    @Override
    public List<User> getUsers() {
        return users;
    }
}

W kontekście DIP klasa JsonFileUsersData jest brzydką implementacją, ponieważ zależy od dwóch niskopoziomowych klas – jedną jest klasa File a drugą Gson.

Jakie korzyści przyniesie zastosowanie DIP?

W przypadku chęci korzystania z zaszyfrowanych plików, skompresowanych lub umieszczonych w chmurze nie trzeba byłoby tworzyć nowej implementacji JsonFileUsersData a jedynie nową implementację JsonData, która może dostarczać w dowolny sposób dane w formacie JSON. Podmiana biblioteki do konwersji z formatu na JSON też byłaby banalnie prosta poprzez napisanie nowej implementacji JsonConverter.

Stwórzmy zatem interfejs JsonData, który będzie dostarczał JSONa:

public interface JsonData {
    String getJsonString();
}

Oraz naiwną (bez obsługi błędów) implementację klasy implementującej JsonData:

public class JsonFile implements JsonData {

    private File file;

    public JsonFile(File file) {
        this.file = file;
    }

    @Override
    public String getJsonString() {
        try {
            byte[] jsonBytes = Files.readAllBytes(file.toPath());
            return new String(jsonBytes, StandardCharsets.UTF_8);
        } catch (IOException e) {
            return "";
        }
    }
}
public class JsonFileTest {
    private String jsonString = "{\"data\":\"json\"}";

    private File file;

    private JsonFile jsonFile;

    @Before
    public void setUp() throws Exception {
        createTestFile();
    }

    @After
    public void tearDown() {
        deleteTestFile();
    }

    private void deleteTestFile() {
        file.deleteOnExit();
    }

    private void createTestFile() throws IOException {
        new File("build/tmp/testFiles").mkdir();
        file = new File("build/tmp/users.json");
        Writer writer = new FileWriter(file);
        writer.write(jsonString);
        writer.close();
    }

    @Test
    public void shouldReturnEmptyStringWhenIOExceptionccuOrsDuringOpenFile(){
        jsonFile = new JsonFile(new File("not/known/path/users.json"));
        String result = jsonFile.getJsonString();
        assertEquals("", result);
    }

    @Test
    public void shouldReturnJsonStringWhenFileIsNotEmpty() throws Exception {
        jsonFile = new JsonFile(file);
        String results = jsonFile.getJsonString();
        assertThat(results, is(jsonString));
    }
}

Następnie stwórzmy interfejs JsonConverter oraz jego implementację GsonConverter:

public interface JsonConverter {
    <T> T fromJson(JsonData jsonData, Class<T> classOf);
}
public class GsonConverter implements JsonConverter {

    private Gson gson;

    public GsonConverter(Gson gson) {
        this.gson = gson;
    }

    @Override
    public <T> T fromJson(JsonData jsonData, Class<T> classOf) {
        return gson.fromJson(jsonData.getJsonString(), classOf);
    }
}
public class GsonConverterTest {

    private Gson gson = new Gson();

    private StubUserJsonData jsonData = new StubUserJsonData();

    private GsonConverter gsonConverter;

    @Before
    public void setUp() {
        gsonConverter = new GsonConverter(gson);
    }

    @Test
    public void gsonShouldParseJsonFromJsonDataObject() {
        User actualUser = gsonConverter.fromJson(jsonData, User.class);
        assertThat(actualUser, is(jsonData.getUser()));
    }
}

Na końcu dodajemy wstrzykiwanie zależności w JsonFileUsersData nowo utworzonych abstrakcji:

public class JsonFileUsersData implements UsersData {

    private List<User> users;

    private JsonConverter jsonConverter;

    private JsonData jsonData;

    public JsonFileUsersData(JsonData jsonData, JsonConverter jsonConverter) {
        this.jsonConverter = jsonConverter;
        this.jsonData = jsonData;
        convertJsonToUserList();
    }

    private void convertJsonToUserList() {
        User[] usersList = jsonConverter.fromJson(jsonData, User[].class);
        users = usersList != null ? Arrays.asList(usersList) : Collections.emptyList();
    }

    @Override
    public List<User> getUsers() {
        return users;
    }
}
public class JsonFileUsersData implements UsersData {

    private List<User> users;

    public JsonFileUsersData(File file, Gson gson) {
        try (FileReader fileReader = new FileReader(file)) {
            convertJsonToUserList(gson, fileReader);
        } catch (IOException e) {
            makeListEmpty();
        }
    }

    private void convertJsonToUserList(Gson gson, FileReader fileReader) {
        User[] usersList = gson.fromJson(fileReader, User[].class);
        users = Arrays.asList(usersList);
    }

    private void makeListEmpty() {
        users = Collections.emptyList();
    }

    @Override
    public List<User> getUsers() {
        return users;
    }
}
public class JsonFileUsersDataTest {

    private StubUsersJsonData jsonData = new StubUsersJsonData();

    private JsonConverter jsonConverter;


    @Test
    public void shouldReturnEmptyListWhenJsonConverterReturnNull() {
        jsonConverter = new StubNullJsonConverter();
        JsonFileUsersData jsonFileUsersData = new JsonFileUsersData(jsonData, jsonConverter);

        List<User> users = jsonFileUsersData.getUsers();

        assertNotNull(users);
        assertTrue(users.isEmpty());
    }

    @Test
    public void shouldReturnListWhenJsonConverterReturnObjects() {
        jsonConverter = new GsonConverter(new Gson());
        JsonFileUsersData jsonFileUsersData = new JsonFileUsersData(jsonData, jsonConverter);

        List<User> users = jsonFileUsersData.getUsers();

        assertThat(users, is(jsonData.getUsers()));
    }
}

Niewątpliwą korzyścią zastosowania DIP w powyższym przykładzie jest zwiększenie czytelności samej klasy JsonFileUsersData i uproszczenie testów.

Podsumowanie

Stosując SRP (Single Responsibility Principle) i OCP (Open/Close Principle) kod staje się bardziej czytelny i logiczny, stosując DIP (Dependency Inversion Principle) w kodzie powstały nowe abstrakcyjne klasy, które zmniejszają prawdopodobieństwo wystąpienia kaskadowych zmian w kodzie. Aktualizacja bibliotek, zmiany API czy SDK nie wpłyną na główną logikę systemu a jedynie na implementacje interfejsów, które są małe.

Gdy sukcesywnie i mądrze zastosujecie DIP w projekcie minimalizujecie ryzyko wystąpienia błędów podczas aktualizowania bibliotek, ulepszania wydajności czy zmiany interfejsu graficznego aplikacji. Zobaczycie też, że wiele problemów po prostu znika a utrzymanie projektu staje się o wiele przyjemniejsze.

Tworząc interfejs do modułu wysokopoziomowego należy upewnić się czy na pewno jest on stworzony z myślą o klasie korzystającej z niego a nie na bazie implementacji, która ma pod tym interfejsem się kryć. Złe zaplanowanie interfejsu da nam mierne korzyści i ryzyko kaskadowych zmian tak jakby w ogóle interfejsu nie było.

Bibliografia i polecane źródła

[1] https://keyholesoftware.com/2014/02/17/dependency-injection-options-for-java/

[2] https://www.youtube.com/watch?v=gwIS9cZlrhk&t=611s

[3] http://blog.cleancoder.com/

[4] Zwinne wytwarzanie oprogramowania. Najlepsze zasady, wzorce i praktyki, Robert C. Martin, Helion 2015

[5] Czysty kod. Podręcznik dobrego programisty, Robert C. Martin, Helion 2014

[6]  Refactoring: Improving the Design of Existing Code,  Martin Fowler

[7] https://www.youtube.com/watch?v=t86v3N4OshQ

Jeśli uważasz treść za wartościową to podziel się nią z innymi. Dziękuję.

Mateusz Chrustny

One thought on “SOLID cz.3 – DIP Dependency Inversion Principle

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *