PV168/Příklad objektového návrhu

Z FI WIKI
Přejít na: navigace, hledání

Ukážeme si příklad, jak udělat objektový návrh. Volím téma správy hřbitova, protože není v tomto semestru povoleno.

Use case diagram

Diagram případů užití (anglicky use case diagram) slouží k zachycení požadované funkcionality z hlediska zákazníka nebo uživatele.

Graveyard usecase diagram.png

Class diagram

Diagram tříd (anglicky class diagram) slouží k přehlednému zobrazení struktury systému. Zobrazuje třídy, jejich atributy a metody, a vztahy mezi třídami.

Entity

Nejdříve si zanalyzujeme, co jsou v tomto případě entity. Entita je ještě obecnější než "věc", je to cokoliv, co má vlastnosti, i když je to nehmotné, třeba výpůjčka knihy. V našem případě jsou entity hrob (Grave) a tělo (Body). Každé má nějaké vlastnosti, které si potřebujeme pamatovat:

Cemetery entities.png

U entit se vždy hodí mít nějaké umělé jednoznačné id. Praxe ukazuje, že identifikátory z reálného světa, třeba rodná čísla osob, uživatelská jména v operačních systémech, nebo VIN u automobilů, nejsou vhodná jako id, protože nejsou vždy jednoznačná a mohou se v čase měnit.

Fialové (P) v kolečku znamená "property", tedy že existuje dvojice set a get metod, například pro property "id" jsou to metody

 
    public Long getId() {
        return id;
    }
 
    public void setId(Long id) {
        this.id = id;
    }

Reprezentace času

Pro reprezentaci času máme mnoho možností:

  • java.util.Date - časový okamžik, počet milisekund od epochy (1.1.1970 0:00 GMT)
  • java.sql.Date - datum v SQL, ale reprezentováno jako java.util.Date zarovnané na lokální půlnoc
  • java.sql.Timestamp - časový okamžik v SQL s přesností na nanosekundy
  • java.sql.Time - čas v rámci dne, reprezentován jako java.util.Date dne 1.1.1970
  • java.time.Instant - časový okamžik, počet sekund a nanosekund od epochy
  • java.time.ZonedDateTime - datum a čas v určité časové zóně
  • java.time.LocalDateTime - datum a čas v rámci dne bez časové zóny
  • java.time.LocalDate - jen datum bez času a bez časové zóny
  • java.time.LocalTime - jen čas bez datumu a bez časové zóny

V příkladu s Body výše by byla nejlepší volba LocalDate, použitá takto:

 
 //nastavení dne nerození
 body.setBorn(LocalDate.of(1912, 6, 23));
 
 //ukládání do databáze pro starší JDBC ovladače 
 preparedStatement.setDate(2,Date.valueOf(body.getBorn()));
 //ukládání do databáze pro JDBC ovladače verze 4.2+
 preparedStatement.setObject(2,body.getBorn(),Types.DATE);
 
 //čtení z databáze
 resultSet.getDate("born").toLocalDate()

Manager

Entitní třída se stará o držení dat pro danou entitu. Ale už není vhodné, aby se starala třeba o zobrazování dat na obrazovku, nebo o ukládání dat do databáze. Pro tyto operace je vhodné mít jinou třídu, a ještě lépe interface, který definuje možné operace s entitní třídou, a pak implementaci tohoto interface. Například:

Cemetery body manager.png

S entitami obvykle potřebujeme provádět tzv. CRUD operace - Create - Retrieve - Update - Delete. Proto interface BodyManager má metodu pro vytvoření nového záznamu o těle (typicky v databázi). Tato metoda má typicky signaturu

 
public interface BodyManager {
 
    void createBody(Body body);
 
    //...
}

Je lepší, když parametrem této metody je instance třídy Body, tedy např.:

 
    void createBody(Body body);

než její položky, např.:

 
    void createBody(Long id, String name, Date born, Date died, boolean vampire);

protože v případě, že přidáme do třídy Body další položku, už nemusíme měnit metodu createBody(). V programu pak vytvoření nového záznamu vypadá nějak takto:

 
   Body body = new Body();
   body.setName("Vlad Dracula");
   SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy");
   String born = "01-01-1431";
   String died = "20-12-1476";
   body.setBorn(sdf.parse(born));
   body.setDied(sdf.parse(died));
   body.setVampire(true);
        
   BodyManager bodyManager = getBodyManager();
   bodyManager.createBody(body);

Interface BodyManager je představitelem návrhového vzoru Data Access Object.

Podle nového API v Javě 8 můžeme pracovat s časem i takto:

 
   Body body = new Body();
   body.setName("Vlad Dracula");
   LocalDate born = LocalDate.of(1431, Month.JANUARY, 1);
   LocalDate died = LocalDate.of(1476, Month.DECEMBER, 20);
 
   body.setBorn(Date.from(born.atStartOfDay(ZoneId.systemDefault()).toInstant())); //abychom nemuseli delat silene konverze, tak bychom museli pracovat jen s LocalDate, pripadne LocalDateTime
   body.setDied(Date.from(died.atStartOfDay(ZoneId.systemDefault()).toInstant()));
   body.setVampire(true);
        
   BodyManager bodyManager = getBodyManager();
   bodyManager.createBody(body);

