wtorek, 26 października 2010

Spring Framework 3.0 Tutorial – cz 5 – envers, hibernate search

W tej części tutorialu zajmiemy się najważniejszą częścią naszej aplikacji, czyli tworzeniem katalogu księgarni. Katalog księgarni składać się będzie z kategorii zagnieżdżonych w sobie oraz książek przypisanych do nich. Żeby było ciekawiej nasza aplikacja będzie indeksować dodane książki z wykorzystaniem mechanizmu Hibernate Search, a biblioteka Envers pozwoli nam zachowywać historię zmian ceny książek co umożliwi w późniejszym czasie wyświetlenie historii w postaci graficznej (funkcja mało ciekawa w księgarni, ale czego się nie robi by zaprezentować ciekawą bibliotekę). Dodatkowo w stworzymy kilka własnych tagów (jsp) w celu odseparowania logiki aplikacji od wyglądu (co jest dobrą praktyką).

Dodawanie kategorii oraz książek


Pierwszym krokiem jaki wykonamy jest stworzenie drzewa kategorii. Jak zwykle na początek należy przygotować encję reprezentującą pojedynczą kategorię:

@Entity
@NamedQueries({@NamedQuery(name="Category.selectTop",query="SELECT c FROM Category c WHERE c.parentId=0")})
public class Category {
@Id
@GeneratedValue(generator="category_id", strategy=GenerationType.SEQUENCE)
@SequenceGenerator(name="category_id",sequenceName="category_id_seq")
private Integer id;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
@NotEmpty
@Size(min=1,max=32)
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Size(max=32)
private String url;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
@Basic
private Integer parentId;
public Integer getParentId() {
return parentId;
}
public void setParentId(Integer parentId) {
this.parentId = parentId;
}
@ManyToOne(fetch=FetchType.LAZY,optional=true,targetEntity=Category.class)
@JoinColumn(name="parentId",nullable=true,insertable=false,updatable=false)
private Category parent;
public Category getParent() {
return parent;
}
public void setParent(Category parent) {
this.parent = parent;
}
@OneToMany(fetch=FetchType.EAGER,mappedBy="parentId",orphanRemoval=true,targetEntity=Category.class)
private Collection<Category> childrens;
public Collection<Category> getChildrens() {
return childrens;
}
public void setChildrens(Collection<Category> childrens) {
this.childrens = childrens;
}
@OneToMany(fetch=FetchType.LAZY,targetEntity=Book.class,mappedBy="categoryId")
private Collection<Book> books;
public Collection<Book> getBooks() {
return books;
}
public void setBooks(Collection<Book> books) {
this.books = books;
}
}

Kod wygląda bardzo podobnie do encji wcześniej stworzonych poza jedną rzeczą, a mianowicie "zapytaniem nazwanym" (z ang. named query). Dzięki tej adnotacji będziemy mogli wykonać stworzone przez nas zapytanie w dowolnym miejscu podając jedynie jego nazwę (do odpowiedniego obiektu). Rozwiązanie to daje nam dwie zasadnicze korzyści, pierwszą jest uproszczenie klas dostępu do danych (DAO), teraz zamiast trzymać w nich rozbudowane zapytania wystarczy że odwołamy się do już wcześniej zdefiniowanego, drugą jest globalizacja zapytań co oznacza nic innego jak możliwość wywołania tego zapytania z różnych obiektów które mają dostęp do bazy.

W powyższym kodzie zauważyć można, że obiekty powiązane (childrens) pobierane są natychmiastowo (FetchType.EAGER), związane jest to z problemem zamykania sesji przy wykorzystywaniu transakcji i JPA (po wykonaniu zapytania zamykana jest sesja, przez co metoda LAZY nie działa).

