Entity beans v JBoss a relace

3. 12. 2008
Doba čtení: 10 minut

Sdílet

Nadešel čas seznámit se s dalším typem business komponenty, s entity beans. Entitní komponenty mají za úkol perzistenci dat, bez které se většina aplikací neobejde. Zachovávají vlastnosti objektového programování a přitom se pohodlně ukládají do relační databáze. Jak to celé funguje?

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 CustomerRecor­d 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říkladICO­NIX), 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/cus­tomers 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_PHONE­NUMBER (CUSTOMERS_ID, PHONES_ID), ITEMORDER (ID, ITEM, ITEMCOUNT, ORDERDATE, CUSTOMER_ID), a PHONENUMBER (ID, NUMBER). Zajímavá je především tabulka CUSTOMER_PHONE­NUMBER (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ů.

ict ve školství 24

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.

Autor článku