Ki ne szeretne nulláról kezdeni egy projektet? A tapasztalt fejlesztők körében a “green field” olyan hívó szó lett, mintha ingyen sört és pizzát osztanának. Sajnos a valóságban szinte mindig van egy “régi rendszer”, ami “működik valahogy”, csak ezt meg azt még bele kellene rakni.


Ezek a legacy rendszerek, és az őket működtető sokszor már Stephen King-i mélységekig gonosz kódbázisok felelősek a fejlesztők hajritkulásának, őszülésének és szakállnövekedésének akár 60-80%-áért. De próbáljuk csak összeszedni, hogy mi is az a legacy kód?

Én azt a megfogalmazást szeretem használni, hogy amire nincs megfelelő unit teszt lefedettség, az legacy kód. Ennek fényében sokan képesek akár 5 perc alatt is legacy kódot előállítani. Néhány közös vonása ezeknek a rendszereknek:

  • Nincs, vagy elhanyagolható a unit teszt lefedettség
  • Nincs elfogadási (vagy bármilyen egyéb) dokumentáció
  • Nagy általánosságban, ami van, az működésileg helyesnek tekinthető
  • A vezető fejlesztő, aki egyedül összerakta az egészet, már nem elérhető

Tehát nem tudjuk, hogy a rendszerünk mit csinál, hogyan csinálja azt.

Láttam én már parkolóház rendszeréből átalakított domain regisztrációs ügyviteli szoftvert. Láttam riportáló rendszert, ami 3 millió SQL sort próbált memóriában eltárolni. Háromszor. Többmilliós látogatóbázis kiszolgálására indított WordPress oldalt.

Írjuk újra nulláról!

Tegye fel a kezét, akinek ilyenkor nem az az első gondolata, hogy “vágjuk ki az egészet a francba, két hét alatt megírom rendesen”! Tudom. Ebbe a kutyagumiba már én is számtalanszor beleléptem. Sajnos a valóság azonban a tapasztalatok alapján nem bennünket igazol. Ilyenkor kerülnek elő a rejtett csapdák. Szinte mindig előkerül valami, amire az eredeti rendszer ismerete nélkül nem gondoltunk, és hatszorosára növeli a komplexitást. Elkezd húzódni, és azt vesszük észre, hogy most már két rendszeren dolgozunk egyszerre, hiszen a régiben is kell egy-két gyors hibajavítás, egy-két új funkció. Közben szép új rendszerünk a gyakori kontextus váltások miatt lassabban készül, nem készülnek hozzá dokumentációk, elmarad a TDD, és elmaradnak a unit tesztek is. Végül aztán elegünk lesz az egészből, és inkább otthagyjuk az adott projektet. Majd az utánunk érkező fejlesztő nem egy, hanem már két legacy rendszerrel kell, hogy elbánjon.

Hogyan piszkáljuk meg mégis egy bottal?

Természetesen nem azt ajánlom, hogy mindenféle védekezés nélkül, gumikesztyű híján tenyereljünk bele a közepébe. Mégis valahogy ott állunk az előtt a gőzölgő halom előtt, nekünk pedig ugye egy várra van szükségünk. Fából vaskarika, ugye? No de azért lettünk mérnökök, hogy nehéz problémákat oldjunk meg.

Nincs más hátra, mint előre! Fel kell nyitni a motorháztetőt, és megnézni mi is történik alatta. Valahogy találnunk kell egy megoldást. Ha egy nagy és komplex rendszerről van szó, lehet hogy a megoldás tényleg a csere. De nem ész nélkül, hanem szépen fokozatosan.

Induljunk ki abból, hogy az adott rendszerünk csinál valamit. És ez a valami nekünk (a megrendelőnknek) jó. Próbáljuk meg tehát mindvégig fenntartani ezt az állapotot. Tesztek nélkül egy ilyen kódot módosítani azonban olyan, mint az orosz aknakereső osztag (fület befog, és nagyokat toppant lépésről lépésre). Olyan veszélyes kötéltánc ez, hogy ha az ember nem vérbeli cowboy, kell valamiféle biztonsági háló. Normál esetben egy refaktorálásnál ez a biztonsági háló a unit teszt lefedettség. (Tudtátok, hogy ezért csináljuk az egészet?) Ez nekünk azonban hiányzik, és kódunk valószínűleg nagy eséllyel nem is unit tesztelhető jelenlegi formájában.

Ami segítségünkre lehet, az a karakterizációs tesztelés.

Mik azok a karakterizációs tesztek?

