Volba frameworku pro mapování objektového a relačního modelu (ORM) je velmi důležitá na začátku vývoje každého projektu, protože zásadně ovlivňuje návrh struktury systému. V dnešní době se nejčastěji pro tyto účely využívá řešení MyBatis, Spring JDBC a Hibernate.
Zatímco MyBatis a Spring JDBC jsou založené na definici SQL dotazu a mapování sloupců na cílové objekty, tak Hibernate je implementací Java Persitence API, která přímo definuje, v jakých tabulkách jsou uložené jednotlivé typy datových objektů (= entit).
A které se pak transparentně stará o synchronizaci stavu objektů v paměti vůči databázi. Na našich projektech je nejpopulárnější volbou právě MyBatis, protože v sobě kombinuje rychlou učící křivku a plnou kontrolu nad zpracováním jednotlivých dotazů.
Přestože je změna ORM na existujícím projektu velmi komplikovaná, tak jsme se do jedné takové migrace pustili a zde bychom rádi ukázali, jaké problémy to může zahrnovat.
Co chceme získat a co nechceme ztratit
Proč jsme se vlastně do takové změny pustili? Pro nás bylo hlavní motivací odstranění složité a komplikované vrstvy, jejímž detailům málokdo rozuměl.
To způsobovalo dlouhou dobu nutnou pro zaučení nových vývojářů, aby dokázali tento framework používat, i když MyBatis dobře znají a s tímto frameworkem běžně pracují.
Také jsme se chtěli zbavit komplikací vývojového procesu, které původní řešení přinášelo. Protože vyžadovalo využívání externího systému pro vytváření definic dotazů a jejich mapování s vlastním odděleným verzovacím systémem. Také jsme chtěli začít využívat některé pokročilé funkce, které na rozdíl od původního řešení MyBatis umožňuje (např. pro řešení N+1 SELECT problému pomocí zanořených asociací a kolekcí).
Změna takovéto interní vrstvy v rozsáhlé produkční aplikaci s dlouholetým vývojem má spoustu rizik:
- Velkým rizikem je zanesení chyb a odchylek v chování, které mohou být ukryté ve velmi neobvyklých případech.
- Ještě větším rizikem může být výsledný výkon aplikace, který může vést až k nutnosti posílení infrastruktury nebo může dokonce změnu znemožnit uvažovaným způsobem.
- Další rizika na zvážení jsou, jak bude takový přechod blokovat rozvoj běžných obchodních požadavků nebo jak to zkomplikuje vývojový cyklus nebo sestavování a nasazování aplikace.
- Vzhledem k závažnosti některých rizik je pak třeba uvažovat i o možných B-plánech pro odložení přechodu nebo dokonce o návratu k původnímu řešení.
Výchozí situace
Původní řešení ORM vrstvy bylo založeno na těchto vlastnostech:
- Jednotlivé dotazy byly uložené přímo jako Oracle SQL fragmenty uvnitř XML konfiguračního souboru. Dotazy jsou buď ve formě SELECT příkazu nebo ve formě volání DB procedury.
- Ke každému dotazu existuje v konfiguraci seznam vstupních a výstupních parametrů, respektive sloupců výsledku. Zde jsou popsány základní vlastnosti (název, pořadí, směr), případně i nepovinné určení konkrétního Java nebo DB typu.
- Vstupem pro SELECT mohou být jen parametry v základních typech, výstupem je pak kolekce řádků, kde je každý reprezentován mapou „název sloupce“ -> „hodnota sloupce“.
- Volání DB procedury pak může obsahovat vstupně / výstupní parametry. A to jak základních typů, tak i uživatelských typů struktura nebo kolekce. Dokonce je podporovaná i funkce vracející referenci na DB kurzor (výstup je pak definován obdobně – jako v případě SELECTu).
- Všechny dotazy jsou z kódu volány přes centrální komponentu, které se předá identifikátor dotazu z konfiguračního souboru a mapa vstupních parametrů. Tato komponenta pak vrátí mapu výstupních parametrů nebo výslednou sadu dat v podobě kolekce map sloupců.
Vzhledem k filozofii původního řešení jsme zavrhli přechod na JPA, protože se jedná o zcela odlišný přístup k ORM.
Taková transformace by nejspíše vyžadovala nejen kompletní přepsání veškeré kooperace Java aplikace s DB, ale i přepsání veškeré logiky uložené v DB procedurách do Java části.
Rozhodli jsme se tedy využít principiální podobnosti původního řešení a MyBatisu a provést přechod pomocí automatického převodu, který změní všechna volání DB do MyBatis syntaxe a provede takové úpravy, aby se současné použití DAO vrstvy nemuselo měnit.
Detaily organizace
Refactoring takového rozsahu není ručně proveditelný během krátké doby. Systém také bylo nutné dále rozvíjet standardním způsobem dle požadavků obchodních zadavatelů. Výše zmíněná rizika pak představovala velkou hrozbu nedodržení obvyklých termínů nasazení v požadované kvalitě. Proto jsme se rozhodli migraci plánovat takto:
- Příprava migrace
- Migrace během sestavení
- Vývoj obchodních požadavků
- Testování migrované aplikace
- Finální sloučení změn
Nejdříve byla aplikace sestavována v původní a v modifikované verzi, vývoj probíhal dle stávajících pravidel, zmigrovaná verze sloužila pouze pro testování správnosti modifikace.
Když už byla zmigrovaná verze dostatečně stabilní a funkční, tak všechny testy aplikace začaly probíhat již pouze na upravované verzi, stále ale docházelo k modifikaci při každém sestavení.
Poslední krok pak nastal, když byla aplikace plně funkční a bez výkonových problémů, v tu chvíli dochází ke sloučení všech změn do hlavní verze kódu a od této chvíle začíná vývojový tým používat MyBatis dotazy napřímo se všemi benefity.
Vlastní migrace byla připravována tak, aby dokázala přepnout volání databáze na původní řešení na základě konfigurace, která byla měnitelná i za běhu aplikace.
To nám společně s postupným přechodem umožňovalo:
- Bez restartu aplikace se vrátit k původnímu způsobu zpracování DB dotazů – toto bylo velmi užitečné při detailním zkoumání ekvivalence výsledků obou řešení a při profilování výkonu obou řešení.
- Možnost na různých prostředích mít puštěné obě verze aplikace – toto bylo velmi užitečné jak ve fázi, kdy zmigrované řešení nebylo dostatečně kvalitní na hlavní testy aplikace, tak ve fázi, kdy se zmigrované řešení připravovalo již k nasazení a bylo třeba vědět, že funkčnost původního řešení zůstalo zachována a je možné se k němu vrátit.
- Časové rozprostření migrace přes několik vydaných verzí aplikace – toto bylo velmi užitečné pro operativní posun migrace na další vydanou verzi v případě, kdy hrozilo zvýšené riziko narušení funkčnosti nebo výkonu aplikace.
- Kdykoliv zastavit migraci a nadále používat původní řešení – toto naštěstí nebylo nakonec vůbec nutné dělat.
Pravidelné modifikace hlavního kódu, které probíhaly při každém sestavení, pak musely řešit například tyto problémy:
- Přidání podpory pro spouštění dotazů v MyBatisu (přidání MyBatis knihoven do aplikace, přidání podpory MyBatisu do Spring konfigurace, přidání podpůrných tříd pro zmigrovaná mapování, přidání tříd pro korektní napojení MyBatisu na zdroje aplikačního serveru (transakční kontext, databázové zdroje).
- Úprava současné DAO vrstvy, aby její volání bylo přesměrováno na zmigrované MyBatis dotazy místo na stávající kód, který přímo pracoval s JDBC.
- Konverze všech současných DB volání do MyBatis syntaxe včetně jejich mapování do objektového modelu (přesněji řečeno do „plochého“ mezimodelu).
Odlišné chování aplikace
U našeho příkladu bylo nutné co nejvíce zabránit zanesení funkčních chyb do aplikace. Nutnou podmínkou také bylo, aby všechny operace pro stejné vstupy vracely i stejné výstupy.
To ale nemusí být dostačující podmínka. Jednotlivé operace mohou mít více či méně zřetelné vedlejší efekty na další části aplikace a přidružené systémy.
Proto je třeba velmi detailně znát veškerá zákoutí systému, vazby různých komponent, procesy na pozadí a okrajové podmínky všech činností.
Vstupy a výstupy operace
Zachování hodnot výstupů pro stejné hodnoty vstupu zní jako samozřejmý úkol. Ďábel se ale vždycky skrývá v detailu, proto je třeba si dát pozor na různé okrajové případy:
- jako je přesnost neceločíselných typů,
- případné ořezávání příliš dlouhých řetězců (zvláště v případě BLOB/CLOB typů),
- přesnost typů pro datum a čas (možná komplikace s časovými zónami),
- používání stejných znakových sad, apod.
Komplikace také může přinést, pokud při konverzi mezi typy Javy a databáze se někde začne používat sice stejná hodnota ale jiného typu.
Například když v DB je číselný typ a zatímco původního řešení ho překládalo na Java int, tak nové řešení by ho začalo překládat jako BigDecimal.
Problém při tom nemusí být na první pohled odhalitelný. Někde může být použita konverzní vrstva, která si s takovými rozdíly „většinou“ poradí.
A v čím více případech takové „většinou“ funguje, tím jsou pak odlišnosti zákeřnější a obtížněji dohledatelné.
Další rozdílnost může zanést i třeba to, jak moc v případě různých kolekcí a map záleží na pořadí prvků, jestli chybějící prvek v mapě je to samé, co prvek přítomný s hodnotou null, apod.
Je také nutné brát v úvahu, že výstupem nemusí být pouze standardní odpověď na dotaz, ale také výjimka ve zpracování. I v takovém případě je nutné se postarat, aby tyto výjimky nesly stejné informace a byly zpracovávány stejným způsobem. V našem případě je kupříkladu část aplikační logiky v DB procedurách, které mohou vyvolávat konkrétní DB výjimky.
Ty jsou pak v DAO vrstvě překládány na odpovídající aplikační chyby s odpovídajícími chybovými hláškami určenými uživateli. Tento překlad bylo nutné zachovat, i když integrace frameworků MyBatis a Spring v takových případech vyvolávala výjimky odlišné a příslušné chybové kódy v nich byly jinak uchované.
Osvědčilo se nám mít pro všechny tyto případy v aplikaci možnost vypsání kompletního vstupu a výstupu nahrazované vrstvy do formátu, který umožňoval snadno porovnat případ volání v původním a novém řešení. Takže byly ihned vidět rozdíly v hodnotách, typech, pořadí nebo vyvolávaných výjimkách.
Databázové spojení a transakce
Co se vazeb na jiné systémy týče, tak jako první je vidět vazba na databázi. Zde je třeba zajistit, aby byly vyhodnocovány stejné (resp. ekvivalentní) SQL dotazy a také aby byly použity napojení na DB jako dříve pomocí dřívějších datových zdrojů aplikačního serveru.
Jinak se velmi snadno vyskytne nějaká odchylka v parametrech DB připojení nebo verzi JDBC ovladačů. Další obvyklá vazba je transakční kontext, který může být pod správou aplikačního serveru nebo si ho může aplikace řídit sama.
V obou případech je ale nutné vědět, kdy přesně dochází k začátku transakce, kdy k jejímu odeslání nebo zrušení (commit, rollback).
Jestli je možné volat DB dotaz bez započaté transakce. A pokud ano, tak jestli se pro něj startuje vlastní transakce, jak dlouho trvá a co se děje v případě chyby při zpracování (aplikační nebo běhové).
Teprve pak je možné stavět řešení se stejným chováním a testovat tyto různé okrajové případy.
Skryté efekty
Pak ale stále může existovat spousta dalších vazeb, jejichž funkce a význam není hned viditelná a jasná.
V našem případě třeba docházelo k tomu, že v rámci každé nové transakce se před prvním příkazem volala zabudovaná DB procedura, které se předaly auditní informace potřebné pro bezpečnostní monitorování přístupu (např. o přihlášeném uživateli).
Tuto informaci aplikace k ničemu sice přímo nepotřebuje, ale jsou na ní napojeny externí auditní a monitorovací systémy.
Dále zde bylo speciální logování doby trvání dotazu, které pak využívá produkční podpora pro diagnostiku a monitoring běžící aplikace.
Dalším příkladem je logika, která znovu zavolá SQL příkaz v případě konkrétních chyb, kterými DB signalizuje, že má být příkaz zopakován. Nebo ve vývojové verzi je zařazeno hledání duplicitních DB volání.
Pro všechny tyto případy platí, že ideální je, když jejich efekt zůstane zachován. Bohužel to někdy není efektivně možné, pak je ale nutné provést důkladnou analýzu a rozhodnout se, jestli příslušné následky jsou akceptovatelné.
A tato analýza musí kromě vlastního chování aplikace zahrnout i procesy nutné pro monitorování a správu vyvíjené, testované anebo produkční aplikace.
Výkon
Jak mohou velké odlišnosti v chování znamenat stopku pro nějakou změnu, tak stejně ji mohou zastavit velké odlišnosti ve výkonu aplikace.
Databázová vrstva je často aplikací intenzivně využívaná a jakékoliv její výkonové zhoršení se může projevit jak nárůstem odezev aplikace, tak nárůstem konzumace procesorového času.
To lze do určité míry kompenzovat posílením HW infrastruktury, pokud ovšem aplikace nenarazí na svůj limit architektury, která špatně podporuje paralelní zátěž.
Stejně tak, jako je třeba mít možnost porovnávat chování, je nutné mít možnost porovnávat výkon.
Aby srovnání bylo vypovídající, musí ale splňovat základní vlastnosti:
- Mít s čím srovnávat – minimum je srovnání se současným řešením, což vyžaduje mít takové měření z minulosti nebo zachovat možnost běhu starého řešení, aby se jeho data dala získat. Nicméně doporučujeme mít přístup k pravidelným historickým výsledkům prováděným jednotnou metodikou, která je zároveň i konfrontována s následnou realitou běžného provozu. Teprve pak dokážete odhadnout, co pro reálný provoz budou znamenat čísla získaná s novým řešením a nebudete falešně vyděšeni (nebo uklidnění) něčím, co se na produkci ukáže jako velký úspěch (nebo průšvih).
- Měřit nejčastěji používané části aplikace – je určitě dobře, když ta extrémně složitá část aplikace, která vytváří nejdůležitější koláčový graf, je rychlá jako blesk. Pokud se ale provádí jednou týdně a přitom se do aplikace ani nejde přihlásit kvůli přetížení, které plyne ze zobrazení domácí stránky, tak to jako omluva nefunguje.
- Měření musí být reprodukovatelné – stejný výkonový test stejné aplikace musí vracet srovnatelné výsledky. Čím větší rozptyl měření, tím méně směrodatné tyto výsledky jsou a tím méně jistě víme, že vyšší/nižší hodnota znamená/neznamená problém. V některých případech je pak nutné přistoupit k simulaci, například můžeme simulovat odpovědi a zpoždění systému mimo naší kontrolu. Sice se tím ztratí vypovídající pro určité operace, získá se ale vypovídající pro vše ostatní.
- Měření musí být provedeno na prostředí srovnatelném s produkcí – čím větší shody s produkční aplikací lze dosáhnout, tím lépe. I malá změna v konfiguraci nebo HW může mít podstatný dopad – například:
- použitý aplikační server, databáze, jejich verze a nastavení jejích propojení,
- architektura aplikace a databáze (samostatný server x cluster),
- geografická distribuovanost (vše v jedné lokalitě s vysokorychlostním spojením x rozmístění serverů po celém světě),
- nastavené úrovně logování.
Poučení pro příště
Provést vypovídající výkonové srovnání není jednoduchá záležitost. Navíc čím dříve se během vývojového cyklu provede, tím je větší riziko, že před dodáním verze se udělají změny s dopadem na výkon a které tyto dřívější výsledky znehodnotí.
V našem případě mělo i vliv to, že prostředí pro výkonové testy bylo k dispozici později, než bychom potřebovali.
Nicméně jsme se dostali do situace, kdy jsme sice měli řešení, které bylo plně odladěno standardními i regresními testy, ale u kterého jsme i tak narazili na závažné výkonové problémy.
Ty bychom dokonce i byli schopni sami do nasazení odstranit, ale jen za cenu takových úprav, které by vyžadovaly nové regresní testy. A na to již nebyl prostor.
Proto došlo k odsunutí celého řešení do další verze.
Proto doporučujeme k testům zátěže přistupovat co nejdříve.
Není nutné, aby řešení bylo hotové bez funkčních chyb, stačí, aby aplikace dokázala nějak projít testovanými scénáři, aby výsledky byly porovnatelné (viz. výše).
A aby byly zapojeny nejčastěji používané části nového řešení a také části, které mohou mít vliv na celkový výkon samy o sobě (např. knihovny běhové podpory pro Aspect Oriented Programming).
Ideální je mít možnost pak takovéto testy provádět opakovaně během vývoje.
Shrnutí
I přes komplikovanost sytému, dobu jeho rozvoje a jeho provozní zátěž se nám podařilo nakonec migraci ORM frameworku na MyBatis dokončit.
Nasazení změny frameworku bylo nakonec posunuto do další verze z důvodu obav o dopady na výkon systému.
Díky zvolenému přístupu tento odklad nezpůsobil žádné problémy ani zavlečené chyby, i když o něm bylo rozhodnuto v poměrně pozdní fázi vývojového procesu. Aktuálně je tato úprava nasazená v produkční aplikaci od poloviny února 2015 bez měřitelného navýšení spotřeby času CPU, bez hlášeného navýšení času odezev a bez faktického dopadu na klienty.
A tohle vše se vám podaří hlavně díky těmto bodům:
- Máte detailní znalost o systému a o části, které se úprava týká.
- Máte naplánovaný takový proces, který nebrzdí běžný životní cyklus a umožňuje flexibilně přecházet na nové řešení a provádět bezproblémový návrat zpět.
- Provádíte vypovídající výkonové porovnání co nejdříve a co nejčastěji během vývoje a nečekáte až na 100% funkční správnost.
- Máte připravenou sadu automatických testů a počítáte v plánu rozsáhlé regresní testy.
Motto: „Je mnohem jednodušší výkon aplikace úplně degradovat než o něco zlepšit.“