Wstęp
Każdy żyjący system trzeba usprawniać, dodawać nowe funkcjonalności i dostosowywać do zmieniających się warunków. Wymaga to zmian które znacząco ingerują w istniejący kod źródłowy.
Jeśli jest to aplikacja na jedno wydarzenie lub sezon konsekwencje złych decyzji projektowych mogą być nawet niezauważone. Jeśli aplikacja, ma służyć użytkownikom przez wiele lat np. aplikacje bankowe to każda zła decyzja projektowa będzie wiązała się ze zwiększonymi kosztami utrzymania w przyszłości. Czasami klient jest przygotowany na zaciągnięcie długu technologicznego w celu zmniejszenia kosztów początkowych np. startupy – jeśli nie wyjdzie stracimy mniej, jeśli wypali to spłacimy dług.
W jaki sposób poradzić sobie ze zmianami wymagań, zmianami w platformie, nowymi funkcjonalnościami i zarządzaniem starym kodem tak aby zminimalizować koszty?
Już w roku 1988 Bertrand Mayer w swojej książce „Object-Oriented Software Construction” przedstawił jedną z ważniejszych zasad dobrego tworzenia oprogramowania, którą 8 lat później Robert C. Martin sparafrazował jako Open/Close Principle (dalej OCP) :
Elementy oprogramowania (klasy, moduły, funkcje etc.) powinny być otwarte na rozbudowę ale zamknięte na modyfikacje.
Oznacza to, że dodanie nowych funkcjonalności lub zmiana starych funkcjonalności nie wiąże się ze zmianą istniejących klas lecz powoduje pojawienie się nowych. Nowe klasy mogą modyfikować funkcjonalności już istniejącego programu. Stare klasy są zamknięte i możemy albo je użyć albo usunąć.
Polecam obejrzeć świetne wstąpienie Grega Younga „The art of destroying software” https://vimeo.com/108441214:
Greg Young – The art of destroying software from tretton37 on Vimeo.
Zmiana działania zachowania programu bez edycji istniejących klas jest możliwa poprzez zastosowanie abstrakcji oraz wstrzykiwania zależności.
Abstrakcja klasy (klasa abstrakcyjna, interfejs) jest jak szablon, za pomocą którego możemy tworzyć wiele implementacji. Z zewnątrz implementacje wyglądają tak samo (mają takie same metody publiczne) ale ich zachowanie jest inne. Dzięki czemu klasa korzystająca z abstrakcyjnej klasy nie musi mieć żadnej informacji o sposobie działa implementacji.
Wstrzykiwanie zależności pozwala na budowanie docelowej klasy z wielu małych części, dzięki czemu możemy zmieniać sposób działania aplikacji bez konieczności modyfikacji samej klasy. Zazwyczaj wstrzykiwanie zachodzi w konstruktorze poprzez podanie wstrzykiwanej klasy jako parametry konstruktora lub przez metodę, która ustawia referencję do zmiennej już po zbudowaniu obiektu.
Przykład 1: Zapis Ebooków
Jako pierwszy przykład posłuży program z poprzedniego artykułu SOLID cz.1 – Single Responsibility Principle . Jest to program, który może zapisać ebooki w różnych formatach.

Jeśli pojawi się nowe wymaganie żeby zapisywać ebooki w formie plików PNG to dodanie PNGEBookSaver
nie spowoduje zmiany implementacji, żadnej z powyższych klas. Kod ten jest zamknięty na zmiany ale otwarty na rozszerzenia związane z typem zapisu EBooka.

A co jeśli będziemy chcieli dodać różne miejsca zapisywania ebooków? Na przykład w chmurze, udostępnienie na OneDrive czy Google Drive? Można stworzyć implementacje OneDrivePDFEBookSaver
GoogleDriveEPUBEBookSaver
jednak nie jest to skalowalne, przy pojawieniu się dodatkowej metody zapisu trzeba stworzyć każdorazowo implementację do każdego formatu a jeśli pojawi się nowy format zapisu to trzeba dodać klasy do metody zapisu.
Zauważ, że OCP jest zawsze względne jakiejś funkcjonalności. Np. EBookSaver
jest otwarty na zmianę formatu zapisywania ale nie jest otwarty na zmianę miejsca zapisywania.
Aby umożliwić elastyczną zmianę miejsca zapisywania trzeba wyekstrahować kod odpowiedzialny za wybór miejsca zapisywania. Dlatego stworzyłem interfejs Destination
, który posiada jedną metodę save(file : FileData)
. Od tego momentu można do programu dodawać dowolne miejsca zapisywania pliku.