Amit tesztelünk, annak van valamiféle bemenete, és erre produkál valamiféle kimenetet. Ez így a legtöbb rendszerre igaznak tűnik. Nincs más dolgunk mint megfogni, hogy mi generálja a bemenetet, és erre a rendszer milyen választ ad. Ha ezt meg tudjuk tenni, és egy adott bemenetre továbbra is helyes választ adunk, akkor feltételezhetjük, hogy nem törjük el a rendszert.

A karakterizációs teszt elnevezést Michael Feathers vezette be, “Working Effectively With Legacy Code” című könyvében. Ha valaki beszéli a nyelvet, igen érdekes olvasmány egy ötórai teához.

Ilyen tesztek előállítása néha igazi művészet lehet. Természetesen, mint a tesztelésnél általában, itt sem lehet minden létező bemenetre minden létező kimenetet letesztelni. De elő lehet állítani egy olyan teszteset sort, ami számunkra megfelelőlen lefedi a programunk működését.

Legyen például egy fontos osztályunk, ami valamiféle fekete mágiát végez a bemenő adatainkkal, és kiköp valami eredményt.

class FontosOsztály {
    function String FontosDolgotCsinál(String bemenőAdat) {
       //[...] Itt valamiféle fekete mágia történik az adatunkkal
       return kimenőAdat;
    }
}

Készítsünk rá tehát valamiféle jó kis tesztet:

class FontosOsztályKarakterizációsTeszt {
    private String[][] bemenőAdatok = {{"nyúl"}, {"cilinder"}, {"motkány"}};

    @DataProvider(name = "input")
    public Object[][] tesztAdatok() {
        return bemenőAdatok;
    }

    @Test(dataProvider = "input")
    public void KarakterizációsTeszt(String bejövőAdat) {
       String elvárt = beolvasomEgyFileból(
           bejövőAdat.concat(".txt")
       );

       String eredmény = fontosOsztály.FontosDolgotCsinál(bejövőAdat);

	  AssertEquals(eredmény, elvárt);

    }
}

Itt felolvasunk egy bemenethez egy eltárolt adatot mondjuk egy fájlból, és összehasonlítjuk a kapott értékkel.

Fontos, hogy ez a kimenet néha szándékosan megváltozhat, miközben alakítjuk a rendszerünket. Tesztjeinket úgy készítsük el tehát, hogy könnyű legyen az új kimenetet elmenteni a régiek helyett. Valahol tehát lesz egy ilyesmi kódunk:

@Test(dataProvider = "input")
public void TesztesetekLétrehozása(String bejövőAdat) {
    elmentemEgyFileba(
       bejövőAdat.concat(".txt"),
       fontosOsztály.FontosDolgotCsinál(bejövőAdat)
    );
}

Ilyetén módon tehát, ha valami hasznosat csináltunk, aminek biztosak vagyunk a hatásában, és az pozitív: újra tudjuk generálni az elvárt kimenetet. Ezzel persze vigyázni kell.

A szép új világ

Most már biztonságban vagyunk, tehát elkezdhetjük felépíteni a szép új világot. Elkezdhetünk óvatosan kiemelni dolgokat, amelyek már unit tesztelhetőek. Szépen lassan módosítjuk a kódot, és előbb-utóbb eljutunk egy használható, 100%-ban tesztekkel lefedett kódbázishoz. Ne kövessük el azt a hibát, hogy a karakterizációs tesztek lefedettségét 100%-nak tekintjük, és eldobjuk a unit tesztek gondolatát. Ezt úgysem tenné senki, ugye?!

Amint elértük a megfelelő unit teszt lefedettséget, a karakterizációs tesztjeinket nyugodtan el is törölhetjük. Soha többé nem lesz rájuk szükségünk. És a világ újra megmenekült…

Tökéletes helyettes

Olyan feladat ez, melyhez idő kell. Gyakran, ha le szeretnénk cserélni a régi rendszert, nem várhatunk addig, míg elkészül az új. Ilyenkor lehet szükségünk egy helyettesre. Nem a Kispál nótára gondolok, hanem a “proxy” programtervezési mintára. Érdemes elfogni egy olyan osztályt, amely az inputokat feldolgozza, és kiemelni belőle egy interfészt. Ezután készítünk egy proxy osztályt, ami ezt implementálja. Ennek eleinte annyi feladata van, hogy delegál minden feladatot a régi rendszernek. Miután egyesével kifejlesztjük magunk a régi funkciókat, ezeket egyenként átválthatjuk az új implementációra. A megközelítés lényege, hogy nem egy nagy átállás van hosszú-hosszú várakozási idő után, hanem folyamatosan újabb és újabb értéket teremtünk. Ha belegondolunk, ez az agilitás, maga.

Egy rövid példa:

class GonoszOsztály {
    public void törpökElpusztítása() {
        // [...] Valami szörnyen gonosz kód
    }

