Jak uchronić się od straszliwej architektury?
Czy struktura programu komputerowego i budynek Empire State Building mają jakieś cechy wspólne? Słowo architektura w kontekście programowania i projektowania budynków ma jedną negatywną cechę wspólną, otóż nie potrafimy cofnąć czasu, przez co błędy popełnione we wczesnych fazach projektowania rzutują na wszystkie kolejne elementy projektu.
Skutki tego problemu są najczęściej niezwykle kosztowne. W przypadku budynków, w zależności od zaawansowania problemu, może być konieczna zmiana kształtu, bądź materiałów, z których budowla jest wykonana. Jeżeli chodzi o architekturę oprogramowania, to pominięcie niektórych składowych w trakcie projektu, może spowodować, że system nie będzie dostatecznie wydajny, skalowalny oraz odporny na zmiany. W obu tych przypadkach im później zostaną wykryte problemy, tym większe koszty mogą zostać poniesione przy próbie naprawy, czasem bywa nawet za późno, by przeprowadzić refaktoryzację, przez co taniej jest zburzyć system i postawić nowy.
W obu przypadkach na straży projektu stoją architekci, którzy mają za zadanie zaprojektować system, bądź budynek, tak, aby spełniał wszystkie wymagania klienta, a do tego był bezpieczny i stabilny. Również po zaplanowaniu architekci są niezbędni do kontrolowania poczynionych prac i ewentualnego wprowadzania modyfikacji. Niestety błędów i zmian nie da się uniknąć, przez co każdy projekt powinien mieć osobę odpowiedzialną za jego strukturę oraz spójność, pozwoli to na prostszy rozwój projektu.
Architektura oprogramowania
Komponenty
Skupiając się na architekturze oprogramowania, jedną z podstawowych cech są komponenty oraz granice pomiędzy nimi (z ang. boundaries). Komponent to fragment oprogramowania, który realizuje pewną funkcję. Może być związany np. z logiką drukowania faktur, bądź tworzenia rezerwacji użytkowników. Jednak bardzo istotnym elementem jest wydzielenie granic pomiędzy tymi komponentami. W idealnym systemie komponenty powinny być niezależne od siebie i jedynie komunikować się poprzez przeróżne protokoły.
Podejście to pozwala na ograniczenie zależności w systemie pomiędzy tymi komponentami. W rezultacie zmiany w jednym komponencie nie powinny w żaden sposób wpływać na inny. Ułatwia to edycję i rozszerzanie kodu, ponieważ w dobrym systemie, gdy zmienimy sposób wysyłania faktur poprzez maila na inny, nie powinno to nieść za sobą zepsucia się drukowania tych faktur. A nierzadko w projektach informatycznych takie sytuacje mają miejsce, poprzez błędną architekturę i błędne wydzielenie granic.
Młodzi programiści ulegają często pokusie użycia metod, które realizują trochę inną funkcjonalność, w miejscu, gdzie nie do końca powinny się znajdować. Przypuśćmy system sklepowy, w którym generowany jest tekst wiadomości mailowej, a następnie wysyłany do klienta. W błędnej architekturze, w klasie realizującej wysyłanie maili, zostanie zaimplementowana metoda, która generuje tekst wiadomości. System działa przez kilka miesięcy, jednak powstała potrzeba wysłania potwierdzenia zakupu poprzez wiadomość SMS. Programiści dopisują kolejny komponent który odpowiada za za wysłanie tej wiadomości poprzez system telekomunikacyjny. Sprawa była dość pilna, gdyż klienci na to naciskali, zatem programiści postanowili użyć metody generowania tej wiadomości z komponentu wysyłania maili. Przez to powstała zależność pomiędzy smsami, a mailami.
System znów działa, ale biznes znów zaczął naciskać, aby dodać logo do wiadomości mailowej. Zadanie zostało zlecone osobie, która wcześniej nie zajmowała się tymi logikami. Programista ten wszedł w komponent wysyłania maili oraz edytował metodę do generowania wiadomości. Następnie przetestował, gdzie wszystko działało, zatem zostało to wdrożone na środowisko produkcyjne.
Ciąg dalszy i... wysoki coupling
Po kilku godzinach okazuje się, że nagle wiadomości SMS przestały docierać do klientów, programista nie spodziewał się, że klasa związa z mailami może rzutować na wiadomości SMS. W systemie tym powstała niepotrzebna zależność pomiędzy mailami i smsami. Miarę takiej zależności nazywamy couplingiem. Możemy więc stwierdzić, że jest wysoki coupling pomiędzy wiadomościami mailowymi, a smsami.
W tym przypadku należałoby wydzielić interfejs do generowania wiadomości i zaimplementować go dla wiadomości mailowych, wtedy sam w sobie interfejs zawiera informacje, które mogą być bezpiecznie dzielone przez wiele logik, a dopiero implementacje zawierają szczegółowe informacje dla pojedynczych logik.
W następnym kroku należało na tej samej zasadzie dodać implementację dla wiadomości SMS, części wspólne używane przez te dwie implementacje mogły zostać zaimplementowane w jeszcze innej klasie, która odpowiada np. za przekształcanie tekstu. Taka architektura pozwala na prostszy sposób rozszerzanie funkcjonalności systemu, a najlepiej byłoby, gdyby te interfejsy wczytywały tekst z zewnętrznego pliku, który powinien być edytowany jedynie w przypadku zmian. Taki kod byłby w luźnym couplingu, ponieważ komponent maili oraz SMS-ów byłby ze sobą niezwiązany. W dodatku kod byłby odporny na wprowadzanie zmian, ponieważ jedynie plik konfiguracyjny wiadomości były zmieniany.
Wspomniany coupling to miara która pozwala na ocenienie zależności pomiędzy komponentami. Najbardziej pożądany jest luźny coupling, czyli sytuacja, gdy klasy, bądź komponenty są jak najmniej ze sobą powiązane i jak najmniej o sobie wiedzą. Pozwala to na pozbycie się problemów z modyfikacją jednego elementu który może skutkować błędem w innym elemencie systemu. Dlatego zawsze najlepiej jest ograniczać informacje o całym systemie, które są przetrzymywane w klasach.
Kohezja
Kolejną miarą architektury jest kohezja. Mówi ona o tym, w jakim stopniu klasa robi tylko to, za co jest odpowiedzialna. Najłatwiejszym sposobem na zmierzenie kohezji jest sprawdzenie ile atrybutów klasy jest używanych pomiędzy metodami w niej. Idealna klasa posiadająca wysoką kohezję używałaby w każdej metodzie wszystkich atrybutów. Takie podejście pozwala na ochronę klasy przed modyfikacją, a kod według zasady open-closed powinien być zamknięty na modyfikację, a otwarty jedynie na rozszerzenia. Co więcej, w momencie, kiedy metoda używa wszystkich atrybutów, mamy właściwie pewność, że jest odpowiedzialna tylko za jedną rzecz, z którą wszystkie metody są związane, zatem realizuje również zasadę single responsibility.
W każdym projekcie powinniśmy dążyć do luźnego couplingu oraz wysokiej kohezji. System tak zbudowany można w prosty sposób rozszerzać, nie obawiając się o nagłe awarie. Ponadto odporny jest na modyfikację, które mogą zaburzyć jego działanie.
Poniższy przykład obrazuje drukowanie oraz wysyłanie faktur w typowym systemie. Przedstawione zostaną dwa przykłady: pierwszy błędny z niską kohezją oraz drugi z luźnym couplingiem i wysoką kohezją.
// Don’t write like that!
public class Invoice {
private String sender;
private String title;
private String formatedContent;
private Config printerConfig;
public void sendInvoice(String email) {
//some implementation
}
public void printInvoice() {
//some implementation
}
}
W klasie tej przechowywane są informacje o fakturze wraz z metodami, które pozwalają na wysłanie oraz drukowanie jej. Jak można się domyślić, w przypadku metody sendInvoice
jedynie atrybut sender
oraz formatedContent
są potrzebne, a pozostałe są zupełnie ignorowane. W przypadku printInvoice
jest używany formatedContent
i printerConfig
. W tej klasie występuje zaburzenie odpowiedzialności, ponieważ klasa przechowuje informacje o fakturze, pozwala na wysłanie jej oraz na wydrukowanie, przez co ta logika odpowiada za zbyt wiele rzeczy.
Dodatkowo problemem jest dzielenie zawartości formatedContent
poprzez metody sendInvoice
oraz printInvoice
. W momencie, gdy niezbędne będzie dodanie klauzuli o poufności do wiadomości mailowej, zostanie też dołączona do drukowanej faktury, co akurat w niektórych przypadkach nie byłoby pożądane. Ponadto problemem jest czytelność takiej klasy. Gdy jakikolwiek programista będzie chciał cokolwiek zmienić, spotka się z ogromnym szumem informacyjnym, który będzie musiał przeanalizować przed jakimkolwiek przystąpieniem do pracy.
Lepszym rozwiązaniem tego problemu byłoby rozbicie klasy Invoice
na klasę InvoiceData
oraz interfejsy: IMailSender
, IPrinter
i IInvoiceTextFormatter
. Całość mogłaby wyglądać w następujący sposób:
public class InvoiceData {
private Money amount;
private InvoiceUser invoiceUser;
private Product product;
}
public interface IInvoiceTextFormatter{
String formatText(InvoiceData invoiceData);
}
public interface IMailSender{
void sendMail(String formattedText, String email);
}
public interface IPrinter {
void print(String formattedText);
}
Każdy z interfejsów posiadałby odpowiednią implementację, co pozwala na pozbycie się zbyt wielu atrybutów wewnątrz klas. Takie podejście ułatwia czytelność i pozwala w prostszy sposób na rozszerzanie kodu. W przypadku zmiany sposobu drukowania, edytujemy jedynie implementację interfejsu IPrinter
, która nie jest powiązana z pozostałymi klasami i taka zmiana może być bezpiecznie wprowadzona bez większych obaw o pozostałe funkcjonalności systemu.
Złoty środek
Warto też pamiętać, że nie wszędzie są potrzebne takie mechanizmy. Żadne wzorce i oddzielone interfejsy nie zastąpią dobrze napisanego kodu. Należy dobrze przemyśleć, które elementy systemu mogą się zmieniać i czy chcemy na to pozwolić poprzez opisane wyżej metody.
Jednak istnieją również części systemu, które pozostają niezmienne i tam nie musimy się skupiać na tym, aby kod był dostatecznie elastyczny. Każde uelastycznienie systemu jest kosztowne, ponieważ często zajmuje więcej czasu. Powinno się odpowiednio dobierać metody do postawionych problemów, bo zbyt długa praca może również zabić projekt. Warto w tym wszystkim znaleźć złoty środek, który pozwoli na szybki i stabilny rozwój systemu, bez zakrywania intencji kodu.
Musimy brać pod uwagę to, że każdy kod da się zepsuć. Istnieją jednak pewne mechanizmy architektury, które to utrudniają, szczególnie w projektach prowadzonych przez wiele osób, gdzie miesza się wiele wizji i standardów programowania. Dobry projekt pozwala na szybki rozwój i poprawne działanie oprogramowania, co jest szczególnie istotne w biznesie. Zatem jeżeli kiedykolwiek będziecie wkurzeni na swojego architekta, że dodaje Wam więcej pracy, to spróbujcie spojrzeć na to od innej strony, może akurat chroni Was przed betonowaniem systemu.