Pisanie kodu według najnowszych wytycznych i zgodnie z aktualnymi trendami technologicznymi nie wystarczy, by uchronić projekt od pojawienia się kodu legacy. Kod napisany zgodnie z dokumentacją jest często wielokrotnie rozbudowywany o kolejne funkcje w ciągu wielu lat. Bez regularnej refaktoryzacji, czyli poprawiania jego czytelności i jakości z czasem kod się starzeje.
Co to znaczy legacy code? Jak efektywnie pracować z kodem legacy?
Zdekodujmy definicję „legacy code”
Czym tak naprawdę jest legacy code? Dosłownie tłumacząc nazwę, można powiedzieć, że jest to kod, który przejmujemy do „obróbki” od innej firmy, od innego developera lub od innego zespołu developerów. Niektórzy określają go po prostu jako stary kod, bez dokumentacji, kod pisany w innej, starszej technologii lub z użyciem przestarzałej metodyki projektowania. Ostatnio popularność zdobywa definicja Michaela Feathersa zawarta w jego książce „Working Effectively with Legacy Code” – według niego to kod niepokryty testami lub posiadający źle napisane testy.
To teoria, ale praktycznie legacy code poznajemy po tym, że musimy się w niego wczytywać i analizować, aby zrozumieć sposób jego działania. Nawet kod z testami, jeśli jest przestarzały, bywa nazywany przez programistów kodem legacy.
Każdy kod się starzeje
Legacy code często normalnie działa, jest zdeployowany na produkcji i dostarcza pewną wartość — użytkownicy korzystają z funkcjonalności aplikacji opartej na tym kodzie, dzięki czemu firma na nim zarabia. Jednak ten kod wymaga najczęściej pewnej modyfikacji – dodania nowej funkcjonalności czy poprawy wydajności. W końcu, po co majstrować przy czymś, co spełnia swoją rolę?
Chociaż istnieją bardzo stare kody, które sprawdzają się przez wiele lat, nie potrzebując modyfikacji, to są to nieliczne przypadki. Kod wymaga nieustannej pracy, stałego łatania nowych dziur bezpieczeństwa czy aktualizacji bibliotek. W przeciwnym przypadku po pewnym czasie kolejne próby zainstalowania nowych zależności będą nieudane.
Główne powody wprowadzania zmian w oprogramowaniu:
- Dodawanie funkcji
- Poprawianie błędów
- Ulepszanie projektu
- Optymalizacja wykorzystania zasobów
Jak zabrać się do pracy z legacy code?
Mniej doświadczonym programistom często towarzyszy obawa, że gdy wprowadzą zmiany w legacy code, to coś może w nim przestać działać. Woleliby pracować nad danym projektem od zera, bez narzuconych ograniczeń i dzięki temu w pełni wykazać się kreatywnością, ale szybko się przekonują, że nawet wtedy sami sobie muszą narzucać pewne ograniczenia. Legacy code od razu definiuje ramy działania i też wymaga kreatywności, ale w nieco inny sposób.
Łatwiej zapanować nad kodem legacy, który już posiada testy, ponieważ chronią one programistów przed zepsuciem już działających funkcjonalności.
Od czego zacząć, gdy musimy przerobić przestarzały kod lub dodać do niego nowe funkcjonalności? Działajmy według sprawdzonego algorytmu.
Jak najwięcej testów
Najpierw musimy zabezpieczyć się przed przypadkowymi zmianami kodu, tak, aby chronić jego istniejącą funkcjonalność. W tym celu najprościej jest przeprowadzić dużą liczbę testów automatycznych. Czy będą to testy jednostkowe, czy testy integracyjne, czy testy end-to-end to już zależy od rozmiaru wydzielonego przez nas najmniejszego fragmentu kodu legacy, który jest niezbędny do pracy nad daną funkcjonalnością.
Testy jednostkowe wystarczą, jeśli uda nam się wydzielić funkcję legacy. Jeśli wydzielimy moduł powiązany z innymi modułami, to zastosujmy testy integracyjne.
Problem pojawia się, gdy musimy wprowadzić zmiany w legacy code i odkrywamy, że czają się w nim „smoki”, czyli niezrozumiałe fragmenty i na dodatek są one ze sobą powiązane. Wtedy niezbędne będzie przeprowadzenie testów end-to-end.
Nawet jeśli kod nie ma testów i dokumentacji, to działająca funkcjonalność jest już zapisana w kodzie i pozostają nam ją wyłuskać. Najprostszym sposobem, aby to osiągnąć, jest zastosowanie smoke testów (zwanych też testami NAS). Traktujemy kod jako czarną skrzynkę – nie badamy go i nie musimy wiedzieć, jak działa. Zamiast tego dostarczamy dane wejściowe, argumenty funkcji i nagrywamy dane wyjściowe. String danych wyjściowych umieszczamy w unit teście.
Przy testach integracyjnych lub end-to-end musimy zrobić snapshot modułów albo rzeczy, które wpływają do tych modułów z modułu, który testujemy lub zrobić snapshot całej aplikacji. Choć te testy będą działały wolniej, to generujemy ich jak najwięcej, aby osiągnąć stuprocentowe pokrycie kodu testami.
Wiele zależy od tego, jak bardzo istotna jest dana część kodu. Na przykład, jeśli jest to bramka płatności, od której zależą wpływy finansowe, to jej testy powinny być jak najgęstsze. Przy prostszych, mniej istotnych funkcjonalnościach możemy poczuć się pewnie, stosując mniejsze pokrycie testami.
Kolejnym krokiem może być użycie narzędzia do generowania testów mutacyjnych. Legacy code zostaje poddany mutacjom – na przykład, jeśli w kodzie występuje liczba 30, to narzędzie generuje nowe wersje tego kodu z -30, z zerem, z 1000, z -1000, z nullem, ze stringiem i tak dalej. W różnych miejscach kodu na przykład dodaje if’y lub je usuwa czy generuje warunki brzegowe w pętlach for, tak aby ten kod zmutował. Potem narzędzie odpala testy na zmienionym kodzie. Jeśli testy nadal przechodzą na zmienionym kodzie, to „mutant” żyje. Jeśli testy „wywalają się” to „mutant” ginie. Naszym zadaniem jest zabicie mutantów. Pozwala to upewnić się, że pokryte są nie tylko linijki kodu czy jakieś instrukcje, ale też, że kod sprawdza istotne fragmenty, które mogą się „wywalić” w kodzie, gdy będziemy nad nim pracować.
Po przeprowadzeniu wielu smoke testów nadal nie wiemy, jak działa kod, ale wiemy, że będzie działać — jeśli testy będą przechodzić, to zachowanie kodu się nie zmieni.
Pora na modyfikacje
Teraz śmiało przechodzimy do kreatywnego etapu — wchodzimy w kod i wprowadzamy modyfikacje, mając poczucie, że przed nieumyślnym zepsuciem kodu ochronią nas testy — wyłapią one zmiany we fragmentach, w które nie powinniśmy ingerować.
Możemy przeprowadzić research and development: zastosować spike’i, które sprawdzą, czy funkcjonalność, jaką chcemy dostarczyć, możemy wprowadzić, modyfikując w określony sposób wybraną część kodu.
Dwa kroki do przodu, krok wstecz
Tu z pomocą przychodzi technika mikado method (po polsku gra w bierki). Jak ona działa? Wchodzimy w kod i próbujemy dostarczyć zadaną funkcjonalność, pisząc nowe linijki kodu i wprowadzając modyfikacje. Gdy napotykamy różne błędy kompilacji i zależności, jakieś braki, notujemy je na kartce jako kroki. Cofamy zmiany i próbujemy uwzględnić wszystko, co zapisaliśmy na kartce. Na przemian idziemy do przodu i wstecz po napotkaniu problemów. W ten sposób buduje się graf zależności — rzeczy, które musimy zmienić w kodzie przez refactoring, żeby móc dostarczyć funkcjonalność. Po wyeliminowaniu fałszywych kroków zostają tylko kroki prowadzące nas prosto do wprowadzenia funkcjonalności
Dla dostarczenia nowej funkcjonalności niezbędne będą też testy badające tę funkcjonalność.
Na tym polega test-driven development. Najpierw piszemy testy, żeby to one wymusiły implementację. Pozwala to jasno sprecyzować plan działania. Dzięki temu później, gdy już dostarczymy tę funkcjonalność, testy na czerwono zaczną przechodzić. Na koniec otrzymujemy serię smoke testów, które chroniły starą funkcjonalność i nowe testy broniące nowej funkcjonalności. Wtedy może pojawić się problem polegający na tym, że nowa funkcjonalność stoi w sprzeczności z poprzednim zachowaniem programu. Wtedy część smoke testów, które broniły starego zachowania, zacznie failować. Na przykład przy wprowadzaniu zmian w programie do księgowości możemy napotkać problem ze zmiennymi stawkami VAT. Wtedy konsultacja z product ownerem — księgowym – pozwoli nam zdecydować, które testy można zastąpić nowymi, a które muszą pozostać.
Refaktoryzacja a optymalizacja
Gdy uda nam się dostarczyć zadaną funkcjonalność, wszystkie testy przechodzą na zielono, wówczas wykonujemy kolejny standardowy krok w test-driven development, jakim jest refactoryzacja.
Refaktoryzacja to ulepszanie projektu bez zmiany jego zachowania. Zamysł kryjący się za refaktoryzacją polega na tym, że możemy sprawić, aby program był prostszy w konserwacji bez zmieniania jego zachowania, jeżeli napiszemy testy gwarantujące, że obecne zachowanie nie zmieni się. W celu weryfikowania tego założenia w trakcie procesu będziemy poruszać się małymi krokami.
Programiści oczyszczali kody od lat, ale dopiero w ostatnich latach refaktoryzacja ruszyła z miejsca. Refaktoryzacja różni się od ogólnego porządkowania tym, że podejmujemy się nie tylko działań o niskim ryzyku, takich jak ponowne formatowanie kodu źródłowego, czy też inwazyjnych i ryzykownych technik, takich jak przepisywanie sporych jego fragmentów. Mianowicie wprowadzamy jeszcze serię niewielkich, strukturalnych zmian wspieranych testami, aby kod był łatwiejszy w modyfikowaniu. Z tego punktu widzenia kluczowa sprawa w refaktoryzacji polega na tym, że kiedy refaktorujemy, nie powinniśmy wprowadzać żadnych zmian funkcjonalnych (chociaż zachowanie może się w pewien sposób zmienić, gdyż strukturalne zmiany, jakie wprowadzamy, mogą wpływać na wydajność — pozytywnie albo negatywnie).
Optymalizacja przypomina refaktoryzację, ale kiedy ją przeprowadzamy, mamy na celu coś innego. Zarówno w przypadku refaktoryzacji, jak i optymalizacji mówimy: „Po wprowadzeniu zmian zamierzamy zachować dokładnie taką samą funkcjonalność, ale za to zmienimy coś innego”. W refaktoryzacji tym „czymś innym” jest struktura programu — chcemy, aby był on łatwiejszy w konserwacji. W optymalizacji z kolei „czymś innym” jest jakiś zasób używany przez program, zwykle czas albo pamięć.
Dodawanie funkcji, refaktoryzacja oraz optymalizacja pozostawiają istniejącą funkcjonalność bez zmian. Jeśli z bliska przyjrzymy się poprawianiu błędów, to zauważymy, że w istocie zmienia ono funkcjonalność, ale zmiany te często są bardzo małe w porównaniu z zakresem istniejącej funkcjonalności, która nie podlega zmianom.
Zależność jest jednym z najbardziej krytycznych problemów występujących podczas rozwijania oprogramowania. Większość pracy nad kodem legacy wiąże się z usuwaniem zależności, dzięki czemu wprowadzanie zmian będzie prostsze.
Ostrożnie z refakoryzacją
Czy bezpieczne jest dokonywanie refaktoryzacji bez testów, na przykład przy upraszczaniu typu parametru i wydzielaniu interfejsu? Kiedy usuwamy zależności, często możemy pisać testy, dzięki którym bardziej inwazyjne zmiany będą bezpieczniejsze. Cała sztuczka polega na tym, aby przeprowadzać te wstępne refaktoryzacje bardzo ostrożnie.
Zachowanie ostrożności to właściwa postawa, jeśli z pewną dozą prawdopodobieństwa możemy spowodować błędy, ale czasami — kiedy usuwamy zależności, aby pokryć kod — sprawy nie przybierają dobrego obrotu. Być może dodamy w metodach parametry, które nie są bezwzględnie potrzebne w kodzie produkcyjnym, albo na dziwne sposoby porozbijamy klasy tylko po to, aby w odpowiednich miejscach porozmieszczać testy. Kiedy tak robimy, skutkiem może być częściowe pogorszenie wyglądu kodu w tym miejscu. Gdybyśmy tylko byli mniej ostrożni, zaraz byśmy to naprawili. Możemy właśnie tak robić, ale to zależy od ryzyka, jakie się z tym wiąże. Kiedy błędy mają duże znaczenie, a z reguły właśnie tak jest, ostrożność się opłaca.