Wyjątkowo miałem plany, aby rozpocząć nowy etap blogowania po Nowym Roku, ale dzisiejsze wydarzenia skłoniły mnie, do jak najszybszego podzielenia się informacjami i tym, jak ważne jest poprawne przechwytywanie błędów w pracy programisty i komunikowanie ich do użytkownika możliwie w jak najbardziej precyzyjny sposób.
Zbudujmy sobie wspólnie całe tło pod zastaną sytuację. Jest 23 grudnia, żona w pracy, ja opiekuję się Naszą 1.5 roczną córką. Dwa dni wcześniej dogadaliśmy się z Liderem Technicznym, że dziś ten dzień będziemy tak trochę po macoszemu traktować. Ogólnie sytuacja w zespole wygląda tak, że jesteśmy ze wszystkim obrobieni, nie ma nas ciśnienia. Wszystko, co konieczne zostało oddane do Testerów. Ten 23 i lukę między świętami a Sylwestrem planowałem uzupełnić dokumentację, ewentualnie jakieś przygotować projekty, które wdrożylibyśmy w życie od Nowego Roku (a jest kilka fajnych pod kątem legacy code, o czym mam nadzieję, napiszę tutaj niebawem). Dzień zapowiadał się na sielankę i już jedną nogą człowiek coś tam mógł ewentualnie pomóc w kuchni, czy pobawić się dzieckiem. Los jednak zaplanował coś innego.
– Aplikacja padła – nie wiadomo dlaczego nawet.
– Ale jak to? Co logi na to?
– W logach komunikat jedynie „Nieznany błąd aplikacji.”
No to mówię sobie, nieźle się zaczyna. Zajrzeliśmy głębiej, dodatkowo informacja pojawiła się w logach, że trwa oczekiwanie na mutex. Coś musiało się przydławić na bazie danych i znowu jakaś transakcja poszła się bujać. Tutaj ważna uwaga, aplikacja została napisana w ten sposób, ze względu na to, że klient oszczędza na licencjach do bazy danych, proces musiał używać jednego połączenia do bazy między wątkami, przekazując sobie połączenie do bazy danych. Kiedy dostałem to w spadku, pierwszy raz złapałem się za głowę, jak tak można zrobić. Na szczęście ja już tutaj, Drogi Czytelniku, postanowiłem uprzedzić, abyś wiedział, że na poprzednich programistów głowy zapadały takie, a nie inne decyzje biznesowe. To rozwiązanie miało swoje wady i zalety, ale po kilku przygodach i naprawie samego sposobu wykonywania zapytań do bazy danych doprowadziłem do stanu, że procesy bazujące na tym rozwiązaniu stały się mniej awaryjne.
Nauczony doświadczeniem, zajrzałem do głównej metody, gdzie ten cały proces wstaje do życia. Tutaj muszę uderzyć się w pierś i przyznać, że jak na programistę C++, czy tam C z klasami (#pdk), to wciąż w temacie szablonów jestem bardzo niedoświadczony i zdarza się, że sam przejście przez sam proces jak aplikacja zachowuje się w tym wypadku, sprawia mi czasem kłopoty. No ale wracając z tej przydługiej dygresji do tematu, patrzę, w jaki sposób obsłużone są wyjątki aplikacji, a tam kodu tylko tyle:
try{
// tutaj mamy ciało naszego procesu...
}
catch(...)
{
LogPrint("Nieznany błąd aplikacji");
}
Także bez wahania po prostu dopisałem podstawowy zestaw do łapania wyjątków, rozszerzając go o te, które mogłyby potencjalnie wystąpić, bazując na tym, co zostało wciągnięte do projektu. Przez podstawowy zestaw w tym konkretnym wypadku rozumiem paczkę catchy z przestrzeni std::, a także bazodanowe z MFC Framework. Dodatkowo dorzuciłem kilka z boosta, bo akurat tutaj był użyty.
Nową aplikację podrzuciłem na testy, rozwiązanie praktycznie znalazło się od razu. Okazało się, że po stronie serwera skończyła się przestrzeń, przez co aplikacja nie miała miejsca na kolejne wpisy w tabelach. Coś, czego nie spodziewały się obie strony, ponieważ wszystko miało się automatycznie rozszerzać i miało mieć zapewnione miejsce.
Kiedyś też bardzo po macoszemu i lekceważąco podchodziłem do obsługi błędów, a także komunikowania ich samemu użytkownikowi. No bo, co może pójść nie tak? Z perspektywy czasu, im dłużej pracuję z takim legacy code, tym częściej staram się wypisywać sobie możliwie jak najwięcej informacji do logów o błędach, często nawet stosuję taki zabieg, że kiedy coś pozyskuję z bazy danych i zamykam to w mniejszej metodzie, to zamiast zwracać tylko te informacje, które są istotne od strony procesu staram się dorzucać też status, czy sam proces ich uzyskania się powiódł. Rozwiązuję to na dwa sposoby – pierwszy przez rzucenie wyjątku, a drugi ( stosunkowo częściej, niż pierwszy ), przez użycie std::tuple<bool, T>, gdzie jako pierwszy element zwracam sobie true/false w zależności od tego, czy proces przebiegł pomyślnie, czy nie i potem dopiero zwracam te informacje, których potrzebuję.
Pierwsze rozwiązanie, przez wyjątki, ma minus taki, że ktoś musi to potem przechwycić. No i dobrze by było, aby to było w miarę precyzyjnie, jak najbardziej się da, czyli nie po prostu std::exception, czy np. domain_error. Projekty legacy, z którymi pracuję są na pograniczu C11 z C14, a nierzadko trafia się klasyk, gdzie jest ręcznie coś robione ze wskaźnikami. Z tego też wynika dla mnie kolejny minus tego rozwiązania, że ilość ogólnych rodzajów wyjątków jest dosyć uboga.
Drugie rozwiązanie ma ten plus niewątpliwie, że w razie niepowodzenia przy sprawdzeniu, czy pierwszy element zwrócony jest true lub false umożliwia sprawne obsłużenie przepływu procesu aplikacji, mogę wtedy po prostu zakończyć dany fragment niepowodzeniem no i trudno, zdarza się. Odpada też wtedy zwracanie jakiś dziwnych kodów błędu i sprawdzania, czy to zero akurat teraz jest wynikiem, czy może oznacza błąd, czyli szerzej rzecz ujmując, odpada mi walidacja samego obiektu pożądanego, czy jest z nim wszystko ok. Minusem tego rozwiązania jest, że jak tylko mam do pobrania więcej niż 3,4 właściwości, to aż się prosi o to, aby zawartość opakować jakąś strukturą, aby nie utonąć w magicznych liczbach. Także wymusza dużo dyscypliny i czasami wymaga nadmiarowej kreatywności w tworzeniu nowych obiektów.
Mimo wszystko gorąco polecam zbierać możliwie najwięcej informacji, kiedy tylko wiemy, że błąd może się pojawić, a pojawi się prędzej, czy później. Dziś w tej opowieści, około 6 linijek kodu ładnie sformatowanych rozwiązało problem, do którego praktycznie dojście podczas debugowania byłoby bliskie zeru.