SOLID cz.1 – Single Responsibility Principle

Wstęp

Jeszcze dziesięć lat temu aplikacje na telefony były tworzone w przysłowiowych garażach przez ludzi z bardzo małym doświadczeniem.  Przez długi czas nie były one w zainteresowaniu większych inwestorów, dlatego brakowało w tej technologii specjalistów. Jakość kodu i jego utrzymanie nie odgrywało tak wielkiej roli, ponieważ wymagania ówczesnych klientów były o wiele mniejsze a projekty dopiero raczkowały – liczyła się cena.

Wzrost wydajności urządzeń mobilnych oraz liczba ich posiadaczy sprawił, że stały się z czasem bardzo atrakcyjne. Pojawiły się w nich największe sklepy, największe wytwórnie gier komputerowych, powstały startupy warte miliardy a przemysł aplikacji mobilnych ciągle się powiększa. Wraz z nadejściem pieniędzy przyszły także większe wymagania. Aplikacje takie jak np. Spotify rozwija blisko 100 osób a wielkość kodu źródłowego zbliża się do miliona linii. Utrzymanie takich projektów wymaga ogromnej wiedzy oraz nakładów.

Złej jakości kod powoduje wzrost kosztów utrzymania projektu w stosunku do jego długości, co jest najszybszą drogą do nieopłacalności. Aby ograniczyć takie ryzyko należy stosować sumiennie dobre praktyki. Nie mogą one ograniczać się tylko do zastosowania kilku wzorców projektowych, dokumentacji, testów jednostkowych i statystycznej analizy kodu. Takie rozwiązania są tylko iluzją dobrej jakości kodu okraszoną hasłami typu MVVM, RxJava, Redux etc..

Sam doświadczyłem takiej sytuacji, w której refactoring lub wprowadzenie nowej funkcjonalności trwało bardzo długo i przy tym generowało dodatkowe błędy. Byłby bez problemu znalezione gdyby testy były lepszej jakości, pokrycie testami było większe a wzorce odpowiednio zastosowane. Co gorsza, poważne błędy były znajdowane nawet po roku działania aplikacji produkcyjnej.

Istnieje dużo zasad dobrego programowania, które sprawiają, że kod staje się bardziej elastyczny i łatwiejszy w utrzymaniu. Aby kod był w całkiem dobrym stanie wystarczy zastosować tylko 5 zasad, które tworzą bardzo trafny akronim SOLID. Zasady te zostały przedstawione przez Roberta C. Martina jako 5 z 11 zasad dobrego projektowania systemów obiektowych. Pozostałe 6 zasad ma także duży wpływ na jakość kodu ale 5 zasad SOLID są fundamentem tworzenia dobrego oprogramowania.

Ciekawostka: Sam akronim SOLID nie został stworzony przez Roberta C. Martina, został on wymyślony przez Michaela Feathersa.

Zasada Single Responsibility Principle (SRP)

W tym artykule (jednym z 5) omówię zasadę pojedynczej odpowiedzialności (Single Responsibility Principle – dalej SRP), która jest pierwszą zasadą SOLID i jednocześnie pierwszą, którą należy zastosować w projekcie aby rozpocząć proces ulepszania jakość kodu. Zasada ta mówi, że:

  • klasa/funkcja powinna odpowiadać za jedną czynność
  • powinno istnieć tylko i wyłącznie jedno wymaganie, którego zmiana pociąga za sobą zmianę klasy/funkcji

Przykład 1: Zapis Ebooków

Rozważmy prosty przykład klasy EBook, która zwiera nazwę autora, tytuł, okładkę oraz treść. Klasa EBookposiada także metodę  saveBook(), która zapisuje e-book na dysku.

Klasa nie spełnia zasady pojedynczej odpowiedzialności, ponieważ istnieje więcej niż jeden powód do zmiany klasy  EBook:

  1. zmiana struktury EBook np. dodanie wartości  ISBN.
  2. zmiana sposobu zapisywania pliku. Jeśli chciałbyś wprowadzić nowe formaty zapisu takie jak pdf, epub, pdb lub miejsce zapisu na GoogleDrive, OneDrive, DropBox musiałbyś zmodyfikować metodę  saveBook() lub dodać kolejne metody zapisujące – czyli zmienić implementację obiektu

Refactoring

Aby program spełniał zasadę SRP należy zapisywanie pliku wynieść do osobnej klasy np. EBookSaver, która mając referencję do klasy  EBook, może pobrać od niej wszystkie potrzebne dane i ją zapisać. Bez jakiejkolwiek ingerencji w klasę EBook

Dzięki temu, że wydzieliłem EBookSaver, klasa EBook jest niezależna od metody zapisywania go. Ponadto jeśli EBookSaver jest interfejsem, możesz stworzyć dowolne implementacje bez naruszania klasy  EBook oraz innych klas, które implementują  EBookSaver – o tym przeczytasz więcej tutaj, gdzie omawiam drugą zasadę SOLID.