Pora zabrać się za formularz dodawania kategorii. Jak zwykle składa się on z dwóch akcji, wyświetlania formularza oraz zapisywania kategorii, wszystko byłoby takie samo jak we wszystkich poprzednich formularzach poza jednym, zastosowaniem własnych tagów w celu wyświetlenia listy rozwijanej.
Dzięki zastosowaniu własnych tagów ukrywamy implementację danego elementu (szczególnie gdy jest dość skomplikowana) oraz rozdzielamy element i widok, dzięki czemu nasza lista rozwijana może być wyświetlana w różnych formularzach. Aby przygotować nasz tag tworzymy folder "tags" w folderze "WEB-INF" a w nim umieszczamy plik "categoriesselect.tag" o zawartości:

<%-- /WEB-INF/tags/categoriesselect.tag --%>
<%@ attribute name="categories" type="java.util.Collection" required="true" %>
<%@ attribute name="level" type="java.lang.Integer" required="true" %>
<%@ attribute name="category" type="com.darekzon.bookstore.domain.Category" required="true" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="tag" uri="http://www.springframework.org/tags"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jstl/core_rt"%>
<%@ taglib prefix="t" tagdir="/WEB-INF/tags" %></p>
<p><c:choose>
<c:when test="${level eq 0}">
<form:select path="parentId">
<form:option value="0"><tag:message code="category.main_category" /></form:option>
<t:categoriesselect categories="${categories}" level="1" category="${category}" />
</form:select>
</c:when>
<c:otherwise>
<c:if test="${not empty categories}">
<c:forEach items="${categories}" var="cat">
<c:choose>
<c:when test="${category.id eq cat.id}">
<option value="${cat.id}" disabled="true"><t:repeatchar character="-" repeats="${level-1}" />${cat.name}</option>
</c:when>
<c:otherwise>
<option value="${cat.id}"><t:repeatchar character="-" repeats="${level-1}" />${cat.name}</option>
</c:otherwise>
</c:choose>
<t:categoriesselect categories="${cat.childrens}" level="${level+1}" category="${category}" />
</c:forEach>
</c:if>
</c:otherwise>
</c:choose>

Zadanie powyższego tagu jest proste, ma on przeszukiwać podane mu kategorie i wyświetlać je w formie listy rozwijanej, do każdej kategorii ma dodawać znaki "-", ilość tych znaków zależeć ma od poziomu zagłębienia naszej kategorii. Powyższy tag przyjmuje 3 wymagane parametry:
  1. categories - lista kategorii do wyświetlenia
  2. level - aktualny poziom który jest generowany
  3. category - kategoria która ma być wykluczona (tak, żebyśmy podczas edycji kategorii nie mogli jej przypisać do samej siebie)

Do wyświetlania poziomu zagłębiania wykorzystujemy kolejny tag własny nazwany "repeatchar" który wygląda następująco:

<%-- /WEB-INF/tags/repeatchar.tag --%>
<%@ attribute name="repeats" type="java.lang.Integer" required="true" %>
<%@ attribute name="character" type="java.lang.String" required="true" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jstl/core_rt"%>
<c:if test="${repeats gt 0}">
<c:forEach var="i" begin="0" end="${repeats}">
${character}
</c:forEach>
</c:if>

Tak przyjmuje 2 parametry:
  1. repeats który oznacza ilość powtórzeń oraz
  2. character który zawiera znak/znaki jakie mają być powtórzone.

Stworzony został również tag wyświetlający tabelę kategorii (z zaznaczonymi poziomami) jednak, z racji, że jest on bardzo podobny do tagu wyświetlania listy postanowiłem go ominąć (można go zobaczyć w źródłach - patrz koniec artykułu)

Po uzupełnieniu formularza dane przekazywane są do obiektu który zapisuje dane do bazy, ponieważ kod jest podobny do zaprezentowanego w poprzednich odcinkach tutorialu uznałem, że nie ma sensu zaśmiecać wpisu.

Dodawanie książek - Indeksowanie i wyszukiwanie


