Debugowanie aplikacji to proces związany z naprawą błędów w kodzie, przez co jest zazwyczaj bardzo nielubiany przez programistów. Postaram się jednak przybliżyć ten temat tak, by stał się bardziej zrozumiały oraz pokazać kilka sztuczek, które powinny sprawić, że będzie on dużo szybszy i bardziej efektywny.
Spis treści
Wprowadzenie
Zgodnie z definicją debugowanie to proces polegający na systematycznej redukcji liczby błędów w oprogramowaniu. Przeprowadzany jest zazwyczaj z kontrolowanym wykonaniem kodu aplikacji przy wykorzystaniu debuggera.
Samo słowo bug (z ang. robak), rozumiane jako błąd po raz pierwszy zostało użyte już w IX wieku przez Tomasza Edisona, który w jednym ze swoich listów określił nim usterki techniczne. Jednak jego popularyzację zawdzięczamy admirał Grace Hopper. Razem ze swoimi współpracownikami w latach 40-tych ubiegłego wieku w komputerze Mark II znalazła ćmę, która zaplątała się w przekaźnik, powodując błędy. Admirał nazwała pozbycie się robaka debugowaniem, czyli odrobaczaniem.
Przebieg debugowania
Każdy problem wymaga indywidualnego podejścia, jednak mimo to zazwyczaj proces od znalezienia błędu do jego naprawy można podzielić na 5 głównych etapów:
1. Reprodukcja błędu
To pierwszy i ważny etap naprawy błędu. Zdarza się bowiem, że zgłaszane problemy tak naprawdę nie są błędami i wynikają z braku zrozumienia działania aplikacji. Zdarza się również, że odtworzenia błędu na środowisku u programisty jest bardzo trudne.
Spowodowane jest to najczęściej inną konfiguracją środowiska niż środowisko produkcyjne lub brakiem odpowiednich danych. Żeby ułatwić sobie ten proces, najczęściej wykorzystuje się zrzuty bazy danych ze środowiska, na którym znaleziono błąd lub przeprowadza się zdalne debugowanie.
2. Wyizolowanie źródła błędu
Kiedy uda się już odtworzyć błąd, trzeba ustalić dokładnie, w jakich warunkach on zachodzi. Na tym etapie szukamy minimalnych warunków, jakie muszą być spełnione, żeby wywołać dany błąd.
3. Identyfikacja przyczyny awarii
Mając już wyizolowane źródło błędu, przystępujemy do jego szukania w kodzie. Najczęściej wykorzystuje się do tego debugger lub przegląda się szczegółowe logi.
4. Usunięcie defektu
Po znalezienie dokładnego miejsca powodującego błąd trzeba podjąć decyzję, jak go naprawić. Trzeba bardzo uważać, ponieważ naprawiając jeden fragment kodu, można jednocześnie wywołać błędy w innych, teoretycznie niepowiązanych, miejscach.
5. Weryfikacja powodzenia naprawy
Po usunięciu błędu trzeba jeszcze potwierdzić, czy system działa w pełni poprawnie oraz czy nie wywołało to innych niepożądanych skutków.
Techniki debugowania
Ze względu na przyjętą technikę oraz wykorzystane narzędzia, można wyróżnić kilka głównych podejść do debugowania problemu:
Printf debugging
Nazwa printf debugging pochodzi od wykorzystania funkcji printf z C. Jednak niezależnie od wyboru konkretnej metody, czy to System.out.println(), logger.debug(), czy języka programowania, zasada pozostaje taka sama. Chodzi o wyświetlanie przygotowanych komunikatów i ich przeglądania.
Powstałe w ten sposób logi można przeglądać w wersji online, jednocześnie korzystając z aplikacji i wywołując wybrane funkcjonalności, żeby spowodować błędy lub w formie zrzutu już po wystąpieniu błędu.
Z debuggerem
Najczęstsza i zazwyczaj najbardziej komfortowa forma debugowania. Dzięki wykorzystaniu debuggera podłączonego do aplikacji można sterować jej wykonaniem, wykonywać instrukcja programu krok po kroku oraz podglądać wartości poszczególnych zmiennych
Zdalne debugowanie
Jeżeli nie można odtworzyć błędu na lokalnym komputerze można spróbować połączyć się debuggerem do zdalnej maszyny. Takie działanie jest prawie identyczne jak w przypadku debugowania aplikacji lokalnej, jednak w tym wypadku aplikacja znajduje się na osobnej maszynie i debugger musi sterować jej wykonaniem przez sieć. Może to powodować dodatkowe problemy oraz narzuty wydajnościowe związane z samym wykorzystaniem sieci.
Poawaryjne debugowanie (post-mortem)
W tym wypadku, jeżeli już doszło do awarii, można próbować dojść, co dokładnie się stało, dzięki różnego rodzaju zrzutom danych, które zostały zrobione bezpośrednio przed lub już w trakcie awarii.
W tym celu najczęściej wykorzystane są zrzuty pamięci (heap dump) lub zrzut wątków z maszyny wirtualnej Javy (thread dump).
Debugowanie przez wykluczenie
Debugowanie przez wykluczenie polega na próbie izolacji przyczyny powodującej błąd. Można to przeprowadzić np. poprzez wyłączanie kolejnych modułów aplikacji i weryfikację, czy błąd nadal występuje.
Taki sposób debugowanie nazywany jest również debugowaniem z wykorzystaniem algorytmu: Wolf fence.
Debugger
Ponieważ debugowanie z wykorzystaniem debuggera daje największe możliwości, przyjrzymy mu się bliżej. W przykładach posłużę się wbudowanych debuggerem w Netbeans IDE.
Netbeans IDE
Debugger w Netbeans daje bardzo dużo możliwości i jest bardzo dobrze zintegrowany z IDE. Dzięki temu naprawdę rzadko potrzebne są jakieś zewnętrzne narzędzia.
W celu uruchomienia projektu w trybie debug można skorzystać z menu głównego Debug lub przycisku jak na grafice poniżej.
Netbeans daje możliwość debugowania aktualnie otwartego projektu lub połączenie się debuggerem do zdalnego procesu.
Bardzo przydatną opcją jest również debugowanie testów jednostkowych. W tym celu na wybranym teście można skorzystać z menu kontekstowego i wybrać opcję: Debug Test File.
Po uruchomieniu aplikacji w debuggerze będzie ona normalnie się wykonywała, aż natrafi na breakpoint. Na widoku powyżej pokazany jest screen z debuggera zatrzymanego na breakpoincie.
W tym momencie można podejrzeć, w jakim dokładnie stanie jest aplikacja. Poszczególne punkty widoku opisane są poniżej:
- Okno debugging – pokazuje stos wywołań do aktualnego miejsca wykonywania aplikacji. Można tu zweryfikować z jakiego miejsca została wywołana aktualnie wykonywana metoda.
- Aktualnie wykonywana linia – ta linia jest oznaczona na zielono i wskazuje na linijkę kodu aktualnie przetwarzaną przez debugger.
- Linia z breakpointem – linijka kodu oznaczona na czerwono wskazuje, że znajduje się przy niej breakpoint.
- Okno Variables – widok z aktualnie dostępnymi zmiennymi oraz ich wartościami.
- Ustawiona czujka – można w ten sposób podejrzeć wartość wygenerowaną przez całe wywołania.
Breakpoint
Breakpoint, czyli punkt wstrzymania pozwala na zatrzymanie wykonywania aplikacji we wskazanym przez nas miejscu. Aplikacja wtedy przekazuje sterowanie do debuggera, a my możemy podejrzeć aktualny stan aplikacji, czyli np. wszystkie dostępne zmienne.
Gdzie można postawić breakpoint?
Breakpointy ustawiamy w linijkach, które zawierają jakiś kod do wykonania. To znaczy, że nie zatrzymamy aplikacji na klamerkach, czy na pustej sygnaturze metody.
Warto zauważyć, że żeby w danym breakpoincie zatrzymała się nasza aplikacja, to wskazany przez niego kod musi się wykonać. Jeżeli więc postawimy breakpoint np. w if’ie i nie zostaną spełnione wymagane warunki, to nasz punkt zatrzymania zostanie pominięty.
Gdzie należy postawić breakpoint?
Głowna zasada jest taka, że breakpoint stawiamy przed miejscem, które chcemy podejrzeć. Ponieważ po zatrzymaniu działania programu nie można się już cofnąć, czasem nawet lepiej jest ustawić kilka nadmiarowych breakpointów lub trochę za wcześnie zatrzymać aplikację, żeby złapać ten właściwy moment.
Działanie programu wstrzymywane jest dokładnie przed wybraną linią, czyli linijka oznaczona breakpointem w momencie zatrzymania się, nie jest jeszcze wykonana.
Jak dodać breakpoint?
Nowy breakpoint możemy dodać, wskazując wybraną linijkę i wybierając z menu kontekstowego: Toggle Line Breakpoint. Alternatywnie można również kliknąć na numer z lewej strony widoku, oznaczający kolejną linijkę kodu.
Po dodaniu breakpointu linijka zostanie oznaczona na czerwono, a przy numerze linii pojawi się czerwony kwadrat.
W analogiczny sposób usuwamy wybrane breakpointy.
Wiele ciekawych możliwości daje również widok Breakpoints – można go włączyć przez menu główne Window -> Debugging -> Breakpoints. Z tego poziomu widać wszystkie dodane wcześniej breakpointy, można je usunąć, tymczasowo wyłączyć lub dodać nowe.
Sterowanie działaniem programu w trybie debugowanie
Podczas debugowania bardzo często poruszamy się po kodzie aplikacji w kontrolowany sposób. Poniżej dostępne opcje sterowania opisane wg przycisków, od lewej do prawej strony.
- Finish Debugger Session (Shift + F5) – kończy działanie debuggera.
- Pause – zatrzymuje wszystkie wątki z aktualnej sesji.
- Continue (F5) – kontynuuje wykonanie aplikacji i zatrzymuje się dopiero na kolejnym breakpoincie, jeżeli taki będzie.
- Step Over (F8) – przechodzi linijkę dalej i wykonuje jedną operację – jeżeli ta linijka jest wywołaniem funkcji, wykonuje ją i oddaje sterowanie bezpośrednio po niej.
- Step Over Expression (Shift + F8) – ta opcja wykorzystywana jest, jeżeli w jednej linijce mamy wyrażenia składające się z kilku różnych wywołań. Dzięki niej można rozbić wywołanie takiej linijki na poszczególne kroki.
- Step Into (F7) – przechodzi linijkę dalej i wykonuje jedną operację – jeżeli ta linijka jest wywołaniem funkcji, przechodzi do jej wnętrza. Jeżeli w danej linii będzie kilka wywołań metod, IDE pogrubi je i trzeba będzie wybrać, do wywołania której chcemy przejść.
- Step Out (Ctrl + F7) – kontynuuje działanie aż do końca aktualnie wykonywanej metody. Oddaje sterowanie zaraz po zwróceniu przez nią wyniku.
- Run to Cursor (F4) – kontynuuje wykonanie aplikacji i zatrzymuje się dopiero na linijce kodu oznaczonej kursorem.
W praktyce najczęściej wykorzystywane jest Continue, Step Over oraz Step Into – dlatego warto nauczyć się ich skrótów klawiszowych.
Sztuczki i kruczki debugowania
Dlaczego to tak wolno działa?
Uruchomienia aplikacji w trybie debugowania sprawia, że jest ona całkowicie inaczej traktowana przez maszynę wirtualną Javy. Debugowanie wymusza przekazywanie między nimi dużo większej ilości informacji, nie jest również przeprowadzana wewnętrznie standardowa optymalizacja. To wszystko przekłada się na wolniejsze wykonywanie się debugowanej aplikacji.
W celu przyspieszenia warto też usunąć wszystkie niepotrzebne breakpointy. Czasami zostawia się ustawione breakpointy w dawno już niedebugowanym kodzie, a to, w skrajnym wypadku, potrafi wręcz uniemożliwić normalną pracę z debuggerem.
Dodanie wyrażeń do obserwowania i modyfikacja zmiennych w locie
Przedstawiony wcześniej widok Variables umożliwia podglądanie nie tylko wartości zmiennych, ale również całych wyrażeń.
Dzięki temu można zatrzymać debugger w wybranym miejscu i testować różne fragmenty kodu dodając je do obserwowanych i dopiero jak taki kod zadziała, przenieść go do normalnego kodu w pliku źródłowym.
W ten sam sposób można również modyfikować zmienne w locie, bez konieczności restartowania całej aplikacji – dodajemy przykładowe wywołanie do obserwowanych, w celu modyfikacji zmiennej:
nazwaZmiennej = „wartość zmodyfikowana przez debugger”;
Takie rozwiązania są bardzo przydatne w większych projektach, gdzie zbudowanie na nowo i zrestartowanie całej aplikacji z nowym kodem zajmuje sporo czasu.
Rozproszone debugowanie
W środowisku rozproszonym debugowanie jest jeszcze trudniejsze. Czasami dochodzi wręcz do sytuacji, że uruchamia się aplikację w trybie debugowania na kilku różnych komputerach i biega między nimi, żeby puścić dalej wykonywanie debuggera 🙂
W takich sytuacjach można spróbować ograniczyć się do debugowania tylko na jednej maszynie, a na pozostałych zwiększyć poziom logowania np. na standardowe wyjście i przeglądać logi już po fakcie.
Breakpoint warunkowy [conditional breakpoint]
Większość debuggerów daje możliwość określenia specjalnych dodatkowych warunków dla breakpointów. Jest to niezwykle przydatne, jeżeli dany fragment kodu jest wykonywany wiele razy, a my chcemy złapać wywołanie aplikacji tylko w jednym przypadku.
Dla przykładu z obrazka, zamiast klikać 300 razy next, next… można ustawić właśnie takie warunki i poczekać, aż debugger zrobi resztę. W ten sam sposób można również np. zatrzymać wykonywanie debuggera w metodzie, dodając warunek na jej argumenty.
20+ BONUSOWYCH materiałów z programowania
e-book – „8 rzeczy, które musisz wiedzieć, żeby dostać pracę jako programista”,
e-book – „Java Cheat Sheet”,
checklista – „Pytania rekrutacyjne”
i wiele, wiele wiecej!