Przykład 2: Tabela ASCI

Rozważmy teraz nieco bardziej złożony program, który wyświetla w konsoli listę użytkowników w formie tabeli ASCI:

----------------------------------------
| ID         | NAME       | LASTNAME   |
----------------------------------------
| MK1        | Michał     | Kowalski   |
| DM1        | Daniel     | Miły       |
| IC1        | Iza        | Cytryna    |
| IC2        | Ireneusz   | Czapka     |
----------------------------------------

Gdybym nie znał zasad SOLID stworzyłbym program, który składa się z 2 klas:

User to prosta klasa przechowująca dane użytkownika:

package com.androidcoder.nonsolid;

public class User {
    private String name;
    private String lastName;
    private String id;

    public User(String name, String lastName, String id) {
        this.name = name;
        this.lastName = lastName;
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }
}

UserData to klasa dostarczająca kolekcję klas User oraz wypisująca na ekran dane użytkowników:

package com.androidcoder.nonsolid;

import java.util.LinkedList;
import java.util.List;

public class UsersData {

    private List<User> users = new LinkedList<>();
    private StringBuilder tableBuilder = new StringBuilder();

    public UsersData() {
        users.add(new User("Michał", "Kowalski", "MK1"));
        users.add(new User("Daniel", "Miły", "DM1"));
        users.add(new User("Iza", "Cytryna", "IC1"));
        users.add(new User("Ireneusz", "Czapka", "IC2"));
    }

    public void printTable() {

        drawLine();
        drawRow("ID", "NAME", "LASTNAME");
        drawLine();
        for (User user : users) {
            drawRow(user.getId(), user.getName(), user.getLastName());
        }
        drawLine();
        System.out.println(tableBuilder.toString());
    }

    private void drawRow(String... row) {
        tableBuilder.append(String.format("| %-10s |", row[0]));
        tableBuilder.append(String.format(" %-10s |", row[1]));
        tableBuilder.append(String.format(" %-10s |%n", row[2]));
    }

    private void drawLine() {
        tableBuilder.append(String.format("%40s%n", "").replace(" ", "-"));
    }
}

W klasie main tworzony jest obiekt i wywoływana funkcja wypisującą dane na ekran:

package com.androidcoder.nonsolid;


public class Main {

    public static void main(String[] args) {
        UsersData users = new UsersData();
        users.printTable();

    }
}

Takie rozwiązanie nie jest złe w przypadku gdy robimy tylko prototyp lub prosty program, który nigdy się nie rozbuduje. Gdy wiemy, że z projekt będzie się rozwijał to możemy w pewnym momencie natrafić na spore problemy. Zmianami, które mogą stać się zapalnikiem to:

  • Zapis tabeli do pliku
  • Zmiana modelu danych
  • Zmiana sposobu wyświetlania
  • Wyświetlanie tabel na niestandardowych ekranach

Wszystkie te zmiany będą mocno ingerowały w kod, który właśnie napisałem. Celem zasady SRP jest zminimalizowanie strat w związku ze zmianą wymagań.

Refactoring

Znając pierwszą zasadę SOLID (SRP) wiemy, że klasa musi być wyspecjalizowana w tym co robi. W powyższym programie można wyeksponować 3 całkowicie osobne czynności:

  1. Stworzenie (lub pobranie) danych użytkownika
  2. Stworzenie tabeli w wersji ASCI
  3. Wyświetlenie tabeli na ekranie komputera

te czynności determinują zestaw klas jaki znajdzie się w programie:

UserData

Klasa UserData odpowiada za dostarczenie listy użytkowników – w tym przypadku bezpośrednio z kodu.

package com.androidcoder.srp.datasource;

import com.androidcoder.srp.model.User;

import java.util.LinkedList;
import java.util.List;

public class UsersData {

    private List<User> users = new LinkedList<>();

    public UsersData()
    {
        users.add(new User("Michał", "Kowalski", "MK1"));
        users.add(new User("Daniel", "Miły", "DM1"));
        users.add(new User("Iza", "Cytryna", "IC1"));
        users.add(new User("Ireneusz", "Czapka", "IC2"));
    }

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

UserListTableConverter pozwala na zamianę listy obiektów User na reprezentację graficzną w postaci tabeli ASCII:

package com.androidcoder.srp.converter;

import com.androidcoder.srp.model.User;

import java.util.List;

class UserListTableConverter {
    private StringBuilder tableBuilder = new StringBuilder();

    public String toString(List<User> usersList) {
        clearOldTableData();
        drawTableHeader();
        drawMainTableData(usersList);
        return getTable();
    }

    private void clearOldTableData() {
        clear();
    }

