Na jednoduchom príklade demonštrujem spôsob využitia objektovo orientovaných princípov. Ukážem, že v porovnaní s priamočiarym prístupom je objektový zápis prehľadnejší. Využijem aj hlavný princíp techniky „domain driven design“ (DDD), ktorým je „všadeprítomný jazyk“. A ukážem aj spôsob využitia niektorých SOLID princípov.
Zadanie
Vstupom je súbor o pohyboch na účte uložený v CSV súbore:
datum,meno,kredit
18.8.2018,Zdeno,500
19.8.2018,Peter,50
19.8.2018,Zdeno,600
20.8.2018,Peter,20
Úlohu je vytvoriť nový CSV súbor, ktorý obsahuje sumár všetkých pohybov na účte pre daného človeka:
meno,suma_kreditov
Peter,70
Zdeno,1100
Jednoduché priamočiare riešenie
Implementácia zadania v Jave využíva BufferedReader na čítanie vstupu a FileWriter na zápis dát do CSV súboru. Jednoduchá a priamočiara implementácia zadania vyzerá takto:
private static void bankSummary( String inputFileName, String outputFileName ) { Map<String, BigDecimal> summary = new HashMap<>(); try ( BufferedReader reader = new BufferedReader( new FileReader( inputFileName ) ) ) { reader.readLine(); // header is ignored String nextLine; while ( ( nextLine = reader.readLine() ) != null ) { String[] data = nextLine.split( CSV_SEPARATOR ); BigDecimal credit = new BigDecimal( data[2] ); BigDecimal sum = summary.get( data[1] ); sum = sum == null ? credit : sum.add(credit); summary.put(data[1], sum ); } } catch (Exception e) { e.printStackTrace(); } try ( FileWriter fileWriter = new FileWriter(outputFileName) ) { fileWriter.append(FILE_HEADER.toString()); fileWriter.append( System.lineSeparator() ); for (Map.Entry<String, BigDecimal> entry : summary.entrySet() ) { fileWriter.append( entry.getKey() ); fileWriter.append( CSV_SEPARATOR ); fileWriter.append( entry.getValue().toString() ); fileWriter.append( System.lineSeparator() ); } } catch (Exception e) { e.printStackTrace(); } }
Zdrojový kód obsahuje dve časti: v prvej sa číta CSV súbor a súčasne sa do mapy napočítava sumár a v druhej časti sa sumár zapíše do nového výstupného CSV súboru. Problém spracovania výnimiek je v programe ignorovaný.
Keď sa k takémuto zápisu dostane iný programátor bez vysvetlenia a bez kontextu, musí veľmi podrobne prečítať celý kód, aby zistil, o čo vlastne ide. Doménové pojmy ako „pohyb na účte“ a „sumár pohybov“ tu nenájde. Hoci v zadaní sa vyskytovali.
Objektovo-orientované riešenie
private static void bankSummary( String inputFileName, String outputFileName ) { try ( AccountMovementRepository accountMovementRepository = new AccountMovementRepositoryCsv(inputFileName); AccountSummaryRepository accountSummaryRepository = new AccountSummaryRepositoryCsv(outputFileName); ) { Collection<AccountMovement> accountMovements = accountMovementRepository.readAll(); Collection<AccountSummary> accountSummaries = summaryMovements( accountMovements ); for (AccountSummary accountSummary : accountSummaries ) { accountSummaryRepository.create(accountSummary); } } catch (Exception e) { e.printStackTrace(); } }
Priamo v zdrojovom kóde sa dá prečítať, že repository načítajú pohyby na účte (account movements). Tie sa volaním metódy „summaryMovements“ prehodia na súčet účtu (account summary). Napokon sa súčty zapíšu zase do iného repository. Keďže repository obvykle zahŕňajú len zapisovanie alebo čítanie dát, z doménového hľadiska je ťažisko v metóde „summaryMovements“:
public static Collection<AccountSummary> summaryMovements( Collection<AccountMovement> movements ) { Map<String, AccountSummary> result = new HashMap<>(); for (AccountMovement accountMovement : movements) { AccountSummary existing = result.get( accountMovement.getName() ); if ( existing == null ) { result.put( accountMovement.getName(), new AccountSummary(accountMovement) ); } else { existing.add( accountMovement ); } } return result.values(); }
Výhody
- Zrozumiteľnosť – hlavné telo programu je oveľa čitateľnejšie.
- Používanie doménových pojmov – namiesto práce so štruktúrou Map<String, BigDecimal> pracuje druhé riešenie so zoznamom pohybov na účte a sumou účtu. Táto zmena je presne v zmysle odporúčaní DDD, aby doménové pojmy našli svoj obraz aj v zdrojovom kóde.
- Oddelenie samostatných častí – čítanie CSV súboru má na starosti trieda AccountMovementRepositoryCsv, zápis zase AccountSummaryRepositoryCsv. Použitie repositories je jednou z techník domain driven designu.
- Dodržanie princípu jednoduchej zodpovednosti (Single Responsibility Principle) – v jednom zdrojovom súbore sa nemiešajú rôzne zodpovednosti.
- Otvorenosť voči zmenám (Open-close principle) – ak by napríklad prišla požiadavka na čítanie vstupných dát z SQL databázy, stačí vytvoriť novú implementáciu rozhrania AccountMovementRepository. Takáto zmena by sa nijako nedotkla podstaty spracovania. Prvé uvedené riešenie by tu narazilo na komplikácie, lebo v prvom riešení je doménová logika súčasťou načítavania dát.
- Odolnosť voči komplikáciám – riešenia nie sú ešte vyladené. V prvom rade treba správne obslúžiť výnimky. Problémy môže narobiť aj čítanie zo súboru (desatinné čísla, dátumy). Prípadne požiadavka na zmenu formátu súboru. Samotná doménová logika je v tomto prípade zámerne jednoduchá, avšak v praxi to tak nebýva. Akékoľvek rozšírenie či doplnenie nového pravidla bude ešte viac zneprehľadňovať riešenie ako celok.
- Znovupoužiteľnosť – čítanie resp. zápis do CSV súboru nijako nesúvisí so samotným spracovaním dát. Uvedené repository sa dajú využiť aj na iné účely. Znovupoužiteľnosť je však len dôsledok dobrého dizajnu, čiže o znovupoužiteľnosť sa netreba snažiť.
- Oddelenie technologickej a doménovej časti – čítanie CSV súborov je sprostredkované cez repositories, čo sú však iba rozhrania (interface). Implementácia týchto rozhraní môže byť umiestnená v inom module (package), aby sa technologická časť nemiešala s doménovou. Otočenie závislosti doménovej logiky na technologickej časti je príklad aplikovania prístupu „dependency inversion“ zo skratky SOLID.
- Testovateľnosť – na kľúčovú časť biznis logiky je jednoduché napísať unit test. Kľúčovou časťou biznis logiky je spočítavanie pohybov na účte do sumarizácie podľa účtov, čiže metóda „summaryMovements“.
Nevýhody
- Druhé riešenie je časovo náročnejšie na implementáciu. Výsledkom implementácie je až sedem tried, hoci prvé riešenie si vystačilo s jednou metódou.
- Prvé riešenie je nenáročné na dizajn. Nevyžaduje premyslieť si zodpovednosť jednotlivých tried.
Vylepšenia prvého riešenia
Niektoré zjavné nevýhody prvého riešenia by sa dali odstrániť využitím techník procedurálneho programovania. Napríklad import dát z CSV by mohla byť jedna podprocedúra, sumarizácia dát druhá a zápis do CSV tretia podprocedúra. Procedurálna dekompozícia je častý nástroj pri procedurálnom programovaní. Objektovo orientované programovanie však pracuje s pojmami, a teda robí „pojmovú dekompozíciu“. Správna dekompozícia je najťažší mentálny posun pre programátora pri prechode z procedurálneho na objektové programovanie.
… a prax
Všetky uvedené výhody „správneho riešenia“ hovoria o kvalite kódu. Čiže o tom, ako rýchlo nový programátor pochopí existujúci kód. A o tom, ako rýchlo bude možné zapracovať nové požiadavky. Aj s využitím znovupoužiteľných komponentov, ktoré kvalitný objektový návrh automaticky prináša.
Všetky tieto výhody v praxi častokrát prevalcuje nevýhoda číslo jedna: pomalšia implementácia na začiatku. Táto nevýhoda je však iba iluzórna. Obvykle sa na konci projektu (alebo iterácie) vynoria ďalšie skryté požiadavky, ktoré nikomu počas analýzy a dizajnu nenapadli. Keďže zdrojový kód je už hotový a otestovaný, ide o normálne požiadavky na zmenu (change request). A už v tomto momente sa začnú prejavovať výhody druhého riešenia.
Ako tvrdí Dr. Dobbs: objektovo-orientovaný prístup je nutnou požiadavkou pre agilný vývoj softvéru.
Ďalšie čítanie
Kompletné zdrojáky uvedených príkladov sú na githube.
Grady Booch: Object-Oriented Analysis and Design with Applications
OO as a Prerequisite to Agile