Co więcej można mieszać wszystkie implementacje EBookSaver
oraz Destination
. Powyższy kod spełnia OCP dla zmian typu zapisywanego ebooka oraz miejsca jego zapisu.
Może istnieć wiele różnych zmian, na które powyższy program nie spełnia OCP. Jednak tworząc oprogramowanie musisz rozróżnić zmiany, które mogą faktycznie zaistnieć od tych nieprawdopodobnych. Uważaj na overengineering (czyli przekombinowanie) – w praktyce wprowadza się OCP dopiero podczas pierwszej zmiany, ponieważ wcześniej takie dostosowanie może okazać się niepotrzebne. Stosowanie TDD często wymusza stworzenie abstrakcji i jednocześnie doprowadzanie do spełnienia OCP.
Przykład 2: Tabela ASCI
Drugim przykładem będzie także problemem z pierwszego artykułu SOLID cz.1 – Single Responsibility Principle . Dla przypomnienia jest to program, który wyświetla w konsoli listę użytkowników w formie tabeli ASCI. Jest już doprowadzony do postaci spełniającej SRP co uprości refaktoryzację.
Cały kod źródłowy przykładu sprzed refaktoryzacji znajdziesz tutaj:
https://github.com/androidCoder-pl/SOLID/tree/SRP
a tutaj kod po refaktoryzacji:
https://github.com/androidCoder-pl/SOLID/tree/OCP
public class Main { public static void main(String[] args) { UserTable userTable = new UserTable(); userTable.writeUserTableOnScreen(); } }
public class UserTable { public void printTable() { UsersData usersData = new UsersData(); UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter(); usersTableScreenWriter.writeTableOnScreen(usersData.getUsers()); } }
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; } }
public class UsersTableScreenWriter { public void writeTableOnScreen(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); } }
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"); } }
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()); } }
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 String getLastName() { return lastName; } public String getId() { return id; } }
Klasy tego programu, jak się domyślasz nie spełniają OCP, ponieważ nie znalazłyby się w tym artykule. No to lecimy po kolei:
OCP dla UsersData
Co by się stało gdyby trzeba było odczytać dane użytkowników z pliku lub bazy danych? Musiałbyś zmodyfikować klasę UsersData
lub stworzyć nową np.FileUsersData
(co pociągnęłoby za sobą zmianę konstruktora w klasie UserTable
). Aktualne rozwiązanie jest mało elastyczne, ponieważ każdorazowa zmiana źródła danych pociąga za sobą zmianę w klasie UsersData
lub UsersTable
– kod nie jest dostosowany do zmian.
Odczytywanie danych z pliku JSON
Dodajmy obsługę odczytywania danych z plików JSON.
Żeby spełnić OCP i uelastycznić nieco ten kod trzeba wyabstrahować dostarczanie danych w aplikacji. I tak powstał interfejs UsersData
, który ma jedną metodę getUsers()
. Interfejs nie zawiera żadnych szczegółów dotyczących pochodzenia danych – jedynie je dostarcza.
public interface UsersData { List<User> getUsers(); }
W kodzie powstał mały konflikt, ponieważ jest już klasa o nazwie UsersData
, która dostarcza dane stworzone lokalnie. Dlatego zmieniłem jej nazwę na LocalUsersData
dzięki czemu nazwa klasy wskazuje jaka jest to implementacja (Odradzam tworzenia klas z nazwami kończącymi się *Impl
np. UsersDataImpl
. Nazwa taka nie przenosi żadnej wartości i jest to zazwyczaj znak, że abstrakcja jest tu nie potrzebna albo jest sztuczna)
Po zmianie nazwy zaimplementowałem UsersData
w klasie LocalUsersData
:
public class LocalUsersData implements UsersData { private List<User> users = new LinkedList<>(); public LocalUsersData() { 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; } }
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; } }
Skoro kod spełnia już OCP można dodać implementację, która pozwala na odczytywanie danych z plików JSON. Operacja jest banalnie prosta, musisz stworzyć klasę, która implementuje UsersData
i w metodzie getUsers()
zwraca użytkowników pobranych z pliku JSON:
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; } }
Teraz możesz tworzyć klasy pochodne, które dostarczają dane użytkowników z różnych źródeł: z internetu, z bazy danych, z plików, z mowy etc..
public class UserTable { public void printUserTableOnScreen() { File file = new File("users.json"); UsersData usersData = new JsonFileUsersData(file, new Gson()); UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter(); usersTableScreenWriter.writeTableOnScreen(usersData.getUsers()); } }
public class UserTable { public void printUserTableOnScreen() { UsersData usersData = new LocalUsersData(); UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter(); usersTableScreenWriter.writeTableOnScreen(usersData.getUsers()); }
Jak już pewnie zauważyłeś, zmiany w UserTable
będą występowały za każdym razem gdy będzie trzeba zmienić źródło danych. Dlatego trzeba się nią zająć!
OCP dla UserTable
Aby uniknąć modyfikowania UserTable
przy zmianie źródła danych, należy zastosować wstrzykiwanie zależności, czyli przekazanie obiektu UsersData
jako parametr konstruktora klasy UserTable
.
public class UserTable { private UsersData usersData; public UserTable(UsersData usersData) { this.usersData = usersData; } public void writeTableOnScreen() { UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter(); usersTableScreenWriter.writeData(usersData.getUsers()); } }
public class UserTable { public void printUserTableOnScreen() { File file = new File("users.json"); UsersData usersData = new JsonFileUsersData(file, new Gson()); UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter(); usersTableScreenWriter.writeTableOnScreen(usersData.getUsers()); }
Nastąpiła także zmiana w metodzie main
przy tworzeniu klasy UserTable
gdzie w konstruktorze trzeba podać źródło danych:
public class Main { public static void main(String[] args) { File file = new File("users.json"); UsersData usersData = new JsonFileUsersData(file, new Gson()); UserTable userTable = new UserTable(usersData); userTable.writeUserTableOnScreen(); } }
public class Main { public static void main(String[] args) { UserTable userTable = new UserTable(); userTable.writeUserTableOnScreen(); } }
Jak na razie sprawiliśmy, że klasa UsersData
oraz UserTable
spełniają zasadę OCP dla zmiany źródła danych. To wszystko za sprawą jednego interfejsu UsersData
. Dzięki niemu gdy zmienią się wymagania lub pojawią się nowe nie będzie już konieczne edytowanie starych klas – stworzycie nowe a stare i niepotrzebne usuniecie.
OCP dla UsersTableScreenWriter
Gdyby zaszła potrzeba wypisania danych na ekranie mającym interfejs USB, Serial, HTTP lub chcielibyśmy zapisać tabelę do pliku, to konieczna byłaby edycja klasy UsersTableScreenWriter
lub napisanie nowej klasy co z kolei prowadziłoby do zmian w klasie UserTable
. UsersTableScreenWriter
nie spełnia OCP w kontekście zmiany miejsca wyświetlenia tabeli.
Tak jak przy klasie UsersData
trzeba stworzyć bardziej abstrakcyjną konstrukcję aby móc tworzyć nowe „writery” bez konieczności edytowania istniejącego kodu. I tak powstał interfejs UsersWriter
z jedną metodą writeData(List usersData)
public interface UsersWriter { void writeData(List<User> usersData); }
Następnym krokiem jest zaimplementowanie przez UsersTableScreenWriter
interfejsu UsersWriter
:
public class UsersTableScreenWriter implements UsersWriter{ 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); } }
public class UsersTableScreenWriter { public void writeTableOnScreen(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); } }
W miejscu gdzie jest używana klasa UsersTableScreenWriter
, użyję interfejsu UsersWriter
tak aby można było wykorzystać każdą inną implementację bez konieczności zmiany typów. UsersTableScreenWriter
jest używany w jednym miejscu w UserTable
.
Jeszcze raz OCP dla UserTable
UserTable
spełnia OCP dla zmiany źródła danych ale nie spełnia dla zmiany miejsca wyświetlania. Każdorazowa zmiana wyświetlania powoduje konieczność zmiany implementacji UserTable
. Dlatego należy:
- Użyć interfejsu
UsersWriter
zamiast konkretnej implementacjiUsersTableScreenWriter
- Dodać wstrzykiwanie zależności dla
UsersWriter
public class UserTable { private UsersData usersData; private UsersWriter writer; public UserTable(UsersData usersData, UsersWriter writer) { this.usersData = usersData; this.writer = writer; } public void printTable() { writer.writeData(usersData.getUsers()); } }
public class UserTable { private UsersData usersData; public UserTable(UsersData usersData) { this.usersData = usersData; } public void writeTableOnScreen() { UsersTableScreenWriter usersTableScreenWriter = new UsersTableScreenWriter(); usersTableScreenWriter.writeData(usersData.getUsers()); } }
UserTable
z listingu OCP 2.10 może stać się dla niektórych bardzo kontrowersyjny, ponieważ funkcja printTable
w rzeczywistości wywołuje tylko 2 metody. To jest główny wynik stosowania prawidłowo zasad SRP i OCP – klasy stają się bardzo małe i testowalne. Wywołanie metody writer.writeData(usersData.getUsers())
w funkcji main uniemożliwiłoby jej przetestowanie i sprawiłoby, że byłaby mniej wyeksponowana niż tutaj.
OCP dla UsersTableScreenWriter
Patrząc na powyższy diagram można zauważyć, że UsersTableScreenWriter
posiada zależność, która może w przyszłości się zmieniać i jest nią klasa UserListTableConverter.
Odpowiada ona za konwersję obiektów User
na tabelę w wersji tekstowej. W większości przypadków elementy odpowiedzialne za prezentację danych najczęściej podlegają modyfikacjom, dlatego jest bardzo prawdopodobne, że UserListTableConverter
będzie wymagał innych implementacji wraz z rozwojem systemu.
Rozwiązaniem jest oczywiście stworzenie abstrakcji klasy UserListTableConverter
i zastosowanie podobnie jak poprzednio wstrzykiwania zależności w klasie UsersTableScreenWriter
.
public interface UsersToStringConverter { String toString(List<User> usersList); }
public class UserListTableConverter implements UsersToStringConverter{ 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"); } }
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"); } }
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 UsersTableScreenWriter implements UsersWriter { 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); } }
Diagram w porównaniu do stanu początkowego mocno się rozrósł a to jeszcze nie koniec. Zastosowanie OCP niesie za sobą pewne koszty, którymi jest zmniejszona czytelności przez rozproszenie logiki i utrudnione debugowanie jednak zalety jakie niesie stosowanie OCP przeważają jej wady.
OCP dla UserListTableConverter
Ostatnią klasą która nie spełnia OCP jest UserListTableConverter
, która jest zależna od StringTableBuilder
, który odpowiada za rysowanie tabeli. Jak spojrzymy w jego implementację to zobaczymy, że jest ona poprawna tylko dla prostej tabeli o stałej szerokości, dlatego jeśli łańcuchy znaków będą bardzo długie to klasa StringTableBuilder
nie będzie wystarczająca i pojawi się konieczność stworzenia nowej implementacji do rysowania tabeli. (Nie tworzymy od razu super uniwersalnych wersji klas, ponieważ zdarza się, że nigdy nie będzie nam potrzebna lepsza wersja a czasami nie wiemy jakie będą w przyszłości wymagania zatem poświęcanie zbyt wielu zasobów na napisanie czegoś bardzo uniwersalnego czasem nie ma sensu a korzyści są znikome lub nawet pojawiają się duże straty.)
Z StringTableBuilder
postępujemy tak samo jak w przypadku poprzednich klas, czyli zmieniamy nazwę klasy na FixedColumnStringTableBuilder
, tworzymy interfejs o nazwie StringTableBuilder
i stosujemy wstrzykiwanie zależności w UserListTableConverter
public interface StringTableBuilder { void drawRow(String... row); void drawLine(); String getTable(); }
public class FixedColumnStringTableBuilder implements StringTableBuilder { private StringBuilder tableBuilder = new StringBuilder(); @Override 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])); } @Override public void drawLine() { tableBuilder.append(String.format("%40s%n", "").replace(" ", "-")); } @Override public String getTable() { return tableBuilder.toString(); } @Override public void clear() { tableBuilder.delete(0, tableBuilder.length()); } }
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()); } }
public class UserListTableConverter implements UsersToStringConverter { private StringTableBuilder stringTableBuilder; public UserListTableConverter(StringTableBuilder stringTableBuilder) { this.stringTableBuilder = 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"); } }
public class UserListTableConverter implements UsersToStringConverter{ 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"); } }
Sprawienie, że StringTableBuilder
jest interfejsem daje możliwość budowania tabeli z różnych znaków nie ingerując w resztę kodu.
Funkcja main
Jak widać zastosowanie OCP w programie wymagało sporo pracy i zmian. Finalnie funkcja main
nie spełnia OCP, ponieważ jest miejscem gdzie możemy podmieniać nasze moduły bez konieczności edytowania reszty klas. W każdym programie istnieją takie miejsca, w których konfigurujemy stworzone obiekty i dokonujemy wstrzykiwania zależności. Dlatego też bardzo ważne było stworzenie klasy UserTable
, która oddziela logikę programu od konfiguracji.
public static void main(String[] args) { File file = new File("users.json"); UsersData usersData = new JsonFileUsersData(file); StringTableBuilder stringTableBuilder = new ThreeColumnStringTableBuilder(); UserStringConverter userStringConverter = new UserListTableConverter(stringTableBuilder); UsersWriter usersWriter = new UsersTableScreenWriter(userStringConverter); UserTable userTable = new UserTable(usersData, usersWriter); userTable.printTable(); }
Gdybyśmy chcieli zmienić źródło danych i sposób rysowania tabeli wystarczy dokonać zmiany tylko w 2 liniach:
public static void main(String[] args) { File file = new File("users.json"); UsersData usersData = new CsvUserData(file); StringTableBuilder stringTableBuilder = new FlexibleStringTableBuilder(); UserStringConverter userStringConverter = new UserListTableConverter(stringTableBuilder); UsersWriter usersWriter = new UsersTableScreenWriter(userStringConverter); UserTable userTable = new UserTable(usersData, usersWriter); userTable.printTable(); }
taka konstrukcja pozwala na niesamowitą elastyczność bez konieczności częstych refactoringów istniejących klas. Niektórzy mogą stwierdzić, że projektowanie modułów w ten sposób jest przerostem formy nad potrzebą ale czasami lepiej jest zainwestować trochę więcej czasu aby w późniejszym etapie realizacji projektu nie dojść do sytuacji w której trzeba cały kod napisać od początku co generuje wielkie koszty, obniża produktywność zespołu oraz może generować błędy.
Cały kod źródłowy przykładu sprzed refaktoryzacji znajdziesz tutaj:
https://github.com/androidCoder-pl/SOLID/tree/SRP
a tutaj kod po refaktoryzacji:
https://github.com/androidCoder-pl/SOLID/tree/OCP
Podsumowanie
Zastosowanie OCP w projekcie pozwala na stworzenie mocno zmodularyzowanego kodu, który zwiększa elastyczność na zmiany. W praktyce jest prawie niemożliwe napisanie kodu w 100% zgodnego z OCP, ponieważ dodanie nowego elementu do istniejącego kodu zawsze wymaga jakiejś zmiany w istniejącym programie. Wyjątkiem są np. pluginy ale napisanie całej aplikacji w ten sposób jest raczej nieopłacalne. Naszym zdaniem jest zrobić to tak aby ograniczyć ilość takich miejsc nie do minimum ale do optimum czyli do miejsca w którym czytelność naszego kodu nie pogorszy się znacznie, elastyczność będzie na wysokim poziomie i koszty tego przedsięwzięcia będą akceptowalne.
To czy jest sensowne ekonomicznie tworzenie tych wszystkich abstrakcji i wstrzykiwania zależności zależy od projektu i tworzenie ich na zaś spowoduje zwiększenie kosztów ale też nie zwiększy późniejszych kosztów utrzymania projektu. Zatem lepiej trochę (ale tylko trochę) przedobrzyć niż odpuścić.
Poprawne zastosowanie OCP jest praktycznie niemożliwe bez wcześniejszego zastosowania SRP (Single Responsible Principle), a próba zrobienia tego może skończyć się katastrofalnie dla projektu – powstaną olbrzymie interfejsy. Połączenie SRP i OCP diametralnie zwiększa jakość kodu co daje się odczuć podczas zmieniających wymagań i dodawania nowych funkcjonalności. Im projekt dłużej będzie trwał tym korzyści będą bardziej zauważalne.
Wprowadzenie OCP sprawi, że pisanie testów jednostkowych będzie o wiele łatwiejsze. Zastosowane wstrzykiwania zależności oraz liczne abstrakcje, ułatwią mockowanie klas.
Bibliografia
[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