Vztah mezi entitami

1:N

Nyní si rozebereme vztah mezi těmito entitami. Je to vztah 1:N, protože v jednom hrobě může být více těl, ale jedno tělo je nanejvýš v jednom hrobě. (Další možnost vztahu je N:M, třeba vztah mezi vyučovanými předměty a studenty, neboť jeden student může studovat více předmětů a jeden předmět je studován více studenty.)

Jedna možnost, jak vyjádřit vztah 1:N, je zavést do třídy Grave vlastnost bodies typu Collection<Body>, jak je vidět na tomto diagramu:   Cemetery 1toN.png

Ale toto řešení má svoje nevýhody. Zejména paměťovou náročnost, protože ke každé instanci třídy Grave v paměti bychom museli mít v paměti i příslušné instance třídy Body. V tomto případě to není tak hrozné, ale představte si třeba model obchodu, který má oddělení, a každé oddělení má odkazy na nadřízená a podřízená oddělení a na výrobky v oddělení, a výrobek má odkaz na oddělení, do kterých patří. Pak musíme při načtení jedné instance výrobku do paměti kvůli tranzitivním závislostem načíst do paměti úplně všechno, což nemusí být únosné.

Z hlediska využité paměti je lepší řešení, kdy zavedeme správce tohoto vztahu. Zavedeme tedy interface CemeteryManager:

Cemetery full.png

Tento interface nám poskytuje metody pro vazbu mezi hroby a těly.

M:N

U vztahu M:N je situace složitější. Například pokud budeme mít půjčovnu vzducholodí, entity budou AirShip a Customer, ale jeden zákazník si může postupně vypůjčit více vzducholodí, a jedna vzducholoď může být postupně půjčena více zákazníkům, jedná se tedy o vztah N:M. Ten je vyjádřen další entitou Lease, která představuje půjčku. Viz následující diagram:

Airships uml.png

Konzistence údajů

Všimněte si, že informace, zda je vzducholoď zrovna vypůjčená nebo volná, není atributem třídy AirShip. Tuto informaci můžeme získat dotazem na všechny výpůjčky dané vzducholodě, tj. voláním metody LeaseManager.findLeasesForAirShip(AirShip), která vrací seznam instancí třídy Lease, a následnou kontrolou, zda je některá z nich ještě aktivní, tj. nemá vyplněnou položku realEndTime. Můžeme si na to případně udělat metodu vracející boolean LeaseManager.isAirShipAvailable(AirShip). Tedy to, zda je nebo není vzducholoď vypůjčená, je vlastností výpůjček, nikoliv vzducholodě samotné.

Pokud bychom měli u třídy AirShip atribut o aktuálním vypůjčení, byla by informace uložená v databázi dvakrát, a to by mohlo vést k nekonzistenci dat. K nekonzistenci dochází, když máme tutéž informaci uloženou vícekrát, a její hodnoty se v jejích kopiích liší. Pak není jasné, která hodnota je správná.

Implementace manažerů

Je zvykem pojmenovávat implementace rozhraní stejným jménem s příponou Impl, tedy implementace interface BodyManager by se měla jmenovat BodyManagerImpl, implementace interface GraveManager by se měla jmenovat GraveManagerImpl a tak dále. Dodržování zvyklostí při programování je dobrá věc, pomáhá orientaci v cizím kódu, a dokonce nám může ušetřit práci při využívání nástrojů pracujících na principu convention-over-configuration.

Implementace manažerů by měly být bezestavové, veškeré operace by měly provádět okamžitě nad nějakým trvalým úložištěm (anglicky persistent storage), v našem případě to bude relační databáze. Neměly by tedy v sobě mít žádné atributy držící data, například kolekce nebo mapy. Mohou mít jen atributy nezávislé na datech, typicky si udržují spojení na databázi.

Časté chyby

Častá chyba návrhu je, že manažer entitní třídy, místo aby poskytoval CRUD operace nad nějakým úložištěm dat, drží všechno v paměti, a obsahuje dvojici metod typu "load()" a "store()". Takový návrh není škálovatelný.

Další častá chyba je, že metody manažerů entit mají místo jednoho argumentu typu entitní třídy mnoho argumentů odpovídajících položkám entitní třídy. Pak je s každou změnou entitní třídy nutné měnit i většinu metod manažeru.

Dalším častým prohřeškem je špatné oddělení odpovědnosti tříd, kdy entitní třída nejenže drží data, ale poskytuje i metody např. "saveToDatabase()" nebo "showOnScreen()". Tím vzniká zbytečná závislost této třídy na jiných třídách. Pokud se pak rozhodneme změnit způsob uložení dat nebo uživatelské rozhraní, je to mnohem těžší.