Najważniejszą funkcją naszego serwisu będzie prezentacja książek, gdy katalog będzie rozbudowany bardzo przydatną funkcjonalnością jest wyszukiwarka pozwalająca na szybkie znalezienie interesującej nas pozycji. Typowe wyszukiwarki opierają się na wpisach bezpośrednio w bazie danych, po wpisaniu przez użytkownika frazy system pyta bazy danych, czy taki wpis istnieje. Jest to dobre rozwiązanie dla mniejszych stron gdyż przy większym obciążeniu może powodować przeciążenie bazy. Ciekawszym rozwiązaniem dla wyszukiwarki jest stworzenie indeksu elementów które mają być wyszukiwane. Takim rozwiązaniem jest HibernateSearch (oparty na wyszukiwarce Lucene), dzięki takiemu rozwiązaniu nasza aplikacja zamiast pytać bazy danych będzie przeszukiwać indeks elementów (który znajduje się na tym samym serwerze co aplikacja), mechanizm w łatwy sposób potrafi wykonać wyszukiwanie rozmyte, zwrócić trafność elementów i wiele więcej. Do tego jest naprawdę łatwy w konfiguracji o czym przekonamy się już za chwilę.
Przed rozpoczęciem pracy warto dodać odpowiednie biblioteki do naszego projektu (bookstore-lib) zrobimy to poprzez dodanie zależności w pliku pom.xml.

<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-search</artifactId>
<version>3.2.1.Final</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-envers</artifactId>
<version>3.5.5-Final</version>
<type>jar</type>
<scope>compile</scope>
</dependency>

Aby zależności działały trzeba dodać repozytorium do naszego mavena:

<repository>
<releases>
<updatePolicy>always</updatePolicy>
</releases>
<id>jboss</id>
<name>JBoss Repo</name>
<url>https://repository.jboss.org/nexus/content/groups/public/</url>
</repository>

Gdy odpowiednie pakiety zostaną pobrane pora zabrać się za konfigurację. Indexer Hibernate potrafi działać w trzech trybach, JMS gdzie wykorzystywana jest kolejka do przekazywania danych które będą indeksowane, JGroups działające na tej samej zasadzie lecz wykorzystujące narzędzie JGroups oraz wybrany przez nas tryb Lucene. W trybie Lucene, Hibernate czeka na operacje (stworzenie, aktualizacja, kasowanie) wykonywane z wykorzystaniem specjalnie spreparowanych encji i gdy te wystąpią automatycznie wykonuje aktualizację indeksu wyszukiwarki.



Aby ustawić indeksowanie wystarczy do pliku presistence.xml dopisać konfigurację Hibernate Search zawierającą miejsce docelowe w którym składowany będzie indeks:

<property name="hibernate.search.default.directory_provider" value="org.hibernate.search.store.FSDirectoryProvider" />
<property name="hibernate.search.default.indexBase" value="/var/tmp/lucene/indexes" />

Aby Hibernate wiedział które elementy indeksować należy odpowiednio opisać wybrane encje, w naszym projekcie indeksowana będzie jedynie encja Book zawierająca informacje na temat książek oraz informację o kategorii w której się znajduje. W tym celu dopisujemy do konkretnych pól encji Book dodatkowe adnotacje, po wszystkim encja wygląda następująco:

@Entity
@Indexed
public class Book {
@Id
@DocumentId
@GeneratedValue(generator = "book_id_seq", strategy = GenerationType.SEQUENCE)
@SequenceGenerator(name = "book_id_seq", initialValue = 1, sequenceName = "book_id_seq")
private Long id;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
BigDecimal price = new BigDecimal(0);
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
@Basic
@NotEmpty
@Length(min = 2, max = 128)
@Field(index = Index.TOKENIZED, store = Store.YES)
private String title;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
@Field(index = Index.TOKENIZED, store = Store.YES)
@Length(max = 128)
private String originalTitle = null;
public String getOriginalTitle() {
return originalTitle;
}
public void setOriginalTitle(String originalTitle) {
this.originalTitle = originalTitle;
}
@NotEmpty
@Column(length = 2, nullable = false)
@Field(index = Index.UN_TOKENIZED, store = Store.YES)
private String language = null;
public String getLanguage() {
return language;
}
public void setLanguage(String language) {
this.language = language;
}
@NotEmpty
@Column(length = 250, nullable = true)
@Field(index = Index.TOKENIZED, store = Store.YES)
private String shortDescription = null;
public String getShortDescription() {
return shortDescription;
}
public void setShortDescription(String shortDescription) {
this.shortDescription = shortDescription;
}
@Column(length = 65535, nullable = true)
@Field(index = Index.TOKENIZED, store = Store.NO)
private String description;
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
@Basic
@NotEmpty
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private String publishDate = null;
public String getPublishDate() {
return publishDate;
}
public void setPublishDate(String publishDate) {
this.publishDate = publishDate;
}
@Basic
@NotEmpty
@Field(index = Index.UN_TOKENIZED, store = Store.NO)
private String isbn10 = null;
public String getIsbn10() {
return isbn10;
}
public void setIsbn10(String isbn10) {
this.isbn10 = isbn10;
}
@Basic
@NotBlank
@Column(insertable = false, updatable = false)
private Integer categoryId;
public Integer getCategoryId() {
return categoryId;
}
public void setCategoryId(Integer categoryId) {
this.categoryId = categoryId;
}
@IndexedEmbedded(depth = 1, targetElement = Category.class)
@ManyToOne(targetEntity = Category.class, fetch = FetchType.EAGER)
@JoinColumn(name = "categoryId")
private Category category;
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
}

