Testowanie złączeń FETCH JOIN w JPA
Z Jacek Laskowski - Wiki Projektanta Java EE
Na grupie użytkowników Geronimo Tomasz Mazan (aka Beniamin) zadał pytanie Force to load subobjects in OpenJPA o wymuszenie wczytywania danych relacji w Java Persistence API (JPA). Domyślnie JPA wczytuje pola relacyjne z opóźnieniem, jednakże istnieje kilka możliwości zmiany tego zachowania. Tak się złożyło, że wcześniej badałem temat złączeń (ang. joins), więc krótka odpowiedź, uściślenie i ostatecznie problem został rozwiązany. Jest kilka możliwości zaprezentowania tematu, z czego ja wybrałem sposób praktyczny z kilkoma wspierającymi narzędziami.
Natrafiając na pytanie na grupie zawsze zastanawiam się, w jaki sposób mogę szybko odtworzyć problem. Nie chciałbym tracić zbyt wiele czasu na zestawianie środowiska, więc należałoby postawić na narzędzia, które udostępniają potrzebne funkcjonalności. Przyglądając się wymaganiom na plan pierwszy wysuwa się JPA i to bez konieczności uruchamiania w środowisku serwera aplikacji (mimo, że temat pojawił się na grupie serwera aplikacji Geronimo to faktycznie nie był z nim związany bezpośrednio). Jako dostawcę JPA mam do wyboru Apache OpenJPA, TopLink JPA oraz Hibernate JPA. W przypadku Hibernate, który nie jest certyfikowanym dostawcą JPA, sprawa się sama rozwiązuje. Tym razem rezygnuję z niego na rzecz dwóch pozostałych. Pozostaje TopLink i OpenJPA, więc nie mając konkretnych zarzutów technicznych, rzucam monetą i wybieram OpenJPA. Potrzebuję odtwarzać problem kilkakrotnie, więc chwila zastanowienia i włączam do zestawu projekt JUnit (może być i TestNG, jednakże coś należałoby wybrać i podobnie jak to miało miejsce z OpenJPA, wybór jest całkowicie przypadkowy). Do tego Maven, który pozwoli mi jednocześnie na uruchomienie testów z linii poleceń bez konieczności uruchamiania rozbudowanego środowiska programistycznego IDE (NetBeans IDE czy Eclipse IDE), ale również i stworzenie plików projektowych, dzięki którym będę mógł projekt zaimportować do IDE dla uproszczenia programowania. Poza tym Maven uprości mi zarządzanie zależnościami projektu oraz utworzenie właściwej ścieżki klas, więc problem pobierania bibliotek, umieszczania ich w odpowiednim katalogu, a następnie stworzenia skryptu, który tworzyłby ścieżkę klas i uruchamiał test kładę na jego barki. Maven sprawi, że wymagane biblioteki będą po prostu dostępne. To zawsze jest nie lada problem, a nie chodzi o ich generowanie, a wręcz przeciwnie - ich rozwiązywanie. Dla utrzymania prostoty rozwiązania dokładam lekką i możliwą do wbudowania w aplikację bazę danych - Apache Derby.
Spis treści |
Oprogramowanie
Środowisko składa się z następujących narzędzi:
- Apache Maven 2.0.8
- Apache OpenJPA 1.0.1
- Apache Derby 10.3.1.4
- JUnit 4.4
- NetBeans IDE 6.0 RC2 (opcjonalnie)
Z powyższego oprogramowania jedynie Apache Maven 2 musi być zainstalowany. Pozostałe narzędzia zostaną pobrane dynamicznie z Sieci podczas pierwszego uruchomienia Mavena. Nie dotyczy to oczywiście NetBeans IDE, który musi być zainstalowany osobno. Może to byc również inne IDE, np. Eclipse.
Kompletny projekt jpa-joins do uruchomienia dostępny jest do pobrania jako jpa-joins.zip.
Utworzenie projektu - jpa-joins
Rozpoczynam od stworzenia projektu za pomocą polecenia mvn archetype:create.
jlaskowski@dev /cygdrive/c $ mvn archetype:create -DgroupId=pl.jaceklaskowski.jpa -DartifactId=jpa-joins -Dversion=1.0 [INFO] Scanning for projects... [INFO] Searching repository for plugin with prefix: 'archetype'. [INFO] ---------------------------------------------------------------------------- [INFO] Building Maven Default Project [INFO] task-segment: [archetype:create] (aggregator-style) [INFO] ---------------------------------------------------------------------------- ... [INFO] [archetype:create] [INFO] Defaulting package to group ID: pl.jaceklaskowski.jpa [INFO] artifact org.apache.maven.archetypes:maven-archetype-quickstart: checking for updates from central [INFO] ---------------------------------------------------------------------------- [INFO] Using following parameters for creating Archetype: maven-archetype-quickstart:RELEASE [INFO] ---------------------------------------------------------------------------- [INFO] Parameter: groupId, Value: pl.jaceklaskowski.jpa [INFO] Parameter: packageName, Value: pl.jaceklaskowski.jpa [INFO] Parameter: basedir, Value: c:\ [INFO] Parameter: package, Value: pl.jaceklaskowski.jpa [INFO] Parameter: version, Value: 1.0 [INFO] Parameter: artifactId, Value: jpa-joins [INFO] ********************* End of debug info from resources from generated POM *********************** [INFO] Archetype created in dir: c:\jpa-joins [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESSFUL [INFO] ------------------------------------------------------------------------
Skorzystam z NetBeans IDE 6 i jego wsparcia dla projektów Maven do oprogramowania środowiska. Domyślnie wtyczka nie jest rozprowadzana z NetBeans 6, więc instaluję wtyczkę dla projektów mavenowych (wybieram menu Tools > Plugins, gdzie w zakładce Available Plugins wybieram wtyczkę Maven w kategorii Java i wciskam przycisk Install) i otwieram projekt stworzony przy pomocy Maven.
Rozdział "ostrego" programowania
Tworzę klasy encji Osoba i Konto oraz konfigurację JPA - persistence.xml. Do utworzenia encji potrzebne będą klasy i interfejsy JPA, więc konieczna jest modyfikacja utworzonego pliku pom.xml projektu. Do projektu dodaję wymagane biblioteki OpenJPA, Derby oraz JUnit. Poza tym projekt będzie korzystał z funkcjonalności Java SE 5, więc odpowiednio konfiguruję wtyczkę maven-compiler-plugin. Wszystko się dzieje w ramach pliku pom.xml.
Wprowadzenie zależności projektowych - pom.xml
Zmodyfikowany plik pom.xml prezentuje się następująco:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pl.jaceklaskowski.jpa</groupId>
<artifactId>jpa-joins</artifactId>
<packaging>jar</packaging>
<version>1.0</version>
<name>jpa-joins</name>
<dependencies>
<dependency>
<groupId>org.apache.openjpa</groupId>
<artifactId>openjpa</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>org.apache.derby</groupId>
<artifactId>derby</artifactId>
<version>10.3.1.4</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.5</source>
<target>1.5</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
Uruchamiam tworzony przez mavena test AppTest, aby upewnić się, że zmiany nie zdestabilizowały projektu.
jlaskowski@dev /cygdrive/c/jpa-joins $ mvn test [INFO] Scanning for projects... [INFO] ------------------------------------------------------------------------ [INFO] Building jpa-joins [INFO] task-segment: [test] [INFO] ------------------------------------------------------------------------ ... ------------------------------------------------------- T E S T S ------------------------------------------------------- Running pl.jaceklaskowski.jpa.AppTest Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.031 sec Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESSFUL [INFO] ------------------------------------------------------------------------
Wszystko wydaje się być w należytym porządku. Krok ten jest o tyle ważny, że w przypadku braku odpowiednich zależności w lokalnym repozytorium mavena zostaną one pobrane z Sieci i stąd ważne jest, aby wykonać go mając aktywne połączenie.
Encja JPA - Osoba
Dodanie bibliotek OpenJPA umożliwia nam stworzenie encji. NetBeans IDE rozpoznał zmiany i teraz podpowiada klasy i interfejsy JPA.
Pierwszą encję Osoba tworzymy w katalogu źródłowym src/main/java/pl/jaceklaskowski/jpa (zgodnie z konwencją mavena i zachowując strukturę katalogową dla pakietu pl.jaceklaskowski.jpa).
Encje nie są nadzwyczaj skomplikowane. Pomiędzy encjami Osoba i Konto istnieje relacja jednokierunkowa jeden-do-wielu (adnotacja @OneToMany po stronie Osoba na kolekcji konta). Zakładamy również, że nie jest możliwe tworzenie osób oraz kont bez odpowiednich danych jak imie dla osoby, czy numeru dla konta, stąd ograniczona dostępność bezparametrowych konstruktorów (kwalifikator protected).
package pl.jaceklaskowski.jpa;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.persistence.CascadeType;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.OneToMany;
@Entity
@NamedQueries({
@NamedQuery(name = "Osoba.poImieniu", query = "SELECT o FROM Osoba o WHERE o.imie = :imie"),
@NamedQuery(name = "Osoba.wszystkieOsoby", query = "SELECT o FROM Osoba o"),
@NamedQuery(name = "Osoba.wszystkieOsoby.JOIN_FETCH", query = "SELECT o FROM Osoba o JOIN FETCH o.konta"),
@NamedQuery(name = "Osoba.wszystkieOsoby.LEFT_JOIN_FETCH", query = "SELECT o FROM Osoba o LEFT JOIN FETCH o.konta")
})
public class Osoba implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String imie;
private List<Konto> konta = new ArrayList<Konto>();
protected Osoba() {
}
public Osoba(String imie) {
this.imie = imie;
}
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
@OneToMany(cascade=CascadeType.PERSIST)
public List<Konto> getKonta() {
return konta;
}
public void setKonta(List<Konto> konta) {
this.konta = konta;
}
public void addKonto(Konto konto) {
getKonta().add(konto);
}
public String getImie() {
return imie;
}
public void setImie(String imie) {
this.imie = imie;
}
@Override
public String toString() {
return "Osoba[id=" + getId() + "]";
}
}
Encja JPA - Konto
Następnym krokiem jest utworzenie encji Konto w tym samym katalogu, co klasa encji Osoba, tj. src/main/java/pl/jaceklaskowski/jpa.
package pl.jaceklaskowski.jpa;
import java.io.Serializable;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Konto implements Serializable {
private static final long serialVersionUID = 1L;
private int id;
private String numer;
protected Konto() {
}
public Konto(String numer) {
this.numer = numer;
}
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getNumer() {
return numer;
}
public void setNumer(String numer) {
this.numer = numer;
}
@Override
public String toString() {
return "Konto[id=" + getId() + "]";
}
}
Konfiguracja JPA - persistence.xml
Stworzymy plik konfiguracyjny JPA - persistence.xml, w którym wskażemy bazę danych, z jakiej będziemy korzystali. Dla zwrócenia uwagi napiszę, że jest to typowa konfiguracja bezserwerowa, gdzie dane o połączeniu z bazą danych są deklarowane w tym pliku, a nie na poziomie serwera aplikacji w ramach deklaracji nazwy w drzewie JNDI, która odpowiadałaby puli połączeń do bazy danych. Poza tym na uwagę zasługuje atrybut transaction-type, który ma wartość RESOURCE_LOCAL, więc sami musimy zadbać o zasięg transakcji.
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="1.0" xmlns="http://java.sun.com/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_1_0.xsd">
<persistence-unit name="derbyPU" transaction-type="RESOURCE_LOCAL">
<provider>org.apache.openjpa.persistence.PersistenceProviderImpl</provider>
<class>pl.jaceklaskowski.jpa.Osoba</class>
<class>pl.jaceklaskowski.jpa.Konto</class>
<properties>
<property name="openjpa.ConnectionDriverName" value="org.apache.derby.jdbc.EmbeddedDriver" />
<property name="openjpa.ConnectionURL" value="jdbc:derby:target/derbyDB;create=true" />
<property name="openjpa.ConnectionUserName" value="app" />
<property name="openjpa.ConnectionPassword" value="app" />
<property name="openjpa.jdbc.SynchronizeMappings" value="buildSchema(SchemaAction='add,deleteTableContents')" />
<property name="openjpa.Log" value="DefaultLevel=WARN,SQL=TRACE" />
</properties>
</persistence-unit>
</persistence>
Plik persistence.xml umieszczamy w katalogu src/main/resources/META-INF.
Klasa testowa - JoinsTest
Tworzę nową klasę testową pl.jaceklaskowski.jpa.JoinsTest w katalogu src/test/java/pl/jaceklaskowski/jpa. Korzyścią z wprowadzenia klasy testowej JoinsTest jest stworzenie środowiska do wykonania zapytań oraz komentarze w niej, które instruują sposób ich działania i oczekiwane rezultaty. Oszczędzę sobie dodatkowych.
package pl.jaceklaskowski.jpa;
import java.util.List;
import java.util.Set;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import javax.persistence.Query;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
public class JoinsTest {
final static String JAREK_IMIE = "Jarek";
final static String JACEK_IMIE = "Jacek";
final static String JACEK_KONTO_1 = "001-abc-12340-573";
final static String JACEK_KONTO_2 = "002-abc-12340-573";
final static String JACEK_KONTO_3 = "003-abc-12340-573";
EntityManagerFactory emf;
EntityManager em;
@Before
public void setUp() throws Exception {
// Analogiczne do @PersistenceUnit w EJB3
emf = Persistence.createEntityManagerFactory("derbyPU");
// Analogiczne do @PersistenceContext w EJB3
em = emf.createEntityManager();
// Tworzymy Jarka bez kont (zbieżność imion przypadkowa)
Osoba jarek = new Osoba(JAREK_IMIE);
// Tworzymy Jacka z 2-ma kontami
Osoba jacek = new Osoba(JACEK_IMIE);
jacek.addKonto(new Konto(JACEK_KONTO_1));
jacek.addKonto(new Konto(JACEK_KONTO_2));
jacek.addKonto(new Konto(JACEK_KONTO_3));
// zapisujemy encje do bazy danych
EntityTransaction tx = em.getTransaction();
tx.begin();
em.persist(jarek);
em.persist(jacek);
tx.commit();
}
@Test
public void zaprezentujZlaczenia() {
Query query;
List<Osoba> osoby;
// czyścimy kontekst trwały
// encje jacek oraz jarek istnieją wyłącznie w bazie danych
em.clear();
// sprawdźmy ilość osób w bazie danych (wczytujemy osoby do kontekstu)
query = em.createNamedQuery("Osoba.wszystkieOsoby");
osoby = query.getResultList();
assert osoby.size() == 2 : "W bazie muszą być 2 osoby: jarek i jacek";
em.clear();
// sprawdźmy dostępność, a raczej niedostępność kont
query = em.createNamedQuery("Osoba.poImieniu");
query.setParameter("imie", JACEK_IMIE);
Osoba jacek = (Osoba) query.getSingleResult();
// ponownie czyścimy kontekst, więc jacek staje się odłączoną encją
// żadne z kolekcji nie pobranych podczas zapytania lub kiedy encja była zarządzana
// nie zostaną wczytane
em.clear();
assert jacek.getKonta() == null : "Konta są niedostępne - domyślnie opóźnione wczytywanie kolekcji";
// sprawdźmy ilość osób z przynajmniej jednym kontem (kolekcja kont wczytana)
query = em.createNamedQuery("Osoba.wszystkieOsoby.JOIN_FETCH");
osoby = query.getResultList();
// ponownie czyścimy kontekst po wykonaniu zapytania z JOIN FETCH
em.clear();
assert osoby.size() == 1 : "W bazie istnieje wyłącznie 1 osoba z kontami";
assert osoby.get(0).getImie().equals(JACEK_IMIE) : "Tylko Jacek posiada konto";
// Ilość kont przypisanych do Jacka to ilość_kont^ilość_użytkowników. Dlaczego?
int iloscKont = 3, iloscOsob = 2;
assert osoby.get(0).getKonta().size() == Math.pow(iloscKont, iloscOsob);
// sprawdźmy ilość osób bez względu czy mają konta czy nie (kolekcja kont wczytana)
query = em.createNamedQuery("Osoba.wszystkieOsoby.LEFT_JOIN_FETCH");
osoby = query.getResultList();
// ponownie czyścimy kontekst po wykonaniu zapytania z LEFT JOIN FETCH
em.clear();
assert osoby.size() == 2 : "W bazie muszą być 2 osoby: jarek i jacek";
// sprawdzmy jacka i jego konta
assert osoby.get(0).getImie().equals(JACEK_IMIE) && osoby.get(0).getKonta().size() == 3 : "Jacek musi mieć 3 konta";
// sprawdzmy jarka i jego konta
assert osoby.get(1).getImie().equals(JAREK_IMIE) && osoby.get(1).getKonta().size() == 0 : "Jarek nie może posiadać konta";
}
@After
public void tearDown() throws Exception {
em.close();
emf.close();
}
}
Uruchomienie
Pora na uruchomienie środowiska i demonstrację działania złączeń w JPA.
jlaskowski@dev /cygdrive/c/jpa-joins $ mvn -Dtest=JoinsTest clean test [INFO] Scanning for projects... [INFO] ------------------------------------------------------------------------ [INFO] Building jpa-joins [INFO] task-segment: [clean, test] [INFO] ------------------------------------------------------------------------ ... ------------------------------------------------------- T E S T S ------------------------------------------------------- Running pl.jaceklaskowski.jpa.JoinsTest ... Results : Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESSFUL [INFO] ------------------------------------------------------------------------
Wszystko działa bez zarzutu. Wiedza została utrwalona (zbieżność słów przypadkowa). Pozostaje dalej komplikować klasę testową, aby doszkolić się dokładniej. Pozostawiam to czytelnikowi, Tobie, jako zadanie domowe. Miłego rozpoznawania JPA!
Komentarze i pomysły dalszych materiałów mile widziane. Skontaktuj się ze mną pod adresem jacek@laskowski.net.pl.