    public String gonoszKacaj() {
        // [...] A tökéletes gonosz kacaj algoritmusa
    }
    
    public Foobar foobarFerbalizálása(Foobar foobar) {
        // [...] Itt szövevényes módon ferbalizálunk egy Foobar objektumot
    }
}

Ez a három dolog nagyon nehezen megfejthető és újraírható, ezért egyenként cserélnénk le őket. A megoldás:

interface GonoszInterface {
    public void törpökElpusztítása();

    public String gonoszKacaj();
    
    public Foobar foobarFerbalizálása(Foobar foobar);
}

class GonoszOsztály implements GonoszInterface { 
   ... //itt minden változatlan 
}

class ÚjGonoszOsztály implements GonoszInterface {
    private GonoszInterface régiGonoszOsztály;

    public ÚjGonoszOsztály(GonoszInterface régiGonoszOsztály) {
       this.régiGonoszOsztály = régiGonoszOsztály;
    } 

    public void törpökElpusztítása() {
        régiGonoszOsztály.TörpökElpusztítása();
    }

    public String gonoszKacaj() {
        return régiGonoszOsztály.gonoszKacaj();
    }
    
    public Foobar foobarFerbalizálása(Foobar foobar) {
        return régiGonoszOsztály.foobarFerbalizálása(foobar);
    }
}

Szép emlékek…

Ez így csak duma, mondod te. Hogy néz ez ki a gyakorlatban? Mesélek. Ülj le mellém (valamit mondok). Kaptam egy megrendelőmtől egy feladatot: Van egy nagyon remek rendszerük, amelynek nagyon remek riportáló rendszere nagyon remek CSV-ket készített. Valaha. Azonban mostanra megeszi az összes memóriát, és keserves kínok között elpusztul. Ejha, mondom: ennek a fele sem tréfa. Gyorsan javítsuk hát meg!

A hiba hamar fel is fedte magát: a Code Igniter PHP framework lekérte cached query-vel az összes sort az adatbázisból (tehát eltárolt minden sort a memóriában). Majd ő maga eltárolta egy PHP tömbben (tehát minden sort eltároltunk kétszer a memóriában), majd a rendszer maga átmásolta ebből a tömbből egy másik tömbbe (tehát minden sort eltároltunk háromszor a memóriában). Sajnos azonban egy bizonyos adatmennyiség fölött a memória végesnek bizonyult, kis adatszámra viszont a rendszer megbízhatóan működött. A CSV export komplexitása, hogy természetesen mindenféle üzleti logika alapján sokféle adatot aggregálni kellett. Ezután több query alapján összedolgozni sorokat, fejlécet tenni hozzá bizonyos esetekben, és a többi, és a többi. Szóval egészen könnyen eltörhető folyamat volt, egy nagyon zavaros kódbázis által előállítva.

Sikerült elkapnom a kontrollert, ahol kiválasztotta a bemenő kritériumokat. Találtam egy olyan céget a teszt adatbázisban, ahol lefutott sikeresen az export, és keletkezett egy referencia CSV. Ezt elmentettem, mint elvárt adat. Készítettem egy tesztet, ami a megadott bejövő adatokkal meghívta a kontrollert, és összehasonlította az elkészült CSV-t az eredetivel.

Bámulatos. Most már szabadon garázdálkodhattam a kódban, azonnal láttam, ha eltörtem valamit. Elkezdhettem hát szétcincálni a kódot. Az MVC-nek mondott, Controllerbe összehányt masszát szépen lassan darabokra szedtem, kiszerveztem felelősség szerint osztályokba. Módosítottam a modellt, hogy egy PHP tömb helyett egy iteratort adjon vissza. Kiterjesztettem a framework query osztályát, egy saját implementációval, ami unbuffered queryket használt. Így két osztály helyett lett összesen 11 darab, SOLID-nak megfelelő, unit tesztelt osztályom. Ráfuttattam ismét a karakterizációs teszteket. Különbség volt a két eredmény között. Észrevettem, hogy véletlenül, amikor két külön adatforrásból állítottam össze az eredményt, a fejlécet középen is kiírtam a file-ba. Kijavítva a hibát már stimmelt a végeredmény. A megoldásom tehát ugyanazt teszi funkcionálisan, mint a régi. Eltöröltem a mostanra fölöslegessé vált karakterizációs teszteket. Kód deploy. Nagy levegő. Rányomtam a teljes méretű exportra, ami megevett több, mint 2,5 GB(!) memóriát. A folyamat során a PHP process memóriafoglalása gyakorlatilag meg sem emelkedett.

A világ egy szebb hely lett. Szemétből műalkotás készült. Ez az igazi Cyberpunk, nem igaz?