    private void drawTableHeader() {
        drawLine();
        drawRow("ID", "NAME", "LASTNAME");
        drawLine();
    }

    private void drawMainTableData(List<User> usersList) {
        for (User user : usersList) {
            drawRow(user.getId(), user.getName(), user.getLastName());
        }
        drawLine();
    }

    private void clear() {
        tableBuilder.delete(0, tableBuilder.length());
    }

    private void drawLine() {
        tableBuilder.append(String.format("%40s%n", "").replace(" ", "-"));
    }

    private void drawRow(String... row) {
        tableBuilder.append(String.format("| %-10s |", row[0]));
        tableBuilder.append(String.format(" %-10s |", row[1]));
        tableBuilder.append(String.format(" %-10s |%n", row[2]));
    }

    private String getTable() {
        return tableBuilder.toString();
    }
}
UsersTableScreenWriter

UsersTableScreenWriter jest klasą, która wykorzystuje UserListTableConverter do konwersji listy użytkowników na ciąg znaków a następnie wypisuje ten ciąg znaków na ekran.

package com.androidcoder.srp.writer;

import com.androidcoder.srp.converter.UserListTableConverter;
import com.androidcoder.srp.model.User;

import java.util.List;

public class UsersTableScreenWriter {
    public void writeData(List<User> usersData) {
        String stringTable = convertUsersListToTable(usersData);
        writeTableOnScreen(stringTable);
    }

    private void writeTableOnScreen(String stringTable) {
        System.out.println(stringTable);
    }

    private String convertUsersListToTable(List<User> usersData) {
        UserListTableConverter userTable = new UserListTableConverter();
        return userTable.toString(usersData);
    }
}

Dalszy refactoring

W UserListTableConverter znajduje więcej kodu niż w pozostałych klasach – powinno zwrócić to Twoją uwagę. Na pierwszy rzut oka może się wydawać, że klasa spełnia SRP – odpowiada tylko i wyłącznie za tworzenie tabeli. To prawda, na wyższym poziomie abstrakcji ma tylko jedną odpowiedzialność ale jeśli spojrzymy na sposób budowania tabeli to nie jest już tak jednoznaczne. Tworzenie tabeli zachodzi na 2 poziomach, pierwszy na poziomie znaków ASCII gdzie budujemy tabelę ze znaków, drugi zaś na poziomie tworzenia poszczególnych wierszy.

Zachodzi tutaj przypadek gdzie wysokopoziomowe metody są pomieszane z niskopoziomowymi np. drawTableHeader oraz tableBuilder.append. Czy jest to problem? Zależy, np. chęć zmiany znaków do rysowania tabeli, powoduje konieczność edycji całej klasy, w której zachodzi budowanie także na wyższym poziomie. Taka zmiana będzie powodowała konieczność zmiany testów dla całej klasy – powstanie ryzyka pojawienia się błędów.

Sposób rysowania tabeli nie powinien mieć w pływu na sposób organizacji danych w tabeli, dlatego wydzielę kod odpowiedzialny za rysowanie tabeli.

SRP dla UserListTableConverter

Jeśli przyjrzysz się klasie UserListTableConverter to zobaczysz, że tylko metody drawLine, clear, drawRow, getTable korzystają ze zmiennej tableBuilder – są to metody niższego poziomu niż np. drawTableHeader. Gdy w kodzie wykryjesz taką sytuację to jest to znak, że klasa może posiadać więcej niż jedną funkcjonalność. Jeśli wydzielisz te funkcje i zmienną do osobnego obiektu, to stanie się on bardziej wyspecjalizowany niż poprzedni – do tego dążymy stosując SRP.

Przeniosłem te 4 metody do klasy StringTableBuilder, która jest odpowiedzialna za budowanie tabeli ze znaków tekstowych:

package com.androidcoder.srp.converter;

public class StringTableBuilder {
    private StringBuilder tableBuilder = new StringBuilder();

    public void drawRow(String... row) {
        tableBuilder.append(String.format("| %-10s |", row[0]));
        tableBuilder.append(String.format(" %-10s |", row[1]));
        tableBuilder.append(String.format(" %-10s |%n", row[2]));
    }

    public void drawLine() {
        tableBuilder.append(String.format("%40s%n", "").replace(" ", "-"));
    }

    public String getTable() {
        return tableBuilder.toString();
    }

    public void clear() {
        tableBuilder.delete(0, tableBuilder.length());
    }
}

Zabieg ten zmniejszył klasę UserListTableConverter i sprawił, że klasa spełnia SRP:

package com.androidcoder.srp.converter;

import com.androidcoder.srp.model.User;

import java.util.List;

public class UserListTableConverter {

    private StringTableBuilder stringTableBuilder = new StringTableBuilder();

    public String toString(List<User> usersList) {
        convertToStringTable(usersList);
        return stringTableBuilder.getTable();
    }

