6 błędów projektowych architektury systemów informatycznych
Podczas moich przygód programistycznych, zauważyłem kilka powodów dlaczego w pewnym momencie praca na projekcie, to raczej piekło niż frajda z kodowania. Chciałbym opowiedzieć o swoich przemyśleniach na ten temat i wskazać co należy zrobić, aby tego uniknąć.
Mariusz Walczak. Tech lead w Softfin. Absolwent Warszawskiej Wyższej Szkoły Informatycznej. Pasjonat inżynierii oprogramowania, swoje aplikacje tworzy w PHP i językach opartych na ES6/7 Prywatnie miłośnik futrzanych czworonogów, oraz winiarstwa i nalewkarstwa.
Spis treści
Zbytnie uszczegółowienie projektu na jego starcie
Kiedy dostajemy zadanie, aby zapoznać się z dokumentacją projektową, w myślach zaczynamy sobie wszystko układać. Myślimy sobie: dobra, tutaj strzelimy hybernate, tutaj połączenie do oracla, a tutaj użyjemy RESTa i będzie wszystko grało. To jest błąd numer jeden.
Takie uszczegółowienie projektu może doprowadzić do radzenia sobie z problemami implementacyjnymi. Nie lubię pracować z wizją, że muszę na zaś przewidzieć wszystkie wyjątki, bo ktoś zaplanował jakieś rozwiązanie już na samym początku naszej drogi z projektem. Przytoczmy sobie przykład.
Podejście pierwsze
Chciałem robić sobie bazę danych z rozkładu jazdy autobusów. Dostałem dostęp do API. Pierwsze co pomyślałem: to mała aplikacja, więc tu się robi pobranie danych, zapisze sobie ją do mysqla i wyrzucę restem do serwisu zewnętrznego. Jak zaplanowałem tak zrobiłem, pierwsze problemy jakie natrafiłem to relacyjność w rozkładach jazdy, okazało się bowiem, że te same autobusy, mają więcej niż jedną trasę, ba niektóre nawet mają tych tras 6. Więc postanowiłem przebudować bazę, o trasy, wybierałem sobie przystanki początkowe i końcowe, znów skucha, niektóre trasy mają przystanki krańcowe, a albo tymczasowe, więc wyszukiwanie połączeń pomiędzy przystankami mocno się skomplikowało, bo czasem wychodziły głupoty, gdyż program nie mógł odróżnić czy to jest początek czy koniec trasy.
Uff, ostatnie podejście — stwierdziłem. Zrobiłem trasy ze wszystkimi przystankami, ustaliłem przystanki i następny przystanek per linia. Zacząłem się mocno już gubić w tym wszystkim.
Powalczyłem z tym jeszcze dwa, trzy tygodnie i odpuściłem, bieganie po tej bazie danych było dla mnie koszmarem, utknąłem na szczegółach, straciłem frajdę z tego projektu.
Podejście drugie
Po jakimś czasie stwierdziłem, że tak proste zadanie nie może mnie pokonać. Zmieniłem podejście, na początku przygotowałem ogólne założenia. Brzmiały one tak: pobieram dane, przetwarzam je, zapisuje sobie, a następnie wysyłam do aplikacji frontendowej. Przystąpiłem do wykonania zadania. Najpierw napisałem fragment, który komunikuje się z API. Następnie ustawiłem zapis do plików, bo był najszybszy do ustalenia, ale postawiłem interface, do którego wysyłam dane, a on podejmuje decyzje, co dalej. W moim przykładzie wysyłam do prostej klasy obiekt, który serializowałem i zapisywałem do plików.
Jak już to miałem w plikach, był też i interface komunikacyjny z jakimś rodzajem bazy danych, ten interface miał tylko dwie metody, zapisz i pobierz, więcej mi nie było trzeba. Stworzyłem kolejny interfejs do komunikacji z aplikacją frontendową. Interface był wyposażony w kilka metod pobierających dane z interfejsu bazodanowego. Zdecydowałem się w tym momencie, że ustawie to na REST, przygotowałem sobie mocki JSONów jakie bym chciał wysyłać, a następnie dane, które otrzymałem przetwarzałem do tych JSONów. Aplikacje frontendową postawiłem sobie na Angularze, bo tak było mi wygodniej.
Tym razem udało mi się wykonać zadanie i nie powiem, sprawiło mi to wielką satysfakcję. Co odróżniało te dwa projekty w drugim, wybór konkretnej technologii pozostawiłem na sam koniec. Stworzyłem tylko uniwersalne interfejsy komunikacyjne, co mi pozwoliło odwlec decyzję o konkretnych technologiach jeszcze później. To opóźnienie decyzji sprawiło, że mogłem wybrać najlepszą technologię do sytuacji w jakiej się znajdowałem, a nie dostosowywać wszystko do technologii.
Wtedy za bardzo się skoncentrowałem na utworzeniu wzorca danych w relacyjnej bazie danych, jak się okazało odwlekając tę decyzję, okazało się, że pliki wystarczają. Borykałem się też z dostosowaniem danych do relacyjnej bazy danych w kontekście JSONów wychodzących do frontendu, w moim przypadku, stworzyłem mocki, a potem z danych jakie miałem sobie potworzyłem co potrzebowałem. Mogłem nawet pójść dalej na podstawie mocków stworzyć cały frontend, a na końcu to dopasować. Takie odwlekanie konkretnych decyzji do samego końca wyszło mi tylko na dobre.
Przez takie uszczegółowienie projektu na samym początku i walkę z ułomnościami technicznymi tych szczegółów w kontekście mojego zadania, dwa razy pożegnałem się z zespołem odchodząc od niego, bo trafiłem do piekła programistycznego, gdzie liczba nadgodzin wzrastała geometrycznie, bo ciągle coś nie działało jak powinno.
Niedopasowanie projektu do warunków w jakich będzie działał
To przypadek jednego z moich ostatnich projektów. Dostałem projekt, założyłem sobie, że backend postawie na symfony, a frontend na Angularze i będzie cudownie. Utworzyłem sobie dwa foldery w folderze projektowym. Jeden to frontend, drugi to backend. Utworzyłem sobie oczywiście vhosty i wszystko śmigało jak należy. Gdy aplikacja już prawie była gotowa, okazało się, że klienci zapomnieli powiedzieć o małej niespodziance.
Niespodzianką było wprowadzanie środowiska CI, opartego na dockerach, gdzie vhost jest przekierowywany na jeden plik, dowolny. Ta decyzja mnie bardzo mocno zdenerwowała. Nagle okazało się, że muszę popisać dockery, przebudować aplikacje, pogrzebać w pliku symfonowym, tak by część ruchu szła do symfony druga część do frontendu, bo nie mogliśmy grzebać w .htaccesach, plus jeszcze trzeba było wyeliminować znaczki dedykowane dla krajów, przy zapisu plików, np. graficznych, jakie dostawaliśmy od grafików.
Ostatni punkt ograłem ręczną zamianą, tak by Google widząc nazwę pliku graficznego czuł się lepiej z tym, że pasuje do alt, nie wiem czy to ma wpływ na SEO, bo się na tym nie znam, ale strzelam, że tak jest lepiej. Stworzenie dockerów, oraz przebudowa struktury, to 3 dni pracy i testowanie, dodatkowo mieliśmy jedną maszynę na wszystkie aplikacje, więc czekanie na deploy trwało czasem 30 minut. Na szczęście udało się to wszystko ogarnąć. Gdy już opanowaliśmy chaos, przyszła kolejna decyzja.
Okazało się, że wszystko ma wylądować do AWSa. Nie miałem doświadczenia z nim, na pomoc przyszła gotowa wtyczka i udało się wszystko ograć szybko i konkretnie. Zaliczyliśmy tylko 3 dni obsuwki, bo aplikacja była tworzona nie w warunkach jakich miała działać.
Nie zawsze jest tak dobrze, pamiętam jeden projekt, byłem wtedy juniorem, więc tylko słuchałem. Wpadł projekt, duży projekt warty kilkadziesiąt milionów, jednym z najważniejszych elementów tego systemu, miało być awaryjne powiadamianie wszystkich z bazy danych, gdy osoba x ulegnie wypadkowi komunikacyjnemu. Ktoś podjął decyzję o czujniku wstrząsu w telefonach, oraz napisaniu niezależnej aplikacji na C#. Stworzyliśmy to wszystko, ja wtedy dłubałem coś w stylach i frontendowych aspektach tego projektu, gdy oddawaliśmy aplikację, szef powiedział: super — to było ostatnie co dobrego o niej słyszeliśmy.
Pierwsze rzeczy jakie nas zaskoczyły, to stan polskich dróg, telefon co chwila pytał kierowcy czy jest przytomny, w przypadku samochodów sportowych wystarczyło dynamicznie ruszyć i ostro przyhamować, aby aplikacja rozpoznała kolizję. Powiem wprost: nie było możliwości korzystania z niej, na naszych testach wypadała świetnie, ale nikt nie sprawdzał jej w takim użyciu jak powinna być sprawdzana, bo kto by rozbijał samochód?
Kolejny błąd, to wybór C#. Ktoś nie dopytał na jakim serwerze, to będzie działać. Niestety Centos słabo sobie radzi z C#. W ten sposób firma straciła 120 tysięcy złotych.
Takich przykładów mogę mnożyć. Wiele firm popełnia ten błąd, pierwsze co trzeba zrobić, to utworzyć takie warunki testowe, w jakich aplikacja będzie pracować docelowo, jeżeli tego nie zrobicie, to gwarantuje wam problemy.
Zbyt późne eksperymenty, lub ich brak
Na początku każdego projektu jest czas na testowanie swoich architektur, można się trochę pobawić, popróbować, co będzie lepsze, rozwiązanie takie, czy takie. Nawet napisać kawałek kodu, który pokaże tylko przepływ danych i czasy wykonania. Ja osobiście od tego zaczynam, jeżeli aplikacja jest szczególna i nie podlega standardowemu podejściu, to lubię sobie stworzyć model danych przykładowych i pobawić się chwilę strukturą aplikacji, tak by zobaczyć co będzie łatwiejsze, co można wyabstrachować, a co musi zostać uszczegółowione.
Strata czasu pomyślicie, nic bardziej mylnego. Na tym etapie postawienie przykładowej struktury i popisanie po jednej metodzie na klasie, daje mi obraz, ile klas muszę ruszyć, mogę sprawdzić na sobie, ile czasu spędzę na poprawieniu czegoś, oraz jak efektywny będzie system. Takie doświadczenia na początku, sprawiają, że dogłębniej poznaje problem i mogę totalnie bezboleśnie dla projektu ustawić taką architekturę, aby praca nad tym systemem była jak najbardziej wydajna.
Przykład statystyczny. Pracowałem jako regular nad jednym projektem, który miał już z 10 lat. Tam nikt już nie dbał o architekturę. Średnio docelowo pisałem około 100 linijek kodu na dzień, czyli przy średniej płacy w Warszawie około 60 zł za godzinę, jedna linijka kosztuje 4,8 zł. W innym projekcie kilka tygodni później, był olbrzymi nacisk na czytelność architektury, po samym linku można było się domyśleć gdzie co siedzi. Wtedy moja wydajność dzienne, była równa około 400 linijek kodu, czyli 1,2 zł. Znam projekty, w których jak programista na dzień czystego kodu wyprodukuje 15 linijek dziennie, to jest to olbrzymi sukces, dla porównania koszt linijki to 32 zł. Poprzez czysty kod rozumiemy, to co commitujemy do repozytorium, czyli wszystkie próby i debugowania się w to nie wliczają.
Czytałem kiedyś o jednym projekcie, w którym koszt per linijka wynosił koło 12 tys. dolarów, bo system był tak zagmatwany, że zmiana czegokolwiek powodowała lawinę błędów. Dlatego warto na początku projektu, napisać malutki model danych, zobaczyć na jakiej architekturze będzie nam się najlepiej pracowało pisząc kilka metod, które nawet nie muszą mieć celu biznesowego.
Kurczowe trzymanie się ideowych reguł projektowania
Zasady są po to, aby się ich słuchać. Ktoś już to przetestował i w jego projekcie to działało bez zarzutu. To są zdania, które mnie bardzo denerwują. Po pierwsze, zasady są po to, aby je zrozumieć i umieć wykorzystać, lub zmodyfikować pod siebie. Jak komuś takie rozwiązanie działało to super, natomiast to było u niego, na jego konkretnym przypadku, a nie musi się to sprawdzić u mnie.
Programiści, analitycy, menedżerowie opierają się na z góry ustalonych schematach. Raz przeszli przez dany schemat, to wypaliło, róbmy to tak dalej. Jak komuś wyszło na innym schemacie, to go sprawdźmy, jak wyjdzie to będzie to nasz nowy obowiązujący. To jest błąd totalny. Jeżeli mamy projekt, to podejdźmy do niego jako coś zupełnie nowego coś nieodkrytego. Wykorzystujemy dobre praktyki, aby stworzyć jak najlepszą architekturę dla naszego projektu.
W pewnym projekcie, obsługiwaliśmy żądania od firm i osób prywatnych, przetwarzaliśmy je i wpisywaliśmy do bazy danych. Aplikacja stała na PHP. Szybko zauważyłem, że jest złamanie zasady pojedynczej odpowiedzialności i zasady DRY (don’t repeat yourself). Jedna klasa, przyjmowała żądanie, przetwarzała je, a gdy skończyła to wysyłała smsa i maila. Dodatkowo druga klasa bliźniacza do niej robiła to samo, ale najpierw wyszukiwała wszystkie żądania danego użytkownika. No dobra, po pierwsze czemu wysyłka smsów nie jest chociaż w innej klasie, podobnie jak maile, czemu nie zrobili switcha jeśli chodzi o grupowe i pojedyncze? Przecież to jest pewne, że to jest błąd w architekturze.
Okazało się, że system jest bardziej skomplikowany niż mi się wydaje. Po pierwsze, jak żądań jest zbyt dużo, to system odpala tę klasę w innym wątku, a wysyłka smsowa daje opóźnienie, co jest zupełnie celowe, bo wtedy w tle robią się inne operację, a dzięki temu system nie gotuje się. Wszystko było w jednym pliku, aby nie musieć odpalać całej aplikacji, tylko traktowały tą klasę, jako skrypt wykonawczy.
Po co to grupowanie? Były firmy, które było wiadomo, że przyślą nam przykładowo 500 tys. żądań do przetworzenia i były one obsługiwane przez inny serwer, dedykowany tylko dla nich.
Te dwa manewry sprawiły, że średni czas oczekiwania na przetworzenie żądania dla małych i indywidualnych klientów, spadł do 5 minut z 30, a klienci masowi wiedzieli, że ich żądania będą przetwarzane przez maks 6 h i będą się pojawiać sukcesywnie.
Przepraszam za ogólnikowość, ale nie mogę więcej przekazać. Najważniejsze z tego jest to, że zasady można łamać, trzeba tylko wiedzieć dlaczego i co chcemy tym uzyskać. W moim przypadku była to jedyna alternatywa, bo PHP jest jednowątkowy i można go odpalać bezpośrednio z linii poleceń.
Inny przykład, architektura rozplanowana zgodnie ze wszystkimi istniejącymi zasadami, dosłownie, każda zasada jaką znam tam była. Praca nad systemem nie była możliwa, zadanie pod tytułem, gdy ktoś coś wybierze, to miałem odpytać bazę danych AJAXem, okazało się na tyle skomplikowane, że poświęciłem na nie 48h w 3 dni. Coś co normalnie robię w 30 minut z przetestowaniem i przerwą na piłkarzyki. Podczas debugowania, wyszło, że aby to co ja chce zostało uruchomione, przechodziło przez 112 obiektów. Z czego niektóre klasy były 4-krotnie wywoływane, struktura była tak skomplikowana i trudna do ogarnięcia, że jedynie architekt rozumiał co się tam dzieje, ale jeśli chodzi o zastosowanie reguł, to były wszystkie poza jedną KISS, „keep it simple, stupid!”
Procedury cykliczne i krypto cykliczne
Nic tak nie złości jak procedury cykliczne w architekturze. Weźmy na to prosty przykład, mamy sobie moduł użytkowników, który daje dane o użytkownikach do modułu z artykułami, a moduł z artykułami, przekazuje informacje o ruchu do modułu statystycznego, który wysyła informacje o najbardziej aktywnych użytkownikach do modułu użytkowników.
Procedura cykliczna jak się patrzy, co się stanie gdy którykolwiek z modułów padnie? Co się stanie gdy zmienimy strukturę danych w którymkolwiek z modułów? Ano wszystkie pozostałe nie będą wstanie pracować poprawnie. To oznacza, że nie zobaczymy artykułu, bo moduł statystyczny jest zepsuty. Kolejna sprawa to pisanie w tego typu projekcie, musimy mieć na uwadze, że cokolwiek zmienimy, może zabić cały system. Dzięki tego typu rozwiązaniom łatwo możemy trafić do programistycznego piekła.
Dodatkowo szefostwo się na pewno ucieszy, że z pozoru łatwa i prosta modyfikacja przerodziła się w wielką awarię. Pamiętam jeden taki problem, kiedy to zmigrowałem dane z tabelki agentów ubezpieczeniowych, do tabelki użytkowników, gdzie była dorzucona kolumna typ użytkownika. Na testach wyszło, że wszystko gra i działa jak ta lala, ale danych testowych było po prostu za mało, okazało się że 15% danych generowało błąd paraliżujący stronę dla tego użytkownika, który przy dziennych odwiedzinach w ilości 5 mln osób, oznaczało mniej więcej tyle: „POŻAR”. Gasiliśmy go w trzy osoby przez 3 h łatając moduły zaślepkami i innymi hakami. Zadanie zostało poprawione, tak by wszystko działało dopiero po 4 dniach.
Gdyby nie było tam procedury cyklicznej, przestałaby działać tylko jedna gałąź aplikacji, tzn. że Ci sami użytkownicy nie mieliby dostępu do około 5% funkcjonalności, ale że tam było kilka procedur cyklicznych, to niestety cały system generował błąd 500.
Co to jest procedura krypto cykliczna? To taka, która została tak rozbudowana o moduły, że trudno dostrzec, że ona się zapętla. W obecnej chwili zawsze rysuje diagram przepływów. Jeżeli znajduje dowolną pętlę, którą mogę chodzić w nieskończoność, to od razu zmieniam strukturę aplikacji, bo wiem, że tam czai się poważna pułapka.
Budowanie stabilnych modułów, czyli tworzenie „stref bólu”
Kiedyś uważałem, że programistyczna doskonałość, to napisanie kuloodpornego kodu, stworzenie takiego modułu, który będzie nie do zajechania, który obsłuży każdy błąd jaki może zaistnieć. Ale byłem głupi. Oto genialny przykład potwierdzający „Paradoks nowoczesnego banku”. Paradoks ten polega na tym, że nowoczesne banki świadczą nowoczesne usługi, ale ich systemy oparte są na języku, który uważany jest za wymarły.
Przyjrzyjmy się temu problemowi na przykładzie. Mamy moduł x, sami go pisaliśmy, chcemy by był idealny, więc uodparniamy go na wszelkie głupoty, dosłownie wszelkie. Każdy nawet najgłupszy scenariusz obsłużyliśmy. Wszyscy są zachwyceni. Zaczynają korzystać z naszego modułu, znów wielki sukces. Do czasu, bo okazało się, że nasze zabezpieczenia bardzo usztywniły moduł, a koszt wzbogacenia go o nowe funkcje jest droższy, niż dostosowanie nowych modułów do niego. Szefostwo podejmuje decyzję oczywistą: tańsze a robi to samo, więc bierzemy.
Dodatkowo nasz moduł przyzwyczaił wszystkich do tego, że jest niezniszczalny, więc coraz więcej innych modułów polega na nim. Przy okazji kiedyś widziałem, jak aplikacja brała dane użytkownika z modułu ofert, a nie użytkownika, bo tam było łatwiej i szybciej. Wracając, po dłuższym czasie widzimy, że nasz moduł to serce, lub wątroba całej aplikacji.
Przychodzą zmiany, trzeba coś ulepszyć, zmienić. Okazuje się, że nasz moduł jest nietykalny, bo jak on się popsuje to cała aplikacja stanie, a naprawa w naszym module, będzie oznaczała zmiany w 80% innych modułów korzystających z naszego modułu. Tak jest też w bankach, raz napisany system działa i wszystkie banki boją się go wymienić na nowy, bo one mogą nie być tak stabilne, a w bankowości dzień bez przelewów to apokalipsa.
Doszedłem wtedy do wniosku, że stabilny moduł, to odcisk na stopie, a nie powód do dumy. Wolę pisać łatwo rozszerzalne moduły, które są stabilne tylko do zadań jakie mają wykonywać, ale koncentrują się na łatwości w rozszerzaniu. Wtedy mniej modułów korzysta z mojej logiki, tylko te co muszą, także odpowiedzialność w aplikacji pozostaje rozproszona, w razie awarii, nie ma tragedii, a w razie potrzeby rozszerzenia, mówię czemu nie i podaję tak krótki termin, że przełożony się uśmiecha, bo wie że już za moment będzie miał fajną funkcjonalność, którą będzie mógł sprzedawać.
Kolejnym aspektem posiadania nie do końca stabilnych modułów jest to, że gdy inne moduły łatają braki w module do którego się odwołują, bo tak jest szybciej, bo tak nie zepsują jego stabilności, to wtedy robi się nie lada architektoniczny bałagan, który potem trudno jest okiełznać. Lepiej po prostu napisać aplikację od nowa, ale to nie ja to powiem szefowi.
Podsumowując
Wiele projektów trwa zbyt długo i ich utrzymanie z wersji na wersji coraz bardziej rośnie, a to dlatego, że wcześniej zostało podjętych wiele błędnych decyzji, wynikających z różnych przyczyn. Z naszych przyzwyczajeń, bo przecież zawsze tak robimy i było dobrze, z naszych upodobań technologicznych. Z niedopasowania środowiska produkcyjnego do testowego. Bo tworzymy procedury cykliczne, czy staramy się scentralizować naszą aplikację do jednego modułu, bo jest niezastąpiony.
Te wszystkie błędy zaczynają nam wcześniej czy później doskwierać, a szefostwo stara się dociec czemu kiedyś nowa wersja powstawała w 3 miesiące, a teraz w rok. Co przekłada się na zwiększone koszty… A my jako programiści coraz bardziej frustrujemy się, bo proste zadania zaczynają nas męczyć, bo dłubanie w systemie jest bardzo skomplikowane.