Kwadrans w Bibliotece z JSF 1.2, EJB 3.0 i JPA
Z Jacek Laskowski - Wiki Projektanta Java EE
Na grupie pl.comp.lang.java padło pytanie hibernate one-to-many - czy tak? o Java Persistence (JPA) i sekwencję metod, jakie należy wywołać, aby uaktualnić autora książki w relacji jeden-do-wielu. Nie byłem pewien odpowiedzi, więc postanowiłem zestawić aplikację zanim udzielę odpowiedzi. Będąc członkiem zespołu NetCAT 6.0 (NetBeans Community Acceptance Test) postanowiłem upiec dwie pieczenie na jednym ogniu i nie tylko stworzyć aplikację, ale i sprawdzić możliwości NetBeans IDE 6.0 w sprawnym skonstruowaniu aplikacji Java EE 5. Okazało się, że to, co sądziłem, że zabierze mi 5 minut, zabrało mi faktycznie znacznie więcej ze względu na wiele tematów pobocznych. Udało mi się jednak zgłębić podstawowy temat oraz w nagrodę kilka innych problemów związanych ze zrozumieniem specyfikacji Korporacyjnej Javy 5 (Java EE 5), które nękały mnie od dawna, ale nie były na tyle uciążliwe, abym się nimi zajął.
Celem artykułu będzie stworzenie aplikacji Biblioteka typu CRUD (tworzenie-odczyt-modyfikacja-usunięcie - bez możliwości kasowania), która składa się z interfejsu użytkownika zbudowanego w JavaServer Faces 1.2 (JSF) wraz z częścią biznesową opartą o ziarna EJB 3.0 z wykorzystaniem encji JPA 1.0. Wszystko uruchomione jest na serwerze aplikacji Java EE 5 - Glassfish v2. Przy tworzeniu korzystałem z wersji rozwojowej NetBeans IDE 6.0 Nightly, jednakże było to podyktowane wyłącznie chęcią poddania go testowi prostoty prototypowania niż konkretnemu wymaganiu aplikacji.
Na uwagę zasługuje prostota aplikacji (pod względem konstrukcji jak i funkcjonalności) i niewielka ilość kodu źródłowego. Wszystkie funkcjonalności dostępne w serwerze zostały wykorzystane - wstrzeliwanie zależności, zarządzanie trwałością danych i transakcje znacząco minimalizując ilość kodu, który musiał zostać stworzony. Dodając do tego wsparcie NetBeans IDE 6.0 dla tworzenia aplikacji korporacyjnych ilość kodu wprowadzonego manualnie była dodatkowo zmniejszona.
Spis treści |
Oprogramowanie
Środowisko składa się z następujących narzędzi:
- GlassFish v2
- NetBeans IDE 6.0 Nightly z dnia 03.10.2007
Zakłada się, że powyższe oprogramowanie jest zainstalowane i działa poprawnie. Instalacja oprogramowania sprowadza się do pobrania i rozpakowania paczek w wybranym katalogu.
Kompletny projekt do zaimportowania do NetBeans IDE 6.0 dostępny jest jako javaee-biblioteka.zip.
Projekt Biblioteka - założenia
- Założenie 1: Książka ma wyłącznie jednego autora (nierealistyczne, ale na potrzeby artykułu jak najbardziej)
Wniosek: Wyróżniamy encję Autor i Ksiazka w relacji jeden-do-wielu (OneToMany).
Wyróżniamy dwie encje Ksiazka oraz Autor (pakiet pl.jaceklaskowski.biblioteka.encje)
- Założenie 2: Książka może zmienić autora
- Założenie 3: Możliwość pobrania wszystkich książek dla danego autora
- Założenie 4: Możliwość pobrania autora dla danej książki
Wniosek: Relacja jeden-do-wielu będzie dwukierunkowa.
Podprojekt encji Autor i Ksiazka - Biblioteka-encje
Rozpoczynamy od utworzenia modelu naszej aplikacji, który oparty będzie o 2 encje - Autor i Ksiazka. Dedykujemy im osobny projekt o nazwie Biblioteka-encje.
Encja Autor
Najpierw tworzymy encję Autor. Ciekawostką jest wykorzystanie zapytań nazwanych poprzez adnotację @NamedQueries oraz zapytania JOIN FETCH do gorliwego pobrania informacji o książkach danego autora mimo domyślnego opóźnionego ich ładowania (element fetch = LAZY na metodzie odczytującej getKsiazki())
package pl.jaceklaskowski.biblioteka.encje;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
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;
import static javax.persistence.CascadeType.ALL;
import static javax.persistence.FetchType.LAZY;
@Entity
@NamedQueries(
{
@NamedQuery(
name = "Autor.wszyscyAutorzyZKsiazkami",
query = "SELECT a FROM Autor a JOIN FETCH a.ksiazki"
),
@NamedQuery(
name = "Autor.wszyscyAutorzy",
query = "SELECT a FROM Autor a"
)
})
public class Autor implements Serializable {
private static final long serialVersionUID = 1L;
private long id;
private String imie;
private String nazwisko;
private Set<Ksiazka> ksiazki = new HashSet<Ksiazka>();
public Autor() {
}
public Autor(String imie, String nazwisko) {
this.imie = imie;
this.nazwisko = nazwisko;
}
public void setId(long id) {
this.id = id;
}
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public long getId() {
return id;
}
public String getImie() {
return imie;
}
public void setImie(String imie) {
this.imie = imie;
}
@OneToMany(cascade = ALL, mappedBy = "autor", fetch = LAZY)
public Set<Ksiazka> getKsiazki() {
return ksiazki;
}
public void setKsiazki(Set<Ksiazka> ksiazki) {
this.ksiazki = ksiazki;
}
public String getNazwisko() {
return nazwisko;
}
public void setNazwisko(String nazwisko) {
this.nazwisko = nazwisko;
}
@Override
public int hashCode() {
int hash = 0;
hash += (int) id;
return hash;
}
@Override
public boolean equals(Object object) {
if (!(object instanceof Autor)) {
return false;
}
Autor other = (Autor) object;
if (this.id != other.id) {
return false;
}
return true;
}
@Override
public String toString() {
return this.imie + " " + this.nazwisko;
}
}
Encja Ksiazka
Kolejnym krokiem jest utworzenie encji Ksiazka:
package pl.jaceklaskowski.biblioteka.encje;
import java.io.Serializable;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToOne;
import javax.persistence.NamedQuery;
@Entity
@NamedQuery(name = "Ksiazka.wszystkieKsiazki", query = "SELECT k FROM Ksiazka k")
public class Ksiazka implements Serializable {
private static final long serialVersionUID = 1L;
private long id;
private String tytul;
private Autor autor;
public Ksiazka() {
}
public Ksiazka(String tytul) {
this.tytul = tytul;
}
public void setId(long id) {
this.id = id;
}
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public long getId() {
return id;
}
@ManyToOne
public Autor getAutor() {
return autor;
}
public void setAutor(Autor autor) {
this.autor = autor;
}
public String getTytul() {
return tytul;
}
public void setTytul(String tytul) {
this.tytul = tytul;
}
@Override
public int hashCode() {
int hash = 0;
hash += (int) id;
return hash;
}
@Override
public boolean equals(Object object) {
if (!(object instanceof Ksiazka)) {
return false;
}
Ksiazka other = (Ksiazka) object;
if (this.id != other.id) {
return false;
}
return true;
}
@Override
public String toString() {
return this.tytul;
}
}
Konfiguracja jednostki trwałej - persistence.xml
Definiujemy jednostkę trwałą o nazwie biblioteka typu JTA z wykorzystaniem źródła danych zarządzanego przez serwer aplikacyjny - jdbc/__default.
Pliku persistence.xml prezentuje się następująco:
<?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="biblioteka" transaction-type="JTA">
<jta-data-source>jdbc/__default</jta-data-source>
<properties>
<property name="toplink.ddl-generation" value="drop-and-create-tables" />
</properties>
</persistence-unit>
</persistence>
Plik zapisujemy w katalogu META-INF.
Podprojekt ziarna EJB PracownikBibliotekiBean - Biblioteka-ejb
Po zdefiniowaniu modelu przystępujemy do utworzenia warstwy dostępowej, którą w naszym przypadku będzie pełniło ziarno EJB PracownikBibliotekiBean.
Interfejs biznesowy - PracownikBibliotekiLocal
Interfejs biznesowy deklaruje 2 metody zmienAutora oraz zarejestrujKsiazke. Jest to interfejs lokalny.
package pl.jaceklaskowski.biblioteka.ejb;
import javax.ejb.Local;
import pl.jaceklaskowski.biblioteka.encje.Autor;
import pl.jaceklaskowski.biblioteka.encje.Ksiazka;
@Local
public interface PracownikBibliotekiLocal {
void zmienAutora(Ksiazka ksiazka, Autor autor);
void zarejestrujKsiazke(String tytulKsiazki, String imieAutora, String nazwiskoAutora);
}
Klasa implementacji ziarna - PracownikBibliotekiBean
Klasa implementacji ziarna PracownikBibliotekiBean dostarcza implementacji interfejsu biznesowego. Deklarujemy ziarno sesyjne bezstanowe, które korzysta z zarządcy trwałego poprzez mechanizm wstrzeliwania zależności.
package pl.jaceklaskowski.biblioteka.ejb;
import java.util.logging.Logger;
import javax.ejb.Stateless;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import pl.jaceklaskowski.biblioteka.encje.Autor;
import pl.jaceklaskowski.biblioteka.encje.Ksiazka;
@Stateless
public class PracownikBibliotekiBean implements PracownikBibliotekiLocal {
@PersistenceContext(unitName = "biblioteka")
EntityManager em;
Logger logger = Logger.getLogger(PracownikBibliotekiBean.class.getName());
public void zmienAutora(Ksiazka ksiazka, Autor autor) {
logger.info("Krok #0: Związuję autora i książkę z kontekstem trwałym");
autor = em.merge(autor);
ksiazka = em.merge(ksiazka);
logger.info("Krok #1: Przypisuję książkę " + ksiazka + " nowemu autorowi " + autor);
ksiazka.setAutor(autor);
}
public void zarejestrujKsiazke(String tytulKsiazki, String imieAutora, String nazwiskoAutora) {
Ksiazka ksiazka = new Ksiazka(tytulKsiazki);
Autor autor = new Autor(imieAutora, nazwiskoAutora);
ksiazka.setAutor(autor);
autor.getKsiazki().add(ksiazka);
em.persist(autor);
}
}
Podprojekt aplikacji internetowej - Biblioteka-war
Jako warstwę kliencką wykorzystamy JavaServer Faces 1.2 (JSF). Aplikacja rozpoczyna działanie prezentacją strony zawierającej listę książek oraz formatkę do tworzenia nowej książki. Wybór książki z listy powoduje wyświetlenie danych książki z możliwością zmiany autora. Zatwierdzenie zmiany powoduje przejście do strony domowej.
Konfiguracja aplikacji internetowej - /WEB-INF/web.xml
Każda aplikacja internetowa musi posiadać plik konfiguracyjny web.xml w katalogu WEB-INF. Jako, że korzystamy z JSF musi zadeklarować, które z zasobów aplikacji są przetwarzane przez JSF. Definiujemy servlet JSF - Faces Servlet - oraz związane z nim adresy *.faces.
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<context-param>
<param-name>com.sun.faces.verifyObjects</param-name>
<param-value>false</param-value>
</context-param>
<context-param>
<param-name>com.sun.faces.validateXml</param-name>
<param-value>true</param-value>
</context-param>
<context-param>
<param-name>javax.faces.STATE_SAVING_METHOD</param-name>
<param-value>client</param-value>
</context-param>
<servlet>
<servlet-name>Faces Servlet</servlet-name>
<servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>Faces Servlet</servlet-name>
<url-pattern>*.faces</url-pattern>
</servlet-mapping>
</web-app>
Konfiguracja aplikacji JSF - faces-config.xml
Każda aplikacja JSF wymaga pliku konfiguracyjnego faces-config.xml. W nim definiuje się przede wszystkim ziarna zarządzane oraz nawigację stron, ale można znaleźć również wiele innych elementów, które rozszerzają podstawową funkcjonalność dostarczaną przez JSF.
<?xml version='1.0' encoding='UTF-8'?>
<faces-config version="1.2" xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-facesconfig_1_2.xsd">
<managed-bean>
<managed-bean-name>biblioteka</managed-bean-name>
<managed-bean-class>pl.jaceklaskowski.biblioteka.faces.Biblioteka</managed-bean-class>
<managed-bean-scope>session</managed-bean-scope>
</managed-bean>
<navigation-rule>
<from-view-id>/lista_ksiazek.jsp</from-view-id>
<navigation-case>
<from-outcome>sukces</from-outcome>
<to-view-id>/ksiazka.jsp</to-view-id>
</navigation-case>
</navigation-rule>
<navigation-rule>
<from-view-id>/ksiazka.jsp</from-view-id>
<navigation-case>
<from-outcome>sukces</from-outcome>
<to-view-id>/lista_ksiazek.jsp</to-view-id>
</navigation-case>
</navigation-rule>
</faces-config>
W naszym przypadku zdefiniowaliśmy pojedyńcze ziarno zarządzane JSF - biblioteka o widoczności session, które reprezentowane jest przez klasę pl.jaceklaskowski.biblioteka.faces.Biblioteka. Poza tym mamy definicję dwóch reguł nawigacyjnych - przejście ze strony lista_ksiazek do strony ksiazka.jsp oraz odwrotnie. Przejście między stronami jest możliwe wyłącznie po zwróceniu identyfikatora sukces z akcji w aplikacji.
Plik musi znajdować się w katalogu WEB-INF.
Ziarno zarządzane JSF - Biblioteka
Przyjrzyjmy się ziarnu zarządzanemu biblioteka. Ziarno reprezentowane jest przez klasę pl.jaceklaskowski.biblioteka.faces.Biblioteka. Ziarna zarządzane JSF mogą korzystać z usługi wstrzeliwania zależności dostarczanej przez serwer aplikacyjny Java EE 5, z czego korzystamy do dostępu do zarządcy trwałego oraz ziarna EJB.
Ziarno biblioteka korzysta z ciekawej funkcjonalności JSF - zarządzania modelem poprzez interfejs javax.faces.model.DataModel (w naszym przypadku korzystamy z javax.faces.model.ListDataModel. Za jego pomocą JSF automatycznie dba o przekazanie informacji, jaki wiersz w tabeli został wybrany przez użytkownika.
package pl.jaceklaskowski.biblioteka.faces;
import java.util.ArrayList;
import java.util.List;
import javax.ejb.EJB;
import javax.faces.convert.Converter;
import javax.faces.model.DataModel;
import javax.faces.model.ListDataModel;
import javax.faces.model.SelectItem;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.Query;
import pl.jaceklaskowski.biblioteka.ejb.PracownikBibliotekiLocal;
import pl.jaceklaskowski.biblioteka.encje.Autor;
import pl.jaceklaskowski.biblioteka.encje.Ksiazka;
public class Biblioteka {
@EJB
PracownikBibliotekiLocal pracownikBiblioteki;
@PersistenceContext(unitName = "biblioteka")
EntityManager em;
private String tytulKsiazki;
private String imieAutora;
private String nazwiskoAutora;
private DataModel ksiazkiDataModel;
private List<SelectItem> autorzyList = new ArrayList<SelectItem>();
private Ksiazka wybranaKsiazka;
private Autor wybranyAutor;
public Converter getAutorConverter() {
return new AutorConverter(this.em);
}
public DataModel getWszystkieKsiazki() {
if (ksiazkiDataModel == null) {
ksiazkiDataModel = new ListDataModel();
Query query = em.createNamedQuery("Ksiazka.wszystkieKsiazki");
ksiazkiDataModel.setWrappedData(query.getResultList());
}
return ksiazkiDataModel;
}
public Ksiazka getWybranaKsiazka() {
return wybranaKsiazka;
}
public List<SelectItem> getWszyscyAutorzy() {
autorzyList.clear();
Query query = em.createNamedQuery("Autor.wszyscyAutorzy");
for (Autor autor : (List<Autor>) query.getResultList()) {
autorzyList.add(new SelectItem(autor, autor.toString()));
}
return autorzyList;
}
public void setWybranyAutor(Autor wybranyAutor) {
this.wybranyAutor = wybranyAutor;
}
public Autor getWybranyAutor() {
return this.wybranyAutor;
}
public String zarejestrujKsiazke() {
pracownikBiblioteki.zarejestrujKsiazke(tytulKsiazki, imieAutora, nazwiskoAutora);
ksiazkiDataModel = null; // wyczysc model, aby wymusić jego odświeżenie
return null;
}
public String zmienAutora() {
System.out.println("Wybrany autor: " + wybranyAutor);
System.out.println("Wybrana książka: " + wybranaKsiazka);
pracownikBiblioteki.zmienAutora(wybranaKsiazka, wybranyAutor);
ksiazkiDataModel = null; // wyczysc model, aby wymusić jego odświeżenie
return "sukces";
}
public String wyswietlKsiazke() {
wybranaKsiazka = (Ksiazka) ksiazkiDataModel.getRowData();
return "sukces";
}
public String getImieAutora() {
return imieAutora;
}
public void setImieAutora(String imieAutora) {
this.imieAutora = imieAutora;
}
public String getNazwiskoAutora() {
return nazwiskoAutora;
}
public void setNazwiskoAutora(String nazwiskoAutora) {
this.nazwiskoAutora = nazwiskoAutora;
}
public String getTytulKsiazki() {
return tytulKsiazki;
}
public void setTytulKsiazki(String tytulKsiazki) {
this.tytulKsiazki = tytulKsiazki;
}
}
Strona domowa aplikacji internetowej - lista_ksiazek.jsp
Przejdźmy do stworzenia strony domowej aplikacji - lista_ksiazek.jsp. Do pozyskania danych wykorzystane jest ziarno zarządzane JSF biblioteka. Wciśnięcie przycisku Zmień autora spowoduje wykonanie metody wyswietlKsiazke, która ostatecznie przeniesie nas do strony ksiazka.jsp zgodnie z definicją reguły nawigacyjnej w pliku faces-config.xml, gdy wynikiem działania akcji będzie identyfikator sukces.
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@taglib prefix="f" uri="http://java.sun.com/jsf/core"%>
<%@taglib prefix="h" uri="http://java.sun.com/jsf/html"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Biblioteka - Lista książek</title>
</head>
<body>
<f:view>
<h:form>
Tytuł: <h:inputText value="#{biblioteka.tytulKsiazki}" />
Imie autora: <h:inputText value="#{biblioteka.imieAutora}" />
Nazwisko autora: <h:inputText value="#{biblioteka.nazwiskoAutora}" />
<h:commandButton value="Zarejestruj książkę" action="#{biblioteka.zarejestrujKsiazke}" />
<br />
<h1>Lista książek</h1>
<h:dataTable value="#{biblioteka.wszystkieKsiazki}" var="ksiazka">
<h:column>
<h:outputText value="#{ksiazka.tytul}" />
</h:column>
<h:column>
<h:outputText value="#{ksiazka.autor.imie}" />
</h:column>
<h:column>
<h:outputText value="#{ksiazka.autor.nazwisko}" />
</h:column>
<h:column>
<h:commandButton value="Zmień autora" action="#{biblioteka.wyswietlKsiazke}" />
</h:column>
</h:dataTable>
</h:form>
</f:view>
</body>
</html>
Strona szczegółów książki - ksiazka.jsp
Strona ksiazka.jsp prezentuje szczegóły wybranej książki z możliwością zmiany autora.
Ciekawostką strony jest wykorzystanie konwertera AutorConverter (o którym za moment). Przekazanie konwertera musi odbyć się poprzez ziarno zarządzane JSF, gdyż konwertery nie podlegają obsłudze wstrzeliwania zależności i niemożliwe byłoby użycie zarządcy trwałego za pomocą adnotacji @PersistenceContext.
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@taglib prefix="f" uri="http://java.sun.com/jsf/core"%>
<%@taglib prefix="h" uri="http://java.sun.com/jsf/html"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Biblioteka - Szczegóły książki</title>
</head>
<body>
<f:view>
<h:form>
<h1>Szczegóły książki</h1>
<h:outputText value="#{biblioteka.wybranaKsiazka.tytul}" />
<br>
<h:outputText value="#{biblioteka.wybranaKsiazka.autor.imie}" />
<br>
<h:outputText value="#{biblioteka.wybranaKsiazka.autor.nazwisko}" />
<br>
<h:selectOneMenu value="#{biblioteka.wybranyAutor}" converter="#{biblioteka.autorConverter}">
<f:selectItems value="#{biblioteka.wszyscyAutorzy}" />
</h:selectOneMenu>
<h:commandButton value="Zmień autora" action="#{biblioteka.zmienAutora}" />
</h:form>
<br>
<h:messages />
</f:view>
</body>
</html>
Uruchomienie
Uruchomienie aplikacji za pomocą NetBeans IDE 6.0 Nightly sprowadza się do zdefiniowania projektów i związania ich ze środowiskiem serwera aplikacji Java EE 5, np. Glassfish v2, a następnie wybrania menu Run.
Uruchomienie aplikacji to otworzenie strony http://localhost:8080/Biblioteka-war/lista_ksiazek.faces, która prezentuje formatkę rejestracji nowych książek wraz z autorami oraz listę już zarejestrowanych pozycji.
Po dodaniu kilku książek aplikacja wygląda następująco:
Wciśnięcie przycisku Zmień autora otworzy kolejną stronę, na której znajdują się szczegóły wybranej książki.
I wybranie innego autora niż aktualnie przypisanego do książki powoduje jego zmianę.
Wykonanie czynności raportowane jest w dzienniku zdarzeń serwera następująco:
... LDR5010: All ejb(s) of [Biblioteka] loaded successfully! naming.bind Initializing Sun's JavaServer Faces implementation (1.2_04-b20-p03) for context '/Biblioteka-war' TopLink, version: Oracle TopLink Essentials - 2.0 (Build b58g-fcs (09/07/2007)) Server: unknown file:/C:/Documents%20and%20Settings/jlaskowski/My%20Documents/NetBeansProjects/Biblioteka/dist/gfdeploy/Biblioteka-encje.jar-biblioteka login successful Wybrany autor: Agata Laskowska Wybrana książka: EJB 3.0 w przykładach Krok #0: Związuję autora i książkę z kontekstem trwałym Krok #1: Przypisuję książkę EJB 3.0 w przykładach nowemu autorowi Agata Laskowska