    private void convertToStringTable(List<User> usersList) {
        clearOldTableData();
        drawTableHeader();
        drawMainTableData(usersList);
    }

    private void clearOldTableData() {
        stringTableBuilder.clear();
    }

    private void drawMainTableData(List<User> usersList) {
        stringTableBuilder.drawLine();
        for (User user : usersList) {
            stringTableBuilder.drawRow(user.getId(), user.getName(), user.getLastName());
        }
        stringTableBuilder.drawLine();
    }

    private void drawTableHeader() {
        stringTableBuilder.drawLine();
        stringTableBuilder.drawRow("ID", "NAME", "LASTNAME");
    }
}
Main nie jest śmietnikiem

Funkcja main jest wejściem do programu i powinniśmy w niej mieć porządek. Chciałem aby funkcja main odpowiadała za uruchomienie programu a nie tworzenie wszystkich zależności. Dlatego dodałem jeszcze jeden obiekt, który odpowiada za stworzenie wszystkich obiektów i połączenie ich ze sobą:

package com.androidcoder.srp;

import com.androidcoder.srp.datasource.UsersData;
import com.androidcoder.srp.writer.UsersTableScreenWriter;

public class UserTable {
    public void writeUserTableOnScreen() {
        UsersData usersData = new UsersData();
        UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter();
        usersTableScreenWriter.writeTableOnScreen(usersData.getUsers());
    }
}

funkcja main posiada teraz tylko jedną odpowiedzialność jaką jest uruchomienie programu:

package com.androidcoder.srp;

public class Main {
    public static void main(String[] args) {
        new UserTable().printUserTableOnScreen();
    }
}

Pewnie niektórzy z Was pomyślą, że jest to głupi pomysł – i będą mieli rację, nie ma sensu wprowadzać rozwiązań, które komplikują ale nie wprowadzają wartości dodanej. W następnych artykułach, gdy będę wprowadzał testy, to te rozwiązanie uprości nieco sprawę – więc ocenicie sami czy warto czy nie.

Kod do powyższego przykładu znajdziesz w repozytorium git. W repozytorium znajdziesz wszystkie zmiany od bazowej wersji do wersji spełniającej resztę zasad SOLID dlatego każde zastosowanie zasady zostało oznaczone tagiem aby łatwiej się było poruszać.

Tutaj są bezpośrednie odnośniki do kodu z tego artykułu:
https://github.com/androidCoder-pl/SOLID/tree/Base
https://github.com/androidCoder-pl/SOLID/tree/SRP

Podsumowanie

Z historii diagramów możemy wywnioskować, że nasz program nieco się powiększył i jego struktura jest bardziej skomplikowana, jednakże dzięki takiemu rozbiciu funkcjonalności nasz program stał się bardziej bardziej elastyczny na wszelkie modyfikacje oraz mniej podatny na zmiany.

Pierwsza wersja
Druga wersja
Trzecia wersja

Zastosowanie pierwszej zasady SOLID (SRP) ma niebagatelny wpływ na przyszły rozwój projektu, odpowiedni podział funkcjonalności na mniejsze klasy zaprocentuje przede wszystkim podczas pracy w zespole wieloosobowym, w którym kilka osób może pracować jednocześnie nad tą samą funkcjonalnością. Tworzenie wielkich klas z mnóstwem kodu zawierającego logikę uniemożliwia lub skutecznie utrudnia taką pracę. W momencie gdy zamiast 2 dużych klas zastosujemy 6 mniejszych wyspecjalizowanych klas to zespół może pracować równolegle bez konieczności zamartwiania się o konflikty podczas publikacji zmian (o ile nie zmieniają się interfejsy lecz sama implementacja). Mniejsze klasy to także mniejsze zmiany, czyli mniej czasu poświęconego na Code Review.

Umieszczenie każdej małej funkcjonalności w osobnej klasie sprawia, że zmieniając jedną funkcjonalność edytujemy tylko niewielką ilości kodu. W monolicie niespełniającym SRP, zmiana małej funkcjonalności pociąga za sobą edycję wielkiego bloku kodu gdzie znajduje się kilka innych funkcjonalności – czy istnieje racjonalne wytłumaczenie dla takiego postępowania?

Zastosowanie SRP w kodzie bez wątpienia polepsza jego jakość, jednakże to co zauważalnie podnosi jakość kodu to połączenie SRP z OCP o czym przeczytasz w następnym artykule. (http://androidcoder.pl/index.php/2019/03/07/solid-cz-2-openclose-principle/)

Bibliografia

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

[2]https://www.javacodegeeks.com/2011/11/solid-single-responsibility-principle.html

[3] https://www.youtube.com/watch?v=L2m-S0Pj_Xk

[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

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

Mateusz Chrustny

Dodaj komentarz

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