Relace mezi entitami
V minulém dílu jsme si představili Entity Beans – další business komponentu. Dnes budeme pokračovat detailním zkoumáním vazeb a strategií načítání vazeb.
Jak již bylo zmíněno, vazby je možné použít celkem čtyři: @ManyToOne
, @OneToOne
, @OneToMany
, @ManyToMany
. Pokud vazbu specifikujete na jedné straně, musíte odpovídající vazbu uvést i na navázané entitě. Konkrétně to ukazuje následující příklad z dokumentace OneToMany vazby, na kterou jsem odkazoval už minule. V dokumentaci je ovšem chyba, neboť generický typ Set<Order>
nemá správně zapsány znaky je menší a je větší a ty se interpretují jako tag. Správně by to tedy mělo vypadat tak, jak ukazuje následující výpis.
public class Customer { ... @OneToMany(cascade=ALL, mappedBy="customer") public Set<Order> getOrders() { return orders; } ... } public class Order { ... @ManyToOne @JoinColumn(name="CUST_ID", nullable=false) public Customer getCustomer() { return customer; } ... }
Všiměte si, že protějškem @OneToMany
je @ManyToOne
, v případě @OneToOne
a @ManyToMany
jsou anotace použité na obou stranách stejné.
V případě, že nepoužijeme generický typ pro množinu, která se vrací z getOrders()
, musíme ještě navíc uvést atribut targetEntity
v anotaci @OneToMany
.
Podívejme se na další atributy u anotace @OneToMany
. Atribut mappedBy
se dokazuje na vlastnost cílové entity, která udržuje odkaz zpět. Konkrétně na vlastnost customer
třídy Order
, kde se ukládá odkaz na třídu Customer
. Atribut cascade
určuje, které operace se třídou Customer
se mají promítnout do třídy Order
. K výběru jsou následující hodnoty:
- ALL – všechny prováděné operace se provedou i s navázanými entitami
- MERGE – operace uložení změn objektů do databáze
- PERSIST – operace vložení do databáze
- REFRESH – operace obnovení
- REMOVE – operace odstranění
Hodnoty je možné kombinovat. Hodnota ALL
je totožná s vyjmenováním všech ostatních.
Anotace @JoinColumn
ve třídě Order
specifikuje jméno sloupečku v tabulce s entitami Customer
, přes který je možné konkrétní navázanou entitu vyhledat.
Vazba @OneToOne
se zdá být poměrně jednoduchá, ale i tak nás může překvapit. Předně je potřeba si uvědomit, že musí být zapsaná u obou provázaných entit. Musíme si také rozmyslet, která z entit bude udržovat atribut pro uložení odkazu na tu druhou. Popíšeme si nyní oba příklady z dokumentace.
public class Customer { ... @OneToOne(optional=false) @JoinColumn(name="CUSTREC_ID", unique=true, nullable=false, updatable=false) public CustomerRecord getCustomerRecord() { return customerRecord; } ... } public class CustomerRecord { ... @OneToOne(optional=false, mappedBy="customerRecord") public Customer getCustomer() { return customer; } ... }
Vidíme, že třída (přesněji její instance) Customer
udržuje ve sloupečku CUSTREC_ID
odkaz na třídu CustomerRecord
. Vazba je povinná, což ještě zdůrazňuje atribut nullable
anotace @JoinColumn
. Ve vazbě není možné objekty CustomerRecord
vyměnit ( updatable=false
). To ovšem neznamená, že nemůžeme měnit přímo hodnoty uložené v CustomerRecord
. Ve třídě CustomerRecord
je pak specifikován záznam, který „vlastní daný vztah“ (tj. udržuje odkaz na navázanou entitu).
Druhý příklad vypadá podstatně uhlazeněji.
public class Employee { @Id Integer id; @OneToOne @PrimaryKeyJoinColumn EmployeeInfo info; ... } public class EmployeeInfo { @Id Integer id; ... }
Špinavá práce je ponechána na aplikačním serveru. Toto řešení je samozřejmě těžko použitelné v případě, že se pokoušíte napasovat do již existují databázove struktury. Pokud ovšem začínáte od začátku a postupujete metodami objektového návrhu (napříkladICONIX), tak je toto řešení vhodné.
Třída Employee
v atributu info
udržuje odkaz na primární klíč třídy EmployeeInfo
. U anotace @OneToOne
můžeme jako v předchozím případě uvést povinnost vazby.
Komplikovanější je vazba @ManyToMany
, která vyžaduje zvláštní vazební tabulku. I když to může vypadat nelogicky, i zde si musíme určit jednu entitu jako tu „hlavní“. V podstatě jde o to, že v jedné z entit nadefinujeme způsob ukládání vazby. Nejlepší bude si situaci opět popsat na příkladech.
public class Customer { ... @ManyToMany @JoinTable(name="CUST_PHONE", joinColumns=@JoinColumn(name="CUST_ID", referencedColumnName="ID"), inverseJoinColumns=@JoinColumn(name="PHONE_ID", referencedColumnName="ID") ) public Set<PhoneNumber> getPhones() { return phones; } ... } public class PhoneNumber { ... @ManyToMany(mappedBy="phones") public Set<Customer> getCustomers() { return customers; } ... }
Ve třídě Customer
je uvedeno jméno vazební tabulky CUST_PHONE
, jména sloupečků pro navázání obou entit a jména sloupečků, které se budou provazovat. Třída PhoneNumber
potom jen definuje atribut, který ve třídě Customer
udržuje seznam instancí PhoneNumber
. Další příklad je opět podstatně jednodušší, protože explicitně nevyžaduje způsob uložení vazby.
public class Customer { ... @ManyToMany(targetEntity=com.acme.PhoneNumber.class) public Set<PhoneNumber> getPhones() { return phones; } ... } public class PhoneNumber { ... @ManyToMany(targetEntity=com.acme.Customer.class, mappedBy="phones") public Set<Customer> getCustomers() { return customers; } ... }
Atribut targetEntity
je volitelný a je zde jen pro ukázku toho, jak se používá. Aplikační server dokáže tento vztah sám zjistit.
Strategie načítání navázaných entit
Při načítání vlastností objektů a navázaných objektů z databáze si můžeme vybrat mezi dvěma typy načítání. Okamžité (eager, horlivé) a na vyžádání (lazy, líné). Standardně se používá okamžité načítání dat. V případě atributu toho můžeme využít například, pokud předpokládáme velký objem uložených dat a nejsme si jisti, že je budeme vždy potřebovat. Při načítání na vyžádání jsou data z databáze získána teprve, když se k nim pokusíme poprvé přistoupit. Zrádné je to v tom, že je vygenerován samostatný SQL příkaz, což může mít v případě takového načítání větších objemů dat vliv na výkon.
public class Article { ... @Basic(fetch=FetchType.LAZY) public String getArticleBody() { ... } ... }
Samotná anotace @Basic
se používá pro označení dat, která chceme persistovat a pro nastavení parametrů základní persistence. Tato anotace je volitelná, proto zatím nebyla nikde využita.
Podobně je možné na vyžádání načítat navázané entity. Ukážeme si to na příkladu vazby 1:N.
public class Customer { ... @OneToMany(cascade=ALL, mappedBy="customer", fetch=FetchType.LAZY) public Set<Order> getOrders() { return orders; } ... } public class Order { ... @ManyToOne @JoinColumn(name="CUST_ID", nullable=false) public Customer getCustomer() { return customer; } ... }
Každý objekt vrácený funkcí getOrders()
bude načten až při prvním přístupu k němu. Naproti tomu bude příslušný objekt Customer
načten zároveň se získáním objektu Order
.
Kompletní příklad
Vše si ukážeme na přiloženém příkladu. Příklad obsahuje čtyři entity beans. Hlavní entita je Customer
(zákazník), která obsahuje odkazy na všechny ostatní. Ke každému zákazníkovi je vazbou 1:1 přivázána entita CustomerInfo
. Využívá se zde připojení přes primární klíč.
@OneToOne(cascade = ALL) @PrimaryKeyJoinColumn public CustomerInfo getCustomerInfo() { return customerInfo; }
Ke každému se vazbou 1:n váží objednávky – Order
, vyabstrahujme se na fakt, že je možné objednat jen jednu položku současně. Za povšimnutí stojí volba fetch = EAGER
, která nastavuje horlivé načítání objednávek. Znamená to, že se při načtení zákazníka rovnou načtou i všechny objednávky. Později si ukážeme úskalí volby LAZY
. Zajímavá je anotace @OrderBy
, která říká v jakém pořadí budou záznamy načteny.
@OneToMany(cascade = ALL, fetch = EAGER, mappedBy = "customer") @Column(nullable = true, updatable = false) @OrderBy("orderDate") public Set<Order> getOrders() { return orders; }
A konečně může mít každý zákazník libovolný počet telefonních čísel. Dokonce mohou dva zákazníci jedno telefonní číslo sdílet. Proto je použita vazba n:m.
@ManyToMany(cascade = ALL, fetch = EAGER) @Column(nullable = true, updatable = false) @OrderBy("number") public Set<PhoneNumber> getPhones() { return phones; }
Po rozbalení a nastavení build.properties
jako obvykle můžeme příklad rovnou zkompilovat a vystavit příkazem ant deploy
. Na adrese localhost:8080/customers pak uvidíme výsledek. Na první pohled není zrovna ohromující, ale důležitější je, co stojí v pozadí. Pokud používáme standardní Hypersonic databází můžeme v příslušné
MBean v JMX konzoli spustit operaci startDatabaseManage
a podívat se na obsah databáze. Aplikace vytvořila tabulky CUSTOMER (ID, FIRSTNAME, LASTNAME), CUSTOMERINFO (ID, DIC, IC), CUSTOMER_PHONENUMBER (CUSTOMERS_ID, PHONES_ID), ITEMORDER (ID, ITEM, ITEMCOUNT, ORDERDATE, CUSTOMER_ID), a PHONENUMBER (ID, NUMBER). Zajímavá je především tabulka CUSTOMER_PHONENUMBER (CUSTOMERS_ID, PHONES_ID), která byla vytvořena automaticky pro ukládání vazby n:m. Ostatní tabulky slouží přímo pro uložení entit. Jediný sloupeček, který se vytvořil navíc speciálně pro odkaz je CUSTOMER_ID v tabulce ITEMORDER pro vazbu 1:n. V ostatních případech se využily primární klíče.
Vzhledem k tomu, jakým způsobem se automaticky vytváří tabulky, nebylo možné vytvořit tabulku ORDER. Jedná se totiž o rezervované slovo. Proto bylo nutné použít jiné jméno.
@Entity(name = "ITEMORDER") public class Order { ...
Práce s entitami je řízena, podobně jako v minulém příkladu, z bezestavové session bean. Má pouze dvě metody. První vrátí všechny zákazníky a druhá vytvoří zkušební data. Při získávání zákazníků dojde automaticky k načtení všech navázaných entit, protože používáme strategii EAGER.
public class CustomersBean implements Customers { @PersistenceContext private EntityManager em; ... public List<Customer> getAllCustomers() { return em.createQuery("select customer from Customer customer order by customer.lastName").getResultList(); } public void generateData() { ... Customer c1 = new Customer(); c1.setLastName("Žblotký"); c1.setFirstName("Lumír"); em.persist(c1); CustomerInfo i1 = new CustomerInfo(); i1.setId(c1.getId()); i1.setDIC("CZ7608233450"); i1.setIC("20725506"); c1.setCustomerInfo(i1); ... } ... }
Všimněte si, že se zákazníkem manipulujeme i poté, co jsme zavolali metodu persist()
. Tím dojde k připojení dané entity do aktuálního persistent contextu. Všechny změny jsou od této chvíle sledovány. Na tomto místě je vhodné se zmínit o tom, jak EJB kontejner (JBoss AS) spravuje entity beans. Pokud entity bean předáte přímo klientské aplikaci (která může fyzicky běžet na jiném stroji), není již možné změny automaticky sledovat. Kontejner automaticky (pokud mu neřeknete jinak) se vsupem do EJB metody vytvoří session (sezení, neplést si například s HTTP session). V rámci tohoto sezení je možné změny sledovat a využívat LAZY načítání navázaných entit. Pokud entity bean předáte ven z tohoto sezení (typicky jako návratovou hodnotu funkce business komponenty), dojde k jejímu odpojení z persistent contextu. Změny provedené na takové entitě musí být zpátky do databáze promítnuty voláním metody merge()
na příslušné instanci EntityManager
. Jedině tak dojde k opětovnému připojení entity do persistent contextu a sezení.
Toto si můžete vyzkoušet. Pokud změníte strategii načítání u objednávek na LAZY
, nebude příklad fungovat, protože ve chvíli, kdy se k objednávkám přistupuje, je zákazník již odpojen.
@OneToMany(cascade = ALL, fetch = LAZY, mappedBy = "customer") ... public Set<ItemOrder> getOrders() { return orders; }
Místo výsledku uvidíme jen vyjímku.
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: cz.root.jboss.customers.Customer.orders, no session or session was closed
Vazba @ManyToMany
používá standardně strategii LAZY. Ne u všech to ale platí stejně (viz například dokumentace @OneToOne
).
Pokud stránku s příkladem načtete vícekrát, uvidíte pořád ty stejné údaje i přesto, že se pokaždé volá metoda na vygenerování dat. Trik je v tom, že tato metoda na začátku všechny zákazníky smaže a díky nastavení cascade=ALL
se smažou i všechny navázané entity.
List<Customer> custs = getAllCustomers(); for (Customer c : custs) { em.remove(c); }
Proto, abychom mohli entity hezky vypsat byla u všech předefinována metoda toString()
, která vrací řetězec popisující entitu.
Ve výstupu si všimněte jak došlo k seřazení telefonních čísel díky anotaci @OrderBy
.
@OrderBy("number") public Set<PhoneNumber> getPhones() { return phones; }
A ještě jedna drobnost. Podle specifikace musí být pole typu java.util.Date
and java.util.Calendar
označeny anotací @Temporal
. Parametr této anotace pak určuje na jaký typ z balíku java.sql
se pole mapuje. V našem příkladu ukládáme datum, takže je použita hodnota DATE
.
public class Order { ... @Temporal(DATE) public Date getOrderDate() { return orderDate; } ... }
Problém výběru n+1 prvků
Strategie líného načítání závislostí sebou nese jedno úskalí, na které je třeba dávat pozor. Představme si, že pro všechny zákazníky chceme vypsat jejich objednávky.
for (Customer c : getAllCustomers()) { for (Order o : c.getOrders()) { ... } }
Při líném načítání budeme muset provést jeden dotaz pro načtení zákazníků a pro každého zákazníka jeden dotaz pro načtení všech objednávek. Celkem tedy N+1 dotazů.
Pro takové případy je vhodné sestavit si vlastní dotaz, který vše načte zaráz pomocí operace join, nebo použít horlivé načítání.
Závěr
Ukázali jsme si, že vazby mezi objekty můžeme tvořit stejné, jako klasická relační databáze – tedy binární s různou aritou. Mezi hlavní přínosy objektového programování patří dědičnost a určitě bychom ocenili, kdyby i takové vztahy bylo možné persistovat. To samozřejmě možné je a v příštím dílu si ukážeme, jak na to.