W powyższym przykładzie wykorzystałem adnotacje:
@Indexed - oznacza, że dana encja ma być indeksowana przez Hibernate
@DocumentId- identyfikator dokumentu w systemie Lucene
@Field - pole do indeksowania, ta adnotacja przyjmuje szereg właściwości:
  • index: jak ma przebiegać indeksowanie (NO - brak, NO_NORMS - indeksowanie bez analizy, TOKENIZED - tokenizowanie pola, UN_TOKENIZED - indeksowanie bez analizy
  • store: czy przechowywać wartość w indeksie (NO - nie, YES - tak)

@IndexedEmbedded - służy do indeksowania relacji @*ToMany, @*ToOne oraz @Embedded

Jak można zauważyć powyżej aplikacja indeksować będzie tytuł książki (przeanalizuje i zachowa wartość w indeksie), język w jakim książka jest wydana (indeksowanie bez analizy, wartość zachowana w indeksie), krótki opis (analiza plus zachowanie w indeksie by wyświetlić go w wynikach wyszukiwania), długi opis (analiza ale bez zachowywania w indeksie), isbn10 (nie analizowane, zachowane w indeksie gdyby ktoś chciał szukać po tym numerze), kategorię.

Poza powyższym nasza aplikacja nie różni się niczym od poprzedniego kodu, tj. pobieramy dane z formularza i je sprawdzamy, a gdy walidacja przebiegnie poprawnie zapisujemy je do bazy danych (poprzez entityManager).

Aby sprawdzić czy mechanizm działa poprawnie, stworzyłem mini wyszukiwarkę. Składa się ona z formularza wyszukiwarki (pole tekstowe na frazę), akcji obsługującej widok formularza, akcji obsługującej wyszukiwanie oraz samego mechanizmu wyszukiwania.
1. Akcja kontrolera SearchController:

@RequestMapping(value = "/search",method=RequestMethod.GET)
public ModelAndView index() {
ModelAndView mav = new ModelAndView("/search/index");
return mav;
}

przekazuje do wyrenderowania widok formularza wyszukiwania:

<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="tag" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jstl/core_rt" %>
<%
pageContext.setAttribute("moduleName","catalog", PageContext.REQUEST_SCOPE);
pageContext.setAttribute("pageName","search", PageContext.REQUEST_SCOPE);
%>
<div class="form">
<form method="post" action="/search/find.html">
<ol>
<li>
<label for="phrase"><tag:message code="search.phrase" /></label>
<input id="phrase" name="phrase" />
</li>
<li class="submit">
<input type="submit" class="right" />
</li>
</ol>
</form>
</div>

po wysłaniu formularza, dane trafiają do akcji wykonującej wyszukiwanie

@RequestMapping(value = "/search/find")
public ModelAndView search(@RequestParam(value="phrase",required=true) String phrase) {
ModelAndView mav = new ModelAndView("/search/search");
mav.addObject("searchResults",cService.search(phrase));
mav.addObject("phrase",phrase);
return mav;
}

Tam wywoływana jest metoda "Search" na rzecz obiektu CatalogService (cService) której argumentem jest wpisany tekst.

@Transactional(readOnly=true)
public Collection<Book> search(String phrase){
FullTextEntityManager fullTextEntityManager =
org.hibernate.search.jpa.Search.getFullTextEntityManager(entityManager);
// tworzenie natywnego zapytania Lucene
String[] fields = new String[]{"title", "subtitle", "authors.name", "publicationDate"};
MultiFieldQueryParser parser = new MultiFieldQueryParser(Version.LUCENE_29,fields, new SimpleAnalyzer());
org.apache.lucene.search.Query query = null;
try{
query = parser.parse( phrase );
} catch (ParseException e) {
e.printStackTrace();
}
// opakowanie zapytania Lucene w zapytanie Javax Persistence
javax.persistence.Query persistenceQuery = fullTextEntityManager.createFullTextQuery(query, Book.class);
// Wykonujemy zapytanie
@SuppressWarnings("unchecked")
List<Book> resultList = (List<Book>) persistenceQuery.getResultList();
return resultList;
}

Metoda search składa się z :

1. Pobranie FullTextEntityManagera który jest rozszerzeniem EntityManagera
2. Ustalamy jakie pola mają być przeszukiwane
3. Tworzymy Parser który stworzy zapytanie
4. Wykorzystując parser tworzymy zapytanie
5. Przekazujemy nasze zapytanie do FullTextEntityManagera który przeszukuje indeks
6. Zwracamy rezultaty wyszukiwania

Versionowanie zmian cen (Envers)


Drugą ciekawą biblioteką jaką użyję w projekcie jest Envers. Bilioteka ta pozwala na banalnie proste zarządzanie wersjami (analogia do systemów zarzadzania wersjami - CVS, SVN, GIT etc.) danego rekordu. Wersja tworzona jest automatycznie podczas tworzenia, aktualizacji oraz kasowania (wersja pusta) danego rekordu. Dzięki Envers możemy stworzyć mechanizm przywracania rekordów które przypadkowo skasowaliśmy gdy użytkownik umyślnie bądź nie je edytował albo zostały skasowane z powodów przez nas niezależnych. W naszej aplikacji wersjonowana będzie jedynie cena ale spokojnie można się pokusić o tworzenie wersji całych encji.
Przygodę z Envers (biblioteki już mamy) zaczynamy od konfiguracji w pliku persistence.xml:

<!-- konfiguracja envers -->
<property name="hibernate.ejb.event.post-insert" value="org.hibernate.ejb.event.EJB3PostInsertEventListener,org.hibernate.envers.event.AuditEventListener" />
<property name="hibernate.ejb.event.post-update" value="org.hibernate.ejb.event.EJB3PostUpdateEventListener,org.hibernate.envers.event.AuditEventListener" />
<property name="hibernate.ejb.event.post-delete" value="org.hibernate.ejb.event.EJB3PostDeleteEventListener,org.hibernate.envers.event.AuditEventListener" />
<property name="hibernate.ejb.event.pre-collection-update" value="org.hibernate.envers.event.AuditEventListener" />
<property name="hibernate.ejb.event.pre-collection-remove" value="org.hibernate.envers.event.AuditEventListener" />
<property name="hibernate.ejb.event.post-collection-recreate" value="org.hibernate.envers.event.AuditEventListener" />
<!-- koniec konfiguracji envers -->

W powyższej konfiguracji ustaliliśmy, że Envers ma nasłuchiwać akcji tworzenia, aktualizacji oraz kasowania rekordów. Aby dokończyć konfigurację należy przejść do encji Book i przy polu price dodać adnotację @Audited

public class Book {
@Audited
BigDecimal price = new BigDecimal(0);
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
}

Dzięki temu zapisowi Envers wie, że ma zapisywać zmiany jedynie pola "price", ważne jest by encja posiadała pole oznaczone @Id które będzie jej identyfikatorem, gdyż identyfikator ten będzie zapisywany w każdej rewizji naszej ceny tak, by można było określić do jakiego elementu należy dana rewizja. Zummiany te zapisywane będa w tabeli "book_aud" która zostanie automatycznie stworzona. Tabela ta prezentuje się następująco:


I zawiera następujące elementy:
  1. id - identyfikator dokumentu który w całości bądź częściowo podlega wersjonowaniu
  2. rev - numer rewizji (liczba)
  3. revtype - typ rewizji (0 - utworzenie, 1 - aktualizacja, 2- skasowanie)
  4. price - cena która jest poddana wersjonowaniu
  5. revision_timestamp - data dokonania rewizji

Czy warto używać Envers-a? Moim zdaniem tak, choć może nie do tak trywialnych rzeczy jak w tym projekcie. Envers idealnie nada się wszędzie tam gdzie wymagane będzie przechowywanie poprzednich wersji dokumentów lub ich elementów, taki wewnętrzny system backupowy, wszystko zależy od naszej wyobraźni.

W następnej części tutorialu dodamy możliwość ładowania zdjęć okładek, oraz zaczniemy tworzyć aplikację główną która na początku wyświetlać będzie jedynie katalog.

Jak pobrać projekt


Projekt został umieszczony na serwerach Github.com. Adres bezpośredni do projektu: https://github.com/darek/bookstore.
Dodatkowo zostało uruchomione repozytorium GIT dla wszystkich chętnych i dostępny jest pod adresem: git://github.com/darek/bookstore.git




1 - Grafika pochodzi ze strony: http://docs.jboss.org/hibernate/stable/search/reference/en-US/html_single/

6 komentarzy:

  1. Witam.
    Dalsza czesc ciekawego tutoriala :)
    Gratuluje dobrej roboty
    Mam tylko pytanie, czy nie mozna zamiast FetchType.EAGER uzyc OpenEntityManagerInViewFilter ?

    OdpowiedzUsuń
  2. Dzięki goompas, nie znałem tego filtra, wygląda na to, że działa tak jak potrzebuję, ale po zastanowieniu, używanie Lazy Loading czy też w/w filtru może mieć pewne konsekwencje w płaszczyźnie wydajności. Bo teraz aplikacja przez dłuższy czas będzie pobierać dane (nie tylko podczas przetwarzania żądania ale również generowania widoków. A że dane pobierane są w transakcjach wiąże się to z dodatkowym blokowaniem tabel, czyli mniejsza wydajność.
    Zapewne któryś z moich kolejnych postów traktować będzie o wydajności wtedy pewnie rozpiszę się ciut więcej. Niemniej jeszcze raz dziękuję.

    OdpowiedzUsuń
  3. [...] http://darekzon.com/2010/10/spring-framework-3-0-tutorial-%E2%80%93-cz-5-%E2%80%93-envers-hibernate-... [...]

    OdpowiedzUsuń
  4. beda nastepne czesci ? bo na zakonczeniu tej czesci byla obietnica dodania funkcjonalnosci uploadu zdjec i itd :)

    OdpowiedzUsuń
  5. raczej się nie zapowiada, chwilowo nie mam zupełnie czasu, ale może zrobię ostatnią część w której będzie upload i kilka innych funkcjonalności

    OdpowiedzUsuń
  6. Witam.
    Przede wszystkim bardzo dobry blog.
    Mam tylko problem z importem projektu do eclipse i uruchomieniem.
    Mogę prosić o jakiś mały opis?

    OdpowiedzUsuń